1(function () { 2 "use strict"; 3 4 // Per-request server replacement targets â see apps/ingest/src/tracker-template.ts. 5 // Leave these lines EXACTLY as-is so applyCookieDomain / applyTrackerOrigins can find them. 6 var COOKIE_DOMAIN = null; 7 var TRACKER_ORIGINS = []; 8 9 // Per-request server replacement target â see apps/ingest/src/tracker-template.ts 10 // (applyIngestOrigin). A string constant survives minification, so even a 11 // self-hosted minified copy whose ?k= was stripped still knows the real 12 // beacon endpoint. Leave this line EXACTLY as-is. 13 var INGEST_ORIGIN = null; 14 var MARKETING_HOST = null; 15 16 function hostMatchesDomain(host, domain) { 17 if (!domain) return false; 18 return host === domain || host.endsWith("." + domain); 19 } 20 21 // Strip a trailing root dot from a hostname (FQDN form, e.g. 22 // "www.mailreach.co." â "www.mailreach.co"). Without this the trailing dot 23 // breaks both the cookie-domain match below AND ClickHouse's domain() on the 24 // sent URL, so those (rare) hits land with no domain and a host-only cookie. 25 function fvStripTrailingDot(h) { return h ? h.replace(/\.+$/, "") : h; } 26 function pageUrl() { 27 try { 28 var u = new URL(location.href); 29 u.hostname = fvStripTrailingDot(u.hostname); 30 return u.href; 31 } catch (_) { return location.href; } 32 } 33 34 // Element discovery chain. Optimizers (WP Rocket Delay-JS, Combine/Minify, 35 // LiteSpeed, Perfmatters) move srcâdata-rocket-src / data-src and may 36 // self-host a copy under the customer's domain â but the rewritten URL 37 // still contains "/t.js", so the selector matches. currentScript is null 38 // once the script runs deferred from an event handler. 39 var script = 40 document.currentScript || 41 document.querySelector('script[src*="/t.js"]') || 42 document.querySelector('script[data-rocket-src*="/t.js"]') || 43 document.querySelector('script[data-src*="/t.js"]'); 44 if (!script) return; 45 46 // Resolve the script URL from whichever attribute carried it. 47 var scriptUrl = ""; 48 try { 49 scriptUrl = 50 script.src || 51 script.getAttribute("data-rocket-src") || 52 script.getAttribute("data-src") || 53 ""; 54 } catch (_) {} 55 56 // Key precedence: ?k= from the resolved URL â data-key attr â window.__fvKey. 57 // data-key is the load-bearing fallback: optimizers that strip the query 58 // string preserve tag attributes (verified in the otpe.fr incident). 59 var apiKey = null; 60 var keyFromUrl = false; 61 try { 62 if (scriptUrl) { 63 apiKey = new URL(scriptUrl, location.href).searchParams.get("k"); 64 if (apiKey) keyFromUrl = true; 65 } 66 } catch (_) {} 67 if (!apiKey) { 68 try { apiKey = script.getAttribute("data-key"); } catch (_) {} 69 } 70 if (!apiKey) { 71 try { if (window.__fvKey) apiKey = window.__fvKey; } catch (_) {} 72 } 73 if (!apiKey) return; 74 75 // Origin precedence: 76 // - key came from the URL's ?k= â the URL is canonical (CNAME/proxy 77 // install): use its origin so first-party stays first-party. 78 // - otherwise the URL is untrustworthy (self-hosted minified copy): 79 // window.__fvOrigin (explicit override) â serve-time-baked INGEST_ORIGIN 80 // â hardcoded canonical host. The last hop matters: WP Rocket fetches 81 // t.js WITHOUT ?k= when self-hosting its minified copy, so INGEST_ORIGIN 82 // stays null and only data-key survives (rubisvoyages.ch incident, 83 // 2026-09-01). Beaconing a custom-domain workspace to the canonical 84 // host as a last resort beats dropping the traffic. 85 var origin = null; 86 if (keyFromUrl) { 87 try { origin = scriptUrl.replace(/\/t\.js(\?.*)?$/, ""); } catch (_) {} 88 } 89 if (!origin) { 90 try { origin = window.__fvOrigin || INGEST_ORIGIN || "https://db.fullvision.io"; } catch (_) { origin = INGEST_ORIGIN || "https://db.fullvision.io"; } 91 } 92 if (!origin) return; 93 94 var endpoint = origin + "/v1/i?api_key=" + encodeURIComponent(apiKey); 95 96 function getCookie(name) { 97 var match = document.cookie.match( 98 new RegExp("(^| )" + name + "=([^;]+)") 99 ); 100 return match ? match[2] : null; 101 } 102 103 function setCookie(name, value, days) { 104 var expires = new Date(Date.now() + days * 864e5).toUTCString(); 105 var domainSegment = hostMatchesDomain(fvStripTrailingDot(location.hostname), COOKIE_DOMAIN) 106 ? "; Domain=" + COOKIE_DOMAIN 107 : ""; 108 document.cookie = 109 name + "=" + value + 110 "; expires=" + expires + 111 "; path=/; SameSite=Lax; Secure" + 112 domainSegment; 113 } 114
115 // âââ Opt-out: per-browser internal-traffic exclusion âââ 116 // Visiting any tracked page with ?fv_optout=1 stores a first-party 117 // fv_optout=1 cookie (same Domain logic as fv_visitor_id); ?fv_optout=0 118 // clears it. While the cookie is present t.js sends NOTHING â no pageview, 119 // events, engagement, replay or experiment config/exposure â and leaves a 120 // no-op window.fv so customer code calling fv.variant()/fv.formSuccess() 121 // does not throw. Complements the workspace IP exclusion list (ingest). 122 var OPTOUT_COOKIE = "fv_optout"; 123 124 // Pure decision kernel (unit-tested via extraction in 125 // tests/unit/tracker-optout.test.ts): the ?fv_optout= query param wins 126 // over the stored cookie. Returns {write: "1"|"0"|null, optedOut: bool}. 127 function __fv_optOutState(search, cookieValue) { 128 var m = /[?&]fv_optout=([^&#]*)/.exec(search || ""); 129 if (m && m[1] === "1") return { write: "1", optedOut: true }; 130 if (m && m[1] === "0") return { write: "0", optedOut: false }; 131 return { write: null, optedOut: cookieValue === "1" }; 132 } 133 134 function __fv_clearOptOutCookie() { 135 var past = "; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax; Secure"; 136 // Clear both the host-only and the Domain-scoped variant: the cookie may 137 // have been written before/after cookie_domain was configured. 138 document.cookie = OPTOUT_COOKIE + "=" + past; 139 if (hostMatchesDomain(fvStripTrailingDot(location.hostname), COOKIE_DOMAIN)) { 140 document.cookie = OPTOUT_COOKIE + "=" + past + "; Domain=" + COOKIE_DOMAIN; 141 } 142 } 143 144 var __fv_optout = { write: null, optedOut: false }; 145 try { 146 __fv_optout = __fv_optOutState(location.search, getCookie(OPTOUT_COOKIE)); 147 if (__fv_optout.write === "1") { 148 // 400 days = the Chrome/Safari ceiling for cookie lifetimes. 149 setCookie(OPTOUT_COOKIE, "1", 400); 150 try { console.info("[FullVision] This browser is now excluded from tracking. Visit with ?fv_optout=0 to undo."); } catch (_) {} 151 } else if (__fv_optout.write === "0") { 152 __fv_clearOptOutCookie(); 153 try { console.info("[FullVision] Tracking re-enabled for this browser."); } catch (_) {} 154 } 155 } catch (_) {} 156 if (__fv_optout.optedOut) { 157 window.fv = window.fv || {}; 158 window.fv.variant = function () { return 'control'; }; 159 window.fv.formSuccess = function () {}; 160 window.fv.optedOut = true; 161 return; 162 } 163 164 var COOKIE_NAME = "fv_visitor_id"; 165 var GADS_COOKIE = "fv_gads"; 166 167 function fvGadsShouldReplace(p) { 168 var hasClick = !!(p.gclid || p.gbraid || p.wbraid); 169 return hasClick || !!(p.gad_source && p.gad_campaignid); 170 } 171 172 function fvGadsCompact(fields) { 173 function put(obj, key, value) { 174 if (value) obj[key] = value; 175 } 176 function build(keepUa, keepGad) { 177 var obj = {}; 178 put(obj, "gclid", fields.gclid); 179 put(obj, "gbraid", fields.gbraid); 180 put(obj, "wbraid", fields.wbraid); 181 if (keepGad) { 182 put(obj, "gad_source", fields.gad_source); 183 put(obj, "gad_campaignid", fields.gad_campaignid); 184 } 185 if (keepUa) put(obj, "ua", fields.ua); 186 obj.captured_at = fields.captured_at; 187 return JSON.stringify(obj); 188 } 189 var json = build(true, true); 190 if (encodeURIComponent(json).length > 2048) json = build(false, true); 191 if (encodeURIComponent(json).length > 2048) json = build(false, false); 192 return json; 193 } 194 195 function fvGadsParams() { 196 try { 197 var sp = new URL(location.href).searchParams; 198 function clean(v) { 199 if (v == null) return ""; 200 v = String(v).trim(); 201 return v; 202 } 203 return { 204 gclid: clean(sp.get("gclid")), 205 gbraid: clean(sp.get("gbraid")), 206 wbraid: clean(sp.get("wbraid")), 207 gad_source: clean(sp.get("gad_source")), 208 gad_campaignid: clean(sp.get("gad_campaignid")) 209 }; 210 } catch (_) { 211 return {}; 212 } 213 } 214 215 function maybeWriteGadsCookie() { 216 var p = fvGadsParams();
217 if (!fvGadsShouldReplace(p)) return; 218 var json = fvGadsCompact({ 219 gclid: p.gclid, 220 gbraid: p.gbraid, 221 wbraid: p.wbraid, 222 gad_source: p.gad_source, 223 gad_campaignid: p.gad_campaignid, 224 ua: navigator.userAgent, 225 captured_at: Date.now() 226 }); 227 setCookie(GADS_COOKIE, encodeURIComponent(json), 90); 228 } 229 230 function readGadsPersist() { 231 var raw = getCookie(GADS_COOKIE); 232 if (!raw) return null; 233 try { 234 return JSON.parse(decodeURIComponent(raw)); 235 } catch (_) { 236 return null; 237 } 238 } 239 240 var LS_KEY = "fv_visitor_id"; 241 242 function readLocal() { 243 try { return window.localStorage.getItem(LS_KEY); } catch (_) { return null; } 244 } 245 function writeLocal(v) { 246 try { window.localStorage.setItem(LS_KEY, v); } catch (_) {} 247 } 248 249 function uuidv4() { 250 if (window.crypto && crypto.randomUUID) return crypto.randomUUID(); 251 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 252 var r = Math.random() * 16 | 0; 253 return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); 254 }); 255 } 256 257 function isUuid(v) { 258 return typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v); 259 } 260 261 function fvHash(str) { 262 var h = 5381, s = String(str == null ? '' : str); 263 for (var i = 0; i < s.length; i++) { h = ((h << 5) + h + s.charCodeAt(i)) | 0; } 264 return (h >>> 0).toString(16); 265 } 266 267 var visitorId = getCookie(COOKIE_NAME) || readLocal(); 268 if (!visitorId) { 269 visitorId = 270 "fv_" + 271 Math.random().toString(36).substring(2) + 272 Math.random().toString(36).substring(2); 273 setCookie(COOKIE_NAME, visitorId, 365); 274 writeLocal(visitorId); 275 } 276 277 // âââ T24: cross-domain inbound adoption (spec §E) âââ 278 // Read fv_vid from location.hash (NOT searchParams â fragments never reach 279 // origin servers / Referer headers / 3rd-party analytics). Must run BEFORE 280 // the initial pageview send so the first hit on the destination domain 281 // upserts against the same visitor row as the source domain. 282 try { 283 var __fv_hash = (location.hash || '').replace(/^#/, ''); 284 var __fv_m = __fv_hash.match(/(?:^|&)fv_vid=([^&]+)/); 285 var __fv_inboundVid = __fv_m ? decodeURIComponent(__fv_m[1]) : null; 286 if (__fv_inboundVid && /^fv_[a-z0-9]{10,}$/.test(__fv_inboundVid)) { 287 visitorId = __fv_inboundVid; 288 setCookie(COOKIE_NAME, visitorId, 365); 289 writeLocal(visitorId); 290 var __fv_cleaned = __fv_hash.replace(/(?:^|&)fv_vid=[^&]+/, '').replace(/^&/, ''); 291 history.replaceState(history.state, '', location.pathname + location.search + (__fv_cleaned ? '#' + __fv_cleaned : '')); 292 } 293 } catch (_) {} 294 295 var __fv_pageview_id = uuidv4(); 296 297 // âââ A/B experiments: config fetch + deterministic assignment (spec §5) âââ 298 // Fail-open: any error â no experiments â everyone sees control. 299 var __fv_assignments = {}; // {experimentKey: variantKey} â stamped on every event 300 var __fv_exposed = {}; // {experimentKey + '|' + pageviewId: true} â once-per-pageview guard 301 302 function __fv_fnv1a32(str) { 303 var h = 0x811c9dc5, s = String(str == null ? '' : str); 304 for (var i = 0; i < s.length; i++) { 305 h ^= s.charCodeAt(i); 306 h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; 307 } 308 return h >>> 0; 309 } 310 311 function __fv_bucket(hashAttr, key, variations) { 312 var h = __fv_fnv1a32(hashAttr + '|' + key); 313 var u = (h % 10000) / 10000, cum = 0; 314 for (var i = 0; i < variations.length; i++) { 315 cum += variations[i].weight; 316 if (u < cum) return variations[i].key; 317 } 318 return variations[variations.length - 1].key; 319 } 320 321 // Split-URL support (spec 2026-07-09 §3.4). ES5 inline of the tested 322 // reference helpers in lib/experiments/page-match.ts â keep in sync. 323 var EXP_CACHE_KEY = 'fv_exp_config'; 324 var __fv_suppress_pv = false; // sync redirect issued â skip the original page's $pageview 325 var __fv_redirect_decided = false;
326 327 // Preview mode (spec 2026-07-09 §4): ?__fv_variant=<experimentId>:<variantKey> 328 // force-applies that arm's transforms on the matching page â no exposure, 329 // no assignment stamping, no cache write, overrides bucketing. Parse 330 // failure â null â normal bucketing proceeds. 331 var __fv_preview = null; 332 var __fv_dom_ready_scheduled = {}; 333 try { 334 var __fv_pm = (location.search || '').match(/[?&]__fv_variant=([^&]+)/); 335 if (__fv_pm) { 336 var __fv_pv = decodeURIComponent(__fv_pm[1]); 337 var __fv_ci = __fv_pv.indexOf(':'); 338 if (__fv_ci > 0 && __fv_ci < __fv_pv.length - 1) { 339 __fv_preview = { expId: __fv_pv.slice(0, __fv_ci), variantKey: __fv_pv.slice(__fv_ci + 1) }; 340 } 341 } 342 } catch (_) {} 343 344 function __fv_normPath(p) { 345 if (p && p.length > 1 && p.charAt(p.length - 1) === '/') return p.slice(0, -1); 346 return p || '/'; 347 } 348 function __fv_matchesPage(pageUrl, currentHref) { 349 try { 350 var a = new URL(pageUrl), b = new URL(currentHref); 351 return a.hostname.toLowerCase() === b.hostname.toLowerCase() && 352 __fv_normPath(a.pathname) === __fv_normPath(b.pathname); 353 } catch (_) { return false; } 354 } 355 function __fv_variantRedirectUrl(variantUrl, currentHref) { 356 var target = new URL(variantUrl), current = new URL(currentHref); 357 // Snapshot the variant's own keys first: a live has() check would flip 358 // true after the first value of a repeated key, dropping the rest. 359 var targetKeys = {}; 360 target.searchParams.forEach(function (v, k) { targetKeys[k] = 1; }); 361 current.searchParams.forEach(function (value, key) { 362 if (!targetKeys[key]) target.searchParams.append(key, value); 363 }); 364 return target.toString(); 365 } 366 function __fv_readExpCache() { 367 try { 368 var raw = localStorage.getItem(EXP_CACHE_KEY); 369 if (!raw) return null; 370 var parsed = JSON.parse(raw); 371 return (parsed && parsed.experiments) ? parsed.experiments : null; 372 } catch (_) { return null; } 373 } 374 function __fv_applyAssignments(experiments) { 375 for (var i = 0; i < experiments.length; i++) { 376 var exp = experiments[i]; 377 if (!exp || !exp.key || !exp.variations) continue; 378 if (__fv_preview && exp.id === __fv_preview.expId) continue; // preview: no assignment stamping 379 // hash_attribute is fv_visitor_id in v1 (only supported attr). 380 var variant = __fv_bucket(visitorId, exp.key, exp.variations); 381 __fv_assignments[exp.key] = variant; 382 // data-variant-<key> on <html> for flicker-free CSS variants (Path B). 383 try { document.documentElement.setAttribute('data-variant-' + exp.key, variant); } catch (_) {} 384 } 385 } 386 // Exposure for the redirect path rides sendPayload (keepalive fetch, 387 // beacon fallback) â the same transport as every other event. keepalive 388 // survives the immediate location.replace by spec, and beacon-FIRST is a 389 // trap: privacy extensions eat sendBeacon while returning true (verified 390 // live 2026-07-09), which defeats any fallback and silently loses the 391 // exposure. Same once-per-pageview guard as fv.variant(). 392 function __fv_sendExposureBeacon(key, variant) { 393 var guard = key + '|' + __fv_pageview_id; 394 if (__fv_exposed[guard]) return; 395 __fv_exposed[guard] = true; 396 sendPayload(JSON.stringify({ 397 kind: 'event', 398 visitor_id: visitorId, 399 event_name: 'Experiment Exposed', 400 properties: { 401 pageview_id: __fv_pageview_id, 402 session_id: getSessionId(), 403 experiment: key, 404 variant: variant, 405 experiments: __fv_assignments, 406 }, 407 })); 408 } 409 410 // DOM-patch apply (spec 2026-07-09 §4). ES5 inline of the tested 411 // reference helpers in lib/experiments/apply-transforms.ts â keep in sync. 412 // Fail-open per transform: a selector matching nothing is a silent no-op 413 // (exposure already sent â honest denominator). Every change type tags 414 // its elements and tagged elements are skipped â the tag is both the 415 // double-apply guard and the MutationObserver loop guard (textContent/ 416 // innerHTML writes fire childList mutations; without the skip, the 417 // observer would re-trigger its own re-apply forever). 418 function __fv_applyTransforms(expKey, transforms) { 419 for (var i = 0; i < transforms.length; i++) { 420 try { 421 var t = transforms[i]; 422 if (!t || !t.selector) continue; 423 var tag = 'data-fv-patched-' + expKey.replace(/[^a-zA-Z0-9_-]/g, '_') + '-' + i; 424 var els = document.querySelectorAll(t.selector); 425 for (var j = 0; j < els.length; j++) { 426 var el = els[j]; 427 if (el.hasAttribute(tag)) continue; 428 if (t.text != null) el.textContent = t.text; 429 else if (t.html != null) el.innerHTML = t.html; 430 else if (t.css != null) el.style.cssText += (el.style.cssText ? '; ' : '') + t.css; 431 el.setAttribute(tag, '1'); 432 } 433 } catch (_) {} 434 } 435 } 436 // Apply now; if the async script is running mid-parse (later elements not 437 // yet in the DOM), stream-apply: a scoped MutationObserver re-applies as 438 // elements land during parsing (GrowthBook dom-mutator pattern), so 439 // below-the-script elements are patched at insertion time instead of 440 // flashing original content until DOMContentLoaded. Final apply + 441 // disconnect on DOMContentLoaded â the observer never outlives parsing. 442 function __fv_applyPatchArm(expKey, arm) { 443 __fv_applyTransforms(expKey, arm.transforms); 444 if (document.readyState === 'loading' && !__fv_dom_ready_scheduled[expKey]) { 445 __fv_dom_ready_scheduled[expKey] = true; 446 var __fv_obs = null; 447 try { 448 __fv_obs = new MutationObserver(function () { 449 __fv_applyTransforms(expKey, arm.transforms); 450 }); 451 __fv_obs.observe(document.documentElement, { childList: true, subtree: true }); 452 } catch (_) {} 453 document.addEventListener('DOMContentLoaded', function () { 454 try { if (__fv_obs) __fv_obs.disconnect(); } catch (_) {} 455 __fv_applyTransforms(expKey, arm.transforms); 456 }); 457 } 458 } 459 460 // Page-experiment runner (initial page load only â no SPA route-change 461 // handling in v1). Experiments with page_url are auto-exposed here, 462 // control included (honest denominator); code-controlled ones keep 463 // requiring window.fv.variant(). url arm â redirect flow; transforms arm 464 // â DOM patches (tag-guarded idempotent, so the cached pass, the refetch 465 // pass, and the parse-time observer may all re-apply). Preview mode is exclusive:
466 // only the previewed experiment acts â no exposure, no redirects â so a 467 // QA session can't be redirected away or pollute assignment data. 468 function __fv_runPageExperiment(experiments, isSync) { 469 try { 470 if (__fv_preview) { 471 for (var p = 0; p < experiments.length; p++) { 472 var pexp = experiments[p]; 473 if (!pexp || pexp.id !== __fv_preview.expId || !pexp.page_url || !pexp.variations) continue; 474 if (!__fv_matchesPage(pexp.page_url, location.href)) return; 475 for (var q = 0; q < pexp.variations.length; q++) { 476 var parm = pexp.variations[q]; 477 if (parm && parm.key === __fv_preview.variantKey && parm.transforms && parm.transforms.length) { 478 __fv_applyPatchArm(pexp.key, parm); 479 } 480 } 481 return; 482 } 483 return; 484 } 485 for (var i = 0; i < experiments.length; i++) { 486 var exp = experiments[i]; 487 if (!exp || !exp.page_url || !exp.key || !exp.variations) continue; 488 if (!__fv_matchesPage(exp.page_url, location.href)) continue; 489 var variant = __fv_assignments[exp.key] || __fv_bucket(visitorId, exp.key, exp.variations); 490 __fv_sendExposureBeacon(exp.key, variant); 491 if (exp.execution === 'edge') continue; // edge: Worker served the page; only record exposure 492 var arm = null; 493 for (var j = 0; j < exp.variations.length; j++) { 494 if (exp.variations[j].key === variant) { arm = exp.variations[j]; break; } 495 } 496 if (!arm) return; 497 if (arm.transforms && arm.transforms.length) { 498 __fv_applyPatchArm(exp.key, arm); 499 return; // first matching experiment only (start guard makes >1 impossible) 500 } 501 if (__fv_redirect_decided) return; // redirect already decided on the cached pass 502 __fv_redirect_decided = true; 503 if (!arm.url) return; // control â page shows normally 504 if (__fv_matchesPage(arm.url, location.href)) return; // already on the variant page 505 if (isSync) __fv_suppress_pv = true; // variant page's pageview is the real one (no double count) 506 // Hide during the hop; 1.5s failsafe un-hide if the redirect never lands. 507 try { 508 document.documentElement.style.opacity = '0'; 509 setTimeout(function () { try { document.documentElement.style.opacity = ''; } catch (_) {} }, 1500); 510 } catch (_) {} 511 location.replace(__fv_variantRedirectUrl(arm.url, location.href)); 512 return; 513 } 514 } catch (_) {} 515 } 516 517 // Config cache: returning visitors get a SYNCHRONOUS redirect decision 518 // from localStorage (any age); the network refetch always runs and 519 // refreshes the cache + assignments + data-variant attributes. A stopped 520 // experiment therefore stops redirecting at most one pageview late. 521 // debt: first-visit redirect decides async (~100-300ms flash of the original page), move to serve-time/edge config if the flash measurably hurts conversion 522 function __fv_bootstrapExperiments() { 523 var cached = __fv_readExpCache(); 524 if (cached) { 525 __fv_applyAssignments(cached); 526 __fv_runPageExperiment(cached, true); 527 } 528 try { 529 var __fv_cfg_url = origin + '/v1/experiments/active?api_key=' + encodeURIComponent(apiKey); 530 if (__fv_preview) __fv_cfg_url += '&preview_exp=' + encodeURIComponent(__fv_preview.expId); 531 fetch(__fv_cfg_url, { 532 method: 'GET', credentials: 'omit', mode: 'cors', 533 }).then(function (r) { return r.json().catch(function () { return null; }); }) 534 .then(function (data) { 535 if (!data || !data.experiments) return; 536 if (!__fv_preview) { 537 // Preview responses can contain drafts â never cache them. 538 try { 539 localStorage.setItem(EXP_CACHE_KEY, JSON.stringify({ fetchedAt: Date.now(), experiments: data.experiments })); 540 } catch (_) {} 541 } 542 __fv_applyAssignments(data.experiments); 543 __fv_runPageExperiment(data.experiments, false); 544 }).catch(function () {}); 545 } catch (_) {} 546 } 547 __fv_bootstrapExperiments(); 548 549 // T21: session helpers â UUIDv4 minted on first event, rotated after 30 550 // min inactivity OR 24h hard cap (Amplitude parity). Persisted in 551 // sessionStorage; the rotation check runs before every event send. 552 var SESSION_KEY = "fv_session"; 553 var SESSION_INACTIVITY_MS = 30 * 60 * 1000; 554 var SESSION_HARD_CAP_MS = 24 * 60 * 60 * 1000; 555 function getSessionId() {
556 try { 557 var raw = sessionStorage.getItem(SESSION_KEY); 558 var now = Date.now(); 559 if (raw) { 560 var s = JSON.parse(raw); 561 var lastSeen = (s && s.last) || 0; 562 var startedAt = (s && s.started) || lastSeen; 563 var inactive = (now - lastSeen) >= SESSION_INACTIVITY_MS; 564 var capExpired = (now - startedAt) >= SESSION_HARD_CAP_MS; 565 if (s && s.id && !inactive && !capExpired) { 566 s.last = now; 567 sessionStorage.setItem(SESSION_KEY, JSON.stringify(s)); 568 return s.id; 569 } 570 } 571 var id = uuidv4(); 572 sessionStorage.setItem(SESSION_KEY, JSON.stringify({ id: id, last: now, started: now })); 573 return id; 574 } catch (_) { return uuidv4(); } 575 } 576 577 function fvBareHost(h) { 578 h = fvStripTrailingDot(h); 579 if (!h) return h; 580 if (h.indexOf('www.') === 0) h = h.slice(4); 581 return h; 582 } 583 function sessionInSample(id) { 584 var hex = ('00000000' + fvHash(id)).slice(-8); 585 return parseInt(hex, 16) / 4294967295 < 0.10; 586 } 587 function maybeStartReplay() { 588 try { 589 if (!MARKETING_HOST) return; 590 if (fvBareHost(location.hostname) === MARKETING_HOST) return; 591 var sid = getSessionId(); 592 if (!sessionInSample(sid)) return; 593 if (window.__fvReplayStarted) return; 594 window.__fvReplayStarted = true; 595 window.__fvReplay = { 596 session_id: sid, 597 visitor_id: visitorId, 598 origin: origin, 599 api_key: apiKey, 600 host: fvStripTrailingDot(location.hostname) 601 }; 602 var el = document.createElement('script'); 603 el.src = origin + '/replay.js'; 604 el.async = true; 605 (document.head || document.documentElement).appendChild(el); 606 } catch (_) {} 607 } 608 maybeStartReplay(); 609 610 function sendPayload(payload) { 611 try { 612 if (typeof fetch === 'function') { 613 fetch(endpoint, { 614 method: 'POST', 615 body: payload, 616 keepalive: true, 617 credentials: 'include', 618 mode: 'cors', 619 }).then(function (r) { 620 return r.json().catch(function () { return null; }); 621 }).then(function (data) { 622 if (data && typeof data.visitor_id === 'string' && data.visitor_id) { 623 visitorId = data.visitor_id; 624 writeLocal(visitorId); 625 } 626 }).catch(function () { 627 if (navigator.sendBeacon) navigator.sendBeacon(endpoint, payload); 628 }); 629 return; 630 } 631 } catch (_) {} 632 if (navigator.sendBeacon) navigator.sendBeacon(endpoint, payload); 633 } 634 635 var __FV_SUBMIT_WINDOW = 3000; // ms â suppress identical re-fires (echo window) 636 var __fv_recent_submits = {}; // content signature -> last-send ms 637 638 // Compact, dependency-free string hash (djb2). Browsers may lack crypto.subtle 639 // in non-secure contexts, so do not use it here. 640 function __fv_hashStr(s) { 641 var h = 5381, i = s.length; 642 while (i) h = (h * 33) ^ s.charCodeAt(--i); 643 return (h >>> 0).toString(36); 644 } 645 646 // Signature over form identity + captured values. Stable across an AJAX 647 // re-render of the same form with the same values. 648 function __fv_submitSig(p) { 649 var fd = ''; 650 if (p.form_data) { 651 var keys = Object.keys(p.form_data).sort(); 652 for (var i = 0; i < keys.length; i++) fd += keys[i] + '=' + p.form_data[keys[i]] + '&'; 653 } 654 return __fv_hashStr([ 655 p.form_id || '', p.form_email || '', p.form_phone || '', 656 p.form_name_value || '', fd, (p.field_names || []).join(',') 657 ].join('|')); 658 } 659 660 // âââ T22: Core Web Vitals (LCP/INP/CLS) via web-vitals npm package. âââ 661 // The iife bundle is injected by tracker-template.ts:applyWebVitals at 662 // serve time â leave the placeholder line EXACTLY as-is. 663 var webVitals=function(e){"use strict";var n,t,r,i,o,a=-1,c=function(e){addEventListener("pageshow",(function(n){n.persisted&&(a=n.timeStamp,e(n))}),!0)},u=function(){var e=self.performance&&performance.getEntriesByType&&performance.getEntriesByType("navigation")[0];if(e&&e.responseStart>0&&e.responseStart<performance.now())return e},s=function(){var e=u();return e&&e.activationStart||0},f=function(e,n){var t=u(),r="navigate";a>=0?r="back-forward-cache":t&&(document.prerendering||s()>0?r="prerender":document.wasDiscarded?r="restore":t.type&&(r=t.type.replace(/_/g,"-")));return{name:e,value:void 0===n?-1:n,rating:"good",delta:0,entries:[],id:"v4-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12),navigationType:r}},d=function(e,n,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){var r=new PerformanceObserver((function(e){Promise.resolve().then((function(){n(e.getEntries())}))}));return r.observe(Object.assign({type:e,buffered:!0},t||{})),r}}catch(e){}},l=function(e,n,t,r){var i,o;return function(a){n.value>
vendor: 1,083 bytes, line 663
663=0&&(a||r)&&((o=n.value-(i||0))||void 0===i)&&(i=n.value,n.delta=o,n.rating=function(e,n){return e>n[1]?"poor":e>n[0]?"needs-improvement":"good"}(n.value,t),e(n))}},p=function(e){requestAnimationFrame((function(){return requestAnimationFrame((function(){return e()}))}))},v=function(e){document.addEventListener("visibilitychange",(function(){"hidden"===document.visibilityState&&e()}))},m=function(e){var n=!1;return function(){n||(e(),n=!0)}},h=-1,g=function(){return"hidden"!==document.visibilityState||document.prerendering?1/0:0},T=function(e){"hidden"===document.visibilityState&&h>-1&&(h="visibilitychange"===e.type?e.timeStamp:0,E())},y=function(){addEventListener("visibilitychange",T,!0),addEventListener("prerenderingchange",T,!0)},E=function(){removeEventListener("visibilitychange",T,!0),removeEventListener("prerenderingchange",T,!0)},C=function(){return h<0&&(h=g(),y(),c((function(){setTimeout((function(){h=g(),y()}),0)}))),{get firstHiddenTime(){return h}}},L=function(e){document.prerendering?addEventListener("prerenderingchange",(function(){return e()}),!0):e()}
663,S=[1800,3e3],b=function(e,n){n=n||{},L((function(){var t,r=C(),i=f("FCP"),o=d("paint",(function(e){e.forEach((function(e){"first-contentful-paint"===e.name&&(o.disconnect(),e.startTime<r.firstHiddenTime&&(i.value=Math.max(e.startTime-s(),0),i.entries.push(e),t(!0)))}))}));o&&(t=l(e,i,S,n.reportAllChanges),c((function(r){i=f("FCP"),t=l(e,i,S,n.reportAllChanges),p((function(){i.value=performance.now()-r.timeStamp,t(!0)}))})))}))},w=[.1,.25],I=0,P=1/0,A=0,F=function(e){e.forEach((function(e){e.interactionId&&(P=Math.min(P,e.interactionId),A=Math.max(A,e.interactionId),I=A?(A-P)/7+1:0)}))},M=function(){return n?I:performance.interactionCount||0},k=function(){"interactionCount"in performance||n||(n=d("event",F,{type:"event",buffered:!0,durationThreshold:0}))},D=[],B=new Map,R=0,x=function(){var e=Math.min(D.length-1,Math.floor((M()-R)/50));return D[e]},H=[],N=function(e){if(H.forEach((function(n){return n(e)})),e.interactionId||"first-input"===e.entryType){var n=D[D.length-1],t=B.get(e.interactionId);if(t||D.length<10||e.duration>n.latency){if(t)e.duration>t.latency?(t.entries=[e],t.latency=e.duration):e.duration===t.latency&&e.startTime===t.entries[0].startTime&&t.entries.push(e);else{var r={id:e.interactionId,latency:e.duration,entries:[e]};B.set(r.id,r),D.push(r)}D.sort((function(e,n){return n.latency-e.latency})),D.length>10&&D.splice(10).forEach((function(e){return B.delete(e.id)}))}}},q=function(e){var n=self.requestIdleCallback||self.setTimeout,t=-1;return e=m(e),"hidden"===document.visibilityState?e():(t=n(e),v(e)),t},O=[200,500],j=[2500,4e3],V={},_=[800,1800],z=function e(n){document.prerendering?L((function(){return e(n)})):"complete"!==document.readyState?addEventListener("load",(function(){return e(n)}),!0):setTimeout(n,0)},G={passive:!0,capture:!0},J=new Date,K=function(e,n){t||(t=n,r=e,i=new Date,W(removeEventListener),Q())},Q=function(){if(r>=0&&r<i-J){var e={entryType:"first-input",name:t.type,target:t.target,cancelable:t.cancelable,startTime:t.timeStamp,processingStart:t.timeStamp+r};o.forEach((function(n){n(e)})),o=[]}},U=function(e){if(e.cancelable){var n=(e.timeStamp>1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,n){var t=function(){K(e,n),i()},r=function(){i()},i=function(){removeEventListener("pointerup",t,G),removeEventListener("pointercancel",r,G)};addEventListener("pointerup",t,G),addEventListener("pointercancel",r,G)}(n,e):K(n,e)}},W=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach((function(n){return e(n,U,G)}))},X=[100,300];return e.CLSThresholds=w,e.FCPThresholds=S,e.FIDThresholds=X,e.INPThresholds=O,e.LCPThresholds=j,e.TTFBThresholds=_,e.onCLS=function(e,n){
663n=n||{},b(m((function(){var t,r=f("CLS",0),i=0,o=[],a=function(e){e.forEach((function(e){if(!e.hadRecentInput){var n=o[0],t=o[o.length-1];i&&e.startTime-t.startTime<1e3&&e.startTime-n.startTime<5e3?(i+=e.value,o.push(e)):(i=e.value,o=[e])}})),i>r.value&&(r.value=i,r.entries=o,t())},u=d("layout-shift",a);u&&(t=l(e,r,w,n.reportAllChanges),v((function(){a(u.takeRecords()),t(!0)})),c((function(){i=0,r=f("CLS",0),t=l(e,r,w,n.reportAllChanges),p((function(){return t()}))})),setTimeout(t,0))})))},e.onFCP=b,e.onFID=function(e,n){n=n||{},L((function(){var i,a=C(),u=f("FID"),s=function(e){e.startTime<a.firstHiddenTime&&(u.value=e.processingStart-e.startTime,u.entries.push(e),i(!0))},p=function(e){e.forEach(s)},h=d("first-input",p);i=l(e,u,X,n.reportAllChanges),h&&(v(m((function(){p(h.takeRecords()),h.disconnect()}))),c((function(){var a;u=f("FID"),i=l(e,u,X,n.reportAllChanges),o=[],r=-1,t=null,W(addEventListener),a=s,o.push(a),Q()})))}))},e.onINP=function(e,n){"PerformanceEventTiming"in self&&"interactionId"in PerformanceEventTiming.prototype&&(n=n||{},L((function(){var t;k();var r,i=f("INP"),o=function(e){q((function(){e.forEach(N);var n=x();n&&n.latency!==i.value&&(i.value=n.latency,i.entries=n.entries,r())}))},a=d("event",o,{durationThreshold:null!==(t=n.durationThreshold)&&void 0!==t?t:40});r=l(e,i,O,n.reportAllChanges),a&&(a.observe({type:"first-input",buffered:!0}),v((function(){o(a.takeRecords()),r(!0)})),c((function(){R=M(),D.length=0,B.clear(),i=f("INP"),r=l(e,i,O,n.reportAllChanges)})))})))}
663,e.onLCP=function(e,n){n=n||{},L((function(){var t,r=C(),i=f("LCP"),o=function(e){n.reportAllChanges||(e=e.slice(-1)),e.forEach((function(e){e.startTime<r.firstHiddenTime&&(i.value=Math.max(e.startTime-s(),0),i.entries=[e],t())}))},a=d("largest-contentful-paint",o);if(a){t=l(e,i,j,n.reportAllChanges);var u=m((function(){V[i.id]||(o(a.takeRecords()),a.disconnect(),V[i.id]=!0,t(!0))}));["keydown","click"].forEach((function(e){addEventListener(e,(function(){return q(u)}),{once:!0,capture:!0})})),v(u),c((function(r){i=f("LCP"),t=l(e,i,j,n.reportAllChanges),p((function(){i.value=performance.now()-r.timeStamp,V[i.id]=!0,t(!0)}))}))}}))},e.onTTFB=function(e,n){n=n||{};var t=f("TTFB"),r=l(e,t,_,n.reportAllChanges);z((function(){var i=u();i&&(t.value=Math.max(i.responseStart-s(),0),t.entries=[i],r(!0),c((function(){t=f("TTFB",0),(r=l(e,t,_,n.reportAllChanges))(!0)})))}))},e}({}); 664 665 window.__fv_cwv = window.__fv_cwv || {}; 666 try { 667 if (typeof webVitals !== 'undefined') { 668 webVitals.onLCP(function (v) { window.__fv_cwv.lcp = v.value; }); 669 webVitals.onINP(function (v) { window.__fv_cwv.inp = v.value; }); 670 webVitals.onCLS(function (v) { window.__fv_cwv.cls = v.value; }); 671 } 672 } catch (_) {} 673 674 // âââ T22: engagement bookkeeping âââ 675 var __fv_engage = { 676 open_at: Date.now(), 677 max_scroll: 0, 678 clicks: 0, 679 first_int: null, 680 visible_ms: 0, 681 hidden_ms: 0, 682 last_vis_change: Date.now(), 683 }; 684 685 function throttle(fn, ms) { 686 var last = 0, timer = null; 687 return function () { 688 var now = Date.now(); 689 if (now - last >= ms) { last = now; fn(); } 690 else { if (timer) clearTimeout(timer); timer = setTimeout(function () { last = Date.now(); fn(); }, ms - (now - last)); } 691 }; 692 } 693 694 // Scroll-depth milestones, two complementary signals fired once per mark 695 // crossed (page-load scoped; reset on SPA route change). sendEvent is hoisted. 696 // $scroll_depth â percent of total document height (25/50/75/100). Cheap 697 // read of "how far through the page", but degenerate on very long pages: 698 // a 40k-px article needs ~15 screens of scroll to reach the 25% mark, so 699 // engaged readers who scan the top never trip a milestone. 700 // $scroll_reach â ABSOLUTE depth in viewport-heights scrolled (1/2/3/5/8). 701 // Independent of page length, so long-form engagement stays measurable. 702 var __fv_scroll_fired = {}; 703 var __fv_reach_fired = {}; 704 var __FV_SCROLL_MARKS = [25, 50, 75, 100]; 705 var __FV_REACH_MARKS = [1, 2, 3, 5, 8]; 706 window.addEventListener('scroll', throttle(function () { 707 var seen = window.scrollY + window.innerHeight; 708 var pct = Math.round((seen / document.documentElement.scrollHeight) * 100); 709 if (pct > 100) pct = 100; 710 if (pct > __fv_engage.max_scroll) __fv_engage.max_scroll = pct; 711 for (var i = 0; i < __FV_SCROLL_MARKS.length; i++) { 712 var m = __FV_SCROLL_MARKS[i]; 713 if (pct >= m && !__fv_scroll_fired[m]) { 714 __fv_scroll_fired[m] = true; 715 sendEvent('$scroll_depth', { 716 pageview_id: __fv_pageview_id, 717 depth: m, 718 time_to_depth_ms: Date.now() - __fv_engage.open_at, 719 page_url: pageUrl(), 720 }); 721 } 722 } 723 var vh = window.innerHeight > 0 ? seen / window.innerHeight : 0; 724 for (var j = 0; j < __FV_REACH_MARKS.length; j++) { 725 var r = __FV_REACH_MARKS[j]; 726 if (vh >= r && !__fv_reach_fired[r]) { 727 __fv_reach_fired[r] = true; 728 sendEvent('$scroll_reach', { 729 pageview_id: __fv_pageview_id, 730 viewports: r, 731 pixels: Math.round(seen), 732 time_to_depth_ms: Date.now() - __fv_engage.open_at, 733 page_url: pageUrl(), 734 }); 735 } 736 } 737 }, 250), { passive: true }); 738 739 ['click', 'keydown', 'pointerdown'].forEach(function (ev) { 740 window.addEventListener(ev, function () { 741 if (ev === 'click') __fv_engage.clicks++; 742 if (__fv_engage.first_int === null) __fv_engage.first_int = Date.now() - __fv_engage.open_at; 743 }, { passive: true }); 744 }); 745 746 document.addEventListener('visibilitychange', function () { 747 var now = Date.now(); 748 var delta = now - __fv_engage.last_vis_change; 749 if (document.visibilityState === 'hidden') __fv_engage.visible_ms += delta; 750 else __fv_engage.hidden_ms += delta; 751 __fv_engage.last_vis_change = now; 752 if (document.visibilityState === 'hidden') flushEngagement(); 753 }); 754 755 window.addEventListener('pagehide', flushEngagement); 756 window.addEventListener('pageshow', function (e) { window.__fv_bfcache = e.persisted; }); 757 758 function flushEngagement() { 759 if (!__fv_pageview_id) return; 760 var nav = (performance.getEntriesByType && performance.getEntriesByType('navigation')[0]) || {}; 761 var payload = JSON.stringify({ 762 kind: 'engagement', 763 visitor_id: visitorId, 764 session_id: getSessionId(), 765 pageview_id: __fv_pageview_id, 766 page_url: location.href, 767 time_on_page_ms: Date.now() - __fv_engage.open_at, 768 max_scroll_pct: __fv_engage.max_scroll, 769 click_count: __fv_engage.clicks, 770 first_interaction_ms: __fv_engage.first_int, 771 navigation_type: nav.type || null, 772 load_ms: nav.loadEventEnd | 0, 773 dcl_ms: nav.domContentLoadedEventEnd | 0, 774 ttfb_ms: nav.responseStart | 0, 775 bfcache_restore: !!window.__fv_bfcache, 776 visible_ms: __fv_engage.visible_ms, 777 hidden_ms: __fv_engage.hidden_ms, 778 lcp_ms: (window.__fv_cwv && window.__fv_cwv.lcp) || null, 779 inp_ms: (window.__fv_cwv && window.__fv_cwv.inp) || null, 780 cls: (window.__fv_cwv && typeof window.__fv_cwv.cls === 'number') ? window.__fv_cwv.cls : null, 781 }); 782 sendPayload(payload); 783 } 784 785 // §B stable signals â emitted on every $pageview; server UPDATEs `visitor` 786 // (latest wins). Per-pageview signals (viewport/connection) computed at 787 // build-payload time so resize/roaming is captured. 788 function readStableSignals() { 789 var out = {};
790 try { 791 out.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 792 } catch (_) { out.timezone = null; } 793 out.locale = (navigator.language || null); 794 out.languages = (navigator.languages && Array.prototype.slice.call(navigator.languages)) || null; 795 out.platform = navigator.platform || null; 796 out.hw_concurrency = typeof navigator.hardwareConcurrency === "number" ? navigator.hardwareConcurrency : null; 797 out.device_memory = typeof navigator.deviceMemory === "number" ? navigator.deviceMemory : null; 798 out.max_touch_points = typeof navigator.maxTouchPoints === "number" ? navigator.maxTouchPoints : null; 799 out.screen_w = (window.screen && window.screen.width) || null; 800 out.screen_h = (window.screen && window.screen.height) || null; 801 out.color_depth = (window.screen && window.screen.colorDepth) || null; 802 out.device_pixel_ratio = window.devicePixelRatio || null; 803 try { 804 out.prefers_color_scheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 805 out.prefers_reduced_motion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 806 out.prefers_contrast_more = window.matchMedia('(prefers-contrast: more)').matches; 807 out.has_hover = window.matchMedia('(hover: hover)').matches; 808 out.pointer_fine = window.matchMedia('(pointer: fine)').matches; 809 } catch (_) {} 810 try { 811 out.ua_brands = (navigator.userAgentData && navigator.userAgentData.brands) || null; 812 } catch (_) { out.ua_brands = null; } 813 // navigator.webdriver === true is high-confidence automation when true; 814 // its absence proves nothing (cloud/stealth agents remove it via CDP). 815 out.webdriver = navigator.webdriver === true; 816 // High-entropy stable signals (cookieless fallback identity). Each in its 817 // own try/catch â null is acceptable when the browser blocks the API. 818 try { 819 var cv = document.createElement('canvas'); 820 var ctx = cv.getContext('2d'); 821 if (ctx) { 822 ctx.textBaseline = 'top'; 823 ctx.font = "14px 'Arial'"; 824 ctx.fillStyle = '#f60'; ctx.fillRect(125, 1, 62, 20); 825 ctx.fillStyle = '#069'; ctx.fillText('Fullvision_fp_v1', 2, 15); 826 out.canvas_hash = fvHash(cv.toDataURL()); 827 } else { out.canvas_hash = null; } 828 } catch (_) { out.canvas_hash = null; } 829 try { 830 var gl = (document.createElement('canvas')).getContext('webgl') || 831 (document.createElement('canvas')).getContext('experimental-webgl'); 832 if (gl) { 833 var dbg = gl.getExtension('WEBGL_debug_renderer_info'); 834 out.webgl_vendor = dbg ? gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL) : null; 835 out.webgl_renderer = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : null; 836 out.webgl_hash = fvHash([ 837 gl.getParameter(gl.VERSION), 838 gl.getParameter(gl.SHADING_LANGUAGE_VERSION), 839 (gl.getSupportedExtensions() || []).join(',') 840 ].join('|')); 841 } else { out.webgl_vendor = null; out.webgl_renderer = null; out.webgl_hash = null; } 842 } catch (_) { out.webgl_vendor = null; out.webgl_renderer = null; out.webgl_hash = null; } 843 try { 844 var AC = window.OfflineAudioContext || window.webkitOfflineAudioContext; 845 if (AC) { 846 var actx = new AC(1, 4096, 44100); 847 var osc = actx.createOscillator(); 848 osc.type = 'triangle'; osc.frequency.value = 10000; 849 var comp = actx.createDynamicsCompressor(); 850 osc.connect(comp); comp.connect(actx.destination); 851 osc.start(0); actx.startRendering(); 852 actx.oncomplete = function (e) { 853 try { 854 var buf = e.renderedBuffer.getChannelData(0); 855 var sum = 0; for (var i = 0; i < buf.length; i++) sum += Math.abs(buf[i]); 856 out.audio_hash = fvHash(String(sum)); 857 } catch (_) {} 858 // Audio resolved (a tick after load). Flush the initial pageview now 859 // so its beacon carries audio_hash instead of waiting out the timer. 860 if (typeof __fv_flushOnAudio === 'function') { try { __fv_flushOnAudio(); } catch (_) {} } 861 }; 862 } 863 } catch (_) { out.audio_hash = out.audio_hash || null; } 864 return out; 865 } 866 867 function readPerPageviewSignals() { 868 var out = { 869 viewport_w: window.innerWidth || null, 870 viewport_h: window.innerHeight || null, 871 };
872 try { 873 var c = navigator.connection || navigator.mozConnection || navigator.webkitConnection; 874 if (c) { 875 out.connection_type = c.effectiveType || null; 876 out.connection_downlink = typeof c.downlink === "number" ? c.downlink : null; 877 out.connection_rtt = typeof c.rtt === "number" ? c.rtt : null; 878 out.connection_save_data = !!c.saveData; 879 } 880 } catch (_) {} 881 return out; 882 } 883 884 // Stable (device-constant) signals are collected ONCE and cached by reference. 885 // audio_hash is computed in an OfflineAudioContext.oncomplete callback that 886 // fires a tick later than the first beacon â but it writes into this same 887 // cached object, so every beacon after the first carries it and ingest 888 // COALESCEs it onto the visitor row. (Re-reading per pageview would orphan 889 // each callback's write into a discarded object â the original bug.) 890 var __fv_stable = null; 891 function getStableSignals() { 892 if (__fv_stable === null) __fv_stable = readStableSignals(); 893 return __fv_stable; 894 } 895 // Set by the initial-pageview block; called from the audio oncomplete so the 896 // first beacon flushes the instant audio_hash is ready (see below). 897 var __fv_flushOnAudio = null; 898 899 function buildPageviewPayload() { 900 maybeWriteGadsCookie(); 901 var stable = getStableSignals(); 902 var perpv = readPerPageviewSignals(); 903 var payload = { 904 kind: "pageview", 905 visitor_id: visitorId, 906 pageview_id: __fv_pageview_id, 907 session_id: getSessionId(), 908 url: pageUrl(), 909 title: document.title, 910 referrer: document.referrer, 911 // _fbp browser cookie set by the customer's Meta Pixel; null when no pixel. 912 // Stored in event properties â CH events.fbp â person.first_touch_fbp (Meta CAPI). 913 fbp: getCookie("_fbp"), 914 // {key: variant} assigned map â server copies it into properties.experiments 915 // (journey display: "assigned", distinct from the Experiment Exposed event). 916 experiments: __fv_assignments, 917 }; 918 // Merge stable + per-pageview client signals 919 for (var k in stable) { if (Object.prototype.hasOwnProperty.call(stable, k)) payload[k] = stable[k]; } 920 for (var k2 in perpv) { if (Object.prototype.hasOwnProperty.call(perpv, k2)) payload[k2] = perpv[k2]; } 921 var persist = readGadsPersist(); 922 if (persist) payload.gads_persist = persist; 923 return JSON.stringify(payload); 924 } 925 926 function send() { 927 var url = location.href; 928 var now = Date.now(); 929 if (window.__fullvision_lastUrl === url) return; 930 if ( 931 typeof window.__fullvision_lastSentAt === "number" && 932 now - window.__fullvision_lastSentAt < 250 933 ) { 934 return; 935 } 936 window.__fullvision_lastUrl = url; 937 window.__fullvision_lastSentAt = now; 938 // Mint a fresh pageview_id for SPA route changes (the IIFE only runs 939 // once; subsequent pageviews via pushState/popstate need a new id). 940 __fv_pageview_id = uuidv4(); 941 // New virtual page â allow one Experiment Exposed per experiment again. 942 __fv_exposed = {}; 943 // SPA route change = a fresh virtual page: reset engagement + scroll-depth 944 // bookkeeping so $scroll_depth fires again and $pageleave is scoped to the 945 // new route instead of carrying max_scroll/clicks over from the previous one. 946 __fv_engage.open_at = now; 947 __fv_engage.max_scroll = 0; 948 __fv_engage.clicks = 0; 949 __fv_engage.first_int = null; 950 __fv_engage.visible_ms = 0; 951 __fv_engage.hidden_ms = 0; 952 __fv_engage.last_vis_change = now; 953 __fv_scroll_fired = {}; 954 __fv_reach_fired = {}; 955 sendPayload(buildPageviewPayload()); 956 } 957 958 function patchHistory(method) { 959 var original = history[method]; 960 history[method] = function () { 961 var result = original.apply(this, arguments); 962 send(); 963 return result; 964 }; 965 } 966 patchHistory("pushState"); 967 patchHistory("replaceState"); 968 969 window.addEventListener("popstate", send); 970 971 // Initial pageview â bypass the URL-equality guard (lastUrl undefined here). 972 // Defer the send up to AUDIO_WAIT_MS so the async audio fingerprint (computed 973 // in OfflineAudioContext.oncomplete a few ms after load) rides this beacon â 974 // on a full page load it's usually the ONLY pageview, so the cache alone 975 // wouldn't deliver audio. The audio callback flushes early the moment it 976 // resolves; hide/unload force-flushes so a fast bounce never drops the 977 // pageview. Sends exactly once. 978 window.__fullvision_lastUrl = location.href; 979 window.__fullvision_lastSentAt = Date.now(); 980 var AUDIO_WAIT_MS = 250; 981 var __fv_pvSent = false;
982 var __fv_pvTimer = null; 983 function flushInitialPageview() { 984 if (__fv_suppress_pv) return; // sync split-URL redirect issued â the variant page owns the pageview 985 if (__fv_pvSent) return; 986 __fv_pvSent = true; 987 if (__fv_pvTimer) { clearTimeout(__fv_pvTimer); __fv_pvTimer = null; } 988 sendPayload(buildPageviewPayload()); 989 } 990 __fv_flushOnAudio = flushInitialPageview; 991 getStableSignals(); // kick the audio render now so it can resolve before the timer 992 __fv_pvTimer = setTimeout(flushInitialPageview, AUDIO_WAIT_MS); 993 document.addEventListener('visibilitychange', function () { 994 if (document.visibilityState === 'hidden') flushInitialPageview(); 995 }); 996 window.addEventListener('pagehide', flushInitialPageview); 997 998 // âââ Public API: variant consumption (spec §5) âââ 999 // window.fv.variant(key) â assigned variant (default 'control' if unknown), 1000 // firing `Experiment Exposed` once per experiment per pageview on first call. 1001 window.fv = window.fv || {}; 1002 window.fv.variant = function (key) { 1003 var variant = __fv_assignments[key]; 1004 if (variant == null) return 'control'; // unknown/inactive â control (fail-open) 1005 var guard = key + '|' + __fv_pageview_id; 1006 if (!__fv_exposed[guard]) { 1007 __fv_exposed[guard] = true; 1008 sendEvent('Experiment Exposed', { 1009 pageview_id: __fv_pageview_id, 1010 experiment: key, 1011 variant: variant, 1012 }); 1013 } 1014 return variant; 1015 }; 1016 1017 // âââ T23: $identify autocapture (spec §D) âââ 1018 // 1019 // Form-email blur, mailto click, meta/global/dataLayer scrape. Per-page 1020 // dedupe via __fv_identified_emails so the same email blurred twice fires 1021 // once. 1022 function sendIdentify(email, source) { 1023 if (!email) return; 1024 window.__fv_identified_emails = window.__fv_identified_emails || {}; 1025 if (window.__fv_identified_emails[email]) return; 1026 window.__fv_identified_emails[email] = true; 1027 var payload = JSON.stringify({ 1028 kind: 'identify', 1029 visitor_id: visitorId, 1030 session_id: getSessionId(), 1031 email: email, 1032 source: source, 1033 }); 1034 sendPayload(payload); 1035 } 1036 1037 // Centralized skip â also reused by T25 click/form autocapture. 1038 var __fv_SENSITIVE_RX = /\b(password|cc|card|cvv|ssn|social[-_]?security)\b/i; 1039 function shouldSkipElement(el) { 1040 if (!el) return true; 1041 var node = el; 1042 while (node && node.tagName) { 1043 if (node.classList && node.classList.contains('fv-no-track')) return true; 1044 if (node.hasAttribute && node.hasAttribute('data-fv-private')) return true; 1045 node = node.parentElement; 1046 } 1047 if (el.tagName === 'INPUT' && /^(password|hidden)$/i.test(el.type || '')) return true; 1048 if (el.name && __fv_SENSITIVE_RX.test(el.name)) return true; 1049 if (el.id && __fv_SENSITIVE_RX.test(el.id)) return true; 1050 if (el.autocomplete && /^(cc-|current-password|new-password)/i.test(el.autocomplete)) return true; 1051 return false; 1052 } 1053 1054 // Email field blur â $identify. Requires the input to live inside a <form> 1055 // (free-standing email inputs are usually search/filter widgets, not signups). 1056 document.addEventListener('change', function (e) { 1057 var t = e.target; 1058 if (!t || t.type !== 'email' || !t.value || !t.closest || !t.closest('form')) return; 1059 if (shouldSkipElement(t)) return; 1060 var v = t.value.trim(); 1061 if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) sendIdentify(v, 'form_email'); 1062 }, true); 1063 1064 // mailto: link click â $identify with the address. 1065 document.addEventListener('click', function (e) { 1066 var a = e.target && e.target.closest && e.target.closest('a[href^="mailto:"]'); 1067 if (a) sendIdentify(a.href.replace(/^mailto:/, '').split('?')[0], 'mailto'); 1068 }, true); 1069 1070 // Scrape meta tag + common globals + GTM dataLayer. 1071 try { 1072 var metaEmail = document.querySelector('meta[name="user-email"]'); 1073 if (metaEmail) sendIdentify(metaEmail.content, 'meta_tag'); 1074 ['__USER__', 'user', 'currentUser'].forEach(function (k) { 1075 if (window[k] && window[k].email) sendIdentify(window[k].email, 'global'); 1076 }); 1077 if (window.intercomSettings && window.intercomSettings.email) { 1078 sendIdentify(window.intercomSettings.email, 'global'); 1079 } 1080 if (Array.isArray(window.dataLayer)) { 1081 window.dataLayer.forEach(function (entry) { 1082 if (entry && entry.email) sendIdentify(entry.email, 'datalayer'); 1083 if (entry && entry.user && entry.user.email) sendIdentify(entry.user.email, 'datalayer'); 1084 }); 1085 } 1086 } catch (_) {} 1087 1088 // âââ T24: cross-domain outbound link decoration (spec §E) â DEACTIVATED 2026-08-28 âââ 1089 // Kept for a future customer whose site and app live on different 1090 // registrable domains (acme.com â app.acme.io), where no cookie can 1091 // carry the visitor id. Every current workspace lists only subdomains of 1092 // its cookie_domain (Evaboot: www/dash under evaboot.com), so the shared 1093 // Domain= cookie already stitches the hop: 104 of 108 wwwâdash hops 1094 // measured 2026-07-20..08-28 were stitched by the cookie alone, the other 1095 // 4 had no source pageview at all. Decorating URLs with a visitor id 1096 // bought nothing and put ids in the address bar, so the listener below is 1097 // commented out. Inbound adoption (~line 226) stays active and harmless. 1098 // Before re-enabling: skip targets sharing cookie_domain, gate behind a 1099 // per-workspace opt-in, add a short expiry (GA4 _gl uses ~2 min). See 1100 // docs/handoffs/handoff-fv-vid-cross-domain-handoff.md. 1101 // 1102 // For each outbound click on `<a href>` whose host is in TRACKER_ORIGINS, 1103 // append `fv_vid=<visitorId>` to the URL FRAGMENT (#) â not the query 1104 // string. Fragments never reach origin servers, Referer headers, or any 1105 // 3rd-party analytics on the destination site. Same idea as PostHog's 1106 // #posthog=ID. 1107 // document.addEventListener('click', function (e) { 1108 // var a = e.target && e.target.closest && e.target.closest('a[href]'); 1109 // if (!a || !a.href) return; 1110 // try { 1111 // var u = new URL(a.href, location.href); 1112 // if (TRACKER_ORIGINS.indexOf(u.hostname) === -1) return; 1113 // // Same-domain navigation already carries the visitor via the cookie â 1114 // // only decorate genuine cross-domain hops, never pollute same-host URLs. 1115 // if (fvStripTrailingDot(u.hostname) === fvStripTrailingDot(location.hostname)) return; 1116 // if (u.hash && u.hash.indexOf('fv_vid=') !== -1) return; 1117 // u.hash = (u.hash ? u.hash + '&' : '') + 'fv_vid=' + encodeURIComponent(visitorId); 1118 // a.href = u.toString(); 1119 // } catch (_) {} 1120 // }, true); 1121 1122 // âââ T25: $click + $form_submit + $rageclick/$dead_click/$exception (spec §F) âââ 1123 var __fv_click_count = 0; 1124 var __fv_last_click_at_selector = {}; 1125 var __fv_rage_cluster = {}; 1126 // Allowlist for data-* attributes â never blanket-capture `dataset` 1127 // (customer apps stash emails/order-ids/customer-ids in arbitrary 1128 // data-* keys). Values containing `@` or matching __fv_SENSITIVE_RX 1129 // are dropped even if the key is allowed. 1130 var __fv_DATA_ATTR_ALLOW_RX = /^(testid|test|track|variant|category|kind|type|role|page|section|state|popup(type)?)$/i; 1131 1132 function __fv_resolveInteractiveAncestor(node) { 1133 var depth = 0; 1134 while (node && depth < 32) { 1135 if (node.tagName) { 1136 var tag = node.tagName.toLowerCase(); 1137 if (tag === 'body' || tag === 'html') return null; 1138 if (tag === 'button' || tag === 'a' || tag === 'label' || tag === 'summary' || tag === 'select' || tag === 'textarea' || tag === 'input') return node; 1139 if (node.getAttribute && node.getAttribute('role') === 'button') return node; 1140 if (node.onclick) return node; 1141 try { 1142 if (typeof getComputedStyle === 'function' && getComputedStyle(node).cursor === 'pointer') return node; 1143 } catch (_) {} 1144 } 1145 if (node.parentElement) node = node.parentElement; 1146 else if (node.getRootNode) { 1147 var root = node.getRootNode(); 1148 node = (root && root.host) || null; 1149 } else node = null; 1150 depth++; 1151 } 1152 return null; 1153 } 1154 1155 function __fv_computeSelector(el) { 1156 var parts = []; var depth = 0; var node = el; 1157 while (node && depth < 4 && node.tagName) { 1158 if (node.id && !__fv_isUnstableFormId(node.id)) { parts.unshift('#' + node.id); break; } 1159 var seg = node.tagName.toLowerCase(); 1160 if (node.className && typeof node.className === 'string') { 1161 seg += '.' + node.className.trim().split(/\s+/).slice(0, 2).join('.'); 1162 } 1163 parts.unshift(seg); 1164 node = node.parentElement; depth++; 1165 } 1166 return parts.join(' > '); 1167 } 1168 1169 function __fv_readDataAttrs(el) { 1170 var out = {}; 1171 if (!el.dataset) return out; 1172 for (var k in el.dataset) { 1173 if (k.indexOf('fv') === 0) continue; 1174 if (!__fv_DATA_ATTR_ALLOW_RX.test(k)) continue; 1175 var v = el.dataset[k]; 1176 if (typeof v === 'string' && (__fv_SENSITIVE_RX.test(v) || /@/.test(v))) continue; 1177 out[k] = v; 1178 } 1179 return out; 1180 } 1181 1182 function sendEvent(name, props) { 1183 props.session_id = getSessionId(); 1184 // Assigned experiments map on every event, inside properties â the CH 1185 // events.experiments column materializes from properties.experiments. 1186 props.experiments = __fv_assignments; 1187 var payload = JSON.stringify({ 1188 kind: 'event', 1189 visitor_id: visitorId, 1190 event_name: name, 1191 properties: props, 1192 }); 1193 sendPayload(payload); 1194 } 1195 1196 // $dead_click liveness (PostHog-shaped). Shared timestamps so we do not 1197 // leak a MutationObserver per click. Skip <a> â browser nav is unobservable. 1198 var __fv_dead_last = { mutation: 0, scroll: 0, selection: 0, visibility: 0, focus: 0 }; 1199 1200 function __fv_skipDeadClick(node) { 1201 while (node) { 1202 if (node.tagName && String(node.tagName).toLowerCase() === 'a') return true; 1203 node = node.parentElement; 1204 } 1205 return false;
1206 } 1207 1208 function __fv_deadClickIsAlive(last, clickAt) { 1209 function after(t, maxMs) { 1210 return t && t >= clickAt && (t - clickAt) < maxMs; 1211 } 1212 function around(t, maxMs) { 1213 if (!t) return false; 1214 var d = t - clickAt; 1215 if (d < 0) d = -d; 1216 return d < maxMs; 1217 } 1218 return after(last.mutation, 2500) 1219 || after(last.scroll, 100) 1220 || after(last.selection, 100) 1221 || around(last.visibility, 1000) 1222 || around(last.focus, 1000); 1223 } 1224 1225 window.addEventListener('scroll', function () { __fv_dead_last.scroll = Date.now(); }, true); 1226 document.addEventListener('selectionchange', function () { __fv_dead_last.selection = Date.now(); }); 1227 document.addEventListener('visibilitychange', function () { __fv_dead_last.visibility = Date.now(); }); 1228 window.addEventListener('focus', function () { __fv_dead_last.focus = Date.now(); }); 1229 window.addEventListener('blur', function () { __fv_dead_last.focus = Date.now(); }); 1230 function __fv_attachDeadMutationObserver() { 1231 try { 1232 if (typeof MutationObserver !== 'function' || !document.body) return; 1233 var deadMo = new MutationObserver(function () { __fv_dead_last.mutation = Date.now(); }); 1234 deadMo.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true }); 1235 } catch (_) {} 1236 } 1237 if (document.body) __fv_attachDeadMutationObserver(); 1238 else document.addEventListener('DOMContentLoaded', __fv_attachDeadMutationObserver); 1239 1240 // $click + $rageclick + $dead_click â all hang off the same capture-phase listener. 1241 document.addEventListener('click', function (e) { 1242 if (__fv_click_count >= 200) return; 1243 var rawTarget = e.target; 1244 try { 1245 if (e.composedPath) { 1246 var composed = e.composedPath(); 1247 if (composed && composed[0]) rawTarget = composed[0]; 1248 } 1249 } catch (_) {} 1250 if (!rawTarget || shouldSkipElement(rawTarget)) return; 1251 var el = __fv_resolveInteractiveAncestor(rawTarget); 1252 if (!el) return; 1253 var selector = __fv_computeSelector(el); 1254 var now = Date.now(); 1255 1256 // $rageclick â sliding window per selector, independent of the $click 1257 // 500ms dedupe so 200â300ms bursts can still reach â¥3 / 1s. 1258 var cluster = (__fv_rage_cluster[selector] || []).filter(function (t) { return now - t < 1000; }); 1259 cluster.push(now); 1260 __fv_rage_cluster[selector] = cluster; 1261 if (cluster.length >= 3) { 1262 sendEvent('$rageclick', { 1263 pageview_id: __fv_pageview_id, 1264 selector: selector, 1265 click_count: cluster.length, 1266 first_click_ms: cluster[0], 1267 last_click_ms: cluster[cluster.length - 1], 1268 page_url: pageUrl(), 1269 }); 1270 __fv_rage_cluster[selector] = []; 1271 } 1272 1273 if (__fv_last_click_at_selector[selector] && now - __fv_last_click_at_selector[selector] < 500) return; 1274 __fv_last_click_at_selector[selector] = now; 1275 __fv_click_count++; 1276 1277 var classes = []; 1278 if (el.className && typeof el.className === 'string') { 1279 classes = el.className.split(/\s+/).filter(Boolean); 1280 } 1281 var formEl = el.closest && el.closest('form'); 1282 // Outbound flag â http(s) link whose host differs from the current host. 1283 // app.evaboot.com from evaboot.com counts as outbound (host inequality), 1284 // which is what we want for marketingâapp / Stripe / Calendly clicks. 1285 var isOutbound = false, outboundHost = null; 1286 if (el.href) { 1287 try { 1288 var ou = new URL(el.href, location.href); 1289 if (/^https?:$/.test(ou.protocol) && ou.hostname && fvStripTrailingDot(ou.hostname) !== fvStripTrailingDot(location.hostname)) { 1290 isOutbound = true; 1291 outboundHost = fvStripTrailingDot(ou.hostname); 1292 } 1293 } catch (_) {} 1294 } 1295 var modalEl = MODAL_AUTOCAPTURE ? __fv_acceptedModalAncestor(el) : null; 1296 if (modalEl) modalEl.__fv_modal_interacted = true; 1297 sendEvent('$click', { 1298 pageview_id: __fv_pageview_id, 1299 tag: el.tagName.toLowerCase(), 1300 text: (el.textContent || '').trim().slice(0, 200), 1301 selector: selector, 1302 id: el.id || null, 1303 classes: classes, 1304 href: el.href || null, 1305 is_outbound: isOutbound, 1306 outbound_host: outboundHost, 1307 target: el.target || null, 1308 data_attrs: __fv_readDataAttrs(el), 1309 form_id: formEl ? __fv_formIdOf(formEl, true) : null, 1310 action: __fv_actionOf(el), 1311 inner_tag: rawTarget !== el && rawTarget.tagName ? rawTarget.tagName.toLowerCase() : null, 1312 page_url: pageUrl(), 1313 modal_id: modalEl ? __fv_modalId(modalEl) : null, 1314 modal_selector: modalEl ? __fv_computeSelector(modalEl) : null, 1315 }); 1316 1317 // $dead_click â alive if mutation < 2500ms, scroll/selection < 100ms, 1318 // or visibility/focus/blur within 1000ms. Anchors skip (nav is silent).
1319 try { 1320 if (!__fv_skipDeadClick(rawTarget)) { 1321 var deadClickAt = now; 1322 setTimeout(function () { 1323 if (!__fv_deadClickIsAlive(__fv_dead_last, deadClickAt)) { 1324 sendEvent('$dead_click', { 1325 pageview_id: __fv_pageview_id, 1326 selector: selector, 1327 page_url: pageUrl(), 1328 }); 1329 } 1330 }, 2500); 1331 } 1332 } catch (_) {} 1333 }, true); 1334 1335 // âââ T25b: form-value capture (global) âââ 1336 // Global kill-switch. Flip to false + redeploy ingest to disable value 1337 // capture everywhere (falls back to names-only $form_submit). 1338 var CAPTURE_FORM_VALUES = true; 1339 1340 var __FV_EMAIL_RX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 1341 var __FV_PHONE_VAL_RX = /^[+()\d][\d\s().-]{6,}$/; 1342 var __FV_EMAIL_HINT_RX = /e-?mail/i; 1343 var __FV_PHONE_HINT_RX = /phone|tel|mobile|portable|t[eé]l[eé]phone/i; 1344 var __FV_NAME_HINT_RX = /name|nom|pr[eé]nom|first|last|full.?name/i; 1345 var __FV_USERNAME_RX = /user-?name/i; 1346 1347 // Per-value caps (stay well under the 64KB ingest body ceiling). 1348 var __FV_MAX_VALUE = 512; 1349 var __FV_MAX_MESSAGE = 2000; 1350 var __FV_MAX_EMAIL = 320; 1351 var __FV_MAX_PHONE = 64; 1352 var __FV_MAX_NAME = 256; 1353 var __FV_MAX_FORM_DATA_FIELDS = 50; 1354 1355 function __fv_attr(el, name) { 1356 return (el && el.getAttribute && el.getAttribute(name)) || ''; 1357 } 1358 1359 // Form identity: data-fv-form wins; then a stable id; then the selector. 1360 // Alpine runtime ids (_x_marker, _x_*) and serialized component state are 1361 // not stable handles â drop them so dash grouping is not per-render. 1362 function __fv_isUnstableFormId(id) { 1363 if (!id) return true; 1364 if (id === '_x_marker' || id.indexOf('_x_') === 0) return true; 1365 var c = id.charAt(0); 1366 return c === '{' || c === '['; 1367 } 1368 1369 function __fv_formIdOf(el, isForm) { 1370 var explicit = __fv_attr(el, 'data-fv-form'); 1371 if (explicit) return explicit.slice(0, 200); 1372 if (isForm) { 1373 var sid = __fv_safeElId(el.id); 1374 return sid || __fv_computeSelector(el); 1375 } 1376 return __fv_computeSelector(el); 1377 } 1378 1379 function __fv_safeElId(id) { 1380 return (typeof id === 'string' && id.length >= 1 && id.length <= 200 && 1381 id.charAt(0) !== '{' && id.charAt(0) !== '[' && 1382 !__fv_isUnstableFormId(id)) ? id : ''; 1383 } 1384 1385 function __fv_actionOf(node) { 1386 var n = node; 1387 var depth = 0; 1388 while (n && n.getAttribute && depth < 8) { 1389 var a = n.getAttribute('data-fv-action'); 1390 if (a) return String(a).slice(0, 200); 1391 n = n.parentElement; 1392 depth++; 1393 } 1394 return null; 1395 } 1396 1397 // Read a field's submitted value; join multi-value controls with ", ". 1398 function __fv_fieldValue(el) { 1399 var tag = el.tagName ? el.tagName.toLowerCase() : ''; 1400 if (tag === 'select' && el.multiple && el.options) { 1401 var vals = []; 1402 for (var i = 0; i < el.options.length; i++) { 1403 if (el.options[i].selected) vals.push(el.options[i].value); 1404 } 1405 return vals.join(', '); 1406 } 1407 if (el.type === 'checkbox' || el.type === 'radio') { 1408 return el.checked ? (el.value || 'on') : ''; 1409 } 1410 return el.value == null ? '' : String(el.value); 1411 } 1412 1413 // Classify by first match: email > phone > name > other. 1414 function __fv_classify(el, val) { 1415 var type = (el.type || '').toLowerCase(); 1416 var name = el.name || ''; 1417 var id = el.id || ''; 1418 var ac = el.autocomplete || ''; 1419 var doi = __fv_attr(el, 'data-original_id'); 1420 var placeholder = __fv_attr(el, 'placeholder'); 1421 1422 // Lead email: type / autocomplete / name only. Id or "value looks like 1423 // an email" would promote tool widgets (ev-email, a pasted address) to 1424 // leads. Those still land in form_data via the name||id key. 1425 if (type === 'email' || __FV_EMAIL_HINT_RX.test(ac) || 1426 __FV_EMAIL_HINT_RX.test(name)) { 1427 return 'email'; 1428 } 1429 if (type === 'tel' || /tel/i.test(ac) || 1430 __FV_PHONE_HINT_RX.test(name) || __FV_PHONE_HINT_RX.test(id) || 1431 __FV_PHONE_HINT_RX.test(placeholder) || __FV_PHONE_VAL_RX.test(val)) { 1432 return 'phone'; 1433 } 1434 if (!__FV_USERNAME_RX.test(name) && !__FV_USERNAME_RX.test(id)) { 1435 if (ac === 'name' || ac === 'given-name' || ac === 'family-name' || 1436 __FV_NAME_HINT_RX.test(name) || __FV_NAME_HINT_RX.test(id) || 1437 __FV_NAME_HINT_RX.test(doi) || __FV_NAME_HINT_RX.test(placeholder)) { 1438 return 'name'; 1439 } 1440 } 1441 return 'other'; 1442 } 1443 1444 // Collect fillable, non-sensitive fields from a <form> or a formless group. 1445 // Name is preferred; id covers CMS/Astro widgets that omit name. 1446 function __fv_collectFields(container) { 1447 var raw; 1448 if (container.tagName === 'FORM' && container.elements) { 1449 raw = Array.prototype.slice.call(container.elements); 1450 } else { 1451 raw = Array.prototype.slice.call( 1452 container.querySelectorAll('input, textarea, select')); 1453 } 1454 return raw.filter(function (i) { return (i.name || i.id) && !shouldSkipElement(i); }); 1455 } 1456 1457 var __FV_SEARCH_NAMES = { q: 1, s: 1, search: 1, query: 1, keyword: 1 }; 1458 1459 function __fv_isSearchForm(form) { 1460 if (!form || form.tagName !== 'FORM') return false;
1461 if (String(form.method || 'get').toLowerCase() !== 'get') return false; 1462 try { 1463 var a = document.createElement('a'); 1464 a.href = form.action; 1465 if (a.origin !== location.origin) return false; 1466 } catch (_) { return false; } 1467 var fields = __fv_collectFields(form); 1468 var n = 0; 1469 for (var i = 0; i < fields.length; i++) { 1470 if (!__fv_isFillableField(fields[i])) continue; 1471 var key = String(fields[i].name || fields[i].id || '').toLowerCase(); 1472 if (!__FV_SEARCH_NAMES[key]) return false; 1473 n++; 1474 } 1475 return n > 0; 1476 } 1477 1478 function __fv_searchTerm(form) { 1479 var fields = __fv_collectFields(form); 1480 for (var i = 0; i < fields.length; i++) { 1481 if (!__fv_isFillableField(fields[i])) continue; 1482 var v = ''; 1483 try { v = __fv_fieldValue(fields[i]); } catch (_) {} 1484 v = (v == null ? '' : String(v)).trim(); 1485 if (v) return v.slice(0, 200); 1486 } 1487 return ''; 1488 } 1489 1490 function __fv_fieldBreadcrumbs(container) { 1491 var fields = __fv_collectFields(container); 1492 var last = null, filled = 0; 1493 for (var i = 0; i < fields.length; i++) { 1494 var el = fields[i]; 1495 if (!__fv_isFillableField(el)) continue; 1496 var v = ''; 1497 try { v = __fv_fieldValue(el); } catch (_) {} 1498 v = (v == null ? '' : String(v)).trim(); 1499 if (!v) continue; 1500 filled++; 1501 last = el.name || el.id || null; 1502 } 1503 return { last_field: last, filled_count: filled }; 1504 } 1505 1506 function __fv_emitStart(container, isForm, firstField) { 1507 if (!container || container.__fv_form_started || shouldSkipElement(container)) return; 1508 if (isForm && __fv_isSearchForm(container)) return; 1509 container.__fv_form_started = true; 1510 sendEvent('$form_start', { 1511 pageview_id: __fv_pageview_id, 1512 form_id: __fv_formIdOf(container, isForm), 1513 form_name: isForm ? (container.name || null) : null, 1514 form_action: isForm ? (container.action || null) : null, 1515 page_url: pageUrl(), 1516 first_field: firstField || null, 1517 }); 1518 } 1519 1520 // One submit path for both native submit and capture-phase click. `container` 1521 // is a <form> or a formless field-group. Deduped per-container within 1.5s. 1522 function captureFormSubmit(container, submitBtn) { 1523 if (!container || shouldSkipElement(container)) return; 1524 var isForm = container.tagName === 'FORM'; 1525 1526 if (isForm && __fv_isSearchForm(container)) { 1527 sendEvent('$search', { 1528 pageview_id: __fv_pageview_id, 1529 form_id: __fv_formIdOf(container, true), 1530 page_url: pageUrl(), 1531 search_term: __fv_searchTerm(container), 1532 }); 1533 return; 1534 } 1535 1536 var crumbs = __fv_fieldBreadcrumbs(container); 1537 container.__fv_last_field = crumbs.last_field; 1538 container.__fv_filled_count = crumbs.filled_count; 1539 if (!container.__fv_form_started) { 1540 __fv_emitStart(container, isForm, null); // first_field: null 1541 } 1542 1543 var fields = __fv_collectFields(container); 1544 if (!submitBtn && container.querySelector) { 1545 submitBtn = container.querySelector('[type=submit]'); 1546 } 1547 1548 var formEmail = '', formPhone = ''; 1549 var givenName = '', familyName = '', firstName = ''; 1550 var formData = {}, formDataCount = 0; 1551 1552 if (CAPTURE_FORM_VALUES) { 1553 for (var i = 0; i < fields.length; i++) { 1554 var el = fields[i]; 1555 if (!__fv_isFillableField(el)) continue; 1556 var val; 1557 try { val = __fv_fieldValue(el); } catch (_) { val = ''; } 1558 val = (val == null ? '' : String(val)).trim(); 1559 if (!val) continue; 1560 var cls = __fv_classify(el, val); 1561 if (cls === 'email') { 1562 if (!formEmail) formEmail = val.toLowerCase().slice(0, __FV_MAX_EMAIL); 1563 } else if (cls === 'phone') { 1564 if (!formPhone) formPhone = val.slice(0, __FV_MAX_PHONE); 1565 } else if (cls === 'name') { 1566 var ac = el.autocomplete || ''; 1567 if (ac === 'given-name') { if (!givenName) givenName = val; } 1568 else if (ac === 'family-name') { if (!familyName) familyName = val; } 1569 else if (!firstName) firstName = val; 1570 } else { 1571 if (formDataCount >= __FV_MAX_FORM_DATA_FIELDS) continue; 1572 var tag = el.tagName ? el.tagName.toLowerCase() : ''; 1573 var cap = (tag === 'textarea' || 1574 /message/i.test(__fv_attr(el, 'data-field_type'))) 1575 ? __FV_MAX_MESSAGE : __FV_MAX_VALUE; 1576 formData[el.name || el.id] = val.slice(0, cap); 1577 formDataCount++; 1578 } 1579 } 1580 } 1581 1582 var formName = (givenName || familyName) 1583 ? (givenName + (givenName && familyName ? ' ' : '') + familyName) 1584 : firstName; 1585 formName = formName.slice(0, __FV_MAX_NAME); 1586 1587 var modalEl = MODAL_AUTOCAPTURE ? __fv_acceptedModalAncestor(container) : null; 1588 var props = { 1589 pageview_id: __fv_pageview_id, 1590 form_id: __fv_formIdOf(container, isForm), 1591 form_name: isForm ? (container.name || null) : null, 1592 form_action: isForm ? (container.action || null) : null, 1593 form_method: isForm ? (container.method || null) : null, 1594 field_names: fields.map(function (i) { return i.name || i.id; }), 1595 field_count: fields.length, 1596 submit_text: submitBtn 1597 ? (submitBtn.textContent || submitBtn.value || '').trim().slice(0, 200) 1598 : null, 1599 page_url: pageUrl(), 1600 modal_id: modalEl ? __fv_modalId(modalEl) : null, 1601 modal_selector: modalEl ? __fv_computeSelector(modalEl) : null, 1602 }; 1603 if (CAPTURE_FORM_VALUES) { 1604 if (formEmail) props.form_email = formEmail; 1605 if (formPhone) props.form_phone = formPhone; 1606 if (formName) props.form_name_value = formName; 1607 if (formDataCount > 0) props.form_data = formData; 1608 } 1609 props.last_field = crumbs.last_field; 1610 props.filled_count = crumbs.filled_count; 1611 var __fv_now = Date.now(); 1612 for (var __fv_k in __fv_recent_submits) { 1613 if (__fv_now - __fv_recent_submits[__fv_k] > __FV_SUBMIT_WINDOW) delete __fv_recent_submits[__fv_k]; 1614 } 1615 var __fv_sig = __fv_submitSig(props); 1616 if (__fv_recent_submits[__fv_sig] && __fv_now - __fv_recent_submits[__fv_sig] < __FV_SUBMIT_WINDOW) return; 1617 __fv_recent_submits[__fv_sig] = __fv_now; 1618 1619 sendEvent('$form_submit', props); 1620 1621 if (CAPTURE_FORM_VALUES && formEmail && __FV_EMAIL_RX.test(formEmail)) { 1622 sendIdentify(formEmail, 'form_field'); 1623 } 1624 } 1625 1626 // $form_submit â native submit path delegates to the shared capture fn. 1627 document.addEventListener('submit', function (e) { 1628 var form = e.target; 1629 if (!form || form.tagName !== 'FORM') return; 1630 captureFormSubmit(form, null); 1631 if (form.querySelector && form.querySelector('input[type="password"]')) return; 1632 if (typeof __fv_isSearchForm === 'function' && __fv_isSearchForm(form)) return; 1633 setTimeout(function () { 1634 if (e.defaultPrevented) return; 1635 form.__fv_success_candidate = true; 1636 }, 0); // setTimeout(0) 1637 }, true); 1638
1639 // Capture-phase click on a submit control â covers AJAX forms (Divi, 1640 // CF7) that never fire a native submit. Resolves the form (or formless 1641 // group) and reads values BEFORE the framework clears/removes the form. 1642 // The content-signature dedupe in captureFormSubmit collapses the 1643 // native-submit + click double-fire on plain HTML forms. 1644 var __FV_SUBMIT_SELECTOR = 1645 'input[type=submit],button[type=submit],.et_pb_contact_submit,.wpcf7-submit'; 1646 document.addEventListener('click', function (e) { 1647 var t = e.target; 1648 if (!t || !t.closest) return; 1649 var ctrl = t.closest(__FV_SUBMIT_SELECTOR); 1650 if (!ctrl) { 1651 // Type-less <button> inside a form, or a role=button submit control. 1652 var btn = t.closest('button,[role=button]'); 1653 if (btn && btn.closest('form')) { 1654 var bt = btn.getAttribute('type'); 1655 if (!bt || /submit/i.test(bt)) ctrl = btn; 1656 } 1657 } 1658 if (!ctrl || shouldSkipElement(ctrl)) return; 1659 var form = ctrl.closest && ctrl.closest('form'); 1660 if (form) { 1661 if (typeof form.checkValidity === 'function' && !form.checkValidity()) return; 1662 captureFormSubmit(form, ctrl); 1663 return; 1664 } 1665 var group = __fv_findFieldGroup(ctrl); 1666 if (group) captureFormSubmit(group, ctrl); 1667 }, true); 1668 1669 // A field a user can type/pick into (excludes buttons/hidden). 1670 function __fv_isFillableField(el) { 1671 if (!el || !el.tagName) return false; 1672 var tg = el.tagName.toLowerCase(); 1673 return tg === 'select' || tg === 'textarea' || 1674 (tg === 'input' && !/^(submit|button|reset|hidden)$/i.test(el.type || '')); 1675 } 1676 1677 // Formless fallback: walk up from a focused field to the nearest ancestor 1678 // holding >=2 fillable fields, and treat that container as a logical form. 1679 // The >=2 rule keeps lone search/newsletter boxes from counting as forms. 1680 function __fv_findFieldGroup(el) { 1681 var node = el.parentElement, depth = 0; 1682 while (node && node !== document.body && depth < 8) { 1683 var fields = node.querySelectorAll('input, textarea, select'); 1684 var count = 0; 1685 for (var i = 0; i < fields.length; i++) { 1686 if (__fv_isFillableField(fields[i]) && ++count >= 2) return node; 1687 } 1688 node = node.parentElement; 1689 depth++; 1690 } 1691 return null; 1692 } 1693 1694 // $form_start â first non-empty fill of a fillable field. Fires once per form 1695 // (marker on the element); pairs with $form_submit to measure conversion/abandonment. 1696 // input+change (not focusin) so empty focus is not a start. When there is no 1697 // <form> wrapper (e.g. Bubble), fall back to the nearest >=2-field container, 1698 // keyed by its CSS selector as form_id. 1699 function __fv_onStartCandidate(e) { 1700 var t = e.target; 1701 if (!t || !t.tagName || !t.closest) return; 1702 if (!__fv_isFillableField(t) || shouldSkipElement(t)) return; 1703 var val = ''; 1704 try { val = __fv_fieldValue(t); } catch (_) {} 1705 if (!(val == null ? '' : String(val)).trim()) return; 1706 var firstField = t.name || t.id || null; 1707 var form = t.closest('form'); 1708 if (form) { __fv_emitStart(form, true, firstField); return; } 1709 var group = __fv_findFieldGroup(t); 1710 if (group) __fv_emitStart(group, false, firstField); 1711 } 1712 document.addEventListener('input', __fv_onStartCandidate, true); 1713 document.addEventListener('change', __fv_onStartCandidate, true); 1714 1715 var __fv_success_emitted = {}; 1716 var __fv_success_ignore = []; 1717 1718 function __fv_isShown(el) { 1719 if (!el) return false; 1720 try { 1721 var st = window.getComputedStyle(el); 1722 if (!st || st.display === 'none' || st.visibility === 'hidden') return false; 1723 if (parseFloat(st.opacity) === 0) return false; 1724 var r = el.getBoundingClientRect(); 1725 return r.width > 0 && r.height > 0; 1726 } catch (_) { return false; } 1727 } 1728 1729 function __fv_successFormIdFromNode(el) { 1730 var v = __fv_attr(el, 'data-fv-success'); 1731 if (v) return v.slice(0, 200); 1732 var form = el.closest && el.closest('form'); 1733 if (form) { 1734 var id = __fv_formIdOf(form, true); 1735 if (id) return id; 1736 } 1737 var wrap = el.closest && el.closest('[data-fv-form]'); 1738 if (wrap) { 1739 var ex = __fv_attr(wrap, 'data-fv-form'); 1740 if (ex) return ex.slice(0, 200); 1741 } 1742 return null; 1743 } 1744 1745 function __fv_emitFormSuccess(formId, source, extras) { 1746 if (typeof formId !== 'string') return; 1747 formId = formId.replace(/^\s+|\s+$/g, '').slice(0, 200); 1748 if (!formId) return; 1749 var key = String(__fv_pageview_id || '') + '\0' + formId; 1750 if (__fv_success_emitted[key]) return; 1751 __fv_success_emitted[key] = 1; 1752 extras = extras || {}; 1753 sendEvent('$form_success', { 1754 pageview_id: __fv_pageview_id, 1755 form_id: formId, 1756 form_name: extras.form_name || null, 1757 form_action: extras.form_action || null, 1758 page_url: pageUrl(), 1759 last_field: extras.last_field || null, 1760 filled_count: extras.filled_count || 0, 1761 source: source, 1762 }); 1763 } 1764 1765 window.fv = window.fv || {}; 1766 window.fv.formSuccess = function (formId) { 1767 if (typeof formId !== 'string') return; 1768 __fv_emitFormSuccess(formId, 'explicit', {}); 1769 }; 1770 1771 function __fv_considerSuccessNode(el) { 1772 if (!el || !el.getAttribute) return; 1773 if (!el.hasAttribute || !el.hasAttribute('data-fv-success')) return; 1774 if (__fv_success_ignore.indexOf(el) !== -1) return; 1775 if (!__fv_isShown(el)) return;
1776 var fid = __fv_successFormIdFromNode(el); 1777 if (!fid) return; 1778 __fv_emitFormSuccess(fid, 'explicit', {}); 1779 } 1780 1781 try { 1782 var existing = document.querySelectorAll('[data-fv-success]'); 1783 for (var i = 0; i < existing.length; i++) { 1784 if (__fv_isShown(existing[i])) __fv_success_ignore.push(existing[i]); 1785 } 1786 if (typeof MutationObserver === 'function' && document.documentElement) { 1787 var smo = new MutationObserver(function (muts) { 1788 for (var m = 0; m < muts.length; m++) { 1789 var mu = muts[m]; 1790 if (mu.type === 'attributes' && mu.target && mu.target.getAttribute) { 1791 __fv_considerSuccessNode(mu.target); 1792 } 1793 var nodes = mu.addedNodes || []; 1794 for (var n = 0; n < nodes.length; n++) { 1795 var node = nodes[n]; 1796 if (!node || node.nodeType !== 1) continue; 1797 if (node.hasAttribute && node.hasAttribute('data-fv-success')) { 1798 __fv_considerSuccessNode(node); 1799 } 1800 if (node.querySelectorAll) { 1801 var inner = node.querySelectorAll('[data-fv-success]'); 1802 for (var k = 0; k < inner.length; k++) __fv_considerSuccessNode(inner[k]); 1803 } 1804 } 1805 } 1806 }); 1807 smo.observe(document.documentElement, { 1808 subtree: true, 1809 childList: true, 1810 attributes: true, 1811 attributeFilter: ['data-fv-success', 'class', 'style', 'hidden'], 1812 }); 1813 } 1814 } catch (_) {} 1815 1816 document.addEventListener('reset', function (e) { 1817 var form = e.target; 1818 if (!form || !form.__fv_success_candidate) return; 1819 form.__fv_success_candidate = false; 1820 __fv_emitFormSuccess(__fv_formIdOf(form, true), 'reset', { 1821 form_name: form.name || null, 1822 form_action: form.action || null, 1823 last_field: form.__fv_last_field || null, 1824 filled_count: form.__fv_filled_count || 0, 1825 }); 1826 }, true); 1827 1828 window.addEventListener('pagehide', function () { 1829 var forms = document.getElementsByTagName('form'); 1830 for (var i = 0; i < forms.length; i++) { 1831 var f = forms[i]; 1832 if (!f.__fv_success_candidate) continue; 1833 f.__fv_success_candidate = false; 1834 __fv_emitFormSuccess(__fv_formIdOf(f, true), 'unload', { 1835 form_name: f.name || null, 1836 form_action: f.action || null, 1837 last_field: f.__fv_last_field || null, 1838 filled_count: f.__fv_filled_count || 0, 1839 }); 1840 } 1841 }); 1842 1843 // $exception â window.error + unhandledrejection. Truncate message/stack 1844 // so we don't ship megabyte payloads from runaway frameworks. 1845 // 1846 // Drop errors thrown by the visitor's own browser extensions â they're the 1847 // extension's bug, not ours, and were ~65% of all $exception volume. We test 1848 // the originating (top) stack frame, so genuine app errors that merely pass 1849 // through an extension-patched API deeper in the stack are still kept. 1850 var __FV_EXT_URL = /(?:chrome|moz|ms-browser|safari(?:-web)?)-extension:\/\//; 1851 function __fv_isExtensionError(stack, filename) { 1852 if (filename && __FV_EXT_URL.test(filename)) return true; 1853 if (!stack) return false; 1854 var lines = stack.split('\n'); 1855 for (var i = 0; i < lines.length; i++) { 1856 if (/^\s*at\b/.test(lines[i])) return __FV_EXT_URL.test(lines[i]); // first frame only 1857 } 1858 return false; 1859 } 1860 1861 window.addEventListener('error', function (ev) { 1862 try { 1863 var msg = (ev.message || (ev.error && ev.error.message) || '').toString().slice(0, 500); 1864 var stack = (ev.error && ev.error.stack ? ev.error.stack.toString() : '').slice(0, 2000); 1865 if (__fv_isExtensionError(stack, ev.filename)) return; 1866 sendEvent('$exception', { 1867 pageview_id: __fv_pageview_id, 1868 message: msg, 1869 stack: stack, 1870 filename: ev.filename || null, 1871 lineno: typeof ev.lineno === 'number' ? ev.lineno : null, 1872 colno: typeof ev.colno === 'number' ? ev.colno : null, 1873 page_url: pageUrl(), 1874 }); 1875 } catch (_) {} 1876 }); 1877 1878 window.addEventListener('unhandledrejection', function (ev) {
1879 try { 1880 var reason = ev.reason; 1881 var msg = ''; 1882 var stack = ''; 1883 if (reason) { 1884 msg = (reason.message || (typeof reason === 'string' ? reason : '') || '').toString().slice(0, 500); 1885 stack = (reason.stack ? reason.stack.toString() : '').slice(0, 2000); 1886 } 1887 if (__fv_isExtensionError(stack, null)) return; 1888 sendEvent('$exception', { 1889 pageview_id: __fv_pageview_id, 1890 message: msg, 1891 stack: stack, 1892 filename: null, 1893 lineno: null, 1894 colno: null, 1895 page_url: pageUrl(), 1896 }); 1897 } catch (_) {} 1898 }); 1899 1900 // âââ T26: modal/popup autocapture (docs/specs/2026-06-10-modal-autocapture-design.md) âââ 1901 // Global kill-switch. Flip to false + redeploy ingest to disable modal 1902 // autocapture everywhere (also nulls modal stamping on $click/$form_submit). 1903 var MODAL_AUTOCAPTURE = true; 1904 1905 // ARIA-strict: only elements that self-declare as dialogs are candidates. 1906 // Sites whose popups carry no dialog ARIA get no modal events â no noise 1907 // over coverage. 1908 var __FV_MODAL_CANDIDATE_SEL = 'dialog, [role=dialog], [aria-modal="true"]'; 1909 // Cookie banners, toasts, dropdowns â dialog-ish roles we never count. 1910 var __FV_MODAL_ROLE_DENY_RX = /^(alertdialog|alert|status|tooltip|menu|listbox)$/i; 1911 1912 function __fv_isAcceptedModal(el) { 1913 if (!el || !el.matches) return false; 1914 try { if (!el.matches(__FV_MODAL_CANDIDATE_SEL)) return false; } catch (_) { return false; } 1915 var role = el.getAttribute && el.getAttribute('role'); 1916 if (role && __FV_MODAL_ROLE_DENY_RX.test(role)) return false; 1917 if (shouldSkipElement(el)) return false; 1918 return true; 1919 } 1920 1921 // Shared accept helper â the observer and the $click/$form_submit stamping 1922 // both resolve modals through this, so detection and stamping can never 1923 // disagree on what counts as a modal. Visibility is deliberately NOT 1924 // checked here: stamping callers just interacted with the element, so it 1925 // is visible by definition; the observer adds its own visibility check. 1926 function __fv_acceptedModalAncestor(el) { 1927 var node = el; 1928 while (node && node.tagName) { 1929 if (__fv_isAcceptedModal(node)) return node; 1930 node = node.parentElement; 1931 } 1932 return null; 1933 } 1934 1935 function __fv_modalVisible(el) { 1936 if (!el.isConnected) return false; 1937 var cs;
1938 try { cs = getComputedStyle(el); } catch (_) { return false; } 1939 if (cs.display === 'none' || cs.visibility === 'hidden') return false; 1940 if (!(parseFloat(cs.opacity) > 0.1)) return false; 1941 var r = el.getBoundingClientRect(); 1942 return r.width > 0 && r.height > 0; 1943 } 1944 1945 function __fv_modalRole(el) { 1946 if (el.tagName && el.tagName.toLowerCase() === 'dialog') return 'native-dialog'; 1947 if (el.getAttribute && el.getAttribute('role') === 'dialog') return 'dialog'; 1948 return 'aria-modal'; 1949 } 1950 1951 function __fv_modalId(el) { 1952 return el.id || __fv_computeSelector(el); 1953 } 1954 1955 // Registry of every candidate we have ever evaluated â needed to re-check 1956 // tracked modals when an ANCESTOR mutates (wrapper-driven hides) or the 1957 // element leaves the DOM. 1958 var __fv_modal_registry = []; 1959 1960 // Per-pageview impression cap (same spirit as the 200-click cap). The 1961 // counter lazily resets when __fv_pageview_id rotates (SPA route change). 1962 var __fv_modal_shown_count = 0; 1963 var __fv_modal_count_pv = null; 1964 function __fv_modalCapOk() { 1965 if (__fv_modal_count_pv !== __fv_pageview_id) { 1966 __fv_modal_count_pv = __fv_pageview_id; 1967 __fv_modal_shown_count = 0; 1968 } 1969 return __fv_modal_shown_count < 20; 1970 } 1971 1972 function __fv_fireModalShown(el) { 1973 var now = Date.now(); 1974 // pageview_id/page_url are snapshotted here and reused by the matching 1975 // $modal_hidden, so a shown/hidden pair always attributes to the same 1976 // pageview even across an SPA route change. 1977 el.__fv_modal_pageview_id = __fv_pageview_id; 1978 el.__fv_modal_page_url = pageUrl(); 1979 el.__fv_modal_shown_at = now; 1980 el.__fv_modal_interacted = false; // re-opens don't inherit prior interaction 1981 el.__fv_modal_last_fire_at = now; 1982 el.__fv_modal_shown_fired = true; 1983 __fv_modal_shown_count++; 1984 sendEvent('$modal_shown', { 1985 pageview_id: el.__fv_modal_pageview_id, 1986 modal_id: __fv_modalId(el), 1987 modal_selector: __fv_computeSelector(el), 1988 modal_role: __fv_modalRole(el), 1989 text_snippet: (el.textContent || '').trim().slice(0, 120), 1990 data_attrs: __fv_readDataAttrs(el), 1991 page_url: el.__fv_modal_page_url, 1992 }); 1993 } 1994 1995 function __fv_fireModalHidden(el) { 1996 sendEvent('$modal_hidden', { 1997 pageview_id: el.__fv_modal_pageview_id, 1998 modal_id: __fv_modalId(el), 1999 modal_selector: __fv_computeSelector(el), 2000 modal_role: __fv_modalRole(el), 2001 visible_ms: Date.now() - el.__fv_modal_shown_at, 2002 interacted: !!el.__fv_modal_interacted, 2003 page_url: el.__fv_modal_page_url, 2004 }); 2005 } 2006 2007 // Transition-only state machine. Steady states are no-ops. $modal_hidden 2008 // only fires when the current visible period actually emitted a 2009 // $modal_shown (__fv_modal_shown_fired) â pairs stay paired even when the 2010 // cap or the refire guard suppressed the shown. 2011 function __fv_checkModal(el) { 2012 if (!__fv_isAcceptedModal(el)) return; 2013 if (!el.__fv_modal_tracked) { 2014 el.__fv_modal_tracked = true; 2015 __fv_modal_registry.push(el); 2016 } 2017 var vis = __fv_modalVisible(el); 2018 var was = !!el.__fv_modal_visible; 2019 if (vis === was) return; 2020 el.__fv_modal_visible = vis; 2021 if (vis) { 2022 var now = Date.now(); 2023 // 1s per-element refire guard absorbs animation flapping. 2024 if (el.__fv_modal_last_fire_at && now - el.__fv_modal_last_fire_at < 1000) { 2025 el.__fv_modal_shown_fired = false; 2026 return; 2027 } 2028 if (!__fv_modalCapOk()) { 2029 el.__fv_modal_shown_fired = false; 2030 return; 2031 } 2032 __fv_fireModalShown(el); 2033 } else if (el.__fv_modal_shown_fired) { 2034 el.__fv_modal_shown_fired = false; 2035 __fv_fireModalHidden(el); 2036 } 2037 } 2038 2039 // Mutation batches are coalesced via a ~100ms debounce timer, then turned 2040 // into a unique candidate set: attribute targets + their candidate 2041 // descendants, childList added/removed nodes + their candidate
2042 // descendants, and already-tracked modals whose ancestor chain contains a 2043 // mutated target (wrapper-driven hides) or that left the DOM. 2044 var __fv_modal_mutation_queue = []; 2045 var __fv_modal_debounce = null; 2046 2047 function __fv_processModalMutations() { 2048 __fv_modal_debounce = null; 2049 var batch = __fv_modal_mutation_queue; 2050 __fv_modal_mutation_queue = []; 2051 var seen = []; 2052 function add(el) { 2053 if (!el || el.nodeType !== 1) return; 2054 if (seen.indexOf(el) === -1) seen.push(el); 2055 } 2056 function addWithDescendants(el) { 2057 if (!el || el.nodeType !== 1) return; 2058 try { 2059 if (el.matches && el.matches(__FV_MODAL_CANDIDATE_SEL)) add(el); 2060 if (el.querySelectorAll) { 2061 var ds = el.querySelectorAll(__FV_MODAL_CANDIDATE_SEL); 2062 for (var i = 0; i < ds.length; i++) add(ds[i]); 2063 } 2064 } catch (_) {} 2065 } 2066 var mutatedTargets = []; 2067 for (var b = 0; b < batch.length; b++) { 2068 var m = batch[b]; 2069 if (m.target && mutatedTargets.indexOf(m.target) === -1) mutatedTargets.push(m.target); 2070 if (m.type === 'attributes') { 2071 addWithDescendants(m.target); 2072 } else if (m.type === 'childList') { 2073 for (var a = 0; a < m.addedNodes.length; a++) addWithDescendants(m.addedNodes[a]); 2074 for (var rm = 0; rm < m.removedNodes.length; rm++) addWithDescendants(m.removedNodes[rm]); 2075 } 2076 } 2077 for (var r = 0; r < __fv_modal_registry.length; r++) { 2078 var el = __fv_modal_registry[r]; 2079 if (seen.indexOf(el) !== -1) continue; 2080 if (!el.isConnected) { add(el); continue; } // removal â hidden transition 2081 for (var t = 0; t < mutatedTargets.length; t++) { 2082 var mt = mutatedTargets[t]; 2083 if (mt && mt !== el && mt.contains && mt.contains(el)) { add(el); break; } 2084 } 2085 } 2086 for (var c = 0; c < seen.length; c++) { 2087 try { __fv_checkModal(seen[c]); } catch (_) {} 2088 } 2089 } 2090 2091 function __fv_attachModalObserver() { 2092 if (typeof MutationObserver !== 'function' || !document.body) return; 2093 var mo = new MutationObserver(function (muts) { 2094 for (var i = 0; i < muts.length; i++) __fv_modal_mutation_queue.push(muts[i]); 2095 if (!__fv_modal_debounce) { 2096 __fv_modal_debounce = setTimeout(__fv_processModalMutations, 100); 2097 } 2098 }); 2099 mo.observe(document.body, { 2100 childList: true, 2101 subtree: true, 2102 attributes: true, 2103 attributeFilter: ['open', 'aria-hidden', 'style', 'class', 'hidden'], 2104 }); 2105 // Init scan â fire $modal_shown for modals already visible at attach. 2106 var initial = document.body.querySelectorAll(__FV_MODAL_CANDIDATE_SEL); 2107 for (var i = 0; i < initial.length; i++) { 2108 try { __fv_checkModal(initial[i]); } catch (_) {} 2109 } 2110 } 2111 2112 if (MODAL_AUTOCAPTURE) { 2113 // Body-ready guard: the tracker can execute from <head> before <body> 2114 // exists â defer attach + init scan to DOMContentLoaded in that case. 2115 if (document.body) __fv_attachModalObserver(); 2116 else document.addEventListener('DOMContentLoaded', __fv_attachModalObserver); 2117 } 2118 })();
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.