Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1649,6 +1649,7 @@ const translations = {
},
cardPage: {
expensifyCard: 'Expensify Card',
expensifyTravelCard: 'Expensify Travel Card',
availableSpend: 'Remaining limit',
smartLimit: {
name: 'Smart limit',
Expand All @@ -1664,9 +1665,11 @@ const translations = {
`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',
travelCardCvv: 'Travel card CVV',
physicalCardNumber: 'Physical card number',
getPhysicalCard: 'Get physical card',
reportFraud: 'Report virtual card fraud',
reportTravelFraud: 'Report travel card fraud',
reviewTransaction: 'Review transaction',
suspiciousBannerTitle: 'Suspicious transaction',
suspiciousBannerDescription: 'We noticed suspicious transactions on your card. Tap below to review.',
Expand All @@ -1677,6 +1680,7 @@ const translations = {
cvv: 'CVV',
address: 'Address',
revealDetails: 'Reveal details',
revealCvv: 'Reveal CVV',
copyCardNumber: 'Copy card number',
updateAddress: 'Update address',
},
Expand Down
4 changes: 4 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1649,6 +1649,7 @@ const translations = {
},
cardPage: {
expensifyCard: 'Tarjeta Expensify',
expensifyTravelCard: 'Tarjeta Expensify de Viaje',
availableSpend: 'Límite restante',
smartLimit: {
name: 'Límite inteligente',
Expand All @@ -1663,9 +1664,11 @@ const translations = {
title: ({formattedLimit}: ViolationsOverLimitParams) => `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',
travelCardCvv: 'CVV de la tarjeta de viaje',
physicalCardNumber: 'Número de la tarjeta física',
getPhysicalCard: 'Obtener tarjeta física',
reportFraud: 'Reportar fraude con la tarjeta virtual',
reportTravelFraud: 'Reportar fraude con la tarjeta de viaje',
reviewTransaction: 'Revisar transacción',
suspiciousBannerTitle: 'Transacción sospechosa',
suspiciousBannerDescription: 'Hemos detectado una transacción sospechosa en la tarjeta. Haz click abajo para revisarla.',
Expand All @@ -1676,6 +1679,7 @@ const translations = {
cvv: 'CVV',
address: 'Dirección',
revealDetails: 'Revelar detalles',
revealCvv: 'Revelar CVV',
copyCardNumber: 'Copiar número de la tarjeta',
updateAddress: 'Actualizar dirección',
},
Expand Down
54 changes: 51 additions & 3 deletions src/pages/settings/Wallet/ExpensifyCardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,11 @@ function ExpensifyCardPage({
const {translate} = useLocalize();
const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(false);
const [currentCardID, setCurrentCardID] = useState<number>(-1);
const shouldDisplayCardDomain = !cardList?.[cardID]?.nameValuePairs?.issuedBy || !cardList?.[cardID]?.nameValuePairs?.isVirtual;
const isTravelCard = cardList?.[cardID]?.nameValuePairs?.isTravelCard;
const shouldDisplayCardDomain = !isTravelCard && (!cardList?.[cardID]?.nameValuePairs?.issuedBy || !cardList?.[cardID]?.nameValuePairs?.isVirtual);
const domain = cardList?.[cardID]?.domainName ?? '';
const pageTitle = shouldDisplayCardDomain ? translate('cardPage.expensifyCard') : cardList?.[cardID]?.nameValuePairs?.cardTitle ?? translate('cardPage.expensifyCard');
const expensifyCardTitle = isTravelCard ? translate('cardPage.expensifyTravelCard') : translate('cardPage.expensifyCard');
const pageTitle = shouldDisplayCardDomain ? expensifyCardTitle : cardList?.[cardID]?.nameValuePairs?.cardTitle ?? expensifyCardTitle;

const [isNotFound, setIsNotFound] = useState(false);
const cardsToShow = useMemo(() => {
Expand All @@ -89,7 +91,8 @@ function ExpensifyCardPage({
setIsNotFound(!cardsToShow);
}, [cardList, cardsToShow]);

const virtualCards = useMemo(() => cardsToShow?.filter((card) => card?.nameValuePairs?.isVirtual), [cardsToShow]);
const virtualCards = useMemo(() => cardsToShow?.filter((card) => card?.nameValuePairs?.isVirtual && !card?.nameValuePairs?.isTravelCard), [cardsToShow]);
const travelCards = useMemo(() => cardsToShow?.filter((card) => card?.nameValuePairs?.isVirtual && card?.nameValuePairs?.isTravelCard), [cardsToShow]);
const physicalCards = useMemo(() => cardsToShow?.filter((card) => !card?.nameValuePairs?.isVirtual), [cardsToShow]);
const [cardsDetails, setCardsDetails] = useState<Record<number, ExpensifyCardDetails | null>>({});
const [isCardDetailsLoading, setIsCardDetailsLoading] = useState<Record<number, boolean>>({});
Expand Down Expand Up @@ -241,6 +244,51 @@ function ExpensifyCardPage({
)}
</>
))}
{isTravelCard &&
travelCards.map((card) => (
<>
{!!cardsDetails[card.cardID] && cardsDetails[card.cardID]?.cvv ? (
<CardDetails
cvv={cardsDetails[card.cardID]?.cvv}
domain={domain}
/>
) : (
<>
<MenuItemWithTopDescription
description={translate('cardPage.travelCardCvv')}
title="•••"
interactive={false}
titleStyle={styles.walletCardNumber}
shouldShowRightComponent
rightComponent={
!isSignedInAsdelegate ? (
<Button
text={translate('cardPage.cardDetails.revealCvv')}
onPress={() => openValidateCodeModal(card.cardID)}
isDisabled={isCardDetailsLoading[card.cardID] || isOffline}
isLoading={isCardDetailsLoading[card.cardID]}
/>
) : undefined
}
/>
<DotIndicatorMessage
messages={cardsDetailsErrors[card.cardID] ? {error: translate(cardsDetailsErrors[card.cardID] as TranslationPaths)} : {}}
type="error"
style={[styles.ph5]}
/>
</>
)}
{!isSignedInAsdelegate && (
<MenuItemWithTopDescription
title={translate('cardPage.reportTravelFraud')}
titleStyle={styles.walletCardMenuItem}
icon={Expensicons.Flag}
shouldShowRightIcon
onPress={() => Navigation.navigate(ROUTES.SETTINGS_REPORT_FRAUD.getRoute(String(card.cardID)))}
/>
)}
</>
))}
{physicalCards.map((card) => {
if (card.state !== CONST.EXPENSIFY_CARD.STATE.OPEN) {
return null;
Expand Down
17 changes: 10 additions & 7 deletions src/pages/settings/Wallet/PaymentMethodList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,10 @@ function PaymentMethodList({
}

const isAdminIssuedVirtualCard = !!card?.nameValuePairs?.issuedBy && !!card?.nameValuePairs?.isVirtual;
const isTravelCard = !!card?.nameValuePairs?.isVirtual && !!card?.nameValuePairs?.isTravelCard;

// The card should be grouped to a specific domain and such domain already exists in a assignedCardsGrouped
if (assignedCardsGrouped.some((item) => item.isGroupedCardDomain && item.description === card.domainName) && !isAdminIssuedVirtualCard) {
if (assignedCardsGrouped.some((item) => item.isGroupedCardDomain && item.description === card.domainName) && !isAdminIssuedVirtualCard && !isTravelCard) {
const domainGroupIndex = assignedCardsGrouped.findIndex((item) => item.isGroupedCardDomain && item.description === card.domainName);
const assignedCardsGroupedItem = assignedCardsGrouped.at(domainGroupIndex);
if (domainGroupIndex >= 0 && assignedCardsGroupedItem) {
Expand All @@ -278,17 +279,18 @@ function PaymentMethodList({
}

// The card shouldn't be grouped or it's domain group doesn't exist yet
const cardDescription =
card?.nameValuePairs?.issuedBy && card?.lastFourPAN
? `${card?.lastFourPAN} ${CONST.DOT_SEPARATOR} ${getDescriptionForPolicyDomainCard(card.domainName)}`
: getDescriptionForPolicyDomainCard(card.domainName);
assignedCardsGrouped.push({
key: card.cardID.toString(),
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
title: card?.nameValuePairs?.cardTitle || card.bank,
description:
card?.nameValuePairs?.issuedBy && card?.lastFourPAN
? `${card?.lastFourPAN} ${CONST.DOT_SEPARATOR} ${getDescriptionForPolicyDomainCard(card.domainName)}`
: getDescriptionForPolicyDomainCard(card.domainName),
title: isTravelCard ? translate('cardPage.expensifyTravelCard') : card?.nameValuePairs?.cardTitle || card.bank,
description: isTravelCard ? translate('cardPage.expensifyTravelCard') : cardDescription,
onPress: () => Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(String(card.cardID))),
cardID: card.cardID,
isGroupedCardDomain: !isAdminIssuedVirtualCard,
isGroupedCardDomain: !isAdminIssuedVirtualCard && !isTravelCard,
shouldShowRightIcon: true,
interactive: !isDisabled,
disabled: isDisabled,
Expand Down Expand Up @@ -368,6 +370,7 @@ function PaymentMethodList({
isLoadingBankAccountList,
isLoadingCardList,
illustrations,
translate,
]);

/**
Expand Down
111 changes: 56 additions & 55 deletions src/pages/settings/Wallet/WalletPage/CardDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from 'react';
import {View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import {useOnyx} from 'react-native-onyx';
import * as Expensicons from '@components/Icon/Expensicons';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import PressableWithDelayToggle from '@components/Pressable/PressableWithDelayToggle';
Expand All @@ -10,7 +9,7 @@ import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Clipboard from '@libs/Clipboard';
import Navigation from '@libs/Navigation/Navigation';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import {getFormattedAddress} from '@libs/PersonalDetailsUtils';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {PrivatePersonalDetails} from '@src/types/onyx';
Expand All @@ -27,12 +26,7 @@ const defaultPrivatePersonalDetails: PrivatePersonalDetails = {
],
};

type CardDetailsOnyxProps = {
/** User's private personal details */
privatePersonalDetails: OnyxEntry<PrivatePersonalDetails>;
};

type CardDetailsProps = CardDetailsOnyxProps & {
type CardDetailsProps = {
/** Card number */
pan?: string;

Expand All @@ -46,64 +40,71 @@ type CardDetailsProps = CardDetailsOnyxProps & {
domain: string;
};

function CardDetails({pan = '', expiration = '', cvv = '', privatePersonalDetails, domain}: CardDetailsProps) {
function CardDetails({pan = '', expiration = '', cvv = '', domain}: CardDetailsProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [privatePersonalDetails] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS);

const handleCopyToClipboard = () => {
Clipboard.setString(pan);
};

return (
<>
<MenuItemWithTopDescription
description={translate('cardPage.cardDetails.cardNumber')}
title={pan}
shouldShowRightComponent
rightComponent={
<View style={styles.justifyContentCenter}>
<PressableWithDelayToggle
tooltipText={translate('reportActionContextMenu.copyToClipboard')}
tooltipTextChecked={translate('reportActionContextMenu.copied')}
icon={Expensicons.Copy}
onPress={handleCopyToClipboard}
accessible={false}
text=""
/>
</View>
}
interactive={false}
/>
<MenuItemWithTopDescription
description={translate('cardPage.cardDetails.expiration')}
title={expiration}
interactive={false}
/>
<MenuItemWithTopDescription
description={translate('cardPage.cardDetails.cvv')}
title={cvv}
interactive={false}
/>
<MenuItemWithTopDescription
description={translate('cardPage.cardDetails.address')}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
title={PersonalDetailsUtils.getFormattedAddress(privatePersonalDetails || defaultPrivatePersonalDetails)}
interactive={false}
/>
<TextLink
style={[styles.link, styles.mh5, styles.mb3]}
onPress={() => Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS.getRoute(domain))}
>
{translate('cardPage.cardDetails.updateAddress')}
</TextLink>
{pan?.length > 0 && (
<MenuItemWithTopDescription
description={translate('cardPage.cardDetails.cardNumber')}
title={pan}
shouldShowRightComponent
rightComponent={
<View style={styles.justifyContentCenter}>
<PressableWithDelayToggle
tooltipText={translate('reportActionContextMenu.copyToClipboard')}
tooltipTextChecked={translate('reportActionContextMenu.copied')}
icon={Expensicons.Copy}
onPress={handleCopyToClipboard}
accessible={false}
text=""
/>
</View>
}
interactive={false}
/>
)}
{expiration?.length > 0 && (
<MenuItemWithTopDescription
description={translate('cardPage.cardDetails.expiration')}
title={expiration}
interactive={false}
/>
)}
{cvv?.length > 0 && (
<MenuItemWithTopDescription
description={translate('cardPage.cardDetails.cvv')}
title={cvv}
interactive={false}
/>
)}
{pan?.length > 0 && (
<>
<MenuItemWithTopDescription
description={translate('cardPage.cardDetails.address')}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
title={getFormattedAddress(privatePersonalDetails || defaultPrivatePersonalDetails)}
interactive={false}
/>
<TextLink
style={[styles.link, styles.mh5, styles.mb3]}
onPress={() => Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS.getRoute(domain))}
>
{translate('cardPage.cardDetails.updateAddress')}
</TextLink>
</>
)}
</>
);
}

CardDetails.displayName = 'CardDetails';

export default withOnyx<CardDetailsProps, CardDetailsOnyxProps>({
privatePersonalDetails: {
key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
},
})(CardDetails);
export default CardDetails;
3 changes: 3 additions & 0 deletions src/types/onyx/Card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ type Card = OnyxCommon.OnyxValueWithOfflineFeedback<{
/** Is a virtual card */
isVirtual?: boolean;

/** Is a travel card */
isTravelCard?: boolean;

/** Previous card state */
previousState?: number;

Expand Down