Diff -- /var/www/vhosts/3dshawn.com/abforge.3dshawn.com/assets/javascript/track.js
Diff

/var/www/vhosts/3dshawn.com/abforge.3dshawn.com/assets/javascript/track.js

added on local at 2026-07-01 16:01:16

Added
+425
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to 56c2f4eae4be
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1/* =====================================================================
2 ABForge - Visitor tracking + support chat widget.
3 Loaded on every public storefront page. No third-party deps.
4
5 What it does:
6 1. Generates / reads a UUID session token cookie
7 2. POSTs an "init" event with full client fingerprint
8 (OS, browser, screen, viewport, language, timezone, touch, ...)
9 3. Tracks page views, clicks (with x/y%), max scroll depth
10 4. Long-polls /_track_poll.cgi every 6s for admin chat msgs +
11 pushed links
12 5. Renders a small chat bubble bottom-right (when admin is online)
13 6. Opens pushed links automatically when admin sends them
14
15 Inject by adding /assets/javascript/track.js to extra_js on any
16 public-facing page. Auth-required dashboard pages do not include
17 this -- only buyer-facing storefronts.
18===================================================================== */
19(function () {
20 'use strict';
21
22 /* ---- Admin / dashboard page guard ------------------------------- */
23 // Heatmaps + visitor analytics are about the public-facing site.
24 // If this script ever loads on an admin or worker URL (a future
25 // template wiring this in by mistake), bail before doing anything.
26 // The server-side ingest enforces the same rule; this just saves
27 // the round-trip.
28 var __path = (window.location && window.location.pathname) || '';
29 if (/^\/(?:admin|admin_|_)/.test(__path)) return;
30
31 /* ---- Configuration ---------------------------------------------- */
32 var INGEST_URL = '/track.cgi';
33 var POLL_URL = '/track_poll.cgi';
34 var POLL_MS = 6000;
35 var HEATMAP_SAMPLE_RATE = 1.0; // 1.0 = log every click; lower to sample
36
37 /* ---- UUID + cookie ---------------------------------------------- */
38 function uuid4() {
39 // RFC 4122 v4 from Math.random; collision risk is negligible at
40 // platform scale and we don't need cryptographic strength.
41 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
42 var r = Math.random() * 16 | 0;
43 var v = c === 'x' ? r : (r & 0x3 | 0x8);
44 return v.toString(16);
45 });
46 }
47 function getCookie(name) {
48 var m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'));
49 return m ? decodeURIComponent(m[1]) : '';
50 }
51 function setCookie(name, value, days) {
52 var expires = '';
53 if (days) {
54 var d = new Date();
55 d.setTime(d.getTime() + days * 86400000);
56 expires = '; expires=' + d.toUTCString();
57 }
58 document.cookie = name + '=' + encodeURIComponent(value) + expires + '; path=/; SameSite=Lax';
59 }
60 var TOKEN = getCookie('abforge_track');
61 if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(TOKEN)) {
62 TOKEN = uuid4();
63 setCookie('abforge_track', TOKEN, 365);
64 }
65
66 /* ---- Client fingerprint ----------------------------------------- */
67 function detectDevice() {
68 var ua = navigator.userAgent || '';
69 if (/iPad|Tablet/i.test(ua)) return 'tablet';
70 if (/Mobi/i.test(ua) || /iPhone|Android/i.test(ua)) return 'mobile';
71 return 'desktop';
72 }
73 function detectOS() {
74 var ua = navigator.userAgent || '';
75 if (/Windows NT 10/i.test(ua)) return { name: 'Windows', version: '10/11' };
76 if (/Windows NT ([\d.]+)/i.test(ua)) return { name: 'Windows', version: RegExp.$1 };
77 if (/Mac OS X ([\d_]+)/i.test(ua)) return { name: 'macOS', version: RegExp.$1.replace(/_/g, '.') };
78 if (/iPhone|iPad|iPod/i.test(ua)) return { name: 'iOS', version: (ua.match(/OS ([\d_]+)/) || [,''])[1].replace(/_/g, '.') };
79 if (/Android ([\d.]+)/i.test(ua)) return { name: 'Android', version: RegExp.$1 };
80 if (/Linux/i.test(ua)) return { name: 'Linux', version: '' };
81 return { name: '', version: '' };
82 }
83 function detectBrowser() {
84 var ua = navigator.userAgent || '';
85 if (/Edg\/([\d.]+)/.test(ua)) return { name: 'Edge', version: RegExp.$1 };
86 if (/OPR\/([\d.]+)/.test(ua)) return { name: 'Opera', version: RegExp.$1 };
87 if (/Firefox\/([\d.]+)/.test(ua)) return { name: 'Firefox', version: RegExp.$1 };
88 if (/Chrome\/([\d.]+)/.test(ua)) return { name: 'Chrome', version: RegExp.$1 };
89 if (/Safari\/.*Version\/([\d.]+)/.test(ua)) return { name: 'Safari', version: RegExp.$1 };
90 if (/Safari/.test(ua)) return { name: 'Safari', version: '' };
91 return { name: '', version: '' };
92 }
93
94 function buildFingerprint() {
95 var os = detectOS();
96 var br = detectBrowser();
97 var tz = '';
98 try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {}
99 return {
100 os_name: os.name,
101 os_version: os.version,
102 browser_name: br.name,
103 browser_version: br.version,
104 device_type: detectDevice(),
105 screen_w: (window.screen && window.screen.width) || 0,
106 screen_h: (window.screen && window.screen.height) || 0,
107 viewport_w: window.innerWidth || 0,
108 viewport_h: window.innerHeight || 0,
109 color_depth: (window.screen && window.screen.colorDepth) || 0,
110 pixel_ratio: window.devicePixelRatio || 1,
111 touch_capable: ('ontouchstart' in window || (navigator.maxTouchPoints > 0)) ? 1 : 0,
112 language: navigator.language || '',
113 timezone: tz,
114 referrer: document.referrer || '',
115 current_page_path: location.pathname + location.search
116 };
117 }
118
119 /* ---- Tiny JSON POST helper -------------------------------------- */
120 function post(url, obj) {
121 return fetch(url, {
122 method: 'POST',
123 headers: { 'Content-Type': 'application/json' },
124 body: JSON.stringify(obj),
125 credentials: 'same-origin',
126 keepalive: true
127 }).then(function (r) { return r.json(); }).catch(function () { return null; });
128 }
129
130 /* ---- Session id (set by init response) -------------------------- */
131 var SESSION_ID = null;
132
133 /* ---- 1. Init ---------------------------------------------------- */
134 var fp = buildFingerprint();
135 fp.kind = 'init';
136 fp.token = TOKEN;
137 fp.page = location.pathname + location.search;
138 post(INGEST_URL, fp).then(function (res) {
139 if (res && res.session_id) SESSION_ID = res.session_id;
140 sendEvent('page_view', {});
141 startPolling();
142 startChatWidget();
143 });
144
145 /* ---- 2. Event helper ------------------------------------------- */
146 function sendEvent(type, extra) {
147 var body = {
148 kind: 'event',
149 token: TOKEN,
150 type: type,
151 page: location.pathname + location.search
152 };
153 if (extra) for (var k in extra) if (extra.hasOwnProperty(k)) body[k] = extra[k];
154 post(INGEST_URL, body);
155 }
156
157 /* ---- 3. Click + heatmap tracking -------------------------------
158 We record TWO coord systems per click so the admin overlay can
159 stay accurate even after the page reflows for a different viewport:
160 - selector + offset_x/y: a stable DOM path to the clicked
161 element, plus the click position as a fraction of that
162 element's bounding box. Re-rendered against the current DOM,
163 the dot stays glued to the button/word it landed on.
164 - x_pct / y_pct (viewport-relative): legacy fallback used when
165 the element no longer exists (page was edited, A/B variant
166 swapped out, etc.) or sits inside <body> directly.
167 -------------------------------------------------------------- */
168 function selectorPath(el) {
169 // Walk up to nearest id (or body) building tag:nth-of-type steps.
170 // Capped at 8 segments / 500 chars to match DB column width.
171 if (!el || el.nodeType !== 1) return '';
172 var parts = [];
173 var depth = 0;
174 while (el && el.nodeType === 1 && depth < 8) {
175 var tag = (el.tagName || '').toLowerCase();
176 if (!tag || tag === 'html') break;
177 if (el.id && /^[A-Za-z][\w\-:.]*$/.test(el.id)) {
178 parts.unshift('#' + el.id);
179 break;
180 }
181 var part = tag;
182 var parent = el.parentNode;
183 if (parent && parent.children && parent.children.length > 1) {
184 var n = 1;
185 for (var i = 0; i < parent.children.length; i++) {
186 var sib = parent.children[i];
187 if (sib === el) break;
188 if (sib.tagName === el.tagName) n++;
189 }
190 part += ':nth-of-type(' + n + ')';
191 }
192 parts.unshift(part);
193 if (tag === 'body') break;
194 el = parent;
195 depth++;
196 }
197 return parts.join('>').slice(0, 500);
198 }
199
200 document.addEventListener('click', function (e) {
201 if (Math.random() > HEATMAP_SAMPLE_RATE) return;
202 var t = e.target;
203 var sel = selectorPath(t);
204
205 // Element-relative offsets: fraction of the target's bounding rect.
206 // Clamp to [0,1] -- a click on the very edge can land marginally
207 // outside the rect due to subpixel rounding.
208 var ox = 0.5, oy = 0.5, haveOffsets = 0;
209 try {
210 var r = t.getBoundingClientRect();
211 if (r && r.width > 0 && r.height > 0) {
212 ox = (e.clientX - r.left) / r.width;
213 oy = (e.clientY - r.top) / r.height;
214 if (ox < 0) ox = 0; else if (ox > 1) ox = 1;
215 if (oy < 0) oy = 0; else if (oy > 1) oy = 1;
216 haveOffsets = 1;
217 }
218 } catch (err) {}
219
220 sendEvent('click', {
221 selector: sel,
222 x_pct: ((e.clientX / Math.max(window.innerWidth, 1)) * 100).toFixed(2),
223 y_pct: (((e.clientY + window.scrollY) / Math.max(document.body.scrollHeight, 1)) * 100).toFixed(2),
224 offset_x_pct: haveOffsets ? ox.toFixed(4) : '',
225 offset_y_pct: haveOffsets ? oy.toFixed(4) : ''
226 });
227 }, true);
228
229 /* ---- 4. Scroll depth tracking ---------------------------------- */
230 var maxScrollPct = 0;
231 var scrollTimer = null;
232 window.addEventListener('scroll', function () {
233 var h = Math.max(document.body.scrollHeight - window.innerHeight, 1);
234 var pct = Math.min(100, Math.round((window.scrollY / h) * 100));
235 if (pct > maxScrollPct) maxScrollPct = pct;
236 clearTimeout(scrollTimer);
237 scrollTimer = setTimeout(function () {
238 sendEvent('scroll', { scroll_pct: maxScrollPct });
239 }, 1500);
240 }, { passive: true });
241
242 /* ---- 5. Poll for admin chat msgs + pushed links --------------- */
243 function startPolling() {
244 setInterval(function () {
245 fetch(POLL_URL + '?token=' + encodeURIComponent(TOKEN) + '&page=' + encodeURIComponent(location.pathname), {
246 credentials: 'same-origin'
247 }).then(function (r) { return r.json(); }).then(function (res) {
248 if (!res || !res.ok) return;
249 // Toggle chat widget visibility based on admin online state
250 toggleWidget(!!res.admin_online);
251 // Admin pushed messages
252 if (res.messages && res.messages.length) {
253 res.messages.forEach(function (m) { appendMessage('admin', m.body, m.sent_at); });
254 openChat();
255 }
256 // Admin pushed links -- open them
257 if (res.pushes && res.pushes.length) {
258 res.pushes.forEach(function (p) {
259 if (p.open_mode === 'same_tab') {
260 window.location.href = p.url;
261 } else if (p.open_mode === 'popup') {
262 window.open(p.url, 'abforge_push', 'width=900,height=600,resizable=yes,scrollbars=yes');
263 } else {
264 window.open(p.url, '_blank', 'noopener');
265 }
266 });
267 }
268 }).catch(function () {});
269 }, POLL_MS);
270 }
271
272 /* ---- 6. Chat widget -------------------------------------------- */
273 // Injected lazily; CSS is inline so the storefront's site.css does
274 // not have to know about it. Keeps the widget plug-and-play.
275 var widget, msgList, inputEl, bubbleEl, panelEl, openState = false, adminOnline = false;
276
277 function startChatWidget() {
278 if (widget) return;
279 widget = document.createElement('div');
280 widget.id = 'wsChatWidget';
281 // Hide inline so there's no paint window before the embedded
282 // <style> tag's display:none rule applies. toggleWidget() flips
283 // this when admin online state changes.
284 widget.style.display = 'none';
285 widget.innerHTML =
286 '<style>' +
287 ' #wsChatWidget{position:fixed;right:20px;bottom:20px;z-index:99000;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;display:none}' +
288 ' #wsChatWidget.is-online{display:block}' +
289 ' #wsChatBubble{width:56px;height:56px;border-radius:50%;background:linear-gradient(135deg,#ff7a3d,#6366f1);color:#fff;display:grid;place-items:center;cursor:pointer;box-shadow:0 8px 24px rgba(255,122,61,0.45);transition:transform .15s}' +
290 ' #wsChatBubble:hover{transform:scale(1.06)}' +
291 ' #wsChatBubble svg{width:24px;height:24px}' +
292 ' #wsChatPanel{position:absolute;bottom:70px;right:0;width:400px;height:520px;background:#0a0e1c;border:1px solid #1e293b;border-radius:14px;box-shadow:0 18px 50px rgba(0,0,0,0.6);display:none;flex-direction:column;overflow:hidden}' +
293 ' #wsChatPanel.open{display:flex}' +
294 ' @media (max-width:480px){#wsChatPanel{width:calc(100vw - 30px);right:-5px;height:70vh}}' +
295 ' #wsChatHead{padding:12px 14px;background:linear-gradient(135deg,#1e3a8a,#1e40af);color:#fff;font-weight:700;font-size:14px;display:flex;align-items:center;gap:8px}' +
296 ' #wsChatHead .dot{width:8px;height:8px;border-radius:50%;background:#22c55e;box-shadow:0 0 6px #22c55e}' +
297 ' #wsChatHead .title{flex:1}' +
298 ' #wsChatEnd{background:rgba(255,255,255,0.12);border:0;color:#fff;padding:5px 12px;border-radius:6px;font-size:11px;font-weight:700;cursor:pointer;letter-spacing:0.4px;text-transform:uppercase}' +
299 ' #wsChatEnd:hover{background:rgba(255,255,255,0.22)}' +
300 ' #wsChatList{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:8px;font-size:13px}' +
301 ' .wsm{max-width:88%;padding:8px 12px;border-radius:14px;line-height:1.45;word-wrap:break-word}' +
302 ' .wsm.admin{background:#1e293b;color:#e2e8f0;border-bottom-left-radius:4px;align-self:flex-start}' +
303 ' .wsm.visitor{background:#ff7a3d;color:#fff;border-bottom-right-radius:4px;align-self:flex-end}' +
304 ' .wsm.system{background:rgba(124,58,237,0.18);color:#c4b5fd;align-self:center;font-size:11px;padding:4px 10px}' +
305 ' .wsm time{display:block;font-size:10px;opacity:0.65;margin-top:3px}' +
306 ' #wsChatForm{display:flex;flex-direction:column;border-top:1px solid #1e293b;background:#0d1224;padding:10px 12px;gap:8px}' +
307 ' #wsChatInput{width:100%;min-height:64px;max-height:160px;resize:vertical;background:#0a0e1c;border:1px solid #1e293b;border-radius:8px;color:#fff;padding:8px 10px;font-size:13px;font-family:inherit;outline:none;box-sizing:border-box}' +
308 ' #wsChatInput:focus{border-color:#ff7a3d}' +
309 ' #wsChatSend{align-self:flex-end;background:#ff7a3d;border:0;color:#fff;padding:8px 18px;border-radius:8px;font-weight:700;cursor:pointer;font-size:13px}' +
310 ' #wsChatSend:hover{background:#ff7a3d}' +
311 ' #wsChatHint{font-size:10px;color:#64748b;align-self:flex-start;margin-top:-4px}' +
312 '</style>' +
313 '<div id="wsChatPanel" role="dialog" aria-label="Support chat">' +
314 ' <div id="wsChatHead"><span class="dot"></span><span class="title">Live support</span>' +
315 ' <button id="wsChatEnd" type="button" title="End this conversation">End</button>' +
316 ' </div>' +
317 ' <div id="wsChatList"></div>' +
318 ' <form id="wsChatForm">' +
319 ' <textarea id="wsChatInput" placeholder="Type a message... (Enter to send, Shift+Enter for new line)" maxlength="2000" rows="3"></textarea>' +
320 ' <span id="wsChatHint">Enter to send &middot; Shift+Enter for a new line</span>' +
321 ' <button id="wsChatSend" type="submit">Send</button>' +
322 ' </form>' +
323 '</div>' +
324 '<div id="wsChatBubble" role="button" aria-label="Open chat">' +
325 ' <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>' +
326 '</div>';
327 document.body.appendChild(widget);
328
329 bubbleEl = document.getElementById('wsChatBubble');
330 panelEl = document.getElementById('wsChatPanel');
331 msgList = document.getElementById('wsChatList');
332 inputEl = document.getElementById('wsChatInput');
333
334 bubbleEl.addEventListener('click', function () { openState ? closeChat() : openChat(); });
335
336 function sendCurrent() {
337 var text = inputEl.value.trim();
338 if (!text) return;
339 appendMessage('visitor', text);
340 inputEl.value = '';
341 post(INGEST_URL, { kind: 'chat_send', token: TOKEN, body: text });
342 }
343
344 document.getElementById('wsChatForm').addEventListener('submit', function (e) {
345 e.preventDefault();
346 sendCurrent();
347 });
348
349 // Enter sends, Shift+Enter inserts a newline. Standard chat ergonomics.
350 inputEl.addEventListener('keydown', function (e) {
351 if (e.key === 'Enter' && !e.shiftKey) {
352 e.preventDefault();
353 sendCurrent();
354 }
355 });
356
357 // End conversation -- notifies the admin server-side, drops a system
358 // message in the visitor's thread, then hides the widget for this
359 // page view. Refreshing the page brings it back if admin is online.
360 document.getElementById('wsChatEnd').addEventListener('click', function () {
361 post(INGEST_URL, { kind: 'chat_end', token: TOKEN });
362 appendMessage('system', 'You ended the conversation. Thanks!');
363 // Hide widget after a short beat so the user sees the confirmation.
364 setTimeout(function () {
365 if (widget) widget.style.display = 'none';
366 }, 1500);
367 });
368 }
369
370 function toggleWidget(on) {
371 if (!widget) return;
372 adminOnline = on;
373 // Inline style wins over the CSS class, so flip it directly.
374 widget.style.display = on ? 'block' : 'none';
375 if (on) widget.classList.add('is-online');
376 else widget.classList.remove('is-online');
377 }
378
379 function openChat() {
380 if (!panelEl) return;
381 panelEl.classList.add('open');
382 openState = true;
383 if (msgList) msgList.scrollTop = msgList.scrollHeight;
384 if (inputEl) inputEl.focus();
385 }
386 function closeChat() {
387 if (!panelEl) return;
388 panelEl.classList.remove('open');
389 openState = false;
390 }
391
392 function appendMessage(role, body, ts) {
393 if (!msgList) return;
394 var d = document.createElement('div');
395 d.className = 'wsm ' + role;
396 var bodyEl = document.createElement('div');
397 // Linkify: split the body on URL boundaries and rebuild as a
398 // mix of text + <a> nodes. Done with createElement (not innerHTML)
399 // so the message body still can't smuggle HTML in.
400 var urlRe = /(https?:\/\/[^\s<>]+)/g;
401 var parts = body.split(urlRe);
402 parts.forEach(function (part, i) {
403 if (i % 2 === 1) {
404 var a = document.createElement('a');
405 a.href = part;
406 a.target = '_blank';
407 a.rel = 'noopener';
408 a.textContent = part;
409 a.style.color = '#93c5fd';
410 a.style.textDecoration = 'underline';
411 bodyEl.appendChild(a);
412 } else if (part) {
413 bodyEl.appendChild(document.createTextNode(part));
414 }
415 });
416 d.appendChild(bodyEl);
417 if (ts) {
418 var t = document.createElement('time');
419 t.textContent = ts;
420 d.appendChild(t);
421 }
422 msgList.appendChild(d);
423 msgList.scrollTop = msgList.scrollHeight;
424 }
425})();
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help