1/** 2 * LP Heatmap â tracker. 3 * 4 * â THIS FILE MUST STAY STATIC AND SHOPPER-AGNOSTIC. 5 * It is served from a cached URL and executed on full-page-cached PDPs. It 6 * reads its context from window.LPH_CFG, which describes the URL, never the 7 * visitor. 8 * 9 * â THERE IS NO CODE PATH IN HERE THAT READS `input.value`. 10 * Field tracking records the field NAME, how long it held focus, and whether 11 * it ended up non-empty (`.value.length > 0` â a length, never the string). 12 * Search this file for `.value` and you will find exactly one use, and it is 13 * that length check. Card fields are unreachable anyway: NMI Collect.js 14 * renders them in cross-origin iframes. 15 * 16 * â NO free text is ever sent from an input, a textarea or a contenteditable. 17 * Click labels come only from links and buttons, whose text is our own site 18 * copy, and are additionally scrubbed of any long digit run before sending. 19 * 20 * One beacon per pageview, on pagehide. We are measuring where attention stops, 21 * not rebuilding session replay, so a per-view summary beats an event stream â 22 * it is ~1 row instead of ~200 and it survives the tab being killed. 23 */ 24(function () { 25 'use strict'; 26 27 var CFG = window.LPH_CFG; 28 if (!CFG || !CFG.url || !navigator.sendBeacon) { return; } 29 30 /* ââ ids ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 31 * sessionStorage / localStorage, deliberately NOT cookies. A novel cookie 32 * is the classic way to punch a hole in a host's full-page cache, and this 33 * keeps us out of the request header entirely. Private-mode throws on 34 * access, so every touch is guarded â a visitor we cannot identify is 35 * simply not tracked, which is the correct failure. */ 36 function store(area, key, mint) { 37 try { 38 var v = window[area].getItem(key); 39 if (!v) { v = mint(); window[area].setItem(key, v); } 40 return v; 41 } catch (e) { return null; } 42 } 43 function rid() { 44 var s = 'abcdefghijklmnopqrstuvwxyz0123456789', o = ''; 45 var a = new Uint8Array(20); 46 (window.crypto || window.msCrypto).getRandomValues(a); 47 for (var i = 0; i < 20; i++) { o += s[a[i] % 36]; } 48 return o; 49 } 50 if (!window.crypto || !window.crypto.getRandomValues) { return; } 51 52 var VID = store('localStorage', 'lph_vid', rid); 53 var SID = store('sessionStorage', 'lph_sid', rid); 54 if (!VID || !SID) { return; } 55 56 /* ââ sampling âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 57 * Sticky per SESSION, not per pageview. Sampling each pageview 58 * independently would shred funnels: a shopper could be sampled on the PDP 59 * and dropped on checkout, and every cross-page path we care about would be 60 * a coin flip. Deriving the die roll from the session id means a sampled-in 61 * session is sampled in for all of its pages. */ 62 var rate = (CFG.sample && CFG.sample[CFG.type]) || 0; 63 if (rate < 100) { 64 var h = 0; 65 for (var i = 0; i < SID.length; i++) { h = (h * 31 + SID.charCodeAt(i)) >>> 0; } 66 if ((h % 100) >= rate) { return; } 67 } 68 69 /* ââ state ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 70 var t0 = Date.now(); 71 var maxScroll = 0; 72 var ttfi = 0; 73 var clicks = []; 74 var rage = 0; 75 var dead = 0; 76 var activeMs = 0; 77 var lastTick = Date.now(); 78 var idle = false; 79 var reachedAtc = 0; 80 var didAtc = 0; 81 var sent = false; 82 var CLICK_CAP = 40; 83 84 function vw() { return window.innerWidth || document.documentElement.clientWidth; } 85 function vh() { return window.innerHeight || document.documentElement.clientHeight; } 86 function docH() { 87 var b = document.body, d = document.documentElement; 88 return Math.max(b ? b.scrollHeight : 0, b ? b.offsetHeight : 0, 89 d.clientHeight, d.scrollHeight, d.offsetHeight); 90 } 91 function device() { 92 var w = vw(); 93 return w < 768 ? 'mobile' : (w < 1024 ? 'tablet' : 'desktop'); 94 } 95 96 /* ââ scroll depth ââââââââââââââââââââââââââââââââââââââââââââââââââââ 97 * Percent of the DOCUMENT whose bottom edge has been revealed, not percent 98 * of scrollTop. On a short page the viewport already shows 100% at 99 * scrollTop 0, and reporting that as "0% scrolled" would libel every page 100 * that fits on one screen. */ 101 function readScroll() {
102 var h = docH(); 103 if (h <= 0) { return; } 104 var seen = (window.pageYOffset || document.documentElement.scrollTop || 0) + vh(); 105 var pct = Math.round(Math.min(100, (seen / h) * 100)); 106 if (pct > maxScroll) { maxScroll = pct; } 107 } 108 readScroll(); 109 addEventListener('scroll', throttle(readScroll, 200), { passive: true }); 110 addEventListener('resize', throttle(readScroll, 400), { passive: true }); 111 112 function throttle(fn, ms) { 113 var wait = false; 114 return function () { 115 if (wait) { return; } 116 wait = true; 117 setTimeout(function () { wait = false; fn(); }, ms); 118 }; 119 } 120 121 /* ââ active time âââââââââââââââââââââââââââââââââââââââââââââââââââââ 122 * Dwell counts wall-clock, including the tab someone left open over lunch. 123 * Active time stops after 30s without an interaction, which is the number 124 * that actually means "was reading". Both are stored; the difference 125 * between them is itself a signal. */ 126 function bump() { 127 var now = Date.now(); 128 if (!idle) { activeMs += Math.min(now - lastTick, 30000); } 129 lastTick = now; 130 idle = false; 131 if (!ttfi) { ttfi = now - t0; } 132 } 133 ['mousemove', 'keydown', 'scroll', 'touchstart', 'click'].forEach(function (e) { 134 addEventListener(e, throttle(bump, 1000), { passive: true }); 135 }); 136 setInterval(function () { 137 if (Date.now() - lastTick > 30000) { idle = true; } 138 }, 5000); 139 140 /* ââ click labelling âââââââââââââââââââââââââââââââââââââââââââââââââ 141 * A short, stable descriptor so a human (and the analyst) can tell what was 142 * clicked. Text is taken ONLY from links and buttons â that text is our own 143 * site copy. Any run of 6+ digits is scrubbed regardless, so a stray order 144 * or phone number rendered into a label can never ride along. */ 145 function label(el) { 146 var out = el.tagName ? el.tagName.toLowerCase() : '?'; 147 if (el.id) { out += '#' + el.id; } 148 else if (el.className && typeof el.className === 'string') { 149 var c = el.className.trim().split(/\s+/).slice(0, 2).join('.'); 150 if (c) { out += '.' + c; } 151 } 152 var tag = out.split(/[#.]/)[0]; 153 if (tag === 'a' || tag === 'button' || el.getAttribute('role') === 'button') { 154 var txt = (el.getAttribute('aria-label') || el.textContent || '') 155 .replace(/\s+/g, ' ').trim().slice(0, 40); 156 if (txt) { out += ' |' + txt; } 157 } 158 return out.replace(/\d{6,}/g, '#').slice(0, 120); 159 } 160 161 function interactive(el) { 162 for (var n = el, i = 0; n && i < 5; n = n.parentElement, i++) { 163 var t = n.tagName ? n.tagName.toLowerCase() : ''; 164 if (t === 'a' || t === 'button' || t === 'input' || t === 'select' || 165 t === 'textarea' || t === 'label' || t === 'summary') { return true; } 166 if (n.getAttribute && (n.getAttribute('role') === 'button' || 167 n.hasAttribute('onclick') || n.tabIndex >= 0)) { return true; } 168 } 169 return false; 170 } 171 172 /* ââ dead + rage clicks ââââââââââââââââââââââââââââââââââââââââââââââ 173 * Rage = 3+ clicks inside a 30px box within 1s. Someone jabbing at a thing 174 * they believe is a button. 175 * Dead = a click on a non-interactive element that produced no DOM change 176 * within 700ms. Someone clicking a thing that looks clickable and 177 * is not. The MutationObserver is what separates "dead" from "it 178 * worked but the effect was subtle". 179 * These two are the highest-yield findings a heatmap produces, which is why 180 * they are counted server-side rather than left for a human to spot. */ 181 var recent = []; 182 var mutated = false; 183 var moPending = 0; 184 // â Observe ON DEMAND, not continuously. We only care whether the DOM 185 // changed in the 700ms after a non-interactive click, so a permanently 186 // attached subtree+attributes observer on documentElement makes the browser 187 // deliver us every mutation an Avada PDP produces (measured: 517 records in 188 // one session) to answer a question we ask a handful of times. Connecting 189 // only around the click drops our idle cost to zero. 190 /* â Not every mutation is a RESPONSE to the click. 191 * An Avada PDP rewrites itself constantly on its own: images swap in as 192 * they lazy-load, carousels advance, tracking scripts inject nodes. Treating 193 * that background churn as "the page responded" marks essentially every 194 * click alive and we would never report a dead click at all â the metric 195 * would read a reassuring zero forever, which is worse than not having it. 196 * So ignore the churn that happens whether or not anybody clicked, and let 197 * everything else count. */ 198 function responsive(recs) { 199 for (var i = 0; i < recs.length; i++) { 200 var r = recs[i]; 201 if (r.type === 'attributes') { 202 var tn = r.target.tagName; 203 if (tn === 'IMG' || tn === 'IFRAME' || tn === 'VIDEO' || tn === 'SOURCE') { continue; } 204 var an = r.attributeName; 205 if (an === 'src' || an === 'srcset' || an === 'sizes' || 206 an === 'loading' || an === 'decoding') { continue; } 207 } else { 208 // Node insertions that are purely script/iframe/link plumbing 209 // are trackers loading, not the UI reacting. 210 var noise = r.addedNodes.length > 0 && r.removedNodes.length === 0; 211 for (var j = 0; j < r.addedNodes.length && noise; j++) { 212 var t = r.addedNodes[j].tagName; 213 if (t !== 'SCRIPT' && t !== 'IFRAME' && t !== 'NOSCRIPT' && t !== 'LINK') { 214 noise = false;
215 } 216 } 217 if (noise) { continue; } 218 } 219 return true; 220 } 221 return false; 222 } 223 224 var mo = window.MutationObserver 225 ? new MutationObserver(function (recs) { if (responsive(recs)) { mutated = true; } }) 226 : null; 227 228 addEventListener('click', function (ev) { 229 bump(); 230 var el = ev.target; 231 if (!el || !el.getBoundingClientRect) { return; } 232 233 var pageX = ev.pageX, pageY = ev.pageY; 234 if (typeof pageX !== 'number') { return; } 235 236 var now = Date.now(); 237 recent.push({ x: pageX, y: pageY, t: now }); 238 recent = recent.filter(function (c) { return now - c.t < 1000; }); 239 var cluster = recent.filter(function (c) { 240 return Math.abs(c.x - pageX) < 30 && Math.abs(c.y - pageY) < 30; 241 }); 242 var isRage = cluster.length >= 3; 243 if (isRage) { rage++; } 244 245 var inter = interactive(el); 246 // â kind 1 means CONFIRMED DEAD, not merely "clicked something 247 // non-interactive". Those are different populations: clicking the text 248 // inside an accordion header is non-interactive by tag but very much 249 // alive. Stamping 1 here would make the dashboard's Dead column count 250 // working elements and invent problems that do not exist, while the 251 // view's own `dead` counter (set only after the 700ms no-mutation 252 // check) disagreed with it. The upgrade to 1 happens below, once. 253 var kind = isRage ? 2 : 0; 254 255 var rec = { 256 x: Math.round((pageX / Math.max(1, docWidth())) * 10000) / 100, 257 y: Math.round(pageY), 258 // â Clamp AFTER the divide, never before. `min(100, ratio*10000)` 259 // caps the scaled integer at 100 and then /100 makes every click 260 // 1.00% â which silently flattens the heatmap's whole vertical 261 // axis onto one line while still looking like plausible data. 262 p: Math.min(100, Math.round((pageY / Math.max(1, docH())) * 10000) / 100), 263 s: label(el), 264 k: kind 265 }; 266 if (clicks.length < CLICK_CAP) { clicks.push(rec); } 267 268 // Add-to-cart intent, for the PDP scroll-to-ATC question specifically. 269 if (/add[-_ ]?to[-_ ]?cart|single_add_to_cart/i.test(rec.s)) { didAtc = 1; } 270 271 if (!inter && mo) { 272 /* â Scope to the clicked element's own container, NOT the document. 273 * Watching documentElement means a carousel advancing or a sticky 274 * bar toggling a class three sections away counts as "the page 275 * responded to this click". Measured on a live Avada PDP: that is 276 * ALWAYS true within 700ms, so every dead click was being scored 277 * alive and the metric read a permanent, reassuring zero. 278 * A genuine response to clicking X â an accordion opening, a class 279 * toggling, a panel appearing â happens inside X's container. 280 * We only ever run this for NON-interactive elements, and those 281 * almost never drive a body-level drawer, so the narrower scope 282 * costs us very little and removes the systematic false-alive. */ 283 mutated = false; 284 moPending++; 285 // â NEVER let the walk reach <body> or <html>. An element sitting 286 // shallow in the DOM would otherwise scope back to the document 287 // root and reintroduce exactly the false-alive this fixes â 288 // measured: a <p> two levels under body scoped to <html> with 2,197 289 // descendants, and a nav dropdown animating elsewhere on the page 290 // counted as its response. 291 var scope = el; 292 for (var up = 0; up < 3; up++) { 293 var par = scope.parentElement; 294 if (!par || par === document.body || par === document.documentElement) { break; } 295 scope = par; 296 } 297 mo.observe(scope, { childList: true, subtree: true, attributes: true }); 298 setTimeout(function () { 299 // Only the LAST outstanding window disconnects â a rage burst 300 // fires several of these and an early disconnect would blind 301 // the ones still open, turning real activity into fake "dead". 302 if (--moPending <= 0) { moPending = 0; mo.disconnect(); } 303 // Confirmed dead: nothing in the DOM changed. A rage click 304 // keeps kind 2 â rage is the stronger signal and we must not 305 // overwrite it, or the leaderboard loses the angriest cases. 306 if (!mutated) { dead++; if (rec.k !== 2) { rec.k = 1; } } 307 }, 700); 308 } 309 }, true); 310 311 function docWidth() { 312 var d = document.documentElement; 313 return Math.max(d.scrollWidth, d.clientWidth, vw()); 314 } 315 316 /* ââ did the add-to-cart button ever get SEEN? ââââââââââââââââââââââââ 317 * The PDP question that matters is not "how far did they scroll" in the 318 * abstract, it is "did the buy button ever enter the viewport". A 40% 319 * scroll depth is fine on a page whose CTA sits at 30% and fatal on one 320 * whose CTA sits at 60%. */ 321 /* â ONLY the in-page buy button counts, never a sticky one. 322 * LP's PDP is a custom template with FIVE add-to-cart controls: 323 * #mainAtc / #order-btn â the real one, sitting in the page flow 324 * #stickyAtc / #dsAtc / #sheetAtc â sticky bar and drawer variants 325 * The sticky ones are on screen from the moment the page loads, so 326 * observing them would report "100% of shoppers saw the buy button" on 327 * every PDP forever â a metric that is always true and therefore useless. 328 * The WooCommerce selectors are kept as a fallback for any product still 329 * rendering a stock template. 330 * 331 * â LIVE AND STAGING DIFFER â this list must cover both. Live PDPs render 332 * FOUR add-to-cart buttons and the only reliable handle on the main one is
333 * `.js-add-to-cart` (its element id is the product id, so it cannot be 334 * selected by id). Staging's older template uses `#mainAtc`. A selector 335 * that matched only staging returned nothing on live, which would have 336 * reported "0% of shoppers saw the buy button" on every live PDP forever â 337 * a broken metric that looks like a catastrophic finding. 338 * `.js-sticky-cta` is deliberately absent: it is pinned on screen from 339 * first paint, so observing it would report 100% just as uselessly. 340 */ 341 if (CFG.type === 'pdp' && window.IntersectionObserver) { 342 var atc = document.querySelector( 343 '.js-add-to-cart, #mainAtc, #order-btn, .single_add_to_cart_button,' 344 + ' [name="add-to-cart"], form.cart button[type="submit"]' 345 ); 346 if (atc) { 347 var io = new IntersectionObserver(function (entries) { 348 for (var i = 0; i < entries.length; i++) { 349 if (entries[i].isIntersecting) { reachedAtc = 1; io.disconnect(); } 350 } 351 }, { threshold: 0.4 }); 352 io.observe(atc); 353 } 354 } 355 356 /* ââ checkout fields âââââââââââââââââââââââââââââââââââââââââââââââââ 357 * Per-field focus count, dwell, and whether it ended non-empty. Plus which 358 * field held focus LAST â on an abandoned checkout that is the single most 359 * actionable number in the whole system: it names the field people quit on. 360 * 361 * The allowlist is a hard boundary, not a convenience: a field not on it is 362 * never recorded at all, so a future card/SSN/coupon input cannot be picked 363 * up by accident. */ 364 var FIELDS = {}; 365 var lastField = ''; 366 var FIELD_OK = /^(billing|shipping)_(first_name|last_name|company|address_1|address_2|city|state|postcode|country|ph
366one|email)$|^(order_comments|coupon_code|terms|createaccount|account_password|shipping_method|payment_method)$/; 367 368 if (CFG.type === 'checkout') { 369 document.addEventListener('focusin', function (ev) { 370 var el = ev.target; 371 if (!el || !el.name || !FIELD_OK.test(el.name)) { return; } 372 var k = el.name; 373 if (!FIELDS[k]) { FIELDS[k] = { f: 0, ms: 0, fill: 0, _t: 0 }; } 374 FIELDS[k].f++; 375 FIELDS[k]._t = Date.now(); 376 lastField = k; 377 }, true); 378 379 document.addEventListener('focusout', function (ev) { 380 var el = ev.target; 381 if (!el || !el.name || !FIELDS[el.name]) { return; } 382 var f = FIELDS[el.name]; 383 if (f._t) { f.ms += Math.min(Date.now() - f._t, 300000); f._t = 0; } 384 // The ONLY read of .value in this file, and it is a length test. 385 // The string itself is never assigned, stored or transmitted. 386 try { 387 f.fill = (el.value && el.value.length > 0) ? 1 : 0; 388 } catch (e) { f.fill = 0; } 389 }, true); 390 } 391 392 /* ââ quick-back âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 393 * Left in under 5 seconds having done nothing. On a PDP that is a bounce 394 * with a reason, and it is worth separating from a considered exit. */ 395 function quickBack() { 396 return (Date.now() - t0 < 5000 && clicks.length === 0 && maxScroll < 25) ? 1 : 0; 397 } 398 399 /* ââ send âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 400 * pagehide is the only event that reliably fires on mobile Safari; the 401 * visibilitychange guard catches tab-switch-then-kill. `sent` makes this 402 * idempotent â a double beacon would double every metric downstream. */ 403 function send() { 404 if (sent) { return; } 405 sent = true; 406 readScroll(); 407 bump(); 408 409 var fields = []; 410 for (var k in FIELDS) { 411 if (!Object.prototype.hasOwnProperty.call(FIELDS, k)) { continue; } 412 var f = FIELDS[k]; 413 if (f._t) { f.ms += Math.min(Date.now() - f._t, 300000); } 414 fields.push({ k: k, f: f.f, ms: Math.round(f.ms), fill: f.fill, last: k === lastField ? 1 : 0 }); 415 } 416 417 var ref = ''; 418 try { 419 if (document.referrer) { ref = new URL(document.referrer).hostname.slice(0, 64); } 420 if (ref === location.hostname) { ref = ''; } 421 } catch (e) { ref = ''; } 422 423 var utm = ''; 424 try { 425 utm = (new URLSearchParams(location.search).get('utm_source') || '').slice(0, 48); 426 } catch (e) { utm = ''; } 427 428 var payload = { 429 v: 1, 430 sid: SID, vid: VID, 431 type: CFG.type, ref: CFG.ref, variant: CFG.variant || '', tag: CFG.tag || '', 432 device: device(), vw: vw(), vh: vh(), doc_h: docH(), 433 scroll: maxScroll, 434 dwell: Math.min(Date.now() - t0, 3600000), 435 active: Math.min(activeMs, 3600000), 436 ttfi: Math.min(ttfi, 600000), 437 rage: rage, dead: dead, back: quickBack(), 438 atc_seen: reachedAtc, atc_click: didAtc, 439 rhost: ref, utm: utm, 440 clicks: clicks, 441 fields: fields, 442 order: (window.LPH_ORDER && CFG.type === 'confirmed') ? window.LPH_ORDER : null 443 }; 444 445 try { 446 navigator.sendBeacon(CFG.url, new Blob([JSON.stringify(payload)], { type: 'text/plain' })); 447 } catch (e) { /* a lost beacon is a lost row, never a broken page */ } 448 } 449 450 addEventListener('pagehide', send); 451 addEventListener('visibilitychange', function () { 452 if (document.visibilityState === 'hidden') { send(); } 453 }); 454})();
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.