@probat/react 0.1.5 → 0.2.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/dist/index.js CHANGED
@@ -21,6 +21,475 @@ function detectEnvironment() {
21
21
  return "prod";
22
22
  }
23
23
 
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 {};
33
+ }
34
+ }
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 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);
367
+ }
368
+ }
369
+ /**
370
+ * Render heatmap overlay on valid points
371
+ */
372
+ renderHeatmapOverlay(data) {
373
+ const points = data.points;
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
+ }
462
+ }
463
+ /**
464
+ * Convert RGB to RGBA
465
+ */
466
+ rgbToRgba(rgb, opacity) {
467
+ const match = rgb.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
468
+ if (match) {
469
+ return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${opacity})`;
470
+ }
471
+ return rgb;
472
+ }
473
+ };
474
+ var trackerInstance = null;
475
+ function initHeatmapTracking(config) {
476
+ if (trackerInstance) {
477
+ trackerInstance.stop();
478
+ }
479
+ trackerInstance = new HeatmapTracker(config);
480
+ trackerInstance.init();
481
+ return trackerInstance;
482
+ }
483
+ function getHeatmapTracker() {
484
+ return trackerInstance;
485
+ }
486
+ function stopHeatmapTracking() {
487
+ if (trackerInstance) {
488
+ trackerInstance.stop();
489
+ trackerInstance = null;
490
+ }
491
+ }
492
+
24
493
  // src/context/ProbatContext.tsx
25
494
  var ProbatContext = React4.createContext(null);
26
495
  function ProbatProvider({
@@ -28,19 +497,63 @@ function ProbatProvider({
28
497
  clientKey,
29
498
  environment: explicitEnvironment,
30
499
  repoFullName: explicitRepoFullName,
500
+ proposalId,
501
+ variantLabel,
31
502
  children
32
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;
33
506
  const contextValue = React4.useMemo(() => {
34
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";
35
508
  const environment = explicitEnvironment || detectEnvironment();
36
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);
37
521
  return {
38
522
  apiBaseUrl: resolvedApiBaseUrl,
39
523
  environment,
40
524
  clientKey,
41
- repoFullName: resolvedRepoFullName
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();
42
555
  };
43
- }, [apiBaseUrl, clientKey, explicitEnvironment, explicitRepoFullName]);
556
+ }, [contextValue.apiBaseUrl, contextValue.proposalId, contextValue.variantLabel]);
44
557
  return /* @__PURE__ */ React4__default.default.createElement(ProbatContext.Provider, { value: contextValue }, children);
45
558
  }
46
559
  function useProbatContext() {
@@ -74,6 +587,14 @@ async function fetchDecision(baseUrl, proposalId) {
74
587
  const data = await res.json();
75
588
  const experiment_id = (data.experiment_id || `exp_${proposalId}`).toString();
76
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
+ }
77
598
  return { experiment_id, label };
78
599
  } finally {
79
600
  pendingFetches.delete(proposalId);
@@ -152,6 +673,13 @@ async function fetchComponentExperimentConfig(baseUrl, repoFullName, componentPa
152
673
  throw new Error(`HTTP ${res.status}`);
153
674
  }
154
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
+ }
155
683
  return data;
156
684
  } catch (e) {
157
685
  console.warn(`[PROBAT] Failed to fetch component config: ${e}`);
@@ -607,14 +1135,25 @@ function markTrackedVisit(proposalId, label) {
607
1135
 
608
1136
  // src/hooks/useExperiment.ts
609
1137
  function useExperiment(proposalId, options) {
610
- const { apiBaseUrl } = useProbatContext();
1138
+ const context = useProbatContext();
1139
+ const { apiBaseUrl } = context;
611
1140
  const [choice, setChoice] = React4.useState(null);
612
1141
  const [isLoading, setIsLoading] = React4.useState(true);
613
1142
  const [error, setError] = React4.useState(null);
614
1143
  const autoTrackImpression = options?.autoTrackImpression !== false;
615
1144
  React4.useEffect(() => {
616
1145
  let alive = true;
617
- const cached = readChoice(proposalId);
1146
+ const isHeatmapMode = typeof window !== "undefined" && new URLSearchParams(window.location.search).get("heatmap") === "true";
1147
+ if (context.proposalId === proposalId && context.variantLabel) {
1148
+ console.log(`[PROBAT] Forced variant from context: ${context.variantLabel}`);
1149
+ setChoice({
1150
+ experiment_id: `forced_${proposalId}`,
1151
+ label: context.variantLabel
1152
+ });
1153
+ setIsLoading(false);
1154
+ return;
1155
+ }
1156
+ const cached = isHeatmapMode ? null : readChoice(proposalId);
618
1157
  if (cached) {
619
1158
  setChoice({ experiment_id: cached.experiment_id, label: cached.label });
620
1159
  setIsLoading(false);
@@ -627,7 +1166,9 @@ function useExperiment(proposalId, options) {
627
1166
  proposalId
628
1167
  );
629
1168
  if (!alive) return;
630
- writeChoice(proposalId, experiment_id, label);
1169
+ if (!isHeatmapMode) {
1170
+ writeChoice(proposalId, experiment_id, label);
1171
+ }
631
1172
  setChoice({ experiment_id, label });
632
1173
  setError(null);
633
1174
  } catch (e) {
@@ -648,7 +1189,7 @@ function useExperiment(proposalId, options) {
648
1189
  return () => {
649
1190
  alive = false;
650
1191
  };
651
- }, [proposalId, apiBaseUrl]);
1192
+ }, [proposalId, apiBaseUrl, context.proposalId, context.variantLabel]);
652
1193
  React4.useEffect(() => {
653
1194
  if (!autoTrackImpression || !choice) return;
654
1195
  const exp = choice.experiment_id;
@@ -784,32 +1325,70 @@ function withExperiment(Control, options) {
784
1325
  if (!proposalId) return;
785
1326
  if (useNewAPI && configLoading) return;
786
1327
  let alive = true;
787
- const cached = readChoice(proposalId);
788
- if (cached) {
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}`);
789
1331
  setChoice({
1332
+ experiment_id: `forced_${proposalId}`,
1333
+ label: context.variantLabel
1334
+ });
1335
+ return;
1336
+ }
1337
+ const cached = isHeatmapMode ? null : readChoice(proposalId);
1338
+ if (cached) {
1339
+ const choiceData = {
790
1340
  experiment_id: cached.experiment_id,
791
1341
  label: cached.label
792
- });
1342
+ };
1343
+ setChoice(choiceData);
1344
+ if (typeof window !== "undefined" && !isHeatmapMode) {
1345
+ try {
1346
+ window.localStorage.setItem("probat_active_proposal_id", proposalId);
1347
+ window.localStorage.setItem("probat_active_variant_label", cached.label);
1348
+ } catch (e) {
1349
+ console.warn("[PROBAT] Failed to set proposal/variant in localStorage:", e);
1350
+ }
1351
+ }
793
1352
  } else {
794
1353
  (async () => {
795
1354
  try {
796
1355
  const { experiment_id, label: label2 } = await fetchDecision(apiBaseUrl, proposalId);
797
1356
  if (!alive) return;
798
- writeChoice(proposalId, experiment_id, label2);
799
- setChoice({ experiment_id, label: label2 });
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);
800
1370
  } catch (e) {
801
1371
  if (!alive) return;
802
- setChoice({
1372
+ const choiceData = {
803
1373
  experiment_id: `exp_${proposalId}`,
804
1374
  label: "control"
805
- });
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
+ }
806
1385
  }
807
1386
  })();
808
1387
  }
809
1388
  return () => {
810
1389
  alive = false;
811
1390
  };
812
- }, [proposalId, apiBaseUrl, useNewAPI, configLoading]);
1391
+ }, [proposalId, apiBaseUrl, useNewAPI, configLoading, context.proposalId, context.variantLabel]);
813
1392
  React4.useEffect(() => {
814
1393
  if (!proposalId) return;
815
1394
  const lbl = choice?.label ?? "control";
@@ -865,10 +1444,13 @@ exports.ProbatProviderClient = ProbatProviderClient;
865
1444
  exports.detectEnvironment = detectEnvironment;
866
1445
  exports.extractClickMeta = extractClickMeta;
867
1446
  exports.fetchDecision = fetchDecision;
1447
+ exports.getHeatmapTracker = getHeatmapTracker;
868
1448
  exports.hasTrackedVisit = hasTrackedVisit;
1449
+ exports.initHeatmapTracking = initHeatmapTracking;
869
1450
  exports.markTrackedVisit = markTrackedVisit;
870
1451
  exports.readChoice = readChoice;
871
1452
  exports.sendMetric = sendMetric;
1453
+ exports.stopHeatmapTracking = stopHeatmapTracking;
872
1454
  exports.useExperiment = useExperiment;
873
1455
  exports.useProbatContext = useProbatContext;
874
1456
  exports.useProbatMetrics = useProbatMetrics;