react-credit-card-kit
Version:
A credit/debit card input / form field for React
755 lines (666 loc) • 22.3 kB
JavaScript
// @flow
import React, { Component } from 'react';
import payment from 'payment';
import creditCardType from 'credit-card-type';
import isValidZip from 'is-valid-zip';
import {
formatCardNumber,
formatExpiry,
hasCardNumberReachedMaxLength,
hasCVCReachedMaxLength,
hasZipReachedMaxLength,
isHighlighted
} from './utils/formatter';
import images from './utils/images';
import isExpiryInvalid from './utils/is-expiry-invalid';
import {
Container,
FieldWrapper,
CardImage,
InputWrapper,
DangerText
} from './utils/styles';
const BACKSPACE_KEY_CODE = 8;
const CARD_TYPES = {
mastercard: 'MASTERCARD',
visa: 'VISA',
amex: 'AMERICAN_EXPRESS'
};
type Props = {
cardCVCInputRenderer: Function,
cardExpiryInputRenderer: Function,
cardNumberInputRenderer: Function,
cardZipInputRenderer: Function,
afterValidateCard: Function,
cardExpiryInputProps: Object,
cardNumberInputProps: Object,
cardCVCInputProps: Object,
cardZipInputProps: Object,
cardNameInputProps: Object,
emailInputProps: Object,
cardImageClassName: string,
cardImageStyle: Object,
containerClassName: string,
containerStyle: Object,
controlClassName: string,
showError: boolean,
showPopoverError: boolean,
dangerTextClassName: string,
dangerTextStyle: Object,
fieldClassName: string,
fieldStyle: Object,
enableZipInput: boolean,
inputComponent: Function | Object | string,
inputClassName: string,
inputStyle: Object,
invalidClassName: string,
invalidStyle: Object,
translator: Object,
autoFocus: boolean,
allowCardTypes: Array
};
type State = {
cardImage: string,
cardNumberLength: number,
cardNumber: ?string,
errorText: ?string,
showZip: boolean,
ccNumberErrorText: string,
ccExpDateErrorText: string,
ccCIDErrorText: string,
ccZipErrorText: string,
ccEmailErrorText: string,
isCardMode: boolean
};
const inputRenderer = ({ props }: Object) => <input {...props} />;
export class CreditCardInput extends Component<Props, State> {
cardNumberField: any;
cardExpiryField: any;
cardNameField: any;
cardNumberdMaskedField: any;
cardNumberdUnmaskedField: any;
cvcField: any;
cvcMaskedField: any;
zipField: any;
emailField: any;
inputRenderer: any;
static defaultProps = {
cardCVCInputRenderer: inputRenderer,
cardExpiryInputRenderer: inputRenderer,
cardNumberInputRenderer: inputRenderer,
cardZipInputRenderer: inputRenderer,
afterValidateCard: () => {},
cardExpiryInputProps: {},
cardNumberInputProps: {},
cardCVCInputProps: {},
cardZipInputProps: {},
cardNameInputProps: {},
emailInputProps: {},
cardImageClassName: '',
cardImageStyle: {},
containerClassName: '',
containerStyle: {},
controlClassName: '',
showError: true,
showPopoverError: false,
dangerTextClassName: '',
dangerTextStyle: {},
enableZipInput: false,
fieldClassName: '',
fieldStyle: {},
inputComponent: 'input',
inputClassName: '',
inputStyle: {},
invalidClassName: 'is-invalid',
invalidStyle: {},
translator: {},
autoFocus: true,
allowCardTypes: []
};
constructor(props: Props) {
super(props);
this.state = {
cardImage: images.placeholder,
cardNumberLength: 0,
cardNumber: null,
errorText: null,
showZip: false,
ccNumberErrorText: null,
ccExpDateErrorText: null,
ccCIDErrorText: null,
ccZipErrorText: null,
ccEmailErrorText: null,
isCardMode: true
};
this.inputRenderer = inputRenderer;
}
componentDidMount = () => {
this.setState({ cardNumber: this.cardNumberField.value }, () => {
const cardType = payment.fns.cardType(this.state.cardNumber);
this.setState({
cardImage: images[cardType] || images.placeholder
});
});
};
checkIsNumeric = (e: any) => {
if (!/^\d*$/.test(e.key)) {
e.preventDefault();
}
};
handleCardNumberBlur = (
{ onBlur }: { onBlur?: ?Function } = { onBlur: null }
) => (e: SyntheticInputEvent<*>) => {
let value = e.target.value;
const cardType = payment.fns.cardType(this.cardNumberField.value);
if (!payment.fns.validateCardNumber(value)) {
let message = value.length
? 'Please enter a valid card number'
: 'This is a required field';
this.setFieldInvalid(message, {
state: 'ccNumberErrorText'
});
}
if (
cardType &&
this.props.allowCardTypes.length &&
this.props.allowCardTypes.indexOf(cardType.toUpperCase()) === -1
) {
this.setFieldInvalid('This type card is not supported', {
state: 'ccNumberErrorText'
});
}
const { cardNumberInputProps, autoFocus } = this.props;
cardNumberInputProps.onBlur && cardNumberInputProps.onBlur(e);
onBlur && onBlur(e);
if (!cardType) {
return;
}
this.updateNumberUnmasked();
/** check cvc again */
if (!autoFocus && this.cvcField.value) {
this.handleCardCVCBlur()({ target: { value: this.cvcField.value } });
}
/** show last 4 digit */
const cardTypeInfo =
creditCardType.getTypeInfo(creditCardType.types[CARD_TYPES[cardType]]) ||
{};
const cardTypeLengths = cardTypeInfo.lengths || [16];
let ccNumber = value.split(' ').join('');
if (ccNumber.length < cardTypeLengths[0]) {
return;
}
let fourDigit = (
' ' + ccNumber.substring(Math.max(ccNumber.length - 4, 0))
).substring(Math.min(ccNumber.length, 4));
this.cardNumberdUnmaskedField &&
(this.cardNumberdUnmaskedField.value = fourDigit);
};
handleCardNumberChange = (
{ onChange }: { onChange?: ?Function } = { onChange: null }
) => (e: SyntheticInputEvent<*>) => {
let cardNumber = e.target.value;
let cardNumberLength = cardNumber.split(' ').join('').length;
const cardType = payment.fns.cardType(cardNumber);
const cardTypeInfo =
creditCardType.getTypeInfo(creditCardType.types[CARD_TYPES[cardType]]) ||
{};
const cardTypeLengths = cardTypeInfo.lengths || [16];
if (
!this.props.autoFocus &&
cardNumberLength > Math.max(...cardTypeLengths)
) {
cardNumber = cardNumber.substring(0, cardNumber.length - 1);
cardNumberLength--;
}
this.cardNumberField.value = formatCardNumber(cardNumber).replace(
/[^0-9]+/,
''
);
this.setState({
cardImage: images[cardType] || images.placeholder,
cardNumber
});
this.setState({ showZip: cardNumberLength >= 6 });
this.setFieldValid({ state: 'ccNumberErrorText' });
if (cardTypeLengths) {
const lastCardTypeLength = cardTypeLengths[cardTypeLengths.length - 1];
for (let length of cardTypeLengths) {
if (
length === cardNumberLength &&
payment.fns.validateCardNumber(cardNumber)
) {
this.props.autoFocus && this.cardExpiryField.focus();
break;
}
if (this.props.autoFocus && cardNumberLength === lastCardTypeLength) {
this.setFieldInvalid('Please enter a valid card number', {
state: 'ccNumberErrorText'
});
}
}
}
if (
cardType &&
this.props.autoFocus &&
this.props.allowCardTypes.length &&
this.props.allowCardTypes.indexOf(cardType.toUpperCase()) === -1
) {
this.setFieldInvalid('This type card is not supported', {
state: 'ccNumberErrorText'
});
}
const { cardNumberInputProps } = this.props;
cardNumberInputProps.onChange && cardNumberInputProps.onChange(e);
onChange && onChange(e);
this.updateNumberUnmasked();
};
handleCardNumberKeyPress = (e: any) => {
const value = e.target.value;
this.checkIsNumeric(e);
if (value && !isHighlighted()) {
const valueLength = value.split(' ').join('').length;
if (hasCardNumberReachedMaxLength(value, valueLength)) {
e.preventDefault();
}
}
};
updateNumberUnmasked = () => {
let formattedCcNumber = this.cardNumberField.value;
let ccNumber = formattedCcNumber.split(' ').join('');
let numberUnmasked = (
' ' + ccNumber.substring(Math.max(ccNumber.length - 4, 0))
).substring(Math.min(ccNumber.length, 4));
numberUnmasked = numberUnmasked.replace(/[0-9]/g, '*');
this.cardNumberdUnmaskedField &&
(this.cardNumberdUnmaskedField.value = numberUnmasked);
this.cardNumberdMaskedField &&
(this.cardNumberdMaskedField.value = formattedCcNumber
.substring(4)
.replace(/[0-9]/g, '*'));
};
handleCardExpiryBlur = (
{ onBlur }: { onBlur?: ?Function } = { onBlur: null }
) => (e: SyntheticInputEvent<*>) => {
const cardExpiry = e.target.value.split(' / ').join('/');
let expiryError = isExpiryInvalid(cardExpiry);
if (expiryError) {
if (!cardExpiry.length) {
expiryError = 'This is a required filed';
}
this.setFieldInvalid(expiryError, { state: 'ccExpDateErrorText' });
}
const { cardExpiryInputProps } = this.props;
cardExpiryInputProps.onBlur && cardExpiryInputProps.onBlur(e);
onBlur && onBlur(e);
};
handleCardExpiryChange = (
{ onChange }: { onChange?: ?Function } = { onChange: null }
) => (e: SyntheticInputEvent<*>) => {
let cardExpiry = e.target.value.split(' / ').join('/');
if (!this.props.autoFocus && cardExpiry.length > 5) {
cardExpiry = cardExpiry.substring(0, cardExpiry.length - 1);
}
this.cardExpiryField.value = formatExpiry(cardExpiry);
this.setFieldValid({ state: 'ccExpDateErrorText' });
const expiryError = isExpiryInvalid(cardExpiry);
if (expiryError && this.props.autoFocus) {
this.setFieldInvalid(expiryError, {
state: 'ccExpDateErrorText'
});
} else {
this.props.autoFocus && this.cvcField.focus();
}
const { cardExpiryInputProps } = this.props;
cardExpiryInputProps.onChange && cardExpiryInputProps.onChange(e);
onChange && onChange(e);
};
handleCardExpiryKeyPress = (e: any) => {
const value = e.target.value;
this.checkIsNumeric(e);
if (value && !isHighlighted()) {
const valueLength = value.split(' / ').join('').length;
if (valueLength >= 4) {
e.preventDefault();
}
}
};
handleCardCVCBlur = (
{ onBlur }: { onBlur?: ?Function } = { onBlur: null }
) => (e: SyntheticInputEvent<*>) => {
const cardType = payment.fns.cardType(this.cardNumberField.value);
let cvcIsValid = cardType
? payment.fns.validateCardCVC(e.target.value, cardType)
: payment.fns.validateCardCVC(e.target.value);
if (!cvcIsValid) {
let message = e.target.value.length
? 'Please enter a valid CSC'
: 'This is a required filed';
this.setFieldInvalid(message, {
state: 'ccCIDErrorText'
});
}
const { cardCVCInputProps } = this.props;
cardCVCInputProps.onBlur && cardCVCInputProps.onBlur(e);
onBlur && onBlur(e);
this.updateCIDMasked();
};
handleCardCVCChange = (
{ onChange }: { onChange?: ?Function } = { onChange: null }
) => (e: SyntheticInputEvent<*>) => {
let CVC = e.target.value;
let CVCLength = CVC.length;
const isZipFieldAvailable = this.props.enableZipInput && this.state.showZip;
const cardType = payment.fns.cardType(this.cardNumberField.value);
const cardTypeInfo =
creditCardType.getTypeInfo(creditCardType.types[CARD_TYPES[cardType]]) ||
{};
if (
Object.keys(cardTypeInfo).length &&
!this.props.autoFocus &&
CVCLength > cardTypeInfo.code.size
) {
CVC = CVC.substring(0, CVC.length - 1);
CVCLength--;
}
if (CVCLength) {
CVC = CVC.replace(/[^0-9]+/, '');
}
this.cvcField && (this.cvcField.value = CVC);
this.setFieldValid({
state: 'ccCIDErrorText'
});
if (!payment.fns.validateCardCVC(CVC, cardType) && this.props.autoFocus) {
this.setFieldInvalid('Please enter a valid CSC', {
state: 'ccCIDErrorText'
});
}
if (isZipFieldAvailable && hasCVCReachedMaxLength(cardType, CVCLength)) {
this.props.autoFocus && this.zipField.focus();
}
const { cardCVCInputProps } = this.props;
cardCVCInputProps.onChange && cardCVCInputProps.onChange(e);
onChange && onChange(e);
this.updateCIDMasked();
};
handleCardCVCKeyPress = (e: any) => {
const cardType = payment.fns.cardType(this.state.cardNumber);
const value = e.target.value;
this.checkIsNumeric(e);
if (value && !isHighlighted()) {
const valueLength = value.split(' / ').join('').length;
if (hasCVCReachedMaxLength(cardType, valueLength)) {
e.preventDefault();
}
}
};
updateCIDMasked = () => {
this.cvcMaskedField &&
(this.cvcMaskedField.value = this.cvcField.value.replace(/[0-9]/g, '*'));
};
handleCardZipBlur = (
{ onBlur }: { onBlur?: ?Function } = { onBlur: null }
) => (e: SyntheticInputEvent<*>) => {
if (!isValidZip(e.target.value)) {
this.setFieldInvalid('Zip code is invalid', {
state: 'ccZipErrorText'
});
}
const { cardZipInputProps } = this.props;
cardZipInputProps.onBlur && cardZipInputProps.onBlur(e);
onBlur && onBlur(e);
};
handleCardZipChange = (
{ onChange }: { onChange?: ?Function } = { onChange: null }
) => (e: SyntheticInputEvent<*>) => {
const zip = e.target.value;
const zipLength = zip.length;
this.setFieldValid({
state: 'ccZipErrorText'
});
if (zipLength >= 5 && !isValidZip(zip)) {
this.setFieldInvalid('Zip code is invalid', {
state: 'ccZipErrorText'
});
}
const { cardZipInputProps } = this.props;
cardZipInputProps.onChange && cardZipInputProps.onChange(e);
onChange && onChange(e);
};
handleCardZipKeyPress = (e: any) => {
const cardType = payment.fns.cardType(this.state.cardNumber);
const value = e.target.value;
this.checkIsNumeric(e);
if (value && !isHighlighted()) {
const valueLength = value.split(' / ').join('').length;
if (hasZipReachedMaxLength(cardType, valueLength)) {
e.preventDefault();
}
}
};
handleKeyDown = (ref: any) => {
return (e: SyntheticInputEvent<*>) => {
if (e.keyCode === BACKSPACE_KEY_CODE && !e.target.value) {
this.props.autoFocus && ref.focus();
}
};
};
setFieldInvalid = (errorText: string, mapState: Object) => {
const { invalidClassName, afterValidateCard } = this.props;
const mainWrapper = document.getElementById('field-wrapper');
mainWrapper && mainWrapper.classList.add(invalidClassName);
const fieldWrapper = document.getElementById(
mapState['state'].replace('ErrorText', '')
);
fieldWrapper && fieldWrapper.classList.add(invalidClassName);
this.setState({
errorText: this.translate(errorText),
[mapState['state']]: this.translate(errorText)
});
afterValidateCard && afterValidateCard(false);
};
setFieldValid = mapState => {
const { invalidClassName, afterValidateCard } = this.props;
const mainWrapper = document.getElementById('field-wrapper');
mainWrapper && mainWrapper.classList.remove(invalidClassName);
const fieldWrapper = document.getElementById(
mapState['state'].replace('ErrorText', '')
);
fieldWrapper && fieldWrapper.classList.remove(invalidClassName);
this.setState({ errorText: null, [mapState['state']]: null });
afterValidateCard && afterValidateCard(this.formIsValid(mapState['state']));
};
formIsValid = ignore => {
let errorList = {};
let requiredFieldValueList = [];
if (this.state.isCardMode) {
errorList = {
ccNumberErrorText: this.state.ccNumberErrorText,
ccExpDateErrorText: this.state.ccExpDateErrorText,
ccCIDErrorText: this.state.ccCIDErrorText,
ccZipErrorText: this.state.ccZipErrorText
};
requiredFieldValueList = [
this.cardNumberField.value,
this.cardExpiryField.value,
this.cvcField.value
];
} else {
errorList = {
ccEmailErrorText: this.state.ccEmailErrorText
};
requiredFieldValueList = [this.emailField.value];
}
ignore && delete errorList[ignore];
let isValid = true;
Object.values(errorList).forEach(errorText => {
isValid &= !errorText;
});
requiredFieldValueList.forEach(value => {
isValid &= !!value;
});
return isValid;
};
getType = () => {
let cardType = payment.fns.cardType(this.state.cardNumber);
return cardType && cardType.charAt(0).toUpperCase() + cardType.slice(1);
};
translate = word => {
return this.props.translator[word] || word;
};
render = () => {
const { cardImage, errorText, showZip } = this.state;
const {
cardImageClassName,
cardImageStyle,
cardCVCInputProps,
cardZipInputProps,
cardExpiryInputProps,
cardNumberInputProps,
cardCVCInputRenderer,
cardExpiryInputRenderer,
cardNumberInputRenderer,
cardZipInputRenderer,
containerClassName,
containerStyle,
showError,
dangerTextClassName,
dangerTextStyle,
enableZipInput,
fieldClassName,
fieldStyle,
inputClassName,
inputStyle,
invalidStyle
} = this.props;
const isZipEnabled = enableZipInput && showZip;
return (
<Container className={containerClassName} styled={containerStyle}>
<FieldWrapper
id="field-wrapper"
className={fieldClassName}
styled={fieldStyle}
invalidStyled={invalidStyle}
>
<CardImage
className={cardImageClassName}
styled={cardImageStyle}
src={cardImage}
/>
<InputWrapper
inputStyled={inputStyle}
data-max="9999 9999 9999 9999 9999"
translateXForZip={true}
>
{cardNumberInputRenderer({
handleCardNumberChange: onChange =>
this.handleCardNumberChange({ onChange }),
handleCardNumberBlur: onBlur =>
this.handleCardNumberBlur({ onBlur }),
props: {
id: 'card-number',
ref: cardNumberField => {
this.cardNumberField = cardNumberField;
},
autoComplete: 'cc-number',
className: `credit-card-input ${inputClassName}`,
pattern: '[0-9]*',
placeholder: 'Card number',
type: 'text',
...cardNumberInputProps,
onBlur: this.handleCardNumberBlur(),
onChange: this.handleCardNumberChange(),
onKeyPress: this.handleCardNumberKeyPress
}
})}
</InputWrapper>
<InputWrapper
inputStyled={inputStyle}
data-max="MM / YY 9"
translateXForZip={isZipEnabled}
>
{cardExpiryInputRenderer({
handleCardExpiryChange: onChange =>
this.handleCardExpiryChange({ onChange }),
handleCardExpiryBlur: onBlur =>
this.handleCardExpiryBlur({ onBlur }),
props: {
id: 'card-expiry',
ref: cardExpiryField => {
this.cardExpiryField = cardExpiryField;
},
autoComplete: 'cc-exp',
className: `credit-card-input ${inputClassName}`,
pattern: '[0-9]*',
placeholder: 'MM/YY',
type: 'text',
...cardExpiryInputProps,
onBlur: this.handleCardExpiryBlur(),
onChange: this.handleCardExpiryChange(),
onKeyDown: this.handleKeyDown(this.cardNumberField),
onKeyPress: this.handleCardExpiryKeyPress
}
})}
</InputWrapper>
<InputWrapper
inputStyled={inputStyle}
data-max="99999"
translateXForZip={isZipEnabled}
>
{cardCVCInputRenderer({
handleCardCVCChange: onChange =>
this.handleCardCVCChange({ onChange }),
handleCardCVCBlur: onBlur => this.handleCardCVCBlur({ onBlur }),
props: {
id: 'cvc',
ref: cvcField => {
this.cvcField = cvcField;
},
autoComplete: 'off',
className: `credit-card-input ${inputClassName}`,
pattern: '[0-9]*',
placeholder: 'CVC',
type: 'text',
...cardCVCInputProps,
onBlur: this.handleCardCVCBlur(),
onChange: this.handleCardCVCChange(),
onKeyDown: this.handleKeyDown(this.cardExpiryField),
onKeyPress: this.handleCardCVCKeyPress
}
})}
</InputWrapper>
<InputWrapper data-max="999999" translateXForZip={isZipEnabled}>
{cardZipInputRenderer({
handleCardZipChange: onChange =>
this.handleCardZipChange({ onChange }),
handleCardZipBlur: onBlur => this.handleCardZipBlur({ onBlur }),
props: {
id: 'zip',
ref: zipField => {
this.zipField = zipField;
},
className: `credit-card-input zip-input ${inputClassName}`,
pattern: '[0-9]*',
placeholder: 'Zip',
type: 'text',
...cardZipInputProps,
onBlur: this.handleCardZipBlur(),
onChange: this.handleCardZipChange(),
onKeyDown: this.handleKeyDown(this.cvcField),
onKeyPress: this.handleCardZipKeyPress
}
})}
</InputWrapper>
</FieldWrapper>
{showError &&
errorText && (
<DangerText
className={dangerTextClassName}
styled={dangerTextStyle}
>
{errorText}
</DangerText>
)}
</Container>
);
};
}