UNPKG

@anvilco/react-credit-card-input

Version:
642 lines (579 loc) 18.7 kB
// @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;