@anvilco/react-credit-card-input
Version:
A credit/debit card input field for React
642 lines (579 loc) • 18.7 kB
JavaScript
// @flow
import React, { Component } from 'react';
import payment from 'payment';
import creditCardType from 'credit-card-type';
import styled from 'styled-components';
import {
formatCardNumber,
formatExpiry,
formatCvc,
hasCardNumberReachedMaxLength,
hasCVCReachedMaxLength,
hasZipReachedMaxLength,
isHighlighted
} from './utils/formatter';
import images from './utils/images';
import isExpiryInvalid from './utils/is-expiry-invalid';
import isZipValid from './utils/is-zip-valid';
const Container = styled.div`
display: inline-block;
${({ styled }) => ({ ...styled })};
`;
const FieldWrapper = styled.div`
display: flex;
align-items: center;
position: relative;
background-color: white;
padding: 10px;
border-radius: 3px;
overflow: hidden;
${({ styled }) => ({ ...styled })};
&.is-invalid {
border: 1px solid #ff3860;
${({ invalidStyled }) => ({ ...invalidStyled })};
}
`;
const CardImage = styled.img`
height: 1em;
${({ styled }) => ({ ...styled })};
`;
const InputWrapper = styled.label`
align-items: center;
display: ${props => (props.isActive ? 'flex' : 'none')};
margin-left: 0.5em;
position: relative;
transition: transform 0.5s;
transform: translateX(${props => (props.translateX ? '4rem' : '0')});
&::after {
content: attr(data-max);
visibility: hidden;
height: 0;
}
& .credit-card-input {
border: 0px;
position: absolute;
width: 100%;
font-size: 1em;
${({ inputStyled }) => ({ ...inputStyled })};
&:focus {
outline: 0px;
}
}
& .zip-input {
display: ${props => (props.isZipActive ? 'flex' : 'none')};
}
`;
const DangerText = styled.p`
font-size: 0.8rem;
margin: 5px 0 0 0;
color: #ff3860;
${({ styled }) => ({ ...styled })};
`;
const BACKSPACE_KEY_CODE = 8;
const CARD_TYPES = {
mastercard: 'MASTERCARD',
visa: 'VISA',
amex: 'AMERICAN_EXPRESS'
};
type Props = {
CARD_TYPES: Object,
cardCVCInputRenderer: Function,
cardExpiryInputRenderer: Function,
cardNumberInputRenderer: Function,
cardZipInputRenderer: Function,
onError?: Function,
cardExpiryInputProps: Object,
cardNumberInputProps: Object,
cardCVCInputProps: Object,
cardZipInputProps: Object,
cardImageClassName: string,
cardImageStyle: Object,
containerClassName: string,
containerStyle: Object,
dangerTextClassName: string,
dangerTextStyle: Object,
fieldClassName: string,
fieldStyle: Object,
enableZipInput: boolean,
images: Object,
inputComponent: Function | Object | string,
inputClassName: string,
inputStyle: Object,
invalidClassName: string,
invalidStyle: Object,
customTextLabels: Object
};
type State = {
cardImage: string,
cardNumberLength: number,
cardNumber: ?string,
errorText: ?string,
showZip: boolean
};
const inputRenderer = ({ inputComponent, props }: Object) => {
const Input = inputComponent || 'input';
return <Input {...props} />;
};
class CreditCardInput extends Component<Props, State> {
cardExpiryField: any;
cardNumberField: any;
cvcField: any;
zipField: any;
static defaultProps = {
cardCVCInputRenderer: inputRenderer,
cardExpiryInputRenderer: inputRenderer,
cardNumberInputRenderer: inputRenderer,
cardZipInputRenderer: inputRenderer,
cardExpiryInputProps: {},
cardNumberInputProps: {},
cardCVCInputProps: {},
cardZipInputProps: {},
cardImageClassName: '',
cardImageStyle: {},
containerClassName: '',
containerStyle: {},
dangerTextClassName: '',
dangerTextStyle: {},
enableZipInput: false,
fieldClassName: '',
fieldStyle: {},
inputComponent: 'input',
inputClassName: '',
inputStyle: {},
invalidClassName: 'is-invalid',
invalidStyle: {},
customTextLabels: {}
};
constructor(props: Props) {
super(props);
this.CARD_TYPES = Object.assign({}, CARD_TYPES, props.CARD_TYPES);
this.images = Object.assign({}, images, props.images);
this.state = {
cardImage: this.images.placeholder,
cardNumberLength: 0,
cardNumber: null,
errorText: null,
showZip: false
};
}
componentDidMount = () => {
this.setState({ cardNumber: this.cardNumberField.value }, () => {
const cardType = payment.fns.cardType(this.state.cardNumber);
const images = this.images;
this.setState({
cardImage: images[cardType] || images.placeholder
});
});
};
isMonthDashKey = ({ key, target: { value } } = {}) => {
return !value.match(/[/-]/) && /^[/-]$/.test(key);
};
checkIsNumeric = (e: any) => {
if (!/^\d*$/.test(e.key)) {
e.preventDefault();
}
};
handleCardNumberBlur = (
{ onBlur }: { onBlur?: ?Function } = { onBlur: null }
) => (e: SyntheticInputEvent<*>) => {
const { customTextLabels } = this.props;
if (!payment.fns.validateCardNumber(e.target.value)) {
this.setFieldInvalid(
customTextLabels.invalidCardNumber || 'Card number is invalid',
'cardNumber'
);
}
const { cardNumberInputProps } = this.props;
cardNumberInputProps.onBlur && cardNumberInputProps.onBlur(e);
onBlur && onBlur(e);
};
handleCardNumberChange = (
{ onChange }: { onChange?: ?Function } = { onChange: null }
) => (e: SyntheticInputEvent<*>) => {
const {
customTextLabels,
enableZipInput,
cardNumberInputProps
} = this.props;
const images = this.images;
const cardNumber = e.target.value;
const cardNumberLength = cardNumber.split(' ').join('').length;
const cardType = payment.fns.cardType(cardNumber);
const cardTypeInfo =
creditCardType.getTypeInfo(
creditCardType.types[this.CARD_TYPES[cardType]]
) || {};
const cardTypeLengths = cardTypeInfo.lengths || [16];
this.cardNumberField.value = formatCardNumber(cardNumber);
this.setState({
cardImage: images[cardType] || images.placeholder,
cardNumber
});
if (enableZipInput) {
this.setState({ showZip: cardNumberLength >= 6 });
}
this.setFieldValid();
if (cardTypeLengths) {
const lastCardTypeLength = cardTypeLengths[cardTypeLengths.length - 1];
for (let length of cardTypeLengths) {
if (
length === cardNumberLength &&
payment.fns.validateCardNumber(cardNumber)
) {
this.cardExpiryField.focus();
break;
}
if (cardNumberLength === lastCardTypeLength) {
this.setFieldInvalid(
customTextLabels.invalidCardNumber || 'Card number is invalid',
'cardNumber'
);
}
}
}
cardNumberInputProps.onChange && cardNumberInputProps.onChange(e);
onChange && onChange(e);
};
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();
}
}
};
handleCardExpiryBlur = (
{ onBlur }: { onBlur?: ?Function } = { onBlur: null }
) => (e: SyntheticInputEvent<*>) => {
const { customTextLabels } = this.props;
const cardExpiry = e.target.value.split(' / ').join('/');
const expiryError = isExpiryInvalid(
cardExpiry,
customTextLabels.expiryError
);
if (expiryError) {
this.setFieldInvalid(expiryError, 'cardExpiry');
}
const { cardExpiryInputProps } = this.props;
cardExpiryInputProps.onBlur && cardExpiryInputProps.onBlur(e);
onBlur && onBlur(e);
};
handleCardExpiryChange = (
{ onChange }: { onChange?: ?Function } = { onChange: null }
) => (e: SyntheticInputEvent<*>) => {
const { customTextLabels } = this.props;
this.cardExpiryField.value = formatExpiry(e);
const value = this.cardExpiryField.value.split(' / ').join('/');
this.setFieldValid();
const expiryError = isExpiryInvalid(value, customTextLabels.expiryError);
if (value.length > 4) {
if (expiryError) {
this.setFieldInvalid(expiryError, 'cardExpiry');
} else {
this.cvcField.focus();
}
}
const { cardExpiryInputProps } = this.props;
cardExpiryInputProps.onChange && cardExpiryInputProps.onChange(e);
onChange && onChange(e);
};
handleCardExpiryKeyPress = (e: any) => {
const value = e.target.value;
if (!this.isMonthDashKey(e)) {
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 { customTextLabels } = this.props;
if (!payment.fns.validateCardCVC(e.target.value)) {
this.setFieldInvalid(
customTextLabels.invalidCvc || 'CVC is invalid',
'cardCVC'
);
}
const { cardCVCInputProps } = this.props;
cardCVCInputProps.onBlur && cardCVCInputProps.onBlur(e);
onBlur && onBlur(e);
};
handleCardCVCChange = (
{ onChange }: { onChange?: ?Function } = { onChange: null }
) => (e: SyntheticInputEvent<*>) => {
const { customTextLabels } = this.props;
const value = formatCvc(e.target.value);
this.cvcField.value = value;
const CVC = value;
const CVCLength = CVC.length;
const isZipFieldAvailable = this.props.enableZipInput && this.state.showZip;
const cardType = payment.fns.cardType(this.state.cardNumber);
this.setFieldValid();
if (CVCLength >= 4) {
if (!payment.fns.validateCardCVC(CVC, cardType)) {
this.setFieldInvalid(
customTextLabels.invalidCvc || 'CVC is invalid',
'cardCVC'
);
}
}
if (isZipFieldAvailable && hasCVCReachedMaxLength(cardType, CVCLength)) {
this.zipField.focus();
}
const { cardCVCInputProps } = this.props;
cardCVCInputProps.onChange && cardCVCInputProps.onChange(e);
onChange && onChange(e);
};
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();
}
}
};
handleCardZipBlur = (
{ onBlur }: { onBlur?: ?Function } = { onBlur: null }
) => (e: SyntheticInputEvent<*>) => {
const { customTextLabels } = this.props;
if (!isZipValid(e.target.value)) {
this.setFieldInvalid(
customTextLabels.invalidZipCode || 'Zip code is invalid',
'cardZip'
);
}
const { cardZipInputProps } = this.props;
cardZipInputProps.onBlur && cardZipInputProps.onBlur(e);
onBlur && onBlur(e);
};
handleCardZipChange = (
{ onChange }: { onChange?: ?Function } = { onChange: null }
) => (e: SyntheticInputEvent<*>) => {
const { customTextLabels } = this.props;
const zip = e.target.value;
const zipLength = zip.length;
this.setFieldValid();
if (zipLength >= 5 && !isZipValid(zip)) {
this.setFieldInvalid(
customTextLabels.invalidZipCode || 'Zip code is invalid',
'cardZip'
);
}
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) {
ref.focus();
}
};
};
setFieldInvalid = (errorText: string, inputName?: string) => {
const { invalidClassName, onError } = this.props;
// $FlowFixMe
document.getElementById('field-wrapper').classList.add(invalidClassName);
this.setState({ errorText });
if (inputName) {
const { onError } = this.props[`${inputName}InputProps`];
onError && onError(errorText);
}
if (onError) {
onError({ inputName, error: errorText });
}
};
setFieldValid = () => {
const { invalidClassName } = this.props;
// $FlowFixMe
document.getElementById('field-wrapper').classList.remove(invalidClassName);
this.setState({ errorText: null });
};
render = () => {
const { cardImage, errorText, showZip } = this.state;
const {
cardImageClassName,
cardImageStyle,
cardCVCInputProps,
cardZipInputProps,
cardExpiryInputProps,
cardNumberInputProps,
cardCVCInputRenderer,
cardExpiryInputRenderer,
cardNumberInputRenderer,
cardZipInputRenderer,
containerClassName,
containerStyle,
dangerTextClassName,
dangerTextStyle,
enableZipInput,
fieldClassName,
fieldStyle,
inputClassName,
inputComponent,
inputStyle,
invalidStyle,
customTextLabels
} = this.props;
return (
<Container className={containerClassName} style={containerStyle}>
<FieldWrapper
id="field-wrapper"
className={fieldClassName}
styled={fieldStyle}
invalidStyled={invalidStyle}
>
<CardImage
className={cardImageClassName}
styled={cardImageStyle}
src={cardImage}
/>
<InputWrapper
inputStyled={inputStyle}
isActive
translateX={false}
data-max="9999 9999 9999 9999 9999"
>
{cardNumberInputRenderer({
inputComponent,
handleCardNumberChange: onChange =>
this.handleCardNumberChange({ onChange }),
handleCardNumberBlur: onBlur =>
this.handleCardNumberBlur({ onBlur }),
props: {
id: 'card-number',
ref: cardNumberField => {
this.cardNumberField = cardNumberField;
},
maxLength: '19',
autoComplete: 'cc-number',
className: `credit-card-input ${inputClassName}`,
placeholder:
customTextLabels.cardNumberPlaceholder || 'Card number',
type: 'tel',
...cardNumberInputProps,
onBlur: this.handleCardNumberBlur(),
onChange: this.handleCardNumberChange(),
onKeyPress: this.handleCardNumberKeyPress
}
})}
</InputWrapper>
<InputWrapper
inputStyled={inputStyle}
isActive
data-max="MM / YY 9"
translateX={enableZipInput && !showZip}
>
{cardExpiryInputRenderer({
inputComponent,
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}`,
placeholder: customTextLabels.expiryPlaceholder || 'MM/YY',
type: 'tel',
...cardExpiryInputProps,
onBlur: this.handleCardExpiryBlur(),
onChange: this.handleCardExpiryChange(),
onKeyDown: this.handleKeyDown(this.cardNumberField),
onKeyPress: this.handleCardExpiryKeyPress
}
})}
</InputWrapper>
<InputWrapper
inputStyled={inputStyle}
isActive
data-max="99999"
translateX={enableZipInput && !showZip}
>
{cardCVCInputRenderer({
inputComponent,
handleCardCVCChange: onChange =>
this.handleCardCVCChange({ onChange }),
handleCardCVCBlur: onBlur => this.handleCardCVCBlur({ onBlur }),
props: {
id: 'cvc',
ref: cvcField => {
this.cvcField = cvcField;
},
maxLength: '5',
autoComplete: 'off',
className: `credit-card-input ${inputClassName}`,
placeholder: customTextLabels.cvcPlaceholder || 'CVC',
type: 'tel',
...cardCVCInputProps,
onBlur: this.handleCardCVCBlur(),
onChange: this.handleCardCVCChange(),
onKeyDown: this.handleKeyDown(this.cardExpiryField),
onKeyPress: this.handleCardCVCKeyPress
}
})}
</InputWrapper>
<InputWrapper
data-max="999999"
isActive={enableZipInput}
isZipActive={showZip}
translateX={enableZipInput && !showZip}
>
{cardZipInputRenderer({
inputComponent,
handleCardZipChange: onChange =>
this.handleCardZipChange({ onChange }),
handleCardZipBlur: onBlur => this.handleCardZipBlur({ onBlur }),
props: {
id: 'zip',
ref: zipField => {
this.zipField = zipField;
},
maxLength: '6',
className: `credit-card-input zip-input ${inputClassName}`,
pattern: '[0-9]*',
placeholder: customTextLabels.zipPlaceholder || 'Zip',
type: 'text',
...cardZipInputProps,
onBlur: this.handleCardZipBlur(),
onChange: this.handleCardZipChange(),
onKeyDown: this.handleKeyDown(this.cvcField),
onKeyPress: this.handleCardZipKeyPress
}
})}
</InputWrapper>
</FieldWrapper>
{errorText && (
<DangerText className={dangerTextClassName} styled={dangerTextStyle}>
{errorText}
</DangerText>
)}
</Container>
);
};
}
export default CreditCardInput;