Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Brave free tier was returning 429 for ~50% of queries due to back-to-back requests. Added 1.1s delay between queries and retry with exponential backoff (1.5s, 3s). Heartbeat endpoint exempted from Flask-Limiter and interval increased from 30s to 60s to reduce log noise. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
348 lines
12 KiB
JavaScript
348 lines
12 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|
|
})();
|