import { StyleSheet, Text, TextInput, View } from 'react-native'; import MaskInput from 'react-native-mask-input'; import { CountryPicker } from '../components/CountryPicker'; import { FormField } from '../components/FormField'; import { getCreditCardBrand } from '../utils/getCreditCardBrand'; import { getPostcodeLabelFn } from '../utils/getPostcodeLabelFn'; import { getPostcodePlaceholderFn } from '../utils/getPostcodePlaceholderFn'; import { newExpiryDate } from '../utils/newExpiryDate'; import { validateFieldFn, type KeyToValidate, type VirtualFields, } from '../utils/validateFieldFn'; import type { CreateCardForTemporaryUsePayload, DuffelCardFormProps, DuffelCardFormStrings, SaveCardPayload, } from '../../../index'; import React from 'react'; export type ConditionalFormPayload = T_HideCVC extends true ? SaveCardPayload : CreateCardForTemporaryUsePayload; export interface CreateCardForTemporaryUseFormProps { hideCVC?: T_HideCVC; data: ConditionalFormPayload & VirtualFields; onChange: (data: ConditionalFormPayload & VirtualFields) => void; onValidateSuccess: DuffelCardFormProps['onValidateSuccess']; onValidateFailure: DuffelCardFormProps['onValidateFailure']; styles: DuffelCardFormProps['styles']; customStrings: DuffelCardFormStrings; } export interface FieldState { touched: boolean; valid: boolean; error: null | string; } type Fields = Record; const creditCardMask = [ /\d/, /\d/, /\d/, /\d/, ' ', /\d/, /\d/, /\d/, /\d/, ' ', /\d/, /\d/, /\d/, /\d/, ' ', /\d/, /\d/, /\d/, /\d/, ]; const expirationDateMask = [/\d/, /\d/, '/', /\d/, /\d/]; export function CreateCardForTemporaryUseForm< T_hideCVC extends boolean = false, >({ hideCVC, data, onChange, onValidateSuccess, onValidateFailure, styles, customStrings, }: CreateCardForTemporaryUseFormProps) { const [hasEmittedValidationState, setHasEmittedValidationState] = React.useState(false); const initialFieldsObject: Fields = { ...copyObjectWithFieldValue( // Assertion is ok here because we want the most complete object, not a variable one data as CreateCardForTemporaryUsePayload, { touched: false, valid: false, error: '' }, [ // `multi_use` and `brand` are not in the form, just the payload, // these are already part of the initial values 'multi_use', 'brand', // the form will have a better representation of these values as a Date 'expiry_month', 'expiry_year', // we exclude the cvc field if it's not rendered on the form ...(hideCVC ? ['cvc' as const] : []), ] ), // address_line_2 doesn't go through validation, so we can set valid to true address_line_2: { valid: true, touched: false, error: '' }, expiry_date: { valid: false, touched: false, error: '' }, }; const [fields, setFields] = React.useState(initialFieldsObject); function setFieldValidationState( fieldName: KeyToValidate, valid: boolean, error: string | null ): Fields { const fieldState = fields[fieldName]; const newState = { ...fields, [fieldName]: { ...fieldState, valid, error }, }; setFields(newState); return newState; } function setFieldTouched(fieldName: KeyToValidate, fieldsState = fields) { const fieldState = fieldsState[fieldName]; const newState = { ...fieldsState, [fieldName]: { ...fieldState, touched: true }, }; setFields(newState); return newState; } function maybeEmitValidationState(fieldsState: Fields) { if (Object.values(fieldsState).every((field) => field.valid)) { onValidateSuccess(); setHasEmittedValidationState(true); } if ( hasEmittedValidationState && Object.values(fieldsState).some((field) => !field.valid) ) { onValidateFailure(); } } const validateField = validateFieldFn(customStrings); function validate( fieldName: KeyToValidate, dataState: ConditionalFormPayload & VirtualFields ) { const { valid, error } = validateField(fieldName, dataState); const newFieldsState = setFieldValidationState(fieldName, valid, error); maybeEmitValidationState(newFieldsState); } const getPostcodeLabel = getPostcodeLabelFn(customStrings); const getPostcodePlaceholder = getPostcodePlaceholderFn(customStrings); return ( { const newState = { ...data, number: unmasked, brand: getCreditCardBrand(unmasked), }; onChange(newState); validate('number', newState); }} onEndEditing={() => { validate('number', data); if (data.number !== '') { setFieldTouched('number'); } }} mask={creditCardMask} placeholder={customStrings.cardNumberPlaceholder} /> { const [expiry_month, expiry_year] = masked.split('/'); const newState = { ...data, expiry_month: expiry_month || '', expiry_year: expiry_year || '', expiry_date: newExpiryDate(expiry_month, expiry_year), }; onChange(newState); validate('expiry_date', newState); }} onEndEditing={() => { validate('expiry_date', data); if (data.expiry_month !== '') { setFieldTouched('expiry_date'); } }} placeholder={customStrings.expirationDatePlaceholder} mask={expirationDateMask} /> {!hideCVC && ( { const newState = { ...data, cvc: e.nativeEvent.text }; onChange(newState); validate('cvc', newState); }} onEndEditing={() => { validate('cvc', data); if ((data as CreateCardForTemporaryUsePayload).cvc !== '') { setFieldTouched('cvc'); } }} placeholder={customStrings.cvvPlaceholder} /> )} { const newState = { ...data, name: e.nativeEvent.text }; onChange(newState); validate('name', newState); }} onEndEditing={() => { validate('name', data); if (data.name !== '') { setFieldTouched('name'); } }} placeholder={customStrings.namePlaceholder} /> {customStrings.billingAddressSectionTitle} { const newState = { ...data, address_country_code }; onChange(newState); setFieldTouched('address_country_code'); if (data.address_postal_code !== '') { validate('address_postal_code', newState); } }} onClose={() => { validate('address_country_code', data); }} /> { const newState = { ...data, address_line_1: e.nativeEvent.text, }; onChange(newState); validate('address_line_1', newState); }} onEndEditing={() => { validate('address_line_1', data); if (data.address_line_1 !== '') { setFieldTouched('address_line_1'); } }} placeholder={customStrings.addressLine1Placeholder} /> { const newState = { ...data, address_line_2: e.nativeEvent.text, }; onChange(newState); validate('address_line_2', newState); }} onEndEditing={() => { validate('address_line_2', data); if (data.address_line_2 !== '') { setFieldTouched('address_line_2'); } }} placeholder={customStrings.addressLine2Placeholder} /> { const newState = { ...data, address_city: e.nativeEvent.text }; onChange(newState); validate('address_city', newState); }} onEndEditing={() => { validate('address_city', data); if (data.address_city !== '') { setFieldTouched('address_city'); } }} placeholder={customStrings.cityPlaceholder} /> { const newState = { ...data, address_postal_code: e.nativeEvent.text, }; onChange(newState); validate('address_postal_code', newState); }} onEndEditing={() => { validate('address_postal_code', data); if (data.address_postal_code !== '') { setFieldTouched('address_postal_code'); } }} placeholder={getPostcodePlaceholder(data.address_country_code)} /> { const newState = { ...data, address_region: e.nativeEvent.text, }; onChange(newState); validate('address_region', newState); }} onEndEditing={() => { validate('address_region', data); if (data.address_region !== '') { setFieldTouched('address_region'); } }} placeholder={customStrings.regionPlaceholder} /> ); } function copyObjectWithFieldValue( data: T_Object, fieldValue: T_Value, excludeKeys: (keyof T_Object)[] = [] ): Record { return Object.keys(data) .filter((key) => !excludeKeys.includes(key as keyof T_Object)) .reduce( (acc, key) => ({ ...acc, [key]: fieldValue }), {} as Record ); } const defaultStyles = StyleSheet.create({ formControl: { fontSize: 16, borderColor: 'lightgrey', borderWidth: 1, borderStyle: 'solid', paddingTop: 6, marginBottom: 6, }, sectionTitle: { fontSize: 16, fontWeight: 'bold', marginBottom: 20, }, });