/** * NOTE this component is part of react-final-form instead of Redux Form. */ /* eslint-disable no-underscore-dangle */ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { intlShape, injectIntl, FormattedMessage } from 'react-intl'; import { Field } from 'react-final-form'; import classNames from 'classnames'; import { debounce } from 'lodash'; import config from '../../config'; import { BANK_ACCOUNT_INPUTS, cleanedString, formatFieldMessage, requiredInputs, mapInputsToStripeAccountKeys, supportedCountries, translateStripeError, } from './StripeBankAccountTokenInputField.util'; import StripeBankAccountRequiredInput from './StripeBankAccountRequiredInput'; import css from './StripeBankAccountTokenInputField.css'; // Since redux-form tracks the onBlur event for marking the field as // touched (which triggers possible error validation rendering), only // trigger the event asynchronously when no other input within this // component has received focus. // // This prevents showing the validation error when the user selects a // value and moves on to another input within this component. const BLUR_TIMEOUT = 100; const DEBOUNCE_WAIT_TIME = 1000; class TokenInputFieldComponent extends Component { constructor(props) { super(props); const intl = props.intl; // Initial state is needed when country (and currency) changes and values need to be cleared. this.initialState = { stripeError: null, }; // Fill initialState with input type specific data BANK_ACCOUNT_INPUTS.forEach(inputType => { this.initialState[inputType] = { value: '', touched: false, error: formatFieldMessage(intl, inputType, 'required'), }; }); this.state = this.initialState; this.blurTimeoutId = null; // We keep track of the mounted state of the component to avoid // setting state or calling callback props if a createToken call // finishes after the component is already removed. // // The correct solution would be to cancel all ongoing operations // in componentWillUnmount, but since Promises don't have a // cancellation mechanism yet, we must use a different solution // for now. // // See: https://facebook.github.io/react/blog/2015/12/16/ismounted-antipattern.html this._isMounted = false; this.requestToken = debounce(this.requestToken.bind(this), DEBOUNCE_WAIT_TIME); this.handleInputChange = this.handleInputChange.bind(this); this.handleInputFocus = this.handleInputFocus.bind(this); this.handleInputBlur = this.handleInputBlur.bind(this); } componentDidMount() { if (!window.Stripe) { throw new Error('Stripe must be loaded for StripeBankAccountTokenInputField'); } this.stripe = window.Stripe(config.stripe.publishableKey); this._isMounted = true; } componentWillReceiveProps(nextProps) { const countryChanged = nextProps.country !== this.props.country; const currencyChanged = nextProps.currency !== this.props.currency; if (countryChanged || currencyChanged) { // Clear the possible input values from the state // if the given country or currency changes. this.setState(this.initialState); nextProps.input.onChange(''); } } componentWillUnmount() { this._isMounted = false; window.clearTimeout(this.blurTimeoutId); } /** * Request a token from the Stripe API with the given bank account data * * This function validates the given data and triggers onChange * events for the parent form to handle. * * * @param {Object} values - values from different input types */ requestToken(values) { const { country, currency, input: { onChange }, intl, } = this.props; // First we have to clear the current token value so the parent // form doesn't submit with an old value. onChange(''); const inputsNeeded = requiredInputs(country); const missingValues = inputsNeeded.filter(inputType => !values[inputType]); const invalidValues = inputsNeeded.filter(inputType => !!this.state[inputType].error); const numbersMissing = missingValues.length > 0; const numbersInvalid = invalidValues.length > 0; if (numbersMissing || numbersInvalid) { // Incomplete/invalid info, not requesting token return; } // Gather data to be sent to Stripe (to create bank account) let accountData = { country, currency, }; // Include input values with correct stripe keys inputsNeeded.forEach(inputType => { // Stripe fails if there are spaces within the number, this is // why we have to clean value up first. const inputValueObj = mapInputsToStripeAccountKeys( inputType, cleanedString(values[inputType]) ); accountData = { ...accountData, ...inputValueObj }; }); // https://stripe.com/docs/stripe-js/reference#collecting-bank-account-details this.stripe .createToken('bank_account', accountData) .then(result => { if (result.error) { const e = new Error(result.error.message); e.stripeError = result.error; throw e; } return result.token.id; }) .then(token => { const changedValues = inputsNeeded.filter( inputType => values[inputType] !== cleanedString(this.state[inputType].value) ); const valuesAreUnchanged = changedValues.length === 0; // Handle response only if the input values haven't changed if (this._isMounted && valuesAreUnchanged) { this.setState(prevState => { const errorsClearedFromInputs = inputsNeeded.map(inputType => { const input = prevState[inputType]; return { ...input, error: null }; }); return { ...errorsClearedFromInputs, stripeError: null }; }); onChange(token); } }) .catch(e => { if (!e.stripeError) { throw e; } if (this._isMounted) { this.setState({ stripeError: translateStripeError(country, intl, e.stripeError), }); } }); } handleInputChange(e, inputType, country, intl) { const rawValue = e.target.value; const value = cleanedString(rawValue); let inputError = null; // Validate the changed routing number if (!value) { inputError = intl.formatMessage({ id: `StripeBankAccountTokenInputField.${inputType}.required`, }); } // Save changes to the state this.setState(prevState => { const input = { ...prevState[inputType], value: rawValue, error: inputError }; return { [inputType]: input, stripeError: null, }; }); // Request new bank account token const unChangedValues = requiredInputs(country).reduce( (acc, iType) => ({ ...acc, [iType]: this.state[iType].value }), {} ); this.requestToken({ ...unChangedValues, [inputType]: value }); } handleInputFocus() { window.clearTimeout(this.blurTimeoutId); } handleInputBlur(inputType) { this.setState(prevState => { const inputData = { ...prevState[inputType], touched: true }; return { [inputType]: inputData }; }); window.clearTimeout(this.blurTimeoutId); this.blurTimeoutId = window.setTimeout(this.props.input.onBlur, BLUR_TIMEOUT); } render() { const { rootClassName, className, country, formName, meta: formMeta, intl, disabled, } = this.props; if (!supportedCountries.includes(country)) { return (
); } const hasInputErrors = requiredInputs(country).some(inputType => { return (this.state[inputType].touched || formMeta.touched) && !!this.state[inputType].error; }); // Only show Stripe and form errors when the fields don't have // more specific errors. const showingFieldErrors = hasInputErrors; const showStripeError = !!(this.state.stripeError && !showingFieldErrors && formMeta.touched); const showFormError = !!( formMeta.touched && formMeta.error && !showingFieldErrors && !showStripeError ); const inputConfiguration = requiredInputs(country); return (
{inputConfiguration.map(inputType => { return ( this.handleInputChange(e, inputType, country, intl)} onFocus={this.handleInputFocus} onBlur={() => this.handleInputBlur(inputType)} isTouched={this.state[inputType].touched || formMeta.touched} showStripeError={showStripeError} inputError={this.state[inputType].error} /> ); })} {showStripeError ?

{this.state.stripeError}

: null} {showFormError ?

{formMeta.error}

: null}
); } } TokenInputFieldComponent.defaultProps = { rootClassName: null, className: null, disabled: false, }; const { string, shape, func, bool } = PropTypes; TokenInputFieldComponent.propTypes = { rootClassName: string, className: string, country: string.isRequired, currency: string.isRequired, formName: string.isRequired, disabled: bool, input: shape({ onChange: func.isRequired, onBlur: func.isRequired, }).isRequired, meta: shape({ touched: bool.isRequired, error: string, }).isRequired, intl: intlShape.isRequired, }; const EnhancedTokenInputFieldComponent = injectIntl(TokenInputFieldComponent); const StripeBankAccountTokenInputField = props => { return ; }; export default StripeBankAccountTokenInputField;