@probat/react 0.1.5 → 0.2.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/README.md +88 -0
- package/dist/index.d.mts +60 -2
- package/dist/index.d.ts +60 -2
- package/dist/index.js +415 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +414 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/context/ProbatContext.tsx +57 -2
- package/src/hoc/withExperiment.tsx +35 -5
- package/src/index.ts +2 -0
- package/src/utils/api.ts +22 -0
- package/src/utils/heatmapTracker.ts +478 -0
|
@@ -163,23 +163,53 @@ export function withExperiment<P = any>(
|
|
|
163
163
|
const cached = readChoice(proposalId);
|
|
164
164
|
|
|
165
165
|
if (cached) {
|
|
166
|
-
|
|
166
|
+
const choiceData = {
|
|
167
167
|
experiment_id: cached.experiment_id,
|
|
168
168
|
label: cached.label,
|
|
169
|
-
}
|
|
169
|
+
};
|
|
170
|
+
setChoice(choiceData);
|
|
171
|
+
// Set localStorage for heatmap tracking
|
|
172
|
+
if (typeof window !== 'undefined') {
|
|
173
|
+
try {
|
|
174
|
+
window.localStorage.setItem('probat_active_proposal_id', proposalId);
|
|
175
|
+
window.localStorage.setItem('probat_active_variant_label', cached.label);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
console.warn('[PROBAT] Failed to set proposal/variant in localStorage:', e);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
170
180
|
} else {
|
|
171
181
|
(async () => {
|
|
172
182
|
try {
|
|
173
183
|
const { experiment_id, label } = await fetchDecision(apiBaseUrl, proposalId);
|
|
174
184
|
if (!alive) return;
|
|
175
185
|
writeChoice(proposalId, experiment_id, label);
|
|
176
|
-
|
|
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);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
177
197
|
} catch (e) {
|
|
178
198
|
if (!alive) return;
|
|
179
|
-
|
|
199
|
+
const choiceData = {
|
|
180
200
|
experiment_id: `exp_${proposalId}`,
|
|
181
201
|
label: "control",
|
|
182
|
-
}
|
|
202
|
+
};
|
|
203
|
+
setChoice(choiceData);
|
|
204
|
+
// Set localStorage for control variant
|
|
205
|
+
if (typeof window !== 'undefined') {
|
|
206
|
+
try {
|
|
207
|
+
window.localStorage.setItem('probat_active_proposal_id', proposalId);
|
|
208
|
+
window.localStorage.setItem('probat_active_variant_label', 'control');
|
|
209
|
+
} catch (err) {
|
|
210
|
+
console.warn('[PROBAT] Failed to set proposal/variant in localStorage:', err);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
183
213
|
}
|
|
184
214
|
})();
|
|
185
215
|
}
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,8 @@ export type { WithExperimentOptions } from "./hoc/withExperiment";
|
|
|
28
28
|
export { detectEnvironment } from "./utils/environment";
|
|
29
29
|
export { fetchDecision, sendMetric, extractClickMeta } from "./utils/api";
|
|
30
30
|
export type { RetrieveResponse } from "./utils/api";
|
|
31
|
+
// Heatmap tracking (automatically initialized by ProbatProvider)
|
|
32
|
+
export { initHeatmapTracking, stopHeatmapTracking, getHeatmapTracker } from "./utils/heatmapTracker";
|
|
31
33
|
export {
|
|
32
34
|
readChoice,
|
|
33
35
|
writeChoice,
|
package/src/utils/api.ts
CHANGED
|
@@ -51,6 +51,16 @@ export async function fetchDecision(
|
|
|
51
51
|
const experiment_id = (data.experiment_id || `exp_${proposalId}`).toString();
|
|
52
52
|
const label = data.label && data.label.trim() ? data.label : "control";
|
|
53
53
|
|
|
54
|
+
// Auto-set localStorage for heatmap tracking
|
|
55
|
+
if (typeof window !== 'undefined') {
|
|
56
|
+
try {
|
|
57
|
+
window.localStorage.setItem('probat_active_proposal_id', proposalId);
|
|
58
|
+
window.localStorage.setItem('probat_active_variant_label', label);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.warn('[PROBAT] Failed to set proposal/variant in localStorage:', e);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
54
64
|
return { experiment_id, label };
|
|
55
65
|
} finally {
|
|
56
66
|
// Remove from pending cache after completion
|
|
@@ -161,6 +171,18 @@ export async function fetchComponentExperimentConfig(
|
|
|
161
171
|
}
|
|
162
172
|
|
|
163
173
|
const data = (await res.json()) as ComponentExperimentConfig;
|
|
174
|
+
|
|
175
|
+
// Auto-set localStorage for heatmap tracking when component config is loaded
|
|
176
|
+
// This will be updated when a specific variant is selected via fetchDecision
|
|
177
|
+
if (typeof window !== 'undefined' && data?.proposal_id) {
|
|
178
|
+
try {
|
|
179
|
+
window.localStorage.setItem('probat_active_proposal_id', data.proposal_id);
|
|
180
|
+
// Variant label will be set when fetchDecision is called
|
|
181
|
+
} catch (e) {
|
|
182
|
+
console.warn('[PROBAT] Failed to set proposal_id in localStorage:', e);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
164
186
|
return data;
|
|
165
187
|
} catch (e) {
|
|
166
188
|
console.warn(`[PROBAT] Failed to fetch component config: ${e}`);
|
|
@@ -0,0 +1,478 @@
|
|
|
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
|
+
}
|