noboarding 0.1.0-alpha → 1.0.1-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/AI_CUSTOM_SCREEN_GUIDE.md +888 -0
- package/README.md +78 -2
- package/lib/OnboardingFlow.js +38 -5
- package/lib/components/ElementRenderer.js +17 -3
- package/lib/types.d.ts +5 -1
- package/package.json +9 -3
- package/src/OnboardingFlow.tsx +44 -6
- package/src/components/ElementRenderer.tsx +17 -1
- package/src/types.ts +10 -1
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
# AI Assistant Guide: Building Custom Screens for Noboarding SDK
|
|
2
|
+
|
|
3
|
+
> **Purpose:** Copy this entire guide and paste it to your AI coding assistant (Claude Code, Cursor, GitHub Copilot, etc.) when you need to build a custom screen for the Noboarding SDK.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Instructions for AI Assistant
|
|
8
|
+
|
|
9
|
+
I need you to help me build a custom screen component for the Noboarding SDK. This guide explains how to structure the code, handle data flow between screens, and set up the screen in the dashboard.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Context: How Custom Screens Work
|
|
14
|
+
|
|
15
|
+
Custom screens are React Native components that integrate into remotely-managed onboarding flows. They:
|
|
16
|
+
- Live in the app code (not on servers)
|
|
17
|
+
- Collect user data during onboarding
|
|
18
|
+
- Pass data to subsequent screens
|
|
19
|
+
- Can access data from previous screens
|
|
20
|
+
- Are positioned in flows via the dashboard
|
|
21
|
+
|
|
22
|
+
**Data Flow:**
|
|
23
|
+
```
|
|
24
|
+
Screen 1 (SDK or Custom) → collects data → passes to Screen 2
|
|
25
|
+
Screen 2 (Custom) → receives data from Screen 1 → adds more data → passes to Screen 3
|
|
26
|
+
Screen 3 (SDK or Custom) → receives all previous data → continues...
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Part 1: Custom Screen Component Structure
|
|
32
|
+
|
|
33
|
+
### Required Imports
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import React, { useState, useEffect } from 'react';
|
|
37
|
+
import { View, Text, Button, StyleSheet } from 'react-native';
|
|
38
|
+
import type { CustomScreenProps } from 'noboarding';
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Component Props Interface
|
|
42
|
+
|
|
43
|
+
Every custom screen receives these props from the SDK:
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
interface CustomScreenProps {
|
|
47
|
+
// Analytics tracking
|
|
48
|
+
analytics: {
|
|
49
|
+
track: (event: string, properties?: Record<string, any>) => void;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Navigation
|
|
53
|
+
onNext: () => void; // Move to next screen
|
|
54
|
+
onBack?: () => void; // Move to previous screen (undefined on first screen)
|
|
55
|
+
onSkip?: () => void; // Skip this screen (optional)
|
|
56
|
+
|
|
57
|
+
// Data flow
|
|
58
|
+
data?: Record<string, any>; // Data from PREVIOUS screens (read-only)
|
|
59
|
+
onDataUpdate?: (newData: Record<string, any>) => void; // Add YOUR data
|
|
60
|
+
|
|
61
|
+
// Preview mode
|
|
62
|
+
preview?: boolean; // True when rendering in dashboard
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Basic Component Template
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
export const MyCustomScreen: React.FC<CustomScreenProps> = ({
|
|
70
|
+
analytics,
|
|
71
|
+
onNext,
|
|
72
|
+
onSkip,
|
|
73
|
+
preview,
|
|
74
|
+
data, // Data from previous screens
|
|
75
|
+
onDataUpdate, // Function to add your data
|
|
76
|
+
}) => {
|
|
77
|
+
// 1. Local state for THIS screen's data
|
|
78
|
+
const [myData, setMyData] = useState({
|
|
79
|
+
// Initialize with default values
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// 2. Track screen view on mount
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
analytics.track('screen_viewed', {
|
|
85
|
+
screen_id: 'my_custom_screen',
|
|
86
|
+
screen_type: 'custom'
|
|
87
|
+
});
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
// 3. Handle continue/next
|
|
91
|
+
const handleContinue = () => {
|
|
92
|
+
// Update collected data with THIS screen's data
|
|
93
|
+
onDataUpdate?.({
|
|
94
|
+
// Add your screen's data here
|
|
95
|
+
...myData,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Track completion
|
|
99
|
+
analytics.track('screen_completed', {
|
|
100
|
+
screen_id: 'my_custom_screen',
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Navigate to next screen
|
|
104
|
+
onNext();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// 4. Preview mode (for dashboard)
|
|
108
|
+
if (preview) {
|
|
109
|
+
return (
|
|
110
|
+
<View style={styles.previewContainer}>
|
|
111
|
+
<Text style={styles.previewText}>Preview: My Custom Screen</Text>
|
|
112
|
+
<Button title="Continue" onPress={onNext} />
|
|
113
|
+
</View>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 5. Real implementation
|
|
118
|
+
return (
|
|
119
|
+
<View style={styles.container}>
|
|
120
|
+
{/* Access data from PREVIOUS screens */}
|
|
121
|
+
{data?.userName && (
|
|
122
|
+
<Text>Welcome back, {data.userName}!</Text>
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
{/* Your screen UI here */}
|
|
126
|
+
|
|
127
|
+
<Button title="Continue" onPress={handleContinue} />
|
|
128
|
+
{onSkip && (
|
|
129
|
+
<Button title="Skip" onPress={onSkip} color="#666" />
|
|
130
|
+
)}
|
|
131
|
+
</View>
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Part 2: Data Flow Between Screens
|
|
139
|
+
|
|
140
|
+
### Example: Multi-Screen Data Collection
|
|
141
|
+
|
|
142
|
+
**Screen 1: Name Collection (Custom)**
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
export const NameScreen: React.FC<CustomScreenProps> = ({
|
|
146
|
+
analytics,
|
|
147
|
+
onNext,
|
|
148
|
+
data,
|
|
149
|
+
onDataUpdate,
|
|
150
|
+
}) => {
|
|
151
|
+
const [name, setName] = useState('');
|
|
152
|
+
|
|
153
|
+
const handleContinue = () => {
|
|
154
|
+
// Add THIS screen's data
|
|
155
|
+
onDataUpdate?.({
|
|
156
|
+
userName: name,
|
|
157
|
+
nameCollectedAt: new Date().toISOString(),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
analytics.track('name_collected', { name });
|
|
161
|
+
onNext();
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<View>
|
|
166
|
+
<TextInput
|
|
167
|
+
placeholder="Enter your name"
|
|
168
|
+
value={name}
|
|
169
|
+
onChangeText={setName}
|
|
170
|
+
/>
|
|
171
|
+
<Button title="Continue" onPress={handleContinue} />
|
|
172
|
+
</View>
|
|
173
|
+
);
|
|
174
|
+
};
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Screen 2: Age Collection (Custom)**
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
export const AgeScreen: React.FC<CustomScreenProps> = ({
|
|
181
|
+
analytics,
|
|
182
|
+
onNext,
|
|
183
|
+
data, // Contains: { userName, nameCollectedAt }
|
|
184
|
+
onDataUpdate,
|
|
185
|
+
}) => {
|
|
186
|
+
const [age, setAge] = useState('');
|
|
187
|
+
|
|
188
|
+
const handleContinue = () => {
|
|
189
|
+
// Add THIS screen's data
|
|
190
|
+
onDataUpdate?.({
|
|
191
|
+
userAge: parseInt(age),
|
|
192
|
+
ageCollectedAt: new Date().toISOString(),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
analytics.track('age_collected', { age });
|
|
196
|
+
onNext();
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<View>
|
|
201
|
+
{/* Access data from previous screen */}
|
|
202
|
+
<Text>Hi {data?.userName}! What's your age?</Text>
|
|
203
|
+
|
|
204
|
+
<TextInput
|
|
205
|
+
placeholder="Enter your age"
|
|
206
|
+
value={age}
|
|
207
|
+
onChangeText={setAge}
|
|
208
|
+
keyboardType="numeric"
|
|
209
|
+
/>
|
|
210
|
+
<Button title="Continue" onPress={handleContinue} />
|
|
211
|
+
</View>
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Screen 3: Summary (Custom)**
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
export const SummaryScreen: React.FC<CustomScreenProps> = ({
|
|
220
|
+
analytics,
|
|
221
|
+
onNext,
|
|
222
|
+
data, // Contains: { userName, nameCollectedAt, userAge, ageCollectedAt }
|
|
223
|
+
}) => {
|
|
224
|
+
return (
|
|
225
|
+
<View>
|
|
226
|
+
<Text>Summary:</Text>
|
|
227
|
+
<Text>Name: {data?.userName}</Text>
|
|
228
|
+
<Text>Age: {data?.userAge}</Text>
|
|
229
|
+
|
|
230
|
+
<Button title="Confirm" onPress={onNext} />
|
|
231
|
+
</View>
|
|
232
|
+
);
|
|
233
|
+
};
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**Final onComplete Handler (in App.tsx)**
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
<OnboardingFlow
|
|
240
|
+
// Dual API keys for automatic environment detection
|
|
241
|
+
testKey="nb_test_..."
|
|
242
|
+
productionKey="nb_live_..."
|
|
243
|
+
// SDK auto-detects __DEV__ and uses appropriate key
|
|
244
|
+
|
|
245
|
+
customComponents={{
|
|
246
|
+
NameScreen,
|
|
247
|
+
AgeScreen,
|
|
248
|
+
SummaryScreen,
|
|
249
|
+
}}
|
|
250
|
+
onComplete={(userData) => {
|
|
251
|
+
// userData contains ALL collected data:
|
|
252
|
+
console.log(userData);
|
|
253
|
+
// {
|
|
254
|
+
// userName: "John",
|
|
255
|
+
// nameCollectedAt: "2025-02-20...",
|
|
256
|
+
// userAge: 25,
|
|
257
|
+
// ageCollectedAt: "2025-02-20...",
|
|
258
|
+
// _variables: { ... }
|
|
259
|
+
// }
|
|
260
|
+
}}
|
|
261
|
+
/>
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Part 3: Common Patterns
|
|
267
|
+
|
|
268
|
+
### Pattern 1: Form with Validation
|
|
269
|
+
|
|
270
|
+
```typescript
|
|
271
|
+
export const FormScreen: React.FC<CustomScreenProps> = ({
|
|
272
|
+
analytics,
|
|
273
|
+
onNext,
|
|
274
|
+
onDataUpdate,
|
|
275
|
+
}) => {
|
|
276
|
+
const [formData, setFormData] = useState({
|
|
277
|
+
email: '',
|
|
278
|
+
phone: '',
|
|
279
|
+
});
|
|
280
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
281
|
+
|
|
282
|
+
const validate = () => {
|
|
283
|
+
const newErrors: Record<string, string> = {};
|
|
284
|
+
|
|
285
|
+
if (!formData.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
|
|
286
|
+
newErrors.email = 'Invalid email';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!formData.phone.match(/^\d{10}$/)) {
|
|
290
|
+
newErrors.phone = 'Phone must be 10 digits';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
setErrors(newErrors);
|
|
294
|
+
return Object.keys(newErrors).length === 0;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const handleSubmit = () => {
|
|
298
|
+
if (validate()) {
|
|
299
|
+
onDataUpdate?.(formData);
|
|
300
|
+
analytics.track('form_submitted', { valid: true });
|
|
301
|
+
onNext();
|
|
302
|
+
} else {
|
|
303
|
+
analytics.track('form_validation_failed', { errors: Object.keys(errors) });
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<View>
|
|
309
|
+
<TextInput
|
|
310
|
+
placeholder="Email"
|
|
311
|
+
value={formData.email}
|
|
312
|
+
onChangeText={(email) => setFormData({ ...formData, email })}
|
|
313
|
+
/>
|
|
314
|
+
{errors.email && <Text style={styles.error}>{errors.email}</Text>}
|
|
315
|
+
|
|
316
|
+
<TextInput
|
|
317
|
+
placeholder="Phone"
|
|
318
|
+
value={formData.phone}
|
|
319
|
+
onChangeText={(phone) => setFormData({ ...formData, phone })}
|
|
320
|
+
/>
|
|
321
|
+
{errors.phone && <Text style={styles.error}>{errors.phone}</Text>}
|
|
322
|
+
|
|
323
|
+
<Button title="Continue" onPress={handleSubmit} />
|
|
324
|
+
</View>
|
|
325
|
+
);
|
|
326
|
+
};
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Pattern 2: Multi-Select Options
|
|
330
|
+
|
|
331
|
+
```typescript
|
|
332
|
+
export const PreferencesScreen: React.FC<CustomScreenProps> = ({
|
|
333
|
+
analytics,
|
|
334
|
+
onNext,
|
|
335
|
+
data,
|
|
336
|
+
onDataUpdate,
|
|
337
|
+
}) => {
|
|
338
|
+
const [selectedPreferences, setSelectedPreferences] = useState<string[]>([]);
|
|
339
|
+
|
|
340
|
+
const options = ['Option A', 'Option B', 'Option C', 'Option D'];
|
|
341
|
+
|
|
342
|
+
const togglePreference = (option: string) => {
|
|
343
|
+
if (selectedPreferences.includes(option)) {
|
|
344
|
+
setSelectedPreferences(selectedPreferences.filter(p => p !== option));
|
|
345
|
+
} else {
|
|
346
|
+
setSelectedPreferences([...selectedPreferences, option]);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const handleContinue = () => {
|
|
351
|
+
onDataUpdate?.({
|
|
352
|
+
preferences: selectedPreferences,
|
|
353
|
+
preferencesCount: selectedPreferences.length,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
analytics.track('preferences_selected', {
|
|
357
|
+
count: selectedPreferences.length,
|
|
358
|
+
selections: selectedPreferences,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
onNext();
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
return (
|
|
365
|
+
<View>
|
|
366
|
+
<Text>Select your preferences:</Text>
|
|
367
|
+
|
|
368
|
+
{options.map(option => (
|
|
369
|
+
<TouchableOpacity
|
|
370
|
+
key={option}
|
|
371
|
+
onPress={() => togglePreference(option)}
|
|
372
|
+
style={[
|
|
373
|
+
styles.option,
|
|
374
|
+
selectedPreferences.includes(option) && styles.optionSelected
|
|
375
|
+
]}
|
|
376
|
+
>
|
|
377
|
+
<Text>{option}</Text>
|
|
378
|
+
{selectedPreferences.includes(option) && <Text>✓</Text>}
|
|
379
|
+
</TouchableOpacity>
|
|
380
|
+
))}
|
|
381
|
+
|
|
382
|
+
<Button
|
|
383
|
+
title="Continue"
|
|
384
|
+
onPress={handleContinue}
|
|
385
|
+
disabled={selectedPreferences.length === 0}
|
|
386
|
+
/>
|
|
387
|
+
</View>
|
|
388
|
+
);
|
|
389
|
+
};
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### Pattern 3: API Call with Data from Previous Screen
|
|
393
|
+
|
|
394
|
+
```typescript
|
|
395
|
+
export const ProfileSetupScreen: React.FC<CustomScreenProps> = ({
|
|
396
|
+
analytics,
|
|
397
|
+
onNext,
|
|
398
|
+
data, // Data from previous screens
|
|
399
|
+
onDataUpdate,
|
|
400
|
+
}) => {
|
|
401
|
+
const [loading, setLoading] = useState(false);
|
|
402
|
+
|
|
403
|
+
const createProfile = async () => {
|
|
404
|
+
setLoading(true);
|
|
405
|
+
analytics.track('profile_creation_started');
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
// Use data from previous screens
|
|
409
|
+
const response = await fetch('https://your-api.com/profile', {
|
|
410
|
+
method: 'POST',
|
|
411
|
+
headers: { 'Content-Type': 'application/json' },
|
|
412
|
+
body: JSON.stringify({
|
|
413
|
+
name: data?.userName,
|
|
414
|
+
age: data?.userAge,
|
|
415
|
+
email: data?.email,
|
|
416
|
+
preferences: data?.preferences,
|
|
417
|
+
}),
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const result = await response.json();
|
|
421
|
+
|
|
422
|
+
// Add API response to collected data
|
|
423
|
+
onDataUpdate?.({
|
|
424
|
+
profileId: result.id,
|
|
425
|
+
profileCreated: true,
|
|
426
|
+
profileCreatedAt: new Date().toISOString(),
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
analytics.track('profile_created', { profileId: result.id });
|
|
430
|
+
onNext();
|
|
431
|
+
} catch (error: any) {
|
|
432
|
+
analytics.track('profile_creation_failed', { error: error.message });
|
|
433
|
+
Alert.alert('Error', 'Failed to create profile. Please try again.');
|
|
434
|
+
} finally {
|
|
435
|
+
setLoading(false);
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
return (
|
|
440
|
+
<View>
|
|
441
|
+
<Text>Creating profile for {data?.userName}...</Text>
|
|
442
|
+
<Button
|
|
443
|
+
title="Create Profile"
|
|
444
|
+
onPress={createProfile}
|
|
445
|
+
disabled={loading}
|
|
446
|
+
/>
|
|
447
|
+
{loading && <ActivityIndicator />}
|
|
448
|
+
</View>
|
|
449
|
+
);
|
|
450
|
+
};
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
### Pattern 4: Conditional Logic Based on Previous Data
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
export const ConditionalScreen: React.FC<CustomScreenProps> = ({
|
|
457
|
+
analytics,
|
|
458
|
+
onNext,
|
|
459
|
+
data,
|
|
460
|
+
onDataUpdate,
|
|
461
|
+
}) => {
|
|
462
|
+
// Show different UI based on previous data
|
|
463
|
+
const isPremiumUser = data?.userAge && data.userAge > 25;
|
|
464
|
+
|
|
465
|
+
if (isPremiumUser) {
|
|
466
|
+
return (
|
|
467
|
+
<View>
|
|
468
|
+
<Text>Premium Experience</Text>
|
|
469
|
+
<Text>Welcome {data?.userName}! You qualify for premium features.</Text>
|
|
470
|
+
<Button
|
|
471
|
+
title="Continue"
|
|
472
|
+
onPress={() => {
|
|
473
|
+
onDataUpdate?.({ userTier: 'premium' });
|
|
474
|
+
onNext();
|
|
475
|
+
}}
|
|
476
|
+
/>
|
|
477
|
+
</View>
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return (
|
|
482
|
+
<View>
|
|
483
|
+
<Text>Standard Experience</Text>
|
|
484
|
+
<Text>Welcome {data?.userName}!</Text>
|
|
485
|
+
<Button
|
|
486
|
+
title="Continue"
|
|
487
|
+
onPress={() => {
|
|
488
|
+
onDataUpdate?.({ userTier: 'standard' });
|
|
489
|
+
onNext();
|
|
490
|
+
}}
|
|
491
|
+
/>
|
|
492
|
+
</View>
|
|
493
|
+
);
|
|
494
|
+
};
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
## Part 4: Analytics Best Practices
|
|
500
|
+
|
|
501
|
+
### Track All Key Events
|
|
502
|
+
|
|
503
|
+
```typescript
|
|
504
|
+
// Screen lifecycle
|
|
505
|
+
useEffect(() => {
|
|
506
|
+
analytics.track('screen_viewed', {
|
|
507
|
+
screen_id: 'my_screen',
|
|
508
|
+
screen_type: 'custom',
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
return () => {
|
|
512
|
+
analytics.track('screen_exited', {
|
|
513
|
+
screen_id: 'my_screen',
|
|
514
|
+
});
|
|
515
|
+
};
|
|
516
|
+
}, []);
|
|
517
|
+
|
|
518
|
+
// User interactions
|
|
519
|
+
analytics.track('button_clicked', { button: 'submit' });
|
|
520
|
+
analytics.track('input_focused', { field: 'email' });
|
|
521
|
+
analytics.track('option_selected', { option: 'premium' });
|
|
522
|
+
|
|
523
|
+
// Completion
|
|
524
|
+
analytics.track('screen_completed', {
|
|
525
|
+
screen_id: 'my_screen',
|
|
526
|
+
data_collected: true,
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// Errors
|
|
530
|
+
analytics.track('error_occurred', {
|
|
531
|
+
error_type: 'validation',
|
|
532
|
+
error_message: 'Invalid email',
|
|
533
|
+
});
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
538
|
+
## Part 5: Setting Up Custom Screens in Dashboard
|
|
539
|
+
|
|
540
|
+
### Step 1: Register Component in App
|
|
541
|
+
|
|
542
|
+
```typescript
|
|
543
|
+
// App.tsx
|
|
544
|
+
import { OnboardingFlow } from 'noboarding';
|
|
545
|
+
import { NameScreen } from './screens/NameScreen';
|
|
546
|
+
import { AgeScreen } from './screens/AgeScreen';
|
|
547
|
+
import { PreferencesScreen } from './screens/PreferencesScreen';
|
|
548
|
+
|
|
549
|
+
<OnboardingFlow
|
|
550
|
+
// Use dual API keys for automatic environment detection
|
|
551
|
+
testKey="nb_test_your_test_key"
|
|
552
|
+
productionKey="nb_live_your_production_key"
|
|
553
|
+
// The SDK automatically uses testKey when __DEV__ is true
|
|
554
|
+
// and productionKey in production builds
|
|
555
|
+
|
|
556
|
+
customComponents={{
|
|
557
|
+
NameScreen: NameScreen, // Component name MUST match exactly
|
|
558
|
+
AgeScreen: AgeScreen, // Case-sensitive!
|
|
559
|
+
PreferencesScreen: PreferencesScreen,
|
|
560
|
+
}}
|
|
561
|
+
onComplete={(userData) => {
|
|
562
|
+
console.log('All collected data:', userData);
|
|
563
|
+
}}
|
|
564
|
+
/>
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### Step 2: Add to Dashboard Flow
|
|
568
|
+
|
|
569
|
+
1. **Log in to Noboarding Dashboard**
|
|
570
|
+
2. **Go to Flows** and select or create a flow
|
|
571
|
+
3. **Click "Add Custom Screen"**
|
|
572
|
+
4. **Enter Component Name:** (must match EXACTLY what you registered)
|
|
573
|
+
- Example: `NameScreen`
|
|
574
|
+
- ❌ Wrong: `nameScreen`, `name_screen`, `NameScreenComponent`
|
|
575
|
+
- ✅ Correct: `NameScreen`
|
|
576
|
+
5. **Add Description:** "Collects user's name"
|
|
577
|
+
6. **Position in Flow:** Drag to desired position
|
|
578
|
+
7. **Save Draft**
|
|
579
|
+
|
|
580
|
+
### Step 3: Flow Order Example
|
|
581
|
+
|
|
582
|
+
```
|
|
583
|
+
Dashboard Flow Builder:
|
|
584
|
+
┌─────────────────────────────────────┐
|
|
585
|
+
│ 1. Welcome Screen (SDK) │
|
|
586
|
+
│ 2. NameScreen (Custom) │ ← Your custom screen
|
|
587
|
+
│ 3. AgeScreen (Custom) │ ← Your custom screen
|
|
588
|
+
│ 4. Feature Tour (SDK) │
|
|
589
|
+
│ 5. PreferencesScreen (Custom) │ ← Your custom screen
|
|
590
|
+
│ 6. Complete (SDK) │
|
|
591
|
+
└─────────────────────────────────────┘
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
### Step 4: Test Locally
|
|
595
|
+
|
|
596
|
+
```bash
|
|
597
|
+
npm start
|
|
598
|
+
# Test in development mode
|
|
599
|
+
# Navigate through onboarding
|
|
600
|
+
# Check console for collected data
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
### Step 5: Deploy & Publish
|
|
604
|
+
|
|
605
|
+
1. Test locally with Test API Key (development mode)
|
|
606
|
+
2. In dashboard: **Publish → Publish for Testing**
|
|
607
|
+
3. Build production app
|
|
608
|
+
4. Submit to App Store / Google Play
|
|
609
|
+
5. **WAIT for approval**
|
|
610
|
+
6. **After app is live:** In dashboard, **Publish → Publish to Production**
|
|
611
|
+
|
|
612
|
+
**Note:** Test and Production environments are separate. You can safely test changes using the Test API Key before rolling them out to production users with the Production API Key.
|
|
613
|
+
|
|
614
|
+
---
|
|
615
|
+
|
|
616
|
+
## Part 6: Complete Example - Multi-Step Form
|
|
617
|
+
|
|
618
|
+
Here's a complete example showing data flow across 3 custom screens:
|
|
619
|
+
|
|
620
|
+
```typescript
|
|
621
|
+
// screens/Step1EmailScreen.tsx
|
|
622
|
+
export const Step1EmailScreen: React.FC<CustomScreenProps> = ({
|
|
623
|
+
analytics,
|
|
624
|
+
onNext,
|
|
625
|
+
preview,
|
|
626
|
+
onDataUpdate,
|
|
627
|
+
}) => {
|
|
628
|
+
const [email, setEmail] = useState('');
|
|
629
|
+
|
|
630
|
+
useEffect(() => {
|
|
631
|
+
analytics.track('screen_viewed', { screen_id: 'step1_email' });
|
|
632
|
+
}, []);
|
|
633
|
+
|
|
634
|
+
const handleContinue = () => {
|
|
635
|
+
onDataUpdate?.({
|
|
636
|
+
email: email,
|
|
637
|
+
emailCollectedAt: new Date().toISOString(),
|
|
638
|
+
});
|
|
639
|
+
analytics.track('email_collected');
|
|
640
|
+
onNext();
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
if (preview) {
|
|
644
|
+
return (
|
|
645
|
+
<View style={styles.preview}>
|
|
646
|
+
<Text>📧 Email Collection Screen</Text>
|
|
647
|
+
<Button title="Continue" onPress={onNext} />
|
|
648
|
+
</View>
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return (
|
|
653
|
+
<View style={styles.container}>
|
|
654
|
+
<Text style={styles.title}>What's your email?</Text>
|
|
655
|
+
<TextInput
|
|
656
|
+
style={styles.input}
|
|
657
|
+
placeholder="email@example.com"
|
|
658
|
+
value={email}
|
|
659
|
+
onChangeText={setEmail}
|
|
660
|
+
keyboardType="email-address"
|
|
661
|
+
autoCapitalize="none"
|
|
662
|
+
/>
|
|
663
|
+
<Button
|
|
664
|
+
title="Continue"
|
|
665
|
+
onPress={handleContinue}
|
|
666
|
+
disabled={!email}
|
|
667
|
+
/>
|
|
668
|
+
</View>
|
|
669
|
+
);
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
// screens/Step2GoalsScreen.tsx
|
|
673
|
+
export const Step2GoalsScreen: React.FC<CustomScreenProps> = ({
|
|
674
|
+
analytics,
|
|
675
|
+
onNext,
|
|
676
|
+
data, // Contains: { email, emailCollectedAt }
|
|
677
|
+
preview,
|
|
678
|
+
onDataUpdate,
|
|
679
|
+
}) => {
|
|
680
|
+
const [selectedGoals, setSelectedGoals] = useState<string[]>([]);
|
|
681
|
+
|
|
682
|
+
useEffect(() => {
|
|
683
|
+
analytics.track('screen_viewed', {
|
|
684
|
+
screen_id: 'step2_goals',
|
|
685
|
+
user_email: data?.email
|
|
686
|
+
});
|
|
687
|
+
}, []);
|
|
688
|
+
|
|
689
|
+
const goals = ['Fitness', 'Nutrition', 'Sleep', 'Mindfulness'];
|
|
690
|
+
|
|
691
|
+
const toggleGoal = (goal: string) => {
|
|
692
|
+
if (selectedGoals.includes(goal)) {
|
|
693
|
+
setSelectedGoals(selectedGoals.filter(g => g !== goal));
|
|
694
|
+
} else {
|
|
695
|
+
setSelectedGoals([...selectedGoals, goal]);
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
const handleContinue = () => {
|
|
700
|
+
onDataUpdate?.({
|
|
701
|
+
goals: selectedGoals,
|
|
702
|
+
goalsCount: selectedGoals.length,
|
|
703
|
+
});
|
|
704
|
+
analytics.track('goals_selected', { count: selectedGoals.length });
|
|
705
|
+
onNext();
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
if (preview) {
|
|
709
|
+
return (
|
|
710
|
+
<View style={styles.preview}>
|
|
711
|
+
<Text>🎯 Goals Selection Screen</Text>
|
|
712
|
+
<Button title="Continue" onPress={onNext} />
|
|
713
|
+
</View>
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return (
|
|
718
|
+
<View style={styles.container}>
|
|
719
|
+
<Text style={styles.title}>
|
|
720
|
+
Hi {data?.email?.split('@')[0]}! What are your goals?
|
|
721
|
+
</Text>
|
|
722
|
+
|
|
723
|
+
{goals.map(goal => (
|
|
724
|
+
<TouchableOpacity
|
|
725
|
+
key={goal}
|
|
726
|
+
onPress={() => toggleGoal(goal)}
|
|
727
|
+
style={[
|
|
728
|
+
styles.goalOption,
|
|
729
|
+
selectedGoals.includes(goal) && styles.goalSelected
|
|
730
|
+
]}
|
|
731
|
+
>
|
|
732
|
+
<Text>{goal}</Text>
|
|
733
|
+
{selectedGoals.includes(goal) && <Text>✓</Text>}
|
|
734
|
+
</TouchableOpacity>
|
|
735
|
+
))}
|
|
736
|
+
|
|
737
|
+
<Button
|
|
738
|
+
title="Continue"
|
|
739
|
+
onPress={handleContinue}
|
|
740
|
+
disabled={selectedGoals.length === 0}
|
|
741
|
+
/>
|
|
742
|
+
</View>
|
|
743
|
+
);
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
// screens/Step3SummaryScreen.tsx
|
|
747
|
+
export const Step3SummaryScreen: React.FC<CustomScreenProps> = ({
|
|
748
|
+
analytics,
|
|
749
|
+
onNext,
|
|
750
|
+
data, // Contains: { email, emailCollectedAt, goals, goalsCount }
|
|
751
|
+
preview,
|
|
752
|
+
}) => {
|
|
753
|
+
useEffect(() => {
|
|
754
|
+
analytics.track('screen_viewed', { screen_id: 'step3_summary' });
|
|
755
|
+
}, []);
|
|
756
|
+
|
|
757
|
+
if (preview) {
|
|
758
|
+
return (
|
|
759
|
+
<View style={styles.preview}>
|
|
760
|
+
<Text>📋 Summary Screen</Text>
|
|
761
|
+
<Button title="Continue" onPress={onNext} />
|
|
762
|
+
</View>
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return (
|
|
767
|
+
<View style={styles.container}>
|
|
768
|
+
<Text style={styles.title}>Summary</Text>
|
|
769
|
+
|
|
770
|
+
<View style={styles.summaryCard}>
|
|
771
|
+
<Text style={styles.label}>Email:</Text>
|
|
772
|
+
<Text style={styles.value}>{data?.email}</Text>
|
|
773
|
+
</View>
|
|
774
|
+
|
|
775
|
+
<View style={styles.summaryCard}>
|
|
776
|
+
<Text style={styles.label}>Goals ({data?.goalsCount}):</Text>
|
|
777
|
+
{data?.goals?.map((goal: string) => (
|
|
778
|
+
<Text key={goal} style={styles.value}>• {goal}</Text>
|
|
779
|
+
))}
|
|
780
|
+
</View>
|
|
781
|
+
|
|
782
|
+
<Button title="Complete Setup" onPress={onNext} />
|
|
783
|
+
</View>
|
|
784
|
+
);
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
const styles = StyleSheet.create({
|
|
788
|
+
container: {
|
|
789
|
+
flex: 1,
|
|
790
|
+
padding: 20,
|
|
791
|
+
backgroundColor: '#fff',
|
|
792
|
+
},
|
|
793
|
+
preview: {
|
|
794
|
+
flex: 1,
|
|
795
|
+
justifyContent: 'center',
|
|
796
|
+
alignItems: 'center',
|
|
797
|
+
padding: 20,
|
|
798
|
+
},
|
|
799
|
+
title: {
|
|
800
|
+
fontSize: 24,
|
|
801
|
+
fontWeight: 'bold',
|
|
802
|
+
marginBottom: 20,
|
|
803
|
+
},
|
|
804
|
+
input: {
|
|
805
|
+
borderWidth: 1,
|
|
806
|
+
borderColor: '#ccc',
|
|
807
|
+
borderRadius: 8,
|
|
808
|
+
padding: 12,
|
|
809
|
+
fontSize: 16,
|
|
810
|
+
marginBottom: 20,
|
|
811
|
+
},
|
|
812
|
+
goalOption: {
|
|
813
|
+
flexDirection: 'row',
|
|
814
|
+
justifyContent: 'space-between',
|
|
815
|
+
padding: 16,
|
|
816
|
+
borderWidth: 1,
|
|
817
|
+
borderColor: '#ccc',
|
|
818
|
+
borderRadius: 8,
|
|
819
|
+
marginBottom: 12,
|
|
820
|
+
},
|
|
821
|
+
goalSelected: {
|
|
822
|
+
borderColor: '#007AFF',
|
|
823
|
+
backgroundColor: '#E3F2FD',
|
|
824
|
+
},
|
|
825
|
+
summaryCard: {
|
|
826
|
+
padding: 16,
|
|
827
|
+
backgroundColor: '#f5f5f5',
|
|
828
|
+
borderRadius: 8,
|
|
829
|
+
marginBottom: 16,
|
|
830
|
+
},
|
|
831
|
+
label: {
|
|
832
|
+
fontSize: 14,
|
|
833
|
+
color: '#666',
|
|
834
|
+
marginBottom: 4,
|
|
835
|
+
},
|
|
836
|
+
value: {
|
|
837
|
+
fontSize: 16,
|
|
838
|
+
color: '#000',
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
**Register in App.tsx:**
|
|
844
|
+
|
|
845
|
+
```typescript
|
|
846
|
+
<OnboardingFlow
|
|
847
|
+
// Dual API keys - SDK auto-detects environment
|
|
848
|
+
testKey="nb_test_..."
|
|
849
|
+
productionKey="nb_live_..."
|
|
850
|
+
|
|
851
|
+
customComponents={{
|
|
852
|
+
Step1EmailScreen,
|
|
853
|
+
Step2GoalsScreen,
|
|
854
|
+
Step3SummaryScreen,
|
|
855
|
+
}}
|
|
856
|
+
onComplete={(userData) => {
|
|
857
|
+
console.log(userData);
|
|
858
|
+
// {
|
|
859
|
+
// email: "john@example.com",
|
|
860
|
+
// emailCollectedAt: "2025-02-20...",
|
|
861
|
+
// goals: ["Fitness", "Nutrition"],
|
|
862
|
+
// goalsCount: 2,
|
|
863
|
+
// _variables: { ... }
|
|
864
|
+
// }
|
|
865
|
+
}}
|
|
866
|
+
/>
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
---
|
|
870
|
+
|
|
871
|
+
## Summary Checklist
|
|
872
|
+
|
|
873
|
+
When building a custom screen, ensure you:
|
|
874
|
+
|
|
875
|
+
- [ ] Import `CustomScreenProps` from 'noboarding'
|
|
876
|
+
- [ ] Use all required props: `analytics`, `onNext`, `data`, `onDataUpdate`
|
|
877
|
+
- [ ] Track `screen_viewed` on mount
|
|
878
|
+
- [ ] Implement `preview` mode for dashboard
|
|
879
|
+
- [ ] Call `onDataUpdate()` to add your data before `onNext()`
|
|
880
|
+
- [ ] Track `screen_completed` before navigating
|
|
881
|
+
- [ ] Access previous data via `data` prop
|
|
882
|
+
- [ ] Register component in `customComponents` with EXACT name
|
|
883
|
+
- [ ] Add to dashboard with matching component name
|
|
884
|
+
- [ ] Test data flow across multiple screens
|
|
885
|
+
|
|
886
|
+
---
|
|
887
|
+
|
|
888
|
+
**Now you're ready to build custom screens!** Ask me any questions if you need clarification on any part of this guide.
|
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ yarn add noboarding
|
|
|
14
14
|
- **[AI Setup](./SETUP_GUIDE.md#ai-setup)** - Copy/paste instructions for your AI coding assistant (Claude Code, Cursor, etc.)
|
|
15
15
|
- **[Manual Setup](./SETUP_GUIDE.md#normal-setup)** - Step-by-step instructions
|
|
16
16
|
- **[RevenueCat Integration](./REVENUECAT_SETUP.md)** - Detailed RevenueCat paywall guide
|
|
17
|
+
- **[AI Custom Screen Guide](./AI_CUSTOM_SCREEN_GUIDE.md)** - Complete guide for building custom screens with data flow (for AI assistants)
|
|
17
18
|
|
|
18
19
|
## Quick Start
|
|
19
20
|
|
|
@@ -26,7 +27,14 @@ function App() {
|
|
|
26
27
|
if (showOnboarding) {
|
|
27
28
|
return (
|
|
28
29
|
<OnboardingFlow
|
|
29
|
-
|
|
30
|
+
// Recommended: Use dual keys for automatic environment detection
|
|
31
|
+
testKey="nb_test_your_test_key_here"
|
|
32
|
+
productionKey="nb_live_your_production_key_here"
|
|
33
|
+
// The SDK automatically uses testKey in __DEV__ and productionKey in production
|
|
34
|
+
|
|
35
|
+
// Alternative: Legacy single key (still supported)
|
|
36
|
+
// apiKey="nb_test_your_api_key_here"
|
|
37
|
+
|
|
30
38
|
onComplete={(userData) => {
|
|
31
39
|
console.log('Collected data:', userData);
|
|
32
40
|
setShowOnboarding(false);
|
|
@@ -47,6 +55,14 @@ function App() {
|
|
|
47
55
|
}
|
|
48
56
|
```
|
|
49
57
|
|
|
58
|
+
### API Keys
|
|
59
|
+
|
|
60
|
+
You'll find two API keys in your dashboard:
|
|
61
|
+
- **Test Key** (`nb_test_...`) - Used for development and testing
|
|
62
|
+
- **Production Key** (`nb_live_...`) - Used for production builds
|
|
63
|
+
|
|
64
|
+
The SDK automatically detects your environment using React Native's `__DEV__` flag and uses the appropriate key.
|
|
65
|
+
|
|
50
66
|
## How It Works
|
|
51
67
|
|
|
52
68
|
1. The SDK fetches your onboarding configuration from Supabase at runtime
|
|
@@ -240,6 +256,7 @@ interface CustomScreenProps {
|
|
|
240
256
|
track: (event: string, properties?: Record<string, any>) => void;
|
|
241
257
|
};
|
|
242
258
|
onNext: () => void;
|
|
259
|
+
onBack?: () => void; // Navigate to previous screen (undefined on first screen)
|
|
243
260
|
onSkip?: () => void;
|
|
244
261
|
preview?: boolean; // True when rendering in dashboard preview
|
|
245
262
|
data?: Record<string, any>; // Previously collected user data
|
|
@@ -426,7 +443,7 @@ useEffect(() => {
|
|
|
426
443
|
- ✅ Handle errors gracefully with user-friendly messages
|
|
427
444
|
- ✅ Call `onNext()` when screen is complete
|
|
428
445
|
|
|
429
|
-
For more details, see [Custom
|
|
446
|
+
For more details, see [AI Custom Screen Guide](./AI_CUSTOM_SCREEN_GUIDE.md).
|
|
430
447
|
|
|
431
448
|
## API
|
|
432
449
|
|
|
@@ -496,6 +513,8 @@ import { API, AnalyticsManager } from 'noboarding';
|
|
|
496
513
|
|
|
497
514
|
## Development
|
|
498
515
|
|
|
516
|
+
### Building the SDK
|
|
517
|
+
|
|
499
518
|
The TestApp imports the SDK from the compiled `lib/` directory (`"main": "lib/index.js"`), not from `src/` directly. After making any changes to files in `sdk/src/`, you must rebuild before testing:
|
|
500
519
|
|
|
501
520
|
```bash
|
|
@@ -505,6 +524,63 @@ npm run build
|
|
|
505
524
|
|
|
506
525
|
Then restart the TestApp. If you skip this step, the TestApp will still be running the old compiled code and your changes won't take effect.
|
|
507
526
|
|
|
527
|
+
### Dashboard Preview Integration
|
|
528
|
+
|
|
529
|
+
The dashboard uses **local copies** of SDK source files for the preview feature. When you modify SDK source files, they need to be synced to the dashboard.
|
|
530
|
+
|
|
531
|
+
**Why copies?** Next.js/Turbopack doesn't support importing from external directories with the react-native-web setup, so the dashboard maintains local copies in `dashboard/lib/sdk/`.
|
|
532
|
+
|
|
533
|
+
#### Files That Need Syncing
|
|
534
|
+
|
|
535
|
+
When you modify these SDK files:
|
|
536
|
+
- `src/types.ts` → Auto-synced to `dashboard/lib/sdk/types.ts`
|
|
537
|
+
- `src/variableUtils.ts` → Auto-synced to `dashboard/lib/sdk/variableUtils.ts`
|
|
538
|
+
- `src/components/ElementRenderer.tsx` → ⚠️ **NOT auto-synced** (dashboard has web-specific modifications)
|
|
539
|
+
|
|
540
|
+
**ElementRenderer Special Case:**
|
|
541
|
+
|
|
542
|
+
The dashboard copy of `ElementRenderer.tsx` has web-specific modifications for icon support:
|
|
543
|
+
- Uses `react-icons` instead of `@expo/vector-icons`
|
|
544
|
+
- Renders real icons in preview (Feather, Material, Ionicons, FontAwesome)
|
|
545
|
+
- Gradients fall back to solid colors
|
|
546
|
+
|
|
547
|
+
**If you modify ElementRenderer.tsx significantly:**
|
|
548
|
+
1. Run `npm run sync:full` from project root to copy it
|
|
549
|
+
2. Manually re-add web icon imports and logic (check git diff to see what changed)
|
|
550
|
+
|
|
551
|
+
#### Syncing Methods
|
|
552
|
+
|
|
553
|
+
**Manual sync (run when needed):**
|
|
554
|
+
```bash
|
|
555
|
+
# From project root
|
|
556
|
+
npm run sync
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
**Auto-sync during development:**
|
|
560
|
+
```bash
|
|
561
|
+
# From project root
|
|
562
|
+
npm run sync:watch
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
This watches SDK files and automatically copies changes to the dashboard when you save.
|
|
566
|
+
|
|
567
|
+
**Full development mode:**
|
|
568
|
+
```bash
|
|
569
|
+
# From project root - starts dashboard + auto-sync
|
|
570
|
+
npm run dev
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
This command:
|
|
574
|
+
1. Syncs SDK files to dashboard
|
|
575
|
+
2. Starts file watcher for auto-sync
|
|
576
|
+
3. Starts dashboard dev server
|
|
577
|
+
|
|
578
|
+
#### Important Notes
|
|
579
|
+
|
|
580
|
+
- ⚠️ **Dashboard preview uses copies** - Changes to SDK files won't appear in dashboard preview until synced
|
|
581
|
+
- ✅ **Mobile app uses npm package** - TestApp uses the built SDK from `lib/`, requires `npm run build`
|
|
582
|
+
- 🔄 **Keep in sync** - Run `npm run sync:watch` while developing SDK to keep dashboard preview accurate
|
|
583
|
+
|
|
508
584
|
## Requirements
|
|
509
585
|
|
|
510
586
|
- React Native >= 0.60.0
|
package/lib/OnboardingFlow.js
CHANGED
|
@@ -47,7 +47,31 @@ const generateUserId = () => {
|
|
|
47
47
|
const generateSessionId = () => {
|
|
48
48
|
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
49
49
|
};
|
|
50
|
-
|
|
50
|
+
// Detect environment: true in dev/Expo, false in production builds
|
|
51
|
+
const detectEnvironment = () => {
|
|
52
|
+
if (__DEV__)
|
|
53
|
+
return 'test';
|
|
54
|
+
return 'production';
|
|
55
|
+
};
|
|
56
|
+
const OnboardingFlow = ({ apiKey, testKey, productionKey, onComplete, onSkip, baseUrl, initialVariables, customComponents, onUserIdGenerated, }) => {
|
|
57
|
+
// Determine which API key to use
|
|
58
|
+
const getApiKey = () => {
|
|
59
|
+
// If dual keys provided, use environment detection
|
|
60
|
+
if (testKey && productionKey) {
|
|
61
|
+
const env = detectEnvironment();
|
|
62
|
+
return env === 'test' ? testKey : productionKey;
|
|
63
|
+
}
|
|
64
|
+
// If only one dual key provided, use it
|
|
65
|
+
if (testKey)
|
|
66
|
+
return testKey;
|
|
67
|
+
if (productionKey)
|
|
68
|
+
return productionKey;
|
|
69
|
+
// Fallback to legacy single key
|
|
70
|
+
if (apiKey)
|
|
71
|
+
return apiKey;
|
|
72
|
+
throw new Error('Noboarding SDK: No API key provided. Please provide either apiKey, or both testKey and productionKey.');
|
|
73
|
+
};
|
|
74
|
+
const activeApiKey = getApiKey();
|
|
51
75
|
const [loading, setLoading] = (0, react_1.useState)(true);
|
|
52
76
|
const [error, setError] = (0, react_1.useState)(null);
|
|
53
77
|
const [screens, setScreens] = (0, react_1.useState)([]);
|
|
@@ -75,8 +99,8 @@ const OnboardingFlow = ({ apiKey, onComplete, onSkip, baseUrl, initialVariables,
|
|
|
75
99
|
try {
|
|
76
100
|
setLoading(true);
|
|
77
101
|
setError(null);
|
|
78
|
-
// Initialize API client
|
|
79
|
-
const api = new api_1.API(
|
|
102
|
+
// Initialize API client with detected API key
|
|
103
|
+
const api = new api_1.API(activeApiKey, baseUrl);
|
|
80
104
|
apiRef.current = api;
|
|
81
105
|
// Initialize analytics
|
|
82
106
|
const analytics = new analytics_1.AnalyticsManager(api, userIdRef.current, sessionIdRef.current);
|
|
@@ -86,7 +110,10 @@ const OnboardingFlow = ({ apiKey, onComplete, onSkip, baseUrl, initialVariables,
|
|
|
86
110
|
// Fetch configuration
|
|
87
111
|
const configResponse = await api.getConfig();
|
|
88
112
|
// Backward compatibility: normalize legacy 'custom_screen' (with elements) to 'noboard_screen'
|
|
89
|
-
const normalizedScreens = configResponse.config.screens
|
|
113
|
+
const normalizedScreens = configResponse.config.screens
|
|
114
|
+
.map(s => (Object.assign(Object.assign({}, s), { type: (s.type === 'custom_screen' && s.elements) ? 'noboard_screen' : s.type })))
|
|
115
|
+
// Filter out hidden screens (dashboard show/hide feature)
|
|
116
|
+
.filter(s => !s.hidden);
|
|
90
117
|
setScreens(normalizedScreens);
|
|
91
118
|
setLoading(false);
|
|
92
119
|
}
|
|
@@ -109,6 +136,12 @@ const OnboardingFlow = ({ apiKey, onComplete, onSkip, baseUrl, initialVariables,
|
|
|
109
136
|
setCurrentIndex((prev) => prev + 1);
|
|
110
137
|
}
|
|
111
138
|
};
|
|
139
|
+
const handleBack = () => {
|
|
140
|
+
// Navigate to previous screen (only if not on first screen)
|
|
141
|
+
if (currentIndex > 0) {
|
|
142
|
+
setCurrentIndex((prev) => prev - 1);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
112
145
|
const handleSkipScreen = () => {
|
|
113
146
|
// Move to next screen or complete
|
|
114
147
|
if (currentIndex >= screens.length - 1) {
|
|
@@ -201,7 +234,7 @@ const OnboardingFlow = ({ apiKey, onComplete, onSkip, baseUrl, initialVariables,
|
|
|
201
234
|
</react_native_1.View>);
|
|
202
235
|
}
|
|
203
236
|
return (<react_native_1.View style={styles.container}>
|
|
204
|
-
<CustomComponent analytics={analyticsRef.current} onNext={() => handleNext()} onSkip={onSkip ? handleSkipAll : undefined} data={collectedData} onDataUpdate={(newData) => setCollectedData(prev => (Object.assign(Object.assign({}, prev), newData)))}/>
|
|
237
|
+
<CustomComponent analytics={analyticsRef.current} onNext={() => handleNext()} onBack={currentIndex > 0 ? handleBack : undefined} onSkip={onSkip ? handleSkipAll : undefined} data={collectedData} onDataUpdate={(newData) => setCollectedData(prev => (Object.assign(Object.assign({}, prev), newData)))}/>
|
|
205
238
|
</react_native_1.View>);
|
|
206
239
|
}
|
|
207
240
|
// Unknown screen type fallback
|
|
@@ -170,7 +170,7 @@ const ElementRenderer = ({ elements, analytics, screenId, onNavigate, onDismiss,
|
|
|
170
170
|
};
|
|
171
171
|
exports.ElementRenderer = ElementRenderer;
|
|
172
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;
|
|
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;
|
|
174
174
|
// Variable-based conditions — hide element if condition is not met
|
|
175
175
|
if ((_a = element.conditions) === null || _a === void 0 ? void 0 : _a.show_if) {
|
|
176
176
|
const shouldShow = (0, variableUtils_1.evaluateCondition)(element.conditions.show_if, variables);
|
|
@@ -204,7 +204,14 @@ const RenderNode = ({ element, toggledIds, groupSelections, onAction, variables
|
|
|
204
204
|
const wrapWithAction = (content) => {
|
|
205
205
|
if (!hasAction)
|
|
206
206
|
return content;
|
|
207
|
-
|
|
207
|
+
// Extract width/alignment styles that should apply to TouchableOpacity wrapper
|
|
208
|
+
// This ensures buttons with width: "100%" don't shrink to content
|
|
209
|
+
const wrapperStyle = {};
|
|
210
|
+
if (style.width)
|
|
211
|
+
wrapperStyle.width = style.width;
|
|
212
|
+
if (style.alignSelf)
|
|
213
|
+
wrapperStyle.alignSelf = style.alignSelf;
|
|
214
|
+
return (<react_native_1.TouchableOpacity key={element.id} activeOpacity={0.7} onPress={() => onAction(element)} style={wrapperStyle}>
|
|
208
215
|
{content}
|
|
209
216
|
</react_native_1.TouchableOpacity>);
|
|
210
217
|
};
|
|
@@ -340,7 +347,14 @@ const RenderNode = ({ element, toggledIds, groupSelections, onAction, variables
|
|
|
340
347
|
</react_native_1.Text>)}
|
|
341
348
|
</react_native_1.View>);
|
|
342
349
|
case 'input':
|
|
343
|
-
|
|
350
|
+
// Only apply default border if borderWidth is not explicitly defined (including 0)
|
|
351
|
+
const inputStyle = style;
|
|
352
|
+
const defaultInputStyle = {};
|
|
353
|
+
if (((_0 = element.style) === null || _0 === void 0 ? void 0 : _0.borderWidth) === undefined && ((_1 = element.style) === null || _1 === void 0 ? void 0 : _1.borderColor) === undefined) {
|
|
354
|
+
defaultInputStyle.borderWidth = 1;
|
|
355
|
+
defaultInputStyle.borderColor = '#E5E5E5';
|
|
356
|
+
}
|
|
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'}/>);
|
|
344
358
|
case 'spacer':
|
|
345
359
|
return <react_native_1.View style={style || { flex: 1 }}/>;
|
|
346
360
|
case 'divider':
|
package/lib/types.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface ScreenConfig {
|
|
|
6
6
|
props: Record<string, any>;
|
|
7
7
|
elements?: ElementNode[];
|
|
8
8
|
custom_component_name?: string;
|
|
9
|
+
hidden?: boolean;
|
|
9
10
|
}
|
|
10
11
|
export type ElementType = 'vstack' | 'hstack' | 'zstack' | 'scrollview' | 'text' | 'image' | 'video' | 'lottie' | 'icon' | 'input' | 'spacer' | 'divider';
|
|
11
12
|
export interface ElementNode {
|
|
@@ -169,13 +170,16 @@ export interface CustomScreenProps {
|
|
|
169
170
|
track: (event: string, properties?: Record<string, any>) => void;
|
|
170
171
|
};
|
|
171
172
|
onNext: () => void;
|
|
173
|
+
onBack?: () => void;
|
|
172
174
|
onSkip?: () => void;
|
|
173
175
|
preview?: boolean;
|
|
174
176
|
data?: Record<string, any>;
|
|
175
177
|
onDataUpdate?: (data: Record<string, any>) => void;
|
|
176
178
|
}
|
|
177
179
|
export interface OnboardingFlowProps {
|
|
178
|
-
|
|
180
|
+
testKey?: string;
|
|
181
|
+
productionKey?: string;
|
|
182
|
+
apiKey?: string;
|
|
179
183
|
onComplete: (data?: Record<string, any>) => void;
|
|
180
184
|
onSkip?: () => void;
|
|
181
185
|
baseUrl?: string;
|
package/package.json
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "noboarding",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "1.0.1-beta",
|
|
4
4
|
"description": "React Native SDK for remote onboarding flow management",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./lib/index.d.ts",
|
|
10
|
+
"default": "./lib/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./src/*": "./src/*"
|
|
13
|
+
},
|
|
7
14
|
"scripts": {
|
|
8
15
|
"build": "tsc",
|
|
9
|
-
"watch": "tsc --watch"
|
|
10
|
-
"prepare": "npm run build"
|
|
16
|
+
"watch": "tsc --watch"
|
|
11
17
|
},
|
|
12
18
|
"keywords": [
|
|
13
19
|
"react-native",
|
package/src/OnboardingFlow.tsx
CHANGED
|
@@ -15,8 +15,16 @@ const generateSessionId = (): string => {
|
|
|
15
15
|
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
// Detect environment: true in dev/Expo, false in production builds
|
|
19
|
+
const detectEnvironment = (): 'test' | 'production' => {
|
|
20
|
+
if (__DEV__) return 'test';
|
|
21
|
+
return 'production';
|
|
22
|
+
};
|
|
23
|
+
|
|
18
24
|
export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
19
25
|
apiKey,
|
|
26
|
+
testKey,
|
|
27
|
+
productionKey,
|
|
20
28
|
onComplete,
|
|
21
29
|
onSkip,
|
|
22
30
|
baseUrl,
|
|
@@ -24,6 +32,25 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
24
32
|
customComponents,
|
|
25
33
|
onUserIdGenerated,
|
|
26
34
|
}) => {
|
|
35
|
+
// Determine which API key to use
|
|
36
|
+
const getApiKey = (): string => {
|
|
37
|
+
// If dual keys provided, use environment detection
|
|
38
|
+
if (testKey && productionKey) {
|
|
39
|
+
const env = detectEnvironment();
|
|
40
|
+
return env === 'test' ? testKey : productionKey;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// If only one dual key provided, use it
|
|
44
|
+
if (testKey) return testKey;
|
|
45
|
+
if (productionKey) return productionKey;
|
|
46
|
+
|
|
47
|
+
// Fallback to legacy single key
|
|
48
|
+
if (apiKey) return apiKey;
|
|
49
|
+
|
|
50
|
+
throw new Error('Noboarding SDK: No API key provided. Please provide either apiKey, or both testKey and productionKey.');
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const activeApiKey = getApiKey();
|
|
27
54
|
const [loading, setLoading] = useState(true);
|
|
28
55
|
const [error, setError] = useState<string | null>(null);
|
|
29
56
|
const [screens, setScreens] = useState<ScreenConfig[]>([]);
|
|
@@ -57,8 +84,8 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
57
84
|
setLoading(true);
|
|
58
85
|
setError(null);
|
|
59
86
|
|
|
60
|
-
// Initialize API client
|
|
61
|
-
const api = new API(
|
|
87
|
+
// Initialize API client with detected API key
|
|
88
|
+
const api = new API(activeApiKey, baseUrl);
|
|
62
89
|
apiRef.current = api;
|
|
63
90
|
|
|
64
91
|
// Initialize analytics
|
|
@@ -75,10 +102,13 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
75
102
|
// Fetch configuration
|
|
76
103
|
const configResponse = await api.getConfig();
|
|
77
104
|
// Backward compatibility: normalize legacy 'custom_screen' (with elements) to 'noboard_screen'
|
|
78
|
-
const normalizedScreens = configResponse.config.screens
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
105
|
+
const normalizedScreens = configResponse.config.screens
|
|
106
|
+
.map(s => ({
|
|
107
|
+
...s,
|
|
108
|
+
type: (s.type === ('custom_screen' as any) && s.elements) ? 'noboard_screen' as ScreenConfig['type'] : s.type,
|
|
109
|
+
}))
|
|
110
|
+
// Filter out hidden screens (dashboard show/hide feature)
|
|
111
|
+
.filter(s => !s.hidden);
|
|
82
112
|
setScreens(normalizedScreens);
|
|
83
113
|
|
|
84
114
|
setLoading(false);
|
|
@@ -103,6 +133,13 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
103
133
|
}
|
|
104
134
|
};
|
|
105
135
|
|
|
136
|
+
const handleBack = () => {
|
|
137
|
+
// Navigate to previous screen (only if not on first screen)
|
|
138
|
+
if (currentIndex > 0) {
|
|
139
|
+
setCurrentIndex((prev) => prev - 1);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
106
143
|
const handleSkipScreen = () => {
|
|
107
144
|
// Move to next screen or complete
|
|
108
145
|
if (currentIndex >= screens.length - 1) {
|
|
@@ -235,6 +272,7 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
235
272
|
<CustomComponent
|
|
236
273
|
analytics={analyticsRef.current!}
|
|
237
274
|
onNext={() => handleNext()}
|
|
275
|
+
onBack={currentIndex > 0 ? handleBack : undefined}
|
|
238
276
|
onSkip={onSkip ? handleSkipAll : undefined}
|
|
239
277
|
data={collectedData}
|
|
240
278
|
onDataUpdate={(newData) => setCollectedData(prev => ({ ...prev, ...newData }))}
|
|
@@ -216,11 +216,19 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
216
216
|
const hasAction = !!element.action || (element.actions && element.actions.length > 0);
|
|
217
217
|
const wrapWithAction = (content: React.ReactElement): React.ReactElement => {
|
|
218
218
|
if (!hasAction) return content;
|
|
219
|
+
|
|
220
|
+
// Extract width/alignment styles that should apply to TouchableOpacity wrapper
|
|
221
|
+
// This ensures buttons with width: "100%" don't shrink to content
|
|
222
|
+
const wrapperStyle: any = {};
|
|
223
|
+
if (style.width) wrapperStyle.width = style.width;
|
|
224
|
+
if (style.alignSelf) wrapperStyle.alignSelf = style.alignSelf;
|
|
225
|
+
|
|
219
226
|
return (
|
|
220
227
|
<TouchableOpacity
|
|
221
228
|
key={element.id}
|
|
222
229
|
activeOpacity={0.7}
|
|
223
230
|
onPress={() => onAction(element)}
|
|
231
|
+
style={wrapperStyle}
|
|
224
232
|
>
|
|
225
233
|
{content}
|
|
226
234
|
</TouchableOpacity>
|
|
@@ -439,9 +447,17 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
439
447
|
);
|
|
440
448
|
|
|
441
449
|
case 'input':
|
|
450
|
+
// Only apply default border if borderWidth is not explicitly defined (including 0)
|
|
451
|
+
const inputStyle = style as TextStyle;
|
|
452
|
+
const defaultInputStyle: TextStyle = {};
|
|
453
|
+
if (element.style?.borderWidth === undefined && element.style?.borderColor === undefined) {
|
|
454
|
+
defaultInputStyle.borderWidth = 1;
|
|
455
|
+
defaultInputStyle.borderColor = '#E5E5E5';
|
|
456
|
+
}
|
|
457
|
+
|
|
442
458
|
return (
|
|
443
459
|
<TextInput
|
|
444
|
-
style={[
|
|
460
|
+
style={[defaultInputStyle, inputStyle]}
|
|
445
461
|
placeholder={element.props?.placeholder || 'Enter text...'}
|
|
446
462
|
keyboardType={getKeyboardType(element.props?.type)}
|
|
447
463
|
secureTextEntry={element.props?.type === 'password'}
|
package/src/types.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface ScreenConfig {
|
|
|
12
12
|
elements?: ElementNode[];
|
|
13
13
|
// For custom_screen type — name of the developer-registered component
|
|
14
14
|
custom_component_name?: string;
|
|
15
|
+
// Dashboard visibility control — if true, screen is hidden from onboarding flow
|
|
16
|
+
hidden?: boolean;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
// ─── Element Tree Types (matches dashboard primitives) ───
|
|
@@ -224,6 +226,7 @@ export interface CustomScreenProps {
|
|
|
224
226
|
track: (event: string, properties?: Record<string, any>) => void;
|
|
225
227
|
};
|
|
226
228
|
onNext: () => void;
|
|
229
|
+
onBack?: () => void;
|
|
227
230
|
onSkip?: () => void;
|
|
228
231
|
preview?: boolean;
|
|
229
232
|
data?: Record<string, any>;
|
|
@@ -232,7 +235,13 @@ export interface CustomScreenProps {
|
|
|
232
235
|
|
|
233
236
|
// Main SDK props
|
|
234
237
|
export interface OnboardingFlowProps {
|
|
235
|
-
|
|
238
|
+
// Option 1: Auto-detection with dual keys (recommended)
|
|
239
|
+
testKey?: string; // nb_test_... key for development/testing
|
|
240
|
+
productionKey?: string; // nb_live_... key for production
|
|
241
|
+
|
|
242
|
+
// Option 2: Legacy single key (backwards compatible)
|
|
243
|
+
apiKey?: string;
|
|
244
|
+
|
|
236
245
|
onComplete: (data?: Record<string, any>) => void;
|
|
237
246
|
onSkip?: () => void;
|
|
238
247
|
baseUrl?: string;
|