@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.
- package/CODE_REVIEW_REPORT.md +462 -0
- package/README.md +619 -0
- package/__tests__/amplitude-core.test.ts +279 -0
- package/__tests__/hooks.test.ts +478 -0
- package/__tests__/validation.test.ts +393 -0
- package/components/AnalyticsDashboard.tsx +339 -0
- package/components/ConsentManager.tsx +265 -0
- package/components/ExperimentWrapper.tsx +440 -0
- package/components/PerformanceMonitor.tsx +578 -0
- package/hooks/useAmplitude.ts +132 -0
- package/hooks/useAmplitudeEvents.ts +100 -0
- package/hooks/useExperiment.ts +195 -0
- package/hooks/useSessionReplay.ts +238 -0
- package/jest.setup.ts +276 -0
- package/lib/amplitude-core.ts +178 -0
- package/lib/cache.ts +181 -0
- package/lib/performance.ts +319 -0
- package/lib/queue.ts +389 -0
- package/lib/security.ts +188 -0
- package/package.json +15 -0
- package/plugin.config.ts +58 -0
- package/providers/AmplitudeProvider.tsx +113 -0
- package/styles/amplitude.css +593 -0
- package/translations/en.json +45 -0
- package/translations/es.json +45 -0
- package/tsconfig.json +47 -0
- package/types/amplitude.types.ts +105 -0
- package/utils/debounce.ts +133 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useEffect, useCallback } from 'react';
|
|
2
|
+
import { useRouter } from 'next/router';
|
|
3
|
+
import { useAmplitude } from './useAmplitude';
|
|
4
|
+
import { debounce } from '../utils/debounce';
|
|
5
|
+
import { trackPerformanceMetric } from '../lib/performance';
|
|
6
|
+
import { EventType } from '../types/amplitude.types';
|
|
7
|
+
|
|
8
|
+
export const useAmplitudeEvents = () => {
|
|
9
|
+
const { track, identify, setUserProperties, isInitialized, context } = useAmplitude();
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
|
|
12
|
+
// Page View Tracking
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
if (!isInitialized || !router) return;
|
|
15
|
+
|
|
16
|
+
const handleRouteChange = (url: string) => {
|
|
17
|
+
const startTime = performance.now();
|
|
18
|
+
track('Page Viewed' as EventType, { path: url, referrer: document.referrer });
|
|
19
|
+
const endTime = performance.now();
|
|
20
|
+
trackPerformanceMetric('page_view_duration', endTime - startTime, 'ms', { path: url });
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
router.events.on('routeChangeComplete', handleRouteChange);
|
|
24
|
+
return () => {
|
|
25
|
+
router.events.off('routeChangeComplete', handleRouteChange);
|
|
26
|
+
};
|
|
27
|
+
}, [isInitialized, router, track]);
|
|
28
|
+
|
|
29
|
+
// Click Tracking
|
|
30
|
+
const handleClick = useCallback(debounce((event: MouseEvent) => {
|
|
31
|
+
const target = event.target as HTMLElement;
|
|
32
|
+
let eventName = 'Click';
|
|
33
|
+
let properties: Record<string, any> = {};
|
|
34
|
+
|
|
35
|
+
if (target.dataset.track) {
|
|
36
|
+
eventName = target.dataset.track;
|
|
37
|
+
} else if (target.tagName === 'BUTTON') {
|
|
38
|
+
eventName = 'Button Click';
|
|
39
|
+
properties.buttonText = target.innerText.substring(0, 100);
|
|
40
|
+
} else if (target.tagName === 'A') {
|
|
41
|
+
eventName = 'Link Click';
|
|
42
|
+
properties.linkHref = (target as HTMLAnchorElement).href;
|
|
43
|
+
properties.linkText = target.innerText.substring(0, 100);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
properties.elementId = target.id;
|
|
47
|
+
properties.elementClass = target.className;
|
|
48
|
+
properties.pagePath = router?.asPath;
|
|
49
|
+
|
|
50
|
+
if (eventName !== 'Click' || Object.keys(properties).length > 0) {
|
|
51
|
+
track(eventName as EventType, properties);
|
|
52
|
+
}
|
|
53
|
+
}, 300), [track, router]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!isInitialized) return;
|
|
57
|
+
document.addEventListener('click', handleClick);
|
|
58
|
+
return () => {
|
|
59
|
+
document.removeEventListener('click', handleClick);
|
|
60
|
+
};
|
|
61
|
+
}, [isInitialized, handleClick]);
|
|
62
|
+
|
|
63
|
+
// Form Submission Tracking
|
|
64
|
+
const handleFormSubmit = useCallback((event: Event) => {
|
|
65
|
+
const form = event.target as HTMLFormElement;
|
|
66
|
+
const formName = form.name || form.id || 'Unnamed Form';
|
|
67
|
+
const properties: Record<string, any> = { formName, pagePath: router?.asPath };
|
|
68
|
+
|
|
69
|
+
// Collect form field data, excluding sensitive fields
|
|
70
|
+
const formData = new FormData(form);
|
|
71
|
+
formData.forEach((value, key) => {
|
|
72
|
+
if (typeof value === 'string' && !/password|secret|token/i.test(key)) {
|
|
73
|
+
properties[`field_${key}`] = value.substring(0, 200);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
track('Form Submitted' as EventType, properties);
|
|
78
|
+
}, [track, router]);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!isInitialized) return;
|
|
82
|
+
document.querySelectorAll('form').forEach(form => {
|
|
83
|
+
form.addEventListener('submit', handleFormSubmit);
|
|
84
|
+
});
|
|
85
|
+
return () => {
|
|
86
|
+
document.querySelectorAll('form').forEach(form => {
|
|
87
|
+
form.removeEventListener('submit', handleFormSubmit);
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
}, [isInitialized, handleFormSubmit]);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
trackPageView: (path: string) => track('Page Viewed' as EventType, { path }),
|
|
94
|
+
trackClick: (element: string, properties?: Record<string, any>) =>
|
|
95
|
+
track('Element Click' as EventType, { element, ...properties }),
|
|
96
|
+
trackFormSubmit: (formName: string, properties?: Record<string, any>) =>
|
|
97
|
+
track('Form Submitted' as EventType, { formName, ...properties }),
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { useAmplitudeContext } from '../providers/AmplitudeProvider';
|
|
3
|
+
import { EventProperties, EventType } from '../types/amplitude.types';
|
|
4
|
+
|
|
5
|
+
interface ExperimentConfig {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
status: 'draft' | 'running' | 'paused' | 'completed';
|
|
10
|
+
variants: ExperimentVariant[];
|
|
11
|
+
targeting: ExperimentTargeting;
|
|
12
|
+
metrics: string[];
|
|
13
|
+
startDate: Date;
|
|
14
|
+
endDate?: Date;
|
|
15
|
+
sampleSize?: number;
|
|
16
|
+
confidenceLevel?: number;
|
|
17
|
+
minimumDetectableEffect?: number;
|
|
18
|
+
allocations: Record<string, number>;
|
|
19
|
+
stickiness: 'user' | 'device' | 'session';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ExperimentVariant {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
allocation: number;
|
|
27
|
+
isControl: boolean;
|
|
28
|
+
config: Record<string, any>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ExperimentTargeting {
|
|
32
|
+
userProperties?: Record<string, any>;
|
|
33
|
+
geolocation?: string[];
|
|
34
|
+
deviceType?: string[];
|
|
35
|
+
timeRange?: { start: string; end: string };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ExperimentExposure {
|
|
39
|
+
experimentId: string;
|
|
40
|
+
variantId: string;
|
|
41
|
+
userId?: string;
|
|
42
|
+
timestamp: number;
|
|
43
|
+
properties?: EventProperties;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ExperimentConversion {
|
|
47
|
+
experimentId: string;
|
|
48
|
+
variantId: string;
|
|
49
|
+
metricId: string;
|
|
50
|
+
value?: number;
|
|
51
|
+
userId?: string;
|
|
52
|
+
timestamp: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const useExperiment = () => {
|
|
56
|
+
const { amplitude, isInitialized, config, consent, error } = useAmplitudeContext();
|
|
57
|
+
const [experiments, setExperiments] = useState<Map<string, ExperimentConfig>>(new Map());
|
|
58
|
+
const [exposures, setExposures] = useState<Map<string, ExperimentExposure>>(new Map());
|
|
59
|
+
const [conversions, setConversions] = useState<ExperimentConversion[]>([]);
|
|
60
|
+
|
|
61
|
+
const canRunExperiments = isInitialized && config?.enableABTesting && consent.experiments;
|
|
62
|
+
|
|
63
|
+
const registerExperiment = useCallback((experiment: ExperimentConfig) => {
|
|
64
|
+
setExperiments(prev => new Map(prev.set(experiment.id, experiment)));
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
const getVariant = useCallback((experimentId: string, userId: string): string | null => {
|
|
68
|
+
if (!canRunExperiments) return null;
|
|
69
|
+
|
|
70
|
+
const experiment = experiments.get(experimentId);
|
|
71
|
+
if (!experiment || experiment.status !== 'running') return null;
|
|
72
|
+
|
|
73
|
+
// Check if user was already assigned
|
|
74
|
+
const exposureKey = `${experimentId}_${userId}`;
|
|
75
|
+
const existingExposure = exposures.get(exposureKey);
|
|
76
|
+
if (existingExposure) {
|
|
77
|
+
return existingExposure.variantId;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Simple hash-based assignment for deterministic results
|
|
81
|
+
const hash = Array.from(userId).reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
|
82
|
+
const totalAllocation = Object.values(experiment.allocations).reduce((sum, val) => sum + val, 0);
|
|
83
|
+
const assignment = hash % totalAllocation;
|
|
84
|
+
|
|
85
|
+
let currentThreshold = 0;
|
|
86
|
+
for (const [variantId, allocation] of Object.entries(experiment.allocations)) {
|
|
87
|
+
currentThreshold += allocation;
|
|
88
|
+
if (assignment < currentThreshold) {
|
|
89
|
+
return variantId;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Fallback to control
|
|
94
|
+
const controlVariant = experiment.variants.find(v => v.isControl);
|
|
95
|
+
return controlVariant?.id || experiment.variants[0]?.id || null;
|
|
96
|
+
}, [canRunExperiments, experiments, exposures]);
|
|
97
|
+
|
|
98
|
+
const trackExposure = useCallback(async (experimentId: string, variantId?: string, properties?: EventProperties) => {
|
|
99
|
+
if (!canRunExperiments || !amplitude) return;
|
|
100
|
+
|
|
101
|
+
const experiment = experiments.get(experimentId);
|
|
102
|
+
if (!experiment) {
|
|
103
|
+
console.warn(`Experiment ${experimentId} not found`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const actualVariantId = variantId || getVariant(experimentId, 'current-user');
|
|
108
|
+
if (!actualVariantId) return;
|
|
109
|
+
|
|
110
|
+
const exposureKey = `${experimentId}_current-user`;
|
|
111
|
+
const exposure: ExperimentExposure = {
|
|
112
|
+
experimentId,
|
|
113
|
+
variantId: actualVariantId,
|
|
114
|
+
userId: 'current-user',
|
|
115
|
+
timestamp: Date.now(),
|
|
116
|
+
properties
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
setExposures(prev => new Map(prev.set(exposureKey, exposure)));
|
|
120
|
+
|
|
121
|
+
await amplitude.track('Experiment Exposed' as EventType, {
|
|
122
|
+
experiment_id: experimentId,
|
|
123
|
+
experiment_name: experiment.name,
|
|
124
|
+
variant_id: actualVariantId,
|
|
125
|
+
variant_name: experiment.variants.find(v => v.id === actualVariantId)?.name,
|
|
126
|
+
is_control: experiment.variants.find(v => v.id === actualVariantId)?.isControl,
|
|
127
|
+
...properties
|
|
128
|
+
});
|
|
129
|
+
}, [canRunExperiments, amplitude, experiments, getVariant]);
|
|
130
|
+
|
|
131
|
+
const trackConversion = useCallback(async (experimentId: string, metricId?: string, value?: number, properties?: EventProperties) => {
|
|
132
|
+
if (!canRunExperiments || !amplitude) return;
|
|
133
|
+
|
|
134
|
+
const exposureKey = `${experimentId}_current-user`;
|
|
135
|
+
const exposure = exposures.get(exposureKey);
|
|
136
|
+
|
|
137
|
+
if (!exposure) {
|
|
138
|
+
console.warn(`No exposure found for experiment ${experimentId}. Must track exposure before conversion.`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const conversion: ExperimentConversion = {
|
|
143
|
+
experimentId,
|
|
144
|
+
variantId: exposure.variantId,
|
|
145
|
+
metricId: metricId || 'default',
|
|
146
|
+
value,
|
|
147
|
+
userId: 'current-user',
|
|
148
|
+
timestamp: Date.now()
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
setConversions(prev => [...prev, conversion]);
|
|
152
|
+
|
|
153
|
+
await amplitude.track('Experiment Converted' as EventType, {
|
|
154
|
+
experiment_id: experimentId,
|
|
155
|
+
variant_id: exposure.variantId,
|
|
156
|
+
metric_id: metricId,
|
|
157
|
+
conversion_value: value,
|
|
158
|
+
time_to_conversion: Date.now() - exposure.timestamp,
|
|
159
|
+
...properties
|
|
160
|
+
});
|
|
161
|
+
}, [canRunExperiments, amplitude, exposures]);
|
|
162
|
+
|
|
163
|
+
const isInExperiment = useCallback((experimentId: string, userId: string): boolean => {
|
|
164
|
+
const variant = getVariant(experimentId, userId);
|
|
165
|
+
return variant !== null;
|
|
166
|
+
}, [getVariant]);
|
|
167
|
+
|
|
168
|
+
const getExperimentConfig = useCallback((experimentId: string): ExperimentConfig | null => {
|
|
169
|
+
return experiments.get(experimentId) || null;
|
|
170
|
+
}, [experiments]);
|
|
171
|
+
|
|
172
|
+
const getAllExperiments = useCallback((): ExperimentConfig[] => {
|
|
173
|
+
return Array.from(experiments.values());
|
|
174
|
+
}, [experiments]);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
// Core functions
|
|
178
|
+
getVariant,
|
|
179
|
+
trackExposure,
|
|
180
|
+
trackConversion,
|
|
181
|
+
isInExperiment,
|
|
182
|
+
registerExperiment,
|
|
183
|
+
|
|
184
|
+
// State getters
|
|
185
|
+
experiments,
|
|
186
|
+
exposures,
|
|
187
|
+
conversions,
|
|
188
|
+
canRunExperiments,
|
|
189
|
+
|
|
190
|
+
// Utility functions
|
|
191
|
+
getExperimentConfig,
|
|
192
|
+
getAllExperiments,
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback } from 'react';
|
|
2
|
+
import { useAmplitudeContext } from '../providers/AmplitudeProvider';
|
|
3
|
+
|
|
4
|
+
interface RecordingState {
|
|
5
|
+
isRecording: boolean;
|
|
6
|
+
isPaused: boolean;
|
|
7
|
+
duration: number;
|
|
8
|
+
eventsCount: number;
|
|
9
|
+
sessionId: string | null;
|
|
10
|
+
recordingSize: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface PrivacyControls {
|
|
14
|
+
maskAllInputs: boolean;
|
|
15
|
+
maskAllText: boolean;
|
|
16
|
+
blockSelectors: string[];
|
|
17
|
+
maskSelectors: string[];
|
|
18
|
+
ignoredPages: string[];
|
|
19
|
+
recordingSampleRate: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SessionReplayConfig {
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
privacyMode: 'strict' | 'balanced' | 'permissive';
|
|
25
|
+
sampleRate: number;
|
|
26
|
+
maxDurationMs: number;
|
|
27
|
+
maxEventsPerSession: number;
|
|
28
|
+
blockClass: string;
|
|
29
|
+
maskClass: string;
|
|
30
|
+
ignoredPages: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const useSessionReplay = () => {
|
|
34
|
+
const { isInitialized, config, consent, error } = useAmplitudeContext();
|
|
35
|
+
|
|
36
|
+
const [recordingState, setRecordingState] = useState<RecordingState>({
|
|
37
|
+
isRecording: false,
|
|
38
|
+
isPaused: false,
|
|
39
|
+
duration: 0,
|
|
40
|
+
eventsCount: 0,
|
|
41
|
+
sessionId: null,
|
|
42
|
+
recordingSize: 0,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const [privacyControls, setPrivacyControls] = useState<PrivacyControls>({
|
|
46
|
+
maskAllInputs: true,
|
|
47
|
+
maskAllText: false,
|
|
48
|
+
blockSelectors: [
|
|
49
|
+
'[data-private]',
|
|
50
|
+
'.sensitive-data',
|
|
51
|
+
'#credit-card-form',
|
|
52
|
+
'input[type="password"]',
|
|
53
|
+
'input[type="email"]',
|
|
54
|
+
'.user-details'
|
|
55
|
+
],
|
|
56
|
+
maskSelectors: [
|
|
57
|
+
'input[type="text"]',
|
|
58
|
+
'textarea',
|
|
59
|
+
'[data-mask]'
|
|
60
|
+
],
|
|
61
|
+
ignoredPages: ['/admin', '/payment', '/settings'],
|
|
62
|
+
recordingSampleRate: 0.1, // 10%
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const canRecord = isInitialized &&
|
|
66
|
+
config?.enableSessionReplay &&
|
|
67
|
+
consent.sessionReplay &&
|
|
68
|
+
!error;
|
|
69
|
+
|
|
70
|
+
const isCurrentPageIgnored = useCallback(() => {
|
|
71
|
+
if (typeof window === 'undefined') return true;
|
|
72
|
+
const currentPath = window.location.pathname;
|
|
73
|
+
return privacyControls.ignoredPages.some(page => currentPath.includes(page));
|
|
74
|
+
}, [privacyControls.ignoredPages]);
|
|
75
|
+
|
|
76
|
+
const shouldSample = useCallback(() => {
|
|
77
|
+
return Math.random() < privacyControls.recordingSampleRate;
|
|
78
|
+
}, [privacyControls.recordingSampleRate]);
|
|
79
|
+
|
|
80
|
+
const generateSessionId = useCallback(() => {
|
|
81
|
+
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
const startRecording = useCallback(async (): Promise<boolean> => {
|
|
85
|
+
if (!canRecord || isCurrentPageIgnored() || !shouldSample()) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (recordingState.isRecording) {
|
|
90
|
+
console.warn('Session replay is already recording');
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const sessionId = generateSessionId();
|
|
96
|
+
|
|
97
|
+
// Initialize session replay (this would integrate with actual Amplitude Session Replay SDK)
|
|
98
|
+
console.log(`[Session Replay] Starting recording with session ID: ${sessionId}`);
|
|
99
|
+
|
|
100
|
+
setRecordingState(prev => ({
|
|
101
|
+
...prev,
|
|
102
|
+
isRecording: true,
|
|
103
|
+
isPaused: false,
|
|
104
|
+
sessionId,
|
|
105
|
+
duration: 0,
|
|
106
|
+
eventsCount: 0,
|
|
107
|
+
recordingSize: 0,
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
// Store session info
|
|
111
|
+
localStorage.setItem('amplitude_session_replay_id', sessionId);
|
|
112
|
+
localStorage.setItem('amplitude_session_replay_start', Date.now().toString());
|
|
113
|
+
|
|
114
|
+
return true;
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.error('[Session Replay] Failed to start recording:', error);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}, [canRecord, isCurrentPageIgnored, shouldSample, recordingState.isRecording, generateSessionId]);
|
|
120
|
+
|
|
121
|
+
const stopRecording = useCallback(async (): Promise<void> => {
|
|
122
|
+
if (!recordingState.isRecording) {
|
|
123
|
+
console.warn('Session replay is not currently recording');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
console.log(`[Session Replay] Stopping recording for session: ${recordingState.sessionId}`);
|
|
129
|
+
|
|
130
|
+
setRecordingState(prev => ({
|
|
131
|
+
...prev,
|
|
132
|
+
isRecording: false,
|
|
133
|
+
isPaused: false,
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
// Clean up session info
|
|
137
|
+
localStorage.removeItem('amplitude_session_replay_id');
|
|
138
|
+
localStorage.removeItem('amplitude_session_replay_start');
|
|
139
|
+
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error('[Session Replay] Failed to stop recording:', error);
|
|
142
|
+
}
|
|
143
|
+
}, [recordingState.isRecording, recordingState.sessionId]);
|
|
144
|
+
|
|
145
|
+
const pauseRecording = useCallback(() => {
|
|
146
|
+
if (!recordingState.isRecording || recordingState.isPaused) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log('[Session Replay] Pausing recording');
|
|
151
|
+
setRecordingState(prev => ({
|
|
152
|
+
...prev,
|
|
153
|
+
isPaused: true,
|
|
154
|
+
}));
|
|
155
|
+
}, [recordingState.isRecording, recordingState.isPaused]);
|
|
156
|
+
|
|
157
|
+
const resumeRecording = useCallback(() => {
|
|
158
|
+
if (!recordingState.isRecording || !recordingState.isPaused) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.log('[Session Replay] Resuming recording');
|
|
163
|
+
setRecordingState(prev => ({
|
|
164
|
+
...prev,
|
|
165
|
+
isPaused: false,
|
|
166
|
+
}));
|
|
167
|
+
}, [recordingState.isRecording, recordingState.isPaused]);
|
|
168
|
+
|
|
169
|
+
const updatePrivacyControls = useCallback((updates: Partial<PrivacyControls>) => {
|
|
170
|
+
setPrivacyControls(prev => ({
|
|
171
|
+
...prev,
|
|
172
|
+
...updates,
|
|
173
|
+
}));
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
// Auto-start recording when conditions are met
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (canRecord && !recordingState.isRecording && !isCurrentPageIgnored()) {
|
|
179
|
+
startRecording();
|
|
180
|
+
}
|
|
181
|
+
}, [canRecord, recordingState.isRecording, isCurrentPageIgnored, startRecording]);
|
|
182
|
+
|
|
183
|
+
// Update recording duration
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (!recordingState.isRecording || recordingState.isPaused) return;
|
|
186
|
+
|
|
187
|
+
const interval = setInterval(() => {
|
|
188
|
+
setRecordingState(prev => ({
|
|
189
|
+
...prev,
|
|
190
|
+
duration: prev.duration + 1000,
|
|
191
|
+
}));
|
|
192
|
+
}, 1000);
|
|
193
|
+
|
|
194
|
+
return () => clearInterval(interval);
|
|
195
|
+
}, [recordingState.isRecording, recordingState.isPaused]);
|
|
196
|
+
|
|
197
|
+
// Page visibility handling
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
const handleVisibilityChange = () => {
|
|
200
|
+
if (document.hidden && recordingState.isRecording && !recordingState.isPaused) {
|
|
201
|
+
pauseRecording();
|
|
202
|
+
} else if (!document.hidden && recordingState.isRecording && recordingState.isPaused) {
|
|
203
|
+
resumeRecording();
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
208
|
+
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
209
|
+
}, [recordingState.isRecording, recordingState.isPaused, pauseRecording, resumeRecording]);
|
|
210
|
+
|
|
211
|
+
// Clean up on unmount
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
return () => {
|
|
214
|
+
if (recordingState.isRecording) {
|
|
215
|
+
stopRecording();
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}, [recordingState.isRecording, stopRecording]);
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
// Core functions
|
|
222
|
+
startRecording,
|
|
223
|
+
stopRecording,
|
|
224
|
+
pauseRecording,
|
|
225
|
+
resumeRecording,
|
|
226
|
+
|
|
227
|
+
// State
|
|
228
|
+
isRecording: recordingState.isRecording,
|
|
229
|
+
canRecord,
|
|
230
|
+
recordingState,
|
|
231
|
+
privacyControls,
|
|
232
|
+
|
|
233
|
+
// Privacy controls
|
|
234
|
+
updatePrivacyControls,
|
|
235
|
+
isCurrentPageIgnored,
|
|
236
|
+
};
|
|
237
|
+
};
|
|
238
|
+
|