PageSourceSearch

https://haiphen.io/components/beacon/beacon.js

js haiphen.io collected 2026-10-01 12:02:33 UTC 11,765 bytes, 296 lines download raw bytes

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">&times;</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.