@probat/react 0.2.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@probat/react",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "React library for Probat A/B testing and experimentation",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -23,7 +23,8 @@
23
23
  "build": "tsup && npm run postbuild",
24
24
  "postbuild": "echo '\"use client\";' | cat - dist/index.mjs > temp && mv temp dist/index.mjs && echo '\"use client\";' | cat - dist/index.js > temp && mv temp dist/index.js",
25
25
  "dev": "tsup --watch",
26
- "typecheck": "tsc --noEmit"
26
+ "typecheck": "tsc --noEmit",
27
+ "prepublishOnly": "npm run build"
27
28
  },
28
29
  "keywords": [
29
30
  "probat",
@@ -102,13 +102,33 @@ export function ProbatProvider({
102
102
  (typeof window !== "undefined" && (window as any).__PROBAT_REPO) ||
103
103
  undefined;
104
104
 
105
+ // Check for URL overrides (used for Live Heatmap visualization)
106
+ const params = (typeof window !== "undefined") ? new URLSearchParams(window.location.search) : null;
107
+ const isHeatmapMode = params?.get('heatmap') === 'true';
108
+
109
+ let urlProposalId: string | undefined;
110
+ let urlVariantLabel: string | undefined;
111
+
112
+ if (isHeatmapMode && params) {
113
+ urlProposalId = params.get('proposal_id') || undefined;
114
+ urlVariantLabel = params.get('variant_label') || undefined;
115
+ console.log('[PROBAT] Heatmap mode: Overriding variant from URL', { urlProposalId, urlVariantLabel });
116
+ }
117
+
118
+ // Priority Logic:
119
+ // 1. URL params (if in heatmap mode)
120
+ // 2. Explicit props passed to Provider
121
+ // 3. Stored values in localStorage (ONLY if NOT in heatmap mode)
122
+ const finalProposalId = urlProposalId || proposalId || (!isHeatmapMode ? storedProposalId : undefined);
123
+ const finalVariantLabel = urlVariantLabel || variantLabel || (!isHeatmapMode ? storedVariantLabel : undefined);
124
+
105
125
  return {
106
126
  apiBaseUrl: resolvedApiBaseUrl,
107
127
  environment,
108
128
  clientKey,
109
129
  repoFullName: resolvedRepoFullName,
110
- proposalId: proposalId || storedProposalId,
111
- variantLabel: variantLabel || storedVariantLabel,
130
+ proposalId: finalProposalId,
131
+ variantLabel: finalVariantLabel,
112
132
  };
113
133
  }, [apiBaseUrl, clientKey, explicitEnvironment, explicitRepoFullName, proposalId, variantLabel, storedProposalId, storedVariantLabel]);
114
134
 
@@ -140,7 +160,7 @@ export function ProbatProvider({
140
160
  return () => {
141
161
  stopHeatmapTracking();
142
162
  };
143
- }, [contextValue.apiBaseUrl]);
163
+ }, [contextValue.apiBaseUrl, contextValue.proposalId, contextValue.variantLabel]);
144
164
 
145
165
  return (
146
166
  <ProbatContext.Provider value={contextValue}>
@@ -1,38 +1,10 @@
1
1
  {
2
- "folders": [
3
- {
4
- "path": "../../../../../itrt-frontend"
5
- },
6
- {
7
- "path": "../../../.."
8
- },
9
- {
10
- "path": "../../../../../../onelab_projects/gushi_test_repo"
11
- },
12
- {
13
- "path": "../../../../../../react-jsx-test-repo"
14
- },
15
- {
16
- "path": "../../../../../../testforprobat"
17
- },
18
- {
19
- "path": "../../../../../portfolio"
20
- },
21
- {
22
- "path": "../../../../../portfolio-nextjs"
23
- },
24
- {
25
- "path": "../../../../../react-native-test"
26
- },
27
- {
28
- "path": "../../../../../../tbg_test"
29
- },
30
- {
31
- "path": "../../../../../../probattestforecho"
32
- },
33
- {
34
- "path": "../../../../../zingexample"
35
- }
36
- ],
37
- "settings": {}
2
+ "folders": [
3
+ {
4
+ "path": "../../../.."
5
+ },
6
+ {
7
+ "path": "../../../../../itrt-frontend"
8
+ }
9
+ ]
38
10
  }
@@ -160,7 +160,24 @@ export function withExperiment<P = any>(
160
160
  if (useNewAPI && configLoading) return;
161
161
 
162
162
  let alive = true;
163
- const cached = readChoice(proposalId);
163
+
164
+ // Detect if we are in heatmap mode
165
+ const isHeatmapMode = typeof window !== 'undefined' &&
166
+ new URLSearchParams(window.location.search).get('heatmap') === 'true';
167
+
168
+ // HIGH PRIORITY: Check if context is already forcing a specific variant for this proposal
169
+ // (This happens during Live Heatmap visualization)
170
+ if (context.proposalId === proposalId && context.variantLabel) {
171
+ console.log(`[PROBAT HOC] Forced variant from context: ${context.variantLabel}`);
172
+ setChoice({
173
+ experiment_id: `forced_${proposalId}`,
174
+ label: context.variantLabel
175
+ });
176
+ return;
177
+ }
178
+
179
+ // If we are in heatmap mode, bypass the cache to avoid showing stale variants
180
+ const cached = isHeatmapMode ? null : readChoice(proposalId);
164
181
 
165
182
  if (cached) {
166
183
  const choiceData = {
@@ -169,7 +186,7 @@ export function withExperiment<P = any>(
169
186
  };
170
187
  setChoice(choiceData);
171
188
  // Set localStorage for heatmap tracking
172
- if (typeof window !== 'undefined') {
189
+ if (typeof window !== 'undefined' && !isHeatmapMode) {
173
190
  try {
174
191
  window.localStorage.setItem('probat_active_proposal_id', proposalId);
175
192
  window.localStorage.setItem('probat_active_variant_label', cached.label);
@@ -182,18 +199,22 @@ export function withExperiment<P = any>(
182
199
  try {
183
200
  const { experiment_id, label } = await fetchDecision(apiBaseUrl, proposalId);
184
201
  if (!alive) return;
185
- writeChoice(proposalId, experiment_id, label);
186
- const choiceData = { experiment_id, label };
187
- setChoice(choiceData);
188
- // Set localStorage for heatmap tracking (fetchDecision already sets it, but ensure it's set here too)
189
- if (typeof window !== 'undefined') {
190
- try {
191
- window.localStorage.setItem('probat_active_proposal_id', proposalId);
192
- window.localStorage.setItem('probat_active_variant_label', label);
193
- } catch (e) {
194
- console.warn('[PROBAT] Failed to set proposal/variant in localStorage:', e);
202
+
203
+ // Only write to choice cache and localStorage if NOT in heatmap mode
204
+ if (!isHeatmapMode) {
205
+ writeChoice(proposalId, experiment_id, label);
206
+ if (typeof window !== 'undefined') {
207
+ try {
208
+ window.localStorage.setItem('probat_active_proposal_id', proposalId);
209
+ window.localStorage.setItem('probat_active_variant_label', label);
210
+ } catch (e) {
211
+ console.warn('[PROBAT] Failed to set proposal/variant in localStorage:', e);
212
+ }
195
213
  }
196
214
  }
215
+
216
+ const choiceData = { experiment_id, label };
217
+ setChoice(choiceData);
197
218
  } catch (e) {
198
219
  if (!alive) return;
199
220
  const choiceData = {
@@ -201,8 +222,8 @@ export function withExperiment<P = any>(
201
222
  label: "control",
202
223
  };
203
224
  setChoice(choiceData);
204
- // Set localStorage for control variant
205
- if (typeof window !== 'undefined') {
225
+
226
+ if (typeof window !== 'undefined' && !isHeatmapMode) {
206
227
  try {
207
228
  window.localStorage.setItem('probat_active_proposal_id', proposalId);
208
229
  window.localStorage.setItem('probat_active_variant_label', 'control');
@@ -215,7 +236,7 @@ export function withExperiment<P = any>(
215
236
  }
216
237
 
217
238
  return () => { alive = false; };
218
- }, [proposalId, apiBaseUrl, useNewAPI, configLoading]);
239
+ }, [proposalId, apiBaseUrl, useNewAPI, configLoading, context.proposalId, context.variantLabel]);
219
240
 
220
241
  // Track visit
221
242
  useEffect(() => {
@@ -59,7 +59,8 @@ export function useExperiment(
59
59
  proposalId: string,
60
60
  options?: { autoTrackImpression?: boolean }
61
61
  ): UseExperimentReturn {
62
- const { apiBaseUrl } = useProbatContext();
62
+ const context = useProbatContext();
63
+ const { apiBaseUrl } = context;
63
64
  const [choice, setChoice] = useState<{
64
65
  experiment_id: string;
65
66
  label: string;
@@ -73,7 +74,25 @@ export function useExperiment(
73
74
  useEffect(() => {
74
75
  let alive = true;
75
76
 
76
- const cached = readChoice(proposalId);
77
+ // Detect if we are in heatmap mode
78
+ const isHeatmapMode = typeof window !== 'undefined' &&
79
+ new URLSearchParams(window.location.search).get('heatmap') === 'true';
80
+
81
+ // HIGH PRIORITY: Check if context is already forcing a specific variant for this proposal
82
+ // (This happens during Live Heatmap visualization)
83
+ if (context.proposalId === proposalId && context.variantLabel) {
84
+ console.log(`[PROBAT] Forced variant from context: ${context.variantLabel}`);
85
+ setChoice({
86
+ experiment_id: `forced_${proposalId}`,
87
+ label: context.variantLabel
88
+ });
89
+ setIsLoading(false);
90
+ return;
91
+ }
92
+
93
+ // If we are in heatmap mode, bypass the cache to avoid showing stale variants
94
+ const cached = isHeatmapMode ? null : readChoice(proposalId);
95
+
77
96
  if (cached) {
78
97
  setChoice({ experiment_id: cached.experiment_id, label: cached.label });
79
98
  setIsLoading(false);
@@ -86,7 +105,12 @@ export function useExperiment(
86
105
  proposalId
87
106
  );
88
107
  if (!alive) return;
89
- writeChoice(proposalId, experiment_id, label);
108
+
109
+ // Only write to choice cache if NOT in heatmap mode
110
+ if (!isHeatmapMode) {
111
+ writeChoice(proposalId, experiment_id, label);
112
+ }
113
+
90
114
  setChoice({ experiment_id, label });
91
115
  setError(null);
92
116
  } catch (e) {
@@ -108,7 +132,7 @@ export function useExperiment(
108
132
  return () => {
109
133
  alive = false;
110
134
  };
111
- }, [proposalId, apiBaseUrl]);
135
+ }, [proposalId, apiBaseUrl, context.proposalId, context.variantLabel]);
112
136
 
113
137
  // Track impression when variant is determined
114
138
  useEffect(() => {
@@ -133,8 +133,12 @@ class HeatmapTracker {
133
133
 
134
134
  // Refresh proposal/variant from localStorage at runtime
135
135
  const stored = getStoredExperimentInfo();
136
- const proposalId = this.config.proposalId || stored.proposalId;
137
- const variantLabel = this.config.variantLabel || stored.variantLabel;
136
+
137
+ // If stored info matches current proposal, or we don't have a proposal yet, use stored variant
138
+ const activeProposalId = this.config.proposalId || stored.proposalId;
139
+ const activeVariantLabel = (stored.proposalId === activeProposalId)
140
+ ? (stored.variantLabel || this.config.variantLabel)
141
+ : (this.config.variantLabel || stored.variantLabel);
138
142
 
139
143
  // Throttle cursor tracking
140
144
  const now = Date.now();
@@ -145,21 +149,19 @@ class HeatmapTracker {
145
149
  this.lastCursorTime = now;
146
150
 
147
151
  // Debug: Log first few movements to verify it's working
148
- if (this.movements.length < 3) {
149
- console.log('[PROBAT Heatmap] Cursor movement captured:', {
150
- x: event.clientX,
151
- y: event.clientY,
152
- movementsInBatch: this.movements.length + 1
153
- });
154
- }
152
+ console.log('[PROBAT Heatmap] Cursor movement captured:', {
153
+ x: event.pageX,
154
+ y: event.pageY,
155
+ movementsInBatch: this.movements.length + 1
156
+ });
155
157
 
156
158
  // Get page information
157
159
  const pageUrl = window.location.href;
158
160
  const siteUrl = window.location.origin;
159
161
 
160
- // Get cursor coordinates relative to viewport
161
- const x = event.clientX;
162
- const y = event.clientY;
162
+ // Get cursor coordinates relative to document (including scroll)
163
+ const x = event.pageX;
164
+ const y = event.pageY;
163
165
 
164
166
  // Get viewport dimensions
165
167
  const viewportWidth = window.innerWidth;
@@ -174,8 +176,8 @@ class HeatmapTracker {
174
176
  viewport_width: viewportWidth,
175
177
  viewport_height: viewportHeight,
176
178
  session_id: this.sessionId,
177
- proposal_id: proposalId,
178
- variant_label: variantLabel,
179
+ proposal_id: activeProposalId,
180
+ variant_label: activeVariantLabel,
179
181
  };
180
182
 
181
183
  // Add to batch
@@ -261,8 +263,12 @@ class HeatmapTracker {
261
263
 
262
264
  // Refresh proposal/variant from localStorage at runtime
263
265
  const stored = getStoredExperimentInfo();
264
- const proposalId = this.config.proposalId || stored.proposalId;
265
- const variantLabel = this.config.variantLabel || stored.variantLabel;
266
+
267
+ // Priority: use localStorage variant if it matches the current proposal
268
+ const activeProposalId = this.config.proposalId || stored.proposalId;
269
+ const activeVariantLabel = (stored.proposalId === activeProposalId)
270
+ ? (stored.variantLabel || this.config.variantLabel)
271
+ : (this.config.variantLabel || stored.variantLabel);
266
272
 
267
273
  const target = event.target as HTMLElement | null;
268
274
  if (!target) return;
@@ -274,8 +280,9 @@ class HeatmapTracker {
274
280
  const pageUrl = window.location.href;
275
281
  const siteUrl = window.location.origin;
276
282
 
277
- const x = event.clientX;
278
- const y = event.clientY;
283
+ // Get click coordinates relative to document (including scroll)
284
+ const x = event.pageX;
285
+ const y = event.pageY;
279
286
 
280
287
  const viewportWidth = window.innerWidth;
281
288
  const viewportHeight = window.innerHeight;
@@ -293,8 +300,8 @@ class HeatmapTracker {
293
300
  element_class: elementInfo.class,
294
301
  element_id: elementInfo.id,
295
302
  session_id: this.sessionId,
296
- proposal_id: proposalId,
297
- variant_label: variantLabel,
303
+ proposal_id: activeProposalId,
304
+ variant_label: activeVariantLabel,
298
305
  };
299
306
 
300
307
  this.clicks.push(clickEvent);
@@ -363,6 +370,19 @@ class HeatmapTracker {
363
370
  return;
364
371
  }
365
372
 
373
+ // Check for heatmap visualization mode FIRST
374
+ const params = new URLSearchParams(window.location.search);
375
+ if (params.get('heatmap') === 'true') {
376
+ console.log('[PROBAT Heatmap] Heatmap visualization mode detected');
377
+ const pageUrl = params.get('page_url');
378
+ if (pageUrl) {
379
+ this.config.enabled = false;
380
+ this.isInitialized = true;
381
+ this.enableVisualization(pageUrl);
382
+ return;
383
+ }
384
+ }
385
+
366
386
  document.addEventListener('click', this.handleClick, true);
367
387
 
368
388
  // Attach cursor movement listener if enabled
@@ -374,12 +394,7 @@ class HeatmapTracker {
374
394
  }
375
395
 
376
396
  this.isInitialized = true;
377
- console.log('[PROBAT Heatmap] Tracker initialized', {
378
- enabled: this.config.enabled,
379
- trackCursor: this.config.trackCursor,
380
- cursorThrottle: this.config.cursorThrottle,
381
- cursorBatchSize: this.config.cursorBatchSize
382
- });
397
+ console.log('[PROBAT Heatmap] Tracker initialized');
383
398
 
384
399
  window.addEventListener('beforeunload', () => {
385
400
  if (this.clicks.length > 0) {
@@ -447,6 +462,178 @@ class HeatmapTracker {
447
462
 
448
463
  this.isInitialized = false;
449
464
  }
465
+
466
+ /**
467
+ * Enable visualization mode
468
+ */
469
+ /**
470
+ * Enable visualization mode
471
+ */
472
+ private async enableVisualization(pageUrl: string): Promise<void> {
473
+ console.log('[PROBAT Heatmap] Enabling visualization mode for:', pageUrl);
474
+
475
+ // Stop tracking just in case
476
+ this.stop();
477
+ this.config.enabled = false;
478
+
479
+ try {
480
+ // Fetch heatmap data
481
+ const url = new URL(`${this.config.apiBaseUrl}/api/heatmap/aggregate`);
482
+ url.searchParams.append('site_url', window.location.origin);
483
+ url.searchParams.append('page_url', pageUrl);
484
+ url.searchParams.append('days', '30'); // Default to 30 days
485
+
486
+ // Add variant filtering if present in URL
487
+ const params = new URLSearchParams(window.location.search);
488
+ const proposalId = params.get('proposal_id');
489
+ const variantLabel = params.get('variant_label');
490
+
491
+ if (proposalId) url.searchParams.append('proposal_id', proposalId);
492
+ if (variantLabel) url.searchParams.append('variant_label', variantLabel);
493
+
494
+ const response = await fetch(url.toString());
495
+ if (!response.ok) throw new Error('Failed to fetch heatmap data');
496
+
497
+ const data = await response.json();
498
+ if (data && data.points) {
499
+ console.log(`[PROBAT Heatmap] Found ${data.points.length} points. Rendering...`);
500
+ this.renderHeatmapOverlay(data);
501
+ }
502
+ } catch (error) {
503
+ console.error('[PROBAT Heatmap] Visualization error:', error);
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Render heatmap overlay on valid points
509
+ */
510
+ private renderHeatmapOverlay(data: any): void {
511
+ const points = data.points;
512
+ const trackedWidth = data.viewport_width || 0;
513
+
514
+ // Remove existing heatmap if any
515
+ const existing = document.getElementById('probat-heatmap-overlay');
516
+ if (existing) existing.remove();
517
+
518
+ const canvas = document.createElement('canvas');
519
+ canvas.id = 'probat-heatmap-overlay';
520
+ canvas.style.position = 'absolute';
521
+ canvas.style.top = '0';
522
+ canvas.style.left = '0';
523
+ canvas.style.zIndex = '999999';
524
+ canvas.style.pointerEvents = 'none';
525
+ canvas.style.display = 'block';
526
+ canvas.style.margin = '0';
527
+ canvas.style.padding = '0';
528
+
529
+ // Append to html to ensure it's relative to the absolute document top-left (0,0)
530
+ document.documentElement.appendChild(canvas);
531
+
532
+ const resize = () => {
533
+ const dpr = window.devicePixelRatio || 1;
534
+ const width = document.documentElement.scrollWidth;
535
+ const height = document.documentElement.scrollHeight;
536
+ const currentWidth = window.innerWidth;
537
+
538
+ // Horizontal centering adjustment
539
+ // Formula: current_x = tracked_x + (current_viewport_width - tracked_viewport_width) / 2
540
+ let offsetX = 0;
541
+ if (trackedWidth > 0 && trackedWidth !== currentWidth) {
542
+ offsetX = (currentWidth - trackedWidth) / 2;
543
+ console.log(`[PROBAT Heatmap] Horizontal adjustment: offset=${offsetX}px (Tracked: ${trackedWidth}px, Current: ${currentWidth}px)`);
544
+ }
545
+
546
+ // Set display size
547
+ canvas.style.width = width + 'px';
548
+ canvas.style.height = height + 'px';
549
+
550
+ // Set internal resolution scaled by pixel ratio
551
+ canvas.width = width * dpr;
552
+ canvas.height = height * dpr;
553
+
554
+ const ctx = canvas.getContext('2d');
555
+ if (ctx) {
556
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // Clear and set scale in one go
557
+ this.renderPoints(ctx, points, offsetX);
558
+ }
559
+ };
560
+
561
+ window.addEventListener('resize', resize);
562
+ // Call multiple times to catch delayed layout shifts
563
+ setTimeout(resize, 300);
564
+ setTimeout(resize, 1000);
565
+ setTimeout(resize, 3000);
566
+ }
567
+
568
+ /**
569
+ * Draw points on canvas
570
+ */
571
+ private renderPoints(ctx: CanvasRenderingContext2D, points: any[], offsetX: number): void {
572
+ points.forEach(point => {
573
+ // Apply horizontal offset for centered layouts
574
+ const x = point.x + offsetX;
575
+ const y = point.y;
576
+ const intensity = point.intensity;
577
+
578
+ // Scale radius based on intensity
579
+ const radius = 20 + (intensity * 12);
580
+ const color = this.getHeatmapColor(intensity);
581
+
582
+ // Use radial gradient for a "heat" look
583
+ const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
584
+ gradient.addColorStop(0, this.rgbToRgba(color, 0.8));
585
+ gradient.addColorStop(1, this.rgbToRgba(color, 0));
586
+
587
+ ctx.beginPath();
588
+ ctx.arc(x, y, radius, 0, 2 * Math.PI);
589
+ ctx.fillStyle = gradient;
590
+ ctx.fill();
591
+ });
592
+ }
593
+
594
+ /**
595
+ * Get heatmap color based on intensity
596
+ */
597
+ private getHeatmapColor(intensity: number): string {
598
+ const clamped = Math.max(0, Math.min(1, intensity));
599
+
600
+ if (clamped < 0.25) {
601
+ const t = clamped / 0.25;
602
+ const r = Math.floor(0 + t * 0);
603
+ const g = Math.floor(0 + t * 255);
604
+ const b = Math.floor(255 + t * (255 - 255));
605
+ return `rgb(${r}, ${g}, ${b})`;
606
+ } else if (clamped < 0.5) {
607
+ const t = (clamped - 0.25) / 0.25;
608
+ const r = Math.floor(0 + t * 0);
609
+ const g = 255;
610
+ const b = Math.floor(255 + t * (0 - 255));
611
+ return `rgb(${r}, ${g}, ${b})`;
612
+ } else if (clamped < 0.75) {
613
+ const t = (clamped - 0.5) / 0.25;
614
+ const r = Math.floor(0 + t * 255);
615
+ const g = 255;
616
+ const b = 0;
617
+ return `rgb(${r}, ${g}, ${b})`;
618
+ } else {
619
+ const t = (clamped - 0.75) / 0.25;
620
+ const r = 255;
621
+ const g = Math.floor(255 + t * (0 - 255));
622
+ const b = 0;
623
+ return `rgb(${r}, ${g}, ${b})`;
624
+ }
625
+ }
626
+
627
+ /**
628
+ * Convert RGB to RGBA
629
+ */
630
+ private rgbToRgba(rgb: string, opacity: number): string {
631
+ const match = rgb.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
632
+ if (match) {
633
+ return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${opacity})`;
634
+ }
635
+ return rgb;
636
+ }
450
637
  }
451
638
 
452
639
  let trackerInstance: HeatmapTracker | null = null;