noboarding 1.0.2-beta → 1.0.6-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.
@@ -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
  );
@@ -388,6 +593,11 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
388
593
 
389
594
  case 'video':
390
595
  // Video placeholder — actual implementation would use expo-av or react-native-video
596
+ // Resolve asset URL if present (for future implementation)
597
+ if (element.props?.url) {
598
+ const resolvedUrl = resolveAssetUrl(element.props.url);
599
+ // TODO: Implement actual video player with resolvedUrl
600
+ }
391
601
  return (
392
602
  <View
393
603
  style={[
@@ -410,6 +620,11 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
410
620
 
411
621
  case 'lottie':
412
622
  // Lottie placeholder — actual implementation would use lottie-react-native
623
+ // Resolve asset URL if present (for future implementation)
624
+ if (element.props?.url) {
625
+ const resolvedUrl = resolveAssetUrl(element.props.url);
626
+ // TODO: Implement actual Lottie player with resolvedUrl
627
+ }
413
628
  return (
414
629
  <View
415
630
  style={[
@@ -441,7 +656,8 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
441
656
 
442
657
  // Get the variable name - use props.variable if specified, otherwise use element.id
443
658
  const variableName = element.props?.variable || element.id;
444
- const currentValue = variables[variableName] || '';
659
+ // Use local state value, or fall back to variables, or empty string
660
+ const currentValue = inputValues[variableName] ?? variables[variableName] ?? '';
445
661
 
446
662
  return (
447
663
  <TextInput
@@ -452,9 +668,8 @@ const RenderNode: React.FC<RenderNodeProps> = ({ element, toggledIds, groupSelec
452
668
  autoCapitalize={element.props?.type === 'email' ? 'none' : 'sentences'}
453
669
  value={currentValue}
454
670
  onChangeText={(text) => {
455
- if (onSetVariable) {
456
- onSetVariable(variableName, text);
457
- }
671
+ // Save to local state only - don't trigger parent re-render
672
+ setInputValues(prev => ({ ...prev, [variableName]: text }));
458
673
  }}
459
674
  />
460
675
  );
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
@@ -189,6 +240,7 @@ export interface AnalyticsEvent {
189
240
  export interface GetConfigResponse {
190
241
  config: OnboardingConfig;
191
242
  version: string;
243
+ config_id: string | null;
192
244
  experiments: Experiment[];
193
245
  organization_id: string;
194
246
  }