diff --git a/.storybook/main.ts b/.storybook/main.ts index 69ac0074d7..fe998862ea 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,20 +1,21 @@ -import type { StorybookConfig } from "@storybook/react-vite"; +import type { StorybookConfig } from '@storybook/react-vite'; const config: StorybookConfig = { - stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], addons: [ - "@storybook/addon-onboarding", - "@storybook/addon-links", - "@storybook/addon-essentials", - "@chromatic-com/storybook", - "@storybook/addon-interactions", + '@storybook/addon-onboarding', + '@storybook/addon-links', + '@storybook/addon-essentials', + '@chromatic-com/storybook', + '@storybook/addon-interactions', + 'storybook-addon-remix-react-router', ], framework: { - name: "@storybook/react-vite", + name: '@storybook/react-vite', options: {}, }, docs: { - autodocs: "tag", + autodocs: 'tag', }, }; export default config; diff --git a/README-STEP2.md b/README-STEP2.md new file mode 100644 index 0000000000..921f6878a1 --- /dev/null +++ b/README-STEP2.md @@ -0,0 +1,25 @@ +# 동적 입력 UI 구현 + +사용자는 카드 번호를 입력할 때 동적으로 제공되는 입력 UI를 통해 집중적으로 하나의 입력 필드에만 집중할 수 있다. + +# 카드사 선택 + +사용자는 카드사를 선택할 수 있고, 카드사에 따라 미리보기 카드의 색상을 변경한다. + +# CVC 번호 + +CVC 번호를 입력할 때는 미리보기 카드의 뒷면을 시각적으로 보여준다. + +입력은 숫자만 가능하며, 유효하지 않은 값을 입력 시 피드백을 제공한다. + +# 폼 제출 및 상태 관리 + +모든 카드 정보가 정확하게 입력되고 검증되었을 때 확인 버튼이 활성화된다. + +사용자가 입력한 정보 중 하나라도 지우거나 수정하여 유효하지 않게 되면, 확인 버튼은 보이지 않는다. + +# 카드 등록 완료 및 네비게이션 + +확인 버튼을 클릭하면 사용자는 카드 등록 완료 페이지로 이동한다. + +카드 등록 완료 페이지에서는 카드 등록이 성공적으로 완료되었다는 메시지와 함께, 다시 카드 등록 페이지로 돌아갈 수 있는 확인 버튼을 제공한다. diff --git a/index.html b/index.html index 4d7b24e64f..10be9b7d91 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - - + + diff --git a/package.json b/package.json index 048968a748..df249f8108 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.23.0", "styled-components": "^6.1.8", "styled-reset": "^4.5.2" }, @@ -44,6 +45,7 @@ "eslint-plugin-storybook": "^0.8.0", "prettier": "^3.2.5", "storybook": "^8.0.8", + "storybook-addon-remix-react-router": "^3.0.0", "typescript": "^5.2.2", "vite": "^5.2.0" } diff --git a/src/App.tsx b/src/App.tsx index c1aa7d86c9..a85552aa92 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,161 +1,11 @@ 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) }]); +import { PropsWithChildren } from 'react'; +function App({ children }: PropsWithChildren) { return ( <> - - - - - - {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} - - - - + {children} ); } diff --git a/src/GlobalStyles.tsx b/src/GlobalStyles.tsx index aaeb0f44a8..6e4ad0c3cb 100644 --- a/src/GlobalStyles.tsx +++ b/src/GlobalStyles.tsx @@ -4,6 +4,10 @@ import reset from 'styled-reset'; const GlobalStyles = createGlobalStyle` ${reset} + select option[value=""][disabled] { + display: none; +} + a{ text-decoration: none; color: inherit; @@ -14,10 +18,6 @@ const GlobalStyles = createGlobalStyle` } #root { - display: flex; - justify-content: center; - align-items: center; - width: 100vw; height: auto; min-height: 100vh; diff --git a/src/app.style.ts b/src/app.style.ts index 09417ee5b1..738b533b6d 100644 --- a/src/app.style.ts +++ b/src/app.style.ts @@ -4,7 +4,17 @@ export const Container = styled.div` padding: 20px 30px; width: 376px; height: 680px; - background-color: beige; + overflow: scroll; + position: relative; +`; + +export const ContentCard = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + min-height: 100vh; `; export const CardInfoContainer = styled.div` @@ -30,3 +40,13 @@ export const Wrapper = styled.div` flex-direction: column; gap: 8px; `; + +export const ButtonContainer = styled.div` + position: fixed; + bottom: 0px; + right: 0; + left: 0; + width: 376px; + margin: 0px auto; + height: 52px; +`; diff --git a/src/assets/images/complete.png b/src/assets/images/complete.png new file mode 100644 index 0000000000..1a611652dc Binary files /dev/null and b/src/assets/images/complete.png differ diff --git a/src/components/CreditCard.tsx b/src/components/cards/CreditCard.tsx similarity index 72% rename from src/components/CreditCard.tsx rename to src/components/cards/CreditCard.tsx index 493da8a349..734491615f 100644 --- a/src/components/CreditCard.tsx +++ b/src/components/cards/CreditCard.tsx @@ -1,6 +1,7 @@ -import { InitialCardNumberState } from '../hooks/useCardNumber'; -import MasterCardImage from '../assets/images/mastercard.png'; -import VisaCardImage from '../assets/images/visa.png'; +import { InitialCardNumberState } from '@/types'; +import MasterCardImage from '../../assets/images/mastercard.png'; +import VisaCardImage from '../../assets/images/visa.png'; +import * as C from './index.style'; import * as S from './creditCard.style'; type CreditCardProps = { @@ -9,24 +10,32 @@ type CreditCardProps = { year: string; name: string; cardBrand: 'none' | 'Visa' | 'MasterCard'; + backgroundColor: string; }; const DATE_SEPARATOR = '/'; -export default function CreditCard({ cardNumbers, month, year, name, cardBrand }: CreditCardProps) { +export default function CreditCard({ + cardNumbers, + month, + year, + name, + cardBrand, + backgroundColor, +}: CreditCardProps) { const cardBrandImageSrc = cardBrand === 'MasterCard' ? MasterCardImage : cardBrand === 'Visa' ? VisaCardImage : ''; return ( - - + + {cardBrandImageSrc ? ( - + ) : null} @@ -52,7 +61,7 @@ export default function CreditCard({ cardNumbers, month, year, name, cardBrand } {month + `${month || year ? DATE_SEPARATOR : ''}` + year} {name} - - + + ); } diff --git a/src/components/cards/CvcCard.tsx b/src/components/cards/CvcCard.tsx new file mode 100644 index 0000000000..969cd5b572 --- /dev/null +++ b/src/components/cards/CvcCard.tsx @@ -0,0 +1,18 @@ +import * as S from './cvcCard.style'; +import * as C from './index.style'; + +type CvcCardProps = { + cvc: string; +}; + +export default function CvcCard({ cvc }: CvcCardProps) { + return ( + + + + {cvc} + + + + ); +} diff --git a/src/components/creditCard.style.ts b/src/components/cards/creditCard.style.ts similarity index 75% rename from src/components/creditCard.style.ts rename to src/components/cards/creditCard.style.ts index 998091820c..97e954fa05 100644 --- a/src/components/creditCard.style.ts +++ b/src/components/cards/creditCard.style.ts @@ -1,27 +1,5 @@ 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; @@ -64,16 +42,13 @@ export const CardHeaderContentWrapper = styled.div` 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; `; @@ -81,7 +56,6 @@ export const CardInfoWrapper = styled.div` display: flex; flex-direction: column; gap: 8px; - margin-top: 14px; margin-left: 5px; `; diff --git a/src/components/cards/cvcCard.style.ts b/src/components/cards/cvcCard.style.ts new file mode 100644 index 0000000000..872ee76ad5 --- /dev/null +++ b/src/components/cards/cvcCard.style.ts @@ -0,0 +1,16 @@ +import { styled } from 'styled-components'; + +export const CvcNumberWrapper = styled.div<{ $backgroundColor: string; $padding: string }>` + background-color: ${(props) => + props.$backgroundColor !== '' ? props.$backgroundColor : '#333333'}; + position: absolute; + bottom: 0; + width: 100%; + display: flex; + justify-content: end; + margin-bottom: 24px; +`; + +export const CvcNumberText = styled.span` + margin-right: 16px; +`; diff --git a/src/components/cards/index.style.ts b/src/components/cards/index.style.ts new file mode 100644 index 0000000000..60865116c6 --- /dev/null +++ b/src/components/cards/index.style.ts @@ -0,0 +1,23 @@ +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<{ $backgroundColor: string; $padding: string }>` + background-color: ${(props) => + props.$backgroundColor !== '' ? props.$backgroundColor : '#333333'}; + width: 212px; + height: 132px; + padding: ${(props) => (props.$padding ? props.$padding : '0')}; + border-radius: 4px; + position: relative; + box-shadow: 3px 3px 5px rgba(0, 0, 0, 0.25); +`; diff --git a/src/components/composables/Button.tsx b/src/components/composables/Button.tsx new file mode 100644 index 0000000000..0dd4e97bb9 --- /dev/null +++ b/src/components/composables/Button.tsx @@ -0,0 +1,8 @@ +import { ButtonHTMLAttributes } from 'react'; +import * as S from './button.style'; + +interface CustomButtonProps extends ButtonHTMLAttributes {} + +export default function Button({ children, ...props }: CustomButtonProps) { + return {children}; +} diff --git a/src/components/composables/Input.tsx b/src/components/composables/Input.tsx index a731f0cc4f..e44d7fd096 100644 --- a/src/components/composables/Input.tsx +++ b/src/components/composables/Input.tsx @@ -1,28 +1,27 @@ -import { InputHTMLAttributes } from 'react'; +import { forwardRef, InputHTMLAttributes } from 'react'; import * as S from './input.style'; interface InputProps extends InputHTMLAttributes { - isError: boolean; + isError?: boolean; } -export default function Input({ - value, - onChange, - type, - placeholder, - id, - isError, - maxLength, -}: InputProps) { - return ( - - ); -} +const Input = forwardRef( + ({ value, onChange, type, placeholder, id, isError, maxLength, onKeyDown, onBlur }, ref) => { + return ( + + ); + }, +); + +export default Input; diff --git a/src/components/composables/button.style.ts b/src/components/composables/button.style.ts new file mode 100644 index 0000000000..cc14c4edf8 --- /dev/null +++ b/src/components/composables/button.style.ts @@ -0,0 +1,9 @@ +import { styled } from 'styled-components'; + +export const Button = styled.button` + color: rgba(243, 243, 243, 1); + background: rgba(51, 51, 51, 1); + cursor: pointer; + width: 100%; + height: 100%; +`; diff --git a/src/components/composables/input.style.ts b/src/components/composables/input.style.ts index 22fb460e2b..5ad1d62a20 100644 --- a/src/components/composables/input.style.ts +++ b/src/components/composables/input.style.ts @@ -1,6 +1,6 @@ import { styled } from 'styled-components'; -export const Input = styled.input<{ isError: boolean }>` +export const Input = styled.input<{ $isError: boolean }>` border: 1px solid #acacac; padding: 8px; font-size: 0.6875rem; @@ -10,6 +10,6 @@ export const Input = styled.input<{ isError: boolean }>` flex: 1; &:focus { - border: 1px solid ${(props) => (props.isError ? '#ff3d3d' : '#000')}; + border: 1px solid ${(props) => (props.$isError ? '#ff3d3d' : '#000')}; } `; diff --git a/src/components/registerSection/CardCVCInputSection.tsx b/src/components/registerSection/CardCVCInputSection.tsx new file mode 100644 index 0000000000..7e7bf0551c --- /dev/null +++ b/src/components/registerSection/CardCVCInputSection.tsx @@ -0,0 +1,36 @@ +import InputSection from './InputSection'; +import * as S from '../../app.style'; +import Input from '../composables/Input'; +import Label from '../composables/Label'; +import { MAX_LENGTH, CARD_CVC } from '../../constants/cardSection'; +import { RegisterFieldProps } from '@/types'; +import { forwardRef } from 'react'; + +const CardCVCInputSection = forwardRef((props, ref) => { + const { value, onChange, isError, onKeyDown, onBlur } = props; + + return ( + + + + + {isError && CARD_CVC.errorMessage} + + + ); +}); + +export default CardCVCInputSection; diff --git a/src/components/registerSection/CardExpirationDateInputSection.tsx b/src/components/registerSection/CardExpirationDateInputSection.tsx new file mode 100644 index 0000000000..312f0b7d72 --- /dev/null +++ b/src/components/registerSection/CardExpirationDateInputSection.tsx @@ -0,0 +1,84 @@ +import * as S from '../../app.style'; +import { MAX_LENGTH, EXPIRATION_PERIOD } from '@/constants/cardSection'; +import { Input } from '../composables/input.style'; +import Label from '../composables/Label'; +import InputSection from './InputSection'; +import { RefObject, useCallback, useRef } from 'react'; + +type Props = { + month: string; + monthChangeHandler: ( + e: React.ChangeEvent, + nextRef: RefObject, + ) => void; + monthError: boolean; + handleMonthBlur: React.FocusEventHandler; + year: string; + yearChangeHandler: React.ChangeEventHandler; + yearError: boolean; + handleYearKeyDown: (e: React.KeyboardEvent) => void; + handleYearBlur: React.FocusEventHandler; +}; + +const CardExpirationDateInputSection = ({ + month, + monthError, + monthChangeHandler, + handleMonthBlur, + year, + yearChangeHandler, + yearError, + handleYearKeyDown, + handleYearBlur, +}: Props) => { + const yearRef = useRef(null); + + const monthRef = useCallback((node: HTMLInputElement | null) => { + node?.focus(); + }, []); + + return ( + + + + + + {monthError && yearError ? EXPIRATION_PERIOD.monthErrorMessage : ''} + {!monthError && yearError ? EXPIRATION_PERIOD.yearErrorMessage : ''} + {monthError && !yearError ? EXPIRATION_PERIOD.monthErrorMessage : ''} + + + + ); +}; + +export default CardExpirationDateInputSection; diff --git a/src/components/registerSection/CardIssuerInputSection.tsx b/src/components/registerSection/CardIssuerInputSection.tsx new file mode 100644 index 0000000000..857b39f56f --- /dev/null +++ b/src/components/registerSection/CardIssuerInputSection.tsx @@ -0,0 +1,37 @@ +import * as S from '../../app.style'; +import * as Styled from './registerCardIssuer.style'; +import Label from '../composables/Label'; +import InputSection from './InputSection'; +import { CARD_ISSUER } from '@/constants/cardSection'; +import { SelectHTMLAttributes, useCallback } from 'react'; +import INITIAL_CARD_ISSUER_INFO from '@/constants/allCardIssuerInfo'; + +interface Props extends SelectHTMLAttributes {} + +const CardIssuerInputSection = ({ onChange }: Props) => { + const cardIssuerRef = useCallback((node: HTMLInputElement | null) => { + node?.focus(); + }, []); + + return ( + + + + + ); +}; + +export default CardIssuerInputSection; diff --git a/src/components/registerSection/CardNumberInputSection.tsx b/src/components/registerSection/CardNumberInputSection.tsx new file mode 100644 index 0000000000..64394a62e8 --- /dev/null +++ b/src/components/registerSection/CardNumberInputSection.tsx @@ -0,0 +1,61 @@ +import * as S from '../../app.style'; +import InputSection from './InputSection'; +import { InitialCardNumberState } from '@/types'; +import { Fragment, RefObject, useRef } from 'react'; +import Label from '../composables/Label'; +import Input from '../composables/Input'; +import { MAX_LENGTH, CARD_NUMBER } from '@/constants/cardSection'; + +type Props = { + cardNumbers: InitialCardNumberState[]; + cardNumbersChangeHandler: ( + e: React.ChangeEvent, + index: number, + ref: RefObject, + ) => void; +}; + +const CardNumberInputSection = ({ cardNumbers, cardNumbersChangeHandler }: Props) => { + const refs = [ + useRef(null), + useRef(null), + useRef(null), + useRef(null), + ]; + + return ( + + + {cardNumbers.map((cardNumber, index) => { + const boundHtmlForId = 'cardNumbers' + index; + return ( + + + ); + })} + + + + {cardNumbers.some((cardNumber) => cardNumber.isError) && CARD_NUMBER.errorMessage} + + + + ); +}; + +export default CardNumberInputSection; diff --git a/src/components/registerSection/CardOwnerNameInputSection.tsx b/src/components/registerSection/CardOwnerNameInputSection.tsx new file mode 100644 index 0000000000..42708e38d3 --- /dev/null +++ b/src/components/registerSection/CardOwnerNameInputSection.tsx @@ -0,0 +1,44 @@ +import * as S from '../../app.style'; +import Input from '../composables/Input'; +import Label from '../composables/Label'; +import InputSection from './InputSection'; +import { MAX_LENGTH, OWNER_NAME } from '@/constants/cardSection'; +import { RegisterFieldProps } from '@/types'; +import { useCallback } from 'react'; + +const CardOwnerNameInputSection = ({ + onChange, + value, + onKeyDown, + isError, + onBlur, +}: RegisterFieldProps) => { + const nameRef = useCallback((node: HTMLInputElement | null) => { + node?.focus(); + }, []); + + return ( + + + + + {isError && OWNER_NAME.errorMessage} + + + ); +}; + +export default CardOwnerNameInputSection; diff --git a/src/components/registerSection/CardPasswordInputSection.tsx b/src/components/registerSection/CardPasswordInputSection.tsx new file mode 100644 index 0000000000..7c6acfb8ae --- /dev/null +++ b/src/components/registerSection/CardPasswordInputSection.tsx @@ -0,0 +1,40 @@ +import * as S from '../../app.style'; +import InputSection from './InputSection'; +import Label from '../composables/Label'; +import Input from '../composables/Input'; +import { MAX_LENGTH, PASSWORD } from '@/constants/cardSection'; +import { RegisterFieldProps } from '@/types'; +import { forwardRef } from 'react'; + +const CardPasswordInputSection = forwardRef>( + (props, ref) => { + const { value, onChange, isError, onBlur } = props; + + return ( + + + + + {isError && PASSWORD.errorMessage} + + + ); + }, +); + +export default CardPasswordInputSection; diff --git a/src/components/InputSection.tsx b/src/components/registerSection/InputSection.tsx similarity index 100% rename from src/components/InputSection.tsx rename to src/components/registerSection/InputSection.tsx diff --git a/src/components/registerSection/RoutingButton.tsx b/src/components/registerSection/RoutingButton.tsx new file mode 100644 index 0000000000..c02fcb6424 --- /dev/null +++ b/src/components/registerSection/RoutingButton.tsx @@ -0,0 +1,25 @@ +import PAGE_ROUTES from '@/constants/routes'; +import { useNavigate } from 'react-router-dom'; +import { ConfirmPageRouteProps } from '@/types'; +import * as S from '@/app.style'; +import { Button } from '../composables/button.style'; + +export default function RoutingButton({ cardNumbers, cardIssuer }: ConfirmPageRouteProps) { + const navigate = useNavigate(); + + const handleNavigateToConfirmPage = () => { + navigate(PAGE_ROUTES.CONFIRM, { + state: { + cardNumbers: cardNumbers, + cardIssuer: cardIssuer, + isSucceed: cardNumbers && cardIssuer ? true : false, + }, + }); + }; + + return ( + + + + ); +} diff --git a/src/components/inputSection.style.ts b/src/components/registerSection/inputSection.style.ts similarity index 100% rename from src/components/inputSection.style.ts rename to src/components/registerSection/inputSection.style.ts diff --git a/src/components/registerSection/registerCardIssuer.style.ts b/src/components/registerSection/registerCardIssuer.style.ts new file mode 100644 index 0000000000..37a1ec835c --- /dev/null +++ b/src/components/registerSection/registerCardIssuer.style.ts @@ -0,0 +1,6 @@ +import { styled } from 'styled-components'; + +export const Select = styled.select` + width: 100%; + padding: 8px; +`; diff --git a/src/constants/allCardIssuerInfo.ts b/src/constants/allCardIssuerInfo.ts new file mode 100644 index 0000000000..bd6a15e77e --- /dev/null +++ b/src/constants/allCardIssuerInfo.ts @@ -0,0 +1,61 @@ +import { AllCardIssuer, CardIssuerBackgroundColor } from '@/types'; + +type InitialCardIssuerInfo = { + id: number; + issuer: AllCardIssuer; + value: string; + backgroundColor: CardIssuerBackgroundColor; +}; + +const All_CARD_ISSUER_INFO: InitialCardIssuerInfo[] = [ + { + id: 1, + issuer: 'BC카드', + value: 'bc', + backgroundColor: 'rgba(240, 70, 81, 1)', + }, + { + id: 2, + issuer: '신한카드', + value: 'shinhan', + backgroundColor: 'rgba(0, 70, 255, 1)', + }, + { + id: 3, + issuer: '카카오뱅크', + value: 'kakao', + backgroundColor: 'rgba(255, 230, 0, 1)', + }, + { + id: 4, + issuer: '현대카드', + value: 'hyundai', + backgroundColor: 'rgba(0, 0, 0, 1)', + }, + { + id: 5, + issuer: '우리카드', + value: 'woori', + backgroundColor: 'rgba(0, 123, 200, 1)', + }, + { + id: 6, + issuer: '롯데카드', + value: 'lotte', + backgroundColor: 'rgba(237, 28, 36, 1)', + }, + { + id: 7, + issuer: '하나카드', + value: 'hana', + backgroundColor: 'rgba(0, 148, 144, 1)', + }, + { + id: 8, + issuer: '국민카드', + value: 'kb', + backgroundColor: 'rgba(106, 96, 86, 1)', + }, +]; + +export default All_CARD_ISSUER_INFO; diff --git a/src/constants/cardSection.ts b/src/constants/cardSection.ts index 4d3d5f335b..6ef09c9222 100644 --- a/src/constants/cardSection.ts +++ b/src/constants/cardSection.ts @@ -7,6 +7,21 @@ export const CARD_NUMBER = Object.freeze({ errorMessage: NUMBER_ERROR_MESSAGE, }); +export const MAX_LENGTH = Object.freeze({ + TOTAL_CARD_NUMBER: 16, + INDIVIDUAL_CARD_NUMBER: 4, + MONTH: 2, + YEAR: 2, + NAME: 30, + CVC: 3, + PASSWORD: 2, +}); + +export const MONTH = Object.freeze({ + MIN: 1, + MAX: 12, +}); + export const EXPIRATION_PERIOD = Object.freeze({ title: '카드 유효기간을 입력해 주세요', description: '월/년도(MMYY)를 순서대로 입력해 주세요.', @@ -20,3 +35,21 @@ export const OWNER_NAME = Object.freeze({ inputTitle: '소유자 이름', errorMessage: '영문 대문자만 입력 가능합니다.', }); + +export const CARD_ISSUER = Object.freeze({ + title: '카드사를 선택해 주세요', + inputTitle: '현재 국내 카드사만 가능합니다.', +}); + +export const CARD_CVC = Object.freeze({ + title: 'CVC 번호를 입력해 주세요.', + inputTitle: 'CVC', + errorMessage: '숫자 3개를 입력해 주세요', +}); + +export const PASSWORD = Object.freeze({ + title: '비밀번호를 입력해 주세요', + description: '앞의 2자리를 입력해 주세요', + inputTitle: '비밀번호 앞 2자리', + errorMessage: '숫자만 입력해 주세요', +}); diff --git a/src/constants/registerStep.ts b/src/constants/registerStep.ts new file mode 100644 index 0000000000..59c9d72fe0 --- /dev/null +++ b/src/constants/registerStep.ts @@ -0,0 +1,11 @@ +const REGISTER_STEP = Object.freeze({ + CARD_NUMBER: 'cardNumbers', + CARD_ISSUER: 'cardIssuer', + CARD_EXPIRATION_DATE: 'cardExpirationDate', + CARD_OWNER_NAME: 'cardOwnerName', + CARD_CVC: 'cardCvc', + CARD_PASSWORD: 'cardPassword', + FINAL: 'final', +}); + +export default REGISTER_STEP; diff --git a/src/constants/routes.ts b/src/constants/routes.ts new file mode 100644 index 0000000000..54735d9f8f --- /dev/null +++ b/src/constants/routes.ts @@ -0,0 +1,6 @@ +const PAGE_ROUTES = Object.freeze({ + MAIN: '/', + CONFIRM: '/confirm', +}); + +export default PAGE_ROUTES; diff --git a/src/hooks/useCardIssuer.ts b/src/hooks/useCardIssuer.ts new file mode 100644 index 0000000000..b648ee5ba0 --- /dev/null +++ b/src/hooks/useCardIssuer.ts @@ -0,0 +1,43 @@ +import All_CARD_ISSUER_INFO from '../constants/allCardIssuerInfo'; +import { useState, RefObject } from 'react'; +import { AllCardIssuer, CardIssuerBackgroundColor } from '@/types'; + +type UseCardIssuerProps = { + nextStepHandler: () => void; + nextRef?: RefObject; + isActiveCurrentStep: boolean; +}; + +type CardIssuerState = { + cardIssuer: AllCardIssuer; + backgroundColor: CardIssuerBackgroundColor; +}; + +const useCardIssuer = ({ nextStepHandler, isActiveCurrentStep }: UseCardIssuerProps) => { + const [cardIssuer, setCardIssuer] = useState({ + cardIssuer: '', + backgroundColor: '', + }); + + const handleCardIssuer = (e: React.ChangeEvent) => { + const targetValue = e.target.value; + const foundCardIssuer = All_CARD_ISSUER_INFO.find( + (cardIssuer) => cardIssuer.value === targetValue, + ); + + if (foundCardIssuer) { + setCardIssuer({ + cardIssuer: targetValue as AllCardIssuer, + backgroundColor: foundCardIssuer.backgroundColor, + }); + + if (isActiveCurrentStep) { + nextStepHandler(); + } + } + }; + + return { handleCardIssuer, cardIssuer }; +}; + +export default useCardIssuer; diff --git a/src/hooks/useCardNumber.ts b/src/hooks/useCardNumber.ts index ae3c2989cb..da51344e8b 100644 --- a/src/hooks/useCardNumber.ts +++ b/src/hooks/useCardNumber.ts @@ -1,24 +1,29 @@ -import { useState } from 'react'; +import { useState, RefObject } from 'react'; import validate from '../utils/validate'; +import { MAX_LENGTH } from '../constants/cardSection'; +import { InitialCardNumberState } from '@/types'; -export type InitialCardNumberState = { - value: string; - isError: boolean; +type UseCardNumberHookProps = { + initialCardNumberStates: InitialCardNumberState[]; + nextStepHandler: () => void; + ref?: RefObject; + isValidCurrentStep: boolean; }; -const MAX_CARD_NUMBER_LENGTH = 16; - -const useCardNumber = (initialStates: InitialCardNumberState[]) => { - const [cardNumbers, setCardNumbers] = useState(initialStates); +const useCardNumber = ({ + initialCardNumberStates, + nextStepHandler, + isValidCurrentStep, +}: UseCardNumberHookProps) => { + const [cardNumbers, setCardNumbers] = useState(initialCardNumberStates); const [cardBrand, setCardBrand] = useState<'none' | 'Visa' | 'MasterCard'>('none'); + const [isCompleted, setIsCompleted] = useState(false); const handleCardBrandImage = (totalCardNumbers: string) => { if (validate.isVisa(totalCardNumbers)) { setCardBrand('Visa'); return; - } - - if (validate.isMasterCard(totalCardNumbers)) { + } else if (validate.isMasterCard(totalCardNumbers)) { setCardBrand('MasterCard'); return; } @@ -26,9 +31,15 @@ const useCardNumber = (initialStates: InitialCardNumberState[]) => { setCardBrand('none'); }; - const cardNumbersChangeHandler = (e: React.ChangeEvent, index: number) => { + const cardNumbersChangeHandler = ( + e: React.ChangeEvent, + index: number, + ref: RefObject, + ) => { const newValue = e.target.value; - const isValid = newValue === '' || validate.isValidDigit(newValue); + + const isValid = + newValue === '' || validate.isValidDigit(newValue) || validate.isEmptyValue(newValue); const newCardNumbers = cardNumbers.map((cardNumber, i) => { if (i === index) { @@ -37,15 +48,32 @@ const useCardNumber = (initialStates: InitialCardNumberState[]) => { isError: !isValid, }; } + return cardNumber; }); const totalCardNumbers = newCardNumbers.map((card) => card.value).join(''); - if (totalCardNumbers.length === MAX_CARD_NUMBER_LENGTH) { + + if (totalCardNumbers.length === MAX_LENGTH.TOTAL_CARD_NUMBER) { handleCardBrandImage(totalCardNumbers); } setCardNumbers(newCardNumbers); + + if (isCompleted && totalCardNumbers.length < MAX_LENGTH.TOTAL_CARD_NUMBER) { + setIsCompleted(false); + } + + if (isValid && newValue.length === MAX_LENGTH.INDIVIDUAL_CARD_NUMBER && index < 3) { + ref.current?.focus(); + } + + if (totalCardNumbers.length === MAX_LENGTH.TOTAL_CARD_NUMBER) { + if (isValidCurrentStep) { + nextStepHandler(); + } + setIsCompleted(true); + } }; return { cardNumbers, cardNumbersChangeHandler, cardBrand }; diff --git a/src/hooks/useExpirationDate.ts b/src/hooks/useExpirationDate.ts new file mode 100644 index 0000000000..4b82c888f2 --- /dev/null +++ b/src/hooks/useExpirationDate.ts @@ -0,0 +1,94 @@ +import validate from '@/utils/validate'; +import { RefObject, useState } from 'react'; +import { MONTH, MAX_LENGTH } from '@/constants/cardSection'; +import formatSingleDigitDate from '@/utils/formatSingleDigitDate'; + +const FIRST_SINGLE_DIGIT_DATE = 10; + +type UseExpirationDateProps = { + nextStepHandler: () => void; + isActiveCurrentStep: boolean; +}; + +const useExpirationDate = ({ nextStepHandler, isActiveCurrentStep }: UseExpirationDateProps) => { + const [month, setMonth] = useState(''); + const [monthError, setMonthError] = useState(false); + const [year, setYear] = useState(''); + const [yearError, setYearError] = useState(false); + + const monthChangeHandler = ( + e: React.ChangeEvent, + nextRef: RefObject, + ) => { + setMonthError(false); + const value = e.target.value; + + if (Number(value) > MONTH.MAX || !validate.isValidDigit(value)) { + setMonthError(true); + setMonth(''); + return; + } + + setMonth(value); + + if (value.length === MAX_LENGTH.MONTH) { + nextRef.current?.focus(); + } + }; + + const yearChangeHandler = (e: React.ChangeEvent) => { + setMonthError(false); + const value = e.target.value; + + if (!validate.isValidDigit(value)) { + setYearError(true); + setYear(''); + return; + } + + setYear(value); + + if (value.length === MAX_LENGTH.YEAR && isActiveCurrentStep) { + nextStepHandler(); + } + }; + + const handleYearKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + if (isActiveCurrentStep) { + setYearError(false); + nextStepHandler(); + } + } + }; + + const handleMonthBlur = (e: React.FocusEvent) => { + const value = e.target.value; + if (Number(value) < FIRST_SINGLE_DIGIT_DATE) { + setMonthError(false); + setMonth(formatSingleDigitDate(value)); + } + }; + + const handleYearBlur = (e: React.FocusEvent) => { + const value = e.target.value; + if (Number(value) < FIRST_SINGLE_DIGIT_DATE) { + setYearError(false); + setYear(formatSingleDigitDate(value)); + } + }; + + return { + month, + monthChangeHandler, + monthError, + handleMonthBlur, + year, + yearChangeHandler, + yearError, + handleYearKeyDown, + handleYearBlur, + }; +}; + +export default useExpirationDate; diff --git a/src/hooks/useInput.ts b/src/hooks/useInput.ts index a297d6aab6..3bc20468f2 100644 --- a/src/hooks/useInput.ts +++ b/src/hooks/useInput.ts @@ -1,25 +1,94 @@ -import { useState } from 'react'; +import { useState, useRef, RefObject, useEffect } from 'react'; +import validate from '../utils/validate'; -const useInput = (validators: { fn: (value: string) => boolean }[]) => { +type ValidateFn = { + fn: (value: string) => boolean; +}; + +type UseInputProps = { + validators: ValidateFn[]; + nextRef?: RefObject; + onComplete?: () => void; + isActiveCurrentStep: boolean; + maxLength: number; +}; + +const useInput = ({ + validators, + nextRef, + onComplete, + maxLength, + isActiveCurrentStep, +}: UseInputProps) => { const [inputValue, setInputValue] = useState(''); - const [error, setError] = useState(false); + const [isError, setIsError] = useState(false); + const [isCompleted, setIsCompleted] = useState(false); + const ref = useRef(null); const onChange = (e: React.ChangeEvent) => { - setError(false); const inputValue = e.target.value.toUpperCase(); + setIsError(false); + + if (!inputValue.length) { + setInputValue(''); + setIsCompleted(false); + return; + } for (let validator of validators) { if (!validator.fn(inputValue)) { - setError(true); + setIsError(true); setInputValue(''); return; } } - setInputValue(e.target.value.toUpperCase()); + setInputValue(inputValue); + + if (inputValue.length === maxLength) { + setIsCompleted(true); + if (onComplete && isActiveCurrentStep) { + onComplete(); + } + } else { + setIsCompleted(false); + } }; - return { inputValue, onChange, error }; + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + if (!isCompleted && onComplete && !validate.isEmptyValue(inputValue) && isActiveCurrentStep) { + onComplete(); + } + setIsCompleted(true); + } + }; + + const onBlur = (e: React.FocusEvent) => { + const inputValue = e.target.value; + + setIsError(false); + if (!inputValue) { + setIsError(true); + return; + } + if (!isCompleted && onComplete && isActiveCurrentStep && !validate.isEmptyValue(inputValue)) { + onComplete(); + } + setIsCompleted(true); + }; + + useEffect(() => { + if (isCompleted && ref && onComplete && isActiveCurrentStep) { + onComplete(); + ref.current?.blur(); + } + if (isCompleted && nextRef) { + nextRef.current?.focus(); + } + }, [isCompleted]); + + return { inputValue, onChange, isError, onKeyDown, ref, onBlur }; }; export default useInput; diff --git a/src/hooks/useRegister.tsx b/src/hooks/useRegister.tsx new file mode 100644 index 0000000000..f9097d03c9 --- /dev/null +++ b/src/hooks/useRegister.tsx @@ -0,0 +1,40 @@ +import { Children, useState, isValidElement, PropsWithChildren } from 'react'; +import { RegisterComponentProps, RegisterStep } from '@/types'; + +interface StepProps { + name: string; +} + +function Register({ children, step }: PropsWithChildren) { + const steps = Children.toArray(children).reverse(); + + const currentIndex = steps.findIndex( + (child) => isValidElement(child) && child.props.name === step, + ); + + const validElements = steps.slice(0, currentIndex + 1).filter(isValidElement); + + return <>{validElements.reverse()}; +} + +function Step({ children }: PropsWithChildren) { + return <>{children}; +} + +Register.Step = Step; + +const useRegister = (steps: readonly [...T]) => { + const [step, setStep] = useState(steps[0]); + const [stepIndex, setStepIndex] = useState(0); + + const nextStepHandler = () => { + if (stepIndex < steps.length - 1) { + setStepIndex((prevStepIndex) => prevStepIndex + 1); + setStep(steps[stepIndex + 1]); + } + }; + + return { step, Register, nextStepHandler }; +}; + +export default useRegister; diff --git a/src/main.tsx b/src/main.tsx index 3d7150da80..2a73f69c14 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,42 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import './index.css' - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - , -) +import { createRoot } from 'react-dom/client'; +import App from './App'; +import './index.css'; +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import ConfirmPage from './pages/confirm/ConfirmPage'; +import NotFoundPage from './pages/error/NotFoundPage'; +import ErrorPage from './pages/error/ErrorPage'; +import PAGE_ROUTES from './constants/routes'; +import RegisterCardInfoPage from './pages/register/RegisterCardInfoPage'; + +const routes = [ + { + path: PAGE_ROUTES.MAIN, + element: ( + + + + ), + errorElement: , + }, + { + path: PAGE_ROUTES.CONFIRM, + element: ( + + + + ), + errorElement: , + }, + + { path: '*', element: }, +]; + +const router = createBrowserRouter(routes, { + basename: PAGE_ROUTES.MAIN, +}); + +createRoot(document.getElementById('root')!).render( + // + , + // , +); diff --git a/src/pages/confirm/ConfirmPage.tsx b/src/pages/confirm/ConfirmPage.tsx new file mode 100644 index 0000000000..06c317c0b4 --- /dev/null +++ b/src/pages/confirm/ConfirmPage.tsx @@ -0,0 +1,37 @@ +import { useLocation, useNavigate } from 'react-router-dom'; +import * as S from './confirmPage.style'; +import ConfirmButton from './components/ConfirmButton'; +import ConfirmImageIcon from './components/ConfirmImageIcon'; +import CompleteText from './components/CompleteText'; +import NotFoundPage from '../error/NotFoundPage'; +import PAGE_ROUTES from '../../constants/routes'; + +export default function ConfirmPage() { + const location = useLocation(); + const navigate = useNavigate(); + const { state } = location; + + const isSucceed = state?.isSucceed; + const cardNumbers = state?.cardNumbers; + const cardIssuer = state?.cardIssuer; + + if (!isSucceed) { + return ; + } + + const goToHomePage = () => { + navigate(PAGE_ROUTES.MAIN); + }; + + return ( + <> + + + + + 확인 + + + + ); +} diff --git a/src/pages/confirm/components/CheckIconImage.tsx b/src/pages/confirm/components/CheckIconImage.tsx new file mode 100644 index 0000000000..6ef183ba01 --- /dev/null +++ b/src/pages/confirm/components/CheckIconImage.tsx @@ -0,0 +1,22 @@ +import { SVGProps } from 'react'; +import { JSX } from 'react/jsx-runtime'; + +const CheckIconImage = (props: JSX.IntrinsicAttributes & SVGProps) => ( + + + +); +export default CheckIconImage; diff --git a/src/pages/confirm/components/CompleteText.tsx b/src/pages/confirm/components/CompleteText.tsx new file mode 100644 index 0000000000..4b9982d376 --- /dev/null +++ b/src/pages/confirm/components/CompleteText.tsx @@ -0,0 +1,15 @@ +import * as S from '../confirmPage.style'; + +type CompleteTextProps = { + cardNumbers: string; + cardIssuer: string; +}; + +export default function CompleteText({ cardNumbers, cardIssuer }: CompleteTextProps) { + return ( + + {cardNumbers}로 시작하는 + {cardIssuer}가 등록되었어요. + + ); +} diff --git a/src/pages/confirm/components/ConfirmButton.tsx b/src/pages/confirm/components/ConfirmButton.tsx new file mode 100644 index 0000000000..42b1b0e9c2 --- /dev/null +++ b/src/pages/confirm/components/ConfirmButton.tsx @@ -0,0 +1,13 @@ +import { ButtonHTMLAttributes } from 'react'; +import * as B from './confirmButton.style'; +import * as S from '../confirmPage.style'; + +interface ConfirmButtonProps extends ButtonHTMLAttributes {} + +export default function ConfirmButton({ children, ...props }: ConfirmButtonProps) { + return ( + + {children} + + ); +} diff --git a/src/pages/confirm/components/ConfirmImageIcon.tsx b/src/pages/confirm/components/ConfirmImageIcon.tsx new file mode 100644 index 0000000000..a7fc320cd5 --- /dev/null +++ b/src/pages/confirm/components/ConfirmImageIcon.tsx @@ -0,0 +1,26 @@ +import * as S from '../confirmPage.style'; +import CheckIconImage from './CheckIconImage'; + +export default function ConfirmImageIcon() { + return ( + + + + + + + + ); +} diff --git a/src/pages/confirm/components/confirmButton.style.ts b/src/pages/confirm/components/confirmButton.style.ts new file mode 100644 index 0000000000..5b0d8df84d --- /dev/null +++ b/src/pages/confirm/components/confirmButton.style.ts @@ -0,0 +1,13 @@ +import { styled } from 'styled-components'; + +export const ConfirmButton = styled.button` + border-radius: 5px; + color: rgba(243, 243, 243, 1); + background: rgba(51, 51, 51, 1); + cursor: pointer; + width: 100%; + height: 100%; + padding: 8px 0; + height: 44px; + width: 320px; +`; diff --git a/src/pages/confirm/confirmPage.style.ts b/src/pages/confirm/confirmPage.style.ts new file mode 100644 index 0000000000..9ee5477bad --- /dev/null +++ b/src/pages/confirm/confirmPage.style.ts @@ -0,0 +1,53 @@ +import { styled } from 'styled-components'; + +export const Container = styled.div` + display: flex; + flex-direction: column; +`; + +export const ContentCard = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + min-height: 100vh; +`; + +export const IconContainer = styled.div` + display: flex; + justify-content: center; +`; + +export const TextContainer = styled.div` + margin: 25px 0; + display: flex; + flex-direction: column; +`; + +export const Text = styled.span` + font-size: 25px; + font-weight: 700; + text-align: center; + margin: 5px 0; +`; + +export const ButtonContainer = styled.div` + width: 320px; +`; + +export const IconImageContainer = styled.div` + width: 76px; + height: 76px; + display: flex; + justify-content: center; + align-items: center; + background: rgba(51, 51, 51, 1); + border-radius: 50%; +`; + +export const IconImage = styled.img` + border: 7.5px; + width: 32px; + height: 20px; +`; diff --git a/src/pages/error/ErrorPage.tsx b/src/pages/error/ErrorPage.tsx new file mode 100644 index 0000000000..161ea8eb60 --- /dev/null +++ b/src/pages/error/ErrorPage.tsx @@ -0,0 +1,15 @@ +import PAGE_ROUTES from '@/constants/routes'; +import { useNavigate } from 'react-router-dom'; + +export default function ErrorPage() { + const navigate = useNavigate(); + const handleClickBackButton = () => { + navigate(PAGE_ROUTES.MAIN, { replace: true }); + }; + return ( +
+ Error가 발생했어요! + +
+ ); +} diff --git a/src/pages/error/NotFoundPage.tsx b/src/pages/error/NotFoundPage.tsx new file mode 100644 index 0000000000..00933c06aa --- /dev/null +++ b/src/pages/error/NotFoundPage.tsx @@ -0,0 +1,15 @@ +import { useNavigate } from 'react-router-dom'; +import PAGE_ROUTES from '../../constants/routes'; + +export default function NotFoundPage() { + const navigate = useNavigate(); + const handleClickNavigateHomeButton = () => { + navigate(PAGE_ROUTES.MAIN, { replace: true }); + }; + return ( +
+ 페이지를 찾을수 없어요! + +
+ ); +} diff --git a/src/pages/register/RegisterCardInfoPage.tsx b/src/pages/register/RegisterCardInfoPage.tsx new file mode 100644 index 0000000000..2a90570cd8 --- /dev/null +++ b/src/pages/register/RegisterCardInfoPage.tsx @@ -0,0 +1,200 @@ +import CreditCard from '@/components/cards/CreditCard'; +import useInput from '@/hooks/useInput'; +import validate from '@/utils/validate'; +import useCardNumber from '@/hooks/useCardNumber'; +import * as S from '@/app.style'; +import useRegister from '@/hooks/useRegister'; +import useCardIssuer from '@/hooks/useCardIssuer'; +import CvcCard from '@/components/cards/CvcCard'; +import REGISTER_STEP from '@/constants/registerStep'; +import { InitialCardNumberState } from '@/types'; +import { MAX_LENGTH } from '@/constants/cardSection'; +import RoutingButton from '@/components/registerSection/RoutingButton'; +import useExpirationDate from '@/hooks/useExpirationDate'; +import CardPasswordInputSection from '@/components/registerSection/CardPasswordInputSection'; +import CardIssuerInputSection from '@/components/registerSection/CardIssuerInputSection'; +import CardOwnerNameInputSection from '@/components/registerSection/CardOwnerNameInputSection'; +import CardNumberInputSection from '@/components/registerSection/CardNumberInputSection'; +import CardCVCInputSection from '@/components/registerSection/CardCVCInputSection'; +import CardExpirationDateInputSection from '@/components/registerSection/CardExpirationDateInputSection'; + +const initialCardNumberState: InitialCardNumberState = { + value: '', + isError: false, +}; + +const CARD_NUMBER_LENGTH = 4; + +export default function RegisterCardInfoPage() { + const { step, Register, nextStepHandler } = useRegister([ + REGISTER_STEP.CARD_NUMBER, + REGISTER_STEP.CARD_ISSUER, + REGISTER_STEP.CARD_EXPIRATION_DATE, + REGISTER_STEP.CARD_OWNER_NAME, + REGISTER_STEP.CARD_CVC, + REGISTER_STEP.CARD_PASSWORD, + ] as const); + + const { + inputValue: password, + onChange: handlePassword, + isError: passwordError, + ref: passwordRef, + onBlur: handlePasswordBlur, + } = useInput({ + validators: [{ fn: (value) => validate.isValidDigit(value) }], + maxLength: MAX_LENGTH.PASSWORD, + onComplete: nextStepHandler, + isActiveCurrentStep: step === REGISTER_STEP.CARD_PASSWORD, + }); + + const { + inputValue: cvc, + onChange: handleCvc, + isError: cvcError, + ref: cardCvcRef, + onKeyDown: handleEnterCvc, + onBlur: handleCvcBlur, + } = useInput({ + validators: [{ fn: (value) => validate.isValidDigit(value) }], + maxLength: MAX_LENGTH.CVC, + onComplete: nextStepHandler, + nextRef: passwordRef, + isActiveCurrentStep: step === REGISTER_STEP.CARD_CVC, + }); + + const { + inputValue: name, + onChange: nameChangeHandler, + isError: nameError, + onKeyDown: handleEnterName, + onBlur: handleNameBlur, + } = useInput({ + validators: [{ fn: (value) => validate.isEnglish(value) }], + maxLength: MAX_LENGTH.NAME, + onComplete: nextStepHandler, + nextRef: cardCvcRef, + isActiveCurrentStep: step === REGISTER_STEP.CARD_OWNER_NAME, + }); + + const { + month, + monthChangeHandler, + monthError, + handleMonthBlur, + year, + yearChangeHandler, + yearError, + handleYearKeyDown, + handleYearBlur, + } = useExpirationDate({ + nextStepHandler, + isActiveCurrentStep: step === REGISTER_STEP.CARD_EXPIRATION_DATE, + }); + + const { handleCardIssuer, cardIssuer } = useCardIssuer({ + nextStepHandler, + isActiveCurrentStep: step === REGISTER_STEP.CARD_ISSUER, + }); + + const { cardNumbers, cardNumbersChangeHandler, cardBrand } = useCardNumber({ + initialCardNumberStates: Array.from( + { length: CARD_NUMBER_LENGTH }, + () => initialCardNumberState, + ), + nextStepHandler, + isValidCurrentStep: step === REGISTER_STEP.CARD_NUMBER, + }); + + const isValidAllFormStates = validate.isValidAllFormStates({ + cardNumbers, + month, + year, + password, + cvc, + name, + }); + + return ( + + + {step === REGISTER_STEP.CARD_CVC ? ( + + ) : ( + + )} + + + {isValidAllFormStates && ( + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/stories/App.stories.tsx b/src/stories/App.stories.tsx index e0df252810..154cfdb6fe 100644 --- a/src/stories/App.stories.tsx +++ b/src/stories/App.stories.tsx @@ -1,10 +1,23 @@ import type { Meta, StoryObj } from '@storybook/react'; -import App from '../App'; +import RegisterCardInfoPage from '@/pages/register/RegisterCardInfoPage'; +import { reactRouterParameters, withRouter } from 'storybook-addon-remix-react-router'; const meta = { - title: 'App', - component: App, -} satisfies Meta; + title: 'RegisterCardInfoPage', + component: RegisterCardInfoPage, + decorators: [withRouter], + parameters: { + reactRouter: reactRouterParameters({ + location: { + state: {}, + }, + routing: { + path: '/confirm', + handle: 'App', + }, + }), + }, +} satisfies Meta; export default meta; diff --git a/src/stories/CardCVCInputSection.stories.ts b/src/stories/CardCVCInputSection.stories.ts new file mode 100644 index 0000000000..cc7192944d --- /dev/null +++ b/src/stories/CardCVCInputSection.stories.ts @@ -0,0 +1,30 @@ +import { Meta, StoryObj } from '@storybook/react'; +import CardCVCInputSection from '@/components/registerSection/CardCVCInputSection'; +import { fn } from '@storybook/test'; + +const meta: Meta = { + title: 'RegisterStep/CardCVCInputSection', + component: CardCVCInputSection, + tags: ['autodocs'], + argTypes: { + value: { + control: 'text', + description: 'CVC 입력값', + }, + isError: { + control: 'boolean', + description: '에러 상태에 관한 상태 값', + }, + }, + args: { + onChange: fn(), + onBlur: fn(), + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/src/stories/CardExpirationDateInputSection.stories.ts b/src/stories/CardExpirationDateInputSection.stories.ts new file mode 100644 index 0000000000..6274f2b0b5 --- /dev/null +++ b/src/stories/CardExpirationDateInputSection.stories.ts @@ -0,0 +1,41 @@ +import { Meta, StoryObj } from '@storybook/react'; +import CardExpirationDateInputSection from '@/components/registerSection/CardExpirationDateInputSection'; +import { fn } from '@storybook/test'; + +const meta: Meta = { + title: 'RegisterStep/CardExpirationDateInputSection', + component: CardExpirationDateInputSection, + tags: ['autodocs'], + argTypes: { + month: { + control: 'text', + description: '카드 유효기간에 대한 월', + }, + monthError: { + control: 'boolean', + description: '월에 대한 에러 상태 값', + }, + year: { + control: 'text', + description: '카드 유효기간에 대한 연도', + }, + yearError: { + control: 'boolean', + description: '연도 에러 상태 값', + }, + }, + args: { + monthChangeHandler: fn(), + handleMonthBlur: fn(), + yearChangeHandler: fn(), + handleYearKeyDown: fn(), + handleYearBlur: fn(), + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/src/stories/CardIssuerInputSection.stories.ts b/src/stories/CardIssuerInputSection.stories.ts new file mode 100644 index 0000000000..22208e5c21 --- /dev/null +++ b/src/stories/CardIssuerInputSection.stories.ts @@ -0,0 +1,24 @@ +import { Meta, StoryObj } from '@storybook/react'; +import CardIssuerInputSection from '@/components/registerSection/CardIssuerInputSection'; +import { fn } from '@storybook/test'; + +const meta: Meta = { + title: 'RegisterStep/CardIssuerInputSection', + component: CardIssuerInputSection, + tags: ['autodocs'], + argTypes: { + onChange: { + description: '카드사 변경 함수', + }, + }, + args: { + onChange: fn(), + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/src/stories/CardNumberInputSection.stories.tsx b/src/stories/CardNumberInputSection.stories.tsx new file mode 100644 index 0000000000..40579d8161 --- /dev/null +++ b/src/stories/CardNumberInputSection.stories.tsx @@ -0,0 +1,32 @@ +import { Meta, StoryObj } from '@storybook/react'; +import CardNumberInputSection from '@/components/registerSection/CardNumberInputSection'; +import { fn } from '@storybook/test'; + +const meta: Meta = { + title: 'RegisterStep/CardNumberInputSection', + component: CardNumberInputSection, + tags: ['autodocs'], + argTypes: { + cardNumbers: { + control: 'object', + description: '카드 번호를 저장하는 배열', + }, + }, + args: { + cardNumbersChangeHandler: fn(), + }, +} 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 }, + ], + }, +}; diff --git a/src/stories/CardOwnerNameInputSection.stories.ts b/src/stories/CardOwnerNameInputSection.stories.ts new file mode 100644 index 0000000000..53c77aa637 --- /dev/null +++ b/src/stories/CardOwnerNameInputSection.stories.ts @@ -0,0 +1,36 @@ +import { Meta, StoryObj } from '@storybook/react'; +import CardOwnerNameInputSection from '@/components/registerSection/CardOwnerNameInputSection'; +import { fn } from '@storybook/test'; + +const meta: Meta = { + title: 'RegisterStep/CardOwnerNameInputSection', + component: CardOwnerNameInputSection, + tags: ['autodocs'], + argTypes: { + onChange: { + description: '카드사 변경 함수', + }, + onBlur: { + description: '포커스 잃었을때 실행할 함수', + }, + value: { + control: 'string', + description: '이름 입력값', + }, + isError: { + control: 'boolean', + description: '에러 상태값', + }, + }, + args: { + onChange: fn(), + onBlur: fn(), + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/src/stories/CardPasswordInputSection.stories.ts b/src/stories/CardPasswordInputSection.stories.ts new file mode 100644 index 0000000000..ed9601a3dd --- /dev/null +++ b/src/stories/CardPasswordInputSection.stories.ts @@ -0,0 +1,36 @@ +import { Meta, StoryObj } from '@storybook/react'; +import CardPasswordInputSection from '@/components/registerSection/CardPasswordInputSection'; +import { fn } from '@storybook/test'; + +const meta: Meta = { + title: 'RegisterStep/CardPasswordInputSection', + component: CardPasswordInputSection, + tags: ['autodocs'], + argTypes: { + onChange: { + description: '카드사 변경 함수', + }, + onBlur: { + description: '포커스 잃었을때 실행할 함수', + }, + value: { + control: 'string', + description: '비밀번호 입력값', + }, + isError: { + control: 'boolean', + description: '비밀번호 입력에 대한 에러 상태값', + }, + }, + args: { + onChange: fn(), + onBlur: fn(), + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/src/stories/ConfirmPage.stories.tsx b/src/stories/ConfirmPage.stories.tsx new file mode 100644 index 0000000000..6128505776 --- /dev/null +++ b/src/stories/ConfirmPage.stories.tsx @@ -0,0 +1,22 @@ +import { reactRouterParameters, withRouter } from 'storybook-addon-remix-react-router'; +import ConfirmPage from '../pages/confirm/ConfirmPage'; + +export default { + title: 'Confirm Page', + render: () => , + decorators: [withRouter], +}; + +export const FromHomePage = { + parameters: { + reactRouter: reactRouterParameters({ + location: { + state: { isSucceed: true, cardNumbers: '1234', cardIssuer: '국민카드' }, + }, + routing: { + path: '/', + handle: 'Home', + }, + }), + }, +}; diff --git a/src/stories/CreditCard.stories.tsx b/src/stories/CreditCard.stories.tsx index 4cd5bbf264..92c4be5377 100644 --- a/src/stories/CreditCard.stories.tsx +++ b/src/stories/CreditCard.stories.tsx @@ -1,8 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react'; -import CreditCard from '../components/CreditCard'; +import CreditCard from '../components/cards/CreditCard'; const meta = { - title: 'CreditCard', + title: 'Card/CreditCard', component: CreditCard, tags: ['autodocs'], argTypes: { @@ -45,6 +45,7 @@ export const Default: Story = { year: '00', name: 'JOHN DOE', cardBrand: 'none', + backgroundColor: '#333333', }, }; @@ -60,6 +61,7 @@ export const Visa: Story = { year: '29', name: 'LIM DONGJUN', cardBrand: 'Visa', + backgroundColor: '#333333', }, }; @@ -75,5 +77,133 @@ export const Master: Story = { year: '29', name: 'LIM DONGJUN', cardBrand: 'MasterCard', + backgroundColor: '#333333', + }, +}; + +export const 신한카드: 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', + backgroundColor: 'rgba(0, 70, 255, 1)', + }, +}; + +export const BC카드: 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', + backgroundColor: 'rgba(240, 70, 81, 1)', + }, +}; + +export const 카카오뱅크: 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', + backgroundColor: 'rgba(255, 230, 0, 1)', + }, +}; + +export const 현대카드: 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', + backgroundColor: 'rgba(0, 0, 0, 1)', + }, +}; + +export const 우리카드: 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', + backgroundColor: 'rgba(0, 123, 200, 1)', + }, +}; +export const 롯데카드: 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', + backgroundColor: 'rgba(237, 28, 36, 1)', + }, +}; + +export const 하나카드: 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', + backgroundColor: 'rgba(0, 148, 144, 1)', + }, +}; + +export const 국민카드: 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', + backgroundColor: 'rgba(106, 96, 86, 1)', }, }; diff --git a/src/stories/CvcCard.stories.ts b/src/stories/CvcCard.stories.ts new file mode 100644 index 0000000000..abbc26a209 --- /dev/null +++ b/src/stories/CvcCard.stories.ts @@ -0,0 +1,21 @@ +import { Meta, StoryObj } from '@storybook/react'; +import CvcCard from '../components/cards/CvcCard'; + +const meta: Meta = { + title: 'Card/CvcCard', + component: CvcCard, + tags: ['autodocs'], + argTypes: { + cvc: { + control: 'text', + description: 'cvc 값', + }, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/src/stories/InputSection.stories.tsx b/src/stories/InputSection.stories.tsx index 5db0e427a5..c0df91dd21 100644 --- a/src/stories/InputSection.stories.tsx +++ b/src/stories/InputSection.stories.tsx @@ -1,9 +1,16 @@ import type { Meta, StoryObj } from '@storybook/react'; -import InputSection from '../components/InputSection'; -import { CARD_NUMBER, EXPIRATION_PERIOD, OWNER_NAME } from '../constants/cardSection'; +import InputSection from '../components/registerSection/InputSection'; +import { + CARD_NUMBER, + EXPIRATION_PERIOD, + OWNER_NAME, + CARD_CVC, + PASSWORD, + CARD_ISSUER, +} from '../constants/cardSection'; const meta = { - title: 'InputSection', + title: 'Register/InputSection', component: InputSection, tags: ['autodocs'], argTypes: { @@ -50,3 +57,27 @@ export const OwnerName: Story = { children: <>, }, }; + +export const CVC: Story = { + args: { + title: CARD_CVC.title, + inputTitle: CARD_CVC.inputTitle, + children: <>, + }, +}; + +export const Password: Story = { + args: { + title: PASSWORD.title, + description: PASSWORD.description, + inputTitle: PASSWORD.inputTitle, + children: <>, + }, +}; + +export const CardIssuer: Story = { + args: { + title: CARD_ISSUER.title, + inputTitle: CARD_ISSUER.inputTitle, + }, +}; diff --git a/src/types/index.d.ts b/src/types/index.d.ts new file mode 100644 index 0000000000..5eea331a83 --- /dev/null +++ b/src/types/index.d.ts @@ -0,0 +1,62 @@ +import { InputHTMLAttributes } from 'react'; + +export interface RegisterFieldProps extends InputHTMLAttributes { + isError: boolean; +} + +export type InitialCardNumberState = { + value: string; + isError: boolean; +}; + +export type RegisterStep = + | 'cardNumbers' + | 'cardIssuer' + | 'cardExpirationDate' + | 'cardOwnerName' + | 'cardCvc' + | 'cardPassword'; + +export type RegisterComponentProps = { + step: RegisterStepType; +}; + +export type RegisterFieldInfos = { + cardNumbers: InitialCardNumberState[]; + month: string; + year: string; + cvc: string; + password: string; + name: string; +}; + +type CardIssuer = { + cardIssuer: AllCardIssuer; +}; + +export type AllCardIssuer = + | '' + | 'BC카드' + | '신한카드' + | '카카오뱅크' + | '현대카드' + | '우리카드' + | '롯데카드' + | '하나카드' + | '국민카드'; + +export type CardIssuerBackgroundColor = + | '' + | 'rgba(240, 70, 81, 1)' + | 'rgba(0, 70, 255, 1)' + | 'rgba(255, 230, 0, 1)' + | 'rgba(0, 0, 0, 1)' + | 'rgba(0, 123, 200, 1)' + | 'rgba(237, 28, 36, 1)' + | 'rgba(0, 148, 144, 1)' + | 'rgba(106, 96, 86, 1)'; + +export type ConfirmPageRouteProps = { + cardNumbers: string; + cardIssuer: AllCardIssuer; +}; diff --git a/src/utils/formatSingleDigitDate.ts b/src/utils/formatSingleDigitDate.ts new file mode 100644 index 0000000000..d03116d0fc --- /dev/null +++ b/src/utils/formatSingleDigitDate.ts @@ -0,0 +1,5 @@ +const formatSingleDigitDate = (dateValue: string) => { + return dateValue.padStart(2, '0'); +}; + +export default formatSingleDigitDate; diff --git a/src/utils/validate.ts b/src/utils/validate.ts index 1303a697dc..4cbd564291 100644 --- a/src/utils/validate.ts +++ b/src/utils/validate.ts @@ -1,3 +1,6 @@ +import { MAX_LENGTH } from '@/constants/cardSection'; +import { RegisterFieldInfos, InitialCardNumberState } from '@/types'; + const validate = { isNumberInRange: ({ min, @@ -26,6 +29,28 @@ const validate = { isEnglish: (value: string) => { return /^[a-zA-Z ]*$/.test(value); }, + + isEmptyValue: (value: string) => { + return value.length === 0; + }, + + isValidAllFormStates: ({ cardNumbers, month, year, cvc, password, name }: RegisterFieldInfos) => { + const totalCardNumbers = cardNumbers + .map((cardNumber: InitialCardNumberState) => cardNumber.value) + .join(''); + + if ( + month.length === MAX_LENGTH.MONTH && + year.length === MAX_LENGTH.YEAR && + totalCardNumbers.length === MAX_LENGTH.TOTAL_CARD_NUMBER && + cvc.length === MAX_LENGTH.CVC && + password.length === MAX_LENGTH.PASSWORD && + name.length + ) { + return true; + } + return false; + }, }; export default validate; diff --git a/tsconfig.json b/tsconfig.json index a7fc6fbf23..deb8eb72d4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,8 +7,7 @@ "skipLibCheck": true, /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, + "moduleResolution": "Node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, @@ -18,8 +17,13 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@types/*": ["@types/*"] + } }, - "include": ["src"], + "include": ["src", "./@types/global.d.ts"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/tsconfig.node.json b/tsconfig.node.json index 97ede7ee6f..5ff5f08afd 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -3,7 +3,7 @@ "composite": true, "skipLibCheck": true, "module": "ESNext", - "moduleResolution": "bundler", + "moduleResolution": "Node", "allowSyntheticDefaultImports": true, "strict": true }, diff --git a/vite.config.ts b/vite.config.ts index 861b04b356..3c86cd06df 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,14 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react-swc' +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react-swc'; +import path from 'path'; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], -}) + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + // base: '/react-payments/dist', +});