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
|
@@ -48,6 +48,7 @@ exports.ElementRenderer = void 0;
|
|
|
48
48
|
const react_1 = __importStar(require("react"));
|
|
49
49
|
const react_native_1 = require("react-native");
|
|
50
50
|
const variableUtils_1 = require("../variableUtils");
|
|
51
|
+
const animationUtils_1 = require("../animationUtils");
|
|
51
52
|
const expo_linear_gradient_1 = require("expo-linear-gradient");
|
|
52
53
|
const vector_icons_1 = require("@expo/vector-icons");
|
|
53
54
|
const IconSets = {
|
|
@@ -59,11 +60,26 @@ const IconSets = {
|
|
|
59
60
|
fontawesome: vector_icons_1.FontAwesome,
|
|
60
61
|
'sf-symbols': vector_icons_1.Ionicons,
|
|
61
62
|
};
|
|
62
|
-
const ElementRenderer = ({ elements, analytics, screenId, onNavigate, onDismiss, variables = {}, onSetVariable, }) => {
|
|
63
|
+
const ElementRenderer = ({ elements, analytics, screenId, onNavigate, onDismiss, variables = {}, onSetVariable, assets = [], }) => {
|
|
63
64
|
// Track toggled element IDs for toggle actions
|
|
64
65
|
const [toggledIds, setToggledIds] = (0, react_1.useState)(new Set());
|
|
65
66
|
// Track selection groups: group name → selected element ID
|
|
66
67
|
const [groupSelections, setGroupSelections] = (0, react_1.useState)({});
|
|
68
|
+
// Track text input values locally (uncontrolled)
|
|
69
|
+
const [inputValues, setInputValues] = (0, react_1.useState)({});
|
|
70
|
+
// Helper function to resolve asset: URLs to actual data URLs
|
|
71
|
+
const resolveAssetUrl = (url) => {
|
|
72
|
+
if (!url || !url.startsWith('asset:')) {
|
|
73
|
+
return url; // Return as-is if not an asset reference
|
|
74
|
+
}
|
|
75
|
+
const assetName = url.replace('asset:', '');
|
|
76
|
+
const asset = assets.find(a => a.name === assetName);
|
|
77
|
+
if (asset) {
|
|
78
|
+
return asset.data; // Return the base64 data URL
|
|
79
|
+
}
|
|
80
|
+
console.warn(`Asset not found: ${assetName}`);
|
|
81
|
+
return url; // Fallback to original URL
|
|
82
|
+
};
|
|
67
83
|
const executeAction = (0, react_1.useCallback)((action, element) => {
|
|
68
84
|
// Track the action
|
|
69
85
|
analytics === null || analytics === void 0 ? void 0 : analytics.track('element_action', {
|
|
@@ -77,6 +93,12 @@ const ElementRenderer = ({ elements, analytics, screenId, onNavigate, onDismiss,
|
|
|
77
93
|
if (action.variable !== undefined && onSetVariable) {
|
|
78
94
|
onSetVariable(action.variable, action.value);
|
|
79
95
|
}
|
|
96
|
+
// Also save all current text input values when any action is triggered
|
|
97
|
+
if (onSetVariable) {
|
|
98
|
+
Object.entries(inputValues).forEach(([key, value]) => {
|
|
99
|
+
onSetVariable(key, value);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
80
102
|
break;
|
|
81
103
|
case 'toggle': {
|
|
82
104
|
const group = action.group;
|
|
@@ -113,6 +135,12 @@ const ElementRenderer = ({ elements, analytics, screenId, onNavigate, onDismiss,
|
|
|
113
135
|
break;
|
|
114
136
|
}
|
|
115
137
|
case 'navigate':
|
|
138
|
+
// Save all text input values before navigating
|
|
139
|
+
if (onSetVariable) {
|
|
140
|
+
Object.entries(inputValues).forEach(([key, value]) => {
|
|
141
|
+
onSetVariable(key, value);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
116
144
|
if (onNavigate && action.destination) {
|
|
117
145
|
onNavigate(action.destination);
|
|
118
146
|
}
|
|
@@ -129,7 +157,7 @@ const ElementRenderer = ({ elements, analytics, screenId, onNavigate, onDismiss,
|
|
|
129
157
|
// Generic tap — analytics already tracked above
|
|
130
158
|
break;
|
|
131
159
|
}
|
|
132
|
-
}, [groupSelections, onNavigate, onDismiss, analytics, screenId, onSetVariable]);
|
|
160
|
+
}, [groupSelections, onNavigate, onDismiss, analytics, screenId, onSetVariable, inputValues]);
|
|
133
161
|
const handleAction = (0, react_1.useCallback)((element) => {
|
|
134
162
|
// Execute single action (backward compatible)
|
|
135
163
|
if (element.action) {
|
|
@@ -146,18 +174,35 @@ const ElementRenderer = ({ elements, analytics, screenId, onNavigate, onDismiss,
|
|
|
146
174
|
return null;
|
|
147
175
|
}
|
|
148
176
|
return (<>
|
|
149
|
-
{elements.map((element) => (<RenderNode key={element.id} element={element} toggledIds={toggledIds} groupSelections={groupSelections} onAction={handleAction} variables={variables}/>))}
|
|
177
|
+
{elements.map((element) => (<RenderNode key={element.id} element={element} toggledIds={toggledIds} groupSelections={groupSelections} onAction={handleAction} variables={variables} inputValues={inputValues} setInputValues={setInputValues} resolveAssetUrl={resolveAssetUrl}/>))}
|
|
150
178
|
</>);
|
|
151
179
|
};
|
|
152
180
|
exports.ElementRenderer = ElementRenderer;
|
|
153
|
-
const RenderNode = ({ element, toggledIds, groupSelections, onAction, variables, onSetVariable }) => {
|
|
154
|
-
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2, _3, _4, _5, _6;
|
|
181
|
+
const RenderNode = ({ element, toggledIds, groupSelections, onAction, variables, onSetVariable, inputValues, setInputValues, staggerDelay = 0, resolveAssetUrl }) => {
|
|
182
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19, _20;
|
|
155
183
|
// Variable-based conditions — hide element if condition is not met
|
|
156
184
|
if ((_a = element.conditions) === null || _a === void 0 ? void 0 : _a.show_if) {
|
|
157
185
|
const shouldShow = (0, variableUtils_1.evaluateCondition)(element.conditions.show_if, variables);
|
|
158
186
|
if (!shouldShow)
|
|
159
187
|
return null;
|
|
160
188
|
}
|
|
189
|
+
// ─── Animation State ───
|
|
190
|
+
const entranceValues = (0, react_1.useRef)((0, animationUtils_1.createEntranceAnimationValues)()).current;
|
|
191
|
+
const interactiveValue = (0, react_1.useRef)(new react_native_1.Animated.Value(1)).current;
|
|
192
|
+
const [hasAnimated, setHasAnimated] = (0, react_1.useState)(false);
|
|
193
|
+
// Start entrance animation on mount
|
|
194
|
+
(0, react_1.useEffect)(() => {
|
|
195
|
+
if (element.entrance && element.entrance.type !== 'none' && !hasAnimated) {
|
|
196
|
+
(0, animationUtils_1.startEntranceAnimation)(element.entrance, entranceValues, staggerDelay);
|
|
197
|
+
setHasAnimated(true);
|
|
198
|
+
}
|
|
199
|
+
}, [element.entrance, hasAnimated, staggerDelay]);
|
|
200
|
+
// Start auto-triggered interactive animations
|
|
201
|
+
(0, react_1.useEffect)(() => {
|
|
202
|
+
if (element.interactive && element.interactive.trigger === 'load' && element.interactive.type !== 'none') {
|
|
203
|
+
(0, animationUtils_1.startInteractiveAnimation)(element.interactive, interactiveValue);
|
|
204
|
+
}
|
|
205
|
+
}, [element.interactive]);
|
|
161
206
|
const style = convertStyle(element.style || {});
|
|
162
207
|
const isToggled = toggledIds.has(element.id);
|
|
163
208
|
// Apply toggle visual state
|
|
@@ -192,32 +237,85 @@ const RenderNode = ({ element, toggledIds, groupSelections, onAction, variables,
|
|
|
192
237
|
wrapperStyle.width = style.width;
|
|
193
238
|
if (style.alignSelf)
|
|
194
239
|
wrapperStyle.alignSelf = style.alignSelf;
|
|
195
|
-
|
|
240
|
+
const handlePress = () => {
|
|
241
|
+
// Trigger interactive animation if configured
|
|
242
|
+
if (element.interactive && element.interactive.trigger === 'tap' && element.interactive.type !== 'none') {
|
|
243
|
+
(0, animationUtils_1.startInteractiveAnimation)(element.interactive, interactiveValue);
|
|
244
|
+
}
|
|
245
|
+
onAction(element);
|
|
246
|
+
};
|
|
247
|
+
return (<react_native_1.TouchableOpacity key={element.id} activeOpacity={0.7} onPress={handlePress} style={wrapperStyle}>
|
|
196
248
|
{content}
|
|
197
249
|
</react_native_1.TouchableOpacity>);
|
|
198
250
|
};
|
|
199
|
-
|
|
251
|
+
// Wrapper for entrance animations
|
|
252
|
+
const wrapWithEntranceAnimation = (content) => {
|
|
253
|
+
if (!element.entrance || element.entrance.type === 'none') {
|
|
254
|
+
return content;
|
|
255
|
+
}
|
|
256
|
+
// Build animated style based on entrance type
|
|
257
|
+
const animatedStyle = {
|
|
258
|
+
opacity: entranceValues.opacity,
|
|
259
|
+
};
|
|
260
|
+
// Apply scale for scale animations
|
|
261
|
+
if (element.entrance.type === 'scaleIn') {
|
|
262
|
+
animatedStyle.transform = [{ scale: entranceValues.scale }];
|
|
263
|
+
}
|
|
264
|
+
// Apply translate for slide animations
|
|
265
|
+
else if (element.entrance.type === 'slideUp' || element.entrance.type === 'slideDown') {
|
|
266
|
+
animatedStyle.transform = [{ translateY: entranceValues.translateY }];
|
|
267
|
+
}
|
|
268
|
+
else if (element.entrance.type === 'slideLeft' || element.entrance.type === 'slideRight') {
|
|
269
|
+
animatedStyle.transform = [{ translateX: entranceValues.translateX }];
|
|
270
|
+
}
|
|
271
|
+
return <react_native_1.Animated.View style={animatedStyle}>{content}</react_native_1.Animated.View>;
|
|
272
|
+
};
|
|
273
|
+
// Wrapper for interactive animations
|
|
274
|
+
const wrapWithInteractiveAnimation = (content) => {
|
|
275
|
+
if (!element.interactive || element.interactive.type === 'none') {
|
|
276
|
+
return content;
|
|
277
|
+
}
|
|
278
|
+
const animatedStyle = {};
|
|
279
|
+
switch (element.interactive.type) {
|
|
280
|
+
case 'scale':
|
|
281
|
+
case 'pulse':
|
|
282
|
+
animatedStyle.transform = [{ scale: interactiveValue }];
|
|
283
|
+
break;
|
|
284
|
+
case 'shake':
|
|
285
|
+
animatedStyle.transform = [{ translateX: interactiveValue }];
|
|
286
|
+
break;
|
|
287
|
+
case 'bounce':
|
|
288
|
+
animatedStyle.transform = [{ translateY: interactiveValue }];
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
return <react_native_1.Animated.View style={animatedStyle}>{content}</react_native_1.Animated.View>;
|
|
292
|
+
};
|
|
293
|
+
const childProps = { toggledIds, groupSelections, onAction, variables, onSetVariable, inputValues, setInputValues, resolveAssetUrl };
|
|
200
294
|
switch (element.type) {
|
|
201
295
|
// ─── Containers ───
|
|
202
296
|
case 'vstack': {
|
|
297
|
+
const stagger = ((_f = element.entrance) === null || _f === void 0 ? void 0 : _f.stagger) || 0;
|
|
203
298
|
const vstackContent = (<react_native_1.View style={[style, { flexDirection: 'column' }]}>
|
|
204
|
-
{(
|
|
299
|
+
{(_g = element.children) === null || _g === void 0 ? void 0 : _g.map((child, index) => (<RenderNode key={child.id} element={child} {...childProps} staggerDelay={stagger > 0 ? index * stagger : 0}/>))}
|
|
205
300
|
</react_native_1.View>);
|
|
206
|
-
|
|
301
|
+
const wrapped = wrapWithAction(((_h = element.style) === null || _h === void 0 ? void 0 : _h.backgroundGradient)
|
|
207
302
|
? wrapWithGradient(vstackContent, element.style, Object.assign(Object.assign({}, style), { flexDirection: 'column' }))
|
|
208
303
|
: vstackContent);
|
|
304
|
+
return wrapWithInteractiveAnimation(wrapWithEntranceAnimation(wrapped));
|
|
209
305
|
}
|
|
210
306
|
case 'hstack': {
|
|
307
|
+
const stagger = ((_j = element.entrance) === null || _j === void 0 ? void 0 : _j.stagger) || 0;
|
|
211
308
|
const hstackContent = (<react_native_1.View style={[style, { flexDirection: 'row' }]}>
|
|
212
|
-
{(
|
|
309
|
+
{(_k = element.children) === null || _k === void 0 ? void 0 : _k.map((child, index) => (<RenderNode key={child.id} element={child} {...childProps} staggerDelay={stagger > 0 ? index * stagger : 0}/>))}
|
|
213
310
|
</react_native_1.View>);
|
|
214
|
-
|
|
311
|
+
const wrapped = wrapWithAction(((_l = element.style) === null || _l === void 0 ? void 0 : _l.backgroundGradient)
|
|
215
312
|
? wrapWithGradient(hstackContent, element.style, Object.assign(Object.assign({}, style), { flexDirection: 'row' }))
|
|
216
313
|
: hstackContent);
|
|
314
|
+
return wrapWithInteractiveAnimation(wrapWithEntranceAnimation(wrapped));
|
|
217
315
|
}
|
|
218
316
|
case 'zstack': {
|
|
219
317
|
const zstackContent = (<react_native_1.View style={style}>
|
|
220
|
-
{(
|
|
318
|
+
{(_m = element.children) === null || _m === void 0 ? void 0 : _m.map((child, index) => {
|
|
221
319
|
var _a;
|
|
222
320
|
const childStyle = convertStyle(child.style || {});
|
|
223
321
|
if (index > 0 && !((_a = child.position) === null || _a === void 0 ? void 0 : _a.type)) {
|
|
@@ -228,34 +326,85 @@ const RenderNode = ({ element, toggledIds, groupSelections, onAction, variables,
|
|
|
228
326
|
return <RenderNode key={child.id} element={child} {...childProps}/>;
|
|
229
327
|
})}
|
|
230
328
|
</react_native_1.View>);
|
|
231
|
-
return wrapWithAction(((
|
|
329
|
+
return wrapWithAction(((_o = element.style) === null || _o === void 0 ? void 0 : _o.backgroundGradient)
|
|
232
330
|
? wrapWithGradient(zstackContent, element.style, style)
|
|
233
331
|
: zstackContent);
|
|
234
332
|
}
|
|
235
333
|
case 'scrollview': {
|
|
236
|
-
const isHorizontal = ((
|
|
334
|
+
const isHorizontal = ((_p = element.props) === null || _p === void 0 ? void 0 : _p.direction) === 'horizontal';
|
|
237
335
|
return (<react_native_1.ScrollView style={style} horizontal={isHorizontal} showsVerticalScrollIndicator={false} showsHorizontalScrollIndicator={false}>
|
|
238
|
-
{isHorizontal ? (<react_native_1.View style={{ flexDirection: 'row', gap: (
|
|
239
|
-
{(
|
|
240
|
-
</react_native_1.View>) : ((
|
|
336
|
+
{isHorizontal ? (<react_native_1.View style={{ flexDirection: 'row', gap: (_q = element.style) === null || _q === void 0 ? void 0 : _q.gap }}>
|
|
337
|
+
{(_r = element.children) === null || _r === void 0 ? void 0 : _r.map((child) => (<RenderNode key={child.id} element={child} {...childProps}/>))}
|
|
338
|
+
</react_native_1.View>) : ((_s = element.children) === null || _s === void 0 ? void 0 : _s.map((child) => (<RenderNode key={child.id} element={child} {...childProps}/>)))}
|
|
241
339
|
</react_native_1.ScrollView>);
|
|
242
340
|
}
|
|
243
341
|
// ─── Content Elements ───
|
|
244
342
|
case 'text': {
|
|
245
|
-
const resolvedText = (0, variableUtils_1.resolveTemplate)(((
|
|
246
|
-
|
|
247
|
-
|
|
343
|
+
const resolvedText = (0, variableUtils_1.resolveTemplate)(((_t = element.props) === null || _t === void 0 ? void 0 : _t.text) || '', variables);
|
|
344
|
+
// Typewriter animation support
|
|
345
|
+
const [displayedText, setDisplayedText] = (0, react_1.useState)('');
|
|
346
|
+
const [showCursor, setShowCursor] = (0, react_1.useState)(false);
|
|
347
|
+
const typewriterInterval = (0, react_1.useRef)(null);
|
|
348
|
+
(0, react_1.useEffect)(() => {
|
|
349
|
+
const textAnim = element.textAnimation;
|
|
350
|
+
if (textAnim && textAnim.type === 'typewriter') {
|
|
351
|
+
const speed = textAnim.speed || 20; // chars per second
|
|
352
|
+
const delay = textAnim.delay || 0;
|
|
353
|
+
const haptic = textAnim.haptic;
|
|
354
|
+
let currentIndex = 0;
|
|
355
|
+
// Show cursor if enabled
|
|
356
|
+
if (textAnim.cursor) {
|
|
357
|
+
setShowCursor(true);
|
|
358
|
+
}
|
|
359
|
+
// Start typewriter animation after delay
|
|
360
|
+
const timeoutId = setTimeout(() => {
|
|
361
|
+
typewriterInterval.current = setInterval(() => {
|
|
362
|
+
if (currentIndex >= resolvedText.length) {
|
|
363
|
+
if (typewriterInterval.current) {
|
|
364
|
+
clearInterval(typewriterInterval.current);
|
|
365
|
+
}
|
|
366
|
+
// Hide cursor when done
|
|
367
|
+
if (textAnim.cursor) {
|
|
368
|
+
setShowCursor(false);
|
|
369
|
+
}
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
setDisplayedText(resolvedText.substring(0, currentIndex + 1));
|
|
373
|
+
// Trigger haptic if enabled
|
|
374
|
+
if (haptic && haptic.enabled && (0, animationUtils_1.shouldTriggerHaptic)(currentIndex, haptic.frequency)) {
|
|
375
|
+
(0, animationUtils_1.triggerHaptic)(haptic.type);
|
|
376
|
+
}
|
|
377
|
+
currentIndex++;
|
|
378
|
+
}, 1000 / speed);
|
|
379
|
+
}, delay);
|
|
380
|
+
return () => {
|
|
381
|
+
clearTimeout(timeoutId);
|
|
382
|
+
if (typewriterInterval.current) {
|
|
383
|
+
clearInterval(typewriterInterval.current);
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
// No typewriter - show full text immediately
|
|
389
|
+
setDisplayedText(resolvedText);
|
|
390
|
+
}
|
|
391
|
+
}, [resolvedText, element.textAnimation]);
|
|
392
|
+
const textContent = ((_u = element.textAnimation) === null || _u === void 0 ? void 0 : _u.type) === 'typewriter' ? displayedText : resolvedText;
|
|
393
|
+
const textElement = (<react_native_1.Text style={style}>
|
|
394
|
+
{textContent}
|
|
395
|
+
{showCursor && (<react_native_1.Text style={{ opacity: 0.5 }}>|</react_native_1.Text>)}
|
|
248
396
|
</react_native_1.Text>);
|
|
397
|
+
return wrapWithInteractiveAnimation(wrapWithEntranceAnimation(wrapWithAction(textElement)));
|
|
249
398
|
}
|
|
250
399
|
case 'icon': {
|
|
251
|
-
if ((
|
|
400
|
+
if ((_v = element.props) === null || _v === void 0 ? void 0 : _v.emoji) {
|
|
252
401
|
return (<react_native_1.Text style={[style, { textAlign: 'center' }]}>
|
|
253
402
|
{element.props.emoji}
|
|
254
403
|
</react_native_1.Text>);
|
|
255
404
|
}
|
|
256
405
|
// Try to render a real vector icon
|
|
257
|
-
const library = (((
|
|
258
|
-
const iconName = (
|
|
406
|
+
const library = (((_w = element.props) === null || _w === void 0 ? void 0 : _w.library) || 'material').toLowerCase();
|
|
407
|
+
const iconName = (_x = element.props) === null || _x === void 0 ? void 0 : _x.name;
|
|
259
408
|
const IconComponent = IconSets[library];
|
|
260
409
|
if (IconComponent && iconName) {
|
|
261
410
|
const iconSize = style.fontSize || 24;
|
|
@@ -275,13 +424,14 @@ const RenderNode = ({ element, toggledIds, groupSelections, onAction, variables,
|
|
|
275
424
|
},
|
|
276
425
|
]}>
|
|
277
426
|
<react_native_1.Text style={{ fontSize: 10, color: '#666' }}>
|
|
278
|
-
{((
|
|
427
|
+
{((_y = element.props) === null || _y === void 0 ? void 0 : _y.name) || '●'}
|
|
279
428
|
</react_native_1.Text>
|
|
280
429
|
</react_native_1.View>);
|
|
281
430
|
}
|
|
282
431
|
case 'image':
|
|
283
|
-
if ((
|
|
284
|
-
|
|
432
|
+
if ((_z = element.props) === null || _z === void 0 ? void 0 : _z.url) {
|
|
433
|
+
const resolvedUrl = resolveAssetUrl(element.props.url);
|
|
434
|
+
return (<react_native_1.Image source={{ uri: resolvedUrl }} style={[style, { resizeMode: 'cover' }]}/>);
|
|
285
435
|
}
|
|
286
436
|
// Placeholder for images without URL
|
|
287
437
|
return (<react_native_1.View style={[
|
|
@@ -293,12 +443,25 @@ const RenderNode = ({ element, toggledIds, groupSelections, onAction, variables,
|
|
|
293
443
|
},
|
|
294
444
|
]}>
|
|
295
445
|
<react_native_1.Text style={{ fontSize: 48 }}>🖼️</react_native_1.Text>
|
|
296
|
-
{((
|
|
446
|
+
{((_0 = element.props) === null || _0 === void 0 ? void 0 : _0.imageDescription) && (<react_native_1.Text style={{ fontSize: 11, color: '#666', textAlign: 'center', padding: 8 }}>
|
|
297
447
|
{element.props.imageDescription}
|
|
298
448
|
</react_native_1.Text>)}
|
|
299
449
|
</react_native_1.View>);
|
|
300
|
-
case 'video':
|
|
301
|
-
|
|
450
|
+
case 'video': {
|
|
451
|
+
const videoUrl = ((_1 = element.props) === null || _1 === void 0 ? void 0 : _1.url) ? resolveAssetUrl(element.props.url) : null;
|
|
452
|
+
let VideoComponent = null;
|
|
453
|
+
try {
|
|
454
|
+
const expoAv = require('expo-av');
|
|
455
|
+
VideoComponent = expoAv.Video;
|
|
456
|
+
}
|
|
457
|
+
catch (_21) { }
|
|
458
|
+
if (VideoComponent && videoUrl) {
|
|
459
|
+
const resizeMode = ((_2 = element.props) === null || _2 === void 0 ? void 0 : _2.resizeMode) || 'contain';
|
|
460
|
+
return (<react_native_1.View style={[style, { backgroundColor: style.backgroundColor || '#1a1a1a', overflow: 'hidden' }]}>
|
|
461
|
+
<VideoComponent source={{ uri: videoUrl }} style={{ width: '100%', height: '100%' }} resizeMode={resizeMode} shouldPlay={((_3 = element.props) === null || _3 === void 0 ? void 0 : _3.autoPlay) !== false} isLooping={((_4 = element.props) === null || _4 === void 0 ? void 0 : _4.loop) !== false} isMuted={((_5 = element.props) === null || _5 === void 0 ? void 0 : _5.muted) !== false} useNativeControls={false}/>
|
|
462
|
+
</react_native_1.View>);
|
|
463
|
+
}
|
|
464
|
+
// Fallback when expo-av is unavailable or no URL
|
|
302
465
|
return (<react_native_1.View style={[
|
|
303
466
|
style,
|
|
304
467
|
{
|
|
@@ -308,12 +471,39 @@ const RenderNode = ({ element, toggledIds, groupSelections, onAction, variables,
|
|
|
308
471
|
},
|
|
309
472
|
]}>
|
|
310
473
|
<react_native_1.Text style={{ fontSize: 48 }}>🎬</react_native_1.Text>
|
|
311
|
-
{((
|
|
474
|
+
{((_6 = element.props) === null || _6 === void 0 ? void 0 : _6.videoDescription) && (<react_native_1.Text style={{ fontSize: 11, color: '#aaa', textAlign: 'center', padding: 8 }}>
|
|
312
475
|
{element.props.videoDescription}
|
|
313
476
|
</react_native_1.Text>)}
|
|
314
477
|
</react_native_1.View>);
|
|
315
|
-
|
|
316
|
-
|
|
478
|
+
}
|
|
479
|
+
case 'lottie': {
|
|
480
|
+
const lottieUrl = ((_7 = element.props) === null || _7 === void 0 ? void 0 : _7.url) ? resolveAssetUrl(element.props.url) : null;
|
|
481
|
+
let LottieView = null;
|
|
482
|
+
try {
|
|
483
|
+
LottieView = require('lottie-react-native').default;
|
|
484
|
+
}
|
|
485
|
+
catch (_22) { }
|
|
486
|
+
if (LottieView && lottieUrl) {
|
|
487
|
+
// Decode base64 data URLs to get the Lottie JSON object
|
|
488
|
+
let lottieSource;
|
|
489
|
+
if (lottieUrl.startsWith('data:application/json;base64,')) {
|
|
490
|
+
try {
|
|
491
|
+
const base64Data = lottieUrl.replace('data:application/json;base64,', '');
|
|
492
|
+
const jsonString = atob(base64Data);
|
|
493
|
+
lottieSource = JSON.parse(jsonString);
|
|
494
|
+
}
|
|
495
|
+
catch (_23) {
|
|
496
|
+
lottieSource = { uri: lottieUrl };
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
lottieSource = { uri: lottieUrl };
|
|
501
|
+
}
|
|
502
|
+
return (<react_native_1.View style={[style, { backgroundColor: style.backgroundColor || '#f8f8ff', overflow: 'hidden' }]}>
|
|
503
|
+
<LottieView source={lottieSource} style={{ width: '100%', height: '100%' }} autoPlay={((_8 = element.props) === null || _8 === void 0 ? void 0 : _8.autoPlay) !== false} loop={((_9 = element.props) === null || _9 === void 0 ? void 0 : _9.loop) !== false} speed={((_10 = element.props) === null || _10 === void 0 ? void 0 : _10.speed) || 1}/>
|
|
504
|
+
</react_native_1.View>);
|
|
505
|
+
}
|
|
506
|
+
// Fallback when lottie-react-native is unavailable or no URL
|
|
317
507
|
return (<react_native_1.View style={[
|
|
318
508
|
style,
|
|
319
509
|
{
|
|
@@ -323,25 +513,26 @@ const RenderNode = ({ element, toggledIds, groupSelections, onAction, variables,
|
|
|
323
513
|
},
|
|
324
514
|
]}>
|
|
325
515
|
<react_native_1.Text style={{ fontSize: 48 }}>✨</react_native_1.Text>
|
|
326
|
-
{((
|
|
516
|
+
{((_11 = element.props) === null || _11 === void 0 ? void 0 : _11.animationDescription) && (<react_native_1.Text style={{ fontSize: 11, color: '#666', textAlign: 'center', padding: 8 }}>
|
|
327
517
|
{element.props.animationDescription}
|
|
328
518
|
</react_native_1.Text>)}
|
|
329
519
|
</react_native_1.View>);
|
|
520
|
+
}
|
|
330
521
|
case 'input': {
|
|
331
522
|
// Only apply default border if borderWidth is not explicitly defined (including 0)
|
|
332
523
|
const inputStyle = style;
|
|
333
524
|
const defaultInputStyle = {};
|
|
334
|
-
if (((
|
|
525
|
+
if (((_12 = element.style) === null || _12 === void 0 ? void 0 : _12.borderWidth) === undefined && ((_13 = element.style) === null || _13 === void 0 ? void 0 : _13.borderColor) === undefined) {
|
|
335
526
|
defaultInputStyle.borderWidth = 1;
|
|
336
527
|
defaultInputStyle.borderColor = '#E5E5E5';
|
|
337
528
|
}
|
|
338
529
|
// Get the variable name - use props.variable if specified, otherwise use element.id
|
|
339
|
-
const variableName = ((
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}
|
|
530
|
+
const variableName = ((_14 = element.props) === null || _14 === void 0 ? void 0 : _14.variable) || element.id;
|
|
531
|
+
// Use local state value, or fall back to variables, or empty string
|
|
532
|
+
const currentValue = (_16 = (_15 = inputValues[variableName]) !== null && _15 !== void 0 ? _15 : variables[variableName]) !== null && _16 !== void 0 ? _16 : '';
|
|
533
|
+
return (<react_native_1.TextInput style={[defaultInputStyle, inputStyle]} placeholder={((_17 = element.props) === null || _17 === void 0 ? void 0 : _17.placeholder) || 'Enter text...'} keyboardType={getKeyboardType((_18 = element.props) === null || _18 === void 0 ? void 0 : _18.type)} secureTextEntry={((_19 = element.props) === null || _19 === void 0 ? void 0 : _19.type) === 'password'} autoCapitalize={((_20 = element.props) === null || _20 === void 0 ? void 0 : _20.type) === 'email' ? 'none' : 'sentences'} value={currentValue} onChangeText={(text) => {
|
|
534
|
+
// Save to local state only - don't trigger parent re-render
|
|
535
|
+
setInputValues(prev => (Object.assign(Object.assign({}, prev), { [variableName]: text })));
|
|
345
536
|
}}/>);
|
|
346
537
|
}
|
|
347
538
|
case 'spacer':
|
package/lib/types.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export interface ScreenConfig {
|
|
|
7
7
|
elements?: ElementNode[];
|
|
8
8
|
custom_component_name?: string;
|
|
9
9
|
hidden?: boolean;
|
|
10
|
+
transition?: ScreenTransition;
|
|
10
11
|
}
|
|
11
12
|
export type ElementType = 'vstack' | 'hstack' | 'zstack' | 'scrollview' | 'text' | 'image' | 'video' | 'lottie' | 'icon' | 'input' | 'spacer' | 'divider';
|
|
12
13
|
export interface ElementNode {
|
|
@@ -23,6 +24,46 @@ export interface ElementNode {
|
|
|
23
24
|
hasSelection: boolean;
|
|
24
25
|
};
|
|
25
26
|
conditions?: ElementConditions;
|
|
27
|
+
entrance?: EntranceAnimation;
|
|
28
|
+
interactive?: InteractiveAnimation;
|
|
29
|
+
textAnimation?: TextAnimation;
|
|
30
|
+
}
|
|
31
|
+
export type EntranceAnimationType = 'fadeIn' | 'slideUp' | 'slideDown' | 'slideLeft' | 'slideRight' | 'scaleIn' | 'none';
|
|
32
|
+
export type InteractiveAnimationType = 'scale' | 'pulse' | 'shake' | 'bounce' | 'none';
|
|
33
|
+
export type HapticType = 'light' | 'medium' | 'heavy' | 'success' | 'warning' | 'error';
|
|
34
|
+
export type HapticFrequency = 'every' | 'every-2' | 'every-3' | 'every-5';
|
|
35
|
+
export interface EntranceAnimation {
|
|
36
|
+
type: EntranceAnimationType;
|
|
37
|
+
duration?: number;
|
|
38
|
+
delay?: number;
|
|
39
|
+
stagger?: number;
|
|
40
|
+
easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'spring';
|
|
41
|
+
}
|
|
42
|
+
export interface InteractiveAnimation {
|
|
43
|
+
type: InteractiveAnimationType;
|
|
44
|
+
trigger?: 'tap' | 'load';
|
|
45
|
+
duration?: number;
|
|
46
|
+
intensity?: number;
|
|
47
|
+
repeat?: boolean;
|
|
48
|
+
haptic?: boolean;
|
|
49
|
+
hapticType?: HapticType;
|
|
50
|
+
}
|
|
51
|
+
export interface TextAnimation {
|
|
52
|
+
type: 'typewriter' | 'none';
|
|
53
|
+
speed?: number;
|
|
54
|
+
delay?: number;
|
|
55
|
+
cursor?: boolean;
|
|
56
|
+
haptic?: {
|
|
57
|
+
enabled: boolean;
|
|
58
|
+
type: HapticType;
|
|
59
|
+
frequency: HapticFrequency;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export interface ScreenTransition {
|
|
63
|
+
type: 'push' | 'modal' | 'fade' | 'slide' | 'none';
|
|
64
|
+
direction?: 'left' | 'right' | 'up' | 'down';
|
|
65
|
+
duration?: number;
|
|
66
|
+
easing?: 'linear' | 'ease-in-out' | 'spring';
|
|
26
67
|
}
|
|
27
68
|
export type ComparisonOperator = 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'in' | 'is_empty' | 'is_not_empty';
|
|
28
69
|
export interface Condition {
|
|
@@ -119,6 +160,11 @@ export interface ElementStyle {
|
|
|
119
160
|
export interface OnboardingConfig {
|
|
120
161
|
version: string;
|
|
121
162
|
screens: ScreenConfig[];
|
|
163
|
+
assets?: Array<{
|
|
164
|
+
name: string;
|
|
165
|
+
type: string;
|
|
166
|
+
data: string;
|
|
167
|
+
}>;
|
|
122
168
|
}
|
|
123
169
|
export interface Experiment {
|
|
124
170
|
id: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "noboarding",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Expo SDK for remote onboarding flow management",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@react-native-async-storage/async-storage": "^1.19.0",
|
|
33
33
|
"expo-linear-gradient": ">=12.0.0",
|
|
34
|
-
"@expo/vector-icons": ">=14.0.0"
|
|
34
|
+
"@expo/vector-icons": ">=14.0.0",
|
|
35
|
+
"expo-av": ">=14.0.0",
|
|
36
|
+
"lottie-react-native": ">=6.0.0"
|
|
35
37
|
},
|
|
36
38
|
"devDependencies": {
|
|
37
39
|
"@types/node": "^25.2.3",
|
package/src/OnboardingFlow.tsx
CHANGED
|
@@ -74,6 +74,7 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
74
74
|
const [loading, setLoading] = useState(true);
|
|
75
75
|
const [error, setError] = useState<string | null>(null);
|
|
76
76
|
const [screens, setScreens] = useState<ScreenConfig[]>([]);
|
|
77
|
+
const [assets, setAssets] = useState<Array<{ name: string; type: string; data: string }>>([]);
|
|
77
78
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
78
79
|
const [collectedData, setCollectedData] = useState<Record<string, any>>({});
|
|
79
80
|
const [variables, setVariables] = useState<Record<string, any>>(initialVariables || {});
|
|
@@ -149,6 +150,11 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
149
150
|
// Store flow_id for analytics
|
|
150
151
|
flowIdRef.current = configResponse.config_id;
|
|
151
152
|
|
|
153
|
+
// Store assets from config
|
|
154
|
+
if (configResponse.config.assets) {
|
|
155
|
+
setAssets(configResponse.config.assets);
|
|
156
|
+
}
|
|
157
|
+
|
|
152
158
|
// Handle A/B test experiment assignment
|
|
153
159
|
let screensToUse = configResponse.config.screens;
|
|
154
160
|
|
|
@@ -161,12 +167,24 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
161
167
|
userIdRef.current!
|
|
162
168
|
);
|
|
163
169
|
|
|
170
|
+
console.log('🧪 A/B Test Assignment:', {
|
|
171
|
+
experiment_id: experiment.id,
|
|
172
|
+
experiment_name: experiment.name,
|
|
173
|
+
variant_id: assignment.variant_id,
|
|
174
|
+
has_variant_screens: assignment.variant_config?.screens?.length > 0,
|
|
175
|
+
variant_screen_count: assignment.variant_config?.screens?.length || 0,
|
|
176
|
+
cached: assignment.cached,
|
|
177
|
+
});
|
|
178
|
+
|
|
164
179
|
// Set experiment context so all events get tagged
|
|
165
180
|
analytics.setExperimentContext(experiment.id, assignment.variant_id);
|
|
166
181
|
|
|
167
182
|
// Use variant screens if available
|
|
168
183
|
if (assignment.variant_config?.screens?.length > 0) {
|
|
169
184
|
screensToUse = assignment.variant_config.screens;
|
|
185
|
+
console.log('📱 Using variant screens:', assignment.variant_config.screens.length, 'screens');
|
|
186
|
+
} else {
|
|
187
|
+
console.log('📱 Using base flow screens (variant has no screens defined)');
|
|
170
188
|
}
|
|
171
189
|
} catch (err) {
|
|
172
190
|
console.warn('Failed to assign experiment variant, using default flow:', err);
|
|
@@ -339,6 +357,7 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
339
357
|
onDismiss={onSkip ? handleSkipAll : handleNext}
|
|
340
358
|
variables={allVariables}
|
|
341
359
|
onSetVariable={handleSetVariable}
|
|
360
|
+
assets={assets}
|
|
342
361
|
/>
|
|
343
362
|
</View>
|
|
344
363
|
);
|