1/** 2 * DataLayerTracker Class 3 * 4 * Tracks clicks, scroll depth, and Gravity Forms interactions, 5 * and merges page-level metadata into every event. 6 */ 7 8class DataLayerTracker { 9 constructor(options = {}) { 10 this.enableScrollTracking = options.scrollTracking ?? false; 11 this.enableGravityFormsTracking = options.gravityFormsTracking ?? false; 12 this.clickSelector = options.clickSelector || '[data-dl-event]'; 13 14 this.scrollThresholds = options.scrollThresholds || [25, 50, 75, 95]; 15 this.firedThresholds = new Set(); 16 17 this.formViewCounts = new Map(); 18 this.formStartCounts = new Map(); 19 this.formSubmitCounts = new Map(); 20 this.formConfirmationCounts = new Map(); 21 22 this.viewedSteps = new Map(); // <-- ADD THIS 23 this.formLastSubmitTimestamps = new Map(); //WO-304 24 this.pendingSubmissions = new Map(); 25 this._beforeUnloadBound = false; 26 27 // Session-level tracking for user events 28 this.sessionStartTracked = this.getSessionFlag('dl_user_form_started'); 29 this.sessionSubmitTracked = this.getSessionFlag('dl_user_form_submitted'); 30 31 // Preserve existing dataLayer 32 window.dataLayer = window.dataLayer || []; 33 34 // Initialize page meta 35 this.pageMeta = window.pageSchema || this.getDefaultPageMeta(); 36 37 this.bindMethods(); 38} 39 40 bindMethods() { 41 this.handleClick = this.handleClick.bind(this); 42 this.handleScroll = this.handleScroll.bind(this); 43 this.handleFormRender = this.handleFormRender.bind(this); 44 this.handleFormStart = this.handleFormStart.bind(this); 45 this.handleFormConfirmation = this.handleFormConfirmation.bind(this); 46 } 47 48 getSessionFlag(key) { 49 try { 50 return sessionStorage.getItem(key) === 'true'; 51 } catch (e) { 52 return false; 53 } 54 } 55 56 setSessionFlag(key) { 57 try { 58 sessionStorage.setItem(key, 'true'); 59 } catch (e) { 60 // Session storage not available 61 } 62 } 63 64 generateEventId() { 65 this._eventCounter = (this._eventCounter || 0) + 1; 66 return `${Date.now()}-${Math.random().toString(36).slice(2,8)}-${this._eventCounter}`; 67 } 68 init() { 69 // Skip Beaver Builder pages 70 if (window.location.search.includes('fl_builder')) { 71 return; 72 } 73 74 // Restore pending submissions persisted across redirects (sessionStorage primary, localStorage fallback) 75 let pendingRaw = null; 76 try { 77 pendingRaw = sessionStorage.getItem('gf_submission') || localStorage.getItem('gf_submission_backup'); 78 } catch (e) { 79 pendingRaw = null; 80 } 81 if (pendingRaw) { 82 try { 83 const data = JSON.parse(pendingRaw); 84 try { console.log('DL Tracker: found pending submission in storage', data); } catch(e){} 85 // New format: { pending: true, form_id, submit_count, confirmation_type } 86 // Only restore if pending is true AND aborted is not true 87 if (data && data.pending === true && !data.aborted && data.form_id) { 88 // form_submit was already emitted before redirect, only emit confirmation 89 this.pushToDataLayer({ event: 'form_confirmation', form_id: data.form_id, confirmation_type: data.confirmation_type || 'redirect' }); 90 try { console.log('DL Tracker: restored pending submission and emitted form_confirmation', { form_id: data.form_id }); } catch (e) {} 91 } else if (data && data.aborted) { 92 try { console.log('DL Tracker: found aborted submission in storage, will not restore', data); } catch (e) {} 93 } else { 94 // Backwards-compat: push whatever was stored 95 this.pushToDataLayer(data); 96 } 97 } catch (e) { 98 try { console.error('DL Tracker: error parsing gf_submission from storage', e); } catch (ex) {} 99 } 100 try { sessionStorage.removeItem('gf_submission'); } catch (ex) {} 101 try { localStorage.removeItem('gf_submission_backup'); } catch (ex) {} 102 } 103 104 // Click tracking 105 document.body.addEventListener('click', this.handleClick); 106 107 // Scroll tracking 108 if (this.enableScrollTracking) { 109 window.addEventListener('scroll', this.handleScroll, { passive: true }); 110 } 111 112 // Gravity Forms tracking 113 if (this.enableGravityFormsTracking) { 114 this.initGravityForms(); 115 } 116 } 117 118 // Default page meta if pageSchema undefined 119 getDefaultPageMeta() { 120 return { 121 page_url: window.location.href, 122 page_path: window.location.pathname, 123 page_title: document.title, 124 referrer: document.referrer 125 }; 126 } 127 128 // Optional: update page meta dynamically 129 refreshPageMeta(newSchema) { 130 if (newSchema && typeof newSchema === 'object') { 131 this.pageMeta = { ...this.pageMeta, ...newSchema }; 132 } 133 } 134 135 // Centralized push with page metadata 136 pushToDataLayer(eventData) { 137 if (!eventData || typeof eventData !== 'object') { 138 return; 139 } 140 141 const eventId = this.generateEventId(); 142 const mergedData = { ...this.pageMeta, ...eventData, _dl_event_id: eventId }; 143 144 // Lightweight diagnostic logging to help correlate pushes with GA hits 145 try { 146 console.debug('[DL Push]', mergedData); 147 } catch (e) { 148 // ignore console errors in older browsers 149 } 150 151 window.dataLayer.push(mergedData); 152 return eventId; 153 } 154 155 initGravityForms() { 156 const $ = window.jQuery; 157 158 const attachListeners = () => { 159 // Global handler for Gravity Forms 'submission_aborted' events. 160 // Register both the jQuery event and the native CustomEvent variant 161 // so we catch whichever GF uses in this runtime. 162 try { 163 $(document).on('gform/submission/submission_aborted.dlTracker', (ev, data) => { 164 try { 165 console.group('%cDL Tracker: submission_aborted (jQuery)', 'color:darkorange;font-weight:bold'); 166 console.log('ev:', ev); 167 console.log('data:', data); 168 console.log((new Error()).stack); 169 console.groupEnd(); 170 } catch (e) {} 171 try { 172 const formEl = (data && data.form) || (ev && ev.detail && ev.detail.form) || null; 173 const abortedFormId = (formEl && formEl.dataset && formEl.dataset.formid) || (formEl && formEl.id && formEl.id.replace && formEl.id.replace(/^gform_/, '')); 174 if (abortedFormId) { 175 const fid = String(abortedFormId); 176 try { 177 const entry = this.pendingSubmissions.get(fid); 178 if (entry && entry.attemptId) { 179 try { 180 const raw = sessionStorage.getItem('gf_submission') || localStorage.getItem('gf_submission_backup'); 181 if (raw) { 182 const cur = JSON.parse(raw); 183 if (cur && cur.submit_attempt_id && cur.submit_attempt_id === entry.attemptId) { 184 cur.pending = false;
185 cur.aborted = true; 186 cur.aborted_timestamp = new Date().toISOString(); 187 const newPayload = JSON.stringify(cur); 188 try { sessionStorage.setItem('gf_submission', newPayload); } catch (ex) {} 189 try { localStorage.setItem('gf_submission_backup', newPayload); } catch (ex) {} 190 } 191 } 192 } catch (ex) {} 193 } 194 } catch (ex) {} 195 196 if (this.pendingSubmissions && this.pendingSubmissions.has(fid)) { 197 this.pendingSubmissions.delete(fid); 198 } 199 try { console.debug('DL Tracker: canceled pending submit due to GF aborted (jQuery)', fid); } catch (e) {} 200 } 201 } catch(e) {} 202 }); 203 204 document.addEventListener('gform/submission/submission_aborted', (ev) => { 205 try { 206 console.group('%cDL Tracker: submission_aborted (native)', 'color:darkorange;font-weight:bold'); 207 console.log('ev:', ev); 208 console.log((new Error()).stack); 209 console.groupEnd(); 210 } catch (e) {} 211 try { 212 const formEl = ev && ev.detail && ev.detail.form; 213 const abortedFormId = (formEl && formEl.dataset && formEl.dataset.formid) || (formEl && formEl.id && formEl.id.replace && formEl.id.replace(/^gform_/, '')); 214 if (abortedFormId) { 215 const fid = String(abortedFormId); 216 try { 217 const entry = this.pendingSubmissions.get(fid); 218 if (entry && entry.attemptId) { 219 try { 220 const raw = sessionStorage.getItem('gf_submission') || localStorage.getItem('gf_submission_backup'); 221 if (raw) { 222 const cur = JSON.parse(raw); 223 if (cur && cur.submit_attempt_id && cur.submit_attempt_id === entry.attemptId) { 224 cur.pending = false; 225 cur.aborted = true; 226 cur.aborted_timestamp = new Date().toISOString(); 227 const newPayload = JSON.stringify(cur); 228 try { sessionStorage.setItem('gf_submission', newPayload); } catch (ex) {} 229 try { localStorage.setItem('gf_submission_backup', newPayload); } catch (ex) {} 230 } 231 } 232 } catch (ex) {} 233 } 234 } catch (ex) {} 235 236 if (this.pendingSubmissions && this.pendingSubmissions.has(fid)) { 237 this.pendingSubmissions.delete(fid); 238 } 239 try { console.debug('DL Tracker: canceled pending submit due to GF aborted (native)', fid); } catch (e) {} 240 } 241 } catch (e) {} 242 }); 243 } catch (e) {} 244 // Form render 245 $(document).on('gform_post_render.dlTracker', (event, formId, currentPage) => { 246 this.handleFormRender(formId, currentPage); 247 248 // Check if there are validation errors on the form 249 const formEl = $(`#gform_${formId}`); 250 if (formEl.length && formEl.find('.gfield_error, .validation_error, .gform_validation_error').length > 0) { 251 // Validation error present 252 this.handleFormError({ data: { formId, currentPage } }); 253 } 254 }); 255 256 257 // Track first input focus as form_start 258 $(document).on('focus.dlTracker', '.gform_wrapper form :input', (e) => { 259 const formId = $(e.target).closest('form').data('formid') 260 || e.target.form?.id.replace('gform_', ''); 261 if (!formId) return; 262 263 // Initialize start count for this form if not exists 264 if (!this.formStartCounts.has(formId)) { 265 this.formStartCounts.set(formId, 0); 266 } 267 268 // Fire form_start only on first field interaction for this form 269 if (this.formStartCounts.get(formId) === 0) { 270 this.formStartCounts.set(formId, 1); 271 this.handleFormStart({ data: { formId } }); 272 } 273 274 // Fire form_start_user only once per session (first form interaction ever) 275 if (!this.sessionStartTracked) { 276 this.sessionStartTracked = true; 277 this.setSessionFlag('dl_user_form_started'); 278 this.handleFormStartUser({ data: { formId } }); 279 } 280 }); 281 282 // AJAX confirmation â ensure we mark and emit submit before confirmation 283 $(document).on('gform_confirmation_loaded.dlTracker', (event, formId) => { 284 // form_submit is now emitted immediately on submit, not deferred 285 // Just handle confirmation tracking 286 this.handleFormConfirmation(formId, 'ajax'); 287 }); 288 289 // Form submit attempt 290 $(document).on('submit.dlTracker', '.gform_wrapper form', (e) => { 291 const formId = $(e.target).data('formid') || e.target.id.replace('gform_', ''); 292 if (!formId) return; 293 294 // Prevent duplicate submit events firing in rapid succession WO-304 295 const now = Date.now(); 296 const last = this.formLastSubmitTimestamps.get(formId) || 0; 297 const DUPLICATE_WINDOW_MS = 2000; // 2 seconds 298 if (now - last < DUPLICATE_WINDOW_MS) { 299 return; 300 } 301 this.formLastSubmitTimestamps.set(formId, now); 302 303 const count = (this.formSubmitCounts.get(formId) || 0) + 1; 304 this.formSubmitCounts.set(formId, count); 305 306 try { console.log('DL Tracker: submit handler fired', { formId, count }); } catch (e) {} 307 308 // Fire form_submit immediately on every submit attempt 309 this.pushToDataLayer({ 310 event: 'form_submit', 311 form_id: formId, 312 submit_count: count 313 }); 314 315 // Fire form_submit_user only once per session (first submit attempt ever) 316 if (!this.sessionSubmitTracked) { 317 this.sessionSubmitTracked = true; 318 this.setSessionFlag('dl_user_form_submitted'); 319 this.handleFormSubmitUser({ data: { formId } }); 320 } 321
322 // Mark a pending submission for confirmation tracking 323 // Create a unique attempt id so aborts can be correlated to this submit 324 const submitAttemptId = this.generateEventId(); 325 const pendingEntry = { pushed: true, pushEventId: null, count: count, abortHandler: null, attemptId: submitAttemptId }; 326 this.pendingSubmissions.set(formId, pendingEntry); 327 328 // Persist immediately so redirects can't lose the pending state (sessionStorage + localStorage fallback) 329 try { 330 const payloadObj = { pending: true, form_id: formId, submit_count: count, confirmation_type: 'redirect', _timestamp: new Date().toISOString(), submit_attempt_id: submitAttemptId }; 331 const payload = JSON.stringify(payloadObj); 332 try { sessionStorage.setItem('gf_submission', payload); } catch (ex) {} 333 try { localStorage.setItem('gf_submission_backup', payload); } catch (ex) {} 334 try { console.log('DL Tracker: persisted pending submission to sessionStorage + localStorage', formId); } catch (e) {} 335 } catch (ex) {} 336 337 // Rely on the global 'submission_aborted' listeners registered above. 338 // Store a reference object for this pending submission. 339 const entryRef = this.pendingSubmissions.get(formId) || {}; 340 this.pendingSubmissions.set(formId, entryRef); 341 }); 342 343 // Visible debug: indicate that GF listeners were attached 344 try { console.log('DL Tracker: Gravity Forms listeners attached'); } catch (e) {} 345 346 // Persist pending submissions on unload for redirect confirmations 347 if (!this._beforeUnloadBound) { 348 window.addEventListener('beforeunload', () => { 349 try { 350 if (this.pendingSubmissions.size > 0) { 351 const it = this.pendingSubmissions.entries().next(); 352 if (!it.done) { 353 const [fid, entry] = it.value; 354 const submitCount = (entry && entry.count) || 1; 355 const attemptId = (entry && entry.attemptId) || null; 356 try { console.log('DL Tracker: beforeunload persisting pending submission', fid, submitCount, attemptId); } catch(e){} 357 try { 358 const payloadObj = { pending: true, form_id: fid, submit_count: submitCount, confirmation_type: 'redirect', _timestamp: new Date().toISOString() }; 359 if (attemptId) payloadObj.submit_attempt_id = attemptId; 360 const p = JSON.stringify(payloadObj); 361 try { sessionStorage.setItem('gf_submission', p); } catch (ex) {} 362 try { localStorage.setItem('gf_submission_backup', p); } catch (ex) {} 363 } catch (ex) {} 364 } 365 } 366 } catch (ex) {} 367 }); 368 this._beforeUnloadBound = true; 369 } 370 }; 371 372 if (typeof window.gform !== 'undefined') { 373 attachListeners(); 374 } else { 375 const checkInterval = setInterval(() => { 376 if (typeof window.gform !== 'undefined') { 377 clearInterval(checkInterval); 378 attachListeners(); 379 } 380 }, 200); 381 } 382 } 383 384 handleFormRender(formId, currentPage = 1) { 385 // Bail if formId is missing or not a number 386 if (!formId) { 387 console.warn('handleFormRender called without formId'); 388 return; 389 } 390 391 const formEl = document.getElementById(`gform_${formId}`); 392 if (!formEl) return; 393 394 // Ensure steps Map entry exists 395 if (!this.viewedSteps.has(formId)) { 396 this.viewedSteps.set(formId, new Set()); 397 } 398 const steps = this.viewedSteps.get(formId); 399 400 // Form view tracking (fire once per form) 401 const observer = new IntersectionObserver((entries) => {
402 entries.forEach(entry => { 403 if (entry.isIntersecting && !this.formViewCounts.has(formId)) { 404 this.formViewCounts.set(formId, 1); 405 this.pushToDataLayer({ 406 event: 'form_view', 407 form_id: formId, 408 view_count: 1 409 }); 410 observer.unobserve(formEl); 411 } 412 }); 413 }, { threshold: 0.25 }); 414 415 observer.observe(formEl); 416 417 // Multi-page step tracking 418 if (!steps.has(currentPage)) { 419 steps.add(currentPage); 420 if (currentPage > 1) { 421 this.pushToDataLayer({ 422 event: 'form_step', 423 form_id: formId, 424 step: currentPage 425 }); 426 } 427 } 428} 429 430 431 handleFormStart(event) { 432 const { formId } = event.data; 433 const formTitle = jQuery(`#gform_title_${formId}`).text() || ''; 434 try { console.log('DL Tracker: handleFormStart', { formId, formTitle }); } catch (e) {} 435 this.pushToDataLayer({ 436 event: 'form_start', 437 form_id: formId, 438 form_name: formTitle 439 }); 440 } 441 442 handleFormStartUser(event) { 443 const { formId } = event.data; 444 const formTitle = jQuery(`#gform_title_${formId}`).text() || ''; 445 try { console.log('DL Tracker: handleFormStartUser (first session interaction)', { formId, formTitle }); } catch (e) {} 446 this.pushToDataLayer({ 447 event: 'form_start_user', 448 form_id: formId, 449 form_name: formTitle 450 }); 451 } 452 453 handleFormSubmitUser(event) { 454 const { formId } = event.data; 455 const formTitle = jQuery(`#gform_title_${formId}`).text() || ''; 456 try { console.log('DL Tracker: handleFormSubmitUser (first session submit)', { formId, formTitle }); } catch (e) {} 457 this.pushToDataLayer({ 458 event: 'form_submit_user', 459 form_id: formId, 460 form_name: formTitle 461 }); 462 } 463 464 handleFormError(event) { 465 const { formId, currentPage } = event.data; 466 const formTitle = jQuery(`#gform_title_${formId}`).text() || ''; 467 try { console.log('DL Tracker: handleFormError', { formId, formTitle, currentPage }); } catch (e) {} 468 this.pushToDataLayer({ 469 event: 'form_error', 470 form_id: formId, 471 form_name: formTitle, 472 error_page: currentPage || 1 473 }); 474 } 475 476 handleFormConfirmation(formId, confirmationType = 'ajax') { 477 const count = (this.formConfirmationCounts.get(formId) || 0) + 1; 478 this.formConfirmationCounts.set(formId, count); 479 480 try { console.log('DL Tracker: handleFormConfirmation', { formId, confirmationType, count }); } catch (e) {} 481 this.pushToDataLayer({ 482 event: 'form_confirmation', 483 form_id: formId, 484 confirmation_type: confirmationType, 485 confirmation_count: count 486 }); 487 488 // Clear pending submission state for this form (AJAX or redirect) 489 try { 490 if (this.pendingSubmissions && this.pendingSubmissions.has(formId)) { 491 this.pendingSubmissions.delete(formId); 492 } 493 } catch (e) {} 494 495 // Clear sessionStorage/localStorage for AJAX confirmations to prevent duplicate redirect confirmation WO-304 496 if (confirmationType === 'ajax') { 497 try { sessionStorage.removeItem('gf_submission'); } catch (ex) {} 498 try { localStorage.removeItem('gf_submission_backup'); } catch (ex) {} 499 } 500 } 501 502 handleScroll() { 503 const scrollableHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight; 504 if (scrollableHeight <= 0) return; 505 const scrollPercentage = (window.scrollY / scrollableHeight) * 100; 506 507 for (const threshold of this.scrollThresholds) { 508 if (scrollPercentage >= threshold && !this.firedThresholds.has(threshold)) { 509 this.pushToDataLayer({ event: 'scroll_depth', scroll_percentage: threshold }); 510 this.firedThresholds.add(threshold); 511 } 512 } 513 } 514 515 handleClick(event) { 516 const target = event.target.closest(this.clickSelector) || event.target.closest('a, button'); 517 if (!target) return; 518 519 const dataset = target.dataset; 520 const eventData = {}; 521 522 if (dataset.dlEvent) { 523 eventData.event = dataset.dlEvent; 524 } else { 525 if (target.tagName === 'A') eventData.event = 'link_click'; 526 else if (target.tagName === 'BUTTON') eventData.event = 'button_click'; 527 else return; 528 } 529 530 if (target.tagName === 'A') { 531 eventData.link_url = target.getAttribute('href'); 532 eventData.link_label = target.textContent.trim() || target.getAttribute('aria-label') || ''; 533 } 534 if (target.tagName === 'BUTTON') { 535 eventData.button_label = target.textContent.trim() || target.getAttribute('aria-label') || ''; 536 } 537 538 for (const key in dataset) { 539 if (key.startsWith('dl') && key !== 'dlEvent') { 540 const trimmedKey = key.substring(2); 541 if (!trimmedKey) continue; 542 const firstChar = trimmedKey.charAt(0).toLowerCase(); 543 const rest = trimmedKey.slice(1).replace(/([A-Z])/g, '_$1').toLowerCase(); 544 eventData[firstChar + rest] = dataset[key]; 545 } 546 } 547 548 this.pushToDataLayer(eventData); 549 } 550} 551 552// ---- Initialize ---- 553document.addEventListener('DOMContentLoaded', () => { 554 // Guard against the script being loaded more than once (e.g. via duplicate plugin enqueues). 555 // Without this, every instance creates its own Maps so form_start / form_submit each fire 556 // once per instance while form_confirmation only fires once (sessionStorage is consumed 557 // by the first instance), producing the 2:1 ratio. 558 if (window.__dlTrackerInitialized) return; 559 window.__dlTrackerInitialized = true; 560 561 const tracker = new DataLayerTracker({ 562 scrollTracking: true, 563 scrollThresholds: [10, 25, 50, 75, 90], 564 gravityFormsTracking: true 565 }); 566 tracker.init(); 567 568 // Optional: refresh pageSchema if it loads later 569 if (window.pageSchema) tracker.refreshPageMeta(window.pageSchema); 570});
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.