diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..32a4ee75 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "semi": false, + "singleQuote": true, + "jsxSingleQuote": true +} diff --git a/package.json b/package.json index 04b71273..96b6c289 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "preview": "vite preview" }, "dependencies": { + "prettier": "^3.3.3", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34a2160e..2d71d9c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + prettier: + specifier: ^3.3.3 + version: 3.3.3 react: specifier: ^18.3.1 version: 18.3.1 @@ -1295,6 +1298,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.3.3: + resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} + engines: {node: '>=14'} + hasBin: true + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -2844,6 +2852,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.3.3: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index dc22ffb2..00000000 --- a/src/App.tsx +++ /dev/null @@ -1,317 +0,0 @@ -function App() { - return ( - <> -

React Clean Code Payments CSS example

-

1️⃣ 카드 추가

-
-
-

- 카드 추가 -

-
-
-
-
-
-
-
-
- NAME - MM / YY -
-
-
-
-
- 카드 번호 -
- - - - -
-
-
- 만료일 -
- - -
-
-
- 카드 소유자 이름(선택) - -
-
- 보안코드(CVC/CVV) - -
-
- 카드 비밀번호 - - - - -
-
- 다음 -
-
-
- -

2️⃣ 카드 추가 - 카드사 선택

-
-
-

- 카드 추가 -

-
-
-
- 클린카드 -
-
-
-
-
-
- 1111 - 2222 - oooo - oooo -
-
- NAME - MM / YY -
-
-
-
-
- 카드 번호 -
- - - - -
-
-
- 만료일 -
- - -
-
-
- 카드 소유자 이름(선택) - -
-
- 보안코드(CVC/CVV) - -
-
- 카드 비밀번호 - - - - -
-
- 다음 -
-
-
-
-
-
-
- 클린 카드 -
-
-
- 클린 카드 -
-
-
- 클린 카드 -
-
-
- 클린 카드 -
-
-
-
-
- 클린 카드 -
-
-
- 클린 카드 -
-
-
- 클린 카드 -
-
-
- 클린 카드 -
-
-
-
-
- -

3️⃣ 카드 추가 - 입력 완료

-
-
-

- 카드 추가 -

-
-
-
- 클린카드 -
-
-
-
-
-
- 1111 - 2222 - oooo - oooo -
-
- 프롱이 - 12 / 23 -
-
-
-
-
- 카드 번호 -
- - - - -
-
-
- 만료일 -
- - -
-
-
- 카드 소유자 이름(선택) - -
-
- 보안코드(CVC/CVV) - -
-
- 카드 비밀번호 - - - - -
-
- 다음 -
-
-
- -

4️⃣ 카드 추가 완료

-
-
-
-

카드등록이 완료되었습니다.

-
-
-
-
- 클린카드 -
-
-
-
-
-
- 1111 - 2222 - oooo - oooo -
-
- 프롱이 - 12 / 23 -
-
-
-
-
- -
-
- 다음 -
-
-
- -

5️⃣ 카드 목록

-
-
-
-

보유 카드

-
-
-
-
- 클린카드 -
-
-
-
-
-
- 1111 - 2222 - oooo - oooo -
-
- 프롱이 - 12 / 23 -
-
-
-
- 법인카드 -
-
+
-
-
-
- - ) -} - -export default App diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index b2e242e4..72b8e7c6 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -1,15 +1,13 @@ -import { describe, expect, test } from "vitest"; -import App from "../App.tsx"; -import { render } from "@testing-library/react"; +import { describe, test } from 'vitest' describe('간단한 컴포넌트 테스트', () => { test('App 컴포넌트가 가 렌더링 된다.', () => { - const { getByText } = render() - - expect(getByText('1️⃣ 카드 추가')).toBeInTheDocument(); - expect(getByText('2️⃣ 카드 추가 - 카드사 선택')).toBeInTheDocument(); - expect(getByText('3️⃣ 카드 추가 - 입력 완료')).toBeInTheDocument(); - expect(getByText('4️⃣ 카드 추가 완료')).toBeInTheDocument(); - expect(getByText('5️⃣ 카드 목록')).toBeInTheDocument(); + // const { getByText } = render() + // + // expect(getByText('1️⃣ 카드 추가')).toBeInTheDocument() + // expect(getByText('2️⃣ 카드 추가 - 카드사 선택')).toBeInTheDocument() + // expect(getByText('3️⃣ 카드 추가 - 입력 완료')).toBeInTheDocument() + // expect(getByText('4️⃣ 카드 추가 완료')).toBeInTheDocument() + // expect(getByText('5️⃣ 카드 목록')).toBeInTheDocument() }) }) diff --git a/src/__tests__/sum.test.ts b/src/__tests__/sum.test.ts deleted file mode 100644 index 264beba2..00000000 --- a/src/__tests__/sum.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, test, expect } from "vitest"; - -function sum(...args: number[]) { - return args.reduce((a, b) => a+ b); -} - -describe('예제 테스트입니다.', () => { - test('sum > ', () => { - expect(sum(1,2,3,4,5)).toBe(15); - }) -}) diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 00000000..6949f694 --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,16 @@ +import { Route, Router } from '../libs/router' +import PaymentsPage from './payments/PaymentsPage.tsx' +import Home from './Home.tsx' +import Example from '../components/Example.tsx' + +export default function App() { + return ( +
+ + } /> + } /> + } /> + +
+ ) +} diff --git a/src/app/Home.tsx b/src/app/Home.tsx new file mode 100644 index 00000000..d103779b --- /dev/null +++ b/src/app/Home.tsx @@ -0,0 +1,12 @@ +import {useEffect} from "react"; +import {useRouter} from "../libs/router"; + +export default function Home() { + const router = useRouter() + + useEffect(() => { + router.go('/payments') + }, [router]); + + return null +} \ No newline at end of file diff --git a/src/app/payments/PaymentCard.tsx b/src/app/payments/PaymentCard.tsx new file mode 100644 index 00000000..c8fd3c43 --- /dev/null +++ b/src/app/payments/PaymentCard.tsx @@ -0,0 +1,62 @@ +import { ICard } from '../../types/paymentTypes.ts' +import { CARD_TYPES } from '../../constants/cardTypes.ts' +import React from 'react' +import Card from '../../components/card' + +interface PaymentCardProps extends ICard { + onClick?: (e: React.MouseEvent) => void + cardSize?: 'small' | 'big' +} + +export default function PaymentCard({ + type, + nickname, + cardNumbers = [], + expirationMonth, + expirationYear, + owner, + cardSize = 'small', + onClick: handleClick, +}: PaymentCardProps) { + return ( + <> + + + {type} + + + + + + + + {cardNumbers + .map(({ numbers, isPrivate }) => + (isPrivate ? '*'.repeat(numbers.length) : numbers).padEnd( + 4, + '_', + ), + ) + .join(' - ')} + + + + {owner} + + + + + {Boolean(nickname && cardSize === 'small') && ( + {nickname} + )} + + ) +} diff --git a/src/app/payments/PaymentsPage.tsx b/src/app/payments/PaymentsPage.tsx new file mode 100644 index 00000000..5bbc6732 --- /dev/null +++ b/src/app/payments/PaymentsPage.tsx @@ -0,0 +1,25 @@ +import { Route, Router } from '../../libs/router' +import Header from '../../components/Header.tsx' +import { PaymentsProvider } from './paymentsContext.tsx' +import CreatePage from './create/CreatePage.tsx' +import ListPage from './list/ListPage.tsx' +import EditPage from './edit/EditPage.tsx' + +export default function PaymentsPage() { + return ( +
+ + +
+ } /> + } + data={{ title: '카드 추가' }} + /> + } /> + + +
+ ) +} diff --git a/src/app/payments/create/CardInputWrapper.tsx b/src/app/payments/create/CardInputWrapper.tsx new file mode 100644 index 00000000..adb3f478 --- /dev/null +++ b/src/app/payments/create/CardInputWrapper.tsx @@ -0,0 +1,23 @@ +import Input from '../../../components/input' +import React from 'react' + +interface CardInputWrapperProps { + title: string + boxWidth?: number + children: React.ReactNode +} + +export default function CardInputWrapper({ + title, + boxWidth, + children, +}: CardInputWrapperProps) { + return ( + + {title} + + {children} + + + ) +} diff --git a/src/app/payments/create/CardTypeSelector.tsx b/src/app/payments/create/CardTypeSelector.tsx new file mode 100644 index 00000000..be5f250c --- /dev/null +++ b/src/app/payments/create/CardTypeSelector.tsx @@ -0,0 +1,34 @@ +interface CardTypeSelectorProps { + cardType: string + onSelect: (type: string) => void + options: Array<[string, { name: string; color: string }]> +} + +export default function CardTypeSelector({ + cardType, + onSelect, + options, +}: CardTypeSelectorProps) { + const handleSelectType = (type: string) => { + if (cardType !== type) { + onSelect(type) + } + } + + return ( +
+ {options.map(([type, { name, color }]) => ( +
{ + handleSelectType(type) + }} + > +
+ {name} +
+ ))} +
+ ) +} diff --git a/src/app/payments/create/CreateCardForm.tsx b/src/app/payments/create/CreateCardForm.tsx new file mode 100644 index 00000000..564cdcbf --- /dev/null +++ b/src/app/payments/create/CreateCardForm.tsx @@ -0,0 +1,90 @@ +import { useFormContext } from '../../../libs/form' +import { ICard } from '../../../types/paymentTypes.ts' +import { usePayments } from '../paymentsContext.tsx' +import { useRouter } from '../../../libs/router' +import Input from '../../../components/input' +import CardInputWrapper from './CardInputWrapper.tsx' +import CardTypeSelector from './CardTypeSelector.tsx' +import { CARD_TYPES } from '../../../constants/cardTypes.ts' + +export default function CreateCardForm() { + const { addCard } = usePayments() + const { register, handleSubmit, watch, setValue, checkValueAll } = + useFormContext() + const router = useRouter() + + const onSubmit = (formData: ICard) => { + addCard(formData) + router.go('/payments/new') + } + + return ( +
+ + { + setValue('type', type) + }} + /> + { + setValue('type', type) + }} + /> + + + {Array.from({ length: 4 }).map((_, index) => ( + + ))} + + + + + + + + + + 보안코드(CVC/CVV) + + + + 카드 비밀번호 + {Array.from({ length: 4 }).map((_, index) => ( + + ))} + + +
+ ) +} diff --git a/src/app/payments/create/CreatePage.tsx b/src/app/payments/create/CreatePage.tsx new file mode 100644 index 00000000..fabba875 --- /dev/null +++ b/src/app/payments/create/CreatePage.tsx @@ -0,0 +1,22 @@ +import useForm from '../../../libs/form/useForm.ts' +import { ICard } from '../../../types/paymentTypes.ts' +import { createCardFormOptions, initialCard } from './createCardFormOptions.ts' +import { FormProvider } from '../../../libs/form' +import CreateCardForm from './CreateCardForm.tsx' +import PaymentCard from '../PaymentCard.tsx' + +export default function CreatePage() { + const formMethods = useForm({ + formOptions: createCardFormOptions, + defaultValues: initialCard, + }) + + return ( +
+ + + + +
+ ) +} diff --git a/src/app/payments/create/createCardFormOptions.ts b/src/app/payments/create/createCardFormOptions.ts new file mode 100644 index 00000000..68d61336 --- /dev/null +++ b/src/app/payments/create/createCardFormOptions.ts @@ -0,0 +1,79 @@ +import { IFormOptions } from '../../../libs/form' +import { ICard } from '../../../types/paymentTypes.ts' + +export const createCardFormOptions: IFormOptions = { + 'cardNumbers.0.numbers': { + check: (value) => value.length === 4, + nextField: 'cardNumbers.1.numbers', + }, + 'cardNumbers.1.numbers': { + check: (value) => value.length === 4, + nextField: 'type', + }, + type: { + check: (value) => Boolean(value), + nextField: 'cardNumbers.2.numbers', + }, + 'cardNumbers.2.numbers': { + type: 'password', + check: (value) => value.length === 4, + nextField: 'cardNumbers.3.numbers', + }, + 'cardNumbers.3.numbers': { + type: 'password', + check: (value) => value.length === 4, + }, + expirationMonth: { + check: (value) => value.length === 2, + nextField: 'expirationYear', + }, + expirationYear: { + check: (value) => value.length === 2, + }, + 'password.0': { + type: 'password', + check: (value) => value.length === 1, + nextField: 'password.1', + }, + 'password.1': { + type: 'password', + check: (value) => value.length === 1, + nextField: 'password.2', + }, + 'password.2': { + type: 'password', + check: (value) => value.length === 1, + nextField: 'password.3', + }, + 'password.3': { + type: 'password', + check: (value) => value.length === 1, + }, +} + +export const initialCard: ICard = { + id: '', + type: '', + cardNumbers: [ + { + numbers: '', + isPrivate: false, + }, + { + numbers: '', + isPrivate: false, + }, + { + numbers: '', + isPrivate: true, + }, + { + numbers: '', + isPrivate: true, + }, + ], + expirationMonth: '', + expirationYear: '', + securityCode: '', + password: [], +} diff --git a/src/app/payments/edit/EditPage.tsx b/src/app/payments/edit/EditPage.tsx new file mode 100644 index 00000000..f7d0d478 --- /dev/null +++ b/src/app/payments/edit/EditPage.tsx @@ -0,0 +1,52 @@ +import { useRouter } from '../../../libs/router' +import { usePayments } from '../paymentsContext.tsx' +import PaymentCard from '../PaymentCard.tsx' +import Input from '../../../components/input' +import { useForm } from '../../../libs/form' +import { ICard } from '../../../types/paymentTypes.ts' + +export default function EditPage() { + const { params: { id } = {}, go } = useRouter() + const { editCard, cards } = usePayments() + + const targetCard = cards.find((card) => card.id === id) + + const { register, handleSubmit } = useForm({ + formOptions: { + nickname: { + default: targetCard?.nickname, + }, + }, + }) + + if (!targetCard) { + return null + } + + const onSubmit = (updatedData: ICard) => { + editCard(id, updatedData) + go('/payments') + } + + return ( +
+
+

+ {id === 'new' ? '카드등록이 완료되었습니다.' : '별칭 수정'} +

+
+ + + + + + + ) +} diff --git a/src/app/payments/list/ListPage.tsx b/src/app/payments/list/ListPage.tsx new file mode 100644 index 00000000..450984b0 --- /dev/null +++ b/src/app/payments/list/ListPage.tsx @@ -0,0 +1,33 @@ +import { usePayments } from '../paymentsContext.tsx' +import { useRouter } from '../../../libs/router' +import PaymentCard from '../PaymentCard.tsx' + +export default function ListPage() { + const { cards } = usePayments() + const router = useRouter() + + return ( +
+
+

보유 카드

+
+ {cards.map((card) => ( + { + router.go(`/payments/${card.id}`) + }} + {...card} + /> + ))} +
{ + router.go('/payments/create') + }} + > +
+
+
+
+ ) +} diff --git a/src/app/payments/paymentsContext.tsx b/src/app/payments/paymentsContext.tsx new file mode 100644 index 00000000..f79cdec8 --- /dev/null +++ b/src/app/payments/paymentsContext.tsx @@ -0,0 +1,65 @@ +import React, { createContext, useContext, useState } from 'react' +import { ICard } from '../../types/paymentTypes.ts' + +interface IPaymentContext { + cards: Array + addCard: (card: ICard) => void + editCard: (id: string, card: Partial) => string + removeCard: (id: string) => void +} + +const PaymentsContext = createContext(null) + +export const usePayments = () => { + const context = useContext(PaymentsContext) + if (context === null) { + throw new Error('usePayments must be used within the PaymentsContext') + } + return context +} + +export const PaymentsProvider = ({ + children, +}: { + children: React.ReactNode +}) => { + const [cards, setCards] = useState([]) + + const addCard = (card: ICard) => { + const newCard = { ...card, id: 'new' } + setCards((prev) => [...prev, newCard]) + } + + const editCard = (id: string, updatedCard: Partial) => { + const cardId = id === 'new' ? `${new Date().getTime()}` : id + setCards((prev) => + prev.map((card) => + card.id === id + ? { + ...card, + id: cardId, + ...updatedCard, + } + : card, + ), + ) + return cardId + } + + const removeCard = (id: string) => { + setCards((prev) => prev.filter((card) => card.id !== id)) + } + + return ( + + {children} + + ) +} diff --git a/src/components/Example.tsx b/src/components/Example.tsx new file mode 100644 index 00000000..e62e469b --- /dev/null +++ b/src/components/Example.tsx @@ -0,0 +1,313 @@ +function Example() { + return ( + <> +

React Clean Code Payments CSS example

+

1️⃣ 카드 추가

+
+
+

카드 추가

+
+
+
+
+
+
+
+
+ NAME + MM / YY +
+
+
+
+
+ 카드 번호 +
+ + + + +
+
+
+ 만료일 +
+ + +
+
+
+ 카드 소유자 이름(선택) + +
+
+ 보안코드(CVC/CVV) + +
+
+ 카드 비밀번호 + + + + +
+
+ 다음 +
+
+
+ +

2️⃣ 카드 추가 - 카드사 선택

+
+
+

카드 추가

+
+
+
+ 클린카드 +
+
+
+
+
+
+ 1111 - 2222 - oooo - oooo +
+
+ NAME + MM / YY +
+
+
+
+
+ 카드 번호 +
+ + + + +
+
+
+ 만료일 +
+ + +
+
+
+ 카드 소유자 이름(선택) + +
+
+ 보안코드(CVC/CVV) + +
+
+ 카드 비밀번호 + + + + +
+
+ 다음 +
+
+
+
+
+
+
+ 클린 카드 +
+
+
+ 클린 카드 +
+
+
+ 클린 카드 +
+
+
+ 클린 카드 +
+
+
+
+
+ 클린 카드 +
+
+
+ 클린 카드 +
+
+
+ 클린 카드 +
+
+
+ 클린 카드 +
+
+
+
+
+ +

3️⃣ 카드 추가 - 입력 완료

+
+
+

카드 추가

+
+
+
+ 클린카드 +
+
+
+
+
+
+ 1111 - 2222 - oooo - oooo +
+
+ 프롱이 + 12 / 23 +
+
+
+
+
+ 카드 번호 +
+ + + + +
+
+
+ 만료일 +
+ + +
+
+
+ 카드 소유자 이름(선택) + +
+
+ 보안코드(CVC/CVV) + +
+
+ 카드 비밀번호 + + + + +
+
+ 다음 +
+
+
+ +

4️⃣ 카드 추가 완료

+
+
+
+

카드등록이 완료되었습니다.

+
+
+
+
+ 클린카드 +
+
+
+
+
+
+ + 1111 - 2222 - oooo - oooo + +
+
+ 프롱이 + 12 / 23 +
+
+
+
+
+ +
+
+ 다음 +
+
+
+ +

5️⃣ 카드 목록

+
+
+
+

보유 카드

+
+
+
+
+ 클린카드 +
+
+
+
+
+
+ 1111 - 2222 - oooo - oooo +
+
+ 프롱이 + 12 / 23 +
+
+
+
+ 법인카드 +
+
+
+
+
+
+ + ) +} + +export default Example diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 00000000..e127be86 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,13 @@ +import { useRouter } from '../libs/router' +import React from 'react' + +export default function Header({ + ...props +}: React.HTMLAttributes) { + const { data } = useRouter() + + const pageTitle = data?.title + if (!pageTitle) return null + + return
{pageTitle}
+} diff --git a/src/components/card/Box.tsx b/src/components/card/Box.tsx new file mode 100644 index 00000000..d7ad9f64 --- /dev/null +++ b/src/components/card/Box.tsx @@ -0,0 +1,21 @@ +import React, { ComponentProps } from 'react' + +export interface BoxProps extends ComponentProps<'div'> { + size: 'small' | 'big' + color: string + children: React.ReactNode +} + +const Box = React.forwardRef( + ({ size, color, children, className = '', ...props }, ref) => { + return ( +
+
+ {children} +
+
+ ) + }, +) + +export default Box diff --git a/src/components/card/Chip.tsx b/src/components/card/Chip.tsx new file mode 100644 index 00000000..a272de22 --- /dev/null +++ b/src/components/card/Chip.tsx @@ -0,0 +1,15 @@ +import React, { ComponentProps } from 'react' + +export interface ChipProps extends ComponentProps<'div'> { + size: 'small' | 'big' +} + +const Chip = React.forwardRef( + ({ size, className = '', ...props }, ref) => { + return ( +
+ ) + }, +) + +export default Chip diff --git a/src/components/card/Date.tsx b/src/components/card/Date.tsx new file mode 100644 index 00000000..cf581e64 --- /dev/null +++ b/src/components/card/Date.tsx @@ -0,0 +1,19 @@ +import React, { ComponentProps } from 'react' + +export interface DateProps extends ComponentProps<'span'> { + size: 'small' | 'big' + month: string + year: string +} + +const Date = React.forwardRef( + ({ size, month, year, className = '', ...props }, ref) => { + return ( + + {month} / {year} + + ) + }, +) + +export default Date diff --git a/src/components/card/Nickname.tsx b/src/components/card/Nickname.tsx new file mode 100644 index 00000000..ad6bcd5e --- /dev/null +++ b/src/components/card/Nickname.tsx @@ -0,0 +1,17 @@ +import React, { ComponentProps } from 'react' + +export interface NicknameProps extends ComponentProps<'span'> { + children: React.ReactNode +} + +const Nickname = React.forwardRef( + ({ children, className = '', ...props }, ref) => { + return ( + + {children} + + ) + }, +) + +export default Nickname diff --git a/src/components/card/Section.tsx b/src/components/card/Section.tsx new file mode 100644 index 00000000..b2b7c39f --- /dev/null +++ b/src/components/card/Section.tsx @@ -0,0 +1,23 @@ +import React, { ComponentProps } from 'react' + +export interface SectionProps extends ComponentProps<'div'> { + position: 'top' | 'bottom' | 'middle' + role?: 'number' | 'info' + children: React.ReactNode +} + +const Section = React.forwardRef( + ({ children, position, role, className = '', ...props }, ref) => { + return ( +
+ {children} +
+ ) + }, +) + +export default Section diff --git a/src/components/card/Text.tsx b/src/components/card/Text.tsx new file mode 100644 index 00000000..e65386c7 --- /dev/null +++ b/src/components/card/Text.tsx @@ -0,0 +1,18 @@ +import React, { ComponentProps } from 'react' + +export interface TextProps extends ComponentProps<'span'> { + size: 'small' | 'big' + children: React.ReactNode +} + +const Text = React.forwardRef( + ({ size, children, className = '', ...props }, ref) => { + return ( + + {children} + + ) + }, +) + +export default Text diff --git a/src/components/card/index.ts b/src/components/card/index.ts new file mode 100644 index 00000000..6abbca79 --- /dev/null +++ b/src/components/card/index.ts @@ -0,0 +1,17 @@ +import Box from './Box.tsx' +import Date from './Date.tsx' +import Chip from './Chip.tsx' +import Section from './Section.tsx' +import Text from './Text.tsx' +import Nickname from './Nickname.tsx' + +const Card = { + Box, + Chip, + Date, + Section, + Text, + Nickname, +} + +export default Card diff --git a/src/components/input/Box.tsx b/src/components/input/Box.tsx new file mode 100644 index 00000000..3b068b8b --- /dev/null +++ b/src/components/input/Box.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { SlotComponentProps } from '../../types/componentTypes.ts' + +const Box = React.forwardRef< + HTMLDivElement, + SlotComponentProps +>(({ children, className = '', ...props }, ref) => { + return ( +
+ {children} +
+ ) +}) + +export default Box diff --git a/src/components/input/Container.tsx b/src/components/input/Container.tsx new file mode 100644 index 00000000..f92ce026 --- /dev/null +++ b/src/components/input/Container.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { SlotComponentProps } from '../../types/componentTypes.ts' + +const Container = React.forwardRef< + HTMLDivElement, + SlotComponentProps +>(({ children, className = '', ...props }, ref) => { + return ( +
+ {children} +
+ ) +}) + +export default Container diff --git a/src/components/input/Label.tsx b/src/components/input/Label.tsx new file mode 100644 index 00000000..62b45bd3 --- /dev/null +++ b/src/components/input/Label.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import { SlotComponentProps } from '../../types/componentTypes.ts' + +const Label = React.forwardRef< + HTMLSpanElement, + SlotComponentProps +>(({ className = '', children, ...props }, ref) => { + return ( + + {children} + + ) +}) + +export default Label diff --git a/src/components/input/Modal.tsx b/src/components/input/Modal.tsx new file mode 100644 index 00000000..94ee98a3 --- /dev/null +++ b/src/components/input/Modal.tsx @@ -0,0 +1,43 @@ +import { forwardRef, useState } from 'react' +import ReactDOM from 'react-dom' +import useOutsideClick from '../../hooks/useOutsideClick.ts' +import { SlotComponentProps } from '../../types/componentTypes.ts' + +const Modal = forwardRef< + HTMLInputElement, + SlotComponentProps +>(({ children, ...inputProps }, ref) => { + const [isModalOpen, setIsModalOpen] = useState(false) + + const handleFocus = () => { + setIsModalOpen(true) + } + + const insideRef = useOutsideClick(() => { + setIsModalOpen(false) + }) + + const AppContainer = document.querySelector('.root') + + return ( + <> + + {isModalOpen && + ReactDOM.createPortal( +
+
+ {children} +
+
, + AppContainer!, + )} + + ) +}) + +export default Modal diff --git a/src/components/input/Value.tsx b/src/components/input/Value.tsx new file mode 100644 index 00000000..aef88f27 --- /dev/null +++ b/src/components/input/Value.tsx @@ -0,0 +1,21 @@ +import React from 'react' + +export interface ValueProps extends React.ComponentProps<'input'> { + variant?: 'underline' | 'basic' +} + +const Value = React.forwardRef( + ({ type = 'text', className = '', variant = 'basic', ...props }, ref) => { + return ( + + ) + }, +) + +export default Value diff --git a/src/components/input/index.ts b/src/components/input/index.ts new file mode 100644 index 00000000..8d294dae --- /dev/null +++ b/src/components/input/index.ts @@ -0,0 +1,15 @@ +import Value from './Value.tsx' +import Box from './Box.tsx' +import Container from './Container.tsx' +import Modal from './Modal.tsx' +import Label from './Label.tsx' + +const Input = { + Value, + Box, + Container, + Label, + Modal, +} + +export default Input diff --git a/src/constants/cardTypes.ts b/src/constants/cardTypes.ts new file mode 100644 index 00000000..b0b62f1c --- /dev/null +++ b/src/constants/cardTypes.ts @@ -0,0 +1,10 @@ +export const CARD_TYPES: Record = { + RED: { name: '찬욱 카드', color: '#E24141' }, + BLUE: { name: '효리 카드', color: '#547CE4' }, + GREEN: { name: '수연 카드', color: '#73BC6D' }, + PINK: { name: '세진 카드', color: '#DE59B9' }, + MINT: { name: '진경 카드', color: '#94DACD' }, + CORAL: { name: '종길 카드', color: '#E76E9A' }, + ORANGE: { name: '건우 카드', color: '#F37D3B' }, + YELLOW: { name: '혜성 카드', color: '#FBCD58' }, +} diff --git a/src/hooks/useOutsideClick.ts b/src/hooks/useOutsideClick.ts new file mode 100644 index 00000000..e51ad4c2 --- /dev/null +++ b/src/hooks/useOutsideClick.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef } from 'react' + +const useOutsideClick = ( + onClickOutside: VoidFunction, +) => { + const targetRef = useRef(null) + + useEffect(() => { + const handleClick = (event: MouseEvent) => { + const targetElement = targetRef.current + if (targetElement && targetElement.contains(event.target as Node)) { + onClickOutside() + } + } + + document.addEventListener('click', handleClick) + + return () => { + document.removeEventListener('click', handleClick) + } + }, [onClickOutside]) + + return targetRef +} + +export default useOutsideClick diff --git a/src/libs/form/__tests__/useForm.test.tsx b/src/libs/form/__tests__/useForm.test.tsx new file mode 100644 index 00000000..fd33baa7 --- /dev/null +++ b/src/libs/form/__tests__/useForm.test.tsx @@ -0,0 +1,116 @@ +import { act, fireEvent, render, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import useForm from '../useForm' + +interface TestFormValues { + firstName: string + lastName: string +} + +describe('useForm 훅', () => { + it('기본값으로 폼 값을 초기화해야 합니다.', () => { + const { result } = renderHook(() => + useForm({ + defaultValues: { firstName: 'John', lastName: 'Doe' }, + }), + ) + + expect(result.current.getValues()).toEqual({ + firstName: 'John', + lastName: 'Doe', + }) + }) + + it('input 변경 시 값이 업데이트되어야 합니다.', () => { + const { result } = renderHook(() => useForm()) + const firstNameInput = result.current.register('firstName') + + act(() => { + firstNameInput.onChange({ + target: { value: 'Jane' }, + } as React.ChangeEvent) + }) + + expect(result.current.getValue('firstName')).toBe('Jane') + }) + + it('폼 제출 시 입력 데이터와 함께 handleSubmit이 호출되어야 합니다.', () => { + const mockSubmitFn = vi.fn() + + const { result } = renderHook(() => + useForm({ + defaultValues: { firstName: 'John', lastName: 'Doe' }, + }), + ) + + const formElement = render( +
, + ).getByTestId('form') + + fireEvent.submit(formElement) + + expect(mockSubmitFn).toHaveBeenCalledWith({ + firstName: 'John', + lastName: 'Doe', + }) + }) + + it('폼 값이 올바르게 유효성 검사를 통과해야 합니다.', () => { + const { result } = renderHook(() => + useForm({ + formOptions: { + firstName: { + check: (value) => value.length > 0, + }, + }, + }), + ) + + result.current.register('firstName') + result.current.register('lastName') + + const isFirstNameValid = result.current.checkValue('firstName') + expect(isFirstNameValid).toBe(false) + + act(() => { + result.current.setValue('firstName', 'Jane') + }) + + expect(result.current.checkValue('firstName')).toBe(true) + }) + + it('nextField가 지정된 경우에는 유효성 검사를 통과하면 다음 필드로 포커스를 이동시켜야 합니다.', () => { + const { result } = renderHook(() => + useForm({ + formOptions: { + firstName: { + check: (value) => value.length > 3, + nextField: 'lastName', + }, + }, + }), + ) + + const firstNameInput = render( + , + ).getByTestId('first-name') + + const lastNameInput = render( + , + ).getByTestId('last-name') + + const focusSpy = vi.spyOn(lastNameInput, 'focus') + fireEvent.change(firstNameInput, { target: { value: 'Jane' } }) + + expect(focusSpy).toHaveBeenCalled() + }) +}) diff --git a/src/libs/form/formContext.tsx b/src/libs/form/formContext.tsx new file mode 100644 index 00000000..216a2023 --- /dev/null +++ b/src/libs/form/formContext.tsx @@ -0,0 +1,23 @@ +import { createContext, useContext } from 'react' +import { UseFormReturnType } from './type.ts' + +interface FormProviderProps { + formMethods: UseFormReturnType + children: React.ReactNode +} + +const FormContext = createContext | null>(null) + +export function FormProvider({ children, formMethods }: FormProviderProps) { + return ( + {children} + ) +} + +export function useFormContext() { + const context = useContext(FormContext) + if (!context) { + throw new Error('useFormContext must be used within a FormProvider') + } + return context as UseFormReturnType +} diff --git a/src/libs/form/index.ts b/src/libs/form/index.ts new file mode 100644 index 00000000..376ad79a --- /dev/null +++ b/src/libs/form/index.ts @@ -0,0 +1,3 @@ +export * from './type.ts' +export { default as useForm } from './useForm.ts' +export * from './formContext.tsx' diff --git a/src/libs/form/makeFormValues.ts b/src/libs/form/makeFormValues.ts new file mode 100644 index 00000000..643fc37d --- /dev/null +++ b/src/libs/form/makeFormValues.ts @@ -0,0 +1,35 @@ +import { TInputValues } from './type.ts' + +// 깊은 복사를 위한 유틸리티 함수 +const deepClone = (obj: T): T => { + return JSON.parse(JSON.stringify(obj)) +} + +const makeFormValues = ( + inputValues: TInputValues, + defaultValues = {} as T, +): T => { + const keyLists = Object.keys(inputValues).map((key) => key.split('.')) + const result: any = deepClone(defaultValues) + + keyLists.forEach((keyList) => { + const lastKey = keyList.join('.') + + keyList.reduce((currentResult, key, index) => { + if (index === keyList.length - 1) { + currentResult[key] = inputValues[lastKey as keyof typeof inputValues] + } else { + const nextKeyIsNumeric = !isNaN(parseInt(keyList[index + 1], 10)) + if (!currentResult[key]) { + currentResult[key] = nextKeyIsNumeric ? [] : {} + } + } + + return currentResult[key] + }, result) + }) + + return result +} + +export default makeFormValues diff --git a/src/libs/form/type.ts b/src/libs/form/type.ts new file mode 100644 index 00000000..8672059a --- /dev/null +++ b/src/libs/form/type.ts @@ -0,0 +1,57 @@ +import { HTMLInputTypeAttribute } from 'react' + +export type FormKey = ObjectType extends object + ? { + [PropertyName in keyof ObjectType]: ObjectType[PropertyName] extends Array< + infer ItemType + > + ? ItemType extends object + ? `${PropertyName & string}.${number}.${FormKey & string}` + : `${PropertyName & string}.${number}` + : ObjectType[PropertyName] extends object | undefined + ? `${PropertyName & string}.${FormKey & string}` + : PropertyName + }[keyof ObjectType] + : never + +export type TInputRef = Record, HTMLInputElement | null> + +export type TInputValues = Record, string> + +export type TWatchUsed = Record, boolean> + +export interface IFormData { + [key: string]: unknown +} + +export type IFormOptions = Partial< + Record< + FormKey, + { + type?: HTMLInputTypeAttribute + default?: string + check?: (value: string) => boolean + nextField?: FormKey + } + > +> + +export interface UseFormReturnType { + register: (key: FormKey | string) => { + ref: (element: HTMLInputElement | null) => void + onChange: (event: React.ChangeEvent) => void + defaultValue?: string + name: string + type?: React.HTMLInputTypeAttribute + } + watch: (key: FormKey) => string + watchAll: () => T + setValue: (key: FormKey, value: string) => void + getValue: (key: FormKey) => string + getValues: () => T + handleSubmit: ( + submitFn: (formData: T) => void, + ) => (e: React.FormEvent) => void + checkValue: (key: FormKey) => boolean + checkValueAll: () => boolean +} diff --git a/src/libs/form/useForm.ts b/src/libs/form/useForm.ts new file mode 100644 index 00000000..5141e94b --- /dev/null +++ b/src/libs/form/useForm.ts @@ -0,0 +1,133 @@ +import React, { useRef, useState } from 'react' +import makeFormValues from './makeFormValues' +import { + FormKey, + IFormOptions, + TInputRef, + TInputValues, + TWatchUsed, + UseFormReturnType, +} from './type' + +interface UseFormParams { + formOptions?: IFormOptions + defaultValues?: T +} + +const useForm = >({ + formOptions, + defaultValues, +}: UseFormParams = {}): UseFormReturnType => { + const inputRef = useRef>({} as TInputRef) + const [watchValues, setWatchValues] = useState>( + {} as TInputValues, + ) + const values = useRef>({} as TInputValues) + + let watchUsedAll = false + let watchUsed = {} as TWatchUsed + + const focusNextField = (key: FormKey, value: string) => { + if (formOptions?.[key]?.check?.(value)) { + const nextField = formOptions[key].nextField + if (nextField && inputRef.current[nextField]) { + inputRef.current[nextField].focus() + } + } + } + + const updateValue = (key: FormKey, value: string) => { + values.current[key] = value + if (watchUsedAll || watchUsed[key]) { + setWatchValues((prev) => ({ ...prev, [key]: value })) + } + } + + const register = (key: FormKey | string) => { + const formKey = key as FormKey + if (!values.current[formKey]) { + values.current[formKey] = '' + } + + return { + name: String(formKey), + + onChange: (event: React.ChangeEvent) => { + const { value } = event.target + updateValue(formKey, value) + focusNextField(formKey, value) + }, + + ref: (element: HTMLInputElement | null) => { + inputRef.current = { ...inputRef.current, [formKey]: element } + }, + + type: formOptions?.[formKey]?.type, + defaultValue: formOptions?.[formKey]?.default, + } + } + + const checkValue = (key: FormKey): boolean => { + const value = values.current[key] + if (formOptions?.[key]?.check === undefined) { + return true + } + return formOptions[key].check(value ?? '') + } + + const checkValueAll = () => { + return Object.keys(formOptions ?? {}).every((key) => + checkValue(key as FormKey), + ) + } + + const setValue = (key: FormKey, value: string) => { + values.current[key] = value + + if (inputRef.current[key]) { + inputRef.current[key].setAttribute('value', value) + inputRef.current[key].dispatchEvent( + new Event('change', { bubbles: true }), + ) + } + } + + const watch = (key: FormKey) => { + watchUsed = { ...watchUsed, [key]: true } + return watchValues[key] + } + + const watchAll = () => { + watchUsedAll = true + return makeFormValues(watchValues, defaultValues) + } + + const getValue = (key: FormKey) => { + return values.current[key] ?? '' + } + + const getValues = () => { + return makeFormValues(values.current, defaultValues) + } + + const handleSubmit = (submitFn: (formData: T) => void) => { + return (e: React.FormEvent) => { + e.preventDefault() + submitFn(getValues()) + } + } + + return { + register, + watch, + watchAll, + setValue, + getValue, + getValues, + handleSubmit, + checkValue, + checkValueAll, + } +} + +export default useForm diff --git a/src/libs/router/Route.tsx b/src/libs/router/Route.tsx new file mode 100644 index 00000000..a549f721 --- /dev/null +++ b/src/libs/router/Route.tsx @@ -0,0 +1,18 @@ +import { IRouteType } from './type.ts' +import { useContext } from 'react' +import { RouterContext } from './Router.tsx' + +export default function Route({ path, element }: IRouteType) { + const routerContext = useContext(RouterContext) + if (!routerContext) { + throw new Error('RouterContext must be provided') + } + + const { currentRoute } = routerContext + + if (path === currentRoute?.path) { + return element + } else { + return null + } +} diff --git a/src/libs/router/Router.tsx b/src/libs/router/Router.tsx new file mode 100644 index 00000000..6a500693 --- /dev/null +++ b/src/libs/router/Router.tsx @@ -0,0 +1,75 @@ +import React, { createContext, useContext, useMemo, useState } from 'react' +import { IRouterContextValue, IRouteType } from './type.ts' +import Route from './Route.tsx' +import extractParams from './extractParams.ts' + +export const RouterContext = createContext(null) + +interface IRouterProviderProps { + children: React.ReactNode +} + +export const Router = ({ children }: IRouterProviderProps) => { + const parentRouteContext = useContext(RouterContext) + + const depth = parentRouteContext === null ? 0 : parentRouteContext.depth + 1 + + const routes: IRouteType[] = React.Children.toArray(children) + .filter( + ( + child, + ): child is React.ReactElement<{ + path: string + element: React.ReactNode + data: Record + }> => React.isValidElement(child) && child.type === Route, + ) + .map(({ props: { path, element, data = {} } }) => ({ + path, + element, + data, + })) + + const [location, setLocation] = useState(window.location.pathname) + const locationSegments = location + .split('/') + .map((segment) => `/${segment}`) + .slice(1) + + const currentRoute = useMemo( + () => + routes.find( + ({ path }) => + path === + (locationSegments.length === depth + ? '/' + : locationSegments[depth]) || path.startsWith('/:'), + ), + [depth, locationSegments, routes], + ) + + const params: Record = useMemo(() => { + const nonParams = routes + .map(({ path }) => path) + .includes(locationSegments[depth]) + + if (nonParams) { + return {} + } + return routes.reduce( + (params, { path }) => ({ + ...params, + ...extractParams(path, locationSegments[depth]), + }), + {}, + ) + }, [depth, locationSegments, routes]) + + return ( + + {children} + + ) +} diff --git a/src/libs/router/__tests__/router.test.tsx b/src/libs/router/__tests__/router.test.tsx new file mode 100644 index 00000000..a3015fd9 --- /dev/null +++ b/src/libs/router/__tests__/router.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, act } from '@testing-library/react' +import Route from '../Route' +import { Router } from '../Router' +import useRouter from '../useRouter' + +describe('라우팅 기능 테스트', () => { + it('기본 라우트가 올바르게 렌더링되어야 합니다.', () => { + render( + + Home Page
} /> + , + ) + expect(screen.getByText('Home Page')).toBeInTheDocument() + }) + + it('useRouter 훅이 작성한 경로로 페이지를 올바르게 이동시켜야 합니다.', () => { + const HomeTest = () => { + const { go } = useRouter() + return ( +
+ Home Page + +
+ ) + } + const AboutTest = () => { + const { go } = useRouter() + return ( +
+ About Page + +
+ ) + } + const ItemTest = () => { + const { params } = useRouter() + return
Param: {params.id}
+ } + render( + + } /> + } /> + } /> + , + ) + + act(() => { + screen.getByText('Go to About').click() + }) + expect(screen.getByText('About Page')).toBeInTheDocument() + + act(() => { + screen.getByText('Go to Item').click() + }) + expect(screen.getByText('Param: 123')).toBeInTheDocument() + }) +}) diff --git a/src/libs/router/extractParams.ts b/src/libs/router/extractParams.ts new file mode 100644 index 00000000..66615d33 --- /dev/null +++ b/src/libs/router/extractParams.ts @@ -0,0 +1,14 @@ +const extractParams = ( + path: string, + segment: string, +): Record | null => { + if (!(path.startsWith('/:') && segment)) { + return null + } + const key = path.replace('/:', '') + const value = segment.substring(1) + + return { [key]: value } +} + +export default extractParams diff --git a/src/libs/router/index.ts b/src/libs/router/index.ts new file mode 100644 index 00000000..1ac5ee87 --- /dev/null +++ b/src/libs/router/index.ts @@ -0,0 +1,3 @@ +export * from './Router.tsx' +export { default as Route } from './Route.tsx' +export { default as useRouter } from './useRouter.ts' diff --git a/src/libs/router/type.ts b/src/libs/router/type.ts new file mode 100644 index 00000000..c2795776 --- /dev/null +++ b/src/libs/router/type.ts @@ -0,0 +1,18 @@ +import React from 'react' + +type TRouterData = Record + +export interface IRouteType { + path: string + element: React.ReactNode + data?: TRouterData +} + +export interface IRouterContextValue { + depth: number + routes: IRouteType[] + currentRoute?: IRouteType + location: string + setLocation: (value: string) => void + params: Record +} diff --git a/src/libs/router/useRouter.ts b/src/libs/router/useRouter.ts new file mode 100644 index 00000000..14c60cad --- /dev/null +++ b/src/libs/router/useRouter.ts @@ -0,0 +1,40 @@ +import { useContext, useEffect } from 'react' +import { RouterContext } from './Router.tsx' + +const useRouter = () => { + const routerContext = useContext(RouterContext) + if (routerContext === null) { + throw new Error('useRouter must be used in ...') + } + + const { location, setLocation, currentRoute, params } = routerContext + + useEffect(() => { + const handlePopState = () => { + setLocation(window.location.pathname) + } + window.addEventListener('popstate', handlePopState) + return () => { + window.removeEventListener('popstate', handlePopState) + } + }, [setLocation]) + + const go = (path: string | -1) => { + if (path === -1) { + window.history.back() + } else { + window.history.pushState({}, '', path) + setLocation(path) + } + } + + return { + location, + go, + path: currentRoute?.path, + data: currentRoute?.data, + params, + } +} + +export default useRouter diff --git a/src/main.tsx b/src/main.tsx index 049d9693..38b6c6d3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,6 @@ import React from 'react' import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import App from './app/App.tsx' import './styles/index.css' ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/setupTests.ts b/src/setupTests.ts index 7b0828bf..c44951a6 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1 +1 @@ -import '@testing-library/jest-dom'; +import '@testing-library/jest-dom' diff --git a/src/styles/button.css b/src/styles/button.css index 972ce4c3..b92fcd94 100644 --- a/src/styles/button.css +++ b/src/styles/button.css @@ -1,8 +1,16 @@ +button { + border: none; + background-color: transparent; + margin: 0; + padding: 0; + cursor: pointer; +} + .button-box { - width: 100%; - text-align: right; + width: 100%; + text-align: right; } .button-text { - margin-right: 10px; + margin-right: 10px; } diff --git a/src/styles/card.css b/src/styles/card.css index c0e9c0e5..feb35ce7 100644 --- a/src/styles/card.css +++ b/src/styles/card.css @@ -1,136 +1,136 @@ .card-box { - display: flex; - align-items: center; - justify-content: center; + display: flex; + align-items: center; + justify-content: center; - margin: 10px 0; + margin: 10px 0; } .empty-card { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; - width: 208px; - height: 130px; + width: 208px; + height: 130px; - font-size: 30px; - color: #575757; + font-size: 30px; + color: #575757; - background: #e5e5e5; - box-shadow: 3px 3px 5px rgba(0, 0, 0, 0.25); - border-radius: 5px; + background: #e5e5e5; + box-shadow: 3px 3px 5px rgba(0, 0, 0, 0.25); + border-radius: 5px; - user-select: none; + user-select: none; } .small-card { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; - width: 208px; - height: 130px; + width: 208px; + height: 130px; - background: #94dacd; - box-shadow: 3px 3px 5px rgba(0, 0, 0, 0.25); - border-radius: 5px; + background: #94dacd; + box-shadow: 3px 3px 5px rgba(0, 0, 0, 0.25); + border-radius: 5px; } .small-card__chip { - width: 40px; - height: 26px; - left: 95px; - top: 122px; + width: 40px; + height: 26px; + left: 95px; + top: 122px; - background: #cbba64; - border-radius: 4px; + background: #cbba64; + border-radius: 4px; } .big-card { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; - width: 290px; - height: 180px; + width: 290px; + height: 180px; - background: #94dacd; - box-shadow: 3px 3px 5px rgba(0, 0, 0, 0.25); - border-radius: 5px; + background: #94dacd; + box-shadow: 3px 3px 5px rgba(0, 0, 0, 0.25); + border-radius: 5px; } .big-card__chip { - width: 55.04px; - height: 35.77px; + width: 55.04px; + height: 35.77px; - background: #cbba64; - border-radius: 4px; + background: #cbba64; + border-radius: 4px; - font-size: 24px; + font-size: 24px; } .card-top { - width: 100%; - height: 100%; + width: 100%; + height: 100%; - display: flex; - align-items: center; + display: flex; + align-items: center; } .card-middle { - width: 100%; - height: 100%; - margin-left: 30px; + width: 100%; + height: 100%; + margin-left: 30px; - display: flex; - align-items: center; + display: flex; + align-items: center; } .card-bottom { - width: 100%; - height: 100%; + width: 100%; + height: 100%; - display: flex; - flex-direction: column; - align-items: center; + display: flex; + flex-direction: column; + align-items: center; } .card-bottom__number { - width: 100%; - height: 100%; + width: 100%; + height: 100%; - display: flex; - align-items: center; - justify-content: center; + display: flex; + align-items: center; + justify-content: center; } .card-bottom__info { - width: 100%; - height: 100%; + width: 100%; + height: 100%; - display: flex; - align-items: center; - justify-content: space-between; + display: flex; + align-items: center; + justify-content: space-between; } -.card-text { - margin: 0 16px; +.card-text__small { + margin: 0 16px; - font-size: 14px; - line-height: 16px; - vertical-align: middle; - font-weight: 400; + font-size: 14px; + line-height: 16px; + vertical-align: middle; + font-weight: 400; } .card-text__big { - margin: 0 16px; + margin: 0 16px; - font-size: 18px; - line-height: 20px; - vertical-align: middle; - font-weight: 400; + font-size: 18px; + line-height: 20px; + vertical-align: middle; + font-weight: 400; } diff --git a/src/styles/index.css b/src/styles/index.css index 783a2e38..3ff1ea68 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -5,38 +5,38 @@ @import "./utils.css"; body { - display: flex; - flex-direction: column; - gap: 0.5rem; - align-items: center; - justify-content: center; - background-color: #e5e5e5; + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: center; + justify-content: center; + background-color: #e5e5e5; } input { - font-size: 16px; + font-size: 16px; } .root { - background-color: #fff; - width: 375px; - min-width: 375px; - height: 700px; - position: relative; - border-radius: 15px; + background-color: #fff; + width: 375px; + min-width: 375px; + height: 700px; + position: relative; + border-radius: 15px; } .app { - height: 100%; - padding: 16px 24px; + height: 100%; + padding: 16px 24px; } .page-title { - font-weight: 500; - font-size: 20px; - line-height: 22px; - display: flex; - align-items: center; + font-weight: 500; + font-size: 20px; + line-height: 22px; + display: flex; + align-items: center; - color: #383838; + color: #383838; } diff --git a/src/styles/input.css b/src/styles/input.css index 74c32037..ea3ce28d 100644 --- a/src/styles/input.css +++ b/src/styles/input.css @@ -1,48 +1,48 @@ .input-container { - margin: 16px 0; + margin: 16px 0; } .input-box { - display: flex; - align-items: center; - margin-top: 0.375rem; - color: #d3d3d3; - border-radius: 0.25rem; - background-color: #ecebf1; + display: flex; + align-items: center; + margin-top: 0.375rem; + color: #d3d3d3; + border-radius: 0.25rem; + background-color: #ecebf1; } .input-title { - display: flex; - align-items: center; + display: flex; + align-items: center; - font-size: 12px; - line-height: 14px; + font-size: 12px; + line-height: 14px; - margin-bottom: 4px; + margin-bottom: 4px; - color: #525252; + color: #525252; } .input-basic { - background-color: #ecebf1; - height: 45px; - width: 100%; - text-align: center; - outline: 2px solid transparent; - outline-offset: 2px; - border-color: #9ca3af; - border: none; - border-radius: 0.25rem; + background-color: #ecebf1; + height: 45px; + width: 100%; + text-align: center; + outline: 2px solid transparent; + outline-offset: 2px; + border-color: #9ca3af; + border: none; + border-radius: 0.25rem; } .input-underline { - text-align: center; - border: none; - background: none; - outline: none; + text-align: center; + border: none; + background: none; + outline: none; - margin: 16px 0; - padding: 4px 0; + margin: 16px 0; + padding: 4px 0; - border-bottom: 1px solid #383838; + border-bottom: 1px solid #383838; } diff --git a/src/styles/modal.css b/src/styles/modal.css index d86fa3b0..0393b229 100644 --- a/src/styles/modal.css +++ b/src/styles/modal.css @@ -1,53 +1,58 @@ .modal { - width: 375px; - height: 220px; + width: 375px; + height: 220px; - border-radius: 5px 5px 15px 15px; + border-radius: 5px 5px 15px 15px; - display: flex; - justify-content: center; - align-items: center; - flex-wrap: wrap; + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; - background: #fff; - z-index: 10; + background: #fff; + z-index: 10; } .modal-dimmed { - width: 100%; - height: 100%; + width: 100%; + height: 100%; - display: flex; - flex-direction: column; - justify-content: flex-end; + display: flex; + flex-direction: column; + justify-content: flex-end; - position: absolute; - top: 0; - left: 0; + position: absolute; + top: 0; + left: 0; - background: rgba(0, 0, 0, 0.5); + background: rgba(0, 0, 0, 0.5); - border-radius: 15px; + border-radius: 15px; - z-index: 5; + z-index: 5; } .modal-item-container { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; } .modal-item-dot { - margin: 0.5rem 1rem; - border-radius: 50%; - width: 2.8rem; - height: 2.8rem; - background-color: #94dacd; + margin: 0.5rem 1rem; + border-radius: 50%; + width: 2.8rem; + height: 2.8rem; + background-color: #94dacd; + cursor: pointer; +} + +.modal-item-dot:hover { + transform: scale(1.02); } .modal-item-name { - font-size: 12px; - letter-spacing: -0.085rem; + font-size: 12px; + letter-spacing: -0.085rem; } diff --git a/src/styles/utils.css b/src/styles/utils.css index e86f525f..cab9ca9e 100644 --- a/src/styles/utils.css +++ b/src/styles/utils.css @@ -1,56 +1,56 @@ .flex-center { - display: flex; - justify-content: center; - align-items: center; + display: flex; + justify-content: center; + align-items: center; } .flex-column-center { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; } .mt-10 { - margin-top: 2.5rem; + margin-top: 2.5rem; } .mt-20 { - margin-top: 5rem; + margin-top: 5rem; } .mt-30 { - margin-top: 7.5rem; + margin-top: 7.5rem; } .mt-40 { - margin-top: 9rem; + margin-top: 9rem; } .mt-50 { - margin-top: 11.5rem; + margin-top: 11.5rem; } .mb-10 { - margin-bottom: 2.5rem; + margin-bottom: 2.5rem; } .w-100 { - width: 100%; + width: 100%; } .w-75 { - width: 75%; + width: 75%; } .w-50 { - width: 50%; + width: 50%; } .w-25 { - width: 25%; + width: 25%; } .w-15 { - width: 15%; + width: 15%; } diff --git a/src/types/componentTypes.ts b/src/types/componentTypes.ts new file mode 100644 index 00000000..7611c4d7 --- /dev/null +++ b/src/types/componentTypes.ts @@ -0,0 +1,5 @@ +import React from 'react' + +export interface SlotComponentProps extends React.HTMLAttributes { + children: React.ReactNode +} diff --git a/src/types/paymentTypes.ts b/src/types/paymentTypes.ts new file mode 100644 index 00000000..d810f291 --- /dev/null +++ b/src/types/paymentTypes.ts @@ -0,0 +1,14 @@ +export interface ICard { + id?: string + type: string + nickname?: string + cardNumbers: Array<{ + numbers: string + isPrivate: boolean + }> + expirationMonth: string + expirationYear: string + owner?: string + securityCode: string + password: Array<0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9> +} diff --git a/tsconfig.app.json b/tsconfig.app.json index ab1d7dc4..9990a25c 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -21,7 +21,8 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true }, "include": [ "src" diff --git a/tsconfig.json b/tsconfig.json index ea9d0cd8..cd68bb2d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,5 +7,8 @@ { "path": "./tsconfig.node.json" } - ] + ], + "compilerOptions": { + "strict": true + } }