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.
@@ -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={() => onAction(element)}
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
- const childProps = { toggledIds, groupSelections, onAction, variables, onSetVariable };
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 key={child.id} element={child} {...childProps} />
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
- return wrapWithAction(
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 key={child.id} element={child} {...childProps} />
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
- return wrapWithAction(
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
- return (
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
- {resolvedText}
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: element.props.url }}
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
- // Video placeholder actual implementation would use expo-av or react-native-video
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
- case 'lottie':
412
- // Lottie placeholder actual implementation would use lottie-react-native
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
- const currentValue = variables[variableName] || '';
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
- if (onSetVariable) {
456
- onSetVariable(variableName, text);
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