PageSourceSearch

https://www.jearlogistics.com/wp-content/themes/bb-theme-child/js/dataLayerTracking.js?ver=1790558102

js jearlogistics.com collected 2026-09-29 15:58:52 UTC 25,580 bytes, 570 lines download raw bytes

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.