noboarding 1.0.2-beta → 1.0.6-beta

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,16 +1,36 @@
1
1
  import React, { useState, useEffect, useRef, useCallback } from 'react';
2
2
  import { View, ActivityIndicator, StyleSheet, Text, TouchableOpacity } from 'react-native';
3
+ import AsyncStorage from '@react-native-async-storage/async-storage';
3
4
  import { API } from './api';
4
5
  import { AnalyticsManager } from './analytics';
5
6
  import { OnboardingFlowProps, ScreenConfig, ConditionalDestination, ConditionalRoutes } from './types';
6
7
  import { resolveDestination } from './variableUtils';
7
8
  import { ElementRenderer } from './components/ElementRenderer';
8
9
 
9
- // Generate unique IDs
10
- const generateUserId = (): string => {
11
- return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
10
+ const USER_ID_STORAGE_KEY = '@noboarding_user_id';
11
+
12
+ // Get or create persistent user ID
13
+ const getPersistentUserId = async (): Promise<string> => {
14
+ try {
15
+ // Try to get existing user ID from storage
16
+ const existingUserId = await AsyncStorage.getItem(USER_ID_STORAGE_KEY);
17
+
18
+ if (existingUserId) {
19
+ return existingUserId;
20
+ }
21
+
22
+ // Generate new user ID if none exists
23
+ const newUserId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
24
+ await AsyncStorage.setItem(USER_ID_STORAGE_KEY, newUserId);
25
+ return newUserId;
26
+ } catch (error) {
27
+ console.error('Failed to get/set user ID from storage:', error);
28
+ // Fallback to generating a non-persistent ID
29
+ return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
30
+ }
12
31
  };
13
32
 
33
+ // Generate session ID (always new per session)
14
34
  const generateSessionId = (): string => {
15
35
  return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
16
36
  };
@@ -54,22 +74,33 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
54
74
  const [loading, setLoading] = useState(true);
55
75
  const [error, setError] = useState<string | null>(null);
56
76
  const [screens, setScreens] = useState<ScreenConfig[]>([]);
77
+ const [assets, setAssets] = useState<Array<{ name: string; type: string; data: string }>>([]);
57
78
  const [currentIndex, setCurrentIndex] = useState(0);
58
79
  const [collectedData, setCollectedData] = useState<Record<string, any>>({});
59
80
  const [variables, setVariables] = useState<Record<string, any>>(initialVariables || {});
60
81
 
61
82
  const apiRef = useRef<API | null>(null);
62
83
  const analyticsRef = useRef<AnalyticsManager | null>(null);
63
- const userIdRef = useRef<string>(generateUserId());
84
+ const userIdRef = useRef<string | null>(null);
64
85
  const sessionIdRef = useRef<string>(generateSessionId());
86
+ const flowIdRef = useRef<string | null>(null);
65
87
 
66
88
  useEffect(() => {
67
- // Notify parent of the generated user ID
68
- if (onUserIdGenerated) {
69
- onUserIdGenerated(userIdRef.current);
70
- }
89
+ const initialize = async () => {
90
+ // Get or create persistent user ID
91
+ const userId = await getPersistentUserId();
92
+ userIdRef.current = userId;
93
+
94
+ // Notify parent of the user ID
95
+ if (onUserIdGenerated) {
96
+ onUserIdGenerated(userId);
97
+ }
71
98
 
72
- initializeFlow();
99
+ // Initialize the flow
100
+ await initializeFlow();
101
+ };
102
+
103
+ initialize();
73
104
 
74
105
  return () => {
75
106
  // Cleanup: flush remaining analytics
@@ -79,11 +110,28 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
79
110
  };
80
111
  }, []);
81
112
 
113
+ // Track screen views whenever user navigates to a different screen
114
+ useEffect(() => {
115
+ if (screens.length > 0 && analyticsRef.current && currentIndex > 0) {
116
+ const currentScreen = screens[currentIndex];
117
+ analyticsRef.current.track('screen_viewed', {
118
+ flow_id: flowIdRef.current,
119
+ screen_id: currentScreen.id,
120
+ screen_index: currentIndex,
121
+ });
122
+ }
123
+ }, [currentIndex, screens]);
124
+
82
125
  const initializeFlow = async () => {
83
126
  try {
84
127
  setLoading(true);
85
128
  setError(null);
86
129
 
130
+ // Ensure user ID is set
131
+ if (!userIdRef.current) {
132
+ throw new Error('User ID not initialized');
133
+ }
134
+
87
135
  // Initialize API client with detected API key
88
136
  const api = new API(activeApiKey, baseUrl);
89
137
  apiRef.current = api;
@@ -96,13 +144,55 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
96
144
  );
97
145
  analyticsRef.current = analytics;
98
146
 
99
- // Track onboarding start
100
- analytics.track('onboarding_started');
101
-
102
147
  // Fetch configuration
103
148
  const configResponse = await api.getConfig();
149
+
150
+ // Store flow_id for analytics
151
+ flowIdRef.current = configResponse.config_id;
152
+
153
+ // Store assets from config
154
+ if (configResponse.config.assets) {
155
+ setAssets(configResponse.config.assets);
156
+ }
157
+
158
+ // Handle A/B test experiment assignment
159
+ let screensToUse = configResponse.config.screens;
160
+
161
+ if (configResponse.experiments && configResponse.experiments.length > 0) {
162
+ // Assign user to the first active experiment
163
+ const experiment = configResponse.experiments[0];
164
+ try {
165
+ const assignment = await api.assignVariant(
166
+ experiment.id,
167
+ userIdRef.current!
168
+ );
169
+
170
+ console.log('🧪 A/B Test Assignment:', {
171
+ experiment_id: experiment.id,
172
+ experiment_name: experiment.name,
173
+ variant_id: assignment.variant_id,
174
+ has_variant_screens: assignment.variant_config?.screens?.length > 0,
175
+ variant_screen_count: assignment.variant_config?.screens?.length || 0,
176
+ cached: assignment.cached,
177
+ });
178
+
179
+ // Set experiment context so all events get tagged
180
+ analytics.setExperimentContext(experiment.id, assignment.variant_id);
181
+
182
+ // Use variant screens if available
183
+ if (assignment.variant_config?.screens?.length > 0) {
184
+ screensToUse = assignment.variant_config.screens;
185
+ console.log('📱 Using variant screens:', assignment.variant_config.screens.length, 'screens');
186
+ } else {
187
+ console.log('📱 Using base flow screens (variant has no screens defined)');
188
+ }
189
+ } catch (err) {
190
+ console.warn('Failed to assign experiment variant, using default flow:', err);
191
+ }
192
+ }
193
+
104
194
  // Backward compatibility: normalize legacy 'custom_screen' (with elements) to 'noboard_screen'
105
- const normalizedScreens = configResponse.config.screens
195
+ const normalizedScreens = screensToUse
106
196
  .map(s => ({
107
197
  ...s,
108
198
  type: (s.type === ('custom_screen' as any) && s.elements) ? 'noboard_screen' as ScreenConfig['type'] : s.type,
@@ -111,6 +201,12 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
111
201
  .filter(s => !s.hidden);
112
202
  setScreens(normalizedScreens);
113
203
 
204
+ // Track onboarding start with first screen
205
+ analytics.track('onboarding_started', {
206
+ flow_id: flowIdRef.current,
207
+ screen_id: normalizedScreens[0]?.id,
208
+ });
209
+
114
210
  setLoading(false);
115
211
  } catch (err) {
116
212
  console.error('Failed to initialize onboarding flow:', err);
@@ -162,7 +258,10 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
162
258
 
163
259
  // Track completion
164
260
  if (analyticsRef.current) {
165
- analyticsRef.current.track('onboarding_completed');
261
+ analyticsRef.current.track('onboarding_completed', {
262
+ flow_id: flowIdRef.current,
263
+ screen_id: screens[currentIndex]?.id,
264
+ });
166
265
  await analyticsRef.current.flush();
167
266
  }
168
267
 
@@ -173,6 +272,8 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
173
272
  const handleSkipAll = async () => {
174
273
  if (analyticsRef.current) {
175
274
  analyticsRef.current.track('onboarding_abandoned', {
275
+ flow_id: flowIdRef.current,
276
+ screen_id: screens[currentIndex]?.id,
176
277
  current_screen_index: currentIndex,
177
278
  });
178
279
  await analyticsRef.current.flush();
@@ -256,6 +357,7 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
256
357
  onDismiss={onSkip ? handleSkipAll : handleNext}
257
358
  variables={allVariables}
258
359
  onSetVariable={handleSetVariable}
360
+ assets={assets}
259
361
  />
260
362
  </View>
261
363
  );
package/src/analytics.ts CHANGED
@@ -10,6 +10,8 @@ export class AnalyticsManager {
10
10
  private userId: string;
11
11
  private sessionId: string;
12
12
  private flushTimer: NodeJS.Timeout | null = null;
13
+ private experimentId: string | null = null;
14
+ private variantId: string | null = null;
13
15
 
14
16
  constructor(api: API, userId: string, sessionId: string) {
15
17
  this.api = api;
@@ -20,16 +22,36 @@ export class AnalyticsManager {
20
22
  this.startFlushTimer();
21
23
  }
22
24
 
25
+ /**
26
+ * Set experiment context — all subsequent events will be tagged
27
+ */
28
+ setExperimentContext(experimentId: string, variantId: string): void {
29
+ this.experimentId = experimentId;
30
+ this.variantId = variantId;
31
+ }
32
+
23
33
  /**
24
34
  * Track an analytics event
25
35
  */
26
36
  track(eventName: string, properties?: Record<string, any>): void {
37
+ const mergedProperties: Record<string, any> = {
38
+ ...(properties || {}),
39
+ };
40
+
41
+ // Auto-inject experiment context if set
42
+ if (this.experimentId) {
43
+ mergedProperties.experiment_id = this.experimentId;
44
+ }
45
+ if (this.variantId) {
46
+ mergedProperties.variant_id = this.variantId;
47
+ }
48
+
27
49
  const event: AnalyticsEvent = {
28
50
  event: eventName,
29
51
  user_id: this.userId,
30
52
  session_id: this.sessionId,
31
53
  timestamp: Date.now(),
32
- properties: properties || {},
54
+ properties: mergedProperties,
33
55
  };
34
56
 
35
57
  this.events.push(event);
@@ -0,0 +1,292 @@
1
+ import { Animated, Easing } from 'react-native';
2
+ import type {
3
+ EntranceAnimation,
4
+ InteractiveAnimation,
5
+ TextAnimation,
6
+ HapticType
7
+ } from './types';
8
+
9
+ // Lazy load haptics to avoid errors if not installed
10
+ let Haptics: any = null;
11
+ try {
12
+ Haptics = require('expo-haptics');
13
+ } catch (e) {
14
+ console.warn('expo-haptics not installed, haptic feedback will be disabled');
15
+ }
16
+
17
+ // ─── Haptic Feedback Helper ───
18
+
19
+ export const triggerHaptic = (type: HapticType = 'light') => {
20
+ if (!Haptics) {
21
+ // Silently skip if expo-haptics not available
22
+ return;
23
+ }
24
+
25
+ try {
26
+ switch (type) {
27
+ case 'light':
28
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
29
+ break;
30
+ case 'medium':
31
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
32
+ break;
33
+ case 'heavy':
34
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
35
+ break;
36
+ case 'success':
37
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
38
+ break;
39
+ case 'warning':
40
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
41
+ break;
42
+ case 'error':
43
+ Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
44
+ break;
45
+ }
46
+ } catch (error) {
47
+ console.warn('Haptic feedback failed:', error);
48
+ }
49
+ };
50
+
51
+ // ─── Easing Function Mapper ───
52
+
53
+ export const getEasing = (easingType?: string) => {
54
+ switch (easingType) {
55
+ case 'linear':
56
+ return Easing.linear;
57
+ case 'ease-in':
58
+ return Easing.in(Easing.ease);
59
+ case 'ease-out':
60
+ return Easing.out(Easing.ease);
61
+ case 'ease-in-out':
62
+ return Easing.inOut(Easing.ease);
63
+ case 'spring':
64
+ return Easing.elastic(1);
65
+ default:
66
+ return Easing.inOut(Easing.ease);
67
+ }
68
+ };
69
+
70
+ // ─── Entrance Animations ───
71
+
72
+ export interface EntranceAnimationValues {
73
+ opacity: Animated.Value;
74
+ translateY: Animated.Value;
75
+ translateX: Animated.Value;
76
+ scale: Animated.Value;
77
+ }
78
+
79
+ export const createEntranceAnimationValues = (): EntranceAnimationValues => ({
80
+ opacity: new Animated.Value(0),
81
+ translateY: new Animated.Value(0),
82
+ translateX: new Animated.Value(0),
83
+ scale: new Animated.Value(1),
84
+ });
85
+
86
+ export const startEntranceAnimation = (
87
+ config: EntranceAnimation,
88
+ values: EntranceAnimationValues,
89
+ delay: number = 0
90
+ ): void => {
91
+ const duration = config.duration || 400;
92
+ const totalDelay = (config.delay || 0) + delay;
93
+ const easing = getEasing(config.easing);
94
+
95
+ // Set initial values based on animation type
96
+ switch (config.type) {
97
+ case 'fadeIn':
98
+ values.opacity.setValue(0);
99
+ break;
100
+ case 'slideUp':
101
+ values.opacity.setValue(0);
102
+ values.translateY.setValue(30);
103
+ break;
104
+ case 'slideDown':
105
+ values.opacity.setValue(0);
106
+ values.translateY.setValue(-30);
107
+ break;
108
+ case 'slideLeft':
109
+ values.opacity.setValue(0);
110
+ values.translateX.setValue(30);
111
+ break;
112
+ case 'slideRight':
113
+ values.opacity.setValue(0);
114
+ values.translateX.setValue(-30);
115
+ break;
116
+ case 'scaleIn':
117
+ values.opacity.setValue(0);
118
+ values.scale.setValue(0.8);
119
+ break;
120
+ case 'none':
121
+ values.opacity.setValue(1);
122
+ return;
123
+ }
124
+
125
+ // Animate to final values
126
+ Animated.parallel([
127
+ Animated.timing(values.opacity, {
128
+ toValue: 1,
129
+ duration,
130
+ delay: totalDelay,
131
+ easing,
132
+ useNativeDriver: true,
133
+ }),
134
+ Animated.timing(values.translateY, {
135
+ toValue: 0,
136
+ duration,
137
+ delay: totalDelay,
138
+ easing,
139
+ useNativeDriver: true,
140
+ }),
141
+ Animated.timing(values.translateX, {
142
+ toValue: 0,
143
+ duration,
144
+ delay: totalDelay,
145
+ easing,
146
+ useNativeDriver: true,
147
+ }),
148
+ Animated.timing(values.scale, {
149
+ toValue: 1,
150
+ duration,
151
+ delay: totalDelay,
152
+ easing,
153
+ useNativeDriver: true,
154
+ }),
155
+ ]).start();
156
+ };
157
+
158
+ // ─── Interactive Animations ───
159
+
160
+ export const startInteractiveAnimation = (
161
+ config: InteractiveAnimation,
162
+ animatedValue: Animated.Value
163
+ ): void => {
164
+ const duration = config.duration || 200;
165
+ const intensity = config.intensity || (config.type === 'scale' ? 0.95 : 10);
166
+
167
+ // Trigger haptic if enabled
168
+ if (config.haptic && config.hapticType) {
169
+ triggerHaptic(config.hapticType);
170
+ }
171
+
172
+ switch (config.type) {
173
+ case 'scale':
174
+ Animated.sequence([
175
+ Animated.timing(animatedValue, {
176
+ toValue: intensity,
177
+ duration: duration / 2,
178
+ easing: Easing.out(Easing.ease),
179
+ useNativeDriver: true,
180
+ }),
181
+ Animated.timing(animatedValue, {
182
+ toValue: 1,
183
+ duration: duration / 2,
184
+ easing: Easing.in(Easing.ease),
185
+ useNativeDriver: true,
186
+ }),
187
+ ]).start();
188
+ break;
189
+
190
+ case 'pulse':
191
+ const pulseSequence = Animated.sequence([
192
+ Animated.timing(animatedValue, {
193
+ toValue: 1.05,
194
+ duration: duration,
195
+ easing: Easing.inOut(Easing.ease),
196
+ useNativeDriver: true,
197
+ }),
198
+ Animated.timing(animatedValue, {
199
+ toValue: 1,
200
+ duration: duration,
201
+ easing: Easing.inOut(Easing.ease),
202
+ useNativeDriver: true,
203
+ }),
204
+ ]);
205
+
206
+ if (config.repeat) {
207
+ Animated.loop(pulseSequence).start();
208
+ } else {
209
+ pulseSequence.start();
210
+ }
211
+ break;
212
+
213
+ case 'shake':
214
+ Animated.sequence([
215
+ Animated.timing(animatedValue, {
216
+ toValue: intensity,
217
+ duration: duration / 8,
218
+ useNativeDriver: true,
219
+ }),
220
+ Animated.timing(animatedValue, {
221
+ toValue: -intensity,
222
+ duration: duration / 4,
223
+ useNativeDriver: true,
224
+ }),
225
+ Animated.timing(animatedValue, {
226
+ toValue: intensity,
227
+ duration: duration / 4,
228
+ useNativeDriver: true,
229
+ }),
230
+ Animated.timing(animatedValue, {
231
+ toValue: -intensity,
232
+ duration: duration / 4,
233
+ useNativeDriver: true,
234
+ }),
235
+ Animated.timing(animatedValue, {
236
+ toValue: 0,
237
+ duration: duration / 8,
238
+ useNativeDriver: true,
239
+ }),
240
+ ]).start();
241
+ break;
242
+
243
+ case 'bounce':
244
+ Animated.sequence([
245
+ Animated.timing(animatedValue, {
246
+ toValue: -20,
247
+ duration: duration / 3,
248
+ easing: Easing.out(Easing.ease),
249
+ useNativeDriver: true,
250
+ }),
251
+ Animated.timing(animatedValue, {
252
+ toValue: 5,
253
+ duration: duration / 3,
254
+ easing: Easing.in(Easing.ease),
255
+ useNativeDriver: true,
256
+ }),
257
+ Animated.timing(animatedValue, {
258
+ toValue: 0,
259
+ duration: duration / 3,
260
+ easing: Easing.out(Easing.ease),
261
+ useNativeDriver: true,
262
+ }),
263
+ ]).start();
264
+ break;
265
+ }
266
+ };
267
+
268
+ // ─── Typewriter Text Animation ───
269
+
270
+ export interface TypewriterState {
271
+ displayedText: string;
272
+ currentIndex: number;
273
+ isComplete: boolean;
274
+ }
275
+
276
+ export const shouldTriggerHaptic = (
277
+ index: number,
278
+ frequency: string
279
+ ): boolean => {
280
+ switch (frequency) {
281
+ case 'every':
282
+ return true;
283
+ case 'every-2':
284
+ return index % 2 === 0;
285
+ case 'every-3':
286
+ return index % 3 === 0;
287
+ case 'every-5':
288
+ return index % 5 === 0;
289
+ default:
290
+ return false;
291
+ }
292
+ };