diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f6f0d34b6ae5..99d2e2dd88b0 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -108,6 +108,10 @@ const DYNAMIC_ROUTES = { path: 'imported-members-role', entryScreens: [SCREENS.WORKSPACE.MEMBERS_IMPORTED_CONFIRMATION], }, + PAYMENT_CARD_CURRENCY_SELECTOR: { + path: 'payment-card-currency', + entryScreens: [SCREENS.SETTINGS.SUBSCRIPTION.CHANGE_BILLING_CURRENCY, SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD, SCREENS.WORKSPACE.OWNER_CHANGE_CHECK], + }, REPORT_SETTINGS_NAME: { path: 'settings/name', entryScreens: [SCREENS.REPORT_DETAILS.ROOT], @@ -847,7 +851,6 @@ const ROUTES = { getRoute: (backTo?: string) => getUrlWithBackToParam('settings/profile', backTo), }, - SETTINGS_CHANGE_CURRENCY: 'settings/add-payment-card/change-currency', SETTINGS_SHARE_CODE: 'settings/shareCode', SETTINGS_DISPLAY_NAME: 'settings/profile/display-name', SETTINGS_AVATAR: 'settings/profile/avatar', @@ -868,7 +871,6 @@ const ROUTES = { SETTINGS_SUBSCRIPTION_EXPENSIFY_CODE: 'settings/subscription/details/expensify-code', SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD: 'settings/subscription/add-payment-card', SETTINGS_SUBSCRIPTION_CHANGE_BILLING_CURRENCY: 'settings/subscription/change-billing-currency', - SETTINGS_SUBSCRIPTION_CHANGE_PAYMENT_CURRENCY: 'settings/subscription/add-payment-card/change-payment-currency', SETTINGS_SUBSCRIPTION_DISABLE_AUTO_RENEW_SURVEY: 'settings/subscription/disable-auto-renew-survey', SETTINGS_SUBSCRIPTION_CANCEL_SUBSCRIPTION: 'settings/subscription/cancel-subscription-survey', SETTINGS_SUBSCRIPTION_DOWNGRADE_BLOCKED: { @@ -3290,7 +3292,7 @@ const ROUTES = { getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding/workspace-currency`, backTo), }, - CURRENCY_SELECTION: { + WORKSPACE_CURRENCY_SELECTION: { route: 'workspace/confirmation/currency', getRoute: (backTo?: string) => getUrlWithBackToParam(`workspace/confirmation/currency`, backTo), diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 16f9aaf4beef..40a7eec14665 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -121,7 +121,6 @@ const SCREENS = { SAVE_THE_WORLD: 'Settings_TeachersUnite', APP_DOWNLOAD_LINKS: 'Settings_App_Download_Links', ADD_DEBIT_CARD: 'Settings_Add_Debit_Card', - ADD_PAYMENT_CARD_CHANGE_CURRENCY: 'Settings_Add_Payment_Card_Change_Currency', ADD_BANK_ACCOUNT: 'Settings_Add_Bank_Account', ADD_US_BANK_ACCOUNT: 'Settings_Add_US_Bank_Account', ADD_US_BANK_ACCOUNT_ENTRY_POINT: 'Settings_Add_US_Bank_Account_Entry_Point', @@ -277,7 +276,7 @@ const SCREENS = { ADD_PAYMENT_CARD: 'Settings_Subscription_Add_Payment_Card', DISABLE_AUTO_RENEW_SURVEY: 'Settings_Subscription_DisableAutoRenewSurvey', CHANGE_BILLING_CURRENCY: 'Settings_Subscription_Change_Billing_Currency', - CHANGE_PAYMENT_CURRENCY: 'Settings_Subscription_Change_Payment_Currency', + DYNAMIC_PAYMENT_CARD_CURRENCY_SELECTOR: 'Dynamic_Settings_Subscription_Payment_Card_Currency_Selector', CANCEL_SUBSCRIPTION: 'Settings_Subscription_CancelSubscription', SUBSCRIPTION_DOWNGRADE_BLOCKED: 'Settings_Subscription_DowngradeBlocked', }, diff --git a/src/components/AddPaymentCard/PaymentCardChangeCurrencyForm.tsx b/src/components/AddPaymentCard/PaymentCardChangeCurrencyForm.tsx deleted file mode 100644 index d24480f11373..000000000000 --- a/src/components/AddPaymentCard/PaymentCardChangeCurrencyForm.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import React, {useCallback, useMemo, useState} from 'react'; -import {View} from 'react-native'; -import type {ValueOf} from 'type-fest'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import SelectionList from '@components/SelectionList'; -import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; -import TextInput from '@components/TextInput'; -import useLocalize from '@hooks/useLocalize'; -import usePermissions from '@hooks/usePermissions'; -import useThemeStyles from '@hooks/useThemeStyles'; -import {getFieldRequiredErrors, isValidSecurityCode} from '@libs/ValidationUtils'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import INPUT_IDS from '@src/types/form/ChangeBillingCurrencyForm'; -import PaymentCardCurrencyHeader from './PaymentCardCurrencyHeader'; -import PaymentCardCurrencyModal from './PaymentCardCurrencyModal'; - -type PaymentCardFormProps = { - initialCurrency?: ValueOf; - isSecurityCodeRequired?: boolean; - changeBillingCurrency: (currency?: ValueOf, values?: FormOnyxValues) => void; -}; - -const REQUIRED_FIELDS = [INPUT_IDS.SECURITY_CODE]; - -function PaymentCardChangeCurrencyForm({changeBillingCurrency, isSecurityCodeRequired, initialCurrency}: PaymentCardFormProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const {isBetaEnabled} = usePermissions(); - - const [isCurrencyModalVisible, setIsCurrencyModalVisible] = useState(false); - const [currency, setCurrency] = useState>(initialCurrency ?? CONST.PAYMENT_CARD_CURRENCY.USD); - - const validate = (values: FormOnyxValues): FormInputErrors => { - const errors = getFieldRequiredErrors(values, REQUIRED_FIELDS, translate); - - if (values.securityCode && !isValidSecurityCode(values.securityCode)) { - errors.securityCode = translate('addPaymentCardPage.error.securityCode'); - } - - return errors; - }; - - const availableCurrencies = useMemo(() => { - const canUseEurBilling = isBetaEnabled(CONST.BETAS.EUR_BILLING); - const allCurrencies = Object.keys(CONST.PAYMENT_CARD_CURRENCY) as Array>; - // Filter out EUR if user doesn't have EUR billing beta - return allCurrencies.filter((currencyItem) => { - if (currencyItem === CONST.PAYMENT_CARD_CURRENCY.EUR && !canUseEurBilling) { - return false; - } - return true; - }); - }, [isBetaEnabled]); - - const currencyOptions = useMemo( - () => - availableCurrencies.map((currencyItem) => ({ - text: currencyItem, - value: currencyItem, - keyForList: currencyItem, - isSelected: currencyItem === currency, - })), - [availableCurrencies, currency], - ); - - const showCurrenciesModal = useCallback(() => { - setIsCurrencyModalVisible(true); - }, []); - - const changeCurrency = useCallback((selectedCurrency: ValueOf) => { - setCurrency(selectedCurrency); - setIsCurrencyModalVisible(false); - }, []); - - const selectCurrency = useCallback( - (selectedCurrency: ValueOf) => { - setCurrency(selectedCurrency); - changeBillingCurrency(selectedCurrency); - }, - [changeBillingCurrency], - ); - - if (isSecurityCodeRequired) { - return ( - changeBillingCurrency(currency, values)} - submitButtonText={translate('common.save')} - scrollContextEnabled - style={[styles.mh5, styles.flexGrow1]} - shouldHideFixErrorsAlert - > - - <> - - - - - - setIsCurrencyModalVisible(false)} - /> - - ); - } - - return ( - - { - selectCurrency(option.value); - }} - style={{containerStyle: styles.mhn5}} - initiallyFocusedItemKey={currency} - customListHeader={} - /> - - ); -} - -export default PaymentCardChangeCurrencyForm; diff --git a/src/components/AddPaymentCard/PaymentCardCurrencyModal.tsx b/src/components/AddPaymentCard/PaymentCardCurrencyModal.tsx deleted file mode 100644 index b32a7f930446..000000000000 --- a/src/components/AddPaymentCard/PaymentCardCurrencyModal.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, {useMemo} from 'react'; -import type {ValueOf} from 'type-fest'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import Modal from '@components/Modal'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; -import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@libs/Navigation/Navigation'; -import CONST from '@src/CONST'; - -type PaymentCardCurrencyModalProps = { - /** Whether the modal is visible */ - isVisible: boolean; - - /** The list of currencies to render */ - currencies: Array>; - - /** Currently selected currency */ - currentCurrency: ValueOf; - - /** Function to call when the user selects a currency */ - onCurrencyChange: (currency: ValueOf) => void; - - /** Function to call when the user closes the currency picker */ - onClose: () => void; -}; - -function PaymentCardCurrencyModal({isVisible, currencies, currentCurrency = CONST.PAYMENT_CARD_CURRENCY.USD, onCurrencyChange, onClose}: PaymentCardCurrencyModalProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const currencyOptions = useMemo( - () => - currencies.map( - (currency) => ({ - text: currency, - value: currency, - keyForList: currency, - isSelected: currency === currentCurrency, - }), - [], - ), - [currencies, currentCurrency], - ); - - return ( - { - onClose(); - Navigation.dismissModal(); - }} - > - - - { - onCurrencyChange(option.value); - }} - initiallyFocusedItemKey={currentCurrency} - showScrollIndicator - /> - - - ); -} - -export default PaymentCardCurrencyModal; diff --git a/src/components/AddPaymentCard/PaymentCardForm.tsx b/src/components/AddPaymentCard/PaymentCardForm.tsx index 78af529f747c..5388f9353a30 100644 --- a/src/components/AddPaymentCard/PaymentCardForm.tsx +++ b/src/components/AddPaymentCard/PaymentCardForm.tsx @@ -38,8 +38,6 @@ type PaymentCardFormProps = { footerContent?: ReactNode; /** Custom content to display in the header before card form */ headerContent?: ReactNode; - /** object to get currency route details from */ - currencySelectorRoute?: typeof ROUTES.SETTINGS_SUBSCRIPTION_CHANGE_PAYMENT_CURRENCY; }; function IAcceptTheLabel() { @@ -123,7 +121,6 @@ function PaymentCardForm({ showStateSelector, footerContent, headerContent, - currencySelectorRoute, }: PaymentCardFormProps) { const styles = useThemeStyles(); const [data, metadata] = useOnyx(ONYXKEYS.FORMS.ADD_PAYMENT_CARD_FORM); @@ -358,7 +355,6 @@ function PaymentCardForm({ {!!showCurrencyField && ( void; - /** object to get route details from */ - currencySelectorRoute?: typeof ROUTES.SETTINGS_SUBSCRIPTION_CHANGE_PAYMENT_CURRENCY | typeof ROUTES.SETTINGS_CHANGE_CURRENCY | typeof ROUTES.CURRENCY_SELECTION; + /** Optional route override; when omitted the selector opens the dynamic payment-card currency picker. */ + currencySelectorRoute?: typeof ROUTES.WORKSPACE_CURRENCY_SELECTION; /** Label for the input */ label?: string; @@ -40,16 +41,7 @@ type CurrencySelectorProps = { ref: ForwardedRef; }; -function CurrencySelector({ - errorText = '', - value: currency, - onInputChange = () => {}, - onBlur, - currencySelectorRoute = ROUTES.SETTINGS_CHANGE_CURRENCY, - label, - shouldShowCurrencySymbol = false, - ref, -}: CurrencySelectorProps) { +function CurrencySelector({errorText = '', value: currency, onInputChange = () => {}, onBlur, currencySelectorRoute, label, shouldShowCurrencySymbol = false, ref}: CurrencySelectorProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {getCurrencySymbol} = useCurrencyListActions(); @@ -84,11 +76,11 @@ function CurrencySelector({ errorText={errorText} onPress={() => { didOpenCurrencySelector.current = true; - if (currencySelectorRoute === ROUTES.CURRENCY_SELECTION) { + if (currencySelectorRoute === ROUTES.WORKSPACE_CURRENCY_SELECTION) { Navigation.navigate(currencySelectorRoute.getRoute(Navigation.getActiveRoute())); - } else { - Navigation.navigate(currencySelectorRoute as typeof ROUTES.SETTINGS_SUBSCRIPTION_CHANGE_PAYMENT_CURRENCY | typeof ROUTES.SETTINGS_CHANGE_CURRENCY); + return; } + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.PAYMENT_CARD_CURRENCY_SELECTOR.path)); }} /> ); diff --git a/src/components/WorkspaceConfirmationForm.tsx b/src/components/WorkspaceConfirmationForm.tsx index 39d6915db492..e6433fbe2982 100644 --- a/src/components/WorkspaceConfirmationForm.tsx +++ b/src/components/WorkspaceConfirmationForm.tsx @@ -251,7 +251,7 @@ function WorkspaceConfirmationForm({onSubmit, policyOwnerEmail = '', onBackButto label={translate('workspace.editor.currencyInputLabel')} value={userCurrency} shouldShowCurrencySymbol - currencySelectorRoute={ROUTES.CURRENCY_SELECTION} + currencySelectorRoute={ROUTES.WORKSPACE_CURRENCY_SELECTION} /> {isApprovedAccountant && ( diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 7e96049388d2..b2e5d33bc397 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -289,7 +289,7 @@ const WorkspaceConfirmationModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/WorkspaceConfirmationPage').default, [SCREENS.WORKSPACE_CONFIRMATION.OWNER_SELECTOR]: () => require('../../../../pages/workspace/WorkspaceConfirmationOwnerSelectorPage').default, [SCREENS.WORKSPACE_CONFIRMATION.SUCCESS]: () => require('../../../../pages/workspace/WorkspaceConfirmationSuccessPage').default, - [SCREENS.CURRENCY.SELECTION]: () => require('../../../../pages/CurrencySelectionPage').default, + [SCREENS.CURRENCY.SELECTION]: () => require('../../../../pages/WorkspaceCurrencySelectionPage').default, }); const WorkspaceDuplicateModalStackNavigator = createModalStackNavigator({ @@ -953,12 +953,13 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/companyCards/WorkspaceCompanyCardStatementCloseDatePage').default, [SCREENS.SETTINGS.SAVE_THE_WORLD]: () => require('../../../../pages/TeachersUnite/SaveTheWorldPage').default, - [SCREENS.SETTINGS.SUBSCRIPTION.CHANGE_PAYMENT_CURRENCY]: withAgentAccessDenied(() => require('../../../../pages/settings/PaymentCard/ChangeCurrency').default), [SCREENS.SETTINGS.SUBSCRIPTION.CHANGE_BILLING_CURRENCY]: withAgentAccessDenied( () => require('../../../../pages/settings/Subscription/PaymentCard/ChangeBillingCurrency').default, ), + [SCREENS.SETTINGS.SUBSCRIPTION.DYNAMIC_PAYMENT_CARD_CURRENCY_SELECTOR]: withAgentAccessDenied( + () => require('../../../../pages/settings/Subscription/PaymentCard/DynamicPaymentCardCurrencySelectorPage').default, + ), [SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD]: withAgentAccessDenied(() => require('../../../../pages/settings/Subscription/PaymentCard').default), - [SCREENS.SETTINGS.ADD_PAYMENT_CARD_CHANGE_CURRENCY]: () => require('../../../../pages/settings/PaymentCard/ChangeCurrency').default, [SCREENS.WORKSPACE.REPORT_FIELDS_CREATE]: () => require('../../../../pages/workspace/reports/CreateReportFieldsPage').default, [SCREENS.WORKSPACE.REPORT_FIELDS_SETTINGS]: () => require('../../../../pages/workspace/reports/ReportFieldsSettingsPage').default, [SCREENS.WORKSPACE.REPORT_FIELDS_LIST_VALUES]: () => require('../../../../pages/workspace/reports/ReportFieldsListValuesPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index bd4b95724c4e..20c0991b9628 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -137,7 +137,6 @@ const SETTINGS_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_SUBSCRIPTION_CHANGE_BILLING_CURRENCY, exact: true, }, - [SCREENS.SETTINGS.SUBSCRIPTION.CHANGE_PAYMENT_CURRENCY]: { - path: ROUTES.SETTINGS_SUBSCRIPTION_CHANGE_PAYMENT_CURRENCY, - exact: true, - }, - [SCREENS.SETTINGS.ADD_PAYMENT_CARD_CHANGE_CURRENCY]: { - path: ROUTES.SETTINGS_CHANGE_CURRENCY, - exact: true, - }, [SCREENS.SETTINGS.PREFERENCES.THEME]: { path: ROUTES.SETTINGS_THEME, exact: true, @@ -468,6 +460,7 @@ const config: LinkingOptions['config'] = { }, [SCREENS.SETTINGS.DYNAMIC_VERIFY_ACCOUNT]: DYNAMIC_ROUTES.VERIFY_ACCOUNT.path, [SCREENS.SETTINGS.DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT]: DYNAMIC_ROUTES.ADD_BANK_ACCOUNT_VERIFY_ACCOUNT.path, + [SCREENS.SETTINGS.SUBSCRIPTION.DYNAMIC_PAYMENT_CARD_CURRENCY_SELECTOR]: DYNAMIC_ROUTES.PAYMENT_CARD_CURRENCY_SELECTOR.path, [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: { path: ROUTES.SETTINGS_CONTACT_METHODS.route, exact: true, @@ -1626,7 +1619,7 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE_CONFIRMATION.ROOT]: ROUTES.WORKSPACE_CONFIRMATION.route, [SCREENS.WORKSPACE_CONFIRMATION.OWNER_SELECTOR]: ROUTES.WORKSPACE_CONFIRMATION_OWNER_SELECTOR, [SCREENS.WORKSPACE_CONFIRMATION.SUCCESS]: ROUTES.WORKSPACE_CONFIRMATION_SUCCESS, - [SCREENS.CURRENCY.SELECTION]: ROUTES.CURRENCY_SELECTION.route, + [SCREENS.CURRENCY.SELECTION]: ROUTES.WORKSPACE_CURRENCY_SELECTION.route, }, }, [SCREENS.RIGHT_MODAL.WORKSPACE_DUPLICATE]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index e36072ac31fc..d02b334f1f48 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -615,7 +615,7 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.SUBSCRIPTION.SETTINGS_DETAILS]: undefined; [SCREENS.SETTINGS.SUBSCRIPTION.ADD_PAYMENT_CARD]: undefined; [SCREENS.SETTINGS.SUBSCRIPTION.CHANGE_BILLING_CURRENCY]: undefined; - [SCREENS.SETTINGS.SUBSCRIPTION.CHANGE_PAYMENT_CURRENCY]: undefined; + [SCREENS.SETTINGS.SUBSCRIPTION.DYNAMIC_PAYMENT_CARD_CURRENCY_SELECTOR]: undefined; [SCREENS.WORKSPACE.TAXES_SETTINGS]: { policyID: string; }; diff --git a/src/pages/CurrencySelectionPage.tsx b/src/pages/WorkspaceCurrencySelectionPage.tsx similarity index 89% rename from src/pages/CurrencySelectionPage.tsx rename to src/pages/WorkspaceCurrencySelectionPage.tsx index 423b49b6d863..d28c51ff8ad8 100644 --- a/src/pages/CurrencySelectionPage.tsx +++ b/src/pages/WorkspaceCurrencySelectionPage.tsx @@ -14,7 +14,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; -type CurrencySelectionPageProps = { +type WorkspaceCurrencySelectionPageProps = { route: { params: { backTo?: Route; @@ -22,7 +22,7 @@ type CurrencySelectionPageProps = { }; }; -function CurrencySelectionPage({route}: CurrencySelectionPageProps) { +function WorkspaceCurrencySelectionPage({route}: WorkspaceCurrencySelectionPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -45,7 +45,7 @@ function CurrencySelectionPage({route}: CurrencySelectionPageProps) { ); return ( - + ) => { - if (currency) { - PaymentMethods.setPaymentMethodCurrency(currency); - } - - Navigation.goBack(); - }, []); - - return ( - - - - - - - ); -} - -export default ChangeCurrency; diff --git a/src/pages/settings/Subscription/PaymentCard/ChangeBillingCurrency/index.tsx b/src/pages/settings/Subscription/PaymentCard/ChangeBillingCurrency/index.tsx index 1e1d823b31fc..1da1362e434a 100644 --- a/src/pages/settings/Subscription/PaymentCard/ChangeBillingCurrency/index.tsx +++ b/src/pages/settings/Subscription/PaymentCard/ChangeBillingCurrency/index.tsx @@ -1,26 +1,49 @@ -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; -import PaymentCardChangeCurrencyForm from '@components/AddPaymentCard/PaymentCardChangeCurrencyForm'; -import type {FormOnyxValues} from '@components/Form/types'; +import PaymentCardCurrencyHeader from '@components/AddPaymentCard/PaymentCardCurrencyHeader'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; +import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@navigation/Navigation'; +import {clearDraftValues, clearErrors} from '@libs/actions/FormActions'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; +import {getFieldRequiredErrors, isValidSecurityCode} from '@libs/ValidationUtils'; import * as PaymentMethods from '@userActions/PaymentMethods'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/ChangeBillingCurrencyForm'; + +type Currency = ValueOf; + +const REQUIRED_FIELDS = [INPUT_IDS.SECURITY_CODE]; function ChangeBillingCurrency() { const styles = useThemeStyles(); const {translate} = useLocalize(); const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); + const [formDraft] = useOnyx(ONYXKEYS.FORMS.CHANGE_BILLING_CURRENCY_FORM_DRAFT); + const [formData] = useOnyx(ONYXKEYS.FORMS.CHANGE_BILLING_CURRENCY_FORM); + const defaultCard = useMemo(() => Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard), [fundList]); + const initialCurrency = defaultCard?.accountData?.currency; + const currency = (formDraft?.[INPUT_IDS.CURRENCY] ?? initialCurrency ?? CONST.PAYMENT_CARD_CURRENCY.USD) as Currency; + + // Keep the latest card currency available to the unmount cleanup below without re-running it on every change. + const initialCurrencyRef = useRef(initialCurrency); + useEffect(() => { + initialCurrencyRef.current = initialCurrency; + }, [initialCurrency]); - const [formData] = useOnyx(ONYXKEYS.FORMS.CHANGE_BILLING_CURRENCY_FORM); const formDataComplete = formData?.isLoading === false && !formData.errors; const prevIsLoading = usePrevious(formData?.isLoading); const prevFormDataComplete = usePrevious(formDataComplete); @@ -32,23 +55,71 @@ function ChangeBillingCurrency() { Navigation.goBack(); }, [formDataComplete, prevFormDataComplete, prevIsLoading]); - const changeBillingCurrency = useCallback((currency?: ValueOf, values?: FormOnyxValues) => { - if (!values?.securityCode) { - Navigation.goBack(); - return; - } - PaymentMethods.updateBillingCurrency(currency ?? CONST.PAYMENT_CARD_CURRENCY.USD, values.securityCode); + useEffect(() => { + // Clear any stale submission error (e.g. an incorrect security code from a previous attempt) so reopening the page starts clean, + // and drop the currency draft when leaving the flow. + clearErrors(ONYXKEYS.FORMS.CHANGE_BILLING_CURRENCY_FORM); + return () => { + clearDraftValues(ONYXKEYS.FORMS.CHANGE_BILLING_CURRENCY_FORM); + // The currency selector mirrors the picked currency into ADD_PAYMENT_CARD_FORM; reset it on exit so reopening + // the flow doesn't show a stale selection that no longer matches the card's actual currency. + PaymentMethods.setPaymentMethodCurrency((initialCurrencyRef.current ?? CONST.PAYMENT_CARD_CURRENCY.USD) as Currency); + }; }, []); + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors = getFieldRequiredErrors(values, REQUIRED_FIELDS, translate); + if (values.securityCode && !isValidSecurityCode(values.securityCode)) { + errors.securityCode = translate('addPaymentCardPage.error.securityCode'); + } + return errors; + }; + + const onSubmit = useCallback( + (values: FormOnyxValues) => { + if (!values?.securityCode) { + Navigation.goBack(); + return; + } + PaymentMethods.updateBillingCurrency(currency, values.securityCode); + }, + [currency], + ); + return ( - + + + + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.PAYMENT_CARD_CURRENCY_SELECTOR.path))} + /> + + + ); diff --git a/src/pages/settings/Subscription/PaymentCard/DynamicPaymentCardCurrencySelectorPage.tsx b/src/pages/settings/Subscription/PaymentCard/DynamicPaymentCardCurrencySelectorPage.tsx new file mode 100644 index 000000000000..49f0b2783a95 --- /dev/null +++ b/src/pages/settings/Subscription/PaymentCard/DynamicPaymentCardCurrencySelectorPage.tsx @@ -0,0 +1,82 @@ +import React, {useMemo} from 'react'; +import type {ValueOf} from 'type-fest'; +import PaymentCardCurrencyHeader from '@components/AddPaymentCard/PaymentCardCurrencyHeader'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {setDraftValues} from '@libs/actions/FormActions'; +import Navigation from '@libs/Navigation/Navigation'; +import {setPaymentMethodCurrency} from '@userActions/PaymentMethods'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/ChangeBillingCurrencyForm'; + +type Currency = ValueOf; + +function DynamicPaymentCardCurrencySelectorPage() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isBetaEnabled} = usePermissions(); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.PAYMENT_CARD_CURRENCY_SELECTOR.path); + // The change-billing-currency screen already renders this note above its own form, so we only show it on the + // selector for the other entry points (add payment card / workspace owner change) where it isn't displayed yet. + const shouldShowCurrencyNote = backPath !== ROUTES.SETTINGS_SUBSCRIPTION_CHANGE_BILLING_CURRENCY; + const [formDraft] = useOnyx(ONYXKEYS.FORMS.CHANGE_BILLING_CURRENCY_FORM_DRAFT); + const [addCardForm] = useOnyx(ONYXKEYS.FORMS.ADD_PAYMENT_CARD_FORM); + const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); + + const fallbackCurrency = useMemo( + () => Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard)?.accountData?.currency ?? CONST.PAYMENT_CARD_CURRENCY.USD, + [fundList], + ); + const currentCurrency = (formDraft?.[INPUT_IDS.CURRENCY] ?? addCardForm?.currency ?? fallbackCurrency) as Currency; + + const currencyOptions = useMemo(() => { + const canUseEurBilling = isBetaEnabled(CONST.BETAS.EUR_BILLING); + return (Object.keys(CONST.PAYMENT_CARD_CURRENCY) as Currency[]) + .filter((currency) => currency !== CONST.PAYMENT_CARD_CURRENCY.EUR || canUseEurBilling) + .map((currency) => ({ + text: currency, + value: currency, + keyForList: currency, + isSelected: currency === currentCurrency, + })); + }, [currentCurrency, isBetaEnabled]); + + return ( + + Navigation.goBack(backPath)} + /> + : undefined} + onSelectRow={(option) => { + setDraftValues(ONYXKEYS.FORMS.CHANGE_BILLING_CURRENCY_FORM, {[INPUT_IDS.CURRENCY]: option.value}); + setPaymentMethodCurrency(option.value); + Navigation.goBack(backPath); + }} + shouldSingleExecuteRowSelect + initiallyFocusedItemKey={currentCurrency} + showScrollIndicator + addBottomSafeAreaPadding + /> + + ); +} + +export default DynamicPaymentCardCurrencySelectorPage; diff --git a/src/pages/settings/Subscription/PaymentCard/index.tsx b/src/pages/settings/Subscription/PaymentCard/index.tsx index 6d2bd12a1a4c..245084fc2f0e 100644 --- a/src/pages/settings/Subscription/PaymentCard/index.tsx +++ b/src/pages/settings/Subscription/PaymentCard/index.tsx @@ -31,7 +31,6 @@ import CardAuthenticationModal from '@pages/settings/Subscription/CardAuthentica import {addSubscriptionPaymentCard, clearPaymentCardFormErrorAndSubmit} from '@userActions/PaymentMethods'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; function AddPaymentCard() { @@ -121,7 +120,6 @@ function AddPaymentCard() { addPaymentCard={addPaymentCard} showAcceptTerms showCurrencyField - currencySelectorRoute={ROUTES.SETTINGS_SUBSCRIPTION_CHANGE_PAYMENT_CURRENCY} submitButtonText={translate('subscription.paymentCard.addPaymentCard')} headerContent={{translate('subscription.paymentCard.enterPaymentCardDetails')}} footerContent={ diff --git a/src/pages/workspace/members/WorkspaceOwnerChangeWrapperPage.tsx b/src/pages/workspace/members/WorkspaceOwnerChangeWrapperPage.tsx index 8039d361929b..b0f4fc1877e2 100644 --- a/src/pages/workspace/members/WorkspaceOwnerChangeWrapperPage.tsx +++ b/src/pages/workspace/members/WorkspaceOwnerChangeWrapperPage.tsx @@ -100,7 +100,7 @@ function WorkspaceOwnerChangeWrapperPage({route, policy, isLoadingPolicy}: Works } }} /> - + {isLoading && ( void) | undefined; +let capturedCustomListHeader: React.ReactNode; + +jest.mock('@hooks/usePermissions', () => jest.fn(() => ({isBetaEnabled: () => false}))); + +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); + +jest.mock('@hooks/useThemeStyles', () => + jest.fn( + () => + new Proxy( + {}, + { + get: () => ({}), + }, + ), + ), +); + +jest.mock('@hooks/useOnyx', () => jest.fn()); + +jest.mock('@hooks/useDynamicBackPath', () => jest.fn(() => 'settings/subscription/change-billing-currency')); + +jest.mock('@libs/actions/FormActions', () => ({ + setDraftValues: jest.fn(), +})); + +jest.mock('@userActions/PaymentMethods', () => ({ + setPaymentMethodCurrency: jest.fn(), +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + goBack: jest.fn(), +})); + +jest.mock('@components/ScreenWrapper', () => { + function MockScreenWrapper({children}: {children: React.ReactNode}) { + return children; + } + return MockScreenWrapper; +}); + +jest.mock('@components/HeaderWithBackButton', () => { + function MockHeader({title}: {title: string}) { + return title; + } + return MockHeader; +}); + +jest.mock('@components/SelectionList', () => { + function MockSelectionList({data, onSelectRow, customListHeader}: {data: CurrencyOption[]; onSelectRow: (option: CurrencyOption) => void; customListHeader?: React.ReactNode}) { + capturedData = data ?? []; + capturedOnSelectRow = onSelectRow; + capturedCustomListHeader = customListHeader; + return (data ?? []).map((item) => item.text).join(','); + } + return MockSelectionList; +}); + +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => 'SingleSelectListItem'); + +jest.mock('@components/AddPaymentCard/PaymentCardCurrencyHeader', () => 'PaymentCardCurrencyHeader'); + +const mockUsePermissions = jest.mocked(usePermissions); +const mockUseOnyx = jest.mocked(useOnyx); +const mockUseDynamicBackPath = jest.mocked(useDynamicBackPath); +const mockSetDraftValues = jest.mocked(setDraftValues); +const mockSetPaymentMethodCurrency = jest.mocked(setPaymentMethodCurrency); +const mockGoBack = jest.mocked(Navigation.goBack); + +/** + * The page reads three Onyx keys in order: the CHANGE_BILLING_CURRENCY form draft, the in-flight + * ADD_PAYMENT_CARD form, and the fund list (for the billing card fallback). + */ +const mockOnyx = (formDraftCurrency?: string, addCardCurrency?: string, billingCardCurrency?: string) => { + mockUseOnyx.mockImplementation((key: string) => { + if (key === ONYXKEYS.FORMS.CHANGE_BILLING_CURRENCY_FORM_DRAFT) { + return [formDraftCurrency ? {currency: formDraftCurrency} : undefined, {status: 'loaded'}] as ReturnType; + } + if (key === ONYXKEYS.FORMS.ADD_PAYMENT_CARD_FORM) { + return [addCardCurrency ? {currency: addCardCurrency} : undefined, {status: 'loaded'}] as ReturnType; + } + if (key === ONYXKEYS.FUND_LIST) { + return [billingCardCurrency ? {card1: {accountData: {additionalData: {isBillingCard: true}, currency: billingCardCurrency}}} : undefined, {status: 'loaded'}] as ReturnType< + typeof useOnyx + >; + } + return [undefined, {status: 'loaded'}] as ReturnType; + }); +}; + +describe('DynamicPaymentCardCurrencySelectorPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + capturedData = []; + capturedOnSelectRow = undefined; + capturedCustomListHeader = undefined; + mockUsePermissions.mockReturnValue({isBetaEnabled: () => false}); + mockUseDynamicBackPath.mockReturnValue('settings/subscription/change-billing-currency'); + mockOnyx(); + }); + + it('hides EUR when the EUR billing beta is disabled', () => { + render(); + + const currencies = capturedData.map((option) => option.value); + expect(currencies).toEqual(['USD', 'AUD', 'GBP', 'NZD']); + expect(currencies).not.toContain('EUR'); + }); + + it('shows EUR when the EUR billing beta is enabled', () => { + mockUsePermissions.mockReturnValue({isBetaEnabled: () => true}); + + render(); + + expect(capturedData.map((option) => option.value)).toContain('EUR'); + }); + + it('marks the form draft currency as selected', () => { + mockOnyx('AUD'); + + render(); + + const selected = capturedData.filter((option) => option.isSelected); + expect(selected).toHaveLength(1); + expect(selected.at(0)?.value).toBe('AUD'); + }); + + it('falls back to the add-payment-card form currency when the draft is empty', () => { + mockOnyx(undefined, 'NZD'); + + render(); + + expect(capturedData.find((option) => option.isSelected)?.value).toBe('NZD'); + }); + + it('falls back to the billing card currency when both the draft and the add-card form are empty', () => { + mockOnyx(undefined, undefined, 'GBP'); + + render(); + + expect(capturedData.find((option) => option.isSelected)?.value).toBe('GBP'); + }); + + it('writes the chosen currency to both flows and navigates back on select', () => { + render(); + + const aud = capturedData.find((option) => option.value === 'AUD'); + expect(aud).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + capturedOnSelectRow?.(aud!); + + expect(mockSetDraftValues).toHaveBeenCalledWith(ONYXKEYS.FORMS.CHANGE_BILLING_CURRENCY_FORM, {currency: 'AUD'}); + expect(mockSetPaymentMethodCurrency).toHaveBeenCalledWith('AUD'); + expect(mockGoBack).toHaveBeenCalledWith('settings/subscription/change-billing-currency'); + }); + + it('shows the currency note when opened from a flow that does not already display it (e.g. add payment card)', () => { + mockUseDynamicBackPath.mockReturnValue('settings/subscription/add-payment-card'); + + render(); + + const header = capturedCustomListHeader as React.ReactElement<{isSectionList?: boolean}>; + expect(header).toBeTruthy(); + expect(header.type).toBe('PaymentCardCurrencyHeader'); + expect(header.props.isSectionList).toBe(true); + }); + + it('hides the currency note when opened from change billing currency, which already shows it on the form', () => { + // The default mocked back path is the change-billing-currency screen. + render(); + + expect(capturedCustomListHeader).toBeUndefined(); + }); +});