diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index c3003699378c..ec09d92ef2e0 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -299,6 +299,7 @@ const ONYXKEYS = { PRIVATE_NOTES_FORM: 'privateNotesForm', I_KNOW_A_TEACHER_FORM: 'iKnowTeacherForm', INTRO_SCHOOL_PRINCIPAL_FORM: 'introSchoolPrincipalForm', + REPORT_PHYSICAL_CARD_FORM: 'requestPhysicalCardForm', REPORT_VIRTUAL_CARD_FRAUD: 'reportVirtualCardFraudForm', }, } as const; @@ -430,6 +431,7 @@ type OnyxValues = { [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_AFTER_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: OnyxTypes.Form; }; type OnyxKeyValue = OnyxEntry; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index a677b7192fac..04201facb4ed 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -90,6 +90,10 @@ export default { }, SETTINGS_WALLET_TRANSFER_BALANCE: 'settings/wallet/transfer-balance', SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT: 'settings/wallet/choose-transfer-account', + SETTINGS_WALLET_REPORT_CARD_LOST_OR_DAMAGED: { + route: '/settings/wallet/card/:domain/report-card-lost-or-damaged', + getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-card-lost-or-damaged`, + }, SETTINGS_WALLET_CARD_ACTIVATE: { route: 'settings/wallet/card/:domain/activate', getRoute: (domain: string) => `settings/wallet/card/${domain}/activate`, diff --git a/src/components/SelectCircle.js b/src/components/SelectCircle.js index 93cf285eab59..55e410f8baa1 100644 --- a/src/components/SelectCircle.js +++ b/src/components/SelectCircle.js @@ -9,15 +9,20 @@ import themeColors from '../styles/themes/default'; const propTypes = { /** Should we show the checkmark inside the circle */ isChecked: PropTypes.bool, + + /** Additional styles to pass to SelectCircle */ + // eslint-disable-next-line react/forbid-prop-types + styles: PropTypes.arrayOf(PropTypes.object), }; const defaultProps = { isChecked: false, + styles: [], }; function SelectCircle(props) { return ( - + {props.isChecked && ( {}, +}; + +function SingleOptionSelector({options, selectedOptionKey, onSelectOption, translate}) { + return ( + + {_.map(options, (option) => ( + + onSelectOption(option)} + accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityState={{checked: selectedOptionKey === option.key}} + aria-checked={selectedOptionKey === option.key} + accessibilityLabel={option.label} + > + + {translate(option.label)} + + + ))} + + ); +} + +SingleOptionSelector.propTypes = propTypes; +SingleOptionSelector.defaultProps = defaultProps; +SingleOptionSelector.displayName = 'SingleOptionSelector'; + +export default withLocalize(SingleOptionSelector); diff --git a/src/languages/en.ts b/src/languages/en.ts index e7f71e755dd8..c7295b523010 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1874,6 +1874,20 @@ export default { selectSuggestedAddress: 'Please select a suggested address or use current location', }, }, + reportCardLostOrDamaged: { + report: 'Report physical card loss / damage', + screenTitle: 'Report card lost or damaged', + nextButtonLabel: 'Next', + reasonTitle: 'Why do you need a new card?', + cardDamaged: 'My card was damaged', + cardLostOrStolen: 'My card was lost or stolen', + confirmAddressTitle: "Please confirm the address below is where you'd like us to send your new card.", + currentCardInfo: 'Your current card will be permanently deactivated as soon as your order is placed. Most cards arrive in a few business days.', + address: 'Address', + deactivateCardButton: 'Deactivate card', + addressError: 'Address is required', + reasonError: 'Reason is required', + }, eReceipt: { guaranteed: 'Guaranteed eReceipt', transactionDate: 'Transaction date', diff --git a/src/languages/es.ts b/src/languages/es.ts index 6020ded30b92..4a24b2243e03 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2359,6 +2359,20 @@ export default { selectSuggestedAddress: 'Por favor, selecciona una dirección sugerida o usa la ubicación actual.', }, }, + reportCardLostOrDamaged: { + report: 'Notificar la pérdida / daño de la tarjeta física', + screenTitle: 'Notificar la pérdida o deterioro de la tarjeta', + nextButtonLabel: 'Siguiente', + reasonTitle: '¿Por qué necesitas una tarjeta nueva?', + cardDamaged: 'Mi tarjeta está dañada', + cardLostOrStolen: 'He perdido o me han robado la tarjeta', + confirmAddressTitle: 'Confirma que la dirección que aparece a continuación es a la que deseas que te enviemos tu nueva tarjeta.', + currentCardInfo: 'La tarjeta actual se desactivará permanentemente en cuanto se realice el pedido. La mayoría de las tarjetas llegan en unos pocos días laborables.', + address: 'Dirección', + deactivateCardButton: 'Desactivar tarjeta', + addressError: 'La dirección es obligatoria', + reasonError: 'Se requiere justificación', + }, eReceipt: { guaranteed: 'eRecibo garantizado', transactionDate: 'Fecha de transacción', diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index c8ea03cc86c0..eb9a85697137 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -4,6 +4,7 @@ import CONST from '../CONST'; import * as Localize from './Localize'; import * as OnyxTypes from '../types/onyx'; import ONYXKEYS, {OnyxValues} from '../ONYXKEYS'; +import {Card} from '../types/onyx'; let allCards: OnyxValues[typeof ONYXKEYS.CARD_LIST] = {}; Onyx.connect({ @@ -88,4 +89,13 @@ function maskCard(lastFour = ''): string { return maskedString.replace(/(.{4})/g, '$1 ').trim(); } -export {isExpensifyCard, getDomainCards, getMonthFromExpirationDateString, getYearFromExpirationDateString, maskCard, getCardDescription}; +/** + * Finds physical card in a list of cards + * + * @returns a physical card object (or undefined if none is found) + */ +function findPhysicalCard(cards: Card[]) { + return cards.find((card) => !card.isVirtual); +} + +export {isExpensifyCard, getDomainCards, getMonthFromExpirationDateString, getYearFromExpirationDateString, maskCard, getCardDescription, findPhysicalCard}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index 54c7b9b8396e..7c8403cc9534 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -169,6 +169,7 @@ const SettingsModalStackNavigator = createModalStackNavigator({ ReimbursementAccount: () => require('../../../pages/ReimbursementAccount/ReimbursementAccountPage').default, GetAssistance: () => require('../../../pages/GetAssistancePage').default, Settings_TwoFactorAuth: () => require('../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default, + Settings_ReportCardLostOrDamaged: () => require('../../../pages/settings/Wallet/ReportCardLostPage').default, KeyboardShortcuts: () => require('../../../pages/KeyboardShortcutsPage').default, }); diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 8a68ec9c0d07..c578e2097175 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -93,6 +93,10 @@ export default { path: ROUTES.SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT, exact: true, }, + Settings_ReportCardLostOrDamaged: { + path: ROUTES.SETTINGS_WALLET_REPORT_CARD_LOST_OR_DAMAGED.route, + exact: true, + }, Settings_Wallet_Card_Activate: { path: ROUTES.SETTINGS_WALLET_CARD_ACTIVATE.route, exact: true, diff --git a/src/libs/actions/Card.js b/src/libs/actions/Card.js index a060c1bc67fa..92b23e2103ee 100644 --- a/src/libs/actions/Card.js +++ b/src/libs/actions/Card.js @@ -43,6 +43,51 @@ function reportVirtualExpensifyCardFraud(cardID) { ); } +/** + * Call the API to deactivate the card and request a new one + * @param {String} cardId - id of the card that is going to be replaced + * @param {String} reason - reason for replacement ('damaged' | 'stolen') + */ +function requestReplacementExpensifyCard(cardId, reason) { + API.write( + 'RequestReplacementExpensifyCard', + { + cardId, + reason, + }, + { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, + value: { + isLoading: true, + errors: null, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, + value: { + isLoading: false, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, + value: { + isLoading: false, + }, + }, + ], + }, + ); +} + /** * Activates the physical Expensify card based on the last four digits of the card number * @@ -101,4 +146,4 @@ function clearCardListErrors(cardID) { Onyx.merge(ONYXKEYS.CARD_LIST, {[cardID]: {errors: null, isLoading: false}}); } -export {reportVirtualExpensifyCardFraud, activatePhysicalExpensifyCard, clearCardListErrors}; +export {requestReplacementExpensifyCard, activatePhysicalExpensifyCard, clearCardListErrors, reportVirtualExpensifyCardFraud}; diff --git a/src/pages/settings/Wallet/ExpensifyCardPage.js b/src/pages/settings/Wallet/ExpensifyCardPage.js index c9ee7ece8fa9..c7a178134139 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage.js +++ b/src/pages/settings/Wallet/ExpensifyCardPage.js @@ -18,6 +18,7 @@ import * as Expensicons from '../../../components/Icon/Expensicons'; import * as CardUtils from '../../../libs/CardUtils'; import Button from '../../../components/Button'; import CardDetails from './WalletPage/CardDetails'; +import MenuItem from '../../../components/MenuItem'; import CONST from '../../../CONST'; import assignedCardPropTypes from './assignedCardPropTypes'; @@ -120,12 +121,20 @@ function ExpensifyCardPage({ )} {!_.isEmpty(physicalCard) && ( - + <> + + Navigation.navigate(ROUTES.SETTINGS_WALLET_REPORT_CARD_LOST_OR_DAMAGED.getRoute(domain))} + /> + )} {physicalCard.state === CONST.EXPENSIFY_CARD.STATE.NOT_ACTIVATED && ( diff --git a/src/pages/settings/Wallet/ReportCardLostPage.js b/src/pages/settings/Wallet/ReportCardLostPage.js new file mode 100644 index 000000000000..29a588916326 --- /dev/null +++ b/src/pages/settings/Wallet/ReportCardLostPage.js @@ -0,0 +1,227 @@ +import React, {useState, useEffect} from 'react'; +import _ from 'underscore'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import ScreenWrapper from '../../../components/ScreenWrapper'; +import Navigation from '../../../libs/Navigation/Navigation'; +import ROUTES from '../../../ROUTES'; +import HeaderWithBackButton from '../../../components/HeaderWithBackButton'; +import styles from '../../../styles/styles'; +import ONYXKEYS from '../../../ONYXKEYS'; +import SingleOptionSelector from '../../../components/SingleOptionSelector'; +import useLocalize from '../../../hooks/useLocalize'; +import Text from '../../../components/Text'; +import MenuItemWithTopDescription from '../../../components/MenuItemWithTopDescription'; +import usePrivatePersonalDetails from '../../../hooks/usePrivatePersonalDetails'; +import assignedCardPropTypes from './assignedCardPropTypes'; +import * as CardUtils from '../../../libs/CardUtils'; +import * as PersonalDetailsUtils from '../../../libs/PersonalDetailsUtils'; +import NotFoundPage from '../../ErrorPage/NotFoundPage'; +import usePrevious from '../../../hooks/usePrevious'; +import * as FormActions from '../../../libs/actions/FormActions'; +import * as CardActions from '../../../libs/actions/Card'; +import FormAlertWithSubmitButton from '../../../components/FormAlertWithSubmitButton'; + +/** Options for reason selector */ +const OPTIONS = [ + { + key: 'damaged', + label: 'reportCardLostOrDamaged.cardDamaged', + }, + { + key: 'stolen', + label: 'reportCardLostOrDamaged.cardLostOrStolen', + }, +]; + +const propTypes = { + /** Onyx form data */ + formData: PropTypes.shape({ + isLoading: PropTypes.bool, + }), + /** User's private personal details */ + privatePersonalDetails: PropTypes.shape({ + /** User's home address */ + address: PropTypes.shape({ + street: PropTypes.string, + city: PropTypes.string, + state: PropTypes.string, + zip: PropTypes.string, + country: PropTypes.string, + }), + }), + /** User's cards list */ + cardList: PropTypes.objectOf(assignedCardPropTypes), + route: PropTypes.shape({ + /** Each parameter passed via the URL */ + params: PropTypes.shape({ + /** Domain string */ + domain: PropTypes.string, + }), + }).isRequired, +}; + +const defaultProps = { + formData: {}, + privatePersonalDetails: { + address: { + street: '', + street2: '', + city: '', + state: '', + zip: '', + country: '', + }, + }, + cardList: {}, +}; + +function ReportCardLostPage({ + privatePersonalDetails, + cardList, + route: { + params: {domain}, + }, + formData, +}) { + usePrivatePersonalDetails(); + + const domainCards = CardUtils.getDomainCards(cardList)[domain]; + const physicalCard = CardUtils.findPhysicalCard(domainCards); + + const {translate} = useLocalize(); + + const [reason, setReason] = useState(); + const [isReasonConfirmed, setIsReasonConfirmed] = useState(false); + const [shouldShowAddressError, setShouldShowAddressError] = useState(false); + const [shouldShowReasonError, setShouldShowReasonError] = useState(false); + + const prevIsLoading = usePrevious(formData.isLoading); + + const formattedAddress = PersonalDetailsUtils.getFormattedAddress(privatePersonalDetails); + + useEffect(() => { + if (!_.isEmpty(physicalCard.errors) || !(prevIsLoading && !formData.isLoading)) { + return; + } + + Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(domain)); + }, [domain, formData.isLoading, prevIsLoading, physicalCard.errors]); + + useEffect(() => { + if (formData.isLoading && _.isEmpty(physicalCard.errors)) { + return; + } + + FormActions.setErrors(ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, physicalCard.errors); + }, [formData.isLoading, physicalCard.errors]); + + if (_.isEmpty(physicalCard)) { + return ; + } + + const handleSubmitFirstStep = () => { + if (!reason) { + setShouldShowReasonError(true); + return; + } + + setIsReasonConfirmed(true); + setShouldShowAddressError(false); + setShouldShowReasonError(false); + }; + + const handleSubmitSecondStep = () => { + if (!formattedAddress) { + setShouldShowAddressError(true); + return; + } + + CardActions.requestReplacementExpensifyCard(physicalCard.cardID, reason); + }; + + const handleOptionSelect = (option) => { + setReason(option); + setShouldShowReasonError(false); + }; + + const handleBackButtonPress = () => { + if (isReasonConfirmed) { + setIsReasonConfirmed(false); + return; + } + + Navigation.goBack(ROUTES.SETTINGS_WALLET); + }; + + return ( + + + + {isReasonConfirmed ? ( + <> + + {translate('reportCardLostOrDamaged.confirmAddressTitle')} + Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS)} + numberOfLinesTitle={2} + /> + {translate('reportCardLostOrDamaged.currentCardInfo')} + + + + ) : ( + <> + + {translate('reportCardLostOrDamaged.reasonTitle')} + + + + + )} + + + ); +} + +ReportCardLostPage.propTypes = propTypes; +ReportCardLostPage.defaultProps = defaultProps; +ReportCardLostPage.displayName = 'ReportCardLostPage'; + +export default withOnyx({ + privatePersonalDetails: { + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + }, + cardList: { + key: ONYXKEYS.CARD_LIST, + }, + formData: { + key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM, + }, +})(ReportCardLostPage); diff --git a/src/styles/styles.ts b/src/styles/styles.ts index 67c1e1cb9589..012a74004b3a 100644 --- a/src/styles/styles.ts +++ b/src/styles/styles.ts @@ -3889,6 +3889,13 @@ const styles = (theme: ThemeDefault) => ...objectFit.oFCover, }, + singleOptionSelectorRow: { + ...flex.flexRow, + ...flex.alignItemsCenter, + gap: 12, + marginBottom: 16, + }, + globalNavigation: { width: variables.globalNavigationWidth, backgroundColor: theme.highlightBG, @@ -3960,6 +3967,10 @@ const styles = (theme: ThemeDefault) => checkboxWithLabelCheckboxStyle: { marginLeft: -2, }, + + singleOptionSelectorCircle: { + borderColor: theme.icon, + }, } satisfies Styles); // For now we need to export the styles function that takes the theme as an argument