@nextsparkjs/plugin-amplitude 0.1.0-beta.1

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.
@@ -0,0 +1,440 @@
1
+ /**
2
+ * ExperimentWrapper component for A/B testing integration
3
+ * Provides declarative experiment participation with automatic exposure tracking
4
+ */
5
+
6
+ import React, { useEffect, useRef, useState, ReactNode } from 'react';
7
+ import { useExperiment } from '../hooks/useExperiment';
8
+
9
+ // Component props
10
+ export interface ExperimentWrapperProps {
11
+ experimentId: string;
12
+ userId?: string;
13
+ children: ReactNode | ((variant: string | null, config: Record<string, any> | null) => ReactNode);
14
+ fallback?: ReactNode;
15
+ trackExposureOnMount?: boolean;
16
+ trackExposureOnVisible?: boolean;
17
+ exposureDelay?: number;
18
+ className?: string;
19
+ style?: React.CSSProperties;
20
+ onVariantAssigned?: (variant: string | null, config: Record<string, any> | null) => void;
21
+ onExposed?: (experimentId: string, variant: string) => void;
22
+ onError?: (error: Error) => void;
23
+ }
24
+
25
+ // Intersection Observer hook for visibility tracking
26
+ const useIntersectionObserver = (
27
+ elementRef: React.RefObject<Element | null>,
28
+ callback: () => void,
29
+ options: IntersectionObserverInit = {}
30
+ ) => {
31
+ const [isVisible, setIsVisible] = useState(false);
32
+ const observerRef = useRef<IntersectionObserver | null>(null);
33
+
34
+ useEffect(() => {
35
+ if (!elementRef.current || !('IntersectionObserver' in window)) {
36
+ return;
37
+ }
38
+
39
+ observerRef.current = new IntersectionObserver(
40
+ ([entry]) => {
41
+ const visible = entry.isIntersecting;
42
+ setIsVisible(visible);
43
+ if (visible) {
44
+ callback();
45
+ }
46
+ },
47
+ {
48
+ threshold: 0.1,
49
+ ...options,
50
+ }
51
+ );
52
+
53
+ observerRef.current.observe(elementRef.current);
54
+
55
+ return () => {
56
+ if (observerRef.current) {
57
+ observerRef.current.disconnect();
58
+ }
59
+ };
60
+ }, [elementRef, callback, options]);
61
+
62
+ return isVisible;
63
+ };
64
+
65
+ /**
66
+ * ExperimentWrapper Component
67
+ */
68
+ export const ExperimentWrapper: React.FC<ExperimentWrapperProps> = ({
69
+ experimentId,
70
+ userId,
71
+ children,
72
+ fallback = null,
73
+ trackExposureOnMount = true,
74
+ trackExposureOnVisible = false,
75
+ exposureDelay = 0,
76
+ className,
77
+ style,
78
+ onVariantAssigned,
79
+ onExposed,
80
+ onError,
81
+ }) => {
82
+ const {
83
+ getVariant,
84
+ isInExperiment,
85
+ trackExposure,
86
+ canRunExperiments,
87
+ experiments,
88
+ getExperimentConfig,
89
+ } = useExperiment();
90
+
91
+ const [variant, setVariant] = useState<string | null>(null);
92
+ const [config, setConfig] = useState<Record<string, any> | null>(null);
93
+ const [isLoading, setIsLoading] = useState(true);
94
+ const [error, setError] = useState<Error | null>(null);
95
+ const [hasTrackedExposure, setHasTrackedExposure] = useState(false);
96
+
97
+ const elementRef = useRef<HTMLDivElement>(null);
98
+ const exposureTimeoutRef = useRef<NodeJS.Timeout | null>(null);
99
+
100
+ // Determine user ID
101
+ const effectiveUserId = userId || 'anonymous';
102
+
103
+ // Get experiment configuration
104
+ const experiment = experiments.get(experimentId);
105
+
106
+ // Track exposure when visible
107
+ const handleVisibilityExposure = () => {
108
+ if (trackExposureOnVisible && variant && !hasTrackedExposure) {
109
+ handleExposure();
110
+ }
111
+ };
112
+
113
+ // Use intersection observer for visibility tracking
114
+ useIntersectionObserver(elementRef, handleVisibilityExposure, {
115
+ threshold: 0.5, // Track when 50% visible
116
+ });
117
+
118
+ // Handle exposure tracking
119
+ const handleExposure = () => {
120
+ if (!variant || hasTrackedExposure || !canRunExperiments) return;
121
+
122
+ const trackExposureWithDelay = () => {
123
+ trackExposure(experimentId, variant)
124
+ .then(() => {
125
+ setHasTrackedExposure(true);
126
+ onExposed?.(experimentId, variant);
127
+ console.log(`[Experiment] Tracked exposure for ${experimentId}:${variant}`);
128
+ })
129
+ .catch((err) => {
130
+ console.error('[Experiment] Failed to track exposure:', err);
131
+ setError(err);
132
+ onError?.(err);
133
+ });
134
+ };
135
+
136
+ if (exposureDelay > 0) {
137
+ exposureTimeoutRef.current = setTimeout(trackExposureWithDelay, exposureDelay);
138
+ } else {
139
+ trackExposureWithDelay();
140
+ }
141
+ };
142
+
143
+ // Initialize experiment
144
+ useEffect(() => {
145
+ if (!canRunExperiments) {
146
+ setIsLoading(false);
147
+ return;
148
+ }
149
+
150
+ try {
151
+ setIsLoading(true);
152
+ setError(null);
153
+
154
+ // Check if experiment exists
155
+ if (!experiment) {
156
+ console.warn(`[Experiment] Experiment ${experimentId} not found`);
157
+ setVariant(null);
158
+ setConfig(null);
159
+ setIsLoading(false);
160
+ return;
161
+ }
162
+
163
+ // Get variant assignment
164
+ const assignedVariant = getVariant(experimentId, effectiveUserId);
165
+
166
+ // Get variant config from experiment config
167
+ let variantConfig: Record<string, any> | null = null;
168
+ if (assignedVariant && experiment.variants) {
169
+ const variantData = experiment.variants.find((v: any) => v.id === assignedVariant);
170
+ variantConfig = variantData?.config || null;
171
+ }
172
+
173
+ setVariant(assignedVariant);
174
+ setConfig(variantConfig);
175
+ setIsLoading(false);
176
+
177
+ // Notify parent of assignment
178
+ onVariantAssigned?.(assignedVariant, variantConfig);
179
+
180
+ console.log(`[Experiment] ${experimentId} assigned variant:`, assignedVariant);
181
+ } catch (err) {
182
+ console.error('[Experiment] Failed to initialize experiment:', err);
183
+ const error = err instanceof Error ? err : new Error('Failed to initialize experiment');
184
+ setError(error);
185
+ setIsLoading(false);
186
+ onError?.(error);
187
+ }
188
+ }, [
189
+ experimentId,
190
+ effectiveUserId,
191
+ canRunExperiments,
192
+ experiment,
193
+ getVariant,
194
+ getExperimentConfig,
195
+ onVariantAssigned,
196
+ onError,
197
+ ]);
198
+
199
+ // Track exposure on mount
200
+ useEffect(() => {
201
+ if (trackExposureOnMount && variant && !trackExposureOnVisible) {
202
+ handleExposure();
203
+ }
204
+ }, [variant, trackExposureOnMount, trackExposureOnVisible]);
205
+
206
+ // Cleanup timeout on unmount
207
+ useEffect(() => {
208
+ return () => {
209
+ if (exposureTimeoutRef.current) {
210
+ clearTimeout(exposureTimeoutRef.current);
211
+ }
212
+ };
213
+ }, []);
214
+
215
+ // Handle loading state
216
+ if (isLoading) {
217
+ return (
218
+ <div
219
+ ref={elementRef}
220
+ className={className}
221
+ style={style}
222
+ data-experiment-loading={experimentId}
223
+ >
224
+ {fallback}
225
+ </div>
226
+ );
227
+ }
228
+
229
+ // Handle error state
230
+ if (error) {
231
+ console.error(`[Experiment] Error in experiment ${experimentId}:`, error);
232
+ return (
233
+ <div
234
+ ref={elementRef}
235
+ className={className}
236
+ style={style}
237
+ data-experiment-error={experimentId}
238
+ >
239
+ {fallback}
240
+ </div>
241
+ );
242
+ }
243
+
244
+ // Handle no experiment or not in experiment
245
+ if (!canRunExperiments || !experiment || !variant) {
246
+ return (
247
+ <div
248
+ ref={elementRef}
249
+ className={className}
250
+ style={style}
251
+ data-experiment-excluded={experimentId}
252
+ >
253
+ {fallback}
254
+ </div>
255
+ );
256
+ }
257
+
258
+ // Render experiment content
259
+ const content = typeof children === 'function'
260
+ ? children(variant, config)
261
+ : children;
262
+
263
+ return (
264
+ <div
265
+ ref={elementRef}
266
+ className={className}
267
+ style={style}
268
+ data-experiment={experimentId}
269
+ data-variant={variant}
270
+ data-exposed={hasTrackedExposure}
271
+ >
272
+ {content}
273
+ </div>
274
+ );
275
+ };
276
+
277
+ /**
278
+ * Feature Flag Component (simplified experiment wrapper)
279
+ */
280
+ export interface FeatureFlagProps {
281
+ flag: string;
282
+ userId?: string;
283
+ children: ReactNode;
284
+ fallback?: ReactNode;
285
+ onEnabled?: (flag: string) => void;
286
+ onDisabled?: (flag: string) => void;
287
+ }
288
+
289
+ export const FeatureFlag: React.FC<FeatureFlagProps> = ({
290
+ flag,
291
+ userId,
292
+ children,
293
+ fallback = null,
294
+ onEnabled,
295
+ onDisabled,
296
+ }) => {
297
+ return (
298
+ <ExperimentWrapper
299
+ experimentId={flag}
300
+ userId={userId}
301
+ fallback={fallback}
302
+ trackExposureOnMount={true}
303
+ onVariantAssigned={(variant) => {
304
+ if (variant === 'enabled' || variant === 'treatment') {
305
+ onEnabled?.(flag);
306
+ } else {
307
+ onDisabled?.(flag);
308
+ }
309
+ }}
310
+ >
311
+ {(variant) => {
312
+ // For feature flags, show content if variant is 'enabled' or 'treatment'
313
+ if (variant === 'enabled' || variant === 'treatment') {
314
+ return children;
315
+ }
316
+ return fallback;
317
+ }}
318
+ </ExperimentWrapper>
319
+ );
320
+ };
321
+
322
+ /**
323
+ * A/B Test Component (variant-based rendering)
324
+ */
325
+ export interface ABTestProps {
326
+ experimentId: string;
327
+ userId?: string;
328
+ variants: Record<string, ReactNode>;
329
+ fallback?: ReactNode;
330
+ onVariantShown?: (experimentId: string, variant: string) => void;
331
+ }
332
+
333
+ export const ABTest: React.FC<ABTestProps> = ({
334
+ experimentId,
335
+ userId,
336
+ variants,
337
+ fallback = null,
338
+ onVariantShown,
339
+ }) => {
340
+ return (
341
+ <ExperimentWrapper
342
+ experimentId={experimentId}
343
+ userId={userId}
344
+ fallback={fallback}
345
+ trackExposureOnMount={true}
346
+ onExposed={onVariantShown}
347
+ >
348
+ {(variant) => {
349
+ if (variant && variants[variant]) {
350
+ return variants[variant];
351
+ }
352
+ return fallback;
353
+ }}
354
+ </ExperimentWrapper>
355
+ );
356
+ };
357
+
358
+ /**
359
+ * Multivariate Test Component (config-based rendering)
360
+ */
361
+ export interface MultivariateTestProps {
362
+ experimentId: string;
363
+ userId?: string;
364
+ children: (config: Record<string, any> | null) => ReactNode;
365
+ fallback?: ReactNode;
366
+ }
367
+
368
+ export const MultivariateTest: React.FC<MultivariateTestProps> = ({
369
+ experimentId,
370
+ userId,
371
+ children,
372
+ fallback = null,
373
+ }) => {
374
+ return (
375
+ <ExperimentWrapper
376
+ experimentId={experimentId}
377
+ userId={userId}
378
+ fallback={fallback}
379
+ trackExposureOnMount={true}
380
+ >
381
+ {(variant, config) => {
382
+ if (variant && config) {
383
+ return children(config);
384
+ }
385
+ return fallback;
386
+ }}
387
+ </ExperimentWrapper>
388
+ );
389
+ };
390
+
391
+ /**
392
+ * Hook for programmatic experiment access
393
+ */
394
+ export const useExperimentComponent = (experimentId: string, userId?: string) => {
395
+ const {
396
+ getVariant,
397
+ isInExperiment,
398
+ trackExposure,
399
+ trackConversion,
400
+ canRunExperiments,
401
+ getExperimentConfig,
402
+ } = useExperiment();
403
+
404
+ const effectiveUserId = userId || 'anonymous';
405
+
406
+ const variant = canRunExperiments ? getVariant(experimentId, effectiveUserId) : null;
407
+
408
+ // Get variant config from experiment config
409
+ let config: Record<string, any> | null = null;
410
+ if (canRunExperiments && variant) {
411
+ const experiment = getExperimentConfig(experimentId);
412
+ if (experiment?.variants) {
413
+ const variantData = experiment.variants.find((v: any) => v.id === variant);
414
+ config = variantData?.config || null;
415
+ }
416
+ }
417
+
418
+ const inExperiment = canRunExperiments ? isInExperiment(experimentId, effectiveUserId) : false;
419
+
420
+ const expose = () => {
421
+ if (variant) {
422
+ return trackExposure(experimentId, variant);
423
+ }
424
+ };
425
+
426
+ const convert = (metricId?: string, value?: number) => {
427
+ return trackConversion(experimentId, metricId, value);
428
+ };
429
+
430
+ return {
431
+ variant,
432
+ config,
433
+ inExperiment,
434
+ canRunExperiments,
435
+ expose,
436
+ convert,
437
+ };
438
+ };
439
+
440
+ export default ExperimentWrapper;