@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/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
- // Fetch the variant code (server compiles TSX/JSX to JS)
208
- const variantUrl = `${baseUrl.replace(/\/$/, "")}/variants/${filePath}`;
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
- const res = await fetch(variantUrl, {
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
- if (!res.ok) {
217
- throw new Error(`HTTP ${res.status}`);
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
- (typeof window !== 'undefined' && (window as any).__PROBAT_API) ||
54
- 'https://gushi.onrender.com';
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) return;
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
- const hasTrackAttribute = target.hasAttribute('data-probat-track') ||
97
- target.closest('[data-probat-track]') !== null;
98
-
99
- const shouldTrack = clickMeta !== undefined || hasTrackAttribute;
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
- metadata.experimentId || undefined,
131
- clickMeta
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();