1<!DOCTYPE html><html style="" data-scribe-recorder-ready="true"><head><meta name="description" content="View this page published on 4responsible.ai"> 2<meta property="og:title" content="Site Index â 4Responsible.AI"> 3<meta property="og:description" content="View this page published on 4responsible.ai"> 4<meta property="og:image" content="https://htmlpub.com/api/og/screenshot?id=rm90klif4ys3o04jw9zgjfpw&v=1790529748919"> 5<meta property="og:image:width" content="1200"> 6<meta property="og:image:height" content="630"> 7<meta property="og:url" content="https://4responsible.ai/site-index"> 8<meta name="twitter:url" content="https://4responsible.ai/site-index"> 9<meta property="og:type" content="website"> 10<meta property="og:site_name" content="4responsible.ai"> 11<meta property="og:locale" content="en_US"> 12<meta property="fb:app_id" content="4198265810445529"> 13<meta name="twitter:card" content="summary_large_image"> 14<meta name="twitter:title" content="Site Index â 4Responsible.AI"> 15<meta name="twitter:description" content="View this page published on 4responsible.ai"> 16<meta name="twitter:image" content="https://htmlpub.com/api/og/screenshot?id=rm90klif4ys3o04jw9zgjfpw&v=1790529748919"> 17<meta name="twitter:image:width" content="1200"> 18<meta name="twitter:image:height" content="630"> 19<meta name="robots" content="index, follow"> 20<link rel="canonical" href="https://4responsible.ai/site-index"> 21<link rel="icon" href="https://4responsible.ai/api/library/at2io7gzpvjczuan26x0xyak"> 22<title>Site Index â 4Responsible.AI</title> 23<meta charset="utf-8"> 24<meta name="viewport" content="width=device-width, initial-scale=1"> 25
26<script type="text/javascript" src="https://analytics.lead.eyes/analytics.js" id="leadpages-analytics" data-leadpages-analytics="true" defer="defer"></script>
26 27<style> 28:root{--panel:#07131e;--line:#17384b;--text:#eef7ff;--muted:#9bb1bd;--cyan:#23d9ff;--bg:#03070d} 29body{margin:0;background:var(--bg);color:var(--text);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;-webkit-font-smoothing:antialiased} 30.index{max-width:900px;margin:0 auto;padding:70px 24px 40px} 31.index h1{font-size:clamp(2rem,4.5vw,3.2rem);font-weight:800;letter-spacing:-.01em;margin:0 0 10px;color:var(--text)} 32.index h1 b{color:var(--cyan)} 33.index p.sub{font-size:.95rem;color:var(--muted);margin:0 0 42px;line-height:1.5;max-width:560px} 34.map{list-style:none;margin:0;padding:0;counter-reset:page} 35.map li{border-bottom:1px solid var(--line)} 36.map a{display:flex;align-items:baseline;gap:20px;padding:20px 8px;text-decoration:none;color:var(--text);transition:background .15s} 37.map a:hover{background:rgba(35,217,255,.05)} 38.map .no{font-size:.78rem;color:var(--muted);font-weight:700;letter-spacing:.08em;white-space:nowrap} 39.map .name{font-size:1.15rem;font-weight:800;color:var(--text);transition:color .15s;white-space:nowrap} 40.map a:hover .name{color:var(--cyan)} 41.map .desc{font-size:.82rem;color:var(--muted);font-weight:400} 42@media(max-width:640px){.map .desc{display:none}} 43</style> 44<style id="htmlpub-breakpoint-visibility">@media (max-width: 767px) { .hp-hide-mobile { display: none !important; } } 45@media (min-width: 768px) and (max-width: 1199px) { .hp-hide-tablet { display: none !important; } } 46@media (min-width: 1200px) { .hp-hide-desktop { display: none !important; } }</style></head> 47<body><a href="https://4responsible.ai/cdn-cgi/content?id=Qj.fA0HZKgYXjB6X6RubkUjXu5yHnSIqOrAEjq6nrfY-1791213108.6624029-1.2.1.1-FIBppySlutbPL.v13in4u9UuGUh_Dn4pzm_ZsbwgTG7W7lygedvDf0091.3LHQAi" aria-hidden="true" rel="nofollow noopener" style="display: none !important; visibility: hidden !important"></a> 48<header data-persist-part-id="4r-site-header" data-persist-part-kind="header" data-persist-nav-id="nav-cmbwk7s" style=""><style data-persist-part-style-for="4r-site-header">[data-persist-part-id="4r-site-header"]:is(*),[data-persist-part-id="4r-site-header"] *{box-sizing:border-box}[data-persist-part-id="4r-site-header"]:is(a),[data-persist-part-id="4r-site-header"] a{color:var(--cyan)}[data-persist-part-id="4r-site-header"]:is(header),[data-persist-part-id="4r-site-header"] header{display:grid;grid-template-columns:1fr auto;gap:24px;align-items:end;padding:18px 0 26px;border-bottom:1px solid var(--line)}@media (max-width:850px){[data-persist-part-id="4r-site-header"]:is(header),[data-persist-part-id="4r-site-header"] header{grid-template-columns:1fr}}[data-persist-part-id="4r-site-header"]:is(.sp-esvf8-site-nav),[data-persist-part-id="4r-site-header"] .sp-esvf8-site-nav{position:sticky;top:0;z-index:100;background:rgba(3,7,13,.88);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}[data-persist-part-id="4r-site-header"]:is(.sp-esvf8-nav-bar),[data-persist-part-id="4r-site-header"] .sp-esvf8-nav-bar{max-width:1280px;margin:auto;padding:14px 24px;display:flex;align-items:center;justify-content:space-between;gap:24px}[data-persist-part-id="4r-site-header"]:is(.sp-esvf8-nav-logo),[data-persist-part-id="4r-site-header"] .sp-esvf8-nav-logo{font-family:ui-monospace,SFMono-Regular,monospace;font-weight:800;font-size:1.05rem;color:var(--text);text-decoration:none;letter-spacing:.02em;white-space:nowrap}[data-persist-part-id="4r-site-header"]:is(.sp-esvf8-nav-logo b),[data-persist-part-id="4r-site-header"] .sp-esvf8-nav-logo b{color:var(--cyan)}[data-persist-part-id="4r-site-header"]:is(.sp-esvf8-nav-links),[data-persist-part-id="4r-site-header"] .sp-esvf8-nav-links{display:flex;gap:22px;list-style:none;margin:0;padding:0}[data-persist-part-id="4r-site-header"]:is(.sp-esvf8-nav-links a),[data-persist-part-id="4r-site-header"] .sp-esvf8-nav-links a{color:var(--muted);text-decoration:none;font-size:.83rem;font-weight:600;letter-spacing:.01em;transition:color .15s;white-space:nowrap}[data-persist-part-id="4r-site-header"]:is(.sp-esvf8-nav-links a:hover),[data-persist-part-id="4r-site-header"] .sp-esvf8-nav-links a:hover{color:var(--cyan)}[data-persist-part-id="4r-site-header"]:is(.sp-esvf8-nav-toggle),[data-persist-part-id="4r-site-header"] .sp-esvf8-nav-toggle{display:none;background:none;border:1px solid var(--line);border-radius:8px;color:var(--text);padding:8px;
48cursor:pointer;align-items:center;justify-content:center}[data-persist-part-id="4r-site-header"]:is(.sp-esvf8-nav-toggle:hover),[data-persist-part-id="4r-site-header"] .sp-esvf8-nav-toggle:hover{border-color:#3289a4;color:var(--cyan)}[data-persist-part-id="4r-site-header"]:is(.sp-esvf8-nav-mobile-panel a),[data-persist-part-id="4r-site-header"] .sp-esvf8-nav-mobile-panel a{color:var(--text);text-decoration:none;font-size:.95rem;font-weight:700;padding:12px 24px}[data-persist-part-id="4r-site-header"]:is(.sp-esvf8-nav-mobile-panel a:hover),[data-persist-part-id="4r-site-header"] .sp-esvf8-nav-mobile-panel a:hover{color:var(--cyan)}@media (max-width:900px){[data-persist-part-id="4r-site-header"]:is(.sp-esvf8-nav-links),[data-persist-part-id="4r-site-header"] .sp-esvf8-nav-links{display:none}[data-persist-part-id="4r-site-header"]:is(.sp-esvf8-nav-toggle),[data-persist-part-id="4r-site-header"] .sp-esvf8-nav-toggle{display:flex}}[data-persist-part-id="4r-site-header"]:is([data-persist-nav-id] li),[data-persist-part-id="4r-site-header"] [data-persist-nav-id] li,[data-persist-part-id="4r-site-header"][data-persist-nav-id] li{list-style:none}[data-persist-part-id="4r-site-header"]:is([data-persist-nav-mobile-panel]),[data-persist-part-id="4r-site-header"] [data-persist-nav-mobile-panel],[data-persist-part-id="4r-site-header"]:is([data-persist-nav-tablet-panel]),[data-persist-part-id="4r-site-header"] [data-persist-nav-tablet-panel]{display:none}[data-persist-part-id="4r-site-header"]:is([data-persist-nav-mobile-panel].is-open),[data-persist-part-id="4r-site-header"] [data-persist-nav-mobile-panel].is-open,[data-persist-part-id="4r-site-header"]:is([data-persist-nav-tablet-panel].is-open),[data-persist-part-id="4r-site-header"] [data-persist-nav-tablet-panel].is-open{display:flex}[data-persist-part-id="4r-site-header"]:is([data-persist-nav-mobile-panel][data-persist-nav-mobile-panel-position="top"]),[data-persist-part-id="4r-site-header"] [data-persist-nav-mobile-panel][data-persist-nav-mobile-panel-position="top"],[data-persist-part-id="4r-site-header"]:is([data-persist-nav-tablet-panel][data-persist-nav-tablet-panel-position="top"]),[data-persist-part-id="4r-site-header"] [data-persist-nav-tablet-panel][data-persist-nav-tablet-panel-position="top"]{position:absolute;top:100%;left:0;right:0;flex-direction:column;gap:8px;padding:16px;background:#fff;box-shadow:0 8px 24px rgba(0,0,0,0.15);border-radius:0 0 8px 8px;z-index:1000;max-height:70vh;overflow-y:auto}[data-persist-part-id="4r-site-header"]:is([data-persist-nav-mobile-panel] a),[data-persist-part-id="4r-site-header"] [data-persist-nav-mobile-panel] a,[data-persist-part-id="4r-site-header"]:is([data-persist-nav-tablet-panel] a),[data-persist-part-id="4r-site-header"] [data-persist-nav-tablet-panel] a{display:block;padding:8px 4px;text-decoration:none;color:inherit}[data-persist-part-id="4r-site-header"]{--line:#17384b;--text:#eef7ff;--muted:#9bb1bd;--cyan:#23d9ff}[data-persist-part-id="4r-site-header"]:is(p),[data-persist-part-id="4r-site-header"] p,[data-persist-part-id="4r-site-header"]:is(div),[data-persist-part-id="4r-site-header"] div{color:rgba(238,247,255,.85)}</style> 49 <nav class="sp-esvf8-site-nav" data-persist-nav-id="4r-main-nav"> 50 <div class="sp-esvf8-nav-bar" style=""> 51 <a href="/4responsible-ai" class="sp-esvf8-nav-logo" data-persist-nav-logo="" style="color: rgb(3, 7, 13);"><br><br></a> 52 <a href="/site-index" class="sp-esvf8-nav-links" style="display: inline-flex; gap: 10px; align-items: center; padding: 9px 18px; border: 1px solid var(--cyan); border-radius: 99px; text-decoration: none; font-size: 0.83rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;" target="_blank" rel="noopener noreferrer"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg><span>Site Index</span></a> 53 54 </div> 55 56 </nav> 57 </header> 58<section class="index" style=""> 59<h1>Site <b>Index</b></h1> 60<p class="sub">A map of every node in the registry â the complete 4Responsible.AI standard, from first principles to sources.</p> 61<ul class="map"> 62<li><a href="/4responsible-ai" style=""><span class="no">01</span><span class="name">Home</span><span class="desc" style="">Coevolutionary workforce system overview</span></a></li> 63<li><a href="/getting-started" style=""><span class="no">02</span><span class="name" style="">Getting Started</span><span class="desc" style="">
63How to begin applying the 4R framework</span></a></li> 64<li><a href="/ai-grounding" style=""><span class="no" style="">03</span><span class="name" style="">AI Grounding</span><span class="desc" style="">Human grounding before consequential AI implementation and delegation</span></a></li> 65<li><a href="/ai-skill-taxonomy" style=""><span class="no">04</span><span class="name" style="">AI Skills Taxonomy</span><span class="desc" style="">Skills for responsible AI work, mapped to the 4R principles</span></a></li> 66<li><a href="/four-governing-principles" style=""><span class="no">05</span><span class="name">Four Principles</span><span class="desc">The governing principles of responsible adoption</span></a></li> 67<li><a href="/the-standard" style=""><span class="no">06</span><span class="name">The Standard</span><span class="desc">The full normative specification</span></a></li> 68<li><a href="/maturity-model" style=""><span class="no">07</span><span class="name" style="">Maturity Model</span><span class="desc" style="">4R maturity â responsibility assessed in progression</span></a></li> 69<li><a href="/adoption-levels" style=""><span class="no">08</span><span class="name" style="">Adoption Levels</span><span class="desc" style="">Levels of AI adoption in the workforce</span></a></li> 70<li><a href="/human-coding" style=""><span class="no" style="">09</span><span class="name" style="">Human-Coding</span><span class="desc" style="">Coding human agency into AI systems</span></a></li> 71<li style=""><a href="/sources" style=""><span class="no">10</span><span class="name">Sources</span><span class="desc" style="">External human agency sources and references</span></a></li> 72<li><a href="/carlton-l-robinson" style=""><span class="no">11</span><span class="name">Architect</span><span class="desc" style="">DocCLR â Carlton L. Robinson, DBA</span></a></li> 73<li><a href="/agi" style=""><span class="no">12</span><span class="name">AGI</span><span class="desc" style="">AGI concerns mapped to the 4Responsible.AI contribution</span></a></li> 74<li><a href="/human-coding-agi-rsi" style=""><span class="no" style="">13</span><span class="name" style="">Human Coding vs AGI vs RSI</span><span class="desc" style="">20-dimension comparison across the three capability regimes</span></a></li> 75</ul> 76</section> 77<footer data-persist-part-id="4r-site-footer" data-persist-part-kind="footer"><style data-persist-part-style-for="4r-site-footer">[data-persist-part-id="4r-site-footer"]:is(*),[data-persist-part-id="4r-site-footer"] *{box-sizing:border-box}[data-persist-part-id="4r-site-footer"]:is(.sp-4r-footer){background:var(--panel);border-top:1px solid var(--line);padding:48px 24px 32px;margin-top:64px;font-family:"Times New Roman",serif}[data-persist-part-id="4r-site-footer"]:is(.sp-4r-footer-inner){max-width:720px;margin:0 auto;text-align:center}[data-persist-part-id="4r-site-footer"]:is(.sp-4r-footer-text){font-size:1rem;color:var(--text);margin:0 0 12px;line-height:1.5}[data-persist-part-id="4r-site-footer"]:is(.sp-4r-footer-architect){font-size:.95rem;color:var(--text);margin:0 0 16px}[data-persist-part-id="4r-site-footer"]:is(.sp-4r-footer-spec){font-size:.85rem;color:var(--muted);margin:0}[data-persist-part-id="4r-site-footer"]:is(.sp-4r-footer-spec a){color:var(--cyan);text-decoration:none}[data-persist-part-id="4r-site-footer"]:is(.sp-4r-footer-spec a:hover){text-decoration:underline}[data-persist-part-id="4r-site-footer"]:is(a),[data-persist-part-id="4r-site-footer"] a{color:var(--cyan)}[data-persist-part-id="4r-site-footer"]{--cyan:#23d9ff}[data-persist-part-id="4r-site-footer"]:is(p),[data-persist-part-id="4r-site-footer"] p,[data-persist-part-id="4r-site-footer"]:is(div),[data-persist-part-id="4r-site-footer"] div{color:rgba(238,247,255,.85)}[data-persist-part-id="4r-site-footer"]:is(p),[data-persist-part-id="4r-site-footer"] p{margin:.4rem 0}</style><div class="sp-4r-footer"><div class="sp-4r-footer-inner"><p class="sp-4r-footer-text" style="">This concept forms part of the 4Responsible.AI Human Agency Architecture for Responsible AI Workforce Adoption.</p><p class="sp-4r-footer-architect" style="">
77Architect: Carlton L. Robinson, DBA.</p><p class="sp-4r-footer-spec" style="">Open specification and version history: <a href="https://github.com/docclr/4ResponsibleAI-4responsible-ai-standard/tree/main" target="_blank" rel="noopener">GitHub</a> · Version: 1.0</p></div></div></footer> 78
79<script data-htmlpub-analytics> 80(function(){ 81var PID="rm90klif4ys3o04jw9zgjfpw"; 82var EXP_ID=""; 83var EXP_VARIANT=""; 84var API="/collect"; 85var APP_ORIGIN="https://htmlpub.com"; 86var FLUSH_INTERVAL=5000; 87var FLUSH_MAX=20; 88 89// HP-4568 consent gate. Runs onGranted() at most once, only when analytics 90// storage is permitted. See app/src/lib/analytics-consent-gate.ts. 91function hpConsent(onGranted){ 92var started=false; 93function start(){if(started)return;started=true;try{onGranted();}catch(e){}} 94 95// Browser-level objection (Global Privacy Control / legacy Do-Not-Track) is 96// terminal: it is the visitor speaking directly, so no later CMP "accept all" 97// re-enables tracking for this page view. Mirrors the Customers.ai pixel gate. 98try{ 99if(navigator.globalPrivacyControl===true)return; 100var dnt=navigator.doNotTrack||window.doNotTrack||navigator.msDoNotTrack; 101if(dnt==="1"||dnt===1||dnt===true)return; 102}catch(e){} 103 104// Analytics/statistics consent per whichever CMP the page carries: 105// 1 granted / 0 denied / -1 present but undecided / null no CMP detected 106function hpConsentState(){ 107// Google consent mode v2 first: vendor-neutral, so a CMP writing it (directly 108// or via GTM) is authoritative even before its own JS API initialises. Last 109// write wins, matching gtag's 'default' then 'update'. 110try{ 111var dl=window.dataLayer; 112if(dl&&dl.length){ 113var seen=null; 114for(var i=0;i<dl.length;i++){ 115var entry=dl[i]; 116if(!entry||entry[0]!=="consent"||!entry[2])continue; 117var mode=entry[2].analytics_storage; 118if(mode==="granted")seen=1;else if(mode==="denied")seen=0; 119} 120if(seen!==null)return seen; 121} 122}catch(e){} 123// Cookiebot. Its object appears only once uc.js has run, so fall back to the 124// script tag it is installed as to tell "undecided" from "absent". 125try{ 126var cb=window.Cookiebot||window.CookieConsent; 127if(cb&&cb.consent)return cb.consent.statistics?1:(cb.hasResponse?0:-1); 128if(document.getElementById("Cookiebot")||document.querySelector("script[src*='consent.cookiebot.com'],script[src*='consentcdn.cookiebot.com']"))return -1; 129}catch(e){} 130// OneTrust: statistics is the reserved C0002 group. OneTrust comma-pads the 131// list itself, so the delimited match cannot hit C00020. Every runtime signal 132// it exposes appears only after otSDKStub.js runs, so without the script-tag 133// fallback a blocked or async stub reads as "no CMP" and we would track â the 134// exact bug, on the pages most likely to care. 135try{ 136var groups=window.OptanonActiveGroups||window.OnetrustActiveGroups; 137if(typeof groups==="string"&&groups)return groups.indexOf(",C0002,")>-1?1:0; 138if(window.OneTrust||window.Optanon||document.getElementById("onetrust-consent-sdk")||document.querySelector("script[src*='cookielaw.org'],script[src*='onetrust.com']"))return -1; 139}catch(e){} 140// CookieYes 141try{ 142if(typeof window.getCkyConsent==="function"){ 143var cky=window.getCkyConsent(); 144if(cky&&cky.categories)return cky.categories.analytics?1:0; 145} 146if(document.querySelector("script[src*='cookieyes.com'],script[src*='cookie-script.com']"))return -1; 147}catch(e){} 148// Iubenda: purpose 4 is measurement. 149try{ 150var iub=window._iub; 151if(iub&&iub.cs&&iub.cs.consent&&iub.cs.consent.purposes)return iub.cs.consent.purposes[4]?1:0;
152if(iub||document.querySelector("script[src*='iubenda.com']"))return -1; 153}catch(e){} 154return null; 155} 156 157var state=hpConsentState(); 158if(state===null||state===1){start();return;} 159 160// CMP present, analytics not granted: write nothing, send nothing, and listen 161// for a later grant. The poll is a catch-all for CMPs whose event names we do 162// not know; it stops after 60s rather than running forever. 163function recheck(){if(hpConsentState()===1)start();} 164var EVENTS=["CookiebotOnAccept","CookiebotOnConsentReady","CookieConsentDeclaration","cookieyes-consent-update","consent.onetrust","OneTrustGroupsUpdated","iubenda-consent-given","hp-analytics-consent"]; 165for(var evt=0;evt<EVENTS.length;evt++){try{window.addEventListener(EVENTS[evt],recheck,false);}catch(e){}} 166var polls=0; 167var timer=setInterval(function(){recheck();if(started||++polls>=120)clearInterval(timer);},500); 168} 169 170// Everything below mints an identifier or sends a beacon, so all of it waits 171// for consent. window.__hpupPushEvent is the exception: it binds synchronously 172// here and only forwards once consent arrives. Binding it late would let the 173// popup embed bundle's fallback shim (app/src/lib/popups/embed-bundle.ts, 174// registered under the same "if not already defined" test) claim the name first 175// and reroute pop-up events. Pre-consent calls are dropped, not queued â they 176// have no visitorId. SYNC with app/src/lib/analytics-tracking-script.ts. 177var hpPush=null; 178if(!window.__hpupPushEvent){window.__hpupPushEvent=function(t,p){try{if(hpPush)hpPush(t,p);}catch(e){}};} 179hpConsent(function(){ 180 181function uuid(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:(r&3|8)).toString(16)});} 182function getOrSet(store,key){try{var v=store.getItem(key);if(v)return v;v=uuid();store.setItem(key,v);return v;}catch(e){return uuid();}} 183 184var visitorId=getOrSet(localStorage,"_hp_vid"); 185var sessionId=getOrSet(sessionStorage,"_hp_sid"); 186 187var params=new URLSearchParams(location.search); 188var utmSource=params.get("utm_source")||undefined; 189var utmMedium=params.get("utm_medium")||undefined; 190var utmCampaign=params.get("utm_campaign")||undefined; 191// Ad-platform click IDs (conversion feedback loop) â captured like the UTMs. 192// SYNC with canonical at app/src/lib/analytics-tracking-script.ts. 193var gclid=params.get("gclid")||undefined; 194var fbclid=params.get("fbclid")||undefined; 195var msclkid=params.get("msclkid")||undefined; 196 197var vw=window.innerWidth||0; 198var vh=window.innerHeight||0; 199var ua=navigator.userAgent||""; 200var isMobileUA=/Android|iPhone|iPod|iPad|webOS|BlackBerry|IEMobile|Opera Mini/i.test(ua); 201var maxScreenDim=Math.max(screen.width||0,screen.height||0); 202var deviceType=isMobileUA?(maxScreenDim<=812||vw<768?"mobile":"tablet"):"desktop"; 203 204function detectBrowser(){ 205if(ua.indexOf("Edg/")>-1)return"Edge"; 206if(ua.indexOf("Chrome/")>-1)return"Chrome"; 207if(ua.indexOf("Safari/")>-1&&ua.indexOf("Chrome/")===-1)return"Safari"; 208if(ua.indexOf("Firefox/")>-1)return"Firefox"; 209return"Other"; 210} 211function detectOS(){ 212if(ua.indexOf("iPhone")>-1||ua.indexOf("iPad")>-1)return"iOS"; 213if(ua.indexOf("Android")>-1)return"Android"; 214if(ua.indexOf("Mac OS")>-1)return"macOS"; 215if(ua.indexOf("Windows")>-1)return"Windows"; 216if(ua.indexOf("Linux")>-1)return"Linux"; 217return"Other"; 218} 219var browserFamily=detectBrowser(); 220var osFamily=detectOS(); 221 222var buffer=[]; 223function makeEvent(type,payload){ 224return{pageId:PID,eventType:type,visitorId:visitorId,sessionId:sessionId,payload:payload||undefined,referrer:document.referrer||undefined,utmSource:utmSource,utmMedium:utmMedium,utmCampaign:utmCampaign,gclid:gclid,fbclid:fbclid,msclkid:msclkid,deviceType:deviceType,browserFamily:browserFamily,osFamily:osFamily,viewportWidth:vw,viewportHeight:vh,experimentId:EXP_ID||undefined,experimentVariant:EXP_VARIANT||undefined,timestamp:Date.now()}; 225} 226function pushEvent(type,payload){buffer.push(makeEvent(type,payload));if(buffer.length>=FLUSH_MAX)flush();} 227 228// HP-1557 â expose a single-arg helper so the popups runtime (separate IIFE, 229// no shared closure with analytics) can feed events into the same buffer. 230// Defensive: only set if not already defined so a future second analytics 231// injection doesn't clobber an in-flight handler. HP-4568: the name is bound 232// above, outside the consent gate; this is where it starts forwarding. 233hpPush=pushEvent; 234// NOTE (HP-3461): this copy deliberately does NOT register window.__hpTrack, even 235// though the app copy registers both names. checkout-script.ts guards on 236// __hpTrack, and inject.ts appends that script to every edge-served page, so 237// adding the name here would start writing checkout_started for the entire edge 238// fleet (pubhtml.com + all custom domains) where that beacon has always been a 239// no-op â beginning an ecommerce event series mid-history, and moving the derived 240// checkout_abandoned with it, from a change about page engagement. It is a real 241// gap and wants its own change with a backfill story. 242// (No backticks anywhere in this file: it is one big template literal, so a 243// backtick in a comment terminates the string. This exact comment did it once.) 244 245function readMmuid(){try{var v=localStorage.getItem("mmuid");return v&&v.length<=128?v:undefined;}catch(e){return undefined;}} 246 247function flush(){ 248if(buffer.length===0)return; 249var batch=buffer.splice(0,FLUSH_MAX); 250var payload={events:batch}; 251var mmuid=readMmuid(); 252if(mmuid)payload.mmuid=mmuid; 253try{navigator.sendBeacon(API,JSON.stringify(payload));}catch(e){} 254} 255 256// Engaged time-on-page (HP-3592) â count a second only while the tab is 257// visible AND the visitor was active in the last 30s; emit the accrued DELTA 258// every 30 engaged seconds and on hide/pagehide. Registered BEFORE the flush 259// handlers below so the final delta lands in the buffer before the last 260// beacon fires (listener registration order = firing order). 261// SYNC with canonical at app/src/lib/analytics-tracking-script.ts. 262var dwellSec=0,dwellSent=0,dwellLastActive=Date.now(); 263var DWELL_IDLE_MS=30000,DWELL_EMIT_SEC=30,DWELL_CAP_SEC=1800; 264function dwellActive(){dwellLastActive=Date.now();} 265document.addEventListener("scroll",dwellActive,{passive:true}); 266document.addEventListener("mousemove",dwellActive,{passive:true}); 267document.addEventListener("keydown",dwellActive,true); 268document.addEventListener("touchstart",dwellActive,{passive:true}); 269document.addEventListener("click",dwellActive,true); 270function dwellEmit(){var d=dwellSec-dwellSent;if(d>0){dwellSent=dwellSec;pushEvent("dwell",{seconds:d});}} 271var dwellTimer=setInterval(function(){ 272if(document.visibilityState!=="visible")return; 273if(Date.now()-dwellLastActive>DWELL_IDLE_MS)return; 274dwellSec++; 275if(dwellSec-dwellSent>=DWELL_EMIT_SEC)dwellEmit(); 276if(dwellSec>=DWELL_CAP_SEC){dwellEmit();clearInterval(dwellTimer);} 277},1000); 278document.addEventListener("visibilitychange",function(){if(document.visibilityState==="hidden")dwellEmit();}); 279window.addEventListener("pagehide",dwellEmit); 280 281setInterval(flush,FLUSH_INTERVAL); 282document.addEventListener("visibilitychange",function(){if(document.visibilityState==="hidden")flush();}); 283window.addEventListener("pagehide",flush); 284 285pushEvent("page_view"); 286 287// On-platform conversion goal ("a thank-you page I own"): report the current 288// pageId plus the visitor's experiment assignments (_hp_exp_<id> cookies) so the 289// server can count this page-view as a conversion. Fires once per session per 290// page. HP-2925 Layer 1: must fire on EVERY page, not just experiment 291// participants â a standalone "Offsite event" goal has no experiment cookie, so 292// gating on cookies means the server never gets to attribute it. The server 293// no-ops cheaply when the page is neither an experiment nor a goal target. 294// SYNC with canonical at app/src/lib/analytics-tracking-script.ts. 295(function(){ 296try{ 297if(!APP_ORIGIN)return; 298var guard="_hp_goal:"+PID; 299if(sessionStorage.getItem(guard))return; 300sessionStorage.setItem(guard,"1"); 301var exps=[];var ck=document.cookie.split(";"); 302for(var i=0;i<ck.length;i++){var kv=ck[i].split("=");var k=(kv[0]||"").trim();if(k.indexOf("_hp_exp_")===0){exps.push({id:k.slice(8),variant:decodeURIComponent((kv[1]||"").trim())});}} 303var body=JSON.stringify({pageId:PID,visitorId:visitorId,sessionId:sessionId,experiments:exps}); 304var url=APP_ORIGIN+"/api/experiments/onsite-conversion"; 305if(navigator.sendBeacon){navigator.sendBeacon(url,new Blob([body],{type:"text/plain"}));} 306else{fetch(url,{method:"POST",headers:{"Content-Type":"application/json"},body:body,keepalive:true}).catch(function(){});} 307}catch(e){} 308})(); 309 310// HP-3461 â milestones reached WITHOUT the visitor scrolling carry {auto:1}. 311// checkScroll runs on a 1s timer regardless of input and at scrollY=0 pct is 312// just winH/docH, so any page under ~4 screens tall banked scroll_depth:25 from 313// every visitor who did nothing (a page fitting the viewport banked 100). Those
314// rows mark the session engaged in the rollup, which pushed the bounce rate of 315// every short page toward zero and inflated avgScrollDepth the same way. The 316// events still fire (that depth WAS visible, and the scroll funnel wants it); 317// the stamp lets the bounce query demand a real interaction. 318// SYNC with app/src/lib/analytics-tracking-script.ts. 319var scrollMilestones={25:false,50:false,75:false,100:false}; 320var scrollUserMoved=false; 321function checkScroll(){ 322var docH=Math.max(document.body.scrollHeight,document.documentElement.scrollHeight); 323var scrollY=window.scrollY||window.pageYOffset; 324var winH=window.innerHeight; 325if(docH<=winH){if(!scrollMilestones[100]){scrollMilestones[100]=true;pushEvent("scroll_depth",{depth:100,auto:1});}return;} 326var pct=Math.round(((scrollY+winH)/docH)*100); 327[25,50,75,100].forEach(function(m){if(pct>=m&&!scrollMilestones[m]){scrollMilestones[m]=true;pushEvent("scroll_depth",scrollUserMoved?{depth:m}:{depth:m,auto:1});}}); 328} 329window.addEventListener("scroll",function(){scrollUserMoved=true;checkScroll();},{passive:true}); 330setTimeout(checkScroll,1000); 331 332// HP-1557 â when a click/submit happens inside a pop-up, add the popup's id + 333// name to the payload. Customers track conversions on the standard form_submit / 334// element_click types; this is just attribution metadata so the analytics surface 335// can say "this conversion came from the Welcome offer pop-up". Matches the 336// canonical data-persist-popup marker and the legacy data-htmlpub-popup one. 337function popupAttribution(node){ 338var p=node&&node.closest&&node.closest("[data-persist-popup],[data-htmlpub-popup]"); 339if(!p)return null; 340var nm=p.getAttribute("data-persist-popup-name");if(nm===null)nm=p.getAttribute("data-htmlpub-popup-name"); 341return{popupId:p.getAttribute("id")||"",popupName:(nm||"").replace(/^\s+|\s+$/g,"")}; 342} 343 344document.addEventListener("click",function(e){ 345var el=e.target; 346if(!el||!el.tagName)return; 347var tag=el.tagName.toLowerCase(); 348if(!(tag==="a"||tag==="button"||tag==="input"||tag==="select"||tag==="textarea"||el.getAttribute("role")==="button"||el.closest("a,button,[role=button]")))return; 349var docH=Math.max(document.body.scrollHeight,document.documentElement.scrollHeight,1); 350var rect=el.getBoundingClientRect(); 351var payload={x:Math.min(1,Math.max(0,(rect.left+rect.width/2)/(document.documentElement.clientWidth||1))),y:Math.min(1,Math.max(0,(rect.top+rect.height/2+(window.scrollY||0))/docH)),selector:el.id?"#"+el.id:tag+(el.className&&typeof el.className==="string"?"."+el.className.trim().split(/\s+/).slice(0,2).join("."):""),viewportWidth:vw,viewportHeight:vh}; 352// HP-3227 â toggling "count clicks as conversions" on a button stamps 353// data-lp-conversion="<configId>" on that element, and both the rollup worker 354// and the dashboard read match a click goal by that id. This tracker never 355// reported it, so a goal whose stored selector is the placeholder 356// a[data-lp-conversion] had nothing to match on and counted zero conversions 357// forever, no matter how many buttons were toggled on. Read from the closest 358// stamped ancestor, since the click target is often an inner <span> of the CTA. 359// SYNC with app/src/lib/analytics-tracking-script.ts. 360var lpConvEl=el.closest?el.closest("[data-lp-conversion]"):null; 361var lpConvId=lpConvEl?lpConvEl.getAttribute("data-lp-conversion"):null; 362if(lpConvId)payload.lpConversion=lpConvId; 363// HP-1557 â only attribute element_click to a pop-up when the actual 364// click target is a button-shaped CTA. A click on the email input 365// inside a lead-capture popup would otherwise inflate the popup's 366// "Button clicks" count on the dashboard. 367var isCtaClick=(tag==="a"||tag==="button"||el.getAttribute("role")==="button"||(el.closest&&el.closest("a,button,[role=button]"))); 368if(isCtaClick){var pa=popupAttribution(el);if(pa){payload.popupId=pa.popupId;payload.popupName=pa.popupName;}} 369// HP-3461 â two NON-IDENTIFYING facts about the clicked anchor: the host it leaves 370// for (external only) and the file extension. The selector above records only "#id" 371// or "tag.class", so a PDF link and any other button were indistinguishable and no 372// past click can be reclassified. The full href is NOT stored: pathnames carry 373// customer identity and this is the highest-volume table (see absUrl). 374// SYNC with app/src/lib/analytics-tracking-script.ts. 375var hrefEl=tag==="a"?el:(el.closest&&el.closest("a[href]")); 376var clickUrl=hrefEl?absUrl(hrefEl):null; 377var clickFacts=clickUrl?linkFacts(clickUrl):null; 378if(clickFacts&&clickFacts.external)payload.linkHost=clickFacts.host; 379if(clickFacts&&clickFacts.ext)payload.linkExt=clickFacts.ext;
380var clickKey=clickUrl?linkClickKey(clickUrl):null; 381if(clickKey)payload.linkKey=clickKey; 382pushEvent("element_click",payload); 383// Classify into a micro-conversion. Emitted ALONGSIDE the element_click, never 384// instead of it, so a classification we later decide was wrong can be re-derived. 385if(hrefEl)classifyLinkClick(hrefEl,clickUrl); 386// HP-2811 â same-tab anchor navigation races the page unload before the 5s 387// flush timer; visibilitychange/pagehide flushes are unreliable (esp. iOS 388// Safari, same-tab nav), so element_click goals for outbound CTAs (e.g. a 389// HubSpot Meetings link) see 0 conversions despite real clicks. Mirror the 390// form_submit flush pattern for anchors that will navigate this tab. 391var navAnchor=tag==="a"?el:(el.closest&&el.closest("a[href]")); 392if(navAnchor&&navAnchor.getAttribute("target")!=="_blank"&&!e.ctrlKey&&!e.metaKey&&!e.shiftKey){ 393var navHref=(navAnchor.getAttribute("href")||"").replace(/^\s+/,""); 394if(navHref&&navHref.charAt(0)!=="#"&&navHref.slice(0,11).toLowerCase()!=="javascript:")flush(); 395} 396},true); 397 398document.addEventListener("submit",function(e){ 399var f=e.target; 400if(!f||f.tagName!=="FORM")return; 401var payload={formIdentifier:f.id||f.getAttribute("name")||"form-"+([].indexOf.call(document.forms,f))}; 402var pa=popupAttribution(f); 403if(pa){payload.popupId=pa.popupId;payload.popupName=pa.popupName;} 404pushEvent("form_submit",payload); 405flush();// send now â a thank-you-page redirect may navigate away before the 5s timer, dropping the experiment conversion 406},true); 407 408// âââ HP-3461 on-page micro-conversions ââââââââââââââââââââââââââââââââââââââ 409// Kinds + classification rules mirror app/src/lib/analytics/page-engagement-kinds.ts 410// (the server-side reference). Literal here because this compiles to an inline 411// string and cannot import; page-engagement-kinds.test.ts asserts both script 412// copies only ever emit registered kinds. 413// SYNC with app/src/lib/analytics-tracking-script.ts. 414 415// Resolve an anchor for RULE MATCHING only â the url itself is never sent. Only 416// host + extension are reported (linkFacts): customer delivery links routinely put 417// identity in the PATH (/thank-you/jane-doe, /download/lead/8842/Invoice-Jane-Doe.pdf), 418// and AnalyticsEvent is the highest-volume table with no redaction pass and no 419// consent gate, so a stored pathname is PII a DSAR erase cannot reach. 420function absUrl(a){ 421try{ 422var raw=(a.getAttribute("href")||"").replace(/^\s+/,""); 423if(!raw||raw.charAt(0)==="#"||raw.slice(0,11).toLowerCase()==="javascript:")return null; 424var u=new URL(raw,location.href); 425if(u.protocol!=="http:"&&u.protocol!=="https:")return null; 426return u; 427}catch(e){return null;} 428} 429// Host + extension, derived from the WHOLE url (never a truncated copy) so a long 430// signed URL cannot lose its extension to a length cap and read as plain navigation. 431function linkFacts(u){ 432var p=u.pathname.toLowerCase(); 433var dot=p.lastIndexOf("."); 434var ext=dot<0?"":p.slice(dot+1); 435if(ext.length>8)ext=""; 436return{host:u.hostname.toLowerCase(),ext:ext,external:u.hostname!==location.hostname}; 437} 438// Stable join key for WHICH link was clicked: FNV-1a hash of the canonicalized 439// URL (lowercase host, no fragment, no trailing slash, query kept minus our own 440// runtime decoration params). The raw href is still never stored (HP-3461) â 441// the dashboard re-derives each key from the page's OWN html and joins on the 442// hash (app/src/lib/analytics/link-click-key.ts, the reference implementation; 443// its test extracts this copy and asserts they agree). 444// SYNC with app/src/lib/analytics-tracking-script.ts and lib/analytics/link-click-key.ts. 445function linkClickKey(u){ 446try{ 447var c=new URL(u.toString()); 448c.hash=""; 449c.hostname=c.hostname.toLowerCase(); 450if(c.pathname.length>1&&c.pathname.slice(-1)==="/")c.pathname=c.pathname.slice(0,-1); 451if(c.search){ 452var strip=["lp_click_id","lp_page_id","lp_ts","_exp","_var","_vid","_pvar","client_reference_id"]; 453var kept=new URLSearchParams(); 454c.searchParams.forEach(function(v,k){if(strip.indexOf(k)<0)kept.append(k,v);}); 455var q=kept.toString(); 456c.search=q?"?"+q:""; 457} 458var t=c.toString(); 459var h=0x811c9dc5; 460for(var i=0;i<t.length;i++){h^=t.charCodeAt(i);h=Math.imul(h,0x01000193);} 461var x=(h>>>0).toString(16); 462while(x.length<8)x="0"+x; 463return x; 464}catch(e){return null;} 465} 466 467var DL_EXT=["pdf","epub","zip","csv","xlsx","xls","docx","doc","pptx","ppt","mp3","wav"]; 468// calendar.google.com is deliberately ABSENT: the standard add-to-calendar button 469// is not a booking, and host-only matching cannot tell them apart, so including it
470// reported bookings on pages where nobody booked. Host entries only â this copy 471// matches on hostname, so a host/path entry could never match here. 472var BOOK_HOSTS=["calendly.com","cal.com","acuityscheduling.com"]; 473 474// Every kind counts at most once PER PAGE LOAD: three clicks on the same PDF is one 475// download, and a scrubbed video cannot re-bank a quartile. 476// 477// IN-MEMORY, and it must stay that way. This used to consult sessionStorage and 478// return true from its catch block, so wherever storage throws (sandboxed iframe, 479// "block all cookies", in-app webviews, full quota) every timeupdate re-emitted 480// three quartiles â 12+/sec, which trips the collect route's 30-req/min-per-IP 481// limit and then 429s that visitor's REAL page_view / form_submit / conversion 482// beacons. A session-scoped key also outlived the page load, so a batch lost to a 483// 429 or a closed tab could never be re-reported. The server counts DISTINCT 484// SESSIONS per kind, so duplicates across page views are deduped there anyway. 485var microFired={}; 486function latch(kind,id){ 487var k=kind+":"+(id||""); 488if(microFired[k])return false; 489microFired[k]=true; 490return true; 491} 492function microEvent(kind,id){ 493if(!latch(kind,id))return; 494pushEvent("page_engagement",id?{kind:kind,id:id}:{kind:kind}); 495} 496 497function classifyLinkClick(a,u){ 498try{ 499if(!u)return; 500var f=linkFacts(u); 501if((a.hasAttribute&&a.hasAttribute("download"))||(f.ext&&DL_EXT.indexOf(f.ext)>-1)){ 502// id is the EXTENSION, not the file name: "Invoice-Jane-Doe.pdf" is as 503// identifying as the path we refuse to store, and phase 1 renders only an 504// aggregate count. 505microEvent("file_download",f.ext||undefined); 506return; 507} 508if(!f.external)return; 509for(var i=0;i<BOOK_HOSTS.length;i++){ 510var b=BOOK_HOSTS[i]; 511if(f.host===b||f.host.slice(-(b.length+1))==="."+b){microEvent("booking_open",b);return;} 512} 513}catch(e){} 514} 515 516// <video> only. YouTube/Vimeo/Loom/Wistia are cross-origin iframes whose 517// playback is invisible without the provider's JS API plus an embed-URL rewrite, 518// both deferred out of phase 1 â a page whose only video is an embed reports NO 519// video kinds, which the read side must treat as "not measurable", not as 0%. 520function videoId(v){ 521try{ 522if(v.id)return v.id.slice(0,128); 523var src=v.currentSrc||v.getAttribute("src")||""; 524if(!src)return undefined; 525var seg=src.split("?")[0].split("/"); 526return (seg[seg.length-1]||"").slice(0,128)||undefined; 527}catch(e){return undefined;} 528} 529// Autoplay policy only lets a video start by itself while MUTED, so "muted and the 530// visitor has never touched the page" is the signature of decorative playback. 531// A <video autoplay muted loop playsinline> hero fires 'play' on every load, so 532// counting it would report "100% played the video" on exactly the pages most likely 533// to have a hero video. Unmuted playback always counts: it needs a gesture. 534// mousedown as well as pointerdown: a path delivering only mouse events would 535// otherwise never set this, silently disabling the feature with nothing in the logs. 536var userGestured=false; 537function markGesture(){userGestured=true;} 538document.addEventListener("pointerdown",markGesture,true); 539document.addEventListener("mousedown",markGesture,true); 540document.addEventListener("keydown",markGesture,true); 541document.addEventListener("touchstart",markGesture,{passive:true,capture:true}); 542// Decide ONCE at 'play' and LATCH on the element. Re-checking the live flag on every 543// timeupdate was a real bug found in Chrome: a decorative muted autoplay correctly 544// suppressed video_start, then one unrelated trusted keystroke flipped userGestured 545// and every later timeupdate passed â banking p25/p50/p75 for a video nobody chose 546// to watch, with NO video_start, leaving the read side dividing by a zero 547// denominator. Unmuted playback always counts: it cannot start without a gesture. 548function decidePlay(v){ 549var intent=(!v.muted)||userGestured; 550try{v.__hpIntent=intent?1:0;}catch(e){} 551return intent; 552} 553// Undefined = never saw this element's 'play' (script injected mid-playback): fail 554// closed, since crediting is the direction that corrupts the headline percentage. 555function countedPlay(v){return v.__hpIntent===1;} 556// Unmuting a decorative video IS intent, and no 'play' fires for it, so re-decide. 557// Emitting video_start keeps the stream coherent: a start precedes its quartiles. 558function onVideoVolume(e){ 559var v=e.target; 560if(!v||v.tagName!=="VIDEO")return; 561if(v.muted||v.paused||v.__hpIntent===1)return; 562v.__hpIntent=1; 563microEvent("video_start",videoId(v)); 564} 565function onVideoTime(e){ 566var v=e.target; 567if(!v||v.tagName!=="VIDEO")return; 568// Consult the LATCHED decision, never the live flag â see decidePlay. 569if(!countedPlay(v))return; 570var d=v.duration; 571// Live streams / metadata not yet loaded report 0, NaN or Infinity â a quartile 572// of an unknown duration is meaningless, so report the play and nothing more. 573if(!isFinite(d)||d<=0)return; 574var pct=(v.currentTime/d)*100; 575var id=videoId(v); 576if(pct>=25)microEvent("video_p25",id); 577if(pct>=50)microEvent("video_p50",id); 578if(pct>=75)microEvent("video_p75",id); 579// p100 comes from 'ended', not a threshold: the last timeupdate can land a 580// fraction short of duration, so a full watch would never bank it. 581} 582document.addEventListener("play",function(e){var v=e.target;if(v&&v.tagName==="VIDEO"&&decidePlay(v))microEvent("video_start",videoId(v));},true); 583document.addEventListener("timeupdate",onVideoTime,true); 584document.addEventListener("volumechange",onVideoVolume,true); 585// SYNC-START embed-video-listeners (app/edge twins pinned by page-engagement-kinds.test.ts) 586// âââ Embedded video: Vimeo (HP-3461) ââââââââââââââââââââââââââââââââââââââââ 587// <video> is handled above by reading the element directly. A Vimeo embed is a 588// CROSS-ORIGIN iframe, so its state is unreadable â but the player reports its own 589// progress over postMessage, and needs NO change to the embed URL. A plainly pasted 590// Vimeo embed becomes measurable as-is. (YouTube is different: it is silent unless 591// enablejsapi=1 is on the URL. Handled separately.) 592// 593// Verified against live players, and two details cost an attempt each: 594// 1. SUBSCRIBE ON THE PLAYER'S ready MESSAGE, not the iframe's load event.
595// Subscribing on load races the player's own init: the addEventListener calls 596// are silently dropped, no ACK comes back, and no timeupdate ever arrives â it 597// looks exactly like "Vimeo does not work". 598// 2. ATTRIBUTE BY e.source, not player_id. Vimeo only echoes a player_id if the 599// embed URL carries one, and a customer's pasted embed has none. 600// 601// Vimeo sends OBJECTS (YouTube sends JSON strings), and timeupdate carries 602// data.percent as a 0..1 fraction, so quartiles need no arithmetic of our own. 603function vimeoHostOk(origin){ 604try{ 605var h=new URL(origin).hostname.toLowerCase(); 606return h==="player.vimeo.com"||h==="vimeo.com"||h.slice(-10)===".vimeo.com"; 607}catch(e){return false;} 608} 609// Low-cardinality, non-identifying label: the numeric video id off the embed URL, 610// prefixed so it cannot collide with a <video> filename in the tally. 611function vimeoLabel(f){ 612try{ 613var m=(f.getAttribute("src")||"").match(/vimeo\.com\/video\/(\d+)/); 614return m?"vimeo:"+m[1]:"vimeo"; 615}catch(e){return "vimeo";} 616} 617function vimeoFrameFor(src){ 618try{ 619var fr=document.querySelectorAll("iframe"); 620for(var i=0;i<fr.length;i++){if(fr[i].contentWindow===src)return fr[i];} 621}catch(e){} 622return null; 623} 624function vimeoSubscribe(f){ 625try{ 626var w=f.contentWindow; 627var evs=["play","timeupdate","ended"]; 628for(var i=0;i<evs.length;i++)w.postMessage({method:"addEventListener",value:evs[i]},"*"); 629}catch(e){} 630} 631window.addEventListener("message",function(e){ 632try{ 633if(!vimeoHostOk(e.origin))return; 634var d=e.data; 635if(!d||typeof d!=="object")return; 636var f=vimeoFrameFor(e.source); 637if(!f)return; 638if(d.event==="ready"){vimeoSubscribe(f);return;} 639var id=vimeoLabel(f); 640if(d.event==="play"){ 641// Same intent rule as <video>, minus the muted check we cannot read through an 642// iframe: browser policy only permits unprompted autoplay while MUTED, so 643// "playback began before the visitor touched anything" is the decorative case. 644if(!userGestured)return; 645f.__hpEmbedCounted=1; 646microEvent("video_start",id); 647return; 648} 649if(f.__hpEmbedCounted!==1)return; 650if(d.event==="timeupdate"){ 651var pct=d.data&&typeof d.data.percent==="number"?d.data.percent*100:-1; 652if(pct<0)return; 653if(pct>=25)microEvent("video_p25",id); 654if(pct>=50)microEvent("video_p50",id); 655if(pct>=75)microEvent("video_p75",id); 656// p100 comes from 'ended', as with <video>: the last timeupdate lands short. 657}else if(d.event==="ended"){ 658microEvent("video_p25",id);microEvent("video_p50",id);microEvent("video_p75",id);microEvent("video_p100",id); 659} 660}catch(err){} 661}); 662 663// âââ Embedded video: YouTube (HP-3461) ââââââââââââââââââââââââââââââââââââââ 664// YouTube is SILENT unless the embed URL carries enablejsapi=1 â verified live: a 665// plain embed answers nothing, even if we send it the handshake. So this listener 666// only hears players whose URL has that flag; adding it to customer HTML at serve 667// time is a separate commit, deliberately, because that mutates what visitors load. 668// 669// With the flag, the protocol is: post a listening message, then the player streams 670// infoDelivery with playerState, currentTime and duration. 671// 672// The handshake repeats on a timer, and the honest reason is that it catches frames 673// that appear after load (a tab, an accordion, a script-mounted embed). Whether a 674// player also STOPS streaming when we stop asking was not established: repeats are 675// answered "alreadyInitialized", which suggests the first one is what arms it. The 676// polling stays because the version with polling is the one verified to work end to 677// end, and trading that for an unverified optimisation would risk silently losing 678// every embed metric. The steady-state cost is one postMessage per api-enabled 679// frame every 2s. 680// 681// Unlike Vimeo, YouTube sends JSON STRINGS, so the payload needs parsing before it 682// looks like anything. 683// 684// playerState is YouTube's enum: -1 unstarted, 0 ended, 1 playing, 2 paused, 685// 3 buffering, 5 cued. 686function ytHostOk(origin){ 687try{ 688var h=new URL(origin).hostname.toLowerCase(); 689return h==="www.youtube.com"||h==="youtube.com"||h==="www.youtube-nocookie.com"||h==="youtube-nocookie.com"; 690}catch(e){return false;} 691} 692function ytLabel(f){ 693try{ 694var m=(f.getAttribute("src")||"").match(/\/embed\/([A-Za-z0-9_-]{11})/); 695return m?"youtube:"+m[1]:"youtube"; 696}catch(e){return "youtube";} 697} 698function ytFrameFor(src){ 699try{ 700var fr=document.querySelectorAll("iframe"); 701for(var i=0;i<fr.length;
701i++){if(fr[i].contentWindow===src)return fr[i];} 702}catch(e){} 703return null; 704} 705// Ask every api-enabled YouTube frame to start reporting, and keep asking. Cheap: a 706// postMessage to a frame that is not a YouTube player is simply ignored. 707function ytListen(){ 708try{ 709var fr=document.querySelectorAll('iframe[src*="enablejsapi=1"]'); 710for(var i=0;i<fr.length;i++){ 711try{fr[i].contentWindow.postMessage('{"event":"listening","id":"hp","channel":"widget"}',"*");}catch(e){} 712} 713}catch(e){} 714} 715setTimeout(ytListen,1200); 716setInterval(ytListen,2000); 717window.addEventListener("message",function(e){ 718try{ 719if(!ytHostOk(e.origin))return; 720var d=e.data; 721// YouTube sends strings; anything else is not its API talking. 722if(typeof d!=="string")return; 723try{d=JSON.parse(d);}catch(x){return;} 724if(!d||typeof d!=="object"||d.event!=="infoDelivery")return; 725var info=d.info; 726if(!info||typeof info!=="object")return; 727var f=ytFrameFor(e.source); 728if(!f)return; 729var id=ytLabel(f); 730var state=info.playerState; 731// Same intent rule as Vimeo and <video>, but YouTube hands us no discrete 'play' 732// event: infoDelivery repeats playerState 1 for the WHOLE of playback. So the 733// decision is made on the TRANSITION into playing and latched there, never 734// re-read. Re-reading it is precisely the bug decidePlay documents, and it is 735// reachable here in a way it is not for <video>: a muted autoplay is correctly 736// suppressed, then one unrelated trusted click (the form, alt-tab, a screenshot) 737// flips userGestured and the next state-1 update promotes a video nobody chose 738// into a counted play, quartiles and all. Latching on the transition also keeps 739// the <video> semantics that a chosen REPLAY after a decorative autoplay counts, 740// because that arrives as 2 then 1. 741var prev=f.__hpYtPrev; 742f.__hpYtPrev=state; 743if(state===1&&prev!==1){ 744f.__hpEmbedCounted=userGestured?1:0; 745if(f.__hpEmbedCounted===1)microEvent("video_start",id); 746} 747if(f.__hpEmbedCounted!==1)return; 748if(state===0){ 749// Ended implies every quartile, even if a throttled tab skipped an update. 750microEvent("video_p25",id);microEvent("video_p50",id);microEvent("video_p75",id);microEvent("video_p100",id); 751return; 752} 753var dur=info.duration,cur=info.currentTime; 754// A live stream reports duration 0, and a quartile of an unknown length is 755// meaningless â report the play and nothing more. 756if(typeof dur!=="number"||typeof cur!=="number"||!isFinite(dur)||dur<=0)return; 757var pct=(cur/dur)*100; 758if(pct>=25)microEvent("video_p25",id); 759if(pct>=50)microEvent("video_p50",id); 760if(pct>=75)microEvent("video_p75",id); 761// p100 comes from playerState 0, not a threshold: the final update lands short. 762}catch(err){} 763}); 764// SYNC-END embed-video-listeners 765document.addEventListener("ended",function(e){ 766var v=e.target; 767if(!v||v.tagName!=="VIDEO")return; 768if(!countedPlay(v))return; 769var id=videoId(v); 770// Watched to the end implies every quartile, even if a throttled background tab 771// skipped a timeupdate. 772microEvent("video_p25",id);microEvent("video_p50",id);microEvent("video_p75",id);microEvent("video_p100",id); 773},true); 774 775// âââ Scheduler widgets (HP-3461 phase 3) ââââââââââââââââââââââââââââââââââââ 776// An inline Calendly/Cal.com embed is a CROSS-ORIGIN iframe, so we cannot see a 777// click inside it â which is why booking_open (a click on an outbound scheduling 778// link) is absent on exactly the pages most likely to take bookings. The widget 779// broadcasts its own progress to the parent window, so we get told instead of 780// guessing. No SDK needed; Calendly posts these regardless. 781// 782// SECURITY: any frame can post a message claiming to be a booking, and this ships 783// on every published page, so the sender's ORIGIN must be a recognised scheduling 784// host AND the message must match the provider's shape. Everything else is ignored. 785// PRIVACY: only WHICH STEP is read. event.data.payload (invitee details on 786// event_scheduled) is never touched. 787// SYNC with app/src/lib/analytics-tracking-script.ts. 788function schedulerOrigin(origin){ 789try{ 790var h=new URL(origin).hostname.toLowerCase(); 791for(var i=0;i<BOOK_HOSTS.length;i++){ 792var b=BOOK_HOSTS[i]; 793if(h===b||h.slice(-(b.length+1))==="."+b)return b; 794} 795}catch(e){} 796return null; 797} 798// calendly.profile_page_viewed is deliberately ignored: it fires for a profile
799// listing rather than a bookable calendar, so it would inflate the funnel's top. 800window.addEventListener("message",function(e){ 801try{ 802var host=schedulerOrigin(e.origin); 803if(!host)return; 804var d=e.data; 805if(!d||typeof d!=="object")return; 806// Calendly's vocabulary reads off d.event and is prefixed; Cal.com's reads off 807// d.type and is not. Verified against live widgets on both, plus Cal.com's own 808// EventDataMap in packages/embeds/embed-core/src/sdk-action-manager.ts. 809// calendly.profile_page_viewed is ignored: it fires for a profile listing rather 810// than a bookable calendar, so it would inflate the top of the funnel. 811// 812// Cal.com HAS NO time-selection event. Its full enum (27 keys) contains nothing for 813// picking a date or slot -- availabilityLoaded and slotsFetched are the widget 814// LOADING options, not a visitor choosing one. So Cal.com reports 2 of the 3 steps, 815// and booking_time_selected is left ABSENT for it rather than synthesised from the 816// booking. Fabricating it would make every Cal.com page show a flat 100% from 817// "selected" to "completed" -- a number that looks measured and is not. Absent 818// already means "not measurable here" everywhere else in this feature (see the 819// video quartiles), and the read side honours that. 820// 821// Emissions are LITERALS, one branch per step, so the sync seam in 822// page-engagement-kinds.test.ts can see them -- it caught booking_completed going 823// unverifiable when this was table-driven. 824var name=typeof d.event==="string"?d.event:(typeof d.type==="string"?d.type:null); 825if(!name)return; 826if(host==="calendly.com"){ 827if(name==="calendly.event_scheduled"){ 828microEvent("booking_viewed",host); 829microEvent("booking_time_selected",host); 830microEvent("booking_completed",host); 831// The booking is the outcome this exists to catch and the visitor usually leaves 832// for the confirmation, so flush rather than wait out the 5s timer. 833flush(); 834}else if(name==="calendly.date_and_time_selected"){ 835microEvent("booking_viewed",host); 836microEvent("booking_time_selected",host); 837}else if(name==="calendly.event_type_viewed"){ 838microEvent("booking_viewed",host); 839} 840}else if(host==="cal.com"){ 841// bookingSuccessfulV2 deprecates bookingSuccessful and both are still in the enum, 842// so accept either -- customers sit on different embed versions. 843if(name==="bookingSuccessfulV2"||name==="bookingSuccessful"){ 844// NOTE: no booking_time_selected here. Cal.com cannot report it. 845microEvent("booking_viewed",host); 846microEvent("booking_completed",host); 847flush(); 848}else if(name==="bookerViewed"){ 849microEvent("booking_viewed",host); 850} 851} 852// No acuityscheduling.com branch, and there never can be one. Its embed script 853// exchanges only sizing/load/scrollTo/scrollBy/gaMeasurementId with the parent and 854// takes a Google Analytics client id INWARD â there is no booking message to hear. 855// Acuity reports outward only through its webhooks (HP-4041's inbound conversion 856// webhook is the route). A branch here would be dead code. See lib/scheduling-hosts. 857// Its origin stays accepted by the gate above, which costs nothing: an origin that 858// never speaks simply never matches. 859}catch(err){} 860}); 861 862// Consent usually arrives seconds after load, so a bare load listener would 863// never fire and rum_timing would vanish from every CMP-gated page. The 864// navigation/paint entries report the same absolute numbers whenever read. 865function hpRumTiming(){ 866setTimeout(function(){ 867try{ 868var perf=performance.getEntriesByType("navigation")[0]; 869if(perf){ 870var ev=makeEvent("rum_timing",{loadTimeMs:Math.round(perf.loadEventEnd-perf.startTime),ttfbMs:Math.round(perf.responseStart-perf.startTime),fcpMs:undefined}); 871var paintEntries=performance.getEntriesByType("paint"); 872for(var i=0;i<paintEntries.length;i++){if(paintEntries[i].name==="first-contentful-paint"){ev.payload.fcpMs=Math.round(paintEntries[i].startTime);break;}} 873buffer.push(ev); 874} 875}catch(e){} 876},2000); 877} 878if(document.readyState==="complete")hpRumTiming();else window.addEventListener("load",hpRumTiming); 879}); 880})(); 881</script>
881<script data-page-id="rm90klif4ys3o04jw9zgjfpw" data-page-slug="site-index" data-api-url="https://htmlpub.com" data-turnstile-key="0x4AAAAAAD7Sp6X7R_qRx-Cz" data-htmlpub-forms> 882(function(){ 883var s=document.querySelector("script[data-htmlpub-forms]"); 884var pageId=s.getAttribute("data-page-id"); 885var slug=s.getAttribute("data-page-slug"); 886var api=s.getAttribute("data-api-url")+"/api/forms/submit"; 887// Owner-mode: editor previews and the in-app shell load this script with 888// data-owner="1". We still listen for submits (the editor surfaces success 889// state to the user via the toolbar), but we MUST NOT fire conversion 890// pixels â that would pollute the customer's Meta / TikTok pixel data 891// with editor test traffic indistinguishable from real visitors. 892var _hpIsOwner=s.getAttribute("data-owner")==="1"; 893// Preview-mode: the editor's Preview loads owner-gated (draft access + no pixel 894// pollution) but must show the true visitor experience. _hpIsPreview lets visitor-UX 895// blocks (multi-step paging) run even though _hpIsOwner is set; pixel/submission gates 896// stay owner-only. 897var _hpIsPreview=s.getAttribute("data-preview")==="1"; 898var _hpAutomaticConversionEventsEnabled=s.getAttribute("data-automatic-conversion-events")!=="0"; 899var _hpTurnstileKey=s.getAttribute("data-turnstile-key")||""; 900// data-persist-hidden: fields hidden via the inspector â hidden from visitors on 901// the live page but still submitted (attribution/UTM). The edit canvas (owner, NOT 902// preview) skips the rule so the inspector keeps them visible with a "Hidden" chip; 903// Preview applies it so it matches the published page. 904if(!_hpIsOwner||_hpIsPreview){try{var _hpHideStyle=document.createElement("style");_hpHideStyle.setAttribute("data-htmlpub-forms-hide","");_hpHideStyle.textContent="[data-persist-hidden]{display:none!important}";(document.head||document.documentElement).appendChild(_hpHideStyle);}catch(e){}} 905// Multiple-choice radio/checkbox width normalization is NOT injected at runtime. 906// The editor bakes an inline width:auto onto each option input (idempotently), so the 907// fix persists in the saved HTML and follows the normal draft -> publish flow instead 908// of overriding every served form's checkboxes/radios on every page load. 909// A field hidden from visitors (data-persist-hidden -> display:none) that ALSO carries native 910// required cannot be focused, so on a single-step form native constraint validation would 911// silently abort submit on it (the multi-step path already skips hidden fields via 912// _hpValidatable). Drop the required attribute from hidden controls on the served page so 913// submission is never blocked by a field the visitor can't see; the field still submits its value. 914if(!_hpIsOwner||_hpIsPreview){try{var _hpHidReq=document.querySelectorAll("[data-persist-hidden][required],[data-persist-hidden] [required]");for(var _hphr=0;_hphr<_hpHidReq.length;_hphr++)_hpHidReq[_hphr].removeAttribute("required");}catch(e){}} 915// The form's real submit button â never one of the authored multi-step nav buttons 916// (data-persist-ms-back/next), which may be type-less if hand-authored. 917function _hpFindSubmit(form){return form.querySelector("[type=submit]:not([data-persist-ms-back]):not([data-persist-ms-next]),button[type=submit]:not([data-persist-ms-back]):not([data-persist-ms-next]),button:not([type]):not([data-persist-ms-back]):not([data-persist-ms-next])");} 918// A checkbox group marked required-as-a-group (data-persist-group-required) is satisfied when 919// AT LEAST ONE member is checked â native required on each box would wrongly demand ALL. 920// Returns true if every such group within the given scope is satisfied; otherwise reports validity on 921// the first empty group (clearing on the next change) and returns false. 922function _hpGroupReqOK(scope){ 923var boxes=scope.querySelectorAll('input[type="checkbox"][data-persist-group-required]'); 924var groups={}; 925for(var i=0;i<boxes.length;i++){var n=boxes[i].name||("__hp"+i);if(!groups[n])groups[n]=[];groups[n].push(boxes[i]);} 926for(var nm in groups){if(!Object.prototype.hasOwnProperty.call(groups,nm))continue; 927var g=groups[nm],any=false,first=null; 928for(var j=0;j<g.length;j++){if(g[j].closest&&g[j].closest("[data-persist-hidden]"))continue;if(!first)first=g[j];if(g[j].checked)any=true;} 929if(first&&!any){try{first.setCustomValidity("Please select at least one option.");
929if(first.reportValidity)first.reportValidity();var _clr=function(){try{first.setCustomValidity("");}catch(e){}for(var k=0;k<g.length;k++)g[k].removeEventListener("change",_clr);};for(var k2=0;k2<g.length;k2++)g[k2].addEventListener("change",_clr);}catch(e){}return false;}} 930return true; 931} 932// Pick a Turnstile theme (light/dark) from the first opaque background behind the form, so the 933// widget matches the PAGE rather than defaulting to the visitor's system theme ("auto"). Parses 934// the rgb() string without a regex escape (template-literal safe). Falls back to "auto". 935function _hpPickTheme(el){ 936try{ 937var n=el; 938while(n&&n.nodeType===1){ 939var bg=window.getComputedStyle(n).backgroundColor||""; 940if(bg.indexOf("rgb")===0){ 941var p=bg.replace(/[^0-9.,]/g,"").split(","); 942var a=p.length>3?parseFloat(p[3]):1; 943if(a>0.1){var lum=(0.299*+p[0]+0.587*+p[1]+0.114*+p[2])/255;return lum>0.5?"light":"dark";} 944} 945n=n.parentElement; 946} 947}catch(e){} 948return "auto"; 949} 950// Inject the Turnstile widget on a spam-protected form via the platform-origin bridge iframe. 951// The widget CANNOT render inline on a customer's custom domain: Turnstile binds a widget to a 952// hostname allowlist (Enterprise caps at 200, no wildcards), so customer domains can't be listed. 953// Instead we embed /turnstile-bridge served from data-api-url (a platform origin that IS on the 954// allowlist); it renders the widget there and postMessages the token back (see the listener below). 955// Placed ABOVE the submit's outermost row so on a multi-step footer nav it sits on its own line. 956// Starts 0px (interaction-only is invisible); the bridge posts a resize only if a challenge appears. 957// Idempotent; no-op without the feature flag or inside a sandboxed iframe (the /p shell renders its 958// own widget on the app origin). 959function _hpInjectCaptcha(f){ 960if(!_hpTurnstileKey||inSandbox||!f||f.querySelector("iframe[data-hp-turnstile]"))return; 961var base=(s.getAttribute("data-api-url")||""); 962if(base&&base.charAt(base.length-1)==="/")base=base.slice(0,-1); 963if(!base)return; 964var fr=document.createElement("iframe"); 965fr.setAttribute("data-hp-turnstile",""); 966fr.setAttribute("title","Spam protection"); 967fr.setAttribute("scrolling","no"); 968fr.style.cssText="width:100%;max-width:320px;height:0;border:0;display:block;overflow:hidden;"; 969fr.src=base+"/turnstile-bridge?theme="+encodeURIComponent(_hpPickTheme(f)); 970var sb=_hpFindSubmit(f); 971if(sb){var row=sb;while(row.parentNode&&row.parentNode!==f)row=row.parentNode;if(row.parentNode===f)f.insertBefore(fr,row);else if(sb.parentNode)sb.parentNode.insertBefore(fr,sb);else f.appendChild(fr);}else f.appendChild(fr); 972} 973// Receive the Turnstile token from the platform-origin bridge iframe. Origin-checked against 974// data-api-url. Stores the token on the owning form for the submit path; resizes the iframe when 975// Managed mode shows an actual challenge (invisible/auto-pass stays 0px). One listener serves 976// every captcha form on the page. 977(function(){ 978var _hpBO="";try{_hpBO=new URL(s.getAttribute("data-api-url")||"",location.href).origin;}catch(e){} 979window.addEventListener("message",function(e){ 980if(!_hpBO||e.origin!==_hpBO)return; 981var d=e.data;if(!d||d.source!=="hp-turnstile")return; 982var frs=document.querySelectorAll("iframe[data-hp-turnstile]"),host=null; 983for(var i=0;i<frs.length;i++){if(frs[i].contentWindow===e.source){host=frs[i];break;}} 984if(!host)return; 985var form=host.closest?host.closest("form"):null; 986if(d.type==="token"){if(form){form.__hpCaptchaToken=(d.token||"");form.__hpCaptchaError=false;}} 987else if(d.type==="expired"){if(form)form.__hpCaptchaToken="";} 988// A terminal bridge error (misconfigured/unreachable widget, keys mismatch) means no token can 989// ever be produced; flag it so the submit gate fails OPEN rather than trapping the visitor forever 990// (the server still fails open for the same unconfigured case). Distinct from expired (retr
990yable). 991else if(d.type==="error"){if(form){form.__hpCaptchaToken="";form.__hpCaptchaError=true;}} 992else if(d.type==="resize"){var h=+d.height||0;host.style.height=(h>0?h:0)+"px";host.style.margin=(h>0?"12px 0":"0");} 993}); 994})(); 995// Bounded wait for a Turnstile token, with a single re-challenge (decided fail policy #1/#2). 996// The bridge delivers the token via postMessage, which can land slightly AFTER the visitor 997// clicks submit (invisible challenge still resolving). Give it a bounded window; if none 998// arrives, force ONE fresh challenge (reload the bridge iframe) and wait a final window; if 999// STILL none, proceed anyway â the server accepts the lead and flags it unverified rather 1000// than dropping it. done() runs exactly once: as soon as a token appears, the bridge reports 1001// a terminal error, or the budget is spent. 1002function _hpWaitForCaptcha(f,done){ 1003var WAIT=3000,STEP=100,called=false; 1004function finish(){if(called)return;called=true;done();} 1005function poll(deadline,onExpire){ 1006if((f.__hpCaptchaToken||"")!==""||f.__hpCaptchaError){finish();return;} 1007if(Date.now()>=deadline){onExpire();return;} 1008setTimeout(function(){poll(deadline,onExpire);},STEP); 1009} 1010poll(Date.now()+WAIT,function(){ 1011if((f.__hpCaptchaToken||"")!==""||f.__hpCaptchaError){finish();return;} 1012try{var fr=f.querySelector("iframe[data-hp-turnstile]");if(fr)fr.src=fr.src;}catch(e){} 1013poll(Date.now()+WAIT,finish); 1014}); 1015} 1016// reCAPTCHA enforcement (HP-1475): only pages whose owner opted in emit this 1017// flag. It gates the client-side submit block below so pages that merely carry 1018// a decorative widget (the pre-HP-1475 status quo) keep working unchanged. 1019var _hpRecaptchaEnforced=s.getAttribute("data-recaptcha-enforced")==="1"; 1020var corrId=0; 1021var pendingCallbacks={}; 1022 1023// Detect if we're in a sandboxed iframe 1024var inSandbox=false; 1025try{inSandbox=window!==window.parent;}catch(e){inSandbox=true;} 1026 1027function cleanKey(s){return s.toLowerCase().replace(/[^a-z0-9]+/g,"_").replace(/^_|_$/g,"").substring(0,30);} 1028// SYNC: mirror worker-pages/src/scripts/forms.ts and app/src/lib/extract-form-fields.ts 1029function looksLikeRawId(s){return /^[a-f0-9]{32}$/i.test(s)||/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(s)||/^[a-f0-9]{32}-[A-Za-z0-9_-]{20,40}$/.test(s);} 1030function fieldKey(el,idx){ 1031if(el.name&&!looksLikeRawId(el.name))return el.name; 1032if(el.id&&!looksLikeRawId(el.id))return el.id; 1033var df=el.getAttribute("data-field")||el.getAttribute("data-name"); 1034if(df&&!looksLikeRawId(df))return df; 1035// Type-based semantic hint. <input type="email"> without an explicit name 1036// should still be identified as "email" so the lead-capture extractor can 1037// find the address. Same for tel/url. Critical when the page builder 1038// (HTMLPub editor / Sacca's offers.leadpages.com pages) emits inputs 1039// without name attributes â without this, the email field gets keyed off 1040// its placeholder ("alex_at_youragency_com") and no lead is extracted. 1041if(el.type==="email")return "email"; 1042if(el.type==="tel")return "phone"; 1043if(el.type==="url")return "url"; 1044// Sibling label pattern: <label>Foo</label><input ...> inside a wrapper. 1045// Common in modern form layouts. The existing closest(label) check below 1046// only catches WRAPPING labels, not sibling ones. 1047var prev=el.previousElementSibling; 1048if(prev&&prev.tagName==="LABEL"&&prev.textContent){var pk=cleanKey(prev.textContent);if(pk)return pk;} 1049if(el.placeholder){var ck=cleanKey(el.placeholder);if(ck)return ck;} 1050var al=el.getAttribute("aria-label"); 1051if(al){var ck2=cleanKey(al);if(ck2)return ck2;} 1052if(el.id){var lbl=document.querySelector('label[for="'+el.id+'"]');if(lbl&&lbl.textContent){var ck3=cleanKey(lbl.textContent);if(ck3)return ck3;}} 1053var par=el.closest?el.closest("label"):null; 1054if(par&&par.textContent){var ck4=cleanKey(par.textContent);if(ck4)return ck4;} 1055return (el.type||"field")+"_"+idx; 1056} 1057// Known third-party tracking field patterns to ignore 1058var _blocklist=/^(lp[_s]?plan|ub[_s]?plan|gtrans|__hstc|__hssc|__hsfp|hutk|_mkto_trk|_biz_uid|_biz_nA|_biz_pendingA|_biz_flagsA|mkt_tok|li_fat_id|_ga|_gid|_fbp|_fbc|_uetsid|_uetvid|mc_cid|mc_eid|_drip_client_|__ss|__ss_referrer|ss_campaign|ss_source|ss_cid|siq_|sq_|_uc_|tl|sl|vote|query|_hp_vid|g-recaptcha-response)$/i; 1059function _isInjected(el){
1060// Skip hidden inputs not present in the original page HTML. 1061// Third-party scripts (Customers.ai, HubSpot, etc.) inject hidden fields 1062// at runtime. We detect these by checking if they were present at DOMContentLoaded. 1063if(el.type==="hidden"&&!el.hasAttribute("data-hp-original"))return true; 1064return false; 1065} 1066function collect(f){ 1067var d={}; 1068var els=f.elements||f.querySelectorAll("input,textarea,select"); 1069var used={}; 1070var hasVisibleValue=false; 1071for(var i=0;i<els.length;i++){ 1072var el=els[i]; 1073if(el.type==="file"||el.type==="submit"||el.type==="button")continue; 1074if((el.type==="checkbox"||el.type==="radio")&&!el.checked)continue; 1075if(_isInjected(el))continue; 1076// International-phone controls: the visible national input (data-hp-phone-nat) and 1077// the injected country <select> (data-hp-phone-cc) are NOT collected â the hidden 1078// E.164 companion carries the value under the field's name. The national input still 1079// counts toward hasVisibleValue so a phone-only form isn't treated as empty. 1080if(el.hasAttribute&&el.hasAttribute("data-hp-phone-nat")){if(el.value&&el.value.trim())hasVisibleValue=true;continue;} 1081if(el.hasAttribute&&el.hasAttribute("data-hp-phone-cc"))continue; 1082// Empty URL-param inputs are dropped from the payload so direct-traffic 1083// submissions from a form with data-persist-capture-utms (or hand-authored 1084// data-persist-url-param inputs) don't ship 7 blank attribution columns 1085// into every submission, integration webhook, CSV export, and CAPI event. 1086// Author-provided value="direct" (or any non-empty default) still ships. 1087if(el.type==="hidden"&&!(el.value||"").trim()&&el.hasAttribute("data-persist-url-param"))continue; 1088var key=fieldKey(el,i); 1089if(_blocklist.test(key))continue; 1090if(used[key]){key=key+"_"+i;} 1091used[key]=true; 1092d[key]=el.value; 1093if(el.type!=="hidden"&&el.value&&el.value.trim())hasVisibleValue=true; 1094} 1095// Require at least one visible field with a value â pure hidden-field submissions are noise 1096if(!hasVisibleValue)return {}; 1097return d; 1098} 1099 1100// Generate a UUID v4 (browser-side). Used as the eventID that ties the 1101// fbq/ttq browser event to the matching CAPI event the server sends via 1102// the integration queue â provider workflow, so pixel platforms can dedup. 1103function _hpUuid(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(c){var r=Math.random()*16|0;return(c==="x"?r:(r&3|8)).toString(16);});} 1104 1105// Fire the canonical "Lead" event on every installed pixel platform. Only 1106// platforms that booted (window.fbq, window.ttq, window.gtag, window.dataLayer) 1107// receive a call â others no-op cleanly. The eventID is threaded into the 1108// provider-specific dedup parameter (Meta: eventID, TikTok: event_id). 1109function _hpFireLead(formIdent,data,eventId){ 1110 if(_hpIsOwner||!_hpAutomaticConversionEventsEnabled)return; 1111 var em=null,ph=null,fn=null,ln=null; 1112 for(var k in data){ 1113 if(!Object.prototype.hasOwnProperty.call(data,k))continue; 1114 var v=(data[k]||"").toString().trim(); 1115 if(!v)continue; 1116 var kl=k.toLowerCase(); 1117 if(!em&&/email/.test(kl)&&/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v))em=v; 1118 else if(!ph&&/phone|tel|mobile/.test(kl))ph=v; 1119 else if(!fn&&/first.?name|fname/.test(kl))fn=v; 1120 else if(!ln&&/last.?name|lname/.test(kl))ln=v; 1121 } 1122 var custom={};if(em)custom.em=em;if(ph)custom.ph=ph;if(fn)custom.fn=fn;if(ln)custom.ln=ln; 1123 try{if(window.fbq)window.fbq("track","Lead",custom,{eventID:eventId});}catch(e){} 1124 try{if(window.ttq&&window.ttq.track)window.ttq.track("SubmitForm",custom,{event_id:eventId});}catch(e){} 1125 try{if(window.gtag)window.gtag("event","generate_lead",{event_id:eventId,lp_form_id:formIdent});}catch(e){} 1126 try{if(window.dataLayer&&window.dataLayer.push)window.dataLayer.push({event:"lp_form_submit",lp_event_id:eventId,lp_form_id:formIdent});}catch(e){} 1127} 1128 1129// First-interaction hook: when the visitor focuses any form field for the 1130// first time on this page, fire the platform's "started a form" event. 1131// Once per form per page (debounced via _hpFiredInitFocus). 1132var _hpFiredInitFocus={}; 1133function _hpFireInitiateCheckout(formIdent){ 1134 if(_hpIsOwner||!_hpAutomaticConversionEventsEnabled)return; 1135 if(_hpFiredInitFocus[formIdent])return; 1136 _hpFiredInitFocus[formIdent]=true; 1137 var eid=_hpUuid(); 1138 try{if(window.fbq)window.fbq("track","InitiateCheckout",{}
1138,{eventID:eid});}catch(e){} 1139 try{if(window.ttq&&window.ttq.track)window.ttq.track("InitiateCheckout",{},{event_id:eid});}catch(e){} 1140 try{if(window.gtag)window.gtag("event","form_start",{event_id:eid,lp_form_id:formIdent});}catch(e){} 1141 try{if(window.dataLayer&&window.dataLayer.push)window.dataLayer.push({event:"lp_form_start",lp_event_id:eid,lp_form_id:formIdent});}catch(e){} 1142} 1143// HP-3461 â the same first-interaction moment, reported to OUR analytics. It has 1144// been detected here all along and told to Facebook, TikTok, Google and GTM 1145// (above) while our own funnel never heard about it, so "169 started the form, 17 1146// finished" was unanswerable from first-party data. 1147// 1148// Deliberately NOT inside _hpFireInitiateCheckout: that is gated on 1149// _hpAutomaticConversionEventsEnabled, the customer's AD-PIXEL preference, and 1150// switching off automatic ad conversion events must not blind our own analytics. 1151// 1152// Requires a real visitor gesture. A focusin alone is not evidence of intent: 1153// <input autofocus>, a popup that focuses its email field on open, and browser 1154// autofill all fire it with nobody touching the keyboard, which would report a 1155// form_start for ~every session and make the abandonment number this feature 1156// exists to answer ("169 started, 17 finished") read as a 100% start rate on a 1157// page nobody typed into. _hpFireInitiateCheckout above has always had this bug; 1158// it only ever fed ad pixels, so nobody noticed. 1159// 1160// Owner previews self-gate: analyticsTrackingScript returns on data-owner="1" 1161// BEFORE registering window.__hpupPushEvent, so this finds no bridge and no-ops. 1162// (The edge serves only public traffic, so the same code is correct on both 1163// hosts. Known gap, pre-existing and not widened by form_start: the Nova page 1164// switcher omits ?token=, so isOwnerForAnalytics misses it and page_view / 1165// scroll_depth / element_click already report from that path.) 1166// SYNC with worker-pages/src/scripts/forms.ts â these files must stay byte-identical. 1167var _hpFormStartGesture=false; 1168function _hpMarkFormGesture(){_hpFormStartGesture=true;} 1169document.addEventListener("pointerdown",_hpMarkFormGesture,true); 1170document.addEventListener("keydown",_hpMarkFormGesture,true); 1171document.addEventListener("touchstart",_hpMarkFormGesture,{passive:true,capture:true}); 1172// Object.create(null), not {}: the key is formId(), i.e. the author-controlled 1173// id/name attribute. With a bare object a form named "constructor" or "toString" 1174// reads as already-fired on its very first focus and never reports at all, and 1175// "__proto__" cannot even be latched â the page would show submissions with no 1176// starts whatsoever. 1177var _hpFiredFormStart=Object.create(null); 1178function _hpReportFormStart(formIdent){ 1179 if(!_hpFormStartGesture)return; 1180 var k=String(formIdent||""); 1181 if(_hpFiredFormStart[k])return; 1182 _hpFiredFormStart[k]=true; 1183 try{if(window.__hpupPushEvent)window.__hpupPushEvent("page_engagement",{kind:"form_start",id:k.slice(0,128)});}catch(e){} 1184} 1185document.addEventListener("focusin",function(e){ 1186 var el=e.target; 1187 if(!el||(el.tagName!=="INPUT"&&el.tagName!=="TEXTAREA"&&el.tagName!=="SELECT"))return; 1188 if(el.type==="hidden"||el.type==="submit"||el.type==="button")return; 1189 var f=el.closest&&(el.closest("form")||el.closest("[data-htmlpub-fake-form]")); 1190 if(!f)return; 1191 _hpReportFormStart(formId(f)); 1192 _hpFireInitiateCheckout(formId(f)); 1193},true); 1194 1195// Set _hp_email cookie when we find an email in form data (for CRM personalization) 1196function setEmailCookie(data){ 1197var email=null; 1198for(var k in data){ 1199if(!data.hasOwnProperty(k))continue; 1200var v=(data[k]||"").toString(); 1201if(/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim())){email=v.trim();break;} 1202} 1203if(email){ 1204try{var enc=btoa(email.toLowerCase().trim());document.cookie="_hp_email="+encodeURIComponent(enc)+";path=/;max-age=31536000;SameSite=Lax;Secure";}catch(e){} 1205} 1206} 1207 1208// Read _hpid from URL and set cookie for future visits 1209(function(){ 1210var p=new URLSearchParams(window.location.search); 1211var hpid=p.get("_hpid"); 1212if(hpid){ 1213try{var decoded=atob(hpid);if(decoded.indexOf("@")!==-1){document.cookie="_hp_email="+encodeURIComponent(hpid)+";path=/;max-age=31536000;SameSite=Lax;Secure";}}catch(e){} 1214} 1215})(); 1216 1217// URL-parameter auto-populate. Any input/textarea/select with 1218// data-persist-url-param="<name>" gets its value set from 1219// URLSearchParams(location.search).get(<name>) on script boot. Enables UTM / 1220// gclid / fbclid / any-custom-param capture into form submissions without a
1221// custom head script. An author (or the AI builder) drops 1222// <input type="hidden" name="utm_source" data-persist-url-param="utm_source"> 1223// and the runtime fills it in from ?utm_source=... on page load. Runs 1224// BEFORE the data-hp-original tagging pass below so the populated hidden 1225// inputs get stamped and pass _isInjected. utm_* names are not on 1226// _blocklist. Missing params leave any pre-existing value intact so authors 1227// can specify a fallback via a plain value="" on the input. Selector is 1228// scoped to form fields so a stray attribute on a non-form element is a 1229// no-op rather than a silent property write. Repeated params 1230// (?utm_source=a&utm_source=b) resolve to the first value ("a") per the 1231// URLSearchParams.get() spec. 1232try{ 1233var _hpUrlParams=new URLSearchParams(window.location.search); 1234document.querySelectorAll("input[data-persist-url-param],textarea[data-persist-url-param],select[data-persist-url-param]").forEach(function(el){ 1235var pname=el.getAttribute("data-persist-url-param"); 1236if(!pname)return; 1237var pv=_hpUrlParams.get(pname); 1238if(pv!==null&&pv!=="")el.value=pv; 1239}); 1240}catch(e){} 1241 1242// Standard-UTM auto-capture. A form with data-persist-capture-utms gets the 1243// seven standard attribution params (utm_source, utm_medium, utm_campaign, 1244// utm_term, utm_content, gclid, fbclid, msclkid) auto-injected as hidden inputs + 1245// populated from URLSearchParams if the URL carries them. Editor UI toggles 1246// the attribute; runtime does the injection. Idempotent by name, so a form 1247// with a pre-existing named hidden input (author-written or AI-authored) 1248// keeps its own definition and is only value-populated by the URL-param 1249// pass above. Runs BEFORE the data-hp-original tagging pass below so the 1250// injected inputs get stamped and pass _isInjected on submission. 1251try{ 1252var _HP_STD_UTMS=["utm_source","utm_medium","utm_campaign","utm_term","utm_content","gclid","fbclid","msclkid"]; 1253document.querySelectorAll("form[data-persist-capture-utms]").forEach(function(f){ 1254_HP_STD_UTMS.forEach(function(n){ 1255if(f.querySelector('input[name="'+n+'"]'))return; 1256var inp=document.createElement("input"); 1257inp.type="hidden"; 1258inp.name=n; 1259inp.setAttribute("data-persist-url-param",n); 1260var v=_hpUrlParams.get(n); 1261if(v!==null&&v!=="")inp.value=v; 1262f.appendChild(inp); 1263}); 1264}); 1265}catch(e){} 1266 1267// Cloudflare Turnstile CAPTCHA. A form with data-persist-captcha gets the bridge iframe 1268// injected before its submit control when the feature flag is set. TOP-LEVEL path only â in 1269// the sandboxed /p/ iframe the parent shell renders the widget (opaque origin can't run 1270// Turnstile). Owner/editor previews skip it. The bridge posts the token back (listener near 1271// _hpInjectCaptcha), stashed on the form and read by submitData as the top-level captchaToken. 1272if(!_hpIsOwner&&!inSandbox){try{ 1273document.querySelectorAll("form[data-persist-captcha]").forEach(function(f){ 1274// Multi-step forms defer injection to the LAST step (see the multi-step render), so the 1275// widget renders fresh + visible next to the submit and never appears on an earlier step. 1276if(f.hasAttribute("data-persist-multistep"))return; 1277_hpInjectCaptcha(f); 1278}); 1279}catch(e){}} 1280 1281// Authored multi-step progress indicator â a real [data-persist-ms-progress] element the 1282// editor builds as page content (variant "text" | "bar" | "dots"), so the author restyles it 1283// like anything else on the page. Only its STATE is written here; the structure is authored 1284// markup and is never rebuilt, or a restyle would be thrown away on every step. MIRROR of 1285// syncMsProgressState in visual-editor/iframe-script/utils.ts, which does the same for the 1286// edit canvas â that file cannot be imported from here (this script is a self-contained twin 1287// of a separate Cloudflare-worker package; see form-scripts-sync.test.ts), so the two copies 1288// must agree on every attribute name below. Not to be confused with the TRANSIENT 1289// data-htmlpub-ms-progress line further down, injected only for a form with no authored nav. 1290function _hpMsProgress(f,cur,total){ 1291var p=f.querySelector("[data-persist-ms-progress]"); 1292if(!p)return; 1293var t=p.querySelector("[data-persist-ms-progress-text]"); 1294if(t)t.textContent="Step "+(cur+1)+" of "+total; 1295var fill=p.querySelector("[data-persist-ms-progress-fill]"); 1296if(fill)fill.style.width=(total>0?((cur+1)/total)*100:100)+"%"; 1297var dots=p.querySelectorAll("[data-persist-ms-progress-dot]"); 1298for(var i=0;i<dots.length;i++)dots[i].style.opacity=(i<=cur?"1":"0.25"); 1299// A connector counts as travelled only once the step AFTER it is reached. 1300var lines=p.querySelectorAll("[data-persist-ms-progress-line]"); 1301for(var j=0;j<lines.length;j++)lines[j].style.opacity=(j<cur?"1":"0.25"); 1302} 1303 1304// ââ Multi-step forms ââ a form[data-persist-multistep] shows one [data-persist-step] 1305// container at a time, and validates the current step's required fields before advancing. 1306// Navigation uses the AUTHORED, page-styled buttons the editor creates â
1307// [data-persist-ms-next] (advance) + [data-persist-ms-back] (retreat) â with the real submit 1308// shown only on the last step. Legacy / hand-authored forms with no such buttons get a 1309// transient injected Back/Next + progress line (data-htmlpub-*, never saved) so they still 1310// work. The controller is stashed on the form as __hpMultiStep so the submit handler advances 1311// instead of submitting until the last step. The edit canvas (owner, NOT preview) skips this 1312// so every field stays visible for editing; Preview (data-preview="1") runs it. 1313if(!_hpIsOwner||_hpIsPreview){try{ 1314document.querySelectorAll("form[data-persist-multistep]").forEach(function(f){ 1315var steps=[].slice.call(f.querySelectorAll("[data-persist-step]")); 1316if(steps.length<2){ 1317// A single-step form has no navigation â hide any authored Back/Next so they don't render 1318// as dead buttons; the submit stays as the only action. 1319var _hpDead=f.querySelectorAll("[data-persist-ms-back],[data-persist-ms-next]"); 1320for(var _hpd=0;_hpd<_hpDead.length;_hpd++)_hpDead[_hpd].style.display="none"; 1321// Same reasoning for an authored progress indicator: with no steps to advance through it 1322// would sit there reading "Step 1 of 1". 1323var _hpProg1=f.querySelector("[data-persist-ms-progress]"); 1324if(_hpProg1)_hpProg1.style.display="none"; 1325// A degenerate multi-step form (0 or 1 real steps) behaves like a plain form, but the boot-time 1326// captcha pass skipped it (it carries data-persist-multistep) and the last-step inject below 1327// never runs. Inject the widget here â otherwise the server enforces a token this page never 1328// produces and every submission is rejected (100% lead loss), while the editor still shows the 1329// placeholder. Mirrors the non-multistep boot path (owner/preview skip via !_hpIsOwner). 1330if(!_hpIsOwner&&f.hasAttribute("data-persist-captcha"))_hpInjectCaptcha(f); 1331return; 1332} 1333steps.sort(function(a,b){return (parseInt(a.getAttribute("data-persist-step"),10)||0)-(parseInt(b.getAttribute("data-persist-step"),10)||0);}); 1334// Suppress native whole-form constraint validation: a required field in a 1335// not-yet-reached (display:none) step would otherwise abort submission and block 1336// Enter-to-advance. We validate each step in JS instead (validateStep on every hop 1337// + the last step on final submit), so required fields are still enforced. 1338f.noValidate=true; 1339// Exclude the authored nav buttons so a type-less [data-persist-ms-*] button is never picked as the submit. 1340var submit=_hpFindSubmit(f); 1341var submitDisp=submit?submit.style.display:""; 1342var cur=0; 1343// Prefer the authored nav buttons; fall back to injecting them for legacy forms. 1344var next=f.querySelector("[data-persist-ms-next]"); 1345var back=f.querySelector("[data-persist-ms-back]"); 1346var prog=null; 1347if(!next){ 1348// Skipped when the form carries an AUTHORED indicator: the transient one exists only to 1349// stand in for a form that has none, and rendering both reads as a duplicate. 1350if(!f.querySelector("[data-persist-ms-progress]")){ 1351prog=document.createElement("div"); 1352prog.setAttribute("data-htmlpub-ms-progress",""); 1353prog.style.cssText="font-family:system-ui,sans-serif;font-size:13px;opacity:0.7;margin-bottom:8px;"; 1354if(steps[0].parentNode)steps[0].parentNode.insertBefore(prog,steps[0]); 1355} 1356var nav=document.createElement("div"); 1357nav.setAttribute("data-htmlpub-ms-nav",""); 1358nav.style.cssText="display:flex;gap:8px;margin-top:8px;"; 1359// Reuse an authored Back if the form has one (only Next was missing) â don't inject a duplicate. 1360if(!back){back=document.createElement("button");back.type="button";back.setAttribute("data-htmlpub-ms-back","");back.textContent="Back";} 1361next=document.createElement("button");next.type="button";next.setAttribute("data-htmlpub-ms-next","");next.textContent="Next"; 1362if(submit&&submit.className){next.className=submit.className;if(!back.className)back.className=submit.className;} 1363nav.appendChild(back);nav.appendChild(next); 1364var lastStep=steps[steps.length-1]; 1365if(lastStep.parentNode)lastStep.parentNode.insertBefore(nav,lastStep.nextSibling); 1366} 1367var nextDisp=next?next.style.display:""; 1368var backDisp=back?back.style.display:""; 1369function render(){ 1370for(var i=0;i<steps.length;i++)steps[i].style.display=(i===cur?"":"none"); 1371if(prog)prog.textContent="Step "+(cur+1)+" of "+steps.length; 1372_hpMsProgress(f,cur,steps.length); 1373var last=cur===steps.length-1; 1374if(back)back.style.display=cur>0?backDisp:"none"; 1375if(next)next.style.display=last?"none":nextDisp; 1376if(submit)submit.style.display=last?submitDisp:"none"; 1377// Spam protection: inject the Turnstile widget the moment the visitor reaches the last step 1378// (fresh + visible so its token populates reliably â hiding it via display:none does not). 1379if(last&&!_hpIsOwner&&f.hasAttribute("data-persist-captcha"))_hpInjectCaptcha(f); 1380} 1381function _hpValidatable(c){ 1382// Skip fields the visitor can't see/act on â a data-persist-hidden (display:none) or 1383// disabled required field can't show a validation bubble and would silently trap them. 1384if(c.disabled)return false;
1385if(c.closest&&c.closest("[data-persist-hidden]"))return false; 1386return typeof c.checkValidity==="function"; 1387} 1388function validateStep(){ 1389var ctrls=steps[cur].querySelectorAll("input,select,textarea"); 1390for(var i=0;i<ctrls.length;i++){ 1391if(_hpValidatable(ctrls[i])&&!ctrls[i].checkValidity()){ 1392if(typeof ctrls[i].reportValidity==="function")ctrls[i].reportValidity(); 1393return false; 1394} 1395} 1396return _hpGroupReqOK(steps[cur]); 1397} 1398function validateOutside(){ 1399// Required controls authored OUTSIDE any step container are never in validateStep's scope; 1400// f.noValidate is on form-wide, so validate them on final submit or they'd submit empty. 1401var all=f.querySelectorAll("input,select,textarea"); 1402for(var i=0;i<all.length;i++){ 1403var c=all[i]; 1404if(c.closest&&c.closest("[data-persist-step]"))continue; 1405if(_hpValidatable(c)&&!c.checkValidity()){ 1406if(typeof c.reportValidity==="function")c.reportValidity(); 1407return false; 1408} 1409} 1410return true; 1411} 1412function advance(){if(cur<steps.length-1&&validateStep()){cur++;render();}} 1413if(next)next.addEventListener("click",function(e){if(e&&e.preventDefault)e.preventDefault();advance();}); 1414if(back)back.addEventListener("click",function(e){if(e&&e.preventDefault)e.preventDefault();if(cur>0){cur--;render();}}); 1415// validate() re-checks the CURRENT step + any required controls outside all steps â the 1416// submit handler calls it on the last step before sending (native validation is off). 1417f.__hpMultiStep={isLast:function(){return cur>=steps.length-1;},advance:advance,validate:function(){return validateStep()&&validateOutside();}}; 1418render(); 1419}); 1420}catch(e){}} 1421 1422// ââ International phone ââ input[type=tel][data-persist-phone] gets a country 1423// dial-code <select> injected before it + a hidden E.164 companion that carries the 1424// real submitted value (+<dial><national digits>) under the field's name. The visible 1425// tel input keeps the national number for display and is excluded from collect() 1426// (data-hp-phone-nat); the <select> is excluded too (data-hp-phone-cc). On every 1427// input / country change the E.164 is reassembled and validated â E.164 spec (7â15 1428// total digits) plus a GENEROUS per-country national-length range where the length is 1429// well known â via setCustomValidity, so native validation blocks a malformed submit 1430// on single-step forms and validateStep() enforces it on multi-step ones. Runs BEFORE 1431// the data-hp-original tagging pass so the hidden companion is stamped and collected. 1432// Runs in owner mode too so the editor preview shows the selector (the validity flag 1433// is harmless there â owner previews don't submit real leads). data-persist-phone / 1434// data-persist-phone-country are persistent (never stripped from the AI diff); the 1435// injected select + companion are data-hp-* (transient) and never saved. 1436try{ 1437var _HP_PHONE=[["US","United States","1",10,10,0],["CA","Canada","1",10,10,0],["GB","United Kingdom","44",9,10,0],["AU","Australia","61",9,9,0],["DE","Germany","49",0,0,0],["FR","France","33",9,9,0],["IN","India","91",10,10,0],["BR","Brazil","55",10,11,0],["MX","Mexico","52",10,10,0],["ES","Spain","34",9,9,0],["IT","Italy","39",9,11,1],["NL","Netherlands","31",9,9,0],["AE","United Arab Emirates","971",9,9,0],["SG","Singapore","65",8,8,0],["JP","Japan","81",9,11,0],["CN","China","86",8,11,0],["ZA","South Africa","27",9,9,0],["NG","Nigeria","234",10,10,0],["IE","Ireland","353",7,9,0],["NZ","New Zealand","64",8,10,0],["AR","Argentina","54",0,0,0],["AT","Austria","43",0,0,0],["BE","Belgium","32",0,0,0],["BD","Bangladesh","880",0,0,0],["BG","Bulgaria","359",0,0,0],["BH","Bahrain","973",8,8,0],["BO","Bolivia","591",0,0,0],["CH","Switzerland","41",9,9,0],["CL","Chile","56",0,0,0],["CO","Colombia","57",8,10,0],["CR","Costa Rica","506",8,8,0],["CY","Cyprus","357",8,8,0],["CZ","Czechia","420",9,9,0],["DK","Denmark","45",8,8,0],["DO","Dominican Republic","1",10,10,0],["EC","Ecuador","593",0,0,0],["EG","Egypt","20",0,0,0],["EE","Estonia","372",0,0,0],["FI","Finland","358",0,0,0],["GR","Greece","30",10,10,0],["GT","Guatemala","502",8,8,0],["HK","Hong Kong","852",8,8,0],["HR","Croatia","385",0,0,0],["HU","Hungary","36",0,0,0],["ID","Indonesia","62",0,0,0],["IL","Israel","972",8,9,0],["JO","Jordan","962",0,0,0],["KE","Kenya","254",0,0,0],["KR","South Korea","82",0,0,0],["KW","Kuwait","965",8,8,0],["LK","Sri Lanka","94",9,9,0],["LT","Lithuania","370",8,8,0],["LU","Luxembourg","352",0,0,0],["LV","Latvia","371",8,8,0],["MA","Morocco","212",9,9,0],["MY","Malaysia","60",0,0,0],["NO","Norway","47",8,8,0],["OM","Oman","968",8,8,0],["PA","Panama","507",0,0,0],["PE","Peru","51",9,9,0],["PH","Philippines","63",9,10,0],["PK","Pakistan","92",10,10,0],["PL","Poland","48",9,9,0],["PT","Portugal","351",9,9,0],["PY","Paraguay","595",0,0,0],["QA","Qatar","974",8,8,0],["RO","Romania","40",9,9,0],["RS","Serbia","381",0,0,0],["RU","Russia","7",10,10,0],["SA","Saudi Arabia","966",9,9,0],["SE","Sweden","46",0,0,0],["SK","Slovakia","421",9,9,0],["SI","Slovenia","386",0,0,0],["TH","Thailand","66",9,9,0],["TN","Tunisia","216",8,8,0],["TR","Turkey","90",10,10,0],["TW","Taiwan","886",0,0,0],["UA","Ukraine","380",9,9,0],["UY","Uruguay","598",0,0,0],["VE","Venezuela","58",0,0,0],["VN","Vietnam","84",0,0,0]]; 1438var _hpPhoneByIso={};for(var _hppi=0;_hppi<_HP_PHONE.length;_hppi++){_hpPhoneByIso[_HP_PHONE[_hppi][0]]=_HP_PHONE[_hppi];} 1439var _hpDigits=function(s){return (s||"").replace(/[^0-9]/g,"");}; 1440var _hpFlag=function(iso){try{return String.fromCodePoint(0x1F1E6+(iso.charCo
1440deAt(0)-65))+String.fromCodePoint(0x1F1E6+(iso.charCodeAt(1)-65));}catch(e){return "";}}; 1441// Reduce the typed digits to the country's national significant number (NSN): strip a 1442// user-typed country code (guarded by the expected NSN length so a valid number is never 1443// shortened) and a national trunk prefix "0" (guarded so it never drops below the country 1444// minimum; suppressed for keepLeadingZero countries like Italy). Lead-safe: each strip 1445// fires only when it yields a still-plausible NSN, so "07911 123456", "06 12 34 56 78" and 1446// "1 (415) 555-2671" all assemble to correct E.164 and pass validation. 1447var _hpNSN=function(nat,cur){ 1448var dial=cur[2],min=cur[3],max=cur[4],keep0=cur[5]; 1449if(dial&&nat.length>dial.length&&nat.substring(0,dial.length)===dial){ 1450var rest=nat.substring(dial.length); 1451var restT=(!keep0&&rest.charAt(0)==="0")?rest.replace(/^0+/,""):rest; 1452if(min&&max){ 1453// Known national length: keep whichever candidate fits the country's range. 1454if(restT.length>=min&&restT.length<=max)nat=restT; 1455else if(rest.length>=min&&rest.length<=max)nat=rest; 1456} 1457// Variable-length country (min/max unknown): do NOT strip the leading dial-code digits â 1458// for those countries a real national number can legitimately begin with its own dial 1459// code, and chopping it corrupts the number (F4). "+"-prefixed input is handled before 1460// _hpNSN, so this only affects the ambiguous no-"+" case; keep the digits as typed. 1461} 1462if(!keep0&&nat.charAt(0)==="0"){var s=nat.replace(/^0+/,"");if(!min||s.length>=min)nat=s;} 1463// Russia/Kazakhstan (dial 7) use "8" as the national trunk prefix, not "0". Strip a lone 1464// leading "8", guarded by the country minimum so a valid NSN is never shortened (F3). 1465if(dial==="7"&&nat.charAt(0)==="8"){var s8=nat.replace(/^8/,"");if(!min||s8.length>=min)nat=s8;} 1466return nat; 1467}; 1468document.querySelectorAll('input[type="tel"][data-persist-phone]').forEach(function(inp){ 1469if(inp.getAttribute("data-hp-phone-on")==="1")return; 1470inp.setAttribute("data-hp-phone-on","1"); 1471// Key the E.164 companion the SAME way collect()/extract-form-fields key a plain tel 1472// input (name -> id -> data-field -> "phone"), computed BEFORE any DOM mutation so 1473// enabling international never changes which lead column the number lands in. 1474var nm=fieldKey(inp,0); 1475var iso=(inp.getAttribute("data-persist-phone-country")||"US").toUpperCase(); 1476if(!_hpPhoneByIso[iso])iso="US"; 1477// Custom country combobox. A native <select> can't show a compact "flag +dial" when 1478// CLOSED and full country names when OPEN: its closed text IS the selected option's 1479// text, and a same-value close fires no event to swap it back. So we build a small 1480// self-contained combobox â a styled trigger button showing "flag +dial" + a panel 1481// listing full country names. curIso tracks the selection for E.164 assembly. Lay it 1482// out side by side with the number in a flex row (standard international-phone layout); 1483// the trigger copies the number input's border/radius/padding/font/colors so it matches 1484// whatever the page's forms look like (align-items:stretch matches their heights). 1485var curIso=iso; 1486// Brand accent for the selected-row tint + checkmark, sampled from the form's primary 1487// (submit) button background so the dropdown matches the page. A light tint keeps the 1488// dark option text legible. Falls back to a neutral blue; transparent / near-white 1489// buttons are ignored so the accent stays visible. 1490var _hpAccent="#2563eb",_hpAccentTint="rgba(37,99,235,0.12)"; 1491try{ 1492var _hpBtnForm=inp.closest?inp.closest("form"):null; 1493var _hpBtn=_hpBtnForm?_hpFindSubmit(_hpBtnForm):null; 1494if(_hpBtn){ 1495var _bg=window.getComputedStyle(_hpBtn).backgroundColor||""; 1496var _pp=_bg.replace(/[^0-9,.]/g,"").split(","); 1497var _rr=parseInt(_pp[0],10),_gg=parseInt(_pp[1],10),_bb=parseInt(_pp[2],10),_aa=_pp.length>3?parseFloat(_pp[3]):1; 1498if(!isNaN(_rr)&&!isNaN(_gg)&&!isNaN(_bb)&&_aa>0.1&&(_rr+_gg+_bb)<720){_hpAccent="rgb("+_rr+","+_gg+","+_bb+")";_hpAccentTint="rgba("+_rr+","+_gg+","+_bb+",0.12)";} 1499} 1500}catch(e){} 1501// Use the AUTHORED wrapper (data-persist-phone-field) that the editor saved, so the 1502// published layout is identical to the edit canvas. Create one only as a fallback for 1503// legacy pages saved before the authored wrapper existed (served pages aren't re-saved, 1504// so a runtime-created wrapper is harmless there). 1505var wrap=inp.closest?inp.closest("[data-persist-phone-field]"):null; 1506if(!wrap){ 1507wrap=document.createElement("span"); 1508wrap.setAttribute("data-persist-phone-field",""); 1509wrap.style.cssText="display:flex;align-items:stretch;gap:6px;width:100%;position:relative;"; 1510if(inp.parentNode)inp.parentNode.insertBefore(wrap,inp); 1511wrap.appendChild(inp); 1512} 1513if(!wrap.style.position)wrap.style.position="relative"; 1514// A <span role=button>, NOT a <button>: the widget sits inside the field's <label>, and 1515// a <label> forwards clicks to its first LABELABLE descendant. A <button> would become 1516// that control, so clicking a list item fires a synthetic click on the trigger and 1517// re-opens the panel. A <span> isn't labelable, so the label keeps targeting the number 1518// input; preventDefault on the trigger/list clicks stops any residual label forwarding. 1519var trig=document.createElement("span"); 1520trig.setAttribute("role","button"); 1521trig.setAttribute("tabindex","0"); 1522trig.setAttribute("data-hp-phone-cc",""); 1523trig.setAttribute("aria-haspopup","listbox");
1524trig.setAttribute("aria-expanded","false"); 1525trig.setAttribute("aria-label","Country calling code"); 1526trig.style.cssText="flex:0 0 auto;display:inline-flex;align-items:center;gap:6px;cursor:pointer;white-space:nowrap;-webkit-user-select:none;user-select:none;box-sizing:border-box;"; 1527try{ 1528var _cs=window.getComputedStyle(inp); 1529var _cp=["fontFamily","fontSize","fontWeight","color","backgroundColor","borderTopWidth","borderRightWidth","borderBottomWidth","borderLeftWidth","borderTopStyle","borderRightStyle","borderBottomStyle","borderLeftStyle","borderTopColor","borderRightColor","borderBottomColor","borderLeftColor","borderTopLeftRadius","borderTopRightRadius","borderBottomRightRadius","borderBottomLeftRadius","paddingTop","paddingBottom","paddingLeft","paddingRight","boxSizing","lineHeight"]; 1530for(var _cpi=0;_cpi<_cp.length;_cpi++){try{if(_cs[_cp[_cpi]])trig.style[_cp[_cpi]]=_cs[_cp[_cpi]];}catch(e){}} 1531}catch(e){} 1532var trigLabel=document.createElement("span"); 1533trig.appendChild(trigLabel); 1534var trigCaret=document.createElement("span");trigCaret.textContent="â¾";trigCaret.style.cssText="opacity:0.55;font-size:0.8em;"; 1535trig.appendChild(trigCaret); 1536var panel=document.createElement("ul"); 1537panel.setAttribute("role","listbox"); 1538panel.setAttribute("data-hp-phone-list",""); 1539panel.style.cssText="display:none;position:absolute;top:100%;left:0;z-index:2147483000;margin:4px 0 0;padding:4px 0;list-style:none;max-height:260px;overflow-y:auto;min-width:240px;background:#fff;color:#111;border:1px solid rgba(0,0,0,0.15);border-radius:8px;box-shadow:0 8px 28px rgba(0,0,0,0.18);font-family:system-ui,-apple-system,sans-serif;font-size:14px;text-align:left;"; 1540// Hover + selected-item styling (one shared, id-guarded stylesheet â inline styles can't 1541// express :hover / ::after). Injected on the live page only; never touches the editor. 1542if(!document.getElementById("hp-phone-cc-style")){var _phSt=document.createElement("style");_phSt.id="hp-phone-cc-style";_phSt.textContent="ul[data-hp-phone-list] li[role=option]:hover{background:rgba(0,0,0,0.06);}ul[data-hp-phone-list] li[aria-selected=true]{font-weight:600;background:var(--hp-accent-tint,rgba(37,99,235,0.12));}ul[data-hp-phone-list] li[aria-selected=true]::after{content:'â';float:right;margin-left:12px;color:var(--hp-accent,#2563eb);}";(document.head||document.documentElement).appendChild(_phSt);} 1543panel.style.setProperty("--hp-accent",_hpAccent); 1544panel.style.setProperty("--hp-accent-tint",_hpAccentTint); 1545for(var pci=0;pci<_HP_PHONE.length;pci++){ 1546var pc=_HP_PHONE[pci]; 1547var li=document.createElement("li"); 1548li.setAttribute("role","option"); 1549li.setAttribute("data-iso",pc[0]); 1550li.setAttribute("data-dial",pc[2]); 1551li.setAttribute("aria-selected",pc[0]===iso?"true":"false"); 1552li.textContent=_hpFlag(pc[0])+" "+pc[1]+" +"+pc[2]; 1553li.style.cssText="padding:7px 14px;cursor:pointer;white-space:nowrap;"; 1554panel.appendChild(li); 1555} 1556var renderTrig=function(){var c=_hpPhoneByIso[curIso];trigLabel.textContent=_hpFlag(curIso)+" +"+(c?c[2]:"");}; 1557var closePanel=function(){panel.style.display="none";trig.setAttribute("aria-expanded","false");}; 1558var openPanel=function(){panel.style.display="block";trig.setAttribute("aria-expanded","true");}; 1559inp.style.flex="1"; 1560inp.style.minWidth="0"; 1561wrap.insertBefore(trig,inp); // trigger sits before the number input (input already in the wrap) 1562wrap.appendChild(panel); 1563var hid=document.createElement("input"); 1564hid.type="hidden"; 1565hid.name=nm; 1566hid.setAttribute("data-hp-phone-e164",""); 1567wrap.appendChild(hid); 1568inp.setAttribute("data-hp-phone-nat",""); 1569inp.removeAttribute("name"); 1570var _hpSyncPhone=function(){ 1571var raw=(inp.value||"").trim(); 1572var cur=_hpPhoneByIso[curIso]; 1573var dial=cur?cur[2]:""; 1574var e164="",nsnLen=0,isIntl=false; 1575if(raw.charAt(0)==="+"){e164="+"+_hpDigits(raw);isIntl=true;} 1576else{var nsn=_hpNSN(_hpDigits(raw),cur);nsnLen=nsn.length;e164=nsn?("+"+dial+nsn):"";} 1577hid.value=e164; 1578var msg=""; 1579if(raw){ 1580var total=_hpDigits(e164).length; 1581// Hard rules only: E.164 spec (7-15 total digits) + a per-country MINIMUM NSN length 1582// (catches a genuinely incomplete number, e.g. a US local number with no area code). No 1583// per-country maximum -> an over-long entry (usually the visitor including extra 1584// country/trunk digits) is accepted, never rejected. A "+"-prefixed entry skips the 1585// per-country floor entirely (the visitor owns their own format). 1586if(total<7||total>15)msg="Enter a valid phone number."; 1587else if(!isIntl&&nsnLen&&cur&&cur[3]&&nsnLen<cur[3])msg="Enter a valid "+cur[1]+" phone number."; 1588} 1589if(typeof inp.setCustomValidity==="function")inp.setCustomValidity(msg); 1590};
1591var selectIso=function(newIso){if(_hpPhoneByIso[newIso])curIso=newIso;for(var _si=0;_si<panel.children.length;_si++){var _c=panel.children[_si];if(_c.getAttribute&&_c.getAttribute("role")==="option")_c.setAttribute("aria-selected",_c.getAttribute("data-iso")===curIso?"true":"false");}renderTrig();closePanel();_hpSyncPhone();}; 1592panel.addEventListener("click",function(e){var t=e.target;while(t&&t!==panel&&!(t.getAttribute&&t.getAttribute("role")==="option"))t=t.parentNode;if(t&&t.getAttribute&&t.getAttribute("role")==="option"){e.preventDefault();selectIso(t.getAttribute("data-iso"));try{inp.focus();}catch(_e){}}}); 1593trig.addEventListener("click",function(e){e.preventDefault();if(panel.style.display==="block")closePanel();else openPanel();}); 1594trig.addEventListener("keydown",function(e){if(e.key==="Escape"){closePanel();}else if(e.key==="ArrowDown"||e.key==="Enter"||e.key===" "){e.preventDefault();openPanel();}}); 1595document.addEventListener("click",function(e){if(!wrap.contains(e.target))closePanel();}); 1596inp.addEventListener("input",_hpSyncPhone); 1597inp.addEventListener("blur",_hpSyncPhone); 1598renderTrig(); 1599_hpSyncPhone(); 1600}); 1601}catch(e){} 1602 1603// Tag all hidden inputs present in the original HTML so we can distinguish 1604// them from ones injected later by third-party scripts (Customers.ai, etc.) 1605document.querySelectorAll('input[type="hidden"]').forEach(function(el){el.setAttribute("data-hp-original","1");}); 1606 1607function showNotice(f,text,ok){ 1608var m=document.createElement("div"); 1609m.textContent=text; 1610m.style.cssText="padding:12px 16px;margin:8px 0;border-radius:6px;font-family:system-ui,sans-serif;font-size:14px;"+(ok?"background:#f0fdf4;color:#166534;border:1px solid #bbf7d0":"background:#fef2f2;color:#991b1b;border:1px solid #fecaca"); 1611f.parentNode.insertBefore(m,f.nextSibling); 1612setTimeout(function(){m.remove()},5000); 1613} 1614// showMsg wraps showNotice; the optional 3rd arg lets callers override the default success/error 1615// copy (e.g. the Turnstile "still verifying" gate). Omit it for the standard success/error notices. 1616function showMsg(f,ok,msg){showNotice(f,msg||(ok?(f.getAttribute("data-success-message")||"Thank you! Your submission has been received."):"Something went wrong. Please try again."),ok);} 1617// reCAPTCHA v2 (HP-1475). The response token lives in a hidden 1618// <textarea name="g-recaptcha-response"> that grecaptcha creates inside the 1619// widget's container; scoping the lookup to the submitted form/container picks 1620// the right token when a page renders more than one widget. 1621function _hpCaptchaToken(f){ 1622try{var ta=f.querySelector&&f.querySelector('textarea[name="g-recaptcha-response"]');return ta&&ta.value?ta.value:"";}catch(e){return "";} 1623} 1624 1625function formId(f){ 1626if(f.tagName==="FORM")return f.id||f.getAttribute("name")||"form-"+([].indexOf.call(document.forms,f)); 1627return f.id||"section-"+([].indexOf.call(document.querySelectorAll("[data-htmlpub-fake-form]"),f)); 1628} 1629 1630// Listen for postMessage responses from parent shell 1631if(inSandbox){ 1632window.addEventListener("message",function(e){ 1633var d=e.data; 1634if(d&&d.type==="htmlpub-form-submit-result"&&d.correlationId){ 1635var cb=pendingCallbacks[d.correlationId]; 1636if(cb){ 1637delete pendingCallbacks[d.correlationId]; 1638cb(d.success,d.captchaRequired,d.error); 1639} 1640} 1641}); 1642} 1643 1644function isSafeRedirectUrl(url){ 1645if(!url)return false; 1646if(/^https?:\/\//i.test(url))return true; 1647if(url.charAt(0)==="/"&&url.charAt(1)!=="/")return true; 1648return false; 1649} 1650// Migrated Builder3 site headers use relative hrefs that break on trailing-slash landing URLs; drop the trailing slash on same-origin targets (/thank-you/ -> /thank-you). 1651function stripTrailingSlash(url){ 1652try{var u=new URL(url,window.location.origin);if(u.origin===window.location.origin&&u.pathname.length>1&&u.pathname.charAt(u.pathname.length-1)==="/")return u.pathname.replace(/\/+$/,"")+u.search+u.hash;}catch(e){} 1653return url; 1654} 1655function maybeRedirectUrl(url){ 1656if(window.htmlpubEditorMode)return; 1657if(!isSafeRedirectUrl(url))return; 1658if(/\/api\/forms\/submit$/.test(url))return; 1659setTimeout(function(){window.location.href=stripTrailingSlash(url);},600); 1660} 1661function maybeRedirect(f){maybeRedirectUrl(f.getAttribute("action"));} 1662function reportFail(kind,status,fident){ 1663try{if(window.__hpupPushEvent)window.__hpupPushEvent("form_submit_failed",{failureKind:kind,httpStatus:status||0,host:location.host,path:location.pathname,apiBase:s.getAttribute("data-api-url")||"",formIdentifier:fident,pageId:pageId,pageSlug:slug});}catch(e){} 1664} 1665function submitData(f,data){
1666var btn=_hpFindSubmit(f); 1667if(btn)btn.disabled=true; 1668 1669// Generate one eventID per submission, threaded through three places: 1670// (a) the JSON payload to /api/forms/submit â integration queue 1671// (b) the postMessage to the parent shell (which forwards to the same API) 1672// (c) the browser pixel events fired on success below 1673// Both browser and server emit the SAME id to the same downstream provider 1674// so Meta / TikTok dedup them as a single conversion. 1675var eventId=_hpUuid(); 1676var fident=formId(f); 1677// Per-form Turnstile token â delivered by the bridge iframe via postMessage (see the listener 1678// near _hpInjectCaptcha) and stashed on the form element. 1679var _hpCapTok=(f.__hpCaptchaToken||""); 1680// stable editor-stamped form id â native lead-magnet routing; "" when unbound 1681var fkey=f.getAttribute("data-persist-form-id")||""; 1682// Classic form uuid re-stamped on migrated forms â per-form scope for carried Classic Zaps 1683var fsrc=f.getAttribute("data-persist-form-source-id")||""; 1684 1685// reCAPTCHA gate (HP-1475): when the page enforces reCAPTCHA but the visitor 1686// hasn't solved the challenge, block the submit with an inline error instead 1687// of POSTing an unverifiable payload the server will reject anyway. Gated on 1688// the enforcement flag (not mere widget presence) so pages with a decorative 1689// widget are unaffected. The server remains the security boundary â it 1690// re-verifies the token against Google. A form that opts into per-form Turnstile 1691// (data-persist-captcha) is handled by the Turnstile gate below instead â skip it here so a 1692// Turnstile form on a reCAPTCHA-enforced page isn't blocked for lacking a reCAPTCHA token 1693// (mirrors the server, which skips its reCAPTCHA block for forms in captchaFormIds). 1694var captchaToken=_hpCaptchaToken(f); 1695if(_hpRecaptchaEnforced&&!captchaToken&&!(f.hasAttribute&&f.hasAttribute("data-persist-captcha"))){ 1696if(btn)btn.disabled=false; 1697showNotice(f,"Please complete the reCAPTCHA challenge before submitting.",false); 1698reportFail("captcha_required",0,fident); 1699return; 1700} 1701 1702if(inSandbox){ 1703var id="fs-"+(++corrId); 1704var timer=setTimeout(function(){ 1705delete pendingCallbacks[id]; 1706if(btn)btn.disabled=false; 1707reportFail("timeout",0,fident);showMsg(f,false); 1708},10000); 1709 1710pendingCallbacks[id]=function(success,captchaRequired,errMsg){ 1711clearTimeout(timer); 1712if(btn)btn.disabled=false; 1713if(success){setEmailCookie(data);_hpFireLead(fident,data,eventId);showMsg(f,true);if(typeof f.reset==="function")f.reset();maybeRedirect(f);} 1714else if(captchaRequired){showMsg(f,false,"Spam protection is still verifying â please wait a moment and submit again.");} 1715else{reportFail("relay_error",0,fident);showMsg(f,false,errMsg||undefined);} 1716}; 1717 1718var vid="";try{vid=localStorage.getItem("_hp_vid")||"";}catch(e){} 1719parent.postMessage({type:"htmlpub-form-submit",correlationId:id,pageId:pageId,pageSlug:slug,formIdentifier:fident,formKey:fkey,classicFormUuid:fsrc,data:data,visitorId:vid,eventId:eventId,captchaToken:(_hpCapTok||captchaToken),formNeedsCaptcha:!!(f.hasAttribute&&f.hasAttribute("data-persist-captcha"))},"*"); 1720}else{ 1721// Send in top-level (direct fetch) mode. Wrapped in _hpProceed so it can be deferred 1722// until the Turnstile token has had a bounded chance to arrive (see _hpWaitForCaptcha). 1723function _hpProceed(){ 1724var vid2="";try{vid2=localStorage.getItem("_hp_vid")||"";}catch(e){} 1725// The captcha token rides EVERY attempt (not just the first). It is single-use, 1726// but the server checks its verified-eventId cache before re-verifying, so a 1727// re-sent already-spent token short-circuits to pass, while a token that was never 1728// spent (attempt 0 failed at the transport / rate-limit layer before verify) still 1729// verifies on retry. Stripping it from retries silently dropped leads on captcha 1730// forms whenever attempt 0 hit a 429/5xx/network error. [HP-2467] Prefer the per-form 1731// Turnstile token; fall back to the reCAPTCHA token (a form uses one mechanism). Read 1732// the FRESHEST token here â it may have arrived via postMessage during the bounded wait, 1733// after _hpCapTok was snapshotted at the top of submitData. 1734var _capTok=(f.__hpCaptchaToken||"")||_hpCapTok||captchaToken; 1735var _hpBody=JSON.stringify({pageId:pageId,pageSlug:slug,formIdentifier:fident,formKey:fkey,classicFormUuid:fsrc,data:data,visitorId:vid2,eventId:eventId,captchaToken:_capTok}); 1736// Retry transient failures so a network blip / 5xx / 429 doesn't silently lose 1737// a lead. Every attempt reuses the same eventId, so the server dedups to 1738// exactly one row + one set of side-effects. Backoff: immediate, 15s, 30s, 1739// 2min, then give up. Failed submits don't redirect, so the page stays open 1740// and the delayed retries can run. The submit button stays disabled across 1741// retries to prevent a manual re-submit (which would mint a new eventId). [HP-2467] 1742var _hpDelays=[0,15000,30000,120000]; 1743function _hpAttempt(n){ 1744fetch(api,{method:"POST",keepalive:true,headers:{"Content-Type":"application/json"},body:_hpBody}).then(function(r){ 1745if(r.ok){if(btn)btn.disabled=false;setEmailCookie(data);_hpFireLead(fident,data,eventId);showMsg(f,true);if(typeof f.reset==="function")f.reset(); 1746// The Turnstile token is single-use; after a successful submit clear it and reload the bridge 1747// iframe so a subsequent submit on the same (non-reset / non-redirected) form gets a FRESH token 1748// instead of re-sending the spent one and being rejected. 1749try{f.__hpCaptchaToken="";f.__hpCaptchaError=false;var _hpCf=f.querySelector("iframe[data-hp-turnstile]");if(_hpCf)_hpCf.src=_hpCf.src;}catch(e){} 1750maybeRedirect(f);return;} 1751if((r.status>=500||r.status===429)&&n<_hpDelays.length){setTimeout(function(){_hpAttempt(n+1);},_hpDelays[n]);return;} 1752if(btn)btn.disabled=false;reportFail("http_error",r.status,fident); 1753// A 403 on a captcha-protected form means the token was spent/invalid â refresh the widget 1754// so a retry gets a fresh one (otherwise "try again" resends the same rejected token).
1755try{if(r.status===403){f.__hpCaptchaToken="";f.__hpCaptchaError=false;var _hpCfe=f.querySelector("iframe[data-hp-turnstile]");if(_hpCfe)_hpCfe.src=_hpCfe.src;}}catch(e){} 1756// Surface the server's specific message (e.g. "CAPTCHA verification failed") instead of a 1757// generic one; fall back to the generic message when the body isn't JSON. 1758try{r.json().then(function(d){showMsg(f,false,(d&&d.error)||undefined);},function(){showMsg(f,false);});}catch(e){showMsg(f,false);} 1759}).catch(function(){ 1760if(n<_hpDelays.length){setTimeout(function(){_hpAttempt(n+1);},_hpDelays[n]);return;} 1761if(btn)btn.disabled=false;reportFail("network",0,fident);showMsg(f,false); 1762}); 1763} 1764_hpAttempt(0); 1765} 1766// Spam-protected form whose Turnstile widget exists but hasn't produced a token yet 1767// (fast submit, or the challenge is still loading). Per the decided fail policy, give the 1768// widget a bounded window plus ONE re-challenge, then submit anyway (the server accepts + 1769// flags unverified â never lose a lead) instead of the old "please submit again" dead end. 1770// Skip the wait when: owner preview (widget is owner-gated), a token is already in hand, the 1771// bridge reported a TERMINAL error (no token could ever come), or no widget is present (site 1772// key unconfigured â nothing to wait for, server fails open). The button stays disabled 1773// during the (sub-second-typical, 6s-worst-case) wait so the visitor can't double-submit. 1774if(_hpIsOwner||_hpCapTok!==""||f.__hpCaptchaError||!f.querySelector("iframe[data-hp-turnstile]")){ 1775_hpProceed(); 1776}else{ 1777_hpWaitForCaptcha(f,_hpProceed); 1778} 1779} 1780} 1781 1782// A pasted third-party form the migration kept (data-persist-third-party-form: Kit, Mailchimp, 1783// PayPal, ...) submits to its own action, natively or through the vendor's script, so it is never 1784// cancelled. Its lead is also sent here, fire-and-forget: keepalive survives the navigation, and 1785// there is no UI, retry or redirect because the page is leaving and the vendor owns the outcome. 1786function _hpBackgroundCapture(f,data){ 1787var eventId=_hpUuid(); 1788var fident=formId(f); 1789var vid="";try{vid=localStorage.getItem("_hp_vid")||"";}catch(e){} 1790var msg={pageId:pageId,pageSlug:slug,formIdentifier:fident,formKey:f.getAttribute("data-persist-form-id")||"",classicFormUuid:f.getAttribute("data-persist-form-source-id")||"",data:data,visitorId:vid,eventId:eventId,captchaToken:_hpCaptchaToken(f)}; 1791try{ 1792if(inSandbox){msg.type="htmlpub-form-submit";msg.correlationId="fs-"+(++corrId);parent.postMessage(msg,"*");} 1793else{fetch(api,{method:"POST",keepalive:true,headers:{"Content-Type":"application/json"},body:JSON.stringify(msg)}).catch(function(){reportFail("network",0,fident);});} 1794setEmailCookie(data);_hpFireLead(fident,data,eventId); 1795}catch(e){} 1796} 1797 1798// Snapshot forms in the server-rendered HTML at init; third-party embeds (ConvertKit/ActiveCampaign) inject their <form> later, so they're excluded. 1799var _hpNativeForms=(function(){try{return new WeakSet([].slice.call(document.querySelectorAll("form")));}catch(e){return null;}})(); 1800document.addEventListener("submit",function(e){ 1801var f=e.target; 1802if(f.tagName!=="FORM")return; 1803if(_hpNativeForms&&!_hpNativeForms.has(f)){reportFail("skipped_non_native",0,formId(f));return;}// injected after init (popup/third-party) â don't hijack it; telemeter so silently-dropped non-native submits are visible [HP-2467] 1804if(f.hasAttribute("data-persist-third-party-form")){if(window.htmlpubEditorMode||(_hpIsOwner&&!_hpIsPreview)){e.preventDefault();e.stopPropagation();return;}// edit canvas: no navigation, and stopPropagation keeps the vendor script (Kit, ...) from posting a real subscriber 1805var tpData=collect(f);if(Object.keys(tpData).length)_hpBackgroundCapture(f,tpData);return;} 1806e.preventDefault(); 1807// Multi-step: if this form is split into steps and the visitor isn't on the last 1808// one yet, advance (validate current step + Next) instead of submitting. Captures 1809// Enter-in-field and any early submit so a partial lead is never sent. On the last 1810// step, validate it in JS first (the form is novalidate, so nothing else does). [P3] 1811if(f.__hpMultiStep){ 1812if(!f.__hpMultiStep.isLast()){f.__hpMultiStep.advance();return;} 1813if(f.__hpMultiStep.validate&&!f.__hpMultiStep.validate())return; 1814} 1815// "Select at least one" enforcement for required checkbox groups (single-step forms have no 1816// __hpMultiStep validate pass, and it's a cheap final guard for multi-step too). 1817if(!_hpGroupReqOK(f))return; 1818var data=collect(f); 1819if(Object.keys(data).length===0){if(f.querySelector&&f.querySelector("input,textarea,select"))reportFail("skipped_empty",0,formId(f));return;}// nothing collected â telemeter only when the form actually has fields (real form, nothing captured) [HP-2467] 1820submitData(f,data); 1821},true); 1822 1823// --- Fake form interception (inputs outside <form>) --- 1824var lastFakeSubmit=0; 1825// Walk up from the clicked element to find a "form-like" container:
1826// - has 1-20 inputs/textareas/selects (excluding hidden/submit/button types) 1827// - has â¤4 button-like descendants (more = page scaffolding, not a form) 1828// - the clicked element sits AFTER the last input in document order 1829// (real submits sit below fields; uses DOCUMENT_POSITION_PRECEDING = 2) 1830// Walk depth capped at 3 â further up is <section>, <main>, or <body>. 1831function findFormLikeContainer(t){ 1832var cur=t.parentElement; 1833for(var depth=0;cur&&depth<3;depth++){ 1834if(cur.tagName==="FORM")return null;// real form, skip 1835var inputs=cur.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]),textarea,select"); 1836if(inputs.length>=1&&inputs.length<=20){ 1837var ctas=cur.querySelectorAll("button,a[href],[role=button],input[type=submit]"); 1838if(ctas.length<=4){ 1839return (t.compareDocumentPosition(inputs[inputs.length-1])&2)?cur:null; 1840} 1841} 1842cur=cur.parentElement; 1843} 1844return null; 1845} 1846// <a> with a real href is navigation, never a submit. Allow empty href / "#" / 1847// no href (button-styled anchors). <button type="button"> is explicitly non-submit. 1848function isPlausibleSubmit(t){ 1849if(t.tagName==="A"){ 1850var href=t.getAttribute("href"); 1851if(href!==null){ 1852href=(href+"").trim(); 1853if(href!==""&&href!=="#")return false; 1854} 1855} 1856if(t.tagName==="BUTTON"&&t.getAttribute("type")==="button")return false; 1857return true; 1858} 1859document.addEventListener("click",function(e){ 1860var t=e.target.closest?e.target.closest("button,a,[role=button],input[type=submit]"):null; 1861if(!t)return; 1862if(t.closest("form"))return;// inside a real form, native handler covers it 1863// Third-party consent-management (CMP) dialogs (Cookiebot/OneTrust/Osano/ 1864// TrustArc/CookieYes) inject a body-level dialog whose "Accept" button + single 1865// pre-checked "necessary" checkbox otherwise look form-like. Cheap early-exit 1866// for known vendors; the lead-field guard below is the load-bearing check. 1867// Vendor tokens are boundary-anchored (CookieYes cky-* matched as whole class 1868// tokens; TrustArc truste-/truste_/trustarc) so they never collide with 1869// sticky-*/trusted* names. [HP-3292] 1870try{if(t.closest("[id*='Cookiebot' i],[id*='CybotCookiebot' i],[id*='onetrust' i],[id*='osano' i],[id*='truste-' i],[id*='truste_' i],[id*='trustarc' i],[class*='cookie-consent' i],[class*='cookieconsent' i],[class^='cky-' i],[class*=' cky-' i]"))return;}catch(e){} 1871if(!isPlausibleSubmit(t))return; 1872var now=Date.now(); 1873if(now-lastFakeSubmit<2000)return; 1874var container=findFormLikeContainer(t); 1875if(!container)return; 1876// A genuine lead always carries at least one typed (non checkbox/radio) field 1877// with a value. A form-like container whose only populated field is a 1878// checkbox/radio is a consent/cookie dialog, not a form â never fire a lead for 1879// it. Keys off DOM field TYPE, never payload values, so real leads whose data 1880// merely contains vendor-name substrings are unaffected. [HP-3292] 1881var _leadFields=container.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]):not
1881([type=checkbox]):not([type=radio]),textarea,select"); 1882var _hasLeadField=false; 1883for(var _lf=0;_lf<_leadFields.length;_lf++){if((_leadFields[_lf].value||"").trim()){_hasLeadField=true;break;}} 1884if(!_hasLeadField)return; 1885var data=collect(container); 1886if(Object.keys(data).length===0)return; 1887// Skip if all field values are empty (button click near empty form fields) 1888var hasAnyValue=Object.keys(data).some(function(k){return data[k]&&String(data[k]).trim();}); 1889if(!hasAnyValue)return; 1890lastFakeSubmit=now; 1891container.setAttribute("data-htmlpub-fake-form",""); 1892submitData(container,data); 1893// Notify analytics script about this fake form submission 1894try{document.dispatchEvent(new CustomEvent("htmlpub:form-submit",{detail:{formIdentifier:formId(container)}}));}catch(e){} 1895},true); 1896 1897// Always report form count to parent (even for non-owner views) 1898function reportFormCount(){ 1899var forms=document.querySelectorAll("form"); 1900var count=forms.length; 1901var labels=[]; 1902for(var i=0;i<forms.length;i++){ 1903var ff=forms[i]; 1904var label=ff.getAttribute("aria-label")||ff.getAttribute("name")||ff.getAttribute("id")||""; 1905if(!label){ 1906var heading=ff.querySelector("h1,h2,h3,h4,h5,h6,label,legend"); 1907if(heading) label=heading.textContent.trim().substring(0,50); 1908} 1909labels.push(label||("Form "+(i+1))); 1910} 1911// needsCaptcha tells the parent shell (sandbox path) to render a Turnstile 1912// widget on its own origin: the sandboxed iframe has an opaque origin so it 1913// can't run Turnstile itself. Owner previews never require the token. 1914var _hpNeedsCaptcha=!_hpIsOwner&&!!document.querySelector("form[data-persist-captcha]"); 1915if(inSandbox) parent.postMessage({type:"htmlpub-form-count",count:count,labels:labels,needsCaptcha:_hpNeedsCaptcha},"*"); 1916} 1917function initFormCount(){ 1918reportFormCount(); 1919if(typeof MutationObserver!=="undefined"){ 1920new MutationObserver(function(){reportFormCount();}).observe(document.body||document.documentElement,{childList:true,subtree:true}); 1921} 1922} 1923if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",initFormCount);} 1924else{initFormCount();} 1925 1926// Leadbox/leadbar iframe form bridge. SYNC with worker-pages/src/scripts/forms.ts. 1927window.addEventListener("message",function(e){ 1928if(!e||!e.data||e.data.type!=="htmlpub:iframe-form-submit")return; 1929var fid=String(e.data.formIdentifier||"iframe-form").slice(0,255); 1930var fsid=e.data.formSourceId?String(e.data.formSourceId).slice(0,255):""; 1931var raw=e.data.data; 1932if(!raw||typeof raw!=="object")return; 1933var clean={}; 1934for(var k in raw){ 1935if(!Object.prototype.hasOwnProperty.call(raw,k))continue; 1936if(looksLikeRawId(k))continue; 1937if(_blocklist.test(k))continue; 1938clean[k]=raw[k]; 1939} 1940var redirect=typeof e.data.redirect==="string"?e.data.redirect:""; 1941var relayCaptcha=typeof e.data.captchaToken==="string"?e.data.captchaToken:undefined; 1942fetch(api,{method:"POST",keepalive:true,headers:{"Content-Type":"application/json"},body:JSON.stringify({pageId:pageId,pageSlug:slug,formIdentifier:fid,formSourceId:fsid,data:clean,captchaToken:relayCaptcha})}).then(function(r){if(r.ok){if(redirect)maybeRedirectUrl(redirect);}else{reportFail("http_error",r.status,fid);}}).catch(function(){reportFail("network",0,fid);}); 1943},false); 1944})(); 1945</script>
1945<script data-htmlpub-popups> 1946(function(){ 1947"use strict"; 1948 1949var SCOPE = "pathname"; 1950var PAGE_ID = "rm90klif4ys3o04jw9zgjfpw"; 1951 1952var DEFAULTS = { 1953 overlay: { color: "#000000", opacity: 0.5, clickToClose: true }, 1954 close: { enabled: true, position: "top-right", escapeToClose: true }, 1955 animation: { open: "fade", close: "fade", durationMs: 250 }, 1956 frequency: { visitsBefore: 0, daysCooldown: 0 } 1957}; 1958 1959function parseJsonAttr(raw, fallback) { 1960 if (!raw) return fallback; 1961 try { 1962 var p = JSON.parse(raw); 1963 if (p && typeof p === "object") return p; 1964 } catch(e) {} 1965 return fallback; 1966} 1967 1968// Back-compat: pages published before the data-persist-popup* rename carry the 1969// legacy data-htmlpub-popup* names. Read canonical first, fall back to legacy. 1970// suffix is "" for the marker, "-name", "-overlay", "-target", etc. 1971function pAttr(el, suffix) { 1972 var v = el.getAttribute("data-persist-popup" + suffix); 1973 return v !== null ? v : el.getAttribute("data-htmlpub-popup" + suffix); 1974} 1975 1976function readConfig(el) { 1977 var overlay = parseJsonAttr(pAttr(el, "-overlay"), {}); 1978 var closeCfg = parseJsonAttr(pAttr(el, "-close"), {}); 1979 var anim = parseJsonAttr(pAttr(el, "-animation"), {}); 1980 var triggers = parseJsonAttr(pAttr(el, "-triggers"), {}); 1981 var frequency = parseJsonAttr(pAttr(el, "-frequency"), {}); 1982 return { 1983 overlay: { 1984 color: typeof overlay.color === "string" ? overlay.color : DEFAULTS.overlay.color, 1985 opacity: typeof overlay.opacity === "number" ? overlay.opacity : DEFAULTS.overlay.opacity, 1986 clickToClose: overlay.clickToClose !== false 1987 }, 1988 close: { 1989 enabled: closeCfg.enabled !== false, 1990 position: closeCfg.position === "top-left" ? "top-left" : "top-right", 1991 escapeToClose: closeCfg.escapeToClose !== false 1992 }, 1993 animation: { 1994 open: typeof anim.open === "string" ? anim.open : DEFAULTS.animation.open, 1995 close: typeof anim.close === "string" ? anim.close : DEFAULTS.animation.close, 1996 durationMs: typeof anim.durationMs === "number" ? anim.durationMs : DEFAULTS.animation.durationMs 1997 }, 1998 triggers: triggers, 1999 frequency: { 2000 visitsBefore: typeof frequency.visitsBefore === "number" ? frequency.visitsBefore : 0, 2001 daysCooldown: typeof frequency.daysCooldown === "number" ? frequency.daysCooldown : 0 2002 }, 2003 // "none" = the marker's content owns its own chrome, so this runtime paints none of it. 2004 // Migrated Classic pop-ups: the marker holds an <iframe> whose document already draws the 2005 // mask, centring, card and close button. Nova-authored pop-ups never set it. 2006 chrome: pAttr(el, "-chrome") === "none" ? "none" : "default" 2007 }; 2008} 2009 2010// ââ LocalStorage helpers â scoped per pathname so two pages on the same 2011// origin track their popups independently. Wrapped in try/catch because 2012// Safari private mode + iframe sandboxing can throw on access. 2013var STORE_PREFIX = SCOPE === "popup-id" ? "_hpup:popup:" : ("_hpup:" + (location.pathname || "/") + ":"); 2014function lsGet(key) { 2015 try { return localStorage.getItem(STORE_PREFIX + key); } catch(e) { return null; } 2016} 2017function lsSet(key, value) { 2018 try { localStorage.setItem(STORE_PREFIX + key, value); } catch(e) {} 2019} 2020 2021// Bump the visit counter once per pageload. Used by visitsBefore. In embed 2022// (popup-id) scope callers pass the popup id as a suffix so each standalone 2023// popup tracks its own visit count â a busy host page with two embedded popups 2024// must not let one popup's pageviews satisfy another's visitsBefore gate. 2025function recordVisit(suffix) { 2026 var key = "visits" + (suffix ? ":" + suffix : ""); 2027 var prev = parseInt(lsGet(key) || "0", 10); 2028 if (!isFinite(prev) || prev < 0) prev = 0; 2029 lsSet(key, String(prev + 1)); 2030 return prev + 1; 2031} 2032 2033function isEligible(cfg, popupId, currentVisit) { 2034 // visitsBefore: 0 = show on first visit. So eligible iff currentVisit > visitsBefore. 2035 if (cfg.frequency.visitsBefore > 0 && currentVisit <= cfg.frequency.visitsBefore) return false; 2036 // daysCooldown: don't reshow until N days have passed since last close. 2037 if (cfg.frequency.daysCooldown > 0) { 2038 var last = parseInt(lsGet("close:" + popupId) || "0", 10); 2039 if (last > 0) { 2040 var elapsedMs = Date.now() - last; 2041 if (elapsedMs < cfg.frequency.daysCooldown * 86400000) return false;
2042 } 2043 } 2044 return true; 2045} 2046 2047function composeRgba(color, opacity) { 2048 var hex = color.match(/^#([0-9a-f]{6})$/i); 2049 if (hex) { 2050 var n = parseInt(hex[1], 16); 2051 return "rgba(" + ((n >> 16) & 0xff) + "," + ((n >> 8) & 0xff) + "," + (n & 0xff) + "," + opacity + ")"; 2052 } 2053 var rgb = color.match(/^rgb(?:a)?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); 2054 if (rgb) return "rgba(" + rgb[1] + "," + rgb[2] + "," + rgb[3] + "," + opacity + ")"; 2055 return "rgba(0,0,0," + opacity + ")"; 2056} 2057 2058function animStart(kind) { 2059 if (kind === "slide-up") return { opacity: 0, transform: "translate(-50%, -40%)" }; 2060 if (kind === "slide-down") return { opacity: 0, transform: "translate(-50%, -60%)" }; 2061 if (kind === "zoom") return { opacity: 0, transform: "translate(-50%, -50%) scale(0.92)" }; 2062 if (kind === "none") return { opacity: 1, transform: "translate(-50%, -50%)" }; 2063 return { opacity: 0, transform: "translate(-50%, -50%)" }; // fade 2064} 2065function animEnd() { 2066 return { opacity: 1, transform: "translate(-50%, -50%) scale(1)" }; 2067} 2068 2069// Body scroll lock â refcounted because two popups can technically be in 2070// the open state at once (different triggers firing back-to-back). The 2071// first lock saves the body's prior overflow + padding-right and replaces 2072// them; subsequent locks just bump the count. We only restore once every 2073// popup that locked has closed, so a still-open popup keeps the page held. 2074// padding-right compensates for the scrollbar disappearing â without it, 2075// the dimmed page behind the overlay shifts right at the moment of open. 2076var _hpupLockCount = 0; 2077var _hpupSavedOverflow = ""; 2078var _hpupSavedPaddingRight = ""; 2079function lockBodyScroll() { 2080 if (_hpupLockCount === 0) { 2081 var scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; 2082 _hpupSavedOverflow = document.body.style.overflow || ""; 2083 _hpupSavedPaddingRight = document.body.style.paddingRight || ""; 2084 if (scrollbarWidth > 0) document.body.style.paddingRight = scrollbarWidth + "px"; 2085 document.body.style.overflow = "hidden"; 2086 } 2087 _hpupLockCount++; 2088} 2089function unlockBodyScroll() { 2090 if (_hpupLockCount === 0) return; 2091 _hpupLockCount--; 2092 if (_hpupLockCount === 0) { 2093 if (_hpupSavedOverflow) document.body.style.overflow = _hpupSavedOverflow; 2094 else document.body.style.removeProperty("overflow"); 2095 if (_hpupSavedPaddingRight) document.body.style.paddingRight = _hpupSavedPaddingRight; 2096 else document.body.style.removeProperty("padding-right"); 2097 } 2098} 2099 2100// Build the overlay backdrop. Returns the element. Wired with the right 2101// pointer behaviour for the popup's clickToClose setting. 2102function buildOverlay(cfg, onOverlayClick) { 2103 var ov = document.createElement("div"); 2104 ov.setAttribute("data-htmlpub-popup-backdrop", ""); 2105 ov.style.cssText = "position:fixed;inset:0;background:" + composeRgba(cfg.overlay.color, cfg.overlay.opacity) + 2106 ";z-index:2147483646;opacity:0;transition:opacity " + cfg.animation.durationMs + "ms ease;"; 2107 if (cfg.overlay.clickToClose) ov.addEventListener("click", onOverlayClick); 2108 return ov; 2109} 2110 2111// Inject a close â button if the popup doesn't already have one. We mark our 2112// injected button so re-show calls don't duplicate it. 2113function ensureCloseButton(el, cfg, onClose) { 2114 if (el.querySelector("[data-htmlpub-popup-close-btn]")) return; 2115 if (!cfg.close.enabled) return; 2116 var btn = document.createElement("button"); 2117 btn.setAttribute("type", "button"); 2118 btn.setAttribute("aria-label", "Close pop-up"); 2119 btn.setAttribute("data-htmlpub-popup-close-btn", ""); 2120 var posStyle = cfg.close.position === "top-left" ? "left:12px;" : "right:12px;"; 2121 btn.style.cssText = "position:absolute;top:12px;" + posStyle + 2122 "width:28px;height:28px;border-radius:9999px;border:0;background:rgba(0,0,0,0.45);color:#fff;" + 2123 "font:16px/1 system-ui,-apple-system,sans-serif;cursor:pointer;display:flex;align-items:center;" + 2124 "justify-content:center;z-index:1;padding:0;"; 2125 btn.textContent = "Ã"; 2126 btn.addEventListener("click", function(e){ e.preventDefault(); e.stopPropagation(); onClose(); }); 2127 // Ensure the popup itself is a positioning context so absolute close button 2128 // anchors to it. Cheap: only sets if not already positioned. 2129 var pos = getComputedStyle(el).position; 2130 if (pos === "static" || !pos) el.style.position = "relative"; 2131 el.appendChild(btn); 2132} 2133 2134// ââ Per-popup wiring ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2135// Each popup gets its own controller closing over its element + config + 2136// trigger cleanup so we can tear down listeners on close. 2137 2138// HP-1557 â fire an analytics event for this popup. Hands off to the 2139// analytics.ts buffer via window.__hpupPushEvent (registered there). If 2140// analytics isn't loaded for some reason we silently no-op rather than 2141// breaking the popup runtime. 2142function emitPopupEvent(eventType, popup, extras) { 2143 if (typeof window.__hpupPushEvent !== "function") return; 2144 var payload = { 2145 popupId: popup.getAttribute("id") || "", 2146 popupName: (pAttr(popup, "-name") || "").replace(/^\s+|\s+$/g, "") 2147 }; 2148 // Standalone-embed pop-ups carry their page id via data-hp-popup-cuid (set by 2149 // the embed bundle) so analytics can attribute to a specific pop-up; the DOM 2150 // id in popupId is a shared name-slug. In-page pop-ups have no cuid so this 2151 // stays absent. 2152 var _hpCuid = popup.getAttribute("data-hp-popup-cuid"); 2153 if (_hpCuid) payload.popupCuid = _hpCuid; 2154 if (extras && typeof extras === "object") { 2155 for (var k in extras) { 2156 if (Object.prototype.hasOwnProperty.call(extras, k)) payload[k] = extras[k]; 2157 } 2158 } 2159 window.__hpupPushEvent(eventType, payload); 2160} 2161 2162// Single-open invariant: only one popup may be visible at a time. 2163// show() force-closes whatever is up before painting itself. 2164var _hpupCurrentOpen = null; 2165 2166function wirePopup(el, currentVisit) { 2167 var cfg = readConfig(el); 2168 var popupId = el.getAttribute("id") || ""; 2169 if (!popupId) return; 2170 // The editor stores popups hidden via inline display:none. We never strip 2171 // that â we toggle it at show/close time so a never-shown popup stays 2172 // invisible (matching its rendered state in the DOM). 2173 var originalDisplay = el.style.display || ""; 2174 var isOpen = false;
2175 var overlay = null; 2176 var keyListener = null; 2177 var sizeObserver = null; 2178 var cleanups = []; // trigger listener removers; called on first show 2179 2180 function cleanupTriggers() { 2181 for (var i = 0; i < cleanups.length; i++) { 2182 try { cleanups[i](); } catch(e) {} 2183 } 2184 cleanups = []; 2185 } 2186 2187 // Why a popup was opened â informs the parent analytics event so the 2188 // dashboard can distinguish "fired by timer" vs. "explicit user click". 2189 // Updated by callers before show() and reset to null after emit. 2190 var lastShowReason = null; 2191 2192 // HP-4147 â the overflow choice in show() is a measurement, and measurements 2193 // go stale. Content can grow AFTER open: an inline checkout's Stripe Payment 2194 // Element progressively reveals card fields and then a "Save your info" 2195 // section as the buyer types. A popup that opened short (overflow:visible, so 2196 // its shadow paints) and then grows past maxHeight spills below the viewport 2197 // instead of scrolling, and because show() locked the body scroll there is 2198 // nothing left for a touch drag to move â the Pay button becomes unreachable 2199 // and closing + reopening is the only way out (reopening re-runs show(), which 2200 // re-measures against the now-tall content and lands on "auto"). So re-decide 2201 // on every resize â but only ever upgrade to "auto", never back to "visible". 2202 // 2203 // One-way on purpose. Reverting to "visible" when content shrinks back under 2204 // maxHeight sounds harmless (it hands HP-3771 its shadow back) but it 2205 // re-releases horizontal spill that "auto" had been containing, so a wide 2206 // child paints outside the popup over the page, and it discards scrollTop, 2207 // bouncing the buyer to the top of the form. Both were measured in Chromium 2208 // at a Pixel viewport. Staying in "auto" costs only the shadow, and only for 2209 // the rest of an open in which content already exceeded the viewport: "auto" 2210 // paints no scrollbar once content fits again, it merely clips. close() 2211 // strips every inline style, so the next open re-decides from scratch. 2212 function syncOverflow() { 2213 if (!isOpen) return; 2214 // Guarded on a real change. Toggling overflow lays out a scrollbar, which 2215 // resizes content, which re-enters this observer â an unguarded write there 2216 // is a feedback loop that never settles. 2217 if (el.scrollHeight > el.clientHeight + 1 && el.style.overflow !== "auto") { 2218 el.style.overflow = "auto"; 2219 } 2220 } 2221 2222 function observeSize() { 2223 if (sizeObserver || typeof ResizeObserver === "undefined") return; 2224 sizeObserver = new ResizeObserver(syncOverflow); 2225 // Observing el catches the growth that crosses maxHeight, since its own box 2226 // tracks content right up to the clamp. Observing its element children 2227 // catches growth that continues past the clamp, where el's box is pinned and 2228 // reports no resize at all â content 1px under maxHeight that then expands 2229 // would otherwise never re-decide. 2230 sizeObserver.observe(el); 2231 for (var childIndex = 0; childIndex < el.children.length; childIndex++) { 2232 sizeObserver.observe(el.children[childIndex]); 2233 } 2234 } 2235 2236 function unobserveSize() { 2237 if (!sizeObserver) return; 2238 sizeObserver.disconnect(); 2239 sizeObserver = null; 2240 } 2241 2242 function show(reason) { 2243 if (isOpen) return; 2244 if (_hpupCurrentOpen && _hpupCurrentOpen !== el && typeof _hpupCurrentOpen._hpupClose === "function") { 2245 _hpupCurrentOpen._hpupClose("replaced", true); 2246 } 2247 _hpupCurrentOpen = el; 2248 isOpen = true; 2249 lastShowReason = reason || "explicit"; 2250 cleanupTriggers(); 2251 lockBodyScroll(); 2252 // chrome:none â the content paints its own â (Classic's .js-close, inside the iframe). 2253 var bare = cfg.chrome === "none"; 2254 if (!bare) ensureCloseButton(el, cfg, function(){ close("close_button"); }); 2255 2256 var start = animStart(cfg.animation.open); 2257 var end = animEnd(); 2258 2259 // Centered fixed positioning with a high z-index so we float above any 2260 // page content + checkout / chat widgets. 2261 el.style.display = "block"; 2262 el.style.position = "fixed"; 2263 el.style.zIndex = "2147483647"; 2264 2265 if (bare) { 2266 // Full-viewport shell. An <iframe> has no intrinsic content height (~150px, and it never 2267 // reports its content size to the parent), so the shrink-wrapped-card model below cannot 2268 // measure it â centring, the maxHeight/maxWidth clamps and the overflow logic all assume a 2269 // box that sizes to its content. Reproduce CDA's model instead: the frame fills the 2270 // viewport and the Classic document inside owns mask, centring and card. 2271 // 2272 // No transform either: every animStart transform carries translate(-50%,-50%), which 2273 // centres a card and would shove a full-viewport shell off screen. Opacity alone. 2274 el.style.top = "0"; 2275 el.style.left = "0"; 2276 el.style.width = "100%"; 2277 el.style.height = "100%"; 2278 el.style.opacity = String(start.opacity); 2279 el.style.transition = "opacity " + cfg.animation.durationMs + "ms ease"; 2280 openClassicFrame(el); 2281 } else { 2282 el.style.top = "50%"; 2283 el.style.left = "50%"; 2284 el.style.transform = start.transform; 2285 el.style.opacity = String(start.opacity); 2286 el.style.maxHeight = "calc(100vh - 32px)"; 2287 el.style.maxWidth = "calc(100vw - 32px)"; 2288 // HP-3771 â "auto" clips any box-shadow set on the popup's own content: 2289 // box-shadow doesn't affect layout size, so this element shrink-wraps to its 2290 // content and its own overflow then clips the shadow it should cast. 2291 // 2292 // But "visible" unconditionally is not safe either. This element is 2293 // position:fixed and centred, so content taller than maxHeight does not 2294 // clip â it spills past the viewport with no scrollbar and nothing can 2295 // reach it. A lead-capture form taller than the viewport would lose its 2296 // submit button. So decide per popup, after layout: overflow stays "auto" 2297 // only when the content genuinely overflows (scrolling wins), and is 2298 // "visible" otherwise (shadow wins). Short popups are the overwhelmingly 2299 // common case, so the shadow renders for almost everyone. 2300 //
2301 // syncOverflow re-applies this same rule for the rest of the open (HP-4147). 2302 el.style.overflow = "visible"; 2303 syncOverflow(); 2304 el.style.transition = "opacity " + cfg.animation.durationMs + "ms ease, transform " + cfg.animation.durationMs + "ms ease"; 2305 } 2306 2307 if (!el.parentNode) document.body.appendChild(el); 2308 if (!bare) { 2309 // Backdrop. Inserted before the popup so the popup naturally paints on top. 2310 // chrome:none skips it â the Classic document paints its own .js-leadbox-mask, and a second 2311 // backdrop would darken the page twice. 2312 overlay = buildOverlay(cfg, function(){ close("overlay_click"); }); 2313 (el.parentNode || document.body).insertBefore(overlay, el); 2314 2315 // Started only once el is attached, so the observer's first delivery measures 2316 // real geometry rather than the 0x0 of a detached node. 2317 observeSize(); 2318 } 2319 2320 // Force reflow so the next style change animates from start, not jumps. 2321 /* eslint-disable-next-line no-unused-expressions */ void el.offsetWidth; 2322 2323 el.style.opacity = String(end.opacity); 2324 if (!bare) el.style.transform = end.transform; 2325 if (overlay) overlay.style.opacity = "1"; 2326 2327 if (cfg.close.escapeToClose) { 2328 keyListener = function(e) { if (e.key === "Escape") close("escape"); }; 2329 document.addEventListener("keydown", keyListener); 2330 } 2331 2332 emitPopupEvent("popup_view", el, { reason: lastShowReason }); 2333 lastShowReason = null; 2334 } 2335 2336 function close(reason, hard) { 2337 if (!isOpen) return; 2338 isOpen = false; 2339 emitPopupEvent("popup_dismiss", el, { reason: reason || "unknown" }); 2340 var start = animStart(cfg.animation.close); 2341 el.style.opacity = String(start.opacity); 2342 // Same reason as show(): the close transform centres a card, and applying it to a 2343 // full-viewport shell would slide it off screen instead of fading it out. 2344 if (cfg.chrome !== "none") el.style.transform = start.transform; 2345 if (overlay) overlay.style.opacity = "0"; 2346 2347 if (keyListener) { 2348 document.removeEventListener("keydown", keyListener); 2349 keyListener = null; 2350 } 2351 2352 var afterAnim = function() { 2353 if (_hpupCurrentOpen === el) _hpupCurrentOpen = null; 2354 unobserveSize(); 2355 el.style.display = originalDisplay || "none"; 2356 el.style.removeProperty("position"); 2357 el.style.removeProperty("top"); 2358 el.style.removeProperty("left"); 2359 el.style.removeProperty("transform"); 2360 el.style.removeProperty("opacity"); 2361 el.style.removeProperty("z-index"); 2362 el.style.removeProperty("max-height"); 2363 el.style.removeProperty("max-width"); 2364 // Only the chrome:none branch sets these, and teardown must remove exactly what this show 2365 // set â a marker can legitimately carry an author's own inline width/height, and stripping 2366 // it on close would resize the pop-up the second time it opens. 2367 if (cfg.chrome === "none") { 2368 el.style.removeProperty("width"); 2369 el.style.removeProperty("height"); 2370 } 2371 el.style.removeProperty("overflow"); 2372 el.style.removeProperty("transition"); 2373 if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); 2374 overlay = null; 2375 // Tear down the close button â its click handler closes over THIS 2376 // show's overlay reference. Leaving it across opens means a second 2377 // show's X would call the previous close() with a stale overlay and 2378 // leave the current overlay floating. 2379 var existingBtn = el.querySelector("[data-htmlpub-popup-close-btn]"); 2380 if (existingBtn && existingBtn.parentNode) existingBtn.parentNode.removeChild(existingBtn); 2381 // Release the scroll lock only after the popup is fully torn down so 2382 // the page doesn't pop back to scrollable while the close animation 2383 // is still painting. 2384 unlockBodyScroll(); 2385 lsSet("close:" + popupId, String(Date.now())); 2386 }; 2387 if (hard || cfg.animation.close === "none") afterAnim(); 2388 else setTimeout(afterAnim, cfg.animation.durationMs); 2389 } 2390 2391 // ââ Triggers ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2392 // Only wire if the popup is eligible to show this pageload (frequency 2393 // rules). Click-to-open via [data-htmlpub-popup-target] always works, 2394 // regardless of eligibility â it's an explicit user action. 2395 var eligible = isEligible(cfg, popupId, currentVisit); 2396 2397 if (eligible) { 2398 var t = cfg.triggers || {}; 2399
2400 if (t.timed && typeof t.timed.delaySec === "number") { 2401 var delayMs = Math.max(0, t.timed.delaySec) * 1000; 2402 var timer = setTimeout(function(){ show("timed"); }, delayMs); 2403 cleanups.push(function(){ clearTimeout(timer); }); 2404 } 2405 2406 if (t.exitIntent === true) { 2407 // Mobile / touch devices don't have a meaningful "exit toward chrome" 2408 // gesture. Standard heuristic: skip if no mouse pointer is available. 2409 if (!matchMedia("(pointer: coarse)").matches) { 2410 var onMove = function(e) { 2411 if (e.clientY < 8) show("exit_intent"); 2412 }; 2413 document.addEventListener("mousemove", onMove); 2414 cleanups.push(function(){ document.removeEventListener("mousemove", onMove); }); 2415 } 2416 } 2417 2418 if (typeof t.scrollPercent === "number" && t.scrollPercent > 0) { 2419 var threshold = Math.max(1, Math.min(100, t.scrollPercent)); 2420 var onScroll = function() { 2421 var doc = document.documentElement; 2422 var scrolled = (window.scrollY || doc.scrollTop || 0); 2423 var max = (doc.scrollHeight - doc.clientHeight) || 1; 2424 var pct = (scrolled / max) * 100; 2425 if (pct >= threshold) show("scroll"); 2426 }; 2427 window.addEventListener("scroll", onScroll, { passive: true }); 2428 cleanups.push(function(){ window.removeEventListener("scroll", onScroll); }); 2429 } 2430 } 2431 2432 // Note: in-popup form submits and CTA clicks are NOT emitted as separate 2433 // popup_* events. They already fire as form_submit / element_click via 2434 // analytics.ts's global listeners, which now attach popupId + popupName 2435 // to the payload so customers can still see which pop-up the conversion 2436 // came from while continuing to track the standard goal types. 2437 2438 // Expose the show function on the element so [data-htmlpub-popup-target] 2439 // clicks (wired globally below) can find and trigger it without re-parsing 2440 // the config. _hpupShow / _hpupClose are deliberately namespaced + ugly to 2441 // avoid colliding with user code. 2442 el._hpupShow = show; 2443 el._hpupClose = close; 2444 // Returned so boot() can flush unfired trigger listeners on pagehide. 2445 return cleanupTriggers; 2446} 2447 2448// A served Classic leadbox renders INACTIVE: its controller only adds js-active to the dialog 2449// and mask when it receives an "open" message, so a frame nobody tells to open is a full-viewport 2450// sheet showing nothing. 2451// 2452// TWO WIRE FORMATS, because there are two leadbox builds in the corpus and they do NOT agree â 2453// measured across all 462 live clones on 2026-09-09, not inferred from one of them (D71): 2454// 2455// · newer build (410 of 462): a handleMessage controller that accepts EITHER the bare string 2456// "open" or {type:"open"}. 2457// · OLDER build (52 of 462): a hand-rolled listener whose whole test is 2458// window.addEventListener('message', function(e){ e && e.data && 'open' === e.data && j.open() }) 2459// read verbatim out of the served bundle. The test 'open' === e.data is a STRING identity check, so 2460// an object payload fails it silently and that pop-up never opens. 2461// 2462// This function used to post ONLY {type:"open"}, and the comment here used to assert both builds 2463// took it. They do not: every old-build pop-up rendered a full-viewport dark sheet with an 2464// invisible modal â no error, no console message, and zero lead capture. So post BOTH forms. The 2465// duplicate is harmless in each direction: the old build ignores the object, and the newer one's 2466// open() just re-adds the class and refocuses. 2467// 2468// The string form is also what Classic's own embed.js sent, which is why the defect stayed hidden 2469// on the two pages that still carried embed.js â its copy opened correctly beside our blank one. 2470// (The close relay below had this right already: it reads Classic's bare-string "close leadbox" 2471// off the wire. Open is the direction that was guessed.) 2472// 2473// Posted on both paths because the ordering is not ours to control: if the frame is already 2474// loaded the immediate post lands, and if it is not, the load listener catches it. Opening twice 2475// is harmless â open() just re-adds the class and refocuses. 2476function openClassicFrame(el) { 2477 var frame = el.querySelector("iframe"); 2478 if (!frame) return; 2479 var post = function() { 2480 // targetOrigin "*": the frame is served sandboxed, so its origin is opaque and cannot be 2481 // named. The payload is a bare open signal carrying nothing worth protecting. 2482 try { 2483 if (!frame.contentWindow) return; 2484 // String FIRST: it is the format the old build needs, and the one that was missing. 2485 frame.contentWindow.postMessage("open", "*"); 2486 frame.contentWindow.postMessage({ type: "open" }, "*"); 2487 } catch(e) {} 2488 }; 2489 post(); 2490 frame.addEventListener("load", post); 2491} 2492 2493// Global close relay. A chrome:none pop-up's â lives INSIDE its iframe (Classic's .js-close), so 2494// the click is invisible to this document â the frame posts htmlpub:leadbox-close instead and we 2495// close the marker holding it. Without this the Classic card hides itself and the full-viewport 2496// iframe stays put, covering the page with an invisible sheet that swallows every click. 2497// 2498// Identify the marker by matching iframe.contentWindow against e.source, NOT by the message's 2499// uuid: a page can carry several migrated pop-ups, and a uuid can be placed more than once. This 2500// is the technique CDA's own injected handler used. 2501function handleCloseRelay(e) { 2502 if (!e || !e.data) return; 2503 // Classic's own leadbox controller posts the bare STRING "close leadbox" to window.parent when 2504 // it closes (300ms after the fade, from its close() method) â read out of the served bundle, not 2505 // guessed. It is already the wire format, so nothing needs injecting into the frame. The 2506 // namespaced form is accepted too, for anything we serve ourselves later. 2507 var type = typeof e.data === "string" ? e.data : e.data.type; 2508 if (type !== "close leadbox" && type !== "htmlpub:leadbox-close") return; 2509 var frames = document.querySelectorAll("[data-persist-popup] iframe, [data-htmlpub-popup] iframe"); 2510 for (var i = 0; i < frames.length; i++) { 2511 if (frames[i].contentWindow !== e.source) continue; 2512 var marker = frames[i].closest("[data-persist-popup], [data-htmlpub-popup]"); 2513 if (marker && typeof marker._hpupClose === "function") marker._hpupClose("close_relay"); 2514 return; 2515 } 2516} 2517 2518// The migrated pop-up runs in a sandboxed cross-origin iframe and cannot read this page's anal
2518ytics 2519// session/visitor ids (opaque origin blocks its storage). Hand them over when the in-frame capture 2520// script asks â matching e.source to one of our own pop-up iframes so we only answer our own frames. 2521// The ids exist only once analytics consent has minted them, so with no consent nothing is sent and 2522// the ingest keeps the lead but skips the conversion event (there is no page_view session to join). 2523// The page id rides along either way, so the lead files under the page that was served, whatever 2524// URL the browser reports. 2525function handleSessionReq(e) { 2526 if (!e || !e.data || e.data.type !== "hp-popup-session-req" || !e.source) return; 2527 var frames = document.querySelectorAll("[data-persist-popup] iframe, [data-htmlpub-popup] iframe"); 2528 var mine = false; 2529 for (var i = 0; i < frames.length; i++) { if (frames[i].contentWindow === e.source) { mine = true; break; } } 2530 if (!mine) return; 2531 var sid = null, vid = null; 2532 try { sid = sessionStorage.getItem("_hp_sid"); } catch(err) {} 2533 try { vid = localStorage.getItem("_hp_vid"); } catch(err) {} 2534 try { e.source.postMessage({ type: "hp-popup-session", sid: sid, vid: vid, pid: PAGE_ID }, "*"); } catch(err) {} 2535} 2536 2537// Global click-to-open dispatcher. Targets carry [data-persist-popup-target] 2538// (or legacy [data-htmlpub-popup-target]) or href="#popup-â¦" (for plain anchors 2539// that the link picker auto-generated). 2540function handleTargetClick(e) { 2541 var t = e.target; 2542 if (!t || !t.closest) return; 2543 var hit = t.closest("[data-persist-popup-target], [data-htmlpub-popup-target], a[href^='#popup-'], a[href^='#modal-'], a[href^='#dialog-']"); 2544 if (!hit) return; 2545 var id = pAttr(hit, "-target"); 2546 if (!id) { 2547 var href = hit.getAttribute("href") || ""; 2548 if (href.charAt(0) === "#") id = href.slice(1); 2549 } 2550 if (!id) return; 2551 var popup = document.getElementById(id); 2552 if (!popup || pAttr(popup, "") === null) return; 2553 e.preventDefault(); 2554 if (typeof popup._hpupShow === "function") popup._hpupShow("click_target"); 2555} 2556 2557// ââ Boot ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2558 2559function flushTeardowns(list) { 2560 for (var j = 0; j < list.length; j++) { 2561 try { list[j](); } catch(e) {} 2562 } 2563} 2564 2565function boot() { 2566 if (SCOPE === "popup-id") { 2567 // Embed mode: each standalone popup ships its OWN copy of this runtime, so 2568 // a single global boot guard would make a second embedded popup bail. Wire 2569 // each popup once via a per-element marker, count visits per popup id, and 2570 // attach the global click / pagehide listeners only once across copies. 2571 var list = document.querySelectorAll("[data-persist-popup],[data-htmlpub-popup]"); 2572 if (!list.length) return; 2573 if (!window.__hpupTeardowns) window.__hpupTeardowns = []; 2574 for (var n = 0; n < list.length; n++) { 2575 var pel = list[n]; 2576 if (pel.__hpupWired) continue; 2577 pel.__hpupWired = true; 2578 var pt = wirePopup(pel, recordVisit(pel.getAttribute("id") || "")); 2579 if (typeof pt === "function") window.__hpupTeardowns.push(pt); 2580 } 2581 if (!window.__hpupClickWired) { 2582 window.__hpupClickWired = true; 2583 document.addEventListener("click", handleTargetClick, false); 2584 window.addEventListener("message", handleCloseRelay, false); 2585 window.addEventListener("message", handleSessionReq, false); 2586 window.addEventListener("pagehide", function(){ flushTeardowns(window.__hpupTeardowns || []); }); 2587 } 2588 return; 2589 } 2590 2591 // In-page mode: one runtime per page wires every popup. Guard against 2592 // double-injection (HMR, accidental double-include). 2593 if (window.__hpupBooted) return; 2594 window.__hpupBooted = true; 2595 var popups = document.querySelectorAll("[data-persist-popup],[data-htmlpub-popup]"); 2596 if (!popups.length) return; 2597 var visit = recordVisit(); 2598 var teardowns = []; 2599 for (var i = 0; i < popups.length; i++) { 2600 var t = wirePopup(popups[i], visit); 2601 if (typeof t === "function") teardowns.push(t); 2602 } 2603 document.addEventListener("click", handleTargetClick, false); 2604 window.addEventListener("message", handleCloseRelay, false); 2605 window.addEventListener("message", handleSessionReq, false); 2606 // Flush unfired trigger listeners on pagehide. 2607 window.addEventListener("pagehide", function(){ flushTeardowns(teardowns); }); 2608} 2609 2610if (document.readyState === "loading") { 2611 document.addEventListener("DOMContentLoaded", boot); 2612} else { 2613 boot(); 2614} 2615 2616})(); 2617</script>
2617<script data-htmlpub-countdown-runtime> 2618(function(){ 2619var nodes=document.querySelectorAll('[data-htmlpub-countdown]'); 2620if(!nodes.length)return; 2621function pad(n){n=String(n);return n.length<2?'0'+n:n;} 2622function setDigit(root,sel,val){var el=root.querySelector(sel);if(el)el.textContent=pad(val);} 2623function tick(node,endMs){ 2624var diff=Math.max(0,endMs-Date.now()); 2625var s=Math.floor(diff/1000); 2626var d=Math.floor(s/86400);s-=d*86400; 2627var h=Math.floor(s/3600);s-=h*3600; 2628var m=Math.floor(s/60);s-=m*60; 2629setDigit(node,'.countdown-days',d); 2630setDigit(node,'.countdown-hours',h); 2631setDigit(node,'.countdown-minutes',m); 2632setDigit(node,'.countdown-seconds',s); 2633if(diff===0){ 2634var redirect=node.getAttribute('data-redirect'); 2635var inFrame=false;try{inFrame=window!==window.top;}catch(e){inFrame=true;} 2636if(redirect&&!inFrame){try{window.location.href=redirect;}catch(e){}} 2637return true; 2638} 2639return false; 2640} 2641function endMsFor(node){ 2642var mode=node.getAttribute('data-htmlpub-countdown'); 2643if(mode==='fixed'){ 2644var target=node.getAttribute('data-target'); 2645var t=target?Date.parse(target):NaN; 2646return isNaN(t)?0:t; 2647} 2648if(mode==='evergreen'){ 2649var dur=parseInt(node.getAttribute('data-duration')||'0',10); 2650if(!dur)return 0; 2651var idx=Array.prototype.indexOf.call(nodes,node); 2652var base='htmlpub_cd_'+(node.getAttribute('data-persist-countdown-id')||node.id||(location.pathname+'|'+dur+'|'+idx)); 2653var ver=parseInt(node.getAttribute('data-persist-countdown-reset')||'0',10)||0; 2654var key=ver>0?base+'|v'+ver:base; 2655if(ver>0){try{for(var li=localStorage.length-1;li>=0;li--){var lk=localStorage.key(li);if(!lk)continue;if(lk===base){localStorage.removeItem(lk);continue;}if(lk.indexOf(base+'|v')===0){var lv=lk.slice(base.length+2);if(/^[0-9]+$/.test(lv)&&parseInt(lv,10)<ver){localStorage.removeItem(lk);}}}}catch(e){}} 2656var first=null; 2657try{first=localStorage.getItem(key);}catch(e){} 2658if(!first){first=String(Date.now());try{localStorage.setItem(key,first);}catch(e){}} 2659return parseInt(first,10)+dur*1000; 2660} 2661return 0; 2662} 2663for(var i=0;i<nodes.length;i++){(function(node){ 2664var endMs=endMsFor(node); 2665if(!endMs)return; 2666if(tick(node,endMs))return; 2667var iv=setInterval(function(){if(tick(node,endMs))clearInterval(iv);},1000); 2668})(nodes[i]);} 2669})(); 2670</script>
2670<script data-text-anim-runtime> 2671(function(){ 2672var nodes=document.querySelectorAll('[data-persist-text-anim]'); 2673if(!nodes.length)return; 2674try{if(window.matchMedia('(prefers-reduced-motion: reduce)').matches)return;}catch(e){} 2675var KEYFRAMES={ 2676fadeIn:[{opacity:0},{opacity:1}], 2677slideUp:[{opacity:0,transform:'translateY(32px)'},{opacity:1,transform:'translateY(0)'}], 2678slideDown:[{opacity:0,transform:'translateY(-32px)'},{opacity:1,transform:'translateY(0)'}], 2679slideLeft:[{opacity:0,transform:'translateX(32px)'},{opacity:1,transform:'translateX(0)'}], 2680slideRight:[{opacity:0,transform:'translateX(-32px)'},{opacity:1,transform:'translateX(0)'}], 2681zoomIn:[{opacity:0,transform:'scale(0.85)'},{opacity:1,transform:'scale(1)'}], 2682blurIn:[{opacity:0,filter:'blur(8px)'},{opacity:1,filter:'blur(0)'}], 2683bounceIn:[{offset:0,opacity:0,transform:'translateY(40px)'},{offset:0.55,opacity:1,transform:'translateY(-8px)'},{offset:0.8,transform:'translateY(4px)'},{offset:1,transform:'translateY(0)'}] 2684}; 2685var DEFAULTS={duration:600,delay:0,easing:'ease-out'}; 2686function readInt(el,attr,fallback,min){ 2687var n=parseInt(el.getAttribute(attr)||'',10); 2688if(!isFinite(n)||n<min)return fallback; 2689return n; 2690} 2691function arm(el){ 2692var type=el.getAttribute('data-persist-text-anim')||'fadeIn'; 2693var frames=KEYFRAMES[type]; 2694if(!frames)return null; 2695var duration=readInt(el,'data-persist-text-anim-duration',DEFAULTS.duration,1); 2696var delay=readInt(el,'data-persist-text-anim-delay',DEFAULTS.delay,0); 2697var easing=el.getAttribute('data-persist-text-anim-easing')||DEFAULTS.easing; 2698try{ 2699var anim=el.animate(frames,{duration:duration,delay:delay,easing:easing,fill:'both'}); 2700anim.pause(); 2701anim.currentTime=0; 2702return anim; 2703}catch(e){return null;} 2704} 2705if('IntersectionObserver' in window){ 2706var io=new IntersectionObserver(function(entries){ 2707for(var i=0;i<entries.length;i++){ 2708var en=entries[i]; 2709if(!en.isIntersecting)continue; 2710io.unobserve(en.target); 2711var armed=en.target._hpTextAnim; 2712if(!armed)continue; 2713en.target._hpTextAnim=null; 2714try{armed.play();}catch(e){} 2715} 2716},{threshold:0.15}); 2717for(var j=0;j<nodes.length;j++){ 2718var pending=arm(nodes[j]); 2719if(!pending)continue; 2720nodes[j]._hpTextAnim=pending; 2721io.observe(nodes[j]); 2722} 2723}else{ 2724for(var k=0;k<nodes.length;k++){ 2725var immediate=arm(nodes[k]); 2726if(immediate)try{immediate.play();}catch(e){} 2727} 2728} 2729})(); 2730</script>
2730<script data-htmlpub-nav> 2731(function(){ 2732"use strict"; 2733 2734function closeAllSubmenus(except) { 2735 var panels = document.querySelectorAll('[data-persist-nav-submenu-panel]'); 2736 for (var i = 0; i < panels.length; i++) { 2737 var panel = panels[i]; 2738 if (panel === except) continue; 2739 panel.classList.remove('is-open'); 2740 var trigger = panel.__hpNavTrigger; 2741 if (trigger) trigger.setAttribute('aria-expanded', 'false'); 2742 } 2743} 2744 2745function wireSubmenus() { 2746 var triggers = document.querySelectorAll('[data-persist-nav-submenu]'); 2747 if (!triggers.length) return; 2748 for (var i = 0; i < triggers.length; i++) { 2749 (function(trigger) { 2750 if (trigger.__hpNavWired) return; 2751 trigger.__hpNavWired = true; 2752 var panelId = trigger.getAttribute('data-persist-nav-submenu'); 2753 var panel = panelId ? document.getElementById(panelId) : (trigger.parentElement ? trigger.parentElement.querySelector('[data-persist-nav-submenu-panel]') : null); 2754 if (!panel) return; 2755 panel.__hpNavTrigger = trigger; 2756 trigger.setAttribute('aria-haspopup', 'true'); 2757 trigger.setAttribute('aria-expanded', 'false'); 2758 trigger.addEventListener('click', function(e) { 2759 var isOpen = panel.classList.contains('is-open'); 2760 e.preventDefault(); 2761 e.stopPropagation(); 2762 closeAllSubmenus(isOpen ? null : panel); 2763 panel.classList.toggle('is-open', !isOpen); 2764 trigger.setAttribute('aria-expanded', isOpen ? 'false' : 'true'); 2765 }); 2766 })(triggers[i]); 2767 } 2768 document.addEventListener('click', function(e) { 2769 var t = e.target; 2770 if (t && t.closest && t.closest('[data-persist-nav-submenu-panel], [data-persist-nav-submenu]')) return; 2771 closeAllSubmenus(null); 2772 }); 2773} 2774 2775function wireMobileMenu() { 2776 var toggles = document.querySelectorAll('[data-persist-nav-mobile-toggle], [data-persist-nav-tablet-toggle]'); 2777 if (!toggles.length) return; 2778 for (var i = 0; i < toggles.length; i++) { 2779 (function(toggle) { 2780 if (toggle.__hpNavWired) return; 2781 toggle.__hpNavWired = true; 2782 var isTablet = toggle.hasAttribute('data-persist-nav-tablet-toggle'); 2783 var targetId = isTablet ? toggle.getAttribute('data-persist-nav-tablet-toggle') : toggle.getAttribute('data-persist-nav-mobile-toggle'); 2784 var panelSelector = isTablet ? '[data-persist-nav-tablet-panel]' : '[data-persist-nav-mobile-panel]'; 2785 var panel = targetId ? document.getElementById(targetId) : (toggle.parentElement ? toggle.parentElement.querySelector(panelSelector) : null); 2786 if (!panel) return; 2787 toggle.setAttribute('aria-expanded', 'false'); 2788 toggle.addEventListener('click', function(e) { 2789 var isOpen = panel.classList.contains('is-open'); 2790 e.preventDefault(); 2791 e.stopPropagation(); 2792 if (isOpen) panel.classList.remove('is-open'); else panel.classList.add('is-open'); 2793 toggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true'); 2794 }); 2795 })(toggles[i]); 2796 } 2797} 2798 2799function wireScrollBackground() { 2800 // Every nav this system manages, not just ones with scroll-bg/blur 2801 // currently set â a page published while an OLDER build still applied 2802 // blur directly to nav carries that inline backdrop-filter baked into 2803 // its served HTML even after blur is later turned off, so the self-heal 2804 // below has to run regardless of the CURRENT attribute state. 2805 var navs = document.querySelectorAll('[data-persist-nav-id]'); 2806 if (!navs.length) return; 2807 for (var i = 0; i < navs.length; i++) { 2808 (function(nav) { 2809 // Self-heal: strip a stale inline backdrop-filter baked into a page 2810 // saved before this fix (HP-3558 round 15 follow-up) â see the 2811 // dedicated blur-layer comment below for why it must never live on 2812 // nav itself. 2813 if (nav.style.getPropertyValue('backdrop-filter') || nav.style.getPropertyValue('-webkit-backdrop-filter')) { 2814 nav.style.removeProperty('backdrop-filter'); 2815 nav.style.removeProperty('-webkit-backdrop-filter'); 2816 } 2817 // "Transparent" (data-persist-nav-always-transparent, HP-3558 round 2818 // 16) â the non-sticky counterpart of scroll-bg above. "Useful over a 2819 // hero image" only holds if nav visually overlaps whatever follows 2820 // it, so lift it out of flow the same way Sticky's own 2821 // position:fixed/sticky already does, rather than leaving it 2822 // position:static (which just reveals blank page background above 2823 // the hero, not the hero itself â see iframe-script/utils.ts's
2824 // ensureNavScrollBackground for the matching editor-side comment). 2825 var alwaysTransparent = nav.hasAttribute('data-persist-nav-always-transparent'); 2826 if (alwaysTransparent) { 2827 var navCsForOverlay = window.getComputedStyle(nav); 2828 if (!navCsForOverlay || (navCsForOverlay.position !== 'fixed' && navCsForOverlay.position !== 'sticky')) { 2829 nav.style.position = 'absolute'; 2830 nav.style.top = '0px'; 2831 nav.style.left = '0px'; 2832 nav.style.right = '0px'; 2833 if (!nav.style.zIndex) nav.style.zIndex = '1000'; 2834 } 2835 } 2836 // Blur needs no scroll-reactivity â a blurred OPAQUE background 2837 // looks identical to an unblurred one, so it's simplest and correct 2838 // to apply it once, unconditionally, regardless of scroll state. 2839 // Applied to a dedicated decorative layer BEHIND nav's real 2840 // content, never to nav itself â backdrop-filter promotes whatever 2841 // element carries it into a new containing block for any 2842 // position:fixed descendant, which silently shrank the full-screen/ 2843 // bottom-drawer hamburger panel (both position:fixed) down to nav's 2844 // own small box instead of the viewport when it lived on nav 2845 // directly (HP-3558 round 15 follow-up: "the full-screen hamburger 2846 // menu... ended up being covered by the next section"). 2847 var blur = nav.getAttribute('data-persist-nav-scroll-blur'); 2848 if (blur) { 2849 var blurLayer = nav.querySelector('[data-persist-nav-scroll-blur-layer]'); 2850 if (!blurLayer) { 2851 blurLayer = document.createElement('div'); 2852 blurLayer.setAttribute('data-persist-nav-scroll-blur-layer', ''); 2853 // z-index:-1 required â without it, backdrop-filter's own 2854 // compositing samples nav's LATER siblings (logo/links/CTA) too, 2855 // visibly blurring them despite painting after this layer in 2856 // plain DOM order. Safe from escaping behind nav's own box into 2857 // the page below because Sticky header always sets an explicit, 2858 // non-auto nav.style.zIndex â position:fixed/sticky + a non-auto 2859 // z-index already makes nav its own stacking context. 2860 blurLayer.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:-1;'; 2861 nav.insertBefore(blurLayer, nav.firstChild); 2862 var navCs = window.getComputedStyle(nav); 2863 if (!navCs || navCs.position === 'static' || !navCs.position) nav.style.position = 'relative'; 2864 } 2865 // Self-heal a layer saved before the z-index fix landed (see 2866 // iframe-script/utils.ts's ensureNavScrollBackground for the 2867 // matching editor-side comment). 2868 if (blurLayer.style.zIndex !== '-1') blurLayer.style.zIndex = '-1'; 2869 blurLayer.style.setProperty('backdrop-filter', 'blur(' + blur + ')', 'important'); 2870 blurLayer.style.setProperty('-webkit-backdrop-filter', 'blur(' + blur + ')', 'important'); 2871 } 2872 if (alwaysTransparent) { 2873 nav.style.setProperty('background-color', 'transparent', 'important'); 2874 return; 2875 } 2876 var color = nav.getAttribute('data-persist-nav-scroll-bg'); 2877 if (!color || nav.__hpNavScrollWired) return; 2878 nav.__hpNavScrollWired = true; 2879 function sync() { 2880 nav.style.setProperty('background-color', window.scrollY > 24 ? color : 'transparent', 'important'); 2881 } 2882 window.addEventListener('scroll', sync, { passive: true }); 2883 sync(); 2884 })(navs[i]); 2885 } 2886} 2887 2888function boot() { 2889 wireSubmenus(); 2890 wireMobileMenu(); 2891 wireScrollBackground(); 2892} 2893 2894if (document.readyState === "loading") { 2895 document.addEventListener("DOMContentLoaded", boot); 2896} else { 2897 boot(); 2898} 2899 2900})(); 2901</script>
2901<script data-htmlpub-dtr> 2902(function(){ 2903if(false)return; 2904var params=new URLSearchParams(location.search); 2905if(!params.toString())return; 2906function run(){ 2907var MAX=500; 2908var replacements=[]; 2909function strip(s){return s.replace(/<[^>]*>/g,"");} 2910function clean(s){try{s=decodeURIComponent(s);}catch(e){}s=strip(s);if(s.length>MAX)s=s.slice(0,MAX);return s;} 2911function replace(el,val,param){ 2912if(!el||!val)return; 2913var orig=el.textContent||""; 2914var cleaned=clean(val); 2915if(cleaned===orig)return; 2916el.setAttribute("data-dtr-original",orig); 2917el.textContent=cleaned; 2918replacements.push({param:param,target:el.tagName.toLowerCase()+(el.id?"#"+el.id:""),original:orig,replacement:cleaned}); 2919} 2920// 1. Explicit data-dtr attributes 2921var dtrMap={headline:"utm_headline",subheadline:"utm_subheadline",cta:"utm_cta"}; 2922var explicitFound={}; 2923for(var key in dtrMap){ 2924var pv=params.get(dtrMap[key]); 2925if(pv){ 2926var els=document.querySelectorAll('[data-dtr="'+key+'"]'); 2927if(els.length>0){ 2928explicitFound[key]=true; 2929for(var i=0;i<els.length;i++)replace(els[i],pv,dtrMap[key]); 2930} 2931} 2932} 2933// 2. Custom data-dtr-param attributes 2934var customEls=document.querySelectorAll("[data-dtr-param]"); 2935for(var j=0;j<customEls.length;j++){ 2936var pn=customEls[j].getAttribute("data-dtr-param"); 2937if(pn){var cv=params.get(pn);if(cv)replace(customEls[j],cv,pn);} 2938} 2939// 3. Auto-detect fallback for standard params (only if no explicit match) 2940if(!explicitFound.headline){ 2941var hv=params.get("utm_headline"); 2942if(hv){var h1=document.querySelector("h1");if(h1)replace(h1,hv,"utm_headline");} 2943} 2944if(!explicitFound.subheadline){ 2945var sv=params.get("utm_subheadline"); 2946if(sv){var sub=document.querySelector("h2")||document.querySelector(".subtitle")||document.querySelector(".subheadline");if(sub)replace(sub,sv,"utm_subheadline");} 2947} 2948if(!explicitFound.cta){ 2949var cv2=params.get("utm_cta"); 2950if(cv2){var btn=document.querySelector('a.cta,button.cta,a[class*="cta"],button[class*="cta"]')||document.querySelector("a.btn,button.btn,a.button,button[type=submit]")||document.querySelector('a[href]:not([href^="#"]):not([href^="mailto"]):not([href^="tel"])>span,a[href]:not([href^="#"]):not([href^="mailto"]):not([href^="tel"])')||document.querySelector("button");if(btn)replace(btn,cv2,"utm_cta");} 2951} 2952// 4. Generic dtr_* prefix params 2953params.forEach(function(val,key){ 2954if(key.indexOf("dtr_")===0){ 2955var slug=key.slice(4); 2956var targets=document.querySelectorAll('[data-dtr-param="'+key+'"]'); 2957if(targets.length>0){for(var k=0;k<targets.length;k++)replace(targets[k],val,key);} 2958} 2959}); 2960// Fire analytics event 2961if(replacements.length===0)return; 2962var PID="rm90klif4ys3o04jw9zgjfpw"; 2963var API="https://htmlpub.com"+"/api/analytics/collect"; 2964function vid(){try{return localStorage.getItem("_hp_vid")||""}catch(e){return""}} 2965function sid(){try{return sessionStorage.getItem("_hp_sid")||""}catch(e){return""}} 2966var ev={pageId:PID,eventType:"dtr_applied",visitorId:vid(),sessionId:sid(),payload:{replacements:replacements},timestamp:Date.now()}; 2967var body=JSON.stringify({events:[ev]}); 2968try{if(navigator.sendBeacon){navigator.sendBeacon(API,new Blob([body],{type:"text/plain"}));} 2969else{fetch(API,{method:"POST",headers:{"Content-Type":"application/json"},body:body,keepalive:true}).catch(function(){});}}catch(e){} 2970} 2971if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",run);}else{run();} 2972})(); 2973</script>
2973<script>(function(){function c(){var b=a.contentDocument||(a.contentWindow&&a.contentWindow.document);if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'a45d5c691d8d65f6',t:'MTc5MTIxMzEwOA=='};var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script>
2973</body></html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.