@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.
- package/README.md +33 -344
- package/dist/index.d.mts +76 -224
- package/dist/index.d.ts +76 -224
- package/dist/index.js +397 -1185
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +394 -1169
- package/dist/index.mjs.map +1 -1
- package/package.json +19 -12
- package/src/__tests__/Experiment.test.tsx +764 -0
- package/src/__tests__/setup.ts +63 -0
- package/src/__tests__/utils.test.ts +79 -0
- package/src/components/Experiment.tsx +291 -0
- package/src/components/ProbatProviderClient.tsx +19 -7
- package/src/context/ProbatContext.tsx +30 -132
- package/src/hooks/useProbatMetrics.ts +18 -134
- package/src/index.ts +9 -32
- package/src/utils/api.ts +96 -577
- package/src/utils/dedupeStorage.ts +40 -0
- package/src/utils/eventContext.ts +94 -0
- package/src/utils/stableInstanceId.ts +113 -0
- package/src/utils/storage.ts +18 -60
- package/src/hoc/itrt-frontend.code-workspace +0 -38
- package/src/hoc/withExperiment.tsx +0 -290
- package/src/hooks/useExperiment.ts +0 -164
- package/src/utils/documentClickTracker.ts +0 -215
- package/src/utils/heatmapTracker.ts +0 -478
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect, useCallback } from "react";
|
|
4
|
-
import type { MouseEvent } from "react";
|
|
5
|
-
import { useProbatContext } from "../context/ProbatContext";
|
|
6
|
-
import { fetchDecision } from "../utils/api";
|
|
7
|
-
import {
|
|
8
|
-
readChoice,
|
|
9
|
-
writeChoice,
|
|
10
|
-
hasTrackedVisit,
|
|
11
|
-
markTrackedVisit,
|
|
12
|
-
} from "../utils/storage";
|
|
13
|
-
import { sendMetric } from "../utils/api";
|
|
14
|
-
|
|
15
|
-
export interface UseExperimentReturn {
|
|
16
|
-
/**
|
|
17
|
-
* The current variant label (e.g., "control", "variant-a")
|
|
18
|
-
*/
|
|
19
|
-
variantLabel: string;
|
|
20
|
-
/**
|
|
21
|
-
* The experiment ID
|
|
22
|
-
*/
|
|
23
|
-
experimentId: string | null;
|
|
24
|
-
/**
|
|
25
|
-
* Whether the experiment decision is still loading
|
|
26
|
-
*/
|
|
27
|
-
isLoading: boolean;
|
|
28
|
-
/**
|
|
29
|
-
* Any error that occurred while fetching the experiment
|
|
30
|
-
*/
|
|
31
|
-
error: Error | null;
|
|
32
|
-
/**
|
|
33
|
-
* Manually track a click for this experiment
|
|
34
|
-
*/
|
|
35
|
-
trackClick: (event?: MouseEvent | null) => void;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Hook for fetching and applying experiment variants
|
|
40
|
-
*
|
|
41
|
-
* @param proposalId - The proposal ID for the experiment
|
|
42
|
-
* @param options - Optional configuration
|
|
43
|
-
* @param options.autoTrackImpression - Automatically track impression when variant is loaded (default: true)
|
|
44
|
-
*
|
|
45
|
-
* @example
|
|
46
|
-
* ```tsx
|
|
47
|
-
* const { variantLabel, isLoading, trackClick } = useExperiment("proposal-id");
|
|
48
|
-
*
|
|
49
|
-
* if (isLoading) return <div>Loading...</div>;
|
|
50
|
-
*
|
|
51
|
-
* return (
|
|
52
|
-
* <div onClick={trackClick}>
|
|
53
|
-
* {variantLabel === "control" ? <ControlComponent /> : <VariantComponent />}
|
|
54
|
-
* </div>
|
|
55
|
-
* );
|
|
56
|
-
* ```
|
|
57
|
-
*/
|
|
58
|
-
export function useExperiment(
|
|
59
|
-
proposalId: string,
|
|
60
|
-
options?: { autoTrackImpression?: boolean }
|
|
61
|
-
): UseExperimentReturn {
|
|
62
|
-
const { apiBaseUrl } = useProbatContext();
|
|
63
|
-
const [choice, setChoice] = useState<{
|
|
64
|
-
experiment_id: string;
|
|
65
|
-
label: string;
|
|
66
|
-
} | null>(null);
|
|
67
|
-
const [isLoading, setIsLoading] = useState(true);
|
|
68
|
-
const [error, setError] = useState<Error | null>(null);
|
|
69
|
-
|
|
70
|
-
const autoTrackImpression = options?.autoTrackImpression !== false;
|
|
71
|
-
|
|
72
|
-
// Fetch experiment decision
|
|
73
|
-
useEffect(() => {
|
|
74
|
-
let alive = true;
|
|
75
|
-
|
|
76
|
-
const cached = readChoice(proposalId);
|
|
77
|
-
if (cached) {
|
|
78
|
-
setChoice({ experiment_id: cached.experiment_id, label: cached.label });
|
|
79
|
-
setIsLoading(false);
|
|
80
|
-
} else {
|
|
81
|
-
setIsLoading(true);
|
|
82
|
-
(async () => {
|
|
83
|
-
try {
|
|
84
|
-
const { experiment_id, label } = await fetchDecision(
|
|
85
|
-
apiBaseUrl,
|
|
86
|
-
proposalId
|
|
87
|
-
);
|
|
88
|
-
if (!alive) return;
|
|
89
|
-
writeChoice(proposalId, experiment_id, label);
|
|
90
|
-
setChoice({ experiment_id, label });
|
|
91
|
-
setError(null);
|
|
92
|
-
} catch (e) {
|
|
93
|
-
if (!alive) return;
|
|
94
|
-
const err = e instanceof Error ? e : new Error(String(e));
|
|
95
|
-
setError(err);
|
|
96
|
-
setChoice({
|
|
97
|
-
experiment_id: `exp_${proposalId}`,
|
|
98
|
-
label: "control",
|
|
99
|
-
});
|
|
100
|
-
} finally {
|
|
101
|
-
if (alive) {
|
|
102
|
-
setIsLoading(false);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
})();
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return () => {
|
|
109
|
-
alive = false;
|
|
110
|
-
};
|
|
111
|
-
}, [proposalId, apiBaseUrl]);
|
|
112
|
-
|
|
113
|
-
// Track impression when variant is determined
|
|
114
|
-
useEffect(() => {
|
|
115
|
-
if (!autoTrackImpression || !choice) return;
|
|
116
|
-
|
|
117
|
-
const exp = choice.experiment_id;
|
|
118
|
-
const lbl = choice.label ?? "control";
|
|
119
|
-
if (!lbl) return;
|
|
120
|
-
|
|
121
|
-
// Only track if we haven't already tracked this visit
|
|
122
|
-
if (hasTrackedVisit(proposalId, lbl)) return;
|
|
123
|
-
markTrackedVisit(proposalId, lbl);
|
|
124
|
-
void sendMetric(apiBaseUrl, proposalId, "visit", lbl, exp);
|
|
125
|
-
}, [proposalId, choice?.experiment_id, choice?.label, apiBaseUrl, autoTrackImpression]);
|
|
126
|
-
|
|
127
|
-
const trackClick = useCallback(
|
|
128
|
-
(event?: MouseEvent | null) => {
|
|
129
|
-
const exp = choice?.experiment_id;
|
|
130
|
-
const lbl = choice?.label ?? "control";
|
|
131
|
-
const meta = event
|
|
132
|
-
? (() => {
|
|
133
|
-
const rawTarget = event.target as HTMLElement | null;
|
|
134
|
-
if (!rawTarget) return undefined;
|
|
135
|
-
const actionable = rawTarget.closest(
|
|
136
|
-
"[data-probat-conversion='true'], button, a, [role='button']"
|
|
137
|
-
);
|
|
138
|
-
if (!actionable) return undefined;
|
|
139
|
-
const meta: Record<string, any> = {
|
|
140
|
-
target_tag: actionable.tagName,
|
|
141
|
-
};
|
|
142
|
-
if (actionable.id) meta.target_id = actionable.id;
|
|
143
|
-
const attr = actionable.getAttribute("data-probat-conversion");
|
|
144
|
-
if (attr) meta.conversion_attr = attr;
|
|
145
|
-
const text = actionable.textContent?.trim();
|
|
146
|
-
if (text) meta.target_text = text.slice(0, 120);
|
|
147
|
-
return meta;
|
|
148
|
-
})()
|
|
149
|
-
: undefined;
|
|
150
|
-
|
|
151
|
-
void sendMetric(apiBaseUrl, proposalId, "click", lbl, undefined, meta);
|
|
152
|
-
},
|
|
153
|
-
[proposalId, choice?.experiment_id, choice?.label, apiBaseUrl]
|
|
154
|
-
);
|
|
155
|
-
|
|
156
|
-
return {
|
|
157
|
-
variantLabel: choice?.label ?? "control",
|
|
158
|
-
experimentId: choice?.experiment_id ?? null,
|
|
159
|
-
isLoading,
|
|
160
|
-
error,
|
|
161
|
-
trackClick,
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
|
|
@@ -1,215 +0,0 @@
|
|
|
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) {
|
|
79
|
-
return; // Click outside probat component
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Rate limiting: prevent duplicate rapid clicks
|
|
83
|
-
const now = Date.now();
|
|
84
|
-
const lastClick = lastClickTime.get(metadata.proposalId) || 0;
|
|
85
|
-
if (now - lastClick < DEBOUNCE_MS) {
|
|
86
|
-
return; // Ignore rapid duplicate clicks
|
|
87
|
-
}
|
|
88
|
-
lastClickTime.set(metadata.proposalId, now);
|
|
89
|
-
|
|
90
|
-
// Extract click metadata (your existing function)
|
|
91
|
-
const clickMeta = extractClickMeta(event);
|
|
92
|
-
|
|
93
|
-
// Determine if we should track this click
|
|
94
|
-
// Track if:
|
|
95
|
-
// 1. It's an actionable element (button, link, etc.) - clickMeta exists
|
|
96
|
-
// 2. Element has data-probat-track attribute
|
|
97
|
-
// 3. Parent has data-probat-track attribute
|
|
98
|
-
// 4. ANY click within a probat component (more permissive - track all clicks)
|
|
99
|
-
const hasTrackAttribute = target.hasAttribute('data-probat-track') ||
|
|
100
|
-
target.closest('[data-probat-track]') !== null;
|
|
101
|
-
|
|
102
|
-
// More permissive: Track ALL clicks within probat components
|
|
103
|
-
// This ensures we capture clicks even if elements don't have explicit tracking attributes
|
|
104
|
-
const shouldTrack = clickMeta !== undefined || hasTrackAttribute || true; // Track all clicks in probat components
|
|
105
|
-
|
|
106
|
-
if (!shouldTrack) {
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Build metadata - use clickMeta if available, otherwise create basic metadata
|
|
111
|
-
const finalMeta = clickMeta || {
|
|
112
|
-
target_tag: target.tagName,
|
|
113
|
-
target_class: target.className || '',
|
|
114
|
-
target_id: target.id || '',
|
|
115
|
-
clicked_inside_probat: true, // Flag to indicate this was tracked via document-level listener
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
// Send click metric
|
|
119
|
-
// For control variant, don't send experiment_id (control might be synthetic)
|
|
120
|
-
// For other variants, send experiment_id if it exists and is valid (not synthetic "exp_" prefix)
|
|
121
|
-
const experimentId = metadata.variantLabel === 'control'
|
|
122
|
-
? undefined
|
|
123
|
-
: (metadata.experimentId && !metadata.experimentId.startsWith('exp_')
|
|
124
|
-
? metadata.experimentId
|
|
125
|
-
: undefined);
|
|
126
|
-
|
|
127
|
-
void sendMetric(
|
|
128
|
-
metadata.apiBaseUrl,
|
|
129
|
-
metadata.proposalId,
|
|
130
|
-
'click',
|
|
131
|
-
metadata.variantLabel,
|
|
132
|
-
experimentId,
|
|
133
|
-
finalMeta
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
// Debug logging (always log for now to help diagnose)
|
|
137
|
-
console.log('[PROBAT] Click tracked:', {
|
|
138
|
-
proposalId: metadata.proposalId,
|
|
139
|
-
variantLabel: metadata.variantLabel,
|
|
140
|
-
target: target.tagName,
|
|
141
|
-
targetId: target.id || 'none',
|
|
142
|
-
targetClass: target.className || 'none',
|
|
143
|
-
meta: finalMeta,
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
// Also log to help debug if API call fails
|
|
147
|
-
console.log('[PROBAT] Sending metric to:', `${metadata.apiBaseUrl}/send_metrics/${metadata.proposalId}`);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Initialize document-level click tracking
|
|
152
|
-
* Call this once when your app initializes (typically in ProbatProvider)
|
|
153
|
-
*/
|
|
154
|
-
export function initDocumentClickTracking(): void {
|
|
155
|
-
if (isListenerAttached) {
|
|
156
|
-
console.warn('[PROBAT] Document click listener already attached');
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (typeof document === 'undefined') {
|
|
161
|
-
// Server-side rendering - skip
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Use capture phase (true) to catch events before they bubble
|
|
166
|
-
// This ensures we catch clicks even if stopPropagation() is called
|
|
167
|
-
document.addEventListener('click', handleDocumentClick, true);
|
|
168
|
-
|
|
169
|
-
isListenerAttached = true;
|
|
170
|
-
console.log('[PROBAT] Document-level click tracking initialized');
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Clean up the listener (useful for testing or cleanup)
|
|
175
|
-
*/
|
|
176
|
-
export function cleanupDocumentClickTracking(): void {
|
|
177
|
-
if (!isListenerAttached) return;
|
|
178
|
-
|
|
179
|
-
if (typeof document !== 'undefined') {
|
|
180
|
-
document.removeEventListener('click', handleDocumentClick, true);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
isListenerAttached = false;
|
|
184
|
-
proposalCache.clear();
|
|
185
|
-
lastClickTime.clear();
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Update proposal metadata cache (call this when proposal data changes)
|
|
190
|
-
* This is useful if you want to update the cache without waiting for DOM queries
|
|
191
|
-
*/
|
|
192
|
-
export function updateProposalMetadata(
|
|
193
|
-
proposalId: string,
|
|
194
|
-
metadata: {
|
|
195
|
-
experimentId?: string | null;
|
|
196
|
-
variantLabel?: string;
|
|
197
|
-
apiBaseUrl?: string;
|
|
198
|
-
}
|
|
199
|
-
): void {
|
|
200
|
-
const existing = proposalCache.get(proposalId);
|
|
201
|
-
if (existing) {
|
|
202
|
-
proposalCache.set(proposalId, {
|
|
203
|
-
...existing,
|
|
204
|
-
...metadata,
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Clear the proposal cache (useful for testing)
|
|
211
|
-
*/
|
|
212
|
-
export function clearProposalCache(): void {
|
|
213
|
-
proposalCache.clear();
|
|
214
|
-
}
|
|
215
|
-
|