PageSourceSearch

https://hsti.com/wp-content/plugins/dreamdiv-heatmap-tracker/assets/js/tracker.js?ver=2.0.0

js hsti.com collected 2026-10-08 06:12:47 UTC 13,679 bytes, 359 lines download raw bytes

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.