noboarding 0.1.0-alpha

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