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