Skip to content

Commit

Permalink
Merge pull request #28453 from pac-guerreiro/feature/add-get-physical…
Browse files Browse the repository at this point in the history
…-card-button-and-necessary-routes

feat(expensify-card): add get physical card button and routes
  • Loading branch information
grgia authored Nov 20, 2023
2 parents 693a262 + 56554f6 commit cc2f150
Show file tree
Hide file tree
Showing 29 changed files with 1,357 additions and 159 deletions.
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,8 @@ const ONYXKEYS = {
REPORT_PHYSICAL_CARD_FORM_DRAFT: 'requestPhysicalCardFormDraft',
REPORT_VIRTUAL_CARD_FRAUD: 'reportVirtualCardFraudForm',
REPORT_VIRTUAL_CARD_FRAUD_DRAFT: 'reportVirtualCardFraudFormDraft',
GET_PHYSICAL_CARD_FORM: 'getPhysicalCardForm',
GET_PHYSICAL_CARD_FORM_DRAFT: 'getPhysicalCardFormDraft',
},
} as const;

Expand Down Expand Up @@ -500,6 +502,8 @@ type OnyxValues = {
[ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: OnyxTypes.Form;
[ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: OnyxTypes.Form;
[ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form | undefined;
};

type OnyxKeyValue<TOnyxKey extends (OnyxKey | OnyxCollectionKey) & keyof OnyxValues> = OnyxEntry<OnyxValues[TOnyxKey]>;
Expand Down
16 changes: 16 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,22 @@ export default {
route: '/settings/wallet/card/:domain/report-virtual-fraud',
getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-virtual-fraud`,
},
SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME: {
route: '/settings/wallet/card/:domain/get-physical/name',
getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/name`,
},
SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE: {
route: '/settings/wallet/card/:domain/get-physical/phone',
getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/phone`,
},
SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS: {
route: '/settings/wallet/card/:domain/get-physical/address',
getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/address`,
},
SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM: {
route: '/settings/wallet/card/:domain/get-physical/confirm',
getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/confirm`,
},
SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card',
SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account',
SETTINGS_ENABLE_PAYMENTS: 'settings/wallet/enable-payments',
Expand Down
8 changes: 7 additions & 1 deletion src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ export default {
SECURITY: 'Settings_Security',
STATUS: 'Settings_Status',
WALLET: 'Settings_Wallet',
WALLET_DOMAIN_CARDS: 'Settings_Wallet_DomainCards',
WALLET_DOMAIN_CARD: 'Settings_Wallet_DomainCard',
WALLET_CARD_GET_PHYSICAL: {
NAME: 'Settings_Card_Get_Physical_Name',
PHONE: 'Settings_Card_Get_Physical_Phone',
ADDRESS: 'Settings_Card_Get_Physical_Address',
CONFIRM: 'Settings_Card_Get_Physical_Confirm',
},
},
SAVE_THE_WORLD: {
ROOT: 'SaveTheWorld_Root',
Expand Down
223 changes: 223 additions & 0 deletions src/components/AddressForm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useCallback} from 'react';
import {View} from 'react-native';
import _ from 'underscore';
import useLocalize from '@hooks/useLocalize';
import Navigation from '@libs/Navigation/Navigation';
import * as ValidationUtils from '@libs/ValidationUtils';
import styles from '@styles/styles';
import CONST from '@src/CONST';
import AddressSearch from './AddressSearch';
import CountrySelector from './CountrySelector';
import Form from './Form';
import StatePicker from './StatePicker';
import TextInput from './TextInput';

const propTypes = {
/** Address city field */
city: PropTypes.string,

/** Address country field */
country: PropTypes.string,

/** Address state field */
state: PropTypes.string,

/** Address street line 1 field */
street1: PropTypes.string,

/** Address street line 2 field */
street2: PropTypes.string,

/** Address zip code field */
zip: PropTypes.string,

/** Callback which is executed when the user changes address, city or state */
onAddressChanged: PropTypes.func,

/** Callback which is executed when the user submits his address changes */
onSubmit: PropTypes.func.isRequired,

/** Whether or not should the form data should be saved as draft */
shouldSaveDraft: PropTypes.bool,

/** Text displayed on the bottom submit button */
submitButtonText: PropTypes.string,

/** A unique Onyx key identifying the form */
formID: PropTypes.string.isRequired,
};

const defaultProps = {
city: '',
country: '',
onAddressChanged: () => {},
shouldSaveDraft: false,
state: '',
street1: '',
street2: '',
submitButtonText: '',
zip: '',
};

function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldSaveDraft, state, street1, street2, submitButtonText, zip}) {
const {translate} = useLocalize();
const zipSampleFormat = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, [country, 'samples'], '');
const zipFormat = translate('common.zipCodeExampleFormat', {zipSampleFormat});
const isUSAForm = country === CONST.COUNTRY.US;

/**
* @param {Function} translate - translate function
* @param {Boolean} isUSAForm - selected country ISO code is US
* @param {Object} values - form input values
* @returns {Object} - An object containing the errors for each inputID
*/
const validator = useCallback((values) => {
const errors = {};
const requiredFields = ['addressLine1', 'city', 'country', 'state'];

// Check "State" dropdown is a valid state if selected Country is USA
if (values.country === CONST.COUNTRY.US && !COMMON_CONST.STATES[values.state]) {
errors.state = 'common.error.fieldRequired';
}

// Add "Field required" errors if any required field is empty
_.each(requiredFields, (fieldKey) => {
if (ValidationUtils.isRequiredFulfilled(values[fieldKey])) {
return;
}
errors[fieldKey] = 'common.error.fieldRequired';
});

// If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object
const countryRegexDetails = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, values.country, {});

// The postal code system might not exist for a country, so no regex either for them.
const countrySpecificZipRegex = lodashGet(countryRegexDetails, 'regex');
const countryZipFormat = lodashGet(countryRegexDetails, 'samples');

if (countrySpecificZipRegex) {
if (!countrySpecificZipRegex.test(values.zipPostCode.trim().toUpperCase())) {
if (ValidationUtils.isRequiredFulfilled(values.zipPostCode.trim())) {
errors.zipPostCode = ['privatePersonalDetails.error.incorrectZipFormat', {zipFormat: countryZipFormat}];
} else {
errors.zipPostCode = 'common.error.fieldRequired';
}
}
} else if (!CONST.GENERIC_ZIP_CODE_REGEX.test(values.zipPostCode.trim().toUpperCase())) {
errors.zipPostCode = 'privatePersonalDetails.error.incorrectZipFormat';
}

return errors;
}, []);

return (
<Form
style={[styles.flexGrow1, styles.mh5]}
formID={formID}
validate={validator}
onSubmit={onSubmit}
submitButtonText={submitButtonText}
enabledWhenOffline
>
<View style={styles.formSpaceVertical} />
<View>
<AddressSearch
inputID="addressLine1"
label={translate('common.addressLine', {lineNumber: 1})}
onValueChange={(data, key) => {
onAddressChanged(data, key);
// This enforces the country selector to use the country from address instead of the country from URL
Navigation.setParams({country: undefined});
}}
defaultValue={street1 || ''}
renamedInputKeys={{
street: 'addressLine1',
street2: 'addressLine2',
city: 'city',
state: 'state',
zipCode: 'zipPostCode',
country: 'country',
}}
maxInputLength={CONST.FORM_CHARACTER_LIMIT}
shouldSaveDraft={shouldSaveDraft}
/>
</View>
<View style={styles.formSpaceVertical} />
<TextInput
inputID="addressLine2"
label={translate('common.addressLine', {lineNumber: 2})}
aria-label={translate('common.addressLine', {lineNumber: 2})}
role={CONST.ACCESSIBILITY_ROLE.TEXT}
defaultValue={street2 || ''}
maxLength={CONST.FORM_CHARACTER_LIMIT}
spellCheck={false}
shouldSaveDraft={shouldSaveDraft}
/>
<View style={styles.formSpaceVertical} />
<View style={styles.mhn5}>
<CountrySelector
inputID="country"
value={country}
shouldSaveDraft={shouldSaveDraft}
/>
</View>
<View style={styles.formSpaceVertical} />
{isUSAForm ? (
<View style={styles.mhn5}>
<StatePicker
inputID="state"
defaultValue={state}
onValueChange={onAddressChanged}
shouldSaveDraft={shouldSaveDraft}
/>
</View>
) : (
<TextInput
inputID="state"
label={translate('common.stateOrProvince')}
aria-label={translate('common.stateOrProvince')}
role={CONST.ACCESSIBILITY_ROLE.TEXT}
value={state || ''}
maxLength={CONST.FORM_CHARACTER_LIMIT}
spellCheck={false}
onValueChange={onAddressChanged}
shouldSaveDraft={shouldSaveDraft}
/>
)}
<View style={styles.formSpaceVertical} />
<TextInput
inputID="city"
label={translate('common.city')}
aria-label={translate('common.city')}
role={CONST.ACCESSIBILITY_ROLE.TEXT}
defaultValue={city || ''}
maxLength={CONST.FORM_CHARACTER_LIMIT}
spellCheck={false}
onValueChange={onAddressChanged}
shouldSaveDraft={shouldSaveDraft}
/>
<View style={styles.formSpaceVertical} />
<TextInput
inputID="zipPostCode"
label={translate('common.zipPostCode')}
aria-label={translate('common.zipPostCode')}
role={CONST.ACCESSIBILITY_ROLE.TEXT}
autoCapitalize="characters"
defaultValue={zip || ''}
maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.ZIP_CODE}
hint={zipFormat}
onValueChange={onAddressChanged}
shouldSaveDraft={shouldSaveDraft}
/>
</Form>
);
}

AddressForm.defaultProps = defaultProps;
AddressForm.displayName = 'AddressForm';
AddressForm.propTypes = propTypes;

export default AddressForm;
6 changes: 4 additions & 2 deletions src/components/Form.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import compose from '@libs/compose';
import * as ErrorUtils from '@libs/ErrorUtils';
import FormUtils from '@libs/FormUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
import Visibility from '@libs/Visibility';
import stylePropTypes from '@styles/stylePropTypes';
Expand Down Expand Up @@ -303,7 +304,8 @@ function Form(props) {

// We want to initialize the input value if it's undefined
if (_.isUndefined(inputValues[inputID])) {
inputValues[inputID] = _.isBoolean(defaultValue) ? defaultValue : defaultValue || '';
// eslint-disable-next-line es/no-nullish-coalescing-operators
inputValues[inputID] = defaultValue ?? '';
}

// We force the form to set the input value from the defaultValue props if there is a saved valid value
Expand Down Expand Up @@ -543,7 +545,7 @@ export default compose(
key: (props) => props.formID,
},
draftValues: {
key: (props) => `${props.formID}Draft`,
key: (props) => FormUtils.getDraftKey(props.formID),
},
}),
)(Form);
3 changes: 1 addition & 2 deletions src/components/TextInput/BaseTextInput/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ function BaseTextInput(props) {
return (
<>
<View
style={styles.pointerEventsNone}
style={[styles.pointerEventsNone, ...props.containerStyles]}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(props.shouldInterceptSwipe && SwipeInterceptPanResponder.panHandlers)}
>
Expand All @@ -261,7 +261,6 @@ function BaseTextInput(props) {
style={[
props.autoGrowHeight && styles.autoGrowHeightInputContainer(textInputHeight, variables.componentSizeLarge, maxHeight),
!isMultiline && styles.componentHeightLarge,
...props.containerStyles,
]}
>
<View
Expand Down
22 changes: 22 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,7 @@ export default {
availableSpend: 'Remaining limit',
virtualCardNumber: 'Virtual card number',
physicalCardNumber: 'Physical card number',
getPhysicalCard: 'Get physical card',
reportFraud: 'Report virtual card fraud',
reviewTransaction: 'Review transaction',
suspiciousBannerTitle: 'Suspicious transaction',
Expand Down Expand Up @@ -903,6 +904,27 @@ export default {
thatDidntMatch: "That didn't match the last 4 digits on your card. Please try again.",
},
},
getPhysicalCard: {
header: 'Get physical card',
nameMessage: 'Enter your first and last name, as this will be shown on your card.',
legalName: 'Legal name',
legalFirstName: 'Legal first name',
legalLastName: 'Legal last name',
phoneMessage: 'Enter your phone number.',
phoneNumber: 'Phone number',
address: 'Address',
addressMessage: 'Enter your shipping address.',
streetAddress: 'Street Address',
city: 'City',
state: 'State',
zipPostcode: 'Zip/Postcode',
country: 'Country',
confirmMessage: 'Please confirm your details below.',
estimatedDeliveryMessage: 'Your physical card will arrive in 2-3 business days.',
next: 'Next',
getPhysicalCard: 'Get physical card',
shipCard: 'Ship card',
},
transferAmountPage: {
transfer: ({amount}: TransferParams) => `Transfer${amount ? ` ${amount}` : ''}`,
instant: 'Instant (Debit card)',
Expand Down
23 changes: 23 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,7 @@ export default {
availableSpend: 'Límite restante',
virtualCardNumber: 'Número de la tarjeta virtual',
physicalCardNumber: 'Número de la tarjeta física',
getPhysicalCard: 'Obtener tarjeta física',
reportFraud: 'Reportar fraude con la tarjeta virtual',
reviewTransaction: 'Revisar transacción',
suspiciousBannerTitle: 'Transacción sospechosa',
Expand Down Expand Up @@ -899,6 +900,28 @@ export default {
thatDidntMatch: 'Los 4 últimos dígitos de tu tarjeta no coinciden. Por favor, inténtalo de nuevo.',
},
},
// TODO: add translation
getPhysicalCard: {
header: 'Obtener tarjeta física',
nameMessage: 'Introduce tu nombre y apellido como aparecerá en tu tarjeta.',
legalName: 'Nombre completo',
legalFirstName: 'Nombre legal',
legalLastName: 'Apellidos legales',
phoneMessage: 'Introduce tu número de teléfono.',
phoneNumber: 'Número de teléfono',
address: 'Dirección',
addressMessage: 'Introduce tu dirección de envío.',
streetAddress: 'Calle de dirección',
city: 'Ciudad',
state: 'Estado',
zipPostcode: 'Código postal',
country: 'País',
confirmMessage: 'Por favor confirma tus datos.',
estimatedDeliveryMessage: 'Tu tarjeta física llegará en 2-3 días laborales.',
next: 'Siguiente',
getPhysicalCard: 'Obtener tarjeta física',
shipCard: 'Enviar tarjeta',
},
transferAmountPage: {
transfer: ({amount}: TransferParams) => `Transferir${amount ? ` ${amount}` : ''}`,
instant: 'Instante',
Expand Down
Loading

0 comments on commit cc2f150

Please sign in to comment.