@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,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dedupe storage for experiment exposures.
|
|
3
|
+
* Uses sessionStorage + in-memory Set fallback.
|
|
4
|
+
* Key format: probat:seen:{id}:{variantKey}:{instanceId}:{pageKey}
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const PREFIX = "probat:seen:";
|
|
8
|
+
const memorySet = new Set<string>();
|
|
9
|
+
|
|
10
|
+
export function makeDedupeKey(
|
|
11
|
+
experimentId: string,
|
|
12
|
+
variantKey: string,
|
|
13
|
+
instanceId: string,
|
|
14
|
+
pageKey: string
|
|
15
|
+
): string {
|
|
16
|
+
return `${PREFIX}${experimentId}:${variantKey}:${instanceId}:${pageKey}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function hasSeen(key: string): boolean {
|
|
20
|
+
if (memorySet.has(key)) return true;
|
|
21
|
+
if (typeof window === "undefined") return false;
|
|
22
|
+
try {
|
|
23
|
+
return sessionStorage.getItem(key) === "1";
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function markSeen(key: string): void {
|
|
30
|
+
memorySet.add(key);
|
|
31
|
+
if (typeof window === "undefined") return;
|
|
32
|
+
try {
|
|
33
|
+
sessionStorage.setItem(key, "1");
|
|
34
|
+
} catch {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Reset all dedupe state — useful for testing. */
|
|
38
|
+
export function resetDedupe(): void {
|
|
39
|
+
memorySet.clear();
|
|
40
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event context helpers: distinct_id, session_id, page info.
|
|
3
|
+
* All browser-safe — no-ops when window is unavailable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DISTINCT_ID_KEY = "probat:distinct_id";
|
|
7
|
+
const SESSION_ID_KEY = "probat:session_id";
|
|
8
|
+
|
|
9
|
+
let cachedDistinctId: string | null = null;
|
|
10
|
+
let cachedSessionId: string | null = null;
|
|
11
|
+
|
|
12
|
+
function generateId(): string {
|
|
13
|
+
// crypto.randomUUID where available, else fallback
|
|
14
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
15
|
+
return crypto.randomUUID();
|
|
16
|
+
}
|
|
17
|
+
// fallback: random hex
|
|
18
|
+
const bytes = new Uint8Array(16);
|
|
19
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
20
|
+
crypto.getRandomValues(bytes);
|
|
21
|
+
} else {
|
|
22
|
+
for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
23
|
+
}
|
|
24
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getDistinctId(): string {
|
|
28
|
+
if (cachedDistinctId) return cachedDistinctId;
|
|
29
|
+
if (typeof window === "undefined") return "server";
|
|
30
|
+
try {
|
|
31
|
+
const stored = localStorage.getItem(DISTINCT_ID_KEY);
|
|
32
|
+
if (stored) {
|
|
33
|
+
cachedDistinctId = stored;
|
|
34
|
+
return stored;
|
|
35
|
+
}
|
|
36
|
+
} catch {}
|
|
37
|
+
const id = `anon_${generateId()}`;
|
|
38
|
+
cachedDistinctId = id;
|
|
39
|
+
try {
|
|
40
|
+
localStorage.setItem(DISTINCT_ID_KEY, id);
|
|
41
|
+
} catch {}
|
|
42
|
+
return id;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getSessionId(): string {
|
|
46
|
+
if (cachedSessionId) return cachedSessionId;
|
|
47
|
+
if (typeof window === "undefined") return "server";
|
|
48
|
+
try {
|
|
49
|
+
const stored = sessionStorage.getItem(SESSION_ID_KEY);
|
|
50
|
+
if (stored) {
|
|
51
|
+
cachedSessionId = stored;
|
|
52
|
+
return stored;
|
|
53
|
+
}
|
|
54
|
+
} catch {}
|
|
55
|
+
const id = `sess_${generateId()}`;
|
|
56
|
+
cachedSessionId = id;
|
|
57
|
+
try {
|
|
58
|
+
sessionStorage.setItem(SESSION_ID_KEY, id);
|
|
59
|
+
} catch {}
|
|
60
|
+
return id;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getPageKey(): string {
|
|
64
|
+
if (typeof window === "undefined") return "";
|
|
65
|
+
return window.location.pathname + window.location.search;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getPageUrl(): string {
|
|
69
|
+
if (typeof window === "undefined") return "";
|
|
70
|
+
return window.location.href;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getReferrer(): string {
|
|
74
|
+
if (typeof document === "undefined") return "";
|
|
75
|
+
return document.referrer;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface EventContext {
|
|
79
|
+
distinct_id: string;
|
|
80
|
+
session_id: string;
|
|
81
|
+
$page_url: string;
|
|
82
|
+
$pathname: string;
|
|
83
|
+
$referrer: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function buildEventContext(): EventContext {
|
|
87
|
+
return {
|
|
88
|
+
distinct_id: getDistinctId(),
|
|
89
|
+
session_id: getSessionId(),
|
|
90
|
+
$page_url: getPageUrl(),
|
|
91
|
+
$pathname: typeof window !== "undefined" ? window.location.pathname : "",
|
|
92
|
+
$referrer: getReferrer(),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable auto-generated instance IDs for <Experiment />.
|
|
3
|
+
*
|
|
4
|
+
* Problem: a naïve useRef + module counter gives a different ID on every mount,
|
|
5
|
+
* so StrictMode double-mount or unmount/remount changes the dedupe key.
|
|
6
|
+
*
|
|
7
|
+
* Solution:
|
|
8
|
+
* 1. React 18+ → useId() is stable per fiber position.
|
|
9
|
+
* 2. Fallback → sessionStorage-backed slot counter per (experimentId, pageKey).
|
|
10
|
+
* 3. Both paths persist a mapping in sessionStorage:
|
|
11
|
+
* probat:instance:{experimentId}:{pageKey}:{positionKey} → stableId
|
|
12
|
+
* so the same position resolves to the same ID across mounts.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React, { useRef } from "react";
|
|
16
|
+
import { getPageKey } from "./eventContext";
|
|
17
|
+
|
|
18
|
+
const INSTANCE_PREFIX = "probat:instance:";
|
|
19
|
+
|
|
20
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function shortId(): string {
|
|
23
|
+
const bytes = new Uint8Array(4);
|
|
24
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
25
|
+
crypto.getRandomValues(bytes);
|
|
26
|
+
} else {
|
|
27
|
+
for (let i = 0; i < 4; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
28
|
+
}
|
|
29
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Look up or create a stable instance ID in sessionStorage.
|
|
34
|
+
*/
|
|
35
|
+
function resolveStableId(storageKey: string): string {
|
|
36
|
+
if (typeof window !== "undefined") {
|
|
37
|
+
try {
|
|
38
|
+
const stored = sessionStorage.getItem(storageKey);
|
|
39
|
+
if (stored) return stored;
|
|
40
|
+
} catch {}
|
|
41
|
+
}
|
|
42
|
+
const id = `inst_${shortId()}`;
|
|
43
|
+
if (typeof window !== "undefined") {
|
|
44
|
+
try {
|
|
45
|
+
sessionStorage.setItem(storageKey, id);
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Fallback: render-wave slot counter ─────────────────────────────────────
|
|
52
|
+
// Each synchronous render batch claims sequential slots per (experimentId,
|
|
53
|
+
// pageKey). A microtask resets the counters so the next batch starts at 0,
|
|
54
|
+
// giving the same component position the same slot across mounts.
|
|
55
|
+
|
|
56
|
+
const slotCounters = new Map<string, number>();
|
|
57
|
+
let resetScheduled = false;
|
|
58
|
+
|
|
59
|
+
function claimSlot(groupKey: string): number {
|
|
60
|
+
const idx = slotCounters.get(groupKey) ?? 0;
|
|
61
|
+
slotCounters.set(groupKey, idx + 1);
|
|
62
|
+
if (!resetScheduled) {
|
|
63
|
+
resetScheduled = true;
|
|
64
|
+
Promise.resolve().then(() => {
|
|
65
|
+
slotCounters.clear();
|
|
66
|
+
resetScheduled = false;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return idx;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Hook: React 18+ path (useId available) ─────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function useStableInstanceIdV18(experimentId: string): string {
|
|
75
|
+
const reactId = (React as any).useId() as string;
|
|
76
|
+
const ref = useRef("");
|
|
77
|
+
if (!ref.current) {
|
|
78
|
+
const key = `${INSTANCE_PREFIX}${experimentId}:${getPageKey()}:${reactId}`;
|
|
79
|
+
ref.current = resolveStableId(key);
|
|
80
|
+
}
|
|
81
|
+
return ref.current;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Hook: fallback path (no useId) ─────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
function useStableInstanceIdFallback(experimentId: string): string {
|
|
87
|
+
const slotRef = useRef(-1);
|
|
88
|
+
const ref = useRef("");
|
|
89
|
+
if (slotRef.current === -1) {
|
|
90
|
+
slotRef.current = claimSlot(`${experimentId}:${getPageKey()}`);
|
|
91
|
+
}
|
|
92
|
+
if (!ref.current) {
|
|
93
|
+
const key = `${INSTANCE_PREFIX}${experimentId}:${getPageKey()}:${slotRef.current}`;
|
|
94
|
+
ref.current = resolveStableId(key);
|
|
95
|
+
}
|
|
96
|
+
return ref.current;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Exported hook ──────────────────────────────────────────────────────────
|
|
100
|
+
// Selection is a module-level constant so the hook-call count never changes
|
|
101
|
+
// between renders — safe for the rules of hooks.
|
|
102
|
+
|
|
103
|
+
export const useStableInstanceId: (experimentId: string) => string =
|
|
104
|
+
typeof (React as any).useId === "function"
|
|
105
|
+
? useStableInstanceIdV18
|
|
106
|
+
: useStableInstanceIdFallback;
|
|
107
|
+
|
|
108
|
+
// ── Test utility ───────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
export function resetInstanceIdState(): void {
|
|
111
|
+
slotCounters.clear();
|
|
112
|
+
resetScheduled = false;
|
|
113
|
+
}
|
package/src/utils/storage.ts
CHANGED
|
@@ -1,77 +1,35 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Safe localStorage/sessionStorage helpers.
|
|
3
|
+
*/
|
|
2
4
|
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
label: string;
|
|
6
|
-
ts: number;
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
export function safeGet(k: string): any | null {
|
|
5
|
+
export function safeGetLocal(key: string): string | null {
|
|
6
|
+
if (typeof window === "undefined") return null;
|
|
10
7
|
try {
|
|
11
|
-
|
|
12
|
-
return raw ? JSON.parse(raw) : null;
|
|
8
|
+
return localStorage.getItem(key);
|
|
13
9
|
} catch {
|
|
14
10
|
return null;
|
|
15
11
|
}
|
|
16
12
|
}
|
|
17
13
|
|
|
18
|
-
export function
|
|
14
|
+
export function safeSetLocal(key: string, value: string): void {
|
|
15
|
+
if (typeof window === "undefined") return;
|
|
19
16
|
try {
|
|
20
|
-
localStorage.setItem(
|
|
21
|
-
} catch {
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function now() {
|
|
25
|
-
return Date.now();
|
|
17
|
+
localStorage.setItem(key, value);
|
|
18
|
+
} catch {}
|
|
26
19
|
}
|
|
27
20
|
|
|
28
|
-
export function
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export const KEY = (proposalId: string) => `probat_choice_v3:${proposalId}`;
|
|
33
|
-
export const VISIT_KEY = (proposalId: string, label: string) =>
|
|
34
|
-
`probat_visit_v1:${proposalId}:${label}`;
|
|
35
|
-
|
|
36
|
-
export function readChoice(proposalId: string): Choice | null {
|
|
37
|
-
const c = safeGet(KEY(proposalId)) as Choice | null;
|
|
38
|
-
return c && fresh(c.ts) ? c : null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function writeChoice(
|
|
42
|
-
proposalId: string,
|
|
43
|
-
experiment_id: string,
|
|
44
|
-
label: string
|
|
45
|
-
) {
|
|
46
|
-
safeSet(KEY(proposalId), { experiment_id, label, ts: now() } as Choice);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const visitMemo = new Set<string>();
|
|
50
|
-
|
|
51
|
-
export function hasTrackedVisit(proposalId: string, label: string): boolean {
|
|
52
|
-
const key = VISIT_KEY(proposalId, label);
|
|
53
|
-
if (visitMemo.has(key)) return true;
|
|
21
|
+
export function safeGetSession(key: string): string | null {
|
|
22
|
+
if (typeof window === "undefined") return null;
|
|
54
23
|
try {
|
|
55
|
-
|
|
56
|
-
if (!raw) return false;
|
|
57
|
-
const ts = Number(raw);
|
|
58
|
-
if (!Number.isFinite(ts) || ts <= 0) return false;
|
|
59
|
-
if (now() - ts > TTL_MS) {
|
|
60
|
-
localStorage.removeItem(key);
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
|
-
visitMemo.add(key);
|
|
64
|
-
return true;
|
|
24
|
+
return sessionStorage.getItem(key);
|
|
65
25
|
} catch {
|
|
66
|
-
return
|
|
26
|
+
return null;
|
|
67
27
|
}
|
|
68
28
|
}
|
|
69
29
|
|
|
70
|
-
export function
|
|
71
|
-
|
|
72
|
-
visitMemo.add(key);
|
|
30
|
+
export function safeSetSession(key: string, value: string): void {
|
|
31
|
+
if (typeof window === "undefined") return;
|
|
73
32
|
try {
|
|
74
|
-
|
|
75
|
-
} catch {
|
|
33
|
+
sessionStorage.setItem(key, value);
|
|
34
|
+
} catch {}
|
|
76
35
|
}
|
|
77
|
-
|
|
@@ -1,311 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import React, { useState, useEffect, useCallback } from "react";
|
|
4
|
-
import type { MouseEvent } from "react";
|
|
5
|
-
import { useProbatContext } from "../context/ProbatContext";
|
|
6
|
-
import {
|
|
7
|
-
fetchDecision,
|
|
8
|
-
fetchComponentExperimentConfig,
|
|
9
|
-
loadVariantComponent,
|
|
10
|
-
sendMetric,
|
|
11
|
-
extractClickMeta,
|
|
12
|
-
} from "../utils/api";
|
|
13
|
-
import {
|
|
14
|
-
readChoice,
|
|
15
|
-
writeChoice,
|
|
16
|
-
hasTrackedVisit,
|
|
17
|
-
markTrackedVisit,
|
|
18
|
-
} from "../utils/storage";
|
|
19
|
-
|
|
20
|
-
// Support both old and new API for backward compatibility
|
|
21
|
-
export interface WithExperimentOptions {
|
|
22
|
-
// New API: component-based
|
|
23
|
-
componentPath?: string;
|
|
24
|
-
repoFullName?: string;
|
|
25
|
-
|
|
26
|
-
// Old API: direct proposal/registry (for backward compatibility)
|
|
27
|
-
proposalId?: string;
|
|
28
|
-
registry?: Record<string, React.ComponentType<any>>;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Higher-Order Component for wrapping components with experiment variants
|
|
33
|
-
*/
|
|
34
|
-
export function withExperiment<P = any>(
|
|
35
|
-
Control: React.ComponentType<P>,
|
|
36
|
-
options: WithExperimentOptions
|
|
37
|
-
): React.ComponentType<P & { probat?: { trackClick: () => void } }> {
|
|
38
|
-
// Validate inputs at HOC level (not in component)
|
|
39
|
-
if (!Control) {
|
|
40
|
-
console.error("[PROBAT] withExperiment: Control component is required");
|
|
41
|
-
return ((props: P) => null) as any;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (!options || typeof options !== 'object') {
|
|
45
|
-
console.error("[PROBAT] withExperiment: options is required");
|
|
46
|
-
return Control as any;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const useNewAPI = !!options.componentPath;
|
|
50
|
-
const useOldAPI = !!(options.proposalId && options.registry);
|
|
51
|
-
|
|
52
|
-
if (!useNewAPI && !useOldAPI) {
|
|
53
|
-
console.warn("[PROBAT] withExperiment: Invalid config, returning Control");
|
|
54
|
-
return Control as any;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const ControlComponent = Control;
|
|
58
|
-
|
|
59
|
-
function Wrapped(props: P) {
|
|
60
|
-
// ============================================================
|
|
61
|
-
// ALL HOOKS MUST BE AT THE TOP - BEFORE ANY CONDITIONAL RETURNS
|
|
62
|
-
// ============================================================
|
|
63
|
-
|
|
64
|
-
// 1. Context hook - always called first
|
|
65
|
-
const context = useProbatContext();
|
|
66
|
-
const apiBaseUrl = context?.apiBaseUrl || "https://gushi.onrender.com";
|
|
67
|
-
const contextRepoFullName = context?.repoFullName;
|
|
68
|
-
|
|
69
|
-
// 2. State hooks - always called in same order
|
|
70
|
-
const [config, setConfig] = useState<{
|
|
71
|
-
proposalId: string;
|
|
72
|
-
variants: Record<string, React.ComponentType<any>>;
|
|
73
|
-
} | null>(null);
|
|
74
|
-
const [configLoading, setConfigLoading] = useState(useNewAPI);
|
|
75
|
-
const [choice, setChoice] = useState<{
|
|
76
|
-
experiment_id: string;
|
|
77
|
-
label: string;
|
|
78
|
-
} | null>(null);
|
|
79
|
-
|
|
80
|
-
// Derived values
|
|
81
|
-
const repoFullName = options.repoFullName || contextRepoFullName;
|
|
82
|
-
const proposalId = useNewAPI ? config?.proposalId : options.proposalId;
|
|
83
|
-
|
|
84
|
-
// 3. Effect hooks - always called
|
|
85
|
-
// Load component config (new API)
|
|
86
|
-
useEffect(() => {
|
|
87
|
-
if (!useNewAPI) return;
|
|
88
|
-
if (!repoFullName) {
|
|
89
|
-
console.warn("[PROBAT] componentPath provided but repoFullName not found");
|
|
90
|
-
setConfigLoading(false);
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
let alive = true;
|
|
95
|
-
setConfigLoading(true);
|
|
96
|
-
|
|
97
|
-
(async () => {
|
|
98
|
-
try {
|
|
99
|
-
const componentConfig = await fetchComponentExperimentConfig(
|
|
100
|
-
apiBaseUrl,
|
|
101
|
-
repoFullName,
|
|
102
|
-
options.componentPath!
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
if (!alive) return;
|
|
106
|
-
|
|
107
|
-
if (!componentConfig) {
|
|
108
|
-
setConfig(null);
|
|
109
|
-
setConfigLoading(false);
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const variantComponents: Record<string, React.ComponentType<any>> = {
|
|
114
|
-
control: ControlComponent,
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
for (const [label, variantInfo] of Object.entries(componentConfig.variants)) {
|
|
118
|
-
if (label === "control") continue;
|
|
119
|
-
if (variantInfo?.file_path) {
|
|
120
|
-
try {
|
|
121
|
-
const VariantComp = await loadVariantComponent(
|
|
122
|
-
apiBaseUrl,
|
|
123
|
-
componentConfig.proposal_id,
|
|
124
|
-
variantInfo.experiment_id,
|
|
125
|
-
variantInfo.file_path,
|
|
126
|
-
componentConfig.repo_full_name,
|
|
127
|
-
componentConfig.base_ref
|
|
128
|
-
);
|
|
129
|
-
if (VariantComp && typeof VariantComp === 'function' && alive) {
|
|
130
|
-
variantComponents[label] = VariantComp;
|
|
131
|
-
}
|
|
132
|
-
} catch (e) {
|
|
133
|
-
console.warn(`[PROBAT] Failed to load variant ${label}:`, e);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (alive) {
|
|
139
|
-
setConfig({
|
|
140
|
-
proposalId: componentConfig.proposal_id,
|
|
141
|
-
variants: variantComponents,
|
|
142
|
-
});
|
|
143
|
-
setConfigLoading(false);
|
|
144
|
-
}
|
|
145
|
-
} catch (e) {
|
|
146
|
-
console.warn("[PROBAT] Failed to load component config:", e);
|
|
147
|
-
if (alive) {
|
|
148
|
-
setConfig(null);
|
|
149
|
-
setConfigLoading(false);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
})();
|
|
153
|
-
|
|
154
|
-
return () => { alive = false; };
|
|
155
|
-
}, [useNewAPI, options.componentPath, repoFullName, apiBaseUrl]);
|
|
156
|
-
|
|
157
|
-
// Fetch experiment decision
|
|
158
|
-
useEffect(() => {
|
|
159
|
-
if (!proposalId) return;
|
|
160
|
-
if (useNewAPI && configLoading) return;
|
|
161
|
-
|
|
162
|
-
let alive = true;
|
|
163
|
-
|
|
164
|
-
// Detect if we are in heatmap mode
|
|
165
|
-
const isHeatmapMode = typeof window !== 'undefined' &&
|
|
166
|
-
new URLSearchParams(window.location.search).get('heatmap') === 'true';
|
|
167
|
-
|
|
168
|
-
// HIGH PRIORITY: Check if context is already forcing a specific variant for this proposal
|
|
169
|
-
// (This happens during Live Heatmap visualization)
|
|
170
|
-
if (context.proposalId === proposalId && context.variantLabel) {
|
|
171
|
-
console.log(`[PROBAT HOC] Forced variant from context: ${context.variantLabel}`);
|
|
172
|
-
setChoice({
|
|
173
|
-
experiment_id: `forced_${proposalId}`,
|
|
174
|
-
label: context.variantLabel
|
|
175
|
-
});
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// If we are in heatmap mode, bypass the cache to avoid showing stale variants
|
|
180
|
-
const cached = isHeatmapMode ? null : readChoice(proposalId);
|
|
181
|
-
|
|
182
|
-
if (cached) {
|
|
183
|
-
const choiceData = {
|
|
184
|
-
experiment_id: cached.experiment_id,
|
|
185
|
-
label: cached.label,
|
|
186
|
-
};
|
|
187
|
-
setChoice(choiceData);
|
|
188
|
-
// Set localStorage for heatmap tracking
|
|
189
|
-
if (typeof window !== 'undefined' && !isHeatmapMode) {
|
|
190
|
-
try {
|
|
191
|
-
window.localStorage.setItem('probat_active_proposal_id', proposalId);
|
|
192
|
-
window.localStorage.setItem('probat_active_variant_label', cached.label);
|
|
193
|
-
} catch (e) {
|
|
194
|
-
console.warn('[PROBAT] Failed to set proposal/variant in localStorage:', e);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
} else {
|
|
198
|
-
(async () => {
|
|
199
|
-
try {
|
|
200
|
-
const { experiment_id, label } = await fetchDecision(apiBaseUrl, proposalId);
|
|
201
|
-
if (!alive) return;
|
|
202
|
-
|
|
203
|
-
// Only write to choice cache and localStorage if NOT in heatmap mode
|
|
204
|
-
if (!isHeatmapMode) {
|
|
205
|
-
writeChoice(proposalId, experiment_id, label);
|
|
206
|
-
if (typeof window !== 'undefined') {
|
|
207
|
-
try {
|
|
208
|
-
window.localStorage.setItem('probat_active_proposal_id', proposalId);
|
|
209
|
-
window.localStorage.setItem('probat_active_variant_label', label);
|
|
210
|
-
} catch (e) {
|
|
211
|
-
console.warn('[PROBAT] Failed to set proposal/variant in localStorage:', e);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
const choiceData = { experiment_id, label };
|
|
217
|
-
setChoice(choiceData);
|
|
218
|
-
} catch (e) {
|
|
219
|
-
if (!alive) return;
|
|
220
|
-
const choiceData = {
|
|
221
|
-
experiment_id: `exp_${proposalId}`,
|
|
222
|
-
label: "control",
|
|
223
|
-
};
|
|
224
|
-
setChoice(choiceData);
|
|
225
|
-
|
|
226
|
-
if (typeof window !== 'undefined' && !isHeatmapMode) {
|
|
227
|
-
try {
|
|
228
|
-
window.localStorage.setItem('probat_active_proposal_id', proposalId);
|
|
229
|
-
window.localStorage.setItem('probat_active_variant_label', 'control');
|
|
230
|
-
} catch (err) {
|
|
231
|
-
console.warn('[PROBAT] Failed to set proposal/variant in localStorage:', err);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
})();
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
return () => { alive = false; };
|
|
239
|
-
}, [proposalId, apiBaseUrl, useNewAPI, configLoading, context.proposalId, context.variantLabel]);
|
|
240
|
-
|
|
241
|
-
// Track visit
|
|
242
|
-
useEffect(() => {
|
|
243
|
-
if (!proposalId) return;
|
|
244
|
-
const lbl = choice?.label ?? "control";
|
|
245
|
-
if (!lbl || hasTrackedVisit(proposalId, lbl)) return;
|
|
246
|
-
markTrackedVisit(proposalId, lbl);
|
|
247
|
-
void sendMetric(apiBaseUrl, proposalId, "visit", lbl, choice?.experiment_id);
|
|
248
|
-
}, [proposalId, choice?.experiment_id, choice?.label, apiBaseUrl]);
|
|
249
|
-
|
|
250
|
-
// 4. Callback hooks - always called
|
|
251
|
-
const trackClick = useCallback(
|
|
252
|
-
(event?: MouseEvent | null, opts?: { force?: boolean }) => {
|
|
253
|
-
if (!proposalId) return false;
|
|
254
|
-
const lbl = choice?.label ?? "control";
|
|
255
|
-
const meta = extractClickMeta(event ?? undefined);
|
|
256
|
-
if (!opts?.force && event && !meta) return false;
|
|
257
|
-
void sendMetric(apiBaseUrl, proposalId, "click", lbl, undefined, meta);
|
|
258
|
-
return true;
|
|
259
|
-
},
|
|
260
|
-
[proposalId, choice?.experiment_id, choice?.label, apiBaseUrl]
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
// ============================================================
|
|
264
|
-
// NOW WE CAN DO CONDITIONAL RETURNS - AFTER ALL HOOKS
|
|
265
|
-
// ============================================================
|
|
266
|
-
|
|
267
|
-
// Loading state - return control
|
|
268
|
-
if (useNewAPI && (configLoading || !config || !proposalId)) {
|
|
269
|
-
return React.createElement(ControlComponent as any, props as any);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// No proposalId - return control
|
|
273
|
-
if (!proposalId) {
|
|
274
|
-
return React.createElement(ControlComponent as any, props as any);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Build registry - always has control
|
|
278
|
-
const registry: Record<string, React.ComponentType<any>> = { control: ControlComponent };
|
|
279
|
-
|
|
280
|
-
if (useNewAPI && config?.variants) {
|
|
281
|
-
for (const [key, value] of Object.entries(config.variants)) {
|
|
282
|
-
if (key !== 'control' && value && typeof value === 'function') {
|
|
283
|
-
registry[key] = value;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
} else if (!useNewAPI && options.registry) {
|
|
287
|
-
for (const [key, value] of Object.entries(options.registry)) {
|
|
288
|
-
if (key !== 'control' && value && typeof value === 'function') {
|
|
289
|
-
registry[key] = value;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// Select variant
|
|
295
|
-
const label = choice?.label ?? "control";
|
|
296
|
-
const Variant = registry[label] || registry.control || ControlComponent;
|
|
297
|
-
|
|
298
|
-
return (
|
|
299
|
-
<div onClick={(event) => trackClick(event)} data-probat-proposal={proposalId}>
|
|
300
|
-
{React.createElement(Variant, {
|
|
301
|
-
key: `${proposalId}:${label}`,
|
|
302
|
-
...(props as any),
|
|
303
|
-
probat: { trackClick: () => trackClick(null, { force: true }) },
|
|
304
|
-
})}
|
|
305
|
-
</div>
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
Wrapped.displayName = `withExperiment(${Control.displayName || Control.name || "Component"})`;
|
|
310
|
-
return Wrapped as any;
|
|
311
|
-
}
|