noboarding 0.1.0-alpha
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/README.md +515 -0
- package/REVENUECAT_SETUP.md +756 -0
- package/SETUP_GUIDE.md +873 -0
- package/cusomte_screens.md +1964 -0
- package/lib/OnboardingFlow.d.ts +3 -0
- package/lib/OnboardingFlow.js +235 -0
- package/lib/analytics.d.ts +25 -0
- package/lib/analytics.js +72 -0
- package/lib/api.d.ts +31 -0
- package/lib/api.js +149 -0
- package/lib/components/ElementRenderer.d.ts +13 -0
- package/lib/components/ElementRenderer.js +521 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +18 -0
- package/lib/types.d.ts +185 -0
- package/lib/types.js +2 -0
- package/lib/variableUtils.d.ts +17 -0
- package/lib/variableUtils.js +118 -0
- package/logic.md +2095 -0
- package/package.json +44 -0
- package/src/OnboardingFlow.tsx +276 -0
- package/src/analytics.ts +84 -0
- package/src/api.ts +173 -0
- package/src/components/ElementRenderer.tsx +627 -0
- package/src/index.ts +32 -0
- package/src/types.ts +242 -0
- package/src/variableUtils.ts +133 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
Image,
|
|
6
|
+
ScrollView,
|
|
7
|
+
TextInput,
|
|
8
|
+
TouchableOpacity,
|
|
9
|
+
Linking,
|
|
10
|
+
StyleSheet,
|
|
11
|
+
ViewStyle,
|
|
12
|
+
TextStyle,
|
|
13
|
+
ImageStyle,
|
|
14
|
+
} from 'react-native';
|
|
15
|
+
import { ElementNode, ElementStyle, ElementAction, Analytics, ConditionalDestination, ConditionalRoutes } from '../types';
|
|
16
|
+
import { resolveTemplate, evaluateCondition } from '../variableUtils';
|
|
17
|
+
|
|
18
|
+
// Try to import LinearGradient — optional peer dependency
|
|
19
|
+
let LinearGradient: any = null;
|
|
20
|
+
try {
|
|
21
|
+
LinearGradient = require('expo-linear-gradient').LinearGradient;
|
|
22
|
+
} catch {
|
|
23
|
+
try {
|
|
24
|
+
LinearGradient = require('react-native-linear-gradient').default;
|
|
25
|
+
} catch {
|
|
26
|
+
// Neither available — gradients will fall back to first color
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Try to import vector icons — optional peer dependency
|
|
31
|
+
let IconSets: Record<string, any> = {};
|
|
32
|
+
try {
|
|
33
|
+
const icons = require('@expo/vector-icons');
|
|
34
|
+
IconSets = {
|
|
35
|
+
lucide: icons.Feather, // Closest match to Lucide
|
|
36
|
+
feather: icons.Feather,
|
|
37
|
+
material: icons.MaterialIcons,
|
|
38
|
+
'material-community': icons.MaterialCommunityIcons,
|
|
39
|
+
ionicons: icons.Ionicons,
|
|
40
|
+
fontawesome: icons.FontAwesome,
|
|
41
|
+
'sf-symbols': icons.Ionicons, // Closest match to SF Symbols
|
|
42
|
+
};
|
|
43
|
+
} catch {
|
|
44
|
+
// Not available — icons will fall back to text placeholder
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ElementRendererProps {
|
|
48
|
+
elements: ElementNode[];
|
|
49
|
+
analytics?: Analytics;
|
|
50
|
+
screenId?: string;
|
|
51
|
+
onNavigate?: (destination: string | ConditionalDestination | ConditionalRoutes) => void;
|
|
52
|
+
onDismiss?: () => void;
|
|
53
|
+
variables?: Record<string, any>;
|
|
54
|
+
onSetVariable?: (name: string, value: any) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const ElementRenderer: React.FC<ElementRendererProps> = ({
|
|
58
|
+
elements,
|
|
59
|
+
analytics,
|
|
60
|
+
screenId,
|
|
61
|
+
onNavigate,
|
|
62
|
+
onDismiss,
|
|
63
|
+
variables = {},
|
|
64
|
+
onSetVariable,
|
|
65
|
+
}) => {
|
|
66
|
+
// Track toggled element IDs for toggle actions
|
|
67
|
+
const [toggledIds, setToggledIds] = useState<Set<string>>(new Set());
|
|
68
|
+
// Track selection groups: group name → selected element ID
|
|
69
|
+
const [groupSelections, setGroupSelections] = useState<Record<string, string>>({});
|
|
70
|
+
|
|
71
|
+
const executeAction = useCallback(
|
|
72
|
+
(action: ElementAction, element: ElementNode) => {
|
|
73
|
+
// Track the action
|
|
74
|
+
analytics?.track('element_action', {
|
|
75
|
+
screen_id: screenId,
|
|
76
|
+
element_id: element.id,
|
|
77
|
+
action_type: action.type,
|
|
78
|
+
destination: typeof action.destination === 'string' ? action.destination : 'conditional',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
switch (action.type) {
|
|
82
|
+
case 'set_variable':
|
|
83
|
+
if (action.variable !== undefined && onSetVariable) {
|
|
84
|
+
onSetVariable(action.variable, action.value);
|
|
85
|
+
}
|
|
86
|
+
break;
|
|
87
|
+
case 'toggle': {
|
|
88
|
+
const group = action.group;
|
|
89
|
+
if (group) {
|
|
90
|
+
// Single-select group: deselect previous, select new
|
|
91
|
+
setGroupSelections((prev) => {
|
|
92
|
+
const prevSelected = prev[group];
|
|
93
|
+
if (prevSelected === element.id) return prev; // already selected
|
|
94
|
+
return { ...prev, [group]: element.id };
|
|
95
|
+
});
|
|
96
|
+
setToggledIds((prev) => {
|
|
97
|
+
const next = new Set(prev);
|
|
98
|
+
const prevSelected = groupSelections[group];
|
|
99
|
+
if (prevSelected) next.delete(prevSelected);
|
|
100
|
+
next.add(element.id);
|
|
101
|
+
return next;
|
|
102
|
+
});
|
|
103
|
+
} else {
|
|
104
|
+
// Ungrouped toggle: multi-select
|
|
105
|
+
setToggledIds((prev) => {
|
|
106
|
+
const next = new Set(prev);
|
|
107
|
+
if (next.has(element.id)) {
|
|
108
|
+
next.delete(element.id);
|
|
109
|
+
} else {
|
|
110
|
+
next.add(element.id);
|
|
111
|
+
}
|
|
112
|
+
return next;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
case 'navigate':
|
|
118
|
+
if (onNavigate && action.destination) {
|
|
119
|
+
onNavigate(action.destination);
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
case 'link':
|
|
123
|
+
if (action.destination && typeof action.destination === 'string') {
|
|
124
|
+
Linking.openURL(action.destination).catch(() => {});
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
case 'dismiss':
|
|
128
|
+
onDismiss?.();
|
|
129
|
+
break;
|
|
130
|
+
case 'tap':
|
|
131
|
+
// Generic tap — analytics already tracked above
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
[groupSelections, onNavigate, onDismiss, analytics, screenId, onSetVariable]
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const handleAction = useCallback(
|
|
139
|
+
(element: ElementNode) => {
|
|
140
|
+
// Execute single action (backward compatible)
|
|
141
|
+
if (element.action) {
|
|
142
|
+
executeAction(element.action, element);
|
|
143
|
+
}
|
|
144
|
+
// Execute all actions in the actions array
|
|
145
|
+
if (element.actions) {
|
|
146
|
+
for (const action of element.actions) {
|
|
147
|
+
executeAction(action, element);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
[executeAction]
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
if (!elements || elements.length === 0) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<>
|
|
160
|
+
{elements.map((element) => (
|
|
161
|
+
<RenderNode
|
|
162
|
+
key={element.id}
|
|
163
|
+
element={element}
|
|
164
|
+
toggledIds={toggledIds}
|
|
165
|
+
groupSelections={groupSelections}
|
|
166
|
+
onAction={handleAction}
|
|
167
|
+
variables={variables}
|
|
168
|
+
/>
|
|
169
|
+
))}
|
|
170
|
+
</>
|
|
171
|
+
);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// ─── Recursive Node Renderer ───
|
|
175
|
+
|
|
176
|
+
interface RenderNodeProps {
|
|
177
|
+
element: ElementNode;
|
|
178
|
+
toggledIds: Set<string>;
|
|
179
|
+
groupSelections: Record<string, string>;
|
|
180
|
+
onAction: (element: ElementNode) => void;
|
|
181
|
+
variables: Record<string, any>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelections, onAction, variables }) => {
|
|
185
|
+
// Variable-based conditions — hide element if condition is not met
|
|
186
|
+
if (element.conditions?.show_if) {
|
|
187
|
+
const shouldShow = evaluateCondition(element.conditions.show_if, variables);
|
|
188
|
+
if (!shouldShow) return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const style = convertStyle(element.style || {});
|
|
192
|
+
const isToggled = toggledIds.has(element.id);
|
|
193
|
+
|
|
194
|
+
// Apply toggle visual state
|
|
195
|
+
const hasToggleAction = element.action?.type === 'toggle' ||
|
|
196
|
+
element.actions?.some(a => a.type === 'toggle');
|
|
197
|
+
if (hasToggleAction) {
|
|
198
|
+
if (isToggled) {
|
|
199
|
+
style.borderWidth = 2;
|
|
200
|
+
style.borderColor = (element.style?.borderColor as string) || '#000000';
|
|
201
|
+
} else {
|
|
202
|
+
style.borderWidth = (element.style?.borderWidth as number) || 2;
|
|
203
|
+
style.borderColor = 'transparent';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Conditional visibility based on selection group state
|
|
208
|
+
if (element.visibleWhen) {
|
|
209
|
+
const groupHasSelection = !!groupSelections[element.visibleWhen.group];
|
|
210
|
+
const shouldShow = groupHasSelection === element.visibleWhen.hasSelection;
|
|
211
|
+
style.opacity = shouldShow ? 1 : 0;
|
|
212
|
+
(style as any).pointerEvents = shouldShow ? 'auto' : 'none';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Wrap in TouchableOpacity if element has an action or actions
|
|
216
|
+
const hasAction = !!element.action || (element.actions && element.actions.length > 0);
|
|
217
|
+
const wrapWithAction = (content: React.ReactElement): React.ReactElement => {
|
|
218
|
+
if (!hasAction) return content;
|
|
219
|
+
return (
|
|
220
|
+
<TouchableOpacity
|
|
221
|
+
key={element.id}
|
|
222
|
+
activeOpacity={0.7}
|
|
223
|
+
onPress={() => onAction(element)}
|
|
224
|
+
>
|
|
225
|
+
{content}
|
|
226
|
+
</TouchableOpacity>
|
|
227
|
+
);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const childProps = { toggledIds, groupSelections, onAction, variables };
|
|
231
|
+
|
|
232
|
+
switch (element.type) {
|
|
233
|
+
// ─── Containers ───
|
|
234
|
+
|
|
235
|
+
case 'vstack': {
|
|
236
|
+
const vstackContent = (
|
|
237
|
+
<View style={[style, { flexDirection: 'column' }]}>
|
|
238
|
+
{element.children?.map((child) => (
|
|
239
|
+
<RenderNode key={child.id} element={child} {...childProps} />
|
|
240
|
+
))}
|
|
241
|
+
</View>
|
|
242
|
+
);
|
|
243
|
+
return wrapWithAction(
|
|
244
|
+
element.style?.backgroundGradient
|
|
245
|
+
? wrapWithGradient(vstackContent, element.style, { ...style, flexDirection: 'column' })
|
|
246
|
+
: vstackContent
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
case 'hstack': {
|
|
251
|
+
const hstackContent = (
|
|
252
|
+
<View style={[style, { flexDirection: 'row' }]}>
|
|
253
|
+
{element.children?.map((child) => (
|
|
254
|
+
<RenderNode key={child.id} element={child} {...childProps} />
|
|
255
|
+
))}
|
|
256
|
+
</View>
|
|
257
|
+
);
|
|
258
|
+
return wrapWithAction(
|
|
259
|
+
element.style?.backgroundGradient
|
|
260
|
+
? wrapWithGradient(hstackContent, element.style, { ...style, flexDirection: 'row' })
|
|
261
|
+
: hstackContent
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
case 'zstack': {
|
|
266
|
+
const zstackContent = (
|
|
267
|
+
<View style={style}>
|
|
268
|
+
{element.children?.map((child, index) => {
|
|
269
|
+
const childStyle = convertStyle(child.style || {});
|
|
270
|
+
if (index > 0 && !child.position?.type) {
|
|
271
|
+
return (
|
|
272
|
+
<View
|
|
273
|
+
key={child.id}
|
|
274
|
+
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}
|
|
275
|
+
>
|
|
276
|
+
<RenderNode element={child} {...childProps} />
|
|
277
|
+
</View>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return <RenderNode key={child.id} element={child} {...childProps} />;
|
|
281
|
+
})}
|
|
282
|
+
</View>
|
|
283
|
+
);
|
|
284
|
+
return wrapWithAction(
|
|
285
|
+
element.style?.backgroundGradient
|
|
286
|
+
? wrapWithGradient(zstackContent, element.style, style)
|
|
287
|
+
: zstackContent
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
case 'scrollview': {
|
|
292
|
+
const isHorizontal = element.props?.direction === 'horizontal';
|
|
293
|
+
return (
|
|
294
|
+
<ScrollView
|
|
295
|
+
style={style}
|
|
296
|
+
horizontal={isHorizontal}
|
|
297
|
+
showsVerticalScrollIndicator={false}
|
|
298
|
+
showsHorizontalScrollIndicator={false}
|
|
299
|
+
>
|
|
300
|
+
{isHorizontal ? (
|
|
301
|
+
<View style={{ flexDirection: 'row', gap: element.style?.gap }}>
|
|
302
|
+
{element.children?.map((child) => (
|
|
303
|
+
<RenderNode key={child.id} element={child} {...childProps} />
|
|
304
|
+
))}
|
|
305
|
+
</View>
|
|
306
|
+
) : (
|
|
307
|
+
element.children?.map((child) => (
|
|
308
|
+
<RenderNode key={child.id} element={child} {...childProps} />
|
|
309
|
+
))
|
|
310
|
+
)}
|
|
311
|
+
</ScrollView>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ─── Content Elements ───
|
|
316
|
+
|
|
317
|
+
case 'text': {
|
|
318
|
+
const resolvedText = resolveTemplate(element.props?.text || '', variables);
|
|
319
|
+
return (
|
|
320
|
+
<Text style={style as TextStyle}>
|
|
321
|
+
{resolvedText}
|
|
322
|
+
</Text>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
case 'icon': {
|
|
327
|
+
if (element.props?.emoji) {
|
|
328
|
+
return (
|
|
329
|
+
<Text style={[style as TextStyle, { textAlign: 'center' }]}>
|
|
330
|
+
{element.props.emoji}
|
|
331
|
+
</Text>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
// Try to render a real vector icon
|
|
335
|
+
const library = (element.props?.library || 'material').toLowerCase();
|
|
336
|
+
const iconName = element.props?.name;
|
|
337
|
+
const IconComponent = IconSets[library];
|
|
338
|
+
if (IconComponent && iconName) {
|
|
339
|
+
const iconSize = (style as TextStyle).fontSize || 24;
|
|
340
|
+
const iconColor = (style as TextStyle).color || '#000000';
|
|
341
|
+
return (
|
|
342
|
+
<View style={[style, { alignItems: 'center', justifyContent: 'center' }]}>
|
|
343
|
+
<IconComponent name={iconName} size={iconSize} color={iconColor} />
|
|
344
|
+
</View>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
// Fallback — render icon name as text placeholder
|
|
348
|
+
return (
|
|
349
|
+
<View
|
|
350
|
+
style={[
|
|
351
|
+
style,
|
|
352
|
+
{
|
|
353
|
+
alignItems: 'center',
|
|
354
|
+
justifyContent: 'center',
|
|
355
|
+
backgroundColor: (style as ViewStyle).backgroundColor || '#f0f0f0',
|
|
356
|
+
borderRadius: ((style as ViewStyle).borderRadius as number) || 6,
|
|
357
|
+
},
|
|
358
|
+
]}
|
|
359
|
+
>
|
|
360
|
+
<Text style={{ fontSize: 10, color: '#666' }}>
|
|
361
|
+
{element.props?.name || '●'}
|
|
362
|
+
</Text>
|
|
363
|
+
</View>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
case 'image':
|
|
368
|
+
if (element.props?.url) {
|
|
369
|
+
return (
|
|
370
|
+
<Image
|
|
371
|
+
source={{ uri: element.props.url }}
|
|
372
|
+
style={[style as ImageStyle, { resizeMode: 'cover' }]}
|
|
373
|
+
/>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
// Placeholder for images without URL
|
|
377
|
+
return (
|
|
378
|
+
<View
|
|
379
|
+
style={[
|
|
380
|
+
style,
|
|
381
|
+
{
|
|
382
|
+
backgroundColor: (style as ViewStyle).backgroundColor || '#f0f0f0',
|
|
383
|
+
alignItems: 'center',
|
|
384
|
+
justifyContent: 'center',
|
|
385
|
+
},
|
|
386
|
+
]}
|
|
387
|
+
>
|
|
388
|
+
<Text style={{ fontSize: 48 }}>🖼️</Text>
|
|
389
|
+
{element.props?.imageDescription && (
|
|
390
|
+
<Text style={{ fontSize: 11, color: '#666', textAlign: 'center', padding: 8 }}>
|
|
391
|
+
{element.props.imageDescription}
|
|
392
|
+
</Text>
|
|
393
|
+
)}
|
|
394
|
+
</View>
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
case 'video':
|
|
398
|
+
// Video placeholder — actual implementation would use expo-av or react-native-video
|
|
399
|
+
return (
|
|
400
|
+
<View
|
|
401
|
+
style={[
|
|
402
|
+
style,
|
|
403
|
+
{
|
|
404
|
+
backgroundColor: (style as ViewStyle).backgroundColor || '#1a1a1a',
|
|
405
|
+
alignItems: 'center',
|
|
406
|
+
justifyContent: 'center',
|
|
407
|
+
},
|
|
408
|
+
]}
|
|
409
|
+
>
|
|
410
|
+
<Text style={{ fontSize: 48 }}>🎬</Text>
|
|
411
|
+
{element.props?.videoDescription && (
|
|
412
|
+
<Text style={{ fontSize: 11, color: '#aaa', textAlign: 'center', padding: 8 }}>
|
|
413
|
+
{element.props.videoDescription}
|
|
414
|
+
</Text>
|
|
415
|
+
)}
|
|
416
|
+
</View>
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
case 'lottie':
|
|
420
|
+
// Lottie placeholder — actual implementation would use lottie-react-native
|
|
421
|
+
return (
|
|
422
|
+
<View
|
|
423
|
+
style={[
|
|
424
|
+
style,
|
|
425
|
+
{
|
|
426
|
+
backgroundColor: (style as ViewStyle).backgroundColor || '#f8f8ff',
|
|
427
|
+
alignItems: 'center',
|
|
428
|
+
justifyContent: 'center',
|
|
429
|
+
},
|
|
430
|
+
]}
|
|
431
|
+
>
|
|
432
|
+
<Text style={{ fontSize: 48 }}>✨</Text>
|
|
433
|
+
{element.props?.animationDescription && (
|
|
434
|
+
<Text style={{ fontSize: 11, color: '#666', textAlign: 'center', padding: 8 }}>
|
|
435
|
+
{element.props.animationDescription}
|
|
436
|
+
</Text>
|
|
437
|
+
)}
|
|
438
|
+
</View>
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
case 'input':
|
|
442
|
+
return (
|
|
443
|
+
<TextInput
|
|
444
|
+
style={[style as TextStyle, { borderWidth: 1, borderColor: '#E5E5E5' }]}
|
|
445
|
+
placeholder={element.props?.placeholder || 'Enter text...'}
|
|
446
|
+
keyboardType={getKeyboardType(element.props?.type)}
|
|
447
|
+
secureTextEntry={element.props?.type === 'password'}
|
|
448
|
+
autoCapitalize={element.props?.type === 'email' ? 'none' : 'sentences'}
|
|
449
|
+
/>
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
case 'spacer':
|
|
453
|
+
return <View style={style || { flex: 1 }} />;
|
|
454
|
+
|
|
455
|
+
case 'divider':
|
|
456
|
+
return (
|
|
457
|
+
<View
|
|
458
|
+
style={[
|
|
459
|
+
{
|
|
460
|
+
height: 1,
|
|
461
|
+
backgroundColor: '#e0e0e0',
|
|
462
|
+
width: '100%',
|
|
463
|
+
},
|
|
464
|
+
style,
|
|
465
|
+
]}
|
|
466
|
+
/>
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
default:
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// ─── Style Converter ───
|
|
475
|
+
|
|
476
|
+
function convertStyle(style: ElementStyle | Record<string, any>): ViewStyle & TextStyle {
|
|
477
|
+
const rnStyle: any = {};
|
|
478
|
+
|
|
479
|
+
if (!style) return rnStyle;
|
|
480
|
+
|
|
481
|
+
// Layout
|
|
482
|
+
if (style.flex !== undefined) rnStyle.flex = style.flex;
|
|
483
|
+
if (style.justifyContent) rnStyle.justifyContent = style.justifyContent;
|
|
484
|
+
if (style.alignItems) rnStyle.alignItems = style.alignItems;
|
|
485
|
+
if (style.alignSelf) rnStyle.alignSelf = style.alignSelf;
|
|
486
|
+
if (style.gap !== undefined) rnStyle.gap = style.gap;
|
|
487
|
+
if (style.flexWrap) rnStyle.flexWrap = style.flexWrap;
|
|
488
|
+
if (style.overflow) rnStyle.overflow = style.overflow;
|
|
489
|
+
|
|
490
|
+
// Spacing
|
|
491
|
+
if (style.padding !== undefined) rnStyle.padding = style.padding;
|
|
492
|
+
if (style.paddingTop !== undefined) rnStyle.paddingTop = style.paddingTop;
|
|
493
|
+
if (style.paddingBottom !== undefined) rnStyle.paddingBottom = style.paddingBottom;
|
|
494
|
+
if (style.paddingLeft !== undefined) rnStyle.paddingLeft = style.paddingLeft;
|
|
495
|
+
if (style.paddingRight !== undefined) rnStyle.paddingRight = style.paddingRight;
|
|
496
|
+
if (style.marginTop !== undefined) rnStyle.marginTop = style.marginTop;
|
|
497
|
+
if (style.marginBottom !== undefined) rnStyle.marginBottom = style.marginBottom;
|
|
498
|
+
if (style.marginLeft !== undefined) rnStyle.marginLeft = style.marginLeft;
|
|
499
|
+
if (style.marginRight !== undefined) rnStyle.marginRight = style.marginRight;
|
|
500
|
+
|
|
501
|
+
// Size
|
|
502
|
+
if (style.width !== undefined) rnStyle.width = style.width;
|
|
503
|
+
if (style.height !== undefined) rnStyle.height = style.height;
|
|
504
|
+
if (style.maxWidth !== undefined) rnStyle.maxWidth = style.maxWidth;
|
|
505
|
+
if (style.minHeight !== undefined) rnStyle.minHeight = style.minHeight;
|
|
506
|
+
|
|
507
|
+
// Visual
|
|
508
|
+
if (style.backgroundColor) rnStyle.backgroundColor = style.backgroundColor;
|
|
509
|
+
if (style.opacity !== undefined) rnStyle.opacity = style.opacity;
|
|
510
|
+
if (style.borderRadius !== undefined) rnStyle.borderRadius = style.borderRadius;
|
|
511
|
+
if (style.borderWidth !== undefined) rnStyle.borderWidth = style.borderWidth;
|
|
512
|
+
if (style.borderColor) rnStyle.borderColor = style.borderColor;
|
|
513
|
+
if (style.borderBottomWidth !== undefined) rnStyle.borderBottomWidth = style.borderBottomWidth;
|
|
514
|
+
if (style.borderBottomColor) rnStyle.borderBottomColor = style.borderBottomColor;
|
|
515
|
+
|
|
516
|
+
// Shadow (React Native uses different shadow props)
|
|
517
|
+
if (style.shadowColor) {
|
|
518
|
+
rnStyle.shadowColor = style.shadowColor;
|
|
519
|
+
rnStyle.shadowOpacity = style.shadowOpacity || 0.2;
|
|
520
|
+
rnStyle.shadowRadius = style.shadowRadius || 4;
|
|
521
|
+
rnStyle.shadowOffset = {
|
|
522
|
+
width: style.shadowOffsetX || 0,
|
|
523
|
+
height: style.shadowOffsetY || 2,
|
|
524
|
+
};
|
|
525
|
+
// Android elevation approximation
|
|
526
|
+
rnStyle.elevation = style.shadowRadius || 4;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Text
|
|
530
|
+
if (style.color) rnStyle.color = style.color;
|
|
531
|
+
if (style.fontSize !== undefined) rnStyle.fontSize = style.fontSize;
|
|
532
|
+
if (style.fontWeight) rnStyle.fontWeight = style.fontWeight;
|
|
533
|
+
if (style.textAlign) rnStyle.textAlign = style.textAlign;
|
|
534
|
+
if (style.lineHeight !== undefined) {
|
|
535
|
+
// React Native lineHeight is in pixels, not a multiplier
|
|
536
|
+
// If value is small (< 4), treat as multiplier and convert
|
|
537
|
+
rnStyle.lineHeight =
|
|
538
|
+
style.lineHeight > 4 ? style.lineHeight : (style.fontSize || 16) * style.lineHeight;
|
|
539
|
+
}
|
|
540
|
+
if (style.letterSpacing !== undefined) rnStyle.letterSpacing = style.letterSpacing;
|
|
541
|
+
if (style.textTransform) rnStyle.textTransform = style.textTransform;
|
|
542
|
+
if (style.textDecorationLine) rnStyle.textDecorationLine = style.textDecorationLine;
|
|
543
|
+
|
|
544
|
+
// backgroundGradient is handled by wrapWithGradient at the component level.
|
|
545
|
+
// If LinearGradient is not available, fall back to the first gradient color.
|
|
546
|
+
if (style.backgroundGradient && !LinearGradient && style.backgroundGradient.colors?.length) {
|
|
547
|
+
const firstColor = style.backgroundGradient.colors[0];
|
|
548
|
+
rnStyle.backgroundColor = typeof firstColor === 'string' ? firstColor : firstColor.color;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return rnStyle;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ─── Gradient Wrapper ───
|
|
555
|
+
|
|
556
|
+
function angleToCoords(angle: number): { start: { x: number; y: number }; end: { x: number; y: number } } {
|
|
557
|
+
// Convert CSS angle (0 = top, 90 = right) to LinearGradient coordinates
|
|
558
|
+
const rad = ((angle - 90) * Math.PI) / 180;
|
|
559
|
+
return {
|
|
560
|
+
start: { x: 0.5 - Math.cos(rad) * 0.5, y: 0.5 - Math.sin(rad) * 0.5 },
|
|
561
|
+
end: { x: 0.5 + Math.cos(rad) * 0.5, y: 0.5 + Math.sin(rad) * 0.5 },
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function wrapWithGradient(
|
|
566
|
+
content: React.ReactElement,
|
|
567
|
+
elementStyle: ElementStyle | Record<string, any>,
|
|
568
|
+
viewStyle: any
|
|
569
|
+
): React.ReactElement {
|
|
570
|
+
const gradient = elementStyle?.backgroundGradient;
|
|
571
|
+
if (!gradient || !LinearGradient || !gradient.colors?.length) return content;
|
|
572
|
+
|
|
573
|
+
// Handle both { color, position } objects and plain color strings
|
|
574
|
+
const colors = gradient.colors.map((c: any) => typeof c === 'string' ? c : c.color);
|
|
575
|
+
const locations = gradient.colors.map((c: any, i: number, arr: any[]) => {
|
|
576
|
+
if (typeof c === 'string') return i / Math.max(arr.length - 1, 1);
|
|
577
|
+
return (c.position ?? Math.round((i / Math.max(arr.length - 1, 1)) * 100)) / 100;
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
// Pull layout-affecting styles onto the gradient wrapper, keep inner styles on the content
|
|
581
|
+
const { backgroundColor, ...innerStyle } = viewStyle;
|
|
582
|
+
const gradientStyle: any = { ...innerStyle };
|
|
583
|
+
|
|
584
|
+
const gradientType = gradient.type || 'linear';
|
|
585
|
+
if (gradientType === 'radial') {
|
|
586
|
+
// No radial support in LinearGradient — use first color as fallback
|
|
587
|
+
return React.cloneElement(content, {
|
|
588
|
+
style: [viewStyle, { backgroundColor: colors[0] }],
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Support both angle and start/end array formats
|
|
593
|
+
let coords;
|
|
594
|
+
if (gradient.start && gradient.end) {
|
|
595
|
+
const s = Array.isArray(gradient.start) ? { x: gradient.start[0], y: gradient.start[1] } : gradient.start;
|
|
596
|
+
const e = Array.isArray(gradient.end) ? { x: gradient.end[0], y: gradient.end[1] } : gradient.end;
|
|
597
|
+
coords = { start: s, end: e };
|
|
598
|
+
} else {
|
|
599
|
+
coords = angleToCoords(gradient.angle ?? 180);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return (
|
|
603
|
+
<LinearGradient
|
|
604
|
+
colors={colors}
|
|
605
|
+
locations={locations}
|
|
606
|
+
start={coords.start}
|
|
607
|
+
end={coords.end}
|
|
608
|
+
style={gradientStyle}
|
|
609
|
+
>
|
|
610
|
+
{content.props.children}
|
|
611
|
+
</LinearGradient>
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ─── Helpers ───
|
|
616
|
+
|
|
617
|
+
function getKeyboardType(type?: string) {
|
|
618
|
+
switch (type) {
|
|
619
|
+
case 'email':
|
|
620
|
+
return 'email-address' as const;
|
|
621
|
+
case 'tel':
|
|
622
|
+
case 'number':
|
|
623
|
+
return 'numeric' as const;
|
|
624
|
+
default:
|
|
625
|
+
return 'default' as const;
|
|
626
|
+
}
|
|
627
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Main component
|
|
2
|
+
export { OnboardingFlow } from './OnboardingFlow';
|
|
3
|
+
|
|
4
|
+
// Types
|
|
5
|
+
export type {
|
|
6
|
+
OnboardingFlowProps,
|
|
7
|
+
ScreenConfig,
|
|
8
|
+
OnboardingConfig,
|
|
9
|
+
AnalyticsEvent,
|
|
10
|
+
BaseComponentProps,
|
|
11
|
+
CustomScreenProps,
|
|
12
|
+
// Element tree types
|
|
13
|
+
ElementNode,
|
|
14
|
+
ElementType,
|
|
15
|
+
ElementAction,
|
|
16
|
+
ElementStyle,
|
|
17
|
+
ElementPosition,
|
|
18
|
+
// Variable & condition types
|
|
19
|
+
Condition,
|
|
20
|
+
ComparisonOperator,
|
|
21
|
+
ConditionalDestination,
|
|
22
|
+
ConditionalRoutes,
|
|
23
|
+
ElementConditions,
|
|
24
|
+
} from './types';
|
|
25
|
+
|
|
26
|
+
// Components
|
|
27
|
+
export { ElementRenderer } from './components/ElementRenderer';
|
|
28
|
+
|
|
29
|
+
// Utilities (if developers want to use them)
|
|
30
|
+
export { API } from './api';
|
|
31
|
+
export { AnalyticsManager } from './analytics';
|
|
32
|
+
export { resolveTemplate, evaluateCondition, resolveDestination } from './variableUtils';
|