@probat/react 0.2.1 → 0.3.1
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/README.md +33 -344
- package/dist/index.d.mts +76 -247
- package/dist/index.d.ts +76 -247
- package/dist/index.js +395 -1357
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +392 -1341
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -11
- package/src/__tests__/Experiment.test.tsx +764 -0
- package/src/__tests__/setup.ts +63 -0
- package/src/__tests__/utils.test.ts +79 -0
- package/src/components/Experiment.tsx +291 -0
- package/src/components/ProbatProviderClient.tsx +19 -7
- package/src/context/ProbatContext.tsx +30 -152
- package/src/hooks/useProbatMetrics.ts +18 -134
- package/src/index.ts +9 -32
- package/src/utils/api.ts +96 -577
- package/src/utils/dedupeStorage.ts +40 -0
- package/src/utils/eventContext.ts +94 -0
- package/src/utils/stableInstanceId.ts +113 -0
- package/src/utils/storage.ts +18 -60
- package/src/hoc/itrt-frontend.code-workspace +0 -10
- package/src/hoc/withExperiment.tsx +0 -311
- package/src/hooks/useExperiment.ts +0 -188
- package/src/utils/documentClickTracker.ts +0 -215
- package/src/utils/heatmapTracker.ts +0 -665
package/dist/index.js
CHANGED
|
@@ -2,12 +2,44 @@
|
|
|
2
2
|
"use client";
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
|
-
var
|
|
5
|
+
var React3 = require('react');
|
|
6
6
|
|
|
7
|
-
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
8
7
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
9
8
|
|
|
10
|
-
var
|
|
9
|
+
var React3__default = /*#__PURE__*/_interopDefault(React3);
|
|
10
|
+
|
|
11
|
+
var ProbatContext = React3.createContext(null);
|
|
12
|
+
var DEFAULT_HOST = "https://gushi.onrender.com";
|
|
13
|
+
function ProbatProvider({
|
|
14
|
+
userId,
|
|
15
|
+
host = DEFAULT_HOST,
|
|
16
|
+
bootstrap,
|
|
17
|
+
children
|
|
18
|
+
}) {
|
|
19
|
+
const value = React3.useMemo(
|
|
20
|
+
() => ({
|
|
21
|
+
host: host.replace(/\/$/, ""),
|
|
22
|
+
userId,
|
|
23
|
+
bootstrap: bootstrap ?? {}
|
|
24
|
+
}),
|
|
25
|
+
[userId, host, bootstrap]
|
|
26
|
+
);
|
|
27
|
+
return /* @__PURE__ */ React3__default.default.createElement(ProbatContext.Provider, { value }, children);
|
|
28
|
+
}
|
|
29
|
+
function useProbatContext() {
|
|
30
|
+
const ctx = React3.useContext(ProbatContext);
|
|
31
|
+
if (!ctx) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"useProbatContext must be used within <ProbatProviderClient>. Wrap your app with <ProbatProviderClient userId={...}>."
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return ctx;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/components/ProbatProviderClient.tsx
|
|
40
|
+
function ProbatProviderClient(props) {
|
|
41
|
+
return React3__default.default.createElement(ProbatProvider, props);
|
|
42
|
+
}
|
|
11
43
|
|
|
12
44
|
// src/utils/environment.ts
|
|
13
45
|
function detectEnvironment() {
|
|
@@ -21,1440 +53,446 @@ function detectEnvironment() {
|
|
|
21
53
|
return "prod";
|
|
22
54
|
}
|
|
23
55
|
|
|
24
|
-
// src/utils/
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
56
|
+
// src/utils/eventContext.ts
|
|
57
|
+
var DISTINCT_ID_KEY = "probat:distinct_id";
|
|
58
|
+
var SESSION_ID_KEY = "probat:session_id";
|
|
59
|
+
var cachedDistinctId = null;
|
|
60
|
+
var cachedSessionId = null;
|
|
61
|
+
function generateId() {
|
|
62
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
63
|
+
return crypto.randomUUID();
|
|
64
|
+
}
|
|
65
|
+
const bytes = new Uint8Array(16);
|
|
66
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
67
|
+
crypto.getRandomValues(bytes);
|
|
68
|
+
} else {
|
|
69
|
+
for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
33
70
|
}
|
|
71
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
34
72
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
this.handleMouseMove = (event) => {
|
|
44
|
-
if (!this.config.enabled || !this.config.trackCursor) return;
|
|
45
|
-
if (typeof window === "undefined") return;
|
|
46
|
-
const stored = getStoredExperimentInfo();
|
|
47
|
-
const activeProposalId = this.config.proposalId || stored.proposalId;
|
|
48
|
-
const activeVariantLabel = stored.proposalId === activeProposalId ? stored.variantLabel || this.config.variantLabel : this.config.variantLabel || stored.variantLabel;
|
|
49
|
-
const now2 = Date.now();
|
|
50
|
-
const cursorThrottle = this.config.cursorThrottle ?? 100;
|
|
51
|
-
if (now2 - this.lastCursorTime < cursorThrottle) {
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
this.lastCursorTime = now2;
|
|
55
|
-
console.log("[PROBAT Heatmap] Cursor movement captured:", {
|
|
56
|
-
x: event.pageX,
|
|
57
|
-
y: event.pageY,
|
|
58
|
-
movementsInBatch: this.movements.length + 1
|
|
59
|
-
});
|
|
60
|
-
const pageUrl = window.location.href;
|
|
61
|
-
const siteUrl = window.location.origin;
|
|
62
|
-
const x = event.pageX;
|
|
63
|
-
const y = event.pageY;
|
|
64
|
-
const viewportWidth = window.innerWidth;
|
|
65
|
-
const viewportHeight = window.innerHeight;
|
|
66
|
-
const movementEvent = {
|
|
67
|
-
page_url: pageUrl,
|
|
68
|
-
site_url: siteUrl,
|
|
69
|
-
x_coordinate: x,
|
|
70
|
-
y_coordinate: y,
|
|
71
|
-
viewport_width: viewportWidth,
|
|
72
|
-
viewport_height: viewportHeight,
|
|
73
|
-
session_id: this.sessionId,
|
|
74
|
-
proposal_id: activeProposalId,
|
|
75
|
-
variant_label: activeVariantLabel
|
|
76
|
-
};
|
|
77
|
-
this.movements.push(movementEvent);
|
|
78
|
-
const cursorBatchSize = this.config.cursorBatchSize ?? 50;
|
|
79
|
-
if (this.movements.length >= cursorBatchSize) {
|
|
80
|
-
this.sendCursorBatch();
|
|
81
|
-
} else {
|
|
82
|
-
this.scheduleCursorBatchSend();
|
|
83
|
-
}
|
|
84
|
-
};
|
|
85
|
-
this.handleClick = (event) => {
|
|
86
|
-
if (!this.config.enabled) return;
|
|
87
|
-
if (typeof window === "undefined") return;
|
|
88
|
-
const stored = getStoredExperimentInfo();
|
|
89
|
-
const activeProposalId = this.config.proposalId || stored.proposalId;
|
|
90
|
-
const activeVariantLabel = stored.proposalId === activeProposalId ? stored.variantLabel || this.config.variantLabel : this.config.variantLabel || stored.variantLabel;
|
|
91
|
-
const target = event.target;
|
|
92
|
-
if (!target) return;
|
|
93
|
-
if (this.shouldExcludeElement(target)) {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
const pageUrl = window.location.href;
|
|
97
|
-
const siteUrl = window.location.origin;
|
|
98
|
-
const x = event.pageX;
|
|
99
|
-
const y = event.pageY;
|
|
100
|
-
const viewportWidth = window.innerWidth;
|
|
101
|
-
const viewportHeight = window.innerHeight;
|
|
102
|
-
const elementInfo = this.extractElementInfo(target);
|
|
103
|
-
const clickEvent = {
|
|
104
|
-
page_url: pageUrl,
|
|
105
|
-
site_url: siteUrl,
|
|
106
|
-
x_coordinate: x,
|
|
107
|
-
y_coordinate: y,
|
|
108
|
-
viewport_width: viewportWidth,
|
|
109
|
-
viewport_height: viewportHeight,
|
|
110
|
-
element_tag: elementInfo.tag,
|
|
111
|
-
element_class: elementInfo.class,
|
|
112
|
-
element_id: elementInfo.id,
|
|
113
|
-
session_id: this.sessionId,
|
|
114
|
-
proposal_id: activeProposalId,
|
|
115
|
-
variant_label: activeVariantLabel
|
|
116
|
-
};
|
|
117
|
-
this.clicks.push(clickEvent);
|
|
118
|
-
const batchSize = this.config.batchSize ?? 10;
|
|
119
|
-
if (this.clicks.length >= batchSize) {
|
|
120
|
-
this.sendBatch();
|
|
121
|
-
} else {
|
|
122
|
-
this.scheduleBatchSend();
|
|
123
|
-
}
|
|
124
|
-
};
|
|
125
|
-
this.sessionId = this.getOrCreateSessionId();
|
|
126
|
-
const stored = getStoredExperimentInfo();
|
|
127
|
-
this.config = {
|
|
128
|
-
apiBaseUrl: config.apiBaseUrl,
|
|
129
|
-
batchSize: config.batchSize || 10,
|
|
130
|
-
batchInterval: config.batchInterval || 5e3,
|
|
131
|
-
enabled: config.enabled !== false,
|
|
132
|
-
excludeSelectors: config.excludeSelectors || [
|
|
133
|
-
"[data-heatmap-exclude]",
|
|
134
|
-
'input[type="password"]',
|
|
135
|
-
'input[type="email"]',
|
|
136
|
-
"textarea"
|
|
137
|
-
],
|
|
138
|
-
trackCursor: config.trackCursor !== false,
|
|
139
|
-
// Enable cursor tracking by default
|
|
140
|
-
cursorThrottle: config.cursorThrottle || 100,
|
|
141
|
-
// Capture cursor position every 100ms
|
|
142
|
-
cursorBatchSize: config.cursorBatchSize || 50,
|
|
143
|
-
// Send every 50 movements
|
|
144
|
-
proposalId: config.proposalId || stored.proposalId,
|
|
145
|
-
variantLabel: config.variantLabel || stored.variantLabel
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
getOrCreateSessionId() {
|
|
149
|
-
if (typeof window === "undefined") return "";
|
|
150
|
-
const storageKey = "probat_heatmap_session_id";
|
|
151
|
-
let sessionId = localStorage.getItem(storageKey);
|
|
152
|
-
if (!sessionId) {
|
|
153
|
-
sessionId = `heatmap_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
|
154
|
-
localStorage.setItem(storageKey, sessionId);
|
|
155
|
-
}
|
|
156
|
-
return sessionId;
|
|
157
|
-
}
|
|
158
|
-
shouldExcludeElement(element) {
|
|
159
|
-
if (!this.config.excludeSelectors) return false;
|
|
160
|
-
for (const selector of this.config.excludeSelectors) {
|
|
161
|
-
if (element.matches(selector) || element.closest(selector)) {
|
|
162
|
-
return true;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
return false;
|
|
166
|
-
}
|
|
167
|
-
extractElementInfo(element) {
|
|
168
|
-
return {
|
|
169
|
-
tag: element.tagName || null,
|
|
170
|
-
class: element.className && typeof element.className === "string" ? element.className : null,
|
|
171
|
-
id: element.id || null
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
scheduleCursorBatchSend() {
|
|
175
|
-
if (this.cursorBatchTimer) {
|
|
176
|
-
clearTimeout(this.cursorBatchTimer);
|
|
177
|
-
}
|
|
178
|
-
this.cursorBatchTimer = setTimeout(() => {
|
|
179
|
-
if (this.movements.length > 0) {
|
|
180
|
-
this.sendCursorBatch();
|
|
181
|
-
}
|
|
182
|
-
}, this.config.batchInterval ?? 5e3);
|
|
183
|
-
}
|
|
184
|
-
async sendCursorBatch() {
|
|
185
|
-
if (this.movements.length === 0) return;
|
|
186
|
-
const siteUrl = this.movements[0]?.site_url || window.location.origin;
|
|
187
|
-
const batch = {
|
|
188
|
-
movements: this.movements,
|
|
189
|
-
site_url: siteUrl,
|
|
190
|
-
proposal_id: this.config.proposalId,
|
|
191
|
-
variant_label: this.config.variantLabel
|
|
192
|
-
};
|
|
193
|
-
this.movements = [];
|
|
194
|
-
if (this.cursorBatchTimer) {
|
|
195
|
-
clearTimeout(this.cursorBatchTimer);
|
|
196
|
-
this.cursorBatchTimer = null;
|
|
197
|
-
}
|
|
198
|
-
try {
|
|
199
|
-
console.log("[PROBAT Heatmap] Sending cursor movements batch:", {
|
|
200
|
-
count: batch.movements.length,
|
|
201
|
-
site_url: batch.site_url
|
|
202
|
-
});
|
|
203
|
-
const response = await fetch(`${this.config.apiBaseUrl}/api/heatmap/cursor-movements`, {
|
|
204
|
-
method: "POST",
|
|
205
|
-
headers: {
|
|
206
|
-
"Content-Type": "application/json"
|
|
207
|
-
},
|
|
208
|
-
body: JSON.stringify(batch)
|
|
209
|
-
// Don't wait for response - fire and forget for performance
|
|
210
|
-
});
|
|
211
|
-
if (!response.ok) {
|
|
212
|
-
console.warn("[PROBAT Heatmap] Failed to send cursor movements:", response.status);
|
|
213
|
-
} else {
|
|
214
|
-
console.log("[PROBAT Heatmap] Successfully sent cursor movements batch");
|
|
215
|
-
}
|
|
216
|
-
} catch (error) {
|
|
217
|
-
console.warn("[PROBAT Heatmap] Error sending cursor movements:", error);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
scheduleBatchSend() {
|
|
221
|
-
if (this.batchTimer) {
|
|
222
|
-
clearTimeout(this.batchTimer);
|
|
223
|
-
}
|
|
224
|
-
this.batchTimer = setTimeout(() => {
|
|
225
|
-
if (this.clicks.length > 0) {
|
|
226
|
-
this.sendBatch();
|
|
227
|
-
}
|
|
228
|
-
}, this.config.batchInterval ?? 5e3);
|
|
229
|
-
}
|
|
230
|
-
async sendBatch() {
|
|
231
|
-
if (this.clicks.length === 0) return;
|
|
232
|
-
if (this.batchTimer) {
|
|
233
|
-
clearTimeout(this.batchTimer);
|
|
234
|
-
this.batchTimer = null;
|
|
235
|
-
}
|
|
236
|
-
const siteUrl = this.clicks[0]?.site_url || window.location.origin;
|
|
237
|
-
const batch = {
|
|
238
|
-
clicks: [...this.clicks],
|
|
239
|
-
site_url: siteUrl
|
|
240
|
-
};
|
|
241
|
-
this.clicks = [];
|
|
242
|
-
try {
|
|
243
|
-
const response = await fetch(`${this.config.apiBaseUrl}/api/heatmap/clicks`, {
|
|
244
|
-
method: "POST",
|
|
245
|
-
headers: {
|
|
246
|
-
"Content-Type": "application/json"
|
|
247
|
-
},
|
|
248
|
-
body: JSON.stringify(batch)
|
|
249
|
-
});
|
|
250
|
-
if (!response.ok) {
|
|
251
|
-
console.warn("[PROBAT Heatmap] Failed to send clicks:", response.status);
|
|
252
|
-
}
|
|
253
|
-
} catch (error) {
|
|
254
|
-
console.warn("[PROBAT Heatmap] Error sending clicks:", error);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
init() {
|
|
258
|
-
if (this.isInitialized) {
|
|
259
|
-
console.warn("[PROBAT Heatmap] Tracker already initialized");
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
if (typeof window === "undefined") {
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
const params = new URLSearchParams(window.location.search);
|
|
266
|
-
if (params.get("heatmap") === "true") {
|
|
267
|
-
console.log("[PROBAT Heatmap] Heatmap visualization mode detected");
|
|
268
|
-
const pageUrl = params.get("page_url");
|
|
269
|
-
if (pageUrl) {
|
|
270
|
-
this.config.enabled = false;
|
|
271
|
-
this.isInitialized = true;
|
|
272
|
-
this.enableVisualization(pageUrl);
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
document.addEventListener("click", this.handleClick, true);
|
|
277
|
-
if (this.config.trackCursor) {
|
|
278
|
-
document.addEventListener("mousemove", this.handleMouseMove, { passive: true });
|
|
279
|
-
console.log("[PROBAT Heatmap] Cursor tracking enabled");
|
|
280
|
-
} else {
|
|
281
|
-
console.log("[PROBAT Heatmap] Cursor tracking disabled");
|
|
282
|
-
}
|
|
283
|
-
this.isInitialized = true;
|
|
284
|
-
console.log("[PROBAT Heatmap] Tracker initialized");
|
|
285
|
-
window.addEventListener("beforeunload", () => {
|
|
286
|
-
if (this.clicks.length > 0) {
|
|
287
|
-
const siteUrl = this.clicks[0]?.site_url || window.location.origin;
|
|
288
|
-
const batch = {
|
|
289
|
-
clicks: this.clicks,
|
|
290
|
-
site_url: siteUrl
|
|
291
|
-
};
|
|
292
|
-
const blob = new Blob([JSON.stringify(batch)], {
|
|
293
|
-
type: "application/json"
|
|
294
|
-
});
|
|
295
|
-
navigator.sendBeacon(
|
|
296
|
-
`${this.config.apiBaseUrl}/api/heatmap/clicks`,
|
|
297
|
-
blob
|
|
298
|
-
);
|
|
299
|
-
}
|
|
300
|
-
if (this.movements.length > 0) {
|
|
301
|
-
const siteUrl = this.movements[0]?.site_url || window.location.origin;
|
|
302
|
-
const batch = {
|
|
303
|
-
movements: this.movements,
|
|
304
|
-
site_url: siteUrl
|
|
305
|
-
};
|
|
306
|
-
const blob = new Blob([JSON.stringify(batch)], {
|
|
307
|
-
type: "application/json"
|
|
308
|
-
});
|
|
309
|
-
navigator.sendBeacon(
|
|
310
|
-
`${this.config.apiBaseUrl}/api/heatmap/cursor-movements`,
|
|
311
|
-
blob
|
|
312
|
-
);
|
|
313
|
-
}
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
stop() {
|
|
317
|
-
if (!this.isInitialized) return;
|
|
318
|
-
if (this.clicks.length > 0) {
|
|
319
|
-
this.sendBatch();
|
|
320
|
-
}
|
|
321
|
-
if (this.movements.length > 0) {
|
|
322
|
-
this.sendCursorBatch();
|
|
323
|
-
}
|
|
324
|
-
document.removeEventListener("click", this.handleClick, true);
|
|
325
|
-
if (this.config.trackCursor) {
|
|
326
|
-
document.removeEventListener("mousemove", this.handleMouseMove);
|
|
327
|
-
}
|
|
328
|
-
if (this.batchTimer) {
|
|
329
|
-
clearTimeout(this.batchTimer);
|
|
330
|
-
this.batchTimer = null;
|
|
331
|
-
}
|
|
332
|
-
if (this.cursorBatchTimer) {
|
|
333
|
-
clearTimeout(this.cursorBatchTimer);
|
|
334
|
-
this.cursorBatchTimer = null;
|
|
335
|
-
}
|
|
336
|
-
this.isInitialized = false;
|
|
337
|
-
}
|
|
338
|
-
/**
|
|
339
|
-
* Enable visualization mode
|
|
340
|
-
*/
|
|
341
|
-
/**
|
|
342
|
-
* Enable visualization mode
|
|
343
|
-
*/
|
|
344
|
-
async enableVisualization(pageUrl) {
|
|
345
|
-
console.log("[PROBAT Heatmap] Enabling visualization mode for:", pageUrl);
|
|
346
|
-
this.stop();
|
|
347
|
-
this.config.enabled = false;
|
|
348
|
-
try {
|
|
349
|
-
const url = new URL(`${this.config.apiBaseUrl}/api/heatmap/aggregate`);
|
|
350
|
-
url.searchParams.append("site_url", window.location.origin);
|
|
351
|
-
url.searchParams.append("page_url", pageUrl);
|
|
352
|
-
url.searchParams.append("days", "30");
|
|
353
|
-
const params = new URLSearchParams(window.location.search);
|
|
354
|
-
const proposalId = params.get("proposal_id");
|
|
355
|
-
const variantLabel = params.get("variant_label");
|
|
356
|
-
if (proposalId) url.searchParams.append("proposal_id", proposalId);
|
|
357
|
-
if (variantLabel) url.searchParams.append("variant_label", variantLabel);
|
|
358
|
-
const response = await fetch(url.toString());
|
|
359
|
-
if (!response.ok) throw new Error("Failed to fetch heatmap data");
|
|
360
|
-
const data = await response.json();
|
|
361
|
-
if (data && data.points) {
|
|
362
|
-
console.log(`[PROBAT Heatmap] Found ${data.points.length} points. Rendering...`);
|
|
363
|
-
this.renderHeatmapOverlay(data);
|
|
364
|
-
}
|
|
365
|
-
} catch (error) {
|
|
366
|
-
console.error("[PROBAT Heatmap] Visualization error:", error);
|
|
73
|
+
function getDistinctId() {
|
|
74
|
+
if (cachedDistinctId) return cachedDistinctId;
|
|
75
|
+
if (typeof window === "undefined") return "server";
|
|
76
|
+
try {
|
|
77
|
+
const stored = localStorage.getItem(DISTINCT_ID_KEY);
|
|
78
|
+
if (stored) {
|
|
79
|
+
cachedDistinctId = stored;
|
|
80
|
+
return stored;
|
|
367
81
|
}
|
|
82
|
+
} catch {
|
|
368
83
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const trackedWidth = data.viewport_width || 0;
|
|
375
|
-
const existing = document.getElementById("probat-heatmap-overlay");
|
|
376
|
-
if (existing) existing.remove();
|
|
377
|
-
const canvas = document.createElement("canvas");
|
|
378
|
-
canvas.id = "probat-heatmap-overlay";
|
|
379
|
-
canvas.style.position = "absolute";
|
|
380
|
-
canvas.style.top = "0";
|
|
381
|
-
canvas.style.left = "0";
|
|
382
|
-
canvas.style.zIndex = "999999";
|
|
383
|
-
canvas.style.pointerEvents = "none";
|
|
384
|
-
canvas.style.display = "block";
|
|
385
|
-
canvas.style.margin = "0";
|
|
386
|
-
canvas.style.padding = "0";
|
|
387
|
-
document.documentElement.appendChild(canvas);
|
|
388
|
-
const resize = () => {
|
|
389
|
-
const dpr = window.devicePixelRatio || 1;
|
|
390
|
-
const width = document.documentElement.scrollWidth;
|
|
391
|
-
const height = document.documentElement.scrollHeight;
|
|
392
|
-
const currentWidth = window.innerWidth;
|
|
393
|
-
let offsetX = 0;
|
|
394
|
-
if (trackedWidth > 0 && trackedWidth !== currentWidth) {
|
|
395
|
-
offsetX = (currentWidth - trackedWidth) / 2;
|
|
396
|
-
console.log(`[PROBAT Heatmap] Horizontal adjustment: offset=${offsetX}px (Tracked: ${trackedWidth}px, Current: ${currentWidth}px)`);
|
|
397
|
-
}
|
|
398
|
-
canvas.style.width = width + "px";
|
|
399
|
-
canvas.style.height = height + "px";
|
|
400
|
-
canvas.width = width * dpr;
|
|
401
|
-
canvas.height = height * dpr;
|
|
402
|
-
const ctx = canvas.getContext("2d");
|
|
403
|
-
if (ctx) {
|
|
404
|
-
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
405
|
-
this.renderPoints(ctx, points, offsetX);
|
|
406
|
-
}
|
|
407
|
-
};
|
|
408
|
-
window.addEventListener("resize", resize);
|
|
409
|
-
setTimeout(resize, 300);
|
|
410
|
-
setTimeout(resize, 1e3);
|
|
411
|
-
setTimeout(resize, 3e3);
|
|
412
|
-
}
|
|
413
|
-
/**
|
|
414
|
-
* Draw points on canvas
|
|
415
|
-
*/
|
|
416
|
-
renderPoints(ctx, points, offsetX) {
|
|
417
|
-
points.forEach((point) => {
|
|
418
|
-
const x = point.x + offsetX;
|
|
419
|
-
const y = point.y;
|
|
420
|
-
const intensity = point.intensity;
|
|
421
|
-
const radius = 20 + intensity * 12;
|
|
422
|
-
const color = this.getHeatmapColor(intensity);
|
|
423
|
-
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
|
|
424
|
-
gradient.addColorStop(0, this.rgbToRgba(color, 0.8));
|
|
425
|
-
gradient.addColorStop(1, this.rgbToRgba(color, 0));
|
|
426
|
-
ctx.beginPath();
|
|
427
|
-
ctx.arc(x, y, radius, 0, 2 * Math.PI);
|
|
428
|
-
ctx.fillStyle = gradient;
|
|
429
|
-
ctx.fill();
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
/**
|
|
433
|
-
* Get heatmap color based on intensity
|
|
434
|
-
*/
|
|
435
|
-
getHeatmapColor(intensity) {
|
|
436
|
-
const clamped = Math.max(0, Math.min(1, intensity));
|
|
437
|
-
if (clamped < 0.25) {
|
|
438
|
-
const t = clamped / 0.25;
|
|
439
|
-
const r = Math.floor(0 + t * 0);
|
|
440
|
-
const g = Math.floor(0 + t * 255);
|
|
441
|
-
const b = Math.floor(255 + t * (255 - 255));
|
|
442
|
-
return `rgb(${r}, ${g}, ${b})`;
|
|
443
|
-
} else if (clamped < 0.5) {
|
|
444
|
-
const t = (clamped - 0.25) / 0.25;
|
|
445
|
-
const r = Math.floor(0 + t * 0);
|
|
446
|
-
const g = 255;
|
|
447
|
-
const b = Math.floor(255 + t * (0 - 255));
|
|
448
|
-
return `rgb(${r}, ${g}, ${b})`;
|
|
449
|
-
} else if (clamped < 0.75) {
|
|
450
|
-
const t = (clamped - 0.5) / 0.25;
|
|
451
|
-
const r = Math.floor(0 + t * 255);
|
|
452
|
-
const g = 255;
|
|
453
|
-
const b = 0;
|
|
454
|
-
return `rgb(${r}, ${g}, ${b})`;
|
|
455
|
-
} else {
|
|
456
|
-
const t = (clamped - 0.75) / 0.25;
|
|
457
|
-
const r = 255;
|
|
458
|
-
const g = Math.floor(255 + t * (0 - 255));
|
|
459
|
-
const b = 0;
|
|
460
|
-
return `rgb(${r}, ${g}, ${b})`;
|
|
461
|
-
}
|
|
84
|
+
const id = `anon_${generateId()}`;
|
|
85
|
+
cachedDistinctId = id;
|
|
86
|
+
try {
|
|
87
|
+
localStorage.setItem(DISTINCT_ID_KEY, id);
|
|
88
|
+
} catch {
|
|
462
89
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
90
|
+
return id;
|
|
91
|
+
}
|
|
92
|
+
function getSessionId() {
|
|
93
|
+
if (cachedSessionId) return cachedSessionId;
|
|
94
|
+
if (typeof window === "undefined") return "server";
|
|
95
|
+
try {
|
|
96
|
+
const stored = sessionStorage.getItem(SESSION_ID_KEY);
|
|
97
|
+
if (stored) {
|
|
98
|
+
cachedSessionId = stored;
|
|
99
|
+
return stored;
|
|
470
100
|
}
|
|
471
|
-
|
|
101
|
+
} catch {
|
|
472
102
|
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
103
|
+
const id = `sess_${generateId()}`;
|
|
104
|
+
cachedSessionId = id;
|
|
105
|
+
try {
|
|
106
|
+
sessionStorage.setItem(SESSION_ID_KEY, id);
|
|
107
|
+
} catch {
|
|
478
108
|
}
|
|
479
|
-
|
|
480
|
-
trackerInstance.init();
|
|
481
|
-
return trackerInstance;
|
|
482
|
-
}
|
|
483
|
-
function getHeatmapTracker() {
|
|
484
|
-
return trackerInstance;
|
|
109
|
+
return id;
|
|
485
110
|
}
|
|
486
|
-
function
|
|
487
|
-
if (
|
|
488
|
-
|
|
489
|
-
trackerInstance = null;
|
|
490
|
-
}
|
|
111
|
+
function getPageKey() {
|
|
112
|
+
if (typeof window === "undefined") return "";
|
|
113
|
+
return window.location.pathname + window.location.search;
|
|
491
114
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
function ProbatProvider({
|
|
496
|
-
apiBaseUrl,
|
|
497
|
-
clientKey,
|
|
498
|
-
environment: explicitEnvironment,
|
|
499
|
-
repoFullName: explicitRepoFullName,
|
|
500
|
-
proposalId,
|
|
501
|
-
variantLabel,
|
|
502
|
-
children
|
|
503
|
-
}) {
|
|
504
|
-
const storedProposalId = typeof window !== "undefined" ? window.localStorage.getItem("probat_active_proposal_id") || void 0 : void 0;
|
|
505
|
-
const storedVariantLabel = typeof window !== "undefined" ? window.localStorage.getItem("probat_active_variant_label") || void 0 : void 0;
|
|
506
|
-
const contextValue = React4.useMemo(() => {
|
|
507
|
-
const resolvedApiBaseUrl = apiBaseUrl || typeof ({ url: (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.js', document.baseURI).href)) }) !== "undefined" && undefined?.VITE_PROBAT_API || typeof globalThis !== "undefined" && globalThis.process?.env?.NEXT_PUBLIC_PROBAT_API || typeof window !== "undefined" && window.__PROBAT_API || "https://gushi.onrender.com";
|
|
508
|
-
const environment = explicitEnvironment || detectEnvironment();
|
|
509
|
-
const resolvedRepoFullName = explicitRepoFullName || typeof globalThis !== "undefined" && globalThis.process?.env?.NEXT_PUBLIC_PROBAT_REPO || typeof ({ url: (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.js', document.baseURI).href)) }) !== "undefined" && undefined?.VITE_PROBAT_REPO || typeof window !== "undefined" && window.__PROBAT_REPO || void 0;
|
|
510
|
-
const params = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
|
|
511
|
-
const isHeatmapMode = params?.get("heatmap") === "true";
|
|
512
|
-
let urlProposalId;
|
|
513
|
-
let urlVariantLabel;
|
|
514
|
-
if (isHeatmapMode && params) {
|
|
515
|
-
urlProposalId = params.get("proposal_id") || void 0;
|
|
516
|
-
urlVariantLabel = params.get("variant_label") || void 0;
|
|
517
|
-
console.log("[PROBAT] Heatmap mode: Overriding variant from URL", { urlProposalId, urlVariantLabel });
|
|
518
|
-
}
|
|
519
|
-
const finalProposalId = urlProposalId || proposalId || (!isHeatmapMode ? storedProposalId : void 0);
|
|
520
|
-
const finalVariantLabel = urlVariantLabel || variantLabel || (!isHeatmapMode ? storedVariantLabel : void 0);
|
|
521
|
-
return {
|
|
522
|
-
apiBaseUrl: resolvedApiBaseUrl,
|
|
523
|
-
environment,
|
|
524
|
-
clientKey,
|
|
525
|
-
repoFullName: resolvedRepoFullName,
|
|
526
|
-
proposalId: finalProposalId,
|
|
527
|
-
variantLabel: finalVariantLabel
|
|
528
|
-
};
|
|
529
|
-
}, [apiBaseUrl, clientKey, explicitEnvironment, explicitRepoFullName, proposalId, variantLabel, storedProposalId, storedVariantLabel]);
|
|
530
|
-
React4.useEffect(() => {
|
|
531
|
-
if (typeof window !== "undefined") {
|
|
532
|
-
initHeatmapTracking({
|
|
533
|
-
apiBaseUrl: contextValue.apiBaseUrl,
|
|
534
|
-
batchSize: 10,
|
|
535
|
-
batchInterval: 5e3,
|
|
536
|
-
enabled: true,
|
|
537
|
-
excludeSelectors: [
|
|
538
|
-
"[data-heatmap-exclude]",
|
|
539
|
-
'input[type="password"]',
|
|
540
|
-
'input[type="email"]',
|
|
541
|
-
"textarea"
|
|
542
|
-
],
|
|
543
|
-
// Explicitly enable cursor tracking with sensible defaults
|
|
544
|
-
trackCursor: true,
|
|
545
|
-
cursorThrottle: 100,
|
|
546
|
-
// capture every 100ms
|
|
547
|
-
cursorBatchSize: 50,
|
|
548
|
-
// send every 50 movements (or after batchInterval)
|
|
549
|
-
proposalId: contextValue.proposalId,
|
|
550
|
-
variantLabel: contextValue.variantLabel
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
return () => {
|
|
554
|
-
stopHeatmapTracking();
|
|
555
|
-
};
|
|
556
|
-
}, [contextValue.apiBaseUrl, contextValue.proposalId, contextValue.variantLabel]);
|
|
557
|
-
return /* @__PURE__ */ React4__default.default.createElement(ProbatContext.Provider, { value: contextValue }, children);
|
|
115
|
+
function getPageUrl() {
|
|
116
|
+
if (typeof window === "undefined") return "";
|
|
117
|
+
return window.location.href;
|
|
558
118
|
}
|
|
559
|
-
function
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
throw new Error(
|
|
563
|
-
"useProbatContext must be used within a ProbatProvider. Please wrap your app with <ProbatProvider>."
|
|
564
|
-
);
|
|
565
|
-
}
|
|
566
|
-
return context;
|
|
119
|
+
function getReferrer() {
|
|
120
|
+
if (typeof document === "undefined") return "";
|
|
121
|
+
return document.referrer;
|
|
567
122
|
}
|
|
568
|
-
function
|
|
569
|
-
return
|
|
123
|
+
function buildEventContext() {
|
|
124
|
+
return {
|
|
125
|
+
distinct_id: getDistinctId(),
|
|
126
|
+
session_id: getSessionId(),
|
|
127
|
+
$page_url: getPageUrl(),
|
|
128
|
+
$pathname: typeof window !== "undefined" ? window.location.pathname : "",
|
|
129
|
+
$referrer: getReferrer()
|
|
130
|
+
};
|
|
570
131
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
const
|
|
132
|
+
|
|
133
|
+
// src/utils/api.ts
|
|
134
|
+
var pendingDecisions = /* @__PURE__ */ new Map();
|
|
135
|
+
async function fetchDecision(host, experimentId, distinctId) {
|
|
136
|
+
const existing = pendingDecisions.get(experimentId);
|
|
137
|
+
if (existing) return existing;
|
|
138
|
+
const promise = (async () => {
|
|
578
139
|
try {
|
|
579
|
-
const url = `${
|
|
140
|
+
const url = `${host.replace(/\/$/, "")}/experiment/decide`;
|
|
580
141
|
const res = await fetch(url, {
|
|
581
142
|
method: "POST",
|
|
582
|
-
headers: {
|
|
583
|
-
|
|
584
|
-
|
|
143
|
+
headers: {
|
|
144
|
+
"Content-Type": "application/json",
|
|
145
|
+
Accept: "application/json"
|
|
146
|
+
},
|
|
147
|
+
credentials: "include",
|
|
148
|
+
body: JSON.stringify({
|
|
149
|
+
experiment_id: experimentId,
|
|
150
|
+
distinct_id: distinctId
|
|
151
|
+
})
|
|
585
152
|
});
|
|
586
153
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
587
154
|
const data = await res.json();
|
|
588
|
-
|
|
589
|
-
const label = data.label && data.label.trim() ? data.label : "control";
|
|
590
|
-
if (typeof window !== "undefined") {
|
|
591
|
-
try {
|
|
592
|
-
window.localStorage.setItem("probat_active_proposal_id", proposalId);
|
|
593
|
-
window.localStorage.setItem("probat_active_variant_label", label);
|
|
594
|
-
} catch (e) {
|
|
595
|
-
console.warn("[PROBAT] Failed to set proposal/variant in localStorage:", e);
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
return { experiment_id, label };
|
|
155
|
+
return data.variant_key || "control";
|
|
599
156
|
} finally {
|
|
600
|
-
|
|
157
|
+
pendingDecisions.delete(experimentId);
|
|
601
158
|
}
|
|
602
159
|
})();
|
|
603
|
-
|
|
604
|
-
return
|
|
160
|
+
pendingDecisions.set(experimentId, promise);
|
|
161
|
+
return promise;
|
|
605
162
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
captured_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
163
|
+
function sendMetric(host, event, properties) {
|
|
164
|
+
if (typeof window === "undefined") return;
|
|
165
|
+
const ctx = buildEventContext();
|
|
166
|
+
const payload = {
|
|
167
|
+
event,
|
|
168
|
+
properties: {
|
|
169
|
+
...ctx,
|
|
170
|
+
environment: detectEnvironment(),
|
|
171
|
+
source: "react-sdk",
|
|
172
|
+
captured_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
173
|
+
...properties
|
|
174
|
+
}
|
|
619
175
|
};
|
|
620
176
|
try {
|
|
621
|
-
|
|
177
|
+
const url = `${host.replace(/\/$/, "")}/experiment/metrics`;
|
|
178
|
+
fetch(url, {
|
|
622
179
|
method: "POST",
|
|
623
|
-
headers: {
|
|
624
|
-
Accept: "application/json",
|
|
625
|
-
"Content-Type": "application/json"
|
|
626
|
-
},
|
|
180
|
+
headers: { "Content-Type": "application/json" },
|
|
627
181
|
credentials: "include",
|
|
628
|
-
|
|
629
|
-
|
|
182
|
+
body: JSON.stringify(payload)
|
|
183
|
+
}).catch(() => {
|
|
630
184
|
});
|
|
631
185
|
} catch {
|
|
632
186
|
}
|
|
633
187
|
}
|
|
634
|
-
function extractClickMeta(
|
|
635
|
-
if (!
|
|
636
|
-
const
|
|
637
|
-
if (
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
188
|
+
function extractClickMeta(target) {
|
|
189
|
+
if (!target || !(target instanceof HTMLElement)) return null;
|
|
190
|
+
const primary = target.closest('[data-probat-click="primary"]');
|
|
191
|
+
if (primary) return buildMeta(primary, true);
|
|
192
|
+
const interactive = target.closest('button, a, [role="button"]');
|
|
193
|
+
if (interactive) return buildMeta(interactive, false);
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
function buildMeta(el, isPrimary) {
|
|
642
197
|
const meta = {
|
|
643
|
-
|
|
198
|
+
click_target_tag: el.tagName,
|
|
199
|
+
click_is_primary: isPrimary
|
|
644
200
|
};
|
|
645
|
-
if (
|
|
646
|
-
const
|
|
647
|
-
if (
|
|
648
|
-
const text = actionable.textContent?.trim();
|
|
649
|
-
if (text) meta.target_text = text.slice(0, 120);
|
|
201
|
+
if (el.id) meta.click_target_id = el.id;
|
|
202
|
+
const text = el.textContent?.trim();
|
|
203
|
+
if (text) meta.click_target_text = text.slice(0, 120);
|
|
650
204
|
return meta;
|
|
651
205
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
}
|
|
659
|
-
const fetchPromise = (async () => {
|
|
660
|
-
try {
|
|
661
|
-
const url = new URL(`${baseUrl.replace(/\/$/, "")}/get_component_experiment_config`);
|
|
662
|
-
url.searchParams.set("repo_full_name", repoFullName);
|
|
663
|
-
url.searchParams.set("component_path", componentPath);
|
|
664
|
-
const res = await fetch(url.toString(), {
|
|
665
|
-
method: "GET",
|
|
666
|
-
headers: { Accept: "application/json" },
|
|
667
|
-
credentials: "include"
|
|
668
|
-
});
|
|
669
|
-
if (res.status === 404) {
|
|
670
|
-
return null;
|
|
671
|
-
}
|
|
672
|
-
if (!res.ok) {
|
|
673
|
-
throw new Error(`HTTP ${res.status}`);
|
|
674
|
-
}
|
|
675
|
-
const data = await res.json();
|
|
676
|
-
if (typeof window !== "undefined" && data?.proposal_id) {
|
|
677
|
-
try {
|
|
678
|
-
window.localStorage.setItem("probat_active_proposal_id", data.proposal_id);
|
|
679
|
-
} catch (e) {
|
|
680
|
-
console.warn("[PROBAT] Failed to set proposal_id in localStorage:", e);
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
return data;
|
|
684
|
-
} catch (e) {
|
|
685
|
-
console.warn(`[PROBAT] Failed to fetch component config: ${e}`);
|
|
686
|
-
return null;
|
|
687
|
-
} finally {
|
|
688
|
-
componentConfigCache.delete(cacheKey);
|
|
689
|
-
}
|
|
690
|
-
})();
|
|
691
|
-
componentConfigCache.set(cacheKey, fetchPromise);
|
|
692
|
-
return fetchPromise;
|
|
206
|
+
|
|
207
|
+
// src/utils/dedupeStorage.ts
|
|
208
|
+
var PREFIX = "probat:seen:";
|
|
209
|
+
var memorySet = /* @__PURE__ */ new Set();
|
|
210
|
+
function makeDedupeKey(experimentId, variantKey, instanceId, pageKey) {
|
|
211
|
+
return `${PREFIX}${experimentId}:${variantKey}:${instanceId}:${pageKey}`;
|
|
693
212
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
if (typeof window
|
|
697
|
-
|
|
698
|
-
|
|
213
|
+
function hasSeen(key) {
|
|
214
|
+
if (memorySet.has(key)) return true;
|
|
215
|
+
if (typeof window === "undefined") return false;
|
|
216
|
+
try {
|
|
217
|
+
return sessionStorage.getItem(key) === "1";
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
699
221
|
}
|
|
700
|
-
function
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
parts.pop();
|
|
707
|
-
} else if (part !== ".") {
|
|
708
|
-
parts.push(part);
|
|
709
|
-
}
|
|
222
|
+
function markSeen(key) {
|
|
223
|
+
memorySet.add(key);
|
|
224
|
+
if (typeof window === "undefined") return;
|
|
225
|
+
try {
|
|
226
|
+
sessionStorage.setItem(key, "1");
|
|
227
|
+
} catch {
|
|
710
228
|
}
|
|
711
|
-
return "/" + parts.join("/");
|
|
712
229
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
230
|
+
var INSTANCE_PREFIX = "probat:instance:";
|
|
231
|
+
function shortId() {
|
|
232
|
+
const bytes = new Uint8Array(4);
|
|
233
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
234
|
+
crypto.getRandomValues(bytes);
|
|
235
|
+
} else {
|
|
236
|
+
for (let i = 0; i < 4; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
717
237
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
const testPath = absolutePath + (absolutePath.includes(".") ? "" : ext);
|
|
238
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
239
|
+
}
|
|
240
|
+
function resolveStableId(storageKey) {
|
|
241
|
+
if (typeof window !== "undefined") {
|
|
723
242
|
try {
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
headers: { Accept: "text/plain" }
|
|
727
|
-
});
|
|
728
|
-
if (localRes.ok) {
|
|
729
|
-
moduleCode = await localRes.text();
|
|
730
|
-
modulePath = testPath;
|
|
731
|
-
break;
|
|
732
|
-
}
|
|
243
|
+
const stored = sessionStorage.getItem(storageKey);
|
|
244
|
+
if (stored) return stored;
|
|
733
245
|
} catch {
|
|
734
246
|
}
|
|
735
|
-
if (!moduleCode && repoFullName) {
|
|
736
|
-
try {
|
|
737
|
-
const githubPath = testPath.startsWith("/") ? testPath.substring(1) : testPath;
|
|
738
|
-
const githubUrl = `https://raw.githubusercontent.com/${repoFullName}/${baseRef || "main"}/${githubPath}`;
|
|
739
|
-
const res = await fetch(githubUrl, { method: "GET", headers: { Accept: "text/plain" } });
|
|
740
|
-
if (res.ok) {
|
|
741
|
-
moduleCode = await res.text();
|
|
742
|
-
modulePath = testPath;
|
|
743
|
-
break;
|
|
744
|
-
}
|
|
745
|
-
} catch {
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
if (!moduleCode || !modulePath) {
|
|
750
|
-
throw new Error(`Could not resolve module: ${absolutePath} from ${baseFilePath}`);
|
|
751
247
|
}
|
|
752
|
-
|
|
753
|
-
if (typeof window !== "undefined"
|
|
754
|
-
Babel = window.Babel;
|
|
755
|
-
} else {
|
|
248
|
+
const id = `inst_${shortId()}`;
|
|
249
|
+
if (typeof window !== "undefined") {
|
|
756
250
|
try {
|
|
757
|
-
|
|
758
|
-
Babel = babelModule.default || babelModule;
|
|
251
|
+
sessionStorage.setItem(storageKey, id);
|
|
759
252
|
} catch {
|
|
760
|
-
throw new Error("Babel not available for compiling relative import");
|
|
761
253
|
}
|
|
762
254
|
}
|
|
763
|
-
|
|
764
|
-
processedCode = processedCode.replace(/^import\s+['"].*\.css['"];?\s*$/gm, "");
|
|
765
|
-
processedCode = processedCode.replace(/^import\s+React\s+from\s+['"]react['"];?\s*$/m, "const React = window.React || globalThis.React;");
|
|
766
|
-
processedCode = processedCode.replace(/import\.meta\.env\.[\w$]+/g, "undefined");
|
|
767
|
-
processedCode = processedCode.replace(/\bimport\.meta\b/g, "({})");
|
|
768
|
-
const isTSX = modulePath.endsWith(".tsx");
|
|
769
|
-
const compiled = Babel.transform(processedCode, {
|
|
770
|
-
presets: [
|
|
771
|
-
["react", { runtime: "classic" }],
|
|
772
|
-
["typescript", { allExtensions: true, isTSX }]
|
|
773
|
-
],
|
|
774
|
-
plugins: [["transform-modules-commonjs", { allowTopLevelThis: true }]],
|
|
775
|
-
sourceType: "module",
|
|
776
|
-
filename: modulePath
|
|
777
|
-
}).code;
|
|
778
|
-
const moduleCodeWrapper = `
|
|
779
|
-
var require = function(name) {
|
|
780
|
-
if (name === "react" || name === "react/jsx-runtime") {
|
|
781
|
-
return window.React || globalThis.React;
|
|
782
|
-
}
|
|
783
|
-
if (name.startsWith("/@vite") || name.includes("@vite/client")) {
|
|
784
|
-
return {};
|
|
785
|
-
}
|
|
786
|
-
throw new Error("Unsupported module in relative import: " + name);
|
|
787
|
-
};
|
|
788
|
-
var module = { exports: {} };
|
|
789
|
-
var exports = module.exports;
|
|
790
|
-
${compiled}
|
|
791
|
-
return module.exports;
|
|
792
|
-
`;
|
|
793
|
-
const moduleExports = new Function(moduleCodeWrapper)();
|
|
794
|
-
moduleCache.set(cacheKey, moduleExports);
|
|
795
|
-
return moduleExports;
|
|
255
|
+
return id;
|
|
796
256
|
}
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
257
|
+
var slotCounters = /* @__PURE__ */ new Map();
|
|
258
|
+
var resetScheduled = false;
|
|
259
|
+
function claimSlot(groupKey) {
|
|
260
|
+
const idx = slotCounters.get(groupKey) ?? 0;
|
|
261
|
+
slotCounters.set(groupKey, idx + 1);
|
|
262
|
+
if (!resetScheduled) {
|
|
263
|
+
resetScheduled = true;
|
|
264
|
+
Promise.resolve().then(() => {
|
|
265
|
+
slotCounters.clear();
|
|
266
|
+
resetScheduled = false;
|
|
267
|
+
});
|
|
805
268
|
}
|
|
806
|
-
|
|
807
|
-
try {
|
|
808
|
-
let code = "";
|
|
809
|
-
let rawCode = "";
|
|
810
|
-
let rawCodeFetched = false;
|
|
811
|
-
const isBrowser = typeof window !== "undefined";
|
|
812
|
-
const isNextJS = isBrowser && (window.__NEXT_DATA__ !== void 0 || window.__NEXT_LOADED_PAGES__ !== void 0 || typeof globalThis.__NEXT_DATA__ !== "undefined");
|
|
813
|
-
if (isBrowser && !isNextJS) {
|
|
814
|
-
try {
|
|
815
|
-
const variantUrl = `/probat/${filePath}`;
|
|
816
|
-
const dynamicImportFunc = new Function("url", "return import(url)");
|
|
817
|
-
const mod = await dynamicImportFunc(variantUrl);
|
|
818
|
-
const VariantComponent2 = mod?.default || mod;
|
|
819
|
-
if (VariantComponent2 && typeof VariantComponent2 === "function") {
|
|
820
|
-
console.log(`[PROBAT] \u2705 Loaded variant via dynamic import (CSR): ${variantUrl}`);
|
|
821
|
-
return VariantComponent2;
|
|
822
|
-
}
|
|
823
|
-
} catch (dynamicImportError) {
|
|
824
|
-
console.debug(`[PROBAT] Dynamic import failed, using fetch+babel:`, dynamicImportError);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
const localUrl = `/probat/${filePath}`;
|
|
828
|
-
try {
|
|
829
|
-
const localRes = await fetch(localUrl, {
|
|
830
|
-
method: "GET",
|
|
831
|
-
headers: { Accept: "text/plain" }
|
|
832
|
-
});
|
|
833
|
-
if (localRes.ok) {
|
|
834
|
-
rawCode = await localRes.text();
|
|
835
|
-
rawCodeFetched = true;
|
|
836
|
-
console.log(`[PROBAT] \u2705 Loaded variant from local (user's repo): ${localUrl}`);
|
|
837
|
-
}
|
|
838
|
-
} catch {
|
|
839
|
-
console.debug(`[PROBAT] Local file not available (${localUrl}), trying GitHub...`);
|
|
840
|
-
}
|
|
841
|
-
if (!rawCodeFetched && repoFullName) {
|
|
842
|
-
const githubPath = `probat/${filePath}`;
|
|
843
|
-
const gitRef = baseRef || "main";
|
|
844
|
-
const githubUrl = `https://raw.githubusercontent.com/${repoFullName}/${gitRef}/${githubPath}`;
|
|
845
|
-
const res = await fetch(githubUrl, { method: "GET", headers: { Accept: "text/plain" } });
|
|
846
|
-
if (res.ok) {
|
|
847
|
-
rawCode = await res.text();
|
|
848
|
-
rawCodeFetched = true;
|
|
849
|
-
console.log(`[PROBAT] \u26A0\uFE0F Loaded variant from GitHub (fallback): ${githubUrl}`);
|
|
850
|
-
} else {
|
|
851
|
-
console.warn(`[PROBAT] \u26A0\uFE0F GitHub fetch failed (${res.status}), falling back to server compilation`);
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
if (rawCodeFetched && rawCode) {
|
|
855
|
-
let Babel;
|
|
856
|
-
if (typeof window !== "undefined" && window.Babel) {
|
|
857
|
-
Babel = window.Babel;
|
|
858
|
-
} else {
|
|
859
|
-
try {
|
|
860
|
-
const babelModule = await import('@babel/standalone');
|
|
861
|
-
Babel = babelModule.default || babelModule;
|
|
862
|
-
} catch (importError) {
|
|
863
|
-
try {
|
|
864
|
-
await new Promise((resolve, reject) => {
|
|
865
|
-
if (typeof document === "undefined") {
|
|
866
|
-
reject(new Error("Document not available"));
|
|
867
|
-
return;
|
|
868
|
-
}
|
|
869
|
-
if (window.Babel) {
|
|
870
|
-
Babel = window.Babel;
|
|
871
|
-
resolve();
|
|
872
|
-
return;
|
|
873
|
-
}
|
|
874
|
-
const script = document.createElement("script");
|
|
875
|
-
script.src = "https://unpkg.com/@babel/standalone/babel.min.js";
|
|
876
|
-
script.async = true;
|
|
877
|
-
script.onload = () => {
|
|
878
|
-
Babel = window.Babel;
|
|
879
|
-
if (!Babel) reject(new Error("Babel not found after script load"));
|
|
880
|
-
else resolve();
|
|
881
|
-
};
|
|
882
|
-
script.onerror = () => reject(new Error("Failed to load Babel from CDN"));
|
|
883
|
-
document.head.appendChild(script);
|
|
884
|
-
});
|
|
885
|
-
} catch (babelError) {
|
|
886
|
-
console.error("[PROBAT] Failed to load Babel, falling back to server compilation", babelError);
|
|
887
|
-
rawCodeFetched = false;
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
if (rawCodeFetched && rawCode && Babel) {
|
|
892
|
-
const isTSX = filePath.endsWith(".tsx");
|
|
893
|
-
rawCode = rawCode.replace(/^import\s+['"].*\.css['"];?\s*$/gm, "");
|
|
894
|
-
rawCode = rawCode.replace(/^import\s+.*from\s+['"].*\.css['"];?\s*$/gm, "");
|
|
895
|
-
rawCode = rawCode.replace(
|
|
896
|
-
/^import\s+React(?:\s*,\s*\{[^}]*\})?\s+from\s+['"]react['"];?\s*$/m,
|
|
897
|
-
"const React = window.React || globalThis.React;"
|
|
898
|
-
);
|
|
899
|
-
rawCode = rawCode.replace(
|
|
900
|
-
/^import\s+\*\s+as\s+React\s+from\s+['"]react['"];?\s*$/m,
|
|
901
|
-
"const React = window.React || globalThis.React;"
|
|
902
|
-
);
|
|
903
|
-
rawCode = rawCode.replace(
|
|
904
|
-
/^import\s+\{([^}]+)\}\s+from\s+['"]react['"];?\s*$/m,
|
|
905
|
-
(match, imports) => `const {${imports}} = window.React || globalThis.React;`
|
|
906
|
-
);
|
|
907
|
-
rawCode = rawCode.replace(/import\.meta\.env\.[\w$]+/g, "undefined");
|
|
908
|
-
rawCode = rawCode.replace(/\bimport\.meta\b/g, "({})");
|
|
909
|
-
rawCode = rawCode.replace(/^import\s+.*\/@vite\/client.*$/gm, "");
|
|
910
|
-
rawCode = rawCode.replace(/import\.meta\.hot(?:\.[\w$]+)*/g, "undefined");
|
|
911
|
-
const relativeImportMap = /* @__PURE__ */ new Map();
|
|
912
|
-
rawCode = rawCode.replace(
|
|
913
|
-
/^import\s+(\w+)\s+from\s+['"](\.\.?\/[^'"]+)['"];?\s*$/gm,
|
|
914
|
-
(match, importName, relativePath) => {
|
|
915
|
-
const baseDir = filePath.substring(0, filePath.lastIndexOf("/"));
|
|
916
|
-
const resolvedPath = resolveRelativePath(relativePath, baseDir);
|
|
917
|
-
const absolutePath = resolvedPath.startsWith("/") ? resolvedPath : "/" + resolvedPath;
|
|
918
|
-
relativeImportMap.set(importName, absolutePath);
|
|
919
|
-
return `import ${importName} from "${absolutePath}";`;
|
|
920
|
-
}
|
|
921
|
-
);
|
|
922
|
-
const compiled = Babel.transform(rawCode, {
|
|
923
|
-
presets: [
|
|
924
|
-
["react", { runtime: "classic" }],
|
|
925
|
-
["typescript", { allExtensions: true, isTSX }]
|
|
926
|
-
],
|
|
927
|
-
plugins: [["transform-modules-commonjs", { allowTopLevelThis: true }]],
|
|
928
|
-
sourceType: "module",
|
|
929
|
-
filename: filePath
|
|
930
|
-
}).code;
|
|
931
|
-
const relativeModules = {};
|
|
932
|
-
if (relativeImportMap.size > 0) {
|
|
933
|
-
for (const [importName, absolutePath] of relativeImportMap.entries()) {
|
|
934
|
-
try {
|
|
935
|
-
const moduleExports = await loadRelativeModule(absolutePath, filePath, repoFullName, baseRef);
|
|
936
|
-
relativeModules[absolutePath] = moduleExports.default || moduleExports;
|
|
937
|
-
} catch (err) {
|
|
938
|
-
console.warn(`[PROBAT] Failed to load relative import ${absolutePath}:`, err);
|
|
939
|
-
relativeModules[absolutePath] = null;
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
const relativeModulesJson = JSON.stringify(relativeModules);
|
|
944
|
-
code = `
|
|
945
|
-
var __probatVariant = (function() {
|
|
946
|
-
var relativeModules = ${relativeModulesJson};
|
|
947
|
-
var require = function(name) {
|
|
948
|
-
if (name === "react" || name === "react/jsx-runtime") {
|
|
949
|
-
return window.React || globalThis.React;
|
|
950
|
-
}
|
|
951
|
-
if (name.startsWith("/@vite") || name.includes("@vite/client") || name.includes(".vite/deps")) {
|
|
952
|
-
return {};
|
|
953
|
-
}
|
|
954
|
-
if (name === "react/jsx-runtime.js") {
|
|
955
|
-
return window.React || globalThis.React;
|
|
956
|
-
}
|
|
957
|
-
// Handle relative imports (now converted to absolute paths)
|
|
958
|
-
if (name.startsWith("/") && relativeModules.hasOwnProperty(name)) {
|
|
959
|
-
var mod = relativeModules[name];
|
|
960
|
-
if (mod === null) {
|
|
961
|
-
throw new Error("Failed to load module: " + name);
|
|
962
|
-
}
|
|
963
|
-
return mod;
|
|
964
|
-
}
|
|
965
|
-
throw new Error("Unsupported module: " + name);
|
|
966
|
-
};
|
|
967
|
-
var module = { exports: {} };
|
|
968
|
-
var exports = module.exports;
|
|
969
|
-
${compiled}
|
|
970
|
-
return module.exports.default || module.exports;
|
|
971
|
-
})();
|
|
972
|
-
`;
|
|
973
|
-
} else {
|
|
974
|
-
rawCodeFetched = false;
|
|
975
|
-
code = "";
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
if (!rawCodeFetched || code === "") {
|
|
979
|
-
const variantUrl = `${baseUrl.replace(/\/$/, "")}/variants/${filePath}`;
|
|
980
|
-
const serverRes = await fetch(variantUrl, {
|
|
981
|
-
method: "GET",
|
|
982
|
-
headers: { Accept: "text/javascript" },
|
|
983
|
-
credentials: "include"
|
|
984
|
-
});
|
|
985
|
-
if (!serverRes.ok) {
|
|
986
|
-
throw new Error(`HTTP ${serverRes.status}`);
|
|
987
|
-
}
|
|
988
|
-
code = await serverRes.text();
|
|
989
|
-
}
|
|
990
|
-
if (typeof window !== "undefined") {
|
|
991
|
-
window.React = window.React || React4__default.default;
|
|
992
|
-
}
|
|
993
|
-
const evalFunc = new Function(`
|
|
994
|
-
var __probatVariant;
|
|
995
|
-
${code}
|
|
996
|
-
return __probatVariant;
|
|
997
|
-
`);
|
|
998
|
-
const result = evalFunc();
|
|
999
|
-
const VariantComponent = result?.default || result;
|
|
1000
|
-
if (typeof VariantComponent === "function") {
|
|
1001
|
-
return VariantComponent;
|
|
1002
|
-
}
|
|
1003
|
-
console.warn("[PROBAT] Variant component is not a function", result);
|
|
1004
|
-
return null;
|
|
1005
|
-
} catch (e) {
|
|
1006
|
-
console.warn(`[PROBAT] Failed to load variant component: ${e}`);
|
|
1007
|
-
return null;
|
|
1008
|
-
} finally {
|
|
1009
|
-
variantComponentCache.delete(cacheKey);
|
|
1010
|
-
}
|
|
1011
|
-
})();
|
|
1012
|
-
variantComponentCache.set(cacheKey, loadPromise);
|
|
1013
|
-
return loadPromise;
|
|
269
|
+
return idx;
|
|
1014
270
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
(
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
return false;
|
|
1024
|
-
}
|
|
1025
|
-
const proposalId = options?.proposalId;
|
|
1026
|
-
const variantLabel = options?.variantLabel || "control";
|
|
1027
|
-
if (!proposalId) {
|
|
1028
|
-
console.warn(
|
|
1029
|
-
"[Probat] trackClick called without proposalId. Provide it in options or use useExperiment hook."
|
|
1030
|
-
);
|
|
1031
|
-
return false;
|
|
1032
|
-
}
|
|
1033
|
-
void sendMetric(
|
|
1034
|
-
apiBaseUrl,
|
|
1035
|
-
proposalId,
|
|
1036
|
-
"click",
|
|
1037
|
-
variantLabel,
|
|
1038
|
-
void 0,
|
|
1039
|
-
{ ...meta, ...options?.dimensions }
|
|
1040
|
-
);
|
|
1041
|
-
return true;
|
|
1042
|
-
},
|
|
1043
|
-
[apiBaseUrl]
|
|
1044
|
-
);
|
|
1045
|
-
const trackMetric = React4.useCallback(
|
|
1046
|
-
(metricName, proposalId, variantLabel = "control", dimensions = {}) => {
|
|
1047
|
-
void sendMetric(
|
|
1048
|
-
apiBaseUrl,
|
|
1049
|
-
proposalId,
|
|
1050
|
-
metricName,
|
|
1051
|
-
variantLabel,
|
|
1052
|
-
void 0,
|
|
1053
|
-
dimensions
|
|
1054
|
-
);
|
|
1055
|
-
},
|
|
1056
|
-
[apiBaseUrl]
|
|
1057
|
-
);
|
|
1058
|
-
const trackImpression = React4.useCallback(
|
|
1059
|
-
(proposalId, variantLabel = "control", experimentId) => {
|
|
1060
|
-
void sendMetric(
|
|
1061
|
-
apiBaseUrl,
|
|
1062
|
-
proposalId,
|
|
1063
|
-
"visit",
|
|
1064
|
-
variantLabel,
|
|
1065
|
-
experimentId
|
|
1066
|
-
);
|
|
1067
|
-
},
|
|
1068
|
-
[apiBaseUrl]
|
|
1069
|
-
);
|
|
1070
|
-
return {
|
|
1071
|
-
trackClick,
|
|
1072
|
-
trackMetric,
|
|
1073
|
-
trackImpression
|
|
1074
|
-
};
|
|
271
|
+
function useStableInstanceIdV18(experimentId) {
|
|
272
|
+
const reactId = React3__default.default.useId();
|
|
273
|
+
const ref = React3.useRef("");
|
|
274
|
+
if (!ref.current) {
|
|
275
|
+
const key = `${INSTANCE_PREFIX}${experimentId}:${getPageKey()}:${reactId}`;
|
|
276
|
+
ref.current = resolveStableId(key);
|
|
277
|
+
}
|
|
278
|
+
return ref.current;
|
|
1075
279
|
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
const raw = localStorage.getItem(k);
|
|
1082
|
-
return raw ? JSON.parse(raw) : null;
|
|
1083
|
-
} catch {
|
|
1084
|
-
return null;
|
|
280
|
+
function useStableInstanceIdFallback(experimentId) {
|
|
281
|
+
const slotRef = React3.useRef(-1);
|
|
282
|
+
const ref = React3.useRef("");
|
|
283
|
+
if (slotRef.current === -1) {
|
|
284
|
+
slotRef.current = claimSlot(`${experimentId}:${getPageKey()}`);
|
|
1085
285
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
localStorage.setItem(k, JSON.stringify(v));
|
|
1090
|
-
} catch {
|
|
286
|
+
if (!ref.current) {
|
|
287
|
+
const key = `${INSTANCE_PREFIX}${experimentId}:${getPageKey()}:${slotRef.current}`;
|
|
288
|
+
ref.current = resolveStableId(key);
|
|
1091
289
|
}
|
|
290
|
+
return ref.current;
|
|
1092
291
|
}
|
|
1093
|
-
function
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
var KEY = (proposalId) => `probat_choice_v3:${proposalId}`;
|
|
1100
|
-
var VISIT_KEY = (proposalId, label) => `probat_visit_v1:${proposalId}:${label}`;
|
|
1101
|
-
function readChoice(proposalId) {
|
|
1102
|
-
const c = safeGet(KEY(proposalId));
|
|
1103
|
-
return c && fresh(c.ts) ? c : null;
|
|
1104
|
-
}
|
|
1105
|
-
function writeChoice(proposalId, experiment_id, label) {
|
|
1106
|
-
safeSet(KEY(proposalId), { experiment_id, label, ts: now() });
|
|
1107
|
-
}
|
|
1108
|
-
var visitMemo = /* @__PURE__ */ new Set();
|
|
1109
|
-
function hasTrackedVisit(proposalId, label) {
|
|
1110
|
-
const key = VISIT_KEY(proposalId, label);
|
|
1111
|
-
if (visitMemo.has(key)) return true;
|
|
292
|
+
var useStableInstanceId = typeof React3__default.default.useId === "function" ? useStableInstanceIdV18 : useStableInstanceIdFallback;
|
|
293
|
+
|
|
294
|
+
// src/components/Experiment.tsx
|
|
295
|
+
var ASSIGNMENT_PREFIX = "probat:assignment:";
|
|
296
|
+
function readAssignment(id) {
|
|
297
|
+
if (typeof window === "undefined") return null;
|
|
1112
298
|
try {
|
|
1113
|
-
const raw = localStorage.getItem(
|
|
1114
|
-
if (!raw) return
|
|
1115
|
-
const
|
|
1116
|
-
|
|
1117
|
-
if (now() - ts > TTL_MS) {
|
|
1118
|
-
localStorage.removeItem(key);
|
|
1119
|
-
return false;
|
|
1120
|
-
}
|
|
1121
|
-
visitMemo.add(key);
|
|
1122
|
-
return true;
|
|
299
|
+
const raw = localStorage.getItem(ASSIGNMENT_PREFIX + id);
|
|
300
|
+
if (!raw) return null;
|
|
301
|
+
const parsed = JSON.parse(raw);
|
|
302
|
+
return parsed.variantKey ?? null;
|
|
1123
303
|
} catch {
|
|
1124
|
-
return
|
|
304
|
+
return null;
|
|
1125
305
|
}
|
|
1126
306
|
}
|
|
1127
|
-
function
|
|
1128
|
-
|
|
1129
|
-
visitMemo.add(key);
|
|
307
|
+
function writeAssignment(id, variantKey) {
|
|
308
|
+
if (typeof window === "undefined") return;
|
|
1130
309
|
try {
|
|
1131
|
-
|
|
310
|
+
const entry = { variantKey, ts: Date.now() };
|
|
311
|
+
localStorage.setItem(ASSIGNMENT_PREFIX + id, JSON.stringify(entry));
|
|
1132
312
|
} catch {
|
|
1133
313
|
}
|
|
1134
314
|
}
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
315
|
+
function Experiment({
|
|
316
|
+
id,
|
|
317
|
+
control,
|
|
318
|
+
variants,
|
|
319
|
+
track,
|
|
320
|
+
componentInstanceId,
|
|
321
|
+
fallback = "control",
|
|
322
|
+
debug = false
|
|
323
|
+
}) {
|
|
324
|
+
const { host, bootstrap } = useProbatContext();
|
|
325
|
+
const autoInstanceId = useStableInstanceId(id);
|
|
326
|
+
const instanceId = componentInstanceId ?? autoInstanceId;
|
|
327
|
+
const trackImpression = track?.impression !== false;
|
|
328
|
+
const trackClick = track?.primaryClick !== false;
|
|
329
|
+
const impressionEvent = track?.impressionEventName ?? "$experiment_exposure";
|
|
330
|
+
const clickEvent = track?.clickEventName ?? "$experiment_click";
|
|
331
|
+
const [variantKey, setVariantKey] = React3.useState(() => {
|
|
332
|
+
if (bootstrap[id]) return bootstrap[id];
|
|
333
|
+
const cached = readAssignment(id);
|
|
334
|
+
if (cached) return cached;
|
|
335
|
+
return "control";
|
|
336
|
+
});
|
|
337
|
+
const [resolved, setResolved] = React3.useState(() => {
|
|
338
|
+
return !!(bootstrap[id] || readAssignment(id));
|
|
339
|
+
});
|
|
340
|
+
React3.useEffect(() => {
|
|
341
|
+
if (bootstrap[id] || readAssignment(id)) {
|
|
342
|
+
const key = bootstrap[id] ?? readAssignment(id) ?? "control";
|
|
343
|
+
setVariantKey(key);
|
|
344
|
+
setResolved(true);
|
|
1154
345
|
return;
|
|
1155
346
|
}
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
);
|
|
1168
|
-
if (!alive) return;
|
|
1169
|
-
if (!isHeatmapMode) {
|
|
1170
|
-
writeChoice(proposalId, experiment_id, label);
|
|
1171
|
-
}
|
|
1172
|
-
setChoice({ experiment_id, label });
|
|
1173
|
-
setError(null);
|
|
1174
|
-
} catch (e) {
|
|
1175
|
-
if (!alive) return;
|
|
1176
|
-
const err = e instanceof Error ? e : new Error(String(e));
|
|
1177
|
-
setError(err);
|
|
1178
|
-
setChoice({
|
|
1179
|
-
experiment_id: `exp_${proposalId}`,
|
|
1180
|
-
label: "control"
|
|
1181
|
-
});
|
|
1182
|
-
} finally {
|
|
1183
|
-
if (alive) {
|
|
1184
|
-
setIsLoading(false);
|
|
347
|
+
let cancelled = false;
|
|
348
|
+
(async () => {
|
|
349
|
+
try {
|
|
350
|
+
const distinctId = getDistinctId();
|
|
351
|
+
const key = await fetchDecision(host, id, distinctId);
|
|
352
|
+
if (cancelled) return;
|
|
353
|
+
if (key !== "control" && !(key in variants)) {
|
|
354
|
+
if (debug) {
|
|
355
|
+
console.warn(
|
|
356
|
+
`[probat] Unknown variant "${key}" for experiment "${id}", falling back to control`
|
|
357
|
+
);
|
|
1185
358
|
}
|
|
359
|
+
setVariantKey("control");
|
|
360
|
+
} else {
|
|
361
|
+
setVariantKey(key);
|
|
362
|
+
writeAssignment(id, key);
|
|
1186
363
|
}
|
|
1187
|
-
}
|
|
1188
|
-
|
|
364
|
+
} catch (err) {
|
|
365
|
+
if (cancelled) return;
|
|
366
|
+
if (debug) {
|
|
367
|
+
console.error(`[probat] fetchDecision failed for "${id}":`, err);
|
|
368
|
+
}
|
|
369
|
+
if (fallback === "suspend") throw err;
|
|
370
|
+
setVariantKey("control");
|
|
371
|
+
} finally {
|
|
372
|
+
if (!cancelled) setResolved(true);
|
|
373
|
+
}
|
|
374
|
+
})();
|
|
1189
375
|
return () => {
|
|
1190
|
-
|
|
376
|
+
cancelled = true;
|
|
1191
377
|
};
|
|
1192
|
-
}, [
|
|
1193
|
-
|
|
1194
|
-
if (
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
if (!rawTarget) return void 0;
|
|
1209
|
-
const actionable = rawTarget.closest(
|
|
1210
|
-
"[data-probat-conversion='true'], button, a, [role='button']"
|
|
1211
|
-
);
|
|
1212
|
-
if (!actionable) return void 0;
|
|
1213
|
-
const meta2 = {
|
|
1214
|
-
target_tag: actionable.tagName
|
|
1215
|
-
};
|
|
1216
|
-
if (actionable.id) meta2.target_id = actionable.id;
|
|
1217
|
-
const attr = actionable.getAttribute("data-probat-conversion");
|
|
1218
|
-
if (attr) meta2.conversion_attr = attr;
|
|
1219
|
-
const text = actionable.textContent?.trim();
|
|
1220
|
-
if (text) meta2.target_text = text.slice(0, 120);
|
|
1221
|
-
return meta2;
|
|
1222
|
-
})() : void 0;
|
|
1223
|
-
void sendMetric(apiBaseUrl, proposalId, "click", lbl, void 0, meta);
|
|
1224
|
-
},
|
|
1225
|
-
[proposalId, choice?.experiment_id, choice?.label, apiBaseUrl]
|
|
378
|
+
}, [id, host]);
|
|
379
|
+
React3.useEffect(() => {
|
|
380
|
+
if (debug && resolved) {
|
|
381
|
+
console.log(`[probat] Experiment "${id}" \u2192 variant "${variantKey}"`, {
|
|
382
|
+
instanceId,
|
|
383
|
+
pageKey: getPageKey()
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}, [debug, id, variantKey, resolved, instanceId]);
|
|
387
|
+
const eventProps = React3.useMemo(
|
|
388
|
+
() => ({
|
|
389
|
+
experiment_id: id,
|
|
390
|
+
variant_key: variantKey,
|
|
391
|
+
component_instance_id: instanceId
|
|
392
|
+
}),
|
|
393
|
+
[id, variantKey, instanceId]
|
|
1226
394
|
);
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
if (!useNewAPI && !useOldAPI) {
|
|
1247
|
-
console.warn("[PROBAT] withExperiment: Invalid config, returning Control");
|
|
1248
|
-
return Control;
|
|
1249
|
-
}
|
|
1250
|
-
const ControlComponent = Control;
|
|
1251
|
-
function Wrapped(props) {
|
|
1252
|
-
const context = useProbatContext();
|
|
1253
|
-
const apiBaseUrl = context?.apiBaseUrl || "https://gushi.onrender.com";
|
|
1254
|
-
const contextRepoFullName = context?.repoFullName;
|
|
1255
|
-
const [config, setConfig] = React4.useState(null);
|
|
1256
|
-
const [configLoading, setConfigLoading] = React4.useState(useNewAPI);
|
|
1257
|
-
const [choice, setChoice] = React4.useState(null);
|
|
1258
|
-
const repoFullName = options.repoFullName || contextRepoFullName;
|
|
1259
|
-
const proposalId = useNewAPI ? config?.proposalId : options.proposalId;
|
|
1260
|
-
React4.useEffect(() => {
|
|
1261
|
-
if (!useNewAPI) return;
|
|
1262
|
-
if (!repoFullName) {
|
|
1263
|
-
console.warn("[PROBAT] componentPath provided but repoFullName not found");
|
|
1264
|
-
setConfigLoading(false);
|
|
1265
|
-
return;
|
|
1266
|
-
}
|
|
1267
|
-
let alive = true;
|
|
1268
|
-
setConfigLoading(true);
|
|
1269
|
-
(async () => {
|
|
1270
|
-
try {
|
|
1271
|
-
const componentConfig = await fetchComponentExperimentConfig(
|
|
1272
|
-
apiBaseUrl,
|
|
1273
|
-
repoFullName,
|
|
1274
|
-
options.componentPath
|
|
1275
|
-
);
|
|
1276
|
-
if (!alive) return;
|
|
1277
|
-
if (!componentConfig) {
|
|
1278
|
-
setConfig(null);
|
|
1279
|
-
setConfigLoading(false);
|
|
1280
|
-
return;
|
|
1281
|
-
}
|
|
1282
|
-
const variantComponents = {
|
|
1283
|
-
control: ControlComponent
|
|
1284
|
-
};
|
|
1285
|
-
for (const [label2, variantInfo] of Object.entries(componentConfig.variants)) {
|
|
1286
|
-
if (label2 === "control") continue;
|
|
1287
|
-
if (variantInfo?.file_path) {
|
|
1288
|
-
try {
|
|
1289
|
-
const VariantComp = await loadVariantComponent(
|
|
1290
|
-
apiBaseUrl,
|
|
1291
|
-
componentConfig.proposal_id,
|
|
1292
|
-
variantInfo.experiment_id,
|
|
1293
|
-
variantInfo.file_path,
|
|
1294
|
-
componentConfig.repo_full_name,
|
|
1295
|
-
componentConfig.base_ref
|
|
1296
|
-
);
|
|
1297
|
-
if (VariantComp && typeof VariantComp === "function" && alive) {
|
|
1298
|
-
variantComponents[label2] = VariantComp;
|
|
1299
|
-
}
|
|
1300
|
-
} catch (e) {
|
|
1301
|
-
console.warn(`[PROBAT] Failed to load variant ${label2}:`, e);
|
|
1302
|
-
}
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
if (alive) {
|
|
1306
|
-
setConfig({
|
|
1307
|
-
proposalId: componentConfig.proposal_id,
|
|
1308
|
-
variants: variantComponents
|
|
1309
|
-
});
|
|
1310
|
-
setConfigLoading(false);
|
|
1311
|
-
}
|
|
1312
|
-
} catch (e) {
|
|
1313
|
-
console.warn("[PROBAT] Failed to load component config:", e);
|
|
1314
|
-
if (alive) {
|
|
1315
|
-
setConfig(null);
|
|
1316
|
-
setConfigLoading(false);
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
})();
|
|
1320
|
-
return () => {
|
|
1321
|
-
alive = false;
|
|
1322
|
-
};
|
|
1323
|
-
}, [useNewAPI, options.componentPath, repoFullName, apiBaseUrl]);
|
|
1324
|
-
React4.useEffect(() => {
|
|
1325
|
-
if (!proposalId) return;
|
|
1326
|
-
if (useNewAPI && configLoading) return;
|
|
1327
|
-
let alive = true;
|
|
1328
|
-
const isHeatmapMode = typeof window !== "undefined" && new URLSearchParams(window.location.search).get("heatmap") === "true";
|
|
1329
|
-
if (context.proposalId === proposalId && context.variantLabel) {
|
|
1330
|
-
console.log(`[PROBAT HOC] Forced variant from context: ${context.variantLabel}`);
|
|
1331
|
-
setChoice({
|
|
1332
|
-
experiment_id: `forced_${proposalId}`,
|
|
1333
|
-
label: context.variantLabel
|
|
1334
|
-
});
|
|
1335
|
-
return;
|
|
395
|
+
const containerRef = React3.useRef(null);
|
|
396
|
+
const impressionSent = React3.useRef(false);
|
|
397
|
+
React3.useEffect(() => {
|
|
398
|
+
if (!trackImpression || !resolved) return;
|
|
399
|
+
impressionSent.current = false;
|
|
400
|
+
const pageKey = getPageKey();
|
|
401
|
+
const dedupeKey = makeDedupeKey(id, variantKey, instanceId, pageKey);
|
|
402
|
+
if (hasSeen(dedupeKey)) {
|
|
403
|
+
impressionSent.current = true;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const el = containerRef.current;
|
|
407
|
+
if (!el) return;
|
|
408
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
409
|
+
if (!impressionSent.current) {
|
|
410
|
+
impressionSent.current = true;
|
|
411
|
+
markSeen(dedupeKey);
|
|
412
|
+
sendMetric(host, impressionEvent, eventProps);
|
|
413
|
+
if (debug) console.log(`[probat] Impression sent (no IO) for "${id}"`);
|
|
1336
414
|
}
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
console.
|
|
1350
|
-
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
let timer = null;
|
|
418
|
+
const observer = new IntersectionObserver(
|
|
419
|
+
([entry]) => {
|
|
420
|
+
if (!entry || impressionSent.current) return;
|
|
421
|
+
if (entry.isIntersecting) {
|
|
422
|
+
timer = setTimeout(() => {
|
|
423
|
+
if (impressionSent.current) return;
|
|
424
|
+
impressionSent.current = true;
|
|
425
|
+
markSeen(dedupeKey);
|
|
426
|
+
sendMetric(host, impressionEvent, eventProps);
|
|
427
|
+
if (debug) console.log(`[probat] Impression sent for "${id}"`);
|
|
428
|
+
observer.disconnect();
|
|
429
|
+
}, 250);
|
|
430
|
+
} else if (timer) {
|
|
431
|
+
clearTimeout(timer);
|
|
432
|
+
timer = null;
|
|
1351
433
|
}
|
|
1352
|
-
} else {
|
|
1353
|
-
(async () => {
|
|
1354
|
-
try {
|
|
1355
|
-
const { experiment_id, label: label2 } = await fetchDecision(apiBaseUrl, proposalId);
|
|
1356
|
-
if (!alive) return;
|
|
1357
|
-
if (!isHeatmapMode) {
|
|
1358
|
-
writeChoice(proposalId, experiment_id, label2);
|
|
1359
|
-
if (typeof window !== "undefined") {
|
|
1360
|
-
try {
|
|
1361
|
-
window.localStorage.setItem("probat_active_proposal_id", proposalId);
|
|
1362
|
-
window.localStorage.setItem("probat_active_variant_label", label2);
|
|
1363
|
-
} catch (e) {
|
|
1364
|
-
console.warn("[PROBAT] Failed to set proposal/variant in localStorage:", e);
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
}
|
|
1368
|
-
const choiceData = { experiment_id, label: label2 };
|
|
1369
|
-
setChoice(choiceData);
|
|
1370
|
-
} catch (e) {
|
|
1371
|
-
if (!alive) return;
|
|
1372
|
-
const choiceData = {
|
|
1373
|
-
experiment_id: `exp_${proposalId}`,
|
|
1374
|
-
label: "control"
|
|
1375
|
-
};
|
|
1376
|
-
setChoice(choiceData);
|
|
1377
|
-
if (typeof window !== "undefined" && !isHeatmapMode) {
|
|
1378
|
-
try {
|
|
1379
|
-
window.localStorage.setItem("probat_active_proposal_id", proposalId);
|
|
1380
|
-
window.localStorage.setItem("probat_active_variant_label", "control");
|
|
1381
|
-
} catch (err) {
|
|
1382
|
-
console.warn("[PROBAT] Failed to set proposal/variant in localStorage:", err);
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
}
|
|
1386
|
-
})();
|
|
1387
|
-
}
|
|
1388
|
-
return () => {
|
|
1389
|
-
alive = false;
|
|
1390
|
-
};
|
|
1391
|
-
}, [proposalId, apiBaseUrl, useNewAPI, configLoading, context.proposalId, context.variantLabel]);
|
|
1392
|
-
React4.useEffect(() => {
|
|
1393
|
-
if (!proposalId) return;
|
|
1394
|
-
const lbl = choice?.label ?? "control";
|
|
1395
|
-
if (!lbl || hasTrackedVisit(proposalId, lbl)) return;
|
|
1396
|
-
markTrackedVisit(proposalId, lbl);
|
|
1397
|
-
void sendMetric(apiBaseUrl, proposalId, "visit", lbl, choice?.experiment_id);
|
|
1398
|
-
}, [proposalId, choice?.experiment_id, choice?.label, apiBaseUrl]);
|
|
1399
|
-
const trackClick = React4.useCallback(
|
|
1400
|
-
(event, opts) => {
|
|
1401
|
-
if (!proposalId) return false;
|
|
1402
|
-
const lbl = choice?.label ?? "control";
|
|
1403
|
-
const meta = extractClickMeta(event ?? void 0);
|
|
1404
|
-
if (!opts?.force && event && !meta) return false;
|
|
1405
|
-
void sendMetric(apiBaseUrl, proposalId, "click", lbl, void 0, meta);
|
|
1406
|
-
return true;
|
|
1407
434
|
},
|
|
1408
|
-
|
|
435
|
+
{ threshold: 0.5 }
|
|
1409
436
|
);
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
437
|
+
observer.observe(el);
|
|
438
|
+
return () => {
|
|
439
|
+
observer.disconnect();
|
|
440
|
+
if (timer) clearTimeout(timer);
|
|
441
|
+
};
|
|
442
|
+
}, [
|
|
443
|
+
trackImpression,
|
|
444
|
+
resolved,
|
|
445
|
+
id,
|
|
446
|
+
variantKey,
|
|
447
|
+
instanceId,
|
|
448
|
+
host,
|
|
449
|
+
impressionEvent,
|
|
450
|
+
eventProps,
|
|
451
|
+
debug
|
|
452
|
+
]);
|
|
453
|
+
const handleClick = React3.useCallback(
|
|
454
|
+
(e) => {
|
|
455
|
+
if (!trackClick) return;
|
|
456
|
+
const meta = extractClickMeta(e.target);
|
|
457
|
+
if (!meta) return;
|
|
458
|
+
sendMetric(host, clickEvent, {
|
|
459
|
+
...eventProps,
|
|
460
|
+
...meta
|
|
461
|
+
});
|
|
462
|
+
if (debug) {
|
|
463
|
+
console.log(`[probat] Click tracked for "${id}"`, meta);
|
|
1428
464
|
}
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
465
|
+
},
|
|
466
|
+
[trackClick, host, clickEvent, eventProps, id, debug]
|
|
467
|
+
);
|
|
468
|
+
const content = variantKey === "control" || !(variantKey in variants) ? control : variants[variantKey];
|
|
469
|
+
return /* @__PURE__ */ React3__default.default.createElement(
|
|
470
|
+
"div",
|
|
471
|
+
{
|
|
472
|
+
ref: containerRef,
|
|
473
|
+
onClick: handleClick,
|
|
474
|
+
"data-probat-experiment": id,
|
|
475
|
+
"data-probat-variant": variantKey,
|
|
476
|
+
style: { display: "block", margin: 0, padding: 0 }
|
|
477
|
+
},
|
|
478
|
+
content
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
function useProbatMetrics() {
|
|
482
|
+
const { host } = useProbatContext();
|
|
483
|
+
const capture = React3.useCallback(
|
|
484
|
+
(event, properties = {}) => {
|
|
485
|
+
sendMetric(host, event, properties);
|
|
486
|
+
},
|
|
487
|
+
[host]
|
|
488
|
+
);
|
|
489
|
+
return { capture };
|
|
1440
490
|
}
|
|
1441
491
|
|
|
1442
|
-
exports.
|
|
492
|
+
exports.Experiment = Experiment;
|
|
1443
493
|
exports.ProbatProviderClient = ProbatProviderClient;
|
|
1444
|
-
exports.detectEnvironment = detectEnvironment;
|
|
1445
|
-
exports.extractClickMeta = extractClickMeta;
|
|
1446
494
|
exports.fetchDecision = fetchDecision;
|
|
1447
|
-
exports.getHeatmapTracker = getHeatmapTracker;
|
|
1448
|
-
exports.hasTrackedVisit = hasTrackedVisit;
|
|
1449
|
-
exports.initHeatmapTracking = initHeatmapTracking;
|
|
1450
|
-
exports.markTrackedVisit = markTrackedVisit;
|
|
1451
|
-
exports.readChoice = readChoice;
|
|
1452
495
|
exports.sendMetric = sendMetric;
|
|
1453
|
-
exports.stopHeatmapTracking = stopHeatmapTracking;
|
|
1454
|
-
exports.useExperiment = useExperiment;
|
|
1455
|
-
exports.useProbatContext = useProbatContext;
|
|
1456
496
|
exports.useProbatMetrics = useProbatMetrics;
|
|
1457
|
-
exports.withExperiment = withExperiment;
|
|
1458
|
-
exports.writeChoice = writeChoice;
|
|
1459
497
|
//# sourceMappingURL=index.js.map
|
|
1460
498
|
//# sourceMappingURL=index.js.map
|