@namiml/expo-sdk 3.4.0-dev.202605060437

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.
Files changed (60) hide show
  1. package/dist/index.cjs +4000 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.d.ts +151 -0
  4. package/dist/index.mjs +3966 -0
  5. package/dist/index.mjs.map +1 -0
  6. package/nami-expo-nami-iap.tgz +0 -0
  7. package/package.json +92 -0
  8. package/src/adapters/expo-device.adapter.ts +106 -0
  9. package/src/adapters/expo-purchase.adapter.ts +79 -0
  10. package/src/adapters/expo-storage.adapter.ts +92 -0
  11. package/src/adapters/expo-ui.adapter.ts +57 -0
  12. package/src/adapters/index.ts +33 -0
  13. package/src/amazon-kepler.d.ts +7 -0
  14. package/src/components/NamiView.tsx +1006 -0
  15. package/src/components/PaywallScreen.tsx +245 -0
  16. package/src/components/TemplateRenderer.tsx +243 -0
  17. package/src/components/containers/NamiBackgroundContainer.tsx +103 -0
  18. package/src/components/containers/NamiCarousel.tsx +217 -0
  19. package/src/components/containers/NamiCollapseContainer.tsx +116 -0
  20. package/src/components/containers/NamiContainer.tsx +315 -0
  21. package/src/components/containers/NamiContentContainer.tsx +140 -0
  22. package/src/components/containers/NamiFooter.tsx +35 -0
  23. package/src/components/containers/NamiHeader.tsx +45 -0
  24. package/src/components/containers/NamiProductContainer.tsx +248 -0
  25. package/src/components/containers/NamiRepeatingGrid.tsx +81 -0
  26. package/src/components/containers/NamiResponsiveGrid.tsx +75 -0
  27. package/src/components/containers/NamiStack.tsx +69 -0
  28. package/src/components/elements/NamiButton.tsx +285 -0
  29. package/src/components/elements/NamiCountdownTimer.tsx +123 -0
  30. package/src/components/elements/NamiImage.tsx +177 -0
  31. package/src/components/elements/NamiPlayPauseButton.tsx +93 -0
  32. package/src/components/elements/NamiProgressBar.tsx +90 -0
  33. package/src/components/elements/NamiProgressIndicator.tsx +41 -0
  34. package/src/components/elements/NamiQRCode.tsx +51 -0
  35. package/src/components/elements/NamiRadioButton.tsx +62 -0
  36. package/src/components/elements/NamiSegmentPicker.tsx +67 -0
  37. package/src/components/elements/NamiSegmentPickerItem.tsx +184 -0
  38. package/src/components/elements/NamiSpacer.tsx +23 -0
  39. package/src/components/elements/NamiSymbol.tsx +104 -0
  40. package/src/components/elements/NamiText.tsx +311 -0
  41. package/src/components/elements/NamiToggleButton.tsx +102 -0
  42. package/src/components/elements/NamiToggleSwitch.tsx +64 -0
  43. package/src/components/elements/NamiVideo.kepler.tsx +638 -0
  44. package/src/components/elements/NamiVideo.tsx +133 -0
  45. package/src/components/elements/NamiVolumeButton.tsx +93 -0
  46. package/src/context/FocusContext.tsx +169 -0
  47. package/src/context/PaywallContext.tsx +343 -0
  48. package/src/global.d.ts +5 -0
  49. package/src/index.ts +62 -0
  50. package/src/nami.ts +24 -0
  51. package/src/react-native-qrcode-svg.d.ts +4 -0
  52. package/src/utils/actionHandler.ts +281 -0
  53. package/src/utils/fonts.ts +359 -0
  54. package/src/utils/iconMap.ts +67 -0
  55. package/src/utils/impression.ts +39 -0
  56. package/src/utils/rendering.ts +197 -0
  57. package/src/utils/smartText.ts +148 -0
  58. package/src/utils/styles.ts +668 -0
  59. package/src/utils/tvFocus.ts +31 -0
  60. package/src/utils/videoControls.ts +49 -0
@@ -0,0 +1,217 @@
1
+ import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react';
2
+ import {
3
+ View, ScrollView, Dimensions, StyleSheet, NativeSyntheticEvent,
4
+ NativeScrollEvent, TouchableOpacity,
5
+ } from 'react-native';
6
+ import { usePaywallContext } from '../../context/PaywallContext';
7
+ import { applyStyles, parseColor, parseSize } from '../../utils/styles';
8
+ import { TemplateRenderer } from '../TemplateRenderer';
9
+ import type { TComponent, TCarouselContainer, TCarouselSlide } from '@namiml/sdk-core';
10
+ import { skuItems, toNamiSKU } from '@namiml/sdk-core';
11
+ import { buildSmartTextReplacements, interpolate } from '../../utils/smartText';
12
+
13
+ interface Props {
14
+ component: TCarouselContainer;
15
+ scaleFactor: number;
16
+ onClose?: () => void;
17
+ parentDirection?: string;
18
+ }
19
+
20
+ export const NamiCarousel: React.FC<Props> = ({ component, scaleFactor, onClose, parentDirection }) => {
21
+ const ctx = usePaywallContext();
22
+ const scrollRef = useRef<ScrollView>(null);
23
+ const { width: screenWidth } = Dimensions.get('window');
24
+ const [activeIndex, setActiveIndex] = useState(0);
25
+
26
+ const containerStyle = useMemo(
27
+ () => applyStyles(component, scaleFactor, false, parentDirection),
28
+ [component, scaleFactor, parentDirection]
29
+ );
30
+ const direction = component.direction ?? 'horizontal';
31
+ const loopSourceKey = typeof component.loopSource === 'string' ? component.loopSource : '';
32
+
33
+ const slides = useMemo((): TCarouselSlide[] => {
34
+ if (Array.isArray(component.loopSource)) {
35
+ return component.loopSource;
36
+ }
37
+
38
+ const stateSlides = (ctx.state as any).slides;
39
+ const fromState = loopSourceKey ? stateSlides?.[loopSourceKey]?.carouselName : undefined;
40
+ if (Array.isArray(fromState)) {
41
+ const productGroupIds = (ctx.filteredSkuMenus ?? []).map((menu: any) => menu.id);
42
+ return fromState.filter((slide: any) => (
43
+ !slide?.productGroupId || productGroupIds.includes(slide.productGroupId)
44
+ ));
45
+ }
46
+
47
+ if (component.components?.length) {
48
+ return component.components.map((c, i) => ({ id: c.id ?? String(i), components: [c] })) as any;
49
+ }
50
+ return [];
51
+ }, [component.loopSource, component.components, ctx.state, loopSourceKey, ctx.filteredSkuMenus]);
52
+
53
+ const slideSkuPool = useMemo(() => {
54
+ if (!loopSourceKey) return [];
55
+ const skuMenus = ctx.state.selectedPaywall?.sku_menus ?? [];
56
+ return skuItems(ctx.productDetails, skuMenus, loopSourceKey);
57
+ }, [ctx.productDetails, ctx.state.selectedPaywall, loopSourceKey]);
58
+
59
+ const nextPad = parseSize(component.nextSlidePadding, scaleFactor) ?? 0;
60
+ const prevPad = parseSize(component.previousSlidePadding, scaleFactor) ?? 0;
61
+ const slideSpacing = parseSize(component.slideSpacing, scaleFactor) ?? 0;
62
+ const rawCarouselWidth = component.width ?? component.fixedWidth;
63
+ const viewportWidth = useMemo(() => {
64
+ if (rawCarouselWidth == null) return screenWidth;
65
+ if (typeof rawCarouselWidth === 'string' && rawCarouselWidth.trim().endsWith('%')) {
66
+ const pct = parseFloat(rawCarouselWidth);
67
+ return Number.isFinite(pct) ? Math.max(1, (screenWidth * pct) / 100) : screenWidth;
68
+ }
69
+ const resolved = parseSize(rawCarouselWidth, scaleFactor);
70
+ return resolved && resolved > 0 ? resolved : screenWidth;
71
+ }, [rawCarouselWidth, scaleFactor, screenWidth]);
72
+ const slideWidth = Math.max(1, viewportWidth - nextPad - prevPad);
73
+
74
+ const applySlideChange = useCallback((index: number) => {
75
+ setActiveIndex(index);
76
+ ctx.setCurrentSlideIndex(index);
77
+ const slide = slides[index];
78
+ const selectedProducts = ctx.state.selectedProducts ?? {};
79
+ const selectedSkuId = slide?.productGroupId ? selectedProducts[slide.productGroupId] : undefined;
80
+ const selectedSku = selectedSkuId
81
+ ? slideSkuPool.find((sku: any) => sku.id === selectedSkuId)
82
+ : undefined;
83
+ const skuVars = selectedSku
84
+ ? { ...toNamiSKU(selectedSku), ...(selectedSku.variables ?? {}) }
85
+ : ctx.state.sku;
86
+ const replacements = buildSmartTextReplacements(ctx.state, ctx.flow, skuVars);
87
+ const resolvedOnChange = component.onChange
88
+ ? interpolate(component.onChange as any, { ...replacements, slide, sku: skuVars })
89
+ : undefined;
90
+ const targetGroupId = (resolvedOnChange as any)?.parameters?.partialState?.currentGroupId;
91
+ if (targetGroupId) {
92
+ ctx.setCurrentGroupData(targetGroupId, '');
93
+ }
94
+ }, [ctx, component.onChange, slides, slideSkuPool]);
95
+
96
+ const getSlideComponents = useCallback((slide: any): TComponent[] => {
97
+ if (Array.isArray(slide?.components) && slide.components.length > 0) {
98
+ return slide.components as TComponent[];
99
+ }
100
+ if (!component.components?.length) return [];
101
+
102
+ const selectedProducts = ctx.state.selectedProducts ?? {};
103
+ const selectedSkuId = slide?.productGroupId ? selectedProducts[slide.productGroupId] : undefined;
104
+ const selectedSku = selectedSkuId
105
+ ? slideSkuPool.find((sku: any) => sku.id === selectedSkuId)
106
+ : undefined;
107
+
108
+ const skuVars = selectedSku
109
+ ? { ...toNamiSKU(selectedSku), ...(selectedSku.variables ?? {}) }
110
+ : ctx.state.sku;
111
+
112
+ const replacements = buildSmartTextReplacements(ctx.state, ctx.flow, skuVars);
113
+ return interpolate(component.components as any, { ...replacements, slide, sku: skuVars }) as TComponent[];
114
+ }, [component.components, ctx.state, ctx.flow, slideSkuPool]);
115
+
116
+ const onScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
117
+ const offset = e.nativeEvent.contentOffset.x;
118
+ const idx = Math.round(offset / (slideWidth + slideSpacing));
119
+ if (idx !== activeIndex && idx >= 0 && idx < slides.length) {
120
+ applySlideChange(idx);
121
+ }
122
+ }, [slideWidth, slideSpacing, activeIndex, slides.length, applySlideChange]);
123
+
124
+ useEffect(() => {
125
+ if (!component.autoplay || !component.autoplaySeconds || slides.length <= 1) return;
126
+ const interval = setInterval(() => {
127
+ const next = (activeIndex + 1) % slides.length;
128
+ scrollRef.current?.scrollTo({ x: next * (slideWidth + slideSpacing), animated: true });
129
+ applySlideChange(next);
130
+ }, component.autoplaySeconds * 1000);
131
+ return () => clearInterval(interval);
132
+ }, [component.autoplay, component.autoplaySeconds, slides.length, slideWidth, slideSpacing, applySlideChange, activeIndex]);
133
+
134
+ useEffect(() => {
135
+ if (slides.length === 0) return;
136
+ if (activeIndex < slides.length) return;
137
+ applySlideChange(0);
138
+ scrollRef.current?.scrollTo({ x: 0, animated: false });
139
+ }, [slides.length, activeIndex, applySlideChange]);
140
+
141
+ const showIndicators = component.showIndicators !== false && slides.length > 1;
142
+ const activeIndicatorColor = parseColor(component.activeIndicatorColor) ?? '#007AFF';
143
+ const indicatorColor = parseColor(component.indicatorColor) ?? '#ccc';
144
+ const indicator = component.indicator;
145
+
146
+ return (
147
+ <View style={[containerStyle, styles.container]}>
148
+ <ScrollView
149
+ ref={scrollRef}
150
+ horizontal
151
+ pagingEnabled={!nextPad && !prevPad}
152
+ showsHorizontalScrollIndicator={false}
153
+ onScroll={onScroll}
154
+ scrollEventThrottle={16}
155
+ decelerationRate="fast"
156
+ snapToInterval={slideWidth + slideSpacing}
157
+ contentContainerStyle={{ paddingLeft: prevPad, paddingRight: nextPad }}
158
+ >
159
+ {slides.map((slide: any, i: number) => (
160
+ <View key={slide.id ?? i} style={{ width: slideWidth, marginRight: slideSpacing }}>
161
+ {getSlideComponents(slide).map((child: TComponent, j: number) => (
162
+ <TemplateRenderer
163
+ key={child.id ?? `${i}-${j}`}
164
+ component={child}
165
+ scaleFactor={scaleFactor}
166
+ onClose={onClose}
167
+ parentDirection={direction}
168
+ />
169
+ ))}
170
+ </View>
171
+ ))}
172
+ </ScrollView>
173
+
174
+ {showIndicators && (
175
+ <View style={styles.pagination}>
176
+ {slides.map((_: any, i: number) => (
177
+ <TouchableOpacity
178
+ key={i}
179
+ style={i === 0 ? null : styles.dotSpacing}
180
+ onPress={() => {
181
+ scrollRef.current?.scrollTo({ x: i * (slideWidth + slideSpacing), animated: true });
182
+ applySlideChange(i);
183
+ }}
184
+ >
185
+ <View
186
+ style={[
187
+ styles.dot,
188
+ {
189
+ backgroundColor: i === activeIndex
190
+ ? (parseColor(component.activeIndicator?.fillColor) ?? activeIndicatorColor)
191
+ : (parseColor(component.indicator?.fillColor) ?? indicatorColor),
192
+ width: i === activeIndex
193
+ ? (component.activeIndicator?.width ?? indicator?.width ?? 8)
194
+ : (indicator?.width ?? 8),
195
+ height: i === activeIndex
196
+ ? (component.activeIndicator?.height ?? indicator?.height ?? 8)
197
+ : (indicator?.height ?? 8),
198
+ borderRadius: i === activeIndex
199
+ ? (component.activeIndicator?.borderRadius ?? (component.activeIndicator?.width ?? indicator?.width ?? 8) / 2)
200
+ : (indicator?.borderRadius ?? (indicator?.width ?? 8) / 2),
201
+ },
202
+ ]}
203
+ />
204
+ </TouchableOpacity>
205
+ ))}
206
+ </View>
207
+ )}
208
+ </View>
209
+ );
210
+ };
211
+
212
+ const styles = StyleSheet.create({
213
+ container: { flexDirection: 'column' },
214
+ pagination: { flexDirection: 'row', justifyContent: 'center', paddingVertical: 8 },
215
+ dotSpacing: { marginLeft: 6 },
216
+ dot: {},
217
+ });
@@ -0,0 +1,116 @@
1
+ import React, { useCallback, useMemo, useRef, useState } from 'react';
2
+ import { View, Pressable, Animated } from 'react-native';
3
+ import { useFirstFocusReadyContext, usePaywallContext } from '../../context/PaywallContext';
4
+ import { applyStyles, childSpacingStyle, focusedStyleOverrides, parseSizeOrPercent } from '../../utils/styles';
5
+ import { TemplateRenderer } from '../TemplateRenderer';
6
+ import type { TComponent, TCollapseContainer } from '@namiml/sdk-core';
7
+ import { FocusedStyleProvider, FocusScope, useFocusableState, useFocusEnabled, useRegisterPreferredFocus } from '../../context/FocusContext';
8
+ import { useTVPreferredFocus } from '../../utils/tvFocus';
9
+
10
+ interface Props {
11
+ component: TCollapseContainer;
12
+ scaleFactor: number;
13
+ onClose?: () => void;
14
+ parentDirection?: string;
15
+ }
16
+
17
+ export const NamiCollapseContainer: React.FC<Props> = ({ component, scaleFactor, onClose, parentDirection }) => {
18
+ const ctx = usePaywallContext();
19
+ const focusReadyCtx = useFirstFocusReadyContext();
20
+ const [focusWithin, setFocusWithin] = useState(false);
21
+ const focusEnabled = useFocusEnabled();
22
+ const initialFocus = Boolean((component as any).focused);
23
+ const { isFocused: selfFocused, onFocus, onBlur } = useFocusableState(
24
+ initialFocus,
25
+ focusEnabled,
26
+ );
27
+ const isFocused = selfFocused || focusWithin;
28
+ const collapseId = component.id ?? '';
29
+ const openIds = (ctx.state as any).openHeaderIds ?? [];
30
+ const defaultOpen = component.open === 'open';
31
+ const isOpen = openIds.includes(collapseId) || (defaultOpen && !openIds.length);
32
+
33
+ const animValue = useRef(new Animated.Value(isOpen ? 1 : 0)).current;
34
+ const pressableRef = useRef<any>(null);
35
+ useTVPreferredFocus(
36
+ pressableRef,
37
+ focusEnabled && initialFocus,
38
+ );
39
+ useRegisterPreferredFocus(
40
+ pressableRef.current,
41
+ focusEnabled && initialFocus,
42
+ );
43
+
44
+ const toggle = useCallback(() => {
45
+ ctx.setOpenHeaderIds(collapseId);
46
+ Animated.timing(animValue, {
47
+ toValue: isOpen ? 0 : 1,
48
+ duration: 250,
49
+ useNativeDriver: false,
50
+ }).start();
51
+ }, [collapseId, isOpen, animValue, ctx]);
52
+
53
+ const handleFocus = useCallback(() => {
54
+ onFocus();
55
+ focusReadyCtx.notifyFirstFocusReady(
56
+ ctx.state.selectedPaywall?.id ?? 'unknown',
57
+ ctx.state.currentPage ?? 'unknown',
58
+ ctx.state.formFactor,
59
+ );
60
+ }, [focusReadyCtx, onFocus, ctx.state.selectedPaywall?.id, ctx.state.currentPage, ctx.state.formFactor]);
61
+
62
+ const containerStyle = useMemo(() => {
63
+ const base = applyStyles(component, scaleFactor, false, parentDirection);
64
+ if (isFocused) {
65
+ Object.assign(base, focusedStyleOverrides(component, scaleFactor));
66
+ }
67
+ return base;
68
+ }, [component, scaleFactor, isFocused, parentDirection]);
69
+
70
+ // TCollapseContainer has `collapseHeader: TContainer` (single container for header)
71
+ // and `components: TComponent[]` for the body
72
+ const headerContainer = component.collapseHeader;
73
+ const bodyComponents = component.components ?? [];
74
+ const direction = component.direction ?? 'vertical';
75
+
76
+ const maxHeight = animValue.interpolate({
77
+ inputRange: [0, 1],
78
+ outputRange: [0, 1000],
79
+ });
80
+
81
+ const bodySizeStyle: any = {};
82
+ const bodyW = parseSizeOrPercent(component.width ?? component.fixedWidth, scaleFactor);
83
+ const bodyH = parseSizeOrPercent(component.height ?? component.fixedHeight, scaleFactor);
84
+ if (bodyW != null) bodySizeStyle.width = bodyW as any;
85
+ if (bodyH != null) bodySizeStyle.height = bodyH as any;
86
+
87
+ const headerSpacing = childSpacingStyle(1, { spacing: component.spacing, direction: component.direction }, scaleFactor);
88
+
89
+ return (
90
+ <FocusScope onFocusWithinChange={setFocusWithin}>
91
+ <View style={containerStyle}>
92
+ <FocusedStyleProvider focused={isFocused}>
93
+ <Pressable
94
+ ref={pressableRef}
95
+ onPress={toggle}
96
+ onFocus={handleFocus}
97
+ onBlur={onBlur}
98
+ hasTVPreferredFocus={focusEnabled && initialFocus}
99
+ style={({ pressed }) => [{ opacity: pressed ? 0.7 : 1 }]}
100
+ >
101
+ {headerContainer ? (
102
+ <TemplateRenderer component={headerContainer as any} scaleFactor={scaleFactor} onClose={onClose} parentDirection={direction} />
103
+ ) : null}
104
+ </Pressable>
105
+ <Animated.View style={[{ maxHeight, overflow: 'hidden' }, bodySizeStyle, headerSpacing]}>
106
+ {bodyComponents.map((child: TComponent, i: number) => (
107
+ <View key={child.id ?? i} style={childSpacingStyle(i, { spacing: component.spacing, direction: component.direction }, scaleFactor)}>
108
+ <TemplateRenderer component={child} scaleFactor={scaleFactor} onClose={onClose} parentDirection={direction} />
109
+ </View>
110
+ ))}
111
+ </Animated.View>
112
+ </FocusedStyleProvider>
113
+ </View>
114
+ </FocusScope>
115
+ );
116
+ };
@@ -0,0 +1,315 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { View, ImageBackground, Pressable, StyleSheet } from 'react-native';
3
+ import { usePaywallContext } from '../../context/PaywallContext';
4
+ import {
5
+ applyStyles,
6
+ childSpacingStyle,
7
+ focusedStyleOverrides,
8
+ parseSizeOrPercent,
9
+ resolveFillImageUrl,
10
+ } from '../../utils/styles';
11
+ import { handleAction } from '../../utils/actionHandler';
12
+ import { buildSmartTextReplacements, interpolateSmartText } from '../../utils/smartText';
13
+ import { TemplateRenderer } from '../TemplateRenderer';
14
+ import type { TComponent } from '@namiml/sdk-core';
15
+ import { parseToSemver } from '@namiml/sdk-core';
16
+ import { FocusScope, useFocusableState, useRegisterPreferredFocus } from '../../context/FocusContext';
17
+ import { expandRenderableComponents } from '../../utils/rendering';
18
+ import { useTVPreferredFocus } from '../../utils/tvFocus';
19
+
20
+ interface Props {
21
+ component: any;
22
+ scaleFactor: number;
23
+ onClose?: () => void;
24
+ parentDirection?: string;
25
+ parentCrossAxisFitContent?: boolean;
26
+ }
27
+
28
+ export const NamiContainer: React.FC<Props> = ({
29
+ component,
30
+ scaleFactor,
31
+ onClose,
32
+ parentDirection,
33
+ parentCrossAxisFitContent = false,
34
+ }) => {
35
+ const ctx = usePaywallContext();
36
+ const { isFocused: selfFocused, onFocus, onBlur } = useFocusableState(Boolean(component.focused));
37
+ const wrapperRef = useRef<any>(null);
38
+ const resolvedChildren = useMemo(
39
+ () => expandRenderableComponents(ctx, component.components),
40
+ [ctx, component.components],
41
+ );
42
+ useTVPreferredFocus(
43
+ wrapperRef,
44
+ Boolean(component.onTap && component.focused),
45
+ );
46
+ useRegisterPreferredFocus(
47
+ wrapperRef.current,
48
+ Boolean(component.onTap && component.focused),
49
+ );
50
+ const initialFocusWithin = useMemo(
51
+ () => hasInitiallyFocusedDescendant(resolvedChildren),
52
+ [resolvedChildren],
53
+ );
54
+ const [focusWithin, setFocusWithin] = useState(initialFocusWithin);
55
+ const isFocused = selfFocused || focusWithin || initialFocusWithin;
56
+
57
+ useEffect(() => {
58
+ if (initialFocusWithin) {
59
+ setFocusWithin(true);
60
+ }
61
+ }, [initialFocusWithin]);
62
+
63
+ const containerStyle = useMemo(
64
+ () => {
65
+ const base = applyStyles(component, scaleFactor, false, parentDirection);
66
+ const rawHeight = component.height ?? component.fixedHeight;
67
+ const componentId = String(component.id ?? '');
68
+ const componentType = String(component.namiComponentType ?? '');
69
+ const isDividerLike = componentType === 'divider' || /^divider/i.test(componentId);
70
+
71
+ if (
72
+ parentDirection === 'horizontal'
73
+ && parentCrossAxisFitContent
74
+ && rawHeight === '100%'
75
+ && !isDividerLike
76
+ ) {
77
+ delete (base as any).height;
78
+ }
79
+
80
+ return base;
81
+ },
82
+ [component, scaleFactor, parentDirection, parentCrossAxisFitContent]
83
+ );
84
+
85
+ const direction = component.direction ?? 'vertical';
86
+ const parentRawHeight = component.height ?? component.fixedHeight;
87
+ const minSdk = parseToSemver(ctx.state.selectedPaywall?.template?.min_sdk_version ?? '0.0.0');
88
+ const shouldSizeChildren = minSdk.minor < 2;
89
+ const sizeKey = direction === 'vertical' ? 'width' : 'height';
90
+ const shouldApplyLegacyChildSizing = shouldSizeChildren && direction !== 'horizontal';
91
+
92
+ const children = useMemo(() => resolvedChildren.map((child: TComponent, i: number) => {
93
+ const spacingStyle = i === 0
94
+ ? {}
95
+ : childSpacingStyle(i, { spacing: component.spacing, direction }, scaleFactor);
96
+ const hasSpacing = Object.keys(spacingStyle).length > 0;
97
+ const childRawWidth = (child as any).width ?? (child as any).fixedWidth;
98
+ const childRawHeight = (child as any).height ?? (child as any).fixedHeight;
99
+ const childType = (child as any).component;
100
+ const childId = String((child as any).id ?? '');
101
+ const childNamiType = String((child as any).namiComponentType ?? '');
102
+ const isDividerLike = childNamiType === 'divider' || /^divider/i.test(childId);
103
+ const conditionFirstChild = childType === 'condition'
104
+ ? (child as any).components?.[0]
105
+ : undefined;
106
+ const resolvedChildWidth =
107
+ childRawWidth
108
+ ?? conditionFirstChild?.width
109
+ ?? conditionFirstChild?.fixedWidth;
110
+ const resolvedChildHeight =
111
+ childRawHeight
112
+ ?? conditionFirstChild?.height
113
+ ?? conditionFirstChild?.fixedHeight;
114
+ const needsFlexShare =
115
+ direction === 'horizontal'
116
+ && resolvedChildWidth == null;
117
+ const shouldShrinkWrapper =
118
+ direction === 'horizontal'
119
+ && (
120
+ resolvedChildWidth == null
121
+ || (typeof resolvedChildWidth === 'string' && resolvedChildWidth.trim().endsWith('%'))
122
+ );
123
+ const hasExplicitPercentWidthSibling =
124
+ direction === 'horizontal'
125
+ && resolvedChildren.some((candidate, candidateIndex) => {
126
+ if (candidateIndex === i) return false;
127
+ const candidateRawWidth =
128
+ (candidate as any).width
129
+ ?? (candidate as any).fixedWidth
130
+ ?? ((candidate as any).component === 'condition'
131
+ ? (candidate as any).components?.[0]?.width ?? (candidate as any).components?.[0]?.fixedWidth
132
+ : undefined);
133
+ return typeof candidateRawWidth === 'string' && candidateRawWidth.trim().endsWith('%');
134
+ });
135
+ const shouldShareFullWidthColumn =
136
+ direction === 'horizontal'
137
+ && typeof resolvedChildWidth === 'string'
138
+ && resolvedChildWidth.trim() === '100%'
139
+ && !hasExplicitPercentWidthSibling;
140
+ const wrapWidth = parseSizeOrPercent(resolvedChildWidth, scaleFactor);
141
+ const wrapHeight = parseSizeOrPercent(resolvedChildHeight, scaleFactor);
142
+ const shouldStretchDivider =
143
+ direction === 'horizontal'
144
+ && isDividerLike
145
+ && resolvedChildHeight === '100%';
146
+ const shouldIgnoreCrossAxisFullHeight =
147
+ direction === 'horizontal'
148
+ && parentRawHeight === 'fitContent'
149
+ && resolvedChildHeight === '100%'
150
+ && !isDividerLike;
151
+ const wrapperSizeStyle = {
152
+ ...(!shouldShareFullWidthColumn && wrapWidth != null ? { width: wrapWidth as any } : {}),
153
+ ...(!shouldStretchDivider && !shouldIgnoreCrossAxisFullHeight && wrapHeight != null
154
+ ? { height: wrapHeight as any }
155
+ : {}),
156
+ ...(shouldStretchDivider ? { alignSelf: 'stretch' as const, minHeight: 1 } : {}),
157
+ };
158
+
159
+ const childNode = (
160
+ <TemplateRenderer
161
+ key={child.id ?? i}
162
+ component={child}
163
+ scaleFactor={scaleFactor}
164
+ onClose={onClose}
165
+ parentDirection={direction}
166
+ parentCrossAxisFitContent={direction === 'horizontal' && parentRawHeight === 'fitContent'}
167
+ />
168
+ );
169
+
170
+ const shouldWrap = shouldSizeChildren || needsFlexShare || hasSpacing || direction === 'horizontal';
171
+
172
+ if (shouldWrap) {
173
+ return (
174
+ <View
175
+ key={`wrap-${child.id ?? i}`}
176
+ style={[
177
+ shouldApplyLegacyChildSizing ? { [sizeKey]: '100%' as const } : null,
178
+ needsFlexShare ? styles.flexShare : null,
179
+ shouldShareFullWidthColumn ? styles.rowPercentColumn : null,
180
+ wrapperSizeStyle,
181
+ shouldShrinkWrapper ? styles.horizontalShrink : null,
182
+ spacingStyle,
183
+ ]}
184
+ >
185
+ {childNode}
186
+ </View>
187
+ );
188
+ }
189
+
190
+ return childNode;
191
+ }), [resolvedChildren, component.spacing, direction, scaleFactor, sizeKey, shouldApplyLegacyChildSizing, onClose, parentRawHeight]);
192
+
193
+ const fillImageUrl = resolveFillImageUrl(component.fillImage)
194
+ ? (() => {
195
+ const replacements = buildSmartTextReplacements(
196
+ ctx.state,
197
+ ctx.flow,
198
+ component?.smartTextSku ?? component?.sku
199
+ );
200
+ const raw = resolveFillImageUrl(component.fillImage) as string;
201
+ const resolved = interpolateSmartText(raw, replacements);
202
+ return resolved == null ? undefined : String(resolved);
203
+ })()
204
+ : undefined;
205
+
206
+ const borderOverride = useMemo(() => {
207
+ if (!isFocused) return {};
208
+ return focusedStyleOverrides(component, scaleFactor);
209
+ }, [component, isFocused, scaleFactor]);
210
+
211
+ const Wrapper = component.onTap ? Pressable : View;
212
+
213
+ const radiusStyles = pickBorderRadius(containerStyle);
214
+ const shouldClip = fillImageUrl && Object.keys(radiusStyles).length > 0;
215
+
216
+ const wrapperStyle = [
217
+ styles.relative,
218
+ containerStyle,
219
+ borderOverride,
220
+ shouldClip ? styles.clip : null,
221
+ ];
222
+
223
+ const wrapperPropsFinal = component.onTap
224
+ ? {
225
+ onPress: () => handleAction({
226
+ onTap: component.onTap,
227
+ ctx,
228
+ onClose,
229
+ componentChange: {
230
+ id: component.id,
231
+ name: component.title,
232
+ },
233
+ }),
234
+ onFocus,
235
+ onBlur,
236
+ ref: wrapperRef,
237
+ hasTVPreferredFocus: Boolean(component.focused),
238
+ style: ({ pressed }: any) => [wrapperStyle, pressed && styles.pressed],
239
+ }
240
+ : { style: wrapperStyle };
241
+
242
+ if (fillImageUrl) {
243
+ return (
244
+ <FocusScope onFocusWithinChange={setFocusWithin}>
245
+ <Wrapper {...wrapperPropsFinal}>
246
+ <ImageBackground
247
+ source={{ uri: fillImageUrl }}
248
+ style={StyleSheet.absoluteFill}
249
+ imageStyle={shouldClip ? radiusStyles : undefined}
250
+ resizeMode={
251
+ component.fillImage?.imageCropping === 'fit' ? 'contain' : 'cover'
252
+ }
253
+ />
254
+ {children}
255
+ </Wrapper>
256
+ </FocusScope>
257
+ );
258
+ }
259
+
260
+ return (
261
+ <FocusScope onFocusWithinChange={setFocusWithin}>
262
+ <Wrapper {...wrapperPropsFinal}>
263
+ {children}
264
+ </Wrapper>
265
+ </FocusScope>
266
+ );
267
+ };
268
+
269
+ const styles = StyleSheet.create({
270
+ pressed: { opacity: 0.7 },
271
+ relative: { position: 'relative' },
272
+ clip: { overflow: 'hidden' },
273
+ flexShare: { flex: 1 },
274
+ horizontalShrink: { minWidth: 0, flexShrink: 1 },
275
+ rowPercentColumn: { flexGrow: 1, flexBasis: 0, minWidth: 0 },
276
+ });
277
+
278
+ function pickBorderRadius(style: any): Record<string, number> {
279
+ if (!style) return {};
280
+ const keys = [
281
+ 'borderRadius',
282
+ 'borderTopLeftRadius',
283
+ 'borderTopRightRadius',
284
+ 'borderBottomLeftRadius',
285
+ 'borderBottomRightRadius',
286
+ ];
287
+ return keys.reduce((acc, key) => {
288
+ const value = style[key];
289
+ if (typeof value === 'number') (acc as any)[key] = value;
290
+ return acc;
291
+ }, {} as Record<string, number>);
292
+ }
293
+
294
+ function hasInitiallyFocusedDescendant(components: TComponent[] = []): boolean {
295
+ for (const component of components) {
296
+ if ((component as any)?.focused) {
297
+ return true;
298
+ }
299
+
300
+ const childGroups = [
301
+ (component as any)?.components,
302
+ (component as any)?.collapseHeader,
303
+ (component as any)?.productBaseComponents,
304
+ (component as any)?.productFeaturedComponents,
305
+ ];
306
+
307
+ for (const group of childGroups) {
308
+ if (Array.isArray(group) && hasInitiallyFocusedDescendant(group as TComponent[])) {
309
+ return true;
310
+ }
311
+ }
312
+ }
313
+
314
+ return false;
315
+ }