@namiml/expo-sdk 3.4.5-dev.202607092259 → 3.4.5-dev.202607100416

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.5-dev.202607092259",
3
+ "version": "3.4.5-dev.202607100416",
4
4
  "type": "module",
5
5
  "description": "Nami Expo SDK — paywall and subscription management for Expo apps",
6
6
  "main": "./dist/index.cjs",
@@ -29,8 +29,8 @@
29
29
  "prepublishOnly": "yarn clean && yarn build:prod"
30
30
  },
31
31
  "dependencies": {
32
- "@namiml/expo-nami-iap": "3.4.5-dev.202607092259",
33
- "@namiml/sdk-core": "3.4.5-dev.202607092259",
32
+ "@namiml/expo-nami-iap": "3.4.5-dev.202607100416",
33
+ "@namiml/sdk-core": "3.4.5-dev.202607100416",
34
34
  "react-native-qrcode-svg": "^6.3.21",
35
35
  "react-native-safe-area-context": "^5.6.0",
36
36
  "react-native-svg": "^15.15.4"
@@ -83,6 +83,9 @@ function resolveRenderer(rendererKey: string): ComponentRenderer | null {
83
83
  case 'NamiToggleSwitch':
84
84
  renderer = require('./elements/NamiToggleSwitch').NamiToggleSwitch;
85
85
  break;
86
+ case 'NamiTextInput':
87
+ renderer = require('./elements/NamiTextInput').NamiTextInput;
88
+ break;
86
89
  case 'NamiRadioButton':
87
90
  renderer = require('./elements/NamiRadioButton').NamiRadioButton;
88
91
  break;
@@ -221,6 +224,8 @@ function renderResolvedComponent(
221
224
  return renderWithKey('NamiSegmentPickerItem', sharedProps);
222
225
  case 'toggleSwitch':
223
226
  return renderWithKey('NamiToggleSwitch', sharedProps);
227
+ case 'textInput':
228
+ return renderWithKey('NamiTextInput', sharedProps);
224
229
  case 'radio':
225
230
  return renderWithKey('NamiRadioButton', sharedProps);
226
231
  case 'progressIndicator':
@@ -0,0 +1,156 @@
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import { TextInput, View, Text } from 'react-native';
3
+ import type { ViewStyle } from 'react-native';
4
+ import {
5
+ type TTextInputComponent,
6
+ getDeviceFormFactor,
7
+ validateTextInput,
8
+ } from '@namiml/sdk-core';
9
+ import { usePaywallContext } from '../../context/PaywallContext';
10
+ import {
11
+ paddingAndMarginStyles,
12
+ parseColor,
13
+ parseSize,
14
+ positionStyles,
15
+ sizeStyles,
16
+ textStyles,
17
+ transformStyles,
18
+ } from '../../utils/styles';
19
+ import {
20
+ TV_DEFAULTS,
21
+ resolveInputVisual,
22
+ resolveLabelText,
23
+ resolveSpacing,
24
+ resolveValidator,
25
+ validatorType,
26
+ } from './textInputStyle';
27
+
28
+ interface Props {
29
+ component: TTextInputComponent;
30
+ scaleFactor: number;
31
+ onClose?: () => void;
32
+ parentDirection?: string;
33
+ }
34
+
35
+ /**
36
+ * `NamiTextInput` — in-flow text capture for the Expo SDK (NAM-1148).
37
+ *
38
+ * Renders a React Native `TextInput` whose value is mirrored into the shared
39
+ * `PaywallState` via `ctx.setFormState(formId, value)`. The field registers its
40
+ * validator with PaywallState on mount so the core implicit flow-submit gate
41
+ * (sdk/core NamiFlow) validates every field before advancing; `setTagsFromForm`
42
+ * and `{{ form.fieldId }}` then consume the captured value. In-flow capture only —
43
+ * native-app value handoff is deferred (matches Apple NAM-1142 / Android NAM-1143).
44
+ *
45
+ * Styling follows the 4-state precedence (error > focused > filled > empty) and TV
46
+ * defaults, resolved in the pure `./textInputStyle` helpers. Form-factor detection
47
+ * goes through core's `getDeviceFormFactor()` (honors `Nami.configure({ formFactor })`).
48
+ */
49
+ export const NamiTextInput: React.FC<Props> = ({ component, scaleFactor, parentDirection }) => {
50
+ const ctx = usePaywallContext();
51
+ const formId = component.formId;
52
+ const [focused, setFocused] = useState(false);
53
+
54
+ // Seed form state + register the validator once per field.
55
+ useEffect(() => {
56
+ if (!formId) return;
57
+ const seeded = ctx.state.formStates?.[formId];
58
+ ctx.setFormState(formId, typeof seeded === 'string' ? seeded : '');
59
+ ctx.registerFormFieldValidator(formId, resolveValidator(component));
60
+ }, [formId]);
61
+
62
+ const rawValue = formId ? ctx.state.formStates?.[formId] : undefined;
63
+ const value = typeof rawValue === 'string' ? rawValue : '';
64
+ const error = formId ? ctx.state.formFieldErrors?.[formId] : undefined;
65
+
66
+ const onChangeText = useCallback((text: string) => {
67
+ if (!formId) return;
68
+ ctx.setFormState(formId, text);
69
+ if (component.validateOn === 'change') {
70
+ const message = validateTextInput(validatorType(component), component.reqed === true, text);
71
+ if (message) {
72
+ ctx.setFormFieldError(formId, component.validationMessage ?? message);
73
+ } else {
74
+ ctx.clearFormFieldError(formId);
75
+ }
76
+ }
77
+ }, [formId, ctx, component]);
78
+
79
+ const isTelevision = getDeviceFormFactor() === 'television';
80
+ const visual = resolveInputVisual(component, { focused, hasError: !!error, isTelevision });
81
+ const spacing = resolveSpacing(component, isTelevision);
82
+
83
+ // The visual box (border / fill / radius / padding) belongs to the TextInput
84
+ // itself via `resolveInputVisual` (matching the Web sibling). The outer View is
85
+ // layout-only — position, outer margins/padding, size, transform — so the box is
86
+ // not painted twice.
87
+ const containerStyle: ViewStyle = {
88
+ ...positionStyles(component),
89
+ ...paddingAndMarginStyles(component, scaleFactor),
90
+ ...sizeStyles(component, scaleFactor, parentDirection),
91
+ ...transformStyles(component, scaleFactor),
92
+ ...(component.zIndex != null ? { zIndex: component.zIndex } : {}),
93
+ };
94
+
95
+ const inputStyle = {
96
+ ...textStyles(
97
+ { fontColor: visual.fontColor, fontName: component.fontName, fontSize: visual.fontSize, alignment: component.alignment },
98
+ scaleFactor,
99
+ ),
100
+ width: '100%' as const,
101
+ borderStyle: 'solid' as const,
102
+ borderWidth: visual.borderWidth,
103
+ borderColor: parseColor(visual.borderColor) ?? 'transparent',
104
+ borderRadius: visual.borderRadius,
105
+ backgroundColor: parseColor(visual.fillColor) ?? 'transparent',
106
+ paddingTop: parseSize(visual.padding.top, scaleFactor),
107
+ paddingRight: parseSize(visual.padding.right, scaleFactor),
108
+ paddingBottom: parseSize(visual.padding.bottom, scaleFactor),
109
+ paddingLeft: parseSize(visual.padding.left, scaleFactor),
110
+ };
111
+
112
+ const labelText = resolveLabelText(component);
113
+ const showLabel = !!labelText;
114
+ const labelStyle = textStyles(
115
+ {
116
+ fontColor: component.labelFontColor,
117
+ fontName: component.labelFontName,
118
+ fontSize: component.labelFontSize ?? (isTelevision ? TV_DEFAULTS.fontSize : undefined),
119
+ },
120
+ scaleFactor,
121
+ );
122
+ const errorStyle = textStyles(
123
+ {
124
+ fontColor: component.validationTextFontColor ?? '#D83A58',
125
+ fontName: component.validationTextFontName,
126
+ fontSize: component.validationTextFontSize ?? (isTelevision ? TV_DEFAULTS.fontSize : undefined),
127
+ },
128
+ scaleFactor,
129
+ );
130
+
131
+ const wrapperStyle: ViewStyle = { flexDirection: 'column', rowGap: spacing || undefined };
132
+ const isEmail = component.type === 'email';
133
+
134
+ return (
135
+ <View style={containerStyle}>
136
+ <View style={wrapperStyle}>
137
+ {showLabel ? <Text style={labelStyle}>{labelText}</Text> : null}
138
+ <TextInput
139
+ value={value}
140
+ onChangeText={onChangeText}
141
+ onFocus={() => setFocused(true)}
142
+ onBlur={() => setFocused(false)}
143
+ placeholder={component.placeholderText}
144
+ placeholderTextColor={parseColor(component.placeholderFontColor)}
145
+ keyboardType={isEmail ? 'email-address' : 'default'}
146
+ autoCapitalize={isEmail ? 'none' : 'sentences'}
147
+ autoCorrect={!isEmail}
148
+ style={inputStyle}
149
+ accessibilityLabel={component.screenreaderText ?? labelText}
150
+ accessibilityHint={component.screenreaderHint}
151
+ />
152
+ {error ? <Text style={errorStyle}>{error}</Text> : null}
153
+ </View>
154
+ </View>
155
+ );
156
+ };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Pure, React-Native-agnostic resolution helpers for the Expo Text Input element
3
+ * (NAM-1148). Kept free of any `react-native` import so they can be unit-tested
4
+ * under the package's `node` jest environment (see
5
+ * tests/components/elements/textInputStyle.test.ts).
6
+ *
7
+ * These mirror the Web sibling (sdk/web/src/components/elements/TextInput.ts) and
8
+ * the Apple (NAM-1142) / Android (NAM-1143) native ports: a 4-state style
9
+ * precedence (error > focused > filled > empty) and larger `television` defaults
10
+ * applied only where the payload omits a value. Values are returned raw (colors as
11
+ * payload strings, sizes as numbers); the component applies `parseColor`/`parseSize`.
12
+ */
13
+
14
+ import type { TTextInputComponent, TTextInputType, FormFieldValidator } from '@namiml/sdk-core';
15
+
16
+ /**
17
+ * TV defaults applied when the form factor is `television` and the payload omits
18
+ * the value. Mirrors the Web `TV_DEFAULTS` and the native ports.
19
+ */
20
+ export const TV_DEFAULTS = {
21
+ borderWidth: 2,
22
+ borderRadius: 6,
23
+ width: 500,
24
+ fontSize: 20,
25
+ spacing: 16,
26
+ innerPadding: 16,
27
+ };
28
+
29
+ export interface ResolvedInputVisual {
30
+ borderColor: string;
31
+ borderWidth: number;
32
+ borderRadius: number;
33
+ fillColor: string;
34
+ fontColor?: string;
35
+ fontSize?: number | string;
36
+ padding: { top: number; right: number; bottom: number; left: number };
37
+ }
38
+
39
+ interface StateFlags {
40
+ focused: boolean;
41
+ hasError: boolean;
42
+ isTelevision: boolean;
43
+ }
44
+
45
+ /** `null`/`undefined` numeric prop → TV fallback (or 0); otherwise `Number(value)`. */
46
+ function resolveBorderWidth(base: number | string | undefined, isTelevision: boolean): number {
47
+ if (base == null) return isTelevision ? TV_DEFAULTS.borderWidth : 0;
48
+ return Number(base);
49
+ }
50
+
51
+ /** Map the wire `type` to the validator's `TTextInputType` (email → email, else name). */
52
+ export function validatorType(component: Pick<TTextInputComponent, 'type'>): TTextInputType {
53
+ return component.type === 'email' ? 'email' : 'name';
54
+ }
55
+
56
+ /** Build the field's validator from the (misspelled) `reqed` flag + author message. */
57
+ export function resolveValidator(component: TTextInputComponent): FormFieldValidator {
58
+ return {
59
+ type: validatorType(component),
60
+ required: component.reqed === true,
61
+ message: component.validationMessage,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * The label to render, or `undefined` when there is no label. Appends ` *` when the
67
+ * field is required, matching the Web renderer.
68
+ */
69
+ export function resolveLabelText(component: TTextInputComponent): string | undefined {
70
+ if (component.labelPosition === 'none') return undefined;
71
+ if (!component.labelText) return undefined;
72
+ return component.reqed === true ? `${component.labelText} *` : component.labelText;
73
+ }
74
+
75
+ /**
76
+ * Resolve the input's visual properties under the 4-state precedence
77
+ * error > focused > filled > empty. Filled and empty share the resting style; the
78
+ * distinction is preserved for parity with the native ports.
79
+ */
80
+ export function resolveInputVisual(
81
+ component: TTextInputComponent,
82
+ { focused, hasError, isTelevision }: StateFlags,
83
+ ): ResolvedInputVisual {
84
+ let borderColor = component.borderColor || 'transparent';
85
+ let borderWidth = resolveBorderWidth(component.borderWidth, isTelevision);
86
+ let borderRadius = Number(component.borderRadius ?? (isTelevision ? TV_DEFAULTS.borderRadius : 0));
87
+ let fillColor = component.fillColor || 'transparent';
88
+
89
+ if (focused) {
90
+ borderColor = component.focusedBorderColor || borderColor;
91
+ borderWidth = resolveBorderWidth(component.focusedBorderWidth ?? component.borderWidth, isTelevision);
92
+ borderRadius = Number(component.focusedBorderRadius ?? component.borderRadius ?? borderRadius);
93
+ fillColor = component.focusedFillColor || fillColor;
94
+ }
95
+
96
+ if (hasError) {
97
+ borderColor = component.errorBorderColor || borderColor;
98
+ borderWidth = resolveBorderWidth(component.errorBorderWidth ?? component.borderWidth, isTelevision);
99
+ borderRadius = Number(component.errorBorderRadius ?? component.borderRadius ?? borderRadius);
100
+ }
101
+
102
+ const fontSize = component.fontSize ?? (isTelevision ? TV_DEFAULTS.fontSize : undefined);
103
+ const fallbackPad = isTelevision ? TV_DEFAULTS.innerPadding : 0;
104
+
105
+ return {
106
+ borderColor,
107
+ borderWidth,
108
+ borderRadius,
109
+ fillColor,
110
+ fontColor: component.fontColor,
111
+ fontSize,
112
+ padding: {
113
+ top: Number(component.innerTopPadding ?? fallbackPad),
114
+ right: Number(component.innerRightPadding ?? fallbackPad),
115
+ bottom: Number(component.innerBottomPadding ?? fallbackPad),
116
+ left: Number(component.innerLeftPadding ?? fallbackPad),
117
+ },
118
+ };
119
+ }
120
+
121
+ /** Spacing between label / input / error, in points. TV gets a default gap. */
122
+ export function resolveSpacing(component: TTextInputComponent, isTelevision: boolean): number {
123
+ const { spacing } = component;
124
+ if (spacing == null) return isTelevision ? TV_DEFAULTS.spacing : 0;
125
+ return Number(spacing);
126
+ }
@@ -4,7 +4,7 @@ import type {
4
4
  IPaywall, ISkuMenu, TPaywallContext, NamiPaywallLaunchContext,
5
5
  NamiCampaign, NamiProductDetails, NamiSKU, NamiPaywallEvent,
6
6
  TDevice, TimerState, TPaywallMedia, NamiAppSuppliedVideoDetails,
7
- NamiFlow,
7
+ NamiFlow, FormFieldValidator,
8
8
  } from '@namiml/sdk-core';
9
9
  import { PaywallState, initialState } from '@namiml/sdk-core';
10
10
 
@@ -35,6 +35,9 @@ export interface PaywallContextValue {
35
35
  setSelectedProducts(products: Record<string, string | null>): void;
36
36
  setCurrentFormId(formId: string, value?: string): void;
37
37
  setFormState(formId: string, value: boolean | string): void;
38
+ registerFormFieldValidator(formId: string, validator: FormFieldValidator): void;
39
+ setFormFieldError(formId: string, message: string): void;
40
+ clearFormFieldError(formId: string): void;
38
41
  setTimerState(timerId: string, remainingSeconds: number, savedAt: number, hasEmittedCompletion: boolean): void;
39
42
  getTimerState(timerId: string): TimerState | undefined;
40
43
  setProductDetails(details: NamiProductDetails[]): void;
@@ -181,6 +184,15 @@ export const PaywallProvider: React.FC<PaywallProviderProps> = ({
181
184
  setFormState: (formId: string, value: boolean | string) => {
182
185
  providerRef.current?.setFormState(formId, value);
183
186
  },
187
+ registerFormFieldValidator: (formId: string, validator: FormFieldValidator) => {
188
+ providerRef.current?.registerFormFieldValidator(formId, validator);
189
+ },
190
+ setFormFieldError: (formId: string, message: string) => {
191
+ providerRef.current?.setFormFieldError(formId, message);
192
+ },
193
+ clearFormFieldError: (formId: string) => {
194
+ providerRef.current?.clearFormFieldError(formId);
195
+ },
184
196
  setTimerState: (timerId: string, remainingSeconds: number, savedAt: number, hasEmittedCompletion: boolean) => {
185
197
  providerRef.current?.setTimerState(timerId, remainingSeconds, savedAt, hasEmittedCompletion);
186
198
  },
package/src/index.ts CHANGED
@@ -54,6 +54,10 @@ export type {
54
54
  CustomerJourneyState,
55
55
  NamiProductDetails,
56
56
  NamiFlow,
57
+ // Text Input component (NAM-1148) — typing parity with the native/Web ports.
58
+ TTextInputComponent,
59
+ TTextInputType,
60
+ FormFieldValidator,
57
61
  IPurchaseAdapter,
58
62
  PurchaseContext,
59
63
  PurchaseResult,