@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.
@@ -1,188 +0,0 @@
1
- "use client";
2
-
3
- import { useState, useEffect, useCallback } from "react";
4
- import type { MouseEvent } from "react";
5
- import { useProbatContext } from "../context/ProbatContext";
6
- import { fetchDecision } from "../utils/api";
7
- import {
8
- readChoice,
9
- writeChoice,
10
- hasTrackedVisit,
11
- markTrackedVisit,
12
- } from "../utils/storage";
13
- import { sendMetric } from "../utils/api";
14
-
15
- export interface UseExperimentReturn {
16
- /**
17
- * The current variant label (e.g., "control", "variant-a")
18
- */
19
- variantLabel: string;
20
- /**
21
- * The experiment ID
22
- */
23
- experimentId: string | null;
24
- /**
25
- * Whether the experiment decision is still loading
26
- */
27
- isLoading: boolean;
28
- /**
29
- * Any error that occurred while fetching the experiment
30
- */
31
- error: Error | null;
32
- /**
33
- * Manually track a click for this experiment
34
- */
35
- trackClick: (event?: MouseEvent | null) => void;
36
- }
37
-
38
- /**
39
- * Hook for fetching and applying experiment variants
40
- *
41
- * @param proposalId - The proposal ID for the experiment
42
- * @param options - Optional configuration
43
- * @param options.autoTrackImpression - Automatically track impression when variant is loaded (default: true)
44
- *
45
- * @example
46
- * ```tsx
47
- * const { variantLabel, isLoading, trackClick } = useExperiment("proposal-id");
48
- *
49
- * if (isLoading) return <div>Loading...</div>;
50
- *
51
- * return (
52
- * <div onClick={trackClick}>
53
- * {variantLabel === "control" ? <ControlComponent /> : <VariantComponent />}
54
- * </div>
55
- * );
56
- * ```
57
- */
58
- export function useExperiment(
59
- proposalId: string,
60
- options?: { autoTrackImpression?: boolean }
61
- ): UseExperimentReturn {
62
- const context = useProbatContext();
63
- const { apiBaseUrl } = context;
64
- const [choice, setChoice] = useState<{
65
- experiment_id: string;
66
- label: string;
67
- } | null>(null);
68
- const [isLoading, setIsLoading] = useState(true);
69
- const [error, setError] = useState<Error | null>(null);
70
-
71
- const autoTrackImpression = options?.autoTrackImpression !== false;
72
-
73
- // Fetch experiment decision
74
- useEffect(() => {
75
- let alive = true;
76
-
77
- // Detect if we are in heatmap mode
78
- const isHeatmapMode = typeof window !== 'undefined' &&
79
- new URLSearchParams(window.location.search).get('heatmap') === 'true';
80
-
81
- // HIGH PRIORITY: Check if context is already forcing a specific variant for this proposal
82
- // (This happens during Live Heatmap visualization)
83
- if (context.proposalId === proposalId && context.variantLabel) {
84
- console.log(`[PROBAT] Forced variant from context: ${context.variantLabel}`);
85
- setChoice({
86
- experiment_id: `forced_${proposalId}`,
87
- label: context.variantLabel
88
- });
89
- setIsLoading(false);
90
- return;
91
- }
92
-
93
- // If we are in heatmap mode, bypass the cache to avoid showing stale variants
94
- const cached = isHeatmapMode ? null : readChoice(proposalId);
95
-
96
- if (cached) {
97
- setChoice({ experiment_id: cached.experiment_id, label: cached.label });
98
- setIsLoading(false);
99
- } else {
100
- setIsLoading(true);
101
- (async () => {
102
- try {
103
- const { experiment_id, label } = await fetchDecision(
104
- apiBaseUrl,
105
- proposalId
106
- );
107
- if (!alive) return;
108
-
109
- // Only write to choice cache if NOT in heatmap mode
110
- if (!isHeatmapMode) {
111
- writeChoice(proposalId, experiment_id, label);
112
- }
113
-
114
- setChoice({ experiment_id, label });
115
- setError(null);
116
- } catch (e) {
117
- if (!alive) return;
118
- const err = e instanceof Error ? e : new Error(String(e));
119
- setError(err);
120
- setChoice({
121
- experiment_id: `exp_${proposalId}`,
122
- label: "control",
123
- });
124
- } finally {
125
- if (alive) {
126
- setIsLoading(false);
127
- }
128
- }
129
- })();
130
- }
131
-
132
- return () => {
133
- alive = false;
134
- };
135
- }, [proposalId, apiBaseUrl, context.proposalId, context.variantLabel]);
136
-
137
- // Track impression when variant is determined
138
- useEffect(() => {
139
- if (!autoTrackImpression || !choice) return;
140
-
141
- const exp = choice.experiment_id;
142
- const lbl = choice.label ?? "control";
143
- if (!lbl) return;
144
-
145
- // Only track if we haven't already tracked this visit
146
- if (hasTrackedVisit(proposalId, lbl)) return;
147
- markTrackedVisit(proposalId, lbl);
148
- void sendMetric(apiBaseUrl, proposalId, "visit", lbl, exp);
149
- }, [proposalId, choice?.experiment_id, choice?.label, apiBaseUrl, autoTrackImpression]);
150
-
151
- const trackClick = useCallback(
152
- (event?: MouseEvent | null) => {
153
- const exp = choice?.experiment_id;
154
- const lbl = choice?.label ?? "control";
155
- const meta = event
156
- ? (() => {
157
- const rawTarget = event.target as HTMLElement | null;
158
- if (!rawTarget) return undefined;
159
- const actionable = rawTarget.closest(
160
- "[data-probat-conversion='true'], button, a, [role='button']"
161
- );
162
- if (!actionable) return undefined;
163
- const meta: Record<string, any> = {
164
- target_tag: actionable.tagName,
165
- };
166
- if (actionable.id) meta.target_id = actionable.id;
167
- const attr = actionable.getAttribute("data-probat-conversion");
168
- if (attr) meta.conversion_attr = attr;
169
- const text = actionable.textContent?.trim();
170
- if (text) meta.target_text = text.slice(0, 120);
171
- return meta;
172
- })()
173
- : undefined;
174
-
175
- void sendMetric(apiBaseUrl, proposalId, "click", lbl, undefined, meta);
176
- },
177
- [proposalId, choice?.experiment_id, choice?.label, apiBaseUrl]
178
- );
179
-
180
- return {
181
- variantLabel: choice?.label ?? "control",
182
- experimentId: choice?.experiment_id ?? null,
183
- isLoading,
184
- error,
185
- trackClick,
186
- };
187
- }
188
-
@@ -1,215 +0,0 @@
1
- /**
2
- * Document-level click tracking for Probat experiments
3
- *
4
- * This module implements event delegation at the document level to track clicks
5
- * even when components don't have onClick handlers or when stopPropagation() is called.
6
- */
7
-
8
- import { sendMetric } from './api';
9
- import { extractClickMeta } from './api';
10
-
11
- // Cache for proposal metadata to avoid repeated DOM queries
12
- const proposalCache = new Map<string, {
13
- proposalId: string;
14
- experimentId: string | null;
15
- variantLabel: string;
16
- apiBaseUrl: string;
17
- }>();
18
-
19
- // Track if listener is already attached
20
- let isListenerAttached = false;
21
-
22
- // Rate limiting: prevent duplicate rapid clicks
23
- const lastClickTime = new Map<string, number>();
24
- const DEBOUNCE_MS = 100; // Ignore clicks within 100ms of each other
25
-
26
- /**
27
- * Get proposal metadata from DOM element
28
- * Looks for the nearest [data-probat-proposal] wrapper
29
- */
30
- function getProposalMetadata(element: HTMLElement): {
31
- proposalId: string;
32
- experimentId: string | null;
33
- variantLabel: string;
34
- apiBaseUrl: string;
35
- } | null {
36
- // Find the nearest probat wrapper
37
- const probatWrapper = element.closest('[data-probat-proposal]') as HTMLElement | null;
38
-
39
- if (!probatWrapper) return null;
40
-
41
- const proposalId = probatWrapper.getAttribute('data-probat-proposal');
42
- if (!proposalId) return null;
43
-
44
- // Check cache first
45
- const cacheKey = `${proposalId}`;
46
- const cached = proposalCache.get(cacheKey);
47
- if (cached) return cached;
48
-
49
- // Extract metadata from data attributes
50
- const experimentId = probatWrapper.getAttribute('data-probat-experiment-id');
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
-
56
- const metadata = {
57
- proposalId,
58
- experimentId: experimentId || null,
59
- variantLabel,
60
- apiBaseUrl,
61
- };
62
-
63
- // Cache it
64
- proposalCache.set(cacheKey, metadata);
65
- return metadata;
66
- }
67
-
68
- /**
69
- * Handle click event at document level
70
- * Uses capture phase to catch events before stopPropagation()
71
- */
72
- function handleDocumentClick(event: MouseEvent): void {
73
- const target = event.target as HTMLElement | null;
74
- if (!target) return;
75
-
76
- // Get proposal metadata
77
- const metadata = getProposalMetadata(target);
78
- if (!metadata) {
79
- return; // Click outside probat component
80
- }
81
-
82
- // Rate limiting: prevent duplicate rapid clicks
83
- const now = Date.now();
84
- const lastClick = lastClickTime.get(metadata.proposalId) || 0;
85
- if (now - lastClick < DEBOUNCE_MS) {
86
- return; // Ignore rapid duplicate clicks
87
- }
88
- lastClickTime.set(metadata.proposalId, now);
89
-
90
- // Extract click metadata (your existing function)
91
- const clickMeta = extractClickMeta(event);
92
-
93
- // Determine if we should track this click
94
- // Track if:
95
- // 1. It's an actionable element (button, link, etc.) - clickMeta exists
96
- // 2. Element has data-probat-track attribute
97
- // 3. Parent has data-probat-track attribute
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
-
106
- if (!shouldTrack) {
107
- return;
108
- }
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
-
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
-
127
- void sendMetric(
128
- metadata.apiBaseUrl,
129
- metadata.proposalId,
130
- 'click',
131
- metadata.variantLabel,
132
- experimentId,
133
- finalMeta
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}`);
148
- }
149
-
150
- /**
151
- * Initialize document-level click tracking
152
- * Call this once when your app initializes (typically in ProbatProvider)
153
- */
154
- export function initDocumentClickTracking(): void {
155
- if (isListenerAttached) {
156
- console.warn('[PROBAT] Document click listener already attached');
157
- return;
158
- }
159
-
160
- if (typeof document === 'undefined') {
161
- // Server-side rendering - skip
162
- return;
163
- }
164
-
165
- // Use capture phase (true) to catch events before they bubble
166
- // This ensures we catch clicks even if stopPropagation() is called
167
- document.addEventListener('click', handleDocumentClick, true);
168
-
169
- isListenerAttached = true;
170
- console.log('[PROBAT] Document-level click tracking initialized');
171
- }
172
-
173
- /**
174
- * Clean up the listener (useful for testing or cleanup)
175
- */
176
- export function cleanupDocumentClickTracking(): void {
177
- if (!isListenerAttached) return;
178
-
179
- if (typeof document !== 'undefined') {
180
- document.removeEventListener('click', handleDocumentClick, true);
181
- }
182
-
183
- isListenerAttached = false;
184
- proposalCache.clear();
185
- lastClickTime.clear();
186
- }
187
-
188
- /**
189
- * Update proposal metadata cache (call this when proposal data changes)
190
- * This is useful if you want to update the cache without waiting for DOM queries
191
- */
192
- export function updateProposalMetadata(
193
- proposalId: string,
194
- metadata: {
195
- experimentId?: string | null;
196
- variantLabel?: string;
197
- apiBaseUrl?: string;
198
- }
199
- ): void {
200
- const existing = proposalCache.get(proposalId);
201
- if (existing) {
202
- proposalCache.set(proposalId, {
203
- ...existing,
204
- ...metadata,
205
- });
206
- }
207
- }
208
-
209
- /**
210
- * Clear the proposal cache (useful for testing)
211
- */
212
- export function clearProposalCache(): void {
213
- proposalCache.clear();
214
- }
215
-