@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/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
- export type RetrieveResponse = {
5
- proposal_id: string;
6
- experiment_id: string | null;
7
- label: string | null;
8
- };
4
+ // ── Types ──────────────────────────────────────────────────────────────────
9
5
 
10
- export type ComponentVariantInfo = {
11
- experiment_id: string;
12
- label: string;
13
- file_path: string | null;
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
- export type ComponentExperimentConfig = {
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
- // Shared promise cache to prevent multiple simultaneous API calls for the same proposal
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
- baseUrl: string,
31
- proposalId: string
32
- ): Promise<{ experiment_id: string; label: string }> {
33
- // Check if there's already a pending fetch for this proposal
34
- const existingFetch = pendingFetches.get(proposalId);
35
- if (existingFetch) {
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
- // Create new fetch promise
40
- const fetchPromise = (async () => {
32
+ const promise = (async () => {
41
33
  try {
42
- const url = `${baseUrl.replace(/\/$/, "")}/retrieve_react_experiment/${encodeURIComponent(proposalId)}`;
34
+ const url = `${host.replace(/\/$/, "")}/experiment/decide`;
43
35
  const res = await fetch(url, {
44
36
  method: "POST",
45
- headers: { Accept: "application/json" },
46
- credentials: "include", // Include cookies for user identification
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 RetrieveResponse;
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
- // Remove from pending cache after completion
67
- pendingFetches.delete(proposalId);
51
+ pendingDecisions.delete(experimentId);
68
52
  }
69
53
  })();
70
54
 
71
- // Store the promise so other components can wait for the same call
72
- pendingFetches.set(proposalId, fetchPromise);
73
- return fetchPromise;
55
+ pendingDecisions.set(experimentId, promise);
56
+ return promise;
74
57
  }
75
58
 
76
- export async function sendMetric(
77
- baseUrl: string,
78
- proposalId: string,
79
- metricName: "visit" | "click" | string,
80
- variantLabel: string = "control",
81
- experimentId?: string,
82
- dimensions: Record<string, any> = {}
83
- ) {
84
- const url = `${baseUrl.replace(/\/$/, "")}/send_metrics/${encodeURIComponent(proposalId)}`;
85
- const body = {
86
- experiment_id: experimentId ?? null,
87
- variant_label: variantLabel,
88
- metric_name: metricName,
89
- metric_value: 1,
90
- metric_unit: "count",
91
- source: "react",
92
- environment: detectEnvironment(), // Include environment (dev or prod)
93
- dimensions,
94
- captured_at: new Date().toISOString(),
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
- await fetch(url, {
84
+ const url = `${host.replace(/\/$/, "")}/experiment/metrics`;
85
+ fetch(url, {
98
86
  method: "POST",
99
- headers: {
100
- Accept: "application/json",
101
- "Content-Type": "application/json",
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
- // Silently fail - metrics should not break the app
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
- return '/' + parts.join('/');
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
- * Fetch and compile a module from absolute path
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
- async function loadRelativeModule(
235
- absolutePath: string,
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
- // Try GitHub if local failed
270
- if (!moduleCode && repoFullName) {
271
- try {
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
- if (!moduleCode || !modulePath) {
287
- throw new Error(`Could not resolve module: ${absolutePath} from ${baseFilePath}`);
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
- // Compile the module
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
- export async function loadVariantComponent(
345
- baseUrl: string,
346
- proposalId: string,
347
- experimentId: string,
348
- filePath: string | null,
349
- repoFullName?: string,
350
- baseRef?: string
351
- ): Promise<React.ComponentType<any> | null> {
352
- if (!filePath) {
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
-