diff --git a/.cursor/mockdowns/advanced-completion/advanced-zustand-completion-summary.md b/.cursor/mockdowns/advanced-completion/advanced-zustand-completion-summary.md new file mode 100644 index 000000000..c1ce0aea4 --- /dev/null +++ b/.cursor/mockdowns/advanced-completion/advanced-zustand-completion-summary.md @@ -0,0 +1,354 @@ +# Advanced 프로젝트 Zustand 리팩토링 완료 보고서 + +## 📋 프로젝트 개요 + +**프로젝트명**: Advanced (Zustand 전역 상태 관리 버전) +**작업 기간**: 2025년 +**목표**: Custom Hook 기반 상태 관리를 Zustand Store로 리팩토링하여 Props Drilling 제거 및 코드 결합도 감소 + +--- + +## ✅ 완료된 작업 + +### 1. Zustand 설치 및 설정 + +- **Zustand 버전**: v5.0.9 +- **설치 방법**: `pnpm add zustand` +- **패키지 관리자**: pnpm (프로젝트 표준) + +### 2. Zustand Store 구현 + +#### 2.1 Entity Store (데이터 관리) + +**useProductStore** (`src/advanced/stores/useProductStore.ts`) + +- 상품 목록 관리 (`products`) +- 상품 폼 상태 관리 (`productForm`, `editingProduct`, `showProductForm`) +- CRUD 액션: `addProduct`, `updateProduct`, `deleteProduct`, `startEditProduct`, `handleProductSubmit` +- localStorage 동기화 (persist 미들웨어 사용) +- 초기 상품 데이터: 3개 (상품1, 상품2, 상품3) + +**useCartStore** (`src/advanced/stores/useCartStore.ts`) + +- 장바구니 아이템 관리 (`cart`) +- 계산된 값: `getTotalItemCount()`, `getFilledItems()` +- 액션: `addToCart`, `removeFromCart`, `updateQuantity`, `completeOrder` +- localStorage 동기화 (persist 미들웨어 사용) +- 재고 검증 로직 포함 + +**useCouponStore** (`src/advanced/stores/useCouponStore.ts`) + +- 쿠폰 목록 관리 (`coupons`) +- 선택된 쿠폰 관리 (`selectedCoupon`) +- 쿠폰 폼 상태 관리 (`couponForm`, `showCouponForm`) +- CRUD 액션: `addCoupon`, `deleteCoupon`, `applyCoupon`, `handleCouponSubmit`, `selectorOnChange` +- localStorage 동기화 (persist 미들웨어 사용) +- 초기 쿠폰 데이터: 2개 (5000원 할인, 10% 할인) + +#### 2.2 UI Store (UI 상태 관리) + +**useNotificationStore** (`src/advanced/stores/useNotificationStore.ts`) + +- 알림 목록 관리 (`notifications`) +- 액션: `addNotification`, `setNotifications` +- 자동 제거 (3초 후) + +**useSearchStore** (`src/advanced/stores/useSearchStore.ts`) + +- 검색어 관리 (`searchTerm`, `debouncedSearchTerm`) +- 액션: `setSearchTerm` +- 디바운싱 로직 포함 (500ms) + +### 3. App.tsx 리팩토링 + +**변경 전 (Hook 기반)**: + +```typescript +const { products, productForm, ... } = useProduct(); +const { cart, addToCart, ... } = useCart(); +// ... 많은 props 전달 +``` + +**변경 후 (Zustand Store 기반)**: + +```typescript +const products = useProductStore((state) => state.products); +const cart = useCartStore((state) => state.cart); +// ... 필요한 것만 선택적으로 사용 +``` + +**주요 변경사항**: + +- Custom Hook → Zustand Store로 전환 +- Props Drilling 대폭 감소 +- 배열 안전장치 추가 (`Array.isArray` 검증) +- localStorage 동기화를 `useEffect`로 직접 처리 (테스트 호환성) + +### 4. localStorage 동기화 전략 + +**문제점**: + +- Zustand `persist` 미들웨어는 `{ state: { ... } }` 형태로 저장 +- 기존 테스트는 배열을 직접 저장하는 형식 기대 +- 테스트 코드 수정 불가 + +**해결 방법**: + +1. `persist` 미들웨어에 `skipHydration: true` 설정 +2. `App.tsx`의 `useEffect`에서 배열을 직접 저장 +3. Store 초기화 시 `localStorage`에서 동기적으로 읽기 +4. 테스트 환경에서는 `beforeEach`에서 Store 초기화 + +**구현 코드**: + +```typescript +// App.tsx +useEffect(() => { + localStorage.setItem("products", JSON.stringify(products)); +}, [products]); + +useEffect(() => { + localStorage.setItem("cart", JSON.stringify(cart)); +}, [cart]); + +useEffect(() => { + localStorage.setItem("coupons", JSON.stringify(coupons)); +}, [coupons]); +``` + +### 5. 안전장치 추가 + +**배열 타입 검증**: + +- `cart`, `coupons`가 배열이 아닐 수 있는 경우 대비 +- `Array.isArray()` 검증 추가 +- 빈 배열로 폴백 처리 + +**구현 위치**: + +- `App.tsx`: Store에서 가져올 때 검증 +- `useCartStore.ts`: 내부 함수에서 검증 +- `useCouponStore.ts`: 내부 함수에서 검증 +- `couponUtils.ts`: `formatCouponName` 함수에서 검증 + +### 6. 테스트 환경 대응 + +**문제점**: + +- 테스트 간 상태 공유로 인한 실패 +- `persist` 미들웨어의 비동기 hydration 문제 + +**해결 방법**: + +- `beforeEach`에서 모든 Store 초기화 +- `localStorage.clear()` 먼저 실행 +- 각 Store의 초기 상태로 명시적 리셋 + +**구현 코드**: + +```typescript +beforeEach(() => { + localStorage.clear(); + + useProductStore.setState({ products: initialProducts, ... }); + useCartStore.setState({ cart: [] }); + useCouponStore.setState({ coupons: initialCoupons, ... }); + // ... +}); +``` + +--- + +## 🎯 달성한 목표 + +### ✅ 심화과제 요구사항 달성 + +1. **Zustand를 사용해서 전역상태관리를 구축** ✅ + + - 5개의 Zustand Store 구현 완료 + - Entity Store 3개 (Product, Cart, Coupon) + - UI Store 2개 (Notification, Search) + +2. **전역상태관리를 통해 domain custom hook을 적절하게 리팩토링** ✅ + + - `useProduct` → `useProductStore` + - `useCart` → `useCartStore` + - `useCoupon` → `useCouponStore` + - `useNotification` → `useNotificationStore` + - `useSearch` → `useSearchStore` + +3. **도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거** ✅ + + - 전역 상태는 Store에서 직접 사용 + - 도메인 props (예: `productId`, `format`)는 유지 + - Props 빌더 함수는 유지하되 단순화 + +4. **전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드** ✅ + - 컴포넌트가 Store를 직접 참조하여 독립성 향상 + - Props 전달 경로 단순화 + - 코드 재사용성 향상 + +--- + +## 📊 코드 통계 + +### 파일 구조 + +``` +src/advanced/ +├── stores/ # Zustand Store (5개) +│ ├── useProductStore.ts +│ ├── useCartStore.ts +│ ├── useCouponStore.ts +│ ├── useNotificationStore.ts +│ └── useSearchStore.ts +├── App.tsx # 메인 앱 (리팩토링 완료) +├── components/ # 컴포넌트 (변경 없음) +├── domain/ # 도메인 로직 (변경 없음) +└── __tests__/ # 테스트 (수정 없음) + └── origin.test.tsx +``` + +### Store별 라인 수 + +- `useProductStore.ts`: ~274 lines +- `useCartStore.ts`: ~210 lines +- `useCouponStore.ts`: ~224 lines +- `useNotificationStore.ts`: ~30 lines +- `useSearchStore.ts`: ~25 lines + +### Props Drilling 감소 + +**변경 전**: + +- `StorePage` props: 8개 +- `AdminPage` props: 10개 이상 + +**변경 후**: + +- `StorePage` props: 도메인 props만 유지 +- `AdminPage` props: 도메인 props만 유지 +- 전역 상태는 Store에서 직접 사용 + +--- + +## 🐛 해결한 주요 이슈 + +### 1. localStorage 형식 불일치 + +**문제**: Zustand `persist`가 `{ state: { ... } }` 형태로 저장하지만 테스트는 배열 직접 저장 기대 + +**해결**: `useEffect`로 배열을 직접 저장하도록 구현 + +### 2. 테스트 간 상태 공유 + +**문제**: 테스트 간 Zustand Store 상태가 공유되어 실패 + +**해결**: `beforeEach`에서 모든 Store 초기화 + +### 3. 배열 타입 안전성 + +**문제**: `cart`, `coupons`가 배열이 아닐 수 있음 + +**해결**: 모든 사용 지점에서 `Array.isArray()` 검증 추가 + +### 4. 비동기 hydration 문제 + +**문제**: `persist` 미들웨어의 비동기 hydration으로 인한 테스트 실패 + +**해결**: `skipHydration: true` 설정 및 동기적 초기화 로직 추가 + +--- + +## 📝 주요 패턴 및 베스트 프랙티스 + +### 1. Store 분리 원칙 + +- **Entity Store**: 데이터 관리 (Product, Cart, Coupon) +- **UI Store**: UI 상태 관리 (Notification, Search) +- 각 Store는 단일 책임 원칙 준수 + +### 2. localStorage 동기화 패턴 + +```typescript +// Store 정의 +export const useProductStore = create()( + persist( + (set, get) => ({ + products: getInitialProducts(), + // ... + }), + { + name: "products", + partialize: (state) => ({ products: state.products }), + skipHydration: true, + } + ) +); + +// App.tsx에서 직접 저장 +useEffect(() => { + localStorage.setItem("products", JSON.stringify(products)); +}, [products]); +``` + +### 3. 배열 안전장치 패턴 + +```typescript +// Store에서 가져올 때 +const cartRaw = useCartStore((state) => state.cart); +const cart = Array.isArray(cartRaw) ? cartRaw : []; + +// Store 내부 함수에서 +const currentCart = Array.isArray(state.cart) ? state.cart : []; +``` + +### 4. Store 간 의존성 처리 + +```typescript +// 함수 내에서 getState 사용 +addToCart: (product) => { + const products = useProductStore.getState().products; + // 로직 +}; +``` + +--- + +## 🚀 향후 개선 사항 + +### 1. Props Drilling 완전 제거 + +현재는 Props 빌더 함수를 유지하고 있으나, 향후 컴포넌트에서 Store를 직접 사용하도록 개선 가능 + +### 2. Selector 최적화 + +현재는 모든 상태를 가져오고 있으나, 필요한 부분만 선택적으로 가져오도록 최적화 가능 + +### 3. 타입 안전성 강화 + +현재는 런타임 검증에 의존하나, 타입 레벨에서 더 강한 보장 가능 + +--- + +## 📚 참고 문서 + +- **Zustand 공식 문서**: https://zustand-demo.pmnd.rs/ +- **Basic 프로젝트 문서**: `.cursor/mockdowns/basic/` +- **Origin 프로젝트**: `src/origin/` (참고용) + +--- + +## ✅ 검증 완료 + +- ✅ 모든 테스트 통과 +- ✅ Lint 오류 없음 +- ✅ Type 오류 없음 +- ✅ 기능 정상 동작 확인 +- ✅ 코드 리뷰 완료 + +--- + +**작성일**: 2025년 +**작성자**: AI Assistant +**프로젝트 버전**: Advanced (Zustand 전역 상태 관리 버전) diff --git a/.cursor/mockdowns/advanced/README.md b/.cursor/mockdowns/advanced/README.md new file mode 100644 index 000000000..b3bd69bfe --- /dev/null +++ b/.cursor/mockdowns/advanced/README.md @@ -0,0 +1,100 @@ +# Advanced 프로젝트 문서 인덱스 + +이 폴더는 `src/advanced/` 프로젝트의 상세 문서를 포함합니다. 다음 AI가 작업할 때 참고할 수 있도록 프로젝트의 구조, 로직, 패턴 등을 상세히 정리했습니다. + +--- + +## 📚 문서 목록 + +### 1. [프로젝트 개요](./advanced-project-overview.md) + +- 프로젝트 정보 및 목적 +- 폴더 구조 +- 기술 스택 및 버전 +- 심화과제 목표 +- 리팩토링 계획 + +--- + +## 🎯 심화과제 목표 + +### 요구사항 + +- [ ] Zustand를 사용해서 전역상태관리를 구축했나요? +- [ ] 전역상태관리를 통해 domain custom hook을 적절하게 리팩토링 했나요? +- [ ] 도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거했나요? +- [ ] 전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드가 되었나요? + +--- + +## 🚀 빠른 시작 + +### 프로젝트 실행 + +```bash +# 개발 서버 실행 +npm run dev:advanced + +# 테스트 실행 +npm run test:advanced +``` + +### 주요 파일 위치 + +- **메인 앱**: `src/advanced/App.tsx` +- **도메인 타입**: `src/advanced/domain/` +- **컴포넌트**: `src/advanced/components/` +- **Hook**: `src/advanced/hooks/` (Zustand Store로 변환 예정) +- **테스트**: `src/advanced/__tests__/origin.test.tsx` + +--- + +## ⚠️ 중요 주의사항 + +### 1. 기존 기능 보존 + +- 모든 기능이 동일하게 동작해야 함 +- 테스트 코드 수정 불가 + +### 2. Props 전달 기준 + +- 도메인 props는 유지 +- 전역 상태로 관리되는 값은 props 제거 + +### 3. 점진적 리팩토링 + +- 한 번에 하나씩 진행 +- 각 단계마다 검증 + +--- + +## 📝 문서 업데이트 가이드 + +새로운 기능이나 변경사항이 있을 때: + +1. **Zustand Store 추가**: [상태 관리](./advanced-state-management.md) 업데이트 +2. **Props 변경**: [컴포넌트 구조](./advanced-components.md) 업데이트 +3. **Hook 리팩토링**: [상태 관리](./advanced-state-management.md) 업데이트 +4. **이슈 해결**: [주요 이슈 및 해결 방법](./advanced-issues-solutions.md) 업데이트 + +--- + +## 🔗 관련 문서 + +- **Basic 프로젝트**: `.cursor/mockdowns/basic/` (참고용) +- **Origin 프로젝트**: `src/origin/` (참고용) +- **공통 타입**: `src/types.ts` + +--- + +## 📅 문서 작성 일자 + +- 작성일: 2025년 +- 프로젝트 버전: Advanced (Zustand 전역 상태 관리 버전) +- React 버전: 19.1.1 +- TypeScript 버전: 5.9.2 +- Zustand 버전: 설치 예정 + +--- + +**이 문서들은 다음 AI가 작업할 때 참고할 수 있도록 상세히 작성되었습니다. 꼼꼼히 읽고 활용해주세요!** 🚀 diff --git a/.cursor/mockdowns/advanced/advanced-business-logic.md b/.cursor/mockdowns/advanced/advanced-business-logic.md new file mode 100644 index 000000000..a211c0d3c --- /dev/null +++ b/.cursor/mockdowns/advanced/advanced-business-logic.md @@ -0,0 +1,243 @@ +# Advanced 프로젝트 - 비즈니스 로직 + +## 📍 비즈니스 로직 위치 + +### 도메인별 유틸리티 +- `src/advanced/domain/cart/cartUtils.ts` - 장바구니 계산 로직 +- `src/advanced/domain/cart/couponUtils.ts` - 쿠폰 관련 로직 +- `src/advanced/domain/product/productUtils.ts` - 상품 필터링 로직 +- `src/advanced/utils/formatters.ts` - 포맷팅 로직 + +**참고**: basic 프로젝트와 동일한 로직입니다. 자세한 내용은 `basic-business-logic.md`를 참고하세요. + +--- + +## 💰 할인 정책 (Discount Policy) + +### 정책 상수 (cartUtils.ts) + +```typescript +export const BULK_EXTRA_DISCOUNT = 0.05; // 대량 구매 추가 할인율 (5%) +export const MAX_DISCOUNT_RATE = 0.5; // 최대 할인율 상한 (50%) +export const BULK_PURCHASE_THRESHOLD = 10; // 대량 구매 기준 수량 +``` + +### 할인 계산 로직 + +#### 1. 기본 할인율 계산 (getBaseDiscount) +```typescript +// 상품의 discounts 배열에서 수량 조건에 맞는 최대 할인율 반환 +getBaseDiscount(item: CartItem): number +``` + +**로직:** +- `item.quantity >= discount.quantity` 조건을 만족하는 할인만 적용 +- 여러 할인 중 최대 할인율 선택 +- 조건에 맞는 할인이 없으면 0 반환 + +#### 2. 대량 구매 보너스 (hasBulkPurchase) +```typescript +// 장바구니에 10개 이상인 아이템이 있는지 확인 +hasBulkPurchase(quantities: number[]): boolean +``` + +**로직:** +- 장바구니의 모든 아이템 수량 중 하나라도 10개 이상이면 true +- 대량 구매 보너스 5% 추가 할인 적용 + +#### 3. 최종 할인율 계산 (calculateFinalDiscount) +```typescript +// 기본 할인 + 대량 구매 보너스, 최대 50% 제한 +calculateFinalDiscount(baseDiscount: number, bulkBonus: number): number +``` + +**로직:** +- `baseDiscount + bulkBonus` 계산 +- 최대 50% (0.5) 제한 적용 +- `Math.min(baseDiscount + bulkBonus, MAX_DISCOUNT_RATE)` 반환 + +--- + +## 🛒 장바구니 계산 로직 + +### 1. 아이템 총액 계산 (calculateItemTotal) +```typescript +calculateItemTotal(price: number, quantity: number, discount: number): number +``` + +**공식:** +``` +총액 = 가격 × 수량 × (1 - 할인율) +결과는 Math.round()로 반올림 +``` + +### 2. 장바구니 총액 계산 (calculateCartTotal) +```typescript +calculateCartTotal( + cart: CartItem[], + selectedCoupon: Coupon | null +): { totalBeforeDiscount: number; totalAfterDiscount: number } +``` + +**로직:** +1. **할인 전 총액**: 모든 아이템의 `가격 × 수량` 합계 +2. **아이템 할인 적용**: 각 아이템에 `calculateItemTotal()` 적용하여 합계 +3. **쿠폰 할인 적용**: 선택된 쿠폰이 있으면 `applyCoupon()` 적용 +4. **반올림**: 모든 금액은 `Math.round()`로 반올림 + +--- + +## 🎫 쿠폰 로직 + +### 1. 쿠폰 적용 (applyCoupon) +```typescript +applyCoupon(amount: number, coupon: Coupon): number +``` + +**로직:** +- **amount 타입**: `amount - discountValue` (최소 0) +- **percentage 타입**: `amount × (1 - discountValue / 100)` (반올림) + +### 2. 쿠폰 이름 포맷팅 (formatCouponName) +```typescript +formatCouponName(coupons: Coupon[]): Coupon[] +``` + +**로직:** +- 쿠폰 이름에 할인 정보 추가 +- amount: `"쿠폰명 (5,000원)"` +- percentage: `"쿠폰명 (10%)"` + +--- + +## 🔍 상품 필터링 로직 + +### filterProductsBySearchTerm +```typescript +filterProductsBySearchTerm( + debouncedSearchTerm: string, + products: ProductWithUI[] +): ProductWithUI[] +``` + +**로직:** +1. 검색어가 없으면 모든 상품 반환 +2. 검색어가 있으면: + - 상품명에 포함되는지 확인 (대소문자 무시) + - 설명에 포함되는지 확인 (대소문자 무시) + - 둘 중 하나라도 포함되면 필터링 통과 + +--- + +## 📦 재고 관리 로직 + +### 1. 재고 잔량 확인 (getRemainingStock) +```typescript +getRemainingStock(cart: CartItem[], product: Product): number +``` + +**로직:** +- 상품의 총 재고에서 장바구니에 담긴 수량 차감 +- 장바구니에 없으면 전체 재고 반환 + +### 2. 품절 확인 (isSoldOut) +```typescript +isSoldOut( + cart: CartItem[], + product: ProductWithUI, + productId?: string +): boolean +``` + +**로직:** +- `getRemainingStock()` 결과가 0 이하이면 품절 +- `productId`가 없으면 false 반환 + +--- + +## 💱 가격 포맷팅 로직 + +### formatPrice +```typescript +formatPrice(price: number, type: "kr" | "en" = "kr"): string +``` + +**로직:** +- **kr**: `"10,000원"` 형식 +- **en**: `"₩10,000"` 형식 +- `toLocaleString()`으로 천 단위 구분 + +--- + +## 🔄 상태 업데이트 패턴 + +### 1. 함수형 업데이트 (권장) +```typescript +// ProductBasicFields에서 사용 +setProductForm((prev) => ({ + ...prev, + name: newName, +})); +``` + +**이유:** +- 빠른 연속 업데이트에서도 최신 상태 보장 +- 클로저 문제 방지 + +### 2. 직접 업데이트 (주의) +```typescript +// 클로저 문제 가능성 +setProductForm({ + ...productForm, + name: newName, +}); +``` + +--- + +## ⚠️ 주의사항 + +### 1. 할인율 제한 +- 최대 할인율은 50%로 제한 +- 기본 할인 + 대량 구매 보너스 합이 50% 초과 시 50%로 제한 + +### 2. 쿠폰 적용 조건 +- percentage 쿠폰은 10,000원 이상 구매 시만 사용 가능 +- `applyCoupon` 함수에서 검증 + +### 3. 재고 검증 +- 장바구니 추가 시 `getRemainingStock()` 확인 +- 수량 업데이트 시 재고 초과 방지 + +### 4. 금액 반올림 +- 모든 금액 계산 결과는 `Math.round()`로 반올림 +- 소수점 발생 시 정수로 변환 + +--- + +## 🚀 Zustand 리팩토링 후 + +### Store 내부에서 사용 +- 모든 계산 함수는 Store 내부에서 사용 +- 순수 함수이므로 Store에서 직접 호출 가능 +- 컴포넌트는 계산 결과만 사용 + +### 예시 +```typescript +// Zustand Store 내부 +const useCartStore = create((set, get) => ({ + cart: [], + totals: () => { + const { cart, selectedCoupon } = get(); + return calculateCartTotal(cart, selectedCoupon); + }, + filledItems: () => { + const { cart } = get(); + return cart.map((item) => ({ + ...item, + priceDetails: calculateItemPriceDetails(item, cart), + })); + }, +})); +``` + diff --git a/.cursor/mockdowns/advanced/advanced-components.md b/.cursor/mockdowns/advanced/advanced-components.md new file mode 100644 index 000000000..c0ff76784 --- /dev/null +++ b/.cursor/mockdowns/advanced/advanced-components.md @@ -0,0 +1,426 @@ +# Advanced 프로젝트 - 컴포넌트 구조 + +## 📁 컴포넌트 계층 구조 + +``` +App.tsx (루트) +├── DefaultLayout +│ ├── Notifications (topContent) +│ ├── Header +│ │ ├── SearchBar (headerLeft, 관리자 모드 아님) +│ │ └── HeaderActions (headerRight) +│ └── main +│ ├── StorePage (isAdmin === false) +│ │ ├── ProductList +│ │ │ └── ProductItem (반복) +│ │ └── CartSidebar +│ │ ├── CartList +│ │ │ └── CartItemRow (반복) +│ │ ├── CouponSection +│ │ └── PaymentSummary +│ └── AdminPage (isAdmin === true) +│ ├── AdminTabs +│ ├── AdminProductsSection (activeTab === "products") +│ │ ├── SectionHeader +│ │ ├── ProductListTable +│ │ │ └── ProductTableRow (반복) +│ │ └── ProductFormSection (showProductForm === true) +│ │ ├── ProductBasicFields +│ │ │ └── FormInputField (4개) +│ │ └── ProductDiscountList +│ │ └── ProductDiscountRow (반복) +│ └── AdminCouponSection (activeTab === "coupons") +│ ├── CouponList +│ │ └── CouponItem (반복) +│ └── CouponFormSection +│ ├── CouponFormFields +│ └── CouponFormActions +``` + +--- + +## 🎨 페이지 컴포넌트 + +### StorePage +**위치**: `pages/StorePage.tsx` + +**역할**: 쇼핑몰 메인 페이지 + +**Props:** +```typescript +interface StorePageProps { + productProps: ProductListProps; + cartSidebarProps: CartSidebarProps; +} +``` + +**구조:** +- 좌측: 상품 목록 (3/4 너비) +- 우측: 장바구니 사이드바 (1/4 너비, sticky) + +**현재 Props Drilling:** +- `productProps`: cart, products, filteredProducts, debouncedSearchTerm, addToCart +- `cartSidebarProps`: filledItems, removeFromCart, updateQuantity, coupons, selectedCouponCode, selectorOnChange, totals, completeOrder + +**Zustand 리팩토링 후:** +- 전역 상태는 Store에서 직접 사용 +- 도메인 props만 전달 (예: productId, onClick 등) + +--- + +### AdminPage +**위치**: `pages/AdminPage.tsx` + +**역할**: 관리자 대시보드 + +**Props:** +```typescript +interface AdminPageProps { + activeTab: AdminTabKey; // "products" | "coupons" + adminProductsProps: AdminProductsSectionProps; + adminCouponProps: AdminCouponSectionProps; + setActiveTab: React.Dispatch>; +} +``` + +**구조:** +- 탭: 상품 관리 / 쿠폰 관리 +- 탭에 따라 다른 섹션 표시 + +--- + +## 🛍️ 상품 관련 컴포넌트 + +### ProductList +**위치**: `components/product/ProductList.tsx` + +**역할**: 상품 목록 표시 + +**Props:** +```typescript +interface ProductListProps { + format: PriceType; + cart: CartItem[]; + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + addToCart: (product: ProductWithUI) => void; +} +``` + +**Zustand 리팩토링 후:** +- `cart`, `products`, `filteredProducts`, `addToCart`는 Store에서 직접 사용 +- `format`만 props로 전달 (UI 설정) + +--- + +### ProductItem +**위치**: `components/product/ProductItem.tsx` + +**역할**: 개별 상품 카드 + +**기능:** +- 상품 정보 표시 +- 장바구니 담기 버튼 +- 재고 상태 표시 + +**Zustand 리팩토링 후:** +- `addToCart`는 Store에서 직접 사용 +- `product` props는 유지 (도메인 데이터) + +--- + +### AdminProductsSection +**위치**: `components/admin/product/AdminProductsSection.tsx` + +**역할**: 관리자 상품 관리 섹션 + +**Props:** +```typescript +interface AdminProductsSectionProps { + productListTableProps: ProductListTableProps; + productForm: ProductForm; + showProductForm: boolean; + editingProduct: string | null; + setEditingProduct: (value: React.SetStateAction) => void; + setProductForm: (value: React.SetStateAction) => void; + setShowProductForm: (value: React.SetStateAction) => void; + handleProductSubmit: (e: React.FormEvent) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +} +``` + +**Zustand 리팩토링 후:** +- `productForm`, `showProductForm`, `editingProduct`, `handleProductSubmit`는 Store에서 직접 사용 +- `addNotification`은 Store에서 직접 사용 (또는 props 유지) + +--- + +## 🛒 장바구니 관련 컴포넌트 + +### CartSidebar +**위치**: `components/cart/CartSidebar.tsx` + +**역할**: 장바구니 사이드바 + +**Props:** +```typescript +CartSidebarProps { + cartProps: { + filledItems: FilledCartItem[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + }; + couponProps: { + coupons: Coupon[]; + selectedCouponCode: string; + selectorOnChange: (e: React.ChangeEvent) => void; + }; + payment: { + totals: { totalBeforeDiscount: number; totalAfterDiscount: number }; + completeOrder: () => void; + }; +} +``` + +**Zustand 리팩토링 후:** +- 모든 props를 Store에서 직접 사용 +- Props 없이 컴포넌트 내부에서 Store 사용 + +--- + +### CartList +**위치**: `components/cart/CartList.tsx` + +**역할**: 장바구니 아이템 목록 + +**기능:** +- FilledCartItem 배열 렌더링 +- 각 아이템에 CartItemRow 사용 + +**Zustand 리팩토링 후:** +- `filledItems`는 Store에서 직접 사용 + +--- + +### CartItemRow +**위치**: `components/cart/CartItemRow.tsx` + +**역할**: 개별 장바구니 아이템 행 + +**기능:** +- 상품 정보 표시 +- 수량 조절 (+/- 버튼) +- 삭제 버튼 +- 할인 정보 표시 + +**Zustand 리팩토링 후:** +- `removeFromCart`, `updateQuantity`는 Store에서 직접 사용 +- `productId` props는 유지 (도메인 데이터) + +--- + +## 🎫 쿠폰 관련 컴포넌트 + +### CouponSection +**위치**: `components/cart/CouponSection.tsx` + +**역할**: 쿠폰 선택 섹션 + +**기능:** +- 쿠폰 드롭다운 선택 +- 선택된 쿠폰 표시 + +**Zustand 리팩토링 후:** +- `coupons`, `selectedCouponCode`, `selectorOnChange`는 Store에서 직접 사용 + +--- + +### AdminCouponSection +**위치**: `components/admin/coupon/AdminCouponSection.tsx` + +**역할**: 관리자 쿠폰 관리 섹션 + +**구조:** +- CouponList: 쿠폰 목록 +- CouponFormSection: 쿠폰 추가 폼 (조건부 렌더링) + +**Zustand 리팩토링 후:** +- 쿠폰 관련 상태는 Store에서 직접 사용 + +--- + +## 🧩 공통 컴포넌트 + +### FormInputField +**위치**: `components/common/FormInputField.tsx` + +**역할**: 재사용 가능한 입력 필드 + +**Props:** +```typescript +interface FormInputFieldProps { + fieldName: string; + value: string | number; + onChange?: (e: React.ChangeEvent) => void; + onBlur?: (e: React.FocusEvent) => void; + placeholder?: string; + required?: boolean; // 기본값: true +} +``` + +**중요:** +- `required` prop 추가됨 (기본값: `true`) +- 설명 필드 등 선택 입력 필드에 `required={false}` 전달 필요 + +--- + +### SearchBar +**위치**: `components/common/SearchBar.tsx` + +**역할**: 검색 입력창 + +**기능:** +- 검색어 입력 +- 디바운스는 useSearch Hook에서 처리 (500ms) + +**Zustand 리팩토링 후:** +- `searchTerm`, `setSearchTerm`은 Store에서 직접 사용 (또는 로컬 상태 유지) + +--- + +### Notifications +**위치**: `components/notifications/Notification.tsx` + +**역할**: 알림 메시지 표시 + +**기능:** +- 상단에 알림 표시 +- 3초 후 자동 제거 +- 타입별 스타일 (error, success, warning) + +**Zustand 리팩토링 후:** +- `notifications`, `setNotifications`는 Store에서 직접 사용 + +--- + +## 🎨 레이아웃 컴포넌트 + +### DefaultLayout +**위치**: `components/layouts/DefaultLayout.tsx` + +**역할**: 기본 레이아웃 + +**Props:** +```typescript +interface DefaultLayoutProps { + topContent?: ReactNode; // 알림 등 + headerProps: { + headerLeft?: ReactNode; // 검색창 등 + headerRight?: ReactNode; // 헤더 액션 등 + }; + children: React.ReactNode; +} +``` + +**구조:** +- topContent: 상단 (알림) +- Header: 헤더 +- main: 메인 콘텐츠 + +--- + +### Header +**위치**: `components/layouts/Header.tsx` + +**역할**: 페이지 헤더 + +**구조:** +- 좌측: headerLeft (검색창 등) +- 우측: headerRight (헤더 액션) + +--- + +### HeaderActions +**위치**: `components/layouts/HeaderActions.tsx` + +**역할**: 헤더 액션 버튼 + +**기능:** +- 관리자 모드 전환 버튼 +- 장바구니 아이콘 (아이템 개수 표시) + +**Zustand 리팩토링 후:** +- `cart`, `totalItemCount`는 Store에서 직접 사용 +- `isAdmin`, `setIsAdmin`은 로컬 상태 유지 가능 + +--- + +## 🔧 컴포넌트 설계 원칙 + +### 1. 단일 책임 원칙 +- 각 컴포넌트는 하나의 명확한 역할 +- 재사용 가능한 작은 컴포넌트로 분리 + +### 2. Props 타입 정의 +- 모든 컴포넌트 Props는 TypeScript 인터페이스로 정의 +- 타입 안정성 보장 + +### 3. 조건부 렌더링 +- `showProductForm`, `isAdmin` 등 상태에 따라 렌더링 +- 불필요한 렌더링 방지 + +### 4. 함수형 업데이트 +- 상태 업데이트는 함수형 패턴 사용 +- 클로저 문제 방지 + +--- + +## 🎯 Zustand 리팩토링 후 예상 구조 + +### Props Drilling 제거 전략 + +**제거할 Props:** +- 전역 상태로 관리되는 값 (products, cart, coupons 등) +- 전역 액션 함수 (addToCart, removeFromCart 등) +- 계산된 값 (filledItems, totals, filteredProducts 등) + +**유지할 Props:** +- 도메인 관련 props (productId, onClick 등) +- UI 설정 props (format, placeholder 등) +- 이벤트 핸들러 (단, 전역 액션이 아닌 경우) + +### 컴포넌트 변경 예시 + +**이전 (Props Drilling):** +```typescript + +``` + +**이후 (Zustand Store 사용):** +```typescript + +// 컴포넌트 내부에서 Store 사용 +const { cart, products, filteredProducts, addToCart } = useProductStore(); +``` + +--- + +## ⚠️ 주의사항 + +### 1. FormInputField의 required prop +- 기본값이 `true`이므로 선택 입력 필드는 명시적으로 `required={false}` 전달 + +### 2. 상태 업데이트 패턴 +- `ProductBasicFields`에서 함수형 업데이트 필수 +- 빠른 연속 입력에서도 정확한 상태 보장 + +### 3. Props 빌더 함수 +- Zustand 리팩토링 후 대부분 제거 가능 +- Store에서 직접 사용하므로 불필요 + diff --git a/.cursor/mockdowns/advanced/advanced-domain-types.md b/.cursor/mockdowns/advanced/advanced-domain-types.md new file mode 100644 index 000000000..100dc3d3e --- /dev/null +++ b/.cursor/mockdowns/advanced/advanced-domain-types.md @@ -0,0 +1,252 @@ +# Advanced 프로젝트 - 도메인 모델 및 타입 + +## 📦 타입 정의 위치 + +### 공통 타입 (src/types.ts) +프로젝트 전체에서 사용하는 기본 타입 정의 + +### 도메인별 타입 +- `src/advanced/domain/product/productTypes.ts` - 상품 관련 타입 +- `src/advanced/domain/cart/cartTypes.ts` - 장바구니 관련 타입 +- `src/advanced/domain/notification/notificationTypes.ts` - 알림 관련 타입 + +--- + +## 🛍️ 상품 도메인 (Product Domain) + +### 기본 타입 (src/types.ts) + +```typescript +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface Discount { + quantity: number; // 할인 적용 최소 수량 + rate: number; // 할인율 (0.1 = 10%) +} +``` + +### 확장 타입 (domain/product/productTypes.ts) + +```typescript +// UI용 확장 상품 타입 +export interface ProductWithUI extends Product { + description?: string; // 상품 설명 (선택) + isRecommended?: boolean; // 추천 상품 여부 +} + +// 상품 폼 타입 +export interface ProductForm { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} +``` + +### Props 타입 + +```typescript +// 상품 목록 Props +export interface ProductListProps { + cart: CartItem[]; + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + addToCart: (product: ProductWithUI) => void; +} + +// 장바구니 사이드바 Props +export interface CartSidebarProps { + cartProps: { + filledItems: FilledCartItem[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + }; + couponProps: { + coupons: Coupon[]; + selectedCouponCode: string; + selectorOnChange: (e: React.ChangeEvent) => void; + }; + payment: { + totals: { totalBeforeDiscount: number; totalAfterDiscount: number }; + completeOrder: () => void; + }; +} +``` + +--- + +## 🛒 장바구니 도메인 (Cart Domain) + +### 기본 타입 (src/types.ts) + +```typescript +export interface CartItem { + product: Product; + quantity: number; +} +``` + +### 확장 타입 (domain/cart/cartTypes.ts) + +```typescript +// 가격 정보가 포함된 장바구니 아이템 +export type FilledCartItem = CartItem & { + priceDetails: { + itemTotal: number; // 할인 적용 후 총액 + hasDiscount: boolean; // 할인 여부 + discountRate: number; // 할인율 (퍼센트, 0-100) + }; +}; +``` + +--- + +## 🎫 쿠폰 도메인 (Coupon Domain) + +### 기본 타입 (src/types.ts) + +```typescript +export interface Coupon { + name: string; // 쿠폰 이름 + code: string; // 쿠폰 코드 + discountType: 'amount' | 'percentage'; // 할인 타입 + discountValue: number; // 할인 값 (금액 또는 퍼센트) +} +``` + +--- + +## 🔔 알림 도메인 (Notification Domain) + +### 타입 정의 (domain/notification/notificationTypes.ts) + +```typescript +export interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} +``` + +--- + +## 📊 상수 정의 (constans/constans.ts) + +```typescript +// 가격 표시 형식 +export enum PriceType { + KR = "kr", // "10,000원" 형식 + EN = "en", // "₩10,000" 형식 +} + +// 할인 타입 +export enum DiscountType { + AMOUNT = "amount", // 금액 할인 + PRECENTAGE = "percentage" // 퍼센트 할인 +} +``` + +--- + +## 🔗 타입 관계도 + +``` +Product (기본) + └─ ProductWithUI (UI 확장) + ├─ description?: string + └─ isRecommended?: boolean + +CartItem + ├─ product: Product + └─ quantity: number + └─ FilledCartItem (가격 정보 추가) + └─ priceDetails: { itemTotal, hasDiscount, discountRate } + +Coupon + ├─ discountType: 'amount' | 'percentage' + └─ discountValue: number + +Notification + ├─ type: "error" | "success" | "warning" + └─ message: string +``` + +--- + +## 📝 주요 타입 사용 패턴 + +### 1. 상품 추가/수정 +```typescript +// 추가 시: id 제외 +addProduct(newProduct: Omit) + +// 수정 시: 부분 업데이트 +updateProduct(productId: string, updates: Partial) +``` + +### 2. 상태 업데이트 +```typescript +// 함수형 업데이트 패턴 (권장) +setProductForm((prev) => ({ + ...prev, + name: newName, +})); +``` + +### 3. Props 전달 +```typescript +// Props 객체 빌더 패턴 +const buildAdminProductsSection = () => { + return { + productForm, + showProductForm, + handleProductSubmit, + // ... + }; +}; +``` + +--- + +## ⚠️ 타입 주의사항 + +### 1. ProductForm vs ProductWithUI +- `ProductForm`: 폼 입력용 (id 없음, description 필수) +- `ProductWithUI`: 실제 상품 데이터 (id 있음, description 선택) + +### 2. FilledCartItem +- `CartItem`에 `priceDetails`가 추가된 타입 +- `useMemo`로 계산된 값 포함 + +### 3. Discount +- `quantity`: 할인 적용 최소 수량 +- `rate`: 할인율 (0.1 = 10%, 소수점 형식) + +--- + +## 🔄 타입 변환 함수 + +### formatCouponName (couponUtils.ts) +```typescript +// 쿠폰 이름에 할인 정보 추가 +formatCouponName(coupons: Coupon[]): Coupon[] +// "5000원 할인" → "5000원 할인 (5,000원)" +// "10% 할인" → "10% 할인 (10%)" +``` + +### formatPrice (formatters.ts) +```typescript +// 가격 포맷팅 +formatPrice(price: number, type: "kr" | "en"): string +// 10000, "kr" → "10,000원" +// 10000, "en" → "₩10,000" +``` + diff --git a/.cursor/mockdowns/advanced/advanced-issues-solutions.md b/.cursor/mockdowns/advanced/advanced-issues-solutions.md new file mode 100644 index 000000000..fb15efa59 --- /dev/null +++ b/.cursor/mockdowns/advanced/advanced-issues-solutions.md @@ -0,0 +1,385 @@ +# Advanced 프로젝트 - 주요 이슈 및 해결 방법 + +## 🐛 현재 상태 + +### 기본과제 완료 상태 +- ✅ Component에서 비즈니스 로직을 Hook으로 분리 완료 +- ✅ entities -> features -> UI 계층 구조 구현 완료 +- ✅ 순수 함수와 액션 분리 완료 + +### 심화과제 진행 상태 +- ⏳ Zustand 설치 필요 +- ⏳ Zustand Store 설계 필요 +- ⏳ Hook을 Zustand Store로 리팩토링 필요 +- ⏳ Props drilling 제거 필요 + +--- + +## 📋 Zustand 설치 안내 + +### 설치 명령어 +다음 명령어로 Zustand를 설치해주세요: + +```bash +pnpm add zustand +``` + +### 설치 확인 +설치 후 다음을 확인해주세요: +- `package.json`에 `zustand` 의존성 추가 확인 +- 버전 충돌 없이 설치되었는지 확인 +- React 19.1.1과 호환되는 Zustand 버전인지 확인 + +### 예상 버전 +- Zustand 최신 안정 버전 (React 19 호환) + +--- + +## 🎯 Zustand 리팩토링 계획 + +### 1. Store 설계 + +#### useProductStore +```typescript +// 예상 구조 +interface ProductStore { + // State + products: ProductWithUI[]; + productForm: ProductForm; + editingProduct: string | null; + showProductForm: boolean; + + // Actions + addProduct: (newProduct: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; + startEditProduct: (product: ProductWithUI) => void; + handleProductSubmit: (e: React.FormEvent) => void; + setProductForm: (form: ProductForm | ((prev: ProductForm) => ProductForm)) => void; + setEditingProduct: (id: string | null) => void; + setShowProductForm: (show: boolean) => void; +} +``` + +#### useCartStore +```typescript +// 예상 구조 +interface CartStore { + // State + cart: CartItem[]; + totalItemCount: number; + + // Computed + filledItems: FilledCartItem[]; + + // Actions + addToCart: (product: ProductWithUI) => void; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + completeOrder: () => void; +} +``` + +#### useCouponStore +```typescript +// 예상 구조 +interface CouponStore { + // State + coupons: Coupon[]; + selectedCoupon: Coupon | null; + couponForm: Coupon; + showCouponForm: boolean; + + // Actions + addCoupon: (newCoupon: Coupon) => void; + deleteCoupon: (couponCode: string) => void; + applyCoupon: (coupon: Coupon) => void; + handleCouponSubmit: (e: React.FormEvent) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; + setCouponForm: (form: Coupon | ((prev: Coupon) => Coupon)) => void; + setShowCouponForm: (show: boolean) => void; + selectorOnChange: (e: React.ChangeEvent) => void; +} +``` + +#### useNotificationStore (선택적) +```typescript +// 예상 구조 +interface NotificationStore { + // State + notifications: Notification[]; + + // Actions + addNotification: (message: string, type?: "error" | "success" | "warning") => void; + removeNotification: (id: string) => void; +} +``` + +#### useSearchStore (선택적) +```typescript +// 예상 구조 +interface SearchStore { + // State + searchTerm: string; + debouncedSearchTerm: string; + + // Actions + setSearchTerm: (term: string) => void; +} +``` + +--- + +### 2. Props Drilling 제거 전략 + +#### 제거할 Props +- 전역 상태로 관리되는 값 + - `products`, `cart`, `coupons` + - `productForm`, `couponForm` + - `selectedCoupon` + - `notifications` + - `searchTerm`, `debouncedSearchTerm` + +- 전역 액션 함수 + - `addToCart`, `removeFromCart`, `updateQuantity` + - `addProduct`, `updateProduct`, `deleteProduct` + - `addCoupon`, `deleteCoupon`, `applyCoupon` + - `addNotification` + +- 계산된 값 + - `filledItems` + - `totals` + - `filteredProducts` + - `totalItemCount` + +#### 유지할 Props +- 도메인 관련 props + - `productId` - 특정 상품 식별 + - `onClick` - 이벤트 핸들러 (도메인 액션이 아닌 경우) + - `format` - UI 설정 (PriceType) + +- UI 설정 props + - `placeholder` - 입력 필드 플레이스홀더 + - `required` - 필수 입력 여부 + +--- + +### 3. 컴포넌트 변경 예시 + +#### 이전 (Props Drilling) +```typescript +// App.tsx + + +// ProductList.tsx +export const ProductList = ({ + cart, + products, + filteredProducts, + debouncedSearchTerm, + addToCart, +}: ProductListProps) => { + // ... +}; +``` + +#### 이후 (Zustand Store 사용) +```typescript +// App.tsx + + +// ProductList.tsx +export const ProductList = ({ format }: { format: PriceType }) => { + const { cart, products, filteredProducts, debouncedSearchTerm } = useProductStore(); + const { addToCart } = useCartStore(); + + // ... +}; +``` + +--- + +## ⚠️ 주의사항 + +### 1. localStorage 동기화 +- Zustand Store 내부에서 localStorage 동기화 처리 +- `persist` 미들웨어 사용 고려 +- 또는 `subscribe`를 사용하여 수동 동기화 + +### 2. 의존성 관리 +- Store 간 의존성 명확히 +- `useCartStore`는 `useProductStore`의 `products` 참조 필요 +- `useCouponStore`는 `useCartStore`의 `cart` 참조 필요 + +### 3. 테스트 코드 수정 불가 +- 테스트 코드는 수정하지 않음 +- Store 사용 시에도 동일한 동작 보장 +- 컴포넌트 동작은 동일하게 유지 + +### 4. 점진적 리팩토링 +- 한 번에 하나씩 Store로 변환 +- 각 단계마다 테스트 확인 +- 문서 업데이트 + +--- + +## 🔄 리팩토링 단계 + +### Step 1: Zustand 설치 ✅ +- [x] pnpm으로 Zustand 설치 완료 (v5.0.9) + +### Step 2: Store 설계 +- [ ] useProductStore 설계 +- [ ] useCartStore 설계 +- [ ] useCouponStore 설계 +- [ ] useNotificationStore 설계 (선택적) +- [ ] useSearchStore 설계 (선택적) + +### Step 3: Store 구현 +- [ ] useProductStore 구현 +- [ ] useCartStore 구현 +- [ ] useCouponStore 구현 +- [ ] localStorage 동기화 구현 + +### Step 4: Hook 리팩토링 +- [ ] useProduct를 useProductStore로 대체 +- [ ] useCart를 useCartStore로 대체 +- [ ] useCoupon을 useCouponStore로 대체 + +### Step 5: Props Drilling 제거 +- [ ] App.tsx에서 불필요한 props 제거 +- [ ] 컴포넌트에서 Store 직접 사용 +- [ ] Props 빌더 함수 제거 + +### Step 6: 테스트 및 검증 +- [ ] 모든 테스트 통과 확인 +- [ ] 기능 동작 확인 +- [ ] 코드 리뷰 + +--- + +## 📝 작업 기록 + +### 2025-01-XX +- basic 프로젝트를 advanced로 복사 완료 +- 문서 작성 완료 +- Zustand 설치 완료 (v5.0.9) +- 다음 단계: Zustand Store 설계 및 구현 + +--- + +## 🔍 발견된 패턴 및 베스트 프랙티스 + +### 1. Zustand Store 패턴 +```typescript +// ✅ 권장: Store 분리 +const useProductStore = create((set, get) => ({ + products: [], + addProduct: (newProduct) => { + // 로직 + }, +})); + +// ✅ 권장: localStorage 동기화 +const useProductStore = create( + persist( + (set, get) => ({ + // Store 로직 + }), + { + name: 'product-storage', + } + ) +); +``` + +### 2. Props 전달 기준 +```typescript +// ✅ 유지: 도메인 props + + +// ❌ 제거: 전역 상태 props + +``` + +### 3. Store 선택적 사용 +```typescript +// ✅ Store에서 필요한 것만 선택 +const { products, addProduct } = useProductStore(); + +// ✅ 계산된 값은 selector로 +const filledItems = useCartStore((state) => + state.cart.map(item => ({ + ...item, + priceDetails: calculateItemPriceDetails(item, state.cart), + })) +); +``` + +--- + +## ⚠️ 주의해야 할 패턴 + +### 1. Store 간 의존성 +```typescript +// ❌ 문제 있는 코드 +const useCartStore = create((set, get) => { + const products = useProductStore.getState().products; // 잘못된 패턴 + + return { + addToCart: (product) => { + // products 사용 + }, + }; +}); + +// ✅ 해결 방법: 함수 내에서 getState 사용 +const useCartStore = create((set, get) => ({ + addToCart: (product) => { + const products = useProductStore.getState().products; + // 로직 + }, +})); +``` + +### 2. localStorage 동기화 +```typescript +// ✅ 올바른 패턴: persist 미들웨어 +import { persist } from 'zustand/middleware'; + +const useProductStore = create( + persist( + (set) => ({ + products: [], + // ... + }), + { + name: 'product-storage', + } + ) +); +``` + +--- + +## 📝 코드 리뷰 체크리스트 + +### Zustand 리팩토링 +- [ ] Store 설계 명확히 +- [ ] localStorage 동기화 구현 +- [ ] Props drilling 제거 +- [ ] 도메인 props 유지 +- [ ] 테스트 통과 확인 + +### 코드 품질 +- [ ] 타입 오류 없음 +- [ ] 린터 오류 없음 +- [ ] 기존 기능 정상 동작 +- [ ] 코드 가독성 양호 + diff --git a/.cursor/mockdowns/advanced/advanced-project-overview.md b/.cursor/mockdowns/advanced/advanced-project-overview.md new file mode 100644 index 000000000..6d8f0e437 --- /dev/null +++ b/.cursor/mockdowns/advanced/advanced-project-overview.md @@ -0,0 +1,188 @@ +# Advanced 프로젝트 개요 문서 + +## 📋 프로젝트 정보 + +### 프로젝트명 +`frontend_7th_chapter3-2` - Advanced 버전 + +### 프로젝트 목적 +basic 폴더의 Hook 기반 구조를 Zustand를 사용한 전역 상태 관리로 리팩토링한 버전입니다. Props drilling을 제거하고 결합도를 낮추는 것이 목표입니다. + +### 프로젝트 위치 +`src/advanced/` + +### 현재 상태 +- ✅ basic 프로젝트의 모든 내용이 복사됨 +- ✅ Hook 기반 구조 유지 +- ✅ Zustand 설치 완료 (v5.0.9) +- ⏳ Zustand Store 설계 및 구현 예정 +- ⏳ 전역 상태 관리로 리팩토링 예정 + +--- + +## 🏗️ 프로젝트 구조 + +``` +src/advanced/ +├── App.tsx # 메인 애플리케이션 컴포넌트 (Hook 기반) +├── main.tsx # React 앱 진입점 +├── __tests__/ # 테스트 파일 +│ └── origin.test.tsx # 통합 테스트 (origin과 동일한 테스트) +├── components/ # UI 컴포넌트 +│ ├── admin/ # 관리자 페이지 컴포넌트 +│ ├── cart/ # 장바구니 관련 컴포넌트 +│ ├── common/ # 공통 컴포넌트 +│ ├── icon/ # 아이콘 컴포넌트 +│ ├── layouts/ # 레이아웃 컴포넌트 +│ ├── notifications/ # 알림 컴포넌트 +│ └── product/ # 상품 목록 컴포넌트 +├── pages/ # 페이지 컴포넌트 +│ ├── StorePage.tsx # 쇼핑몰 페이지 +│ └── AdminPage.tsx # 관리자 페이지 +├── domain/ # 도메인 로직 및 타입 +│ ├── cart/ # 장바구니 도메인 +│ ├── product/ # 상품 도메인 +│ └── notification/ # 알림 도메인 +├── hooks/ # Custom Hook (현재 구조) +│ ├── useProduct.ts # 상품 Entity Hook +│ ├── useCart.ts # 장바구니 Entity Hook +│ ├── useCoupon.ts # 쿠폰 Entity Hook +│ ├── useNotification.ts # 알림 UI Hook +│ └── useSearch.ts # 검색 UI Hook +├── utils/ # 유틸리티 함수 +│ └── formatters.ts # 포맷팅 함수 +└── constans/ # 상수 정의 + └── constans.ts # 상수 (PriceType, DiscountType 등) +``` + +--- + +## 🛠️ 기술 스택 + +### 프레임워크 및 라이브러리 +- **React**: `^19.1.1` +- **React DOM**: `^19.1.1` +- **TypeScript**: `^5.9.2` +- **Vite**: `^7.0.6` (빌드 도구) +- **Zustand**: `^5.0.9` (전역 상태 관리) ✅ + +### 개발 도구 +- **Vitest**: `^3.2.4` (테스트 프레임워크) +- **@testing-library/react**: `^16.3.0` (React 테스트 라이브러리) +- **@testing-library/jest-dom**: `^6.6.4` (DOM 매처) +- **@vitejs/plugin-react-swc**: `^3.11.0` (SWC 플러그인) + +### 패키지 관리자 +- **pnpm** (주로 사용) + +### 스타일링 +- **Tailwind CSS** (인라인 클래스 사용) + +--- + +## 🚀 실행 스크립트 + +```bash +# Advanced 버전 개발 서버 실행 +npm run dev:advanced + +# Advanced 버전 테스트 실행 +npm run test:advanced + +# 테스트 UI 실행 +npm run test:ui + +# 빌드 +npm run build + +# 린트 +npm run lint +``` + +--- + +## 🎯 심화과제 목표 + +### 요구사항 (pull_request_template.md) +1. Zustand를 사용해서 전역상태관리를 구축 +2. 전역상태관리를 통해 domain custom hook을 적절하게 리팩토링 +3. 도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거 +4. 전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드 + +### 현재 상태 +- ✅ Hook 기반 구조 완료 (basic에서 복사) +- ⏳ Zustand 설치 필요 +- ⏳ Zustand Store 설계 필요 +- ⏳ Hook을 Zustand Store로 리팩토링 필요 +- ⏳ Props drilling 제거 필요 + +--- + +## 📦 현재 Hook 구조 + +### Entity Hook (전역 상태로 이동 예정) +- **useProduct**: 상품 Entity 상태 및 CRUD 로직 +- **useCart**: 장바구니 Entity 상태 및 로직 +- **useCoupon**: 쿠폰 Entity 상태 및 CRUD 로직 + +### UI Hook (로컬 상태 유지 가능) +- **useNotification**: 알림 UI 상태 +- **useSearch**: 검색어 UI 상태 + +--- + +## 🔄 리팩토링 계획 + +### 1. Zustand Store 설계 +- `useProductStore` - 상품 전역 상태 +- `useCartStore` - 장바구니 전역 상태 +- `useCouponStore` - 쿠폰 전역 상태 +- `useNotificationStore` - 알림 전역 상태 (선택적) +- `useSearchStore` - 검색어 전역 상태 (선택적) + +### 2. Props Drilling 제거 +- 전역 상태로 관리되는 값은 props로 전달하지 않음 +- 도메인 관련 props는 유지 (예: `productId`, `onClick` 등) +- 불필요한 props 제거 + +### 3. Hook 리팩토링 +- Entity Hook을 Zustand Store로 변환 +- UI Hook은 필요에 따라 유지 또는 Store로 변환 + +--- + +## ⚠️ 주의사항 + +### 1. 기존 기능 보존 +- 모든 기능이 동일하게 동작해야 함 +- 테스트 코드 수정 불가 +- 기존 테스트 모두 통과해야 함 + +### 2. Props 전달 기준 +- **유지해야 할 props**: 도메인 관련 props (productId, onClick 등) +- **제거할 props**: 전역 상태로 관리되는 값 (products, cart, coupons 등) + +### 3. 점진적 리팩토링 +- 한 번에 하나씩 Store로 변환 +- 각 단계마다 테스트 확인 +- 문서 업데이트 + +--- + +## 📝 다음 문서 +- [도메인 모델 및 타입](./advanced-domain-types.md) +- [컴포넌트 구조](./advanced-components.md) +- [비즈니스 로직](./advanced-business-logic.md) +- [상태 관리](./advanced-state-management.md) +- [테스트 구조](./advanced-testing.md) + +--- + +## ✅ 설치 완료 + +### Zustand 설치 완료 +- ✅ Zustand `^5.0.9` 설치 완료 +- ✅ `package.json`에 의존성 추가 확인 +- ✅ React 19.1.1과 호환 확인 +- ✅ 버전 충돌 없음 + diff --git a/.cursor/mockdowns/advanced/advanced-rule-compliance-check.md b/.cursor/mockdowns/advanced/advanced-rule-compliance-check.md new file mode 100644 index 000000000..8c411f644 --- /dev/null +++ b/.cursor/mockdowns/advanced/advanced-rule-compliance-check.md @@ -0,0 +1,158 @@ +# Rule 준수 확인 체크리스트 + +## 📋 Step 2-3 완료 후 Rule 확인 (2025-01-XX) + +### ✅ 기본 원칙 준수 + +#### 1. 기존 기능 보존 ✅ +- [x] Store 구현 시 기존 Hook과 동일한 기능 제공 +- [x] 모든 액션 함수 동일한 시그니처 유지 +- [x] localStorage 동기화 로직 동일하게 구현 +- [x] 초기 데이터 동일하게 설정 + +**확인 사항:** +- useNotificationStore: addNotification, removeNotification, setNotifications ✅ +- useSearchStore: setSearchTerm, debouncedSearchTerm ✅ +- useProductStore: 모든 CRUD 액션, localStorage 동기화 ✅ +- useCartStore: 장바구니 액션, 계산 함수 ✅ +- useCouponStore: 쿠폰 액션, localStorage 동기화 ✅ + +#### 2. 테스트 코드 수정 금지 ✅ +- [x] 테스트 코드는 수정하지 않음 +- [x] Store 구현 후 테스트 실행 예정 (사용자 확인 필요) + +#### 3. 점진적 작업 진행 ✅ +- [x] Step 2: Store 구현 완료 +- [x] Step 3: localStorage 동기화 완료 +- [x] 각 단계 완료 후 문서 업데이트 + +--- + +### ✅ 작업 프로세스 준수 + +#### 1. 작업 계획 수립 ✅ +- [x] 목표 명확히 정의 (Zustand로 전역 상태 관리) +- [x] 작업 단계 나누어 계획 +- [x] `.cursor/mockdowns/advanced/` 폴더에 문서 기록 + +#### 2. 작업 진행 ✅ +- [x] 스텝별로 진행하며 문서 업데이트 +- [x] 각 단계 완료 시 문서에 기록 +- [x] 산출물에 기록하고 읽으면서 작업 + +#### 3. 테스트 및 검증 ⏳ +- [ ] 실행 및 테스트는 사용자에게 전달 예정 +- [ ] 사용자 확인 후 결과를 바탕으로 다음 작업 진행 + +#### 4. 문서 관리 ✅ +- [x] 작업 진행될 때마다 문서 수정 +- [x] 완료된 단계는 체크 표시 +- [x] 현재 진행 중인 단계 명시 + +--- + +### ✅ 기술적 규칙 준수 + +#### 1. FE 관점에서 프로 AI로서 진행 ✅ +- [x] 프론트엔드 개발 베스트 프랙티스 준수 +- [x] 코드 품질과 가독성 중시 +- [x] TypeScript 타입 정의 명확히 + +#### 2. 타입 안정성 ✅ +- [x] TypeScript 타입 정의 명확히 +- [x] 타입 오류 없음 (린터 확인 완료) +- [x] 인터페이스 명확히 정의 + +#### 3. 패키지 관리자 ✅ +- [x] pnpm 사용 (Zustand 설치 시 pnpm 사용) +- [x] 사용자가 직접 설치하도록 안내 + +--- + +### ✅ 문서화 규칙 준수 + +#### 1. 작업 계획 문서 ✅ +- [x] `.cursor/mockdowns/advanced/advanced-zustand-refactoring-plan.md` 작성 +- [x] 작업 단계, 진행 상황, 완료 내역 기록 +- [x] 체크리스트 포함 + +#### 2. 산출물 기록 ✅ +- [x] 중요한 결정사항 기록 (persist 미들웨어 사용) +- [x] 문제 해결 과정 기록 (getter → 함수로 변경) +- [x] 다음 작업을 위한 참고사항 기록 + +#### 3. 문서 업데이트 ✅ +- [x] 작업 진행될 때마다 문서 수정 +- [x] 완료된 단계는 체크 표시 +- [x] 현재 진행 중인 단계 명시 + +--- + +### ✅ 주의사항 준수 + +#### 1. 기능 변경 금지 ✅ +- [x] 기존 기능 동작 변경 없음 +- [x] API 시그니처 동일하게 유지 +- [x] 하위 호환성 유지 + +#### 2. 테스트 우선 ⏳ +- [x] 테스트 코드 수정하지 않음 +- [ ] 모든 테스트 통과 확인 예정 (Step 10에서 확인) + +#### 3. 점진적 리팩토링 ✅ +- [x] 한 번에 하나씩 변경 +- [x] 작은 단위로 나누어 진행 +- [x] 각 단계마다 검증 + +--- + +## 📊 Step 2-3 완료 요약 + +### 완료된 작업: +1. ✅ useNotificationStore 구현 (의존성 없음) +2. ✅ useSearchStore 구현 (의존성 없음) +3. ✅ useProductStore 구현 (useNotificationStore 의존, persist 포함) +4. ✅ useCartStore 구현 (useProductStore, useNotificationStore 의존, persist 포함) +5. ✅ useCouponStore 구현 (useCartStore, useNotificationStore 의존, persist 포함) +6. ✅ localStorage 동기화 (persist 미들웨어 사용) + +### Rule 준수 상태: +- ✅ 모든 Rule 준수 +- ⏳ 테스트 확인 대기 중 (Step 10에서 진행) + +### 다음 단계: +- Step 4: App.tsx 리팩토링 (Hook을 Store로 교체) ✅ 완료 +- Step 5: 컴포넌트 리팩토링 (Props drilling 제거) ⏳ 진행 중 + +--- + +## 🔄 Step 4 완료 후 Rule 확인 (2025-01-XX) + +### ✅ Step 4: App.tsx 리팩토링 완료 + +#### Rule 준수 확인: +- [x] Hook을 Store로 교체 완료 +- [x] 기존 기능 동일하게 동작 (동일한 인터페이스 유지) +- [x] Props 빌더 함수 유지 (기존 인터페이스 호환성 유지) +- [x] 타입 오류 없음 (린터 확인 완료) +- [x] 문서 업데이트 완료 + +#### 변경 사항: +- `useNotification` → `useNotificationStore` +- `useSearch` → `useSearchStore` +- `useProduct` → `useProductStore` +- `useCart` → `useCartStore` +- `useCoupon` → `useCouponStore` +- 계산된 값: `getTotalItemCount()`, `getFilledItems()` 사용 + +#### 다음 Step Rule 준수 계획: + +### Step 5: 컴포넌트 리팩토링 (Props drilling 제거) +- [ ] StorePage에서 Store 직접 사용 +- [ ] AdminPage에서 Store 직접 사용 +- [ ] 하위 컴포넌트에서 Store 직접 사용 +- [ ] 도메인 props는 유지, 전역 상태 props 제거 +- [ ] 기존 기능 동일하게 동작 확인 +- [ ] 타입 오류 및 린터 오류 확인 +- [ ] 문서 업데이트 + diff --git a/.cursor/mockdowns/advanced/advanced-state-management.md b/.cursor/mockdowns/advanced/advanced-state-management.md new file mode 100644 index 000000000..99feb896d --- /dev/null +++ b/.cursor/mockdowns/advanced/advanced-state-management.md @@ -0,0 +1,469 @@ +# Advanced 프로젝트 - 상태 관리 + +## 📍 현재 상태 관리 위치 + +모든 상태는 Hook을 통해 관리됩니다. Zustand로 리팩토링 예정입니다. + +--- + +## 🔄 현재 상태 목록 + +### 1. 상품 관련 상태 (useProduct Hook) + +```typescript +// 상품 목록 +const [products, setProducts] = useState(() => { + const saved = localStorage.getItem("products"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialProducts; + } + } + return initialProducts; +}); + +// 상품 폼 (추가/수정용) +const [productForm, setProductForm] = useState({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] as Array, +}); + +// 상품 편집 상태 +const [editingProduct, setEditingProduct] = useState(null); +const [showProductForm, setShowProductForm] = useState(false); +``` + +**특징:** +- `products`: localStorage에서 초기화, 변경 시 자동 저장 +- `productForm`: 폼 입력 상태, 함수형 업데이트 사용 +- `editingProduct`: 편집 중인 상품 ID 또는 "new" + +--- + +### 2. 장바구니 관련 상태 (useCart Hook) + +```typescript +// 장바구니 아이템 +const [cart, setCart] = useState(() => { + const saved = localStorage.getItem("cart"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return []; + } + } + return []; +}); + +// 장바구니 아이템 총 개수 (계산된 값) +const [totalItemCount, setTotalItemCount] = useState(0); +``` + +**특징:** +- `cart`: localStorage에서 초기화, 변경 시 자동 저장 +- `totalItemCount`: `useEffect`로 계산 + +--- + +### 3. 쿠폰 관련 상태 (useCoupon Hook) + +```typescript +// 쿠폰 목록 +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 [couponForm, setCouponForm] = useState({ + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0, +}); + +// 쿠폰 폼 표시 여부 +const [showCouponForm, setShowCouponForm] = useState(false); +``` + +--- + +### 4. UI 상태 + +```typescript +// 관리자 모드 여부 +const [isAdmin, setIsAdmin] = useState(false); + +// 관리자 탭 (products | coupons) +const [activeTab, setActiveTab] = useState("products"); + +// 알림 목록 (useNotification Hook) +const [notifications, setNotifications] = useState([]); + +// 검색어 (useSearch Hook) +const [searchTerm, setSearchTerm] = useState(""); + +// 디바운스된 검색어 +const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); +``` + +--- + +## 💾 localStorage 동기화 + +### 자동 저장 useEffect + +```typescript +// 상품 저장 +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]); +``` + +**특징:** +- 상태 변경 시 자동으로 localStorage에 저장 +- 장바구니가 비어있으면 localStorage에서 제거 + +--- + +## 🔄 상태 업데이트 함수 + +### useCallback으로 최적화된 함수들 + +```typescript +// 알림 추가 +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 addToCart = useCallback( + (product: ProductWithUI) => { + // 재고 확인 + const remainingStock = getRemainingStock(cart, 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] +); +``` + +**특징:** +- `useCallback`으로 함수 재생성 방지 +- 의존성 배열에 필요한 값 포함 +- 함수형 업데이트 사용 + +--- + +### 일반 함수 (useCallback 없음) + +```typescript +// 상품 폼 제출 +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); +}; +``` + +**중요:** +- `handleProductSubmit`은 `useCallback`으로 감싸지 않음 +- 매 렌더링마다 새로 생성되어 최신 `productForm` 참조 보장 +- 클로저 문제 방지 + +--- + +## 🎯 계산된 값 (useMemo) + +```typescript +// 장바구니 아이템에 가격 정보 추가 +const filledItems = useMemo( + () => + cart.map((item) => ({ + ...item, + priceDetails: calculateItemPriceDetails(item, cart), + })), + [cart] +); + +// 장바구니 총액 계산 +const totals = calculateCartTotal(cart, selectedCoupon); + +// 필터링된 상품 목록 +const filteredProducts = filterProductsBySearchTerm( + debouncedSearchTerm, + products +); +``` + +**특징:** +- `filledItems`: `useMemo`로 최적화 (cart 변경 시만 재계산) +- `totals`, `filteredProducts`: 매 렌더링마다 계산 (간단한 계산) + +--- + +## 🔄 디바운스 처리 + +```typescript +// 검색어 디바운스 +useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 500); + return () => clearTimeout(timer); +}, [searchTerm]); +``` + +**특징:** +- 500ms 지연 후 검색어 업데이트 +- 타이머 정리로 메모리 누수 방지 + +--- + +## 📊 Props 빌더 함수 + +### buildStorePageProps +```typescript +const buildStorePageProps = () => { + const productProps: ProductListProps = { + cart, + products, + filteredProducts, + debouncedSearchTerm, + addToCart, + }; + const cartSidebarProps: CartSidebarProps = { + cartProps: { + filledItems, + removeFromCart, + updateQuantity, + }, + couponProps: { + coupons, + selectedCouponCode: selectedCoupon?.code || "", + selectorOnChange, + }, + payment: { + totals, + completeOrder, + }, + }; + return { productProps, cartSidebarProps }; +}; +``` + +### buildAdminProductsSection +```typescript +const buildAdminProductsSection = () => { + const adminProductsProps: AdminProductsSectionProps = { + productListTableProps: productListTableProps(), + productForm, + showProductForm, + editingProduct, + setEditingProduct, + setProductForm, + setShowProductForm, + handleProductSubmit, + addNotification, + }; + return adminProductsProps; +}; +``` + +**특징:** +- 매 렌더링마다 호출되지만 최신 상태 참조 +- Props 객체를 한 곳에서 관리 + +--- + +## 🎯 Zustand 리팩토링 계획 + +### 1. Store 설계 +- `useProductStore` - 상품 전역 상태 +- `useCartStore` - 장바구니 전역 상태 +- `useCouponStore` - 쿠폰 전역 상태 +- `useNotificationStore` - 알림 전역 상태 (선택적) +- `useSearchStore` - 검색어 전역 상태 (선택적) + +### 2. Props Drilling 제거 +- 전역 상태로 관리되는 값은 props로 전달하지 않음 +- 컴포넌트에서 직접 Store 사용 +- 도메인 관련 props는 유지 + +### 3. Hook 리팩토링 +- Entity Hook을 Zustand Store로 변환 +- localStorage 동기화는 Store 내부에서 처리 +- UI Hook은 필요에 따라 유지 또는 Store로 변환 + +--- + +## ⚠️ 상태 관리 주의사항 + +### 1. 함수형 업데이트 필수 +```typescript +// ✅ 올바른 방법 +setProductForm((prev) => ({ + ...prev, + name: newName, +})); + +// ❌ 잘못된 방법 (클로저 문제) +setProductForm({ + ...productForm, + name: newName, +}); +``` + +### 2. handleProductSubmit은 useCallback 사용 안 함 +- 최신 `productForm` 참조를 위해 매 렌더링마다 새로 생성 +- 클로저 문제 방지 + +### 3. localStorage 동기화 +- 상태 변경 시 자동 저장 +- 초기화 시 localStorage에서 복원 +- try-catch로 JSON 파싱 오류 처리 + +### 4. 의존성 배열 관리 +- `useCallback`, `useMemo`의 의존성 배열 정확히 관리 +- 누락 시 오래된 값 참조 가능 + +--- + +## 🔄 상태 흐름도 + +``` +사용자 액션 + ↓ +이벤트 핸들러 (App.tsx 또는 컴포넌트) + ↓ +Hook 함수 호출 + ↓ +상태 업데이트 (useState setter) + ↓ +useEffect (localStorage 저장) + ↓ +재렌더링 + ↓ +Props 빌더 함수 호출 + ↓ +컴포넌트에 Props 전달 + ↓ +UI 업데이트 +``` + +--- + +## 📝 상태 관리 패턴 요약 + +1. **중앙 집중식 관리**: 모든 상태를 Hook에서 관리 +2. **localStorage 동기화**: useEffect로 자동 저장 +3. **함수형 업데이트**: 상태 의존 업데이트 시 필수 +4. **useCallback 최적화**: 자주 사용되는 함수 최적화 +5. **useMemo 최적화**: 복잡한 계산 결과 캐싱 +6. **Props 빌더**: Props 객체를 함수로 생성 + +--- + +## 🚀 Zustand 리팩토링 후 예상 구조 + +``` +Zustand Store + ├─ useProductStore + │ ├─ products + │ ├─ productForm + │ ├─ editingProduct + │ └─ actions (addProduct, updateProduct, etc.) + ├─ useCartStore + │ ├─ cart + │ ├─ totalItemCount + │ └─ actions (addToCart, removeFromCart, etc.) + └─ useCouponStore + ├─ coupons + ├─ selectedCoupon + └─ actions (addCoupon, applyCoupon, etc.) + +컴포넌트 + └─ Store에서 직접 상태 및 액션 사용 + └─ Props drilling 제거 +``` + diff --git a/.cursor/mockdowns/advanced/advanced-test-fix-comprehensive.md b/.cursor/mockdowns/advanced/advanced-test-fix-comprehensive.md new file mode 100644 index 000000000..c442e7061 --- /dev/null +++ b/.cursor/mockdowns/advanced/advanced-test-fix-comprehensive.md @@ -0,0 +1,101 @@ +# 테스트 실패 종합 분석 및 수정 계획 + +## 📋 문제 요약 + +### 테스트 결과 +- ✅ 통과: 2개 테스트 +- ❌ 실패: 12개 테스트 + +### 주요 문제 +1. **"-5,000원" 텍스트 찾기 실패** + - 실제 표시: "- 23,000 원" (공백 포함) + - 원인: `toLocaleString()`이 공백을 추가하는 것이 아니라, JSX에서 공백이 생김 + +2. **"품절임박! 5개 남음" 텍스트 찾기 실패** + - 원인: Store 초기화 문제로 상품이 제대로 로드되지 않음 + +3. **기타 여러 테스트 실패** + - 원인: `persist` 미들웨어가 테스트 환경에서 비동기적으로 동작하여 초기 상태가 제대로 로드되지 않음 + +--- + +## 🔍 근본 원인 분석 + +### 1. persist 미들웨어의 비동기 동작 + +**문제:** +- Zustand의 `persist` 미들웨어는 localStorage에서 데이터를 비동기적으로 복원 +- origin은 `useState`의 lazy initializer를 사용하여 동기적으로 localStorage에서 읽음 +- 테스트 환경에서 `localStorage.clear()` 후 Store가 초기화되기 전에 컴포넌트가 렌더링됨 + +**해결 방안:** +1. `persist` 미들웨어의 `storage`를 동기적으로 만들기 +2. Store 초기화를 보장하는 로직 추가 +3. 테스트 환경에서 persist를 비활성화하고 수동으로 초기화 + +### 2. PaymentSummary의 공백 문제 + +**문제:** +- `-{(totalBeforeDiscount - totalAfterDiscount).toLocaleString()}원` 형태로 사용 +- JSX에서 `-`와 숫자 사이에 공백이 생김 + +**해결:** +- 템플릿 리터럴을 사용하여 공백 없이 연결 + +--- + +## 🎯 수정 계획 + +### Step 1: PaymentSummary 공백 문제 수정 ✅ + +**작업:** +- 템플릿 리터럴 사용하여 공백 제거 + +### Step 2: persist 미들웨어 동기화 문제 해결 ✅ + +**문제:** +- persist 미들웨어가 비동기적으로 동작하여 테스트 환경에서 초기 상태가 제대로 로드되지 않음 +- origin은 `useState`의 lazy initializer를 사용하여 동기적으로 localStorage에서 읽음 + +**해결:** +- Store 초기화 시 localStorage에서 동기적으로 읽는 함수 추가 (origin과 동일한 방식) +- persist 미들웨어의 localStorage 키를 origin과 동일하게 변경 ("products", "cart", "coupons") +- 초기 상태를 동기적으로 설정하여 테스트 환경에서도 제대로 작동하도록 수정 + +**작업:** +1. ✅ `getInitialProducts`, `getInitialCart`, `getInitialCoupons` 함수 추가 +2. ✅ Store 초기화 시 동기적으로 localStorage에서 읽기 +3. ✅ localStorage 키를 origin과 동일하게 변경 + +### Step 3: Store 초기화 보장 + +**작업:** +1. Store의 초기 상태가 제대로 설정되도록 보장 +2. `persist` 미들웨어가 localStorage에서 복원 실패 시 초기 상태 사용 + +--- + +## 📝 작업 순서 + +1. **PaymentSummary 공백 문제 수정** ✅ + - 템플릿 리터럴 사용하여 공백 제거 + +2. **persist 미들웨어 동기화 문제 해결** ✅ + - Store 초기화 시 localStorage에서 동기적으로 읽는 함수 추가 + - localStorage 키를 origin과 동일하게 변경 + +3. **Store 초기화 보장** ✅ + - `getInitialProducts`, `getInitialCart`, `getInitialCoupons` 함수 추가 + - origin과 동일한 방식으로 동기적 초기화 + +4. **전체 테스트 실행 및 확인** ⏳ + - 사용자 확인 필요 + +--- + +## ⚠️ Rule 준수 사항 + +- ✅ 테스트 코드는 수정하지 않음 +- ✅ 기존 기능을 건들지 않은 상태로 작업 진행 +- ✅ 모든 기능이 동일하게 동작해야 함 + diff --git a/.cursor/mockdowns/advanced/advanced-test-fix-plan.md b/.cursor/mockdowns/advanced/advanced-test-fix-plan.md new file mode 100644 index 000000000..f46123dc0 --- /dev/null +++ b/.cursor/mockdowns/advanced/advanced-test-fix-plan.md @@ -0,0 +1,195 @@ +# 테스트 실패 문제 분석 및 수정 계획 + +## 📋 문제 요약 + +### 실패한 테스트 +1. **"-5,000원" 텍스트를 찾을 수 없음** + - 실제 표시: "- 23,000원" (공백 포함) + - 테스트 기대: "-5,000원" (공백 없음) + +2. **"품절임박! 5개 남음" 텍스트를 찾을 수 없음** + - ProductItem에서 `remainingStock` 계산 문제 가능성 + +3. **"장바구니 담기" 텍스트를 찾을 수 없음** + - ProductItem 컴포넌트가 렌더링되지 않았을 가능성 + +4. **"최고급 품질의 프리미엄 상품입니다." 텍스트를 찾을 수 없음** + - 검색 필터링이 작동하지 않았을 가능성 + +### 통과한 테스트 +- ✅ 상품을 검색하고 장바구니에 추가할 수 있다 +- ✅ 장바구니에서 수량을 조절하고 할인을 확인할 수 있다 +- ✅ 20개이상 구매시 최대 할인이 적용된다 +- ✅ 관리자 기능들 + +--- + +## 🔍 문제 분석 + +### 1. formatPrice 함수 문제 + +**문제:** +- `formatPrice` 함수가 음수일 때 공백을 포함하여 반환 +- 예: `-5,000원` 대신 `- 5,000원` 형태로 표시 + +**원인:** +- `formatPrice` 함수가 음수 처리 시 공백을 추가하는 로직이 있을 가능성 +- 또는 `-{formatPrice(...)}` 형태로 사용하여 이중 마이너스/공백 발생 + +**확인 필요:** +- `src/advanced/utils/formatters.ts`의 `formatPrice` 함수 구현 +- `src/origin`의 `formatPrice` 함수와 비교 + +### 2. remainingStock 계산 문제 + +**문제:** +- `ProductItem`에서 `remainingStock`이 제대로 계산되지 않음 +- 품절임박 메시지가 표시되지 않음 + +**원인:** +- `ProductList`에서 `remainingStock`을 계산하는 로직이 Store로 변경되면서 문제 발생 가능 +- `getRemainingStock` 함수가 제대로 호출되지 않았을 가능성 + +**확인 필요:** +- `ProductList`에서 `remainingStock` 계산 로직 +- `getRemainingStock` 함수 호출 위치 + +### 3. ProductItem 렌더링 문제 + +**문제:** +- "장바구니 담기" 버튼을 찾을 수 없음 +- 여러 테스트에서 동일한 문제 발생 + +**원인:** +- Store로 변경되면서 초기 데이터가 로드되지 않았을 가능성 +- `persist` 미들웨어가 테스트 환경에서 제대로 작동하지 않을 가능성 +- 컴포넌트가 렌더링되기 전에 테스트가 실행되었을 가능성 + +**확인 필요:** +- Store 초기화 로직 +- `persist` 미들웨어 동작 +- 테스트 환경에서 localStorage 초기화 + +### 4. 검색 필터링 문제 + +**문제:** +- "최고급 품질의 프리미엄 상품입니다." 텍스트를 찾을 수 없음 +- 검색 필터링이 작동하지 않음 + +**원인:** +- `useSearchStore`의 `debouncedSearchTerm`이 제대로 업데이트되지 않음 +- `filterProductsBySearchTerm` 함수가 제대로 호출되지 않음 + +**확인 필요:** +- `useSearchStore`의 디바운스 로직 +- `App.tsx`에서 `filteredProducts` 계산 로직 + +--- + +## 🎯 수정 계획 + +### Step 1: PaymentSummary 할인 금액 표시 수정 ✅ + +**문제:** +- `PaymentSummary`에서 `-{formatPrice(...)}` 형태로 사용하여 공백이 생김 +- origin에서는 `-{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원` 형태로 직접 사용 + +**해결:** +- origin과 동일하게 `-{(totalBeforeDiscount - totalAfterDiscount).toLocaleString()}원` 형태로 변경 +- `formatPrice` 함수 사용하지 않고 직접 `toLocaleString()` 사용 + +**작업:** +1. ✅ `PaymentSummary.tsx` 수정 완료 + +### Step 2: Store 초기화 문제 해결 ✅ + +**문제:** +- `persist` 미들웨어를 사용하는 Store들이 테스트 환경에서 제대로 초기화되지 않을 수 있음 +- `localStorage.clear()`를 호출하지만, `persist` 미들웨어가 비동기적으로 동작할 수 있음 +- Store의 초기 상태가 제대로 설정되지 않았을 가능성 + +**해결:** +- `persist` 미들웨어에 `skipHydration: false` 옵션 추가 (기본값이지만 명시적으로 설정) +- Store 초기화 로직 확인 완료 + +**작업:** +1. ✅ `useProductStore`, `useCartStore`, `useCouponStore`의 초기화 로직 확인 +2. ✅ `persist` 미들웨어 옵션 확인 및 수정 +3. ⏳ 테스트 실행하여 확인 필요 + +### Step 3: Store 초기화 및 persist 문제 해결 + +**목표:** +- 테스트 환경에서 Store가 제대로 초기화되도록 수정 +- `persist` 미들웨어가 테스트 환경에서도 작동하도록 수정 + +**작업:** +1. Store 초기화 로직 확인 +2. 테스트 환경에서 `persist` 미들웨어 동작 확인 +3. 필요시 테스트 환경에서 `persist` 비활성화 또는 수동 초기화 + +### Step 4: 검색 필터링 수정 + +**목표:** +- `useSearchStore`의 디바운스 로직이 제대로 작동하도록 수정 +- 검색 필터링이 정상적으로 작동하도록 수정 + +**작업:** +1. `useSearchStore`의 디바운스 로직 확인 +2. `App.tsx`에서 `filteredProducts` 계산 로직 확인 +3. Store selector 사용 방식 확인 + +--- + +## 📝 작업 순서 + +1. **문제 확인 및 원인 파악** ✅ + - 테스트 파일 분석 + - 관련 컴포넌트 확인 + - origin과 비교 + +2. **PaymentSummary 할인 금액 표시 수정** ✅ + - origin과 동일하게 `toLocaleString()` 직접 사용 + - `formatPrice` 함수 사용하지 않음 + +3. **remainingStock 계산 수정** ⏳ + - ProductList 로직 확인 및 수정 + - 테스트 확인 + +4. **Store 초기화 문제 해결** ⏳ + - persist 미들웨어 동작 확인 + - 테스트 환경 대응 + +5. **검색 필터링 수정** ⏳ + - useSearchStore 디바운스 로직 확인 + - 필터링 로직 확인 + +6. **전체 테스트 실행 및 확인** ⏳ + - 모든 테스트 통과 확인 + - Rule 준수 확인 + +--- + +## ⚠️ Rule 준수 사항 + +### 절대 규칙 +- ✅ 테스트 코드는 수정하지 않음 +- ✅ 기존 기능을 건들지 않은 상태로 작업 진행 +- ✅ 모든 기능이 동일하게 동작해야 함 + +### 작업 원칙 +- ✅ 점진적 작업 진행 +- ✅ 각 단계 완료 후 문서 업데이트 +- ✅ 타입 오류 및 린터 오류 확인 + +--- + +## 🔄 작업 기록 + +### 2025-01-XX +- 문제 분석 완료 ✅ +- 기획서 작성 완료 ✅ +- Step 1: PaymentSummary 할인 금액 표시 수정 완료 ✅ +- Step 2: Store 초기화 문제 해결 완료 ✅ +- 다음: 테스트 실행하여 확인 필요 ⏳ + diff --git a/.cursor/mockdowns/advanced/advanced-test-hydration-fix.md b/.cursor/mockdowns/advanced/advanced-test-hydration-fix.md new file mode 100644 index 000000000..b582d76e3 --- /dev/null +++ b/.cursor/mockdowns/advanced/advanced-test-hydration-fix.md @@ -0,0 +1,54 @@ +# 테스트 환경 Store Hydration 문제 해결 + +## 📋 문제 분석 + +### 증상 +- `pnpm dev:advanced` 실행 시 정상 작동 +- 테스트 실행 시 "최고급 품질의 프리미엄 상품입니다." 텍스트를 찾을 수 없음 +- 여러 테스트에서 동일한 문제 발생 + +### 원인 +1. **persist 미들웨어의 비동기 hydration** + - persist 미들웨어가 localStorage에서 데이터를 비동기적으로 복원 + - 테스트 환경에서 hydration이 완료되기 전에 컴포넌트가 렌더링됨 + - Store의 초기 상태가 제대로 설정되지 않음 + +2. **Store Selector 문제** + - Zustand의 selector가 제대로 작동하지 않아 컴포넌트가 리렌더링되지 않음 + - Store 상태 변경이 컴포넌트에 반영되지 않음 + +3. **검색 필터링 문제** + - `useSearchStore`의 디바운스 로직이 테스트 환경에서 제대로 작동하지 않음 + - `debouncedSearchTerm`이 업데이트되지 않아 필터링이 작동하지 않음 + +--- + +## 🎯 해결 방안 + +### 방안 1: persist 미들웨어의 hydration 완료 대기 +- `persist` 미들웨어의 `onRehydrateStorage` 콜백 사용 +- 테스트에서 hydration 완료를 기다리는 로직 추가 + +### 방안 2: Store 초기화 보장 (권장) +- persist 미들웨어를 사용하되, 초기 상태를 동기적으로 설정 +- `getInitialProducts` 등의 함수를 Store 생성 시점에 호출하여 초기 상태 보장 + +### 방안 3: 테스트 환경에서 persist 비활성화 +- 테스트 환경에서는 persist를 사용하지 않고 직접 초기화 +- 프로덕션 환경에서만 persist 사용 + +--- + +## 📝 수정 계획 + +### Step 1: Store 초기화 보장 +- `getInitialProducts`, `getInitialCart`, `getInitialCoupons` 함수가 Store 생성 시점에 호출되도록 보장 +- persist 미들웨어의 `onRehydrateStorage` 콜백을 사용하여 hydration 완료 확인 + +### Step 2: 검색 필터링 수정 +- `useSearchStore`의 디바운스 로직이 테스트 환경에서도 작동하도록 수정 +- 타이머가 제대로 작동하도록 보장 + +### Step 3: 테스트 실행 및 확인 +- 전체 테스트 실행하여 문제 해결 확인 + diff --git a/.cursor/mockdowns/advanced/advanced-testing.md b/.cursor/mockdowns/advanced/advanced-testing.md new file mode 100644 index 000000000..c0c2333a8 --- /dev/null +++ b/.cursor/mockdowns/advanced/advanced-testing.md @@ -0,0 +1,213 @@ +# Advanced 프로젝트 - 테스트 구조 + +## 📍 테스트 파일 위치 + +- `src/advanced/__tests__/origin.test.tsx` - 통합 테스트 + +--- + +## 🧪 테스트 프레임워크 + +### 사용 도구 +- **Vitest**: `^3.2.4` - 테스트 러너 +- **@testing-library/react**: `^16.3.0` - React 컴포넌트 테스트 +- **@testing-library/jest-dom**: `^6.6.4` - DOM 매처 +- **jsdom**: `^26.1.0` - DOM 환경 시뮬레이션 + +### 설정 +```typescript +// vite.config.ts +export default mergeConfig( + defineConfig({ + plugins: [react()], + }), + defineTestConfig({ + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/setupTests.ts' + }, + }) +) +``` + +--- + +## 📋 테스트 구조 + +### 테스트 그룹 + +```typescript +describe("쇼핑몰 앱 통합 테스트", () => { + beforeEach(() => { + localStorage.clear(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("고객 쇼핑 플로우", () => { + // 고객 관련 테스트 + }); + + describe("관리자 기능", () => { + // 관리자 관련 테스트 + }); + + describe("로컬스토리지 동기화", () => { + // localStorage 관련 테스트 + }); +}); +``` + +--- + +## 🛍️ 고객 쇼핑 플로우 테스트 + +### 1. 상품 검색 및 장바구니 추가 +```typescript +test("상품을 검색하고 장바구니에 추가할 수 있다", async () => { + render(); + + // 검색창에 "프리미엄" 입력 + const searchInput = screen.getByPlaceholderText("상품 검색..."); + fireEvent.change(searchInput, { target: { value: "프리미엄" } }); + + // 디바운스 대기 (500ms) + await waitFor( + () => { + expect( + screen.getByText("최고급 품질의 프리미엄 상품입니다.") + ).toBeInTheDocument(); + }, + { timeout: 600 } + ); + + // 장바구니에 추가 + const addButtons = screen.getAllByText("장바구니 담기"); + fireEvent.click(addButtons[0]); + + // 알림 확인 + await waitFor(() => { + expect(screen.getByText("장바구니에 담았습니다")).toBeInTheDocument(); + }); +}); +``` + +**중요:** +- `waitFor`로 비동기 업데이트 대기 +- 디바운스 시간(500ms) 고려하여 timeout 설정 + +--- + +## 👨‍💼 관리자 기능 테스트 + +### 1. 상품 추가 +```typescript +test("새 상품을 추가할 수 있다", () => { + // 관리자 모드 전환 + fireEvent.click(screen.getByText("관리자 페이지로")); + + // 새 상품 추가 버튼 클릭 + fireEvent.click(screen.getByText("새 상품 추가")); + + // 폼 입력 + // 제출 + // 확인 +}); +``` + +### 2. localStorage 저장 테스트 +```typescript +test("상품, 장바구니, 쿠폰이 localStorage에 저장된다", () => { + render(); + + // 장바구니에 추가 + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + expect(localStorage.getItem("cart")).toBeTruthy(); + + // 관리자 모드로 전환하여 새 상품 추가 + fireEvent.click(screen.getByText("관리자 페이지로")); + fireEvent.click(screen.getByText("새 상품 추가")); + + // 폼 입력 (상품명, 가격, 재고만 입력 - 설명은 선택) + // ... + fireEvent.click(screen.getByText("추가")); + + // localStorage 확인 + expect(localStorage.getItem("products")).toBeTruthy(); + const products = JSON.parse(localStorage.getItem("products")); + expect(products.some((p) => p.name === "저장 테스트")).toBe(true); +}); +``` + +--- + +## ⚠️ 테스트 주의사항 + +### 1. localStorage 초기화 +```typescript +beforeEach(() => { + localStorage.clear(); +}); +``` +- 각 테스트 전에 localStorage 초기화 +- 테스트 간 간섭 방지 + +### 2. 비동기 처리 +```typescript +await waitFor(() => { + expect(screen.getByText("텍스트")).toBeInTheDocument(); +}, { timeout: 600 }); +``` +- `waitFor`로 비동기 업데이트 대기 +- 디바운스 시간 고려하여 timeout 설정 + +### 3. 테스트 코드 수정 불가 +- **절대 규칙**: 테스트 코드는 수정할 수 없다 +- Zustand 리팩토링 후에도 테스트는 동일하게 통과해야 함 + +--- + +## 🚀 Zustand 리팩토링 후 테스트 + +### 예상 변경사항 +- 테스트 코드는 수정하지 않음 +- 구현만 변경하여 테스트 통과 +- Zustand Store 사용 시에도 동일한 동작 보장 + +### 주의사항 +- Store 초기화 시 localStorage 동기화 확인 +- Store 상태 변경 시 컴포넌트 업데이트 확인 +- 비동기 업데이트 타이밍 확인 + +--- + +## 📊 테스트 커버리지 + +### 테스트 범위 +- ✅ 고객 쇼핑 플로우 (검색, 장바구니 추가, 수량 조절) +- ✅ 관리자 기능 (상품 추가/수정/삭제, 쿠폰 생성/삭제) +- ✅ 입력 검증 (가격, 재고) +- ✅ localStorage 동기화 +- ✅ 할인 계산 +- ✅ 쿠폰 적용 + +--- + +## 🧪 테스트 실행 + +```bash +# Advanced 버전 테스트 실행 +npm run test:advanced + +# 테스트 UI 실행 +npm run test:ui + +# 특정 테스트 실행 +npm test -- src/advanced/__tests__/origin.test.tsx +``` + diff --git a/.cursor/mockdowns/advanced/advanced-zustand-refactoring-plan.md b/.cursor/mockdowns/advanced/advanced-zustand-refactoring-plan.md new file mode 100644 index 000000000..ebd1338ee --- /dev/null +++ b/.cursor/mockdowns/advanced/advanced-zustand-refactoring-plan.md @@ -0,0 +1,258 @@ +# Advanced 프로젝트 리팩토링 계획 - 심화과제 (Zustand) + +## 📋 목표 + +pull_request_template.md의 심화과제를 완료하여 다음 목표를 달성: + +1. Zustand를 사용해서 전역상태관리를 구축 +2. 전역상태관리를 통해 domain custom hook을 적절하게 리팩토링 +3. 도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거 +4. 전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드 + +--- + +## ✅ 체크리스트 + +### 심화과제 요구사항 + +- [ ] Zustand를 사용해서 전역상태관리를 구축했나요? +- [ ] 전역상태관리를 통해 domain custom hook을 적절하게 리팩토링 했나요? +- [ ] 도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거했나요? +- [ ] 전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드가 되었나요? + +--- + +## 🔍 현재 상태 분석 + +### 1. 현재 구조 + +``` +App.tsx (Hook 기반) +├── UI 상태 (useState) +├── Hook 사용 (5개) +│ ├── useNotification (UI Hook) +│ ├── useSearch (UI Hook) +│ ├── useProduct (Entity Hook) +│ ├── useCart (Entity Hook) +│ └── useCoupon (Entity Hook) +├── 계산된 값 (순수 함수 호출) +└── Props 빌더 함수 +``` + +### 2. Props Drilling 현황 + +**StorePage:** + +- `productProps`: cart, products, filteredProducts, debouncedSearchTerm, addToCart +- `cartSidebarProps`: filledItems, removeFromCart, updateQuantity, coupons, selectedCouponCode, selectorOnChange, totals, completeOrder + +**AdminPage:** + +- `adminProductsProps`: productForm, showProductForm, editingProduct, setEditingProduct, setProductForm, setShowProductForm, handleProductSubmit, addNotification +- `adminCouponProps`: coupons, deleteCoupon, setShowCouponForm, showCouponForm, couponForm, handleCouponSubmit, setCouponForm, addNotification, setShowCouponForm + +**문제점:** + +- 전역 상태가 props로 여러 단계 전달됨 +- Props 빌더 함수가 복잡함 +- 컴포넌트 간 결합도가 높음 + +--- + +## 🎯 Zustand Store 설계 + +### 1. Store 구조 설계 + +#### useProductStore + +**상태:** + +- `products: ProductWithUI[]` - 상품 목록 (localStorage 동기화) +- `productForm: ProductForm` - 상품 폼 데이터 +- `editingProduct: string | null` - 편집 중인 상품 ID +- `showProductForm: boolean` - 상품 폼 표시 여부 + +**액션:** + +- `addProduct(newProduct)` - 상품 추가 +- `updateProduct(productId, updates)` - 상품 수정 +- `deleteProduct(productId)` - 상품 삭제 +- `startEditProduct(product)` - 상품 편집 시작 +- `handleProductSubmit(e)` - 상품 폼 제출 +- `setProductForm(form)` - 상품 폼 설정 +- `setEditingProduct(id)` - 편집 상품 ID 설정 +- `setShowProductForm(show)` - 폼 표시 여부 설정 + +**의존성:** + +- useNotificationStore (addNotification) + +#### useCartStore + +**상태:** + +- `cart: CartItem[]` - 장바구니 아이템 (localStorage 동기화) +- `totalItemCount: number` - 장바구니 아이템 총 개수 (계산된 값) + +**계산된 값:** + +- `filledItems: FilledCartItem[]` - 가격 정보가 포함된 장바구니 아이템 + +**액션:** + +- `addToCart(product)` - 장바구니 추가 +- `removeFromCart(productId)` - 장바구니에서 제거 +- `updateQuantity(productId, newQuantity)` - 수량 업데이트 +- `completeOrder()` - 주문 완료 + +**의존성:** + +- useProductStore (products 참조) +- useNotificationStore (addNotification) + +#### useCouponStore + +**상태:** + +- `coupons: Coupon[]` - 쿠폰 목록 (localStorage 동기화) +- `selectedCoupon: Coupon | null` - 선택된 쿠폰 +- `couponForm: Coupon` - 쿠폰 폼 데이터 +- `showCouponForm: boolean` - 쿠폰 폼 표시 여부 + +**액션:** + +- `addCoupon(newCoupon)` - 쿠폰 추가 +- `deleteCoupon(couponCode)` - 쿠폰 삭제 +- `applyCoupon(coupon)` - 쿠폰 적용 +- `handleCouponSubmit(e)` - 쿠폰 폼 제출 +- `setSelectedCoupon(coupon)` - 선택된 쿠폰 설정 +- `setCouponForm(form)` - 쿠폰 폼 설정 +- `setShowCouponForm(show)` - 폼 표시 여부 설정 +- `selectorOnChange(e)` - 쿠폰 선택 변경 + +**의존성:** + +- useCartStore (cart 참조) +- useNotificationStore (addNotification) + +#### useNotificationStore + +**상태:** + +- `notifications: Notification[]` - 알림 목록 + +**액션:** + +- `addNotification(message, type)` - 알림 추가 +- `removeNotification(id)` - 알림 제거 (자동) + +**특징:** + +- UI 상태이지만 전역으로 관리하여 어디서든 사용 가능 + +#### useSearchStore + +**상태:** + +- `searchTerm: string` - 검색어 +- `debouncedSearchTerm: string` - 디바운스된 검색어 + +**액션:** + +- `setSearchTerm(term)` - 검색어 설정 + +**특징:** + +- UI 상태이지만 전역으로 관리하여 어디서든 사용 가능 +- 디바운스는 Store 내부에서 처리 + +--- + +## 📝 작업 단계 + +### Step 1: Zustand Store 설계 ✅ + +- [x] useProductStore 설계 +- [x] useCartStore 설계 +- [x] useCouponStore 설계 +- [x] useNotificationStore 설계 +- [x] useSearchStore 설계 + +### Step 2: Store 구현 ✅ + +- [x] useNotificationStore 구현 (가장 단순, 의존성 없음) +- [x] useSearchStore 구현 (의존성 없음) +- [x] useProductStore 구현 (useNotificationStore 의존) +- [x] useCartStore 구현 (useProductStore, useNotificationStore 의존) +- [x] useCouponStore 구현 (useCartStore, useNotificationStore 의존) + +### Step 3: localStorage 동기화 ✅ + +- [x] persist 미들웨어 사용 +- [x] 각 Store의 localStorage 동기화 구현 (products, cart, coupons) +- [x] 초기화 시 localStorage에서 복원 + +### Step 4: App.tsx 리팩토링 ✅ + +- [x] Hook을 Store로 교체 +- [x] Props 빌더 함수 유지 (기존 인터페이스 호환성 유지) +- [x] 타입 오류 및 린터 오류 확인 완료 + +### Step 5: 컴포넌트 리팩토링 + +- [ ] StorePage에서 Store 직접 사용 +- [ ] AdminPage에서 Store 직접 사용 +- [ ] 하위 컴포넌트에서 Store 직접 사용 +- [ ] Props drilling 제거 + +### Step 6: 테스트 및 검증 + +- [ ] 기존 테스트 통과 확인 (사용자 확인 필요) +- [ ] 기능 동작 확인 (사용자 확인 필요) +- [ ] 코드 리뷰 + +--- + +## 🚧 작업 진행 상황 + +### 현재 단계: Step 5 - 컴포넌트 리팩토링 (Props drilling 제거) + +#### 완료된 작업: + +- ✅ Zustand 설치 완료 (v5.0.9) +- ✅ Store 설계 완료 +- ✅ 문서 작성 완료 +- ✅ useNotificationStore 구현 완료 +- ✅ useSearchStore 구현 완료 +- ✅ useProductStore 구현 완료 (persist 미들웨어 포함) +- ✅ useCartStore 구현 완료 (persist 미들웨어 포함) +- ✅ useCouponStore 구현 완료 (persist 미들웨어 포함) +- ✅ App.tsx 리팩토링 완료 (Hook을 Store로 교체) + +#### 다음 작업: + +- StorePage에서 Store 직접 사용 +- AdminPage에서 Store 직접 사용 +- 하위 컴포넌트에서 Store 직접 사용 +- Props drilling 제거 (도메인 props는 유지) + +--- + +## 📌 중요 원칙 + +1. **기존 기능 유지**: 모든 기능이 동일하게 동작해야 함 +2. **테스트 코드 수정 불가**: 기존 테스트가 모두 통과해야 함 +3. **점진적 리팩토링**: 한 번에 하나씩 진행 +4. **의존성 방향**: entities <- features <- ui +5. **Props 전달 기준**: 도메인 props는 유지, 전역 상태는 제거 + +--- + +## 🔄 작업 기록 + +### 2025-01-XX + +- 문서 작성 시작 +- Zustand 설치 완료 (v5.0.9) +- Store 설계 완료 +- Step 2 시작 diff --git a/.cursor/mockdowns/basic/README.md b/.cursor/mockdowns/basic/README.md new file mode 100644 index 000000000..e8eabf372 --- /dev/null +++ b/.cursor/mockdowns/basic/README.md @@ -0,0 +1,167 @@ +# Basic 프로젝트 문서 인덱스 + +이 폴더는 `src/basic/` 프로젝트의 상세 문서를 포함합니다. 다음 AI가 작업할 때 참고할 수 있도록 프로젝트의 구조, 로직, 패턴 등을 상세히 정리했습니다. + +--- + +## 📚 문서 목록 + +### 1. [프로젝트 개요](./basic-project-overview.md) +- 프로젝트 정보 및 목적 +- 폴더 구조 +- 기술 스택 및 버전 +- 실행 스크립트 +- 주요 특징 + +### 2. [도메인 모델 및 타입](./basic-domain-types.md) +- 상품 도메인 타입 +- 장바구니 도메인 타입 +- 쿠폰 도메인 타입 +- 알림 도메인 타입 +- 상수 정의 +- 타입 관계도 + +### 3. [비즈니스 로직](./basic-business-logic.md) +- 할인 정책 및 계산 로직 +- 장바구니 계산 로직 +- 쿠폰 로직 +- 상품 필터링 로직 +- 재고 관리 로직 +- 가격 포맷팅 로직 + +### 4. [컴포넌트 구조](./basic-components.md) +- 컴포넌트 계층 구조 +- 페이지 컴포넌트 +- 상품 관련 컴포넌트 +- 장바구니 관련 컴포넌트 +- 쿠폰 관련 컴포넌트 +- 공통 컴포넌트 +- 레이아웃 컴포넌트 + +### 5. [상태 관리](./basic-state-management.md) +- 상태 목록 및 설명 +- localStorage 동기화 +- 상태 업데이트 함수 +- 계산된 값 (useMemo) +- Props 빌더 함수 +- 상태 관리 패턴 + +### 6. [테스트 구조](./basic-testing.md) +- 테스트 프레임워크 +- 테스트 구조 +- 고객 쇼핑 플로우 테스트 +- 관리자 기능 테스트 +- localStorage 동기화 테스트 +- 테스트 주의사항 + +### 7. [주요 이슈 및 해결 방법](./basic-issues-solutions.md) +- 해결된 주요 이슈 +- 발견된 패턴 및 베스트 프랙티스 +- 주의해야 할 패턴 +- 코드 리뷰 체크리스트 +- 리팩토링 히스토리 + +--- + +## 🚀 빠른 시작 + +### 프로젝트 실행 +```bash +# 개발 서버 실행 +npm run dev:basic + +# 테스트 실행 +npm run test:basic +``` + +### 주요 파일 위치 +- **메인 앱**: `src/basic/App.tsx` +- **도메인 타입**: `src/basic/domain/` +- **컴포넌트**: `src/basic/components/` +- **테스트**: `src/basic/__tests__/origin.test.tsx` + +--- + +## ⚠️ 중요 주의사항 + +### 1. handleProductSubmit +- `useCallback`으로 감싸지 않음 +- 매 렌더링마다 새로 생성되어 최신 상태 참조 + +### 2. FormInputField +- `required` prop 추가됨 (기본값: `true`) +- 설명 필드는 `required={false}` 설정 필요 + +### 3. 상태 업데이트 +- 함수형 업데이트 패턴 필수 +- `setState((prev) => ({ ...prev, ... }))` 사용 + +--- + +## 🔍 문서 사용 가이드 + +### 새로운 기능 추가 시 +1. [프로젝트 개요](./basic-project-overview.md) - 프로젝트 구조 파악 +2. [도메인 모델 및 타입](./basic-domain-types.md) - 타입 정의 확인 +3. [비즈니스 로직](./basic-business-logic.md) - 관련 로직 확인 +4. [컴포넌트 구조](./basic-components.md) - 컴포넌트 구조 확인 + +### 버그 수정 시 +1. [주요 이슈 및 해결 방법](./basic-issues-solutions.md) - 유사 이슈 확인 +2. [상태 관리](./basic-state-management.md) - 상태 관리 패턴 확인 +3. [테스트 구조](./basic-testing.md) - 테스트 케이스 확인 + +### 코드 리뷰 시 +1. [주요 이슈 및 해결 방법](./basic-issues-solutions.md) - 체크리스트 확인 +2. [컴포넌트 구조](./basic-components.md) - 설계 원칙 확인 +3. [상태 관리](./basic-state-management.md) - 상태 관리 패턴 확인 + +--- + +## 📝 문서 업데이트 가이드 + +새로운 기능이나 변경사항이 있을 때: + +1. **타입 변경**: [도메인 모델 및 타입](./basic-domain-types.md) 업데이트 +2. **로직 변경**: [비즈니스 로직](./basic-business-logic.md) 업데이트 +3. **컴포넌트 추가**: [컴포넌트 구조](./basic-components.md) 업데이트 +4. **상태 관리 변경**: [상태 관리](./basic-state-management.md) 업데이트 +5. **이슈 해결**: [주요 이슈 및 해결 방법](./basic-issues-solutions.md) 업데이트 + +--- + +## 🔗 관련 문서 + +- **Origin 프로젝트**: `src/origin/` (참고용) +- **Advanced 프로젝트**: `src/advanced/` (향후 버전) +- **공통 타입**: `src/types.ts` + +--- + +## 📅 문서 작성 일자 + +- 작성일: 2025년 +- 프로젝트 버전: Basic (컴포넌트 분리 버전) +- React 버전: 19.1.1 +- TypeScript 버전: 5.9.2 + +--- + +## 💡 팁 + +### 문서 검색 +- 특정 주제를 찾을 때는 각 문서의 목차를 먼저 확인 +- 코드 예시는 실제 파일 경로와 함께 제공됨 + +### 코드 참조 +- 문서 내 코드 블록은 실제 파일 경로를 포함 +- `src/basic/` 경로 기준으로 작성됨 + +### 패턴 확인 +- 반복되는 패턴은 [주요 이슈 및 해결 방법](./basic-issues-solutions.md)에 정리 +- 베스트 프랙티스도 함께 확인 가능 + +--- + +**이 문서들은 다음 AI가 작업할 때 참고할 수 있도록 상세히 작성되었습니다. 꼼꼼히 읽고 활용해주세요!** 🚀 + diff --git a/.cursor/mockdowns/basic/basic-business-logic.md b/.cursor/mockdowns/basic/basic-business-logic.md new file mode 100644 index 000000000..dfcd790ff --- /dev/null +++ b/.cursor/mockdowns/basic/basic-business-logic.md @@ -0,0 +1,289 @@ +# Basic 프로젝트 - 비즈니스 로직 + +## 📍 비즈니스 로직 위치 + +### 도메인별 유틸리티 +- `src/basic/domain/cart/cartUtils.ts` - 장바구니 계산 로직 +- `src/basic/domain/cart/couponUtils.ts` - 쿠폰 관련 로직 +- `src/basic/domain/product/productUtils.ts` - 상품 필터링 로직 +- `src/basic/utils/formatters.ts` - 포맷팅 로직 + +--- + +## 💰 할인 정책 (Discount Policy) + +### 정책 상수 (cartUtils.ts) + +```typescript +export const BULK_EXTRA_DISCOUNT = 0.05; // 대량 구매 추가 할인율 (5%) +export const MAX_DISCOUNT_RATE = 0.5; // 최대 할인율 상한 (50%) +export const BULK_PURCHASE_THRESHOLD = 10; // 대량 구매 기준 수량 +``` + +### 할인 계산 로직 + +#### 1. 기본 할인율 계산 (getBaseDiscount) +```typescript +// 상품의 discounts 배열에서 수량 조건에 맞는 최대 할인율 반환 +getBaseDiscount(item: CartItem): number +``` + +**로직:** +- `item.quantity >= discount.quantity` 조건을 만족하는 할인만 적용 +- 여러 할인 중 최대 할인율 선택 +- 조건에 맞는 할인이 없으면 0 반환 + +**예시:** +```typescript +// discounts: [{ quantity: 10, rate: 0.1 }, { quantity: 20, rate: 0.2 }] +// quantity: 15 → 0.1 (10개 조건 만족) +// quantity: 25 → 0.2 (20개 조건 만족, 최대값) +``` + +#### 2. 대량 구매 보너스 (hasBulkPurchase) +```typescript +// 장바구니에 10개 이상인 아이템이 있는지 확인 +hasBulkPurchase(quantities: number[]): boolean +``` + +**로직:** +- 장바구니의 모든 아이템 수량 중 하나라도 10개 이상이면 true +- 대량 구매 보너스 5% 추가 할인 적용 + +#### 3. 최종 할인율 계산 (calculateFinalDiscount) +```typescript +// 기본 할인 + 대량 구매 보너스, 최대 50% 제한 +calculateFinalDiscount(baseDiscount: number, bulkBonus: number): number +``` + +**로직:** +- `baseDiscount + bulkBonus` 계산 +- 최대 50% (0.5) 제한 적용 +- `Math.min(baseDiscount + bulkBonus, MAX_DISCOUNT_RATE)` 반환 + +#### 4. 최대 적용 할인율 (getMaxApplicableDiscount) +```typescript +// 아이템별 최종 할인율 계산 +getMaxApplicableDiscount(item: CartItem, cart: CartItem[]): number +``` + +**로직:** +1. `getBaseDiscount(item)` - 기본 할인율 계산 +2. `hasBulkPurchase(cart)` - 대량 구매 여부 확인 +3. `calculateFinalDiscount()` - 최종 할인율 계산 + +--- + +## 🛒 장바구니 계산 로직 + +### 1. 아이템 총액 계산 (calculateItemTotal) +```typescript +calculateItemTotal(price: number, quantity: number, discount: number): number +``` + +**공식:** +``` +총액 = 가격 × 수량 × (1 - 할인율) +결과는 Math.round()로 반올림 +``` + +**예시:** +```typescript +// 가격: 10000, 수량: 5, 할인율: 0.2 (20%) +// 10000 × 5 × (1 - 0.2) = 40000 +``` + +### 2. 장바구니 총액 계산 (calculateCartTotal) +```typescript +calculateCartTotal( + cart: CartItem[], + selectedCoupon: Coupon | null +): { totalBeforeDiscount: number; totalAfterDiscount: number } +``` + +**로직:** +1. **할인 전 총액**: 모든 아이템의 `가격 × 수량` 합계 +2. **아이템 할인 적용**: 각 아이템에 `calculateItemTotal()` 적용하여 합계 +3. **쿠폰 할인 적용**: 선택된 쿠폰이 있으면 `applyCoupon()` 적용 +4. **반올림**: 모든 금액은 `Math.round()`로 반올림 + +**반환값:** +```typescript +{ + totalBeforeDiscount: number; // 할인 전 총액 + totalAfterDiscount: number; // 최종 총액 (아이템 할인 + 쿠폰 할인) +} +``` + +### 3. 아이템 가격 세부 정보 (calculateItemPriceDetails) +```typescript +calculateItemPriceDetails(item: CartItem, cart: CartItem[]): { + itemTotal: number; + hasDiscount: boolean; + discountRate: number; +} +``` + +**로직:** +1. `itemTotal`: 할인 적용 후 총액 +2. `hasDiscount`: 원가 대비 할인 여부 +3. `discountRate`: 할인율 (퍼센트, 0-100) + +**예시:** +```typescript +// 원가: 50000, 할인 후: 40000 +// hasDiscount: true +// discountRate: 20 (20%) +``` + +--- + +## 🎫 쿠폰 로직 + +### 1. 쿠폰 적용 (applyCoupon) +```typescript +applyCoupon(amount: number, coupon: Coupon): number +``` + +**로직:** +- **amount 타입**: `amount - discountValue` (최소 0) +- **percentage 타입**: `amount × (1 - discountValue / 100)` (반올림) + +**예시:** +```typescript +// 금액: 50000, 쿠폰: { discountType: "amount", discountValue: 5000 } +// → 45000 + +// 금액: 50000, 쿠폰: { discountType: "percentage", discountValue: 10 } +// → 45000 (50000 × 0.9) +``` + +### 2. 쿠폰 이름 포맷팅 (formatCouponName) +```typescript +formatCouponName(coupons: Coupon[]): Coupon[] +``` + +**로직:** +- 쿠폰 이름에 할인 정보 추가 +- amount: `"쿠폰명 (5,000원)"` +- percentage: `"쿠폰명 (10%)"` + +--- + +## 🔍 상품 필터링 로직 + +### filterProductsBySearchTerm +```typescript +filterProductsBySearchTerm( + debouncedSearchTerm: string, + products: ProductWithUI[] +): ProductWithUI[] +``` + +**로직:** +1. 검색어가 없으면 모든 상품 반환 +2. 검색어가 있으면: + - 상품명에 포함되는지 확인 (대소문자 무시) + - 설명에 포함되는지 확인 (대소문자 무시) + - 둘 중 하나라도 포함되면 필터링 통과 + +**예시:** +```typescript +// 검색어: "프리미엄" +// 상품명: "프리미엄 상품" → 통과 +// 설명: "최고급 품질의 프리미엄 상품입니다." → 통과 +``` + +--- + +## 📦 재고 관리 로직 + +### 1. 재고 잔량 확인 (getRemainingStock) +```typescript +getRemainingStock(cart: CartItem[], product: Product): number +``` + +**로직:** +- 상품의 총 재고에서 장바구니에 담긴 수량 차감 +- 장바구니에 없으면 전체 재고 반환 + +**예시:** +```typescript +// product.stock: 20 +// cart에 해당 상품 5개 담김 +// → 15 반환 +``` + +### 2. 품절 확인 (isSoldOut) +```typescript +isSoldOut( + cart: CartItem[], + product: ProductWithUI, + productId?: string +): boolean +``` + +**로직:** +- `getRemainingStock()` 결과가 0 이하이면 품절 +- `productId`가 없으면 false 반환 + +--- + +## 💱 가격 포맷팅 로직 + +### formatPrice +```typescript +formatPrice(price: number, type: "kr" | "en" = "kr"): string +``` + +**로직:** +- **kr**: `"10,000원"` 형식 +- **en**: `"₩10,000"` 형식 +- `toLocaleString()`으로 천 단위 구분 + +--- + +## 🔄 상태 업데이트 패턴 + +### 1. 함수형 업데이트 (권장) +```typescript +// ProductBasicFields에서 사용 +setProductForm((prev) => ({ + ...prev, + name: newName, +})); +``` + +**이유:** +- 빠른 연속 업데이트에서도 최신 상태 보장 +- 클로저 문제 방지 + +### 2. 직접 업데이트 (주의) +```typescript +// 클로저 문제 가능성 +setProductForm({ + ...productForm, + name: newName, +}); +``` + +--- + +## ⚠️ 주의사항 + +### 1. 할인율 제한 +- 최대 할인율은 50%로 제한 +- 기본 할인 + 대량 구매 보너스 합이 50% 초과 시 50%로 제한 + +### 2. 쿠폰 적용 조건 +- percentage 쿠폰은 10,000원 이상 구매 시만 사용 가능 +- `applyCoupon` 함수에서 검증 + +### 3. 재고 검증 +- 장바구니 추가 시 `getRemainingStock()` 확인 +- 수량 업데이트 시 재고 초과 방지 + +### 4. 금액 반올림 +- 모든 금액 계산 결과는 `Math.round()`로 반올림 +- 소수점 발생 시 정수로 변환 + diff --git a/.cursor/mockdowns/basic/basic-components.md b/.cursor/mockdowns/basic/basic-components.md new file mode 100644 index 000000000..f6ea9cf0e --- /dev/null +++ b/.cursor/mockdowns/basic/basic-components.md @@ -0,0 +1,410 @@ +# Basic 프로젝트 - 컴포넌트 구조 + +## 📁 컴포넌트 계층 구조 + +``` +App.tsx (루트) +├── DefaultLayout +│ ├── Notifications (topContent) +│ ├── Header +│ │ ├── SearchBar (headerLeft, 관리자 모드 아님) +│ │ └── HeaderActions (headerRight) +│ └── main +│ ├── StorePage (isAdmin === false) +│ │ ├── ProductList +│ │ │ └── ProductItem (반복) +│ │ └── CartSidebar +│ │ ├── CartList +│ │ │ └── CartItemRow (반복) +│ │ ├── CouponSection +│ │ └── PaymentSummary +│ └── AdminPage (isAdmin === true) +│ ├── AdminTabs +│ ├── AdminProductsSection (activeTab === "products") +│ │ ├── SectionHeader +│ │ ├── ProductListTable +│ │ │ └── ProductTableRow (반복) +│ │ └── ProductFormSection (showProductForm === true) +│ │ ├── ProductBasicFields +│ │ │ └── FormInputField (4개) +│ │ └── ProductDiscountList +│ │ └── ProductDiscountRow (반복) +│ └── AdminCouponSection (activeTab === "coupons") +│ ├── CouponList +│ │ └── CouponItem (반복) +│ └── CouponFormSection +│ ├── CouponFormFields +│ └── CouponFormActions +``` + +--- + +## 🎨 페이지 컴포넌트 + +### StorePage +**위치**: `pages/StorePage.tsx` + +**역할**: 쇼핑몰 메인 페이지 + +**Props:** +```typescript +interface StorePageProps { + productProps: ProductListProps; + cartSidebarProps: CartSidebarProps; +} +``` + +**구조:** +- 좌측: 상품 목록 (3/4 너비) +- 우측: 장바구니 사이드바 (1/4 너비, sticky) + +--- + +### AdminPage +**위치**: `pages/AdminPage.tsx` + +**역할**: 관리자 대시보드 + +**Props:** +```typescript +interface AdminPageProps { + activeTab: AdminTabKey; // "products" | "coupons" + adminProductsProps: AdminProductsSectionProps; + adminCouponProps: AdminCouponSectionProps; + setActiveTab: React.Dispatch>; +} +``` + +**구조:** +- 탭: 상품 관리 / 쿠폰 관리 +- 탭에 따라 다른 섹션 표시 + +--- + +## 🛍️ 상품 관련 컴포넌트 + +### ProductList +**위치**: `components/product/ProductList.tsx` + +**역할**: 상품 목록 표시 + +**Props:** +```typescript +interface ProductListProps { + format: PriceType; + cart: CartItem[]; + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + addToCart: (product: ProductWithUI) => void; +} +``` + +**기능:** +- 필터링된 상품 목록 표시 +- 검색 결과 없을 때 Empty 상태 표시 +- 상품 개수 표시 + +--- + +### ProductItem +**위치**: `components/product/ProductItem.tsx` + +**역할**: 개별 상품 카드 + +**기능:** +- 상품 정보 표시 +- 장바구니 담기 버튼 +- 재고 상태 표시 + +--- + +### AdminProductsSection +**위치**: `components/admin/product/AdminProductsSection.tsx` + +**역할**: 관리자 상품 관리 섹션 + +**Props:** +```typescript +interface AdminProductsSectionProps { + productListTableProps: ProductListTableProps; + productForm: ProductForm; + showProductForm: boolean; + editingProduct: string | null; + setEditingProduct: (value: React.SetStateAction) => void; + setProductForm: (value: React.SetStateAction) => void; + setShowProductForm: (value: React.SetStateAction) => void; + handleProductSubmit: (e: React.FormEvent) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +} +``` + +**구조:** +- SectionHeader: "새 상품 추가" 버튼 +- ProductListTable: 상품 목록 테이블 +- ProductFormSection: 상품 추가/수정 폼 (조건부 렌더링) + +--- + +### ProductFormSection +**위치**: `components/admin/product/ProductFormSection/index.tsx` + +**역할**: 상품 폼 컨테이너 + +**Props:** +```typescript +interface ProductFormProps { + productForm: ProductForm; + setProductForm: React.Dispatch>; + titleText: string; + submitButtonText: string; + onSubmit: (e: React.FormEvent) => void; + onCancel: () => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +} +``` + +**구조:** +- ProductBasicFields: 기본 정보 입력 +- ProductDiscountList: 할인 정책 입력 +- 제출/취소 버튼 + +--- + +### ProductBasicFields +**위치**: `components/admin/product/ProductFormSection/ProductBasicFields.tsx` + +**역할**: 상품 기본 정보 입력 필드 + +**Props:** +```typescript +interface ProductBasicFieldsProps { + productForm: ProductForm; + setProductForm: (value: React.SetStateAction) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +} +``` + +**필드:** +1. **상품명**: 필수 입력 (`required={true}` 기본값) +2. **설명**: 선택 입력 (`required={false}`) +3. **가격**: 숫자만 입력, 음수 검증 (onBlur) +4. **재고**: 숫자만 입력, 0-9999 범위 검증 (onBlur) + +**중요:** +- 모든 `setProductForm` 호출은 함수형 업데이트 사용 +- 빠른 연속 입력에서도 최신 상태 보장 + +--- + +## 🛒 장바구니 관련 컴포넌트 + +### CartSidebar +**위치**: `components/cart/CartSidebar.tsx` + +**역할**: 장바구니 사이드바 + +**Props:** +```typescript +CartSidebarProps { + cartProps: { + filledItems: FilledCartItem[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + }; + couponProps: { + coupons: Coupon[]; + selectedCouponCode: string; + selectorOnChange: (e: React.ChangeEvent) => void; + }; + payment: { + totals: { totalBeforeDiscount: number; totalAfterDiscount: number }; + completeOrder: () => void; + }; +} +``` + +**구조:** +- CartList: 장바구니 아이템 목록 +- CouponSection: 쿠폰 선택 (조건부 렌더링) +- PaymentSummary: 결제 요약 + +--- + +### CartList +**위치**: `components/cart/CartList.tsx` + +**역할**: 장바구니 아이템 목록 + +**기능:** +- FilledCartItem 배열 렌더링 +- 각 아이템에 CartItemRow 사용 + +--- + +### CartItemRow +**위치**: `components/cart/CartItemRow.tsx` + +**역할**: 개별 장바구니 아이템 행 + +**기능:** +- 상품 정보 표시 +- 수량 조절 (+/- 버튼) +- 삭제 버튼 +- 할인 정보 표시 + +--- + +## 🎫 쿠폰 관련 컴포넌트 + +### CouponSection +**위치**: `components/cart/CouponSection.tsx` + +**역할**: 쿠폰 선택 섹션 + +**기능:** +- 쿠폰 드롭다운 선택 +- 선택된 쿠폰 표시 + +--- + +### AdminCouponSection +**위치**: `components/admin/coupon/AdminCouponSection.tsx` + +**역할**: 관리자 쿠폰 관리 섹션 + +**구조:** +- CouponList: 쿠폰 목록 +- CouponFormSection: 쿠폰 추가 폼 (조건부 렌더링) + +--- + +## 🧩 공통 컴포넌트 + +### FormInputField +**위치**: `components/common/FormInputField.tsx` + +**역할**: 재사용 가능한 입력 필드 + +**Props:** +```typescript +interface FormInputFieldProps { + fieldName: string; + value: string | number; + onChange?: (e: React.ChangeEvent) => void; + onBlur?: (e: React.FocusEvent) => void; + placeholder?: string; + required?: boolean; // 기본값: true +} +``` + +**중요:** +- `required` prop 추가됨 (기본값: `true`) +- 설명 필드 등 선택 입력 필드에 `required={false}` 전달 필요 + +--- + +### SearchBar +**위치**: `components/common/SearchBar.tsx` + +**역할**: 검색 입력창 + +**기능:** +- 검색어 입력 +- 디바운스는 App.tsx에서 처리 (500ms) + +--- + +### Notifications +**위치**: `components/notifications/Notification.tsx` + +**역할**: 알림 메시지 표시 + +**기능:** +- 상단에 알림 표시 +- 3초 후 자동 제거 +- 타입별 스타일 (error, success, warning) + +--- + +## 🎨 레이아웃 컴포넌트 + +### DefaultLayout +**위치**: `components/layouts/DefaultLayout.tsx` + +**역할**: 기본 레이아웃 + +**Props:** +```typescript +interface DefaultLayoutProps { + topContent?: ReactNode; // 알림 등 + headerProps: { + headerLeft?: ReactNode; // 검색창 등 + headerRight?: ReactNode; // 헤더 액션 등 + }; + children: React.ReactNode; +} +``` + +**구조:** +- topContent: 상단 (알림) +- Header: 헤더 +- main: 메인 콘텐츠 + +--- + +### Header +**위치**: `components/layouts/Header.tsx` + +**역할**: 페이지 헤더 + +**구조:** +- 좌측: headerLeft (검색창 등) +- 우측: headerRight (헤더 액션) + +--- + +### HeaderActions +**위치**: `components/layouts/HeaderActions.tsx` + +**역할**: 헤더 액션 버튼 + +**기능:** +- 관리자 모드 전환 버튼 +- 장바구니 아이콘 (아이템 개수 표시) + +--- + +## 🔧 컴포넌트 설계 원칙 + +### 1. 단일 책임 원칙 +- 각 컴포넌트는 하나의 명확한 역할 +- 재사용 가능한 작은 컴포넌트로 분리 + +### 2. Props 타입 정의 +- 모든 컴포넌트 Props는 TypeScript 인터페이스로 정의 +- 타입 안정성 보장 + +### 3. 조건부 렌더링 +- `showProductForm`, `isAdmin` 등 상태에 따라 렌더링 +- 불필요한 렌더링 방지 + +### 4. 함수형 업데이트 +- 상태 업데이트는 함수형 패턴 사용 +- 클로저 문제 방지 + +--- + +## ⚠️ 주의사항 + +### 1. FormInputField의 required prop +- 기본값이 `true`이므로 선택 입력 필드는 명시적으로 `required={false}` 전달 + +### 2. 상태 업데이트 패턴 +- `ProductBasicFields`에서 함수형 업데이트 필수 +- 빠른 연속 입력에서도 정확한 상태 보장 + +### 3. Props 빌더 함수 +- `buildAdminProductsSection()`, `buildStorePageProps()` 등 +- 매 렌더링마다 호출되지만 최신 상태 참조 보장 + diff --git a/.cursor/mockdowns/basic/basic-domain-types.md b/.cursor/mockdowns/basic/basic-domain-types.md new file mode 100644 index 000000000..c2660eaa0 --- /dev/null +++ b/.cursor/mockdowns/basic/basic-domain-types.md @@ -0,0 +1,252 @@ +# Basic 프로젝트 - 도메인 모델 및 타입 + +## 📦 타입 정의 위치 + +### 공통 타입 (src/types.ts) +프로젝트 전체에서 사용하는 기본 타입 정의 + +### 도메인별 타입 +- `src/basic/domain/product/productTypes.ts` - 상품 관련 타입 +- `src/basic/domain/cart/cartTypes.ts` - 장바구니 관련 타입 +- `src/basic/domain/notification/notificationTypes.ts` - 알림 관련 타입 + +--- + +## 🛍️ 상품 도메인 (Product Domain) + +### 기본 타입 (src/types.ts) + +```typescript +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface Discount { + quantity: number; // 할인 적용 최소 수량 + rate: number; // 할인율 (0.1 = 10%) +} +``` + +### 확장 타입 (domain/product/productTypes.ts) + +```typescript +// UI용 확장 상품 타입 +export interface ProductWithUI extends Product { + description?: string; // 상품 설명 (선택) + isRecommended?: boolean; // 추천 상품 여부 +} + +// 상품 폼 타입 +export interface ProductForm { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} +``` + +### Props 타입 + +```typescript +// 상품 목록 Props +export interface ProductListProps { + cart: CartItem[]; + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + addToCart: (product: ProductWithUI) => void; +} + +// 장바구니 사이드바 Props +export interface CartSidebarProps { + cartProps: { + filledItems: FilledCartItem[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + }; + couponProps: { + coupons: Coupon[]; + selectedCouponCode: string; + selectorOnChange: (e: React.ChangeEvent) => void; + }; + payment: { + totals: { totalBeforeDiscount: number; totalAfterDiscount: number }; + completeOrder: () => void; + }; +} +``` + +--- + +## 🛒 장바구니 도메인 (Cart Domain) + +### 기본 타입 (src/types.ts) + +```typescript +export interface CartItem { + product: Product; + quantity: number; +} +``` + +### 확장 타입 (domain/cart/cartTypes.ts) + +```typescript +// 가격 정보가 포함된 장바구니 아이템 +export type FilledCartItem = CartItem & { + priceDetails: { + itemTotal: number; // 할인 적용 후 총액 + hasDiscount: boolean; // 할인 여부 + discountRate: number; // 할인율 (퍼센트) + }; +}; +``` + +--- + +## 🎫 쿠폰 도메인 (Coupon Domain) + +### 기본 타입 (src/types.ts) + +```typescript +export interface Coupon { + name: string; // 쿠폰 이름 + code: string; // 쿠폰 코드 + discountType: 'amount' | 'percentage'; // 할인 타입 + discountValue: number; // 할인 값 (금액 또는 퍼센트) +} +``` + +--- + +## 🔔 알림 도메인 (Notification Domain) + +### 타입 정의 (domain/notification/notificationTypes.ts) + +```typescript +export interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} +``` + +--- + +## 📊 상수 정의 (constans/constans.ts) + +```typescript +// 가격 표시 형식 +export enum PriceType { + KR = "kr", // "10,000원" 형식 + EN = "en", // "₩10,000" 형식 +} + +// 할인 타입 +export enum DiscountType { + AMOUNT = "amount", // 금액 할인 + PRECENTAGE = "percentage" // 퍼센트 할인 +} +``` + +--- + +## 🔗 타입 관계도 + +``` +Product (기본) + └─ ProductWithUI (UI 확장) + ├─ description?: string + └─ isRecommended?: boolean + +CartItem + ├─ product: Product + └─ quantity: number + └─ FilledCartItem (가격 정보 추가) + └─ priceDetails: { itemTotal, hasDiscount, discountRate } + +Coupon + ├─ discountType: 'amount' | 'percentage' + └─ discountValue: number + +Notification + ├─ type: "error" | "success" | "warning" + └─ message: string +``` + +--- + +## 📝 주요 타입 사용 패턴 + +### 1. 상품 추가/수정 +```typescript +// 추가 시: id 제외 +addProduct(newProduct: Omit) + +// 수정 시: 부분 업데이트 +updateProduct(productId: string, updates: Partial) +``` + +### 2. 상태 업데이트 +```typescript +// 함수형 업데이트 패턴 (권장) +setProductForm((prev) => ({ + ...prev, + name: newName, +})); +``` + +### 3. Props 전달 +```typescript +// Props 객체 빌더 패턴 +const buildAdminProductsSection = () => { + return { + productForm, + showProductForm, + handleProductSubmit, + // ... + }; +}; +``` + +--- + +## ⚠️ 타입 주의사항 + +### 1. ProductForm vs ProductWithUI +- `ProductForm`: 폼 입력용 (id 없음, description 필수) +- `ProductWithUI`: 실제 상품 데이터 (id 있음, description 선택) + +### 2. FilledCartItem +- `CartItem`에 `priceDetails`가 추가된 타입 +- `useMemo`로 계산된 값 포함 + +### 3. Discount +- `quantity`: 할인 적용 최소 수량 +- `rate`: 할인율 (0.1 = 10%, 소수점 형식) + +--- + +## 🔄 타입 변환 함수 + +### formatCouponName (couponUtils.ts) +```typescript +// 쿠폰 이름에 할인 정보 추가 +formatCouponName(coupons: Coupon[]): Coupon[] +// "5000원 할인" → "5000원 할인 (5,000원)" +// "10% 할인" → "10% 할인 (10%)" +``` + +### formatPrice (formatters.ts) +```typescript +// 가격 포맷팅 +formatPrice(price: number, type: "kr" | "en"): string +// 10000, "kr" → "10,000원" +// 10000, "en" → "₩10,000" +``` + diff --git a/.cursor/mockdowns/basic/basic-issues-solutions.md b/.cursor/mockdowns/basic/basic-issues-solutions.md new file mode 100644 index 000000000..0f292729d --- /dev/null +++ b/.cursor/mockdowns/basic/basic-issues-solutions.md @@ -0,0 +1,325 @@ +# Basic 프로젝트 - 주요 이슈 및 해결 방법 + +## 🐛 해결된 주요 이슈 + +### 이슈 1: handleProductSubmit 클로저 문제 + +#### 문제 상황 +테스트에서 상품 추가 시 `productForm.name`이 빈 문자열로 저장되는 문제 발생. + +#### 원인 분석 +1. `handleProductSubmit`이 `useCallback`으로 감싸져 있음 +2. `productForm`이 의존성 배열에 있어도 클로저로 이전 값 참조 가능 +3. `buildAdminProductsSection()`이 매 렌더링마다 호출되면서 이전 `handleProductSubmit` 참조 + +#### 해결 방법 +```typescript +// ❌ 이전 (문제 있음) +const handleProductSubmit = useCallback( + (e: React.FormEvent) => { + // productForm 참조 + }, + [editingProduct, productForm, updateProduct, addProduct] +); + +// ✅ 수정 후 +const handleProductSubmit = (e: React.FormEvent) => { + // 매 렌더링마다 새로 생성되어 최신 productForm 참조 +}; +``` + +**결과:** +- 매 렌더링마다 새로운 함수 생성 +- 항상 최신 `productForm` 상태 참조 +- 클로저 문제 해결 + +--- + +### 이슈 2: FormInputField required 속성 + +#### 문제 상황 +테스트에서 설명 필드를 입력하지 않아도 폼 제출이 되어야 하는데, `required` 속성으로 인해 제출 불가. + +#### 원인 분석 +1. `FormInputField` 컴포넌트에 `required`가 하드코딩됨 +2. 모든 필드가 필수 입력으로 설정됨 +3. origin에서는 설명 필드에 `required` 속성이 없음 + +#### 해결 방법 +```typescript +// ❌ 이전 (문제 있음) +interface FormInputFieldProps { + // required prop 없음 +} + +export const FormInputField = ({ ... }) => { + return ( + + ); +}; + +// ✅ 수정 후 +interface FormInputFieldProps { + required?: boolean; // prop 추가 +} + +export const FormInputField = ({ + required = true, // 기본값 true + ... +}) => { + return ( + + ); +}; +``` + +**사용:** +```typescript +// 설명 필드에 required={false} 전달 + +``` + +**결과:** +- 설명 필드가 선택 입력으로 변경 +- 테스트에서 설명 없이도 폼 제출 가능 +- origin과 동일한 동작 + +--- + +### 이슈 3: 상태 업데이트 타이밍 문제 + +#### 문제 상황 +`fireEvent.change`를 빠르게 연속 호출할 때 상태 업데이트가 누락되는 문제. + +#### 원인 분석 +1. `setProductForm({ ...productForm, ... })` 패턴 사용 +2. 클로저로 인해 이전 `productForm` 값 참조 +3. React의 상태 업데이트 배치 처리 + +#### 해결 방법 +```typescript +// ❌ 이전 (문제 있음) +onChange={(e) => + setProductForm({ + ...productForm, // 이전 값 참조 가능 + name: e.target.value, + }) +} + +// ✅ 수정 후 +onChange={(e) => + setProductForm((prev) => ({ // 함수형 업데이트 + ...prev, // 최신 상태 보장 + name: e.target.value, + })) +} +``` + +**적용 위치:** +- `ProductBasicFields.tsx`의 모든 `setProductForm` 호출 +- 상품명, 설명, 가격, 재고 입력 필드 + +**결과:** +- 빠른 연속 입력에서도 최신 상태 보장 +- 상태 업데이트 누락 방지 + +--- + +## 🔍 발견된 패턴 및 베스트 프랙티스 + +### 1. 함수형 업데이트 패턴 +```typescript +// ✅ 권장: 함수형 업데이트 +setState((prev) => ({ ...prev, newValue })); + +// ❌ 피해야 할 패턴 (클로저 문제) +setState({ ...state, newValue }); +``` + +**이유:** +- 최신 상태 보장 +- 클로저 문제 방지 +- 빠른 연속 업데이트에서도 정확 + +--- + +### 2. useCallback 사용 전략 +```typescript +// ✅ useCallback 사용: 의존성이 명확하고 자주 사용되는 함수 +const addProduct = useCallback( + (newProduct) => { ... }, + [addNotification] +); + +// ❌ useCallback 사용 안 함: 최신 상태 참조가 중요한 함수 +const handleProductSubmit = (e) => { + // 매 렌더링마다 새로 생성하여 최신 상태 참조 +}; +``` + +**판단 기준:** +- 최신 상태 참조가 중요하면 `useCallback` 사용 안 함 +- 의존성이 명확하고 최적화가 필요하면 `useCallback` 사용 + +--- + +### 3. Props 빌더 함수 패턴 +```typescript +// ✅ Props 빌더 함수 +const buildAdminProductsSection = () => { + return { + productForm, // 최신 상태 참조 + handleProductSubmit, // 최신 함수 참조 + // ... + }; +}; + +// 사용 + +``` + +**이유:** +- Props 객체를 한 곳에서 관리 +- 최신 상태와 함수 참조 보장 +- 코드 가독성 향상 + +--- + +## ⚠️ 주의해야 할 패턴 + +### 1. 클로저 문제 +```typescript +// ❌ 문제 있는 코드 +const MyComponent = ({ value, onChange }) => { + const handleClick = useCallback(() => { + onChange(value); // 이전 value 참조 가능 + }, [value, onChange]); + + return ; +}; + +// ✅ 해결 방법 1: 함수형 업데이트 +const handleClick = useCallback(() => { + onChange((prev) => ({ ...prev, newValue })); +}, [onChange]); + +// ✅ 해결 방법 2: useCallback 제거 +const handleClick = () => { + onChange(value); // 최신 value 참조 +}; +``` + +--- + +### 2. 상태 의존성 관리 +```typescript +// ❌ 문제 있는 코드 +const [count, setCount] = useState(0); +const doubleCount = count * 2; // 매 렌더링마다 계산 + +// ✅ 해결 방법: useMemo +const doubleCount = useMemo(() => count * 2, [count]); +``` + +--- + +### 3. localStorage 동기화 +```typescript +// ✅ 올바른 패턴 +useEffect(() => { + localStorage.setItem("key", JSON.stringify(value)); +}, [value]); + +// 초기화 +const [value, setValue] = useState(() => { + const saved = localStorage.getItem("key"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return defaultValue; + } + } + return defaultValue; +}); +``` + +**주의:** +- try-catch로 JSON 파싱 오류 처리 +- 초기화 시 localStorage 확인 + +--- + +## 📝 코드 리뷰 체크리스트 + +### 상태 관리 +- [ ] 함수형 업데이트 사용 (상태 의존 업데이트) +- [ ] useCallback 의존성 배열 정확히 관리 +- [ ] localStorage 동기화 try-catch 처리 + +### 컴포넌트 설계 +- [ ] Props 타입 정의 +- [ ] 단일 책임 원칙 준수 +- [ ] 재사용 가능한 컴포넌트 분리 + +### 테스트 +- [ ] localStorage 초기화 (beforeEach) +- [ ] 비동기 업데이트 대기 (waitFor) +- [ ] 설명 필드 required={false} 설정 + +--- + +## 🔄 리팩토링 히스토리 + +### origin → basic 변경 사항 + +1. **컴포넌트 분리** + - 단일 파일 → 여러 컴포넌트 파일 + - 기능별 폴더 구조 + +2. **타입 정의 분리** + - 도메인별 타입 파일 + - Props 인터페이스 정의 + +3. **비즈니스 로직 분리** + - domain 폴더에 유틸리티 함수 + - 순수 함수로 분리 + +4. **상태 관리 개선** + - 함수형 업데이트 패턴 적용 + - useCallback 최적화 + +5. **테스트 호환성** + - origin과 동일한 테스트 통과 + - 동일한 기능 보장 + +--- + +## 🎯 향후 개선 사항 + +### 1. 에러 처리 강화 +- 네트워크 오류 처리 +- localStorage 용량 초과 처리 + +### 2. 성능 최적화 +- React.memo 적용 검토 +- 가상화 (대량 데이터) + +### 3. 접근성 개선 +- ARIA 속성 추가 +- 키보드 네비게이션 + +### 4. 테스트 커버리지 향상 +- 단위 테스트 추가 +- 경계값 테스트 + diff --git a/.cursor/mockdowns/basic/basic-project-overview.md b/.cursor/mockdowns/basic/basic-project-overview.md new file mode 100644 index 000000000..c73bf3d41 --- /dev/null +++ b/.cursor/mockdowns/basic/basic-project-overview.md @@ -0,0 +1,183 @@ +# Basic 프로젝트 개요 문서 + +## 📋 프로젝트 정보 + +### 프로젝트명 + +`frontend_7th_chapter3-2` - Basic 버전 + +### 프로젝트 목적 + +origin 폴더의 단일 파일 구조를 컴포넌트 분리 구조로 리팩토링한 버전입니다. 기능은 동일하지만 코드 구조가 개선되었습니다. + +### 프로젝트 위치 + +`src/basic/` + +--- + +## 🏗️ 프로젝트 구조 + +``` +src/basic/ +├── App.tsx # 메인 애플리케이션 컴포넌트 (상태 관리 및 로직) +├── main.tsx # React 앱 진입점 +├── __tests__/ # 테스트 파일 +│ └── origin.test.tsx # 통합 테스트 (origin과 동일한 테스트) +├── components/ # UI 컴포넌트 +│ ├── admin/ # 관리자 페이지 컴포넌트 +│ │ ├── common/ # 관리자 공통 컴포넌트 +│ │ ├── product/ # 상품 관리 컴포넌트 +│ │ └── coupon/ # 쿠폰 관리 컴포넌트 +│ ├── cart/ # 장바구니 관련 컴포넌트 +│ ├── common/ # 공통 컴포넌트 +│ ├── icon/ # 아이콘 컴포넌트 +│ ├── layouts/ # 레이아웃 컴포넌트 +│ ├── notifications/ # 알림 컴포넌트 +│ └── product/ # 상품 목록 컴포넌트 +├── pages/ # 페이지 컴포넌트 +│ ├── StorePage.tsx # 쇼핑몰 페이지 +│ └── AdminPage.tsx # 관리자 페이지 +├── domain/ # 도메인 로직 및 타입 +│ ├── cart/ # 장바구니 도메인 +│ ├── product/ # 상품 도메인 +│ └── notification/ # 알림 도메인 +├── utils/ # 유틸리티 함수 +│ └── formatters.ts # 포맷팅 함수 +└── constans/ # 상수 정의 + └── constans.ts # 상수 (PriceType, DiscountType 등) +``` + +--- + +## 🛠️ 기술 스택 + +### 프레임워크 및 라이브러리 + +- **React**: `^19.1.1` (최신 버전) +- **React DOM**: `^19.1.1` +- **TypeScript**: `^5.9.2` +- **Vite**: `^7.0.6` (빌드 도구) + +### 개발 도구 + +- **Vitest**: `^3.2.4` (테스트 프레임워크) +- **@testing-library/react**: `^16.3.0` (React 테스트 라이브러리) +- **@testing-library/jest-dom**: `^6.6.4` (DOM 매처) +- **@vitejs/plugin-react-swc**: `^3.11.0` (SWC 플러그인) + +### 스타일링 + +- **Tailwind CSS** (인라인 클래스 사용, 별도 설정 파일 없음) + +--- + +## 📦 주요 의존성 + +### 프로덕션 의존성 + +```json +{ + "react": "^19.1.1", + "react-dom": "^19.1.1" +} +``` + +### 개발 의존성 + +```json +{ + "@testing-library/jest-dom": "^6.6.4", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.1.9", + "@types/react-dom": "^19.1.7", + "@typescript-eslint/eslint-plugin": "^8.38.0", + "@typescript-eslint/parser": "^8.38.0", + "@vitejs/plugin-react-swc": "^3.11.0", + "@vitest/ui": "^3.2.4", + "eslint": "^9.32.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "jsdom": "^26.1.0", + "typescript": "^5.9.2", + "vite": "^7.0.6", + "vitest": "^3.2.4" +} +``` + +--- + +## 🚀 실행 스크립트 + +```bash +# Basic 버전 개발 서버 실행 +npm run dev:basic + +# Basic 버전 테스트 실행 +npm run test:basic + +# 테스트 UI 실행 +npm run test:ui + +# 빌드 +npm run build + +# 린트 +npm run lint +``` + +--- + +## 🎯 주요 특징 + +### 1. 컴포넌트 분리 구조 + +- origin의 단일 파일 구조를 기능별로 분리 +- 재사용 가능한 컴포넌트 구조 +- 관심사 분리 (Separation of Concerns) + +### 2. 도메인 주도 설계 (DDD) + +- `domain/` 폴더에 비즈니스 로직 분리 +- 타입 정의와 유틸리티 함수 분리 + +### 3. 타입 안정성 + +- TypeScript로 전체 타입 정의 +- 인터페이스 기반 컴포넌트 Props + +### 4. 상태 관리 + +- React Hooks 기반 상태 관리 +- localStorage 동기화 +- useCallback, useMemo를 활용한 최적화 + +--- + +## ⚠️ 주의사항 + +### 1. handleProductSubmit 함수 + +- `useCallback`으로 감싸지 않음 (클로저 문제 해결을 위해) +- 매 렌더링마다 새로 생성되어 최신 상태 참조 보장 + +### 2. FormInputField 컴포넌트 + +- `required` prop 추가됨 (기본값: `true`) +- 설명 필드는 `required={false}`로 설정 필요 + +### 3. 상태 업데이트 패턴 + +- `ProductBasicFields`에서 함수형 업데이트 사용 +- `setProductForm((prev) => ({ ...prev, ... }))` 패턴 사용 + +--- + +## 📝 다음 문서 + +- [도메인 모델 및 타입](./basic-domain-types.md) +- [컴포넌트 구조](./basic-components.md) +- [비즈니스 로직](./basic-business-logic.md) +- [상태 관리](./basic-state-management.md) +- [테스트 구조](./basic-testing.md) diff --git a/.cursor/mockdowns/basic/basic-refactoring-plan.md b/.cursor/mockdowns/basic/basic-refactoring-plan.md new file mode 100644 index 000000000..e13841fef --- /dev/null +++ b/.cursor/mockdowns/basic/basic-refactoring-plan.md @@ -0,0 +1,323 @@ +# Basic 프로젝트 리팩토링 계획 - 기본과제 + +## 📋 목표 + +pull_request_template.md의 기본과제를 완료하여 다음 목표를 달성: + +1. Component에서 비즈니스 로직을 분리하기 +2. 비즈니스 로직에서 특정 엔티티만 다루는 계산을 분리하기 +3. 뷰데이터와 엔티티데이터의 분리에 대한 이해 +4. entities -> features -> UI 계층에 대한 이해 + +--- + +## ✅ 체크리스트 + +### 기본과제 요구사항 +- [x] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? ✅ +- [x] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? ✅ +- [x] 계산함수는 순수함수로 작성이 되었나요? ✅ +- [x] 특정 Entity만 다루는 함수는 분리되어 있나요? ✅ +- [x] 특정 Entity만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요? ✅ +- [x] 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요? ✅ + +--- + +## 🔍 현재 상태 분석 + +### 1. 현재 구조 +``` +App.tsx (모든 상태와 로직 포함) +├── 상태 관리 (useState) - 15개 상태 +├── 비즈니스 로직 (함수들) - 10개 함수 +├── 계산 로직 (순수 함수 호출) - domain/ 폴더 +└── UI 렌더링 +``` + +### 2. 상태 목록 (App.tsx) +**Entity 상태:** +- `products` - 상품 목록 +- `productForm` - 상품 폼 데이터 +- `editingProduct` - 편집 중인 상품 ID +- `cart` - 장바구니 아이템 +- `coupons` - 쿠폰 목록 +- `couponForm` - 쿠폰 폼 데이터 +- `selectedCoupon` - 선택된 쿠폰 + +**UI 상태:** +- `isAdmin` - 관리자 모드 여부 +- `activeTab` - 관리자 탭 (products/coupons) +- `showProductForm` - 상품 폼 표시 여부 +- `showCouponForm` - 쿠폰 폼 표시 여부 +- `notifications` - 알림 목록 +- `searchTerm` - 검색어 +- `debouncedSearchTerm` - 디바운스된 검색어 +- `totalItemCount` - 장바구니 아이템 총 개수 + +### 3. 비즈니스 로직 함수 목록 +**상품 관련:** +- `addProduct` - 상품 추가 +- `updateProduct` - 상품 수정 +- `deleteProduct` - 상품 삭제 +- `startEditProduct` - 상품 편집 시작 +- `handleProductSubmit` - 상품 폼 제출 + +**장바구니 관련:** +- `addToCart` - 장바구니 추가 +- `removeFromCart` - 장바구니에서 제거 +- `updateQuantity` - 수량 업데이트 +- `completeOrder` - 주문 완료 + +**쿠폰 관련:** +- `addCoupon` - 쿠폰 추가 +- `deleteCoupon` - 쿠폰 삭제 +- `applyCoupon` - 쿠폰 적용 +- `handleCouponSubmit` - 쿠폰 폼 제출 + +**기타:** +- `addNotification` - 알림 추가 +- `selectorOnChange` - 쿠폰 선택 변경 + +### 4. 계산 함수 (이미 분리됨 ✅) +**domain/cart/cartUtils.ts:** +- `calculateCartTotal` - 장바구니 총액 계산 +- `calculateItemPriceDetails` - 아이템 가격 세부 정보 +- `getRemainingStock` - 재고 잔량 +- `getMaxApplicableDiscount` - 최대 할인율 +- `applyCoupon` - 쿠폰 적용 계산 + +**domain/product/productUtils.ts:** +- `filterProductsBySearchTerm` - 상품 필터링 + +**domain/cart/couponUtils.ts:** +- `formatCouponName` - 쿠폰 이름 포맷팅 + +### 5. 문제점 +- ❌ Component(App.tsx)에 비즈니스 로직이 혼재 +- ❌ 상태 관리와 로직이 분리되지 않음 +- ✅ 계산 함수는 순수 함수로 분리됨 (domain/ 폴더) +- ❌ Entity별 hook이 없음 +- ❌ entities -> features -> UI 계층 구조가 명확하지 않음 +- ❌ localStorage 동기화 로직이 App.tsx에 있음 + +--- + +## 🎯 리팩토링 목표 구조 + +### 목표 계층 구조 +``` +entities/ # 엔티티 데이터 타입 및 순수 함수 +├── product/ +│ ├── productTypes.ts +│ └── productUtils.ts +├── cart/ +│ ├── cartTypes.ts +│ └── cartUtils.ts +└── coupon/ + ├── couponTypes.ts + └── couponUtils.ts + +features/ # 도메인별 기능 (hook) +├── product/ +│ └── useProduct.ts +├── cart/ +│ └── useCart.ts +└── coupon/ + └── useCoupon.ts + +ui/ # UI 컴포넌트 +├── components/ +├── pages/ +└── layouts/ +``` + +### Hook 책임 분리 + +#### 1. useNotification +**책임**: 알림 상태 및 추가/제거 로직 +**상태:** +- `notifications: Notification[]` +**함수:** +- `addNotification(message, type)` +**의존성:** 없음 (가장 단순) + +#### 2. useSearch +**책임**: 검색어 상태 및 디바운스 처리 +**상태:** +- `searchTerm: string` +- `debouncedSearchTerm: string` +**함수:** +- `setSearchTerm(value)` +**의존성:** 없음 + +#### 3. useProduct +**책임**: 상품 Entity 상태 및 CRUD 로직 +**상태:** +- `products: ProductWithUI[]` (localStorage 동기화) +- `productForm: ProductForm` +- `editingProduct: string | null` +- `showProductForm: boolean` +**함수:** +- `addProduct(newProduct)` +- `updateProduct(productId, updates)` +- `deleteProduct(productId)` +- `startEditProduct(product)` +- `handleProductSubmit(e)` +- `setProductForm(value)` +- `setEditingProduct(value)` +- `setShowProductForm(value)` +**의존성:** useNotification + +#### 4. useCart +**책임**: 장바구니 Entity 상태 및 로직 +**상태:** +- `cart: CartItem[]` (localStorage 동기화) +- `totalItemCount: number` (계산된 값) +**함수:** +- `addToCart(product)` +- `removeFromCart(productId)` +- `updateQuantity(productId, newQuantity)` +- `completeOrder()` +- `filledItems: FilledCartItem[]` (계산된 값) +**의존성:** useNotification, useProduct (products 참조) + +#### 5. useCoupon +**책임**: 쿠폰 Entity 상태 및 CRUD 로직 +**상태:** +- `coupons: Coupon[]` (localStorage 동기화) +- `selectedCoupon: Coupon | null` +- `couponForm: Coupon` +- `showCouponForm: boolean` +**함수:** +- `addCoupon(newCoupon)` +- `deleteCoupon(couponCode)` +- `applyCoupon(coupon)` +- `handleCouponSubmit(e)` +- `setSelectedCoupon(coupon)` +- `setCouponForm(value)` +- `setShowCouponForm(value)` +**의존성:** useNotification, useCart (cart, selectedCoupon 참조) + +--- + +## 📝 작업 단계 + +### Step 1: 현재 상태 상세 분석 ✅ +- [x] App.tsx의 모든 상태와 로직 파악 +- [x] domain 폴더의 순수 함수 확인 +- [x] Entity별로 그룹핑 +- [x] 의존성 관계 파악 + +**분석 결과:** +- Entity 상태: products, productForm, editingProduct, cart, coupons, couponForm, selectedCoupon +- UI 상태: isAdmin, activeTab, showProductForm, showCouponForm, notifications, searchTerm, debouncedSearchTerm, totalItemCount +- 계산 함수는 이미 domain/ 폴더에 순수 함수로 분리됨 ✅ + +### Step 2: Hook 설계 🔄 +- [x] useProduct hook 설계 +- [x] useCart hook 설계 +- [x] useCoupon hook 설계 +- [x] useNotification hook 설계 +- [x] useSearch hook 설계 + +### Step 3: Hook 구현 ✅ +- [x] useNotification 구현 ✅ +- [x] useSearch 구현 ✅ +- [x] useProduct 구현 ✅ +- [x] useCart 구현 ✅ +- [x] useCoupon 구현 ✅ + +### Step 4: App.tsx 리팩토링 ✅ +- [x] Hook으로 로직 이동 ✅ +- [x] App.tsx는 Hook 조합만 수행 ✅ +- [x] Props 빌더 함수 유지 ✅ + +### Step 5: 테스트 및 검증 ✅ +- [x] 기존 테스트 통과 확인 ✅ +- [x] 기능 동작 확인 ✅ +- [x] 코드 리뷰 완료 + +--- + +## 🚧 작업 진행 상황 + +### 현재 단계: ✅ 모든 작업 완료 + +#### 완료된 작업: +- ✅ App.tsx의 모든 상태와 로직 파악 +- ✅ Entity별 그룹핑 완료 +- ✅ Hook 설계 완료 +- ✅ 모든 Hook 구현 완료 (useNotification, useSearch, useProduct, useCart, useCoupon) +- ✅ App.tsx 리팩토링 완료 (Hook 사용) +- ✅ 테스트 전부 통과 확인 + +#### 리팩토링 결과: +**이전 App.tsx:** +- 530줄, 모든 상태와 로직 포함 +- 비즈니스 로직이 컴포넌트에 혼재 + +**리팩토링 후 App.tsx:** +- 206줄, Hook 조합만 수행 +- 비즈니스 로직은 Hook으로 분리 +- entities -> features -> UI 계층 구조 명확 + +#### 최종 검증: +- ✅ 테스트 전부 통과 +- ✅ 기존 기능 유지 +- ✅ 코드 구조 개선 + +--- + +## 📌 중요 원칙 + +1. **기존 기능 유지**: 모든 기능이 동일하게 동작해야 함 +2. **테스트 코드 수정 불가**: 기존 테스트가 모두 통과해야 함 +3. **점진적 리팩토링**: 한 번에 하나씩 진행 +4. **의존성 방향**: entities <- features <- ui + +--- + +## 🔄 작업 기록 + +### 2025-01-XX +- 문서 작성 시작 +- 현재 상태 분석 시작 +- Hook 설계 완료 +- 모든 Hook 구현 완료 +- App.tsx 리팩토링 완료 + +## 📊 리팩토링 결과 요약 + +### 코드 라인 수 변화 +- **이전 App.tsx**: 530줄 +- **리팩토링 후 App.tsx**: 206줄 (61% 감소) +- **새로 생성된 Hook 파일**: 5개 (약 400줄) + +### 계층 구조 개선 +**이전:** +``` +App.tsx (모든 것 포함) +``` + +**리팩토링 후:** +``` +entities/ (domain/) + └─ 순수 함수 (이미 존재) +features/ (hooks/) + ├─ useProduct + ├─ useCart + ├─ useCoupon + ├─ useNotification + └─ useSearch +ui/ (components/, pages/) + └─ App.tsx (Hook 조합만) +``` + +### 체크리스트 달성도 +- [x] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? ✅ +- [x] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? ✅ +- [x] 계산함수는 순수함수로 작성이 되었나요? ✅ (이미 완료) +- [x] 특정 Entity만 다루는 함수는 분리되어 있나요? ✅ +- [x] 특정 Entity만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요? ✅ (이미 완료) +- [x] 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요? ✅ + diff --git a/.cursor/mockdowns/basic/basic-refactoring-summary.md b/.cursor/mockdowns/basic/basic-refactoring-summary.md new file mode 100644 index 000000000..96de1bc1c --- /dev/null +++ b/.cursor/mockdowns/basic/basic-refactoring-summary.md @@ -0,0 +1,215 @@ +# Basic 프로젝트 리팩토링 완료 요약 + +## ✅ 완료된 작업 + +### 1. Hook 구현 완료 +모든 비즈니스 로직을 Hook으로 분리했습니다. + +#### 구현된 Hook 목록 +1. **useNotification** (`hooks/useNotification.ts`) + - 알림 상태 및 추가/제거 로직 + - Entity를 다루지 않는 UI Hook + +2. **useSearch** (`hooks/useSearch.ts`) + - 검색어 상태 및 디바운스 처리 + - Entity를 다루지 않는 UI Hook + +3. **useProduct** (`hooks/useProduct.ts`) + - 상품 Entity 상태 및 CRUD 로직 + - localStorage 동기화 포함 + - Entity를 다루는 Hook + +4. **useCart** (`hooks/useCart.ts`) + - 장바구니 Entity 상태 및 로직 + - localStorage 동기화 포함 + - Entity를 다루는 Hook + +5. **useCoupon** (`hooks/useCoupon.ts`) + - 쿠폰 Entity 상태 및 CRUD 로직 + - localStorage 동기화 포함 + - Entity를 다루는 Hook + +### 2. App.tsx 리팩토링 완료 +- **이전**: 530줄, 모든 상태와 로직 포함 +- **리팩토링 후**: 206줄, Hook 조합만 수행 +- **코드 감소율**: 61% 감소 + +### 3. 계층 구조 개선 +``` +entities/ (domain/) + ├─ product/ + │ ├─ productTypes.ts + │ └─ productUtils.ts (순수 함수) + ├─ cart/ + │ ├─ cartTypes.ts + │ └─ cartUtils.ts (순수 함수) + └─ notification/ + └─ notificationTypes.ts + +features/ (hooks/) + ├─ useProduct.ts (Product Entity Hook) + ├─ useCart.ts (Cart Entity Hook) + ├─ useCoupon.ts (Coupon Entity Hook) + ├─ useNotification.ts (UI Hook) + └─ useSearch.ts (UI Hook) + +ui/ (components/, pages/) + └─ App.tsx (Hook 조합 및 UI 렌더링) +``` + +--- + +## 📋 기본과제 체크리스트 + +### ✅ 완료된 항목 + +- [x] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? + - ✅ 모든 비즈니스 로직을 Hook으로 이동 + +- [x] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? + - ✅ useProduct: 상품 Entity만 다룸 + - ✅ useCart: 장바구니 Entity만 다룸 + - ✅ useCoupon: 쿠폰 Entity만 다룸 + - ✅ useNotification: 알림 UI만 다룸 + - ✅ useSearch: 검색 UI만 다룸 + +- [x] 계산함수는 순수함수로 작성이 되었나요? + - ✅ domain/cart/cartUtils.ts의 모든 함수는 순수 함수 + - ✅ domain/product/productUtils.ts의 모든 함수는 순수 함수 + +- [x] 특정 Entity만 다루는 함수는 분리되어 있나요? + - ✅ useProduct: Product Entity만 다루는 함수 + - ✅ useCart: Cart Entity만 다루는 함수 + - ✅ useCoupon: Coupon Entity만 다루는 함수 + +- [x] 특정 Entity만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요? + - ✅ 이미 완료 (컴포넌트 분리 구조) + +- [x] 데이터 흐름에 맞는 계층구조를 이루고 의존성 방향이 맞게 작성이 되었나요? + - ✅ entities <- features <- ui 의존성 방향 준수 + +--- + +## 🎯 달성한 목표 + +### 1. Component에서 비즈니스 로직 분리 ✅ +- App.tsx에서 모든 비즈니스 로직을 Hook으로 이동 +- App.tsx는 Hook 조합과 UI 렌더링만 수행 + +### 2. 비즈니스 로직에서 특정 엔티티만 다루는 계산 분리 ✅ +- 각 Hook이 특정 Entity만 다루도록 설계 +- 순수 함수는 domain/ 폴더에 유지 + +### 3. 뷰데이터와 엔티티데이터의 분리 이해 ✅ +- Entity 상태: products, cart, coupons +- UI 상태: isAdmin, activeTab, notifications, searchTerm +- 명확히 구분됨 + +### 4. entities -> features -> UI 계층 구조 이해 ✅ +- entities: domain/ 폴더 (순수 함수) +- features: hooks/ 폴더 (Entity Hook) +- ui: components/, pages/, App.tsx + +--- + +## 📁 생성된 파일 + +### Hook 파일 +1. `src/basic/hooks/useNotification.ts` +2. `src/basic/hooks/useSearch.ts` +3. `src/basic/hooks/useProduct.ts` +4. `src/basic/hooks/useCart.ts` +5. `src/basic/hooks/useCoupon.ts` + +--- + +## 🔍 주요 변경 사항 + +### App.tsx 변경 +**이전:** +```typescript +const App = () => { + const [products, setProducts] = useState(...); + const [cart, setCart] = useState(...); + // ... 15개 상태 + // ... 10개 비즈니스 로직 함수 + // ... 5개 useEffect + // ... UI 렌더링 +}; +``` + +**리팩토링 후:** +```typescript +const App = () => { + // UI 상태만 + const [isAdmin, setIsAdmin] = useState(false); + const [activeTab, setActiveTab] = useState("products"); + + // Hook 사용 + const { notifications, setNotifications, addNotification } = useNotification(); + const { searchTerm, setSearchTerm, debouncedSearchTerm } = useSearch(); + const { products, ... } = useProduct(addNotification); + const { cart, ... } = useCart(products, addNotification); + const { coupons, ... } = useCoupon(cart, addNotification); + + // 계산된 값 (순수 함수) + const totals = calculateCartTotal(cart, selectedCoupon); + const filteredProducts = filterProductsBySearchTerm(...); + + // Props 빌더 및 UI 렌더링 +}; +``` + +--- + +## ⚠️ 주의사항 + +### 1. 기존 기능 유지 +- ✅ 모든 기능이 동일하게 동작하도록 구현 +- ✅ localStorage 동기화 유지 +- ✅ Props 빌더 함수 유지 + +### 2. 테스트 코드 수정 불가 +- ✅ 테스트 코드는 수정하지 않음 +- ✅ 기존 테스트가 통과해야 함 + +### 3. 의존성 방향 +- ✅ entities <- features <- ui +- ✅ Hook은 domain의 순수 함수만 사용 +- ✅ App.tsx는 Hook만 사용 + +--- + +## 🧪 테스트 결과 + +✅ **모든 테스트 통과 확인 완료** + +```bash +# 테스트 실행 결과 +npm run test:basic +# ✅ 모든 테스트 통과 +``` + +**확인 완료된 사항:** +1. ✅ 모든 테스트가 통과 +2. ✅ 기능이 정상적으로 동작 +3. ✅ localStorage 동기화 정상 + +--- + +## ✅ 최종 검증 완료 + +### 완료된 단계 +- ✅ Step 1: 현재 상태 상세 분석 +- ✅ Step 2: Hook 설계 +- ✅ Step 3: Hook 구현 +- ✅ Step 4: App.tsx 리팩토링 +- ✅ Step 5: 테스트 및 검증 + +### 리팩토링 성공 지표 +- ✅ 코드 라인 수 61% 감소 (530줄 → 206줄) +- ✅ 비즈니스 로직 완전 분리 +- ✅ 계층 구조 명확화 (entities → features → ui) +- ✅ 모든 테스트 통과 +- ✅ 기존 기능 100% 유지 + diff --git a/.cursor/mockdowns/basic/basic-state-management.md b/.cursor/mockdowns/basic/basic-state-management.md new file mode 100644 index 000000000..232f13dbf --- /dev/null +++ b/.cursor/mockdowns/basic/basic-state-management.md @@ -0,0 +1,421 @@ +# Basic 프로젝트 - 상태 관리 + +## 📍 상태 관리 위치 + +모든 상태는 `App.tsx`에서 관리됩니다. 컴포넌트 분리 구조이지만 상태는 중앙 집중식으로 관리합니다. + +--- + +## 🔄 상태 목록 + +### 1. 상품 관련 상태 + +```typescript +// 상품 목록 +const [products, setProducts] = useState(() => { + const saved = localStorage.getItem("products"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialProducts; + } + } + return initialProducts; +}); + +// 상품 폼 (추가/수정용) +const [productForm, setProductForm] = useState({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] as Array, +}); + +// 상품 편집 상태 +const [editingProduct, setEditingProduct] = useState(null); +const [showProductForm, setShowProductForm] = useState(false); +``` + +**특징:** +- `products`: localStorage에서 초기화, 변경 시 자동 저장 +- `productForm`: 폼 입력 상태, 함수형 업데이트 사용 +- `editingProduct`: 편집 중인 상품 ID 또는 "new" + +--- + +### 2. 장바구니 관련 상태 + +```typescript +// 장바구니 아이템 +const [cart, setCart] = useState(() => { + const saved = localStorage.getItem("cart"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return []; + } + } + return []; +}); + +// 장바구니 아이템 총 개수 (계산된 값) +const [totalItemCount, setTotalItemCount] = useState(0); +``` + +**특징:** +- `cart`: localStorage에서 초기화, 변경 시 자동 저장 +- `totalItemCount`: `useEffect`로 계산 + +--- + +### 3. 쿠폰 관련 상태 + +```typescript +// 쿠폰 목록 +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 [couponForm, setCouponForm] = useState({ + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0, +}); + +// 쿠폰 폼 표시 여부 +const [showCouponForm, setShowCouponForm] = useState(false); +``` + +--- + +### 4. UI 상태 + +```typescript +// 관리자 모드 여부 +const [isAdmin, setIsAdmin] = useState(false); + +// 관리자 탭 (products | coupons) +const [activeTab, setActiveTab] = useState("products"); + +// 알림 목록 +const [notifications, setNotifications] = useState([]); + +// 검색어 +const [searchTerm, setSearchTerm] = useState(""); + +// 디바운스된 검색어 +const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); +``` + +--- + +## 💾 localStorage 동기화 + +### 자동 저장 useEffect + +```typescript +// 상품 저장 +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]); +``` + +**특징:** +- 상태 변경 시 자동으로 localStorage에 저장 +- 장바구니가 비어있으면 localStorage에서 제거 + +--- + +## 🔄 상태 업데이트 함수 + +### useCallback으로 최적화된 함수들 + +```typescript +// 알림 추가 +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 addToCart = useCallback( + (product: ProductWithUI) => { + // 재고 확인 + const remainingStock = getRemainingStock(cart, 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] +); +``` + +**특징:** +- `useCallback`으로 함수 재생성 방지 +- 의존성 배열에 필요한 값 포함 +- 함수형 업데이트 사용 + +--- + +### 일반 함수 (useCallback 없음) + +```typescript +// 상품 폼 제출 +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); +}; +``` + +**중요:** +- `handleProductSubmit`은 `useCallback`으로 감싸지 않음 +- 매 렌더링마다 새로 생성되어 최신 `productForm` 참조 보장 +- 클로저 문제 방지 + +--- + +## 🎯 계산된 값 (useMemo) + +```typescript +// 장바구니 아이템에 가격 정보 추가 +const filledItems = useMemo( + () => + cart.map((item) => ({ + ...item, + priceDetails: calculateItemPriceDetails(item, cart), + })), + [cart] +); + +// 장바구니 총액 계산 +const totals = calculateCartTotal(cart, selectedCoupon); + +// 필터링된 상품 목록 +const filteredProducts = filterProductsBySearchTerm( + debouncedSearchTerm, + products +); +``` + +**특징:** +- `filledItems`: `useMemo`로 최적화 (cart 변경 시만 재계산) +- `totals`, `filteredProducts`: 매 렌더링마다 계산 (간단한 계산) + +--- + +## 🔄 디바운스 처리 + +```typescript +// 검색어 디바운스 +useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 500); + return () => clearTimeout(timer); +}, [searchTerm]); +``` + +**특징:** +- 500ms 지연 후 검색어 업데이트 +- 타이머 정리로 메모리 누수 방지 + +--- + +## 📊 Props 빌더 함수 + +### buildStorePageProps +```typescript +const buildStorePageProps = () => { + const productProps: ProductListProps = { + cart, + products, + filteredProducts, + debouncedSearchTerm, + addToCart, + }; + const cartSidebarProps: CartSidebarProps = { + cartProps: { + filledItems, + removeFromCart, + updateQuantity, + }, + couponProps: { + coupons, + selectedCouponCode: selectedCoupon?.code || "", + selectorOnChange, + }, + payment: { + totals, + completeOrder, + }, + }; + return { productProps, cartSidebarProps }; +}; +``` + +### buildAdminProductsSection +```typescript +const buildAdminProductsSection = () => { + const adminProductsProps: AdminProductsSectionProps = { + productListTableProps: productListTableProps(), + productForm, + showProductForm, + editingProduct, + setEditingProduct, + setProductForm, + setShowProductForm, + handleProductSubmit, + addNotification, + }; + return adminProductsProps; +}; +``` + +**특징:** +- 매 렌더링마다 호출되지만 최신 상태 참조 +- Props 객체를 한 곳에서 관리 + +--- + +## ⚠️ 상태 관리 주의사항 + +### 1. 함수형 업데이트 필수 +```typescript +// ✅ 올바른 방법 +setProductForm((prev) => ({ + ...prev, + name: newName, +})); + +// ❌ 잘못된 방법 (클로저 문제) +setProductForm({ + ...productForm, + name: newName, +}); +``` + +### 2. handleProductSubmit은 useCallback 사용 안 함 +- 최신 `productForm` 참조를 위해 매 렌더링마다 새로 생성 +- 클로저 문제 방지 + +### 3. localStorage 동기화 +- 상태 변경 시 자동 저장 +- 초기화 시 localStorage에서 복원 +- try-catch로 JSON 파싱 오류 처리 + +### 4. 의존성 배열 관리 +- `useCallback`, `useMemo`의 의존성 배열 정확히 관리 +- 누락 시 오래된 값 참조 가능 + +--- + +## 🔄 상태 흐름도 + +``` +사용자 액션 + ↓ +이벤트 핸들러 (App.tsx) + ↓ +상태 업데이트 (useState setter) + ↓ +useEffect (localStorage 저장) + ↓ +재렌더링 + ↓ +Props 빌더 함수 호출 + ↓ +컴포넌트에 Props 전달 + ↓ +UI 업데이트 +``` + +--- + +## 📝 상태 관리 패턴 요약 + +1. **중앙 집중식 관리**: 모든 상태를 App.tsx에서 관리 +2. **localStorage 동기화**: useEffect로 자동 저장 +3. **함수형 업데이트**: 상태 의존 업데이트 시 필수 +4. **useCallback 최적화**: 자주 사용되는 함수 최적화 +5. **useMemo 최적화**: 복잡한 계산 결과 캐싱 +6. **Props 빌더**: Props 객체를 함수로 생성 + diff --git a/.cursor/mockdowns/basic/basic-testing.md b/.cursor/mockdowns/basic/basic-testing.md new file mode 100644 index 000000000..701b54b5e --- /dev/null +++ b/.cursor/mockdowns/basic/basic-testing.md @@ -0,0 +1,341 @@ +# Basic 프로젝트 - 테스트 구조 + +## 📍 테스트 파일 위치 + +- `src/basic/__tests__/origin.test.tsx` - 통합 테스트 + +--- + +## 🧪 테스트 프레임워크 + +### 사용 도구 +- **Vitest**: `^3.2.4` - 테스트 러너 +- **@testing-library/react**: `^16.3.0` - React 컴포넌트 테스트 +- **@testing-library/jest-dom**: `^6.6.4` - DOM 매처 +- **jsdom**: `^26.1.0` - DOM 환경 시뮬레이션 + +### 설정 +```typescript +// vite.config.ts +export default mergeConfig( + defineConfig({ + plugins: [react()], + }), + defineTestConfig({ + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/setupTests.ts' + }, + }) +) +``` + +--- + +## 📋 테스트 구조 + +### 테스트 그룹 + +```typescript +describe("쇼핑몰 앱 통합 테스트", () => { + beforeEach(() => { + localStorage.clear(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("고객 쇼핑 플로우", () => { + // 고객 관련 테스트 + }); + + describe("관리자 기능", () => { + // 관리자 관련 테스트 + }); + + describe("로컬스토리지 동기화", () => { + // localStorage 관련 테스트 + }); +}); +``` + +--- + +## 🛍️ 고객 쇼핑 플로우 테스트 + +### 1. 상품 검색 및 장바구니 추가 +```typescript +test("상품을 검색하고 장바구니에 추가할 수 있다", async () => { + render(); + + // 검색창에 "프리미엄" 입력 + const searchInput = screen.getByPlaceholderText("상품 검색..."); + fireEvent.change(searchInput, { target: { value: "프리미엄" } }); + + // 디바운스 대기 (500ms) + await waitFor( + () => { + expect( + screen.getByText("최고급 품질의 프리미엄 상품입니다.") + ).toBeInTheDocument(); + }, + { timeout: 600 } + ); + + // 장바구니에 추가 + const addButtons = screen.getAllByText("장바구니 담기"); + fireEvent.click(addButtons[0]); + + // 알림 확인 + await waitFor(() => { + expect(screen.getByText("장바구니에 담았습니다")).toBeInTheDocument(); + }); +}); +``` + +**중요:** +- `waitFor`로 비동기 업데이트 대기 +- 디바운스 시간(500ms) 고려하여 timeout 설정 + +--- + +### 2. 장바구니 수량 조절 및 할인 확인 +```typescript +test("장바구니에서 수량을 조절하고 할인을 확인할 수 있다", async () => { + // 장바구니에 아이템 추가 + // 수량 증가/감소 테스트 + // 할인 정보 확인 +}); +``` + +--- + +## 👨‍💼 관리자 기능 테스트 + +### 1. 상품 추가 +```typescript +test("새 상품을 추가할 수 있다", () => { + // 관리자 모드 전환 + fireEvent.click(screen.getByText("관리자 페이지로")); + + // 새 상품 추가 버튼 클릭 + fireEvent.click(screen.getByText("새 상품 추가")); + + // 폼 입력 + // 제출 + // 확인 +}); +``` + +--- + +### 2. 상품 수정 +```typescript +test("기존 상품을 수정할 수 있다", () => { + // 수정 버튼 클릭 + // 폼 값 변경 + // 제출 + // 확인 +}); +``` + +--- + +### 3. 상품 삭제 +```typescript +test("상품을 삭제할 수 있다", () => { + // 삭제 버튼 클릭 + // 확인 +}); +``` + +--- + +### 4. 가격 입력 검증 +```typescript +test("상품의 가격 입력 시 숫자만 허용된다", async () => { + // 상품 수정 + fireEvent.click(screen.getAllByText("수정")[0]); + + const priceInput = screen.getAllByPlaceholderText("숫자만 입력")[0]; + + // 문자와 숫자 혼합 입력 시도 - 숫자만 남음 + fireEvent.change(priceInput, { target: { value: "abc123def" } }); + expect(priceInput.value).toBe("10000"); // 유효하지 않은 입력은 무시됨 + + // 숫자만 입력 + fireEvent.change(priceInput, { target: { value: "123" } }); + expect(priceInput.value).toBe("123"); +}); +``` + +**중요:** +- `onChange` 핸들러에서 `/^\d+$/` 정규식으로 숫자만 허용 +- 유효하지 않은 입력은 이전 값 유지 + +--- + +### 5. 쿠폰 생성 +```typescript +test("새 쿠폰을 생성할 수 있다", () => { + // 쿠폰 탭으로 이동 + // 쿠폰 생성 버튼 클릭 + // 폼 입력 + // 생성 + // 확인 +}); +``` + +--- + +## 💾 로컬스토리지 동기화 테스트 + +### 1. localStorage 저장 테스트 +```typescript +test("상품, 장바구니, 쿠폰이 localStorage에 저장된다", () => { + render(); + + // 장바구니에 추가 + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + expect(localStorage.getItem("cart")).toBeTruthy(); + expect(JSON.parse(localStorage.getItem("cart"))).toHaveLength(1); + + // 관리자 모드로 전환하여 새 상품 추가 + fireEvent.click(screen.getByText("관리자 페이지로")); + fireEvent.click(screen.getByText("새 상품 추가")); + + // 폼 입력 (상품명, 가격, 재고만 입력 - 설명은 선택) + const labels = screen.getAllByText("상품명"); + const nameLabel = labels.find((el) => el.tagName === "LABEL"); + const nameInput = nameLabel.closest("div").querySelector("input"); + fireEvent.change(nameInput, { target: { value: "저장 테스트" } }); + + const priceInput = screen.getAllByPlaceholderText("숫자만 입력")[0]; + fireEvent.change(priceInput, { target: { value: "10000" } }); + + const stockInput = screen.getAllByPlaceholderText("숫자만 입력")[1]; + fireEvent.change(stockInput, { target: { value: "10" } }); + + fireEvent.click(screen.getByText("추가")); + + // localStorage 확인 + expect(localStorage.getItem("products")).toBeTruthy(); + const products = JSON.parse(localStorage.getItem("products")); + expect(products.some((p) => p.name === "저장 테스트")).toBe(true); +}); +``` + +**중요:** +- 설명 필드는 입력하지 않아도 폼 제출 가능 (`required={false}`) +- `fireEvent.change`는 동기적으로 실행되지만 상태 업데이트는 비동기 +- `handleProductSubmit`이 최신 `productForm`을 참조하도록 설계 + +--- + +### 2. 페이지 새로고침 후 데이터 유지 +```typescript +test("페이지 새로고침 후에도 데이터가 유지된다", () => { + // 데이터 추가 + // 컴포넌트 재렌더링 + // 데이터 확인 +}); +``` + +--- + +## ⚠️ 테스트 주의사항 + +### 1. localStorage 초기화 +```typescript +beforeEach(() => { + localStorage.clear(); +}); +``` +- 각 테스트 전에 localStorage 초기화 +- 테스트 간 간섭 방지 + +### 2. 비동기 처리 +```typescript +await waitFor(() => { + expect(screen.getByText("텍스트")).toBeInTheDocument(); +}, { timeout: 600 }); +``` +- `waitFor`로 비동기 업데이트 대기 +- 디바운스 시간 고려하여 timeout 설정 + +### 3. fireEvent vs userEvent +- 현재는 `fireEvent` 사용 +- 더 현실적인 사용자 상호작용을 원하면 `userEvent` 고려 + +### 4. 설명 필드 입력 불필요 +- 테스트에서 설명 필드는 입력하지 않아도 됨 +- `FormInputField`의 `required={false}` 설정으로 폼 제출 가능 + +--- + +## 🐛 알려진 테스트 이슈 및 해결 + +### 이슈 1: handleProductSubmit 클로저 문제 +**문제:** +- `useCallback`으로 감싸진 `handleProductSubmit`이 이전 `productForm` 참조 +- 빠른 연속 입력 시 최신 값이 반영되지 않음 + +**해결:** +- `useCallback` 제거하여 매 렌더링마다 새로 생성 +- 최신 `productForm` 참조 보장 + +### 이슈 2: FormInputField required 속성 +**문제:** +- 모든 필드가 `required`로 설정되어 설명 필드 없이 폼 제출 불가 + +**해결:** +- `FormInputField`에 `required` prop 추가 (기본값: `true`) +- 설명 필드에 `required={false}` 전달 + +### 이슈 3: 상태 업데이트 타이밍 +**문제:** +- `fireEvent.change` 연속 호출 시 상태 업데이트 누락 + +**해결:** +- 함수형 업데이트 패턴 사용 +- `setProductForm((prev) => ({ ...prev, ... }))` + +--- + +## 📊 테스트 커버리지 + +### 테스트 범위 +- ✅ 고객 쇼핑 플로우 (검색, 장바구니 추가, 수량 조절) +- ✅ 관리자 기능 (상품 추가/수정/삭제, 쿠폰 생성/삭제) +- ✅ 입력 검증 (가격, 재고) +- ✅ localStorage 동기화 +- ✅ 할인 계산 +- ✅ 쿠폰 적용 + +### 미테스트 영역 +- 에러 처리 +- 경계값 테스트 +- 접근성 테스트 + +--- + +## 🚀 테스트 실행 + +```bash +# Basic 버전 테스트 실행 +npm run test:basic + +# 테스트 UI 실행 +npm run test:ui + +# 특정 테스트 실행 +npm test -- src/basic/__tests__/origin.test.tsx + +# 특정 테스트 케이스 실행 +npm test -- src/basic/__tests__/origin.test.tsx -t "상품, 장바구니, 쿠폰이 localStorage에 저장된다" +``` + diff --git a/.cursor/mockdowns/react-implementation/23_github-pages-deployment-verification.md b/.cursor/mockdowns/react-implementation/23_github-pages-deployment-verification.md new file mode 100644 index 000000000..12b76489d --- /dev/null +++ b/.cursor/mockdowns/react-implementation/23_github-pages-deployment-verification.md @@ -0,0 +1,288 @@ +# GitHub Pages 배포 설정 검증 보고서 + +## 📋 목표 + +`https://jumoooo.github.io/front_7th_chapter3-2/` 에서 `src/advanced` 화면이 정상적으로 표시되도록 설정 + +**참고 프로젝트**: `front_7th_chapter3-1` (입증된 배포 설정) + +--- + +## 🔍 현재 상태 분석 + +### ✅ 이미 구현된 부분 + +#### 1. Base Path 설정 ✅ + +**vite.config.ts**: + +```typescript +const base: string = + process.env.NODE_ENV === "production" ? "/front_7th_chapter3-2/" : ""; +``` + +**검증 결과**: + +- ✅ 배포 링크와 코드의 base path가 일치함: `/front_7th_chapter3-2/` +- ✅ front_7th_chapter3-1과 동일한 패턴 사용 +- ✅ 프로덕션 환경에서만 base path 적용 + +--- + +#### 2. 배포용 HTML 파일 ✅ + +**index.html** (루트 디렉토리): + +- ✅ `src/advanced/main.tsx`를 사용하도록 설정 +- ✅ GitHub Pages 배포용 기본 HTML 파일로 사용 +- ✅ front_7th_chapter3-1 패턴과 일치 + +--- + +#### 3. 빌드 스크립트 ✅ + +**package.json**: + +```json +{ + "scripts": { + "build:advanced": "tsc -b && vite build --config vite.config.ts" + } +} +``` + +**검증 결과**: + +- ✅ `build:advanced` 스크립트가 올바르게 설정됨 +- ✅ TypeScript 컴파일 후 Vite 빌드 실행 + +--- + +#### 4. GitHub Actions 워크플로우 ✅ + +**`.github/workflows/deploy.yml`**: + +- ✅ front_7th_chapter3-1과 동일한 구조 +- ✅ `publish_dir: ./dist` 설정 올바름 +- ✅ `build:advanced` 명령어 사용 +- ✅ `NODE_ENV: production` 설정으로 base path 적용 + +--- + +#### 5. 빌드 결과물 검증 ✅ + +**로컬 빌드 테스트 결과**: + +``` +✓ 86 modules transformed. +dist/index.html 0.44 kB │ gzip: 0.35 kB +dist/assets/index-BVSKioPT.js 225.60 kB │ gzip: 69.52 kB +✓ built in 1.11s +``` + +**생성된 파일 구조**: + +``` +dist/ +├── index.html ✅ +├── assets/ +│ └── index-BVSKioPT.js ✅ +└── vite.svg +``` + +**검증 결과**: + +- ✅ `index.html` 파일이 올바르게 생성됨 +- ✅ base path (`/front_7th_chapter3-2/`)가 JavaScript 파일 경로에 적용됨 +- ✅ 모든 필수 파일이 생성됨 + +--- + +## 📊 front_7th_chapter3-1과 비교 + +### ✅ 동일한 패턴 + +1. **vite.config.ts 구조**: 동일한 패턴 사용 + + - base path 설정 방식 동일 + - test 설정 조건부 제외 동일 + - dirname 처리 방식 동일 + +2. **GitHub Actions 워크플로우 구조**: 동일 + + - 동일한 액션 버전 사용 + - 동일한 권한 설정 + - 동일한 배포 방식 + +3. **빌드 결과물 구조**: 동일 + - `dist/index.html` 생성 + - `dist/assets/` 폴더 구조 동일 + +### 차이점 (의도된 차이) ✅ + +1. **Base Path**: `/front_7th_chapter3-1/` vs `/front_7th_chapter3-2/` (저장소 이름) +2. **빌드 스크립트**: `build:after` vs `build:advanced` (프로젝트 구조 차이) +3. **publish_dir**: `./packages/after/dist` vs `./dist` (monorepo vs 단일 프로젝트) + +--- + +## ✅ 검증 체크리스트 + +### 파일 설정 검증 + +- [x] vite.config.ts: base path 설정 확인 (`/front_7th_chapter3-2/`) +- [x] index.html: advanced 버전 사용 확인 (`src/advanced/main.tsx`) +- [x] package.json: build:advanced 스크립트 확인 +- [x] deploy.yml: 워크플로우 설정 확인 (`publish_dir: ./dist`) +- [x] 로컬 빌드 테스트: 성공 확인 +- [x] 빌드 결과물: index.html 및 assets 파일 생성 확인 +- [x] 빌드된 경로: base path 적용 확인 + +### 사용자 확인 필요 (외부 설정) + +- [ ] GitHub Settings → Pages: Source가 "GitHub Actions"로 설정됨 +- [ ] GitHub Actions: 워크플로우가 성공적으로 실행됨 (✅) +- [ ] gh-pages 브랜치: 브랜치가 생성되고 index.html 파일이 있음 + +--- + +## 👤 사용자가 직접 해야 할 작업 (외부 설정) + +### ⚠️ 중요: 다음 작업들은 코드 수정이 아닌 GitHub 웹사이트에서 직접 설정해야 합니다 + +#### 1. GitHub 저장소 설정 확인 + +1. **GitHub 저장소 접속** + + - 저장소 URL: `https://github.com/jumoooo/front_7th_chapter3-2` + - 저장소가 존재하고 접근 가능한지 확인 + +2. **저장소 권한 확인** + - Settings > General에서 저장소 설정 확인 + - Actions 권한이 활성화되어 있는지 확인 + +--- + +#### 2. GitHub Pages 설정 ⚠️ 가장 중요! + +1. **Settings > Pages 메뉴 접속** + + - 저장소의 Settings 탭 클릭 + - 왼쪽 메뉴에서 "Pages" 클릭 + - URL: `https://github.com/jumoooo/front_7th_chapter3-2/settings/pages` + +2. **Source 설정** ⚠️ + + - Source: **"GitHub Actions"** 선택 ✅ + - ❌ "Deploy from a branch"가 아니라 **"GitHub Actions"**를 선택해야 함 + - Save 클릭 + +3. **설정 확인** + - 페이지 새로고침 후 Source가 "GitHub Actions"로 설정되었는지 재확인 + +--- + +#### 3. GitHub Actions 권한 설정 + +1. **Settings > Actions > General 접속** + + - 저장소의 Settings > Actions > General 메뉴로 이동 + +2. **Workflow permissions 설정** + + - "Workflow permissions" 섹션 확인 + - "Read and write permissions" 선택 + - Save 버튼 클릭 + +3. **Actions 권한 확인** + - Actions 탭에서 워크플로우가 실행 가능한지 확인 + +--- + +#### 4. 초기 배포 확인 + +1. **워크플로우 실행 확인** + + - 코드 푸시 후 Actions 탭에서 워크플로우 실행 확인 + - URL: `https://github.com/jumoooo/front_7th_chapter3-2/actions` + - `Deploy to GitHub Pages` 워크플로우 확인 + - ✅ 초록색 체크마크가 보이면 성공 + +2. **gh-pages 브랜치 확인** + + - Code 탭에서 브랜치 목록 확인 + - URL: `https://github.com/jumoooo/front_7th_chapter3-2/branches` + - `gh-pages` 브랜치가 생성되었는지 확인 + - 브랜치 내용 확인: `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages` + - `index.html` 파일이 있는지 확인 + +3. **배포 사이트 접속 확인** + - `https://jumoooo.github.io/front_7th_chapter3-2/` 접속 + - `src/advanced` 화면이 표시되는지 확인 + - 브라우저 캐시 지우기 (Ctrl + Shift + R 또는 Cmd + Shift + R) + +--- + +#### 5. 문제 해결 (필요시) + +1. **배포 실패 시** + + - Actions 탭에서 실패한 워크플로우 클릭 + - 로그 확인하여 에러 원인 파악 + - 필요시 코드 수정 후 재푸시 + +2. **사이트가 404 에러인 경우** + + - GitHub Pages 설정 확인 (Source가 "GitHub Actions"인지) + - `gh-pages` 브랜치가 올바르게 설정되었는지 확인 + - Base path가 올바른지 확인 (`/front_7th_chapter3-2/`) + - 브라우저 캐시 지우기 + +3. **권한 에러인 경우** + - Settings > Actions > General에서 권한 확인 + - 조직 저장소인 경우 관리자에게 권한 요청 + +--- + +## 📌 다음 단계 (작업 순서) + +### 개발자(AI)가 한 작업 ✅ + +1. ✅ Base path 확인 완료 (`/front_7th_chapter3-2/`) +2. ✅ index.html 파일 생성 (advanced 버전 사용) +3. ✅ vite.config.ts 설정 (front_7th_chapter3-1 패턴 적용) +4. ✅ GitHub Actions 워크플로우 파일 생성 및 검증 +5. ✅ 로컬 빌드 테스트 완료 + +### 사용자가 할 작업 (외부 설정) + +6. **GitHub 저장소 설정 확인** (위의 "1. GitHub 저장소 설정 확인" 참조) +7. **GitHub Pages 설정** (위의 "2. GitHub Pages 설정" 참조) ⚠️ 가장 중요! +8. **GitHub Actions 권한 설정** (위의 "3. GitHub Actions 권한 설정" 참조) + +### 공동 작업 + +9. **코드 푸시 및 배포 테스트** + + - 개발자가 코드를 main 브랜치에 푸시 + - 사용자가 Actions 탭에서 워크플로우 실행 확인 + - 사용자가 gh-pages 브랜치 생성 확인 + - 사용자가 배포된 사이트 접속하여 기능 검증 + +10. **배포 검증 및 문제 해결** + - 배포된 사이트 기능 확인 + - 문제 발생 시 위의 "5. 문제 해결" 참조 + +--- + +## 🎉 결론 + +**모든 파일 설정이 올바르게 구성되어 있습니다!** + +파일 레벨에서는 문제가 없으며, `front_7th_chapter3-1`과 동일한 입증된 패턴을 사용하고 있습니다. + +404 에러가 발생한다면, 가장 먼저 확인해야 할 것은: + +1. **GitHub Settings → Pages**: Source가 "GitHub Actions"로 설정되었는지 ⚠️ + +이것만 확인하면 대부분의 경우 해결됩니다! diff --git a/.cursor/rules/code-quality.mdc b/.cursor/rules/code-quality.mdc new file mode 100644 index 000000000..1dc0d9227 --- /dev/null +++ b/.cursor/rules/code-quality.mdc @@ -0,0 +1,142 @@ +# 코드 품질 규칙 + +## 📋 코드 작성 원칙 + +### 1. 타입 안정성 +- TypeScript 타입 정의 명확히 +- `any` 타입 사용 최소화 +- 타입 오류는 반드시 해결 +- 인터페이스와 타입 별칭 적절히 활용 + +### 2. 함수형 프로그래밍 +- 순수 함수 우선 사용 +- 사이드 이펙트 최소화 +- 불변성 유지 +- 액션과 순수 함수 분리 + +### 3. 관심사 분리 +- 단일 책임 원칙 준수 +- 컴포넌트는 UI만 담당 +- 비즈니스 로직은 Hook으로 분리 +- 계산 로직은 순수 함수로 분리 + +--- + +## 🏗️ 아키텍처 규칙 + +### 1. 계층 구조 +``` +entities/ (domain/) + └─ 순수 함수 (계산 로직) + +features/ (hooks/ 또는 stores/) + └─ Entity Hook 또는 Store + +ui/ (components/, pages/) + └─ UI 컴포넌트 +``` + +### 2. 의존성 방향 +- entities <- features <- ui +- 하위 계층은 상위 계층에 의존하지 않음 +- 순환 의존성 방지 + +### 3. Entity 분리 +- 특정 Entity만 다루는 함수는 분리 +- Entity를 다루는 Hook과 UI Hook 구분 +- Entity 상태와 UI 상태 구분 + +--- + +## 🔧 React 규칙 + +### 1. Hook 사용 +- Custom Hook으로 비즈니스 로직 분리 +- Hook의 책임 명확히 +- 의존성 배열 정확히 관리 +- 불필요한 재렌더링 방지 + +### 2. 상태 관리 +- Entity 상태와 UI 상태 구분 +- 함수형 업데이트 패턴 사용 (필요시) +- localStorage 동기화 적절히 처리 +- 상태 업데이트 배치 처리 고려 + +### 3. 컴포넌트 설계 +- 재사용 가능한 컴포넌트 설계 +- Props 타입 명확히 정의 +- 조건부 렌더링 적절히 사용 +- 불필요한 props 전달 방지 + +--- + +## 📦 파일 구조 규칙 + +### 1. 폴더 구조 +- 기능별로 폴더 분리 +- 관련 파일은 같은 폴더에 배치 +- 계층 구조 명확히 + +### 2. 파일 명명 +- 컴포넌트: PascalCase (예: `ProductList.tsx`) +- Hook: camelCase with `use` prefix (예: `useProduct.ts`) +- 유틸리티: camelCase (예: `cartUtils.ts`) +- 타입: camelCase (예: `productTypes.ts`) + +### 3. Import 순서 +1. React 및 외부 라이브러리 +2. 타입 정의 +3. 도메인 로직 (순수 함수) +4. Hook +5. 컴포넌트 +6. 상대 경로 import + +--- + +## 🎨 코드 스타일 + +### 1. 가독성 +- 의미 있는 변수명 사용 +- 복잡한 로직은 주석 추가 +- 함수는 작고 명확하게 +- 중첩 깊이 최소화 + +### 2. 일관성 +- 프로젝트 내 코딩 스타일 일관성 유지 +- 기존 코드 스타일 따라가기 +- 팀 컨벤션 준수 + +### 3. 최적화 +- 불필요한 재렌더링 방지 +- useMemo, useCallback 적절히 사용 +- 큰 리스트는 가상화 고려 +- 메모이제이션 전략 수립 + +--- + +## ⚠️ 금지 사항 + +### 1. 절대 하지 말아야 할 것 +- 테스트 코드 수정 +- 기존 기능 동작 변경 +- 타입 안정성 무시 +- 순환 의존성 생성 + +### 2. 지양해야 할 것 +- 과도한 중첩 +- 긴 함수 (50줄 이상) +- 복잡한 조건문 +- 하드코딩된 값 + +--- + +## ✅ 코드 리뷰 체크리스트 + +작성한 코드 확인: +- [ ] 타입 오류 없음 +- [ ] 린터 오류 없음 +- [ ] 테스트 통과 +- [ ] 기존 기능 정상 동작 +- [ ] 코드 가독성 양호 +- [ ] 성능 이슈 없음 +- [ ] 보안 이슈 없음 diff --git a/.cursor/rules/project-workflow.mdc b/.cursor/rules/project-workflow.mdc new file mode 100644 index 000000000..1fd576bff --- /dev/null +++ b/.cursor/rules/project-workflow.mdc @@ -0,0 +1,162 @@ +# 프로젝트 작업 규칙 + +## 📋 기본 원칙 + +### 1. 기존 기능 보존 + +- **절대 규칙**: 기존 기능을 건들지 않은 상태로 작업 진행 +- 모든 기능이 동일하게 동작해야 함 +- 리팩토링 시에도 동작 결과는 동일해야 함 + +### 2. 테스트 코드 수정 금지 + +- **절대 규칙**: 테스트 코드는 수정할 수 없다 +- 기존 테스트가 모두 통과해야 함 +- 테스트 실패 시 구현 코드를 수정하여 해결 + +### 3. 점진적 작업 진행 + +- 작업은 스텝별로 진행 +- 한 번에 하나씩 완료 후 다음 단계 진행 +- 각 단계 완료 후 검증 + +--- + +## 🔄 작업 프로세스 + +### 1. 작업 계획 수립 + +- 목표를 명확히 정의 +- 작업 단계를 나누어 계획 +- `.cursor/mockdowns/` 폴더에 문서로 기록 + +### 2. 작업 진행 + +- 스텝별로 진행하며 문서 업데이트 +- 각 단계 완료 시 문서에 기록 +- 망각 방지를 위해 산출물에 기록하고 읽으면서 작업 + +### 3. 테스트 및 검증 + +- **중요**: 실행 및 테스트는 사용자에게 전달 +- 사용자가 확인 후 결과를 다시 AI에게 전달 +- AI는 결과를 바탕으로 다음 작업 진행 + +### 4. 문서 관리 + +- 작업 진행될 때마다 해당 문서를 수정 +- `.cursor/mockdowns/` 폴더에 상세 기록 +- 다음 AI가 작업할 때 참고할 수 있도록 작성 + +--- + +## 🛠️ 기술적 규칙 + +### 1. FE 관점에서 프로 AI로서 진행 + +- 프론트엔드 개발 베스트 프랙티스 준수 +- 코드 품질과 가독성 중시 +- 성능 최적화 고려 + +### 2. MCP 활용 + +- 필요시 셋업된 MCP를 이용 +- 도구를 적절히 활용하여 효율성 향상 + +### 3. 타입 안정성 + +- TypeScript 타입 정의 명확히 +- 타입 오류는 반드시 해결 +- 린터 오류 확인 및 수정 + +### 4. 패키지 관리자 + +- **pnpm을 주로 사용** +- 모든 패키지 설치 및 관리 명령어는 pnpm 사용 +- `pnpm install`, `pnpm add`, `pnpm remove` 등 +- 설치가 필요한 경우 사용자에게 알려주고 사용자가 직접 설치 + +--- + +## 📝 문서화 규칙 + +### 1. 작업 계획 문서 + +- `.cursor/mockdowns/` 폴더에 `.md` 파일로 작성 +- 작업 단계, 진행 상황, 완료 내역 기록 +- 체크리스트 포함 + +### 2. 산출물 기록 + +- 작업 중 중요한 결정사항 기록 +- 문제 해결 과정 기록 +- 다음 작업을 위한 참고사항 기록 + +### 3. 문서 업데이트 + +- 작업 진행될 때마다 문서 수정 +- 완료된 단계는 체크 표시 +- 현재 진행 중인 단계 명시 + +--- + +## ⚠️ 주의사항 + +### 1. 기능 변경 금지 + +- 기존 기능 동작 변경 금지 +- 새로운 기능 추가 시 기존 기능 영향 없어야 함 +- API 변경 시 하위 호환성 유지 + +### 2. 테스트 우선 + +- 테스트 통과가 최우선 +- 테스트 실패 시 구현 코드 수정 +- 테스트 코드는 절대 수정하지 않음 + +### 3. 점진적 리팩토링 + +- 한 번에 너무 많은 변경 금지 +- 작은 단위로 나누어 진행 +- 각 단계마다 검증 + +--- + +## 🎯 작업 목표별 규칙 + +### 기본과제 (Basic) + +- Component에서 비즈니스 로직을 Hook으로 분리 +- entities -> features -> UI 계층 구조 구현 +- 순수 함수와 액션 분리 + +### 심화과제 (Advanced) + +- Zustand를 사용한 전역 상태 관리 +- Props drilling 제거 +- 도메인 props는 유지, 불필요한 props 제거 +- 결합도 낮추기 + +--- + +## 📌 체크리스트 + +작업 시작 전 확인: + +- [ ] 기존 기능 보존 계획 수립 +- [ ] 테스트 코드 수정하지 않을 것 확인 +- [ ] 작업 단계 계획 수립 +- [ ] 문서 작성 위치 확인 + +작업 중 확인: + +- [ ] 각 단계 완료 후 문서 업데이트 +- [ ] 테스트 실행은 사용자에게 요청 +- [ ] 타입 오류 및 린터 오류 확인 + +작업 완료 후 확인: + +- [ ] 모든 테스트 통과 확인 +- [ ] 기존 기능 정상 동작 확인 +- [ ] 문서 최종 업데이트 +- [ ] 코드 리뷰 준비 diff --git a/.cursor/rules/refactoring-guidelines.mdc b/.cursor/rules/refactoring-guidelines.mdc new file mode 100644 index 000000000..2cfc9ea9e --- /dev/null +++ b/.cursor/rules/refactoring-guidelines.mdc @@ -0,0 +1,145 @@ +# 리팩토링 가이드라인 + +## 📋 리팩토링 원칙 + +### 1. 안전한 리팩토링 +- 기존 기능 보존 최우선 +- 테스트 코드 수정 금지 +- 점진적 리팩토링 +- 각 단계마다 검증 + +### 2. 점진적 접근 +- 한 번에 하나씩 변경 +- 작은 단위로 나누어 진행 +- 각 단계 완료 후 테스트 +- 롤백 가능한 구조 유지 + +### 3. 문서화 +- 리팩토링 계획 문서화 +- 진행 상황 기록 +- 변경 사항 상세 기록 +- 다음 작업을 위한 참고사항 기록 + +--- + +## 🔄 리팩토링 프로세스 + +### 1. 분석 단계 +- 현재 코드 구조 파악 +- 문제점 식별 +- 개선 방향 수립 +- 리팩토링 계획 작성 + +### 2. 설계 단계 +- 목표 구조 설계 +- 의존성 관계 파악 +- 변경 영향도 분석 +- 단계별 계획 수립 + +### 3. 구현 단계 +- 단계별로 구현 +- 각 단계 완료 후 검증 +- 문서 업데이트 +- 테스트 확인 + +### 4. 검증 단계 +- 모든 테스트 통과 확인 +- 기능 동작 확인 +- 성능 확인 +- 코드 리뷰 + +--- + +## 🎯 리팩토링 목표 + +### 기본과제 (Basic) +- Component에서 비즈니스 로직 분리 +- Hook으로 로직 이동 +- entities -> features -> UI 계층 구조 +- 순수 함수와 액션 분리 + +### 심화과제 (Advanced) +- Zustand로 전역 상태 관리 +- Props drilling 제거 +- 도메인 props 유지, 불필요한 props 제거 +- 결합도 낮추기 + +--- + +## 📐 리팩토링 패턴 + +### 1. Hook 추출 +- 비즈니스 로직을 Hook으로 분리 +- Entity별 Hook 분리 +- UI Hook과 Entity Hook 구분 +- 의존성 명확히 + +### 2. 상태 관리 개선 +- 전역 상태와 로컬 상태 구분 +- Zustand Store 설계 +- Props drilling 제거 +- 필요한 props만 전달 + +### 3. 컴포넌트 분리 +- 큰 컴포넌트를 작은 컴포넌트로 분리 +- 재사용 가능한 컴포넌트 추출 +- 관심사별로 분리 +- Props 인터페이스 명확히 + +--- + +## ⚠️ 주의사항 + +### 1. 기능 보존 +- 기존 기능 동작 동일하게 유지 +- API 변경 시 하위 호환성 유지 +- 사용자 경험 변경 없음 + +### 2. 테스트 우선 +- 테스트 코드 수정 금지 +- 모든 테스트 통과 필수 +- 테스트 실패 시 구현 수정 + +### 3. 점진적 진행 +- 한 번에 너무 많은 변경 금지 +- 작은 단위로 나누어 진행 +- 각 단계마다 검증 + +--- + +## 🔍 리팩토링 체크리스트 + +리팩토링 전: +- [ ] 현재 코드 구조 파악 +- [ ] 문제점 식별 +- [ ] 리팩토링 계획 수립 +- [ ] 영향도 분석 + +리팩토링 중: +- [ ] 단계별로 진행 +- [ ] 각 단계 완료 후 검증 +- [ ] 문서 업데이트 +- [ ] 테스트 확인 + +리팩토링 후: +- [ ] 모든 테스트 통과 +- [ ] 기능 정상 동작 +- [ ] 코드 품질 개선 확인 +- [ ] 문서 최종 업데이트 + +--- + +## 📝 리팩토링 문서화 + +### 필수 기록 사항 +- 리팩토링 목표 +- 현재 상태 분석 +- 변경 계획 +- 진행 상황 +- 완료 내역 +- 문제 해결 과정 + +### 문서 위치 +- `.cursor/mockdowns/` 폴더 +- `.md` 파일 형식 +- 다음 AI가 참고할 수 있도록 상세히 작성 diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 000000000..39b4c2c2b --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "context7": { + "httpUrl": "https://mcp.context7.com/mcp" + }, + "sequential-thinking": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"] + } + } +} \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..9ca5848ad --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,63 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: write + pages: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - name: Build advanced package + run: pnpm build:advanced + env: + NODE_ENV: production + + # 빌드 결과 확인 (디버깅용) + - name: Verify build output + run: | + echo "=== Build output verification ===" + ls -la dist/ + echo "---" + ls -la dist/assets/ || echo "ERROR: assets directory not found!" + echo "---" + if [ -f dist/.nojekyll ]; then + echo "✅ .nojekyll file exists" + else + echo "❌ .nojekyll file NOT found!" + fi + if [ -f dist/index.html ]; then + echo "✅ index.html exists" + echo "--- index.html content (first 10 lines) ---" + head -10 dist/index.html + else + echo "❌ index.html NOT found!" + fi + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist diff --git "a/404_\353\254\270\354\240\234\355\225\264\352\262\260_\353\213\250\352\263\204\353\263\204\352\260\200\354\235\264\353\223\234.md" "b/404_\353\254\270\354\240\234\355\225\264\352\262\260_\353\213\250\352\263\204\353\263\204\352\260\200\354\235\264\353\223\234.md" new file mode 100644 index 000000000..002253d34 --- /dev/null +++ "b/404_\353\254\270\354\240\234\355\225\264\352\262\260_\353\213\250\352\263\204\353\263\204\352\260\200\354\235\264\353\223\234.md" @@ -0,0 +1,278 @@ +# 🔴 404 에러 해결 - 단계별 진단 가이드 + +## 🎯 현재 상황 +`https://jumoooo.github.io/front_7th_chapter3-2/` 접속 시 404 에러 발생 + +--- + +## 📋 단계별 진단 및 해결 + +### ✅ 1단계: GitHub 저장소 Settings → Pages 확인 (가장 중요!) + +#### 확인 방법: +1. **GitHub 저장소로 이동** + - URL: `https://github.com/jumoooo/front_7th_chapter3-2` + +2. **Settings → Pages로 이동** + - 저장소 상단 메뉴: `Settings` 클릭 + - 왼쪽 사이드바: `Pages` 클릭 + +3. **현재 설정 확인** ⚠️ + - **Source** 필드를 확인하세요 + - 현재 무엇으로 설정되어 있나요? + +#### 올바른 설정: +``` +Source: GitHub Actions ✅ +``` +> ❌ "Deploy from a branch" 또는 "None"으로 되어 있다면 문제입니다! + +#### 설정 변경 방법: +1. Source 드롭다운 클릭 +2. **"GitHub Actions"** 선택 +3. **Save** 버튼 클릭 +4. 저장 후 페이지 새로고침 + +--- + +### ✅ 2단계: GitHub Actions 워크플로우 확인 + +#### 확인 방법: +1. **Actions 탭으로 이동** + - URL: `https://github.com/jumoooo/front_7th_chapter3-2/actions` + +2. **워크플로우 목록 확인** + - 왼쪽 사이드바에서 "Deploy to GitHub Pages" 확인 + - 워크플로우가 실행되었는지 확인 + +3. **실행 상태 확인** + - ✅ **초록색 체크마크**: 성공! (다음 단계로) + - 🟡 **노란색 원**: 진행 중 (대기) + - ❌ **빨간색 X**: 실패 (로그 확인 필요) + +#### 워크플로우가 없다면: +```bash +# 로컬에서 빈 커밋으로 트리거 +git commit --allow-empty -m "trigger deployment" +git push +``` + +#### 워크플로우가 실패했다면: +1. 실패한 워크플로우 클릭 +2. 로그 확인 +3. 에러 메시지 확인 +4. 대부분의 경우: + - `pnpm-lock.yaml` 문제 + - 해결: 로컬에서 `pnpm install` 후 다시 커밋 + +--- + +### ✅ 3단계: gh-pages 브랜치 확인 + +#### 확인 방법: +1. **브랜치 목록 확인** + - URL: `https://github.com/jumoooo/front_7th_chapter3-2/branches` + +2. **gh-pages 브랜치 확인** + - `gh-pages` 브랜치가 있는지 확인 + - 없다면 → GitHub Actions가 아직 실행되지 않았거나 실패한 것입니다 + +3. **브랜치 내용 확인** (있는 경우) + - URL: `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages` + - 다음 파일들이 있어야 합니다: + - ✅ `index.html` + - ✅ `assets/` 폴더 + - ✅ 기타 빌드된 파일들 + +#### 파일이 없다면: +- GitHub Actions 워크플로우가 실패했거나 +- 아직 실행되지 않았습니다 + +--- + +### ✅ 4단계: 로컬 빌드 테스트 + +#### 빌드 확인: +```bash +# 로컬에서 빌드 테스트 +pnpm build:advanced + +# 빌드 결과 확인 +ls -la dist/ +``` + +#### 확인해야 할 것: +- ✅ `dist/index.html` 파일이 있어야 함 +- ✅ `dist/assets/` 폴더가 있어야 함 +- ✅ JavaScript 파일이 있어야 함 + +--- + +### ✅ 5단계: 코드 변경사항 확인 + +#### 변경된 파일 확인: +```bash +git status +``` + +#### 확인해야 할 파일: +- ✅ `index.html` (새로 생성됨) +- ✅ `vite.config.ts` (수정됨) +- ✅ `.github/workflows/deploy.yml` (확인) + +#### 커밋 & Push: +```bash +# 모든 변경사항 추가 +git add . + +# 커밋 +git commit -m "fix: GitHub Pages 배포 설정" + +# Push +git push +``` + +--- + +## 🔍 가장 흔한 원인과 해결책 + +### 원인 1: GitHub Pages Source 설정이 잘못됨 ⚠️ 가장 흔함! + +**증상**: 404 에러, README.md가 나오거나, 파일을 찾을 수 없음 + +**해결**: +1. GitHub 저장소 → Settings → Pages +2. Source를 **"GitHub Actions"**로 변경 +3. Save 클릭 + +--- + +### 원인 2: GitHub Actions 워크플로우가 실행되지 않음 + +**증상**: Actions 탭에 워크플로우가 없거나 실행되지 않음 + +**해결**: +1. 코드 변경사항을 push +2. 또는 빈 커밋으로 트리거: + ```bash + git commit --allow-empty -m "trigger deployment" + git push + ``` + +--- + +### 원인 3: GitHub Actions 워크플로우 실패 + +**증상**: Actions 탭에서 빨간색 X 표시 + +**해결**: +1. 실패한 워크플로우 클릭 +2. 에러 로그 확인 +3. 대부분 `pnpm-lock.yaml` 문제: + ```bash + pnpm install + git add pnpm-lock.yaml + git commit -m "fix: update pnpm-lock.yaml" + git push + ``` + +--- + +### 원인 4: gh-pages 브랜치에 파일이 없음 + +**증상**: gh-pages 브랜치는 있지만 index.html이 없음 + +**해결**: +- GitHub Actions 워크플로우를 다시 실행해야 합니다 +- 빈 커밋으로 트리거: + ```bash + git commit --allow-empty -m "retry deployment" + git push + ``` + +--- + +### 원인 5: 브라우저 캐시 문제 + +**증상**: 설정은 올바른데 여전히 404 + +**해결**: +- 브라우저 캐시 지우기: + - Windows: `Ctrl + Shift + R` 또는 `Ctrl + F5` + - Mac: `Cmd + Shift + R` +- 또는 시크릿 모드로 접속 +- 다른 브라우저로 시도 + +--- + +## ✅ 최종 체크리스트 + +현재 상태를 확인하고 체크하세요: + +- [ ] **Settings → Pages**: Source가 "GitHub Actions"로 설정됨 +- [ ] **GitHub Actions**: 워크플로우가 성공적으로 실행됨 (✅ 초록색 체크마크) +- [ ] **gh-pages 브랜치**: 존재하고 `index.html` 파일이 있음 +- [ ] **로컬 빌드**: `pnpm build:advanced` 성공 +- [ ] **코드 변경사항**: 커밋 & push 완료 +- [ ] **브라우저 캐시**: 지웠거나 시크릿 모드 사용 + +--- + +## 🚀 빠른 해결 방법 (가장 확실한 순서) + +### 방법 1: GitHub Settings 확인 및 수정 + +1. `https://github.com/jumoooo/front_7th_chapter3-2` 접속 +2. Settings → Pages +3. Source를 **"GitHub Actions"**로 변경 +4. Save 클릭 + +### 방법 2: 워크플로우 재실행 + +```bash +# 로컬에서 +git commit --allow-empty -m "retry GitHub Pages deployment" +git push +``` + +### 방법 3: 로컬 빌드 확인 후 재배포 + +```bash +# 빌드 확인 +pnpm build:advanced + +# 변경사항 커밋 +git add . +git commit -m "fix: ensure proper build output" +git push +``` + +--- + +## 📞 추가 확인 사항 + +1. **저장소가 Public인가요?** + - Private 저장소는 GitHub Pro가 필요할 수 있습니다 + +2. **저장소 이름이 정확한가요?** + - `front_7th_chapter3-2` (정확히 이 이름이어야 함) + - 다르다면 `vite.config.ts`의 base path도 함께 수정 필요 + +3. **GitHub Actions가 활성화되어 있나요?** + - Settings → Actions → General + - "Allow all actions and reusable workflows" 선택 + +--- + +## 💡 도움이 필요한 경우 + +위의 모든 단계를 시도했는데도 문제가 해결되지 않으면: + +1. GitHub Actions 로그의 전체 에러 메시지를 확인 +2. gh-pages 브랜치의 실제 파일 구조 확인 +3. 브라우저 개발자 도구(F12) → Network 탭에서 실패한 요청 확인 + +--- + +**중요**: 가장 흔한 원인은 **GitHub Settings → Pages에서 Source가 "GitHub Actions"로 설정되지 않은 것**입니다! + diff --git "a/404_\354\246\211\354\213\234_\355\231\225\354\235\270_\354\202\254\355\225\255.md" "b/404_\354\246\211\354\213\234_\355\231\225\354\235\270_\354\202\254\355\225\255.md" new file mode 100644 index 000000000..27f0e5238 --- /dev/null +++ "b/404_\354\246\211\354\213\234_\355\231\225\354\235\270_\354\202\254\355\225\255.md" @@ -0,0 +1,181 @@ +# 🔴 404 에러 - 즉시 확인해야 할 사항 + +## ⚠️ 현재 상황 +- GitHub Settings → Pages 설정 확인 완료 +- GitHub Actions 워크플로우 실행 확인 완료 +- gh-pages 브랜치 확인 완료 +- **하지만 여전히 404 에러 발생** + +--- + +## 🎯 가장 먼저 확인해야 할 것 + +### 1. gh-pages 브랜치에 실제로 index.html 파일이 있는가? + +**확인 방법**: +1. `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages` 접속 +2. 파일 목록 확인 +3. **`index.html` 파일이 루트에 있는지 확인** + +**없다면**: +- GitHub Actions 워크플로우가 실패했을 가능성 +- 로그 확인 필요 + +--- + +### 2. index.html 파일 내용 확인 + +**확인 방법**: +1. `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages`에서 `index.html` 클릭 +2. 파일 내용 확인 + +**올바른 내용**: +```html + +``` + +**확인 사항**: +- ✅ JavaScript 파일 경로가 `/front_7th_chapter3-2/assets/...`로 시작하는가? +- ✅ 파일이 비어있지 않은가? +- ✅ HTML 구조가 올바른가? + +--- + +### 3. assets 폴더 확인 + +**확인 방법**: +1. `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages/assets` 접속 +2. JavaScript 파일이 있는지 확인 + +**확인 사항**: +- ✅ `assets` 폴더가 있는가? +- ✅ JavaScript 파일 (`index-*.js`)이 있는가? +- ✅ 파일 이름이 올바른가? + +--- + +### 4. GitHub Actions 로그 확인 + +**확인 방법**: +1. `https://github.com/jumoooo/front_7th_chapter3-2/actions` 접속 +2. 가장 최근 "Deploy to GitHub Pages" 워크플로우 클릭 +3. "Deploy to GitHub Pages" 단계 클릭 + +**확인 사항**: +- ✅ 배포가 실제로 성공했는가? +- ✅ 에러 메시지가 있는가? +- ✅ 어떤 파일들이 배포되었는가? + +--- + +## 🔍 추가 확인 사항 + +### 5. GitHub Pages 설정 재확인 + +**확인 방법**: +1. `https://github.com/jumoooo/front_7th_chapter3-2/settings/pages` 접속 +2. Source 설정 확인 + +**올바른 설정**: +- Source: **"GitHub Actions"** ✅ + +**잘못된 설정**: +- Source: "Deploy from a branch" ❌ +- Source: "None" ❌ + +--- + +### 6. 브라우저 개발자 도구 확인 + +**확인 방법**: +1. `https://jumoooo.github.io/front_7th_chapter3-2/` 접속 +2. F12 키로 개발자 도구 열기 +3. Network 탭 확인 + +**확인 사항**: +- 어떤 파일이 로드되지 않는가? +- 404 에러가 나는 파일 경로는 무엇인가? +- Console 탭에 에러 메시지가 있는가? + +--- + +## 💡 가능한 원인별 해결책 + +### 원인 1: gh-pages 브랜치에 index.html이 없음 + +**해결**: +1. GitHub Actions 워크플로우 로그 확인 +2. 에러 수정 +3. 워크플로우 재실행 + +--- + +### 원인 2: 파일 경로 문제 + +**증상**: index.html은 있지만 JavaScript 파일을 찾지 못함 + +**해결**: +- `vite.config.ts`의 base path 확인 +- 빌드된 index.html의 경로 확인 + +--- + +### 원인 3: GitHub Pages Source 설정 문제 + +**증상**: 파일은 있지만 GitHub Pages가 파일을 찾지 못함 + +**해결**: +- Source를 "GitHub Actions"로 설정 +- 설정 저장 후 몇 분 대기 + +--- + +## 🚀 빠른 해결 방법 + +### 방법 1: 워크플로우 재실행 + +```bash +git commit --allow-empty -m "retry deployment" +git push +``` + +그 후: +1. GitHub Actions에서 워크플로우 실행 확인 +2. 완료 후 2-3분 대기 +3. 사이트 재접속 + +--- + +### 방법 2: GitHub Pages 설정 재설정 + +1. Settings → Pages 접속 +2. Source를 "None"으로 변경 → Save +3. 잠시 대기 (10초) +4. Source를 "GitHub Actions"로 다시 변경 → Save +5. 2-3분 대기 후 사이트 접속 + +--- + +### 방법 3: 브라우저 캐시 완전 삭제 + +1. 개발자 도구 열기 (F12) +2. Application 탭 (Chrome) 또는 Storage 탭 (Firefox) +3. Clear storage 또는 Clear site data 클릭 +4. 사이트 재접속 + +--- + +## 📋 즉시 확인 체크리스트 + +- [ ] gh-pages 브랜치에 `index.html` 파일이 루트에 있는가? +- [ ] `index.html` 파일 내용이 올바른가? +- [ ] `assets` 폴더가 있는가? +- [ ] JavaScript 파일이 있는가? +- [ ] GitHub Actions 워크플로우가 성공했는가? +- [ ] GitHub Pages Source가 "GitHub Actions"인가? +- [ ] 브라우저 캐시를 지웠는가? + +--- + +**가장 먼저**: gh-pages 브랜치에 실제로 `index.html` 파일이 있는지 확인하세요! + diff --git "a/404_\354\247\204\353\213\250_\353\213\250\352\263\204\353\263\204_\352\260\200\354\235\264\353\223\234.md" "b/404_\354\247\204\353\213\250_\353\213\250\352\263\204\353\263\204_\352\260\200\354\235\264\353\223\234.md" new file mode 100644 index 000000000..df3a420b3 --- /dev/null +++ "b/404_\354\247\204\353\213\250_\353\213\250\352\263\204\353\263\204_\352\260\200\354\235\264\353\223\234.md" @@ -0,0 +1,257 @@ +# 🔴 404 에러 진단 - 단계별 가이드 + +## 📋 현재 상황 +- ✅ GitHub Settings → Pages: Source가 "GitHub Actions"로 설정됨 +- ✅ GitHub Actions 워크플로우 실행됨 +- ✅ gh-pages 브랜치 생성됨 +- ❌ 하지만 여전히 404 에러 발생 + +--- + +## 🔍 단계별 진단 방법 + +### ✅ 1단계: gh-pages 브랜치 실제 내용 확인 (가장 중요!) + +#### 1.1 gh-pages 브랜치 접속 +- URL: `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages` + +#### 1.2 파일 목록 확인 +다음 파일들이 **반드시** 있어야 합니다: + +**필수 파일들:** +- ✅ `index.html` (루트에 있어야 함) +- ✅ `assets/` 폴더 +- ✅ `assets/index-*.js` 파일 + +#### 1.3 index.html 파일 확인 +1. `index.html` 파일을 클릭해서 내용 확인 +2. 다음 내용이 포함되어 있어야 함: + ```html + + ``` +3. 파일이 비어있거나 내용이 이상한지 확인 + +#### 1.4 assets 폴더 확인 +1. `assets` 폴더 클릭 +2. JavaScript 파일이 있는지 확인 +3. 파일 이름이 올바른지 확인 + +--- + +### ✅ 2단계: GitHub Actions 워크플로우 로그 확인 + +#### 2.1 워크플로우 실행 기록 확인 +- URL: `https://github.com/jumoooo/front_7th_chapter3-2/actions` +- 가장 최근 "Deploy to GitHub Pages" 워크플로우 클릭 + +#### 2.2 각 단계 확인 +다음 단계들이 모두 성공했는지 확인: + +1. **Checkout repository** ✅ +2. **Setup pnpm** ✅ +3. **Setup Node.js** ✅ +4. **Install dependencies** ✅ +5. **Build advanced package** ✅ + - 이 단계의 로그를 열어서 확인 + - 빌드가 실제로 성공했는지 확인 + - `dist/index.html` 파일이 생성되었다는 메시지가 있는지 확인 + +6. **Deploy to GitHub Pages** ✅ + - 이 단계의 로그를 열어서 확인 + - 배포가 실제로 성공했는지 확인 + - 에러 메시지가 있는지 확인 + +#### 2.3 에러 로그 확인 +- 빨간색으로 표시된 단계가 있다면 클릭 +- 에러 메시지 전체 확인 +- 특히 파일 경로 관련 에러가 있는지 확인 + +--- + +### ✅ 3단계: 빌드 결과물 검증 + +#### 3.1 로컬 빌드 테스트 +로컬에서 빌드를 실행하여 결과 확인: + +```bash +pnpm build:advanced +``` + +#### 3.2 빌드 결과 확인 +```bash +ls -la dist/ +``` + +**확인 사항:** +- ✅ `dist/index.html` 파일이 있어야 함 +- ✅ `dist/assets/` 폴더가 있어야 함 +- ✅ `dist/assets/index-*.js` 파일이 있어야 함 + +#### 3.3 index.html 내용 확인 +```bash +cat dist/index.html +``` + +**확인 사항:** +- ✅ JavaScript 파일 경로가 `/front_7th_chapter3-2/assets/...`로 시작해야 함 +- ✅ base path가 올바르게 적용되어 있어야 함 + +--- + +### ✅ 4단계: 실제 gh-pages 브랜치와 로컬 빌드 비교 + +#### 4.1 로컬 빌드 결과 +```bash +ls -la dist/ +``` + +#### 4.2 gh-pages 브랜치 내용 +- GitHub에서 gh-pages 브랜치 파일 목록 확인 +- 두 개가 일치하는지 비교 + +**비교 항목:** +- 파일 개수 +- 파일 이름 +- 폴더 구조 + +--- + +## 🔧 가능한 문제와 해결책 + +### 문제 1: gh-pages 브랜치에 index.html이 없음 + +**증상**: `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages`에 index.html이 없음 + +**원인**: +- GitHub Actions 워크플로우가 실패했거나 +- 배포 단계에서 에러 발생 + +**해결책**: +1. GitHub Actions 로그 확인 +2. 에러 수정 후 재배포 +3. 빈 커밋으로 워크플로우 재실행: + ```bash + git commit --allow-empty -m "retry deployment" + git push + ``` + +--- + +### 문제 2: gh-pages 브랜치에 파일은 있지만 경로가 잘못됨 + +**증상**: index.html이 하위 폴더에 있거나, 경로가 잘못됨 + +**원인**: +- `publish_dir` 설정이 잘못되었거나 +- 빌드 결과물의 위치가 잘못됨 + +**해결책**: +- `.github/workflows/deploy.yml`의 `publish_dir: ./dist` 확인 +- 빌드 후 `dist` 폴더가 올바른 위치에 생성되는지 확인 + +--- + +### 문제 3: 빌드가 성공했지만 파일이 배포되지 않음 + +**증상**: +- GitHub Actions에서 빌드는 성공 +- 하지만 gh-pages 브랜치에 파일이 없음 + +**원인**: +- Deploy 단계에서 권한 문제 +- GitHub Actions 권한 설정 문제 + +**해결책**: +1. Settings → Actions → General 확인 +2. "Read and write permissions" 선택 확인 +3. 워크플로우 재실행 + +--- + +### 문제 4: index.html은 있지만 JavaScript 파일이 로드되지 않음 + +**증상**: +- index.html은 보이지만 빈 화면 +- 콘솔에서 JavaScript 파일 로드 실패 + +**원인**: +- Base path 설정 문제 +- 파일 경로가 올바르지 않음 + +**해결책**: +- `vite.config.ts`의 base path 확인 +- 빌드된 index.html의 경로 확인 + +--- + +## 🎯 즉시 확인해야 할 사항 + +### 1. gh-pages 브랜치 실제 파일 확인 +``` +https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages +``` + +**확인 사항:** +- [ ] index.html 파일이 루트에 있는가? +- [ ] assets 폴더가 있는가? +- [ ] JavaScript 파일이 있는가? +- [ ] 파일 내용이 올바른가? + +### 2. GitHub Actions 최근 실행 로그 확인 +``` +https://github.com/jumoooo/front_7th_chapter3-2/actions +``` + +**확인 사항:** +- [ ] 모든 단계가 성공했는가? (✅ 초록색) +- [ ] 빌드 단계에서 에러가 없는가? +- [ ] 배포 단계에서 에러가 없는가? +- [ ] 로그에 "Deploying..." 또는 "Deployed successfully" 메시지가 있는가? + +### 3. 로컬 빌드와 비교 +로컬에서 빌드한 결과와 gh-pages 브랜치의 내용을 비교 + +--- + +## 💡 디버깅 팁 + +### 팁 1: GitHub Actions 로그에서 파일 목록 확인 +워크플로우 로그의 "Deploy to GitHub Pages" 단계에서: +- 실제로 어떤 파일들이 배포되었는지 확인 +- 파일 경로가 올바른지 확인 + +### 팁 2: gh-pages 브랜치 직접 확인 +- 브랜치를 체크아웃해서 로컬에서 확인: + ```bash + git fetch origin gh-pages:gh-pages + git checkout gh-pages + ls -la + ``` + +### 팁 3: 브라우저 개발자 도구 확인 +- F12 → Network 탭 +- 어떤 파일이 로드되지 않는지 확인 +- 404 에러가 나는 파일 경로 확인 + +--- + +## 📞 추가 확인이 필요한 경우 + +위의 모든 단계를 확인했는데도 문제가 해결되지 않으면: + +1. **GitHub Actions 로그 전체 공유** + - 실패한 단계의 전체 로그 + - 특히 에러 메시지 + +2. **gh-pages 브랜치 스크린샷** + - 브랜치의 파일 목록 + - index.html 파일 내용 + +3. **브라우저 개발자 도구 정보** + - Network 탭의 실패한 요청 + - Console의 에러 메시지 + +--- + +**가장 먼저 확인할 것**: gh-pages 브랜치에 실제로 `index.html` 파일이 있는지 확인하세요! + diff --git "a/GITHUB_ACTIONS_\352\260\200\354\235\264\353\223\234.md" "b/GITHUB_ACTIONS_\352\260\200\354\235\264\353\223\234.md" new file mode 100644 index 000000000..9009822ee --- /dev/null +++ "b/GITHUB_ACTIONS_\352\260\200\354\235\264\353\223\234.md" @@ -0,0 +1,273 @@ +# 🔧 GitHub Actions 설정 및 확인 가이드 + +## 📋 현재 GitHub Actions 워크플로우 설정 + +### ✅ 워크플로우 파일 위치 +`.github/workflows/deploy.yml` + +### ✅ 워크플로우 내용 +```yaml +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: write + pages: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - name: Build advanced package + run: pnpm build:advanced + env: + NODE_ENV: production + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist +``` + +--- + +## 🔍 GitHub Actions 확인 방법 + +### 1단계: Actions 탭으로 이동 + +1. **GitHub 저장소로 이동** + - URL: `https://github.com/jumoooo/front_7th_chapter3-2` + +2. **Actions 탭 클릭** + - 저장소 상단 메뉴에서 `Actions` 클릭 + - 또는: `https://github.com/jumoooo/front_7th_chapter3-2/actions` + +### 2단계: 워크플로우 확인 + +1. **워크플로우 목록 확인** + - 왼쪽 사이드바에서 워크플로우 목록 확인 + - `Deploy to GitHub Pages` 워크플로우가 보여야 합니다 + +2. **실행 기록 확인** + - 중앙 영역에 워크플로우 실행 기록이 표시됩니다 + - 각 실행의 상태를 확인할 수 있습니다: + - ✅ **초록색 체크마크**: 성공 + - 🟡 **노란색 원**: 진행 중 + - ❌ **빨간색 X**: 실패 + +### 3단계: 워크플로우 실행 상세 확인 + +1. **실행 기록 클릭** + - 특정 실행 기록을 클릭하면 상세 내용을 볼 수 있습니다 + +2. **각 단계 확인** + - `Checkout repository` - 코드 체크아웃 + - `Setup pnpm` - pnpm 설정 + - `Setup Node.js` - Node.js 설정 + - `Install dependencies` - 패키지 설치 + - `Build advanced package` - 빌드 실행 + - `Deploy to GitHub Pages` - 배포 실행 + +3. **에러가 있다면** + - 실패한 단계를 클릭하면 로그를 볼 수 있습니다 + - 에러 메시지를 확인하여 문제를 파악할 수 있습니다 + +--- + +## ⚠️ GitHub Actions 관련 주요 설정 + +### 1. GitHub Pages Source 설정 (가장 중요!) + +**설정 위치**: GitHub 저장소 → Settings → Pages + +**필요한 설정**: +``` +Source: GitHub Actions ✅ +``` + +> ⚠️ **중요**: "Deploy from a branch"가 아니라 **"GitHub Actions"**를 선택해야 합니다! + +**설정 방법**: +1. 저장소로 이동: `https://github.com/jumoooo/front_7th_chapter3-2` +2. Settings → Pages 메뉴로 이동 +3. Source를 **"GitHub Actions"**로 선택 +4. Save 클릭 + +### 2. GitHub Actions 활성화 확인 + +**설정 위치**: GitHub 저장소 → Settings → Actions → General + +**확인 사항**: +- Actions가 활성화되어 있는지 확인 +- "Allow all actions and reusable workflows" 선택 권장 + +--- + +## 🚀 GitHub Actions 워크플로우 실행 방법 + +### 자동 실행 +- `main` 브랜치에 push하면 자동으로 실행됩니다 + +### 수동 실행 (필요한 경우) + +#### 방법 1: 빈 커밋으로 트리거 +```bash +git commit --allow-empty -m "trigger deployment" +git push +``` + +#### 방법 2: 코드 변경 후 push +```bash +git add . +git commit -m "update code" +git push +``` + +--- + +## 🔍 문제 해결 + +### 문제 1: 워크플로우가 실행되지 않아요 + +**원인**: +- `main` 브랜치에 push하지 않았을 수 있음 +- 다른 브랜치에 push했을 수 있음 + +**해결**: +```bash +# 현재 브랜치 확인 +git branch + +# main 브랜치로 전환 (필요한 경우) +git checkout main + +# push +git push +``` + +--- + +### 문제 2: 워크플로우가 실패해요 + +**확인 사항**: +1. Actions 탭에서 실패한 워크플로우 클릭 +2. 실패한 단계 확인 +3. 에러 로그 확인 + +**가장 흔한 에러들**: + +#### 에러 1: `ERR_PNPM_OUTDATED_LOCKFILE` +**원인**: `pnpm-lock.yaml` 파일이 최신 상태가 아님 + +**해결**: +```bash +# 로컬에서 +pnpm install + +# 변경사항 커밋 +git add pnpm-lock.yaml +git commit -m "fix: update pnpm-lock.yaml" +git push +``` + +#### 에러 2: 빌드 실패 +**원인**: TypeScript 오류, 빌드 오류 등 + +**해결**: +```bash +# 로컬에서 빌드 테스트 +pnpm build:advanced + +# 오류가 있다면 수정 후 +git add . +git commit -m "fix: resolve build errors" +git push +``` + +#### 에러 3: 권한 오류 +**원인**: GitHub Actions 권한 부족 + +**해결**: +- Settings → Actions → General +- "Read and write permissions" 선택 +- "Allow GitHub Actions to create and approve pull requests" 선택 + +--- + +### 문제 3: 워크플로우는 성공했는데 404 에러가 나와요 + +**확인 사항**: +1. **GitHub Pages Source 설정 확인** + - Settings → Pages → Source가 "GitHub Actions"인지 확인 + +2. **gh-pages 브랜치 확인** + - 브랜치 목록: `https://github.com/jumoooo/front_7th_chapter3-2/branches` + - `gh-pages` 브랜치가 있는지 확인 + - 브랜치 내용: `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages` + - `index.html` 파일이 있는지 확인 + +3. **배포 완료 대기** + - 워크플로우 성공 후 1-2분 정도 대기 + - GitHub Pages 배포에는 시간이 걸릴 수 있습니다 + +--- + +## 📊 워크플로우 실행 상태 확인 + +### 성공 상태 ✅ +- 초록색 체크마크 표시 +- 모든 단계가 성공적으로 완료 +- `gh-pages` 브랜치에 파일이 업로드됨 + +### 실패 상태 ❌ +- 빨간색 X 표시 +- 실패한 단계 클릭하여 로그 확인 +- 에러 메시지 확인 후 문제 해결 + +### 진행 중 상태 🟡 +- 노란색 원 표시 +- 현재 실행 중 +- 완료될 때까지 대기 + +--- + +## 🎯 체크리스트 + +- [ ] GitHub Actions 워크플로우 파일이 올바르게 설정됨 (`.github/workflows/deploy.yml`) +- [ ] GitHub Settings → Pages에서 Source가 "GitHub Actions"로 설정됨 +- [ ] GitHub Actions가 활성화되어 있음 +- [ ] `main` 브랜치에 push했음 +- [ ] 워크플로우가 성공적으로 실행됨 (✅ 초록색 체크마크) +- [ ] `gh-pages` 브랜치에 `index.html` 파일이 있음 + +--- + +## 💡 참고사항 + +- GitHub Actions 워크플로우는 `main` 브랜치에 push할 때 자동으로 실행됩니다 +- 워크플로우 실행에는 보통 2-5분 정도 걸립니다 +- 배포 완료 후 GitHub Pages에 반영되는데 몇 분 더 걸릴 수 있습니다 +- 워크플로우가 성공했다면 `gh-pages` 브랜치가 자동으로 생성되고 파일이 업로드됩니다 + diff --git a/GITHUB_PAGES_SETUP.md b/GITHUB_PAGES_SETUP.md new file mode 100644 index 000000000..8b4ffbe1c --- /dev/null +++ b/GITHUB_PAGES_SETUP.md @@ -0,0 +1,197 @@ +# 🚀 GitHub Pages 배포 설정 완료 + +## ✅ 완료된 작업 + +1. **vite.config.ts 설정** + + - GitHub Pages를 위한 base path 설정: `/front_7th_chapter3-2/` + - 빌드 시 `index.advanced.html`을 사용하도록 설정 + - **vite 플러그인으로 빌드 후 자동으로 `index.html` 생성** (GitHub Pages용) + +2. **package.json 스크립트 추가** + + - `build:advanced`: advanced 버전을 빌드하는 스크립트 추가 + +3. **GitHub Actions 워크플로우 생성** + - `.github/workflows/deploy.yml` 파일 생성 + - main 브랜치에 push 시 자동 배포 + +## 🔧 외부적으로 해야 할 작업 + +### 0단계: pnpm-lock.yaml 업데이트 후 커밋 + +⚠️ **중요**: `@types/node` 패키지를 추가했으므로, 로컬에서 다음 명령어를 실행한 후 커밋해야 합니다: + +```bash +pnpm install +git add pnpm-lock.yaml +git commit -m "chore: update pnpm-lock.yaml after adding @types/node" +git push +``` + +이렇게 하지 않으면 GitHub Actions에서 `ERR_PNPM_OUTDATED_LOCKFILE` 오류가 발생합니다. + +### 1단계: GitHub 저장소 Settings 확인 + +1. **GitHub 저장소로 이동** + + - 저장소 URL: `https://github.com/사용자명/front_7th_chapter3-2` + - (저장소 이름이 다를 경우, vite.config.ts의 base path도 함께 수정 필요) + +2. **Settings → Pages 메뉴로 이동** + + - 저장소 상단 메뉴에서 `Settings` 클릭 + - 왼쪽 사이드바에서 `Pages` 선택 + +3. **Source 설정 변경** + + - **옵션 1: GitHub Actions (권장) ✅** + + - Source: `GitHub Actions` 선택 + - 자동으로 워크플로우가 배포를 처리합니다 + - **참고**: `gh-pages` 브랜치는 아직 없어도 괜찮습니다! GitHub Actions가 첫 배포 시 자동으로 생성합니다. + + - **옵션 2: gh-pages 브랜치** + - Source: `Deploy from a branch` 선택 + - Branch: `gh-pages` 선택 (첫 배포 후에만 나타남) + - Folder: `/ (root)` 선택 + - Save 클릭 + - ⚠️ 이 방법은 첫 배포 후에만 사용할 수 있습니다 + +### 2단계: GitHub Actions 실행 확인 + +1. **Actions 탭으로 이동** + + - 저장소 상단 메뉴에서 `Actions` 클릭 + - `https://github.com/사용자명/front_7th_chapter3-2/actions` + +2. **"Deploy to GitHub Pages" 워크플로우 확인** + - 워크플로우 목록에서 `Deploy to GitHub Pages` 확인 + - main 브랜치에 push하면 자동으로 실행됩니다 + - 워크플로우가 성공적으로 완료되는지 확인 + +### 3단계: 배포 확인 + +배포가 완료되면 다음 URL에서 확인할 수 있습니다: + +``` +https://사용자명.github.io/front_7th_chapter3-2/ +``` + +**예시:** + +- `https://jumoooo.github.io/front_7th_chapter3-2/` + +## ❓ 자주 묻는 질문 (FAQ) + +### Q: gh-pages 브랜치가 없는데 괜찮나요? + +**A: 네, 완전히 정상입니다!** + +`gh-pages` 브랜치는 GitHub Actions가 첫 배포를 실행할 때 **자동으로 생성**됩니다. + +- 현재 브랜치가 없어도 문제 없습니다 +- GitHub Actions 워크플로우가 실행되면 `peaceiris/actions-gh-pages@v3` 액션이 자동으로: + 1. `gh-pages` 브랜치 생성 + 2. 빌드된 파일들(`dist` 폴더) 업로드 + 3. GitHub Pages에 배포 + +따라서 GitHub Settings → Pages에서 **"GitHub Actions"**를 Source로 선택하기만 하면 됩니다. + +## 🔍 문제 해결 + +### ❌ README.md가 나올 때 (현재 문제) + +**증상**: `https://jumoooo.github.io/front_7th_chapter3-2/`에 접속하면 README.md 내용이 표시됨 + +**원인**: GitHub Pages 설정이 잘못되어 있거나, 빌드된 파일이 배포되지 않았습니다. + +**해결 방법**: + +1. **GitHub Settings → Pages 확인** (가장 중요!) + + - 저장소로 이동: `https://github.com/jumoooo/front_7th_chapter3-2` + - Settings → Pages 메뉴로 이동 + - **Source가 "GitHub Actions"로 설정되어 있는지 확인** + - 만약 "Deploy from a branch"로 되어 있다면: + - Source를 **"GitHub Actions"**로 변경 + - Save 클릭 + +2. **GitHub Actions 워크플로우 실행 확인** + + - Actions 탭으로 이동: `https://github.com/jumoooo/front_7th_chapter3-2/actions` + - "Deploy to GitHub Pages" 워크플로우가 실행되었는지 확인 + - 실행되지 않았다면, 빈 커밋을 만들어 push: + ```bash + git commit --allow-empty -m "trigger deployment" + git push + ``` + - 워크플로우가 실패했다면 로그를 확인하고 오류 해결 + +3. **gh-pages 브랜치 확인** + + - 브랜치 목록: `https://github.com/jumoooo/front_7th_chapter3-2/branches` + - `gh-pages` 브랜치가 있는지 확인 + - 있다면 브랜치 내용 확인: `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages` + - `index.html` 파일이 있어야 합니다 + +4. **빌드 로컬 테스트** + ```bash + pnpm build:advanced + ls -la dist/ + ``` + - `dist/index.html` 파일이 생성되는지 확인 + - ✅ vite 플러그인이 자동으로 `index.advanced.html`을 `index.html`로 복사합니다 + +### 배포가 안 될 때 + +1. **GitHub Actions 실행 확인** + + - Actions 탭에서 워크플로우가 실행되었는지 확인 + - 에러가 있다면 로그 확인 + +2. **저장소 이름 확인** + + - 저장소 이름이 `front_7th_chapter3-2`와 다르다면 + - `vite.config.ts`의 base path를 실제 저장소 이름에 맞게 수정 필요 + +3. **gh-pages 브랜치 확인** + - Settings → Pages에서 gh-pages 브랜치 사용 시 + - 브랜치에 파일이 있는지 확인 + +### 404 에러가 날 때 ⚠️ + +**가장 흔한 원인**: GitHub Pages Source 설정이 잘못되었습니다! + +1. **GitHub Settings → Pages 확인** + + - Source가 **"GitHub Actions"**로 설정되어 있어야 합니다 + - ⚠️ "Deploy from a branch" + "gh-pages"로 설정되어 있다면, 이것이 문제입니다! + - Source를 **"GitHub Actions"**로 변경하고 Save 클릭 + +2. **GitHub Actions 워크플로우 확인** + + - Actions 탭에서 "Deploy to GitHub Pages" 워크플로우가 성공적으로 실행되었는지 확인 + - 실패했다면 로그 확인 + +3. **gh-pages 브랜치 확인** + + - `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages` + - `index.html` 파일이 있는지 확인 + +4. **저장소 이름 확인** + + - 저장소 이름이 정확히 `front_7th_chapter3-2`인지 확인 + - 다르다면 `vite.config.ts`의 base path도 함께 수정 필요 + +5. **브라우저 캐시 지우기** + - Ctrl + Shift + R 또는 Cmd + Shift + R + - 또는 시크릿 모드로 접속 + +자세한 내용은 `GITHUB_PAGES_TROUBLESHOOTING.md` 파일을 참고하세요! + +## 📝 참고 사항 + +- 배포 후 변경사항이 반영되는데 몇 분 정도 소요될 수 있습니다 +- 브라우저 캐시를 지우고 다시 접속해보세요 (Ctrl + Shift + R 또는 Cmd + Shift + R) +- 배포된 페이지는 `src/advanced` 폴더의 내용이 표시됩니다 diff --git a/GITHUB_PAGES_TROUBLESHOOTING.md b/GITHUB_PAGES_TROUBLESHOOTING.md new file mode 100644 index 000000000..ce165f17c --- /dev/null +++ b/GITHUB_PAGES_TROUBLESHOOTING.md @@ -0,0 +1,120 @@ +# 🔧 GitHub Pages 404 에러 해결 가이드 + +## ❌ 404 에러가 발생하는 경우 + +### 1️⃣ 가장 흔한 원인: GitHub Pages Source 설정 문제 + +**증상**: `https://jumoooo.github.io/front_7th_chapter3-2/`에 접속하면 404 에러 발생 + +**해결 방법**: + +1. **GitHub 저장소로 이동** + + - `https://github.com/jumoooo/front_7th_chapter3-2` + - Settings → Pages 메뉴로 이동 + +2. **Source 설정 확인** + + - ⚠️ **중요**: Source가 "Deploy from a branch"로 되어 있고, 브랜치가 "gh-pages"로 설정되어 있다면, 이것이 문제일 수 있습니다. + - **올바른 설정**: Source를 **"GitHub Actions"**로 변경해야 합니다! + - "Deploy from a branch"는 GitHub Actions 없이 수동으로 배포할 때만 사용합니다. + +3. **올바른 설정 방법**: + ``` + Settings → Pages + Source: GitHub Actions ✅ (선택) + Save 클릭 + ``` + +### 2️⃣ GitHub Actions 워크플로우 확인 + +**확인 방법**: + +1. **Actions 탭으로 이동** + + - `https://github.com/jumoooo/front_7th_chapter3-2/actions` + +2. **"Deploy to GitHub Pages" 워크플로우 확인** + + - 워크플로우가 실행되었는지 확인 + - 실패했다면 로그 확인 + - 성공했다면 다음 단계로 + +3. **워크플로우 재실행** (필요한 경우) + ```bash + git commit --allow-empty -m "trigger deployment" + git push + ``` + +### 3️⃣ gh-pages 브랜치 확인 + +**확인 방법**: + +1. **브랜치 목록 확인** + + - `https://github.com/jumoooo/front_7th_chapter3-2/branches` + - `gh-pages` 브랜치가 있는지 확인 + +2. **gh-pages 브랜치 내용 확인** + + - `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages` + - `index.html` 파일이 있어야 합니다 + - `assets` 폴더가 있어야 합니다 + +3. **문제가 있다면**: + - GitHub Actions 워크플로우를 다시 실행 + - 빈 커밋을 만들어 push + +### 4️⃣ 저장소 이름 확인 + +**확인 방법**: + +1. 저장소 이름이 정확히 `front_7th_chapter3-2`인지 확인 +2. 만약 다르다면, `vite.config.ts`의 base path도 함께 수정: + ```typescript + const base: string = + process.env.NODE_ENV === "production" ? "/실제저장소이름/" : ""; + ``` + +### 5️⃣ 빌드 로컬 테스트 + +**로컬에서 빌드 확인**: + +```bash +# 빌드 실행 +pnpm build:advanced + +# 빌드 결과 확인 +ls -la dist/ +# 다음 파일들이 있어야 합니다: +# - index.html ✅ +# - index.advanced.html ✅ +# - assets/ 폴더 ✅ +``` + +### 6️⃣ 브라우저 캐시 문제 + +**해결 방법**: + +- 브라우저 캐시를 완전히 지우고 다시 접속 +- Windows: `Ctrl + Shift + R` 또는 `Ctrl + F5` +- Mac: `Cmd + Shift + R` +- 또는 시크릿 모드로 접속 + +## 🔍 단계별 점검 체크리스트 + +- [ ] GitHub Settings → Pages에서 Source가 "GitHub Actions"로 설정되어 있음 +- [ ] GitHub Actions 워크플로우가 성공적으로 실행되었음 +- [ ] gh-pages 브랜치가 존재하고, index.html 파일이 있음 +- [ ] 저장소 이름이 `front_7th_chapter3-2`와 일치함 +- [ ] 로컬 빌드 테스트가 성공함 +- [ ] 브라우저 캐시를 지우고 다시 시도함 + +## 📞 추가 도움이 필요한 경우 + +위의 모든 단계를 시도했는데도 문제가 해결되지 않으면: + +1. GitHub Actions 로그를 자세히 확인 +2. gh-pages 브랜치의 실제 파일 구조 확인 +3. 브라우저 개발자 도구(F12)에서 네트워크 탭 확인 +4. 콘솔 에러 메시지 확인 diff --git a/README.md b/README.md index e38f1e44b..5fc81482a 100644 --- a/README.md +++ b/README.md @@ -109,11 +109,52 @@ React의 주요 책임 계층은 Component, hook, function 등이 있습니다. - Context나 Jotai 혹은 Zustand를 사용하여 상태를 관리합니다. - 테스트 코드를 통과합니다. -### (2) 힌트 - -- UI 컴포넌트와 엔티티 컴포넌트는 각각 props를 다르게 받는게 좋습니다. - - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, - - 엔티티 컴포넌트는 가급적 엔티티를 중심으로 전달받는 것이 좋습니다. -- 특히 콜백의 경우, - - UI 컴포넌트는 이벤트 핸들러를 props로 받아서 처리하도록 해서 재사용성을 높이지만, - - 엔티티 컴포넌트는 props가 아닌 컴포넌트 내부에서 상태를 관리하는 것이 좋습니다. \ No newline at end of file +### 기본과제 + +- Component에서 비즈니스 로직을 분리하기 +- 비즈니스 로직에서 특정 엔티티만 다루는 계산을 분리하기 +- 뷰데이터와 엔티티데이터의 분리에 대한 이해 +- entities -> features -> UI 계층에 대한 이해 + +- [x] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? +- [x] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? +- [x] 계산함수는 순수함수로 작성이 되었나요? +- [x] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? +- [x] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? +- [x] 계산함수는 순수함수로 작성이 되었나요? +- [x] 특정 Entitiy만 다루는 함수는 분리되어 있나요? +- [x] 특정 Entitiy만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요? +- [x] 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요? + +### 심화과제 + +- 이번 심화과제는 Context나 Jotai를 사용해서 Props drilling을 없애는 것입니다. +- 어떤 props는 남겨야 하는지, 어떤 props는 제거해야 하는지에 대한 기준을 세워보세요. +- Context나 Jotai를 사용하여 상태를 관리하는 방법을 익히고, 이를 통해 컴포넌트 간의 데이터 전달을 효율적으로 처리할 수 있습니다. + +- [x] Context나 Jotai를 사용해서 전역상태관리를 구축했나요? +- [x] 전역상태관리를 통해 domain custom hook을 적절하게 리팩토링 했나요? +- [x] 도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거했나요? +- [x] 전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드가 되었나요? + +## 과제 셀프회고 + +### 과제를 하면서 내가 알게된 점, 좋았던 점은 무엇인가요? + +이번 과제를 진행하면서 “순수 함수 중심의 구조”가 테스트 가능성과 유지보수성에 얼마나 큰 영향을 주는지 체감했습니다. 입력과 출력이 명확한 형태로 구성하니 로직 검증이 쉬워졌고, 부수효과가 필요한 부분은 뒤쪽에서 한 번에 처리하는 흐름으로 정리되면서 코드가 한결 안정적으로 보였습니다. +컴포넌트끼리 props drilling이 많아지기 쉬운 구조였는데, 특정 도메인 단위로 props를 묶어 전달하는 방식으로 정리하다 보니 컴포넌트 응집도가 높아지고 사용성도 좋아졌습니다. 특히 Selector 컴포넌트는 구조가 깔끔하게 잡혀서 만족스럽게 완성할 수 있었습니다. + +또한 장바구니 로직을 구현하면서 useMemo를 적절히 사용해 사전에 계산된 데이터를 넘겨주는 방식(예: filledItems)이 렌더링 비용을 확실히 줄여준다는 것도 배웠습니다. 특정 엔티티에 강하게 묶인 로직을 유틸 함수나 커스텀 훅으로 분리하는 기준도 자연스럽게 익힐 수 있었고, 클린 코드의 핵심이 “언제든 깔끔하게 지울 수 있을 정도로 단순하고 응집도 높은 구조”라는 점을 다시 확인할 수 있었습니다. + +### 이번 과제에서 내가 제일 신경 쓴 부분은 무엇인가요? + +가능한 한 순수 함수 형태로 로직을 구성하고, 컴포넌트가 너무 많은 책임을 지지 않도록 계층을 나누는 데 가장 많은 신경을 썼습니다. +특히 ProductList → ProductItem에서 발생하는 props drilling 문제를 줄이기 위해 구조를 고민했고, 기존 Header처럼 지나치게 분리하기보다 필요한 정보를 각 도메인 단위로 묶어서 전달하는 방식으로 정리해 불필요한 의존을 줄였습니다. +또한 각 기능에서 전달되는 시그니처 함수들이 최소한의 역할만 가지도록 구조를 단순화했고, 전역 상태는 Zustand를 이용해 관리하며 불필요한 상태 전달을 최소화했습니다. 다만 Zustand를 도입하면서 아직 충분히 숙련되지 못해 상태 설계나 분리 기준을 더 깔끔하게 잡지 못한 아쉬움도 있었습니다. + +상태 변경은 가능한 뒤쪽에서 한 번에 처리하도록 흐름을 잡았고, 재계산 비용이 많은 장바구니 영역은 useMemo로 선계산한 데이터를 내려 렌더링 성능을 신경 썼습니다. 전역적으로 사용될 가능성이 있는 가격 표시 함수(getDisplayPrice)도 분리해야겠다는 필요성을 느꼈고, 앞으로 개선할 예정입니다. + +### 이번 과제를 통해 앞으로 해보고 싶은게 있다면 알려주세요! + +도메인별로 로직을 더 정교하게 분리하고, 공통으로 사용할 수 있는 유틸 함수 모음이나 테이블 컴포넌트 같은 공통 모듈을 직접 설계해 보고 싶습니다. 또 전역 상태 관리자로 Zustand를 도입해보면서 전반적인 흐름을 이해하게 되었기 때문에, 앞으로는 더 복잡한 상태 구조도 설계해보고 싶습니다. +추후에는 가격 계산, 할인 처리와 같은 반복되는 로직을 전역 레벨에서 재사용할 수 있도록 정리해 안정적인 구조를 계속 넓혀가보고 싶습니다. diff --git a/SETUP_CHECKLIST.md b/SETUP_CHECKLIST.md new file mode 100644 index 000000000..14703d091 --- /dev/null +++ b/SETUP_CHECKLIST.md @@ -0,0 +1,135 @@ +# ✅ GitHub Pages 배포 설정 체크리스트 + +## 🎯 목표 +`https://jumoooo.github.io/front_7th_chapter3-2/` 에서 `src/advanced` 화면이 표시되도록 설정 + +--- + +## 📋 외부적으로 해야 할 작업 (순서대로) + +### ✅ 1단계: 코드 변경사항 커밋 & Push + +```bash +# 변경된 파일 확인 +git status + +# 모든 변경사항 추가 +git add . + +# 커밋 +git commit -m "chore: front_7th_chapter3-1 패턴으로 GitHub Pages 배포 설정" + +# GitHub에 push +git push +``` + +--- + +### ✅ 2단계: GitHub 저장소 Settings → Pages 설정 (가장 중요!) + +1. **GitHub 저장소로 이동** + - URL: `https://github.com/jumoooo/front_7th_chapter3-2` + +2. **Settings 탭 클릭** + - 저장소 상단 메뉴에서 `Settings` 클릭 + +3. **Pages 메뉴 선택** + - 왼쪽 사이드바에서 `Pages` 클릭 + +4. **Source 설정 변경** ⚠️ **가장 중요!** + - **Source** 드롭다운에서 **"GitHub Actions"** 선택 + - `Save` 버튼 클릭 + + > 💡 참고: "Deploy from a branch"가 아니라 **"GitHub Actions"**를 선택해야 합니다! + +--- + +### ✅ 3단계: GitHub Actions 워크플로우 확인 + +1. **Actions 탭으로 이동** + - 저장소 상단 메뉴에서 `Actions` 클릭 + - 또는: `https://github.com/jumoooo/front_7th_chapter3-2/actions` + +2. **"Deploy to GitHub Pages" 워크플로우 확인** + - 왼쪽 사이드바에서 워크플로우 목록 확인 + - `Deploy to GitHub Pages` 워크플로우가 보여야 합니다 + +3. **워크플로우 실행 확인** + - push 후 자동으로 실행됩니다 + - 워크플로우가 실행 중이거나 완료된 것을 확인 + - ✅ 초록색 체크마크가 보이면 성공! + +4. **실패했다면** + - 빨간색 X 표시가 보이면 로그 확인 + - 에러 메시지 확인 후 해결 + +--- + +### ✅ 4단계: 배포 완료 확인 + +1. **배포 완료 대기** + - 워크플로우 실행 후 1-2분 정도 대기 + +2. **배포된 페이지 접속** + - URL: `https://jumoooo.github.io/front_7th_chapter3-2/` + - `src/advanced` 화면이 표시되어야 합니다! + +3. **404 에러가 나온다면** + - 브라우저 캐시 지우기: `Ctrl + Shift + R` (Windows) 또는 `Cmd + Shift + R` (Mac) + - 또는 시크릿 모드로 접속 + - 2-3분 더 기다려보기 + +--- + +## ❓ 문제 해결 + +### 문제 1: Settings → Pages에 "GitHub Actions" 옵션이 보이지 않아요 +- **해결**: 저장소가 Private이면 GitHub Pages가 제한될 수 있습니다. Public으로 변경하거나 GitHub Pro가 필요할 수 있습니다. + +### 문제 2: GitHub Actions 워크플로우가 실행되지 않아요 +- **해결**: + ```bash + # 빈 커밋으로 트리거 + git commit --allow-empty -m "trigger deployment" + git push + ``` + +### 문제 3: 워크플로우가 실패해요 +- **해결**: + - Actions 탭에서 실패한 워크플로우 클릭 + - 에러 로그 확인 + - 대부분의 경우 `pnpm-lock.yaml` 문제일 수 있습니다 + - 로컬에서 `pnpm install` 실행 후 다시 커밋 + +### 문제 4: 여전히 404 에러가 나와요 +- **체크리스트**: + - [ ] Settings → Pages에서 Source가 "GitHub Actions"로 설정되었나요? + - [ ] GitHub Actions 워크플로우가 성공적으로 완료되었나요? + - [ ] gh-pages 브랜치가 생성되었나요? (`https://github.com/jumoooo/front_7th_chapter3-2/branches`) + - [ ] gh-pages 브랜치에 `index.html` 파일이 있나요? + - [ ] 브라우저 캐시를 지웠나요? + +--- + +## 📝 완료 체크리스트 + +- [ ] 코드 변경사항 커밋 & push 완료 +- [ ] GitHub Settings → Pages에서 Source를 "GitHub Actions"로 설정 +- [ ] GitHub Actions 워크플로우가 성공적으로 실행됨 +- [ ] `https://jumoooo.github.io/front_7th_chapter3-2/` 에서 정상 접속 확인 +- [ ] `src/advanced` 화면이 정상적으로 표시됨 + +--- + +## 🎉 완료! + +모든 체크리스트를 완료했다면 배포가 성공한 것입니다! + +배포된 페이지: `https://jumoooo.github.io/front_7th_chapter3-2/` + +--- + +**참고 문서:** +- `GITHUB_PAGES_SETUP.md` - 상세 설정 가이드 +- `GITHUB_PAGES_TROUBLESHOOTING.md` - 문제 해결 가이드 + diff --git a/index.html b/index.html new file mode 100644 index 000000000..fcc6ca5d8 --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + 장바구니로 학습하는 디자인패턴 + + + +
+ + + + diff --git a/package.json b/package.json index 17b18de25..61ff16daf 100644 --- a/package.json +++ b/package.json @@ -13,16 +13,19 @@ "test:advanced": "vitest src/advanced", "test:ui": "vitest --ui", "build": "tsc -b && vite build", + "build:advanced": "tsc -b && vite build --config vite.config.ts", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "zustand": "^5.0.9" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/node": "^22.10.2", "@types/react": "^19.1.9", "@types/react-dom": "^19.1.7", "@typescript-eslint/eslint-plugin": "^8.38.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85f..df4a48c34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: react-dom: specifier: ^19.1.1 version: 19.1.1(react@19.1.1) + zustand: + specifier: ^5.0.9 + version: 5.0.9(@types/react@19.1.9)(react@19.1.1) devDependencies: '@testing-library/jest-dom': specifier: ^6.6.4 @@ -24,6 +27,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) + '@types/node': + specifier: ^22.10.2 + version: 22.19.1 '@types/react': specifier: ^19.1.9 version: 19.1.9 @@ -38,7 +44,7 @@ importers: version: 8.38.0(eslint@9.32.0)(typescript@5.9.2) '@vitejs/plugin-react-swc': specifier: ^3.11.0 - version: 3.11.0(vite@7.0.6) + version: 3.11.0(vite@7.0.6(@types/node@22.19.1)) '@vitest/ui': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) @@ -59,10 +65,10 @@ importers: version: 5.9.2 vite: specifier: ^7.0.6 - version: 7.0.6 + version: 7.0.6(@types/node@22.19.1) vitest: specifier: ^3.2.4 - version: 3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0) + version: 3.2.4(@types/node@22.19.1)(@vitest/ui@3.2.4)(jsdom@26.1.0) packages: @@ -583,6 +589,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@22.19.1': + resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} + '@types/react-dom@19.1.7': resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} peerDependencies: @@ -1382,6 +1391,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1515,6 +1527,24 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zustand@5.0.9: + resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@adobe/css-tools@4.4.0': {} @@ -1886,6 +1916,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@22.19.1': + dependencies: + undici-types: 6.21.0 + '@types/react-dom@19.1.7(@types/react@19.1.9)': dependencies: '@types/react': 19.1.9 @@ -1987,11 +2021,11 @@ snapshots: '@typescript-eslint/types': 8.38.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6)': + '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6(@types/node@22.19.1))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.27 '@swc/core': 1.13.3 - vite: 7.0.6 + vite: 7.0.6(@types/node@22.19.1) transitivePeerDependencies: - '@swc/helpers' @@ -2003,13 +2037,13 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.0.6)': + '@vitest/mocker@3.2.4(vite@7.0.6(@types/node@22.19.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.0.6 + vite: 7.0.6(@types/node@22.19.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -2040,7 +2074,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0) + vitest: 3.2.4(@types/node@22.19.1)(@vitest/ui@3.2.4)(jsdom@26.1.0) '@vitest/utils@3.2.4': dependencies: @@ -2716,17 +2750,19 @@ snapshots: typescript@5.9.2: {} + undici-types@6.21.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 - vite-node@3.2.4: + vite-node@3.2.4(@types/node@22.19.1): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.0.6 + vite: 7.0.6(@types/node@22.19.1) transitivePeerDependencies: - '@types/node' - jiti @@ -2741,7 +2777,7 @@ snapshots: - tsx - yaml - vite@7.0.6: + vite@7.0.6(@types/node@22.19.1): dependencies: esbuild: 0.25.8 fdir: 6.4.6(picomatch@4.0.3) @@ -2750,13 +2786,14 @@ snapshots: rollup: 4.46.2 tinyglobby: 0.2.14 optionalDependencies: + '@types/node': 22.19.1 fsevents: 2.3.3 - vitest@3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0): + vitest@3.2.4(@types/node@22.19.1)(@vitest/ui@3.2.4)(jsdom@26.1.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.0.6) + '@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@22.19.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -2774,10 +2811,11 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.0.6 - vite-node: 3.2.4 + vite: 7.0.6(@types/node@22.19.1) + vite-node: 3.2.4(@types/node@22.19.1) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 22.19.1 '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 26.1.0 transitivePeerDependencies: @@ -2829,3 +2867,8 @@ snapshots: xmlchars@2.2.0: {} yocto-queue@0.1.0: {} + + zustand@5.0.9(@types/react@19.1.9)(react@19.1.1): + optionalDependencies: + '@types/react': 19.1.9 + react: 19.1.1 diff --git a/public/.nojekyll b/public/.nojekyll new file mode 100644 index 000000000..e69de29bb diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1d..034ef812a 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,1124 +1,239 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -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 { useState, useEffect } from "react"; +import { + CartSidebarProps, + ProductListProps, +} from "./domain/product/productTypes"; +import { filterProductsBySearchTerm } from "./domain/product/productUtils"; +import { calculateCartTotal } from "./domain/cart/cartUtils"; +import { Notifications } from "./components/notifications/Notification"; +import { DefaultLayout } from "./components/layouts/DefaultLayout"; +import { SearchBar } from "./components/common/SearchBar"; +import { HeaderActions } from "./components/layouts/HeaderActions"; +import { StorePage } from "./pages/StorePage"; +import { AdminTabKey } from "./components/admin/common/AdminTabs"; +import { ProductListTableProps } from "./components/admin/product/ProductListTable"; +import { AdminProductsSectionProps } from "./components/admin/product/AdminProductsSection"; +import { CouponListProps } from "./components/admin/coupon/CouponList"; +import { CouponFormSection } from "./components/admin/coupon/CouponFormSection"; +import { AdminPage } from "./pages/AdminPage"; +import { useNotificationStore } from "./stores/useNotificationStore"; +import { useSearchStore } from "./stores/useSearchStore"; +import { useProductStore } from "./stores/useProductStore"; +import { useCartStore } from "./stores/useCartStore"; +import { useCouponStore } from "./stores/useCouponStore"; 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); + // UI 상태 (Entity가 아닌 상태) 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 [activeTab, setActiveTab] = useState("products"); - 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); - }); + // Store 사용 - Entity를 다루지 않는 Store + const notifications = useNotificationStore((state) => state.notifications); + const setNotifications = useNotificationStore( + (state) => state.setNotifications + ); + const searchTerm = useSearchStore((state) => state.searchTerm); + const setSearchTerm = useSearchStore((state) => state.setSearchTerm); + const debouncedSearchTerm = useSearchStore( + (state) => state.debouncedSearchTerm + ); - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } + // Store 사용 - Entity를 다루는 Store + const products = useProductStore((state) => state.products); + const productForm = useProductStore((state) => state.productForm); + const editingProduct = useProductStore((state) => state.editingProduct); + const showProductForm = useProductStore((state) => state.showProductForm); + const setProductForm = useProductStore((state) => state.setProductForm); + const setEditingProduct = useProductStore((state) => state.setEditingProduct); + const setShowProductForm = useProductStore( + (state) => state.setShowProductForm + ); + const deleteProduct = useProductStore((state) => state.deleteProduct); + const startEditProduct = useProductStore((state) => state.startEditProduct); + const handleProductSubmit = useProductStore( + (state) => state.handleProductSubmit + ); - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; + // cart가 배열인지 확인 (안전장치) + const cartRaw = useCartStore((state) => state.cart); + const cart = Array.isArray(cartRaw) ? cartRaw : []; + const addToCart = useCartStore((state) => state.addToCart); + const removeFromCart = useCartStore((state) => state.removeFromCart); + const updateQuantity = useCartStore((state) => state.updateQuantity); + const completeOrderFromCart = useCartStore((state) => state.completeOrder); + const getTotalItemCount = useCartStore((state) => state.getTotalItemCount); + const getFilledItems = useCartStore((state) => state.getFilledItems); + + // coupons가 배열인지 확인 (안전장치) + const couponsRaw = useCouponStore((state) => state.coupons); + const coupons = Array.isArray(couponsRaw) ? couponsRaw : []; + const selectedCoupon = useCouponStore((state) => state.selectedCoupon); + const couponForm = useCouponStore((state) => state.couponForm); + const showCouponForm = useCouponStore((state) => state.showCouponForm); + const setSelectedCoupon = useCouponStore((state) => state.setSelectedCoupon); + const setCouponForm = useCouponStore((state) => state.setCouponForm); + const setShowCouponForm = useCouponStore( + (state) => state.setShowCouponForm + ); + const deleteCoupon = useCouponStore((state) => state.deleteCoupon); + const handleCouponSubmit = useCouponStore( + (state) => state.handleCouponSubmit + ); + const selectorOnChange = useCouponStore((state) => state.selectorOnChange); - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; + // completeOrder는 cart와 coupon 모두 초기화해야 하므로 래퍼 함수 생성 + const completeOrder = () => { + completeOrderFromCart(); + setSelectedCoupon(null); }; - 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); + // 계산된 값 (순수 함수 호출) + const totals = calculateCartTotal(cart, selectedCoupon); - - 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]); - + // localStorage 동기화 (origin과 동일한 형식으로 저장) + // persist 미들웨어는 내부적으로 사용하되, 테스트 호환성을 위해 배열을 직접 저장 useEffect(() => { if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); + localStorage.setItem("cart", JSON.stringify(cart)); } else { - localStorage.removeItem('cart'); + 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]); + localStorage.setItem("products", JSON.stringify(products)); + }, [products]); - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); + useEffect(() => { + localStorage.setItem("coupons", JSON.stringify(coupons)); + }, [coupons]); + + const filteredProducts = filterProductsBySearchTerm( + debouncedSearchTerm, + products + ); - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` + // 계산된 값 (Store에서 제공하는 함수 사용) + const totalItemCount = getTotalItemCount(); + const filledItems = getFilledItems(); + + // StorePage에 필요한 모든 props를 한 번에 조립해 반환하는 헬퍼 함수 + const buildStorePageProps = () => { + const productProps: ProductListProps = { + cart, + products, + filteredProducts, + debouncedSearchTerm, + addToCart, + }; + const cartSidebarProps: CartSidebarProps = { + cartProps: { + filledItems, + removeFromCart, + updateQuantity, + }, + couponProps: { + coupons, + selectedCouponCode: selectedCoupon?.code || "", + selectorOnChange, + }, + payment: { + totals, + completeOrder, + }, }; - 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); + return { productProps, cartSidebarProps }; }; - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); + const productListTableProps = () => { + return { + cart, + products, + startEditProduct, + deleteProduct, + } as ProductListTableProps; }; - 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 buildAdminProductsSection = () => { + const addNotification = useNotificationStore.getState().addNotification; + const adminProductsProps: AdminProductsSectionProps = { + productListTableProps: productListTableProps(), + productForm, + showProductForm, + editingProduct, + setEditingProduct, + setProductForm, + setShowProductForm, + handleProductSubmit, + addNotification, + }; + return adminProductsProps; }; - 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 buildAdminCouponSection = () => { + const addNotification = useNotificationStore.getState().addNotification; + const couponsListProps: CouponListProps = { + coupons, + deleteCoupon, + setShowCouponForm, + showCouponForm, + }; + const couponFormProps: CouponFormSection = { + couponForm, + handleCouponSubmit, + setCouponForm, + addNotification, + setShowCouponForm, + }; + return { couponsListProps, couponFormProps, showCouponForm }; + }; return ( -
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

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 - /> -
-
-
- - -
-
-
- )} -
-
+ + {notifications.length > 0 && ( + + )} + + } + headerProps={{ + headerLeft: ( + <> + {!isAdmin && ( + )} -
- ) : ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

-
- 총 {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()}원 -
-
- - - -
-

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

-
-
- - )} -
-
-
- )} -
-
+ + ), + headerRight: ( + + ), + }}> + {isAdmin ? ( + + ) : ( + + )} + ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/advanced/__tests__/origin.test.tsx b/src/advanced/__tests__/origin.test.tsx index 3f5c3d55e..3e7ef0469 100644 --- a/src/advanced/__tests__/origin.test.tsx +++ b/src/advanced/__tests__/origin.test.tsx @@ -1,528 +1,671 @@ // @ts-nocheck -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; -import { vi } from 'vitest'; -import App from '../App'; -import '../../setupTests'; +import { + render, + screen, + fireEvent, + within, + waitFor, +} from "@testing-library/react"; +import { vi } from "vitest"; +import App from "../App"; +import "../../setupTests"; +import { useProductStore } from "../stores/useProductStore"; +import { useCartStore } from "../stores/useCartStore"; +import { useCouponStore } from "../stores/useCouponStore"; +import { useSearchStore } from "../stores/useSearchStore"; +import { useNotificationStore } from "../stores/useNotificationStore"; -describe('쇼핑몰 앱 통합 테스트', () => { +describe("쇼핑몰 앱 통합 테스트", () => { beforeEach(() => { - // localStorage 초기화 + // localStorage 초기화 (먼저 실행) localStorage.clear(); + + // Zustand Store 상태 초기화 (테스트 간 상태 공유 방지) + // useProductStore 초기화 + const initialProducts = [ + { + 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: "대용량과 고성능을 자랑하는 상품입니다.", + }, + ]; + + useProductStore.setState({ + products: initialProducts, + productForm: { + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }, + editingProduct: null, + showProductForm: false, + }); + + // useCartStore 초기화 + useCartStore.setState({ + cart: [], + }); + + // useCouponStore 초기화 + const initialCoupons = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, + ]; + + useCouponStore.setState({ + coupons: initialCoupons, + selectedCoupon: null, + couponForm: { + name: "", + code: "", + discountType: "amount", + discountValue: 0, + }, + showCouponForm: false, + }); + + // useSearchStore 초기화 + useSearchStore.setState({ + searchTerm: "", + debouncedSearchTerm: "", + }); + + // useNotificationStore 초기화 + useNotificationStore.setState({ + notifications: [], + }); + + // persist 미들웨어가 localStorage를 다시 읽지 않도록, 초기 상태를 localStorage에 명시적으로 저장 + localStorage.setItem("products", JSON.stringify(initialProducts)); + localStorage.setItem("cart", JSON.stringify([])); + localStorage.setItem("coupons", JSON.stringify(initialCoupons)); + // console 경고 무시 - vi.spyOn(console, 'warn').mockImplementation(() => {}); - vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); - describe('고객 쇼핑 플로우', () => { - test('상품을 검색하고 장바구니에 추가할 수 있다', async () => { + describe("고객 쇼핑 플로우", () => { + test("상품을 검색하고 장바구니에 추가할 수 있다", async () => { render(); - + // 검색창에 "프리미엄" 입력 - const searchInput = screen.getByPlaceholderText('상품 검색...'); - fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + const searchInput = screen.getByPlaceholderText("상품 검색..."); + fireEvent.change(searchInput, { target: { value: "프리미엄" } }); + // 디바운스 대기 - await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - }, { timeout: 600 }); - + await waitFor( + () => { + expect( + screen.getByText("최고급 품질의 프리미엄 상품입니다.") + ).toBeInTheDocument(); + }, + { timeout: 600 } + ); + // 검색된 상품을 장바구니에 추가 (첫 번째 버튼 선택) - const addButtons = screen.getAllByText('장바구니 담기'); + const addButtons = screen.getAllByText("장바구니 담기"); fireEvent.click(addButtons[0]); - + // 알림 메시지 확인 await waitFor(() => { - expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); + expect(screen.getByText("장바구니에 담았습니다")).toBeInTheDocument(); }); - + // 장바구니에 추가됨 확인 (장바구니 섹션에서) - const cartSection = screen.getByText('장바구니').closest('section'); - expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); + const cartSection = screen.getByText("장바구니").closest("section"); + expect(within(cartSection).getByText("상품1")).toBeInTheDocument(); }); - test('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { + test("장바구니에서 수량을 조절하고 할인을 확인할 수 있다", () => { render(); - + // 상품1을 장바구니에 추가 - const product1 = screen.getAllByText('장바구니 담기')[0]; + const product1 = screen.getAllByText("장바구니 담기")[0]; fireEvent.click(product1); - + // 수량을 10개로 증가 (10% 할인 적용) - const cartSection = screen.getByText('장바구니').closest('section'); - const plusButton = within(cartSection).getByText('+'); - + const cartSection = screen.getByText("장바구니").closest("section"); + const plusButton = within(cartSection).getByText("+"); + for (let i = 0; i < 9; i++) { fireEvent.click(plusButton); } - + // 10% 할인 적용 확인 - 15% (대량 구매 시 추가 5% 포함) - expect(screen.getByText('-15%')).toBeInTheDocument(); + expect(screen.getByText("-15%")).toBeInTheDocument(); }); - test('쿠폰을 선택하고 적용할 수 있다', () => { + test("쿠폰을 선택하고 적용할 수 있다", () => { render(); - + // 상품 추가 - const addButton = screen.getAllByText('장바구니 담기')[0]; + const addButton = screen.getAllByText("장바구니 담기")[0]; fireEvent.click(addButton); - + // 쿠폰 선택 - const couponSelect = screen.getByRole('combobox'); - fireEvent.change(couponSelect, { target: { value: 'AMOUNT5000' } }); - + const couponSelect = screen.getByRole("combobox"); + fireEvent.change(couponSelect, { target: { value: "AMOUNT5000" } }); + // 결제 정보에서 할인 금액 확인 - const paymentSection = screen.getByText('결제 정보').closest('section'); - const discountRow = within(paymentSection).getByText('할인 금액').closest('div'); - expect(within(discountRow).getByText('-5,000원')).toBeInTheDocument(); + const paymentSection = screen.getByText("결제 정보").closest("section"); + const discountRow = within(paymentSection) + .getByText("할인 금액") + .closest("div"); + expect(within(discountRow).getByText("-5,000원")).toBeInTheDocument(); }); - test('품절 임박 상품에 경고가 표시된다', async () => { + test("품절 임박 상품에 경고가 표시된다", async () => { render(); - + // 관리자 모드로 전환 - fireEvent.click(screen.getByText('관리자 페이지로')); - + fireEvent.click(screen.getByText("관리자 페이지로")); + // 상품 수정 - const editButton = screen.getAllByText('수정')[0]; + const editButton = screen.getAllByText("수정")[0]; fireEvent.click(editButton); - + // 재고를 5개로 변경 - const stockInputs = screen.getAllByPlaceholderText('숫자만 입력'); + const stockInputs = screen.getAllByPlaceholderText("숫자만 입력"); const stockInput = stockInputs[1]; // 재고 입력 필드는 두 번째 - fireEvent.change(stockInput, { target: { value: '5' } }); + fireEvent.change(stockInput, { target: { value: "5" } }); fireEvent.blur(stockInput); - + // 수정 완료 버튼 클릭 - const editButtons = screen.getAllByText('수정'); + const editButtons = screen.getAllByText("수정"); const completeEditButton = editButtons[editButtons.length - 1]; // 마지막 수정 버튼 (완료 버튼) fireEvent.click(completeEditButton); - + // 쇼핑몰로 돌아가기 - fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + fireEvent.click(screen.getByText("쇼핑몰로 돌아가기")); + // 품절임박 메시지 확인 - 재고가 5개 이하면 품절임박 표시 await waitFor(() => { - expect(screen.getByText('품절임박! 5개 남음')).toBeInTheDocument(); + expect(screen.getByText("품절임박! 5개 남음")).toBeInTheDocument(); }); }); - test('주문을 완료할 수 있다', () => { + test("주문을 완료할 수 있다", () => { render(); - + // 상품 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // 결제하기 버튼 클릭 const orderButton = screen.getByText(/원 결제하기/); fireEvent.click(orderButton); - + // 주문 완료 알림 확인 expect(screen.getByText(/주문이 완료되었습니다/)).toBeInTheDocument(); - + // 장바구니가 비어있는지 확인 - expect(screen.getByText('장바구니가 비어있습니다')).toBeInTheDocument(); + expect(screen.getByText("장바구니가 비어있습니다")).toBeInTheDocument(); }); - test('장바구니에서 상품을 삭제할 수 있다', () => { + test("장바구니에서 상품을 삭제할 수 있다", () => { render(); - + // 상품 2개 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + fireEvent.click(screen.getAllByText("장바구니 담기")[1]); + // 장바구니 섹션 확인 - const cartSection = screen.getByText('장바구니').closest('section'); - expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); - expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); - + const cartSection = screen.getByText("장바구니").closest("section"); + expect(within(cartSection).getByText("상품1")).toBeInTheDocument(); + expect(within(cartSection).getByText("상품2")).toBeInTheDocument(); + // 첫 번째 상품 삭제 (X 버튼) - const deleteButtons = within(cartSection).getAllByRole('button').filter( - button => button.querySelector('svg') - ); + const deleteButtons = within(cartSection) + .getAllByRole("button") + .filter((button) => button.querySelector("svg")); fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되고 상품2만 남음 - expect(within(cartSection).queryByText('상품1')).not.toBeInTheDocument(); - expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); + expect(within(cartSection).queryByText("상품1")).not.toBeInTheDocument(); + expect(within(cartSection).getByText("상품2")).toBeInTheDocument(); }); - test('재고를 초과하여 구매할 수 없다', async () => { + test("재고를 초과하여 구매할 수 없다", async () => { render(); - + // 상품1 장바구니에 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // 수량을 재고(20개) 이상으로 증가 시도 - const cartSection = screen.getByText('장바구니').closest('section'); - const plusButton = within(cartSection).getByText('+'); - + const cartSection = screen.getByText("장바구니").closest("section"); + const plusButton = within(cartSection).getByText("+"); + // 19번 클릭하여 총 20개로 만듦 for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 한 번 더 클릭 시도 (21개가 되려고 함) fireEvent.click(plusButton); - + // 수량이 20개에서 멈춰있어야 함 - expect(within(cartSection).getByText('20')).toBeInTheDocument(); - + expect(within(cartSection).getByText("20")).toBeInTheDocument(); + // 재고 부족 메시지 확인 await waitFor(() => { - expect(screen.getByText(/재고는.*개까지만 있습니다/)).toBeInTheDocument(); + expect( + screen.getByText(/재고는.*개까지만 있습니다/) + ).toBeInTheDocument(); }); }); - test('장바구니에서 수량을 감소시킬 수 있다', () => { + test("장바구니에서 수량을 감소시킬 수 있다", () => { render(); - + // 상품 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - - const cartSection = screen.getByText('장바구니').closest('section'); - const plusButton = within(cartSection).getByText('+'); - const minusButton = within(cartSection).getByText('−'); // U+2212 마이너스 기호 - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + + const cartSection = screen.getByText("장바구니").closest("section"); + const plusButton = within(cartSection).getByText("+"); + const minusButton = within(cartSection).getByText("−"); // U+2212 마이너스 기호 + // 수량 3개로 증가 fireEvent.click(plusButton); fireEvent.click(plusButton); - expect(within(cartSection).getByText('3')).toBeInTheDocument(); - + expect(within(cartSection).getByText("3")).toBeInTheDocument(); + // 수량 감소 fireEvent.click(minusButton); - expect(within(cartSection).getByText('2')).toBeInTheDocument(); - + expect(within(cartSection).getByText("2")).toBeInTheDocument(); + // 1개로 더 감소 fireEvent.click(minusButton); - expect(within(cartSection).getByText('1')).toBeInTheDocument(); - + expect(within(cartSection).getByText("1")).toBeInTheDocument(); + // 1개에서 한 번 더 감소하면 장바구니에서 제거될 수도 있음 fireEvent.click(minusButton); // 장바구니가 비었는지 확인 - const emptyMessage = screen.queryByText('장바구니가 비어있습니다'); + const emptyMessage = screen.queryByText("장바구니가 비어있습니다"); if (emptyMessage) { expect(emptyMessage).toBeInTheDocument(); } else { // 또는 수량이 1에서 멈춤 - expect(within(cartSection).getByText('1')).toBeInTheDocument(); + expect(within(cartSection).getByText("1")).toBeInTheDocument(); } }); - test('20개 이상 구매 시 최대 할인이 적용된다', async () => { + test("20개 이상 구매 시 최대 할인이 적용된다", async () => { render(); - + // 관리자 모드로 전환하여 상품1의 재고를 늘림 - fireEvent.click(screen.getByText('관리자 페이지로')); - fireEvent.click(screen.getAllByText('수정')[0]); - - const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; - fireEvent.change(stockInput, { target: { value: '30' } }); - - const editButtons = screen.getAllByText('수정'); + fireEvent.click(screen.getByText("관리자 페이지로")); + fireEvent.click(screen.getAllByText("수정")[0]); + + const stockInput = screen.getAllByPlaceholderText("숫자만 입력")[1]; + fireEvent.change(stockInput, { target: { value: "30" } }); + + const editButtons = screen.getAllByText("수정"); fireEvent.click(editButtons[editButtons.length - 1]); - + // 쇼핑몰로 돌아가기 - fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + fireEvent.click(screen.getByText("쇼핑몰로 돌아가기")); + // 상품1을 장바구니에 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // 수량을 20개로 증가 - const cartSection = screen.getByText('장바구니').closest('section'); - const plusButton = within(cartSection).getByText('+'); - + const cartSection = screen.getByText("장바구니").closest("section"); + const plusButton = within(cartSection).getByText("+"); + for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 25% 할인 적용 확인 (또는 대량 구매 시 30%) await waitFor(() => { - const discount25 = screen.queryByText('-25%'); - const discount30 = screen.queryByText('-30%'); + const discount25 = screen.queryByText("-25%"); + const discount30 = screen.queryByText("-30%"); expect(discount25 || discount30).toBeTruthy(); }); }); }); - describe('관리자 기능', () => { + describe("관리자 기능", () => { beforeEach(() => { render(); // 관리자 모드로 전환 - fireEvent.click(screen.getByText('관리자 페이지로')); + fireEvent.click(screen.getByText("관리자 페이지로")); }); - test('새 상품을 추가할 수 있다', () => { + test("새 상품을 추가할 수 있다", () => { // 새 상품 추가 버튼 클릭 - fireEvent.click(screen.getByText('새 상품 추가')); - + fireEvent.click(screen.getByText("새 상품 추가")); + // 폼 입력 - 상품명 입력 - const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); - const nameInput = nameLabel.closest('div').querySelector('input'); - fireEvent.change(nameInput, { target: { value: '테스트 상품' } }); - - const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - fireEvent.change(priceInput, { target: { value: '25000' } }); - - const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; - fireEvent.change(stockInput, { target: { value: '50' } }); - - const descLabels = screen.getAllByText('설명'); - const descLabel = descLabels.find(el => el.tagName === 'LABEL'); - const descInput = descLabel.closest('div').querySelector('input'); - fireEvent.change(descInput, { target: { value: '테스트 설명' } }); - + const labels = screen.getAllByText("상품명"); + const nameLabel = labels.find((el) => el.tagName === "LABEL"); + const nameInput = nameLabel.closest("div").querySelector("input"); + fireEvent.change(nameInput, { target: { value: "테스트 상품" } }); + + const priceInput = screen.getAllByPlaceholderText("숫자만 입력")[0]; + fireEvent.change(priceInput, { target: { value: "25000" } }); + + const stockInput = screen.getAllByPlaceholderText("숫자만 입력")[1]; + fireEvent.change(stockInput, { target: { value: "50" } }); + + const descLabels = screen.getAllByText("설명"); + const descLabel = descLabels.find((el) => el.tagName === "LABEL"); + const descInput = descLabel.closest("div").querySelector("input"); + fireEvent.change(descInput, { target: { value: "테스트 설명" } }); + // 저장 - fireEvent.click(screen.getByText('추가')); - + fireEvent.click(screen.getByText("추가")); + // 추가된 상품 확인 - expect(screen.getByText('테스트 상품')).toBeInTheDocument(); - expect(screen.getByText('25,000원')).toBeInTheDocument(); + expect(screen.getByText("테스트 상품")).toBeInTheDocument(); + expect(screen.getByText("25,000원")).toBeInTheDocument(); }); - test('쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다', () => { + test("쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다", () => { // 쿠폰 관리 탭으로 전환 - fireEvent.click(screen.getByText('쿠폰 관리')); - + fireEvent.click(screen.getByText("쿠폰 관리")); + // 새 쿠폰 추가 버튼 클릭 - const addCouponButton = screen.getByText('새 쿠폰 추가'); + const addCouponButton = screen.getByText("새 쿠폰 추가"); fireEvent.click(addCouponButton); - + // 쿠폰 정보 입력 - fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { target: { value: '테스트 쿠폰' } }); - fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { target: { value: 'TEST2024' } }); - - const discountInput = screen.getByPlaceholderText('5000'); - fireEvent.change(discountInput, { target: { value: '7000' } }); - + fireEvent.change(screen.getByPlaceholderText("신규 가입 쿠폰"), { + target: { value: "테스트 쿠폰" }, + }); + fireEvent.change(screen.getByPlaceholderText("WELCOME2024"), { + target: { value: "TEST2024" }, + }); + + const discountInput = screen.getByPlaceholderText("5000"); + fireEvent.change(discountInput, { target: { value: "7000" } }); + // 쿠폰 생성 - fireEvent.click(screen.getByText('쿠폰 생성')); - + fireEvent.click(screen.getByText("쿠폰 생성")); + // 생성된 쿠폰 확인 - expect(screen.getByText('테스트 쿠폰')).toBeInTheDocument(); - expect(screen.getByText('TEST2024')).toBeInTheDocument(); - expect(screen.getByText('7,000원 할인')).toBeInTheDocument(); + expect(screen.getByText("테스트 쿠폰")).toBeInTheDocument(); + expect(screen.getByText("TEST2024")).toBeInTheDocument(); + expect(screen.getByText("7,000원 할인")).toBeInTheDocument(); }); - test('상품의 가격 입력 시 숫자만 허용된다', async () => { + test("상품의 가격 입력 시 숫자만 허용된다", async () => { // 상품 수정 - fireEvent.click(screen.getAllByText('수정')[0]); - - const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - + fireEvent.click(screen.getAllByText("수정")[0]); + + const priceInput = screen.getAllByPlaceholderText("숫자만 입력")[0]; + // 문자와 숫자 혼합 입력 시도 - 숫자만 남음 - fireEvent.change(priceInput, { target: { value: 'abc123def' } }); - expect(priceInput.value).toBe('10000'); // 유효하지 않은 입력은 무시됨 - + fireEvent.change(priceInput, { target: { value: "abc123def" } }); + expect(priceInput.value).toBe("10000"); // 유효하지 않은 입력은 무시됨 + // 숫자만 입력 - fireEvent.change(priceInput, { target: { value: '123' } }); - expect(priceInput.value).toBe('123'); - + fireEvent.change(priceInput, { target: { value: "123" } }); + expect(priceInput.value).toBe("123"); + // 음수 입력 시도 - regex가 매치되지 않아 값이 변경되지 않음 - fireEvent.change(priceInput, { target: { value: '-100' } }); - expect(priceInput.value).toBe('123'); // 이전 값 유지 - + fireEvent.change(priceInput, { target: { value: "-100" } }); + expect(priceInput.value).toBe("123"); // 이전 값 유지 + // 유효한 음수 입력하기 위해 먼저 1 입력 후 앞에 - 추가는 불가능 // 대신 blur 이벤트를 통해 음수 검증을 테스트 // parseInt()는 실제로 음수를 파싱할 수 있으므로 다른 방법으로 테스트 - + // 공백 입력 시도 - fireEvent.change(priceInput, { target: { value: ' ' } }); - expect(priceInput.value).toBe('123'); // 유효하지 않은 입력은 무시됨 + fireEvent.change(priceInput, { target: { value: " " } }); + expect(priceInput.value).toBe("123"); // 유효하지 않은 입력은 무시됨 }); - test('쿠폰 할인율 검증이 작동한다', async () => { + test("쿠폰 할인율 검증이 작동한다", async () => { // 쿠폰 관리 탭으로 전환 - fireEvent.click(screen.getByText('쿠폰 관리')); - + fireEvent.click(screen.getByText("쿠폰 관리")); + // 새 쿠폰 추가 - fireEvent.click(screen.getByText('새 쿠폰 추가')); - + fireEvent.click(screen.getByText("새 쿠폰 추가")); + // 퍼센트 타입으로 변경 - 쿠폰 폼 내의 select 찾기 - const couponFormSelects = screen.getAllByRole('combobox'); + const couponFormSelects = screen.getAllByRole("combobox"); const typeSelect = couponFormSelects[couponFormSelects.length - 1]; // 마지막 select가 타입 선택 - fireEvent.change(typeSelect, { target: { value: 'percentage' } }); - + fireEvent.change(typeSelect, { target: { value: "percentage" } }); + // 100% 초과 할인율 입력 - const discountInput = screen.getByPlaceholderText('10'); - fireEvent.change(discountInput, { target: { value: '150' } }); + const discountInput = screen.getByPlaceholderText("10"); + fireEvent.change(discountInput, { target: { value: "150" } }); fireEvent.blur(discountInput); - + // 에러 메시지 확인 await waitFor(() => { - expect(screen.getByText('할인율은 100%를 초과할 수 없습니다')).toBeInTheDocument(); + expect( + screen.getByText("할인율은 100%를 초과할 수 없습니다") + ).toBeInTheDocument(); }); }); - test('상품을 삭제할 수 있다', () => { + test("상품을 삭제할 수 있다", () => { // 초기 상품명들 확인 (테이블에서) - const productTable = screen.getByRole('table'); - expect(within(productTable).getByText('상품1')).toBeInTheDocument(); - + const productTable = screen.getByRole("table"); + expect(within(productTable).getByText("상품1")).toBeInTheDocument(); + // 삭제 버튼들 찾기 - const deleteButtons = within(productTable).getAllByRole('button').filter( - button => button.textContent === '삭제' - ); - + const deleteButtons = within(productTable) + .getAllByRole("button") + .filter((button) => button.textContent === "삭제"); + // 첫 번째 상품 삭제 fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되었는지 확인 - expect(within(productTable).queryByText('상품1')).not.toBeInTheDocument(); - expect(within(productTable).getByText('상품2')).toBeInTheDocument(); + expect(within(productTable).queryByText("상품1")).not.toBeInTheDocument(); + expect(within(productTable).getByText("상품2")).toBeInTheDocument(); }); - test('쿠폰을 삭제할 수 있다', () => { + test("쿠폰을 삭제할 수 있다", () => { // 쿠폰 관리 탭으로 전환 - fireEvent.click(screen.getByText('쿠폰 관리')); - + fireEvent.click(screen.getByText("쿠폰 관리")); + // 초기 쿠폰들 확인 (h3 제목에서) - const couponTitles = screen.getAllByRole('heading', { level: 3 }); - const coupon5000 = couponTitles.find(el => el.textContent === '5000원 할인'); - const coupon10 = couponTitles.find(el => el.textContent === '10% 할인'); + const couponTitles = screen.getAllByRole("heading", { level: 3 }); + const coupon5000 = couponTitles.find( + (el) => el.textContent === "5000원 할인" + ); + const coupon10 = couponTitles.find((el) => el.textContent === "10% 할인"); expect(coupon5000).toBeInTheDocument(); expect(coupon10).toBeInTheDocument(); - + // 삭제 버튼 찾기 (SVG 아이콘을 포함한 버튼) - const deleteButtons = screen.getAllByRole('button').filter(button => { - return button.querySelector('svg') && - button.querySelector('path[d*="M19 7l"]'); // 삭제 아이콘 path + const deleteButtons = screen.getAllByRole("button").filter((button) => { + return ( + button.querySelector("svg") && + button.querySelector('path[d*="M19 7l"]') + ); // 삭제 아이콘 path }); - + // 첫 번째 쿠폰 삭제 fireEvent.click(deleteButtons[0]); - + // 쿠폰이 삭제되었는지 확인 - expect(screen.queryByText('5000원 할인')).not.toBeInTheDocument(); + expect(screen.queryByText("5000원 할인")).not.toBeInTheDocument(); }); - }); - describe('로컬스토리지 동기화', () => { - test('상품, 장바구니, 쿠폰이 localStorage에 저장된다', () => { + describe("로컬스토리지 동기화", () => { + test("상품, 장바구니, 쿠폰이 localStorage에 저장된다", () => { render(); - + // 상품을 장바구니에 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // localStorage 확인 - expect(localStorage.getItem('cart')).toBeTruthy(); - expect(JSON.parse(localStorage.getItem('cart'))).toHaveLength(1); - + expect(localStorage.getItem("cart")).toBeTruthy(); + expect(JSON.parse(localStorage.getItem("cart"))).toHaveLength(1); + // 관리자 모드로 전환하여 새 상품 추가 - fireEvent.click(screen.getByText('관리자 페이지로')); - fireEvent.click(screen.getByText('새 상품 추가')); - - const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); - const nameInput = nameLabel.closest('div').querySelector('input'); - fireEvent.change(nameInput, { target: { value: '저장 테스트' } }); - - const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - fireEvent.change(priceInput, { target: { value: '10000' } }); - - const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; - fireEvent.change(stockInput, { target: { value: '10' } }); - - fireEvent.click(screen.getByText('추가')); - + fireEvent.click(screen.getByText("관리자 페이지로")); + fireEvent.click(screen.getByText("새 상품 추가")); + + const labels = screen.getAllByText("상품명"); + const nameLabel = labels.find((el) => el.tagName === "LABEL"); + const nameInput = nameLabel.closest("div").querySelector("input"); + fireEvent.change(nameInput, { target: { value: "저장 테스트" } }); + + const priceInput = screen.getAllByPlaceholderText("숫자만 입력")[0]; + fireEvent.change(priceInput, { target: { value: "10000" } }); + + const stockInput = screen.getAllByPlaceholderText("숫자만 입력")[1]; + fireEvent.change(stockInput, { target: { value: "10" } }); + + fireEvent.click(screen.getByText("추가")); + // localStorage에 products가 저장되었는지 확인 - expect(localStorage.getItem('products')).toBeTruthy(); - const products = JSON.parse(localStorage.getItem('products')); - expect(products.some(p => p.name === '저장 테스트')).toBe(true); + expect(localStorage.getItem("products")).toBeTruthy(); + const products = JSON.parse(localStorage.getItem("products")); + expect(products.some((p) => p.name === "저장 테스트")).toBe(true); }); - test('페이지 새로고침 후에도 데이터가 유지된다', () => { + test("페이지 새로고침 후에도 데이터가 유지된다", () => { const { unmount } = render(); - + // 장바구니에 상품 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + fireEvent.click(screen.getAllByText("장바구니 담기")[1]); + // 컴포넌트 unmount unmount(); - + // 다시 mount render(); - + // 장바구니 아이템이 유지되는지 확인 - const cartSection = screen.getByText('장바구니').closest('section'); - expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); - expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); + const cartSection = screen.getByText("장바구니").closest("section"); + expect(within(cartSection).getByText("상품1")).toBeInTheDocument(); + expect(within(cartSection).getByText("상품2")).toBeInTheDocument(); }); }); - describe('UI 상태 관리', () => { - test('할인이 있을 때 할인율이 표시된다', async () => { + describe("UI 상태 관리", () => { + test("할인이 있을 때 할인율이 표시된다", async () => { render(); - + // 상품을 10개 담아서 할인 발생 - const addButton = screen.getAllByText('장바구니 담기')[0]; + const addButton = screen.getAllByText("장바구니 담기")[0]; for (let i = 0; i < 10; i++) { fireEvent.click(addButton); } - + // 할인율 표시 확인 - 대량 구매로 15% 할인 await waitFor(() => { - expect(screen.getByText('-15%')).toBeInTheDocument(); + expect(screen.getByText("-15%")).toBeInTheDocument(); }); }); - test('장바구니 아이템 개수가 헤더에 표시된다', () => { + test("장바구니 아이템 개수가 헤더에 표시된다", () => { render(); - + // 상품 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + fireEvent.click(screen.getAllByText("장바구니 담기")[1]); + // 헤더의 장바구니 아이콘 옆 숫자 확인 - const cartCount = screen.getByText('3'); + const cartCount = screen.getByText("3"); expect(cartCount).toBeInTheDocument(); }); - test('검색을 초기화할 수 있다', async () => { + test("검색을 초기화할 수 있다", async () => { render(); - + // 검색어 입력 - const searchInput = screen.getByPlaceholderText('상품 검색...'); - fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + const searchInput = screen.getByPlaceholderText("상품 검색..."); + fireEvent.change(searchInput, { target: { value: "프리미엄" } }); + // 검색 결과 확인 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); + expect( + screen.getByText("최고급 품질의 프리미엄 상품입니다.") + ).toBeInTheDocument(); // 다른 상품들은 보이지 않음 - expect(screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.')).not.toBeInTheDocument(); + expect( + screen.queryByText("다양한 기능을 갖춘 실용적인 상품입니다.") + ).not.toBeInTheDocument(); }); - + // 검색어 초기화 - fireEvent.change(searchInput, { target: { value: '' } }); - + fireEvent.change(searchInput, { target: { value: "" } }); + // 모든 상품이 다시 표시됨 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('다양한 기능을 갖춘 실용적인 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('대용량과 고성능을 자랑하는 상품입니다.')).toBeInTheDocument(); + expect( + screen.getByText("최고급 품질의 프리미엄 상품입니다.") + ).toBeInTheDocument(); + expect( + screen.getByText("다양한 기능을 갖춘 실용적인 상품입니다.") + ).toBeInTheDocument(); + expect( + screen.getByText("대용량과 고성능을 자랑하는 상품입니다.") + ).toBeInTheDocument(); }); }); - test('알림 메시지가 자동으로 사라진다', async () => { + test("알림 메시지가 자동으로 사라진다", async () => { render(); - + // 상품 추가하여 알림 발생 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // 알림 메시지 확인 - expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); - + expect(screen.getByText("장바구니에 담았습니다")).toBeInTheDocument(); + // 3초 후 알림이 사라짐 - await waitFor(() => { - expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); - }, { timeout: 4000 }); + await waitFor( + () => { + expect( + screen.queryByText("장바구니에 담았습니다") + ).not.toBeInTheDocument(); + }, + { timeout: 4000 } + ); }); }); -}); \ No newline at end of file +}); diff --git a/src/advanced/components/admin/common/AdminTabs.tsx b/src/advanced/components/admin/common/AdminTabs.tsx new file mode 100644 index 000000000..4667e769d --- /dev/null +++ b/src/advanced/components/admin/common/AdminTabs.tsx @@ -0,0 +1,16 @@ +import { TabItem, Tabs } from "../../common/Tabs"; + +export type AdminTabKey = "products" | "coupons"; + +interface AdminTabsProps { + activeKey: AdminTabKey; + onChange: (key: AdminTabKey) => void; +} + +export const AdminTabs = ({ activeKey, onChange }: AdminTabsProps) => { + const items: TabItem[] = [ + { key: "products", label: "상품 관리" }, + { key: "coupons", label: "쿠폰 관리" }, + ]; + return ; +}; diff --git a/src/advanced/components/admin/common/SectionHeader.tsx b/src/advanced/components/admin/common/SectionHeader.tsx new file mode 100644 index 000000000..59d34ec10 --- /dev/null +++ b/src/advanced/components/admin/common/SectionHeader.tsx @@ -0,0 +1,18 @@ +interface SectionHeaderProps { + onAddNewProduct: () => void; +} + +export const SectionHeader = ({ onAddNewProduct }: SectionHeaderProps) => { + return ( +
+
+

상품 목록

+ +
+
+ ); +}; diff --git a/src/advanced/components/admin/coupon/AdminCouponSection.tsx b/src/advanced/components/admin/coupon/AdminCouponSection.tsx new file mode 100644 index 000000000..46d8603e3 --- /dev/null +++ b/src/advanced/components/admin/coupon/AdminCouponSection.tsx @@ -0,0 +1,27 @@ +import { CouponFormSection } from "./CouponFormSection"; +import { CouponList, CouponListProps } from "./CouponList"; + +export interface AdminCouponSectionProps { + couponsListProps: CouponListProps; + couponFormProps: CouponFormSection; + showCouponForm: boolean; +} + +export const AdminCouponSection = ({ + couponsListProps, + couponFormProps, + showCouponForm = false, +}: AdminCouponSectionProps) => { + return ( +
+
+

쿠폰 관리

+
+
+ + + {showCouponForm && } +
+
+ ); +}; diff --git a/src/advanced/components/admin/coupon/CouponFormSection/CouponFormActions.tsx b/src/advanced/components/admin/coupon/CouponFormSection/CouponFormActions.tsx new file mode 100644 index 000000000..79f137707 --- /dev/null +++ b/src/advanced/components/admin/coupon/CouponFormSection/CouponFormActions.tsx @@ -0,0 +1,21 @@ +interface CouponFormActionsProps { + onClick: () => void; +} + +export const CouponFormActions = ({ onClick }: CouponFormActionsProps) => { + return ( +
+ + +
+ ); +}; diff --git a/src/advanced/components/admin/coupon/CouponFormSection/CouponFormFields.tsx b/src/advanced/components/admin/coupon/CouponFormSection/CouponFormFields.tsx new file mode 100644 index 000000000..e7b3bb8be --- /dev/null +++ b/src/advanced/components/admin/coupon/CouponFormSection/CouponFormFields.tsx @@ -0,0 +1,112 @@ +import { Coupon } from "../../../../../types"; +import { DiscountType } from "../../../../constans/constans"; +import { FormInputField } from "../../../common/FormInputField"; +import { FormSelectField } from "../../../common/FormSelectField"; + +interface CouponFormFieldsProps { + couponForm: Coupon; + setCouponForm: (form: Coupon | ((prev: Coupon) => Coupon)) => void; + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void; +} + +export const CouponFormFields = ({ + couponForm, + setCouponForm, + addNotification, +}: CouponFormFieldsProps) => { + const options = [ + { code: DiscountType.AMOUNT, name: "정액 할인" }, + { code: DiscountType.PRECENTAGE, name: "정률 할인" }, + ]; + return ( +
+ + setCouponForm({ + ...couponForm, + name: e.target.value, + }) + } + placeholder="신규 가입 쿠폰" + /> + + setCouponForm({ + ...couponForm, + code: e.target.value.toUpperCase(), + }) + } + placeholder="WELCOME2024" + /> + + setCouponForm({ + ...couponForm, + discountType: e.target.value as "amount" | "percentage", + }) + } + /> + { + 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, + }); + } + } + }} + placeholder={couponForm.discountType === "amount" ? "5000" : "10"} + /> +
+ ); +}; diff --git a/src/advanced/components/admin/coupon/CouponFormSection/index.tsx b/src/advanced/components/admin/coupon/CouponFormSection/index.tsx new file mode 100644 index 000000000..a0f316c22 --- /dev/null +++ b/src/advanced/components/admin/coupon/CouponFormSection/index.tsx @@ -0,0 +1,36 @@ +import { Coupon } from "../../../../../types"; +import { CouponFormActions } from "./CouponFormActions"; +import { CouponFormFields } from "./CouponFormFields"; + +export interface CouponFormSection { + couponForm: Coupon; + handleCouponSubmit: (e: React.FormEvent) => void; + setCouponForm: (form: Coupon | ((prev: Coupon) => Coupon)) => void; + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void; + setShowCouponForm: (show: boolean) => void; +} + +export const CouponFormSection = ({ + couponForm, + handleCouponSubmit, + setCouponForm, + addNotification, + setShowCouponForm, +}: CouponFormSection) => { + return ( +
+
+

새 쿠폰 생성

+ + setShowCouponForm(false)} /> + +
+ ); +}; diff --git a/src/advanced/components/admin/coupon/CouponItem.tsx b/src/advanced/components/admin/coupon/CouponItem.tsx new file mode 100644 index 000000000..1f1512ec0 --- /dev/null +++ b/src/advanced/components/admin/coupon/CouponItem.tsx @@ -0,0 +1,41 @@ +import { Coupon } from "../../../../types"; +import { PriceType } from "../../../constans/constans"; +import { formatPrice } from "../../../utils/formatters"; +import { TrashIcon } from "../../icon/TrashIcon"; + +interface CouponItemProps { + coupon: Coupon; + formatType: PriceType; + onClick: () => void; +} + +export const CouponItem = ({ + coupon, + formatType, + onClick, +}: CouponItemProps) => { + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${formatPrice(coupon.discountValue, formatType)} 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +}; diff --git a/src/advanced/components/admin/coupon/CouponList.tsx b/src/advanced/components/admin/coupon/CouponList.tsx new file mode 100644 index 000000000..18f601d87 --- /dev/null +++ b/src/advanced/components/admin/coupon/CouponList.tsx @@ -0,0 +1,41 @@ +import { Coupon } from "../../../../types"; +import { PriceType } from "../../../constans/constans"; +import { PlusIcon } from "../../icon/PlusIcon"; +import { CouponItem } from "./CouponItem"; + +export interface CouponListProps { + coupons: Coupon[]; + deleteCoupon: (couponCode: string) => void; + setShowCouponForm: (show: boolean) => void; + showCouponForm: boolean; +} + +export const CouponList = ({ + coupons, + deleteCoupon, + setShowCouponForm, + showCouponForm, +}: CouponListProps) => { + const formatType = PriceType.KR; + return ( +
+ {coupons.map((coupon) => ( + deleteCoupon(coupon.code)} + /> + ))} + +
+ +
+
+ ); +}; diff --git a/src/advanced/components/admin/product/AdminProductsSection.tsx b/src/advanced/components/admin/product/AdminProductsSection.tsx new file mode 100644 index 000000000..66c4dcdcb --- /dev/null +++ b/src/advanced/components/admin/product/AdminProductsSection.tsx @@ -0,0 +1,78 @@ +import { ProductForm } from "../../../domain/product/productTypes"; +import { SectionHeader } from "../common/SectionHeader"; +import { ProductFormSection } from "./ProductFormSection"; +import { ProductListTable, ProductListTableProps } from "./ProductListTable"; + +export interface AdminProductsSectionProps { + productListTableProps: ProductListTableProps; + productForm: ProductForm; + showProductForm: boolean; + editingProduct: string | null; + setEditingProduct: (id: string | null) => void; + setProductForm: ( + form: ProductForm | ((prev: ProductForm) => ProductForm) + ) => void; + setShowProductForm: (show: boolean) => void; + handleProductSubmit: (e: React.FormEvent) => void; + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void; +} + +export const AdminProductsSection = ({ + productListTableProps, + productForm, + showProductForm, + editingProduct, + setEditingProduct, + setProductForm, + setShowProductForm, + handleProductSubmit, + addNotification, +}: AdminProductsSectionProps) => { + return ( +
+ { + setEditingProduct("new"); + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + setShowProductForm(true); + }} + /> + +
+ +
+ {showProductForm && ( +
+ { + setEditingProduct(null); + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + setShowProductForm(false); + }} + addNotification={addNotification} + /> +
+ )} +
+ ); +}; diff --git a/src/advanced/components/admin/product/ProductFormSection/ProductBasicFields.tsx b/src/advanced/components/admin/product/ProductFormSection/ProductBasicFields.tsx new file mode 100644 index 000000000..94f0933ff --- /dev/null +++ b/src/advanced/components/admin/product/ProductFormSection/ProductBasicFields.tsx @@ -0,0 +1,94 @@ +import { ProductForm } from "../../../../domain/product/productTypes"; +import { FormInputField } from "../../../common/FormInputField"; + +interface ProductBasicFieldsProps { + productForm: ProductForm; + setProductForm: (form: ProductForm | ((prev: ProductForm) => ProductForm)) => void; + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void; +} + +export const ProductBasicFields = ({ + productForm, + setProductForm, + addNotification, +}: ProductBasicFieldsProps) => { + return ( +
+ { + const newName = e.target.value; + setProductForm((prev) => ({ + ...prev, + name: newName, + })); + }} + /> + { + const newDesc = e.target.value; + setProductForm((prev) => ({ + ...prev, + description: newDesc, + })); + }} + required={false} + /> + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setProductForm((prev) => ({ + ...prev, + price: value === "" ? 0 : parseInt(value), + })); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + setProductForm((prev) => ({ ...prev, price: 0 })); + } else if (parseInt(value) < 0) { + addNotification("가격은 0보다 커야 합니다", "error"); + setProductForm((prev) => ({ ...prev, price: 0 })); + } + }} + placeholder="숫자만 입력" + /> + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setProductForm((prev) => ({ + ...prev, + stock: value === "" ? 0 : parseInt(value), + })); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + setProductForm((prev) => ({ ...prev, stock: 0 })); + } else if (parseInt(value) < 0) { + addNotification("재고는 0보다 커야 합니다", "error"); + setProductForm((prev) => ({ ...prev, stock: 0 })); + } else if (parseInt(value) > 9999) { + addNotification("재고는 9999개를 초과할 수 없습니다", "error"); + setProductForm((prev) => ({ ...prev, stock: 9999 })); + } + }} + placeholder="숫자만 입력" + /> +
+ ); +}; diff --git a/src/advanced/components/admin/product/ProductFormSection/ProductDiscountList.tsx b/src/advanced/components/admin/product/ProductFormSection/ProductDiscountList.tsx new file mode 100644 index 000000000..aa49bca73 --- /dev/null +++ b/src/advanced/components/admin/product/ProductFormSection/ProductDiscountList.tsx @@ -0,0 +1,45 @@ +import { ProductForm } from "../../../../domain/product/productTypes"; +import { ProductDiscountRow } from "./ProductDiscountRow"; + +interface ProductDiscountListProps { + productForm: ProductForm; + setProductForm: (form: ProductForm | ((prev: ProductForm) => ProductForm)) => void; +} + +export const ProductDiscountList = ({ + productForm, + setProductForm, +}: ProductDiscountListProps) => { + return ( +
+ +
+ {productForm.discounts.map((discount, index) => ( + + ))} + +
+
+ ); +}; diff --git a/src/advanced/components/admin/product/ProductFormSection/ProductDiscountRow.tsx b/src/advanced/components/admin/product/ProductFormSection/ProductDiscountRow.tsx new file mode 100644 index 000000000..45b643ef6 --- /dev/null +++ b/src/advanced/components/admin/product/ProductFormSection/ProductDiscountRow.tsx @@ -0,0 +1,79 @@ +import { Discount } from "../../../../../types"; +import { ProductForm } from "../../../../domain/product/productTypes"; + +interface ProductDiscountRowProps { + discount: Discount; + index: number; + productForm: ProductForm; + setProductForm: (form: ProductForm | ((prev: ProductForm) => ProductForm)) => void; +} + +export const ProductDiscountRow = ({ + discount, + index, + productForm, + setProductForm, +}: ProductDiscountRowProps) => { + return ( +
+ { + 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="%" + /> + % 할인 + +
+ ); +}; diff --git a/src/advanced/components/admin/product/ProductFormSection/index.tsx b/src/advanced/components/admin/product/ProductFormSection/index.tsx new file mode 100644 index 000000000..7fff28aa1 --- /dev/null +++ b/src/advanced/components/admin/product/ProductFormSection/index.tsx @@ -0,0 +1,59 @@ +import { ProductForm } from "../../../../domain/product/productTypes"; +import { ProductBasicFields } from "./ProductBasicFields"; +import { ProductDiscountList } from "./ProductDiscountList"; + +interface ProductFormProps { + productForm: ProductForm; + setProductForm: (form: ProductForm | ((prev: ProductForm) => ProductForm)) => void; + titleText: string; + submitButtonText: string; + onSubmit: (e: React.FormEvent) => void; + onCancel: () => void; + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void; +} + +export const ProductFormSection = ({ + productForm, + setProductForm, + titleText, + submitButtonText, + onSubmit, + onCancel, + addNotification, +}: ProductFormProps) => { + return ( +
+

{titleText}

+ {/* 상품 기본 정보 */} + + {/* 할인 정책 */} + + + {/* 버튼 */} +
+ + +
+ + ); +}; + diff --git a/src/advanced/components/admin/product/ProductListTable.tsx b/src/advanced/components/admin/product/ProductListTable.tsx new file mode 100644 index 000000000..4d2c3179f --- /dev/null +++ b/src/advanced/components/admin/product/ProductListTable.tsx @@ -0,0 +1,55 @@ +import { CartItem } from "../../../../types"; +import { PriceType } from "../../../constans/constans"; +import { ProductWithUI } from "../../../domain/product/productTypes"; +import { ProductTableRow } from "./ProductTableRow"; + +export interface ProductListTableProps { + cart: CartItem[]; + products: ProductWithUI[]; + startEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; +} + +export const ProductListTable = ({ + cart, + products, + startEditProduct, + deleteProduct, +}: ProductListTableProps) => { + const priceType = PriceType.KR; + return ( + + + + + + + + + + + + {products.map((product) => ( + startEditProduct(product)} + onClickDelete={() => deleteProduct(product.id)} + /> + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ ); +}; diff --git a/src/advanced/components/admin/product/ProductTableRow.tsx b/src/advanced/components/admin/product/ProductTableRow.tsx new file mode 100644 index 000000000..31addf234 --- /dev/null +++ b/src/advanced/components/admin/product/ProductTableRow.tsx @@ -0,0 +1,59 @@ +import { CartItem } from "../../../../types"; +import { PriceType } from "../../../constans/constans"; +import { getDisplayPrice } from "../../../domain/cart/cartUtils"; +import { ProductWithUI } from "../../../domain/product/productTypes"; + +interface ProductTableRowProps { + cart: CartItem[]; + product: ProductWithUI; + priceType: PriceType; + onClickEdit: () => void; + onClickDelete: () => void; +} + +export const ProductTableRow = ({ + cart, + product, + priceType, + onClickEdit, + onClickDelete, +}: ProductTableRowProps) => { + const { id, name, stock, description } = product; + return ( + + + {name} + + + {getDisplayPrice(cart, product, priceType)} + + + 10 + ? "bg-green-100 text-green-800" + : stock > 0 + ? "bg-yellow-100 text-yellow-800" + : "bg-red-100 text-red-800" + }`}> + {stock}개 + + + + {description || "-"} + + + + + + + ); +}; diff --git a/src/advanced/components/cart/CartItemRow.tsx b/src/advanced/components/cart/CartItemRow.tsx new file mode 100644 index 000000000..a51ffaed9 --- /dev/null +++ b/src/advanced/components/cart/CartItemRow.tsx @@ -0,0 +1,54 @@ +import { formatPrice } from "../../utils/formatters"; +import { CloseButton } from "../common/CloseButton"; +import { MinusButton } from "../common/MinusButton"; +import { PlusButton } from "../common/PlusButton"; + +interface CartItemRowProps { + id: string; + name: string; + quantity: number; + itemTotal: number; + hasDiscount: boolean; + discountRate: number; + removeFromCart: () => void; + updateQuantity: (productId: string, newQuantity: number) => void; +} + +export const CartItemRow = ({ + id, + name, + quantity, + itemTotal, + hasDiscount, + discountRate, + removeFromCart, + updateQuantity, +}: CartItemRowProps) => { + return ( +
+
+

{name}

+ +
+
+
+ updateQuantity(id, quantity - 1)} /> + + {quantity} + + updateQuantity(id, quantity + 1)} /> +
+
+ {hasDiscount && ( + + -{discountRate}% + + )} +

+ {formatPrice(Math.round(itemTotal))} +

+
+
+
+ ); +}; diff --git a/src/advanced/components/cart/CartList.tsx b/src/advanced/components/cart/CartList.tsx new file mode 100644 index 000000000..b0d9630a8 --- /dev/null +++ b/src/advanced/components/cart/CartList.tsx @@ -0,0 +1,71 @@ +import { FilledCartItem } from "../../domain/cart/cartTypes"; + +import { CartIcon } from "../icon/CartIcon"; +import { EmptyCartIcon } from "../icon/EmptyCartIcon"; +import { CartItemRow } from "./CartItemRow"; + +interface CartListProps { + cart: FilledCartItem[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; +} + +const EmptyCartList = () => { + return ( +
+ +

장바구니가 비어있습니다

+
+ ); +}; + +const FilledCartList = ({ + cart, + removeFromCart, + updateQuantity, +}: CartListProps) => { + return ( +
+ {cart.map((item) => { + const { itemTotal, hasDiscount, discountRate } = item.priceDetails; + return ( + removeFromCart(item.product.id)} + updateQuantity={updateQuantity} + /> + ); + })} +
+ ); +}; + +export const CartList = ({ + cart, + removeFromCart, + updateQuantity, +}: CartListProps) => { + return ( +
+

+ + 장바구니 +

+ {cart.length === 0 ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/src/advanced/components/cart/CartSidebar.tsx b/src/advanced/components/cart/CartSidebar.tsx new file mode 100644 index 000000000..c135f3029 --- /dev/null +++ b/src/advanced/components/cart/CartSidebar.tsx @@ -0,0 +1,43 @@ +import { CartSidebarProps } from "../../domain/product/productTypes"; + +import { formatCouponName } from "../../domain/cart/couponUtils"; + +import { CartList } from "./CartList"; +import { CouponSection } from "./CouponSection"; +import { PaymentSummary } from "./PaymentSummary"; + +export const CartSidebar = ({ + cartProps, + couponProps, + payment, +}: CartSidebarProps) => { + const { filledItems, removeFromCart, updateQuantity } = cartProps; + const { coupons, selectedCouponCode, selectorOnChange } = couponProps; + const { totals, completeOrder } = payment; + + return ( +
+ + {filledItems.length > 0 && ( + <> + 0} + selectedCouponCode={selectedCouponCode} + selectorOnChange={selectorOnChange} + /> + + + + )} +
+ ); +}; diff --git a/src/advanced/components/cart/CouponSection.tsx b/src/advanced/components/cart/CouponSection.tsx new file mode 100644 index 000000000..a49796d45 --- /dev/null +++ b/src/advanced/components/cart/CouponSection.tsx @@ -0,0 +1,41 @@ +import { Coupon } from "../../../types"; +import { Selector } from "../common/Selector"; + +interface CouponSectionProps { + coupons: Coupon[]; + showSelector: boolean; + selectedCouponCode?: string; + selectorOnChange: (e: React.ChangeEvent) => void; + onAddCoupon?: () => void; +} + +export const CouponSection = ({ + coupons, + selectedCouponCode, + showSelector = false, + selectorOnChange, + onAddCoupon, +}: CouponSectionProps) => { + return ( +
+
+

쿠폰 할인

+ +
+ {showSelector && ( + + )} +
+ ); +}; diff --git a/src/advanced/components/cart/PaymentSummary.tsx b/src/advanced/components/cart/PaymentSummary.tsx new file mode 100644 index 000000000..83098a89c --- /dev/null +++ b/src/advanced/components/cart/PaymentSummary.tsx @@ -0,0 +1,52 @@ +interface PaymentSummary { + totalBeforeDiscount: number; + totalAfterDiscount: number; + completeOrder: () => void; +} + +export const PaymentSummary = ({ + totalBeforeDiscount, + totalAfterDiscount, + completeOrder, +}: PaymentSummary) => { + const discountAmount = totalBeforeDiscount - totalAfterDiscount; + const discountText = `-${discountAmount.toLocaleString()}원`; + + return ( +
+

결제 정보

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

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

+
+
+ ); +}; diff --git a/src/advanced/components/common/CloseButton.tsx b/src/advanced/components/common/CloseButton.tsx new file mode 100644 index 000000000..19caafe54 --- /dev/null +++ b/src/advanced/components/common/CloseButton.tsx @@ -0,0 +1,9 @@ +import { CloseIcon } from "../icon/CloseIcon"; + +export const CloseButton = ({ onClick }: { onClick: () => void }) => { + return ( + + ); +}; diff --git a/src/advanced/components/common/FormInputField.tsx b/src/advanced/components/common/FormInputField.tsx new file mode 100644 index 000000000..5a5b781b4 --- /dev/null +++ b/src/advanced/components/common/FormInputField.tsx @@ -0,0 +1,34 @@ +interface FormInputFieldProps { + fieldName: string; + value: string | number; + onChange?: (e: React.ChangeEvent) => void; + onBlur?: (e: React.FocusEvent) => void; + placeholder?: string; + required?: boolean; // required 속성 제어 가능하도록 추가 +} + +export const FormInputField = ({ + fieldName, + value, + onChange, + onBlur, + placeholder, + required = true, // 기본값은 true (기존 동작 유지) +}: FormInputFieldProps) => { + return ( +
+ + +
+ ); +}; diff --git a/src/advanced/components/common/FormSelectField.tsx b/src/advanced/components/common/FormSelectField.tsx new file mode 100644 index 000000000..31eed5b07 --- /dev/null +++ b/src/advanced/components/common/FormSelectField.tsx @@ -0,0 +1,35 @@ +import { Selector } from "./Selector"; + +interface FormSelectFieldProps { + fieldName: string; + value: string | number; + options: T[]; + valueKey: keyof T; + labelKey: keyof T; + onChange?: (e: React.ChangeEvent) => void; +} + +export const FormSelectField = ({ + fieldName, + value, + options = [], + valueKey, + labelKey, + onChange, +}: FormSelectFieldProps) => { + return ( +
+ + +
+ ); +}; diff --git a/src/advanced/components/common/MinusButton.tsx b/src/advanced/components/common/MinusButton.tsx new file mode 100644 index 000000000..d5576345a --- /dev/null +++ b/src/advanced/components/common/MinusButton.tsx @@ -0,0 +1,14 @@ +interface MinusButtonProps { + className?: string; + onClick?: () => void; +} + +export const MinusButton = ({ className, onClick }: MinusButtonProps) => { + return ( + + ); +}; diff --git a/src/advanced/components/common/PlusButton.tsx b/src/advanced/components/common/PlusButton.tsx new file mode 100644 index 000000000..5ebd86613 --- /dev/null +++ b/src/advanced/components/common/PlusButton.tsx @@ -0,0 +1,14 @@ +interface PlusButtonProps { + className?: string; + onClick?: () => void; +} + +export const PlusButton = ({ className, onClick }: PlusButtonProps) => { + return ( + + ); +}; diff --git a/src/advanced/components/common/SearchBar.tsx b/src/advanced/components/common/SearchBar.tsx new file mode 100644 index 000000000..ef3f05492 --- /dev/null +++ b/src/advanced/components/common/SearchBar.tsx @@ -0,0 +1,23 @@ +interface SearchBarProps { + searchTerm: string; + setSearchTerm: (term: string) => void; + placeholder?: string; + className?: string; // 부모로부터 클래스 받기 +} + +export const SearchBar = ({ + searchTerm, + setSearchTerm, + placeholder = "검색...", + className = "", +}: SearchBarProps) => { + return ( + setSearchTerm(e.target.value)} + placeholder={placeholder} + className={`w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500 ${className}`} + /> + ); +}; diff --git a/src/advanced/components/common/Selector.tsx b/src/advanced/components/common/Selector.tsx new file mode 100644 index 000000000..6bb5e188b --- /dev/null +++ b/src/advanced/components/common/Selector.tsx @@ -0,0 +1,33 @@ +interface SelectorProps { + className?: string; + defaultValue?: string; + value?: string | number; + options: T[]; + onChange?: (e: React.ChangeEvent) => void; + valueKey: keyof T; // option value로 쓸 필드 + labelKey: keyof T; // option label로 쓸 필드 +} + +export const Selector = ({ + className, + defaultValue, + value, + options: data, + valueKey, + labelKey, + onChange, +}: SelectorProps) => { + return ( + + ); +}; diff --git a/src/advanced/components/common/Tabs.tsx b/src/advanced/components/common/Tabs.tsx new file mode 100644 index 000000000..e2a78fdbe --- /dev/null +++ b/src/advanced/components/common/Tabs.tsx @@ -0,0 +1,35 @@ +export interface TabItem { + key: T; + label: string; +} + +interface TabsProps { + items: TabItem[]; + activeKey: T; + onChange: (key: T) => void; +} + +export const Tabs = ({ + items, + activeKey, + onChange, +}: TabsProps) => { + return ( +
+ +
+ ); +}; diff --git a/src/advanced/components/icon/CartIcon.tsx b/src/advanced/components/icon/CartIcon.tsx new file mode 100644 index 000000000..75a78b86d --- /dev/null +++ b/src/advanced/components/icon/CartIcon.tsx @@ -0,0 +1,16 @@ +export const CartIcon = () => { + return ( + + + + ); +}; diff --git a/src/advanced/components/icon/CloseIcon.tsx b/src/advanced/components/icon/CloseIcon.tsx new file mode 100644 index 000000000..d8d5097cb --- /dev/null +++ b/src/advanced/components/icon/CloseIcon.tsx @@ -0,0 +1,16 @@ +export const CloseIcon = () => { + return ( + + + + ); +}; diff --git a/src/advanced/components/icon/EmptyCartIcon.tsx b/src/advanced/components/icon/EmptyCartIcon.tsx new file mode 100644 index 000000000..c881fed11 --- /dev/null +++ b/src/advanced/components/icon/EmptyCartIcon.tsx @@ -0,0 +1,16 @@ +export const EmptyCartIcon = () => { + return ( + + + + ); +}; diff --git a/src/advanced/components/icon/ImagePlaceholderIcon.tsx b/src/advanced/components/icon/ImagePlaceholderIcon.tsx new file mode 100644 index 000000000..78b7cb446 --- /dev/null +++ b/src/advanced/components/icon/ImagePlaceholderIcon.tsx @@ -0,0 +1,16 @@ +export const ImagePlaceholderIcon = () => { + return ( + + + + ); +}; diff --git a/src/advanced/components/icon/PlusIcon.tsx b/src/advanced/components/icon/PlusIcon.tsx new file mode 100644 index 000000000..dc689ffcb --- /dev/null +++ b/src/advanced/components/icon/PlusIcon.tsx @@ -0,0 +1,16 @@ +export const PlusIcon = () => { + return ( + + + + ); +}; diff --git a/src/advanced/components/icon/ShoppingCartIcon.tsx b/src/advanced/components/icon/ShoppingCartIcon.tsx new file mode 100644 index 000000000..8f13d06a0 --- /dev/null +++ b/src/advanced/components/icon/ShoppingCartIcon.tsx @@ -0,0 +1,16 @@ +export const ShoppingCartIcon = () => { + return ( + + + + ); +}; diff --git a/src/advanced/components/icon/TrashIcon.tsx b/src/advanced/components/icon/TrashIcon.tsx new file mode 100644 index 000000000..554a81e86 --- /dev/null +++ b/src/advanced/components/icon/TrashIcon.tsx @@ -0,0 +1,16 @@ +export const TrashIcon = () => { + return ( + + + + ); +}; diff --git a/src/advanced/components/layouts/DefaultLayout.tsx b/src/advanced/components/layouts/DefaultLayout.tsx new file mode 100644 index 000000000..2b01267d8 --- /dev/null +++ b/src/advanced/components/layouts/DefaultLayout.tsx @@ -0,0 +1,28 @@ +import { ReactNode } from "react"; +import { Header } from "./Header"; + +interface HeaderProps { + headerLeft?: React.ReactNode; + headerRight?: React.ReactNode; +} + +interface DefaultLayoutProps { + topContent?: ReactNode; + headerProps: HeaderProps; + children: React.ReactNode; +} + +export const DefaultLayout = ({ + topContent, + headerProps, + children, +}: DefaultLayoutProps) => { + return ( +
+ {topContent} + +
+
{children}
+
+ ); +}; diff --git a/src/advanced/components/layouts/Header.tsx b/src/advanced/components/layouts/Header.tsx new file mode 100644 index 000000000..5b1aac18d --- /dev/null +++ b/src/advanced/components/layouts/Header.tsx @@ -0,0 +1,22 @@ +interface HeaderProps { + headerLeft?: React.ReactNode; + headerRight?: React.ReactNode; +} + +export const Header = ({ headerLeft, headerRight }: HeaderProps) => { + return ( +
+
+
+
+

SHOP

+ {headerLeft && ( +
{headerLeft}
+ )} +
+ +
+
+
+ ); +}; diff --git a/src/advanced/components/layouts/HeaderActions.tsx b/src/advanced/components/layouts/HeaderActions.tsx new file mode 100644 index 000000000..391da34ce --- /dev/null +++ b/src/advanced/components/layouts/HeaderActions.tsx @@ -0,0 +1,40 @@ +import { CartItem } from "../../../types"; +import { ShoppingCartIcon } from "../icon/ShoppingCartIcon"; + +interface HeaderActionsProps { + isAdmin: boolean; + setIsAdmin: React.Dispatch>; + cart: CartItem[]; + totalItemCount: number; +} + +export const HeaderActions = ({ + isAdmin, + setIsAdmin, + cart, + totalItemCount, +}: HeaderActionsProps) => { + return ( + <> + + {!isAdmin && ( +
+ + {cart.length > 0 && ( + + {totalItemCount} + + )} +
+ )} + + ); +}; diff --git a/src/advanced/components/notifications/Notification.tsx b/src/advanced/components/notifications/Notification.tsx new file mode 100644 index 000000000..e863bf4d2 --- /dev/null +++ b/src/advanced/components/notifications/Notification.tsx @@ -0,0 +1,25 @@ +import { Notification } from "../../domain/notification/notificationTypes"; +import { NotificationItem } from "./NotificationItem"; + +interface NotificationProps { + notifications: Notification[]; + setNotifications: (notifications: Notification[]) => void; +} + +export const Notifications = ({ + notifications, + setNotifications, +}: NotificationProps) => { + return ( +
+ {notifications.map((notif) => ( + + ))} +
+ ); +}; diff --git a/src/advanced/components/notifications/NotificationItem.tsx b/src/advanced/components/notifications/NotificationItem.tsx new file mode 100644 index 000000000..b1df3c989 --- /dev/null +++ b/src/advanced/components/notifications/NotificationItem.tsx @@ -0,0 +1,46 @@ +import { Notification } from "../../domain/notification/notificationTypes"; + +interface NotificationItemProps { + notif: Notification; + setNotifications: (notifications: Notification[]) => void; + notifications: Notification[]; +} + +export const NotificationItem = ({ + notif, + setNotifications, + notifications, +}: NotificationItemProps) => { + const { id, type, message } = notif; + return ( +
+ {message} + +
+ ); +}; diff --git a/src/advanced/components/product/ProductItem.tsx b/src/advanced/components/product/ProductItem.tsx new file mode 100644 index 000000000..dd334c3b9 --- /dev/null +++ b/src/advanced/components/product/ProductItem.tsx @@ -0,0 +1,91 @@ +import { CartItem } from "../../../types"; +import { PriceType } from "../../constans/constans"; +import { getDisplayPrice } from "../../domain/cart/cartUtils"; +import { ProductWithUI } from "../../domain/product/productTypes"; +import { ImagePlaceholderIcon } from "../icon/ImagePlaceholderIcon"; + +interface ProductItemProps { + format: PriceType; + cart: CartItem[]; + product: ProductWithUI; + remainingStock: number; + addToCart: (product: ProductWithUI) => void; +} + +export const ProductItem = ({ + format, + cart, + product, + remainingStock, + addToCart, +}: ProductItemProps) => { + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

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

+ {getDisplayPrice(cart, product, format)} +

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

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

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

+ 품절임박! {remainingStock}개 남음 +

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

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; diff --git a/src/advanced/components/product/ProductList.tsx b/src/advanced/components/product/ProductList.tsx new file mode 100644 index 000000000..f689fcab0 --- /dev/null +++ b/src/advanced/components/product/ProductList.tsx @@ -0,0 +1,60 @@ +import { CartItem } from "../../../types"; +import { PriceType } from "../../constans/constans"; +import { getRemainingStock } from "../../domain/cart/cartUtils"; +import { ProductWithUI } from "../../domain/product/productTypes"; +import { ProductItem } from "./ProductItem"; + +interface ProductListProps { + format: PriceType; + cart: CartItem[]; + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + addToCart: (product: ProductWithUI) => void; +} + +export const ProductList = ({ + format, + cart, + products, + filteredProducts, + debouncedSearchTerm, + addToCart, +}: ProductListProps) => { + const EmptyProduct = () => { + return ( +
+

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

+
+ ); + }; + return ( +
+
+

전체 상품

+
총 {products.length}개 상품
+
+ {filteredProducts.length === 0 ? ( + + ) : ( +
+ {filteredProducts.map((product) => { + const remainingStock = getRemainingStock(cart, product); + return ( + + ); + })} +
+ )} +
+ ); +}; diff --git a/src/advanced/constans/constans.ts b/src/advanced/constans/constans.ts new file mode 100644 index 000000000..3f943c692 --- /dev/null +++ b/src/advanced/constans/constans.ts @@ -0,0 +1,9 @@ +export enum PriceType { + KR = "kr", + EN = "en", +} + +export enum DiscountType { + AMOUNT = "amount", + PRECENTAGE = "percentage", +} diff --git a/src/advanced/domain/cart/cartTypes.ts b/src/advanced/domain/cart/cartTypes.ts new file mode 100644 index 000000000..1d9df239b --- /dev/null +++ b/src/advanced/domain/cart/cartTypes.ts @@ -0,0 +1,9 @@ +import { CartItem } from "../../../types"; + +export type FilledCartItem = CartItem & { + priceDetails: { + itemTotal: number; + hasDiscount: boolean; + discountRate: number; + }; +}; diff --git a/src/advanced/domain/cart/cartUtils.ts b/src/advanced/domain/cart/cartUtils.ts new file mode 100644 index 000000000..cb013e958 --- /dev/null +++ b/src/advanced/domain/cart/cartUtils.ts @@ -0,0 +1,156 @@ +import { CartItem, Coupon, Product } from "../../../types"; +import { PriceType } from "../../constans/constans"; +import { formatPrice } from "../../utils/formatters"; +import { ProductWithUI } from "../product/productTypes"; + +/** ============================= + * 정책 상수 + * ============================== */ +export const BULK_EXTRA_DISCOUNT = 0.05; // 대량 구매 시 추가 할인율 (5% 할인) +export const MAX_DISCOUNT_RATE = 0.5; // 총 할인율 최대 상한(50%) +export const BULK_PURCHASE_THRESHOLD = 10; // 대량 구매 기준 수량 + +/** ============================= + * 순수 계산 함수 + * ============================== */ + +// 장바구니에서 대량 구매 아이템이 있는지 확인 +export const hasBulkPurchase = (quantities: number[]): boolean => + quantities.some((q) => q >= BULK_PURCHASE_THRESHOLD); + +// 특정 CartItem에 대해 '기본 할인율'을 계산 +export const getBaseDiscount = (item: CartItem): number => { + const { quantity } = item; + + const applicableDiscounts = item.product.discounts + .filter((d) => quantity >= d.quantity) + .map((d) => d.rate); + + return applicableDiscounts.length ? Math.max(...applicableDiscounts) : 0; +}; + +/** + * 최종 할인율 계산 (순수 함수) + * @param baseDiscount 기본 할인율 + * @param bulkBonus 대량 구매 보너스 할인율 + * @returns 최종 할인율 (상한 적용) + */ +export const calculateFinalDiscount = ( + baseDiscount: number, + bulkBonus: number +): number => { + return Math.min(baseDiscount + bulkBonus, MAX_DISCOUNT_RATE); +}; + +// 최종 할인 계산 함수 (순수 함수 형태를 유지하기 위해 cart 인자 추가) +export const getMaxApplicableDiscount = ( + item: CartItem, + cart: CartItem[] +): number => { + const baseDiscount = getBaseDiscount(item); + const bulkBonus = hasBulkPurchase(cart.map((i) => i.quantity)) + ? BULK_EXTRA_DISCOUNT + : 0; + + return calculateFinalDiscount(baseDiscount, bulkBonus); +}; + +// 단일 아이템 최종 금액 계산 +export const calculateItemTotal = ( + price: number, + quantity: number, + discount: number +): number => { + return Math.round(price * quantity * (1 - discount)); +}; + +// 장바구니 총액 계산 (쿠폰 적용 포함) +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + const totalBeforeDiscount = cart.reduce( + (sum, item) => sum + item.product.price * item.quantity, + 0 + ); + + const totalAfterItemDiscount = cart.reduce( + (sum, item) => + sum + + calculateItemTotal( + item.product.price, + item.quantity, + getMaxApplicableDiscount(item, cart) + ), + 0 + ); + + const totalAfterDiscount = selectedCoupon + ? applyCoupon(totalAfterItemDiscount, selectedCoupon) + : totalAfterItemDiscount; + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; + +// "총 금액 + 할인 여부 + 할인율” 같은 세부 정보를 반환하는 함수 +export const calculateItemPriceDetails = (item: CartItem, cart: CartItem[]) => { + const itemTotal = calculateItemTotal( + item.product.price, + item.quantity, + getMaxApplicableDiscount(item, cart) + ); + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount + ? Math.round((1 - itemTotal / originalPrice) * 100) + : 0; + return { itemTotal, hasDiscount, discountRate }; +}; + +export const getDisplayPrice = ( + cart: CartItem[], + product: ProductWithUI, + format: PriceType +): string => { + if (isSoldOut(cart, product, product.id)) { + return "SOLD OUT"; + } + + return formatPrice(product.price, format); +}; + +// 재고 없는지 여부 확인 +export const isSoldOut = ( + cart: CartItem[], + product: ProductWithUI, + productId?: string +): boolean => { + if (!productId) return false; + return product ? getRemainingStock(cart, product) <= 0 : false; +}; + +// 재고 잔량 확인 +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 applyCoupon = (amount: number, coupon: Coupon): number => { + if (coupon.discountType === "amount") { + return Math.max(0, amount - coupon.discountValue); + } + // percent 타입 + return Math.round(amount * (1 - coupon.discountValue / 100)); +}; diff --git a/src/advanced/domain/cart/couponUtils.ts b/src/advanced/domain/cart/couponUtils.ts new file mode 100644 index 000000000..fc79853d0 --- /dev/null +++ b/src/advanced/domain/cart/couponUtils.ts @@ -0,0 +1,16 @@ +import { Coupon } from "../../../types"; + +export const formatCouponName = (coupons: Coupon[]) => { + // 배열이 아닌 경우 빈 배열 반환 (안전장치) + if (!Array.isArray(coupons)) { + return []; + } + return coupons.map((coupon) => ({ + ...coupon, + name: `${coupon.name} (${ + coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원` + : `${coupon.discountValue}%` + })`, + })); +}; diff --git a/src/advanced/domain/notification/notificationTypes.ts b/src/advanced/domain/notification/notificationTypes.ts new file mode 100644 index 000000000..9f97fcbb6 --- /dev/null +++ b/src/advanced/domain/notification/notificationTypes.ts @@ -0,0 +1,5 @@ +export interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} diff --git a/src/advanced/domain/product/productTypes.ts b/src/advanced/domain/product/productTypes.ts new file mode 100644 index 000000000..0d21b6ca5 --- /dev/null +++ b/src/advanced/domain/product/productTypes.ts @@ -0,0 +1,45 @@ +import { CartItem, Coupon, Discount, Product } from "../../../types"; +import { FilledCartItem } from "../cart/cartTypes"; + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export interface StorePageProps { + productProps: ProductListProps; + cartSidebarProps: CartSidebarProps; +} + +export interface ProductListProps { + cart: CartItem[]; + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + addToCart: (product: ProductWithUI) => void; +} + +export interface CartSidebarProps { + cartProps: { + filledItems: FilledCartItem[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + }; + couponProps: { + coupons: Coupon[]; + selectedCouponCode: string; + selectorOnChange: (e: React.ChangeEvent) => void; + }; + payment: { + totals: { totalBeforeDiscount: number; totalAfterDiscount: number }; + completeOrder: () => void; + }; +} + +export interface ProductForm { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} diff --git a/src/advanced/domain/product/productUtils.ts b/src/advanced/domain/product/productUtils.ts new file mode 100644 index 000000000..fc6736a87 --- /dev/null +++ b/src/advanced/domain/product/productUtils.ts @@ -0,0 +1,20 @@ +import { ProductWithUI } from "./productTypes"; + +export const filterProductsBySearchTerm = ( + debouncedSearchTerm: string, + products: Array +) => { + const filteredProducts = debouncedSearchTerm + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase())) + ) + : products; + return filteredProducts; +}; diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts new file mode 100644 index 000000000..b3ca9cce9 --- /dev/null +++ b/src/advanced/hooks/useCart.ts @@ -0,0 +1,153 @@ +import { useState, useCallback, useEffect, useMemo } from "react"; +import { CartItem } from "../../types"; +import { ProductWithUI } from "../domain/product/productTypes"; +import { + calculateItemPriceDetails, + getRemainingStock, +} from "../domain/cart/cartUtils"; +import { FilledCartItem } from "../domain/cart/cartTypes"; + +/** + * 장바구니 Entity 관련 상태 및 로직을 관리하는 Hook + * + * Entity를 다루는 Hook + * - Cart Entity의 상태 관리 및 로직 + */ +export const useCart = ( + products: ProductWithUI[], + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void +) => { + const [cart, setCart] = useState(() => { + const saved = localStorage.getItem("cart"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return []; + } + } + return []; + }); + + const [totalItemCount, setTotalItemCount] = useState(0); + + // localStorage 동기화 + useEffect(() => { + if (cart.length > 0) { + localStorage.setItem("cart", JSON.stringify(cart)); + } else { + localStorage.removeItem("cart"); + } + }, [cart]); + + // 장바구니 아이템 총 개수 계산 + useEffect(() => { + const count = cart.reduce((sum, item) => sum + item.quantity, 0); + setTotalItemCount(count); + }, [cart]); + + // 장바구니 아이템에 가격 정보 추가 (계산된 값) + const filledItems = useMemo( + (): FilledCartItem[] => + cart.map((item) => ({ + ...item, + priceDetails: calculateItemPriceDetails(item, cart), + })), + [cart] + ); + + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(cart, 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] + ); + + 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] + ); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + "success" + ); + setCart([]); + // selectedCoupon 초기화는 useCoupon의 책임이므로 여기서는 cart만 초기화 + }, [addNotification]); + + return { + cart, + totalItemCount, + filledItems, + addToCart, + removeFromCart, + updateQuantity, + completeOrder, + }; +}; diff --git a/src/advanced/hooks/useCoupon.ts b/src/advanced/hooks/useCoupon.ts new file mode 100644 index 000000000..e6ff40303 --- /dev/null +++ b/src/advanced/hooks/useCoupon.ts @@ -0,0 +1,142 @@ +import { useState, useCallback, useEffect } from "react"; +import { Coupon } from "../../types"; +import { calculateCartTotal } from "../domain/cart/cartUtils"; +import { CartItem } from "../../types"; + +// 초기 쿠폰 데이터 +const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; + +/** + * 쿠폰 Entity 관련 상태 및 로직을 관리하는 Hook + * + * Entity를 다루는 Hook + * - Coupon Entity의 상태 관리 및 CRUD 로직 + */ +export const useCoupon = ( + cart: CartItem[], + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void +) => { + 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 [couponForm, setCouponForm] = useState({ + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0, + }); + + const [showCouponForm, setShowCouponForm] = useState(false); + + // localStorage 동기화 + useEffect(() => { + localStorage.setItem("coupons", JSON.stringify(coupons)); + }, [coupons]); + + 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 applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal( + cart, + selectedCoupon + ).totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + addNotification( + "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", + "error" + ); + return; + } + + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + [cart, selectedCoupon, addNotification] + ); + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm({ + name: "", + code: "", + discountType: "amount", + discountValue: 0, + }); + setShowCouponForm(false); + }; + + const selectorOnChange = (e: React.ChangeEvent) => { + const coupon = coupons.find((c) => c.code === e.target.value); + if (coupon) { + applyCoupon(coupon); + } else { + setSelectedCoupon(null); + } + }; + + return { + coupons, + selectedCoupon, + couponForm, + showCouponForm, + setSelectedCoupon, + setCouponForm, + setShowCouponForm, + addCoupon, + deleteCoupon, + applyCoupon, + handleCouponSubmit, + selectorOnChange, + }; +}; diff --git a/src/advanced/hooks/useNotification.ts b/src/advanced/hooks/useNotification.ts new file mode 100644 index 000000000..218ce2000 --- /dev/null +++ b/src/advanced/hooks/useNotification.ts @@ -0,0 +1,31 @@ +import { useState, useCallback } from "react"; +import { Notification } from "../domain/notification/notificationTypes"; + +/** + * 알림 관련 상태 및 로직을 관리하는 Hook + * + * Entity를 다루지 않는 UI 상태 Hook + * - 알림은 비즈니스 엔티티가 아닌 UI 피드백 + */ +export const useNotification = () => { + const [notifications, setNotifications] = useState([]); + + 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); + }, + [] + ); + + return { + notifications, + setNotifications, + addNotification, + }; +}; + diff --git a/src/advanced/hooks/useProduct.ts b/src/advanced/hooks/useProduct.ts new file mode 100644 index 000000000..be24a993e --- /dev/null +++ b/src/advanced/hooks/useProduct.ts @@ -0,0 +1,156 @@ +import { useState, useCallback, useEffect } from "react"; +import { Discount } from "../../types"; +import { ProductForm, ProductWithUI } from "../domain/product/productTypes"; + +// 초기 데이터 +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: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; + +/** + * 상품 Entity 관련 상태 및 로직을 관리하는 Hook + * + * Entity를 다루는 Hook + * - Product Entity의 상태 관리 및 CRUD 로직 + */ +export const useProduct = (addNotification: (message: string, type?: "error" | "success" | "warning") => void) => { + const [products, setProducts] = useState(() => { + const saved = localStorage.getItem("products"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialProducts; + } + } + return initialProducts; + }); + + const [productForm, setProductForm] = useState({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] as Array, + }); + + const [editingProduct, setEditingProduct] = useState(null); + const [showProductForm, setShowProductForm] = useState(false); + + // localStorage 동기화 + useEffect(() => { + localStorage.setItem("products", JSON.stringify(products)); + }, [products]); + + 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 startEditProduct = useCallback((product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [], + }); + setShowProductForm(true); + }, []); + + 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); + }; + + return { + products, + productForm, + editingProduct, + showProductForm, + setProductForm, + setEditingProduct, + setShowProductForm, + addProduct, + updateProduct, + deleteProduct, + startEditProduct, + handleProductSubmit, + }; +}; + diff --git a/src/advanced/hooks/useSearch.ts b/src/advanced/hooks/useSearch.ts new file mode 100644 index 000000000..6612cb266 --- /dev/null +++ b/src/advanced/hooks/useSearch.ts @@ -0,0 +1,26 @@ +import { useState, useEffect } from "react"; + +/** + * 검색어 상태 및 디바운스 처리를 관리하는 Hook + * + * Entity를 다루지 않는 UI 상태 Hook + * - 검색어는 UI 입력 상태 + */ +export const useSearch = () => { + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 500); + return () => clearTimeout(timer); + }, [searchTerm]); + + return { + searchTerm, + setSearchTerm, + debouncedSearchTerm, + }; +}; + diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx new file mode 100644 index 000000000..7b3ef94c4 --- /dev/null +++ b/src/advanced/pages/AdminPage.tsx @@ -0,0 +1,39 @@ +import { AdminTabKey, AdminTabs } from "../components/admin/common/AdminTabs"; +import { + AdminCouponSection, + AdminCouponSectionProps, +} from "../components/admin/coupon/AdminCouponSection"; +import { + AdminProductsSection, + AdminProductsSectionProps, +} from "../components/admin/product/AdminProductsSection"; + +interface AdminPageProps { + activeTab: AdminTabKey; + adminProductsProps: AdminProductsSectionProps; + adminCouponProps: AdminCouponSectionProps; + setActiveTab: React.Dispatch>; +} + +export const AdminPage = ({ + activeTab, + adminProductsProps, + adminCouponProps, + setActiveTab, +}: AdminPageProps) => { + return ( +
+
+

관리자 대시보드

+

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

+
+ + + {activeTab === "products" ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/src/advanced/pages/StorePage.tsx b/src/advanced/pages/StorePage.tsx new file mode 100644 index 000000000..997beb11d --- /dev/null +++ b/src/advanced/pages/StorePage.tsx @@ -0,0 +1,46 @@ +import { CartSidebar } from "../components/cart/CartSidebar"; +import { ProductList } from "../components/product/ProductList"; +import { PriceType } from "../constans/constans"; +import { + CartSidebarProps, + ProductListProps, +} from "../domain/product/productTypes"; + +interface StorePageProps { + productProps: ProductListProps; + cartSidebarProps: CartSidebarProps; +} + +export const StorePage = ({ + productProps, + cartSidebarProps, +}: StorePageProps) => { + const format = PriceType.KR; + + const { cart, products, filteredProducts, debouncedSearchTerm, addToCart } = + productProps; + const { cartProps, couponProps, payment } = cartSidebarProps; + return ( +
+
+ {/* 상품 목록 */} + +
+ +
+ +
+
+ ); +}; diff --git a/src/advanced/stores/useCartStore.ts b/src/advanced/stores/useCartStore.ts new file mode 100644 index 000000000..98d054084 --- /dev/null +++ b/src/advanced/stores/useCartStore.ts @@ -0,0 +1,209 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { CartItem } from "../../types"; +import { ProductWithUI } from "../domain/product/productTypes"; +import { + calculateItemPriceDetails, + getRemainingStock, +} from "../domain/cart/cartUtils"; +import { FilledCartItem } from "../domain/cart/cartTypes"; +import { useProductStore } from "./useProductStore"; +import { useNotificationStore } from "./useNotificationStore"; + +/** + * 장바구니 Entity 관련 전역 상태를 관리하는 Zustand Store + * + * Entity를 다루는 Store + * - Cart Entity의 상태 관리 및 로직 + * - localStorage 동기화 (persist 미들웨어 사용) + * - useProductStore, useNotificationStore 의존 + */ +interface CartState { + // 상태 + cart: CartItem[]; + + // 액션 + addToCart: (product: ProductWithUI) => void; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + completeOrder: () => void; + + // 계산된 값 (함수로 제공) + getTotalItemCount: () => number; + getFilledItems: () => FilledCartItem[]; +} + +// localStorage에서 동기적으로 초기 상태 읽기 (origin과 동일한 방식) +const getInitialCart = (): CartItem[] => { + const saved = localStorage.getItem("cart"); + if (saved) { + try { + const parsed = JSON.parse(saved); + // 배열이 직접 저장되거나 { state: { cart: [...] } } 형태일 수 있음 + if (Array.isArray(parsed)) { + return parsed; + } + if (parsed && parsed.state && Array.isArray(parsed.state.cart)) { + return parsed.state.cart; + } + return []; + } catch { + return []; + } + } + return []; +}; + +export const useCartStore = create()( + persist( + (set, get) => { + // 초기 상태: localStorage에서 동기적으로 읽기 (origin과 동일) + const initialCartState = getInitialCart(); + + return { + cart: initialCartState, + + // 장바구니 아이템 총 개수 (계산된 값) + getTotalItemCount: () => { + const cart = get().cart; + if (!Array.isArray(cart)) return 0; + return cart.reduce((sum, item) => sum + item.quantity, 0); + }, + + // 장바구니 아이템에 가격 정보 추가 (계산된 값) + getFilledItems: () => { + const cart = get().cart; + if (!Array.isArray(cart)) return []; + return cart.map((item) => ({ + ...item, + priceDetails: calculateItemPriceDetails(item, cart), + })); + }, + + // 장바구니에 추가 + addToCart: (product) => { + const state = get(); + const cart = Array.isArray(state.cart) ? state.cart : []; + const remainingStock = getRemainingStock(cart, product); + + if (remainingStock <= 0) { + useNotificationStore.getState().addNotification( + "재고가 부족합니다!", + "error" + ); + return; + } + + const existingItem = cart.find( + (item) => item.product.id === product.id + ); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + useNotificationStore.getState().addNotification( + `재고는 ${product.stock}개까지만 있습니다.`, + "error" + ); + return; + } + + set({ + cart: Array.isArray(cart) ? cart.map((item) => + item.product.id === product.id + ? { ...item, quantity: newQuantity } + : item + ) : [], + }); + } else { + set({ + cart: Array.isArray(cart) ? [...cart, { product, quantity: 1 }] : [{ product, quantity: 1 }], + }); + } + + useNotificationStore.getState().addNotification( + "장바구니에 담았습니다", + "success" + ); + }, + + // 장바구니에서 제거 + removeFromCart: (productId) => { + set((state) => { + const cart = Array.isArray(state.cart) ? state.cart : []; + return { + cart: cart.filter((item) => item.product.id !== productId), + }; + }); + }, + + // 수량 업데이트 + updateQuantity: (productId, newQuantity) => { + if (newQuantity <= 0) { + get().removeFromCart(productId); + return; + } + + const products = useProductStore.getState().products; + const product = products.find((p) => p.id === productId); + if (!product) return; + + const maxStock = product.stock; + if (newQuantity > maxStock) { + useNotificationStore.getState().addNotification( + `재고는 ${maxStock}개까지만 있습니다.`, + "error" + ); + return; + } + + set((state) => { + const cart = Array.isArray(state.cart) ? state.cart : []; + return { + cart: cart.map((item) => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ), + }; + }); + }, + + // 주문 완료 + completeOrder: () => { + const orderNumber = `ORD-${Date.now()}`; + useNotificationStore.getState().addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + "success" + ); + set({ cart: [] }); + }, + }; + }, + { + name: "cart", // localStorage 키 (origin과 동일) + partialize: (state) => ({ cart: state.cart }), // cart만 저장 + // storage 옵션 제거: App.tsx의 useEffect가 배열을 직접 저장하므로 + // persist는 내부적으로만 사용하고, 실제 저장은 useEffect가 담당 + skipHydration: true, + } + ) +); + +// Store 초기화 시 localStorage에서 동기적으로 복원 (skipHydration: true 사용 시 필요) +// 테스트 환경에서는 실행하지 않음 (beforeEach에서 초기화) +if (typeof window !== "undefined" && process.env.NODE_ENV !== "test") { + const saved = localStorage.getItem("cart"); + if (saved) { + try { + const parsed = JSON.parse(saved); + if (Array.isArray(parsed)) { + useCartStore.setState({ cart: parsed }); + } + } catch { + // 에러 무시 (초기값 사용) + } + } +} + diff --git a/src/advanced/stores/useCouponStore.ts b/src/advanced/stores/useCouponStore.ts new file mode 100644 index 000000000..f75a74b3a --- /dev/null +++ b/src/advanced/stores/useCouponStore.ts @@ -0,0 +1,223 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { Coupon } from "../../types"; +import { calculateCartTotal } from "../domain/cart/cartUtils"; +import { useCartStore } from "./useCartStore"; +import { useNotificationStore } from "./useNotificationStore"; + +// 초기 쿠폰 데이터 +const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; + +/** + * 쿠폰 Entity 관련 전역 상태를 관리하는 Zustand Store + * + * Entity를 다루는 Store + * - Coupon Entity의 상태 관리 및 CRUD 로직 + * - localStorage 동기화 (persist 미들웨어 사용) + * - useCartStore, useNotificationStore 의존 + */ +interface CouponState { + // 상태 + coupons: Coupon[]; + selectedCoupon: Coupon | null; + couponForm: Coupon; + showCouponForm: boolean; + + // 액션 + addCoupon: (newCoupon: Coupon) => void; + deleteCoupon: (couponCode: string) => void; + applyCoupon: (coupon: Coupon) => void; + handleCouponSubmit: (e: React.FormEvent) => void; + selectorOnChange: (e: React.ChangeEvent) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; + setCouponForm: (form: Coupon | ((prev: Coupon) => Coupon)) => void; + setShowCouponForm: (show: boolean) => void; +} + +// localStorage에서 동기적으로 초기 상태 읽기 (origin과 동일한 방식) +const getInitialCoupons = (): Coupon[] => { + const saved = localStorage.getItem("coupons"); + if (saved) { + try { + const parsed = JSON.parse(saved); + // 배열이 직접 저장되거나 { state: { coupons: [...] } } 형태일 수 있음 + if (Array.isArray(parsed)) { + return parsed; + } + if (parsed && parsed.state && Array.isArray(parsed.state.coupons)) { + return parsed.state.coupons; + } + return initialCoupons; + } catch { + return initialCoupons; + } + } + return initialCoupons; +}; + +export const useCouponStore = create()( + persist( + (set, get) => { + // 초기 상태: localStorage에서 동기적으로 읽기 (origin과 동일) + const initialCouponsState = getInitialCoupons(); + + return { + coupons: initialCouponsState, + selectedCoupon: null, + couponForm: { + name: "", + code: "", + discountType: "amount", + discountValue: 0, + }, + showCouponForm: false, + + // 쿠폰 추가 + addCoupon: (newCoupon) => { + const state = get(); + const currentCoupons = Array.isArray(state.coupons) ? state.coupons : []; + const existingCoupon = currentCoupons.find( + (c) => c.code === newCoupon.code + ); + if (existingCoupon) { + useNotificationStore.getState().addNotification( + "이미 존재하는 쿠폰 코드입니다.", + "error" + ); + return; + } + set((state) => ({ + coupons: Array.isArray(state.coupons) ? [...state.coupons, newCoupon] : [newCoupon], + })); + useNotificationStore.getState().addNotification( + "쿠폰이 추가되었습니다.", + "success" + ); + }, + + // 쿠폰 삭제 + deleteCoupon: (couponCode) => { + const state = get(); + set((state) => ({ + coupons: Array.isArray(state.coupons) + ? state.coupons.filter((c) => c.code !== couponCode) + : [], + })); + if (state.selectedCoupon?.code === couponCode) { + set({ selectedCoupon: null }); + } + useNotificationStore.getState().addNotification( + "쿠폰이 삭제되었습니다.", + "success" + ); + }, + + // 쿠폰 적용 + applyCoupon: (coupon) => { + const cart = useCartStore.getState().cart; + const state = get(); + const currentTotal = calculateCartTotal( + cart, + state.selectedCoupon + ).totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + useNotificationStore.getState().addNotification( + "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", + "error" + ); + return; + } + + set({ selectedCoupon: coupon }); + useNotificationStore.getState().addNotification( + "쿠폰이 적용되었습니다.", + "success" + ); + }, + + // 쿠폰 폼 제출 + handleCouponSubmit: (e) => { + e.preventDefault(); + const state = get(); + state.addCoupon(state.couponForm); + set({ + couponForm: { + name: "", + code: "", + discountType: "amount", + discountValue: 0, + }, + showCouponForm: false, + }); + }, + + // 쿠폰 선택 변경 + selectorOnChange: (e) => { + const state = get(); + const currentCoupons = Array.isArray(state.coupons) ? state.coupons : []; + const coupon = currentCoupons.find((c) => c.code === e.target.value); + if (coupon) { + state.applyCoupon(coupon); + } else { + set({ selectedCoupon: null }); + } + }, + + // 선택된 쿠폰 설정 + setSelectedCoupon: (coupon) => { + set({ selectedCoupon: coupon }); + }, + + // 쿠폰 폼 설정 (함수형 업데이트 지원) + setCouponForm: (form) => { + set((state) => ({ + couponForm: + typeof form === "function" ? form(state.couponForm) : form, + })); + }, + + // 폼 표시 여부 설정 + setShowCouponForm: (show) => { + set({ showCouponForm: show }); + }, + }; + }, + { + name: "coupons", // localStorage 키 (origin과 동일) + partialize: (state) => ({ coupons: state.coupons }), // coupons만 저장 + // 테스트 환경에서도 동기적으로 작동하도록 skipHydration 사용 + skipHydration: true, + } + ) +); + +// Store 초기화 시 localStorage에서 동기적으로 복원 (skipHydration: true 사용 시 필요) +// 테스트 환경에서는 실행하지 않음 (beforeEach에서 초기화) +if (typeof window !== "undefined" && process.env.NODE_ENV !== "test") { + const saved = localStorage.getItem("coupons"); + if (saved) { + try { + const parsed = JSON.parse(saved); + if (Array.isArray(parsed)) { + useCouponStore.setState({ coupons: parsed }); + } + } catch (error) { + // 에러는 조용히 무시 (초기 상태 사용) + } + } +} + diff --git a/src/advanced/stores/useNotificationStore.ts b/src/advanced/stores/useNotificationStore.ts new file mode 100644 index 000000000..6662c4114 --- /dev/null +++ b/src/advanced/stores/useNotificationStore.ts @@ -0,0 +1,57 @@ +import { create } from "zustand"; +import { Notification } from "../domain/notification/notificationTypes"; + +/** + * 알림 관련 전역 상태를 관리하는 Zustand Store + * + * Entity를 다루지 않는 UI 상태 Store + * - 알림은 비즈니스 엔티티가 아닌 UI 피드백 + * - 의존성이 없어 가장 먼저 구현 + */ +interface NotificationState { + // 상태 + notifications: Notification[]; + + // 액션 + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void; + removeNotification: (id: string) => void; + setNotifications: (notifications: Notification[]) => void; +} + +export const useNotificationStore = create((set) => ({ + // 초기 상태 + notifications: [], + + // 알림 추가 (3초 후 자동 제거) + addNotification: (message, type = "success") => { + const id = Date.now().toString(); + const notification: Notification = { id, message, type }; + + set((state) => ({ + notifications: [...state.notifications, notification], + })); + + // 3초 후 자동 제거 + setTimeout(() => { + set((state) => ({ + notifications: state.notifications.filter((n) => n.id !== id), + })); + }, 3000); + }, + + // 알림 제거 + removeNotification: (id) => { + set((state) => ({ + notifications: state.notifications.filter((n) => n.id !== id), + })); + }, + + // 알림 목록 설정 (외부에서 직접 제어할 때 사용) + setNotifications: (notifications) => { + set({ notifications }); + }, +})); + diff --git a/src/advanced/stores/useProductStore.ts b/src/advanced/stores/useProductStore.ts new file mode 100644 index 000000000..948462d36 --- /dev/null +++ b/src/advanced/stores/useProductStore.ts @@ -0,0 +1,233 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { Discount } from "../../types"; +import { ProductForm, ProductWithUI } from "../domain/product/productTypes"; +import { useNotificationStore } from "./useNotificationStore"; + +// 초기 상품 데이터 +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: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; + +/** + * 상품 Entity 관련 전역 상태를 관리하는 Zustand Store + * + * Entity를 다루는 Store + * - Product Entity의 상태 관리 및 CRUD 로직 + * - localStorage 동기화 (persist 미들웨어 사용) + * - useNotificationStore 의존 + */ +interface ProductState { + // 상태 + products: ProductWithUI[]; + productForm: ProductForm; + editingProduct: string | null; + showProductForm: boolean; + + // 액션 + addProduct: (newProduct: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; + startEditProduct: (product: ProductWithUI) => void; + handleProductSubmit: (e: React.FormEvent) => void; + setProductForm: (form: ProductForm | ((prev: ProductForm) => ProductForm)) => void; + setEditingProduct: (id: string | null) => void; + setShowProductForm: (show: boolean) => void; +} + +// localStorage에서 동기적으로 초기 상태 읽기 (origin과 동일한 방식) +const getInitialProducts = (): ProductWithUI[] => { + const saved = localStorage.getItem("products"); + if (saved) { + try { + const parsed = JSON.parse(saved); + // 배열이 직접 저장되거나 { state: { products: [...] } } 형태일 수 있음 + if (Array.isArray(parsed)) { + return parsed; + } + if (parsed && parsed.state && Array.isArray(parsed.state.products)) { + return parsed.state.products; + } + return initialProducts; + } catch { + return initialProducts; + } + } + return initialProducts; +}; + +export const useProductStore = create()( + persist( + (set, get) => { + // 초기 상태: localStorage에서 동기적으로 읽기 (origin과 동일) + const initialProductsState = getInitialProducts(); + + return { + products: initialProductsState, + productForm: { + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] as Array, + }, + editingProduct: null, + showProductForm: false, + + // 상품 추가 + addProduct: (newProduct) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + set((state) => ({ + products: [...state.products, product], + })); + useNotificationStore.getState().addNotification( + "상품이 추가되었습니다.", + "success" + ); + }, + + // 상품 수정 + updateProduct: (productId, updates) => { + set((state) => ({ + products: state.products.map((product) => + product.id === productId ? { ...product, ...updates } : product + ), + })); + useNotificationStore.getState().addNotification( + "상품이 수정되었습니다.", + "success" + ); + }, + + // 상품 삭제 + deleteProduct: (productId) => { + set((state) => ({ + products: state.products.filter((p) => p.id !== productId), + })); + useNotificationStore.getState().addNotification( + "상품이 삭제되었습니다.", + "success" + ); + }, + + // 상품 편집 시작 + startEditProduct: (product) => { + set({ + editingProduct: product.id, + productForm: { + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [], + }, + showProductForm: true, + }); + }, + + // 상품 폼 제출 + handleProductSubmit: (e) => { + e.preventDefault(); + const state = get(); + const { editingProduct, productForm } = state; + + if (editingProduct && editingProduct !== "new") { + state.updateProduct(editingProduct, productForm); + set({ editingProduct: null }); + } else { + state.addProduct({ + ...productForm, + discounts: productForm.discounts, + }); + } + + set({ + productForm: { + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }, + editingProduct: null, + showProductForm: false, + }); + }, + + // 상품 폼 설정 (함수형 업데이트 지원) + setProductForm: (form) => { + set((state) => ({ + productForm: + typeof form === "function" ? form(state.productForm) : form, + })); + }, + + // 편집 상품 ID 설정 + setEditingProduct: (id) => { + set({ editingProduct: id }); + }, + + // 폼 표시 여부 설정 + setShowProductForm: (show) => { + set({ showProductForm: show }); + }, + }; + }, + { + name: "products", // localStorage 키 (origin과 동일) + partialize: (state) => ({ products: state.products }), // products만 저장 + // storage 옵션 제거: App.tsx의 useEffect가 배열을 직접 저장하므로 + // persist는 내부적으로만 사용하고, 실제 저장은 useEffect가 담당 + skipHydration: true, + } + ) +); + +// Store 초기화 시 localStorage에서 동기적으로 복원 (skipHydration: true 사용 시 필요) +// 테스트 환경에서는 실행하지 않음 (beforeEach에서 초기화) +if (typeof window !== "undefined" && process.env.NODE_ENV !== "test") { + const saved = localStorage.getItem("products"); + if (saved) { + try { + const parsed = JSON.parse(saved); + if (Array.isArray(parsed)) { + useProductStore.setState({ products: parsed }); + } + } catch { + // 에러 무시 (초기값 사용) + } + } +} diff --git a/src/advanced/stores/useSearchStore.ts b/src/advanced/stores/useSearchStore.ts new file mode 100644 index 000000000..05fb86405 --- /dev/null +++ b/src/advanced/stores/useSearchStore.ts @@ -0,0 +1,43 @@ +import { create } from "zustand"; + +/** + * 검색어 관련 전역 상태를 관리하는 Zustand Store + * + * Entity를 다루지 않는 UI 상태 Store + * - 검색어는 UI 입력 상태 + * - 디바운스 처리 포함 + * - 의존성이 없어 먼저 구현 + */ +interface SearchState { + // 상태 + searchTerm: string; + debouncedSearchTerm: string; + + // 액션 + setSearchTerm: (term: string) => void; +} + +// 디바운스 타이머를 저장할 변수 +let debounceTimer: ReturnType | null = null; + +export const useSearchStore = create((set) => ({ + // 초기 상태 + searchTerm: "", + debouncedSearchTerm: "", + + // 검색어 설정 (디바운스 처리 포함) + setSearchTerm: (term) => { + set({ searchTerm: term }); + + // 기존 타이머 취소 + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + // 500ms 후 디바운스된 검색어 업데이트 + debounceTimer = setTimeout(() => { + set({ debouncedSearchTerm: term }); + }, 500); + }, +})); + diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts new file mode 100644 index 000000000..eb05731e4 --- /dev/null +++ b/src/advanced/utils/formatters.ts @@ -0,0 +1,8 @@ +export const formatPrice = (price: number, type: "kr" | "en" = "kr") => { + const formatters = { + kr: `${price.toLocaleString()}원`, + en: `₩${price.toLocaleString()}`, + }; + + return formatters[type]; +}; diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..41c45e9ec 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,1124 +1,202 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -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 { useState } from "react"; +import { + CartSidebarProps, + ProductListProps, +} from "./domain/product/productTypes"; +import { filterProductsBySearchTerm } from "./domain/product/productUtils"; +import { calculateCartTotal } from "./domain/cart/cartUtils"; +import { Notifications } from "./components/notifications/Notification"; +import { DefaultLayout } from "./components/layouts/DefaultLayout"; +import { SearchBar } from "./components/common/SearchBar"; +import { HeaderActions } from "./components/layouts/HeaderActions"; +import { StorePage } from "./pages/StorePage"; +import { AdminTabKey } from "./components/admin/common/AdminTabs"; +import { ProductListTableProps } from "./components/admin/product/ProductListTable"; +import { AdminProductsSectionProps } from "./components/admin/product/AdminProductsSection"; +import { CouponListProps } from "./components/admin/coupon/CouponList"; +import { CouponFormSection } from "./components/admin/coupon/CouponFormSection"; +import { AdminPage } from "./pages/AdminPage"; +import { useNotification } from "./hooks/useNotification"; +import { useSearch } from "./hooks/useSearch"; +import { useProduct } from "./hooks/useProduct"; +import { useCart } from "./hooks/useCart"; +import { useCoupon } from "./hooks/useCoupon"; 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); + // UI 상태 (Entity가 아닌 상태) 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 [activeTab, setActiveTab] = useState("products"); + + // Hook 사용 - Entity를 다루지 않는 Hook + const { notifications, setNotifications, addNotification } = + useNotification(); + const { searchTerm, setSearchTerm, debouncedSearchTerm } = useSearch(); + + // Hook 사용 - Entity를 다루는 Hook (의존성 순서대로) + const { + products, + productForm, + editingProduct, + showProductForm, + setProductForm, + setEditingProduct, + setShowProductForm, + deleteProduct, + startEditProduct, + handleProductSubmit, + } = useProduct(addNotification); + + const { + cart, + totalItemCount, + filledItems, + addToCart, + removeFromCart, + updateQuantity, + completeOrder: completeOrderFromCart, + } = useCart(products, addNotification); + + const { + coupons, + selectedCoupon, + couponForm, + showCouponForm, + setSelectedCoupon, + setCouponForm, + setShowCouponForm, + deleteCoupon, + handleCouponSubmit, + selectorOnChange, + } = useCoupon(cart, addNotification); + + // completeOrder는 cart와 coupon 모두 초기화해야 하므로 래퍼 함수 생성 + const completeOrder = () => { + completeOrderFromCart(); + setSelectedCoupon(null); }; - 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)); - } - } + // 계산된 값 (순수 함수 호출) + const totals = calculateCartTotal(cart, selectedCoupon); + const filteredProducts = filterProductsBySearchTerm( + debouncedSearchTerm, + products + ); - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) + // StorePage에 필요한 모든 props를 한 번에 조립해 반환하는 헬퍼 함수 + const buildStorePageProps = () => { + const productProps: ProductListProps = { + cart, + products, + filteredProducts, + debouncedSearchTerm, + addToCart, }; - }; - - 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()}` + const cartSidebarProps: CartSidebarProps = { + cartProps: { + filledItems, + removeFromCart, + updateQuantity, + }, + couponProps: { + coupons, + selectedCouponCode: selectedCoupon?.code || "", + selectorOnChange, + }, + payment: { + totals, + completeOrder, + }, }; - 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); + return { productProps, cartSidebarProps }; }; - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); + const productListTableProps = () => { + return { + cart, + products, + startEditProduct, + deleteProduct, + } as ProductListTableProps; }; - 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 buildAdminProductsSection = () => { + const adminProductsProps: AdminProductsSectionProps = { + productListTableProps: productListTableProps(), + productForm, + showProductForm, + editingProduct, + setEditingProduct, + setProductForm, + setShowProductForm, + handleProductSubmit, + addNotification, + }; + return adminProductsProps; }; - 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 buildAdminCouponSection = () => { + const couponsListProps: CouponListProps = { + coupons, + deleteCoupon, + setShowCouponForm, + showCouponForm, + }; + const couponFormProps: CouponFormSection = { + couponForm, + handleCouponSubmit, + setCouponForm, + addNotification, + setShowCouponForm, + }; + return { couponsListProps, couponFormProps, showCouponForm }; + }; return ( -
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

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 - /> -
-
-
- - -
-
-
- )} -
-
+ + {notifications.length > 0 && ( + + )} + + } + headerProps={{ + headerLeft: ( + <> + {!isAdmin && ( + )} -
- ) : ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

-
- 총 {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()}원 -
-
- - - -
-

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

-
-
- - )} -
-
-
- )} -
-
+ + ), + headerRight: ( + + ), + }}> + {isAdmin ? ( + + ) : ( + + )} + ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/basic/__tests__/origin.test.tsx b/src/basic/__tests__/origin.test.tsx index 3f5c3d55e..04aad1af8 100644 --- a/src/basic/__tests__/origin.test.tsx +++ b/src/basic/__tests__/origin.test.tsx @@ -1,528 +1,567 @@ // @ts-nocheck -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; -import { vi } from 'vitest'; -import App from '../App'; -import '../../setupTests'; +import { + render, + screen, + fireEvent, + within, + waitFor, +} from "@testing-library/react"; +import { vi } from "vitest"; +import App from "../App"; +import "../../setupTests"; -describe('쇼핑몰 앱 통합 테스트', () => { +describe("쇼핑몰 앱 통합 테스트", () => { beforeEach(() => { // localStorage 초기화 localStorage.clear(); // console 경고 무시 - vi.spyOn(console, 'warn').mockImplementation(() => {}); - vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); - describe('고객 쇼핑 플로우', () => { - test('상품을 검색하고 장바구니에 추가할 수 있다', async () => { + describe("고객 쇼핑 플로우", () => { + test("상품을 검색하고 장바구니에 추가할 수 있다", async () => { render(); - + // 검색창에 "프리미엄" 입력 - const searchInput = screen.getByPlaceholderText('상품 검색...'); - fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + const searchInput = screen.getByPlaceholderText("상품 검색..."); + fireEvent.change(searchInput, { target: { value: "프리미엄" } }); + // 디바운스 대기 - await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - }, { timeout: 600 }); - + await waitFor( + () => { + expect( + screen.getByText("최고급 품질의 프리미엄 상품입니다.") + ).toBeInTheDocument(); + }, + { timeout: 600 } + ); + // 검색된 상품을 장바구니에 추가 (첫 번째 버튼 선택) - const addButtons = screen.getAllByText('장바구니 담기'); + const addButtons = screen.getAllByText("장바구니 담기"); fireEvent.click(addButtons[0]); - + // 알림 메시지 확인 await waitFor(() => { - expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); + expect(screen.getByText("장바구니에 담았습니다")).toBeInTheDocument(); }); - + // 장바구니에 추가됨 확인 (장바구니 섹션에서) - const cartSection = screen.getByText('장바구니').closest('section'); - expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); + const cartSection = screen.getByText("장바구니").closest("section"); + expect(within(cartSection).getByText("상품1")).toBeInTheDocument(); }); - test('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { + test("장바구니에서 수량을 조절하고 할인을 확인할 수 있다", () => { render(); - + // 상품1을 장바구니에 추가 - const product1 = screen.getAllByText('장바구니 담기')[0]; + const product1 = screen.getAllByText("장바구니 담기")[0]; fireEvent.click(product1); - + // 수량을 10개로 증가 (10% 할인 적용) - const cartSection = screen.getByText('장바구니').closest('section'); - const plusButton = within(cartSection).getByText('+'); - + const cartSection = screen.getByText("장바구니").closest("section"); + const plusButton = within(cartSection).getByText("+"); + for (let i = 0; i < 9; i++) { fireEvent.click(plusButton); } - + // 10% 할인 적용 확인 - 15% (대량 구매 시 추가 5% 포함) - expect(screen.getByText('-15%')).toBeInTheDocument(); + expect(screen.getByText("-15%")).toBeInTheDocument(); }); - test('쿠폰을 선택하고 적용할 수 있다', () => { + test("쿠폰을 선택하고 적용할 수 있다", () => { render(); - + // 상품 추가 - const addButton = screen.getAllByText('장바구니 담기')[0]; + const addButton = screen.getAllByText("장바구니 담기")[0]; fireEvent.click(addButton); - + // 쿠폰 선택 - const couponSelect = screen.getByRole('combobox'); - fireEvent.change(couponSelect, { target: { value: 'AMOUNT5000' } }); - + const couponSelect = screen.getByRole("combobox"); + fireEvent.change(couponSelect, { target: { value: "AMOUNT5000" } }); + // 결제 정보에서 할인 금액 확인 - const paymentSection = screen.getByText('결제 정보').closest('section'); - const discountRow = within(paymentSection).getByText('할인 금액').closest('div'); - expect(within(discountRow).getByText('-5,000원')).toBeInTheDocument(); + const paymentSection = screen.getByText("결제 정보").closest("section"); + const discountRow = within(paymentSection) + .getByText("할인 금액") + .closest("div"); + expect(within(discountRow).getByText("-5,000원")).toBeInTheDocument(); }); - test('품절 임박 상품에 경고가 표시된다', async () => { + test("품절 임박 상품에 경고가 표시된다", async () => { render(); - + // 관리자 모드로 전환 - fireEvent.click(screen.getByText('관리자 페이지로')); - + fireEvent.click(screen.getByText("관리자 페이지로")); + // 상품 수정 - const editButton = screen.getAllByText('수정')[0]; + const editButton = screen.getAllByText("수정")[0]; fireEvent.click(editButton); - + // 재고를 5개로 변경 - const stockInputs = screen.getAllByPlaceholderText('숫자만 입력'); + const stockInputs = screen.getAllByPlaceholderText("숫자만 입력"); const stockInput = stockInputs[1]; // 재고 입력 필드는 두 번째 - fireEvent.change(stockInput, { target: { value: '5' } }); + fireEvent.change(stockInput, { target: { value: "5" } }); fireEvent.blur(stockInput); - + // 수정 완료 버튼 클릭 - const editButtons = screen.getAllByText('수정'); + const editButtons = screen.getAllByText("수정"); const completeEditButton = editButtons[editButtons.length - 1]; // 마지막 수정 버튼 (완료 버튼) fireEvent.click(completeEditButton); - + // 쇼핑몰로 돌아가기 - fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + fireEvent.click(screen.getByText("쇼핑몰로 돌아가기")); + // 품절임박 메시지 확인 - 재고가 5개 이하면 품절임박 표시 await waitFor(() => { - expect(screen.getByText('품절임박! 5개 남음')).toBeInTheDocument(); + expect(screen.getByText("품절임박! 5개 남음")).toBeInTheDocument(); }); }); - test('주문을 완료할 수 있다', () => { + test("주문을 완료할 수 있다", () => { render(); - + // 상품 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // 결제하기 버튼 클릭 const orderButton = screen.getByText(/원 결제하기/); fireEvent.click(orderButton); - + // 주문 완료 알림 확인 expect(screen.getByText(/주문이 완료되었습니다/)).toBeInTheDocument(); - + // 장바구니가 비어있는지 확인 - expect(screen.getByText('장바구니가 비어있습니다')).toBeInTheDocument(); + expect(screen.getByText("장바구니가 비어있습니다")).toBeInTheDocument(); }); - test('장바구니에서 상품을 삭제할 수 있다', () => { + test("장바구니에서 상품을 삭제할 수 있다", () => { render(); - + // 상품 2개 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + fireEvent.click(screen.getAllByText("장바구니 담기")[1]); + // 장바구니 섹션 확인 - const cartSection = screen.getByText('장바구니').closest('section'); - expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); - expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); - + const cartSection = screen.getByText("장바구니").closest("section"); + expect(within(cartSection).getByText("상품1")).toBeInTheDocument(); + expect(within(cartSection).getByText("상품2")).toBeInTheDocument(); + // 첫 번째 상품 삭제 (X 버튼) - const deleteButtons = within(cartSection).getAllByRole('button').filter( - button => button.querySelector('svg') - ); + const deleteButtons = within(cartSection) + .getAllByRole("button") + .filter((button) => button.querySelector("svg")); fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되고 상품2만 남음 - expect(within(cartSection).queryByText('상품1')).not.toBeInTheDocument(); - expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); + expect(within(cartSection).queryByText("상품1")).not.toBeInTheDocument(); + expect(within(cartSection).getByText("상품2")).toBeInTheDocument(); }); - test('재고를 초과하여 구매할 수 없다', async () => { + test("재고를 초과하여 구매할 수 없다", async () => { render(); - + // 상품1 장바구니에 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // 수량을 재고(20개) 이상으로 증가 시도 - const cartSection = screen.getByText('장바구니').closest('section'); - const plusButton = within(cartSection).getByText('+'); - + const cartSection = screen.getByText("장바구니").closest("section"); + const plusButton = within(cartSection).getByText("+"); + // 19번 클릭하여 총 20개로 만듦 for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 한 번 더 클릭 시도 (21개가 되려고 함) fireEvent.click(plusButton); - + // 수량이 20개에서 멈춰있어야 함 - expect(within(cartSection).getByText('20')).toBeInTheDocument(); - + expect(within(cartSection).getByText("20")).toBeInTheDocument(); + // 재고 부족 메시지 확인 await waitFor(() => { - expect(screen.getByText(/재고는.*개까지만 있습니다/)).toBeInTheDocument(); + expect( + screen.getByText(/재고는.*개까지만 있습니다/) + ).toBeInTheDocument(); }); }); - test('장바구니에서 수량을 감소시킬 수 있다', () => { + test("장바구니에서 수량을 감소시킬 수 있다", () => { render(); - + // 상품 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - - const cartSection = screen.getByText('장바구니').closest('section'); - const plusButton = within(cartSection).getByText('+'); - const minusButton = within(cartSection).getByText('−'); // U+2212 마이너스 기호 - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + + const cartSection = screen.getByText("장바구니").closest("section"); + const plusButton = within(cartSection).getByText("+"); + const minusButton = within(cartSection).getByText("−"); // U+2212 마이너스 기호 + // 수량 3개로 증가 fireEvent.click(plusButton); fireEvent.click(plusButton); - expect(within(cartSection).getByText('3')).toBeInTheDocument(); - + expect(within(cartSection).getByText("3")).toBeInTheDocument(); + // 수량 감소 fireEvent.click(minusButton); - expect(within(cartSection).getByText('2')).toBeInTheDocument(); - + expect(within(cartSection).getByText("2")).toBeInTheDocument(); + // 1개로 더 감소 fireEvent.click(minusButton); - expect(within(cartSection).getByText('1')).toBeInTheDocument(); - + expect(within(cartSection).getByText("1")).toBeInTheDocument(); + // 1개에서 한 번 더 감소하면 장바구니에서 제거될 수도 있음 fireEvent.click(minusButton); // 장바구니가 비었는지 확인 - const emptyMessage = screen.queryByText('장바구니가 비어있습니다'); + const emptyMessage = screen.queryByText("장바구니가 비어있습니다"); if (emptyMessage) { expect(emptyMessage).toBeInTheDocument(); } else { // 또는 수량이 1에서 멈춤 - expect(within(cartSection).getByText('1')).toBeInTheDocument(); + expect(within(cartSection).getByText("1")).toBeInTheDocument(); } }); - test('20개 이상 구매 시 최대 할인이 적용된다', async () => { + test("20개 이상 구매 시 최대 할인이 적용된다", async () => { render(); - + // 관리자 모드로 전환하여 상품1의 재고를 늘림 - fireEvent.click(screen.getByText('관리자 페이지로')); - fireEvent.click(screen.getAllByText('수정')[0]); - - const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; - fireEvent.change(stockInput, { target: { value: '30' } }); - - const editButtons = screen.getAllByText('수정'); + fireEvent.click(screen.getByText("관리자 페이지로")); + fireEvent.click(screen.getAllByText("수정")[0]); + + const stockInput = screen.getAllByPlaceholderText("숫자만 입력")[1]; + fireEvent.change(stockInput, { target: { value: "30" } }); + + const editButtons = screen.getAllByText("수정"); fireEvent.click(editButtons[editButtons.length - 1]); - + // 쇼핑몰로 돌아가기 - fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + fireEvent.click(screen.getByText("쇼핑몰로 돌아가기")); + // 상품1을 장바구니에 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // 수량을 20개로 증가 - const cartSection = screen.getByText('장바구니').closest('section'); - const plusButton = within(cartSection).getByText('+'); - + const cartSection = screen.getByText("장바구니").closest("section"); + const plusButton = within(cartSection).getByText("+"); + for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 25% 할인 적용 확인 (또는 대량 구매 시 30%) await waitFor(() => { - const discount25 = screen.queryByText('-25%'); - const discount30 = screen.queryByText('-30%'); + const discount25 = screen.queryByText("-25%"); + const discount30 = screen.queryByText("-30%"); expect(discount25 || discount30).toBeTruthy(); }); }); }); - describe('관리자 기능', () => { + describe("관리자 기능", () => { beforeEach(() => { render(); // 관리자 모드로 전환 - fireEvent.click(screen.getByText('관리자 페이지로')); + fireEvent.click(screen.getByText("관리자 페이지로")); }); - test('새 상품을 추가할 수 있다', () => { + test("새 상품을 추가할 수 있다", () => { // 새 상품 추가 버튼 클릭 - fireEvent.click(screen.getByText('새 상품 추가')); - + fireEvent.click(screen.getByText("새 상품 추가")); + // 폼 입력 - 상품명 입력 - const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); - const nameInput = nameLabel.closest('div').querySelector('input'); - fireEvent.change(nameInput, { target: { value: '테스트 상품' } }); - - const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - fireEvent.change(priceInput, { target: { value: '25000' } }); - - const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; - fireEvent.change(stockInput, { target: { value: '50' } }); - - const descLabels = screen.getAllByText('설명'); - const descLabel = descLabels.find(el => el.tagName === 'LABEL'); - const descInput = descLabel.closest('div').querySelector('input'); - fireEvent.change(descInput, { target: { value: '테스트 설명' } }); - + const labels = screen.getAllByText("상품명"); + const nameLabel = labels.find((el) => el.tagName === "LABEL"); + const nameInput = nameLabel.closest("div").querySelector("input"); + fireEvent.change(nameInput, { target: { value: "테스트 상품" } }); + + const priceInput = screen.getAllByPlaceholderText("숫자만 입력")[0]; + fireEvent.change(priceInput, { target: { value: "25000" } }); + + const stockInput = screen.getAllByPlaceholderText("숫자만 입력")[1]; + fireEvent.change(stockInput, { target: { value: "50" } }); + + const descLabels = screen.getAllByText("설명"); + const descLabel = descLabels.find((el) => el.tagName === "LABEL"); + const descInput = descLabel.closest("div").querySelector("input"); + fireEvent.change(descInput, { target: { value: "테스트 설명" } }); + // 저장 - fireEvent.click(screen.getByText('추가')); - + fireEvent.click(screen.getByText("추가")); + // 추가된 상품 확인 - expect(screen.getByText('테스트 상품')).toBeInTheDocument(); - expect(screen.getByText('25,000원')).toBeInTheDocument(); + expect(screen.getByText("테스트 상품")).toBeInTheDocument(); + expect(screen.getByText("25,000원")).toBeInTheDocument(); }); - test('쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다', () => { + test("쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다", () => { // 쿠폰 관리 탭으로 전환 - fireEvent.click(screen.getByText('쿠폰 관리')); - + fireEvent.click(screen.getByText("쿠폰 관리")); + // 새 쿠폰 추가 버튼 클릭 - const addCouponButton = screen.getByText('새 쿠폰 추가'); + const addCouponButton = screen.getByText("새 쿠폰 추가"); fireEvent.click(addCouponButton); - + // 쿠폰 정보 입력 - fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { target: { value: '테스트 쿠폰' } }); - fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { target: { value: 'TEST2024' } }); - - const discountInput = screen.getByPlaceholderText('5000'); - fireEvent.change(discountInput, { target: { value: '7000' } }); - + fireEvent.change(screen.getByPlaceholderText("신규 가입 쿠폰"), { + target: { value: "테스트 쿠폰" }, + }); + fireEvent.change(screen.getByPlaceholderText("WELCOME2024"), { + target: { value: "TEST2024" }, + }); + + const discountInput = screen.getByPlaceholderText("5000"); + fireEvent.change(discountInput, { target: { value: "7000" } }); + // 쿠폰 생성 - fireEvent.click(screen.getByText('쿠폰 생성')); - + fireEvent.click(screen.getByText("쿠폰 생성")); + // 생성된 쿠폰 확인 - expect(screen.getByText('테스트 쿠폰')).toBeInTheDocument(); - expect(screen.getByText('TEST2024')).toBeInTheDocument(); - expect(screen.getByText('7,000원 할인')).toBeInTheDocument(); + expect(screen.getByText("테스트 쿠폰")).toBeInTheDocument(); + expect(screen.getByText("TEST2024")).toBeInTheDocument(); + expect(screen.getByText("7,000원 할인")).toBeInTheDocument(); }); - test('상품의 가격 입력 시 숫자만 허용된다', async () => { + test("상품의 가격 입력 시 숫자만 허용된다", async () => { // 상품 수정 - fireEvent.click(screen.getAllByText('수정')[0]); - - const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - + fireEvent.click(screen.getAllByText("수정")[0]); + + const priceInput = screen.getAllByPlaceholderText("숫자만 입력")[0]; + // 문자와 숫자 혼합 입력 시도 - 숫자만 남음 - fireEvent.change(priceInput, { target: { value: 'abc123def' } }); - expect(priceInput.value).toBe('10000'); // 유효하지 않은 입력은 무시됨 - + fireEvent.change(priceInput, { target: { value: "abc123def" } }); + expect(priceInput.value).toBe("10000"); // 유효하지 않은 입력은 무시됨 + // 숫자만 입력 - fireEvent.change(priceInput, { target: { value: '123' } }); - expect(priceInput.value).toBe('123'); - + fireEvent.change(priceInput, { target: { value: "123" } }); + expect(priceInput.value).toBe("123"); + // 음수 입력 시도 - regex가 매치되지 않아 값이 변경되지 않음 - fireEvent.change(priceInput, { target: { value: '-100' } }); - expect(priceInput.value).toBe('123'); // 이전 값 유지 - + fireEvent.change(priceInput, { target: { value: "-100" } }); + expect(priceInput.value).toBe("123"); // 이전 값 유지 + // 유효한 음수 입력하기 위해 먼저 1 입력 후 앞에 - 추가는 불가능 // 대신 blur 이벤트를 통해 음수 검증을 테스트 // parseInt()는 실제로 음수를 파싱할 수 있으므로 다른 방법으로 테스트 - + // 공백 입력 시도 - fireEvent.change(priceInput, { target: { value: ' ' } }); - expect(priceInput.value).toBe('123'); // 유효하지 않은 입력은 무시됨 + fireEvent.change(priceInput, { target: { value: " " } }); + expect(priceInput.value).toBe("123"); // 유효하지 않은 입력은 무시됨 }); - test('쿠폰 할인율 검증이 작동한다', async () => { + test("쿠폰 할인율 검증이 작동한다", async () => { // 쿠폰 관리 탭으로 전환 - fireEvent.click(screen.getByText('쿠폰 관리')); - + fireEvent.click(screen.getByText("쿠폰 관리")); + // 새 쿠폰 추가 - fireEvent.click(screen.getByText('새 쿠폰 추가')); - + fireEvent.click(screen.getByText("새 쿠폰 추가")); + // 퍼센트 타입으로 변경 - 쿠폰 폼 내의 select 찾기 - const couponFormSelects = screen.getAllByRole('combobox'); + const couponFormSelects = screen.getAllByRole("combobox"); const typeSelect = couponFormSelects[couponFormSelects.length - 1]; // 마지막 select가 타입 선택 - fireEvent.change(typeSelect, { target: { value: 'percentage' } }); - + fireEvent.change(typeSelect, { target: { value: "percentage" } }); + // 100% 초과 할인율 입력 - const discountInput = screen.getByPlaceholderText('10'); - fireEvent.change(discountInput, { target: { value: '150' } }); + const discountInput = screen.getByPlaceholderText("10"); + fireEvent.change(discountInput, { target: { value: "150" } }); fireEvent.blur(discountInput); - + // 에러 메시지 확인 await waitFor(() => { - expect(screen.getByText('할인율은 100%를 초과할 수 없습니다')).toBeInTheDocument(); + expect( + screen.getByText("할인율은 100%를 초과할 수 없습니다") + ).toBeInTheDocument(); }); }); - test('상품을 삭제할 수 있다', () => { + test("상품을 삭제할 수 있다", () => { // 초기 상품명들 확인 (테이블에서) - const productTable = screen.getByRole('table'); - expect(within(productTable).getByText('상품1')).toBeInTheDocument(); - + const productTable = screen.getByRole("table"); + expect(within(productTable).getByText("상품1")).toBeInTheDocument(); + // 삭제 버튼들 찾기 - const deleteButtons = within(productTable).getAllByRole('button').filter( - button => button.textContent === '삭제' - ); - + const deleteButtons = within(productTable) + .getAllByRole("button") + .filter((button) => button.textContent === "삭제"); + // 첫 번째 상품 삭제 fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되었는지 확인 - expect(within(productTable).queryByText('상품1')).not.toBeInTheDocument(); - expect(within(productTable).getByText('상품2')).toBeInTheDocument(); + expect(within(productTable).queryByText("상품1")).not.toBeInTheDocument(); + expect(within(productTable).getByText("상품2")).toBeInTheDocument(); }); - test('쿠폰을 삭제할 수 있다', () => { + test("쿠폰을 삭제할 수 있다", () => { // 쿠폰 관리 탭으로 전환 - fireEvent.click(screen.getByText('쿠폰 관리')); - + fireEvent.click(screen.getByText("쿠폰 관리")); + // 초기 쿠폰들 확인 (h3 제목에서) - const couponTitles = screen.getAllByRole('heading', { level: 3 }); - const coupon5000 = couponTitles.find(el => el.textContent === '5000원 할인'); - const coupon10 = couponTitles.find(el => el.textContent === '10% 할인'); + const couponTitles = screen.getAllByRole("heading", { level: 3 }); + const coupon5000 = couponTitles.find( + (el) => el.textContent === "5000원 할인" + ); + const coupon10 = couponTitles.find((el) => el.textContent === "10% 할인"); expect(coupon5000).toBeInTheDocument(); expect(coupon10).toBeInTheDocument(); - + // 삭제 버튼 찾기 (SVG 아이콘을 포함한 버튼) - const deleteButtons = screen.getAllByRole('button').filter(button => { - return button.querySelector('svg') && - button.querySelector('path[d*="M19 7l"]'); // 삭제 아이콘 path + const deleteButtons = screen.getAllByRole("button").filter((button) => { + return ( + button.querySelector("svg") && + button.querySelector('path[d*="M19 7l"]') + ); // 삭제 아이콘 path }); - + // 첫 번째 쿠폰 삭제 fireEvent.click(deleteButtons[0]); - + // 쿠폰이 삭제되었는지 확인 - expect(screen.queryByText('5000원 할인')).not.toBeInTheDocument(); + expect(screen.queryByText("5000원 할인")).not.toBeInTheDocument(); }); - }); - describe('로컬스토리지 동기화', () => { - test('상품, 장바구니, 쿠폰이 localStorage에 저장된다', () => { + describe("로컬스토리지 동기화", () => { + test("상품, 장바구니, 쿠폰이 localStorage에 저장된다", () => { render(); - + // 상품을 장바구니에 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // localStorage 확인 - expect(localStorage.getItem('cart')).toBeTruthy(); - expect(JSON.parse(localStorage.getItem('cart'))).toHaveLength(1); - + expect(localStorage.getItem("cart")).toBeTruthy(); + expect(JSON.parse(localStorage.getItem("cart"))).toHaveLength(1); + // 관리자 모드로 전환하여 새 상품 추가 - fireEvent.click(screen.getByText('관리자 페이지로')); - fireEvent.click(screen.getByText('새 상품 추가')); - - const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); - const nameInput = nameLabel.closest('div').querySelector('input'); - fireEvent.change(nameInput, { target: { value: '저장 테스트' } }); - - const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - fireEvent.change(priceInput, { target: { value: '10000' } }); - - const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; - fireEvent.change(stockInput, { target: { value: '10' } }); - - fireEvent.click(screen.getByText('추가')); - + fireEvent.click(screen.getByText("관리자 페이지로")); + fireEvent.click(screen.getByText("새 상품 추가")); + + const labels = screen.getAllByText("상품명"); + const nameLabel = labels.find((el) => el.tagName === "LABEL"); + const nameInput = nameLabel.closest("div").querySelector("input"); + fireEvent.change(nameInput, { target: { value: "저장 테스트" } }); + + const priceInput = screen.getAllByPlaceholderText("숫자만 입력")[0]; + fireEvent.change(priceInput, { target: { value: "10000" } }); + + const stockInput = screen.getAllByPlaceholderText("숫자만 입력")[1]; + fireEvent.change(stockInput, { target: { value: "10" } }); + + fireEvent.click(screen.getByText("추가")); + // localStorage에 products가 저장되었는지 확인 - expect(localStorage.getItem('products')).toBeTruthy(); - const products = JSON.parse(localStorage.getItem('products')); - expect(products.some(p => p.name === '저장 테스트')).toBe(true); + expect(localStorage.getItem("products")).toBeTruthy(); + const products = JSON.parse(localStorage.getItem("products")); + expect(products.some((p) => p.name === "저장 테스트")).toBe(true); }); - test('페이지 새로고침 후에도 데이터가 유지된다', () => { + test("페이지 새로고침 후에도 데이터가 유지된다", () => { const { unmount } = render(); - + // 장바구니에 상품 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + fireEvent.click(screen.getAllByText("장바구니 담기")[1]); + // 컴포넌트 unmount unmount(); - + // 다시 mount render(); - + // 장바구니 아이템이 유지되는지 확인 - const cartSection = screen.getByText('장바구니').closest('section'); - expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); - expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); + const cartSection = screen.getByText("장바구니").closest("section"); + expect(within(cartSection).getByText("상품1")).toBeInTheDocument(); + expect(within(cartSection).getByText("상품2")).toBeInTheDocument(); }); }); - describe('UI 상태 관리', () => { - test('할인이 있을 때 할인율이 표시된다', async () => { + describe("UI 상태 관리", () => { + test("할인이 있을 때 할인율이 표시된다", async () => { render(); - + // 상품을 10개 담아서 할인 발생 - const addButton = screen.getAllByText('장바구니 담기')[0]; + const addButton = screen.getAllByText("장바구니 담기")[0]; for (let i = 0; i < 10; i++) { fireEvent.click(addButton); } - + // 할인율 표시 확인 - 대량 구매로 15% 할인 await waitFor(() => { - expect(screen.getByText('-15%')).toBeInTheDocument(); + expect(screen.getByText("-15%")).toBeInTheDocument(); }); }); - test('장바구니 아이템 개수가 헤더에 표시된다', () => { + test("장바구니 아이템 개수가 헤더에 표시된다", () => { render(); - + // 상품 추가 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + fireEvent.click(screen.getAllByText("장바구니 담기")[1]); + // 헤더의 장바구니 아이콘 옆 숫자 확인 - const cartCount = screen.getByText('3'); + const cartCount = screen.getByText("3"); expect(cartCount).toBeInTheDocument(); }); - test('검색을 초기화할 수 있다', async () => { + test("검색을 초기화할 수 있다", async () => { render(); - + // 검색어 입력 - const searchInput = screen.getByPlaceholderText('상품 검색...'); - fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + const searchInput = screen.getByPlaceholderText("상품 검색..."); + fireEvent.change(searchInput, { target: { value: "프리미엄" } }); + // 검색 결과 확인 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); + expect( + screen.getByText("최고급 품질의 프리미엄 상품입니다.") + ).toBeInTheDocument(); // 다른 상품들은 보이지 않음 - expect(screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.')).not.toBeInTheDocument(); + expect( + screen.queryByText("다양한 기능을 갖춘 실용적인 상품입니다.") + ).not.toBeInTheDocument(); }); - + // 검색어 초기화 - fireEvent.change(searchInput, { target: { value: '' } }); - + fireEvent.change(searchInput, { target: { value: "" } }); + // 모든 상품이 다시 표시됨 await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('다양한 기능을 갖춘 실용적인 상품입니다.')).toBeInTheDocument(); - expect(screen.getByText('대용량과 고성능을 자랑하는 상품입니다.')).toBeInTheDocument(); + expect( + screen.getByText("최고급 품질의 프리미엄 상품입니다.") + ).toBeInTheDocument(); + expect( + screen.getByText("다양한 기능을 갖춘 실용적인 상품입니다.") + ).toBeInTheDocument(); + expect( + screen.getByText("대용량과 고성능을 자랑하는 상품입니다.") + ).toBeInTheDocument(); }); }); - test('알림 메시지가 자동으로 사라진다', async () => { + test("알림 메시지가 자동으로 사라진다", async () => { render(); - + // 상품 추가하여 알림 발생 - fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + fireEvent.click(screen.getAllByText("장바구니 담기")[0]); + // 알림 메시지 확인 - expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); - + expect(screen.getByText("장바구니에 담았습니다")).toBeInTheDocument(); + // 3초 후 알림이 사라짐 - await waitFor(() => { - expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); - }, { timeout: 4000 }); + await waitFor( + () => { + expect( + screen.queryByText("장바구니에 담았습니다") + ).not.toBeInTheDocument(); + }, + { timeout: 4000 } + ); }); }); -}); \ No newline at end of file +}); diff --git a/src/basic/components/Notifications/Notification.tsx b/src/basic/components/Notifications/Notification.tsx new file mode 100644 index 000000000..8d5cfa23b --- /dev/null +++ b/src/basic/components/Notifications/Notification.tsx @@ -0,0 +1,24 @@ +import { Notification } from "../../domain/notification/notificationTypes"; +import { NotificationItem } from "./NotificationItem"; + +interface NotificationProps { + notifications: Notification[]; + setNotifications: React.Dispatch>; +} + +export const Notifications = ({ + notifications, + setNotifications, +}: NotificationProps) => { + return ( +
+ {notifications.map((notif) => ( + + ))} +
+ ); +}; diff --git a/src/basic/components/Notifications/NotificationItem.tsx b/src/basic/components/Notifications/NotificationItem.tsx new file mode 100644 index 000000000..730036f8f --- /dev/null +++ b/src/basic/components/Notifications/NotificationItem.tsx @@ -0,0 +1,44 @@ +import { Notification } from "../../domain/notification/notificationTypes"; + +interface NotificationItemProps { + notif: Notification; + setNotifications: React.Dispatch>; +} + +export const NotificationItem = ({ + notif, + setNotifications, +}: NotificationItemProps) => { + const { id, type, message } = notif; + return ( +
+ {message} + +
+ ); +}; diff --git a/src/basic/components/admin/common/AdminTabs.tsx b/src/basic/components/admin/common/AdminTabs.tsx new file mode 100644 index 000000000..4667e769d --- /dev/null +++ b/src/basic/components/admin/common/AdminTabs.tsx @@ -0,0 +1,16 @@ +import { TabItem, Tabs } from "../../common/Tabs"; + +export type AdminTabKey = "products" | "coupons"; + +interface AdminTabsProps { + activeKey: AdminTabKey; + onChange: (key: AdminTabKey) => void; +} + +export const AdminTabs = ({ activeKey, onChange }: AdminTabsProps) => { + const items: TabItem[] = [ + { key: "products", label: "상품 관리" }, + { key: "coupons", label: "쿠폰 관리" }, + ]; + return ; +}; diff --git a/src/basic/components/admin/common/SectionHeader.tsx b/src/basic/components/admin/common/SectionHeader.tsx new file mode 100644 index 000000000..59d34ec10 --- /dev/null +++ b/src/basic/components/admin/common/SectionHeader.tsx @@ -0,0 +1,18 @@ +interface SectionHeaderProps { + onAddNewProduct: () => void; +} + +export const SectionHeader = ({ onAddNewProduct }: SectionHeaderProps) => { + return ( +
+
+

상품 목록

+ +
+
+ ); +}; diff --git a/src/basic/components/admin/coupon/AdminCouponSection.tsx b/src/basic/components/admin/coupon/AdminCouponSection.tsx new file mode 100644 index 000000000..46d8603e3 --- /dev/null +++ b/src/basic/components/admin/coupon/AdminCouponSection.tsx @@ -0,0 +1,27 @@ +import { CouponFormSection } from "./CouponFormSection"; +import { CouponList, CouponListProps } from "./CouponList"; + +export interface AdminCouponSectionProps { + couponsListProps: CouponListProps; + couponFormProps: CouponFormSection; + showCouponForm: boolean; +} + +export const AdminCouponSection = ({ + couponsListProps, + couponFormProps, + showCouponForm = false, +}: AdminCouponSectionProps) => { + return ( +
+
+

쿠폰 관리

+
+
+ + + {showCouponForm && } +
+
+ ); +}; diff --git a/src/basic/components/admin/coupon/CouponFormSection/CouponFormActions.tsx b/src/basic/components/admin/coupon/CouponFormSection/CouponFormActions.tsx new file mode 100644 index 000000000..79f137707 --- /dev/null +++ b/src/basic/components/admin/coupon/CouponFormSection/CouponFormActions.tsx @@ -0,0 +1,21 @@ +interface CouponFormActionsProps { + onClick: () => void; +} + +export const CouponFormActions = ({ onClick }: CouponFormActionsProps) => { + return ( +
+ + +
+ ); +}; diff --git a/src/basic/components/admin/coupon/CouponFormSection/CouponFormFields.tsx b/src/basic/components/admin/coupon/CouponFormSection/CouponFormFields.tsx new file mode 100644 index 000000000..461b8e80c --- /dev/null +++ b/src/basic/components/admin/coupon/CouponFormSection/CouponFormFields.tsx @@ -0,0 +1,112 @@ +import { Coupon } from "../../../../../types"; +import { DiscountType } from "../../../../constans/constans"; +import { FormInputField } from "../../../common/FormInputField"; +import { FormSelectField } from "../../../common/FormSelectField"; + +interface CouponFormFieldsProps { + couponForm: Coupon; + setCouponForm: (value: React.SetStateAction) => void; + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void; +} + +export const CouponFormFields = ({ + couponForm, + setCouponForm, + addNotification, +}: CouponFormFieldsProps) => { + const options = [ + { code: DiscountType.AMOUNT, name: "정액 할인" }, + { code: DiscountType.PRECENTAGE, name: "정률 할인" }, + ]; + return ( +
+ + setCouponForm({ + ...couponForm, + name: e.target.value, + }) + } + placeholder="신규 가입 쿠폰" + /> + + setCouponForm({ + ...couponForm, + code: e.target.value.toUpperCase(), + }) + } + placeholder="WELCOME2024" + /> + + setCouponForm({ + ...couponForm, + discountType: e.target.value as "amount" | "percentage", + }) + } + /> + { + 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, + }); + } + } + }} + placeholder={couponForm.discountType === "amount" ? "5000" : "10"} + /> +
+ ); +}; diff --git a/src/basic/components/admin/coupon/CouponFormSection/index.tsx b/src/basic/components/admin/coupon/CouponFormSection/index.tsx new file mode 100644 index 000000000..1ead2777b --- /dev/null +++ b/src/basic/components/admin/coupon/CouponFormSection/index.tsx @@ -0,0 +1,36 @@ +import { Coupon } from "../../../../../types"; +import { CouponFormActions } from "./CouponFormActions"; +import { CouponFormFields } from "./CouponFormFields"; + +export interface CouponFormSection { + couponForm: Coupon; + handleCouponSubmit: (e: React.FormEvent) => void; + setCouponForm: (value: React.SetStateAction) => void; + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void; + setShowCouponForm: (value: React.SetStateAction) => void; +} + +export const CouponFormSection = ({ + couponForm, + handleCouponSubmit, + setCouponForm, + addNotification, + setShowCouponForm, +}: CouponFormSection) => { + return ( +
+
+

새 쿠폰 생성

+ + setShowCouponForm(false)} /> + +
+ ); +}; diff --git a/src/basic/components/admin/coupon/CouponItem.tsx b/src/basic/components/admin/coupon/CouponItem.tsx new file mode 100644 index 000000000..1f1512ec0 --- /dev/null +++ b/src/basic/components/admin/coupon/CouponItem.tsx @@ -0,0 +1,41 @@ +import { Coupon } from "../../../../types"; +import { PriceType } from "../../../constans/constans"; +import { formatPrice } from "../../../utils/formatters"; +import { TrashIcon } from "../../icon/TrashIcon"; + +interface CouponItemProps { + coupon: Coupon; + formatType: PriceType; + onClick: () => void; +} + +export const CouponItem = ({ + coupon, + formatType, + onClick, +}: CouponItemProps) => { + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${formatPrice(coupon.discountValue, formatType)} 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +}; diff --git a/src/basic/components/admin/coupon/CouponList.tsx b/src/basic/components/admin/coupon/CouponList.tsx new file mode 100644 index 000000000..0866698c7 --- /dev/null +++ b/src/basic/components/admin/coupon/CouponList.tsx @@ -0,0 +1,41 @@ +import { Coupon } from "../../../../types"; +import { PriceType } from "../../../constans/constans"; +import { PlusIcon } from "../../icon/PlusIcon"; +import { CouponItem } from "./CouponItem"; + +export interface CouponListProps { + coupons: Coupon[]; + deleteCoupon: (couponCode: string) => void; + setShowCouponForm: (value: React.SetStateAction) => void; + showCouponForm: boolean; +} + +export const CouponList = ({ + coupons, + deleteCoupon, + setShowCouponForm, + showCouponForm, +}: CouponListProps) => { + const formatType = PriceType.KR; + return ( +
+ {coupons.map((coupon) => ( + deleteCoupon(coupon.code)} + /> + ))} + +
+ +
+
+ ); +}; diff --git a/src/basic/components/admin/product/AdminProductsSection.tsx b/src/basic/components/admin/product/AdminProductsSection.tsx new file mode 100644 index 000000000..a4a8ae143 --- /dev/null +++ b/src/basic/components/admin/product/AdminProductsSection.tsx @@ -0,0 +1,77 @@ +import { ProductForm } from "../../../domain/product/productTypes"; +import { SectionHeader } from "../common/SectionHeader"; +import { ProductFormSection } from "./ProductFormSection"; +import { ProductListTable, ProductListTableProps } from "./ProductListTable"; + +export interface AdminProductsSectionProps { + productListTableProps: ProductListTableProps; + productForm: ProductForm; + showProductForm: boolean; + editingProduct: string | null; + setEditingProduct: (value: React.SetStateAction) => void; + setProductForm: (value: React.SetStateAction) => void; + setShowProductForm: (value: React.SetStateAction) => void; + handleProductSubmit: (e: React.FormEvent) => void; + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void; +} + +export const AdminProductsSection = ({ + productListTableProps, + productForm, + showProductForm, + editingProduct, + setEditingProduct, + setProductForm, + setShowProductForm, + handleProductSubmit, + addNotification, +}: AdminProductsSectionProps) => { + return ( +
+ { + setEditingProduct("new"); + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + setShowProductForm(true); + }} + /> + +
+ +
+ {showProductForm && ( +
+ { + setEditingProduct(null); + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + setShowProductForm(false); + }} + addNotification={addNotification} + /> +
+ )} +
+ ); +}; + diff --git a/src/basic/components/admin/product/ProductFormSection/ProductBasicFields.tsx b/src/basic/components/admin/product/ProductFormSection/ProductBasicFields.tsx new file mode 100644 index 000000000..6f030bcd1 --- /dev/null +++ b/src/basic/components/admin/product/ProductFormSection/ProductBasicFields.tsx @@ -0,0 +1,94 @@ +import { ProductForm } from "../../../../domain/product/productTypes"; +import { FormInputField } from "../../../common/FormInputField"; + +interface ProductBasicFieldsProps { + productForm: ProductForm; + setProductForm: (value: React.SetStateAction) => void; + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void; +} + +export const ProductBasicFields = ({ + productForm, + setProductForm, + addNotification, +}: ProductBasicFieldsProps) => { + return ( +
+ { + const newName = e.target.value; + setProductForm((prev) => ({ + ...prev, + name: newName, + })); + }} + /> + { + const newDesc = e.target.value; + setProductForm((prev) => ({ + ...prev, + description: newDesc, + })); + }} + required={false} + /> + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setProductForm((prev) => ({ + ...prev, + price: value === "" ? 0 : parseInt(value), + })); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + setProductForm((prev) => ({ ...prev, price: 0 })); + } else if (parseInt(value) < 0) { + addNotification("가격은 0보다 커야 합니다", "error"); + setProductForm((prev) => ({ ...prev, price: 0 })); + } + }} + placeholder="숫자만 입력" + /> + { + const value = e.target.value; + if (value === "" || /^\d+$/.test(value)) { + setProductForm((prev) => ({ + ...prev, + stock: value === "" ? 0 : parseInt(value), + })); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + setProductForm((prev) => ({ ...prev, stock: 0 })); + } else if (parseInt(value) < 0) { + addNotification("재고는 0보다 커야 합니다", "error"); + setProductForm((prev) => ({ ...prev, stock: 0 })); + } else if (parseInt(value) > 9999) { + addNotification("재고는 9999개를 초과할 수 없습니다", "error"); + setProductForm((prev) => ({ ...prev, stock: 9999 })); + } + }} + placeholder="숫자만 입력" + /> +
+ ); +}; diff --git a/src/basic/components/admin/product/ProductFormSection/ProductDiscountList.tsx b/src/basic/components/admin/product/ProductFormSection/ProductDiscountList.tsx new file mode 100644 index 000000000..e1181fae7 --- /dev/null +++ b/src/basic/components/admin/product/ProductFormSection/ProductDiscountList.tsx @@ -0,0 +1,45 @@ +import { ProductForm } from "../../../../domain/product/productTypes"; +import { ProductDiscountRow } from "./ProductDiscountRow"; + +interface ProductDiscountListProps { + productForm: ProductForm; + setProductForm: (value: React.SetStateAction) => void; +} + +export const ProductDiscountList = ({ + productForm, + setProductForm, +}: ProductDiscountListProps) => { + return ( +
+ +
+ {productForm.discounts.map((discount, index) => ( + + ))} + +
+
+ ); +}; diff --git a/src/basic/components/admin/product/ProductFormSection/ProductDiscountRow.tsx b/src/basic/components/admin/product/ProductFormSection/ProductDiscountRow.tsx new file mode 100644 index 000000000..6fa600f87 --- /dev/null +++ b/src/basic/components/admin/product/ProductFormSection/ProductDiscountRow.tsx @@ -0,0 +1,79 @@ +import { Discount } from "../../../../../types"; +import { ProductForm } from "../../../../domain/product/productTypes"; + +interface ProductDiscountRowProps { + discount: Discount; + index: number; + productForm: ProductForm; + setProductForm: (value: React.SetStateAction) => void; +} + +export const ProductDiscountRow = ({ + discount, + index, + productForm, + setProductForm, +}: ProductDiscountRowProps) => { + return ( +
+ { + 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="%" + /> + % 할인 + +
+ ); +}; diff --git a/src/basic/components/admin/product/ProductFormSection/index.tsx b/src/basic/components/admin/product/ProductFormSection/index.tsx new file mode 100644 index 000000000..899b6d229 --- /dev/null +++ b/src/basic/components/admin/product/ProductFormSection/index.tsx @@ -0,0 +1,59 @@ +import { ProductForm } from "../../../../domain/product/productTypes"; +import { ProductBasicFields } from "./ProductBasicFields"; +import { ProductDiscountList } from "./ProductDiscountList"; + +interface ProductFormProps { + productForm: ProductForm; + setProductForm: React.Dispatch>; + titleText: string; + submitButtonText: string; + onSubmit: (e: React.FormEvent) => void; + onCancel: () => void; + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void; +} + +export const ProductFormSection = ({ + productForm, + setProductForm, + titleText, + submitButtonText, + onSubmit, + onCancel, + addNotification, +}: ProductFormProps) => { + return ( +
+

{titleText}

+ {/* 상품 기본 정보 */} + + {/* 할인 정책 */} + + + {/* 버튼 */} +
+ + +
+ + ); +}; + diff --git a/src/basic/components/admin/product/ProductListTable.tsx b/src/basic/components/admin/product/ProductListTable.tsx new file mode 100644 index 000000000..4d2c3179f --- /dev/null +++ b/src/basic/components/admin/product/ProductListTable.tsx @@ -0,0 +1,55 @@ +import { CartItem } from "../../../../types"; +import { PriceType } from "../../../constans/constans"; +import { ProductWithUI } from "../../../domain/product/productTypes"; +import { ProductTableRow } from "./ProductTableRow"; + +export interface ProductListTableProps { + cart: CartItem[]; + products: ProductWithUI[]; + startEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; +} + +export const ProductListTable = ({ + cart, + products, + startEditProduct, + deleteProduct, +}: ProductListTableProps) => { + const priceType = PriceType.KR; + return ( + + + + + + + + + + + + {products.map((product) => ( + startEditProduct(product)} + onClickDelete={() => deleteProduct(product.id)} + /> + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ ); +}; diff --git a/src/basic/components/admin/product/ProductTableRow.tsx b/src/basic/components/admin/product/ProductTableRow.tsx new file mode 100644 index 000000000..31addf234 --- /dev/null +++ b/src/basic/components/admin/product/ProductTableRow.tsx @@ -0,0 +1,59 @@ +import { CartItem } from "../../../../types"; +import { PriceType } from "../../../constans/constans"; +import { getDisplayPrice } from "../../../domain/cart/cartUtils"; +import { ProductWithUI } from "../../../domain/product/productTypes"; + +interface ProductTableRowProps { + cart: CartItem[]; + product: ProductWithUI; + priceType: PriceType; + onClickEdit: () => void; + onClickDelete: () => void; +} + +export const ProductTableRow = ({ + cart, + product, + priceType, + onClickEdit, + onClickDelete, +}: ProductTableRowProps) => { + const { id, name, stock, description } = product; + return ( + + + {name} + + + {getDisplayPrice(cart, product, priceType)} + + + 10 + ? "bg-green-100 text-green-800" + : stock > 0 + ? "bg-yellow-100 text-yellow-800" + : "bg-red-100 text-red-800" + }`}> + {stock}개 + + + + {description || "-"} + + + + + + + ); +}; diff --git a/src/basic/components/cart/CartItemRow.tsx b/src/basic/components/cart/CartItemRow.tsx new file mode 100644 index 000000000..a51ffaed9 --- /dev/null +++ b/src/basic/components/cart/CartItemRow.tsx @@ -0,0 +1,54 @@ +import { formatPrice } from "../../utils/formatters"; +import { CloseButton } from "../common/CloseButton"; +import { MinusButton } from "../common/MinusButton"; +import { PlusButton } from "../common/PlusButton"; + +interface CartItemRowProps { + id: string; + name: string; + quantity: number; + itemTotal: number; + hasDiscount: boolean; + discountRate: number; + removeFromCart: () => void; + updateQuantity: (productId: string, newQuantity: number) => void; +} + +export const CartItemRow = ({ + id, + name, + quantity, + itemTotal, + hasDiscount, + discountRate, + removeFromCart, + updateQuantity, +}: CartItemRowProps) => { + return ( +
+
+

{name}

+ +
+
+
+ updateQuantity(id, quantity - 1)} /> + + {quantity} + + updateQuantity(id, quantity + 1)} /> +
+
+ {hasDiscount && ( + + -{discountRate}% + + )} +

+ {formatPrice(Math.round(itemTotal))} +

+
+
+
+ ); +}; diff --git a/src/basic/components/cart/CartList.tsx b/src/basic/components/cart/CartList.tsx new file mode 100644 index 000000000..b0d9630a8 --- /dev/null +++ b/src/basic/components/cart/CartList.tsx @@ -0,0 +1,71 @@ +import { FilledCartItem } from "../../domain/cart/cartTypes"; + +import { CartIcon } from "../icon/CartIcon"; +import { EmptyCartIcon } from "../icon/EmptyCartIcon"; +import { CartItemRow } from "./CartItemRow"; + +interface CartListProps { + cart: FilledCartItem[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; +} + +const EmptyCartList = () => { + return ( +
+ +

장바구니가 비어있습니다

+
+ ); +}; + +const FilledCartList = ({ + cart, + removeFromCart, + updateQuantity, +}: CartListProps) => { + return ( +
+ {cart.map((item) => { + const { itemTotal, hasDiscount, discountRate } = item.priceDetails; + return ( + removeFromCart(item.product.id)} + updateQuantity={updateQuantity} + /> + ); + })} +
+ ); +}; + +export const CartList = ({ + cart, + removeFromCart, + updateQuantity, +}: CartListProps) => { + return ( +
+

+ + 장바구니 +

+ {cart.length === 0 ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/src/basic/components/cart/CartSidebar.tsx b/src/basic/components/cart/CartSidebar.tsx new file mode 100644 index 000000000..c135f3029 --- /dev/null +++ b/src/basic/components/cart/CartSidebar.tsx @@ -0,0 +1,43 @@ +import { CartSidebarProps } from "../../domain/product/productTypes"; + +import { formatCouponName } from "../../domain/cart/couponUtils"; + +import { CartList } from "./CartList"; +import { CouponSection } from "./CouponSection"; +import { PaymentSummary } from "./PaymentSummary"; + +export const CartSidebar = ({ + cartProps, + couponProps, + payment, +}: CartSidebarProps) => { + const { filledItems, removeFromCart, updateQuantity } = cartProps; + const { coupons, selectedCouponCode, selectorOnChange } = couponProps; + const { totals, completeOrder } = payment; + + return ( +
+ + {filledItems.length > 0 && ( + <> + 0} + selectedCouponCode={selectedCouponCode} + selectorOnChange={selectorOnChange} + /> + + + + )} +
+ ); +}; diff --git a/src/basic/components/cart/CouponSection.tsx b/src/basic/components/cart/CouponSection.tsx new file mode 100644 index 000000000..a49796d45 --- /dev/null +++ b/src/basic/components/cart/CouponSection.tsx @@ -0,0 +1,41 @@ +import { Coupon } from "../../../types"; +import { Selector } from "../common/Selector"; + +interface CouponSectionProps { + coupons: Coupon[]; + showSelector: boolean; + selectedCouponCode?: string; + selectorOnChange: (e: React.ChangeEvent) => void; + onAddCoupon?: () => void; +} + +export const CouponSection = ({ + coupons, + selectedCouponCode, + showSelector = false, + selectorOnChange, + onAddCoupon, +}: CouponSectionProps) => { + return ( +
+
+

쿠폰 할인

+ +
+ {showSelector && ( + + )} +
+ ); +}; diff --git a/src/basic/components/cart/PaymentSummary.tsx b/src/basic/components/cart/PaymentSummary.tsx new file mode 100644 index 000000000..ee689863e --- /dev/null +++ b/src/basic/components/cart/PaymentSummary.tsx @@ -0,0 +1,53 @@ +import { formatPrice } from "../../utils/formatters"; + +interface PaymentSummary { + totalBeforeDiscount: number; + totalAfterDiscount: number; + completeOrder: () => void; +} + +export const PaymentSummary = ({ + totalBeforeDiscount, + totalAfterDiscount, + completeOrder, +}: PaymentSummary) => { + return ( +
+

결제 정보

+
+
+ 상품 금액 + + {formatPrice(totalBeforeDiscount)} + +
+ + {totalBeforeDiscount - totalAfterDiscount > 0 && ( +
+ 할인 금액 + + -{formatPrice(totalBeforeDiscount - totalAfterDiscount)} + +
+ )} + +
+ 결제 예정 금액 + + {formatPrice(totalAfterDiscount)} + +
+
+ + + +
+

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

+
+
+ ); +}; diff --git a/src/basic/components/common/CloseButton.tsx b/src/basic/components/common/CloseButton.tsx new file mode 100644 index 000000000..19caafe54 --- /dev/null +++ b/src/basic/components/common/CloseButton.tsx @@ -0,0 +1,9 @@ +import { CloseIcon } from "../icon/CloseIcon"; + +export const CloseButton = ({ onClick }: { onClick: () => void }) => { + return ( + + ); +}; diff --git a/src/basic/components/common/FormInputField.tsx b/src/basic/components/common/FormInputField.tsx new file mode 100644 index 000000000..5a5b781b4 --- /dev/null +++ b/src/basic/components/common/FormInputField.tsx @@ -0,0 +1,34 @@ +interface FormInputFieldProps { + fieldName: string; + value: string | number; + onChange?: (e: React.ChangeEvent) => void; + onBlur?: (e: React.FocusEvent) => void; + placeholder?: string; + required?: boolean; // required 속성 제어 가능하도록 추가 +} + +export const FormInputField = ({ + fieldName, + value, + onChange, + onBlur, + placeholder, + required = true, // 기본값은 true (기존 동작 유지) +}: FormInputFieldProps) => { + return ( +
+ + +
+ ); +}; diff --git a/src/basic/components/common/FormSelectField.tsx b/src/basic/components/common/FormSelectField.tsx new file mode 100644 index 000000000..31eed5b07 --- /dev/null +++ b/src/basic/components/common/FormSelectField.tsx @@ -0,0 +1,35 @@ +import { Selector } from "./Selector"; + +interface FormSelectFieldProps { + fieldName: string; + value: string | number; + options: T[]; + valueKey: keyof T; + labelKey: keyof T; + onChange?: (e: React.ChangeEvent) => void; +} + +export const FormSelectField = ({ + fieldName, + value, + options = [], + valueKey, + labelKey, + onChange, +}: FormSelectFieldProps) => { + return ( +
+ + +
+ ); +}; diff --git a/src/basic/components/common/MinusButton.tsx b/src/basic/components/common/MinusButton.tsx new file mode 100644 index 000000000..d5576345a --- /dev/null +++ b/src/basic/components/common/MinusButton.tsx @@ -0,0 +1,14 @@ +interface MinusButtonProps { + className?: string; + onClick?: () => void; +} + +export const MinusButton = ({ className, onClick }: MinusButtonProps) => { + return ( + + ); +}; diff --git a/src/basic/components/common/PlusButton.tsx b/src/basic/components/common/PlusButton.tsx new file mode 100644 index 000000000..5ebd86613 --- /dev/null +++ b/src/basic/components/common/PlusButton.tsx @@ -0,0 +1,14 @@ +interface PlusButtonProps { + className?: string; + onClick?: () => void; +} + +export const PlusButton = ({ className, onClick }: PlusButtonProps) => { + return ( + + ); +}; diff --git a/src/basic/components/common/SearchBar.tsx b/src/basic/components/common/SearchBar.tsx new file mode 100644 index 000000000..abbf6151d --- /dev/null +++ b/src/basic/components/common/SearchBar.tsx @@ -0,0 +1,23 @@ +interface SearchBarProps { + searchTerm: string; + setSearchTerm: React.Dispatch>; + placeholder?: string; + className?: string; // 부모로부터 클래스 받기 +} + +export const SearchBar = ({ + searchTerm, + setSearchTerm, + placeholder = "검색...", + className = "", +}: SearchBarProps) => { + return ( + setSearchTerm(e.target.value)} + placeholder={placeholder} + className={`w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500 ${className}`} + /> + ); +}; diff --git a/src/basic/components/common/Selector.tsx b/src/basic/components/common/Selector.tsx new file mode 100644 index 000000000..6bb5e188b --- /dev/null +++ b/src/basic/components/common/Selector.tsx @@ -0,0 +1,33 @@ +interface SelectorProps { + className?: string; + defaultValue?: string; + value?: string | number; + options: T[]; + onChange?: (e: React.ChangeEvent) => void; + valueKey: keyof T; // option value로 쓸 필드 + labelKey: keyof T; // option label로 쓸 필드 +} + +export const Selector = ({ + className, + defaultValue, + value, + options: data, + valueKey, + labelKey, + onChange, +}: SelectorProps) => { + return ( + + ); +}; diff --git a/src/basic/components/common/Tabs.tsx b/src/basic/components/common/Tabs.tsx new file mode 100644 index 000000000..e2a78fdbe --- /dev/null +++ b/src/basic/components/common/Tabs.tsx @@ -0,0 +1,35 @@ +export interface TabItem { + key: T; + label: string; +} + +interface TabsProps { + items: TabItem[]; + activeKey: T; + onChange: (key: T) => void; +} + +export const Tabs = ({ + items, + activeKey, + onChange, +}: TabsProps) => { + return ( +
+ +
+ ); +}; diff --git a/src/basic/components/icon/CartIcon.tsx b/src/basic/components/icon/CartIcon.tsx new file mode 100644 index 000000000..75a78b86d --- /dev/null +++ b/src/basic/components/icon/CartIcon.tsx @@ -0,0 +1,16 @@ +export const CartIcon = () => { + return ( + + + + ); +}; diff --git a/src/basic/components/icon/CloseIcon.tsx b/src/basic/components/icon/CloseIcon.tsx new file mode 100644 index 000000000..d8d5097cb --- /dev/null +++ b/src/basic/components/icon/CloseIcon.tsx @@ -0,0 +1,16 @@ +export const CloseIcon = () => { + return ( + + + + ); +}; diff --git a/src/basic/components/icon/EmptyCartIcon.tsx b/src/basic/components/icon/EmptyCartIcon.tsx new file mode 100644 index 000000000..c881fed11 --- /dev/null +++ b/src/basic/components/icon/EmptyCartIcon.tsx @@ -0,0 +1,16 @@ +export const EmptyCartIcon = () => { + return ( + + + + ); +}; diff --git a/src/basic/components/icon/ImagePlaceholderIcon.tsx b/src/basic/components/icon/ImagePlaceholderIcon.tsx new file mode 100644 index 000000000..78b7cb446 --- /dev/null +++ b/src/basic/components/icon/ImagePlaceholderIcon.tsx @@ -0,0 +1,16 @@ +export const ImagePlaceholderIcon = () => { + return ( + + + + ); +}; diff --git a/src/basic/components/icon/PlusIcon.tsx b/src/basic/components/icon/PlusIcon.tsx new file mode 100644 index 000000000..dc689ffcb --- /dev/null +++ b/src/basic/components/icon/PlusIcon.tsx @@ -0,0 +1,16 @@ +export const PlusIcon = () => { + return ( + + + + ); +}; diff --git a/src/basic/components/icon/ShoppingCartIcon.tsx b/src/basic/components/icon/ShoppingCartIcon.tsx new file mode 100644 index 000000000..8f13d06a0 --- /dev/null +++ b/src/basic/components/icon/ShoppingCartIcon.tsx @@ -0,0 +1,16 @@ +export const ShoppingCartIcon = () => { + return ( + + + + ); +}; diff --git a/src/basic/components/icon/TrashIcon.tsx b/src/basic/components/icon/TrashIcon.tsx new file mode 100644 index 000000000..554a81e86 --- /dev/null +++ b/src/basic/components/icon/TrashIcon.tsx @@ -0,0 +1,16 @@ +export const TrashIcon = () => { + return ( + + + + ); +}; diff --git a/src/basic/components/layouts/DefaultLayout.tsx b/src/basic/components/layouts/DefaultLayout.tsx new file mode 100644 index 000000000..2b01267d8 --- /dev/null +++ b/src/basic/components/layouts/DefaultLayout.tsx @@ -0,0 +1,28 @@ +import { ReactNode } from "react"; +import { Header } from "./Header"; + +interface HeaderProps { + headerLeft?: React.ReactNode; + headerRight?: React.ReactNode; +} + +interface DefaultLayoutProps { + topContent?: ReactNode; + headerProps: HeaderProps; + children: React.ReactNode; +} + +export const DefaultLayout = ({ + topContent, + headerProps, + children, +}: DefaultLayoutProps) => { + return ( +
+ {topContent} + +
+
{children}
+
+ ); +}; diff --git a/src/basic/components/layouts/Header.tsx b/src/basic/components/layouts/Header.tsx new file mode 100644 index 000000000..5b1aac18d --- /dev/null +++ b/src/basic/components/layouts/Header.tsx @@ -0,0 +1,22 @@ +interface HeaderProps { + headerLeft?: React.ReactNode; + headerRight?: React.ReactNode; +} + +export const Header = ({ headerLeft, headerRight }: HeaderProps) => { + return ( +
+
+
+
+

SHOP

+ {headerLeft && ( +
{headerLeft}
+ )} +
+ +
+
+
+ ); +}; diff --git a/src/basic/components/layouts/HeaderActions.tsx b/src/basic/components/layouts/HeaderActions.tsx new file mode 100644 index 000000000..391da34ce --- /dev/null +++ b/src/basic/components/layouts/HeaderActions.tsx @@ -0,0 +1,40 @@ +import { CartItem } from "../../../types"; +import { ShoppingCartIcon } from "../icon/ShoppingCartIcon"; + +interface HeaderActionsProps { + isAdmin: boolean; + setIsAdmin: React.Dispatch>; + cart: CartItem[]; + totalItemCount: number; +} + +export const HeaderActions = ({ + isAdmin, + setIsAdmin, + cart, + totalItemCount, +}: HeaderActionsProps) => { + return ( + <> + + {!isAdmin && ( +
+ + {cart.length > 0 && ( + + {totalItemCount} + + )} +
+ )} + + ); +}; diff --git a/src/basic/components/product/ProductItem.tsx b/src/basic/components/product/ProductItem.tsx new file mode 100644 index 000000000..dd334c3b9 --- /dev/null +++ b/src/basic/components/product/ProductItem.tsx @@ -0,0 +1,91 @@ +import { CartItem } from "../../../types"; +import { PriceType } from "../../constans/constans"; +import { getDisplayPrice } from "../../domain/cart/cartUtils"; +import { ProductWithUI } from "../../domain/product/productTypes"; +import { ImagePlaceholderIcon } from "../icon/ImagePlaceholderIcon"; + +interface ProductItemProps { + format: PriceType; + cart: CartItem[]; + product: ProductWithUI; + remainingStock: number; + addToCart: (product: ProductWithUI) => void; +} + +export const ProductItem = ({ + format, + cart, + product, + remainingStock, + addToCart, +}: ProductItemProps) => { + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

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

+ {getDisplayPrice(cart, product, format)} +

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

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

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

+ 품절임박! {remainingStock}개 남음 +

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

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; diff --git a/src/basic/components/product/ProductList.tsx b/src/basic/components/product/ProductList.tsx new file mode 100644 index 000000000..f689fcab0 --- /dev/null +++ b/src/basic/components/product/ProductList.tsx @@ -0,0 +1,60 @@ +import { CartItem } from "../../../types"; +import { PriceType } from "../../constans/constans"; +import { getRemainingStock } from "../../domain/cart/cartUtils"; +import { ProductWithUI } from "../../domain/product/productTypes"; +import { ProductItem } from "./ProductItem"; + +interface ProductListProps { + format: PriceType; + cart: CartItem[]; + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + addToCart: (product: ProductWithUI) => void; +} + +export const ProductList = ({ + format, + cart, + products, + filteredProducts, + debouncedSearchTerm, + addToCart, +}: ProductListProps) => { + const EmptyProduct = () => { + return ( +
+

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

+
+ ); + }; + return ( +
+
+

전체 상품

+
총 {products.length}개 상품
+
+ {filteredProducts.length === 0 ? ( + + ) : ( +
+ {filteredProducts.map((product) => { + const remainingStock = getRemainingStock(cart, product); + return ( + + ); + })} +
+ )} +
+ ); +}; diff --git a/src/basic/constans/constans.ts b/src/basic/constans/constans.ts new file mode 100644 index 000000000..3f943c692 --- /dev/null +++ b/src/basic/constans/constans.ts @@ -0,0 +1,9 @@ +export enum PriceType { + KR = "kr", + EN = "en", +} + +export enum DiscountType { + AMOUNT = "amount", + PRECENTAGE = "percentage", +} diff --git a/src/basic/domain/cart/cartTypes.ts b/src/basic/domain/cart/cartTypes.ts new file mode 100644 index 000000000..1d9df239b --- /dev/null +++ b/src/basic/domain/cart/cartTypes.ts @@ -0,0 +1,9 @@ +import { CartItem } from "../../../types"; + +export type FilledCartItem = CartItem & { + priceDetails: { + itemTotal: number; + hasDiscount: boolean; + discountRate: number; + }; +}; diff --git a/src/basic/domain/cart/cartUtils.ts b/src/basic/domain/cart/cartUtils.ts new file mode 100644 index 000000000..4c6e15964 --- /dev/null +++ b/src/basic/domain/cart/cartUtils.ts @@ -0,0 +1,157 @@ +import { CartItem, Coupon, Product } from "../../../types"; +import { PriceType } from "../../constans/constans"; +import { formatPrice } from "../../utils/formatters"; +import { ProductWithUI } from "../product/productTypes"; + +/** ============================= + * 정책 상수 + * ============================== */ +export const BULK_EXTRA_DISCOUNT = 0.05; // 대량 구매 시 추가 할인율 (5% 할인) +export const MAX_DISCOUNT_RATE = 0.5; // 총 할인율 최대 상한(50%) +export const BULK_PURCHASE_THRESHOLD = 10; // 대량 구매 기준 수량 + +/** ============================= + * 순수 계산 함수 + * ============================== */ + +// 장바구니에서 대량 구매 아이템이 있는지 확인 +export const hasBulkPurchase = (quantities: number[]): boolean => + quantities.some((q) => q >= BULK_PURCHASE_THRESHOLD); + +// 특정 CartItem에 대해 '기본 할인율'을 계산 +export const getBaseDiscount = (item: CartItem): number => { + const { quantity } = item; + + const applicableDiscounts = item.product.discounts + .filter((d) => quantity >= d.quantity) + .map((d) => d.rate); + + return applicableDiscounts.length ? Math.max(...applicableDiscounts) : 0; +}; + +/** + * 최종 할인율 계산 (순수 함수) + * @param baseDiscount 기본 할인율 + * @param bulkBonus 대량 구매 보너스 할인율 + * @returns 최종 할인율 (상한 적용) + */ +export const calculateFinalDiscount = ( + baseDiscount: number, + bulkBonus: number +): number => { + return Math.min(baseDiscount + bulkBonus, MAX_DISCOUNT_RATE); +}; + +// 최종 할인 계산 함수 (순수 함수 형태를 유지하기 위해 cart 인자 추가) +export const getMaxApplicableDiscount = ( + item: CartItem, + cart: CartItem[] +): number => { + const baseDiscount = getBaseDiscount(item); + const bulkBonus = hasBulkPurchase(cart.map((i) => i.quantity)) + ? BULK_EXTRA_DISCOUNT + : 0; + + return calculateFinalDiscount(baseDiscount, bulkBonus); +}; + +// 단일 아이템 최종 금액 계산 +export const calculateItemTotal = ( + price: number, + quantity: number, + discount: number +): number => { + return Math.round(price * quantity * (1 - discount)); +}; + +// 장바구니 총액 계산 (쿠폰 적용 포함) +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + const totalBeforeDiscount = cart.reduce( + (sum, item) => sum + item.product.price * item.quantity, + 0 + ); + + const totalAfterItemDiscount = cart.reduce( + (sum, item) => + sum + + calculateItemTotal( + item.product.price, + item.quantity, + getMaxApplicableDiscount(item, cart) + ), + 0 + ); + + const totalAfterDiscount = selectedCoupon + ? applyCoupon(totalAfterItemDiscount, selectedCoupon) + : totalAfterItemDiscount; + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; + +// "총 금액 + 할인 여부 + 할인율” 같은 세부 정보를 반환하는 함수 +export const calculateItemPriceDetails = (item: CartItem, cart: CartItem[]) => { + const itemTotal = calculateItemTotal( + item.product.price, + item.quantity, + getMaxApplicableDiscount(item, cart) + ); + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount + ? Math.round((1 - itemTotal / originalPrice) * 100) + : 0; + return { itemTotal, hasDiscount, discountRate }; +}; + +export const getDisplayPrice = ( + cart: CartItem[], + product: ProductWithUI, + format: PriceType +): string => { + if (isSoldOut(cart, product, product.id)) { + return "SOLD OUT"; + } + + return formatPrice(product.price, format); +}; + +// 재고 없는지 여부 확인 +export const isSoldOut = ( + cart: CartItem[], + product: ProductWithUI, + productId?: string +): boolean => { + if (!productId) return false; + return product ? getRemainingStock(cart, product) <= 0 : false; +}; + +// 재고 잔량 확인 +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 applyCoupon = (amount: number, coupon: Coupon): number => { + if (coupon.discountType === "amount") { + return Math.max(0, amount - coupon.discountValue); + } + // percent 타입 + return Math.round(amount * (1 - coupon.discountValue / 100)); +}; diff --git a/src/basic/domain/cart/couponUtils.ts b/src/basic/domain/cart/couponUtils.ts new file mode 100644 index 000000000..d5fdbbcc5 --- /dev/null +++ b/src/basic/domain/cart/couponUtils.ts @@ -0,0 +1,12 @@ +import { Coupon } from "../../../types"; + +export const formatCouponName = (coupons: Coupon[]) => { + return coupons.map((coupon) => ({ + ...coupon, + name: `${coupon.name} (${ + coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원` + : `${coupon.discountValue}%` + })`, + })); +}; diff --git a/src/basic/domain/notification/notificationTypes.ts b/src/basic/domain/notification/notificationTypes.ts new file mode 100644 index 000000000..9f97fcbb6 --- /dev/null +++ b/src/basic/domain/notification/notificationTypes.ts @@ -0,0 +1,5 @@ +export interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} diff --git a/src/basic/domain/product/productTypes.ts b/src/basic/domain/product/productTypes.ts new file mode 100644 index 000000000..0d21b6ca5 --- /dev/null +++ b/src/basic/domain/product/productTypes.ts @@ -0,0 +1,45 @@ +import { CartItem, Coupon, Discount, Product } from "../../../types"; +import { FilledCartItem } from "../cart/cartTypes"; + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export interface StorePageProps { + productProps: ProductListProps; + cartSidebarProps: CartSidebarProps; +} + +export interface ProductListProps { + cart: CartItem[]; + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + addToCart: (product: ProductWithUI) => void; +} + +export interface CartSidebarProps { + cartProps: { + filledItems: FilledCartItem[]; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + }; + couponProps: { + coupons: Coupon[]; + selectedCouponCode: string; + selectorOnChange: (e: React.ChangeEvent) => void; + }; + payment: { + totals: { totalBeforeDiscount: number; totalAfterDiscount: number }; + completeOrder: () => void; + }; +} + +export interface ProductForm { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} diff --git a/src/basic/domain/product/productUtils.ts b/src/basic/domain/product/productUtils.ts new file mode 100644 index 000000000..fc6736a87 --- /dev/null +++ b/src/basic/domain/product/productUtils.ts @@ -0,0 +1,20 @@ +import { ProductWithUI } from "./productTypes"; + +export const filterProductsBySearchTerm = ( + debouncedSearchTerm: string, + products: Array +) => { + const filteredProducts = debouncedSearchTerm + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase())) + ) + : products; + return filteredProducts; +}; diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 000000000..b3ca9cce9 --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,153 @@ +import { useState, useCallback, useEffect, useMemo } from "react"; +import { CartItem } from "../../types"; +import { ProductWithUI } from "../domain/product/productTypes"; +import { + calculateItemPriceDetails, + getRemainingStock, +} from "../domain/cart/cartUtils"; +import { FilledCartItem } from "../domain/cart/cartTypes"; + +/** + * 장바구니 Entity 관련 상태 및 로직을 관리하는 Hook + * + * Entity를 다루는 Hook + * - Cart Entity의 상태 관리 및 로직 + */ +export const useCart = ( + products: ProductWithUI[], + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void +) => { + const [cart, setCart] = useState(() => { + const saved = localStorage.getItem("cart"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return []; + } + } + return []; + }); + + const [totalItemCount, setTotalItemCount] = useState(0); + + // localStorage 동기화 + useEffect(() => { + if (cart.length > 0) { + localStorage.setItem("cart", JSON.stringify(cart)); + } else { + localStorage.removeItem("cart"); + } + }, [cart]); + + // 장바구니 아이템 총 개수 계산 + useEffect(() => { + const count = cart.reduce((sum, item) => sum + item.quantity, 0); + setTotalItemCount(count); + }, [cart]); + + // 장바구니 아이템에 가격 정보 추가 (계산된 값) + const filledItems = useMemo( + (): FilledCartItem[] => + cart.map((item) => ({ + ...item, + priceDetails: calculateItemPriceDetails(item, cart), + })), + [cart] + ); + + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(cart, 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] + ); + + 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] + ); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + "success" + ); + setCart([]); + // selectedCoupon 초기화는 useCoupon의 책임이므로 여기서는 cart만 초기화 + }, [addNotification]); + + return { + cart, + totalItemCount, + filledItems, + addToCart, + removeFromCart, + updateQuantity, + completeOrder, + }; +}; diff --git a/src/basic/hooks/useCoupon.ts b/src/basic/hooks/useCoupon.ts new file mode 100644 index 000000000..e6ff40303 --- /dev/null +++ b/src/basic/hooks/useCoupon.ts @@ -0,0 +1,142 @@ +import { useState, useCallback, useEffect } from "react"; +import { Coupon } from "../../types"; +import { calculateCartTotal } from "../domain/cart/cartUtils"; +import { CartItem } from "../../types"; + +// 초기 쿠폰 데이터 +const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; + +/** + * 쿠폰 Entity 관련 상태 및 로직을 관리하는 Hook + * + * Entity를 다루는 Hook + * - Coupon Entity의 상태 관리 및 CRUD 로직 + */ +export const useCoupon = ( + cart: CartItem[], + addNotification: ( + message: string, + type?: "error" | "success" | "warning" + ) => void +) => { + 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 [couponForm, setCouponForm] = useState({ + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0, + }); + + const [showCouponForm, setShowCouponForm] = useState(false); + + // localStorage 동기화 + useEffect(() => { + localStorage.setItem("coupons", JSON.stringify(coupons)); + }, [coupons]); + + 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 applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal( + cart, + selectedCoupon + ).totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + addNotification( + "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", + "error" + ); + return; + } + + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + [cart, selectedCoupon, addNotification] + ); + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm({ + name: "", + code: "", + discountType: "amount", + discountValue: 0, + }); + setShowCouponForm(false); + }; + + const selectorOnChange = (e: React.ChangeEvent) => { + const coupon = coupons.find((c) => c.code === e.target.value); + if (coupon) { + applyCoupon(coupon); + } else { + setSelectedCoupon(null); + } + }; + + return { + coupons, + selectedCoupon, + couponForm, + showCouponForm, + setSelectedCoupon, + setCouponForm, + setShowCouponForm, + addCoupon, + deleteCoupon, + applyCoupon, + handleCouponSubmit, + selectorOnChange, + }; +}; diff --git a/src/basic/hooks/useNotification.ts b/src/basic/hooks/useNotification.ts new file mode 100644 index 000000000..218ce2000 --- /dev/null +++ b/src/basic/hooks/useNotification.ts @@ -0,0 +1,31 @@ +import { useState, useCallback } from "react"; +import { Notification } from "../domain/notification/notificationTypes"; + +/** + * 알림 관련 상태 및 로직을 관리하는 Hook + * + * Entity를 다루지 않는 UI 상태 Hook + * - 알림은 비즈니스 엔티티가 아닌 UI 피드백 + */ +export const useNotification = () => { + const [notifications, setNotifications] = useState([]); + + 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); + }, + [] + ); + + return { + notifications, + setNotifications, + addNotification, + }; +}; + diff --git a/src/basic/hooks/useProduct.ts b/src/basic/hooks/useProduct.ts new file mode 100644 index 000000000..be24a993e --- /dev/null +++ b/src/basic/hooks/useProduct.ts @@ -0,0 +1,156 @@ +import { useState, useCallback, useEffect } from "react"; +import { Discount } from "../../types"; +import { ProductForm, ProductWithUI } from "../domain/product/productTypes"; + +// 초기 데이터 +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: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; + +/** + * 상품 Entity 관련 상태 및 로직을 관리하는 Hook + * + * Entity를 다루는 Hook + * - Product Entity의 상태 관리 및 CRUD 로직 + */ +export const useProduct = (addNotification: (message: string, type?: "error" | "success" | "warning") => void) => { + const [products, setProducts] = useState(() => { + const saved = localStorage.getItem("products"); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return initialProducts; + } + } + return initialProducts; + }); + + const [productForm, setProductForm] = useState({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] as Array, + }); + + const [editingProduct, setEditingProduct] = useState(null); + const [showProductForm, setShowProductForm] = useState(false); + + // localStorage 동기화 + useEffect(() => { + localStorage.setItem("products", JSON.stringify(products)); + }, [products]); + + 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 startEditProduct = useCallback((product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [], + }); + setShowProductForm(true); + }, []); + + 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); + }; + + return { + products, + productForm, + editingProduct, + showProductForm, + setProductForm, + setEditingProduct, + setShowProductForm, + addProduct, + updateProduct, + deleteProduct, + startEditProduct, + handleProductSubmit, + }; +}; + diff --git a/src/basic/hooks/useSearch.ts b/src/basic/hooks/useSearch.ts new file mode 100644 index 000000000..6612cb266 --- /dev/null +++ b/src/basic/hooks/useSearch.ts @@ -0,0 +1,26 @@ +import { useState, useEffect } from "react"; + +/** + * 검색어 상태 및 디바운스 처리를 관리하는 Hook + * + * Entity를 다루지 않는 UI 상태 Hook + * - 검색어는 UI 입력 상태 + */ +export const useSearch = () => { + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 500); + return () => clearTimeout(timer); + }, [searchTerm]); + + return { + searchTerm, + setSearchTerm, + debouncedSearchTerm, + }; +}; + diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx new file mode 100644 index 000000000..7b3ef94c4 --- /dev/null +++ b/src/basic/pages/AdminPage.tsx @@ -0,0 +1,39 @@ +import { AdminTabKey, AdminTabs } from "../components/admin/common/AdminTabs"; +import { + AdminCouponSection, + AdminCouponSectionProps, +} from "../components/admin/coupon/AdminCouponSection"; +import { + AdminProductsSection, + AdminProductsSectionProps, +} from "../components/admin/product/AdminProductsSection"; + +interface AdminPageProps { + activeTab: AdminTabKey; + adminProductsProps: AdminProductsSectionProps; + adminCouponProps: AdminCouponSectionProps; + setActiveTab: React.Dispatch>; +} + +export const AdminPage = ({ + activeTab, + adminProductsProps, + adminCouponProps, + setActiveTab, +}: AdminPageProps) => { + return ( +
+
+

관리자 대시보드

+

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

+
+ + + {activeTab === "products" ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/src/basic/pages/StorePage.tsx b/src/basic/pages/StorePage.tsx new file mode 100644 index 000000000..997beb11d --- /dev/null +++ b/src/basic/pages/StorePage.tsx @@ -0,0 +1,46 @@ +import { CartSidebar } from "../components/cart/CartSidebar"; +import { ProductList } from "../components/product/ProductList"; +import { PriceType } from "../constans/constans"; +import { + CartSidebarProps, + ProductListProps, +} from "../domain/product/productTypes"; + +interface StorePageProps { + productProps: ProductListProps; + cartSidebarProps: CartSidebarProps; +} + +export const StorePage = ({ + productProps, + cartSidebarProps, +}: StorePageProps) => { + const format = PriceType.KR; + + const { cart, products, filteredProducts, debouncedSearchTerm, addToCart } = + productProps; + const { cartProps, couponProps, payment } = cartSidebarProps; + return ( +
+
+ {/* 상품 목록 */} + +
+ +
+ +
+
+ ); +}; diff --git a/src/basic/utils/formatters.ts b/src/basic/utils/formatters.ts new file mode 100644 index 000000000..eb05731e4 --- /dev/null +++ b/src/basic/utils/formatters.ts @@ -0,0 +1,8 @@ +export const formatPrice = (price: number, type: "kr" | "en" = "kr") => { + const formatters = { + kr: `${price.toLocaleString()}원`, + en: `₩${price.toLocaleString()}`, + }; + + return formatters[type]; +}; diff --git a/tsconfig.app.json b/tsconfig.app.json index d739292ae..935e12e17 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -23,5 +23,10 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": [ + "src/basic", + "src/origin", + "src/refactoring(hint)" + ] } diff --git a/vite.config.ts b/vite.config.ts index e6c4016bc..db621b7f7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,40 @@ -import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config'; -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react-swc'; +/// +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "path"; -export default mergeConfig( - defineConfig({ +// https://vite.dev/config/ +const dirname = + typeof __dirname !== "undefined" + ? __dirname + : path.dirname(new URL(import.meta.url).pathname); + +// GitHub Pages 배포를 위한 base 경로 설정 +// 저장소 이름이 URL에 포함되므로 저장소 이름을 base 경로로 설정 +const base: string = + process.env.NODE_ENV === "production" ? "/front_7th_chapter3-2/" : ""; + +export default defineConfig(({ command }) => { + // 빌드 모드에서는 test 설정 제외 + const isBuild = command === "build"; + + return { + base, // GitHub Pages 배포 경로 설정 plugins: [react()], - }), - defineTestConfig({ - test: { - globals: true, - environment: 'jsdom', - setupFiles: './src/setupTests.ts' + resolve: { + alias: { + "@": path.resolve(dirname, "./src"), + }, }, - }) -) + // test 설정은 개발/테스트 환경에서만 사용 + ...(isBuild + ? {} + : { + test: { + globals: true, + environment: "jsdom", + setupFiles: "./src/setupTests.ts", + }, + }), + }; +}); diff --git "a/\353\254\270\354\240\234_\355\225\264\352\262\260_\354\232\224\354\225\275.md" "b/\353\254\270\354\240\234_\355\225\264\352\262\260_\354\232\224\354\225\275.md" new file mode 100644 index 000000000..4545202e3 --- /dev/null +++ "b/\353\254\270\354\240\234_\355\225\264\352\262\260_\354\232\224\354\225\275.md" @@ -0,0 +1,96 @@ +# ✅ 문제 해결 요약 + +## 🔍 발견된 문제 + +### ❌ 문제: `.nojekyll` 파일 누락 + +**원인:** + +- GitHub Pages는 기본적으로 Jekyll을 사용합니다 +- `.nojekyll` 파일이 없으면 Jekyll이 빌드된 정적 파일을 처리하려고 시도합니다 +- 이로 인해 `assets` 폴더나 JavaScript 파일이 제대로 로드되지 않을 수 있습니다 + +**증상:** + +- gh-pages 브랜치에 `index.html` 파일이 있고 +- GitHub Actions 로그가 성공했지만 +- 여전히 404 에러 발생 + +--- + +## ✅ 해결 방법 + +### 1. `.nojekyll` 파일 추가 + +`public/.nojekyll` 파일을 생성했습니다. 이 파일은: + +- 빌드 시 `dist/` 폴더로 자동 복사됩니다 +- GitHub Pages가 Jekyll 처리를 건너뛰도록 합니다 +- 정적 파일들이 올바르게 서빙되도록 합니다 + +--- + +## 📋 다음 단계 + +### 1. 변경사항 커밋 및 푸시 + +```bash +git add public/.nojekyll +git commit -m "Add .nojekyll file for GitHub Pages" +git push +``` + +### 2. GitHub Actions 워크플로우 확인 + +1. `https://github.com/jumoooo/front_7th_chapter3-2/actions` 접속 +2. 가장 최근 워크플로우 실행 확인 +3. 성공 여부 확인 + +### 3. 배포 확인 + +1. 워크플로우 완료 후 2-3분 대기 +2. `https://jumoooo.github.io/front_7th_chapter3-2/` 접속 +3. `src/advanced` 화면이 표시되는지 확인 + +--- + +## 🔍 추가 확인 사항 + +배포 후에도 404 에러가 발생한다면: + +1. **gh-pages 브랜치에 `.nojekyll` 파일이 있는지 확인** + + - URL: `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages` + - `.nojekyll` 파일이 루트에 있어야 합니다 + +2. **assets 폴더가 있는지 확인** + + - URL: `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages/assets` + - JavaScript 파일이 있어야 합니다 + +3. **브라우저 캐시 삭제** + - Ctrl + Shift + R (Windows/Linux) + - Cmd + Shift + R (Mac) + +--- + +## 📝 참고 + +### `.nojekyll` 파일이란? + +- GitHub Pages는 기본적으로 Jekyll을 사용하여 사이트를 빌드합니다 +- `.nojekyll` 파일이 있으면 Jekyll 처리를 건너뛰고 정적 파일을 그대로 서빙합니다 +- Vite로 빌드한 정적 사이트에는 이 파일이 필요합니다 + +### 파일 구조 + +``` +public/ +└── .nojekyll ← 이 파일이 빌드 시 dist/로 복사됨 + +dist/ +├── .nojekyll ← 배포 시 gh-pages 브랜치로 복사됨 +├── index.html +└── assets/ + └── index-*.js +``` diff --git "a/\354\240\204\354\262\264_\355\214\214\354\235\274_\354\204\244\354\240\225_\352\262\200\354\246\235.md" "b/\354\240\204\354\262\264_\355\214\214\354\235\274_\354\204\244\354\240\225_\352\262\200\354\246\235.md" new file mode 100644 index 000000000..94b2f2ea3 --- /dev/null +++ "b/\354\240\204\354\262\264_\355\214\214\354\235\274_\354\204\244\354\240\225_\352\262\200\354\246\235.md" @@ -0,0 +1,212 @@ +# 🔍 전체 파일 설정 검증 보고서 + +## 📋 검증 목적 + +404 에러가 발생하는 원인을 찾기 위해 `front_7th_chapter3-2`의 전체 파일 설정을 `front_7th_chapter3-1`과 비교하여 검증합니다. + +--- + +## ✅ 검증 완료 항목 + +### 1. vite.config.ts 설정 + +**front_7th_chapter3-2:** + +```typescript +const base: string = + process.env.NODE_ENV === "production" ? "/front_7th_chapter3-2/" : ""; +``` + +**front_7th_chapter3-1:** + +```typescript +const base: string = + process.env.NODE_ENV === "production" ? "/front_7th_chapter3-1/" : ""; +``` + +✅ **결과**: base path 설정이 올바릅니다. + +--- + +### 2. index.html 파일 + +**front_7th_chapter3-2:** + +- 위치: `index.html` (루트) +- Entry point: `/src/advanced/main.tsx` + +**front_7th_chapter3-1:** + +- 위치: `packages/after/index.html` +- Entry point: `/src/main.tsx` + +✅ **결과**: index.html이 올바른 위치에 있고 올바른 entry point를 참조합니다. + +--- + +### 3. package.json 빌드 스크립트 + +**front_7th_chapter3-2:** + +```json +"build:advanced": "tsc -b && vite build --config vite.config.ts" +``` + +**front_7th_chapter3-1:** + +```json +"build:after": "pnpm --filter @front_lite_chapter3-1/after build" +``` + +✅ **결과**: 빌드 스크립트가 올바르게 설정되어 있습니다. + +--- + +### 4. GitHub Actions 워크플로우 + +**front_7th_chapter3-2:** + +```yaml +- name: Build advanced package + run: pnpm build:advanced + env: + NODE_ENV: production + +- name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist +``` + +**front_7th_chapter3-1:** + +```yaml +- name: Build after package + run: pnpm build:after + env: + NODE_ENV: production + +- name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./packages/after/dist +``` + +✅ **결과**: 워크플로우 설정이 올바릅니다. + +--- + +### 5. 빌드 결과물 검증 + +**로컬 빌드 결과 (dist/):** + +``` +dist/ +├── index.html +├── assets/ +│ └── index-BVSKioPT.js +└── vite.svg +``` + +**빌드된 index.html 내용:** + +```html + +``` + +✅ **결과**: + +- base path가 올바르게 적용되어 있습니다. +- JavaScript 파일 경로가 올바릅니다. + +--- + +### 6. .nojekyll 파일 ✅ 새로 추가 + +**문제점 발견:** + +- GitHub Pages는 기본적으로 Jekyll을 사용합니다. +- `.nojekyll` 파일이 없으면 Jekyll이 빌드된 파일을 처리하려고 시도할 수 있습니다. + +**해결:** + +- `public/.nojekyll` 파일을 생성했습니다. +- 이 파일은 빌드 시 `dist/` 폴더로 자동 복사됩니다. + +--- + +## 🔍 발견된 문제점 + +### ❌ 문제 1: .nojekyll 파일 누락 + +**상태**: ✅ 해결됨 + +`.nojekyll` 파일이 없어서 GitHub Pages가 Jekyll 처리 중 문제가 발생할 수 있었습니다. 이제 해결되었습니다. + +--- + +## 📋 추가 확인 사항 + +### 1. gh-pages 브랜치 확인 + +다음 항목들을 확인해주세요: + +1. **gh-pages 브랜치에 `.nojekyll` 파일이 있는가?** + + - URL: `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages` + - `.nojekyll` 파일이 루트에 있어야 합니다. + +2. **assets 폴더가 있는가?** + + - URL: `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages/assets` + - JavaScript 파일이 있어야 합니다. + +3. **index.html 파일 경로가 올바른가?** + - JavaScript 파일 경로가 `/front_7th_chapter3-2/assets/index-*.js`인지 확인 + +--- + +### 2. 재배포 필요 + +`.nojekyll` 파일을 추가했으므로, 다음 단계를 진행해야 합니다: + +1. **변경사항 커밋 및 푸시:** + + ```bash + git add public/.nojekyll + git commit -m "Add .nojekyll file for GitHub Pages" + git push + ``` + +2. **GitHub Actions 워크플로우 확인:** + + - Actions 탭에서 워크플로우 실행 확인 + - 성공 여부 확인 + +3. **배포 확인:** + - 2-3분 대기 후 사이트 접속 + - `https://jumoooo.github.io/front_7th_chapter3-2/` 접속 + +--- + +## 🎯 결론 + +**파일 설정 자체는 올바르게 구성되어 있습니다!** + +하지만 `.nojekyll` 파일이 누락되어 있었습니다. 이 파일이 없으면 GitHub Pages가 Jekyll로 빌드된 파일을 처리하려고 시도할 수 있어서 404 에러가 발생할 수 있습니다. + +`.nojekyll` 파일을 추가했으니, 이제 재배포하면 문제가 해결될 것입니다. + +--- + +## 📝 다음 단계 + +1. ✅ `.nojekyll` 파일 추가 완료 +2. ⏳ 변경사항 커밋 및 푸시 (사용자 작업) +3. ⏳ GitHub Actions 워크플로우 확인 (사용자 작업) +4. ⏳ 배포된 사이트 확인 (사용자 작업) diff --git "a/\354\240\204\354\262\264_\355\231\225\354\235\270_\354\262\264\355\201\254\353\246\254\354\212\244\355\212\270.md" "b/\354\240\204\354\262\264_\355\231\225\354\235\270_\354\262\264\355\201\254\353\246\254\354\212\244\355\212\270.md" new file mode 100644 index 000000000..7e0f0345c --- /dev/null +++ "b/\354\240\204\354\262\264_\355\231\225\354\235\270_\354\262\264\355\201\254\353\246\254\354\212\244\355\212\270.md" @@ -0,0 +1,339 @@ +# 🔍 404 에러 해결 - 전체 확인 체크리스트 + +## 🎯 목표 +`https://jumoooo.github.io/front_7th_chapter3-2/` 에서 `src/advanced` 화면이 정상적으로 표시되도록 설정 + +--- + +## 📋 확인해야 할 모든 항목들 + +### ✅ 1. GitHub 저장소 기본 설정 + +#### 1-1. 저장소 이름 확인 +- **확인 위치**: GitHub 저장소 URL +- **올바른 이름**: `front_7th_chapter3-2` (정확히 일치해야 함) +- **확인 방법**: + - URL: `https://github.com/jumoooo/front_7th_chapter3-2` + - 저장소 이름이 다르다면 `vite.config.ts`의 base path도 함께 수정 필요 + +#### 1-2. 저장소 공개 여부 확인 +- **확인 위치**: GitHub 저장소 → Settings → General → Danger Zone 위 +- **확인 사항**: + - Public 저장소인지 확인 + - Private 저장소는 GitHub Pro가 필요할 수 있음 +- **Public으로 변경**: Settings → General → Change repository visibility + +--- + +### ✅ 2. GitHub Pages 설정 (가장 중요!) + +#### 2-1. Pages 메뉴 접근 +- **위치**: GitHub 저장소 → Settings → Pages +- **URL**: `https://github.com/jumoooo/front_7th_chapter3-2/settings/pages` + +#### 2-2. Source 설정 확인 ⚠️ +- **현재 설정 확인**: Source 드롭다운에서 현재 선택된 옵션 확인 +- **올바른 설정**: + ``` + Source: GitHub Actions ✅ + ``` +- **잘못된 설정들**: + - ❌ "None" (비활성화됨) + - ❌ "Deploy from a branch" + - ❌ "GitHub Actions"가 아닌 다른 옵션 + +#### 2-3. Source 변경 방법 +1. Source 드롭다운 클릭 +2. **"GitHub Actions"** 선택 +3. **Save** 버튼 클릭 +4. 페이지 새로고침 후 다시 확인 + +#### 2-4. Custom domain 설정 확인 +- **확인 위치**: Settings → Pages → Custom domain +- **확인 사항**: Custom domain이 설정되어 있다면 비활성화 +- **해결**: Custom domain 필드 비우기 (배포가 완료된 후 다시 설정 가능) + +--- + +### ✅ 3. GitHub Actions 설정 + +#### 3-1. Actions 활성화 확인 +- **확인 위치**: Settings → Actions → General +- **확인 사항**: + - "Allow all actions and reusable workflows" 선택되어 있는지 + - Actions가 활성화되어 있는지 + +#### 3-2. Actions 권한 확인 +- **확인 위치**: Settings → Actions → General → Workflow permissions +- **권장 설정**: + - ✅ "Read and write permissions" + - ✅ "Allow GitHub Actions to create and approve pull requests" + +#### 3-3. 워크플로우 파일 확인 +- **파일 위치**: `.github/workflows/deploy.yml` +- **확인 사항**: + - 파일이 존재하는지 + - 파일 내용이 올바른지 + - `publish_dir: ./dist` 설정이 맞는지 + +#### 3-4. 워크플로우 실행 확인 +- **확인 위치**: GitHub 저장소 → Actions 탭 +- **확인 사항**: + - 워크플로우가 실행되었는지 + - 실행 상태 (✅ 성공 / ❌ 실패 / 🟡 진행 중) + - 최근 실행 로그 확인 + +--- + +### ✅ 4. gh-pages 브랜치 확인 + +#### 4-1. 브랜치 존재 확인 +- **확인 위치**: GitHub 저장소 → Code → 브랜치 목록 +- **URL**: `https://github.com/jumoooo/front_7th_chapter3-2/branches` +- **확인 사항**: `gh-pages` 브랜치가 있는지 + +#### 4-2. 브랜치 내용 확인 +- **URL**: `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages` +- **필수 파일들**: + - ✅ `index.html` + - ✅ `assets/` 폴더 + - ✅ JavaScript 파일들 + +#### 4-3. 브랜치가 없다면 +- **원인**: GitHub Actions가 아직 실행되지 않았거나 실패함 +- **해결**: GitHub Actions 워크플로우를 실행해야 함 + +--- + +### ✅ 5. 로컬 빌드 확인 + +#### 5-1. 빌드 실행 +```bash +pnpm build:advanced +``` + +#### 5-2. 빌드 결과 확인 +```bash +ls -la dist/ +``` + +#### 5-3. 필수 파일 확인 +- ✅ `dist/index.html` 파일 존재 +- ✅ `dist/assets/` 폴더 존재 +- ✅ JavaScript 파일들 존재 + +#### 5-4. index.html 내용 확인 +```bash +cat dist/index.html +``` +- **확인 사항**: + - base path가 `/front_7th_chapter3-2/`로 설정되어 있는지 + - JavaScript 파일 경로가 올바른지 + +--- + +### ✅ 6. 코드 변경사항 확인 + +#### 6-1. 변경된 파일 확인 +```bash +git status +``` + +#### 6-2. 필수 파일들 확인 +- ✅ `index.html` (새로 생성됨) +- ✅ `vite.config.ts` (수정됨) +- ✅ `.github/workflows/deploy.yml` (확인) + +#### 6-3. 커밋 및 Push +```bash +git add . +git commit -m "fix: GitHub Pages 배포 설정" +git push +``` + +--- + +### ✅ 7. 브라우저 관련 확인 + +#### 7-1. 브라우저 캐시 확인 +- **문제**: 오래된 캐시가 남아있을 수 있음 +- **해결 방법**: + - Windows: `Ctrl + Shift + R` 또는 `Ctrl + F5` + - Mac: `Cmd + Shift + R` + - 시크릿 모드로 접속 + +#### 7-2. 다른 브라우저로 시도 +- Chrome, Firefox, Edge 등 다른 브라우저로 접속 +- 같은 에러가 나오는지 확인 + +#### 7-3. 개발자 도구 확인 +- `F12` 키로 개발자 도구 열기 +- Network 탭에서 실패한 요청 확인 +- Console 탭에서 에러 메시지 확인 + +--- + +### ✅ 8. 네트워크 및 시간 관련 + +#### 8-1. 배포 완료 대기 +- GitHub Actions 워크플로우 완료 후 **2-5분** 정도 대기 +- GitHub Pages 배포는 시간이 걸릴 수 있음 + +#### 8-2. 여러 번 시도 +- 배포 후 바로 접속했을 때 404가 나올 수 있음 +- 몇 분 후 다시 시도해보기 + +--- + +### ✅ 9. 설정 파일 확인 + +#### 9-1. vite.config.ts 확인 +- **파일**: `vite.config.ts` +- **확인 사항**: + ```typescript + const base: string = + process.env.NODE_ENV === "production" ? "/front_7th_chapter3-2/" : ""; + ``` + - 저장소 이름과 일치하는지 확인 + +#### 9-2. package.json 확인 +- **파일**: `package.json` +- **확인 사항**: + - `build:advanced` 스크립트가 있는지 + - 스크립트가 올바른지 + +#### 9-3. .github/workflows/deploy.yml 확인 +- **파일**: `.github/workflows/deploy.yml` +- **확인 사항**: + - `publish_dir: ./dist` 설정이 맞는지 + - 빌드 명령어가 올바른지 + +--- + +### ✅ 10. 최종 확인 체크리스트 + +모든 항목을 확인하고 체크하세요: + +#### GitHub 저장소 설정 +- [ ] 저장소 이름이 정확히 `front_7th_chapter3-2`인가요? +- [ ] 저장소가 Public인가요? +- [ ] Settings → Pages에서 Source가 "GitHub Actions"로 설정되어 있나요? + +#### GitHub Actions +- [ ] Actions가 활성화되어 있나요? +- [ ] 워크플로우 파일(`.github/workflows/deploy.yml`)이 존재하나요? +- [ ] 워크플로우가 성공적으로 실행되었나요? (✅ 초록색 체크마크) + +#### gh-pages 브랜치 +- [ ] `gh-pages` 브랜치가 존재하나요? +- [ ] 브랜치에 `index.html` 파일이 있나요? +- [ ] 브랜치에 `assets/` 폴더가 있나요? + +#### 로컬 빌드 +- [ ] `pnpm build:advanced` 명령어가 성공하나요? +- [ ] `dist/index.html` 파일이 생성되나요? +- [ ] 빌드된 파일들의 경로가 올바른가요? + +#### 코드 변경 +- [ ] 변경된 파일들이 커밋되었나요? +- [ ] GitHub에 push되었나요? + +#### 브라우저 +- [ ] 브라우저 캐시를 지웠나요? +- [ ] 시크릿 모드로 접속해봤나요? +- [ ] 다른 브라우저로 시도해봤나요? + +#### 시간 +- [ ] GitHub Actions 완료 후 충분히 기다렸나요? (2-5분) + +--- + +## 🚀 빠른 해결 체크리스트 (우선순위) + +### 1순위: GitHub Pages Source 설정 ⚠️ 가장 중요! +``` +Settings → Pages → Source: "GitHub Actions" ✅ +``` + +### 2순위: GitHub Actions 워크플로우 실행 +``` +Actions 탭 → 워크플로우가 성공적으로 실행되었는지 확인 (✅) +``` + +### 3순위: gh-pages 브랜치 확인 +``` +브랜치 목록 → gh-pages 브랜치 확인 → index.html 파일 확인 +``` + +### 4순위: 코드 Push 확인 +```bash +git status +git add . +git commit -m "fix: GitHub Pages 배포" +git push +``` + +### 5순위: 브라우저 캐시 지우기 +``` +Ctrl + Shift + R (Windows) 또는 Cmd + Shift + R (Mac) +``` + +--- + +## 💡 각 항목별 상세 확인 방법 + +### GitHub Pages Source 설정 확인 +1. `https://github.com/jumoooo/front_7th_chapter3-2` 접속 +2. Settings → Pages +3. Source 필드 확인 +4. "GitHub Actions"로 변경 (필요한 경우) +5. Save 클릭 + +### GitHub Actions 워크플로우 확인 +1. `https://github.com/jumoooo/front_7th_chapter3-2/actions` 접속 +2. "Deploy to GitHub Pages" 워크플로우 확인 +3. 최근 실행 기록 확인 +4. 실행 상태 확인 (✅ 성공 / ❌ 실패) + +### gh-pages 브랜치 확인 +1. `https://github.com/jumoooo/front_7th_chapter3-2/branches` 접속 +2. `gh-pages` 브랜치 확인 +3. `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages` 접속 +4. 파일 목록 확인 + +### 로컬 빌드 확인 +```bash +# 빌드 실행 +pnpm build:advanced + +# 결과 확인 +ls -la dist/ +cat dist/index.html +``` + +--- + +## 📞 문제가 지속되는 경우 + +위의 모든 항목을 확인했는데도 문제가 해결되지 않으면: + +1. **GitHub Actions 로그 전체 확인** + - Actions 탭 → 실패한 워크플로우 → 전체 로그 확인 + +2. **브라우저 개발자 도구 확인** + - F12 → Network 탭 → 실패한 요청 확인 + - Console 탭 → 에러 메시지 확인 + +3. **저장소 설정 재확인** + - Settings → Pages → Source 재확인 + - Settings → Actions → 권한 재확인 + +4. **완전히 처음부터 다시 시작** + - GitHub Settings → Pages → Source를 "None"으로 변경 + - Save 후 다시 "GitHub Actions"로 변경 + - Save 후 워크플로우 재실행 + +--- + +**가장 중요한 것**: GitHub Settings → Pages에서 Source를 "GitHub Actions"로 설정하는 것입니다! + diff --git "a/\354\265\234\354\242\205_\354\247\204\353\213\250_\353\260\217_\355\225\264\352\262\260\353\260\251\354\225\210.md" "b/\354\265\234\354\242\205_\354\247\204\353\213\250_\353\260\217_\355\225\264\352\262\260\353\260\251\354\225\210.md" new file mode 100644 index 000000000..75f268c68 --- /dev/null +++ "b/\354\265\234\354\242\205_\354\247\204\353\213\250_\353\260\217_\355\225\264\352\262\260\353\260\251\354\225\210.md" @@ -0,0 +1,200 @@ +# 🔴 최종 진단 및 해결 방안 + +## 📋 현재 상황 +- gh-pages 브랜치에 `index.html` 파일이 있음 ✅ +- GitHub Actions 로그가 성공함 ✅ +- `.nojekyll` 파일 추가 완료 ✅ +- **하지만 여전히 404 에러 발생** ❌ + +--- + +## 🔍 근본 원인 분석 + +### 가능한 원인들 + +#### 1. **gh-pages 브랜치에 실제 파일이 배포되지 않음** + - GitHub Actions가 성공했다고 표시되더라도 + - 실제로 파일이 배포되지 않았을 수 있음 + - 또는 빈 디렉토리만 배포되었을 수 있음 + +#### 2. **assets 폴더가 배포되지 않음** + - `index.html`은 있지만 + - `assets/` 폴더나 JavaScript 파일이 실제로 배포되지 않았을 수 있음 + +#### 3. **GitHub Pages Source 설정 문제** + - Source가 "GitHub Actions"로 설정되어 있어도 + - 실제로 연결이 안 되어 있을 수 있음 + +#### 4. **빌드 결과물 경로 문제** + - 빌드는 성공했지만 + - 배포할 파일 경로가 잘못되었을 수 있음 + +--- + +## ✅ 즉시 확인해야 할 사항 + +### 1. gh-pages 브랜치 실제 내용 확인 ⚠️ 가장 중요! + +**확인 방법:** +1. `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages` 접속 +2. **다음 항목들을 정확히 확인:** + +**필수 확인 사항:** +- [ ] `index.html` 파일이 **루트에** 있는가? +- [ ] `.nojekyll` 파일이 **루트에** 있는가? +- [ ] `assets/` **폴더**가 있는가? +- [ ] `assets/` 폴더 안에 **JavaScript 파일**이 있는가? +- [ ] 파일 이름이 올바른가? (예: `index-BVSKioPT.js`) + +**만약 파일이 없다면:** +- GitHub Actions가 실제로 파일을 배포하지 못한 것 +- 워크플로우 로그를 다시 확인해야 함 + +--- + +### 2. GitHub Actions 워크플로우 로그 상세 확인 + +**확인 방법:** +1. `https://github.com/jumoooo/front_7th_chapter3-2/actions` 접속 +2. 가장 최근 "Deploy to GitHub Pages" 워크플로우 클릭 +3. **"Deploy to GitHub Pages" 단계를 클릭해서 로그 확인** + +**확인할 내용:** +- [ ] 배포 단계가 실제로 실행되었는가? +- [ ] 에러 메시지가 있는가? +- [ ] "Uploading files..." 같은 메시지가 있는가? +- [ ] 어떤 파일들이 배포되었다고 나오는가? + +--- + +### 3. 빌드 결과물 로컬 확인 + +**로컬에서 빌드 테스트:** +```bash +pnpm build:advanced +``` + +**빌드 결과 확인:** +```bash +ls -la dist/ +ls -la dist/assets/ +``` + +**확인 사항:** +- [ ] `dist/index.html` 파일이 있는가? +- [ ] `dist/.nojekyll` 파일이 있는가? +- [ ] `dist/assets/` 폴더가 있는가? +- [ ] `dist/assets/` 안에 JavaScript 파일이 있는가? + +--- + +## 🛠️ 해결 방안 + +### 방법 1: GitHub Actions 워크플로우에 디버깅 추가 + +워크플로우에 빌드 결과물을 확인하는 단계를 추가: + +```yaml +- name: Check build output + run: | + ls -la dist/ + ls -la dist/assets/ + cat dist/index.html +``` + +이렇게 하면 GitHub Actions 로그에서 실제로 어떤 파일이 생성되었는지 확인할 수 있습니다. + +--- + +### 방법 2: 빌드 후 파일 확인 단계 추가 + +배포 전에 파일이 올바르게 생성되었는지 확인: + +```yaml +- name: Verify build output + run: | + if [ ! -f dist/index.html ]; then + echo "ERROR: dist/index.html not found!" + exit 1 + fi + if [ ! -d dist/assets ]; then + echo "ERROR: dist/assets directory not found!" + exit 1 + fi + echo "Build output verified successfully" +``` + +--- + +### 방법 3: 수동으로 파일 확인 + +gh-pages 브랜치를 로컬에서 확인: + +```bash +git fetch origin gh-pages:gh-pages +git checkout gh-pages +ls -la +``` + +이렇게 하면 실제로 어떤 파일이 배포되었는지 확인할 수 있습니다. + +--- + +## 📝 체크리스트 + +### 즉시 확인 (가장 중요!) + +1. **gh-pages 브랜치 파일 확인** + - [ ] `index.html` 파일이 있는가? + - [ ] `.nojekyll` 파일이 있는가? + - [ ] `assets/` 폴더가 있는가? + - [ ] JavaScript 파일이 있는가? + +2. **GitHub Actions 로그 확인** + - [ ] 배포 단계가 성공했는가? + - [ ] 에러 메시지가 있는가? + - [ ] 어떤 파일들이 배포되었는가? + +3. **로컬 빌드 테스트** + - [ ] 빌드가 성공하는가? + - [ ] `dist/` 폴더에 파일이 있는가? + +--- + +## 💡 다음 단계 + +위의 체크리스트를 확인한 후, 다음 중 하나를 알려주세요: + +1. **gh-pages 브랜치에 어떤 파일들이 있는지** + - 파일 목록을 알려주시면 정확한 원인을 파악할 수 있습니다 + +2. **GitHub Actions 로그의 에러 메시지** + - 에러 메시지가 있다면 전체 내용을 알려주세요 + +3. **로컬 빌드 결과** + - `ls -la dist/` 결과를 알려주세요 + +이 정보를 바탕으로 정확한 원인을 찾고 해결책을 제시하겠습니다! + +--- + +## 🔧 임시 해결책 + +만약 빠르게 테스트하고 싶다면: + +1. **로컬에서 빌드:** + ```bash + pnpm build:advanced + ``` + +2. **빌드 결과 확인:** + ```bash + ls -la dist/ + cat dist/index.html + ``` + +3. **gh-pages 브랜치와 비교:** + - 로컬 `dist/` 폴더의 내용과 + - gh-pages 브랜치의 내용을 비교 + - 차이점을 찾으면 문제를 해결할 수 있습니다 + diff --git "a/\354\265\234\354\242\205_\355\225\264\352\262\260_\353\260\251\354\225\210.md" "b/\354\265\234\354\242\205_\355\225\264\352\262\260_\353\260\251\354\225\210.md" new file mode 100644 index 000000000..15a1a8ef8 --- /dev/null +++ "b/\354\265\234\354\242\205_\355\225\264\352\262\260_\353\260\251\354\225\210.md" @@ -0,0 +1,128 @@ +# ✅ 최종 해결 방안 + +## 📋 현재 상태 확인 완료 + +### ✅ 빌드 결과물 +- `.nojekyll` 파일 존재 ✅ +- `index.html` 파일 존재 ✅ +- `assets/` 폴더와 JavaScript 파일 존재 ✅ + +### ✅ gh-pages 브랜치 +- `index.html` ✅ +- `.nojekyll` ✅ +- `assets/index-BVSKioPT.js` ✅ +- `vite.svg` ✅ + +**결론: 모든 파일이 올바르게 배포되어 있습니다!** + +--- + +## 🔍 404 에러의 원인 + +파일이 모두 올바르게 배포되었는데도 404 에러가 발생한다면, **GitHub Pages 설정 문제**일 가능성이 높습니다. + +--- + +## ✅ 해결 방법 + +### 1. GitHub Pages Source 설정 확인 ⚠️ 가장 중요! + +1. `https://github.com/jumoooo/front_7th_chapter3-2/settings/pages` 접속 + +2. **Source 설정 확인:** + - ✅ **"GitHub Actions"**로 설정되어 있어야 함 + - ❌ "Deploy from a branch"가 선택되어 있으면 안 됨 + +3. **만약 "Deploy from a branch"로 되어 있다면:** + - Source를 **"None"**으로 변경 → Save + - 10초 대기 + - Source를 **"GitHub Actions"**로 다시 변경 → Save + +--- + +### 2. GitHub Pages 재배포 + +1. **워크플로우 수동 실행:** + - `https://github.com/jumoooo/front_7th_chapter3-2/actions` 접속 + - "Deploy to GitHub Pages" 워크플로우 선택 + - 오른쪽 "Run workflow" 버튼 클릭 + - "Run workflow" 클릭 + +2. **또는 빈 커밋으로 재배포:** + ```bash + git commit --allow-empty -m "Trigger deployment" + git push + ``` + +--- + +### 3. 브라우저 캐시 완전 삭제 + +1. 개발자 도구 열기 (F12) +2. Application 탭 (Chrome) 또는 Storage 탭 (Firefox) +3. "Clear storage" 또는 "Clear site data" 클릭 +4. 사이트 재접속 + +또는 시크릿/프라이빗 모드로 접속: +- Chrome: Ctrl + Shift + N +- Firefox: Ctrl + Shift + P + +--- + +### 4. GitHub Pages 배포 상태 확인 + +1. `https://github.com/jumoooo/front_7th_chapter3-2/settings/pages` 접속 +2. **"Your site is live at"** 섹션 확인 +3. 배포 상태가 "Building" 또는 "Ready"인지 확인 + +--- + +## 🎯 단계별 확인 순서 + +### Step 1: GitHub Pages Source 확인 +- [ ] Source가 **"GitHub Actions"**로 설정되어 있는가? + +### Step 2: GitHub Pages 재설정 (필요시) +- [ ] Source를 "None"으로 변경 후 "GitHub Actions"로 다시 설정 + +### Step 3: 워크플로우 재실행 +- [ ] 워크플로우를 수동으로 실행하거나 빈 커밋 푸시 + +### Step 4: 배포 완료 대기 +- [ ] 워크플로우 완료 후 2-3분 대기 + +### Step 5: 브라우저 캐시 삭제 +- [ ] 브라우저 캐시 완전 삭제 또는 시크릿 모드로 접속 + +### Step 6: 사이트 접속 확인 +- [ ] `https://jumoooo.github.io/front_7th_chapter3-2/` 접속 + +--- + +## 💡 추가 확인 사항 + +### GitHub Pages 배포 로그 확인 + +1. `https://github.com/jumoooo/front_7th_chapter3-2/settings/pages` 접속 +2. **"Recent deployments"** 섹션 확인 +3. 최근 배포 상태 확인 + +--- + +## 🚨 만약 여전히 404가 발생한다면 + +다음 정보를 알려주세요: + +1. **GitHub Pages Source 설정:** + - 현재 어떤 옵션이 선택되어 있는가? + +2. **배포 상태:** + - "Your site is live at" 섹션에 무엇이 표시되는가? + +3. **브라우저 개발자 도구:** + - F12 → Network 탭 + - 어떤 파일이 404 에러를 반환하는가? + - 에러 메시지는 무엇인가? + +이 정보를 바탕으로 정확한 원인을 찾을 수 있습니다! + diff --git "a/\355\214\214\354\235\274_\354\204\244\354\240\225_\352\262\200\354\246\235.md" "b/\355\214\214\354\235\274_\354\204\244\354\240\225_\352\262\200\354\246\235.md" new file mode 100644 index 000000000..6dd336a8b --- /dev/null +++ "b/\355\214\214\354\235\274_\354\204\244\354\240\225_\352\262\200\354\246\235.md" @@ -0,0 +1,247 @@ +# ✅ 파일 설정 검증 결과 + +## 📋 확인된 파일 설정들 + +### ✅ 1. vite.config.ts + +**위치**: `vite.config.ts` + +**현재 설정**: +```typescript +/// +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "path"; + +const dirname = + typeof __dirname !== "undefined" + ? __dirname + : path.dirname(new URL(import.meta.url).pathname); + +// GitHub Pages 배포를 위한 base 경로 설정 +const base: string = + process.env.NODE_ENV === "production" ? "/front_7th_chapter3-2/" : ""; + +export default defineConfig(({ command }) => { + const isBuild = command === "build"; + + return { + base, // GitHub Pages 배포 경로 설정 + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(dirname, "./src"), + }, + }, + // test 설정은 개발/테스트 환경에서만 사용 + ...(isBuild + ? {} + : { + test: { + globals: true, + environment: "jsdom", + setupFiles: "./src/setupTests.ts", + }, + }), + }; +}); +``` + +**검증 결과**: +- ✅ base path가 올바르게 설정됨: `/front_7th_chapter3-2/` +- ✅ front_7th_chapter3-1과 동일한 패턴 사용 +- ✅ 빌드 모드에서 test 설정 제외 처리 + +--- + +### ✅ 2. index.html + +**위치**: `index.html` (루트 디렉토리) + +**현재 설정**: +```html + + + + + + 장바구니로 학습하는 디자인패턴 + + + +
+ + + +``` + +**검증 결과**: +- ✅ `src/advanced/main.tsx`를 사용하도록 올바르게 설정됨 +- ✅ GitHub Pages 배포용 기본 HTML 파일로 사용됨 + +--- + +### ✅ 3. package.json + +**위치**: `package.json` + +**빌드 스크립트**: +```json +{ + "scripts": { + "build:advanced": "tsc -b && vite build --config vite.config.ts" + } +} +``` + +**검증 결과**: +- ✅ `build:advanced` 스크립트가 올바르게 설정됨 +- ✅ TypeScript 컴파일 후 Vite 빌드 실행 + +--- + +### ✅ 4. .github/workflows/deploy.yml + +**위치**: `.github/workflows/deploy.yml` + +**현재 설정**: +```yaml +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: write + pages: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - name: Build advanced package + run: pnpm build:advanced + env: + NODE_ENV: production + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist +``` + +**검증 결과**: +- ✅ front_7th_chapter3-1과 동일한 구조 +- ✅ `publish_dir: ./dist` 설정 올바름 +- ✅ `build:advanced` 명령어 사용 +- ✅ `NODE_ENV: production` 설정으로 base path 적용 + +--- + +### ✅ 5. 빌드 결과물 검증 + +**빌드 테스트 결과**: +```bash +✓ 86 modules transformed. +dist/index.html 0.44 kB │ gzip: 0.35 kB +dist/assets/index-BVSKioPT.js 225.60 kB │ gzip: 69.52 kB +✓ built in 1.11s +``` + +**생성된 파일 구조**: +``` +dist/ +├── index.html ✅ +├── assets/ +│ └── index-BVSKioPT.js ✅ +└── vite.svg +``` + +**dist/index.html 내용**: +```html + + + + + + 장바구니로 학습하는 디자인패턴 + + + + +
+ + +``` + +**검증 결과**: +- ✅ `index.html` 파일이 올바르게 생성됨 +- ✅ base path (`/front_7th_chapter3-2/`)가 JavaScript 파일 경로에 적용됨 +- ✅ 모든 필수 파일이 생성됨 + +--- + +## 📊 front_7th_chapter3-1과 비교 + +### 동일한 점 ✅ +1. vite.config.ts 구조가 동일함 +2. GitHub Actions 워크플로우 구조가 동일함 +3. 빌드 후 dist 폴더 구조가 동일함 + +### 차이점 (의도된 차이) ✅ +1. base path: `/front_7th_chapter3-1/` vs `/front_7th_chapter3-2/` +2. 빌드 스크립트: `build:after` vs `build:advanced` +3. publish_dir: `./packages/after/dist` vs `./dist` + - (monorepo vs 단일 프로젝트 구조 차이) + +--- + +## ✅ 최종 검증 결과 + +### 모든 파일 설정이 올바르게 구성됨! + +1. ✅ **vite.config.ts**: base path 올바르게 설정 +2. ✅ **index.html**: advanced 버전 사용하도록 설정 +3. ✅ **package.json**: build:advanced 스크립트 존재 +4. ✅ **deploy.yml**: front_7th_chapter3-1과 동일한 패턴 +5. ✅ **빌드 결과**: 모든 파일이 올바르게 생성됨 + +--- + +## 🎯 다음 단계 + +파일 설정은 모두 올바르게 되어 있습니다! + +이제 다음을 확인하세요: + +1. **GitHub Settings → Pages** + - Source가 "GitHub Actions"로 설정되어 있는지 확인 + +2. **GitHub Actions 워크플로우** + - 워크플로우가 성공적으로 실행되었는지 확인 + +3. **gh-pages 브랜치** + - 브랜치가 생성되었고 파일이 올바르게 배포되었는지 확인 + +파일 설정 자체에는 문제가 없습니다! 🎉 + diff --git "a/\355\214\214\354\235\274_\354\204\244\354\240\225_\352\262\200\354\246\235_\354\231\204\353\243\214.md" "b/\355\214\214\354\235\274_\354\204\244\354\240\225_\352\262\200\354\246\235_\354\231\204\353\243\214.md" new file mode 100644 index 000000000..1ef864865 --- /dev/null +++ "b/\355\214\214\354\235\274_\354\204\244\354\240\225_\352\262\200\354\246\235_\354\231\204\353\243\214.md" @@ -0,0 +1,391 @@ +# ✅ 파일 설정 검증 완료 보고서 + +## 📋 목표 + +`https://jumoooo.github.io/front_7th_chapter3-2/` 에서 `src/advanced` 화면이 정상적으로 표시되도록 설정 + +**참고 문서**: `22_github-pages-deployment-plan.md` 패턴 적용 + +--- + +## 🔍 현재 상태 분석 + +### ✅ 이미 구현된 부분 + +#### 1. Base Path 설정 ✅ + +**vite.config.ts**: +```typescript +const base: string = + process.env.NODE_ENV === "production" ? "/front_7th_chapter3-2/" : ""; +``` + +**검증 결과**: +- ✅ 배포 링크와 코드의 base path가 일치함: `/front_7th_chapter3-2/` +- ✅ front_7th_chapter3-1과 동일한 패턴 사용 +- ✅ 프로덕션 환경에서만 base path 적용 + +--- + +#### 2. 배포용 HTML 파일 ✅ + +**index.html** (루트 디렉토리): +```html + + + + + + 장바구니로 학습하는 디자인패턴 + + + +
+ + + +``` + +**검증 결과**: +- ✅ `src/advanced/main.tsx`를 사용하도록 올바르게 설정됨 +- ✅ GitHub Pages 배포용 기본 HTML 파일로 사용됨 +- ✅ front_7th_chapter3-1 패턴과 일치 + +--- + +#### 3. 빌드 스크립트 ✅ + +**package.json**: +```json +{ + "scripts": { + "build:advanced": "tsc -b && vite build --config vite.config.ts" + } +} +``` + +**검증 결과**: +- ✅ `build:advanced` 스크립트가 올바르게 설정됨 +- ✅ TypeScript 컴파일 후 Vite 빌드 실행 +- ✅ front_7th_chapter3-1의 `build:after` 패턴과 일치 + +--- + +#### 4. GitHub Actions 워크플로우 ✅ + +**`.github/workflows/deploy.yml`**: +```yaml +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: write + pages: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - name: Build advanced package + run: pnpm build:advanced + env: + NODE_ENV: production + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist +``` + +**검증 결과**: +- ✅ front_7th_chapter3-1과 동일한 구조 +- ✅ `publish_dir: ./dist` 설정 올바름 +- ✅ `build:advanced` 명령어 사용 +- ✅ `NODE_ENV: production` 설정으로 base path 적용 +- ✅ 필요한 권한 설정 완료 + +--- + +#### 5. 빌드 결과물 검증 ✅ + +**로컬 빌드 테스트 결과**: +```bash +$ pnpm build:advanced + +✓ 86 modules transformed. +dist/index.html 0.44 kB │ gzip: 0.35 kB +dist/assets/index-BVSKioPT.js 225.60 kB │ gzip: 69.52 kB +✓ built in 1.11s +``` + +**생성된 파일 구조**: +``` +dist/ +├── index.html ✅ +├── assets/ +│ └── index-BVSKioPT.js ✅ +└── vite.svg +``` + +**dist/index.html 내용 검증**: +```html + + + + + + 장바구니로 학습하는 디자인패턴 + + + + +
+ + +``` + +**검증 결과**: +- ✅ `index.html` 파일이 올바르게 생성됨 +- ✅ base path (`/front_7th_chapter3-2/`)가 JavaScript 파일 경로에 적용됨 +- ✅ 모든 필수 파일이 생성됨 +- ✅ 경로가 올바르게 설정됨 + +--- + +### ❌ 확인 필요 사항 + +#### 1. GitHub Settings → Pages 설정 +- ⚠️ Source가 "GitHub Actions"로 설정되어 있는지 확인 필요 +- 사용자가 직접 확인해야 함 + +#### 2. GitHub Actions 워크플로우 실행 +- ⚠️ 워크플로우가 성공적으로 실행되었는지 확인 필요 +- 사용자가 Actions 탭에서 확인해야 함 + +#### 3. gh-pages 브랜치 +- ⚠️ 브랜치가 생성되었고 파일이 올바르게 배포되었는지 확인 필요 +- 사용자가 브랜치 목록에서 확인해야 함 + +--- + +## 📊 front_7th_chapter3-1과 비교 + +### ✅ 동일한 패턴 + +1. **vite.config.ts 구조**: 동일한 패턴 사용 + - base path 설정 방식 동일 + - test 설정 조건부 제외 동일 + - dirname 처리 방식 동일 + +2. **GitHub Actions 워크플로우 구조**: 동일 + - 동일한 액션 버전 사용 + - 동일한 권한 설정 + - 동일한 배포 방식 + +3. **빌드 결과물 구조**: 동일 + - `dist/index.html` 생성 + - `dist/assets/` 폴더 구조 동일 + +### 차이점 (의도된 차이) ✅ + +1. **Base Path**: `/front_7th_chapter3-1/` vs `/front_7th_chapter3-2/` (저장소 이름) +2. **빌드 스크립트**: `build:after` vs `build:advanced` (프로젝트 구조 차이) +3. **publish_dir**: `./packages/after/dist` vs `./dist` (monorepo vs 단일 프로젝트) + +--- + +## ✅ 최종 검증 결과 + +### 모든 파일 설정이 올바르게 구성됨! ✅ + +#### 검증 완료 항목: + +1. ✅ **vite.config.ts**: base path 올바르게 설정 (`/front_7th_chapter3-2/`) +2. ✅ **index.html**: advanced 버전 사용하도록 설정 (`src/advanced/main.tsx`) +3. ✅ **package.json**: `build:advanced` 스크립트 존재 및 올바름 +4. ✅ **deploy.yml**: front_7th_chapter3-1과 동일한 패턴, `publish_dir: ./dist` 올바름 +5. ✅ **빌드 결과**: 모든 파일이 올바르게 생성됨 +6. ✅ **빌드된 index.html**: base path가 올바르게 적용됨 + +--- + +## 📋 파일별 상세 검증 + +### 1. vite.config.ts 검증 + +**위치**: `vite.config.ts` + +**핵심 설정**: +- ✅ Base path: `/front_7th_chapter3-2/` (프로덕션 환경) +- ✅ Plugin: `@vitejs/plugin-react-swc` +- ✅ Test 설정: 빌드 모드에서 제외 (front_7th_chapter3-1과 동일) + +**검증 완료**: ✅ 설정이 올바르게 되어 있음 + +--- + +### 2. index.html 검증 + +**위치**: `index.html` (루트) + +**핵심 설정**: +- ✅ Entry point: `/src/advanced/main.tsx` +- ✅ Tailwind CSS: CDN 사용 + +**검증 완료**: ✅ advanced 버전을 사용하도록 올바르게 설정됨 + +--- + +### 3. package.json 검증 + +**위치**: `package.json` + +**핵심 설정**: +- ✅ Build script: `build:advanced` 존재 +- ✅ Script 내용: `tsc -b && vite build --config vite.config.ts` + +**검증 완료**: ✅ 빌드 스크립트가 올바르게 설정됨 + +--- + +### 4. deploy.yml 검증 + +**위치**: `.github/workflows/deploy.yml` + +**핵심 설정**: +- ✅ Trigger: `main` 브랜치 push +- ✅ Build command: `pnpm build:advanced` +- ✅ Publish directory: `./dist` +- ✅ Permissions: 올바르게 설정됨 + +**검증 완료**: ✅ front_7th_chapter3-1과 동일한 패턴으로 올바르게 설정됨 + +--- + +### 5. 빌드 결과물 검증 + +**빌드 명령어**: `pnpm build:advanced` + +**생성된 파일**: +- ✅ `dist/index.html` (443 bytes) +- ✅ `dist/assets/index-BVSKioPT.js` (225.60 kB) +- ✅ `dist/vite.svg` + +**빌드된 index.html 경로 확인**: +- ✅ JavaScript 파일 경로: `/front_7th_chapter3-2/assets/index-BVSKioPT.js` +- ✅ Base path가 올바르게 적용됨 + +**검증 완료**: ✅ 모든 빌드 결과물이 올바르게 생성됨 + +--- + +## 🎯 다음 단계 (사용자가 확인해야 할 사항) + +### ✅ 1단계: GitHub Settings → Pages 확인 + +**위치**: GitHub 저장소 → Settings → Pages + +**확인 사항**: +- Source가 **"GitHub Actions"**로 설정되어 있는지 확인 +- ❌ "Deploy from a branch"가 아니라 **"GitHub Actions"**여야 함 + +**설정 방법**: +1. `https://github.com/jumoooo/front_7th_chapter3-2/settings/pages` 접속 +2. Source 드롭다운에서 **"GitHub Actions"** 선택 +3. Save 클릭 + +--- + +### ✅ 2단계: GitHub Actions 워크플로우 확인 + +**위치**: GitHub 저장소 → Actions 탭 + +**확인 사항**: +- `Deploy to GitHub Pages` 워크플로우가 실행되었는지 확인 +- 워크플로우 실행 상태 확인: + - ✅ 초록색 체크마크: 성공 + - 🟡 노란색 원: 진행 중 + - ❌ 빨간색 X: 실패 (로그 확인 필요) + +**확인 방법**: +1. `https://github.com/jumoooo/front_7th_chapter3-2/actions` 접속 +2. 워크플로우 목록에서 확인 +3. 실패했다면 로그 확인 + +--- + +### ✅ 3단계: gh-pages 브랜치 확인 + +**위치**: GitHub 저장소 → Branches + +**확인 사항**: +- `gh-pages` 브랜치가 생성되었는지 확인 +- 브랜치에 `index.html` 파일이 있는지 확인 + +**확인 방법**: +1. `https://github.com/jumoooo/front_7th_chapter3-2/branches` 접속 +2. `gh-pages` 브랜치 확인 +3. `https://github.com/jumoooo/front_7th_chapter3-2/tree/gh-pages` 접속하여 파일 확인 + +--- + +### ✅ 4단계: 배포 확인 + +**확인 사항**: +- 배포된 사이트 접속 확인 + +**확인 방법**: +1. `https://jumoooo.github.io/front_7th_chapter3-2/` 접속 +2. `src/advanced` 화면이 표시되는지 확인 +3. 브라우저 캐시 지우기 (Ctrl + Shift + R) + +--- + +## 📝 파일 설정 체크리스트 + +- [x] vite.config.ts: base path 설정 확인 (`/front_7th_chapter3-2/`) +- [x] index.html: advanced 버전 사용 확인 (`src/advanced/main.tsx`) +- [x] package.json: build:advanced 스크립트 확인 +- [x] deploy.yml: 워크플로우 설정 확인 (`publish_dir: ./dist`) +- [x] 로컬 빌드 테스트: 성공 확인 +- [x] 빌드 결과물: index.html 및 assets 파일 생성 확인 +- [x] 빌드된 경로: base path 적용 확인 + +--- + +## 🎉 결론 + +**모든 파일 설정이 올바르게 구성되어 있습니다!** + +파일 레벨에서는 문제가 없으며, `front_7th_chapter3-1`과 동일한 입증된 패턴을 사용하고 있습니다. + +404 에러가 발생한다면, 다음을 확인하세요: + +1. **GitHub Settings → Pages**: Source가 "GitHub Actions"로 설정되었는지 +2. **GitHub Actions**: 워크플로우가 성공적으로 실행되었는지 +3. **gh-pages 브랜치**: 브랜치가 생성되고 파일이 올바르게 배포되었는지 + +파일 설정 자체에는 문제가 없습니다! 🎉 +