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>
325 lines
8.7 KiB
JavaScript
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();
|
|
}
|
|
})();
|