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.