import React from 'react'; import { TextInput, type TextInputProps } from 'react-native'; import { useSecureCardForm, fieldNamesMap } from './Context'; import { useFocus } from './hooks/useFocus'; import { useUpdateEffect } from './hooks/useUpdateEffect'; import type { SecureFieldType } from './types'; import { MoneyHash } from '../nativeModule'; import { throwMhError } from '../utils/throwIf'; export type CardInfo = { first6Digits: string; brand: 'visa' | 'mastercard' | 'mada' | 'troy' | 'amex' | 'unknown'; brandIconUrl: string; }; type Field = { onFocus?: () => void; onBlur?: () => void; onChange?: (options: { isValid: boolean; length: number; errorMessage?: string; }) => void; onErrorChange?: (options: { isValid: boolean; errorMessage?: string; }) => void; style?: | TextInputProps['style'] | ((states: { isDirty: boolean; isFocused: boolean; isError: boolean; }) => Required['style']); }; type NonCvvFieldTypes = { [K in Exclude]: { name: K; onCardBrandChange?: never; maskCvv?: never; } & Field; }[Exclude]; type CvvField = Field & { name: Extract; onCardBrandChange?: never; /** * When `true`, renders the CVV input with password-style masking (dots) * via `TextInput`'s `secureTextEntry`. The underlying stored value is * unchanged — this is a purely visual setting. Defaults to `false`. * * Available only on the CVV field; setting this prop on any other field * name is a compile-time error. */ maskCvv?: boolean; }; type CardNumberField = Field & { name: Extract; onCardBrandChange?: (cardInfo: CardInfo | undefined) => void; maskCvv?: never; }; type CardHolderNameField = Field & { name: Extract; onCardBrandChange?: never; maskCvv?: never; }; export type SecureTextFieldProps = ( | NonCvvFieldTypes | CvvField | CardNumberField | CardHolderNameField ) & Omit< TextInputProps, 'style' | 'onFocus' | 'onBlur' | 'onChangeText' | 'onChange' | 'maxLength' >; const autoCompleteMap: Record = { cardNumber: 'cc-number', cardHolderName: 'name', expiryYear: 'cc-exp-year', expiryMonth: 'cc-exp-month', cvv: 'cc-csc', }; type FieldState = { isValid: boolean; errorMessage?: string; formattedValue: string; length: number; cardBrand?: { first6Digits: string; brand: 'visa' | 'mastercard' | 'mada' | 'troy' | 'amex' | 'unknown'; brandIconUrl: string; }; }; function parseFieldState(result: any): FieldState { const parsedJson = JSON.parse(result); return { isValid: parsedJson.isValid, errorMessage: parsedJson.isValid ? undefined : parsedJson.errorMessage, formattedValue: parsedJson.formattedValue, length: parsedJson.length, cardBrand: parsedJson.cardBrand ? { first6Digits: parsedJson.cardBrand.first6Digits, brand: parsedJson.cardBrand.brand, brandIconUrl: parsedJson.cardBrand.brandIconUrl, } : undefined, }; } // Stores the field's value and refreshes the brand. Returns no state — call this // only when the field's text changes, then call `validate` to read the state. function update(type: SecureFieldType, value: string): Promise { return new Promise((resolve, reject) => { MoneyHash.updateCardField(fieldNamesMap[type], value) .then(() => resolve()) .catch((error: any) => { throwMhError(error, reject); }); }); } // Validates the field's already-stored value against the current brand and // returns its formatted/validated state. Call after `update` on text change, // and on focus change / blur / brand change (CVV) — it never re-sends a value. function validate(type: SecureFieldType): Promise { return new Promise((resolve, reject) => { MoneyHash.validateCardField(fieldNamesMap[type]) .then((result: any) => { try { resolve(parseFieldState(result)); } catch (error: any) { throwMhError(error, reject); } }) .catch((error: any) => { throwMhError(error, reject); }); }); } export type SecureTextFieldRef = { focus: () => void; blur: () => void; clear: () => void; }; export const SecureTextField = React.forwardRef< SecureTextFieldRef, SecureTextFieldProps >( ( { name, onChange, onBlur, onFocus, onErrorChange, onCardBrandChange, style, maskCvv, ...props }, ref ) => { const textInputRef = React.useRef(null); const [value, setValue] = React.useState(''); const valueRef = React.useRef(''); const [isDirty, setIsDirty] = React.useState(false); const isDirtyRef = React.useRef(false); const [isError, setIsError] = React.useState(false); const isErrorRef = React.useRef(false); const [cardInfo, setCardInfo] = React.useState( undefined ); const [isFocused, _onFocus, _onBlur] = useFocus(); const { updateValue, updateFieldValidity, brand, setBrand } = useSecureCardForm(); React.useImperativeHandle( ref, () => ({ focus: () => textInputRef.current?.focus(), blur: () => textInputRef.current?.blur(), clear: () => textInputRef.current?.clear(), }), [textInputRef] ); React.useEffect(() => { updateValue({ name, value }); }, [name, updateValue, value]); React.useEffect(() => { update(name, '') .then(() => validate(name)) .then(({ isValid }) => { updateFieldValidity({ name, isValid, }); }); }, [name, updateFieldValidity]); // Re-validate this field against the current card brand without changing its // value. The form calls this for the CVV whenever the brand changes, since // the CVV's required length is brand-dependent (e.g. Amex needs 4 digits). // Surface the new state whenever the field has a value (mirrors the native // cvvStateHandler, which emits on brand change for any non-empty field), so // a 3-digit CVV is flagged the moment the number reveals Amex. const revalidate = React.useCallback(async () => { const { isValid, errorMessage, length } = await validate(name); if (length === 0) return; // not filled yet — nothing to surface updateFieldValidity({ name, isValid }); if (!isDirtyRef.current) { setIsDirty(true); isDirtyRef.current = true; } if (!isValid !== isErrorRef.current) { setIsError(!isValid); isErrorRef.current = !isValid; onErrorChange?.({ isValid, errorMessage }); } }, [name, updateFieldValidity, onErrorChange]); // Only the CVV re-validates when the brand changes (its length is // brand-dependent). useUpdateEffect skips the first render, so this runs only // on actual brand changes. useUpdateEffect(() => { if (name === 'cvv') revalidate(); }, [brand]); return ( { _onFocus(); onFocus?.(); }} onBlur={async () => { _onBlur(); onBlur?.(); // Re-validate (only) on every blur so the field's error is always // consistent with its current value — leaving any field flags it red // when invalid, clears it when valid. The value was already stored by // the last text-change `update`, so no update is needed here. if (!isDirtyRef.current) { setIsDirty(true); isDirtyRef.current = true; } const { isValid, errorMessage } = await validate(name); updateFieldValidity({ name, isValid }); if (!isValid !== isErrorRef.current) { setIsError(!isValid); isErrorRef.current = !isValid; onErrorChange?.({ isValid, errorMessage }); } }} style={ typeof style === 'function' ? style({ isFocused, isDirty, isError }) : style } {...props} secureTextEntry={name === 'cvv' && maskCvv === true} onChangeText={async (updatedValue) => { setValue(updatedValue); valueRef.current = updatedValue; // Text changed: update (store) first, then validate to read state. await update(name, updatedValue); const { isValid, errorMessage, cardBrand, formattedValue, length } = await validate(name); setValue(formattedValue); valueRef.current = formattedValue; if (name === 'cardNumber') { if ( cardBrand?.brand !== cardInfo?.brand || cardBrand?.first6Digits !== cardInfo?.first6Digits ) { setCardInfo(cardBrand); onCardBrandChange?.(cardBrand); // Share the new brand so the CVV re-validates (its length is // brand-dependent, e.g. Amex needs 4 digits). setBrand(cardBrand?.brand); } } // Once a field has been interacted with (blurred at least once), keep // its error in sync as the user edits. Same rule for every field. if (isDirtyRef.current && !isValid !== isErrorRef.current) { setIsError(!isValid); isErrorRef.current = !isValid; onErrorChange?.({ isValid, errorMessage }); } updateFieldValidity({ name, isValid, }); onChange?.({ isValid, length, errorMessage, }); }} /> ); } );