@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.
@@ -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
+