PageSourceSearch

https://licensedpeptides.com/wp-content/plugins/lp-heatmap/assets/lph.js?ver=0.1.9

js licensedpeptides.com collected 2026-10-04 23:59:07 UTC 20,690 bytes, 454 lines download raw bytes

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.