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.
- package/lib/OnboardingFlow.js +101 -15
- package/lib/analytics.d.ts +6 -0
- package/lib/analytics.js +18 -1
- package/lib/components/ElementRenderer.js +28 -39
- package/lib/types.d.ts +1 -0
- package/package.json +6 -14
- package/src/OnboardingFlow.tsx +114 -16
- package/src/analytics.ts +23 -1
- package/src/components/ElementRenderer.tsx +27 -32
- package/src/types.ts +1 -0
package/lib/OnboardingFlow.js
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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)(
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 =
|
|
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,
|
|
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={
|
|
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
|
package/lib/analytics.d.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "noboarding",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "
|
|
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",
|
package/src/OnboardingFlow.tsx
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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>(
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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={
|
|
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:
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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 }} />;
|