@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.
@@ -1,478 +0,0 @@
1
- /**
2
- * Universal Heatmap Tracker for User Websites
3
- *
4
- * Tracks all clicks across the entire user website for heatmap visualization.
5
- * This is injected into user websites via the probat-react package.
6
- */
7
-
8
- interface ClickEvent {
9
- page_url: string;
10
- site_url: string;
11
- x_coordinate: number;
12
- y_coordinate: number;
13
- viewport_width: number;
14
- viewport_height: number;
15
- element_tag: string | null;
16
- element_class: string | null;
17
- element_id: string | null;
18
- session_id: string;
19
- proposal_id?: string | null;
20
- variant_label?: string | null;
21
- }
22
-
23
- interface CursorMovementEvent {
24
- page_url: string;
25
- site_url: string;
26
- x_coordinate: number;
27
- y_coordinate: number;
28
- viewport_width: number;
29
- viewport_height: number;
30
- session_id: string;
31
- proposal_id?: string | null;
32
- variant_label?: string | null;
33
- }
34
-
35
- interface HeatmapConfig {
36
- apiBaseUrl: string;
37
- batchSize?: number;
38
- batchInterval?: number;
39
- enabled?: boolean;
40
- excludeSelectors?: string[];
41
- trackCursor?: boolean; // Enable cursor movement tracking
42
- cursorThrottle?: number; // Milliseconds between cursor movement captures (default: 100ms)
43
- cursorBatchSize?: number; // Number of cursor movements to batch before sending
44
- proposalId?: string; // Optional proposal/experiment id
45
- variantLabel?: string; // Optional variant label
46
- }
47
-
48
- function getStoredExperimentInfo(): { proposalId?: string; variantLabel?: string } {
49
- if (typeof window === 'undefined') return {};
50
- try {
51
- const proposalId = window.localStorage.getItem('probat_active_proposal_id') || undefined;
52
- const variantLabel = window.localStorage.getItem('probat_active_variant_label') || undefined;
53
- return { proposalId, variantLabel };
54
- } catch {
55
- return {};
56
- }
57
- }
58
-
59
- class HeatmapTracker {
60
- private clicks: ClickEvent[] = [];
61
- private movements: CursorMovementEvent[] = [];
62
- private sessionId: string;
63
- private config: HeatmapConfig;
64
- private batchTimer: ReturnType<typeof setTimeout> | null = null;
65
- private cursorBatchTimer: ReturnType<typeof setTimeout> | null = null;
66
- private lastCursorTime = 0;
67
- private isInitialized = false;
68
-
69
- constructor(config: HeatmapConfig) {
70
- this.sessionId = this.getOrCreateSessionId();
71
- const stored = getStoredExperimentInfo();
72
-
73
- this.config = {
74
- apiBaseUrl: config.apiBaseUrl,
75
- batchSize: config.batchSize || 10,
76
- batchInterval: config.batchInterval || 5000,
77
- enabled: config.enabled !== false,
78
- excludeSelectors: config.excludeSelectors || [
79
- '[data-heatmap-exclude]',
80
- 'input[type="password"]',
81
- 'input[type="email"]',
82
- 'textarea',
83
- ],
84
- trackCursor: config.trackCursor !== false, // Enable cursor tracking by default
85
- cursorThrottle: config.cursorThrottle || 100, // Capture cursor position every 100ms
86
- cursorBatchSize: config.cursorBatchSize || 50, // Send every 50 movements
87
- proposalId: config.proposalId || stored.proposalId,
88
- variantLabel: config.variantLabel || stored.variantLabel,
89
- };
90
- }
91
-
92
- private getOrCreateSessionId(): string {
93
- if (typeof window === 'undefined') return '';
94
-
95
- const storageKey = 'probat_heatmap_session_id';
96
- let sessionId = localStorage.getItem(storageKey);
97
-
98
- if (!sessionId) {
99
- sessionId = `heatmap_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
100
- localStorage.setItem(storageKey, sessionId);
101
- }
102
-
103
- return sessionId;
104
- }
105
-
106
- private shouldExcludeElement(element: HTMLElement): boolean {
107
- if (!this.config.excludeSelectors) return false;
108
-
109
- for (const selector of this.config.excludeSelectors) {
110
- if (element.matches(selector) || element.closest(selector)) {
111
- return true;
112
- }
113
- }
114
-
115
- return false;
116
- }
117
-
118
- private extractElementInfo(element: HTMLElement): {
119
- tag: string | null;
120
- class: string | null;
121
- id: string | null;
122
- } {
123
- return {
124
- tag: element.tagName || null,
125
- class: element.className && typeof element.className === 'string' ? element.className : null,
126
- id: element.id || null,
127
- };
128
- }
129
-
130
- private handleMouseMove = (event: MouseEvent): void => {
131
- if (!this.config.enabled || !this.config.trackCursor) return;
132
- if (typeof window === 'undefined') return;
133
-
134
- // Refresh proposal/variant from localStorage at runtime
135
- const stored = getStoredExperimentInfo();
136
- const proposalId = this.config.proposalId || stored.proposalId;
137
- const variantLabel = this.config.variantLabel || stored.variantLabel;
138
-
139
- // Throttle cursor tracking
140
- const now = Date.now();
141
- const cursorThrottle = this.config.cursorThrottle ?? 100;
142
- if (now - this.lastCursorTime < cursorThrottle) {
143
- return;
144
- }
145
- this.lastCursorTime = now;
146
-
147
- // 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
- }
155
-
156
- // Get page information
157
- const pageUrl = window.location.href;
158
- const siteUrl = window.location.origin;
159
-
160
- // Get cursor coordinates relative to viewport
161
- const x = event.clientX;
162
- const y = event.clientY;
163
-
164
- // Get viewport dimensions
165
- const viewportWidth = window.innerWidth;
166
- const viewportHeight = window.innerHeight;
167
-
168
- // Create cursor movement event
169
- const movementEvent: CursorMovementEvent = {
170
- page_url: pageUrl,
171
- site_url: siteUrl,
172
- x_coordinate: x,
173
- y_coordinate: y,
174
- viewport_width: viewportWidth,
175
- viewport_height: viewportHeight,
176
- session_id: this.sessionId,
177
- proposal_id: proposalId,
178
- variant_label: variantLabel,
179
- };
180
-
181
- // Add to batch
182
- this.movements.push(movementEvent);
183
-
184
- // Send batch if it reaches the size limit
185
- const cursorBatchSize = this.config.cursorBatchSize ?? 50;
186
- if (this.movements.length >= cursorBatchSize) {
187
- this.sendCursorBatch();
188
- } else {
189
- // Otherwise, schedule a send after the interval
190
- this.scheduleCursorBatchSend();
191
- }
192
- };
193
-
194
- private scheduleCursorBatchSend(): void {
195
- // Clear existing timer
196
- if (this.cursorBatchTimer) {
197
- clearTimeout(this.cursorBatchTimer);
198
- }
199
-
200
- // Schedule new send
201
- this.cursorBatchTimer = setTimeout(() => {
202
- if (this.movements.length > 0) {
203
- this.sendCursorBatch();
204
- }
205
- }, this.config.batchInterval ?? 5000);
206
- }
207
-
208
- private async sendCursorBatch(): Promise<void> {
209
- if (this.movements.length === 0) return;
210
-
211
- // Get site URL from first movement
212
- const siteUrl = this.movements[0]?.site_url || window.location.origin;
213
-
214
- // Create batch
215
- const batch = {
216
- movements: this.movements,
217
- site_url: siteUrl,
218
- proposal_id: this.config.proposalId,
219
- variant_label: this.config.variantLabel,
220
- };
221
-
222
- // Clear movements array
223
- this.movements = [];
224
-
225
- // Clear timer
226
- if (this.cursorBatchTimer) {
227
- clearTimeout(this.cursorBatchTimer);
228
- this.cursorBatchTimer = null;
229
- }
230
-
231
- try {
232
- console.log('[PROBAT Heatmap] Sending cursor movements batch:', {
233
- count: batch.movements.length,
234
- site_url: batch.site_url
235
- });
236
-
237
- // Send to backend
238
- const response = await fetch(`${this.config.apiBaseUrl}/api/heatmap/cursor-movements`, {
239
- method: 'POST',
240
- headers: {
241
- 'Content-Type': 'application/json',
242
- },
243
- body: JSON.stringify(batch),
244
- // Don't wait for response - fire and forget for performance
245
- });
246
-
247
- if (!response.ok) {
248
- console.warn('[PROBAT Heatmap] Failed to send cursor movements:', response.status);
249
- } else {
250
- console.log('[PROBAT Heatmap] Successfully sent cursor movements batch');
251
- }
252
- } catch (error) {
253
- // Silently fail - don't interrupt user experience
254
- console.warn('[PROBAT Heatmap] Error sending cursor movements:', error);
255
- }
256
- }
257
-
258
- private handleClick = (event: MouseEvent): void => {
259
- if (!this.config.enabled) return;
260
- if (typeof window === 'undefined') return;
261
-
262
- // Refresh proposal/variant from localStorage at runtime
263
- const stored = getStoredExperimentInfo();
264
- const proposalId = this.config.proposalId || stored.proposalId;
265
- const variantLabel = this.config.variantLabel || stored.variantLabel;
266
-
267
- const target = event.target as HTMLElement | null;
268
- if (!target) return;
269
-
270
- if (this.shouldExcludeElement(target)) {
271
- return;
272
- }
273
-
274
- const pageUrl = window.location.href;
275
- const siteUrl = window.location.origin;
276
-
277
- const x = event.clientX;
278
- const y = event.clientY;
279
-
280
- const viewportWidth = window.innerWidth;
281
- const viewportHeight = window.innerHeight;
282
-
283
- const elementInfo = this.extractElementInfo(target);
284
-
285
- const clickEvent: ClickEvent = {
286
- page_url: pageUrl,
287
- site_url: siteUrl,
288
- x_coordinate: x,
289
- y_coordinate: y,
290
- viewport_width: viewportWidth,
291
- viewport_height: viewportHeight,
292
- element_tag: elementInfo.tag,
293
- element_class: elementInfo.class,
294
- element_id: elementInfo.id,
295
- session_id: this.sessionId,
296
- proposal_id: proposalId,
297
- variant_label: variantLabel,
298
- };
299
-
300
- this.clicks.push(clickEvent);
301
-
302
- const batchSize = this.config.batchSize ?? 10;
303
- if (this.clicks.length >= batchSize) {
304
- this.sendBatch();
305
- } else {
306
- this.scheduleBatchSend();
307
- }
308
- };
309
-
310
- private scheduleBatchSend(): void {
311
- if (this.batchTimer) {
312
- clearTimeout(this.batchTimer);
313
- }
314
-
315
- this.batchTimer = setTimeout(() => {
316
- if (this.clicks.length > 0) {
317
- this.sendBatch();
318
- }
319
- }, this.config.batchInterval ?? 5000);
320
- }
321
-
322
- private async sendBatch(): Promise<void> {
323
- if (this.clicks.length === 0) return;
324
-
325
- if (this.batchTimer) {
326
- clearTimeout(this.batchTimer);
327
- this.batchTimer = null;
328
- }
329
-
330
- const siteUrl = this.clicks[0]?.site_url || window.location.origin;
331
-
332
- const batch = {
333
- clicks: [...this.clicks],
334
- site_url: siteUrl,
335
- };
336
-
337
- this.clicks = [];
338
-
339
- try {
340
- const response = await fetch(`${this.config.apiBaseUrl}/api/heatmap/clicks`, {
341
- method: 'POST',
342
- headers: {
343
- 'Content-Type': 'application/json',
344
- },
345
- body: JSON.stringify(batch),
346
- });
347
-
348
- if (!response.ok) {
349
- console.warn('[PROBAT Heatmap] Failed to send clicks:', response.status);
350
- }
351
- } catch (error) {
352
- console.warn('[PROBAT Heatmap] Error sending clicks:', error);
353
- }
354
- }
355
-
356
- public init(): void {
357
- if (this.isInitialized) {
358
- console.warn('[PROBAT Heatmap] Tracker already initialized');
359
- return;
360
- }
361
-
362
- if (typeof window === 'undefined') {
363
- return;
364
- }
365
-
366
- document.addEventListener('click', this.handleClick, true);
367
-
368
- // Attach cursor movement listener if enabled
369
- if (this.config.trackCursor) {
370
- document.addEventListener('mousemove', this.handleMouseMove, { passive: true });
371
- console.log('[PROBAT Heatmap] Cursor tracking enabled');
372
- } else {
373
- console.log('[PROBAT Heatmap] Cursor tracking disabled');
374
- }
375
-
376
- 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
- });
383
-
384
- window.addEventListener('beforeunload', () => {
385
- if (this.clicks.length > 0) {
386
- const siteUrl = this.clicks[0]?.site_url || window.location.origin;
387
- const batch = {
388
- clicks: this.clicks,
389
- site_url: siteUrl,
390
- };
391
-
392
- const blob = new Blob([JSON.stringify(batch)], {
393
- type: 'application/json',
394
- });
395
- navigator.sendBeacon(
396
- `${this.config.apiBaseUrl}/api/heatmap/clicks`,
397
- blob
398
- );
399
- }
400
-
401
- if (this.movements.length > 0) {
402
- const siteUrl = this.movements[0]?.site_url || window.location.origin;
403
- const batch = {
404
- movements: this.movements,
405
- site_url: siteUrl,
406
- };
407
-
408
- const blob = new Blob([JSON.stringify(batch)], {
409
- type: 'application/json',
410
- });
411
- navigator.sendBeacon(
412
- `${this.config.apiBaseUrl}/api/heatmap/cursor-movements`,
413
- blob
414
- );
415
- }
416
- });
417
- }
418
-
419
- public stop(): void {
420
- if (!this.isInitialized) return;
421
-
422
- // Send any pending clicks
423
- if (this.clicks.length > 0) {
424
- this.sendBatch();
425
- }
426
-
427
- // Send any pending cursor movements
428
- if (this.movements.length > 0) {
429
- this.sendCursorBatch();
430
- }
431
-
432
- // Remove event listeners
433
- document.removeEventListener('click', this.handleClick, true);
434
- if (this.config.trackCursor) {
435
- document.removeEventListener('mousemove', this.handleMouseMove);
436
- }
437
-
438
- // Clear timers
439
- if (this.batchTimer) {
440
- clearTimeout(this.batchTimer);
441
- this.batchTimer = null;
442
- }
443
- if (this.cursorBatchTimer) {
444
- clearTimeout(this.cursorBatchTimer);
445
- this.cursorBatchTimer = null;
446
- }
447
-
448
- this.isInitialized = false;
449
- }
450
- }
451
-
452
- let trackerInstance: HeatmapTracker | null = null;
453
-
454
- /**
455
- * Initialize heatmap tracking for user websites
456
- * Called automatically by ProbatProvider
457
- */
458
- export function initHeatmapTracking(config: HeatmapConfig): HeatmapTracker {
459
- if (trackerInstance) {
460
- trackerInstance.stop();
461
- }
462
-
463
- trackerInstance = new HeatmapTracker(config);
464
- trackerInstance.init();
465
-
466
- return trackerInstance;
467
- }
468
-
469
- export function getHeatmapTracker(): HeatmapTracker | null {
470
- return trackerInstance;
471
- }
472
-
473
- export function stopHeatmapTracking(): void {
474
- if (trackerInstance) {
475
- trackerInstance.stop();
476
- trackerInstance = null;
477
- }
478
- }