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/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/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 gmt gmt-392726" data-gmt-settings="[]"> 3690 <div class="gmt-inner"> 3691 <div class="gmt-section-wrap"> 3692 <section class="gmt-element gmt-element-6625613 gmt-section-boxed gmt-section-height-default gmt-section-height-default gmt-section 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 gmt-element-61b97e7 gmt-column gmt-col-100 gmt-top-column" data-id="61b97e7" data-element_type="column"> 3697 <div class="gmt-column-wrap  gmt-element-populated"> 3698 <div class="gmt-widget-wrap"> 3699 <div class="gmt-element gmt-element-bd5bb27 gmt-widget 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="{"goomento-widget-text-editor":[]}"> 3701 <div class="gmt-text-editor 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="* Required 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">✕</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 Me" /> 4339 <label for="remember_meAVfHhpy1Av" class="label"><span>Remember Me</span></label> 4340 <span class="tooltip wrapper"> 4341 <strong class="tooltip toggle"> What's this?</strong> 4342 <span class="tooltip content"> Check "Remember Me" 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 gmt gmt-392726" data-gmt-settings="[]"> 4447 <div class="gmt-inner"> 4448 <div class="gmt-section-wrap"> 4449 <section class="gmt-element gmt-element-6625613 gmt-section-boxed gmt-section-height-default gmt-section-height-default gmt-section 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 gmt-element-61b97e7 gmt-column gmt-col-100 gmt-top-column" data-id="61b97e7" data-element_type="column"> 4454 <div class="gmt-column-wrap  gmt-element-populated"> 4455 <div class="gmt-widget-wrap"> 4456 <div class="gmt-element gmt-element-bd5bb27 gmt-widget 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="{"goomento-widget-text-editor":[]}"> 4458 <div class="gmt-text-editor 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.