diff --git a/README.md b/README.md index 8d917806e0..3c9e33e13c 100644 --- a/README.md +++ b/README.md @@ -1 +1,65 @@ # react-payments + +## ๐Ÿ“‚ ํŒŒ์ผ ๊ตฌ์กฐ +``` +๐Ÿ“ฆsrc +โ”ฃ ๐Ÿ“‚assets +โ”ƒ โ”ฃ ๐Ÿ“œMastercard.svg +โ”ƒ โ”— ๐Ÿ“œVisa.svg +โ”ฃ ๐Ÿ“‚components +โ”ƒ โ”ฃ ๐Ÿ“œCard.tsx +โ”ƒ โ”ฃ ๐Ÿ“œCardForm.tsx +โ”ƒ โ”— ๐Ÿ“œCardFormInput.tsx +โ”ฃ ๐Ÿ“‚constants +โ”ƒ โ”ฃ ๐Ÿ“œerrorMessage.ts +โ”ƒ โ”ฃ ๐Ÿ“œindex.ts +โ”ƒ โ”ฃ ๐Ÿ“œtextConstants.ts +โ”ƒ โ”— ๐Ÿ“œusingNumbers.ts +โ”ฃ ๐Ÿ“‚pages +โ”ƒ โ”— ๐Ÿ“œHome.tsx +โ”ฃ ๐Ÿ“‚stories +โ”ƒ โ”— ๐Ÿ“œApp.stories.tsx +โ”ฃ ๐Ÿ“‚styles +โ”ƒ โ”ฃ ๐Ÿ“œCard.module.css +โ”ƒ โ”ฃ ๐Ÿ“œCardForm.module.css +โ”ƒ โ”— ๐Ÿ“œCardFormInput.module.css +โ”ฃ ๐Ÿ“‚types +โ”ƒ โ”ฃ ๐Ÿ“œcardTypes.ts +โ”ƒ โ”ฃ ๐Ÿ“œerrorType.ts +โ”ƒ โ”— ๐Ÿ“œindex.ts +โ”ฃ ๐Ÿ“‚utils +โ”ƒ โ”ฃ ๐Ÿ“œcardNumberValidators.tsx +โ”ƒ โ”ฃ ๐Ÿ“œerrorHelpers.tsx +โ”ƒ โ”ฃ ๐Ÿ“œexpirationDateValidators.tsx +โ”ƒ โ”ฃ ๐Ÿ“œindex.ts +โ”ƒ โ”ฃ ๐Ÿ“œinputFilters.tsx +โ”ƒ โ”— ๐Ÿ“œownerNameValidators.tsx +โ”ฃ ๐Ÿ“œApp.tsx +โ”ฃ ๐Ÿ“œindex.css +โ”ฃ ๐Ÿ“œmain.tsx +โ”— ๐Ÿ“œvite-env.d.ts +``` + +## ๐Ÿ’ณ ๊ธฐ๋Šฅ ์š”๊ตฌ ์‚ฌํ•ญ + +- ์นด๋“œ ๋ฒˆํ˜ธ ์ž…๋ ฅ ๋ฐ ์‹๋ณ„ + - ์นด๋“œ ๋ฒˆํ˜ธ์˜ 3~4๋ฒˆ ๋ธ”๋Ÿญ์€ ์ˆจ๊น€ ์ฒ˜๋ฆฌํ•œ๋‹ค. + - ์ˆซ์ž๋ฅผ ์ž…๋ ฅํ•˜์ง€ ์•Š์œผ๋ฉด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ž…๋ ฅํ•˜๋ผ๋Š” ํ”ผ๋“œ๋ฐฑ์„ ๋ณด์—ฌ์ฃผ๊ณ , ์ž…๋ ฅ์„ ์ œํ•œํ•œ๋‹ค. + - ๊ฐ ์นด๋“œ ๋ฒˆํ˜ธ ์ž…๋ ฅ ๋ธ”๋Ÿญ์€ 0~9์˜ ์ˆซ์ž 4์ž๋ฆฌ๋กœ ์ด๋ฃจ์–ด์ ธ์žˆ๋‹ค. + - ์ž…๋ ฅ์€ ์ˆซ์ž๋งŒ ๊ฐ€๋Šฅํ•˜๋ฉฐ, ์œ ํšจํ•˜์ง€ ์•Š์€ ๋ฒˆํ˜ธ ์ž…๋ ฅ ์‹œ ํ”ผ๋“œ๋ฐฑ์„ ์ œ๊ณตํ•œ๋‹ค. + - ์นด๋“œ ๋ฒˆํ˜ธ 4์ž๋ฆฌ ๋ฏธ๋งŒ์œผ๋กœ ์ž…๋ ฅํ•˜๋Š” ์ค‘์— ํฌ์ปค์Šค๋ฅผ ๋„˜๊ธฐ๋ ค๊ณ  ํ•œ๋‹ค๋ฉด ์—๋Ÿฌ๋กœ ๋ง‰๋Š”๋‹ค. +- ์นด๋“œ ์œ ํšจ๊ธฐ๊ฐ„ ์ž…๋ ฅ + - ์ž…๋ ฅ์€ ์ˆซ์ž๋งŒ ๊ฐ€๋Šฅํ•˜๋ฉฐ ์ˆซ์ž๊ฐ€ ์•„๋‹์‹œ ํ”ผ๋“œ๋ฐฑ์„ ์ œ๊ณตํ•œ๋‹ค. + - ์œ ํšจํ•˜์ง€ ์•Š์€ ์›”์„ ์ž…๋ ฅ ์‹œ(ex 13์›”) ํ”ผ๋“œ๋ฐฑ์„ ์ œ๊ณตํ•œ๋‹ค. + - ํ˜„์žฌ๋ณด๋‹ค ์ด์ „ ๋‚ ์งœ๋ฅผ ์ž…๋ ฅ ์‹œ ํ”ผ๋“œ๋ฐฑ์„ ์ œ๊ณตํ•œ๋‹ค. + - ํ•œ ์ž๋ฆฌ ์ˆซ์ž๋ฅผ ์ž…๋ ฅ ์‹œ ์ž๋™์œผ๋กœ ํ˜•์‹์— ๋งž์ถฐ 0์„ ๋„ฃ์–ด์ค€๋‹ค. +- ์นด๋“œ ์†Œ์œ ์ž ์ด๋ฆ„ ์ž…๋ ฅ + - ์†Œ๋ฌธ์ž๋กœ ์ž…๋ ฅ ์‹œ ๊ฐ•์ œ๋กœ ๋Œ€๋ฌธ์ž๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค. + - ์˜์–ด๊ฐ€ ์•„๋‹Œ ๋ฌธ์ž ์ž…๋ ฅ ์‹œ ์ž…๋ ฅ์„ ์ œํ•œํ•˜๊ณ  ํ”ผ๋“œ๋ฐฑ์„ ์ œ๊ณตํ•œ๋‹ค. + - (์ถ”๊ฐ€) ์‚ฌ์šฉ์ž ์ด๋ฆ„์€ ์ตœ์†Œ ๋‘๊ธ€์ž ์ด์ƒ ์ž…๋ ฅํ•ด์•ผ ํ•œ๋‹ค. +- ์‹ค์‹œ๊ฐ„ ํ”„๋ฆฌ๋ทฐ ์—…๋ฐ์ดํŠธ + - ์นด๋“œ ๋ฒˆํ˜ธ๊ฐ€ 4๋กœ ์‹œ์ž‘ํ•˜๋ฉด Visa์นด๋“œ ๋กœ๊ณ ๋ฅผ ์นด๋“œ ํ”„๋ฆฌ๋ทฐ์— ์—…๋ฐ์ดํŠธํ•œ๋‹ค. + - ์นด๋“œ ๋ฒˆํ˜ธ๊ฐ€ 51~55๋กœ ์‹œ์ž‘ํ•˜๋ฉด MasterCard ๋กœ๊ณ ๋ฅผ ์นด๋“œ ํ”„๋ฆฌ๋ทฐ์— ์—…๋ฐ์ดํŠธํ•œ๋‹ค. + - ์‚ฌ์šฉ์ž๊ฐ€ ์นด๋“œ ๋ฒˆํ˜ธ ์ž…๋ ฅ ์‹œ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์นด๋“œ ํ”„๋ฆฌ๋ทฐ์— ์—…๋ฐ์ดํŠธํ•œ๋‹ค. + - ์‚ฌ์šฉ์ž๊ฐ€ ์นด๋“œ ์œ ํšจ๊ธฐ๊ฐ„ ์ž…๋ ฅ ์‹œ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์นด๋“œ ํ”„๋ฆฌ๋ทฐ์— ์—…๋ฐ์ดํŠธํ•œ๋‹ค. + - ์‚ฌ์šฉ์ž๊ฐ€ ์นด๋“œ ์†Œ์œ ์ž ์ด๋ฆ„ ์ž…๋ ฅ ์‹œ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์นด๋“œ ํ”„๋ฆฌ๋ทฐ์— ์—…๋ฐ์ดํŠธํ•œ๋‹ค. diff --git a/src/App.css b/src/App.css deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/App.tsx b/src/App.tsx index ef7e3632d2..c24dfaac54 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,7 @@ -import "./App.css"; +import Home from "../src/pages/Home"; function App() { - return ( - <> -

React Payments

- - ); + return ; } export default App; diff --git a/src/assets/Mastercard.svg b/src/assets/Mastercard.svg new file mode 100644 index 0000000000..dbe057ab78 --- /dev/null +++ b/src/assets/Mastercard.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/Visa.svg b/src/assets/Visa.svg new file mode 100644 index 0000000000..99b2ccebd7 --- /dev/null +++ b/src/assets/Visa.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/Card.tsx b/src/components/Card.tsx new file mode 100644 index 0000000000..b07226774a --- /dev/null +++ b/src/components/Card.tsx @@ -0,0 +1,41 @@ +import styles from "../styles/Card.module.css"; +import type { CardData } from "../types/cardTypes"; +import { hideNumber, setCardLogo } from "../utils/cardNumberValidators"; +import { EMPTY_STRING } from "../constants"; + +interface CardProps { + cardNumbers: CardData["numbers"]; + expirationDate: CardData["expirationDate"]; + owner: CardData["owner"]; +} + +function Card({ owner, expirationDate, cardNumbers }: CardProps) { + const expirationMonth = expirationDate.month; + const expirationYear = expirationDate.year; + const cardLogo = setCardLogo(cardNumbers.firstBlock); + const date = + expirationMonth.length > 0 || expirationYear.length > 0 + ? `${expirationMonth}/${expirationYear}` + : EMPTY_STRING; + const numbers: string = `${cardNumbers.firstBlock} ${cardNumbers.secondBlock} ${hideNumber(cardNumbers.thirdBlock)} ${hideNumber(cardNumbers.fourthBlock)}`; + + return ( + <> +
+
+
+ {cardLogo ? ( + + ) : ( +
+ )} +
+

{numbers}

+

{date}

+

{owner}

+
+ + ); +} + +export default Card; diff --git a/src/components/CardForm.tsx b/src/components/CardForm.tsx new file mode 100644 index 0000000000..39ad5d1477 --- /dev/null +++ b/src/components/CardForm.tsx @@ -0,0 +1,25 @@ +import styles from "../styles/CardForm.module.css"; + +interface CardFormProps { + cardFormLabelText: string; + cardFormLabelCaption?: string; + cardLabelText: string; +} + +function CardForm({ + cardFormLabelText, + cardFormLabelCaption, + cardLabelText, +}: CardFormProps) { + return ( + <> +

{cardFormLabelText}

+ {cardFormLabelCaption && ( +

{cardFormLabelCaption}

+ )} +

{cardLabelText}

+ + ); +} + +export default CardForm; diff --git a/src/components/CardFormInput.tsx b/src/components/CardFormInput.tsx new file mode 100644 index 0000000000..1db33db469 --- /dev/null +++ b/src/components/CardFormInput.tsx @@ -0,0 +1,67 @@ +import React, { forwardRef } from "react"; +import type { ChangeEvent } from "../types/cardTypes"; +import styles from "../styles/CardFormInput.module.css"; + +interface CardFormProps { + name: string; + cardPlaceHolder: string; + cardInput: number | string; + handleChange: (e: ChangeEvent) => void; + width: string; + isDisable?: boolean; + hasError: boolean; + handleBlur?: (e: React.FocusEvent) => void; + handleOnFocus?: (e: React.FocusEvent) => void; + maxLength: number; + pattern?: string; + handleOnInput?: (e: React.FormEvent) => void; + autoFocus?: boolean; +} + +const CardFormInput = forwardRef( + ( + { + name, + cardPlaceHolder, + cardInput, + handleChange, + width, + isDisable, + hasError, + handleBlur, + handleOnFocus, + maxLength, + pattern, + handleOnInput, + autoFocus, + }: CardFormProps, + ref + ) => { + return ( + <> + + + ); + } +); + +export default CardFormInput; diff --git a/src/constants/cardConstants.ts b/src/constants/cardConstants.ts new file mode 100644 index 0000000000..382fd341bf --- /dev/null +++ b/src/constants/cardConstants.ts @@ -0,0 +1,22 @@ +export const CARD_LOGO_NUMBER = { + VISA_NUMBER: "4", + MASTER_MIN_NUMBER: 51, + MASTER_MAX_NUMBER: 55, +}; + +export const CARD_PREFIX_LENGTH = { + VISA: 1, + MASTER: 2, +}; + +export const CARD_NUMBER_MAX_LENGTH = 4; + +export const CARD_INFORMATION = { + EXPIRATION_DATE: ["month", "year"] as const, + CARD_NUMBER_BLOCK: [ + "firstBlock", + "secondBlock", + "thirdBlock", + "fourthBlock", + ] as const, +}; diff --git a/src/constants/errorMessege.ts b/src/constants/errorMessege.ts new file mode 100644 index 0000000000..7bce166174 --- /dev/null +++ b/src/constants/errorMessege.ts @@ -0,0 +1,12 @@ +export const ERROR_MESSAGE = { + ONLY_NUMBER: "์ˆซ์ž๋งŒ ์ž…๋ ฅ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.", + ONLY_ENGLISH: "์˜์–ด๋งŒ ์ž…๋ ฅ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.", + MIN_LENGTH_REQUIRED: "๋‘๊ธ€์ž ์ด์ƒ ์ž…๋ ฅํ•˜์„ธ์š”.", + MONTH_OUT_OF_RANGE: "์œ ํšจํ•œ ์›”์„ ์ž…๋ ฅํ•˜์„ธ์š”.", + EXPIRATION_DATE_IN_PAST: "์œ ํšจ๊ธฐ๊ฐ„ ๋‚ ์งœ๊ฐ€ ์ง€๋‚ฌ์Šต๋‹ˆ๋‹ค.", + VALID_MONTH_RANGE: "1์›”๋ถ€ํ„ฐ 12์›” ์‚ฌ์ด๋งŒ ์ž…๋ ฅํ•˜์„ธ์š”.", + PAST_DATE: "์œ ํšจ๊ธฐ๊ฐ„์ด ์ง€๋‚œ ์—ฐ๋„์ž…๋‹ˆ๋‹ค.", + OVER_MAX_VALID_YEAR: "์œ ํšจ๊ธฐ๊ฐ„์€ ์ตœ๋Œ€ 10๋…„๊นŒ์ง€๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.", + MIN_LENGTH_TWO: "์ˆซ์ž ๋‘ ์ž๋ฆฌ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”.", + REQUIRE_FOUR_DIGIT_NUMBER: "4์ž๋ฆฌ ์ˆซ์ž๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”.", +}; diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000000..028ba410cd --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,3 @@ +export * from "./errorMessege"; +export * from "./textConstants"; +export * from "./cardConstants"; diff --git a/src/constants/textConstants.ts b/src/constants/textConstants.ts new file mode 100644 index 0000000000..20b13d8556 --- /dev/null +++ b/src/constants/textConstants.ts @@ -0,0 +1,42 @@ +export const INPUTS = { + FIRST_BLOCK: "firstBlock", + SECOND_BLOCK: "secondBlock", + THIRD_BLOCK: "thirdBlock", + FOURTH_BLOCK: "fourthBlock", + MONTH: "month", + YEAR: "year", + OWNER: "owner", +} as const; + +const CARD_FORM_LABELS = { + CARD_NUMBER: "๊ฒฐ์ œํ•  ์นด๋“œ ๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”", + CARD_NUMBER_CAPTION: "๋ณธ์ธ ๋ช…์˜์˜ ์นด๋“œ๋งŒ ๊ฒฐ์ œ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.", + EXPIRATION_DATE: "์นด๋“œ ์œ ํšจ๊ธฐ๊ฐ„์„ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”", + EXPIRATION_DATE_CAPTION: "์›”/๋…„๋„(MMYY)๋ฅผ ์ˆœ์„œ๋Œ€๋กœ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.", + CARD_OWNER: "์นด๋“œ ์†Œ์œ ์ž ์ด๋ฆ„์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”", +}; + +const CARD_LABELS = { + CARD_NUMBER: "์นด๋“œ ๋ฒˆํ˜ธ", + EXPIRATION_DATE: "์œ ํšจ๊ธฐ๊ฐ„", + CARD_OWNER: "์†Œ์œ ์ž ์ด๋ฆ„", +}; + +const CARD_PLACEHOLDERS = { + CARD_NUMBER: "1234", + EXPIRATION_MONTH: "MM", + EXPIRATION_YEAR: "YY", + CARD_OWNER: "JOHN DOE", +}; + +const MASK_SYMBOL = "โ—"; + +const EMPTY_STRING = ""; + +export { + MASK_SYMBOL, + CARD_FORM_LABELS, + CARD_LABELS, + CARD_PLACEHOLDERS, + EMPTY_STRING, +}; diff --git a/src/main.tsx b/src/main.tsx index 3d7150da80..966f17a4b2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,10 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import './index.css' +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; +import "./index.css"; -ReactDOM.createRoot(document.getElementById('root')!).render( +ReactDOM.createRoot(document.getElementById("root")!).render( - , -) + +); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx new file mode 100644 index 0000000000..d4dc726601 --- /dev/null +++ b/src/pages/Home.tsx @@ -0,0 +1,315 @@ +import { useState, useRef, useEffect } from "react"; +import CardForm from "../components/CardForm"; +import CardFormInput from "../components/CardFormInput"; +import Card from "../components/Card"; +import { + CARD_FORM_LABELS, + CARD_LABELS, + CARD_PLACEHOLDERS, + EMPTY_STRING, + INPUTS, +} from "../constants"; +import type { ChangeEvent, CardData, CardFormError } from "../types"; +import { + formatStringToUpper, + validOwnerName, + validExpirationDate, + getFirstExpirationErrorMessage, + getFirstErrorMessage, + validCardNumbersBlock, + hideNumber, + onInputOnlyNumber, + onInputOnlyString, +} from "../utils"; +import styles from "../styles/CardForm.module.css"; + +function Home() { + const [expirationDate, setExpirationDate] = useState< + CardData["expirationDate"] + >({ + month: EMPTY_STRING, + year: EMPTY_STRING, + }); + + const [cardNumbers, setCardNumbers] = useState({ + firstBlock: EMPTY_STRING, + secondBlock: EMPTY_STRING, + thirdBlock: EMPTY_STRING, + fourthBlock: EMPTY_STRING, + }); + + const [owner, setOwner] = useState(EMPTY_STRING); + + const [cardError, setCardError] = useState({ + numbers: { + firstBlock: { + hasError: false, + errorMessage: EMPTY_STRING, + isDisable: false, + }, + secondBlock: { + hasError: false, + errorMessage: EMPTY_STRING, + isDisable: true, + }, + thirdBlock: { + hasError: false, + errorMessage: EMPTY_STRING, + isDisable: true, + }, + fourthBlock: { + hasError: false, + errorMessage: EMPTY_STRING, + isDisable: true, + }, + }, + expirationDate: { + month: { hasError: false, errorMessage: EMPTY_STRING }, + year: { hasError: false, errorMessage: EMPTY_STRING }, + }, + owner: { hasError: false, errorMessage: EMPTY_STRING }, + }); + + const [isAllFocusDone, setIsAllFocusDone] = useState(false); + const [isOnFocus, setIsOnFocus] = useState(false); + + const firstRef = useRef(null); + const secondRef = useRef(null); + const thirdRef = useRef(null); + const fourthRef = useRef(null); + const monthRef = useRef(null); + const yearRef = useRef(null); + const ownerRef = useRef(null); + + const handleExpirationDateChange = (event: ChangeEvent) => { + const name = event.target.name; + const value = event.target.value; + + const newDate = { + ...expirationDate, + [name]: value, + }; + + setExpirationDate((prev) => ({ + ...prev, + [name]: value, + })); + + const validResult = validExpirationDate(newDate.month, newDate.year); + + setCardError((pre) => ({ + ...pre, + expirationDate: validResult, + })); + }; + + const handleExpirationDateBlur = ( + date: keyof CardData["expirationDate"], + e: React.FocusEvent + ) => { + const value = e.target.value; + + if (value.length === 1) { + setExpirationDate((prev) => ({ + ...prev, + [date]: `0${value}`, + })); + } + }; + + const handleOwnerChange = (event: ChangeEvent) => { + const value = event.target.value; + + const validResult = validOwnerName(event.target.value); + + setCardError((pre) => ({ + ...pre, + owner: validResult, + })); + const ownerName = formatStringToUpper(value); + setOwner(ownerName); + }; + + const handleCardNumbersChange = (event: ChangeEvent) => { + const name = event.target.name; + const value = event.target.value; + + const numberBlock = { + ...cardNumbers, + [name]: value, + }; + + const result: CardFormError["numbers"] = validCardNumbersBlock(numberBlock); + + setCardError((pre) => ({ + ...pre, + numbers: result, + })); + + setCardNumbers({ + firstBlock: numberBlock.firstBlock, + secondBlock: numberBlock.secondBlock, + thirdBlock: numberBlock.thirdBlock, + fourthBlock: numberBlock.fourthBlock, + }); + }; + + const currentInputRef = ( + key: string + ): React.RefObject | null => { + switch (key) { + case INPUTS.FIRST_BLOCK: + return firstRef; + case INPUTS.SECOND_BLOCK: + return secondRef; + case INPUTS.THIRD_BLOCK: + return thirdRef; + case INPUTS.FOURTH_BLOCK: + return fourthRef; + case INPUTS.MONTH: + return monthRef; + case INPUTS.YEAR: + return yearRef; + case INPUTS.OWNER: + return ownerRef; + default: + return null; + } + }; + + useEffect(() => { + if (isAllFocusDone) return; + if (cardNumbers.firstBlock.length === 4) secondRef.current?.focus(); + if (cardNumbers.secondBlock.length === 4) thirdRef.current?.focus(); + if (cardNumbers.thirdBlock.length === 4) fourthRef.current?.focus(); + if (cardNumbers.fourthBlock.length === 4) monthRef.current?.focus(); + }, [cardNumbers, isAllFocusDone]); + + useEffect(() => { + if (isAllFocusDone) return; + if (expirationDate.month.length === 2) yearRef.current?.focus(); + if (expirationDate.year.length === 2) { + ownerRef.current?.focus(); + setIsAllFocusDone(true); + } + }, [expirationDate, isAllFocusDone]); + + return ( + <> + + + + {Object.entries(cardNumbers).map(([key, value], index) => { + const isNeedHiding = + key === INPUTS.THIRD_BLOCK || key === INPUTS.FOURTH_BLOCK; + + return ( + setIsOnFocus(true)} + handleBlur={() => setIsOnFocus(false)} + autoFocus={index === 0} + /> + ); + })} + + { +

+ {cardError.numbers + ? getFirstErrorMessage(cardError.numbers) + : EMPTY_STRING} +

+ } + + + + {Object.entries(expirationDate).map(([key, value]) => ( + + handleExpirationDateBlur( + key as keyof CardFormError["expirationDate"], + e + ) + } + /> + ))} + + { +

+ {cardError.expirationDate + ? getFirstExpirationErrorMessage(cardError.expirationDate) + : EMPTY_STRING} +

+ } + + + + + { +

+ {cardError.owner ? cardError.owner.errorMessage : EMPTY_STRING} +

+ } + + ); +} + +export default Home; diff --git a/src/styles/Card.module.css b/src/styles/Card.module.css new file mode 100644 index 0000000000..d75375ff5e --- /dev/null +++ b/src/styles/Card.module.css @@ -0,0 +1,37 @@ +.card { + width: 220px; + height: 120px; + margin-left: 40px; + background-color: #333333; + border-radius: 4px; +} + +.logo { + width: 30px; + height: 30px; + margin-right: 10px; + margin-top: 4px; +} + +.card-detail { + color: white; + margin: 0; + padding: 4px; + font-size: 14px; + margin-left: 10px; +} + +.card-ic { + width: 30px; + height: 18px; + background-color: #ddcd78; + border-radius: 3px; + margin-left: 10px; + margin-top: 4px; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; +} diff --git a/src/styles/CardForm.module.css b/src/styles/CardForm.module.css new file mode 100644 index 0000000000..4f6ed28c87 --- /dev/null +++ b/src/styles/CardForm.module.css @@ -0,0 +1,27 @@ +.label { + margin-top: 40px; + margin-bottom: 4px; +} + +.caption { + color: gray; + font-size: 12px; + margin: 0; + padding: 0; +} + +.form-label { + color: black; + font-size: 14px; +} + +.error { + color: red; + height: 0.1rem; + font-size: 0.75rem; +} + +.none { + height: 0.1rem; + font-size: 0.75rem; +} diff --git a/src/styles/CardFormInput.module.css b/src/styles/CardFormInput.module.css new file mode 100644 index 0000000000..290b5fea45 --- /dev/null +++ b/src/styles/CardFormInput.module.css @@ -0,0 +1,14 @@ +.inputBox { + padding: 8px; + border-radius: 4px; + border: 1.6px solid #ccc; + margin-right: 10px; +} + +.error-inputBox { + border-color: red; +} + +.not-error-inputBox { + border-color: #d5d5d5; +} diff --git a/src/types/cardTypes.ts b/src/types/cardTypes.ts new file mode 100644 index 0000000000..0aebe7742b --- /dev/null +++ b/src/types/cardTypes.ts @@ -0,0 +1,24 @@ +import { INPUTS } from "../constants"; + +export type InputName = (typeof INPUTS)[keyof typeof INPUTS]; + +export type ChangeEvent = React.ChangeEvent & { + target: { + name: InputName; + value: string; + }; +}; + +export type CardData = { + numbers: { + firstBlock: string; + secondBlock: string; + thirdBlock: string; + fourthBlock: string; + }; + expirationDate: { + month: string; + year: string; + }; + owner: string; +}; diff --git a/src/types/errorTypes.ts b/src/types/errorTypes.ts new file mode 100644 index 0000000000..fe8aab8553 --- /dev/null +++ b/src/types/errorTypes.ts @@ -0,0 +1,20 @@ +export interface ErrorType { + hasError: boolean; + errorMessage: string; +} +export interface CardNumberErrorType extends ErrorType { + isDisable: boolean; +} +export type CardFormError = { + numbers: { + firstBlock: CardNumberErrorType; + secondBlock: CardNumberErrorType; + thirdBlock: CardNumberErrorType; + fourthBlock: CardNumberErrorType; + }; + expirationDate: { + month: ErrorType; + year: ErrorType; + }; + owner: ErrorType; +}; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000000..7d313ac1b1 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,2 @@ +export * from "./cardTypes"; +export * from "./errorTypes"; diff --git a/src/utils/cardNumberValidators.tsx b/src/utils/cardNumberValidators.tsx new file mode 100644 index 0000000000..3560513699 --- /dev/null +++ b/src/utils/cardNumberValidators.tsx @@ -0,0 +1,116 @@ +import { + ERROR_MESSAGE, + CARD_LOGO_NUMBER, + MASK_SYMBOL, + CARD_PREFIX_LENGTH, + CARD_NUMBER_MAX_LENGTH, + EMPTY_STRING, + CARD_INFORMATION, +} from "../constants"; +import { isOnlyNumber } from "./validationUtils"; +import visa from "../assets/Visa.svg"; +import master from "../assets/Mastercard.svg"; +import { CardFormError, CardData, CardNumberErrorType } from "../types"; + +// Visa ๋˜๋Š” Master ๋กœ๊ณ  ์„ค์ • +export const setCardLogo = (value: string): string => { + if (value.slice(0, CARD_PREFIX_LENGTH.VISA) === CARD_LOGO_NUMBER.VISA_NUMBER) + return visa; + const first = Number(value.slice(0, CARD_PREFIX_LENGTH.MASTER)); + if ( + CARD_LOGO_NUMBER.MASTER_MIN_NUMBER <= first && + first <= CARD_LOGO_NUMBER.MASTER_MAX_NUMBER + ) + return master; + return EMPTY_STRING; +}; + +// ์ˆซ์ž๋ฅผ 'โ—' ์ฒ˜๋ฆฌ +export const hideNumber = (value: string): string => + value.replace(/[0-9]/g, MASK_SYMBOL); + +// ์นด๋“œ ๋ฒˆํ˜ธ ๊ฒ€์ฆ +export const validCardNumbers = (block: string): CardNumberErrorType => { + if (!isOnlyNumber(block)) + return { + hasError: true, + errorMessage: ERROR_MESSAGE.ONLY_NUMBER, + isDisable: false, + }; + + return { + hasError: false, + errorMessage: EMPTY_STRING, + isDisable: false, + }; +}; + +// ํ•ด๋‹น ๋ธ”๋ก์ด ์ˆซ์ž๋กœ ์ „๋ถ€ ์ฑ„์›Œ์กŒ๋Š”์ง€ ํ™•์ธ +const isBlockFilledWithNumbers = (numbers: string, length: number): boolean => { + return numbers.length === length; +}; + +// ์ด์ „ ๋ธ”๋ก์˜ ๊ฐ’์ด ์ˆซ์ž๋กœ ์ „๋ถ€ ์ฑ„์›Œ์กŒ๋Š”์ง€ ํ™•์ธ +const validBlockIsFull = ( + prevBlock: string, + currentBlock: string +): CardNumberErrorType => { + const isPrevFilled = isBlockFilledWithNumbers( + prevBlock, + CARD_NUMBER_MAX_LENGTH + ); + const isCurrentFilled = isBlockFilledWithNumbers( + currentBlock, + CARD_NUMBER_MAX_LENGTH + ); + + return { + hasError: !isPrevFilled || !isCurrentFilled, + errorMessage: + !isPrevFilled || !isCurrentFilled + ? ERROR_MESSAGE.REQUIRE_FOUR_DIGIT_NUMBER + : EMPTY_STRING, + isDisable: !isPrevFilled, + }; +}; + +// ์นด๋“œ ๋ฒˆํ˜ธ ๋ธ”๋ก ๊ฒ€์ฆ +export const validCardNumbersBlock = ( + cardNumbers: CardData["numbers"] +): CardFormError["numbers"] => { + const result: CardFormError["numbers"] = { + firstBlock: { + hasError: false, + errorMessage: EMPTY_STRING, + isDisable: false, + }, + secondBlock: { + hasError: false, + errorMessage: EMPTY_STRING, + isDisable: false, + }, + thirdBlock: { + hasError: false, + errorMessage: EMPTY_STRING, + isDisable: false, + }, + fourthBlock: { + hasError: false, + errorMessage: EMPTY_STRING, + isDisable: false, + }, + }; + + const cardNumberKeys = CARD_INFORMATION.CARD_NUMBER_BLOCK; + + cardNumberKeys.forEach((key, index) => { + if (index === 0) return; + + result[key] = validBlockIsFull( + cardNumbers[cardNumberKeys[index - 1]], + cardNumbers[cardNumberKeys[index]] + ); + }); + + return result; +}; diff --git a/src/utils/errorHelpers.tsx b/src/utils/errorHelpers.tsx new file mode 100644 index 0000000000..004b40488d --- /dev/null +++ b/src/utils/errorHelpers.tsx @@ -0,0 +1,27 @@ +import type { CardFormError } from "../types"; +import { EMPTY_STRING, CARD_INFORMATION } from "../constants"; + +// ์œ ํšจ๊ธฐ๊ฐ„ ๊ฒ€์ฆ์—์„œ ๊ฐ€์žฅ ์ฒซ๋ฒˆ์งธ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ๋ฐ˜ํ™˜ +export const getFirstExpirationErrorMessage = ( + error: CardFormError["expirationDate"] +): string => { + for (const field of CARD_INFORMATION.EXPIRATION_DATE) { + if (error[field].hasError) { + return error[field].errorMessage; + } + } + return EMPTY_STRING; +}; + +// ์นด๋“œ๋ฒˆํ˜ธ ๊ฒ€์ฆ์—์„œ ๊ฐ€์žฅ ์ฒซ๋ฒˆ์งธ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ๋ฐ˜ํ™˜ +export const getFirstErrorMessage = ( + error: CardFormError["numbers"] +): string => { + for (const field of CARD_INFORMATION.CARD_NUMBER_BLOCK) { + if (error[field].hasError) { + return error[field].errorMessage; + } + } + + return EMPTY_STRING; +}; diff --git a/src/utils/expirationDateValidators.tsx b/src/utils/expirationDateValidators.tsx new file mode 100644 index 0000000000..eed9723891 --- /dev/null +++ b/src/utils/expirationDateValidators.tsx @@ -0,0 +1,110 @@ +import { ERROR_MESSAGE, EMPTY_STRING } from "../constants"; +import { isOnlyNumber } from "./validationUtils"; +import type { ErrorType, CardFormError } from "../types"; + +// ์˜ค๋Š˜ ๋‚ ์งœ, ์—ฐ๋„, ์›” +const today: Date = new Date(); +const currentYear: number = today.getFullYear(); +const currentMonth: number = today.getMonth() + 1; + +// ์ž…๋ ฅํ•œ 2์ž๋ฆฌ ์—ฐ๋„๋ฅผ 4์ž๋ฆฌ ์—ฐ๋„๋กœ ๋ณ€๊ฒฝ +const convertYear = (year: number): number => + Math.floor(currentYear / 100) * 100 + year; + +// ์ˆซ์ž ํ•œ์ž๋ฆฌ ์ž…๋ ฅ ์‹œ ๋‘์ž๋ฆฌ ๋ฐ˜ํ™˜ +const convertTwoLength = (date: string): string => { + return date.length === 1 ? `0${date}` : date; +}; + +// 2๊ธ€์ž๋ฅผ ์ž…๋ ฅํ–ˆ๋Š”์ง€ ๊ฒ€์ฆ +const isLengthTwo = (value: string): boolean => value.length === 2; + +// ์ž…๋ ฅํ•œ ๋‹ฌ์ด 1์›”๋ถ€ํ„ฐ 12์›” ์‚ฌ์ด์ธ์ง€ ๊ฒ€์ฆ +const isInValidMonthRange = (month: number): boolean => + month >= 1 && month <= 12; + +// ์œ ํšจ๊ธฐ๊ฐ„์ด ์ง€๋‚ฌ๋Š”์ง€ ํ™•์ธ +const isNotPastDate = (month: number, year: number): boolean => { + const inputDate: Date = new Date(year, month - 1, 1); + return inputDate >= today; +}; + +// ์œ ํšจ๊ธฐ๊ฐ„์ด 10๋…„ ์ด๋‚ด์ธ์ง€ ํ™•์ธ +const isOverMaxValidYear = (month: number, year: number): boolean => { + const tenYearsLaterDate = new Date(currentYear + 10, currentMonth - 1, 1); + const inputDate = new Date(year, month - 1, 1); + + return inputDate <= tenYearsLaterDate; +}; + +// ์œ ํšจ๊ธฐ๊ฐ„์— ์ž…๋ ฅํ•œ '์›”(Month)' ๊ฒ€์ฆ +const validExpirationMonth = (inputMonth: string): ErrorType => { + if (!isOnlyNumber(inputMonth)) + return { hasError: true, errorMessage: ERROR_MESSAGE.ONLY_NUMBER }; + + if (!isLengthTwo(convertTwoLength(inputMonth))) + return { hasError: true, errorMessage: ERROR_MESSAGE.MIN_LENGTH_TWO }; + + if (!isInValidMonthRange(Number(inputMonth))) + return { hasError: true, errorMessage: ERROR_MESSAGE.VALID_MONTH_RANGE }; + + return { hasError: false, errorMessage: EMPTY_STRING }; +}; + +// ์œ ํšจ๊ธฐ๊ฐ„์— ์ž…๋ ฅํ•œ '์—ฐ๋„(Year)' ๊ฒ€์ฆ +const validExpirationYear = (inputYear: string): ErrorType => { + if (!isOnlyNumber(inputYear)) + return { hasError: true, errorMessage: ERROR_MESSAGE.ONLY_NUMBER }; + + if (!isLengthTwo(convertTwoLength(inputYear))) + return { hasError: true, errorMessage: ERROR_MESSAGE.MIN_LENGTH_TWO }; + + return { hasError: false, errorMessage: EMPTY_STRING }; +}; + +// ์œ ํšจ๊ธฐ๊ฐ„ ๊ฒ€์ฆ ํ›„ ์—๋Ÿฌํƒ€์ž… ๋ฐ˜ํ™˜ +export const validExpirationDate = ( + inputMonth: string, + inputYear: string +): CardFormError["expirationDate"] => { + const monthError: ErrorType = validExpirationMonth(inputMonth); + const yearError: ErrorType = validExpirationYear(inputYear); + + if (monthError.hasError || yearError.hasError) + return { + month: monthError, + year: yearError, + }; + + const monthNumber: number = Number(inputMonth); + const yearNumber: number = convertYear(Number(inputYear)); + + if (!isNotPastDate(monthNumber, yearNumber)) + return { + month: { + hasError: true, + errorMessage: ERROR_MESSAGE.PAST_DATE, + }, + year: { + hasError: true, + errorMessage: ERROR_MESSAGE.PAST_DATE, + }, + }; + + if (!isOverMaxValidYear(monthNumber, yearNumber)) + return { + month: { + hasError: true, + errorMessage: ERROR_MESSAGE.OVER_MAX_VALID_YEAR, + }, + year: { + hasError: true, + errorMessage: ERROR_MESSAGE.OVER_MAX_VALID_YEAR, + }, + }; + + return { + month: monthError, + year: yearError, + }; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000000..13251a7b58 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,6 @@ +export * from "./inputFilters"; +export * from "./ownerNameValidators"; +export * from "./expirationDateValidators"; +export * from "./cardNumberValidators"; +export * from "./errorHelpers"; +export * from "./validationUtils"; diff --git a/src/utils/inputFilters.tsx b/src/utils/inputFilters.tsx new file mode 100644 index 0000000000..dea0fd899b --- /dev/null +++ b/src/utils/inputFilters.tsx @@ -0,0 +1,16 @@ +import { EMPTY_STRING } from "../constants"; + +// ๋Œ€๋ฌธ์ž๋กœ ๋ณ€๊ฒฝ +export const formatStringToUpper = (text: string): string => text.toUpperCase(); + +// ์ˆซ์ž๋งŒ ์ž…๋ ฅ ๊ฐ€๋Šฅ +export const onInputOnlyNumber = (event: React.FormEvent) => { + const input = event.currentTarget; + input.value = input.value.replace(/[^0-9]/g, EMPTY_STRING); +}; + +// ๋ฌธ์ž๋งŒ ์ž…๋ ฅ ๊ฐ€๋Šฅ +export const onInputOnlyString = (event: React.FormEvent) => { + const input = event.currentTarget; + input.value = input.value.replace(/[^a-zA-Z]/g, EMPTY_STRING); +}; diff --git a/src/utils/ownerNameValidators.tsx b/src/utils/ownerNameValidators.tsx new file mode 100644 index 0000000000..db104d4cca --- /dev/null +++ b/src/utils/ownerNameValidators.tsx @@ -0,0 +1,27 @@ +import { ERROR_MESSAGE, EMPTY_STRING } from "../constants"; +import type { ErrorType } from "../types"; + +// ์˜๋ฌธ๋งŒ ์ž…๋ ฅํ–ˆ๋Š”์ง€ ๊ฒ€์ฆ +const isEnglishOnly = (text: string): boolean => /^[A-Za-z\s]+$/.test(text); + +// ์ตœ์†Œ ๋‘๊ธ€์ž ์ด์ƒ์ธ์ง€ ๊ฒ€์ฆ +const isMinLength = (text: string): boolean => text.length >= 2; + +// ์†Œ์œ ์ž ์ด๋ฆ„ ๊ฒ€์ฆ ํ›„ ์—๋Ÿฌ ๋ฐ˜ํ™˜ +export const validOwnerName = (text: string): ErrorType => { + if (!isEnglishOnly(text)) + return { + hasError: true, + errorMessage: ERROR_MESSAGE.ONLY_ENGLISH, + }; + if (!isMinLength(text)) + return { + hasError: true, + errorMessage: ERROR_MESSAGE.MIN_LENGTH_REQUIRED, + }; + + return { + hasError: false, + errorMessage: EMPTY_STRING, + }; +}; diff --git a/src/utils/validationUtils.tsx b/src/utils/validationUtils.tsx new file mode 100644 index 0000000000..43d73eb30a --- /dev/null +++ b/src/utils/validationUtils.tsx @@ -0,0 +1,4 @@ +// ์ˆซ์ž๋งŒ ์ž…๋ ฅํ–ˆ๋Š”์ง€ ๊ฒ€์ฆ +export const isOnlyNumber = (inputValue: string): boolean => { + return /^[0-9]+$/.test(inputValue); +};