UNPKG

@fanatics-ui/react-credit-card-input

Version:

A React component for credit/debit card input - it was inspired from react-credit-card-input, authored by jxom <jake@medipass.io> (https://medipass.com.au)

612 lines (559 loc) 18.7 kB
import React, { Component } from 'react'; import payment from 'payment'; import creditCardType from 'credit-card-type'; import { DEFAULT_CARD_NUMBER_MAX_LENGTH, DEFAULT_CVC_LENGTH, formatExpiry, formatCvc, hasCardNumberReachedMaxLength, hasCVCReachedMaxLength, isHighlighted } from './utils/formatter'; import images from './utils/images'; import isExpiryInvalid from './utils/is-expiry-invalid'; // SassMeister | The Sass Playground! // https://www.sassmeister.com/ // // Four ways to style react components // https://codeburst.io/4-four-ways-to-style-react-components-ac6f323da822 const styles = { container: { display: 'inline-block' }, fieldWrapper: { display: 'flex', alignItems: 'center', position: 'relative', backgroundColor: 'white', padding: '10px', borderRadius: '3px', overflow: 'hidden' }, isInvalid: { border: '1px solid #ff3860' }, cardImage: { height: '1.5em', zIndex: 2, width: '2em' }, inputWrapper: { alignItems: 'center', marginLeft: '0.5em', // position: 'relative', display: 'flex', transition: 'transform 0.5s', transform: 'translateX(0)', height: '1.1em', overflow: 'hidden' }, inputWrapperPsedoAfter: { // https://stackoverflow.com/questions/43701748/react-pseudo-selector-inline-styling // https://stackoverflow.com/questions/45730224/css-pseudo-code-libefore-in-react-inline-style // https://blog.logrocket.com/the-best-react-inline-style-libraries-comparing-radium-aphrodite-emotion-849ef148c473 // https://medium.com/@pioul/modular-css-with-react-61638ae9ea3e // https://stackoverflow.com/questions/28269669/css-pseudo-elements-in-react/28269950 visibility: 'hidden', height: 0 }, creditCardInput: { border: '0px', minWidth: '100%', fontSize: '1em', outline: '0px' }, dangerText: { fontSize: '0.8rem', margin: '5px 0 0 0', color: '#ff3860' } }; const BACKSPACE_KEY_CODE = 8; const CARD_TYPES = { mastercard: 'MASTERCARD', visa: 'VISA', amex: 'AMERICAN_EXPRESS', dinersclub: 'DINERS_CLUB', discover: 'DISCOVER' }; class Cursor { constructor(event) { this.cursorStart = event.target.selectionStart; this.cursorEnd = event.target.selectionEnd; this.event = event; } isSpace(value) { return value.charAt(this.cursorStart - 1) === ' '; } setSelectionRange(value) { const currentCursor = this.event.target.selectionStart; if (currentCursor - this.cursorStart > 2) { const isSpace = this.isSpace(value); const eventData = this.event.nativeEvent && this.event.nativeEvent.data; const cursorStart = isSpace && eventData ? this.cursorStart + 1 : this.cursorStart; const cursorEnd = isSpace && eventData ? this.cursorEnd + 1 : this.cursorEnd; this.event.target.setSelectionRange(cursorStart, cursorEnd); } } } const removeObjectKey = (obj, keyName) => { return Object.entries(obj) .filter(([key, value]) => key !== keyName) .reduce((memo, [key, value]) => Object.assign(memo, { [key]: value }), {}); }; const extractNumbers = str => { return ((str || '').match(/\d+/g) || []) .join('') .substr(0, DEFAULT_CARD_NUMBER_MAX_LENGTH); }; const inputRenderer = ({ props }, style = {}) => ( <input style={Object.assign({}, styles.creditCardInput, style)} {...props} /> ); class CreditCardInput extends Component { static defaultProps = { cardCVCInputRenderer: inputRenderer, cardExpiryInputRenderer: inputRenderer, cardNumberInputRenderer: inputRenderer, cardExpiryInputProps: {}, cardNumberInputProps: {}, cardCVVInputProps: {}, cardImageClassName: '', cardImageStyle: {}, containerClassName: '', containerStyle: {}, dangerTextClassName: '', dangerTextStyle: {}, fieldClassName: '', fieldStyle: {}, inputComponent: 'input', inputClassName: '', inputStyle: {}, invalidStyle: { border: '1px solid #ff3860' }, customTextLabels: {} }; constructor(props) { super(props); this.cardExpiryField = null; this.cardNumberField = null; this.cvcField = null; this.state = { cardImage: images.placeholder, cardNumberLength: 0, cardNumber: null, errorText: null, errors: {} }; } componentDidMount = () => { this.setState({ cardNumber: this.cardNumberField.value }, () => { const cardType = payment.fns.cardType(this.state.cardNumber); this.setState({ cardImage: images[cardType] || images.placeholder }); }); }; isMonthDashKey = ({ key, target: { value } } = {}) => { return !value.match(/[/-]/) && /^[/-]$/.test(key); }; checkIsNumeric = e => { if (!/^\d*$/.test(e.key)) { e.preventDefault(); } }; handleCardNumberBlur = ({ onBlur } = { onBlur: null }) => e => { const { customTextLabels } = this.props; if (!payment.fns.validateCardNumber(e.target.value)) { this.setFieldInvalid( customTextLabels.invalidCardNumber || 'Card number is invalid', 'cardNumber' ); } else { this.setOtherFieldInvalidIfNeeded(); } const { cardNumberInputProps } = this.props; cardNumberInputProps.onBlur && cardNumberInputProps.onBlur(e); onBlur && onBlur(e); }; handleCardNumberFocus = ({ onFocus } = { onFocus: null }) => e => { const { cardNumberInputProps } = this.props; this.setFieldValid('cardNumber'); cardNumberInputProps.onFocus && cardNumberInputProps.onFocus(e); onFocus && onFocus(e); }; handleCardNumberChange = ({ onChange } = { onChange: null }) => e => { const { customTextLabels, cardNumberInputProps } = this.props; const cursor = new Cursor(e); const cardNumber = extractNumbers(e.target.value); const cardNumberLength = cardNumber.split(' ').join('').length; const cardType = payment.fns.cardType(cardNumber); const cardTypeInfo = creditCardType.getTypeInfo( creditCardType.types[CARD_TYPES[cardType]] ) || { code: { size: DEFAULT_CVC_LENGTH } }; const cardTypeLengths = cardTypeInfo.lengths || [ DEFAULT_CARD_NUMBER_MAX_LENGTH ]; if (this.cvcField && this.cvcField.value) { const codeSize = cardTypeInfo.code.size; this.cvcField.value = this.cvcField.value.substr(0, codeSize); if (!payment.fns.validateCardCVC(this.cvcField.value, cardType)) { this.setFieldInvalid( customTextLabels.invalidCvc || 'CVV is invalid', 'cardCVV' ); } } this.cardNumberField.value = payment.fns.formatCardNumber(cardNumber); cursor.setSelectionRange(this.cardNumberField.value); this.setState({ cardImage: images[cardType] || images.placeholder, cardNumber }); this.setFieldValid('cardNumber'); if (cardTypeLengths) { const lastCardTypeLength = Math.min( DEFAULT_CARD_NUMBER_MAX_LENGTH, cardTypeLengths[cardTypeLengths.length - 1] ); if ( cardTypeLengths.includes(cardNumberLength) && payment.fns.validateCardNumber(cardNumber) ) { this.cardExpiryField.focus(); } else if (cardNumberLength >= lastCardTypeLength) { this.setFieldInvalid( customTextLabels.invalidCardNumber || 'Card number is invalid', 'cardNumber' ); } } cardNumberInputProps.onChange && cardNumberInputProps.onChange(e); onChange && onChange(e); }; handleCardNumberKeyPress = e => { 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: null }) => e => { const { customTextLabels } = this.props; const cardExpiry = e.target.value.split(' / ').join('/'); const expiryError = isExpiryInvalid( cardExpiry, customTextLabels.expiryError ); if (expiryError) { this.setFieldInvalid(expiryError, 'cardExpiry'); } else { this.setOtherFieldInvalidIfNeeded(); } const { cardExpiryInputProps } = this.props; cardExpiryInputProps.onBlur && cardExpiryInputProps.onBlur(e); onBlur && onBlur(e); }; handleCardExpiryFocus = ({ onFocus } = { onFocus: null }) => e => { const { cardExpiryInputProps } = this.props; this.setFieldValid('cardExpiry'); cardExpiryInputProps.onFocus && cardExpiryInputProps.onFocus(e); onFocus && onFocus(e); }; handleCardExpiryChange = ({ onChange } = { onChange: null }) => e => { const { customTextLabels } = this.props; this.cardExpiryField.value = formatExpiry(e); this.setFieldValid('cardExpiry'); const value = this.cardExpiryField.value.split(' / ').join('/'); 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 => { 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: null }) => e => { const { customTextLabels } = this.props; const cardType = payment.fns.cardType(this.state.cardNumber); if (!payment.fns.validateCardCVC(e.target.value, cardType)) { this.setFieldInvalid( customTextLabels.invalidCvc || 'CVV is invalid', 'cardCVV' ); } else { this.setOtherFieldInvalidIfNeeded(); } const { cardCVVInputProps } = this.props; cardCVVInputProps.onBlur && cardCVVInputProps.onBlur(e); onBlur && onBlur(e); }; handleCardCVCFocus = ({ onFocus } = { onFocus: null }) => e => { const { cardCVVInputProps } = this.props; this.setFieldValid('cardCVV'); cardCVVInputProps.onFocus && cardCVVInputProps.onFocus(e); onFocus && onFocus(e); }; handleCardCVCChange = ({ onChange } = { onChange: null }) => e => { const { customTextLabels } = this.props; const value = formatCvc(e.target.value); this.cvcField.value = value; const CVC = value; const CVCLength = CVC.length; const cardType = payment.fns.cardType(this.state.cardNumber); this.setFieldValid('cardCVV'); if (CVCLength >= 4) { if (!payment.fns.validateCardCVC(CVC, cardType)) { this.setFieldInvalid( customTextLabels.invalidCvc || 'CVV is invalid', 'cardCVV' ); } } const { cardCVVInputProps } = this.props; cardCVVInputProps.onChange && cardCVVInputProps.onChange(e); onChange && onChange(e); }; handleCardCVCKeyPress = e => { 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(); } } }; handleKeyDown = ref => { return e => { if (e.keyCode === BACKSPACE_KEY_CODE && !e.target.value) { ref.focus(); } }; }; setOtherFieldInvalidIfNeeded = () => { const errors = this.state.errors; const [inputName, errorText = null] = Object.entries(errors)[0] || []; errorText && this.setFieldInvalid(errorText, inputName); }; setFieldInvalid = (errorText, inputName) => { const { onError } = this.props; this.setState({ errorText, isFormInvalid: true }); if (inputName) { const { onError } = this.props[`${inputName}InputProps`]; this.setState({ errors: Object.assign({ ...this.state.errors, [inputName]: errorText }) }); onError && onError(errorText); } if (onError) { onError({ inputName, error: errorText }); } }; setFieldValid = inputName => { this.setState(state => { const errors = removeObjectKey(state.errors, inputName); return { ...state, errors, errorText: null, isFormInvalid: false }; }); }; render = () => { const { cardImage, errors } = this.state; const { cardImageClassName, cardImageStyle, cardCVVInputProps, cardExpiryInputProps, cardNumberInputProps, cardCVCInputRenderer, cardExpiryInputRenderer, cardNumberInputRenderer, containerClassName, containerStyle, dangerTextClassName, dangerTextStyle, errorText, fieldClassName, fieldStyle, inputClassName, inputStyle, invalidStyle, customTextLabels, setFieldInvalid } = this.props; return ( <div className={containerClassName} style={Object.assign({}, styles.container, containerStyle)} > <div className={fieldClassName} style={Object.assign( {}, styles.fieldWrapper, fieldStyle, (!!errors.cardNumber || setFieldInvalid) && invalidStyle )} > <img alt="credit card flag" className={cardImageClassName} style={Object.assign({}, styles.cardImage, cardImageStyle)} src={cardImage} /> <label style={Object.assign({}, styles.inputWrapper, inputStyle)} className="card-number-wrapper" data-max="9999 9999 9999 9999 9999" > {cardNumberInputRenderer({ handleCardNumberChange: onChange => this.handleCardNumberChange({ onChange }), handleCardNumberBlur: onBlur => this.handleCardNumberBlur({ onBlur }), handleCardNumberFocus: onFocus => this.handleCardNumberFocus({ onFocus }), props: { id: 'card-number', ref: cardNumberField => { this.cardNumberField = cardNumberField; }, autoComplete: 'cc-number', className: `credit-card-input ${inputClassName}`, placeholder: customTextLabels.cardNumberPlaceholder || 'Card Number', type: 'tel', ...cardNumberInputProps, onBlur: this.handleCardNumberBlur(), onFocus: this.handleCardNumberFocus(), onChange: this.handleCardNumberChange(), onKeyPress: this.handleCardNumberKeyPress } })} <label style={styles.inputWrapperPsedoAfter}> 9999 9999 9999 9999 9999 </label> </label> </div> <div className={fieldClassName} style={Object.assign( { margin: '10px 0 0 0' }, styles.fieldWrapper, fieldStyle, (!!errors.cardExpiry || !!errors.cardCVV || setFieldInvalid) && invalidStyle )} > <label style={Object.assign({}, styles.inputWrapper, inputStyle)} className="card-expiry-wrapper" data-max="MM / YY 9" > {cardExpiryInputRenderer({ handleCardExpiryChange: onChange => this.handleCardExpiryChange({ onChange }), handleCardExpiryBlur: onBlur => this.handleCardExpiryBlur({ onBlur }), handleCardExpiryFocus: onFocus => this.handleCardExpiryFocus({ onFocus }), 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(), onFocus: this.handleCardExpiryFocus(), onChange: this.handleCardExpiryChange(), onKeyDown: this.handleKeyDown(this.cardNumberField), onKeyPress: this.handleCardExpiryKeyPress } })} <label style={styles.inputWrapperPsedoAfter}>MM / YY 9</label> </label> <label style={Object.assign({}, styles.inputWrapper, inputStyle)} className="card-cvc-wrapper" data-max="99999" > {cardCVCInputRenderer({ handleCardCVCChange: onChange => this.handleCardCVCChange({ onChange }), handleCardCVCBlur: onBlur => this.handleCardCVCBlur({ onBlur }), handleCardCVCFocus: onFocus => this.handleCardCVCFocus({ onFocus }), props: { id: 'cvc', ref: cvcField => { this.cvcField = cvcField; }, maxLength: '5', autoComplete: 'off', className: `credit-card-input ${inputClassName}`, placeholder: customTextLabels.cvcPlaceholder || 'CVV', type: 'tel', ...cardCVVInputProps, onBlur: this.handleCardCVCBlur(), onFocus: this.handleCardCVCFocus(), onChange: this.handleCardCVCChange(), onKeyDown: this.handleKeyDown(this.cardExpiryField), onKeyPress: this.handleCardCVCKeyPress } })} <label style={styles.inputWrapperPsedoAfter}>99999</label> </label> </div> {(!!errors.cardNumber || !!errors.cardExpiry || !!errors.cardCVV || errorText) && ( <p className={dangerTextClassName} style={Object.assign({}, styles.dangerText, dangerTextStyle)} > {errors.cardNumber || errors.cardExpiry || errors.cardCVV || errorText} </p> )} </div> ); }; } export default CreditCardInput;