@probat/react 0.2.0 → 0.3.0

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