diff --git a/src/CONST.js b/src/CONST.js index 19a7bdf92f08..a32ad3121da9 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -271,6 +271,7 @@ const CONST = { KEYBOARD_TYPE: { NUMERIC: 'numeric', PHONE_PAD: 'phone-pad', + NUMBER_PAD: 'number-pad', }, ATTACHMENT_PICKER_TYPE: { @@ -443,7 +444,7 @@ const CONST = { NUMBER: /^[0-9]+$/, CARD_NUMBER: /^[0-9]{15,16}$/, CARD_SECURITY_CODE: /^[0-9]{3,4}$/, - CARD_EXPIRATION_DATE: /(0[1-9]|10|11|12)\/20[0-9]{2}$/, + CARD_EXPIRATION_DATE: /^(0[1-9]|1[0-2])([^0-9])?([0-9]{4}|([0-9]{2}))$/, PAYPAL_ME_USERNAME: /^[a-zA-Z0-9]+$/, // Adapted from: https://gist.github.com/dperini/729294 diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index 4317a9ac6be1..ece47440dabe 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -137,4 +137,7 @@ export default { // Set when we are loading payment methods IS_LOADING_PAYMENT_METHODS: 'isLoadingPaymentMethods', + + // Stores values for the add debit card form + ADD_DEBIT_CARD_FORM: 'addDebitCardForm', }; diff --git a/src/components/FormAlertWithSubmitButton.js b/src/components/FormAlertWithSubmitButton.js index 0bd2659eb32e..077814e11f2c 100644 --- a/src/components/FormAlertWithSubmitButton.js +++ b/src/components/FormAlertWithSubmitButton.js @@ -37,6 +37,9 @@ const propTypes = { /** Styles for container element */ containerStyles: PropTypes.arrayOf(PropTypes.object), + /** Is the button in a loading state */ + isLoading: PropTypes.bool, + ...withLocalizePropTypes, }; @@ -45,6 +48,7 @@ const defaultProps = { isDisabled: false, isMessageHtml: false, containerStyles: [], + isLoading: false, }; const FormAlertWithSubmitButton = ({ @@ -57,6 +61,7 @@ const FormAlertWithSubmitButton = ({ message, isMessageHtml, containerStyles, + isLoading, }) => { /** * @returns {React.Component} @@ -114,6 +119,7 @@ const FormAlertWithSubmitButton = ({ text={buttonText} onPress={onSubmit} isDisabled={isDisabled} + isLoading={isLoading} /> </View> ); diff --git a/src/languages/en.js b/src/languages/en.js index bde22d3861bd..097d9bb5724c 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -84,6 +84,8 @@ export default { confirm: 'Confirm', reset: 'Reset', done: 'Done', + debitCard: 'Debit card', + payPalMe: 'PayPal.me/', }, attachmentPicker: { cameraPermissionRequired: 'Camera permission required', @@ -279,7 +281,6 @@ export default { }, addPayPalMePage: { enterYourUsernameToGetPaidViaPayPal: 'Enter your username to get paid back via PayPal.', - payPalMe: 'PayPal.me/', yourPayPalUsername: 'Your PayPal username', addPayPalAccount: 'Add PayPal account', editPayPalAccount: 'Update PayPal account', @@ -287,24 +288,22 @@ export default { formatError: 'Invalid PayPal.me username', }, addDebitCardPage: { - addADebitCard: 'Add a Debit Card', - nameOnCard: 'Name on Card', - debitCardNumber: 'Debit Card Number', - expiration: 'Expiration', - expirationDate: 'MM/YYYY', + addADebitCard: 'Add a debit card', + nameOnCard: 'Name on card', + debitCardNumber: 'Debit card number', + expiration: 'Expiration date', + expirationDate: 'MM/YY', cvv: 'CVV', - billingAddress: 'Billing Address', - streetAddress: 'Street Address', - cityName: 'City Name', - expensifyTermsOfService: 'Expensify Terms Of Service', + billingAddress: 'Billing address', + expensifyTermsOfService: 'Expensify Terms of Service', growlMessageOnSave: 'Your debit card was successfully added', error: { - invalidName: 'Please add a valid name', - zipCode: 'Please enter a valid zip code', + invalidName: 'Please enter a valid name', + addressZipCode: 'Please enter a valid zip code', debitCardNumber: 'Please enter a valid debit card number', expirationDate: 'Please enter a valid expiration date', securityCode: 'Please enter a valid security code', - address: 'Please enter a valid billing address', + addressStreet: 'Please enter a valid billing address that is not a PO Box', addressState: 'Please select a state', addressCity: 'Please enter a city', acceptedTerms: 'You must accept the Terms of Service to continue', diff --git a/src/languages/es.js b/src/languages/es.js index b46ff9ae7e50..37eb855bbf3c 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -84,6 +84,8 @@ export default { confirm: 'Confirmar', reset: 'Restablecer', done: 'Listo', + debitCard: 'Tarjeta de débito', + payPalMe: 'PayPal.me/', }, attachmentPicker: { cameraPermissionRequired: 'Se necesita permiso para usar la cámara', @@ -279,7 +281,6 @@ export default { }, addPayPalMePage: { enterYourUsernameToGetPaidViaPayPal: 'Escribe tu nombre de usuario para que otros puedan pagarte a través de PayPal.', - payPalMe: 'PayPal.me/', yourPayPalUsername: 'Tu usuario de PayPal', addPayPalAccount: 'Agregar cuenta de PayPal', growlMessageOnSave: 'Su nombre de usuario de PayPal se agregó correctamente', @@ -290,21 +291,19 @@ export default { addADebitCard: 'Agregar una tarjeta de débito', nameOnCard: 'Nombre en la tarjeta', debitCardNumber: 'Numero de la tarjeta de débito', - expiration: 'Vencimiento', + expiration: 'Fecha de vencimiento', expirationDate: 'MM/AA', cvv: 'CVV', - billingAddress: 'Dirección de Envio', - streetAddress: 'Dirección', - cityName: 'Nombre de la ciudad', + billingAddress: 'Dirección de envio', expensifyTermsOfService: 'Expensify Términos de servicio', growlMessageOnSave: 'Su tarteja de débito se agregó correctamente', error: { - invalidName: 'Por favor agregue un nombre válido', - zipCode: 'Por favor ingrese un código postal válido', + invalidName: 'Por favor ingrese un nombre válido', + addressZipCode: 'Por favor ingrese un código postal válido', debitCardNumber: 'Ingrese un número de tarjeta de débito válido', expirationDate: 'Por favor introduzca una fecha de vencimiento válida', securityCode: 'Ingrese un código de seguridad válido', - address: 'Ingrese una dirección de facturación válida', + addressStreet: 'Ingrese una dirección de facturación válida que no sea un apartado postal', addressState: 'Por favor seleccione un estado', addressCity: 'Por favor ingrese una ciudad', acceptedTerms: 'Debes aceptar los Términos de servicio para continuar', diff --git a/src/libs/CardUtils.js b/src/libs/CardUtils.js new file mode 100644 index 000000000000..5b7a0d3ec7fc --- /dev/null +++ b/src/libs/CardUtils.js @@ -0,0 +1,39 @@ +/** + * Returns the masked card number (ex: 4242XXXXXXXX4242) + * + * @param {String} cardNumber + * @return {Boolean} + */ +function maskCardNumber(cardNumber) { + const firstFour = cardNumber.substring(0, 4); + const lastFour = cardNumber.substring(cardNumber.length - 4); + + return `${firstFour}${'X'.repeat(cardNumber.length - 8)}${lastFour}`; +} + +/** + * @param {String} expirationDateString - string in MM/YYYY, MM/YY, MMYY, or MMYYYY format + * @returns {String} + */ +function getMonthFromExpirationDateString(expirationDateString) { + return expirationDateString.substr(0, 2); +} + +/** + * @param {String} expirationDateString - string in MMYY or MMYYYY format, with any non-number separator + * @returns {String} + */ +function getYearFromExpirationDateString(expirationDateString) { + const stringContainsNumbersOnly = /^\d+$/.test(expirationDateString); + const cardYear = stringContainsNumbersOnly + ? expirationDateString.substr(2) + : expirationDateString.substr(3); + + return cardYear.length === 2 ? `20${cardYear}` : cardYear; +} + +export { + maskCardNumber, + getMonthFromExpirationDateString, + getYearFromExpirationDateString, +}; diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.js index 2de72b331023..13d1c2501321 100644 --- a/src/libs/ValidationUtils.js +++ b/src/libs/ValidationUtils.js @@ -1,7 +1,7 @@ import moment from 'moment'; import _ from 'underscore'; import CONST from '../CONST'; - +import {getMonthFromExpirationDateString, getYearFromExpirationDateString} from './CardUtils'; /** * Implements the Luhn Algorithm, a checksum formula used to validate credit card @@ -76,14 +76,23 @@ function isRequiredFulfilled(value) { } /** - * Validates that this is a valid expiration date - * in the MM/YY or MM/YYYY format + * Validates that this is a valid expiration date. Supports the following formats: + * 1. MM/YY + * 2. MM/YYYY + * 3. MMYY + * 4. MMYYYY * * @param {String} string * @returns {Boolean} */ function isValidExpirationDate(string) { - return CONST.REGEX.CARD_EXPIRATION_DATE.test(string); + if (!CONST.REGEX.CARD_EXPIRATION_DATE.test(string)) { + return false; + } + + // Use the last of the month to check if the expiration date is in the future or not + const expirationDate = `${getYearFromExpirationDateString(string)}-${getMonthFromExpirationDateString(string)}-01`; + return moment(expirationDate).endOf('month').isAfter(moment()); } /** diff --git a/src/libs/actions/PaymentMethods.js b/src/libs/actions/PaymentMethods.js index d9467327244d..f8b6cacedf4f 100644 --- a/src/libs/actions/PaymentMethods.js +++ b/src/libs/actions/PaymentMethods.js @@ -7,7 +7,7 @@ import ROUTES from '../../ROUTES'; import Growl from '../Growl'; import {translateLocal} from '../translate'; import Navigation from '../Navigation/Navigation'; -import {maskCardNumber} from '../cardUtils'; +import {maskCardNumber, getMonthFromExpirationDateString, getYearFromExpirationDateString} from '../CardUtils'; /** * Calls the API to get the user's bankAccountList, cardList, wallet, and payPalMe @@ -41,18 +41,20 @@ function getPaymentMethods() { * @param {Object} params */ function addBillingCard(params) { - const cardYear = params.expirationDate.substr(3); - const cardMonth = params.expirationDate.substr(0, 2); + const cardMonth = getMonthFromExpirationDateString(params.expirationDate); + const cardYear = getYearFromExpirationDateString(params.expirationDate); + Onyx.merge(ONYXKEYS.ADD_DEBIT_CARD_FORM, {submitting: true}); API.AddBillingCard({ cardNumber: params.cardNumber, cardYear, cardMonth, cardCVV: params.securityCode, addressName: params.nameOnCard, - addressZip: params.zipCode, + addressZip: params.addressZipCode, currency: CONST.CURRENCY.USD, }).then(((response) => { + let errorMessage = ''; if (response.jsonCode === 200) { const cardObject = { additionalData: { @@ -60,9 +62,9 @@ function addBillingCard(params) { isP2PDebitCard: true, }, addressName: params.nameOnCard, - addressState: params.selectedState, - addressStreet: params.billingAddress, - addressZip: params.zipCode, + addressState: params.addressState, + addressStreet: params.addressStreet, + addressZip: params.addressZipCode, cardMonth, cardNumber: maskCardNumber(params.cardNumber), cardYear, @@ -73,12 +75,28 @@ function addBillingCard(params) { Growl.show(translateLocal('addDebitCardPage.growlMessageOnSave'), CONST.GROWL.SUCCESS, 3000); Navigation.navigate(ROUTES.SETTINGS_PAYMENTS); } else { - Growl.error(translateLocal('addDebitCardPage.error.genericFailureMessage', 3000)); + errorMessage = response.message ? response.message : translateLocal('addDebitCardPage.error.genericFailureMessage'); } + + Onyx.merge(ONYXKEYS.ADD_DEBIT_CARD_FORM, { + submitting: false, + error: errorMessage, + }); })); } +/** + * Resets the values for the add debit card form back to their initial states + */ +function clearDebitCardFormErrorAndSubmit() { + Onyx.set(ONYXKEYS.ADD_DEBIT_CARD_FORM, { + submitting: false, + error: '', + }); +} + export { getPaymentMethods, addBillingCard, + clearDebitCardFormErrorAndSubmit, }; diff --git a/src/libs/cardUtils.js b/src/libs/cardUtils.js deleted file mode 100644 index a3bc8a9fcf75..000000000000 --- a/src/libs/cardUtils.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Returns the masked card number (ex: 4242XXXXXXXX4242) - * - * @param {String} cardNumber - * @return {Boolean} - */ -function maskCardNumber(cardNumber) { - const firstFour = cardNumber.substring(0, 4); - const lastFour = cardNumber.substring(cardNumber.length - 4); - - return `${firstFour}${'X'.repeat(cardNumber.length - 8)}${lastFour}`; -} - -export { - // eslint-disable-next-line import/prefer-default-export - maskCardNumber, -}; diff --git a/src/pages/EnablePayments/AdditionalDetailsStep.js b/src/pages/EnablePayments/AdditionalDetailsStep.js index bf943b4966fb..12e47d568587 100644 --- a/src/pages/EnablePayments/AdditionalDetailsStep.js +++ b/src/pages/EnablePayments/AdditionalDetailsStep.js @@ -89,7 +89,7 @@ class AdditionalDetailsStep extends React.Component { label: props.translate('common.ssnLast4'), fieldName: 'ssn', maxLength: 4, - keyboardType: 'number-pad', + keyboardType: CONST.KEYBOARD_TYPE.NUMBER_PAD, }, ]; diff --git a/src/pages/ReimbursementAccount/BankAccountStep.js b/src/pages/ReimbursementAccount/BankAccountStep.js index ae24c6fa3192..d302ead8dca5 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.js +++ b/src/pages/ReimbursementAccount/BankAccountStep.js @@ -263,7 +263,7 @@ class BankAccountStep extends React.Component { /> <ExpensiTextInput label={this.props.translate('bankAccount.routingNumber')} - keyboardType="number-pad" + keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} value={this.state.routingNumber} onChangeText={value => this.clearErrorAndSetValue('routingNumber', value)} disabled={shouldDisableInputs} @@ -272,7 +272,7 @@ class BankAccountStep extends React.Component { <ExpensiTextInput containerStyles={[styles.mt4]} label={this.props.translate('bankAccount.accountNumber')} - keyboardType="number-pad" + keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} value={this.state.accountNumber} onChangeText={value => this.clearErrorAndSetValue('accountNumber', value)} disabled={shouldDisableInputs} diff --git a/src/pages/settings/Payments/AddDebitCardPage.js b/src/pages/settings/Payments/AddDebitCardPage.js index f56801066e85..476d609b1d05 100644 --- a/src/pages/settings/Payments/AddDebitCardPage.js +++ b/src/pages/settings/Payments/AddDebitCardPage.js @@ -3,31 +3,48 @@ import { View, ScrollView, } from 'react-native'; +import lodashGet from 'lodash/get'; +import _ from 'underscore'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton'; import Navigation from '../../../libs/Navigation/Navigation'; import ScreenWrapper from '../../../components/ScreenWrapper'; -import TextInputWithLabel from '../../../components/TextInputWithLabel'; import styles from '../../../styles/styles'; -import StatePicker from '../../../components/StatePicker'; import Text from '../../../components/Text'; import TextLink from '../../../components/TextLink'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; -import {addBillingCard} from '../../../libs/actions/PaymentMethods'; -import Button from '../../../components/Button'; +import {addBillingCard, clearDebitCardFormErrorAndSubmit} from '../../../libs/actions/PaymentMethods'; import KeyboardAvoidingView from '../../../components/KeyboardAvoidingView'; -import FixedFooter from '../../../components/FixedFooter'; -import Growl from '../../../libs/Growl'; import { isValidAddress, isValidExpirationDate, isValidZipCode, isValidDebitCard, isValidSecurityCode, } from '../../../libs/ValidationUtils'; import CheckboxWithLabel from '../../../components/CheckboxWithLabel'; +import ExpensiTextInput from '../../../components/ExpensiTextInput'; +import CONST from '../../../CONST'; +import FormAlertWithSubmitButton from '../../../components/FormAlertWithSubmitButton'; +import ONYXKEYS from '../../../ONYXKEYS'; +import compose from '../../../libs/compose'; +import AddressSearch from '../../../components/AddressSearch'; const propTypes = { + addDebitCardForm: PropTypes.shape({ + /** Error message from API call */ + error: PropTypes.string, + + /** Whether or not the form is submitting */ + submitting: PropTypes.bool, + }), + /* Onyx Props */ ...withLocalizePropTypes, }; const defaultProps = { + addDebitCardForm: { + error: '', + submitting: false, + }, }; class DebitCardPage extends Component { @@ -39,97 +56,121 @@ class DebitCardPage extends Component { cardNumber: '', expirationDate: '', securityCode: '', - billingAddress: '', - city: '', - selectedState: '', - zipCode: '', + addressStreet: '', + addressState: '', + addressZipCode: '', acceptedTerms: false, - isAddingCard: false, + errors: {}, + shouldShowAlertPrompt: false, + }; + + this.requiredFields = [ + 'nameOnCard', + 'cardNumber', + 'expirationDate', + 'securityCode', + 'addressStreet', + 'addressState', + 'addressZipCode', + 'acceptedTerms', + ]; + + // Map a field to the key of the error's translation + this.errorTranslationKeys = { + nameOnCard: 'addDebitCardPage.error.invalidName', + cardNumber: 'addDebitCardPage.error.debitCardNumber', + expirationDate: 'addDebitCardPage.error.expirationDate', + securityCode: 'addDebitCardPage.error.securityCode', + addressStreet: 'addDebitCardPage.error.addressStreet', + addressState: 'addDebitCardPage.error.addressState', + addressZipCode: 'addDebitCardPage.error.addressZipCode', + acceptedTerms: 'addDebitCardPage.error.acceptedTerms', }; - this.toggleTermsOfService = this.toggleTermsOfService.bind(this); - this.handleExpirationInput = this.handleExpirationInput.bind(this); - this.handleCardNumberInput = this.handleCardNumberInput.bind(this); this.submit = this.submit.bind(this); + this.clearErrorAndSetValue = this.clearErrorAndSetValue.bind(this); + this.getErrorText = this.getErrorText.bind(this); + } + + /** + * Make sure we reset the onyx values so old errors don't show if this form is displayed later + */ + componentWillUnmount() { + clearDebitCardFormErrorAndSubmit(); + } + + /** + * @param {String} inputKey + * @returns {String} + */ + getErrorText(inputKey) { + if (!lodashGet(this.state.errors, inputKey, false)) { + return ''; + } + + return this.props.translate(this.errorTranslationKeys[inputKey]); } /** * @returns {Boolean} */ validate() { - if (this.state.nameOnCard === '') { - Growl.error(this.props.translate('addDebitCardPage.error.invalidName')); - return false; + const errors = {}; + if (_.isEmpty(this.state.nameOnCard.trim())) { + errors.nameOnCard = true; } if (!isValidDebitCard(this.state.cardNumber.replace(/ /g, ''))) { - Growl.error(this.props.translate('addDebitCardPage.error.debitCardNumber')); - return false; + errors.cardNumber = true; } if (!isValidExpirationDate(this.state.expirationDate)) { - Growl.error(this.props.translate('addDebitCardPage.error.expirationDate')); - return false; + errors.expirationDate = true; } if (!isValidSecurityCode(this.state.securityCode)) { - Growl.error(this.props.translate('addDebitCardPage.error.securityCode')); - return false; - } - - if (!isValidAddress(this.state.billingAddress)) { - Growl.error(this.props.translate('addDebitCardPage.error.address')); - return false; - } - - if (this.state.city === '') { - Growl.error(this.props.translate('addDebitCardPage.error.addressCity')); - return false; + errors.securityCode = true; } - if (this.state.selectedState === '') { - Growl.error(this.props.translate('addDebitCardPage.error.addressState')); - return false; - } - - if (!isValidZipCode(this.state.zipCode)) { - Growl.error(this.props.translate('addDebitCardPage.error.zipCode')); - return false; + if (!isValidAddress(this.state.addressStreet) + || !this.state.addressState + || !isValidZipCode(this.state.addressZipCode)) { + errors.addressStreet = true; } if (!this.state.acceptedTerms) { - Growl.error(this.props.translate('addDebitCardPage.error.acceptedTerms')); - return false; + errors.acceptedTerms = true; } - return true; + const hasErrors = _.size(errors) > 0; + this.setState({ + errors, + shouldShowAlertPrompt: hasErrors, + }); + return !hasErrors; } submit() { if (!this.validate()) { return; } - this.setState({isAddingCard: true}); addBillingCard(this.state); } - toggleTermsOfService() { - this.setState(prevState => ({acceptedTerms: !prevState.acceptedTerms})); - } - - handleExpirationInput(expirationDate) { - let newExpirationDate = expirationDate; - const isErasing = expirationDate.length < this.state.expirationDate.length; - if (expirationDate.length === 2 && !isErasing) { - newExpirationDate = `${expirationDate}/`; - } - this.setState({expirationDate: newExpirationDate}); - } - - handleCardNumberInput(newCardNumber) { - if (/^[0-9]{0,16}$/.test(newCardNumber)) { - this.setState({cardNumber: newCardNumber}); - } + /** + * Clear the error associated to inputKey if found and store the inputKey new value in the state. + * + * @param {String} inputKey + * @param {String} value + */ + clearErrorAndSetValue(inputKey, value) { + this.setState(prevState => ({ + [inputKey]: value, + errors: { + ...prevState.errors, + [inputKey]: false, + }, + })); } render() { @@ -138,97 +179,101 @@ class DebitCardPage extends Component { <KeyboardAvoidingView> <HeaderWithCloseButton title={this.props.translate('addDebitCardPage.addADebitCard')} + shouldShowBackButton + onBackButtonPress={() => Navigation.goBack()} onCloseButtonPress={() => Navigation.dismissModal(true)} /> - <ScrollView style={styles.flex1} contentContainerStyle={styles.p5}> - <TextInputWithLabel - label={this.props.translate('addDebitCardPage.nameOnCard')} - placeholder={this.props.translate('addDebitCardPage.nameOnCard')} - containerStyles={[styles.flex1, styles.mb2]} - onChangeText={nameOnCard => this.setState({nameOnCard})} - value={this.state.nameOnCard} - /> - <TextInputWithLabel - label={this.props.translate('addDebitCardPage.debitCardNumber')} - placeholder={this.props.translate('addDebitCardPage.debitCardNumber')} - keyboardType="number-pad" - containerStyles={[styles.flex1, styles.mb2]} - onChangeText={cardNumber => this.handleCardNumberInput(cardNumber)} - value={this.state.cardNumber} - /> - <View style={[styles.flexRow, styles.mb2]}> - <TextInputWithLabel - label={this.props.translate('addDebitCardPage.expiration')} - placeholder={this.props.translate('addDebitCardPage.expirationDate')} - keyboardType="number-pad" - containerStyles={[styles.flex2, styles.mr4]} - onChangeText={expirationDate => this.handleExpirationInput(expirationDate)} - value={this.state.expirationDate} + <ScrollView + style={[styles.w100, styles.flex1]} + contentContainerStyle={styles.flexGrow1} + keyboardShouldPersistTaps="handled" + ref={el => this.form = el} + > + <View style={[styles.mh5, styles.mb5]}> + <ExpensiTextInput + label={this.props.translate('addDebitCardPage.nameOnCard')} + onChangeText={nameOnCard => this.clearErrorAndSetValue('nameOnCard', nameOnCard)} + value={this.state.nameOnCard} + errorText={this.getErrorText('nameOnCard')} /> - <TextInputWithLabel - label={this.props.translate('addDebitCardPage.cvv')} - placeholder="123" - keyboardType="number-pad" - containerStyles={[styles.flex2]} - onChangeText={securityCode => this.setState({securityCode})} - value={this.state.securityCode} + <ExpensiTextInput + label={this.props.translate('addDebitCardPage.debitCardNumber')} + containerStyles={[styles.mt4]} + onChangeText={cardNumber => this.clearErrorAndSetValue('cardNumber', cardNumber)} + value={this.state.cardNumber} + errorText={this.getErrorText('cardNumber')} + keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} /> - </View> - <TextInputWithLabel - label={this.props.translate('addDebitCardPage.billingAddress')} - placeholder={this.props.translate('addDebitCardPage.streetAddress')} - containerStyles={[styles.flex1, styles.mb2]} - onChangeText={billingAddress => this.setState({billingAddress})} - value={this.state.billingAddress} - /> - <TextInputWithLabel - label={this.props.translate('common.city')} - placeholder={this.props.translate('addDebitCardPage.cityName')} - containerStyles={[styles.flex1, styles.mb2]} - onChangeText={city => this.setState({city})} - value={this.state.city} - /> - <View style={[styles.flexRow, styles.mb6]}> - <View style={[styles.flex2, styles.mr4]}> - <Text style={[styles.mb1, styles.formLabel]}> - {this.props.translate('common.state')} - </Text> - <StatePicker - onChange={state => this.setState({selectedState: state})} - value={this.state.selectedState} - /> + <View style={[styles.flexRow, styles.mt4]}> + <View style={[styles.flex1, styles.mr2]}> + <ExpensiTextInput + label={this.props.translate('addDebitCardPage.expiration')} + placeholder={this.props.translate('addDebitCardPage.expirationDate')} + onChangeText={expirationDate => this.clearErrorAndSetValue('expirationDate', expirationDate)} + value={this.state.expirationDate} + errorText={this.getErrorText('expirationDate')} + keyboardType={CONST.KEYBOARD_TYPE.PHONE_PAD} + translateX={-10} + /> + </View> + <View style={[styles.flex1]}> + <ExpensiTextInput + label={this.props.translate('addDebitCardPage.cvv')} + onChangeText={securityCode => this.clearErrorAndSetValue('securityCode', securityCode)} + value={this.state.securityCode} + errorText={this.getErrorText('securityCode')} + translateX={-10} + /> + </View> </View> - <TextInputWithLabel - label={this.props.translate('common.zip')} - placeholder={this.props.translate('common.zip')} - containerStyles={[styles.flex2]} - onChangeText={zipCode => this.setState({zipCode})} - value={this.state.zipCode} + <AddressSearch + label={this.props.translate('addDebitCardPage.billingAddress')} + containerStyles={[styles.mt4]} + value={this.state.addressStreet} + onChangeText={(fieldName, value) => this.clearErrorAndSetValue(fieldName, value)} + errorText={this.getErrorText('addressStreet')} + /> + <CheckboxWithLabel + isChecked={this.state.acceptedTerms} + onPress={() => { + this.setState(prevState => ({ + acceptedTerms: !prevState.acceptedTerms, + errors: { + ...prevState.errors, + acceptedTerms: false, + }, + })); + }} + LabelComponent={() => ( + <> + <Text>{`${this.props.translate('common.iAcceptThe')}`}</Text> + <TextLink href="https://use.expensify.com/terms"> + {`${this.props.translate('addDebitCardPage.expensifyTermsOfService')}`} + </TextLink> + </> + )} + style={[styles.mt4, styles.mb4]} + errorText={this.getErrorText('acceptedTerms')} + hasError={Boolean(this.state.errors.acceptedTerms)} /> </View> - <CheckboxWithLabel - isChecked={this.state.acceptedTerms} - onPress={this.toggleTermsOfService} - LabelComponent={() => ( - <Text> - {`${this.props.translate('common.iAcceptThe')} `} - <TextLink href="https://use.expensify.com/terms"> - {`${this.props.translate('addDebitCardPage.expensifyTermsOfService')}`} - </TextLink> + {!_.isEmpty(this.props.addDebitCardForm.error) && ( + <View style={[styles.mh5, styles.mb5]}> + <Text style={[styles.formError]}> + {this.props.addDebitCardForm.error} </Text> - )} + </View> + )} + <FormAlertWithSubmitButton + isAlertVisible={this.state.shouldShowAlertPrompt} + buttonText={this.props.translate('common.save')} + onSubmit={this.submit} + onFixTheErrorsLinkPressed={() => { + this.form.scrollTo({y: 0, animated: true}); + }} + isLoading={this.props.addDebitCardForm.submitting} /> </ScrollView> - <FixedFooter> - <Button - success - onPress={this.submit} - style={[styles.w100]} - text={this.props.translate('common.save')} - isLoading={this.state.isAddingCard} - pressOnEnter - /> - </FixedFooter> </KeyboardAvoidingView> </ScreenWrapper> ); @@ -238,4 +283,11 @@ class DebitCardPage extends Component { DebitCardPage.propTypes = propTypes; DebitCardPage.defaultProps = defaultProps; -export default withLocalize(DebitCardPage); +export default compose( + withOnyx({ + addDebitCardForm: { + key: ONYXKEYS.ADD_DEBIT_CARD_FORM, + }, + }), + withLocalize, +)(DebitCardPage); diff --git a/src/pages/settings/Payments/AddPayPalMePage.js b/src/pages/settings/Payments/AddPayPalMePage.js index edfa0c8858af..4b1cb1e0a937 100644 --- a/src/pages/settings/Payments/AddPayPalMePage.js +++ b/src/pages/settings/Payments/AddPayPalMePage.js @@ -86,7 +86,7 @@ class AddPayPalMePage extends React.Component { {this.props.translate('addPayPalMePage.enterYourUsernameToGetPaidViaPayPal')} </Text> <ExpensiTextInput - label={this.props.translate('addPayPalMePage.payPalMe')} + label={this.props.translate('common.payPalMe')} autoCompleteType="off" autoCorrect={false} value={this.state.payPalMeUsername} diff --git a/src/pages/settings/Payments/PaymentsPage.js b/src/pages/settings/Payments/PaymentsPage.js index 9ef9e2234d03..4e8c5a5e2ec9 100644 --- a/src/pages/settings/Payments/PaymentsPage.js +++ b/src/pages/settings/Payments/PaymentsPage.js @@ -139,12 +139,12 @@ class PaymentsPage extends React.Component { }} > <MenuItem - title="PayPal.me" + title={this.props.translate('common.payPalMe')} icon={PayPal} onPress={() => this.addPaymentMethodTypePressed(PAYPAL)} /> <MenuItem - title="Debit Card" + title={this.props.translate('common.debitCard')} icon={CreditCard} onPress={() => this.addPaymentMethodTypePressed(DEBIT_CARD)} /> diff --git a/tests/unit/CardUtilsTest.js b/tests/unit/CardUtilsTest.js new file mode 100644 index 000000000000..ac2c4345b389 --- /dev/null +++ b/tests/unit/CardUtilsTest.js @@ -0,0 +1,42 @@ +const cardUtils = require('../../src/libs/CardUtils'); + +const shortDate = '0924'; +const shortDateSlashed = '09/24'; +const shortDateHyphen = '09-24'; +const longDate = '092024'; +const longDateSlashed = '09/2024'; +const longDateHyphen = '09-2024'; +const expectedMonth = '09'; +const expectedYear = '2024'; + +describe('CardUtils', () => { + it('Test MM/YYYY format for getting expirationDate month and year', () => { + expect(cardUtils.getMonthFromExpirationDateString(longDateSlashed)).toBe(expectedMonth); + expect(cardUtils.getYearFromExpirationDateString(longDateSlashed)).toBe(expectedYear); + }); + + it('Test MM-YYYY format for getting expirationDate month and year', () => { + expect(cardUtils.getMonthFromExpirationDateString(longDateHyphen)).toBe(expectedMonth); + expect(cardUtils.getYearFromExpirationDateString(longDateHyphen)).toBe(expectedYear); + }); + + it('Test MMYYYY format for getting expirationDate month and year', () => { + expect(cardUtils.getMonthFromExpirationDateString(longDate)).toBe(expectedMonth); + expect(cardUtils.getYearFromExpirationDateString(longDate)).toBe(expectedYear); + }); + + it('Test MM/YY format for getting expirationDate month and year', () => { + expect(cardUtils.getMonthFromExpirationDateString(shortDateSlashed)).toBe(expectedMonth); + expect(cardUtils.getYearFromExpirationDateString(shortDateSlashed)).toBe(expectedYear); + }); + + it('Test MM-YY format for getting expirationDate month and year', () => { + expect(cardUtils.getMonthFromExpirationDateString(shortDateHyphen)).toBe(expectedMonth); + expect(cardUtils.getYearFromExpirationDateString(shortDateHyphen)).toBe(expectedYear); + }); + + it('Test MMYY format for getting expirationDate month and year', () => { + expect(cardUtils.getMonthFromExpirationDateString(shortDate)).toBe(expectedMonth); + expect(cardUtils.getYearFromExpirationDateString(shortDate)).toBe(expectedYear); + }); +});