@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/dist/index.cjs +14 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.mjs +14 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/components/TemplateRenderer.tsx +5 -0
- package/src/components/elements/NamiTextInput.tsx +156 -0
- package/src/components/elements/textInputStyle.ts +126 -0
- package/src/context/PaywallContext.tsx +13 -1
- package/src/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@namiml/expo-sdk",
|
|
3
|
-
"version": "3.4.5-dev.
|
|
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.
|
|
33
|
-
"@namiml/sdk-core": "3.4.5-dev.
|
|
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,
|