noboarding 0.1.0-alpha → 0.1.0-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 +872 -0
- package/README.md +19 -2
- package/lib/OnboardingFlow.js +34 -4
- package/lib/components/ElementRenderer.js +9 -2
- package/lib/types.d.ts +4 -1
- package/package.json +1 -1
- package/src/OnboardingFlow.tsx +37 -2
- package/src/components/ElementRenderer.tsx +9 -1
- package/src/types.ts +8 -1
|
@@ -0,0 +1,872 @@
|
|
|
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
|
+
apiKey="sk_live_..."
|
|
241
|
+
customComponents={{
|
|
242
|
+
NameScreen,
|
|
243
|
+
AgeScreen,
|
|
244
|
+
SummaryScreen,
|
|
245
|
+
}}
|
|
246
|
+
onComplete={(userData) => {
|
|
247
|
+
// userData contains ALL collected data:
|
|
248
|
+
console.log(userData);
|
|
249
|
+
// {
|
|
250
|
+
// userName: "John",
|
|
251
|
+
// nameCollectedAt: "2025-02-20...",
|
|
252
|
+
// userAge: 25,
|
|
253
|
+
// ageCollectedAt: "2025-02-20...",
|
|
254
|
+
// _variables: { ... }
|
|
255
|
+
// }
|
|
256
|
+
}}
|
|
257
|
+
/>
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Part 3: Common Patterns
|
|
263
|
+
|
|
264
|
+
### Pattern 1: Form with Validation
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
export const FormScreen: React.FC<CustomScreenProps> = ({
|
|
268
|
+
analytics,
|
|
269
|
+
onNext,
|
|
270
|
+
onDataUpdate,
|
|
271
|
+
}) => {
|
|
272
|
+
const [formData, setFormData] = useState({
|
|
273
|
+
email: '',
|
|
274
|
+
phone: '',
|
|
275
|
+
});
|
|
276
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
277
|
+
|
|
278
|
+
const validate = () => {
|
|
279
|
+
const newErrors: Record<string, string> = {};
|
|
280
|
+
|
|
281
|
+
if (!formData.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
|
|
282
|
+
newErrors.email = 'Invalid email';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!formData.phone.match(/^\d{10}$/)) {
|
|
286
|
+
newErrors.phone = 'Phone must be 10 digits';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
setErrors(newErrors);
|
|
290
|
+
return Object.keys(newErrors).length === 0;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const handleSubmit = () => {
|
|
294
|
+
if (validate()) {
|
|
295
|
+
onDataUpdate?.(formData);
|
|
296
|
+
analytics.track('form_submitted', { valid: true });
|
|
297
|
+
onNext();
|
|
298
|
+
} else {
|
|
299
|
+
analytics.track('form_validation_failed', { errors: Object.keys(errors) });
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<View>
|
|
305
|
+
<TextInput
|
|
306
|
+
placeholder="Email"
|
|
307
|
+
value={formData.email}
|
|
308
|
+
onChangeText={(email) => setFormData({ ...formData, email })}
|
|
309
|
+
/>
|
|
310
|
+
{errors.email && <Text style={styles.error}>{errors.email}</Text>}
|
|
311
|
+
|
|
312
|
+
<TextInput
|
|
313
|
+
placeholder="Phone"
|
|
314
|
+
value={formData.phone}
|
|
315
|
+
onChangeText={(phone) => setFormData({ ...formData, phone })}
|
|
316
|
+
/>
|
|
317
|
+
{errors.phone && <Text style={styles.error}>{errors.phone}</Text>}
|
|
318
|
+
|
|
319
|
+
<Button title="Continue" onPress={handleSubmit} />
|
|
320
|
+
</View>
|
|
321
|
+
);
|
|
322
|
+
};
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### Pattern 2: Multi-Select Options
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
export const PreferencesScreen: React.FC<CustomScreenProps> = ({
|
|
329
|
+
analytics,
|
|
330
|
+
onNext,
|
|
331
|
+
data,
|
|
332
|
+
onDataUpdate,
|
|
333
|
+
}) => {
|
|
334
|
+
const [selectedPreferences, setSelectedPreferences] = useState<string[]>([]);
|
|
335
|
+
|
|
336
|
+
const options = ['Option A', 'Option B', 'Option C', 'Option D'];
|
|
337
|
+
|
|
338
|
+
const togglePreference = (option: string) => {
|
|
339
|
+
if (selectedPreferences.includes(option)) {
|
|
340
|
+
setSelectedPreferences(selectedPreferences.filter(p => p !== option));
|
|
341
|
+
} else {
|
|
342
|
+
setSelectedPreferences([...selectedPreferences, option]);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const handleContinue = () => {
|
|
347
|
+
onDataUpdate?.({
|
|
348
|
+
preferences: selectedPreferences,
|
|
349
|
+
preferencesCount: selectedPreferences.length,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
analytics.track('preferences_selected', {
|
|
353
|
+
count: selectedPreferences.length,
|
|
354
|
+
selections: selectedPreferences,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
onNext();
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
return (
|
|
361
|
+
<View>
|
|
362
|
+
<Text>Select your preferences:</Text>
|
|
363
|
+
|
|
364
|
+
{options.map(option => (
|
|
365
|
+
<TouchableOpacity
|
|
366
|
+
key={option}
|
|
367
|
+
onPress={() => togglePreference(option)}
|
|
368
|
+
style={[
|
|
369
|
+
styles.option,
|
|
370
|
+
selectedPreferences.includes(option) && styles.optionSelected
|
|
371
|
+
]}
|
|
372
|
+
>
|
|
373
|
+
<Text>{option}</Text>
|
|
374
|
+
{selectedPreferences.includes(option) && <Text>✓</Text>}
|
|
375
|
+
</TouchableOpacity>
|
|
376
|
+
))}
|
|
377
|
+
|
|
378
|
+
<Button
|
|
379
|
+
title="Continue"
|
|
380
|
+
onPress={handleContinue}
|
|
381
|
+
disabled={selectedPreferences.length === 0}
|
|
382
|
+
/>
|
|
383
|
+
</View>
|
|
384
|
+
);
|
|
385
|
+
};
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### Pattern 3: API Call with Data from Previous Screen
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
export const ProfileSetupScreen: React.FC<CustomScreenProps> = ({
|
|
392
|
+
analytics,
|
|
393
|
+
onNext,
|
|
394
|
+
data, // Data from previous screens
|
|
395
|
+
onDataUpdate,
|
|
396
|
+
}) => {
|
|
397
|
+
const [loading, setLoading] = useState(false);
|
|
398
|
+
|
|
399
|
+
const createProfile = async () => {
|
|
400
|
+
setLoading(true);
|
|
401
|
+
analytics.track('profile_creation_started');
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
// Use data from previous screens
|
|
405
|
+
const response = await fetch('https://your-api.com/profile', {
|
|
406
|
+
method: 'POST',
|
|
407
|
+
headers: { 'Content-Type': 'application/json' },
|
|
408
|
+
body: JSON.stringify({
|
|
409
|
+
name: data?.userName,
|
|
410
|
+
age: data?.userAge,
|
|
411
|
+
email: data?.email,
|
|
412
|
+
preferences: data?.preferences,
|
|
413
|
+
}),
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const result = await response.json();
|
|
417
|
+
|
|
418
|
+
// Add API response to collected data
|
|
419
|
+
onDataUpdate?.({
|
|
420
|
+
profileId: result.id,
|
|
421
|
+
profileCreated: true,
|
|
422
|
+
profileCreatedAt: new Date().toISOString(),
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
analytics.track('profile_created', { profileId: result.id });
|
|
426
|
+
onNext();
|
|
427
|
+
} catch (error: any) {
|
|
428
|
+
analytics.track('profile_creation_failed', { error: error.message });
|
|
429
|
+
Alert.alert('Error', 'Failed to create profile. Please try again.');
|
|
430
|
+
} finally {
|
|
431
|
+
setLoading(false);
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
return (
|
|
436
|
+
<View>
|
|
437
|
+
<Text>Creating profile for {data?.userName}...</Text>
|
|
438
|
+
<Button
|
|
439
|
+
title="Create Profile"
|
|
440
|
+
onPress={createProfile}
|
|
441
|
+
disabled={loading}
|
|
442
|
+
/>
|
|
443
|
+
{loading && <ActivityIndicator />}
|
|
444
|
+
</View>
|
|
445
|
+
);
|
|
446
|
+
};
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### Pattern 4: Conditional Logic Based on Previous Data
|
|
450
|
+
|
|
451
|
+
```typescript
|
|
452
|
+
export const ConditionalScreen: React.FC<CustomScreenProps> = ({
|
|
453
|
+
analytics,
|
|
454
|
+
onNext,
|
|
455
|
+
data,
|
|
456
|
+
onDataUpdate,
|
|
457
|
+
}) => {
|
|
458
|
+
// Show different UI based on previous data
|
|
459
|
+
const isPremiumUser = data?.userAge && data.userAge > 25;
|
|
460
|
+
|
|
461
|
+
if (isPremiumUser) {
|
|
462
|
+
return (
|
|
463
|
+
<View>
|
|
464
|
+
<Text>Premium Experience</Text>
|
|
465
|
+
<Text>Welcome {data?.userName}! You qualify for premium features.</Text>
|
|
466
|
+
<Button
|
|
467
|
+
title="Continue"
|
|
468
|
+
onPress={() => {
|
|
469
|
+
onDataUpdate?.({ userTier: 'premium' });
|
|
470
|
+
onNext();
|
|
471
|
+
}}
|
|
472
|
+
/>
|
|
473
|
+
</View>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return (
|
|
478
|
+
<View>
|
|
479
|
+
<Text>Standard Experience</Text>
|
|
480
|
+
<Text>Welcome {data?.userName}!</Text>
|
|
481
|
+
<Button
|
|
482
|
+
title="Continue"
|
|
483
|
+
onPress={() => {
|
|
484
|
+
onDataUpdate?.({ userTier: 'standard' });
|
|
485
|
+
onNext();
|
|
486
|
+
}}
|
|
487
|
+
/>
|
|
488
|
+
</View>
|
|
489
|
+
);
|
|
490
|
+
};
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
## Part 4: Analytics Best Practices
|
|
496
|
+
|
|
497
|
+
### Track All Key Events
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
// Screen lifecycle
|
|
501
|
+
useEffect(() => {
|
|
502
|
+
analytics.track('screen_viewed', {
|
|
503
|
+
screen_id: 'my_screen',
|
|
504
|
+
screen_type: 'custom',
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
return () => {
|
|
508
|
+
analytics.track('screen_exited', {
|
|
509
|
+
screen_id: 'my_screen',
|
|
510
|
+
});
|
|
511
|
+
};
|
|
512
|
+
}, []);
|
|
513
|
+
|
|
514
|
+
// User interactions
|
|
515
|
+
analytics.track('button_clicked', { button: 'submit' });
|
|
516
|
+
analytics.track('input_focused', { field: 'email' });
|
|
517
|
+
analytics.track('option_selected', { option: 'premium' });
|
|
518
|
+
|
|
519
|
+
// Completion
|
|
520
|
+
analytics.track('screen_completed', {
|
|
521
|
+
screen_id: 'my_screen',
|
|
522
|
+
data_collected: true,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Errors
|
|
526
|
+
analytics.track('error_occurred', {
|
|
527
|
+
error_type: 'validation',
|
|
528
|
+
error_message: 'Invalid email',
|
|
529
|
+
});
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
534
|
+
## Part 5: Setting Up Custom Screens in Dashboard
|
|
535
|
+
|
|
536
|
+
### Step 1: Register Component in App
|
|
537
|
+
|
|
538
|
+
```typescript
|
|
539
|
+
// App.tsx
|
|
540
|
+
import { OnboardingFlow } from 'noboarding';
|
|
541
|
+
import { NameScreen } from './screens/NameScreen';
|
|
542
|
+
import { AgeScreen } from './screens/AgeScreen';
|
|
543
|
+
import { PreferencesScreen } from './screens/PreferencesScreen';
|
|
544
|
+
|
|
545
|
+
<OnboardingFlow
|
|
546
|
+
apiKey="sk_live_your_api_key"
|
|
547
|
+
customComponents={{
|
|
548
|
+
NameScreen: NameScreen, // Component name MUST match exactly
|
|
549
|
+
AgeScreen: AgeScreen, // Case-sensitive!
|
|
550
|
+
PreferencesScreen: PreferencesScreen,
|
|
551
|
+
}}
|
|
552
|
+
onComplete={(userData) => {
|
|
553
|
+
console.log('All collected data:', userData);
|
|
554
|
+
}}
|
|
555
|
+
/>
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
### Step 2: Add to Dashboard Flow
|
|
559
|
+
|
|
560
|
+
1. **Log in to Noboarding Dashboard**
|
|
561
|
+
2. **Go to Flows** and select or create a flow
|
|
562
|
+
3. **Click "Add Custom Screen"**
|
|
563
|
+
4. **Enter Component Name:** (must match EXACTLY what you registered)
|
|
564
|
+
- Example: `NameScreen`
|
|
565
|
+
- ❌ Wrong: `nameScreen`, `name_screen`, `NameScreenComponent`
|
|
566
|
+
- ✅ Correct: `NameScreen`
|
|
567
|
+
5. **Add Description:** "Collects user's name"
|
|
568
|
+
6. **Position in Flow:** Drag to desired position
|
|
569
|
+
7. **Save Draft**
|
|
570
|
+
|
|
571
|
+
### Step 3: Flow Order Example
|
|
572
|
+
|
|
573
|
+
```
|
|
574
|
+
Dashboard Flow Builder:
|
|
575
|
+
┌─────────────────────────────────────┐
|
|
576
|
+
│ 1. Welcome Screen (SDK) │
|
|
577
|
+
│ 2. NameScreen (Custom) │ ← Your custom screen
|
|
578
|
+
│ 3. AgeScreen (Custom) │ ← Your custom screen
|
|
579
|
+
│ 4. Feature Tour (SDK) │
|
|
580
|
+
│ 5. PreferencesScreen (Custom) │ ← Your custom screen
|
|
581
|
+
│ 6. Complete (SDK) │
|
|
582
|
+
└─────────────────────────────────────┘
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
### Step 4: Test Locally
|
|
586
|
+
|
|
587
|
+
```bash
|
|
588
|
+
npm start
|
|
589
|
+
# Test in development mode
|
|
590
|
+
# Navigate through onboarding
|
|
591
|
+
# Check console for collected data
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
### Step 5: Deploy & Publish
|
|
595
|
+
|
|
596
|
+
1. Build production app
|
|
597
|
+
2. Submit to App Store / Google Play
|
|
598
|
+
3. **WAIT for approval**
|
|
599
|
+
4. **After app is live:** Publish flow in dashboard
|
|
600
|
+
|
|
601
|
+
---
|
|
602
|
+
|
|
603
|
+
## Part 6: Complete Example - Multi-Step Form
|
|
604
|
+
|
|
605
|
+
Here's a complete example showing data flow across 3 custom screens:
|
|
606
|
+
|
|
607
|
+
```typescript
|
|
608
|
+
// screens/Step1EmailScreen.tsx
|
|
609
|
+
export const Step1EmailScreen: React.FC<CustomScreenProps> = ({
|
|
610
|
+
analytics,
|
|
611
|
+
onNext,
|
|
612
|
+
preview,
|
|
613
|
+
onDataUpdate,
|
|
614
|
+
}) => {
|
|
615
|
+
const [email, setEmail] = useState('');
|
|
616
|
+
|
|
617
|
+
useEffect(() => {
|
|
618
|
+
analytics.track('screen_viewed', { screen_id: 'step1_email' });
|
|
619
|
+
}, []);
|
|
620
|
+
|
|
621
|
+
const handleContinue = () => {
|
|
622
|
+
onDataUpdate?.({
|
|
623
|
+
email: email,
|
|
624
|
+
emailCollectedAt: new Date().toISOString(),
|
|
625
|
+
});
|
|
626
|
+
analytics.track('email_collected');
|
|
627
|
+
onNext();
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
if (preview) {
|
|
631
|
+
return (
|
|
632
|
+
<View style={styles.preview}>
|
|
633
|
+
<Text>📧 Email Collection Screen</Text>
|
|
634
|
+
<Button title="Continue" onPress={onNext} />
|
|
635
|
+
</View>
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return (
|
|
640
|
+
<View style={styles.container}>
|
|
641
|
+
<Text style={styles.title}>What's your email?</Text>
|
|
642
|
+
<TextInput
|
|
643
|
+
style={styles.input}
|
|
644
|
+
placeholder="email@example.com"
|
|
645
|
+
value={email}
|
|
646
|
+
onChangeText={setEmail}
|
|
647
|
+
keyboardType="email-address"
|
|
648
|
+
autoCapitalize="none"
|
|
649
|
+
/>
|
|
650
|
+
<Button
|
|
651
|
+
title="Continue"
|
|
652
|
+
onPress={handleContinue}
|
|
653
|
+
disabled={!email}
|
|
654
|
+
/>
|
|
655
|
+
</View>
|
|
656
|
+
);
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
// screens/Step2GoalsScreen.tsx
|
|
660
|
+
export const Step2GoalsScreen: React.FC<CustomScreenProps> = ({
|
|
661
|
+
analytics,
|
|
662
|
+
onNext,
|
|
663
|
+
data, // Contains: { email, emailCollectedAt }
|
|
664
|
+
preview,
|
|
665
|
+
onDataUpdate,
|
|
666
|
+
}) => {
|
|
667
|
+
const [selectedGoals, setSelectedGoals] = useState<string[]>([]);
|
|
668
|
+
|
|
669
|
+
useEffect(() => {
|
|
670
|
+
analytics.track('screen_viewed', {
|
|
671
|
+
screen_id: 'step2_goals',
|
|
672
|
+
user_email: data?.email
|
|
673
|
+
});
|
|
674
|
+
}, []);
|
|
675
|
+
|
|
676
|
+
const goals = ['Fitness', 'Nutrition', 'Sleep', 'Mindfulness'];
|
|
677
|
+
|
|
678
|
+
const toggleGoal = (goal: string) => {
|
|
679
|
+
if (selectedGoals.includes(goal)) {
|
|
680
|
+
setSelectedGoals(selectedGoals.filter(g => g !== goal));
|
|
681
|
+
} else {
|
|
682
|
+
setSelectedGoals([...selectedGoals, goal]);
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
const handleContinue = () => {
|
|
687
|
+
onDataUpdate?.({
|
|
688
|
+
goals: selectedGoals,
|
|
689
|
+
goalsCount: selectedGoals.length,
|
|
690
|
+
});
|
|
691
|
+
analytics.track('goals_selected', { count: selectedGoals.length });
|
|
692
|
+
onNext();
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
if (preview) {
|
|
696
|
+
return (
|
|
697
|
+
<View style={styles.preview}>
|
|
698
|
+
<Text>🎯 Goals Selection Screen</Text>
|
|
699
|
+
<Button title="Continue" onPress={onNext} />
|
|
700
|
+
</View>
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return (
|
|
705
|
+
<View style={styles.container}>
|
|
706
|
+
<Text style={styles.title}>
|
|
707
|
+
Hi {data?.email?.split('@')[0]}! What are your goals?
|
|
708
|
+
</Text>
|
|
709
|
+
|
|
710
|
+
{goals.map(goal => (
|
|
711
|
+
<TouchableOpacity
|
|
712
|
+
key={goal}
|
|
713
|
+
onPress={() => toggleGoal(goal)}
|
|
714
|
+
style={[
|
|
715
|
+
styles.goalOption,
|
|
716
|
+
selectedGoals.includes(goal) && styles.goalSelected
|
|
717
|
+
]}
|
|
718
|
+
>
|
|
719
|
+
<Text>{goal}</Text>
|
|
720
|
+
{selectedGoals.includes(goal) && <Text>✓</Text>}
|
|
721
|
+
</TouchableOpacity>
|
|
722
|
+
))}
|
|
723
|
+
|
|
724
|
+
<Button
|
|
725
|
+
title="Continue"
|
|
726
|
+
onPress={handleContinue}
|
|
727
|
+
disabled={selectedGoals.length === 0}
|
|
728
|
+
/>
|
|
729
|
+
</View>
|
|
730
|
+
);
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
// screens/Step3SummaryScreen.tsx
|
|
734
|
+
export const Step3SummaryScreen: React.FC<CustomScreenProps> = ({
|
|
735
|
+
analytics,
|
|
736
|
+
onNext,
|
|
737
|
+
data, // Contains: { email, emailCollectedAt, goals, goalsCount }
|
|
738
|
+
preview,
|
|
739
|
+
}) => {
|
|
740
|
+
useEffect(() => {
|
|
741
|
+
analytics.track('screen_viewed', { screen_id: 'step3_summary' });
|
|
742
|
+
}, []);
|
|
743
|
+
|
|
744
|
+
if (preview) {
|
|
745
|
+
return (
|
|
746
|
+
<View style={styles.preview}>
|
|
747
|
+
<Text>📋 Summary Screen</Text>
|
|
748
|
+
<Button title="Continue" onPress={onNext} />
|
|
749
|
+
</View>
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return (
|
|
754
|
+
<View style={styles.container}>
|
|
755
|
+
<Text style={styles.title}>Summary</Text>
|
|
756
|
+
|
|
757
|
+
<View style={styles.summaryCard}>
|
|
758
|
+
<Text style={styles.label}>Email:</Text>
|
|
759
|
+
<Text style={styles.value}>{data?.email}</Text>
|
|
760
|
+
</View>
|
|
761
|
+
|
|
762
|
+
<View style={styles.summaryCard}>
|
|
763
|
+
<Text style={styles.label}>Goals ({data?.goalsCount}):</Text>
|
|
764
|
+
{data?.goals?.map((goal: string) => (
|
|
765
|
+
<Text key={goal} style={styles.value}>• {goal}</Text>
|
|
766
|
+
))}
|
|
767
|
+
</View>
|
|
768
|
+
|
|
769
|
+
<Button title="Complete Setup" onPress={onNext} />
|
|
770
|
+
</View>
|
|
771
|
+
);
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const styles = StyleSheet.create({
|
|
775
|
+
container: {
|
|
776
|
+
flex: 1,
|
|
777
|
+
padding: 20,
|
|
778
|
+
backgroundColor: '#fff',
|
|
779
|
+
},
|
|
780
|
+
preview: {
|
|
781
|
+
flex: 1,
|
|
782
|
+
justifyContent: 'center',
|
|
783
|
+
alignItems: 'center',
|
|
784
|
+
padding: 20,
|
|
785
|
+
},
|
|
786
|
+
title: {
|
|
787
|
+
fontSize: 24,
|
|
788
|
+
fontWeight: 'bold',
|
|
789
|
+
marginBottom: 20,
|
|
790
|
+
},
|
|
791
|
+
input: {
|
|
792
|
+
borderWidth: 1,
|
|
793
|
+
borderColor: '#ccc',
|
|
794
|
+
borderRadius: 8,
|
|
795
|
+
padding: 12,
|
|
796
|
+
fontSize: 16,
|
|
797
|
+
marginBottom: 20,
|
|
798
|
+
},
|
|
799
|
+
goalOption: {
|
|
800
|
+
flexDirection: 'row',
|
|
801
|
+
justifyContent: 'space-between',
|
|
802
|
+
padding: 16,
|
|
803
|
+
borderWidth: 1,
|
|
804
|
+
borderColor: '#ccc',
|
|
805
|
+
borderRadius: 8,
|
|
806
|
+
marginBottom: 12,
|
|
807
|
+
},
|
|
808
|
+
goalSelected: {
|
|
809
|
+
borderColor: '#007AFF',
|
|
810
|
+
backgroundColor: '#E3F2FD',
|
|
811
|
+
},
|
|
812
|
+
summaryCard: {
|
|
813
|
+
padding: 16,
|
|
814
|
+
backgroundColor: '#f5f5f5',
|
|
815
|
+
borderRadius: 8,
|
|
816
|
+
marginBottom: 16,
|
|
817
|
+
},
|
|
818
|
+
label: {
|
|
819
|
+
fontSize: 14,
|
|
820
|
+
color: '#666',
|
|
821
|
+
marginBottom: 4,
|
|
822
|
+
},
|
|
823
|
+
value: {
|
|
824
|
+
fontSize: 16,
|
|
825
|
+
color: '#000',
|
|
826
|
+
},
|
|
827
|
+
});
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
**Register in App.tsx:**
|
|
831
|
+
|
|
832
|
+
```typescript
|
|
833
|
+
<OnboardingFlow
|
|
834
|
+
apiKey="sk_live_..."
|
|
835
|
+
customComponents={{
|
|
836
|
+
Step1EmailScreen,
|
|
837
|
+
Step2GoalsScreen,
|
|
838
|
+
Step3SummaryScreen,
|
|
839
|
+
}}
|
|
840
|
+
onComplete={(userData) => {
|
|
841
|
+
console.log(userData);
|
|
842
|
+
// {
|
|
843
|
+
// email: "john@example.com",
|
|
844
|
+
// emailCollectedAt: "2025-02-20...",
|
|
845
|
+
// goals: ["Fitness", "Nutrition"],
|
|
846
|
+
// goalsCount: 2,
|
|
847
|
+
// _variables: { ... }
|
|
848
|
+
// }
|
|
849
|
+
}}
|
|
850
|
+
/>
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
---
|
|
854
|
+
|
|
855
|
+
## Summary Checklist
|
|
856
|
+
|
|
857
|
+
When building a custom screen, ensure you:
|
|
858
|
+
|
|
859
|
+
- [ ] Import `CustomScreenProps` from 'noboarding'
|
|
860
|
+
- [ ] Use all required props: `analytics`, `onNext`, `data`, `onDataUpdate`
|
|
861
|
+
- [ ] Track `screen_viewed` on mount
|
|
862
|
+
- [ ] Implement `preview` mode for dashboard
|
|
863
|
+
- [ ] Call `onDataUpdate()` to add your data before `onNext()`
|
|
864
|
+
- [ ] Track `screen_completed` before navigating
|
|
865
|
+
- [ ] Access previous data via `data` prop
|
|
866
|
+
- [ ] Register component in `customComponents` with EXACT name
|
|
867
|
+
- [ ] Add to dashboard with matching component name
|
|
868
|
+
- [ ] Test data flow across multiple screens
|
|
869
|
+
|
|
870
|
+
---
|
|
871
|
+
|
|
872
|
+
**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
|
|
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);
|
|
@@ -109,6 +133,12 @@ const OnboardingFlow = ({ apiKey, onComplete, onSkip, baseUrl, initialVariables,
|
|
|
109
133
|
setCurrentIndex((prev) => prev + 1);
|
|
110
134
|
}
|
|
111
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
|
+
};
|
|
112
142
|
const handleSkipScreen = () => {
|
|
113
143
|
// Move to next screen or complete
|
|
114
144
|
if (currentIndex >= screens.length - 1) {
|
|
@@ -201,7 +231,7 @@ const OnboardingFlow = ({ apiKey, onComplete, onSkip, baseUrl, initialVariables,
|
|
|
201
231
|
</react_native_1.View>);
|
|
202
232
|
}
|
|
203
233
|
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)))}/>
|
|
234
|
+
<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
235
|
</react_native_1.View>);
|
|
206
236
|
}
|
|
207
237
|
// 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);
|
|
@@ -340,7 +340,14 @@ const RenderNode = ({ element, toggledIds, groupSelections, onAction, variables
|
|
|
340
340
|
</react_native_1.Text>)}
|
|
341
341
|
</react_native_1.View>);
|
|
342
342
|
case 'input':
|
|
343
|
-
|
|
343
|
+
// Only apply default border if borderWidth is not explicitly defined (including 0)
|
|
344
|
+
const inputStyle = style;
|
|
345
|
+
const defaultInputStyle = {};
|
|
346
|
+
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) {
|
|
347
|
+
defaultInputStyle.borderWidth = 1;
|
|
348
|
+
defaultInputStyle.borderColor = '#E5E5E5';
|
|
349
|
+
}
|
|
350
|
+
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
351
|
case 'spacer':
|
|
345
352
|
return <react_native_1.View style={style || { flex: 1 }}/>;
|
|
346
353
|
case 'divider':
|
package/lib/types.d.ts
CHANGED
|
@@ -169,13 +169,16 @@ export interface CustomScreenProps {
|
|
|
169
169
|
track: (event: string, properties?: Record<string, any>) => void;
|
|
170
170
|
};
|
|
171
171
|
onNext: () => void;
|
|
172
|
+
onBack?: () => void;
|
|
172
173
|
onSkip?: () => void;
|
|
173
174
|
preview?: boolean;
|
|
174
175
|
data?: Record<string, any>;
|
|
175
176
|
onDataUpdate?: (data: Record<string, any>) => void;
|
|
176
177
|
}
|
|
177
178
|
export interface OnboardingFlowProps {
|
|
178
|
-
|
|
179
|
+
testKey?: string;
|
|
180
|
+
productionKey?: string;
|
|
181
|
+
apiKey?: string;
|
|
179
182
|
onComplete: (data?: Record<string, any>) => void;
|
|
180
183
|
onSkip?: () => void;
|
|
181
184
|
baseUrl?: string;
|
package/package.json
CHANGED
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
|
|
@@ -103,6 +130,13 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
103
130
|
}
|
|
104
131
|
};
|
|
105
132
|
|
|
133
|
+
const handleBack = () => {
|
|
134
|
+
// Navigate to previous screen (only if not on first screen)
|
|
135
|
+
if (currentIndex > 0) {
|
|
136
|
+
setCurrentIndex((prev) => prev - 1);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
106
140
|
const handleSkipScreen = () => {
|
|
107
141
|
// Move to next screen or complete
|
|
108
142
|
if (currentIndex >= screens.length - 1) {
|
|
@@ -235,6 +269,7 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
235
269
|
<CustomComponent
|
|
236
270
|
analytics={analyticsRef.current!}
|
|
237
271
|
onNext={() => handleNext()}
|
|
272
|
+
onBack={currentIndex > 0 ? handleBack : undefined}
|
|
238
273
|
onSkip={onSkip ? handleSkipAll : undefined}
|
|
239
274
|
data={collectedData}
|
|
240
275
|
onDataUpdate={(newData) => setCollectedData(prev => ({ ...prev, ...newData }))}
|
|
@@ -439,9 +439,17 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
439
439
|
);
|
|
440
440
|
|
|
441
441
|
case 'input':
|
|
442
|
+
// Only apply default border if borderWidth is not explicitly defined (including 0)
|
|
443
|
+
const inputStyle = style as TextStyle;
|
|
444
|
+
const defaultInputStyle: TextStyle = {};
|
|
445
|
+
if (element.style?.borderWidth === undefined && element.style?.borderColor === undefined) {
|
|
446
|
+
defaultInputStyle.borderWidth = 1;
|
|
447
|
+
defaultInputStyle.borderColor = '#E5E5E5';
|
|
448
|
+
}
|
|
449
|
+
|
|
442
450
|
return (
|
|
443
451
|
<TextInput
|
|
444
|
-
style={[
|
|
452
|
+
style={[defaultInputStyle, inputStyle]}
|
|
445
453
|
placeholder={element.props?.placeholder || 'Enter text...'}
|
|
446
454
|
keyboardType={getKeyboardType(element.props?.type)}
|
|
447
455
|
secureTextEntry={element.props?.type === 'password'}
|
package/src/types.ts
CHANGED
|
@@ -224,6 +224,7 @@ export interface CustomScreenProps {
|
|
|
224
224
|
track: (event: string, properties?: Record<string, any>) => void;
|
|
225
225
|
};
|
|
226
226
|
onNext: () => void;
|
|
227
|
+
onBack?: () => void;
|
|
227
228
|
onSkip?: () => void;
|
|
228
229
|
preview?: boolean;
|
|
229
230
|
data?: Record<string, any>;
|
|
@@ -232,7 +233,13 @@ export interface CustomScreenProps {
|
|
|
232
233
|
|
|
233
234
|
// Main SDK props
|
|
234
235
|
export interface OnboardingFlowProps {
|
|
235
|
-
|
|
236
|
+
// Option 1: Auto-detection with dual keys (recommended)
|
|
237
|
+
testKey?: string; // nb_test_... key for development/testing
|
|
238
|
+
productionKey?: string; // nb_live_... key for production
|
|
239
|
+
|
|
240
|
+
// Option 2: Legacy single key (backwards compatible)
|
|
241
|
+
apiKey?: string;
|
|
242
|
+
|
|
236
243
|
onComplete: (data?: Record<string, any>) => void;
|
|
237
244
|
onSkip?: () => void;
|
|
238
245
|
baseUrl?: string;
|