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/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
+ });
@@ -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
+ }