@probat/react 0.4.1 → 0.4.3
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 +117 -7
- package/dist/index.d.ts +117 -7
- package/dist/index.js +185 -109
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +182 -110
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- 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 +682 -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/useTrack.ts +213 -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 ? { distinct_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 type TrackProps = 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 ?? "server-resolved"}
|
|
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
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
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
|
+
interface UseTrackBaseOptions {
|
|
13
|
+
/** Experiment identifier */
|
|
14
|
+
experimentId: string;
|
|
15
|
+
/** Stable instance id when multiple instances of the same experiment exist on a page */
|
|
16
|
+
componentInstanceId?: string;
|
|
17
|
+
/** Auto-track impressions (default true) */
|
|
18
|
+
impression?: boolean;
|
|
19
|
+
/** Auto-track clicks (default true) */
|
|
20
|
+
click?: boolean;
|
|
21
|
+
/** Custom impression event name (default "$experiment_exposure") */
|
|
22
|
+
impressionEventName?: string;
|
|
23
|
+
/** Custom click event name (default "$experiment_click") */
|
|
24
|
+
clickEventName?: string;
|
|
25
|
+
/** Log events to console */
|
|
26
|
+
debug?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Explicit mode: pass the variant key directly */
|
|
30
|
+
export interface UseTrackExplicitOptions extends UseTrackBaseOptions {
|
|
31
|
+
/** The variant key to attach to events */
|
|
32
|
+
variantKey: string;
|
|
33
|
+
customerId?: undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Customer mode: backend resolves variant from assignment table */
|
|
37
|
+
export interface UseTrackCustomerOptions extends UseTrackBaseOptions {
|
|
38
|
+
variantKey?: undefined;
|
|
39
|
+
/** Customer ID to resolve variant server-side. Falls back to provider's customerId. */
|
|
40
|
+
customerId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type UseTrackOptions = UseTrackExplicitOptions | UseTrackCustomerOptions;
|
|
44
|
+
|
|
45
|
+
// ── Hook ───────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Attaches impression and click tracking to a DOM element via a ref.
|
|
49
|
+
*
|
|
50
|
+
* Two modes:
|
|
51
|
+
* - **Explicit**: pass `variantKey` directly — stamped on every event.
|
|
52
|
+
* - **Customer**: omit `variantKey` — backend resolves it from the assignment table
|
|
53
|
+
* using `(experimentId, customerId)`.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```tsx
|
|
57
|
+
* // Explicit mode
|
|
58
|
+
* const trackRef = useTrack({ experimentId: "pricing", variantKey: "ai_v1" });
|
|
59
|
+
*
|
|
60
|
+
* // Customer mode (backend resolves variant)
|
|
61
|
+
* const trackRef = useTrack({ experimentId: "pricing", customerId: "user_123" });
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export function useTrack(options: UseTrackOptions): React.RefObject<HTMLElement | null> {
|
|
65
|
+
const {
|
|
66
|
+
experimentId,
|
|
67
|
+
componentInstanceId,
|
|
68
|
+
impression: trackImpression = true,
|
|
69
|
+
click: trackClick = true,
|
|
70
|
+
impressionEventName = "$experiment_exposure",
|
|
71
|
+
clickEventName = "$experiment_click",
|
|
72
|
+
debug = false,
|
|
73
|
+
} = options;
|
|
74
|
+
|
|
75
|
+
const variantKey = options.variantKey ?? undefined;
|
|
76
|
+
const explicitCustomerId = "customerId" in options ? options.customerId : undefined;
|
|
77
|
+
|
|
78
|
+
const { host, customerId: providerCustomerId } = useProbatContext();
|
|
79
|
+
|
|
80
|
+
// In customer mode, use explicit customerId or fall back to provider's
|
|
81
|
+
const resolvedCustomerId = explicitCustomerId ?? providerCustomerId;
|
|
82
|
+
const isCustomerMode = !variantKey;
|
|
83
|
+
|
|
84
|
+
const autoInstanceId = useStableInstanceId(experimentId);
|
|
85
|
+
const instanceId = componentInstanceId ?? autoInstanceId;
|
|
86
|
+
|
|
87
|
+
const containerRef = useRef<HTMLElement | null>(null);
|
|
88
|
+
const impressionSent = useRef(false);
|
|
89
|
+
|
|
90
|
+
// Runtime warning
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (isCustomerMode && !resolvedCustomerId && debug) {
|
|
93
|
+
console.warn(
|
|
94
|
+
`[probat] useTrack called without variantKey and no customerId ` +
|
|
95
|
+
`available for "${experimentId}". Events will have no variant attribution.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}, [isCustomerMode, resolvedCustomerId, experimentId, debug]);
|
|
99
|
+
|
|
100
|
+
const eventProps = useMemo(
|
|
101
|
+
() => ({
|
|
102
|
+
experiment_id: experimentId,
|
|
103
|
+
...(variantKey ? { variant_key: variantKey } : {}),
|
|
104
|
+
component_instance_id: instanceId,
|
|
105
|
+
...(resolvedCustomerId ? { distinct_id: resolvedCustomerId } : {}),
|
|
106
|
+
}),
|
|
107
|
+
[experimentId, variantKey, instanceId, resolvedCustomerId]
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// ── Impression tracking via IntersectionObserver ────────────────────────
|
|
111
|
+
|
|
112
|
+
// In customer mode, use customerId for dedupe instead of variantKey
|
|
113
|
+
const dedupeVariant = variantKey ?? resolvedCustomerId ?? "__anon__";
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!trackImpression) return;
|
|
117
|
+
|
|
118
|
+
impressionSent.current = false;
|
|
119
|
+
|
|
120
|
+
const pageKey = getPageKey();
|
|
121
|
+
const dedupeKey = makeDedupeKey(experimentId, dedupeVariant, instanceId, pageKey);
|
|
122
|
+
|
|
123
|
+
if (hasSeen(dedupeKey)) {
|
|
124
|
+
impressionSent.current = true;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const el = containerRef.current;
|
|
129
|
+
if (!el) return;
|
|
130
|
+
|
|
131
|
+
// Fallback: no IntersectionObserver (SSR, old browser)
|
|
132
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
133
|
+
if (!impressionSent.current) {
|
|
134
|
+
impressionSent.current = true;
|
|
135
|
+
markSeen(dedupeKey);
|
|
136
|
+
sendMetric(host, impressionEventName, eventProps);
|
|
137
|
+
if (debug) console.log(`[probat] Impression sent (no IO) for "${experimentId}"`);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
143
|
+
|
|
144
|
+
const observer = new IntersectionObserver(
|
|
145
|
+
([entry]) => {
|
|
146
|
+
if (!entry || impressionSent.current) return;
|
|
147
|
+
|
|
148
|
+
if (entry.isIntersecting) {
|
|
149
|
+
timer = setTimeout(() => {
|
|
150
|
+
if (impressionSent.current) return;
|
|
151
|
+
impressionSent.current = true;
|
|
152
|
+
markSeen(dedupeKey);
|
|
153
|
+
sendMetric(host, impressionEventName, eventProps);
|
|
154
|
+
if (debug) console.log(`[probat] Impression sent for "${experimentId}"`);
|
|
155
|
+
observer.disconnect();
|
|
156
|
+
}, 250);
|
|
157
|
+
} else if (timer) {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
timer = null;
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
{ threshold: 0.5 }
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
observer.observe(el);
|
|
166
|
+
|
|
167
|
+
return () => {
|
|
168
|
+
observer.disconnect();
|
|
169
|
+
if (timer) clearTimeout(timer);
|
|
170
|
+
};
|
|
171
|
+
}, [
|
|
172
|
+
trackImpression,
|
|
173
|
+
experimentId,
|
|
174
|
+
dedupeVariant,
|
|
175
|
+
instanceId,
|
|
176
|
+
host,
|
|
177
|
+
impressionEventName,
|
|
178
|
+
eventProps,
|
|
179
|
+
debug,
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
// ── Click tracking ─────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
const handleClick = useCallback(
|
|
185
|
+
(e: Event) => {
|
|
186
|
+
if (!trackClick) return;
|
|
187
|
+
|
|
188
|
+
const meta = extractClickMeta(e.target as EventTarget);
|
|
189
|
+
if (!meta) return;
|
|
190
|
+
|
|
191
|
+
sendMetric(host, clickEventName, {
|
|
192
|
+
...eventProps,
|
|
193
|
+
...meta,
|
|
194
|
+
});
|
|
195
|
+
if (debug) {
|
|
196
|
+
console.log(`[probat] Click tracked for "${experimentId}"`, meta);
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
[trackClick, host, clickEventName, eventProps, experimentId, debug]
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
const el = containerRef.current;
|
|
204
|
+
if (!el || !trackClick) return;
|
|
205
|
+
|
|
206
|
+
el.addEventListener("click", handleClick);
|
|
207
|
+
return () => {
|
|
208
|
+
el.removeEventListener("click", handleClick);
|
|
209
|
+
};
|
|
210
|
+
}, [handleClick, trackClick]);
|
|
211
|
+
|
|
212
|
+
return containerRef;
|
|
213
|
+
}
|
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, UseTrackExplicitOptions, UseTrackCustomerOptions } 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";
|