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);
+};