flex-template-web/src/components/StripeBankAccountTokenInputField/StripeBankAccountTokenInputField.js
2018-05-09 16:38:43 +03:00

336 lines
10 KiB
JavaScript

/**
* 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 (
<div className={css.unsupportedCountryError}>
<FormattedMessage
id="StripeBankAccountTokenInputField.unsupportedCountry"
values={{ country }}
/>
</div>
);
}
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 (
<div className={classNames(rootClassName || css.root, className)}>
{inputConfiguration.map(inputType => {
return (
<StripeBankAccountRequiredInput
disabled={disabled}
key={inputType}
inputType={inputType}
formName={formName}
value={this.state[inputType].value}
placeholder={formatFieldMessage(intl, inputType, 'placeholder')}
onChange={e => 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 ? <p className={css.error}>{this.state.stripeError}</p> : null}
{showFormError ? <p className={css.error}>{formMeta.error}</p> : null}
</div>
);
}
}
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 <Field component={EnhancedTokenInputFieldComponent} {...props} />;
};
export default StripeBankAccountTokenInputField;