noboarding 1.0.1-beta → 1.0.3-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.
@@ -32,18 +32,39 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
35
38
  Object.defineProperty(exports, "__esModule", { value: true });
36
39
  exports.OnboardingFlow = void 0;
37
40
  const react_1 = __importStar(require("react"));
38
41
  const react_native_1 = require("react-native");
42
+ const async_storage_1 = __importDefault(require("@react-native-async-storage/async-storage"));
39
43
  const api_1 = require("./api");
40
44
  const analytics_1 = require("./analytics");
41
45
  const variableUtils_1 = require("./variableUtils");
42
46
  const ElementRenderer_1 = require("./components/ElementRenderer");
43
- // Generate unique IDs
44
- const generateUserId = () => {
45
- return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
47
+ const USER_ID_STORAGE_KEY = '@noboarding_user_id';
48
+ // Get or create persistent user ID
49
+ const getPersistentUserId = async () => {
50
+ try {
51
+ // Try to get existing user ID from storage
52
+ const existingUserId = await async_storage_1.default.getItem(USER_ID_STORAGE_KEY);
53
+ if (existingUserId) {
54
+ return existingUserId;
55
+ }
56
+ // Generate new user ID if none exists
57
+ const newUserId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
58
+ await async_storage_1.default.setItem(USER_ID_STORAGE_KEY, newUserId);
59
+ return newUserId;
60
+ }
61
+ catch (error) {
62
+ console.error('Failed to get/set user ID from storage:', error);
63
+ // Fallback to generating a non-persistent ID
64
+ return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
65
+ }
46
66
  };
67
+ // Generate session ID (always new per session)
47
68
  const generateSessionId = () => {
48
69
  return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
49
70
  };
@@ -80,14 +101,22 @@ const OnboardingFlow = ({ apiKey, testKey, productionKey, onComplete, onSkip, ba
80
101
  const [variables, setVariables] = (0, react_1.useState)(initialVariables || {});
81
102
  const apiRef = (0, react_1.useRef)(null);
82
103
  const analyticsRef = (0, react_1.useRef)(null);
83
- const userIdRef = (0, react_1.useRef)(generateUserId());
104
+ const userIdRef = (0, react_1.useRef)(null);
84
105
  const sessionIdRef = (0, react_1.useRef)(generateSessionId());
106
+ const flowIdRef = (0, react_1.useRef)(null);
85
107
  (0, react_1.useEffect)(() => {
86
- // Notify parent of the generated user ID
87
- if (onUserIdGenerated) {
88
- onUserIdGenerated(userIdRef.current);
89
- }
90
- initializeFlow();
108
+ const initialize = async () => {
109
+ // Get or create persistent user ID
110
+ const userId = await getPersistentUserId();
111
+ userIdRef.current = userId;
112
+ // Notify parent of the user ID
113
+ if (onUserIdGenerated) {
114
+ onUserIdGenerated(userId);
115
+ }
116
+ // Initialize the flow
117
+ await initializeFlow();
118
+ };
119
+ initialize();
91
120
  return () => {
92
121
  // Cleanup: flush remaining analytics
93
122
  if (analyticsRef.current) {
@@ -95,26 +124,65 @@ const OnboardingFlow = ({ apiKey, testKey, productionKey, onComplete, onSkip, ba
95
124
  }
96
125
  };
97
126
  }, []);
127
+ // Track screen views whenever user navigates to a different screen
128
+ (0, react_1.useEffect)(() => {
129
+ if (screens.length > 0 && analyticsRef.current && currentIndex > 0) {
130
+ const currentScreen = screens[currentIndex];
131
+ analyticsRef.current.track('screen_viewed', {
132
+ flow_id: flowIdRef.current,
133
+ screen_id: currentScreen.id,
134
+ screen_index: currentIndex,
135
+ });
136
+ }
137
+ }, [currentIndex, screens]);
98
138
  const initializeFlow = async () => {
139
+ var _a, _b, _c;
99
140
  try {
100
141
  setLoading(true);
101
142
  setError(null);
143
+ // Ensure user ID is set
144
+ if (!userIdRef.current) {
145
+ throw new Error('User ID not initialized');
146
+ }
102
147
  // Initialize API client with detected API key
103
148
  const api = new api_1.API(activeApiKey, baseUrl);
104
149
  apiRef.current = api;
105
150
  // Initialize analytics
106
151
  const analytics = new analytics_1.AnalyticsManager(api, userIdRef.current, sessionIdRef.current);
107
152
  analyticsRef.current = analytics;
108
- // Track onboarding start
109
- analytics.track('onboarding_started');
110
153
  // Fetch configuration
111
154
  const configResponse = await api.getConfig();
155
+ // Store flow_id for analytics
156
+ flowIdRef.current = configResponse.config_id;
157
+ // Handle A/B test experiment assignment
158
+ let screensToUse = configResponse.config.screens;
159
+ if (configResponse.experiments && configResponse.experiments.length > 0) {
160
+ // Assign user to the first active experiment
161
+ const experiment = configResponse.experiments[0];
162
+ try {
163
+ const assignment = await api.assignVariant(experiment.id, userIdRef.current);
164
+ // Set experiment context so all events get tagged
165
+ analytics.setExperimentContext(experiment.id, assignment.variant_id);
166
+ // Use variant screens if available
167
+ if (((_b = (_a = assignment.variant_config) === null || _a === void 0 ? void 0 : _a.screens) === null || _b === void 0 ? void 0 : _b.length) > 0) {
168
+ screensToUse = assignment.variant_config.screens;
169
+ }
170
+ }
171
+ catch (err) {
172
+ console.warn('Failed to assign experiment variant, using default flow:', err);
173
+ }
174
+ }
112
175
  // Backward compatibility: normalize legacy 'custom_screen' (with elements) to 'noboard_screen'
113
- const normalizedScreens = configResponse.config.screens
176
+ const normalizedScreens = screensToUse
114
177
  .map(s => (Object.assign(Object.assign({}, s), { type: (s.type === 'custom_screen' && s.elements) ? 'noboard_screen' : s.type })))
115
178
  // Filter out hidden screens (dashboard show/hide feature)
116
179
  .filter(s => !s.hidden);
117
180
  setScreens(normalizedScreens);
181
+ // Track onboarding start with first screen
182
+ analytics.track('onboarding_started', {
183
+ flow_id: flowIdRef.current,
184
+ screen_id: (_c = normalizedScreens[0]) === null || _c === void 0 ? void 0 : _c.id,
185
+ });
118
186
  setLoading(false);
119
187
  }
120
188
  catch (err) {
@@ -155,18 +223,25 @@ const OnboardingFlow = ({ apiKey, testKey, productionKey, onComplete, onSkip, ba
155
223
  setVariables(prev => (Object.assign(Object.assign({}, prev), { [name]: value })));
156
224
  }, []);
157
225
  const handleComplete = async (lastScreenData) => {
226
+ var _a;
158
227
  const finalData = Object.assign(Object.assign(Object.assign({}, collectedData), (lastScreenData || {})), { _variables: variables });
159
228
  // Track completion
160
229
  if (analyticsRef.current) {
161
- analyticsRef.current.track('onboarding_completed');
230
+ analyticsRef.current.track('onboarding_completed', {
231
+ flow_id: flowIdRef.current,
232
+ screen_id: (_a = screens[currentIndex]) === null || _a === void 0 ? void 0 : _a.id,
233
+ });
162
234
  await analyticsRef.current.flush();
163
235
  }
164
236
  // Call developer's completion callback with collected data
165
237
  onComplete(finalData);
166
238
  };
167
239
  const handleSkipAll = async () => {
240
+ var _a;
168
241
  if (analyticsRef.current) {
169
242
  analyticsRef.current.track('onboarding_abandoned', {
243
+ flow_id: flowIdRef.current,
244
+ screen_id: (_a = screens[currentIndex]) === null || _a === void 0 ? void 0 : _a.id,
170
245
  current_screen_index: currentIndex,
171
246
  });
172
247
  await analyticsRef.current.flush();
@@ -194,8 +269,19 @@ const OnboardingFlow = ({ apiKey, testKey, productionKey, onComplete, onSkip, ba
194
269
  const currentScreen = screens[currentIndex];
195
270
  // Handle noboard_screen type — render with ElementRenderer
196
271
  if (currentScreen.type === 'noboard_screen' && currentScreen.elements) {
272
+ // Merge collectedData (from custom screens) + variables (from noboard screens)
273
+ // This allows noboard screens to reference custom screen data in templates like {height_cm}
274
+ const allVariables = Object.assign(Object.assign({}, collectedData), variables);
275
+ // Warn about conflicts (same key in both sources)
276
+ if (__DEV__) {
277
+ Object.keys(collectedData).forEach(key => {
278
+ if (variables[key] !== undefined) {
279
+ console.warn(`[Noboarding] Variable conflict: "${key}" exists in both custom screen data and noboard variables. Using noboard value.`);
280
+ }
281
+ });
282
+ }
197
283
  const handleElementNavigate = (destination) => {
198
- const resolved = (0, variableUtils_1.resolveDestination)(destination, variables);
284
+ const resolved = (0, variableUtils_1.resolveDestination)(destination, allVariables);
199
285
  if (!resolved)
200
286
  return;
201
287
  if (resolved === 'next') {
@@ -216,7 +302,7 @@ const OnboardingFlow = ({ apiKey, testKey, productionKey, onComplete, onSkip, ba
216
302
  }
217
303
  };
218
304
  return (<react_native_1.View style={styles.container}>
219
- <ElementRenderer_1.ElementRenderer elements={currentScreen.elements} analytics={analyticsRef.current} screenId={currentScreen.id} onNavigate={handleElementNavigate} onDismiss={onSkip ? handleSkipAll : handleNext} variables={variables} onSetVariable={handleSetVariable}/>
305
+ <ElementRenderer_1.ElementRenderer elements={currentScreen.elements} analytics={analyticsRef.current} screenId={currentScreen.id} onNavigate={handleElementNavigate} onDismiss={onSkip ? handleSkipAll : handleNext} variables={allVariables} onSetVariable={handleSetVariable}/>
220
306
  </react_native_1.View>);
221
307
  }
222
308
  // Handle custom_screen type — developer-registered React Native components
@@ -5,7 +5,13 @@ export declare class AnalyticsManager {
5
5
  private userId;
6
6
  private sessionId;
7
7
  private flushTimer;
8
+ private experimentId;
9
+ private variantId;
8
10
  constructor(api: API, userId: string, sessionId: string);
11
+ /**
12
+ * Set experiment context — all subsequent events will be tagged
13
+ */
14
+ setExperimentContext(experimentId: string, variantId: string): void;
9
15
  /**
10
16
  * Track an analytics event
11
17
  */
package/lib/analytics.js CHANGED
@@ -7,22 +7,39 @@ class AnalyticsManager {
7
7
  constructor(api, userId, sessionId) {
8
8
  this.events = [];
9
9
  this.flushTimer = null;
10
+ this.experimentId = null;
11
+ this.variantId = null;
10
12
  this.api = api;
11
13
  this.userId = userId;
12
14
  this.sessionId = sessionId;
13
15
  // Start auto-flush timer
14
16
  this.startFlushTimer();
15
17
  }
18
+ /**
19
+ * Set experiment context — all subsequent events will be tagged
20
+ */
21
+ setExperimentContext(experimentId, variantId) {
22
+ this.experimentId = experimentId;
23
+ this.variantId = variantId;
24
+ }
16
25
  /**
17
26
  * Track an analytics event
18
27
  */
19
28
  track(eventName, properties) {
29
+ const mergedProperties = Object.assign({}, (properties || {}));
30
+ // Auto-inject experiment context if set
31
+ if (this.experimentId) {
32
+ mergedProperties.experiment_id = this.experimentId;
33
+ }
34
+ if (this.variantId) {
35
+ mergedProperties.variant_id = this.variantId;
36
+ }
20
37
  const event = {
21
38
  event: eventName,
22
39
  user_id: this.userId,
23
40
  session_id: this.sessionId,
24
41
  timestamp: Date.now(),
25
- properties: properties || {},
42
+ properties: mergedProperties,
26
43
  };
27
44
  this.events.push(event);
28
45
  // Auto-flush if batch size reached
@@ -48,36 +48,17 @@ exports.ElementRenderer = void 0;
48
48
  const react_1 = __importStar(require("react"));
49
49
  const react_native_1 = require("react-native");
50
50
  const variableUtils_1 = require("../variableUtils");
51
- // Try to import LinearGradient — optional peer dependency
52
- let LinearGradient = null;
53
- try {
54
- LinearGradient = require('expo-linear-gradient').LinearGradient;
55
- }
56
- catch (_a) {
57
- try {
58
- LinearGradient = require('react-native-linear-gradient').default;
59
- }
60
- catch (_b) {
61
- // Neither available — gradients will fall back to first color
62
- }
63
- }
64
- // Try to import vector icons — optional peer dependency
65
- let IconSets = {};
66
- try {
67
- const icons = require('@expo/vector-icons');
68
- IconSets = {
69
- lucide: icons.Feather, // Closest match to Lucide
70
- feather: icons.Feather,
71
- material: icons.MaterialIcons,
72
- 'material-community': icons.MaterialCommunityIcons,
73
- ionicons: icons.Ionicons,
74
- fontawesome: icons.FontAwesome,
75
- 'sf-symbols': icons.Ionicons, // Closest match to SF Symbols
76
- };
77
- }
78
- catch (_c) {
79
- // Not available — icons will fall back to text placeholder
80
- }
51
+ const expo_linear_gradient_1 = require("expo-linear-gradient");
52
+ const vector_icons_1 = require("@expo/vector-icons");
53
+ const IconSets = {
54
+ lucide: vector_icons_1.Feather,
55
+ feather: vector_icons_1.Feather,
56
+ material: vector_icons_1.MaterialIcons,
57
+ 'material-community': vector_icons_1.MaterialCommunityIcons,
58
+ ionicons: vector_icons_1.Ionicons,
59
+ fontawesome: vector_icons_1.FontAwesome,
60
+ 'sf-symbols': vector_icons_1.Ionicons,
61
+ };
81
62
  const ElementRenderer = ({ elements, analytics, screenId, onNavigate, onDismiss, variables = {}, onSetVariable, }) => {
82
63
  // Track toggled element IDs for toggle actions
83
64
  const [toggledIds, setToggledIds] = (0, react_1.useState)(new Set());
@@ -169,8 +150,8 @@ const ElementRenderer = ({ elements, analytics, screenId, onNavigate, onDismiss,
169
150
  </>);
170
151
  };
171
152
  exports.ElementRenderer = ElementRenderer;
172
- const RenderNode = ({ element, toggledIds, groupSelections, onAction, variables }) => {
173
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2, _3, _4, _5;
153
+ const RenderNode = ({ element, toggledIds, groupSelections, onAction, variables, onSetVariable }) => {
154
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2, _3, _4, _5, _6;
174
155
  // Variable-based conditions — hide element if condition is not met
175
156
  if ((_a = element.conditions) === null || _a === void 0 ? void 0 : _a.show_if) {
176
157
  const shouldShow = (0, variableUtils_1.evaluateCondition)(element.conditions.show_if, variables);
@@ -215,7 +196,7 @@ const RenderNode = ({ element, toggledIds, groupSelections, onAction, variables
215
196
  {content}
216
197
  </react_native_1.TouchableOpacity>);
217
198
  };
218
- const childProps = { toggledIds, groupSelections, onAction, variables };
199
+ const childProps = { toggledIds, groupSelections, onAction, variables, onSetVariable };
219
200
  switch (element.type) {
220
201
  // ─── Containers ───
221
202
  case 'vstack': {
@@ -346,7 +327,7 @@ const RenderNode = ({ element, toggledIds, groupSelections, onAction, variables
346
327
  {element.props.animationDescription}
347
328
  </react_native_1.Text>)}
348
329
  </react_native_1.View>);
349
- case 'input':
330
+ case 'input': {
350
331
  // Only apply default border if borderWidth is not explicitly defined (including 0)
351
332
  const inputStyle = style;
352
333
  const defaultInputStyle = {};
@@ -354,7 +335,15 @@ const RenderNode = ({ element, toggledIds, groupSelections, onAction, variables
354
335
  defaultInputStyle.borderWidth = 1;
355
336
  defaultInputStyle.borderColor = '#E5E5E5';
356
337
  }
357
- return (<react_native_1.TextInput style={[defaultInputStyle, inputStyle]} placeholder={((_2 = element.props) === null || _2 === void 0 ? void 0 : _2.placeholder) || 'Enter text...'} keyboardType={getKeyboardType((_3 = element.props) === null || _3 === void 0 ? void 0 : _3.type)} secureTextEntry={((_4 = element.props) === null || _4 === void 0 ? void 0 : _4.type) === 'password'} autoCapitalize={((_5 = element.props) === null || _5 === void 0 ? void 0 : _5.type) === 'email' ? 'none' : 'sentences'}/>);
338
+ // Get the variable name - use props.variable if specified, otherwise use element.id
339
+ const variableName = ((_2 = element.props) === null || _2 === void 0 ? void 0 : _2.variable) || element.id;
340
+ const currentValue = variables[variableName] || '';
341
+ return (<react_native_1.TextInput style={[defaultInputStyle, inputStyle]} placeholder={((_3 = element.props) === null || _3 === void 0 ? void 0 : _3.placeholder) || 'Enter text...'} keyboardType={getKeyboardType((_4 = element.props) === null || _4 === void 0 ? void 0 : _4.type)} secureTextEntry={((_5 = element.props) === null || _5 === void 0 ? void 0 : _5.type) === 'password'} autoCapitalize={((_6 = element.props) === null || _6 === void 0 ? void 0 : _6.type) === 'email' ? 'none' : 'sentences'} value={currentValue} onChangeText={(text) => {
342
+ if (onSetVariable) {
343
+ onSetVariable(variableName, text);
344
+ }
345
+ }}/>);
346
+ }
358
347
  case 'spacer':
359
348
  return <react_native_1.View style={style || { flex: 1 }}/>;
360
349
  case 'divider':
@@ -469,7 +458,7 @@ function convertStyle(style) {
469
458
  rnStyle.textDecorationLine = style.textDecorationLine;
470
459
  // backgroundGradient is handled by wrapWithGradient at the component level.
471
460
  // If LinearGradient is not available, fall back to the first gradient color.
472
- if (style.backgroundGradient && !LinearGradient && ((_a = style.backgroundGradient.colors) === null || _a === void 0 ? void 0 : _a.length)) {
461
+ if (style.backgroundGradient && !expo_linear_gradient_1.LinearGradient && ((_a = style.backgroundGradient.colors) === null || _a === void 0 ? void 0 : _a.length)) {
473
462
  const firstColor = style.backgroundGradient.colors[0];
474
463
  rnStyle.backgroundColor = typeof firstColor === 'string' ? firstColor : firstColor.color;
475
464
  }
@@ -487,7 +476,7 @@ function angleToCoords(angle) {
487
476
  function wrapWithGradient(content, elementStyle, viewStyle) {
488
477
  var _a, _b;
489
478
  const gradient = elementStyle === null || elementStyle === void 0 ? void 0 : elementStyle.backgroundGradient;
490
- if (!gradient || !LinearGradient || !((_a = gradient.colors) === null || _a === void 0 ? void 0 : _a.length))
479
+ if (!gradient || !expo_linear_gradient_1.LinearGradient || !((_a = gradient.colors) === null || _a === void 0 ? void 0 : _a.length))
491
480
  return content;
492
481
  // Handle both { color, position } objects and plain color strings
493
482
  const colors = gradient.colors.map((c) => typeof c === 'string' ? c : c.color);
@@ -517,9 +506,9 @@ function wrapWithGradient(content, elementStyle, viewStyle) {
517
506
  else {
518
507
  coords = angleToCoords((_b = gradient.angle) !== null && _b !== void 0 ? _b : 180);
519
508
  }
520
- return (<LinearGradient colors={colors} locations={locations} start={coords.start} end={coords.end} style={gradientStyle}>
509
+ return (<expo_linear_gradient_1.LinearGradient colors={colors} locations={locations} start={coords.start} end={coords.end} style={gradientStyle}>
521
510
  {content.props.children}
522
- </LinearGradient>);
511
+ </expo_linear_gradient_1.LinearGradient>);
523
512
  }
524
513
  // ─── Helpers ───
525
514
  function getKeyboardType(type) {
package/lib/types.d.ts CHANGED
@@ -141,6 +141,7 @@ export interface AnalyticsEvent {
141
141
  export interface GetConfigResponse {
142
142
  config: OnboardingConfig;
143
143
  version: string;
144
+ config_id: string | null;
144
145
  experiments: Experiment[];
145
146
  organization_id: string;
146
147
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "noboarding",
3
- "version": "1.0.1-beta",
4
- "description": "React Native SDK for remote onboarding flow management",
3
+ "version": "1.0.3-beta",
4
+ "description": "Expo SDK for remote onboarding flow management",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
7
7
  "exports": {
@@ -26,20 +26,12 @@
26
26
  "license": "MIT",
27
27
  "peerDependencies": {
28
28
  "react": ">=16.8.0",
29
- "react-native": ">=0.60.0",
30
- "expo-linear-gradient": ">=12.0.0",
31
- "@expo/vector-icons": ">=14.0.0"
32
- },
33
- "peerDependenciesMeta": {
34
- "expo-linear-gradient": {
35
- "optional": true
36
- },
37
- "@expo/vector-icons": {
38
- "optional": true
39
- }
29
+ "react-native": ">=0.60.0"
40
30
  },
41
31
  "dependencies": {
42
- "@react-native-async-storage/async-storage": "^1.19.0"
32
+ "@react-native-async-storage/async-storage": "^1.19.0",
33
+ "expo-linear-gradient": ">=12.0.0",
34
+ "@expo/vector-icons": ">=14.0.0"
43
35
  },
44
36
  "devDependencies": {
45
37
  "@types/node": "^25.2.3",
@@ -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
  };
@@ -60,16 +80,26 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
60
80
 
61
81
  const apiRef = useRef<API | null>(null);
62
82
  const analyticsRef = useRef<AnalyticsManager | null>(null);
63
- const userIdRef = useRef<string>(generateUserId());
83
+ const userIdRef = useRef<string | null>(null);
64
84
  const sessionIdRef = useRef<string>(generateSessionId());
85
+ const flowIdRef = useRef<string | null>(null);
65
86
 
66
87
  useEffect(() => {
67
- // Notify parent of the generated user ID
68
- if (onUserIdGenerated) {
69
- onUserIdGenerated(userIdRef.current);
70
- }
88
+ const initialize = async () => {
89
+ // Get or create persistent user ID
90
+ const userId = await getPersistentUserId();
91
+ userIdRef.current = userId;
92
+
93
+ // Notify parent of the user ID
94
+ if (onUserIdGenerated) {
95
+ onUserIdGenerated(userId);
96
+ }
71
97
 
72
- initializeFlow();
98
+ // Initialize the flow
99
+ await initializeFlow();
100
+ };
101
+
102
+ initialize();
73
103
 
74
104
  return () => {
75
105
  // Cleanup: flush remaining analytics
@@ -79,11 +109,28 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
79
109
  };
80
110
  }, []);
81
111
 
112
+ // Track screen views whenever user navigates to a different screen
113
+ useEffect(() => {
114
+ if (screens.length > 0 && analyticsRef.current && currentIndex > 0) {
115
+ const currentScreen = screens[currentIndex];
116
+ analyticsRef.current.track('screen_viewed', {
117
+ flow_id: flowIdRef.current,
118
+ screen_id: currentScreen.id,
119
+ screen_index: currentIndex,
120
+ });
121
+ }
122
+ }, [currentIndex, screens]);
123
+
82
124
  const initializeFlow = async () => {
83
125
  try {
84
126
  setLoading(true);
85
127
  setError(null);
86
128
 
129
+ // Ensure user ID is set
130
+ if (!userIdRef.current) {
131
+ throw new Error('User ID not initialized');
132
+ }
133
+
87
134
  // Initialize API client with detected API key
88
135
  const api = new API(activeApiKey, baseUrl);
89
136
  apiRef.current = api;
@@ -96,13 +143,38 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
96
143
  );
97
144
  analyticsRef.current = analytics;
98
145
 
99
- // Track onboarding start
100
- analytics.track('onboarding_started');
101
-
102
146
  // Fetch configuration
103
147
  const configResponse = await api.getConfig();
148
+
149
+ // Store flow_id for analytics
150
+ flowIdRef.current = configResponse.config_id;
151
+
152
+ // Handle A/B test experiment assignment
153
+ let screensToUse = configResponse.config.screens;
154
+
155
+ if (configResponse.experiments && configResponse.experiments.length > 0) {
156
+ // Assign user to the first active experiment
157
+ const experiment = configResponse.experiments[0];
158
+ try {
159
+ const assignment = await api.assignVariant(
160
+ experiment.id,
161
+ userIdRef.current!
162
+ );
163
+
164
+ // Set experiment context so all events get tagged
165
+ analytics.setExperimentContext(experiment.id, assignment.variant_id);
166
+
167
+ // Use variant screens if available
168
+ if (assignment.variant_config?.screens?.length > 0) {
169
+ screensToUse = assignment.variant_config.screens;
170
+ }
171
+ } catch (err) {
172
+ console.warn('Failed to assign experiment variant, using default flow:', err);
173
+ }
174
+ }
175
+
104
176
  // Backward compatibility: normalize legacy 'custom_screen' (with elements) to 'noboard_screen'
105
- const normalizedScreens = configResponse.config.screens
177
+ const normalizedScreens = screensToUse
106
178
  .map(s => ({
107
179
  ...s,
108
180
  type: (s.type === ('custom_screen' as any) && s.elements) ? 'noboard_screen' as ScreenConfig['type'] : s.type,
@@ -111,6 +183,12 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
111
183
  .filter(s => !s.hidden);
112
184
  setScreens(normalizedScreens);
113
185
 
186
+ // Track onboarding start with first screen
187
+ analytics.track('onboarding_started', {
188
+ flow_id: flowIdRef.current,
189
+ screen_id: normalizedScreens[0]?.id,
190
+ });
191
+
114
192
  setLoading(false);
115
193
  } catch (err) {
116
194
  console.error('Failed to initialize onboarding flow:', err);
@@ -162,7 +240,10 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
162
240
 
163
241
  // Track completion
164
242
  if (analyticsRef.current) {
165
- analyticsRef.current.track('onboarding_completed');
243
+ analyticsRef.current.track('onboarding_completed', {
244
+ flow_id: flowIdRef.current,
245
+ screen_id: screens[currentIndex]?.id,
246
+ });
166
247
  await analyticsRef.current.flush();
167
248
  }
168
249
 
@@ -173,6 +254,8 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
173
254
  const handleSkipAll = async () => {
174
255
  if (analyticsRef.current) {
175
256
  analyticsRef.current.track('onboarding_abandoned', {
257
+ flow_id: flowIdRef.current,
258
+ screen_id: screens[currentIndex]?.id,
176
259
  current_screen_index: currentIndex,
177
260
  });
178
261
  await analyticsRef.current.flush();
@@ -212,8 +295,23 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
212
295
 
213
296
  // Handle noboard_screen type — render with ElementRenderer
214
297
  if (currentScreen.type === 'noboard_screen' && currentScreen.elements) {
298
+ // Merge collectedData (from custom screens) + variables (from noboard screens)
299
+ // This allows noboard screens to reference custom screen data in templates like {height_cm}
300
+ const allVariables = { ...collectedData, ...variables };
301
+
302
+ // Warn about conflicts (same key in both sources)
303
+ if (__DEV__) {
304
+ Object.keys(collectedData).forEach(key => {
305
+ if (variables[key] !== undefined) {
306
+ console.warn(
307
+ `[Noboarding] Variable conflict: "${key}" exists in both custom screen data and noboard variables. Using noboard value.`
308
+ );
309
+ }
310
+ });
311
+ }
312
+
215
313
  const handleElementNavigate = (destination: string | ConditionalDestination | ConditionalRoutes) => {
216
- const resolved = resolveDestination(destination, variables);
314
+ const resolved = resolveDestination(destination, allVariables);
217
315
  if (!resolved) return;
218
316
 
219
317
  if (resolved === 'next') {
@@ -239,7 +337,7 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
239
337
  screenId={currentScreen.id}
240
338
  onNavigate={handleElementNavigate}
241
339
  onDismiss={onSkip ? handleSkipAll : handleNext}
242
- variables={variables}
340
+ variables={allVariables}
243
341
  onSetVariable={handleSetVariable}
244
342
  />
245
343
  </View>
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);
@@ -14,35 +14,18 @@ import {
14
14
  } from 'react-native';
15
15
  import { ElementNode, ElementStyle, ElementAction, Analytics, ConditionalDestination, ConditionalRoutes } from '../types';
16
16
  import { resolveTemplate, evaluateCondition } from '../variableUtils';
17
-
18
- // Try to import LinearGradient optional peer dependency
19
- let LinearGradient: any = null;
20
- try {
21
- LinearGradient = require('expo-linear-gradient').LinearGradient;
22
- } catch {
23
- try {
24
- LinearGradient = require('react-native-linear-gradient').default;
25
- } catch {
26
- // Neither available — gradients will fall back to first color
27
- }
28
- }
29
-
30
- // Try to import vector icons — optional peer dependency
31
- let IconSets: Record<string, any> = {};
32
- try {
33
- const icons = require('@expo/vector-icons');
34
- IconSets = {
35
- lucide: icons.Feather, // Closest match to Lucide
36
- feather: icons.Feather,
37
- material: icons.MaterialIcons,
38
- 'material-community': icons.MaterialCommunityIcons,
39
- ionicons: icons.Ionicons,
40
- fontawesome: icons.FontAwesome,
41
- 'sf-symbols': icons.Ionicons, // Closest match to SF Symbols
42
- };
43
- } catch {
44
- // Not available — icons will fall back to text placeholder
45
- }
17
+ import { LinearGradient } from 'expo-linear-gradient';
18
+ import { Feather, MaterialIcons, MaterialCommunityIcons, Ionicons, FontAwesome } from '@expo/vector-icons';
19
+
20
+ const IconSets: Record<string, any> = {
21
+ lucide: Feather,
22
+ feather: Feather,
23
+ material: MaterialIcons,
24
+ 'material-community': MaterialCommunityIcons,
25
+ ionicons: Ionicons,
26
+ fontawesome: FontAwesome,
27
+ 'sf-symbols': Ionicons,
28
+ };
46
29
 
47
30
  interface ElementRendererProps {
48
31
  elements: ElementNode[];
@@ -179,9 +162,10 @@ interface RenderNodeProps {
179
162
  groupSelections: Record<string, string>;
180
163
  onAction: (element: ElementNode) => void;
181
164
  variables: Record<string, any>;
165
+ onSetVariable?: (name: string, value: any) => void;
182
166
  }
183
167
 
184
- const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelections, onAction, variables }) => {
168
+ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelections, onAction, variables, onSetVariable }) => {
185
169
  // Variable-based conditions — hide element if condition is not met
186
170
  if (element.conditions?.show_if) {
187
171
  const shouldShow = evaluateCondition(element.conditions.show_if, variables);
@@ -235,7 +219,7 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
235
219
  );
236
220
  };
237
221
 
238
- const childProps = { toggledIds, groupSelections, onAction, variables };
222
+ const childProps = { toggledIds, groupSelections, onAction, variables, onSetVariable };
239
223
 
240
224
  switch (element.type) {
241
225
  // ─── Containers ───
@@ -446,7 +430,7 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
446
430
  </View>
447
431
  );
448
432
 
449
- case 'input':
433
+ case 'input': {
450
434
  // Only apply default border if borderWidth is not explicitly defined (including 0)
451
435
  const inputStyle = style as TextStyle;
452
436
  const defaultInputStyle: TextStyle = {};
@@ -455,6 +439,10 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
455
439
  defaultInputStyle.borderColor = '#E5E5E5';
456
440
  }
457
441
 
442
+ // Get the variable name - use props.variable if specified, otherwise use element.id
443
+ const variableName = element.props?.variable || element.id;
444
+ const currentValue = variables[variableName] || '';
445
+
458
446
  return (
459
447
  <TextInput
460
448
  style={[defaultInputStyle, inputStyle]}
@@ -462,8 +450,15 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
462
450
  keyboardType={getKeyboardType(element.props?.type)}
463
451
  secureTextEntry={element.props?.type === 'password'}
464
452
  autoCapitalize={element.props?.type === 'email' ? 'none' : 'sentences'}
453
+ value={currentValue}
454
+ onChangeText={(text) => {
455
+ if (onSetVariable) {
456
+ onSetVariable(variableName, text);
457
+ }
458
+ }}
465
459
  />
466
460
  );
461
+ }
467
462
 
468
463
  case 'spacer':
469
464
  return <View style={style || { flex: 1 }} />;
package/src/types.ts CHANGED
@@ -189,6 +189,7 @@ export interface AnalyticsEvent {
189
189
  export interface GetConfigResponse {
190
190
  config: OnboardingConfig;
191
191
  version: string;
192
+ config_id: string | null;
192
193
  experiments: Experiment[];
193
194
  organization_id: string;
194
195
  }