diff --git a/src/CONST.ts b/src/CONST.ts index dd048f95d374..cfeb2f197d6d 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5960,6 +5960,7 @@ const CONST = { HAS_WALLET_TERMS_ERRORS: 'hasWalletTermsErrors', HAS_LOGIN_LIST_INFO: 'hasLoginListInfo', HAS_SUBSCRIPTION_INFO: 'hasSubscriptionInfo', + HAS_PHONE_NUMBER_ERROR: 'hasPhoneNumberError', }, DEBUG: { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index c346da6cadcb..2e895537eaac 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -208,6 +208,7 @@ const ROUTES = { }, SETTINGS_LEGAL_NAME: 'settings/profile/legal-name', SETTINGS_DATE_OF_BIRTH: 'settings/profile/date-of-birth', + SETTINGS_PHONE_NUMBER: 'settings/profile/phone', SETTINGS_ADDRESS: 'settings/profile/address', SETTINGS_ADDRESS_COUNTRY: { route: 'settings/profile/address/country', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 2e44c5ed5695..feded7c81a47 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -84,6 +84,7 @@ const SCREENS = { TIMEZONE_SELECT: 'Settings_Timezone_Select', LEGAL_NAME: 'Settings_LegalName', DATE_OF_BIRTH: 'Settings_DateOfBirth', + PHONE_NUMBER: 'Settings_PhoneNumber', ADDRESS: 'Settings_Address', ADDRESS_COUNTRY: 'Settings_Address_Country', ADDRESS_STATE: 'Settings_Address_State', diff --git a/src/hooks/useIndicatorStatus.ts b/src/hooks/useIndicatorStatus.ts index b026bc52fd7b..7fcd81112a9b 100644 --- a/src/hooks/useIndicatorStatus.ts +++ b/src/hooks/useIndicatorStatus.ts @@ -28,6 +28,7 @@ function useIndicatorStatus(): IndicatorStatusResult { const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET); const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS); const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); + const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); // If a policy was just deleted from Onyx, then Onyx will pass a null value to the props, and // those should be cleaned out before doing any error checking @@ -57,6 +58,7 @@ function useIndicatorStatus(): IndicatorStatusResult { [CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_ERROR]: !!loginList && UserUtils.hasLoginListError(loginList), // Wallet term errors that are not caused by an IOU (we show the red brick indicator for those in the LHN instead) [CONST.INDICATOR_STATUS.HAS_WALLET_TERMS_ERRORS]: Object.keys(walletTerms?.errors ?? {}).length > 0 && !walletTerms?.chatReportID, + [CONST.INDICATOR_STATUS.HAS_PHONE_NUMBER_ERROR]: !!privatePersonalDetails?.errorFields?.phoneNumber ?? undefined, }; const infoChecking: Partial> = { diff --git a/src/languages/en.ts b/src/languages/en.ts index 5daecbc98e5f..3b2bde3d0fa5 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1783,6 +1783,7 @@ const translations = { dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `Date should be after ${dateString}.`, hasInvalidCharacter: 'Name can only include Latin characters.', incorrectZipFormat: ({zipFormat}: IncorrectZipFormatParams = {}) => `Incorrect zip code format.${zipFormat ? ` Acceptable format: ${zipFormat}` : ''}`, + invalidPhoneNumber: `Please ensure the phone number is valid (e.g. ${CONST.EXAMPLE_PHONE_NUMBER}).`, }, }, resendValidationForm: { diff --git a/src/languages/es.ts b/src/languages/es.ts index c38e9052bd60..00b869132b2d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1784,6 +1784,7 @@ const translations = { dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `La fecha debe ser posterior a ${dateString}.`, incorrectZipFormat: ({zipFormat}: IncorrectZipFormatParams = {}) => `Formato de código postal incorrecto.${zipFormat ? ` Formato aceptable: ${zipFormat}` : ''}`, hasInvalidCharacter: 'El nombre sólo puede incluir caracteres latinos.', + invalidPhoneNumber: `Asegúrese de que el número de teléfono sean válidos (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER}).`, }, }, resendValidationForm: { diff --git a/src/libs/API/parameters/UpdatePhoneNumberParams.ts b/src/libs/API/parameters/UpdatePhoneNumberParams.ts new file mode 100644 index 000000000000..683ecb16be13 --- /dev/null +++ b/src/libs/API/parameters/UpdatePhoneNumberParams.ts @@ -0,0 +1,5 @@ +type UpdatePhoneNumberParams = { + phoneNumber?: string; +}; + +export default UpdatePhoneNumberParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 9f07049736ed..4c12a236a43c 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -73,6 +73,7 @@ export type {default as UpdateGroupChatMemberRolesParams} from './UpdateGroupCha export type {default as UpdateHomeAddressParams} from './UpdateHomeAddressParams'; export type {default as UpdatePolicyAddressParams} from './UpdatePolicyAddressParams'; export type {default as UpdateLegalNameParams} from './UpdateLegalNameParams'; +export type {default as UpdatePhoneNumberParams} from './UpdatePhoneNumberParams'; export type {default as UpdateNewsletterSubscriptionParams} from './UpdateNewsletterSubscriptionParams'; export type {default as UpdatePersonalInformationForBankAccountParams} from './UpdatePersonalInformationForBankAccountParams'; export type {default as UpdatePreferredEmojiSkinToneParams} from './UpdatePreferredEmojiSkinToneParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 063be53a2eda..22bf33c0c22a 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -57,6 +57,7 @@ const WRITE_COMMANDS = { UPDATE_DISPLAY_NAME: 'UpdateDisplayName', UPDATE_LEGAL_NAME: 'UpdateLegalName', UPDATE_DATE_OF_BIRTH: 'UpdateDateOfBirth', + UPDATE_PHONE_NUMBER: 'UpdatePhoneNumber', UPDATE_HOME_ADDRESS: 'UpdateHomeAddress', UPDATE_POLICY_ADDRESS: 'SetPolicyAddress', UPDATE_AUTOMATIC_TIMEZONE: 'UpdateAutomaticTimezone', @@ -466,6 +467,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_DISPLAY_NAME]: Parameters.UpdateDisplayNameParams; [WRITE_COMMANDS.UPDATE_LEGAL_NAME]: Parameters.UpdateLegalNameParams; [WRITE_COMMANDS.UPDATE_DATE_OF_BIRTH]: Parameters.UpdateDateOfBirthParams; + [WRITE_COMMANDS.UPDATE_PHONE_NUMBER]: Parameters.UpdatePhoneNumberParams; [WRITE_COMMANDS.UPDATE_POLICY_ADDRESS]: Parameters.UpdatePolicyAddressParams; [WRITE_COMMANDS.UPDATE_HOME_ADDRESS]: Parameters.UpdateHomeAddressParams; [WRITE_COMMANDS.UPDATE_AUTOMATIC_TIMEZONE]: Parameters.UpdateAutomaticTimezoneParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 35f67e0253c6..8a64424c8f7d 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -217,6 +217,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Profile/TimezoneSelectPage').default, [SCREENS.SETTINGS.PROFILE.LEGAL_NAME]: () => require('../../../../pages/settings/Profile/PersonalDetails/LegalNamePage').default, [SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: () => require('../../../../pages/settings/Profile/PersonalDetails/DateOfBirthPage').default, + [SCREENS.SETTINGS.PROFILE.PHONE_NUMBER]: () => require('../../../../pages/settings/Profile/PersonalDetails/PhoneNumberPage').default, [SCREENS.SETTINGS.PROFILE.ADDRESS]: () => require('../../../../pages/settings/Profile/PersonalDetails/PersonalAddressPage').default, [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: () => require('../../../../pages/settings/Profile/PersonalDetails/CountrySelectionPage').default, [SCREENS.SETTINGS.PROFILE.ADDRESS_STATE]: () => require('../../../../pages/settings/Profile/PersonalDetails/StateSelectionPage').default, 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 574f4d26a01c..7c9f9775cd09 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -16,6 +16,7 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT, SCREENS.SETTINGS.PROFILE.LEGAL_NAME, SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH, + SCREENS.SETTINGS.PROFILE.PHONE_NUMBER, SCREENS.SETTINGS.PROFILE.ADDRESS, SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY, SCREENS.SETTINGS.SHARE_CODE, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 6f551d7cc41c..cb3840e034b5 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -264,6 +264,10 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_LEGAL_NAME, exact: true, }, + [SCREENS.SETTINGS.PROFILE.PHONE_NUMBER]: { + path: ROUTES.SETTINGS_PHONE_NUMBER, + exact: true, + }, [SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: { path: ROUTES.SETTINGS_DATE_OF_BIRTH, exact: true, diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts index 3e309fc74021..37b69d1d5d35 100644 --- a/src/libs/UserUtils.ts +++ b/src/libs/UserUtils.ts @@ -6,7 +6,7 @@ import * as defaultAvatars from '@components/Icon/DefaultAvatars'; import {ConciergeAvatar, NotificationsAvatar} from '@components/Icon/Expensicons'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Account, LoginList, Session} from '@src/types/onyx'; +import type {Account, LoginList, PrivatePersonalDetails, Session} from '@src/types/onyx'; import type Login from '@src/types/onyx/Login'; import type IconAsset from '@src/types/utils/IconAsset'; import hashCode from './hashCode'; @@ -78,6 +78,23 @@ function getLoginListBrickRoadIndicator(loginList: OnyxEntry): LoginL if (hasLoginListInfo(loginList)) { return CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; } + + return undefined; +} + +/** + * Gets the appropriate brick road indicator status for the Profile section. + * Error status is higher priority, so we check for that first. + */ +function getProfilePageBrickRoadIndicator(loginList: OnyxEntry, privatePersonalDetails: OnyxEntry): LoginListIndicator { + const hasPhoneNumberError = !!privatePersonalDetails?.errorFields?.phoneNumber; + if (hasLoginListError(loginList) || hasPhoneNumberError) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + } + if (hasLoginListInfo(loginList)) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; + } + return undefined; } @@ -240,6 +257,7 @@ export { getDefaultAvatarURL, getFullSizeAvatar, getLoginListBrickRoadIndicator, + getProfilePageBrickRoadIndicator, getSecondaryPhoneLogin, getSmallSizeAvatar, hasLoginListError, diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index 4d8041b07bc9..f759decda812 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -10,6 +10,7 @@ import type { UpdateDisplayNameParams, UpdateHomeAddressParams, UpdateLegalNameParams, + UpdatePhoneNumberParams, UpdatePronounsParams, UpdateSelectedTimezoneParams, UpdateUserAvatarParams, @@ -17,6 +18,7 @@ import type { import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; import DateUtils from '@libs/DateUtils'; +import * as ErrorUtils from '@libs/ErrorUtils'; import * as LoginUtils from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; @@ -157,6 +159,41 @@ function updateDateOfBirth({dob}: DateOfBirthForm) { Navigation.goBack(); } +function updatePhoneNumber(phoneNumber: string, currenPhoneNumber: string) { + const parameters: UpdatePhoneNumberParams = {phoneNumber}; + API.write(WRITE_COMMANDS.UPDATE_PHONE_NUMBER, parameters, { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + value: { + phoneNumber, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + value: { + phoneNumber: currenPhoneNumber, + errorFields: { + phoneNumber: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('privatePersonalDetails.error.invalidPhoneNumber'), + }, + }, + }, + ], + }); +} + +function clearPhoneNumberError() { + Onyx.merge(ONYXKEYS.PRIVATE_PERSONAL_DETAILS, { + errorFields: { + phoneNumber: null, + }, + }); +} + function updateAddress(street: string, street2: string, city: string, state: string, zip: string, country: Country | '') { const parameters: UpdateHomeAddressParams = { homeAddressStreet: street, @@ -480,6 +517,8 @@ export { setDisplayName, updateDisplayName, updateLegalName, + updatePhoneNumber, + clearPhoneNumberError, updatePronouns, updateSelectedTimezone, updatePersonalDetailsAndShipExpensifyCards, diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index d4455b54ffcb..40d2c2ed4174 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -80,6 +80,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS); const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); const network = useNetwork(); const theme = useTheme(); @@ -126,7 +127,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr * @returns object with translationKey, style and items for the account section */ const accountMenuItemsData: Menu = useMemo(() => { - const profileBrickRoadIndicator = UserUtils.getLoginListBrickRoadIndicator(loginList); + const profileBrickRoadIndicator = UserUtils.getProfilePageBrickRoadIndicator(loginList, privatePersonalDetails); const paymentCardList = fundList; const defaultMenu: Menu = { sectionStyle: styles.accountSettingsSectionContainer, @@ -161,7 +162,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr }; return defaultMenu; - }, [loginList, fundList, styles.accountSettingsSectionContainer, bankAccountList, userWallet?.errors, walletTerms?.errors]); + }, [loginList, fundList, styles.accountSettingsSectionContainer, bankAccountList, userWallet?.errors, walletTerms?.errors, privatePersonalDetails]); /** * Retuns a list of menu items data for workspace section diff --git a/src/pages/settings/Profile/PersonalDetails/PhoneNumberPage.tsx b/src/pages/settings/Profile/PersonalDetails/PhoneNumberPage.tsx new file mode 100644 index 000000000000..12c6f011c6a2 --- /dev/null +++ b/src/pages/settings/Profile/PersonalDetails/PhoneNumberPage.tsx @@ -0,0 +1,121 @@ +import {Str} from 'expensify-common'; +import React, {useCallback} from 'react'; +import {useOnyx} 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 FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TextInput from '@components/TextInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import * as LoginUtils from '@libs/LoginUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as PhoneNumberUtils from '@libs/PhoneNumber'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import * as PersonalDetails from '@userActions/PersonalDetails'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; +import type {PrivatePersonalDetails} from '@src/types/onyx'; + +function PhoneNumberPage() { + const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {initialValue: true}); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const phoneNumber = privatePersonalDetails?.phoneNumber ?? ''; + + const validateLoginError = ErrorUtils.getEarliestErrorField(privatePersonalDetails, 'phoneNumber'); + const currenPhoneNumber = privatePersonalDetails?.phoneNumber ?? ''; + + const updatePhoneNumber = (values: PrivatePersonalDetails) => { + // Clear the error when the user tries to submit the form + if (validateLoginError) { + PersonalDetails.clearPhoneNumberError(); + } + + // Only call the API if the user has changed their phone number + if (phoneNumber !== values?.phoneNumber) { + PersonalDetails.updatePhoneNumber(values?.phoneNumber ?? '', currenPhoneNumber); + } + + Navigation.goBack(); + }; + + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.PHONE_NUMBER])) { + errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.fieldRequired'); + } + const phoneNumberWithCountryCode = LoginUtils.appendCountryCode(values[INPUT_IDS.PHONE_NUMBER]); + const parsedPhoneNumber = PhoneNumberUtils.parsePhoneNumber(phoneNumberWithCountryCode); + if (!parsedPhoneNumber.possible || !Str.isValidE164Phone(phoneNumberWithCountryCode.slice(0))) { + errors[INPUT_IDS.PHONE_NUMBER] = translate('bankAccount.error.phoneNumber'); + } + + // Clear the error when the user tries to validate the form and there are errors + if (validateLoginError && !!errors) { + PersonalDetails.clearPhoneNumberError(); + } + return errors; + }, + [translate, validateLoginError], + ); + + return ( + + Navigation.goBack()} + /> + {isLoadingApp ? ( + + ) : ( + + PersonalDetails.clearPhoneNumberError()} + > + { + if (!validateLoginError) { + return; + } + PersonalDetails.clearPhoneNumberError(); + }} + /> + + + )} + + ); +} + +PhoneNumberPage.displayName = 'PhoneNumberPage'; + +export default PhoneNumberPage; diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index f42be1385ecf..faa399598d05 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -96,6 +96,12 @@ function ProfilePage() { title: privateDetails.dob ?? '', pageRoute: ROUTES.SETTINGS_DATE_OF_BIRTH, }, + { + description: translate('common.phoneNumber'), + title: privateDetails.phoneNumber ?? '', + pageRoute: ROUTES.SETTINGS_PHONE_NUMBER, + brickRoadIndicator: privatePersonalDetails?.errorFields?.phoneNumber ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + }, { description: translate('privatePersonalDetails.address'), title: PersonalDetailsUtils.getFormattedAddress(privateDetails), @@ -195,6 +201,7 @@ function ProfilePage() { description={detail.description} wrapperStyle={styles.sectionMenuItemTopDescription} onPress={() => Navigation.navigate(detail.pageRoute)} + brickRoadIndicator={detail.brickRoadIndicator} /> ))} diff --git a/src/types/onyx/PrivatePersonalDetails.ts b/src/types/onyx/PrivatePersonalDetails.ts index 1635c610badf..1d88cd3af1ff 100644 --- a/src/types/onyx/PrivatePersonalDetails.ts +++ b/src/types/onyx/PrivatePersonalDetails.ts @@ -1,4 +1,5 @@ import type {Country} from '@src/CONST'; +import type * as OnyxCommon from './OnyxCommon'; /** User address data */ type Address = { @@ -64,6 +65,9 @@ type PrivatePersonalDetails = { /** User's home address history. The most recent address is the last item in the array */ addresses?: Address[]; + + /** Error objects keyed by field name containing errors keyed by microtime */ + errorFields?: OnyxCommon.ErrorFields; }; export default PrivatePersonalDetails;