← 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;
}