Skip to content

Commit

Permalink
Merge pull request #26778 from JKobrynski/addRevealDetails
Browse files Browse the repository at this point in the history
Add “Reveal details” for the digital card
  • Loading branch information
grgia authored Oct 4, 2023
2 parents 538e272 + 3a82c34 commit fb9c763
Show file tree
Hide file tree
Showing 8 changed files with 216 additions and 36 deletions.
21 changes: 18 additions & 3 deletions src/components/MenuItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import _ from 'underscore';
import React, {useEffect, useMemo} from 'react';
import {View} from 'react-native';
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
import PressableWithFeedback from './Pressable/PressableWithFeedback';
import Text from './Text';
import styles from '../styles/styles';
import themeColors from '../styles/themes/default';
Expand Down Expand Up @@ -49,6 +50,8 @@ const defaultProps = {
iconHeight: undefined,
description: undefined,
iconRight: Expensicons.ArrowRight,
onIconRightPress: undefined,
iconRightAccessibilityLabel: undefined,
iconStyles: [],
iconFill: undefined,
secondaryIconFill: undefined,
Expand Down Expand Up @@ -77,6 +80,8 @@ const defaultProps = {
shouldGreyOutWhenDisabled: true,
error: '',
shouldRenderAsHTML: false,
rightComponent: undefined,
shouldShowRightComponent: false,
};

const MenuItem = React.forwardRef((props, ref) => {
Expand Down Expand Up @@ -131,6 +136,8 @@ const MenuItem = React.forwardRef((props, ref) => {
return '';
}, [props.title, props.shouldRenderAsHTML, props.shouldParseTitle, html]);

const hasPressableRightComponent = props.onIconRightPress || props.iconRight || (props.rightComponent && props.shouldShowRightComponent);

return (
<Hoverable>
{(isHovered) => (
Expand Down Expand Up @@ -300,7 +307,7 @@ const MenuItem = React.forwardRef((props, ref) => {
</View>
</View>
</View>
<View style={[styles.flexRow, styles.menuItemTextContainer, styles.pointerEventsNone]}>
<View style={[styles.flexRow, styles.menuItemTextContainer, !hasPressableRightComponent && styles.pointerEventsNone]}>
{Boolean(props.badgeText) && (
<Badge
text={props.badgeText}
Expand Down Expand Up @@ -338,13 +345,21 @@ const MenuItem = React.forwardRef((props, ref) => {
</View>
)}
{Boolean(props.shouldShowRightIcon) && (
<View style={[styles.popoverMenuIcon, styles.pointerEventsAuto, props.disabled && styles.cursorDisabled]}>
<PressableWithFeedback
wrapperStyle={[styles.popoverMenuIcon, styles.pointerEventsAuto, props.disabled && styles.cursorDisabled]}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
accessibilityLabel={props.iconRightAccessibilityLabel ? props.iconRightAccessibilityLabel : ''}
accessible={!props.onIconRightPress}
disabled={!props.onIconRightPress}
onPress={props.onIconRightPress}
>
<Icon
src={props.iconRight}
fill={StyleUtils.getIconFillColor(getButtonState(props.focused || isHovered, pressed, props.success, props.disabled, props.interactive))}
/>
</View>
</PressableWithFeedback>
)}
{props.shouldShowRightComponent && props.rightComponent}
{props.shouldShowSelectedState && <SelectCircle isChecked={props.isSelected} />}
</View>
</>
Expand Down
12 changes: 12 additions & 0 deletions src/components/menuItemPropTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ const propTypes = {
/** Overrides the icon for shouldShowRightIcon */
iconRight: PropTypes.elementType,

/** Function to fire when the right icon has been pressed */
onIconRightPress: PropTypes.func,

/** accessibilityLabel for the right icon when it's pressable */
iconRightAccessibilityLabel: PropTypes.string,

/** A description text to show under the title */
description: PropTypes.string,

Expand Down Expand Up @@ -147,6 +153,12 @@ const propTypes = {

/** Should render the content in HTML format */
shouldRenderAsHTML: PropTypes.bool,

/** Component to be displayed on the right */
rightComponent: PropTypes.node,

/** Should render component on the right */
shouldShowRightComponent: PropTypes.bool,
};

export default propTypes;
8 changes: 8 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -822,6 +822,14 @@ export default {
availableSpend: 'Remaining spending power',
virtualCardNumber: 'Virtual card number',
physicalCardNumber: 'Physical card number',
cardDetails: {
cardNumber: 'Virtual card number',
expiration: 'Expiration',
cvv: 'CVV',
address: 'Address',
revealDetails: 'Reveal details',
copyCardNumber: 'Copy card number',
},
},
transferAmountPage: {
transfer: ({amount}: TransferParams) => `Transfer${amount ? ` ${amount}` : ''}`,
Expand Down
8 changes: 8 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,14 @@ export default {
availableSpend: 'Capacidad de gasto restante',
virtualCardNumber: 'Número de la tarjeta virtual',
physicalCardNumber: 'Número de la tarjeta física',
cardDetails: {
cardNumber: 'Número de tarjeta virtual',
expiration: 'Expiración',
cvv: 'CVV',
address: 'Dirección',
revealDetails: 'Revelar detalles',
copyCardNumber: 'Copiar número de la tarjeta',
},
},
transferAmountPage: {
transfer: ({amount}: TransferParams) => `Transferir${amount ? ` ${amount}` : ''}`,
Expand Down
27 changes: 26 additions & 1 deletion src/libs/PersonalDetailsUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,29 @@ function getNewPersonalDetailsOnyxData(logins, accountIDs) {
};
}

export {getDisplayNameOrDefault, getPersonalDetailsByIDs, getAccountIDsByLogins, getLoginsByAccountIDs, getNewPersonalDetailsOnyxData};
/**
* Applies common formatting to each piece of an address
*
* @param {String} piece - address piece to format
* @returns {String} - formatted piece
*/
function formatPiece(piece) {
return piece ? `${piece}, ` : '';
}

/**
* Formats an address object into an easily readable string
*
* @param {OnyxTypes.PrivatePersonalDetails} privatePersonalDetails - details object
* @returns {String} - formatted address
*/
function getFormattedAddress(privatePersonalDetails) {
const {address} = privatePersonalDetails;
const [street1, street2] = (address.street || '').split('\n');
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};
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import ONYXKEYS from '../../../../ONYXKEYS';
import {withNetwork} from '../../../../components/OnyxProvider';
import usePrivatePersonalDetails from '../../../../hooks/usePrivatePersonalDetails';
import FullscreenLoadingIndicator from '../../../../components/FullscreenLoadingIndicator';
import * as PersonalDetailsUtils from '../../../../libs/PersonalDetailsUtils';

const propTypes = {
/* Onyx Props */
Expand Down Expand Up @@ -58,32 +59,9 @@ const defaultProps = {
function PersonalDetailsInitialPage(props) {
usePrivatePersonalDetails();
const privateDetails = props.privatePersonalDetails || {};
const address = privateDetails.address || {};
const legalName = `${privateDetails.legalFirstName || ''} ${privateDetails.legalLastName || ''}`.trim();
const isLoadingPersonalDetails = lodashGet(props.privatePersonalDetails, 'isLoading', true);

/**
* Applies common formatting to each piece of an address
*
* @param {String} piece
* @returns {String}
*/
const formatPiece = (piece) => (piece ? `${piece}, ` : '');

/**
* Formats an address object into an easily readable string
*
* @returns {String}
*/
const getFormattedAddress = () => {
const [street1, street2] = (address.street || '').split('\n');
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(/,$/, '');
};

return (
<ScreenWrapper testID={PersonalDetailsInitialPage.displayName}>
<HeaderWithBackButton
Expand Down Expand Up @@ -112,7 +90,7 @@ function PersonalDetailsInitialPage(props) {
titleStyle={[styles.flex1]}
/>
<MenuItemWithTopDescription
title={getFormattedAddress()}
title={PersonalDetailsUtils.getFormattedAddress(props.privatePersonalDetails)}
description={props.translate('privatePersonalDetails.homeAddress')}
shouldShowRightIcon
onPress={() => Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS)}
Expand Down
43 changes: 35 additions & 8 deletions src/pages/settings/Wallet/ExpensifyCardPage.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import React from 'react';
import React, {useState} from 'react';
import {ScrollView, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
Expand All @@ -16,6 +16,8 @@ import * as CurrencyUtils from '../../../libs/CurrencyUtils';
import Navigation from '../../../libs/Navigation/Navigation';
import styles from '../../../styles/styles';
import * as CardUtils from '../../../libs/CardUtils';
import Button from '../../../components/Button';
import CardDetails from './WalletPage/CardDetails';

const propTypes = {
/* Onyx Props */
Expand Down Expand Up @@ -45,12 +47,18 @@ function ExpensifyCardPage({
const virtualCard = _.find(domainCards, (card) => card.isVirtual) || {};
const physicalCard = _.find(domainCards, (card) => !card.isVirtual) || {};

const [shouldShowCardDetails, setShouldShowCardDetails] = useState(false);

if (_.isEmpty(virtualCard) && _.isEmpty(physicalCard)) {
return <NotFoundPage />;
}

const formattedAvailableSpendAmount = CurrencyUtils.convertToDisplayString(physicalCard.availableSpend || virtualCard.availableSpend || 0);

const handleRevealDetails = () => {
setShouldShowCardDetails(true);
};

return (
<ScreenWrapper
includeSafeAreaPaddingBottom={false}
Expand All @@ -73,13 +81,32 @@ function ExpensifyCardPage({
interactive={false}
titleStyle={styles.newKansasLarge}
/>
{!_.isEmpty(physicalCard) && (
<MenuItemWithTopDescription
description={translate('cardPage.virtualCardNumber')}
title={CardUtils.maskCard(virtualCard.lastFourPAN)}
interactive={false}
titleStyle={styles.walletCardNumber}
/>
{!_.isEmpty(virtualCard) && (
<>
{shouldShowCardDetails ? (
<CardDetails
// This is just a temporary mock, it will be replaced in this issue https://github.com/orgs/Expensify/projects/58?pane=issue&itemId=33286617
pan="1234123412341234"
expiration="11/02/2024"
cvv="321"
/>
) : (
<MenuItemWithTopDescription
description={translate('cardPage.virtualCardNumber')}
title={CardUtils.maskCard(virtualCard.lastFourPAN)}
interactive={false}
titleStyle={styles.walletCardNumber}
shouldShowRightComponent
rightComponent={
<Button
medium
text={translate('cardPage.cardDetails.revealDetails')}
onPress={handleRevealDetails}
/>
}
/>
)}
</>
)}
{!_.isEmpty(physicalCard) && (
<MenuItemWithTopDescription
Expand Down
107 changes: 107 additions & 0 deletions src/pages/settings/Wallet/WalletPage/CardDetails.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import React from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import * as Expensicons from '../../../../components/Icon/Expensicons';
import MenuItemWithTopDescription from '../../../../components/MenuItemWithTopDescription';
import Clipboard from '../../../../libs/Clipboard';
import useLocalize from '../../../../hooks/useLocalize';
import usePrivatePersonalDetails from '../../../../hooks/usePrivatePersonalDetails';
import ONYXKEYS from '../../../../ONYXKEYS';
import * as PersonalDetailsUtils from '../../../../libs/PersonalDetailsUtils';
import PressableWithDelayToggle from '../../../../components/Pressable/PressableWithDelayToggle';
import styles from '../../../../styles/styles';

const propTypes = {
/** Card number */
pan: PropTypes.string,

/** Card expiration date */
expiration: PropTypes.string,

/** 3 digit code */
cvv: PropTypes.string,

/** 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,
}),
}),
};

const defaultProps = {
pan: '',
expiration: '',
cvv: '',
privatePersonalDetails: {
address: {
street: '',
street2: '',
city: '',
state: '',
zip: '',
country: '',
},
},
};

function CardDetails({pan, expiration, cvv, privatePersonalDetails}) {
usePrivatePersonalDetails();
const {translate} = useLocalize();

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}
/>
</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')}
title={PersonalDetailsUtils.getFormattedAddress(privatePersonalDetails)}
interactive={false}
/>
</>
);
}

CardDetails.displayName = 'CardDetails';
CardDetails.propTypes = propTypes;
CardDetails.defaultProps = defaultProps;

export default withOnyx({
privatePersonalDetails: {
key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
},
})(CardDetails);

0 comments on commit fb9c763

Please sign in to comment.