1/** 2 * Form Tracker â lightweight customer journey telemetry for Show Off Inc forms. 3 * Loaded via <script src="/js/form-tracker.js"></script> on each form page. 4 * MUST NEVER break the form â every public method is wrapped in try/catch. 5 */ 6(function () { 7 'use strict'; 8 9 var FLUSH_INTERVAL = 30000; // 30 seconds 10 var FIELD_FOCUS_MIN = 3000; // 3 seconds minimum to log a field interaction 11 var MAX_BATCH = 50; 12 var ENDPOINT = '/api/form-tracking'; 13 14 var config = { formType: '', email: null, dealNumber: null }; 15 var sessionId = ''; 16 var queue = []; 17 var stepTimer = null; // timestamp when current step started 18 var currentStep = null; 19 var fieldTimers = {}; // field_name -> focus timestamp 20 var flushTimer = null; 21 var initialized = false; 22 23 // ---- Helpers ---- 24 25 function generateId() { 26 return 'ft_' + Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 8); 27 } 28 29 function enqueue(eventType, extra) { 30 try { 31 var evt = { 32 session_id: sessionId, 33 form_type: config.formType, 34 event_type: eventType, 35 email: config.email || null, 36 deal_number: config.dealNumber || null, 37 step: currentStep || null, 38 field_name: (extra && extra.field_name) || null, 39 detail: (extra && extra.detail) || null, 40 duration_ms: (extra && extra.duration_ms) || null 41 }; 42 queue.push(evt); 43 } catch (e) { /* silent */ } 44 } 45 46 function flush() { 47 try { 48 if (queue.length === 0) return; 49 var batch = queue.splice(0, MAX_BATCH); 50 var body = JSON.stringify({ events: batch }); 51 52 // Prefer sendBeacon for reliability during page leave; fall back to fetch 53 if (navigator.sendBeacon) { 54 navigator.sendBeacon(ENDPOINT, new Blob([body], { type: 'application/json' })); 55 } else { 56 fetch(ENDPOINT, { 57 method: 'POST', 58 headers: { 'Content-Type': 'application/json' }, 59 body: body, 60 keepalive: true 61 }).catch(function () { /* silent */ }); 62 } 63 } catch (e) { /* silent */ } 64 } 65 66 function startFlushTimer() { 67 if (flushTimer) return; 68 flushTimer = setInterval(function () { 69 try { flush(); } catch (e) { /* silent */ } 70 }, FLUSH_INTERVAL); 71 } 72 73 // ---- Public API ---- 74 75 var FormTracker = { 76 init: function (opts) { 77 try { 78 if (initialized) return; 79 initialized = true; 80 81 config.formType = (opts && opts.formType) || 'unknown'; 82 config.email = (opts && opts.email) || null; 83 config.dealNumber = (opts && opts.dealNumber) || null; 84 85 // Session ID: reuse within same tab visit, detect return visits 86 var storageKey = 'ft_session_' + config.formType; 87 var prev = sessionStorage.getItem(storageKey); 88 if (prev) { 89 sessionId = prev; 90 enqueue('return_visit'); 91 } else { 92 sessionId = generateId(); 93 sessionStorage.setItem(storageKey, sessionId); 94 } 95 96 stepTimer = Date.now(); 97 enqueue('page_load'); 98 startFlushTimer(); 99 100 // Global error capture 101 window.addEventListener('error', function (e) { 102 try { 103 FormTracker.trackError({ 104 message: e.message, 105 source: e.filename, 106 line: e.lineno, 107 col: e.colno 108 }); 109 } catch (ex) { /* silent */ } 110 }); 111 112 window.addEventListener('unhandledrejection', function (e) { 113 try { 114 FormTracker.trackError({ 115 message: e.reason ? (e.reason.message || String(e.reason)) : 'Unhandled rejection' 116 }); 117 } catch (ex) { /* silent */ } 118 }); 119 120 // Page leave â flush remaining events 121 document.addEventListener('visibilitychange', function () { 122 try { 123 if (document.visibilityState === 'hidden') { 124 if (currentStep) { 125 enqueue('page_leave', { duration_ms: Date.now() - (stepTimer || Date.now()) }); 126 } 127 flush(); 128 } 129 } catch (e) { /* silent */ } 130 }); 131 132 window.addEventListener('beforeunload', function () { 133 try { 134 if (currentStep) { 135 enqueue('page_leave', { duration_ms: Date.now() - (stepTimer || Date.now()) }); 136 } 137 flush(); 138 } catch (e) { /* silent */ } 139 }); 140 141 } catch (e) { /* silent â never break the form */ } 142 }, 143 144 setEmail: function (email) {
145 try { config.email = email || null; } catch (e) { /* silent */ } 146 }, 147 148 setDealNumber: function (num) { 149 try { config.dealNumber = num || null; } catch (e) { /* silent */ } 150 }, 151 152 trackStep: function (stepName) { 153 try { 154 var now = Date.now(); 155 var duration = stepTimer ? (now - stepTimer) : null; 156 enqueue('step_change', { duration_ms: duration }); 157 currentStep = stepName; 158 stepTimer = now; 159 } catch (e) { /* silent */ } 160 }, 161 162 trackField: function (fieldName) { 163 try { 164 // Called on blur â only log if focus lasted > 3 seconds 165 var start = fieldTimers[fieldName]; 166 if (!start) return; 167 delete fieldTimers[fieldName]; 168 var duration = Date.now() - start; 169 if (duration >= FIELD_FOCUS_MIN) { 170 enqueue('field_focus', { field_name: fieldName, duration_ms: duration }); 171 } 172 } catch (e) { /* silent */ } 173 }, 174 175 fieldFocusStart: function (fieldName) { 176 try { fieldTimers[fieldName] = Date.now(); } catch (e) { /* silent */ } 177 }, 178 179 trackSubmit: function (success, detail) { 180 try { 181 enqueue(success ? 'submit_success' : 'submit_failure', { 182 detail: detail || null, 183 duration_ms: stepTimer ? (Date.now() - stepTimer) : null 184 }); 185 flush(); // immediate flush on submit 186 } catch (e) { /* silent */ } 187 }, 188 189 trackError: function (error) { 190 try { 191 enqueue('js_error', { detail: error || null }); 192 } catch (e) { /* silent */ } 193 } 194 }; 195 196 // Expose globally 197 window.FormTracker = FormTracker; 198})();
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.