noboarding 0.1.0-alpha
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/README.md +515 -0
- package/REVENUECAT_SETUP.md +756 -0
- package/SETUP_GUIDE.md +873 -0
- package/cusomte_screens.md +1964 -0
- package/lib/OnboardingFlow.d.ts +3 -0
- package/lib/OnboardingFlow.js +235 -0
- package/lib/analytics.d.ts +25 -0
- package/lib/analytics.js +72 -0
- package/lib/api.d.ts +31 -0
- package/lib/api.js +149 -0
- package/lib/components/ElementRenderer.d.ts +13 -0
- package/lib/components/ElementRenderer.js +521 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +18 -0
- package/lib/types.d.ts +185 -0
- package/lib/types.js +2 -0
- package/lib/variableUtils.d.ts +17 -0
- package/lib/variableUtils.js +118 -0
- package/logic.md +2095 -0
- package/package.json +44 -0
- package/src/OnboardingFlow.tsx +276 -0
- package/src/analytics.ts +84 -0
- package/src/api.ts +173 -0
- package/src/components/ElementRenderer.tsx +627 -0
- package/src/index.ts +32 -0
- package/src/types.ts +242 -0
- package/src/variableUtils.ts +133 -0
- package/tsconfig.json +20 -0
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "noboarding",
|
|
3
|
+
"version": "0.1.0-alpha",
|
|
4
|
+
"description": "React Native SDK for remote onboarding flow management",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"types": "lib/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"watch": "tsc --watch",
|
|
10
|
+
"prepare": "npm run build"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"react-native",
|
|
14
|
+
"onboarding",
|
|
15
|
+
"sdk",
|
|
16
|
+
"analytics",
|
|
17
|
+
"a/b-testing"
|
|
18
|
+
],
|
|
19
|
+
"author": "",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"react": ">=16.8.0",
|
|
23
|
+
"react-native": ">=0.60.0",
|
|
24
|
+
"expo-linear-gradient": ">=12.0.0",
|
|
25
|
+
"@expo/vector-icons": ">=14.0.0"
|
|
26
|
+
},
|
|
27
|
+
"peerDependenciesMeta": {
|
|
28
|
+
"expo-linear-gradient": {
|
|
29
|
+
"optional": true
|
|
30
|
+
},
|
|
31
|
+
"@expo/vector-icons": {
|
|
32
|
+
"optional": true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@react-native-async-storage/async-storage": "^1.19.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^25.2.3",
|
|
40
|
+
"@types/react": "^18.2.0",
|
|
41
|
+
"@types/react-native": "^0.72.0",
|
|
42
|
+
"typescript": "^5.9.3"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
2
|
+
import { View, ActivityIndicator, StyleSheet, Text, TouchableOpacity } from 'react-native';
|
|
3
|
+
import { API } from './api';
|
|
4
|
+
import { AnalyticsManager } from './analytics';
|
|
5
|
+
import { OnboardingFlowProps, ScreenConfig, ConditionalDestination, ConditionalRoutes } from './types';
|
|
6
|
+
import { resolveDestination } from './variableUtils';
|
|
7
|
+
import { ElementRenderer } from './components/ElementRenderer';
|
|
8
|
+
|
|
9
|
+
// Generate unique IDs
|
|
10
|
+
const generateUserId = (): string => {
|
|
11
|
+
return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const generateSessionId = (): string => {
|
|
15
|
+
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
19
|
+
apiKey,
|
|
20
|
+
onComplete,
|
|
21
|
+
onSkip,
|
|
22
|
+
baseUrl,
|
|
23
|
+
initialVariables,
|
|
24
|
+
customComponents,
|
|
25
|
+
onUserIdGenerated,
|
|
26
|
+
}) => {
|
|
27
|
+
const [loading, setLoading] = useState(true);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
const [screens, setScreens] = useState<ScreenConfig[]>([]);
|
|
30
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
31
|
+
const [collectedData, setCollectedData] = useState<Record<string, any>>({});
|
|
32
|
+
const [variables, setVariables] = useState<Record<string, any>>(initialVariables || {});
|
|
33
|
+
|
|
34
|
+
const apiRef = useRef<API | null>(null);
|
|
35
|
+
const analyticsRef = useRef<AnalyticsManager | null>(null);
|
|
36
|
+
const userIdRef = useRef<string>(generateUserId());
|
|
37
|
+
const sessionIdRef = useRef<string>(generateSessionId());
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
// Notify parent of the generated user ID
|
|
41
|
+
if (onUserIdGenerated) {
|
|
42
|
+
onUserIdGenerated(userIdRef.current);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
initializeFlow();
|
|
46
|
+
|
|
47
|
+
return () => {
|
|
48
|
+
// Cleanup: flush remaining analytics
|
|
49
|
+
if (analyticsRef.current) {
|
|
50
|
+
analyticsRef.current.destroy();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
const initializeFlow = async () => {
|
|
56
|
+
try {
|
|
57
|
+
setLoading(true);
|
|
58
|
+
setError(null);
|
|
59
|
+
|
|
60
|
+
// Initialize API client
|
|
61
|
+
const api = new API(apiKey, baseUrl);
|
|
62
|
+
apiRef.current = api;
|
|
63
|
+
|
|
64
|
+
// Initialize analytics
|
|
65
|
+
const analytics = new AnalyticsManager(
|
|
66
|
+
api,
|
|
67
|
+
userIdRef.current,
|
|
68
|
+
sessionIdRef.current
|
|
69
|
+
);
|
|
70
|
+
analyticsRef.current = analytics;
|
|
71
|
+
|
|
72
|
+
// Track onboarding start
|
|
73
|
+
analytics.track('onboarding_started');
|
|
74
|
+
|
|
75
|
+
// Fetch configuration
|
|
76
|
+
const configResponse = await api.getConfig();
|
|
77
|
+
// Backward compatibility: normalize legacy 'custom_screen' (with elements) to 'noboard_screen'
|
|
78
|
+
const normalizedScreens = configResponse.config.screens.map(s => ({
|
|
79
|
+
...s,
|
|
80
|
+
type: (s.type === ('custom_screen' as any) && s.elements) ? 'noboard_screen' as ScreenConfig['type'] : s.type,
|
|
81
|
+
}));
|
|
82
|
+
setScreens(normalizedScreens);
|
|
83
|
+
|
|
84
|
+
setLoading(false);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
console.error('Failed to initialize onboarding flow:', err);
|
|
87
|
+
setError('Failed to load onboarding. Please try again.');
|
|
88
|
+
setLoading(false);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const handleNext = (data?: Record<string, any>) => {
|
|
93
|
+
// Collect data from this screen
|
|
94
|
+
if (data) {
|
|
95
|
+
setCollectedData((prev) => ({ ...prev, ...data }));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Check if this is the last screen
|
|
99
|
+
if (currentIndex >= screens.length - 1) {
|
|
100
|
+
handleComplete(data);
|
|
101
|
+
} else {
|
|
102
|
+
setCurrentIndex((prev) => prev + 1);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const handleSkipScreen = () => {
|
|
107
|
+
// Move to next screen or complete
|
|
108
|
+
if (currentIndex >= screens.length - 1) {
|
|
109
|
+
handleComplete();
|
|
110
|
+
} else {
|
|
111
|
+
setCurrentIndex((prev) => prev + 1);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const handleSetVariable = useCallback((name: string, value: any) => {
|
|
116
|
+
setVariables(prev => ({ ...prev, [name]: value }));
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
const handleComplete = async (lastScreenData?: Record<string, any>) => {
|
|
120
|
+
const finalData = {
|
|
121
|
+
...collectedData,
|
|
122
|
+
...(lastScreenData || {}),
|
|
123
|
+
_variables: variables,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Track completion
|
|
127
|
+
if (analyticsRef.current) {
|
|
128
|
+
analyticsRef.current.track('onboarding_completed');
|
|
129
|
+
await analyticsRef.current.flush();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Call developer's completion callback with collected data
|
|
133
|
+
onComplete(finalData);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const handleSkipAll = async () => {
|
|
137
|
+
if (analyticsRef.current) {
|
|
138
|
+
analyticsRef.current.track('onboarding_abandoned', {
|
|
139
|
+
current_screen_index: currentIndex,
|
|
140
|
+
});
|
|
141
|
+
await analyticsRef.current.flush();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (onSkip) {
|
|
145
|
+
onSkip();
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (loading) {
|
|
150
|
+
return (
|
|
151
|
+
<View style={styles.centerContainer}>
|
|
152
|
+
<ActivityIndicator size="large" color="#007AFF" />
|
|
153
|
+
<Text style={styles.loadingText}>Loading...</Text>
|
|
154
|
+
</View>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (error) {
|
|
159
|
+
return (
|
|
160
|
+
<View style={styles.centerContainer}>
|
|
161
|
+
<Text style={styles.errorText}>{error}</Text>
|
|
162
|
+
</View>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (screens.length === 0) {
|
|
167
|
+
return (
|
|
168
|
+
<View style={styles.centerContainer}>
|
|
169
|
+
<Text style={styles.errorText}>No onboarding screens configured</Text>
|
|
170
|
+
</View>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const currentScreen = screens[currentIndex];
|
|
175
|
+
|
|
176
|
+
// Handle noboard_screen type — render with ElementRenderer
|
|
177
|
+
if (currentScreen.type === 'noboard_screen' && currentScreen.elements) {
|
|
178
|
+
const handleElementNavigate = (destination: string | ConditionalDestination | ConditionalRoutes) => {
|
|
179
|
+
const resolved = resolveDestination(destination, variables);
|
|
180
|
+
if (!resolved) return;
|
|
181
|
+
|
|
182
|
+
if (resolved === 'next') {
|
|
183
|
+
handleNext();
|
|
184
|
+
} else if (resolved === 'previous') {
|
|
185
|
+
setCurrentIndex((prev) => Math.max(0, prev - 1));
|
|
186
|
+
} else {
|
|
187
|
+
// Navigate to specific screen by ID
|
|
188
|
+
const targetIndex = screens.findIndex((s) => s.id === resolved);
|
|
189
|
+
if (targetIndex >= 0) {
|
|
190
|
+
setCurrentIndex(targetIndex);
|
|
191
|
+
} else {
|
|
192
|
+
handleNext();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<View style={styles.container}>
|
|
199
|
+
<ElementRenderer
|
|
200
|
+
elements={currentScreen.elements}
|
|
201
|
+
analytics={analyticsRef.current!}
|
|
202
|
+
screenId={currentScreen.id}
|
|
203
|
+
onNavigate={handleElementNavigate}
|
|
204
|
+
onDismiss={onSkip ? handleSkipAll : handleNext}
|
|
205
|
+
variables={variables}
|
|
206
|
+
onSetVariable={handleSetVariable}
|
|
207
|
+
/>
|
|
208
|
+
</View>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Handle custom_screen type — developer-registered React Native components
|
|
213
|
+
if (currentScreen.type === 'custom_screen') {
|
|
214
|
+
const componentName = currentScreen.custom_component_name;
|
|
215
|
+
const CustomComponent = customComponents?.[componentName || ''];
|
|
216
|
+
|
|
217
|
+
if (!CustomComponent) {
|
|
218
|
+
return (
|
|
219
|
+
<View style={styles.centerContainer}>
|
|
220
|
+
<Text style={styles.errorText}>
|
|
221
|
+
Component "{componentName}" not found.
|
|
222
|
+
</Text>
|
|
223
|
+
<TouchableOpacity
|
|
224
|
+
style={{ marginTop: 16, padding: 12 }}
|
|
225
|
+
onPress={() => handleNext()}
|
|
226
|
+
>
|
|
227
|
+
<Text style={{ color: '#007AFF', fontSize: 16 }}>Skip</Text>
|
|
228
|
+
</TouchableOpacity>
|
|
229
|
+
</View>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<View style={styles.container}>
|
|
235
|
+
<CustomComponent
|
|
236
|
+
analytics={analyticsRef.current!}
|
|
237
|
+
onNext={() => handleNext()}
|
|
238
|
+
onSkip={onSkip ? handleSkipAll : undefined}
|
|
239
|
+
data={collectedData}
|
|
240
|
+
onDataUpdate={(newData) => setCollectedData(prev => ({ ...prev, ...newData }))}
|
|
241
|
+
/>
|
|
242
|
+
</View>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Unknown screen type fallback
|
|
247
|
+
return (
|
|
248
|
+
<View style={styles.centerContainer}>
|
|
249
|
+
<Text style={styles.errorText}>
|
|
250
|
+
Unknown screen type: "{currentScreen.type}"
|
|
251
|
+
</Text>
|
|
252
|
+
</View>
|
|
253
|
+
);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const styles = StyleSheet.create({
|
|
257
|
+
container: {
|
|
258
|
+
flex: 1,
|
|
259
|
+
},
|
|
260
|
+
centerContainer: {
|
|
261
|
+
flex: 1,
|
|
262
|
+
justifyContent: 'center',
|
|
263
|
+
alignItems: 'center',
|
|
264
|
+
padding: 24,
|
|
265
|
+
},
|
|
266
|
+
loadingText: {
|
|
267
|
+
marginTop: 16,
|
|
268
|
+
fontSize: 16,
|
|
269
|
+
color: '#000000',
|
|
270
|
+
},
|
|
271
|
+
errorText: {
|
|
272
|
+
fontSize: 16,
|
|
273
|
+
color: '#FF3B30',
|
|
274
|
+
textAlign: 'center',
|
|
275
|
+
},
|
|
276
|
+
});
|
package/src/analytics.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { API } from './api';
|
|
2
|
+
import { AnalyticsEvent } from './types';
|
|
3
|
+
|
|
4
|
+
const BATCH_SIZE = 10;
|
|
5
|
+
const FLUSH_INTERVAL_MS = 10000; // 10 seconds
|
|
6
|
+
|
|
7
|
+
export class AnalyticsManager {
|
|
8
|
+
private api: API;
|
|
9
|
+
private events: AnalyticsEvent[] = [];
|
|
10
|
+
private userId: string;
|
|
11
|
+
private sessionId: string;
|
|
12
|
+
private flushTimer: NodeJS.Timeout | null = null;
|
|
13
|
+
|
|
14
|
+
constructor(api: API, userId: string, sessionId: string) {
|
|
15
|
+
this.api = api;
|
|
16
|
+
this.userId = userId;
|
|
17
|
+
this.sessionId = sessionId;
|
|
18
|
+
|
|
19
|
+
// Start auto-flush timer
|
|
20
|
+
this.startFlushTimer();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Track an analytics event
|
|
25
|
+
*/
|
|
26
|
+
track(eventName: string, properties?: Record<string, any>): void {
|
|
27
|
+
const event: AnalyticsEvent = {
|
|
28
|
+
event: eventName,
|
|
29
|
+
user_id: this.userId,
|
|
30
|
+
session_id: this.sessionId,
|
|
31
|
+
timestamp: Date.now(),
|
|
32
|
+
properties: properties || {},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
this.events.push(event);
|
|
36
|
+
|
|
37
|
+
// Auto-flush if batch size reached
|
|
38
|
+
if (this.events.length >= BATCH_SIZE) {
|
|
39
|
+
this.flush();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Manually flush events to backend
|
|
45
|
+
*/
|
|
46
|
+
async flush(): Promise<void> {
|
|
47
|
+
if (this.events.length === 0) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Take current events and clear queue
|
|
52
|
+
const eventsToSend = [...this.events];
|
|
53
|
+
this.events = [];
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
await this.api.trackEvents(eventsToSend);
|
|
57
|
+
console.log(`Flushed ${eventsToSend.length} events`);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error('Failed to flush events:', error);
|
|
60
|
+
// Put events back in queue to retry later
|
|
61
|
+
this.events.unshift(...eventsToSend);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Start auto-flush timer
|
|
67
|
+
*/
|
|
68
|
+
private startFlushTimer(): void {
|
|
69
|
+
this.flushTimer = setInterval(() => {
|
|
70
|
+
this.flush();
|
|
71
|
+
}, FLUSH_INTERVAL_MS);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Stop auto-flush timer and flush remaining events
|
|
76
|
+
*/
|
|
77
|
+
async destroy(): Promise<void> {
|
|
78
|
+
if (this.flushTimer) {
|
|
79
|
+
clearInterval(this.flushTimer);
|
|
80
|
+
this.flushTimer = null;
|
|
81
|
+
}
|
|
82
|
+
await this.flush();
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2
|
+
import {
|
|
3
|
+
OnboardingConfig,
|
|
4
|
+
GetConfigResponse,
|
|
5
|
+
AnalyticsEvent,
|
|
6
|
+
TrackEventsResponse,
|
|
7
|
+
AssignVariantResponse,
|
|
8
|
+
} from './types';
|
|
9
|
+
|
|
10
|
+
const CACHE_KEY = '@noboarding:config';
|
|
11
|
+
const CACHE_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
|
|
12
|
+
|
|
13
|
+
interface CachedConfig {
|
|
14
|
+
config: GetConfigResponse;
|
|
15
|
+
timestamp: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class API {
|
|
19
|
+
private apiKey: string;
|
|
20
|
+
private baseUrl: string;
|
|
21
|
+
private supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImhobW16bXJzcHRlZ3ByZnp0cXRxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzA3ODM1MDUsImV4cCI6MjA4NjM1OTUwNX0.otIIs3ZyWTHTnDZ1NbZolzeHjWv__wmHekxZevhKryI';
|
|
22
|
+
|
|
23
|
+
constructor(apiKey: string, baseUrl?: string) {
|
|
24
|
+
this.apiKey = apiKey;
|
|
25
|
+
this.baseUrl = baseUrl || 'https://hhmmzmrsptegprfztqtq.supabase.co/functions/v1';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Fetch onboarding configuration from API
|
|
30
|
+
*/
|
|
31
|
+
async getConfig(): Promise<GetConfigResponse> {
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch(`${this.baseUrl}/get-config`, {
|
|
34
|
+
method: 'GET',
|
|
35
|
+
headers: {
|
|
36
|
+
'Authorization': `Bearer ${this.supabaseAnonKey}`,
|
|
37
|
+
'x-api-key': this.apiKey,
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
throw new Error(`Failed to fetch config: ${response.status}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const data: GetConfigResponse = await response.json();
|
|
47
|
+
|
|
48
|
+
// Cache the config
|
|
49
|
+
await this.cacheConfig(data);
|
|
50
|
+
|
|
51
|
+
return data;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error('Error fetching config:', error);
|
|
54
|
+
// Try to return cached config as fallback
|
|
55
|
+
const cached = await this.getCachedConfig();
|
|
56
|
+
if (cached) {
|
|
57
|
+
console.log('Using cached config as fallback');
|
|
58
|
+
return cached;
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get cached configuration
|
|
66
|
+
*/
|
|
67
|
+
async getCachedConfig(): Promise<GetConfigResponse | null> {
|
|
68
|
+
try {
|
|
69
|
+
const cached = await AsyncStorage.getItem(CACHE_KEY);
|
|
70
|
+
if (!cached) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const parsedCache: CachedConfig = JSON.parse(cached);
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
|
|
77
|
+
// Check if cache is expired
|
|
78
|
+
if (now - parsedCache.timestamp > CACHE_EXPIRY_MS) {
|
|
79
|
+
await AsyncStorage.removeItem(CACHE_KEY);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return parsedCache.config;
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error('Error reading cached config:', error);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Cache configuration locally
|
|
92
|
+
*/
|
|
93
|
+
private async cacheConfig(config: GetConfigResponse): Promise<void> {
|
|
94
|
+
try {
|
|
95
|
+
const cacheData: CachedConfig = {
|
|
96
|
+
config,
|
|
97
|
+
timestamp: Date.now(),
|
|
98
|
+
};
|
|
99
|
+
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error('Error caching config:', error);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Send analytics events to backend
|
|
107
|
+
*/
|
|
108
|
+
async trackEvents(events: AnalyticsEvent[]): Promise<TrackEventsResponse> {
|
|
109
|
+
try {
|
|
110
|
+
const response = await fetch(`${this.baseUrl}/track-events`, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: {
|
|
113
|
+
'Authorization': `Bearer ${this.supabaseAnonKey}`,
|
|
114
|
+
'x-api-key': this.apiKey,
|
|
115
|
+
'Content-Type': 'application/json',
|
|
116
|
+
},
|
|
117
|
+
body: JSON.stringify({ events }),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
throw new Error(`Failed to track events: ${response.status}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return await response.json();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
console.error('Error tracking events:', error);
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get A/B test variant assignment
|
|
133
|
+
*/
|
|
134
|
+
async assignVariant(
|
|
135
|
+
experimentId: string,
|
|
136
|
+
userId: string
|
|
137
|
+
): Promise<AssignVariantResponse> {
|
|
138
|
+
try {
|
|
139
|
+
const response = await fetch(`${this.baseUrl}/assign-variant`, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: {
|
|
142
|
+
'Authorization': `Bearer ${this.supabaseAnonKey}`,
|
|
143
|
+
'x-api-key': this.apiKey,
|
|
144
|
+
'Content-Type': 'application/json',
|
|
145
|
+
},
|
|
146
|
+
body: JSON.stringify({
|
|
147
|
+
experiment_id: experimentId,
|
|
148
|
+
user_id: userId,
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
throw new Error(`Failed to assign variant: ${response.status}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return await response.json();
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.error('Error assigning variant:', error);
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Clear cached config
|
|
165
|
+
*/
|
|
166
|
+
async clearCache(): Promise<void> {
|
|
167
|
+
try {
|
|
168
|
+
await AsyncStorage.removeItem(CACHE_KEY);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.error('Error clearing cache:', error);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|