From 0515ffbd7daa444bfd7f7f3d855d6446fb1c341d Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Tue, 26 Mar 2024 12:59:31 +0100 Subject: [PATCH 01/19] make wallet page cards grouped by domain --- src/ROUTES.ts | 4 +- src/languages/en.ts | 12 ++ src/languages/es.ts | 12 ++ src/libs/Navigation/types.ts | 7 +- .../settings/Wallet/ExpensifyCardPage.tsx | 158 ++++++++++++------ .../settings/Wallet/PaymentMethodList.tsx | 62 +++++-- src/styles/index.ts | 5 + 7 files changed, 184 insertions(+), 76 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index c216d5ac288c..2f0f5851407a 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -84,8 +84,8 @@ const ROUTES = { SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links', SETTINGS_WALLET: 'settings/wallet', SETTINGS_WALLET_DOMAINCARD: { - route: 'settings/wallet/card/:domain', - getRoute: (domain: string) => `settings/wallet/card/${domain}` as const, + route: 'settings/wallet/card/:domain/:cardId', + getRoute: (domain: string, cardId: string) => `settings/wallet/card/${domain}/${cardId}` as const, }, SETTINGS_REPORT_FRAUD: { route: 'settings/wallet/card/:domain/report-virtual-fraud', diff --git a/src/languages/en.ts b/src/languages/en.ts index c3ad6d82d6b2..d5b6d5107f3f 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1029,6 +1029,18 @@ export default { cardPage: { expensifyCard: 'Expensify Card', availableSpend: 'Remaining limit', + smartLimit: { + name: 'Smart limit', + title: (formattedLimit: string) => `You can spend up to ${formattedLimit} on this card, and the limit will reset as your submitted expenses are approved.`, + }, + fixedLimit: { + name: 'Fixed limit', + title: (formattedLimit: string) => `You can spend up to ${formattedLimit} on this card, and then it will deactivate.`, + }, + monthlyLimit: { + name: 'Monthly limit', + title: (formattedLimit: string) => `You can spend up to ${formattedLimit} on this card per month. The limit will reset on the 1st day of each calendar month.`, + }, virtualCardNumber: 'Virtual card number', physicalCardNumber: 'Physical card number', getPhysicalCard: 'Get physical card', diff --git a/src/languages/es.ts b/src/languages/es.ts index 78b80adb16d4..d28b112a5471 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1028,6 +1028,18 @@ export default { cardPage: { expensifyCard: 'Tarjeta Expensify', availableSpend: 'Límite restante', + smartLimit: { + name: 'Smart limit', + title: (formattedLimit: string) => `Puedes gastar hasta ${formattedLimit} en esta tarjeta al mes. El límite se restablecerá el primer día del mes.`, + }, + fixedLimit: { + name: 'Fixed limit', + title: (formattedLimit: string) => `Puedes gastar hasta ${formattedLimit} en esta tarjeta, luego se desactivará.`, + }, + monthlyLimit: { + name: 'Monthly limit', + title: (formattedLimit: string) => `Puedes gastar hasta ${formattedLimit} en esta tarjeta y el límite se restablecerá a medida que se aprueben tus gastos.`, + }, virtualCardNumber: 'Número de la tarjeta virtual', physicalCardNumber: 'Número de la tarjeta física', getPhysicalCard: 'Obtener tarjeta física', diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 3f85aec3a560..ae74ac795f3c 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -111,7 +111,12 @@ type SettingsNavigatorParamList = { }; [SCREENS.SETTINGS.WALLET.ROOT]: undefined; [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: undefined; - [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: undefined; + [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: { + /** domain passed via route /settings/wallet/card/:domain/:card */ + domain: string; + /** cardId passed via route /settings/wallet/card/:domain/:card */ + cardId: string; + }; [SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD]: undefined; [SCREENS.SETTINGS.WALLET.CARD_ACTIVATE]: undefined; [SCREENS.SETTINGS.WALLET.CARD_GET_PHYSICAL.NAME]: { diff --git a/src/pages/settings/Wallet/ExpensifyCardPage.tsx b/src/pages/settings/Wallet/ExpensifyCardPage.tsx index 4c8b02eabdc6..80f134fc575a 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage.tsx @@ -3,6 +3,7 @@ import React, {useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import Button from '@components/Button'; import CardPreview from '@components/CardPreview'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; @@ -20,7 +21,7 @@ import * as CardUtils from '@libs/CardUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as GetPhysicalCardUtils from '@libs/GetPhysicalCardUtils'; import Navigation from '@libs/Navigation/Navigation'; -import type {PublicScreensParamList} from '@libs/Navigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import * as Card from '@userActions/Card'; import * as Link from '@userActions/Link'; @@ -31,7 +32,6 @@ import type SCREENS from '@src/SCREENS'; import type {GetPhysicalCardForm} from '@src/types/form'; import type {LoginList, Card as OnyxCard, PrivatePersonalDetails} from '@src/types/onyx'; import type {TCardDetails} from '@src/types/onyx/Card'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import RedDotCardSection from './RedDotCardSection'; import CardDetails from './WalletPage/CardDetails'; @@ -49,7 +49,7 @@ type ExpensifyCardPageOnyxProps = { loginList: OnyxEntry; }; -type ExpensifyCardPageProps = ExpensifyCardPageOnyxProps & StackScreenProps; +type ExpensifyCardPageProps = ExpensifyCardPageOnyxProps & StackScreenProps; function ExpensifyCardPage({ cardList, @@ -57,45 +57,85 @@ function ExpensifyCardPage({ privatePersonalDetails, loginList, route: { - params: {domain = ''}, + params: {domain = '', cardId = ''}, }, }: ExpensifyCardPageProps) { const styles = useThemeStyles(); const {isOffline} = useNetwork(); const {translate} = useLocalize(); - const domainCards = useMemo(() => cardList && CardUtils.getDomainCards(cardList)[domain], [cardList, domain]); - const virtualCard = useMemo(() => domainCards?.find((card) => card.isVirtual), [domainCards]); - const physicalCard = useMemo(() => domainCards?.find((card) => !card.isVirtual), [domainCards]); + const isCardDomain = !cardList?.[cardId].isAdminIssuedVirtualCard; - const [isLoading, setIsLoading] = useState(false); const [isNotFound, setIsNotFound] = useState(false); - const [details, setDetails] = useState(); - const [cardDetailsError, setCardDetailsError] = useState(''); - + const cardsToShow = useMemo( + () => (isCardDomain ? CardUtils.getDomainCards(cardList)[domain].filter((card) => !card.isAdminIssuedVirtualCard) : [cardList?.[cardId]]), + [isCardDomain, cardList, cardId, domain], + ); useEffect(() => { - if (!cardList) { - return; - } - setIsNotFound(isEmptyObject(virtualCard) && isEmptyObject(physicalCard)); - }, [cardList, physicalCard, virtualCard]); + setIsNotFound(!cardsToShow); + }, [cardList, cardsToShow]); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- availableSpend can be 0 - const formattedAvailableSpendAmount = CurrencyUtils.convertToDisplayString(physicalCard?.availableSpend || virtualCard?.availableSpend || 0); + const virtualCards = useMemo(() => cardsToShow.filter((card) => card.isVirtual), [cardsToShow]); + const physicalCards = useMemo(() => cardsToShow.filter((card) => !card.isVirtual), [cardsToShow]); + const [cardsDetails, setCardsDetails] = useState>({}); + const [isCardDetailsLoading, setIsCardDetailsLoading] = useState>({}); + const [cardsDetailsErrors, setCardsDetailsErrors] = useState>({}); - const handleRevealDetails = () => { - setIsLoading(true); + const handleRevealDetails = (revealedCardId: number) => { + setIsCardDetailsLoading((prevState: Record) => { + const newLoadingStates = {...prevState}; + newLoadingStates[revealedCardId] = true; + return newLoadingStates; + }); // We can't store the response in Onyx for security reasons. // That is why this action is handled manually and the response is stored in a local state // Hence eslint disable here. // eslint-disable-next-line rulesdir/no-thenable-actions-in-views - Card.revealVirtualCardDetails(virtualCard?.cardID ?? 0) + Card.revealVirtualCardDetails(revealedCardId) .then((value) => { - setDetails(value as TCardDetails); - setCardDetailsError(''); + setCardsDetails((prevState: Record) => { + const newCardsDetails = {...prevState}; + newCardsDetails[revealedCardId] = value as TCardDetails; + return newCardsDetails; + }); + setCardsDetailsErrors((prevState) => { + const newCardsDetailsErrors = {...prevState}; + newCardsDetailsErrors[revealedCardId] = ''; + return newCardsDetailsErrors; + }); + }) + .catch((error) => { + setCardsDetailsErrors((prevState) => { + const newCardsDetailsErrors = {...prevState}; + newCardsDetailsErrors[revealedCardId] = error; + return newCardsDetailsErrors; + }); }) - .catch(setCardDetailsError) - .finally(() => setIsLoading(false)); + .finally(() => + setIsCardDetailsLoading((prevState: Record) => { + const newLoadingStates = {...prevState}; + newLoadingStates[revealedCardId] = false; + return newLoadingStates; + }), + ); + }; + + const hasDetectedDomainFraud = cardsToShow?.some((card) => card?.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN); + const hasDetectedIndividualFraud = cardsToShow?.some((card) => card?.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL); + + const formattedAvailableSpendAmount = CurrencyUtils.convertToDisplayString(cardsToShow?.[0]?.availableSpend); + const getLimitStrings = (limitType: ValueOf) => { + switch (limitType) { + case CONST.EXPENSIFY_CARD.LIMIT_TYPES.SMART: + return {limitName: translate('cardPage.smartLimit.name'), limitTitle: translate('cardPage.smartLimit.title', formattedAvailableSpendAmount)}; + case CONST.EXPENSIFY_CARD.LIMIT_TYPES.MONTHLY: + return {limitName: translate('cardPage.monthlyLimit.name'), limitTitle: translate('cardPage.monthlyLimit.title', formattedAvailableSpendAmount)}; + case CONST.EXPENSIFY_CARD.LIMIT_TYPES.FIXED: + return {limitName: translate('cardPage.fixedLimit.name'), limitTitle: translate('cardPage.fixedLimit.title', formattedAvailableSpendAmount)}; + default: + return {limitName: '', limitTitle: ''}; + } }; + const {limitName, limitTitle} = getLimitStrings(cardsToShow?.[0]?.limitType); const goToGetPhysicalCardFlow = () => { let updatedDraftValues = draftValues; @@ -109,9 +149,6 @@ function ExpensifyCardPage({ GetPhysicalCardUtils.goToNextPhysicalCardRoute(domain, GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(updatedDraftValues)); }; - const hasDetectedDomainFraud = domainCards?.some((card) => card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN); - const hasDetectedIndividualFraud = domainCards?.some((card) => card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL); - if (isNotFound) { return Navigation.goBack(ROUTES.SETTINGS_WALLET)} />; } @@ -165,13 +202,21 @@ function ExpensifyCardPage({ interactive={false} titleStyle={styles.newKansasLarge} /> - {!isEmptyObject(virtualCard) && ( + + + {virtualCards.map((card) => ( <> - {details?.pan ? ( + {!!cardsDetails[card.cardID] && cardsDetails[card.cardID]?.pan ? ( ) : ( @@ -186,14 +231,14 @@ function ExpensifyCardPage({