1/** 2 * DreamDiv Heatmap Tracker â frontend script. 3 * 4 * PHI safety rule enforced throughout this file: 5 * we only ever record WHERE something was clicked and its element 6 * label/type, or HOW FAR a user scrolled. We never read or send 7 * the VALUE of any input, textarea, or select field. 8 */ 9(function () { 10 'use strict'; 11 12 if ( typeof window.ddivHeatmap === 'undefined' ) { 13 return; // Config not localized â bail safely. 14 } 15 16 var config = window.ddivHeatmap; // { restUrl } â only the REST URL comes from PHP; everything else below is generated client-side, so this works correctly even when the page itself is served from a cache. 17 var queue = []; 18 var scrollMilestonesSent = {}; 19 var flushTimer = null; 20 var pageLoadedAt = Date.now(); 21 22 /** 23 * Session ID and page path are deliberately generated here in JS, not 24 * passed in from PHP. If a caching plugin (Seraphinite Accelerator, 25 * GoDaddy's server cache, etc.) serves the same saved HTML to many 26 * different visitors, any value baked into that HTML by PHP would be 27 * identical for all of them â which would badly corrupt session 28 * counts. Generating it fresh in each visitor's own browser sidesteps 29 * that entirely, regardless of how aggressively the page is cached. 30 */ 31 function getOrCreateSessionId() { 32 var cookieName = 'ddiv_hm_sid'; 33 var match = document.cookie.match( new RegExp( '(?:^|; )' + cookieName + '=([a-f0-9]{32})' ) ); 34 if ( match ) return match[ 1 ]; 35 36 var bytes = new Uint8Array( 16 ); 37 if ( window.crypto && window.crypto.getRandomValues ) { 38 window.crypto.getRandomValues( bytes ); 39 } else { 40 for ( var i = 0; i < 16; i++ ) bytes[ i ] = Math.floor( Math.random() * 256 ); 41 } 42 var sessionId = Array.prototype.map.call( bytes, function ( b ) { return ( '0' + b.toString( 16 ) ).slice( -2 ); } ).join( '' ); 43 44 var expires = new Date( Date.now() + 24 * 60 * 60 * 1000 ).toUTCString(); 45 document.cookie = cookieName + '=' + sessionId + '; expires=' + expires + '; path=/; SameSite=Lax'; 46 return sessionId; 47 } 48 49 var sessionId = getOrCreateSessionId(); 50 function currentPagePath() { 51 return window.location.pathname || '/'; // Read fresh from the browser's actual address bar, not baked into cached HTML. 52 } 53 54 function deviceType() { 55 var w = window.innerWidth; 56 if ( w < 768 ) return 'mobile'; 57 if ( w < 1200 ) return 'tablet'; 58 return 'desktop'; 59 } 60 61 function referrerSource() { 62 // Paid/campaign traffic takes priority over document.referrer, 63 // since referrer alone can't distinguish a Google Ads click from 64 // an organic Google search click â both show referrer=google.com. 65 var campaign = getCampaignAttribution(); 66 if ( campaign.source ) return campaign.source; 67 68 var ref = document.referrer; 69 if ( ! ref ) return 'direct'; 70 try { 71 var host = new URL( ref ).hostname; 72 if ( host.indexOf( 'google' ) > -1 ) return 'google_organic'; 73 if ( host.indexOf( 'facebook' ) > -1 ) return 'facebook'; 74 if ( host.indexOf( 'bing' ) > -1 ) return 'bing'; 75 return host; 76 } catch ( e ) { 77 return 'unknown'; 78 } 79 } 80 81 /** 82 * Reads gclid/utm_* from the URL on first landing and stores them in 83 * sessionStorage so every page the visitor views in this session is 84 * attributed to the same campaign â not just the landing page, since 85 * the ad parameters usually only appear on the first URL clicked. 86 */ 87 function getCampaignAttribution() { 88 var STORAGE_KEY = 'ddiv_hm_campaign'; 89 var params = new URLSearchParams( window.location.search ); 90 var gclid = params.get( 'gclid' ); 91 var utmSource = params.get( 'utm_source' ); 92 var utmMedium = params.get( 'utm_medium' ); 93 var utmCampaign = params.get( 'utm_campaign' ); 94 95 var attribution = null; 96 97 if ( gclid ) { 98 attribution = { source: 'google_ads', campaign: utmCampaign || 'gclid_' + gclid.substring( 0, 8 ) }; 99 } else if ( utmSource ) { 100 attribution = { source: ( utmSource + ( utmMedium ? '_' + utmMedium : '' ) ).substring( 0, 100 ), campaign: utmCampaign || null }; 101 } 102 103 try { 104 if ( attribution ) { 105 sessionStorage.setItem( STORAGE_KEY, JSON.stringify( attribution ) ); 106 return attribution; 107 } 108 var stored = sessionStorage.getItem( STORAGE_KEY ); 109 if ( stored ) return JSON.parse( stored ); 110 } catch ( e ) { 111 // sessionStorage unavailable (private browsing, etc.) â fall through. 112 } 113 114 return { source: null, campaign: null }; 115 } 116 117 function elementLabel( el ) { 118 var text = ( el.innerText || el.value || el.getAttribute( 'aria-label' ) || '' ).trim(); 119 return text.substring( 0, 80 ); 120 } 121 122 function queueEvent( data ) { 123 var campaign = getCampaignAttribution(); 124 queue.push( Object.assign( { 125 session_id: sessionId, 126 page_path: currentPagePath(), 127 device_type: deviceType(), 128 referrer_source: referrerSource(), 129 campaign_name: campaign.campaign || null, 130 }, data ) ); 131 132 scheduleFlush(); 133 } 134 135 function scheduleFlush() { 136 if ( flushTimer ) return; 137 flushTimer = setTimeout( flush, 2000 ); 138 } 139 140 function flush( useBeacon ) { 141 if ( ! queue.length ) return; 142 var payload = JSON.stringify( { events: queue } ); 143 queue = []; 144 flushTimer = null; 145 146 if ( useBeacon && navigator.sendBeacon ) { 147 var blob = new Blob( [ payload ], { type: 'application/json' } ); 148 navigator.sendBeacon( config.restUrl, blob ); 149 } else { 150 fetch( config.restUrl, { 151 method: 'POST', 152 headers: { 'Content-Type': 'application/json' }, 153 body: payload, 154 keepalive: true, 155 } ).catch( function () {} ); // Fail silently â never break the site for a tracking error. 156 } 157 } 158 159 /* ---------- Page view ---------- */ 160 queueEvent( { event_type: 'pageview' } ); 161 162 /* ---------- Live visitor heartbeat ---------- */ 163 if ( config.trackLive !== false ) { 164 function sendHeartbeat() { 165 if ( document.visibilityState === 'visible' ) { 166 queueEvent( { event_type: 'heartbeat' } ); 167 flush(); // Send immediately â heartbeats shouldn't sit in the queue. 168 } 169 } 170 sendHeartbeat(); 171 setInterval( sendHeartbeat, 20000 ); 172 } 173 174 /* ---------- Clicks ---------- */ 175 if ( config.trackClicks !== false ) { 176 document.addEventListener( 'click', function ( e ) { 177 var el = e.target.closest( 'a, button, [role="button"], input[type="submit"]' ) || e.target; 178 179 queueEvent( { 180 event_type: 'click', 181 pos_x: Math.round( e.pageX ), 182 pos_y: Math.round( e.pageY ), 183 viewport_w: document.documentElement.scrollWidth, 184 viewport_h: document.documentElement.scrollHeight, 185 element_label: elementLabel( el ), 186 element_type: el.tagName ? el.tagName.toLowerCase() : 'unknown', 187 } ); 188 189 // Semantic click classification â CTA / appointment / phone / email / download. 190 var href = ( el.getAttribute && el.getAttribute( 'href' ) ) || '';
191 var apptKeywords = config.appointmentKeywords && config.appointmentKeywords.length ? config.appointmentKeywords : [ 'appointment', 'book' ]; 192 var ctaSelector = config.ctaSelector || '.cta, [data-track="cta"]'; 193 var label = elementLabel( el ).toLowerCase(); 194 195 if ( href.indexOf( 'tel:' ) === 0 ) { 196 queueEvent( { event_type: 'phone_click', element_label: elementLabel( el ) } ); 197 } else if ( href.indexOf( 'mailto:' ) === 0 ) { 198 queueEvent( { event_type: 'email_click', element_label: elementLabel( el ) } ); 199 } else if ( /\.(pdf|docx?|xlsx?|zip)$/i.test( href ) ) { 200 queueEvent( { event_type: 'file_download', element_label: href } ); 201 } else if ( el.matches && el.matches( '[data-track="appointment"]' ) || apptKeywords.some( function ( kw ) { return kw && label.indexOf( kw.toLowerCase() ) > -1; } ) ) { 202 queueEvent( { event_type: 'appointment_click', element_label: elementLabel( el ) } ); 203 } else if ( el.matches && el.matches( ctaSelector ) ) { 204 queueEvent( { event_type: 'cta_click', element_label: elementLabel( el ) } ); 205 } 206 }, true ); 207 } 208 209 /* ---------- Scroll depth milestones ---------- */ 210 if ( config.trackScroll !== false ) { 211 var milestones = [ 25, 50, 75, 100 ]; 212 function checkScroll() { 213 var scrollTop = window.scrollY || document.documentElement.scrollTop; 214 var docHeight = document.documentElement.scrollHeight - window.innerHeight; 215 if ( docHeight <= 0 ) return; 216 var pct = Math.min( 100, Math.round( ( ( scrollTop + window.innerHeight ) / document.documentElement.scrollHeight ) * 100 ) ); 217 218 milestones.forEach( function ( m ) { 219 if ( pct >= m && ! scrollMilestonesSent[ m ] ) { 220 scrollMilestonesSent[ m ] = true; 221 queueEvent( { event_type: 'scroll', scroll_pct: m } ); 222 } 223 } ); 224 } 225 var scrollThrottle = null; 226 window.addEventListener( 'scroll', function () { 227 if ( scrollThrottle ) return; 228 scrollThrottle = setTimeout( function () { 229 checkScroll(); 230 scrollThrottle = null; 231 }, 300 ); 232 } ); 233 } 234 235 /* ---------- Forms: start / abandon / complete â NEVER field values ---------- */ 236 if ( config.trackForms !== false ) { 237 var formCompletedSent = {}; 238 var formStartedSent = {}; 239 var boundForms = new WeakSet(); // Prevents double-binding the same form if it's scanned twice. 240 241 function bindForm( form ) { 242 if ( boundForms.has( form ) ) return; 243 boundForms.add( form ); 244 245 var formId = form.getAttribute( 'id' ) || form.getAttribute( 'name' ) || ( 'form_' + Math.random().toString( 36 ).substr( 2, 8 ) ); 246 form.setAttribute( 'data-ddiv-form-id', formId ); 247 248 form.addEventListener( 'focusin', function () { 249 if ( ! formStartedSent[ formId ] ) { 250 formStartedSent[ formId ] = true; 251 queueEvent( { event_type: 'form_start', element_label: formId } ); 252 } 253 } ); 254 255 // Field-level engagement: WHICH field was focused, never its value. 256 form.querySelectorAll( 'input, textarea, select' ).forEach( function ( field ) { 257 field.addEventListener( 'focus', function () { 258 var fieldName = field.getAttribute( 'name' ) || field.getAttribute( 'id' ) || field.getAttribute( 'placeholder' ) || 'unnamed_field'; 259 queueEvent( { 260 event_type: 'form_field_focus', 261 element_label: fieldName.substring( 0, 80 ), 262 element_type: formId, // form id stored here so the field can be grouped per form. 263 } ); 264 } ); 265 } ); 266 267 form.addEventListener( 'submit', function () { 268 formCompletedSent[ formId ] = true; 269 queueEvent( { event_type: 'form_complete', element_label: formId } ); 270 } ); 271 } 272 273 function scanForms( root ) { 274 ( root || document ).querySelectorAll( 'form' ).forEach( bindForm ); 275 } 276 277 // Initial scan â covers forms present at page load. 278 scanForms(); 279 280 // Builders like Elementor often inject forms (modals, AJAX-loaded 281 // sections, popups) after the initial page load. A MutationObserver 282 // catches those so they still get tracked instead of being silently missed. 283 if ( window.MutationObserver ) { 284 var formObserver = new MutationObserver( function ( mutations ) { 285 mutations.forEach( function ( mutation ) { 286 mutation.addedNodes.forEach( function ( node ) { 287 if ( node.nodeType !== 1 ) return; // Element nodes only. 288 if ( node.tagName === 'FORM' ) { 289 bindForm( node ); 290 } else if ( node.querySelectorAll ) { 291 scanForms( node ); 292 } 293 } ); 294 } ); 295 } ); 296 formObserver.observe( document.body, { childList: true, subtree: true } ); 297 } 298 299 /* ---------- JotForm embeds (and similar iframe-based form builders) ---------- 300 * A JotForm embed is an <iframe> loading a page from JotForm's own domain, 301 * not a native <form> tag â so nothing above (form_start, field focus, 302 * abandonment) can see inside it; that's a hard browser security boundary, 303 * not something we can or should try to bypass. What we CAN safely detect: 304 * JotForm's embed script sends the parent page a postMessage when a 305 * submission completes. We listen for that specifically â never reaching 306 * into the iframe's contents â so at least "form_complete" still works. 307 */ 308 var jotformCompleteSent = {}; 309 window.addEventListener( 'message', function ( e ) { 310 if ( ! e.origin || e.origin.indexOf( 'jotform' ) === -1 ) return; 311 312 var raw = e.data; 313 var text = ''; 314 if ( typeof raw === 'string' ) { 315 text = raw; 316 } else if ( raw && typeof raw === 'object' ) { 317 try { text = JSON.stringify( raw ); } catch ( err ) { text = ''; } 318 } 319 if ( ! text ) return; 320 321 // JotForm's exact message shape varies by embed method/version, so we 322 // match loosely on known submission-completion signals rather than 323 // depending on one exact format that could silently break on an update. 324 var isSubmission = /submission-completed|formSubmitted|"type"\s*:\s*"submission"/i.test( text ); 325 if ( ! isSubmission ) return; 326 327 var dedupeKey = text.substring( 0, 60 ); 328 if ( jotformCompleteSent[ dedupeKey ] ) return; // Avoid double-counting if JotForm fires the event more than once. 329 jotformCompleteSent[ dedupeKey ] = true; 330 331 queueEvent( { event_type: 'form_complete', element_label: 'jotform_embed' } ); 332 flush(); // Send right away â a submission is a meaningful event worth not delaying. 333 } ); 334 } // end trackForms 335 336 /* ---------- Page exit / abandonment detection ---------- */ 337 var exitSent = false;
338 function handlePageExit() { 339 if ( exitSent ) return; 340 exitSent = true; 341 342 var durationSec = Math.round( ( Date.now() - pageLoadedAt ) / 1000 ); 343 queueEvent( { event_type: 'page_exit', duration_sec: durationSec } ); 344 345 // Any form that was started but never submitted on this page = abandoned. 346 Object.keys( formStartedSent ).forEach( function ( formId ) { 347 if ( ! formCompletedSent[ formId ] ) { 348 queueEvent( { event_type: 'form_abandon', element_label: formId } ); 349 } 350 } ); 351 352 flush( true ); // Use sendBeacon so the request survives page unload. 353 } 354 355 // beforeunload covers desktop browsers; pagehide is the reliable 356 // signal on mobile Safari/iOS, where beforeunload often never fires. 357 window.addEventListener( 'beforeunload', handlePageExit ); 358 window.addEventListener( 'pagehide', handlePageExit ); 359})();
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.