@neowhale/storefront 0.2.13 → 0.2.19
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.
- package/dist/chunk-7KXJLHGA.cjs +160 -0
- package/dist/chunk-7KXJLHGA.cjs.map +1 -0
- package/dist/{chunk-3VKRKDPL.cjs → chunk-CQCCXDUS.cjs} +52 -11
- package/dist/chunk-CQCCXDUS.cjs.map +1 -0
- package/dist/chunk-PXS2DPVL.js +158 -0
- package/dist/chunk-PXS2DPVL.js.map +1 -0
- package/dist/{chunk-M2MR6C55.js → chunk-XHWAUMWS.js} +52 -11
- package/dist/chunk-XHWAUMWS.js.map +1 -0
- package/dist/{client-Ca8Otk-R.d.cts → client-D1XVKpFt.d.cts} +76 -4
- package/dist/{client-Ca8Otk-R.d.ts → client-D1XVKpFt.d.ts} +76 -4
- package/dist/index.cjs +5 -5
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/next/index.cjs +7 -6
- package/dist/next/index.cjs.map +1 -1
- package/dist/next/index.d.cts +1 -1
- package/dist/next/index.d.ts +1 -1
- package/dist/next/index.js +5 -4
- package/dist/next/index.js.map +1 -1
- package/dist/{pixel-manager-CIZKghfx.d.ts → pixel-manager-C6PAp7vQ.d.ts} +1 -1
- package/dist/{pixel-manager-CIR16DXY.d.cts → pixel-manager-DZwpn_x2.d.cts} +1 -1
- package/dist/react/index.cjs +1270 -37
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +53 -7
- package/dist/react/index.d.ts +53 -7
- package/dist/react/index.js +1264 -36
- package/dist/react/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-3VKRKDPL.cjs.map +0 -1
- package/dist/chunk-BTGOSNMP.cjs +0 -95
- package/dist/chunk-BTGOSNMP.cjs.map +0 -1
- package/dist/chunk-M2MR6C55.js.map +0 -1
- package/dist/chunk-NLH3W6JA.js +0 -93
- package/dist/chunk-NLH3W6JA.js.map +0 -1
package/dist/react/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { PixelManager } from '../chunk-
|
|
2
|
-
import { WhaleClient } from '../chunk-
|
|
1
|
+
import { PixelManager } from '../chunk-PXS2DPVL.js';
|
|
2
|
+
import { resilientSend, WhaleClient } from '../chunk-XHWAUMWS.js';
|
|
3
3
|
import { createContext, useContext, useRef, useCallback, useEffect, useState, useMemo } from 'react';
|
|
4
4
|
import { usePathname } from 'next/navigation';
|
|
5
5
|
import { createStore } from 'zustand/vanilla';
|
|
@@ -10,7 +10,7 @@ import { ThemeProvider } from '@neowhale/ui';
|
|
|
10
10
|
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
11
11
|
|
|
12
12
|
var WhaleContext = createContext(null);
|
|
13
|
-
function createCartStore(client, storagePrefix, onAddToCart, onRemoveFromCart) {
|
|
13
|
+
function createCartStore(client, storagePrefix, onAddToCart, onRemoveFromCart, onCartChange) {
|
|
14
14
|
return createStore()(
|
|
15
15
|
persist(
|
|
16
16
|
(set, get) => ({
|
|
@@ -98,6 +98,8 @@ function createCartStore(client, storagePrefix, onAddToCart, onRemoveFromCart) {
|
|
|
98
98
|
}
|
|
99
99
|
await get().syncCart();
|
|
100
100
|
onAddToCart?.(productId, productName || "", quantity, unitPrice || 0, tier);
|
|
101
|
+
const state = get();
|
|
102
|
+
if (state.cartId) onCartChange?.(state.cartId, state.total, state.itemCount);
|
|
101
103
|
} finally {
|
|
102
104
|
set({ cartLoading: false, addItemInFlight: false });
|
|
103
105
|
}
|
|
@@ -109,6 +111,8 @@ function createCartStore(client, storagePrefix, onAddToCart, onRemoveFromCart) {
|
|
|
109
111
|
if (!cartId) return;
|
|
110
112
|
await client.updateCartItem(cartId, itemId, quantity);
|
|
111
113
|
await get().syncCart();
|
|
114
|
+
const state = get();
|
|
115
|
+
if (state.cartId) onCartChange?.(state.cartId, state.total, state.itemCount);
|
|
112
116
|
} finally {
|
|
113
117
|
set({ cartLoading: false });
|
|
114
118
|
}
|
|
@@ -124,6 +128,8 @@ function createCartStore(client, storagePrefix, onAddToCart, onRemoveFromCart) {
|
|
|
124
128
|
if (item) {
|
|
125
129
|
onRemoveFromCart?.(item.product_id, productName || item.product_name);
|
|
126
130
|
}
|
|
131
|
+
const state = get();
|
|
132
|
+
if (state.cartId) onCartChange?.(state.cartId, state.total, state.itemCount);
|
|
127
133
|
} finally {
|
|
128
134
|
set({ cartLoading: false });
|
|
129
135
|
}
|
|
@@ -237,6 +243,12 @@ function createAuthStore(client, storagePrefix) {
|
|
|
237
243
|
set({ authLoading: false });
|
|
238
244
|
}
|
|
239
245
|
},
|
|
246
|
+
updateProfile: async (data) => {
|
|
247
|
+
const customer = get().customer;
|
|
248
|
+
if (!customer?.id) throw new Error("Not authenticated");
|
|
249
|
+
const updated = await client.updateProfile(customer.id, data);
|
|
250
|
+
set({ customer: updated });
|
|
251
|
+
},
|
|
240
252
|
restoreSession: async () => {
|
|
241
253
|
const { sessionToken, sessionExpiresAt, customer } = get();
|
|
242
254
|
if (!sessionToken || !sessionExpiresAt) return;
|
|
@@ -297,6 +309,38 @@ function createAuthStore(client, storagePrefix) {
|
|
|
297
309
|
);
|
|
298
310
|
}
|
|
299
311
|
var SESSION_KEY_SUFFIX = "-analytics-session";
|
|
312
|
+
var VISITOR_KEY_SUFFIX = "-visitor-id";
|
|
313
|
+
function parseMarketingParams() {
|
|
314
|
+
if (typeof window === "undefined") return {};
|
|
315
|
+
const params = new URLSearchParams(window.location.search);
|
|
316
|
+
const result = {};
|
|
317
|
+
for (const key of ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term", "gclid", "fbclid"]) {
|
|
318
|
+
const val = params.get(key);
|
|
319
|
+
if (val) result[key] = val;
|
|
320
|
+
}
|
|
321
|
+
return result;
|
|
322
|
+
}
|
|
323
|
+
function getVisitorId(prefix) {
|
|
324
|
+
const key = `${prefix}${VISITOR_KEY_SUFFIX}`;
|
|
325
|
+
try {
|
|
326
|
+
const existing = localStorage.getItem(key);
|
|
327
|
+
if (existing) return existing;
|
|
328
|
+
} catch {
|
|
329
|
+
}
|
|
330
|
+
const id = `v-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
331
|
+
try {
|
|
332
|
+
localStorage.setItem(key, id);
|
|
333
|
+
} catch {
|
|
334
|
+
}
|
|
335
|
+
return id;
|
|
336
|
+
}
|
|
337
|
+
function detectDevice() {
|
|
338
|
+
if (typeof navigator === "undefined") return "unknown";
|
|
339
|
+
const ua = navigator.userAgent;
|
|
340
|
+
if (/Mobi|Android/i.test(ua)) return "mobile";
|
|
341
|
+
if (/Tablet|iPad/i.test(ua)) return "tablet";
|
|
342
|
+
return "desktop";
|
|
343
|
+
}
|
|
300
344
|
function useAnalytics() {
|
|
301
345
|
const ctx = useContext(WhaleContext);
|
|
302
346
|
if (!ctx) throw new Error("useAnalytics must be used within <WhaleProvider>");
|
|
@@ -319,9 +363,15 @@ function useAnalytics() {
|
|
|
319
363
|
} catch {
|
|
320
364
|
}
|
|
321
365
|
try {
|
|
366
|
+
const marketing = parseMarketingParams();
|
|
367
|
+
const visitorId = getVisitorId(config.storagePrefix);
|
|
322
368
|
const session = await client.createSession({
|
|
369
|
+
visitor_id: visitorId,
|
|
323
370
|
user_agent: navigator.userAgent,
|
|
324
|
-
referrer: document.referrer || void 0
|
|
371
|
+
referrer: document.referrer || void 0,
|
|
372
|
+
page_url: window.location.href,
|
|
373
|
+
device: detectDevice(),
|
|
374
|
+
...marketing
|
|
325
375
|
});
|
|
326
376
|
if (session?.id) {
|
|
327
377
|
localStorage.setItem(sessionKey, JSON.stringify({ id: session.id, createdAt: Date.now() }));
|
|
@@ -346,10 +396,12 @@ function useAnalytics() {
|
|
|
346
396
|
pixelManager?.track(eventType, { ...data, eventID: eventId });
|
|
347
397
|
try {
|
|
348
398
|
const sessionId = await getOrCreateSession();
|
|
399
|
+
const visitorId = getVisitorId(config.storagePrefix);
|
|
349
400
|
await client.trackEvent({
|
|
350
401
|
session_id: sessionId,
|
|
351
402
|
event_type: eventType,
|
|
352
|
-
event_data: { ...data, event_id: eventId }
|
|
403
|
+
event_data: { ...data, event_id: eventId },
|
|
404
|
+
visitor_id: visitorId
|
|
353
405
|
});
|
|
354
406
|
} catch {
|
|
355
407
|
}
|
|
@@ -368,9 +420,38 @@ function useAnalytics() {
|
|
|
368
420
|
},
|
|
369
421
|
[client, getOrCreateSession, trackingEnabled]
|
|
370
422
|
);
|
|
423
|
+
const updateSessionCart = useCallback(
|
|
424
|
+
async (cartId, cartTotal, cartItemCount) => {
|
|
425
|
+
if (!trackingEnabled) return;
|
|
426
|
+
try {
|
|
427
|
+
const sessionId = await getOrCreateSession();
|
|
428
|
+
if (sessionId.startsWith("local-")) return;
|
|
429
|
+
await client.updateSession(sessionId, {
|
|
430
|
+
cart_id: cartId,
|
|
431
|
+
cart_total: cartTotal,
|
|
432
|
+
cart_item_count: cartItemCount,
|
|
433
|
+
status: "carting"
|
|
434
|
+
});
|
|
435
|
+
} catch {
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
[client, getOrCreateSession, trackingEnabled]
|
|
439
|
+
);
|
|
440
|
+
const updateSessionOrder = useCallback(
|
|
441
|
+
async (orderId) => {
|
|
442
|
+
if (!trackingEnabled) return;
|
|
443
|
+
try {
|
|
444
|
+
const sessionId = await getOrCreateSession();
|
|
445
|
+
if (sessionId.startsWith("local-")) return;
|
|
446
|
+
await client.updateSession(sessionId, { order_id: orderId, status: "converted" });
|
|
447
|
+
} catch {
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
[client, getOrCreateSession, trackingEnabled]
|
|
451
|
+
);
|
|
371
452
|
const trackPageView = useCallback(
|
|
372
453
|
(url, referrer) => {
|
|
373
|
-
track("page_view", { url, referrer });
|
|
454
|
+
track("page_view", { url, referrer, page_url: url });
|
|
374
455
|
},
|
|
375
456
|
[track]
|
|
376
457
|
);
|
|
@@ -427,6 +508,8 @@ function useAnalytics() {
|
|
|
427
508
|
trackAddToCart,
|
|
428
509
|
trackRemoveFromCart,
|
|
429
510
|
linkCustomer,
|
|
511
|
+
updateSessionCart,
|
|
512
|
+
updateSessionOrder,
|
|
430
513
|
getOrCreateSession,
|
|
431
514
|
/** Whether tracking is globally enabled for this storefront */
|
|
432
515
|
trackingEnabled,
|
|
@@ -444,6 +527,7 @@ function useAuth() {
|
|
|
444
527
|
isAuthenticated: s.isSessionValid(),
|
|
445
528
|
sendCode: s.sendOTP,
|
|
446
529
|
verifyCode: s.verifyOTP,
|
|
530
|
+
updateProfile: s.updateProfile,
|
|
447
531
|
restoreSession: s.restoreSession,
|
|
448
532
|
logout: s.logout,
|
|
449
533
|
fetchCustomer: s.fetchCustomer
|
|
@@ -463,7 +547,8 @@ function AnalyticsTracker({ pathname }) {
|
|
|
463
547
|
if (pathname === prevPathname.current) return;
|
|
464
548
|
const referrer = prevPathname.current || (typeof document !== "undefined" ? document.referrer : "");
|
|
465
549
|
prevPathname.current = pathname;
|
|
466
|
-
|
|
550
|
+
const fullUrl = typeof window !== "undefined" ? window.location.href : pathname;
|
|
551
|
+
trackPageView(fullUrl, referrer || void 0);
|
|
467
552
|
}, [pathname, trackPageView, trackingEnabled]);
|
|
468
553
|
useEffect(() => {
|
|
469
554
|
if (!trackingEnabled) return;
|
|
@@ -557,6 +642,983 @@ function PixelInitializer({ onReady, onTheme }) {
|
|
|
557
642
|
}, [ctx, onReady, onTheme]);
|
|
558
643
|
return null;
|
|
559
644
|
}
|
|
645
|
+
|
|
646
|
+
// src/behavioral/tracker.ts
|
|
647
|
+
var SCROLL_MILESTONES = [25, 50, 75, 100];
|
|
648
|
+
var TIME_MILESTONES = [30, 60, 120, 300];
|
|
649
|
+
var MOUSE_THROTTLE_MS = 200;
|
|
650
|
+
var MOUSE_BUFFER_MAX = 100;
|
|
651
|
+
var RAGE_CLICK_COUNT = 3;
|
|
652
|
+
var RAGE_CLICK_RADIUS = 50;
|
|
653
|
+
var RAGE_CLICK_WINDOW_MS = 2e3;
|
|
654
|
+
var MAX_CLICK_HISTORY = 10;
|
|
655
|
+
var BehavioralTracker = class {
|
|
656
|
+
constructor(config) {
|
|
657
|
+
this.buffer = [];
|
|
658
|
+
this.pageUrl = "";
|
|
659
|
+
this.pagePath = "";
|
|
660
|
+
this.flushTimer = null;
|
|
661
|
+
this.scrollMilestones = /* @__PURE__ */ new Set();
|
|
662
|
+
this.timeMilestones = /* @__PURE__ */ new Set();
|
|
663
|
+
this.timeTimers = [];
|
|
664
|
+
this.exitIntentFired = false;
|
|
665
|
+
this.startTime = 0;
|
|
666
|
+
this.clickHistory = [];
|
|
667
|
+
this.mouseBuffer = [];
|
|
668
|
+
this.lastMouseTime = 0;
|
|
669
|
+
this.listeners = [];
|
|
670
|
+
this.observer = null;
|
|
671
|
+
this.sentinels = [];
|
|
672
|
+
// ---------------------------------------------------------------------------
|
|
673
|
+
// Event handlers (arrow functions for stable `this`)
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
this.handleClick = (e) => {
|
|
676
|
+
const me = e;
|
|
677
|
+
const target = me.target;
|
|
678
|
+
if (!target) return;
|
|
679
|
+
const now2 = Date.now();
|
|
680
|
+
const x = me.clientX;
|
|
681
|
+
const y = me.clientY;
|
|
682
|
+
this.clickHistory.push({ x, y, t: now2 });
|
|
683
|
+
if (this.clickHistory.length > MAX_CLICK_HISTORY) {
|
|
684
|
+
this.clickHistory.shift();
|
|
685
|
+
}
|
|
686
|
+
const tag = target.tagName?.toLowerCase() ?? "";
|
|
687
|
+
const rawText = target.textContent ?? "";
|
|
688
|
+
const text = rawText.trim().slice(0, 50);
|
|
689
|
+
this.push({
|
|
690
|
+
data_type: "click",
|
|
691
|
+
data: {
|
|
692
|
+
tag,
|
|
693
|
+
text,
|
|
694
|
+
selector: this.getSelector(target),
|
|
695
|
+
x,
|
|
696
|
+
y,
|
|
697
|
+
timestamp: now2
|
|
698
|
+
},
|
|
699
|
+
page_url: this.pageUrl,
|
|
700
|
+
page_path: this.pagePath
|
|
701
|
+
});
|
|
702
|
+
this.detectRageClick(x, y, now2);
|
|
703
|
+
};
|
|
704
|
+
this.handleMouseMove = (e) => {
|
|
705
|
+
const me = e;
|
|
706
|
+
const now2 = Date.now();
|
|
707
|
+
if (now2 - this.lastMouseTime < MOUSE_THROTTLE_MS) return;
|
|
708
|
+
this.lastMouseTime = now2;
|
|
709
|
+
this.mouseBuffer.push({ x: me.clientX, y: me.clientY, t: now2 });
|
|
710
|
+
if (this.mouseBuffer.length > MOUSE_BUFFER_MAX) {
|
|
711
|
+
this.mouseBuffer.shift();
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
this.handleMouseOut = (e) => {
|
|
715
|
+
const me = e;
|
|
716
|
+
if (this.exitIntentFired) return;
|
|
717
|
+
if (me.clientY > 0) return;
|
|
718
|
+
if (me.relatedTarget !== null) return;
|
|
719
|
+
this.exitIntentFired = true;
|
|
720
|
+
this.push({
|
|
721
|
+
data_type: "exit_intent",
|
|
722
|
+
data: {
|
|
723
|
+
time_on_page_ms: Date.now() - this.startTime,
|
|
724
|
+
timestamp: Date.now()
|
|
725
|
+
},
|
|
726
|
+
page_url: this.pageUrl,
|
|
727
|
+
page_path: this.pagePath
|
|
728
|
+
});
|
|
729
|
+
};
|
|
730
|
+
this.handleCopy = () => {
|
|
731
|
+
const selection = window.getSelection();
|
|
732
|
+
const length = selection?.toString().length ?? 0;
|
|
733
|
+
this.push({
|
|
734
|
+
data_type: "copy",
|
|
735
|
+
data: {
|
|
736
|
+
text_length: length,
|
|
737
|
+
timestamp: Date.now()
|
|
738
|
+
},
|
|
739
|
+
page_url: this.pageUrl,
|
|
740
|
+
page_path: this.pagePath
|
|
741
|
+
});
|
|
742
|
+
};
|
|
743
|
+
this.handleVisibilityChange = () => {
|
|
744
|
+
if (document.visibilityState !== "hidden") return;
|
|
745
|
+
const timeSpent = Date.now() - this.startTime;
|
|
746
|
+
this.push({
|
|
747
|
+
data_type: "page_exit",
|
|
748
|
+
data: {
|
|
749
|
+
time_spent_ms: timeSpent,
|
|
750
|
+
timestamp: Date.now()
|
|
751
|
+
},
|
|
752
|
+
page_url: this.pageUrl,
|
|
753
|
+
page_path: this.pagePath
|
|
754
|
+
});
|
|
755
|
+
this.flushMouseBuffer();
|
|
756
|
+
this.flush();
|
|
757
|
+
};
|
|
758
|
+
this.config = {
|
|
759
|
+
sendBatch: config.sendBatch,
|
|
760
|
+
sessionId: config.sessionId,
|
|
761
|
+
visitorId: config.visitorId,
|
|
762
|
+
flushIntervalMs: config.flushIntervalMs ?? 1e4,
|
|
763
|
+
maxBufferSize: config.maxBufferSize ?? 500
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
start() {
|
|
767
|
+
this.startTime = Date.now();
|
|
768
|
+
this.addListener(document, "click", this.handleClick);
|
|
769
|
+
this.addListener(document, "mousemove", this.handleMouseMove);
|
|
770
|
+
this.addListener(document, "mouseout", this.handleMouseOut);
|
|
771
|
+
this.addListener(document, "copy", this.handleCopy);
|
|
772
|
+
this.addListener(document, "visibilitychange", this.handleVisibilityChange);
|
|
773
|
+
this.setupScrollTracking();
|
|
774
|
+
this.setupTimeMilestones();
|
|
775
|
+
this.flushTimer = setInterval(() => this.flush(), this.config.flushIntervalMs);
|
|
776
|
+
}
|
|
777
|
+
stop() {
|
|
778
|
+
for (const [target, event, handler] of this.listeners) {
|
|
779
|
+
target.removeEventListener(event, handler, { capture: true });
|
|
780
|
+
}
|
|
781
|
+
this.listeners = [];
|
|
782
|
+
if (this.flushTimer !== null) {
|
|
783
|
+
clearInterval(this.flushTimer);
|
|
784
|
+
this.flushTimer = null;
|
|
785
|
+
}
|
|
786
|
+
this.clearTimeMilestones();
|
|
787
|
+
this.cleanupScrollTracking();
|
|
788
|
+
this.flushMouseBuffer();
|
|
789
|
+
this.flush();
|
|
790
|
+
}
|
|
791
|
+
setPageContext(url, path) {
|
|
792
|
+
this.flushMouseBuffer();
|
|
793
|
+
this.flush();
|
|
794
|
+
this.pageUrl = url;
|
|
795
|
+
this.pagePath = path;
|
|
796
|
+
this.scrollMilestones.clear();
|
|
797
|
+
this.timeMilestones.clear();
|
|
798
|
+
this.exitIntentFired = false;
|
|
799
|
+
this.startTime = Date.now();
|
|
800
|
+
this.clickHistory = [];
|
|
801
|
+
this.clearTimeMilestones();
|
|
802
|
+
this.cleanupScrollTracking();
|
|
803
|
+
this.setupTimeMilestones();
|
|
804
|
+
requestAnimationFrame(() => this.setupScrollTracking());
|
|
805
|
+
}
|
|
806
|
+
// ---------------------------------------------------------------------------
|
|
807
|
+
// Buffer management
|
|
808
|
+
// ---------------------------------------------------------------------------
|
|
809
|
+
push(event) {
|
|
810
|
+
this.buffer.push(event);
|
|
811
|
+
if (this.buffer.length >= this.config.maxBufferSize) {
|
|
812
|
+
this.flush();
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
flush() {
|
|
816
|
+
if (this.buffer.length === 0) return;
|
|
817
|
+
const batch = {
|
|
818
|
+
session_id: this.config.sessionId,
|
|
819
|
+
visitor_id: this.config.visitorId,
|
|
820
|
+
events: this.buffer
|
|
821
|
+
};
|
|
822
|
+
this.buffer = [];
|
|
823
|
+
this.config.sendBatch(batch).catch(() => {
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
addListener(target, event, handler) {
|
|
827
|
+
target.addEventListener(event, handler, { passive: true, capture: true });
|
|
828
|
+
this.listeners.push([target, event, handler]);
|
|
829
|
+
}
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
// Scroll tracking with IntersectionObserver
|
|
832
|
+
// ---------------------------------------------------------------------------
|
|
833
|
+
setupScrollTracking() {
|
|
834
|
+
if (typeof IntersectionObserver === "undefined") return;
|
|
835
|
+
this.observer = new IntersectionObserver(
|
|
836
|
+
(entries) => {
|
|
837
|
+
for (const entry of entries) {
|
|
838
|
+
if (!entry.isIntersecting) continue;
|
|
839
|
+
const milestone = Number(entry.target.getAttribute("data-scroll-milestone"));
|
|
840
|
+
if (isNaN(milestone) || this.scrollMilestones.has(milestone)) continue;
|
|
841
|
+
this.scrollMilestones.add(milestone);
|
|
842
|
+
this.push({
|
|
843
|
+
data_type: "scroll_depth",
|
|
844
|
+
data: {
|
|
845
|
+
depth_percent: milestone,
|
|
846
|
+
timestamp: Date.now()
|
|
847
|
+
},
|
|
848
|
+
page_url: this.pageUrl,
|
|
849
|
+
page_path: this.pagePath
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
},
|
|
853
|
+
{ threshold: 0 }
|
|
854
|
+
);
|
|
855
|
+
const docHeight = document.documentElement.scrollHeight;
|
|
856
|
+
for (const pct of SCROLL_MILESTONES) {
|
|
857
|
+
const sentinel = document.createElement("div");
|
|
858
|
+
sentinel.setAttribute("data-scroll-milestone", String(pct));
|
|
859
|
+
sentinel.style.position = "absolute";
|
|
860
|
+
sentinel.style.left = "0";
|
|
861
|
+
sentinel.style.width = "1px";
|
|
862
|
+
sentinel.style.height = "1px";
|
|
863
|
+
sentinel.style.pointerEvents = "none";
|
|
864
|
+
sentinel.style.opacity = "0";
|
|
865
|
+
sentinel.style.top = `${docHeight * pct / 100 - 1}px`;
|
|
866
|
+
document.body.appendChild(sentinel);
|
|
867
|
+
this.sentinels.push(sentinel);
|
|
868
|
+
this.observer.observe(sentinel);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
cleanupScrollTracking() {
|
|
872
|
+
if (this.observer) {
|
|
873
|
+
this.observer.disconnect();
|
|
874
|
+
this.observer = null;
|
|
875
|
+
}
|
|
876
|
+
for (const sentinel of this.sentinels) {
|
|
877
|
+
sentinel.remove();
|
|
878
|
+
}
|
|
879
|
+
this.sentinels = [];
|
|
880
|
+
}
|
|
881
|
+
// ---------------------------------------------------------------------------
|
|
882
|
+
// Time milestones
|
|
883
|
+
// ---------------------------------------------------------------------------
|
|
884
|
+
setupTimeMilestones() {
|
|
885
|
+
for (const seconds of TIME_MILESTONES) {
|
|
886
|
+
const timer = setTimeout(() => {
|
|
887
|
+
if (this.timeMilestones.has(seconds)) return;
|
|
888
|
+
this.timeMilestones.add(seconds);
|
|
889
|
+
this.push({
|
|
890
|
+
data_type: "time_on_page",
|
|
891
|
+
data: {
|
|
892
|
+
milestone_seconds: seconds,
|
|
893
|
+
timestamp: Date.now()
|
|
894
|
+
},
|
|
895
|
+
page_url: this.pageUrl,
|
|
896
|
+
page_path: this.pagePath
|
|
897
|
+
});
|
|
898
|
+
}, seconds * 1e3);
|
|
899
|
+
this.timeTimers.push(timer);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
clearTimeMilestones() {
|
|
903
|
+
for (const timer of this.timeTimers) {
|
|
904
|
+
clearTimeout(timer);
|
|
905
|
+
}
|
|
906
|
+
this.timeTimers = [];
|
|
907
|
+
}
|
|
908
|
+
// ---------------------------------------------------------------------------
|
|
909
|
+
// Rage click detection
|
|
910
|
+
// ---------------------------------------------------------------------------
|
|
911
|
+
detectRageClick(x, y, now2) {
|
|
912
|
+
const windowStart = now2 - RAGE_CLICK_WINDOW_MS;
|
|
913
|
+
const nearby = this.clickHistory.filter((c) => {
|
|
914
|
+
if (c.t < windowStart) return false;
|
|
915
|
+
const dx = c.x - x;
|
|
916
|
+
const dy = c.y - y;
|
|
917
|
+
return Math.sqrt(dx * dx + dy * dy) <= RAGE_CLICK_RADIUS;
|
|
918
|
+
});
|
|
919
|
+
if (nearby.length >= RAGE_CLICK_COUNT) {
|
|
920
|
+
this.push({
|
|
921
|
+
data_type: "rage_click",
|
|
922
|
+
data: {
|
|
923
|
+
x,
|
|
924
|
+
y,
|
|
925
|
+
click_count: nearby.length,
|
|
926
|
+
timestamp: now2
|
|
927
|
+
},
|
|
928
|
+
page_url: this.pageUrl,
|
|
929
|
+
page_path: this.pagePath
|
|
930
|
+
});
|
|
931
|
+
this.clickHistory = [];
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
// ---------------------------------------------------------------------------
|
|
935
|
+
// Mouse buffer flush
|
|
936
|
+
// ---------------------------------------------------------------------------
|
|
937
|
+
flushMouseBuffer() {
|
|
938
|
+
if (this.mouseBuffer.length === 0) return;
|
|
939
|
+
this.push({
|
|
940
|
+
data_type: "mouse_movement",
|
|
941
|
+
data: {
|
|
942
|
+
points: [...this.mouseBuffer],
|
|
943
|
+
timestamp: Date.now()
|
|
944
|
+
},
|
|
945
|
+
page_url: this.pageUrl,
|
|
946
|
+
page_path: this.pagePath
|
|
947
|
+
});
|
|
948
|
+
this.mouseBuffer = [];
|
|
949
|
+
}
|
|
950
|
+
// ---------------------------------------------------------------------------
|
|
951
|
+
// CSS selector helper
|
|
952
|
+
// ---------------------------------------------------------------------------
|
|
953
|
+
getSelector(el) {
|
|
954
|
+
const parts = [];
|
|
955
|
+
let current = el;
|
|
956
|
+
let depth = 0;
|
|
957
|
+
while (current && depth < 3) {
|
|
958
|
+
let segment = current.tagName.toLowerCase();
|
|
959
|
+
if (current.id) {
|
|
960
|
+
segment += `#${current.id}`;
|
|
961
|
+
} else if (current.classList.length > 0) {
|
|
962
|
+
segment += `.${Array.from(current.classList).join(".")}`;
|
|
963
|
+
}
|
|
964
|
+
parts.unshift(segment);
|
|
965
|
+
current = current.parentElement;
|
|
966
|
+
depth++;
|
|
967
|
+
}
|
|
968
|
+
return parts.join(" > ");
|
|
969
|
+
}
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
// src/react/components/behavioral-tracker.tsx
|
|
973
|
+
var SESSION_KEY_SUFFIX2 = "-analytics-session";
|
|
974
|
+
var VISITOR_KEY_SUFFIX2 = "-visitor-id";
|
|
975
|
+
var MAX_SESSION_WAIT_MS = 1e4;
|
|
976
|
+
var SESSION_POLL_MS = 500;
|
|
977
|
+
function BehavioralTrackerComponent({ pathname }) {
|
|
978
|
+
const ctx = useContext(WhaleContext);
|
|
979
|
+
const trackerRef = useRef(null);
|
|
980
|
+
const initRef = useRef(false);
|
|
981
|
+
useEffect(() => {
|
|
982
|
+
if (!ctx || !ctx.config.trackingEnabled) return;
|
|
983
|
+
if (typeof window === "undefined") return;
|
|
984
|
+
const { config } = ctx;
|
|
985
|
+
let cancelled = false;
|
|
986
|
+
let pollTimer = null;
|
|
987
|
+
const startTime = Date.now();
|
|
988
|
+
const readSessionId = () => {
|
|
989
|
+
const key = `${config.storagePrefix}${SESSION_KEY_SUFFIX2}`;
|
|
990
|
+
try {
|
|
991
|
+
const raw = localStorage.getItem(key);
|
|
992
|
+
if (raw) {
|
|
993
|
+
const stored = JSON.parse(raw);
|
|
994
|
+
return stored.id ?? null;
|
|
995
|
+
}
|
|
996
|
+
} catch {
|
|
997
|
+
}
|
|
998
|
+
return null;
|
|
999
|
+
};
|
|
1000
|
+
const readVisitorId = () => {
|
|
1001
|
+
const key = `${config.storagePrefix}${VISITOR_KEY_SUFFIX2}`;
|
|
1002
|
+
try {
|
|
1003
|
+
const existing = localStorage.getItem(key);
|
|
1004
|
+
if (existing) return existing;
|
|
1005
|
+
} catch {
|
|
1006
|
+
}
|
|
1007
|
+
const id = `v-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1008
|
+
try {
|
|
1009
|
+
localStorage.setItem(key, id);
|
|
1010
|
+
} catch {
|
|
1011
|
+
}
|
|
1012
|
+
return id;
|
|
1013
|
+
};
|
|
1014
|
+
const tryInit = () => {
|
|
1015
|
+
if (cancelled) return;
|
|
1016
|
+
const sessionId = readSessionId();
|
|
1017
|
+
if (!sessionId) {
|
|
1018
|
+
if (Date.now() - startTime < MAX_SESSION_WAIT_MS) {
|
|
1019
|
+
pollTimer = setTimeout(tryInit, SESSION_POLL_MS);
|
|
1020
|
+
}
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
initRef.current = true;
|
|
1024
|
+
const visitorId = readVisitorId();
|
|
1025
|
+
const baseUrl = config.proxyPath;
|
|
1026
|
+
const endpoint = `${baseUrl}/v1/stores/${config.storeId}/storefront/behavioral`;
|
|
1027
|
+
const sendBatch = async (batch) => {
|
|
1028
|
+
await resilientSend(endpoint, batch, {
|
|
1029
|
+
"Content-Type": "application/json",
|
|
1030
|
+
"x-api-key": config.apiKey
|
|
1031
|
+
});
|
|
1032
|
+
};
|
|
1033
|
+
const tracker = new BehavioralTracker({ sendBatch, sessionId, visitorId });
|
|
1034
|
+
tracker.start();
|
|
1035
|
+
trackerRef.current = tracker;
|
|
1036
|
+
};
|
|
1037
|
+
tryInit();
|
|
1038
|
+
return () => {
|
|
1039
|
+
cancelled = true;
|
|
1040
|
+
if (pollTimer) clearTimeout(pollTimer);
|
|
1041
|
+
if (trackerRef.current) {
|
|
1042
|
+
trackerRef.current.stop();
|
|
1043
|
+
trackerRef.current = null;
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
}, []);
|
|
1047
|
+
useEffect(() => {
|
|
1048
|
+
if (!trackerRef.current || !pathname) return;
|
|
1049
|
+
const url = typeof window !== "undefined" ? window.location.href : pathname;
|
|
1050
|
+
trackerRef.current.setPageContext(url, pathname);
|
|
1051
|
+
}, [pathname]);
|
|
1052
|
+
return null;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// src/fingerprint/collector.ts
|
|
1056
|
+
async function sha256(input) {
|
|
1057
|
+
const data = new TextEncoder().encode(input);
|
|
1058
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
1059
|
+
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
1060
|
+
}
|
|
1061
|
+
async function getCanvasFingerprint() {
|
|
1062
|
+
try {
|
|
1063
|
+
const canvas = document.createElement("canvas");
|
|
1064
|
+
canvas.width = 256;
|
|
1065
|
+
canvas.height = 256;
|
|
1066
|
+
const ctx = canvas.getContext("2d");
|
|
1067
|
+
if (!ctx) return "";
|
|
1068
|
+
const gradient = ctx.createLinearGradient(0, 0, 256, 256);
|
|
1069
|
+
gradient.addColorStop(0, "#ff6b35");
|
|
1070
|
+
gradient.addColorStop(0.5, "#1a73e8");
|
|
1071
|
+
gradient.addColorStop(1, "#34a853");
|
|
1072
|
+
ctx.fillStyle = gradient;
|
|
1073
|
+
ctx.fillRect(0, 0, 256, 256);
|
|
1074
|
+
ctx.fillStyle = "#ffffff";
|
|
1075
|
+
ctx.font = "18px Arial";
|
|
1076
|
+
ctx.textBaseline = "top";
|
|
1077
|
+
ctx.fillText("WhaleTools", 10, 10);
|
|
1078
|
+
ctx.beginPath();
|
|
1079
|
+
ctx.arc(128, 128, 60, 0, Math.PI * 2);
|
|
1080
|
+
ctx.strokeStyle = "#fbbc04";
|
|
1081
|
+
ctx.lineWidth = 3;
|
|
1082
|
+
ctx.stroke();
|
|
1083
|
+
ctx.beginPath();
|
|
1084
|
+
ctx.moveTo(0, 0);
|
|
1085
|
+
ctx.lineTo(256, 256);
|
|
1086
|
+
ctx.strokeStyle = "#ea4335";
|
|
1087
|
+
ctx.lineWidth = 2;
|
|
1088
|
+
ctx.stroke();
|
|
1089
|
+
const dataUrl = canvas.toDataURL();
|
|
1090
|
+
return sha256(dataUrl);
|
|
1091
|
+
} catch {
|
|
1092
|
+
return "";
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
async function getWebGLFingerprint() {
|
|
1096
|
+
try {
|
|
1097
|
+
const canvas = document.createElement("canvas");
|
|
1098
|
+
const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
|
|
1099
|
+
if (!gl || !(gl instanceof WebGLRenderingContext)) return "";
|
|
1100
|
+
const ext = gl.getExtension("WEBGL_debug_renderer_info");
|
|
1101
|
+
const renderer = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : "unknown";
|
|
1102
|
+
const vendor = ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : "unknown";
|
|
1103
|
+
const version = gl.getParameter(gl.VERSION);
|
|
1104
|
+
const combined = `${renderer}|${vendor}|${version}`;
|
|
1105
|
+
return sha256(combined);
|
|
1106
|
+
} catch {
|
|
1107
|
+
return "";
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
async function getAudioFingerprint() {
|
|
1111
|
+
try {
|
|
1112
|
+
const AudioCtx = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
|
1113
|
+
if (!AudioCtx) return "";
|
|
1114
|
+
const context = new AudioCtx(1, 44100, 44100);
|
|
1115
|
+
const oscillator = context.createOscillator();
|
|
1116
|
+
oscillator.type = "triangle";
|
|
1117
|
+
oscillator.frequency.setValueAtTime(1e4, context.currentTime);
|
|
1118
|
+
const compressor = context.createDynamicsCompressor();
|
|
1119
|
+
compressor.threshold.setValueAtTime(-50, context.currentTime);
|
|
1120
|
+
compressor.knee.setValueAtTime(40, context.currentTime);
|
|
1121
|
+
compressor.ratio.setValueAtTime(12, context.currentTime);
|
|
1122
|
+
compressor.attack.setValueAtTime(0, context.currentTime);
|
|
1123
|
+
compressor.release.setValueAtTime(0.25, context.currentTime);
|
|
1124
|
+
oscillator.connect(compressor);
|
|
1125
|
+
compressor.connect(context.destination);
|
|
1126
|
+
oscillator.start(0);
|
|
1127
|
+
const buffer = await context.startRendering();
|
|
1128
|
+
const samples = buffer.getChannelData(0).slice(0, 100);
|
|
1129
|
+
const sampleStr = Array.from(samples).map((s) => s.toString()).join(",");
|
|
1130
|
+
return sha256(sampleStr);
|
|
1131
|
+
} catch {
|
|
1132
|
+
return "";
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
async function collectFingerprint() {
|
|
1136
|
+
const [canvas_fingerprint, webgl_fingerprint, audio_fingerprint] = await Promise.all([
|
|
1137
|
+
getCanvasFingerprint(),
|
|
1138
|
+
getWebGLFingerprint(),
|
|
1139
|
+
getAudioFingerprint()
|
|
1140
|
+
]);
|
|
1141
|
+
const screen_resolution = `${window.screen.width}x${window.screen.height}`;
|
|
1142
|
+
const platform = navigator.platform || "";
|
|
1143
|
+
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
|
|
1144
|
+
const language = navigator.language || "";
|
|
1145
|
+
const hardware_concurrency = navigator.hardwareConcurrency || 0;
|
|
1146
|
+
const device_memory = navigator.deviceMemory ?? null;
|
|
1147
|
+
const color_depth = window.screen.colorDepth;
|
|
1148
|
+
const pixel_ratio = window.devicePixelRatio || 1;
|
|
1149
|
+
const touch_support = "ontouchstart" in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
|
|
1150
|
+
const cookie_enabled = navigator.cookieEnabled;
|
|
1151
|
+
const do_not_track = navigator.doNotTrack ?? null;
|
|
1152
|
+
const fingerprintSource = [
|
|
1153
|
+
canvas_fingerprint,
|
|
1154
|
+
webgl_fingerprint,
|
|
1155
|
+
audio_fingerprint,
|
|
1156
|
+
screen_resolution,
|
|
1157
|
+
platform,
|
|
1158
|
+
timezone,
|
|
1159
|
+
language,
|
|
1160
|
+
String(hardware_concurrency)
|
|
1161
|
+
].join("|");
|
|
1162
|
+
const fingerprint_id = await sha256(fingerprintSource);
|
|
1163
|
+
return {
|
|
1164
|
+
fingerprint_id,
|
|
1165
|
+
canvas_fingerprint,
|
|
1166
|
+
webgl_fingerprint,
|
|
1167
|
+
audio_fingerprint,
|
|
1168
|
+
screen_resolution,
|
|
1169
|
+
platform,
|
|
1170
|
+
timezone,
|
|
1171
|
+
language,
|
|
1172
|
+
hardware_concurrency,
|
|
1173
|
+
device_memory,
|
|
1174
|
+
color_depth,
|
|
1175
|
+
pixel_ratio,
|
|
1176
|
+
touch_support,
|
|
1177
|
+
cookie_enabled,
|
|
1178
|
+
do_not_track
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// src/react/components/fingerprint-collector.tsx
|
|
1183
|
+
var SESSION_KEY_SUFFIX3 = "-analytics-session";
|
|
1184
|
+
function FingerprintCollector() {
|
|
1185
|
+
const ctx = useContext(WhaleContext);
|
|
1186
|
+
const sent = useRef(false);
|
|
1187
|
+
useEffect(() => {
|
|
1188
|
+
if (!ctx || sent.current) return;
|
|
1189
|
+
if (!ctx.config.trackingEnabled) return;
|
|
1190
|
+
if (typeof window === "undefined") return;
|
|
1191
|
+
sent.current = true;
|
|
1192
|
+
const { config, client } = ctx;
|
|
1193
|
+
const prefix = config.storagePrefix;
|
|
1194
|
+
const fpKey = `${prefix}-fingerprint-sent`;
|
|
1195
|
+
const linkFingerprintToSession = (fingerprintId) => {
|
|
1196
|
+
try {
|
|
1197
|
+
const sessionRaw = localStorage.getItem(`${prefix}${SESSION_KEY_SUFFIX3}`);
|
|
1198
|
+
if (sessionRaw) {
|
|
1199
|
+
const session = JSON.parse(sessionRaw);
|
|
1200
|
+
client.updateSession(session.id, { fingerprint_id: fingerprintId }).catch(() => {
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
} catch {
|
|
1204
|
+
}
|
|
1205
|
+
};
|
|
1206
|
+
const existing = localStorage.getItem(fpKey);
|
|
1207
|
+
if (existing) {
|
|
1208
|
+
linkFingerprintToSession(existing);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
collectFingerprint().then(async (fp) => {
|
|
1212
|
+
const baseUrl = config.proxyPath;
|
|
1213
|
+
const url = `${baseUrl}/v1/stores/${config.storeId}/storefront/fingerprints`;
|
|
1214
|
+
await resilientSend(url, fp, {
|
|
1215
|
+
"Content-Type": "application/json",
|
|
1216
|
+
"x-api-key": config.apiKey
|
|
1217
|
+
}).catch(() => {
|
|
1218
|
+
});
|
|
1219
|
+
localStorage.setItem(fpKey, fp.fingerprint_id);
|
|
1220
|
+
linkFingerprintToSession(fp.fingerprint_id);
|
|
1221
|
+
}).catch(() => {
|
|
1222
|
+
});
|
|
1223
|
+
}, [ctx]);
|
|
1224
|
+
return null;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// src/recording/recorder.ts
|
|
1228
|
+
function now() {
|
|
1229
|
+
return Date.now();
|
|
1230
|
+
}
|
|
1231
|
+
function throttle(fn, ms) {
|
|
1232
|
+
let last = 0;
|
|
1233
|
+
let timer = null;
|
|
1234
|
+
const throttled = (...args) => {
|
|
1235
|
+
const elapsed = now() - last;
|
|
1236
|
+
if (elapsed >= ms) {
|
|
1237
|
+
last = now();
|
|
1238
|
+
fn(...args);
|
|
1239
|
+
} else if (!timer) {
|
|
1240
|
+
timer = setTimeout(() => {
|
|
1241
|
+
last = now();
|
|
1242
|
+
timer = null;
|
|
1243
|
+
fn(...args);
|
|
1244
|
+
}, ms - elapsed);
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
return throttled;
|
|
1248
|
+
}
|
|
1249
|
+
function shortSelector(el) {
|
|
1250
|
+
if (el.id) return `#${el.id}`;
|
|
1251
|
+
let sel = el.tagName.toLowerCase();
|
|
1252
|
+
if (el.className && typeof el.className === "string") {
|
|
1253
|
+
const cls = el.className.trim().split(/\s+/).slice(0, 2).join(".");
|
|
1254
|
+
if (cls) sel += `.${cls}`;
|
|
1255
|
+
}
|
|
1256
|
+
return sel;
|
|
1257
|
+
}
|
|
1258
|
+
var SessionRecorder = class {
|
|
1259
|
+
constructor(config) {
|
|
1260
|
+
this.events = [];
|
|
1261
|
+
this.sequence = 0;
|
|
1262
|
+
this.observer = null;
|
|
1263
|
+
this.flushTimer = null;
|
|
1264
|
+
this.listeners = [];
|
|
1265
|
+
this.started = false;
|
|
1266
|
+
this.flushing = false;
|
|
1267
|
+
/** Pending mutations collected within the current animation frame. */
|
|
1268
|
+
this.pendingMutations = [];
|
|
1269
|
+
this.mutationRafId = null;
|
|
1270
|
+
this.config = {
|
|
1271
|
+
sendChunk: config.sendChunk,
|
|
1272
|
+
flushIntervalMs: config.flushIntervalMs ?? 5e3,
|
|
1273
|
+
maxChunkSize: config.maxChunkSize ?? 200
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
// -----------------------------------------------------------------------
|
|
1277
|
+
// Public API
|
|
1278
|
+
// -----------------------------------------------------------------------
|
|
1279
|
+
start() {
|
|
1280
|
+
if (this.started) return;
|
|
1281
|
+
this.started = true;
|
|
1282
|
+
this.captureFullSnapshot();
|
|
1283
|
+
this.setupMutationObserver();
|
|
1284
|
+
this.setupEventListeners();
|
|
1285
|
+
this.flushTimer = setInterval(() => {
|
|
1286
|
+
void this.flush();
|
|
1287
|
+
}, this.config.flushIntervalMs);
|
|
1288
|
+
}
|
|
1289
|
+
stop() {
|
|
1290
|
+
if (!this.started) return;
|
|
1291
|
+
this.started = false;
|
|
1292
|
+
if (this.observer) {
|
|
1293
|
+
this.observer.disconnect();
|
|
1294
|
+
this.observer = null;
|
|
1295
|
+
}
|
|
1296
|
+
if (this.mutationRafId !== null) {
|
|
1297
|
+
cancelAnimationFrame(this.mutationRafId);
|
|
1298
|
+
this.mutationRafId = null;
|
|
1299
|
+
}
|
|
1300
|
+
for (const [target, event, handler, options] of this.listeners) {
|
|
1301
|
+
target.removeEventListener(event, handler, options);
|
|
1302
|
+
}
|
|
1303
|
+
this.listeners = [];
|
|
1304
|
+
if (this.flushTimer !== null) {
|
|
1305
|
+
clearInterval(this.flushTimer);
|
|
1306
|
+
this.flushTimer = null;
|
|
1307
|
+
}
|
|
1308
|
+
void this.flush();
|
|
1309
|
+
}
|
|
1310
|
+
// -----------------------------------------------------------------------
|
|
1311
|
+
// Full Snapshot (type 0)
|
|
1312
|
+
// -----------------------------------------------------------------------
|
|
1313
|
+
captureFullSnapshot() {
|
|
1314
|
+
const tree = this.serializeNode(document.documentElement);
|
|
1315
|
+
this.push({
|
|
1316
|
+
type: 0,
|
|
1317
|
+
timestamp: now(),
|
|
1318
|
+
data: {
|
|
1319
|
+
href: location.href,
|
|
1320
|
+
width: window.innerWidth,
|
|
1321
|
+
height: window.innerHeight,
|
|
1322
|
+
tree: tree ?? {}
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
serializeNode(node) {
|
|
1327
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
1328
|
+
const el = node;
|
|
1329
|
+
const tag = el.tagName.toLowerCase();
|
|
1330
|
+
if (tag === "script" || tag === "noscript") return null;
|
|
1331
|
+
const attrs = {};
|
|
1332
|
+
for (const attr of Array.from(el.attributes)) {
|
|
1333
|
+
if (attr.name === "value" && el instanceof HTMLInputElement && el.type === "password") {
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
attrs[attr.name] = attr.value;
|
|
1337
|
+
}
|
|
1338
|
+
const children = [];
|
|
1339
|
+
for (const child of Array.from(el.childNodes)) {
|
|
1340
|
+
const serialized = this.serializeNode(child);
|
|
1341
|
+
if (serialized) children.push(serialized);
|
|
1342
|
+
}
|
|
1343
|
+
return { tag, attrs, children };
|
|
1344
|
+
}
|
|
1345
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
1346
|
+
const text = node.textContent || "";
|
|
1347
|
+
if (!text.trim()) return null;
|
|
1348
|
+
return { text };
|
|
1349
|
+
}
|
|
1350
|
+
return null;
|
|
1351
|
+
}
|
|
1352
|
+
// -----------------------------------------------------------------------
|
|
1353
|
+
// Mutation Observer (type 1)
|
|
1354
|
+
// -----------------------------------------------------------------------
|
|
1355
|
+
setupMutationObserver() {
|
|
1356
|
+
this.observer = new MutationObserver((mutations) => {
|
|
1357
|
+
this.pendingMutations.push(...mutations);
|
|
1358
|
+
if (this.mutationRafId === null) {
|
|
1359
|
+
this.mutationRafId = requestAnimationFrame(() => {
|
|
1360
|
+
this.processMutations(this.pendingMutations);
|
|
1361
|
+
this.pendingMutations = [];
|
|
1362
|
+
this.mutationRafId = null;
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
});
|
|
1366
|
+
this.observer.observe(document.documentElement, {
|
|
1367
|
+
childList: true,
|
|
1368
|
+
attributes: true,
|
|
1369
|
+
characterData: true,
|
|
1370
|
+
subtree: true,
|
|
1371
|
+
attributeOldValue: false
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
processMutations(mutations) {
|
|
1375
|
+
const ts = now();
|
|
1376
|
+
const adds = [];
|
|
1377
|
+
const removes = [];
|
|
1378
|
+
const attrs = [];
|
|
1379
|
+
const texts = [];
|
|
1380
|
+
for (const m of mutations) {
|
|
1381
|
+
if (m.type === "childList") {
|
|
1382
|
+
for (const node of Array.from(m.addedNodes)) {
|
|
1383
|
+
if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE) {
|
|
1384
|
+
const serialized = this.serializeNode(node);
|
|
1385
|
+
if (serialized) {
|
|
1386
|
+
adds.push({
|
|
1387
|
+
parentSelector: m.target.nodeType === Node.ELEMENT_NODE ? shortSelector(m.target) : null,
|
|
1388
|
+
node: serialized
|
|
1389
|
+
});
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
for (const node of Array.from(m.removedNodes)) {
|
|
1394
|
+
removes.push({
|
|
1395
|
+
parentSelector: m.target.nodeType === Node.ELEMENT_NODE ? shortSelector(m.target) : null,
|
|
1396
|
+
tag: node.nodeType === Node.ELEMENT_NODE ? node.tagName.toLowerCase() : "#text"
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
} else if (m.type === "attributes" && m.target.nodeType === Node.ELEMENT_NODE) {
|
|
1400
|
+
const el = m.target;
|
|
1401
|
+
const name = m.attributeName || "";
|
|
1402
|
+
attrs.push({
|
|
1403
|
+
selector: shortSelector(el),
|
|
1404
|
+
name,
|
|
1405
|
+
value: el.getAttribute(name)
|
|
1406
|
+
});
|
|
1407
|
+
} else if (m.type === "characterData") {
|
|
1408
|
+
texts.push({
|
|
1409
|
+
parentSelector: m.target.parentElement ? shortSelector(m.target.parentElement) : null,
|
|
1410
|
+
value: (m.target.textContent || "").slice(0, 200)
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
if (adds.length || removes.length || attrs.length || texts.length) {
|
|
1415
|
+
this.push({
|
|
1416
|
+
type: 1,
|
|
1417
|
+
timestamp: ts,
|
|
1418
|
+
data: {
|
|
1419
|
+
adds: adds.length ? adds : void 0,
|
|
1420
|
+
removes: removes.length ? removes : void 0,
|
|
1421
|
+
attrs: attrs.length ? attrs : void 0,
|
|
1422
|
+
texts: texts.length ? texts : void 0
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
// -----------------------------------------------------------------------
|
|
1428
|
+
// Event Listeners (types 2–5)
|
|
1429
|
+
// -----------------------------------------------------------------------
|
|
1430
|
+
setupEventListeners() {
|
|
1431
|
+
const onMouseMove = throttle(((e) => {
|
|
1432
|
+
this.push({
|
|
1433
|
+
type: 2,
|
|
1434
|
+
timestamp: now(),
|
|
1435
|
+
data: { source: "move", x: e.clientX, y: e.clientY }
|
|
1436
|
+
});
|
|
1437
|
+
}), 100);
|
|
1438
|
+
const onClick = ((e) => {
|
|
1439
|
+
const target = e.target;
|
|
1440
|
+
this.push({
|
|
1441
|
+
type: 2,
|
|
1442
|
+
timestamp: now(),
|
|
1443
|
+
data: {
|
|
1444
|
+
source: "click",
|
|
1445
|
+
x: e.clientX,
|
|
1446
|
+
y: e.clientY,
|
|
1447
|
+
target: target ? shortSelector(target) : null
|
|
1448
|
+
}
|
|
1449
|
+
});
|
|
1450
|
+
});
|
|
1451
|
+
this.addListener(document, "mousemove", onMouseMove, { passive: true, capture: true });
|
|
1452
|
+
this.addListener(document, "click", onClick, { passive: true, capture: true });
|
|
1453
|
+
const onScroll = throttle(((e) => {
|
|
1454
|
+
const target = e.target;
|
|
1455
|
+
if (target === document || target === document.documentElement || target === window) {
|
|
1456
|
+
this.push({
|
|
1457
|
+
type: 3,
|
|
1458
|
+
timestamp: now(),
|
|
1459
|
+
data: { target: "window", x: window.scrollX, y: window.scrollY }
|
|
1460
|
+
});
|
|
1461
|
+
} else if (target instanceof Element) {
|
|
1462
|
+
this.push({
|
|
1463
|
+
type: 3,
|
|
1464
|
+
timestamp: now(),
|
|
1465
|
+
data: {
|
|
1466
|
+
target: shortSelector(target),
|
|
1467
|
+
x: target.scrollLeft,
|
|
1468
|
+
y: target.scrollTop
|
|
1469
|
+
}
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
}), 200);
|
|
1473
|
+
this.addListener(document, "scroll", onScroll, { passive: true, capture: true });
|
|
1474
|
+
const onInput = ((e) => {
|
|
1475
|
+
const target = e.target;
|
|
1476
|
+
if (!(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement)) {
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
const isPassword = target instanceof HTMLInputElement && target.type === "password";
|
|
1480
|
+
this.push({
|
|
1481
|
+
type: 4,
|
|
1482
|
+
timestamp: now(),
|
|
1483
|
+
data: {
|
|
1484
|
+
selector: shortSelector(target),
|
|
1485
|
+
tag: target.tagName.toLowerCase(),
|
|
1486
|
+
inputType: target instanceof HTMLInputElement ? target.type : void 0,
|
|
1487
|
+
value: isPassword ? "***" : (target.value || "").slice(0, 100)
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
});
|
|
1491
|
+
this.addListener(document, "input", onInput, { passive: true, capture: true });
|
|
1492
|
+
this.addListener(document, "change", onInput, { passive: true, capture: true });
|
|
1493
|
+
const onResize = throttle((() => {
|
|
1494
|
+
this.push({
|
|
1495
|
+
type: 5,
|
|
1496
|
+
timestamp: now(),
|
|
1497
|
+
data: { width: window.innerWidth, height: window.innerHeight }
|
|
1498
|
+
});
|
|
1499
|
+
}), 500);
|
|
1500
|
+
this.addListener(window, "resize", onResize, { passive: true });
|
|
1501
|
+
const onVisibility = (() => {
|
|
1502
|
+
if (document.visibilityState === "hidden") {
|
|
1503
|
+
void this.flush();
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
this.addListener(document, "visibilitychange", onVisibility);
|
|
1507
|
+
}
|
|
1508
|
+
// -----------------------------------------------------------------------
|
|
1509
|
+
// Event buffer & flushing
|
|
1510
|
+
// -----------------------------------------------------------------------
|
|
1511
|
+
push(event) {
|
|
1512
|
+
if (!this.started) return;
|
|
1513
|
+
this.events.push(event);
|
|
1514
|
+
if (this.events.length >= this.config.maxChunkSize) {
|
|
1515
|
+
void this.flush();
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
async flush() {
|
|
1519
|
+
if (this.events.length === 0) return;
|
|
1520
|
+
if (this.flushing) return;
|
|
1521
|
+
this.flushing = true;
|
|
1522
|
+
const chunk = this.events;
|
|
1523
|
+
this.events = [];
|
|
1524
|
+
const seq = this.sequence++;
|
|
1525
|
+
try {
|
|
1526
|
+
await this.config.sendChunk(chunk, seq);
|
|
1527
|
+
} catch {
|
|
1528
|
+
} finally {
|
|
1529
|
+
this.flushing = false;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
// -----------------------------------------------------------------------
|
|
1533
|
+
// Listener bookkeeping
|
|
1534
|
+
// -----------------------------------------------------------------------
|
|
1535
|
+
addListener(target, event, handler, options) {
|
|
1536
|
+
target.addEventListener(event, handler, options);
|
|
1537
|
+
this.listeners.push([target, event, handler, options]);
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
|
|
1541
|
+
// src/react/components/session-recorder.tsx
|
|
1542
|
+
var SESSION_KEY_SUFFIX4 = "-analytics-session";
|
|
1543
|
+
var VISITOR_KEY_SUFFIX3 = "-visitor-id";
|
|
1544
|
+
var MAX_SESSION_WAIT_MS2 = 1e4;
|
|
1545
|
+
var SESSION_POLL_MS2 = 500;
|
|
1546
|
+
function SessionRecorderComponent() {
|
|
1547
|
+
const ctx = useContext(WhaleContext);
|
|
1548
|
+
const recorderRef = useRef(null);
|
|
1549
|
+
const sampledRef = useRef(null);
|
|
1550
|
+
const initRef = useRef(false);
|
|
1551
|
+
useEffect(() => {
|
|
1552
|
+
if (!ctx || initRef.current) return;
|
|
1553
|
+
if (!ctx.config.trackingEnabled) return;
|
|
1554
|
+
if (typeof window === "undefined") return;
|
|
1555
|
+
if (sampledRef.current === null) {
|
|
1556
|
+
sampledRef.current = Math.random() < ctx.config.recordingRate;
|
|
1557
|
+
}
|
|
1558
|
+
if (!sampledRef.current) return;
|
|
1559
|
+
const { config } = ctx;
|
|
1560
|
+
const prefix = config.storagePrefix;
|
|
1561
|
+
let cancelled = false;
|
|
1562
|
+
let pollTimer = null;
|
|
1563
|
+
const startTime = Date.now();
|
|
1564
|
+
const tryInit = () => {
|
|
1565
|
+
if (cancelled) return;
|
|
1566
|
+
let sessionId = null;
|
|
1567
|
+
try {
|
|
1568
|
+
const sessionRaw = localStorage.getItem(`${prefix}${SESSION_KEY_SUFFIX4}`);
|
|
1569
|
+
if (sessionRaw) {
|
|
1570
|
+
const session = JSON.parse(sessionRaw);
|
|
1571
|
+
sessionId = session.id || null;
|
|
1572
|
+
}
|
|
1573
|
+
} catch {
|
|
1574
|
+
}
|
|
1575
|
+
if (!sessionId) {
|
|
1576
|
+
if (Date.now() - startTime < MAX_SESSION_WAIT_MS2) {
|
|
1577
|
+
pollTimer = setTimeout(tryInit, SESSION_POLL_MS2);
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
initRef.current = true;
|
|
1583
|
+
const visitorId = (() => {
|
|
1584
|
+
try {
|
|
1585
|
+
return localStorage.getItem(`${prefix}${VISITOR_KEY_SUFFIX3}`) || "unknown";
|
|
1586
|
+
} catch {
|
|
1587
|
+
return "unknown";
|
|
1588
|
+
}
|
|
1589
|
+
})();
|
|
1590
|
+
const baseUrl = config.proxyPath;
|
|
1591
|
+
const url = `${baseUrl}/v1/stores/${config.storeId}/storefront/recordings`;
|
|
1592
|
+
const sid = sessionId;
|
|
1593
|
+
const recorder = new SessionRecorder({
|
|
1594
|
+
sendChunk: async (events, sequence) => {
|
|
1595
|
+
await resilientSend(url, {
|
|
1596
|
+
session_id: sid,
|
|
1597
|
+
visitor_id: visitorId,
|
|
1598
|
+
events,
|
|
1599
|
+
sequence,
|
|
1600
|
+
started_at: sequence === 0 ? (/* @__PURE__ */ new Date()).toISOString() : void 0
|
|
1601
|
+
}, {
|
|
1602
|
+
"Content-Type": "application/json",
|
|
1603
|
+
"x-api-key": config.apiKey
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
});
|
|
1607
|
+
recorder.start();
|
|
1608
|
+
recorderRef.current = recorder;
|
|
1609
|
+
};
|
|
1610
|
+
tryInit();
|
|
1611
|
+
return () => {
|
|
1612
|
+
cancelled = true;
|
|
1613
|
+
if (pollTimer) clearTimeout(pollTimer);
|
|
1614
|
+
if (recorderRef.current) {
|
|
1615
|
+
recorderRef.current.stop();
|
|
1616
|
+
recorderRef.current = null;
|
|
1617
|
+
}
|
|
1618
|
+
};
|
|
1619
|
+
}, []);
|
|
1620
|
+
return null;
|
|
1621
|
+
}
|
|
560
1622
|
function envBool(name) {
|
|
561
1623
|
if (typeof process === "undefined") return void 0;
|
|
562
1624
|
const raw = process.env[name];
|
|
@@ -615,7 +1677,39 @@ function WhaleProvider({
|
|
|
615
1677
|
gatewayUrl: resolvedConfig.gatewayUrl,
|
|
616
1678
|
proxyPath: resolvedConfig.proxyPath
|
|
617
1679
|
});
|
|
618
|
-
const
|
|
1680
|
+
const readSessionId = () => {
|
|
1681
|
+
try {
|
|
1682
|
+
const raw = localStorage.getItem(`${resolvedConfig.storagePrefix}-analytics-session`);
|
|
1683
|
+
if (!raw) return void 0;
|
|
1684
|
+
const stored = JSON.parse(raw);
|
|
1685
|
+
if (Date.now() - stored.createdAt < resolvedConfig.sessionTtl) return stored.id;
|
|
1686
|
+
} catch {
|
|
1687
|
+
}
|
|
1688
|
+
return void 0;
|
|
1689
|
+
};
|
|
1690
|
+
const syncCartToSession = (cartId, total, itemCount) => {
|
|
1691
|
+
const sid = readSessionId();
|
|
1692
|
+
if (sid && !sid.startsWith("local-")) {
|
|
1693
|
+
client.updateSession(sid, {
|
|
1694
|
+
cart_id: cartId,
|
|
1695
|
+
cart_total: total,
|
|
1696
|
+
cart_item_count: itemCount,
|
|
1697
|
+
status: "carting"
|
|
1698
|
+
}).catch(() => {
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
};
|
|
1702
|
+
const onAddToCart = resolvedConfig.trackingEnabled ? (productId, productName, quantity, price, tier) => {
|
|
1703
|
+
const sid = readSessionId();
|
|
1704
|
+
if (sid) client.trackEvent({ session_id: sid, event_type: "add_to_cart", event_data: { product_id: productId, product_name: productName, quantity, price, tier } }).catch(() => {
|
|
1705
|
+
});
|
|
1706
|
+
} : void 0;
|
|
1707
|
+
const onRemoveFromCart = resolvedConfig.trackingEnabled ? (productId, productName) => {
|
|
1708
|
+
const sid = readSessionId();
|
|
1709
|
+
if (sid) client.trackEvent({ session_id: sid, event_type: "remove_from_cart", event_data: { product_id: productId, product_name: productName } }).catch(() => {
|
|
1710
|
+
});
|
|
1711
|
+
} : void 0;
|
|
1712
|
+
const cartStore = createCartStore(client, resolvedConfig.storagePrefix, onAddToCart, onRemoveFromCart, syncCartToSession);
|
|
619
1713
|
const authStore = createAuthStore(client, resolvedConfig.storagePrefix);
|
|
620
1714
|
return {
|
|
621
1715
|
client,
|
|
@@ -639,6 +1733,9 @@ function WhaleProvider({
|
|
|
639
1733
|
/* @__PURE__ */ jsx(CartInitializer, {}),
|
|
640
1734
|
/* @__PURE__ */ jsx(AnalyticsTracker, { pathname }),
|
|
641
1735
|
/* @__PURE__ */ jsx(PixelInitializer, { onReady: handlePixelReady, onTheme: handleTheme }),
|
|
1736
|
+
/* @__PURE__ */ jsx(BehavioralTrackerComponent, { pathname }),
|
|
1737
|
+
/* @__PURE__ */ jsx(FingerprintCollector, {}),
|
|
1738
|
+
/* @__PURE__ */ jsx(SessionRecorderComponent, {}),
|
|
642
1739
|
children
|
|
643
1740
|
] }) });
|
|
644
1741
|
}
|
|
@@ -1149,9 +2246,9 @@ function useShipping() {
|
|
|
1149
2246
|
}, [ctx.client]);
|
|
1150
2247
|
return { methods, rates, loading, error, refreshMethods, calculateRates };
|
|
1151
2248
|
}
|
|
1152
|
-
function
|
|
2249
|
+
function useDeals() {
|
|
1153
2250
|
const ctx = useContext(WhaleContext);
|
|
1154
|
-
if (!ctx) throw new Error("
|
|
2251
|
+
if (!ctx) throw new Error("useDeals must be used within <WhaleProvider>");
|
|
1155
2252
|
const [validation, setValidation] = useState(null);
|
|
1156
2253
|
const [loading, setLoading] = useState(false);
|
|
1157
2254
|
const [error, setError] = useState(null);
|
|
@@ -1159,7 +2256,7 @@ function useCoupons() {
|
|
|
1159
2256
|
setLoading(true);
|
|
1160
2257
|
setError(null);
|
|
1161
2258
|
try {
|
|
1162
|
-
const result = await ctx.client.
|
|
2259
|
+
const result = await ctx.client.validateDeal(code, cartId ? { cart_id: cartId } : void 0);
|
|
1163
2260
|
setValidation(result);
|
|
1164
2261
|
return result;
|
|
1165
2262
|
} catch (err) {
|
|
@@ -1175,7 +2272,7 @@ function useCoupons() {
|
|
|
1175
2272
|
setLoading(true);
|
|
1176
2273
|
setError(null);
|
|
1177
2274
|
try {
|
|
1178
|
-
const cart = await ctx.client.
|
|
2275
|
+
const cart = await ctx.client.applyDeal(cartId, code);
|
|
1179
2276
|
return cart;
|
|
1180
2277
|
} catch (err) {
|
|
1181
2278
|
const e = err instanceof Error ? err : new Error(String(err));
|
|
@@ -1189,7 +2286,7 @@ function useCoupons() {
|
|
|
1189
2286
|
setLoading(true);
|
|
1190
2287
|
setError(null);
|
|
1191
2288
|
try {
|
|
1192
|
-
const cart = await ctx.client.
|
|
2289
|
+
const cart = await ctx.client.removeDeal(cartId);
|
|
1193
2290
|
setValidation(null);
|
|
1194
2291
|
return cart;
|
|
1195
2292
|
} catch (err) {
|
|
@@ -1206,6 +2303,112 @@ function useCoupons() {
|
|
|
1206
2303
|
}, []);
|
|
1207
2304
|
return { validation, loading, error, validate, apply, remove, clear };
|
|
1208
2305
|
}
|
|
2306
|
+
var useCoupons = useDeals;
|
|
2307
|
+
function useReferral() {
|
|
2308
|
+
const ctx = useContext(WhaleContext);
|
|
2309
|
+
if (!ctx) throw new Error("useReferral must be used within <WhaleProvider>");
|
|
2310
|
+
const customer = useStore(ctx.authStore, (s) => s.customer);
|
|
2311
|
+
const [status, setStatus] = useState(null);
|
|
2312
|
+
const [loading, setLoading] = useState(false);
|
|
2313
|
+
const [error, setError] = useState(null);
|
|
2314
|
+
const refresh = useCallback(async () => {
|
|
2315
|
+
if (!customer?.id) {
|
|
2316
|
+
setStatus(null);
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
setLoading(true);
|
|
2320
|
+
setError(null);
|
|
2321
|
+
try {
|
|
2322
|
+
const result = await ctx.client.getReferralStatus(customer.id);
|
|
2323
|
+
setStatus(result);
|
|
2324
|
+
} catch (err) {
|
|
2325
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2326
|
+
} finally {
|
|
2327
|
+
setLoading(false);
|
|
2328
|
+
}
|
|
2329
|
+
}, [customer?.id, ctx.client]);
|
|
2330
|
+
useEffect(() => {
|
|
2331
|
+
refresh();
|
|
2332
|
+
}, [refresh]);
|
|
2333
|
+
const enroll = useCallback(async () => {
|
|
2334
|
+
if (!customer?.id) throw new Error("Not authenticated");
|
|
2335
|
+
setLoading(true);
|
|
2336
|
+
setError(null);
|
|
2337
|
+
try {
|
|
2338
|
+
const result = await ctx.client.enrollReferral(customer.id);
|
|
2339
|
+
await refresh();
|
|
2340
|
+
return result;
|
|
2341
|
+
} catch (err) {
|
|
2342
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
2343
|
+
setError(e);
|
|
2344
|
+
throw e;
|
|
2345
|
+
} finally {
|
|
2346
|
+
setLoading(false);
|
|
2347
|
+
}
|
|
2348
|
+
}, [customer?.id, ctx.client, refresh]);
|
|
2349
|
+
const attributeReferral = useCallback(
|
|
2350
|
+
async (code) => {
|
|
2351
|
+
if (!customer?.id) throw new Error("Not authenticated");
|
|
2352
|
+
setLoading(true);
|
|
2353
|
+
setError(null);
|
|
2354
|
+
try {
|
|
2355
|
+
const result = await ctx.client.attributeReferral(customer.id, code);
|
|
2356
|
+
await refresh();
|
|
2357
|
+
return result;
|
|
2358
|
+
} catch (err) {
|
|
2359
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
2360
|
+
setError(e);
|
|
2361
|
+
throw e;
|
|
2362
|
+
} finally {
|
|
2363
|
+
setLoading(false);
|
|
2364
|
+
}
|
|
2365
|
+
},
|
|
2366
|
+
[customer?.id, ctx.client, refresh]
|
|
2367
|
+
);
|
|
2368
|
+
useEffect(() => {
|
|
2369
|
+
if (!customer?.id || !status || status.referred_by) return;
|
|
2370
|
+
if (typeof window === "undefined") return;
|
|
2371
|
+
const params = new URLSearchParams(window.location.search);
|
|
2372
|
+
const code = params.get("code") || localStorage.getItem("whale_ref_code");
|
|
2373
|
+
if (!code) return;
|
|
2374
|
+
ctx.client.attributeReferral(customer.id, code).then(() => {
|
|
2375
|
+
localStorage.removeItem("whale_ref_code");
|
|
2376
|
+
refresh();
|
|
2377
|
+
}).catch(() => {
|
|
2378
|
+
});
|
|
2379
|
+
}, [customer?.id, status, ctx.client, refresh]);
|
|
2380
|
+
const share = useCallback(async () => {
|
|
2381
|
+
if (!status?.share_url) throw new Error("Not enrolled in referral program");
|
|
2382
|
+
const shareData = {
|
|
2383
|
+
title: "Check this out!",
|
|
2384
|
+
text: `Use my referral code ${status.referral_code} for rewards!`,
|
|
2385
|
+
url: status.share_url
|
|
2386
|
+
};
|
|
2387
|
+
if (typeof navigator !== "undefined" && navigator.share) {
|
|
2388
|
+
try {
|
|
2389
|
+
await navigator.share(shareData);
|
|
2390
|
+
return;
|
|
2391
|
+
} catch {
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
|
2395
|
+
await navigator.clipboard.writeText(status.share_url);
|
|
2396
|
+
}
|
|
2397
|
+
}, [status]);
|
|
2398
|
+
return {
|
|
2399
|
+
status,
|
|
2400
|
+
loading,
|
|
2401
|
+
error,
|
|
2402
|
+
enroll,
|
|
2403
|
+
refresh,
|
|
2404
|
+
share,
|
|
2405
|
+
attributeReferral,
|
|
2406
|
+
referralCode: status?.referral_code ?? null,
|
|
2407
|
+
shareUrl: status?.share_url ?? null,
|
|
2408
|
+
isEnrolled: status?.enrolled ?? false,
|
|
2409
|
+
referredBy: status?.referred_by ?? null
|
|
2410
|
+
};
|
|
2411
|
+
}
|
|
1209
2412
|
function SectionRenderer({
|
|
1210
2413
|
section,
|
|
1211
2414
|
data,
|
|
@@ -1675,10 +2878,10 @@ function buildDefaultSections(data) {
|
|
|
1675
2878
|
let order = 0;
|
|
1676
2879
|
const productName = qr.landing_page.title || product?.name || qr.name;
|
|
1677
2880
|
const productImage = qr.landing_page.image_url || product?.featured_image || null;
|
|
1678
|
-
const description =
|
|
1679
|
-
const ctaUrl = qr.landing_page.cta_url || qr.destination_url;
|
|
2881
|
+
const description = product?.description || "";
|
|
1680
2882
|
const categoryName = product?.category_name ?? null;
|
|
1681
2883
|
const strainType = toStr(cf?.strain_type);
|
|
2884
|
+
const tagline = toStr(cf?.tagline);
|
|
1682
2885
|
if (productImage) {
|
|
1683
2886
|
sections.push({
|
|
1684
2887
|
id: "auto-hero",
|
|
@@ -1687,9 +2890,7 @@ function buildDefaultSections(data) {
|
|
|
1687
2890
|
content: {
|
|
1688
2891
|
title: productName,
|
|
1689
2892
|
subtitle: [categoryName, strainType].filter(Boolean).join(" \xB7 "),
|
|
1690
|
-
background_image: productImage
|
|
1691
|
-
cta_text: qr.landing_page.cta_text || null,
|
|
1692
|
-
cta_url: ctaUrl
|
|
2893
|
+
background_image: productImage
|
|
1693
2894
|
}
|
|
1694
2895
|
});
|
|
1695
2896
|
} else {
|
|
@@ -1704,6 +2905,15 @@ function buildDefaultSections(data) {
|
|
|
1704
2905
|
config: { align: "center" }
|
|
1705
2906
|
});
|
|
1706
2907
|
}
|
|
2908
|
+
if (tagline) {
|
|
2909
|
+
sections.push({
|
|
2910
|
+
id: "auto-tagline",
|
|
2911
|
+
type: "text",
|
|
2912
|
+
order: order++,
|
|
2913
|
+
content: { body: tagline },
|
|
2914
|
+
config: { align: "center" }
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
1707
2917
|
const thca = toNum(cf?.thca_percentage);
|
|
1708
2918
|
const thc = toNum(cf?.d9_percentage);
|
|
1709
2919
|
const cbd = toNum(cf?.cbd_total);
|
|
@@ -1719,23 +2929,23 @@ function buildDefaultSections(data) {
|
|
|
1719
2929
|
content: { stats }
|
|
1720
2930
|
});
|
|
1721
2931
|
}
|
|
1722
|
-
const
|
|
2932
|
+
const profileDetails = [];
|
|
1723
2933
|
const genetics = toStr(cf?.genetics);
|
|
1724
2934
|
const terpenes = toStr(cf?.terpenes);
|
|
1725
2935
|
const effects = toStr(cf?.effects);
|
|
1726
|
-
const
|
|
1727
|
-
const
|
|
1728
|
-
if (genetics)
|
|
1729
|
-
if (terpenes)
|
|
1730
|
-
if (effects)
|
|
1731
|
-
if (
|
|
1732
|
-
if (
|
|
1733
|
-
if (
|
|
2936
|
+
const flavorProfile = toStr(cf?.flavor_profile);
|
|
2937
|
+
const bestFor = toStr(cf?.best_for);
|
|
2938
|
+
if (genetics) profileDetails.push({ label: "Genetics", value: genetics });
|
|
2939
|
+
if (terpenes) profileDetails.push({ label: "Terpenes", value: terpenes });
|
|
2940
|
+
if (effects) profileDetails.push({ label: "Effects", value: effects });
|
|
2941
|
+
if (flavorProfile) profileDetails.push({ label: "Flavor", value: flavorProfile });
|
|
2942
|
+
if (bestFor) profileDetails.push({ label: "Best For", value: bestFor });
|
|
2943
|
+
if (profileDetails.length > 0) {
|
|
1734
2944
|
sections.push({
|
|
1735
|
-
id: "auto-
|
|
2945
|
+
id: "auto-profile",
|
|
1736
2946
|
type: "stats",
|
|
1737
2947
|
order: order++,
|
|
1738
|
-
content: { stats:
|
|
2948
|
+
content: { stats: profileDetails },
|
|
1739
2949
|
config: { layout: "list" }
|
|
1740
2950
|
});
|
|
1741
2951
|
}
|
|
@@ -1755,16 +2965,34 @@ function buildDefaultSections(data) {
|
|
|
1755
2965
|
content: { button_text: "View Lab Results" }
|
|
1756
2966
|
});
|
|
1757
2967
|
}
|
|
1758
|
-
|
|
2968
|
+
const labDetails = [];
|
|
2969
|
+
const batchNumber = toStr(cf?.batch_number);
|
|
2970
|
+
const dateTested = toStr(cf?.date_tested);
|
|
2971
|
+
if (batchNumber) labDetails.push({ label: "Batch", value: batchNumber });
|
|
2972
|
+
if (dateTested) labDetails.push({ label: "Tested", value: formatDate(dateTested) });
|
|
2973
|
+
if (labDetails.length > 0) {
|
|
1759
2974
|
sections.push({
|
|
1760
|
-
id: "auto-
|
|
1761
|
-
type: "
|
|
2975
|
+
id: "auto-lab-info",
|
|
2976
|
+
type: "stats",
|
|
1762
2977
|
order: order++,
|
|
1763
|
-
content: {
|
|
1764
|
-
|
|
1765
|
-
}
|
|
2978
|
+
content: { stats: labDetails },
|
|
2979
|
+
config: { layout: "list" }
|
|
1766
2980
|
});
|
|
1767
2981
|
}
|
|
2982
|
+
const productSlug = product?.slug;
|
|
2983
|
+
if (productSlug) {
|
|
2984
|
+
const storeDomain = data.store?.name === "Flora Distro" ? "floradistro.com" : null;
|
|
2985
|
+
if (storeDomain) {
|
|
2986
|
+
sections.push({
|
|
2987
|
+
id: "auto-shop",
|
|
2988
|
+
type: "cta",
|
|
2989
|
+
order: order++,
|
|
2990
|
+
content: {
|
|
2991
|
+
buttons: [{ text: "Shop This Product", url: `https://${storeDomain}/shop/${productSlug}`, style: "outline" }]
|
|
2992
|
+
}
|
|
2993
|
+
});
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
1768
2996
|
return sections;
|
|
1769
2997
|
}
|
|
1770
2998
|
function toNum(v) {
|
|
@@ -1943,6 +3171,6 @@ function DefaultError2({ message }) {
|
|
|
1943
3171
|
] }) });
|
|
1944
3172
|
}
|
|
1945
3173
|
|
|
1946
|
-
export { AnalyticsTracker, AuthInitializer, CartInitializer, LandingPage, PixelInitializer, QRLandingPage, SectionRenderer, WhaleContext, WhaleProvider, useAnalytics, useAuth, useCart, useCartItemCount, useCartTotal, useCategories, useCheckout, useCoupons, useCustomerAnalytics, useCustomerOrders, useLocations, useLoyalty, useProduct, useProducts, useRecommendations, useReviews, useSearch, useShipping, useWhaleClient, useWishlist };
|
|
3174
|
+
export { AnalyticsTracker, AuthInitializer, BehavioralTrackerComponent, CartInitializer, FingerprintCollector, LandingPage, PixelInitializer, QRLandingPage, SectionRenderer, SessionRecorderComponent, WhaleContext, WhaleProvider, useAnalytics, useAuth, useCart, useCartItemCount, useCartTotal, useCategories, useCheckout, useCoupons, useCustomerAnalytics, useCustomerOrders, useDeals, useLocations, useLoyalty, useProduct, useProducts, useRecommendations, useReferral, useReviews, useSearch, useShipping, useWhaleClient, useWishlist };
|
|
1947
3175
|
//# sourceMappingURL=index.js.map
|
|
1948
3176
|
//# sourceMappingURL=index.js.map
|