diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d6c95379..f45dce57 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -2,17 +2,48 @@ module.exports = { root: true, env: { browser: true, es2020: true }, extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react-hooks/recommended', + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "plugin:react-hooks/recommended", ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parser: '@typescript-eslint/parser', - plugins: ['react-refresh'], + ignorePatterns: ["dist", ".eslintrc.cjs"], + parser: "@typescript-eslint/parser", + plugins: ["react-refresh", "react"], + settings: { + react: { + version: "detect", + }, + }, rules: { - 'react-refresh/only-export-components': [ - 'warn', + "react-refresh/only-export-components": [ + "warn", { allowConstantExport: true }, ], + // React dependency 체크 관련 규칙들 + "react-hooks/exhaustive-deps": "warn", + "react-hooks/rules-of-hooks": "error", + // React 관련 추가 규칙들 + "react/jsx-key": "error", + "react/jsx-no-duplicate-props": "error", + "react/jsx-no-undef": "error", + "react/jsx-pascal-case": "warn", + "react/jsx-uses-react": "error", + "react/jsx-uses-vars": "error", + "react/no-array-index-key": "warn", + "react/no-danger": "warn", + "react/no-deprecated": "warn", + "react/no-direct-mutation-state": "error", + "react/no-find-dom-node": "warn", + "react/no-is-mounted": "error", + "react/no-render-return-value": "error", + "react/no-string-refs": "error", + "react/no-unescaped-entities": "warn", + "react/no-unknown-property": "error", + "react/no-unsafe": "warn", + "react/self-closing-comp": "warn", + "react/sort-comp": "warn", + "react/style-prop-object": "error", + "react/void-dom-elements-no-children": "error", }, -} +}; diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..5c6bb0c4 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,38 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 10.12.4 + + - name: Install dependencies + run: pnpm install + + - name: Build + run: pnpm build + env: + NODE_ENV: production + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + if: github.ref == 'refs/heads/main' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist diff --git a/1days-refactoring-prd.md b/1days-refactoring-prd.md new file mode 100644 index 00000000..72d8aec6 --- /dev/null +++ b/1days-refactoring-prd.md @@ -0,0 +1,91 @@ +# Day 1: Product 도메인 완전 분리 (컴포넌트 → 상수 → 순수함수 → 상태관리) + +## 목표 + +- Product 도메인의 모든 요소를 단계별로 완전 분리 +- 컴포넌트 → 상수 → 순수함수 → 상태관리 순서로 진행 +- 테스트를 계속 통과시키며 점진적 리팩토링 + +## 작업 범위 + +### 1단계: Jotai 설치 및 기본 설정 + +```bash +npm install jotai +``` + +### 2단계: Product 관련 컴포넌트 분리 + +```typescript +// src/advanced/components/ProductList.tsx +export const ProductList: React.FC<{ + products: ProductWithUI[]; + onAddToCart: (product: ProductWithUI) => void; + isAdmin: boolean; + onEditProduct?: (product: ProductWithUI) => void; +}>; + +// src/advanced/components/ProductCard.tsx +export const ProductCard: React.FC; + +// src/advanced/components/ProductForm.tsx (관리자용) +export const ProductForm: React.FC; +``` + +### 3단계: Product 상수 분리 + +```typescript +// src/advanced/constants/product.ts +export const initialProducts: ProductWithUI[] = [...] +export const PRODUCT_FORM_INITIAL_STATE = {...} +``` + +### 4단계: Product 순수함수 분리 + +```typescript +// src/advanced/utils/productUtils.ts +export const filterProducts = (products: ProductWithUI[], searchTerm: string): ProductWithUI[] +export const generateProductId = (): string +export const validateProductData = (product: Partial): boolean + +// src/advanced/utils/formatters.ts +export const formatPrice = (price: number, isAdmin: boolean = false): string +``` + +### 5단계: Product 상태관리 (Atom + Hook) + +```typescript +// src/advanced/models/productAtoms.ts +export const productsAtom = atom(initialProducts); +export const searchTermAtom = atom(""); +export const debouncedSearchTermAtom = atom(""); + +// src/advanced/hooks/useProducts.ts +export const useProducts = () => { + // atom 기반 상태 관리 + // CRUD 함수들 +}; +``` + +### 6단계: App.tsx에서 Product 관련 코드 제거 및 연결 + +- useState들 제거 +- 순수함수들 제거 +- 컴포넌트 import로 교체 + +## 테스트 전략 + +- [ ] 각 단계 완료 후 즉시 테스트 실행 +- [ ] 기존 Product 관련 테스트 통과 확인 +- [ ] 새로운 유틸리티 함수 단위 테스트 + +## 성공 기준 + +1. Product 도메인이 완전히 분리됨 +2. App 컴포넌트에서 Product 관련 코드가 모두 제거됨 +3. 모든 기존 테스트가 통과함 +4. 검색, 상품 관리 기능이 정상 동작함 + +## 다음 날 준비사항 + +- Cart 도메인 분석 준비 diff --git a/2days-refactoring-prd.md b/2days-refactoring-prd.md new file mode 100644 index 00000000..a91712e2 --- /dev/null +++ b/2days-refactoring-prd.md @@ -0,0 +1,103 @@ +# Day 2: Cart 도메인 완전 분리 (컴포넌트 → 상수 → 순수함수 → 상태관리) + +## 목표 + +- Cart 도메인의 모든 요소를 단계별로 완전 분리 +- 장바구니 관련 컴포넌트, 상수, 순수함수, 상태관리 모두 분리 + +## 작업 범위 + +### 1단계: Cart 관련 컴포넌트 분리 + +```typescript +// src/advanced/components/CartSummary.tsx +export const CartSummary: React.FC<{ + cart: CartItem[]; + selectedCoupon: Coupon | null; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemoveItem: (productId: string) => void; + onApplyCoupon: (coupon: Coupon) => void; + onCompleteOrder: () => void; +}>; + +// src/advanced/components/CartItem.tsx +export const CartItem: React.FC; + +// src/advanced/components/CouponSelector.tsx +export const CouponSelector: React.FC; +``` + +### 2단계: Cart 상수 분리 + +```typescript +// src/advanced/constants/cart.ts +export const BULK_PURCHASE_THRESHOLD = 10; +export const BULK_PURCHASE_ADDITIONAL_DISCOUNT = 0.05; +export const MAX_DISCOUNT_RATE = 0.5; +export const MIN_PERCENTAGE_COUPON_AMOUNT = 10000; +``` + +### 3단계: Cart 순수함수 분리 + +```typescript +// src/advanced/utils/cartUtils.ts +export const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number +export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number +export const calculateCartTotal = (cart: CartItem[], selectedCoupon: Coupon | null): CartTotal +export const getRemainingStock = (product: Product, cart: CartItem[]): number +export const getCartItemCount = (cart: CartItem[]): number + +// src/advanced/utils/discountUtils.ts +export const calculateDiscountRate = (discounts: Discount[], quantity: number): number +export const hasBulkPurchase = (cart: CartItem[]): boolean +export const applyCouponDiscount = (total: number, coupon: Coupon): number +``` + +### 4단계: Cart 상태관리 (Atom + Hook) + +```typescript +// src/advanced/models/cartAtoms.ts +export const cartAtom = atom([]) +export const selectedCouponAtom = atom(null) +export const cartTotalAtom = atom((get) => ...) +export const cartItemCountAtom = atom((get) => ...) + +// src/advanced/hooks/useCart.ts +export const useCart = () => { + // localStorage 연동 + // CRUD 함수들 + // 재고 검증 +} +``` + +### 5단계: LocalStorage 연동 + +```typescript +// src/advanced/utils/hooks/useLocalStorage.ts +export const useLocalStorage = (key: string, initialValue: T) +``` + +### 6단계: App.tsx에서 Cart 관련 코드 제거 및 연결 + +## 이전 작업과의 연결점 + +- Day 1에서 분리한 formatPrice 유틸리티 활용 +- Product 도메인과의 연동 (재고 계산) + +## 테스트 전략 + +- [ ] 각 단계 완료 후 즉시 테스트 실행 +- [ ] 장바구니 CRUD 기능 테스트 +- [ ] 할인 계산 로직 테스트 +- [ ] localStorage 연동 테스트 + +## 성공 기준 + +1. Cart 도메인이 완전히 분리됨 +2. App 컴포넌트에서 Cart 관련 코드가 모두 제거됨 +3. 장바구니 기능이 정상 동작함 +4. 모든 기존 테스트가 통과함 + +## 다음 날 준비사항 + +- Coupon 도메인 분석 준비 diff --git a/3days-refactoring-prd.md b/3days-refactoring-prd.md new file mode 100644 index 00000000..d5026bb9 --- /dev/null +++ b/3days-refactoring-prd.md @@ -0,0 +1,89 @@ +# Day 3: Coupon 도메인 완전 분리 (컴포넌트 → 상수 → 순수함수 → 상태관리) + +## 목표 + +- Coupon 도메인의 모든 요소를 단계별로 완전 분리 +- 쿠폰 관련 컴포넌트, 상수, 순수함수, 상태관리 모두 분리 + +## 작업 범위 + +### 1단계: Coupon 관련 컴포넌트 분리 + +```typescript +// src/advanced/components/CouponList.tsx +export const CouponList: React.FC<{ + coupons: Coupon[]; + onApplyCoupon: (coupon: Coupon) => void; + selectedCoupon: Coupon | null; +}>; + +// src/advanced/components/CouponCard.tsx +export const CouponCard: React.FC; + +// src/advanced/components/CouponForm.tsx (관리자용) +export const CouponForm: React.FC; + +// src/advanced/components/CouponManagement.tsx (관리자용) +export const CouponManagement: React.FC; +``` + +### 2단계: Coupon 상수 분리 + +```typescript +// src/advanced/constants/coupon.ts +export const initialCoupons: Coupon[] = [...] +export const COUPON_FORM_INITIAL_STATE = {...} +export const COUPON_VALIDATION_RULES = {...} +``` + +### 3단계: Coupon 순수함수 분리 + +```typescript +// src/advanced/utils/couponUtils.ts +export const validateCouponCondition = (coupon: Coupon, cartTotal: number): boolean +export const applyCouponDiscount = (total: number, coupon: Coupon): number +export const generateCouponCode = (): string +export const validateCouponData = (coupon: Partial): boolean +export const isCouponDuplicate = (coupons: Coupon[], code: string): boolean +export const getCouponDisplayText = (coupon: Coupon): string +``` + +### 4단계: Coupon 상태관리 (Atom + Hook) + +```typescript +// src/advanced/models/couponAtoms.ts +export const couponsAtom = atom(initialCoupons) +export const availableCouponsAtom = atom((get) => ...) + +// src/advanced/hooks/useCoupons.ts +export const useCoupons = () => { + // localStorage 연동 + // CRUD 함수들 + // 검증 로직 +} +``` + +### 5단계: App.tsx에서 Coupon 관련 코드 제거 및 연결 + +## 이전 작업과의 연결점 + +- Day 2의 Cart total과 연동하여 쿠폰 적용 검증 +- 기존 localStorage 유틸리티 재사용 + +## 테스트 전략 + +- [ ] 각 단계 완료 후 즉시 테스트 실행 +- [ ] 쿠폰 적용 조건 검증 테스트 +- [ ] 쿠폰 할인 계산 테스트 +- [ ] 쿠폰 CRUD 기능 테스트 + +## 성공 기준 + +1. Coupon 도메인이 완전히 분리됨 +2. App 컴포넌트에서 Coupon 관련 코드가 모두 제거됨 +3. 쿠폰 적용 및 검증이 정상 동작함 +4. 모든 기존 테스트가 통과함 + +## 다음 날 준비사항 + +- Notification 시스템 분석 준비 diff --git a/4days-refactoring-prd.md b/4days-refactoring-prd.md new file mode 100644 index 00000000..d165ce32 --- /dev/null +++ b/4days-refactoring-prd.md @@ -0,0 +1,103 @@ +# Day 4: Notification 시스템 + UI 컴포넌트 분리 + +## 목표 + +- Notification 시스템 완전 분리 +- 재사용 가능한 UI 컴포넌트 생성 +- 검색 및 디바운스 기능 모듈화 + +## 작업 범위 + +### 1단계: Notification 관련 컴포넌트 분리 + +```typescript +// src/advanced/components/ui/NotificationList.tsx +export const NotificationList: React.FC; + +// src/advanced/components/ui/Toast.tsx +export const Toast: React.FC; +``` + +### 2단계: Notification 상수 분리 + +```typescript +// src/advanced/constants/notification.ts +export const NOTIFICATION_DURATION = 3000; +export const NOTIFICATION_TYPES = { + SUCCESS: "success", + ERROR: "error", + WARNING: "warning", +} as const; +``` + +### 3단계: Notification 순수함수 분리 + +```typescript +// src/advanced/utils/notificationUtils.ts +export const createNotification = (message: string, type: NotificationType): Notification +export const generateNotificationId = (): string +``` + +### 4단계: Notification 상태관리 (Atom + Hook) + +```typescript +// src/advanced/models/notificationAtoms.ts +export const notificationsAtom = atom([]); + +// src/advanced/hooks/useNotifications.ts +export const useNotifications = () => { + // 알림 추가/제거/자동제거 +}; +``` + +### 5단계: 재사용 가능한 UI 컴포넌트 생성 + +```typescript +// src/advanced/components/ui/Button.tsx +export const Button: React.FC; + +// src/advanced/components/ui/Input.tsx +export const Input: React.FC; + +// src/advanced/components/ui/SearchInput.tsx +export const SearchInput: React.FC; + +// src/advanced/components/ui/TabNavigation.tsx +export const TabNavigation: React.FC; +``` + +### 6단계: 검색 및 디바운스 기능 모듈화 + +```typescript +// src/advanced/utils/hooks/useDebounce.ts +export const useDebounce = (value: T, delay: number): T + +// src/advanced/utils/searchUtils.ts +export const searchProducts = (products: ProductWithUI[], term: string): ProductWithUI[] +``` + +### 7단계: App.tsx에서 해당 코드 제거 및 연결 + +## 이전 작업과의 연결점 + +- Day 1-3에서 분리한 모든 도메인과 연동 +- 모든 도메인에서 notification 사용 + +## 테스트 전략 + +- [ ] 알림 시스템 동작 테스트 +- [ ] UI 컴포넌트 렌더링 테스트 +- [ ] 디바운스 기능 테스트 +- [ ] 검색 기능 테스트 + +## 성공 기준 + +1. Notification 시스템이 완전히 분리됨 +2. 재사용 가능한 UI 컴포넌트들이 생성됨 +3. App 컴포넌트가 대폭 간소화됨 +4. 모든 기존 테스트가 통과함 + +## 다음 날 준비사항 + +- 최종 App 컴포넌트 정리 준비 +- 관리자 페이지 컴포넌트 완성 준비 diff --git a/5days-refactoring-prd.md b/5days-refactoring-prd.md new file mode 100644 index 00000000..116ca095 --- /dev/null +++ b/5days-refactoring-prd.md @@ -0,0 +1,148 @@ +# Day 5: 최종 정리 및 레이아웃 컴포넌트 분리 + +## 목표 + +- 레이아웃 관련 컴포넌트 분리 완료 +- AdminPage, ProductListPage 최종 정리 +- App 컴포넌트를 최소한의 라우팅 로직만 포함하도록 정리 +- 전체 아키텍처 검증 및 문서화 + +## 작업 범위 + +### 1단계: 레이아웃 컴포넌트 분리 + +```typescript +// src/basic/components/layout/Header.tsx +export const Header: React.FC<{ + isAdmin: boolean; + onToggleAdmin: () => void; + cartItemCount: number; +}>; + +// src/basic/components/layout/Navigation.tsx +export const Navigation: React.FC; + +// src/basic/components/layout/MainLayout.tsx +export const MainLayout: React.FC; +``` + +### 2단계: 페이지 컴포넌트 최종 정리 + +```typescript +// src/basic/components/ProductListPage.tsx +export const ProductListPage: React.FC - 완전한 상품 목록 페이지 + +// src/basic/components/AdminPage.tsx +export const AdminPage: React.FC - 완전한 관리자 페이지 +``` + +### 3단계: App 컴포넌트 최종 정리 + +```typescript +// src/basic/App.tsx - 최종 모습 (50줄 이하) +const App = () => { + const [isAdmin, setIsAdmin] = useState(false); + + return ( + +
setIsAdmin(!isAdmin)} + cartItemCount={...} + /> + +
+ {isAdmin ? : } +
+ + ); +}; +``` + +### 4단계: 최종 폴더 구조 정리 + +``` +src/basic/ +├── App.tsx (50줄 이하) +├── components/ +│ ├── layout/ - 레이아웃 컴포넌트 +│ ├── ui/ - 재사용 UI 컴포넌트 +│ ├── ProductListPage.tsx +│ ├── AdminPage.tsx +│ ├── CartSummary.tsx +│ ├── ProductList.tsx +│ ├── ProductCard.tsx +│ ├── ProductForm.tsx +│ ├── CouponList.tsx +│ ├── CouponCard.tsx +│ └── CouponForm.tsx +├── constants/ +│ ├── product.ts +│ ├── cart.ts +│ ├── coupon.ts +│ └── notification.ts +├── utils/ +│ ├── hooks/ +│ ├── productUtils.ts +│ ├── cartUtils.ts +│ ├── couponUtils.ts +│ ├── discountUtils.ts +│ ├── formatters.ts +│ └── searchUtils.ts +├── models/ +│ ├── productAtoms.ts +│ ├── cartAtoms.ts +│ ├── couponAtoms.ts +│ └── notificationAtoms.ts +└── hooks/ + ├── useProducts.ts + ├── useCart.ts + ├── useCoupons.ts + └── useNotifications.ts +``` + +### 5단계: 최종 통합 테스트 + +- [ ] 모든 도메인 기능 통합 테스트 +- [ ] E2E 시나리오 테스트 +- [ ] 성능 테스트 (렌더링 최적화 확인) + +### 6단계: 아키텍처 문서화 + +```markdown +// docs/refactoring-summary.md + +- 리팩토링 전후 비교 +- 아키텍처 다이어그램 +- 각 계층의 책임 범위 +- 데이터 흐름 설명 +``` + +## 최종 검증 항목 + +### PR 체크리스트 기준 + +- [x] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? +- [x] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? +- [x] 계산함수는 순수함수로 작성이 되었나요? +- [x] 특정 Entity만 다루는 함수는 분리되어 있나요? +- [x] 특정 Entity만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요? +- [x] 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요? +- [x] UI 컴포넌트 계층과 엔티티 컴포넌트의 계층의 성격이 다르다는 것을 이해하고 적용했는가? +- [x] 엔티티 Hook과 라이브러리 훅과의 계층의 성격이 다르다는 것을 이해하고 적용했는가? +- [x] 엔티티 순수함수와 유틸리티 함수의 계층의 성격이 다르다는 것을 이해하고 적용했는가? + +## 최종 성공 기준 + +1. App 컴포넌트가 50줄 이하로 단순화됨 +2. 모든 기존 테스트가 통과함 +3. 각 도메인이 완전히 독립적으로 분리됨 +4. 재사용 가능한 컴포넌트들이 확보됨 +5. 코드 가독성과 유지보수성이 대폭 향상됨 + +## 최종 deliverable + +- 완전히 리팩토링된 쇼핑몰 애플리케이션 +- 계층별 아키텍처 문서 +- 리팩토링 전후 비교 분석 +- 각 도메인별 사용 가이드 diff --git a/README.md b/README.md index 3198c545..8c8d22e2 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,15 @@ 이번 과제는 단일책임원칙을 위반한 거대한 컴포넌트를 리팩토링 하는 것입니다. React의 컴포넌트는 단일 책임 원칙(Single Responsibility Principle, SRP)을 따르는 것이 좋습니다. 즉, 각 컴포넌트는 하나의 책임만을 가져야 합니다. 하지만 실제로는 여러 가지 기능을 가진 거대한 컴포넌트를 작성하는 경우가 많습니다. [목표] + ## 1. 취지 + - React의 추구미(!)를 이해해보아요! - 단일 책임 원칙(SRP)을 위반한 거대한 컴포넌트가 얼마나 안 좋은지 경험해보아요! - 단일 책임이라는 개념을 이해하기 상태, 순수함수, 컴포넌트, 훅 등 다양한 계층을 이해해합니다. - 엔티티와 UI를 구분하고 데이터, 상태, 비즈니스 로직 등의 특징이 다르다는 것을 이해해보세요. - 이를 통해 적절한 Custom Hook과 유틸리티 함수를 분리하고, 컴포넌트 계층 구조를 정리하는 능력을 갖춥니다! - ## 2. 목표 모든 소프트웨어에는 적절한 책임과 계층이 존재합니다. 하나의 계층(Component)만으로 소프트웨어를 구성하게 되면 나중에는 정리정돈이 되지 않은 코드를 만나게 됩니다. 예전에는 이러한 BestPractice에 대해서 혼돈의 시대였지만 FE가 진화를 거듭하는 과정에서 적절한 계측에 대한 합의가 이루어지고 있는 상태입니다. @@ -22,7 +23,7 @@ React의 주요 책임 계층은 Component, hook, function 등이 있습니다. - 엔티티를 다루는 상태와 그렇지 않은 상태 - cart, isCartFull vs isShowPopup - 엔티티를 다루는 컴포넌트와 훅 - CartItemView, useCart(), useProduct() - 엔티티를 다루지 않는 컴포넌트와 훅 - Button, useRoute, useEvent 등 -- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) +- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) 이번 과제의 목표는 이러한 계층을 이해하고 분리하여 정리정돈을 하는 기준이나 방법등을 습득하는데 있습니다. @@ -35,34 +36,34 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. #### 1) 장바구니 페이지 요구사항 - 상품 목록 - - 상품명, 가격, 재고 수량 등을 표시 - - 각 상품의 할인 정보 표시 - - 재고가 없는 경우 품절 표시가 되며 장바구니 추가가 불가능 + - 상품명, 가격, 재고 수량 등을 표시 + - 각 상품의 할인 정보 표시 + - 재고가 없는 경우 품절 표시가 되며 장바구니 추가가 불가능 - 장바구니 - - 장바구니 내 상품 수량 조절 가능 - - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시 - - 적용된 할인율 표시 (예: "10% 할인 적용") - - 장바구니 내 모든 상품의 총액을 계산해야 + - 장바구니 내 상품 수량 조절 가능 + - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시 + - 적용된 할인율 표시 (예: "10% 할인 적용") + - 장바구니 내 모든 상품의 총액을 계산해야 - 쿠폰 할인 - - 할인 쿠폰을 선택하면 적용하면 최종 결제 금액에 할인정보가 반영 + - 할인 쿠폰을 선택하면 적용하면 최종 결제 금액에 할인정보가 반영 - 주문요약 - - 할인 전 총 금액 - - 총 할인 금액 - - 최종 결제 금액 + - 할인 전 총 금액 + - 총 할인 금액 + - 최종 결제 금액 #### 2) 관리자 페이지 요구사항 - 상품 관리 - - 상품 정보 (상품명, 가격, 재고, 할인율) 수정 가능 - - 새로운 상품 추가 가능 - - 상품 제거 가능 + - 상품 정보 (상품명, 가격, 재고, 할인율) 수정 가능 + - 새로운 상품 추가 가능 + - 상품 제거 가능 - 할인 관리 - - 상품별 할인 정보 추가/수정/삭제 가능 - - 할인 조건 설정 (구매 수량에 따른 할인율) + - 상품별 할인 정보 추가/수정/삭제 가능 + - 할인 조건 설정 (구매 수량에 따른 할인율) - 쿠폰 관리 - - 전체 상품에 적용 가능한 쿠폰 생성 - - 쿠폰 정보 입력 (이름, 코드, 할인 유형, 할인 값) - - 할인 유형은 금액 또는 비율로 설정 가능 + - 전체 상품에 적용 가능한 쿠폰 생성 + - 쿠폰 정보 입력 (이름, 코드, 할인 유형, 할인 값) + - 할인 유형은 금액 또는 비율로 설정 가능 ### (2) 코드 개선 요구사항 @@ -88,9 +89,8 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. ### (3) 테스트 코드 통과하기 - - ## 심화과제: Props drilling + - 이번 심화과제는 Context나 Jotai를 사용해서 Props drilling을 없애는 것입니다. - 어떤 props는 남겨야 하는지, 어떤 props는 제거해야 하는지에 대한 기준을 세워보세요. - Context나 Jotai를 사용하여 상태를 관리하는 방법을 익히고, 이를 통해 컴포넌트 간의 데이터 전달을 효율적으로 처리할 수 있습니다. @@ -102,9 +102,8 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. - basic에서 열심히 컴포넌트를 분리해주었겠죠? - 아마 그 과정에서 container - presenter 패턴으로 만들어졌기에 props drilling이 상당히 불편했을거에요. - 그래서 심화과제에서는 props drilling을 제거하는 작업을 할거에요. - - 전역상태관리가 아직 낯설다 - jotai를 선택해주세요 (참고자료 참고) - - 나는 깊이를 공부해보고 싶아. - context를 선택해서 상태관리를 해보세요. - + - 전역상태관리가 아직 낯설다 - jotai를 선택해주세요 (참고자료 참고) + - 나는 깊이를 공부해보고 싶아. - context를 선택해서 상태관리를 해보세요. ### (1) 요구사항 @@ -112,15 +111,92 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. - Context나 Jotai를 사용하여 상태를 관리합니다. - 테스트 코드를 통과합니다. - ### (2) 힌트 - UI 컴포넌트와 엔티티 컴포넌트는 각각 props를 다르게 받는게 좋습니다. - - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, + - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, - 엔티티 컴포넌트는 가급적 엔티티를 중심으로 전달받는 것이 좋습니다. - 특히 콜백의 경우, - UI 컴포넌트는 이벤트 핸들러를 props로 받아서 처리하도록 해서 재사용성을 높이지만, - 엔티티 컴포넌트는 props가 아닌 컴포넌트 내부에서 상태를 관리하는 것이 좋습니다. +### (3) 추가된 Custom Hook + +#### useSearchParamsStore + +Jotai atom을 사용하여 URL search parameters를 관리하는 훅입니다. + +**주요 기능:** + +- `getParam(key)`: 특정 파라미터 값 가져오기 +- `setParam(key, value)`: 단일 파라미터 설정 +- `setParams(object)`: 여러 파라미터 한번에 설정 +- `clearParams()`: 모든 파라미터 제거 +- `searchParams`: 현재 모든 파라미터 객체 + +**사용 예시:** + +```tsx +const { searchParams, getParam, setParam, setParams, clearParams } = + useSearchParamsStore(); + +// 단일 파라미터 설정 +setParam("category", "electronics"); + +// 여러 파라미터 설정 +setParams({ + category: "clothing", + brand: "nike", + price: "100-500", +}); + +// 파라미터 값 가져오기 +const category = getParam("category"); +``` + +#### useSearchParams (직접 구현) + +React Router의 `useSearchParams`를 직접 구현한 커스텀 훅입니다. + +**주요 기능:** + +- `get(key)`: 특정 파라미터 값 가져오기 +- `set(key, value)`: 단일 파라미터 설정 +- `delete(key)`: 특정 파라미터 삭제 +- `clear()`: 모든 파라미터 제거 +- `has(key)`: 파라미터 존재 여부 확인 +- `keys()`: 모든 파라미터 키 배열 +- `values()`: 모든 파라미터 값 배열 +- `size()`: 파라미터 개수 +- `toString()`: URL 쿼리 문자열로 변환 +- `forEach(callback)`: 모든 파라미터 순회 + +**사용 예시:** + +```tsx +const [searchParams, setSearchParams] = useSearchParams(); + +// 단일 파라미터 설정 +searchParams.set("category", "electronics"); + +// 여러 파라미터 설정 +setSearchParams({ + category: "clothing", + brand: "nike", + price: "100-500", +}); + +// 파라미터 값 가져오기 +const category = searchParams.get("category"); +// 파라미터 삭제 +searchParams.delete("category"); +// 히스토리 교체 (replace: true) +setSearchParams( + { + newParam: "value", + }, + { replace: true } +); +``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..f166c976 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,125 @@ +import js from "@eslint/js"; +import tseslint from "@typescript-eslint/eslint-plugin"; +import tsparser from "@typescript-eslint/parser"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import react from "eslint-plugin-react"; + +export default [ + js.configs.recommended, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + globals: { + // 브라우저 API + window: "readonly", + document: "readonly", + localStorage: "readonly", + sessionStorage: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + console: "readonly", + URLSearchParams: "readonly", + CustomEvent: "readonly", + EventListener: "readonly", + history: "readonly", + // React + React: "readonly", + // HTML 타입들 + HTMLInputElement: "readonly", + HTMLButtonElement: "readonly", + HTMLFormElement: "readonly", + // 테스트 환경 + describe: "readonly", + test: "readonly", + it: "readonly", + expect: "readonly", + beforeEach: "readonly", + afterEach: "readonly", + beforeAll: "readonly", + afterAll: "readonly", + vi: "readonly", + }, + }, + plugins: { + "@typescript-eslint": tseslint, + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + react: react, + }, + settings: { + react: { + version: "detect", + }, + }, + rules: { + // TypeScript 관련 규칙 + ...tseslint.configs.recommended.rules, + + // React Hooks 규칙 - React dependency 체크의 핵심 + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + + // React Refresh 규칙 + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + + // React 관련 규칙들 + "react/jsx-key": "error", + "react/jsx-no-duplicate-props": "error", + "react/jsx-no-undef": "error", + "react/jsx-pascal-case": "warn", + "react/jsx-uses-react": "error", + "react/jsx-uses-vars": "error", + "react/no-array-index-key": "warn", + "react/no-danger": "warn", + "react/no-deprecated": "warn", + "react/no-direct-mutation-state": "error", + "react/no-find-dom-node": "warn", + "react/no-is-mounted": "error", + "react/no-render-return-value": "error", + "react/no-string-refs": "error", + "react/no-unescaped-entities": "warn", + "react/no-unknown-property": "error", + "react/no-unsafe": "warn", + "react/self-closing-comp": "warn", + "react/sort-comp": "warn", + "react/style-prop-object": "error", + "react/void-dom-elements-no-children": "error", + }, + }, + { + files: ["**/*.test.{ts,tsx}", "**/__tests__/**/*.{ts,tsx}"], + languageOptions: { + globals: { + describe: "readonly", + test: "readonly", + it: "readonly", + expect: "readonly", + beforeEach: "readonly", + afterEach: "readonly", + beforeAll: "readonly", + afterAll: "readonly", + vi: "readonly", + }, + }, + rules: { + "@typescript-eslint/ban-ts-comment": "off", + }, + }, + { + ignores: ["dist/**", ".eslintrc.cjs", "eslint.config.js"], + }, +]; diff --git a/index.advanced.html b/index.html similarity index 100% rename from index.advanced.html rename to index.html diff --git a/package.json b/package.json index a0fb5177..11582f2b 100644 --- a/package.json +++ b/package.json @@ -9,17 +9,20 @@ "start:advanced": "vite serve --open ./index.advanced.html", "test": "vitest", "test:origin": "vitest src/origin", - "test:basic": "vitest basic.test.ts", - "test:advanced": "vitest advanced.test.js", + "test:basic": "vitest src/basic", + "test:advanced": "vitest src/advanced", "test:ui": "vitest --ui", "build": "tsc -b && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "jotai": "^2.12.5", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "zod": "^4.0.14" }, "devDependencies": { + "@eslint/js": "^9.32.0", "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -30,6 +33,7 @@ "@vitejs/plugin-react-swc": "^3.11.0", "@vitest/ui": "^3.2.4", "eslint": "^9.32.0", + "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "jsdom": "^26.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85..8b60b929 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,13 +8,22 @@ importers: .: dependencies: + jotai: + specifier: ^2.12.5 + version: 2.12.5(@types/react@19.1.9)(react@19.1.1) react: specifier: ^19.1.1 version: 19.1.1 react-dom: specifier: ^19.1.1 version: 19.1.1(react@19.1.1) + zod: + specifier: ^4.0.14 + version: 4.0.14 devDependencies: + '@eslint/js': + specifier: ^9.32.0 + version: 9.32.0 '@testing-library/jest-dom': specifier: ^6.6.4 version: 6.6.4 @@ -45,6 +54,9 @@ importers: eslint: specifier: ^9.32.0 version: 9.32.0 + eslint-plugin-react: + specifier: ^7.37.5 + version: 7.37.5(eslint@9.32.0) eslint-plugin-react-hooks: specifier: ^5.2.0 version: 5.2.0(eslint@9.32.0) @@ -732,10 +744,46 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -753,6 +801,18 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -807,6 +867,18 @@ packages: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} @@ -835,23 +907,71 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + es-abstract@1.24.0: + resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.2.1: + resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + esbuild@0.25.8: resolution: {integrity: sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==} engines: {node: '>=18'} @@ -876,6 +996,12 @@ packages: peerDependencies: eslint: '>=8.40' + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -974,11 +1100,37 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -991,9 +1143,21 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -1002,6 +1166,25 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -1038,14 +1221,70 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.0: + resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==} + engines: {node: '>= 0.4'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -1053,9 +1292,64 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jotai@2.12.5: + resolution: {integrity: sha512-G8m32HW3lSmcz/4mbqx0hgJIQ0ekndKWiYP7kWVKi0p6saLXdSoye+FZiOFyonnd7Q482LCzm8sMDl7Ar1NWDw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1084,6 +1378,10 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1101,6 +1399,10 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + loupe@3.1.2: resolution: {integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==} @@ -1117,6 +1419,10 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1154,10 +1460,42 @@ packages: nwsapi@2.2.20: resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -1181,6 +1519,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1203,6 +1544,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1215,6 +1560,9 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1227,6 +1575,9 @@ packages: peerDependencies: react: ^19.1.1 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -1238,13 +1589,25 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -1260,6 +1623,18 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1270,11 +1645,27 @@ packages: scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.6.3: resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} hasBin: true + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1283,6 +1674,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1300,6 +1707,29 @@ packages: std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -1319,6 +1749,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -1377,11 +1811,31 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} hasBin: true + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1478,6 +1932,22 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1515,6 +1985,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.0.14: + resolution: {integrity: sha512-nGFJTnJN6cM2v9kXL+SOBq3AtjQby3Mv5ySGFof5UGRHrRioSJ5iG680cYNjE/yWk671nROcpPj4hAS8nyLhSw==} + snapshots: '@adobe/css-tools@4.4.0': {} @@ -2083,8 +2556,71 @@ snapshots: aria-query@5.3.2: {} + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + balanced-match@1.0.2: {} brace-expansion@1.1.11: @@ -2102,6 +2638,23 @@ snapshots: cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} chai@5.2.1: @@ -2159,6 +2712,24 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + debug@4.3.7: dependencies: ms: 2.1.3 @@ -2173,16 +2744,139 @@ snapshots: deep-is@0.1.4: {} + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + dequal@2.0.3: {} + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + entities@4.5.0: {} + es-abstract@1.24.0: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.19 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + safe-array-concat: 1.1.3 + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + esbuild@0.25.8: optionalDependencies: '@esbuild/aix-ppc64': 0.25.8 @@ -2224,6 +2918,28 @@ snapshots: dependencies: eslint: 9.32.0 + eslint-plugin-react@7.37.5(eslint@9.32.0): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.1 + eslint: 9.32.0 + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -2347,9 +3063,50 @@ snapshots: flatted@3.3.3: {} + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2360,12 +3117,39 @@ snapshots: globals@14.0.0: {} + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + graphemer@1.4.0: {} + has-bigints@1.1.0: {} + has-flag@3.0.0: {} has-flag@4.0.0: {} + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -2401,18 +3185,139 @@ snapshots: indent-string@4.0.0: {} + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-extglob@2.1.1: {} + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.0: + dependencies: + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-number@7.0.0: {} is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.19 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + isexe@2.0.0: {} + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jotai@2.12.5(@types/react@19.1.9)(react@19.1.1): + optionalDependencies: + '@types/react': 19.1.9 + react: 19.1.1 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -2454,6 +3359,13 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -2471,6 +3383,10 @@ snapshots: lodash@4.17.21: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + loupe@3.1.2: {} loupe@3.2.0: {} @@ -2483,6 +3399,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + math-intrinsics@1.1.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -2510,6 +3428,42 @@ snapshots: nwsapi@2.2.20: {} + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2519,6 +3473,12 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -2539,6 +3499,8 @@ snapshots: path-key@3.1.1: {} + path-parse@1.0.7: {} + pathe@2.0.3: {} pathval@2.0.0: {} @@ -2551,6 +3513,8 @@ snapshots: picomatch@4.0.3: {} + possible-typed-array-names@1.1.0: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -2565,6 +3529,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -2574,6 +3544,8 @@ snapshots: react: 19.1.1 scheduler: 0.26.0 + react-is@16.13.1: {} + react-is@17.0.2: {} react@19.1.1: {} @@ -2583,10 +3555,36 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + regenerator-runtime@0.14.1: {} + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + resolve-from@4.0.0: {} + resolve@2.0.0-next.5: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + reusify@1.0.4: {} rollup@4.46.2: @@ -2621,6 +3619,25 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + safer-buffer@2.1.2: {} saxes@6.0.0: @@ -2629,14 +3646,66 @@ snapshots: scheduler@0.26.0: {} + semver@6.3.1: {} + semver@7.6.3: {} + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} sirv@3.0.1: @@ -2651,6 +3720,55 @@ snapshots: std-env@3.9.0: {} + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.0 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -2669,6 +3787,8 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} tinybench@2.9.0: {} @@ -2714,8 +3834,48 @@ snapshots: dependencies: prelude-ls: 1.2.1 + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + typescript@5.9.2: {} + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -2811,6 +3971,47 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.0 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.19: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -2829,3 +4030,5 @@ snapshots: xmlchars@2.2.0: {} yocto-queue@0.1.0: {} + + zod@4.0.14: {} diff --git a/pr.md b/pr.md new file mode 100644 index 00000000..9556a527 --- /dev/null +++ b/pr.md @@ -0,0 +1,261 @@ +## 과제의 핵심취지 + +- React의 hook 이해하기 +- 함수형 프로그래밍에 대한 이해 +- 액션과 순수함수의 분리 + +## 과제에서 꼭 알아가길 바라는 점 + +- 엔티티를 다루는 상태와 그렇지 않은 상태 - cart, isCartFull vs isShowPopup +- 엔티티를 다루는 컴포넌트와 훅 - CartItemView, useCart(), useProduct() +- 엔티티를 다루지 않는 컴포넌트와 훅 - Button, useRoute, useEvent 등 +- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) + +### 기본과제 + +- 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] 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요? + +### 심화과제 + +- 재사용 가능한 Custom UI 컴포넌트를 만들어 보기 +- 재사용 가능한 Custom 라이브러리 Hook을 만들어 보기 +- 재사용 가능한 Custom 유틸 함수를 만들어 보기 +- 그래서 엔티티와는 어떤 다른 계층적 특징을 가지는지 이해하기 + +- [x] UI 컴포넌트 계층과 엔티티 컴포넌트의 계층의 성격이 다르다는 것을 이해하고 적용했는가? +- [x] 엔티티 Hook과 라이브러리 훅과의 계층의 성격이 다르다는 것을 이해하고 적용했는가? +- [x] 엔티티 순수함수와 유틸리티 함수의 계층의 성격이 다르다는 것을 이해하고 적용했는가? + +## 과제 셀프회고 + +### 과제를 하면서 내가 제일 신경 쓴 부분은 무엇인가요? + +1. **함수 분리와 책임 분담** + + - 기존 `formatPrice` 함수가 너무 많은 책임을 가지고 있었습니다. admin 여부 확인, 도메인 체크, 카트 데이터와의 결합, 개수 계산 등 여러 로직이 하나의 함수에 얽혀있었습니다. + - 이를 논리적으로 분리하여 `getRefinedProduct`로 상품 데이터와 장바구니 데이터를 결합한 새로운 상품 객체를 정의하고, `displayPrice`로 순수하게 가격 표시 로직만 담당하도록 리팩토링했습니다. + - `displayPrice`는 admin 도메인 여부와 관계없이 독립적으로 suffix, prefix를 받아 자유롭게 가격을 노출 시킬 수 있도록 리팩토링하였습니다. + +2. **MVVM 패턴으로 계층 구조의 명확한 구분** + + - **View Layer (UI Components)**: 사용자 인터페이스 표시 + - **ViewModel Layer (Hooks)**: 상태 관리와 비즈니스 로직 조합 + - **Model Layer (Store)**: 데이터 저장과 상태 관리 + +3. **불필요한 상태, 계산 함수 제거** + +불필요한 상태를 사용하고 있는 ProductAccordion 컴포넌트에서 하나의 상태만 사용하도록 리팩토링 + +```tsx +// Basic 버전 - 복잡한 상태 관리 +const [showProductForm, setShowProductForm] = useState(false); +const [editingProduct, setEditingProduct] = useState(null); +const [selectedProduct, setSelectedProduct] = useState( + undefined +); + +// Advanced 버전 - 단순화된 상태 관리 +const [initProductFormData, setInitProductFormData] = useState( + null +); +const showProductForm = initProductFormData !== null; +``` + +### 4. useSearchParams 활용한 URL 기반 상태 관리 + +기존의 상태 관리 방식을 URL 기반 상태 관리로 개선하여 더 나은 사용자 경험과 상태 동기화를 구현했습니다. + +```ts +import { useState, useEffect, useCallback } from "react"; + +/** + * useState와 useEffect를 활용한 간단한 useSearchParams 훅 + * URL의 search parameters를 구독하고 업데이트할 수 있습니다. + */ +export function useSearchParams(): [ + URLSearchParams, + (params: URLSearchParams | Record) => void +] { + const [searchParams, setSearchParamsState] = useState(() => { + if (typeof window === "undefined") { + return new URLSearchParams(); + } + return new URLSearchParams(window.location.search); + }); + + // URL 변경 감지 + useEffect(() => { + if (typeof window === "undefined") return; + + const handlePopState = () => { + setSearchParamsState(new URLSearchParams(window.location.search)); + }; + + const handleSearchParamsChanged = (event: CustomEvent) => { + setSearchParamsState(event.detail.searchParams); + }; + + window.addEventListener("popstate", handlePopState); + window.addEventListener( + "searchParamsChanged", + handleSearchParamsChanged as EventListener + ); + + return () => { + window.removeEventListener("popstate", handlePopState); + window.removeEventListener( + "searchParamsChanged", + handleSearchParamsChanged as EventListener + ); + }; + }, []); + + // searchParams 업데이트 함수 + const setSearchParams = useCallback( + (params: URLSearchParams | Record) => { + if (typeof window === "undefined") return; + + const newSearchParams = + params instanceof URLSearchParams + ? params + : new URLSearchParams(params); + + const newUrl = `${window.location.pathname}${ + newSearchParams.toString() ? `?${newSearchParams.toString()}` : "" + }${window.location.hash}`; + + history.replaceState(null, "", newUrl); + + // replaceState 후 상태 동기화를 위한 커스텀 이벤트 발생 + window.dispatchEvent( + new CustomEvent("searchParamsChanged", { + detail: { searchParams: newSearchParams }, + }) + ); + }, + [] + ); + + return [searchParams, setSearchParams]; +} +``` + +#### 4.1 Zod를 활용한 product query hook 구성 + +```tsx +// useFilterSearchParams.ts - URL 기반 상태 관리 전용 Hook +export default function useFilterSearchParams() { + const [searchParams, setSearchParams] = useSearchParams(); + + // 데이터 변환 로직 + const filterSearchParams: filterSearchParamsSchemaType = React.useMemo(() => { + const paramsObject = Object.fromEntries(searchParams.entries()); + const validSearchParams = filterSearchParamsSchema.safeParse(paramsObject); + + if (!validSearchParams.success) { + return { searchTerm: "" }; + } + + return { ...validSearchParams.data }; + }, [searchParams]); + + // 액션 함수 + const setFilterSearchParams = ( + value: Partial + ) => { + const newSearchParams = { ...filterSearchParams, ...value }; + + // 빈 값들은 제거하고 URL에 설정 + const filteredParams = Object.fromEntries( + Object.entries(newSearchParams).filter( + ([_, v]) => v !== "" && v !== undefined + ) + ); + + setSearchParams(filteredParams); + }; + + return { filterSearchParams, setFilterSearchParams }; +} +``` + +#### 4.2 기존에 단순히 searchTerm으로만 쿼리하던 로직을 향후 다른 query를 반영할 수 있도록 확장성 있게 구성 + +```tsx +/** + * 상품 검색 필터링 + */ +export const filterProducts = ( + products: Product[], + filterQuery: filterSearchParamsSchemaType +): Product[] => { + return filterProductsBySearchTerm(products, filterQuery.searchTerm ?? ""); +}; + +const filterProductsBySearchTerm = ( + products: Product[], + searchTerm: string +) => { + if (!searchTerm.trim()) { + return products; + } + return products.filter( + (product) => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(searchTerm.toLowerCase())) + ); +}; +``` + +### 과제를 다시 해보면 더 잘 할 수 있었겠다 아쉬운 점이 있다면 무엇인가요? + +### 1. Hook에서 데이터와 액션의 분리가 안됨 + +현재 `useCart`와 같은 Hook에서 데이터(`cart`)와 액션 함수들(`addToCart`, `updateCartItemQuantity` 등)을 함께 제공하고 있는데, 이 부분이 함수형 프로그래밍 원칙에 완전히 부합하는지 의문이 들었습니다. + +**고민했던 점:** + +- 데이터와 액션을 완전히 분리하면 순수성은 높아지지만 DX(Developer Experience)가 나빠질 수 있음 +- 분리하지 않으면 편리하지만 Hook의 책임이 모호해질 수 있음 + +### 2. Handle 함수에서 validation을 담당 + +현재 구현에서는 `handleUpdateQuantity` 같은 함수에서 매번 검증 코드를 작성하고 있어서 재사용성이 떨어지는 문제가 있었습니다. 아래 방법들중 하나를 채택해서 수정하는게 좋았을 것 같습니다. + +1. **액션 함수 내부에서 에러 처리** + + - 장점: 재사용성 높음 + - 단점: Hook이 UI 의존성을 가질 수 있음 + +2. **전용 Handle Hook 분리** + + - 장점: 관심사 분리 명확 + - 단점: Hook이 너무 세분화될 수 있음 + +3. **에러 핸들러 패턴** + + - 장점: 유연한 에러 처리 + - 단점: 사용하는 쪽에서 매번 핸들러 전달 필요 + +## 리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 + +1. 위에서 얘기한 아쉬운점에 대한 내용들에 대해서 의견을 받아보고 싶습니다. + +2. MVVM패턴으로 분리가 되었다고 생각했는데, 지금 분리된 구조가 MVVM 패턴이 맞을까요? + 추가적으로 테오는 어떤 패턴을 가장 선호하시나요? + +3. 전체적으로 제가 나눠놓은 함수들이 함수형패턴에 위배되지 않는지 피드백 받고싶습니다. diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1..5a9c9854 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,1124 +1,30 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { useState } from "react"; -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} +import { AdminPage } from "./pages/AdminPage"; +import { ShopPage } from "./pages/ShopPage"; + +import { Header } from "./components/ui/Header"; +import { NotificationList } from "./components/ui/NotificationList"; -interface Notification { +export interface Notification { id: string; message: string; - type: 'error' | 'success' | 'warning'; + type: "error" | "success" | "warning"; } -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; - const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; 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 - /> -
-
-
- - -
-
-
- )} -
-
- )} -
- ) : ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

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

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

-
-
- - )} -
-
-
- )} + {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 3f5c3d55..a1daa950 100644 --- a/src/advanced/__tests__/origin.test.tsx +++ b/src/advanced/__tests__/origin.test.tsx @@ -1,528 +1,581 @@ // @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('쇼핑몰 앱 통합 테스트', () => { +import { useAtom } from "jotai"; +import React from "react"; +import { notificationsAtom } from "../models/notification"; + +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(() => {}); + + // 알림 상태 초기화 (Jotai atom 사용 시 필요) + const TestComponent = () => { + const [, setNotifications] = useAtom(notificationsAtom); + React.useEffect(() => { + setNotifications([]); + }, [setNotifications]); + return null; + }; + render(); }); 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/cart/CartSection.tsx b/src/advanced/components/cart/CartSection.tsx new file mode 100644 index 00000000..5f079542 --- /dev/null +++ b/src/advanced/components/cart/CartSection.tsx @@ -0,0 +1,113 @@ +import { Product } from "../../../types"; +import { CartIcon, TrashIcon } from "../icons"; +import { calculateItemTotal, getRemainingStock } from "../../utils/cartUtils"; +import { useCart } from "../../hooks/useCart"; +import { useCallback } from "react"; +import { useNotifications } from "../../hooks/useNotifications"; + +export function CartSection({}: {}) { + const { cart, removeFromCart, updateCartItemQuantity } = useCart(); + + const { addNotification } = useNotifications(); + + // 장바구니 상품 수량 변경 (props로 받은 함수 사용) + const handleUpdateQuantity = useCallback( + (product: Product, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(product.id); + return; + } + + if (newQuantity > product.stock) { + addNotification?.(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return; + } + + if (!getRemainingStock(product, cart)) { + addNotification("재고가 부족합니다", "error"); + return; + } + + updateCartItemQuantity(product.id, newQuantity); + }, + [addNotification, cart, removeFromCart, updateCartItemQuantity] + ); + + return ( +
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => { + const itemTotal = calculateItemTotal(item, cart); + 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()}원 +

+
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/advanced/components/coupon/CouponForm.tsx b/src/advanced/components/coupon/CouponForm.tsx new file mode 100644 index 00000000..1e44802a --- /dev/null +++ b/src/advanced/components/coupon/CouponForm.tsx @@ -0,0 +1,168 @@ +import { useState } from "react"; +import { Coupon } from "../../../types"; +import { + COUPON_FORM_INITIAL_STATE, + COUPON_LIMITS, +} from "../../constants/coupon"; +import { useNotifications } from "../../hooks/useNotifications"; + +interface CouponFormProps { + onSubmit: (coupon: Coupon) => void; + onCancel: () => void; +} + +export const CouponForm: React.FC = ({ + onSubmit, + onCancel, +}) => { + const { addNotification } = useNotifications(); + const [formData, setFormData] = useState(COUPON_FORM_INITIAL_STATE); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(formData as Coupon); + setFormData(COUPON_FORM_INITIAL_STATE); + }; + + const handleInputChange = ( + field: keyof typeof formData, + value: string | number + ) => { + setFormData((prev) => ({ + ...prev, + [field]: value, + })); + }; + + const getDiscountValuePlaceholder = () => { + return formData.discountType === "amount" ? "5000" : "10"; + }; + + const getDiscountValueMax = () => { + return COUPON_LIMITS[formData.discountType].max; + }; + + const getDiscountValueMin = () => { + return COUPON_LIMITS[formData.discountType].min; + }; + + return ( +
+

새 쿠폰 생성

+ +
+
+ + handleInputChange("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 + /> +
+ +
+ + + handleInputChange("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)) { + handleInputChange( + "discountValue", + value === "" ? 0 : parseInt(value) + ); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + const max = getDiscountValueMax(); + const min = getDiscountValueMin(); + + if (value > max) { + if (formData.discountType === "percentage") { + addNotification( + `할인율은 ${max}%를 초과할 수 없습니다`, + "error" + ); + } else { + addNotification( + `할인 금액은 ${max}원을 초과할 수 없습니다`, + "error" + ); + } + + handleInputChange("discountValue", max); + } else if (value < min) { + handleInputChange("discountValue", min); + } + }} + 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={getDiscountValuePlaceholder()} + min={getDiscountValueMin()} + max={getDiscountValueMax()} + required + /> +
+
+ +
+ + +
+
+ ); +}; diff --git a/src/advanced/components/coupon/CouponList.tsx b/src/advanced/components/coupon/CouponList.tsx new file mode 100644 index 00000000..72601752 --- /dev/null +++ b/src/advanced/components/coupon/CouponList.tsx @@ -0,0 +1,50 @@ +import { Coupon } from "../../../types"; +import { getCouponDisplayText } from "../../utils/couponUtils"; + +interface CouponListProps { + coupons: Coupon[]; + onApplyCoupon: (coupon: Coupon) => void; + selectedCoupon: Coupon | null; +} + +export const CouponList: React.FC = ({ + coupons, + onApplyCoupon, + selectedCoupon, +}) => { + if (coupons.length === 0) { + return ( +
+

사용 가능한 쿠폰이 없습니다

+
+ ); + } + + return ( +
+ {coupons.map((coupon) => ( +
onApplyCoupon(coupon)} + > +
+
+

{coupon.name}

+

{coupon.code}

+
+
+ + {getCouponDisplayText(coupon)} + +
+
+
+ ))} +
+ ); +}; diff --git a/src/advanced/components/coupon/CouponManagement.tsx b/src/advanced/components/coupon/CouponManagement.tsx new file mode 100644 index 00000000..0b0bcfa1 --- /dev/null +++ b/src/advanced/components/coupon/CouponManagement.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { Coupon } from "../../../types"; +import { CouponForm } from "./CouponForm"; +import { + getCouponDisplayText, + validateCouponData, + isCouponDuplicate, +} from "../../utils/couponUtils"; +import { TrashIcon, PlusIcon } from "../icons"; +import { useCoupons } from "../../hooks/useCoupons"; +import { useNotifications } from "../../hooks/useNotifications"; + +export const CouponManagement: React.FC = () => { + const { coupons, addCoupon, removeCoupon } = useCoupons(); + const { addNotification } = useNotifications(); + const [showCouponForm, setShowCouponForm] = useState(false); + + const handleAddCoupon = (coupon: Coupon) => { + // 검증 로직을 먼저 수행 + if (!validateCouponData(coupon)) { + return; + } + + // 중복 검사 + if (isCouponDuplicate(coupons, coupon.code)) { + addNotification("이미 존재하는 쿠폰 코드입니다.", "error"); + return; + } + + // 검증 통과 후 쿠폰 추가 + addCoupon(coupon); + addNotification("쿠폰이 추가되었습니다.", "success"); + setShowCouponForm(false); + }; + + const handleRemoveCoupon = (couponCode: string) => { + removeCoupon(couponCode); + addNotification("쿠폰이 삭제되었습니다.", "success"); + }; + + const handleCancelForm = () => { + setShowCouponForm(false); + }; + + return ( +
+
+

쿠폰 관리

+
+ +
+
+ {coupons.map((coupon) => ( +
+
+
+

{coupon.name}

+

+ {coupon.code} +

+
+ + {getCouponDisplayText(coupon)} + +
+
+ +
+
+ ))} + +
+ +
+
+ + {showCouponForm && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/src/advanced/components/coupon/CouponSection.tsx b/src/advanced/components/coupon/CouponSection.tsx new file mode 100644 index 00000000..23a62884 --- /dev/null +++ b/src/advanced/components/coupon/CouponSection.tsx @@ -0,0 +1,47 @@ +import { Coupon } from "../../../types"; +import { useCoupons } from "../../hooks/useCoupons"; + +export function CouponSection({ + selectedCoupon, + setSelectedCoupon, + applyCoupon, +}: { + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + applyCoupon: (coupon: Coupon) => void; +}) { + const { coupons } = useCoupons(); + + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +} diff --git a/src/advanced/components/icons/index.tsx b/src/advanced/components/icons/index.tsx new file mode 100644 index 00000000..d548b556 --- /dev/null +++ b/src/advanced/components/icons/index.tsx @@ -0,0 +1,206 @@ +import React from "react"; + +interface IconProps { + className?: string; + size?: number; +} + +export const CartIcon: React.FC = ({ + className = "w-6 h-6", + size, +}) => ( + + + +); + +export const TrashIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); + +export const PlusIcon: React.FC = ({ + className = "w-8 h-8", + size, +}) => ( + + + +); + +export const MinusIcon: React.FC = ({ + className = "w-4 h-4", + size, +}) => ( + + + +); + +export const ImageIcon: React.FC = ({ + className = "w-24 h-24", + size, +}) => ( + + + +); + +export const CloseIcon: React.FC = ({ + className = "w-4 h-4", + size, +}) => ( + + + +); + +export const ChevronDownIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); + +export const ChevronUpIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); + +export const CheckIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); + +export const AdminIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); diff --git a/src/advanced/components/payment/PaymentSection.tsx b/src/advanced/components/payment/PaymentSection.tsx new file mode 100644 index 00000000..c691ccfb --- /dev/null +++ b/src/advanced/components/payment/PaymentSection.tsx @@ -0,0 +1,70 @@ +import { calculateCartTotal } from "../../utils/cartUtils"; +import { useCart } from "../../hooks/useCart"; +import { useNotifications } from "../../hooks/useNotifications"; +import { Coupon } from "../../../types"; + +export function PaymentSection({ + selectedCoupon, + setSelectedCoupon, +}: { + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; +}) { + const { cart, clearCart } = useCart(); + const { addNotification } = useNotifications(); + + const totals = calculateCartTotal(cart, selectedCoupon); + + const completeOrder = () => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + "success" + ); + clearCart(); + setSelectedCoupon(null); + }; + + return ( +
+

결제 정보

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

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

+
+
+ ); +} diff --git a/src/advanced/components/product/ProductAccordion.tsx b/src/advanced/components/product/ProductAccordion.tsx new file mode 100644 index 00000000..c554e159 --- /dev/null +++ b/src/advanced/components/product/ProductAccordion.tsx @@ -0,0 +1,163 @@ +import React, { useState, useCallback } from "react"; +import { Product } from "../../../types"; +import { displayPrice } from "../../utils/formatters"; +import { ProductForm } from "./ProductForm"; +import { useProducts } from "../../hooks/useProducts"; +import { useNotifications } from "../../hooks/useNotifications"; +import { + NEW_PRODUCT_FORM_KEY, + PRODUCT_FORM_INITIAL_STATE, +} from "../../constants/product"; + +export const ProductAccordion: React.FC = () => { + const { products, addProduct, updateProduct, deleteProduct } = useProducts(); + const { addNotification } = useNotifications(); + + const [initProductFormData, setInitProductFormData] = + useState(null); + + const showProductForm = initProductFormData !== null; + + // 상품 편집 시작 + const handleStartEditProduct = useCallback((product: Product) => { + setInitProductFormData(product); + }, []); + + // 새 상품 추가 시작 + const handleAddNewProduct = useCallback(() => { + setInitProductFormData(PRODUCT_FORM_INITIAL_STATE); + }, []); + + // 상품 폼 취소 + const handleCancelProductForm = useCallback(() => { + setInitProductFormData(null); + }, []); + + // 상품 폼 제출 처리 + const handleProductSubmit = useCallback( + (productData: Product) => { + addNotification( + initProductFormData?.id === NEW_PRODUCT_FORM_KEY + ? "상품이 추가되었습니다." + : "상품이 수정되었습니다.", + "success" + ); + setInitProductFormData(null); + + if (!initProductFormData?.id) { + return; + } + + if (initProductFormData.id === NEW_PRODUCT_FORM_KEY) { + addProduct(productData); + return; + } + + updateProduct(initProductFormData.id, productData); + }, + [addNotification, initProductFormData?.id, updateProduct, addProduct] + ); + + // 상품 삭제 + const handleDeleteProduct = useCallback( + (productId: string) => { + deleteProduct(productId); + addNotification("상품이 삭제되었습니다.", "success"); + }, + [deleteProduct, addNotification] + ); + + return ( + <> +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {displayPrice(product, { + suffix: "원", + })} + + 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 && ( + + )} + + ); +}; diff --git a/src/advanced/components/product/ProductCard.tsx b/src/advanced/components/product/ProductCard.tsx new file mode 100644 index 00000000..90643014 --- /dev/null +++ b/src/advanced/components/product/ProductCard.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { Product } from "../../../types"; +import { ImageIcon } from "../icons"; + +interface ProductCardProps { + product: Product; + remainingStock: number; + onAddToCart: (product: Product) => void; + formatPrice: (price: number, productId?: string) => string; +} + +export const ProductCard: React.FC = ({ + product, + remainingStock, + onAddToCart, + formatPrice, +}) => { + 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}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; diff --git a/src/advanced/components/product/ProductForm.tsx b/src/advanced/components/product/ProductForm.tsx new file mode 100644 index 00000000..7e38d86f --- /dev/null +++ b/src/advanced/components/product/ProductForm.tsx @@ -0,0 +1,230 @@ +import React, { useState } from "react"; +import { Product, ProductFormData } from "../../../types"; +import { CloseIcon } from "../icons"; +import { useNotifications } from "../../hooks/useNotifications"; +import { PRODUCT_FORM_INITIAL_STATE } from "../../constants/product"; + +interface ProductFormProps { + initProductFormData: Product | null; + onSubmit: (productData: Product) => void; + onCancel: () => void; +} + +export const ProductForm: React.FC = ({ + initProductFormData, + onSubmit, + onCancel, +}) => { + const { addNotification } = useNotifications(); + const [productForm, setProductForm] = useState>( + initProductFormData ?? PRODUCT_FORM_INITIAL_STATE + ); + + const isNewProduct = initProductFormData?.id === "new"; + + const updateForm = (updates: Partial) => { + setProductForm({ ...productForm, ...updates }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if ( + !productForm?.name || + !productForm?.price || + productForm?.stock === undefined + ) { + addNotification("필수 필드를 모두 입력해주세요.", "error"); + return; + } + + onSubmit({ + name: productForm.name, + price: productForm.price, + stock: productForm.stock, + description: productForm.description ?? "", + discounts: productForm.discounts ?? [], + id: initProductFormData?.id ?? "new", + }); + }; + + return ( +
+
+

+ {initProductFormData?.id === "new" ? "새 상품 추가" : "상품 수정"} +

+
+
+ + updateForm({ 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 + /> +
+
+ + updateForm({ 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)) { + updateForm({ price: value === "" ? 0 : parseInt(value) }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + updateForm({ price: 0 }); + } else if (parseInt(value) < 0) { + addNotification("가격은 0보다 커야 합니다", "error"); + updateForm({ 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)) { + updateForm({ stock: value === "" ? 0 : parseInt(value) }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + updateForm({ stock: 0 }); + } else if (parseInt(value) < 0) { + addNotification("재고는 0보다 커야 합니다", "error"); + updateForm({ stock: 0 }); + } else if (parseInt(value) > 9999) { + addNotification( + "재고는 9999개를 초과할 수 없습니다", + "error" + ); + updateForm({ 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; + updateForm({ 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; + updateForm({ discounts: newDiscounts }); + }} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/advanced/components/product/ProductList.tsx b/src/advanced/components/product/ProductList.tsx new file mode 100644 index 00000000..a102e955 --- /dev/null +++ b/src/advanced/components/product/ProductList.tsx @@ -0,0 +1,133 @@ +import React, { useCallback } from "react"; + +import { Product } from "../../../types"; +import { getRefinedProduct } from "../../utils/productUtils"; +import { displayPrice } from "../../utils/formatters"; +import { getRemainingStock } from "../../utils/cartUtils"; +import useFilterSearchParams from "../../../hooks/useFilterSearchParams"; +import { useProducts } from "../../hooks/useProducts"; +import { useCart } from "../../hooks/useCart"; +import { useNotifications } from "../../hooks/useNotifications"; +import { ImageIcon } from "../icons"; + +export const ProductList: React.FC = () => { + const { products, filteredProducts } = useProducts(); + const { cart, addToCart } = useCart(); + const { addNotification } = useNotifications(); + const { filterSearchParams } = useFilterSearchParams(); + + const handleAddToCart = useCallback( + (product: Product) => { + if (!getRemainingStock(product, cart)) { + addNotification("재고가 부족합니다", "error"); + return; + } + + addToCart(product, 1); + + addNotification("장바구니에 담았습니다", "success"); + }, + [cart, addToCart, addNotification] + ); + + return ( +
+
+

전체 상품

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

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

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

+ {product.name} +

+ {product.description && ( +

+ {product.description} +

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

+ {displayPrice(refinedProduct, { + prefix: "₩", + })} +

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

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

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

+ 품절임박! {refinedProduct.stock}개 남음 +

+ )} + {refinedProduct.stock > 5 && ( +

+ 재고 {refinedProduct.stock}개 +

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); + })} +
+ )} +
+ ); +}; diff --git a/src/advanced/components/ui/Header.tsx b/src/advanced/components/ui/Header.tsx new file mode 100644 index 00000000..8817ff11 --- /dev/null +++ b/src/advanced/components/ui/Header.tsx @@ -0,0 +1,77 @@ +import useFilterSearchParams from "../../../hooks/useFilterSearchParams"; +import { useDebouncedCallback } from "../../utils/hooks/useDebounce"; +import { getCartItemCount } from "../../utils/cartUtils"; +import { CartIcon } from "../icons"; +import { useCart } from "../../hooks/useCart"; + +export const Header = ({ + isAdmin, + setIsAdmin, +}: { + isAdmin: boolean; + setIsAdmin: (isAdmin: boolean) => void; +}) => { + const { cart } = useCart(); + const { filterSearchParams, setFilterSearchParams } = useFilterSearchParams(); + + const handleSearchChange = (e: React.ChangeEvent) => { + const value = e.target.value; + + if (!value) { + setFilterSearchParams({ searchTerm: "" }); + return; + } + + debouncedSetSearchTerm(value); + }; + + const debouncedSetSearchTerm = useDebouncedCallback((value: string) => { + setFilterSearchParams({ searchTerm: value }); + }, 500); + + return ( +
+
+
+
+

SHOP

+ {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} + {!isAdmin && ( +
+ +
+ )} +
+ +
+
+
+ ); +}; diff --git a/src/advanced/components/ui/NotificationList.tsx b/src/advanced/components/ui/NotificationList.tsx new file mode 100644 index 00000000..25fcc348 --- /dev/null +++ b/src/advanced/components/ui/NotificationList.tsx @@ -0,0 +1,31 @@ +import { useNotifications } from "../../hooks/useNotifications"; +import { CloseIcon } from "../icons"; + +export const NotificationList = () => { + const { notifications, removeNotification } = useNotifications(); + + return ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ); +}; diff --git a/src/advanced/constants/cart.ts b/src/advanced/constants/cart.ts new file mode 100644 index 00000000..3282d3a0 --- /dev/null +++ b/src/advanced/constants/cart.ts @@ -0,0 +1,4 @@ +export const BULK_PURCHASE_THRESHOLD = 10; +export const BULK_PURCHASE_ADDITIONAL_DISCOUNT = 0.05; +export const MAX_DISCOUNT_RATE = 0.5; +export const MIN_PERCENTAGE_COUPON_AMOUNT = 10000; diff --git a/src/advanced/constants/coupon.ts b/src/advanced/constants/coupon.ts new file mode 100644 index 00000000..2a4ca77c --- /dev/null +++ b/src/advanced/constants/coupon.ts @@ -0,0 +1,46 @@ +import { Coupon } from "../../types"; + +// 초기 쿠폰 데이터 +export const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; + +// 쿠폰 폼 초기 상태 +export const COUPON_FORM_INITIAL_STATE = { + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0, +}; + +// 쿠폰 검증 규칙 +export const COUPON_VALIDATION_RULES = { + MIN_AMOUNT_DISCOUNT: 1000, + MAX_AMOUNT_DISCOUNT: 100000, + MIN_PERCENTAGE_DISCOUNT: 1, + MAX_PERCENTAGE_DISCOUNT: 100, + MIN_CART_TOTAL_FOR_PERCENTAGE: 10000, +}; + +// 쿠폰 타입별 최대값 +export const COUPON_LIMITS = { + amount: { + min: 1000, + max: 100000, + }, + percentage: { + min: 1, + max: 100, + }, +}; diff --git a/src/refactoring(hint)/constants/index.ts b/src/advanced/constants/index.ts similarity index 100% rename from src/refactoring(hint)/constants/index.ts rename to src/advanced/constants/index.ts diff --git a/src/advanced/constants/product.ts b/src/advanced/constants/product.ts new file mode 100644 index 00000000..77b77beb --- /dev/null +++ b/src/advanced/constants/product.ts @@ -0,0 +1,47 @@ +import { Product } from "../../types"; + +export const initialProducts: Product[] = [ + { + id: "p1", + name: "상품1", + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: "최고급 품질의 프리미엄 상품입니다.", + }, + { + id: "p2", + name: "상품2", + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, + }, + { + id: "p3", + name: "상품3", + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; + +export const NEW_PRODUCT_FORM_KEY = "new"; + +export const PRODUCT_FORM_INITIAL_STATE: Product = { + name: "", + price: 0, + stock: 0, + description: "", + isRecommended: false, + discounts: [], + id: NEW_PRODUCT_FORM_KEY, +}; diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts new file mode 100644 index 00000000..23647e41 --- /dev/null +++ b/src/advanced/hooks/useCart.ts @@ -0,0 +1,81 @@ +import { useCallback } from "react"; +import { CartItem, Product } from "../../types"; +import { useAtom } from "jotai"; +import { cartAtom } from "../models/cart"; + +export const useCart = () => { + const [cart, setCart] = useAtom(cartAtom); + + // 장바구니에 상품 추가 + const addToCart = useCallback( + (product: Product, quantity: number = 1) => { + setCart((prev) => { + const existingItem = prev.find( + (item) => item.product.id === product.id + ); + + if (existingItem) { + return prev.map((item) => + item.product.id === product.id + ? { ...item, quantity: item.quantity + quantity } + : item + ); + } + + return [...prev, { product, quantity }]; + }); + }, + [setCart] + ); + + // 장바구니에서 상품 수량 변경 + const updateCartItemQuantity = useCallback( + (productId: string, quantity: number) => { + setCart((prev) => { + return prev.map((item) => + item.product.id === productId ? { ...item, quantity } : item + ); + }); + }, + [setCart] + ); + + // 장바구니에서 상품 제거 + const removeFromCart = useCallback( + (productId: string) => { + setCart((prev) => prev.filter((item) => item.product.id !== productId)); + }, + [setCart] + ); + + // 장바구니 비우기 + const clearCart = useCallback(() => { + setCart([]); + }, [setCart]); + + // 장바구니에 상품이 있는지 확인 + const isInCart = useCallback( + (productId: string) => { + return cart.some((item) => item.product.id === productId); + }, + [cart] + ); + + // 장바구니 아이템 가져오기 + const getCartItem = useCallback( + (productId: string) => { + return cart.find((item) => item.product.id === productId); + }, + [cart] + ); + + return { + cart, + addToCart, + updateCartItemQuantity, + removeFromCart, + clearCart, + isInCart, + getCartItem, + }; +}; diff --git a/src/advanced/hooks/useCoupons.ts b/src/advanced/hooks/useCoupons.ts new file mode 100644 index 00000000..be0d5e3c --- /dev/null +++ b/src/advanced/hooks/useCoupons.ts @@ -0,0 +1,66 @@ +import { useCallback } from "react"; +import { Coupon } from "../../types"; +import { useAtom } from "jotai"; +import { couponsAtom } from "../models/coupon"; +import { generateCouponCode } from "../utils/couponUtils"; + +export const useCoupons = () => { + const [coupons, setCoupons] = useAtom(couponsAtom); + + // 쿠폰 추가 + const addCoupon = useCallback( + (couponData: Coupon) => { + setCoupons((prev) => [...prev, couponData]); + return couponData; + }, + [setCoupons] + ); + + // 쿠폰 삭제 + const removeCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((coupon) => coupon.code !== couponCode)); + }, + [setCoupons] + ); + + // 쿠폰 코드로 쿠폰 찾기 + const getCouponByCode = useCallback( + (code: string) => { + return coupons.find((coupon) => coupon.code === code); + }, + [coupons] + ); + + // 쿠폰 존재 여부 확인 + const isCouponExists = useCallback( + (code: string) => { + return coupons.some((coupon) => coupon.code === code); + }, + [coupons] + ); + + // 쿠폰 목록 가져오기 + const getAllCoupons = useCallback(() => { + return coupons; + }, [coupons]); + + // 쿠폰 코드 자동 생성 + const generateUniqueCouponCode = useCallback(() => { + let code: string; + do { + code = generateCouponCode(); + } while (isCouponExists(code)); + return code; + }, [isCouponExists]); + + return { + coupons, + addCoupon, + removeCoupon, + getCouponByCode, + isCouponExists, + getAllCoupons, + generateUniqueCouponCode, + }; +}; diff --git a/src/advanced/hooks/useNotifications.ts b/src/advanced/hooks/useNotifications.ts new file mode 100644 index 00000000..217e9abf --- /dev/null +++ b/src/advanced/hooks/useNotifications.ts @@ -0,0 +1,42 @@ +import { useAtom } from "jotai"; +import { useCallback } from "react"; + +import { Notification, notificationsAtom } from "../models/notification"; + +export const useNotifications = () => { + const [notifications, setNotifications] = useAtom(notificationsAtom); + + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = `${Date.now()}-${Math.random()}`; + const newNotification: Notification = { id, message, type }; + + setNotifications((prev) => [...prev, newNotification]); + + // 3초 후 자동으로 제거 + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [setNotifications] + ); + + const removeNotification = useCallback( + (id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, + [setNotifications] + ); + + const clearAllNotifications = useCallback(() => { + setNotifications([]); + }, [setNotifications]); + + return { + notifications, + addNotification, + removeNotification, + clearAllNotifications, + setNotifications, + }; +}; diff --git a/src/advanced/hooks/useProducts.ts b/src/advanced/hooks/useProducts.ts new file mode 100644 index 00000000..9844adc2 --- /dev/null +++ b/src/advanced/hooks/useProducts.ts @@ -0,0 +1,73 @@ +import { useCallback } from "react"; +import { Product } from "../../types"; +import { useAtom } from "jotai"; +import { productsAtom } from "../models/product"; +import { + filterProducts, + generateProductId, + validateProductData, +} from "../utils/productUtils"; +import useFilterSearchParams from "../../hooks/useFilterSearchParams"; + +export const useProducts = () => { + const [products, setProducts] = useAtom(productsAtom); + const { filterSearchParams } = useFilterSearchParams(); + + // 상품 추가 + const addProduct = useCallback( + (productData: Omit) => { + if (!validateProductData(productData)) { + throw new Error("Invalid product data"); + } + + const newProduct: Product = { + ...productData, + id: generateProductId(), + }; + + setProducts((prev: Product[]) => [...prev, newProduct]); + return newProduct; + }, + [setProducts] + ); + + // 상품 수정 + const updateProduct = useCallback( + (id: string, updates: Partial) => { + setProducts((prev: Product[]) => + prev.map((product: Product) => + product.id === id ? { ...product, ...updates } : product + ) + ); + }, + [setProducts] + ); + + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + }, + [setProducts] + ); + + // 상품 삭제 + const removeProduct = useCallback( + (id: string) => { + setProducts((prev: Product[]) => + prev.filter((product: Product) => product.id !== id) + ); + }, + [setProducts] + ); + + const filteredProducts = filterProducts(products, filterSearchParams); + + return { + products, + filteredProducts, + addProduct, + updateProduct, + deleteProduct, + removeProduct, + }; +}; diff --git a/src/advanced/main.tsx b/src/advanced/main.tsx index e63eef4a..a8c4ccfb 100644 --- a/src/advanced/main.tsx +++ b/src/advanced/main.tsx @@ -1,9 +1,12 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import { Provider } from "jotai"; -ReactDOM.createRoot(document.getElementById('root')!).render( +ReactDOM.createRoot(document.getElementById("root")!).render( - - , -) + + + + +); diff --git a/src/advanced/models/cart.ts b/src/advanced/models/cart.ts new file mode 100644 index 00000000..f39b8408 --- /dev/null +++ b/src/advanced/models/cart.ts @@ -0,0 +1,4 @@ +import { atomWithStorage } from "jotai/utils"; +import { CartItem } from "../../types"; + +export const cartAtom = atomWithStorage("cart", []); diff --git a/src/advanced/models/coupon.ts b/src/advanced/models/coupon.ts new file mode 100644 index 00000000..6cd25bae --- /dev/null +++ b/src/advanced/models/coupon.ts @@ -0,0 +1,5 @@ +import { atomWithStorage } from "jotai/utils"; +import { Coupon } from "../../types"; +import { initialCoupons } from "../../basic/constants/coupon"; + +export const couponsAtom = atomWithStorage("coupons", initialCoupons); diff --git a/src/advanced/models/notification.ts b/src/advanced/models/notification.ts new file mode 100644 index 00000000..9d2e8927 --- /dev/null +++ b/src/advanced/models/notification.ts @@ -0,0 +1,17 @@ +import { atom } from "jotai"; +export interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} + +export interface NotificationState { + notifications: Notification[]; +} + +export interface AddNotificationParams { + message: string; + type: "error" | "success" | "warning"; +} + +export const notificationsAtom = atom([]); diff --git a/src/advanced/models/product.ts b/src/advanced/models/product.ts new file mode 100644 index 00000000..12d011e1 --- /dev/null +++ b/src/advanced/models/product.ts @@ -0,0 +1,8 @@ +import { atomWithStorage } from "jotai/utils"; +import { Product } from "../../types"; +import { initialProducts } from "../constants/product"; + +export const productsAtom = atomWithStorage( + "products", + initialProducts +); diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx new file mode 100644 index 00000000..aa4893d7 --- /dev/null +++ b/src/advanced/pages/AdminPage.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { ProductAccordion } from "../components/product/ProductAccordion"; +import { CouponManagement } from "../components/coupon/CouponManagement"; + +export function AdminPage() { + // 내부 상태 관리 + const [activeTab, setActiveTab] = useState<"products" | "coupons">( + "products" + ); + + return ( +
+
+

관리자 대시보드

+

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

+
+
+ +
+ + {activeTab === "products" ? : } +
+ ); +} diff --git a/src/advanced/pages/ShopPage.tsx b/src/advanced/pages/ShopPage.tsx new file mode 100644 index 00000000..87fbba6d --- /dev/null +++ b/src/advanced/pages/ShopPage.tsx @@ -0,0 +1,69 @@ +import { useCallback, useState } from "react"; +import { Coupon, Product } from "../../types"; + +import { ProductList } from "../components/product/ProductList"; +import { calculateCartTotal, getRemainingStock } from "../utils/cartUtils"; +import { CartSection } from "../components/cart/CartSection"; +import { CouponSection } from "../components/coupon/CouponSection"; +import { PaymentSection } from "../components/payment/PaymentSection"; +import { useCart } from "../hooks/useCart"; +import { useNotifications } from "../hooks/useNotifications"; + +export function ShopPage() { + const [selectedCoupon, setSelectedCoupon] = useState(null); + + const { cart } = useCart(); + const { addNotification } = useNotifications(); + + // 쿠폰 적용 + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal( + cart, + selectedCoupon + ).totalBeforeDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + addNotification( + "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", + "error" + ); + return; + } + + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + [addNotification, cart, selectedCoupon, setSelectedCoupon] + ); + + return ( +
+
+ {/* 상품 목록 */} + +
+ +
+
+ + + {cart.length > 0 && ( + <> + + + + + )} +
+
+
+ ); +} diff --git a/src/advanced/utils/cartUtils.ts b/src/advanced/utils/cartUtils.ts new file mode 100644 index 00000000..5a3b1d63 --- /dev/null +++ b/src/advanced/utils/cartUtils.ts @@ -0,0 +1,93 @@ +import { CartItem, Coupon, Product } from "../../types"; +import { + BULK_PURCHASE_THRESHOLD, + BULK_PURCHASE_ADDITIONAL_DISCOUNT, + MAX_DISCOUNT_RATE, +} from "../constants/cart"; + +export interface CartTotal { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} + +export const getMaxApplicableDiscount = ( + item: CartItem, + cart: 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 >= BULK_PURCHASE_THRESHOLD + ); + if (hasBulkPurchase) { + return Math.min( + baseDiscount + BULK_PURCHASE_ADDITIONAL_DISCOUNT, + MAX_DISCOUNT_RATE + ); + } + + return baseDiscount; +}; + +export const calculateItemTotal = ( + item: CartItem, + cart: CartItem[] +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): CartTotal => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === "amount") { + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - selectedCoupon.discountValue + ); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; + +export const getRemainingStock = ( + product: Product, + cart: CartItem[] +): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const usedQuantity = cartItem ? cartItem.quantity : 0; + return Math.max(0, product.stock - usedQuantity); +}; + +export const getCartItemCount = (cart: CartItem[]): number => { + return cart.reduce((sum, item) => sum + item.quantity, 0); +}; diff --git a/src/advanced/utils/couponUtils.ts b/src/advanced/utils/couponUtils.ts new file mode 100644 index 00000000..961e8a93 --- /dev/null +++ b/src/advanced/utils/couponUtils.ts @@ -0,0 +1,69 @@ +import { Coupon } from "../../types"; +import { COUPON_VALIDATION_RULES } from "../constants/coupon"; + +// 쿠폰 할인 적용 +export const applyCouponDiscount = (total: number, coupon: Coupon): number => { + if (!coupon) return total; + + if (coupon.discountType === "amount") { + return Math.max(0, total - coupon.discountValue); + } else if (coupon.discountType === "percentage") { + const discountAmount = (total * coupon.discountValue) / 100; + return Math.max(0, total - discountAmount); + } + + return total; +}; + +// 쿠폰 코드 생성 +export const generateCouponCode = (): string => { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + return Array.from({ length: 8 }, () => + chars.charAt(Math.floor(Math.random() * chars.length)) + ).join(""); +}; + +// 쿠폰 데이터 검증 +export const validateCouponData = (coupon: Partial): boolean => { + if ( + !coupon.name || + !coupon.code || + !coupon.discountType || + !coupon.discountValue + ) { + return false; + } + + if (coupon.discountType === "amount") { + return ( + coupon.discountValue >= COUPON_VALIDATION_RULES.MIN_AMOUNT_DISCOUNT && + coupon.discountValue <= COUPON_VALIDATION_RULES.MAX_AMOUNT_DISCOUNT + ); + } + + if (coupon.discountType === "percentage") { + return ( + coupon.discountValue >= COUPON_VALIDATION_RULES.MIN_PERCENTAGE_DISCOUNT && + coupon.discountValue <= COUPON_VALIDATION_RULES.MAX_PERCENTAGE_DISCOUNT + ); + } + + return false; +}; + +// 쿠폰 중복 검사 +export const isCouponDuplicate = (coupons: Coupon[], code: string): boolean => { + return coupons.some((coupon) => coupon.code === code); +}; + +// 쿠폰 표시 텍스트 생성 +export const getCouponDisplayText = (coupon: Coupon): string => { + switch (coupon.discountType) { + case "amount": + return `${coupon.discountValue.toLocaleString()}원 할인`; + case "percentage": + return `${coupon.discountValue}% 할인`; + default: + return ""; + } +}; diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts new file mode 100644 index 00000000..b33d888b --- /dev/null +++ b/src/advanced/utils/formatters.ts @@ -0,0 +1,26 @@ +import { Product } from "../../types"; + +export const displayPrice = ( + product: Product, + { suffix = "", prefix = "" }: { suffix?: string; prefix?: string } = {} +): string => { + if (product.stock <= 0) { + return "SOLD OUT"; + } + + return `${prefix}${product.price.toLocaleString()}${suffix}`; +}; + +/** + * 날짜를 YYYY-MM-DD 형식으로 포맷 + */ +export const formatDate = (date: Date): string => { + return date.toISOString().split("T")[0]; +}; + +/** + * 소수를 퍼센트로 변환 (0.1 → 10%) + */ +export const formatPercentage = (rate: number): string => { + return `${(rate * 100).toFixed(0)}%`; +}; diff --git a/src/advanced/utils/hooks/useDebounce.ts b/src/advanced/utils/hooks/useDebounce.ts new file mode 100644 index 00000000..67b48cff --- /dev/null +++ b/src/advanced/utils/hooks/useDebounce.ts @@ -0,0 +1,37 @@ +import { useEffect, useCallback, useRef } from "react"; + +/** + * 디바운스된 콜백 Hook + * 함수 호출을 지정된 시간만큼 지연시킵니다 + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useDebouncedCallback any>( + callback: T, + delay: number +): T { + const timeoutRef = useRef(null); + + const debouncedCallback = useCallback( + (...args: Parameters) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + callback(...args); + }, delay); + }, + [callback, delay] + ); + + // 컴포넌트 언마운트 시 타이머 정리 + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return debouncedCallback as T; +} diff --git a/src/advanced/utils/productUtils.ts b/src/advanced/utils/productUtils.ts new file mode 100644 index 00000000..b55c19a3 --- /dev/null +++ b/src/advanced/utils/productUtils.ts @@ -0,0 +1,63 @@ +import { filterSearchParamsSchemaType } from "../../hooks/useFilterSearchParams"; +import { CartItem, Product } from "../../types"; + +/** + * 상품 검색 필터링 + */ +export const filterProducts = ( + products: Product[], + filterQuery: filterSearchParamsSchemaType +): Product[] => { + return filterProductsBySearchTerm(products, filterQuery.searchTerm ?? ""); +}; + +const filterProductsBySearchTerm = ( + products: Product[], + searchTerm: string +) => { + if (!searchTerm.trim()) { + return products; + } + return products.filter( + (product) => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(searchTerm.toLowerCase())) + ); +}; + +/** + * 상품 ID 생성 + */ +export const generateProductId = (): string => { + return "p" + Date.now(); +}; + +/** + * 상품 데이터 유효성 검증 + */ +export const validateProductData = (product: Partial): boolean => { + return !!( + product.name && + product.name.trim() && + product.price && + product.price > 0 && + product.stock !== undefined && + product.stock >= 0 + ); +}; + +/** + * 남은 재고 계산 + */ +const getRemainingStock = (product: Product, cart: CartItem[]): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); +}; + +export const getRefinedProduct = (product: Product, cart: CartItem[]) => { + return { + ...product, + stock: getRemainingStock(product, cart), + } satisfies Product; +}; diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1..cf566076 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,1124 +1,81 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { useState } from "react"; +import { Coupon } from "../types"; -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} +import { AdminPage } from "./pages/AdminPage"; +import { ShopPage } from "./pages/ShopPage"; + +import { Header } from "./components/ui/Header"; +import { NotificationList } from "./components/ui/NotificationList"; +import { useCart } from "./hooks/useCart"; +import { useCoupons } from "./hooks/useCoupons"; +import { useNotifications } from "./hooks/useNotifications"; +import { useProducts } from "./hooks/useProducts"; -interface Notification { +export interface Notification { id: string; message: string; - type: 'error' | 'success' | 'warning'; + type: "error" | "success" | "warning"; } -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; - const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); + const { notifications, addNotification, setNotifications } = + useNotifications(); - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); + const { + products, + filteredProducts, + addProduct, + updateProduct, + deleteProduct, + } = useProducts(); - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } + const { coupons, addCoupon, removeCoupon } = useCoupons(); - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); + const [selectedCoupon, setSelectedCoupon] = useState(null); - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + const { cart, addToCart, updateCartItemQuantity, removeFromCart, clearCart } = + useCart(); 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 - /> -
-
-
- - -
-
-
- )} -
-
- )} -
+ ) : ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

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

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

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

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

- - - - 장바구니 -

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

장바구니가 비어있습니다

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

{item.product.name}

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

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

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

쿠폰 할인

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

결제 정보

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

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

-
-
- - )} -
-
-
+ )}
); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/basic/components/cart/CartSection.tsx b/src/basic/components/cart/CartSection.tsx new file mode 100644 index 00000000..768e3138 --- /dev/null +++ b/src/basic/components/cart/CartSection.tsx @@ -0,0 +1,91 @@ +import { CartItem, Product } from "../../../types"; +import { CartIcon, TrashIcon } from "../icons"; +import { calculateItemTotal } from "../../utils/cartUtils"; + +export function CartSection({ + cart, + removeFromCart, + handleUpdateQuantity, +}: { + cart: CartItem[]; + removeFromCart: (productId: string) => void; + handleUpdateQuantity: (product: Product, newQuantity: number) => void; +}) { + return ( +
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => { + const itemTotal = calculateItemTotal(item, cart); + 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()}원 +

+
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/basic/components/coupon/CouponForm.tsx b/src/basic/components/coupon/CouponForm.tsx new file mode 100644 index 00000000..d24b5807 --- /dev/null +++ b/src/basic/components/coupon/CouponForm.tsx @@ -0,0 +1,171 @@ +import { useState } from "react"; +import { Coupon } from "../../../types"; +import { + COUPON_FORM_INITIAL_STATE, + COUPON_LIMITS, +} from "../../constants/coupon"; + +interface CouponFormProps { + onSubmit: (coupon: Coupon) => void; + onCancel: () => void; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const CouponForm: React.FC = ({ + onSubmit, + onCancel, + addNotification, +}) => { + const [formData, setFormData] = useState(COUPON_FORM_INITIAL_STATE); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(formData as Coupon); + setFormData(COUPON_FORM_INITIAL_STATE); + }; + + const handleInputChange = ( + field: keyof typeof formData, + value: string | number + ) => { + setFormData((prev) => ({ + ...prev, + [field]: value, + })); + }; + + const getDiscountValuePlaceholder = () => { + return formData.discountType === "amount" ? "5000" : "10"; + }; + + const getDiscountValueMax = () => { + return COUPON_LIMITS[formData.discountType].max; + }; + + const getDiscountValueMin = () => { + return COUPON_LIMITS[formData.discountType].min; + }; + + return ( +
+

새 쿠폰 생성

+ +
+
+ + handleInputChange("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 + /> +
+ +
+ + + handleInputChange("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)) { + handleInputChange( + "discountValue", + value === "" ? 0 : parseInt(value) + ); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + const max = getDiscountValueMax(); + const min = getDiscountValueMin(); + + if (value > max) { + if (formData.discountType === "percentage") { + addNotification( + `할인율은 ${max}%를 초과할 수 없습니다`, + "error" + ); + } else { + addNotification( + `할인 금액은 ${max}원을 초과할 수 없습니다`, + "error" + ); + } + + handleInputChange("discountValue", max); + } else if (value < min) { + handleInputChange("discountValue", min); + } + }} + 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={getDiscountValuePlaceholder()} + min={getDiscountValueMin()} + max={getDiscountValueMax()} + required + /> +
+
+ +
+ + +
+
+ ); +}; diff --git a/src/basic/components/coupon/CouponList.tsx b/src/basic/components/coupon/CouponList.tsx new file mode 100644 index 00000000..72601752 --- /dev/null +++ b/src/basic/components/coupon/CouponList.tsx @@ -0,0 +1,50 @@ +import { Coupon } from "../../../types"; +import { getCouponDisplayText } from "../../utils/couponUtils"; + +interface CouponListProps { + coupons: Coupon[]; + onApplyCoupon: (coupon: Coupon) => void; + selectedCoupon: Coupon | null; +} + +export const CouponList: React.FC = ({ + coupons, + onApplyCoupon, + selectedCoupon, +}) => { + if (coupons.length === 0) { + return ( +
+

사용 가능한 쿠폰이 없습니다

+
+ ); + } + + return ( +
+ {coupons.map((coupon) => ( +
onApplyCoupon(coupon)} + > +
+
+

{coupon.name}

+

{coupon.code}

+
+
+ + {getCouponDisplayText(coupon)} + +
+
+
+ ))} +
+ ); +}; diff --git a/src/basic/components/coupon/CouponManagement.tsx b/src/basic/components/coupon/CouponManagement.tsx new file mode 100644 index 00000000..3162a7a6 --- /dev/null +++ b/src/basic/components/coupon/CouponManagement.tsx @@ -0,0 +1,114 @@ +import { useState } from "react"; +import { Coupon } from "../../../types"; +import { CouponForm } from "./CouponForm"; +import { + getCouponDisplayText, + validateCouponData, + isCouponDuplicate, +} from "../../utils/couponUtils"; +import { TrashIcon, PlusIcon } from "../icons"; + +interface CouponManagementProps { + coupons: Coupon[]; + onAddCoupon: (coupon: Coupon) => void; + onRemoveCoupon: (couponCode: string) => void; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const CouponManagement: React.FC = ({ + coupons, + onAddCoupon, + onRemoveCoupon, + addNotification, +}) => { + const [showCouponForm, setShowCouponForm] = useState(false); + + const handleAddCoupon = (coupon: Coupon) => { + // 검증 로직을 먼저 수행 + if (!validateCouponData(coupon)) { + return; + } + + // 중복 검사 + if (isCouponDuplicate(coupons, coupon.code)) { + addNotification("이미 존재하는 쿠폰 코드입니다.", "error"); + return; + } + + // 검증 통과 후 쿠폰 추가 + onAddCoupon(coupon); + addNotification("쿠폰이 추가되었습니다.", "success"); + setShowCouponForm(false); + }; + + const handleRemoveCoupon = (couponCode: string) => { + onRemoveCoupon(couponCode); + addNotification("쿠폰이 삭제되었습니다.", "success"); + }; + + const handleCancelForm = () => { + setShowCouponForm(false); + }; + + return ( +
+
+

쿠폰 관리

+
+ +
+
+ {coupons.map((coupon) => ( +
+
+
+

{coupon.name}

+

+ {coupon.code} +

+
+ + {getCouponDisplayText(coupon)} + +
+
+ +
+
+ ))} + +
+ +
+
+ + {showCouponForm && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/src/basic/components/coupon/CouponSection.tsx b/src/basic/components/coupon/CouponSection.tsx new file mode 100644 index 00000000..868baaa2 --- /dev/null +++ b/src/basic/components/coupon/CouponSection.tsx @@ -0,0 +1,46 @@ +import { Coupon } from "../../../types"; + +export function CouponSection({ + coupons, + selectedCoupon, + setSelectedCoupon, + applyCoupon, +}: { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + applyCoupon: (coupon: Coupon) => void; +}) { + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +} diff --git a/src/basic/components/icons/index.tsx b/src/basic/components/icons/index.tsx new file mode 100644 index 00000000..d548b556 --- /dev/null +++ b/src/basic/components/icons/index.tsx @@ -0,0 +1,206 @@ +import React from "react"; + +interface IconProps { + className?: string; + size?: number; +} + +export const CartIcon: React.FC = ({ + className = "w-6 h-6", + size, +}) => ( + + + +); + +export const TrashIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); + +export const PlusIcon: React.FC = ({ + className = "w-8 h-8", + size, +}) => ( + + + +); + +export const MinusIcon: React.FC = ({ + className = "w-4 h-4", + size, +}) => ( + + + +); + +export const ImageIcon: React.FC = ({ + className = "w-24 h-24", + size, +}) => ( + + + +); + +export const CloseIcon: React.FC = ({ + className = "w-4 h-4", + size, +}) => ( + + + +); + +export const ChevronDownIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); + +export const ChevronUpIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); + +export const CheckIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); + +export const AdminIcon: React.FC = ({ + className = "w-5 h-5", + size, +}) => ( + + + +); diff --git a/src/basic/components/payment/PaymentSection.tsx b/src/basic/components/payment/PaymentSection.tsx new file mode 100644 index 00000000..91587377 --- /dev/null +++ b/src/basic/components/payment/PaymentSection.tsx @@ -0,0 +1,52 @@ +import { CartTotal } from "../../utils/cartUtils"; + +export function PaymentSection({ + totals, + completeOrder, +}: { + totals: CartTotal; + completeOrder: () => void; +}) { + return ( +
+

결제 정보

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

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

+
+
+ ); +} diff --git a/src/basic/components/product/ProductAccordion.tsx b/src/basic/components/product/ProductAccordion.tsx new file mode 100644 index 00000000..92259716 --- /dev/null +++ b/src/basic/components/product/ProductAccordion.tsx @@ -0,0 +1,169 @@ +import React, { useState, useCallback } from "react"; +import { Product } from "../../../types"; +import { displayPrice } from "../../utils/formatters"; +import { ProductForm } from "./ProductForm"; + +interface ProductAccordionProps { + products: Product[]; + onEditProduct: (id: string, updates: Partial) => void; + onDeleteProduct: (productId: string) => void; + onAddNewProduct: (productData: Omit) => Product; + onNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const ProductAccordion: React.FC = ({ + products, + onEditProduct, + onDeleteProduct, + onAddNewProduct, + onNotification, +}) => { + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [selectedProduct, setSelectedProduct] = useState( + undefined + ); + + // 상품 편집 시작 + const handleStartEditProduct = useCallback((product: Product) => { + setSelectedProduct(product); + setEditingProduct(product.id); + setShowProductForm(true); + }, []); + + // 새 상품 추가 시작 + const handleAddNewProduct = useCallback(() => { + setSelectedProduct(undefined); + setEditingProduct("new"); + setShowProductForm(true); + }, []); + + // 상품 폼 취소 + const handleCancelProductForm = useCallback(() => { + setEditingProduct(null); + setSelectedProduct(undefined); + setShowProductForm(false); + }, []); + + // 상품 폼 제출 처리 + const handleProductSubmit = useCallback( + (productData: Omit) => { + if (editingProduct && editingProduct !== "new") { + onEditProduct(editingProduct, productData); + } else { + onAddNewProduct(productData); + } + setEditingProduct(null); + setSelectedProduct(undefined); + setShowProductForm(false); + }, + [editingProduct, onEditProduct, onAddNewProduct] + ); + + // 상품 삭제 + const handleDeleteProduct = useCallback( + (productId: string) => { + onDeleteProduct(productId); + onNotification("상품이 삭제되었습니다.", "success"); + }, + [onDeleteProduct, onNotification] + ); + + return ( + <> +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {displayPrice(product, { + suffix: "원", + })} + + 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 && ( + + )} + + ); +}; diff --git a/src/basic/components/product/ProductCard.tsx b/src/basic/components/product/ProductCard.tsx new file mode 100644 index 00000000..90643014 --- /dev/null +++ b/src/basic/components/product/ProductCard.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { Product } from "../../../types"; +import { ImageIcon } from "../icons"; + +interface ProductCardProps { + product: Product; + remainingStock: number; + onAddToCart: (product: Product) => void; + formatPrice: (price: number, productId?: string) => string; +} + +export const ProductCard: React.FC = ({ + product, + remainingStock, + onAddToCart, + formatPrice, +}) => { + 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}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; diff --git a/src/basic/components/product/ProductForm.tsx b/src/basic/components/product/ProductForm.tsx new file mode 100644 index 00000000..a5ceee14 --- /dev/null +++ b/src/basic/components/product/ProductForm.tsx @@ -0,0 +1,265 @@ +import React, { useEffect, useState } from "react"; +import { Product } from "../../../types"; +import { CloseIcon } from "../icons"; + +interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Array<{ quantity: number; rate: number }>; +} + +interface ProductFormProps { + editingProduct: string | null; + product?: Product; + onSubmit: (productData: Omit) => void; + onCancel: () => void; + onNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const ProductForm: React.FC = ({ + editingProduct, + product, + onSubmit, + onCancel, + onNotification, +}) => { + const [productForm, setProductForm] = useState({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + + // 상품 데이터가 변경될 때 폼 초기화 + useEffect(() => { + if (product && editingProduct === product.id) { + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [], + }); + } else if (editingProduct === "new") { + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + } + }, [editingProduct, product]); + + const updateForm = (updates: Partial) => { + setProductForm({ ...productForm, ...updates }); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ + ...productForm, + discounts: productForm.discounts, + }); + onNotification( + editingProduct === "new" + ? "상품이 추가되었습니다." + : "상품이 수정되었습니다.", + "success" + ); + }; + + const handleCancel = () => { + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); + onCancel(); + }; + + return ( +
+
+

+ {editingProduct === "new" ? "새 상품 추가" : "상품 수정"} +

+
+
+ + updateForm({ 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 + /> +
+
+ + updateForm({ 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)) { + updateForm({ price: value === "" ? 0 : parseInt(value) }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + updateForm({ price: 0 }); + } else if (parseInt(value) < 0) { + onNotification("가격은 0보다 커야 합니다", "error"); + updateForm({ 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)) { + updateForm({ stock: value === "" ? 0 : parseInt(value) }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === "") { + updateForm({ stock: 0 }); + } else if (parseInt(value) < 0) { + onNotification("재고는 0보다 커야 합니다", "error"); + updateForm({ stock: 0 }); + } else if (parseInt(value) > 9999) { + onNotification("재고는 9999개를 초과할 수 없습니다", "error"); + updateForm({ 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; + updateForm({ 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; + updateForm({ discounts: newDiscounts }); + }} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/basic/components/product/ProductList.tsx b/src/basic/components/product/ProductList.tsx new file mode 100644 index 00000000..0a32bc14 --- /dev/null +++ b/src/basic/components/product/ProductList.tsx @@ -0,0 +1,144 @@ +import React, { useCallback } from "react"; + +import { CartItem, Product } from "../../../types"; +import { getRefinedProduct } from "../../utils/productUtils"; +import { displayPrice } from "../../utils/formatters"; +import { getRemainingStock } from "../../utils/cartUtils"; +import useFilterSearchParams from "../../../hooks/useFilterSearchParams"; +import { ImageIcon } from "../icons"; + +interface ProductListProps { + products: Product[]; + filteredProducts: Product[]; + cart: CartItem[]; + addToCart: (product: Product, quantity?: number) => void; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; +} + +export const ProductList: React.FC = ({ + products, + filteredProducts, + cart, + addToCart, + addNotification, +}) => { + const { filterSearchParams } = useFilterSearchParams(); + + const handleAddToCart = useCallback( + (product: Product) => { + if (!getRemainingStock(product, cart)) { + addNotification("재고가 부족합니다", "error"); + return; + } + + addToCart(product, 1); + + addNotification("장바구니에 담았습니다", "success"); + }, + [cart, addToCart, addNotification] + ); + + return ( +
+
+

전체 상품

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

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

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

+ {product.name} +

+ {product.description && ( +

+ {product.description} +

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

+ {displayPrice(refinedProduct, { + prefix: "₩", + })} +

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

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

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

+ 품절임박! {refinedProduct.stock}개 남음 +

+ )} + {refinedProduct.stock > 5 && ( +

+ 재고 {refinedProduct.stock}개 +

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); + })} +
+ )} +
+ ); +}; diff --git a/src/basic/components/ui/Header.tsx b/src/basic/components/ui/Header.tsx new file mode 100644 index 00000000..2829605a --- /dev/null +++ b/src/basic/components/ui/Header.tsx @@ -0,0 +1,78 @@ +import { CartItem } from "../../../types"; +import useFilterSearchParams from "../../../hooks/useFilterSearchParams"; +import { useDebouncedCallback } from "../../utils/hooks/useDebounce"; +import { getCartItemCount } from "../../utils/cartUtils"; +import { CartIcon } from "../icons"; + +export const Header = ({ + isAdmin, + setIsAdmin, + cart, +}: { + isAdmin: boolean; + setIsAdmin: (isAdmin: boolean) => void; + cart: CartItem[]; +}) => { + const { filterSearchParams, setFilterSearchParams } = useFilterSearchParams(); + + const handleSearchChange = (e: React.ChangeEvent) => { + const value = e.target.value; + + if (!value) { + setFilterSearchParams({ searchTerm: "" }); + return; + } + + debouncedSetSearchTerm(value); + }; + + const debouncedSetSearchTerm = useDebouncedCallback((value: string) => { + setFilterSearchParams({ searchTerm: value }); + }, 500); + + return ( +
+
+
+
+

SHOP

+ {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} + {!isAdmin && ( +
+ +
+ )} +
+ +
+
+
+ ); +}; diff --git a/src/basic/components/ui/NotificationList.tsx b/src/basic/components/ui/NotificationList.tsx new file mode 100644 index 00000000..3942ca5c --- /dev/null +++ b/src/basic/components/ui/NotificationList.tsx @@ -0,0 +1,37 @@ +import { Notification } from "../../App"; +import { CloseIcon } from "../icons"; + +export const NotificationList = ({ + notifications, + setNotifications, +}: { + notifications: Notification[]; + setNotifications: React.Dispatch>; +}) => { + return ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ); +}; diff --git a/src/basic/constants/cart.ts b/src/basic/constants/cart.ts new file mode 100644 index 00000000..3282d3a0 --- /dev/null +++ b/src/basic/constants/cart.ts @@ -0,0 +1,4 @@ +export const BULK_PURCHASE_THRESHOLD = 10; +export const BULK_PURCHASE_ADDITIONAL_DISCOUNT = 0.05; +export const MAX_DISCOUNT_RATE = 0.5; +export const MIN_PERCENTAGE_COUPON_AMOUNT = 10000; diff --git a/src/basic/constants/coupon.ts b/src/basic/constants/coupon.ts new file mode 100644 index 00000000..2a4ca77c --- /dev/null +++ b/src/basic/constants/coupon.ts @@ -0,0 +1,46 @@ +import { Coupon } from "../../types"; + +// 초기 쿠폰 데이터 +export const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; + +// 쿠폰 폼 초기 상태 +export const COUPON_FORM_INITIAL_STATE = { + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0, +}; + +// 쿠폰 검증 규칙 +export const COUPON_VALIDATION_RULES = { + MIN_AMOUNT_DISCOUNT: 1000, + MAX_AMOUNT_DISCOUNT: 100000, + MIN_PERCENTAGE_DISCOUNT: 1, + MAX_PERCENTAGE_DISCOUNT: 100, + MIN_CART_TOTAL_FOR_PERCENTAGE: 10000, +}; + +// 쿠폰 타입별 최대값 +export const COUPON_LIMITS = { + amount: { + min: 1000, + max: 100000, + }, + percentage: { + min: 1, + max: 100, + }, +}; diff --git a/src/basic/constants/index.ts b/src/basic/constants/index.ts new file mode 100644 index 00000000..bef3834f --- /dev/null +++ b/src/basic/constants/index.ts @@ -0,0 +1,8 @@ +// TODO: 초기 데이터 상수 +// 정의할 상수들: +// - initialProducts: 초기 상품 목록 (상품1, 상품2, 상품3 + 설명 필드 포함) +// - initialCoupons: 초기 쿠폰 목록 (5000원 할인, 10% 할인) +// +// 참고: origin/App.tsx의 초기 데이터 구조를 참조 + +// TODO: 구현 \ No newline at end of file diff --git a/src/basic/constants/product.ts b/src/basic/constants/product.ts new file mode 100644 index 00000000..c9700aa3 --- /dev/null +++ b/src/basic/constants/product.ts @@ -0,0 +1,43 @@ +import { Product } from "../../types"; + +export const initialProducts: Product[] = [ + { + id: "p1", + name: "상품1", + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: "최고급 품질의 프리미엄 상품입니다.", + }, + { + id: "p2", + name: "상품2", + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, + }, + { + id: "p3", + name: "상품3", + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; + +export const PRODUCT_FORM_INITIAL_STATE = { + name: "", + price: 0, + stock: 0, + description: "", + isRecommended: false, +}; diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 00000000..d0dcf9e4 --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,80 @@ +import { useCallback } from "react"; +import { CartItem, Product } from "../../types"; +import { useLocalStorage } from "./useLocalStorage"; + +export const useCart = () => { + const [cart, setCart] = useLocalStorage("cart", []); + + // 장바구니에 상품 추가 + const addToCart = useCallback( + (product: Product, quantity: number = 1) => { + setCart((prev) => { + const existingItem = prev.find( + (item) => item.product.id === product.id + ); + + if (existingItem) { + return prev.map((item) => + item.product.id === product.id + ? { ...item, quantity: item.quantity + quantity } + : item + ); + } + + return [...prev, { product, quantity }]; + }); + }, + [setCart] + ); + + // 장바구니에서 상품 수량 변경 + const updateCartItemQuantity = useCallback( + (productId: string, quantity: number) => { + setCart((prev) => { + return prev.map((item) => + item.product.id === productId ? { ...item, quantity } : item + ); + }); + }, + [setCart] + ); + + // 장바구니에서 상품 제거 + const removeFromCart = useCallback( + (productId: string) => { + setCart((prev) => prev.filter((item) => item.product.id !== productId)); + }, + [setCart] + ); + + // 장바구니 비우기 + const clearCart = useCallback(() => { + setCart([]); + }, [setCart]); + + // 장바구니에 상품이 있는지 확인 + const isInCart = useCallback( + (productId: string) => { + return cart.some((item) => item.product.id === productId); + }, + [cart] + ); + + // 장바구니 아이템 가져오기 + const getCartItem = useCallback( + (productId: string) => { + return cart.find((item) => item.product.id === productId); + }, + [cart] + ); + + return { + cart, + addToCart, + updateCartItemQuantity, + removeFromCart, + clearCart, + isInCart, + getCartItem, + }; +}; diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts new file mode 100644 index 00000000..ca0da9a5 --- /dev/null +++ b/src/basic/hooks/useCoupons.ts @@ -0,0 +1,70 @@ +import { useCallback } from "react"; +import { Coupon } from "../../types"; +import { initialCoupons } from "../constants/coupon"; +import { generateCouponCode } from "../utils/couponUtils"; +import { useLocalStorage } from "./useLocalStorage"; + +export const useCoupons = () => { + const [coupons, setCoupons] = useLocalStorage( + "coupons", + initialCoupons + ); + + // 쿠폰 추가 + const addCoupon = useCallback( + (couponData: Coupon) => { + setCoupons((prev) => [...prev, couponData]); + return couponData; + }, + [setCoupons] + ); + + // 쿠폰 삭제 + const removeCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((coupon) => coupon.code !== couponCode)); + }, + [setCoupons] + ); + + // 쿠폰 코드로 쿠폰 찾기 + const getCouponByCode = useCallback( + (code: string) => { + return coupons.find((coupon) => coupon.code === code); + }, + [coupons] + ); + + // 쿠폰 존재 여부 확인 + const isCouponExists = useCallback( + (code: string) => { + return coupons.some((coupon) => coupon.code === code); + }, + [coupons] + ); + + // 쿠폰 목록 가져오기 + const getAllCoupons = useCallback(() => { + return coupons; + }, [coupons]); + + // 쿠폰 코드 자동 생성 + const generateUniqueCouponCode = useCallback(() => { + let code: string; + do { + code = generateCouponCode(); + } while (isCouponExists(code)); + return code; + }, [isCouponExists]); + + return { + coupons, + addCoupon, + removeCoupon, + + getCouponByCode, + isCouponExists, + getAllCoupons, + generateUniqueCouponCode, + }; +}; diff --git a/src/basic/hooks/useLocalStorage.ts b/src/basic/hooks/useLocalStorage.ts new file mode 100644 index 00000000..73109f28 --- /dev/null +++ b/src/basic/hooks/useLocalStorage.ts @@ -0,0 +1,34 @@ +import { useCallback, useState } from "react"; + +export const useLocalStorage = (key: string, initialValue: T) => { + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(`Error reading localStorage key "${key}":`, error); + return initialValue; + } + }); + + const setValue: React.Dispatch> = useCallback( + (value) => { + try { + const valueToStore = + value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + + if (valueToStore !== null && valueToStore !== undefined) { + localStorage.setItem(key, JSON.stringify(valueToStore)); + } else { + localStorage.removeItem(key); + } + } catch (error) { + console.error(`Error setting localStorage key "${key}":`, error); + } + }, + [key, storedValue] + ); + + return [storedValue, setValue] as const; +}; diff --git a/src/basic/hooks/useNotifications.ts b/src/basic/hooks/useNotifications.ts new file mode 100644 index 00000000..ed1dc701 --- /dev/null +++ b/src/basic/hooks/useNotifications.ts @@ -0,0 +1,41 @@ +import { useCallback, useState } from "react"; + +import { Notification } from "../models/notification"; + +export const useNotifications = () => { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = `${Date.now()}-${Math.random()}`; + const newNotification: Notification = { id, message, type }; + + setNotifications((prev) => [...prev, newNotification]); + + // 3초 후 자동으로 제거 + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [setNotifications] + ); + + const removeNotification = useCallback( + (id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, + [setNotifications] + ); + + const clearAllNotifications = useCallback(() => { + setNotifications([]); + }, [setNotifications]); + + return { + notifications, + addNotification, + removeNotification, + clearAllNotifications, + setNotifications, + }; +}; diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts new file mode 100644 index 00000000..9211d253 --- /dev/null +++ b/src/basic/hooks/useProducts.ts @@ -0,0 +1,73 @@ +import { useCallback } from "react"; + +import useFilterSearchParams from "../../hooks/useFilterSearchParams"; +import { Product } from "../../types"; +import { initialProducts } from "../constants/product"; +import { generateProductId, validateProductData } from "../utils/productUtils"; +import { useLocalStorage } from "./useLocalStorage"; + +export const useProducts = () => { + const [products, setProducts] = useLocalStorage( + "products", + initialProducts + ); + + const { filterSearchParams } = useFilterSearchParams(); + + // 상품 추가 + const addProduct = useCallback((productData: Omit) => { + if (!validateProductData(productData)) { + throw new Error("Invalid product data"); + } + + const newProduct: Product = { + ...productData, + id: generateProductId(), + }; + + setProducts((prev: Product[]) => [...prev, newProduct]); + return newProduct; + }, []); + + // 상품 수정 + const updateProduct = useCallback((id: string, updates: Partial) => { + setProducts((prev: Product[]) => + prev.map((product: Product) => + product.id === id ? { ...product, ...updates } : product + ) + ); + }, []); + + const deleteProduct = useCallback((productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + }, []); + + // 상품 삭제 + const removeProduct = useCallback((id: string) => { + setProducts((prev: Product[]) => + prev.filter((product: Product) => product.id !== id) + ); + }, []); + + const filteredProducts = filterSearchParams.searchTerm + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(filterSearchParams.searchTerm?.toLowerCase() ?? "") || + (product.description && + product.description + .toLowerCase() + .includes(filterSearchParams.searchTerm?.toLowerCase() ?? "")) + ) + : products; + + return { + products, + filteredProducts, + addProduct, + updateProduct, + deleteProduct, + removeProduct, + }; +}; diff --git a/src/basic/main.tsx b/src/basic/main.tsx index e63eef4a..f46c379c 100644 --- a/src/basic/main.tsx +++ b/src/basic/main.tsx @@ -1,9 +1,9 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; -ReactDOM.createRoot(document.getElementById('root')!).render( +ReactDOM.createRoot(document.getElementById("root")!).render( - , -) + +); diff --git a/src/basic/models/notification.ts b/src/basic/models/notification.ts new file mode 100644 index 00000000..660ec8bc --- /dev/null +++ b/src/basic/models/notification.ts @@ -0,0 +1,14 @@ +export interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} + +export interface NotificationState { + notifications: Notification[]; +} + +export interface AddNotificationParams { + message: string; + type: "error" | "success" | "warning"; +} diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx new file mode 100644 index 00000000..0dc90c68 --- /dev/null +++ b/src/basic/pages/AdminPage.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { Coupon, Product } from "../../types"; +import { CouponManagement } from "../components/coupon/CouponManagement"; +import { ProductAccordion } from "../components/product/ProductAccordion"; + +interface AdminPageProps { + products: Product[]; + coupons: Coupon[]; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; + addProduct: (productData: Omit) => Product; + updateProduct: (id: string, updates: Partial) => void; + deleteProduct: (id: string) => void; + addCoupon: (coupon: Coupon) => void; + removeCoupon: (couponCode: string) => void; +} + +export function AdminPage({ + products, + coupons, + addNotification, + addProduct, + updateProduct, + deleteProduct, + addCoupon, + removeCoupon, +}: AdminPageProps) { + // 내부 상태 관리 + const [activeTab, setActiveTab] = useState<"products" | "coupons">( + "products" + ); + + return ( +
+
+

관리자 대시보드

+

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

+
+
+ +
+ + {activeTab === "products" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/basic/pages/ShopPage.tsx b/src/basic/pages/ShopPage.tsx new file mode 100644 index 00000000..a481de64 --- /dev/null +++ b/src/basic/pages/ShopPage.tsx @@ -0,0 +1,136 @@ +import { useCallback } from "react"; +import { CartItem, Coupon, Product } from "../../types"; + +import { ProductList } from "../components/product/ProductList"; +import { calculateCartTotal, getRemainingStock } from "../utils/cartUtils"; +import { CartSection } from "../components/cart/CartSection"; +import { CouponSection } from "../components/coupon/CouponSection"; +import { PaymentSection } from "../components/payment/PaymentSection"; + +interface ShopPageProps { + products: Product[]; + filteredProducts: Product[]; + + cart: CartItem[]; + addToCart: (product: Product, quantity?: number) => void; + updateCartItemQuantity: (productId: string, quantity: number) => void; + removeFromCart: (productId: string) => void; + clearCart: () => void; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + addNotification: ( + message: string, + type: "error" | "success" | "warning" + ) => void; + coupons: Coupon[]; +} + +export function ShopPage({ + products, + filteredProducts, + cart, + addToCart, + updateCartItemQuantity, + removeFromCart, + clearCart, + selectedCoupon, + setSelectedCoupon, + addNotification, + coupons, +}: ShopPageProps) { + // 장바구니 상품 수량 변경 (props로 받은 함수 사용) + const handleUpdateQuantity = useCallback( + (product: Product, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(product.id); + return; + } + + if (newQuantity > product.stock) { + addNotification?.(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return; + } + + if (!getRemainingStock(product, cart)) { + addNotification("재고가 부족합니다", "error"); + return; + } + + updateCartItemQuantity(product.id, newQuantity); + }, + [addNotification, cart, removeFromCart, updateCartItemQuantity] + ); + + // 쿠폰 적용 + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal( + cart, + selectedCoupon + ).totalBeforeDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + addNotification( + "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", + "error" + ); + return; + } + + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + [addNotification, cart, selectedCoupon, setSelectedCoupon] + ); + + // 주문 완료 + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + "success" + ); + clearCart(); + setSelectedCoupon(null); + }, [addNotification, clearCart, setSelectedCoupon]); + + const totals = calculateCartTotal(cart, selectedCoupon); + + return ( +
+
+ {/* 상품 목록 */} + +
+ +
+
+ + + {cart.length > 0 && ( + <> + + + + + )} +
+
+
+ ); +} diff --git a/src/basic/utils/cartUtils.ts b/src/basic/utils/cartUtils.ts new file mode 100644 index 00000000..5a3b1d63 --- /dev/null +++ b/src/basic/utils/cartUtils.ts @@ -0,0 +1,93 @@ +import { CartItem, Coupon, Product } from "../../types"; +import { + BULK_PURCHASE_THRESHOLD, + BULK_PURCHASE_ADDITIONAL_DISCOUNT, + MAX_DISCOUNT_RATE, +} from "../constants/cart"; + +export interface CartTotal { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} + +export const getMaxApplicableDiscount = ( + item: CartItem, + cart: 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 >= BULK_PURCHASE_THRESHOLD + ); + if (hasBulkPurchase) { + return Math.min( + baseDiscount + BULK_PURCHASE_ADDITIONAL_DISCOUNT, + MAX_DISCOUNT_RATE + ); + } + + return baseDiscount; +}; + +export const calculateItemTotal = ( + item: CartItem, + cart: CartItem[] +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): CartTotal => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === "amount") { + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - selectedCoupon.discountValue + ); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; + +export const getRemainingStock = ( + product: Product, + cart: CartItem[] +): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const usedQuantity = cartItem ? cartItem.quantity : 0; + return Math.max(0, product.stock - usedQuantity); +}; + +export const getCartItemCount = (cart: CartItem[]): number => { + return cart.reduce((sum, item) => sum + item.quantity, 0); +}; diff --git a/src/basic/utils/couponUtils.ts b/src/basic/utils/couponUtils.ts new file mode 100644 index 00000000..6d293fe0 --- /dev/null +++ b/src/basic/utils/couponUtils.ts @@ -0,0 +1,66 @@ +import { Coupon } from "../../types"; +import { COUPON_VALIDATION_RULES } from "../constants/coupon"; + +// 쿠폰 할인 적용 +export const applyCouponDiscount = (total: number, coupon: Coupon): number => { + if (!coupon) return total; + + if (coupon.discountType === "amount") { + return Math.max(0, total - coupon.discountValue); + } else if (coupon.discountType === "percentage") { + const discountAmount = (total * coupon.discountValue) / 100; + return Math.max(0, total - discountAmount); + } + + return total; +}; + +// 쿠폰 코드 생성 +export const generateCouponCode = (): string => { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let result = ""; + for (let i = 0; i < 8; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +}; + +// 쿠폰 데이터 검증 +export const validateCouponData = (coupon: Partial): boolean => { + if ( + !coupon.name || + !coupon.code || + !coupon.discountType || + !coupon.discountValue + ) { + return false; + } + + if (coupon.discountType === "amount") { + return ( + coupon.discountValue >= COUPON_VALIDATION_RULES.MIN_AMOUNT_DISCOUNT && + coupon.discountValue <= COUPON_VALIDATION_RULES.MAX_AMOUNT_DISCOUNT + ); + } else if (coupon.discountType === "percentage") { + return ( + coupon.discountValue >= COUPON_VALIDATION_RULES.MIN_PERCENTAGE_DISCOUNT && + coupon.discountValue <= COUPON_VALIDATION_RULES.MAX_PERCENTAGE_DISCOUNT + ); + } + + return false; +}; + +// 쿠폰 중복 검사 +export const isCouponDuplicate = (coupons: Coupon[], code: string): boolean => { + return coupons.some((coupon) => coupon.code === code); +}; + +// 쿠폰 표시 텍스트 생성 +export const getCouponDisplayText = (coupon: Coupon): string => { + if (coupon.discountType === "amount") { + return `${coupon.discountValue.toLocaleString()}원 할인`; + } else { + return `${coupon.discountValue}% 할인`; + } +}; diff --git a/src/basic/utils/formatters.ts b/src/basic/utils/formatters.ts new file mode 100644 index 00000000..b33d888b --- /dev/null +++ b/src/basic/utils/formatters.ts @@ -0,0 +1,26 @@ +import { Product } from "../../types"; + +export const displayPrice = ( + product: Product, + { suffix = "", prefix = "" }: { suffix?: string; prefix?: string } = {} +): string => { + if (product.stock <= 0) { + return "SOLD OUT"; + } + + return `${prefix}${product.price.toLocaleString()}${suffix}`; +}; + +/** + * 날짜를 YYYY-MM-DD 형식으로 포맷 + */ +export const formatDate = (date: Date): string => { + return date.toISOString().split("T")[0]; +}; + +/** + * 소수를 퍼센트로 변환 (0.1 → 10%) + */ +export const formatPercentage = (rate: number): string => { + return `${(rate * 100).toFixed(0)}%`; +}; diff --git a/src/basic/utils/hooks/useDebounce.ts b/src/basic/utils/hooks/useDebounce.ts new file mode 100644 index 00000000..c2224b46 --- /dev/null +++ b/src/basic/utils/hooks/useDebounce.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useRef } from "react"; + +/** + * 디바운스된 콜백 Hook + * 함수 호출을 지정된 시간만큼 지연시킵니다 + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useDebouncedCallback any>( + callback: T, + delay: number +): T { + const timeoutRef = useRef(null); + + const debouncedCallback = useCallback( + (...args: Parameters) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + callback(...args); + }, delay); + }, + [callback, delay] + ); + + // 컴포넌트 언마운트 시 타이머 정리 + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return debouncedCallback as T; +} diff --git a/src/basic/utils/productUtils.ts b/src/basic/utils/productUtils.ts new file mode 100644 index 00000000..747300dc --- /dev/null +++ b/src/basic/utils/productUtils.ts @@ -0,0 +1,55 @@ +import { CartItem, Product } from "../../types"; + +/** + * 상품 검색 필터링 + */ +export const filterProducts = ( + //TODO: 진석 리팩토링 필요 + products: Product[], + searchTerm: string +): Product[] => { + if (!searchTerm) return products; + + return products.filter( + (product) => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(searchTerm.toLowerCase())) + ); +}; + +/** + * 상품 ID 생성 + */ +export const generateProductId = (): string => { + return "p" + Date.now(); +}; + +/** + * 상품 데이터 유효성 검증 + */ +export const validateProductData = (product: Partial): boolean => { + return !!( + product.name && + product.name.trim() && + product.price && + product.price > 0 && + product.stock !== undefined && + product.stock >= 0 + ); +}; + +/** + * 남은 재고 계산 + */ +const getRemainingStock = (product: Product, cart: CartItem[]): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + return product.stock - (cartItem?.quantity || 0); +}; + +export const getRefinedProduct = (product: Product, cart: CartItem[]) => { + return { + ...product, + stock: getRemainingStock(product, cart), + } satisfies Product; +}; diff --git a/src/hooks/useFilterSearchParams.ts b/src/hooks/useFilterSearchParams.ts new file mode 100644 index 00000000..c3626f82 --- /dev/null +++ b/src/hooks/useFilterSearchParams.ts @@ -0,0 +1,54 @@ +import React from "react"; +import { z } from "zod"; +import { useSearchParams } from "./useSearchParams"; + +const filterSearchParamsSchema = z.object({ + searchTerm: z.string().optional(), +}); + +export type filterSearchParamsSchemaType = z.infer< + typeof filterSearchParamsSchema +>; + +/** + * searchParams를 Order requestType에 맞는 타입으로 변환해서 return + */ +export default function useFilterSearchParams() { + const [searchParams, setSearchParams] = useSearchParams(); + + const filterSearchParams: filterSearchParamsSchemaType = React.useMemo(() => { + // URLSearchParams를 일반 객체로 변환 + const paramsObject = Object.fromEntries(searchParams.entries()); + const validSearchParams = filterSearchParamsSchema.safeParse(paramsObject); + + if (!validSearchParams.success) { + return { + searchTerm: "", + }; + } + + return { + ...validSearchParams.data, + }; + }, [searchParams]); + + const setFilterSearchParams = ( + value: Partial + ) => { + const newSearchParams = { + ...filterSearchParams, + ...value, + }; + + // 빈 값들은 제거하고 URL에 설정 + const filteredParams = Object.fromEntries( + Object.entries(newSearchParams).filter( + ([_, v]) => v !== "" && v !== undefined + ) + ); + + setSearchParams(filteredParams); + }; + + return { filterSearchParams, setFilterSearchParams }; +} diff --git a/src/hooks/useSearchParams.ts b/src/hooks/useSearchParams.ts new file mode 100644 index 00000000..26fd0c8c --- /dev/null +++ b/src/hooks/useSearchParams.ts @@ -0,0 +1,72 @@ +import { useState, useEffect, useCallback } from "react"; + +/** + * useState와 useEffect를 활용한 간단한 useSearchParams 훅 + * URL의 search parameters를 구독하고 업데이트할 수 있습니다. + */ +export function useSearchParams(): [ + URLSearchParams, + (params: URLSearchParams | Record) => void +] { + const [searchParams, setSearchParamsState] = useState(() => { + if (typeof window === "undefined") { + return new URLSearchParams(); + } + return new URLSearchParams(window.location.search); + }); + + // URL 변경 감지 + useEffect(() => { + if (typeof window === "undefined") return; + + const handlePopState = () => { + setSearchParamsState(new URLSearchParams(window.location.search)); + }; + + const handleSearchParamsChanged = (event: CustomEvent) => { + setSearchParamsState(event.detail.searchParams); + }; + + window.addEventListener("popstate", handlePopState); + window.addEventListener( + "searchParamsChanged", + handleSearchParamsChanged as EventListener + ); + + return () => { + window.removeEventListener("popstate", handlePopState); + window.removeEventListener( + "searchParamsChanged", + handleSearchParamsChanged as EventListener + ); + }; + }, []); + + // searchParams 업데이트 함수 + const setSearchParams = useCallback( + (params: URLSearchParams | Record) => { + if (typeof window === "undefined") return; + + const newSearchParams = + params instanceof URLSearchParams + ? params + : new URLSearchParams(params); + + const newUrl = `${window.location.pathname}${ + newSearchParams.toString() ? `?${newSearchParams.toString()}` : "" + }${window.location.hash}`; + + history.replaceState(null, "", newUrl); + + // replaceState 후 상태 동기화를 위한 커스텀 이벤트 발생 + window.dispatchEvent( + new CustomEvent("searchParamsChanged", { + detail: { searchParams: newSearchParams }, + }) + ); + }, + [] + ); + + return [searchParams, setSearchParams]; +} diff --git a/src/refactoring(hint)/App.tsx b/src/refactoring(hint)/App.tsx deleted file mode 100644 index d8cc004c..00000000 --- a/src/refactoring(hint)/App.tsx +++ /dev/null @@ -1,12 +0,0 @@ -// TODO: 메인 App 컴포넌트 -// 힌트: -// 1. isAdmin 상태를 관리하여 쇼핑몰/관리자 모드 전환 -// 2. 네비게이션 바에 모드 전환 버튼 포함 -// 3. 조건부 렌더링으로 CartPage 또는 AdminPage 표시 -// 4. 상태 관리는 각 페이지 컴포넌트에서 처리 (App은 라우팅만 담당) - -export function App() { - // TODO: 구현 -} - -export default App; \ No newline at end of file diff --git a/src/refactoring(hint)/components/AdminPage.tsx b/src/refactoring(hint)/components/AdminPage.tsx deleted file mode 100644 index afb5b1ae..00000000 --- a/src/refactoring(hint)/components/AdminPage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// TODO: 관리자 페이지 컴포넌트 -// 힌트: -// 1. 탭 UI로 상품 관리와 쿠폰 관리 분리 -// 2. 상품 추가/수정/삭제 기능 -// 3. 쿠폰 생성 기능 -// 4. 할인 규칙 설정 -// -// 필요한 hooks: -// - useProducts: 상품 CRUD -// - useCoupons: 쿠폰 CRUD -// -// 하위 컴포넌트: -// - ProductForm: 새 상품 추가 폼 -// - ProductAccordion: 상품 정보 표시 및 수정 -// - CouponForm: 새 쿠폰 추가 폼 -// - CouponList: 쿠폰 목록 표시 - -export function AdminPage() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/components/CartPage.tsx b/src/refactoring(hint)/components/CartPage.tsx deleted file mode 100644 index 069edafc..00000000 --- a/src/refactoring(hint)/components/CartPage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -// TODO: 장바구니 페이지 컴포넌트 -// 힌트: -// 1. 상품 목록 표시 (검색 기능 포함) -// 2. 장바구니 관리 -// 3. 쿠폰 적용 -// 4. 주문 처리 -// -// 필요한 hooks: -// - useProducts: 상품 목록 관리 -// - useCart: 장바구니 상태 관리 -// - useCoupons: 쿠폰 목록 관리 -// - useDebounce: 검색어 디바운싱 -// -// 하위 컴포넌트: -// - SearchBar: 검색 입력 -// - ProductList: 상품 목록 표시 -// - Cart: 장바구니 표시 및 결제 - -export function CartPage() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/components/icons/index.tsx b/src/refactoring(hint)/components/icons/index.tsx deleted file mode 100644 index 1609d774..00000000 --- a/src/refactoring(hint)/components/icons/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -// TODO: SVG 아이콘 컴포넌트들 -// 구현할 아이콘: -// - CartIcon: 장바구니 아이콘 -// - AdminIcon: 관리자 아이콘 -// - PlusIcon: 플러스 아이콘 -// - MinusIcon: 마이너스 아이콘 -// - TrashIcon: 삭제 아이콘 -// - ChevronDownIcon: 아래 화살표 -// - ChevronUpIcon: 위 화살표 -// - CheckIcon: 체크 아이콘 - -// TODO: 구현 \ No newline at end of file diff --git a/src/refactoring(hint)/components/ui/UIToast.ts b/src/refactoring(hint)/components/ui/UIToast.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/refactoring(hint)/hooks/useCart.ts b/src/refactoring(hint)/hooks/useCart.ts deleted file mode 100644 index 6db309aa..00000000 --- a/src/refactoring(hint)/hooks/useCart.ts +++ /dev/null @@ -1,29 +0,0 @@ -// TODO: 장바구니 관리 Hook -// 힌트: -// 1. 장바구니 상태 관리 (localStorage 연동) -// 2. 상품 추가/삭제/수량 변경 -// 3. 쿠폰 적용 -// 4. 총액 계산 -// 5. 재고 확인 -// -// 사용할 모델 함수: -// - cartModel.addItemToCart -// - cartModel.removeItemFromCart -// - cartModel.updateCartItemQuantity -// - cartModel.calculateCartTotal -// - cartModel.getRemainingStock -// -// 반환할 값: -// - cart: 장바구니 아이템 배열 -// - selectedCoupon: 선택된 쿠폰 -// - addToCart: 상품 추가 함수 -// - removeFromCart: 상품 제거 함수 -// - updateQuantity: 수량 변경 함수 -// - applyCoupon: 쿠폰 적용 함수 -// - calculateTotal: 총액 계산 함수 -// - getRemainingStock: 재고 확인 함수 -// - clearCart: 장바구니 비우기 함수 - -export function useCart() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/hooks/useCoupons.ts b/src/refactoring(hint)/hooks/useCoupons.ts deleted file mode 100644 index d2ad82ab..00000000 --- a/src/refactoring(hint)/hooks/useCoupons.ts +++ /dev/null @@ -1,13 +0,0 @@ -// TODO: 쿠폰 관리 Hook -// 힌트: -// 1. 쿠폰 목록 상태 관리 (localStorage 연동 고려) -// 2. 쿠폰 추가/삭제 -// -// 반환할 값: -// - coupons: 쿠폰 배열 -// - addCoupon: 새 쿠폰 추가 -// - removeCoupon: 쿠폰 삭제 - -export function useCoupons() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/hooks/useProducts.ts b/src/refactoring(hint)/hooks/useProducts.ts deleted file mode 100644 index f4bef103..00000000 --- a/src/refactoring(hint)/hooks/useProducts.ts +++ /dev/null @@ -1,18 +0,0 @@ -// TODO: 상품 관리 Hook -// 힌트: -// 1. 상품 목록 상태 관리 (localStorage 연동 고려) -// 2. 상품 CRUD 작업 -// 3. 재고 업데이트 -// 4. 할인 규칙 추가/삭제 -// -// 반환할 값: -// - products: 상품 배열 -// - updateProduct: 상품 정보 수정 -// - addProduct: 새 상품 추가 -// - updateProductStock: 재고 수정 -// - addProductDiscount: 할인 규칙 추가 -// - removeProductDiscount: 할인 규칙 삭제 - -export function useProducts() { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/main.tsx b/src/refactoring(hint)/main.tsx deleted file mode 100644 index 589b1645..00000000 --- a/src/refactoring(hint)/main.tsx +++ /dev/null @@ -1,4 +0,0 @@ -// TODO: React 앱 엔트리 포인트 -// App 컴포넌트를 root DOM 요소에 렌더링 - -// TODO: 구현 \ No newline at end of file diff --git a/src/refactoring(hint)/models/cart.ts b/src/refactoring(hint)/models/cart.ts deleted file mode 100644 index 5c681048..00000000 --- a/src/refactoring(hint)/models/cart.ts +++ /dev/null @@ -1,18 +0,0 @@ -// TODO: 장바구니 비즈니스 로직 (순수 함수) -// 힌트: 모든 함수는 순수 함수로 구현 (부작용 없음, 같은 입력에 항상 같은 출력) -// -// 구현할 함수들: -// 1. calculateItemTotal(item): 개별 아이템의 할인 적용 후 총액 계산 -// 2. getMaxApplicableDiscount(item): 적용 가능한 최대 할인율 계산 -// 3. calculateCartTotal(cart, coupon): 장바구니 총액 계산 (할인 전/후, 할인액) -// 4. updateCartItemQuantity(cart, productId, quantity): 수량 변경 -// 5. addItemToCart(cart, product): 상품 추가 -// 6. removeItemFromCart(cart, productId): 상품 제거 -// 7. getRemainingStock(product, cart): 남은 재고 계산 -// -// 원칙: -// - UI와 관련된 로직 없음 -// - 외부 상태에 의존하지 않음 -// - 모든 필요한 데이터는 파라미터로 전달받음 - -// TODO: 구현 \ No newline at end of file diff --git a/src/refactoring(hint)/models/coupon.ts b/src/refactoring(hint)/models/coupon.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/refactoring(hint)/models/discount.ts b/src/refactoring(hint)/models/discount.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/refactoring(hint)/models/product.ts b/src/refactoring(hint)/models/product.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/refactoring(hint)/utils/formatters.ts b/src/refactoring(hint)/utils/formatters.ts deleted file mode 100644 index ff157f5c..00000000 --- a/src/refactoring(hint)/utils/formatters.ts +++ /dev/null @@ -1,7 +0,0 @@ -// TODO: 포맷팅 유틸리티 함수들 -// 구현할 함수: -// - formatPrice(price: number): string - 가격을 한국 원화 형식으로 포맷 -// - formatDate(date: Date): string - 날짜를 YYYY-MM-DD 형식으로 포맷 -// - formatPercentage(rate: number): string - 소수를 퍼센트로 변환 (0.1 → 10%) - -// TODO: 구현 \ No newline at end of file diff --git a/src/refactoring(hint)/utils/hooks/useDebounce.ts b/src/refactoring(hint)/utils/hooks/useDebounce.ts deleted file mode 100644 index 53c8a374..00000000 --- a/src/refactoring(hint)/utils/hooks/useDebounce.ts +++ /dev/null @@ -1,11 +0,0 @@ -// TODO: 디바운스 Hook -// 힌트: -// 1. 값이 변경되어도 지정된 시간 동안 대기 -// 2. 대기 시간 동안 값이 다시 변경되면 타이머 리셋 -// 3. 최종적으로 안정된 값만 반환 -// -// 사용 예시: 검색어 입력 디바운싱 - -export function useDebounce(value: T, delay: number): T { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts b/src/refactoring(hint)/utils/hooks/useLocalStorage.ts deleted file mode 100644 index 5dc72c50..00000000 --- a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts +++ /dev/null @@ -1,15 +0,0 @@ -// TODO: LocalStorage Hook -// 힌트: -// 1. localStorage와 React state 동기화 -// 2. 초기값 로드 시 에러 처리 -// 3. 저장 시 JSON 직렬화/역직렬화 -// 4. 빈 배열이나 undefined는 삭제 -// -// 반환값: [저장된 값, 값 설정 함수] - -export function useLocalStorage( - key: string, - initialValue: T -): [T, (value: T | ((val: T) => T)) => void] { - // TODO: 구현 -} \ No newline at end of file diff --git a/src/refactoring(hint)/utils/hooks/useValidate.ts b/src/refactoring(hint)/utils/hooks/useValidate.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/refactoring(hint)/utils/validators.ts b/src/refactoring(hint)/utils/validators.ts deleted file mode 100644 index 7d2dda44..00000000 --- a/src/refactoring(hint)/utils/validators.ts +++ /dev/null @@ -1,8 +0,0 @@ -// TODO: 검증 유틸리티 함수들 -// 구현할 함수: -// - isValidCouponCode(code: string): boolean - 쿠폰 코드 형식 검증 (4-12자 영문 대문자와 숫자) -// - isValidStock(stock: number): boolean - 재고 수량 검증 (0 이상) -// - isValidPrice(price: number): boolean - 가격 검증 (양수) -// - extractNumbers(value: string): string - 문자열에서 숫자만 추출 - -// TODO: 구현 \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 5489e296..b6da5bf7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,16 @@ export interface Product { price: number; stock: number; discounts: Discount[]; + description?: string; + isRecommended?: boolean; +} + +export interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; } export interface Discount { @@ -19,6 +29,6 @@ export interface CartItem { export interface Coupon { name: string; code: string; - discountType: 'amount' | 'percentage'; + discountType: "amount" | "percentage"; discountValue: number; } diff --git a/vite.config.ts b/vite.config.ts index e6c4016b..52b3d647 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,25 @@ -import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config'; -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react-swc'; +import { defineConfig as defineTestConfig, mergeConfig } from "vitest/config"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; export default mergeConfig( defineConfig({ plugins: [react()], + root: ".", + base: "/front_6th_chapter2-2/", + build: { + rollupOptions: { + input: "./index.html", + }, + outDir: "dist", + copyPublicDir: true, + }, }), defineTestConfig({ test: { globals: true, - environment: 'jsdom', - setupFiles: './src/setupTests.ts' + environment: "jsdom", + setupFiles: "./src/setupTests.ts", }, }) -) +);