@probat/react 0.1.0 → 0.1.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.d.mts +30 -1
- package/dist/index.d.ts +30 -1
- package/dist/index.js +146 -41
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +144 -43
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/context/ProbatContext.tsx +12 -1
- package/src/hoc/withExperiment.tsx +11 -1
- package/src/index.ts +6 -0
- package/src/utils/documentClickTracker.ts +200 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document-level click tracking for Probat experiments
|
|
3
|
+
*
|
|
4
|
+
* This module implements event delegation at the document level to track clicks
|
|
5
|
+
* even when components don't have onClick handlers or when stopPropagation() is called.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { sendMetric } from './api';
|
|
9
|
+
import { extractClickMeta } from './api';
|
|
10
|
+
|
|
11
|
+
// Cache for proposal metadata to avoid repeated DOM queries
|
|
12
|
+
const proposalCache = new Map<string, {
|
|
13
|
+
proposalId: string;
|
|
14
|
+
experimentId: string | null;
|
|
15
|
+
variantLabel: string;
|
|
16
|
+
apiBaseUrl: string;
|
|
17
|
+
}>();
|
|
18
|
+
|
|
19
|
+
// Track if listener is already attached
|
|
20
|
+
let isListenerAttached = false;
|
|
21
|
+
|
|
22
|
+
// Rate limiting: prevent duplicate rapid clicks
|
|
23
|
+
const lastClickTime = new Map<string, number>();
|
|
24
|
+
const DEBOUNCE_MS = 100; // Ignore clicks within 100ms of each other
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get proposal metadata from DOM element
|
|
28
|
+
* Looks for the nearest [data-probat-proposal] wrapper
|
|
29
|
+
*/
|
|
30
|
+
function getProposalMetadata(element: HTMLElement): {
|
|
31
|
+
proposalId: string;
|
|
32
|
+
experimentId: string | null;
|
|
33
|
+
variantLabel: string;
|
|
34
|
+
apiBaseUrl: string;
|
|
35
|
+
} | null {
|
|
36
|
+
// Find the nearest probat wrapper
|
|
37
|
+
const probatWrapper = element.closest('[data-probat-proposal]') as HTMLElement | null;
|
|
38
|
+
|
|
39
|
+
if (!probatWrapper) return null;
|
|
40
|
+
|
|
41
|
+
const proposalId = probatWrapper.getAttribute('data-probat-proposal');
|
|
42
|
+
if (!proposalId) return null;
|
|
43
|
+
|
|
44
|
+
// Check cache first
|
|
45
|
+
const cacheKey = `${proposalId}`;
|
|
46
|
+
const cached = proposalCache.get(cacheKey);
|
|
47
|
+
if (cached) return cached;
|
|
48
|
+
|
|
49
|
+
// Extract metadata from data attributes
|
|
50
|
+
const experimentId = probatWrapper.getAttribute('data-probat-experiment-id');
|
|
51
|
+
const variantLabel = probatWrapper.getAttribute('data-probat-variant-label') || 'control';
|
|
52
|
+
const apiBaseUrl = probatWrapper.getAttribute('data-probat-api-base-url') ||
|
|
53
|
+
(typeof window !== 'undefined' && (window as any).__PROBAT_API) ||
|
|
54
|
+
'https://gushi.onrender.com';
|
|
55
|
+
|
|
56
|
+
const metadata = {
|
|
57
|
+
proposalId,
|
|
58
|
+
experimentId: experimentId || null,
|
|
59
|
+
variantLabel,
|
|
60
|
+
apiBaseUrl,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Cache it
|
|
64
|
+
proposalCache.set(cacheKey, metadata);
|
|
65
|
+
return metadata;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Handle click event at document level
|
|
70
|
+
* Uses capture phase to catch events before stopPropagation()
|
|
71
|
+
*/
|
|
72
|
+
function handleDocumentClick(event: MouseEvent): void {
|
|
73
|
+
const target = event.target as HTMLElement | null;
|
|
74
|
+
if (!target) return;
|
|
75
|
+
|
|
76
|
+
// Get proposal metadata
|
|
77
|
+
const metadata = getProposalMetadata(target);
|
|
78
|
+
if (!metadata) return;
|
|
79
|
+
|
|
80
|
+
// Rate limiting: prevent duplicate rapid clicks
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
const lastClick = lastClickTime.get(metadata.proposalId) || 0;
|
|
83
|
+
if (now - lastClick < DEBOUNCE_MS) {
|
|
84
|
+
return; // Ignore rapid duplicate clicks
|
|
85
|
+
}
|
|
86
|
+
lastClickTime.set(metadata.proposalId, now);
|
|
87
|
+
|
|
88
|
+
// Extract click metadata (your existing function)
|
|
89
|
+
const clickMeta = extractClickMeta(event);
|
|
90
|
+
|
|
91
|
+
// Determine if we should track this click
|
|
92
|
+
// Track if:
|
|
93
|
+
// 1. It's an actionable element (button, link, etc.) - clickMeta exists
|
|
94
|
+
// 2. Element has data-probat-track attribute
|
|
95
|
+
// 3. Parent has data-probat-track attribute
|
|
96
|
+
const hasTrackAttribute = target.hasAttribute('data-probat-track') ||
|
|
97
|
+
target.closest('[data-probat-track]') !== null;
|
|
98
|
+
|
|
99
|
+
const shouldTrack = clickMeta !== undefined || hasTrackAttribute;
|
|
100
|
+
|
|
101
|
+
if (!shouldTrack) {
|
|
102
|
+
// Optional: Track "dead clicks" for UX insights
|
|
103
|
+
// Uncomment the code below if you want to track clicks on non-interactive elements
|
|
104
|
+
/*
|
|
105
|
+
const deadClickMeta = {
|
|
106
|
+
dead_click: true,
|
|
107
|
+
target_tag: target.tagName,
|
|
108
|
+
target_class: target.className || '',
|
|
109
|
+
target_id: target.id || '',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
void sendMetric(
|
|
113
|
+
metadata.apiBaseUrl,
|
|
114
|
+
metadata.proposalId,
|
|
115
|
+
'dead_click',
|
|
116
|
+
metadata.variantLabel,
|
|
117
|
+
metadata.experimentId,
|
|
118
|
+
deadClickMeta
|
|
119
|
+
);
|
|
120
|
+
*/
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Send click metric
|
|
125
|
+
void sendMetric(
|
|
126
|
+
metadata.apiBaseUrl,
|
|
127
|
+
metadata.proposalId,
|
|
128
|
+
'click',
|
|
129
|
+
metadata.variantLabel,
|
|
130
|
+
metadata.experimentId || undefined,
|
|
131
|
+
clickMeta
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Initialize document-level click tracking
|
|
137
|
+
* Call this once when your app initializes (typically in ProbatProvider)
|
|
138
|
+
*/
|
|
139
|
+
export function initDocumentClickTracking(): void {
|
|
140
|
+
if (isListenerAttached) {
|
|
141
|
+
console.warn('[PROBAT] Document click listener already attached');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (typeof document === 'undefined') {
|
|
146
|
+
// Server-side rendering - skip
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Use capture phase (true) to catch events before they bubble
|
|
151
|
+
// This ensures we catch clicks even if stopPropagation() is called
|
|
152
|
+
document.addEventListener('click', handleDocumentClick, true);
|
|
153
|
+
|
|
154
|
+
isListenerAttached = true;
|
|
155
|
+
console.log('[PROBAT] Document-level click tracking initialized');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Clean up the listener (useful for testing or cleanup)
|
|
160
|
+
*/
|
|
161
|
+
export function cleanupDocumentClickTracking(): void {
|
|
162
|
+
if (!isListenerAttached) return;
|
|
163
|
+
|
|
164
|
+
if (typeof document !== 'undefined') {
|
|
165
|
+
document.removeEventListener('click', handleDocumentClick, true);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
isListenerAttached = false;
|
|
169
|
+
proposalCache.clear();
|
|
170
|
+
lastClickTime.clear();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Update proposal metadata cache (call this when proposal data changes)
|
|
175
|
+
* This is useful if you want to update the cache without waiting for DOM queries
|
|
176
|
+
*/
|
|
177
|
+
export function updateProposalMetadata(
|
|
178
|
+
proposalId: string,
|
|
179
|
+
metadata: {
|
|
180
|
+
experimentId?: string | null;
|
|
181
|
+
variantLabel?: string;
|
|
182
|
+
apiBaseUrl?: string;
|
|
183
|
+
}
|
|
184
|
+
): void {
|
|
185
|
+
const existing = proposalCache.get(proposalId);
|
|
186
|
+
if (existing) {
|
|
187
|
+
proposalCache.set(proposalId, {
|
|
188
|
+
...existing,
|
|
189
|
+
...metadata,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Clear the proposal cache (useful for testing)
|
|
196
|
+
*/
|
|
197
|
+
export function clearProposalCache(): void {
|
|
198
|
+
proposalCache.clear();
|
|
199
|
+
}
|
|
200
|
+
|