← back to Watches
analytics/ga4-integration.js
379 lines
/**
* Google Analytics 4 Integration
* Comprehensive GA4 setup with event tracking, user properties, and custom dimensions
*/
class GA4Analytics {
constructor(measurementId) {
this.measurementId = measurementId;
this.isInitialized = false;
this.sessionId = this.generateSessionId();
this.userId = this.getUserId();
this.eventQueue = [];
}
/**
* Initialize GA4 with gtag.js
*/
async initialize() {
if (this.isInitialized) return;
// Load gtag.js script
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${this.measurementId}`;
document.head.appendChild(script);
// Initialize gtag
window.dataLayer = window.dataLayer || [];
window.gtag = function() { dataLayer.push(arguments); };
gtag('js', new Date());
gtag('config', this.measurementId, {
send_page_view: true,
user_id: this.userId,
custom_map: {
dimension1: 'watch_model',
dimension2: 'watch_series',
dimension3: 'price_range',
dimension4: 'year_introduced',
dimension5: 'session_id'
}
});
this.isInitialized = true;
this.processQueue();
this.setupAutoTracking();
}
/**
* Generate or retrieve session ID
*/
generateSessionId() {
let sessionId = sessionStorage.getItem('ga4_session_id');
if (!sessionId) {
sessionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
sessionStorage.setItem('ga4_session_id', sessionId);
}
return sessionId;
}
/**
* Get or create user ID
*/
getUserId() {
let userId = localStorage.getItem('ga4_user_id');
if (!userId) {
userId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('ga4_user_id', userId);
}
return userId;
}
/**
* Track custom event
*/
trackEvent(eventName, eventParams = {}) {
const event = {
event_name: eventName,
params: {
...eventParams,
session_id: this.sessionId,
timestamp: new Date().toISOString()
}
};
if (!this.isInitialized) {
this.eventQueue.push(event);
return;
}
gtag('event', eventName, event.params);
// Also send to our backend for custom analytics
this.sendToBackend(event);
}
/**
* Process queued events
*/
processQueue() {
while (this.eventQueue.length > 0) {
const event = this.eventQueue.shift();
gtag('event', event.event_name, event.params);
this.sendToBackend(event);
}
}
/**
* Send event to backend analytics
*/
async sendToBackend(event) {
try {
await fetch('/api/analytics/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event)
});
} catch (error) {
console.error('Failed to send analytics event:', error);
}
}
/**
* Setup automatic event tracking
*/
setupAutoTracking() {
// Track page visibility
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.trackEvent('page_hidden', {
time_on_page: this.getTimeOnPage()
});
} else {
this.trackEvent('page_visible');
}
});
// Track scroll depth
this.setupScrollTracking();
// Track outbound clicks
this.setupOutboundClickTracking();
// Track errors
window.addEventListener('error', (event) => {
this.trackEvent('javascript_error', {
error_message: event.message,
error_stack: event.error?.stack,
error_file: event.filename,
error_line: event.lineno
});
});
}
/**
* Track scroll depth
*/
setupScrollTracking() {
const thresholds = [25, 50, 75, 100];
const tracked = new Set();
const trackScroll = () => {
const scrollPercentage = Math.round(
(window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100
);
for (const threshold of thresholds) {
if (scrollPercentage >= threshold && !tracked.has(threshold)) {
tracked.add(threshold);
this.trackEvent('scroll_depth', {
scroll_percentage: threshold
});
}
}
};
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
window.requestAnimationFrame(() => {
trackScroll();
ticking = false;
});
ticking = true;
}
});
}
/**
* Track outbound clicks
*/
setupOutboundClickTracking() {
document.addEventListener('click', (event) => {
const link = event.target.closest('a');
if (link && link.hostname !== window.location.hostname) {
this.trackEvent('outbound_click', {
link_url: link.href,
link_text: link.textContent.trim(),
link_domain: link.hostname
});
}
});
}
/**
* Get time on page in seconds
*/
getTimeOnPage() {
const sessionStart = sessionStorage.getItem('page_load_time') || Date.now();
return Math.round((Date.now() - sessionStart) / 1000);
}
/**
* Set user properties
*/
setUserProperty(propertyName, propertyValue) {
gtag('set', 'user_properties', {
[propertyName]: propertyValue
});
}
/**
* Track page view
*/
trackPageView(pagePath, pageTitle) {
this.trackEvent('page_view', {
page_path: pagePath,
page_title: pageTitle,
page_location: window.location.href
});
}
/**
* E-commerce event tracking (for future features)
*/
trackPurchase(transactionId, value, items) {
this.trackEvent('purchase', {
transaction_id: transactionId,
value: value,
currency: 'USD',
items: items
});
}
/**
* Track watch view
*/
trackWatchView(watch) {
this.trackEvent('watch_view', {
watch_id: watch.id,
watch_model: watch.model,
watch_series: watch.series,
watch_reference: watch.reference,
current_price: watch.priceHistory?.[watch.priceHistory.length - 1]?.price,
year_introduced: watch.yearIntroduced,
price_range: this.getPriceRange(watch.priceHistory?.[watch.priceHistory.length - 1]?.price)
});
}
/**
* Track watch comparison
*/
trackWatchComparison(watches) {
this.trackEvent('watch_comparison', {
watch_count: watches.length,
watch_ids: watches.map(w => w.id).join(','),
watch_models: watches.map(w => w.model).join(',')
});
}
/**
* Track search
*/
trackSearch(searchTerm, resultsCount) {
this.trackEvent('search', {
search_term: searchTerm,
results_count: resultsCount
});
}
/**
* Track filter usage
*/
trackFilter(filterType, filterValue) {
this.trackEvent('filter_applied', {
filter_type: filterType,
filter_value: filterValue
});
}
/**
* Track chart interaction
*/
trackChartInteraction(interactionType, details) {
this.trackEvent('chart_interaction', {
interaction_type: interactionType,
...details
});
}
/**
* Get price range bucket
*/
getPriceRange(price) {
if (!price) return 'unknown';
if (price < 5000) return 'under_5k';
if (price < 10000) return '5k_to_10k';
if (price < 25000) return '10k_to_25k';
if (price < 50000) return '25k_to_50k';
return 'over_50k';
}
/**
* Track engagement time
*/
startEngagementTracking() {
let engagementTime = 0;
let isEngaged = !document.hidden;
let lastActiveTime = Date.now();
const updateEngagement = () => {
if (isEngaged) {
const now = Date.now();
engagementTime += now - lastActiveTime;
lastActiveTime = now;
}
};
// Track visibility changes
document.addEventListener('visibilitychange', () => {
updateEngagement();
isEngaged = !document.hidden;
lastActiveTime = Date.now();
});
// Track user activity
['mousedown', 'keydown', 'scroll', 'touchstart'].forEach(eventType => {
document.addEventListener(eventType, () => {
if (!isEngaged) {
isEngaged = true;
lastActiveTime = Date.now();
}
});
});
// Send engagement metrics every 30 seconds
setInterval(() => {
updateEngagement();
if (engagementTime > 0) {
this.trackEvent('user_engagement', {
engagement_time_msec: engagementTime
});
engagementTime = 0;
}
}, 30000);
// Send final engagement on unload
window.addEventListener('beforeunload', () => {
updateEngagement();
if (engagementTime > 0) {
navigator.sendBeacon('/api/analytics/events', JSON.stringify({
event_name: 'session_end',
params: {
engagement_time_msec: engagementTime,
session_id: this.sessionId
}
}));
}
});
}
}
// Export for use in main app
if (typeof module !== 'undefined' && module.exports) {
module.exports = GA4Analytics;
} else {
window.GA4Analytics = GA4Analytics;
}