nordabiz/static/js/analytics-tracker.js
Maciej Pienczyn c4c113aa6f
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
fix(zopk): Add Brave API rate limit handling and heartbeat limit fix
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>
2026-02-09 14:43:34 +01:00

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();
}
})();