lthn.io/public/js/analytics.js
Claude 77cc45dd83
feat: lthn.io CorePHP app — TLD website + blockchain services
Modules:
- Chain: daemon RPC client (DaemonRpc singleton, cached queries)
- Explorer: block browser, tx viewer, alias directory, search, stats API
- Names: .lthn TLD registrar portal (availability check, lookup, directory)
- Trade: scaffold (DEX frontend + API)
- Pool: scaffold (mining pool dashboard)

Replaces 5 Node.js containers (5.9GB) with one FrankenPHP app.
Built on CorePHP framework pattern from host.uk.com.

Co-Authored-By: Charon <charon@lethean.io>
2026-04-03 16:13:55 +01:00

325 lines
8.7 KiB
JavaScript

/**
* Host UK Analytics - Lightweight privacy-focused tracking
*
* Usage:
* <script defer data-key="YOUR_PIXEL_KEY" src="https://host.uk.com/js/analytics.js"></script>
*/
(function() {
'use strict';
// Configuration
const ENDPOINT = '/api/analytics';
const HEARTBEAT_INTERVAL = 30000; // 30 seconds
const STORAGE_KEY = 'huk_vid';
const SESSION_KEY = 'huk_sid';
const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
// State
let pixelKey = null;
let visitorId = null;
let sessionId = null;
let currentEventId = null;
let pageStartTime = null;
let maxScrollDepth = 0;
let heartbeatTimer = null;
let isTracking = false;
/**
* Initialise the tracker
*/
function init() {
// Get pixel key from script tag
const script = document.currentScript || document.querySelector('script[data-key]');
if (!script) {
console.warn('[Analytics] No script tag found');
return;
}
pixelKey = script.getAttribute('data-key');
if (!pixelKey) {
console.warn('[Analytics] No pixel key provided');
return;
}
// Respect Do Not Track
if (navigator.doNotTrack === '1' || window.doNotTrack === '1') {
return;
}
// Get or create visitor ID
visitorId = getVisitorId();
// Get or create session ID
sessionId = getSessionId();
// Mark as tracking
isTracking = true;
// Track initial pageview
trackPageview();
// Set up scroll tracking
setupScrollTracking();
// Set up heartbeat
setupHeartbeat();
// Set up page unload
setupUnload();
// Expose public API
window.hukAnalytics = {
track: trackEvent,
pageview: trackPageview,
};
}
/**
* Get or create visitor ID (persisted in localStorage)
*/
function getVisitorId() {
let id = null;
try {
id = localStorage.getItem(STORAGE_KEY);
} catch (e) {
// localStorage not available
}
if (!id) {
id = generateUUID();
try {
localStorage.setItem(STORAGE_KEY, id);
} catch (e) {
// Ignore storage errors
}
}
return id;
}
/**
* Get or create session ID (persisted in sessionStorage with timeout)
*/
function getSessionId() {
let session = null;
try {
const stored = sessionStorage.getItem(SESSION_KEY);
if (stored) {
session = JSON.parse(stored);
// Check if session has timed out
if (Date.now() - session.lastActive > SESSION_TIMEOUT) {
session = null;
}
}
} catch (e) {
// sessionStorage not available
}
if (!session) {
session = {
id: generateUUID(),
lastActive: Date.now(),
};
}
// Update last active
session.lastActive = Date.now();
try {
sessionStorage.setItem(SESSION_KEY, JSON.stringify(session));
} catch (e) {
// Ignore storage errors
}
return session.id;
}
/**
* Generate UUID v4
*/
function generateUUID() {
if (crypto && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* Track a pageview
*/
function trackPageview() {
if (!isTracking) return;
pageStartTime = Date.now();
maxScrollDepth = 0;
const data = {
key: pixelKey,
type: 'pageview',
visitor_id: visitorId,
session_id: sessionId,
path: location.pathname + location.search,
title: document.title,
referrer: document.referrer || null,
screen_width: screen.width,
screen_height: screen.height,
language: navigator.language,
};
// Extract UTM parameters
const params = new URLSearchParams(location.search);
['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'].forEach(function(key) {
const value = params.get(key);
if (value) data[key] = value;
});
send(ENDPOINT + '/track', data, function(response) {
if (response && response.event_id) {
currentEventId = response.event_id;
}
if (response && response.visitor_id) {
visitorId = response.visitor_id;
}
if (response && response.session_id) {
sessionId = response.session_id;
}
});
}
/**
* Track a custom event
*/
function trackEvent(name, properties) {
if (!isTracking) return;
send(ENDPOINT + '/event', {
key: pixelKey,
name: name,
visitor_id: visitorId,
session_id: sessionId,
properties: properties || {},
});
}
/**
* Set up scroll depth tracking
*/
function setupScrollTracking() {
let ticking = false;
function onScroll() {
if (!ticking) {
requestAnimationFrame(function() {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const docHeight = Math.max(
document.body.scrollHeight,
document.documentElement.scrollHeight
);
const winHeight = window.innerHeight;
const scrollPercent = Math.round((scrollTop / (docHeight - winHeight)) * 100);
if (scrollPercent > maxScrollDepth) {
maxScrollDepth = Math.min(scrollPercent, 100);
}
ticking = false;
});
ticking = true;
}
}
window.addEventListener('scroll', onScroll, { passive: true });
}
/**
* Set up heartbeat to track time on page
*/
function setupHeartbeat() {
heartbeatTimer = setInterval(function() {
if (!currentEventId || !isTracking) return;
send(ENDPOINT + '/heartbeat', {
event_id: currentEventId,
time_on_page: Math.round((Date.now() - pageStartTime) / 1000),
scroll_depth: maxScrollDepth,
session_id: sessionId,
});
}, HEARTBEAT_INTERVAL);
}
/**
* Set up page unload handler
*/
function setupUnload() {
function onLeave() {
if (!isTracking) return;
// Clear heartbeat
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
}
// Send final data
const data = {
event_id: currentEventId,
time_on_page: Math.round((Date.now() - pageStartTime) / 1000),
scroll_depth: maxScrollDepth,
session_id: sessionId,
};
// Use sendBeacon for reliability
if (navigator.sendBeacon) {
navigator.sendBeacon(
ENDPOINT + '/heartbeat',
new Blob([JSON.stringify(data)], { type: 'application/json' })
);
}
}
// Handle various unload scenarios
window.addEventListener('pagehide', onLeave);
window.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'hidden') {
onLeave();
}
});
}
/**
* Send data to the server
*/
function send(url, data, callback) {
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(data),
keepalive: true,
})
.then(function(response) {
return response.json();
})
.then(function(json) {
if (callback) callback(json);
})
.catch(function(error) {
// Silently fail - analytics shouldn't break the site
});
}
// Initialise when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();