PageSourceSearch

https://donotage.org/customer/account/login/

html donotage.org collected 2026-09-24 11:56:15 UTC 314,840 bytes, 5,596 lines download raw bytes

1<!doctype html>
2<html lang="en">
3    <head >
4        
4<script>
5    var LOCALE = 'en\u002DUS';
6    var BASE_URL = 'https\u003A\u002F\u002Fdonotage.org\u002F';
7    var require = {
8        'baseUrl': 'https\u003A\u002F\u002Fdonotage.org\u002Fstatic\u002Fversion1790156720\u002Ffrontend\u002FDna\u002Fb2c\u002Dtheme\u002Fen_US'
9    };</script>
9        <meta charset="utf-8"/>
10<meta name="title" content="Customer Login"/>
11<meta name="robots" content="INDEX,FOLLOW"/>
12<meta name="viewport" content="width=device-width, initial-scale=1"/>
13<meta name="format-detection" content="telephone=no"/>
14<title>Customer Login</title>
15<link  rel="stylesheet" type="text/css"  media="all" href="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/mage/calendar.css" />
16<link  rel="stylesheet" type="text/css"  media="all" href="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/DoNotAge_Testimonials/css/style.css" />
17<link  rel="stylesheet" type="text/css"  media="all" href="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/DoNotAge_Testimonials/css/font-awesome.css" />
18<link  rel="stylesheet" type="text/css"  media="all" href="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/css/styles-m.css" />
19<link  rel="stylesheet" type="text/css"  media="all" href="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/DoNotAge_GlideSlider/css/glide.core.min.css" />
20<link  rel="stylesheet" type="text/css"  media="all" href="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/DoNotAge_GlideSlider/css/glide.theme.min.css" />
21<link  rel="stylesheet" type="text/css"  media="all" href="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/StripeIntegration_Payments/css/wallets.css" />
22<link  rel="stylesheet" type="text/css"  media="screen and (min-width: 768px)" href="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/css/styles-l.css" />
23<link  rel="stylesheet" type="text/css"  media="print" href="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/css/print.css" />
24<link  rel="icon" type="image/x-icon" href="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/Magento_Theme/favicon.ico" />
25<link  rel="shortcut icon" type="image/x-icon" href="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/Magento_Theme/favicon.ico" />
26<script  type="text/javascript"  src="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/requirejs/require.js"></script>
vendor: 1 bytes, line 26
26
27<script  type="text/javascript"  src="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/mage/requirejs/mixins.js"></script>
vendor: 1 bytes, line 27
27
28<script  type="text/javascript"  src="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/requirejs-config.js"></script>
vendor: 1 bytes, line 28
28
29<script  type="text/javascript"  async="async" src="//widget.trustpilot.com/bootstrap/v5/tp.widget.bootstrap.min.js"></script>
29
30<link rel="preload" as="font" crossorigin="anonymous" href="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/fonts/Luma-Icons.woff2" />
31<meta name="msvalidate.01" content="CB89E43EA5B37A22B42BF20AB4E8ED1C" />
32<meta name="google-site-verification" content="WcVDLQjUAbnJU1-UxoPSlcQkaRnElLkE39_1ttOaJp8" />
33<meta name="google-site-verification" content="I8d9HHJHfAY5SrnCu0ymEoYZ2JddBmoSxMputIIWRYE" />
34<meta name='dmca-site-verification' content='cEpuREtEaVBkNEUyNTdRWVY0aTFDdz090' />
35
36<!-- begin Microsoft Clarity code -->
37<script type="text/javascript"> (function(c,l,a,r,i,t,y){ c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)}; t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i; y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y); })(window, document, "clarity", "script", "wfxhmvh0hr"); </script>
37
38<!-- end Microsoft Clarity code -->        <!-- dna:openai-ads-pixel -->
39<script>(function (w, d) {
40    try {
41        if (w.__dnaOaiq) { return; }
42        w.__dnaOaiq = 1;
43
44        var PIXEL_ID = "R5kiMU3LmdqiJQ356hx5FW";
45        var REQUIRE_CONSENT = false;
46        var DEBUG = false;
47
48        /**
49         * Consent is decided in the BROWSER, not in PHP, and that is deliberate:
50         * this block is rendered into the full-page cache, so a server-side
51         * cookie read would bake ONE visitor's consent choice into the cached
52         * HTML served to everyone else.
53         *
54         * Posture (dna_consent, written as `n:1,a:1,m:1`):
55         *   - an explicit a:0 or m:0 is a HARD BLOCK, always;
56         *   - REQUIRE_CONSENT on  -> fire only on an explicit marketing grant m:1;
57         *   - REQUIRE_CONSENT off -> an absent cookie still fires (interim
58         *     posture, matching the Collector storefront tracker).
59         * Unlike our own first-party beacons there is NO server-side backstop
60         * here: this data goes straight to OpenAI, which is why an explicit
61         * opt-out is honoured even in the permissive mode.
62         */
63        /**
64         * Reads EVERY dna_consent cookie, not just the first, and lets the MOST
65         * RESTRICTIVE value win.
66         *
67         * A single `match()` is unsafe here: cookies are serialised
68         * longest-Path-first (RFC 6265 §5.4), so a stale
69         * `dna_consent=...a:1,m:1; Path=/checkout` is presented BEFORE the
70         * site-wide `Path=/` cookie the banner just wrote with a:0,m:0 — and
71         * stopping at the first hit would read the stale GRANT and miss the
72         * visitor's current DENIAL. Any explicit 0 anywhere is therefore sticky.
73         */
74        function consentBits() {
75            var analytics = null, marketing = null;
76            try {
77                var parts = ('; ' + d.cookie).split('; dna_consent=');
78                for (var i = 1; i < parts.length; i++) {
79                    var v = decodeURIComponent(parts[i].split(';')[0]);
80                    var a = /(?:^|,)a:(0|1)/.exec(v);
81                    if (a && (a[1] === '0' || analytics === null)) { analytics = a[1]; }
82                    var k = /(?:^|,)m:(0|1)/.exec(v);
83                    if (k && (k[1] === '0' || marketing === null)) { marketing = k[1]; }
84                }
85            } catch (e) {}
86            return { a: analytics, m: marketing };
87        }
88
89        function consentAllows() {
90            var bits = consentBits();
91            if (bits.a === '0' || bits.m === '0') { return false; }
92            if (REQUIRE_CONSENT) { return bits.m === '1'; }
93            return true;
94        }
95
96        /* Exposed so DEFERRED emitters (the lead listener, which fires on a form
97           submit minutes later) can RE-EVALUATE consent at fire time instead of
98           trusting this page-load decision. Without that, a visitor who denies
99           marketing via the banner after load — no navigation — would still have
100           their signup sent, because `window.oaiq` already exists. */
101        w.__dnaOaiqConsentAllows = consentAllows;
102
103        if (!consentAllows()) {
104            /* We never load the SDK on this path, so no request reaches
105               bzrcdn.openai.com at all. The consent(false) call is only a
106               belt-and-braces mute in case some other tag already loaded it. */
107            try { if (w.oaiq) { w.oaiq('consent', false); } } catch (e) {}
108            return;
109        }
110
111        /**
112         * Queue stub first, then inject the SDK tag ourselves. Injecting (rather
113         * than a static 
113<script async src>) is precisely what lets the consent
114         * gate above run BEFORE any byte is requested from OpenAI.
115         */
116        if (!w.oaiq) {
117            var q = function () { q.q.push(arguments); };
118            q.q = [];
119            w.oaiq = q;
120
121            var js = d.createElement('script');
122            js.async = true;
123            js.src = 'https://bzrcdn.openai.com/sdk/oaiq.min.js';
124
125            var first = d.getElementsByTagName('script')[0];
126            if (first && first.parentNode) {
127                first.parentNode.insertBefore(js, first);
128            } else {
129                (d.head || d.documentElement).appendChild(js);
130            }
131        }
132
133        var init = { pixelId: PIXEL_ID };
134        if (DEBUG) { init.debug = true; }
135        w.oaiq('init', init);
136
137        /* page_viewed is NOT automatic — the SDK does no implicit page or SPA
138           route tracking, so every page view is an explicit measure call. */
139        w.oaiq('measure', 'page_viewed', { type: 'contents' });
140    } catch (e) {}
141})(window, document);</script>
141<!-- /dna:openai-ads-pixel -->
142<!-- dna:meta-browser-pixel -->
143<script type="text&#x2F;x-magento-init">{"*":{"DoNotAge_Meta/js/meta-pixel":{"enabled":true,"pixelId":"1075182783548207","requireConsent":false,"currency":"USD"}}}</script>
143<!-- /dna:meta-browser-pixel -->
144<script>window.__dnaErrLogCfg = {"site":"","release":"1790156720","ingestHost":"","consoleSampleRate":0};
145(function () {
146  try {
147    var w = window, d = document, nav = navigator, loc = location;
148    // Only OUR marker suppresses a second copy. A tag executing before this
149    // snippet (they all do — we render in head.additional) could otherwise
150    // disable error reporting for the whole page with `__dnaErrLog = 1`, and
151    // make it permanent by defining the property non-configurable.
152    if (w.__dnaErrLog && w.__dnaErrLog.__dnaOwn) { return; }
153    try { w.__dnaErrLog = { __dnaOwn: true }; } catch (e) { /* squatted; carry on */ }
154    if (!w.addEventListener || !w.JSON) { return; }
155
156    // Capture JSON.stringify NOW. It is read from mutable page-global state at
157    // send time otherwise, so a later tag could replace it and rewrite every
158    // beacon body. (A tag that replaced it BEFORE we ran still wins — this
159    // narrows the window, it does not close it.)
160    var STRINGIFY = w.JSON.stringify;
161    try { STRINGIFY = Function.prototype.call.bind(w.JSON.stringify, w.JSON); } catch (e) {}
162
163    // Read the config defensively and force primitives. A tag executing before us
164    // can pre-define __dnaErrLogCfg as non-writable (our own assignment then fails
165    // silently in sloppy mode) with an OBJECT-valued site — which would ride into
166    // the beacon envelope undetached, handing an inherited toJSON a way back in.
167    var CFG;
168    try { CFG = w.__dnaErrLogCfg || {}; } catch (e) { CFG = {}; }
169    function safeStr(v, n) {
170      var s;
171      try { s = (v == null) ? '' : String(v); } catch (e) { return ''; }
172      return s.length > n ? s.slice(0, n) : s;
173    }
174    // Read each field EXACTLY ONCE, inside the guard. A getter can throw (which
175    // would abort init from outside safeStr) or return a different value on a
176    // second read — so a type check followed by a separate read is not safe.
177    function cfgStr(k, n) {
178      var v;
179      try { v = CFG[k]; } catch (e) { return ''; }
180      return safeStr(v, n);
181    }
182    var SITE = cfgStr('site', 32) ||
183      (/(^|\.)routine\./i.test(loc.hostname) ? 'routine' : 'legacy');
184    var RELEASE = cfgStr('release', 64);
185    var RAW_SAMPLE;
186    try { RAW_SAMPLE = CFG.consoleSampleRate; } catch (e) {}
187    var SAMPLE = (typeof RAW_SAMPLE === 'number' && isFinite(RAW_SAMPLE)) ? RAW_SAMPLE : 0;
188    // ingestHost builds the beacon URL, so a hostile value is an exfiltration
189    // vector, not just a typo. Accept a plain hostname and nothing else.
190    // REJECT an over-long value rather than truncating it: a 128-char valid
191    // hostname followed by `@evil.example` would otherwise pass validation as its
192    // own truncated prefix. Read with a generous cap, then require the whole
193    // thing to be a plain hostname. Anything else (IDN, bracketed IPv6, trailing
194    // dot) falls back to the default host — the safe direction for a beacon
195    // destination, at the cost of silently ignoring an exotic config.
196    var ING = cfgStr('ingestHost', 512);
197    if (ING.length > 128 || !/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i.test(ING)) { ING = ''; }
198    ING = ING ||
199      (/(^|\.)staging\.donotage\.org$/i.test(loc.hostname) ? 'go.staging.donotage.org' : 'go.donotage.org');
200    var ENDPOINT = 'https://' + ING + '/clientlog';
201
202    var MAX_MSG = 512, MAX_STACK = 4096, MAX_URL = 1024, MAX_SCRIPTS = 30;
203    var MAX_BATCH = 20, MAX_PER_PAGE = 50;
204
205    var buf = [], seen = {}, timer = null, sent = 0;
206
207    // Redact the unambiguous PII/secret shapes BEFORE anything leaves the browser.
208    //
209    // The collector already re-scrubs with the same patterns, but relying on that
210    // alone means the raw value still leaves the visitor's machine — and this
211    // logger is deliberately NOT consent-gated. Scrubbing here keeps it local.
212    // Deliberately NOT trying to catch arbitrary names/addresses: that would
213    // shred the diagnostic value this exists for. Mirrors services/collector
214    // routes/clientlog.ts scrubPii(); keep the two in step.
215    var EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
216    var JWT_RE = /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g;
217    var BEARER_RE = /\b(bearer|token|authorization)\s+[A-Za-z0-9._~+\/=-]{8,}/gi;
218    var LONG_DIGITS_RE = /\b(?:\d[ -]?){13,}\b/g;
219    function scrub(s) {
220      try {
221        return String(s)
222          .replace(EMAIL_RE, '[redacted-email]')
223          .replace(JWT_RE, '[redacted-jwt]')
224          .replace(BEARER_RE, function (m) { return m.split(/\s+/)[0] + ' [redacted]'; })
225          .replace(LONG_DIGITS_RE, '[redacted-number]');
226      } catch (e) { return ''; }
227    }
228    // TRUNCATE FIRST, then scrub. Scrubbing the raw value would run the regexes
229    // over an unbounded attacker-controlled string: the email/JWT patterns
230    // backtrack quadratically on near-misses, and a 64KB rejection message like
231    // ('a'.repeat(32000) + '@' + 'b'.repeat(32000)) froze the main thread for
232    // ~2.3s in testing. try/catch cannot interrupt a regex, and this snippet's
233    // first rule is that it must never break the page.
234    //
235    // The window is a little wider than the cap so a token straddling the
236    // boundary is still matched whole rather than sliced into an unmatchable
237    // fragment; the result is clipped to `n` afterwards.
238    var SCRUB_SLACK = 256;
239    function clip(v, n) {
240      var s = (v == null) ? '' : String(v);
241      if (s.length > n + SCRUB_SLACK) { s = s.slice(0, n + SCRUB_SLACK); }
242      s = scrub(s);
243      return s.length > n ? s.slice(0, n) : s;
244    }
245    // Query/fragment are dropped (that is where click ids and tokens usually
246    // ride), but a PATH can carry PII too — /reset/<token>, /orders/<email>. So
247    // scrub what is left and cap it: MAX_URL existed but was never applied here.
248    /**
249     * Decode a path so the scrub patterns can actually see what is in it.
250     * `pathname` keeps percent-encoding, so /orders/alice%40example.com sails
251     * past the email pattern — it never sees an '@'.
252     *
253     * Three things this has to get right, each one a bypass on its own:
254     *  - REPEATED encoding: `%2540` decodes to `%40`, not `@`. Decode until it
255     *    stops changing (bounded — an attacker must not be able to drive it).
256     *  - PARTIAL failure: one malformed escape (`%ZZ`) makes decodeURIComponent
257     *    throw for the WHOLE string, previously keeping every other segment raw
258     *    and unredacted. Decode per segment so a bad one only costs itself.
259     *  - A decoded `?` or `#` does not restore a query, but everything after it
260     *    is attacker-shaped text we never meant to send — cut there.
261     */
262    function decodePath(p) {
263      var i, j, prev, parts;
264      for (i = 0; i < 3; i++) {
265        prev = p;
266        parts = p.split('/');
267        for (j = 0; j < parts.length; j++) {
268          try { parts[j] = decodeURIComponent(parts[j]); } catch (e) { /* keep segment raw */ }
269        }
270        p = parts.join('/');
271        if (p === prev) { break; }
272      }
273      var cut = p.search(/[?#]/);
274      return cut >= 0 ? p.slice(0, cut) : p;
275    }
276    function pathOnly(u) {
277      if (!u) { return undefined; }
278      var out, raw;
279      // Bound the input BEFORE any parsing/decoding: everything below is linear,
280      // but linear over a multi-megabyte attacker-supplied URL is still work we
281      // have no reason to do on the main thread.
282      try { raw = String(u); } catch (e) { return undefined; }
283      if (raw.length > MAX_URL + SCRUB_SLACK) { raw = raw.slice(0, MAX_URL + SCRUB_SLACK); }
284      try {
285        var a = d.createElement('a');
286        a.href = raw;
287        out = a.protocol + '//' + a.host + a.pathname;
288      } catch (e) {
289        var i = raw.search(/[?#]/);
290        out = i >= 0 ? raw.slice(0, i) : raw;
291      }
292      // Decoding is a DETECTOR, not the output format. Emitting the decoded path
293      // would corrupt legitimate data — `/docs/what%3Fever/more` truncates at the
294      // decoded '?', and `/a%2Fb/c` becomes indistinguishable from `/a/b/c`. So
295      // decode only to look for PII: if scrubbing changed something we emit the
296      // scrubbed decoded form (fidelity is already forfeit once it holds a
297      // secret), otherwise we emit the ORIGINAL path untouched.
298      var probe = decodePath(out), scrubbed = scrub(probe);
299      return clip(scrubbed === probe ? out : scrubbed, MAX_URL);
300    }
301    function scriptsSnapshot() {
302      try {
303        var out = [], els = d.getElementsByTagName('script'), i;
304        for (i = 0; i < els.length && out.length < MAX_SCRIPTS; i++) {
305          if (els[i].src) { out.push(pathOnly(els[i].src)); }
306        }
307        return out.length ? out : undefined;
308      } catch (e) { return undefined; }
309    }
310    // Drop noise that is provably not ours and never actionable: errors thrown by
311    // BROWSER EXTENSIONS (the visitor's own software — Dark Reader-style restylers,
312    // user-scripts — injected as `user-script:` / `*-extension://` stack frames), and
313    // a known-benign third-party logger artifact (HubSpot Collected Forms rejects
314    // 'Level "ERROR" is not supported' from its own bundled logger). Matched on the
315    // stack/source string only; first-party and ordinary third-party site errors are
316    // left untouched.
317    var EXT_RE = /user-script:\d|moz-extension:\/\/|chrome-extension:\/\/|safari-web-extension:\/\/|safari-extension:/i;
318    function isIgnorableNoise(ev) {
319      try {
320        var hay = (ev.stack || '') + ' ' + (ev.source || '');
321        if (EXT_RE.test(hay)) { return true; }
322        if (/collectedforms\.js/i.test(hay) &&
323            /^Level\s+".*"\s+is not supported/i.test(ev.message || '')) { return true; }
324        return false;
325      } catch (e) { return false; }
326    }
327    /**
328     * Re-home a value onto a null prototype so no INHERITED toJSON can reach it.
329     *
330     * JSON.stringify honours toJSON anywhere in the graph, so a tag that installed
331     * Object.prototype.toJSON can otherwise (a) substitute our payload with its own
332     * content, or (b) — if what it returns nests further objects — recurse until
333     * stringify throws, silently suppressing every report. Null-prototyping the
334     * root alone fixes neither: the events array and each event still inherit.
335     */
336    function detach(v) {
337      var out, i, k;
338      if (v === null || typeof v !== 'object') { return v; }
339      if (Object.prototype.toString.call(v) === '[object Array]') {
340        out = [];
341        for (i = 0; i < v.length; i++) { out.push(detach(v[i])); }
342      } else {
343        out = Object.create(null);
344        for (k in v) {
345          if (Object.prototype.hasOwnProperty.call(v, k)) { out[k] = detach(v[k]); }
346        }
347        return out;
348      }
349      try { Object.setPrototypeOf(out, null); } catch (e) {}
350      return out;
351    }
352    // Compare strings, NOT a parsed <a>. document.createElement is page-mutable,
353    // and a tag that replaced it could report a cross-origin host while xhr.open()
354    // still received the real same-origin ENDPOINT — sending cookies through the
355    // very gate meant to prevent it. ING is already validated as a bare hostname
356    // (no port, no userinfo), so this comparison is exact.
357    function endpointIsCrossOrigin() {
358      try {
359        return ('https://' + ING).toLowerCase() !==
360          (loc.protocol + '//' + loc.host).toLowerCase();
361      } catch (e) { return false; }
362    }
363    function send(batch) {
364      var body, payload;
365      try {
366        payload = Object.create(null);
367        payload.site = SITE;
368        payload.release = RELEASE;
369        payload.events = detach(batch);
370        body = STRINGIFY(payload);
371      } catch (e) { return; }
372      if (!body) { return; }
373      try {
374        // ALWAYS fetch, never sendBeacon — including the page-exit flushes.
375        // navigator.sendBeacon's specified credentials mode is "include", so it
376        // sends cookies scoped to the ingest host (and anything on .donotage.org,
377        // which is where the visitor id lives) with every report. This logger is
378        // deliberately NOT consent-gated, so that is not a trade we are entitled
379        // to make — and the file has always claimed credentials never ride along.
380        // This makes the claim true.
381        //
382        // keepalive carries the exit flush across the navigation. Where it is
383        // unsupported the request may be cancelled on unload; losing a best-effort
384        // diagnostic is the right side of that trade, so there is deliberately NO
385        // sendBeacon fallback to recover it.
386        if (w.fetch) {
387          w.fetch(ENDPOINT, { method: 'POST', keepalive: true, credentials: 'omit',
388            headers: { 'content-type': 'text/plain' }, body: body })['catch'](function () {});
389          return;
390        }
391        // No fetch (effectively only very old browsers): fall back to XHR rather
392        // than dropping the report entirely — but ONLY cross-origin. `withCredentials
393        // = false` maps to the "same-origin" credentials mode, NOT "omit" (XH
393R
394        // Standard), so a same-origin endpoint would still send cookies. That only
395        // arises if ingestHost is misconfigured to the storefront host, and this
396        // logger is not consent-gated, so we drop the report instead.
397        if (w.XMLHttpRequest && endpointIsCrossOrigin()) {
398          var xhr = new w.XMLHttpRequest();
399          xhr.open('POST', ENDPOINT, true);
400          xhr.withCredentials = false;
401          try { xhr.setRequestHeader('content-type', 'text/plain'); } catch (e) {}
402          xhr.send(body);
403        }
404      } catch (e) {}
405    }
406    function flush() {
407      if (timer) { clearTimeout(timer); timer = null; }
408      if (!buf.length) { return; }
409      var batch = buf.splice(0, buf.length);
410      send(batch);
411    }
412    function schedule() {
413      if (timer) { return; }
414      timer = setTimeout(function () { flush(); }, 1000);
415    }
416    function capture(ev) {
417      try {
418        if (sent >= MAX_PER_PAGE) { return; }
419        if (isIgnorableNoise(ev)) { return; }
420        var sig = ev.kind + '|' + (ev.message || '') + '|' + (ev.source || '') + '|' + (ev.line || '');
421        var now = (new Date()).getTime();
422        if (seen[sig] && (now - seen[sig]) < 5000) { return; }
423        seen[sig] = now;
424        ev.ts = now;
425        buf.push(ev);
426        sent++;
427        if (buf.length >= MAX_BATCH) { flush(); } else { schedule(); }
428      } catch (e) {}
429    }
430
431    // Reports from guards that run BEFORE this snippet.
432    //
433    // This template renders in head.additional, which is emitted AFT
433ER the
434    // <head> 
434<script> declarations — so require.js, requirejs-config.js and the
435    // third-party tags have all executed by the time capture() exists. A guard
436    // that must install ahead of those (the AMD shield wraps window.define
437    // before any vendor tag can call it) therefore cannot call capture()
438    // directly; it pushes plain objects onto __dnaErrLogQ instead.
439    //
440    // Drain whatever is already queued, then swap the array for a push-through
441    // shim so later pushes arrive immediately. Sanitising HERE (path-only URLs,
442    // clipped strings) keeps the PII rules in one place — producers stay dumb.
443    // The queue is a PUBLIC ingress: any script on the page can push to it, so it
444    // is treated as untrusted input rather than as a friendly internal channel.
445    // (It grants no new capability — /clientlog is a public endpoint anyone can
446    // POST to — but it must not launder arbitrary data through our own sanitiser
447    // and inherit its credibility.)
448    // Explicit allowlist rather than a shape check. An unrecognised item is
449    // dropped WITHOUT consuming quota, so a buggy or hostile producer cannot
450    // exhaust the per-page budget with junk and silently suppress the genuine
451    // uncaught-error reporting that shares it. Add a kind here when a new
452    // producer ships — that coupling is deliberate.
453    // Two SEPARATE bounds, because one cannot do both jobs:
454    //   MAX_QUEUED — how many items we ACCEPT (the per-page reporting quota).
455    //   MAX_SCAN   — how many items we EXAMINE (the work bound).
456    // Capping only the scan lets junk crowd out genuine reports: 50 rejected
457    // items followed by a real one means the real one is never even looked at.
458    // Rejection is cheap (one property read plus an allowlist lookup), so the
459    // scan window is deliberately much wider than the accept quota.
460    var MAX_QUEUED = 50, MAX_SCAN = 200, queuedSeen = 0, scanned = 0,
461        QUEUED_KINDS = { 'anon-define-quarantine': 1, 'requirejs-integrity': 1 };
462
463    // Read a plain own data property. Inherited properties and accessors are
464    // ignored, so a producer cannot execute a getter (or smuggle a prototype
465    // value) during the drain, and only primitives are accepted so no
466    // toString/valueOf runs either.
467    function ownPrimitive(o, k) {
468      try {
469        var d = Object.getOwnPropertyDescriptor(o, k), v, t;
470        if (!d || !('value' in d)) { return undefined; }
471        v = d.value;
472        t = typeof v;
473        return (t === 'string' || t === 'number' || t === 'boolean') ? v : undefined;
474      } catch (e) { return undefined; }
475    }
476    function captureQueued(ev) {
477      try {
478        if (scanned >= MAX_SCAN || queuedSeen >= MAX_QUEUED) { return; }
479        scanned++;
480        if (!ev || typeof ev !== 'object') { return; }
481        var kind = ownPrimitive(ev, 'kind'), src, pg;
482        // Reject BEFORE spending the accept quota, so junk costs an attacker
483        // nothing but also buys them nothing: it cannot suppress the genuine
484        // reports that share the per-page budget.
485        if (typeof kind !== 'string' || QUEUED_KINDS[kind] !== 1) { return; }
486        // Re-check the quota: reading `kind` above touches an attacker-controlled
487        // object, and a Proxy trap there can re-enter this function and accept
488        // items before we resume. Without this the outer call would increment
489        // past the cap.
490        if (queuedSeen >= MAX_QUEUED) { return; }
491        // Bound the work BEFORE the expensive mapping — capture()'s own
492        // MAX_PER_PAGE check happens too late to stop URL parsing and a script
493        // snapshot per item.
494        queuedSeen++;
495        src = ownPrimitive(ev, 'source');
496        pg = ownPrimitive(ev, 'page');
497        capture({
498          kind: kind,
499          message: clip(ownPrimitive(ev, 'message'), MAX_MSG),
500          source: src ? clip(pathOnly(clip(src, MAX_URL)), MAX_URL) : undefined,
501          page: clip(pathOnly(clip(pg || loc.href, MAX_URL)), MAX_URL),
502          scripts: scriptsSnapshot()
503        });
504      } catch (e) {}
505    }
506    var queued = null;
507    // Drain and sink-installation are separately guarded on purpose: a hostile
508    // container (throwing length getter, proxy trap) must not be able to abort
509    // this block early and leave the sink uninstalled, which would silently
510    // divert every later report.
511    try {
512      queued = w.__dnaErrLogQ;
513      if (!queued || typeof queued.length !== 'number') { queued = []; }
514    } catch (e) { queued = []; }
515    // Snapshot the bound BEFORE anything else: re-reading a live .length lets a
516    // producer that appends during the drain (or reports length === Infinity)
517    // spin this loop forever and freeze the main thread on a synchronous head
518    // script.
519    var qlen = 0;
520    try { qlen = Math.min(queued.length, MAX_SCAN); } catch (e) { qlen = 0; }
521    try {
522      // Install the sink FIRST, then drain. Overriding push on the SAME object
523      // (rather than replacing it) keeps a producer that captured
524      // `window.__dnaErrLogQ` before we ran. Doing it before the drain also
525      // means a report produced DURING the drain — a re-entrant producer, or
526      // capture() itself flushing through a wrapped fetch — routes straight to
527      // captureQueued instead of landing past the snapshot and being discarded
528      // by the clear below.
529      queued.push = function () {
530        for (var i = 0; i < arguments.length; i++) {
531          // Stop at either cap instead of walking every argument of a
532          // push.apply(queue, hugeArray).
533          if (queuedSeen >= MAX_QUEUED || scanned >= MAX_SCAN) { return 0; }
534          captureQueued(arguments[i]);
535        }
536        return 0;
537      };
538      w.__dnaErrLogQ = queued;
539    } catch (e) {}
540    try {
541      var qi;
542      for (qi = 0; qi < qlen && queuedSeen < MAX_QUEUED && scanned < MAX_SCAN; qi++) { captureQueued(queued[qi]); }
543      try { queued.length = 0; } catch (e) {}
544      // Give the live sink its OWN scan window. Otherwise a queue that arrives
545      // pre-inflated (a sparse array claiming a huge length, whose holes each
546      // cost a scan) spends the whole budget during the drain and starves every
547      // genuine report pushed afterwards. The ACCEPT quota stays global — that
548      // is the real per-page cap; only the work bound is per-phase.
549      scanned = 0;
550    } catch (e) {}
551
552    w.addEventListener('error', function (e) {
553      try {
554        var t = e && e.target;
555        // Resource load failure (script/img/css 404): target is the element, not window.
556        if (t && t !== w && (t.src || t.href)) {
557          capture({ kind: 'error', message: 'Resource failed to load',
558            source: pathOnly(t.src || t.href), page: pathOnly(loc.href) });
559          return;
560        }
561        capture({
562          kind: 'error',
563          message: clip(e && e.message, MAX_MSG),
564          source: pathOnly(e && e.filename),
565          line: (e && typeof e.lineno === 'number') ? e.lineno : undefined,
566          col: (e && typeof e.colno === 'number') ? e.colno : undefined,
567          stack: clip(e && e.error && e.error.stack, MAX_STACK),
568          page: pathOnly(loc.href),
569          scripts: scriptsSnapshot()
570        });
571      } catch (err) {}
572    }, true);
573
574    w.addEventListener('unhandledrejection', function (e) {
575      try {
576        var r = e && e.reason;
577        var msg = (r && r.message) ? r.message
578          : (typeof r === 'string' ? r : 'Unhandled promise rejection');
579        capture({
580          kind: 'unhandledrejection',
581          message: clip(msg, MAX_MSG),
582          stack: clip(r && r.stack, MAX_STACK),
583          page: pathOnly(loc.href)
584        });
585      } catch (err) {}
586    });
587
588    // RequireJS failures — "Mismatched anonymous define()" and load timeouts
589    // surface via requirejs.onError, NOT always via window.onerror. Hook it once
590    // RequireJS is present (it may load after this snippet runs).
591    //
592    // RECOVERY: the bulk of our prod "Script error for X" volume is transient —
593    // WebKit/iOS aborts in-flight subresource requests on navigation, tab
594    // backgrounding and flaky cellular, dropping a handful of modules per page
595    // load. Those re-fetch cleanly. So for the two network-recoverable error
596    // types ('scripterror' = the 
596<script> failed to load, 'timeout' = it never
597    // arrived in time) we undef + re-request the failed modules a bounded number
598    // of times before giving up. Code-bug types ('mismatch', 'nodefine', …) are
599    // NOT retried (re-requesting can't fix them) and fall straight through to
600    // logging. A repeat failure re-enters this handler, where the per-module cap
601    // eventually lets it through so genuine permanent failures are still logged.
602    var RQ_MAX_RETRY = 2;     // attempts per module before we give up + log
603    var RQ_MAX_TRACKED = 200; // cap distinct tracked modules (bounds memory)
604    // Null-prototype map so a module id like 'hasOwnProperty'/'__proto__' can't
605    // poison the tracking; rqHas() tests own-key existence safely.
606    var rqRetried = Object.create(null), rqTrackedCount = 0;
607    function rqHas(m) { return Object.prototype.hasOwnProperty.call(rqRetried, m); }
608    function hookRequire() {
609      try {
610        var rq = w.requirejs || w.require;
611        if (!rq || rq.__dnaHooked) { return !!rq; }
612        rq.__dnaHooked = 1;
613        var orig = rq.onError;
614        rq.onError = function (err) {
615          try {
616            var type = err && err.requireType;
617            var mods = (err && err.requireModules) || null;
618            if (mods && mods.length && (type === 'scripterror' || type === 'timeout') &&
619                typeof rq === 'function' && typeof rq.undef === 'function') {
620              // Retry ONLY when every failed module is still recoverable (under
621              // its per-module cap, and we have tracking room). If any module is
622              // exhausted we suppress nothing and fall through to logging, so a
623              // permanent failure riding alongside a retryable one is never hidden.
624              var allRetryable = true, newCount = 0, i, m, n;
625              for (i = 0; i < mods.length; i++) {
626                m = mods[i];
627                n = rqRetried[m] || 0;
628                if (n >= RQ_MAX_RETRY) { allRetryable = false; break; }
629                // Count NEW modules within this batch too, so a multi-module
630                // batch near the cap cannot overshoot RQ_MAX_TRACKED.
631                if (!rqHas(m)) {
632                  newCount++;
633                  if (rqTrackedCount + newCount > RQ_MAX_TRACKED) { allRetryable = false; break; }
634                }
635              }
636              if (allRetryable) {
637                for (i = 0; i < mods.length; i++) {
638                  m = mods[i];
639                  if (!rqHas(m)) { rqTrackedCount++; }
640                  rqRetried[m] = (rqRetried[m] || 0) + 1;
641                  try { rq.undef(m); } catch (eU) {}
642                }
643                // No errback: a repeat failure must re-enter THIS handler (where
644                // the cap decides) rather than being swallowed by a local errback.
645                var scheduled = false;
646                try { rq(mods, function () {}); scheduled = true; } catch (eR) {}
647                // `orig` (the RequireJS default onError — a thrower) is deliberately
648                // NOT invoked while a retry is in flight: re-throwing would defeat
649                // the recovery. It still runs on the final give-up below. Only
650                // suppress if the re-request was actually scheduled; if rq() threw
651                // synchronously, fall through so the error is still logged/raised.
652                if (scheduled) { return; }
653              }
654            }
655          } catch (eRetry) { /* fall through to logging */ }
656          try {
657            capture({
658              kind: 'requirejs',
659              message: clip(err && err.message, MAX_MSG),
660              stack: clip(err && err.stack, MAX_STACK),
661              moduleId: clip(err && err.requireModules && err.requireModules.join(','), MAX_URL),
662              page: pathOnly(loc.href),
663              scripts: scriptsSnapshot()
664            });
665          } catch (e2) {}
666          if (typeof orig === 'function') { return orig.apply(this, arguments); }
667          throw err;
668        };
669        return true;
670      } catch (e) { return false; }
671    }
672    if (!hookRequire()) {
673      var tries = 0;
674      var iv = setInterval(function () {
675        if (hookRequire() || (++tries > 40)) { clearInterval(iv); }
676      }, 250);
677    }
678
679    // Optional sampled console.error/warn capture (off unless consoleSampleRate>0).
680    if (SAMPLE > 0 && w.console) {
681      var wrap = function (lvl) {
682        var orig = w.console[lvl];
683        if (typeof orig !== 'function') { return; }
684        w.console[lvl] = function () {
685          try {
686            if (Math.random() < SAMPLE) {
687              var parts = Array.prototype.map.call(arguments, function (a) {
688                // NEVER serialize arbitrary objects — console args routinely carry
689                // customer/order/cart objects full of PII. Emit an Error-like
690                // message, else just the type name. The server scrubs the rest.
691                if (a && typeof a === 'object') {
692                  if (a.message) { return String(a.message); }
693                  return '[' + ((a.constructor && a.constructor.name) || 'object') + ']';
694                }
695                return String(a);
696              });
697              capture({ kind: 'console.' + lvl, message: clip(parts.join(' '), MAX_MSG),
698                stack: clip(arguments[0] && arguments[0].stack, MAX_STACK), page: pathOnly(loc.href) });
699            }
700          } catch (e) {}
701          return orig.apply(w.console, arguments);
702        };
703      };
704      wrap('error');
705      wrap('warn');
706    }
707
708    // Flush buffered reports before the page goes away so a navigation that
709    // follows the error doesn't drop them.
710    w.addEventListener('visibilitychange', function () {
711      if (d.visibilityState === 'hidden') { flush(); }
712    });
713    w.addEventListener('pagehide', function () { flush(); });
714  } catch (e) { /* never break the page */ }
715})();</script>
715    
715<script type="text/x-magento-init">
716        {
717            "*": {
718                "Magento_PageCache/js/form-key-provider": {
719                    "isPaginationCacheEnabled":
720                        0                }
721            }
722        }
723    </script>
723
724
725<!-- Magento Page Builder By https://goomento.com/  -->
726<link rel="preconnect" href="https://fonts.googleapis.com">
727<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
728<!-- End Magento Page Builder By https://goomento.com/ -->
729    <link rel="canonical" href="https://donotage.org/customer/account/login" />
730<!-- dna:collector-funnel-events -->
731<script>(function (w, d) {
732    try {
733        if (w.__dnaFunnel) { return; }
734
735        var HOST_OVERRIDE = "";
736
737        /* The two surfaces are gated independently. The core renders when EITHER
738           is on, so this flag decides whether a beacon actually reaches /collect;
739           GA4 is gated separately by ga4.phtml rendering at all. With the ledger
740           off and GA4 on, this file emits gtag events and writes nothing. */
741        var LEDGER_ENABLED = false;
742
743        /* Captured from store-scoped server config rather than window.__dnaGa4,
744           which is deliberately created by the next inline block. This is therefore
745           available while a pending add from the previous page is being located. */
746        var GA4_DESTINATION = "G-PQM4QX6D89";
747
748        /* Derived EXACTLY as the storefront tracker derives it, so the two can
749           never disagree about where ingest lives. */
750        function base() {
751            try {
752                var meta = d.querySelector('meta[name="dna-ingest-host"]');
753                var host = HOST_OVERRIDE ||
754                    (meta && meta.getAttribute('content')) ||
755                    (/(^|\.)staging\.donotage\.org$/i.test(location.hostname)
756                        ? 'go.staging.donotage.org'
757                        : 'go.donotage.org');
758                return 'https://' + host;
759            } catch (e) { return null; }
760        }
761
762        /* WHICH LEDGER A DURABLE MARKER BELONGS TO.
763           sessionStorage is scoped to the ORIGIN, while the ingest host is
764           store-view config — so two store views on one domain can post to two
765           DIFFERENT Collectors out of one shared key space, and an unqualified
766           `dna_fnl_<key>` let whichever was visited first record the event as
767           delivered for both. The second ledger then never received it, for the
768           life of the tab, with nothing anywhere to show for it.
769
770           Keyed on the HOST and not on the store view, because the ordinary case
771           is the opposite one: several store views (one per currency) share ONE
772           ledger, where the same product viewed in two currencies is one journey
773           step and keying per store view would report it twice. Same ledger,
774           same key; different ledger, different key. */
775        function ledgerScope() {
776            try {
777                var b = base();
778                return b ? b.replace(/^https?:\/\//, '') : '';
779            } catch (e) { return ''; }
780        }
781
782        /* Consent: an explicit ANALYTICS refusal is a hard block. Emitted from
783           ViewModel\BrowserConsent so this tag and the GA4 tag cannot answer the
784           same question differently — see that class for why the scan looks the
785           way it does (most-restrictive-wins across every dna_consent cookie). */
786        function dnaAnalyticsDeclined() {
787    var parts;
788    try {
789        parts = ('; ' + document.cookie).split('; dna_consent=');
790    } catch (e) { return false; }
791    for (var i = 1; i < parts.length; i++) {
792        /* The try is PER COOKIE, not around the loop. decodeURIComponent throws
793           on malformed percent-encoding, and one junk cookie must not stop the
794           scan before it reaches a later cookie that says a:0 — that would turn
795           an explicit refusal into a silent grant. A cookie we cannot decode is
796           skipped; every other cookie is still examined. */
797        try {
798            var v = decodeURIComponent(parts[i].split(';')[0]);
799            var a = /(?:^|,)a:(0|1)/.exec(v);
800            if (a && a[1] === '0') { return true; }
801        } catch (e) {}
802    }
803    return false;
804}
805
806function dnaMarketingDeclined() {
807    var parts;
808    try {
809        parts = ('; ' + document.cookie).split('; dna_consent=');
810    } catch (e) { return false; }
811    for (var i = 1; i < parts.length; i++) {
812        try {
813            var v = decodeURIComponent(parts[i].split(';')[0]);
814            var m = /(?:^|,)m:(0|1)/.exec(v);
815            if (m && m[1] === '0') { return true; }
816        } catch (e) {}
817    }
818    return false;
819}
820
821        /* Per-tab session id, same key + 30-min idle rotation as the storefront
822           tracker, so a funnel event shares the session row its pageview created
823           instead of opening a second one. */
824        function sessionId() {
825            try {
826                var now = Date.now();
827                var raw = sessionStorage.getItem('dna_sid');
828                var at = parseInt(sessionStorage.getItem('dna_sid_at') || '0', 10) || 0;
829                if (raw && now - at <= 30 * 60 * 1000) {
830                    sessionStorage.setItem('dna_sid_at', String(now));
831                    return raw;
832                }
833                if (w.crypto && w.crypto.randomUUID) {
834                    var id = w.crypto.randomUUID();
835                    sessionStorage.setItem('dna_sid', id);
836                    sessionStorage.setItem('dna_sid_at', String(now));
837                    return id;
838                }
839            } catch (e) {}
840            return null;
841        }
842
843        /* /collect and /api/event require the signed cookie /session/init mints.
844           This module and the storefront tracker used to bootstrap independently:
845           on a fresh browser both requests could arrive without a cookie, mint two
846           visitors, and let the last Set-Cookie win. The pageview then belonged to
847           one journey while product/cart/checkout belonged to another.
848
849           Publish ONE page-global broker, keyed by the exact ingest base. Both
850           emitters synchronously obtain the same in-flight promise, so only one
851           bootstrap can mint identity. The broker also owns the resolver's signed
852           #dh handoff and allow-listed click ids: whichever emitter asks first sends
853           the same authoritative initial body. The handoff body is deliberately
854           consumed by the FIRST network attempt only because #dh is a one-time
855           bearer token;
855 a retry or 401 renewal must never replay it. */
856        /* Byte length of a string's UTF-8 encoding — the unit /session/init's
857           bodyLimit and the mint's click-id budget both count in, as opposed to the
858           UTF-16 characters String#length reports. TextEncoder everywhere modern;
859           the escape/encodeURIComponent pair is the ES5 fallback, and a throw
860           degrades to the 6-bytes-per-char worst case so the budget can only ever
861           be conservative, never permissive. */
862        function u8len(s) {
863            try {
864                if (w.TextEncoder) { return new w.TextEncoder().encode(s).length; }
865                return unescape(encodeURIComponent(s)).length;
866            } catch (e) { return s.length * 6; }
867        }
868
869        /* Bytes of a string's JSON encoding — the ONE unit every bound below counts
870           in. The per-value click-id cap used to count UTF-16 CHARACTERS while the
871           bag budget spent BYTES, and three bytes per character crossed them: 512 x
872           U+4E00 costs 1538 serialized bytes, MORE THAN THE WHOLE 1536 BUDGET, so a
873           single junk value consumed the budget and every real id behind it was
874           dropped (#639). Charging both in JSON bytes closes that for every character
875           width at once. It is also the strictest of the two wire forms the
876           vid-handoff token travels in (this file's JSON body field, the funnel
877           tracker's raw header) and never smaller than the raw UTF-8 length, so one
878           measurement bounds both carriers. Mirrors jsonLen() in
879           dna-platform/apps/funnels/scripts/session-snippet.html and in the
880           storefront theme's collector-tracker.phtml. */
881        function jsonLen(s) {
882            try { return u8len(JSON.stringify(s)); } catch (e) { return s.length * 6 + 2; }
883        }
884
885        function storefrontSessionBody() {
886            var vh = '';
887            /* THE VID-HANDOFF READ: its own try/catch, and BOUNDED WHERE IT IS READ (#638).
888
889               OWN try/catch: decodeURIComponent THROWS on a malformed escape, so
890               #dh=%2 used to escape to this function's OUTER catch and return null —
891               discarding the WHOLE body, valid click-ids included. A handoff problem
892               may cost the handoff and nothing else.
893
894               BOUNDED: vh rides the /session/init BODY, and that route's bodyLimit
895               (MAX_SESSION_INIT_BODY_BYTES, 16 KiB) is enforced by Fastify's parser
896               BEFORE Zod runs — so the .catch(undefined) that exists to discard a
897               malformed vh never gets the chance. A 20 KB #dh= built a 20,489-byte
898               body, was answered 413, and cost the visitor their vid: no dna_sess, no
899               vid cookie, and every later /collect and /api/event beacon 401 for the
900               rest of the visit. 4098 = MAX_HANDOFF_TOKEN_LEN (4096,
901               services/collector/src/routes/ingest.ts) + the two JSON quotes,
902               measured in JSON BYTES because a CHARACTER cap does not close it: 4096
903               NUL characters are 4096 characters and 24,576 escaped bytes. Over-long
904               -> DROP THE FIELD and send the handshake WITHOUT it. The handshake is
905               NEVER abandoned over a handoff problem: a fresh vid beats no vid. The
906               fragment is then left in place, deliberately — an unusable token is not
907               a token we consumed. The collector applies the same bound independently
908               (handoffTokenWithinBounds, ingest.ts), because a client bound is not a
909               server bound.
910
911               SHAPE-CHECKED TOO. A real handoff is base64url + "." + base64url
912               (issueVidHandoff), so the token's own alphabet is the guard: anything
913               outside it could not have verified anyway. Same test at all four read
914               sites — on the funnel session snippet this carrier is a raw HEADER, whose
915               value is a ByteString, and there one character above U+00FF (or a NUL)
916               makes fetch() reject outright and the visitor gets NO handshake at all.
917               One rule everywhere keeps the four copies diffable, and the collector
918               states it as well (HANDOFF_TOKEN_SHAPE). */
919            try {
920                var rawHash = (location.hash || '').replace(/^#/, '');
921                if (rawHash) {
922                    var keep = [], segs = rawHash.split('&');
923                    for (var hi = 0; hi < segs.length; hi++) {
924                        if (segs[hi].indexOf('dh=') === 0) {
925                            var vhCandidate = '';
926                            try { vhCandidate = decodeURIComponent(segs[hi].slice(3)); } catch (e) { vhCandidate = ''; }
927                            if (vhCandidate && jsonLen(vhCandidate) <= 4098 && /^[A-Za-z0-9._-]+$/.test(vhCandidate)) { vh = vhCandidate; }
928                            else { keep.push(segs[hi]); }
929                        } else if (segs[hi]) {
930                            keep.push(segs[hi]);
931                        }
932                    }
933                    if (vh && w.history && w.history.replaceState) {
934                        w.history.replaceState(
935                            null,
936                            '',
937                            location.pathname + (location.search || '') + (keep.length ? '#' + keep.join('&') : '')
938                        );
939                    }
940                }
941            } catch (e) {}
942
943            /* The vh-only body is the FLOOR. The two halves of this handshake fail
944               independently in BOTH directions: a malformed #dh already costs only
945               the handoff (its own try/catch above), and a throw anywhere in the
946               click-id half must likewise cost only the click-ids — not the handoff,
947               which is the sole carrier of the click's vid when the 302 Set-Cookie
948               was dropped. Defence in depth rather than a reachable bug today: every
949               decode in the loop below is individually caught. */
950            var vhOnlyBody = null;
951            if (vh) {
952                try { vhOnlyBody = JSON.stringify({ vh: vh }); } catch (e) { vhOnlyBody = null; }
953            }
954
955            try {
956
957                /* Ad click-ids the landing URL carries, parsed under the SAME rules and
958                   bounds as every other parser of this list in the estate
959                   (packages/sdk/src/click-ids.ts, apps/funnels/scripts/session-snippet.html,
960                   and the storefront theme's collector-tracker.phtml). Where they
961                   disagreed, this copy was the one that was wrong. The numbers come
962                   from services/collector/src/routes/ingest.ts: MAX_CLICK_ID_KEYS 12,
963                   MAX_CLICK_ID_KEY_LEN 32, MAX_CLICK_ID_VALUE_LEN 512.
964
965                   DROP an over-length id, NEVER truncate. A truncated gclid is not a
966                   lost signal, it is a WRONG-BUT-PLAUSIBLE one: it clears every bound,
967                   so the collector accepts it, HMAC-signs it into the 90-day
968                   dna_click_bundle and hands it to Google Ads / Meta as a match key —
969                   where it matches nothing, silently, for the life of the cookie. The
970                   mint drops the same value anyway (canonicalizeBundleClickIds,
971                   packages/security/src/session-token.ts), so truncating never bought a
972                   surviving id; it only manufactured a corrupt one.
973
974                   DUPLICATE key → the FIRST occurrence wins (this used to be the last).
975                   The value rides a SIGNED cookie into checkout up to 90 days later, so
976                   on ?gclid=A&gclid=B the landing URL's own first value is the
977                   fail-closed choice: an appended duplicate must not be able to steer
978                   the durable bundle. This matches canonicalizeBundleClickIds and the
979                   resolver's clickContext, which are both first-wins.
980
981                   SERIALIZED-BYTE BUDGET (1536) for the whole bag, and a PER-VALUE CAP
982                   OF 514 BYTES charged in the SAME unit. Both numbers are the mint's own
983                   (MAX_BUNDLE_CLICK_IDS_BYTES, and 512 ASCII chars + the two JSON
984                   quotes — packages/security/src/session-token.ts), so nothing this
985                   parser sends is discarded at signing.
986
987                   The per-value cap USED TO COUNT UTF-16 CHARACTERS while the budget
988                   spent BYTES, and that gap was the whole defect (#639). JSON escaping
989                   separates the two by up to 6x, but even 3x is enough, because
990                   3 x 512 = 1536 IS the entire budget:
991                   ?fbclid=<500 x U+4E00>&gclid=<real gclid> spent the budget on the junk
992                   and DROPPED the real gclid one parameter later. Junk survived, the
993                   money-path id did not. A control-character rule closes %00 and nothing
994                   else — U+2028, U+FFFD and any 3-byte character do the same job.
995                   Measuring the value in the budget's own unit bounds the worst single
996                   pair at "msclkid": (10) + 514 + , (1) = 525 bytes of the 1534
997                   available, for 1-, 2-, 3- and 4-byte characters and surrogate pairs
998                   alike.
999
1000                   THAT CAP ALONE WAS NOT ENOUGH, and the first roun
1000d of #639 wrongly
1001                   said it was. Three allowlisted junk values, 493 ASCII characters each
1002                   and every one inside the per-value cap, still sum to the whole budget
1003                   and drop a real gclid written after them:
1004                   ?fbclid=<493 x A>&gbraid=<493 x A>&msclkid=<493 x A>&gclid=<real> —
1005                   about 1 KB of query string, no oversized value and no exotic character
1006                   needed. So the budget is now spent in want[] ORDER rather than URL
1007                   order (PASS 2 below): who gets crowded out of a full bag is our
1008                   ranking, not the attacker's parameter ordering. Neither rule defends
1009                   against a link that simply writes ?gclid=JUNK itself; nothing about
1010                   size or order can.
1011
1012                   The budget also keeps a crafted link from 413-ing /session/init
1013                   (16 KiB MAX_SESSION_INIT_BODY_BYTES, enforced by Fastify BEFORE Zod
1014                   runs, so the per-field .catch(undefined) never gets the chance and the
1015                   page gets no dna_sess and no vid at all). `continue`, never `break`:
1016                   a rejected id costs only itself, and its valid siblings still ride.
1017
1018                   A field that did not percent-decode cleanly is DROPPED (it used to be
1019                   relayed RAW), so ?gclid=%2 can no longer put the literal "%2" into the
1020                   durable bundle — matching click-ids.ts (decodedCleanly) and the funnel
1021                   session snippet, which drops on the same decodeURIComponent throw.
1022
1023                   Total, never throws: a click-id problem must never cost the vid
1024                   handshake. On any failure the body still carries vh. */
1025                /* want[] IS THE ADMISSION PRIORITY, not just the allowlist: PASS 2
1026                   spends the budget in this order, and it is the order CLICK_ID_KEYS
1027                   lists these eight in at the mint, so this parser and the mint trim
1028                   the same bag. Identical list, identical order, in every copy. */
1029                var want = ['gclid', 'gbraid', 'wbraid', 'fbclid', 'msclkid', 'ttclid', 'twclid', 'sccid'];
1030                var clickIds = {};
1031                var bytes = 2;   /* the enclosing `{}`, charged exactly as the mint charges it */
1032                /* PASS 1 — candidates. Every per-VALUE rule applies here; the SHARED
1033                   budget deliberately does not, so spending it cannot depend on the
1034                   order the attacker chose to write the query in. */
1035                var cand = {}, candN = 0;
1036                var query = (location.search || '').replace(/^\?/, '');
1037                if (query) {
1038                    var pairs = query.split('&');
1039                    for (var i = 0; i < pairs.length; i++) {
1040                        if (!pairs[i]) { continue; }
1041                        if (candN >= 12) { break; }                 /* MAX_CLICK_ID_KEYS (want[] is 8, headroom only) */
1042                        var eq = pairs[i].indexOf('=');
1043                        if (eq === -1) { continue; }                /* a bare flag carries no id */
1044                        var key = '';
1045                        try { key = decodeURIComponent(pairs[i].slice(0, eq)); } catch (e) { continue; }
1046                        if (key.length > 32) { continue; }          /* MAX_CLICK_ID_KEY_LEN */
1047                        key = key.toLowerCase();
1048                        if (want.indexOf(key) === -1) { continue; }
1049                        if (cand[key]) { continue; }                /* FIRST occurrence wins — see above */
1050                        var value = '';
1051                        try { value = decodeURIComponent(pairs[i].slice(eq + 1).replace(/\+/g, ' ')); } catch (e) { continue; }
1052                        if (!value) { continue; }
1053                        if (jsonLen(value) > 514) { continue; }
1053     /* PER-VALUE CAP in the budget's own unit:
1054                                                                       514 = 512 ASCII chars + the two JSON
1055                                                                       quotes. DROP, never truncate. Counted in
1056                                                                       BYTES so no single value can eat the 1536
1057                                                                       budget, whatever its character width. */
1058                        cand[key] = value;
1059                        candN++;
1060                    }
1061                }
1062                /* PASS 2 — spend the 1536-byte budget in want[] order, never in URL
1063                   order. `continue`, never `break`: an unaffordable id costs only
1064                   itself. */
1065                for (var wi = 0; wi < want.length; wi++) {
1066                    var wk = want[wi];
1067                    if (!cand[wk]) { continue; }
1068                    var cost = jsonLen(wk) + 1 + jsonLen(cand[wk]) + 1;
1069                    if (bytes + cost > 1536) { continue; }          /* over the serialized budget → drop THIS id only */
1070                    clickIds[wk] = cand[wk];
1071                    bytes += cost;
1072                }
1073
1074                var hasClickIds = false;
1075                for (var name in clickIds) {
1076                    if (Object.prototype.hasOwnProperty.call(clickIds, name)) { hasClickIds = true; break; }
1077                }
1078                if (!vh && !hasClickIds) { return null; }
1079
1080                var body = {};
1081                if (vh) { body.vh = vh; }
1082                if (hasClickIds) { body.clickIds = clickIds; }
1083                return JSON.stringify(body);
1084            } catch (e) { return vhOnlyBody; }
1085        }
1086
1087        if (typeof w.__dnaSessionBrokerFor !== 'function') {
1088            (function installSessionBroker() {
1089                var brokers = Object.create(null);
1090                var SESSION_MAX_ATTEMPTS = 3;
1091
1092                w.__dnaSessionBrokerFor = function (ingestBase, suppliedBody) {
1093                    if (!ingestBase || typeof ingestBase !== 'string' || !w.fetch) { return null; }
1094                    if (brokers[ingestBase]) { return brokers[ingestBase]; }
1095
1096                    var initialBody = typeof suppliedBody === 'string' && suppliedBody
1097                        ? suppliedBody
1098                        : storefrontSessionBody();
1099                    var initialBodyPending = !!initialBody;
1100                    var ready = null;
1101                    var attempts = 0;
1102                    var notBefore = 0;
1103
1104                    var broker = {
1105                        ready: function () {
1106                            if (ready) { return ready; }
1107                            if (attempts >= SESSION_MAX_ATTEMPTS) { return Promise.resolve(false); }
1108                            if (Date.now() < notBefore) { return Promise.resolve(false); }
1109
1110                            attempts++;
1111                            var options = {
1112                                method: 'POST',
1113                                credentials: 'include',
1114                                referrerPolicy: 'origin',
1115                                headers: { 'content-type': 'text/plain' }
1116                            };
1117                            if (initialBodyPending) {
1118                                options.body = initialBody;
1119                                initialBodyPending = false;
1120                            }
1121
1122                            var attempt = w.fetch(ingestBase + '/session/init', options)
1123                                .then(function (res) { return !!(res && res.ok); })
1124                                .catch(function () { return false; });
1125                            var chain = attempt.then(function (ok) {
1126                                if (!ok) {
1127                                    if (ready === chain) { ready = null; }
1128                                    notBefore = Date.now() + 5000 * attempts;
1129                                } else {
1130                                    attempts = 0;
1131                                    notBefore = 0;
1132                                }
1133                                return ok;
1134                            });
1135                            ready = chain;
1136                            return chain;
1137                        },
1138                        invalidate: function (expected) {
1139                            /* Compare-and-clear: concurrent 401s may share the old
1140                               promise, but cannot erase a renewal already started. */
1141                            if (!expected || ready === expected) {
1142                                ready = null;
1143                                notBefore = 0;
1144                            }
1145                        }
1146                    };
1147
1148                    brokers[ingestBase] = broker;
1149                    return broker;
1150                };
1151            })();
1152        }
1153
1154        var pageSessionBroker = null;
1155        function sessionBroker() {
1156            if (pageSessionBroker) { return pageSessionBroker; }
1157            var b = base();
1158            if (!b || typeof w.__dnaSessionBrokerFor !== 'function') { return null; }
1159            pageSessionBroker = w.__dnaSessionBrokerFor(b);
1160            return pageSessionBroker;
1161        }
1162
1163        function sessionReady() {
1164            var broker = sessionBroker();
1165            return broker ? broker.ready() : Promise.resolve(false);
1166        }
1167
1168        /* Origin + path only — a storefront URL's query can carry PII (an email in
1169           a share link, a token, a search term) and the page identity is all the
1170           ledger needs. Mirrors the tracker's scrubbing. */
1171        function safePage() {
1172            try { return location.origin + location.pathname; } catch (e) { return null; }
1173        }
1174
1175        /* Ledger beacon. `toGa4` decides whether this event is ALSO projected to
1176           GA4 through the shared taxonomy — the two are independent systems, so
1177           GA4 is emitted whether or not the /collect handshake succeeds, and a
1178           ledger-only event (the GA4 identity ping) never becomes a GA4 event.
1179
1180           `onAccepted` is the DELIVERY BOUNDARY a once-per-session caller needs:
1181           it runs only when the event has actually been handed off, never when
1182           the event was dropped (declined consent, no ingest host, a failed
1183           /session/init handshake). Callers that write a durable "already sent"
1184           marker hang it off this, so a dropped event stays retryable instead of
1185           being remembered as delivered — see trackOnce. It is HANDOFF, not
1186           acknowledgement: sendBeacon reports only that the browser queued the
1187           payload, and nothing in this estate can do better from a page that is
1188           about to unload.
1189
1190           `onDropped` IS THE OTHER HALF OF THAT BOUNDARY, and it exists because
1191           a caller can hold an IN-MEMORY claim as well as a durable one. Writing
1192           no marker is enough to keep the next PAGE LOAD retryable, but it says
1193           nothing to a caller that has already set a flag on THIS page to stop
1194           itself double-sending. The GA4 identity ping does exactly that (see
1195           reportIdentity in ga4.phtml) and had no way to learn that its beacon
1196           never left, so it stopped retrying for the life of the page. Exactly
1197           one of the two callbacks runs, and it runs at most once.
1198
1199           `ledgerIndependent` EXEMPTS THIS ONE EVENT FROM LEDGER_ENABLED, and
1200           only identity() below ever passes it — see the comment there for why
1201           the identity ping is not a funnel observation. Nothing else about the
1202           beacon changes: consent, an ingest host, `fetch`, the /session/init
1203           handshake and the /collect result all still decide whether it goes. */
1204        function emit(name, props, toGa4, onAccepted, onDropped, ledgerIndependent) {
1205            /* ONE SETTLEMENT, WHICHEVER WAY IT GOES. Both boundaries are
1206               reachable from one emit — a synchronous throw after an
1207               asynchronous handoff, say — and a caller told "sent" must never
1208               afterwards be told "dropped" and release a claim that was
1209               honoured. */
1210            var settled = false;
1211            function accepted() {
1212                if (settled) { return; }
1213                settled = true;
1214                if (onAccepted) { try { onAccepted(); } catch (e) {} }
1215            }
1216            function dropped() {
1217                if (settled) { return; }
1218                settled = true;
1219                if (onDropped) { try { onDropped(); } catch (e) {} }
1220            }
1221
1222            try {
1223                if (!name || dnaAnalyticsDeclined()) { dropped(); return; }
1224
1225                /* THROUGH ga4(), NOT STRAIGHT AT __dnaGa4.track — the boot gate
1226                   lives in there, and a second call site that skipped it is how
1227                   add_to_cart came to land ahead of the config that gives it a
1228                   destination. */
1229                if (toGa4) { ga4(name, props); }
1230
1231                if (!LEDGER_ENABLED && !ledgerIndependent) {
1232                    /* LEDGER OFF: THERE IS NOTHING TO ACCEPT.
1233                       `onAccepted` is the LEDGER's delivery boundary and nothing
1234                       else — every caller hangs a durable "the ledger already
1235                       has this" marker off it. This branch makes no /collect
1236                       request at all, so calling it recorded a delivery that had
1237                       not happened, and the marker it wrote is sessionStorage:
1238                       ORIGIN-scoped, while this flag is per STORE VIEW. One tab
1239                       that passed through a GA4-only store view therefore
1240                       claimed the marker for the whole origin, and the
1241                       Collector-enabled store view it switched to next found the
1242                       event already delivered and never sent it — permanently,
1243                       for that tab, on the surface that was actually switched
1244                       on. The same defect the GA4 identity marker had, in the
1245                       other direction.
1246
1247                       GA4 is not left exposed by this: it keeps its OWN marker
1248                       (see trackOnce), claimed on gtag's load, which is what
1249                       stops a GA4-only store re-reporting view_item on every
1250                       reload. The ledger's marker never described GA4 and must
1251                       not stand in for it. */
1252                    dropped();
1253                    return;
1254                }
1255
1256                var b = base();
1257                if (!b || !w.fetch) { dropped(); return; }
1258
1259                var body = { kind: 'track', name: name };
1260                var page = safePage();
1261                if (page) { body.page = page; }
1262                var sid = sessionId();
1263                if (sid) { body.sessionId = sid; }
1264                if (props && typeof props === 'object') { body.properties = props; }
1265
1266                /* The GA4 identity this browser is reporting under, so the ledger
1267                   can key a Measurement Protocol send (a refund, a renewal, a
1268                   back-office order — things the browser never sees) to the SAME
1269                   client and session. A server event that invents a client_id
1270                   stitches to nothing: it opens a new user and a new session,
1271                   which is how a property fills with unassigned traffic. Absent
1272                   until gtag resolves them, and absent entirely when GA4 is off —
1273                   the Collector treats both as "cannot send" rather than
1274                   substituting anything. */
1275                var ga = (w.__dnaGa4 && w.__dnaGa4.ids) ? w.__dnaGa4.ids() : null;
1276                if (ga) {
1277                    body.gaClientId = ga.clientId;
1278                    body.gaSessionId = ga.sessionId;
1279                }
1280
1281                var payload = JSON.stringify(body);
1282                /* A successful sendBeacon() only acknowledges that the browser
1283                   queued bytes;
1283 it says nothing about Collector's HTTP result.
1284                   State-consuming events therefore use an acknowledged
1285                   keepalive fetch and become durable only after a 2xx. */
1286                function dispatch(canRenewSession) {
1287                    if (dnaAnalyticsDeclined()) { dropped(); return; }
1288                    var handshake = sessionReady();
1289                    handshake.then(function (ok) {
1290                        /* Consent may change while /session/init is in flight.
1291                           Re-read it at the actual disclosure boundary. */
1292                        if (!ok || dnaAnalyticsDeclined()) { dropped(); return; }
1293                        fetch(b + '/collect', {
1294                            method: 'POST', credentials: 'include', keepalive: true,
1295                            headers: { 'content-type': 'text/plain' }, body: payload
1296                        }).then(function (res) {
1297                            if (res && res.ok) { accepted(); return; }
1298                            if (res && res.status === 401 && canRenewSession) {
1299                                /* The signed session cookie expired between init
1300                                   and collect. Mint once more and retry this SAME
1301                                   event once; never spin on a persistent 401.
1302                                   Concurrent events can share the expired
1303                                   handshake, so the second 401 must not erase a
1304                                   renewal already started by the first. */
1305                                var broker = sessionBroker();
1306                                if (broker) { broker.invalidate(handshake); }
1307                                dispatch(false);
1308                                return;
1309                            }
1310                            dropped();
1311                        }, dropped);
1312                    }).catch(dropped);
1313                }
1314
1315                dispatch(true);
1316            } catch (e) { dropped(); }
1317        }
1318
1319        /* Fire a name at most once per tab. Used for the intent events that have
1320           no server-side dedup partner, so a reload must not re-report them. */
1321        var fired = {};
1322        function trackOnce(key, name, props) {
1323            try {
1324                if (fired[key]) { return; }
1325
1326                /* AN INELIGIBLE EVENT CLAIMS NOTHING.
1327                   Both markers used to be written before track() reached its
1328                   consent gate, so a product or checkout page rendered under an
1329                   analytics refusal recorded the event as ALREADY SENT while
1330                   both the ledger and GA4 dropped it. The visitor then accepted
1331                   the banner and reloaded — and the marker they had never
1332                   earned suppressed the event permanently, for the whole tab.
1333                   The refusal has to leave the page exactly as it found it. */
1334                if (dnaAnalyticsDeclined()) { return; }
1335
1336                /* TWO SURFACES, TWO DELIVERY BOUNDARIES, SO TWO MARKERS — AND
1337                   ONLY ONE IMPLEMENTATION OF EACH.
1338                   GA4 is handed the event synchronously while the ledger beacon
1339                   must first await the /session/init handshake, so one shared
1340                   marker cannot honestly describe both. Claiming on the ledger's
1341                   handoff is what stops a refusal from burning the event — but
1342                   on its own it means a failed or reload-interrupted handshake
1343                   leaves the marker unclaimed, and the retry on the next load
1344                   re-offers the event to GA4, which already had it and has no
1345                   cross-source dedup to collapse the repeat. So each surface
1346                   keeps its own marker, claimed when THAT surface actually took
1347                   the event, and neither may short-circuit for the other. The
1348                   ledger's marker is not evidence about GA4: on a GA4-only store
1349                   it is not written at all, so letting it stand in for GA4 would
1350                   suppress the one surface that exists.
1351
1352                   GA4's HALF IS ga4Once's JOB. This function used to keep a
1353                   second copy of it, and the copy was missing the one gate that
1354                   matters: ga4Once refuses to claim anything until
1355                   `__dnaGa4.isBooted()`, and this did not. A visitor who accepts
1356                   the banner AFTER ga4.phtml has rendered leaves the tag
1357                   un-booted — no `config` command has been pushed — so an event
1358                   emitted at that moment lands on the dataLayer AHEAD of the
1359                   config that would give it a destination, and gtag discards it
1360                   when it finally runs. The durable per-property marker was then
1361                   written anyway, on gtag's load, so the next page view never
1362                   re-offered it: view_item and begin_checkout silently missing
1363                   for exactly the visitors who accepted the banner.
1364
1365                   ga4Once already solves that: not booted means DEFER, and the
1366                   deferred callback runs on the granted edge, after bootIfAllowed
1367                   has pushed consent, config and page_view. It also owns the
1368                   per-tab guard and the whenLoaded claim this copy duplicated. */
1369                ga4Once(ga4OnceKey(key), name, props);
1370
1371                /* THE LEDGER MARKER CARRIES THE INGEST HOST. sessionStorage is
1372                   scoped to the ORIGIN and this estate runs several store views
1373                   on one domain; several store views normally share ONE
1374                   Collector, where the same product viewed in two currencies is
1375                   one journey step and a per-store-view key would report it
1376                   twice — but the host is store-view config, so two store views
1377                   CAN address two different ledgers and must then keep two
1378                   markers. One key per destination. (ga4Once's key carries the
1379                   MEASUREMENT ID for the same reason at the other granularity —
1380                   see ga4OnceKey.) */
1381                var marker = 'dna_fnl_' + ledgerScope() + '_' + key;
1382                var ledgerDone = false;
1383                try { ledgerDone = !!sessionStorage.getItem(marker); } catch (e) {}
1384                if (ledgerDone) { fired[key] = 1; return; }
1385
1386                /* The in-memory guard IS claimed synchronously: it is the only
1387                   thing standing between two same-tick calls (a theme that fires
1388                   its PDP hook twice) and a duplicate event, and the durable
1389                   marker cannot be written yet because nothing has been sent. */
1390                fired[key] = 1;
1391
1392                /* LEDGER-ONLY, because GA4 has already been offered the event
1393                   above; emit()'s own fan-out would make it a second copy. The
1394                   DURABLE marker is claimed only once emit() has handed the
1395                   beacon off — see emit's `onAccepted`. A beacon that never left
1396                   (dead ingest host, failed handshake) leaves sessionStorage
1397                   untouched, so the next page load tries again. */
1398                ledger(name, props, function () {
1399                    try { sessionStorage.setItem(marker, '1'); } catch (e) {}
1400                }, function () {
1401                    delete fired[key];
1402                });
1403            } catch (e) {}
1404        }
1405
1406        /* The key ga4Once keeps this event's GA4 marker under.
1407           `fnl_` separates it from the order keys purchase.phtml passes, and the
1408           MEASUREMENT ID is part of it because sessionStorage is origin-scoped
1409           while the property is per store view: a bare key let store view A's
1410           view_item suppress store view B's, and B's property silently never
1411           learned the event. */
1412        function ga4OnceKey(key) {
1413            return 'fnl_' + ((w.__dnaGa4 && w.__dnaGa4.id) ? w.__dnaGa4.id + '_' : '') + key;
1414        }
1415
1416        /* Ledger + GA4. The default for a funnel event. */
1417        function track(name, props, onAccepted, onDropped) {
1418            emit(name, props, true, onAccepted, onDropped);
1419        }
1420
1421        /* Ledger ONLY. For events with no GA4 meaning and for anything GA4
1422           already receives directly from the page, so the two systems never
1423           double-count the same action. */
1424        function ledger(name, props, onAccepted, onDropped) {
1425            emit(name, props, false, onAccepted, onDropped);
1426        }
1427
1428        /* THE GA4 IDENTITY PING — ledger-only, and the ONE event this file will
1429           send with LEDGER_ENABLED off (#653).
1430
1431           WHY IT IS EXEMPT. LEDGER_ENABLED answers "does this store view send
1432           funnel OBSERVATIONS to the Collector" — and on both production
1433           storefronts the answer is no, while the GA4 tag is on. The identity
1434           ping is not an observation: it is the plumbing that tells the ledger
1435           WHICH GA4 client and session this browser is, and it is the entire
1436           reason this file renders on a GA4-only store view (see the render
1437           gate at the top and the header comment). Sending it through ledger()
1438           meant emit() dropped it at the flag before building a body or issuing
1439           a request, so gtag resolved a real client/session id on every page and
1440           it went nowhere: `context.ga` was absent from 100% of events, and a
1441           server-side Measurement Protocol send for a refund, a renewal or a
1442           back-office order had no identity to stitch to and could not be made
1443           at all.
1444
1445           A NAMED DOOR, NOT A FLAG ON ledger(). The exemption belongs to ONE
1446           event, not to a destination, and every OTHER ledger-only caller is a
1447           funnel observation that must keep being dropped. A boolean argument on
1448           ledger() would put that decision at every call site; a door that only
1449           this event fits keeps it in one reviewable place.
1450
1451           AND THE DOOR IS THE WIDTH OF THAT ONE EVENT. Any other name is handed
1452           straight back to the ordinary gated path rather than refused, so a
1453           future caller that reaches for the wrong door gets today's behaviour
1454           (dropped while the ledger is off) and still gets exactly one of its two
1455           callbacks — a silent return would strand a caller holding an in-memory
1456           lease, which is the failure emit()'s onDropped exists to prevent.
1457
1458           NOTHING IS SYNTHESISED HERE. The ids come from gtag's own `get`
1459           command (see ga4.phtml); when they do not resolve, no ping is offered
1460           and nothing is sent. A fabricated client_id is worse than none — it
1461           mints a fresh GA4 user per hit.
1462
1463           NO CALLER PAYLOAD, AND NOT WITHOUT AN IDENTITY TO REPORT. This is a
1464           PUBLIC entry point on a store view the operator has switched the
1465           ledger OFF on, so the two things it must not become are a
1466           write-anything channel and a write-anyway channel:
1467
1468             - `props` is accepted for call-shape parity with ledger() (see the
1469               `send` indirection in ga4.phtml, which must be able to call
1470               either) and is DELIBERATELY NOT FORWARDED. The ping's whole
1471               payload is the two ids emit() attaches from gtag; there is no
1472               third thing to say. Forwarding a caller's object would let any
1473               script sharing the page — a GTM container tag, a page-builder
1474               widget, a chat embed — post arbitrary properties into a Collector
1475               that was told not to write, and ingest mirrors track properties on
1476               to Plausible as well, so it would land in two places.
1477             - The exemption additionally requires that gtag has actually
1478               RESOLVED an identity. Without ids the row carries no `context.ga`,
1479               which is the only reason this door exists — so there is nothing to
1480               exempt, and it falls through to the ordinary gated path and is
1481               dropped (retryably: onDropped fires, ga4.phtml does not claim the
1482               once-per-session marker). This is a narrowing, not a fallback:
1483               nothing is invented when the ids are missing. */
1484        var IDENTITY_EVENT = 'ga4_identity';
1485        function identity(name, props, onAccepted, onDropped) {
1486            var ids = (w.__dnaGa4 && w.__dnaGa4.ids) ? w.__dnaGa4.ids() : null;
1487            emit(name, {}, false, onAccepted, onDropped, name === IDENTITY_EVENT && !!ids);
1488        }
1489
1490        /* GA4 ONLY. For an action the ledger already holds from a trusted
1491           server-side path — a placed order arrives over the HMAC-signed binding
1492           webhook, so re-reporting it from the browser would put a second,
1493           analytics-grade copy of a money event in the ledger.
1494
1495           THE BOOT GATE IS HERE, so every GA4 fan-out in this file passes
1496           through it: emit()'s (track), the stashed-add release, and ga4Once's.
1497           `__dnaGa4.track()` checks consent and the load budget but NOT whether
1498           the tag has BOOTED, and un-booted means no `config` command has been
1499           pushed — so an event queued at that moment sits on the dataLayer AHEAD
1500           of the config that would give it a destination and gtag discards it
1501           when it finally runs. Measured side by side in one window on a visitor
1502           who accepted the banner after the tag rendered:
1503
1504             view_item  : consent, consent, js, config, page_view, event
1505             add_to_cart: consent, EVENT, consent, js, config, page_view
1506
1507           ga4Once already refused to claim anything until isBooted(); this is
1508           the same gate for the events that have no once-per-tab marker.
1509
1510           NOT BOOTED MEANS DEFER, NOT DROP. deferUntilAllowed releases on the
1511           granted edge, after bootIfAllowed has pushed consent, config and
1512           page_view — and it releases nothing once the tag has given up loading,
1513           which is the honest answer for an event that could never have arrived.
1514           The deferred copy sends DIRECTLY rather than re-entering here: it only
1515           ever runs when the tag has booted and analytics is allowed, so a second
1516           gate could only re-queue it, and one queued copy per call is the whole
1517           budget this fan-out gets. Consent is re-read at release time because a
1518           grant can be withdrawn again while the callback sits in the queue. */
1519        function ga4(name, props) {
1520            try {
1521                if (!name || dnaAnalyticsDeclined() || !w.__dnaGa4) { return; }
1522
1523                if (w.__dnaGa4.isBooted && !w.__dnaGa4.isBooted()) {
1524                    if (!w.__dnaGa4.deferUntilAllowed) { return; }
1525                    w.__dnaGa4.deferUntilAllowed(function () {
1526                        try {
1527                            if (dnaAnalyticsDeclined()) { return; }
1528                            w.__dnaGa4.track(name, props);
1529                        } catch (e) {}
1530                    });
1531
1532                    return;
1533                }
1534
1535                w.__dnaGa4.track(name, props);
1536            } catch (e) {}
1537        }
1538
1539        /* GA4-only, at most once per tab. GA4 has no server-side dedup partner,
1540           so anything a page can render twice (a reloaded order-success page)
1541           needs its own guard or the metric simply drifts.
1542
1543           THE GUARD IS CLAIMED ONLY WHEN THE EVENT CAN ACTUALLY FIRE. Marking
1544           first and emitting second is subtly wrong: on a success page loaded
1545           while analytics is declined, or before the GA4 tag has rendered, the
1546           purchase would be recorded as "already sent" and then never sent —
1547           so granting consent and reloading would find the marker and suppress
1548           it permanently. Checking the same conditions ga4() checks, before
1549           claiming the key, means a suppressed attempt stays retryable. */
1550        var ga4Deferred = {};
1551        function ga4Once(key, name, props) {
1552            try {
1553                if (!name || fired['ga4_' + key] || !w.__dnaGa4) { return; }
1554
1555                /* DECLINED IS NOT DROPPED — it is DEFERRED. A success page
1556                   renders its purchase exactly once, so a visitor who accepts
1557                   the banner while still on that page would otherwise lose that
1558                   order entirely, with no reload to rescue it. Register for the
1559                   moment consent is withdrawn-and-granted; if it never is,
1560                   nothing was recorded and nothing was sent. */
1561                if (dnaAnalyticsDeclined() || !w.__dnaGa4.isBooted || !w.__dnaGa4.isBooted()) {
1562                    /* AT MOST ONE QUEUED COPY PER KEY. A banner is a toggle a
1563                       visitor can work several times, and every denied->granted
1564                       edge re-runs this function. Without this guard an
1565                       allow->deny->allow->deny sequence would push a second and
1566                       third copy of the SAME purchase onto the queue, and the
1567                       grant that finally released it would emit the order once
1568                       per copy — GA4 has no cross-source dedup to collapse them,
1569                       so the property's revenue would simply multiply. The flag
1570                       clears inside the callback, so a re-deferral after a
1571                       flush is still possible; only a duplicate is not. */
1572                    if (!ga4Deferred[key] && w.__dnaGa4.deferUntilAllowed) {
1573                        ga4Deferred[key] = 1;
1574                        w.__dnaGa4.deferUntilAllowed(function () {
1575                            ga4Deferred[key] = 0;
1576                            ga4Once(key, name, props);
1577                        });
1578                    }
1579                    return;
1580                }
1581                /* Per-tab guard, so one page render cannot emit twice. */
1582                fired['ga4_' + key] = 1;
1583                try {
1584                    if (sessionStorage.getItem('dna_ga4_' + key)) { return; }
1585                } catch (e) {}
1586
1587                ga4(name, props);
1588
1589                /* THE DURABLE MARKER IS CLAIMED ONLY ONCE GTAG HAS RUN.
1590                   `ga4()` pushes onto dataLayer, which is just an array until
1591                   gtag.js executes — so writing the marker first means a 
1591success
1592                   page that unloads mid-download loses the purchase AND
1593                   suppresses the reload that would have retried it. Deferring
1594                   the write to gtag's own onload makes the failure case
1595                   retryable: no load, no marker, next view tries again. */
1596                var claim = function () {
1597                    try { sessionStorage.setItem('dna_ga4_' + key, '1'); } catch (e) {}
1598                };
1599                if (w.__dnaGa4.whenLoaded) { w.__dnaGa4.whenLoaded(claim); } else { claim(); }
1600            } catch (e) {}
1601        }
1602
1603        w.__dnaFunnel = {
1604            track: track,
1605            trackOnce: trackOnce,
1606            ledger: ledger,
1607            identity: identity,
1608            ga4: ga4,
1609            ga4Once: ga4Once
1610        };
1611
1612        /* ── DID THE ADD ACTUALLY HAPPEN? ─────────────────────────────────────
1613           The submit listener below is CAPTURE-phase, on purpose: an AJAX theme
1614           attaches its own handler and may preventDefault, and a bubble-phase
1615           listener can be skipped outright by stopPropagation. The cost of
1616           being first is that it runs BEFORE Magento has seen the request, let
1617           alone accepted it — so an out-of-stock product, a required option
1618           left blank, a qty above the allowed maximum, a cart rule that refuses
1619           the line, all counted as add_to_cart. Those are not rare on a
1620           storefront; they are what the validation exists for. The funnel's
1621           widest step was inflated by exactly the customers who failed to take
1622           it.
1623
1624           WHAT CAN BE OBSERVED FROM HERE. Magento's customer-data `cart`
1625           section, kept in localStorage under `mage-cache-storage`, is refreshed
1626           by BOTH paths: an AJAX add refreshes it in place, a full-page-post add
1627           refreshes it on the next page. Nothing else on the page distinguishes
1628           an accepted add from a refused one without knowing a theme's markup.
1629
1630           SO THE EVENT IS STASHED, NOT SENT. The submitted product's visible
1631           quantity is captured with it, and a later customer-data snapshot must
1632           contain enough additional quantity to pay for that specific attempt.
1633           The stash outlives the page (sessionStorage), because the native form
1634           post navigates away before any answer arrives — the next page confirms
1635           it.
1636
1637           ONE OBSERVED DELTA CAN BE SPENT ONCE. Two submits captured against
1638           quantity zero do not both become true when the cart reaches one: the
1639           first pending attempt consumes that unit and the second is rebased to
1640           one. A different product's success has no quantity to spend here and
1641           therefore cannot release a rejected attempt. The cart section is still
1642           state rather than a receipt, so two same-product operations cannot be
1643           attributed to their individual HTTP responses. The allocator therefore
1644           requires an exact subset of requested quantities for each observed
1645           baseline interval. Equivalent payload subsets are interchangeable;
1646           materially different allocations fail closed instead of guessing.
1647
1648           FAIL CLOSED WHEN THE PRODUCT QUANTITIES ARE NOT OBSERVABLE. A generic
1649           cart signature, summary count, subtotal, refresh id or timestamp can
1650           prove only that *something* changed. Sending on that basis recreates
1651           the false-positive path this confirmation exists to remove. */
1652        /* ONE KEY PER PAIR OF DESTINATIONS, the same rule every other durable key
1653           in this file follows (dna_fnl_<ingestHost>_,
1654           dna_fnl_ga4_<measurementId>_, dna_ga4_ident_<measurementId>). A pending
1655           add goes to BOTH the ledger and GA4, so ledger scope alone is not its
1656           identity: same-origin store views can share a Collector while targeting
1657           different GA4 properties. Releasing store A's entry on store B otherwise
1658           sends A's product/value/currency to B's property; if GA4 is off on B, the
1659           successful ledger handoff drops A's only GA4 copy. The verified measurement
1660           id — or the explicit `off` state — keeps those queues separate.
1661
1662           RESOLVED AT USE, NOT AT LOAD. ledgerScope() reads the
1663           `dna-ingest-host` meta tag, and this file runs from head.additional
1664           while that tag may be emitted later in the same <head>; a key frozen
1665           at IIFE time could then name a different ledger from the one the
1666           beacon is actually sent to. */
1667        function addPendingKey() {
1668            return 'dna_add_pending_' + ledgerScope() + '_ga4_' + GA4_DESTINATION;
1669        }
1670        /* Unconfirmed after this long, an add is treated as REFUSED and dropped.
1671           Short on purpose: it is also the window in which a later cart change
1672           could release an earlier failed add. */
1673        var ADD_TTL_MS = 60000;
1674        var MAX_UNCONFIRMED_ADDS = 10;
1675        var MAX_CONFIRMED_ADDS = 100;
1676        var addTimer = null;
1677
1678        /* ── THE CONFIRMATION SOURCE: COMPLETE PER-PRODUCT QUANTITIES ─────────
1679           Magento's customer-data cart carries every visible quote line with
1680           product_id and qty. Summing by product handles repeat adds even when
1681           the admin renders summary_count as a count of lines rather than units.
1682
1683           Every line must be attributable. If one line has no valid product id
1684           or quantity, absence of the target id is no longer proof of zero, so
1685           the entire snapshot is unusable. An empty items array is different:
1686           it is a complete observation that every product quantity is zero. */
1687        function cartQuantities() {
1688            try {
1689                var raw = w.localStorage ? w.localStorage.getItem('mage-cache-storage') : null;
1690                if (!raw) { return null; }
1691                var cart = JSON.parse(raw).cart;
1692                if (!cart) { return null; }
1693
1694                var items = cart.items;
1695                if (!items || items.length === undefined) { return null; }
1696
1697                var byProduct = {};
1698                for (var i = 0; i < items.length; i++) {
1699                    var item = items[i] || {};
1700                    var productId = Number(item.product_id);
1701                    var quantity = Number(item.qty);
1702                    if (!isFinite(productId) || productId <= 0
1703                        || !isFinite(quantity) || quantity < 0) {
1704                        return null;
1705                    }
1706
1707                    var key = String(productId);
1708                    byProduct[key] = (byProduct[key] || 0) + quantity;
1709                }
1710
1711                return byProduct;
1712            } catch (e) { return null; }
1713        }
1714
1715        function readPending() {
1716            try {
1717                var raw = sessionStorage.getItem(addPendingKey());
1718                var list = raw ? JSON.parse(raw) : [];
1719                return Object.prototype.toString.call(list) === '[object Array]' ? list : [];
1720            } catch (e) { return []; }
1721        }
1722
1723        function writePending(list) {
1724            try {
1725                var key = addPendingKey();
1726                if (!list.length) {
1727                    sessionStorage.removeItem(key);
1728                    return;
1729                }
1730                /* Unconfirmed form attempts have a short correlation window.
1731                   Once an exact cart delta proves an add, it is a delivery row:
1732                   retain it independently for this tab/session until Collector
1733                   acknowledges it. The separate bound prevents an outage from
1734                   exhausting sessionStorage without letting later attempts evict
1735                   the first ten confirmed events immediately. */
1736                var keep = [];
1737                var unconfirmed = MAX_UNCONFIRMED_ADDS;
1738                var confirmed = MAX_CONFIRMED_ADDS;
1739                for (var i = list.length - 1; i >= 0; i--) {
1740                    var entry = list[i];
1741                    if (entry && entry.confirmed) {
1742                        if (confirmed-- > 0) { keep.unshift(entry); }
1743                    } else if (unconfirmed-- > 0) {
1744                        keep.unshift(entry);
1745                    }
1746                }
1747                sessionStorage.setItem(key, JSON.stringify(keep));
1748            } catch (e) {}
1749        }
1750
1751        /* `addTimer` HOLDS A HANDLE ONLY WHILE A WATCHER IS ACTUALLY RUNNING —
1752           that is the invariant startAddWatch() checks, and it is why the field
1753           is cleared AFTER clearInterval rather than before.
1754
1755           THE OPPOSITE OF ga4.phtml's teardownConsentObservers, deliberately.
1756           That one nulls `consentPoll` first, so that a clearInterval which
1757           throws still leaves a second teardown pass a no-op — safe there
1758           because the poll is started once and torn down once, on a terminal
1759           one-way path that can never re-arm it. This watcher is started and
1760           stopped repeatedly for the life of the page. Nulling first would mean
1761           a clearInterval that threw left the field empty while its interval was
1762           still live, and the next add would start a SECOND 2Hz watcher on top
1763           of it. Keeping the handle is the honest record: the timer was not
1764           cleared, so it is not gone. */
1765
1766        function deferPending(key) {
1767            try {
1768                var list = readPending();
1769                for (var i = 0; i < list.length; i++) {
1770                    if (!list[i] || entryKey(list[i]) !== key) { continue; }
1771                    var attempts = parseInt(list[i].deliveryAttempts || '0', 10) + 1;
1772                    list[i].deliveryAttempts = attempts;
1773                    list[i].nextDeliveryAt = Date.now()
1774                        + Math.min(30000, 1000 * Math.pow(2, Math.min(attempts - 1, 5)));
1775                    break;
1776                }
1777                writePending(list);
1778            } catch (e) {}
1779        }
1780
1781        function stopAddWatch() {
1782            if (addTimer === null) { return; }
1783            try {
1784                clearInterval(addTimer);
1785            } catch (e) {
1786                /* Still running, so still ours to hold. */
1787                return;
1788            }
1789            addTimer = null;
1790        }
1791
1792        function startAddWatch() {
1793            try { if (addTimer === null) { addTimer = setInterval(confirmAdds, 500); } } catch (e) {}
1794        }
1795
1796        /* ── A CONFIRMED ADD IS HELD UNTIL ITS BEACON HAS LEFT ────────────────
1797           The entry used to be deleted in the same pass that emitted it
1798           (`track(...); continue;`), which threw away the delivery boundary
1799           this file had just been given. emit() reports back through
1800           onAccepted / onDropped and track() forwards both, and a DROP is not
1801           rare here: a release landing inside the /session/init cooldown is
1802           answered false without a request, and the add was gone — though a
1803           mint five seconds later would have delivered it.
1804
1805           So a confirmed entry stays in the stash until Collector acknowledges
1806           a 2xx response, and a drop schedules it for another attempt.
1807           `releasing` stops a tick that lands mid-flight from sending a
1808           second copy; it is in memory only, so a page that unloads with a
1809           request in flight forgets the claim and the stash it left behind is
1810           re-offered on the next page — a possible repeat in place of a silent
1811           loss, the same trade this file takes everywhere else.
1812
1813           THE ONE-MINUTE TTL APPLIES ONLY BEFORE CONFIRMATION. Once Magento's
1814           cart proves the add, dropping it because Collector is temporarily
1815           unavailable would turn a known event into permanent data loss. A
1816           confirmed row therefore survives the correlation window and retries
1817           with bounded exponential backoff (up to 30 seconds). The separate
1818           confirmed-row cap bounds sessionStorage during a prolonged outage. */
1819        var releasing = {};
1820
1821        /* GA4 ALREADY HAD THIS ADD, AS THIS PAGE KNOWS IT.
1822           The durable half of the same claim is `entry.g`, and `entry.g` is only
1823           durable if writePending() SUCCEEDS. sessionStorage.setItem throws on a
1824           full quota while getItem and removeItem keep working, so the stash can
1825           be readable and unwritable at once — and with a Collector request that
1826           keeps being dropped, every watcher tick could otherwise re-read an
1827           entry with no `g` and push add_to_cart again. GA4 has no cross-source
1828           deduplication to collapse those copies.
1829
1830           This is the copy of the claim that cannot fail to be written. A reload
1831           still re-offers an add whose durable stamp never landed — one repeat
1832           rather than a silent loss, the same trade `releasing` takes — but no
1833           page can push the same stashed add to GA4 twice. */
1834        var ga4Fanned = {};
1835
1836        /* An entry's identity, for as long as this page can see it. `k` is
1837           minted at stash time; the content signature is the fallback for an
1838           entry a previous deploy stashed without one. */
1839        function entryKey(entry) {
1840            try {
1841                if (entry.k) { return String(entry.k); }
1842
1843                return JSON.stringify([entry.t, entry.c, entry.p]);
1844            } catch (e) { return null; }
1845        }
1846
1847        function dropPending(key) {
1848            var list = readPending();
1849            var keep = [];
1850            for (var i = 0; i < list.length; i++) {
1851                if (entryKey(list[i]) !== key) { keep.push(list[i]); }
1852            }
1853            writePending(keep);
1854            if (!keep.length) { stopAddWatch(); }
1855        }
1856
1857        /* THE DURABLE HALF OF "GA4 HAS THIS ADD", STAMPED WHEN GTAG HAS RUN.
1858           ga4() pushes onto dataLayer, which is an array until gtag.js executes
1859           — so recording the claim at push time meant a page that unloaded
1860           before the download finished had `g` on an add GA4 never received, and
1861           the next page would not re-offer it. This is the same boundary
1862           ga4Once() uses for its own marker: no load, no stamp, next page tries
1863           again.
1864
1865           IT RE-READS THE STASH RATHER THAN MUTATING THE ENTRY IT WAS GIVEN,
1866           because it runs later than the pass that wrote it and the entry may by
1867           then have been delivered and dropped. Stamping a list it re-read
1868           cannot resurrect a dropped entry: it only ever sets a flag on rows
1869           that are still there. */
1870        function claimGa4(key) {
1871            var stamp = function () {
1872                try {
1873                    var list = readPending();
1874                    var changed = false;
1875                    for (var i = 0; i < list.length; i++) {
1876                        if (entryKey(list[i]) === key && !list[i].g) {
1877                            list[i].g = 1;
1878                            changed = true;
1879                        }
1880                    }
1881                    if (changed) { writePending(list); }
1882                } catch (e) {}
1883            };
1884            try {
1885                if (w.__dnaGa4 && w.__dnaGa4.whenLoaded) { w.__dnaGa4.whenLoaded(stamp); return; }
1886            } catch (e) {}
1887            stamp();
1888        }
1889
1890        /* GA4 IS FANNED OUT EXACTLY ONCE PER STASHED ADD, and the ledger beacon
1891           is retried on its own. Going back through track() would re-push
1892           add_to_cart to GA4 on EVERY ledger retry, and GA4 has no cross-source
1893           dedup to collapse the copies, so
1894           the property's add_to_cart count would simply multiply. `g` records
1895           that GA4 has been handed the event, is set only when GA4 was actually
1896           reachable (a tag on the page, and no refusal), and is persisted 
1896with
1897           the entry so a reload does not re-push it either — backed on this page
1898           by `ga4Fanned`, which is the copy of that decision storage cannot
1899           lose. Same two-surfaces-two-claims rule as trackOnce. */
1900        function releaseAdd(entry, key) {
1901            releasing[key] = 1;
1902            if (!LEDGER_ENABLED) {
1903                /* NOTHING LEFT TO DELIVER. The stash holds an add until the
1904                   surfaces that exist have had it; with the ledger off, GA4 is
1905                   the only one and it just did. Retrying would retry nothing. */
1906                delete releasing[key];
1907                dropPending(key);
1908                return;
1909            }
1910            ledger('cart_item_added', entry.p, function () {
1911                delete releasing[key];
1912                dropPending(key);
1913            }, function () {
1914                delete releasing[key];
1915                deferPending(key);
1916            });
1917        }
1918
1919        /* Release what the cart confirms, drop what it never confirmed. Runs
1920           twice a second while anything is pending — and stops the moment
1921           nothing is, so a page left open costs nothing.
1922
1923           Confirmation is DURABLE independently of delivery. A confirmed entry
1924           can remain while /session/init or /collect retries; `confirmed` keeps
1925           that delivery eligible even if the customer removes the product in the
1926           meantime, while productQty on later entries prevents them from spending
1927           the same observed increment. */
1928        var ADD_QTY_EPSILON = 1e-9;
1929
1930        function requestedAddQty(entry) {
1931            var quantity = Number(entry && entry.p && entry.p.quantity);
1932            return isFinite(quantity) && quantity > 0 ? quantity : null;
1933        }
1934
1935        /* Two allocations that emit the same payload multiset are equivalent:
1936           choosing the oldest of two identical quantity-one attempts preserves
1937           the only observable answer (one accepted add). Different payloads are
1938           materially ambiguous and must not be guessed. */
1939        function allocationSignature(entries, mask) {
1940            try {
1941                var payloads = [];
1942                for (var i = 0; i < entries.length; i++) {
1943                    if (mask & (1 << i)) { payloads.push(JSON.stringify(entries[i].entry.p)); }
1944                }
1945                payloads.sort();
1946                return JSON.stringify(payloads);
1947            } catch (e) { return null; }
1948        }
1949
1950        /* Find a subset whose requested quantities explain the WHOLE observed
1951           delta. The queue is capped at ten entries, so exhaustive search is at
1952           most 1023 candidates and buys a crucial guarantee: a rejected qty-1
1953           attempt cannot steal one unit from a later accepted qty-2 attempt when
1954           the cart lands at exactly two.
1955
1956           More than one materially different subset means the cart state cannot
1957           identify what succeeded. Fail closed there; a later quantity snapshot
1958           may make the allocation unambiguous before the TTL expires. */
1959        function exactAllocation(entries, delta) {
1960            var matches = [];
1961            var limit = 1 << entries.length;
1962            for (var mask = 1; mask < limit; mask++) {
1963                var total = 0;
1964                for (var i = 0; i < entries.length; i++) {
1965                    if (mask & (1 << i)) { total += entries[i].quantity; }
1966                }
1967                if (Math.abs(total - delta) <= ADD_QTY_EPSILON) {
1968                    matches.push({ mask: mask, signature: allocationSignature(entries, mask) });
1969                }
1970            }
1971            if (!matches.length) { return null; }
1972
1973            var signature = matches[0].signature;
1974            for (var m = 1; m < matches.length; m++) {
1975                if (signature === null || matches[m].signature !== signature) { return null; }
1976            }
1977
1978            var selected = {};
1979            for (var j = 0; j < entries.length; j++) {
1980                if (matches[0].mask & (1 << j)) { selected[entries[j].index] = 1; }
1981            }
1982            return selected;
1983        }
1984
1985        /* Split a product's pending queue into baseline intervals. An attempt
1986           captured at quantity zero can only spend the delta up to the next
1987           observed baseline; an attempt captured later at quantity one owns the
1988           interval after that. Within each interval, confirm only an exact,
1989           unambiguous subset and rebase every unselected entry behind the delta
1990           that was consumed. */
1991        function confirmationPlan(list, current, now) {
1992            var plan = { selected: {}, rebased: {} };
1993            if (!current) { return plan; }
1994
1995            var products = {};
1996            for (var i = 0; i < list.length; i++) {
1997                var entry = list[i];
1998                var productId = Number(entry && entry.p && entry.p.product_id);
1999                var baseline = entry && entry.productQty;
2000                var quantity = requestedAddQty(entry);
2001                if (!entry || !entry.p || entry.confirmed
2002                    || !isFinite(productId) || productId <= 0 || quantity === null
2003                    || now - (entry.t || 0) > ADD_TTL_MS
2004                    || baseline === null || baseline === '' || !isFinite(Number(baseline))) {
2005                    continue;
2006                }
2007
2008                baseline = Number(baseline);
2009                if (baseline < 0) { continue; }
2010                var productKey = String(productId);
2011                var baselineKey = String(baseline);
2012                if (!products[productKey]) { products[productKey] = {}; }
2013                if (!products[productKey][baselineKey]) { products[productKey][baselineKey] = []; }
2014                products[productKey][baselineKey].push({
2015                    entry: entry,
2016                    index: i,
2017                    quantity: quantity
2018                });
2019            }
2020
2021            for (var productKey in products) {
2022                if (!Object.prototype.hasOwnProperty.call(products, productKey)) { continue; }
2023                var groups = products[productKey];
2024                var baselines = [];
2025                for (var baselineKey in groups) {
2026                    if (Object.prototype.hasOwnProperty.call(groups, baselineKey)) {
2027                        baselines.push(Number(baselineKey));
2028                    }
2029                }
2030                baselines.sort(function (a, b) { return a - b; });
2031
2032                var observed = current[productKey] || 0;
2033                for (var b = 0; b < baselines.length; b++) {
2034                    var baseline = baselines[b];
2035                    var ceiling = observed;
2036                    if (b + 1 < baselines.length) {
2037                        ceiling = Math.min(ceiling, baselines[b + 1]);
2038                    }
2039                    var delta = ceiling - baseline;
2040                    if (delta <= ADD_QTY_EPSILON) { continue; }
2041
2042                    var entries = groups[String(baseline)];
2043                    var selected = exactAllocation(entries, delta);
2044                    if (!selected) {
2045                        /* A later captured baseline closes this interval. Its
2046                           delta has already happened and was not attributable,
2047                           so carry none of it forward after that later group is
2048                           removed. The final/open interval is left untouched so
2049                           a subsequent snapshot can still make it exact. */
2050                        if (b + 1 < baselines.length
2051                            && observed + ADD_QTY_EPSILON >= baselines[b + 1]) {
2052                            for (var skipped = 0; skipped < entries.length; skipped++) {
2053                                plan.rebased[entries[skipped].index] = ceiling;
2054                            }
2055                        }
2056                        continue;
2057                    }
2058
2059                    for (var e = 0; e < entries.length; e++) {
2060                        var index = entries[e].index;
2061                        if (selected[index]) {
2062                            plan.selected[index] = 1;
2063                        } else {
2064                            plan.rebased[index] = ceiling;
2065                        }
2066                    }
2067                }
2068            }
2069
2070            return plan;
2071        }
2072
2073        function confirmAdds() {
2074            try {
2075                var list = readPending();
2076                if (!list.length) { stopAddWatch(); return; }
2077
2078                var current = cartQuantities();
2079                var now = Date.now();
2080                var keep = [];
2081                var release = [];
2082                var fanOut = [];
2083                var plan = confirmationPlan(list, current, now);
2084                for (var i = 0; i < list.length; i++) {
2085                    var entry = list[i];
2086                    var quantity = requestedAddQty(entry);
2087                    var key = entryKey(entry);
2088                    if (!entry || !entry.p || quantity === null
2089                        || (!entry.confirmed && now - (entry.t || 0) > ADD_TTL_MS)) {
2090                        if (key !== null) { delete releasing[key]; }
2091                        continue;
2092                    }
2093
2094                    if (!entry.confirmed) {
2095                        if (!plan.selected[i]) {
2096                            if (plan.rebased[i] !== undefined) {
2097                                entry.productQty = plan.rebased[i];
2098                            }
2099                            keep.push(entry);
2100                            continue;
2101                        }
2102                        entry.confirmed = true;
2103                        entry.confirmedAt = now;
2104                        entry.deliveryAttempts = 0;
2105                        entry.nextDeliveryAt = 0;
2106                    }
2107
2108                    key = entryKey(entry);
2109                    if (key === null) { continue; }
2110                    if (!entry.g && !ga4Fanned[key] && w.__dnaGa4 && !dnaAnalyticsDeclined()) {
2111                        /* Claimed in memory NOW — this is the guard that holds
2112                           when the durable one cannot be written — and emitted
2113                           below, once the stash has been written. */
2114                        ga4Fanned[key] = 1;
2115                        fanOut.push([entry, key]);
2116                    }
2117                    keep.push(entry);
2118                    if (Number(entry.nextDeliveryAt || 0) > now || releasing[key]) {
2119                        continue;
2120                    }
2121                    release.push([entry, key]);
2122                }
2123                writePending(keep);
2124                if (!keep.length) { stopAddWatch(); return; }
2125                /* AFTER the stash is written, so claimGa4()'s stamp — which may
2126                   run synchronously, on a page where gtag.js has already loaded
2127                   — lands on the list this pass just persisted rather than being
2128                   overwritten by it. */
2129                for (var f = 0; f < fanOut.length; f++) {
2130                    ga4('cart_item_added', fanOut[f][0].p);
2131                    claimGa4(fanOut[f][1]);
2132                }
2133                for (var j = 0; j < release.length; j++) {
2134                    releaseAdd(release[j][0], release[j][1]);
2135                }
2136            } catch (e) {}
2137        }
2138
2139        function pendAdd(props) {
2140            try {
2141                /* A REFUSAL HAS TO LEAVE THE PAGE EXACTLY AS IT FOUND IT — the
2142                   same posture trackOnce takes a few hundred lines up, and the
2143                   posture the pre-stash code took here by accident: it called
2144                   track(), which returned at its own consent gate having stored
2145                   nothing at all.
2146
2147                   Stashing first and gating at emit() changed that. Under a
2148                   declined banner every add-to-cart now wrote the visitor's sku,
2149                   product id, quantity, price and currency into sessionStorage
2150                   and started a 2Hz timer to watch their cart — a record of what
2151                   they were buying, kept on a page that had been told not to
2152                   observe them, for as long as the tab stayed open. Nothing was
2153                   ever sent, which is not the same thing as nothing having
2154                   happened.
2155
2156                   THIS IS NOT A CONSENT-POLICY CHANGE and does not touch the
2157                   deferred consent work: it is a storage side effect this diff
2158                   introduced, removed. */
2159                if (dnaAnalyticsDeclined()) { return; }
2160
2161                var baseline = cartQuantities();
2162                if (baseline === null) { return; }
2163
2164                var productId = Number(props.product_id);
2165                if (!isFinite(productId) || productId <= 0) { return; }
2166                var productQty = baseline[String(productId)] || 0;
2167                var list = readPending();
2168                /* `k` is the entry's handle for the release boundary above: two
2169                   adds of the same product against the same cart in the same
2170                   millisecond are two events, and a content signature would
2171                   collapse them into one. */
2172                list.push({
2173                    p: props,
2174                    productQty: productQty,
2175                    t: Date.now(),
2176                    k: String(Date.now()) + '-' + Math.random().toString(36).slice(2)
2177                });
2178                writePending(list);
2179                startAddWatch();
2180            } catch (e) {}
2181        }
2182
2183        /* ── WHAT WAS ADDED, NOT WHAT THE PAGE IS ABOUT ───────────────────────
2184           A form says `super_attribute[93]=61`: an attribute id and an option
2185           value id. Nothing in it names a sku or a price, so the listener used
2186           to report the PDP's PARENT — and on a configurable that is a
2187           different product from the one that reaches the order (`SHIRT` in
2188           add_to_cart, `SHIRT-L-BLUE` in purchase) at the parent's opening
2189           price rather than the variant's. One product under two identities
2190           across the funnel, which is exactly the split itemsOf()'s comment in
2191           ga4.phtml says must never happen: the add-to-cart to purchase item
2192           funnel did not join up, and the reported cart value was whatever the
2193           cheapest variant cost.
2194
2195           ViewModel\FunnelEvents publishes the mapping the catalogue has and
2196           the form does not (see getProductContext), so the selection can be
2197           resolved here. WHERE IT CANNOT BE, THE IDENTITY IS OMITTED — never
2198           the parent's: a sku that names the wrong product is a number nothing
2199           downstream can question.
2200
2201           ── WHAT THE PAIR (product_id, sku) MEANS, EXACTLY ────────────────────
2202           They do NOT always name the same product, and that is deliberate:
2203           they name what `sales_order_item` names, so an add and the purchase it
2204           becomes can be joined on either field.
2205
2206             simple / bundle / downloadable   both the submitted product.
2207             grouped / dna_grouped            both the CHILD; the composite
2208                                              parent is never a line at all.
2209             configurable                     product_id is the PARENT and sku is
2210                                              the VARIANT — because that is the
2211                                              order row. Magento writes the
2212                                              configurable's id into
2213                                              sales_order_item.product_id while
2214                                              Type\Configurable::getSku() returns
2215                                              the chosen simple's sku (it reads
2216                                              the `simple_product` custom option),
2217                                              and Ga4Purchase reports that parent
2218                                              row. Publishing the variant's own id
2219                                              here would look tidier and would
2220                                              stop matching the books.
2221
2222           SO A BARE product_id IS NOT A SKU WAITING TO BE LOOKED UP. On an
2223           unresolved configurable selection the id names the parent, and
2224           resolving it to a sku downstream would produce `SHIRT` against an
2225           order that says `SHIRT-L-BLUE` — reintroducing, one system later,
2226           exactly the split this function exists to prevent. An earlier version
2227           of this comment said the ledger "can fill the gap later"; it cannot,
2228           not from the id alone. The line travels as a countable event with no
2229           product identity, and that is the whole of the claim. */
2230
2231        /* The `attributeId:valueId` key the variant map is built on: sorted, so
2232           a theme's field order cannot decide whether a lookup hits. Null if any
2233           configurable attribute is unanswered — a selection that is not a whole
2234           selection resolves to no variant, and Magento would refuse it anyway. */
2235        function superAttributeKey(form) {
2236            try {
2237                var fields = form.querySelectorAll('[name^="super_attribute["]');
2238                var pairs = [];
2239                for (var i = 0; i < fields.length; i++) {
2240                    var m = /^super_attribute\[(\d+)\]$/.exec(fields[i].name || '');
2241                    if (!m) { continue; }
2242                    var v = fields[i].value;
2243                    if (v === '' || v === null || v === undefined) { return null; }
2244                    pairs.push(m[1] + ':' + String(v));
2245                }
2246                if (!pairs.length) { return null; }
2247                pairs.sort();
2248                return pairs.join('|');
2249            } catch (e) { return null; }
2250        }
2251
2252        /* Is any field of this name shape present with a value? Used to detect
2253           the option shapes whose PRICE cannot be resolved here (a paid custom
2254           option, a separately-priced downloadable link). */
2255        function hasSelected(form, selector, pattern) {
2256            try {
2257                var fields = form.querySelectorAll(selector);
2258                for (var i = 0; i < fields.length; i++) {
2259                    var f = fields[i];
2260                    if (pattern && !pattern.test(f.name || '')) { continue; }
2261                    if (f.type === 'checkbox' || f.type === 'radio') {
2262                        if (f.checked) { return true; }
2263                        continue;
2264                    }
2265                    if (f.value !== '' && f.value !== null && f.value !== undefined) { return true; }
2266                }
2267                return false;
2268            } catch (e) { return false; }
2269        }
2270
2271        /* ── DOES THIS COMPOSITE CHILD STAND FOR ITSELF, OR FOR A VARIANT? ────
2272           A core grouped product's children are simple, and the published sku is
2273           what the order records. `dna_grouped` accepts a CONFIGURABLE child:
2274           DnaGrouped::_prepareProduct() reads
2275           super_attribute[<childId>][<attrId>] and adds the simple that
2276           selection names, so the order gets the VARIANT's sku while the
2277           published row names its parent. Reporting that row would be the same
2278           split the configurable branch avoids, one level down.
2279
2280           TWO WITNESSES, EITHER OF WHICH IS ENOUGH. The published `type` says
2281           what the catalogue thinks the child is; the form says what was
2282           actually submitted for it. A page whose context predates the type
2283           being published still answers correctly from the form, and a child
2284           flagged configurable is refused even if its select never rendered.
2285
2286           RESOLVING THOSE VARIANTS IS DELIBERATELY NOT MODELLED. It would mean
2287           publishing a variant map per child — a catalogue dump on a page that
2288           already carries several products — and the answer would still be
2289           omitted for anything unmapped. The identity is dropped instead and the
2290           product id is kept, which is the same posture this file takes for
2291           every shape it cannot resolve. */
2292        function resolvesToVariant(form, childId, child) {
2293            if (child && child.type === 'configurable') { return true; }
2294            try {
2295                /* The name is TWO-LEVEL here — super_attribute[<childId>][<attrId>]
2296                   — which the single-level parse in superAttributeKey() cannot
2297                   match at all, so it saw nothing to warn about. `childId` came
2298                   from a \d+ capture, so a plain prefix test is exact. */
2299                var prefix = 'super_attribute[' + childId + '][';
2300                var fields = form.querySelectorAll('[name^="super_attribute["]');
2301                for (var i = 0; i < fields.length; i++) {
2302                    if (String(fields[i].name || '').indexOf(prefix) === 0) { return true; }
2303                }
2304            } catch (e) {}
2305
2306            return false;
2307        }
2308
2309        /* Is the published PDP context about the product this form submits?
2310           Both sides are compared as NUMBERS: the published id is an int from
2311           PHP and the submitted one is parseInt'd out of a form value, and a
2312           string/number mismatch would silently disqualify every PDP. */
2313        function describesSubmitted(pdp, base) {
2314            var published = Number(pdp.product_id);
2315            var submitted = Number(base.product_id);
2316
2317            return isFinite(published) && published > 0 && published === submitted;
2318        }
2319
2320        /* One submitted form -> the lines it actually adds. Usually one; a
2321           grouped product adds one per selected child, which is what the order
2322           will record. */
2323        function resolveAdds(form, base) {
2324            var pdp = w.__dnaFunnelProduct;
2325            /* A CATEGORY LISTING has no PDP context at all: product_id only,
2326               exactly as before. */
2327            if (!pdp) { return [base]; }
2328
2329            /* ── AND THE CONTEXT HAS TO DESCRIBE THE FORM THAT WAS SUBMITTED ──
2330               `__dnaFunnelProduct` is the PDP's MAIN product, and it was read
2331               unconditionally — for whatever add-to-cart form happened to be
2332               submitted on the page. A PDP carries several: this estate's b2c
2333               theme MOVES catalog.product.related and product.info.upsell into
2334               the product-details wrapper (Dna/b2c-theme
2335               Magento_Catalog/layout/catalog_product_view.xml:46-47) and
2336               list/items.phtml renders a real add-to-cart form, with its own
2337               <input name="product">, for every one of them.
2338
2339               So adding a related product reported the related product's ID
2340               with the MAIN product's sku, name, value and currency:
2341
2342                   {"product_id":999,"sku":"NMN-500","value":59,"currency":"GBP"}
2343
2344               — one line under two products' identities, which is precisely the
2345               split itemsOf()'s comment in ga4.phtml forbids and which the whole
2346               of getProductContext() exists to prevent.
2347
2348               THE CHECK IS ONE COMPARISON, because getProductContext() already
2349               publishes `product_id` for exactly this purpose. A form with no
2350               product field at all cannot be confirmed either, so it takes the
2351               same branch — omitting an identity is a gap the ledger can fill
2352               from the id later, while a sku naming the wrong product is a
2353               number nothing downstream can question. */
2354            if (!describesSubmitted(pdp, base)) { return [base]; }
2355
2356            var type = pdp.type || '';
2357
2358            /* ── WHERE THE PUBLISHED PRICE IS NO LONGER THE LINE'S ────────────
2359               Each of these adds money to the line that only the server can work
2360               out — a percentage-priced bundle selection, a separately priced
2361               download, a paid custom option. The SKU is unaffected 
2361in every
2362               case, so the identity stays and only the value goes: a line with
2363               no value is a line the reports can still count.
2364
2365               COMPUTED BEFORE THE COMPOSITE BRANCH, because a paid custom option
2366               on a grouped or dna_grouped parent is copied onto EVERY child line
2367               (DnaGrouped::_prepareProduct forwards the parent's option_ids), so
2368               the child prices stop describing their lines exactly as a simple
2369               product's would. */
2370            var priced = false;
2371            /* Even a Magento fixed-price bundle can add fixed/percentage
2372               selection prices, so only a server-calculated configuration
2373               total would be safe. */
2374            if (type === 'bundle') { priced = true; }
2375            if (pdp.links_priced && hasSelected(form, '[name="links[]"]')) { priced = true; }
2376            if (pdp.options_priced && hasSelected(form, '[name^="options["]')) { priced = true; }
2377
2378            if (type === 'grouped' || type === 'dna_grouped') {
2379                /* The grouped PARENT is not purchasable and never appears on an
2380                   order — reporting it was reporting a product nobody can buy.
2381                   Each selected child is its own line.
2382
2383                   `dna_grouped` IS THIS ESTATE'S OWN COMPOSITE TYPE
2384                   (DoNotAge_GroupedProducts registers it composite=true, with a
2385                   PDP handle of its own). It submits the same
2386                   super_group[<childId>] quantities, and
2387                   DnaGrouped::_prepareProduct() builds a buy request per child,
2388                   so the parent is no more purchasable than a core grouped one.
2389                   It used to fall through to the simple branch below and report
2390                   the pack's sku, name and price against an order that carries N
2391                   child lines and no pack at all. */
2392                var out = [];
2393                try {
2394                    var fields = form.querySelectorAll('[name^="super_group["]');
2395                    for (var i = 0; i < fields.length; i++) {
2396                        var m = /^super_group\[(\d+)\]$/.exec(fields[i].name || '');
2397                        if (!m) { continue; }
2398                        var qty = Number(fields[i].value);
2399                        if (!isFinite(qty) || qty <= 0) { continue; }
2400                        var line = { product_id: parseInt(m[1], 10) || undefined, quantity: qty };
2401                        var child = pdp.children ? pdp.children[m[1]] : null;
2402                        /* A CHILD WHOSE OWN LINE IS A VARIANT CANNOT BE NAMED
2403                           FROM HERE — see resolvesToVariant. The id still
2404                           travels; the sku would be the variant's parent, which
2405                           is the split this whole function exists to avoid. */
2406                        if (child && !resolvesToVariant(form, m[1], child)) {
2407                            if (child.sku) { line.sku = child.sku; }
2408                            if (child.name) { line.name = child.name; }
2409                            if (child.value && !priced) {
2410                                line.value = child.value;
2411                                if (pdp.currency) { line.currency = pdp.currency; }
2412                            }
2413                        }
2414                        out.push(line);
2415                    }
2416                } catch (e) {}
2417                return out;
2418            }
2419
2420            var props = base;
2421
2422            if (type === 'configurable') {
2423                var variant = null;
2424                var key = superAttributeKey(form);
2425                if (key && pdp.variants) {
2426                    variant = Object.prototype.hasOwnProperty.call(pdp.variants, key)
2427                        ? pdp.variants[key] : null;
2428                }
2429                if (!variant) {
2430                    /* Unresolvable: an incomplete selection, or a catalogue too
2431                       large to publish a map for. Report the id and nothing
2432                       else — never the parent's sku or price. */
2433                    return [props];
2434                }
2435                props.sku = variant.sku;
2436                if (variant.name) { props.name = variant.name; }
2437                if (variant.value) {
2438                    props.value = variant.value;
2439                    if (pdp.currency) { props.currency = pdp.currency; }
2440                } else {
2441                    delete props.value;
2442                    delete props.currency;
2443                }
2444            } else {
2445                /* Simple, virtual, bundle, downloadable: the PDP's own sku IS
2446                   the identity the order records — Ga4Purchase folds a bundle's
2447                   children into its parent row for the same reason. */
2448                if (pdp.sku) { props.sku = pdp.sku; }
2449                if (pdp.name) { props.name = pdp.name; }
2450                if (pdp.value) {
2451                    props.value = pdp.value;
2452                    if (pdp.currency) { props.currency = pdp.currency; }
2453                }
2454            }
2455
2456            /* AND NOW THE PRICE, WHERE THE PUBLISHED ONE IS NO LONGER IT — see
2457               the note where `priced` is computed, above the composite branch. */
2458            if (priced) {
2459                delete props.value;
2460                delete props.currency;
2461            }
2462
2463            return [props];
2464        }
2465
2466        /* ADD TO CART — matched on the ROUTE, not on theme markup.
2467           Magento's add-to-cart controller is checkout/cart/add, and that is
2468           stable across themes; the button's classes and data-roles are not.
2469           Capture phase because Breeze/AJAX themes attach their own submit
2470           handler and may preventDefault + post via fetch, and a bubble-phase
2471           listener can be skipped by stopPropagation.
2472           NOT once-per-tab: adding two different products is two real events. */
2473        d.addEventListener('submit', function (ev) {
2474            try {
2475                var form = ev.target;
2476                if (!form || String(form.tagName).toLowerCase() !== 'form') { return; }
2477                var action = form.action || form.getAttribute('action') || '';
2478                var path;
2479                try { path = new URL(action, location.href).pathname; } catch (e) { return; }
2480                if (!/(^|\/)checkout\/cart\/add(\/|$)/i.test(path)) { return; }
2481
2482                var props = {};
2483                try {
2484                    /* The product id is the one field every add-to-cart form
2485                       carries, on a PDP and in a category listing al
2485ike. */
2486                    var idField = form.querySelector('input[name="product"]');
2487                    if (idField && idField.value) { props.product_id = parseInt(idField.value, 10) || undefined; }
2488                    /* Number(), NOT parseInt — Magento sells fractional
2489                       quantities and this is the form boundary, the point where
2490                       the real value still exists. parseInt('0.5') is 0, which
2491                       `|| undefined` then turned into "no quantity", which the
2492                       GA4 taxonomy reports as 1; parseInt('1.5') is 1 outright.
2493                       Both under-report the line, and once the fraction is gone
2494                       here no amount of care downstream gets it back.
2495
2496                       A LOCALE-DECIMAL VALUE ('1,5' on a de_DE/fr_FR store view)
2497                       makes Number() return NaN and is DROPPED, not repaired:
2498                       the comma is genuinely ambiguous ('1,500' is 1.5 in de_DE
2499                       and 1500 in en_US), and falling back to the taxonomy's
2500                       "no quantity means 1" is a small error where guessing
2501                       wrong is a 1000x one. Magento's qty input is type=number,
2502                       whose DOM .value is dot-decimal by spec regardless of
2503                       locale, so this is the guard for a themed text input
2504                       rather than the normal path. */
2505                    var qtyField = form.querySelector('[name="qty"]');
2506                    /* Category/list add forms normally omit qty and Magento
2507                       interprets that as exactly one. A present-but-invalid field
2508                       is different: do not invent a quantity for malformed input. */
2509                    if (!qtyField) {
2510                        props.quantity = 1;
2511                    } else {
2512                        var rawQty = qtyField.value;
2513                        if (rawQty === null || rawQty === undefined
2514                            || String(rawQty).trim() === '') { return; }
2515                        var qty = Number(rawQty);
2516                        if (!isFinite(qty) || qty <= 0) { return; }
2517                        props.quantity = qty;
2518                    }
2519                } catch (e) {}
2520
2521                /* SKU, NAME AND PRICE COME FROM THE SELECTION, not from the page
2522                   — see resolveAdds, and the note there on what the pair
2523                   (product_id, sku) is claiming. In a listing there is no PDP
2524                   context and they are omitted, not faked. The currency travels
2525                   with whatever price is resolved, because a converted price
2526                   handed on without its denomination is a price a downstream
2527                   default will relabel. */
2528                var lines = resolveAdds(form, props);
2529
2530                /* STASHED, NOT SENT: released once the cart confirms the add.
2531                   See the block above for why a capture-phase listener cannot
2532                   report on its own. */
2533                for (var i = 0; i < lines.length; i++) {
2534                    if (lines[i]) { pendAdd(lines[i]); }
2535                }
2536            } catch (e) {}
2537        }, true);
2538
2539        /* A stash left behind by the page that navigated away — the native
2540           form-post path, where the answer only exists on the page after the
2541           one that asked.
2542
2543           ── AND IT IS RELEASED BY THE WATCHER, NEVER INLINE ──────────────────
2544           This used to call confirmAdds() right here, synchronously, at the end
2545           of the core's own IIFE. That is the ONE moment on the page when the
2546           GA4 tag does not exist yet: both scripts are inline in
2547           head.additional and the layout declares this block FIRST (the GA4
2548           block is `after=` it), so `window.__dnaGa4` is still undefined while
2549           this line runs. emit()'s fan-out is written as `if (toGa4 &&
2550           w.__dnaGa4)`, so it was skipped — with no queue, no defer and no
2551           marker to show for it — and the beacon went out with no gaClientId
2552           and no gaSessionId either, because those come from the same object.
2553
2554           A released add therefore reached the ledger and NEVER GA4, and
2555           confirmAdds deletes the entry in the same pass, so nothing could ever
2556           retry it. The trigger is ordinary: add to cart on an AJAX PDP, click
2557           through to checkout before the 500ms tick, and the stash is released
2558           in the next page's <head>. Measured: ledger [cart_item_added], gtag
2559           [page_view].
2560
2561           SO THE WATCHER IS THE ONLY DRIVER, rather than deferring this one
2562           call by a macrotask. One code path releases every add, on a page that
2563           has finished building itself, so the fan-out can never again see a
2564           half-constructed page — and there is no second timing primitive whose
2565           relationship to the parser has to be reasoned about (an inline script
2566           later in the same <head> is not guaranteed to have run by the first
2567           macrotask). The cost is that a release waits up to 500ms, which is
2568           nothing beside the /session/init round trip the beacon already awaits,
2569           and a page that unloads inside that window leaves the stash exactly
2570           where it was for the next page to confirm. */
2571        if (readPending().length) { startAddWatch(); }
2572    } catch (e) {}
2573})(window, document);</script>
2573<!-- /dna:collector-funnel-events -->
2574<!-- dna:collector-ga4 -->
2575<script>(function (w, d) {
2576    try {
2577        if (w.__dnaGa4) { return; }
2578
2579        var MEASUREMENT_ID = "G-PQM4QX6D89";
2580        var CURRENCY = "USD";
2581        var DEBUG = false;
2582
2583        /* A measurement id is required server-side (Ga4::canRender), so a blank
2584           one here can only mean the substitution above fell back. A tag with no
2585           property to report to must not request gtag.js — and must not claim
2586           window.__dnaGa4 either, because every caller reads that object's
2587           presence as "GA4 is a destination on this page" and would burn its
2588           once-per-tab markers on a tag that can never send. */
2589        if (!MEASUREMENT_ID) { return; }
2590
2591        function dnaAnalyticsDeclined() {
2592    var parts;
2593    try {
2594        parts = ('; ' + document.cookie).split('; dna_consent=');
2595    } catch (e) { return false; }
2596    for (var i = 1; i < parts.length; i++) {
2597        /* The try is PER COOKIE, not around the loop. decodeURIComponent throws
2598           on malformed percent-encoding, and one junk cookie must not stop the
2599           scan before it reaches a later cookie that says a:0 — that would turn
2600           an explicit refusal into a silent grant. A cookie we cannot decode is
2601           skipped; every other cookie is still examined. */
2602        try {
2603            var v = decodeURIComponent(parts[i].split(';')[0]);
2604            var a = /(?:^|,)a:(0|1)/.exec(v);
2605            if (a && a[1] === '0') { return true; }
2606        } catch (e) {}
2607    }
2608    return false;
2609}
2610
2611function dnaMarketingDeclined() {
2612    var parts;
2613    try {
2614        parts = ('; ' + document.cookie).split('; dna_consent=');
2615    } catch (e) { return false; }
2616    for (var i = 1; i < parts.length; i++) {
2617        try {
2618            var v = decodeURIComponent(parts[i].split(';')[0]);
2619            var m = /(?:^|,)m:(0|1)/.exec(v);
2620            if (m && m[1] === '0') { return true; }
2621        } catch (e) {}
2622    }
2623    return false;
2624}
2625
2626        w.dataLayer = w.dataLayer || [];
2627        function gtag() { w.dataLayer.push(arguments); }
2628
2629        /* CONSENT IS WATCHED, NOT SAMPLED ONCE.
2630           An earlier version returned here when analytics was declined, before
2631           installing any listener — so a visitor who started with a:0 and then
2632           ACCEPTED in the banner got no GA4 at all until they happened to
2633           navigate. And in the other direction, a refusal recorded mid-page went
2634           unseen until the next focus/visibility change, while gtag's own
2635           automatic and enhanced-measurement events (which never pass through
2636           __dnaGa4.track) kept flowing. Both are the banner moment itself, which
2637           is exactly when Google requires the update.
2638
2639           So: the state is DECLARED up front, WATCHED for changes, and the tag
2640           BOOTS the first time analytics is allowed — which may be now, or may
2641           be after the visitor clicks accept. */
2642        function consentState() {
2643            var analyticsDenied = dnaAnalyticsDeclined();
2644            var marketingDenied = dnaMarketingDeclined();
2645            return {
2646                analytics_storage: analyticsDenied ? 'denied' : 'granted',
2647                ad_storage: marketingDenied ? 'denied' : 'granted',
2648                ad_user_data: marketingDenied ? 'denied' : 'granted',
2649                ad_personalization: marketingDenied ? 'denied' : 'granted'
2650            };
2651        }
2652
2653        /* GOOGLE'S OWN CONSENT STATE, declared BEFORE any config command.
2654           Not loading the tag is only half a gate: once gtag runs it generates
2655           events our wrapper never sees. Consent Mode is the only switch that
2656           reaches those. It also carries the distinction our own gate ignores —
2657           a marketing-only opt-out (m:0) does not stop first-party analytics but
2658           MUST stop ad personalisation, which matters the moment this property
2659           is linked to Google Ads. */
2660        var lastConsent = consentState();
2661        gtag('consent', 'default', lastConsent);
2662
2663        /* THE CURRENT answer, re-read every time it is asked — never a cached
2664           snapshot. Consent is a TOGGLE: a decision taken after the tag booted
2665           has to be able to close it again, so every gate below keys on this
2666           rather than on `lastConsent` (a value that is only as fresh as the
2667           last poll) or on `booted` (a latch that never clears). */
2668        function analyticsAllowed() {
2669            try { return !dnaAnalyticsDeclined(); } catch (e) { return false; }
2670        }
2671
2672        var booted = false;
2673        /* Emits suppressed by a refusal that was later WITHDRAWN. A success page
2674           is the case that matters: it renders once, and if the visitor accepts
2675           the banner while still on it, nothing would otherwise replay that
2676           order's `purchase` — it would be lost unless they happened to reload. */
2677        var deferred = [];
2678
2679        /* SLICE AND CLEAR BEFORE INVOKING. A released callback may legitimately
2680           re-defer itself — consent can flip back between the flush and the
2681           callback running — and re-queueing into the array currently being
2682           iterated would make the loop grow as fast as it consumes. Taking a
2683           snapshot first means a re-defer lands in a FRESH queue, released by
2684           the next grant, and this loop always terminates. */
2685        function flushDeferred() {
2686            /* No allocation on the common path: syncConsent calls into here
2687               once a second forever, and the queue is empty almost always. */
2688            if (!deferred.length) { return; }
2689            var pending = deferred.slice();
2690            deferred.length = 0;
2691            for (var i = 0; i < pending.length; i++) {
2692                try { pending[i](); } catch (e) {}
2693            }
2694        }
2695
2696        function bootIfAllowed() {
2697            /* GATED ON CURRENT CONSENT, NOT ON `booted`.
2698               `booted` is one-way — gtag.js cannot be un-fetched — so an earlier
2699               version that returned as soon as it was set had two failure modes,
2700               both of them post-boot consent changes:
2701
2702               (1) INFINITE RECURSION on a revoke. deferUntilAllowed ran its
2703                   callback immediately "because we are booted", the callback was
2704                   ga4Once, ga4Once saw the CURRENT denial and deferred again —
2705                   synchronously, in the same tick, until the stack blew and took
2706                   the whole tag down with it.
2707               (2) A LOST RE-GRANT. Callbacks queued during a later denial were
2708                   never flushed, because the one flush was welded to the first
2709                   boot.
2710
2711               So: refuse while denied, boot at most once, and flush on EVERY
2712               denied->granted edge. syncConsent is the retry path. */
2713            if (!analyticsAllowed()) { return; }
2714            if (!booted) {
2715                booted = true;
2716                bootGtag();
2717            } else {
2718                /* A DOWNLOAD THAT FAILED IS RETRIED FROM HERE TOO, not only
2719                   from its own backoff timer. If the visitor withdrew consent
2720                   while a retry was pending, that timer fires into a denial and
2721                   declines to spend an attempt — this edge is what picks the
2722                   retry back up when they grant again. Bounded and
2723                   cooldown-gated inside retryLoad, so the per-second poll that
2724                   also lands here cannot turn it into a request loop.
2725
2726                   NOT by clearing `booted`: that would send the whole boot
2727                   through again and queue a second config and a second
2728                   page_view for whichever attempt eventually lands. Only the
2729                   download repeats. */
2730                retryLoad();
2731            }
2732            flushDeferred();
2733            /* The same edge makes the identity ping sendable again — it refuses
2734               to burn its once-per-session marker under a denial (see
2735               reportIdentity), so it needs a nudge when the denial lifts. */
2736            reportIdentity();
2737        }
2738
2739        function syncConsent() {
2740            try {
2741                var next = consentState();
2742                var changed = false;
2743                for (var k in next) {
2744                    if (next[k] !== lastConsent[k]) { changed = true; break; }
2745                }
2746                /* Only push on a REAL change — a per-second poll that pushed
2747                   every tick would flood the dataLayer for no benefit. */
2748                if (changed) {
2749                    lastConsent = next;
2750                    /* AND NOT ONTO A QUEUE NOTHING WILL EVER DRAIN. Once the
2751                       download budget is spent (see abandonLoad) gtag.js will
2752                       not run on this page, so a consent update is a push onto
2753                       an array with no consumer — and a banner is a toggle a
2754                       visitor can work as many times as they like, on a page
2755                       that may stay open for hours. `lastConsent` still tracks
2756                       the truth so the tag's picture stays accurate; only the
2757                       push is dropped. */
2758                    if (!loadAbandoned) { gtag('consent', 'update', next); }
2759                }
2760                bootIfAllowed();
2761            } catch (e) {}
2762        }
2763
2764        /* THE POLL HANDLE IS KEPT, not thrown away at registration: this is the
2765           one observer that cannot be unregistered by identity alone, and it is
2766           the one that costs something every second. See
2767           teardownConsentObservers. */
2768        var consentPoll = null;
2769
2770        try {
2771            d.addEventListener('visibilitychange', syncConsent);
2772            w.addEventListener('focus', syncConsent);
2773            /* THE OBSERVATION WINDOW IS UP TO ONE SECOND, AND THAT IS THE
2774               DELIBERATE TRADE. There is no cookie-change event in most
2775               browsers and the banner is a same-page interaction, so short of
2776               the banner calling us (it does not — it is a separate module,
2777               and a tag that only worked when another module remembered to
2778               notify it is the fragile design) a poll is the only way to
2779               observe the click itself rather than the next navigation.
2780               cookieStore, where it exists, removes the window entirely and is
2781               preferred above. Where it does not, a visitor can spend up to one
2782               second after clicking accept before gtag is told — a second in
2783               which we send nothing, which is the safe direction. Polling
2784               faster buys nothing: the events waiting on that transition are
2785               queued (see `deferred`) and go out on the same tick the poll
2786               fires, so the only thing a shorter interval shortens is the wait,
2787               not the loss. A cookie read plus two regexes once a second is
2788               negligible next to what a missed transition costs either way. */
2789            if (w.cookieStore && w.cookieStore.addEventListener) {
2790                w.cookieStore.addEventListener('change', syncConsent);
2791            } else {
2792                consentPoll = setInterval(syncConsent, 1000);
2793            }
2794        } catch (e) {}
2795
2796        /* ── AND WHEN THERE IS NOTHING LEFT TO OBSERVE, STOP OBSERVING ────────
2797           These observers exist for ONE purpose: to notice a consent change and
2798           let the tag act on it. abandonLoad is the point at which the tag can
2799           no longer act on anything, so past it they are pure cost — a cookie
2800           read and two regexes every second, plus a wake-up on every focus and
2801           tab switch, for the life of a page a customer can leave open all day.
2802           A checkout tab is exactly that page, and a blocked gtag.js is the
2803           common case, so this is a real share of sessions.
2804
2805           EVERY CONSUMER OF syncConsent IS DEAD AT THAT POINT — checked one by
2806           one, because removing an observer something still needs is the worse
2807           bug:
2808             - `gtag('consent', 'update')` is already withheld while abandoned,
2809               and abandonment is a one-way door, so it can never resume;
2810             - `lastConsent` is then written and read by nothing but this loop;
2811             - bootIfAllowed -> retryLoad is refused by the attempt ceiling, the
2812               same ceiling that produced this state;
2813             - flushDeferred holds an empty queue that deferUntilAllowed can no
2814               longer refill;
2815             - reportIdentity — the one path that still LOOKS live, and the
2816               reason this needed working out rather than assuming — needs a
2817               client_id AND a session_id, and both are only ever assigned from
2818               startIdentity/refreshSession, which run only from s.onload. A
2819               load that succeeds also clears `loadFailed`, which is what
2820               retryLoad requires to spend another attempt, so the budget can
2821               only run out on a page where NO attempt ever loaded: the ids are
2822               null and null is what they stay. The ping has nothing to report,
2823               not merely nowhere to send it.
2824
2825           DELIBERATELY LEFT ALONE: the 60s `sessionTimer`, which only exists
2826           after a load this state proves never happened, and which serves the
2827           ledger's `ids()` rather than consent — clearing it here would be
2828           reaching into the healthy path from the failure path; and the one-shot
2829           backoff setTimeout, which fires at most once more and returns at
2830           retryLoad's first guard. Neither is a recurring cost.
2831
2832           `track()` keeps reading consent directly on every call. That is a
2833           GATE, not an observer: it runs only when the page emits something,
2834           and it must stay honest. */
2835        function teardownConsentObservers() {
2836            try {
2837                if (consentPoll !== null) {
2838                    var poll = consentPoll;
2839                    /* Cleared BEFORE the call, so a second pass is a no-op even
2840                       if clearInterval itself throws. */
2841                    consentPoll = null;
2842                    clearInterval(poll);
2843                }
2844            } catch (e) {}
2845            /* removeEventListener with the same function reference is inherently
2846               idempotent, and each is wrapped on its own so one hostile host
2847               object cannot leave the others installed. */
2848            try { d.removeEventListener('visibilitychange', syncConsent); } catch (e) {}
2849            try { w.removeEventListener('focus', syncConsent); } catch (e) {}
2850            try {
2851                if (w.cookieStore && w.cookieStore.removeEventListener) {
2852                    w.cookieStore.removeEventListener('change', syncConsent);
2853                }
2854            } catch (e) {}
2855        }
2856
2857        /* ── THE TAXONOMY. One definition, no second copy anywhere. ──────────
2858           Ledger event name -> GA4 event name + parameters. The ledger's names
2859           are the source of truth; GA4's reserved names are a projection of
2860           them. Adding an event means adding one entry here and emitting the
2861           ledger name — never calling gtag directly from a page template.
2862
2863           `value` always travels with `currency`: GA4 accepts a value without
2864           one and then silently omits the revenue from every report, which
2865           reads as "the tag is broken" long after the change that caused it. */
2866        /* QUANTISED AT SIX DECIMALS, NOT TWO.
2867           Two is only correct for a 2-decimal currency. KWD, BHD and OMR carry
2868           THREE minor digits, so rounding a money figure to two here hands back
2869           exactly what ViewModel\Ga4Purchase went to the trouble of preserving:
2870           a 10.495 order would report 10.50, above the books, on the one event
2871           that has to agree with them. It would also flatten the borrowed
2872           decimals that class emits on a unit price when a line does not divide
2873           evenly (a 1,000-unit line priced 0.995 would become 1.00 and put the
2874           item rows 5.00 above `value` again).
2875           Six is far enough out to leave every ISO minor unit alone and still
2876           close enough in to absorb the binary float noise this side generates
2877           on its own (9.99 x 3 is 29.970000000000002), which is the only reason
2878           to round here at all. */
2879        function quantise(n) { return Math.round(n * 1e6) / 1e6; }
2880
2881        function money(params, value, currency) {
2882            var v = Number(value);
2883            /* An order carries its OWN currency: prices here are fixed per
2884               region/currency rather than FX-derived, so an order placed in a
2885               different store currency must not be reported under the currency
2886               of the store view the tag happens to be rendering in. Falls back
2887               to the store's currency for events that have no order. */
2888            var c = currency || CURRENCY;
2889            if (isFinite(v) && v > 0 && c) {
2890                params.value = quantise(v);
2891                params.currency = c;
2892            }
2893            return params;
2894        }
2895
2896        /* Is there ANY money on this event — event scope or item scope? */
2897        function hasMoney(params) {
2898            if (params.value !== undefined
2899                || params.shipping !== undefined
2900                || params.tax !== undefined) {
2901                return true;
2902            }
2903            var items = params.items;
2904            for (var i = 0; items && i < items.length; i++) {
2905                var item = items[i] || {};
2906                if (item.price !== undefined || item.discount !== undefined) { return true; }
2907            }
2908
2909            return false;
2910        }
2911
2912        /* NO MONETARY PARAMETER TRAVELS WITHOUT ITS CURRENCY, and the rule
2913           belongs to the FIELD rather than to the event.
2914
2915           money() withholds BOTH `value` and `currency` when the goods revenue
2916           is not positive. That is right for the value and wrong for everything
2917           else on the event: the other money fields are attached independently,
2918           so GA4 books them in the PROPERTY's reporting currency rather than the
2919           order's, and a EUR 4.95 carriage silently becomes 4.95 of whatever the
2920           property reports in.
2921
2922           `shipping` and `tax` were the visible half of that. ITEM SCOPE is the
2923           other half, and it is the half that survives a cart with no carriage
2924           at all: a fully comped EUR order — goods 40.00, no shipping, no tax —
2925           emitted {"transaction_id":"100002","items":[{...,"discount":40}]},
2926           forty units of money under no denomination, which is precisely the
2927           failure a shipping-or-tax-only backstop was written to prevent. And
2928           begin_checkout, which has no shipping or tax to trigger such a
2929           backstop, had none at all: the same comped cart carries
2930           items[].discount and a coupon and named no currency.
2931
2932           The order's own code where it has one, the store's where it does not —
2933           the same fallback money() uses. Where neither exists there is nothing
2934           to attach and the figures stand undenominated, which is the degenerate
2935           case money() already leaves alone. */
2936        function withCurrency(params, currency) {
2937            if (params.currency === undefined && hasMoney(params)) {
2938                var code = currency || CURRENCY;
2939                if (code) { params.currency = String(code); }
2940            }
2941
2942            return params;
2943        }
2944
2945        /* A single-product item array from the ledger's funnel properties.
2946           GA4 requires item_id OR item_name on every item, and treats `items` as
2947           required on view_item / add_to_cart — without it the event still
2948           counts but contributes nothing to any product report.
2949
2950           A CATEGORY-LISTING add-to-cart carries only `product_id` — no sku, no
2951           name — and gets NO items array. Reporting the numeric id as `item_id`
2952           was tried and reverted: purchases identify the same product by SKU, so
2953           a product would appear under two unrelated identities and every
2954           add-to-cart → purchase item funnel would break. One product must have
2955           ONE identity across events, and a missing item is a smaller error than
2956           a split one. The gap closes when the listing markup carries a sku
2957           (dna-platform#294). */
2958        function itemsOf(p) {
2959            var item = {};
2960            if (p.sku) { item.item_id = String(p.sku); }
2961            if (p.name) { item.item_name = String(p.name); }
2962            if (!item.item_id && !item.item_name) { return null; }
2963            var price = Number(p.value);
2964            if (isFinite(price) && price > 0) { item.price = quantise(price); }
2965            /* Magento supports DECIMAL quantities (0.5 kg). parseInt would make
2966               that 0, fall through to 1, and overstate the line. */
2967            var qty = Number(p.quantity);
2968            item.quantity = (isFinite(qty) && qty > 0) ? qty : 1;
2969            return [item];
2970        }
2971
2972        var TAXONOMY = {
2973            /* `p.currency` IS THE ONE THE PRICE WAS CONVERTED INTO, and it is
2974               carried on the event rather than assumed here. The catalogue price
2975               these events report is a BASE-currency column that
2976               ViewModel\FunnelEvents converts before publishing it; stamping the
2977               store view's display currency on whatever number arrived was how a
2978               100 USD product came to report as 100 EUR. money() still falls
2979               back to the store's currency for an event whose source could not
2980               name one. */
2981            product_viewed: function (p) {
2982                var params = money({}, p.value, p.currency);
2983                var items = itemsOf(p);
2984                if (items) { params.items = items; }
2985                /* A no-op on these two as they stand — itemsOf() takes its price
2986                   from the same `p.value` money() gates on, so an item price and
2987                   an event value cannot disagree about whether there is money
2988                   here. Applied anyway: the invariant is the file's, not the
2989                   event's, and the next parameter added to itemsOf() should not
2990                   have to rediscover it. */
2991                return { name: 'view_item', params: withCurrency(params, p.currency) };
2992            },
2993            cart_item_added: function (p) {
2994                var qty = Number(p.quantity);
2995                if (!isFinite(qty) || qty <= 0) { qty = 1; }
2996                var params = money({}, Number(p.value) * qty, p.currency);
2997                var items = itemsOf(p);
2998                if (items) { params.items = items; }
2999                return { name: 'add_to_cart', params: withCurrency(params, p.currency) };
3000            },
3001            /* THE CART, NOT AN EMPTY OBJECT. This used to emit `{}`, and GA4
3002               treats `items` as required on begin_checkout — so the property
3003               recorded that a checkout had started and nothing about what was
3004               in it: no cart value, no products, and therefore no add_to_cart
3005               -> begin_checkout -> purchase item funnel and no abandoned-cart
3006               value on the one step between the catalogue and the money.
3007
3008               Same shape and the same arithmetic as order_completed, from
3009               ViewModel\LineItems — a begin_checkout that disagreed with the
3010               purchase it turns into would be worse than one that said nothing.
3011               No shipping or tax: neither is chosen yet when a checkout starts,
3012               and begin_checkout has no parameter for either. */
3013            checkout_started: function (p) {
3014                var params = money({}, p.value, p.currency);
3015                if (p.items && p.items.length) { params.items = p.items; }
3016                if (p.coupon) { params.coupon = String(p.coupon); }
3017                /* ITEM SCOPE IS REAL MONEY HERE. These rows come from
3018                   ViewModel\LineItems and carry `price` and `discount`, so a
3019                   wholly comped cart emits per-unit discounts under no
3020                   denomination unless the currency is attached — and this event
3021                   has no shipping or tax that could trigger a narrower
3022                   backstop. */
3023                return { name: 'begin_checkout', params: withCurrency(params, p.currency) };
3024            },
3025            order_completed: function (p) {
3026                var params = money({}, p.value, p.currency);
3027                if (p.order_ref) { params.transaction_id = String(p.order_ref); }
3028                if (p.items && p.items.length) { params.items = p.items; }
3029                if (p.coupon) { params.coupon = String(p.coupon); }
3030                var shipping = Number(p.shipping);
3031                if (isFinite(shipping) && shipping > 0) { params.shipping = quantise(shipping); }
3032                var tax = Number(p.tax);
3033                if (isFinite(tax) && tax > 0) { params.tax = quantise(tax); }
3034                /* CARRIAGE, TAX AND THE ITEM ROWS ALIKE — see withCurrency().
3035                   A 100%-discounted order (a full staff or loyalty comp, an
3036                   all-store voucher) reaches here with no `value`, and whichever
3037                   of those three it still carries has to be denominated. */
3038                return { name: 'purchase', params: withCurrency(params, p.currency) };
3039            },
3040            lead_created: function () {
3041                return { name: 'generate_lead', params: {} };
3042            },
3043            signup: function () {
3044                return { name: 'sign_up', params: {} };
3045            }
3046        };
3047
3048        /* Resolved GA4 identity for THIS browser: the `_ga` cookie's client id
3049           and the current session id. Read via gtag's own `get` command rather
3050           than by parsing `_ga`/`_ga_<id>` ourselves — the cookie formats are
3051           Google's to change, and on a first-ever visit the cookie does not
3052           exist until gtag writes it, so a parser would return nothing exactly
3053           when a new user most needs stitching.
3054
3055           These are what the Measurement Protocol has to reuse for a refund or a
3056           renewal. A server-side event that invents a client_id does not stitch
3057           to anything: it opens a brand-new user and a brand-new session, which
3058           is precisely how a property fills up with unassigned traffic. */
3059        var identity = { clientId: null, sessionId: null };
3060        var identityReported = false;
3061        /* THE PING'S OWN RETRY BUDGET, per GA4 session, for the life of the
3062           page. `identityReported` is given back when a send is dropped (see
3063           reportIdentity), and the callers that would then retry are the 60s
3064           session refresh AND the one-second consent poll — so without a budget
3065           a ledger that keeps refusing would be re-offered the same ping every
3066           second until the visitor left. Three attempts with a widening
3067           cooldown covers the failure that is actually transient (a /session/init
3068           that had not minted the cookie yet, a dropped connection) and stops
3069           there; nothing durable is claimed by a failure, so the next page view
3070           starts the budget again. Reset on a session rotation, because a new
3071           session is a new thing to report. */
3072        var IDENTITY_MAX_ATTEMPTS = 3;
3073        var identityAttempts = 0;
3074        var identityRetryNotBefore = 0;
3075        /* gtag.js has actually executed — see s.onload. Everything before that
3076           point is only queued on dataLayer, which does not survive the page. */
3077        var loaded = false;
3078        var onLoaded = [];
3079        /* The 60s session-id poll, held so it can be started once and only once
3080           gtag is really there — see startIdentity. */
3081        var sessionTimer = null;
3082
3083        /* Re-read the CURRENT session id from gtag. A rotation makes this a new
3084           session, so the once-per-session identity ping is allowed to fire
3085           again — `reportIdentity`'s marker is keyed on the session id precisely
3086           so that a genuinely new session is reported and a repeat is not. */
3087        function refreshSession() {
3088            try {
3089                gtag('get', MEASUREMENT_ID, 'session_id', function (v) {
3090                    if (!v) { return; }
3091                    var next = String(v);
3092                    if (next !== identity.sessionId) {
3093                        identity.sessionId = next;
3094                        identityReported = false;
3095                        /* A ROTATION IS A FRESH BUDGET. The attempts spent
3096                           failing to report the previous session say nothing
3097                           about this one, and this one has never been sent. */
3098                        identityAttempts = 0;
3099                        identityRetryNotBefore = 0;
3100                    }
3101                    reportIdentity();
3102                });
3103            } catch (e) {}
3104        }
3105
3106        function reportIdentity() {
3107            try {
3108                if (identityReported || !identity.clientId || !identity.sessionId) { return; }
3109
3110                /* THE IDENTITY DOOR, NOT THE LEDGER DOOR (#653).
3111                   `ledger()` is gated on LEDGER_ENABLED — the funnel-events
3112                   flag, which is OFF on every production store view here — so
3113                   this ping resolved a real client and session id and was then
3114                   dropped inside emit() before any request was made, on every
3115                   page, for the life of the property. `identity()` is the same
3116                   beacon exempted from THAT flag and from nothing else; see
3117                   funnel-events.phtml.
3118
3119                   THE ledger() FALLBACK IS FOR A SKEWED RENDER, not for choice.
3120                   These two tags are separate layout blocks with separate
3121                   block_html cache entries, so a deploy can serve a page whose
3122                   emitter core predates this change. Falling back leaves that
3123                   page behaving exactly as it does today (the ping is offered
3124                   and dropped) rather than turning a stale block into a tag that
3125                   never reports at all. */
3126                var send = w.__dnaFunnel && (w.__dnaFunnel.identity || w.__dnaFunnel.ledger);
3127                if (!send) { return; }
3128
3129                /* NOT WHILE ANALYTICS IS DENIED. emit() re-reads consent and
3130                   drops the beacon — the identity exemption is from the
3131                   funnel-events flag alone and never from consent — so claiming
3132                   the marker here would burn the once-per-session retry on a
3133                   send that never left the page: a visitor who accepts the
3134                   banner a moment later would spend their whole GA4 session
3135                   unreportable, and every Measurement Protocol send keyed on it
3136                   would be unstitched. bootIfAllowed calls back in on the
3137                   denied->granted edge. */
3138                if (!analyticsAllowed()) { return; }
3139
3140                /* The budget above, checked before anything is claimed so a
3141                   refusal to spend an attempt leaves the state exactly as it
3142                   found it. */
3143                if (identityAttempts >= IDENTITY_MAX_ATTEMPTS) { return; }
3144                if (Date.now() < identityRetryNotBefore) { return; }
3145
3146                /* Once per GA4 SESSION, not once per tab.
3147                   The marker carries the session id it was written for, because
3148                   GA4 rotates session_id after 30 minutes idle — a bare boolean
3149                   would report the first session and then suppress every later
3150                   one, leaving the ledger holding a session id that has already
3151                   closed. It is also only claimed once __dnaFunnel is actually
3152                   present, so a page that loaded before the emitter core does
3153                   not burn the marker without sending anything.
3154
3155                   THE KEY CARRIES THE MEASUREMENT ID. sessionStorage is scoped
3156                   to the ORIGIN, while this tag is configured per STORE VIEW —
3157                   and this estate runs several store views (one per currency)
3158                   on one domain. Two of them share this storage, so a single
3159                   `dna_ga4_ident` holding only a session id let the first
3160                   property's ping suppress the second property's entirely: the
3161                   value matched, the marker looked claimed, and a whole GA4
3162                   property silently never learned its own client/session ids. */
3163                var marker = 'dna_ga4_ident_' + MEASUREMENT_ID;
3164                try {
3165                    if (sessionStorage.getItem(marker) === identity.sessionId) {
3166                        identityReported = true;
3167                        return;
3168                    }
3169                } catch (e) {}
3170
3171                /* THE SESSION THIS PING IS FOR, CAPTURED NOW — not read again
3172                   inside the callback below. `identity.sessionId` is MUTABLE:
3173                   refreshSession rewrites it whenever GA4 rotates the session
3174                   after 30 minutes idle. The acceptance callback runs after an
3175                   async handshake, so reading the live field there would stamp
3176                   the marker with whatever session happens to be current when
3177                   the beacon lands, while the beacon itself carries the id
3178                   captured at emit time. A rotation during an in-flight
3179                   handshake would therefore durably record the NEW session as
3180                   reported when only the OLD one ever was — and the new one
3181                   could then never be reported, leaving every Measurement
3182                   Protocol send keyed on it unstitchable. */
3183                var reportingSession = identity.sessionId;
3184
3185                /* IN-MEMORY FIRST, BUT ONLY AS A LEASE ON THE ATTEMPT.
3186                   Set now so the 60s refreshSession tick cannot stack a second
3187                   ping while the first is still in flight — that same-tick guard
3188                   is the whole reason it is claimed before the send.
3189
3190                   It used to be claimed and never given back. If /session/init
3191                   failed, or the fallback fetch('/collect') rejected, the
3192                   acceptance callback never ran and this flag stayed true
3193                   anyway: the 60s refresh and every later consent re-grant
3194                   returned at the first guard, so the identity beacon could
3195                   never be retried for the life of the page. The durable marker
3196                   already knew better (it is claimed on handoff, below); this
3197                   flag was the copy of that decision that nobody rolled back —
3198                   and it is the one that suppresses the retry. */
3199                identityAttempts++;
3200                identityReported = true;
3201                /* THE DURABLE MARKER IS CLAIMED ON HANDOFF, not on attempt: the
3202                   beacon only counts once the /session/init handshake succeeded
3203                   and sendBeacon/fetch accepted the payload. Writing it up front
3204                   meant a failed handshake suppressed the reload that would have
3205                   retried. This is handoff, NOT delivery — sendBeacon gives no
3206                   acknowledgement, so a 4xx at /collect still consumes the
3207                   session's marker. That is the deliberate trade: the alternative
3208                   is re-pinging on every tick for a session that will never
3209                   succeed. */
3210                send('ga4_identity', {}, function () {
3211                    try { sessionStorage.setItem(marker, reportingSession); } catch (e) {}
3212                }, function () {
3213                    /* NOT SENT, SO NOT REPORTED. The other half of the same
3214                       boundary: emit() calls exactly one of these, so a ping
3215                       that was dropped (declined consent, no ingest host, a
3216                       failed handshake, a rejected fetch) hands the lease back
3217                       and the next refresh tick or consent grant may try again.
3218
3219                       ONLY IF THIS IS STILL THE SESSION BEING REPORTED. GA4
3220                       rotates session_id after 30 minutes idle; if that
3221                       happened while this ping was in flight, refreshSession has
3222                       already cleared the flag and started a fresh budget, and a
3223                       ping for the NEW session may be in flight too. A stale
3224                       failure that wrote either of them would release that
3225                       ping's lease and send the new session twice, or hold its
3226                       retry back behind a cooldown it never earned.
3227
3228                       AND ONLY AFTER A COOLDOWN. Both the 60s session refresh
3229                       and the one-second consent poll call back in here, so a
3230                       lease handed straight back would be taken again on the
3231                       very next poll — one /collect per second at a ledger that
3232                       is already refusing. The attempt is spent either way (see
3233                       IDENTITY_MAX_ATTEMPTS); this only spaces out the next
3234                       one. */
3235                    if (identity.sessionId !== reportingSession) { return; }
3236                    identityRetryNotBefore = Date.now() + 5000 * identityAttempts;
3237                    identityReported = false;
3238                });
3239            } catch (e) {}
3240        }
3241
3242        w.__dnaGa4 = {
3243            /* WHICH PROPERTY THIS PAGE REPORTS TO. Published because callers
3244               keep once-per-tab markers in sessionStorage, which is scoped to
3245               the ORIGIN while this tag is configured per STORE VIEW — and this
3246               estate runs several store views (one per currency) on one domain.
3247               A marker recording "GA4 already has this event" is only true of
3248               ONE property, so the caller needs the id to say which. */
3249            id: MEASUREMENT_ID,
3250            /* Ledger name in, GA4 event out. Unmapped names are dropped on
3251               purpose: a ledger event with no GA4 meaning is not something to
3252               forward under its raw name and discover later in the reports. */
3253            track: function (name, props) {
3254                try {
3255                    /* A TAG THAT CANNOT LOAD IS NOT A TAG THAT IS LATE.
3256                       Everything below queues onto dataLayer, which only ever
3257                       becomes events when gtag.js runs. With the load budget
3258                       spent that will not happen on this page, so each further
3259                       add_to_cart would add one more command to an array that
3260                       nothing drains and nothing frees — unbounded on a page a
3261                       customer can leave open, and not one of those events
3262                       would ever reach GA4 anyway. */
3263                    if (!name || loadAbandoned || dnaAnalyticsDeclined()) { return; }
3264                    var map = Object.prototype.hasOwnProperty.call(TAXONOMY, name) ? TAXONOMY[name] : null;
3265                    if (!map) { return; }
3266                    var out = map(props && typeof props === 'object' ? props : {});
3267                    if (!out || !out.name) { return; }
3268                    gtag('event', out.name, out.params || {});
3269                } catch (e) {}
3270            },
3271            /* The ids the funnel beacons attach so the ledger can stitch a
3272               server-sent GA4 event to this browser session. Null until gtag
3273               resolves them (a few ms after load). */
3274            ids: function () {
3275                return (identity.clientId && identity.sessionId) ? identity : null;
3276            },
3277            /* Run `fn` once gtag.js has actually executed, so a caller can wait
3278               before recording that a once-per-session event was delivered. */
3279            whenLoaded: function (fn) {
3280                try {
3281                    if (loaded) { fn(); return; }
3282                    /* NOT QUEUED AGAINST A LOAD THAT CANNOT HAPPEN — AND NOT
3283                       RUN EITHER. A whenLoaded callback exists to write a
3284                       durable "GA4 already has this" marker, so running it once
3285                       the budget is spent would record a delivery to a tag that
3286                       never executed and suppress the retry on the visitor's
3287                       next page view — the exact defect this boundary was
3288                       introduced to fix. Dropping it keeps the event
3289                       retryable, and keeps this queue from growing for every
3290                       once-per-tab event the rest of the page emits. */
3291                    if (loadAbandoned) { return; }
3292                    onLoaded.push(fn);
3293                } catch (e) {}
3294            },
3295            /* True once analytics consent allowed the tag to boot. A caller that
3296               is suppressed while this is false should DEFER rather than drop —
3297               see `deferUntilAllowed`. */
3298            isBooted: function () { return booted; },
3299            /* Re-run `fn` if and when a refusal is withdrawn on THIS page. The
3300               page that renders a one-shot event (an order-success purchase)
3301               gets no second chance otherwise.
3302
3303               RUNS IMMEDIATELY ONLY WHEN THE EVENT COULD ACTUALLY GO OUT — the
3304               tag has booted AND analytics is allowed RIGHT NOW. Testing
3305               `booted` alone is what let a post-boot revocation recurse: the
3306               caller deferred because consent was denied, this handed the
3307               callback straight back because the tag had booted earlier, the
3308               callback re-checked consent and deferred again, in the same tick,
3309               until the stack was exhausted. */
3310            deferUntilAllowed: function (fn) {
3311                try {
3312                    if (booted && analyticsAllowed()) { fn(); return; }
3313                    /* Same reason as whenLoaded: this queue is released, not
3314                       retained, once the tag can no longer load. What it holds
3315                       are GA4 emits waiting for a grant, and a grant can no
3316                       longer produce one. */
3317                    if (loadAbandoned) { return; }
3318                    deferred.push(fn);
3319                } catch (e) {}
3320            }
3321        };
3322
3323        /* ── URL SANITISER ────────────────────────────────────────────────────
3324           A storefront query string is not safe to hand to an analytics vendor:
3325           it carries share-link emails, password-reset and one-click-login
3326           tokens, and `catalogsearch` terms the visitor typed themselves. The
3327           ledger has stripped queries since day one (see safePage() in
3328           funnel-events.phtml); GA4 must not be the leak the ledger refuses to
3329           be.
3330
3331           BUT NOT BY DROPPING THE QUERY WHOLESALE. GA4 derives session
3332           attribution by PARSING page_location — take gclid/utm_* away and every
3333           paid click is re-labelled Direct, which is a far more expensive kind of
3334           wrong than the one being fixed and looks exactly like a broken tag.
3335
3336           So: origin + pathname, plus an explicit allowlist of the parameters
3337           Google itself reads. Everything else is dropped, including anything a
3338           marketing team invents next week — a new attribution parameter is one
3339           line here, a leaked token is an incident. */
3340        /* The manual-tagging set is Google's WHOLE published list, not the five
3341           parameters everyone remembers. utm_source_platform,
3342           utm_creative_format and utm_marketing_tactic are reported dimensions
3343           in GA4 exactly like utm_source is; dropping them does not merely lose
3344           a label, it strips detail off a session that a campaign report is
3345           expected to break down by, and nothing anywhere surfaces the loss.
3346           They are cheap to keep and expensive to notice missing. */
3347        var PAGE_PARAM_ALLOWLIST = [
3348            'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id',
3349            'utm_source_platform', 'utm_creative_format', 'utm_marketing_tactic',
3350            'gclid', 'gbraid', 'wbraid', 'dclid', 'gad_source', 'gad_campaignid', 'srsltid'
3351        ];
3352
3353        function safeUrl(raw) {
3354            try {
3355                if (!raw) { return null; }
3356                var u = new URL(String(raw), w.location.href);
3357                /* Non-http(s) schemes have no meaningful origin (URL reports the
3358                   string "null"), and neither GA4 nor we have any use for one. */
3359                if (u.protocol !== 'http:' && u.protocol !== 'https:') { return null; }
3360                var query = '';
3361                for (var i = 0; i < PAGE_PARAM_ALLOWLIST.length; i++) {
3362                    var k = PAGE_PARAM_ALLOWLIST[i];
3363                    var v = u.searchParams.get(k);
3364                    if (v === null || v === '') { continue; }
3365                    query += (query ? '&' : '?') + encodeURIComponent(k) + '=' + encodeURIComponent(v);
3366                }
3367                return u.origin + u.pathname + query;
3368            } catch (e) { return null; }
3369        }
3370
3371        /* PAGE TITLE POLICY. Magento's search-results title EMBEDS the visitor's
3372           own query — `Search results for: '[email protected]'` is a real title on
3373           a real storefront — and gtag defaults page_title to document.title
3374           whenever we do not set it. So the title is sent, EXCEPT on
3375           catalogsearch, where the route is reported instead of the term. */
3376        function safeTitle() {
3377            try {
3378                if (/(^|\/)catalogsearch(\/|$)/i.test(w.location.pathname || '')) {
3379                    return 'Search results';
3380                }
3381                var t = d.title ? String(d.title) : '';
3382                /* GA4 truncates page_title at 300 bytes anyway; doing it here
3383                   keeps what we send identical to what gets stored. */
3384                return t ? t.slice(0, 300) : null;
3385            } catch (e) { return null; }
3386        }
3387
3388        /* CONFIGURING THE STREAM, then asking for the script. Deferred into a
3389           function so it can run LATER — the visitor who arrives declined and
3390           then accepts in the banner boots here, not on their next navigation.
3391           Runs at most once per page; the download it ends with is the retryable
3392           half (see loadGtag). */
3393        function bootGtag() {
3394            gtag('js', new Date());
3395
3396            /* THE PAGE FIELDS ARE SET ON `config`, NOT ONLY ON THE page_view.
3397               Config parameters become the stream's defaults for EVERY event, so
3398               setting them only on the page_view left begin_checkout, purchase
3399               and gtag's own automatic events still deriving page_location from
3400               document.location — the raw one, query string and all. The leak
3401               would have survived the fix on every event that actually matters.
3402
3403               CONSEQUENCE ON THE CHECKOUT SPA, deliberately accepted: a config
3404               default is a snapshot taken when the tag boots, and Magento's
3405               onepage checkout (and Breeze's soft navigation) changes the URL
3406               through the History API without re-running this script. Later
3407               events therefore report the page the tag booted on, not the step
3408               the customer is looking at. That is correct for our taxonomy —
3409               step identity travels in the EVENT name (begin_checkout,
3410               purchase), never in the URL — and the alternative, re-configuring
3411               on each soft navigation, would re-send page_view and inflate the
3412               session's page counts. A future soft-navigation page_view must
3413               pass its own sanitised page_location on the event. */
3414            var configParams = {
3415                /* NO CONFIG-TIME IMPLICIT PAGE VIEW. gtag's automatic page_view
3416                   builds page_location from document.location itself, bypassing
3417                   the sanitiser entirely. This flag suppresses that one event;
3418                   it does NOT suppress Enhanced Measurement's History API
3419                   pageviews. Ga4::canRender() separately requires the exact stream
3420                   to be recorded as having that setting disabled before this file
3421                   can render at all. Both halves are required, then this manual
3422                   event is the only pageview path. */
3423                send_page_view: false
3424            };
3425            var pageLocation = safeUrl(w.location.href);
3426            if (pageLocation) { configParams.page_location = pageLocation; }
3427            /* The referrer is a full URL from ANOTHER page of this storefront on
3428               an internal hop — same PII exposure, same sanitiser. */
3429            var pageReferrer = safeUrl(d.referrer);
3430            if (pageReferrer) { configParams.page_referrer = pageReferrer; }
3431            var pageTitle = safeTitle();
3432            if (pageTitle) { configParams.page_title = pageTitle; }
3433            if (DEBUG) { configParams.debug_mode = true; }
3434            gtag('config', MEASUREMENT_ID, configParams);
3435
3436            /* The page_view we suppressed above, emitted explicitly. It inherits
3437               the sanitised page fields from the config command, so there is
3438               exactly one definition of them. Without this event GA4 records no
3439               session start, no engagement and no landing page — the property
3440               would look emptier than the tag being absent. */
3441            gtag('event', 'page_view');
3442
3443            loadGtag();
3444        }
3445
3446        /* ── THE DOWNLOAD, SEPARATED FROM THE CONFIGURATION ───────────────────
3447           Fetching gtag.js is the only part of booting that can FAIL, and it is
3448           therefore the only part that may be repeated. Splitting it out is what
3449           makes a retry safe: the commands above are queued on dataLayer, which
3450           survives a failed download untouched, so re-running them would leave a
3451           SECOND config and a SECOND page_view waiting for whichever attempt
3452           finally succeeds — one blocked request would have turned into two
3453           pageviews and a doubled session count. The retry re-requests the
3454           script and nothing else. */
3455        var LOAD_MAX_ATTEMPTS = 3;
3456        var loadAttempts = 0;
3457        var loadFailed = false;
3458        /* The budget is spent AND the last attempt failed: no gtag will run on
3459           this page. Read by every surface that would otherwise keep queueing
3460           for it — see abandonLoad. */
3461        var loadAbandoned = false;
3462        var retryNotBefore = 0;
3463
3464        function loadGtag() {
3465            loadAttempts++;
3466            loadFailed = false;
3467
3468            var s = d.createElement('script');
3469            s.async = true;
3470            s.src = 'https://www.googletagmanager.com/gtag/js?id=' + encodeURIComponent(MEASUREMENT_ID);
3471            /* LOADED is what makes a queued event safe to mark as delivered.
3472               Until gtag.js runs, `gtag(...)` has only pushed onto dataLayer —
3473               an array that dies with the page. Anything claiming a
3474               once-per-session marker has to wait for this, or a success page
3475               that unloads mid-download loses the purchase AND suppresses the
3476               retry. */
3477            s.onload = function () {
3478                loaded = true;
3479                loadFailed = false;
3480                startIdentity();
3481                for (var i = 0; i < onLoaded.length; i++) {
3482                    try { onLoaded[i](); } catch (e) {}
3483                }
3484                onLoaded.length = 0;
3485            };
3486            /* THE FAILURE PATH. An ad blocker, an offline tab or a corporate
3487               proxy makes this request fail, and without an onerror the tag had
3488               no way to know: `booted` stayed true forever, so nothing could
3489               boot it again; a withdraw-and-re-grant found the latch already
3490               set and did nothing; every whenLoaded callback sat unflushed; and
3491               the 60-second identity poll below kept pushing `get` commands onto
3492               a dataLayer no gtag would ever drain, for the life of the page. A
3493               blocked script is the COMMON case on a storefront, not an edge
3494               one — this is the branch most visitors with a blocker take. */
3495            s.onerror = function () {
3496                loadFailed = true;
3497                /* Backoff, and a HARD CEILING of LOAD_MAX_ATTEMPTS. A blocker
3498                   does not change its mind: retrying on the consent poll's
3499                   schedule would mean one blocked request per second per page,
3500                   which is a request storm aimed at Google from every visitor
3501                   who runs an extension. Three attempts covers the failure that
3502                   is actually transient (a dropped connection on a mobile
3503                   handover); beyond that the page stays configured but silent,
3504                   and — because no durable marker is ever claimed without a
3505                   load — every suppressed event is still there to be retried on
3506                   the visitor's next page view. */
3507                retryNotBefore = Date.now() + 5000 * loadAttempts;
3508                if (loadAttempts >= LOAD_MAX_ATTEMPTS) { abandonLoad(); return; }
3509                setTimeout(retryLoad, 5000 * loadAttempts);
3510            };
3511
3512            var first = d.getElementsByTagName('script')[0];
3513            if (first && first.parentNode) {
3514                first.parentNode.insertBefore(s, first);
3515            } else {
3516                (d.head || d.documentElement).appendChild(s);
3517            }
3518        }
3519
3520        /* ── THE BUDGET IS SPENT: STOP ACCUMULATING ───────────────────────────
3521           A ceiling on the retries stopped the request storm and nothing else.
3522           The page carried on as though the tag were merely late: every later
3523           add_to_cart pushed another command onto a dataLayer no gtag will
3524           drain, every consent transition pushed another, and every
3525           once-per-tab caller queued another whenLoaded callback with no
3526           possible consumer. On a page that lives for hours — a checkout tab
3527           left open — both grow without bound, all of it for a tag already
3528           known to be dead. A blocked script is the common case on a
3529           storefront, so this is the state a real share of sessions end up in.
3530
3531           So the surfaces go quiet: `track` becomes a no-op, `whenLoaded` and
3532           `deferUntilAllowed` stop queueing, the consent observers are removed
3533           (see teardownConsentObservers — they drove a state machine that has
3534           no moves left), and whatever is already queued is RELEASED — dropped,
3535           not invoked. Invoking would be the worse bug:
3536           those callbacks write durable "GA4 already has this" markers, and GA4
3537           has nothing, so running them would suppress on the next page view the
3538           very events that were just lost. Dropped, every suppressed event is
3539           still there to be retried, which is the posture the rest of this file
3540           already takes.
3541
3542           A ONE-WAY DOOR, deliberately. A later consent re-grant reaches
3543           bootIfAllowed -> retryLoad, which the attempt ceiling already refuses:
3544           nothing can resurrect a script that has failed its last attempt, so
3545           nothing may start queueing for one again either.
3546
3547           WHAT IT COSTS, stated plainly: if some OTHER Google library on the
3548           page were draining the shared dataLayer, commands we now withhold
3549           might have reached it. Our own load signal is the only thing this tag
3550           can honestly assert about its own property — and the request that
3551           failed here is to the same host that library would have to come from,
3552           so in the case that actually produces this state (a blocker, an
3553           offline tab, a proxy) there is no such library either. */
3554        function abandonLoad() {
3555            loadAbandoned = true;
3556            onLoaded.length = 0;
3557            deferred.length = 0;
3558            teardownConsentObservers();
3559        }
3560
3561        /* Every guard for a retry in ONE place, because it has two callers: the
3562           backoff timer above and the consent poll through bootIfAllowed. Either
3563           may fire at any time, and between them they must still produce at most
3564           LOAD_MAX_ATTEMPTS requests. Refusing while analytics is denied does
3565           NOT consume an attempt — the grant edge is a legitimate retry
3566           trigger, not a wasted one. */
3567        function retryLoad() {
3568            try {
3569                if (!loadFailed || loadAttempts >= LOAD_MAX_ATTEMPTS) { return; }
3570                if (Date.now() < retryNotBefore) { return; }
3571                if (!analyticsAllowed()) { return; }
3572                loadGtag();
3573            } catch (e) {}
3574        }
3575
3576        /* The identity handshake, started ONLY once gtag.js has actually run.
3577           `get` is answered by gtag itself, so asking before the script exists
3578           queues a question nothing can answer — and the 60s repeat below turned
3579           that into an unbounded leak on any page whose download failed. */
3580        function startIdentity() {
3581            try {
3582                gtag('get', MEASUREMENT_ID, 'client_id', function (v) {
3583                    if (v) { identity.clientId = String(v); reportIdentity(); }
3584                });
3585                refreshSession();
3586                /* GA4 ROTATES session_id AFTER 30 MINUTES IDLE, and checkout is
3587                   a single-page app a customer can leave open far longer than
3588                   that. Asking once would leave `ids()` handing the ledger a
3589                   session that has already closed, so the server-side hit could
3590                   never join the browser's — the two sides silently stop
3591                   reconciling. Re-asking periodically costs a dataLayer push. */
3592                if (!sessionTimer) { sessionTimer = setInterval(refreshSession, 60000); }
3593            } catch (e) {}
3594        }
3595
3596        bootIfAllowed();
3597    } catch (e) {}
3598})(window, document);</script>
3598<!-- /dna:collector-ga4 -->
3599    <link rel="stylesheet" id="goomento-frontend-css"  href="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/Goomento_PageBuilder/build/frontend.min.css" type="text/css" media="all" />
3600<style id='goomento-frontend-inline-css' type="text/css">
3601.green-bar{padding:0 !important}.green-bar-message{color:#fff !important}.green-bar{padding:0 !important}.green-bar-message{color:#fff !important}
3602</style>
3603<link rel="stylesheet" id="goomento-global-css"  href="https://donotage.org/media/goomento/css/pagebuilder-global.css?v=1700431331" type="text/css" media="all" />
3604<link rel="stylesheet" id="goomento-widgets-css"  href="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/Goomento_PageBuilder/css/widgets.css" type="text/css" media="all" />
3605<script type="text/javascript">!function(e){var t="require",r=()=>!(!e[t]||!e[t].config),l=n=>"function"==typeof n[0]?n[0]():e[t].apply(null,n);const i=[];if(!r()){let e=0,t=setInterval(()=>{if(r())for(clearInterval(t);i.length;){var n=i.shift();l(n)}1000<e&&clearInterval(t),++e},100)}e.gmtRequire=function(){var n=arguments;!r()||0<i.length?i.push(n):l(n)}}(window);</script>
3605<script type="text/javascript">gmtRequire(() => {require.config({"paths":{"goomento-widget-base":"Goomento_PageBuilder\/js\/widgets\/base","jquery-numerator":"Goomento_PageBuilder\/lib\/jquery-numerator\/jquery-numerator.min","imagesloaded":"Goomento_PageBuilder\/lib\/imagesloaded\/imagesloaded.min","dialogs-manager":"Goomento_PageBuilder\/lib\/dialog\/dialog.min","swiper":"Goomento_PageBuilder\/js\/view\/swiper-wrapper","pen":"Goomento_PageBuilder\/lib\/sofish\/pen","jquery-waypoints":"Goomento_PageBuilder\/lib\/waypoints\/waypoints.min","jquery-tipsy":"Goomento_PageBuilder\/js\/view\/tipsy-wrapper","goomento-backend":"Goomento_PageBuilder\/js\/goomento-backend","goomento-widget-banner-slider":"Goomento_PageBuilder\/js\/widgets\/banner-slider","call-to-action":"Goomento_PageBuilder\/js\/widgets\/call-to-action","goomento-facebook-sdk":"Goomento_PageBuilder\/js\/widgets\/facebook-sdk","goomento-widget-image-carousel":"Goomento_PageBuilder\/js\/widgets\/image-carousel","goomento-widget-product-slider":"Goomento_PageBuilder\/js\/widgets\/product-slider","goomento-widget-alert":"Goomento_PageBuilder\/js\/widgets\/alert","goomento-widget-progress":"Goomento_PageBuilder\/js\/widgets\/progress","goomento-widget-counter":"Goomento_PageBuilder\/js\/widgets\/counter","goomento-widget-text-editor":"Goomento_PageBuilder\/js\/widgets\/text-editor","goomento-widget-tabs":"Goomento_PageBuilder\/js\/widgets\/tabs","goomento-widget-toggle":"Goomento_PageBuilder\/js\/widgets\/toggle","goomento-widget-accordion":"Goomento_PageBuilder\/js\/widgets\/accordion","goomento-widget-video":"Goomento_PageBuilder\/js\/widgets\/video","pagebuilder-sample-data-bubbling":"Goomento_PageBuilderSampleData\/js\/bubbling","goomento-frontend-modules":"Goomento_PageBuilder\/build\/frontend-modules.min","goomento-frontend-engine":"Goomento_PageBuilder\/build\/frontend.min","goomento-frontend":"Goomento_PageBuilder\/js\/frontend-entry"},"shim":{"jquery-numerator":{"deps":["jquery"]},"dialogs-manager":{"deps":["jquery\/ui"]},"jquery-waypoints":{"deps":["jquery"]},"jquery-tipsy":{"deps":["jquery"]},"goomento-frontend-modules":{"deps":["jquery","jquery\/ui"]},"goomento-frontend-engine":{"deps":["jquery","dialogs-manager","jquery-waypoints","goomento-frontend-modules"]},"goomento-frontend":{"deps":["underscore"]}}})})</script>
3605</head>
3606    <body data-container="body"
3607          data-mage-init='{"loaderAjax": {}, "loader": { "icon": "https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/images/loader-2.gif"}}'
3608        class="goomento-default goomento-page gmt-250 store-usd customer-account-login page-layout-2columns-left" id="html-body">
3609        
3610<script type="text/x-magento-init">
3611    {
3612        "*": {
3613            "Magento_PageBuilder/js/widget-initializer": {
3614                "config": {"[data-content-type=\"slider\"][data-appearance=\"default\"]":{"Magento_PageBuilder\/js\/content-type\/slider\/appearance\/default\/widget":false},"[data-content-type=\"map\"]":{"Magento_PageBuilder\/js\/content-type\/map\/appearance\/default\/widget":false},"[data-content-type=\"row\"]":{"Magento_PageBuilder\/js\/content-type\/row\/appearance\/default\/widget":false},"[data-content-type=\"tabs\"]":{"Magento_PageBuilder\/js\/content-type\/tabs\/appearance\/default\/widget":false},"[data-content-type=\"slide\"]":{"Magento_PageBuilder\/js\/content-type\/slide\/appearance\/default\/widget":{"buttonSelector":".pagebuilder-slide-button","showOverlay":"hover","dataRole":"slide"}},"[data-content-type=\"banner\"]":{"Magento_PageBuilder\/js\/content-type\/banner\/appearance\/default\/widget":{"buttonSelector":".pagebuilder-banner-button","showOverlay":"hover","dataRole":"banner"}},"[data-content-type=\"buttons\"]":{"Magento_PageBuilder\/js\/content-type\/buttons\/appearance\/inline\/widget":false},"[data-content-type=\"products\"][data-appearance=\"carousel\"]":{"Magento_PageBuilder\/js\/content-type\/products\/appearance\/carousel\/widget":false}},
3615                "breakpoints": {"desktop":{"label":"Desktop","stage":true,"default":true,"class":"desktop-switcher","icon":"Magento_PageBuilder::css\/images\/switcher\/switcher-desktop.svg","conditions":{"min-width":"1024px"},"options":{"products":{"default":{"slidesToShow":"5"}}}},"tablet":{"conditions":{"max-width":"1024px","min-width":"768px"},"options":{"products":{"default":{"slidesToShow":"4"},"continuous":{"slidesToShow":"3"}}}},"mobile":{"label":"Mobile","stage":true,"class":"mobile-switcher","icon":"Magento_PageBuilder::css\/images\/switcher\/switcher-mobile.svg","media":"only screen and (max-width: 768px)","conditions":{"max-width":"768px","min-width":"640px"},"options":{"products":{"default":{"slidesToShow":"3"}}}},"mobile-small":{"conditions":{"max-width":"640px"},"options":{"products":{"default":{"slidesToShow":"2"},"continuous":{"slidesToShow":"1"}}}}}            }
3616        }
3617    }
3618</script>
3618
3619
3620<div class="cookie-status-message" id="cookie-status">
3621    The store will not work correctly when cookies are disabled.</div>
3622<script type="text&#x2F;javascript">document.querySelector("#cookie-status").style.display = "none";</script>
vendor: 1 bytes, line 3622
3622
3623<script type="text/x-magento-init">
3624    {
3625        "*": {
3626            "cookieStatus": {}
3627        }
3628    }
3629</script>
3629
3630
3631<script type="text/x-magento-init">
3632    {
3633        "*": {
3634            "mage/cookies": {
3635                "expires": null,
3636                "path": "\u002F",
3637                "domain": ".donotage.org",
3638                "secure": true,
3639                "lifetime": "3600"
3640            }
3641        }
3642    }
3643</script>
3643
3644    <noscript>
3645        <div class="message global noscript">
3646            <div class="content">
3647                <p>
3648                    <strong>JavaScript seems to be disabled in your browser.</strong>
3649                    <span>
3650                        For the best experience on our site, be sure to turn on Javascript in your browser.                    </span>
3651                </p>
3652            </div>
3653        </div>
3654    </noscript>
3655
3656<script>
3657    window.cookiesConfig = window.cookiesConfig || {};
3658    window.cookiesConfig.secure = true;
3659</script>
3659<script>    require.config({
3660        map: {
3661            '*': {
3662                wysiwygAdapter: 'mage/adminhtml/wysiwyg/tiny_mce/tinymceAdapter'
3663            }
3664        }
3665    });</script>
3665<script>    require.config({
3666        paths: {
3667            googleMaps: 'https\u003A\u002F\u002Fmaps.googleapis.com\u002Fmaps\u002Fapi\u002Fjs\u003Fv\u003D3\u0026key\u003D'
3668        },
3669        config: {
3670            'Magento_PageBuilder/js/utils/map': {
3671                style: '',
3672            },
3673            'Magento_PageBuilder/js/content-type/map/preview': {
3674                apiKey: '',
3675                apiKeyErrorMessage: 'You\u0020must\u0020provide\u0020a\u0020valid\u0020\u003Ca\u0020href\u003D\u0027https\u003A\u002F\u002Fdonotage.org\u002Fadminhtml\u002Fsystem_config\u002Fedit\u002Fsection\u002Fcms\u002F\u0023cms_pagebuilder\u0027\u0020target\u003D\u0027_blank\u0027\u003EGoogle\u0020Maps\u0020API\u0020key\u003C\u002Fa\u003E\u0020to\u0020use\u0020a\u0020map.'
3676            },
3677            'Magento_PageBuilder/js/form/element/map': {
3678                apiKey: '',
3679                apiKeyErrorMessage: 'You\u0020must\u0020provide\u0020a\u0020valid\u0020\u003Ca\u0020href\u003D\u0027https\u003A\u002F\u002Fdonotage.org\u002Fadminhtml\u002Fsystem_config\u002Fedit\u002Fsection\u002Fcms\u002F\u0023cms_pagebuilder\u0027\u0020target\u003D\u0027_blank\u0027\u003EGoogle\u0020Maps\u0020API\u0020key\u003C\u002Fa\u003E\u0020to\u0020use\u0020a\u0020map.'
3680            },
3681        }
3682    });</script>
3682<script>
3683    require.config({
3684        shim: {
3685            'Magento_PageBuilder/js/utils/map': {
3686                deps: ['googleMaps']
3687            }
3688        }
3689    });</script>
3689<div class="page-wrapper"><header class="page-header"><div id="green-bar-header" class="green-bar">        <div data-gmt-type="section" data-gmt-id="250" class="goomento&#x20;gmt&#x20;gmt-392726" data-gmt-settings="&#x5B;&#x5D;">
3690            <div class="gmt-inner">
3691                <div class="gmt-section-wrap">
3692                            <section class="gmt-element&#x20;gmt-element-6625613&#x20;gmt-section-boxed&#x20;gmt-section-height-default&#x20;gmt-section-height-default&#x20;gmt-section&#x20;gmt-top-section" data-id="6625613" data-element_type="section">
3693            
3694            <div class="gmt-container gmt-column-gap-default">
3695                <div class="gmt-row">
3696                <div class="gmt-element&#x20;gmt-element-61b97e7&#x20;gmt-column&#x20;gmt-col-100&#x20;gmt-top-column" data-id="61b97e7" data-element_type="column">
3697        <div class="gmt-column-wrap&#x20;&#x20;gmt-element-populated">
3698                <div class="gmt-widget-wrap">
3699                <div class="gmt-element&#x20;gmt-element-bd5bb27&#x20;gmt-widget&#x20;gmt-widget-text-editor" data-id="bd5bb27" data-element_type="widget" data-widget_type="text-editor">
3700                <div class="gmt-widget-container" data-mage-init="&#x7B;&quot;goomento-widget-text-editor&quot;&#x3A;&#x5B;&#x5D;&#x7D;">
3701            <div class="gmt-text-editor&#x20;gmt-clearfix">
3702
3703            <p style="text-align: center;">
3704<div class="green-bar__columns">
3705            <div class="green-bar__column green-bar__column--left">
3706            <span class="green-bar-message">
3707                Free USA delivery on orders over $50 | Worldwide $300            </span>
3708        </div>
3709    </div>
3710</p>
3711        </div>
3712        </div>
3713                </div>
3714                </div>
3715        </div>
3716        </div>
3717                        </div>
3718            </div>
3719        </section>
3720                        </div>
3721            </div>
3722        </div>
3723        </div><div class="header content"><div class="header-container">
3724    <div class="header-left">
3725        <div class=" nav-list">
3726    <ul>
3727        <li  class="level0 nav-1 first"><a href="/" ><span>Home</span></a></li><li  class="level0 nav-2 parent"><span ><span class="ui-menu-icon ui-icon ui-icon-caret-1-e"></span>DoNotAge.org</span><ul class="level0 "><li  class="level1 nav-2-1 first"><a href="/about-us" ><span>About Us</span></a></li><li  class="level1 nav-2-2"><a href="/science" ><span>Science</span></a></li><li  class="level1 nav-2-3"><a href="/blogs" ><span>Blogs</span></a></li><li  class="level1 nav-2-4"><a href="/frequently-asked-questions" ><span>Frequently Asked Questions</span></a></li><li  class="level1 nav-2-5 last"><a href="/contact-us" ><span>Contact Us</span></a></li></ul></li><li  class="level0 nav-3"><a href="/products" ><span>Shop</span></a></li><li  class="level0 nav-4 last"><a href="/deals" ><span>Deals</span></a></li>            </ul>
3728</div>
3729    </div>
3730    <div class="nav-toggle-container">
3731        <span data-action="toggle-nav" class="action nav-toggle"><span>Toggle Nav</span></span>
3732    </div>
3733    <div class="logo-container"><a
3734    class="logo"
3735    href="https://donotage.org/"
3736    title=""
3737    aria-label="store logo">
3738    <img src="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/images/logo.svg"
3739         title=""
3740         alt=""
3741        width="65"            />
3742</a>
3743</div>
3744    <div class="header-right login-modal-wrapper" data-block="login-modal">
3745        <div class="header-block nav-list">
3746    <ul>
3747        <li  class="level0 nav-1 first last"><a href="/work-with-us" ><span>Work With Us</span></a></li>        <li class="link">
3748    <button type="button" class="action action-auth-toggle" data-trigger="authentication">
3749        <span>Login</span>
3750    </button>
3751</li>
3752    </ul>
3753</div>
3754            <div class="authentication-wrapper" data-block="authentication">
3755        <div class="block block-login" id="login-modal"
3756             data-role="dropdownDialog"
3757             style="display: none"
3758             data-mage-init='{
3759              "Magento_Ui/js/modal/modal": {
3760                "type": "custom",
3761                "modalClass": "authentication-dropdown",
3762                "trigger": "[data-trigger=authentication]",
3763                "wrapperClass": "authentication-wrapper",
3764                "parentModalClass": "_has-modal-custom _has-auth-shown",
3765                "responsive": true,
3766                "responsiveClass": "custom-slide",
3767                "overlayClass": "dropdown-overlay modal-custom-overlay",
3768                "buttons": []
3769              }
3770            }'>
3771            <div class="block-content">
3772                <form
3773                        novalidate
3774                        action="https://donotage.org/customer/account/loginPost/"
3775                        method="post"
3776                        data-mage-init='{"validation": {"errorClass": "mage-error"}}'>
3777                    <div class="fieldset"
3778                         data-hasrequired="&#x2A;&#x20;Required&#x20;Fields">
3779                        <div class="field field-email required">
3780                            <label class="label" for="login-email"><span>Email Address</span></label>
3781                            <div class="control">
3782                                <input name="login[username]"
3783                                       id="login-email"
3784                                       type="email"
3785                                       class="input-text"
3786                                       data-validate="{required:true, 'validate-email':true}"
3787                                />
3788                            </div>
3789                        </div>
3790                        <div class="field field-password required">
3791                            <label for="login-password" class="label"><span>Password</span></label>
3792                            <a class="action action-remind" href="https://donotage.org/customer/account/forgotpassword/">
3793                                <span>Forgot?</span>
3794                            </a>
3795                            <div class="control">
3796                                <input type="password"
3797                                       class="input-text"
3798                                       id="login-password"
3799                                       name="login[password]"
3800                                       data-validate="{required:true}"
3801                                       autocomplete="off" />
3802                            </div>
3803                        </div>
3804                                            </div>
3805                    <div class="actions-toolbar">
3806                        <input name="context" type="hidden" value="checkout" />
3807                        <div class="primary">
3808                            <button type="submit" class="action action-login primary"><span>Sign In</span></button>
3809                        </div>
3810                        <div class="secondary">
3811                            <a class="action" href="https://donotage.org/customer/account/create/">
3812                                <span>Create account</span>
3813                            </a>
3814                        </div>
3815                    </div>
3816                </form>
3817            </div>
3818        </div>
3819    </div>
3820        <div class="header-block">
3821            <!-- <img class="search-icon js-search-toggle" src="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/images/icons/search.svg" width="32" height="32" /> -->
3822            <div class="website-switcher website-switcher-mini">
3823    <div class="website-option current-website js-switcher-modal-toggle">
3824        <span class="flag usd">
3825            <span></span>
3826        </span>
3827        <div class="website-option-labels">
3828            <span class="website-label">US Store</span>
3829            <span class="website-currency">$ USD</span>
3830        </div>
3831    </div>
3832</div>
3833<div class="website-switcher website-switcher-modal" id="website_switcher_modal">
3834    <div class="switcher-modal-header">
3835        <span class="switcher-modal-close js-switcher-modal-toggle">&#10005;</span>
3836        <span class="shop-in-another">Shop in another region</span>
3837        <span class="shop-in-another-explained">We have automatically selected the best store for you based on your current location. If you would like to shop in a different region please select one of the options below.</span>
3838    </div>
3839    <ul class="switcher-options">
3840                                    <li>
3841                    <div class="website-option js-website-option" data-target-url="https://donotage.org/stores/store/redirect/___store/gbp/___from_store/usd/uenc/aHR0cHM6Ly9kb25vdGFnZS5vcmcvY3VzdG9tZXIvYWNjb3VudC9sb2dpbi8~/?___store=gbp">
3842                        <span class="flag gbp">
3843                            <span></span>
3844                        </span>
3845                        <div class="website-option-labels">
3846                            <span class="website-label">UK Store</span>
3847                            <span class="website-currency">£ GBP</span>
3848                        </div>
3849                    </div>
3850                </li>
3851                                                                    <li>
3852                    <div class="website-option js-website-option" data-target-url="https://donotage.org/stores/store/redirect/___store/aud/___from_store/usd/uenc/aHR0cHM6Ly9kb25vdGFnZS5vcmcvY3VzdG9tZXIvYWNjb3VudC9sb2dpbi8~/?___store=aud">
3853                        <span class="flag aud">
3854                            <span></span>
3855                        </span>
3856                        <div class="website-option-labels">
3857                            <span class="website-label">Australian Store</span>
3858                            <span class="website-currency">$ AUD</span>
3859                        </div>
3860                    </div>
3861                </li>
3862                                                <li>
3863                    <div class="website-option js-website-option" data-target-url="https://donotage.org/stores/store/redirect/___store/eur/___from_store/u
3863sd/uenc/aHR0cHM6Ly9kb25vdGFnZS5vcmcvY3VzdG9tZXIvYWNjb3VudC9sb2dpbi8~/?___store=eur">
3864                        <span class="flag eur">
3865                            <span></span>
3866                        </span>
3867                        <div class="website-option-labels">
3868                            <span class="website-label">European Store</span>
3869                            <span class="website-currency">€ EUR</span>
3870                        </div>
3871                    </div>
3872                </li>
3873                                                <li>
3874                    <div class="website-option js-website-option" data-target-url="https://donotage.org/stores/store/redirect/___store/cad/___from_store/usd/uenc/aHR0cHM6Ly9kb25vdGFnZS5vcmcvY3VzdG9tZXIvYWNjb3VudC9sb2dpbi8~/?___store=cad">
3875                        <span class="flag cad">
3876                            <span></span>
3877                        </span>
3878                        <div class="website-option-labels">
3879                            <span class="website-label">Canadian Store</span>
3880                            <span class="website-currency">$ CAD</span>
3881                        </div>
3882                    </div>
3883                </li>
3884                        <ul>
3885</div>
3886<style>
3887            .website-switcher .flag.gbp > span {
3888            background-image: url('https://donotage.org/media/wysiwyg/website/flags/gbp.png');
3889        }
3890            .website-switcher .flag.usd > span {
3891            background-image: url('https://donotage.org/media/wysiwyg/website/flags/usd.png');
3892        }
3893            .website-switcher .flag.aud > span {
3894            background-image: url('https://donotage.org/media/wysiwyg/website/flags/aud.png');
3895        }
3896            .website-switcher .flag.eur > span {
3897            background-image: url('https://donotage.org/media/wysiwyg/website/flags/eur.png');
3898        }
3899            .website-switcher .flag.cad > span {
3900            background-image: url('https://donotage.org/media/wysiwyg/website/flags/cad.png');
3901        }
3902    </style>
3903<script type="text/x-magento-init">
3904    {
3905        "*": {
3906            "DoNotAge_MultiSite/js/switcher": {}
3907        }
3908    }
3909</script>
3909            
3910<div data-block="minicart" class="minicart-wrapper">
3911    <a class="action showcart" href="https://donotage.org/checkout/cart/"
3912       data-bind="scope: 'minicart_content'">
3913        <svg xmlns="http://www.w3.org/2000/svg" width="25.599" height="25.599" viewBox="0 0 25.599 25.599">
3914            <path id="basket" d="M12.262,2.318a1.1,1.1,0,0,1,0,1.537L8.742,7.441a1.053,1.053,0,0,1-1.508,0,1.1,1.1,0,0,1,0-1.537l3.52-3.586A1.053,1.053,0,0,1,12.262,2.318Zm5.064,1.537a1.1,1.1,0,0,1,0-1.537,1.053,1.053,0,0,1,1.508,0L22.354,5.9a1.1,1.1,0,0,1,0,1.537,1.053,1.053,0,0,1-1.508,0ZM2.64,10.213a.64.64,0,0,0-.64.64v.893a.64.64,0,0,0,.64.64H26.959a.64.64,0,0,0,.64-.64v-.893a.64.64,0,0,0-.64-.64ZM3.5,14.56a.64.64,0,0,0-.627.769L4.976,25.556A2.56,2.56,0,0,0,7.484,27.6H22.115a2.56,2.56,0,0,0,2.507-2.043l2.107-10.227a.64.64,0,0,0-.627-.769Zm13.792,2.9a1.067,1.067,0,0,0-1.067,1.067v5.111a1.067,1.067,0,0,0,2.133,0V18.524A1.067,1.067,0,0,0,17.288,17.457Zm-5.333,1.067a1.067,1.067,0,0,1,2.133,0v5.111a1.067,1.067,0,0,1-2.133,0Z" transform="translate(-2 -2)" fill="#28453e" fill-rule="evenodd"/>
3915        </svg>
3916        <span class="text">My Cart</span>
3917        <span class="counter qty empty"
3918              data-bind="css: { empty: !!getCartParam('summary_count') == false && !isLoading() },
3919               blockLoader: isLoading">
3920            <span class="counter-number">
3921                <!-- ko if: getCartParam('summary_count') -->
3922                <!-- ko text: getCartParam('summary_count').toLocaleString(window.LOCALE) --><!-- /ko -->
3923                <!-- /ko -->
3924            </span>
3925            <span class="counter-label">
3926            <!-- ko if: getCartParam('summary_count') -->
3927                <!-- ko text: getCartParam('summary_count').toLocaleString(window.LOCALE) --><!-- /ko -->
3928                <!-- ko i18n: 'items' --><!-- /ko -->
3929                <!-- /ko -->
3930            </span>
3931        </span>
3932    </a>
3933            <div class="block block-minicart"
3934             data-role="dropdownDialog"
3935             data-mage-init='{"dropdownDialog":{
3936                "appendTo":"[data-block=minicart]",
3937                "triggerTarget":".showcart",
3938                "timeout": "2000",
3939                "closeOnMouseLeave": false,
3940                "closeOnEscape": true,
3941                "triggerClass":"active",
3942                "parentClass":"active",
3943                "buttons":[]}}'>
3944            <div id="minicart-content-wrapper" data-bind="scope: 'minicart_content'">
3945                <!-- ko template: getTemplate() --><!-- /ko -->
3946            </div>
3947                    </div>
3948        
3948<script>window.checkout = {"shoppingCartUrl":"https:\/\/donotage.org\/checkout\/cart\/","checkoutUrl":"https:\/\/donotage.org\/checkout\/","updateItemQtyUrl":"https:\/\/donotage.org\/checkout\/sidebar\/updateItemQty\/","removeItemUrl":"https:\/\/donotage.org\/checkout\/sidebar\/removeItem\/","imageTemplate":"Magento_Catalog\/product\/image_with_borders","baseUrl":"https:\/\/donotage.org\/","minicartMaxItemsVisible":5,"websiteId":"1","maxItemsToDisplay":10,"storeId":["2","2"],"storeGroupId":"1","customerLoginUrl":"https:\/\/donotage.org\/customer\/account\/login\/referer\/aHR0cHM6Ly9kb25vdGFnZS5vcmcvY3VzdG9tZXIvYWNjb3VudC9sb2dpbi8~\/","isRedirectRequired":false,"autocomplete":"off","captcha":{"user_login":{"isCaseSensitive":false,"imageHeight":50,"imageSrc":"","refreshUrl":"https:\/\/donotage.org\/captcha\/refresh\/","isRequired":false,"timestamp":1790250985}}}</script>
3948    
3948<script type="text/x-magento-init">
3949    {
3950        "[data-block='minicart']": {
3951            "Magento_Ui/js/core/app": {"components":{"minicart_content":{"children":{"subtotal.container":{"children":{"subtotal":{"children":{"subtotal.totals":{"config":{"display_cart_subtotal_incl_tax":0,"display_cart_subtotal_excl_tax":1,"template":"Magento_Tax\/checkout\/minicart\/subtotal\/totals"},"children":{"subtotal.totals.msrp":{"component":"Magento_Msrp\/js\/view\/checkout\/minicart\/subtotal\/totals","config":{"displayArea":"minicart-subtotal-hidden","template":"Magento_Msrp\/checkout\/minicart\/subtotal\/totals"}}},"component":"Magento_Tax\/js\/view\/checkout\/minicart\/subtotal\/totals"}},"component":"uiComponent","config":{"template":"Magento_Checkout\/minicart\/subtotal"}},"payment_method_messaging_element_minicart":{"component":"StripeIntegration_Payments\/js\/view\/checkout\/cart\/payment_method_messaging_element_minicart","config":{"template":"StripeIntegration_Payments\/checkout\/cart\/payment_method_messaging_element_minicart","selector":"payment-method-messaging-element-minicart"}}},"component":"uiComponent","config":{"displayArea":"subtotalContainer"}},"item.renderer":{"component":"Magento_Checkout\/js\/view\/cart-item-renderer","config":{"displayArea":"defaultRenderer","template":"Magento_Checkout\/minicart\/item\/default"},"children":{"item.image":{"component":"Magento_Catalog\/js\/view\/image","config":{"template":"Magento_Catalog\/product\/image","displayArea":"itemImage"}},"checkout.cart.item.price.sidebar":{"component":"uiComponent","config":{"template":"Magento_Checkout\/minicart\/item\/price","displayArea":"priceSidebar"}}}},"extra_info":{"component":"uiComponent","config":{"displayArea":"extraInfo"}},"promotion":{"component":"uiComponent","config":{"displayArea":"promotion"}}},"config":{"itemRenderer":{"default":"defaultRenderer","simple":"defaultRenderer","virtual":"defaultRenderer"},"template":"Magento_Checkout\/minicart\/content"},"component":"Magento_Checkout\/js\/view\/minicart"}},"types":[]}        },
3952        "*": {
3953            "Magento_Ui/js/block-loader": "https\u003A\u002F\u002Fdonotage.org\u002Fstatic\u002Fversion1790156720\u002Ffrontend\u002FDna\u002Fb2c\u002Dtheme\u002Fen_US\u002Fimages\u002Floader\u002D1.gif"
3954        }
3955    }
3956    </script>
3956
3957</div>
3958        </div>
3959    </div>
3960</div>
3961<div class="search-bar">
3962    <form mathod="GET" action="/catalogsearch/result">
3963        <label for="site_search">Find your Supplement</label>
3964        <input type="text" name="q" id="site_search" placeholder="NAD Booster, SIRT6, Pure TMG ..." />
3965    </form>
3966</div>
3967<div class="sections nav-sections nav-list">
3968    <ul>
3969        <li  class="level0 nav-1 first"><a href="/" ><span>Home</span></a></li><li  class="level0 nav-2 parent"><span ><span class="ui-menu-icon ui-icon ui-icon-caret-1-e"></span>DoNotAge.org</span><ul class="level0 "><li  class="level1 nav-2-1 first"><a href="/about-us" ><span>About Us</span></a></li><li  class="level1 nav-2-2"><a href="/science" ><span>Science</span></a></li><li  class="level1 nav-2-3"><a href="/blogs" ><span>Blogs</span></a></li><li  class="level1 nav-2-4"><a href="/frequently-asked-questions" ><span>Frequently Asked Questions</span></a></li><li  class="level1 nav-2-5 last"><a href="/contact-us" ><span>Contact Us</span></a></li></ul></li><li  class="level0 nav-3"><a href="/products" ><span>Shop</span></a></li><li  class="level0 nav-4"><a href="/deals" ><span>Deals</span></a></li><li  class="level0 nav-5 last"><a href="/work-with-us" ><span>Work With Us</span></a></li>        <li class="link">
3970    <a href="https://donotage.org/customer/account/login/referer/aHR0cHM6Ly9kb25vdGFnZS5vcmcvYmxvZ3MvYXV0b3BoYWd5LWFjdGl2YXRpb24tdGhlLWtleS10by1jZWxsdWxhci1jbGVhbi11cC1hbmQtbG9uZ2V2aXR5/">Sign In</a>
3971</li>
3972    </ul>
3973</div>
3974<script type="text/x-magento-init">
3975    {
3976        ".header-container .nav-list ul": {
3977            "topMenu": {}
3978        },
3979        ".nav-toggle": {
3980            "mobileMenu": {"target": ".nav-sections"}
3981        }
3982    }
3983</script>
3983</div>        
3983<script type="application/javascript" async
3984            src=https://static.klaviyo.com/onsite/js/Y46Ge8/klaviyo.js ></script>
3984
3985
3986    
3986<script type="text/x-magento-init">
3987        {
3988            "*": {
3989                "KlaviyoCustomerData": {}
3990            }
3991        }
3992    </script>
3992
3993</header><div class="widget block block-static-block">
3994    <style>
3995
3996.rated-excellent {
3997    display: none !important;
3998}
3999
4000.dna-klarna-messaging-container {
4001    margin-top: 16px;
4002    display: inline-block;
4003    width: 100%;
4004    /* Removing as we moved to new stripe account which does not support klarna */
4005    display: none !important
4006}
4007
4008.special-price .price {
4009    color: #008523 !important;
4010}
4011
4012.reward-spend-header h3 {
4013    display: none !important;
4014}
4015
4016.catalog-product-view .search-bar {
4017    display: none !important;
4018}
4019
4020.search.results > dl.block {
4021    display: none !important;
4022}
4023
4024.block.related .short-description,
4025.block.upsell .short-description,
4026.block.crosssell .short-description {
4027    display: none;
4028}
4029
4030.blog-page-list .dna-cms-header h2,
4031.blog-page-list .dna-cms-header h3,
4032.blog-page-list .dna-cms-header p {
4033    color: #fff !important;
4034}
4035
4036.blog-page-list .dna-cms-header h3 {
4037    font-style: none;
4038    font-weight: 600;
4039}
4040
4041.blog-page-list .dna-cms-header h2,
4042.blog-page-list .dna-cms-header h2 strong {
4043    font-weight: 400 !importnat;
4044}
4045
4046.blog-page-list .dna-cms-overlay {
4047    opacity: 0.8;
4048}
4049#reviews {
4050   display: none !important;
4051}
4052.dna-low-stock-notice {
4053    display: none !important;
4054}
4055.product-card .product-image .sticker.sale {
4056    display: none;
4057}
4058.dna-checkout-crosssell {
4059    display: none;
4060}
4061.product-card .product-rating {
4062    display: none;
4063}
4064.product-card .product-details {
4065    padding-top: 14px !important;
4066}
4067
4068.product-coach-montana-bundle #reviews {
4069    display: none !important;
4070}
4071
4072.product-coach-montana-bundle .dna-product-info-tab.description .tab-content {
4073    display: block;
4074}
4075
4076.product-the-energy-bundle-nmn-ca-akg #reviews {
4077    display: none !important;
4078}
4079
4080.product-the-energy-bundle-nmn-ca-akg .dna-product-info-tab.description .tab-content {
4081    display: block;
4082}
4083
4084.product-reviews-summary {
4085    display: none !important;
4086}
4087
4088.hro-cards .hro-card-feature::before {
4089        background-size: auto !important;
4090        background-position: 0 0 !important;
4091        filter: none !important;
4092    }
4093</style>
4094<style>
4095    /* card's own background — stop it tiling behind the ::before */
4096    .hro-card-feature[style*="Blog_Thumbnails"] {
4097        background-size: contain !important;
4098        background-repeat: no-repeat !important;
4099        background-position: center center !important;
4100        background-color: #1e453e !important;
4101    }
4102    .hro-card-feature[style*="NickEngerer"] {
4103        background-size: contain !important;
4104        background-repeat: no-repeat !important;
4105        background-position: center center !important;
4106        background-color: #0c4c3f !important;
4107    }
4108    /* the ::before overlay */
4109    .hro-card-feature[style*="NickEngerer"]::before,
4110    .hro-card-feature[style*="Blog_Thumbnails"]::before {
4111        background-size: contain !important;
4112        background-repeat: no-repeat !important;
4113        background-position: center center !important;
4114        filter: none !important;
4115    }
4116    .hro-card-feature[style*="Blog_Thumbnails"] .hro-text-overlay {
4117        display: none !important;
4118    }
4119    /* Knowledge Hub big card */
4120    .khub-img[style*="Blog_Thumbnails"] {
4121        background-size: contain !important;
4122        background-repeat: no-repeat !important;
4123        background-position: center center !important;
4124        background-color: #1e453e !important;
4125    }
4126    .khub-img[style*="NickEngerer"] {
4127        background-size: contain !important;
4128        background-repeat: no-repeat !important;
4129        background-position: center center !important;
4130        background-color: #0c4c3f !important;
4131    }
4132    .khub-card__img[style*="Blog_Thumbnails"] {
4133        background-size: contain !important;
4134        background-repeat: no-repeat !important;
4135        background-position: center center !important;
4136        background-color: #1e453e !important;
4137    }
4138    .khub-card__img[style*="NickEngerer"] {
4139        background-size: contain !important;
4140        background-repeat: no-repeat !important;
4141        background-position: center center !important;
4142        background-color: #0c4c3f !important;
4143    }
4144    .blog-post-111 .post-header,
4145    .blog-post-112 .post-header,
4146    .blog-post-113 .post-header,
4147    .blog-post-114 .post-header,
4148    .blog-post-115 .post-header,
4149    .blog-post-116 .post-header,
4150    .blog-post-117 .post-header,
4151    .blog-post-118 .post-header,
4152    .blog-post-119 .post-header,
4153    .blog-post-120 .post-header,
4154    .blog-post-121 .post-header,
4155    .blog-post-122 .post-header,
4156    .blog-post-123 .post-header,
4157    .blog-post-124 .post-header {
4158        background-image: none !important;
4159    }
4160</style>
4161
4162<style>
4163    /* card's own background — stop it tiling behind the ::before */
4164    .hro-card-feature[style*="Informed_Sport_Certified"] {
4165        background-size: contain !important;
4166        background-repeat: no-repeat !important;
4167        background-position: center center !important;
4168        background: #1e453e;
4169    }
4170    /* the ::before overlay */
4171    .hro-card-feature[style*="Informed_Sport_Certified"]::before {
4172        background-size: contain !important;
4173        background-repeat: no-repeat !important;
4174        background-position: center center !important;
4175        filter: none !important;
4176    }
4177    .hro-card-feature[style*="Informed_Sport_Certified"] .hro-text-overlay {
4178        display: none !important;
4179    }
4180    /* Knowledge Hub big card - same Informed Sport image */
4181    .khub-card__img[style*="Informed_Sport_Certified"] {
4182        background-size: contain !important;
4183        background-repeat: no-repeat !important;
4184        background-position: center center !important;
4185        background-color: #1e453e !important;
4186    }
4187</style></div>
4188<main id="maincontent" class="page-main"><a id="contentarea" tabindex="-1"></a>
4189<div class="page-title-wrapper">
4190    <h1 class="page-title"
4191                >
4192        <span class="base" data-ui-id="page-title-wrapper" >Customer Login</span>    </h1>
4193    </div>
4194<div class="page messages"><div data-placeholder="messages"></div>
4195<div data-bind="scope: 'messages'">
4196    <!-- ko if: cookieMessages && cookieMessages.length > 0 -->
4197    <div aria-atomic="true" role="alert" data-bind="foreach: { data: cookieMessages, as: 'message' }" class="messages">
4198        <div data-bind="attr: {
4199            class: 'message-' + message.type + ' ' + message.type + ' message',
4200            'data-ui-id': 'message-' + message.type
4201        }">
4202            <div data-bind="html: $parent.prepareMessageForHtml(message.text)"></div>
4203        </div>
4204    </div>
4205    <!-- /ko -->
4206
4207    <!-- ko if: messages().messages && messages().messages.length > 0 -->
4208    <div aria-atomic="true" role="alert" class="messages" data-bind="foreach: {
4209        data: messages().messages, as: 'message'
4210    }">
4211        <div data-bind="attr: {
4212            class: 'message-' + message.type + ' ' + message.type + ' message',
4213            'data-ui-id': 'message-' + message.type
4214        }">
4215            <div data-bind="html: $parent.prepareMessageForHtml(message.text)"></div>
4216        </div>
4217    </div>
4218    <!-- /ko -->
4219</div>
4220<script type="text/x-magento-init">
4221    {
4222        "*": {
4223            "Magento_Ui/js/core/app": {
4224                "components": {
4225                        "messages": {
4226                            "component": "Magento_Theme/js/view/messages"
4227                        }
4228                    }
4229                }
4230            }
4231    }
4232</script>
4232
4233</div><div class="columns"><div class="column main"><input name="form_key" type="hidden" value="kcKb9bBBd7xrDP8r" /><div id="authenticationPopup" data-bind="scope:'authenticationPopup', style: {display: 'none'}">
4234        
4234<script>window.authenticationPopup = {"autocomplete":"off","customerRegisterUrl":"https:\/\/donotage.org\/customer\/account\/create\/","customerForgotPasswordUrl":"https:\/\/donotage.org\/customer\/account\/forgotpassword\/","baseUrl":"https:\/\/donotage.org\/","customerLoginUrl":"https:\/\/donotage.org\/customer\/ajax\/login\/"}</script>
4234    <!-- ko template: getTemplate() --><!-- /ko -->
4235        
4235<script type="text/x-magento-init">
4236        {
4237            "#authenticationPopup": {
4238                "Magento_Ui/js/core/app": {"components":{"authenticationPopup":{"component":"Magento_Customer\/js\/view\/authentication-popup","children":{"messages":{"component":"Magento_Ui\/js\/view\/messages","displayArea":"messages"},"captcha":{"component":"Magento_Captcha\/js\/view\/checkout\/loginCaptcha","displayArea":"additional-login-form-fields","formId":"user_login","configSource":"checkout"}}}}}            },
4239            "*": {
4240                "Magento_Ui/js/block-loader": "https\u003A\u002F\u002Fdonotage.org\u002Fstatic\u002Fversion1790156720\u002Ffrontend\u002FDna\u002Fb2c\u002Dtheme\u002Fen_US\u002Fimages\u002Floader\u002D1.gif"
4241                                ,
4242                "Magento_Customer/js/customer-global-session-loader": {}
4243                            }
4244        }
4245    </script>
4245
4246</div>
4247<script type="text/x-magento-init">
4248    {
4249        "*": {
4250            "Magento_Customer/js/section-config": {
4251                "sections": {"stores\/store\/switch":["*"],"stores\/store\/switchrequest":["*"],"directory\/currency\/switch":["*"],"*":["messages"],"customer\/account\/logout":["*","recently_viewed_product","recently_compared_product","persistent"],"customer\/account\/loginpost":["*"],"customer\/account\/createpost":["*"],"customer\/account\/editpost":["*"],"customer\/ajax\/login":["checkout-data","cart","captcha","rally-checkout-config"],"catalog\/product_compare\/add":["compare-products"],"catalog\/product_compare\/remove":["compare-products"],"catalog\/product_compare\/clear":["compare-products"],"sales\/guest\/reorder":["cart"],"sales\/order\/reorder":["cart"],"checkout\/cart\/add":["cart","directory-data","rally-checkout-config"],"checkout\/cart\/delete":["cart","rally-checkout-config"],"checkout\/cart\/updatepost":["cart","rally-checkout-config"],"checkout\/cart\/updateitemoptions":["cart"],"checkout\/cart\/couponpost":["cart"],"checkout\/cart\/estimatepost":["cart"],"checkout\/cart\/estimateupdatepost":["cart"],"checkout\/onepage\/saveorder":["cart","checkout-data","last-ordered-items"],"checkout\/sidebar\/removeitem":["cart","rally-checkout-config"],"checkout\/sidebar\/updateitemqty":["cart","rally-checkout-config"],"rest\/*\/v1\/carts\/*\/payment-information":["cart","last-ordered-items","captcha","instant-purchase"],"rest\/*\/v1\/guest-carts\/*\/payment-information":["cart","captcha"],"rest\/*\/v1\/guest-carts\/*\/selected-payment-method":["cart","checkout-data","rally-checkout-config"],"rest\/*\/v1\/carts\/*\/selected-payment-method":["cart","checkout-data","instant-purchase","rally-checkout-config"],"customer\/address\/*":["instant-purchase"],"customer\/account\/*":["instant-purchase"],"vault\/cards\/deleteaction":["instant-purchase"],"multishipping\/checkout\/overviewpost":["cart"],"paypal\/express\/placeorder":["cart","checkout-data"],"paypal\/payflowexpress\/placeorder":["cart","checkout-data"],"paypal\/express\/onauthorization":["cart","checkout-data"],"persistent\/index\/unsetcookie":["persistent"],"review\/product\/post":["review"],"wishlist\/index\/add":["wishlist"],"wishlist\/index\/remove":["wishlist"],"wishlist\/index\/updateitemoptions":["wishlist"],"wishlist\/index\/update":["wishlist"],"wishlist\/index\/cart":["wishlist","cart"],"wishlist\/index\/fromcart":["wishlist","cart"],"wishlist\/index\/allcart":["wishlist","cart"],"wishlist\/shared\/allcart":["wishlist","cart"],"wishlist\/shared\/cart":["cart"],"my_subscriptions\/action\/renew":["cart"],"reclaim\/checkout\/reload":["cart"],"braintree\/paypal\/placeorder":["cart","checkout-data"],"braintree\/googlepay\/placeorder":["cart","checkout-data"],"rest\/*\/v1\/guest-carts\/*\/shipping-information":["rally-checkout-config"],"rest\/*\/v1\/carts\/*\/shipping-information":["rally-checkout-config"],"rest\/*\/v1\/guest-carts\/*\/billing-address":["rally-checkout-config"],"rest\/*\/v1\/carts\/*\/billing-address":["rally-checkout-config"]},
4252                "clientSideSections": ["checkout-data","cart-data"],
4253                "baseUrls": ["https:\/\/donotage.org\/"],
4254                "sectionNames": ["messages","customer","compare-products","last-ordered-items","cart","directory-data","captcha","instant-purchase","loggedAsCustomer","persistent","review","wishlist","rally-checkout-config","recently_viewed_product","recently_compared_product","product_data_storage","paypal-billing-agreement"]            }
4255        }
4256    }
4257</script>
vendor: 1 bytes, line 4257
4257
4258<script type="text/x-magento-init">
4259    {
4260        "*": {
4261            "Magento_Customer/js/customer-data": {
4262                "sectionLoadUrl": "https\u003A\u002F\u002Fdonotage.org\u002Fcustomer\u002Fsection\u002Fload\u002F",
4263                "expirableSectionLifetime": 60,
4264                "expirableSectionNames": ["cart","persistent"],
4265                "cookieLifeTime": "3600",
4266                "updateSessionUrl": "https\u003A\u002F\u002Fdonotage.org\u002Fcustomer\u002Faccount\u002FupdateSession\u002F"
4267            }
4268        }
4269    }
4270</script>
vendor: 1 bytes, line 4270
4270
4271<script type="text/x-magento-init">
4272    {
4273        "*": {
4274            "Magento_Customer/js/invalidation-processor": {
4275                "invalidationRules": {
4276                    "website-rule": {
4277                        "Magento_Customer/js/invalidation-rules/website-rule": {
4278                            "scopeConfig": {
4279                                "websiteId": "1"
4280                            }
4281                        }
4282                    }
4283                }
4284            }
4285        }
4286    }
4287</script>
vendor: 1 bytes, line 4287
4287
4288<script type="text/x-magento-init">
4289    {
4290        "body": {
4291            "pageCache": {"url":"https:\/\/donotage.org\/page_cache\/block\/render\/","handles":["default","customer_account_login"],"originalRequest":{"route":"customer","controller":"account","action":"login","uri":"\/customer\/account\/login\/"},"versionCookieName":"private_content_version"}        }
4292    }
4293</script>
4293
4294
4295                    
4295<script>
4296            require(['jquery', 'domReady!'], function($){
4297                if ($('.mfblogunveil').length) {
4298                    require(['Magefan_Blog/js/lib/mfblogunveil'], function(){
4299                        $('.mfblogunveil').mfblogunveil();
4300                    });
4301                }
4302            });
4303        </script>
4303<div class="login-container"><div class="block block-customer-login">
4304    <div class="block-title">
4305        <strong id="block-customer-login-heading" role="heading" aria-level="2">Registered Customers</strong>
4306    </div>
4307    <div class="block-content" aria-labelledby="block-customer-login-heading">
4308        <form class="form form-login"
4309              action="https://donotage.org/customer/account/loginPost/"
4310              method="post"
4311              id="login-form"
4312              data-mage-init='{"validation":{}}'>
4313            <input name="form_key" type="hidden" value="kcKb9bBBd7xrDP8r" />            <fieldset class="fieldset login" data-hasrequired="* Required Fields">
4314                <div class="field note">If you have an account, sign in with your email address.</div>
4315                <div class="field email required">
4316                    <label class="label" for="email"><span>Email</span></label>
4317                    <div class="control">
4318                        <input name="login[username]" value=""
4319                             autocomplete="off"                               id="email" type="email" class="input-text"
4320                               title="Email"
4321                               data-mage-init='{"mage/trim-input":{}}'
4322                               data-validate="{required:true, 'validate-email':true}">
4323                    </div>
4324                </div>
4325                <div class="field password required">
4326                    <label for="pass" class="label"><span>Password</span></label>
4327                    <div class="control">
4328                        <input name="login[password]" type="password"
4329                             autocomplete="off"                               class="input-text" id="pass"
4330                               title="Password"
4331                               data-validate="{required:true}">
4332                    </div>
4333                </div>
4334                <div class="field choice" data-bind="scope: 'showPassword'">
4335                    <!-- ko template: getTemplate() --><!-- /ko -->
4336                </div>
4337                <div id="remember-me-box" class="field choice persistent">
4338        <input type="checkbox" name="persistent_remember_me" class="checkbox" id="remember_meAVfHhpy1Av"  checked="checked"  title="Remember&#x20;Me" />
4339    <label for="remember_meAVfHhpy1Av" class="label"><span>Remember Me</span></label>
4340    <span class="tooltip wrapper">
4341        <strong class="tooltip toggle"> What&#039;s this?</strong>
4342        <span class="tooltip content"> Check &quot;Remember Me&quot; to access your shopping cart on this computer even if you are not signed in.</span>
4343    </span>
4344</div>
4345                <div class="actions-toolbar">
4346                    <div class="primary"><button type="submit" class="action login primary" name="send" id="send2" ><span>Sign In</span></button></div>
4347                    <div class="secondary"><a class="action remind" href="https://donotage.org/customer/account/forgotpassword/"><span>Forgot Your Password?</span></a></div>
4348                </div>
4349            </fieldset>
4350        </form>
4351    </div>
4352        
4352<script type="text/x-magento-init">
4353        {
4354            "*": {
4355                "Magento_Customer/js/block-submit-on-send": {
4356                    "formId": "login-form"
4357                },
4358                "Magento_Ui/js/core/app": {
4359                    "components": {
4360                        "showPassword": {
4361                            "component": "Magento_Customer/js/show-password",
4362                            "passwordSelector": "#pass"
4363                        }
4364                    }
4365                }
4366            }
4367        }
4368    </script>
4368
4369</div>
4370<div class="block block-new-customer">
4371    <div class="block-title">
4372        <strong id="block-new-customer-heading" role="heading" aria-level="2">New Customers</strong>
4373    </div>
4374    <div class="block-content" aria-labelledby="block-new-customer-heading">
4375        <p>Creating an account has many benefits: earn rewards, check out faster, keep more than one address, track orders and more.</p>
4376        <div class="actions-toolbar">
4377            <div class="primary">
4378                <a href="https://donotage.org/customer/account/create/" class="action create primary"><span>Create an Account</span></a>
4379            </div>
4380        </div>
4381    </div>
4382</div>
4383</div>
4383<script type="text/x-magento-init">
4384    {
4385        "body": {
4386            "requireCookie": {"noCookieUrl":"https:\/\/donotage.org\/cookie\/index\/noCookies\/","triggers":[".action.login"],"isRedirectCmsPage":true}        }
4387    }
4388</script>
4388
4389</div></div></main><div class="as-seen-on-container">    <div class="as-seen-on-title">Our research partners</div>
4390    <div class="glide as-seen-on-slider">
4391        <div class="glide__track" data-glide-el="track">
4392            <ul class="glide__slides">
4393                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/23.png')"></li>
4394                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/ARDD1.png')"></li>
4395                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/Aarhus_University_Hospital_Denmark1.png')"></li>
4396                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/Alliance_for_longevity_initiatives.png')"></li>
4397                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/BSRA.png')"></li>
4398                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/Bloodo.png')"></li>
4399                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/Citrus_Labs.png')"></li>
4400                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/DoNotAge.png')"></li>
4401                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/Evandro_Fang_Lab.png')"></li>
4402                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/Imperial_college_london.png')"></li>
4403                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/Institute_of_Rejuvenation_Science.png')"></li>
4404                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/Kings_College_London.png')"></li>
4405                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/LifeLab_new.png')"></li>
4406                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/National_university_Singapore.png')"></li>
4407                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/Newcastle_University%281%29.png')"></li>
4408                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/No_age_network.png')"></li>
4409                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/Queen_Mary_University_of_London%281%29.png')"></li>
4410                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/Teeside_university.png')"></li>
4411                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/The_Clock_Foundation.png')"></li>
4412                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/TriHealth.png')"></li>
4413                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/TruDiagnostics.png')"></li>
4414                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/UNICAMP.png')"></li>
4415                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/USC_new.png')"></li>
4416                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/Ulster_University.png')"></li>
4417                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/University_of_Arizona%282%29.png')"></li>
4418                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/University_of_Birmingham1.png')"></li>
4419                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/University_of_Central_Lancashire.png')"></li>
4420                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/University_of_Hertfordshire.png')"></li>
4421                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/University_of_Lincoln%281%29.png')"></li>
4422                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/University_of_Minnesota.png')"></li>
4423                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/University_of_Nottingham%281%29.png')"></li>
4424                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/University_of_O
4424slo.png')"></li>
4425                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/University_of_Rocester.png')"></li>
4426                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/University_of_Southampton%281%29.png')"></li>
4427                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/University_of_exeter.png')"></li>
4428                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/University_of_oxford%281%29.png')"></li>
4429                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/University_of_rochester_medicine.png')"></li>
4430                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/Washington_University_Saint_Louis.png')"></li>
4431                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/Yong_Loo_Lin_School_of_Medicine.png')"></li>
4432                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/aarhus_university_new.png')"></li>
4433                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/texas_tech.png')"></li>
4434                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/university_of_edinburgh%281%29.png')"></li>
4435                                    <li class="glide__slide" style="background-image: url('https://donotage.org/media/wysiwyg/as_seen_on/university_of_glasgow.png')"></li>
4436                            </ul>
4437        </div>
4438    </div>
4439    
4439<script type="text/x-magento-init">
4440        {
4441            "*": {
4442                "DoNotAge_AsSeenOn/js/slider" : {}
4443            }
4444        }
4445    </script>
4445
4446</div><footer class="page-footer"><div id="green-bar-footer" class="green-bar">        <div data-gmt-type="section" data-gmt-id="250" class="goomento&#x20;gmt&#x20;gmt-392726" data-gmt-settings="&#x5B;&#x5D;">
4447            <div class="gmt-inner">
4448                <div class="gmt-section-wrap">
4449                            <section class="gmt-element&#x20;gmt-element-6625613&#x20;gmt-section-boxed&#x20;gmt-section-height-default&#x20;gmt-section-height-default&#x20;gmt-section&#x20;gmt-top-section" data-id="6625613" data-element_type="section">
4450            
4451            <div class="gmt-container gmt-column-gap-default">
4452                <div class="gmt-row">
4453                <div class="gmt-element&#x20;gmt-element-61b97e7&#x20;gmt-column&#x20;gmt-col-100&#x20;gmt-top-column" data-id="61b97e7" data-element_type="column">
4454        <div class="gmt-column-wrap&#x20;&#x20;gmt-element-populated">
4455                <div class="gmt-widget-wrap">
4456                <div class="gmt-element&#x20;gmt-element-bd5bb27&#x20;gmt-widget&#x20;gmt-widget-text-editor" data-id="bd5bb27" data-element_type="widget" data-widget_type="text-editor">
4457                <div class="gmt-widget-container" data-mage-init="&#x7B;&quot;goomento-widget-text-editor&quot;&#x3A;&#x5B;&#x5D;&#x7D;">
4458            <div class="gmt-text-editor&#x20;gmt-clearfix">
4459
4460            <p style="text-align: center;">
4461<div class="green-bar__columns">
4462            <div class="green-bar__column green-bar__column--left">
4463            <span class="green-bar-message">
4464                Free USA delivery on orders over $50 | Worldwide $300            </span>
4465        </div>
4466    </div>
4467</p>
4468        </div>
4469        </div>
4470                </div>
4471                </div>
4472        </div>
4473        </div>
4474                        </div>
4475            </div>
4476        </section>
4477                        </div>
4478            </div>
4479        </div>
4480        </div><div class="footer content"><div id="footer-content-links" class="footer-content-links"><div class="footer-links__row">
4481    <div class="footer-links__columns">
4482        <div class="footer-links__column social-column">
4483            <a href="/" aria-label="Back to Homepage"><img width="75" src="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/images/logo-white.svg" alt="Small DoNotAge.org Logo"></a>
4484            <a href="/" aria-label="Back to Homepage" class="logo-link"><img width="253" src="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/images/footer-logo.svg" alt="Standard DoNotAge.org Logo"></a>
4485            <div class="social-links">
4486                <a href="https://www.facebook.com/DoNotAge.org/" aria-label="Facebook Link" target="_blank"><img width="46" src="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/images/facebook.svg" alt="Facebook Icon"></a>
4487                <a href="https://twitter.com/Do_Not_Age" aria-label="Twitter Link" target="_blank"><img width="46" src="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/images/twitter.svg"  alt="Twitter Icon"></a>
4488                <a href="https://instagram.com/do.not.age" aria-label="Instagram Link" target="_blank"><img width="46" src="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/images/instagram.svg"  alt="Instagram Icon"></a>
4489                <a href="https://www.tiktok.com/@donotage.org" aria-label="TikTok Link" target="_blank"><img width="46" style="border-radius:30%;" src="https://donotage.org/static/version1790156720/frontend/Dna/b2c-theme/en_US/images/tiktok.webp" alt="TikTok Icon"></a>
4490            </div>
4491        </div>
4492        <div class="footer-links__column">
4493            <h2>QUICK LINKS</h2>
4494            <div>
4495                <ul>
4496                    <li><a href="https://donotage.org/" aria-label="Back to Homepage">Home</a></li>
4497                    <li><a href="https://donotage.org/productstmp" aria-label="Ingredients Page">Shop</a></li>
4498                    <!-- Removed as we no longer use Amasty Affiliate module -->
4499                    <!-- <li><a href="https://donotage.org/affiliate/account/program" aria-label="Partner Program">Partner Program</li> -->
4500                    <li><a href="https://donotage.org/science" aria-label="The Science">Science</a></li>
4501                    <li><a href="https://donotage.org/about-us" aria-label="About Us">About Us</a></li>
4502                    <li><a href="https://donotage.org/blogs" aria-label="Blog Posts">Blogs</a></li>
4503                    <li><a href="https://donotage.org/contact-us" aria-label="Contact Us">Contact Us</a></li>
4504                </ul>
4505            </div>
4506        </div>
4507        <div class="footer-links__column">
4508            <h2>USEFUL LINKS</h2>
4509            <div>
4510                <ul>
4511                    <li><a href="https://donotage.org/contact-us" aria-label="Contact Us">Contact Us</a></li>
4512                    <li><a href="https://donotage.org/work-with-us" aria-label="Work With Us">Work With Us</a></li>
4513                    <li><a href="https://donotage.org/frequently-asked-questions" aria-label="Frequently Asked Questions">Frequently Asked Questions</a></li>
4514                    <li><a href="https://donotage.org/refund-returns" aria-label="Returns Policy">Returns</a></li>
4515                    <li><a href="https://donotage.org/privacy-policy" aria-label="Privacy Policy">Privacy Policy</a></li>
4516                    <li><a href="https://donotage.org/cookie-policy" aria-label="Cookie Policy">Cookie Policy</a></li>
4517                </ul>
4518            </div>
4519        </div>
4520        <div class="footer-links__column">
4521            <h2>USER</h2>
4522            <div>
4523                <ul>
4524                    <li class="link authorization-link" data-label="or">
4525    <a href="https://donotage.org/customer/account/login/referer/aHR0cHM6Ly9kb25vdGFnZS5vcmcvY3VzdG9tZXIvYWNjb3VudC9sb2dpbi8~/"        >Sign In</a>
4526</li>
4527<li><a href="https://donotage.org/customer/account/create/" id="idc0Q9OYbC" >
4527Registration</a></li>                    <li><a href="https://donotage.org/checkout/cart" aria-label="My Cart">Cart</a></li>
4528                </ul>
4529            </div>
4530        </div>
4531    </div>
4532    <div style="text-align: center; padding-top: 2rem;">
4533        <a href="//www.dmca.com/Protection/Status.aspx?id=60f35731-d3f3-4908-92f3-9ff202253fd9" title="DMCA.com Protection Status" class="dmca-badge">
4534            <img src="//images.dmca.com/Badges/dmca-badge-w150-5x1-01.png?ID=//www.dmca.com/Protection/Status.aspx?id=60f35731-d3f3-4908-92f3-9ff202253fd9" alt="DMCA.com Protection Status">
4535        </a>
4536        
4536<script src="//images.dmca.com/Badges/DMCABadgeHelper.min.js"></script>
4536
4537    </div>
4538</div>
4539<!-- Start of HubSpot Embed Code -->
4540<script type="text/javascript" id="hs-script-loader" async defer src="//js.hs-scripts.com/49276204.js"></script>
4540
4541<!-- End of HubSpot Embed Code --></div>    
4541<script>
4542    !function(){var o=window.tdl=window.tdl||[];if(o.invoked)window.console&&console.error&&console.error("Tune snippet has been included more than once.");else{o.invoked=!0,o.methods=["init","identify","convert"],o.factory=function(n){return function(){var e=Array.prototype.slice.call(arguments);return e.unshift(n),o.push(e),o}};for(var e=0;e<o.methods.length;e++){var n=o.methods[e];o[n]=o.factory(n)}o.init=function(e){var n=document.createElement("script");n.type="text/javascript",n.async=!0,n.src="https://js.go2sdk.com/v2/tune.js";var t=document.getElementsByTagName("script")[0];t.parentNode.insertBefore(n,t),o.domain=e}}}();
4543    tdl.init("https://shopback.go2cloud.org");
4544    tdl.identify();
4545    </script>
4545
4546</div>
4546<script>
4547    (async function() {
4548        try {
4549            let campaignId    = false;
4550            const queryString = window.location.search;
4551            const urlParams   = new URLSearchParams(queryString);
4552            if (urlParams.has('dna_link')) {
4553                campaignId = urlParams.get('dna_link');
4554            } else if (urlParams.has('dna_campaign')) {
4555                campaignId = urlParams.get('dna_campaign');
4556            }
4557            if (campaignId) {
4558
4559                const reqHeaders = new Headers();
4560                reqHeaders.append("X-Requested-With", "XMLHttpRequest");
4561
4562                const formData = new FormData();
4563                formData.append("campaign_id", campaignId);
4564
4565                const response = await fetch(
4566                    'https://donotage.org/dna_affiliates/campaign/track/',
4567                    {
4568                        method: "POST",
4569                        body: formData,
4570                        headers: reqHeaders
4571                    }
4572                );
4573                if (!response.ok) {
4574                    throw new Error('Response status: ${response.status}');
4575                }
4576            }
4577        } catch (error) {
4578            console.log(error.message);
4579        }
4580    })();
4581</script>
vendor: 1 bytes, line 4581
4581
4582<script>
4583    (async function dnaLocate() {
4584        try {
4585
4586            const storeCurrency = 'USD';
4587            const urls = {
4588                'GBP': 'https://donotage.org/stores/store/redirect/___store/gbp/___from_store/usd/uenc/aHR0cHM6Ly9kb25vdGFnZS5vcmcvY3VzdG9tZXIvYWNjb3VudC9sb2dpbi8_X19fc3RvcmU9dXNk/',
4589                'USD': 'https://donotage.org/stores/store/redirect/___store/usd/___from_store/usd/uenc/aHR0cHM6Ly9kb25vdGFnZS5vcmcvY3VzdG9tZXIvYWNjb3VudC9sb2dpbi8_X19fc3RvcmU9dXNk/',
4590                'EUR': 'https://donotage.org/stores/store/redirect/___store/eur/___from_store/usd/uenc/aHR0cHM6Ly9kb25vdGFnZS5vcmcvY3VzdG9tZXIvYWNjb3VudC9sb2dpbi8_X19fc3RvcmU9dXNk/',
4591                'AUD': 'https://donotage.org/stores/store/redirect/___store/aud/___from_store/usd/uenc/aHR0cHM6Ly9kb25vdGFnZS5vcmcvY3VzdG9tZXIvYWNjb3VudC9sb2dpbi8_X19fc3RvcmU9dXNk/',
4592                'CAD': 'https://donotage.org/stores/store/redirect/___store/cad/___from_store/usd/uenc/aHR0cHM6Ly9kb25vdGFnZS5vcmcvY3VzdG9tZXIvYWNjb3VudC9sb2dpbi8_X19fc3RvcmU9dXNk/'
4593            };
4594
4595            const reqHeaders = new Headers();
4596            reqHeaders.append("X-Requested-With", "XMLHttpRequest");
4597            const response = await fetch(
4598                'https://donotage.org/dna_geoip/locate/me/',
4599                {
4600                    method: "POST",
4601                    headers: reqHeaders
4602                }
4603            )
4604            .then(response => response.json())
4605            .then(data => {
4606                if (data.action == 'redirect'
4607                    && storeCurrency !== data.currency) {
4608                    const params = window.location.search;
4609                    const targetUrl = urls[data.currency] + (params ? params : '');
4610                    const hasRef = new URLSearchParams(params).has('ref');
4611                    if (hasRef) {
4612                        setTimeout(() => { window.location.href = targetUrl; }, 3000);
4613                    } else {
4614                        window.location.href = targetUrl;
4615                    }
4616                }
4617            });
4618
4619        } catch (error) {
4620            console.log(error.message);
4621        }
4622    })();
4623</script>
4623
4624</footer>
4624<script type="text/x-magento-init">
4625        {
4626            "*": {
4627                "Magento_Ui/js/core/app": {
4628                    "components": {
4629                        "storage-manager": {
4630                            "component": "Magento_Catalog/js/storage-manager",
4631                            "appendTo": "",
4632                            "storagesConfiguration" : {"recently_viewed_product":{"requestConfig":{"syncUrl":"https:\/\/donotage.org\/catalog\/product\/frontend_action_synchronize\/"},"lifetime":"1000","allowToSendRequest":null},"recently_compared_product":{"requestConfig":{"syncUrl":"https:\/\/donotage.org\/catalog\/product\/frontend_action_synchronize\/"},"lifetime":"1000","allowToSendRequest":null},"product_data_storage":{"updateRequestConfig":{"url":"https:\/\/donotage.org\/rest\/usd\/V1\/products-render-info"},"requestConfig":{"syncUrl":"https:\/\/donotage.org\/catalog\/product\/frontend_action_synchronize\/"},"allowToSendRequest":null}}                        }
4633                    }
4634                }
4635            }
4636        }
4637</script>
4637
4638<!-- Google Tag Manager (noscript) -->
vendor: 69 bytes, lines 4638-4639
4638
4639<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=
4639GTM-TDVDZJR
vendor: 85 bytes, lines 4639-4640
4639"
4640height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
4641<!-- End Google Tag Manager (noscript) -->
4642
4643<!-- Wicked Reports -->
4644<script type="text/javascript" src="https://widget.wickedreports.com/v2/4751/wr-c179075f57f54cef0da259a9f0a26b62.js" async></script>
4644
4645<!-- End of Wicked Reports -->
4645<script type="text/x-magento-init">
4646{
4647    "*": {
4648        "dnaGtmEvents": {
4649            "pageViewData": {}
4650        }
4651    }
4652}
4653</script>
4653
4654<!-- dna:openai-ads-lead-created -->
4655<script>(function (w, d) {
4656    try {
4657        if (w.__dnaOaiqLead) { return; }
4658        w.__dnaOaiqLead = 1;
4659
4660        var PENDING_PARAM = 'dna_oaiq_lead_pending';
4661        var PENDING_STORAGE = 'dna_oaiq_lead_pending';
4662        var CONFIRMED_COOKIE_PREFIX = 'dna_oaiq_lead_confirmed_';
4663        var CURRENT_STORE_ID = String(2);
4664        var PENDING_MARKER = new RegExp('^' + CURRENT_STORE_ID + '\\.[A-Za-z0-9]{32}$');
4665        var SUBSCRIBE_PATH = /(^|\/)newsletter\/subscriber\/new\/?$/i;
4666
4667        function isSubscribeAction(action) {
4668            if (!action) { return false; }
4669            try {
4670                return SUBSCRIBE_PATH.test(new URL(action, location.href).pathname);
4671            } catch (e) {
4672                return false;
4673            }
4674        }
4675
4676        function isSubscriptionForm(form) {
4677            try {
4678                var action = form.action ||
4679                    (typeof form.getAttribute === 'function' && form.getAttribute('action'));
4680                if (isSubscribeAction(action)) { return true; }
4681
4682                /* Customer registration, account preferences and checkout opt-ins
4683                   reach subscribeCustomer() through other routes. Only arm those
4684                   forms when their newsletter checkbox is actually selected. */
4685                if (typeof form.querySelector !== 'function') { return false; }
4686                var optIn = form.querySelector(
4687                    'input[type="checkbox"][name="is_subscribed"], ' +
4688                    'input[type="checkbox"][name="newsletter"]'
4689                );
4690                return !!(optIn && optIn.checked);
4691            } catch (e) {
4692                return false;
4693            }
4694        }
4695
4696        /* Re-read the CURRENT analytics choice. The ledger POST is first-party, so
4697           it survives a marketing-only opt-out (m:0) — but an explicit a:0 is a
4698           refusal of analytics itself, which is exactly what this is, and the
4699           Collector storefront tracker honours it by sending nothing. Honour it
4700           here too rather than routing around our own opt-out. */
4701        /* Scans EVERY dna_consent cookie, not just the first. Cookies are
4702           serialised longest-Path-first (RFC 6265 5.4), so a stale
4703           `a:1; Path=/checkout` is presented BEFORE the site-wide `Path=/` cookie
4704           the banner just wrote with a:0 — and stopping at the first hit would miss
4705           the visitor's current DENIAL. An explicit 0 anywhere wins. Mirrors
4706           consentBits() in pixel.phtml; each template is self-contained by design. */
4707        function analyticsDeclined() {
4708            try {
4709                var parts = ('; ' + d.cookie).split('; dna_consent=');
4710                for (var i = 1; i < parts.length; i++) {
4711                    var v;
4712                    try {
4713                        v = decodeURIComponent(parts[i].split(';')[0]);
4714                    } catch (e) {
4715                        /* A malformed consent value must not turn into permission. */
4716                        return true;
4717                    }
4718                    var a = /(?:^|,)a:(0|1)/.exec(v);
4719                    if (a && a[1] === '0') { return true; }
4720                }
4721                return false;
4722            } catch (e) {
4723                return true;
4724            }
4725        }
4726
4727        /* The pixel block's own gate, re-evaluated AT FIRE TIME. A submit happens
4728           minutes after load, so a page-load decision is stale: a visitor who
4729           denied marketing via the banner in between would otherwise still be sent
4730           to OpenAI purely because `window.oaiq` already existed. */
4731        function adConsentAllows() {
4732            try {
4733                if (typeof w.__dnaOaiqConsentAllows === 'function') {
4734                    return !!w.__dnaOaiqConsentAllows();
4735                }
4736            } catch (e) {}
4737            return true;
4738        }
4739
4740        function ingestBase() {
4741            try {
4742                var meta = d.querySelector('meta[name="dna-ingest-host"]');
4743                var host = (meta && meta.getAttribute('content')) ||
4744                    (/(^|\.)staging\.donotage\.org$/i.test(location.hostname)
4745                        ? 'go.staging.donotage.org'
4746                        : 'go.donotage.org');
4747                return 'https://' + host;
4748            } catch (e) { return null; }
4749        }
4750
4751        /* The LEDGER half. Reported as an analytics-grade `track` so the signup
4752           lands in the attribution ledger on the same vid spine as the pageview
4753           and the later order — that is what makes the journey reconstructable.
4754           A form submit happens long after page load, so the storefront
4755           tracker's /session/init has already minted the signed session cookie
4756           this endpoint requires. Best-effort and never blocking: if the
4757           Collector is down the pixel event below still fires. */
4758        /* Origin + path only, no query or fragment. A storefront URL's query can
4759           carry PII (an email in a share link, a token, a search term), and the
4760           page identity is all the ledger needs. Mirrors the scrubbing the
4761           Collector storefront tracker already does. */
4762        function safePage() {
4763            try {
4764                return location.origin + location.pathname;
4765            } catch (e) {
4766                return null;
4767            }
4768        }
4769
4770        function reportToLedger() {
4771            try {
4772                var base = ingestBase();
4773                if (!base) { return; }
4774                var payload = { kind: 'track', name: 'newsletter_signup' };
4775                /* WHICH page the signup happened on is the point of recording it:
4776                   homepage form vs a PDP vs the popup on a blog post are different
4777                   journeys, and without this the ledger row cannot tell them apart. */
4778                var page = safePage();
4779                if (page) { payload.page = page; }
4780                var body = JSON.stringify(payload);
4781                if (navigator.sendBeacon) {
4782                    try {
4783                        var blob = new Blob([body], { type: 'text/plain' });
4784                        if (navigator.sendBeacon(base + '/collect', blob)) { return; }
4785                    } catch (e) {}
4786                }
4787                fetch(base + '/collect', {
4788                    method: 'POST', credentials: 'include', keepalive: true,
4789                    headers: { 'content-type': 'text/plain' }, body: body
4790                }).catch(function () {});
4791            } catch (e) {}
4792        }
4793
4794        /* In-page fallback for the once-per-tab guard. sessionStorage THROWS under
4795           some privacy settings, and the old code swallowed that and carried on —
4796           failing OPEN, so a double submit produced two undeduplicated leads. */
4797        var firedInPage = false;
4798
4799        function alreadyFired() {
4800            if (firedInPage) { return true; }
4801            try {
4802                return !!sessionStorage.getItem('dna_oaiq_lead');
4803            } catch (e) {
4804                return false;
4805            }
4806        }
4807
4808        function markFired() {
4809            firedInPage = true;
4810            try { sessionStorage.setItem('dna_oaiq_lead', '1'); } catch (e) {}
4811        }
4812
4813        function fire() {
4814            try {
4815                /* Once per tab. A visitor who submits the homepage form and then
4816                   the popup is ONE lead, not two — and unlike order_created there
4817                   is no order id to dedup on server-side. */
4818                if (alreadyFired()) { return; }
4819                markFired();
4820
4821                /* First-party ledger event: blocked only by an explicit analytics
4822                   refusal, not by a marketing-only opt-out. */
4823                var analyticsAllowed = !analyticsDeclined();
4824                if (analyticsAllowed) { reportToLedger(); }
4825
4826                /* No stub means the pixel declined to render at load (off, or
4827                   consent denied). adConsentAllows() re-checks the CURRENT choice,
4828                   so a denial made after load also stops the send. */
4829                if (!analyticsAllowed || !w.oaiq || !adConsentAllows()) { return; }
4830                w.oaiq('measure', 'lead_created', { type: 'customer_action' });
4831            } catch (e) {}
4832        }
4833
4834        function readPendingMarkers() {
4835            try {
4836                var parsed = JSON.parse(sessionStorage.getItem(PENDING_STORAGE) || '[]');
4837                if (!Array.isArray(parsed)) { return []; }
4838                var valid = [];
4839                for (var i = 0; i < parsed.length; i++) {
4840                    if (typeof parsed[i] === 'string' && PENDING_MARKER.test(parsed[i])) {
4841                        valid.push(parsed[i]);
4842                    }
4843                }
4844                return valid;
4845            } catch (e) {
4846                return [];
4847            }
4848        }
4849
4850        function writePendingMarkers(markers) {
4851            try {
4852                var encoded = JSON.stringify(markers);
4853                sessionStorage.setItem(PENDING_STORAGE, encoded);
4854                return sessionStorage.getItem(PENDING_STORAGE) === encoded;
4855            } catch (e) {
4856                return false;
4857            }
4858        }
4859
4860        function randomToken() {
4861            var alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
4862            var token = '';
4863            try {
4864                if (w.crypto && typeof w.crypto.getRandomValues === 'function') {
4865                    var bytes = new Uint8Array(32);
4866                    w.crypto.getRandomValues(bytes);
4867                    for (var i = 0; i < bytes.length; i++) {
4868                        token += alphabet.charAt(bytes[i] % alphabet.length);
4869                    }
4870                    return token;
4871                }
4872            } catch (e) {}
4873            try {
4874                token = '';
4875                for (var j = 0; j < 32; j++) {
4876                    token += alphabet.charAt(Math.floor(Math.random() * alphabet.length));
4877                }
4878                return token;
4879            } catch (e) {
4880                return null;
4881            }
4882        }
4883
4884        function createPendingMarker() {
4885            if (!CURRENT_STORE_ID || CURRENT_STORE_ID === '0') { return null; }
4886            var token = randomToken();
4887            if (!token) { return null; }
4888            var marker = CURRENT_STORE_ID + '.' + token;
4889            var markers = readPendingMarkers();
4890            markers.push(marker);
4891            /* More than eight concurrent newsletter requests in one tab still
4892               represent one lead; cap stale bookkeeping without affecting normal
4893               retries or redirect persistence. */
4894            markers = markers.slice(-8);
4895            return writePendingMarkers(markers) ? marker : null;
4896        }
4897
4898        function removePendingMarker(marker) {
4899            var markers = readPendingMarkers().filter(function (value) {
4900                return value !== marker;
4901            });
4902            writePendingMarkers(markers);
4903        }
4904
4905        function confirmedCookieName(marker) {
4906            return CONFIRMED_COOKIE_PREFIX + marker.split('.')[1];
4907        }
4908
4909        function readConfirmedMarker() {
4910            try {
4911                var markers = readPendingMarkers();
4912                for (var i = 0; i < markers.length; i++) {
4913                    var cookieName = confirmedCookieName(markers[i]);
4914                    var parts = (';
4914 ' + d.cookie).split('; ' + cookieName + '=');
4915                    for (var j = 1; j < parts.length; j++) {
4916                        var value;
4917                        try {
4918                            value = decodeURIComponent(parts[j].split(';')[0]);
4919                        } catch (e) {
4920                            continue;
4921                        }
4922                        if (value === markers[i]) {
4923                            return { marker: markers[i], cookieName: cookieName };
4924                        }
4925                    }
4926                }
4927            } catch (e) {}
4928            return null;
4929        }
4930
4931        function clearConfirmedMarker(cookieName) {
4932            try {
4933                d.cookie = cookieName + '=; Max-Age=0; path=/; SameSite=Lax' +
4934                    (location.protocol === 'https:' ? '; Secure' : '');
4935            } catch (e) {}
4936        }
4937
4938        function consumeConfirmedMarker() {
4939            var confirmation = readConfirmedMarker();
4940            if (!confirmation) { return false; }
4941            /* Delete BEFORE sending. A navigation or exception cannot replay the
4942               marker; sessionStorage remains the second once-per-tab guard. */
4943            clearConfirmedMarker(confirmation.cookieName);
4944            removePendingMarker(confirmation.marker);
4945            fire();
4946            return true;
4947        }
4948
4949        /* A normal Magento form redirects, so the marker is consumed on the next
4950           page load. AJAX/Breeze forms stay on the page: CookieStore observes the
4951           response where available. The fallback polls only for 30 seconds after
4952           a native newsletter submit; it never treats that submit as success and
4953           never leaves a timer running on every storefront page. */
4954        consumeConfirmedMarker();
4955        w.addEventListener('pageshow', consumeConfirmedMarker, false);
4956        d.addEventListener('visibilitychange', function () {
4957            if (!d.hidden) { consumeConfirmedMarker(); }
4958        }, false);
4959        try {
4960            if (w.cookieStore && typeof w.cookieStore.addEventListener === 'function') {
4961                w.cookieStore.addEventListener('change', consumeConfirmedMarker);
4962            }
4963        } catch (e) {}
4964
4965        var markerPoll = null;
4966        var markerPollAttempts = 0;
4967
4968        function stopMarkerPoll() {
4969            if (markerPoll === null) { return; }
4970            w.clearInterval(markerPoll);
4971            markerPoll = null;
4972        }
4973
4974        function startMarkerPoll() {
4975            markerPollAttempts = 0;
4976            if (markerPoll !== null) { return; }
4977            markerPoll = w.setInterval(function () {
4978                markerPollAttempts++;
4979                if (consumeConfirmedMarker() || markerPollAttempts >= 60) {
4980                    stopMarkerPoll();
4981                }
4982            }, 500);
4983        }
4984
4985        function attachPendingMarker(form) {
4986            var marker = createPendingMarker();
4987            if (!marker) { return false; }
4988            try {
4989                var field = typeof form.querySelector === 'function'
4990                    ? form.querySelector('input[name="' + PENDING_PARAM + '"]')
4991                    : null;
4992                if (!field) {
4993                    field = d.createElement('input');
4994                    field.type = 'hidden';
4995                    field.name = PENDING_PARAM;
4996                    form.appendChild(field);
4997                }
4998                field.disabled = false;
4999                field.value = marker;
5000                return true;
5001            } catch (e) {
5002                removePendingMarker(marker);
5003                return false;
5004            }
5005        }
5006
5007        d.addEventListener('submit', function (ev) {
5008            try {
5009                var form = ev.target;
5010                if (!form || String(form.tagName).toLowerCase() !== 'form') { return; }
5011                if (!isSubscriptionForm(form)) { return; }
5012                if (attachPendingMarker(form)) { startMarkerPoll(); }
5013            } catch (e) {}
5014        }, true);
5015
5016        w.addEventListener('pagehide', function () {
5017            stopMarkerPoll();
5018        }, false);
5019
5020        /* Since we use diff forms on diff site using form id is the proper way */
5021        var HUBSPOT_LEAD_FORM_IDS = ["a1a2e1de-a5c5-41ab-839b-394f35bf08cc","6fc4b0a6-2a62-4a6f-a391-b80b535898ce"];
5022        w.addEventListener('message', function (ev) {
5023            try {
5024                var data = ev && ev.data;
5025                if (!data || data.type !== 'hsFormCallback' || data.eventName !== 'onF
5025ormSubmitted') {
5026                    return;
5027                }
5028                if (!data.id || HUBSPOT_LEAD_FORM_IDS.indexOf(data.id) === -1) {
5029                    return;
5030                }
5031                if (!/(^|\.)hsforms\.(com|net)$/i.test((function () {
5032                    try { return new URL(ev.origin).hostname; } catch (e) { return ''; }
5033                })())) {
5034                    return;
5035                }
5036                fire();
5037            } catch (e) {}
5038        }, false);
5039
5040        w.dnaOpenAiAds = w.dnaOpenAiAds || {};
5041        w.dnaOpenAiAds.lead = fire;
5042    } catch (e) {}
5043})(window, document);</script>
5043<!-- /dna:openai-ads-lead-created -->
5044<script>
5045    const customActionsHandler = (actionType, actionData) => {
5046        if (actionType === 'redirect') {
5047            const loginUrl = `${window.location.origin}/customer/account/login/`;
5048            const isLoggedIn = RallyCheckoutData?.customerData?.firstName && actionData?.url;
5049            window.location.href = isLoggedIn ? actionData.url : loginUrl;
5050        }
5051    }
5052    document.addEventListener('rally.storefront.initiated', () => Rally.updateConfig({ redirect: false, customActionsHandler }));
5053    try {
5054        Rally.updateConfig({ redirect: false, customActionsHandler });
5055    } catch (e) {
5056        console.log(e);
5057    }
5058</script>
5058
5059<!-- dna:collector-tracker -->
5060<script>(function () {
5061  try {
5062    var w = window, d = document;
5063    if (w.__dnaSfTracker) return; w.__dnaSfTracker = 1;
5064    if (!w.fetch) return;
5065
5066    /* Ingest host. Both environments route every Collector surface through the
5067       single `go.` edge host (path mode) — the browser-ingest endpoints
5068       (/api/event, /collect, /session/*) are served there alongside the resolver,
5069       so there is no separate ingest subdomain. Derive it from the storefront host:
5070         staging -> go.staging.donotage.org
5071         prod    -> go.donotage.org
5072       An explicit <meta name="dna-ingest-host"> still overrides (future envs). */
5073    var meta = d.querySelector('meta[name="dna-ingest-host"]');
5074    var ING = (meta && meta.getAttribute('content')) ||
5075      (/(^|\.)staging\.donotage\.org$/i.test(location.hostname) ? 'go.staging.donotage.org' : 'go.donotage.org');
5076    var BASE = 'https://' + ING;
5077
5078    /* Consent: honour an explicit dna_consent opt-out (a:0 → send nothing, below).
5079       `analyticsGranted` gates the high-entropy device-fingerprint stitch beacon.
5080       TEMPORARY (dna-platform issue #36): default to GRANTED under the interim
5081       posture so the stitch (fingerprint + click-ids) also fires for un-prompted
5082       visitors — this storefront has no consent banner yet, and this mirrors the
5083       collector's CONSENT_DEFAULT_GRANT_ALL flag. An explicit `a:0` still opts out.
5084       REVERT to `false` (fingerprint gated on an EXPLICIT a:1) once the storefront
5085       consent banner ships. */
5086    var analyticsGranted = true;
5087    try {
5088      var cm = ('; ' + d.cookie).match(/; dna_consent=([^;]*)/);
5089      if (cm) {
5090        var a = /(?:^|,)a:(0|1)/.exec(decodeURIComponent(cm[1]));
5091        if (a && a[1] === '0') return; /* analytics declined -> send nothing */
5092        if (a && a[1] === '1') analyticsGranted = true; /* explicit grant: fingerprint allowed */
5093      }
5094    } catch (e) {}
5095
5096    /* Per-tab session id (events.session_id -> visits/duration). 30-min idle rotation. */
5097    var SID = null;
5098    try {
5099      var now = Date.now();
5100      var raw = sessionStorage.getItem('dna_sid');
5101      var at = parseInt(sessionStorage.getItem('dna_sid_at') || '0', 10) || 0;
5102      if (raw && now - at <= 30 * 60 * 1000) {
5103        SID = raw; sessionStorage.setItem('dna_sid_at', String(now));
5104      } else if (w.crypto && w.crypto.randomUUID) {
5105        SID = w.crypto.randomUUID();
5106        sessionStorage.setItem('dna_sid', SID);
5107        sessionStorage.setItem('dna_sid_at', String(now));
5108      }
5109    } catch (e) {}
5110
5111    function post(path, body, beacon) {
5112      try {
5113        var s = JSON.stringify(body);
5114        if (beacon && navigator.sendBeacon) { navigator.sendBeacon(BASE + path, s); return; }
5115        fetch(BASE + path, {
5116          method: 'POST', credentials: 'include', keepalive: true,
5117          headers: { 'content-type': 'text/plain' }, body: s
5118        }).catch(function () {});
5119      } catch (e) {}
5120    }
5121
5122    /* Scrub a URL to origin + path + ONLY the allowlisted marketing params, so a
5123       storefront URL's PII-bearing query/hash (email, token, search, order ids)
5124       NEVER crosses the wire — the Collector strips server-side too, but we do not
5125       send it in the first place. */
5126    var ALLOW = ['ref', 'campaign_id', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
5127    function safeUrl(u) {
5128      try {
5129        var x = new URL(u), keep = new URLSearchParams();
5130        for (var i = 0; i < ALLOW.length; i++) { var v = x.searchParams.get(ALLOW[i]); if (v) keep.set(ALLOW[i], v); }
5131        var qs = keep.toString();
5132        return x.origin + x.pathname + (qs ? ('?' + qs) : '');
5133      } catch (e) { return ''; }
5134    }
5135    /* Referrer reduced to origin + path (drop its query/fragment entirely). */
5136    function refOriginPath(u) {
5137      try { var x = new URL(u); return x.origin + x.pathname; } catch (e) { return null; }
5138    }
5139
5140    /* Pageview (Plausible v34 wire). */
5141    function pageview() {
5142      var p = { n: 'pageview', u: safeUrl(location.href), d: location.host, r: d.referrer ? refOriginPath(d.referrer) : null, v: 34 };
5143      try { p.vw = w.innerWidth || 0; } catch (e) {}
5144      if (SID) p.sid = SID;
5145      post('/api/event', p);
5146    }
5147
5148    /* Landing attribution: capture ?ref= / ?campaign_id= / utm_* as ONE
5149       analytics-grade `attribution_touch` track (NEVER a money-path click — that
5150       is server-only). Deduped per tab. Mirrors @dna/sdk ATTRIBUTION_TOUCH_EVENT. */
5151    function clean(v) {
5152      if (!v) return null;
5153      var o = '';
5154      for (var i = 0; i < v.length; i++) {
5155        var c = v.charCodeAt(i);
5156        /* drop C0 controls, DEL, and C1 controls (0x80-0x9f) */
5157        if (c >= 32 && c !== 127 && !(c >= 128 && c <= 159)) o += v[i];
5158      }
5159      o = o.trim();
5160      if (!o) return null;
5161      return o.length > 256 ? o.slice(0, 256) : o;
5162    }
5163    function attributionTouch() {
5164      try {
5165        var q = new URLSearchParams(location.search);
5166        var props = {};
5167        var ref = clean(q.get('ref')); if (ref) props.ref = ref;
5168        var cid = clean(q.get('campaign_id')); if (cid) props.campaign_id = cid;
5169        var utm = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'];
5170        for (var i = 0; i < utm.length; i++) { var v = clean(q.get(utm[i])); if (v) props[utm[i]] = v; }
5171        if (!Object.keys(props).length) return;
5172        var sig = JSON.stringify(props);
5173        try { if (sessionStorage.getItem('dna_attr_touch') === sig) return; sessionStorage.setItem('dna_attr_touch', sig); } catch (e) {}
5174        var body = { kind: 'track', name: 'attribution_touch', page: safeUrl(location.href), properties: props };
5175        if (SID) body.sessionId = SID;
5176        post('/collect', body);
5177      } catch (e) {}
5178    }
5179
5180    /* Identity stitch (probabilistic backstop). When the #dh handoff above did NOT
5181       survive (cookies wiped, jar-isolated in-app browser, fragment stripped by a
5182       tracking-redirect chain), an in-app email-browser click and the later
5183       real-browser purchase can still be bridged server-side via two device-bound
5184       signals that ride the SAME engine + hardware across that seam: ad click-ids
5185       from the URL, and a compact first-party device-core fingerprint (no 3rd-party
5186       lib). Ported from the funnel tracker (apps/funnels/scripts/tracker-snippet.html).
5187       Fires AT MOST ONCE per session (sessionStorage 'dna_fp_sent'), ONLY post-consent
5188       (the a:0 early-return above already gated us), and ONLY after the session
5189       handshake (so the signed session cookie rides along). Everything guarded:
5190       a missing API or sub-signal failure must never throw. */
5191
5192    /* cyrb53 — tiny non-cryptographic string hash (folds all signals into one
5193       stable, compact `full` token). Inlined; no deps. */
5194    function cyrb53(str, seed) {
5195      var h1 = 0xdeadbeef ^ (seed || 0), h2 = 0x41c6ce57 ^ (seed || 0);
5196      for (var i = 0, ch; i < str.length; i++) {
5197        ch = str.charCodeAt(i);
5198        h1 = Math.imul(h1 ^ ch, 2654435761);
5199        h2 = Math.imul(h2 ^ ch, 1597334677);
5200      }
5201      h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
5202      h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
5203      return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
5204    }
5205
5206    /* The high-stability device-core subset: GPU renderer + screen/dpr/colour +
5207       timezone + CPU/memory + platform/lang. Each sub-signal independently
5208       guarded — a single missing API must not lose the rest. */
5209    function deviceCore() {
5210      var c = {};
5211      try {
5212        var gpu = null;
5213        var cv = d.createElement('canvas');
5214        var gl = cv.getContext('webgl') || cv.getContext('experimental-webgl');
5215        if (gl) {
5216          var ext = gl.getExtension('WEBGL_debug_renderer_info');
5217          if (ext) gpu = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || null;
5218        }
5219        c.gpu = gpu;
5220      } catch (e) { c.gpu = null; }
5221      try { c.scr = (screen.width || 0) + 'x' + (screen.height || 0); } catch (e) {}
5222      try { c.dpr = w.devicePixelRatio; } catch (e) {}
5223      try { c.cd = screen.colorDepth; } catch (e) {}
5224      try { c.tz = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch (e) {}
5225      try { c.cores = navigator.hardwareConcurrency || null; } catch (e) {}
5226      try { c.mem = navigator.deviceMemory || null; } catch (e) {}
5227      try { c.plat = navigator.platform || null; } catch (e) {}
5228      try { c.lang = navigator.language || null; } catch (e) {}
5229      return c;
5230    }
5231
5232    /* A small canvas-render hash — text + shapes exercise the GPU/driver/AA
5233       stack, a stable per-device signal. Returns '' on any failure. */
5234    function canvasHash() {
5235      try {
5236        var cv = d.createElement('canvas');
5237        cv.width = 240; cv.height = 60;
5238        var cx = cv.getContext('2d');
5239        if (!cx) return '';
5240        cx.textBaseline = 'top';
5241        cx.font = "14px 'Arial'";
5242        cx.fillStyle = '#f60'; cx.fillRect(125, 1, 62, 20);
5243        cx.fillStyle = '#069'; cx.fillText('DoNotAge ⚡ fp', 2, 15);
5244        cx.fillStyle = 'rgba(102,204,0,0.7)'; cx.fillText('DoNotAge ⚡ fp', 4, 17);
5245        return cv.toDataURL();
5246      } catch (e) { return ''; }
5247    }
5248
5249    /* An optional audio-context hash (OfflineAudioContext render) — another
5250       device/driver-bound signal. Synchronous best-effort: returns '' if the API
5251       is missing or anything throws (never blocks the beacon). */
5252    function audioHash() {
5253      try {
5254        var AC = w.OfflineAudioContext || w.webkitOfflineAudioContext;
5255        if (!AC) return '';
5256        var ctx = new AC(1, 5000, 44100);
5257        var osc = ctx.createOscillator();
5258        osc.type = 'triangle';
5259        osc.frequency.value = 10000;
5260        var comp = ctx.createDynamicsCompressor();
5261        osc.connect(comp); comp.connect(ctx.destination);
5262        osc.start(0);
5263        ctx.startRendering();
5264        /* Don't await the async render (keeps the beacon synchronous & cheap) —
5265           the param config alone is a stable device signal we can fold in. */
5266        return String(comp.threshold.value) + ',' + String(comp.ratio.value) + ',' + osc.frequency.value;
5267      } catch (e) { return ''; }
5268    }
5269
5270    /* UTF-8 byte length of a string. TextEncoder where available, else the
5271       unescape(encodeURIComponent()) trick — this snippet runs BEFORE every
5272       polyfill. On a throw, OVER-count so the caller drops the value: fail
5273       closed. Mirrors u8len() in apps/funnels/scripts/session-snippet.html. */
5274    function u8len(s) {
5275      try {
5276        if (w.TextEncoder) return new w.TextEncoder().encode(s).length;
5277        return unescape(encodeURIComponent(s)).length;
5278      } catch (e) { return s.length * 6; }
5279    }
5280
5281    /* Bytes of a string's JSON encoding — the ONE unit every bound below counts in.
5282       The per-value click-id cap used to count UTF-16 CHARACTERS while the bag budget
5283       spent BYTES, and three bytes per character crossed them: 512 x U+4E00 costs
5284       1538 serialized bytes, MORE THAN THE WHOLE 1536 BUDGET, so one junk value
5285       consumed the budget and every real id behind it was dropped (#639). Charging
5286       both in JSON bytes closes that for every character width at once. It is also
5287       the strictest of the two wire forms the vid-handoff token travels in (this
5288       file's JSON body field, the funnel tracker's raw header) and is never smaller
5289       than the raw UTF-8 length, so one measurement bounds both carriers.
5290       Mirrors jsonLen() in apps/funnels/scripts/session-snippet.html. */
5291    function jsonLen(s) {
5292      try { return u8len(JSON.stringify(s)); } catch (e) { return s.length * 6 + 2; }
5293    }
5294
5295    /* Ad click-ids from the URL query (case-insensitive key match, value decoded).
5296       These are the strongest cross-context bridge: the SAME click-id presented by
5297       the in-app browser and later the real browser links the two vids server-side.
5298
5299       BOUNDS AND RULES MIRROR THE COLLECTOR and the three other parsers of this
5300       same list (packages/sdk/src/click-ids.ts, apps/funnels/scripts/session-
5301       snippet.html and tracker-snippet.html in dna-platform). Where they disagreed,
5302       this file was the one that was wrong. The numbers come from
5303       services/collector/src/routes/ingest.ts: MAX_CLICK_ID_KEYS 12,
5304       MAX_CLICK_ID_KEY_LEN 32, MAX_CLICK_ID_VALUE_LEN 512.
5305
5306       DROP an over-length id, NEVER truncate. A truncated gclid is not a lost
5307       signal, it is a WRONG-BUT-PLAUSIBLE one: it is inside every bound, so the
5308       collector accepts it, HMAC-signs it into the 90-day dna_click_bundle and
5309       hands it to Google Ads / Meta as a match key — where it matches nothing,
5310       silently, for the life of the cookie. The mint drops it too
5311       (canonicalizeBundleClickIds, packages/security/src/session-token.ts), so
5312       truncating never bought a surviving id; it only manufactured a corrupt one.
5313
5314       DUPLICATE key → the FIRST occurrence wins (this used to be the last). The
5315       value is minted into a SIGNED cookie that rides into checkout up to 90 days
5316       later, so on ?gclid=A&gclid=B keeping the landing URL's own first value is
5317       the fail-closed direction: an appended duplicate must not be able to steer
5318       the durable bundle.
5319
5320       SERIALIZED-BYTE BUDGET (1536) for the whole bag, and a PER-VALUE CAP OF 514
5321       BYTES charged in the SAME unit. Both numbers are the mint's own
5322       (MAX_BUNDLE_CLICK_IDS_BYTES and 512 ASCII chars + two JSON quotes,
5323       packages/security/src/session-token.ts), so nothing this parser sends is
5324       discarded at signing.
5325
5326       The per-value cap USED TO COUNT UTF-16 CHARACTERS while the budget spent
5327       BYTES, and that gap was the whole defect (#639): JSON escaping separates the
5328       two by up to 6x, but even 3x is enough, because 3 x 512 = 1536 IS the entire
5329       budget. ?fbclid=<500 x U+4E00>&gclid=<real gclid> therefore spent the budget
5330       on the junk and DROPPED the real gclid one parameter later. Junk survived,
5331       the money-path id did not. A control-character rule closes %00 and nothing
5332       else — U+2028, U+FFFD and any 3-byte character do the same job. Measuring
5333       the value in the budget's own unit bounds the worst single pair at
5334       "msclkid": (10) + 514 + , (1) = 525 bytes of the 1534 available, for
5335       1-, 2-, 3- and 4-byte characters and surrogate pairs alike.
5336
5337       THAT CAP ALONE WAS NOT ENOUGH, and the first round of #639 wrongly said it
5338       was. Three allowlisted junk values, 493 ASCII characters each and every one
5339       inside the per-value cap, still sum to the whole budget and drop a real
5340       gclid written after them: ?fbclid=<493 x A>&gbraid=<493 x A>&msclkid=<493 x
5341       A>&gclid=<real> — about 1 KB of query string, no oversized value and no
5342       exotic character needed. So the budget is now spent in want[] ORDER rather
5343       than URL order (PASS 2 below): who gets crowded out of a full bag is our
5344       ranking, not the attacker's parameter ordering. Neither rule defends against
5345       a link that simply writes ?gclid=JUNK itself; nothing about size or order can.
5346
5347       The budget also keeps a crafted link from 413-ing /session/init (16 KiB
5348       MAX_SESSION_INIT_BODY_BYTES, enforced by Fastify BEFORE Zod runs, so the
5349       per-field .catch(undefined) never gets the chance and the page gets no
5350       dna_sess and no vid at all). `continue`, never `break`: a rejected id costs
5351       only itself and its valid siblings still ride.
5352
5353       A field that did not percent-decode cleanly is DROPPED (it used to be
5354       relayed RAW), so ?gclid=%2 can no longer put the literal "%2" into the
5355       durable bundle — matching both funnel snippets, which drop on the same
5356       decodeURIComponent throw.
5357
5358       Total, never throws: a click-id problem must never cost the vid handshake.
5359       Whatever survived is returned; the caller sends the handshake regardless. */
5360    function clickIdsFromUrl() {
5361      /* want[] IS THE ADMISSION PRIORITY, not just the allowlist: PASS 2 spends the
5362         budget in this order, and it is the order CLICK_ID_KEYS lists these eight in
5363         at the mint (packages/security/src/session-token.ts), so this parser and the
5364         mint trim the same bag. Identical list, identical order, in every copy. */
5365      var want = ['gclid', 'gbraid', 'wbraid', 'fbclid', 'msclkid', 'ttclid', 'twclid', 'sccid'];
5366      var out = {};
5367      var bytes = 2;   /* the enclosing `{}`, charged exactly as the mint charges it */
5368      try {
5369        /* PASS 1 — candidates. Every per-VALUE rule applies here; the SHARED budget
5370           deliberately does not, so spending it cannot depend on the order the
5371           attacker chose to write the query in. */
5372        var cand = {}, candN = 0;
5373        var qs = (location.search || '').replace(/^\?/, '');
5374        if (!qs) return out;
5375        var parts = qs.split('&');
5376        for (var i = 0; i < parts.length; i++) {
5377          if (!parts[i]) continue;
5378          if (candN >= 12) break;                  /* MAX_CLICK_ID_KEYS (want[] is 8, headroom only) */
5379          var eq = parts[i].indexOf('=');
5380          if (eq === -1) continue;                 /* a bare flag carries no id */
5381          var key = '';
5382          try { key = decodeURIComponent(parts[i].slice(0, eq)); } catch (e) { continue; }
5383          if (key.length > 32) continue;           /* MAX_CLICK_ID_KEY_LEN */
5384          var lk = key.toLowerCase();
5385          if (want.indexOf(lk) === -1) continue;
5386          if (cand[lk]) continue;                  /* FIRST occurrence wins — see above */
5387          var val = '';
5388          try { val = decodeURIComponent(parts[i].slice(eq + 1).replace(/\+/g, ' ')); } catch (e) { continue; }
5389          if (!val) continue;
5390          if (jsonLen(val) > 514) continue;        /* PER-VALUE CAP in the budget's own unit: 514 =
5391                                                      512 ASCII chars + the two JSON quotes. DROP, never
5392                                                      truncate. Counted in BYTES so no single value can
5393                                                      eat the 1536 budget, whatever its character width. */
5394          cand[lk] = val;
5395          candN++;
5396        }
5397        /* PASS 2 — spend the 1536-byte budget in want[] order, never in URL order.
5398           `continue`, never `break`: an unaffordable id costs only itself. */
5399        for (var wi = 0; wi < want.length; wi++) {
5400          var wk = want[wi];
5401          if (!cand[wk]) continue;
5402          var cost = jsonLen(wk) + 1 + jsonLen(cand[wk]) + 1;
5403          if (bytes + cost > 1536) continue;       /* over the serialized budget → drop THIS id only */
5404          out[wk] = cand[wk];
5405          bytes += cost;
5406        }
5407      } catch (e) {}
5408      return out;
5409    }
5410
5411    var fpComputed = false, fpCore = null, fpFull = null;
5412    function computeFp() {
5413      if (fpComputed) return; fpComputed = true;   /* compute ONCE per page */
5414      try {
5415        fpCore = deviceCore();
5416        var ch = canvasHash();
5417        var ah = audioHash();
5418        /* Fold core + canvas + audio into one compact `full` token. */
5419        var blob = JSON.stringify(fpCore) + '|' + ch + '|' + ah;
5420        fpFull = cyrb53(blob, 0);
5421      } catch (e) {}
5422    }
5423
5424    function stitch() {
5425      try {
5426        try { if (sessionStorage.getItem('dna_fp_sent')) return; } catch (e) {}
5427
5428        computeFp();
5429        var clickIds = clickIdsFromUrl();
5430
5431        var fp = null;
5432        if (fpFull || (fpCore && (function () { for (var k in fpCore) return true; return false; })())) {
5433          fp = {};
5434          if (fpFull) fp.full = fpFull;
5435          if (fpCore) fp.core = fpCore;
5436        }
5437        var hasClickIds = (function () { for (var k in clickIds) return true; return false; })();
5438
5439        /* Nothing to stitch → don't fire (and don't burn the once-flag). */
5440        if (!fp && !hasClickIds) return;
5441
5442        var payload = { kind: 'track', name: 'dna_stitch' };
5443        if (fp) payload.fp = fp;
5444        if (hasClickIds) payload.clickIds = clickIds;
5445
5446        /* Mark BEFORE the send so a redraw/retry can't double-fire this session. */
5447        try { sessionStorage.setItem('dna_fp_sent', '1'); } catch (e) {}
5448
5449        /* CROSS-origin to the collector: POST the ABSOLUTE BASE + '/collect'. Prefer
5450           sendBeacon (text/plain Blob, CORS-simple) with a keepalive fetch fallback,
5451           mirroring the funnel tracker's wire. */
5452        var body = JSON.stringify(payload);
5453        if (navigator.sendBeacon) {
5454          try {
5455            var blob = new Blob([body], { type: 'text/plain' });
5456            if (navigator.sendBeacon(BASE + '/collect', blob)) return;
5457          } catch (e) {}
5458        }
5459        fetch(BASE + '/collect', {
5460          method: 'POST', credentials: 'include', keepalive: true,
5461          headers: { 'content-type': 'text/plain' }, body: body
5462        }).catch(function () {});
5463      } catch (e) {}
5464    }
5465
5466    /* Vid handoff (one-shot): when an email campaign link resolves straight to the
5467       storefront, the resolver stamps a signed, short-TTL token in the URL fragment
5468       (#dh=...) on the 302 INTO this page. It is the ONLY carrier of the click's vid
5469       when the redirect Set-Cookie was dropped/jar-isolated (in-app email browsers,
5470       Klaviyo/Gmail tracking-redirect chains) — without it /session/init mints a
5471       FRESH vid and the order is orphaned from its campaign click. Relay it in the
5472       BODY (not a header): we are CROSS-origin to the collector, and a text/plain
5473       body stays a CORS-simple request, whereas a custom header would force a
5474       preflight in-app browsers handle poorly. Strip ONLY the dh param afterwards so
5475       it never lingers in history or a Referer; any real fragment is preserved.
5476       Absent → a plain handshake, exactly as before. */
5477    var initBody = null;
5478    var vh = '';
5479    /* ITS OWN try/catch, and BOUNDED WHERE IT IS READ (#638).
5480
5481       OWN try/catch: decodeURIComponent THROWS on a malformed escape, so #dh=%2
5482       used to abort this whole block and take the click-id relay below down with
5483       it — a broken fragment cost the ids too. A handoff problem may cost the
5484       handoff and nothing else.
5485
5486       BOUNDED: vh rides the /session/init BODY, and that route's bodyLimit
5487       (MAX_SESSION_INIT_BODY_BYTES, 16 KiB) is enforced by Fastify's parser BEFORE
5488       Zod runs — so the .catch(undefined) that exists to discard a malformed vh
5489       never gets the chance. https://donotage.org/#dh=<20 KB> built a 20,489-byte
5490       body, was answered 413, and cost the visitor their vid: no dna_sess, no vid
5491       cookie, and every later /collect and /api/event beacon 401 for the rest of
5492       the visit. 4098 = MAX_HANDOFF_TOKEN_LEN (4096, services/collector/src/routes/
5493       ingest.ts) + the two JSON quotes, measured in JSON BYTES because a CHARACTER
5494       cap does not close it — 4096 NUL characters are 4096 characters and 24,576
5495       escaped bytes. Over-long -> DROP THE FIELD and send the handshake WITHOUT it.
5496       The handshake is NEVER abandoned over a handoff problem: a fresh vid beats no
5497       vid. The fragment is then left in place, deliberately — an unusable token is
5498       not a token we consumed. The collector applies the same bound independently
5499       (handoffTokenWithinBounds, ingest.ts), because a client bound is not a server
5500       bound.
5501
5502       SHAPE-CHECKED TOO. A real handoff is base64url + "." + base64url
5503       (issueVidHandoff), so the token's own alphabet is the guard: anything outside
5504       it could not have verified anyway. Same test at all four read sites — on the
5505       funnel snippet this carrier is a raw HEADER, whose value is a ByteString, and
5506       there one character above U+00FF (or a NUL) makes fetch() reject outright and
5507       the visitor gets NO handshake at all. Same rule everywhere keeps the four
5508       copies diffable, and the collector states it as well (HANDOFF_TOKEN_SHAPE). */
5509    try {
5510      var rawHash = (location.hash || '').replace(/^#/, '');
5511      if (rawHash) {
5512        var keep = [], segs = rawHash.split('&');
5513        for (var hi = 0; hi < segs.length; hi++) {
5514          if (segs[hi].indexOf('dh=') === 0) {
5515            var vhCandidate = '';
5516            try { vhCandidate = decodeURIComponent(segs[hi].slice(3)); } catch (e) { vhCandidate = ''; }
5517            if (vhCandidate && jsonLen(vhCandidate) <= 4098 && /^[A-Za-z0-9._-]+$/.test(vhCandidate)) vh = vhCandidate;
5518            else keep.push(segs[hi]);
5519          }
5520          else if (segs[hi]) keep.push(segs[hi]);
5521        }
5522        if (vh && w.history && w.history.replaceState) {
5523          w.history.replaceState(null, '', location.pathname + location.search + (keep.length ? '#' + keep.join('&') : ''));
5524        }
5525      }
5526    } catch (e) {}
5527    /* The vh-only body is the FLOOR, built before the click-id relay is attempted.
5528       The two halves of this handshake fail independently in BOTH directions: a
5529       malformed #dh already costs only the handoff (its own try/catch above), and a
5530       throw anywhere in the click-id half must likewise cost only the click-ids —
5531       not the handoff, which is the sole carrier of the click's vid when the 302
5532       Set-Cookie was dropped. Defence in depth rather than a reachable bug today:
5533       clickIdsFromUrl() is itself total and every decode inside it is caught. */
5534    if (vh) { try { initBody = JSON.stringify({ vh: vh }); } catch (e) {} }
5535    try {
5536      /* Relay the visitor's OWN ad click-ids to /session/init so the collector can
5537         mint the durable signed dna_click_bundle cookie that rides into checkout.
5538         Sent WITH vh, or standalone on a storefront landing that carries only
5539         ?gclid/?fbclid. These are BROWSER-asserted (unsigned) fallback keys —
5540         analytics/corroboration only, never payout-grade on their own. Relayed
5541         under the same interim posture as pageview()/attributionTouch() below —
5542         the explicit dna_consent a:0 early-return above already covers opt-out;
5543         this is not further gated on analyticsGranted (that flag guards only the
5544         high-entropy stitch() fingerprint). */
5545      var initClickIds = clickIdsFromUrl();
5546      var hasInitClickIds = (function () { for (var k in initClickIds) return true; return false; })();
5547      if (vh || hasInitClickIds) {
5548        var initObj = {};
5549        if (vh) initObj.vh = vh;
5550        if (hasInitClickIds) initObj.clickIds = initClickIds;
5551        initBody = JSON.stringify(initObj);
5552      }
5553    } catch (e) {}
5554
5555    /* Mint/refresh the shared vid FIRST (sets the signed session cookie /api/event
5556       + /collect require), THEN emit — so the events are not rejected 401.
5557
5558       module-collector publishes a page-global broker for this exact BASE. Reuse
5559       it so the storefront pageview and the module's product/cart/checkout events
5560       cannot race two cookie-less /session/init requests and split one journey
5561       across two vids. Pass the handoff-aware body into the broker before asking
5562       for its promise; if the module is absent or from an older release, retain
5563       the standalone request for a safe staggered deployment. */
5564    var sessionBroker = null;
5565    try {
5566      if (typeof w.__dnaSessionBrokerFor === 'function') {
5567        sessionBroker = w.__dnaSessionBrokerFor(BASE, initBody);
5568      }
5569    } catch (e) {}
5570
5571    var sessReady;
5572    if (sessionBroker && typeof sessionBroker.ready === 'function') {
5573      sessReady = sessionBroker.ready();
5574    } else {
5575      var initOpts = {
5576        method: 'POST', credentials: 'include', referrerPolicy: 'origin',
5577        headers: { 'content-type': 'text/plain' }
5578      };
5579      if (initBody) initOpts.body = initBody;
5580      sessReady = fetch(BASE + '/session/init', initOpts)
5581        .then(function (res) { return !!(res && res.ok); }).catch(function () { return false; });
5582    }
5583
5584    /* Emit ONLY once the session is established — /api/event + /collect require the
5585       signed session cookie, so on a failed init (collector down, or the storefront
5586       origin not yet in CORS_ALLOWED_ORIGINS) we send nothing rather than 401-spam. */
5587    function emit() { sessReady.then(function (ok) { if (!ok) return; pageview(); attributionTouch(); if (analyticsGranted) stitch(); }); }
5588    if (d.readyState === 'loading') d.addEventListener('DOMContentLoaded', emit);
5588 else emit();
5589  } catch (e) {}
5590})();</script>
5590<!-- /dna:collector-tracker -->
5591</div>    <link rel="stylesheet" id="google-fonts-1-css"  href="https://fonts.googleapis.com/css?family=Lato:100,100italic,200,200italic,300,300italic,400,400italic,500,500italic,600,600italic,700,700italic,800,800italic,900,900italic&display=swap" type="text/css" media="all" />
5592<script type="text/javascript">
5593var goomentoFrontendConfig = {"environmentMode":{"edit":false},"is_rtl":false,"breakpoints":{"xs":0,"sm":480,"md":768,"lg":1025,"xl":1440,"xxl":1600},"version":"0.5.0","urls":{"assets":"https:\/\/donotage.org\/static\/version1790156720\/frontend\/Dna\/b2c-theme\/en_US\/Goomento_PageBuilder\/"},"settings":{"general":{"global_image_lightbox":"yes","enable_lightbox_in_editor":"yes"}}};
5594</script>
vendor: 1 bytes, line 5594
5594
5595<script type="text/javascript">gmtRequire(['underscore','goomento-frontend'])</script>
5595</body>
5596</html>

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.