diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 65ba6d2d6..7acd5cf45 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -18,15 +18,15 @@ - 뷰데이터와 엔티티데이터의 분리에 대한 이해 - entities -> features -> UI 계층에 대한 이해 -- [ ] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? -- [ ] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? -- [ ] 계산함수는 순수함수로 작성이 되었나요? -- [ ] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? -- [ ] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? -- [ ] 계산함수는 순수함수로 작성이 되었나요? -- [ ] 특정 Entitiy만 다루는 함수는 분리되어 있나요? -- [ ] 특정 Entitiy만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요? -- [ ] 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요? +- [x] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? +- [x] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? +- [x] 계산함수는 순수함수로 작성이 되었나요? +- [x] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? +- [x] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? +- [x] 계산함수는 순수함수로 작성이 되었나요? +- [x] 특정 Entitiy만 다루는 함수는 분리되어 있나요? +- [x] 특정 Entitiy만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요? +- [x] 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요? ### 심화과제 @@ -34,20 +34,108 @@ - 어떤 props는 남겨야 하는지, 어떤 props는 제거해야 하는지에 대한 기준을 세워보세요. - Context나 Jotai를 사용하여 상태를 관리하는 방법을 익히고, 이를 통해 컴포넌트 간의 데이터 전달을 효율적으로 처리할 수 있습니다. -- [ ] Context나 Jotai를 사용해서 전역상태관리를 구축했나요? -- [ ] 전역상태관리를 통해 domain custom hook을 적절하게 리팩토링 했나요? -- [ ] 도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거했나요? -- [ ] 전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드가 되었나요? - +- [x] Context나 Jotai를 사용해서 전역상태관리를 구축했나요? +- [x] 전역상태관리를 통해 domain custom hook을 적절하게 리팩토링 했나요? +- [x] 도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거했나요? +- [x] 전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드가 되었나요? ## 과제 셀프회고 +https://youngh02.github.io/front_7th_chapter3-2/ + +이번 과제를 통해 "읽기 좋은 코드"와 "좋은 설계"가 무엇인지 깊이 고민하는 시간을 가졌습니다. 특히 거대한 컴포넌트를 분리하면서 어디까지 나눠야 적절한지, 과도한 분리는 아닌지 끊임없이 고민했습니다. 관심사 분리의 원칙을 지키면서도 실용성을 잃지 않는 균형점을 찾는 것이 가장 어려웠고, 그 과정에서 많은 것을 배울 수 있었습니다. ### 과제를 하면서 내가 알게된 점, 좋았던 점은 무엇인가요? +**관심사 분리의 실질적인 효과** + +처음 1000줄이 넘는 거대한 App.tsx를 마주했을 때는 막막했지만, 단계적으로 분리하면서 각 계층의 역할이 명확해지는 것을 체감했습니다. Model(순수 함수) → Hook(상태 관리) → Component(렌더링)로 분리하니 각 부분을 독립적으로 테스트하고 수정할 수 있었고, 코드의 흐름을 파악하기도 훨씬 수월해졌습니다. + + +**컴포넌트 계층 설계의 중요성** + +Feature 컴포넌트는 Hook을 직접 사용하고, UI 컴포넌트는 Props로 데이터를 받는 패턴을 적용하면서 재사용성과 테스트 용이성이 크게 향상되는 것을 경험했습니다. 특히 `ProductCard`, `CartItem` 같은 작은 UI 컴포넌트들이 여러 곳에서 재사용되는 모습을 보며, 처음부터 계층을 잘 설계하는 것이 얼마나 중요한지 깨달았습니다. + ### 이번 과제에서 내가 제일 신경 쓴 부분은 무엇인가요? +**1. 적절한 추상화 레벨 찾기** + +힌트 코드보다 더 많은 models와 hooks로 분리했는데, 너무 세분화하면 오히려 복잡도가 증가할 수 있다는 고민이 있었습니다. 예를 들어 `cart.ts`, `product.ts`, `coupon.ts`, `discount.ts`로 나누고, hooks도 각각 분리했는데, 이것이 과도한 분리인지 아니면 단일 책임 원칙에 더 부합하는 구조인지 계속 고민했습니다. + +**2. Props 전달 패턴의 일관성** + +UI 컴포넌트에 Props를 전달할 때, 모든 핸들러를 개별적으로 전달하는 방식과 통합 핸들러를 사용하는 방식 사이에서 고민이 많았습니다. 예를 들어 `CouponForm`에 `onNameChange`, `onCodeChange` 등을 각각 전달하는 것이 명시적이긴 하지만, Props가 너무 많아져서 가독성이 떨어지는 것 같았습니다. 명시성과 간결함 사이의 균형을 어떻게 맞춰야 할지 고민이 깊었습니다. + +**3. 폴더 구조와 파일 분산** + +`_common`, `_icons` 같이 언더스코어를 붙여서 일반 도메인 폴더와 구분했는데, 이것이 최선의 방법인지 확신이 서지 않았습니다. 또한 도메인별 구조(`cartPage/`, `adminPage/`)와 기능별 구조(`features/`) 중 어떤 것이 더 확장성이 좋을지 고민했습니다. 특히 하나의 엔티티(예: cart)와 관련된 파일들이 `models/cart.ts`, `hooks/useCart.ts`, `components/cartPage/` 등 여러 폴더에 흩어져 있어서, 이것이 올바른 구조인지 의문이 들었습니다. + ### 이번 과제를 통해 앞으로 해보고 싶은게 있다면 알려주세요! ### 리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 편하게 남겨주세요 :) + +**1. 파일 분리 수준과 디렉토리 구조** + +힌트 코드보다 더 많은 models와 hooks로 분리했는데, 이것이 적절한 수준인지 궁금합니다. + +현재 구조: + +``` +src/basic/ +├── models/ +│ ├── cart.ts +│ ├── product.ts +│ ├── coupon.ts +│ └── discount.ts +├── hooks/ +│ ├── useCart.ts +│ ├── useProducts.ts +│ ├── useCoupons.ts +│ └── useDiscount.ts +└── components/ + ├── _common/ + ├── _icons/ + ├── cartPage/ + └── adminPage/ +``` + +- 이렇게 세분화하는 것이 단일 책임 원칙에 더 부합하는 건지, 아니면 과도한 분리로 오히려 복잡도만 증가시키는 건지 궁금합니다. +- 하나의 엔티티(예: cart)와 관련된 파일들이 `models/cart.ts`, `hooks/useCart.ts`, `components/cartPage/` 등 여러 폴더에 흩어져 있는데, 이것이 올바른 구조인가요? 아니면 feature-based 구조로 `features/cart/` 안에 모아두는 것이 더 나을까요? +- 모든 비즈니스 로직과 상태 관리가 hooks로 빠지는 것이 맞는 건가요? 어디까지 분리해야 적절한지 기준이 궁금합니다. + + +2. Props 전달 패턴 +basic에서 관심사를 분리하고자 모든 로직을 이렇게 props로 전달하는게 맞을까요? + +```tsx + handleChange("name", value)} + onCodeChange={(value) => handleChange("code", value, formatCouponCode)} + onDiscountTypeChange={(value) => handleChange("discountType", value)} + onDiscountValueChange={(value) => + handleChange("discountValue", parseDiscountValue(value)) + } + onSubmit={handleCouponSubmit} + onCancel={() => setShowCouponForm(false)} +/> +``` +- 이렇게 하면 명시적이긴 하지만 Props가 너무 많아집니다. +- 대안으로 하나의 `onChange` 핸들러로 통합하거나, `useForm` Hook을 만들어서 컴포넌트 내부에서 상태를 관리하는 방법도 있는데, 어떤 방식이 더 나은 설계일까요? +- UI 컴포넌트의 재사용성을 위해서는 Props로 받는 것이 맞지만, 너무 많은 Props는 오히려 사용하기 어렵게 만드는 것 같습니다. 적절한 균형점은 어디일까요? + +3. Event Emitter 패턴 사용 +localStorage 동기화를 위해 Event Emitter 패턴(Pub/Sub)을 사용했습니다: +```tsx +// 발행 +window.dispatchEvent(new Event('cart-updated')); + +// 구독 +window.addEventListener('cart-updated', handleUpdate); +``` +이 방식이 Hook 내부에서 사용하기에 적절한지 궁금합니다. +- Event Emitter는 상태관리 라이브러리는 아닌 것 같은데, hooks 내부에서 이렇게 사용해도 괜찮은가요? +- Context API나 Zustand 같은 상태관리 라이브러리를 사용하는 것이 더 나은 선택일까요? +- Event Emitter 패턴의 적절한 사용 범위는 어디까지인지, 그리고 언제 상태관리 라이브러리로 전환해야 하는지 기준이 궁금합니다. + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..8dfc763d6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,67 @@ +name: Deploy to GitHub Pages + +# 환경 변수 정의 +env: + NODE_VERSION: "20" + PNPM_VERSION: "10.22.0" + BUILD_TYPE: "basic" + DIST_PATH: "dist" + +on: + push: # push trigger + branches: + - main + - release-* # release 브랜치도 배포 + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build App + run: pnpm run build:${{ env.BUILD_TYPE }} + + - name: Create SPA fallback + run: | + # index.basic.html을 index.html로 복사 (GitHub Pages용) + cp ${{ env.DIST_PATH }}/index.${{ env.BUILD_TYPE }}.html ${{ env.DIST_PATH }}/index.html + # 404 fallback 생성 + cp ${{ env.DIST_PATH }}/index.html ${{ env.DIST_PATH }}/404.html + + - name: Setup Pages + uses: actions/configure-pages@v4 + with: + enablement: true + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ${{ env.DIST_PATH }} + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/docs/component-folder-structure.md b/docs/component-folder-structure.md new file mode 100644 index 000000000..1a4ef7180 --- /dev/null +++ b/docs/component-folder-structure.md @@ -0,0 +1,418 @@ +# React 프로젝트 폴더 구조 가이드 + +> 복습용 - 프론트엔드 폴더 구조 핵심 정리 + +## 📚 목차 + +1. [폴더 네이밍 규칙](#폴더-네이밍-규칙) +2. [컴포넌트 폴더 패턴](#컴포넌트-폴더-패턴) +3. [models vs utils vs services](#models-vs-utils-vs-services) +4. [실무 구조 예시](#실무-구조-예시) +5. [의사결정 가이드](#의사결정-가이드) + +--- + +## 폴더 네이밍 규칙 + +### 파일 타입별 규칙 + +| 타입 | 네이밍 | 예시 | +| -------------- | ---------------------- | -------------------------------- | +| React 컴포넌트 | PascalCase | `Header.tsx`, `ProductCard.tsx` | +| Custom Hook | camelCase (use 접두사) | `useCart.ts`, `useSearch.ts` | +| 유틸리티 | camelCase | `formatters.ts`, `validators.ts` | +| 폴더 | kebab-case (소문자) | `cart/`, `icons/`, `hooks/` | + +### 폴더 네이밍 원칙 + +```typescript +// ✅ 추천: 소문자 (kebab-case) +components/ +├── cart/ # 소문자 - 단순 그룹 +├── icons/ # 소문자 - 단순 그룹 +└── layout/ + └── Header/ # 대문자 - 복잡한 모듈 (예외) + +이유: +- 대소문자 구분 없는 파일시스템 호환 +- URL 친화적 +- 업계 표준 +``` + +--- + +## 컴포넌트 폴더 패턴 + +### 패턴 1: 단순 그룹 (index.tsx 없음) + +**언제:** 독립적인 컴포넌트들의 그룹 + +```typescript +components/cart/ +├── ProductCard.tsx +├── CartItem.tsx +└── OrderSummary.tsx + +// import +import ProductCard from '@/components/cart/ProductCard'; +import CartItem from '@/components/cart/CartItem'; +``` + +**특징:** + +- ✅ 각 컴포넌트가 동등한 위치 +- ✅ 명확한 import 경로 +- ✅ 도메인별 분류 + +--- + +### 패턴 2: 복잡한 모듈 (index.tsx 사용) + +**언제:** 메인 컴포넌트 + 서브 컴포넌트 구조 + +```typescript +components/layout/Header/ +├── index.tsx # 메인 컴포넌트 +├── SearchBar.tsx # 서브 (내부에서만 사용) +└── CartBadge.tsx # 서브 (내부에서만 사용) + +// index.tsx +import SearchBar from './SearchBar'; +import CartBadge from './CartBadge'; + +const Header = ({ ... }) => { + return ( +
+ + +
+ ); +}; + +export default Header; + +// import +import Header from '@/components/layout/Header'; // ✅ +``` + +**특징:** + +- ✅ 캡슐화 (내부 구현 숨김) +- ✅ 서브 컴포넌트는 외부에서 직접 import 안 함 +- ✅ 하나의 모듈처럼 동작 + +--- + +### index.tsx 사용 판단 기준 + +```typescript +// ✅ index.tsx 사용 +Header/ +├── index.tsx // 메인 +├── SearchBar.tsx // 서브 (Header 내부에서만) +└── CartBadge.tsx // 서브 (Header 내부에서만) + +// ❌ index.tsx 불필요 +cart/ +├── ProductCard.tsx // 독립 컴포넌트 +├── CartItem.tsx // 독립 컴포넌트 +└── OrderSummary.tsx // 독립 컴포넌트 +``` + +--- + +## models vs utils vs services + +### models/ - 도메인 비즈니스 로직 + +**특징:** 특정 도메인, 순수 함수, UI 무관 + +```typescript +// models/cart.ts +import { CartItem, Product } from "@/types"; + +/** + * 장바구니 아이템 총액 계산 + */ +export const calculateItemTotal = ( + cart: CartItem[], + item: CartItem +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(cart, item); + + return Math.round(price * quantity * (1 - discount)); +}; + +/** + * 장바구니에 상품 추가 + */ +export const addItemToCart = ( + cart: CartItem[], + product: Product +): CartItem[] => { + const existing = cart.find((item) => item.product.id === product.id); + + if (existing) { + return cart.map((item) => + item.product.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + ); + } + + return [...cart, { product, quantity: 1 }]; +}; +``` + +--- + +### utils/ - 범용 유틸리티 + +**특징:** 도메인 독립적, 어디서든 사용 가능 + +```typescript +// utils/formatters.ts + +export const formatCurrency = (amount: number): string => { + return `₩${amount.toLocaleString()}`; +}; + +export const formatDate = (date: Date): string => { + return date.toISOString().split("T")[0]; +}; + +export const formatPercentage = (rate: number): string => { + return `${Math.round(rate * 100)}%`; +}; + +// utils/validators.ts + +export const isValidEmail = (email: string): boolean => { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +}; +``` + +--- + +### services/ - API 통신 + +**특징:** 외부 서비스 연동, 비동기 + +```typescript +// services/api/cartService.ts + +export const getCart = async (): Promise => { + const response = await apiClient.get("/cart"); + return response.data; +}; + +export const addToCart = async ( + productId: string, + quantity: number +): Promise => { + const response = await apiClient.post("/cart/items", { + productId, + quantity, + }); + return response.data; +}; +``` + +--- + +### 비교표 + +| 구분 | models/ | utils/ | services/ | +| --------------- | -------------------- | ---------------- | -------------- | +| **목적** | 도메인 로직 | 범용 유틸 | API 통신 | +| **도메인 의존** | ✅ 특정 도메인 | ❌ 독립적 | ✅ 특정 도메인 | +| **순수 함수** | ✅ 필수 | ✅ 필수 | ❌ 비동기 | +| **예시** | `calculateCartTotal` | `formatCurrency` | `getCart` | + +--- + +## 실무 구조 예시 + +### 중규모 프로젝트 (추천) + +```typescript +src/ +├── components/ +│ ├── layout/ +│ │ ├── Header/ # 복잡한 모듈 (대문자 + index.tsx) +│ │ │ ├── index.tsx +│ │ │ ├── SearchBar.tsx +│ │ │ └── CartBadge.tsx +│ │ └── NotificationList/ +│ │ └── index.tsx +│ │ +│ ├── cart/ # 단순 그룹 (소문자) +│ │ ├── ProductCard.tsx +│ │ ├── CartItem.tsx +│ │ └── OrderSummary.tsx +│ │ +│ ├── common/ # 공통 UI +│ │ ├── Button.tsx +│ │ └── Input.tsx +│ │ +│ └── icons/ +│ ├── CartIcon.tsx +│ └── CloseIcon.tsx +│ +├── pages/ +│ ├── CartPage.tsx +│ └── AdminPage.tsx +│ +├── hooks/ +│ ├── useCart.ts +│ └── useSearch.ts +│ +├── models/ +│ ├── cart.ts +│ └── product.ts +│ +├── services/ +│ └── api/ +│ └── cartService.ts +│ +├── utils/ +│ ├── formatters.ts +│ └── validators.ts +│ +└── types/ + └── index.ts +``` + +--- + +### Feature-based (대규모) + +```typescript +src/ +├── features/ +│ ├── cart/ +│ │ ├── components/ +│ │ ├── hooks/ +│ │ ├── models/ +│ │ └── pages/ +│ │ +│ └── product/ +│ ├── components/ +│ └── hooks/ +│ +└── shared/ + ├── components/ + ├── hooks/ + └── utils/ +``` + +--- + +## 의사결정 가이드 + +### Q1: 새 컴포넌트를 어디에? + +```typescript +// 1. 특정 도메인에 속하는가? +→ Yes: components/[domain]/ + +// 2. 레이아웃 컴포넌트인가? +→ Yes: components/layout/ + +// 3. 공통 UI인가? +→ Yes: components/common/ + +// 4. 아이콘인가? +→ Yes: components/icons/ +``` + +--- + +### Q2: index.tsx를 만들어야 할까? + +```typescript +// 1. 메인 + 서브 컴포넌트 구조인가? +→ Yes: index.tsx 사용 + +// 2. 독립적인 컴포넌트들인가? +→ Yes: index.tsx 불필요 + +예시: +✅ Header/ (메인 + 서브) → index.tsx 사용 +❌ cart/ (독립 컴포넌트들) → index.tsx 불필요 +``` + +--- + +### Q3: models vs utils? + +```typescript +// 특정 도메인에 종속되는가? + +✅ models/ (도메인 종속) +- calculateCartTotal(cart, coupon) +- isProductInStock(product) + +✅ utils/ (도메인 독립) +- formatCurrency(1000) +- isValidEmail(email) +``` + +--- + +## 핵심 원칙 + +### 1. 목적에 맞게 선택 + +```typescript +// ❌ 잘못된 생각 +"모든 폴더를 소문자로 통일"; + +// ✅ 올바른 생각 +"복잡한 모듈은 대문자 + index.tsx"; +"단순 그룹은 소문자"; +``` + +### 2. 확장 가능성 고려 + +```typescript +// 처음: 간단하게 +components/ProductCard.tsx + +// 복잡해지면: 폴더로 +components/ProductCard/ +├── index.tsx +├── ProductImage.tsx +└── ProductInfo.tsx +``` + +### 3. 일관성보다 이유가 중요 + +- Header: 복잡한 모듈 → 대문자 + index.tsx +- cart: 단순 그룹 → 소문자 + +--- + +## 체크리스트 + +### 새 컴포넌트 생성 시 + +- [ ] 도메인 파악 +- [ ] 재사용 범위 확인 +- [ ] 복잡도 평가 +- [ ] 적절한 위치 선택 +- [ ] index.tsx 필요 여부 판단 + +--- + +## 요약 + +| 구분 | 폴더명 | index.tsx | 예시 | +| --------------- | ---------- | --------- | ------------------------------ | +| **복잡한 모듈** | PascalCase | ✅ | `Header/`, `NotificationList/` | +| **단순 그룹** | kebab-case | ❌ | `cart/`, `icons/` | +| **기타** | kebab-case | ❌ | `hooks/`, `utils/`, `models/` | + +**핵심: 구조의 목적에 맞게 선택!** 🚀 diff --git a/docs/event-emitter-vs-observer.md b/docs/event-emitter-vs-observer.md new file mode 100644 index 000000000..3cf71ad07 --- /dev/null +++ b/docs/event-emitter-vs-observer.md @@ -0,0 +1,376 @@ +# Event Emitter vs Observer 패턴 + +> 주니어 개발자를 위한 디자인 패턴 비교 가이드 + +## 📚 목차 + +1. [핵심 개념](#핵심-개념) +2. [Event Emitter 패턴](#event-emitter-패턴) +3. [Observer 패턴](#observer-패턴) +4. [비교표](#비교표) +5. [실전 예시](#실전-예시) +6. [언제 무엇을 사용할까](#언제-무엇을-사용할까) + +--- + +## 핵심 개념 + +### 공통점 + +둘 다 **"변화를 알려주는"** 패턴입니다. + +``` +상태 변경 → 관심있는 곳에 알림 → 자동 업데이트 +``` + +### 차이점 + +| 구분 | Event Emitter | Observer | +| ---------- | ------------------- | ----------- | +| **별칭** | Pub/Sub 패턴 | 관찰자 패턴 | +| **중재자** | ✅ 있음 (Event Bus) | ❌ 없음 | +| **결합도** | 느슨함 | 강함 | + +--- + +## Event Emitter 패턴 + +### 개념 + +**중재자(Event Bus)를 통해 간접적으로 통신** + +``` +발행자 → Event Bus → 구독자 + ↓ ↓ ↓ +모름 중재자 모름 +``` + +### 코드 예시 + +```typescript +// 1. 이벤트 발행 (Publish) +const addToCart = (product) => { + // 상태 변경 + cart.push(product); + + // 이벤트 발행 - 누가 듣는지 모름! + window.dispatchEvent(new Event("cart-updated")); +}; + +// 2. 이벤트 구독 (Subscribe) +useEffect(() => { + const handleUpdate = () => { + console.log("장바구니가 업데이트됨!"); + }; + + // 구독 시작 + window.addEventListener("cart-updated", handleUpdate); + + // 구독 해제 + return () => { + window.removeEventListener("cart-updated", handleUpdate); + }; +}, []); +``` + +### 장점 + +✅ **느슨한 결합** - 발행자와 구독자가 서로 모름 +✅ **확장성** - 새 구독자 추가 쉬움 +✅ **독립성** - 컴포넌트 간 의존성 없음 + +### 단점 + +❌ **디버깅 어려움** - 누가 발행했는지 추적 어려움 +❌ **이벤트 이름 관리** - 오타 위험 +❌ **메모리 누수** - 구독 해제 잊으면 문제 + +--- + +## Observer 패턴 + +### 개념 + +**직접 연결하여 통신** + +``` +Subject (주체) → Observer (관찰자) + ↓ ↓ + 알고 있음 알고 있음 +``` + +### 코드 예시 + +```typescript +// 1. Subject (관찰 대상) +class Cart { + private observers: Observer[] = []; + private items: Product[] = []; + + // 옵저버 등록 + subscribe(observer: Observer) { + this.observers.push(observer); + } + + // 옵저버 제거 + unsubscribe(observer: Observer) { + this.observers = this.observers.filter((obs) => obs !== observer); + } + + // 모든 옵저버에게 알림 + notify() { + this.observers.forEach((observer) => observer.update(this.items)); + } + + // 상태 변경 + addItem(product: Product) { + this.items.push(product); + this.notify(); // 직접 호출! + } +} + +// 2. Observer (관찰자) +class CartDisplay { + update(items: Product[]) { + console.log("장바구니 업데이트:", items); + } +} + +// 3. 사용 +const cart = new Cart(); +const display = new CartDisplay(); + +cart.subscribe(display); // 직접 등록 +cart.addItem(product); // 자동으로 display.update() 호출됨 +``` + +### 장점 + +✅ **명확한 관계** - 누가 누구를 관찰하는지 명확 +✅ **타입 안전** - TypeScript에서 타입 체크 가능 +✅ **디버깅 쉬움** - 호출 흐름 추적 쉬움 + +### 단점 + +❌ **강한 결합** - Subject와 Observer가 서로 알아야 함 +❌ **확장성** - 새 옵저버 추가 시 Subject 수정 필요 +❌ **순환 참조** - 메모리 누수 위험 + +--- + +## 비교표 + +### 구조 비교 + +```typescript +// Event Emitter (Pub/Sub) +발행자 --이벤트--> Event Bus --이벤트--> 구독자 + ↓ ↓ ↓ +모름 중재자 모름 + +// Observer +Subject --직접 호출--> Observer + ↓ ↓ +알고 있음 알고 있음 +``` + +### 특징 비교 + +| 특징 | Event Emitter | Observer | +| ------------- | ------------------- | ------------ | +| **결합도** | 느슨함 (Loose) | 강함 (Tight) | +| **중재자** | Event Bus | 없음 | +| **확장성** | 높음 | 낮음 | +| **디버깅** | 어려움 | 쉬움 | +| **타입 안전** | 어려움 | 쉬움 | +| **사용 예** | Redux, EventEmitter | RxJS, MobX | + +--- + +## 실전 예시 + +### Event Emitter - 장바구니 동기화 + +```typescript +// hooks/useCart.ts +export const useCart = () => { + const [cart, setCart] = useState([]); + + // 구독 + useEffect(() => { + const handleUpdate = () => { + const saved = localStorage.getItem("cart"); + setCart(JSON.parse(saved)); + }; + + window.addEventListener("cart-updated", handleUpdate); + return () => window.removeEventListener("cart-updated", handleUpdate); + }, []); + + // 발행 + const addToCart = (product) => { + const updated = [...cart, product]; + localStorage.setItem("cart", JSON.stringify(updated)); + window.dispatchEvent(new Event("cart-updated")); // 발행! + setCart(updated); + }; + + return { cart, addToCart }; +}; + +// 여러 컴포넌트에서 사용 +const ProductList = () => { + const { addToCart } = useCart(); // 구독자 1 + // ... +}; + +const CartArea = () => { + const { cart } = useCart(); // 구독자 2 + // ... +}; +``` + +### Observer - 테마 변경 + +```typescript +// Subject +class ThemeManager { + private observers: ThemeObserver[] = []; + private theme: "light" | "dark" = "light"; + + subscribe(observer: ThemeObserver) { + this.observers.push(observer); + } + + setTheme(theme: "light" | "dark") { + this.theme = theme; + this.observers.forEach((obs) => obs.onThemeChange(theme)); + } +} + +// Observer +class Header implements ThemeObserver { + onThemeChange(theme: string) { + this.element.className = `header-${theme}`; + } +} + +class Sidebar implements ThemeObserver { + onThemeChange(theme: string) { + this.element.className = `sidebar-${theme}`; + } +} + +// 사용 +const themeManager = new ThemeManager(); +themeManager.subscribe(new Header()); +themeManager.subscribe(new Sidebar()); +themeManager.setTheme("dark"); // 모든 옵저버에게 알림 +``` + +--- + +## 언제 무엇을 사용할까 + +### Event Emitter 사용 + +```typescript +✅ 사용하는 경우: +- 컴포넌트 간 느슨한 결합 필요 +- 발행자와 구독자가 서로 모르는 게 좋음 +- 동적으로 구독자 추가/제거 +- 전역 이벤트 (로그인, 알림 등) + +예시: +- 장바구니 동기화 +- 전역 알림 시스템 +- 로그인/로그아웃 이벤트 +- 실시간 데이터 업데이트 +``` + +### Observer 사용 + +```typescript +✅ 사용하는 경우: +- 명확한 관계 필요 +- 타입 안전성 중요 +- 디버깅 용이성 필요 +- 1:N 관계가 명확함 + +예시: +- 폼 검증 (Form → Validators) +- 테마 변경 (ThemeManager → Components) +- 데이터 바인딩 (Model → View) +- 상태 관리 (Store → Components) +``` + +--- + +## 실무 라이브러리 + +### Event Emitter 계열 + +```typescript +// 1. Redux +dispatch({ type: "ADD_TO_CART" }); // 발행 +useSelector((state) => state.cart); // 구독 + +// 2. Node.js EventEmitter +eventEmitter.emit("data", payload); +eventEmitter.on("data", handler); + +// 3. 현재 프로젝트 +window.dispatchEvent(new Event("cart-updated")); +window.addEventListener("cart-updated", handler); +``` + +### Observer 계열 + +```typescript +// 1. RxJS +subject.next(value); // 발행 +subject.subscribe(handler); // 구독 + +// 2. MobX +@observable cart = []; // Subject +@observer CartComponent // Observer +``` + +--- + +## 핵심 정리 + +### Event Emitter (Pub/Sub) + +``` +특징: 느슨한 결합, Event Bus 사용 +장점: 확장성, 독립성 +단점: 디버깅 어려움 +사용: 전역 이벤트, 컴포넌트 간 통신 +``` + +### Observer + +``` +특징: 직접 연결, 명확한 관계 +장점: 타입 안전, 디버깅 쉬움 +단점: 강한 결합, 확장성 낮음 +사용: 1:N 관계, 데이터 바인딩 +``` + +--- + +## 체크리스트 + +**어떤 패턴을 선택할까?** + +- [ ] 컴포넌트가 서로 모르는 게 좋은가? → Event Emitter +- [ ] 명확한 관계가 필요한가? → Observer +- [ ] 동적으로 구독자가 추가되는가? → Event Emitter +- [ ] 타입 안전성이 중요한가? → Observer +- [ ] 디버깅이 중요한가? → Observer +- [ ] 전역 이벤트인가? → Event Emitter + +--- + +**핵심: 상황에 맞는 패턴을 선택하자!** 🚀 diff --git a/docs/model-vs-hook.md b/docs/model-vs-hook.md new file mode 100644 index 000000000..cf3a5a53b --- /dev/null +++ b/docs/model-vs-hook.md @@ -0,0 +1,435 @@ +# Model vs Hook 완벽 가이드 + +> **핵심 질문**: `models/cart.ts`와 `hooks/useCart.ts`의 차이가 뭘까? + +--- + +## 🎯 핵심 개념 + +### Model = 순수 비즈니스 로직 + +### Hook = React 상태 관리 + 부수 효과 + +--- + +## 📊 역할 분리 다이어그램 + +``` +┌─────────────────────────────────────────┐ +│ Component (UI Layer) │ +│ - useCart() 호출 │ +│ - 렌더링만 담당 │ +│ - 사용자 인터랙션 처리 │ +└──────────────┬──────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────┐ +│ Hook (State + Side Effects) │ +│ ✅ useState (React 상태) │ +│ ✅ useEffect (동기화) │ +│ ✅ localStorage 읽기/쓰기 │ +│ ✅ alert, 이벤트 발생 │ +│ ✅ Model 함수 호출 ← 핵심! │ +└──────────────┬──────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────┐ +│ Model (Pure Business Logic) │ +│ ✅ 순수 함수만 │ +│ ✅ React 무관 │ +│ ✅ 부수 효과 없음 │ +│ ✅ 같은 입력 → 같은 출력 │ +│ ✅ 테스트 용이 │ +└─────────────────────────────────────────┘ +``` + +--- + +## 📦 Model (models/cart.ts) + +### 역할 + +- **순수 비즈니스 로직**만 담당 +- React와 완전히 독립적 +- 어디서든 재사용 가능 (React, Vue, Node.js 등) + +### 특징 + +```typescript +// ✅ 순수 함수 예시 +export const addItemToCart = ( + cart: CartItem[], + product: Product +): CartItem[] => { + // 1. 모든 데이터를 파라미터로 받음 + // 2. 외부 상태에 의존하지 않음 + // 3. 부수 효과 없음 (localStorage, API 호출 등 X) + // 4. 새로운 값을 반환 (원본 변경 X) + + const existingItem = cart.find((item) => item.product.id === product.id); + + if (existingItem) { + return cart.map((item) => + item.product.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + ); + } + + return [...cart, { product, quantity: 1 }]; +}; +``` + +### 포함되는 함수들 + +```typescript +// 계산 함수 +calculateItemTotal(cart, item); +calculateCartTotal(cart, coupon); +calculateDiscountAmount(totalBefore, totalAfter); + +// 상태 판단 함수 +getCartItemDiscount(cart, item); +getRemainingStock(cart, product); + +// 데이터 변환 함수 +addItemToCart(cart, product); +removeItemFromCart(cart, productId); +updateCartItemQuantity(cart, productId, quantity); +``` + +### ✅ Model에 들어가야 하는 것 + +- ✅ 계산 로직 +- ✅ 데이터 변환 +- ✅ 비즈니스 규칙 +- ✅ 유효성 검증 로직 + +### ❌ Model에 들어가면 안 되는 것 + +- ❌ localStorage 접근 +- ❌ API 호출 +- ❌ useState, useEffect +- ❌ alert, console.log +- ❌ 이벤트 발생 +- ❌ DOM 조작 + +--- + +## 🎣 Hook (hooks/useCart.ts) + +### 역할 + +- **React 상태 관리** +- **부수 효과 처리** (localStorage, API 등) +- **Model 함수 활용** + +### 특징 + +```typescript +// ✅ Hook 예시 +export const useCart = () => { + // 1. React 상태 관리 + const [cart, setCart] = useState(() => { + // 부수 효과: localStorage 읽기 + const saved = localStorage.getItem("cart"); + return saved ? JSON.parse(saved) : []; + }); + + // 2. 부수 효과: localStorage 동기화 + useEffect(() => { + localStorage.setItem("cart", JSON.stringify(cart)); + window.dispatchEvent(new Event("cart-updated")); + }, [cart]); + + // 3. Model 함수 활용 + const addToCart = (product: ProductWithUI) => { + setCart((prev) => { + // Model의 순수 함수 호출 + const newCart = addItemToCart(prev, product); + + // Hook에서만 가능한 UI 피드백 + if (newCart === prev) { + alert(`재고는 ${product.stock}개까지만 있습니다.`); + } + + return newCart; + }); + }; + + return { cart, addToCart, removeFromCart, updateQuantity }; +}; +``` + +### ✅ Hook에 들어가야 하는 것 + +- ✅ useState, useEffect +- ✅ localStorage 읽기/쓰기 +- ✅ API 호출 +- ✅ 이벤트 발생/구독 +- ✅ alert, toast 등 UI 피드백 +- ✅ Model 함수 호출 + +### ❌ Hook에 들어가면 안 되는 것 + +- ❌ 복잡한 계산 로직 (→ Model로) +- ❌ 비즈니스 규칙 (→ Model로) +- ❌ 데이터 변환 로직 (→ Model로) + +--- + +## 🔍 실전 예시 + +### ❌ 잘못된 예시 (현재 많은 코드가 이렇게 되어 있음) + +```typescript +// ❌ Hook에 비즈니스 로직이 너무 많음 +const addToCart = (product: ProductWithUI) => { + setCart((prev) => { + // 비즈니스 로직이 Hook 안에! + const existingItem = prev.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + alert(`재고는 ${product.stock}개까지만 있습니다.`); + return prev; + } + + const updated = prev.map((item) => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ); + + localStorage.setItem("cart", JSON.stringify(updated)); + return updated; + } + + const updated = [...prev, { product, quantity: 1 }]; + localStorage.setItem("cart", JSON.stringify(updated)); + return updated; + }); +}; +``` + +**문제점:** + +- 비즈니스 로직이 Hook에 섞여 있음 +- Model 함수를 활용하지 않음 +- 테스트하기 어려움 +- 재사용 불가능 + +--- + +### ✅ 올바른 예시 + +```typescript +// ✅ Model: 순수 비즈니스 로직 +// models/cart.ts +export const addItemToCart = ( + cart: CartItem[], + product: Product +): CartItem[] => { + const existingItem = cart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + // 재고 초과 시 원본 반환 + if (newQuantity > product.stock) { + return cart; + } + + return cart.map((item) => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item + ); + } + + return [...cart, { product, quantity: 1 }]; +}; + +// ✅ Hook: 상태 관리 + Model 활용 +// hooks/useCart.ts +export const useCart = () => { + const [cart, setCart] = useState([]); + + // localStorage 동기화 + useEffect(() => { + localStorage.setItem("cart", JSON.stringify(cart)); + }, [cart]); + + const addToCart = (product: ProductWithUI) => { + setCart((prev) => { + // Model 함수 사용! + const newCart = addItemToCart(prev, product); + + // UI 피드백 (Hook의 책임) + if (newCart === prev) { + alert(`재고는 ${product.stock}개까지만 있습니다.`); + } + + return newCart; + }); + }; + + return { cart, addToCart }; +}; +``` + +**장점:** + +- 비즈니스 로직은 Model에서 재사용 가능 +- Hook은 React 관련 작업만 처리 +- 테스트하기 쉬움 +- 관심사 분리 명확 + +--- + +## 📋 비교 표 + +| 구분 | Model | Hook | +| ---------------- | ------------------------------ | --------------------------------------- | +| **파일 위치** | `models/cart.ts` | `hooks/useCart.ts` | +| **역할** | 비즈니스 로직 | 상태 관리 + 부수 효과 | +| **순수성** | 순수 함수 | 부수 효과 있음 | +| **React 의존** | ❌ 무관 | ✅ React 전용 | +| **useState** | ❌ 사용 불가 | ✅ 사용 | +| **useEffect** | ❌ 사용 불가 | ✅ 사용 | +| **localStorage** | ❌ 접근 불가 | ✅ 접근 가능 | +| **API 호출** | ❌ 불가 | ✅ 가능 | +| **alert/toast** | ❌ 불가 | ✅ 가능 | +| **테스트** | 쉬움 (순수 함수) | 상대적으로 어려움 | +| **재사용** | 어디서든 가능 | React 컴포넌트만 | +| **예시** | `addItemToCart(cart, product)` | `const { cart, addToCart } = useCart()` | + +--- + +## 💡 LocalStorage는 어디에? + +### ❌ Model에 넣으면 안 되는 이유 + +```typescript +// ❌ 이렇게 하면 안 됨! +export const addItemToCart = (cart, product) => { + const newCart = [...cart, { product, quantity: 1 }]; + + // 부수 효과! 순수 함수가 아님! + localStorage.setItem("cart", JSON.stringify(newCart)); + + return newCart; +}; +``` + +**문제점:** + +1. **순수 함수가 아님** - 부수 효과 발생 +2. **테스트 어려움** - localStorage mock 필요 +3. **서버 사이드 불가** - Node.js에는 localStorage 없음 +4. **재사용 불가** - localStorage 없는 환경에서 사용 불가 + +--- + +### ✅ Hook에 넣어야 하는 이유 + +```typescript +// ✅ Hook에서 처리 +export const useCart = () => { + const [cart, setCart] = useState([]); + + // localStorage는 Hook의 책임 + useEffect(() => { + localStorage.setItem("cart", JSON.stringify(cart)); + }, [cart]); + + const addToCart = (product) => { + // Model 함수 사용 + setCart((prev) => addItemToCart(prev, product)); + }; + + return { cart, addToCart }; +}; +``` + +**장점:** + +1. **Model은 순수 함수 유지** - 어디서든 재사용 가능 +2. **Hook이 부수 효과 처리** - React 환경에서만 실행 +3. **관심사 분리** - 각자의 책임만 수행 +4. **테스트 용이** - Model은 순수 함수로 쉽게 테스트 + +--- + +## 🎓 핵심 원칙 + +### 1. Model은 순수하게 + +```typescript +// ✅ 순수 함수 +export const calculateTotal = (items: Item[]): number => { + return items.reduce((sum, item) => sum + item.price, 0); +}; +``` + +### 2. Hook은 Model을 활용 + +```typescript +// ✅ Hook에서 Model 함수 사용 +const useShoppingCart = () => { + const [items, setItems] = useState([]); + + const total = calculateTotal(items); // Model 함수 활용 + + return { items, total }; +}; +``` + +### 3. 부수 효과는 Hook에서만 + +```typescript +// ✅ Hook에서만 부수 효과 처리 +useEffect(() => { + localStorage.setItem("items", JSON.stringify(items)); + analytics.track("cart_updated", { itemCount: items.length }); +}, [items]); +``` + +--- + +## 🚀 실전 체크리스트 + +### Model 함수를 만들 때 + +- [ ] 모든 데이터를 파라미터로 받는가? +- [ ] 외부 상태에 의존하지 않는가? +- [ ] 부수 효과가 없는가? +- [ ] 같은 입력에 항상 같은 출력을 반환하는가? +- [ ] React 없이도 사용 가능한가? + +### Hook을 만들 때 + +- [ ] Model 함수를 최대한 활용하는가? +- [ ] 비즈니스 로직을 Hook에 넣지 않았는가? +- [ ] useState/useEffect를 적절히 사용하는가? +- [ ] 부수 효과를 명확히 관리하는가? + +--- + +## 📝 요약 + +``` +┌─────────────────────────────────────────┐ +│ "비즈니스 로직은 Model로, │ +│ 상태 관리는 Hook으로, │ +│ 렌더링은 Component로" │ +└─────────────────────────────────────────┘ +``` + +**기억하기:** + +- **Model** = 순수 함수, 어디서든 재사용 +- **Hook** = React 상태 + 부수 효과 + Model 활용 +- **Component** = Hook 사용 + UI 렌더링 + +🎯 **핵심**: Model은 순수하게, Hook은 Model을 활용하게! diff --git a/docs/props-vs-hooks.md b/docs/props-vs-hooks.md new file mode 100644 index 000000000..1dab12252 --- /dev/null +++ b/docs/props-vs-hooks.md @@ -0,0 +1,227 @@ +# Props vs Hooks 사용 가이드 + +> 언제 Props를 전달하고, 언제 Hook을 직접 사용할까? + +## 🎯 핵심 원칙 + +### Hook 직접 사용 ✅ + +**큰 Feature 컴포넌트 = Hook 직접 사용** + +```typescript +// ✅ Hook 직접 사용 +const ProductList = () => { + const { products } = useProducts(); // 직접 사용! + const { addToCart } = useCart(); + + return
{/* ... */}
; +}; + +const CartSummary = () => { + const { cart } = useCart(); // 직접 사용! + const { coupons } = useCoupons(); + + return
{/* ... */}
; +}; +``` + +--- + +### Props 전달 ✅ + +**작은 UI 컴포넌트 = Props 전달** + +```typescript +// ✅ Props 전달 +const ProductCard = ({ product, onAddToCart }) => { + return ( +
+

{product.name}

+ +
+ ); +}; + +const CartItem = ({ item, onRemove, onUpdate }) => { + return ( +
+ {item.name} + +
+ ); +}; +``` + +--- + +## 📋 판단 기준 + +### 1. 컴포넌트 크기 + +```typescript +// 큰 컴포넌트 (100줄 이상) → Hook 사용 +const ProductList = () => { + const { products } = useProducts(); + // ... +}; + +// 작은 컴포넌트 (50줄 이하) → Props 전달 +const ProductCard = ({ product }) => { + // ... +}; +``` + +--- + +### 2. 역할 + +```typescript +// Feature 컴포넌트 (기능 단위) → Hook 사용 +const CartSummary = () => { + const { cart } = useCart(); + const { coupons } = useCoupons(); + // 여러 기능 조합 +}; + +// Presentational 컴포넌트 (UI만) → Props 전달 +const Button = ({ onClick, children }) => { + return ; +}; +``` + +--- + +### 3. 재사용성 + +```typescript +// 독립적인 Feature → Hook 사용 +const ProductList = () => { + const { products } = useProducts(); + // 다른 페이지에서도 독립적으로 사용 +}; + +// 재사용되는 UI → Props 전달 +const Card = ({ title, content }) => { + // 여러 곳에서 다른 데이터로 재사용 +}; +``` + +--- + +## 🎯 실전 예시 + +### CartPage 구조 + +```typescript +CartPage (레이아웃만) +│ +├── ProductList (Hook ✅) +│ └── ProductCard (Props ✅) +│ +└── CartSummary (Hook ✅) + ├── CartItems (Props ✅) + │ └── CartItem (Props ✅) + └── OrderSummary (Props ✅) +``` + +### 코드 + +```typescript +// CartPage.tsx - 레이아웃 +const CartPage = ({ searchTerm }) => { + return ( +
+ + +
+ ); +}; + +// ProductList.tsx - Hook 사용 +const ProductList = ({ searchTerm }) => { + const { products } = useProducts(); // Hook! + const { addToCart } = useCart(); // Hook! + + return ( +
+ {products.map((p) => ( + + ))} +
+ ); +}; + +// ProductCard.tsx - Props 전달 +const ProductCard = ({ product, onAdd }) => { + return ( +
+

{product.name}

+ +
+ ); +}; + +// CartSummary.tsx - Hook 사용 +const CartSummary = () => { + const { cart, removeFromCart } = useCart(); // Hook! + const { coupons, applyCoupon } = useCoupons(); // Hook! + + return ( +
+ + +
+ ); +}; + +// CartItems.tsx - Props 전달 +const CartItems = ({ cart, onRemove }) => { + return ( +
+ {cart.map((item) => ( + + ))} +
+ ); +}; +``` + +--- + +## ✅ 빠른 체크리스트 + +**Hook을 직접 사용할까?** + +- [ ] Feature 컴포넌트인가? (ProductList, CartSummary) +- [ ] 100줄 이상인가? +- [ ] 여러 Hook을 조합하는가? +- [ ] 독립적으로 동작하는가? + +→ **Yes가 2개 이상이면 Hook 사용** + +**Props를 전달할까?** + +- [ ] UI만 담당하는가? (ProductCard, CartItem) +- [ ] 50줄 이하인가? +- [ ] 재사용되는가? +- [ ] 순수 컴포넌트인가? (같은 props → 같은 결과) + +→ **Yes가 2개 이상이면 Props 전달** + +--- + +## 🎓 핵심 정리 + +| 구분 | Hook 사용 | Props 전달 | +| ---------- | ------------------------ | --------------------- | +| **크기** | 큰 컴포넌트 (100줄+) | 작은 컴포넌트 (50줄-) | +| **역할** | Feature (기능) | Presentational (UI) | +| **재사용** | 독립적 | 재사용 가능 | +| **예시** | ProductList, CartSummary | ProductCard, CartItem | + +**기억하기:** + +- 큰 Feature = Hook +- 작은 UI = Props + +🚀 diff --git a/docs/react-localstorage-sync.md b/docs/react-localstorage-sync.md new file mode 100644 index 000000000..d19bef0d8 --- /dev/null +++ b/docs/react-localstorage-sync.md @@ -0,0 +1,285 @@ +# React에서 localStorage 변경을 다른 컴포넌트에 알리는 방법 + +> localStorage 저장만으로는 React 컴포넌트가 동기화되지 않는 이유와 해결 방법 + +## 🚨 문제 상황 + +### localStorage에 저장했는데 왜 다른 컴포넌트는 업데이트 안 될까? + +```typescript +// ComponentA - 장바구니에 상품 추가 +const addToCart = (product) => { + setCart((prev) => { + const updated = [...prev, product]; + localStorage.setItem("cart", JSON.stringify(updated)); // 저장! + return updated; + }); +}; + +// ComponentB - 장바구니 표시 +const { cart } = useCart(); +console.log(cart); // [] ← 여전히 비어있음! 왜? +``` + +--- + +## 💡 핵심 원인 + +### React는 localStorage를 감시하지 않습니다! + +``` +localStorage 변경 → React는 모름 → 리렌더링 안 됨 +``` + +**이유:** + +- `useState`는 **React 상태만** 추적합니다 +- localStorage는 **React 외부** 저장소입니다 +- localStorage가 변경되어도 **React는 감지하지 못합니다** + +--- + +## 🔄 동작 원리 비교 + +### ❌ 기존 방식 (동기화 안 됨) + +```typescript +// useCart.ts +export const useCart = () => { + const [cart, setCart] = useState(() => { + const saved = localStorage.getItem("cart"); + return saved ? JSON.parse(saved) : []; + }); + + const addToCart = (product) => { + setCart((prev) => { + const updated = [...prev, product]; + localStorage.setItem("cart", JSON.stringify(updated)); + return updated; // 현재 컴포넌트만 업데이트! + }); + }; + + return { cart, addToCart }; +}; +``` + +**문제:** + +``` +1. ComponentA에서 addToCart() 호출 + ↓ +2. ComponentA의 cart만 업데이트 + ↓ +3. localStorage에 저장 + ↓ +4. ComponentB는 변경을 모름 ❌ +``` + +--- + +### ✅ 해결 방법 (Event Emitter 패턴) + +```typescript +// useCart.ts +export const useCart = () => { + const [cart, setCart] = useState(() => { + const saved = localStorage.getItem("cart"); + return saved ? JSON.parse(saved) : []; + }); + + // 1. localStorage 변경 감지 + useEffect(() => { + const handleStorageChange = () => { + const saved = localStorage.getItem("cart"); + setCart(saved ? JSON.parse(saved) : []); + }; + + // 이벤트 구독 + window.addEventListener("cart-updated", handleStorageChange); + + return () => { + window.removeEventListener("cart-updated", handleStorageChange); + }; + }, []); + + // 2. 변경 시 이벤트 발행 + const addToCart = (product) => { + setCart((prev) => { + const updated = [...prev, product]; + localStorage.setItem("cart", JSON.stringify(updated)); + window.dispatchEvent(new Event("cart-updated")); // 📢 알림! + return updated; + }); + }; + + return { cart, addToCart }; +}; +``` + +**동작:** + +``` +1. ComponentA에서 addToCart() 호출 + ↓ +2. localStorage에 저장 + ↓ +3. "cart-updated" 이벤트 발행 📢 + ↓ +4. 모든 구독자(ComponentA, ComponentB)의 handleStorageChange 실행 + ↓ +5. 각 컴포넌트에서 localStorage 다시 읽기 + ↓ +6. setCart() 호출 → 모든 컴포넌트 리렌더링 ✅ +``` + +--- + +## 📊 패턴 비교 + +### Event Emitter (Pub/Sub) 패턴 + +``` +발행자 (Publisher) Event Bus 구독자 (Subscriber) + ↓ ↓ ↓ +addToCart() → dispatchEvent("cart-updated") → addEventListener() + ↓ + window (중재자) + ↓ + 모든 구독자에게 전달 +``` + +**특징:** + +- ✅ 느슨한 결합 (발행자와 구독자가 서로 모름) +- ✅ 확장성 (새 구독자 추가 쉬움) +- ✅ 상태관리 라이브러리 없이 동기화 가능 + +--- + +## 🎯 실전 예시 + +### 장바구니 동기화 + +```typescript +// ProductList 컴포넌트 +const ProductList = () => { + const { addToCart } = useCart(); + + return ; +}; + +// CartArea 컴포넌트 +const CartArea = () => { + const { cart } = useCart(); + + return ( +
+ {cart.map((item) => ( +
{item.name}
+ ))} +
+ ); +}; +``` + +**결과:** + +- ProductList에서 "장바구니 담기" 클릭 +- CartArea가 자동으로 업데이트됨! ✅ + +--- + +## 🔧 다른 해결 방법 + +### 1. Context API + +```typescript +const CartContext = createContext(); + +export const CartProvider = ({ children }) => { + const [cart, setCart] = useState([]); + + return ( + + {children} + + ); +}; +``` + +**장점:** React 공식 방법 +**단점:** Provider로 감싸야 함 + +--- + +### 2. 상태관리 라이브러리 (Zustand) + +```typescript +const useCartStore = create((set) => ({ + cart: [], + addToCart: (product) => + set((state) => ({ + cart: [...state.cart, product], + })), +})); +``` + +**장점:** 간단하고 강력 +**단점:** 외부 라이브러리 필요 + +--- + +## 📋 비교표 + +| 방법 | 장점 | 단점 | 추천 | +| ----------------- | ---------------------- | ------------- | ------ | +| **Event Emitter** | 외부 라이브러리 불필요 | 디버깅 어려움 | 소규모 | +| **Context API** | React 공식 | Provider 필요 | 중규모 | +| **Zustand/Redux** | 강력한 기능 | 학습 곡선 | 대규모 | + +--- + +## ✅ 핵심 정리 + +### localStorage는 "수동 저장소"입니다 + +```typescript +// ❌ 이렇게 동작하지 않습니다 +localStorage.setItem("cart", data); +// → 자동으로 모든 컴포넌트 업데이트 (안 됨!) + +// ✅ 이벤트로 알려줘야 합니다 +localStorage.setItem("cart", data); +window.dispatchEvent(new Event("cart-updated")); // 📢 +// → 구독자들이 감지하고 업데이트 +``` + +### React 상태 vs localStorage + +| 구분 | React State | localStorage | +| ------------- | ---------------- | ------------- | +| **반응형** | ✅ 자동 | ❌ 수동 | +| **감지** | ✅ React가 추적 | ❌ 추적 안 함 | +| **변경 알림** | ✅ 자동 리렌더링 | ❌ 알림 없음 | + +--- + +## 💡 결론 + +**localStorage에 저장했다고 React가 자동으로 아는 게 아닙니다!** + +해결 방법: + +1. **Event Emitter 패턴** - 이벤트로 수동 알림 +2. **Context API** - 상태 공유 +3. **상태관리 라이브러리** - 전역 상태 + +**핵심:** localStorage는 저장소일 뿐, React에게 변경을 알려줘야 합니다! 📢 + +--- + +## 🔗 참고 자료 + +- [MDN - Window: storage event](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) +- [React - State Management](https://react.dev/learn/managing-state) +- [Event Emitter Pattern](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) diff --git a/docs/useState-initialization-patterns.md b/docs/useState-initialization-patterns.md new file mode 100644 index 000000000..8a8ae37df --- /dev/null +++ b/docs/useState-initialization-patterns.md @@ -0,0 +1,379 @@ +# React useState 초기값 패턴 가이드 + +> 복습용 - useState 초기화 핵심 정리 + +## 📚 목차 + +1. [초기값 패턴 4가지](#초기값-패턴-4가지) +2. [Lazy Initialization](#lazy-initialization) +3. [localStorage 연동](#localstorage-연동) +4. [실전 예시](#실전-예시) +5. [안티패턴](#안티패턴) + +--- + +## 초기값 패턴 4가지 + +### 패턴 비교표 + +| 패턴 | 코드 | 실행 시점 | 성능 | 사용 | +| --------------- | ----------------------- | -------------------- | ------ | -------------- | +| **직접 값** | `useState(0)` | 매번 평가 (문제없음) | ⭐⭐⭐ | ✅ 간단한 값 | +| **함수 호출** | `useState(fn())` | 매 렌더링 | ❌ | ❌ 사용 금지 | +| **함수 전달** | `useState(fn)` | 첫 렌더링만 | ⭐⭐⭐ | ✅ 외부 함수 | +| **화살표 함수** | `useState(() => {...})` | 첫 렌더링만 | ⭐⭐⭐ | ✅ 복잡한 로직 | + +--- + +### 패턴 1: 직접 값 + +```typescript +const [count, setCount] = useState(0); +const [name, setName] = useState("홍길동"); +const [items, setItems] = useState([]); +``` + +**언제:** 간단한 값 (숫자, 문자열, boolean, 빈 배열/객체) + +--- + +### 패턴 2: 함수 호출 결과 ❌ + +```typescript +// ❌ 나쁜 예 - 매 렌더링마다 실행 +const [cart, setCart] = useState(loadFromLocalStorage()); + +// 동작: +// 1렌더링: loadFromLocalStorage() 실행 → 사용 ✅ +// 2렌더링: loadFromLocalStorage() 실행 → 무시 ❌ +// 3렌더링: loadFromLocalStorage() 실행 → 무시 ❌ +``` + +**문제:** 비효율적, 성능 낭비 + +--- + +### 패턴 3: 함수 전달 ✅ + +```typescript +// ✅ 좋은 예 - 첫 렌더링만 실행 +const [cart, setCart] = useState(loadFromLocalStorage); + +const loadFromLocalStorage = () => { + const saved = localStorage.getItem("cart"); + return saved ? JSON.parse(saved) : []; +}; +``` + +**언제:** 외부 함수 재사용, 간단한 로직 + +--- + +### 패턴 4: 화살표 함수 ✅ + +```typescript +// ✅ 좋은 예 - 첫 렌더링만 실행 +const [cart, setCart] = useState(() => { + const saved = localStorage.getItem("cart"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return []; + } + } + return []; +}); +``` + +**언제:** 복잡한 로직, 여러 줄, try-catch 필요 + +--- + +## Lazy Initialization + +### 왜 필요한가? + +```typescript +// ❌ 나쁜 예 +const [data, setData] = useState(expensiveCalculation()); +// 매 렌더링마다 expensiveCalculation() 실행! + +// ✅ 좋은 예 +const [data, setData] = useState(() => expensiveCalculation()); +// 첫 렌더링만 실행! +``` + +### 성능 비교 + +```typescript +function heavyCalculation() { + console.time("계산"); + let result = 0; + for (let i = 0; i < 1000000; i++) { + result += Math.random(); + } + console.timeEnd("계산"); + return result; +} + +// ❌ 함수 호출 +const [bad] = useState(heavyCalculation()); +// 콘솔: 계산: 150ms (매 렌더링마다!) + +// ✅ Lazy +const [good] = useState(() => heavyCalculation()); +// 콘솔: 계산: 150ms (첫 렌더링만!) +``` + +--- + +## localStorage 연동 + +### 패턴 1: 읽기만 + +```typescript +const [cart, setCart] = useState(() => { + const saved = localStorage.getItem("cart"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return []; + } + } + return []; +}); +``` + +--- + +### 패턴 2: 읽기 + 자동 저장 + +```typescript +const [cart, setCart] = useState(() => { + const saved = localStorage.getItem("cart"); + return saved ? JSON.parse(saved) : []; +}); + +// 자동 저장 +useEffect(() => { + if (cart.length > 0) { + localStorage.setItem("cart", JSON.stringify(cart)); + } else { + localStorage.removeItem("cart"); + } +}, [cart]); +``` + +--- + +### 패턴 3: Custom Hook (추천) + +```typescript +// hooks/useLocalStorage.ts +export const useLocalStorage = ( + key: string, + initialValue: T +): [T, (value: T) => void] => { + const [value, setValue] = useState(() => { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch { + return initialValue; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error(`localStorage 저장 실패:`, error); + } + }, [key, value]); + + return [value, setValue]; +}; + +// 사용 +const [cart, setCart] = useLocalStorage("cart", []); +const [user, setUser] = useLocalStorage("user", null); +``` + +--- + +## 실전 예시 + +### 예시 1: 장바구니 + +```typescript +// hooks/useCart.ts +export const useCart = () => { + const [cart, setCart] = useState(() => { + const saved = localStorage.getItem("cart"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return []; + } + } + return []; + }); + + useEffect(() => { + if (cart.length > 0) { + localStorage.setItem("cart", JSON.stringify(cart)); + } else { + localStorage.removeItem("cart"); + } + }, [cart]); + + return { cart, setCart }; +}; +``` + +--- + +### 예시 2: 테마 설정 + +```typescript +type Theme = "light" | "dark" | "system"; + +const useTheme = () => { + const [theme, setTheme] = useState(() => { + // localStorage 확인 + const saved = localStorage.getItem("theme") as Theme; + if (saved) return saved; + + // 시스템 설정 확인 + if (window.matchMedia("(prefers-color-scheme: dark)").matches) { + return "dark"; + } + + return "light"; + }); + + useEffect(() => { + localStorage.setItem("theme", theme); + }, [theme]); + + return { theme, setTheme }; +}; +``` + +--- + +## 안티패턴 + +### 1. 함수 호출 결과 전달 + +```typescript +// ❌ 안티패턴 +const [data, setData] = useState(expensiveFunction()); + +// ✅ 해결책 +const [data, setData] = useState(expensiveFunction); +// 또는 +const [data, setData] = useState(() => expensiveFunction()); +``` + +--- + +### 2. props를 초기값으로 사용 + +```typescript +// ❌ 안티패턴 - props 변경 시 반영 안 됨 +const Component = ({ initialCount }: { initialCount: number }) => { + const [count, setCount] = useState(initialCount); + // initialCount 변경되어도 count는 변경 안 됨! +}; + +// ✅ 해결책 1: useEffect로 동기화 +useEffect(() => { + setCount(initialCount); +}, [initialCount]); + +// ✅ 해결책 2: key로 리셋 +; + +// ✅ 해결책 3: 그냥 props 사용 +const Component = ({ count }: { count: number }) => { + return
{count}
; +}; +``` + +--- + +### 3. 복잡한 객체 매번 생성 + +```typescript +// ❌ 안티패턴 +const [config, setConfig] = useState({ + api: { baseUrl: "https://api.example.com" }, + features: { darkMode: true }, +}); + +// ✅ 해결책 1: Lazy +const [config, setConfig] = useState(() => ({ + api: { baseUrl: "https://api.example.com" }, + features: { darkMode: true }, +})); + +// ✅ 해결책 2: 상수로 분리 +const DEFAULT_CONFIG = { + api: { baseUrl: "https://api.example.com" }, + features: { darkMode: true }, +} as const; + +const [config, setConfig] = useState(DEFAULT_CONFIG); +``` + +--- + +## 체크리스트 + +### useState 초기값 작성 시 + +- [ ] 계산 비용이 높은가? → Lazy Initialization +- [ ] localStorage를 읽는가? → 반드시 Lazy +- [ ] 외부 함수를 호출하는가? → 함수 전달 또는 화살표 함수 +- [ ] 여러 줄의 로직이 필요한가? → 화살표 함수 +- [ ] 에러 처리가 필요한가? → 화살표 함수 + try-catch + +--- + +## 핵심 정리 + +### 선택 가이드 + +```typescript +// 1. 간단한 값 → 직접 값 +const [count, setCount] = useState(0); + +// 2. 외부 함수 → 함수 전달 +const [data, setData] = useState(loadData); + +// 3. 복잡한 로직 → 화살표 함수 +const [cart, setCart] = useState(() => { + const saved = localStorage.getItem("cart"); + return saved ? JSON.parse(saved) : []; +}); + +// 4. localStorage → 항상 Lazy +const [settings, setSettings] = useState(() => + JSON.parse(localStorage.getItem("settings") || "{}") +); +``` + +### 성능 원칙 + +- ✅ 비싼 계산은 Lazy Initialization +- ✅ localStorage 읽기는 항상 Lazy +- ❌ 함수 호출 결과 전달 금지 +- ✅ 복잡한 객체는 상수로 분리 + +--- + +**핵심: 비싼 계산은 Lazy Initialization으로!** 🚀 diff --git a/package.json b/package.json index 17b18de25..a19e7ac6a 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,12 @@ "test:advanced": "vitest src/advanced", "test:ui": "vitest --ui", "build": "tsc -b && vite build", + "build:basic": "vite build --config vite.config.basic.ts", + "build:advanced": "vite build --config vite.config.advanced.ts", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "jotai": "^2.15.2", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85f..3f6655757 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + jotai: + specifier: ^2.15.2 + version: 2.15.2(@types/react@19.1.9)(react@19.1.1) react: specifier: ^19.1.1 version: 19.1.1 @@ -1056,6 +1059,24 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jotai@2.15.2: + resolution: {integrity: sha512-El86CCfXNMEOytp20NPfppqGGmcp6H6kIA+tJHdmASEUURJCYW4fh8nTHEnB8rUXEFAY1pm8PdHPwnrcPGwdEg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2413,6 +2434,11 @@ snapshots: isexe@2.0.0: {} + jotai@2.15.2(@types/react@19.1.9)(react@19.1.1): + optionalDependencies: + '@types/react': 19.1.9 + react: 19.1.1 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1d..94ba372b6 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,1124 +1,33 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { useState } from "react"; +import { Provider } from "jotai"; +import AdminPage from "./Pages/AdminPage"; +import CartPage from "./Pages/CartPage"; -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} +import { useNotification } from "./hooks/useNotification"; +import NotificationList from "./components/NotificationList"; -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; - -const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); +const AppContent = () => { const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + const { notifications, remove } = useNotification(); return (
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
+ + {isAdmin ? ( + setIsAdmin(false)} /> + ) : ( + setIsAdmin(true)} /> )} -
-
-
-
-

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} - {!isAdmin && ( -
- setSearchTerm(e.target.value)} - placeholder="상품 검색..." - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" - /> -
- )} -
- -
-
-
- -
- {isAdmin ? ( -
-
-

관리자 대시보드

-

상품과 쿠폰을 관리할 수 있습니다

-
-
- -
- - {activeTab === 'products' ? ( -
-
-
-

상품 목록

- -
-
- -
- - - - - - - - - - - - {(activeTab === 'products' ? products : products).map(product => ( - - - - - - - - ))} - -
상품명가격재고설명작업
{product.name}{formatPrice(product.price, product.id)} - 10 ? 'bg-green-100 text-green-800' : - product.stock > 0 ? 'bg-yellow-100 text-yellow-800' : - 'bg-red-100 text-red-800' - }`}> - {product.stock}개 - - {product.description || '-'} - - -
-
- {showProductForm && ( -
-
-

- {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} -

-
-
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, price: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, price: 0 }); - } else if (parseInt(value) < 0) { - addNotification('가격은 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, price: 0 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, stock: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) < 0) { - addNotification('재고는 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) > 9999) { - addNotification('재고는 9999개를 초과할 수 없습니다', 'error'); - setProductForm({ ...productForm, stock: 9999 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
-
- -
- {productForm.discounts.map((discount, index) => ( -
- { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].quantity = parseInt(e.target.value) || 0; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-20 px-2 py-1 border rounded" - min="1" - placeholder="수량" - /> - 개 이상 구매 시 - { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-16 px-2 py-1 border rounded" - min="0" - max="100" - placeholder="%" - /> - % 할인 - -
- ))} - -
-
- -
- - -
-
-
- )} -
- ) : ( -
-
-

쿠폰 관리

-
-
-
- {coupons.map(coupon => ( -
-
-
-

{coupon.name}

-

{coupon.code}

-
- - {coupon.discountType === 'amount' - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${coupon.discountValue}% 할인`} - -
-
- -
-
- ))} - -
- -
-
- - {showCouponForm && ( -
-
-

새 쿠폰 생성

-
-
- - setCouponForm({ ...couponForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === 'percentage') { - if (value > 100) { - addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} - required - /> -
-
-
- - -
-
-
- )} -
-
- )} -
- ) : ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

-
- 총 {products.length}개 상품 -
-
- {filteredProducts.length === 0 ? ( -
-

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

-
- ) : ( -
- {filteredProducts.map(product => { - const remainingStock = getRemainingStock(product); - - return ( -
- {/* 상품 이미지 영역 (placeholder) */} -
-
- - - -
- {product.isRecommended && ( - - BEST - - )} - {product.discounts.length > 0 && ( - - ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% - - )} -
- - {/* 상품 정보 */} -
-

{product.name}

- {product.description && ( -

{product.description}

- )} - - {/* 가격 정보 */} -
-

{formatPrice(product.price, product.id)}

- {product.discounts.length > 0 && ( -

- {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% -

- )} -
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

품절임박! {remainingStock}개 남음

- )} - {remainingStock > 5 && ( -

재고 {remainingStock}개

- )} -
- - {/* 장바구니 버튼 */} - -
-
- ); - })} -
- )} -
-
- -
-
-
-

- - - - 장바구니 -

- {cart.length === 0 ? ( -
- - - -

장바구니가 비어있습니다

-
- ) : ( -
- {cart.map(item => { - const itemTotal = calculateItemTotal(item); - const originalPrice = item.product.price * item.quantity; - const hasDiscount = itemTotal < originalPrice; - const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; - - return ( -
-
-

{item.product.name}

- -
-
-
- - {item.quantity} - -
-
- {hasDiscount && ( - -{discountRate}% - )} -

- {Math.round(itemTotal).toLocaleString()}원 -

-
-
-
- ); - })} -
- )} -
- - {cart.length > 0 && ( - <> -
-
-

쿠폰 할인

- -
- {coupons.length > 0 && ( - - )} -
- -
-

결제 정보

-
-
- 상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 -
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && ( -
- 할인 금액 - -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원 -
- )} -
- 결제 예정 금액 - {totals.totalAfterDiscount.toLocaleString()}원 -
-
- - - -
-

* 실제 결제는 이루어지지 않습니다

-
-
- - )} -
-
-
- )} -
); }; -export default App; \ No newline at end of file +const App = () => { + return ( + + + + ); +}; + +export default App; diff --git a/src/advanced/Pages/AdminPage/CouponManagement.tsx b/src/advanced/Pages/AdminPage/CouponManagement.tsx new file mode 100644 index 000000000..2686c4c19 --- /dev/null +++ b/src/advanced/Pages/AdminPage/CouponManagement.tsx @@ -0,0 +1,78 @@ +import { type FC, useState } from "react"; +import { useCoupons } from "../../hooks/useCoupons"; +import { Coupon } from "../../../types"; +import CouponList from "../../components/adminPage/CouponList"; +import CouponForm from "../../components/adminPage/CouponForm"; +import Section from "../../components/_common/Section"; +import { useForm } from "../../utils/hooks/useForm"; +import { formatCouponCode } from "../../utils/validators"; +import { validateDiscountRate } from "../../models/validation"; +import { useAddNotification } from "../../hooks/useNotification"; + +const INITIAL_COUPON: Coupon = { + name: "", + code: "", + discountType: "amount", + discountValue: 0, +}; + +const CouponManagement: FC = () => { + const [showCouponForm, setShowCouponForm] = useState(false); + const addNotification = useAddNotification(); + const { + values: couponForm, + handleChange, + resetForm, + } = useForm(INITIAL_COUPON); + + const { coupons, addCoupon, deleteCoupon } = useCoupons(); + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + resetForm(); + setShowCouponForm(false); + }; + + return ( +
+ setShowCouponForm(true)} + onDelete={deleteCoupon} + /> + + {showCouponForm && ( + handleChange("name", value)} + onCodeChange={(value) => + handleChange("code", value, formatCouponCode) + } + onDiscountTypeChange={(value) => handleChange("discountType", value)} + onDiscountValueChange={(value) => { + if (value !== "" && !/^\d+$/.test(value)) { + return; + } + + const numValue = value === "" ? 0 : parseInt(value); + + if (couponForm.discountType === "percentage") { + const error = validateDiscountRate(numValue); + if (error) { + addNotification(error, "error"); + return; + } + } + + handleChange("discountValue", numValue); + }} + onSubmit={handleCouponSubmit} + onCancel={() => setShowCouponForm(false)} + /> + )} +
+ ); +}; + +export default CouponManagement; diff --git a/src/advanced/Pages/AdminPage/NaviTab.tsx b/src/advanced/Pages/AdminPage/NaviTab.tsx new file mode 100644 index 000000000..9e0765275 --- /dev/null +++ b/src/advanced/Pages/AdminPage/NaviTab.tsx @@ -0,0 +1,31 @@ +import { type FC } from "react"; +import TabButton from "../../components/_common/TabButton"; + +interface IProps { + activeTab: "products" | "coupons"; + onChange: (prev: "products" | "coupons") => void; +} + +const NaviTab: FC = ({ activeTab, onChange }) => { + const TABS = [ + { id: "products" as const, label: "상품 관리" }, + { id: "coupons" as const, label: "쿠폰 관리" }, + ]; + + return ( +
+ +
+ ); +}; + +export default NaviTab; diff --git a/src/advanced/Pages/AdminPage/ProductManagement.tsx b/src/advanced/Pages/AdminPage/ProductManagement.tsx new file mode 100644 index 000000000..15ecf08ff --- /dev/null +++ b/src/advanced/Pages/AdminPage/ProductManagement.tsx @@ -0,0 +1,136 @@ +import { useState, type FC } from "react"; +import ProductListTable from "../../components/adminPage/ProductListTable"; +import ProductForm from "../../components/adminPage/ProductForm"; +import { useProducts } from "../../hooks/useProducts"; +import { ProductWithUI } from "../../../types"; +import Section from "../../components/_common/Section"; +import Button from "../../components/_common/Button"; +import { useForm } from "../../utils/hooks/useForm"; +import { validateStock, validatePrice } from "../../models/validation"; +import { useAddNotification } from "../../hooks/useNotification"; + +const INITIAL_PRODUCT_FORM: Omit = { + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], +}; + +const ProductManagement: FC = () => { + const [showForm, setShowForm] = useState(false); + const [editingProductId, setEditingProductId] = useState(null); + const addNotification = useAddNotification(); + const { + values: productForm, + handleChange, + resetForm, + setValues: setProductForm, + } = useForm>(INITIAL_PRODUCT_FORM); + + const { products, addProduct, updateProduct, deleteProduct } = useProducts(); + + const handleAddNew = () => { + resetForm(); + setEditingProductId(null); + setShowForm(true); + }; + + const handleEditStart = (product: ProductWithUI) => { + const { id, ...formData } = product; + setProductForm(formData); + setEditingProductId(id); + setShowForm(true); + }; + + const saveProduct = () => { + if (editingProductId) { + updateProduct({ ...productForm, id: editingProductId }); + } else { + addProduct(productForm); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + saveProduct(); + handleClose(); + }; + + const handleClose = () => { + setShowForm(false); + setEditingProductId(null); + }; + + const handleNameChange = (value: string) => handleChange("name", value); + const handleDescriptionChange = (value: string) => + handleChange("description", value); + const handlePriceChange = (value: string) => { + if (value !== "" && !/^\d+$/.test(value)) { + return; + } + + const numValue = value === "" ? 0 : parseInt(value); + const error = validatePrice(numValue); + + if (error) { + addNotification(error, "error"); + return; + } + + handleChange("price", numValue); + }; + const handleStockChange = (value: string) => { + if (value !== "" && !/^\d+$/.test(value)) { + return; + } + + const numValue = value === "" ? 0 : parseInt(value); + const error = validateStock(numValue); + + if (error) { + addNotification(error, "error"); + return; + } + + handleChange("stock", numValue); + }; + const handleDiscountsChange = (value: any) => + handleChange("discounts", value); + + return ( +
+ 새 상품 추가 + + }> + + + {showForm && ( + + )} +
+ ); +}; + +export default ProductManagement; diff --git a/src/advanced/Pages/AdminPage/index.tsx b/src/advanced/Pages/AdminPage/index.tsx new file mode 100644 index 000000000..4757a442c --- /dev/null +++ b/src/advanced/Pages/AdminPage/index.tsx @@ -0,0 +1,41 @@ +import { useState, type FC } from "react"; +import ProductManagement from "./ProductManagement"; +import CouponManagement from "./CouponManagement"; +import NaviTab from "./NaviTab"; +import AdminHeader from "../../components/layout/AdminHeader"; + +interface IProps { + onChange: () => void; +} + +const AdminPage: FC = ({ onChange }) => { + const [activeTab, setActiveTab] = useState<"products" | "coupons">( + "products" + ); + return ( + <> + +
+
+
+

+ 관리자 대시보드 +

+

+ 상품과 쿠폰을 관리할 수 있습니다 +

+
+ + + {activeTab === "products" ? ( + + ) : ( + + )} +
+
+ + ); +}; + +export default AdminPage; diff --git a/src/advanced/Pages/CartPage/CartSummary.tsx b/src/advanced/Pages/CartPage/CartSummary.tsx new file mode 100644 index 000000000..d6ae3cdb9 --- /dev/null +++ b/src/advanced/Pages/CartPage/CartSummary.tsx @@ -0,0 +1,69 @@ +import { type FC } from "react"; +import CartItems from "../../components/cartPage/CartItems"; +import PayItem from "../../components/cartPage/PayItem"; +import ShoppingBagIcon from "../../components/_icons/ShoppingBagIcon"; +import CouponSelector from "../../components/cartPage/CouponSelector"; +import { useCart } from "../../hooks/useCart"; +import { useCoupons } from "../../hooks/useCoupons"; +import { useAddNotification } from "../../hooks/useNotification"; + +const CartSummary: FC = () => { + const { + cart, + selectedCoupon, + applyCoupon, + removeFromCart, + updateQuantity, + emptyCart, + calculateTotal, + } = useCart(); + + const { coupons } = useCoupons(); + const addNotification = useAddNotification(); + const totals = calculateTotal(); + + const handleCompleteOrder = () => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + "success" + ); + emptyCart(); + }; + + return ( +
+
+

+ + 장바구니 +

+ {cart.length === 0 ? ( +
+ +

장바구니가 비어있습니다

+
+ ) : ( + + )} +
+ + {cart.length > 0 && ( + <> + + + + )} +
+ ); +}; + +export default CartSummary; diff --git a/src/advanced/Pages/CartPage/ProductCards.tsx b/src/advanced/Pages/CartPage/ProductCards.tsx new file mode 100644 index 000000000..d9710fcda --- /dev/null +++ b/src/advanced/Pages/CartPage/ProductCards.tsx @@ -0,0 +1,57 @@ +import { type FC } from "react"; +import ProductCard from "../../components/cartPage/ProductCard"; +import { ProductWithUI } from "../../../types"; + +interface IProps { + searchTerm?: string; + products: ProductWithUI[]; + addToCart: (product: ProductWithUI) => void; + getStock: (product: ProductWithUI) => number; +} + +const ProductCards: FC = ({ + searchTerm, + products, + addToCart, + getStock, +}) => { + const filteredProducts = searchTerm + ? products.filter( + (product) => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(searchTerm.toLowerCase())) + ) + : products; + + return ( +
+
+

전체 상품

+
총 {products.length}개 상품
+
+ {filteredProducts.length === 0 ? ( +
+

+ "{searchTerm}"에 대한 검색 결과가 없습니다. +

+
+ ) : ( +
+ {filteredProducts.map((product) => ( + + ))} +
+ )} +
+ ); +}; + +export default ProductCards; diff --git a/src/advanced/Pages/CartPage/index.tsx b/src/advanced/Pages/CartPage/index.tsx new file mode 100644 index 000000000..2e39df95a --- /dev/null +++ b/src/advanced/Pages/CartPage/index.tsx @@ -0,0 +1,47 @@ +import { type FC } from "react"; +import ProductCards from "./ProductCards"; +import CartSummary from "./CartSummary"; +import { useCart } from "../../hooks/useCart"; +import { useProducts } from "../../hooks/useProducts"; +import CartHeader from "../../components/layout/CartHeader"; +import { useSearch } from "../../hooks/useSearch"; + +interface IProps { + onChange: () => void; +} + +const CartPage: FC = ({ onChange }) => { + const { cart, addToCart, getStock } = useCart(); + const { products } = useProducts(); + const { searchTerm, setSearchTerm } = useSearch(); + const totalCartCount = cart.reduce((sum, item) => sum + item.quantity, 0); + + return ( + <> + +
+
+
+ +
+ +
+ +
+
+
+ + ); +}; + +export default CartPage; diff --git a/src/advanced/components/NotificationList/index.tsx b/src/advanced/components/NotificationList/index.tsx new file mode 100644 index 000000000..398a10aa9 --- /dev/null +++ b/src/advanced/components/NotificationList/index.tsx @@ -0,0 +1,43 @@ +import { type FC } from "react"; +import CloseIcon from "../_icons/CloseIcon"; +import Button from "../_common/Button"; +import { Notification } from "../../models/notificiation"; + +interface IProps { + notifications: Notification[]; + onClose: (id: string) => void; +} + +const NotificationList: FC = ({ notifications, onClose }) => { + if (notifications.length === 0) return null; + + const getNotificationClass = (type: Notification["type"]) => { + const baseClass = + "p-4 rounded-md shadow-md text-white flex justify-between items-center"; + const colorMap = { + error: "bg-red-600", + warning: "bg-yellow-600", + success: "bg-green-600", + }; + return `${baseClass} ${colorMap[type]}`; + }; + + return ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ); +}; + +export default NotificationList; diff --git a/src/advanced/components/_common/Button.tsx b/src/advanced/components/_common/Button.tsx new file mode 100644 index 000000000..d2f07896a --- /dev/null +++ b/src/advanced/components/_common/Button.tsx @@ -0,0 +1,71 @@ +import { type FC, type ButtonHTMLAttributes } from "react"; + +interface IProps extends ButtonHTMLAttributes { + variant?: "solid" | "outline" | "ghost"; + color?: "primary" | "secondary" | "danger" | "indigo" | "gray"; + size?: "sm" | "md" | "lg"; +} + +const Button: FC = ({ + variant = "solid", + color = "primary", + size = "md", + className = "", + disabled = false, + children, + ...props +}) => { + const baseClass = + "rounded font-medium transition-colors flex items-center justify-center"; + + const colorClass = { + primary: { + solid: "bg-yellow-400 text-gray-900 hover:bg-yellow-500", + outline: "border border-yellow-400 text-yellow-600 hover:bg-yellow-50", + ghost: "text-yellow-600 hover:bg-yellow-50", + }, + secondary: { + solid: "bg-gray-900 text-white hover:bg-gray-800", + outline: "border border-gray-900 text-gray-900 hover:bg-gray-50", + ghost: "text-gray-900 hover:bg-gray-100", + }, + gray: { + solid: "bg-gray-200 text-gray-800 hover:bg-gray-300", + outline: "border border-gray-300 text-gray-700 hover:bg-gray-50", + ghost: "text-gray-600 hover:text-gray-900 hover:bg-gray-100", + }, + danger: { + solid: "bg-red-600 text-white hover:bg-red-700", + outline: "border border-red-600 text-red-600 hover:bg-red-50", + ghost: "text-gray-400 hover:text-red-500 hover:bg-red-50", + }, + indigo: { + solid: "bg-indigo-600 text-white hover:bg-indigo-700", + outline: "border border-indigo-600 text-indigo-600 hover:bg-indigo-50", + ghost: "text-indigo-600 hover:bg-indigo-50", + }, + }[color][variant]; + + const sizeClass = { + sm: "px-3 py-1.5 text-sm", + md: "py-2 px-4 text-base", + lg: "py-3 px-6 text-lg", + }[size]; + + const disabledClass = disabled + ? "bg-gray-100 text-gray-400 cursor-not-allowed border-gray-100 hover:bg-gray-100 hover:text-gray-400" + : ""; + + return ( + + ); +}; + +export default Button; diff --git a/src/advanced/components/_common/FormInput.tsx b/src/advanced/components/_common/FormInput.tsx new file mode 100644 index 000000000..e891ece54 --- /dev/null +++ b/src/advanced/components/_common/FormInput.tsx @@ -0,0 +1,33 @@ +import type { InputHTMLAttributes, FC } from "react"; + +interface IProps extends InputHTMLAttributes { + label: string; + value?: string | number; + onValueChange: (value: string) => void; +} + +const FormInput: FC = ({ + label, + value, + onValueChange, + required = false, + placeholder, +}) => { + return ( +
+ + onValueChange(e.target.value)} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder={placeholder} + required={required} + /> +
+ ); +}; + +export default FormInput; diff --git a/src/advanced/components/_common/Section.tsx b/src/advanced/components/_common/Section.tsx new file mode 100644 index 000000000..86b200b73 --- /dev/null +++ b/src/advanced/components/_common/Section.tsx @@ -0,0 +1,21 @@ +import { type FC, ReactNode } from "react"; + +interface IProps { + title: ReactNode; + children: ReactNode; + action?: ReactNode; +} + +const Section: FC = ({ title, children, action }) => { + return ( +
+
+

{title}

+ {action &&
{action}
} +
+
{children}
+
+ ); +}; + +export default Section; diff --git a/src/advanced/components/_common/TabButton.tsx b/src/advanced/components/_common/TabButton.tsx new file mode 100644 index 000000000..7580e08a8 --- /dev/null +++ b/src/advanced/components/_common/TabButton.tsx @@ -0,0 +1,23 @@ +import { type FC } from "react"; + +interface IProps { + label: string; + isActive: boolean; + onClick: () => void; +} + +const TabButton: FC = ({ label, isActive, onClick }) => { + return ( + + ); +}; + +export default TabButton; diff --git a/src/advanced/components/_icons/CartIcon.tsx b/src/advanced/components/_icons/CartIcon.tsx new file mode 100644 index 000000000..4b0caa48a --- /dev/null +++ b/src/advanced/components/_icons/CartIcon.tsx @@ -0,0 +1,22 @@ +import { type FC } from "react"; + +interface IProps {} + +const CartIcon: FC = ({}) => { + return ( + + + + ); +}; + +export default CartIcon; diff --git a/src/advanced/components/_icons/CloseIcon.tsx b/src/advanced/components/_icons/CloseIcon.tsx new file mode 100644 index 000000000..45ab27487 --- /dev/null +++ b/src/advanced/components/_icons/CloseIcon.tsx @@ -0,0 +1,20 @@ +import { type FC } from "react"; + +const CloseIcon: FC = () => { + return ( + + + + ); +}; + +export default CloseIcon; diff --git a/src/advanced/components/_icons/ImagePlaceholderIcon.tsx b/src/advanced/components/_icons/ImagePlaceholderIcon.tsx new file mode 100644 index 000000000..ffa1f4717 --- /dev/null +++ b/src/advanced/components/_icons/ImagePlaceholderIcon.tsx @@ -0,0 +1,20 @@ +import { type FC } from "react"; + +const ImagePlaceholderIcon: FC = () => { + return ( + + + + ); +}; + +export default ImagePlaceholderIcon; diff --git a/src/advanced/components/_icons/PlusIcon.tsx b/src/advanced/components/_icons/PlusIcon.tsx new file mode 100644 index 000000000..75a2fefc0 --- /dev/null +++ b/src/advanced/components/_icons/PlusIcon.tsx @@ -0,0 +1,22 @@ +import { type FC } from "react"; + +interface IProps {} + +const PlusIcon: FC = ({}) => { + return ( + + + + ); +}; + +export default PlusIcon; diff --git a/src/advanced/components/_icons/ShoppingBagIcon.tsx b/src/advanced/components/_icons/ShoppingBagIcon.tsx new file mode 100644 index 000000000..97770d427 --- /dev/null +++ b/src/advanced/components/_icons/ShoppingBagIcon.tsx @@ -0,0 +1,24 @@ +import { type FC } from "react"; + +interface IProps { + className?: string; +} + +const ShoppingBagIcon: FC = ({ className }) => { + return ( + + + + ); +}; + +export default ShoppingBagIcon; diff --git a/src/advanced/components/_icons/TrashIcon.tsx b/src/advanced/components/_icons/TrashIcon.tsx new file mode 100644 index 000000000..a09cd0440 --- /dev/null +++ b/src/advanced/components/_icons/TrashIcon.tsx @@ -0,0 +1,22 @@ +import { type FC } from "react"; + +interface IProps {} + +const TrashIcon: FC = ({}) => { + return ( + + + + ); +}; + +export default TrashIcon; diff --git a/src/advanced/components/adminPage/CouponForm/index.tsx b/src/advanced/components/adminPage/CouponForm/index.tsx new file mode 100644 index 000000000..1a820c681 --- /dev/null +++ b/src/advanced/components/adminPage/CouponForm/index.tsx @@ -0,0 +1,87 @@ +import { type FC } from "react"; +import Button from "../../_common/Button"; +import { Coupon } from "../../../../types"; +import FormInput from "../../_common/FormInput"; + +interface IProps { + couponForm: Coupon; + onNameChange: (value: string) => void; + onCodeChange: (value: string) => void; + onDiscountTypeChange: (type: "amount" | "percentage") => void; + onDiscountValueChange: (value: string) => void; + onSubmit: (e: React.FormEvent) => void; + onCancel: () => void; +} + +const CouponForm: FC = ({ + couponForm, + onNameChange, + onCodeChange, + onDiscountTypeChange, + onDiscountValueChange, + onSubmit, + onCancel, +}) => { + return ( +
+
+

새 쿠폰 생성

+
+ + +
+ + +
+ +
+
+ + +
+
+
+ ); +}; + +export default CouponForm; diff --git a/src/advanced/components/adminPage/CouponList/CouponCard.tsx b/src/advanced/components/adminPage/CouponList/CouponCard.tsx new file mode 100644 index 000000000..92663f7b9 --- /dev/null +++ b/src/advanced/components/adminPage/CouponList/CouponCard.tsx @@ -0,0 +1,39 @@ +import { type FC } from "react"; +import { Coupon } from "../../../../types"; +import Button from "../../_common/Button"; +import TrashIcon from "../../_icons/TrashIcon"; +import { formatCouponDiscount } from "../../../models/coupon"; + +interface IProps { + coupon: Coupon; + onDelete: (couponCode: string) => void; +} + +const CouponCard: FC = ({ coupon, onDelete }) => { + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {formatCouponDiscount(coupon)} + +
+
+ +
+
+ ); +}; + +export default CouponCard; diff --git a/src/advanced/components/adminPage/CouponList/index.tsx b/src/advanced/components/adminPage/CouponList/index.tsx new file mode 100644 index 000000000..23b567409 --- /dev/null +++ b/src/advanced/components/adminPage/CouponList/index.tsx @@ -0,0 +1,34 @@ +import { type FC } from "react"; +import CouponCard from "./CouponCard"; +import Button from "../../_common/Button"; +import PlusIcon from "../../_icons/PlusIcon"; +import { Coupon } from "../../../../types"; + +interface IProps { + coupons: Coupon[]; + onAddClick: () => void; + onDelete: (couponCode: string) => void; +} + +const CouponList: FC = ({ coupons, onAddClick, onDelete }) => { + return ( +
+ {coupons.map((coupon) => ( + + ))} + +
+ +
+
+ ); +}; + +export default CouponList; diff --git a/src/advanced/components/adminPage/ProductForm/DiscountForm.tsx b/src/advanced/components/adminPage/ProductForm/DiscountForm.tsx new file mode 100644 index 000000000..b7908232a --- /dev/null +++ b/src/advanced/components/adminPage/ProductForm/DiscountForm.tsx @@ -0,0 +1,90 @@ +import { type FC } from "react"; +import Button from "../../_common/Button"; +import { Discount } from "../../../../types"; +import CloseIcon from "../../_icons/CloseIcon"; +import { + addDiscount, + removeDiscount, + updateDiscount, + getDiscountRatePercent, +} from "../../../models/discount"; + +interface IProps { + discounts: Discount[]; + onChange: (newDiscounts: Discount[]) => void; +} + +const DiscountForm: FC = ({ discounts, onChange }) => { + const handleQuantityChange = (index: number, value: number) => { + onChange(updateDiscount(discounts, index, { quantity: value })); + }; + + const handleRateChange = (index: number, ratePercent: number) => { + onChange(updateDiscount(discounts, index, { rate: ratePercent / 100 })); + }; + + const handleRemoveDiscount = (index: number) => { + onChange(removeDiscount(discounts, index)); + }; + + const handleAddDiscount = () => { + onChange(addDiscount(discounts)); + }; + + return ( +
+ +
+ {discounts.map((discount, index) => ( +
+ + handleQuantityChange(index, parseInt(e.target.value) || 0) + } + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + + handleRateChange(index, parseInt(e.target.value) || 0) + } + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ ); +}; + +export default DiscountForm; diff --git a/src/advanced/components/adminPage/ProductForm/index.tsx b/src/advanced/components/adminPage/ProductForm/index.tsx new file mode 100644 index 000000000..c9a42df7d --- /dev/null +++ b/src/advanced/components/adminPage/ProductForm/index.tsx @@ -0,0 +1,92 @@ +import { type FC } from "react"; +import FormInput from "../../_common/FormInput"; +import Button from "../../_common/Button"; +import DiscountForm from "./DiscountForm"; +import { ProductWithUI, Discount } from "../../../../types"; + +interface IProps { + productForm: Omit; + onNameChange: (value: string) => void; + onDescriptionChange: (value: string) => void; + onPriceChange: (value: string) => void; + onStockChange: (value: string) => void; + onDiscountsChange: (value: Discount[]) => void; + onSubmit: (e: React.FormEvent) => void; + onClose: () => void; + isEditing: boolean; +} + +const ProductForm: FC = ({ + productForm, + onNameChange, + onDescriptionChange, + onPriceChange, + onStockChange, + onDiscountsChange, + onSubmit, + onClose, + isEditing, +}) => { + return ( +
+
+

+ {isEditing ? "상품 수정" : "새 상품 추가"} +

+ +
+ + + + + + + +
+ + + +
+ + +
+ +
+ ); +}; + +export default ProductForm; diff --git a/src/advanced/components/adminPage/ProductListTable/ProductItem.tsx b/src/advanced/components/adminPage/ProductListTable/ProductItem.tsx new file mode 100644 index 000000000..49fe0c91b --- /dev/null +++ b/src/advanced/components/adminPage/ProductListTable/ProductItem.tsx @@ -0,0 +1,57 @@ +import { type FC } from "react"; +import Button from "../../_common/Button"; +import { ProductWithUI } from "../../../../types"; +import { formatPriceKr } from "../../../utils/formatters"; + +interface IProps { + product: ProductWithUI; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +} + +const ProductItem: FC = ({ product, onEdit, onDelete }) => { + return ( + + + {product.name} + + + {formatPriceKr(product.price)} + + + 10 + ? "bg-green-100 text-green-800" + : product.stock > 0 + ? "bg-yellow-100 text-yellow-800" + : "bg-red-100 text-red-800" + }`}> + {product.stock}개 + + + + {product.description || "-"} + + + + + + + ); +}; + +export default ProductItem; diff --git a/src/advanced/components/adminPage/ProductListTable/index.tsx b/src/advanced/components/adminPage/ProductListTable/index.tsx new file mode 100644 index 000000000..c28eec0e6 --- /dev/null +++ b/src/advanced/components/adminPage/ProductListTable/index.tsx @@ -0,0 +1,49 @@ +import { type FC } from "react"; +import { ProductWithUI } from "../../../../types"; +import ProductItem from "./ProductItem"; + +interface IProps { + products: ProductWithUI[]; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +} + +const ProductListTable: FC = ({ products, onEdit, onDelete }) => { + return ( +
+ + + + + + + + + + + + {products.map((product) => ( + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+
+ ); +}; + +export default ProductListTable; diff --git a/src/advanced/components/cartPage/CartItems/CartItem.tsx b/src/advanced/components/cartPage/CartItems/CartItem.tsx new file mode 100644 index 000000000..452b63f53 --- /dev/null +++ b/src/advanced/components/cartPage/CartItems/CartItem.tsx @@ -0,0 +1,77 @@ +import { type FC } from "react"; +import { CartItem as TCartItem } from "../../../../types"; +import Button from "../../_common/Button"; +import CloseIcon from "../../_icons/CloseIcon"; + +interface IProps { + item: TCartItem; + itemTotal: number; + hasDiscount: boolean; + discountRate: number; + onRemove: (productId: string) => void; + onUpdateQuantity: (productId: string, quantity: number) => void; +} + +const CartItem: FC = ({ + item, + itemTotal, + hasDiscount, + discountRate, + onRemove, + onUpdateQuantity, +}) => { + return ( +
+
+

+ {item.product.name} +

+ +
+
+
+ + + {item.quantity} + + +
+
+ {hasDiscount && ( + + -{discountRate}% + + )} +

+ {Math.round(itemTotal).toLocaleString()}원 +

+
+
+
+ ); +}; + +export default CartItem; diff --git a/src/advanced/components/cartPage/CartItems/index.tsx b/src/advanced/components/cartPage/CartItems/index.tsx new file mode 100644 index 000000000..c750c32f0 --- /dev/null +++ b/src/advanced/components/cartPage/CartItems/index.tsx @@ -0,0 +1,35 @@ +import { type FC } from "react"; +import { CartItem as TCartItem } from "../../../../types"; +import CartItemRow from "./CartItem"; +import { calculateItemTotal, getCartItemDiscount } from "../../../models/cart"; + +interface IProps { + cart: TCartItem[]; + onRemove: (productId: string) => void; + onUpdateQuantity: (productId: string, quantity: number) => void; +} + +const CartItems: FC = ({ cart, onRemove, onUpdateQuantity }) => { + return ( +
+ {cart.map((item) => { + const itemTotal = calculateItemTotal(cart, item); + const { hasDiscount, discountRate } = getCartItemDiscount(cart, item); + + return ( + + ); + })} +
+ ); +}; + +export default CartItems; diff --git a/src/advanced/components/cartPage/CouponSelector.tsx b/src/advanced/components/cartPage/CouponSelector.tsx new file mode 100644 index 000000000..e35ff6751 --- /dev/null +++ b/src/advanced/components/cartPage/CouponSelector.tsx @@ -0,0 +1,37 @@ +import { type FC } from "react"; +import { Coupon } from "../../../types"; +import { getCouponOptionText } from "../../models/coupon"; + +interface IProps { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + onApply: (coupon: Coupon | null) => void; +} + +const CouponSelector: FC = ({ coupons, selectedCoupon, onApply }) => { + return ( +
+
+

쿠폰 할인

+
+ {coupons.length > 0 && ( + + )} +
+ ); +}; + +export default CouponSelector; diff --git a/src/advanced/components/cartPage/PayItem.tsx b/src/advanced/components/cartPage/PayItem.tsx new file mode 100644 index 000000000..863ff0bfd --- /dev/null +++ b/src/advanced/components/cartPage/PayItem.tsx @@ -0,0 +1,58 @@ +import { type FC } from "react"; +import { calculateDiscountAmount } from "../../models/cart"; +import Button from "../_common/Button"; + +interface IProps { + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + onCheckout: () => void; +} + +const PayItem: FC = ({ totals, onCheckout }) => { + const discountAmount = calculateDiscountAmount( + totals.totalBeforeDiscount, + totals.totalAfterDiscount + ); + + return ( +
+

결제 정보

+
+
+ 상품 금액 + + {totals.totalBeforeDiscount.toLocaleString()}원 + +
+ {discountAmount > 0 && ( +
+ 할인 금액 + -{discountAmount.toLocaleString()}원 +
+ )} +
+ 결제 예정 금액 + + {totals.totalAfterDiscount.toLocaleString()}원 + +
+
+ + +
+

* 실제 결제는 이루어지지 않습니다

+
+
+ ); +}; + +export default PayItem; diff --git a/src/advanced/components/cartPage/ProductCard/DiscountInfo.tsx b/src/advanced/components/cartPage/ProductCard/DiscountInfo.tsx new file mode 100644 index 000000000..b5f0161ec --- /dev/null +++ b/src/advanced/components/cartPage/ProductCard/DiscountInfo.tsx @@ -0,0 +1,16 @@ +import { type FC } from "react"; + +interface IProps { + quantity: number; + rate: number; +} + +const DiscountInfo: FC = ({ quantity, rate }) => { + return ( +

+ {quantity}개 이상 구매시 할인 {rate}% +

+ ); +}; + +export default DiscountInfo; diff --git a/src/advanced/components/cartPage/ProductCard/ProductBadge.tsx b/src/advanced/components/cartPage/ProductCard/ProductBadge.tsx new file mode 100644 index 000000000..759e9f92a --- /dev/null +++ b/src/advanced/components/cartPage/ProductCard/ProductBadge.tsx @@ -0,0 +1,28 @@ +import { type FC } from "react"; + +interface IProps { + type: "best" | "discount"; + value?: number; +} + +const ProductBadge: FC = ({ type, value }) => { + if (type === "best") { + return ( + + BEST + + ); + } + + if (type === "discount" && value !== undefined) { + return ( + + ~{value}% + + ); + } + + return null; +}; + +export default ProductBadge; diff --git a/src/advanced/components/cartPage/ProductCard/StockStatus.tsx b/src/advanced/components/cartPage/ProductCard/StockStatus.tsx new file mode 100644 index 000000000..9ec5c2c81 --- /dev/null +++ b/src/advanced/components/cartPage/ProductCard/StockStatus.tsx @@ -0,0 +1,21 @@ +import { type FC } from "react"; + +interface IProps { + isLowStock: boolean; + message: string; +} + +const StockStatus: FC = ({ isLowStock, message }) => { + if (!message) return null; + + return ( +

+ {message} +

+ ); +}; + +export default StockStatus; diff --git a/src/advanced/components/cartPage/ProductCard/index.tsx b/src/advanced/components/cartPage/ProductCard/index.tsx new file mode 100644 index 000000000..1b18eba21 --- /dev/null +++ b/src/advanced/components/cartPage/ProductCard/index.tsx @@ -0,0 +1,82 @@ +import { type FC } from "react"; +import { ProductWithUI } from "../../../../types"; +import { formatPrice } from "../../../utils/formatters"; +import { + getMaxDiscountRate, + getStockStatus, + getFirstDiscount, +} from "../../../models/product"; +import Button from "../../_common/Button"; +import ImagePlaceholderIcon from "../../_icons/ImagePlaceholderIcon"; +import ProductBadge from "./ProductBadge"; +import StockStatus from "./StockStatus"; +import DiscountInfo from "./DiscountInfo"; + +interface IProps { + product: ProductWithUI; + remainingStock: number; + onAddToCart: (product: ProductWithUI) => void; +} + +const ProductCard: FC = ({ product, onAddToCart, remainingStock }) => { + const maxDiscountRate = getMaxDiscountRate(product); + const stockStatus = getStockStatus(remainingStock); + const firstDiscount = getFirstDiscount(product); + + return ( +
+ {/* 상품 이미지 영역 */} +
+
+ +
+ {product.isRecommended && } + {maxDiscountRate > 0 && ( + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

+ )} + +
+

+ {formatPrice(product.price, remainingStock)} +

+ {firstDiscount && ( + + )} +
+ + {/* 재고 상태 */} +
+ +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; + +export default ProductCard; diff --git a/src/advanced/components/layout/AdminHeader/index.tsx b/src/advanced/components/layout/AdminHeader/index.tsx new file mode 100644 index 000000000..786daa0b8 --- /dev/null +++ b/src/advanced/components/layout/AdminHeader/index.tsx @@ -0,0 +1,21 @@ +import { type FC } from "react"; +import HeaderLayout from "../_common/HeaderLayout"; +import Button from "../../_common/Button"; + +interface IProps { + onChange: () => void; +} + +const AdminHeader: FC = ({ onChange }) => { + return ( + + 쇼핑몰로 돌아가기 + + } + /> + ); +}; + +export default AdminHeader; diff --git a/src/advanced/components/layout/CartHeader/CartBadge.tsx b/src/advanced/components/layout/CartHeader/CartBadge.tsx new file mode 100644 index 000000000..ad833c03d --- /dev/null +++ b/src/advanced/components/layout/CartHeader/CartBadge.tsx @@ -0,0 +1,21 @@ +import { type FC } from "react"; +import CartIcon from "../../_icons/CartIcon"; + +interface IProps { + count: number; +} + +const CartBadge: FC = ({ count }) => { + return ( +
+ + {count > 0 && ( + + {count} + + )} +
+ ); +}; + +export default CartBadge; diff --git a/src/advanced/components/layout/CartHeader/SearchBar.tsx b/src/advanced/components/layout/CartHeader/SearchBar.tsx new file mode 100644 index 000000000..93ca18e71 --- /dev/null +++ b/src/advanced/components/layout/CartHeader/SearchBar.tsx @@ -0,0 +1,22 @@ +import { type FC } from "react"; + +interface IProps { + searchTerm : string; + setSearchTerm : (v : string) => void; +} + +const SearchBar: FC = ({searchTerm, setSearchTerm}) => { + return ( +
+ setSearchTerm(e.target.value)} + placeholder="상품 검색..." + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ ); +}; + +export default SearchBar; diff --git a/src/advanced/components/layout/CartHeader/index.tsx b/src/advanced/components/layout/CartHeader/index.tsx new file mode 100644 index 000000000..f275070b4 --- /dev/null +++ b/src/advanced/components/layout/CartHeader/index.tsx @@ -0,0 +1,35 @@ +import { type FC } from "react"; +import CartBadge from "./CartBadge"; +import SearchBar from "./SearchBar"; +import HeaderLayout from "../_common/HeaderLayout"; +import Button from "../../_common/Button"; + +interface IProps { + searchTerm: string; + totalCount: number; + onChange: () => void; + setSearchTerm: (searchTerm: string) => void; +} + +const CartHeader: FC = ({ + searchTerm, + totalCount, + onChange, + setSearchTerm, +}) => { + return ( + } + right={ + <> + + + + } + /> + ); +}; + +export default CartHeader; diff --git a/src/advanced/components/layout/_common/HeaderLayout.tsx b/src/advanced/components/layout/_common/HeaderLayout.tsx new file mode 100644 index 000000000..33651fe32 --- /dev/null +++ b/src/advanced/components/layout/_common/HeaderLayout.tsx @@ -0,0 +1,24 @@ +import { type FC, ReactNode } from "react"; + +interface IProps { + left?: ReactNode; + right?: ReactNode; +} + +const HeaderLayout: FC = ({ left, right }) => { + return ( +
+
+
+
+

SHOP

+ {left} +
+ +
+
+
+ ); +}; + +export default HeaderLayout; diff --git a/src/advanced/constants/index.ts b/src/advanced/constants/index.ts new file mode 100644 index 000000000..e91a5e3ab --- /dev/null +++ b/src/advanced/constants/index.ts @@ -0,0 +1,57 @@ +// 정의할 상수들: +// - initialProducts: 초기 상품 목록 (상품1, 상품2, 상품3 + 설명 필드 포함) +// - initialCoupons: 초기 쿠폰 목록 (5000원 할인, 10% 할인) +// +// 참고: origin/App.tsx의 초기 데이터 구조를 참조 + +import { Coupon, ProductWithUI } from "../../types"; + +// 초기 데이터 +export const initialProducts: ProductWithUI[] = [ + { + id: "p1", + name: "상품1", + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: "최고급 품질의 프리미엄 상품입니다.", + }, + { + id: "p2", + name: "상품2", + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, + }, + { + id: "p3", + name: "상품3", + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; + +export const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts new file mode 100644 index 000000000..9b347a335 --- /dev/null +++ b/src/advanced/hooks/useCart.ts @@ -0,0 +1,96 @@ +import { useAtom } from "jotai"; +import { Coupon, ProductWithUI } from "../../types"; +import { + addItemToCart, + calculateCartTotal, + getRemainingStock, + removeItemFromCart, + updateCartItemQuantity, +} from "../models/cart"; +import { cartAtom, selectedCouponAtom } from "../stores/atoms"; +import { useAddNotification } from "./useNotification"; + +export const useCart = () => { + const [cart, setCart] = useAtom(cartAtom); + const [selectedCoupon, setSelectedCoupon] = useAtom(selectedCouponAtom); + const addNotification = useAddNotification(); + + const addToCart = (product: ProductWithUI) => { + if (getStock(product) <= 0) { + addNotification("재고가 부족합니다!", "error"); + return; + } + + // 먼저 재고 체크 + const currentItem = cart.find((item) => item.product.id === product.id); + const currentQty = currentItem?.quantity || 0; + + if (currentQty >= product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다`, "error"); + return; + } + + setCart((prev) => addItemToCart(prev, product)); + addNotification("장바구니에 담았습니다", "success"); + }; + + const removeFromCart = (productId: string) => { + setCart((prev) => removeItemFromCart(prev, productId)); + }; + + const updateQuantity = (productId: string, newQuantity: number) => { + setCart((prev) => { + const item = prev.find((i) => i.product.id === productId); + if (item && newQuantity > item.product.stock) { + addNotification( + `재고는 ${item.product.stock}개까지만 있습니다`, + "error" + ); + } + return updateCartItemQuantity(prev, productId, newQuantity); + }); + }; + + const emptyCart = () => { + setCart([]); + }; + + const calculateTotal = () => { + return calculateCartTotal(cart, selectedCoupon); + }; + + const applyCoupon = (coupon: Coupon | null) => { + if (!coupon) { + setSelectedCoupon(null); + return; + } + + const { totalAfterDiscount } = calculateTotal(); + + if (totalAfterDiscount < 10000 && coupon.discountType === "percentage") { + addNotification( + "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", + "warning" + ); + return; + } + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다", "success"); + }; + + const getStock = (product: ProductWithUI) => { + return getRemainingStock(cart, product); + }; + + return { + cart, + selectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + emptyCart, + getStock, + applyCoupon, + calculateTotal, + }; +}; diff --git a/src/advanced/hooks/useCoupons.ts b/src/advanced/hooks/useCoupons.ts new file mode 100644 index 000000000..5ab97207b --- /dev/null +++ b/src/advanced/hooks/useCoupons.ts @@ -0,0 +1,38 @@ +import { useAtom } from "jotai"; +import { Coupon } from "../../types"; +import { addCouponToList, deleteCouponToList } from "../models/coupon"; +import { couponsAtom } from "../stores/atoms"; +import { useAddNotification } from "./useNotification"; + +export const useCoupons = () => { + const [coupons, setCoupons] = useAtom(couponsAtom); + const addNotification = useAddNotification(); + + const addCoupon = (newCoupon: Coupon) => { + setCoupons((prev) => { + const newCoupons = addCouponToList(prev, newCoupon); + + if (newCoupons === prev) { + addNotification("이미 존재하는 쿠폰 코드입니다", "error"); + } else { + addNotification("쿠폰이 추가되었습니다", "success"); + } + return newCoupons; + }); + }; + + const deleteCoupon = (couponCode: string) => { + setCoupons((prev) => { + const newCoupons = deleteCouponToList(prev, couponCode); + const wasDeleted = newCoupons.length < prev.length; + + if (wasDeleted) { + addNotification("쿠폰이 삭제되었습니다", "success"); + } + + return newCoupons; + }); + }; + + return { coupons, addCoupon, deleteCoupon }; +}; diff --git a/src/advanced/hooks/useDiscount.ts b/src/advanced/hooks/useDiscount.ts new file mode 100644 index 000000000..cfcaf1fc4 --- /dev/null +++ b/src/advanced/hooks/useDiscount.ts @@ -0,0 +1,39 @@ +import { useCallback } from "react"; +import { Discount } from "../../types"; +import { + addDiscount, + removeDiscount, + updateDiscount, +} from "../models/discount"; + +export const useDiscount = ( + discounts: Discount[], + onChange: (newDiscounts: Discount[]) => void +) => { + const add = useCallback(() => { + onChange(addDiscount(discounts)); + }, [discounts, onChange]); + + const remove = useCallback( + (index: number) => { + onChange(removeDiscount(discounts, index)); + }, + [discounts, onChange] + ); + + const updateQuantity = useCallback( + (index: number, quantity: number) => { + onChange(updateDiscount(discounts, index, { quantity })); + }, + [discounts, onChange] + ); + + const updateRate = useCallback( + (index: number, rate: number) => { + onChange(updateDiscount(discounts, index, { rate: rate / 100 })); + }, + [discounts, onChange] + ); + + return { add, remove, updateQuantity, updateRate }; +}; diff --git a/src/advanced/hooks/useNotification.ts b/src/advanced/hooks/useNotification.ts new file mode 100644 index 000000000..200a99c34 --- /dev/null +++ b/src/advanced/hooks/useNotification.ts @@ -0,0 +1,39 @@ +import { useAtom } from "jotai"; +import { useCallback } from "react"; +import { Notification } from "../../types"; +import { + createNotification, + removeNotification, +} from "../models/notificiation"; +import { notificationsAtom } from "../stores/atoms"; + +export const useNotification = () => { + const [notifications, setNotifications] = useAtom(notificationsAtom); + + const addNotification = useCallback( + (message: string, type: Notification["type"] = "success") => { + const notification = createNotification(message, type); + setNotifications((prev) => [...prev, notification]); + + setTimeout(() => { + setNotifications((prev) => removeNotification(prev, notification.id)); + }, 3000); + }, + [setNotifications] + ); + + const remove = useCallback( + (id: string) => { + setNotifications((prev) => removeNotification(prev, id)); + }, + [setNotifications] + ); + + return { notifications, addNotification, remove }; +}; + +// 알림 추가만 필요한 경우 (props drilling 제거용) +export const useAddNotification = () => { + const { addNotification } = useNotification(); + return addNotification; +}; diff --git a/src/advanced/hooks/useProducts.ts b/src/advanced/hooks/useProducts.ts new file mode 100644 index 000000000..5c19b3dcc --- /dev/null +++ b/src/advanced/hooks/useProducts.ts @@ -0,0 +1,34 @@ +import { useAtom } from "jotai"; +import { ProductWithUI } from "../../types"; +import { productsAtom } from "../stores/atoms"; +import { useAddNotification } from "./useNotification"; + +export const useProducts = () => { + const [products, setProducts] = useAtom(productsAtom); + const addNotification = useAddNotification(); + + const addProduct = (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification("상품이 추가되었습니다", "success"); + }; + + const updateProduct = (updates: Partial) => { + setProducts((prev) => + prev.map((product) => + product.id === updates.id ? { ...product, ...updates } : product + ) + ); + addNotification("상품이 수정되었습니다", "success"); + }; + + const deleteProduct = (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification("상품이 삭제되었습니다", "success"); + }; + + return { products, addProduct, updateProduct, deleteProduct }; +}; diff --git a/src/advanced/hooks/useSearch.ts b/src/advanced/hooks/useSearch.ts new file mode 100644 index 000000000..cf5c2c6d0 --- /dev/null +++ b/src/advanced/hooks/useSearch.ts @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { useDebounce } from "../utils/hooks/useDebounce"; + +export const useSearch = (delay: number = 300) => { + const [searchTerm, setSearchTerm] = useState(""); + + // 300ms 디바운스 적용 + const debouncedSearchTerm = useDebounce(searchTerm, delay); + + // 제네릭 필터링 함수 (디바운스된 값 사용) + const filterItems = >( + items: T[], + searchKeys: (keyof T)[] + ): T[] => { + if (!debouncedSearchTerm.trim()) return items; + + return items.filter((item) => + searchKeys.some((key) => + String(item[key]) + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) + ) + ); + }; + + return { + searchTerm, // 실시간 입력값 (UI 표시용) + debouncedSearchTerm, // 디바운스된 값 (필터링용) + setSearchTerm, + filterItems, + }; +}; diff --git a/src/advanced/models/cart.ts b/src/advanced/models/cart.ts new file mode 100644 index 000000000..6255a964f --- /dev/null +++ b/src/advanced/models/cart.ts @@ -0,0 +1,174 @@ +// TODO: 장바구니 비즈니스 로직 (순수 함수) +// 힌트: 모든 함수는 순수 함수로 구현 (부작용 없음, 같은 입력에 항상 같은 출력) +// +// 구현할 함수들: +// 1. calculateItemTotal(item): 개별 아이템의 할인 적용 후 총액 계산 +// 2. getMaxApplicableDiscount(item): 적용 가능한 최대 할인율 계산 +// 3. calculateCartTotal(cart, coupon): 장바구니 총액 계산 (할인 전/후, 할인액) +// 4. updateCartItemQuantity(cart, productId, quantity): 수량 변경 +// 5. addItemToCart(cart, product): 상품 추가 +// 6. removeItemFromCart(cart, productId): 상품 제거 +// 7. getRemainingStock(product, cart): 남은 재고 계산 +// +// 원칙: +// - UI와 관련된 로직 없음 +// - 외부 상태에 의존하지 않음 +// - 모든 필요한 데이터는 파라미터로 전달받음 + +import { CartItem, Coupon, Product } from "../../types"; + +export const calculateItemTotal = ( + cart: CartItem[], + item: CartItem +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(cart, item); + + return Math.round(price * quantity * (1 - discount)); +}; + +const getMaxApplicableDiscount = (cart: CartItem[], item: CartItem): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + } + + return baseDiscount; +}; + +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(cart, item); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === "amount") { + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - selectedCoupon.discountValue + ); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; + +export const addItemToCart = ( + cart: CartItem[], + product: any //ProductWithUI +): CartItem[] => { + const existingItem = cart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + return cart; + } + + return cart.map((item) => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item + ); + } + + return [...cart, { product, quantity: 1 }]; +}; + +/** + * 장바구니에서 상품 제거 + */ +export const removeItemFromCart = ( + cart: CartItem[], + productId: string +): CartItem[] => { + return cart.filter((item) => item.product.id !== productId); +}; + +/** + * 장바구니 아이템 수량 변경 + */ +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + newQuantity: number +): CartItem[] => { + // 수량이 0 이하면 제거 + if (newQuantity <= 0) { + return removeItemFromCart(cart, productId); + } + + return cart.map((item) => { + if (item.product.id === productId) { + // 재고 체크: 새 수량이 재고를 초과하면 재고만큼만 설정 + const maxQuantity = item.product.stock; + const validQuantity = Math.min(newQuantity, maxQuantity); + return { ...item, quantity: validQuantity }; + } + return item; + }); +}; + +export const getRemainingStock = ( + cart: CartItem[], + product: Product +): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +}; + +/** + * 할인 금액 계산 + */ +export const calculateDiscountAmount = ( + totalBefore: number, + totalAfter: number +): number => { + return totalBefore - totalAfter; +}; + +/** + * 장바구니 아이템의 할인 정보 계산 + */ +export const getCartItemDiscount = ( + cart: CartItem[], + item: CartItem +): { hasDiscount: boolean; discountRate: number } => { + const itemTotal = calculateItemTotal(cart, item); + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount + ? Math.round((1 - itemTotal / originalPrice) * 100) + : 0; + + return { hasDiscount, discountRate }; +}; diff --git a/src/advanced/models/coupon.ts b/src/advanced/models/coupon.ts new file mode 100644 index 000000000..55ffe6622 --- /dev/null +++ b/src/advanced/models/coupon.ts @@ -0,0 +1,40 @@ +// TODO: 쿠폰 비즈니스 로직 (순수 함수) + +import { Coupon } from "../../types"; + +export const addCouponToList = ( + coupons: Coupon[], + newCoupon: Coupon +): Coupon[] => { + const existingCoupon = coupons.find( + (coupon) => coupon.code === newCoupon.code + ); + + if (existingCoupon) { + return coupons; + } + + return [...coupons, newCoupon]; +}; + +export const deleteCouponToList = ( + coupons: Coupon[], + couponCode: string +): Coupon[] => { + return coupons.filter((coupon) => coupon.code !== couponCode); +}; +/** + * 쿠폰 할인 금액/비율을 포맷팅 + */ +export const formatCouponDiscount = (coupon: Coupon): string => { + return coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`; +}; + +/** + * 쿠폰 선택 옵션 텍스트 생성 + */ +export const getCouponOptionText = (coupon: Coupon): string => { + return `${coupon.name} (${formatCouponDiscount(coupon)})`; +}; diff --git a/src/advanced/models/discount.ts b/src/advanced/models/discount.ts new file mode 100644 index 000000000..2822b1285 --- /dev/null +++ b/src/advanced/models/discount.ts @@ -0,0 +1,28 @@ +import { Discount } from "../../types"; + +export const NEW_DISCOUNT: Discount = { quantity: 10, rate: 0.1 }; + +export const addDiscount = (discounts: Discount[]): Discount[] => { + return [...discounts, NEW_DISCOUNT]; +}; + +export const removeDiscount = ( + discounts: Discount[], + index: number +): Discount[] => { + return discounts.filter((_, i) => i !== index); +}; + +export const updateDiscount = ( + discounts: Discount[], + index: number, + updates: Partial +): Discount[] => { + return discounts.map((discount, i) => + i === index ? { ...discount, ...updates } : discount + ); +}; + +export const getDiscountRatePercent = (rate: number): number => { + return Math.round(rate * 100); +}; diff --git a/src/advanced/models/notificiation.ts b/src/advanced/models/notificiation.ts new file mode 100644 index 000000000..a9327f8a3 --- /dev/null +++ b/src/advanced/models/notificiation.ts @@ -0,0 +1,17 @@ +import { Notification } from "../../types"; + +let notificationCounter = 0; + +export const createNotification = ( + message: string, + type: Notification["type"] = "success" +): Notification => ({ + id: `${Date.now()}-${++notificationCounter}`, + message, + type, +}); + +export const removeNotification = ( + notifications: Notification[], + id: string +): Notification[] => notifications.filter((n) => n.id !== id); diff --git a/src/advanced/models/product.ts b/src/advanced/models/product.ts new file mode 100644 index 000000000..df3f6a04d --- /dev/null +++ b/src/advanced/models/product.ts @@ -0,0 +1,70 @@ +// TODO: 상품 비즈니스 로직 (순수 함수) +// 힌트: 모든 함수는 순수 함수로 구현 (부작용 없음, 같은 입력에 항상 같은 출력) + +import { Product } from "../../types"; + +const LOW_STOCK_THRESHOLD = 5; + +/** + * 상품의 최대 할인율 계산 + */ +export const getMaxDiscountRate = (product: Product): number => { + if (product.discounts.length === 0) return 0; + return Math.max(...product.discounts.map((d) => d.rate)) * 100; +}; + +/** + * 재고 상태 정보 반환 + */ +export const getStockStatus = ( + remainingStock: number +): { + isLowStock: boolean; + isOutOfStock: boolean; + message: string; + buttonText: string; +} => { + if (remainingStock <= 0) { + return { + isLowStock: false, + isOutOfStock: true, + message: "", + buttonText: "품절", + }; + } + + if (remainingStock <= LOW_STOCK_THRESHOLD) { + return { + isLowStock: true, + isOutOfStock: false, + message: `품절임박! ${remainingStock}개 남음`, + buttonText: "장바구니 담기", + }; + } + + return { + isLowStock: false, + isOutOfStock: false, + message: `재고 ${remainingStock}개`, + buttonText: "장바구니 담기", + }; +}; + +/** + * 할인 정보가 있는지 확인 + */ +export const hasDiscount = (product: Product): boolean => { + return product.discounts.length > 0; +}; + +/** + * 첫 번째 할인 정보 반환 + */ +export const getFirstDiscount = (product: Product) => { + if (!hasDiscount(product)) return null; + const discount = product.discounts[0]; + return { + quantity: discount.quantity, + rate: discount.rate * 100, + }; +}; diff --git a/src/advanced/models/validation.ts b/src/advanced/models/validation.ts new file mode 100644 index 000000000..ff9a85d94 --- /dev/null +++ b/src/advanced/models/validation.ts @@ -0,0 +1,31 @@ +/** + * 폼 검증을 위한 순수 함수들 + * 검증 실패 시 에러 메시지를 반환하고, 성공 시 null을 반환합니다. + */ + +/** + * 재고 수량 검증 + */ +export const validateStock = (value: number): string | null => { + if (value < 0) return "재고는 0 이상이어야 합니다"; + if (value === 0) return "재고는 0보다 커야 합니다"; + if (value > 9999) return "재고는 9999 이하여야 합니다"; + return null; // 검증 통과 +}; + +/** + * 가격 검증 + */ +export const validatePrice = (value: number): string | null => { + if (value <= 0) return "가격은 0보다 커야 합니다"; + return null; +}; + +/** + * 쿠폰 할인율 검증 (퍼센트 타입) + */ +export const validateDiscountRate = (value: number): string | null => { + if (value > 100) return "할인율은 100%를 초과할 수 없습니다"; + if (value < 0) return "할인율은 0 이상이어야 합니다"; + return null; +}; diff --git a/src/advanced/stores/atoms.ts b/src/advanced/stores/atoms.ts new file mode 100644 index 000000000..109be957f --- /dev/null +++ b/src/advanced/stores/atoms.ts @@ -0,0 +1,22 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; +import { CartItem, Coupon, Notification, ProductWithUI } from "../../types"; +import { initialCoupons, initialProducts } from "../constants"; + +// 장바구니 (localStorage 저장) +export const cartAtom = atomWithStorage("cart", []); + +// 상품 목록 (localStorage 저장) +export const productsAtom = atomWithStorage( + "products", + initialProducts +); + +// 쿠폰 목록 (localStorage 저장) +export const couponsAtom = atomWithStorage("coupons", initialCoupons); + +// 선택된 쿠폰 (세션 중에만 유지) +export const selectedCouponAtom = atom(null); + +// 알림 목록 (세션 중에만 유지) +export const notificationsAtom = atom([]); diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts new file mode 100644 index 000000000..f068ba0f0 --- /dev/null +++ b/src/advanced/utils/formatters.ts @@ -0,0 +1,17 @@ +// TODO: 포맷팅 유틸리티 함수들 +// 구현할 함수: +// - formatPrice(price: number): string - 가격을 한국 원화 형식으로 포맷 +// - formatDate(date: Date): string - 날짜를 YYYY-MM-DD 형식으로 포맷 +// - formatPercentage(rate: number): string - 소수를 퍼센트로 변환 (0.1 → 10%) + + +export const formatPrice = (price: number, stock: number): string => { + if (stock <= 0) { + return "SOLD OUT"; + } + return `₩${price.toLocaleString()}`; +}; + +export const formatPriceKr = (price: number): string => { + return `${price.toLocaleString()}원`; +}; diff --git a/src/advanced/utils/hooks/useDebounce.ts b/src/advanced/utils/hooks/useDebounce.ts new file mode 100644 index 000000000..c8a52fef0 --- /dev/null +++ b/src/advanced/utils/hooks/useDebounce.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; + +/** + * 디바운스 Hook + * 값이 변경되어도 지정된 시간(delay) 동안 대기 후 최종값 반환 + * 대기 시간 동안 값이 다시 변경되면 타이머 리셋 + * + * @param value - 디바운스할 값 + * @param delay - 대기 시간 (ms) + * @returns 디바운스된 값 + * + * @example + * const debouncedSearch = useDebounce(searchTerm, 300); + */ +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + // delay 후에 값 업데이트 + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // 값이 변경되면 이전 타이머 취소 (cleanup) + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/advanced/utils/hooks/useForm.ts b/src/advanced/utils/hooks/useForm.ts new file mode 100644 index 000000000..7a3997ff6 --- /dev/null +++ b/src/advanced/utils/hooks/useForm.ts @@ -0,0 +1,20 @@ +import { useState } from "react"; + +export function useForm>(initialValues: T) { + const [values, setValues] = useState(initialValues); + + const handleChange = ( + field: K, + value: T[K], + transform?: (v: any) => T[K] + ) => { + setValues((prev) => ({ + ...prev, + [field]: transform ? transform(value) : value, + })); + }; + + const resetForm = () => setValues(initialValues); + + return { values, handleChange, resetForm, setValues }; +} diff --git a/src/advanced/utils/hooks/useLocalStorage.ts b/src/advanced/utils/hooks/useLocalStorage.ts new file mode 100644 index 000000000..7b8eb34f0 --- /dev/null +++ b/src/advanced/utils/hooks/useLocalStorage.ts @@ -0,0 +1,46 @@ +// TODO: LocalStorage Hook +// 힌트: +// 1. localStorage와 React state 동기화 +// 2. 초기값 로드 시 에러 처리 +// 3. 저장 시 JSON 직렬화/역직렬화 +// 4. 빈 배열이나 undefined는 삭제 +// +// 반환값: [저장된 값, 값 설정 함수] + +import { useState } from "react"; + +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void] { + // 초기값 로드 + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(`Error loading localStorage key "${key}":`, error); + return initialValue; + } + }); + + //setValue 함수 + const setValue = (value: T | ((val: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(storedValue) : value; + if ( + valueToStore === undefined || + (Array.isArray(valueToStore) && valueToStore.length === 0) + ) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } + setStoredValue(valueToStore); + } catch (error) { + console.error(`Error setting localStorage key "${key}":`, error); + } + }; + + return [storedValue, setValue]; +} diff --git a/src/advanced/utils/hooks/useValidate.ts b/src/advanced/utils/hooks/useValidate.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/advanced/utils/validators.ts b/src/advanced/utils/validators.ts new file mode 100644 index 000000000..6f7f434f5 --- /dev/null +++ b/src/advanced/utils/validators.ts @@ -0,0 +1,35 @@ +// TODO: 검증 유틸리티 함수들 +// 구현할 함수: +// - isValidCouponCode(code: string): boolean - 쿠폰 코드 형식 검증 (4-12자 영문 대문자와 숫자) +// - isValidStock(stock: number): boolean - 재고 수량 검증 (0 이상) +// - isValidPrice(price: number): boolean - 가격 검증 (양수) +// - extractNumbers(value: string): string - 문자열에서 숫자만 추출 + +const isValidCouponCode = (code: string): boolean => { + const couponCodeRegex = /^[A-Z0-9]{4,12}$/; + return couponCodeRegex.test(code); +}; + +export const isValidPrice = (price: number): boolean => { + return !isNaN(price) && price >= 0; +}; + +export const isValidStock = (stock: number): boolean => { + return !isNaN(stock) && stock >= 0 && stock <= 9999; +}; + +const formatCouponCode = (code: string): string => { + return code.toUpperCase(); +}; + +const extractNumbers = (value: string): string => { + return value.replace(/\D/g, ""); +}; + +const parseDiscountValue = (value: string | number): number => { + if (typeof value === "number") return value; + const numeric = extractNumbers(value); + return numeric === "" ? 0 : parseInt(numeric); +}; + +export { isValidCouponCode, formatCouponCode, extractNumbers, parseDiscountValue }; diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..c455a6fe5 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,1124 +1,30 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { useState } from "react"; +import AdminPage from "./Pages/AdminPage"; +import CartPage from "./Pages/CartPage"; -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +import { useNotification } from "./hooks/useNotification"; +import NotificationList from "./components/NotificationList"; const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + const { notifications, addNotification, remove } = useNotification(); return (
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
+ + {isAdmin ? ( + setIsAdmin(false)} + /> + ) : ( + setIsAdmin(true)} + /> )} -
-
-
-
-

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} - {!isAdmin && ( -
- setSearchTerm(e.target.value)} - placeholder="상품 검색..." - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" - /> -
- )} -
- -
-
-
- -
- {isAdmin ? ( -
-
-

관리자 대시보드

-

상품과 쿠폰을 관리할 수 있습니다

-
-
- -
- - {activeTab === 'products' ? ( -
-
-
-

상품 목록

- -
-
- -
- - - - - - - - - - - - {(activeTab === 'products' ? products : products).map(product => ( - - - - - - - - ))} - -
상품명가격재고설명작업
{product.name}{formatPrice(product.price, product.id)} - 10 ? 'bg-green-100 text-green-800' : - product.stock > 0 ? 'bg-yellow-100 text-yellow-800' : - 'bg-red-100 text-red-800' - }`}> - {product.stock}개 - - {product.description || '-'} - - -
-
- {showProductForm && ( -
-
-

- {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} -

-
-
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, price: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, price: 0 }); - } else if (parseInt(value) < 0) { - addNotification('가격은 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, price: 0 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, stock: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) < 0) { - addNotification('재고는 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) > 9999) { - addNotification('재고는 9999개를 초과할 수 없습니다', 'error'); - setProductForm({ ...productForm, stock: 9999 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
-
- -
- {productForm.discounts.map((discount, index) => ( -
- { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].quantity = parseInt(e.target.value) || 0; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-20 px-2 py-1 border rounded" - min="1" - placeholder="수량" - /> - 개 이상 구매 시 - { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-16 px-2 py-1 border rounded" - min="0" - max="100" - placeholder="%" - /> - % 할인 - -
- ))} - -
-
- -
- - -
-
-
- )} -
- ) : ( -
-
-

쿠폰 관리

-
-
-
- {coupons.map(coupon => ( -
-
-
-

{coupon.name}

-

{coupon.code}

-
- - {coupon.discountType === 'amount' - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${coupon.discountValue}% 할인`} - -
-
- -
-
- ))} - -
- -
-
- - {showCouponForm && ( -
-
-

새 쿠폰 생성

-
-
- - setCouponForm({ ...couponForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === 'percentage') { - if (value > 100) { - addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} - required - /> -
-
-
- - -
-
-
- )} -
-
- )} -
- ) : ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

-
- 총 {products.length}개 상품 -
-
- {filteredProducts.length === 0 ? ( -
-

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

-
- ) : ( -
- {filteredProducts.map(product => { - const remainingStock = getRemainingStock(product); - - return ( -
- {/* 상품 이미지 영역 (placeholder) */} -
-
- - - -
- {product.isRecommended && ( - - BEST - - )} - {product.discounts.length > 0 && ( - - ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% - - )} -
- - {/* 상품 정보 */} -
-

{product.name}

- {product.description && ( -

{product.description}

- )} - - {/* 가격 정보 */} -
-

{formatPrice(product.price, product.id)}

- {product.discounts.length > 0 && ( -

- {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% -

- )} -
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

품절임박! {remainingStock}개 남음

- )} - {remainingStock > 5 && ( -

재고 {remainingStock}개

- )} -
- - {/* 장바구니 버튼 */} - -
-
- ); - })} -
- )} -
-
- -
-
-
-

- - - - 장바구니 -

- {cart.length === 0 ? ( -
- - - -

장바구니가 비어있습니다

-
- ) : ( -
- {cart.map(item => { - const itemTotal = calculateItemTotal(item); - const originalPrice = item.product.price * item.quantity; - const hasDiscount = itemTotal < originalPrice; - const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; - - return ( -
-
-

{item.product.name}

- -
-
-
- - {item.quantity} - -
-
- {hasDiscount && ( - -{discountRate}% - )} -

- {Math.round(itemTotal).toLocaleString()}원 -

-
-
-
- ); - })} -
- )} -
- - {cart.length > 0 && ( - <> -
-
-

쿠폰 할인

- -
- {coupons.length > 0 && ( - - )} -
- -
-

결제 정보

-
-
- 상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 -
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && ( -
- 할인 금액 - -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원 -
- )} -
- 결제 예정 금액 - {totals.totalAfterDiscount.toLocaleString()}원 -
-
- - - -
-

* 실제 결제는 이루어지지 않습니다

-
-
- - )} -
-
-
- )} -
); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/basic/Pages/AdminPage/CouponManagement.tsx b/src/basic/Pages/AdminPage/CouponManagement.tsx new file mode 100644 index 000000000..87f836f48 --- /dev/null +++ b/src/basic/Pages/AdminPage/CouponManagement.tsx @@ -0,0 +1,82 @@ +import { type FC, useState } from "react"; +import { useCoupons } from "../../hooks/useCoupons"; +import { Coupon } from "../../../types"; +import CouponList from "../../components/adminPage/CouponList"; +import CouponForm from "../../components/adminPage/CouponForm"; +import Section from "../../components/_common/Section"; +import { useForm } from "../../utils/hooks/useForm"; +import { formatCouponCode } from "../../utils/validators"; +import { Notification } from "../../models/notificiation"; +import { validateDiscountRate } from "../../models/validation"; + +const INITIAL_COUPON: Coupon = { + name: "", + code: "", + discountType: "amount", + discountValue: 0, +}; + +interface IProps { + addNotification: (message: string, type: Notification["type"]) => void; +} +const CouponManagement: FC = ({ addNotification }) => { + const [showCouponForm, setShowCouponForm] = useState(false); + const { + values: couponForm, + handleChange, + resetForm, + } = useForm(INITIAL_COUPON); + + const { coupons, addCoupon, deleteCoupon } = useCoupons(addNotification); + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + resetForm(); + setShowCouponForm(false); + }; + + return ( +
+ setShowCouponForm(true)} + onDelete={deleteCoupon} + /> + + {showCouponForm && ( + handleChange("name", value)} + onCodeChange={(value) => + handleChange("code", value, formatCouponCode) + } + onDiscountTypeChange={(value) => handleChange("discountType", value)} + onDiscountValueChange={(value) => { + // 빈 문자열이거나 순수 숫자가 아니면 무시 + if (value !== "" && !/^\d+$/.test(value)) { + return; // 이전 값 유지 + } + + const numValue = value === "" ? 0 : parseInt(value); + + // 퍼센트 타입일 때만 검증 + if (couponForm.discountType === "percentage") { + const error = validateDiscountRate(numValue); + if (error) { + addNotification(error, "error"); + return; + } + } + + handleChange("discountValue", numValue); + }} + onSubmit={handleCouponSubmit} + onCancel={() => setShowCouponForm(false)} + /> + )} +
+ ); +}; + +export default CouponManagement; diff --git a/src/basic/Pages/AdminPage/NaviTab.tsx b/src/basic/Pages/AdminPage/NaviTab.tsx new file mode 100644 index 000000000..822989766 --- /dev/null +++ b/src/basic/Pages/AdminPage/NaviTab.tsx @@ -0,0 +1,31 @@ +import { type FC } from "react"; +import TabButton from "../../components/_common/TabButton"; + +interface IProps { + activeTab: "products" | "coupons"; + onChange: (prev: "products" | "coupons") => void; +} + +const NaviTab: FC = ({ activeTab, onChange }) => { + const TABS = [ + { id: "products" as const, label: "상품 관리" }, + { id: "coupons" as const, label: "쿠폰 관리" }, + ]; + + return ( +
+ +
+ ); +}; + +export default NaviTab; diff --git a/src/basic/Pages/AdminPage/ProductManagement.tsx b/src/basic/Pages/AdminPage/ProductManagement.tsx new file mode 100644 index 000000000..677418a0d --- /dev/null +++ b/src/basic/Pages/AdminPage/ProductManagement.tsx @@ -0,0 +1,140 @@ +import { useState, type FC } from "react"; +import ProductListTable from "../../components/adminPage/ProductListTable"; +import ProductForm from "../../components/adminPage/ProductForm"; +import { useProducts } from "../../hooks/useProducts"; +import { ProductWithUI } from "../../../types"; +import Section from "../../components/_common/Section"; +import Button from "../../components/_common/Button"; +import { useForm } from "../../utils/hooks/useForm"; +import { Notification } from "../../models/notificiation"; +import { validateStock, validatePrice } from "../../models/validation"; + +const INITIAL_PRODUCT_FORM: Omit = { + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], +}; +interface IProps { + addNotification: (message: string, type: Notification["type"]) => void; +} +const ProductManagement: FC = ({ addNotification }) => { + const [showForm, setShowForm] = useState(false); + const [editingProductId, setEditingProductId] = useState(null); + const { + values: productForm, + handleChange, + resetForm, + setValues: setProductForm, + } = useForm>(INITIAL_PRODUCT_FORM); + + const { products, addProduct, updateProduct, deleteProduct } = + useProducts(addNotification); + + const handleAddNew = () => { + resetForm(); + setEditingProductId(null); + setShowForm(true); + }; + + const handleEditStart = (product: ProductWithUI) => { + const { id, ...formData } = product; + setProductForm(formData); + setEditingProductId(id); + setShowForm(true); + }; + + const saveProduct = () => { + if (editingProductId) { + updateProduct({ ...productForm, id: editingProductId }); + } else { + addProduct(productForm); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + saveProduct(); + handleClose(); + }; + + const handleClose = () => { + setShowForm(false); + setEditingProductId(null); + }; + + const handleNameChange = (value: string) => handleChange("name", value); + const handleDescriptionChange = (value: string) => + handleChange("description", value); + const handlePriceChange = (value: string) => { + // 빈 문자열이거나 순수 숫자가 아니면 무시 + if (value !== "" && !/^\d+$/.test(value)) { + return; // 이전 값 유지 + } + + const numValue = value === "" ? 0 : parseInt(value); + const error = validatePrice(numValue); + + if (error) { + addNotification(error, "error"); + return; + } + + handleChange("price", numValue); + }; + const handleStockChange = (value: string) => { + // 빈 문자열이거나 순수 숫자가 아니면 무시 + if (value !== "" && !/^\d+$/.test(value)) { + return; // 이전 값 유지 + } + + const numValue = value === "" ? 0 : parseInt(value); + const error = validateStock(numValue); + + if (error) { + addNotification(error, "error"); + return; + } + + handleChange("stock", numValue); + }; + const handleDiscountsChange = (value: any) => + handleChange("discounts", value); + + return ( +
+ 새 상품 추가 + + }> + + + {showForm && ( + + )} +
+ ); +}; + +export default ProductManagement; diff --git a/src/basic/Pages/AdminPage/index.tsx b/src/basic/Pages/AdminPage/index.tsx new file mode 100644 index 000000000..c5ddccec2 --- /dev/null +++ b/src/basic/Pages/AdminPage/index.tsx @@ -0,0 +1,43 @@ +import { useState, type FC } from "react"; +import ProductManagement from "./ProductManagement"; +import CouponManagement from "./CouponManagement"; +import NaviTab from "./NaviTab"; +import { Notification } from "../../models/notificiation"; +import AdminHeader from "../../components/layout/AdminHeader"; + +interface IProps { + onChange: () => void; + addNotification: (message: string, type: Notification["type"]) => void; +} + +const AdminPage: FC = ({ addNotification, onChange }) => { + const [activeTab, setActiveTab] = useState<"products" | "coupons">( + "products" + ); + return ( + <> + +
+
+
+

+ 관리자 대시보드 +

+

+ 상품과 쿠폰을 관리할 수 있습니다 +

+
+ + + {activeTab === "products" ? ( + + ) : ( + + )} +
+
+ + ); +}; + +export default AdminPage; diff --git a/src/basic/Pages/CartPage/CartSummary.tsx b/src/basic/Pages/CartPage/CartSummary.tsx new file mode 100644 index 000000000..81863aa23 --- /dev/null +++ b/src/basic/Pages/CartPage/CartSummary.tsx @@ -0,0 +1,77 @@ +import { type FC } from "react"; +import CartItems from "../../components/cartPage/CartItems"; +import PayItem from "../../components/cartPage/PayItem"; +import ShoppingBagIcon from "../../components/_icons/ShoppingBagIcon"; +import CouponSelector from "../../components/cartPage/CouponSelector"; +import { calculateCartTotal } from "../../models/cart"; +import { CartItem, Coupon } from "../../../types"; +import { Notification } from "../../models/notificiation"; + +interface IProps { + cart: CartItem[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon | null) => void; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + emptyCart: () => void; + addNotification: (message: string, type: Notification["type"]) => void; +} + +const CartSummary: FC = ({ + cart, + coupons, + selectedCoupon, + applyCoupon, + removeFromCart, + updateQuantity, + emptyCart, + addNotification, +}) => { + const totals = calculateCartTotal(cart, selectedCoupon); + + const handleCompleteOrder = () => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + "success" + ); + emptyCart(); + }; + + return ( +
+
+

+ + 장바구니 +

+ {cart.length === 0 ? ( +
+ +

장바구니가 비어있습니다

+
+ ) : ( + + )} +
+ + {cart.length > 0 && ( + <> + + + + )} +
+ ); +}; + +export default CartSummary; diff --git a/src/basic/Pages/CartPage/ProductCards.tsx b/src/basic/Pages/CartPage/ProductCards.tsx new file mode 100644 index 000000000..d9710fcda --- /dev/null +++ b/src/basic/Pages/CartPage/ProductCards.tsx @@ -0,0 +1,57 @@ +import { type FC } from "react"; +import ProductCard from "../../components/cartPage/ProductCard"; +import { ProductWithUI } from "../../../types"; + +interface IProps { + searchTerm?: string; + products: ProductWithUI[]; + addToCart: (product: ProductWithUI) => void; + getStock: (product: ProductWithUI) => number; +} + +const ProductCards: FC = ({ + searchTerm, + products, + addToCart, + getStock, +}) => { + const filteredProducts = searchTerm + ? products.filter( + (product) => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(searchTerm.toLowerCase())) + ) + : products; + + return ( +
+
+

전체 상품

+
총 {products.length}개 상품
+
+ {filteredProducts.length === 0 ? ( +
+

+ "{searchTerm}"에 대한 검색 결과가 없습니다. +

+
+ ) : ( +
+ {filteredProducts.map((product) => ( + + ))} +
+ )} +
+ ); +}; + +export default ProductCards; diff --git a/src/basic/Pages/CartPage/index.tsx b/src/basic/Pages/CartPage/index.tsx new file mode 100644 index 000000000..f5e6ebeb6 --- /dev/null +++ b/src/basic/Pages/CartPage/index.tsx @@ -0,0 +1,69 @@ +import { type FC } from "react"; +import ProductCards from "./ProductCards"; +import CartSummary from "./CartSummary"; +import { useCart } from "../../hooks/useCart"; +import { Notification } from "../../models/notificiation"; +import { useProducts } from "../../hooks/useProducts"; +import { useCoupons } from "../../hooks/useCoupons"; +import CartHeader from "../../components/layout/CartHeader"; +import { useSearch } from "../../hooks/useSearch"; + +interface IProps { + onChange: () => void; + addNotification: (message: string, type: Notification["type"]) => void; +} + +const CartPage: FC = ({ addNotification, onChange }) => { + const { + cart, + selectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + emptyCart, + getStock, + applyCoupon, + } = useCart(addNotification); + + const { products } = useProducts(addNotification); + const { coupons } = useCoupons(addNotification); + const { searchTerm, setSearchTerm } = useSearch(); + const totalCartCount = cart.reduce((sum, item) => sum + item.quantity, 0); + return ( + <> + +
+
+
+ +
+ +
+ +
+
+
+ + ); +}; + +export default CartPage; diff --git a/src/basic/components/NotificationList/index.tsx b/src/basic/components/NotificationList/index.tsx new file mode 100644 index 000000000..398a10aa9 --- /dev/null +++ b/src/basic/components/NotificationList/index.tsx @@ -0,0 +1,43 @@ +import { type FC } from "react"; +import CloseIcon from "../_icons/CloseIcon"; +import Button from "../_common/Button"; +import { Notification } from "../../models/notificiation"; + +interface IProps { + notifications: Notification[]; + onClose: (id: string) => void; +} + +const NotificationList: FC = ({ notifications, onClose }) => { + if (notifications.length === 0) return null; + + const getNotificationClass = (type: Notification["type"]) => { + const baseClass = + "p-4 rounded-md shadow-md text-white flex justify-between items-center"; + const colorMap = { + error: "bg-red-600", + warning: "bg-yellow-600", + success: "bg-green-600", + }; + return `${baseClass} ${colorMap[type]}`; + }; + + return ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ); +}; + +export default NotificationList; diff --git a/src/basic/components/_common/Button.tsx b/src/basic/components/_common/Button.tsx new file mode 100644 index 000000000..d2f07896a --- /dev/null +++ b/src/basic/components/_common/Button.tsx @@ -0,0 +1,71 @@ +import { type FC, type ButtonHTMLAttributes } from "react"; + +interface IProps extends ButtonHTMLAttributes { + variant?: "solid" | "outline" | "ghost"; + color?: "primary" | "secondary" | "danger" | "indigo" | "gray"; + size?: "sm" | "md" | "lg"; +} + +const Button: FC = ({ + variant = "solid", + color = "primary", + size = "md", + className = "", + disabled = false, + children, + ...props +}) => { + const baseClass = + "rounded font-medium transition-colors flex items-center justify-center"; + + const colorClass = { + primary: { + solid: "bg-yellow-400 text-gray-900 hover:bg-yellow-500", + outline: "border border-yellow-400 text-yellow-600 hover:bg-yellow-50", + ghost: "text-yellow-600 hover:bg-yellow-50", + }, + secondary: { + solid: "bg-gray-900 text-white hover:bg-gray-800", + outline: "border border-gray-900 text-gray-900 hover:bg-gray-50", + ghost: "text-gray-900 hover:bg-gray-100", + }, + gray: { + solid: "bg-gray-200 text-gray-800 hover:bg-gray-300", + outline: "border border-gray-300 text-gray-700 hover:bg-gray-50", + ghost: "text-gray-600 hover:text-gray-900 hover:bg-gray-100", + }, + danger: { + solid: "bg-red-600 text-white hover:bg-red-700", + outline: "border border-red-600 text-red-600 hover:bg-red-50", + ghost: "text-gray-400 hover:text-red-500 hover:bg-red-50", + }, + indigo: { + solid: "bg-indigo-600 text-white hover:bg-indigo-700", + outline: "border border-indigo-600 text-indigo-600 hover:bg-indigo-50", + ghost: "text-indigo-600 hover:bg-indigo-50", + }, + }[color][variant]; + + const sizeClass = { + sm: "px-3 py-1.5 text-sm", + md: "py-2 px-4 text-base", + lg: "py-3 px-6 text-lg", + }[size]; + + const disabledClass = disabled + ? "bg-gray-100 text-gray-400 cursor-not-allowed border-gray-100 hover:bg-gray-100 hover:text-gray-400" + : ""; + + return ( + + ); +}; + +export default Button; diff --git a/src/basic/components/_common/FormInput.tsx b/src/basic/components/_common/FormInput.tsx new file mode 100644 index 000000000..e891ece54 --- /dev/null +++ b/src/basic/components/_common/FormInput.tsx @@ -0,0 +1,33 @@ +import type { InputHTMLAttributes, FC } from "react"; + +interface IProps extends InputHTMLAttributes { + label: string; + value?: string | number; + onValueChange: (value: string) => void; +} + +const FormInput: FC = ({ + label, + value, + onValueChange, + required = false, + placeholder, +}) => { + return ( +
+ + onValueChange(e.target.value)} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder={placeholder} + required={required} + /> +
+ ); +}; + +export default FormInput; diff --git a/src/basic/components/_common/Section.tsx b/src/basic/components/_common/Section.tsx new file mode 100644 index 000000000..86b200b73 --- /dev/null +++ b/src/basic/components/_common/Section.tsx @@ -0,0 +1,21 @@ +import { type FC, ReactNode } from "react"; + +interface IProps { + title: ReactNode; + children: ReactNode; + action?: ReactNode; +} + +const Section: FC = ({ title, children, action }) => { + return ( +
+
+

{title}

+ {action &&
{action}
} +
+
{children}
+
+ ); +}; + +export default Section; diff --git a/src/basic/components/_common/TabButton.tsx b/src/basic/components/_common/TabButton.tsx new file mode 100644 index 000000000..7580e08a8 --- /dev/null +++ b/src/basic/components/_common/TabButton.tsx @@ -0,0 +1,23 @@ +import { type FC } from "react"; + +interface IProps { + label: string; + isActive: boolean; + onClick: () => void; +} + +const TabButton: FC = ({ label, isActive, onClick }) => { + return ( + + ); +}; + +export default TabButton; diff --git a/src/basic/components/_icons/CartIcon.tsx b/src/basic/components/_icons/CartIcon.tsx new file mode 100644 index 000000000..4b0caa48a --- /dev/null +++ b/src/basic/components/_icons/CartIcon.tsx @@ -0,0 +1,22 @@ +import { type FC } from "react"; + +interface IProps {} + +const CartIcon: FC = ({}) => { + return ( + + + + ); +}; + +export default CartIcon; diff --git a/src/basic/components/_icons/CloseIcon.tsx b/src/basic/components/_icons/CloseIcon.tsx new file mode 100644 index 000000000..45ab27487 --- /dev/null +++ b/src/basic/components/_icons/CloseIcon.tsx @@ -0,0 +1,20 @@ +import { type FC } from "react"; + +const CloseIcon: FC = () => { + return ( + + + + ); +}; + +export default CloseIcon; diff --git a/src/basic/components/_icons/ImagePlaceholderIcon.tsx b/src/basic/components/_icons/ImagePlaceholderIcon.tsx new file mode 100644 index 000000000..ffa1f4717 --- /dev/null +++ b/src/basic/components/_icons/ImagePlaceholderIcon.tsx @@ -0,0 +1,20 @@ +import { type FC } from "react"; + +const ImagePlaceholderIcon: FC = () => { + return ( + + + + ); +}; + +export default ImagePlaceholderIcon; diff --git a/src/basic/components/_icons/PlusIcon.tsx b/src/basic/components/_icons/PlusIcon.tsx new file mode 100644 index 000000000..75a2fefc0 --- /dev/null +++ b/src/basic/components/_icons/PlusIcon.tsx @@ -0,0 +1,22 @@ +import { type FC } from "react"; + +interface IProps {} + +const PlusIcon: FC = ({}) => { + return ( + + + + ); +}; + +export default PlusIcon; diff --git a/src/basic/components/_icons/ShoppingBagIcon.tsx b/src/basic/components/_icons/ShoppingBagIcon.tsx new file mode 100644 index 000000000..97770d427 --- /dev/null +++ b/src/basic/components/_icons/ShoppingBagIcon.tsx @@ -0,0 +1,24 @@ +import { type FC } from "react"; + +interface IProps { + className?: string; +} + +const ShoppingBagIcon: FC = ({ className }) => { + return ( + + + + ); +}; + +export default ShoppingBagIcon; diff --git a/src/basic/components/_icons/TrashIcon.tsx b/src/basic/components/_icons/TrashIcon.tsx new file mode 100644 index 000000000..a09cd0440 --- /dev/null +++ b/src/basic/components/_icons/TrashIcon.tsx @@ -0,0 +1,22 @@ +import { type FC } from "react"; + +interface IProps {} + +const TrashIcon: FC = ({}) => { + return ( + + + + ); +}; + +export default TrashIcon; diff --git a/src/basic/components/adminPage/CouponForm/index.tsx b/src/basic/components/adminPage/CouponForm/index.tsx new file mode 100644 index 000000000..1a820c681 --- /dev/null +++ b/src/basic/components/adminPage/CouponForm/index.tsx @@ -0,0 +1,87 @@ +import { type FC } from "react"; +import Button from "../../_common/Button"; +import { Coupon } from "../../../../types"; +import FormInput from "../../_common/FormInput"; + +interface IProps { + couponForm: Coupon; + onNameChange: (value: string) => void; + onCodeChange: (value: string) => void; + onDiscountTypeChange: (type: "amount" | "percentage") => void; + onDiscountValueChange: (value: string) => void; + onSubmit: (e: React.FormEvent) => void; + onCancel: () => void; +} + +const CouponForm: FC = ({ + couponForm, + onNameChange, + onCodeChange, + onDiscountTypeChange, + onDiscountValueChange, + onSubmit, + onCancel, +}) => { + return ( +
+
+

새 쿠폰 생성

+
+ + +
+ + +
+ +
+
+ + +
+
+
+ ); +}; + +export default CouponForm; diff --git a/src/basic/components/adminPage/CouponList/CouponCard.tsx b/src/basic/components/adminPage/CouponList/CouponCard.tsx new file mode 100644 index 000000000..92663f7b9 --- /dev/null +++ b/src/basic/components/adminPage/CouponList/CouponCard.tsx @@ -0,0 +1,39 @@ +import { type FC } from "react"; +import { Coupon } from "../../../../types"; +import Button from "../../_common/Button"; +import TrashIcon from "../../_icons/TrashIcon"; +import { formatCouponDiscount } from "../../../models/coupon"; + +interface IProps { + coupon: Coupon; + onDelete: (couponCode: string) => void; +} + +const CouponCard: FC = ({ coupon, onDelete }) => { + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {formatCouponDiscount(coupon)} + +
+
+ +
+
+ ); +}; + +export default CouponCard; diff --git a/src/basic/components/adminPage/CouponList/index.tsx b/src/basic/components/adminPage/CouponList/index.tsx new file mode 100644 index 000000000..23b567409 --- /dev/null +++ b/src/basic/components/adminPage/CouponList/index.tsx @@ -0,0 +1,34 @@ +import { type FC } from "react"; +import CouponCard from "./CouponCard"; +import Button from "../../_common/Button"; +import PlusIcon from "../../_icons/PlusIcon"; +import { Coupon } from "../../../../types"; + +interface IProps { + coupons: Coupon[]; + onAddClick: () => void; + onDelete: (couponCode: string) => void; +} + +const CouponList: FC = ({ coupons, onAddClick, onDelete }) => { + return ( +
+ {coupons.map((coupon) => ( + + ))} + +
+ +
+
+ ); +}; + +export default CouponList; diff --git a/src/basic/components/adminPage/ProductForm/DiscountForm.tsx b/src/basic/components/adminPage/ProductForm/DiscountForm.tsx new file mode 100644 index 000000000..b7908232a --- /dev/null +++ b/src/basic/components/adminPage/ProductForm/DiscountForm.tsx @@ -0,0 +1,90 @@ +import { type FC } from "react"; +import Button from "../../_common/Button"; +import { Discount } from "../../../../types"; +import CloseIcon from "../../_icons/CloseIcon"; +import { + addDiscount, + removeDiscount, + updateDiscount, + getDiscountRatePercent, +} from "../../../models/discount"; + +interface IProps { + discounts: Discount[]; + onChange: (newDiscounts: Discount[]) => void; +} + +const DiscountForm: FC = ({ discounts, onChange }) => { + const handleQuantityChange = (index: number, value: number) => { + onChange(updateDiscount(discounts, index, { quantity: value })); + }; + + const handleRateChange = (index: number, ratePercent: number) => { + onChange(updateDiscount(discounts, index, { rate: ratePercent / 100 })); + }; + + const handleRemoveDiscount = (index: number) => { + onChange(removeDiscount(discounts, index)); + }; + + const handleAddDiscount = () => { + onChange(addDiscount(discounts)); + }; + + return ( +
+ +
+ {discounts.map((discount, index) => ( +
+ + handleQuantityChange(index, parseInt(e.target.value) || 0) + } + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + + handleRateChange(index, parseInt(e.target.value) || 0) + } + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ ); +}; + +export default DiscountForm; diff --git a/src/basic/components/adminPage/ProductForm/index.tsx b/src/basic/components/adminPage/ProductForm/index.tsx new file mode 100644 index 000000000..c9a42df7d --- /dev/null +++ b/src/basic/components/adminPage/ProductForm/index.tsx @@ -0,0 +1,92 @@ +import { type FC } from "react"; +import FormInput from "../../_common/FormInput"; +import Button from "../../_common/Button"; +import DiscountForm from "./DiscountForm"; +import { ProductWithUI, Discount } from "../../../../types"; + +interface IProps { + productForm: Omit; + onNameChange: (value: string) => void; + onDescriptionChange: (value: string) => void; + onPriceChange: (value: string) => void; + onStockChange: (value: string) => void; + onDiscountsChange: (value: Discount[]) => void; + onSubmit: (e: React.FormEvent) => void; + onClose: () => void; + isEditing: boolean; +} + +const ProductForm: FC = ({ + productForm, + onNameChange, + onDescriptionChange, + onPriceChange, + onStockChange, + onDiscountsChange, + onSubmit, + onClose, + isEditing, +}) => { + return ( +
+
+

+ {isEditing ? "상품 수정" : "새 상품 추가"} +

+ +
+ + + + + + + +
+ + + +
+ + +
+ +
+ ); +}; + +export default ProductForm; diff --git a/src/basic/components/adminPage/ProductListTable/ProductItem.tsx b/src/basic/components/adminPage/ProductListTable/ProductItem.tsx new file mode 100644 index 000000000..49fe0c91b --- /dev/null +++ b/src/basic/components/adminPage/ProductListTable/ProductItem.tsx @@ -0,0 +1,57 @@ +import { type FC } from "react"; +import Button from "../../_common/Button"; +import { ProductWithUI } from "../../../../types"; +import { formatPriceKr } from "../../../utils/formatters"; + +interface IProps { + product: ProductWithUI; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +} + +const ProductItem: FC = ({ product, onEdit, onDelete }) => { + return ( + + + {product.name} + + + {formatPriceKr(product.price)} + + + 10 + ? "bg-green-100 text-green-800" + : product.stock > 0 + ? "bg-yellow-100 text-yellow-800" + : "bg-red-100 text-red-800" + }`}> + {product.stock}개 + + + + {product.description || "-"} + + + + + + + ); +}; + +export default ProductItem; diff --git a/src/basic/components/adminPage/ProductListTable/index.tsx b/src/basic/components/adminPage/ProductListTable/index.tsx new file mode 100644 index 000000000..c28eec0e6 --- /dev/null +++ b/src/basic/components/adminPage/ProductListTable/index.tsx @@ -0,0 +1,49 @@ +import { type FC } from "react"; +import { ProductWithUI } from "../../../../types"; +import ProductItem from "./ProductItem"; + +interface IProps { + products: ProductWithUI[]; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +} + +const ProductListTable: FC = ({ products, onEdit, onDelete }) => { + return ( +
+ + + + + + + + + + + + {products.map((product) => ( + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+
+ ); +}; + +export default ProductListTable; diff --git a/src/basic/components/cartPage/CartItems/CartItem.tsx b/src/basic/components/cartPage/CartItems/CartItem.tsx new file mode 100644 index 000000000..452b63f53 --- /dev/null +++ b/src/basic/components/cartPage/CartItems/CartItem.tsx @@ -0,0 +1,77 @@ +import { type FC } from "react"; +import { CartItem as TCartItem } from "../../../../types"; +import Button from "../../_common/Button"; +import CloseIcon from "../../_icons/CloseIcon"; + +interface IProps { + item: TCartItem; + itemTotal: number; + hasDiscount: boolean; + discountRate: number; + onRemove: (productId: string) => void; + onUpdateQuantity: (productId: string, quantity: number) => void; +} + +const CartItem: FC = ({ + item, + itemTotal, + hasDiscount, + discountRate, + onRemove, + onUpdateQuantity, +}) => { + return ( +
+
+

+ {item.product.name} +

+ +
+
+
+ + + {item.quantity} + + +
+
+ {hasDiscount && ( + + -{discountRate}% + + )} +

+ {Math.round(itemTotal).toLocaleString()}원 +

+
+
+
+ ); +}; + +export default CartItem; diff --git a/src/basic/components/cartPage/CartItems/index.tsx b/src/basic/components/cartPage/CartItems/index.tsx new file mode 100644 index 000000000..c750c32f0 --- /dev/null +++ b/src/basic/components/cartPage/CartItems/index.tsx @@ -0,0 +1,35 @@ +import { type FC } from "react"; +import { CartItem as TCartItem } from "../../../../types"; +import CartItemRow from "./CartItem"; +import { calculateItemTotal, getCartItemDiscount } from "../../../models/cart"; + +interface IProps { + cart: TCartItem[]; + onRemove: (productId: string) => void; + onUpdateQuantity: (productId: string, quantity: number) => void; +} + +const CartItems: FC = ({ cart, onRemove, onUpdateQuantity }) => { + return ( +
+ {cart.map((item) => { + const itemTotal = calculateItemTotal(cart, item); + const { hasDiscount, discountRate } = getCartItemDiscount(cart, item); + + return ( + + ); + })} +
+ ); +}; + +export default CartItems; diff --git a/src/basic/components/cartPage/CouponSelector.tsx b/src/basic/components/cartPage/CouponSelector.tsx new file mode 100644 index 000000000..e35ff6751 --- /dev/null +++ b/src/basic/components/cartPage/CouponSelector.tsx @@ -0,0 +1,37 @@ +import { type FC } from "react"; +import { Coupon } from "../../../types"; +import { getCouponOptionText } from "../../models/coupon"; + +interface IProps { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + onApply: (coupon: Coupon | null) => void; +} + +const CouponSelector: FC = ({ coupons, selectedCoupon, onApply }) => { + return ( +
+
+

쿠폰 할인

+
+ {coupons.length > 0 && ( + + )} +
+ ); +}; + +export default CouponSelector; diff --git a/src/basic/components/cartPage/PayItem.tsx b/src/basic/components/cartPage/PayItem.tsx new file mode 100644 index 000000000..863ff0bfd --- /dev/null +++ b/src/basic/components/cartPage/PayItem.tsx @@ -0,0 +1,58 @@ +import { type FC } from "react"; +import { calculateDiscountAmount } from "../../models/cart"; +import Button from "../_common/Button"; + +interface IProps { + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + onCheckout: () => void; +} + +const PayItem: FC = ({ totals, onCheckout }) => { + const discountAmount = calculateDiscountAmount( + totals.totalBeforeDiscount, + totals.totalAfterDiscount + ); + + return ( +
+

결제 정보

+
+
+ 상품 금액 + + {totals.totalBeforeDiscount.toLocaleString()}원 + +
+ {discountAmount > 0 && ( +
+ 할인 금액 + -{discountAmount.toLocaleString()}원 +
+ )} +
+ 결제 예정 금액 + + {totals.totalAfterDiscount.toLocaleString()}원 + +
+
+ + +
+

* 실제 결제는 이루어지지 않습니다

+
+
+ ); +}; + +export default PayItem; diff --git a/src/basic/components/cartPage/ProductCard/DiscountInfo.tsx b/src/basic/components/cartPage/ProductCard/DiscountInfo.tsx new file mode 100644 index 000000000..b5f0161ec --- /dev/null +++ b/src/basic/components/cartPage/ProductCard/DiscountInfo.tsx @@ -0,0 +1,16 @@ +import { type FC } from "react"; + +interface IProps { + quantity: number; + rate: number; +} + +const DiscountInfo: FC = ({ quantity, rate }) => { + return ( +

+ {quantity}개 이상 구매시 할인 {rate}% +

+ ); +}; + +export default DiscountInfo; diff --git a/src/basic/components/cartPage/ProductCard/ProductBadge.tsx b/src/basic/components/cartPage/ProductCard/ProductBadge.tsx new file mode 100644 index 000000000..759e9f92a --- /dev/null +++ b/src/basic/components/cartPage/ProductCard/ProductBadge.tsx @@ -0,0 +1,28 @@ +import { type FC } from "react"; + +interface IProps { + type: "best" | "discount"; + value?: number; +} + +const ProductBadge: FC = ({ type, value }) => { + if (type === "best") { + return ( + + BEST + + ); + } + + if (type === "discount" && value !== undefined) { + return ( + + ~{value}% + + ); + } + + return null; +}; + +export default ProductBadge; diff --git a/src/basic/components/cartPage/ProductCard/StockStatus.tsx b/src/basic/components/cartPage/ProductCard/StockStatus.tsx new file mode 100644 index 000000000..9ec5c2c81 --- /dev/null +++ b/src/basic/components/cartPage/ProductCard/StockStatus.tsx @@ -0,0 +1,21 @@ +import { type FC } from "react"; + +interface IProps { + isLowStock: boolean; + message: string; +} + +const StockStatus: FC = ({ isLowStock, message }) => { + if (!message) return null; + + return ( +

+ {message} +

+ ); +}; + +export default StockStatus; diff --git a/src/basic/components/cartPage/ProductCard/index.tsx b/src/basic/components/cartPage/ProductCard/index.tsx new file mode 100644 index 000000000..1b18eba21 --- /dev/null +++ b/src/basic/components/cartPage/ProductCard/index.tsx @@ -0,0 +1,82 @@ +import { type FC } from "react"; +import { ProductWithUI } from "../../../../types"; +import { formatPrice } from "../../../utils/formatters"; +import { + getMaxDiscountRate, + getStockStatus, + getFirstDiscount, +} from "../../../models/product"; +import Button from "../../_common/Button"; +import ImagePlaceholderIcon from "../../_icons/ImagePlaceholderIcon"; +import ProductBadge from "./ProductBadge"; +import StockStatus from "./StockStatus"; +import DiscountInfo from "./DiscountInfo"; + +interface IProps { + product: ProductWithUI; + remainingStock: number; + onAddToCart: (product: ProductWithUI) => void; +} + +const ProductCard: FC = ({ product, onAddToCart, remainingStock }) => { + const maxDiscountRate = getMaxDiscountRate(product); + const stockStatus = getStockStatus(remainingStock); + const firstDiscount = getFirstDiscount(product); + + return ( +
+ {/* 상품 이미지 영역 */} +
+
+ +
+ {product.isRecommended && } + {maxDiscountRate > 0 && ( + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

+ )} + +
+

+ {formatPrice(product.price, remainingStock)} +

+ {firstDiscount && ( + + )} +
+ + {/* 재고 상태 */} +
+ +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; + +export default ProductCard; diff --git a/src/basic/components/layout/AdminHeader/index.tsx b/src/basic/components/layout/AdminHeader/index.tsx new file mode 100644 index 000000000..786daa0b8 --- /dev/null +++ b/src/basic/components/layout/AdminHeader/index.tsx @@ -0,0 +1,21 @@ +import { type FC } from "react"; +import HeaderLayout from "../_common/HeaderLayout"; +import Button from "../../_common/Button"; + +interface IProps { + onChange: () => void; +} + +const AdminHeader: FC = ({ onChange }) => { + return ( + + 쇼핑몰로 돌아가기 + + } + /> + ); +}; + +export default AdminHeader; diff --git a/src/basic/components/layout/CartHeader/CartBadge.tsx b/src/basic/components/layout/CartHeader/CartBadge.tsx new file mode 100644 index 000000000..ad833c03d --- /dev/null +++ b/src/basic/components/layout/CartHeader/CartBadge.tsx @@ -0,0 +1,21 @@ +import { type FC } from "react"; +import CartIcon from "../../_icons/CartIcon"; + +interface IProps { + count: number; +} + +const CartBadge: FC = ({ count }) => { + return ( +
+ + {count > 0 && ( + + {count} + + )} +
+ ); +}; + +export default CartBadge; diff --git a/src/basic/components/layout/CartHeader/SearchBar.tsx b/src/basic/components/layout/CartHeader/SearchBar.tsx new file mode 100644 index 000000000..93ca18e71 --- /dev/null +++ b/src/basic/components/layout/CartHeader/SearchBar.tsx @@ -0,0 +1,22 @@ +import { type FC } from "react"; + +interface IProps { + searchTerm : string; + setSearchTerm : (v : string) => void; +} + +const SearchBar: FC = ({searchTerm, setSearchTerm}) => { + return ( +
+ setSearchTerm(e.target.value)} + placeholder="상품 검색..." + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ ); +}; + +export default SearchBar; diff --git a/src/basic/components/layout/CartHeader/index.tsx b/src/basic/components/layout/CartHeader/index.tsx new file mode 100644 index 000000000..f275070b4 --- /dev/null +++ b/src/basic/components/layout/CartHeader/index.tsx @@ -0,0 +1,35 @@ +import { type FC } from "react"; +import CartBadge from "./CartBadge"; +import SearchBar from "./SearchBar"; +import HeaderLayout from "../_common/HeaderLayout"; +import Button from "../../_common/Button"; + +interface IProps { + searchTerm: string; + totalCount: number; + onChange: () => void; + setSearchTerm: (searchTerm: string) => void; +} + +const CartHeader: FC = ({ + searchTerm, + totalCount, + onChange, + setSearchTerm, +}) => { + return ( + } + right={ + <> + + + + } + /> + ); +}; + +export default CartHeader; diff --git a/src/basic/components/layout/_common/HeaderLayout.tsx b/src/basic/components/layout/_common/HeaderLayout.tsx new file mode 100644 index 000000000..33651fe32 --- /dev/null +++ b/src/basic/components/layout/_common/HeaderLayout.tsx @@ -0,0 +1,24 @@ +import { type FC, ReactNode } from "react"; + +interface IProps { + left?: ReactNode; + right?: ReactNode; +} + +const HeaderLayout: FC = ({ left, right }) => { + return ( +
+
+
+
+

SHOP

+ {left} +
+ +
+
+
+ ); +}; + +export default HeaderLayout; diff --git a/src/basic/constants/index.ts b/src/basic/constants/index.ts new file mode 100644 index 000000000..e91a5e3ab --- /dev/null +++ b/src/basic/constants/index.ts @@ -0,0 +1,57 @@ +// 정의할 상수들: +// - initialProducts: 초기 상품 목록 (상품1, 상품2, 상품3 + 설명 필드 포함) +// - initialCoupons: 초기 쿠폰 목록 (5000원 할인, 10% 할인) +// +// 참고: origin/App.tsx의 초기 데이터 구조를 참조 + +import { Coupon, ProductWithUI } from "../../types"; + +// 초기 데이터 +export const initialProducts: ProductWithUI[] = [ + { + id: "p1", + name: "상품1", + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: "최고급 품질의 프리미엄 상품입니다.", + }, + { + id: "p2", + name: "상품2", + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, + }, + { + id: "p3", + name: "상품3", + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; + +export const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 000000000..678de3650 --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,94 @@ +import { useState } from "react"; +import { CartItem, Coupon, ProductWithUI } from "../../types"; +import { + addItemToCart, + calculateCartTotal, + getRemainingStock, + removeItemFromCart, + updateCartItemQuantity, +} from "../models/cart"; +import { Notification } from "../models/notificiation"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; + +export const useCart = ( + addNotification: (message: string, type: Notification["type"]) => void +) => { + const [cart, setCart] = useLocalStorage("cart", []); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + const addToCart = (product: ProductWithUI) => { + if (getStock(product) <= 0) { + addNotification("재고가 부족합니다!", "error"); + return; + } + setCart((prev) => { + const newCart = addItemToCart(prev, product); + + if (newCart === prev) { + addNotification(`재고는 ${product.stock}개까지만 있습니다`, "error"); + } + addNotification("장바구니에 담았습니다", "success"); + return newCart; + }); + }; + + const removeFromCart = (productId: string) => { + setCart((prev) => removeItemFromCart(prev, productId)); + }; + + const updateQuantity = (productId: string, newQuantity: number) => { + setCart((prev) => { + const item = prev.find((i) => i.product.id === productId); + if (item && newQuantity > item.product.stock) { + addNotification( + `재고는 ${item.product.stock}개까지만 있습니다`, + "error" + ); + } + return updateCartItemQuantity(prev, productId, newQuantity); + }); + }; + + const emptyCart = () => { + setCart([]); + }; + + const calculateTotal = () => { + return calculateCartTotal(cart, selectedCoupon); + }; + + const applyCoupon = (coupon: Coupon | null) => { + if (!coupon) { + setSelectedCoupon(null); + return; + } + + const { totalAfterDiscount } = calculateTotal(); + + if (totalAfterDiscount < 10000 && coupon.discountType === "percentage") { + addNotification( + "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", + "warning" + ); + return; + } + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다", "success"); + }; + + const getStock = (product: ProductWithUI) => { + return getRemainingStock(cart, product); + }; + + return { + cart, + selectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + emptyCart, + getStock, + applyCoupon, + calculateTotal, + }; +}; diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts new file mode 100644 index 000000000..d5667ae41 --- /dev/null +++ b/src/basic/hooks/useCoupons.ts @@ -0,0 +1,46 @@ +import { useCallback } from "react"; +import { Coupon } from "../../types"; +import { initialCoupons } from "../constants"; +import { addCouponToList, deleteCouponToList } from "../models/coupon"; +import { Notification } from "../models/notificiation"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; + +export const useCoupons = ( + addNotification: (message: string, type: Notification["type"]) => void +) => { + const [coupons, setCoupons] = useLocalStorage("coupons", initialCoupons); + + const addCoupon = useCallback((newCoupon: Coupon) => { + setCoupons((prev) => { + const newCoupons = addCouponToList(prev, newCoupon); + + if (newCoupons === prev) { + addNotification("이미 존재하는 쿠폰 코드입니다", "error"); + } else { + addNotification("쿠폰이 추가되었습니다", "success"); + } + return newCoupons; + }); + }, []); + + const deleteCoupon = useCallback((couponCode: string) => { + setCoupons((prev) => { + const newCoupons = deleteCouponToList(prev, couponCode); + + // 삭제 성공 여부 확인 + const wasDeleted = newCoupons.length < prev.length; + + if (wasDeleted) { + addNotification("쿠폰이 삭제되었습니다", "success"); + } + + return newCoupons; + }); + }, []); + + return { + coupons, + addCoupon, + deleteCoupon, + }; +}; diff --git a/src/basic/hooks/useDiscount.ts b/src/basic/hooks/useDiscount.ts new file mode 100644 index 000000000..cfcaf1fc4 --- /dev/null +++ b/src/basic/hooks/useDiscount.ts @@ -0,0 +1,39 @@ +import { useCallback } from "react"; +import { Discount } from "../../types"; +import { + addDiscount, + removeDiscount, + updateDiscount, +} from "../models/discount"; + +export const useDiscount = ( + discounts: Discount[], + onChange: (newDiscounts: Discount[]) => void +) => { + const add = useCallback(() => { + onChange(addDiscount(discounts)); + }, [discounts, onChange]); + + const remove = useCallback( + (index: number) => { + onChange(removeDiscount(discounts, index)); + }, + [discounts, onChange] + ); + + const updateQuantity = useCallback( + (index: number, quantity: number) => { + onChange(updateDiscount(discounts, index, { quantity })); + }, + [discounts, onChange] + ); + + const updateRate = useCallback( + (index: number, rate: number) => { + onChange(updateDiscount(discounts, index, { rate: rate / 100 })); + }, + [discounts, onChange] + ); + + return { add, remove, updateQuantity, updateRate }; +}; diff --git a/src/basic/hooks/useNotification.ts b/src/basic/hooks/useNotification.ts new file mode 100644 index 000000000..b6566de73 --- /dev/null +++ b/src/basic/hooks/useNotification.ts @@ -0,0 +1,27 @@ +import { useCallback, useState } from "react"; +import { + Notification, + createNotification, + removeNotification, +} from "../models/notificiation"; + +export const useNotification = () => { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + (message: string, type: Notification["type"] = "success") => { + const notification = createNotification(message, type); + setNotifications((prev) => [...prev, notification]); + + setTimeout(() => { + setNotifications((prev) => removeNotification(prev, notification.id)); + }, 3000); + }, + [] + ); + const remove = useCallback((id: string) => { + setNotifications((prev) => removeNotification(prev, id)); + }, []); + + return { notifications, addNotification, remove }; +}; diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts new file mode 100644 index 000000000..aa02735f3 --- /dev/null +++ b/src/basic/hooks/useProducts.ts @@ -0,0 +1,38 @@ +import { ProductWithUI } from "../../types"; +import { initialProducts } from "../constants"; +import { Notification } from "../models/notificiation"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; + +export const useProducts = ( + addNotification: (message: string, type: Notification["type"]) => void +) => { + const [products, setProducts] = useLocalStorage( + "products", + initialProducts + ); + + const addProduct = (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification("상품이 추가되었습니다", "success"); + }; + + const updateProduct = (updates: Partial) => { + setProducts((prev) => + prev.map((product) => + product.id === updates.id ? { ...product, ...updates } : product + ) + ); + addNotification("상품이 수정되었습니다", "success"); + }; + + const deleteProduct = (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + addNotification("상품이 삭제되었습니다", "success"); + }; + + return { products, addProduct, updateProduct, deleteProduct }; +}; diff --git a/src/basic/hooks/useSearch.ts b/src/basic/hooks/useSearch.ts new file mode 100644 index 000000000..cf5c2c6d0 --- /dev/null +++ b/src/basic/hooks/useSearch.ts @@ -0,0 +1,32 @@ +import { useState } from "react"; +import { useDebounce } from "../utils/hooks/useDebounce"; + +export const useSearch = (delay: number = 300) => { + const [searchTerm, setSearchTerm] = useState(""); + + // 300ms 디바운스 적용 + const debouncedSearchTerm = useDebounce(searchTerm, delay); + + // 제네릭 필터링 함수 (디바운스된 값 사용) + const filterItems = >( + items: T[], + searchKeys: (keyof T)[] + ): T[] => { + if (!debouncedSearchTerm.trim()) return items; + + return items.filter((item) => + searchKeys.some((key) => + String(item[key]) + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) + ) + ); + }; + + return { + searchTerm, // 실시간 입력값 (UI 표시용) + debouncedSearchTerm, // 디바운스된 값 (필터링용) + setSearchTerm, + filterItems, + }; +}; diff --git a/src/basic/models/cart.ts b/src/basic/models/cart.ts new file mode 100644 index 000000000..6255a964f --- /dev/null +++ b/src/basic/models/cart.ts @@ -0,0 +1,174 @@ +// TODO: 장바구니 비즈니스 로직 (순수 함수) +// 힌트: 모든 함수는 순수 함수로 구현 (부작용 없음, 같은 입력에 항상 같은 출력) +// +// 구현할 함수들: +// 1. calculateItemTotal(item): 개별 아이템의 할인 적용 후 총액 계산 +// 2. getMaxApplicableDiscount(item): 적용 가능한 최대 할인율 계산 +// 3. calculateCartTotal(cart, coupon): 장바구니 총액 계산 (할인 전/후, 할인액) +// 4. updateCartItemQuantity(cart, productId, quantity): 수량 변경 +// 5. addItemToCart(cart, product): 상품 추가 +// 6. removeItemFromCart(cart, productId): 상품 제거 +// 7. getRemainingStock(product, cart): 남은 재고 계산 +// +// 원칙: +// - UI와 관련된 로직 없음 +// - 외부 상태에 의존하지 않음 +// - 모든 필요한 데이터는 파라미터로 전달받음 + +import { CartItem, Coupon, Product } from "../../types"; + +export const calculateItemTotal = ( + cart: CartItem[], + item: CartItem +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(cart, item); + + return Math.round(price * quantity * (1 - discount)); +}; + +const getMaxApplicableDiscount = (cart: CartItem[], item: CartItem): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + } + + return baseDiscount; +}; + +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(cart, item); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === "amount") { + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - selectedCoupon.discountValue + ); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; + +export const addItemToCart = ( + cart: CartItem[], + product: any //ProductWithUI +): CartItem[] => { + const existingItem = cart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + return cart; + } + + return cart.map((item) => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item + ); + } + + return [...cart, { product, quantity: 1 }]; +}; + +/** + * 장바구니에서 상품 제거 + */ +export const removeItemFromCart = ( + cart: CartItem[], + productId: string +): CartItem[] => { + return cart.filter((item) => item.product.id !== productId); +}; + +/** + * 장바구니 아이템 수량 변경 + */ +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + newQuantity: number +): CartItem[] => { + // 수량이 0 이하면 제거 + if (newQuantity <= 0) { + return removeItemFromCart(cart, productId); + } + + return cart.map((item) => { + if (item.product.id === productId) { + // 재고 체크: 새 수량이 재고를 초과하면 재고만큼만 설정 + const maxQuantity = item.product.stock; + const validQuantity = Math.min(newQuantity, maxQuantity); + return { ...item, quantity: validQuantity }; + } + return item; + }); +}; + +export const getRemainingStock = ( + cart: CartItem[], + product: Product +): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +}; + +/** + * 할인 금액 계산 + */ +export const calculateDiscountAmount = ( + totalBefore: number, + totalAfter: number +): number => { + return totalBefore - totalAfter; +}; + +/** + * 장바구니 아이템의 할인 정보 계산 + */ +export const getCartItemDiscount = ( + cart: CartItem[], + item: CartItem +): { hasDiscount: boolean; discountRate: number } => { + const itemTotal = calculateItemTotal(cart, item); + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount + ? Math.round((1 - itemTotal / originalPrice) * 100) + : 0; + + return { hasDiscount, discountRate }; +}; diff --git a/src/basic/models/coupon.ts b/src/basic/models/coupon.ts new file mode 100644 index 000000000..55ffe6622 --- /dev/null +++ b/src/basic/models/coupon.ts @@ -0,0 +1,40 @@ +// TODO: 쿠폰 비즈니스 로직 (순수 함수) + +import { Coupon } from "../../types"; + +export const addCouponToList = ( + coupons: Coupon[], + newCoupon: Coupon +): Coupon[] => { + const existingCoupon = coupons.find( + (coupon) => coupon.code === newCoupon.code + ); + + if (existingCoupon) { + return coupons; + } + + return [...coupons, newCoupon]; +}; + +export const deleteCouponToList = ( + coupons: Coupon[], + couponCode: string +): Coupon[] => { + return coupons.filter((coupon) => coupon.code !== couponCode); +}; +/** + * 쿠폰 할인 금액/비율을 포맷팅 + */ +export const formatCouponDiscount = (coupon: Coupon): string => { + return coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`; +}; + +/** + * 쿠폰 선택 옵션 텍스트 생성 + */ +export const getCouponOptionText = (coupon: Coupon): string => { + return `${coupon.name} (${formatCouponDiscount(coupon)})`; +}; diff --git a/src/basic/models/discount.ts b/src/basic/models/discount.ts new file mode 100644 index 000000000..2822b1285 --- /dev/null +++ b/src/basic/models/discount.ts @@ -0,0 +1,28 @@ +import { Discount } from "../../types"; + +export const NEW_DISCOUNT: Discount = { quantity: 10, rate: 0.1 }; + +export const addDiscount = (discounts: Discount[]): Discount[] => { + return [...discounts, NEW_DISCOUNT]; +}; + +export const removeDiscount = ( + discounts: Discount[], + index: number +): Discount[] => { + return discounts.filter((_, i) => i !== index); +}; + +export const updateDiscount = ( + discounts: Discount[], + index: number, + updates: Partial +): Discount[] => { + return discounts.map((discount, i) => + i === index ? { ...discount, ...updates } : discount + ); +}; + +export const getDiscountRatePercent = (rate: number): number => { + return Math.round(rate * 100); +}; diff --git a/src/basic/models/notificiation.ts b/src/basic/models/notificiation.ts new file mode 100644 index 000000000..47ceb096e --- /dev/null +++ b/src/basic/models/notificiation.ts @@ -0,0 +1,19 @@ +export type Notification = { + id: string; + message: string; + type: "error" | "success" | "warning"; +}; + +export const createNotification = ( + message: string, + type: Notification["type"] = "success" +): Notification => ({ + id: Date.now().toString(), + message, + type, +}); + +export const removeNotification = ( + notifications: Notification[], + id: string +): Notification[] => notifications.filter(n => n.id !== id); \ No newline at end of file diff --git a/src/basic/models/product.ts b/src/basic/models/product.ts new file mode 100644 index 000000000..df3f6a04d --- /dev/null +++ b/src/basic/models/product.ts @@ -0,0 +1,70 @@ +// TODO: 상품 비즈니스 로직 (순수 함수) +// 힌트: 모든 함수는 순수 함수로 구현 (부작용 없음, 같은 입력에 항상 같은 출력) + +import { Product } from "../../types"; + +const LOW_STOCK_THRESHOLD = 5; + +/** + * 상품의 최대 할인율 계산 + */ +export const getMaxDiscountRate = (product: Product): number => { + if (product.discounts.length === 0) return 0; + return Math.max(...product.discounts.map((d) => d.rate)) * 100; +}; + +/** + * 재고 상태 정보 반환 + */ +export const getStockStatus = ( + remainingStock: number +): { + isLowStock: boolean; + isOutOfStock: boolean; + message: string; + buttonText: string; +} => { + if (remainingStock <= 0) { + return { + isLowStock: false, + isOutOfStock: true, + message: "", + buttonText: "품절", + }; + } + + if (remainingStock <= LOW_STOCK_THRESHOLD) { + return { + isLowStock: true, + isOutOfStock: false, + message: `품절임박! ${remainingStock}개 남음`, + buttonText: "장바구니 담기", + }; + } + + return { + isLowStock: false, + isOutOfStock: false, + message: `재고 ${remainingStock}개`, + buttonText: "장바구니 담기", + }; +}; + +/** + * 할인 정보가 있는지 확인 + */ +export const hasDiscount = (product: Product): boolean => { + return product.discounts.length > 0; +}; + +/** + * 첫 번째 할인 정보 반환 + */ +export const getFirstDiscount = (product: Product) => { + if (!hasDiscount(product)) return null; + const discount = product.discounts[0]; + return { + quantity: discount.quantity, + rate: discount.rate * 100, + }; +}; diff --git a/src/basic/models/validation.ts b/src/basic/models/validation.ts new file mode 100644 index 000000000..ff9a85d94 --- /dev/null +++ b/src/basic/models/validation.ts @@ -0,0 +1,31 @@ +/** + * 폼 검증을 위한 순수 함수들 + * 검증 실패 시 에러 메시지를 반환하고, 성공 시 null을 반환합니다. + */ + +/** + * 재고 수량 검증 + */ +export const validateStock = (value: number): string | null => { + if (value < 0) return "재고는 0 이상이어야 합니다"; + if (value === 0) return "재고는 0보다 커야 합니다"; + if (value > 9999) return "재고는 9999 이하여야 합니다"; + return null; // 검증 통과 +}; + +/** + * 가격 검증 + */ +export const validatePrice = (value: number): string | null => { + if (value <= 0) return "가격은 0보다 커야 합니다"; + return null; +}; + +/** + * 쿠폰 할인율 검증 (퍼센트 타입) + */ +export const validateDiscountRate = (value: number): string | null => { + if (value > 100) return "할인율은 100%를 초과할 수 없습니다"; + if (value < 0) return "할인율은 0 이상이어야 합니다"; + return null; +}; diff --git a/src/basic/utils/formatters.ts b/src/basic/utils/formatters.ts new file mode 100644 index 000000000..f068ba0f0 --- /dev/null +++ b/src/basic/utils/formatters.ts @@ -0,0 +1,17 @@ +// TODO: 포맷팅 유틸리티 함수들 +// 구현할 함수: +// - formatPrice(price: number): string - 가격을 한국 원화 형식으로 포맷 +// - formatDate(date: Date): string - 날짜를 YYYY-MM-DD 형식으로 포맷 +// - formatPercentage(rate: number): string - 소수를 퍼센트로 변환 (0.1 → 10%) + + +export const formatPrice = (price: number, stock: number): string => { + if (stock <= 0) { + return "SOLD OUT"; + } + return `₩${price.toLocaleString()}`; +}; + +export const formatPriceKr = (price: number): string => { + return `${price.toLocaleString()}원`; +}; diff --git a/src/basic/utils/hooks/useDebounce.ts b/src/basic/utils/hooks/useDebounce.ts new file mode 100644 index 000000000..c8a52fef0 --- /dev/null +++ b/src/basic/utils/hooks/useDebounce.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; + +/** + * 디바운스 Hook + * 값이 변경되어도 지정된 시간(delay) 동안 대기 후 최종값 반환 + * 대기 시간 동안 값이 다시 변경되면 타이머 리셋 + * + * @param value - 디바운스할 값 + * @param delay - 대기 시간 (ms) + * @returns 디바운스된 값 + * + * @example + * const debouncedSearch = useDebounce(searchTerm, 300); + */ +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + // delay 후에 값 업데이트 + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // 값이 변경되면 이전 타이머 취소 (cleanup) + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/basic/utils/hooks/useForm.ts b/src/basic/utils/hooks/useForm.ts new file mode 100644 index 000000000..7a3997ff6 --- /dev/null +++ b/src/basic/utils/hooks/useForm.ts @@ -0,0 +1,20 @@ +import { useState } from "react"; + +export function useForm>(initialValues: T) { + const [values, setValues] = useState(initialValues); + + const handleChange = ( + field: K, + value: T[K], + transform?: (v: any) => T[K] + ) => { + setValues((prev) => ({ + ...prev, + [field]: transform ? transform(value) : value, + })); + }; + + const resetForm = () => setValues(initialValues); + + return { values, handleChange, resetForm, setValues }; +} diff --git a/src/basic/utils/hooks/useLocalStorage.ts b/src/basic/utils/hooks/useLocalStorage.ts new file mode 100644 index 000000000..7b8eb34f0 --- /dev/null +++ b/src/basic/utils/hooks/useLocalStorage.ts @@ -0,0 +1,46 @@ +// TODO: LocalStorage Hook +// 힌트: +// 1. localStorage와 React state 동기화 +// 2. 초기값 로드 시 에러 처리 +// 3. 저장 시 JSON 직렬화/역직렬화 +// 4. 빈 배열이나 undefined는 삭제 +// +// 반환값: [저장된 값, 값 설정 함수] + +import { useState } from "react"; + +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((val: T) => T)) => void] { + // 초기값 로드 + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(`Error loading localStorage key "${key}":`, error); + return initialValue; + } + }); + + //setValue 함수 + const setValue = (value: T | ((val: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(storedValue) : value; + if ( + valueToStore === undefined || + (Array.isArray(valueToStore) && valueToStore.length === 0) + ) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } + setStoredValue(valueToStore); + } catch (error) { + console.error(`Error setting localStorage key "${key}":`, error); + } + }; + + return [storedValue, setValue]; +} diff --git a/src/basic/utils/hooks/useValidate.ts b/src/basic/utils/hooks/useValidate.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/utils/validators.ts b/src/basic/utils/validators.ts new file mode 100644 index 000000000..6f7f434f5 --- /dev/null +++ b/src/basic/utils/validators.ts @@ -0,0 +1,35 @@ +// TODO: 검증 유틸리티 함수들 +// 구현할 함수: +// - isValidCouponCode(code: string): boolean - 쿠폰 코드 형식 검증 (4-12자 영문 대문자와 숫자) +// - isValidStock(stock: number): boolean - 재고 수량 검증 (0 이상) +// - isValidPrice(price: number): boolean - 가격 검증 (양수) +// - extractNumbers(value: string): string - 문자열에서 숫자만 추출 + +const isValidCouponCode = (code: string): boolean => { + const couponCodeRegex = /^[A-Z0-9]{4,12}$/; + return couponCodeRegex.test(code); +}; + +export const isValidPrice = (price: number): boolean => { + return !isNaN(price) && price >= 0; +}; + +export const isValidStock = (stock: number): boolean => { + return !isNaN(stock) && stock >= 0 && stock <= 9999; +}; + +const formatCouponCode = (code: string): string => { + return code.toUpperCase(); +}; + +const extractNumbers = (value: string): string => { + return value.replace(/\D/g, ""); +}; + +const parseDiscountValue = (value: string | number): number => { + if (typeof value === "number") return value; + const numeric = extractNumbers(value); + return numeric === "" ? 0 : parseInt(numeric); +}; + +export { isValidCouponCode, formatCouponCode, extractNumbers, parseDiscountValue }; diff --git a/src/types.ts b/src/types.ts index 5489e296e..fbc9f821e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,19 +6,30 @@ export interface Product { discounts: Discount[]; } +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + export interface Discount { quantity: number; rate: number; } export interface CartItem { - product: Product; + product: ProductWithUI; quantity: number; } export interface Coupon { name: string; code: string; - discountType: 'amount' | 'percentage'; + discountType: "amount" | "percentage"; discountValue: number; } + +export type Notification = { + id: string; + message: string; + type: "error" | "success" | "warning"; +}; diff --git a/vite.config.advanced.ts b/vite.config.advanced.ts new file mode 100644 index 000000000..7de5317ad --- /dev/null +++ b/vite.config.advanced.ts @@ -0,0 +1,23 @@ +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "vite"; +import { defineConfig as defineTestConfig, mergeConfig } from "vitest/config"; + +export default mergeConfig( + defineConfig({ + plugins: [react()], + build: { + rollupOptions: { + input: "index.advanced.html", + }, + outDir: "dist", + }, + base: "./", + }), + defineTestConfig({ + test: { + globals: true, + environment: "jsdom", + setupFiles: "./src/setupTests.ts", + }, + }) +); diff --git a/vite.config.basic.ts b/vite.config.basic.ts new file mode 100644 index 000000000..ca90570d1 --- /dev/null +++ b/vite.config.basic.ts @@ -0,0 +1,23 @@ +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "vite"; +import { defineConfig as defineTestConfig, mergeConfig } from "vitest/config"; + +export default mergeConfig( + defineConfig({ + plugins: [react()], + build: { + rollupOptions: { + input: "index.basic.html", + }, + outDir: "dist", + }, + base: "./", + }), + defineTestConfig({ + test: { + globals: true, + environment: "jsdom", + setupFiles: "./src/setupTests.ts", + }, + }) +);