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 (
-
-
- 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}
-
-
-
-
+ {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 (
+
+
+
+ ) => monthChangeHandler(e, yearRef)}
+ isError={monthError}
+ ref={monthRef}
+ onBlur={handleMonthBlur}
+ />
+
+
+
+
+
+ {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 (
+
+
+
+
+
+ 카드사를 선택해주세요
+
+ {INITIAL_CARD_ISSUER_INFO.map((cardIssuer) => {
+ return (
+
+ {cardIssuer.issuer}
+
+ );
+ })}
+
+
+
+ );
+};
+
+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 (
+
+
+ cardNumbersChangeHandler(e, index, refs[index + 1])}
+ isError={cardNumber.isError}
+ />
+
+ );
+ })}
+
+
+
+ {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 (
+
+ 페이지를 찾을수 없어요!
+ Home 으로 이동
+
+ );
+}
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',
+});