diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index 200a166584..5146a4e7a3 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -2,18 +2,31 @@ module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
- "eslint:recommended",
- "plugin:@typescript-eslint/recommended",
- "plugin:react-hooks/recommended",
- "plugin:storybook/recommended",
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react-hooks/recommended',
+ 'plugin:storybook/recommended',
+ 'plugin:react/recommended',
+ 'plugin:import/recommended',
+ 'plugin:jsx-a11y/recommended',
+ 'eslint-config-prettier',
],
- ignorePatterns: ["dist", ".eslintrc.cjs"],
- parser: "@typescript-eslint/parser",
- plugins: ["react-refresh"],
+ ignorePatterns: ['dist', '.eslintrc.cjs'],
+ parser: '@typescript-eslint/parser',
+ plugins: ['react-refresh'],
rules: {
- "react-refresh/only-export-components": [
- "warn",
- { allowConstantExport: true },
- ],
+ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
+ 'react/react-in-jsx-scope': 'off',
+ },
+ settings: {
+ react: {
+ version: 'detect',
+ },
+ 'import/resolver': {
+ node: {
+ paths: ['src'],
+ extensions: ['.js', '.jsx', '.ts', '.tsx'],
+ },
+ },
},
};
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000000..28959948a7
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+v20.0.0
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000000..acb462469b
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,11 @@
+{
+ "singleQuote": true,
+ "semi": true,
+ "useTabs": false,
+ "tabWidth": 2,
+ "trailingComma": "all",
+ "printWidth": 100,
+ "bracketSpacing": true,
+ "arrowParens": "always",
+ "endOfLine": "auto"
+}
diff --git a/.storybook/preview.ts b/.storybook/preview.ts
deleted file mode 100644
index 37914b18f2..0000000000
--- a/.storybook/preview.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import type { Preview } from "@storybook/react";
-
-const preview: Preview = {
- parameters: {
- controls: {
- matchers: {
- color: /(background|color)$/i,
- date: /Date$/i,
- },
- },
- },
-};
-
-export default preview;
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
new file mode 100644
index 0000000000..53995d740d
--- /dev/null
+++ b/.storybook/preview.tsx
@@ -0,0 +1,26 @@
+import type { Preview } from '@storybook/react';
+import GlobalStyles from '../src/GlobalStyles';
+import React from 'react';
+
+const preview: Preview = {
+ parameters: {
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/i,
+ },
+ },
+ layout: 'centered',
+ },
+};
+
+export const decorators = [
+ (Story) => (
+ <>
+
+
+ >
+ ),
+];
+
+export default preview;
diff --git a/README.md b/README.md
index 8d917806e0..983d03929f 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,27 @@
-# react-payments
+# ๐ณ ํ์ด๋จผ์ธ
+
+ํ์ด๋จผ์ธ ์ฌ์ฉ์์ ์นด๋ ์ ๋ณด๋ฅผ ์
๋ ฅํ๊ณ ํด๋น ์นด๋๋ฅผ ์ดํ๋ฆฌ์ผ์ด์
์ ๋ฑ๋กํ๋ค.
+
+### ์นด๋ ๋ฒํธ ์
๋ ฅ ๋ฐ ์๋ณ
+
+- ์ฌ์ฉ์๊ฐ ์
๋ ฅํ๋ ์นด๋ ๋ฒํธ๋ฅผ ์ค์๊ฐ์ผ๋ก ํ์
ํ์ฌ, Visa๋ MasterCard์ ํด๋นํ๋ฉด ํด๋น ๋ธ๋๋์ ๋ก๊ณ ๋ฅผ UI์ ํ์ํ๋ค.
+ - Visa : 4๋ก ์์ํ๋ 16์๋ฆฌ
+ - MasterCard: 51~55๋ก ์์ํ๋ 16์๋ฆฌ ์ซ์
+- ์
๋ ฅ์ ์ซ์๋ง ๊ฐ๋ฅํ๋ฉฐ, ์ ํจํ์ง ์์ ๋ฒํธ ์
๋ ฅ ์ ํผ๋๋ฐฑ์ ์ ๊ณตํ๋ค.
+
+### ์นด๋ ์ ํจ๊ธฐ๊ฐ ์
๋ ฅ
+
+- ์๊ณผ ๋
๋๋ฅผ ๋ฒ์ ๋ด์์๋ง ์
๋ ฅํ ์ ์์ด์ผ ํ๋ฉฐ, ์
๋ ฅ ์ ํ์ ๋์ด ์ฌ์ฉ์๊ฐ ์ซ์๋ง ์
๋ ฅํ ์ ์๋๋ก ํ๋ค.
+
+### ์นด๋ ์์ ์ ์ด๋ฆ ์
๋ ฅ
+
+- ์๋ฌธ์ ๋๋ฌธ์๋ง ์
๋ ฅ ๊ฐ๋ฅํ ํผ์ ๊ตฌํํ๋ค.
+
+### ์ค์๊ฐ ํ๋ฆฌ๋ทฐ ์
๋ฐ์ดํธ
+
+- ์ฌ์ฉ์์ ์นด๋ ์ ๋ณด ์
๋ ฅ์ ๋ฐ๋ผ ์นด๋ ํ๋ฆฌ๋ทฐ๋ฅผ ๋์์ ์
๋ฐ์ดํธํ๋ค.
+
+### โ ๏ธ ์ฃผ์ ์ฌํญ
+
+- ์ฌ์ฉ์์ ์
๋ ฅ input์ ํฌ์ปค์ค๋ฅผ ์๋์ผ๋ก ์ด๋ํ๋ ๋ถ๋ถ์ 1๋จ๊ณ์์ ๊ณ ๋ คํ์ง ์๋๋ค. **'๊ธฐ๋ฅ' ์์ฒด์ ์ง์คํด์ ๊ตฌํ**ํ๋ค. ๋ง์ฝ ๋ฆฌ๋ทฐ์ด๊ฐ ์ฌ์ฉ์ ๊ฒฝํ ์ธก๋ฉด์์ ํผ๋๋ฐฑ์ ์ค๋ค๋ฉด, ํผ๋๋ฐฑ์ ๋ฐ์ํ๋ ์์ ์์ ์ฌ์ฉ์ ๊ฒฝํ๋ ํจ๊ป ๊ณ ๋ คํ์ฌ ๊ฐ์ ํ๋ค.
+- ๊ธฐ๋ณธ์ ์ธ ๊ธฐ๋ฅ ์๊ตฌ์ฌํญ์ ๋ง์กฑํ์ง ๋ชปํ ์ฝ๋๋ ์ฝ๋ ๋ฆฌ๋ทฐ ๋์์ด ์๋๋ค.
diff --git a/package.json b/package.json
index 1613fb4bec..048968a748 100644
--- a/package.json
+++ b/package.json
@@ -9,11 +9,14 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
- "build-storybook": "storybook build"
+ "build-storybook": "storybook build",
+ "chromatic": "npx chromatic --project-token=chpt_a2a6e1e59fdce40"
},
"dependencies": {
"react": "^18.2.0",
- "react-dom": "^18.2.0"
+ "react-dom": "^18.2.0",
+ "styled-components": "^6.1.8",
+ "styled-reset": "^4.5.2"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.3.3",
@@ -30,10 +33,16 @@
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.5.0",
+ "chromatic": "^11.3.0",
"eslint": "^8.57.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-import": "^2.29.1",
+ "eslint-plugin-jsx-a11y": "^6.8.0",
+ "eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"eslint-plugin-storybook": "^0.8.0",
+ "prettier": "^3.2.5",
"storybook": "^8.0.8",
"typescript": "^5.2.2",
"vite": "^5.2.0"
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..c1aa7d86c9 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,9 +1,161 @@
-import "./App.css";
+import GlobalStyles from './GlobalStyles';
+import InputInfo from './components/InputSection';
+import Input from './components/composables/Input';
+import CreditCard from './components/CreditCard';
+import useInput from './hooks/useInput';
+import Label from './components/composables/Label';
+import validate from './utils/validate';
+import { CARD_NUMBER, EXPIRATION_PERIOD, OWNER_NAME } from './constants/cardSection';
+import * as React from 'react';
+import useCardNumber, { InitialCardNumberState } from './hooks/useCardNumber';
+import * as S from './app.style';
+
+const initialCardNumberState: InitialCardNumberState = {
+ value: '',
+ isError: false,
+};
+
+const CARD_NUMBER_LENGTH = 4;
+
+const MONTH = Object.freeze({
+ MIN: 1,
+ MAX: 12,
+});
+
+const MAX_LENGTH = Object.freeze({
+ CARD_NUMBERS: 4,
+ MONTH: 2,
+ YEAR: 2,
+ NAME: 30,
+});
function App() {
+ const { cardNumbers, cardNumbersChangeHandler, cardBrand } = useCardNumber(
+ Array.from({ length: CARD_NUMBER_LENGTH }, () => initialCardNumberState),
+ );
+
+ const {
+ inputValue: month,
+ onChange: monthChangeHandler,
+ error: monthError,
+ } = useInput([
+ {
+ fn: (value) =>
+ validate.isNumberInRange({ min: MONTH.MIN, max: MONTH.MAX, compareNumber: Number(value) }),
+ },
+ { fn: (value) => validate.isValidDigit(value) },
+ ]);
+
+ const {
+ inputValue: year,
+ onChange: yearChangeHandler,
+ error: yearError,
+ } = useInput([{ fn: (value) => validate.isValidDigit(value) }]);
+
+ const {
+ inputValue: name,
+ onChange: nameChangeHandler,
+ error: nameError,
+ } = useInput([{ fn: (value) => validate.isEnglish(value) }]);
+
return (
<>
-
React Payments
+
+
+
+
+
+
+ {cardNumbers.map((cardNumber, index) => {
+ const uniqueId = 'cardNumbers' + index;
+ return (
+
+
+ cardNumbersChangeHandler(e, index)}
+ isError={cardNumber.isError}
+ />
+
+ );
+ })}
+
+
+
+ {cardNumbers.some((cardNumber) => cardNumber.isError) && CARD_NUMBER.errorMessage}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {monthError && yearError ? EXPIRATION_PERIOD.monthErrorMessage : ''}
+ {!monthError && yearError ? EXPIRATION_PERIOD.yearErrorMessage : ''}
+ {monthError && !yearError ? EXPIRATION_PERIOD.monthErrorMessage : ''}
+
+
+
+
+
+
+
+
+
+
+ {nameError && OWNER_NAME.errorMessage}
+
+
+
+
>
);
}
diff --git a/src/GlobalStyles.tsx b/src/GlobalStyles.tsx
new file mode 100644
index 0000000000..aaeb0f44a8
--- /dev/null
+++ b/src/GlobalStyles.tsx
@@ -0,0 +1,45 @@
+import { createGlobalStyle } from 'styled-components';
+import reset from 'styled-reset';
+
+const GlobalStyles = createGlobalStyle`
+ ${reset}
+
+ a{
+ text-decoration: none;
+ color: inherit;
+ }
+
+ *{
+ box-sizing: border-box;
+ }
+
+ #root {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ width: 100vw;
+ height: auto;
+ min-height: 100vh;
+ }
+
+ input, textarea {
+ -moz-user-select: auto;
+ -webkit-user-select: auto;
+ -ms-user-select: auto;
+ user-select: auto;
+ }
+
+ input:focus {
+ outline: none;
+ }
+
+ button {
+ border: none;
+ background: none;
+ padding: 0;
+ cursor: pointer;
+ }
+`;
+
+export default GlobalStyles;
diff --git a/src/app.style.ts b/src/app.style.ts
new file mode 100644
index 0000000000..09417ee5b1
--- /dev/null
+++ b/src/app.style.ts
@@ -0,0 +1,32 @@
+import { styled } from 'styled-components';
+
+export const Container = styled.div`
+ padding: 20px 30px;
+ width: 376px;
+ height: 680px;
+ background-color: beige;
+`;
+
+export const CardInfoContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+`;
+
+export const ErrorContainer = styled.div`
+ height: 14px;
+`;
+
+export const ErrorMessageSpan = styled.span`
+ color: #ff3d3d;
+
+ font-size: 0.5938rem;
+ font-weight: 400;
+ line-height: 0.875rem;
+`;
+
+export const Wrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`;
diff --git a/src/assets/images/mastercard.png b/src/assets/images/mastercard.png
new file mode 100644
index 0000000000..fbe529136b
Binary files /dev/null and b/src/assets/images/mastercard.png differ
diff --git a/src/assets/images/visa.png b/src/assets/images/visa.png
new file mode 100644
index 0000000000..f0fe0edcb1
Binary files /dev/null and b/src/assets/images/visa.png differ
diff --git a/src/components/CreditCard.tsx b/src/components/CreditCard.tsx
new file mode 100644
index 0000000000..493da8a349
--- /dev/null
+++ b/src/components/CreditCard.tsx
@@ -0,0 +1,58 @@
+import { InitialCardNumberState } from '../hooks/useCardNumber';
+import MasterCardImage from '../assets/images/mastercard.png';
+import VisaCardImage from '../assets/images/visa.png';
+import * as S from './creditCard.style';
+
+type CreditCardProps = {
+ cardNumbers: InitialCardNumberState[];
+ month: string;
+ year: string;
+ name: string;
+ cardBrand: 'none' | 'Visa' | 'MasterCard';
+};
+
+const DATE_SEPARATOR = '/';
+
+export default function CreditCard({ cardNumbers, month, year, name, cardBrand }: CreditCardProps) {
+ const cardBrandImageSrc =
+ cardBrand === 'MasterCard' ? MasterCardImage : cardBrand === 'Visa' ? VisaCardImage : '';
+
+ return (
+
+
+
+
+
+
+
+ {cardBrandImageSrc ? (
+
+ ) : null}
+
+
+
+
+
+ {cardNumbers[0].value}
+
+
+ {cardNumbers[1].value}
+
+
+ {Array.from({ length: cardNumbers[2].value.length }).map((_, index) => (
+
+ ))}
+
+
+ {Array.from({ length: cardNumbers[3].value.length }).map((_, index) => (
+
+ ))}
+
+
+ {month + `${month || year ? DATE_SEPARATOR : ''}` + year}
+ {name}
+
+
+
+ );
+}
diff --git a/src/components/InputSection.tsx b/src/components/InputSection.tsx
new file mode 100644
index 0000000000..df827c0aa7
--- /dev/null
+++ b/src/components/InputSection.tsx
@@ -0,0 +1,36 @@
+import { PropsWithChildren } from 'react';
+import * as S from './inputSection.style';
+
+type InfoProps = {
+ title: string;
+ description?: string;
+ inputTitle: string;
+};
+
+export default function InputSection({
+ title,
+ description,
+ inputTitle,
+ children,
+}: PropsWithChildren) {
+ return (
+
+
+
+ {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+
+
+ {inputTitle}
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/composables/Input.tsx b/src/components/composables/Input.tsx
new file mode 100644
index 0000000000..a731f0cc4f
--- /dev/null
+++ b/src/components/composables/Input.tsx
@@ -0,0 +1,28 @@
+import { InputHTMLAttributes } from 'react';
+import * as S from './input.style';
+
+interface InputProps extends InputHTMLAttributes {
+ isError: boolean;
+}
+
+export default function Input({
+ value,
+ onChange,
+ type,
+ placeholder,
+ id,
+ isError,
+ maxLength,
+}: InputProps) {
+ return (
+
+ );
+}
diff --git a/src/components/composables/Label.tsx b/src/components/composables/Label.tsx
new file mode 100644
index 0000000000..5e04384f81
--- /dev/null
+++ b/src/components/composables/Label.tsx
@@ -0,0 +1,8 @@
+import { PropsWithChildren, LabelHTMLAttributes } from 'react';
+import * as S from './label.style';
+
+type LabelProps = LabelHTMLAttributes;
+
+export default function Label({ children, ...props }: PropsWithChildren) {
+ return {children};
+}
diff --git a/src/components/composables/input.style.ts b/src/components/composables/input.style.ts
new file mode 100644
index 0000000000..22fb460e2b
--- /dev/null
+++ b/src/components/composables/input.style.ts
@@ -0,0 +1,15 @@
+import { styled } from 'styled-components';
+
+export const Input = styled.input<{ isError: boolean }>`
+ border: 1px solid #acacac;
+ padding: 8px;
+ font-size: 0.6875rem;
+ border-radius: 2px;
+ min-width: 71.25px;
+ height: 32px;
+ flex: 1;
+
+ &:focus {
+ border: 1px solid ${(props) => (props.isError ? '#ff3d3d' : '#000')};
+ }
+`;
diff --git a/src/components/composables/label.style.ts b/src/components/composables/label.style.ts
new file mode 100644
index 0000000000..9b89f35985
--- /dev/null
+++ b/src/components/composables/label.style.ts
@@ -0,0 +1,13 @@
+import { styled } from 'styled-components';
+
+export const Label = styled.label`
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ border: 0;
+ clip: rect(0 0 0 0);
+`;
diff --git a/src/components/creditCard.style.ts b/src/components/creditCard.style.ts
new file mode 100644
index 0000000000..998091820c
--- /dev/null
+++ b/src/components/creditCard.style.ts
@@ -0,0 +1,87 @@
+import { styled } from 'styled-components';
+
+export const Container = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ margin-top: 50px;
+ margin-bottom: 45px;
+
+ width: 100%;
+`;
+
+export const CardContainer = styled.div`
+ background-color: #333333;
+
+ width: 212px;
+ height: 132px;
+
+ padding: 8px 12px;
+
+ border-radius: 4px;
+`;
+
+export const NumbersContainer = styled.div`
+ display: flex;
+ gap: 10px;
+ min-height: 20px;
+`;
+
+export const NumbersWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 5px;
+
+ min-width: 32px;
+`;
+
+export const Text = styled.span`
+ color: #fff;
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1.25rem;
+`;
+
+export const Dot = styled.span`
+ width: 4px;
+ height: 4px;
+
+ background-color: #fff;
+ border-radius: 50%;
+`;
+
+export const CardHeader = styled.div`
+ display: flex;
+ justify-content: space-between;
+`;
+
+export const CardHeaderContentWrapper = styled.div`
+ width: 36px;
+ height: 22px;
+`;
+
+export const IcChip = styled.div`
+ width: 100%;
+ height: 100%;
+
+ border-radius: 4px;
+
+ background-color: #ddcd78;
+`;
+
+export const CardBrand = styled.img`
+ width: 100%;
+ height: 100%;
+
+ border-radius: 4px;
+`;
+
+export const CardInfoWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ margin-top: 14px;
+ margin-left: 5px;
+`;
diff --git a/src/components/inputSection.style.ts b/src/components/inputSection.style.ts
new file mode 100644
index 0000000000..d7148999a7
--- /dev/null
+++ b/src/components/inputSection.style.ts
@@ -0,0 +1,55 @@
+import { styled } from 'styled-components';
+
+export const Section = styled.section`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+`;
+
+export const TitleContainer = styled.div`
+ display: flex;
+ align-items: center;
+
+ height: 26px;
+`;
+
+export const Title = styled.h2`
+ font-size: 1.125rem;
+ font-weight: 700;
+`;
+
+export const DescriptionContainer = styled.div`
+ height: 14px;
+`;
+
+export const Description = styled.span`
+ line-height: 0.875rem;
+ font-size: 0.5938rem;
+ font-weight: 400;
+ color: #8b95a1;
+`;
+
+export const Span = styled.span`
+ font-weight: 500;
+ font-size: 0.75rem;
+ line-height: 0.9375rem;
+`;
+
+export const SpanWrapper = styled.div``;
+
+export const InputWrapper = styled.div`
+ display: flex;
+ gap: 10px;
+`;
+
+export const TitleDescriptionWrapper = styled.div`
+ display: 'flex';
+ flex-direction: 'column';
+ gap: '4px';
+`;
+
+export const TitleChildrenWrapper = styled.div`
+ display: 'flex';
+ flex-direction: 'column';
+ gap: '8px';
+`;
diff --git a/src/constants/cardSection.ts b/src/constants/cardSection.ts
new file mode 100644
index 0000000000..4d3d5f335b
--- /dev/null
+++ b/src/constants/cardSection.ts
@@ -0,0 +1,22 @@
+const NUMBER_ERROR_MESSAGE = '์ซ์๋ง ์
๋ ฅ ๊ฐ๋ฅํฉ๋๋ค.';
+
+export const CARD_NUMBER = Object.freeze({
+ title: '๊ฒฐ์ ํ ์นด๋ ๋ฒํธ๋ฅผ ์
๋ ฅํด ์ฃผ์ธ์',
+ description: '๋ณธ์ธ ๋ช
์์ ์นด๋๋ง ๊ฒฐ์ ๊ฐ๋ฅํฉ๋๋ค.',
+ inputTitle: '์นด๋ ๋ฒํธ',
+ errorMessage: NUMBER_ERROR_MESSAGE,
+});
+
+export const EXPIRATION_PERIOD = Object.freeze({
+ title: '์นด๋ ์ ํจ๊ธฐ๊ฐ์ ์
๋ ฅํด ์ฃผ์ธ์',
+ description: '์/๋
๋(MMYY)๋ฅผ ์์๋๋ก ์
๋ ฅํด ์ฃผ์ธ์.',
+ inputTitle: '์ ํจ๊ธฐ๊ฐ',
+ monthErrorMessage: '1๋ถํฐ 12์ฌ์ด์ ์ซ์๋ง ์
๋ ฅ ๊ฐ๋ฅํฉ๋๋ค.',
+ yearErrorMessage: NUMBER_ERROR_MESSAGE,
+});
+
+export const OWNER_NAME = Object.freeze({
+ title: '์นด๋ ์์ ์ ์ด๋ฆ์ ์
๋ ฅํด์ฃผ์ธ์',
+ inputTitle: '์์ ์ ์ด๋ฆ',
+ errorMessage: '์๋ฌธ ๋๋ฌธ์๋ง ์
๋ ฅ ๊ฐ๋ฅํฉ๋๋ค.',
+});
diff --git a/src/hooks/useCardNumber.ts b/src/hooks/useCardNumber.ts
new file mode 100644
index 0000000000..ae3c2989cb
--- /dev/null
+++ b/src/hooks/useCardNumber.ts
@@ -0,0 +1,54 @@
+import { useState } from 'react';
+import validate from '../utils/validate';
+
+export type InitialCardNumberState = {
+ value: string;
+ isError: boolean;
+};
+
+const MAX_CARD_NUMBER_LENGTH = 16;
+
+const useCardNumber = (initialStates: InitialCardNumberState[]) => {
+ const [cardNumbers, setCardNumbers] = useState(initialStates);
+ const [cardBrand, setCardBrand] = useState<'none' | 'Visa' | 'MasterCard'>('none');
+
+ const handleCardBrandImage = (totalCardNumbers: string) => {
+ if (validate.isVisa(totalCardNumbers)) {
+ setCardBrand('Visa');
+ return;
+ }
+
+ if (validate.isMasterCard(totalCardNumbers)) {
+ setCardBrand('MasterCard');
+ return;
+ }
+
+ setCardBrand('none');
+ };
+
+ const cardNumbersChangeHandler = (e: React.ChangeEvent, index: number) => {
+ const newValue = e.target.value;
+ const isValid = newValue === '' || validate.isValidDigit(newValue);
+
+ const newCardNumbers = cardNumbers.map((cardNumber, i) => {
+ if (i === index) {
+ return {
+ value: isValid ? newValue : cardNumber.value,
+ isError: !isValid,
+ };
+ }
+ return cardNumber;
+ });
+
+ const totalCardNumbers = newCardNumbers.map((card) => card.value).join('');
+ if (totalCardNumbers.length === MAX_CARD_NUMBER_LENGTH) {
+ handleCardBrandImage(totalCardNumbers);
+ }
+
+ setCardNumbers(newCardNumbers);
+ };
+
+ return { cardNumbers, cardNumbersChangeHandler, cardBrand };
+};
+
+export default useCardNumber;
diff --git a/src/hooks/useInput.ts b/src/hooks/useInput.ts
new file mode 100644
index 0000000000..a297d6aab6
--- /dev/null
+++ b/src/hooks/useInput.ts
@@ -0,0 +1,25 @@
+import { useState } from 'react';
+
+const useInput = (validators: { fn: (value: string) => boolean }[]) => {
+ const [inputValue, setInputValue] = useState('');
+ const [error, setError] = useState(false);
+
+ const onChange = (e: React.ChangeEvent) => {
+ setError(false);
+ const inputValue = e.target.value.toUpperCase();
+
+ for (let validator of validators) {
+ if (!validator.fn(inputValue)) {
+ setError(true);
+ setInputValue('');
+ return;
+ }
+ }
+
+ setInputValue(e.target.value.toUpperCase());
+ };
+
+ return { inputValue, onChange, error };
+};
+
+export default useInput;
diff --git a/src/stories/App.stories.tsx b/src/stories/App.stories.tsx
index 7c5f8946bc..e0df252810 100644
--- a/src/stories/App.stories.tsx
+++ b/src/stories/App.stories.tsx
@@ -1,8 +1,8 @@
-import type { Meta, StoryObj } from "@storybook/react";
-import App from "../App";
+import type { Meta, StoryObj } from '@storybook/react';
+import App from '../App';
const meta = {
- title: "App",
+ title: 'App',
component: App,
} satisfies Meta;
diff --git a/src/stories/CreditCard.stories.tsx b/src/stories/CreditCard.stories.tsx
new file mode 100644
index 0000000000..4cd5bbf264
--- /dev/null
+++ b/src/stories/CreditCard.stories.tsx
@@ -0,0 +1,79 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import CreditCard from '../components/CreditCard';
+
+const meta = {
+ title: 'CreditCard',
+ component: CreditCard,
+ tags: ['autodocs'],
+ argTypes: {
+ cardNumbers: {
+ control: 'object',
+ description: '์นด๋ ๋ฒํธ ์
๋ ฅ ๊ฐ',
+ },
+ month: {
+ control: 'text',
+ description: '์นด๋ ์ ์
๋ ฅ ๊ฐ',
+ },
+ year: {
+ control: 'text',
+ description: '์นด๋ ์ฐ๋ ์
๋ ฅ ๊ฐ',
+ },
+ name: {
+ control: 'text',
+ description: '์นด๋ ์์ ์ ์
๋ ฅ ๊ฐ',
+ },
+ cardBrand: {
+ control: 'select',
+ options: ['none', 'MasterCard', 'Visa'],
+ description: '์นด๋ ๋ธ๋๋ ์ด๋ฏธ์ง',
+ },
+ },
+} satisfies Meta;
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ cardNumbers: [
+ { value: '0000', isError: false },
+ { value: '0000', isError: false },
+ { value: '0000', isError: false },
+ { value: '0000', isError: false },
+ ],
+ month: '00',
+ year: '00',
+ name: 'JOHN DOE',
+ cardBrand: 'none',
+ },
+};
+
+export const Visa: Story = {
+ args: {
+ cardNumbers: [
+ { value: '4321', isError: false },
+ { value: '1234', isError: false },
+ { value: '1234', isError: false },
+ { value: '9876', isError: false },
+ ],
+ month: '12',
+ year: '29',
+ name: 'LIM DONGJUN',
+ cardBrand: 'Visa',
+ },
+};
+
+export const Master: Story = {
+ args: {
+ cardNumbers: [
+ { value: '5323', isError: false },
+ { value: '1234', isError: false },
+ { value: '4321', isError: false },
+ { value: '9872', isError: false },
+ ],
+ month: '12',
+ year: '29',
+ name: 'LIM DONGJUN',
+ cardBrand: 'MasterCard',
+ },
+};
diff --git a/src/stories/Input.stories.tsx b/src/stories/Input.stories.tsx
new file mode 100644
index 0000000000..c8a525359b
--- /dev/null
+++ b/src/stories/Input.stories.tsx
@@ -0,0 +1,73 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import Input from '../components/composables/Input';
+import { fn } from '@storybook/test';
+
+const meta = {
+ title: 'Composable/Input',
+ component: Input,
+ tags: ['autodocs'],
+ argTypes: {
+ value: {
+ control: 'text',
+ description: 'value ์์ฑ',
+ },
+ id: {
+ control: 'text',
+ description: 'id ์์ฑ',
+ },
+ type: {
+ options: ['text', 'number', 'email', 'password', 'tel'],
+ description: 'type ์์ฑ',
+ },
+ placeholder: {
+ control: 'string',
+ description: 'placeholder ์์ฑ',
+ },
+ isError: {
+ control: 'boolean',
+ description: '์๋ฌ ๊ฐ์ง ์์ฑ ๊ฐ',
+ },
+ maxLength: {
+ control: 'number',
+ description: '์
๋ ฅ๊ฐ ์ต๋ ๊ธธ์ด',
+ },
+ },
+ args: {
+ onChange: fn(),
+ },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const CardNumber: Story = {
+ args: {
+ id: '',
+ value: '',
+ type: 'text',
+ placeholder: '1234',
+ isError: false,
+ maxLength: 4,
+ },
+};
+
+export const Month: Story = {
+ args: {
+ id: '',
+ value: '',
+ type: 'text',
+ placeholder: 'MM',
+ isError: false,
+ maxLength: 2,
+ },
+};
+
+export const Year: Story = {
+ args: {
+ id: '',
+ value: '',
+ type: 'text',
+ placeholder: 'YY',
+ isError: false,
+ maxLength: 2,
+ },
+};
diff --git a/src/stories/InputSection.stories.tsx b/src/stories/InputSection.stories.tsx
new file mode 100644
index 0000000000..5db0e427a5
--- /dev/null
+++ b/src/stories/InputSection.stories.tsx
@@ -0,0 +1,52 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import InputSection from '../components/InputSection';
+import { CARD_NUMBER, EXPIRATION_PERIOD, OWNER_NAME } from '../constants/cardSection';
+
+const meta = {
+ title: 'InputSection',
+ component: InputSection,
+ tags: ['autodocs'],
+ argTypes: {
+ title: {
+ control: 'text',
+ description: '์ ๋ชฉ',
+ },
+ description: {
+ control: 'text',
+ description: '์ค๋ช
',
+ },
+ inputTitle: {
+ control: 'text',
+ description: '๋ผ๋ฒจ',
+ },
+ },
+} satisfies Meta;
+export default meta;
+
+type Story = StoryObj;
+
+export const Title: Story = {
+ args: {
+ title: CARD_NUMBER.title,
+ description: CARD_NUMBER.description,
+ inputTitle: CARD_NUMBER.inputTitle,
+ children: <>>,
+ },
+};
+
+export const ExpirationPeriod: Story = {
+ args: {
+ title: EXPIRATION_PERIOD.title,
+ description: EXPIRATION_PERIOD.description,
+ inputTitle: EXPIRATION_PERIOD.inputTitle,
+ children: <>>,
+ },
+};
+
+export const OwnerName: Story = {
+ args: {
+ title: OWNER_NAME.title,
+ inputTitle: OWNER_NAME.inputTitle,
+ children: <>>,
+ },
+};
diff --git a/src/utils/validate.ts b/src/utils/validate.ts
new file mode 100644
index 0000000000..1303a697dc
--- /dev/null
+++ b/src/utils/validate.ts
@@ -0,0 +1,31 @@
+const validate = {
+ isNumberInRange: ({
+ min,
+ max,
+ compareNumber,
+ }: {
+ min: number;
+ max: number;
+ compareNumber: number;
+ }) => {
+ return compareNumber >= min && compareNumber <= max;
+ },
+
+ isValidDigit: (value: string) => {
+ return /^\d+$/.test(value);
+ },
+
+ isVisa: (value: string) => {
+ return /\b4\d{15}\b/.test(value);
+ },
+
+ isMasterCard: (value: string) => {
+ return /\b5[1-5]\d{14}\b/.test(value);
+ },
+
+ isEnglish: (value: string) => {
+ return /^[a-zA-Z ]*$/.test(value);
+ },
+};
+
+export default validate;