1/* docs/components/beacon/beacon.js 2 * Session-aggregated analytics beacon â consent-gated, one POST per session. 3 * 4 * Buffers all events in memory. Flushes a single summary payload via 5 * navigator.sendBeacon() on page hide / unload. Never hits the network 6 * until the session ends. 7 */ 8(function () { 9 'use strict'; 10 11 var API = 'https://api.haiphen.io/v1/t'; 12 var VID_KEY = 'haiphen.vid'; 13 var SID_KEY = 'haiphen.sid'; 14 var CONSENT_KEY = 'haiphen.cookie_consent'; 15 var COOKIE_NAME = 'hp_vid'; 16 var COOKIE_DOMAIN = '.haiphen.io'; 17 var COOKIE_DAYS = 365; 18 19 // ââ State ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 20 21 var visitorId = ''; 22 var sessionId = ''; 23 var sections = []; // unique section names 24 var maxScroll = 0; 25 var events = []; 26 var startTime = Date.now(); 27 var flushed = false; 28 var consentOk = false; 29 var utmParams = {}; 30 31 // ââ UTM capture âââââââââââââââââââââââââââââââââââââââââââââââââââââ 32 33 function captureUtm() { 34 try { 35 var params = new URLSearchParams(window.location.search); 36 var keys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']; 37 keys.forEach(function (k) { 38 var v = params.get(k); 39 if (v) utmParams[k] = v.slice(0, 128); 40 }); 41 var ref = params.get('ref'); 42 if (ref) { 43 if (!utmParams.utm_source) utmParams.utm_source = 'referral'; 44 if (!utmParams.utm_medium) utmParams.utm_medium = 'referral'; 45 if (!utmParams.utm_content) utmParams.utm_content = ref.slice(0, 128); 46 } 47 } catch (_) {} 48 } 49 50 // ââ Helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 51 52 function uuid() { 53 if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID(); 54 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 55 var r = (Math.random() * 16) | 0; 56 return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); 57 }); 58 } 59 60 function setCookie(name, value, days) { 61 var d = new Date(); 62 d.setTime(d.getTime() + days * 86400000); 63 document.cookie = name + '=' + encodeURIComponent(value) + 64 ';expires=' + d.toUTCString() + 65 ';path=/;domain=' + COOKIE_DOMAIN + ';SameSite=Lax;Secure'; 66 } 67 68 function getCookie(name) { 69 var m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)')); 70 return m ? decodeURIComponent(m[1]) : null; 71 } 72 73 function checkConsent() { 74 try { 75 var raw = localStorage.getItem(CONSENT_KEY); 76 if (!raw) return false; 77 var p = JSON.parse(raw); 78 return p && p.analytics === true; 79 } catch { return false; } 80 } 81 82 function deviceType() { 83 var ua = navigator.userAgent || ''; 84 if (/Mobi|Android/i.test(ua)) return 'mobile'; 85 if (/Tablet|iPad/i.test(ua)) return 'tablet'; 86 return 'desktop'; 87 } 88 89 // ââ Init âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 90 91 function init() { 92 captureUtm(); 93 consentOk = checkConsent(); 94 if (!consentOk) return; 95 96 // Visitor ID: localStorage â cookie â generate 97 visitorId = localStorage.getItem(VID_KEY) || getCookie(COOKIE_NAME) || uuid(); 98 try { localStorage.setItem(VID_KEY, visitorId); } catch {} 99 setCookie(COOKIE_NAME, visitorId, COOKIE_DAYS); 100 101 // Session ID: per-tab 102 sessionId = sessionStorage.getItem(SID_KEY) || uuid(); 103 try { sessionStorage.setItem(SID_KEY, sessionId); } catch {} 104 105 // Listen for consent revocation 106 window.addEventListener('haiphen:consent:updated', function (e) { 107 var detail = e.detail || {}; 108 if (detail.analytics === false) { 109 consentOk = false; 110 sections = []; 111 events = []; 112 } else if (detail.analytics === true) { 113 consentOk = true; 114 } 115 }); 116 117 // Flush on page hide / unload 118 document.addEventListener('visibilitychange', function () { 119 if (document.visibilityState === 'hidden') flush(); 120 }); 121 window.addEventListener('pagehide', flush); 122 123 // Scroll depth via IntersectionObserver 124 observeScroll(); 125 } 126 127 // ââ Instrumentation API ââââââââââââââââââââââââââââââââââââââââââââââ 128 129 function sectionView(name) { 130 if (!consentOk || !name) return; 131 if (sections.indexOf(name) === -1) sections.push(name); 132 events.push({ t: 'sv', s: name, ts: Date.now() }); 133 } 134 135 function ctaClick(ctaName, sectionName) { 136 if (!consentOk) return; 137 events.push({ t: 'cta', s: sectionName || '', d: { cta: ctaName }, ts: Date.now() }); 138 } 139 140 // ââ Scroll depth ââââââââââââââââââââââââââââââââââââââââââââââââââââ 141 142 function observeScroll() { 143 if (typeof IntersectionObserver === 'undefined') return; 144 var thresholds = [0.25, 0.50, 0.75, 1.0]; 145 var el = document.getElementById('content-widget'); 146 if (!el) return; 147 148 // We track scroll on body/documentElement since content-widget is a container 149 var handler = function () { 150 var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 151 var docHeight = Math.max( 152 document.body.scrollHeight, document.documentElement.scrollHeight, 153 document.body.offsetHeight, document.documentElement.offsetHeight 154 ); 155 var winHeight = window.innerHeight; 156 if (docHeight <= winHeight) { maxScroll = 100; return; } 157 var pct = Math.round((scrollTop / (docHeight - winHeight)) * 100); 158 if (pct > maxScroll) maxScroll = Math.min(pct, 100); 159 }; 160 window.addEventListener('scroll', handler, { passive: true }); 161 } 162 163 // ââ CTA delegation ââââââââââââââââââââââââââââââââââââââââââââââââââ 164 165 function wireCta() { 166 document.addEventListener('click', function (e) { 167 var btn = e.target.closest('[data-beacon-cta]'); 168 if (!btn) return; 169 var cta = btn.getAttribute('data-beacon-cta') || 'unknown'; 170 var sec = btn.closest('[data-section]'); 171 ctaClick(cta, sec ? sec.getAttribute('data-section') : ''); 172 }); 173 } 174 175 // ââ Flush âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 176 177 function flush() { 178 if (flushed || !consentOk || !sessionId) return; 179 if (events.length === 0 && sections.length === 0) return; 180 flushed = true; 181 182 var dur = Math.round((Date.now() - startTime) / 1000); 183 var payload = JSON.stringify({ 184 v: visitorId, 185 s: sessionId, 186 ua: navigator.userAgent, 187 ref: document.referrer || '', 188 sc: screen.width + 'x' + screen.height, 189 tz: Intl.DateTimeFormat().resolvedOptions().timeZone || '', 190 dt: deviceType(), 191 dur: dur, 192 sections: sections, 193 maxScroll: maxScroll, 194 events: events, 195 utm: Object.keys(utmParams).length >
195 0 ? utmParams : undefined 196 }); 197 198 // sendBeacon is fire-and-forget â perfect for page unload 199 if (navigator.sendBeacon) { 200 navigator.sendBeacon(API, payload); 201 } else { 202 // Fallback: keepalive fetch 203 try { 204 fetch(API, { 205 method: 'POST', 206 body: payload, 207 headers: { 'Content-Type': 'text/plain' }, 208 keepalive: true 209 }); 210 } catch { /* best effort */ } 211 } 212 } 213 214 // ââ Bootstrap âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 215 216 // ââ Identity linking ââââââââââââââââââââââââââââââââââââââââââââââââ 217 218 function linkIdentity() { 219 if (!visitorId) return; 220 // Fire-and-forget: link visitor_id to authenticated user 221 try { 222 fetch('https://api.haiphen.io/v1/identity/link', { 223 method: 'POST', 224 credentials: 'include', 225 headers: { 'Content-Type': 'application/json' }, 226 body: JSON.stringify({ visitor_id: visitorId }), 227 keepalive: true 228 }).catch(function () { /* best effort */ }); 229 } catch (e) { /* best effort */ } 230 } 231 232 // ââ Signup nudge for anonymous high-engagement visitors âââââââââââ 233 234 var nudgeDismissedKey = 'haiphen.signup_nudge_dismissed'; 235 var nudgeShown = false; 236 237 function maybeShowSignupNudge() { 238 if (nudgeShown) return; 239 // Only for anonymous visitors with real engagement 240 if (!consentOk || !visitorId) return; 241 // Skip if logged in 242 if (document.cookie.indexOf('haiphen_session') !== -1) return; 243 // Skip if already dismissed
244 try { if (localStorage.getItem(nudgeDismissedKey)) return; } catch {} 245 // Need at least 3 sections viewed and 60s on page 246 if (sections.length < 3) return; 247 var elapsed = Math.round((Date.now() - startTime) / 1000); 248 if (elapsed < 60) return; 249 250 nudgeShown = true; 251 var bar = document.createElement('div'); 252 bar.style.cssText = 'position:fixed;bottom:0;left:0;right:0;z-index:9999;background:linear-gradient(135deg,#1a1a2e,#16213e);border-top:2px solid #5A9BD4;padding:14px 20px;font-family:system-ui,-apple-system,sans-serif;animation:slideUp .3s ease-out;'; 253 bar.innerHTML = 254 '<div style="max-width:900px;margin:0 auto;display:flex;align-items:center;gap:12px;flex-wrap:wrap;">' + 255 '<span style="color:#e2e8f0;font-size:14px;flex:1;min-width:200px;">Enjoying Haiphen? Create a free account to unlock personalized insights.</span>' + 256 '<a href="#" style="background:#5A9BD4;color:#fff;padding:8px 18px;border-radius:6px;text-decoration:none;font-size:13px;font-weight:600;white-space:nowrap;" data-beacon-cta="signup-nudge" onclick="if(window.HAIPHEN&&HAIPHEN.AuthSession)HAIPHEN.AuthSession.redirectToLogin();return false;">Sign Up Free</a>' + 257 '<button style="background:none;border:none;color:#94a3b8;font-size:20px;cursor:pointer;padding:4px 8px;" aria-label="Dismiss">×</button>' + 258 '</div>'; 259 bar.querySelector('button[aria-label="Dismiss"]').addEventListener('click', function () { 260 bar.remove(); 261 try { localStorage.setItem(nudgeDismissedKey, '1'); } catch {} 262 }); 263 document.body.appendChild(bar); 264 } 265 266 // Check periodically for nudge conditions 267 var nudgeInterval = setInterval(function () { 268 if (nudgeShown || !consentOk) { clearInterval(nudgeInterval); return; } 269 maybeShowSignupNudge(); 270 }, 15000); // check every 15s 271 272 window.HAIPHEN = window.HAIPHEN || {}; 273 window.HAIPHEN.Beacon = { 274 sectionView: sectionView, 275 ctaClick: ctaClick, 276 flush: flush, 277 linkIdentity: linkIdentity, 278 getVisitorId: function () { return visitorId; }, 279 getUtm: function () { return utmParams; } 280 }; 281 282 // Init on DOM ready 283 if (document.readyState === 'loading') { 284 document.addEventListener('DOMContentLoaded', function () { init(); wireCta(); }); 285 } else { 286 init(); 287 wireCta(); 288 } 289 290 // Also init if consent arrives after load (user clicks Accept) 291 window.addEventListener('haiphen:consent:updated', function (e) { 292 if (e.detail && e.detail.analytics === true && !visitorId) { 293 init(); 294 } 295 }); 296})();
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.