diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index a5a969adb833..475f355c6a10 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -334,6 +334,8 @@ const ONYXKEYS = { REPORT_PHYSICAL_CARD_FORM_DRAFT: 'requestPhysicalCardFormDraft', REPORT_VIRTUAL_CARD_FRAUD: 'reportVirtualCardFraudForm', REPORT_VIRTUAL_CARD_FRAUD_DRAFT: 'reportVirtualCardFraudFormDraft', + GET_PHYSICAL_CARD_FORM: 'getPhysicalCardForm', + GET_PHYSICAL_CARD_FORM_DRAFT: 'getPhysicalCardFormDraft', }, } as const; @@ -500,6 +502,8 @@ type OnyxValues = { [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form | undefined; }; type OnyxKeyValue = OnyxEntry; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 57d4eb8187ec..26589a3db0e0 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -83,6 +83,22 @@ export default { route: '/settings/wallet/card/:domain/report-virtual-fraud', getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-virtual-fraud`, }, + SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME: { + route: '/settings/wallet/card/:domain/get-physical/name', + getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/name`, + }, + SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE: { + route: '/settings/wallet/card/:domain/get-physical/phone', + getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/phone`, + }, + SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS: { + route: '/settings/wallet/card/:domain/get-physical/address', + getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/address`, + }, + SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM: { + route: '/settings/wallet/card/:domain/get-physical/confirm', + getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/confirm`, + }, SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card', SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account', SETTINGS_ENABLE_PAYMENTS: 'settings/wallet/enable-payments', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index afc368858f55..f957a1dbb25e 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -23,7 +23,13 @@ export default { SECURITY: 'Settings_Security', STATUS: 'Settings_Status', WALLET: 'Settings_Wallet', - WALLET_DOMAIN_CARDS: 'Settings_Wallet_DomainCards', + WALLET_DOMAIN_CARD: 'Settings_Wallet_DomainCard', + WALLET_CARD_GET_PHYSICAL: { + NAME: 'Settings_Card_Get_Physical_Name', + PHONE: 'Settings_Card_Get_Physical_Phone', + ADDRESS: 'Settings_Card_Get_Physical_Address', + CONFIRM: 'Settings_Card_Get_Physical_Confirm', + }, }, SAVE_THE_WORLD: { ROOT: 'SaveTheWorld_Root', diff --git a/src/components/AddressForm.js b/src/components/AddressForm.js new file mode 100644 index 000000000000..19ab35f036c1 --- /dev/null +++ b/src/components/AddressForm.js @@ -0,0 +1,223 @@ +import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST'; +import lodashGet from 'lodash/get'; +import PropTypes from 'prop-types'; +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import _ from 'underscore'; +import useLocalize from '@hooks/useLocalize'; +import Navigation from '@libs/Navigation/Navigation'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import styles from '@styles/styles'; +import CONST from '@src/CONST'; +import AddressSearch from './AddressSearch'; +import CountrySelector from './CountrySelector'; +import Form from './Form'; +import StatePicker from './StatePicker'; +import TextInput from './TextInput'; + +const propTypes = { + /** Address city field */ + city: PropTypes.string, + + /** Address country field */ + country: PropTypes.string, + + /** Address state field */ + state: PropTypes.string, + + /** Address street line 1 field */ + street1: PropTypes.string, + + /** Address street line 2 field */ + street2: PropTypes.string, + + /** Address zip code field */ + zip: PropTypes.string, + + /** Callback which is executed when the user changes address, city or state */ + onAddressChanged: PropTypes.func, + + /** Callback which is executed when the user submits his address changes */ + onSubmit: PropTypes.func.isRequired, + + /** Whether or not should the form data should be saved as draft */ + shouldSaveDraft: PropTypes.bool, + + /** Text displayed on the bottom submit button */ + submitButtonText: PropTypes.string, + + /** A unique Onyx key identifying the form */ + formID: PropTypes.string.isRequired, +}; + +const defaultProps = { + city: '', + country: '', + onAddressChanged: () => {}, + shouldSaveDraft: false, + state: '', + street1: '', + street2: '', + submitButtonText: '', + zip: '', +}; + +function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldSaveDraft, state, street1, street2, submitButtonText, zip}) { + const {translate} = useLocalize(); + const zipSampleFormat = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, [country, 'samples'], ''); + const zipFormat = translate('common.zipCodeExampleFormat', {zipSampleFormat}); + const isUSAForm = country === CONST.COUNTRY.US; + + /** + * @param {Function} translate - translate function + * @param {Boolean} isUSAForm - selected country ISO code is US + * @param {Object} values - form input values + * @returns {Object} - An object containing the errors for each inputID + */ + const validator = useCallback((values) => { + const errors = {}; + const requiredFields = ['addressLine1', 'city', 'country', 'state']; + + // Check "State" dropdown is a valid state if selected Country is USA + if (values.country === CONST.COUNTRY.US && !COMMON_CONST.STATES[values.state]) { + errors.state = 'common.error.fieldRequired'; + } + + // Add "Field required" errors if any required field is empty + _.each(requiredFields, (fieldKey) => { + if (ValidationUtils.isRequiredFulfilled(values[fieldKey])) { + return; + } + errors[fieldKey] = 'common.error.fieldRequired'; + }); + + // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object + const countryRegexDetails = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, values.country, {}); + + // The postal code system might not exist for a country, so no regex either for them. + const countrySpecificZipRegex = lodashGet(countryRegexDetails, 'regex'); + const countryZipFormat = lodashGet(countryRegexDetails, 'samples'); + + if (countrySpecificZipRegex) { + if (!countrySpecificZipRegex.test(values.zipPostCode.trim().toUpperCase())) { + if (ValidationUtils.isRequiredFulfilled(values.zipPostCode.trim())) { + errors.zipPostCode = ['privatePersonalDetails.error.incorrectZipFormat', {zipFormat: countryZipFormat}]; + } else { + errors.zipPostCode = 'common.error.fieldRequired'; + } + } + } else if (!CONST.GENERIC_ZIP_CODE_REGEX.test(values.zipPostCode.trim().toUpperCase())) { + errors.zipPostCode = 'privatePersonalDetails.error.incorrectZipFormat'; + } + + return errors; + }, []); + + return ( +
+ + + { + onAddressChanged(data, key); + // This enforces the country selector to use the country from address instead of the country from URL + Navigation.setParams({country: undefined}); + }} + defaultValue={street1 || ''} + renamedInputKeys={{ + street: 'addressLine1', + street2: 'addressLine2', + city: 'city', + state: 'state', + zipCode: 'zipPostCode', + country: 'country', + }} + maxInputLength={CONST.FORM_CHARACTER_LIMIT} + shouldSaveDraft={shouldSaveDraft} + /> + + + + + + + + + {isUSAForm ? ( + + + + ) : ( + + )} + + + + + + ); +} + +AddressForm.defaultProps = defaultProps; +AddressForm.displayName = 'AddressForm'; +AddressForm.propTypes = propTypes; + +export default AddressForm; diff --git a/src/components/Form.js b/src/components/Form.js index 28343691ea15..d5865dab44b8 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -6,6 +6,7 @@ import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import compose from '@libs/compose'; import * as ErrorUtils from '@libs/ErrorUtils'; +import FormUtils from '@libs/FormUtils'; import * as ValidationUtils from '@libs/ValidationUtils'; import Visibility from '@libs/Visibility'; import stylePropTypes from '@styles/stylePropTypes'; @@ -303,7 +304,8 @@ function Form(props) { // We want to initialize the input value if it's undefined if (_.isUndefined(inputValues[inputID])) { - inputValues[inputID] = _.isBoolean(defaultValue) ? defaultValue : defaultValue || ''; + // eslint-disable-next-line es/no-nullish-coalescing-operators + inputValues[inputID] = defaultValue ?? ''; } // We force the form to set the input value from the defaultValue props if there is a saved valid value @@ -543,7 +545,7 @@ export default compose( key: (props) => props.formID, }, draftValues: { - key: (props) => `${props.formID}Draft`, + key: (props) => FormUtils.getDraftKey(props.formID), }, }), )(Form); diff --git a/src/components/TextInput/BaseTextInput/index.js b/src/components/TextInput/BaseTextInput/index.js index bfd3a19659bb..a28365480c7a 100644 --- a/src/components/TextInput/BaseTextInput/index.js +++ b/src/components/TextInput/BaseTextInput/index.js @@ -250,7 +250,7 @@ function BaseTextInput(props) { return ( <> @@ -261,7 +261,6 @@ function BaseTextInput(props) { style={[ props.autoGrowHeight && styles.autoGrowHeightInputContainer(textInputHeight, variables.componentSizeLarge, maxHeight), !isMultiline && styles.componentHeightLarge, - ...props.containerStyles, ]} > `Transfer${amount ? ` ${amount}` : ''}`, instant: 'Instant (Debit card)', diff --git a/src/languages/es.ts b/src/languages/es.ts index 12b0c95579e5..d0c5de6066b4 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -868,6 +868,7 @@ export default { availableSpend: 'Límite restante', virtualCardNumber: 'Número de la tarjeta virtual', physicalCardNumber: 'Número de la tarjeta física', + getPhysicalCard: 'Obtener tarjeta física', reportFraud: 'Reportar fraude con la tarjeta virtual', reviewTransaction: 'Revisar transacción', suspiciousBannerTitle: 'Transacción sospechosa', @@ -899,6 +900,28 @@ export default { thatDidntMatch: 'Los 4 últimos dígitos de tu tarjeta no coinciden. Por favor, inténtalo de nuevo.', }, }, + // TODO: add translation + getPhysicalCard: { + header: 'Obtener tarjeta física', + nameMessage: 'Introduce tu nombre y apellido como aparecerá en tu tarjeta.', + legalName: 'Nombre completo', + legalFirstName: 'Nombre legal', + legalLastName: 'Apellidos legales', + phoneMessage: 'Introduce tu número de teléfono.', + phoneNumber: 'Número de teléfono', + address: 'Dirección', + addressMessage: 'Introduce tu dirección de envío.', + streetAddress: 'Calle de dirección', + city: 'Ciudad', + state: 'Estado', + zipPostcode: 'Código postal', + country: 'País', + confirmMessage: 'Por favor confirma tus datos.', + estimatedDeliveryMessage: 'Tu tarjeta física llegará en 2-3 días laborales.', + next: 'Siguiente', + getPhysicalCard: 'Obtener tarjeta física', + shipCard: 'Enviar tarjeta', + }, transferAmountPage: { transfer: ({amount}: TransferParams) => `Transferir${amount ? ` ${amount}` : ''}`, instant: 'Instante', diff --git a/src/libs/FormUtils.ts b/src/libs/FormUtils.ts new file mode 100644 index 000000000000..facaf5bfddf4 --- /dev/null +++ b/src/libs/FormUtils.ts @@ -0,0 +1,10 @@ +import {OnyxFormKey} from '@src/ONYXKEYS'; + +type ExcludeDraft = T extends `${string}Draft` ? never : T; +type OnyxFormKeyWithoutDraft = ExcludeDraft; + +function getDraftKey(formID: OnyxFormKeyWithoutDraft): `${OnyxFormKeyWithoutDraft}Draft` { + return `${formID}Draft`; +} + +export default {getDraftKey}; diff --git a/src/libs/GetPhysicalCardUtils.ts b/src/libs/GetPhysicalCardUtils.ts new file mode 100644 index 000000000000..57a9d773cc9d --- /dev/null +++ b/src/libs/GetPhysicalCardUtils.ts @@ -0,0 +1,130 @@ +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import {Login} from '@src/types/onyx'; +import Navigation from './Navigation/Navigation'; +import * as PersonalDetailsUtils from './PersonalDetailsUtils'; +import * as UserUtils from './UserUtils'; + +type DraftValues = { + addressLine1: string; + addressLine2: string; + city: string; + country: string; + legalFirstName: string; + legalLastName: string; + phoneNumber: string; + state: string; + zipPostCode: string; +}; + +type PrivatePersonalDetails = { + address: {street: string; city: string; state: string; country: string; zip: string}; + legalFirstName: string; + legalLastName: string; + phoneNumber: string; +}; + +type LoginList = Record; + +/** + * + * @param domain + * @param privatePersonalDetails + * @param loginList + * @returns + */ +function getCurrentRoute(domain: string, privatePersonalDetails: PrivatePersonalDetails, loginList: LoginList) { + const { + address: {street, city, state, country, zip}, + legalFirstName, + legalLastName, + phoneNumber, + } = privatePersonalDetails; + + if (!legalFirstName && !legalLastName) { + return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.getRoute(domain); + } + if (!phoneNumber && !UserUtils.getSecondaryPhoneLogin(loginList)) { + return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.getRoute(domain); + } + if (!(street && city && state && country && zip)) { + return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS.getRoute(domain); + } + + return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.getRoute(domain); +} + +/** + * + * @param domain + * @param privatePersonalDetails + * @param loginList + * @returns + */ +function goToNextPhysicalCardRoute(domain: string, privatePersonalDetails: PrivatePersonalDetails, loginList: LoginList) { + Navigation.navigate(getCurrentRoute(domain, privatePersonalDetails, loginList)); +} + +/** + * + * @param currentRoute + * @param domain + * @param privatePersonalDetails + * @param loginList + * @returns + */ +function setCurrentRoute(currentRoute: string, domain: string, privatePersonalDetails: PrivatePersonalDetails, loginList: LoginList) { + const expectedRoute = getCurrentRoute(domain, privatePersonalDetails, loginList); + + // If the user is on the current route or the current route is confirmation, then he's allowed to stay on the current step + if ([currentRoute, ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.getRoute(domain)].includes(expectedRoute)) { + return; + } + + // Redirect the user if he's not allowed to be on the current step + Navigation.navigate(expectedRoute, CONST.NAVIGATION.ACTION_TYPE.REPLACE); +} + +/** + * + * @param draftValues + * @param privatePersonalDetails + * @returns + */ +function getUpdatedDraftValues(draftValues: DraftValues, privatePersonalDetails: PrivatePersonalDetails, loginList: LoginList) { + const { + address: {city, country, state, street = '', zip}, + legalFirstName, + legalLastName, + phoneNumber, + } = privatePersonalDetails; + + return { + legalFirstName: draftValues.legalFirstName || legalFirstName, + legalLastName: draftValues.legalLastName || legalLastName, + addressLine1: draftValues.addressLine1 || street.split('\n')[0], + addressLine2: draftValues.addressLine2 || street.split('\n')[1] || '', + city: draftValues.city || city, + country: draftValues.country || country, + phoneNumber: draftValues.phoneNumber || (phoneNumber ?? UserUtils.getSecondaryPhoneLogin(loginList) ?? ''), + state: draftValues.state || state, + zipPostCode: draftValues.zipPostCode || zip, + }; +} + +/** + * + * @param draftValues + * @returns + */ +function getUpdatedPrivatePersonalDetails(draftValues: DraftValues) { + const {addressLine1, addressLine2, city, country, legalFirstName, legalLastName, phoneNumber, state, zipPostCode} = draftValues; + return { + legalFirstName, + legalLastName, + phoneNumber, + address: {street: PersonalDetailsUtils.getFormattedStreet(addressLine1, addressLine2), city, country, state, zip: zipPostCode}, + }; +} + +export {getUpdatedDraftValues, getUpdatedPrivatePersonalDetails, goToNextPhysicalCardRoute, setCurrentRoute}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index a2f9bdd7a903..01573cb434b4 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -159,9 +159,13 @@ const SettingsModalStackNavigator = createModalStackNavigator({ Settings_Lounge_Access: () => require('../../../pages/settings/Profile/LoungeAccessPage').default, Settings_Wallet: () => require('../../../pages/settings/Wallet/WalletPage').default, Settings_Wallet_Cards_Digital_Details_Update_Address: () => require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default, - Settings_Wallet_DomainCards: () => require('../../../pages/settings/Wallet/ExpensifyCardPage').default, + Settings_Wallet_DomainCard: () => require('../../../pages/settings/Wallet/ExpensifyCardPage').default, Settings_Wallet_ReportVirtualCardFraud: () => require('../../../pages/settings/Wallet/ReportVirtualCardFraudPage').default, Settings_Wallet_Card_Activate: () => require('../../../pages/settings/Wallet/ActivatePhysicalCardPage').default, + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.NAME]: () => require('../../../pages/settings/Wallet/Card/GetPhysicalCardName').default, + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.PHONE]: () => require('../../../pages/settings/Wallet/Card/GetPhysicalCardPhone').default, + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.ADDRESS]: () => require('../../../pages/settings/Wallet/Card/GetPhysicalCardAddress').default, + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.CONFIRM]: () => require('../../../pages/settings/Wallet/Card/GetPhysicalCardConfirm').default, Settings_Wallet_Transfer_Balance: () => require('../../../pages/settings/Wallet/TransferBalancePage').default, Settings_Wallet_Choose_Transfer_Account: () => require('../../../pages/settings/Wallet/ChooseTransferAccountPage').default, Settings_Wallet_EnablePayments: () => require('../../../pages/EnablePayments/EnablePaymentsPage').default, diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js index 7a2c61ea7b53..2629d36999bf 100644 --- a/src/libs/Navigation/Navigation.js +++ b/src/libs/Navigation/Navigation.js @@ -111,7 +111,6 @@ function navigate(route = ROUTES.HOME, type) { pendingRoute = route; return; } - linkTo(navigationRef.current, route, type); } diff --git a/src/libs/Navigation/linkTo.js b/src/libs/Navigation/linkTo.js index 286074914cf7..55bd4b31dbdf 100644 --- a/src/libs/Navigation/linkTo.js +++ b/src/libs/Navigation/linkTo.js @@ -83,6 +83,17 @@ export default function linkTo(navigation, path, type) { if (action.payload.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { const minimalAction = getMinimalAction(action, navigation.getRootState()); if (minimalAction) { + // There are situations where a route already exists on the current navigation stack + // But we want to push the same route instead of going back in the stack + // Which would break the user navigation history + if (type === CONST.NAVIGATION.ACTION_TYPE.PUSH) { + minimalAction.type = CONST.NAVIGATION.ACTION_TYPE.PUSH; + } + // There are situations when the user is trying to access a route which he has no access to + // So we want to redirect him to the right one and replace the one he tried to access + if (type === CONST.NAVIGATION.ACTION_TYPE.REPLACE) { + minimalAction.type = CONST.NAVIGATION.ACTION_TYPE.REPLACE; + } root.dispatch(minimalAction); return; } diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 44473998ac62..e0ac35c957a3 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -73,7 +73,7 @@ export default { path: ROUTES.SETTINGS_WALLET, exact: true, }, - Settings_Wallet_DomainCards: { + Settings_Wallet_DomainCard: { path: ROUTES.SETTINGS_WALLET_DOMAINCARD.route, exact: true, }, @@ -81,6 +81,22 @@ export default { path: ROUTES.SETTINGS_REPORT_FRAUD.route, exact: true, }, + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.NAME]: { + path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.route, + exact: true, + }, + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.PHONE]: { + path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.route, + exact: true, + }, + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.ADDRESS]: { + path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS.route, + exact: true, + }, + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.CONFIRM]: { + path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.route, + exact: true, + }, Settings_Wallet_EnablePayments: { path: ROUTES.SETTINGS_ENABLE_PAYMENTS, exact: true, diff --git a/src/libs/PersonalDetailsUtils.js b/src/libs/PersonalDetailsUtils.js index c99adc32a56a..560480dcec9d 100644 --- a/src/libs/PersonalDetailsUtils.js +++ b/src/libs/PersonalDetailsUtils.js @@ -162,6 +162,26 @@ function formatPiece(piece) { return piece ? `${piece}, ` : ''; } +/** + * + * @param {String} street1 - street line 1 + * @param {String} street2 - street line 2 + * @returns {String} formatted street + */ +function getFormattedStreet(street1 = '', street2 = '') { + return `${street1}\n${street2}`; +} + +/** + * + * @param {*} street - formatted address + * @returns {[string, string]} [street1, street2] + */ +function getStreetLines(street = '') { + const streets = street.split('\n'); + return [streets[0], streets[1]]; +} + /** * Formats an address object into an easily readable string * @@ -170,11 +190,20 @@ function formatPiece(piece) { */ function getFormattedAddress(privatePersonalDetails) { const {address} = privatePersonalDetails; - const [street1, street2] = (address.street || '').split('\n'); + const [street1, street2] = getStreetLines(address.street); const formattedAddress = formatPiece(street1) + formatPiece(street2) + formatPiece(address.city) + formatPiece(address.state) + formatPiece(address.zip) + formatPiece(address.country); // Remove the last comma of the address return formattedAddress.trim().replace(/,$/, ''); } -export {getDisplayNameOrDefault, getPersonalDetailsByIDs, getAccountIDsByLogins, getLoginsByAccountIDs, getNewPersonalDetailsOnyxData, getFormattedAddress}; +export { + getDisplayNameOrDefault, + getPersonalDetailsByIDs, + getAccountIDsByLogins, + getLoginsByAccountIDs, + getNewPersonalDetailsOnyxData, + getFormattedAddress, + getFormattedStreet, + getStreetLines, +}; diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts index 1a5ced6c9f85..b6d061432585 100644 --- a/src/libs/UserUtils.ts +++ b/src/libs/UserUtils.ts @@ -1,3 +1,4 @@ +import Str from 'expensify-common/lib/str'; import {SvgProps} from 'react-native-svg'; import {ValueOf} from 'type-fest'; import * as defaultAvatars from '@components/Icon/DefaultAvatars'; @@ -190,6 +191,14 @@ function generateAccountID(searchValue: string): number { return hashText(searchValue, 2 ** 32); } +/** + * Gets the secondary phone login number + */ +function getSecondaryPhoneLogin(loginList: Record): string | undefined { + const parsedLoginList = Object.keys(loginList).map((login) => Str.removeSMSDomain(login)); + return parsedLoginList.find((login) => Str.isValidPhone(login)); +} + export { hashText, hasLoginListError, @@ -203,5 +212,6 @@ export { getSmallSizeAvatar, getFullSizeAvatar, generateAccountID, + getSecondaryPhoneLogin, }; export type {AvatarSource}; diff --git a/src/libs/actions/FormActions.ts b/src/libs/actions/FormActions.ts index ecaf38dc44f2..29d9ecda9f73 100644 --- a/src/libs/actions/FormActions.ts +++ b/src/libs/actions/FormActions.ts @@ -1,5 +1,6 @@ import Onyx from 'react-native-onyx'; import {KeyValueMapping, NullishDeep} from 'react-native-onyx/lib/types'; +import FormUtils from '@libs/FormUtils'; import {OnyxFormKey} from '@src/ONYXKEYS'; import {Form} from '@src/types/onyx'; import * as OnyxCommon from '@src/types/onyx/OnyxCommon'; @@ -19,8 +20,15 @@ function setErrorFields(formID: OnyxFormKey, errorFields: OnyxCommon.ErrorFields Onyx.merge(formID, {errorFields} satisfies Form); } -function setDraftValues(formID: T, draftValues: NullishDeep) { - Onyx.merge(`${formID}Draft`, draftValues); +function setDraftValues(formID: OnyxFormKeyWithoutDraft, draftValues: NullishDeep) { + Onyx.merge(FormUtils.getDraftKey(formID), draftValues); } -export {setDraftValues, setErrorFields, setErrors, setIsLoading}; +/** + * @param formID + */ +function clearDraftValues(formID: OnyxFormKeyWithoutDraft) { + Onyx.merge(FormUtils.getDraftKey(formID), undefined); +} + +export {setDraftValues, setErrorFields, setErrors, setIsLoading, clearDraftValues}; diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index 2d51fbb9e8d2..12da798177ab 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -4,6 +4,7 @@ import * as API from '@libs/API'; import {CustomRNImageManipulatorResult, FileWithUri} from '@libs/cropOrRotateImage/types'; import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as UserUtils from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -267,7 +268,7 @@ function updateAddress(street: string, street2: string, city: string, state: str key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, value: { address: { - street: `${street}\n${street2}`, + street: PersonalDetailsUtils.getFormattedStreet(street, street2), city, state, zip, diff --git a/src/libs/actions/Wallet.js b/src/libs/actions/Wallet.js index bfc2a7306434..fad529c4b1f5 100644 --- a/src/libs/actions/Wallet.js +++ b/src/libs/actions/Wallet.js @@ -330,6 +330,45 @@ function answerQuestionsForWallet(answers, idNumber) { ); } +function requestPhysicalExpensifyCard(cardID, authToken, privatePersonalDetails) { + const { + legalFirstName, + legalLastName, + phoneNumber, + address: {city, country, state, street, zip}, + } = privatePersonalDetails; + const params = { + authToken, + legalFirstName, + legalLastName, + phoneNumber, + addressCity: city, + addressCountry: country, + addressState: state, + addressStreet: street, + addressZip: zip, + }; + const onyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: { + [cardID]: { + state: 4, // NOT_ACTIVATED + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + value: privatePersonalDetails, + }, + ], + }; + API.write('RequestPhysicalExpensifyCard', params, onyxData); +} + export { openOnfidoFlow, openInitialSettingsPage, @@ -343,4 +382,5 @@ export { verifyIdentity, acceptWalletTerms, setKYCWallSource, + requestPhysicalExpensifyCard, }; diff --git a/src/pages/ErrorPage/NotFoundPage.js b/src/pages/ErrorPage/NotFoundPage.js index 9ada6b820e8e..aac2e6d613f9 100644 --- a/src/pages/ErrorPage/NotFoundPage.js +++ b/src/pages/ErrorPage/NotFoundPage.js @@ -1,16 +1,31 @@ +import PropTypes from 'prop-types'; import React from 'react'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import ScreenWrapper from '@components/ScreenWrapper'; +const propTypes = { + /** Method to trigger when pressing back button of the header */ + onBackButtonPress: PropTypes.func, +}; + +const defaultProps = { + onBackButtonPress: undefined, +}; + // eslint-disable-next-line rulesdir/no-negated-variables -function NotFoundPage() { +function NotFoundPage({onBackButtonPress}) { return ( - + ); } NotFoundPage.displayName = 'NotFoundPage'; +NotFoundPage.propTypes = propTypes; +NotFoundPage.defaultProps = defaultProps; export default NotFoundPage; diff --git a/src/pages/settings/Profile/PersonalDetails/AddressPage.js b/src/pages/settings/Profile/PersonalDetails/AddressPage.js index 22907aa6e5b0..fa22a3b22f9e 100644 --- a/src/pages/settings/Profile/PersonalDetails/AddressPage.js +++ b/src/pages/settings/Profile/PersonalDetails/AddressPage.js @@ -1,25 +1,16 @@ -import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import AddressSearch from '@components/AddressSearch'; -import CountrySelector from '@components/CountrySelector'; -import Form from '@components/Form'; +import AddressForm from '@components/AddressForm'; import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; -import StatePicker from '@components/StatePicker'; -import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; import usePrivatePersonalDetails from '@hooks/usePrivatePersonalDetails'; import Navigation from '@libs/Navigation/Navigation'; -import * as ValidationUtils from '@libs/ValidationUtils'; import useThemeStyles from '@styles/useThemeStyles'; import * as PersonalDetails from '@userActions/PersonalDetails'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -75,9 +66,6 @@ function AddressPage({privatePersonalDetails, route}) { const address = useMemo(() => lodashGet(privatePersonalDetails, 'address') || {}, [privatePersonalDetails]); const countryFromUrl = lodashGet(route, 'params.country'); const [currentCountry, setCurrentCountry] = useState(address.country); - const zipSampleFormat = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, [currentCountry, 'samples'], ''); - const zipFormat = translate('common.zipCodeExampleFormat', {zipSampleFormat}); - const isUSAForm = currentCountry === CONST.COUNTRY.US; const isLoadingPersonalDetails = lodashGet(privatePersonalDetails, 'isLoading', true); const [street1, street2] = (address.street || '').split('\n'); const [state, setState] = useState(address.state); @@ -94,51 +82,6 @@ function AddressPage({privatePersonalDetails, route}) { setZipcode(address.zip); }, [address]); - /** - * @param {Function} translate - translate function - * @param {Boolean} isUSAForm - selected country ISO code is US - * @param {Object} values - form input values - * @returns {Object} - An object containing the errors for each inputID - */ - const validate = useCallback((values) => { - const errors = {}; - const requiredFields = ['addressLine1', 'city', 'country', 'state']; - - // Check "State" dropdown is a valid state if selected Country is USA - if (values.country === CONST.COUNTRY.US && !COMMON_CONST.STATES[values.state]) { - errors.state = 'common.error.fieldRequired'; - } - - // Add "Field required" errors if any required field is empty - _.each(requiredFields, (fieldKey) => { - if (ValidationUtils.isRequiredFulfilled(values[fieldKey])) { - return; - } - errors[fieldKey] = 'common.error.fieldRequired'; - }); - - // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object - const countryRegexDetails = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, values.country, {}); - - // The postal code system might not exist for a country, so no regex either for them. - const countrySpecificZipRegex = lodashGet(countryRegexDetails, 'regex'); - const countryZipFormat = lodashGet(countryRegexDetails, 'samples'); - - if (countrySpecificZipRegex) { - if (!countrySpecificZipRegex.test(values.zipPostCode.trim().toUpperCase())) { - if (ValidationUtils.isRequiredFulfilled(values.zipPostCode.trim())) { - errors.zipPostCode = ['privatePersonalDetails.error.incorrectZipFormat', {zipFormat: countryZipFormat}]; - } else { - errors.zipPostCode = 'common.error.fieldRequired'; - } - } - } else if (!CONST.GENERIC_ZIP_CODE_REGEX.test(values.zipPostCode.trim().toUpperCase())) { - errors.zipPostCode = 'privatePersonalDetails.error.incorrectZipFormat'; - } - - return errors; - }, []); - const handleAddressChange = useCallback((value, key) => { if (key !== 'country' && key !== 'state' && key !== 'city' && key !== 'zipPostCode') { return; @@ -184,93 +127,18 @@ function AddressPage({privatePersonalDetails, route}) { {isLoadingPersonalDetails ? ( ) : ( -
- - - - - - - - - - - {isUSAForm ? ( - - - - ) : ( - - )} - - - - - + city={city} + country={currentCountry} + onAddressChanged={handleAddressChange} + state={state} + street1={street1} + street2={street2} + zip={zipcode} + /> )} ); diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js new file mode 100644 index 000000000000..030ca04b7074 --- /dev/null +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js @@ -0,0 +1,236 @@ +import PropTypes from 'prop-types'; +import React, {useCallback, useEffect, useRef} from 'react'; +import {Text} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import Form from '@components/Form'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import * as FormActions from '@libs/actions/FormActions'; +import * as Wallet from '@libs/actions/Wallet'; +import * as CardUtils from '@libs/CardUtils'; +import FormUtils from '@libs/FormUtils'; +import * as GetPhysicalCardUtils from '@libs/GetPhysicalCardUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import assignedCardPropTypes from '@pages/settings/Wallet/assignedCardPropTypes'; +import styles from '@styles/styles'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +const propTypes = { + /* Onyx Props */ + /** List of available assigned cards */ + cardList: PropTypes.objectOf(assignedCardPropTypes), + + /** User's private personal details */ + privatePersonalDetails: PropTypes.shape({ + legalFirstName: PropTypes.string, + legalLastName: PropTypes.string, + phoneNumber: PropTypes.string, + /** User's home address */ + address: PropTypes.shape({ + street: PropTypes.string, + city: PropTypes.string, + state: PropTypes.string, + zip: PropTypes.string, + country: PropTypes.string, + }), + }), + + /** Draft values used by the get physical card form */ + draftValues: PropTypes.shape({ + addressLine1: PropTypes.string, + addressLine2: PropTypes.string, + city: PropTypes.string, + country: PropTypes.string, + phoneNumber: PropTypes.string, + legalFirstName: PropTypes.string, + legalLastName: PropTypes.string, + state: PropTypes.string, + zipPostCode: PropTypes.string, + }), + + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged in user authToken */ + authToken: PropTypes.string, + }), + + /** List of available login methods */ + loginList: PropTypes.shape({ + /** The partner creating the account. It depends on the source: website, mobile, integrations, ... */ + partnerName: PropTypes.string, + + /** Phone/Email associated with user */ + partnerUserID: PropTypes.string, + + /** The date when the login was validated, used to show the brickroad status */ + validatedDate: PropTypes.string, + + /** Field-specific server side errors keyed by microtime */ + errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + + /** Field-specific pending states for offline UI status */ + pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + }), + + /* Base Props */ + /** Text displayed below page title */ + headline: PropTypes.string.isRequired, + + /** Children components that will be rendered by renderContent */ + children: PropTypes.node, + + /** Current route from ROUTES */ + currentRoute: PropTypes.string.isRequired, + + /** Expensify card domain */ + domain: PropTypes.string, + + /** Whether or not the current step of the get physical card flow is the confirmation page */ + isConfirmation: PropTypes.bool, + + /** Render prop, used to render form content */ + renderContent: PropTypes.func, + + /** Text displayed on bottom submit button */ + submitButtonText: PropTypes.string.isRequired, + + /** Title displayed on top of the page */ + title: PropTypes.string.isRequired, + + /** Callback executed when validating get physical card form data */ + onValidate: PropTypes.func, +}; + +const defaultProps = { + cardList: {}, + children: null, + domain: '', + draftValues: null, + privatePersonalDetails: null, + session: {}, + loginList: {}, + isConfirmation: false, + renderContent: (onSubmit, submitButtonText, children = () => {}, onValidate = () => ({})) => ( +
+ {children} +
+ ), + onValidate: () => ({}), +}; + +function BaseGetPhysicalCard({ + cardList, + children, + currentRoute, + domain, + draftValues, + privatePersonalDetails, + headline, + isConfirmation, + loginList, + renderContent, + session: {authToken}, + submitButtonText, + title, + onValidate, +}) { + const isRouteSet = useRef(false); + + useEffect(() => { + if (isRouteSet.current || !privatePersonalDetails || !cardList) { + return; + } + + const domainCards = CardUtils.getDomainCards(cardList)[domain] || []; + const physicalCard = _.find(domainCards, (card) => !card.isVirtual); + + // When there are no cards for the specified domain, user is redirected to the wallet page + if (domainCards.length === 0) { + Navigation.goBack(ROUTES.SETTINGS_WALLET); + return; + } + + // When there's no physical card or it exists but it doesn't have the required state for this flow, + // redirect user to the espensify card page + if (!physicalCard || physicalCard.state !== CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED) { + Navigation.goBack(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain)); + return; + } + + if (!draftValues) { + const updatedDraftValues = GetPhysicalCardUtils.getUpdatedDraftValues({}, privatePersonalDetails, loginList); + // Form draft data needs to be initialized with the private personal details + // If no draft data exists + FormActions.setDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM, updatedDraftValues); + return; + } + + // Redirect user to previous steps of the flow if he hasn't finished them yet + const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues); + GetPhysicalCardUtils.setCurrentRoute(currentRoute, domain, updatedPrivatePersonalDetails, loginList); + isRouteSet.current = true; + }, [cardList, currentRoute, domain, draftValues, loginList, privatePersonalDetails]); + + const onSubmit = useCallback(() => { + const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues); + // If the current step of the get physical card flow is the confirmation page + if (isConfirmation) { + const domainCards = CardUtils.getDomainCards(cardList)[domain]; + const virtualCard = _.find(domainCards, (card) => card.isVirtual) || {}; + const cardID = virtualCard.cardID; + Wallet.requestPhysicalExpensifyCard(cardID, authToken, updatedPrivatePersonalDetails); + // Form draft data needs to be erased when the flow is complete, + // so that no stale data is left on Onyx + FormActions.clearDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM); + Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain)); + return; + } + GetPhysicalCardUtils.goToNextPhysicalCardRoute(domain, updatedPrivatePersonalDetails, loginList); + }, [authToken, cardList, domain, draftValues, isConfirmation, loginList]); + return ( + + Navigation.goBack(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain))} + /> + {headline} + {renderContent(onSubmit, submitButtonText, children, onValidate)} + + ); +} + +BaseGetPhysicalCard.defaultProps = defaultProps; +BaseGetPhysicalCard.displayName = 'BaseGetPhysicalCard'; +BaseGetPhysicalCard.propTypes = propTypes; + +export default withOnyx({ + cardList: { + key: ONYXKEYS.CARD_LIST, + }, + loginList: { + key: ONYXKEYS.LOGIN_LIST, + }, + session: { + key: ONYXKEYS.SESSION, + }, + privatePersonalDetails: { + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + }, + draftValues: { + key: FormUtils.getDraftKey(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM), + }, +})(BaseGetPhysicalCard); diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardAddress.js b/src/pages/settings/Wallet/Card/GetPhysicalCardAddress.js new file mode 100644 index 000000000000..21ba85b6c5dd --- /dev/null +++ b/src/pages/settings/Wallet/Card/GetPhysicalCardAddress.js @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import React, {useCallback, useEffect} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import AddressForm from '@components/AddressForm'; +import useLocalize from '@hooks/useLocalize'; +import * as FormActions from '@libs/actions/FormActions'; +import FormUtils from '@libs/FormUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import BaseGetPhysicalCard from './BaseGetPhysicalCard'; + +const propTypes = { + /* Onyx Props */ + /** Draft values used by the get physical card form */ + draftValues: PropTypes.shape({ + // User home address + addressLine1: PropTypes.string, + addressLine2: PropTypes.string, + city: PropTypes.string, + country: PropTypes.string, + state: PropTypes.string, + zipPostCode: PropTypes.string, + }), + + /** Route from navigation */ + route: PropTypes.shape({ + /** Params from the route */ + params: PropTypes.shape({ + /** Currently selected country */ + country: PropTypes.string, + /** domain passed via route /settings/wallet/card/:domain */ + domain: PropTypes.string, + }), + }).isRequired, +}; + +const defaultProps = { + draftValues: { + addressLine1: '', + addressLine2: '', + city: '', + country: '', + state: '', + zipPostCode: '', + }, +}; + +function GetPhysicalCardAddress({ + draftValues: {addressLine1, addressLine2, city, state, zipPostCode, country}, + route: { + params: {country: countryFromUrl, domain}, + }, +}) { + const {translate} = useLocalize(); + + useEffect(() => { + if (!countryFromUrl) { + return; + } + FormActions.setDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM, {country: countryFromUrl}); + }, [countryFromUrl]); + + const renderContent = useCallback( + (onSubmit, submitButtonText) => ( + + ), + [addressLine1, addressLine2, city, country, state, zipPostCode], + ); + + return ( + + ); +} + +GetPhysicalCardAddress.defaultProps = defaultProps; +GetPhysicalCardAddress.displayName = 'GetPhysicalCardAddress'; +GetPhysicalCardAddress.propTypes = propTypes; + +export default withOnyx({ + draftValues: { + key: FormUtils.getDraftKey(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM), + }, +})(GetPhysicalCardAddress); diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardConfirm.js b/src/pages/settings/Wallet/Card/GetPhysicalCardConfirm.js new file mode 100644 index 000000000000..e6a11e2ba1e1 --- /dev/null +++ b/src/pages/settings/Wallet/Card/GetPhysicalCardConfirm.js @@ -0,0 +1,127 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import {withOnyx} from 'react-native-onyx'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import FormUtils from '@libs/FormUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import styles from '@styles/styles'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import BaseGetPhysicalCard from './BaseGetPhysicalCard'; + +const goToGetPhysicalCardName = (domain) => { + Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.getRoute(domain), CONST.NAVIGATION.ACTION_TYPE.PUSH); +}; + +const goToGetPhysicalCardPhone = (domain) => { + Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.getRoute(domain), CONST.NAVIGATION.ACTION_TYPE.PUSH); +}; + +const goToGetPhysicalCardAddress = (domain) => { + Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS.getRoute(domain), CONST.NAVIGATION.ACTION_TYPE.PUSH); +}; + +const propTypes = { + /* Onyx Props */ + /** Draft values used by the get physical card form */ + draftValues: PropTypes.shape({ + addressLine1: PropTypes.string, + addressLine2: PropTypes.string, + city: PropTypes.string, + state: PropTypes.string, + country: PropTypes.string, + zipPostCode: PropTypes.string, + phoneNumber: PropTypes.string, + legalFirstName: PropTypes.string, + legalLastName: PropTypes.string, + }), + + /* Navigation Props */ + /** Navigation route context info provided by react navigation */ + route: PropTypes.shape({ + params: PropTypes.shape({ + /** domain passed via route /settings/wallet/card/:domain */ + domain: PropTypes.string, + }), + }).isRequired, +}; + +const defaultProps = { + draftValues: { + addressLine1: '', + addressLine2: '', + city: '', + state: '', + country: '', + zipPostCode: '', + phoneNumber: '', + legalFirstName: '', + legalLastName: '', + }, +}; + +function GetPhysicalCardConfirm({ + draftValues: {addressLine1, addressLine2, city, state, country, zipPostCode, legalFirstName, legalLastName, phoneNumber}, + route: { + params: {domain}, + }, +}) { + const {translate} = useLocalize(); + + return ( + + {translate('getPhysicalCard.estimatedDeliveryMessage')} + goToGetPhysicalCardName(domain)} + shouldShowRightIcon + title={`${legalFirstName} ${legalLastName}`} + /> + goToGetPhysicalCardPhone(domain)} + shouldShowRightIcon + title={phoneNumber} + /> + goToGetPhysicalCardAddress(domain)} + shouldShowRightIcon + title={PersonalDetailsUtils.getFormattedAddress({ + address: { + street: PersonalDetailsUtils.getFormattedStreet(addressLine1, addressLine2), + city, + state, + zip: zipPostCode, + country, + }, + })} + /> + + ); +} + +GetPhysicalCardConfirm.defaultProps = defaultProps; +GetPhysicalCardConfirm.displayName = 'GetPhysicalCardConfirm'; +GetPhysicalCardConfirm.propTypes = propTypes; + +export default withOnyx({ + draftValues: { + key: FormUtils.getDraftKey(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM), + }, +})(GetPhysicalCardConfirm); diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardName.js b/src/pages/settings/Wallet/Card/GetPhysicalCardName.js new file mode 100644 index 000000000000..3a5399adad3a --- /dev/null +++ b/src/pages/settings/Wallet/Card/GetPhysicalCardName.js @@ -0,0 +1,108 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import TextInput from '@components/TextInput'; +import useLocalize from '@hooks/useLocalize'; +import FormUtils from '@libs/FormUtils'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import styles from '@styles/styles'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import BaseGetPhysicalCard from './BaseGetPhysicalCard'; + +const propTypes = { + /* Onyx Props */ + /** Draft values used by the get physical card form */ + draftValues: PropTypes.shape({ + legalFirstName: PropTypes.string, + legalLastName: PropTypes.string, + }), + + /** Route from navigation */ + route: PropTypes.shape({ + /** Params from the route */ + params: PropTypes.shape({ + /** domain passed via route /settings/wallet/card/:domain */ + domain: PropTypes.string, + }), + }).isRequired, +}; + +const defaultProps = { + draftValues: { + legalFirstName: '', + legalLastName: '', + }, +}; + +function GetPhysicalCardName({ + draftValues: {legalFirstName, legalLastName}, + route: { + params: {domain}, + }, +}) { + const {translate} = useLocalize(); + const onValidate = (values) => { + const errors = {}; + + if (!ValidationUtils.isValidLegalName(values.legalFirstName)) { + errors.legalFirstName = 'privatePersonalDetails.error.hasInvalidCharacter'; + } else if (_.isEmpty(values.legalFirstName)) { + errors.legalFirstName = 'common.error.fieldRequired'; + } + + if (!ValidationUtils.isValidLegalName(values.legalLastName)) { + errors.legalLastName = 'privatePersonalDetails.error.hasInvalidCharacter'; + } else if (_.isEmpty(values.legalLastName)) { + errors.legalLastName = 'common.error.fieldRequired'; + } + + return errors; + }; + + return ( + + + + + ); +} + +GetPhysicalCardName.defaultProps = defaultProps; +GetPhysicalCardName.displayName = 'GetPhysicalCardName'; +GetPhysicalCardName.propTypes = propTypes; + +export default withOnyx({ + draftValues: { + key: FormUtils.getDraftKey(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM), + }, +})(GetPhysicalCardName); diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.js b/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.js new file mode 100644 index 000000000000..9d9ae607438e --- /dev/null +++ b/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.js @@ -0,0 +1,90 @@ +import {parsePhoneNumber} from 'awesome-phonenumber'; +import Str from 'expensify-common/lib/str'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import TextInput from '@components/TextInput'; +import useLocalize from '@hooks/useLocalize'; +import FormUtils from '@libs/FormUtils'; +import styles from '@styles/styles'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import BaseGetPhysicalCard from './BaseGetPhysicalCard'; + +const propTypes = { + /* Onyx Props */ + /** Draft values used by the get physical card form */ + draftValues: PropTypes.shape({ + phoneNumber: PropTypes.string, + }), + + /** Route from navigation */ + route: PropTypes.shape({ + /** Params from the route */ + params: PropTypes.shape({ + /** domain passed via route /settings/wallet/card/:domain */ + domain: PropTypes.string, + }), + }).isRequired, +}; + +const defaultProps = { + draftValues: { + phoneNumber: '', + }, +}; + +function GetPhysicalCardPhone({ + draftValues: {phoneNumber}, + route: { + params: {domain}, + }, +}) { + const {translate} = useLocalize(); + + const onValidate = (values) => { + const errors = {}; + + if (!(parsePhoneNumber(values.phoneNumber).possible && Str.isValidPhone(values.phoneNumber))) { + errors.phoneNumber = 'common.error.phoneNumber'; + } else if (_.isEmpty(values.phoneNumber)) { + errors.phoneNumber = 'common.error.fieldRequired'; + } + + return errors; + }; + + return ( + + + + ); +} + +GetPhysicalCardPhone.defaultProps = defaultProps; +GetPhysicalCardPhone.displayName = 'GetPhysicalCardPhone'; +GetPhysicalCardPhone.propTypes = propTypes; + +export default withOnyx({ + draftValues: { + key: FormUtils.getDraftKey(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM), + }, +})(GetPhysicalCardPhone); diff --git a/src/pages/settings/Wallet/ExpensifyCardPage.js b/src/pages/settings/Wallet/ExpensifyCardPage.js index 8f1be0425622..e92fca171817 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage.js +++ b/src/pages/settings/Wallet/ExpensifyCardPage.js @@ -15,6 +15,8 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import * as CardUtils from '@libs/CardUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; +import FormUtils from '@libs/FormUtils'; +import * as GetPhysicalCardUtils from '@libs/GetPhysicalCardUtils'; import Navigation from '@libs/Navigation/Navigation'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import useTheme from '@styles/themes/useTheme'; @@ -32,6 +34,48 @@ const propTypes = { /* Onyx Props */ /** The details about the Expensify cards */ cardList: PropTypes.objectOf(assignedCardPropTypes), + /** Draft values used by the get physical card form */ + draftValues: PropTypes.shape({ + addressLine1: PropTypes.string, + addressLine2: PropTypes.string, + city: PropTypes.string, + state: PropTypes.string, + country: PropTypes.string, + zipPostCode: PropTypes.string, + phoneNumber: PropTypes.string, + legalFirstName: PropTypes.string, + legalLastName: PropTypes.string, + }), + loginList: PropTypes.shape({ + /** The partner creating the account. It depends on the source: website, mobile, integrations, ... */ + partnerName: PropTypes.string, + + /** Phone/Email associated with user */ + partnerUserID: PropTypes.string, + + /** The date when the login was validated, used to show the brickroad status */ + validatedDate: PropTypes.string, + + /** Field-specific server side errors keyed by microtime */ + errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + + /** Field-specific pending states for offline UI status */ + pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + }), + /** User's private personal details */ + privatePersonalDetails: PropTypes.shape({ + legalFirstName: PropTypes.string, + legalLastName: PropTypes.string, + phoneNumber: PropTypes.string, + /** User's home address */ + address: PropTypes.shape({ + street: PropTypes.string, + city: PropTypes.string, + state: PropTypes.string, + zip: PropTypes.string, + country: PropTypes.string, + }), + }), /** Navigation route context info provided by react navigation */ route: PropTypes.shape({ @@ -44,10 +88,37 @@ const propTypes = { const defaultProps = { cardList: {}, + draftValues: { + addressLine1: '', + addressLine2: '', + city: '', + state: '', + country: '', + zipPostCode: '', + phoneNumber: '', + legalFirstName: '', + legalLastName: '', + }, + loginList: {}, + privatePersonalDetails: { + legalFirstName: '', + legalLastName: '', + phoneNumber: null, + address: { + street: '', + city: '', + state: '', + zip: '', + country: '', + }, + }, }; function ExpensifyCardPage({ cardList, + draftValues, + loginList, + privatePersonalDetails, route: { params: {domain}, }, @@ -65,7 +136,7 @@ function ExpensifyCardPage({ const [cardDetailsError, setCardDetailsError] = useState(''); if (_.isEmpty(virtualCard) && _.isEmpty(physicalCard)) { - return ; + return Navigation.goBack(ROUTES.SETTINGS_WALLET)} />; } const formattedAvailableSpendAmount = CurrencyUtils.convertToDisplayString(physicalCard.availableSpend || virtualCard.availableSpend || 0); @@ -85,6 +156,12 @@ function ExpensifyCardPage({ .finally(() => setIsLoading(false)); }; + const goToGetPhysicalCardFlow = () => { + const updatedDraftValues = GetPhysicalCardUtils.getUpdatedDraftValues(draftValues, privatePersonalDetails, loginList); + + GetPhysicalCardUtils.goToNextPhysicalCardRoute(domain, GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(updatedDraftValues), loginList); + }; + const hasDetectedDomainFraud = _.some(domainCards, (card) => card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN); const hasDetectedIndividualFraud = _.some(domainCards, (card) => card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL); const cardDetailsErrorObject = cardDetailsError ? {error: cardDetailsError} : {}; @@ -211,6 +288,15 @@ function ExpensifyCardPage({ text={translate('activateCardPage.activatePhysicalCard')} /> )} + {physicalCard.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED && ( +