@hifilabs/pixel 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,554 @@
1
+ var BalancePixel = (() => {
2
+ // src/browser.ts
3
+ (function() {
4
+ function parseUserAgent(ua) {
5
+ let device_type = "desktop";
6
+ if (/ipad|tablet|android(?!.*mobile)/i.test(ua))
7
+ device_type = "tablet";
8
+ else if (/mobile|iphone|android.*mobile|blackberry|iemobile/i.test(ua))
9
+ device_type = "mobile";
10
+ let browser = "Unknown";
11
+ if (/edg/i.test(ua))
12
+ browser = "Edge";
13
+ else if (/opr|opera/i.test(ua))
14
+ browser = "Opera";
15
+ else if (/firefox/i.test(ua))
16
+ browser = "Firefox";
17
+ else if (/chrome/i.test(ua))
18
+ browser = "Chrome";
19
+ else if (/safari/i.test(ua))
20
+ browser = "Safari";
21
+ let os = "Unknown";
22
+ if (/iphone|ipad/i.test(ua))
23
+ os = "iOS";
24
+ else if (/android/i.test(ua))
25
+ os = "Android";
26
+ else if (/windows/i.test(ua))
27
+ os = "Windows";
28
+ else if (/mac os/i.test(ua))
29
+ os = "macOS";
30
+ else if (/linux/i.test(ua))
31
+ os = "Linux";
32
+ else if (/cros/i.test(ua))
33
+ os = "ChromeOS";
34
+ return { device_type, browser, os };
35
+ }
36
+ let cachedDeviceInfo = null;
37
+ function getDeviceInfo() {
38
+ if (!cachedDeviceInfo) {
39
+ try {
40
+ cachedDeviceInfo = parseUserAgent(navigator.userAgent);
41
+ } catch {
42
+ cachedDeviceInfo = { device_type: "desktop", browser: "Unknown", os: "Unknown" };
43
+ }
44
+ }
45
+ return cachedDeviceInfo;
46
+ }
47
+ const currentScript = document.currentScript;
48
+ const artistId = currentScript?.dataset.artistId;
49
+ const projectId = currentScript?.dataset.projectId;
50
+ const useEmulator = currentScript?.dataset.emulator === "true";
51
+ const debug = currentScript?.dataset.debug === "true";
52
+ const heartbeatInterval = parseInt(currentScript?.dataset.heartbeatInterval || "30000", 10);
53
+ const heartbeatEnabled = currentScript?.dataset.heartbeat !== "false";
54
+ const explicitSource = currentScript?.dataset.source;
55
+ function detectTrackingSource() {
56
+ if (explicitSource)
57
+ return explicitSource;
58
+ const hasDataLayer = typeof window.dataLayer !== "undefined" && Array.isArray(window.dataLayer);
59
+ const hasGtag = typeof window.gtag === "function";
60
+ if (hasDataLayer && hasGtag) {
61
+ return "gtm";
62
+ }
63
+ return "pixel";
64
+ }
65
+ let trackingSource = "pixel";
66
+ if (!artistId) {
67
+ console.error("[BALANCE Pixel] Error: data-artist-id attribute is required");
68
+ return;
69
+ }
70
+ const SESSION_KEY = "session_id";
71
+ const SESSION_TIMESTAMP_KEY = "session_timestamp";
72
+ const ATTRIBUTION_KEY = "attribution";
73
+ const FAN_ID_KEY = "fan_id_hash";
74
+ const CONSENT_STORAGE_KEY = "balance_consent";
75
+ const SESSION_DURATION = 60 * 60 * 1e3;
76
+ const STORAGE_PREFIX = "balance_";
77
+ const API_ENDPOINT = useEmulator ? `http://localhost:5001/artist-os-distro/us-central1/pixelEndpoint` : `https://us-central1-artist-os-distro.cloudfunctions.net/pixelEndpoint`;
78
+ let sessionId = null;
79
+ let fanIdHash = null;
80
+ let consent = null;
81
+ let attribution = {};
82
+ let eventQueue = [];
83
+ let flushTimer = null;
84
+ let currentStorageTier = "session";
85
+ let heartbeatTimer = null;
86
+ let pageStartTime = 0;
87
+ let activeTime = 0;
88
+ let lastActiveTimestamp = 0;
89
+ let isPageVisible = true;
90
+ const IDLE_TIMEOUT = 2 * 60 * 1e3;
91
+ let lastActivityTime = Date.now();
92
+ let isIdle = false;
93
+ const log = (...args) => {
94
+ if (debug)
95
+ console.log("[BALANCE Pixel]", ...args);
96
+ };
97
+ function getStorage() {
98
+ try {
99
+ return currentStorageTier === "local" ? localStorage : sessionStorage;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+ function storageGet(key) {
105
+ const storage = getStorage();
106
+ if (!storage)
107
+ return null;
108
+ try {
109
+ const fullKey = STORAGE_PREFIX + key;
110
+ let value = storage.getItem(fullKey);
111
+ if (!value && currentStorageTier === "session") {
112
+ try {
113
+ value = localStorage.getItem(fullKey);
114
+ } catch {
115
+ }
116
+ }
117
+ return value;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+ function storageSet(key, value) {
123
+ const storage = getStorage();
124
+ if (!storage)
125
+ return;
126
+ try {
127
+ storage.setItem(STORAGE_PREFIX + key, value);
128
+ } catch {
129
+ }
130
+ }
131
+ function upgradeStorageTier() {
132
+ if (currentStorageTier === "local")
133
+ return;
134
+ log("Upgrading storage tier: session -> local");
135
+ try {
136
+ const keysToMigrate = [];
137
+ for (let i = 0; i < sessionStorage.length; i++) {
138
+ const key = sessionStorage.key(i);
139
+ if (key?.startsWith(STORAGE_PREFIX)) {
140
+ keysToMigrate.push(key);
141
+ }
142
+ }
143
+ for (const key of keysToMigrate) {
144
+ const value = sessionStorage.getItem(key);
145
+ if (value) {
146
+ localStorage.setItem(key, value);
147
+ }
148
+ }
149
+ for (const key of keysToMigrate) {
150
+ sessionStorage.removeItem(key);
151
+ }
152
+ currentStorageTier = "local";
153
+ log(`Storage tier upgraded, migrated ${keysToMigrate.length} items`);
154
+ } catch (error) {
155
+ console.error("[BALANCE Pixel] Storage migration failed:", error);
156
+ }
157
+ }
158
+ function generateUUID() {
159
+ if (crypto && crypto.randomUUID) {
160
+ return crypto.randomUUID();
161
+ }
162
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
163
+ const r = Math.random() * 16 | 0;
164
+ const v = c === "x" ? r : r & 3 | 8;
165
+ return v.toString(16);
166
+ });
167
+ }
168
+ function getOrCreateSession() {
169
+ try {
170
+ const stored = storageGet(SESSION_KEY);
171
+ const timestamp = storageGet(SESSION_TIMESTAMP_KEY);
172
+ if (stored && timestamp) {
173
+ const age = Date.now() - parseInt(timestamp, 10);
174
+ if (age < SESSION_DURATION) {
175
+ storageSet(SESSION_TIMESTAMP_KEY, Date.now().toString());
176
+ return stored;
177
+ }
178
+ }
179
+ const newId = generateUUID();
180
+ storageSet(SESSION_KEY, newId);
181
+ storageSet(SESSION_TIMESTAMP_KEY, Date.now().toString());
182
+ return newId;
183
+ } catch (e) {
184
+ return generateUUID();
185
+ }
186
+ }
187
+ function extractUTM() {
188
+ const params = new URLSearchParams(window.location.search);
189
+ const utm = {};
190
+ ["source", "medium", "campaign", "content", "term"].forEach((param) => {
191
+ const value = params.get(`utm_${param}`);
192
+ if (value)
193
+ utm[`utm_${param}`] = value;
194
+ });
195
+ return utm;
196
+ }
197
+ function captureAttribution() {
198
+ try {
199
+ const stored = storageGet(ATTRIBUTION_KEY);
200
+ if (stored) {
201
+ attribution = JSON.parse(stored);
202
+ log("Loaded attribution:", attribution);
203
+ return;
204
+ }
205
+ const utm = extractUTM();
206
+ if (Object.keys(utm).length > 0) {
207
+ attribution = utm;
208
+ storageSet(ATTRIBUTION_KEY, JSON.stringify(utm));
209
+ log("Captured attribution:", attribution);
210
+ }
211
+ } catch (e) {
212
+ }
213
+ }
214
+ function loadFanId() {
215
+ try {
216
+ fanIdHash = storageGet(FAN_ID_KEY);
217
+ } catch (e) {
218
+ }
219
+ }
220
+ function loadConsent() {
221
+ try {
222
+ const stored = localStorage.getItem(CONSENT_STORAGE_KEY);
223
+ if (stored) {
224
+ const parsed = JSON.parse(stored);
225
+ consent = parsed.preferences || null;
226
+ log("Loaded consent:", consent);
227
+ }
228
+ } catch (e) {
229
+ }
230
+ }
231
+ function setConsent(preferences) {
232
+ const previousConsent = consent;
233
+ consent = preferences;
234
+ try {
235
+ const storage = {
236
+ preferences,
237
+ method: "explicit",
238
+ version: 1
239
+ };
240
+ localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(storage));
241
+ log("Consent saved:", preferences);
242
+ } catch (e) {
243
+ console.error("[BALANCE Pixel] Could not save consent:", e);
244
+ }
245
+ if (preferences.analytics === true) {
246
+ upgradeStorageTier();
247
+ }
248
+ const event = buildEvent({
249
+ event_name: "consent_updated",
250
+ metadata: {
251
+ consent_preferences: preferences,
252
+ consent_method: "explicit",
253
+ previous_consent: previousConsent || void 0
254
+ }
255
+ });
256
+ enqueueEvent(event);
257
+ }
258
+ function getConsent() {
259
+ return consent;
260
+ }
261
+ function hasConsent(type) {
262
+ return consent?.[type] === true;
263
+ }
264
+ async function hashEmail(email) {
265
+ const normalized = email.toLowerCase().trim();
266
+ const encoder = new TextEncoder();
267
+ const data = encoder.encode(normalized);
268
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
269
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
270
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
271
+ }
272
+ function buildEvent(partial) {
273
+ const deviceInfo = getDeviceInfo();
274
+ const base = {
275
+ artist_id: artistId,
276
+ fan_session_id: sessionId,
277
+ fan_id_hash: fanIdHash || void 0,
278
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
279
+ source_url: window.location.href,
280
+ referrer_url: document.referrer || void 0,
281
+ user_agent: navigator.userAgent,
282
+ // Device info (parsed client-side)
283
+ device_type: deviceInfo.device_type,
284
+ browser: deviceInfo.browser,
285
+ os: deviceInfo.os,
286
+ // Tracking source (gtm, pixel, etc.) - enables filtering in artistHQ
287
+ tracking_source: trackingSource,
288
+ ...partial,
289
+ ...attribution
290
+ };
291
+ if (projectId && !partial.projectId) {
292
+ base.projectId = projectId;
293
+ }
294
+ return base;
295
+ }
296
+ function enqueueEvent(event) {
297
+ eventQueue.push(event);
298
+ log("Event queued:", event.event_name, "(queue:", eventQueue.length, ")");
299
+ if (eventQueue.length >= 10) {
300
+ flush();
301
+ }
302
+ }
303
+ async function flush() {
304
+ if (eventQueue.length === 0)
305
+ return;
306
+ const events = [...eventQueue];
307
+ eventQueue = [];
308
+ log("Flushing", events.length, "events to", API_ENDPOINT);
309
+ try {
310
+ const response = await fetch(API_ENDPOINT, {
311
+ method: "POST",
312
+ headers: { "Content-Type": "application/json" },
313
+ body: JSON.stringify({ events }),
314
+ keepalive: true
315
+ // Send even if page is closing
316
+ });
317
+ if (!response.ok) {
318
+ throw new Error(`HTTP ${response.status}`);
319
+ }
320
+ log("Events sent successfully");
321
+ } catch (error) {
322
+ console.error("[BALANCE Pixel] Failed to send events:", error);
323
+ if (eventQueue.length < 50) {
324
+ eventQueue.push(...events);
325
+ }
326
+ }
327
+ }
328
+ function startFlushTimer() {
329
+ if (flushTimer)
330
+ clearInterval(flushTimer);
331
+ flushTimer = window.setInterval(() => {
332
+ if (eventQueue.length > 0)
333
+ flush();
334
+ }, 5e3);
335
+ }
336
+ function startActiveTimeTracking() {
337
+ if (!lastActiveTimestamp) {
338
+ pageStartTime = Date.now();
339
+ }
340
+ lastActiveTimestamp = Date.now();
341
+ isPageVisible = true;
342
+ log("Active time tracking started/resumed");
343
+ }
344
+ function pauseActiveTimeTracking() {
345
+ if (lastActiveTimestamp && isPageVisible) {
346
+ activeTime += Date.now() - lastActiveTimestamp;
347
+ }
348
+ isPageVisible = false;
349
+ log("Active time tracking paused, accumulated:", activeTime, "ms");
350
+ }
351
+ function getCumulativeActiveTime() {
352
+ let total = activeTime;
353
+ if (isPageVisible && lastActiveTimestamp) {
354
+ total += Date.now() - lastActiveTimestamp;
355
+ }
356
+ return total;
357
+ }
358
+ function resetIdleTimer() {
359
+ lastActivityTime = Date.now();
360
+ if (isIdle) {
361
+ isIdle = false;
362
+ startActiveTimeTracking();
363
+ log("User returned from idle");
364
+ }
365
+ }
366
+ function sendHeartbeat() {
367
+ if (document.visibilityState === "hidden") {
368
+ log("Skipping heartbeat - tab hidden");
369
+ return;
370
+ }
371
+ if (Date.now() - lastActivityTime > IDLE_TIMEOUT) {
372
+ if (!isIdle) {
373
+ isIdle = true;
374
+ pauseActiveTimeTracking();
375
+ log("User idle - pausing heartbeat");
376
+ }
377
+ return;
378
+ }
379
+ const timeOnPage = getCumulativeActiveTime();
380
+ const timeOnPageSeconds = Math.round(timeOnPage / 1e3);
381
+ const event = buildEvent({
382
+ event_name: "engagement_heartbeat",
383
+ metadata: {
384
+ time_on_page_seconds: timeOnPageSeconds,
385
+ time_on_page_ms: timeOnPage,
386
+ heartbeat_interval_ms: heartbeatInterval,
387
+ is_active: isPageVisible && !isIdle
388
+ }
389
+ });
390
+ enqueueEvent(event);
391
+ log("Heartbeat sent:", timeOnPageSeconds, "seconds active");
392
+ }
393
+ function startHeartbeat() {
394
+ if (!heartbeatEnabled) {
395
+ log('Heartbeat disabled via data-heartbeat="false"');
396
+ return;
397
+ }
398
+ if (heartbeatTimer)
399
+ clearInterval(heartbeatTimer);
400
+ startActiveTimeTracking();
401
+ heartbeatTimer = window.setInterval(() => {
402
+ sendHeartbeat();
403
+ }, heartbeatInterval);
404
+ log("Heartbeat started with interval:", heartbeatInterval, "ms");
405
+ }
406
+ function stopHeartbeat() {
407
+ if (heartbeatTimer) {
408
+ clearInterval(heartbeatTimer);
409
+ heartbeatTimer = null;
410
+ }
411
+ if (heartbeatEnabled) {
412
+ sendHeartbeat();
413
+ log("Heartbeat stopped, final time sent");
414
+ }
415
+ }
416
+ function trackPageView(options = {}) {
417
+ const event = buildEvent({
418
+ event_name: "pageview",
419
+ page_title: options.title || document.title,
420
+ source_url: options.url || window.location.href
421
+ });
422
+ enqueueEvent(event);
423
+ }
424
+ function track(eventName, properties = {}) {
425
+ const event = buildEvent({
426
+ event_name: "custom",
427
+ metadata: {
428
+ event_type: eventName,
429
+ ...properties
430
+ }
431
+ });
432
+ enqueueEvent(event);
433
+ }
434
+ async function identify(email, traits = {}) {
435
+ try {
436
+ if (consent && consent.analytics === false) {
437
+ log("Identify skipped - user declined analytics consent");
438
+ return;
439
+ }
440
+ fanIdHash = await hashEmail(email);
441
+ if (consent?.analytics === true) {
442
+ upgradeStorageTier();
443
+ }
444
+ storageSet(FAN_ID_KEY, fanIdHash);
445
+ const emailParts = email.split("@");
446
+ const maskedEmail = emailParts[0].charAt(0) + "***@" + (emailParts[1] || "");
447
+ log("Fan identified:", {
448
+ name: traits.name || "(no name)",
449
+ email: maskedEmail,
450
+ hash: fanIdHash.substring(0, 16) + "...",
451
+ traits,
452
+ storageTier: currentStorageTier
453
+ });
454
+ const event = buildEvent({
455
+ event_name: "identify",
456
+ fan_id_hash: fanIdHash,
457
+ metadata: {
458
+ email_sha256: fanIdHash,
459
+ traits,
460
+ consent_preferences: consent || void 0,
461
+ storage_tier: currentStorageTier
462
+ }
463
+ });
464
+ enqueueEvent(event);
465
+ } catch (error) {
466
+ console.error("[BALANCE Pixel] Failed to identify:", error);
467
+ }
468
+ }
469
+ function purchase(revenue, currency = "USD", properties = {}) {
470
+ const event = buildEvent({
471
+ event_name: "purchase",
472
+ metadata: {
473
+ revenue,
474
+ currency,
475
+ ...properties
476
+ }
477
+ });
478
+ enqueueEvent(event);
479
+ }
480
+ function init() {
481
+ trackingSource = detectTrackingSource();
482
+ log("Tracking source detected:", trackingSource);
483
+ loadConsent();
484
+ if (consent?.analytics === true) {
485
+ currentStorageTier = "local";
486
+ log("Storage tier: local (analytics consent granted)");
487
+ } else {
488
+ currentStorageTier = "session";
489
+ log("Storage tier: session (privacy by default)");
490
+ }
491
+ sessionId = getOrCreateSession();
492
+ loadFanId();
493
+ if (!consent) {
494
+ consent = {
495
+ analytics: true,
496
+ marketing: true,
497
+ personalization: true,
498
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
499
+ };
500
+ log("Default consent enabled (all tracking):", consent);
501
+ upgradeStorageTier();
502
+ }
503
+ captureAttribution();
504
+ startFlushTimer();
505
+ log("Initialized", {
506
+ artistId,
507
+ projectId: projectId || "(none - will track to all projects)",
508
+ sessionId,
509
+ fanIdHash,
510
+ consent,
511
+ storageTier: currentStorageTier,
512
+ trackingSource,
513
+ useEmulator,
514
+ endpoint: API_ENDPOINT
515
+ });
516
+ window._balanceInitialPageviewFired = true;
517
+ trackPageView();
518
+ startHeartbeat();
519
+ ["mousemove", "keydown", "scroll", "touchstart"].forEach((eventType) => {
520
+ document.addEventListener(eventType, resetIdleTimer, { passive: true });
521
+ });
522
+ window.addEventListener("beforeunload", () => {
523
+ stopHeartbeat();
524
+ flush();
525
+ });
526
+ document.addEventListener("visibilitychange", () => {
527
+ if (document.visibilityState === "hidden") {
528
+ pauseActiveTimeTracking();
529
+ flush();
530
+ } else {
531
+ startActiveTimeTracking();
532
+ }
533
+ });
534
+ }
535
+ window.balance = {
536
+ track,
537
+ identify,
538
+ page: trackPageView,
539
+ purchase,
540
+ getSessionId: () => sessionId,
541
+ getFanIdHash: () => fanIdHash,
542
+ getAttribution: () => attribution,
543
+ setConsent,
544
+ getConsent,
545
+ hasConsent
546
+ };
547
+ if (document.readyState === "loading") {
548
+ document.addEventListener("DOMContentLoaded", init);
549
+ } else {
550
+ init();
551
+ }
552
+ log("Pixel script loaded");
553
+ })();
554
+ })();