@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,140 @@
1
+ import React, { useMemo } from 'react';
2
+ import { ScrollView, StyleSheet, View, type ViewStyle } from 'react-native';
3
+ import type { TContainer, TComponent } from '@namiml/sdk-core';
4
+ import { applyStyles, childSpacingStyle, flexDirectionFromConfig } from '../../utils/styles';
5
+ import { usePaywallContext } from '../../context/PaywallContext';
6
+ import { TemplateRenderer } from '../TemplateRenderer';
7
+
8
+ interface Props {
9
+ component: TContainer;
10
+ scaleFactor: number;
11
+ onClose?: () => void;
12
+ }
13
+
14
+ function splitContainerStyles(style: ViewStyle): { outer: ViewStyle; inner: ViewStyle } {
15
+ const {
16
+ paddingLeft, paddingRight, paddingTop, paddingBottom,
17
+ flexDirection, alignItems, justifyContent,
18
+ ...rest
19
+ } = style;
20
+
21
+ const outer: ViewStyle = { ...rest };
22
+ const inner: ViewStyle = {
23
+ ...(paddingLeft != null ? { paddingLeft } : {}),
24
+ ...(paddingRight != null ? { paddingRight } : {}),
25
+ ...(paddingTop != null ? { paddingTop } : {}),
26
+ ...(paddingBottom != null ? { paddingBottom } : {}),
27
+ ...(flexDirection ? { flexDirection } : {}),
28
+ ...(alignItems ? { alignItems } : {}),
29
+ ...(justifyContent ? { justifyContent } : {}),
30
+ };
31
+
32
+ return { outer, inner };
33
+ }
34
+
35
+ export const NamiContentContainer: React.FC<Props> = ({
36
+ component,
37
+ scaleFactor,
38
+ onClose,
39
+ }) => {
40
+ const ctx = usePaywallContext();
41
+ if (!component || component.hidden) return null;
42
+
43
+ const baseStyle = useMemo(
44
+ () => applyStyles(component as any, scaleFactor) as ViewStyle,
45
+ [component, scaleFactor]
46
+ );
47
+ const { outer, inner } = splitContainerStyles(baseStyle);
48
+
49
+ const formFactor = ctx.state.formFactor;
50
+ const scrollEnabled = formFactor !== 'television';
51
+ const direction = component.direction ?? 'vertical';
52
+ const flexDir = flexDirectionFromConfig(direction);
53
+ const contentAlignment = useMemo(() => {
54
+ const vertical = component.verticalAlignment;
55
+ const horizontal = component.horizontalAlignment;
56
+ const isRow = flexDir === 'row' || flexDir === 'row-reverse';
57
+
58
+ return {
59
+ alignItems: isRow
60
+ ? (vertical === 'top' ? 'flex-start' : vertical === 'bottom' ? 'flex-end' : vertical === 'center' ? 'center' : 'center')
61
+ : (horizontal === 'left' ? 'flex-start' : horizontal === 'right' ? 'flex-end' : horizontal === 'center' ? 'center' : 'center'),
62
+ justifyContent: isRow
63
+ ? (horizontal === 'left' ? 'flex-start' : horizontal === 'right' ? 'flex-end' : horizontal === 'center' ? 'center' : 'flex-start')
64
+ : (vertical === 'top' ? 'flex-start' : vertical === 'bottom' ? 'flex-end' : vertical === 'center' ? 'center' : 'flex-start'),
65
+ } as const;
66
+ }, [component.verticalAlignment, component.horizontalAlignment, flexDir]);
67
+ const renderedChildren = useMemo(() => {
68
+ return component.components?.map((comp: TComponent, i: number) => (
69
+ <View
70
+ key={comp.id ?? i}
71
+ style={[
72
+ styles.fullWidth,
73
+ i === 0
74
+ ? null
75
+ : childSpacingStyle(i, { spacing: component.spacing, direction }, scaleFactor),
76
+ ]}
77
+ >
78
+ <TemplateRenderer
79
+ component={comp}
80
+ scaleFactor={scaleFactor}
81
+ onClose={onClose}
82
+ parentDirection={direction}
83
+ />
84
+ </View>
85
+ ));
86
+ }, [component.components, component.spacing, direction, scaleFactor, onClose]);
87
+
88
+ const innerContentStyle = useMemo(
89
+ () => [
90
+ styles.inner,
91
+ {
92
+ flexDirection: flexDir,
93
+ },
94
+ inner,
95
+ contentAlignment,
96
+ ],
97
+ [flexDir, inner, contentAlignment],
98
+ );
99
+
100
+ if (!scrollEnabled) {
101
+ return (
102
+ <View style={[styles.outer, outer]}>
103
+ <View style={[styles.scroll, innerContentStyle]}>
104
+ {renderedChildren}
105
+ </View>
106
+ </View>
107
+ );
108
+ }
109
+
110
+ return (
111
+ <View style={[styles.outer, outer]}>
112
+ <ScrollView
113
+ style={styles.scroll}
114
+ contentContainerStyle={innerContentStyle}
115
+ showsVerticalScrollIndicator={false}
116
+ scrollEnabled
117
+ >
118
+ {renderedChildren}
119
+ </ScrollView>
120
+ </View>
121
+ );
122
+ };
123
+
124
+ const styles = StyleSheet.create({
125
+ outer: {
126
+ flex: 1,
127
+ minHeight: 0,
128
+ zIndex: 2,
129
+ },
130
+ scroll: {
131
+ flex: 1,
132
+ },
133
+ inner: {
134
+ width: '100%',
135
+ flexGrow: 1,
136
+ },
137
+ fullWidth: {
138
+ width: '100%',
139
+ },
140
+ });
@@ -0,0 +1,35 @@
1
+ import React from 'react';
2
+ import { View, StyleSheet } from 'react-native';
3
+ import type { THeaderFooter } from '@namiml/sdk-core';
4
+ import { TemplateRenderer } from '../TemplateRenderer';
5
+
6
+ interface Props {
7
+ components: THeaderFooter;
8
+ scaleFactor: number;
9
+ onClose?: () => void;
10
+ }
11
+
12
+ export const NamiFooter: React.FC<Props> = ({ components, scaleFactor, onClose }) => {
13
+ const list = Array.isArray(components)
14
+ ? components
15
+ : (components as any)?.components ?? [components];
16
+
17
+ return (
18
+ <View style={styles.footer}>
19
+ {list.map((comp: any, i: number) => (
20
+ <TemplateRenderer key={comp.id ?? i} component={comp} scaleFactor={scaleFactor} onClose={onClose} />
21
+ ))}
22
+ </View>
23
+ );
24
+ };
25
+
26
+ const styles = StyleSheet.create({
27
+ footer: {
28
+ position: 'absolute',
29
+ bottom: 0,
30
+ width: '100%',
31
+ alignItems: 'center',
32
+ justifyContent: 'center',
33
+ zIndex: 3,
34
+ },
35
+ });
@@ -0,0 +1,45 @@
1
+ import React from 'react';
2
+ import { View, StyleSheet } from 'react-native';
3
+ import type { THeaderFooter } from '@namiml/sdk-core';
4
+ import { usePaywallContext } from '../../context/PaywallContext';
5
+ import { TemplateRenderer } from '../TemplateRenderer';
6
+
7
+ interface Props {
8
+ components: THeaderFooter;
9
+ scaleFactor: number;
10
+ onClose?: () => void;
11
+ }
12
+
13
+ export const NamiHeader: React.FC<Props> = ({ components, scaleFactor, onClose }) => {
14
+ const ctx = usePaywallContext();
15
+ const formFactor = ctx.state.formFactor;
16
+ const inFullScreen = ctx.state.fullScreenPresentation ?? false;
17
+ const marginTop = formFactor === 'phone' && !inFullScreen ? 40 : 0;
18
+
19
+ const list = Array.isArray(components)
20
+ ? components
21
+ : (components as any)?.components ?? [components];
22
+ const renderedList = React.useMemo(
23
+ () => list.map((comp: any, i: number) => (
24
+ <TemplateRenderer key={comp.id ?? i} component={comp} scaleFactor={scaleFactor} onClose={onClose} />
25
+ )),
26
+ [list, scaleFactor, onClose],
27
+ );
28
+
29
+ return (
30
+ <View style={[styles.header, { marginTop }]}>
31
+ {renderedList}
32
+ </View>
33
+ );
34
+ };
35
+
36
+ const styles = StyleSheet.create({
37
+ header: {
38
+ display: 'flex',
39
+ position: 'relative',
40
+ width: '100%',
41
+ alignItems: 'center',
42
+ justifyContent: 'center',
43
+ zIndex: 2,
44
+ },
45
+ });
@@ -0,0 +1,248 @@
1
+ import React, { useMemo, useRef, useEffect } from 'react';
2
+ import { View, StyleSheet, ImageBackground, Animated } from 'react-native';
3
+ import { usePaywallContext } from '../../context/PaywallContext';
4
+ import { applyStyles, childSpacingStyle, parseSizeOrPercent, resolveFillImageUrl } from '../../utils/styles';
5
+ import { TemplateRenderer } from '../TemplateRenderer';
6
+ import { getProductDetail, getSkuProductDetailKeys, skuItems, toNamiSKU } from '@namiml/sdk-core';
7
+ import type { TComponent, TProductContainer, PaywallSKU } from '@namiml/sdk-core';
8
+ import { buildSmartTextReplacements, interpolate } from '../../utils/smartText';
9
+ import { FocusedStyleProvider } from '../../context/FocusContext';
10
+
11
+
12
+ interface Props {
13
+ component: TProductContainer;
14
+ scaleFactor: number;
15
+ onClose?: () => void;
16
+ parentDirection?: string;
17
+ }
18
+
19
+ type InjectedSkuContext = {
20
+ skuActionPayload: any;
21
+ skuVars: Record<string, any>;
22
+ replacements: ReturnType<typeof buildSmartTextReplacements>;
23
+ };
24
+
25
+ const AnimatedImageBackground = Animated.createAnimatedComponent(ImageBackground);
26
+
27
+ export const NamiProductContainer: React.FC<Props> = ({ component, scaleFactor, onClose, parentDirection }) => {
28
+ const ctx = usePaywallContext();
29
+ const containerStyle = useMemo(() => applyStyles(component, scaleFactor, false, parentDirection), [component, scaleFactor, parentDirection]);
30
+ const opacity = useRef(new Animated.Value(1)).current;
31
+ const prevGroupId = useRef<string | undefined>(undefined);
32
+
33
+ const skus = useMemo(() => {
34
+ const groupRef = component.subsetGroup ?? ctx.state.currentGroupId;
35
+ const skuMenus = ctx.state.selectedPaywall?.sku_menus ?? [];
36
+ return skuItems(ctx.productDetails, skuMenus, groupRef);
37
+ }, [ctx.productDetails, ctx.state.selectedPaywall, ctx.state.currentGroupId, component.subsetGroup]);
38
+
39
+ const currentGroupId = component.subsetGroup ?? ctx.state.currentGroupId;
40
+ const selectedId = (ctx.state.selectedProducts ?? {})[currentGroupId];
41
+ const fillImageUrl = resolveFillImageUrl(component.fillImage);
42
+
43
+ const sizeKey = component.direction === 'vertical' ? 'width' : 'height';
44
+ const childSizeStyle = { [sizeKey]: '100%' as const };
45
+ const direction = component.direction ?? 'vertical';
46
+
47
+ useEffect(() => {
48
+ if (prevGroupId.current != null && prevGroupId.current !== currentGroupId) {
49
+ opacity.setValue(0);
50
+ Animated.timing(opacity, {
51
+ toValue: 1,
52
+ duration: 300,
53
+ useNativeDriver: true,
54
+ }).start();
55
+ }
56
+ prevGroupId.current = currentGroupId;
57
+ }, [currentGroupId, opacity]);
58
+
59
+ const content = useMemo(() => (
60
+ <>
61
+ {skus.map((sku: PaywallSKU, i: number) => {
62
+ const isFeatured = sku.featured ?? false;
63
+ const isSelected = sku.id === selectedId;
64
+ const useFeaturedFocus = ctx.state.formFactor === 'television' && isFeatured;
65
+ const injectedSkuContext = buildInjectedSkuContext(sku, isSelected, ctx);
66
+ const template = isFeatured
67
+ ? (component.productFeaturedComponents ?? component.components)
68
+ : (component.productBaseComponents ?? component.components);
69
+
70
+ if (!template?.length) return null;
71
+
72
+ const spacingStyle = childSpacingStyle(i, { spacing: component.spacing, direction }, scaleFactor);
73
+ const templatePrimary = template[0] as any;
74
+ const primaryRawWidth = templatePrimary?.width ?? templatePrimary?.fixedWidth;
75
+ const wrapperWidth = parseSizeOrPercent(primaryRawWidth, scaleFactor);
76
+ const shouldShareHorizontal = direction === 'horizontal' && primaryRawWidth == null;
77
+ const shouldShrinkHorizontal =
78
+ direction === 'horizontal'
79
+ && (
80
+ primaryRawWidth == null
81
+ || (typeof primaryRawWidth === 'string' && primaryRawWidth.trim().endsWith('%'))
82
+ );
83
+
84
+ return (
85
+ <View
86
+ key={sku.id ?? i}
87
+ style={[
88
+ childSizeStyle,
89
+ wrapperWidth != null ? { width: wrapperWidth as any } : null,
90
+ shouldShareHorizontal ? styles.flexShare : null,
91
+ shouldShrinkHorizontal ? styles.horizontalShrink : null,
92
+ spacingStyle,
93
+ ]}
94
+ >
95
+ <FocusedStyleProvider focused={useFeaturedFocus}>
96
+ {template.map((child: TComponent, j: number) => {
97
+ const enriched = injectSkuIntoComponent(child, injectedSkuContext, useFeaturedFocus);
98
+ return (
99
+ <TemplateRenderer
100
+ key={child.id ? `${child.id}-${sku.id}` : `${i}-${j}`}
101
+ component={enriched}
102
+ scaleFactor={scaleFactor}
103
+ onClose={onClose}
104
+ parentDirection={direction}
105
+ />
106
+ );
107
+ })}
108
+ </FocusedStyleProvider>
109
+ </View>
110
+ );
111
+ })}
112
+ </>
113
+ ), [
114
+ childSizeStyle,
115
+ component.components,
116
+ component.productBaseComponents,
117
+ component.productFeaturedComponents,
118
+ component.spacing,
119
+ ctx,
120
+ direction,
121
+ onClose,
122
+ scaleFactor,
123
+ selectedId,
124
+ skus,
125
+ ]);
126
+
127
+ const radiusStyles = pickBorderRadius(containerStyle);
128
+ const shouldClip = fillImageUrl && Object.keys(radiusStyles).length > 0;
129
+
130
+ if (fillImageUrl) {
131
+ return (
132
+ <AnimatedImageBackground
133
+ source={{ uri: fillImageUrl }}
134
+ resizeMode="cover"
135
+ imageStyle={shouldClip ? radiusStyles : undefined}
136
+ style={[styles.relative, containerStyle, shouldClip ? styles.clip : null, { opacity }]}
137
+ >
138
+ {content}
139
+ </AnimatedImageBackground>
140
+ );
141
+ }
142
+
143
+ return (
144
+ <Animated.View style={[styles.relative, containerStyle, { opacity }]}>
145
+ {content}
146
+ </Animated.View>
147
+ );
148
+ };
149
+
150
+ const styles = StyleSheet.create({
151
+ relative: { position: 'relative' },
152
+ clip: { overflow: 'hidden' },
153
+ flexShare: { flex: 1 },
154
+ horizontalShrink: { minWidth: 0, flexShrink: 1 },
155
+ });
156
+
157
+ function pickBorderRadius(style: any): Record<string, number> {
158
+ if (!style) return {};
159
+ const keys = [
160
+ 'borderRadius',
161
+ 'borderTopLeftRadius',
162
+ 'borderTopRightRadius',
163
+ 'borderBottomLeftRadius',
164
+ 'borderBottomRightRadius',
165
+ ];
166
+ return keys.reduce((acc, key) => {
167
+ const value = style[key];
168
+ if (typeof value === 'number') (acc as any)[key] = value;
169
+ return acc;
170
+ }, {} as Record<string, number>);
171
+ }
172
+
173
+ function injectSkuIntoComponent(
174
+ component: TComponent,
175
+ injectedSkuContext: InjectedSkuContext,
176
+ useFeaturedFocus: boolean,
177
+ ): any {
178
+ const nextComponent: any = {
179
+ ...component,
180
+ sku: injectedSkuContext.skuActionPayload,
181
+ smartTextSku: injectedSkuContext.skuVars,
182
+ ...(useFeaturedFocus && component.component === 'button' ? { focused: true } : {}),
183
+ };
184
+
185
+ const childComponents = nextComponent.components;
186
+ const collapseHeader = nextComponent.collapseHeader;
187
+ const result: any = interpolate(
188
+ {
189
+ ...nextComponent,
190
+ components: undefined,
191
+ collapseHeader: collapseHeader
192
+ ? { ...collapseHeader, components: undefined }
193
+ : undefined,
194
+ },
195
+ injectedSkuContext.replacements,
196
+ );
197
+ result.__namiSmartTextResolved = true;
198
+
199
+ if (childComponents) {
200
+ result.components = childComponents.map((c: TComponent) =>
201
+ injectSkuIntoComponent(c, injectedSkuContext, useFeaturedFocus)
202
+ );
203
+ }
204
+ if (collapseHeader?.components) {
205
+ result.collapseHeader = {
206
+ ...(result.collapseHeader ?? collapseHeader),
207
+ components: collapseHeader.components.map((c: TComponent) =>
208
+ injectSkuIntoComponent(c, injectedSkuContext, useFeaturedFocus)
209
+ ),
210
+ };
211
+ }
212
+ return result;
213
+ }
214
+
215
+ function buildInjectedSkuContext(
216
+ sku: PaywallSKU,
217
+ isSelected: boolean,
218
+ ctx: any,
219
+ ): InjectedSkuContext {
220
+ const entitlements = (sku.entitlements ?? []).map((e: any) => e.entitlement_ref_id);
221
+ const skuActionPayload = toNamiSKU(sku);
222
+ const detail = (sku as any).product_details ?? (sku as any).productDetails ?? null;
223
+ const recomputedDetailValues = getSkuProductDetailKeys().reduce((output: Record<string, any>, key: string) => {
224
+ const value = getProductDetail(key, detail, undefined, [sku]);
225
+ if (value !== null && value !== undefined) {
226
+ output[key] = value;
227
+ }
228
+ return output;
229
+ }, {});
230
+ const skuVars = {
231
+ ...sku,
232
+ ...recomputedDetailValues,
233
+ ...(sku.variables ?? {}),
234
+ freeTrialEligible: (sku as any).freeTrialEligible ?? false,
235
+ introEligible: (sku as any).introEligible ?? false,
236
+ promoEligible: (sku as any).promoEligible ?? false,
237
+ invalid: (sku as any).invalid ?? false,
238
+ unavailable: (sku as any).unavailable ?? false,
239
+ entitlements,
240
+ selected: isSelected,
241
+ };
242
+
243
+ return {
244
+ skuActionPayload,
245
+ skuVars,
246
+ replacements: buildSmartTextReplacements(ctx.state, ctx.flow, skuVars),
247
+ };
248
+ }
@@ -0,0 +1,81 @@
1
+ import React, { useCallback, useMemo, useState } from 'react';
2
+ import { Dimensions, LayoutChangeEvent, StyleSheet, View, type ViewStyle } from 'react-native';
3
+ import type { TComponent } from '@namiml/sdk-core';
4
+ import { applyGridStyles, focusedStyleOverrides, parseSize } from '../../utils/styles';
5
+ import { usePaywallContext } from '../../context/PaywallContext';
6
+ import { FocusScope } from '../../context/FocusContext';
7
+ import { TemplateRenderer } from '../TemplateRenderer';
8
+ import { getRepeatingListBlocks } from '../../utils/rendering';
9
+
10
+ interface Props {
11
+ component: any;
12
+ scaleFactor: number;
13
+ onClose?: () => void;
14
+ parentDirection?: string;
15
+ }
16
+
17
+ export const NamiRepeatingGrid: React.FC<Props> = ({ component, scaleFactor, onClose, parentDirection }) => {
18
+ const ctx = usePaywallContext();
19
+ const [isFocused, setIsFocused] = useState(false);
20
+ const [containerWidth, setContainerWidth] = useState<number | null>(null);
21
+ const columns = Math.max(1, component.columns ?? 2);
22
+ const columnSpacing = parseSize(component.columnSpacing ?? component.spacing, scaleFactor) ?? 0;
23
+ const rowSpacing = parseSize(component.rowSpacing ?? component.spacing, scaleFactor) ?? 0;
24
+ const { width: screenWidth } = Dimensions.get('window');
25
+ const blocks = useMemo(
26
+ () => getRepeatingListBlocks(ctx, component),
27
+ [ctx, component],
28
+ );
29
+ const items = useMemo(() => blocks.flat(), [blocks]);
30
+ const usableWidth = Math.max(1, (containerWidth ?? screenWidth) - columnSpacing * Math.max(0, columns - 1));
31
+ const itemWidth = usableWidth / columns;
32
+
33
+ const onLayout = useCallback((event: LayoutChangeEvent) => {
34
+ setContainerWidth(event.nativeEvent.layout.width);
35
+ }, []);
36
+
37
+ const containerStyle = useMemo(() => {
38
+ const base = applyGridStyles(component, scaleFactor, false, parentDirection);
39
+ Object.assign(base, styles.grid);
40
+ if (isFocused) {
41
+ Object.assign(base, focusedStyleOverrides(component, scaleFactor));
42
+ }
43
+ return base;
44
+ }, [component, isFocused, parentDirection, scaleFactor]);
45
+
46
+ return (
47
+ <FocusScope onFocusWithinChange={setIsFocused}>
48
+ <View style={containerStyle} onLayout={onLayout}>
49
+ {items.map((child: TComponent, index: number) => {
50
+ const columnIndex = index % columns;
51
+ const isLastInRow = columnIndex === columns - 1;
52
+ const cellStyle: ViewStyle = {
53
+ width: itemWidth,
54
+ marginRight: isLastInRow ? 0 : columnSpacing,
55
+ marginBottom: rowSpacing,
56
+ };
57
+
58
+ return (
59
+ <View key={child.id ?? index} style={cellStyle}>
60
+ <TemplateRenderer
61
+ component={child}
62
+ scaleFactor={scaleFactor}
63
+ onClose={onClose}
64
+ parentDirection={component.direction}
65
+ />
66
+ </View>
67
+ );
68
+ })}
69
+ </View>
70
+ </FocusScope>
71
+ );
72
+ };
73
+
74
+ const styles = StyleSheet.create({
75
+ grid: {
76
+ flexDirection: 'row',
77
+ flexWrap: 'wrap',
78
+ alignItems: 'center',
79
+ justifyContent: 'center',
80
+ },
81
+ });
@@ -0,0 +1,75 @@
1
+ import React, { useMemo, useState, useCallback } from 'react';
2
+ import { View, StyleSheet, Dimensions, LayoutChangeEvent, ViewStyle } from 'react-native';
3
+ import { applyGridStyles, focusedStyleOverrides, parseSize } from '../../utils/styles';
4
+ import { TemplateRenderer } from '../TemplateRenderer';
5
+ import type { TComponent } from '@namiml/sdk-core';
6
+ import { usePaywallContext } from '../../context/PaywallContext';
7
+ import { FocusScope } from '../../context/FocusContext';
8
+ import { getRepeatingListBlocks } from '../../utils/rendering';
9
+
10
+ interface Props {
11
+ component: any;
12
+ scaleFactor: number;
13
+ onClose?: () => void;
14
+ parentDirection?: string;
15
+ }
16
+
17
+ export const NamiResponsiveGrid: React.FC<Props> = ({ component, scaleFactor, onClose, parentDirection }) => {
18
+ const ctx = usePaywallContext();
19
+ const [isFocused, setIsFocused] = useState(false);
20
+ const containerStyle = useMemo(() => {
21
+ const base = applyGridStyles(component, scaleFactor, false, parentDirection);
22
+ if (isFocused) {
23
+ Object.assign(base, focusedStyleOverrides(component, scaleFactor));
24
+ }
25
+ return base;
26
+ }, [component, scaleFactor, parentDirection, isFocused]);
27
+ const isVertical = component.direction === 'vertical';
28
+ const direction = component.direction ?? 'vertical';
29
+ const columns = component.columns ?? (isVertical ? 1 : 2);
30
+ const gap = parseSize(component.spacing ?? component.gap, scaleFactor) ?? 8;
31
+ const { width: screenWidth } = Dimensions.get('window');
32
+ const [containerWidth, setContainerWidth] = useState<number | null>(null);
33
+
34
+ const onLayout = useCallback((e: LayoutChangeEvent) => {
35
+ setContainerWidth(e.nativeEvent.layout.width);
36
+ }, [component, containerStyle, columns, gap]);
37
+
38
+ const leftPad = parseSize(component.leftPadding, scaleFactor) ?? 0;
39
+ const rightPad = parseSize(component.rightPadding, scaleFactor) ?? 0;
40
+ const usableWidth = (containerWidth ?? screenWidth) - leftPad - rightPad;
41
+ const itemWidth = (usableWidth - gap * (columns - 1)) / columns;
42
+
43
+ const repeatingBlocks = useMemo(() => getRepeatingListBlocks(ctx, component), [ctx, component]);
44
+ const items: TComponent[] = useMemo(
45
+ () => (repeatingBlocks.length ? repeatingBlocks.flat() : (component.components ?? [])),
46
+ [component.components, repeatingBlocks],
47
+ );
48
+
49
+ return (
50
+ <FocusScope onFocusWithinChange={setIsFocused}>
51
+ <View style={[containerStyle, styles.grid, isVertical ? styles.vertical : styles.horizontal]} onLayout={onLayout}>
52
+ {items.map((child: TComponent, i: number) => {
53
+ const col = i % columns;
54
+ const isLastInRow = col === columns - 1;
55
+ const style = {
56
+ width: isVertical ? '100%' : itemWidth,
57
+ marginRight: isVertical ? 0 : (isLastInRow ? 0 : gap),
58
+ marginBottom: gap,
59
+ };
60
+ return (
61
+ <View key={child.id ?? i} style={style as ViewStyle}>
62
+ <TemplateRenderer component={child} scaleFactor={scaleFactor} onClose={onClose} parentDirection={direction} />
63
+ </View>
64
+ );
65
+ })}
66
+ </View>
67
+ </FocusScope>
68
+ );
69
+ };
70
+
71
+ const styles = StyleSheet.create({
72
+ grid: { alignItems: 'center', justifyContent: 'center' },
73
+ horizontal: { flexDirection: 'row', flexWrap: 'wrap', alignSelf: 'baseline' },
74
+ vertical: { flexDirection: 'column', flexWrap: 'nowrap', alignSelf: 'baseline' },
75
+ });
@@ -0,0 +1,69 @@
1
+ import React, { useMemo } from 'react';
2
+ import { View, StyleSheet, ImageBackground } from 'react-native';
3
+ import { usePaywallContext } from '../../context/PaywallContext';
4
+ import { applyStyles, childSpacingStyle, resolveFillImageUrl } from '../../utils/styles';
5
+ import { buildSmartTextReplacements, interpolateSmartText } from '../../utils/smartText';
6
+ import { TemplateRenderer } from '../TemplateRenderer';
7
+ import type { TComponent } from '@namiml/sdk-core';
8
+ import { parseToSemver } from '@namiml/sdk-core';
9
+
10
+ interface Props {
11
+ component: any;
12
+ scaleFactor: number;
13
+ onClose?: () => void;
14
+ parentDirection?: string;
15
+ }
16
+
17
+ export const NamiStack: React.FC<Props> = ({ component, scaleFactor, onClose, parentDirection }) => {
18
+ const ctx = usePaywallContext();
19
+ const containerStyle = useMemo(() => applyStyles(component, scaleFactor, false, parentDirection), [component, scaleFactor, parentDirection]);
20
+
21
+ const minSdk = parseToSemver(ctx.state.selectedPaywall?.template?.min_sdk_version ?? '0.0.0');
22
+ const shouldSizeChildren = minSdk.minor < 2;
23
+ const sizeKey = component.direction === 'vertical' ? 'width' : 'height';
24
+ const direction = component.direction ?? 'vertical';
25
+
26
+ const fillImageUrl = resolveFillImageUrl(component.fillImage)
27
+ ? (() => {
28
+ const replacements = buildSmartTextReplacements(ctx.state, ctx.flow, component?.sku);
29
+ const raw = resolveFillImageUrl(component.fillImage) as string;
30
+ const resolved = interpolateSmartText(raw, replacements);
31
+ return resolved == null ? undefined : String(resolved);
32
+ })()
33
+ : undefined;
34
+
35
+ const textLike = new Set(['text', 'text-list', 'symbol', 'title', 'body', 'legal']);
36
+
37
+ return (
38
+ <View style={[containerStyle, styles.stack]}>
39
+ {fillImageUrl ? (
40
+ <ImageBackground
41
+ source={{ uri: fillImageUrl }}
42
+ resizeMode="cover"
43
+ style={StyleSheet.absoluteFill}
44
+ />
45
+ ) : null}
46
+ {component.components?.map((child: TComponent, i: number) => {
47
+ const isTextLike = textLike.has(child.component);
48
+ const sizeStyle = shouldSizeChildren ? { [sizeKey]: '100%' as const } : {};
49
+ const spacingStyle = childSpacingStyle(i, { spacing: component.spacing, direction }, scaleFactor);
50
+ const wrapperStyle = [
51
+ isTextLike ? styles.base : styles.overlay,
52
+ sizeStyle,
53
+ spacingStyle,
54
+ ];
55
+ return (
56
+ <View key={child.id ?? i} style={wrapperStyle}>
57
+ <TemplateRenderer component={child} scaleFactor={scaleFactor} onClose={onClose} parentDirection={direction} />
58
+ </View>
59
+ );
60
+ })}
61
+ </View>
62
+ );
63
+ };
64
+
65
+ const styles = StyleSheet.create({
66
+ stack: { alignItems: 'center', justifyContent: 'center' },
67
+ base: {},
68
+ overlay: { position: 'absolute', alignSelf: 'center' },
69
+ });