@probat/react 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -344
- package/dist/index.d.mts +76 -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
package/src/utils/api.ts
CHANGED
|
@@ -1,614 +1,133 @@
|
|
|
1
|
-
import React from "react";
|
|
2
1
|
import { detectEnvironment } from "./environment";
|
|
2
|
+
import { buildEventContext } from "./eventContext";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
proposal_id: string;
|
|
6
|
-
experiment_id: string | null;
|
|
7
|
-
label: string | null;
|
|
8
|
-
};
|
|
4
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
9
5
|
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
6
|
+
export interface DecisionResponse {
|
|
7
|
+
variant_key: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface MetricPayload {
|
|
11
|
+
event: string;
|
|
12
|
+
properties: Record<string, unknown>;
|
|
13
|
+
}
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
proposal_id: string;
|
|
18
|
-
repo_full_name: string;
|
|
19
|
-
base_ref?: string; // Git branch/ref (default: "main")
|
|
20
|
-
variants: Record<string, ComponentVariantInfo>;
|
|
21
|
-
};
|
|
15
|
+
// ── Assignment fetching ────────────────────────────────────────────────────
|
|
22
16
|
|
|
23
|
-
|
|
24
|
-
const pendingFetches = new Map<
|
|
25
|
-
string,
|
|
26
|
-
Promise<{ experiment_id: string; label: string }>
|
|
27
|
-
>();
|
|
17
|
+
const pendingDecisions = new Map<string, Promise<string>>();
|
|
28
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Fetch the variant assignment for an experiment.
|
|
21
|
+
* Returns the variant key string (e.g. "control", "ai_v1").
|
|
22
|
+
* Deduplicates concurrent calls for the same experiment.
|
|
23
|
+
*/
|
|
29
24
|
export async function fetchDecision(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
if (
|
|
36
|
-
return existingFetch;
|
|
37
|
-
}
|
|
25
|
+
host: string,
|
|
26
|
+
experimentId: string,
|
|
27
|
+
distinctId: string
|
|
28
|
+
): Promise<string> {
|
|
29
|
+
const existing = pendingDecisions.get(experimentId);
|
|
30
|
+
if (existing) return existing;
|
|
38
31
|
|
|
39
|
-
|
|
40
|
-
const fetchPromise = (async () => {
|
|
32
|
+
const promise = (async () => {
|
|
41
33
|
try {
|
|
42
|
-
const url = `${
|
|
34
|
+
const url = `${host.replace(/\/$/, "")}/experiment/decide`;
|
|
43
35
|
const res = await fetch(url, {
|
|
44
36
|
method: "POST",
|
|
45
|
-
headers: {
|
|
46
|
-
|
|
37
|
+
headers: {
|
|
38
|
+
"Content-Type": "application/json",
|
|
39
|
+
Accept: "application/json",
|
|
40
|
+
},
|
|
41
|
+
credentials: "include",
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
experiment_id: experimentId,
|
|
44
|
+
distinct_id: distinctId,
|
|
45
|
+
}),
|
|
47
46
|
});
|
|
48
47
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
49
|
-
const data = (await res.json()) as
|
|
50
|
-
|
|
51
|
-
const experiment_id = (data.experiment_id || `exp_${proposalId}`).toString();
|
|
52
|
-
const label = data.label && data.label.trim() ? data.label : "control";
|
|
53
|
-
|
|
54
|
-
// Auto-set localStorage for heatmap tracking
|
|
55
|
-
if (typeof window !== 'undefined') {
|
|
56
|
-
try {
|
|
57
|
-
window.localStorage.setItem('probat_active_proposal_id', proposalId);
|
|
58
|
-
window.localStorage.setItem('probat_active_variant_label', label);
|
|
59
|
-
} catch (e) {
|
|
60
|
-
console.warn('[PROBAT] Failed to set proposal/variant in localStorage:', e);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return { experiment_id, label };
|
|
48
|
+
const data = (await res.json()) as DecisionResponse;
|
|
49
|
+
return data.variant_key || "control";
|
|
65
50
|
} finally {
|
|
66
|
-
|
|
67
|
-
pendingFetches.delete(proposalId);
|
|
51
|
+
pendingDecisions.delete(experimentId);
|
|
68
52
|
}
|
|
69
53
|
})();
|
|
70
54
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return fetchPromise;
|
|
55
|
+
pendingDecisions.set(experimentId, promise);
|
|
56
|
+
return promise;
|
|
74
57
|
}
|
|
75
58
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
59
|
+
// ── Metric sending ─────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fire-and-forget metric send. Never throws.
|
|
63
|
+
*/
|
|
64
|
+
export function sendMetric(
|
|
65
|
+
host: string,
|
|
66
|
+
event: string,
|
|
67
|
+
properties: Record<string, unknown>
|
|
68
|
+
): void {
|
|
69
|
+
if (typeof window === "undefined") return;
|
|
70
|
+
|
|
71
|
+
const ctx = buildEventContext();
|
|
72
|
+
const payload: MetricPayload = {
|
|
73
|
+
event,
|
|
74
|
+
properties: {
|
|
75
|
+
...ctx,
|
|
76
|
+
environment: detectEnvironment(),
|
|
77
|
+
source: "react-sdk",
|
|
78
|
+
captured_at: new Date().toISOString(),
|
|
79
|
+
...properties,
|
|
80
|
+
},
|
|
95
81
|
};
|
|
82
|
+
|
|
96
83
|
try {
|
|
97
|
-
|
|
84
|
+
const url = `${host.replace(/\/$/, "")}/experiment/metrics`;
|
|
85
|
+
fetch(url, {
|
|
98
86
|
method: "POST",
|
|
99
|
-
headers: {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
credentials: "include", // CRITICAL: Include cookies to distinguish different users
|
|
104
|
-
body: JSON.stringify(body),
|
|
105
|
-
});
|
|
87
|
+
headers: { "Content-Type": "application/json" },
|
|
88
|
+
credentials: "include",
|
|
89
|
+
body: JSON.stringify(payload),
|
|
90
|
+
}).catch(() => {});
|
|
106
91
|
} catch {
|
|
107
|
-
//
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function extractClickMeta(
|
|
112
|
-
event?: { target?: EventTarget | null } | null
|
|
113
|
-
): Record<string, any> | undefined {
|
|
114
|
-
if (!event || !event.target) return undefined;
|
|
115
|
-
const rawTarget = event.target as HTMLElement | null;
|
|
116
|
-
if (!rawTarget) return undefined;
|
|
117
|
-
const actionable = rawTarget.closest(
|
|
118
|
-
"[data-probat-conversion='true'], button, a, [role='button']"
|
|
119
|
-
);
|
|
120
|
-
if (!actionable) return undefined;
|
|
121
|
-
const meta: Record<string, any> = {
|
|
122
|
-
target_tag: actionable.tagName,
|
|
123
|
-
};
|
|
124
|
-
if (actionable.id) meta.target_id = actionable.id;
|
|
125
|
-
const attr = actionable.getAttribute("data-probat-conversion");
|
|
126
|
-
if (attr) meta.conversion_attr = attr;
|
|
127
|
-
const text = actionable.textContent?.trim();
|
|
128
|
-
if (text) meta.target_text = text.slice(0, 120);
|
|
129
|
-
return meta;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Cache for component config fetches
|
|
133
|
-
const componentConfigCache = new Map<
|
|
134
|
-
string,
|
|
135
|
-
Promise<ComponentExperimentConfig | null>
|
|
136
|
-
>();
|
|
137
|
-
|
|
138
|
-
export async function fetchComponentExperimentConfig(
|
|
139
|
-
baseUrl: string,
|
|
140
|
-
repoFullName: string,
|
|
141
|
-
componentPath: string
|
|
142
|
-
): Promise<ComponentExperimentConfig | null> {
|
|
143
|
-
const cacheKey = `${repoFullName}:${componentPath}`;
|
|
144
|
-
|
|
145
|
-
// Check cache
|
|
146
|
-
const existingFetch = componentConfigCache.get(cacheKey);
|
|
147
|
-
if (existingFetch) {
|
|
148
|
-
return existingFetch;
|
|
92
|
+
// silently drop
|
|
149
93
|
}
|
|
150
|
-
|
|
151
|
-
// Create new fetch promise
|
|
152
|
-
const fetchPromise = (async () => {
|
|
153
|
-
try {
|
|
154
|
-
const url = new URL(`${baseUrl.replace(/\/$/, "")}/get_component_experiment_config`);
|
|
155
|
-
url.searchParams.set("repo_full_name", repoFullName);
|
|
156
|
-
url.searchParams.set("component_path", componentPath);
|
|
157
|
-
|
|
158
|
-
const res = await fetch(url.toString(), {
|
|
159
|
-
method: "GET",
|
|
160
|
-
headers: { Accept: "application/json" },
|
|
161
|
-
credentials: "include",
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
if (res.status === 404) {
|
|
165
|
-
// No experiments for this component - return null
|
|
166
|
-
return null;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
if (!res.ok) {
|
|
170
|
-
throw new Error(`HTTP ${res.status}`);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const data = (await res.json()) as ComponentExperimentConfig;
|
|
174
|
-
|
|
175
|
-
// Auto-set localStorage for heatmap tracking when component config is loaded
|
|
176
|
-
// This will be updated when a specific variant is selected via fetchDecision
|
|
177
|
-
if (typeof window !== 'undefined' && data?.proposal_id) {
|
|
178
|
-
try {
|
|
179
|
-
window.localStorage.setItem('probat_active_proposal_id', data.proposal_id);
|
|
180
|
-
// Variant label will be set when fetchDecision is called
|
|
181
|
-
} catch (e) {
|
|
182
|
-
console.warn('[PROBAT] Failed to set proposal_id in localStorage:', e);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return data;
|
|
187
|
-
} catch (e) {
|
|
188
|
-
console.warn(`[PROBAT] Failed to fetch component config: ${e}`);
|
|
189
|
-
return null;
|
|
190
|
-
} finally {
|
|
191
|
-
// Remove from cache after completion
|
|
192
|
-
componentConfigCache.delete(cacheKey);
|
|
193
|
-
}
|
|
194
|
-
})();
|
|
195
|
-
|
|
196
|
-
// Store the promise
|
|
197
|
-
componentConfigCache.set(cacheKey, fetchPromise);
|
|
198
|
-
return fetchPromise;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Cache for variant component loads
|
|
202
|
-
const variantComponentCache = new Map<string, Promise<React.ComponentType<any> | null>>();
|
|
203
|
-
const moduleCache = new Map<string, any>(); // Cache for loaded modules (relative imports)
|
|
204
|
-
|
|
205
|
-
// Make React available globally for variant components
|
|
206
|
-
if (typeof window !== "undefined") {
|
|
207
|
-
(window as any).__probatReact = React;
|
|
208
|
-
(window as any).React = (window as any).React || React;
|
|
209
94
|
}
|
|
210
95
|
|
|
211
|
-
|
|
212
|
-
* Resolve relative import path to absolute path
|
|
213
|
-
*/
|
|
214
|
-
function resolveRelativePath(relativePath: string, basePath: string): string {
|
|
215
|
-
// Remove file extension if present
|
|
216
|
-
const baseDir = basePath.substring(0, basePath.lastIndexOf('/'));
|
|
217
|
-
const parts = baseDir.split('/').filter(Boolean);
|
|
218
|
-
const relativeParts = relativePath.split('/').filter(Boolean);
|
|
219
|
-
|
|
220
|
-
for (const part of relativeParts) {
|
|
221
|
-
if (part === '..') {
|
|
222
|
-
parts.pop();
|
|
223
|
-
} else if (part !== '.') {
|
|
224
|
-
parts.push(part);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
96
|
+
// ── Click metadata extraction ──────────────────────────────────────────────
|
|
227
97
|
|
|
228
|
-
|
|
98
|
+
export interface ClickMeta {
|
|
99
|
+
click_target_tag: string;
|
|
100
|
+
click_target_text?: string;
|
|
101
|
+
click_target_id?: string;
|
|
102
|
+
click_is_primary: boolean;
|
|
229
103
|
}
|
|
230
104
|
|
|
231
105
|
/**
|
|
232
|
-
*
|
|
106
|
+
* Given a click event inside an experiment boundary, extract metadata.
|
|
107
|
+
* Prioritizes elements with data-probat-click="primary",
|
|
108
|
+
* then falls back to button/a/role=button.
|
|
233
109
|
*/
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
baseFilePath: string,
|
|
237
|
-
repoFullName?: string,
|
|
238
|
-
baseRef?: string
|
|
239
|
-
): Promise<any> {
|
|
240
|
-
const cacheKey = `${baseFilePath}:${absolutePath}`;
|
|
241
|
-
if (moduleCache.has(cacheKey)) {
|
|
242
|
-
return moduleCache.get(cacheKey);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Try to determine file extension
|
|
246
|
-
const extensions = ['.jsx', '.tsx', '.js', '.ts'];
|
|
247
|
-
let moduleCode: string | null = null;
|
|
248
|
-
let modulePath: string | null = null;
|
|
249
|
-
|
|
250
|
-
// Try each extension
|
|
251
|
-
for (const ext of extensions) {
|
|
252
|
-
const testPath = absolutePath + (absolutePath.includes('.') ? '' : ext);
|
|
253
|
-
|
|
254
|
-
// Try local first
|
|
255
|
-
try {
|
|
256
|
-
const localRes = await fetch(testPath, {
|
|
257
|
-
method: "GET",
|
|
258
|
-
headers: { Accept: "text/plain" },
|
|
259
|
-
});
|
|
260
|
-
if (localRes.ok) {
|
|
261
|
-
moduleCode = await localRes.text();
|
|
262
|
-
modulePath = testPath;
|
|
263
|
-
break;
|
|
264
|
-
}
|
|
265
|
-
} catch {
|
|
266
|
-
// Continue to next extension or GitHub
|
|
267
|
-
}
|
|
110
|
+
export function extractClickMeta(target: EventTarget | null): ClickMeta | null {
|
|
111
|
+
if (!target || !(target instanceof HTMLElement)) return null;
|
|
268
112
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const githubPath = testPath.startsWith('/') ? testPath.substring(1) : testPath;
|
|
273
|
-
const githubUrl = `https://raw.githubusercontent.com/${repoFullName}/${baseRef || 'main'}/${githubPath}`;
|
|
274
|
-
const res = await fetch(githubUrl, { method: "GET", headers: { Accept: "text/plain" } });
|
|
275
|
-
if (res.ok) {
|
|
276
|
-
moduleCode = await res.text();
|
|
277
|
-
modulePath = testPath;
|
|
278
|
-
break;
|
|
279
|
-
}
|
|
280
|
-
} catch {
|
|
281
|
-
// Continue
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
113
|
+
// Priority 1: explicit primary marker
|
|
114
|
+
const primary = target.closest('[data-probat-click="primary"]');
|
|
115
|
+
if (primary) return buildMeta(primary as HTMLElement, true);
|
|
285
116
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
117
|
+
// Priority 2: interactive elements
|
|
118
|
+
const interactive = target.closest('button, a, [role="button"]');
|
|
119
|
+
if (interactive) return buildMeta(interactive as HTMLElement, false);
|
|
289
120
|
|
|
290
|
-
|
|
291
|
-
let Babel: any;
|
|
292
|
-
if (typeof window !== "undefined" && (window as any).Babel) {
|
|
293
|
-
Babel = (window as any).Babel;
|
|
294
|
-
} else {
|
|
295
|
-
try {
|
|
296
|
-
// @ts-ignore
|
|
297
|
-
const babelModule = await import("@babel/standalone");
|
|
298
|
-
Babel = babelModule.default || babelModule;
|
|
299
|
-
} catch {
|
|
300
|
-
throw new Error("Babel not available for compiling relative import");
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Preprocess module code
|
|
305
|
-
let processedCode = moduleCode;
|
|
306
|
-
processedCode = processedCode.replace(/^import\s+['"].*\.css['"];?\s*$/gm, "");
|
|
307
|
-
processedCode = processedCode.replace(/^import\s+React\s+from\s+['"]react['"];?\s*$/m, "const React = window.React || globalThis.React;");
|
|
308
|
-
processedCode = processedCode.replace(/import\.meta\.env\.[\w$]+/g, "undefined");
|
|
309
|
-
processedCode = processedCode.replace(/\bimport\.meta\b/g, "({})");
|
|
310
|
-
|
|
311
|
-
const isTSX = modulePath.endsWith(".tsx");
|
|
312
|
-
const compiled = Babel.transform(processedCode, {
|
|
313
|
-
presets: [
|
|
314
|
-
["react", { runtime: "classic" }],
|
|
315
|
-
["typescript", { allExtensions: true, isTSX }],
|
|
316
|
-
],
|
|
317
|
-
plugins: [["transform-modules-commonjs", { allowTopLevelThis: true }]],
|
|
318
|
-
sourceType: "module",
|
|
319
|
-
filename: modulePath,
|
|
320
|
-
}).code;
|
|
321
|
-
|
|
322
|
-
// Execute compiled module
|
|
323
|
-
const moduleCodeWrapper = `
|
|
324
|
-
var require = function(name) {
|
|
325
|
-
if (name === "react" || name === "react/jsx-runtime") {
|
|
326
|
-
return window.React || globalThis.React;
|
|
327
|
-
}
|
|
328
|
-
if (name.startsWith("/@vite") || name.includes("@vite/client")) {
|
|
329
|
-
return {};
|
|
330
|
-
}
|
|
331
|
-
throw new Error("Unsupported module in relative import: " + name);
|
|
332
|
-
};
|
|
333
|
-
var module = { exports: {} };
|
|
334
|
-
var exports = module.exports;
|
|
335
|
-
${compiled}
|
|
336
|
-
return module.exports;
|
|
337
|
-
`;
|
|
338
|
-
|
|
339
|
-
const moduleExports = new Function(moduleCodeWrapper)();
|
|
340
|
-
moduleCache.set(cacheKey, moduleExports);
|
|
341
|
-
return moduleExports;
|
|
121
|
+
return null;
|
|
342
122
|
}
|
|
343
123
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
)
|
|
352
|
-
|
|
353
|
-
return null;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const cacheKey = `${proposalId}:${experimentId}`;
|
|
357
|
-
|
|
358
|
-
// Check cache
|
|
359
|
-
const existingLoad = variantComponentCache.get(cacheKey);
|
|
360
|
-
if (existingLoad) {
|
|
361
|
-
return existingLoad;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// Create new load promise
|
|
365
|
-
const loadPromise = (async () => {
|
|
366
|
-
try {
|
|
367
|
-
// ============================================
|
|
368
|
-
// Hybrid approach: Dynamic import for CSR (Vite), fetch+babel for SSR (Next.js)
|
|
369
|
-
// ============================================
|
|
370
|
-
let code: string = "";
|
|
371
|
-
let rawCode: string = "";
|
|
372
|
-
let rawCodeFetched = false;
|
|
373
|
-
|
|
374
|
-
// Try dynamic import first (works great for Vite/CSR, resolves relative imports automatically)
|
|
375
|
-
// Skip for Next.js - Turbopack statically analyzes import() calls and fails
|
|
376
|
-
const isBrowser = typeof window !== "undefined";
|
|
377
|
-
const isNextJS = isBrowser && (
|
|
378
|
-
(window as any).__NEXT_DATA__ !== undefined ||
|
|
379
|
-
(window as any).__NEXT_LOADED_PAGES__ !== undefined ||
|
|
380
|
-
typeof (globalThis as any).__NEXT_DATA__ !== "undefined"
|
|
381
|
-
);
|
|
382
|
-
|
|
383
|
-
// Only use dynamic import for non-Next.js environments (Vite, etc.)
|
|
384
|
-
if (isBrowser && !isNextJS) {
|
|
385
|
-
try {
|
|
386
|
-
const variantUrl = `/probat/${filePath}`;
|
|
387
|
-
// Use Function constructor to prevent Next.js/Turbopack from statically analyzing
|
|
388
|
-
// This makes the import completely dynamic and invisible to static analysis
|
|
389
|
-
const dynamicImportFunc = new Function('url', 'return import(url)');
|
|
390
|
-
const mod = await dynamicImportFunc(variantUrl);
|
|
391
|
-
const VariantComponent = (mod as any)?.default || mod;
|
|
392
|
-
if (VariantComponent && typeof VariantComponent === "function") {
|
|
393
|
-
console.log(`[PROBAT] ✅ Loaded variant via dynamic import (CSR): ${variantUrl}`);
|
|
394
|
-
return VariantComponent as React.ComponentType<any>;
|
|
395
|
-
}
|
|
396
|
-
} catch (dynamicImportError) {
|
|
397
|
-
// Dynamic import failed, fall through to fetch+babel
|
|
398
|
-
console.debug(`[PROBAT] Dynamic import failed, using fetch+babel:`, dynamicImportError);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// Fallback: fetch + babel compilation (works for Next.js SSR and when dynamic import fails)
|
|
403
|
-
const localUrl = `/probat/${filePath}`;
|
|
404
|
-
try {
|
|
405
|
-
const localRes = await fetch(localUrl, {
|
|
406
|
-
method: "GET",
|
|
407
|
-
headers: { Accept: "text/plain" },
|
|
408
|
-
});
|
|
409
|
-
if (localRes.ok) {
|
|
410
|
-
rawCode = await localRes.text();
|
|
411
|
-
rawCodeFetched = true;
|
|
412
|
-
console.log(`[PROBAT] ✅ Loaded variant from local (user's repo): ${localUrl}`);
|
|
413
|
-
}
|
|
414
|
-
} catch {
|
|
415
|
-
console.debug(`[PROBAT] Local file not available (${localUrl}), trying GitHub...`);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
if (!rawCodeFetched && repoFullName) {
|
|
419
|
-
const githubPath = `probat/${filePath}`;
|
|
420
|
-
const gitRef = baseRef || "main";
|
|
421
|
-
const githubUrl = `https://raw.githubusercontent.com/${repoFullName}/${gitRef}/${githubPath}`;
|
|
422
|
-
const res = await fetch(githubUrl, { method: "GET", headers: { Accept: "text/plain" } });
|
|
423
|
-
if (res.ok) {
|
|
424
|
-
rawCode = await res.text();
|
|
425
|
-
rawCodeFetched = true;
|
|
426
|
-
console.log(`[PROBAT] ⚠️ Loaded variant from GitHub (fallback): ${githubUrl}`);
|
|
427
|
-
} else {
|
|
428
|
-
console.warn(`[PROBAT] ⚠️ GitHub fetch failed (${res.status}), falling back to server compilation`);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
if (rawCodeFetched && rawCode) {
|
|
433
|
-
let Babel: any;
|
|
434
|
-
if (typeof window !== "undefined" && (window as any).Babel) {
|
|
435
|
-
Babel = (window as any).Babel;
|
|
436
|
-
} else {
|
|
437
|
-
try {
|
|
438
|
-
// @ts-ignore
|
|
439
|
-
const babelModule = await import("@babel/standalone");
|
|
440
|
-
Babel = babelModule.default || babelModule;
|
|
441
|
-
} catch (importError) {
|
|
442
|
-
try {
|
|
443
|
-
await new Promise<void>((resolve, reject) => {
|
|
444
|
-
if (typeof document === "undefined") {
|
|
445
|
-
reject(new Error("Document not available"));
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
if ((window as any).Babel) {
|
|
449
|
-
Babel = (window as any).Babel;
|
|
450
|
-
resolve();
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
const script = document.createElement("script");
|
|
454
|
-
script.src = "https://unpkg.com/@babel/standalone/babel.min.js";
|
|
455
|
-
script.async = true;
|
|
456
|
-
script.onload = () => {
|
|
457
|
-
Babel = (window as any).Babel;
|
|
458
|
-
if (!Babel) reject(new Error("Babel not found after script load"));
|
|
459
|
-
else resolve();
|
|
460
|
-
};
|
|
461
|
-
script.onerror = () => reject(new Error("Failed to load Babel from CDN"));
|
|
462
|
-
document.head.appendChild(script);
|
|
463
|
-
});
|
|
464
|
-
} catch (babelError) {
|
|
465
|
-
console.error("[PROBAT] Failed to load Babel, falling back to server compilation", babelError);
|
|
466
|
-
rawCodeFetched = false;
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
if (rawCodeFetched && rawCode && Babel) {
|
|
472
|
-
const isTSX = filePath.endsWith(".tsx");
|
|
473
|
-
rawCode = rawCode.replace(/^import\s+['"].*\.css['"];?\s*$/gm, "");
|
|
474
|
-
rawCode = rawCode.replace(/^import\s+.*from\s+['"].*\.css['"];?\s*$/gm, "");
|
|
475
|
-
rawCode = rawCode.replace(
|
|
476
|
-
/^import\s+React(?:\s*,\s*\{[^}]*\})?\s+from\s+['"]react['"];?\s*$/m,
|
|
477
|
-
"const React = window.React || globalThis.React;"
|
|
478
|
-
);
|
|
479
|
-
rawCode = rawCode.replace(
|
|
480
|
-
/^import\s+\*\s+as\s+React\s+from\s+['"]react['"];?\s*$/m,
|
|
481
|
-
"const React = window.React || globalThis.React;"
|
|
482
|
-
);
|
|
483
|
-
rawCode = rawCode.replace(
|
|
484
|
-
/^import\s+\{([^}]+)\}\s+from\s+['"]react['"];?\s*$/m,
|
|
485
|
-
(match, imports) => `const {${imports}} = window.React || globalThis.React;`
|
|
486
|
-
);
|
|
487
|
-
rawCode = rawCode.replace(/import\.meta\.env\.[\w$]+/g, "undefined");
|
|
488
|
-
rawCode = rawCode.replace(/\bimport\.meta\b/g, "({})");
|
|
489
|
-
rawCode = rawCode.replace(/^import\s+.*\/@vite\/client.*$/gm, "");
|
|
490
|
-
rawCode = rawCode.replace(/import\.meta\.hot(?:\.[\w$]+)*/g, "undefined");
|
|
491
|
-
|
|
492
|
-
// Preprocess relative imports: convert to absolute paths that can be fetched
|
|
493
|
-
// This allows the require shim to fetch them later
|
|
494
|
-
const relativeImportMap = new Map<string, string>();
|
|
495
|
-
rawCode = rawCode.replace(
|
|
496
|
-
/^import\s+(\w+)\s+from\s+['"](\.\.?\/[^'"]+)['"];?\s*$/gm,
|
|
497
|
-
(match, importName, relativePath) => {
|
|
498
|
-
// Resolve relative path to absolute
|
|
499
|
-
const baseDir = filePath.substring(0, filePath.lastIndexOf('/'));
|
|
500
|
-
const resolvedPath = resolveRelativePath(relativePath, baseDir);
|
|
501
|
-
// Try to find the file with extension
|
|
502
|
-
const absolutePath = resolvedPath.startsWith('/') ? resolvedPath : '/' + resolvedPath;
|
|
503
|
-
relativeImportMap.set(importName, absolutePath);
|
|
504
|
-
// Replace with absolute path import - Babel will compile this to require()
|
|
505
|
-
return `import ${importName} from "${absolutePath}";`;
|
|
506
|
-
}
|
|
507
|
-
);
|
|
508
|
-
|
|
509
|
-
const compiled = Babel.transform(rawCode, {
|
|
510
|
-
presets: [
|
|
511
|
-
["react", { runtime: "classic" }],
|
|
512
|
-
["typescript", { allExtensions: true, isTSX }],
|
|
513
|
-
],
|
|
514
|
-
plugins: [["transform-modules-commonjs", { allowTopLevelThis: true }]],
|
|
515
|
-
sourceType: "module",
|
|
516
|
-
filename: filePath,
|
|
517
|
-
}).code;
|
|
518
|
-
|
|
519
|
-
// Pre-load relative imports if any
|
|
520
|
-
const relativeModules: Record<string, any> = {};
|
|
521
|
-
if (relativeImportMap.size > 0) {
|
|
522
|
-
for (const [importName, absolutePath] of relativeImportMap.entries()) {
|
|
523
|
-
try {
|
|
524
|
-
const moduleExports = await loadRelativeModule(absolutePath, filePath, repoFullName, baseRef);
|
|
525
|
-
relativeModules[absolutePath] = moduleExports.default || moduleExports;
|
|
526
|
-
} catch (err) {
|
|
527
|
-
console.warn(`[PROBAT] Failed to load relative import ${absolutePath}:`, err);
|
|
528
|
-
relativeModules[absolutePath] = null;
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// Build require shim that can resolve relative imports
|
|
534
|
-
const relativeModulesJson = JSON.stringify(relativeModules);
|
|
535
|
-
code = `
|
|
536
|
-
var __probatVariant = (function() {
|
|
537
|
-
var relativeModules = ${relativeModulesJson};
|
|
538
|
-
var require = function(name) {
|
|
539
|
-
if (name === "react" || name === "react/jsx-runtime") {
|
|
540
|
-
return window.React || globalThis.React;
|
|
541
|
-
}
|
|
542
|
-
if (name.startsWith("/@vite") || name.includes("@vite/client") || name.includes(".vite/deps")) {
|
|
543
|
-
return {};
|
|
544
|
-
}
|
|
545
|
-
if (name === "react/jsx-runtime.js") {
|
|
546
|
-
return window.React || globalThis.React;
|
|
547
|
-
}
|
|
548
|
-
// Handle relative imports (now converted to absolute paths)
|
|
549
|
-
if (name.startsWith("/") && relativeModules.hasOwnProperty(name)) {
|
|
550
|
-
var mod = relativeModules[name];
|
|
551
|
-
if (mod === null) {
|
|
552
|
-
throw new Error("Failed to load module: " + name);
|
|
553
|
-
}
|
|
554
|
-
return mod;
|
|
555
|
-
}
|
|
556
|
-
throw new Error("Unsupported module: " + name);
|
|
557
|
-
};
|
|
558
|
-
var module = { exports: {} };
|
|
559
|
-
var exports = module.exports;
|
|
560
|
-
${compiled}
|
|
561
|
-
return module.exports.default || module.exports;
|
|
562
|
-
})();
|
|
563
|
-
`;
|
|
564
|
-
} else {
|
|
565
|
-
rawCodeFetched = false;
|
|
566
|
-
code = "";
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
if (!rawCodeFetched || code === "") {
|
|
571
|
-
const variantUrl = `${baseUrl.replace(/\/$/, "")}/variants/${filePath}`;
|
|
572
|
-
const serverRes = await fetch(variantUrl, {
|
|
573
|
-
method: "GET",
|
|
574
|
-
headers: { Accept: "text/javascript" },
|
|
575
|
-
credentials: "include",
|
|
576
|
-
});
|
|
577
|
-
if (!serverRes.ok) {
|
|
578
|
-
throw new Error(`HTTP ${serverRes.status}`);
|
|
579
|
-
}
|
|
580
|
-
code = await serverRes.text();
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
if (typeof window !== "undefined") {
|
|
584
|
-
(window as any).React = (window as any).React || React;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
const evalFunc = new Function(`
|
|
588
|
-
var __probatVariant;
|
|
589
|
-
${code}
|
|
590
|
-
return __probatVariant;
|
|
591
|
-
`);
|
|
592
|
-
|
|
593
|
-
const result = evalFunc();
|
|
594
|
-
const VariantComponent = result?.default || result;
|
|
595
|
-
if (typeof VariantComponent === "function") {
|
|
596
|
-
return VariantComponent as React.ComponentType<any>;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
console.warn("[PROBAT] Variant component is not a function", result);
|
|
600
|
-
return null;
|
|
601
|
-
} catch (e) {
|
|
602
|
-
console.warn(`[PROBAT] Failed to load variant component: ${e}`);
|
|
603
|
-
return null;
|
|
604
|
-
} finally {
|
|
605
|
-
// Remove from cache after completion
|
|
606
|
-
variantComponentCache.delete(cacheKey);
|
|
607
|
-
}
|
|
608
|
-
})();
|
|
609
|
-
|
|
610
|
-
// Store the promise
|
|
611
|
-
variantComponentCache.set(cacheKey, loadPromise);
|
|
612
|
-
return loadPromise;
|
|
124
|
+
function buildMeta(el: HTMLElement, isPrimary: boolean): ClickMeta {
|
|
125
|
+
const meta: ClickMeta = {
|
|
126
|
+
click_target_tag: el.tagName,
|
|
127
|
+
click_is_primary: isPrimary,
|
|
128
|
+
};
|
|
129
|
+
if (el.id) meta.click_target_id = el.id;
|
|
130
|
+
const text = el.textContent?.trim();
|
|
131
|
+
if (text) meta.click_target_text = text.slice(0, 120);
|
|
132
|
+
return meta;
|
|
613
133
|
}
|
|
614
|
-
|