@probat/react 0.1.1 → 0.1.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 +1 -30
- package/dist/index.d.ts +1 -30
- package/dist/index.js +189 -157
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +191 -155
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -2
- package/src/context/ProbatContext.tsx +1 -12
- package/src/hoc/itrt-frontend.code-workspace +3 -0
- package/src/hoc/withExperiment.tsx +4 -12
- package/src/index.ts +0 -6
- package/src/utils/api.ts +155 -16
- package/src/utils/documentClickTracker.ts +61 -46
package/src/utils/api.ts
CHANGED
|
@@ -15,6 +15,8 @@ export type ComponentVariantInfo = {
|
|
|
15
15
|
|
|
16
16
|
export type ComponentExperimentConfig = {
|
|
17
17
|
proposal_id: string;
|
|
18
|
+
repo_full_name: string;
|
|
19
|
+
base_ref?: string; // Git branch/ref (default: "main")
|
|
18
20
|
variants: Record<string, ComponentVariantInfo>;
|
|
19
21
|
};
|
|
20
22
|
|
|
@@ -187,7 +189,9 @@ export async function loadVariantComponent(
|
|
|
187
189
|
baseUrl: string,
|
|
188
190
|
proposalId: string,
|
|
189
191
|
experimentId: string,
|
|
190
|
-
filePath: string | null
|
|
192
|
+
filePath: string | null,
|
|
193
|
+
repoFullName?: string,
|
|
194
|
+
baseRef?: string
|
|
191
195
|
): Promise<React.ComponentType<any> | null> {
|
|
192
196
|
if (!filePath) {
|
|
193
197
|
return null;
|
|
@@ -204,29 +208,167 @@ export async function loadVariantComponent(
|
|
|
204
208
|
// Create new load promise
|
|
205
209
|
const loadPromise = (async () => {
|
|
206
210
|
try {
|
|
207
|
-
//
|
|
208
|
-
|
|
211
|
+
// ============================================
|
|
212
|
+
// Preferred path: dynamic ESM import (Vite/Next can resolve deps)
|
|
213
|
+
// ============================================
|
|
214
|
+
try {
|
|
215
|
+
const variantUrl = `/probat/${filePath}`;
|
|
216
|
+
const mod = await import(/* @vite-ignore */ variantUrl);
|
|
217
|
+
const VariantComponent = (mod as any)?.default || mod;
|
|
218
|
+
if (VariantComponent && typeof VariantComponent === "function") {
|
|
219
|
+
return VariantComponent as React.ComponentType<any>;
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
// Fall through to legacy path
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Legacy path (fetch + babel + eval) retained for non-Vite envs
|
|
226
|
+
let code: string = "";
|
|
227
|
+
let rawCode: string = "";
|
|
228
|
+
let rawCodeFetched = false;
|
|
229
|
+
|
|
230
|
+
const localUrl = `/probat/${filePath}`;
|
|
231
|
+
try {
|
|
232
|
+
const localRes = await fetch(localUrl, {
|
|
233
|
+
method: "GET",
|
|
234
|
+
headers: { Accept: "text/plain" },
|
|
235
|
+
});
|
|
236
|
+
if (localRes.ok) {
|
|
237
|
+
rawCode = await localRes.text();
|
|
238
|
+
rawCodeFetched = true;
|
|
239
|
+
console.log(`[PROBAT] ✅ Loaded variant from local (user's repo): ${localUrl}`);
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
console.debug(`[PROBAT] Local file not available (${localUrl}), trying GitHub...`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!rawCodeFetched && repoFullName) {
|
|
246
|
+
const githubPath = `probat/${filePath}`;
|
|
247
|
+
const gitRef = baseRef || "main";
|
|
248
|
+
const githubUrl = `https://raw.githubusercontent.com/${repoFullName}/${gitRef}/${githubPath}`;
|
|
249
|
+
const res = await fetch(githubUrl, { method: "GET", headers: { Accept: "text/plain" } });
|
|
250
|
+
if (res.ok) {
|
|
251
|
+
rawCode = await res.text();
|
|
252
|
+
rawCodeFetched = true;
|
|
253
|
+
console.log(`[PROBAT] ⚠️ Loaded variant from GitHub (fallback): ${githubUrl}`);
|
|
254
|
+
} else {
|
|
255
|
+
console.warn(`[PROBAT] ⚠️ GitHub fetch failed (${res.status}), falling back to server compilation`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (rawCodeFetched && rawCode) {
|
|
260
|
+
let Babel: any;
|
|
261
|
+
if (typeof window !== "undefined" && (window as any).Babel) {
|
|
262
|
+
Babel = (window as any).Babel;
|
|
263
|
+
} else {
|
|
264
|
+
try {
|
|
265
|
+
// @ts-ignore
|
|
266
|
+
const babelModule = await import("@babel/standalone");
|
|
267
|
+
Babel = babelModule.default || babelModule;
|
|
268
|
+
} catch (importError) {
|
|
269
|
+
try {
|
|
270
|
+
await new Promise<void>((resolve, reject) => {
|
|
271
|
+
if (typeof document === "undefined") {
|
|
272
|
+
reject(new Error("Document not available"));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if ((window as any).Babel) {
|
|
276
|
+
Babel = (window as any).Babel;
|
|
277
|
+
resolve();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const script = document.createElement("script");
|
|
281
|
+
script.src = "https://unpkg.com/@babel/standalone/babel.min.js";
|
|
282
|
+
script.async = true;
|
|
283
|
+
script.onload = () => {
|
|
284
|
+
Babel = (window as any).Babel;
|
|
285
|
+
if (!Babel) reject(new Error("Babel not found after script load"));
|
|
286
|
+
else resolve();
|
|
287
|
+
};
|
|
288
|
+
script.onerror = () => reject(new Error("Failed to load Babel from CDN"));
|
|
289
|
+
document.head.appendChild(script);
|
|
290
|
+
});
|
|
291
|
+
} catch (babelError) {
|
|
292
|
+
console.error("[PROBAT] Failed to load Babel, falling back to server compilation", babelError);
|
|
293
|
+
rawCodeFetched = false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (rawCodeFetched && rawCode && Babel) {
|
|
299
|
+
const isTSX = filePath.endsWith(".tsx");
|
|
300
|
+
rawCode = rawCode.replace(/^import\s+['"].*\.css['"];?\s*$/gm, "");
|
|
301
|
+
rawCode = rawCode.replace(/^import\s+.*from\s+['"].*\.css['"];?\s*$/gm, "");
|
|
302
|
+
rawCode = rawCode.replace(
|
|
303
|
+
/^import\s+React(?:\s*,\s*\{[^}]*\})?\s+from\s+['"]react['"];?\s*$/m,
|
|
304
|
+
"const React = window.React || globalThis.React;"
|
|
305
|
+
);
|
|
306
|
+
rawCode = rawCode.replace(
|
|
307
|
+
/^import\s+\*\s+as\s+React\s+from\s+['"]react['"];?\s*$/m,
|
|
308
|
+
"const React = window.React || globalThis.React;"
|
|
309
|
+
);
|
|
310
|
+
rawCode = rawCode.replace(
|
|
311
|
+
/^import\s+\{([^}]+)\}\s+from\s+['"]react['"];?\s*$/m,
|
|
312
|
+
(match, imports) => `const {${imports}} = window.React || globalThis.React;`
|
|
313
|
+
);
|
|
314
|
+
rawCode = rawCode.replace(/import\.meta\.env\.[\w$]+/g, "undefined");
|
|
315
|
+
rawCode = rawCode.replace(/\bimport\.meta\b/g, "({})");
|
|
316
|
+
rawCode = rawCode.replace(/^import\s+.*\/@vite\/client.*$/gm, "");
|
|
317
|
+
rawCode = rawCode.replace(/import\.meta\.hot(?:\.[\w$]+)*/g, "undefined");
|
|
318
|
+
|
|
319
|
+
const compiled = Babel.transform(rawCode, {
|
|
320
|
+
presets: [
|
|
321
|
+
["react", { runtime: "classic" }],
|
|
322
|
+
["typescript", { allExtensions: true, isTSX }],
|
|
323
|
+
],
|
|
324
|
+
plugins: [["transform-modules-commonjs", { allowTopLevelThis: true }]],
|
|
325
|
+
sourceType: "module",
|
|
326
|
+
filename: filePath,
|
|
327
|
+
}).code;
|
|
328
|
+
|
|
329
|
+
code = `
|
|
330
|
+
var __probatVariant = (function() {
|
|
331
|
+
var require = function(name) {
|
|
332
|
+
if (name === "react" || name === "react/jsx-runtime") {
|
|
333
|
+
return window.React || globalThis.React;
|
|
334
|
+
}
|
|
335
|
+
if (name.startsWith("/@vite") || name.includes("@vite/client") || name.includes(".vite/deps")) {
|
|
336
|
+
return {};
|
|
337
|
+
}
|
|
338
|
+
if (name === "react/jsx-runtime.js") {
|
|
339
|
+
return window.React || globalThis.React;
|
|
340
|
+
}
|
|
341
|
+
throw new Error("Unsupported module: " + name);
|
|
342
|
+
};
|
|
343
|
+
var module = { exports: {} };
|
|
344
|
+
var exports = module.exports;
|
|
345
|
+
${compiled}
|
|
346
|
+
return module.exports.default || module.exports;
|
|
347
|
+
})();
|
|
348
|
+
`;
|
|
349
|
+
} else {
|
|
350
|
+
rawCodeFetched = false;
|
|
351
|
+
code = "";
|
|
352
|
+
}
|
|
353
|
+
}
|
|
209
354
|
|
|
210
|
-
|
|
355
|
+
if (!rawCodeFetched || code === "") {
|
|
356
|
+
const variantUrl = `${baseUrl.replace(/\/$/, "")}/variants/${filePath}`;
|
|
357
|
+
const serverRes = await fetch(variantUrl, {
|
|
211
358
|
method: "GET",
|
|
212
359
|
headers: { Accept: "text/javascript" },
|
|
213
360
|
credentials: "include",
|
|
214
361
|
});
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
362
|
+
if (!serverRes.ok) {
|
|
363
|
+
throw new Error(`HTTP ${serverRes.status}`);
|
|
364
|
+
}
|
|
365
|
+
code = await serverRes.text();
|
|
218
366
|
}
|
|
219
367
|
|
|
220
|
-
const code = await res.text();
|
|
221
|
-
|
|
222
|
-
// The server returns an IIFE that assigns to __probatVariant
|
|
223
|
-
// Execute the code to get the component
|
|
224
|
-
// First, ensure React is available globally
|
|
225
368
|
if (typeof window !== "undefined") {
|
|
226
369
|
(window as any).React = (window as any).React || React;
|
|
227
370
|
}
|
|
228
371
|
|
|
229
|
-
// Execute the IIFE code
|
|
230
372
|
const evalFunc = new Function(`
|
|
231
373
|
var __probatVariant;
|
|
232
374
|
${code}
|
|
@@ -234,10 +376,7 @@ export async function loadVariantComponent(
|
|
|
234
376
|
`);
|
|
235
377
|
|
|
236
378
|
const result = evalFunc();
|
|
237
|
-
|
|
238
|
-
// The result is { default: Component } from esbuild's IIFE output
|
|
239
379
|
const VariantComponent = result?.default || result;
|
|
240
|
-
|
|
241
380
|
if (typeof VariantComponent === "function") {
|
|
242
381
|
return VariantComponent as React.ComponentType<any>;
|
|
243
382
|
}
|
|
@@ -35,31 +35,31 @@ function getProposalMetadata(element: HTMLElement): {
|
|
|
35
35
|
} | null {
|
|
36
36
|
// Find the nearest probat wrapper
|
|
37
37
|
const probatWrapper = element.closest('[data-probat-proposal]') as HTMLElement | null;
|
|
38
|
-
|
|
38
|
+
|
|
39
39
|
if (!probatWrapper) return null;
|
|
40
|
-
|
|
40
|
+
|
|
41
41
|
const proposalId = probatWrapper.getAttribute('data-probat-proposal');
|
|
42
42
|
if (!proposalId) return null;
|
|
43
|
-
|
|
43
|
+
|
|
44
44
|
// Check cache first
|
|
45
45
|
const cacheKey = `${proposalId}`;
|
|
46
46
|
const cached = proposalCache.get(cacheKey);
|
|
47
47
|
if (cached) return cached;
|
|
48
|
-
|
|
48
|
+
|
|
49
49
|
// Extract metadata from data attributes
|
|
50
50
|
const experimentId = probatWrapper.getAttribute('data-probat-experiment-id');
|
|
51
51
|
const variantLabel = probatWrapper.getAttribute('data-probat-variant-label') || 'control';
|
|
52
|
-
const apiBaseUrl = probatWrapper.getAttribute('data-probat-api-base-url') ||
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
const apiBaseUrl = probatWrapper.getAttribute('data-probat-api-base-url') ||
|
|
53
|
+
(typeof window !== 'undefined' && (window as any).__PROBAT_API) ||
|
|
54
|
+
'https://gushi.onrender.com';
|
|
55
|
+
|
|
56
56
|
const metadata = {
|
|
57
57
|
proposalId,
|
|
58
58
|
experimentId: experimentId || null,
|
|
59
59
|
variantLabel,
|
|
60
60
|
apiBaseUrl,
|
|
61
61
|
};
|
|
62
|
-
|
|
62
|
+
|
|
63
63
|
// Cache it
|
|
64
64
|
proposalCache.set(cacheKey, metadata);
|
|
65
65
|
return metadata;
|
|
@@ -72,11 +72,13 @@ function getProposalMetadata(element: HTMLElement): {
|
|
|
72
72
|
function handleDocumentClick(event: MouseEvent): void {
|
|
73
73
|
const target = event.target as HTMLElement | null;
|
|
74
74
|
if (!target) return;
|
|
75
|
-
|
|
75
|
+
|
|
76
76
|
// Get proposal metadata
|
|
77
77
|
const metadata = getProposalMetadata(target);
|
|
78
|
-
if (!metadata)
|
|
79
|
-
|
|
78
|
+
if (!metadata) {
|
|
79
|
+
return; // Click outside probat component
|
|
80
|
+
}
|
|
81
|
+
|
|
80
82
|
// Rate limiting: prevent duplicate rapid clicks
|
|
81
83
|
const now = Date.now();
|
|
82
84
|
const lastClick = lastClickTime.get(metadata.proposalId) || 0;
|
|
@@ -84,52 +86,65 @@ function handleDocumentClick(event: MouseEvent): void {
|
|
|
84
86
|
return; // Ignore rapid duplicate clicks
|
|
85
87
|
}
|
|
86
88
|
lastClickTime.set(metadata.proposalId, now);
|
|
87
|
-
|
|
89
|
+
|
|
88
90
|
// Extract click metadata (your existing function)
|
|
89
91
|
const clickMeta = extractClickMeta(event);
|
|
90
|
-
|
|
92
|
+
|
|
91
93
|
// Determine if we should track this click
|
|
92
94
|
// Track if:
|
|
93
95
|
// 1. It's an actionable element (button, link, etc.) - clickMeta exists
|
|
94
96
|
// 2. Element has data-probat-track attribute
|
|
95
97
|
// 3. Parent has data-probat-track attribute
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
// 4. ANY click within a probat component (more permissive - track all clicks)
|
|
99
|
+
const hasTrackAttribute = target.hasAttribute('data-probat-track') ||
|
|
100
|
+
target.closest('[data-probat-track]') !== null;
|
|
101
|
+
|
|
102
|
+
// More permissive: Track ALL clicks within probat components
|
|
103
|
+
// This ensures we capture clicks even if elements don't have explicit tracking attributes
|
|
104
|
+
const shouldTrack = clickMeta !== undefined || hasTrackAttribute || true; // Track all clicks in probat components
|
|
105
|
+
|
|
101
106
|
if (!shouldTrack) {
|
|
102
|
-
// Optional: Track "dead clicks" for UX insights
|
|
103
|
-
// Uncomment the code below if you want to track clicks on non-interactive elements
|
|
104
|
-
/*
|
|
105
|
-
const deadClickMeta = {
|
|
106
|
-
dead_click: true,
|
|
107
|
-
target_tag: target.tagName,
|
|
108
|
-
target_class: target.className || '',
|
|
109
|
-
target_id: target.id || '',
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
void sendMetric(
|
|
113
|
-
metadata.apiBaseUrl,
|
|
114
|
-
metadata.proposalId,
|
|
115
|
-
'dead_click',
|
|
116
|
-
metadata.variantLabel,
|
|
117
|
-
metadata.experimentId,
|
|
118
|
-
deadClickMeta
|
|
119
|
-
);
|
|
120
|
-
*/
|
|
121
107
|
return;
|
|
122
108
|
}
|
|
123
|
-
|
|
109
|
+
|
|
110
|
+
// Build metadata - use clickMeta if available, otherwise create basic metadata
|
|
111
|
+
const finalMeta = clickMeta || {
|
|
112
|
+
target_tag: target.tagName,
|
|
113
|
+
target_class: target.className || '',
|
|
114
|
+
target_id: target.id || '',
|
|
115
|
+
clicked_inside_probat: true, // Flag to indicate this was tracked via document-level listener
|
|
116
|
+
};
|
|
117
|
+
|
|
124
118
|
// Send click metric
|
|
119
|
+
// For control variant, don't send experiment_id (control might be synthetic)
|
|
120
|
+
// For other variants, send experiment_id if it exists and is valid (not synthetic "exp_" prefix)
|
|
121
|
+
const experimentId = metadata.variantLabel === 'control'
|
|
122
|
+
? undefined
|
|
123
|
+
: (metadata.experimentId && !metadata.experimentId.startsWith('exp_')
|
|
124
|
+
? metadata.experimentId
|
|
125
|
+
: undefined);
|
|
126
|
+
|
|
125
127
|
void sendMetric(
|
|
126
128
|
metadata.apiBaseUrl,
|
|
127
129
|
metadata.proposalId,
|
|
128
130
|
'click',
|
|
129
131
|
metadata.variantLabel,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
experimentId,
|
|
133
|
+
finalMeta
|
|
132
134
|
);
|
|
135
|
+
|
|
136
|
+
// Debug logging (always log for now to help diagnose)
|
|
137
|
+
console.log('[PROBAT] Click tracked:', {
|
|
138
|
+
proposalId: metadata.proposalId,
|
|
139
|
+
variantLabel: metadata.variantLabel,
|
|
140
|
+
target: target.tagName,
|
|
141
|
+
targetId: target.id || 'none',
|
|
142
|
+
targetClass: target.className || 'none',
|
|
143
|
+
meta: finalMeta,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Also log to help debug if API call fails
|
|
147
|
+
console.log('[PROBAT] Sending metric to:', `${metadata.apiBaseUrl}/send_metrics/${metadata.proposalId}`);
|
|
133
148
|
}
|
|
134
149
|
|
|
135
150
|
/**
|
|
@@ -141,16 +156,16 @@ export function initDocumentClickTracking(): void {
|
|
|
141
156
|
console.warn('[PROBAT] Document click listener already attached');
|
|
142
157
|
return;
|
|
143
158
|
}
|
|
144
|
-
|
|
159
|
+
|
|
145
160
|
if (typeof document === 'undefined') {
|
|
146
161
|
// Server-side rendering - skip
|
|
147
162
|
return;
|
|
148
163
|
}
|
|
149
|
-
|
|
164
|
+
|
|
150
165
|
// Use capture phase (true) to catch events before they bubble
|
|
151
166
|
// This ensures we catch clicks even if stopPropagation() is called
|
|
152
167
|
document.addEventListener('click', handleDocumentClick, true);
|
|
153
|
-
|
|
168
|
+
|
|
154
169
|
isListenerAttached = true;
|
|
155
170
|
console.log('[PROBAT] Document-level click tracking initialized');
|
|
156
171
|
}
|
|
@@ -160,11 +175,11 @@ export function initDocumentClickTracking(): void {
|
|
|
160
175
|
*/
|
|
161
176
|
export function cleanupDocumentClickTracking(): void {
|
|
162
177
|
if (!isListenerAttached) return;
|
|
163
|
-
|
|
178
|
+
|
|
164
179
|
if (typeof document !== 'undefined') {
|
|
165
180
|
document.removeEventListener('click', handleDocumentClick, true);
|
|
166
181
|
}
|
|
167
|
-
|
|
182
|
+
|
|
168
183
|
isListenerAttached = false;
|
|
169
184
|
proposalCache.clear();
|
|
170
185
|
lastClickTime.clear();
|