@probat/react 0.2.1 → 0.3.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/README.md +33 -344
- package/dist/index.d.mts +76 -247
- package/dist/index.d.ts +76 -247
- package/dist/index.js +395 -1357
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +392 -1341
- package/dist/index.mjs.map +1 -1
- package/package.json +17 -11
- 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 -152
- 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 -10
- package/src/hoc/withExperiment.tsx +0 -311
- package/src/hooks/useExperiment.ts +0 -188
- package/src/utils/documentClickTracker.ts +0 -215
- package/src/utils/heatmapTracker.ts +0 -665
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import "@testing-library/jest-dom/vitest";
|
|
2
|
+
|
|
3
|
+
// Mock IntersectionObserver
|
|
4
|
+
class MockIntersectionObserver implements IntersectionObserver {
|
|
5
|
+
readonly root: Element | null = null;
|
|
6
|
+
readonly rootMargin: string = "";
|
|
7
|
+
readonly thresholds: ReadonlyArray<number> = [];
|
|
8
|
+
|
|
9
|
+
private callback: IntersectionObserverCallback;
|
|
10
|
+
private elements: Set<Element> = new Set();
|
|
11
|
+
|
|
12
|
+
constructor(callback: IntersectionObserverCallback, _options?: IntersectionObserverInit) {
|
|
13
|
+
this.callback = callback;
|
|
14
|
+
MockIntersectionObserver._instances.push(this);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
observe(el: Element) {
|
|
18
|
+
this.elements.add(el);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
unobserve(el: Element) {
|
|
22
|
+
this.elements.delete(el);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
disconnect() {
|
|
26
|
+
this.elements.clear();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
takeRecords(): IntersectionObserverEntry[] {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Test helpers
|
|
34
|
+
static _instances: MockIntersectionObserver[] = [];
|
|
35
|
+
|
|
36
|
+
static _reset() {
|
|
37
|
+
MockIntersectionObserver._instances = [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_trigger(isIntersecting: boolean) {
|
|
41
|
+
const entries: IntersectionObserverEntry[] = Array.from(this.elements).map(
|
|
42
|
+
(el) =>
|
|
43
|
+
({
|
|
44
|
+
target: el,
|
|
45
|
+
isIntersecting,
|
|
46
|
+
intersectionRatio: isIntersecting ? 0.6 : 0,
|
|
47
|
+
boundingClientRect: {} as DOMRectReadOnly,
|
|
48
|
+
intersectionRect: {} as DOMRectReadOnly,
|
|
49
|
+
rootBounds: null,
|
|
50
|
+
time: Date.now(),
|
|
51
|
+
}) as IntersectionObserverEntry
|
|
52
|
+
);
|
|
53
|
+
this.callback(entries, this);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
Object.defineProperty(globalThis, "IntersectionObserver", {
|
|
58
|
+
value: MockIntersectionObserver,
|
|
59
|
+
writable: true,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Export for test use
|
|
63
|
+
export { MockIntersectionObserver };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { makeDedupeKey, hasSeen, markSeen, resetDedupe } from "../utils/dedupeStorage";
|
|
3
|
+
import { getDistinctId, getSessionId, buildEventContext } from "../utils/eventContext";
|
|
4
|
+
|
|
5
|
+
// ── dedupeStorage ──────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
describe("dedupeStorage", () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
sessionStorage.clear();
|
|
10
|
+
resetDedupe();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("makeDedupeKey produces correct format", () => {
|
|
14
|
+
const key = makeDedupeKey("exp1", "ai_v1", "inst_1", "/pricing?plan=pro");
|
|
15
|
+
expect(key).toBe("probat:seen:exp1:ai_v1:inst_1:/pricing?plan=pro");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("hasSeen returns false for unseen keys", () => {
|
|
19
|
+
expect(hasSeen("probat:seen:new:key:a:b")).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("markSeen + hasSeen round-trips", () => {
|
|
23
|
+
const key = makeDedupeKey("exp1", "control", "inst_1", "/");
|
|
24
|
+
expect(hasSeen(key)).toBe(false);
|
|
25
|
+
markSeen(key);
|
|
26
|
+
expect(hasSeen(key)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("resetDedupe clears in-memory set", () => {
|
|
30
|
+
const key = makeDedupeKey("exp1", "control", "inst_1", "/");
|
|
31
|
+
markSeen(key);
|
|
32
|
+
expect(hasSeen(key)).toBe(true);
|
|
33
|
+
resetDedupe();
|
|
34
|
+
// sessionStorage still has it, so it should still be seen
|
|
35
|
+
expect(hasSeen(key)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("resetDedupe + clear sessionStorage fully resets", () => {
|
|
39
|
+
const key = makeDedupeKey("exp1", "control", "inst_1", "/");
|
|
40
|
+
markSeen(key);
|
|
41
|
+
resetDedupe();
|
|
42
|
+
sessionStorage.clear();
|
|
43
|
+
expect(hasSeen(key)).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ── eventContext ────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
describe("eventContext", () => {
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
localStorage.clear();
|
|
52
|
+
sessionStorage.clear();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("getDistinctId returns a stable id", () => {
|
|
56
|
+
// Clear the module-level cache by removing from storage and re-importing
|
|
57
|
+
// Since the module caches in a variable, we just check consistency
|
|
58
|
+
const id1 = getDistinctId();
|
|
59
|
+
const id2 = getDistinctId();
|
|
60
|
+
expect(id1).toBe(id2);
|
|
61
|
+
expect(id1).toMatch(/^anon_|^server$/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("getSessionId returns a stable id within session", () => {
|
|
65
|
+
const id1 = getSessionId();
|
|
66
|
+
const id2 = getSessionId();
|
|
67
|
+
expect(id1).toBe(id2);
|
|
68
|
+
expect(id1).toMatch(/^sess_|^server$/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("buildEventContext includes all required fields", () => {
|
|
72
|
+
const ctx = buildEventContext();
|
|
73
|
+
expect(ctx).toHaveProperty("distinct_id");
|
|
74
|
+
expect(ctx).toHaveProperty("session_id");
|
|
75
|
+
expect(ctx).toHaveProperty("$page_url");
|
|
76
|
+
expect(ctx).toHaveProperty("$pathname");
|
|
77
|
+
expect(ctx).toHaveProperty("$referrer");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
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
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export interface ExperimentTrackOptions {
|
|
48
|
+
/** Auto-track impressions (default true) */
|
|
49
|
+
impression?: boolean;
|
|
50
|
+
/** Auto-track clicks (default true) */
|
|
51
|
+
primaryClick?: boolean;
|
|
52
|
+
/** Custom impression event name (default "$experiment_exposure") */
|
|
53
|
+
impressionEventName?: string;
|
|
54
|
+
/** Custom click event name (default "$experiment_click") */
|
|
55
|
+
clickEventName?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ExperimentProps {
|
|
59
|
+
/** Experiment key / identifier */
|
|
60
|
+
id: string;
|
|
61
|
+
/** Control variant ReactNode */
|
|
62
|
+
control: React.ReactNode;
|
|
63
|
+
/** Named variant ReactNodes, keyed by variant key (e.g. { ai_v1: <MyVariant /> }) */
|
|
64
|
+
variants: Record<string, React.ReactNode>;
|
|
65
|
+
/** Tracking configuration */
|
|
66
|
+
track?: ExperimentTrackOptions;
|
|
67
|
+
/** Stable instance id when multiple instances of the same experiment exist on a page */
|
|
68
|
+
componentInstanceId?: string;
|
|
69
|
+
/** Behavior when assignment fetch fails: "control" (default) renders control, "suspend" throws */
|
|
70
|
+
fallback?: "control" | "suspend";
|
|
71
|
+
/** Log decisions + events to console */
|
|
72
|
+
debug?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function Experiment({
|
|
76
|
+
id,
|
|
77
|
+
control,
|
|
78
|
+
variants,
|
|
79
|
+
track,
|
|
80
|
+
componentInstanceId,
|
|
81
|
+
fallback = "control",
|
|
82
|
+
debug = false,
|
|
83
|
+
}: ExperimentProps) {
|
|
84
|
+
const { host, bootstrap } = useProbatContext();
|
|
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
|
+
// Sync resolution order: bootstrap → localStorage → "control"
|
|
100
|
+
if (bootstrap[id]) return bootstrap[id];
|
|
101
|
+
const cached = readAssignment(id);
|
|
102
|
+
if (cached) return cached;
|
|
103
|
+
return "control";
|
|
104
|
+
});
|
|
105
|
+
const [resolved, setResolved] = useState<boolean>(() => {
|
|
106
|
+
return !!(bootstrap[id] || readAssignment(id));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
// Already resolved from bootstrap or cache
|
|
111
|
+
if (bootstrap[id] || readAssignment(id)) {
|
|
112
|
+
// Ensure state is synced (StrictMode may re-mount)
|
|
113
|
+
const key = bootstrap[id] ?? readAssignment(id) ?? "control";
|
|
114
|
+
setVariantKey(key);
|
|
115
|
+
setResolved(true);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let cancelled = false;
|
|
120
|
+
|
|
121
|
+
(async () => {
|
|
122
|
+
try {
|
|
123
|
+
const distinctId = getDistinctId();
|
|
124
|
+
const key = await fetchDecision(host, id, distinctId);
|
|
125
|
+
if (cancelled) return;
|
|
126
|
+
|
|
127
|
+
// Validate variant key
|
|
128
|
+
if (key !== "control" && !(key in variants)) {
|
|
129
|
+
if (debug) {
|
|
130
|
+
console.warn(
|
|
131
|
+
`[probat] Unknown variant "${key}" for experiment "${id}", falling back to control`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
setVariantKey("control");
|
|
135
|
+
} else {
|
|
136
|
+
setVariantKey(key);
|
|
137
|
+
writeAssignment(id, key);
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (cancelled) return;
|
|
141
|
+
if (debug) {
|
|
142
|
+
console.error(`[probat] fetchDecision failed for "${id}":`, err);
|
|
143
|
+
}
|
|
144
|
+
if (fallback === "suspend") throw err;
|
|
145
|
+
setVariantKey("control");
|
|
146
|
+
} finally {
|
|
147
|
+
if (!cancelled) setResolved(true);
|
|
148
|
+
}
|
|
149
|
+
})();
|
|
150
|
+
|
|
151
|
+
return () => {
|
|
152
|
+
cancelled = true;
|
|
153
|
+
};
|
|
154
|
+
}, [id, host]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
155
|
+
|
|
156
|
+
// ── Debug logging ──────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (debug && resolved) {
|
|
160
|
+
console.log(`[probat] Experiment "${id}" → variant "${variantKey}"`, {
|
|
161
|
+
instanceId,
|
|
162
|
+
pageKey: getPageKey(),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}, [debug, id, variantKey, resolved, instanceId]);
|
|
166
|
+
|
|
167
|
+
// ── Shared event properties ────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
const eventProps = useMemo(
|
|
170
|
+
() => ({
|
|
171
|
+
experiment_id: id,
|
|
172
|
+
variant_key: variantKey,
|
|
173
|
+
component_instance_id: instanceId,
|
|
174
|
+
}),
|
|
175
|
+
[id, variantKey, instanceId]
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// ── Impression tracking via IntersectionObserver ────────────────────────
|
|
179
|
+
|
|
180
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
181
|
+
const impressionSent = useRef(false);
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (!trackImpression || !resolved) return;
|
|
185
|
+
|
|
186
|
+
// Reset on re-mount (StrictMode safety)
|
|
187
|
+
impressionSent.current = false;
|
|
188
|
+
|
|
189
|
+
const pageKey = getPageKey();
|
|
190
|
+
const dedupeKey = makeDedupeKey(id, variantKey, instanceId, pageKey);
|
|
191
|
+
|
|
192
|
+
// Already seen this session
|
|
193
|
+
if (hasSeen(dedupeKey)) {
|
|
194
|
+
impressionSent.current = true;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const el = containerRef.current;
|
|
199
|
+
if (!el) return;
|
|
200
|
+
|
|
201
|
+
// Fallback: no IntersectionObserver (SSR, old browser)
|
|
202
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
203
|
+
if (!impressionSent.current) {
|
|
204
|
+
impressionSent.current = true;
|
|
205
|
+
markSeen(dedupeKey);
|
|
206
|
+
sendMetric(host, impressionEvent, eventProps);
|
|
207
|
+
if (debug) console.log(`[probat] Impression sent (no IO) for "${id}"`);
|
|
208
|
+
}
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
213
|
+
|
|
214
|
+
const observer = new IntersectionObserver(
|
|
215
|
+
([entry]) => {
|
|
216
|
+
if (!entry || impressionSent.current) return;
|
|
217
|
+
|
|
218
|
+
if (entry.isIntersecting) {
|
|
219
|
+
timer = setTimeout(() => {
|
|
220
|
+
if (impressionSent.current) return;
|
|
221
|
+
impressionSent.current = true;
|
|
222
|
+
markSeen(dedupeKey);
|
|
223
|
+
sendMetric(host, impressionEvent, eventProps);
|
|
224
|
+
if (debug) console.log(`[probat] Impression sent for "${id}"`);
|
|
225
|
+
observer.disconnect();
|
|
226
|
+
}, 250);
|
|
227
|
+
} else if (timer) {
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
timer = null;
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
{ threshold: 0.5 }
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
observer.observe(el);
|
|
236
|
+
|
|
237
|
+
return () => {
|
|
238
|
+
observer.disconnect();
|
|
239
|
+
if (timer) clearTimeout(timer);
|
|
240
|
+
};
|
|
241
|
+
}, [
|
|
242
|
+
trackImpression,
|
|
243
|
+
resolved,
|
|
244
|
+
id,
|
|
245
|
+
variantKey,
|
|
246
|
+
instanceId,
|
|
247
|
+
host,
|
|
248
|
+
impressionEvent,
|
|
249
|
+
eventProps,
|
|
250
|
+
debug,
|
|
251
|
+
]);
|
|
252
|
+
|
|
253
|
+
// ── Click tracking ─────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
const handleClick = useCallback(
|
|
256
|
+
(e: React.MouseEvent) => {
|
|
257
|
+
if (!trackClick) return;
|
|
258
|
+
|
|
259
|
+
const meta = extractClickMeta(e.target as EventTarget);
|
|
260
|
+
if (!meta) return;
|
|
261
|
+
|
|
262
|
+
sendMetric(host, clickEvent, {
|
|
263
|
+
...eventProps,
|
|
264
|
+
...meta,
|
|
265
|
+
});
|
|
266
|
+
if (debug) {
|
|
267
|
+
console.log(`[probat] Click tracked for "${id}"`, meta);
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
[trackClick, host, clickEvent, eventProps, id, debug]
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// ── Render ─────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
const content =
|
|
276
|
+
variantKey === "control" || !(variantKey in variants)
|
|
277
|
+
? control
|
|
278
|
+
: variants[variantKey];
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<div
|
|
282
|
+
ref={containerRef}
|
|
283
|
+
onClick={handleClick}
|
|
284
|
+
data-probat-experiment={id}
|
|
285
|
+
data-probat-variant={variantKey}
|
|
286
|
+
style={{ display: "block", margin: 0, padding: 0 }}
|
|
287
|
+
>
|
|
288
|
+
{content}
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import React from "react";
|
|
4
|
-
import { ProbatProvider
|
|
4
|
+
import { ProbatProvider } from "../context/ProbatContext";
|
|
5
5
|
import type { ProbatProviderProps } from "../context/ProbatContext";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Client-only provider for Next.js App Router.
|
|
9
|
+
* Import this in your layout/providers file.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```tsx
|
|
13
|
+
* // app/providers.tsx
|
|
14
|
+
* "use client";
|
|
15
|
+
* import { ProbatProviderClient } from "@probat/react";
|
|
16
|
+
*
|
|
17
|
+
* export function Providers({ children }) {
|
|
18
|
+
* return (
|
|
19
|
+
* <ProbatProviderClient userId="your-user-uuid">
|
|
20
|
+
* {children}
|
|
21
|
+
* </ProbatProviderClient>
|
|
22
|
+
* );
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
10
25
|
*/
|
|
11
26
|
export function ProbatProviderClient(props: ProbatProviderProps) {
|
|
12
|
-
return React.createElement(
|
|
27
|
+
return React.createElement(ProbatProvider, props);
|
|
13
28
|
}
|
|
14
29
|
|
|
15
|
-
// Also export as ProbatProvider for convenience
|
|
16
|
-
export { ProbatProviderClient as ProbatProvider };
|
|
17
30
|
export type { ProbatProviderProps };
|
|
18
|
-
|
|
@@ -1,181 +1,59 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import React, { createContext, useContext, useMemo
|
|
4
|
-
import { detectEnvironment } from "../utils/environment";
|
|
5
|
-
import { initHeatmapTracking, stopHeatmapTracking } from "../utils/heatmapTracker";
|
|
6
|
-
|
|
7
|
-
declare global {
|
|
8
|
-
interface Window {
|
|
9
|
-
__PROBAT_API?: string;
|
|
10
|
-
}
|
|
11
|
-
}
|
|
3
|
+
import React, { createContext, useContext, useMemo } from "react";
|
|
12
4
|
|
|
13
5
|
export interface ProbatContextValue {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
repoFullName?: string; // Repository full name (e.g., "owner/repo") for component-based experiments
|
|
18
|
-
proposalId?: string;
|
|
19
|
-
variantLabel?: string;
|
|
6
|
+
host: string;
|
|
7
|
+
userId: string;
|
|
8
|
+
bootstrap: Record<string, string>;
|
|
20
9
|
}
|
|
21
10
|
|
|
22
11
|
const ProbatContext = createContext<ProbatContextValue | null>(null);
|
|
23
12
|
|
|
13
|
+
const DEFAULT_HOST = "https://gushi.onrender.com";
|
|
14
|
+
|
|
24
15
|
export interface ProbatProviderProps {
|
|
16
|
+
/** Gushi user ID (UUID) — used to scope SDK requests to a customer */
|
|
17
|
+
userId: string;
|
|
18
|
+
/** Base URL for the Probat API. Defaults to https://gushi.onrender.com */
|
|
19
|
+
host?: string;
|
|
25
20
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* -
|
|
29
|
-
* - NEXT_PUBLIC_PROBAT_API (Next.js)
|
|
30
|
-
* - window.__PROBAT_API
|
|
31
|
-
* - Default: "https://gushi.onrender.com"
|
|
32
|
-
*/
|
|
33
|
-
apiBaseUrl?: string;
|
|
34
|
-
/**
|
|
35
|
-
* Optional: proposal/experiment id for heatmap segregation
|
|
36
|
-
*/
|
|
37
|
-
proposalId?: string;
|
|
38
|
-
/**
|
|
39
|
-
* Optional: variant label for heatmap segregation
|
|
40
|
-
*/
|
|
41
|
-
variantLabel?: string;
|
|
42
|
-
/**
|
|
43
|
-
* Client key for identification (optional)
|
|
44
|
-
*/
|
|
45
|
-
clientKey?: string;
|
|
46
|
-
/**
|
|
47
|
-
* Explicitly set environment. If not provided, will auto-detect based on hostname.
|
|
48
|
-
* "dev" for localhost, "prod" for production.
|
|
49
|
-
*/
|
|
50
|
-
environment?: "dev" | "prod";
|
|
51
|
-
/**
|
|
52
|
-
* Repository full name (e.g., "owner/repo") for component-based experiments.
|
|
53
|
-
* If not provided, will try to read from:
|
|
54
|
-
* - NEXT_PUBLIC_PROBAT_REPO (Next.js)
|
|
55
|
-
* - VITE_PROBAT_REPO (Vite)
|
|
56
|
-
* - window.__PROBAT_REPO
|
|
21
|
+
* Bootstrap assignments to avoid flash on first render.
|
|
22
|
+
* Map of experiment id → variant key.
|
|
23
|
+
* e.g. { "cta-copy-test": "ai_v1" }
|
|
57
24
|
*/
|
|
58
|
-
|
|
25
|
+
bootstrap?: Record<string, string>;
|
|
59
26
|
children: React.ReactNode;
|
|
60
27
|
}
|
|
61
28
|
|
|
62
29
|
export function ProbatProvider({
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
repoFullName: explicitRepoFullName,
|
|
67
|
-
proposalId,
|
|
68
|
-
variantLabel,
|
|
30
|
+
userId,
|
|
31
|
+
host = DEFAULT_HOST,
|
|
32
|
+
bootstrap,
|
|
69
33
|
children,
|
|
70
34
|
}: ProbatProviderProps) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
: undefined;
|
|
80
|
-
|
|
81
|
-
const contextValue = useMemo<ProbatContextValue>(() => {
|
|
82
|
-
// Determine API base URL
|
|
83
|
-
const resolvedApiBaseUrl =
|
|
84
|
-
apiBaseUrl ||
|
|
85
|
-
(typeof import.meta !== "undefined" &&
|
|
86
|
-
(import.meta as any).env?.VITE_PROBAT_API) ||
|
|
87
|
-
(typeof globalThis !== "undefined" &&
|
|
88
|
-
(globalThis as any).process?.env?.NEXT_PUBLIC_PROBAT_API) ||
|
|
89
|
-
(typeof window !== "undefined" && window.__PROBAT_API) ||
|
|
90
|
-
"https://gushi.onrender.com";
|
|
91
|
-
|
|
92
|
-
// Determine environment
|
|
93
|
-
const environment = explicitEnvironment || detectEnvironment();
|
|
94
|
-
|
|
95
|
-
// Determine repo full name
|
|
96
|
-
const resolvedRepoFullName =
|
|
97
|
-
explicitRepoFullName ||
|
|
98
|
-
(typeof globalThis !== "undefined" &&
|
|
99
|
-
(globalThis as any).process?.env?.NEXT_PUBLIC_PROBAT_REPO) ||
|
|
100
|
-
(typeof import.meta !== "undefined" &&
|
|
101
|
-
(import.meta as any).env?.VITE_PROBAT_REPO) ||
|
|
102
|
-
(typeof window !== "undefined" && (window as any).__PROBAT_REPO) ||
|
|
103
|
-
undefined;
|
|
104
|
-
|
|
105
|
-
// Check for URL overrides (used for Live Heatmap visualization)
|
|
106
|
-
const params = (typeof window !== "undefined") ? new URLSearchParams(window.location.search) : null;
|
|
107
|
-
const isHeatmapMode = params?.get('heatmap') === 'true';
|
|
108
|
-
|
|
109
|
-
let urlProposalId: string | undefined;
|
|
110
|
-
let urlVariantLabel: string | undefined;
|
|
111
|
-
|
|
112
|
-
if (isHeatmapMode && params) {
|
|
113
|
-
urlProposalId = params.get('proposal_id') || undefined;
|
|
114
|
-
urlVariantLabel = params.get('variant_label') || undefined;
|
|
115
|
-
console.log('[PROBAT] Heatmap mode: Overriding variant from URL', { urlProposalId, urlVariantLabel });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Priority Logic:
|
|
119
|
-
// 1. URL params (if in heatmap mode)
|
|
120
|
-
// 2. Explicit props passed to Provider
|
|
121
|
-
// 3. Stored values in localStorage (ONLY if NOT in heatmap mode)
|
|
122
|
-
const finalProposalId = urlProposalId || proposalId || (!isHeatmapMode ? storedProposalId : undefined);
|
|
123
|
-
const finalVariantLabel = urlVariantLabel || variantLabel || (!isHeatmapMode ? storedVariantLabel : undefined);
|
|
124
|
-
|
|
125
|
-
return {
|
|
126
|
-
apiBaseUrl: resolvedApiBaseUrl,
|
|
127
|
-
environment,
|
|
128
|
-
clientKey,
|
|
129
|
-
repoFullName: resolvedRepoFullName,
|
|
130
|
-
proposalId: finalProposalId,
|
|
131
|
-
variantLabel: finalVariantLabel,
|
|
132
|
-
};
|
|
133
|
-
}, [apiBaseUrl, clientKey, explicitEnvironment, explicitRepoFullName, proposalId, variantLabel, storedProposalId, storedVariantLabel]);
|
|
134
|
-
|
|
135
|
-
// Initialize heatmap tracking when provider mounts
|
|
136
|
-
useEffect(() => {
|
|
137
|
-
// Only initialize on client-side
|
|
138
|
-
if (typeof window !== 'undefined') {
|
|
139
|
-
initHeatmapTracking({
|
|
140
|
-
apiBaseUrl: contextValue.apiBaseUrl,
|
|
141
|
-
batchSize: 10,
|
|
142
|
-
batchInterval: 5000,
|
|
143
|
-
enabled: true,
|
|
144
|
-
excludeSelectors: [
|
|
145
|
-
'[data-heatmap-exclude]',
|
|
146
|
-
'input[type="password"]',
|
|
147
|
-
'input[type="email"]',
|
|
148
|
-
'textarea',
|
|
149
|
-
],
|
|
150
|
-
// Explicitly enable cursor tracking with sensible defaults
|
|
151
|
-
trackCursor: true,
|
|
152
|
-
cursorThrottle: 100, // capture every 100ms
|
|
153
|
-
cursorBatchSize: 50, // send every 50 movements (or after batchInterval)
|
|
154
|
-
proposalId: contextValue.proposalId,
|
|
155
|
-
variantLabel: contextValue.variantLabel,
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Cleanup on unmount
|
|
160
|
-
return () => {
|
|
161
|
-
stopHeatmapTracking();
|
|
162
|
-
};
|
|
163
|
-
}, [contextValue.apiBaseUrl, contextValue.proposalId, contextValue.variantLabel]);
|
|
35
|
+
const value = useMemo<ProbatContextValue>(
|
|
36
|
+
() => ({
|
|
37
|
+
host: host.replace(/\/$/, ""),
|
|
38
|
+
userId,
|
|
39
|
+
bootstrap: bootstrap ?? {},
|
|
40
|
+
}),
|
|
41
|
+
[userId, host, bootstrap]
|
|
42
|
+
);
|
|
164
43
|
|
|
165
44
|
return (
|
|
166
|
-
<ProbatContext.Provider value={
|
|
45
|
+
<ProbatContext.Provider value={value}>
|
|
167
46
|
{children}
|
|
168
47
|
</ProbatContext.Provider>
|
|
169
48
|
);
|
|
170
49
|
}
|
|
171
50
|
|
|
172
51
|
export function useProbatContext(): ProbatContextValue {
|
|
173
|
-
const
|
|
174
|
-
if (!
|
|
52
|
+
const ctx = useContext(ProbatContext);
|
|
53
|
+
if (!ctx) {
|
|
175
54
|
throw new Error(
|
|
176
|
-
"useProbatContext must be used within
|
|
55
|
+
"useProbatContext must be used within <ProbatProviderClient>. Wrap your app with <ProbatProviderClient userId={...}>."
|
|
177
56
|
);
|
|
178
57
|
}
|
|
179
|
-
return
|
|
58
|
+
return ctx;
|
|
180
59
|
}
|
|
181
|
-
|