@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.
- package/dist/index.cjs +4000 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +151 -0
- package/dist/index.mjs +3966 -0
- package/dist/index.mjs.map +1 -0
- package/nami-expo-nami-iap.tgz +0 -0
- package/package.json +92 -0
- package/src/adapters/expo-device.adapter.ts +106 -0
- package/src/adapters/expo-purchase.adapter.ts +79 -0
- package/src/adapters/expo-storage.adapter.ts +92 -0
- package/src/adapters/expo-ui.adapter.ts +57 -0
- package/src/adapters/index.ts +33 -0
- package/src/amazon-kepler.d.ts +7 -0
- package/src/components/NamiView.tsx +1006 -0
- package/src/components/PaywallScreen.tsx +245 -0
- package/src/components/TemplateRenderer.tsx +243 -0
- package/src/components/containers/NamiBackgroundContainer.tsx +103 -0
- package/src/components/containers/NamiCarousel.tsx +217 -0
- package/src/components/containers/NamiCollapseContainer.tsx +116 -0
- package/src/components/containers/NamiContainer.tsx +315 -0
- package/src/components/containers/NamiContentContainer.tsx +140 -0
- package/src/components/containers/NamiFooter.tsx +35 -0
- package/src/components/containers/NamiHeader.tsx +45 -0
- package/src/components/containers/NamiProductContainer.tsx +248 -0
- package/src/components/containers/NamiRepeatingGrid.tsx +81 -0
- package/src/components/containers/NamiResponsiveGrid.tsx +75 -0
- package/src/components/containers/NamiStack.tsx +69 -0
- package/src/components/elements/NamiButton.tsx +285 -0
- package/src/components/elements/NamiCountdownTimer.tsx +123 -0
- package/src/components/elements/NamiImage.tsx +177 -0
- package/src/components/elements/NamiPlayPauseButton.tsx +93 -0
- package/src/components/elements/NamiProgressBar.tsx +90 -0
- package/src/components/elements/NamiProgressIndicator.tsx +41 -0
- package/src/components/elements/NamiQRCode.tsx +51 -0
- package/src/components/elements/NamiRadioButton.tsx +62 -0
- package/src/components/elements/NamiSegmentPicker.tsx +67 -0
- package/src/components/elements/NamiSegmentPickerItem.tsx +184 -0
- package/src/components/elements/NamiSpacer.tsx +23 -0
- package/src/components/elements/NamiSymbol.tsx +104 -0
- package/src/components/elements/NamiText.tsx +311 -0
- package/src/components/elements/NamiToggleButton.tsx +102 -0
- package/src/components/elements/NamiToggleSwitch.tsx +64 -0
- package/src/components/elements/NamiVideo.kepler.tsx +638 -0
- package/src/components/elements/NamiVideo.tsx +133 -0
- package/src/components/elements/NamiVolumeButton.tsx +93 -0
- package/src/context/FocusContext.tsx +169 -0
- package/src/context/PaywallContext.tsx +343 -0
- package/src/global.d.ts +5 -0
- package/src/index.ts +62 -0
- package/src/nami.ts +24 -0
- package/src/react-native-qrcode-svg.d.ts +4 -0
- package/src/utils/actionHandler.ts +281 -0
- package/src/utils/fonts.ts +359 -0
- package/src/utils/iconMap.ts +67 -0
- package/src/utils/impression.ts +39 -0
- package/src/utils/rendering.ts +197 -0
- package/src/utils/smartText.ts +148 -0
- package/src/utils/styles.ts +668 -0
- package/src/utils/tvFocus.ts +31 -0
- 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
|
+
}
|