diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..c8fe33882 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,68 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + paths: + - "src/advanced/**" + - "index.advanced.html" + - "vite.config.ts" + - ".github/workflows/deploy.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: latest + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + env: + NODE_ENV: production + VITE_BASE_PATH: /${{ github.event.repository.name }}/ + + # [추가된 핵심 로직] + # 빌드된 dist 폴더 안의 index.advanced.html을 index.html로 이름 변경 + # 이렇게 하면 GitHub Pages가 index.html을 찾을 수 있게 됩니다. + - name: Rename entry file for GitHub Pages + run: mv dist/index.advanced.html dist/index.html + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./dist + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/README.md b/README.md index e38f1e44b..e0c0ffe92 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,456 @@ -# Chapter3-2. 디자인 패턴과 함수형 프로그래밍 그리고 상태 관리 설계 +[구조 변경] +- FSD 폴더 구조 초기화 (shared, entities, widgets) +- 공통 타입과 유틸리티 함수를 `shared` 레이어로 이동 -## 기본과제: 거대 단일 컴포넌트 리팩토링 +[Entities] +- `ProductCard` UI 생성 및 도메인 로직(재고 확인) 캡슐화 +- 장바구니 계산 로직을 `entities/cart/lib`으로 분리 -이번 과제는 단일책임원칙을 위반한 거대한 컴포넌트를 리팩토링 하는 것입니다. React의 컴포넌트는 단일 책임 원칙(Single Responsibility Principle, SRP)을 따르는 것이 좋습니다. 즉, 각 컴포넌트는 하나의 책임만을 가져야 합니다. 하지만 실제로는 여러 가지 기능을 가진 거대한 컴포넌트를 작성하는 경우가 많습니다. +[Widgets] +- `Header` 위젯 추출: UI 렌더링과 상태 분리 +- `ProductList` 위젯 추출: 목록 렌더링 및 검색 결과 없음 처리 분리 +- `CartSidebar` 위젯 추출: 자율적인 가격 계산 로직 구현 -[목표] -## 1. 취지 -- React의 추구미(!)를 이해해보아요! -- 단일 책임 원칙(SRP)을 위반한 거대한 컴포넌트가 얼마나 안 좋은지 경험해보아요! -- 단일 책임이라는 개념을 이해하기 상태, 순수함수, 컴포넌트, 훅 등 다양한 계층을 이해해합니다. -- 엔티티와 UI를 구분하고 데이터, 상태, 비즈니스 로직 등의 특징이 다르다는 것을 이해해보세요. -- 이를 통해 적절한 Custom Hook과 유틸리티 함수를 분리하고, 컴포넌트 계층 구조를 정리하는 능력을 갖춥니다! +[리팩토링] +- App.tsx의 원시 JSX 코드를 위젯 컴포넌트 조합으로 교체 +- 애플리케이션 전체의 가격 표기 방식 표준화 +- UI 컴포넌트의 TSX 확장자 관련 에러 수정 +## 과제의 핵심취지 -## 2. 목표 +- React의 hook 이해하기 +- 함수형 프로그래밍에 대한 이해 +- 액션과 순수함수의 분리 -모든 소프트웨어에는 적절한 책임과 계층이 존재합니다. 하나의 계층(Component)만으로 소프트웨어를 구성하게 되면 나중에는 정리정돈이 되지 않은 코드를 만나게 됩니다. 예전에는 이러한 BestPractice에 대해서 혼돈의 시대였지만 FE가 진화를 거듭하는 과정에서 적절한 계측에 대한 합의가 이루어지고 있는 상태입니다. - -React의 주요 책임 계층은 Component, hook, function 등이 있습니다. 그리고 주요 분류 기준은 **엔티티**가 되어 줍니다. +## 과제에서 꼭 알아가길 바라는 점 - 엔티티를 다루는 상태와 그렇지 않은 상태 - cart, isCartFull vs isShowPopup - 엔티티를 다루는 컴포넌트와 훅 - CartItemView, useCart(), useProduct() - 엔티티를 다루지 않는 컴포넌트와 훅 - Button, useRoute, useEvent 등 -- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) +- 엔티티를 다루는 함수와 그렇지 않은 함수 - 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] 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요? + +### 심화과제 + +- 이번 심화과제는 Context나 Jotai를 사용해서 Props drilling을 없애는 것입니다. +- 어떤 props는 남겨야 하는지, 어떤 props는 제거해야 하는지에 대한 기준을 세워보세요. +- Context나 Jotai를 사용하여 상태를 관리하는 방법을 익히고, 이를 통해 컴포넌트 간의 데이터 전달을 효율적으로 처리할 수 있습니다. + +- [ ] Context나 Jotai를 사용해서 전역상태관리를 구축했나요? +- [ ] 전역상태관리를 통해 domain custom hook을 적절하게 리팩토링 했나요? +- [ ] 도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거했나요? +- [ ] 전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드가 되었나요? + + +## 과제 셀프회고 + +### 리팩토링 청사진 + +이번 리팩토링의 핵심은 **수직적 분리** 와 **수평적 분리** 를 통해 입체적인 코드 구조를 만드는 것입니다. + +### 수직적 분리 (레이어 위계) + +``` +Widgets (조립) + ↓ +Features (액션) + ↓ +Entities (데이터) + ↓ +Shared (공통) +``` + +상위 레이어만 하위 레이어를 참조할 수 있는 **단방향 의존성 규칙** 을 세워 데이터 흐름을 통제했습니다. + +### 수평적 분리 (내부 역할) + +각 폴더 내부에서 책임을 다시 분리합니다: + +``` +feature/cart/ +├── ui/ # 사용자 액션 (View) +├── model/ # 상태 관리 (State) +└── lib/ # 순수 계산 (Logic) +``` + +이는 함수형 프로그래밍의 **"데이터와 로직의 분리"** 원칙을 파일 시스템에 적용한 것입니다. + +### 입체적 메트릭스 + +수직과 수평을 조합하면, 코드의 위치만으로도 **"어떤 계층의, 어떤 역할"** 인지 즉시 파악할 수 있습니다: + +- `widgets/ProductList/ui` → 상위 레이어의 UI 조립 +- `features/cart/model` → 중간 레이어의 상태 관리 +- `entities/cart/lib` → 하위 레이어의 순수 계산 로직 + +이 구조는 파일 경로 자체가 명세서 역할을 합니다. + +### 시행착오: 수평적 분리에 대한 오해 + +처음에는 FSD의 레이어 구조만 이해했습니다. 그래서 단순히 +- Entities = 데이터 +- Features = 액션 +- Shared = 계산 + +이렇게 함수형 프로그래밍의 3단계 분리(데이터, 계산, 액션)와 1:1로 매핑된다고 생각했습니다. + +**하지만 이건 잘못된 이해였습니다.** + +image +image +image +image + + +영서님과의 대화를 통해 깨달은 것은: + +- **수직적 분리(Layer)**: '권력(의존성)'을 기준으로 나눈다 + - 누가 누구를 참조할 수 있는가? + +- **수평적 분리(Slice)**: '주제(도메인)'를 기준으로 나눈다 + - product, cart, user 등 + +- **내부 분리(Segment)**: '직무(역할)'를 기준으로 나눈다 + - ui, model, lib 등 + +코드를 분리할 때는 **"이건 어떤 Layer의 어떤 Slice에 있는 어떤 Segment가 해야 할 일인지"** 를 생각하면 됩니다. + +예를 들어, 장바구니 총합 계산 로직은: +- Layer: `entities` (데이터 레이어) +- Slice: `cart` (장바구니 도메인) +- Segment: `lib` (순수 계산 로직) +→ `entities/cart/lib/calculateCartTotal.ts` + +FSD 설계 + 함수형 프로그래밍은 **3차원 좌표계** 처럼 코드의 정확한 위치를 지정하는 시스템같다고 느껴졌습니다. + +### 설계 의사결정 과정 +폴더 구조의 큰 틀은 FSD 공식 문서와 레퍼런스를 통해 파악했지만, 실제로 중요한 것은 **"어떤 설계를 선택할 것인가"** 에 대한 의사결정이었습니다. + +예를 들어: +- `calculateCartTotal` 로직을 `entities/cart/lib`에 둘 것인가, `shared/lib`에 둘 것인가? +- `ProductCard`의 재고 확인 로직은 컴포넌트 내부에 둘 것인가, 별도 lib로 분리할 것인가? +- `onAddToCart` 핸들러는 어느 레이어가 소유해야 하는가? + +이런 질문들은 정답이 명확하지 않았고, 각 선택마다 트레이드오프가 있었습니다. +[함수형 프로그래밍 블로그](https://velog.io/@teo/functional-programming) 와 [FSD 설계 블로그](https://velog.io/@teo/fsd)를 정독한 후 → +- 특정 도메인에 강하게 결합된 로직은 해당 Entity에 두는 것이 응집도가 높다 +- 범용적으로 쓰이는 유틸리티만 Shared로 올린다 +- Feature 레이어 없이 Entity가 액션을 소유하면 재사용성이 떨어진다 + +이런 인사이트를 얻을 수 있었고, **설계 결정의 타당성을 검증** 할 수 있었습니다. + +결국 좋은 아키텍처는 폴더를 어떻게 나누느냐의 문제가 아니라, **"왜 이렇게 나눴는가"를 설명할 수 있는가** 의 문제라는 것을 배웠습니다. + + +--- + + +### 과제를 하면서 내가 알게된 점, 좋았던 점은 무엇인가요? + +## FSD 패턴: 폴더 구조가 곧 명세서다 [Screaming Architecture] + +로버트 C. 마틴(Uncle Bob)이 주창한 "Screaming Architecture" 개념이 있습니다. + +> "프로젝트의 폴더 구조를 봤을 때, '우리는 리액트를 씁니다!'라고 소리치지 말고, '우리는 쇼핑몰입니다!'라고 소리쳐야 한다." + +### 기술이 아닌 비즈니스가 보여야 한다 + +**기존 구조:** + +``` +src/ +├── components/ +├── hooks/ +├── utils/ +└── contexts/ +``` + +이 구조는 "우리는 리액트를 씁니다!"라고 소리칩니다. + +- 컴포넌트를 쓰는구나 +- 훅을 쓰는구나 +- 컨텍스트를 쓰는구나 + +**하지만 정작 중요한 질문에는 답하지 못합니다:** + +- 이게 쇼핑몰인가요? SNS인가요? 투두리스트인가요? +- 어떤 기능이 있나요? +- 어떤 데이터를 다루나요? + +**FSD 구조:** + +``` +src/ +📦advanced + ┣ 📂entities # [평민] 비즈니스 데이터 모델 & 순수 계산 (Domain Model) + ┃ ┃ # * 원칙: 데이터 구조와 순수 함수만 존재. 상태(State)나 액션(Action)을 모름. + ┃ ┣ 📂cart # ┗ 장바구니 도메인 + ┃ ┃ ┣ 📂lib # ┗ 계산 로직 (calculateCartTotal - 순수 함수) + ┃ ┃ ┗ 📂model # ┗ 타입 정의 (CartItem) + ┃ ┣ 📂coupon # ┗ 쿠폰 도메인 + ┃ ┃ ┣ 📂lib # ┗ 검증 로직 (canApplyCoupon - 순수 함수) + ┃ ┃ ┗ 📂model # ┗ 타입 정의 (Coupon) + ┃ ┗ 📂product # ┗ 상품 도메인 + ┃ ┃ ┣ 📂lib # ┗ 재고 파악 (getRemainingStock - 순수 함수) + ┃ ┃ ┣ 📂model # ┗ 타입 정의 (ProductWithUI) + ┃ ┃ ┗ 📂ui # ┗ 멍청한 컴포넌트 (ProductCard - 데이터 표시 전용) + ┣ 📂features # [귀족] 사용자 액션 & 상태 관리 (User Actions & State) + ┃ ┃ # * 원칙: 사용자의 '행동(Verb)'을 담당. Entity와 Shared를 사용하여 비즈니스 가치 창출. + ┃ ┣ 📂app # ┗ 앱 전반을 아우르는 통합 기능 + ┃ ┃ ┗ 📜useShop.ts # ┗ Facade Hook (모든 Feature Hook을 하나로 묶는 배선반) + ┃ ┣ 📂cart # ┗ 장바구니 기능 슬라이스 + ┃ ┃ ┗ 📂model # ┗ useCart (담기, 삭제, 수량 변경 로직) + ┃ ┣ 📂coupon # ┗ 쿠폰 기능 슬라이스 + ┃ ┃ ┣ 📂model # ┗ useCoupons (쿠폰 CRUD 로직) + ┃ ┃ ┗ 📂ui # ┗ CouponManagementForm (쿠폰 입력 및 검증 UI) + ┃ ┗ 📂product # ┗ 상품 기능 슬라이스 + ┃ ┃ ┗ 📂model # ┗ 상품 관련 상태 및 로직 + ┃ ┃ ┃ ┣ 📂ui # ┗ ProductManagementForm (상품 입력 폼 UI) + ┃ ┃ ┃ ┣ 📜useProductFilter.ts # ┗ 검색 및 필터링 로직 + ┃ ┃ ┃ ┣ 📜useProductForm.ts # ┗ 폼 상태 관리 (Headless Logic) + ┃ ┃ ┃ ┗ 📜useProducts.ts # ┗ 상품 CRUD 및 저장소 동기화 + ┣ 📂shared # [도구] 도메인을 모르는 범용 유틸리티 & 인프라 (Infrastructure) + ┃ ┃ # * 원칙: 프로젝트 전반에서 쓰이는 공용 도구. 비즈니스 로직(쇼핑몰)을 전혀 모름. + ┃ ┣ 📂lib # ┗ 범용 함수 및 커스텀 훅 + ┃ ┃ ┣ 📜format.ts # ┗ 포맷터 (순수 함수) + ┃ ┃ ┣ 📜useDebounce.ts # ┗ UI 최적화 (시간 지연) + ┃ ┃ ┣ 📜useLocalStorage.ts # ┗ 저장소 추상화 (인프라 격리) + ┃ ┃ ┣ 📜useNotification.ts # ┗ 알림 로직 (삭제 예정 혹은 레거시 호환) + ┃ ┃ ┗ 📜useNotificationSystem.ts # ┗ 개선된 알림 시스템 로직 + ┃ ┣ 📂model # ┗ 범용 데이터 모델 + ┃ ┃ ┗ 📜types.ts # ┗ Notification 등 시스템 공용 타입 + ┃ ┗ 📂ui # ┗ 범용 UI 컴포넌트 + ┃ ┃ ┗ 📜NotificationSystem.ts # ┗ 알림 메시지 렌더러 (순수 UI) + ┣ 📂widgets # [조립] 화면을 구성하는 독립적인 UI 블록 (Composition) + ┃ ┃ # * 원칙: Entity와 Feature를 조립하여 완성된 덩어리를 만듦. + ┃ ┣ 📂AdminDashboard # ┗ 관리자 패널 위젯 + ┃ ┃ ┗ 📂ui + ┃ ┃ ┃ ┣ 📜CouponListGrid.tsx # ┗ (Sub) 쿠폰 목록 그리드 + ┃ ┃ ┃ ┣ 📜ProductListTable.tsx # ┗ (Sub) 상품 목록 테이블 + ┃ ┃ ┃ ┗ 📜index.tsx # ┗ 위젯 진입점 (조립 및 상태 연결) + ┃ ┣ 📂CartSidebar # ┗ 장바구니 사이드바 위젯 + ┃ ┃ ┗ 📂ui # ┗ (결제 정보 포함) + ┃ ┣ 📂Header # ┗ 헤더 위젯 + ┃ ┃ ┗ 📂ui # ┗ (네비게이션, 카트 아이콘) + ┃ ┗ 📂ProductList # ┗ 상품 목록 위젯 + ┃ ┃ ┗ 📂ui # ┗ (검색 결과 표시) + ┣ 📂__tests__ # 테스트 코드 + ┃ ┗ 📜origin.test.tsx # ┗ 통합 테스트 (E2E 시나리오 검증) + ┣ 📜App.tsx # [왕/페이지] 레이어 조립 및 데이터 주입 (Wiring) + ┃ # * 원칙: 로직 없음. Feature Hooks(useShop)를 호출하고 Widget에 주입하는 역할. + ┗ 📜main.tsx # 앱 진입점 +``` + +이 구조는 "우리는 쇼핑몰입니다!"라고 소리칩니다. + +- 상품이 있구나 +- 장바구니가 있구나 +- 쿠폰을 적용할 수 있구나 +- 결제 기능이 있구나 + +**폴더 이름만 봐도 기획서의 목차가 보입니다.** + +FSD는 기술 스택이 아닌 비즈니스 도메인이 앞으로 나옵니다. + +## 레이어 자체가 기능 명세서 + +FSD의 각 폴더는 기획서의 섹션과 정확히 매칭됩니다. + +| FSD 레이어 | 기획서/명세서의 항목 | 우리 프로젝트의 실제 폴더 (예시) | +|------------|---------------------|--------------------------------| +| Pages | 사이트맵 / 라우팅 | App.tsx (현재 메인) | +| Widgets | 와이어프레임 (UI 블록) | widgets/Header, widgets/ProductList, widgets/CartSidebar, widgets/AdminDashboard | +| Features | 유저 스토리 (비즈니스 액션) | features/cart (담기/수량변경), features/product (상품 CRUD), features/coupon (쿠폰 적용) | +| Entities | 도메인 모델 (데이터 구조) | entities/product (ProductCard, 재고파악), entities/cart (가격계산) | +| Shared | 인프라 / 공통 규약 | shared/model (types.ts), shared/lib (format.ts, useNotification) | + +특히 `features` 폴더는 그 자체로 "이 애플리케이션이 수행할 수 있는 동작 목록"입니다. + +신규 입사자가 왔을 때, 두꺼운 위키 문서를 던져주는 것보다 `src/features` 폴더를 열어보라고 하는 게 훨씬 빠르고 정확합니다. + +## 강제된 의존성 규칙이 곧 설계도 + +일반적인 프로젝트에서는 개발자마다 "이 코드를 어디에 둘까?"에 대한 기준이 달라 스파게티가 되기 쉽습니다. 하지만 FSD는 **상위 레이어만 하위 레이어를 참조할 수 있다**는 엄격한 규칙이 있습니다. + +이 규칙 자체가 "코드의 흐름도" 역할을 합니다: + +- Widget을 보면 → "아, 이 기능(Feature)과 저 데이터(Entity)를 조립했구나." +- Feature를 보면 → "아, 이 데이터(Entity)를 조작하는구나." + +폴더 위치만으로도 데이터의 흐름과 의존 관계를 파악할 수 있습니다. 폴더구조가 하나의 명세로 사용된다는 게 신기하고 충격이었습니다. + +--- + +## FSD 리팩토링: "어디에 둘까?"를 결정한 5가지 원칙 + +리팩토링을 진행하면서 가장 어려웠던 건 **"이 코드를 어느 폴더에 넣을까?"** 였습니다. +수십 개의 파일을 재배치하면서, 저는 5가지 질문을 스스로에게 던졌습니다. + +--- + +## 1. "이 코드가 세상을 바꾸는가?" (액션 vs 계산의 분리) + +### 함수형 프로그래밍의 제1원칙 + +가장 먼저 적용한 기준입니다. 코드를 볼 때마다 자문했습니다. + +**질문:** +> "이 함수를 100번 실행하면 외부 세상(DB, 화면, 전역변수)에 영향을 미치는가?" + +**분리 기준:** + +- **NO (순수함):** 입력만 같으면 결과가 늘 같고, 아무것도 안 건드린다. + → **lib (Entities/Shared)** 으로 격리 + - 예: `calculateCartTotal`, `formatCurrency`, `canApplyCoupon` + +- **YES (부수효과):** 상태를 바꾸거나(`setState`), 저장하거나(`localStorage`), 화면을 그린다. + → **model (Hook) 또는 ui (Component)** 에 남김 + - 예: `addToCart`, `useLocalStorage` + +**왜 중요한가?** +순수 함수는 테스트하기 쉽고, 재사용하기 좋고, 버그가 적습니다. +lib 폴더만 열면 "아, 이건 믿고 쓸 수 있는 계산기구나"라는 안도감이 들어야 합니다. + +--- + +## 2. "이 코드는 '쇼핑몰'이라는 사실을 아는가?" (도메인 지식 유무) + +### FSD 레이어 결정 기준 + +코드가 비즈니스(도메인)와 얼마나 끈끈한지 따졌습니다. + +**질문:** +> "이 코드를 떼어내서 '투두 리스트' 앱에 가져가면 에러 없이 작동하는가?" + +**분리 기준:** + +- **YES (작동함):** 도메인을 모르는 멍청한 도구다. + → **Shared** + - 예: `useLocalStorage` (저장만 함), `useDebounce` (시간만 끔), `formatCurrency` (숫자만 바꿈) + +- **NO (에러남):** '상품', '쿠폰' 같은 단어를 알아야 한다. + → **Entities / Features** + - 예: `ProductCard`, `useCart` + +**왜 중요한가?** +Shared는 다른 프로젝트에서도 복붙할 수 있는 "범용 인프라"입니다. +반면 Entities/Features는 "이 쇼핑몰만의 고유한 지식"입니다. +이 경계가 명확해야 코드를 재사용하거나 교체하기 쉬워집니다. + +--- + +## 3. "책임이 너무 무겁지 않은가?" (단일 책임 원칙) + +### 컴포넌트 분할 기준 + +`AdminDashboard`나 `App.tsx`를 찢을 때 적용한 기준입니다. + +**질문:** +> "이 컴포넌트가 변경되어야 하는 이유가 2개 이상인가?" + +**분리 기준:** + +- `App.tsx`는 "라우팅/배치"가 바뀌면 수정되어야 하는데, "검색 로직" 때문에도 수정되어야 했다. + → **분리 대상** + +- `AdminDashboard`는 "레이아웃" 때문에 수정되어야 하는데, "상품 입력 폼의 유효성 검사" 때문에도 수정되어야 했다. + → **ProductManagementForm**으로 분리 + +**왜 중요한가?** +하나의 파일이 여러 이유로 수정되면, 팀원들끼리 Git 충돌이 나고, 버그 수정 시 예상치 못한 부분이 깨집니다. +"한 가지 이유로만 변경되는 파일"은 안전하고 예측 가능합니다. + +--- + +## 4. "구현(How)인가, 의도(What)인가?" (추상화 레벨) + +### 훅(Hook) 분리 기준 + +`useCart` 내부를 정리할 때 쓴 기준입니다. -이번 과제의 목표는 이러한 계층을 이해하고 분리하여 정리정돈을 하는 기준이나 방법등을 습득하는데 있습니다. +**질문:** +> "이 코드가 '무엇을 하는지' 설명하는가, '어떻게 하는지' 구구절절 설명하는가?" -제시된 코드는 각각의 컴포넌트에 모든 비즈니스 로직이 작성되어 있습니다. 여기에서 custom hook과 util 함수를 적절하게 분리하고, **테스트 코드를 통과할 수 있도록 해주세요.** +**분리 기준:** -> basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. +- **Before:** `localStorage.getItem('cart')... JSON.parse...` + → 이건 "어떻게(How)"에 집착하는 저수준 코드 -### (1) 요구사항 +- **Action:** 저수준 코드를 `useLocalStorage`로 숨김 -#### 1) 장바구니 페이지 요구사항 +- **After:** `const [cart, setCart] = useLocalStorage("cart", []);` + → "저장한다(What)"는 의도만 남김 + → **비즈니스 로직이 선명해짐** -- 상품 목록 - - 상품명, 가격, 재고 수량 등을 표시 - - 각 상품의 할인 정보 표시 - - 재고가 없는 경우 품절 표시가 되며 장바구니 추가가 불가능 -- 장바구니 - - 장바구니 내 상품 수량 조절 가능 - - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시 - - 적용된 할인율 표시 (예: "10% 할인 적용") - - 장바구니 내 모든 상품의 총액을 계산해야 -- 쿠폰 할인 - - 할인 쿠폰을 선택하면 적용하면 최종 결제 금액에 할인정보가 반영 -- 주문요약 - - 할인 전 총 금액 - - 총 할인 금액 - - 최종 결제 금액 +**왜 중요한가?** +비즈니스 로직에 `JSON.parse`, `try-catch` 같은 저수준 코드가 섞이면 "진짜 중요한 로직"이 묻힙니다. +추상화를 통해 "쿠폰을 적용한다", "장바구니에 담는다" 같은 **비즈니스 언어** 만 남겨야 합니다. -#### 2) 관리자 페이지 요구사항 +--- -- 상품 관리 - - 상품 정보 (상품명, 가격, 재고, 할인율) 수정 가능 - - 새로운 상품 추가 가능 - - 상품 제거 가능 -- 할인 관리 - - 상품별 할인 정보 추가/수정/삭제 가능 - - 할인 조건 설정 (구매 수량에 따른 할인율) -- 쿠폰 관리 - - 전체 상품에 적용 가능한 쿠폰 생성 - - 쿠폰 정보 입력 (이름, 코드, 할인 유형, 할인 값) - - 할인 유형은 금액 또는 비율로 설정 가능 +## 5. "자주 함께 바뀌는가?" (응집도) -### (2) 코드 개선 요구사항 +### 파일 위치 선정 기준 -#### 1) cart, product에 대한 계산 함수 분리 +타입(`types.ts`)을 찢어서 각 폴더에 넣을 때 쓴 기준입니다. -- calculateItemTotal -- getMaxApplicableDiscount -- calculateCartTotal -- updateCartItemQuantity +**질문:** +> "Product 타입이 바뀌면 누가 가장 괴로운가?" -#### 2) 상태를 다루는 hook, 유틸리티 hook 분리 +**분리 기준:** -- useCart -- useCoupon -- useProduct -- useLocalStorage +- `Product` 타입이 바뀌면 `ProductCard`와 `useProducts`가 괴롭다. + → 그들 곁(`entities/product`)에 둔다. **(Co-location)** -#### 3) 엔티티 컴포넌트와 UI 컴포넌트 분리하여 계층구조 만들기 +- 서로 관련 없는 코드들이 한 파일(`shared/types.ts`)에 모여 있으면, 하나 고칠 때마다 깃 충돌(Conflict)만 난다. + → **찢는다** -- ProductCard -- Cart -- … +**왜 중요한가?** +"함께 변경되는 것은 함께 둔다"는 원칙은 개발 속도를 올립니다. +`Product` 관련 코드를 수정할 때, `entities/product` 폴더만 열면 모든 게 거기 있습니다. +여러 폴더를 헤매지 않아도 됩니다. -### (3) 테스트 코드 통과하기 +--- +## ⚡ 5가지 원칙 요약표 +| 상황 | 질문 | 결정 (Action) | +|------|------|---------------| +| **로직** | 결과가 늘 같은가? | ✅ → `lib` (순수함수)
❌ → `hook` (액션) | +| **위치** | 쇼핑몰인 걸 아는가? | ✅ → `Entities/Features`
❌ → `Shared` | +| **UI** | 역할이 2개 이상인가? | ✅ → 하위 컴포넌트로 분리 (Form, List) | +| **상태** | 구체적 구현인가? | ✅ → 커스텀 훅으로 추상화 (`useLocalStorage`) | +| **데이터** | 누구랑 친한가? | 친한 놈 옆방(`model`)으로 이동 | -## 심화과제: Props drilling -> **이번 심화과제는 Props drilling을 없애기 입니다.** +--- -# 2. 목표 +## 마치며 -- basic에서 열심히 컴포넌트를 분리해주었겠죠? -- 아마 그 과정에서 container - presenter 패턴으로 만들어졌기에 props drilling이 상당히 불편했을거에요. -- 그래서 심화과제에서는 props drilling을 제거하는 작업을 할거에요. - - 전역상태관리가 아직 낯설다. - jotai를 선택해주세요 (참고자료 참고) - - 나는 React만으로 해보고 싶다. - context를 선택해서 상태관리를 해보세요. - - 나는 지금 대세인 Zustand를 할거에요. - zustand를 선택해주세요. +이 5가지 질문은 **"코드를 어디에 둘까?"** 라는 과제에서의 고민을 비교적 쉽게 해결해주었습니다. +처음엔 답이 명확하지 않았지만, 원칙을 반복 적용하다 보니 +이제는 코드를 보는 순간 "아, 이건 `entities/product/lib`에 가야겠네"라는 직감이 생겼습니다. +물론 이게 가장 좋은 방법이라고 말한 순 없지만, 저만의 기준을 근거로 삼고, 근거를 통해 분리를 하니 복잡했던 컴포넌트가 많이 정리된 것 같습니다. -### (1) 요구사항 -- 불필요한 props를 제거하고, 필요한 props만을 전달하도록 개선합니다. -- Context나 Jotai 혹은 Zustand를 사용하여 상태를 관리합니다. -- 테스트 코드를 통과합니다. +### 이번 과제에서 내가 제일 신경 쓴 부분은 무엇인가요? -### (2) 힌트 +### 이번 과제를 통해 앞으로 해보고 싶은게 있다면 알려주세요! -- UI 컴포넌트와 엔티티 컴포넌트는 각각 props를 다르게 받는게 좋습니다. - - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, - - 엔티티 컴포넌트는 가급적 엔티티를 중심으로 전달받는 것이 좋습니다. -- 특히 콜백의 경우, - - UI 컴포넌트는 이벤트 핸들러를 props로 받아서 처리하도록 해서 재사용성을 높이지만, - - 엔티티 컴포넌트는 props가 아닌 컴포넌트 내부에서 상태를 관리하는 것이 좋습니다. \ No newline at end of file +### 리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 편하게 남겨주세요 :) diff --git a/package.json b/package.json index 17b18de25..ea90096c9 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,14 @@ }, "dependencies": { "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "zustand": "^5.0.9" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.1", "@types/react": "^19.1.9", "@types/react-dom": "^19.1.7", "@typescript-eslint/eslint-plugin": "^8.38.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85f..9edadd207 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: react-dom: specifier: ^19.1.1 version: 19.1.1(react@19.1.1) + zustand: + specifier: ^5.0.9 + version: 5.0.9(@types/react@19.1.9)(react@19.1.1) devDependencies: '@testing-library/jest-dom': specifier: ^6.6.4 @@ -24,6 +27,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 '@types/react': specifier: ^19.1.9 version: 19.1.9 @@ -38,7 +44,7 @@ importers: version: 8.38.0(eslint@9.32.0)(typescript@5.9.2) '@vitejs/plugin-react-swc': specifier: ^3.11.0 - version: 3.11.0(vite@7.0.6) + version: 3.11.0(vite@7.0.6(@types/node@24.10.1)) '@vitest/ui': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) @@ -59,10 +65,10 @@ importers: version: 5.9.2 vite: specifier: ^7.0.6 - version: 7.0.6 + version: 7.0.6(@types/node@24.10.1) vitest: specifier: ^3.2.4 - version: 3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0) + version: 3.2.4(@types/node@24.10.1)(@vitest/ui@3.2.4)(jsdom@26.1.0) packages: @@ -583,6 +589,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/react-dom@19.1.7': resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} peerDependencies: @@ -1382,6 +1391,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1515,6 +1527,24 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zustand@5.0.9: + resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@adobe/css-tools@4.4.0': {} @@ -1886,6 +1916,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + '@types/react-dom@19.1.7(@types/react@19.1.9)': dependencies: '@types/react': 19.1.9 @@ -1987,11 +2021,11 @@ snapshots: '@typescript-eslint/types': 8.38.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6)': + '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6(@types/node@24.10.1))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.27 '@swc/core': 1.13.3 - vite: 7.0.6 + vite: 7.0.6(@types/node@24.10.1) transitivePeerDependencies: - '@swc/helpers' @@ -2003,13 +2037,13 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.0.6)': + '@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.10.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.0.6 + vite: 7.0.6(@types/node@24.10.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -2040,7 +2074,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0) + vitest: 3.2.4(@types/node@24.10.1)(@vitest/ui@3.2.4)(jsdom@26.1.0) '@vitest/utils@3.2.4': dependencies: @@ -2716,17 +2750,19 @@ snapshots: typescript@5.9.2: {} + undici-types@7.16.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 - vite-node@3.2.4: + vite-node@3.2.4(@types/node@24.10.1): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.0.6 + vite: 7.0.6(@types/node@24.10.1) transitivePeerDependencies: - '@types/node' - jiti @@ -2741,7 +2777,7 @@ snapshots: - tsx - yaml - vite@7.0.6: + vite@7.0.6(@types/node@24.10.1): dependencies: esbuild: 0.25.8 fdir: 6.4.6(picomatch@4.0.3) @@ -2750,13 +2786,14 @@ snapshots: rollup: 4.46.2 tinyglobby: 0.2.14 optionalDependencies: + '@types/node': 24.10.1 fsevents: 2.3.3 - vitest@3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0): + vitest@3.2.4(@types/node@24.10.1)(@vitest/ui@3.2.4)(jsdom@26.1.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.0.6) + '@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.10.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -2774,10 +2811,11 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.0.6 - vite-node: 3.2.4 + vite: 7.0.6(@types/node@24.10.1) + vite-node: 3.2.4(@types/node@24.10.1) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 24.10.1 '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 26.1.0 transitivePeerDependencies: @@ -2829,3 +2867,8 @@ snapshots: xmlchars@2.2.0: {} yocto-queue@0.1.0: {} + + zustand@5.0.9(@types/react@19.1.9)(react@19.1.1): + optionalDependencies: + '@types/react': 19.1.9 + react: 19.1.1 diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1d..e450d2d95 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,1118 +1,206 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ +import { useState, useEffect } from "react"; + +// Shared Store +import { useNotificationStore } from "./shared/lib/notificationStore"; +// Feature Stores (동기화용) +import { useCartStore } from "./features/cart/model/cartStore"; +import { useProductStore } from "./features/product/model/productStore"; +import { useCouponStore } from "./features/coupon/model/couponStore"; + +// Shared UI & Lib +import { NotificationSystem } from "./shared/ui/NotificationSystem"; +import { useDebounce } from "./shared/lib/useDebounce"; + +// Widgets +import { Header } from "./widgets/Header/ui"; +import { ProductList } from "./widgets/ProductList/ui"; +import { CartSidebar } from "./widgets/CartSidebar/ui"; +import { AdminDashboard } from "./widgets/AdminDashboard/ui"; + +// Feature Hooks (Selector) +import { useProducts } from "./features/product/model/useProducts"; +import { useCoupons } from "./features/coupon/model/useCoupons"; +import { useCart } from "./features/cart/model/useCart"; + +const initialProducts = [ { - id: 'p1', - name: '상품1', + id: "p1", + name: "상품1", price: 10000, stock: 20, discounts: [ { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } + { quantity: 20, rate: 0.2 }, ], - description: '최고급 품질의 프리미엄 상품입니다.' + description: "최고급 품질의 프리미엄 상품입니다.", }, { - id: 'p2', - name: '상품2', + id: "p2", + name: "상품2", price: 20000, stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, }, { - id: 'p3', - name: '상품3', + id: "p3", + name: "상품3", price: 30000, stock: 20, discounts: [ { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } + { quantity: 30, rate: 0.25 }, ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, ]; - -const initialCoupons: Coupon[] = [ +const initialCoupons = [ { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, }, { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } + 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]); - + // -------------------------------------------------------------------------- + // 2. Data Synchronization (LocalStorage <-> Zustand) + // -------------------------------------------------------------------------- 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; + // (1) 초기화: 알림 등 휘발성 데이터 비우기 (테스트 격리) + useNotificationStore.setState({ notifications: [] }); + + // (2) 로드 (Hydration): 로컬스토리지 -> 스토어 + const hydrateStore = ( + key: string, + initial: T, + setter: (data: T) => void + ) => { + const saved = localStorage.getItem(key); + if (saved) { + try { + setter(JSON.parse(saved)); + } catch (e) { + console.error(`Failed to parse ${key}`, e); + setter(initial); } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); + } else { + setter(initial); } - - 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 - ) + // 각 스토어 초기화 + hydrateStore( + "products", + initialProducts, + useProductStore.getState().setProducts ); - 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); - }; + hydrateStore( + "coupons", + initialCoupons as any, + useCouponStore.getState().setCoupons + ); // as any는 타입 호환성 때문일 수 있음 + hydrateStore("cart", [], (cart) => useCartStore.setState({ cart })); // Cart는 별도 setter가 없다면 setState 사용 + + // (3) 구독 (Subscription): 스토어 -> 로컬스토리지 + const unsubs = [ + useProductStore.subscribe((state) => + localStorage.setItem("products", JSON.stringify(state.products)) + ), + useCouponStore.subscribe((state) => + localStorage.setItem("coupons", JSON.stringify(state.coupons)) + ), + useCartStore.subscribe((state) => + localStorage.setItem("cart", JSON.stringify(state.cart)) + ), + ]; + + // Cleanup: 구독 해제 + return () => unsubs.forEach((fn) => fn()); + }, []); - const totals = calculateCartTotal(); + // -------------------------------------------------------------------------- + // 3. Feature Hooks (Business Logic) + // -------------------------------------------------------------------------- + const { products } = useProducts(); + const { coupons } = useCoupons(); + const { + cart, + selectedCoupon, + setSelectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + completeOrder, + } = useCart(); + + // -------------------------------------------------------------------------- + // 4. UI State & Logic + // -------------------------------------------------------------------------- + const [isAdmin, setIsAdmin] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 500); const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ? 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" - /> -
- )} -
- -
-
-
+ + +
setIsAdmin(!isAdmin)} + searchTerm={searchTerm} + onSearchChange={setSearchTerm} + />
{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()}원 -
-
- - - -
-

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

-
-
- - )} -
+
+
)} @@ -1121,4 +209,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/advanced/entities/cart/lib/index.ts b/src/advanced/entities/cart/lib/index.ts new file mode 100644 index 000000000..fe16e7985 --- /dev/null +++ b/src/advanced/entities/cart/lib/index.ts @@ -0,0 +1,81 @@ +import { CartItem } from "../../../entities/cart/model/types"; +import { Coupon } from "../../../entities/coupon/model/types"; + +/** + * 장바구니 아이템에 적용 가능한 최대 할인율을 계산합니다. + * (대량 구매 로직 포함) + */ +export const getMaxApplicableDiscount = ( + item: CartItem, + cart: CartItem[] +): number => { + const { discounts } = item.product; + const { quantity } = item; + + // 1. 상품 자체의 수량 할인 확인 + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + // 2. 장바구니 전체를 뒤져서 대량 구매 여부 확인 (비즈니스 룰) + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 추가 5% 할인, 최대 50% + } + + 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 +) => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + // calculateItemTotal을 재사용 + 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), + }; +}; diff --git a/src/advanced/entities/cart/model/types.ts b/src/advanced/entities/cart/model/types.ts new file mode 100644 index 000000000..8a6135ce3 --- /dev/null +++ b/src/advanced/entities/cart/model/types.ts @@ -0,0 +1,6 @@ +import { ProductWithUI } from "../../product/model/types"; + +export interface CartItem { + product: ProductWithUI; + quantity: number; +} diff --git a/src/advanced/entities/coupon/lib/index.ts b/src/advanced/entities/coupon/lib/index.ts new file mode 100644 index 000000000..81e2946e4 --- /dev/null +++ b/src/advanced/entities/coupon/lib/index.ts @@ -0,0 +1,17 @@ +import { Coupon } from "../../../entities/coupon/model/types"; +/** + * 쿠폰을 적용할 수 있는지 판단하는 순수 함수 + * @param coupon 적용하려는 쿠폰 + * @param currentTotalAmount 현재 장바구니 총액 (할인 전) + * @returns 적용 가능 여부 + */ +export const canApplyCoupon = ( + coupon: Coupon, + currentTotalAmount: number +): boolean => { + // 비즈니스 규칙: 정률 할인은 10,000원 이상일 때만 가능 + if (coupon.discountType === "percentage" && currentTotalAmount < 10000) { + return false; + } + return true; +}; diff --git a/src/advanced/entities/coupon/model/types.ts b/src/advanced/entities/coupon/model/types.ts new file mode 100644 index 000000000..5f5750118 --- /dev/null +++ b/src/advanced/entities/coupon/model/types.ts @@ -0,0 +1,6 @@ +export interface Coupon { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +} diff --git a/src/advanced/entities/product/lib/index.ts b/src/advanced/entities/product/lib/index.ts new file mode 100644 index 000000000..f5d225906 --- /dev/null +++ b/src/advanced/entities/product/lib/index.ts @@ -0,0 +1,18 @@ +import { CartItem } from "../../../entities/cart/model/types"; +import { Product } from "../../../entities/product/model/types"; + + +/** + * 상품의 재고가 얼마나 남았는지 계산합니다. + * @param product 확인할 상품 + * @param cart 현재 장바구니 상태 (전체 재고 확인을 위해 필요) + */ +export const getRemainingStock = ( + product: Product, + cart: CartItem[] +): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +}; diff --git a/src/advanced/entities/product/model/types.ts b/src/advanced/entities/product/model/types.ts new file mode 100644 index 000000000..00e53cf6e --- /dev/null +++ b/src/advanced/entities/product/model/types.ts @@ -0,0 +1,17 @@ +export interface Discount { + quantity: number; + rate: number; +} + +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} diff --git a/src/advanced/entities/product/ui/ProductCard.tsx b/src/advanced/entities/product/ui/ProductCard.tsx new file mode 100644 index 000000000..89ada5368 --- /dev/null +++ b/src/advanced/entities/product/ui/ProductCard.tsx @@ -0,0 +1,108 @@ +import { ProductWithUI} from "../../../entities/product/model/types"; +import { CartItem } from "../../../entities/cart/model/types"; +import { formatCurrencyWithSymbol } from "../../../shared/lib/format"; +import { getRemainingStock } from "../lib"; + +interface Props { + product: ProductWithUI; + cart: CartItem[]; + onAddToCart: (product: ProductWithUI) => void; +} + +export const ProductCard = ({ product, cart, onAddToCart }: Props) => { + // 도메인 로직: 재고 계산 + const remainingStock = getRemainingStock(product, cart); + const isSoldOut = remainingStock <= 0; + + // UI 로직: 최대 할인율 계산 (배지용) + const maxDiscountRate = product.discounts.reduce( + (max, d) => Math.max(max, d.rate), + 0 + ); + + return ( +
+ {/* 1. 이미지 및 배지 영역 */} +
+
+ + + +
+ + {/* BEST 배지 */} + {product.isRecommended && ( + + BEST + + )} + + {/* 할인율 배지 */} + {product.discounts.length > 0 && ( + + ~{Math.round(maxDiscountRate * 100)}% + + )} +
+ + {/* 2. 상품 정보 영역 */} +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

+ )} + + {/* 가격 및 할인 정책 */} +
+

+ {formatCurrencyWithSymbol(product.price)} +

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

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

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

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

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

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; diff --git a/src/advanced/features/app/useShop.ts b/src/advanced/features/app/useShop.ts new file mode 100644 index 000000000..6218ad062 --- /dev/null +++ b/src/advanced/features/app/useShop.ts @@ -0,0 +1,23 @@ +import { useProducts } from "../product/model/useProducts"; +import { useCoupons } from "../coupon/model/useCoupons"; +import { useCart } from "../cart/model/useCart"; +import { useNotificationSystem } from "../../shared/lib/useNotificationSystem"; + +export const useShop = () => { + // 1. 알림 시스템 + const { notifications, addNotification, removeNotification } = useNotificationSystem(); + + // 2. 도메인 훅 연결 (의존성 주입 해결) + const productLogic = useProducts(); + const couponLogic = useCoupons(); + const cartLogic = useCart(); + + return { + addNotification, + notifications, + removeNotification, + productLogic, + couponLogic, + cartLogic, + }; +}; \ No newline at end of file diff --git a/src/advanced/features/cart/model/cartStore.ts b/src/advanced/features/cart/model/cartStore.ts new file mode 100644 index 000000000..f70646b01 --- /dev/null +++ b/src/advanced/features/cart/model/cartStore.ts @@ -0,0 +1,150 @@ +import { create } from "zustand"; + +// Shared (Store) +import { useNotificationStore } from "../../../shared/lib/notificationStore"; + +// Entities (Model - types) +import { CartItem } from "../../../entities/cart/model/types"; +import { ProductWithUI } from "../../../entities/product/model/types"; +import { Coupon } from "../../../entities/coupon/model/types"; + +// Entities (Lib - Pure Functions) +import { getRemainingStock } from "../../../entities/product/lib"; +import { calculateCartTotal } from "../../../entities/cart/lib"; +import { canApplyCoupon } from "../../../entities/coupon/lib"; + +interface CartState { + cart: CartItem[]; + selectedCoupon: Coupon | null; + + // Actions + addToCart: (product: ProductWithUI) => void; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number, products: ProductWithUI[]) => void; + applyCoupon: (coupon: Coupon) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; + completeOrder: () => void; +} + +// ✅ persist 미들웨어 제거: 순수 메모리 저장소로 변경 +export const useCartStore = create((set, get) => ({ + cart: [], + selectedCoupon: null, + + /** + * 상품을 장바구니에 추가합니다. + */ + addToCart: (product: ProductWithUI) => { + const { cart } = get(); + const { addNotification } = useNotificationStore.getState(); + + const remaining = getRemainingStock(product, cart); + if (remaining <= 0) { + addNotification("재고가 부족합니다!", "error"); + return; + } + + const existing = cart.find((item) => item.product.id === product.id); + if (existing) { + if (existing.quantity + 1 > product.stock) { + addNotification(`재고는 ${product.stock}개까지만 있습니다.`, "error"); + return; + } + + set({ + cart: cart.map((item) => + item.product.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + ), + }); + } else { + set({ cart: [...cart, { product, quantity: 1 }] }); + } + + addNotification("장바구니에 담았습니다", "success"); + }, + + /** + * 장바구니에서 상품을 제거합니다. + */ + removeFromCart: (productId: string) => { + set((state) => ({ + cart: state.cart.filter((item) => item.product.id !== productId), + })); + }, + + /** + * 수량을 업데이트합니다. + */ + updateQuantity: (productId: string, newQuantity: number, products: ProductWithUI[]) => { + const { removeFromCart } = get(); + const { addNotification } = useNotificationStore.getState(); + + 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; + } + + set((state) => ({ + cart: state.cart.map((item) => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ), + })); + }, + + /** + * 쿠폰을 적용합니다. + */ + applyCoupon: (coupon: Coupon) => { + const { cart } = get(); + const { addNotification } = useNotificationStore.getState(); + + const { totalAfterDiscount } = calculateCartTotal(cart, null); + + if (!canApplyCoupon(coupon, totalAfterDiscount)) { + addNotification( + "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", + "error" + ); + return; + } + + set({ selectedCoupon: coupon }); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + + /** + * 쿠폰 선택 상태를 직접 변경합니다. (해제 등) + */ + setSelectedCoupon: (coupon: Coupon | null) => { + set({ selectedCoupon: coupon }); + }, + + /** + * 주문을 완료하고 장바구니를 비웁니다. + */ + completeOrder: () => { + const { addNotification } = useNotificationStore.getState(); + // crypto.randomUUID()를 사용하여 고유한 주문번호 생성 (키 중복 방지) + const orderNumber = `ORD-${crypto.randomUUID().slice(0, 8)}`; + + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + "success" + ); + + set({ cart: [], selectedCoupon: null }); + }, +})); \ No newline at end of file diff --git a/src/advanced/features/cart/model/useCart.ts b/src/advanced/features/cart/model/useCart.ts new file mode 100644 index 000000000..a6e29a550 --- /dev/null +++ b/src/advanced/features/cart/model/useCart.ts @@ -0,0 +1,28 @@ +// Shared (Store) +import { useCartStore } from "./cartStore"; +import { useProductStore } from "../../product/model/productStore"; + +/** + * 장바구니 관련 상태와 액션을 관리하는 커스텀 훅 + * 내부적으로 ProductStore를 구독하여 재고 확인에 필요한 데이터를 자동으로 확보합니다. + */ +export const useCart = () => { + const cartStore = useCartStore(); + const products = useProductStore((state) => state.products); + + return { + cart: cartStore.cart, + selectedCoupon: cartStore.selectedCoupon, + + // 단순 전달 액션 + addToCart: cartStore.addToCart, + removeFromCart: cartStore.removeFromCart, + applyCoupon: cartStore.applyCoupon, + setSelectedCoupon: cartStore.setSelectedCoupon, + completeOrder: cartStore.completeOrder, + + // 복합 액션 (Dependency Injection) + updateQuantity: (productId: string, newQuantity: number) => + cartStore.updateQuantity(productId, newQuantity, products) + }; +}; \ No newline at end of file diff --git a/src/advanced/features/coupon/model/couponStore.ts b/src/advanced/features/coupon/model/couponStore.ts new file mode 100644 index 000000000..e457df66e --- /dev/null +++ b/src/advanced/features/coupon/model/couponStore.ts @@ -0,0 +1,44 @@ +import { create } from "zustand"; +import { Coupon } from "../../../entities/coupon/model/types"; +import { useNotificationStore } from "../../../shared/lib/notificationStore"; + +interface CouponState { + coupons: Coupon[]; + setCoupons: (coupons: Coupon[]) => void; + addCoupon: (coupon: Coupon) => void; + deleteCoupon: (couponCode: string) => void; +} + +export const useCouponStore = create((set, get) => ({ + coupons: [], + + setCoupons: (coupons) => set({ coupons }), + + addCoupon: (newCoupon) => { + const { coupons } = get(); + const existing = coupons.find((c) => c.code === newCoupon.code); + + if (existing) { + useNotificationStore + .getState() + .addNotification("이미 존재하는 쿠폰 코드입니다.", "error"); + return; + } + + set((state) => ({ + coupons: [...state.coupons, newCoupon], + })); + useNotificationStore + .getState() + .addNotification("쿠폰이 추가되었습니다.", "success"); + }, + + deleteCoupon: (couponCode) => { + set((state) => ({ + coupons: state.coupons.filter((c) => c.code !== couponCode), + })); + useNotificationStore + .getState() + .addNotification("쿠폰이 삭제되었습니다.", "success"); + }, +})); \ No newline at end of file diff --git a/src/advanced/features/coupon/model/useCoupons.ts b/src/advanced/features/coupon/model/useCoupons.ts new file mode 100644 index 000000000..27606a644 --- /dev/null +++ b/src/advanced/features/coupon/model/useCoupons.ts @@ -0,0 +1,14 @@ +import { useCouponStore } from "./couponStore"; + +/** + * 쿠폰 관련 상태와 액션을 제공하는 커스텀 훅 + */ +export const useCoupons = () => { + const couponStore = useCouponStore(); + + return { + coupons: couponStore.coupons, + addCoupon: couponStore.addCoupon, + deleteCoupon: couponStore.deleteCoupon, + }; +}; \ No newline at end of file diff --git a/src/advanced/features/coupon/ui/CouponManagementForm.tsx b/src/advanced/features/coupon/ui/CouponManagementForm.tsx new file mode 100644 index 000000000..20c4b208e --- /dev/null +++ b/src/advanced/features/coupon/ui/CouponManagementForm.tsx @@ -0,0 +1,194 @@ +import { useState, FormEvent } from "react"; + +// Shared Store +import { useNotificationStore } from "../../../shared/lib/notificationStore"; + +// Entities (Model) +import { Coupon } from "../../../entities/coupon/model/types"; + +interface Props { + /** 쿠폰 추가 확정 시 호출되는 콜백 */ + onAddCoupon: (coupon: Coupon) => void; + /** 취소 버튼 클릭 시 호출되는 콜백 */ + onCancel: () => void; +} + +/** + * 쿠폰 생성을 위한 폼 UI 컴포넌트 + * 내부적으로 폼 상태를 관리하며, 입력 값에 대한 유효성 검사(Validation)를 수행합니다. + */ +export const CouponManagementForm = ({ + onAddCoupon, + onCancel, +}: Props) => { + const addNotification = useNotificationStore((state) => state.addNotification); + + // -------------------------------------------------------------------------- + // Local State + // -------------------------------------------------------------------------- + const [couponForm, setCouponForm] = useState({ + name: "", + code: "", + discountType: "amount", + discountValue: 0, + }); + + // -------------------------------------------------------------------------- + // Handlers + // -------------------------------------------------------------------------- + + /** + * 폼 제출 핸들러 + * 데이터를 부모에게 전달하고 폼을 초기화합니다. + */ + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + onAddCoupon(couponForm); + setCouponForm({ + name: "", + code: "", + discountType: "amount", + discountValue: 0, + }); + }; + + /** + * 할인 금액/율 입력 필드의 Blur 핸들러 + * 입력된 값의 유효성을 검사하고, 범위를 벗어난 경우 알림을 띄우고 값을 보정합니다. + */ + const handleDiscountBlur = () => { + const value = couponForm.discountValue; + + if (couponForm.discountType === "percentage") { + if (value > 100) { + addNotification("할인율은 100%를 초과할 수 없습니다", "error"); + setCouponForm((prev) => ({ ...prev, discountValue: 100 })); + } else if (value < 0) { + setCouponForm((prev) => ({ ...prev, discountValue: 0 })); + } + } else { + // 정액 할인일 경우 + if (value > 100000) { + addNotification("할인 금액은 100,000원을 초과할 수 없습니다", "error"); + setCouponForm((prev) => ({ ...prev, discountValue: 100000 })); + } else if (value < 0) { + setCouponForm((prev) => ({ ...prev, discountValue: 0 })); + } + } + }; + + // -------------------------------------------------------------------------- + // Render + // -------------------------------------------------------------------------- + return ( +
+
+

새 쿠폰 생성

+ +
+ {/* 쿠폰명 입력 */} +
+ + + 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 val = e.target.value; + if (val === "" || /^\d+$/.test(val)) { + setCouponForm({ + ...couponForm, + discountValue: val === "" ? 0 : parseInt(val), + }); + } + }} + onBlur={handleDiscountBlur} + 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 + /> +
+
+ + {/* 액션 버튼 */} +
+ + +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/advanced/features/product/model/productStore.ts b/src/advanced/features/product/model/productStore.ts new file mode 100644 index 000000000..f257a9b82 --- /dev/null +++ b/src/advanced/features/product/model/productStore.ts @@ -0,0 +1,51 @@ +import { create } from "zustand"; +import { ProductWithUI } from "../../../entities/product/model/types"; +import { useNotificationStore } from "../../../shared/lib/notificationStore"; + +interface ProductState { + products: ProductWithUI[]; + setProducts: (products: ProductWithUI[]) => void; + + // ✅ [수정] 배열([])이 아니라 함수((...)=>void)여야 합니다! + addProduct: (product: Omit) => void; + + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; +} + +export const useProductStore = create((set) => ({ + products: [], + + setProducts: (products) => set({ products }), + + addProduct: (product: Omit) => { + const newProduct = { ...product, id: `p-${crypto.randomUUID()}` }; + set((state) => ({ + products: [...state.products, newProduct], + })); + + useNotificationStore + .getState() + .addNotification("상품이 추가되었습니다.", "success"); + }, + + updateProduct: (productId, updates) => { + set((state) => ({ + products: state.products.map((p) => + p.id === productId ? { ...p, ...updates } : p + ), + })); + useNotificationStore + .getState() + .addNotification("상품이 수정되었습니다.", "success"); + }, + + deleteProduct: (productId) => { + set((state) => ({ + products: state.products.filter((p) => p.id !== productId), + })); + useNotificationStore + .getState() + .addNotification("상품이 삭제되었습니다.", "success"); + }, +})); \ No newline at end of file diff --git a/src/advanced/features/product/model/ui/ProductManagementForm.tsx b/src/advanced/features/product/model/ui/ProductManagementForm.tsx new file mode 100644 index 000000000..63ae7652c --- /dev/null +++ b/src/advanced/features/product/model/ui/ProductManagementForm.tsx @@ -0,0 +1,192 @@ +import { FormEvent } from "react"; + +// Shared +import { ProductWithUI } from "../../../../entities/product/model/types"; +import { useNotificationStore } from "../../../../shared/lib/notificationStore"; + +// Features (Model) +import { useProductForm } from "../useProductForm"; + +interface Props { + initialData?: ProductWithUI | null; + onSubmit: (product: ProductWithUI) => void; + onCancel: () => void; +} + +/** + * 상품 추가/수정을 위한 폼 UI 컴포넌트 + */ +export const ProductManagementForm = ({ + initialData, + onSubmit, + onCancel, +}: Props) => { + + const addNotification = useNotificationStore((state) => state.addNotification); + // Logic을 Hook으로 위임 + const { + productForm, + handleChange, + handleNumberChange, + handleNumberBlur, + addDiscount, + removeDiscount, + updateDiscount, + } = useProductForm({ initialData, onNotification: addNotification }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + onSubmit(productForm); + }; + + const isEditMode = !!initialData; + + return ( +
+
+

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

+ +
+ {/* 상품명 */} +
+ + handleChange("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 + /> +
+ + {/* 설명 */} +
+ + handleChange("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" + /> +
+ + {/* 가격 */} +
+ + handleNumberChange("price", e.target.value)} + onBlur={(e) => handleNumberBlur("price", parseInt(e.target.value) || 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 + /> +
+ + {/* 재고 */} +
+ + handleNumberChange("stock", e.target.value)} + onBlur={(e) => handleNumberBlur("stock", parseInt(e.target.value) || 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 + /> +
+ + {/* 추천 상품 체크박스 */} +
+ +
+
+ + {/* 할인 정책 섹션 */} +
+ +
+ {productForm.discounts.map((discount, index) => ( +
+ updateDiscount(index, "quantity", parseInt(e.target.value) || 0)} + className="w-20 px-2 py-1 border rounded" + placeholder="수량" + /> + 개 이상 구매 시 + updateDiscount(index, "rate", (parseInt(e.target.value) || 0) / 100)} + className="w-16 px-2 py-1 border rounded" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ + {/* 폼 버튼 */} +
+ + +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/advanced/features/product/model/useProductFilter.ts b/src/advanced/features/product/model/useProductFilter.ts new file mode 100644 index 000000000..95aee09dd --- /dev/null +++ b/src/advanced/features/product/model/useProductFilter.ts @@ -0,0 +1,23 @@ +import { useState } from "react"; +import { ProductWithUI } from "../../../entities/product/model/types"; +import { useDebounce } from "../../../shared/lib/useDebounce"; + +export const useProductFilter = (products: ProductWithUI[]) => { + const [searchTerm, setSearchTerm] = useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 500); + + const filteredProducts = debouncedSearchTerm + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase())) + ) + : products; + + return { searchTerm, setSearchTerm, filteredProducts }; +}; \ No newline at end of file diff --git a/src/advanced/features/product/model/useProductForm.ts b/src/advanced/features/product/model/useProductForm.ts new file mode 100644 index 000000000..11e424b9b --- /dev/null +++ b/src/advanced/features/product/model/useProductForm.ts @@ -0,0 +1,123 @@ +import { useState, useEffect } from "react"; + +// Shared +import { ProductWithUI } from "../../../entities/product/model/types"; + +// 초기값 상수 +const INITIAL_FORM_STATE: ProductWithUI = { + id: "", // 폼에서는 사용 안 함 + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + isRecommended: false, +}; + +interface UseProductFormProps { + initialData?: ProductWithUI | null; + onNotification: (message: string, type?: "error" | "success" | "warning") => void; +} + +/** + * 상품 입력 폼의 상태와 로직을 관리하는 커스텀 훅 (Feature Model) + * @param initialData 수정 시 채워넣을 초기 데이터 + * @param onNotification 유효성 검사 실패 시 알림 + */ +export const useProductForm = ({ initialData, onNotification }: UseProductFormProps) => { + const [productForm, setProductForm] = useState(INITIAL_FORM_STATE); + + // 수정 모드일 때 초기 데이터 주입 + useEffect(() => { + if (initialData) { + setProductForm(initialData); + } else { + setProductForm(INITIAL_FORM_STATE); + } + }, [initialData]); + + const resetForm = () => { + setProductForm(INITIAL_FORM_STATE); + }; + + /** + * 텍스트 입력 핸들러 + */ + const handleChange = (field: keyof ProductWithUI, value: any) => { + setProductForm((prev) => ({ ...prev, [field]: value })); + }; + + /** + * 숫자 입력 핸들러 (유효성 검사 포함) + */ + const handleNumberChange = (field: keyof ProductWithUI, value: string) => { + if (value === "" || /^\d+$/.test(value)) { + const parsedValue = value === "" ? 0 : parseInt(value); + setProductForm((prev) => ({ ...prev, [field]: parsedValue })); + } + }; + + /** + * 숫자 필드 Blur 핸들러 (값 보정 및 알림) + */ + const handleNumberBlur = (field: keyof ProductWithUI, value: number, label: string) => { + if (Number.isNaN(value) || value === 0) { // 빈 값 처리 + // 0 허용 여부에 따라 로직이 다를 수 있음. 여기선 0으로 리셋 + setProductForm((prev) => ({ ...prev, [field]: 0 })); + return; + } + + if (value < 0) { + onNotification(`${label}은 0보다 커야 합니다`, "error"); + setProductForm((prev) => ({ ...prev, [field]: 0 })); + } + + // 재고 9999 제한 (비즈니스 규칙) + if (field === "stock" && value > 9999) { + onNotification("재고는 9999개를 초과할 수 없습니다", "error"); + setProductForm((prev) => ({ ...prev, stock: 9999 })); + } + }; + + /** + * 할인 추가 핸들러 + */ + const addDiscount = () => { + setProductForm((prev) => ({ + ...prev, + discounts: [...prev.discounts, { quantity: 10, rate: 0.1 }], + })); + }; + + /** + * 할인 제거 핸들러 + */ + const removeDiscount = (index: number) => { + setProductForm((prev) => ({ + ...prev, + discounts: prev.discounts.filter((_, i) => i !== index), + })); + }; + + /** + * 할인 아이템 변경 핸들러 + */ + const updateDiscount = (index: number, field: "quantity" | "rate", value: number) => { + setProductForm((prev) => { + const newDiscounts = [...prev.discounts]; + newDiscounts[index][field] = value; + return { ...prev, discounts: newDiscounts }; + }); + }; + + return { + productForm, + handleChange, + handleNumberChange, + handleNumberBlur, + addDiscount, + removeDiscount, + updateDiscount, + resetForm, + }; +}; \ No newline at end of file diff --git a/src/advanced/features/product/model/useProducts.ts b/src/advanced/features/product/model/useProducts.ts new file mode 100644 index 000000000..1a796cc16 --- /dev/null +++ b/src/advanced/features/product/model/useProducts.ts @@ -0,0 +1,17 @@ +import { useProductStore } from "./productStore"; + +/** + * 상품 관련 상태와 액션을 제공하는 커스텀 훅 + * (Zustand Store를 컴포넌트에서 쉽게 쓰도록 연결하는 Selector 역할) + */ +export const useProducts = () => { + // 스토어 전체를 반환하거나, 필요한 것만 골라서 반환 + const productStore = useProductStore(); + + return { + products: productStore.products, + addProduct: productStore.addProduct, + updateProduct: productStore.updateProduct, + deleteProduct: productStore.deleteProduct, + }; +}; \ No newline at end of file diff --git a/src/advanced/shared/lib/format.ts b/src/advanced/shared/lib/format.ts new file mode 100644 index 000000000..a86bbaa09 --- /dev/null +++ b/src/advanced/shared/lib/format.ts @@ -0,0 +1,20 @@ +/** + * 숫자를 한국 통화 형식으로 변환합니다. + * 예: 10000 -> "10,000원" + * @param value 금액 + * @returns 포맷팅된 문자열 + */ +export const formatCurrency = (value: number): string => { + // 순수 계산: 입력(number) -> 출력(string) + return `${value.toLocaleString()}원`; +}; + +/** + * 숫자를 ₩ 표시가 있는 통화 형식으로 변환합니다. (기존 코드의 비관리자용)- `src/shared/lib/useLocalStorage.ts` 생성: 로컬 스토리지 읽기/쓰기 로직을 제네릭 훅으로 캡슐화 +- Feature Hooks(`useCart`, `useProducts`, `useCoupons`)에서 중복되는 `useEffect` 및 저장소 접근 로직 제거 +- `JSON.parse` 에러 처리를 공통 훅 내부로 통합하여 안정성 확보 +- 비즈니스 로직에서 저장 매체(Implementation Detail)에 대한 의존성 제거 + */ +export const formatCurrencyWithSymbol = (value: number): string => { + return `₩${value.toLocaleString()}`; +}; diff --git a/src/advanced/shared/lib/notificationStore.ts b/src/advanced/shared/lib/notificationStore.ts new file mode 100644 index 000000000..adbc333de --- /dev/null +++ b/src/advanced/shared/lib/notificationStore.ts @@ -0,0 +1,33 @@ +import { create } from "zustand"; +import { Notification } from "../model/types"; + +interface NotificationState { + notifications: Notification[]; + // 액션도 스토어 안에 함께 정의 + addNotification: (message: string, type?: "error" | "success" | "warning") => void; + removeNotification: (id: string) => void; +} + +export const useNotificationStore = create((set, get) => ({ + notifications: [], + + addNotification: (message, type = "success") => { + const id = crypto.randomUUID(); + + // 1. 상태 업데이트 (추가) + set((state) => ({ + notifications: [...state.notifications, { id, message, type }], + })); + + // 2. 사이드 이펙트 (타이머). + setTimeout(() => { + get().removeNotification(id); + }, 3000); + }, + + removeNotification: (id) => { + set((state) => ({ + notifications: state.notifications.filter((n) => n.id !== id), + })); + }, +})); \ No newline at end of file diff --git a/src/advanced/shared/lib/useDebounce.ts b/src/advanced/shared/lib/useDebounce.ts new file mode 100644 index 000000000..599aee279 --- /dev/null +++ b/src/advanced/shared/lib/useDebounce.ts @@ -0,0 +1,25 @@ +import { useState, useEffect } from "react"; + +/** + * 값이 변경되면 지정된 시간(delay)만큼 기다렸다가 업데이트하는 훅 + * @param value 관찰할 값 + * @param delay 지연 시간 (ms) + * @returns 디바운스된 값 + */ +export const useDebounce = (value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + // 1. 타이머 설정: delay 후에 상태 업데이트 + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // 2. 클린업(Cleanup): 값이 또 바뀌면 이전 타이머 취소 (핵심) + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +}; diff --git a/src/advanced/shared/lib/useLocalStorage.ts b/src/advanced/shared/lib/useLocalStorage.ts new file mode 100644 index 000000000..85ede0ae2 --- /dev/null +++ b/src/advanced/shared/lib/useLocalStorage.ts @@ -0,0 +1,38 @@ +import { useState, useEffect } from "react"; + +/** + * 로컬 스토리지와 동기화되는 상태를 관리하는 커스텀 훅 (Shared Action) + * @param key 로컬 스토리지 키 + * @param initialValue 초기값 + */ +export const useLocalStorage = ( + key: string, + initialValue: T +): [T, React.Dispatch>] => { + // 1. 초기화 (Read Action): 마운트 시 한 번만 실행 + const [storedValue, setStoredValue] = useState(() => { + try { + if (typeof window === "undefined") { + return initialValue; + } + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(`Error reading localStorage key "${key}":`, error); + return initialValue; + } + }); + + // 2. 동기화 (Write Action): 값이 변경될 때마다 실행 + useEffect(() => { + try { + if (typeof window !== "undefined") { + window.localStorage.setItem(key, JSON.stringify(storedValue)); + } + } catch (error) { + console.error(`Error saving localStorage key "${key}":`, error); + } + }, [key, storedValue]); + + return [storedValue, setStoredValue]; +}; \ No newline at end of file diff --git a/src/advanced/shared/lib/useNotificationSystem.ts b/src/advanced/shared/lib/useNotificationSystem.ts new file mode 100644 index 000000000..8f3c907e6 --- /dev/null +++ b/src/advanced/shared/lib/useNotificationSystem.ts @@ -0,0 +1,24 @@ +import { useState, useCallback } from "react"; +import { Notification } from "../model/types"; + +export const useNotificationSystem = () => { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); + + const removeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return { notifications, addNotification, removeNotification }; +}; \ No newline at end of file diff --git a/src/advanced/shared/model/types.ts b/src/advanced/shared/model/types.ts new file mode 100644 index 000000000..9f97fcbb6 --- /dev/null +++ b/src/advanced/shared/model/types.ts @@ -0,0 +1,5 @@ +export interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} diff --git a/src/advanced/shared/ui/NotificationSystem.tsx b/src/advanced/shared/ui/NotificationSystem.tsx new file mode 100644 index 000000000..c85d2b90a --- /dev/null +++ b/src/advanced/shared/ui/NotificationSystem.tsx @@ -0,0 +1,59 @@ +// Global State +import { useNotificationStore } from "../lib/notificationStore"; + +// Model +import { Notification } from "../model/types"; + +// 알림 타입별 배경색 매핑 +const NOTIFICATION_STYLES: Record = { + error: "bg-red-600", + warning: "bg-yellow-600", + success: "bg-green-600", +}; + +/** + * 전역 알림 메시지를 렌더링하는 순수 UI 컴포넌트 (Smart Component) + * Zustand Store를 구독하여 알림 목록을 표시하고 닫기 이벤트를 처리합니다. + */ +export const NotificationSystem = () => { + // Store 구독 + const notifications = useNotificationStore((state) => state.notifications); + const removeNotification = useNotificationStore((state) => state.removeNotification); + + // 알림이 없으면 렌더링하지 않음 + if (notifications.length === 0) return null; + + return ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ); +}; \ No newline at end of file diff --git a/src/advanced/widgets/AdminDashboard/ui/CouponListGrid.tsx b/src/advanced/widgets/AdminDashboard/ui/CouponListGrid.tsx new file mode 100644 index 000000000..4f99d3bd6 --- /dev/null +++ b/src/advanced/widgets/AdminDashboard/ui/CouponListGrid.tsx @@ -0,0 +1,46 @@ +import { Coupon } from "../../../entities/coupon/model/types"; + +interface Props { + coupons: Coupon[]; + onDelete: (code: string) => void; + onAddClick: () => void; +} + +export const CouponListGrid = ({ coupons, onDelete, onAddClick }: Props) => { + return ( +
+ {coupons.map((coupon) => ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} + + {/* 추가 버튼 카드 */} +
+ +
+
+ ); +}; \ No newline at end of file diff --git a/src/advanced/widgets/AdminDashboard/ui/ProductListTable.tsx b/src/advanced/widgets/AdminDashboard/ui/ProductListTable.tsx new file mode 100644 index 000000000..cccb459fd --- /dev/null +++ b/src/advanced/widgets/AdminDashboard/ui/ProductListTable.tsx @@ -0,0 +1,57 @@ +import { ProductWithUI } from "../../../entities/product/model/types"; +import { formatCurrency } from "../../../shared/lib/format"; + +interface Props { + products: ProductWithUI[]; + onEdit: (product: ProductWithUI) => void; + onDelete: (id: string) => void; +} + +export const ProductListTable = ({ products, onEdit, onDelete }: Props) => { + return ( +
+ + + + {["상품명", "가격", "재고", "설명", "작업"].map((header) => ( + + ))} + + + + {products.map((product) => ( + + + + + + + + ))} + +
+ {header} +
+ {product.name} + {product.isRecommended && (BEST)} + + {formatCurrency(product.price)} + + 10 ? "bg-green-100 text-green-800" : product.stock > 0 ? "bg-yellow-100 text-yellow-800" : "bg-red-100 text-red-800" + }`}> + {product.stock}개 + + + {product.description || "-"} + + + +
+
+ ); +}; \ No newline at end of file diff --git a/src/advanced/widgets/AdminDashboard/ui/index.tsx b/src/advanced/widgets/AdminDashboard/ui/index.tsx new file mode 100644 index 000000000..d6baf5060 --- /dev/null +++ b/src/advanced/widgets/AdminDashboard/ui/index.tsx @@ -0,0 +1,262 @@ +import { useState } from "react"; + +// Shared Store & Lib +import { formatCurrency } from "../../../shared/lib/format"; + +// Entities (Model) +import { ProductWithUI } from "../../../entities/product/model/types"; +import { Coupon } from "../../../entities/coupon/model/types"; + +// Features (Hooks - Selectors) +// ✅ [핵심] 부모가 안 주니까, 내가 직접 훅을 불러서 씁니다. +import { useProducts } from "../../../features/product/model/useProducts"; +import { useCoupons } from "../../../features/coupon/model/useCoupons"; + +// Features (UI Forms) +import { ProductManagementForm } from "../../../features/product/model/ui/ProductManagementForm"; +import { CouponManagementForm } from "../../../features/coupon/ui/CouponManagementForm"; + + +export const AdminDashboard = () => { + // -------------------------------------------------------------------------- + // 1. Global State Connection (Hooks) + // -------------------------------------------------------------------------- + const { products, addProduct, updateProduct, deleteProduct } = useProducts(); + const { coupons, addCoupon, deleteCoupon } = useCoupons(); + + // -------------------------------------------------------------------------- + // 2. Local UI State + // -------------------------------------------------------------------------- + const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); + + // 상품 폼 상태 + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + + // 쿠폰 폼 상태 + const [showCouponForm, setShowCouponForm] = useState(false); + + // -------------------------------------------------------------------------- + // 3. Handlers + // -------------------------------------------------------------------------- + const handleProductSubmit = (productData: Omit) => { + if (editingProduct) { + updateProduct(editingProduct.id, productData); + } else { + addProduct(productData); + } + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleEditClick = (product: ProductWithUI) => { + setEditingProduct(product); + setShowProductForm(true); + }; + + const handleCouponSubmit = (newCoupon: Coupon) => { + addCoupon(newCoupon); + setShowCouponForm(false); + }; + + // -------------------------------------------------------------------------- + // 4. Render + // -------------------------------------------------------------------------- + return ( +
+
+

관리자 대시보드

+

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

+
+ +
+ +
+ + {activeTab === "products" ? ( +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
상품명가격재고설명작업
+ {product.name} + {product.isRecommended && (BEST)} + + {formatCurrency(product.price)} + + 10 + ? "bg-green-100 text-green-800" + : product.stock > 0 + ? "bg-yellow-100 text-yellow-800" + : "bg-red-100 text-red-800" + }`} + > + {product.stock}개 + + + {product.description || "-"} + + + +
+
+ + {showProductForm && ( + { + setShowProductForm(false); + setEditingProduct(null); + }} + /> + )} +
+ ) : ( +
+
+

쿠폰 관리

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

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} + +
+ +
+
+ + {showCouponForm && ( + setShowCouponForm(false)} + /> + )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/advanced/widgets/CartSidebar/ui/index.tsx b/src/advanced/widgets/CartSidebar/ui/index.tsx new file mode 100644 index 000000000..92cc18037 --- /dev/null +++ b/src/advanced/widgets/CartSidebar/ui/index.tsx @@ -0,0 +1,233 @@ +import { CartItem } from "../../../entities/cart/model/types"; +import { Coupon } from "../../../entities/coupon/model/types"; +import { + calculateItemTotal, + calculateCartTotal, +} from "../../../entities/cart/lib"; +import { formatCurrency } from "../../../shared/lib/format"; + +interface Props { + cart: CartItem[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + + // Actions + onUpdateQuantity: (productId: string, newQuantity: number) => void; + onRemoveFromCart: (productId: string) => void; + onApplyCoupon: (coupon: Coupon) => void; + onCouponSelected: (coupon: Coupon | null) => void; + onCompleteOrder: () => void; +} + +export const CartSidebar = ({ + cart, + coupons, + selectedCoupon, + onUpdateQuantity, + onRemoveFromCart, + onApplyCoupon, + onCouponSelected, + onCompleteOrder, +}: Props) => { + const { totalBeforeDiscount, totalAfterDiscount } = calculateCartTotal( + cart, + selectedCoupon + ); + + 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}% + + )} +

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

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

쿠폰 할인

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

결제 정보

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

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

+
+
+ + )} +
+ ); +}; diff --git a/src/advanced/widgets/Header/ui/index.tsx b/src/advanced/widgets/Header/ui/index.tsx new file mode 100644 index 000000000..88d2ba62d --- /dev/null +++ b/src/advanced/widgets/Header/ui/index.tsx @@ -0,0 +1,90 @@ +import { CartItem } from "../../../entities/cart/model/types"; + +interface Props { + // 1. 데이터 (Data) + cart: CartItem[]; + isAdmin: boolean; + searchTerm: string; + + // 2. 액션 (Event Handlers) -> 부모에게 위임 + onToggleAdmin: () => void; + onSearchChange: (value: string) => void; +} + +export const Header = ({ + cart, + isAdmin, + onToggleAdmin, + searchTerm, + onSearchChange, +}: Props) => { + // UI 로직: 장바구니 총 수량 계산 + // (이 로직은 '장바구니' 도메인에 가깝지만, 배지 표시용 UI 로직이므로 여기서 계산해도 무방합니다.) + // 추후 features/cart/lib 등으로 이동할 수도 있습니다. + const totalItemCount = cart.reduce((acc, item) => acc + item.quantity, 0); + + return ( +
+
+
+
+

SHOP

+ + {/* 검색창 영역 */} + {!isAdmin && ( +
+ onSearchChange(e.target.value)} + placeholder="상품 검색..." + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ )} +
+ + +
+
+
+ ); +}; diff --git a/src/advanced/widgets/ProductList/ui/index.tsx b/src/advanced/widgets/ProductList/ui/index.tsx new file mode 100644 index 000000000..21c03c67f --- /dev/null +++ b/src/advanced/widgets/ProductList/ui/index.tsx @@ -0,0 +1,59 @@ +import { ProductWithUI} from "../../../entities/product/model/types"; +import { CartItem } from "../../../entities/cart/model/types"; +import { ProductCard } from "../../../entities/product/ui/ProductCard"; + +interface Props { + // 화면에 보여줄 목록 (검색 필터링된 결과) + products: ProductWithUI[]; + + // 전체 상품 개수 (헤더 표시용: '총 5개 상품') + totalCount: number; + + // 재고 확인용 + cart: CartItem[]; + + // 액션 + onAddToCart: (product: ProductWithUI) => void; + + // 검색어 (결과 없음 메시지용) + searchTerm: string; +} + +export const ProductList = ({ + products, + totalCount, + cart, + onAddToCart, + searchTerm, +}: Props) => { + return ( +
+ {/* 위젯 헤더 */} +
+

전체 상품

+
총 {totalCount}개 상품
+
+ + {/* 검색 결과 없음 처리 */} + {products.length === 0 ? ( +
+

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

+
+ ) : ( + // 상품 목록 +
+ {products.map((product) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..eb65056d2 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,1118 +1,161 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { useState, useCallback } from "react"; +import { Notification } from "./shared/model/types"; -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} +// Widgets (UI 조립) +import { Header } from "./widgets/Header/ui"; +import { ProductList } from "./widgets/ProductList/ui"; +import { CartSidebar } from "./widgets/CartSidebar/ui"; +import { AdminDashboard } from "./widgets/AdminDashboard/ui"; -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +// Feature Hooks (비즈니스 로직) +import { useProducts } from "./features/product/model/useProducts"; +import { useCoupons } from "./features/coupon/model/useCoupons"; +import { useCart } from "./features/cart/model/useCart"; +import { useDebounce } from "./shared/lib/useDebounce"; 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); + // 1. Shared Logic: 알림 시스템 + // (테스트가 '알림 메시지 자동 사라짐'을 체크하므로 레거시 로직 유지) 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 addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); - 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]); + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); - 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); - }; + // 2. Feature Hooks: 도메인 로직 주입 + const { products, addProduct, updateProduct, deleteProduct } = + useProducts(addNotification); - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; + const { coupons, addCoupon, deleteCoupon } = useCoupons(addNotification); - 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 { + cart, + selectedCoupon, + setSelectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + completeOrder, + } = useCart(products, addNotification); - const totals = calculateCartTotal(); + // 3. UI State: 화면 제어 + const [isAdmin, setIsAdmin] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 500); + // 검색 필터링 로직 const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase())) ) : products; return (
+ {/* 알림 메시지 영역 (Shared UI) */} {notifications.length > 0 && (
- {notifications.map(notif => ( + {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" - /> -
- )} -
- -
-
-
+ + {/* 헤더 위젯 */} +
setIsAdmin(!isAdmin)} + searchTerm={searchTerm} + onSearchChange={setSearchTerm} + />
{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()}원 -
-
- - - -
-

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

-
-
- - )} -
+
+
)} @@ -1121,4 +164,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/basic/entities/cart/lib/index.ts b/src/basic/entities/cart/lib/index.ts new file mode 100644 index 000000000..fe16e7985 --- /dev/null +++ b/src/basic/entities/cart/lib/index.ts @@ -0,0 +1,81 @@ +import { CartItem } from "../../../entities/cart/model/types"; +import { Coupon } from "../../../entities/coupon/model/types"; + +/** + * 장바구니 아이템에 적용 가능한 최대 할인율을 계산합니다. + * (대량 구매 로직 포함) + */ +export const getMaxApplicableDiscount = ( + item: CartItem, + cart: CartItem[] +): number => { + const { discounts } = item.product; + const { quantity } = item; + + // 1. 상품 자체의 수량 할인 확인 + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + // 2. 장바구니 전체를 뒤져서 대량 구매 여부 확인 (비즈니스 룰) + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 추가 5% 할인, 최대 50% + } + + 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 +) => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + // calculateItemTotal을 재사용 + 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), + }; +}; diff --git a/src/basic/entities/cart/model/types.ts b/src/basic/entities/cart/model/types.ts new file mode 100644 index 000000000..8a6135ce3 --- /dev/null +++ b/src/basic/entities/cart/model/types.ts @@ -0,0 +1,6 @@ +import { ProductWithUI } from "../../product/model/types"; + +export interface CartItem { + product: ProductWithUI; + quantity: number; +} diff --git a/src/basic/entities/coupon/lib/index.ts b/src/basic/entities/coupon/lib/index.ts new file mode 100644 index 000000000..81e2946e4 --- /dev/null +++ b/src/basic/entities/coupon/lib/index.ts @@ -0,0 +1,17 @@ +import { Coupon } from "../../../entities/coupon/model/types"; +/** + * 쿠폰을 적용할 수 있는지 판단하는 순수 함수 + * @param coupon 적용하려는 쿠폰 + * @param currentTotalAmount 현재 장바구니 총액 (할인 전) + * @returns 적용 가능 여부 + */ +export const canApplyCoupon = ( + coupon: Coupon, + currentTotalAmount: number +): boolean => { + // 비즈니스 규칙: 정률 할인은 10,000원 이상일 때만 가능 + if (coupon.discountType === "percentage" && currentTotalAmount < 10000) { + return false; + } + return true; +}; diff --git a/src/basic/entities/coupon/model/types.ts b/src/basic/entities/coupon/model/types.ts new file mode 100644 index 000000000..5f5750118 --- /dev/null +++ b/src/basic/entities/coupon/model/types.ts @@ -0,0 +1,6 @@ +export interface Coupon { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +} diff --git a/src/basic/entities/product/lib/index.ts b/src/basic/entities/product/lib/index.ts new file mode 100644 index 000000000..f5d225906 --- /dev/null +++ b/src/basic/entities/product/lib/index.ts @@ -0,0 +1,18 @@ +import { CartItem } from "../../../entities/cart/model/types"; +import { Product } from "../../../entities/product/model/types"; + + +/** + * 상품의 재고가 얼마나 남았는지 계산합니다. + * @param product 확인할 상품 + * @param cart 현재 장바구니 상태 (전체 재고 확인을 위해 필요) + */ +export const getRemainingStock = ( + product: Product, + cart: CartItem[] +): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +}; diff --git a/src/basic/entities/product/model/types.ts b/src/basic/entities/product/model/types.ts new file mode 100644 index 000000000..00e53cf6e --- /dev/null +++ b/src/basic/entities/product/model/types.ts @@ -0,0 +1,17 @@ +export interface Discount { + quantity: number; + rate: number; +} + +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} diff --git a/src/basic/entities/product/ui/ProductCard.tsx b/src/basic/entities/product/ui/ProductCard.tsx new file mode 100644 index 000000000..14731f851 --- /dev/null +++ b/src/basic/entities/product/ui/ProductCard.tsx @@ -0,0 +1,107 @@ +import { ProductWithUI} from "../../../entities/product/model/types"; +import { CartItem } from "../../../entities/cart/model/types"; +import { formatCurrencyWithSymbol } from "../../../shared/lib/format"; +import { getRemainingStock } from "../lib"; + +interface Props { + product: ProductWithUI; + cart: CartItem[]; + onAddToCart: (product: ProductWithUI) => void; +} + +export const ProductCard = ({ product, cart, onAddToCart }: Props) => { + // 도메인 로직: 재고 계산 + const remainingStock = getRemainingStock(product, cart); + const isSoldOut = remainingStock <= 0; + + // UI 로직: 최대 할인율 계산 (배지용) + const maxDiscountRate = product.discounts.reduce( + (max, d) => Math.max(max, d.rate), + 0 + ); + + return ( +
+ {/* 1. 이미지 및 배지 영역 */} +
+
+ + + +
+ + {/* BEST 배지 */} + {product.isRecommended && ( + + BEST + + )} + + {/* 할인율 배지 */} + {product.discounts.length > 0 && ( + + ~{Math.round(maxDiscountRate * 100)}% + + )} +
+ + {/* 2. 상품 정보 영역 */} +
+

{product.name}

+ {product.description && ( +

+ {product.description} +

+ )} + + {/* 가격 및 할인 정책 */} +
+

+ {formatCurrencyWithSymbol(product.price)} +

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

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

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

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

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

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; diff --git a/src/basic/features/cart/model/useCart.ts b/src/basic/features/cart/model/useCart.ts new file mode 100644 index 000000000..e9ac02b42 --- /dev/null +++ b/src/basic/features/cart/model/useCart.ts @@ -0,0 +1,116 @@ +import { useState, useCallback } from "react"; +import { CartItem } from "../../../entities/cart/model/types"; +import { ProductWithUI } from "../../../entities/product/model/types"; +import { Coupon } from "../../../entities/coupon/model/types"; +import { getRemainingStock } from "../../../entities/product/lib"; +import { calculateCartTotal } from "../../../entities/cart/lib"; +import { useLocalStorage } from "../../../shared/lib/useLocalStorage"; +import { canApplyCoupon } from "../../../entities/coupon/lib"; + +export const useCart = ( + products: ProductWithUI[], + addNotification: (msg: string, type?: "error" | "success" | "warning") => void +) => { + const [cart, setCart] = useLocalStorage("cart", []); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + const addToCart = useCallback( + (product: ProductWithUI) => { + const remaining = getRemainingStock(product, cart); + if (remaining <= 0) { + addNotification("재고가 부족합니다!", "error"); + return; + } + + setCart((prev) => { + const existing = prev.find((item) => item.product.id === product.id); + if (existing) { + if (existing.quantity + 1 > product.stock) { + addNotification( + `재고는 ${product.stock}개까지만 있습니다.`, + "error" + ); + return prev; + } + return prev.map((item) => + item.product.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + ); + } + return [...prev, { product, quantity: 1 }]; + }); + addNotification("장바구니에 담았습니다", "success"); + }, + [cart, addNotification] + ); + + const removeFromCart = useCallback((productId: string) => { + setCart((prev) => prev.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((prev) => + prev.map((item) => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ) + ); + }, + [products, removeFromCart, addNotification] + ); + + const applyCoupon = useCallback( + (coupon: Coupon) => { + const { totalAfterDiscount } = calculateCartTotal(cart, null); + if (!canApplyCoupon(coupon, totalAfterDiscount)) { + addNotification( + "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.", + "error" + ); + return; + } + + setSelectedCoupon(coupon); + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + [cart, addNotification] + ); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification( + `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + "success" + ); + setCart([]); + setSelectedCoupon(null); + }, [addNotification]); + + return { + cart, + selectedCoupon, + setSelectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + completeOrder, + }; +}; diff --git a/src/basic/features/coupon/model/useCoupons.ts b/src/basic/features/coupon/model/useCoupons.ts new file mode 100644 index 000000000..659d98ed6 --- /dev/null +++ b/src/basic/features/coupon/model/useCoupons.ts @@ -0,0 +1,50 @@ +import { useCallback } from "react"; +import { Coupon } from "../../../entities/coupon/model/types"; +import { useLocalStorage } from "../../../shared/lib/useLocalStorage"; + +const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; + +export const useCoupons = ( + addNotification: (msg: string, type?: "error" | "success") => void +) => { + const [coupons, setCoupons] = useLocalStorage( + "coupons", + initialCoupons + ); + + 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)); + addNotification("쿠폰이 삭제되었습니다.", "success"); + }, + [addNotification] + ); + + return { coupons, addCoupon, deleteCoupon }; +}; diff --git a/src/basic/features/product/model/useProducts.ts b/src/basic/features/product/model/useProducts.ts new file mode 100644 index 000000000..916ab30c4 --- /dev/null +++ b/src/basic/features/product/model/useProducts.ts @@ -0,0 +1,81 @@ +import {useCallback } from "react"; +import { ProductWithUI} from "../../../entities/product/model/types"; +import { useLocalStorage } from "../../../shared/lib/useLocalStorage" + +// 초기 데이터 (실제로는 API에서 가져오거나 상수로 관리) +const initialProducts: ProductWithUI[] = [ + { + id: "p1", + name: "상품1", + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: "최고급 품질의 프리미엄 상품입니다.", + }, + { + id: "p2", + name: "상품2", + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, + }, + { + id: "p3", + name: "상품3", + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; + +export const useProducts = ( + addNotification: (msg: string, type?: "error" | "success") => void +) => { + const [products, setProducts] = useLocalStorage( + "products", + initialProducts + ); + + 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] + ); + + return { products, addProduct, updateProduct, deleteProduct }; +}; \ No newline at end of file diff --git a/src/basic/shared/lib/format.ts b/src/basic/shared/lib/format.ts new file mode 100644 index 000000000..a86bbaa09 --- /dev/null +++ b/src/basic/shared/lib/format.ts @@ -0,0 +1,20 @@ +/** + * 숫자를 한국 통화 형식으로 변환합니다. + * 예: 10000 -> "10,000원" + * @param value 금액 + * @returns 포맷팅된 문자열 + */ +export const formatCurrency = (value: number): string => { + // 순수 계산: 입력(number) -> 출력(string) + return `${value.toLocaleString()}원`; +}; + +/** + * 숫자를 ₩ 표시가 있는 통화 형식으로 변환합니다. (기존 코드의 비관리자용)- `src/shared/lib/useLocalStorage.ts` 생성: 로컬 스토리지 읽기/쓰기 로직을 제네릭 훅으로 캡슐화 +- Feature Hooks(`useCart`, `useProducts`, `useCoupons`)에서 중복되는 `useEffect` 및 저장소 접근 로직 제거 +- `JSON.parse` 에러 처리를 공통 훅 내부로 통합하여 안정성 확보 +- 비즈니스 로직에서 저장 매체(Implementation Detail)에 대한 의존성 제거 + */ +export const formatCurrencyWithSymbol = (value: number): string => { + return `₩${value.toLocaleString()}`; +}; diff --git a/src/basic/shared/lib/useDebounce.ts b/src/basic/shared/lib/useDebounce.ts new file mode 100644 index 000000000..599aee279 --- /dev/null +++ b/src/basic/shared/lib/useDebounce.ts @@ -0,0 +1,25 @@ +import { useState, useEffect } from "react"; + +/** + * 값이 변경되면 지정된 시간(delay)만큼 기다렸다가 업데이트하는 훅 + * @param value 관찰할 값 + * @param delay 지연 시간 (ms) + * @returns 디바운스된 값 + */ +export const useDebounce = (value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + // 1. 타이머 설정: delay 후에 상태 업데이트 + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + // 2. 클린업(Cleanup): 값이 또 바뀌면 이전 타이머 취소 (핵심) + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +}; diff --git a/src/basic/shared/lib/useLocalStorage.ts b/src/basic/shared/lib/useLocalStorage.ts new file mode 100644 index 000000000..85ede0ae2 --- /dev/null +++ b/src/basic/shared/lib/useLocalStorage.ts @@ -0,0 +1,38 @@ +import { useState, useEffect } from "react"; + +/** + * 로컬 스토리지와 동기화되는 상태를 관리하는 커스텀 훅 (Shared Action) + * @param key 로컬 스토리지 키 + * @param initialValue 초기값 + */ +export const useLocalStorage = ( + key: string, + initialValue: T +): [T, React.Dispatch>] => { + // 1. 초기화 (Read Action): 마운트 시 한 번만 실행 + const [storedValue, setStoredValue] = useState(() => { + try { + if (typeof window === "undefined") { + return initialValue; + } + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(`Error reading localStorage key "${key}":`, error); + return initialValue; + } + }); + + // 2. 동기화 (Write Action): 값이 변경될 때마다 실행 + useEffect(() => { + try { + if (typeof window !== "undefined") { + window.localStorage.setItem(key, JSON.stringify(storedValue)); + } + } catch (error) { + console.error(`Error saving localStorage key "${key}":`, error); + } + }, [key, storedValue]); + + return [storedValue, setStoredValue]; +}; \ No newline at end of file diff --git a/src/basic/shared/lib/useNotification.ts b/src/basic/shared/lib/useNotification.ts new file mode 100644 index 000000000..d2cadf375 --- /dev/null +++ b/src/basic/shared/lib/useNotification.ts @@ -0,0 +1,24 @@ +import { useState, useCallback } from "react"; +import { Notification } from "../model/types"; + +export const useNotification = () => { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }, + [] + ); + + const removeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return { notifications, addNotification, removeNotification }; +}; \ No newline at end of file diff --git a/src/basic/shared/model/types.ts b/src/basic/shared/model/types.ts new file mode 100644 index 000000000..9f97fcbb6 --- /dev/null +++ b/src/basic/shared/model/types.ts @@ -0,0 +1,5 @@ +export interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} diff --git a/src/basic/widgets/AdminDashboard/ui/index.tsx b/src/basic/widgets/AdminDashboard/ui/index.tsx new file mode 100644 index 000000000..a573fb722 --- /dev/null +++ b/src/basic/widgets/AdminDashboard/ui/index.tsx @@ -0,0 +1,653 @@ +import { useState } from "react"; +import { ProductWithUI} from "../../../entities/product/model/types"; +import { Coupon } from "../../../entities/coupon/model/types"; +import { formatCurrency } from "../../../shared/lib/format"; + +interface Props { + products: ProductWithUI[]; + coupons: Coupon[]; + onAddProduct: (product: Omit) => void; + onUpdateProduct: (id: string, product: Partial) => void; + onDeleteProduct: (id: string) => void; + onAddCoupon: (coupon: Coupon) => void; + onDeleteCoupon: (id: string) => void; + // 알림 기능 주입 추가 + onNotification: (message: string, type?: "error" | "success" | "warning") => void; +} + +export const AdminDashboard = ({ + products, + coupons, + onAddProduct, + onUpdateProduct, + onDeleteProduct, + onAddCoupon, + onDeleteCoupon, + onNotification, // Props로 받기 +}: Props) => { + const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + + const [productForm, setProductForm] = useState({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] as Array<{ quantity: number; rate: number }>, + isRecommended: false, + }); + + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState({ + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0, + }); + + const handleProductSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== "new") { + onUpdateProduct(editingProduct, productForm); + } else { + onAddProduct(productForm); + } + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + isRecommended: false, + }); + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onAddCoupon(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 || [], + isRecommended: product.isRecommended || false, + }); + setShowProductForm(true); + }; + + return ( +
+
+

관리자 대시보드

+

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

+
+ +
+ +
+ + {activeTab === "products" ? ( +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + {product.isRecommended && (BEST)} + + {formatCurrency(product.price)} + + 10 + ? "bg-green-100 text-green-800" + : product.stock > 0 + ? "bg-yellow-100 text-yellow-800" + : "bg-red-100 text-red-800" + }`} + > + {product.stock}개 + + + {product.description || "-"} + + + +
+
+ + {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" + /> +
+
+ + text 변경 (테스트 입력 호환) + value={productForm.price === 0 ? "" : productForm.price} + onChange={(e) => { + 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) { + // alert 대신 onNotification 사용 + onNotification("가격은 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 + /> +
+
+ + text 변경 + value={productForm.stock === 0 ? "" : productForm.stock} + onChange={(e) => { + 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) { + // alert 대신 onNotification 사용 + onNotification("재고는 0보다 커야 합니다", "error"); + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + onNotification("재고는 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" + 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" + 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) { + // alert 대신 onNotification 사용 (테스트 통과 핵심) + onNotification("할인율은 100%를 초과할 수 없습니다", "error"); + setCouponForm({ + ...couponForm, + discountValue: 100, + }); + } else if (value < 0) { + setCouponForm({ + ...couponForm, + discountValue: 0, + }); + } + } else { + if (value > 100000) { + // alert 대신 onNotification 사용 + onNotification("할인 금액은 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 + /> +
+
+
+ + +
+
+
+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/basic/widgets/CartSidebar/ui/index.tsx b/src/basic/widgets/CartSidebar/ui/index.tsx new file mode 100644 index 000000000..89e96b842 --- /dev/null +++ b/src/basic/widgets/CartSidebar/ui/index.tsx @@ -0,0 +1,230 @@ +import { CartItem } from "../../../entities/cart/model/types"; +import { Coupon } from "../../../entities/coupon/model/types"; +import { + calculateItemTotal, + calculateCartTotal, +} from "../../../entities/cart/lib"; +import { formatCurrency } from "../../../shared/lib/format"; + +interface Props { + cart: CartItem[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + + // Actions + onUpdateQuantity: (productId: string, newQuantity: number) => void; + onRemoveFromCart: (productId: string) => void; + onApplyCoupon: (coupon: Coupon) => void; + onCouponSelected: (coupon: Coupon | null) => void; + onCompleteOrder: () => void; +} + +export const CartSidebar = ({ + cart, + coupons, + selectedCoupon, + onUpdateQuantity, + onRemoveFromCart, + onApplyCoupon, + onCouponSelected, + onCompleteOrder, +}: Props) => { + const { totalBeforeDiscount, totalAfterDiscount } = calculateCartTotal( + cart, + selectedCoupon + ); + + 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}% + + )} +

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

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

쿠폰 할인

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

결제 정보

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

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

+
+
+ + )} +
+ ); +}; diff --git a/src/basic/widgets/Header/ui/index.tsx b/src/basic/widgets/Header/ui/index.tsx new file mode 100644 index 000000000..7e44a7d1f --- /dev/null +++ b/src/basic/widgets/Header/ui/index.tsx @@ -0,0 +1,89 @@ +import { CartItem } from "../../../entities/cart/model/types"; +interface Props { + // 1. 데이터 (Data) + cart: CartItem[]; + isAdmin: boolean; + searchTerm: string; + + // 2. 액션 (Event Handlers) -> 부모에게 위임 + onToggleAdmin: () => void; + onSearchChange: (value: string) => void; +} + +export const Header = ({ + cart, + isAdmin, + onToggleAdmin, + searchTerm, + onSearchChange, +}: Props) => { + // UI 로직: 장바구니 총 수량 계산 + // (이 로직은 '장바구니' 도메인에 가깝지만, 배지 표시용 UI 로직이므로 여기서 계산해도 무방합니다.) + // 추후 features/cart/lib 등으로 이동할 수도 있습니다. + const totalItemCount = cart.reduce((acc, item) => acc + item.quantity, 0); + + return ( +
+
+
+
+

SHOP

+ + {/* 검색창 영역 */} + {!isAdmin && ( +
+ onSearchChange(e.target.value)} + placeholder="상품 검색..." + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ )} +
+ + +
+
+
+ ); +}; diff --git a/src/basic/widgets/ProductList/ui/index.tsx b/src/basic/widgets/ProductList/ui/index.tsx new file mode 100644 index 000000000..21c03c67f --- /dev/null +++ b/src/basic/widgets/ProductList/ui/index.tsx @@ -0,0 +1,59 @@ +import { ProductWithUI} from "../../../entities/product/model/types"; +import { CartItem } from "../../../entities/cart/model/types"; +import { ProductCard } from "../../../entities/product/ui/ProductCard"; + +interface Props { + // 화면에 보여줄 목록 (검색 필터링된 결과) + products: ProductWithUI[]; + + // 전체 상품 개수 (헤더 표시용: '총 5개 상품') + totalCount: number; + + // 재고 확인용 + cart: CartItem[]; + + // 액션 + onAddToCart: (product: ProductWithUI) => void; + + // 검색어 (결과 없음 메시지용) + searchTerm: string; +} + +export const ProductList = ({ + products, + totalCount, + cart, + onAddToCart, + searchTerm, +}: Props) => { + return ( +
+ {/* 위젯 헤더 */} +
+

전체 상품

+
총 {totalCount}개 상품
+
+ + {/* 검색 결과 없음 처리 */} + {products.length === 0 ? ( +
+

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

+
+ ) : ( + // 상품 목록 +
+ {products.map((product) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/origin/App.tsx b/src/origin/App.tsx index a4369fe1d..4ef0350e8 100644 --- a/src/origin/App.tsx +++ b/src/origin/App.tsx @@ -1,5 +1,5 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { useState, useCallback, useEffect } from "react"; +import { CartItem, Coupon, Product } from "../types"; interface ProductWithUI extends Product { description?: string; @@ -9,65 +9,62 @@ interface ProductWithUI extends Product { interface Notification { id: string; message: string; - type: 'error' | 'success' | 'warning'; + type: "error" | "success" | "warning"; } // 초기 데이터 const initialProducts: ProductWithUI[] = [ { - id: 'p1', - name: '상품1', + id: "p1", + name: "상품1", price: 10000, stock: 20, discounts: [ { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } + { quantity: 20, rate: 0.2 }, ], - description: '최고급 품질의 프리미엄 상품입니다.' + description: "최고급 품질의 프리미엄 상품입니다.", }, { - id: 'p2', - name: '상품2', + id: "p2", + name: "상품2", price: 20000, stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true + discounts: [{ quantity: 10, rate: 0.15 }], + description: "다양한 기능을 갖춘 실용적인 상품입니다.", + isRecommended: true, }, { - id: 'p3', - name: '상품3', + id: "p3", + name: "상품3", price: 30000, stock: 20, discounts: [ { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } + { quantity: 30, rate: 0.25 }, ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } + description: "대용량과 고성능을 자랑하는 상품입니다.", + }, ]; const initialCoupons: Coupon[] = [ { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, }, { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, ]; const App = () => { - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); + const saved = localStorage.getItem("products"); if (saved) { try { return JSON.parse(saved); @@ -79,7 +76,7 @@ const App = () => { }); const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); + const saved = localStorage.getItem("cart"); if (saved) { try { return JSON.parse(saved); @@ -91,7 +88,7 @@ const App = () => { }); const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); + const saved = localStorage.getItem("coupons"); if (saved) { try { return JSON.parse(saved); @@ -106,59 +103,60 @@ const App = () => { const [isAdmin, setIsAdmin] = useState(false); const [notifications, setNotifications] = useState([]); const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [activeTab, setActiveTab] = useState<"products" | "coupons">( + "products" + ); const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); // Admin const [editingProduct, setEditingProduct] = useState(null); const [productForm, setProductForm] = useState({ - name: '', + name: "", price: 0, stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> + description: "", + discounts: [] as Array<{ quantity: number; rate: number }>, }); const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 + 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); + const product = products.find((p) => p.id === productId); if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; + 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 + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate : maxDiscount; }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); if (hasBulkPurchase) { return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 } - + return baseDiscount; }; @@ -166,7 +164,7 @@ const App = () => { const { price } = item.product; const { quantity } = item; const discount = getMaxApplicableDiscount(item); - + return Math.round(price * quantity * (1 - discount)); }; @@ -177,44 +175,51 @@ const App = () => { let totalBeforeDiscount = 0; let totalAfterDiscount = 0; - cart.forEach(item => { + 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); + if (selectedCoupon.discountType === "amount") { + totalAfterDiscount = Math.max( + 0, + totalAfterDiscount - selectedCoupon.discountValue + ); } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); } } return { totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) + totalAfterDiscount: Math.round(totalAfterDiscount), }; }; const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); + 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 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); @@ -222,18 +227,18 @@ const App = () => { }, [cart]); useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); + localStorage.setItem("products", JSON.stringify(products)); }, [products]); useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); + localStorage.setItem("coupons", JSON.stringify(coupons)); }, [coupons]); useEffect(() => { if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); + localStorage.setItem("cart", JSON.stringify(cart)); } else { - localStorage.removeItem('cart'); + localStorage.removeItem("cart"); } }, [cart]); @@ -244,139 +249,180 @@ const App = () => { return () => clearTimeout(timer); }, [searchTerm]); - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } + 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; - 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; + 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.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); + return [...prevCart, { product, quantity: 1 }]; + }); + + addNotification("장바구니에 담았습니다", "success"); + }, + [cart, addNotification, getRemainingStock] + ); const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + setCart((prevCart) => + prevCart.filter((item) => item.product.id !== productId) + ); }, []); - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } + 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 product = products.find((p) => p.id === productId); + if (!product) return; - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - 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; - } + 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; - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); + 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'); + 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 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 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 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 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') { + if (editingProduct && editingProduct !== "new") { updateProduct(editingProduct, productForm); setEditingProduct(null); } else { addProduct({ ...productForm, - discounts: productForm.discounts + discounts: productForm.discounts, }); } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], + }); setEditingProduct(null); setShowProductForm(false); }; @@ -385,10 +431,10 @@ const App = () => { e.preventDefault(); addCoupon(couponForm); setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 + name: "", + code: "", + discountType: "amount", + discountValue: 0, }); setShowCouponForm(false); }; @@ -399,8 +445,8 @@ const App = () => { name: product.name, price: product.price, stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] + description: product.description || "", + discounts: product.discounts || [], }); setShowProductForm(true); }; @@ -408,9 +454,15 @@ const App = () => { const totals = calculateCartTotal(); const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ? products.filter( + (product) => + product.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + (product.description && + product.description + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase())) ) : products; @@ -418,22 +470,38 @@ const App = () => {
{notifications.length > 0 && (
- {notifications.map(notif => ( + {notifications.map((notif) => (
{notif.message} -
@@ -462,17 +530,27 @@ const App = () => { {!isAdmin && (
- - + + {cart.length > 0 && ( @@ -490,27 +568,31 @@ const App = () => { {isAdmin ? (
-

관리자 대시보드

-

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

+

+ 관리자 대시보드 +

+

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

- {activeTab === 'products' ? ( + {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) }); + + + {(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, + }) } - }} - 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" + 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" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, stock: value === '' ? 0 : parseInt(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 === '') { - 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 }); + onChange={(e) => { + 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 + /> +
+
+ + + onChange={(e) => { + 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="%" - /> - % 할인 - -
- ))} + { + 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}% 할인`} - +
+

쿠폰 관리

+
+
+
+ {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 - /> -
-
-
+
-
-
- )} -
+ + {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 + /> +
+
+
+ + +
+
+
+ )} +
)}
@@ -897,137 +1169,221 @@ const App = () => { {/* 상품 목록 */}
-

전체 상품

+

+ 전체 상품 +

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

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

+

+ "{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)}

+ {filteredProducts.map((product) => { + const remainingStock = getRemainingStock(product); + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} {product.discounts.length > 0 && ( -

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

+ + ~ + {Math.max( + ...product.discounts.map((d) => d.rate) + ) * 100} + % + )}
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

+ + {/* 상품 정보 */} +
+

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

{item.product.name}

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

{Math.round(itemTotal).toLocaleString()}원 @@ -1053,27 +1411,33 @@ const App = () => { <>

-

쿠폰 할인

+

+ 쿠폰 할인 +

{coupons.length > 0 && ( - @@ -1085,27 +1449,40 @@ const App = () => {
상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 + + {totals.totalBeforeDiscount.toLocaleString()}원 +
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && ( + {totals.totalBeforeDiscount - + totals.totalAfterDiscount > + 0 && (
할인 금액 - -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원 + + - + {( + totals.totalBeforeDiscount - + totals.totalAfterDiscount + ).toLocaleString()} + 원 +
)}
결제 예정 금액 - {totals.totalAfterDiscount.toLocaleString()}원 + + {totals.totalAfterDiscount.toLocaleString()}원 +
- + - +

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

@@ -1121,4 +1498,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/refactoring(hint)/utils/hooks/useDebounce.ts b/src/refactoring(hint)/utils/hooks/useDebounce.ts index 53c8a3746..22b560647 100644 --- a/src/refactoring(hint)/utils/hooks/useDebounce.ts +++ b/src/refactoring(hint)/utils/hooks/useDebounce.ts @@ -1,4 +1,4 @@ -// TODO: 디바운스 Hook +/* TODO: 디바운스 Hook // 힌트: // 1. 값이 변경되어도 지정된 시간 동안 대기 // 2. 대기 시간 동안 값이 다시 변경되면 타이머 리셋 @@ -8,4 +8,5 @@ export function useDebounce(value: T, delay: number): T { // TODO: 구현 -} \ No newline at end of file +} + */ \ No newline at end of file diff --git a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts b/src/refactoring(hint)/utils/hooks/useLocalStorage.ts index 5dc72c501..2cc5003db 100644 --- a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts +++ b/src/refactoring(hint)/utils/hooks/useLocalStorage.ts @@ -1,4 +1,4 @@ -// TODO: LocalStorage Hook +/** TODO: LocalStorage Hook // 힌트: // 1. localStorage와 React state 동기화 // 2. 초기값 로드 시 에러 처리 @@ -12,4 +12,5 @@ export function useLocalStorage( initialValue: T ): [T, (value: T | ((val: T) => T)) => void] { // TODO: 구현 -} \ No newline at end of file +} + */ \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 5489e296e..720c0bef6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,17 @@ export interface CartItem { export interface Coupon { name: string; code: string; - discountType: 'amount' | 'percentage'; + discountType: "amount" | "percentage"; discountValue: number; } + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export interface Notification { + id: string; + message: string; + type: "error" | "success" | "warning"; +} diff --git a/tsconfig.app.json b/tsconfig.app.json index d739292ae..59d52892b 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -7,6 +7,7 @@ "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, + /* Bundler mode */ "moduleResolution": "bundler", @@ -23,5 +24,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] -} + "include": ["src"], + "exclude": ["src/refactoring(hint)"] +} \ No newline at end of file diff --git a/tsconfig.node.json b/tsconfig.node.json index 3afdd6e38..bccc62268 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -7,7 +7,8 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "strict": true, - "noEmit": true + "noEmit": true, + "types": ["node"] }, "include": ["vite.config.ts"] } diff --git a/vite.config.ts b/vite.config.ts index e6c4016bc..11923f8ca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,30 @@ -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"; +import { resolve } from 'path'; export default mergeConfig( defineConfig({ plugins: [react()], + build: { + rollupOptions: { + input: { + main: resolve(__dirname, 'index.advanced.html'), + }, + }, + }, + base: process.env.VITE_BASE_PATH || "/", }), defineTestConfig({ test: { globals: true, - environment: 'jsdom', - setupFiles: './src/setupTests.ts' + environment: "jsdom", + setupFiles: "./src/setupTests.ts", + environmentOptions: { + jsdom: { + resources: "usable", + }, + }, }, }) -) +); \ No newline at end of file