noboarding 1.0.3-beta → 1.0.7
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/ANIMATIONS.md +446 -0
- package/README.md +356 -323
- package/lib/OnboardingFlow.js +21 -4
- package/lib/animationUtils.d.ts +19 -0
- package/lib/animationUtils.js +252 -0
- package/lib/components/ElementRenderer.d.ts +5 -0
- package/lib/components/ElementRenderer.js +231 -40
- package/lib/types.d.ts +46 -0
- package/package.json +4 -2
- package/src/OnboardingFlow.tsx +19 -0
- package/src/animationUtils.ts +292 -0
- package/src/components/ElementRenderer.tsx +287 -22
- package/src/types.ts +51 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useCallback } from 'react';
|
|
1
|
+
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
View,
|
|
4
4
|
Text,
|
|
@@ -11,9 +11,17 @@ import {
|
|
|
11
11
|
ViewStyle,
|
|
12
12
|
TextStyle,
|
|
13
13
|
ImageStyle,
|
|
14
|
+
Animated,
|
|
14
15
|
} from 'react-native';
|
|
15
16
|
import { ElementNode, ElementStyle, ElementAction, Analytics, ConditionalDestination, ConditionalRoutes } from '../types';
|
|
16
17
|
import { resolveTemplate, evaluateCondition } from '../variableUtils';
|
|
18
|
+
import {
|
|
19
|
+
createEntranceAnimationValues,
|
|
20
|
+
startEntranceAnimation,
|
|
21
|
+
startInteractiveAnimation,
|
|
22
|
+
triggerHaptic,
|
|
23
|
+
shouldTriggerHaptic,
|
|
24
|
+
} from '../animationUtils';
|
|
17
25
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
18
26
|
import { Feather, MaterialIcons, MaterialCommunityIcons, Ionicons, FontAwesome } from '@expo/vector-icons';
|
|
19
27
|
|
|
@@ -35,6 +43,7 @@ interface ElementRendererProps {
|
|
|
35
43
|
onDismiss?: () => void;
|
|
36
44
|
variables?: Record<string, any>;
|
|
37
45
|
onSetVariable?: (name: string, value: any) => void;
|
|
46
|
+
assets?: Array<{ name: string; type: string; data: string }>;
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
export const ElementRenderer: React.FC<ElementRendererProps> = ({
|
|
@@ -45,11 +54,31 @@ export const ElementRenderer: React.FC<ElementRendererProps> = ({
|
|
|
45
54
|
onDismiss,
|
|
46
55
|
variables = {},
|
|
47
56
|
onSetVariable,
|
|
57
|
+
assets = [],
|
|
48
58
|
}) => {
|
|
49
59
|
// Track toggled element IDs for toggle actions
|
|
50
60
|
const [toggledIds, setToggledIds] = useState<Set<string>>(new Set());
|
|
51
61
|
// Track selection groups: group name → selected element ID
|
|
52
62
|
const [groupSelections, setGroupSelections] = useState<Record<string, string>>({});
|
|
63
|
+
// Track text input values locally (uncontrolled)
|
|
64
|
+
const [inputValues, setInputValues] = useState<Record<string, string>>({});
|
|
65
|
+
|
|
66
|
+
// Helper function to resolve asset: URLs to actual data URLs
|
|
67
|
+
const resolveAssetUrl = (url: string): string => {
|
|
68
|
+
if (!url || !url.startsWith('asset:')) {
|
|
69
|
+
return url; // Return as-is if not an asset reference
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const assetName = url.replace('asset:', '');
|
|
73
|
+
const asset = assets.find(a => a.name === assetName);
|
|
74
|
+
|
|
75
|
+
if (asset) {
|
|
76
|
+
return asset.data; // Return the base64 data URL
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console.warn(`Asset not found: ${assetName}`);
|
|
80
|
+
return url; // Fallback to original URL
|
|
81
|
+
};
|
|
53
82
|
|
|
54
83
|
const executeAction = useCallback(
|
|
55
84
|
(action: ElementAction, element: ElementNode) => {
|
|
@@ -66,6 +95,12 @@ export const ElementRenderer: React.FC<ElementRendererProps> = ({
|
|
|
66
95
|
if (action.variable !== undefined && onSetVariable) {
|
|
67
96
|
onSetVariable(action.variable, action.value);
|
|
68
97
|
}
|
|
98
|
+
// Also save all current text input values when any action is triggered
|
|
99
|
+
if (onSetVariable) {
|
|
100
|
+
Object.entries(inputValues).forEach(([key, value]) => {
|
|
101
|
+
onSetVariable(key, value);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
69
104
|
break;
|
|
70
105
|
case 'toggle': {
|
|
71
106
|
const group = action.group;
|
|
@@ -98,6 +133,12 @@ export const ElementRenderer: React.FC<ElementRendererProps> = ({
|
|
|
98
133
|
break;
|
|
99
134
|
}
|
|
100
135
|
case 'navigate':
|
|
136
|
+
// Save all text input values before navigating
|
|
137
|
+
if (onSetVariable) {
|
|
138
|
+
Object.entries(inputValues).forEach(([key, value]) => {
|
|
139
|
+
onSetVariable(key, value);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
101
142
|
if (onNavigate && action.destination) {
|
|
102
143
|
onNavigate(action.destination);
|
|
103
144
|
}
|
|
@@ -115,7 +156,7 @@ export const ElementRenderer: React.FC<ElementRendererProps> = ({
|
|
|
115
156
|
break;
|
|
116
157
|
}
|
|
117
158
|
},
|
|
118
|
-
[groupSelections, onNavigate, onDismiss, analytics, screenId, onSetVariable]
|
|
159
|
+
[groupSelections, onNavigate, onDismiss, analytics, screenId, onSetVariable, inputValues]
|
|
119
160
|
);
|
|
120
161
|
|
|
121
162
|
const handleAction = useCallback(
|
|
@@ -148,6 +189,9 @@ export const ElementRenderer: React.FC<ElementRendererProps> = ({
|
|
|
148
189
|
groupSelections={groupSelections}
|
|
149
190
|
onAction={handleAction}
|
|
150
191
|
variables={variables}
|
|
192
|
+
inputValues={inputValues}
|
|
193
|
+
setInputValues={setInputValues}
|
|
194
|
+
resolveAssetUrl={resolveAssetUrl}
|
|
151
195
|
/>
|
|
152
196
|
))}
|
|
153
197
|
</>
|
|
@@ -163,15 +207,39 @@ interface RenderNodeProps {
|
|
|
163
207
|
onAction: (element: ElementNode) => void;
|
|
164
208
|
variables: Record<string, any>;
|
|
165
209
|
onSetVariable?: (name: string, value: any) => void;
|
|
210
|
+
inputValues: Record<string, string>;
|
|
211
|
+
setInputValues: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
|
212
|
+
staggerDelay?: number; // For staggered entrance animations
|
|
213
|
+
resolveAssetUrl: (url: string) => string; // Asset URL resolver
|
|
166
214
|
}
|
|
167
215
|
|
|
168
|
-
const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelections, onAction, variables, onSetVariable }) => {
|
|
216
|
+
const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelections, onAction, variables, onSetVariable, inputValues, setInputValues, staggerDelay = 0, resolveAssetUrl }) => {
|
|
169
217
|
// Variable-based conditions — hide element if condition is not met
|
|
170
218
|
if (element.conditions?.show_if) {
|
|
171
219
|
const shouldShow = evaluateCondition(element.conditions.show_if, variables);
|
|
172
220
|
if (!shouldShow) return null;
|
|
173
221
|
}
|
|
174
222
|
|
|
223
|
+
// ─── Animation State ───
|
|
224
|
+
const entranceValues = useRef(createEntranceAnimationValues()).current;
|
|
225
|
+
const interactiveValue = useRef(new Animated.Value(1)).current;
|
|
226
|
+
const [hasAnimated, setHasAnimated] = useState(false);
|
|
227
|
+
|
|
228
|
+
// Start entrance animation on mount
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
if (element.entrance && element.entrance.type !== 'none' && !hasAnimated) {
|
|
231
|
+
startEntranceAnimation(element.entrance, entranceValues, staggerDelay);
|
|
232
|
+
setHasAnimated(true);
|
|
233
|
+
}
|
|
234
|
+
}, [element.entrance, hasAnimated, staggerDelay]);
|
|
235
|
+
|
|
236
|
+
// Start auto-triggered interactive animations
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
if (element.interactive && element.interactive.trigger === 'load' && element.interactive.type !== 'none') {
|
|
239
|
+
startInteractiveAnimation(element.interactive, interactiveValue);
|
|
240
|
+
}
|
|
241
|
+
}, [element.interactive]);
|
|
242
|
+
|
|
175
243
|
const style = convertStyle(element.style || {});
|
|
176
244
|
const isToggled = toggledIds.has(element.id);
|
|
177
245
|
|
|
@@ -207,11 +275,19 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
207
275
|
if (style.width) wrapperStyle.width = style.width;
|
|
208
276
|
if (style.alignSelf) wrapperStyle.alignSelf = style.alignSelf;
|
|
209
277
|
|
|
278
|
+
const handlePress = () => {
|
|
279
|
+
// Trigger interactive animation if configured
|
|
280
|
+
if (element.interactive && element.interactive.trigger === 'tap' && element.interactive.type !== 'none') {
|
|
281
|
+
startInteractiveAnimation(element.interactive, interactiveValue);
|
|
282
|
+
}
|
|
283
|
+
onAction(element);
|
|
284
|
+
};
|
|
285
|
+
|
|
210
286
|
return (
|
|
211
287
|
<TouchableOpacity
|
|
212
288
|
key={element.id}
|
|
213
289
|
activeOpacity={0.7}
|
|
214
|
-
onPress={
|
|
290
|
+
onPress={handlePress}
|
|
215
291
|
style={wrapperStyle}
|
|
216
292
|
>
|
|
217
293
|
{content}
|
|
@@ -219,39 +295,103 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
219
295
|
);
|
|
220
296
|
};
|
|
221
297
|
|
|
222
|
-
|
|
298
|
+
// Wrapper for entrance animations
|
|
299
|
+
const wrapWithEntranceAnimation = (content: React.ReactElement): React.ReactElement => {
|
|
300
|
+
if (!element.entrance || element.entrance.type === 'none') {
|
|
301
|
+
return content;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Build animated style based on entrance type
|
|
305
|
+
const animatedStyle: any = {
|
|
306
|
+
opacity: entranceValues.opacity,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// Apply scale for scale animations
|
|
310
|
+
if (element.entrance.type === 'scaleIn') {
|
|
311
|
+
animatedStyle.transform = [{ scale: entranceValues.scale }];
|
|
312
|
+
}
|
|
313
|
+
// Apply translate for slide animations
|
|
314
|
+
else if (element.entrance.type === 'slideUp' || element.entrance.type === 'slideDown') {
|
|
315
|
+
animatedStyle.transform = [{ translateY: entranceValues.translateY }];
|
|
316
|
+
}
|
|
317
|
+
else if (element.entrance.type === 'slideLeft' || element.entrance.type === 'slideRight') {
|
|
318
|
+
animatedStyle.transform = [{ translateX: entranceValues.translateX }];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return <Animated.View style={animatedStyle}>{content}</Animated.View>;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// Wrapper for interactive animations
|
|
325
|
+
const wrapWithInteractiveAnimation = (content: React.ReactElement): React.ReactElement => {
|
|
326
|
+
if (!element.interactive || element.interactive.type === 'none') {
|
|
327
|
+
return content;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const animatedStyle: any = {};
|
|
331
|
+
|
|
332
|
+
switch (element.interactive.type) {
|
|
333
|
+
case 'scale':
|
|
334
|
+
case 'pulse':
|
|
335
|
+
animatedStyle.transform = [{ scale: interactiveValue }];
|
|
336
|
+
break;
|
|
337
|
+
case 'shake':
|
|
338
|
+
animatedStyle.transform = [{ translateX: interactiveValue }];
|
|
339
|
+
break;
|
|
340
|
+
case 'bounce':
|
|
341
|
+
animatedStyle.transform = [{ translateY: interactiveValue }];
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return <Animated.View style={animatedStyle}>{content}</Animated.View>;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const childProps = { toggledIds, groupSelections, onAction, variables, onSetVariable, inputValues, setInputValues, resolveAssetUrl };
|
|
223
349
|
|
|
224
350
|
switch (element.type) {
|
|
225
351
|
// ─── Containers ───
|
|
226
352
|
|
|
227
353
|
case 'vstack': {
|
|
354
|
+
const stagger = element.entrance?.stagger || 0;
|
|
228
355
|
const vstackContent = (
|
|
229
356
|
<View style={[style, { flexDirection: 'column' }]}>
|
|
230
|
-
{element.children?.map((child) => (
|
|
231
|
-
<RenderNode
|
|
357
|
+
{element.children?.map((child, index) => (
|
|
358
|
+
<RenderNode
|
|
359
|
+
key={child.id}
|
|
360
|
+
element={child}
|
|
361
|
+
{...childProps}
|
|
362
|
+
staggerDelay={stagger > 0 ? index * stagger : 0}
|
|
363
|
+
/>
|
|
232
364
|
))}
|
|
233
365
|
</View>
|
|
234
366
|
);
|
|
235
|
-
|
|
367
|
+
const wrapped = wrapWithAction(
|
|
236
368
|
element.style?.backgroundGradient
|
|
237
369
|
? wrapWithGradient(vstackContent, element.style, { ...style, flexDirection: 'column' })
|
|
238
370
|
: vstackContent
|
|
239
371
|
);
|
|
372
|
+
return wrapWithInteractiveAnimation(wrapWithEntranceAnimation(wrapped));
|
|
240
373
|
}
|
|
241
374
|
|
|
242
375
|
case 'hstack': {
|
|
376
|
+
const stagger = element.entrance?.stagger || 0;
|
|
243
377
|
const hstackContent = (
|
|
244
378
|
<View style={[style, { flexDirection: 'row' }]}>
|
|
245
|
-
{element.children?.map((child) => (
|
|
246
|
-
<RenderNode
|
|
379
|
+
{element.children?.map((child, index) => (
|
|
380
|
+
<RenderNode
|
|
381
|
+
key={child.id}
|
|
382
|
+
element={child}
|
|
383
|
+
{...childProps}
|
|
384
|
+
staggerDelay={stagger > 0 ? index * stagger : 0}
|
|
385
|
+
/>
|
|
247
386
|
))}
|
|
248
387
|
</View>
|
|
249
388
|
);
|
|
250
|
-
|
|
389
|
+
const wrapped = wrapWithAction(
|
|
251
390
|
element.style?.backgroundGradient
|
|
252
391
|
? wrapWithGradient(hstackContent, element.style, { ...style, flexDirection: 'row' })
|
|
253
392
|
: hstackContent
|
|
254
393
|
);
|
|
394
|
+
return wrapWithInteractiveAnimation(wrapWithEntranceAnimation(wrapped));
|
|
255
395
|
}
|
|
256
396
|
|
|
257
397
|
case 'zstack': {
|
|
@@ -308,11 +448,75 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
308
448
|
|
|
309
449
|
case 'text': {
|
|
310
450
|
const resolvedText = resolveTemplate(element.props?.text || '', variables);
|
|
311
|
-
|
|
451
|
+
|
|
452
|
+
// Typewriter animation support
|
|
453
|
+
const [displayedText, setDisplayedText] = useState('');
|
|
454
|
+
const [showCursor, setShowCursor] = useState(false);
|
|
455
|
+
const typewriterInterval = useRef<NodeJS.Timeout | null>(null);
|
|
456
|
+
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
const textAnim = element.textAnimation;
|
|
459
|
+
|
|
460
|
+
if (textAnim && textAnim.type === 'typewriter') {
|
|
461
|
+
const speed = textAnim.speed || 20; // chars per second
|
|
462
|
+
const delay = textAnim.delay || 0;
|
|
463
|
+
const haptic = textAnim.haptic;
|
|
464
|
+
let currentIndex = 0;
|
|
465
|
+
|
|
466
|
+
// Show cursor if enabled
|
|
467
|
+
if (textAnim.cursor) {
|
|
468
|
+
setShowCursor(true);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Start typewriter animation after delay
|
|
472
|
+
const timeoutId = setTimeout(() => {
|
|
473
|
+
typewriterInterval.current = setInterval(() => {
|
|
474
|
+
if (currentIndex >= resolvedText.length) {
|
|
475
|
+
if (typewriterInterval.current) {
|
|
476
|
+
clearInterval(typewriterInterval.current);
|
|
477
|
+
}
|
|
478
|
+
// Hide cursor when done
|
|
479
|
+
if (textAnim.cursor) {
|
|
480
|
+
setShowCursor(false);
|
|
481
|
+
}
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
setDisplayedText(resolvedText.substring(0, currentIndex + 1));
|
|
486
|
+
|
|
487
|
+
// Trigger haptic if enabled
|
|
488
|
+
if (haptic && haptic.enabled && shouldTriggerHaptic(currentIndex, haptic.frequency)) {
|
|
489
|
+
triggerHaptic(haptic.type);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
currentIndex++;
|
|
493
|
+
}, 1000 / speed);
|
|
494
|
+
}, delay);
|
|
495
|
+
|
|
496
|
+
return () => {
|
|
497
|
+
clearTimeout(timeoutId);
|
|
498
|
+
if (typewriterInterval.current) {
|
|
499
|
+
clearInterval(typewriterInterval.current);
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
} else {
|
|
503
|
+
// No typewriter - show full text immediately
|
|
504
|
+
setDisplayedText(resolvedText);
|
|
505
|
+
}
|
|
506
|
+
}, [resolvedText, element.textAnimation]);
|
|
507
|
+
|
|
508
|
+
const textContent = element.textAnimation?.type === 'typewriter' ? displayedText : resolvedText;
|
|
509
|
+
|
|
510
|
+
const textElement = (
|
|
312
511
|
<Text style={style as TextStyle}>
|
|
313
|
-
{
|
|
512
|
+
{textContent}
|
|
513
|
+
{showCursor && (
|
|
514
|
+
<Text style={{ opacity: 0.5 }}>|</Text>
|
|
515
|
+
)}
|
|
314
516
|
</Text>
|
|
315
517
|
);
|
|
518
|
+
|
|
519
|
+
return wrapWithInteractiveAnimation(wrapWithEntranceAnimation(wrapWithAction(textElement)));
|
|
316
520
|
}
|
|
317
521
|
|
|
318
522
|
case 'icon': {
|
|
@@ -358,9 +562,10 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
358
562
|
|
|
359
563
|
case 'image':
|
|
360
564
|
if (element.props?.url) {
|
|
565
|
+
const resolvedUrl = resolveAssetUrl(element.props.url);
|
|
361
566
|
return (
|
|
362
567
|
<Image
|
|
363
|
-
source={{ uri:
|
|
568
|
+
source={{ uri: resolvedUrl }}
|
|
364
569
|
style={[style as ImageStyle, { resizeMode: 'cover' }]}
|
|
365
570
|
/>
|
|
366
571
|
);
|
|
@@ -386,8 +591,32 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
386
591
|
</View>
|
|
387
592
|
);
|
|
388
593
|
|
|
389
|
-
case 'video':
|
|
390
|
-
|
|
594
|
+
case 'video': {
|
|
595
|
+
const videoUrl = element.props?.url ? resolveAssetUrl(element.props.url) : null;
|
|
596
|
+
let VideoComponent: any = null;
|
|
597
|
+
try {
|
|
598
|
+
const expoAv = require('expo-av');
|
|
599
|
+
VideoComponent = expoAv.Video;
|
|
600
|
+
} catch {}
|
|
601
|
+
|
|
602
|
+
if (VideoComponent && videoUrl) {
|
|
603
|
+
const resizeMode = element.props?.resizeMode || 'contain';
|
|
604
|
+
return (
|
|
605
|
+
<View style={[style, { backgroundColor: (style as ViewStyle).backgroundColor || '#1a1a1a', overflow: 'hidden' }]}>
|
|
606
|
+
<VideoComponent
|
|
607
|
+
source={{ uri: videoUrl }}
|
|
608
|
+
style={{ width: '100%', height: '100%' }}
|
|
609
|
+
resizeMode={resizeMode}
|
|
610
|
+
shouldPlay={element.props?.autoPlay !== false}
|
|
611
|
+
isLooping={element.props?.loop !== false}
|
|
612
|
+
isMuted={element.props?.muted !== false}
|
|
613
|
+
useNativeControls={false}
|
|
614
|
+
/>
|
|
615
|
+
</View>
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Fallback when expo-av is unavailable or no URL
|
|
391
620
|
return (
|
|
392
621
|
<View
|
|
393
622
|
style={[
|
|
@@ -407,9 +636,44 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
407
636
|
)}
|
|
408
637
|
</View>
|
|
409
638
|
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
case 'lottie': {
|
|
642
|
+
const lottieUrl = element.props?.url ? resolveAssetUrl(element.props.url) : null;
|
|
643
|
+
let LottieView: any = null;
|
|
644
|
+
try {
|
|
645
|
+
LottieView = require('lottie-react-native').default;
|
|
646
|
+
} catch {}
|
|
647
|
+
|
|
648
|
+
if (LottieView && lottieUrl) {
|
|
649
|
+
// Decode base64 data URLs to get the Lottie JSON object
|
|
650
|
+
let lottieSource: any;
|
|
651
|
+
if (lottieUrl.startsWith('data:application/json;base64,')) {
|
|
652
|
+
try {
|
|
653
|
+
const base64Data = lottieUrl.replace('data:application/json;base64,', '');
|
|
654
|
+
const jsonString = atob(base64Data);
|
|
655
|
+
lottieSource = JSON.parse(jsonString);
|
|
656
|
+
} catch {
|
|
657
|
+
lottieSource = { uri: lottieUrl };
|
|
658
|
+
}
|
|
659
|
+
} else {
|
|
660
|
+
lottieSource = { uri: lottieUrl };
|
|
661
|
+
}
|
|
410
662
|
|
|
411
|
-
|
|
412
|
-
|
|
663
|
+
return (
|
|
664
|
+
<View style={[style, { backgroundColor: (style as ViewStyle).backgroundColor || '#f8f8ff', overflow: 'hidden' }]}>
|
|
665
|
+
<LottieView
|
|
666
|
+
source={lottieSource}
|
|
667
|
+
style={{ width: '100%', height: '100%' }}
|
|
668
|
+
autoPlay={element.props?.autoPlay !== false}
|
|
669
|
+
loop={element.props?.loop !== false}
|
|
670
|
+
speed={element.props?.speed || 1}
|
|
671
|
+
/>
|
|
672
|
+
</View>
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Fallback when lottie-react-native is unavailable or no URL
|
|
413
677
|
return (
|
|
414
678
|
<View
|
|
415
679
|
style={[
|
|
@@ -429,6 +693,7 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
429
693
|
)}
|
|
430
694
|
</View>
|
|
431
695
|
);
|
|
696
|
+
}
|
|
432
697
|
|
|
433
698
|
case 'input': {
|
|
434
699
|
// Only apply default border if borderWidth is not explicitly defined (including 0)
|
|
@@ -441,7 +706,8 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
441
706
|
|
|
442
707
|
// Get the variable name - use props.variable if specified, otherwise use element.id
|
|
443
708
|
const variableName = element.props?.variable || element.id;
|
|
444
|
-
|
|
709
|
+
// Use local state value, or fall back to variables, or empty string
|
|
710
|
+
const currentValue = inputValues[variableName] ?? variables[variableName] ?? '';
|
|
445
711
|
|
|
446
712
|
return (
|
|
447
713
|
<TextInput
|
|
@@ -452,9 +718,8 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
|
|
|
452
718
|
autoCapitalize={element.props?.type === 'email' ? 'none' : 'sentences'}
|
|
453
719
|
value={currentValue}
|
|
454
720
|
onChangeText={(text) => {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
}
|
|
721
|
+
// Save to local state only - don't trigger parent re-render
|
|
722
|
+
setInputValues(prev => ({ ...prev, [variableName]: text }));
|
|
458
723
|
}}
|
|
459
724
|
/>
|
|
460
725
|
);
|
package/src/types.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface ScreenConfig {
|
|
|
14
14
|
custom_component_name?: string;
|
|
15
15
|
// Dashboard visibility control — if true, screen is hidden from onboarding flow
|
|
16
16
|
hidden?: boolean;
|
|
17
|
+
// Screen transition animation (OTA updateable)
|
|
18
|
+
transition?: ScreenTransition;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
// ─── Element Tree Types (matches dashboard primitives) ───
|
|
@@ -45,6 +47,54 @@ export interface ElementNode {
|
|
|
45
47
|
actions?: ElementAction[]; // multi-action support (runs all in sequence)
|
|
46
48
|
visibleWhen?: { group: string; hasSelection: boolean };
|
|
47
49
|
conditions?: ElementConditions; // variable-based show/hide
|
|
50
|
+
// Animation configurations (OTA updateable)
|
|
51
|
+
entrance?: EntranceAnimation;
|
|
52
|
+
interactive?: InteractiveAnimation;
|
|
53
|
+
textAnimation?: TextAnimation;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Animation Types (OTA Updateable) ───
|
|
57
|
+
|
|
58
|
+
export type EntranceAnimationType = 'fadeIn' | 'slideUp' | 'slideDown' | 'slideLeft' | 'slideRight' | 'scaleIn' | 'none';
|
|
59
|
+
export type InteractiveAnimationType = 'scale' | 'pulse' | 'shake' | 'bounce' | 'none';
|
|
60
|
+
export type HapticType = 'light' | 'medium' | 'heavy' | 'success' | 'warning' | 'error';
|
|
61
|
+
export type HapticFrequency = 'every' | 'every-2' | 'every-3' | 'every-5';
|
|
62
|
+
|
|
63
|
+
export interface EntranceAnimation {
|
|
64
|
+
type: EntranceAnimationType;
|
|
65
|
+
duration?: number; // milliseconds (default: 400)
|
|
66
|
+
delay?: number; // delay before starting (default: 0)
|
|
67
|
+
stagger?: number; // for lists: delay between each child (default: 0)
|
|
68
|
+
easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'spring';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface InteractiveAnimation {
|
|
72
|
+
type: InteractiveAnimationType;
|
|
73
|
+
trigger?: 'tap' | 'load'; // when to trigger (default: 'tap')
|
|
74
|
+
duration?: number; // milliseconds (default: 200)
|
|
75
|
+
intensity?: number; // scale/shake intensity 0-1 (default: 0.95 for scale, 10 for shake)
|
|
76
|
+
repeat?: boolean; // repeat continuously (default: false)
|
|
77
|
+
haptic?: boolean; // trigger haptic on animation (default: false)
|
|
78
|
+
hapticType?: HapticType; // type of haptic feedback
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface TextAnimation {
|
|
82
|
+
type: 'typewriter' | 'none';
|
|
83
|
+
speed?: number; // characters per second (default: 20)
|
|
84
|
+
delay?: number; // delay before starting (default: 0)
|
|
85
|
+
cursor?: boolean; // show blinking cursor (default: false)
|
|
86
|
+
haptic?: {
|
|
87
|
+
enabled: boolean;
|
|
88
|
+
type: HapticType;
|
|
89
|
+
frequency: HapticFrequency;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ScreenTransition {
|
|
94
|
+
type: 'push' | 'modal' | 'fade' | 'slide' | 'none';
|
|
95
|
+
direction?: 'left' | 'right' | 'up' | 'down';
|
|
96
|
+
duration?: number; // milliseconds (default: 300)
|
|
97
|
+
easing?: 'linear' | 'ease-in-out' | 'spring';
|
|
48
98
|
}
|
|
49
99
|
|
|
50
100
|
// ─── Condition & Variable Types ───
|
|
@@ -160,6 +210,7 @@ export interface ElementStyle {
|
|
|
160
210
|
export interface OnboardingConfig {
|
|
161
211
|
version: string;
|
|
162
212
|
screens: ScreenConfig[];
|
|
213
|
+
assets?: Array<{ name: string; type: string; data: string }>;
|
|
163
214
|
}
|
|
164
215
|
|
|
165
216
|
// Experiment/A/B test variant
|