@probat/react 0.4.0 → 0.4.2
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 +99 -7
- package/dist/index.d.ts +99 -7
- package/dist/index.js +175 -110
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +172 -111
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/Experiment.test.tsx +4 -4
- package/src/__tests__/Track.test.tsx +125 -0
- package/src/__tests__/createExperimentContext.test.tsx +62 -0
- package/src/__tests__/useExperiment.test.tsx +169 -0
- package/src/__tests__/useTrack.test.tsx +431 -0
- package/src/components/Experiment.tsx +22 -222
- package/src/components/Track.tsx +34 -0
- package/src/hooks/useExperiment.ts +118 -0
- package/src/hooks/useProbatMetrics.ts +1 -1
- package/src/hooks/useTrack.ts +173 -0
- package/src/index.ts +9 -0
- package/src/utils/createExperimentContext.ts +47 -0
|
@@ -1,46 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import React
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
useState,
|
|
7
|
-
useCallback,
|
|
8
|
-
useMemo,
|
|
9
|
-
} from "react";
|
|
10
|
-
import { useProbatContext } from "../context/ProbatContext";
|
|
11
|
-
import { fetchDecision, sendMetric, extractClickMeta } from "../utils/api";
|
|
12
|
-
import { getDistinctId, getPageKey } from "../utils/eventContext";
|
|
13
|
-
import { makeDedupeKey, hasSeen, markSeen } from "../utils/dedupeStorage";
|
|
14
|
-
import { useStableInstanceId } from "../utils/stableInstanceId";
|
|
15
|
-
|
|
16
|
-
// ── localStorage assignment cache ──────────────────────────────────────────
|
|
17
|
-
|
|
18
|
-
const ASSIGNMENT_PREFIX = "probat:assignment:";
|
|
19
|
-
|
|
20
|
-
interface StoredAssignment {
|
|
21
|
-
variantKey: string;
|
|
22
|
-
ts: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function readAssignment(id: string): string | null {
|
|
26
|
-
if (typeof window === "undefined") return null;
|
|
27
|
-
try {
|
|
28
|
-
const raw = localStorage.getItem(ASSIGNMENT_PREFIX + id);
|
|
29
|
-
if (!raw) return null;
|
|
30
|
-
const parsed: StoredAssignment = JSON.parse(raw);
|
|
31
|
-
return parsed.variantKey ?? null;
|
|
32
|
-
} catch {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function writeAssignment(id: string, variantKey: string): void {
|
|
38
|
-
if (typeof window === "undefined") return;
|
|
39
|
-
try {
|
|
40
|
-
const entry: StoredAssignment = { variantKey, ts: Date.now() };
|
|
41
|
-
localStorage.setItem(ASSIGNMENT_PREFIX + id, JSON.stringify(entry));
|
|
42
|
-
} catch {}
|
|
43
|
-
}
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { useExperiment } from "../hooks/useExperiment";
|
|
5
|
+
import { useTrack } from "../hooks/useTrack";
|
|
44
6
|
|
|
45
7
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
46
8
|
|
|
@@ -81,193 +43,32 @@ export function Experiment({
|
|
|
81
43
|
fallback = "control",
|
|
82
44
|
debug = false,
|
|
83
45
|
}: ExperimentProps) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// Stable instance id (useId + sessionStorage for cross-mount stability)
|
|
87
|
-
const autoInstanceId = useStableInstanceId(id);
|
|
88
|
-
const instanceId = componentInstanceId ?? autoInstanceId;
|
|
89
|
-
|
|
90
|
-
// Track options with defaults
|
|
91
|
-
const trackImpression = track?.impression !== false;
|
|
92
|
-
const trackClick = track?.primaryClick !== false;
|
|
93
|
-
const impressionEvent = track?.impressionEventName ?? "$experiment_exposure";
|
|
94
|
-
const clickEvent = track?.clickEventName ?? "$experiment_click";
|
|
95
|
-
|
|
96
|
-
// ── Assignment resolution ──────────────────────────────────────────────
|
|
97
|
-
|
|
98
|
-
const [variantKey, setVariantKey] = useState<string>(() => {
|
|
99
|
-
// Defer localStorage read to useEffect to avoid hydration mismatch.
|
|
100
|
-
if (bootstrap[id]) return bootstrap[id];
|
|
101
|
-
return "control";
|
|
102
|
-
});
|
|
103
|
-
const [resolved, setResolved] = useState<boolean>(() => {
|
|
104
|
-
return !!bootstrap[id];
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
useEffect(() => {
|
|
108
|
-
// Already resolved from bootstrap or cache
|
|
109
|
-
if (bootstrap[id] || readAssignment(id)) {
|
|
110
|
-
// Ensure state is synced (StrictMode may re-mount)
|
|
111
|
-
const key = bootstrap[id] ?? readAssignment(id) ?? "control";
|
|
112
|
-
setVariantKey(key);
|
|
113
|
-
setResolved(true);
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
let cancelled = false;
|
|
118
|
-
|
|
119
|
-
(async () => {
|
|
120
|
-
try {
|
|
121
|
-
const distinctId = customerId ?? getDistinctId();
|
|
122
|
-
const key = await fetchDecision(host, id, distinctId);
|
|
123
|
-
if (cancelled) return;
|
|
124
|
-
|
|
125
|
-
// Validate variant key
|
|
126
|
-
if (key !== "control" && !(key in variants)) {
|
|
127
|
-
if (debug) {
|
|
128
|
-
console.warn(
|
|
129
|
-
`[probat] Unknown variant "${key}" for experiment "${id}", falling back to control`
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
setVariantKey("control");
|
|
133
|
-
} else {
|
|
134
|
-
setVariantKey(key);
|
|
135
|
-
writeAssignment(id, key);
|
|
136
|
-
}
|
|
137
|
-
} catch (err) {
|
|
138
|
-
if (cancelled) return;
|
|
139
|
-
if (debug) {
|
|
140
|
-
console.error(`[probat] fetchDecision failed for "${id}":`, err);
|
|
141
|
-
}
|
|
142
|
-
if (fallback === "suspend") throw err;
|
|
143
|
-
setVariantKey("control");
|
|
144
|
-
} finally {
|
|
145
|
-
if (!cancelled) setResolved(true);
|
|
146
|
-
}
|
|
147
|
-
})();
|
|
148
|
-
|
|
149
|
-
return () => {
|
|
150
|
-
cancelled = true;
|
|
151
|
-
};
|
|
152
|
-
}, [id, host]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
46
|
+
// ── Assignment (decoupled) ──────────────────────────────────────────────
|
|
153
47
|
|
|
154
|
-
|
|
48
|
+
const { variantKey: rawKey, resolved } = useExperiment(id, { fallback, debug });
|
|
155
49
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
instanceId,
|
|
160
|
-
pageKey: getPageKey(),
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
}, [debug, id, variantKey, resolved, instanceId]);
|
|
50
|
+
// Validate variant key against the ReactNode map
|
|
51
|
+
const variantKey =
|
|
52
|
+
rawKey === "control" || rawKey in variants ? rawKey : "control";
|
|
164
53
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
() => ({
|
|
169
|
-
experiment_id: id,
|
|
170
|
-
variant_key: variantKey,
|
|
171
|
-
component_instance_id: instanceId,
|
|
172
|
-
...(customerId ? { customer_id: customerId } : {}),
|
|
173
|
-
}),
|
|
174
|
-
[id, variantKey, instanceId, customerId]
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
// ── Impression tracking via IntersectionObserver ────────────────────────
|
|
178
|
-
|
|
179
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
180
|
-
const impressionSent = useRef(false);
|
|
181
|
-
|
|
182
|
-
useEffect(() => {
|
|
183
|
-
if (!trackImpression || !resolved) return;
|
|
184
|
-
|
|
185
|
-
// Reset on re-mount (StrictMode safety)
|
|
186
|
-
impressionSent.current = false;
|
|
187
|
-
|
|
188
|
-
const pageKey = getPageKey();
|
|
189
|
-
const dedupeKey = makeDedupeKey(id, variantKey, instanceId, pageKey);
|
|
190
|
-
|
|
191
|
-
// Already seen this session
|
|
192
|
-
if (hasSeen(dedupeKey)) {
|
|
193
|
-
impressionSent.current = true;
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const el = containerRef.current;
|
|
198
|
-
if (!el) return;
|
|
199
|
-
|
|
200
|
-
// Fallback: no IntersectionObserver (SSR, old browser)
|
|
201
|
-
if (typeof IntersectionObserver === "undefined") {
|
|
202
|
-
if (!impressionSent.current) {
|
|
203
|
-
impressionSent.current = true;
|
|
204
|
-
markSeen(dedupeKey);
|
|
205
|
-
sendMetric(host, impressionEvent, eventProps);
|
|
206
|
-
if (debug) console.log(`[probat] Impression sent (no IO) for "${id}"`);
|
|
207
|
-
}
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
212
|
-
|
|
213
|
-
const observer = new IntersectionObserver(
|
|
214
|
-
([entry]) => {
|
|
215
|
-
if (!entry || impressionSent.current) return;
|
|
216
|
-
|
|
217
|
-
if (entry.isIntersecting) {
|
|
218
|
-
timer = setTimeout(() => {
|
|
219
|
-
if (impressionSent.current) return;
|
|
220
|
-
impressionSent.current = true;
|
|
221
|
-
markSeen(dedupeKey);
|
|
222
|
-
sendMetric(host, impressionEvent, eventProps);
|
|
223
|
-
if (debug) console.log(`[probat] Impression sent for "${id}"`);
|
|
224
|
-
observer.disconnect();
|
|
225
|
-
}, 250);
|
|
226
|
-
} else if (timer) {
|
|
227
|
-
clearTimeout(timer);
|
|
228
|
-
timer = null;
|
|
229
|
-
}
|
|
230
|
-
},
|
|
231
|
-
{ threshold: 0.5 }
|
|
54
|
+
if (debug && rawKey !== variantKey) {
|
|
55
|
+
console.warn(
|
|
56
|
+
`[probat] Unknown variant "${rawKey}" for experiment "${id}", falling back to control`
|
|
232
57
|
);
|
|
58
|
+
}
|
|
233
59
|
|
|
234
|
-
|
|
60
|
+
// ── Tracking (decoupled) ────────────────────────────────────────────────
|
|
235
61
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (timer) clearTimeout(timer);
|
|
239
|
-
};
|
|
240
|
-
}, [
|
|
241
|
-
trackImpression,
|
|
242
|
-
resolved,
|
|
243
|
-
id,
|
|
62
|
+
const trackRef = useTrack({
|
|
63
|
+
experimentId: id,
|
|
244
64
|
variantKey,
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
65
|
+
componentInstanceId,
|
|
66
|
+
impression: resolved ? (track?.impression !== false) : false,
|
|
67
|
+
click: track?.primaryClick !== false,
|
|
68
|
+
impressionEventName: track?.impressionEventName,
|
|
69
|
+
clickEventName: track?.clickEventName,
|
|
249
70
|
debug,
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
// ── Click tracking ─────────────────────────────────────────────────────
|
|
253
|
-
|
|
254
|
-
const handleClick = useCallback(
|
|
255
|
-
(e: React.MouseEvent) => {
|
|
256
|
-
if (!trackClick) return;
|
|
257
|
-
|
|
258
|
-
const meta = extractClickMeta(e.target as EventTarget);
|
|
259
|
-
if (!meta) return;
|
|
260
|
-
|
|
261
|
-
sendMetric(host, clickEvent, {
|
|
262
|
-
...eventProps,
|
|
263
|
-
...meta,
|
|
264
|
-
});
|
|
265
|
-
if (debug) {
|
|
266
|
-
console.log(`[probat] Click tracked for "${id}"`, meta);
|
|
267
|
-
}
|
|
268
|
-
},
|
|
269
|
-
[trackClick, host, clickEvent, eventProps, id, debug]
|
|
270
|
-
);
|
|
71
|
+
});
|
|
271
72
|
|
|
272
73
|
// ── Render ─────────────────────────────────────────────────────────────
|
|
273
74
|
|
|
@@ -278,8 +79,7 @@ export function Experiment({
|
|
|
278
79
|
|
|
279
80
|
return (
|
|
280
81
|
<div
|
|
281
|
-
ref={
|
|
282
|
-
onClick={handleClick}
|
|
82
|
+
ref={trackRef as React.RefObject<HTMLDivElement>}
|
|
283
83
|
data-probat-experiment={id}
|
|
284
84
|
data-probat-variant={variantKey}
|
|
285
85
|
style={{
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { useTrack, type UseTrackOptions } from "../hooks/useTrack";
|
|
5
|
+
|
|
6
|
+
export interface TrackProps extends UseTrackOptions {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Wrapper component that attaches impression and click tracking to its children.
|
|
12
|
+
* Alternative to the `useTrack` hook when you prefer a component over a ref.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* <Track experimentId="pricing" variantKey="ai_v1">
|
|
17
|
+
* <PricingCard />
|
|
18
|
+
* </Track>
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function Track({ children, ...trackOptions }: TrackProps) {
|
|
22
|
+
const trackRef = useTrack(trackOptions);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
ref={trackRef as React.RefObject<HTMLDivElement>}
|
|
27
|
+
data-probat-track={trackOptions.experimentId}
|
|
28
|
+
data-probat-variant={trackOptions.variantKey}
|
|
29
|
+
style={{ display: "contents" }}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useProbatContext } from "../context/ProbatContext";
|
|
5
|
+
import { fetchDecision } from "../utils/api";
|
|
6
|
+
import { getDistinctId } from "../utils/eventContext";
|
|
7
|
+
|
|
8
|
+
// ── localStorage assignment cache ──────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const ASSIGNMENT_PREFIX = "probat:assignment:";
|
|
11
|
+
|
|
12
|
+
interface StoredAssignment {
|
|
13
|
+
variantKey: string;
|
|
14
|
+
ts: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readAssignment(id: string): string | null {
|
|
18
|
+
if (typeof window === "undefined") return null;
|
|
19
|
+
try {
|
|
20
|
+
const raw = localStorage.getItem(ASSIGNMENT_PREFIX + id);
|
|
21
|
+
if (!raw) return null;
|
|
22
|
+
const parsed: StoredAssignment = JSON.parse(raw);
|
|
23
|
+
return parsed.variantKey ?? null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function writeAssignment(id: string, variantKey: string): void {
|
|
30
|
+
if (typeof window === "undefined") return;
|
|
31
|
+
try {
|
|
32
|
+
const entry: StoredAssignment = { variantKey, ts: Date.now() };
|
|
33
|
+
localStorage.setItem(ASSIGNMENT_PREFIX + id, JSON.stringify(entry));
|
|
34
|
+
} catch {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export interface UseExperimentOptions {
|
|
40
|
+
/** Behavior when assignment fetch fails: "control" (default) renders control, "suspend" throws */
|
|
41
|
+
fallback?: "control" | "suspend";
|
|
42
|
+
/** Log decisions to console */
|
|
43
|
+
debug?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface UseExperimentReturn {
|
|
47
|
+
/** The resolved variant key (e.g. "control", "ai_v1") */
|
|
48
|
+
variantKey: string;
|
|
49
|
+
/** Whether the assignment has been resolved (bootstrap, cache, or fetch) */
|
|
50
|
+
resolved: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Hook ───────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolves the variant assignment for an experiment.
|
|
57
|
+
* No tracking — use `useTrack` or `<Track>` to observe impressions/clicks.
|
|
58
|
+
*
|
|
59
|
+
* Priority: bootstrap > localStorage cache > fetchDecision.
|
|
60
|
+
*/
|
|
61
|
+
export function useExperiment(
|
|
62
|
+
id: string,
|
|
63
|
+
options: UseExperimentOptions = {}
|
|
64
|
+
): UseExperimentReturn {
|
|
65
|
+
const { fallback = "control", debug = false } = options;
|
|
66
|
+
const { host, bootstrap, customerId } = useProbatContext();
|
|
67
|
+
|
|
68
|
+
const [variantKey, setVariantKey] = useState<string>(() => {
|
|
69
|
+
if (bootstrap[id]) return bootstrap[id];
|
|
70
|
+
return "control";
|
|
71
|
+
});
|
|
72
|
+
const [resolved, setResolved] = useState<boolean>(() => {
|
|
73
|
+
return !!bootstrap[id];
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (bootstrap[id] || readAssignment(id)) {
|
|
78
|
+
const key = bootstrap[id] ?? readAssignment(id) ?? "control";
|
|
79
|
+
setVariantKey(key);
|
|
80
|
+
setResolved(true);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let cancelled = false;
|
|
85
|
+
|
|
86
|
+
(async () => {
|
|
87
|
+
try {
|
|
88
|
+
const distinctId = customerId ?? getDistinctId();
|
|
89
|
+
const key = await fetchDecision(host, id, distinctId);
|
|
90
|
+
if (cancelled) return;
|
|
91
|
+
|
|
92
|
+
setVariantKey(key);
|
|
93
|
+
writeAssignment(id, key);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if (cancelled) return;
|
|
96
|
+
if (debug) {
|
|
97
|
+
console.error(`[probat] fetchDecision failed for "${id}":`, err);
|
|
98
|
+
}
|
|
99
|
+
if (fallback === "suspend") throw err;
|
|
100
|
+
setVariantKey("control");
|
|
101
|
+
} finally {
|
|
102
|
+
if (!cancelled) setResolved(true);
|
|
103
|
+
}
|
|
104
|
+
})();
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
cancelled = true;
|
|
108
|
+
};
|
|
109
|
+
}, [id, host]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (debug && resolved) {
|
|
113
|
+
console.log(`[probat] Experiment "${id}" -> variant "${variantKey}"`);
|
|
114
|
+
}
|
|
115
|
+
}, [debug, id, variantKey, resolved]);
|
|
116
|
+
|
|
117
|
+
return { variantKey, resolved };
|
|
118
|
+
}
|
|
@@ -28,7 +28,7 @@ export function useProbatMetrics(): UseProbatMetricsReturn {
|
|
|
28
28
|
const capture = useCallback(
|
|
29
29
|
(event: string, properties: Record<string, unknown> = {}) => {
|
|
30
30
|
sendMetric(host, event, {
|
|
31
|
-
...(customerId ? {
|
|
31
|
+
...(customerId ? { distinct_id: customerId } : {}),
|
|
32
32
|
...properties,
|
|
33
33
|
});
|
|
34
34
|
},
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useMemo, useCallback } from "react";
|
|
4
|
+
import { useProbatContext } from "../context/ProbatContext";
|
|
5
|
+
import { sendMetric, extractClickMeta } from "../utils/api";
|
|
6
|
+
import { getPageKey } from "../utils/eventContext";
|
|
7
|
+
import { makeDedupeKey, hasSeen, markSeen } from "../utils/dedupeStorage";
|
|
8
|
+
import { useStableInstanceId } from "../utils/stableInstanceId";
|
|
9
|
+
|
|
10
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface UseTrackOptions {
|
|
13
|
+
/** Experiment identifier */
|
|
14
|
+
experimentId: string;
|
|
15
|
+
/** The variant key to attach to events */
|
|
16
|
+
variantKey: string;
|
|
17
|
+
/** Stable instance id when multiple instances of the same experiment exist on a page */
|
|
18
|
+
componentInstanceId?: string;
|
|
19
|
+
/** Auto-track impressions (default true) */
|
|
20
|
+
impression?: boolean;
|
|
21
|
+
/** Auto-track clicks (default true) */
|
|
22
|
+
click?: boolean;
|
|
23
|
+
/** Custom impression event name (default "$experiment_exposure") */
|
|
24
|
+
impressionEventName?: string;
|
|
25
|
+
/** Custom click event name (default "$experiment_click") */
|
|
26
|
+
clickEventName?: string;
|
|
27
|
+
/** Log events to console */
|
|
28
|
+
debug?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Hook ───────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Attaches impression and click tracking to a DOM element via a ref.
|
|
35
|
+
* Completely independent of variant assignment — pass the variantKey explicitly.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* const trackRef = useTrack({ experimentId: "pricing", variantKey: "ai_v1" });
|
|
40
|
+
* return <div ref={trackRef}>...</div>;
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function useTrack(options: UseTrackOptions): React.RefObject<HTMLElement | null> {
|
|
44
|
+
const {
|
|
45
|
+
experimentId,
|
|
46
|
+
variantKey,
|
|
47
|
+
componentInstanceId,
|
|
48
|
+
impression: trackImpression = true,
|
|
49
|
+
click: trackClick = true,
|
|
50
|
+
impressionEventName = "$experiment_exposure",
|
|
51
|
+
clickEventName = "$experiment_click",
|
|
52
|
+
debug = false,
|
|
53
|
+
} = options;
|
|
54
|
+
|
|
55
|
+
const { host, customerId } = useProbatContext();
|
|
56
|
+
|
|
57
|
+
const autoInstanceId = useStableInstanceId(experimentId);
|
|
58
|
+
const instanceId = componentInstanceId ?? autoInstanceId;
|
|
59
|
+
|
|
60
|
+
const containerRef = useRef<HTMLElement | null>(null);
|
|
61
|
+
const impressionSent = useRef(false);
|
|
62
|
+
|
|
63
|
+
const eventProps = useMemo(
|
|
64
|
+
() => ({
|
|
65
|
+
experiment_id: experimentId,
|
|
66
|
+
variant_key: variantKey,
|
|
67
|
+
component_instance_id: instanceId,
|
|
68
|
+
...(customerId ? { distinct_id: customerId } : {}),
|
|
69
|
+
}),
|
|
70
|
+
[experimentId, variantKey, instanceId, customerId]
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// ── Impression tracking via IntersectionObserver ────────────────────────
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (!trackImpression) return;
|
|
77
|
+
|
|
78
|
+
impressionSent.current = false;
|
|
79
|
+
|
|
80
|
+
const pageKey = getPageKey();
|
|
81
|
+
const dedupeKey = makeDedupeKey(experimentId, variantKey, instanceId, pageKey);
|
|
82
|
+
|
|
83
|
+
if (hasSeen(dedupeKey)) {
|
|
84
|
+
impressionSent.current = true;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const el = containerRef.current;
|
|
89
|
+
if (!el) return;
|
|
90
|
+
|
|
91
|
+
// Fallback: no IntersectionObserver (SSR, old browser)
|
|
92
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
93
|
+
if (!impressionSent.current) {
|
|
94
|
+
impressionSent.current = true;
|
|
95
|
+
markSeen(dedupeKey);
|
|
96
|
+
sendMetric(host, impressionEventName, eventProps);
|
|
97
|
+
if (debug) console.log(`[probat] Impression sent (no IO) for "${experimentId}"`);
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
103
|
+
|
|
104
|
+
const observer = new IntersectionObserver(
|
|
105
|
+
([entry]) => {
|
|
106
|
+
if (!entry || impressionSent.current) return;
|
|
107
|
+
|
|
108
|
+
if (entry.isIntersecting) {
|
|
109
|
+
timer = setTimeout(() => {
|
|
110
|
+
if (impressionSent.current) return;
|
|
111
|
+
impressionSent.current = true;
|
|
112
|
+
markSeen(dedupeKey);
|
|
113
|
+
sendMetric(host, impressionEventName, eventProps);
|
|
114
|
+
if (debug) console.log(`[probat] Impression sent for "${experimentId}"`);
|
|
115
|
+
observer.disconnect();
|
|
116
|
+
}, 250);
|
|
117
|
+
} else if (timer) {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
timer = null;
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
{ threshold: 0.5 }
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
observer.observe(el);
|
|
126
|
+
|
|
127
|
+
return () => {
|
|
128
|
+
observer.disconnect();
|
|
129
|
+
if (timer) clearTimeout(timer);
|
|
130
|
+
};
|
|
131
|
+
}, [
|
|
132
|
+
trackImpression,
|
|
133
|
+
experimentId,
|
|
134
|
+
variantKey,
|
|
135
|
+
instanceId,
|
|
136
|
+
host,
|
|
137
|
+
impressionEventName,
|
|
138
|
+
eventProps,
|
|
139
|
+
debug,
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
// ── Click tracking ─────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
const handleClick = useCallback(
|
|
145
|
+
(e: Event) => {
|
|
146
|
+
if (!trackClick) return;
|
|
147
|
+
|
|
148
|
+
const meta = extractClickMeta(e.target as EventTarget);
|
|
149
|
+
if (!meta) return;
|
|
150
|
+
|
|
151
|
+
sendMetric(host, clickEventName, {
|
|
152
|
+
...eventProps,
|
|
153
|
+
...meta,
|
|
154
|
+
});
|
|
155
|
+
if (debug) {
|
|
156
|
+
console.log(`[probat] Click tracked for "${experimentId}"`, meta);
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
[trackClick, host, clickEventName, eventProps, experimentId, debug]
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
const el = containerRef.current;
|
|
164
|
+
if (!el || !trackClick) return;
|
|
165
|
+
|
|
166
|
+
el.addEventListener("click", handleClick);
|
|
167
|
+
return () => {
|
|
168
|
+
el.removeEventListener("click", handleClick);
|
|
169
|
+
};
|
|
170
|
+
}, [handleClick, trackClick]);
|
|
171
|
+
|
|
172
|
+
return containerRef;
|
|
173
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,10 +8,19 @@ export type { ProbatProviderProps } from "./context/ProbatContext";
|
|
|
8
8
|
export { Experiment } from "./components/Experiment";
|
|
9
9
|
export type { ExperimentProps, ExperimentTrackOptions } from "./components/Experiment";
|
|
10
10
|
|
|
11
|
+
// ── Track component ────────────────────────────────────────────────────────
|
|
12
|
+
export { Track } from "./components/Track";
|
|
13
|
+
export type { TrackProps } from "./components/Track";
|
|
14
|
+
|
|
11
15
|
// ── Hooks ──────────────────────────────────────────────────────────────────
|
|
16
|
+
export { useExperiment } from "./hooks/useExperiment";
|
|
17
|
+
export type { UseExperimentOptions, UseExperimentReturn } from "./hooks/useExperiment";
|
|
18
|
+
export { useTrack } from "./hooks/useTrack";
|
|
19
|
+
export type { UseTrackOptions } from "./hooks/useTrack";
|
|
12
20
|
export { useProbatMetrics } from "./hooks/useProbatMetrics";
|
|
13
21
|
export type { UseProbatMetricsReturn } from "./hooks/useProbatMetrics";
|
|
14
22
|
|
|
15
23
|
// ── Utilities (advanced) ───────────────────────────────────────────────────
|
|
16
24
|
export { sendMetric, fetchDecision } from "./utils/api";
|
|
17
25
|
export type { MetricPayload, DecisionResponse } from "./utils/api";
|
|
26
|
+
export { createExperimentContext } from "./utils/createExperimentContext";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, type ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Factory that creates a typed context + hook pair for passing a variantKey
|
|
7
|
+
* between components in different files without prop-drilling.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* // experiment-context.ts
|
|
12
|
+
* export const { ExperimentProvider, useVariantKey } = createExperimentContext("pricing-test");
|
|
13
|
+
*
|
|
14
|
+
* // Parent.tsx
|
|
15
|
+
* const { variantKey } = useExperiment("pricing-test");
|
|
16
|
+
* <ExperimentProvider value={variantKey}><Child /></ExperimentProvider>
|
|
17
|
+
*
|
|
18
|
+
* // Child.tsx (different file)
|
|
19
|
+
* const variantKey = useVariantKey();
|
|
20
|
+
* const trackRef = useTrack({ experimentId: "pricing-test", variantKey });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function createExperimentContext(experimentId: string) {
|
|
24
|
+
const Ctx = createContext<string | null>(null);
|
|
25
|
+
|
|
26
|
+
function ExperimentProvider({
|
|
27
|
+
value,
|
|
28
|
+
children,
|
|
29
|
+
}: {
|
|
30
|
+
value: string;
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
}) {
|
|
33
|
+
return React.createElement(Ctx.Provider, { value }, children);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function useVariantKey(): string {
|
|
37
|
+
const v = useContext(Ctx);
|
|
38
|
+
if (v === null) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`useVariantKey() must be used inside <ExperimentProvider> for "${experimentId}"`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { ExperimentProvider, useVariantKey };
|
|
47
|
+
}
|