/** * NordaBiz Analytics Tracker * Tracks user clicks, scroll depth, time on page, performance, and JS errors * Created: 2026-01-13 * Updated: 2026-01-30 (Analytics Expansion) */ (function() { 'use strict'; const HEARTBEAT_INTERVAL = 60000; // 60 seconds const SCROLL_DEBOUNCE_MS = 500; const TRACK_ENDPOINT = '/api/analytics/track'; const HEARTBEAT_ENDPOINT = '/api/analytics/heartbeat'; const SCROLL_ENDPOINT = '/api/analytics/scroll'; const ERROR_ENDPOINT = '/api/analytics/error'; const PERFORMANCE_ENDPOINT = '/api/analytics/performance'; let pageStartTime = Date.now(); let currentPageViewId = null; let maxScrollDepth = 0; let scrollTimeout = null; // Get page view ID from meta tag (set by Flask) function init() { const pageViewMeta = document.querySelector('meta[name="page-view-id"]'); if (pageViewMeta && pageViewMeta.content) { currentPageViewId = parseInt(pageViewMeta.content, 10); } // Start heartbeat setInterval(sendHeartbeat, HEARTBEAT_INTERVAL); // Track clicks document.addEventListener('click', handleClick, true); // Track time on page before leaving window.addEventListener('beforeunload', handleUnload); // Track visibility change (tab switch) document.addEventListener('visibilitychange', handleVisibilityChange); // Track scroll depth window.addEventListener('scroll', handleScroll, { passive: true }); // Track JS errors window.onerror = handleError; window.addEventListener('unhandledrejection', handlePromiseRejection); // Track performance metrics (after page load) if (document.readyState === 'complete') { trackPerformance(); } else { window.addEventListener('load', function() { // Wait a bit for all metrics to be available setTimeout(trackPerformance, 100); }); } // Track contact clicks (conversion tracking) trackContactClicks(); } function handleClick(e) { // Find the closest interactive element const target = e.target.closest('a, button, [data-track], input[type="submit"], .clickable, .company-card, nav a'); if (!target) return; const data = { type: 'click', page_view_id: currentPageViewId, element_type: getElementType(target, e), element_id: target.id || null, element_text: (target.textContent || '').trim().substring(0, 100) || null, element_class: target.className || null, target_url: target.href || target.closest('a')?.href || null, x: e.clientX, y: e.clientY }; sendTracking(data); } function getElementType(target, e) { // Determine element type more precisely if (target.closest('nav')) return 'nav'; if (target.closest('.company-card')) return 'company_card'; if (target.closest('form')) return 'form'; if (target.closest('.sidebar')) return 'sidebar'; if (target.closest('.dropdown')) return 'dropdown'; if (target.closest('.modal')) return 'modal'; if (target.tagName === 'A') return 'link'; if (target.tagName === 'BUTTON') return 'button'; if (target.tagName === 'INPUT') return 'input'; if (target.hasAttribute('data-track')) return target.getAttribute('data-track'); return target.tagName.toLowerCase(); } function handleUnload() { if (!currentPageViewId) return; const timeOnPage = Math.round((Date.now() - pageStartTime) / 1000); // Use sendBeacon for reliable tracking on page exit const data = JSON.stringify({ type: 'page_time', page_view_id: currentPageViewId, time_seconds: timeOnPage }); navigator.sendBeacon(TRACK_ENDPOINT, data); // Also send final scroll depth if (maxScrollDepth > 0) { navigator.sendBeacon(SCROLL_ENDPOINT, JSON.stringify({ page_view_id: currentPageViewId, scroll_depth: maxScrollDepth })); } } function handleVisibilityChange() { if (document.visibilityState === 'hidden' && currentPageViewId) { // Send page time when tab becomes hidden const timeOnPage = Math.round((Date.now() - pageStartTime) / 1000); sendTracking({ type: 'page_time', page_view_id: currentPageViewId, time_seconds: timeOnPage }); } } // ============================================================ // SCROLL DEPTH TRACKING // ============================================================ function handleScroll() { // Debounce scroll events if (scrollTimeout) { clearTimeout(scrollTimeout); } scrollTimeout = setTimeout(function() { calculateScrollDepth(); }, SCROLL_DEBOUNCE_MS); } function calculateScrollDepth() { const scrollTop = window.pageYOffset || document.documentElement.scrollTop; const scrollHeight = document.documentElement.scrollHeight; const clientHeight = document.documentElement.clientHeight; // Calculate percentage (0-100) const scrollPercent = Math.round((scrollTop + clientHeight) / scrollHeight * 100); // Only track if increased (max depth) if (scrollPercent > maxScrollDepth) { maxScrollDepth = Math.min(scrollPercent, 100); // Send to server at milestones: 25%, 50%, 75%, 100% if (currentPageViewId && (maxScrollDepth === 25 || maxScrollDepth === 50 || maxScrollDepth === 75 || maxScrollDepth >= 95)) { fetch(SCROLL_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ page_view_id: currentPageViewId, scroll_depth: maxScrollDepth }), credentials: 'same-origin' }).catch(function() { // Silently fail }); } } } // ============================================================ // ERROR TRACKING // ============================================================ function handleError(message, source, lineno, colno, error) { const errorData = { message: message ? message.toString() : 'Unknown error', source: source, lineno: lineno, colno: colno, stack: error && error.stack ? error.stack : null, url: window.location.href }; fetch(ERROR_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(errorData), credentials: 'same-origin' }).catch(function() { // Silently fail - don't cause more errors }); // Don't prevent default error handling return false; } function handlePromiseRejection(event) { const reason = event.reason; handleError( 'Unhandled Promise Rejection: ' + (reason && reason.message ? reason.message : String(reason)), null, null, null, reason instanceof Error ? reason : null ); } // ============================================================ // PERFORMANCE TRACKING // ============================================================ function trackPerformance() { if (!currentPageViewId) return; if (!window.performance || !performance.timing) return; const timing = performance.timing; const navStart = timing.navigationStart; // Calculate metrics const metrics = { page_view_id: currentPageViewId, dom_content_loaded_ms: timing.domContentLoadedEventEnd - navStart, load_time_ms: timing.loadEventEnd - navStart }; // Get paint metrics if available if (performance.getEntriesByType) { const paintEntries = performance.getEntriesByType('paint'); paintEntries.forEach(function(entry) { if (entry.name === 'first-paint') { metrics.first_paint_ms = Math.round(entry.startTime); } if (entry.name === 'first-contentful-paint') { metrics.first_contentful_paint_ms = Math.round(entry.startTime); } }); } // Only send if we have valid data if (metrics.load_time_ms > 0 && metrics.load_time_ms < 300000) { fetch(PERFORMANCE_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(metrics), credentials: 'same-origin' }).catch(function() { // Silently fail }); } } // ============================================================ // CONVERSION TRACKING (Contact Clicks) // ============================================================ function trackContactClicks() { // Track email clicks document.querySelectorAll('a[href^="mailto:"]').forEach(function(link) { link.addEventListener('click', function(e) { trackConversion('contact_click', 'email', link.href.replace('mailto:', '')); }); }); // Track phone clicks document.querySelectorAll('a[href^="tel:"]').forEach(function(link) { link.addEventListener('click', function(e) { trackConversion('contact_click', 'phone', link.href.replace('tel:', '')); }); }); // Track website clicks (external links from company pages) if (window.location.pathname.startsWith('/company/')) { document.querySelectorAll('a[target="_blank"][href^="http"]').forEach(function(link) { // Only track links that look like company websites (not social media) const href = link.href.toLowerCase(); if (!href.includes('facebook.com') && !href.includes('linkedin.com') && !href.includes('instagram.com') && !href.includes('twitter.com')) { link.addEventListener('click', function(e) { trackConversion('contact_click', 'website', link.href); }); } }); } } function trackConversion(eventType, targetType, targetValue) { // Get company_id from page if available let companyId = null; const companyMeta = document.querySelector('meta[name="company-id"]'); if (companyMeta && companyMeta.content) { companyId = parseInt(companyMeta.content, 10); } fetch('/api/analytics/conversion', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event_type: eventType, target_type: targetType, target_value: targetValue, company_id: companyId }), credentials: 'same-origin' }).catch(function() { // Silently fail }); } // ============================================================ // HELPERS // ============================================================ function sendHeartbeat() { fetch(HEARTBEAT_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin' }).catch(function() { // Silently fail }); } function sendTracking(data) { fetch(TRACK_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), credentials: 'same-origin' }).catch(function() { // Silently fail }); } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();