diff --git a/package.json b/package.json index 8d8002b..b2096b2 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "rimraf": "^2.6.2", "rollup": "^0.54.1", "rollup-plugin-babel": "^3.0.3", + "rollup-plugin-node-resolve": "^4.2.3", "styled-components": "4.2.0" }, "peerDependencies": { diff --git a/rollup.config.js b/rollup.config.js index 81e7041..f43c4f5 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,33 +1,47 @@ import babel from 'rollup-plugin-babel'; +// https://github.com/rollup/rollup/issues/346 +import resolve from 'rollup-plugin-node-resolve'; -export default { +const globals = { + react: 'React', + payment: 'payment', + 'credit-card-type': 'creditCardType', + 'styled-components': 'styled' +}; + +const external = [ + 'react', + 'credit-card-type', + 'payment', + 'styled-components' +]; + +const plugins = [ + resolve({}), + babel({ + presets: [['env', { modules: false }], 'react', 'flow'], + plugins: [ + 'external-helpers', + 'transform-class-properties', + 'transform-object-rest-spread' + ] + }) +] + +export default [{ input: `${__dirname}/src/index.js`, output: { name: 'CreditCardInput', file: `${__dirname}/lib/index.js`, format: 'umd', - globals: { - react: 'React', - payment: 'payment', - 'credit-card-type': 'creditCardType', - 'styled-components': 'styled' - } - }, - sourcemap: true, - external: [ - 'react', - 'credit-card-type', - 'payment', - 'styled-components' - ], - plugins: [ - babel({ - presets: [['env', { modules: false }], 'react', 'flow'], - plugins: [ - 'external-helpers', - 'transform-class-properties', - 'transform-object-rest-spread' - ] - }) - ] -}; + globals + } +}, { + input: `${__dirname}/src/hooks/use-credit-card-input.js`, + output: { + name: 'useCreditCardInput', + file: `${__dirname}/lib/useCreditCardInput.js`, + format: 'umd', + globals + } +}].map(output => Object.assign(output, {external, plugins, sourcemap: true})); diff --git a/src/credit-card-input.js b/src/credit-card-input.js new file mode 100644 index 0000000..a316032 --- /dev/null +++ b/src/credit-card-input.js @@ -0,0 +1,214 @@ +// @flow + +import React, { useEffect } from 'react'; +import styled from 'styled-components'; +import useCreditCardInput from './hooks/use-credit-card-input'; + +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 })}; +`; + +type CreditCardInputProps = { + cardTypes: Object, + 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, + images: Object, + inputClassName: string, + inputStyle: Object, + invalidClassName: string, + invalidStyle: Object, + customTextLabels: Object +}; + +const defaultProps: CreditCardInputProps = { + cardExpiryInputProps: {}, + cardNumberInputProps: {}, + cardCVCInputProps: {}, + cardZipInputProps: {}, + cardImageClassName: '', + cardImageStyle: {}, + containerClassName: '', + containerStyle: {}, + dangerTextClassName: '', + dangerTextStyle: {}, + fieldClassName: '', + fieldStyle: {}, + inputClassName: '', + inputStyle: {}, + invalidClassName: 'is-invalid', + invalidStyle: {}, + customTextLabels: {} +}; + +const CreditCardInput = (props: CreditCardInputProps) => { + const { + state, + cardNumberProps, + cardExpiryProps, + cardCvcProps, + cardZipProps + } = useCreditCardInput(props); + + const { + cardImageClassName, + cardImageStyle, + containerClassName, + containerStyle, + dangerTextClassName, + dangerTextStyle, + fieldClassName, + fieldStyle, + inputStyle, + invalidClassName, + invalidStyle + } = props; + + const { cardImage, enableZipInput, errorText, showZip } = state; + + const setFieldInvalid = (errorText: string) => { + // $FlowFixMe + document.getElementById('field-wrapper').classList.add(invalidClassName); + }; + + const setFieldValid = () => { + // $FlowFixMe + document.getElementById('field-wrapper').classList.remove(invalidClassName); + }; + + useEffect(() => { + if (errorText) { + setFieldInvalid(errorText); + } else { + setFieldValid(); + } + }, [errorText]); + + return ( + + + + + + + + + + + + + + + + + {errorText && ( + + {errorText} + + )} + + ); +}; + +CreditCardInput.defaultProps = defaultProps; + +export default CreditCardInput; diff --git a/src/events/events.js b/src/events/events.js new file mode 100644 index 0000000..4272766 --- /dev/null +++ b/src/events/events.js @@ -0,0 +1,30 @@ +export default function() { + var topics = {}; + var hOP = topics.hasOwnProperty; + + return { + subscribe: function(topic, listener) { + // Create the topic's object if not yet created + if (!hOP.call(topics, topic)) topics[topic] = []; + + // Add the listener to queue + var index = topics[topic].push(listener) - 1; + + // Provide handle back for removal of topic + return { + remove: function() { + delete topics[topic][index]; + } + }; + }, + publish: function(topic, info) { + // If the topic doesn't exist, or there's no listeners in queue, just leave + if (!hOP.call(topics, topic)) return; + + // Cycle through topics queue, fire! + topics[topic].forEach(function(item) { + item(info !== undefined ? info : {}); + }); + } + }; +} diff --git a/src/events/index.js b/src/events/index.js new file mode 100644 index 0000000..b890785 --- /dev/null +++ b/src/events/index.js @@ -0,0 +1,9 @@ +import events from './events'; +import * as types from './types'; + +export default function() { + return { + ...events(), + types + }; +} diff --git a/src/events/types.js b/src/events/types.js new file mode 100644 index 0000000..3ee911e --- /dev/null +++ b/src/events/types.js @@ -0,0 +1,4 @@ +export const FOCUS_ON_CARD_NUMBER = Symbol('FOCUS_ON_CARD_NUMBER'); +export const FOCUS_ON_CARD_EXPIRY = Symbol('FOCUS_ON_CARD_EXPIRY'); +export const FOCUS_ON_CARD_CVC = Symbol('FOCUS_ON_CARD_CVC'); +export const FOCUS_ON_CARD_ZIP = Symbol('FOCUS_ON_CARD_ZIP'); diff --git a/src/hooks/use-card-cvc-input.js b/src/hooks/use-card-cvc-input.js new file mode 100644 index 0000000..3da6cec --- /dev/null +++ b/src/hooks/use-card-cvc-input.js @@ -0,0 +1,126 @@ +// @flow + +import { useEffect, useRef } from 'react'; +import payment from 'payment'; +import { + formatCvc, + hasCVCReachedMaxLength, + isHighlighted +} from '../utils/formatter'; +import checkIsNumeric from '../utils/check-is-numeric'; +import handleKeyDown from '../utils/handle-key-down'; +import { CHANGE_ERROR_TEXT } from '../reducer/actions'; + +type CardCVCInputProps = { + cardCVCInputProps: Object, + dispatch: Function, + state: Object +}; + +const defaultProps: CardCVCInputProps = { + cardCVCInputProps: {} +}; + +const useCardCVCInput = (props: CardCVCInputProps = defaultProps) => { + const cvcField = useRef(null); + const { cardCVCInputProps, dispatch, state } = props; + const { + cardNumber, + customTextLabels, + enableZipInput, + events, + inputClassName, + showZip + } = state; + + useEffect(() => { + const subscription = events.subscribe( + events.types.FOCUS_ON_CARD_CVC, + () => { + cvcField.current.focus(); + } + ); + + return () => { + subscription.remove(); + }; + }, []); + + const setFieldInvalid = (errorText: string) => { + const { onError } = cardCVCInputProps; + dispatch({ type: CHANGE_ERROR_TEXT, errorText }); + if (onError) { + onError({ error: errorText }); + } + }; + + const setFieldValid = () => { + dispatch({ type: CHANGE_ERROR_TEXT, errorText: null }); + }; + + const handleCardCVCBlur = ( + { onBlur }: { onBlur?: ?Function } = { onBlur: null } + ) => (e: SyntheticInputEvent<*>) => { + if (!payment.fns.validateCardCVC(e.target.value)) { + setFieldInvalid(customTextLabels.invalidCvc || 'CVC is invalid'); + } + cardCVCInputProps.onBlur && cardCVCInputProps.onBlur(e); + onBlur && onBlur(e); + }; + + const handleCardCVCChange = ( + { onChange }: { onChange?: ?Function } = { onChange: null } + ) => (e: SyntheticInputEvent<*>) => { + const value = formatCvc(e.target.value); + cvcField.current.value = value; + const CVC = value; + const CVCLength = CVC.length; + const isZipFieldAvailable = enableZipInput && showZip; + const cardType = payment.fns.cardType(cardNumber); + + setFieldValid(); + if (CVCLength >= 4) { + if (!payment.fns.validateCardCVC(CVC, cardType)) { + setFieldInvalid(customTextLabels.invalidCvc || 'CVC is invalid'); + } + } + + if (isZipFieldAvailable && hasCVCReachedMaxLength(cardType, CVCLength)) { + events.publish(events.types.FOCUS_ON_CARD_ZIP); + } + + cardCVCInputProps.onChange && cardCVCInputProps.onChange(e); + onChange && onChange(e); + }; + + const handleCardCVCKeyPress = (e: any) => { + const cardType = payment.fns.cardType(cardNumber); + const value = e.target.value; + checkIsNumeric(e); + if (value && !isHighlighted()) { + const valueLength = value.split(' / ').join('').length; + if (hasCVCReachedMaxLength(cardType, valueLength)) { + e.preventDefault(); + } + } + }; + + return { + id: 'cvc', + ref: cvcField, + maxLength: '5', + autoComplete: 'off', + className: `credit-card-input ${inputClassName}`, + placeholder: customTextLabels.cvcPlaceholder || 'CVC', + type: 'tel', + ...cardCVCInputProps, + onBlur: handleCardCVCBlur(), + onChange: handleCardCVCChange(), + onKeyDown: handleKeyDown(() => { + events.publish(events.types.FOCUS_ON_CARD_EXPIRY); + }), + onKeyPress: handleCardCVCKeyPress + }; +}; + +export default useCardCVCInput; diff --git a/src/hooks/use-card-expiry-input.js b/src/hooks/use-card-expiry-input.js new file mode 100644 index 0000000..2b56ce7 --- /dev/null +++ b/src/hooks/use-card-expiry-input.js @@ -0,0 +1,120 @@ +// @flow + +import { useEffect, useRef } from 'react'; +import { CHANGE_ERROR_TEXT } from '../reducer/actions'; +import { formatExpiry, isHighlighted } from '../utils/formatter'; +import isExpiryInvalid from '../utils/is-expiry-invalid'; +import checkIsNumeric from '../utils/check-is-numeric'; +import handleKeyDown from '../utils/handle-key-down'; +import isMonthDashKey from '../utils/is-month-dash-key'; + +type CardExpiryInputProps = { + cardExpiryInputProps: Object, + dispatch: Function, + state: Object +}; + +const defaultProps: CardExpiryInputProps = { + cardExpiryInputProps: {} +}; + +const useCardExpiryInput = (props: CardExpiryInputProps = defaultProps) => { + const cardExpiryField = useRef(null); + const { cardExpiryInputProps, dispatch, state } = props; + const { customTextLabels, events, inputClassName } = state; + + useEffect(() => { + const subscription = events.subscribe( + events.types.FOCUS_ON_CARD_EXPIRY, + () => { + cardExpiryField.current.focus(); + } + ); + + return () => { + subscription.remove(); + }; + }, []); + + const setFieldInvalid = (errorText: string) => { + const { onError } = cardExpiryInputProps; + dispatch({ type: CHANGE_ERROR_TEXT, errorText }); + if (onError) { + onError({ error: errorText }); + } + }; + + const setFieldValid = () => { + dispatch({ type: CHANGE_ERROR_TEXT, errorText: null }); + }; + + const handleCardExpiryBlur = ( + { onBlur }: { onBlur?: ?Function } = { onBlur: null } + ) => (e: SyntheticInputEvent<*>) => { + const cardExpiry = e.target.value.split(' / ').join('/'); + const expiryError = isExpiryInvalid( + cardExpiry, + customTextLabels.expiryError + ); + if (expiryError) { + setFieldInvalid(expiryError); + } + + cardExpiryInputProps.onBlur && cardExpiryInputProps.onBlur(e); + onBlur && onBlur(e); + }; + + const handleCardExpiryChange = ( + { onChange }: { onChange?: ?Function } = { onChange: null } + ) => (e: SyntheticInputEvent<*>) => { + cardExpiryField.current.value = formatExpiry(e); + const value = cardExpiryField.current.value.split(' / ').join('/'); + + setFieldValid(); + + const expiryError = isExpiryInvalid(value, customTextLabels.expiryError); + if (value.length > 4) { + if (expiryError) { + setFieldInvalid(expiryError); + } else { + events.publish(events.types.FOCUS_ON_CARD_CVC); + } + } + + cardExpiryInputProps.onChange && cardExpiryInputProps.onChange(e); + onChange && onChange(e); + }; + + const handleCardExpiryKeyPress = (e: any) => { + const value = e.target.value; + + if (!isMonthDashKey(e)) { + checkIsNumeric(e); + } + + if (value && !isHighlighted()) { + const valueLength = value.split(' / ').join('').length; + if (valueLength >= 4) { + e.preventDefault(); + } + } + }; + + return { + id: 'card-expiry', + ref: cardExpiryField, + autoComplete: 'cc-exp', + className: `credit-card-input ${inputClassName}`, + placeholder: customTextLabels.expiryPlaceholder || 'MM/YY', + type: 'tel', + ...cardExpiryInputProps, + onBlur: handleCardExpiryBlur(), + onChange: handleCardExpiryChange(), + onKeyDown: handleKeyDown(() => { + events.publish(events.types.FOCUS_ON_CARD_NUMBER); + }), + onKeyPress: handleCardExpiryKeyPress + }; +}; + +export default useCardExpiryInput; diff --git a/src/hooks/use-card-number-input.js b/src/hooks/use-card-number-input.js new file mode 100644 index 0000000..6d1737e --- /dev/null +++ b/src/hooks/use-card-number-input.js @@ -0,0 +1,157 @@ +// @flow + +import { useEffect, useRef } from 'react'; +import payment from 'payment'; +import creditCardType from 'credit-card-type'; +import checkIsNumeric from '../utils/check-is-numeric'; +import { + formatCardNumber, + hasCardNumberReachedMaxLength, + isHighlighted +} from '../utils/formatter'; +import { + CHANGE_CARD_IMAGE, + CHANGE_CARD_NUMBER, + CHANGE_ERROR_TEXT, + SHOW_ZIP +} from '../reducer/actions'; + +type CardNumberInputProps = { + cardNumberInputProps: Object, + dispatch: Function, + state: Object +}; + +const defaultProps: CardNumberInputProps = { + cardNumberInputProps: {} +}; + +const useCardNumberInput = (props: CardNumberInputProps = defaultProps) => { + const cardNumberField = useRef(null); + const { cardNumberInputProps, dispatch, state } = props; + const { + cardTypes: CARD_TYPES, + customTextLabels, + enableZipInput, + events, + images, + inputClassName + } = state; + + useEffect(() => { + const subscription = events.subscribe( + events.types.FOCUS_ON_CARD_NUMBER, + () => { + cardNumberField.current.focus(); + } + ); + + return () => { + subscription.remove(); + }; + }, []); + + const setFieldInvalid = (errorText: string) => { + const { onError } = cardNumberInputProps; + dispatch({ type: CHANGE_ERROR_TEXT, errorText }); + if (onError) { + onError({ error: errorText }); + } + }; + + const setFieldValid = () => { + dispatch({ type: CHANGE_ERROR_TEXT, errorText: null }); + }; + + const handleCardNumberBlur = ( + { onBlur }: { onBlur?: ?Function } = { onBlur: null } + ) => (e: SyntheticInputEvent<*>) => { + if (!payment.fns.validateCardNumber(e.target.value)) { + setFieldInvalid( + customTextLabels.invalidCardNumber || 'Card number is invalid' + ); + } + + cardNumberInputProps.onBlur && cardNumberInputProps.onBlur(e); + onBlur && onBlur(e); + }; + + const handleCardNumberChange = ( + { onChange }: { onChange?: ?Function } = { onChange: null } + ) => (e: SyntheticInputEvent<*>) => { + const cardNumber = e.target.value; + const 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]; + + cardNumberField.current.value = formatCardNumber(cardNumber); + + dispatch({ + type: CHANGE_CARD_IMAGE, + cardImage: images[cardType] || images.placeholder + }); + dispatch({ + type: CHANGE_CARD_NUMBER, + cardNumber + }); + + if (enableZipInput) { + dispatch({ + type: SHOW_ZIP, + showZip: cardNumberLength >= 6 + }); + } + + setFieldValid(); + if (cardTypeLengths) { + const lastCardTypeLength = cardTypeLengths[cardTypeLengths.length - 1]; + for (let length of cardTypeLengths) { + if ( + length === cardNumberLength && + payment.fns.validateCardNumber(cardNumber) + ) { + events.publish(events.types.FOCUS_ON_CARD_EXPIRY); + break; + } + if (cardNumberLength === lastCardTypeLength) { + setFieldInvalid( + customTextLabels.invalidCardNumber || 'Card number is invalid' + ); + } + } + } + + cardNumberInputProps.onChange && cardNumberInputProps.onChange(e); + onChange && onChange(e); + }; + + const handleCardNumberKeyPress = (e: any) => { + const value = e.target.value; + checkIsNumeric(e); + if (value && !isHighlighted()) { + const valueLength = value.split(' ').join('').length; + if (hasCardNumberReachedMaxLength(value, valueLength)) { + e.preventDefault(); + } + } + }; + + return { + id: 'card-number', + ref: cardNumberField, + maxLength: '19', + autoComplete: 'cc-number', + className: `credit-card-input ${inputClassName}`, + placeholder: customTextLabels.cardNumberPlaceholder || 'Card number', + type: 'tel', + ...cardNumberInputProps, + onBlur: handleCardNumberBlur(), + onChange: handleCardNumberChange(), + onKeyPress: handleCardNumberKeyPress + }; +}; + +export default useCardNumberInput; diff --git a/src/hooks/use-card-zip-input.js b/src/hooks/use-card-zip-input.js new file mode 100644 index 0000000..b752517 --- /dev/null +++ b/src/hooks/use-card-zip-input.js @@ -0,0 +1,108 @@ +// @flow + +import { useEffect, useRef } from 'react'; +import payment from 'payment'; +import { hasZipReachedMaxLength, isHighlighted } from '../utils/formatter'; +import isZipValid from '../utils/is-zip-valid'; +import checkIsNumeric from '../utils/check-is-numeric'; +import handleKeyDown from '../utils/handle-key-down'; +import { CHANGE_ERROR_TEXT } from '../reducer/actions'; + +type CardZipInputProps = { + cardZipInputProps: Object, + dispatch: Function, + state: Object +}; + +const defaultProps: CardZipInputProps = { + cardZipInputProps: {} +}; + +const useCardZipInput = (props: CardZipInputProps = defaultProps) => { + const zipField = useRef(null); + const { cardZipInputProps, dispatch, state } = props; + const { cardNumber, customTextLabels, events, inputClassName } = state; + + useEffect(() => { + const subscription = events.subscribe( + events.types.FOCUS_ON_CARD_ZIP, + () => { + zipField.current.focus(); + } + ); + + return () => { + subscription.remove(); + }; + }, []); + + const setFieldInvalid = (errorText: string) => { + const { onError } = cardZipInputProps; + dispatch({ type: CHANGE_ERROR_TEXT, errorText }); + if (onError) { + onError({ error: errorText }); + } + }; + + const setFieldValid = () => { + dispatch({ type: CHANGE_ERROR_TEXT, errorText: null }); + }; + + const handleCardZipBlur = ( + { onBlur }: { onBlur?: ?Function } = { onBlur: null } + ) => (e: SyntheticInputEvent<*>) => { + if (!isZipValid(e.target.value)) { + setFieldInvalid(customTextLabels.invalidZipCode || 'Zip code is invalid'); + } + + cardZipInputProps.onBlur && cardZipInputProps.onBlur(e); + onBlur && onBlur(e); + }; + + const handleCardZipChange = ( + { onChange }: { onChange?: ?Function } = { onChange: null } + ) => (e: SyntheticInputEvent<*>) => { + const zip = e.target.value; + const zipLength = zip.length; + + setFieldValid(); + + if (zipLength >= 5 && !isZipValid(zip)) { + setFieldInvalid(customTextLabels.invalidZipCode || 'Zip code is invalid'); + } + + cardZipInputProps.onChange && cardZipInputProps.onChange(e); + onChange && onChange(e); + }; + + const handleCardZipKeyPress = (e: any) => { + const cardType = payment.fns.cardType(cardNumber); + const value = e.target.value; + checkIsNumeric(e); + if (value && !isHighlighted()) { + const valueLength = value.split(' / ').join('').length; + if (hasZipReachedMaxLength(cardType, valueLength)) { + e.preventDefault(); + } + } + }; + + return { + id: 'zip', + ref: zipField, + maxLength: '6', + className: `credit-card-input zip-input ${inputClassName}`, + pattern: '[0-9]*', + placeholder: customTextLabels.zipPlaceholder || 'Zip', + type: 'text', + ...cardZipInputProps, + onBlur: handleCardZipBlur(), + onChange: handleCardZipChange(), + onKeyDown: handleKeyDown(() => { + events.publish(events.types.FOCUS_ON_CARD_CVC); + }), + onKeyPress: handleCardZipKeyPress + }; +}; + +export default useCardZipInput; diff --git a/src/hooks/use-credit-card-input.js b/src/hooks/use-credit-card-input.js new file mode 100644 index 0000000..4545705 --- /dev/null +++ b/src/hooks/use-credit-card-input.js @@ -0,0 +1,99 @@ +// @flow + +import { useEffect, useReducer } from 'react'; +import useCardNumberInput from './use-card-number-input'; +import useCardExpiryInput from './use-card-expiry-input'; +import useCardCvcInput from './use-card-cvc-input'; +import useCardZipInput from './use-card-zip-input'; +import { inititalState, reducer } from '../reducer'; +import { + CHANGE_CARD_TYPES, + CHANGE_IMAGES, + CHANGE_CUSTOM_TEXT_LABELS, + CHANGE_INPUT_CLASS_NAME, + ENABLE_ZIP_INPUT +} from '../reducer/actions'; + +type CreditCardInputProps = { + cardTypes: Object, + onError?: Function, + cardExpiryInputProps: Object, + cardNumberInputProps: Object, + cardCVCInputProps: Object, + cardZipInputProps: Object, + images: Object, + inputClassName: string, + customTextLabels: Object +}; + +const defaultProps: CreditCardInputProps = { + cardExpiryInputProps: {}, + cardNumberInputProps: {}, + cardCVCInputProps: {}, + cardZipInputProps: {}, + inputClassName: '', + customTextLabels: {} +}; + +const useCreditCardInput = (props: CreditCardInputProps = defaultProps) => { + const [state, dispatch] = useReducer(reducer, inititalState); + + const { + cardCVCInputProps, + cardZipInputProps, + cardExpiryInputProps, + cardNumberInputProps, + onError + } = props; + + const { errorText } = state; + + useEffect(() => { + const { + images, + inputClassName, + cardTypes, + customTextLabels, + enableZipInput + } = props; + dispatch({ type: CHANGE_CARD_TYPES, cardTypes }); + dispatch({ type: CHANGE_IMAGES, images }); + dispatch({ type: ENABLE_ZIP_INPUT, enableZipInput }); + dispatch({ type: CHANGE_CUSTOM_TEXT_LABELS, customTextLabels }); + dispatch({ type: CHANGE_INPUT_CLASS_NAME, inputClassName }); + }, []); + + const setFieldInvalid = (errorText: string) => { + if (onError) { + onError({ error: errorText }); + } + }; + + const setFieldValid = () => {}; + + useEffect(() => { + if (errorText) { + setFieldInvalid(errorText); + } else { + setFieldValid(); + } + }, [errorText]); + + return { + cardNumberProps: useCardNumberInput({ + cardNumberInputProps, + dispatch, + state + }), + cardExpiryProps: useCardExpiryInput({ + cardExpiryInputProps, + dispatch, + state + }), + cardCvcProps: useCardCvcInput({ cardCVCInputProps, dispatch, state }), + cardZipProps: useCardZipInput({ cardZipInputProps, dispatch, state }), + state + }; +}; + +export default useCreditCardInput; diff --git a/src/index.js b/src/index.js index b191cee..ee6d1b4 100644 --- a/src/index.js +++ b/src/index.js @@ -1,633 +1,5 @@ -// @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 = ({ props }: Object) => ; - -class CreditCardInput extends Component { - 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, - inputStyle, - invalidStyle, - customTextLabels - } = this.props; - - return ( - - - - - {cardNumberInputRenderer({ - 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 - } - })} - - - {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}`, - placeholder: customTextLabels.expiryPlaceholder || 'MM/YY', - type: 'tel', - ...cardExpiryInputProps, - onBlur: this.handleCardExpiryBlur(), - onChange: this.handleCardExpiryChange(), - onKeyDown: this.handleKeyDown(this.cardNumberField), - onKeyPress: this.handleCardExpiryKeyPress - } - })} - - - {cardCVCInputRenderer({ - 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 - } - })} - - - {cardZipInputRenderer({ - 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 - } - })} - - - {errorText && ( - - {errorText} - - )} - - ); - }; -} +import CreditCardInput from './credit-card-input'; +import useCreditCardInput from './hooks/use-credit-card-input'; +export { useCreditCardInput }; export default CreditCardInput; diff --git a/src/index.stories.js b/src/index.stories.js index 73b6668..ee5c81a 100644 --- a/src/index.stories.js +++ b/src/index.stories.js @@ -32,12 +32,20 @@ storiesOf('CreditCardInput', module) onChange: e => console.log('number change', e), onError: err => console.log(`number error: ${err}`) }} + onError={err => console.log('onError', err)} /> )) .add('with zip field enabled', () => ( - + console.log('zip blur', e), + onChange: e => console.log('zip change', e), + onError: err => console.log(`zip error: ${err}`) + }} + /> )) .add('with pre-filled values', () => ( @@ -59,7 +67,7 @@ storiesOf('CreditCardInput', module) images={{ dinersclub: `data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgNDggNDgiIHZlcnNpb249IjEuMSIgd2lkdGg9IjQ4cHgiIGhlaWdodD0iNDhweCI+CjxnIGlkPSJzdXJmYWNlMSI+CjxwYXRoIHN0eWxlPSIgZmlsbDojRTFFN0VBOyIgZD0iTSA0NSAzNSBDIDQ1IDM3LjE5OTIxOSA0My4xOTkyMTkgMzkgNDEgMzkgTCA3IDM5IEMgNC44MDA3ODEgMzkgMyAzNy4xOTkyMTkgMyAzNSBMIDMgMTMgQyAzIDEwLjgwMDc4MSA0LjgwMDc4MSA5IDcgOSBMIDQxIDkgQyA0My4xOTkyMTkgOSA0NSAxMC44MDA3ODEgNDUgMTMgWiAiLz4KPHBhdGggc3R5bGU9IiBmaWxsOiNGRjZEMDA7IiBkPSJNIDQ1IDM1IEMgNDUgMzcuMTk5MjE5IDQzLjE5OTIxOSAzOSA0MSAzOSBMIDE2IDM5IEMgMTYgMzkgMzkuNjAxNTYzIDM1LjE5OTIxOSA0NSAyNCBaIE0gMjIgMjQgQyAyMiAyNS42OTkyMTkgMjMuMzAwNzgxIDI3IDI1IDI3IEMgMjYuNjk5MjE5IDI3IDI4IDI1LjY5OTIxOSAyOCAyNCBDIDI4IDIyLjMwMDc4MSAyNi42OTkyMTkgMjEgMjUgMjEgQyAyMy4zMDA3ODEgMjEgMjIgMjIuMzAwNzgxIDIyIDI0IFogIi8+CjxwYXRoIHN0eWxlPSIgIiBkPSJNIDExLjE5OTIxOSAyMSBMIDEyLjMwMDc4MSAyMSBMIDEyLjMwMDc4MSAyNyBMIDExLjE5OTIxOSAyNyBaIE0gMTcuMTk5MjE5IDI0IEMgMTcuMTk5MjE5IDI1LjY5OTIxOSAxOC41IDI3IDIwLjE5OTIxOSAyNyBDIDIwLjY5OTIxOSAyNyAyMS4xMDE1NjMgMjYuODk4NDM4IDIxLjYwMTU2MyAyNi42OTkyMTkgTCAyMS42MDE1NjMgMjUuMzk4NDM4IEMgMjEuMTk5MjE5IDI1LjgwMDc4MSAyMC44MDA3ODEgMjYgMjAuMTk5MjE5IDI2IEMgMTkuMTAxNTYzIDI2IDE4LjMwMDc4MSAyNS4xOTkyMTkgMTguMzAwNzgxIDI0IEMgMTguMzAwNzgxIDIyLjg5ODQzOCAxOS4xMDE1NjMgMjIgMjAuMTk5MjE5IDIyIEMgMjAuNjk5MjE5IDIyIDIxLjEwMTU2MyAyMi4xOTkyMTkgMjEuNjAxNTYzIDIyLjYwMTU2MyBMIDIxLjYwMTU2MyAyMS4zMDA3ODEgQyAyMS4xMDE1NjMgMjEuMTAxNTYzIDIwLjY5OTIxOSAyMC44OTg0MzggMjAuMTk5MjE5IDIwLjg5ODQzOCBDIDE4LjUgMjEgMTcuMTk5MjE5IDIyLjM5ODQzOCAxNy4xOTkyMTkgMjQgWiBNIDMwLjYwMTU2MyAyNC44OTg0MzggTCAyOSAyMSBMIDI3LjgwMDc4MSAyMSBMIDMwLjMwMDc4MSAyNyBMIDMwLjg5ODQzOCAyNyBMIDMzLjM5ODQzOCAyMSBMIDMyLjE5OTIxOSAyMSBaIE0gMzMuODk4NDM4IDI3IEwgMzcuMTAxNTYzIDI3IEwgMzcuMTAxNTYzIDI2IEwgMzUgMjYgTCAzNSAyNC4zOTg0MzggTCAzNyAyNC4zOTg0MzggTCAzNyAyMy4zOTg0MzggTCAzNSAyMy4zOTg0MzggTCAzNSAyMiBMIDM3LjEwMTU2MyAyMiBMIDM3LjEwMTU2MyAyMSBMIDMzLjg5ODQzOCAyMSBaIE0gNDEuNSAyMi44MDA3ODEgQyA0MS41IDIxLjY5OTIxOSA0MC44MDA3ODEgMjEgMzkuNSAyMSBMIDM3LjgwMDc4MSAyMSBMIDM3LjgwMDc4MSAyNyBMIDM4Ljg5ODQzOCAyNyBMIDM4Ljg5ODQzOCAyNC42MDE1NjMgTCAzOSAyNC42MDE1NjMgTCA0MC42MDE1NjMgMjcgTCA0MiAyNyBMIDQwLjE5OTIxOSAyNC41IEMgNDEgMjQuMzAwNzgxIDQxLjUgMjMuNjk5MjE5IDQxLjUgMjIuODAwNzgxIFogTSAzOS4xOTkyMTkgMjMuODAwNzgxIEwgMzguODk4NDM4IDIzLjgwMDc4MSBMIDM4Ljg5ODQzOCAyMiBMIDM5LjE5OTIxOSAyMiBDIDM5Ljg5ODQzOCAyMiA0MC4zMDA3ODEgMjIuMzAwNzgxIDQwLjMwMDc4MSAyMi44OTg0MzggQyA0MC4zMDA3ODEgMjMuMzk4NDM4IDQwIDIzLjgwMDc4MSAzOS4xOTkyMTkgMjMuODAwNzgxIFogTSA3LjY5OTIxOSAyMSBMIDYgMjEgTCA2IDI3IEwgNy42MDE1NjMgMjcgQyAxMC4xMDE1NjMgMjcgMTAuNjk5MjE5IDI0Ljg5ODQzOCAxMC42OTkyMTkgMjQgQyAxMC44MDA3ODEgMjIuMTk5MjE5IDkuNSAyMSA3LjY5OTIxOSAyMSBaIE0gNy4zOTg0MzggMjYgTCA3LjEwMTU2MyAyNiBMIDcuMTAxNTYzIDIyIEwgNy41IDIyIEMgOSAyMiA5LjYwMTU2MyAyMyA5LjYwMTU2MyAyNCBDIDkuNjAxNTYzIDI0LjM5ODQzOCA5LjUgMjYgNy4zOTg0MzggMjYgWiBNIDE1LjMwMDc4MSAyMy4zMDA3ODEgQyAxNC42MDE1NjMgMjMgMTQuMzk4NDM4IDIyLjg5ODQzOCAxNC4zOTg0MzggMjIuNjAxNTYzIEMgMTQuMzk4NDM4IDIyLjE5OTIxOSAxNC44MDA3ODEgMjIgMTUuMTk5MjE5IDIyIEMgMTUuNSAyMiAxNS44MDA3ODEgMjIuMTAxNTYzIDE2LjEwMTU2MyAyMi41IEwgMTYuNjk5MjE5IDIxLjY5OTIxOSBDIDE2LjE5OTIxOSAyMS4xOTkyMTkgMTUuNjk5MjE5IDIxIDE1IDIxIEMgMTQgMjEgMTMuMTk5MjE5IDIxLjY5OTIxOSAxMy4xOTkyMTkgMjIuNjk5MjE5IEMgMTMuMTk5MjE5IDIzLjUgMTMuNjAxNTYzIDIzLjg5ODQzOCAxNC42MDE1NjMgMjQuMzAwNzgxIEMgMTUuMTk5MjE5IDI0LjUgMTUuNjk5MjE5IDI0LjY5OTIxOSAxNS42OTkyMTkgMjUuMTk5MjE5IEMgMTUuNjk5MjE5IDI1LjY5OTIxOSAxNS4zMDA3ODEgMjYgMTQuODAwNzgxIDI2IEMgMTQuMzAwNzgxIDI2IDEzLjgwMDc4MSAyNS42OTkyMTkgMTMuNjAxNTYzIDI1LjE5OTIxOSBMIDEyLjg5ODQzOCAyNS44OTg0MzggQyAxMy4zOTg0MzggMjYuNjk5MjE5IDE0IDI3IDE0Ljg5ODQzOCAyNyBDIDE2LjEwMTU2MyAyNyAxNi44OTg0MzggMjYuMTk5MjE5IDE2Ljg5ODQzOCAyNS4xMDE1NjMgQyAxNi44OTg0MzggMjQuMTk5MjE5IDE2LjUgMjMuODAwNzgxIDE1LjMwMDc4MSAyMy4zMDA3ODEgWiAiLz4KPC9nPgo8L3N2Zz4K` }} - CARD_TYPES={{ + cardTypes={{ dinersclub: 'DINERS_CLUB' }} /> diff --git a/src/reducer/actions.js b/src/reducer/actions.js new file mode 100644 index 0000000..3e58859 --- /dev/null +++ b/src/reducer/actions.js @@ -0,0 +1,9 @@ +export const CHANGE_CARD_IMAGE = Symbol('CHANGE_CARD_IMAGE'); +export const CHANGE_CARD_NUMBER = Symbol('CHANGE_CARD_NUMBER'); +export const CHANGE_CARD_TYPES = Symbol('CHANGE_CARD_TYPES'); +export const CHANGE_CUSTOM_TEXT_LABELS = Symbol('CHANGE_CUSTOM_TEXT_LABELS'); +export const CHANGE_ERROR_TEXT = Symbol('CHANGE_ERROR_TEXT'); +export const CHANGE_IMAGES = Symbol('CHANGE_IMAGES'); +export const CHANGE_INPUT_CLASS_NAME = Symbol('CHANGE_INPUT_CLASS_NAME'); +export const ENABLE_ZIP_INPUT = Symbol('ENABLE_ZIP_INPUT'); +export const SHOW_ZIP = Symbol('SHOW_ZIP'); diff --git a/src/reducer/index.js b/src/reducer/index.js new file mode 100644 index 0000000..e4d0eca --- /dev/null +++ b/src/reducer/index.js @@ -0,0 +1,100 @@ +import cardTypes from '../utils/card-types'; +import images from '../utils/images'; +import events from '../events'; +import { + CHANGE_CARD_IMAGE, + CHANGE_CARD_NUMBER, + CHANGE_CARD_TYPES, + CHANGE_CUSTOM_TEXT_LABELS, + CHANGE_ERROR_TEXT, + CHANGE_IMAGES, + CHANGE_INPUT_CLASS_NAME, + ENABLE_ZIP_INPUT, + SHOW_ZIP +} from './actions'; + +type State = { + cardTypes: Object, + cardImage: string, + cardNumber: ?string, + customTextLabels: Object, + enableZipInput: boolean, + errorText: ?string, + events: Object, + images: Object, + inputClassName: string, + showZip: boolean +}; + +const inititalState: State = { + cardImage: images.placeholder, + cardNumber: null, + cardTypes, + customTextLabels: {}, + enableZipInput: false, + errorText: null, + events: events(), + images, + inputClassName: '', + showZip: false +}; + +const reducer = (state: State, action) => { + switch (action.type) { + case CHANGE_CARD_IMAGE: + return { + ...state, + cardImage: action.cardImage + }; + case CHANGE_CARD_NUMBER: + return { + ...state, + cardNumber: action.cardNumber + }; + case CHANGE_CARD_TYPES: + return { + ...state, + cardTypes: { + ...state.cardTypes, + ...action.cardTypes + } + }; + case CHANGE_CUSTOM_TEXT_LABELS: + return { + ...state, + customTextLabels: action.customTextLabels + }; + case CHANGE_ERROR_TEXT: + return { + ...state, + errorText: action.errorText + }; + case CHANGE_IMAGES: + return { + ...state, + images: { + ...state.images, + ...action.images + } + }; + case CHANGE_INPUT_CLASS_NAME: + return { + ...state, + inputClassName: action.inputClassName + }; + case ENABLE_ZIP_INPUT: + return { + ...state, + enableZipInput: action.enableZipInput + }; + case SHOW_ZIP: + return { + ...state, + showZip: !!action.showZip + }; + default: + throw new Error(); + } +}; + +export { inititalState, reducer }; diff --git a/src/utils/card-types.js b/src/utils/card-types.js new file mode 100644 index 0000000..963c525 --- /dev/null +++ b/src/utils/card-types.js @@ -0,0 +1,5 @@ +export default { + mastercard: 'MASTERCARD', + visa: 'VISA', + amex: 'AMERICAN_EXPRESS' +}; diff --git a/src/utils/check-is-numeric.js b/src/utils/check-is-numeric.js new file mode 100644 index 0000000..fd81a91 --- /dev/null +++ b/src/utils/check-is-numeric.js @@ -0,0 +1,9 @@ +// @flow + +const checkIsNumeric = (e: any) => { + if (!/^\d*$/.test(e.key)) { + e.preventDefault(); + } +}; + +export default checkIsNumeric; diff --git a/src/utils/handle-key-down.js b/src/utils/handle-key-down.js new file mode 100644 index 0000000..5b94a8e --- /dev/null +++ b/src/utils/handle-key-down.js @@ -0,0 +1,13 @@ +// @flow + +const BACKSPACE_KEY_CODE = 8; + +const handleKeyDown = (callback: Function) => { + return (e: SyntheticInputEvent<*>) => { + if (e.keyCode === BACKSPACE_KEY_CODE && !e.target.value) { + callback && callback(); + } + }; +}; + +export default handleKeyDown; diff --git a/src/utils/is-month-dash-key.js b/src/utils/is-month-dash-key.js new file mode 100644 index 0000000..811ee47 --- /dev/null +++ b/src/utils/is-month-dash-key.js @@ -0,0 +1,7 @@ +// @flow + +const isMonthDashKey = ({ key, target: { value } } = {}) => { + return !value.match(/[/-]/) && /^[/-]$/.test(key); +}; + +export default isMonthDashKey;