diff --git a/src/ROUTES.ts b/src/ROUTES.ts index d88fe3170e79..5a8f4a2cd4d0 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -103,6 +103,7 @@ const ROUTES = { SETTINGS_PREFERENCES: 'settings/preferences', SETTINGS_SUBSCRIPTION: 'settings/subscription', SETTINGS_SUBSCRIPTION_SIZE: 'settings/subscription/subscription-size', + SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD: 'settings/subscription/add-payment-card', SETTINGS_PRIORITY_MODE: 'settings/preferences/priority-mode', SETTINGS_LANGUAGE: 'settings/preferences/language', SETTINGS_THEME: 'settings/preferences/theme', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index fe6983623a8a..fd7418aee1c5 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -107,6 +107,7 @@ const SCREENS = { SUBSCRIPTION: { ROOT: 'Settings_Subscription', SIZE: 'Settings_Subscription_Size', + ADD_PAYMENT_CARD: 'Settings_Subscription_Add_Payment_Card', }, }, SAVE_THE_WORLD: { diff --git a/src/pages/workspace/members/WorkspaceOwnerPaymentCardCurrencyModal.tsx b/src/components/AddPaymentCard/PaymentCardCurrencyModal.tsx similarity index 82% rename from src/pages/workspace/members/WorkspaceOwnerPaymentCardCurrencyModal.tsx rename to src/components/AddPaymentCard/PaymentCardCurrencyModal.tsx index fcbbbbd4af3f..60fa838b0577 100644 --- a/src/pages/workspace/members/WorkspaceOwnerPaymentCardCurrencyModal.tsx +++ b/src/components/AddPaymentCard/PaymentCardCurrencyModal.tsx @@ -6,9 +6,10 @@ import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import CONST from '@src/CONST'; -type WorkspaceOwnerPaymentCardCurrencyModalProps = { +type PaymentCardCurrencyModalProps = { /** Whether the modal is visible */ isVisible: boolean; @@ -25,7 +26,8 @@ type WorkspaceOwnerPaymentCardCurrencyModalProps = { onClose?: () => void; }; -function WorkspaceOwnerPaymentCardCurrencyModal({isVisible, currencies, currentCurrency = CONST.CURRENCY.USD, onCurrencyChange, onClose}: WorkspaceOwnerPaymentCardCurrencyModalProps) { +function PaymentCardCurrencyModal({isVisible, currencies, currentCurrency = CONST.CURRENCY.USD, onCurrencyChange, onClose}: PaymentCardCurrencyModalProps) { + const {isSmallScreenWidth} = useWindowDimensions(); const styles = useThemeStyles(); const {translate} = useLocalize(); const {sections} = useMemo( @@ -51,13 +53,14 @@ function WorkspaceOwnerPaymentCardCurrencyModal({isVisible, currencies, currentC onClose={() => onClose?.()} onModalHide={onClose} hideModalContentWhileAnimating + innerContainerStyle={styles.RHPNavigatorContainer(isSmallScreenWidth)} useNativeDriver > , currency?: ValueOf) => void; + submitButtonText: string; + /** Custom content to display in the footer after card form */ + footerContent?: ReactNode; + /** Custom content to display in the header before card form */ + headerContent?: ReactNode; +}; + +function IAcceptTheLabel() { + const {translate} = useLocalize(); + + return ( + + {`${translate('common.iAcceptThe')}`} + {`${translate('common.addCardTermsOfService')}`} {`${translate('common.and')}`} + {` ${translate('common.privacyPolicy')} `} + + ); +} + +const REQUIRED_FIELDS = [ + INPUT_IDS.NAME_ON_CARD, + INPUT_IDS.CARD_NUMBER, + INPUT_IDS.EXPIRATION_DATE, + INPUT_IDS.ADDRESS_STREET, + INPUT_IDS.SECURITY_CODE, + INPUT_IDS.ADDRESS_ZIP_CODE, + INPUT_IDS.ADDRESS_STATE, +]; + +const CARD_TYPES = { + DEBIT_CARD: 'debit', + PAYMENT_CARD: 'payment', +}; + +const CARD_TYPE_SECTIONS = { + DEFAULTS: 'defaults', + ERROR: 'error', +}; +type CartTypesMap = (typeof CARD_TYPES)[keyof typeof CARD_TYPES]; +type CartTypeSectionsMap = (typeof CARD_TYPE_SECTIONS)[keyof typeof CARD_TYPE_SECTIONS]; + +type CardLabels = Record>>; + +const CARD_LABELS: CardLabels = { + [CARD_TYPES.DEBIT_CARD]: { + [CARD_TYPE_SECTIONS.DEFAULTS]: { + cardNumber: 'addDebitCardPage.debitCardNumber', + nameOnCard: 'addDebitCardPage.nameOnCard', + expirationDate: 'addDebitCardPage.expirationDate', + expiration: 'addDebitCardPage.expiration', + securityCode: 'addDebitCardPage.cvv', + billingAddress: 'addDebitCardPage.billingAddress', + }, + [CARD_TYPE_SECTIONS.ERROR]: { + nameOnCard: 'addDebitCardPage.error.invalidName', + cardNumber: 'addDebitCardPage.error.debitCardNumber', + expirationDate: 'addDebitCardPage.error.expirationDate', + securityCode: 'addDebitCardPage.error.securityCode', + addressStreet: 'addDebitCardPage.error.addressStreet', + addressZipCode: 'addDebitCardPage.error.addressZipCode', + }, + }, + [CARD_TYPES.PAYMENT_CARD]: { + defaults: { + cardNumber: 'addPaymentCardPage.paymentCardNumber', + nameOnCard: 'addPaymentCardPage.nameOnCard', + expirationDate: 'addPaymentCardPage.expirationDate', + expiration: 'addPaymentCardPage.expiration', + securityCode: 'addPaymentCardPage.cvv', + billingAddress: 'addPaymentCardPage.billingAddress', + }, + error: { + nameOnCard: 'addPaymentCardPage.error.invalidName', + cardNumber: 'addPaymentCardPage.error.paymentCardNumber', + expirationDate: 'addPaymentCardPage.error.expirationDate', + securityCode: 'addPaymentCardPage.error.securityCode', + addressStreet: 'addPaymentCardPage.error.addressStreet', + addressZipCode: 'addPaymentCardPage.error.addressZipCode', + }, + }, +}; + +function PaymentCardForm({ + shouldShowPaymentCardForm, + addPaymentCard, + showAcceptTerms, + showAddressField, + showCurrencyField, + isDebitCard, + submitButtonText, + showStateSelector, + footerContent, + headerContent, +}: PaymentCardFormProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const route = useRoute(); + const label = CARD_LABELS[isDebitCard ? CARD_TYPES.DEBIT_CARD : CARD_TYPES.PAYMENT_CARD]; + + const cardNumberRef = useRef(null); + + const [isCurrencyModalVisible, setIsCurrencyModalVisible] = useState(false); + const [currency, setCurrency] = useState(CONST.CURRENCY.USD); + + const validate = (formValues: FormOnyxValues): FormInputErrors => { + const errors = ValidationUtils.getFieldRequiredErrors(formValues, REQUIRED_FIELDS); + + if (formValues.nameOnCard && !ValidationUtils.isValidLegalName(formValues.nameOnCard)) { + errors.nameOnCard = label.error.nameOnCard; + } + + if (formValues.cardNumber && !ValidationUtils.isValidDebitCard(formValues.cardNumber.replace(/ /g, ''))) { + errors.cardNumber = label.error.cardNumber; + } + + if (formValues.expirationDate && !ValidationUtils.isValidExpirationDate(formValues.expirationDate)) { + errors.expirationDate = label.error.expirationDate; + } + + if (formValues.securityCode && !ValidationUtils.isValidSecurityCode(formValues.securityCode)) { + errors.securityCode = label.error.securityCode; + } + + if (formValues.addressStreet && !ValidationUtils.isValidAddress(formValues.addressStreet)) { + errors.addressStreet = label.error.addressStreet; + } + + if (formValues.addressZipCode && !ValidationUtils.isValidZipCode(formValues.addressZipCode)) { + errors.addressZipCode = label.error.addressZipCode; + } + + if (!formValues.acceptTerms) { + errors.acceptTerms = 'common.error.acceptTerms'; + } + + return errors; + }; + + const showCurrenciesModal = useCallback(() => { + setIsCurrencyModalVisible(true); + }, []); + + const changeCurrency = useCallback((newCurrency: keyof typeof CONST.CURRENCY) => { + setCurrency(newCurrency); + setIsCurrencyModalVisible(false); + }, []); + + if (!shouldShowPaymentCardForm) { + return null; + } + + return ( + <> + {headerContent} + addPaymentCard(formData, currency)} + submitButtonText={submitButtonText} + scrollContextEnabled + style={[styles.mh5, styles.flexGrow1]} + > + + + + + + + + + + + {!!showAddressField && ( + + + + )} + + {!!showStateSelector && ( + + + + )} + {!!showCurrencyField && ( + + {(isHovered) => ( + + )} + + )} + {!!showAcceptTerms && ( + + + + )} + + } + currentCurrency={currency} + onCurrencyChange={changeCurrency} + onClose={() => setIsCurrencyModalVisible(false)} + /> + {footerContent} + + + ); +} + +PaymentCardForm.displayName = 'PaymentCardForm'; + +export default PaymentCardForm; diff --git a/src/components/Section/IconSection.tsx b/src/components/Section/IconSection.tsx index cc42c6b7ace5..df5392027df4 100644 --- a/src/components/Section/IconSection.tsx +++ b/src/components/Section/IconSection.tsx @@ -3,14 +3,20 @@ import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Icon from '@components/Icon'; import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; import type IconAsset from '@src/types/utils/IconAsset'; type IconSectionProps = { icon?: IconAsset; iconContainerStyles?: StyleProp; + /** The width of the icon. */ + width?: number; + + /** The height of the icon. */ + height?: number; }; -function IconSection({icon, iconContainerStyles}: IconSectionProps) { +function IconSection({icon, iconContainerStyles, width = variables.menuIconSize, height = variables.menuIconSize}: IconSectionProps) { const styles = useThemeStyles(); return ( @@ -18,8 +24,8 @@ function IconSection({icon, iconContainerStyles}: IconSectionProps) { {!!icon && ( )} diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx index 9d6e6cbbd41f..8d65bf2c8a1b 100644 --- a/src/components/Section/index.tsx +++ b/src/components/Section/index.tsx @@ -22,12 +22,12 @@ const CARD_LAYOUT = { ICON_ON_RIGHT: 'iconOnRight', } as const; -type SectionProps = ChildrenProps & { +type SectionProps = Partial & { /** An array of props that are passed to individual MenuItem components */ menuItems?: MenuItemWithLink[]; /** The text to display in the title of the section */ - title: string; + title?: string; /** The text to display in the subtitle of the section */ subtitle?: string; @@ -76,6 +76,15 @@ type SectionProps = ChildrenProps & { /** The component to display in the title of the section */ renderSubtitle?: () => ReactNode; + + /** The component to display custom title */ + renderTitle?: () => ReactNode; + + /** The width of the icon. */ + iconWidth?: number; + + /** The height of the icon. */ + iconHeight?: number; }; function Section({ @@ -90,6 +99,7 @@ function Section({ subtitleStyles, subtitleMuted = false, title, + renderTitle, titleStyles, isCentralPane = false, illustration, @@ -97,6 +107,8 @@ function Section({ illustrationStyle, contentPaddingOnLargeScreens, overlayContent, + iconWidth, + iconHeight, renderSubtitle, }: SectionProps) { const styles = useThemeStyles(); @@ -110,6 +122,8 @@ function Section({ {cardLayout === CARD_LAYOUT.ICON_ON_TOP && ( @@ -132,15 +146,17 @@ function Section({ {cardLayout === CARD_LAYOUT.ICON_ON_LEFT && ( )} - - {title} - + {renderTitle ? renderTitle() : {title}} {cardLayout === CARD_LAYOUT.ICON_ON_RIGHT && ( diff --git a/src/languages/en.ts b/src/languages/en.ts index 947880f3d71b..9a047178147e 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -164,6 +164,7 @@ export default { continue: 'Continue', firstName: 'First name', lastName: 'Last name', + addCardTermsOfService: 'Expensify Terms of Service', phone: 'Phone', phoneNumber: 'Phone number', phoneNumberPlaceholder: '(xxx) xxx-xxxx', @@ -171,6 +172,7 @@ export default { and: 'and', details: 'Details', privacy: 'Privacy', + privacyPolicy: 'Privacy Policy', hidden: 'Hidden', visible: 'Visible', delete: 'Delete', @@ -186,7 +188,6 @@ export default { saveAndContinue: 'Save & continue', settings: 'Settings', termsOfService: 'Terms of Service', - expensifyTermsOfService: 'Expensify Terms of Service', members: 'Members', invite: 'Invite', here: 'here', @@ -1070,6 +1071,29 @@ export default { password: 'Please enter your Expensify password.', }, }, + addPaymentCardPage: { + addAPaymentCard: 'Add payment card', + nameOnCard: 'Name on card', + paymentCardNumber: 'Card number', + expiration: 'Expiration date', + expirationDate: 'MMYY', + cvv: 'CVV', + billingAddress: 'Billing address', + growlMessageOnSave: 'Your payment card was successfully added', + expensifyPassword: 'Expensify password', + error: { + invalidName: 'Name can only include letters.', + addressZipCode: 'Please enter a valid zip code.', + paymentCardNumber: 'Please enter a valid card number.', + expirationDate: 'Please select a valid expiration date.', + securityCode: 'Please enter a valid security code.', + addressStreet: 'Please enter a valid billing address that is not a PO Box.', + addressState: 'Please select a state.', + addressCity: 'Please enter a city.', + genericFailureMessage: 'An error occurred while adding your card, please try again.', + password: 'Please enter your Expensify password.', + }, + }, walletPage: { paymentMethodsTitle: 'Payment methods', setDefaultConfirmation: 'Make default payment method', @@ -3233,5 +3257,11 @@ export default { size: 'Please enter a valid subscription size.', }, }, + paymentCard: { + addPaymentCard: 'Add payment card', + enterPaymentCardDetails: 'Enter your payment card details.', + security: 'Expensify is PCI-DSS compliant, uses bank-level encryption, and utilizes redundant infrastructure to protect your data.', + learnMoreAboutSecurity: 'Learn more about our security.', + }, }, } satisfies TranslationBase; diff --git a/src/languages/es.ts b/src/languages/es.ts index e4b2117fe225..c3fd2bc46849 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -148,6 +148,8 @@ export default { preferences: 'Preferencias', view: 'Ver', not: 'No', + privacyPolicy: 'la Política de Privacidad de Expensify', + addCardTermsOfService: 'Términos de Servicio', signIn: 'Conectarse', signInWithGoogle: 'Iniciar sesión con Google', signInWithApple: 'Iniciar sesión con Apple', @@ -177,7 +179,6 @@ export default { saveAndContinue: 'Guardar y continuar', settings: 'Configuración', termsOfService: 'Términos de Servicio', - expensifyTermsOfService: 'Términos de Servicio de Expensify', members: 'Miembros', invite: 'Invitar', here: 'aquí', @@ -1067,6 +1068,29 @@ export default { password: 'Por favor, introduce tu contraseña de Expensify.', }, }, + addPaymentCardPage: { + addAPaymentCard: 'Añade tarjeta de pago', + nameOnCard: 'Nombre en la tarjeta', + paymentCardNumber: 'Número de la tarjeta', + expiration: 'Fecha de vencimiento', + expirationDate: 'MMAA', + cvv: 'CVV', + billingAddress: 'Dirección de envio', + growlMessageOnSave: 'Tu tarjeta de pago se añadió correctamente', + expensifyPassword: 'Contraseña de Expensify', + error: { + invalidName: 'El nombre sólo puede incluir letras.', + addressZipCode: 'Por favor, introduce un código postal válido.', + paymentCardNumber: 'Por favor, introduce un número de tarjeta de pago válido.', + expirationDate: 'Por favor, selecciona una fecha de vencimiento válida.', + securityCode: 'Por favor, introduce un código de seguridad válido.', + addressStreet: 'Por favor, introduce una dirección de facturación válida que no sea un apartado postal.', + addressState: 'Por favor, selecciona un estado.', + addressCity: 'Por favor, introduce una ciudad.', + genericFailureMessage: 'Se produjo un error al añadir tu tarjeta. Vuelva a intentarlo.', + password: 'Por favor, introduce tu contraseña de Expensify.', + }, + }, walletPage: { paymentMethodsTitle: 'Métodos de pago', setDefaultConfirmation: 'Marcar como método de pago predeterminado', @@ -3740,5 +3764,11 @@ export default { size: 'Por favor ingrese un tamaño de suscripción valido.', }, }, + paymentCard: { + addPaymentCard: 'Añade tarjeta de pago', + enterPaymentCardDetails: 'Introduce los datos de tu tarjeta de pago.', + security: 'Expensify es PCI-DSS obediente, utiliza cifrado a nivel bancario, y emplea infraestructura redundante para proteger tus datos.', + learnMoreAboutSecurity: 'Conozca más sobre nuestra seguridad.', + }, }, } satisfies EnglishTranslation; diff --git a/src/libs/API/parameters/AddSubscriptionPaymentCardParams.ts b/src/libs/API/parameters/AddSubscriptionPaymentCardParams.ts new file mode 100644 index 000000000000..ef8fbc382737 --- /dev/null +++ b/src/libs/API/parameters/AddSubscriptionPaymentCardParams.ts @@ -0,0 +1,11 @@ +type AddSubscriptionPaymentCardParams = { + cardNumber: string; + cardYear: string; + cardMonth: string; + cardCVV: string; + addressName: string; + addressZip: string; + currency: string; +}; + +export default AddSubscriptionPaymentCardParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 6fa58f44bd89..de8d6f55418b 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -205,6 +205,7 @@ export type {default as DeletePolicyTaxesParams} from './DeletePolicyTaxesParams export type {default as UpdatePolicyTaxValueParams} from './UpdatePolicyTaxValueParams'; export type {default as RenamePolicyTagsParams} from './RenamePolicyTagsParams'; export type {default as DeletePolicyTagsParams} from './DeletePolicyTagsParams'; +export type {default as AddSubscriptionPaymentCardParams} from './AddSubscriptionPaymentCardParams'; export type {default as SetPolicyCustomTaxNameParams} from './SetPolicyCustomTaxNameParams'; export type {default as SetPolicyForeignCurrencyDefaultParams} from './SetPolicyForeignCurrencyDefaultParams'; export type {default as SetPolicyCurrencyDefaultParams} from './SetPolicyCurrencyDefaultParams'; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 531d53b0f3fe..9a391e386f15 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -334,6 +334,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/taxes/ValuePage').default as React.ComponentType, [SCREENS.WORKSPACE.TAX_CREATE]: () => require('../../../../pages/workspace/taxes/WorkspaceCreateTaxPage').default as React.ComponentType, [SCREENS.SETTINGS.SAVE_THE_WORLD]: () => require('../../../../pages/TeachersUnite/SaveTheWorldPage').default as React.ComponentType, + [SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD]: () => require('../../../../pages/settings/Subscription/PaymentCard/AddPaymentCard').default as React.ComponentType, }); const EnablePaymentsStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts index 4e77edeaa633..af21a02f7da4 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -39,7 +39,7 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = [SCREENS.SETTINGS.SAVE_THE_WORLD]: [SCREENS.I_KNOW_A_TEACHER, SCREENS.INTRO_SCHOOL_PRINCIPAL, SCREENS.I_AM_A_TEACHER], [SCREENS.SETTINGS.TROUBLESHOOT]: [SCREENS.SETTINGS.CONSOLE], [SCREENS.SEARCH.CENTRAL_PANE]: [SCREENS.SEARCH.REPORT_RHP], - [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: [SCREENS.SETTINGS.SUBSCRIPTION.SIZE], + [SCREENS.SETTINGS.SUBSCRIPTION.ROOT]: [SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD, SCREENS.SETTINGS.SUBSCRIPTION.SIZE], }; export default CENTRAL_PANE_TO_RHP_MAPPING; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index c4a065cb67d6..b3f27d422b4b 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -126,6 +126,10 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_LANGUAGE, exact: true, }, + [SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD]: { + path: ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD, + exact: true, + }, [SCREENS.SETTINGS.PREFERENCES.THEME]: { path: ROUTES.SETTINGS_THEME, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index c52d36256547..2267d2d38fb7 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -249,6 +249,7 @@ type SettingsNavigatorParamList = { orderWeight: number; tagName: string; }; + [SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD]: undefined; [SCREENS.WORKSPACE.TAXES_SETTINGS]: { policyID: string; }; diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts index c5a74bdc6ace..c12f7a042659 100644 --- a/src/libs/actions/PaymentMethods.ts +++ b/src/libs/actions/PaymentMethods.ts @@ -3,6 +3,7 @@ import type {MutableRefObject} from 'react'; import type {GestureResponderEvent} from 'react-native'; import type {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import * as API from '@libs/API'; import type {AddPaymentCardParams, DeletePaymentCardParams, MakeDefaultPaymentMethodParams, PaymentCardParams, TransferWalletBalanceParams} from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; @@ -198,6 +199,64 @@ function addPaymentCard(params: PaymentCardParams) { }); } +/** + * Calls the API to add a new card. + * + */ +function addSubscriptionPaymentCard(cardData: { + cardNumber: string; + cardYear: string; + cardMonth: string; + cardCVV: string; + addressName: string; + addressZip: string; + currency: ValueOf; +}) { + const {cardNumber, cardYear, cardMonth, cardCVV, addressName, addressZip, currency} = cardData; + + const parameters: AddPaymentCardParams = { + cardNumber, + cardYear, + cardMonth, + cardCVV, + addressName, + addressZip, + currency, + isP2PDebitCard: false, + }; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, + value: {isLoading: true}, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, + value: {isLoading: false}, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, + value: {isLoading: false}, + }, + ]; + + // TODO integrate API for subscription card as a follow up + API.write(WRITE_COMMANDS.ADD_PAYMENT_CARD, parameters, { + optimisticData, + successData, + failureData, + }); +} + /** * Resets the values for the add debit card form back to their initial states */ @@ -373,6 +432,7 @@ export { makeDefaultPaymentMethod, kycWallRef, continueSetup, + addSubscriptionPaymentCard, clearDebitCardFormErrorAndSubmit, dismissSuccessfulTransferBalancePage, transferWalletBalance, diff --git a/src/pages/settings/Subscription/PaymentCard/AddPaymentCard.tsx b/src/pages/settings/Subscription/PaymentCard/AddPaymentCard.tsx new file mode 100644 index 000000000000..a36072c51942 --- /dev/null +++ b/src/pages/settings/Subscription/PaymentCard/AddPaymentCard.tsx @@ -0,0 +1,95 @@ +import React, {useCallback, useEffect} from 'react'; +import {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import PaymentCardForm from '@components/AddPaymentCard/PaymentCardForm'; +import type {FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Illustrations from '@components/Icon/Illustrations'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Section, {CARD_LAYOUT} from '@components/Section'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CardUtils from '@libs/CardUtils'; +import Navigation from '@navigation/Navigation'; +import * as PaymentMethods from '@userActions/PaymentMethods'; +import CONST from '@src/CONST'; +import type ONYXKEYS from '@src/ONYXKEYS'; + +function AddPaymentCard() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + useEffect(() => { + PaymentMethods.clearDebitCardFormErrorAndSubmit(); + + return () => { + PaymentMethods.clearDebitCardFormErrorAndSubmit(); + }; + }, []); + + // TODO refactor ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM to ONYXKEYS.FORMS.ADD_CARD_FORM as a follow up + const addPaymentCard = useCallback((values: FormOnyxValues, currency?: ValueOf) => { + const cardData = { + cardNumber: values.cardNumber, + cardMonth: CardUtils.getMonthFromExpirationDateString(values.expirationDate), + cardYear: CardUtils.getYearFromExpirationDateString(values.expirationDate), + cardCVV: values.securityCode, + addressName: values.nameOnCard, + addressZip: values.addressZipCode, + currency: currency ?? CONST.CURRENCY.USD, + }; + if (currency === CONST.CURRENCY.GBP) { + // TODO add AddPaymentCardGBP flow as a follow up + return; + } + PaymentMethods.addSubscriptionPaymentCard(cardData); + Navigation.goBack(); + }, []); + + return ( + + + + {translate('subscription.paymentCard.enterPaymentCardDetails')}} + footerContent={ + <> +
( + + {translate('subscription.paymentCard.security')}{' '} + + {translate('subscription.paymentCard.learnMoreAboutSecurity')} + + + )} + /> + {/** TODO reusable component will be taken from https://github.com/Expensify/App/pull/42690 */} + + From $5/active member with the Expensify Card, $10/active member without the Expensify Card. + + + } + /> + + + ); +} + +AddPaymentCard.displayName = 'AddPaymentCard'; + +export default AddPaymentCard; diff --git a/src/pages/settings/Wallet/AddDebitCardPage.tsx b/src/pages/settings/Wallet/AddDebitCardPage.tsx index 0beb3c16018d..0befaa55da52 100644 --- a/src/pages/settings/Wallet/AddDebitCardPage.tsx +++ b/src/pages/settings/Wallet/AddDebitCardPage.tsx @@ -1,67 +1,20 @@ -import {useRoute} from '@react-navigation/native'; import React, {useEffect, useRef} from 'react'; -import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; -import AddressSearch from '@components/AddressSearch'; -import CheckboxWithLabel from '@components/CheckboxWithLabel'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import {useOnyx} from 'react-native-onyx'; +import PaymentCardForm from '@components/AddPaymentCard/PaymentCardForm'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; -import StateSelector from '@components/StateSelector'; -import Text from '@components/Text'; -import TextInput from '@components/TextInput'; -import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; -import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import * as ValidationUtils from '@libs/ValidationUtils'; import * as PaymentMethods from '@userActions/PaymentMethods'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; -import type {AddDebitCardForm} from '@src/types/form'; -import INPUT_IDS from '@src/types/form/AddDebitCardForm'; -type DebitCardPageOnyxProps = { - /** Form data propTypes */ - formData: OnyxEntry; -}; - -type DebitCardPageProps = DebitCardPageOnyxProps; - -function IAcceptTheLabel() { - const {translate} = useLocalize(); - - return ( - - {`${translate('common.iAcceptThe')}`} - {`${translate('common.expensifyTermsOfService')}`} - - ); -} - -const REQUIRED_FIELDS = [ - INPUT_IDS.NAME_ON_CARD, - INPUT_IDS.CARD_NUMBER, - INPUT_IDS.EXPIRATION_DATE, - INPUT_IDS.SECURITY_CODE, - INPUT_IDS.ADDRESS_STREET, - INPUT_IDS.ADDRESS_ZIP_CODE, - INPUT_IDS.ADDRESS_STATE, -]; - -function DebitCardPage({formData}: DebitCardPageProps) { - const styles = useThemeStyles(); +function DebitCardPage() { const {translate} = useLocalize(); + const [formData] = useOnyx(ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM); const prevFormDataSetupComplete = usePrevious(!!formData?.setupComplete); const nameOnCardRef = useRef(null); - const route = useRoute(); /** * Reset the form values on the mount and unmount so that old errors don't show when this form is displayed again. @@ -82,43 +35,6 @@ function DebitCardPage({formData}: DebitCardPageProps) { PaymentMethods.continueSetup(); }, [prevFormDataSetupComplete, formData?.setupComplete]); - /** - * @param values - form input values passed by the Form component - */ - const validate = (values: FormOnyxValues): FormInputErrors => { - const errors = ValidationUtils.getFieldRequiredErrors(values, REQUIRED_FIELDS); - - if (values.nameOnCard && !ValidationUtils.isValidLegalName(values.nameOnCard)) { - errors.nameOnCard = 'addDebitCardPage.error.invalidName'; - } - - if (values.cardNumber && !ValidationUtils.isValidDebitCard(values.cardNumber.replace(/ /g, ''))) { - errors.cardNumber = 'addDebitCardPage.error.debitCardNumber'; - } - - if (values.expirationDate && !ValidationUtils.isValidExpirationDate(values.expirationDate)) { - errors.expirationDate = 'addDebitCardPage.error.expirationDate'; - } - - if (values.securityCode && !ValidationUtils.isValidSecurityCode(values.securityCode)) { - errors.securityCode = 'addDebitCardPage.error.securityCode'; - } - - if (values.addressStreet && !ValidationUtils.isValidAddress(values.addressStreet)) { - errors.addressStreet = 'addDebitCardPage.error.addressStreet'; - } - - if (values.addressZipCode && !ValidationUtils.isValidZipCode(values.addressZipCode)) { - errors.addressZipCode = 'addDebitCardPage.error.addressZipCode'; - } - - if (!values.acceptTerms) { - errors.acceptTerms = 'common.error.acceptTerms'; - } - - return errors; - }; - return ( nameOnCardRef.current?.focus()} @@ -129,103 +45,19 @@ function DebitCardPage({formData}: DebitCardPageProps) { title={translate('addDebitCardPage.addADebitCard')} onBackButtonPress={() => Navigation.goBack()} /> - - - - - - - - - - - - - - - - - - - - + /> ); } DebitCardPage.displayName = 'DebitCardPage'; -export default withOnyx({ - formData: { - key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, - }, -})(DebitCardPage); +export default DebitCardPage; diff --git a/src/pages/workspace/members/WorkspaceOwnerChangeWrapperPage.tsx b/src/pages/workspace/members/WorkspaceOwnerChangeWrapperPage.tsx index b32c04a5c4aa..bf5e8cb869e2 100644 --- a/src/pages/workspace/members/WorkspaceOwnerChangeWrapperPage.tsx +++ b/src/pages/workspace/members/WorkspaceOwnerChangeWrapperPage.tsx @@ -67,7 +67,7 @@ function WorkspaceOwnerChangeWrapperPage({route, policy}: WorkspaceOwnerChangeWr Navigation.navigate(ROUTES.WORKSPACE_MEMBER_DETAILS.getRoute(policyID, accountID)); }} /> - + {policy?.isLoading && } {!policy?.isLoading && (error === CONST.POLICY.OWNERSHIP_ERRORS.NO_BILLING_CARD ? ( diff --git a/src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx b/src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx index 1a2f32449c41..31e40473d33f 100644 --- a/src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx +++ b/src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx @@ -1,47 +1,33 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import Hoverable from '@components/Hoverable'; +import PaymentCardForm from '@components/AddPaymentCard/PaymentCardForm'; +import type {FormOnyxValues} from '@components/Form/types'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; -import type {AnimatedTextInputRef} from '@components/RNTextInput'; import Section, {CARD_LAYOUT} from '@components/Section'; import Text from '@components/Text'; -import TextInput from '@components/TextInput'; import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; -import * as ValidationUtils from '@libs/ValidationUtils'; import * as PaymentMethods from '@userActions/PaymentMethods'; import * as PolicyActions from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import INPUT_IDS from '@src/types/form/AddDebitCardForm'; +import type ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -import WorkspaceOwnerPaymentCardCurrencyModal from './WorkspaceOwnerPaymentCardCurrencyModal'; type WorkspaceOwnerPaymentCardFormProps = { /** The policy */ policy: OnyxEntry; }; -const REQUIRED_FIELDS = [INPUT_IDS.NAME_ON_CARD, INPUT_IDS.CARD_NUMBER, INPUT_IDS.EXPIRATION_DATE, INPUT_IDS.ADDRESS_STREET, INPUT_IDS.SECURITY_CODE, INPUT_IDS.ADDRESS_ZIP_CODE]; - function WorkspaceOwnerPaymentCardForm({policy}: WorkspaceOwnerPaymentCardFormProps) { - const styles = useThemeStyles(); - const theme = useTheme(); const {translate} = useLocalize(); - - const cardNumberRef = useRef(null); - - const [isCurrencyModalVisible, setIsCurrencyModalVisible] = useState(false); - const [currency, setCurrency] = useState(CONST.CURRENCY.USD); + const theme = useTheme(); + const styles = useThemeStyles(); const [shouldShowPaymentCardForm, setShouldShowPaymentCardForm] = useState(false); const policyID = policy?.id ?? ''; @@ -72,36 +58,6 @@ function WorkspaceOwnerPaymentCardForm({policy}: WorkspaceOwnerPaymentCardFormPr checkIfCanBeRendered(); }, [checkIfCanBeRendered]); - const validate = (formValues: FormOnyxValues): FormInputErrors => { - const errors = ValidationUtils.getFieldRequiredErrors(formValues, REQUIRED_FIELDS); - - if (formValues.nameOnCard && !ValidationUtils.isValidLegalName(formValues.nameOnCard)) { - errors.nameOnCard = 'addDebitCardPage.error.invalidName'; - } - - if (formValues.cardNumber && !ValidationUtils.isValidDebitCard(formValues.cardNumber.replace(/ /g, ''))) { - errors.cardNumber = 'addDebitCardPage.error.debitCardNumber'; - } - - if (formValues.expirationDate && !ValidationUtils.isValidExpirationDate(formValues.expirationDate)) { - errors.expirationDate = 'addDebitCardPage.error.expirationDate'; - } - - if (formValues.securityCode && !ValidationUtils.isValidSecurityCode(formValues.securityCode)) { - errors.securityCode = 'addDebitCardPage.error.securityCode'; - } - - if (formValues.addressStreet && !ValidationUtils.isValidAddress(formValues.addressStreet)) { - errors.addressStreet = 'addDebitCardPage.error.addressStreet'; - } - - if (formValues.addressZipCode && !ValidationUtils.isValidZipCode(formValues.addressZipCode)) { - errors.addressZipCode = 'addDebitCardPage.error.addressZipCode'; - } - - return errors; - }; - const addPaymentCard = useCallback( (values: FormOnyxValues) => { const cardData = { @@ -119,174 +75,78 @@ function WorkspaceOwnerPaymentCardForm({policy}: WorkspaceOwnerPaymentCardFormPr [policyID], ); - const showCurrenciesModal = useCallback(() => { - setIsCurrencyModalVisible(true); - }, []); - - const changeCurrency = useCallback((newCurrency: keyof typeof CONST.CURRENCY) => { - setCurrency(newCurrency); - setIsCurrencyModalVisible(false); - }, []); - - if (!shouldShowPaymentCardForm) { - return null; - } - return ( - <> - {translate('workspace.changeOwner.addPaymentCardTitle')} - - - - - - - - - - - - - - - - - - {(isHovered) => ( - - )} - - - - - } - currentCurrency={currency} - onCurrencyChange={changeCurrency} - onClose={() => setIsCurrencyModalVisible(false)} - /> - - - {translate('workspace.changeOwner.addPaymentCardReadAndAcceptTextPart1')}{' '} - - {translate('workspace.changeOwner.addPaymentCardTerms')} - {' '} - {translate('workspace.changeOwner.addPaymentCardAnd')}{' '} - - {translate('workspace.changeOwner.addPaymentCardPrivacy')} - {' '} - {translate('workspace.changeOwner.addPaymentCardReadAndAcceptTextPart2')} - -
- - - - {translate('workspace.changeOwner.addPaymentCardPciCompliant')} - - - - {translate('workspace.changeOwner.addPaymentCardBankLevelEncrypt')} - - - - {translate('workspace.changeOwner.addPaymentCardRedundant')} - - - - {translate('workspace.changeOwner.addPaymentCardLearnMore')}{' '} + {translate('workspace.changeOwner.addPaymentCardTitle')}} + footerContent={ + <> + + {translate('workspace.changeOwner.addPaymentCardReadAndAcceptTextPart1')}{' '} - {translate('workspace.changeOwner.addPaymentCardSecurity')} - - . + {translate('workspace.changeOwner.addPaymentCardTerms')} + {' '} + {translate('workspace.changeOwner.addPaymentCardAnd')}{' '} + + {translate('workspace.changeOwner.addPaymentCardPrivacy')} + {' '} + {translate('workspace.changeOwner.addPaymentCardReadAndAcceptTextPart2')} -
-
- +
+ + + + {translate('workspace.changeOwner.addPaymentCardPciCompliant')} + + + + {translate('workspace.changeOwner.addPaymentCardBankLevelEncrypt')} + + + + {translate('workspace.changeOwner.addPaymentCardRedundant')} + + + + {translate('workspace.changeOwner.addPaymentCardLearnMore')}{' '} + + {translate('workspace.changeOwner.addPaymentCardSecurity')} + + . + +
+ + } + /> ); } diff --git a/src/styles/index.ts b/src/styles/index.ts index 718942582801..610bc722cfdc 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -3662,7 +3662,7 @@ const styles = (theme: ThemeColors) => cardSectionContainer: { backgroundColor: theme.cardBG, - borderRadius: variables.componentBorderRadiusCard, + borderRadius: variables.componentBorderRadiusLarge, width: 'auto', textAlign: 'left', overflow: 'hidden', diff --git a/src/styles/variables.ts b/src/styles/variables.ts index f81e2ad9fd51..493dd993b45b 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -86,6 +86,7 @@ export default { iconBottomBar: 24, sidebarAvatarSize: 28, iconHeader: 48, + iconSection: 68, emojiSize: 20, emojiLineHeight: 28, iouAmountTextSize: 40,