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..4d46e40edd 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..8aca249949 --- /dev/null +++ b/src/components/Card.tsx @@ -0,0 +1,40 @@ +import styles from "../styles/Card.module.css"; +import type { CardNumbers, ExpirationDate } from "../types/cardTypes"; +import { hideNumber, setCardLogo } from "../utils/cardNumberValidators"; + +interface CardProps { + cardNumbers: CardNumbers; + expirationDate: ExpirationDate; + owner: string; +} + +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}` + : ""; + 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..705321cd15 --- /dev/null +++ b/src/components/CardFormInput.tsx @@ -0,0 +1,43 @@ +import React 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; +} + +function CardFormInput({ + name, + cardPlaceHolder, + cardInput, + handleChange, + width, + isDisable, + hasError, +}: CardFormProps) { + return ( + <> + + + ); +} + +export default CardFormInput; 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..3fb2c8442e --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,3 @@ +export * from "./errorMessege"; +export * from "./textConstants"; +export * from "./usingNumbers"; diff --git a/src/constants/textConstants.ts b/src/constants/textConstants.ts new file mode 100644 index 0000000000..d4aada3276 --- /dev/null +++ b/src/constants/textConstants.ts @@ -0,0 +1,22 @@ +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", +}; + +export { CARD_FORM_LABELS, CARD_LABELS, CARD_PLACEHOLDERS }; diff --git a/src/constants/usingNumbers.ts b/src/constants/usingNumbers.ts new file mode 100644 index 0000000000..67a5f3243b --- /dev/null +++ b/src/constants/usingNumbers.ts @@ -0,0 +1,14 @@ +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 MASK_SYMBOL = "*"; 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..8007aea4cb --- /dev/null +++ b/src/pages/Home.tsx @@ -0,0 +1,208 @@ +import { useState } 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, +} from "../constants/textConstants"; +import type { + ChangeEvent, + ExpirationDate, + CardNumbers, + CardError, + ExpirationDateError, + CardNumbersError, +} from "../types"; +import { + formatStringToUpper, + validOwnerName, + convertMonth, + validExpirationDate, + filterNumber, + filterString, + getFirstExpirationErrorMessage, + getFirstErrorMessage, + validCardNumbersBlock, +} from "../utils"; +import styles from "../styles/CardForm.module.css"; + +function Home() { + const [expirationDate, setExpirationDate] = useState({ + month: "", + year: "", + }); + + const [cardNumbers, setCardNumbers] = useState({ + firstBlock: "", + secondBlock: "", + thirdBlock: "", + fourthBlock: "", + }); + + const [owner, setOwner] = useState(""); + + const [cardError, setCardError] = useState({ + cardNumbersError: { + firstBlock: { hasError: false, errorMessage: "", isDisable: false }, + secondBlock: { hasError: false, errorMessage: "", isDisable: true }, + thirdBlock: { hasError: false, errorMessage: "", isDisable: true }, + fourthBlock: { hasError: false, errorMessage: "", isDisable: true }, + }, + expirationDateError: { + month: { hasError: false, errorMessage: "" }, + year: { hasError: false, errorMessage: "" }, + }, + ownerError: { hasError: false, errorMessage: "" }, + }); + + const handleExpirationDateChange = (event: ChangeEvent) => { + const name = event.target.name; + const value = event.target.value; + + const filterValue = filterNumber( + name === "month" ? convertMonth(value) : value, + 2 + ); + + const newDate = { + ...expirationDate, + [name]: filterValue, + }; + + const validResult = validExpirationDate(newDate.month, newDate.year); + + setCardError((pre) => ({ + ...pre, + expirationDateError: validResult, + })); + + setExpirationDate({ + month: newDate.month, + year: newDate.year, + }); + }; + + const handleOwnerChange = (event: ChangeEvent) => { + const value = filterString(event.target.value, 20); + + const validResult = validOwnerName(event.target.value); + + setCardError((pre) => ({ + ...pre, + ownerError: 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]: filterNumber(value, 4), + }; + + const result: CardNumbersError = validCardNumbersBlock(numberBlock); + + setCardError((pre) => ({ + ...pre, + cardNumbersError: result, + })); + + setCardNumbers({ + firstBlock: numberBlock.firstBlock, + secondBlock: numberBlock.secondBlock, + thirdBlock: numberBlock.thirdBlock, + fourthBlock: numberBlock.fourthBlock, + }); + }; + + return ( + <> + + + + {Object.entries(cardNumbers).map(([key, value]) => ( + + ))} + {cardError.cardNumbersError && ( +

+ {getFirstErrorMessage(cardError.cardNumbersError)} +

+ )} + + + {Object.entries(expirationDate).map(([key, value]) => ( + + ))} + {cardError.expirationDateError && ( +

+ {getFirstExpirationErrorMessage(cardError.expirationDateError)} +

+ )} + + + + {cardError.ownerError && ( +

{cardError.ownerError.errorMessage}

+ )} + + ); +} + +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..0818c10d67 --- /dev/null +++ b/src/styles/CardForm.module.css @@ -0,0 +1,21 @@ +.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; + font-size: 12px; +} 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..51113ac7ef --- /dev/null +++ b/src/types/cardTypes.ts @@ -0,0 +1,13 @@ +export type ChangeEvent = React.ChangeEvent; + +export interface ExpirationDate { + month: string; + year: string; +} + +export interface CardNumbers { + firstBlock: string; + secondBlock: string; + thirdBlock: string; + fourthBlock: string; +} diff --git a/src/types/errorType.ts b/src/types/errorType.ts new file mode 100644 index 0000000000..6074bf525e --- /dev/null +++ b/src/types/errorType.ts @@ -0,0 +1,26 @@ +export interface CardError { + cardNumbersError: CardNumbersError; + expirationDateError: ExpirationDateError; + ownerError: ErrorType; +} + +export interface ErrorType { + hasError: boolean; + errorMessage: string; +} + +export interface CardNumberBlockError extends ErrorType { + isDisable: boolean; +} + +export interface ExpirationDateError { + month: ErrorType; + year: ErrorType; +} + +export interface CardNumbersError { + firstBlock: CardNumberBlockError; + secondBlock: CardNumberBlockError; + thirdBlock: CardNumberBlockError; + fourthBlock: CardNumberBlockError; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000000..d9674448f4 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,2 @@ +export * from "./cardTypes"; +export * from "./errorType"; diff --git a/src/utils/cardNumberValidators.tsx b/src/utils/cardNumberValidators.tsx new file mode 100644 index 0000000000..fb42e71cef --- /dev/null +++ b/src/utils/cardNumberValidators.tsx @@ -0,0 +1,83 @@ +import { ERROR_MESSAGE } from "../constants"; +import { isOnlyNumber } from "./inputFilters"; +import visa from "../assets/Visa.svg"; +import master from "../assets/Mastercard.svg"; +import { CardNumberBlockError, CardNumbers, CardNumbersError } from "../types"; +import { + CARD_LOGO_NUMBER, + MASK_SYMBOL, + CARD_PREFIX_LENGTH, + CARD_NUMBER_MAX_LENGTH, +} from "../constants"; + +// 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 ""; +}; + +// ์ˆซ์ž๋ฅผ '*' ์ฒ˜๋ฆฌ +export const hideNumber = (value: string): string => + value.replace(/[0-9]/g, MASK_SYMBOL); + +// ์นด๋“œ ๋ฒˆํ˜ธ ๊ฒ€์ฆ +export const validCardNumbers = (block: string): CardNumberBlockError => { + if (!isOnlyNumber(block)) + return { + hasError: true, + errorMessage: ERROR_MESSAGE.ONLY_NUMBER, + isDisable: false, + }; + + return { + hasError: false, + errorMessage: "", + isDisable: false, + }; +}; + +// ์นด๋“œ ๋ฒˆํ˜ธ ๋ธ”๋ก ๊ฒ€์ฆ +export const validCardNumbersBlock = ( + cardNumbers: CardNumbers +): CardNumbersError => { + type CardNumbersKeys = keyof CardNumbers; + const keys: CardNumbersKeys[] = [ + "firstBlock", + "secondBlock", + "thirdBlock", + "fourthBlock", + ]; + const result: CardNumbersError = { + firstBlock: { hasError: false, errorMessage: "", isDisable: false }, + secondBlock: { hasError: false, errorMessage: "", isDisable: false }, + thirdBlock: { hasError: false, errorMessage: "", isDisable: false }, + fourthBlock: { hasError: false, errorMessage: "", isDisable: false }, + }; + + keys.forEach((key, index) => { + if (index === 0) return; + + const preValue = cardNumbers[keys[index - 1]]; + const currentValue = cardNumbers[keys[index]]; + const isPrevFilled = preValue.length === CARD_NUMBER_MAX_LENGTH; + const isCurrentFilled = currentValue.length === CARD_NUMBER_MAX_LENGTH; + + result[key] = { + hasError: !isPrevFilled || !isCurrentFilled, + errorMessage: + !isPrevFilled || !isCurrentFilled + ? ERROR_MESSAGE.REQUIRE_FOUR_DIGIT_NUMBER + : "", + isDisable: !isPrevFilled, + }; + }); + + return result; +}; diff --git a/src/utils/errorHelpers.tsx b/src/utils/errorHelpers.tsx new file mode 100644 index 0000000000..4d85d0777a --- /dev/null +++ b/src/utils/errorHelpers.tsx @@ -0,0 +1,17 @@ +import type { ExpirationDateError, CardNumbersError } from "../types"; + +// ์œ ํšจ๊ธฐ๊ฐ„ ๊ฒ€์ฆ์—์„œ ๊ฐ€์žฅ ์ฒซ๋ฒˆ์งธ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ๋ฐ˜ํ™˜ +export const getFirstExpirationErrorMessage = ( + error: ExpirationDateError +): string => { + if (error.month.hasError) return error.month.errorMessage; + if (error.year.hasError) return error.year.errorMessage; + + return ""; +}; + +// ์นด๋“œ๋ฒˆํ˜ธ ๊ฒ€์ฆ์—์„œ ๊ฐ€์žฅ ์ฒซ๋ฒˆ์งธ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ๋ฐ˜ํ™˜ +export const getFirstErrorMessage = (error: CardNumbersError): string => { + const errorBlock = Object.values(error).find((block) => block.hasError); + return errorBlock ? errorBlock.errorMessage : ""; +}; diff --git a/src/utils/expirationDateValidators.tsx b/src/utils/expirationDateValidators.tsx new file mode 100644 index 0000000000..b4c94be801 --- /dev/null +++ b/src/utils/expirationDateValidators.tsx @@ -0,0 +1,112 @@ +import { ERROR_MESSAGE } from "../constants"; +import { isOnlyNumber } from "./inputFilters"; +import type { ErrorType, ExpirationDateError } 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; + +// ๋‘์ž๋ฆฌ๋กœ ์›”์„ ๋ฐ˜ํ™˜ (ex. 3์›” ์ž…๋ ฅ ์‹œ 03์›”๋กœ ๋ณ€ํ™˜) +export const convertMonth = (month: string): string => { + const monthNumber = Number(month); + if (isNaN(monthNumber)) return ""; + return monthNumber < 10 ? `0${monthNumber}` : `${monthNumber}`; +}; + +// 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(convertMonth(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: "" }; +}; + +// ์œ ํšจ๊ธฐ๊ฐ„์— ์ž…๋ ฅํ•œ '์—ฐ๋„(Year)' ๊ฒ€์ฆ +const validExpirationYear = (inputYear: string): ErrorType => { + if (!isOnlyNumber(inputYear)) + return { hasError: true, errorMessage: ERROR_MESSAGE.ONLY_NUMBER }; + + if (!isLengthTwo(convertMonth(inputYear))) + return { hasError: true, errorMessage: ERROR_MESSAGE.MIN_LENGTH_TWO }; + + return { hasError: false, errorMessage: "" }; +}; + +// ์œ ํšจ๊ธฐ๊ฐ„ ๊ฒ€์ฆ ํ›„ ์—๋Ÿฌํƒ€์ž… ๋ฐ˜ํ™˜ +export const validExpirationDate = ( + inputMonth: string, + inputYear: string +): ExpirationDateError => { + 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..1c84796346 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,5 @@ +export * from "./inputFilters"; +export * from "./ownerNameValidators"; +export * from "./expirationDateValidators"; +export * from "./cardNumberValidators"; +export * from "./errorHelpers"; diff --git a/src/utils/inputFilters.tsx b/src/utils/inputFilters.tsx new file mode 100644 index 0000000000..3409721b9b --- /dev/null +++ b/src/utils/inputFilters.tsx @@ -0,0 +1,28 @@ +// ์ˆซ์ž๋งŒ ์ž…๋ ฅํ–ˆ๋Š”์ง€ ๊ฒ€์ฆ +export const isOnlyNumber = (inputValue: string): boolean => { + return /^[0-9]+$/.test(inputValue); +}; + +// ๊ธ€์ž์ˆ˜ ์ œํ•œ +const limitToDigits = (value: string, max: number) => { + return value.length <= 2 ? value : value.slice(0, max); +}; + +// ์ˆซ์ž๋งŒ ์ž…๋ ฅ ๊ฐ€๋Šฅ +const inputOnlyNumber = (value: string): string => value.replace(/[^0-9]/g, ""); + +// ์˜์–ด๋งŒ ์ž…๋ ฅ ๊ฐ€๋Šฅ +const inputOnlyEnglish = (value: string): string => + value.replace(/[^a-zA-Z\s]/g, ""); + +// ์ž…๋ ฅํ•œ max ์ˆ˜๋งŒํผ ์ˆซ์ž๋งŒ ์ž…๋ ฅ ๊ฐ€๋Šฅ +export const filterNumber = (value: string, max: number) => { + const result = limitToDigits(value, max); + return inputOnlyNumber(result); +}; + +// ์ž…๋ ฅํ•œ max ์ˆ˜๋งŒํผ ์˜์–ด๋งŒ ์ž…๋ ฅ ๊ฐ€๋Šฅ +export const filterString = (value: string, max: number) => { + const result = limitToDigits(value, max); + return inputOnlyEnglish(result); +}; diff --git a/src/utils/ownerNameValidators.tsx b/src/utils/ownerNameValidators.tsx new file mode 100644 index 0000000000..cb5a0fac18 --- /dev/null +++ b/src/utils/ownerNameValidators.tsx @@ -0,0 +1,30 @@ +import { ERROR_MESSAGE } from "../constants"; +import type { ErrorType } from "../types"; + +// ๋Œ€๋ฌธ์ž๋กœ ๋ณ€๊ฒฝ +export const formatStringToUpper = (text: string): string => text.toUpperCase(); + +// ์˜๋ฌธ๋งŒ ์ž…๋ ฅํ–ˆ๋Š”์ง€ ๊ฒ€์ฆ +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: "", + }; +};