@namiml/expo-sdk 3.4.1 → 3.4.2-dev.202606032130

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@namiml/expo-sdk",
3
- "version": "3.4.1",
3
+ "version": "3.4.2-dev.202606032130",
4
4
  "type": "module",
5
5
  "description": "Nami Expo SDK — paywall and subscription management for Expo apps",
6
6
  "main": "./dist/index.cjs",
@@ -29,9 +29,10 @@
29
29
  "prepublishOnly": "yarn clean && yarn build:prod"
30
30
  },
31
31
  "dependencies": {
32
- "@namiml/expo-nami-iap": "3.4.1",
33
- "@namiml/sdk-core": "3.4.1",
32
+ "@namiml/expo-nami-iap": "3.4.2-dev.202606032130",
33
+ "@namiml/sdk-core": "3.4.2-dev.202606032130",
34
34
  "react-native-qrcode-svg": "^6.3.21",
35
+ "react-native-safe-area-context": "^5.6.0",
35
36
  "react-native-svg": "^15.15.4"
36
37
  },
37
38
  "peerDependencies": {
@@ -1,5 +1,6 @@
1
1
  import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react';
2
- import { View, StyleSheet, Animated, Dimensions, SafeAreaView, StatusBar, BackHandler, ActivityIndicator } from 'react-native';
2
+ import { View, StyleSheet, Animated, Dimensions, StatusBar, BackHandler, ActivityIndicator } from 'react-native';
3
+ import { SafeAreaProvider, initialWindowMetrics } from 'react-native-safe-area-context';
3
4
  import type {
4
5
  IPaywall,
5
6
  NamiPaywallLaunchContext,
@@ -11,6 +12,7 @@ import type {
11
12
  import {
12
13
  getPaywallDataFromLabel,
13
14
  getPaywall,
15
+ isValidUrl,
14
16
  isNamiFlowCampaign,
15
17
  NamiReservedActions,
16
18
  hasAllPaywalls,
@@ -141,7 +143,13 @@ export const NamiView: React.FC<NamiViewProps> = ({
141
143
  const resolvedLaunch = useMemo<LaunchRequest>(() => {
142
144
  if (launchRequest?.value) return launchRequest;
143
145
  if (url) return { type: 'url', value: url };
144
- if (placement) return { type: 'label', value: placement };
146
+ if (placement) {
147
+ // A URL/deeplink campaign carries its URL in the value. If one is handed
148
+ // in via the placement prop, classify it as a url launch so core takes
149
+ // the URL-match branch — otherwise the label lookup misses, no paywall
150
+ // resolves, and NamiView spins on the placeholder forever.
151
+ return { type: isValidUrl(placement) ? 'url' : 'label', value: placement };
152
+ }
145
153
  return { type: undefined, value: '' };
146
154
  }, [launchRequest, url, placement]);
147
155
 
@@ -638,7 +646,7 @@ export const NamiView: React.FC<NamiViewProps> = ({
638
646
  }, []);
639
647
 
640
648
  if (isClosing) {
641
- return <SafeAreaView style={styles.root} />;
649
+ return <View style={styles.root} />;
642
650
  }
643
651
 
644
652
  if (loading || !campaignData || (!isFlowCampaign && !paywallData) || (isFlowCampaign && !hasFlowScreen)) {
@@ -646,8 +654,14 @@ export const NamiView: React.FC<NamiViewProps> = ({
646
654
  }
647
655
 
648
656
  return (
649
- <SafeAreaView style={styles.root}>
650
- <StatusBar barStyle="light-content" />
657
+ // Full-bleed root so the paywall background extends under the status bar and
658
+ // home indicator (edge-to-edge). Safe-area insets are applied only to the
659
+ // chrome (header/footer) via useSafeAreaInsets, not the whole tree — the
660
+ // previous core SafeAreaView inset every edge and let the #000 root show
661
+ // through as letterbox bands. SafeAreaProvider guarantees an inset context
662
+ // even if the host app didn't mount one (nested providers are supported).
663
+ <SafeAreaProvider initialMetrics={initialWindowMetrics} style={styles.root}>
664
+ <StatusBar barStyle="light-content" translucent backgroundColor="transparent" />
651
665
  {isFlowCampaign ? (
652
666
  <FlowRenderer
653
667
  paywalls={flowState.paywalls}
@@ -673,7 +687,7 @@ export const NamiView: React.FC<NamiViewProps> = ({
673
687
  )}
674
688
  {!isClosing && showLaunchPlaceholder && <LaunchPlaceholder paywall={launchPlaceholderPaywall} overlay />}
675
689
  {showTransitionPlaceholder && <LaunchPlaceholder paywall={pendingTransitionPaywall} overlay />}
676
- </SafeAreaView>
690
+ </SafeAreaProvider>
677
691
  );
678
692
  };
679
693
 
@@ -1,5 +1,6 @@
1
1
  import React, { useEffect, useMemo, useState } from 'react';
2
2
  import { ActivityIndicator, BackHandler, View, StyleSheet } from 'react-native';
3
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
3
4
  import type { IPaywall, TPages } from '@namiml/sdk-core';
4
5
  import { useFirstFocusReadyContext, usePaywallContext } from '../context/PaywallContext';
5
6
  import { NamiContentContainer } from './containers/NamiContentContainer';
@@ -36,7 +37,20 @@ export const PaywallScreen: React.FC<Props> = ({
36
37
  isActive = true,
37
38
  }) => {
38
39
  const ctx = usePaywallContext();
40
+ const insets = useSafeAreaInsets();
39
41
  const focusReadyCtx = useFirstFocusReadyContext();
42
+
43
+ // Push the measured top safe-area inset into paywall state so templates can
44
+ // position chrome via ${state.safeAreaTop} (e.g. header topPadding,
45
+ // background topMargin). Mirrors the native Android renderer, which measures
46
+ // the display cutout and calls setSafeAreaTop. The full-bleed root lets the
47
+ // background extend under the status bar; this offsets the content back into
48
+ // the safe area. No safeAreaBottom equivalent exists in core/Android state.
49
+ // Depend only on insets.top (stable per device/orientation) — depending on
50
+ // ctx would re-run every render and loop through setState.
51
+ useEffect(() => {
52
+ ctx.setSafeAreaTop(insets.top);
53
+ }, [insets.top]);
40
54
  const scaleFactor = getDeviceScaleFactor(ctx.state.formFactor);
41
55
  const userInteractionEnabled = ctx.state.userInteractionEnabled !== false;
42
56
  const currentPageName = ctx.state.selectedPaywall === paywall
@@ -19,6 +19,17 @@ function splitContainerStyles(style: ViewStyle): { outer: ViewStyle; inner: View
19
19
  } = style;
20
20
 
21
21
  const outer: ViewStyle = { ...rest };
22
+ // The content container is always the page's vertical fill region: styles.outer
23
+ // declares flex:1 and the body scrolls internally. A payload height of
24
+ // 'fitContent' makes sizeStyles emit flexGrow:0, which would otherwise override
25
+ // that fill and collapse the body to 0 height — the flex:1 ScrollView child
26
+ // can't give an unbounded parent a height. Drop the flex-sizing keys so the
27
+ // fill stays authoritative, matching the native renderers which always
28
+ // fill-and-scroll this region regardless of the authored height.
29
+ delete outer.flex;
30
+ delete outer.flexGrow;
31
+ delete outer.flexBasis;
32
+ delete outer.flexShrink;
22
33
  const inner: ViewStyle = {
23
34
  ...(paddingLeft != null ? { paddingLeft } : {}),
24
35
  ...(paddingRight != null ? { paddingRight } : {}),
@@ -1,5 +1,5 @@
1
1
  import React, { useMemo, useState, useCallback } from 'react';
2
- import { View, StyleSheet, Dimensions, LayoutChangeEvent, ViewStyle } from 'react-native';
2
+ import { View, StyleSheet, Dimensions, LayoutChangeEvent, ScrollView, ViewStyle } from 'react-native';
3
3
  import { applyGridStyles, focusedStyleOverrides, parseSize } from '../../utils/styles';
4
4
  import { TemplateRenderer } from '../TemplateRenderer';
5
5
  import type { TComponent } from '@namiml/sdk-core';
@@ -41,11 +41,75 @@ export const NamiResponsiveGrid: React.FC<Props> = ({ component, scaleFactor, on
41
41
  const itemWidth = (usableWidth - gap * (columns - 1)) / columns;
42
42
 
43
43
  const repeatingBlocks = useMemo(() => getRepeatingListBlocks(ctx, component), [ctx, component]);
44
+
45
+ const isGrouped = !!(component.groupBy && component.groupHeaderTemplate);
46
+ const isHorizontalGrouped = !isVertical && isGrouped && repeatingBlocks.length > 0;
47
+
48
+ // Grouped horizontal: walk blocks and build per-group sections.
49
+ // Only used when isHorizontalGrouped; computed unconditionally to satisfy hook rules.
50
+ const groupSections = useMemo<Array<{ header: TComponent; items: TComponent[] }>>(() => {
51
+ if (!isHorizontalGrouped) return [];
52
+ const sections: Array<{ header: TComponent; items: TComponent[] }> = [];
53
+ let currentItems: TComponent[] = [];
54
+ repeatingBlocks.forEach((subArray: TComponent[]) => {
55
+ const isHeader =
56
+ subArray.length === 1 && (subArray[0] as any)?.__namiGroupHeader === true;
57
+ if (isHeader) {
58
+ currentItems = [];
59
+ sections.push({ header: subArray[0], items: currentItems });
60
+ } else {
61
+ subArray.forEach((child) => currentItems.push(child));
62
+ }
63
+ });
64
+ return sections;
65
+ }, [isHorizontalGrouped, repeatingBlocks]);
66
+
67
+ // Flat items list — used only by non-grouped paths.
44
68
  const items: TComponent[] = useMemo(
45
69
  () => (repeatingBlocks.length ? repeatingBlocks.flat() : (component.components ?? [])),
46
70
  [component.components, repeatingBlocks],
47
71
  );
48
72
 
73
+ if (isHorizontalGrouped) {
74
+ return (
75
+ <FocusScope onFocusWithinChange={setIsFocused}>
76
+ <View style={containerStyle} onLayout={onLayout}>
77
+ {groupSections.map((group, groupIdx) => (
78
+ <View key={(group.header as any).id ?? `group-${groupIdx}`} style={styles.groupSection}>
79
+ <View style={styles.groupHeader}>
80
+ <TemplateRenderer
81
+ component={group.header}
82
+ scaleFactor={scaleFactor}
83
+ onClose={onClose}
84
+ parentDirection={direction}
85
+ />
86
+ </View>
87
+ <ScrollView
88
+ horizontal
89
+ showsHorizontalScrollIndicator={false}
90
+ contentContainerStyle={styles.horizontalScrollContent}
91
+ >
92
+ {group.items.map((child: TComponent, itemIdx: number) => (
93
+ <View
94
+ key={(child as any).id ?? `group-${groupIdx}-item-${itemIdx}`}
95
+ style={{ marginRight: itemIdx < group.items.length - 1 ? gap : 0 }}
96
+ >
97
+ <TemplateRenderer
98
+ component={child}
99
+ scaleFactor={scaleFactor}
100
+ onClose={onClose}
101
+ parentDirection={direction}
102
+ />
103
+ </View>
104
+ ))}
105
+ </ScrollView>
106
+ </View>
107
+ ))}
108
+ </View>
109
+ </FocusScope>
110
+ );
111
+ }
112
+
49
113
  return (
50
114
  <FocusScope onFocusWithinChange={setIsFocused}>
51
115
  <View style={[containerStyle, styles.grid, isVertical ? styles.vertical : styles.horizontal]} onLayout={onLayout}>
@@ -72,4 +136,7 @@ const styles = StyleSheet.create({
72
136
  grid: { alignItems: 'center', justifyContent: 'center' },
73
137
  horizontal: { flexDirection: 'row', flexWrap: 'wrap', alignSelf: 'baseline' },
74
138
  vertical: { flexDirection: 'column', flexWrap: 'nowrap', alignSelf: 'baseline' },
139
+ groupSection: { width: '100%' },
140
+ groupHeader: { width: '100%' },
141
+ horizontalScrollContent: { flexDirection: 'row', flexWrap: 'nowrap' },
75
142
  });