PageSourceSearch

https://ckwebsolutions.com/js/tracker.js?v=5

js ckwebsolutions.com collected 2026-09-24 14:40:20 UTC 20,892 bytes, 644 lines download raw bytes

1// CK Analytics Tracker
2// Lightweight self-hosted analytics using Firebase Firestore REST API
3
4(function() {
5  'use strict';
6
7  // Firebase config
8  const PROJECT_ID = 'ck-dev-united-apps';
9  const DATABASE_ID = 'ckwebsolutions-analytics';
10  const API_KEY = 'AIzaSyD4Hi49mKEXk4TAevDBdBFcAEzLsaMbAfI';
11
12  // Firestore REST API base URL
13  const FIRESTORE_URL = `https://firestore.googleapis.com/v1/projects/${PROJECT_ID}/databases/${DATABASE_ID}/documents`;
14
15  // Generate or retrieve visitor ID (persists forever in localStorage)
16  function getVisitorId() {
17    let visitorId = localStorage.getItem('ck_visitor_id');
18    if (!visitorId) {
19      visitorId = 'v_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now();
20      localStorage.setItem('ck_visitor_id', visitorId);
21    }
22    return visitorId;
23  }
24
25  // Generate or retrieve visit ID (persists in sessionStorage - same across pages until browser closes)
26  function getVisitId() {
27    let visitId = sessionStorage.getItem('ck_visit_id');
28    if (!visitId) {
29      visitId = 'visit_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now();
30      sessionStorage.setItem('ck_visit_id', visitId);
31    }
32    return visitId;
33  }
34
35  // Generate unique page ID for each page load (NOT persisted - new each time)
36  function generatePageId() {
37    return 'page_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now();
38  }
39
40  // Get or initialize pages viewed in this visit
41  function getPagesViewed() {
42    const stored = sessionStorage.getItem('ck_pages_viewed');
43    return stored ? JSON.parse(stored) : [];
44  }
45
46  function addPageViewed(pageName) {
47    const pages = getPagesViewed();
48    const pageData = {
49      path: window.location.pathname,
50      name: pageName,
51      timestamp: Date.now(),
52      time: new Date().toISOString()
53    };
54    pages.push(pageData);
55    sessionStorage.setItem('ck_pages_viewed', JSON.stringify(pages));
56    return pages;
57  }
58
59  // Get page name from path
60  function getPageName() {
61    const path = window.location.pathname;
62    if (path === '/' || path === '/index.html') return 'Home';
63    if (path.includes('web-solutions')) return 'Web Solutions';
64    if (path.includes('ai-labs')) return 'AI Labs';
65    if (path.includes('book')) return 'Book a Call';
66    // Handle case study pages
67    if (path.includes('cs-')) {
68      const name = path.replace(/.*cs-/, '').replace('.html', '').replace(/-/g, ' ');
69      return 'Case Study: ' + name.charAt(0).toUpperCase() + name.slice(1);
70    }
71    return path.replace('.html', '').replace(/\//g, '') || 'Home';
72  }
73
74  // Get device type based on screen width
75  function getDeviceType() {
76    const width = window.innerWidth;
77    if (width < 768) return 'mobile';
78    if (width < 1024) return 'tablet';
79    return 'desktop';
80  }
81
82  // Convert JS object to Firestore document format
83  function toFirestoreValue(value) {
84    if (value === null || value === undefined) {
85      return { nullValue: null };
86    }
87    if (typeof value === 'string') {
88      return { stringValue: value };
89    }
90    if (typeof value === 'number') {
91      if (Number.isInteger(value)) {
92        return { integerValue: String(value) };
93      }
94      return { doubleValue: value };
95    }
96    if (typeof value === 'boolean') {
97      return { booleanValue: value };
98    }
99    if (Array.isArray(value)) {
100      return { arrayValue: { values: value.map(toFirestoreValue) } };
101    }
102    if (typeof value === 'object') {
103      const fields = {};
104      for (const key in value) {
105        if (value.hasOwnProperty(key)) {
106          fields[key] = toFirestoreValue(value[key]);
107        }
108      }
109      return { mapValue: { fields: fields } };
110    }
111    return { stringValue: String(value) };
112  }
113
114  // Convert entire object to Firestore document
115  function toFirestoreDoc(obj) {
116    const fields = {};
117    for (const key in obj) {
118      if (obj.hasOwnProperty(key)) {
119        fields[key] = toFirestoreValue(obj[key]);
120      }
121    }
122    return { fields: fields };
123  }
124
125  // ========== CLICK IDENTIFICATION ==========
126  // Build a descriptive click ID from element context
127
128  function getClickId(element) {
129    // 1. Check for data-analytics-id (highest priority)
130    if (element.dataset && element.dataset.analyticsId) {
131      return element.dataset.analyticsId;
132    }
133
134    // 2. Check for id attribute
135    if (element.id) {
136      return element.id;
137    }
138
139    // 3. Check parent for data-analytics-id
140    const parent = element.closest('[data-analytics-id]');
141    if (parent && parent.dataset.analyticsId) {
142      return parent.dataset.analyticsId;
143    }
144
145    // 4. Build from context: find nearest card/section with identifiable info
146    const card = element.closest('.service-card, .product-card, .path-card, .capability-card, .case-study-card, .ai-card, [class*="card"]');
147    if (card) {
148      const cardTitle = card.querySelector('h3, h4, .card-title, .service-title, .product-title');
149      if (cardTitle) {
150        const titleText = cardTitle.textContent.trim().substring(0, 30).toLowerCase().replace(/\s+/g, '-');
151        const buttonText = element.textContent.trim().substring(0, 20).toLowerCase().replace(/\s+/g, '-');
152        return titleText + '_' + buttonText;
153      }
154    }
155
156    // 5. Build from section context
157    const section = element.closest('section[id], [id]');
158    if (section && section.id) {
159      const buttonText = element.textContent.trim().substring(0, 20).toLowerCase().replace(/\s+/g, '-');
160      return section.id + '_' + buttonText;
161    }
162
163    // 6. Fallback: use text content
164    return element.textContent.trim().substring(0, 30).toLowerCase().replace(/\s+/g, '-') || 'unknown';
165  }
166
167  // ========== SESSION DATA ==========
168
169  const visitorId = getVisitorId();
170  const visitId = getVisitId();
171  const pageId = generatePageId();
172  const pageStart = Date.now();
173  const pageEnterTime = pageStart;  // Alias for clarity
174  const currentPage = getPageName();
175  const clicks = [];
176  const scrollDepths = [];
177  let maxScrollPercent = 0;
178
179  // Track page height (for accurate replay coordinate calculation)
180  let currentPageHeight = document.documentElement.scrollHeight;
181  function updatePageHeight() {
182    currentPageHeight = document.documentElement.scrollHeight;
183  }
184  window.addEventListener('resize', updatePageHeight);
185  // Check periodically for dynamic content
186  setInterval(updatePageHeight, 5000);
187
188  // ========== REPLAY DATA (Mouse & Scroll) ==========
189  const mouseMovements = [];  // {x, y, t} - position as % of viewport, t = ms since page start
190  const scrollPositions = []; // {y, t} - scroll position in px, t = ms since page start
191  let lastMouseX = -1, lastMouseY = -1;
192  let lastScrollY = -1;
193  let isRecordingMouse = true;
194
195  // Throttled mouse tracking (every 50ms when moving)
196  let mouseThrottle = null;
197  document.addEventListener('mousemove', function(e) {
198    if (!isRecordingMouse) return;
199    if (mouseThrottle) return;
200
201    mouseThrottle = setTimeout(function() {
202      mouseThrottle = null;
203    }, 50);
204
205    // Convert to percentage of viewport for responsive replay
206    const x = Math.round((e.clientX / window.innerWidth) * 1000) / 10;  // 0.0 to 100.0
207    const y = Math.round((e.clientY / window.innerHeight) * 1000) / 10;
208
209    // Only record if position changed significantly (> 0.5%)
210    if (Math.abs(x - lastMouseX) > 0.5 || Math.abs(y - lastMouseY) > 0.5) {
211      lastMouseX = x;
212      lastMouseY = y;
213      mouseMovements.push({
214        x: x,
215        y: y,
216        t: Date.now() - pageStart
217      });
218
219      // Cap at 5000 points per page (~4 min of continuous movement)
220      if (mouseMovements.length > 5000) {
221        mouseMovements.shift();
222      }
223    }
224  });
225
226  // Continuous scroll tracking (every 100ms when scrolling)
227  let scrollTrackThrottle = null;
228  window.addEventListener('scroll', function() {
229    if (scrollTrackThrottle) return;
230
231    scrollTrackThrottle = setTimeout(function() {
232      scrollTrackThrottle = null;
233
234      const scrollY = window.pageYOffset || document.documentElement.scrollTop;
235
236      // Only record if scroll changed by more than 10px
237      if (Math.abs(scrollY - lastScrollY) > 10) {
238        lastScrollY = scrollY;
239        scrollPositions.push({
240          y: scrollY,
241          t: Date.now() - pageStart
242        });
243
244        // Cap at 2000 points per page
245        if (scrollPositions.length > 2000) {
246          scrollPositions.shift();
247        }
248      }
249    }, 100);
250  }, { passive: true });
251
252  // Record initial scroll position
253  scrollPositions.push({
254    y: window.pageYOffset || 0,
255    t: 0
256  });
257
258  // ========== IDLE HEARTBEATS ==========
259  // Record periodic "still here" markers to capture full visit duration even during idle periods
260  const HEARTBEAT_INTERVAL = 5000;
261  setInterval(function() {
262    const now = Date.now();
263    const timeSinceStart = now - pageStart;
264
265    // Add heartbeat to mouse array if no recent movement
266    const lastMouseTime = mouseMovements.length > 0 ? mouseMovements[mouseMovements.length - 1].t : 0;
267    if (timeSinceStart - lastMouseTime > HEARTBEAT_INTERVAL) {
268      mouseMovements.push({
269        x: lastMouseX >= 0 ? lastMouseX : 50, // Use last known position or center
270        y: lastMouseY >= 0 ? lastMouseY : 50,
271        t: timeSinceStart,
272        heartbeat: true
273      });
274      // Cap at 5000 points
275      if (mouseMovements.length > 5000) {
276        mouseMovements.shift();
277      }
278    }
279
280    // Add heartbeat to scroll array if no recent scroll
281    const lastScrollTime = scrollPositions.length > 0 ? scrollPositions[scrollPositions.length - 1].t : 0;
282    if (timeSinceStart - lastScrollTime > HEARTBEAT_INTERVAL) {
283      scrollPositions.push({
284        y: window.pageYOffset || document.documentElement.scrollTop || 0,
285        t: timeSinceStart,
286        heartbeat: true
287      });
288      // Cap at 2000 points
289      if (scrollPositions.length > 2000) {
290        scrollPositions.shift();
291      }
292    }
293  }, HEARTBEAT_INTERVAL);
294
295  // Track this page view
296  const pagesViewed = addPageViewed(currentPage);
297
298  // Section attention tracking
299  const sectionTime = {};
300  const sectionVisible = {};
301
302  // Get visit start time (from first page in this visit)
303  function getVisitStartTime() {
304    const stored = sessionStorage.getItem('ck_visit_start');
305    if (stored) return parseInt(stored);
306    sessionStorage.setItem('ck_visit_start', String(pageStart));
307    return pageStart;
308  }
309  const visitStart = getVisitStartTime();
310
311  // Core identity fields - always included in every save
312  const coreData = {
313    visitorId: visitorId,
314    visitId: visitId,
315    currentPage: currentPage,
316    currentPath: window.location.pathname,
317    referrer: document.referrer || 'direct',
318    device: {
319      width: window.innerWidth,
320      height: window.innerHeight,
321      type: getDeviceType(),
322      userAgent: navigator.userAgent
323    },
324    visitStartTime: new Date(visitStart).toISOString(),
325    visitStartTimestamp: visitStart
326  };
327
328  // ========== NEW DATA MODEL: Per-Page Subcollections ==========
329
330  // Save page-specific data to subcollection: analytics_visits/{visitId}/pages/{pageId}
331  function savePageData(isFinal) {
332    const now = Date.now();
333    const pageDurationMs = now - pageEnterTime;
334
335    const pageData = {
336      pageId: pageId,
337      pagePath: window.location.pathname,
338      pageName: currentPage,
339      enterTime: new Date(pageEnterTime).toISOString(),
340      exitTime: isFinal ? new Date(now).toISOString() : null,
341      durationMs: pageDurationMs,
342      viewport: {
343        width: window.innerWidth,
344        height: window.innerHeight
345      },
346      pageHeight: currentPageHeight,
347      mouse: mouseMovements,
348      scroll: scrollPositions,
349      clicks: clicks.filter(function(c) { return c.pageId === pageId; }),
350      scrollDepths: scrollDepths.filter(function(s) { return s.page === currentPage; }),
351      maxScrollPercent: maxScrollPercent,
352      sectionTime: getSectionTimeData()
353    };
354
355    const url = `${FIRESTORE_URL}/analytics_visits/${visitId}/pages/${pageId}?key=${API_KEY}`;
356    const doc = toFirestoreDoc(pageData);
357
358    fetch(url, {
359      method: 'PATCH',
360      headers: { 'Content-Type': 'application/json' },
361      body: JSON.stringify(doc),
362      keepalive: isFinal
363    }).catch(function(err) {
364      console.warn('Analytics: Failed to save page data', err);
365    });
366  }
367
368  // Lightweight page save for exit events - avoids keepalive 64KB limit
369  function savePageDataFinal() {
370    var now = Date.now();
371    var pageDurationMs = now - pageEnterTime;
372
373    // Metadata only - no heavy mouse/scroll arrays
374    var pageData = {
375      pageId: pageId,
376      pagePath: window.location.pathname,
377      pageName: currentPage,
378      enterTime: new Date(pageEnterTime).toISOString(),
379      exitTime: new Date(now).toISOString(),
380      durationMs: pageDurationMs,
381      maxScrollPercent: maxScrollPercent,
382      clickCount: clicks.filter(function(c) { return c.pageId === pageId; }).length,
383      finalSave: true
384    };
385
386    var url = FIRESTORE_URL + '/analytics_visits/' + visitId + '/pages/' + pageId + '?key=' + API_KEY;
387    var doc = toFirestoreDoc(pageData);
388
389    fetch(url, {
390      method: 'PATCH',
391      headers: { 'Content-Type': 'application/json' },
392      body: JSON.stringify(doc),
393      keepalive: true
394    }).catch(function() {
395      // Silent fail - data was already saved by periodic sync
396    });
397  }
398
399  // Save visit-level summary (metadata only, not replay data)
400  function saveVisitSummary(isFinal) {
401    const now = Date.now();
402    const totalDurationMs = now - visitStart;
403    const pagesViewedList = getPagesViewed();
404
405    const visitSummary = {
406      visitorId: visitorId,
407      visitId: visitId,
408      device: coreData.device,
409      referrer: coreData.referrer,
410      visitStartTime: coreData.visitStartTime,
411      visitStartTimestamp: coreData.visitStartTimestamp,
412      visitEndTime: isFinal ? new Date(now).toISOString() : null,
413      totalDurationMs: totalDurationMs,
414      timeOnSite: Math.round(totalDurationMs / 1000),
415      totalPages: pagesViewedList.length,
416      entryPage: pagesViewedList[0] ? pagesViewedList[0].name : currentPage,
417      exitPage: currentPage,
418      currentPage: currentPage,
419      currentPath: window.location.pathname,
420      pagesViewed: pagesViewedList
421    };
422
423    const url = `${FIRESTORE_URL}/analytics_visits/${visitId}?key=${API_KEY}`;
424    const doc = toFirestoreDoc(visitSummary);
425
426    fetch(url, {
427      method: 'PATCH',
428      headers: { 'Content-Type': 'application/json' },
429      body: JSON.stringify(doc),
430      keepalive: isFinal
431    }).catch(function(err) {
432      console.warn('Analytics: Failed to save visit summary', err);
433    });
434  }
435
436  // Lightweight visit summary save for exit events - avoids keepalive 64KB limit
437  function saveVisitSummaryFinal() {
438    var now = Date.now();
439    var totalDurationMs = now - visitStart;
440    var pagesViewedList = getPagesViewed();
441
442    // Metadata only - no heavy arrays
443    var visitSummary = {
444      visitorId: visitorId,
445      visitId: visitId,
446      visitEndTime: new Date(now).toISOString(),
447      totalDurationMs: totalDurationMs,
448      timeOnSite: Math.round(totalDurationMs / 1000),
449      totalPages: pagesViewedList.length,
450      exitPage: currentPage,
451      currentPage: currentPage,
452      currentPath: window.location.pathname,
453      finalSave: true
454    };
455
456    var url = FIRESTORE_URL + '/analytics_visits/' + visitId + '?key=' + API_KEY;
457    var doc = toFirestoreDoc(visitSummary);
458
459    fetch(url, {
460      method: 'PATCH',
461      headers: { 'Content-Type': 'application/json' },
462      body: JSON.stringify(doc),
463      keepalive: true
464    }).catch(function() {
465      // Silent fail - data was already saved by periodic sync
466    });
467  }
468
469  // Legacy saveVisit for backwards compatibility during transition
470  function saveVisit(data) {
471    // Now just calls both new functions
472    savePageData(false);
473    saveVisitSummary(false);
474  }
475
476  // Save initial data
477  savePageData(false);
478  saveVisitSummary(false);
479
480  // Track clicks
481  document.addEventListener('click', function(e) {
482    let target = e.target;
483
484    // Find the actual clickable element (button or anchor)
485    const clickable = target.closest('a, button, [onclick], [role="button"]');
486    if (clickable) {
487      target = clickable;
488    }
489
490    // Get descriptive click ID
491    const clickIdentifier = getClickId(target);
492
493    // Calculate click position as percentage of viewport (for replay accuracy)
494    const clickX = Math.round((e.clientX / window.innerWidth) * 1000) / 10;
495    const clickY = Math.round((e.clientY / window.innerHeight) * 1000) / 10;
496
497    const clickData = {
498      page: currentPage,
499      pagePath: window.location.pathname,
500      pageId: pageId,  // Associate click with specific page
501      clickId: clickIdentifier,
502      element: target.tagName.toLowerCase(),
503      text: (target.textContent || '').substring(0, 50).trim(),
504      href: target.href || null,
505      x: clickX,  // Click position for replay
506      y: clickY,
507      timestamp: Date.now() - visitStart,
508      timeOnPage: Date.now() - pageEnterTime
509    };
510
511    clicks.push(clickData);
512
513    // Save on first click and every 5 clicks
514    if (clicks.length % 5 === 0 || clicks.length === 1) {
515      savePageData(false);
516      saveVisitSummary(false);
517    }
518  });
519
520  // Track scroll depth
521  const scrollMilestones = new Set();
522  function checkScrollDepth() {
523    const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
524    const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
525
526    if (scrollHeight <= 0) return;
527
528    const scrollPercent = Math.round((scrollTop / scrollHeight) * 100);
529    maxScrollPercent = Math.max(maxScrollPercent, scrollPercent);
530
531    [25, 50, 75, 90, 100].forEach(function(milestone) {
532      if (scrollPercent >= milestone && !scrollMilestones.has(milestone)) {
533        scrollMilestones.add(milestone);
534        scrollDepths.push({
535          percent: milestone,
536          page: currentPage,
537          timestamp: Date.now() - visitStart
538        });
539      }
540    });
541  }
542
543  let scrollTimeout;
544  window.addEventListener('scroll', function() {
545    if (scrollTimeout) return;
546    scrollTimeout = setTimeout(function() {
547      checkScrollDepth();
548      scrollTimeout = null;
549    }, 100);
550  });
551
552  // ========== SECTION ATTENTION TRACKING ==========
553
554  function setupSectionTracking() {
555    const sections = document.querySelectorAll('section[id], header[id], footer[id], div[id].section, [id*="section"], #about, #home, #contact, #services, #team, #choose-path, #hero, #ai-capabilities, #case-studies, #products');
556
557    if (sections.length === 0) return;
558
559    const observer = new IntersectionObserver(function(entries) {
560      entries.forEach(function(entry) {
561        const sectionId = entry.target.id;
562        if (!sectionId) return;
563
564        // Prefix with page name for clarity
565        const fullSectionId = currentPage + ':' + sectionId;
566
567        if (entry.isIntersecting) {
568          if (!sectionVisible[fullSectionId]) {
569            sectionVisible[fullSectionId] = Date.now();
570          }
571        } else {
572          if (sectionVisible[fullSectionId]) {
573            const timeSpent = Date.now() - sectionVisible[fullSectionId];
574            sectionTime[fullSectionId] = (sectionTime[fullSectionId] || 0) + timeSpent;
575            delete sectionVisible[fullSectionId];
576          }
577        }
578      });
579    }, {
580      threshold: 0.3
581    });
582
583    sections.forEach(function(section) {
584      if (section.id) {
585        observer.observe(section);
586        const fullSectionId = currentPage + ':' + section.id;
587        sectionTime[fullSectionId] = 0;
588      }
589    });
590  }
591
592  function getSectionTimeData() {
593    const result = {};
594    const now = Date.now();
595
596    for (const id in sectionTime) {
597      result[id] = sectionTime[id];
598    }
599
600    for (const id in sectionVisible) {
601      const additionalTime = now - sectionVisible[id];
602      result[id] = (result[id] || 0) + additionalTime;
603    }
604
605    for (const id in result) {
606      result[id] = Math.round(result[id] / 1000);
607    }
608
609    return result;
610  }
611
612  if (document.readyState === 'loading') {
613    document.addEventListener('DOMContentLoaded', setupSectionTracking);
614  } else {
615    setupSectionTracking();
616  }
617
618  // ========== VISIT SAVE ==========
619
620  function updateFinalVisit() {
621    // Use lightweight saves for exit events to avoid keepalive 64KB limit
622    // Full replay data is already captured by the 30-second periodic syncs
623    savePageDataFinal();
624    saveVisitSummaryFinal();
625  }
626
627  document.addEventListener('visibilitychange', function() {
628    if (document.visibilityState === 'hidden') {
629      updateFinalVisit();
630    }
631  });
632
633  window.addEventListener('beforeunload', function() {
634    updateFinalVisit();
635  });
636
637  // Periodic updates every 30 seconds
638  setInterval(function() {
639    savePageData(false);
640    saveVisitSummary(false);
641  }, 30000);
642
643  console.log('CK Analytics initialized - Visit:', visitId, '| Page:', currentPage);
644})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.