Client-Side Script

Last Updated: 2025-08-22

        
const nvStorage = (() => {
  let m={}, ok=1;
  const warn=()=>{if(ok){console.warn('Local storage disabled');ok=0;}};
  return {
    getItem:k=>{try{return ok?localStorage.getItem(k):m[k]||null;}catch(e){warn();return m[k]||null;}},
    setItem:(k,v)=>{try{if(ok)return localStorage.setItem(k,v);}catch(e){warn();}m[k]=v;},
    removeItem:k=>{try{if(ok)return localStorage.removeItem(k);}catch(e){warn();}delete m[k];}
  };
})();

window.stopNimvueTracking = function() {
  nvStorage.setItem('nv_tracking_halted', '1');
};
window.startNimvueTracking = function() {
  nvStorage.removeItem('nv_tracking_halted');
};

if (window.nvConfig && window.nvConfig.disableTracking) {
  window.stopNimvueTracking();
}

(() => {

  if (window.nvScriptLoaded) return;

  window.nvScriptLoaded = true;

  function isTrackingHalted() {
    return nvStorage.getItem('nv_tracking_halted') === '1';
  }

  const ingestUrl = 'http://127.0.0.1:8000/ingest';
  const infoUrl = 'http://127.0.0.1:8000/info';
  const clientId = document.querySelector('script[data-ba-measurement-id]')?.getAttribute('data-ba-measurement-id');
  if (!clientId) throw new Error('Missing measurement ID');

  searchParams = '', scrollBreakpoints = '', engagedTime = 0, isTracking = false, isUserActive = false, idleTimeout = null, currentPageEventId = null, stopTracking = 0;
  const eventQueue = [], FLUSH_INTERVAL = 700, MAX_BATCH_SIZE = 10;
  let flushTimer = null;

  function queueEvent(ev) {
    if (isTrackingHalted()) return;
    eventQueue.push(...ev);
    if (eventQueue.length >= MAX_BATCH_SIZE) return flushEventQueue();
    if (!flushTimer) flushTimer = setTimeout(flushEventQueue, FLUSH_INTERVAL);
  }

  function flushEventQueue() {
    if (isTrackingHalted()) return;
    if (!eventQueue.length) return;
    const toSend = eventQueue.splice(0), resetFlush = () => flushTimer = null;
    fetch(ingestUrl, {
      method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(toSend)
    }).then(res => res.ok ? res.json() : Promise.reject('Bad network response')).then(data => {
      if (data?.mh) createMeasurementDataHash().then(hash => {
        if (hash !== data.mh) {
          getMeasurementData().then(newData => newData && nvStorage.setItem('nv_measurementData', JSON.stringify(newData)));
        }
      });
    }).catch(e => console.error('Batch send error:', e)).finally(resetFlush);
  }

  const UUID = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => (c == 'x' ? Math.random() * 16 | 0 : (Math.random() * 16 | 0 & 0x3 | 0x8)).toString(16));

  function buildEvent(type, time, p1Key, p1Val, p2Key, p2Val) {
    const eventId = UUID();
    if (type === 'page_view') currentPageEventId = eventId;
    const info = {};
    if (p1Key && p1Val !== undefined) info[p1Key] = p1Val;
    if (p2Key && p2Val !== undefined) info[p2Key] = p2Val;
    return [{
      ei: eventId, ci: clientId, et: type,
      pp: location.href,
      sr: `${screen.width}x${screen.height}`, pt: document.title, pr: document.referrer,
      ...(Object.keys(info).length ? { einfo: info } : {})
    }];
  }

  async function getMeasurementData() {
    try {
      const cachedData = nvStorage.getItem('nv_measurementData');
      if (cachedData) {
        return JSON.parse(cachedData);
      }
      const res = await fetch(infoUrl, {
        method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
        body: JSON.stringify([{ client_id: clientId }])
      });
      if (!res.ok) throw new Error('Failed to fetch measurement data');
      const data = await res.json();
      nvStorage.setItem('nv_measurementData', JSON.stringify(data));
      return data;
    } catch (e) { console.error('Measurement fetch error:', e); throw e; }
  }

  function flushEngagement() {
    if (isTrackingHalted()) return;
    if (engagedTime > 0) {
      const now = Date.now();
      const events = [
        ...buildEvent("session_engagement_time", now, "engaged_time", engagedTime, "current_page_event_id", currentPageEventId)
      ];
      queueEvent(events);
      engagedTime = 0;
    }
  }

  function startEngagementTracking() {
    if (isTracking) return; isTracking = true;
    ['mousemove', 'keydown', 'click', 'scroll'].forEach(evt => window.addEventListener(evt, markUserActive));
    document.addEventListener('visibilitychange', () => { isUserActive = !document.hidden; });
    startIdleWatcher(); startTicking();
    window.addEventListener('beforeunload', () => {
      if (isTrackingHalted()) return;
      if (engagedTime > 0) {
        const now = Date.now(), events = [
          ...buildEvent("session_engagement_time", now, "engaged_time", engagedTime, "current_page_event_id", currentPageEventId)
        ];
        try {
          navigator.sendBeacon(ingestUrl, new Blob([JSON.stringify(events)], { type: 'text/plain' }));
        } catch (e) {
          queueEvent(events);
        }
      }
    });
  }
  function markUserActive() { isUserActive = true; clearTimeout(idleTimeout); startIdleWatcher(); }
  function startIdleWatcher() { idleTimeout = setTimeout(() => isUserActive = false, 10000); }
  function startTicking() { setInterval(() => { if (!document.hidden && isUserActive && isTracking) engagedTime += 0.5; }, 500); }
  function getScrollPercentage() {
    const t = document.documentElement, total = t.scrollHeight - t.clientHeight, pos = window.scrollY || t.scrollTop;
    return (pos / total) * 100;
  }
  function processFurtherEvents() { startScrollTracking(); startSearchTracking(); startEngagementTracking(); }
  function startScrollTracking() {
    const sent = new Set();
    if (scrollBreakpoints && scrollBreakpoints.trim()) {
      const breakpoints = scrollBreakpoints.split(',').map(bp => parseInt(bp.trim(), 10)).filter(bp => !isNaN(bp) && bp >= 0 && bp <= 100);
      if (breakpoints.length) window.addEventListener('scroll', () => {
        if (isTrackingHalted()) return;
        const percent = Math.round(getScrollPercentage());
        if (breakpoints.includes(percent) && !sent.has(percent)) {
          sent.add(percent);
          queueEvent(buildEvent('scroll', Date.now(), 'percent_scrolled', percent, 'current_page_event_id', currentPageEventId));
        }
      });
    }
  }
  function startSearchTracking() {
    if (searchParams && searchParams.trim()) {
      const params = searchParams.split(',').map(p => p.trim()), urlParams = new URLSearchParams(location.search);
      params.filter(param => urlParams.has(param)).forEach(term => {
        if (isTrackingHalted()) return;
        const val = urlParams.get(term)?.trim().toLowerCase();
        if (val) queueEvent(buildEvent('search', Date.now(), 'search_term', val));
      });
    }
  }
  function initSession() {
    if (isTrackingHalted()) return;
    const timestamp = Date.now();
    queueEvent(buildEvent('page_view', timestamp));
    processFurtherEvents();
  }

  getMeasurementData().then(data => {
    searchParams = data?.sp || '';
    scrollBreakpoints = data?.sb || '';
    stopTracking = data?.st || 0;
    if (stopTracking === 1) return console.warn('Tracking stopped for this measurement');
    initSession();
  });

  window.nv_create_event = function({ event_name, parameters = {} }) {
    if (isTrackingHalted()) return;
    if (!event_name || typeof parameters !== 'object') return console.error("Invalid custom event");
    const cleanParams = {};
    for (const [k, v] of Object.entries(parameters)) cleanParams[k.slice(0, 36)] = v;
    const payload = [{
      ei: UUID(), ci: clientId, et: event_name,
      pp: location.href,
      sr: `${screen.width}x${screen.height}`, pt: document.title, pr: document.referrer,
      ...(Object.keys(cleanParams).length ? { einfo: cleanParams } : {})
    }];
    window.nv_events = window.nv_events || [];
    window.nv_events.push({ event_name, parameters: cleanParams });
    queueEvent(payload);
  };

  function createMeasurementDataHash() {
    const data = nvStorage.getItem('nv_measurementData');
    if (!data) return null;
    const encoder = new TextEncoder();
    return crypto.subtle.digest('SHA-256', encoder.encode(data))
      .then(hash => Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16))
      .catch(e => (console.error('Hashing error:', e), null));
  }

  var enableSpaSupport = window.nvConfig && window.nvConfig.enableSpaSupport;

  if (enableSpaSupport) {
    let lastPath = location.pathname + location.search;
    let spaDebounceTimer;

    function spaPageView() {
      if (isTrackingHalted()) return;
      flushEngagement();
      queueEvent(buildEvent('page_view', Date.now()));
    }
    function debouncedSpaPageView() {
      clearTimeout(spaDebounceTimer);
      spaDebounceTimer = setTimeout(spaPageView, 500);
    }

    setInterval(() => {
      const currentPath = location.pathname + location.search;
      if (currentPath !== lastPath) {
        lastPath = currentPath;
        debouncedSpaPageView();
      }
    }, 250);
  }

})();