1(function () { 2 'use strict'; 3 4 // Early exit if no MetForm forms on the page (check immediately if DOM is ready) 5 if (!document.querySelector('.mf-form-wrapper')) { 6 return; 7 } 8 9 // --- Configuration --- 10 const activeSessions = {}; // Track active form sessions for metadata 11 12 /** 13 * Calculate session duration for a form 14 * @param {string} formId - Form ID 15 * @returns {number} Duration in seconds 16 */ 17 function getSessionDuration(formId) { 18 const key = 'mf_session_' + formId; 19 const stored = sessionStorage.getItem(key); 20 21 if (!stored) { 22 return 0; 23 } 24 25 try { 26 const session = JSON.parse(stored); 27 return Math.floor((Date.now() - session.startTime) / 1000); 28 } catch (e) { 29 return 0; 30 } 31 } 32 33 // --- 2. Client-side detection functions --- 34 function detectDevice() { 35 const ua = navigator.userAgent.toLowerCase(); 36 if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) { 37 return 'tablet'; 38 } 39 if (/Mobile|iP(hone|od)|Android|BlackBerry|IEMobile|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/i.test(ua)) { 40 return 'mobile'; 41 } 42 return 'desktop'; 43 } 44 function detectBrowser() { 45 const ua = navigator.userAgent.toLowerCase(); 46 if (ua.indexOf('opera') > -1 || ua.indexOf('opr') > -1) return 'opera'; 47 if (ua.indexOf('edg') > -1) return 'edge'; 48 if (ua.indexOf('firefox') > -1) return 'firefox'; 49 if (ua.indexOf('trident') > -1 || ua.indexOf('msie') > -1) return 'ie'; 50 if (ua.indexOf('chrome') > -1) return 'chrome'; 51 if (ua.indexOf('safari') > -1) return 'safari'; 52 return 'other'; 53 } 54 55 function detectTrafficSource() { 56 const referrer = document.referrer; 57 if (!referrer) { 58 return 'direct'; 59 } 60 61 const currentDomain = window.location.hostname; 62 const referrerUrl = new URL(referrer); 63 const referrerDomain = referrerUrl.hostname; 64 65 if (referrerDomain === currentDomain) { 66 return 'direct'; 67 } 68 69 const searchEngines = ['google', 'bing', 'yahoo', 'duckduckgo', 'baidu', 'yandex']; 70 for (const engine of searchEngines) { 71 if (referrerDomain.includes(engine)) { 72 return 'organic'; 73 } 74 } 75 76 return 'referral'; 77 } 78 79 /** 80 * Get referrer URL with same-domain filtering 81 * Returns empty string if referrer is same domain or not available 82 * @returns {string} Referrer URL or empty string if same domain or not available 83 */ 84 function getReferrer() { 85 const referrer = document.referrer; 86 if (!referrer) { 87 return ''; 88 } 89 90 try { 91 const currentDomain = window.location.hostname; 92 const referrerUrl = new URL(referrer); 93 const referrerDomain = referrerUrl.hostname; 94 95 // Return empty if same domain 96 if (referrerDomain === currentDomain) { 97 return ''; 98 } 99 } catch (e) { 100 // If URL parsing fails, return the referrer as-is 101 } 102 103 return referrer; 104 } 105 106 /** 107 * Set cookie 108 * @param {string} name - Cookie name 109 * @param {string} value - Cookie value 110 * @param {number} days - Days until expiry 111 */ 112 function setCookie(name, value, days) { 113 const date = new Date(); 114 date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); 115 const expires = "expires=" + date.toUTCString(); 116 const secure = window.location.protocol === 'https:' ? ';Secure' : ''; 117 document.cookie = name + "=" + value + ";" + expires + ";path=/;SameSite=Lax" + secure; 118 } 119 120 // --- Country detection start --- 121 let countryData = null; 122 let countryFetched = false; 123 124 /** 125 * Fetch country name from ipinfo.io (client-side) 126 * Caches result in memory to avoid multiple requests 127 * @returns {Promise<string>} Country name or empty string if not available 128 */ 129 async function getCountry() { 130 // Return cached country if already fetched 131 if (countryFetched) { 132 return countryData; 133 } 134 135 // Respect the server-side privacy gate: skip the third-party (ipinfo.io) 136 // lookup unless country detection is explicitly enabled. 137 if (typeof metformAnalyticsTracking === 'undefined' || !metformAnalyticsTracking.countryDetection) { 138 countryData = ''; 139 countryFetched = true; 140 return countryData; 141 } 142 143 try { 144 const response = await fetch('https://ipinfo.io/json'); 145 if (response.ok) { 146 const data = await response.json(); 147 countryData = data.country || ''; 148 } 149 } catch (e) { 150 // Silently fail if country detection doesn't work 151 countryData = ''; 152 } 153 154 countryFetched = true; 155 return countryData; 156 } 157 158 /** 159 * Track form view with analytics data 160 * async function to allow country detection before sending data 161 * @param {string} formId - The ID of the form being viewed 162 */ 163 async function trackFormView(formId) { 164 if (typeof metformAnalyticsTracking === 'undefined' || !metformAnalyticsTracking.nonce) { 165 return; 166 } 167 168 const duration = getSessionDuration(formId); 169 const device = detectDevice(); 170 const browser = detectBrowser(); 171 const source = detectTrafficSource(); 172 const referrer = getReferrer(); 173 const country = await getCountry(); 174 175 // Persist country/referrer for the server-side conversion tracker 176 // (base.php::track_submission reads this cookie on form submission). 177 const handoff = JSON.stringify({ country: country || '', referrer: referrer || '' });
178 setCookie('mf_analytics_' + formId, encodeURIComponent(handoff), 1); 179 180 fetch(`${metformAnalyticsTracking.restUrl}/track_view/${formId}`, { 181 method: 'POST', 182 headers: { 183 'X-WP-Nonce': metformAnalyticsTracking.nonce, 184 'Content-Type': 'application/json', 185 }, 186 body: JSON.stringify({ 187 session_duration: duration, 188 device: device, 189 browser: browser, 190 source: source, 191 referrer: referrer, 192 country: country || '' 193 }) 194 }).catch(() => {}); 195 } 196 197 /** 198 * Send incremental duration update (Burst-style delta) 199 * This allows multiple updates without double counting 200 * @param {string} formId - Form ID 201 * @param {boolean} isFinal - Whether this is a final update 202 */ 203 function sendDurationUpdate(formId) { 204 if (typeof metformAnalyticsTracking === 'undefined') return; 205 206 const sessionKey = 'mf_session_' + formId; 207 const stored = sessionStorage.getItem(sessionKey); 208 if (!stored) return; 209 210 const session = JSON.parse(stored); 211 const totalDuration = Math.floor((Date.now() - session.startTime) / 1000); 212 const delta = totalDuration - (session.sentDuration || 0); 213 214 // Only send if there's new time (at least 1 second) 215 if (delta <= 0) return; 216 217 // Update sent duration in storage BEFORE sending to handle race conditions 218 session.sentDuration = (session.sentDuration || 0) + delta; 219 sessionStorage.setItem(sessionKey, JSON.stringify(session)); 220 221 const data = JSON.stringify({ 222 session_duration: delta, // Send only the increment 223 is_update: true // Crucial: This ensures views are NOT incremented on update 224 }); 225 226 const url = `${metformAnalyticsTracking.restUrl}/track_view/${formId}`; 227 228 // send duration update to server 229 if (navigator.sendBeacon) { // sendBeacon is used for sending data to the server when the page is being unloaded 230 const urlWithNonce = `${url}?_wpnonce=${metformAnalyticsTracking.nonce}`; 231 const blob = new Blob([data], { type: 'application/json' }); // blob is used for sending data to the server when the page is being unloaded 232 navigator.sendBeacon(urlWithNonce, blob); 233 } else { 234 fetch(url, { 235 method: 'POST', 236 headers: { 237 'X-WP-Nonce': metformAnalyticsTracking.nonce, 238 'Content-Type': 'application/json', 239 }, 240 body: data, 241 keepalive: true // keepalive is used for sending data to the server when the page is being unloaded 242 }).catch(() => { }); 243 } 244 } 245 246 /** 247 * Extract form ID from .mf-form-wrapper element 248 * @param {Element} formWrapper - The .mf-form-wrapper element 249 * @returns {string|null} The form ID or null if not found 250 */ 251 function getFormId(formWrapper) { 252 let formId = formWrapper.getAttribute('data-form-id'); 253 if (formId) return formId; 254 255 const wrapperId = formWrapper.id; 256 if (wrapperId) { 257 const m = wrapperId.match(/metform-wrap-(\d+)/); 258 if (m) return m[1]; 259 } 260 return null; 261 } 262 263 /** 264 * Setup optimized tracking events 265 * Uses visibilitychange and pagehide for incremental updates 266 */ 267 function setupEventListeners() { 268 // Track when user switches tabs or minimizes window 269 document.addEventListener('visibilitychange', () => { 270 if (document.hidden) {
271 Object.keys(activeSessions).forEach(formId => { 272 sendDurationUpdate(formId); 273 }); 274 } 275 }); 276 277 // Track when page is being hidden (closing tab, navigating away) 278 window.addEventListener('pagehide', () => { 279 Object.keys(activeSessions).forEach(formId => { 280 sendDurationUpdate(formId); 281 }); 282 }); 283 } 284 285 /** 286 * Initialize IntersectionObserver to track when forms come into view 287 * Observes all .mf-form-wrapper elements and tracks view when they are at least 50% visible 288 * Uses trackFormView to send analytics data when a form is viewed 289 * Ensures each form is only tracked once per page load 290 * @returns {void} 291 */ 292 function initIntersectionObserver() { 293 const formWrappers = document.querySelectorAll('.mf-form-wrapper'); 294 if (!formWrappers.length) return; 295 296 const observer = new IntersectionObserver((entries) => { 297 entries.forEach((entry) => { 298 if (entry.isIntersecting) { 299 const formWrapper = entry.target; // .mf-form-wrapper 300 const formId = getFormId(formWrapper); 301 if (formId && !activeSessions[formId]) { 302 // Initialize session for this page load/refresh 303 const now = Date.now(); 304 const session = { 305 startTime: now, 306 sentDuration: 0 307 }; 308 sessionStorage.setItem('mf_session_' + formId, JSON.stringify(session)); 309 activeSessions[formId] = true; 310 311 trackFormView(formId); 312 } 313 } 314 }); 315 }, { 316 root: null, 317 rootMargin: '0px', 318 threshold: 0.5, // Consider form "viewed" when 50% visible 319 }); 320 321 formWrappers.forEach((wrapper) => observer.observe(wrapper)); // Observe all metform form wrapper in loading page 322 } 323 324 // Auto init 325 function init() { 326 initIntersectionObserver(); 327 setupEventListeners(); 328 } 329 330 if (document.readyState === 'loading') { 331 document.addEventListener('DOMContentLoaded', init); 332 } else { 333 init(); 334 } 335 336 window.MetFormAnalytics = { 337 trackView: trackFormView, 338 getSessionDuration: (formId) => getSessionDuration(formId) 339 }; 340})();
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.