import React, { useCallback, useEffect, useRef, useState } from 'react'; import { TextInput, View, Text } from 'react-native'; import type { ViewStyle } from 'react-native'; import { type TTextInputComponent, getDeviceFormFactor, validateTextInput, } from '@namiml/sdk-core'; import { useFirstFocusReadyContext, usePaywallContext } from '../../context/PaywallContext'; import { useFocusEnabled, useRegisterPreferredFocus } from '../../context/FocusContext'; import { useTVPreferredFocus } from '../../utils/tvFocus'; import { paddingAndMarginStyles, parseColor, parseSize, positionStyles, sizeStyles, textStyles, transformStyles, } from '../../utils/styles'; import { TV_DEFAULTS, resolveAutoFocus, resolveInputVisual, resolveLabelText, resolveSpacing, resolveValidator, validatorType, } from './textInputStyle'; interface Props { component: TTextInputComponent; scaleFactor: number; onClose?: () => void; parentDirection?: string; } /** * `NamiTextInput` — in-flow text capture for the Expo SDK (NAM-1148). * * Renders a React Native `TextInput` whose value is mirrored into the shared * `PaywallState` via `ctx.setFormState(formId, value)`. The field registers its * validator with PaywallState on mount so the core implicit flow-submit gate * (sdk/core NamiFlow) validates every field before advancing; `setTagsFromForm` * and `{{ form.fieldId }}` then consume the captured value. In-flow capture only — * native-app value handoff is deferred (matches Apple NAM-1142 / Android NAM-1143). * * Styling follows the 4-state precedence (error > focused > filled > empty) and TV * defaults, resolved in the pure `./textInputStyle` helpers. Form-factor detection * goes through core's `getDeviceFormFactor()` (honors `Nami.configure({ formFactor })`). * * `component.focused` (NAM-1529) drives autofocus on mount, mirroring the * `NamiButton` `focused` precedent: `useTVPreferredFocus` (utils/tvFocus.ts) calls * `.focus()` on mobile (raises the keyboard) but is a no-op on TV, where * `hasTVPreferredFocus` sets d-pad focus only. The `focused` useState below is * unrelated — it only drives the 4-state visual precedence. */ export const NamiTextInput: React.FC = ({ component, scaleFactor, parentDirection }) => { const ctx = usePaywallContext(); const focusReadyCtx = useFirstFocusReadyContext(); const focusEnabled = useFocusEnabled(); const formId = component.formId; const [focused, setFocused] = useState(false); const inputRef = useRef(null); const autoFocus = resolveAutoFocus(component, focusEnabled); useTVPreferredFocus(inputRef, autoFocus); useRegisterPreferredFocus(inputRef.current, autoFocus); // Seed form state + register the validator once per field. useEffect(() => { if (!formId) return; const seeded = ctx.state.formStates?.[formId]; ctx.setFormState(formId, typeof seeded === 'string' ? seeded : ''); ctx.registerFormFieldValidator(formId, resolveValidator(component)); }, [formId]); const rawValue = formId ? ctx.state.formStates?.[formId] : undefined; const value = typeof rawValue === 'string' ? rawValue : ''; const error = formId ? ctx.state.formFieldErrors?.[formId] : undefined; const onChangeText = useCallback((text: string) => { if (!formId) return; ctx.setFormState(formId, text); if (component.validateOn === 'change') { const message = validateTextInput(validatorType(component), component.reqed === true, text); if (message) { ctx.setFormFieldError(formId, component.validationMessage ?? message); } else { ctx.clearFormFieldError(formId); } } }, [formId, ctx, component]); const handleFocus = useCallback(() => { setFocused(true); const paywallId = ctx.state.selectedPaywall?.id ?? 'unknown'; const page = ctx.state.currentPage ?? 'unknown'; const formFactor = ctx.state.formFactor; focusReadyCtx.notifyFirstFocusReady(paywallId, page, formFactor); }, [focusReadyCtx, ctx.state.selectedPaywall?.id, ctx.state.currentPage, ctx.state.formFactor]); const isTelevision = getDeviceFormFactor() === 'television'; const visual = resolveInputVisual(component, { focused, hasError: !!error, isTelevision }); const spacing = resolveSpacing(component, isTelevision); // The visual box (border / fill / radius / padding) belongs to the TextInput // itself via `resolveInputVisual` (matching the Web sibling). The outer View is // layout-only — position, outer margins/padding, size, transform — so the box is // not painted twice. const containerStyle: ViewStyle = { ...positionStyles(component), ...paddingAndMarginStyles(component, scaleFactor), ...sizeStyles(component, scaleFactor, parentDirection), ...transformStyles(component, scaleFactor), ...(component.zIndex != null ? { zIndex: component.zIndex } : {}), }; const inputStyle = { ...textStyles( { fontColor: visual.fontColor, fontName: component.fontName, fontSize: visual.fontSize, alignment: component.alignment }, scaleFactor, ), width: '100%' as const, borderStyle: 'solid' as const, borderWidth: visual.borderWidth, borderColor: parseColor(visual.borderColor) ?? 'transparent', borderRadius: visual.borderRadius, backgroundColor: parseColor(visual.fillColor) ?? 'transparent', paddingTop: parseSize(visual.padding.top, scaleFactor), paddingRight: parseSize(visual.padding.right, scaleFactor), paddingBottom: parseSize(visual.padding.bottom, scaleFactor), paddingLeft: parseSize(visual.padding.left, scaleFactor), }; const labelText = resolveLabelText(component); const showLabel = !!labelText; const labelStyle = textStyles( { fontColor: component.labelFontColor, fontName: component.labelFontName, fontSize: component.labelFontSize ?? (isTelevision ? TV_DEFAULTS.fontSize : undefined), }, scaleFactor, ); const errorStyle = textStyles( { fontColor: component.validationTextFontColor ?? '#D83A58', fontName: component.validationTextFontName, fontSize: component.validationTextFontSize ?? (isTelevision ? TV_DEFAULTS.fontSize : undefined), }, scaleFactor, ); const wrapperStyle: ViewStyle = { flexDirection: 'column', rowGap: spacing || undefined }; const isEmail = component.type === 'email'; return ( {showLabel ? {labelText} : null} setFocused(false)} placeholder={component.placeholderText} placeholderTextColor={parseColor(component.placeholderFontColor)} keyboardType={isEmail ? 'email-address' : 'default'} autoCapitalize={isEmail ? 'none' : 'sentences'} autoCorrect={!isEmail} style={inputStyle} hasTVPreferredFocus={autoFocus} accessibilityLabel={component.screenreaderText ?? labelText} accessibilityHint={component.screenreaderHint} /> {error ? {error} : null} ); };