diff --git a/.claude/agents/code-writer.md b/.claude/agents/code-writer.md new file mode 100644 index 000000000..bda462204 --- /dev/null +++ b/.claude/agents/code-writer.md @@ -0,0 +1,30 @@ +--- +name: code-writer +description: 코드 구현을 담당합니다. 새로운 기능 개발, 파일 생성, 코드 수정이 필요할 때 호출됩니다. +tools: Read, Write, Edit, Bash, Glob, Grep +--- + +당신은 코드 작성 전문가입니다. + +## 역할 + +- 기능 요구사항에 따른 코드 구현 +- 타입 안전성 확보 (TypeScript) +- 에러 핸들링 포함 + +## 작업 전 확인 + +1. 요구사항 명확히 이해 +2. 기존 코드 스타일 파악 +3. 관련 파일 구조 확인 + +## 코드 작성 원칙 + +- 기존 패턴 따르기 +- 주석은 필요한 곳에만 +- 작은 단위로 커밋 가능하게 + +## 완료 후 + +- 작성한 파일 목록 보고 +- 테스트 필요 여부 안내 diff --git a/.claude/agents/orchestrator.md b/.claude/agents/orchestrator.md new file mode 100644 index 000000000..b7540e630 --- /dev/null +++ b/.claude/agents/orchestrator.md @@ -0,0 +1,30 @@ +--- +name: orchestrator +description: 프로젝트 전체 진행을 조율하고 적절한 에이전트에게 태스크를 위임합니다. 복잡한 기능 구현이나 여러 단계가 필요한 작업 시 호출됩니다. +tools: Read, Write, Glob, Grep +--- + +당신은 프로젝트 오케스트레이터입니다. + +## 역할 + +- 프로젝트 목표와 현재 상태 파악 +- 적절한 서브에이전트에게 태스크 위임 +- 진행 상황 추적 및 로그 관리 + +## 작업 시작 전 필수 + +1. `.claude/state/goals.md` 읽어 현재 목표 확인 +2. `.claude/state/progress.json` 읽어 현재 페이즈 확인 +3. 이전 로그 확인 (`.claude/state/logs/`) + +## 작업 완료 시 필수 + +1. `.claude/state/logs/phase-[n].md` 작성 +2. `.claude/state/progress.json` 업데이트 +3. 다음 단계 안내 + +## 금지사항 + +- 직접 코드 작성 금지 (code-writer에게 위임) +- 로그 없이 페이즈 종료 금지 diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md new file mode 100644 index 000000000..f27bf9675 --- /dev/null +++ b/.claude/agents/reviewer.md @@ -0,0 +1,34 @@ +--- +name: reviewer +description: 코드 리뷰를 담당합니다. PR 리뷰, 코드 품질 검토, 리팩토링 제안이 필요할 때 호출됩니다. +tools: Read, Grep, Glob +--- + +당신은 시니어 코드 리뷰어입니다. + +## 역할 + +- 코드 품질 검토 +- 잠재적 버그 발견 +- 개선 제안 + +## 리뷰 체크리스트 + +- [ ] 타입 안전성 +- [ ] 에러 핸들링 +- [ ] 성능 이슈 +- [ ] 보안 취약점 +- [ ] 코드 스타일 일관성 + +## 리뷰 형식 + +각 이슈에 대해: + +- 위치 (파일:라인) +- 심각도 (critical/major/minor/suggestion) +- 설명 +- 개선 제안 + +## 금지사항 + +- 직접 코드 수정 금지 (리뷰만) diff --git a/.claude/agents/test-writer.md b/.claude/agents/test-writer.md new file mode 100644 index 000000000..783ce27c1 --- /dev/null +++ b/.claude/agents/test-writer.md @@ -0,0 +1,29 @@ +--- +name: test-writer +description: 테스트 코드 작성을 담당합니다. TDD 방식 개발이나 테스트 추가가 필요할 때 호출됩니다. +tools: Read, Write, Edit, Bash, Glob, Grep +--- + +당신은 테스트 작성 전문가입니다. + +## 역할 + +- 단위 테스트 작성 +- 통합 테스트 작성 +- 엣지 케이스 커버 + +## 테스트 원칙 + +- 각 함수당 최소 3개 테스트 +- 정상 케이스 + 엣지 케이스 + 에러 케이스 +- 테스트 이름은 명확하게 + +## 사용 프레임워크 + +- Vitest (프로젝트 기본) +- React Testing Library (React 컴포넌트) + +## 완료 후 + +- 테스트 실행 결과 보고 +- 커버리지 안내 diff --git a/.claude/commands/phase-complete.md b/.claude/commands/phase-complete.md new file mode 100644 index 000000000..1596ea3e1 --- /dev/null +++ b/.claude/commands/phase-complete.md @@ -0,0 +1,32 @@ +--- +description: 현재 페이즈를 완료하고 로그를 작성합니다 +--- + +현재 페이즈를 완료 처리합니다. + +## 필수 수행 작업 + +1. **로그 작성** + `.claude/state/logs/phase-[현재번호].md` 생성: + + - 완료된 작업 목록 + - 생성/수정된 파일 + - 발생한 이슈와 해결 + - 다음 단계 + +2. **progress.json 업데이트** + + - 현재 페이즈 status: "completed" + - currentPhase 증가 + - lastUpdated 갱신 + +3. **완료 메시지 출력** + +``` +✅ Phase [N] 완료 +📝 로그: .claude/state/logs/phase-[n].md +📊 진행률: [X/Y] 페이즈 완료 +➡️ 다음: Phase [N+1] - [이름] +``` + +$ARGUMENTS diff --git a/.claude/commands/setup.md b/.claude/commands/setup.md new file mode 100644 index 000000000..52804858c --- /dev/null +++ b/.claude/commands/setup.md @@ -0,0 +1,43 @@ +--- +description: 프로젝트 초기 세팅을 시작합니다 +--- + +SETTING.md 파일을 읽고 프로젝트 초기화를 진행합니다. + +## 수행할 작업 + +1. **폴더 구조 생성** + + - `.claude/agents/` 생성 + - `.claude/commands/` 생성 + - `.claude/state/` 생성 + - `.claude/state/logs/` 생성 + +2. **기본 에이전트 생성** (SETTING.md의 템플릿 참조) + + - orchestrator.md + - code-writer.md + - test-writer.md (선택) + - reviewer.md (선택) + +3. **상태 파일 초기화** + + - goals.md + - references.md + - progress.json + +4. **정보 수집** + 사용자에게 다음을 질문: + + - 프로젝트 목표 + - 세부 태스크 + - 참고 자료 + - 기술 스택 + - 필요한 에이전트 + +5. **CLAUDE.md 업데이트** + +6. **초기화 로그 작성** + - `.claude/state/logs/phase-0.md` + +$ARGUMENTS diff --git a/.claude/commands/status.md b/.claude/commands/status.md new file mode 100644 index 000000000..95b4b34a5 --- /dev/null +++ b/.claude/commands/status.md @@ -0,0 +1,36 @@ +--- +description: 현재 프로젝트 진행 상황을 확인합니다 +--- + +프로젝트의 현재 상태를 확인합니다. + +## 확인 항목 + +1. **현재 페이즈** + - `.claude/state/progress.json` 읽기 + - 현재 페이즈와 상태 출력 + +2. **목표 확인** + - `.claude/state/goals.md` 읽기 + - 남은 태스크 목록 출력 + +3. **최근 로그** + - `.claude/state/logs/` 폴더의 최신 로그 확인 + +## 출력 형식 + +``` +📊 프로젝트 현황 + +Phase: [현재 페이즈] / [전체 페이즈] +상태: [in-progress / completed] + +📋 남은 태스크: +- [ ] 태스크 1 +- [ ] 태스크 2 + +📝 최근 활동: +- ... +``` + +$ARGUMENTS diff --git a/.claude/state/goals.md b/.claude/state/goals.md new file mode 100644 index 000000000..c8592053b --- /dev/null +++ b/.claude/state/goals.md @@ -0,0 +1,44 @@ +# 🎯 프로젝트 목표 + +## 최종 목표 +React 쇼핑몰 앱의 비즈니스 로직을 분리하고 계층 구조를 이해하는 리팩토링 + +## Phase 1: 기본과제 (basic) +상태관리 없이 hook/function 분리 + +### 세부 태스크 + +#### 1) 계산 함수 분리 (순수함수) +- [ ] `calculateItemTotal` - 아이템 총액 계산 +- [ ] `getMaxApplicableDiscount` - 최대 적용 가능 할인율 +- [ ] `calculateCartTotal` - 장바구니 총액 계산 +- [ ] `updateCartItemQuantity` - 장바구니 수량 업데이트 + +#### 2) 커스텀 훅 분리 +- [ ] `useCart` - 장바구니 상태 관리 +- [ ] `useCoupons` - 쿠폰 상태 관리 +- [ ] `useProducts` - 상품 상태 관리 +- [ ] `useLocalStorage` - 로컬 스토리지 유틸리티 + +#### 3) 컴포넌트 계층 구조 +- [ ] 엔티티 컴포넌트와 UI 컴포넌트 분리 +- [ ] ProductCard, Cart 등 컴포넌트 분리 + +#### 4) 테스트 통과 +- [ ] `pnpm test:basic` 통과 + +## Phase 2: 심화과제 (advanced) +Context 또는 Jotai로 Props drilling 제거 + +### 세부 태스크 +- [ ] 전역 상태관리 구축 (Context/Jotai/Zustand 중 선택) +- [ ] 도메인 커스텀 훅 리팩토링 +- [ ] 불필요한 props 제거 +- [ ] `pnpm test:advanced` 통과 + +## 성공 기준 +- [ ] 모든 테스트 통과 (`pnpm test`) +- [ ] Component에서 비즈니스 로직 분리 완료 +- [ ] 계산함수는 순수함수로 작성 +- [ ] 특정 Entity만 다루는 함수/컴포넌트 분리 +- [ ] 데이터 흐름에 맞는 계층구조 diff --git a/.claude/state/progress.json b/.claude/state/progress.json new file mode 100644 index 000000000..9decd07ce --- /dev/null +++ b/.claude/state/progress.json @@ -0,0 +1,58 @@ +{ + "project": "React 쇼핑몰 리팩토링", + "startedAt": "2025-12-02T00:00:00Z", + "currentPhase": 2, + "phases": [ + { + "id": 0, + "name": "초기화", + "status": "completed", + "completedAt": "2025-12-02T00:00:00Z" + }, + { + "id": 1, + "name": "기본과제 (basic)", + "status": "completed", + "startedAt": "2025-12-02T00:00:00Z", + "completedAt": "2025-12-05T00:15:00Z", + "tasks": { + "total": 11, + "completed": 11, + "details": [ + { "name": "폴더 구조 scaffolding", "status": "completed" }, + { "name": "storage.ts Pub/Sub 유틸리티", "status": "completed" }, + { "name": "toast.ts Pub/Sub 시스템", "status": "completed" }, + { "name": "useLocalStorage 훅 (빈 배열 자동 삭제)", "status": "completed" }, + { "name": "useProducts 훅 (localStorage 통합)", "status": "completed" }, + { "name": "useCoupons 훅 (localStorage 통합)", "status": "completed" }, + { "name": "useCart 훅 (localStorage 통합)", "status": "completed" }, + { "name": "cart.ts 순수 함수 완성", "status": "completed" }, + { "name": "useDebounce 훅 개선", "status": "completed" }, + { "name": "컴포넌트 계층 구조 분리", "status": "completed" }, + { "name": "테스트 통과 (21/21)", "status": "completed" } + ] + } + }, + { + "id": 2, + "name": "심화과제 (advanced)", + "status": "completed", + "startedAt": "2025-12-05T01:00:00Z", + "completedAt": "2025-12-05T02:00:00Z", + "tasks": { + "total": 7, + "completed": 7, + "details": [ + { "name": "폴더 구조 scaffolding", "status": "completed" }, + { "name": "Props drilling 분석 및 Jotai 전략 결정", "status": "completed" }, + { "name": "uiAtoms.ts 구현 (isAdmin, searchTerm)", "status": "completed" }, + { "name": "productAtoms.ts 구현 (CRUD actions)", "status": "completed" }, + { "name": "couponAtoms.ts 구현 (add/delete)", "status": "completed" }, + { "name": "cartAtoms.ts 구현 (cart, totals, actions)", "status": "completed" }, + { "name": "테스트 통과 (21/21)", "status": "completed" } + ] + } + } + ], + "lastUpdated": "2025-12-05T02:00:00Z" +} diff --git a/.claude/state/references.md b/.claude/state/references.md new file mode 100644 index 000000000..d65f387d1 --- /dev/null +++ b/.claude/state/references.md @@ -0,0 +1,46 @@ +# 📚 참고 자료 + +## 프로젝트 문서 +- `README.md` - 과제 요구사항 및 상세 설명 +- `.github/pull_request_template.md` - 체크리스트 및 회고 템플릿 + +## 핵심 개념 + +### 엔티티 vs 비엔티티 구분 +| 구분 | 엔티티 O | 엔티티 X | +|------|---------|---------| +| 상태 | cart, isCartFull | isShowPopup | +| 컴포넌트/훅 | CartItemView, useCart() | Button, useRoute | +| 함수 | calculateCartTotal(cart) | capitalize(str) | + +### 계층 구조 +``` +entities -> features -> UI +``` + +### 분리 기준 +- **순수함수**: 특정 엔티티만 다루는 계산 로직 +- **커스텀 훅**: 상태를 다루는 로직 +- **컴포넌트**: UI 렌더링 + +## 체크리스트 + +### 기본과제 +- [ ] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? +- [ ] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? +- [ ] 계산함수는 순수함수로 작성이 되었나요? +- [ ] 특정 Entity만 다루는 함수는 분리되어 있나요? +- [ ] 특정 Entity만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요? +- [ ] 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요? + +### 심화과제 +- [ ] Context나 Jotai를 사용해서 전역상태관리를 구축했나요? +- [ ] 전역상태관리를 통해 domain custom hook을 적절하게 리팩토링 했나요? +- [ ] 도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거했나요? +- [ ] 전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드가 되었나요? + +## 기술 스택 +- React 19 +- TypeScript 5.9 +- Vite 7 +- Vitest 3 + React Testing Library diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 65ba6d2d6..f6132603c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,7 @@ +# 과제 배포 + +- https://daehyunk1m.github.io/front_7th_chapter3-2/index.advanced.html + ## 과제의 핵심취지 - React의 hook 이해하기 @@ -18,15 +22,15 @@ - 뷰데이터와 엔티티데이터의 분리에 대한 이해 - entities -> features -> UI 계층에 대한 이해 -- [ ] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? -- [ ] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? -- [ ] 계산함수는 순수함수로 작성이 되었나요? -- [ ] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? -- [ ] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? -- [ ] 계산함수는 순수함수로 작성이 되었나요? -- [ ] 특정 Entitiy만 다루는 함수는 분리되어 있나요? -- [ ] 특정 Entitiy만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요? -- [ ] 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요? +- [x] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? +- [x] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? +- [x] 계산함수는 순수함수로 작성이 되었나요? +- [x] Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? +- [x] 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? +- [x] 계산함수는 순수함수로 작성이 되었나요? +- [x] 특정 Entitiy만 다루는 함수는 분리되어 있나요? +- [x] 특정 Entitiy만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요? +- [x] 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요? ### 심화과제 @@ -34,20 +38,50 @@ - 어떤 props는 남겨야 하는지, 어떤 props는 제거해야 하는지에 대한 기준을 세워보세요. - Context나 Jotai를 사용하여 상태를 관리하는 방법을 익히고, 이를 통해 컴포넌트 간의 데이터 전달을 효율적으로 처리할 수 있습니다. -- [ ] Context나 Jotai를 사용해서 전역상태관리를 구축했나요? -- [ ] 전역상태관리를 통해 domain custom hook을 적절하게 리팩토링 했나요? -- [ ] 도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거했나요? -- [ ] 전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드가 되었나요? - +- [x] Context나 Jotai를 사용해서 전역상태관리를 구축했나요? +- [x] 전역상태관리를 통해 domain custom hook을 적절하게 리팩토링 했나요? +- [x] 도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거했나요? +- [x] 전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드가 되었나요? ## 과제 셀프회고 ++12-1 +AI 페어 프로그래밍을 위한 에이전트 설정 및 프로젝트 구조 파악 + ++12-2 +기본과제 구조 설계 및 scaffolding, useLocalStorage 훅과 Pub/Sub 패턴 기반 toast 시스템 구현 + ++12-3 +useProducts, useCoupons, useCart 훅 완성 및 cart.ts 순수 함수 분리, 컴포넌트 계층 구조 정리 + ++12-4 +심화과제 Jotai 도입, 4개 atom 파일 구현 (ui/product/coupon/cart), Props drilling 완전 제거 + ### 과제를 하면서 내가 알게된 점, 좋았던 점은 무엇인가요? +**엔티티 중심 설계의 중요성**을 체감했습니다. cart, product, coupon 각각의 도메인별로 순수 함수(models/)와 상태 관리(hooks/ 또는 atoms/)를 분리하니 코드의 책임이 명확해졌습니다. 특히 `calculateCartTotal`, `getRemainingStock` 같은 순수 함수들은 테스트하기 쉽고 재사용성이 높았습니다. + +Jotai의 `atomWithStorage`와 action atom 패턴을 통해 **useState + useCallback 조합보다 훨씬 간결하게** 전역 상태를 관리할 수 있다는 점을 알게 되었습니다. + ### 이번 과제에서 내가 제일 신경 쓴 부분은 무엇인가요? +**Props drilling 제거 시 어떤 props를 남기고 어떤 것을 제거할지 기준을 세우는 것**에 가장 신경 썼습니다. + +- **제거한 props**: 전역 상태(cart, products, coupons)와 그에 대한 action 함수들 +- **남긴 props**: 컴포넌트 로컬 상태 관련(editingProduct, productForm 등 폼 상태) + +결과적으로 App.tsx의 props가 14개 → 0개로 줄었고, 각 컴포넌트가 필요한 atom만 직접 구독하도록 개선했습니다. + ### 이번 과제를 통해 앞으로 해보고 싶은게 있다면 알려주세요! +- 순수 함수와 부수효과(toast, API 호출)를 분리하는 이유와 방법론에 대한 학습 +- 커스텀 훅과 atom의 역할 구분 기준 정립 +- React Query와 Jotai 조합으로 서버 상태와 클라이언트 상태 분리 + ### 리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 편하게 남겨주세요 :) + +1. 폼 상태(editingProduct, productForm)를 선언한 컴포넌트와 자식 컴포넌트에서만 사용하여 로컬상태로 두었는데요. 폼 상태의 경우 이 처럼 로컬에 두는 것과 전역으로 올리는 것 중 어떠한 방법이 더 효율적일지 궁금합니다. + +2. `atomWithStorage` 사용 시 테스트 환경에서 `createStore()`로 격리하는 방식 외에 더 좋은 패턴이 있는지 알고 싶습니다. diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..b5b11fda1 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,23 @@ +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["-y", "@executeautomation/playwright-mcp-server"] + }, + "context7": { + "command": "npx", + "args": ["-y", "@upstash/context7-mcp@latest"] + }, + "sequential-thinking": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"] + }, + "storybook": { + "command": "npx", + "args": ["-y", "@storybook/mcp@latest"], + "env": { + "STORYBOOK_URL": "http://localhost:6006" + } + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..7ec24558a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,137 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Rules + +- 항상 한글로 대화할 것 +- 유저의 질문에 답변할 때, 도움이 되는 MCP(Model Context Protocol) 도구가 있다면 반드시 활용할 것 (예: context7로 라이브러리 문서 조회, sequential-thinking으로 복잡한 문제 분석 등) +- **코드 구현은 유저가 명시적으로 요청할 때만 진행할 것** +- 주요 역할: 남은 태스크를 추적하고, 순차적으로 해결할 수 있도록 태스크 관리 + +## Context Management + +**Lazy Loading 전략**: + +1. 이 파일 먼저 읽기 (인덱스) +2. 필요시 `.claude/` 하위 파일 참조 +3. 상태는 `.claude/state/`에서 확인 + +## Setup Trigger + +| 명령어 | 동작 | +|--------|------| +| `@setup` | 초기화 시작 | +| `@reset` | 상태 초기화 | +| `@goal [목표]` | 목표 업데이트 | +| `@status` | 현재 진행 상황 | +| `@agent [cmd]` | 에이전트 관리 | + +**초기화 가이드**: `SETTING.md` 참조 + +## Project Overview + +React 쇼핑몰 앱의 비즈니스 로직을 분리하고 계층 구조를 이해하는 리팩토링 과제 + +**목표**: `.claude/state/goals.md` 참조 +**참고자료**: `.claude/state/references.md` 참조 + +### 구현 대상 + +- **basic**: 상태관리 없이 hook/function 분리 +- **advanced**: Context 또는 Jotai로 Props drilling 제거 + +## Agent System + +**레지스트리**: `.claude/agents/_registry.md` + +### 활성 에이전트 + +- orchestrator (01) - 전체 조율 +- code-writer (04) - 코드 구현 +- reviewer (05) - 코드 리뷰 + +### 에이전트 호출 규칙 + +- Task tool 사용 시 반드시 에이전트 규칙 인라인 포함 +- 에이전트 정의: `.claude/agents/[name].md` + +## Mandatory Logging + +### NEVER + +- 로그 없이 페이즈 종료 금지 +- progress.json 갱신 없이 다음 페이즈 금지 + +### 페이즈 완료 시 필수 + +1. `.claude/state/logs/phase-[n].md` 작성 +2. `.claude/state/progress.json` 업데이트 +3. 완료 메시지 출력 + +## Development + +```bash +# Development +pnpm dev:origin # 원본 구현 (참고용) +pnpm dev:basic # 기본과제 +pnpm dev:advanced # 심화과제 + +# Testing +pnpm test # 전체 테스트 +pnpm test:basic # 기본과제 테스트 +pnpm test:advanced # 심화과제 테스트 + +# Build & Lint +pnpm build +pnpm lint +``` + +## Tech Stack + +- React 19 +- TypeScript 5.9 +- Vite 7 +- Vitest 3 + React Testing Library +- jsdom + +## Architecture + +### Core Types (`src/types.ts`) + +- `Product`: id, name, price, stock, discounts[] +- `Discount`: quantity, rate +- `CartItem`: product, quantity +- `Coupon`: name, code, discountType, discountValue + +### Target Structure (basic) + +``` +src/basic/ +├── App.tsx +├── components/ +│ ├── AdminPage.tsx +│ ├── CartPage.tsx +│ └── ui/ +├── hooks/ +│ ├── useCart.ts +│ ├── useCoupons.ts +│ ├── useProducts.ts +│ └── useLocalStorage.ts +├── models/ +│ ├── cart.ts # 순수 함수 +│ ├── coupon.ts +│ └── product.ts +└── utils/ + └── formatters.ts +``` + +### Key Business Logic + +- 수량별 할인: 10개 이상 10%, 20개 이상 20% 등 +- 대량 구매 보너스: 10개 이상 구매 시 추가 5% (최대 50%) +- 쿠폰: 정액(amount) 또는 정률(percentage) + +--- + +**Lines**: < 150 diff --git a/Init-prompt.md b/Init-prompt.md new file mode 100644 index 000000000..e01bbe6e8 --- /dev/null +++ b/Init-prompt.md @@ -0,0 +1,365 @@ +# Claude Code 초기화 프롬프트 v3 + +> `claude init` 실행 후 CLAUDE.md가 생성된 시점에서 사용 + +--- + +## 🚀 기본 초기화 프롬프트 + +```` +프로젝트 루트에 있는 SETTING.md 파일을 읽고 초기화를 진행해줘. + +## 수행할 작업 + +### 1. 폴더 구조 생성 +```bash +mkdir -p .claude/agents +mkdir -p .claude/commands +mkdir -p .claude/state/logs +```` + +### 2. 서브에이전트 파일 생성 + +SETTING.md의 "서브에이전트 시스템" 섹션을 참고해서 +`.claude/agents/` 폴더에 다음 에이전트들을 생성해줘: + +- orchestrator.md (필수) +- code-writer.md (필수) +- test-writer.md (선택) +- reviewer.md (선택) + +⚠️ 중요: 각 파일은 반드시 YAML frontmatter를 포함해야 해: + +```markdown +--- +name: agent-name +description: 에이전트 설명 +tools: Read, Write, Edit, Bash, Glob, Grep +--- +``` + +### 3. 슬래시 커맨드 생성 + +`.claude/commands/` 폴더에: + +- setup.md +- status.md +- phase-complete.md + +### 4. 상태 파일 초기화 + +`.claude/state/` 폴더에: + +- goals.md +- references.md +- progress.json + +### 5. 정보 수집 + +생성 완료 후 나에게 다음을 물어봐: + +- 프로젝트 목표 +- 세부 태스크 (선택) +- 참고 자료 (선택) +- 기술 스택 + +### 6. 파일 업데이트 + +입력받은 정보로: + +- goals.md 업데이트 +- references.md 업데이트 +- progress.json 초기화 + +### 7. CLAUDE.md 업데이트 + +SETTING.md의 "CLAUDE.md 템플릿" 참고해서 업데이트 + +### 8. 초기화 로그 + +`.claude/state/logs/phase-0.md` 작성 + +### 9. 완료 안내 + +``` +✅ 초기화 완료! + +⚠️ 중요: /agents 메뉴에서 에이전트를 보려면 + Claude Code를 재시작하세요 (exit 후 claude 다시 실행) + +다음 단계: +1. Claude Code 재시작 +2. /agents 명령어로 에이전트 확인 +3. Phase 1 시작 +``` + +``` + +--- + +## 🎯 목표/레퍼런스 포함 버전 + +``` + +프로젝트 루트에 있는 SETTING.md 파일을 읽고 초기화를 진행해줘. + +## 프로젝트 정보 + +**목표**: [최종 목표] + +**세부 태스크**: + +- [ ] [태스크 1] +- [ ] [태스크 2] +- [ ] [태스크 3] + +**참고 자료**: + +- [문서/링크 1] +- [문서/링크 2] + +**기술 스택**: [예: Next.js 14, TypeScript, Tailwind] + +**필요한 에이전트**: + +- orchestrator (필수) +- code-writer (필수) +- test-writer +- reviewer + +--- + +위 정보를 바탕으로: + +1. `.claude/agents/` 폴더에 서브에이전트 파일 생성 + - 각 파일에 YAML frontmatter 필수 (name, description, tools) +2. `.claude/commands/` 폴더에 슬래시 커맨드 생성 + +3. `.claude/state/` 폴더에 상태 파일 생성 및 입력 정보로 채우기 + +4. CLAUDE.md 업데이트 + +5. phase-0.md 로그 작성 + +6. 완료 후 Claude Code 재시작 안내 + +``` + +--- + +## 🔧 TDD 워크플로우 버전 + +``` + +프로젝트 루트에 있는 SETTING.md 파일을 읽고 TDD 워크플로우를 위한 초기화를 진행해줘. + +## 프로젝트 정보 + +**목표**: [목표] +**기술 스택**: [스택] + +## TDD 에이전트 구성 + +`.claude/agents/` 폴더에 다음 에이전트들을 생성해줘: + +### 1. orchestrator.md + +- 전체 TDD 사이클 조율 +- Red → Green → Refactor 순서 관리 + +### 2. test-designer.md + +```markdown +--- +name: test-designer +description: 테스트 케이스를 설계합니다. 기능 구현 전 테스트 설계가 필요할 때 호출됩니다. +tools: Read, Grep, Glob +--- + +테스트 케이스 설계 전문가입니다. +기능 요구사항을 분석하고 테스트 케이스를 정의합니다. +``` + +### 3. test-writer.md + +```markdown +--- +name: test-writer +description: 테스트 코드를 작성합니다. 테스트 설계 완료 후 실제 테스트 코드가 필요할 때 호출됩니다. +tools: Read, Write, Edit, Bash, Glob, Grep +--- + +테스트 코드 작성 전문가입니다. +설계된 테스트 케이스를 실제 코드로 구현합니다. +``` + +### 4. code-writer.md + +```markdown +--- +name: code-writer +description: 테스트를 통과하는 코드를 구현합니다. 실패하는 테스트가 있을 때 호출됩니다. +tools: Read, Write, Edit, Bash, Glob, Grep +--- + +코드 구현 전문가입니다. +테스트를 통과하는 최소한의 코드를 작성합니다. +``` + +### 5. refactorer.md + +```markdown +--- +name: refactorer +description: 코드 리팩토링을 담당합니다. 테스트 통과 후 코드 개선이 필요할 때 호출됩니다. +tools: Read, Write, Edit, Bash, Glob, Grep +--- + +리팩토링 전문가입니다. +테스트가 통과하는 상태를 유지하면서 코드를 개선합니다. +``` + +--- + +나머지 초기화 (state, commands, CLAUDE.md)도 진행하고 +Claude Code 재시작 안내해줘. + +``` + +--- + +## 📋 기존 프로젝트에 추가 + +``` + +프로젝트 루트에 있는 SETTING.md 파일을 읽고 +기존 프로젝트에 에이전트 시스템을 추가해줘. + +## 현재 상태 + +- [완료된 작업] +- [남은 작업] + +## 요청사항 + +1. `.claude/` 폴더 구조만 추가 (기존 파일 수정 X) + +2. 에이전트 생성: + + - orchestrator.md + - code-writer.md + +3. 상태 파일에 현재 상태 반영: + + - progress.json의 currentPhase를 [N]으로 설정 + - goals.md에 남은 작업 기록 + +4. CLAUDE.md에 에이전트 시스템 섹션만 추가 (기존 내용 유지) + +5. Claude Code 재시작 안내 + +``` + +--- + +## ⚡ 빠른 초기화 (최소 버전) + +``` + +SETTING.md를 읽고 최소한의 에이전트 시스템을 설정해줘. + +필요한 것: + +1. `.claude/agents/orchestrator.md` - YAML frontmatter 포함 +2. `.claude/agents/code-writer.md` - YAML frontmatter 포함 +3. `.claude/state/progress.json` - 빈 상태로 초기화 + +목표: [한 줄 목표] +기술: [기술 스택] + +설정 완료 후 Claude Code 재시작 안내해줘. + +``` + +--- + +## 🔍 초기화 후 확인 프롬프트 + +Claude Code 재시작 후: + +``` + +/agents 명령어로 등록된 에이전트 목록을 확인하고, +각 에이전트가 제대로 설정됐는지 알려줘. + +확인 항목: + +1. orchestrator가 보이는가? +2. code-writer가 보이는가? +3. 다른 에이전트들은? + +문제가 있다면 해결 방법도 알려줘. + +``` + +--- + +## 🛠️ 트러블슈팅 프롬프트 + +### 에이전트가 안 보일 때 + +``` + +.claude/agents/ 폴더의 파일들을 확인해줘. + +확인할 것: + +1. 파일이 존재하는가? +2. YAML frontmatter가 올바른가? + - --- 로 시작하고 끝나는가? + - name, description 필드가 있는가? + - 콜론(:) 뒤에 공백이 있는가? + +문제가 있으면 수정해줘. + +``` + +### 에이전트가 규칙을 안 따를 때 + +``` + +.claude/agents/[에이전트명].md 파일을 열어서 +시스템 프롬프트를 더 명확하게 수정해줘. + +추가할 것: + +- "## 금지사항" 섹션에 구체적인 규칙 +- "## 필수사항" 섹션에 반드시 해야 할 것 +- description을 더 구체적으로 + +```` + +--- + +## 📌 핵심 포인트 + +1. **YAML frontmatter 필수** + ```markdown + --- + name: agent-name + description: 설명 + tools: Read, Write + --- +```` + +2. **Claude Code 재시작 필수** + + - 에이전트 파일 생성/수정 후 + - `exit` → `claude` 다시 실행 + +3. **/agents로 확인** + + - 에이전트가 제대로 등록됐는지 확인 + +4. **description이 중요** + - Claude가 언제 이 에이전트를 호출할지 결정하는 기준 diff --git a/SETTING.md b/SETTING.md new file mode 100644 index 000000000..102ed935e --- /dev/null +++ b/SETTING.md @@ -0,0 +1,773 @@ +# 🚀 Claude Code 프로젝트 세팅 가이드 v3 + +> **목적**: 과제 완료를 위한 에이전트 시스템 구축 +> **핵심 변경**: 실제 Claude Code `/agents` 시스템과 통합 +> **트리거**: `@setup` 명령어로 초기화 시작 + +--- + +## 📋 목차 + +1. [핵심 개념](#핵심-개념) +2. [폴더 구조](#폴더-구조) +3. [서브에이전트 시스템](#서브에이전트-시스템) +4. [초기화 프로세스](#초기화-프로세스) +5. [상태 관리 & 로깅](#상태-관리--로깅) +6. [템플릿 모음](#템플릿-모음) +7. [트러블슈팅](#트러블슈팅) + +--- + +## 🎯 핵심 개념 + +### Claude Code 서브에이전트란? + +``` +서브에이전트 = 독립 컨텍스트 + 커스텀 시스템 프롬프트 + 제한된 도구 + +특징: +- 별도 컨텍스트에서 실행 (메인 대화 오염 방지) +- 각자의 도구 권한 설정 가능 +- /agents 명령어로 관리 +- .claude/agents/ 폴더에 .md 파일로 정의 +``` + +### 서브에이전트 vs 슬래시 커맨드 + +| 구분 | 서브에이전트 | 슬래시 커맨드 | +| -------- | ------------------ | ------------------------- | +| 위치 | `.claude/agents/` | `.claude/commands/` | +| 컨텍스트 | 독립 (격리됨) | 메인 대화에서 실행 | +| 용도 | 복잡한 위임 작업 | 반복 프롬프트 자동화 | +| 호출 | Claude가 자동 위임 | 사용자가 `/명령어`로 호출 | + +### 이 시스템의 목표 + +``` +✅ 실제 /agents 메뉴에 에이전트 등록 +✅ 태스크 완료 중심 워크플로우 +✅ 자동 로그 갱신 +✅ 페이즈별 체크포인트 +``` + +--- + +## 🏗️ 폴더 구조 + +``` +프로젝트/ +│ +├── CLAUDE.md # 메인 설정 (init 후 생성) +├── SETTING.md # 이 파일 (마스터 가이드) +│ +└── .claude/ + ├── agents/ # 🤖 서브에이전트 (Claude Code 공식) + │ ├── orchestrator.md + │ ├── code-writer.md + │ ├── test-writer.md + │ └── reviewer.md + │ + ├── commands/ # ⚡ 슬래시 커맨드 + │ ├── setup.md # @setup 트리거 + │ ├── status.md # @status 확인 + │ └── phase-complete.md + │ + ├── state/ # 📍 동적 상태 (런타임) + │ ├── goals.md # 현재 목표 + │ ├── references.md # 참고 자료 + │ ├── progress.json # 진행 상황 + │ └── logs/ # 페이즈별 로그 + │ └── phase-[n].md + │ + └── settings.json # 프로젝트 설정 (선택) +``` + +--- + +## 🤖 서브에이전트 시스템 + +### 에이전트 파일 형식 (필수!) + +Claude Code가 인식하려면 **YAML frontmatter**가 필수입니다: + +```markdown +--- +name: agent-name +description: 이 에이전트가 언제 호출되어야 하는지 설명 +tools: Read, Write, Edit, Bash, Glob, Grep +--- + +시스템 프롬프트 내용... +``` + +### 사용 가능한 도구 목록 + +| 도구 | 설명 | 용도 | +| ----------- | ------------------ | ------------------ | +| `Read` | 파일 읽기 | 모든 에이전트 | +| `Write` | 파일 쓰기 | 코드 작성 에이전트 | +| `Edit` | 파일 수정 | 코드 수정 에이전트 | +| `Bash` | 명령어 실행 | 테스트, 빌드 | +| `Glob` | 파일 패턴 검색 | 탐색 | +| `Grep` | 텍스트 검색 | 코드 분석 | +| `WebSearch` | 웹 검색 | 리서치 | +| `WebFetch` | 웹 페이지 가져오기 | 문서 참조 | + +### 에이전트 역할별 도구 권장 + +``` +읽기 전용 (리뷰어, 분석가): Read, Grep, Glob +리서치 에이전트: Read, Grep, Glob, WebSearch, WebFetch +코드 작성 에이전트: Read, Write, Edit, Bash, Glob, Grep +오케스트레이터: Read, Write, Glob, Grep +``` + +### 기본 에이전트 정의 + +#### 1. orchestrator.md + +```markdown +--- +name: orchestrator +description: 프로젝트 전체 진행을 조율하고 적절한 에이전트에게 태스크를 위임합니다. 복잡한 기능 구현이나 여러 단계가 필요한 작업 시 호출됩니다. +tools: Read, Write, Glob, Grep +--- + +당신은 프로젝트 오케스트레이터입니다. + +## 역할 + +- 프로젝트 목표와 현재 상태 파악 +- 적절한 서브에이전트에게 태스크 위임 +- 진행 상황 추적 및 로그 관리 + +## 작업 시작 전 필수 + +1. `.claude/state/goals.md` 읽어 현재 목표 확인 +2. `.claude/state/progress.json` 읽어 현재 페이즈 확인 +3. 이전 로그 확인 (`.claude/state/logs/`) + +## 작업 완료 시 필수 + +1. `.claude/state/logs/phase-[n].md` 작성 +2. `.claude/state/progress.json` 업데이트 +3. 다음 단계 안내 + +## 금지사항 + +- 직접 코드 작성 금지 (code-writer에게 위임) +- 로그 없이 페이즈 종료 금지 +``` + +#### 2. code-writer.md + +```markdown +--- +name: code-writer +description: 코드 구현을 담당합니다. 새로운 기능 개발, 파일 생성, 코드 수정이 필요할 때 호출됩니다. +tools: Read, Write, Edit, Bash, Glob, Grep +--- + +당신은 코드 작성 전문가입니다. + +## 역할 + +- 기능 요구사항에 따른 코드 구현 +- 타입 안전성 확보 (TypeScript) +- 에러 핸들링 포함 + +## 작업 전 확인 + +1. 요구사항 명확히 이해 +2. 기존 코드 스타일 파악 +3. 관련 파일 구조 확인 + +## 코드 작성 원칙 + +- 기존 패턴 따르기 +- 주석은 필요한 곳에만 +- 작은 단위로 커밋 가능하게 + +## 완료 후 + +- 작성한 파일 목록 보고 +- 테스트 필요 여부 안내 +``` + +#### 3. test-writer.md + +```markdown +--- +name: test-writer +description: 테스트 코드 작성을 담당합니다. TDD 방식 개발이나 테스트 추가가 필요할 때 호출됩니다. +tools: Read, Write, Edit, Bash, Glob, Grep +--- + +당신은 테스트 작성 전문가입니다. + +## 역할 + +- 단위 테스트 작성 +- 통합 테스트 작성 +- 엣지 케이스 커버 + +## 테스트 원칙 + +- 각 함수당 최소 3개 테스트 +- 정상 케이스 + 엣지 케이스 + 에러 케이스 +- 테스트 이름은 명확하게 + +## 사용 프레임워크 + +- Jest / Vitest (프로젝트에 따라) +- React Testing Library (React 컴포넌트) + +## 완료 후 + +- 테스트 실행 결과 보고 +- 커버리지 안내 +``` + +#### 4. reviewer.md + +```markdown +--- +name: reviewer +description: 코드 리뷰를 담당합니다. PR 리뷰, 코드 품질 검토, 리팩토링 제안이 필요할 때 호출됩니다. +tools: Read, Grep, Glob +--- + +당신은 시니어 코드 리뷰어입니다. + +## 역할 + +- 코드 품질 검토 +- 잠재적 버그 발견 +- 개선 제안 + +## 리뷰 체크리스트 + +- [ ] 타입 안전성 +- [ ] 에러 핸들링 +- [ ] 성능 이슈 +- [ ] 보안 취약점 +- [ ] 코드 스타일 일관성 + +## 리뷰 형식 + +각 이슈에 대해: + +- 위치 (파일:라인) +- 심각도 (critical/major/minor/suggestion) +- 설명 +- 개선 제안 + +## 금지사항 + +- 직접 코드 수정 금지 (리뷰만) +``` + +--- + +## 🚀 초기화 프로세스 + +### 슬래시 커맨드로 트리거 + +`.claude/commands/setup.md`: + +```markdown +--- +description: 프로젝트 초기 세팅을 시작합니다 +--- + +SETTING.md 파일을 읽고 프로젝트 초기화를 진행합니다. + +## 수행할 작업 + +1. **폴더 구조 생성** + + - `.claude/agents/` 생성 + - `.claude/commands/` 생성 + - `.claude/state/` 생성 + - `.claude/state/logs/` 생성 + +2. **기본 에이전트 생성** (SETTING.md의 템플릿 참조) + + - orchestrator.md + - code-writer.md + - test-writer.md (선택) + - reviewer.md (선택) + +3. **상태 파일 초기화** + + - goals.md + - references.md + - progress.json + +4. **정보 수집** + 사용자에게 다음을 질문: + + - 프로젝트 목표 + - 세부 태스크 + - 참고 자료 + - 기술 스택 + - 필요한 에이전트 + +5. **CLAUDE.md 업데이트** + +6. **초기화 로그 작성** + - `.claude/state/logs/phase-0.md` + +$ARGUMENTS +``` + +### 초기화 완료 후 안내 + +``` +✅ 초기화 완료! + +생성된 파일: +- .claude/agents/orchestrator.md +- .claude/agents/code-writer.md +- .claude/state/goals.md +- .claude/state/progress.json + +⚠️ 중요: 에이전트를 /agents 메뉴에서 보려면 +Claude Code를 재시작하세요 (터미널에서 claude 다시 실행) + +다음 단계: +1. /agents 명령어로 에이전트 확인 +2. Phase 1 시작 +``` + +--- + +## 📊 상태 관리 & 로깅 + +### progress.json 구조 + +```json +{ + "project": "프로젝트명", + "startedAt": "2024-01-15T10:00:00Z", + "currentPhase": 1, + "phases": [ + { + "id": 0, + "name": "초기화", + "status": "completed", + "completedAt": "2024-01-15T10:30:00Z" + }, + { + "id": 1, + "name": "기능 구현", + "status": "in-progress", + "startedAt": "2024-01-15T10:30:00Z" + } + ], + "lastUpdated": "2024-01-15T14:30:00Z" +} +``` + +### 페이즈 완료 슬래시 커맨드 + +`.claude/commands/phase-complete.md`: + +```markdown +--- +description: 현재 페이즈를 완료하고 로그를 작성합니다 +--- + +현재 페이즈를 완료 처리합니다. + +## 필수 수행 작업 + +1. **로그 작성** + `.claude/state/logs/phase-[현재번호].md` 생성: + + - 완료된 작업 목록 + - 생성/수정된 파일 + - 발생한 이슈와 해결 + - 다음 단계 + +2. **progress.json 업데이트** + + - 현재 페이즈 status: "completed" + - currentPhase 증가 + - lastUpdated 갱신 + +3. **완료 메시지 출력** +``` + +✅ Phase [N] 완료 +📝 로그: .claude/state/logs/phase-[n].md +📊 진행률: [X/Y] 페이즈 완료 +➡️ 다음: Phase [N+1] - [이름] + +``` + +$ARGUMENTS +``` + +### 로그 갱신 강제 규칙 + +CLAUDE.md에 추가: + +```markdown +## 📝 Mandatory Logging + +### ⛔ BLOCKING RULES + +- 로그 없이 페이즈 종료 금지 +- progress.json 갱신 없이 다음 페이즈 금지 + +### 페이즈 완료 체크리스트 + +모든 항목 완료 전 다음 페이즈 진행 불가: + +1. [ ] 해당 페이즈 결과물 확인됨 +2. [ ] `.claude/state/logs/phase-[n].md` 작성됨 +3. [ ] `.claude/state/progress.json` 업데이트됨 +4. [ ] 완료 메시지 출력됨 + +### 자동 트리거 + +`/project:phase-complete` 명령어 사용 또는 +"페이즈 완료", "phase complete" 키워드 감지 시 자동 실행 +``` + +--- + +## 📝 템플릿 모음 + +### CLAUDE.md 템플릿 + +````markdown +# CLAUDE.md + +## 📖 Context Management + +1. 이 파일 먼저 읽기 +2. 필요시 `.claude/state/` 확인 +3. 에이전트는 `.claude/agents/`에 정의됨 + +## 🎬 Commands + +| 명령어 | 설명 | +| ------------------------- | ----------- | +| `/project:setup` | 초기화 시작 | +| `/project:status` | 현재 상태 | +| `/project:phase-complete` | 페이즈 완료 | + +## 🎯 Project + +**목표**: `.claude/state/goals.md` 참조 +**진행**: `.claude/state/progress.json` 참조 + +## 🤖 Agents + +에이전트 관리: `/agents` 명령어 사용 + +### 활성 에이전트 + +- orchestrator: 전체 조율 +- code-writer: 코드 구현 +- test-writer: 테스트 작성 +- reviewer: 코드 리뷰 + +## 📝 Mandatory Logging + +### ⛔ NEVER + +- 로그 없이 페이즈 종료 금지 +- progress.json 갱신 없이 진행 금지 + +### 페이즈 완료 시 + +1. `/project:phase-complete` 실행 +2. 로그 확인 +3. 다음 페이즈 진행 + +## 🛠️ Tech Stack + +[프로젝트별 작성] + +## Development + +```bash +npm install +npm run dev +``` +```` + +```` + +### 빠른 시작 스크립트 + +```bash +#!/bin/bash +# setup-claude-agents.sh + +echo "🚀 Claude Code 에이전트 시스템 설정 중..." + +# 폴더 생성 +mkdir -p .claude/{agents,commands,state/logs} + +# orchestrator 에이전트 +cat > .claude/agents/orchestrator.md << 'EOF' +--- +name: orchestrator +description: 프로젝트 전체 진행을 조율합니다. 복잡한 기능 구현이나 여러 단계가 필요한 작업 시 호출됩니다. +tools: Read, Write, Glob, Grep +--- + +당신은 프로젝트 오케스트레이터입니다. + +## 작업 시작 전 +1. `.claude/state/goals.md` 확인 +2. `.claude/state/progress.json` 확인 + +## 작업 완료 시 +1. `.claude/state/logs/phase-[n].md` 작성 +2. `.claude/state/progress.json` 업데이트 + +## 금지사항 +- 직접 코드 작성 금지 +- 로그 없이 페이즈 종료 금지 +EOF + +# code-writer 에이전트 +cat > .claude/agents/code-writer.md << 'EOF' +--- +name: code-writer +description: 코드 구현을 담당합니다. 새로운 기능 개발, 파일 생성이 필요할 때 호출됩니다. +tools: Read, Write, Edit, Bash, Glob, Grep +--- + +당신은 코드 작성 전문가입니다. + +## 원칙 +- 기존 패턴 따르기 +- 타입 안전성 확보 +- 에러 핸들링 포함 + +## 완료 후 +- 작성한 파일 목록 보고 +EOF + +# setup 커맨드 +cat > .claude/commands/setup.md << 'EOF' +--- +description: 프로젝트 초기 세팅을 시작합니다 +--- + +SETTING.md를 읽고 초기화를 진행합니다. + +다음 정보를 입력받아 설정: +1. 프로젝트 목표 +2. 세부 태스크 +3. 참고 자료 +4. 기술 스택 + +$ARGUMENTS +EOF + +# phase-complete 커맨드 +cat > .claude/commands/phase-complete.md << 'EOF' +--- +description: 현재 페이즈를 완료하고 로그를 작성합니다 +--- + +1. `.claude/state/logs/phase-[n].md` 작성 +2. `.claude/state/progress.json` 업데이트 +3. 완료 메시지 출력 + +$ARGUMENTS +EOF + +# 상태 파일 초기화 +cat > .claude/state/progress.json << 'EOF' +{ + "project": "", + "startedAt": null, + "currentPhase": 0, + "phases": [], + "lastUpdated": null +} +EOF + +cat > .claude/state/goals.md << 'EOF' +# 🎯 프로젝트 목표 + +## 최종 목표 +[`/project:setup` 실행 후 입력] + +## 세부 태스크 +- [ ] ... + +## 성공 기준 +- ... +EOF + +cat > .claude/state/references.md << 'EOF' +# 📚 참고 자료 + +## 문서 +- ... + +## 링크 +- ... +EOF + +echo "" +echo "✅ 설정 완료!" +echo "" +echo "📁 생성된 구조:" +echo " .claude/agents/ - 서브에이전트" +echo " .claude/commands/ - 슬래시 커맨드" +echo " .claude/state/ - 상태 파일" +echo "" +echo "⚠️ 중요: Claude Code를 재시작해야 에이전트가 인식됩니다" +echo "" +echo "다음 단계:" +echo " 1. Claude Code 재시작" +echo " 2. /agents 명령어로 에이전트 확인" +echo " 3. /project:setup 실행" +```` + +--- + +## 🔧 트러블슈팅 + +### 문제 1: /agents에 에이전트가 안 보임 + +**원인**: Claude Code가 시작할 때만 에이전트를 로드함 + +**해결**: + +```bash +# Claude Code 종료 후 다시 시작 +exit +claude +``` + +### 문제 2: YAML frontmatter 오류 + +**확인사항**: + +```markdown +# ✅ 올바른 형식 + +--- + +name: my-agent +description: 설명 +tools: Read, Write + +--- + +# ❌ 잘못된 형식 (콜론 뒤 공백 없음) + +--- + +name:my-agent +description:설명 + +--- + +# ❌ 잘못된 형식 (--- 누락) + +name: my-agent +description: 설명 +``` + +### 문제 3: 에이전트가 규칙을 안 따름 + +**원인**: description이 모호하거나, 시스템 프롬프트가 불명확 + +**해결**: + +```markdown +# description을 구체적으로 + +description: 코드 리뷰가 필요할 때 호출됩니다. PR 리뷰, 품질 검토 시 사용합니다. + +# 시스템 프롬프트에 명확한 규칙 추가 + +## 금지사항 + +- 절대 코드를 직접 수정하지 마세요 +- 리뷰만 제공하세요 +``` + +### 문제 4: 로그가 갱신되지 않음 + +**해결**: + +```markdown +# CLAUDE.md에 강제 규칙 추가 + +## ⛔ BLOCKING RULE + +페이즈 종료 전 필수 확인: +"로그를 작성했나요? Y/N" + +N인 경우 진행 불가. +``` + +--- + +## ✅ 설정 체크리스트 + +### 필수 + +- [ ] `.claude/agents/` 폴더에 에이전트 파일 생성 +- [ ] 각 에이전트 파일에 YAML frontmatter 포함 +- [ ] Claude Code 재시작 +- [ ] `/agents` 명령어로 에이전트 확인 + +### 권장 + +- [ ] `.claude/commands/` 슬래시 커맨드 설정 +- [ ] `.claude/state/` 상태 파일 초기화 +- [ ] CLAUDE.md에 로깅 규칙 추가 + +### 테스트 + +- [ ] `/agents` 에서 에이전트 목록 확인 +- [ ] 에이전트 호출 테스트 +- [ ] 페이즈 완료 시 로그 갱신 확인 + +--- + +## 🚀 Quick Start + +```bash +# 1. 스크립트 실행 +chmod +x setup-claude-agents.sh +./setup-claude-agents.sh + +# 2. Claude Code 재시작 +claude + +# 3. 에이전트 확인 +/agents + +# 4. 프로젝트 설정 +/project:setup +``` + +또는 수동으로: + +```bash +# 폴더 생성 +mkdir -p .claude/{agents,commands,state/logs} + +# 에이전트 파일 생성 (위 템플릿 참조) +# Claude Code 재시작 +# /agents 확인 +``` + +--- + +**Happy Shipping!** 🚀 diff --git a/package.json b/package.json index 17b18de25..0248cd0e5 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "test:advanced": "vitest src/advanced", "test:ui": "vitest --ui", "build": "tsc -b && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "gh-pages": "pnpm run build && gh-pages -d dist" }, "dependencies": { + "jotai": "^2.15.2", "react": "^19.1.1", "react-dom": "^19.1.1" }, @@ -23,6 +25,7 @@ "@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", @@ -32,6 +35,7 @@ "eslint": "^9.32.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", + "gh-pages": "^6.3.0", "jsdom": "^26.1.0", "typescript": "^5.9.2", "vite": "^7.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85f..4f4accd8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + jotai: + specifier: ^2.15.2 + version: 2.15.2(@types/react@19.1.9)(react@19.1.1) react: specifier: ^19.1.1 version: 19.1.1 @@ -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) @@ -51,6 +57,9 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.20 version: 0.4.20(eslint@9.32.0) + gh-pages: + specifier: ^6.3.0 + version: 6.3.0 jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -59,10 +68,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 +592,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: @@ -732,10 +744,17 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -786,6 +805,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -839,12 +865,19 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + email-addresses@5.0.0: + resolution: {integrity: sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -956,10 +989,26 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + filename-reserved-regex@2.0.0: + resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} + engines: {node: '>=4'} + + filenamify@4.3.0: + resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==} + engines: {node: '>=8'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -974,11 +1023,20 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + fs-extra@11.3.2: + resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} + engines: {node: '>=14.14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + gh-pages@6.3.0: + resolution: {integrity: sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==} + engines: {node: '>=10'} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -991,6 +1049,13 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -1056,6 +1121,24 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jotai@2.15.2: + resolution: {integrity: sha512-El86CCfXNMEOytp20NPfppqGGmcp6H6kIA+tJHdmASEUURJCYW4fh8nTHEnB8rUXEFAY1pm8PdHPwnrcPGwdEg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1084,6 +1167,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1091,6 +1177,10 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1117,6 +1207,10 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1158,14 +1252,26 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1181,6 +1287,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1203,6 +1313,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1270,6 +1384,10 @@ packages: scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.6.3: resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} @@ -1290,6 +1408,10 @@ packages: resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} engines: {node: '>=18'} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1311,6 +1433,10 @@ packages: strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + strip-outer@1.0.1: + resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} + engines: {node: '>=0.10.0'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -1367,6 +1493,10 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + trim-repeated@1.0.0: + resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} + engines: {node: '>=0.10.0'} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -1382,6 +1512,13 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1886,6 +2023,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 +2128,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 +2144,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 +2181,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: @@ -2083,8 +2224,12 @@ snapshots: aria-query@5.3.2: {} + array-union@2.1.0: {} + assertion-error@2.0.1: {} + async@3.2.6: {} + balanced-match@1.0.2: {} brace-expansion@1.1.11: @@ -2137,6 +2282,10 @@ snapshots: color-name@1.1.4: {} + commander@13.1.0: {} + + commondir@1.0.1: {} + concat-map@0.0.1: {} cross-spawn@7.0.6: @@ -2175,10 +2324,16 @@ snapshots: dequal@2.0.3: {} + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} + email-addresses@5.0.0: {} + entities@4.5.0: {} es-module-lexer@1.7.0: {} @@ -2329,10 +2484,29 @@ snapshots: dependencies: flat-cache: 4.0.1 + filename-reserved-regex@2.0.0: {} + + filenamify@4.3.0: + dependencies: + filename-reserved-regex: 2.0.0 + strip-outer: 1.0.1 + trim-repeated: 1.0.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -2347,9 +2521,25 @@ snapshots: flatted@3.3.3: {} + fs-extra@11.3.2: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fsevents@2.3.3: optional: true + gh-pages@6.3.0: + dependencies: + async: 3.2.6 + commander: 13.1.0 + email-addresses: 5.0.0 + filenamify: 4.3.0 + find-cache-dir: 3.3.2 + fs-extra: 11.3.2 + globby: 11.1.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2360,6 +2550,17 @@ snapshots: globals@14.0.0: {} + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + graceful-fs@4.2.11: {} + graphemer@1.4.0: {} has-flag@3.0.0: {} @@ -2413,6 +2614,11 @@ snapshots: isexe@2.0.0: {} + jotai@2.15.2(@types/react@19.1.9)(react@19.1.1): + optionalDependencies: + '@types/react': 19.1.9 + react: 19.1.1 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -2454,6 +2660,12 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -2463,6 +2675,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -2483,6 +2699,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + merge2@1.4.1: {} micromatch@4.0.8: @@ -2519,14 +2739,24 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -2539,6 +2769,8 @@ snapshots: path-key@3.1.1: {} + path-type@4.0.0: {} + pathe@2.0.3: {} pathval@2.0.0: {} @@ -2551,6 +2783,10 @@ snapshots: picomatch@4.0.3: {} + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -2629,6 +2865,8 @@ snapshots: scheduler@0.26.0: {} + semver@6.3.1: {} + semver@7.6.3: {} shebang-command@2.0.0: @@ -2645,6 +2883,8 @@ snapshots: mrmime: 2.0.0 totalist: 3.0.1 + slash@3.0.0: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} @@ -2661,6 +2901,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + strip-outer@1.0.1: + dependencies: + escape-string-regexp: 1.0.5 + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -2706,6 +2950,10 @@ snapshots: dependencies: punycode: 2.3.1 + trim-repeated@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + ts-api-utils@2.1.0(typescript@5.9.2): dependencies: typescript: 5.9.2 @@ -2716,17 +2964,21 @@ snapshots: typescript@5.9.2: {} + undici-types@7.16.0: {} + + universalify@2.0.1: {} + 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 +2993,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 +3002,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 +3027,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: diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1d..ad4d17584 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,1124 +1,21 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +import { useAtomValue } from "jotai"; +import { isAdminAtom } from "./stores/atoms/uiAtoms"; +import { Header, AdminPage, CartPage } from "./components/layout"; +import { ToastContainer } from "./components/toast"; +import { SearchBar } from "./components/search"; const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + const isAdmin = useAtomValue(isAdminAtom); return ( -
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

SHOP

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

관리자 대시보드

-

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

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

상품 목록

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

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

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

쿠폰 관리

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

{coupon.name}

-

{coupon.code}

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

새 쿠폰 생성

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

전체 상품

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

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

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

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

- - - - 장바구니 -

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

장바구니가 비어있습니다

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

{item.product.name}

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

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

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

쿠폰 할인

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

결제 정보

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

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

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

관리자 대시보드

+

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

+
+
+ +
+ + ); +}; diff --git a/src/advanced/components/admin/ManagementCoupon.tsx b/src/advanced/components/admin/ManagementCoupon.tsx new file mode 100644 index 000000000..a2a40627f --- /dev/null +++ b/src/advanced/components/admin/ManagementCoupon.tsx @@ -0,0 +1,50 @@ +import { useCallback, useState } from "react"; +import { useSetAtom } from "jotai"; +import { CouponList, CouponForm } from "../coupon"; +import { addCouponAtom } from "../../stores/atoms/couponAtoms"; + +import type { FormEvent } from "react"; +import type { Coupon } from "../../../types"; + +export const ManagementCoupon = () => { + const [show, setShow] = useState(false); + const [couponForm, setCouponForm] = useState>(couponFormInit); + + const addCoupon = useSetAtom(addCouponAtom); + + const handleCouponSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm(couponFormInit); + setShow(false); + }, + [couponForm, addCoupon, setCouponForm, setShow] + ); + + return ( +
+
+

쿠폰 관리

+
+
+ + {show && ( + + )} +
+
+ ); +}; + +const couponFormInit: Omit = { + name: "", + code: "", + discountType: "amount", + discountValue: 0, +}; diff --git a/src/advanced/components/admin/ManagementProduct.tsx b/src/advanced/components/admin/ManagementProduct.tsx new file mode 100644 index 000000000..ce574f50e --- /dev/null +++ b/src/advanced/components/admin/ManagementProduct.tsx @@ -0,0 +1,82 @@ +import { useCallback, useState } from "react"; +import { ProductForm, ProductAccordion } from "../product"; +import type { ProductWithUI } from "../../hooks/useProducts"; +import { addProductAtom, updateProductAtom } from "../../stores/atoms/productAtoms"; +import { useSetAtom } from "jotai"; + +export const ManagementProduct = () => { + const [show, setShow] = useState(false); + + const [productForm, setProductForm] = useState>(productFormInit); + + const [editingProduct, setEditingProduct] = useState(null); + + const resetProductForm = useCallback(() => { + setProductForm(productFormInit); + setEditingProduct(null); + setShow(false); + }, [setProductForm, setEditingProduct, setShow]); + + const updateProduct = useSetAtom(updateProductAtom); + const addProduct = useSetAtom(addProductAtom); + + const handleProductSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== "new") { + updateProduct({ productId: editingProduct, updates: productForm }); + setEditingProduct(null); + } else { + addProduct({ + ...productForm, + discounts: productForm.discounts, + }); + } + resetProductForm(); + }, + [editingProduct, productForm, addProduct, updateProduct, resetProductForm] + ); + + return ( +
+
+
+

상품 목록

+ +
+
+ + + {show && ( + + )} +
+ ); +}; + +const productFormInit: Omit = { + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], +}; diff --git a/src/advanced/components/admin/index.ts b/src/advanced/components/admin/index.ts new file mode 100644 index 000000000..a8569c84e --- /dev/null +++ b/src/advanced/components/admin/index.ts @@ -0,0 +1,5 @@ +import { ManagementProduct } from "./ManagementProduct"; +import { ManagementCoupon } from "./ManagementCoupon"; +import { AdminHeader } from "./AdminHeader"; + +export { ManagementProduct, ManagementCoupon, AdminHeader }; diff --git a/src/advanced/components/cart/Cart.tsx b/src/advanced/components/cart/Cart.tsx new file mode 100644 index 000000000..c54e619cd --- /dev/null +++ b/src/advanced/components/cart/Cart.tsx @@ -0,0 +1,52 @@ +import { BagIcon } from "../icons"; +import { CouponDiscount } from "../coupon/CouponDiscount"; +import { PaymentContainer } from "../payment/PaymentContainer"; +import { CartStockItem } from "./CartStockItem"; +import { selectedCouponAtom } from "../../stores/atoms/couponAtoms"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { applyCouponAtom, cartAtom } from "../../stores/atoms/cartAtoms"; +import { calculateItemTotal } from "../../models/cart"; +import type { Coupon } from "../../../types"; + +export const Cart = () => { + const cart = useAtomValue(cartAtom); + const applyCoupon = useSetAtom(applyCouponAtom); + const [, setSelectedCoupon] = useAtom(selectedCouponAtom); + + const handleApplyCoupon = (e: React.ChangeEvent, coupons: Coupon[]) => { + const coupon = coupons.find((c) => c.code === e.target.value); + if (coupon) applyCoupon(coupon); + else setSelectedCoupon(null); + }; + + return ( +
+
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => { + const itemTotal = calculateItemTotal(item, cart); + return ; + })} +
+ )} +
+ + {cart.length > 0 && ( + <> + + + + )} +
+ ); +}; diff --git a/src/advanced/components/cart/CartStockItem.tsx b/src/advanced/components/cart/CartStockItem.tsx new file mode 100644 index 000000000..f74ad5049 --- /dev/null +++ b/src/advanced/components/cart/CartStockItem.tsx @@ -0,0 +1,56 @@ +import { XIcon } from "../icons"; +import { formatPrice } from "../../utils/formatters"; +import type { CartItem } from "../../../types"; +import { removeFromCartAtom, updateQuantityAtom } from "../../stores/atoms/cartAtoms"; +import { useSetAtom } from "jotai"; + +export const CartStockItem = ({ item, itemTotal }: { item: CartItem; itemTotal: number }) => { + const removeFromCart = useSetAtom(removeFromCartAtom); + const updateQuantity = useSetAtom(updateQuantityAtom); + 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}% + )} +

+ {formatPrice(Math.round(itemTotal), "원")} +

+
+
+
+ ); +}; diff --git a/src/advanced/components/cart/ProductItem.tsx b/src/advanced/components/cart/ProductItem.tsx new file mode 100644 index 000000000..989660ff5 --- /dev/null +++ b/src/advanced/components/cart/ProductItem.tsx @@ -0,0 +1,78 @@ +import { ProductIcon } from "../icons"; +import { ProductWithUI } from "../../hooks/useProducts"; +import { addToCartAtom } from "../../stores/atoms/cartAtoms"; +import { useSetAtom } from "jotai"; + +export const ProductItem = ({ + product, + productPrice, + stock, +}: { + product: ProductWithUI; + productPrice: (price: number, productId?: string) => string; + stock: number; +}) => { + const addToCart = useSetAtom(addToCartAtom); + + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

{product.description}

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

+ {productPrice(product.price, product.id)} +

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

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

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

품절임박! {stock}개 남음

+ )} + {stock > 5 &&

재고 {stock}개

} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; diff --git a/src/advanced/components/cart/ProductList.tsx b/src/advanced/components/cart/ProductList.tsx new file mode 100644 index 000000000..aba2e5f91 --- /dev/null +++ b/src/advanced/components/cart/ProductList.tsx @@ -0,0 +1,61 @@ +import { useCallback } from "react"; +import { formatPrice } from "../../utils/formatters"; +import { ProductItem } from "./ProductItem"; +import { useAtomValue } from "jotai"; +import { debouncedSearchTermAtom } from "../../stores/atoms/uiAtoms"; +import { getRemainingStock } from "../../models/cart"; +import { cartAtom } from "../../stores/atoms/cartAtoms"; +import { productAtom } from "../../stores/atoms/productAtoms"; + +export const ProductList = () => { + const cart = useAtomValue(cartAtom); + const products = useAtomValue(productAtom); + const searchTerm = useAtomValue(debouncedSearchTermAtom); + + const filteredProducts = searchTerm + ? products.filter( + (product) => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(searchTerm.toLowerCase())) + ) + : products; + + const productPrice = useCallback( + (price: number, productId?: string) => { + if (productId) { + const product = filteredProducts.find((p) => p.id === productId); + if (product && getRemainingStock(product, cart) <= 0) { + return "SOLD OUT"; + } + } + return formatPrice(price, "₩"); + }, + [filteredProducts, cart] + ); + + return ( +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map((product) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/advanced/components/cart/index.ts b/src/advanced/components/cart/index.ts new file mode 100644 index 000000000..4d5fc3a5b --- /dev/null +++ b/src/advanced/components/cart/index.ts @@ -0,0 +1,6 @@ +import { Cart } from "./Cart"; +import { ProductList } from "./ProductList"; +import { ProductItem } from "./ProductItem"; +import { CartStockItem } from "./CartStockItem"; + +export { Cart, ProductList, ProductItem, CartStockItem }; diff --git a/src/advanced/components/coupon/CouponContainer.tsx b/src/advanced/components/coupon/CouponContainer.tsx new file mode 100644 index 000000000..44e4c198a --- /dev/null +++ b/src/advanced/components/coupon/CouponContainer.tsx @@ -0,0 +1,32 @@ +import { useSetAtom } from "jotai"; +import { TrashIcon } from "../icons"; +import { deleteCouponAtom } from "../../stores/atoms/couponAtoms"; +import type { Coupon } from "../../../types"; + +export const CouponContainer = ({ coupon }: { coupon: Coupon }) => { + const deleteCoupon = useSetAtom(deleteCouponAtom); + + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +}; diff --git a/src/advanced/components/coupon/CouponDiscount.tsx b/src/advanced/components/coupon/CouponDiscount.tsx new file mode 100644 index 000000000..ae3fa0a87 --- /dev/null +++ b/src/advanced/components/coupon/CouponDiscount.tsx @@ -0,0 +1,40 @@ +import { formatPrice } from "../../utils/formatters"; +import type { Coupon } from "../../../types"; +import { useAtomValue } from "jotai"; +import { couponsAtom, selectedCouponAtom } from "../../stores/atoms/couponAtoms"; + +export const CouponDiscount = ({ + handleApplyCoupon, +}: { + handleApplyCoupon: (e: React.ChangeEvent, coupons: Coupon[]) => void; +}) => { + const coupons = useAtomValue(couponsAtom); + const selectedCoupon = useAtomValue(selectedCouponAtom); + + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +}; diff --git a/src/advanced/components/coupon/CouponForm.tsx b/src/advanced/components/coupon/CouponForm.tsx new file mode 100644 index 000000000..59f737ae6 --- /dev/null +++ b/src/advanced/components/coupon/CouponForm.tsx @@ -0,0 +1,119 @@ +import { toast } from "../../utils/toast"; +import type { FormEvent } from "react"; +import type { Coupon } from "../../../types"; + +// 새 쿠폰 추가 폼 +export const CouponForm = ({ + form, + setForm, + setShow, + handleCouponSubmit, +}: { + form: Coupon; + setForm: (form: Coupon) => void; + setShow: (show: boolean) => void; + handleCouponSubmit: (e: FormEvent) => void; +}) => { + return ( +
+
+

새 쿠폰 생성

+
+
+ + setForm({ ...form, 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 + /> +
+
+ + setForm({ ...form, 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)) { + setForm({ + ...form, + discountValue: value === "" ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + if (form.discountType === "percentage") { + if (value > 100) { + toast.error("할인율은 100%를 초과할 수 없습니다"); + + setForm({ ...form, discountValue: 100 }); + } else if (value < 0) { + setForm({ ...form, discountValue: 0 }); + } + } else { + if (value > 100000) { + toast.error("할인 금액은 100,000원을 초과할 수 없습니다"); + setForm({ ...form, discountValue: 100000 }); + } else if (value < 0) { + setForm({ ...form, 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={form.discountType === "amount" ? "5000" : "10"} + required + /> +
+
+
+ + +
+
+
+ ); +}; diff --git a/src/advanced/components/coupon/CouponList.tsx b/src/advanced/components/coupon/CouponList.tsx new file mode 100644 index 000000000..8693b3e1b --- /dev/null +++ b/src/advanced/components/coupon/CouponList.tsx @@ -0,0 +1,27 @@ +import { useAtomValue } from "jotai"; +import { couponsAtom } from "../../stores/atoms/couponAtoms"; +import { PlusIcon } from "../icons"; +import { CouponContainer } from "./CouponContainer"; + +// 쿠폰 목록 표시 +export const CouponList = ({ setShow }: { setShow: (show: boolean) => void }) => { + const coupons = useAtomValue(couponsAtom); + + return ( +
+ {coupons.map((coupon) => ( + + ))} + +
+ +
+
+ ); +}; diff --git a/src/advanced/components/coupon/index.ts b/src/advanced/components/coupon/index.ts new file mode 100644 index 000000000..c1f452ea1 --- /dev/null +++ b/src/advanced/components/coupon/index.ts @@ -0,0 +1,6 @@ +import { CouponContainer } from "./CouponContainer"; +import { CouponDiscount } from "./CouponDiscount"; +import { CouponForm } from "./CouponForm"; +import { CouponList } from "./CouponList"; + +export { CouponContainer, CouponDiscount, CouponForm, CouponList }; diff --git a/src/advanced/components/icons/AdminIcon.tsx b/src/advanced/components/icons/AdminIcon.tsx new file mode 100644 index 000000000..636a5c235 --- /dev/null +++ b/src/advanced/components/icons/AdminIcon.tsx @@ -0,0 +1,3 @@ +export const AdminIcon = () => { + return
AdminIcon
; +}; diff --git a/src/advanced/components/icons/BagIcon.tsx b/src/advanced/components/icons/BagIcon.tsx new file mode 100644 index 000000000..d91591a6f --- /dev/null +++ b/src/advanced/components/icons/BagIcon.tsx @@ -0,0 +1,20 @@ +import { SvgBase } from "./SvgBase"; + +export const BagIcon = ({ + className = "w-16 h-16 text-gray-300 mx-auto mb-4", + strokeWidth = 2, +}: { + className?: string; + strokeWidth?: number; +}) => { + return ( + + + + ); +}; diff --git a/src/advanced/components/icons/CartIcon.tsx b/src/advanced/components/icons/CartIcon.tsx new file mode 100644 index 000000000..81374cce1 --- /dev/null +++ b/src/advanced/components/icons/CartIcon.tsx @@ -0,0 +1,14 @@ +import { SvgBase } from "./SvgBase"; + +export const CartIcon = ({ className = "w-6 h-6 text-gray-700" }) => { + return ( + + + + ); +}; diff --git a/src/advanced/components/icons/CheckIcon.tsx b/src/advanced/components/icons/CheckIcon.tsx new file mode 100644 index 000000000..e15277594 --- /dev/null +++ b/src/advanced/components/icons/CheckIcon.tsx @@ -0,0 +1,3 @@ +export const CheckIcon = () => { + return
CheckIcon
; +}; diff --git a/src/advanced/components/icons/ChevronDownIcon.tsx b/src/advanced/components/icons/ChevronDownIcon.tsx new file mode 100644 index 000000000..07233ea5d --- /dev/null +++ b/src/advanced/components/icons/ChevronDownIcon.tsx @@ -0,0 +1,3 @@ +export const ChevronDownIcon = () => { + return
ChevronDownIcon
; +}; diff --git a/src/advanced/components/icons/ChevronUpIcon.tsx b/src/advanced/components/icons/ChevronUpIcon.tsx new file mode 100644 index 000000000..85367144f --- /dev/null +++ b/src/advanced/components/icons/ChevronUpIcon.tsx @@ -0,0 +1,3 @@ +export const ChevronUpIcon = () => { + return
ChevronUpIcon
; +}; diff --git a/src/advanced/components/icons/MinusIcon.tsx b/src/advanced/components/icons/MinusIcon.tsx new file mode 100644 index 000000000..925584e48 --- /dev/null +++ b/src/advanced/components/icons/MinusIcon.tsx @@ -0,0 +1,3 @@ +export const MinusIcon = () => { + return
MinusIcon
; +}; diff --git a/src/advanced/components/icons/PlusIcon.tsx b/src/advanced/components/icons/PlusIcon.tsx new file mode 100644 index 000000000..1ac1ea956 --- /dev/null +++ b/src/advanced/components/icons/PlusIcon.tsx @@ -0,0 +1,9 @@ +import { SvgBase } from "./SvgBase"; + +export const PlusIcon = ({ className = "w-8 h-8" }) => { + return ( + + + + ); +}; diff --git a/src/advanced/components/icons/ProductIcon.tsx b/src/advanced/components/icons/ProductIcon.tsx new file mode 100644 index 000000000..cb520cb6d --- /dev/null +++ b/src/advanced/components/icons/ProductIcon.tsx @@ -0,0 +1,14 @@ +import { SvgBase } from "./SvgBase"; + +export const ProductIcon = ({ className = "w-24 h-24 text-gray-300" }) => { + return ( + + + + ); +}; diff --git a/src/advanced/components/icons/SvgBase.tsx b/src/advanced/components/icons/SvgBase.tsx new file mode 100644 index 000000000..21b1c877e --- /dev/null +++ b/src/advanced/components/icons/SvgBase.tsx @@ -0,0 +1,6 @@ +export const SvgBase = ({ + children, + ...props +}: React.PropsWithChildren>) => { + return {children}; +}; diff --git a/src/advanced/components/icons/TrashIcon.tsx b/src/advanced/components/icons/TrashIcon.tsx new file mode 100644 index 000000000..03f8c8fed --- /dev/null +++ b/src/advanced/components/icons/TrashIcon.tsx @@ -0,0 +1,14 @@ +import { SvgBase } from "./SvgBase"; + +export const TrashIcon = ({ className = "w-5 h-5" }) => { + return ( + + + + ); +}; diff --git a/src/advanced/components/icons/XIcon.tsx b/src/advanced/components/icons/XIcon.tsx new file mode 100644 index 000000000..88a18f79d --- /dev/null +++ b/src/advanced/components/icons/XIcon.tsx @@ -0,0 +1,9 @@ +import { SvgBase } from "./SvgBase"; + +export const XIcon = ({ className = "w-4 h-4" }) => { + return ( + + + + ); +}; diff --git a/src/advanced/components/icons/index.ts b/src/advanced/components/icons/index.ts new file mode 100644 index 000000000..56bbea3a9 --- /dev/null +++ b/src/advanced/components/icons/index.ts @@ -0,0 +1,25 @@ +import { CartIcon } from "./CartIcon"; +import { AdminIcon } from "./AdminIcon"; +import { PlusIcon } from "./PlusIcon"; +import { MinusIcon } from "./MinusIcon"; +import { TrashIcon } from "./TrashIcon"; +import { ChevronDownIcon } from "./ChevronDownIcon"; +import { ChevronUpIcon } from "./ChevronUpIcon"; +import { CheckIcon } from "./CheckIcon"; +import { XIcon } from "./XIcon"; +import { BagIcon } from "./BagIcon"; +import { ProductIcon } from "./ProductIcon"; + +export { + CartIcon, + AdminIcon, + PlusIcon, + MinusIcon, + TrashIcon, + ChevronDownIcon, + ChevronUpIcon, + CheckIcon, + XIcon, + BagIcon, + ProductIcon, +}; diff --git a/src/advanced/components/layout/AdminPage.tsx b/src/advanced/components/layout/AdminPage.tsx new file mode 100644 index 000000000..013d2d7d2 --- /dev/null +++ b/src/advanced/components/layout/AdminPage.tsx @@ -0,0 +1,25 @@ +// TODO: 관리자 페이지 컴포넌트 +// 힌트: +// 1. 탭 UI로 상품 관리와 쿠폰 관리 분리 +// 2. 상품 추가/수정/삭제 기능 +// 3. 쿠폰 생성 기능 +// 4. 할인 규칙 설정 +// +// 필요한 hooks: +// - useProducts: 상품 CRUD +// - useCoupons: 쿠폰 CRUD +// + +import { useState } from "react"; +import { ManagementCoupon, AdminHeader, ManagementProduct } from "../admin"; + +export function AdminPage() { + const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); + + return ( +
+ + {activeTab === "products" ? : } +
+ ); +} diff --git a/src/advanced/components/layout/CartPage.tsx b/src/advanced/components/layout/CartPage.tsx new file mode 100644 index 000000000..804d7cebf --- /dev/null +++ b/src/advanced/components/layout/CartPage.tsx @@ -0,0 +1,35 @@ +// TODO: 장바구니 페이지 컴포넌트 +// 힌트: +// 1. 상품 목록 표시 (검색 기능 포함) +// 2. 장바구니 관리 +// 3. 쿠폰 적용 +// 4. 주문 처리 +// +// 필요한 hooks: +// - useProducts: 상품 목록 관리 +// - useCart: 장바구니 상태 관리 +// - useCoupons: 쿠폰 목록 관리 +// - useDebounce: 검색어 디바운싱 +// +// 하위 컴포넌트: +// - SearchBar: 검색 입력 +// - ProductList: 상품 목록 표시 +// - Cart: 장바구니 표시 및 결제 + +import { ProductList } from "../cart/ProductList"; +import { Cart } from "../cart/Cart"; + +export function CartPage() { + return ( +
+
+ {/* 상품 목록 */} + +
+ +
+ +
+
+ ); +} diff --git a/src/advanced/components/layout/Header.tsx b/src/advanced/components/layout/Header.tsx new file mode 100644 index 000000000..ebe9d5fa3 --- /dev/null +++ b/src/advanced/components/layout/Header.tsx @@ -0,0 +1,50 @@ +import { PropsWithChildren, useEffect, useState } from "react"; +import { CartIcon } from "../icons"; +import { isAdminAtom } from "../../stores/atoms/uiAtoms"; +import { useAtom, useAtomValue } from "jotai"; +import { cartAtom } from "../../stores/atoms/cartAtoms"; + +export const Header = ({ children }: PropsWithChildren) => { + const cart = useAtomValue(cartAtom); + + const [isAdmin, setIsAdmin] = useAtom(isAdminAtom); + const [totalItemCount, setTotalItemCount] = useState(0); + + useEffect(() => { + const count = cart.reduce((sum, item) => sum + item.quantity, 0); + setTotalItemCount(count); + }, [cart]); + + return ( +
+
+
+
+

SHOP

+ {!isAdmin && children} +
+ +
+
+
+ ); +}; diff --git a/src/advanced/components/layout/index.ts b/src/advanced/components/layout/index.ts new file mode 100644 index 000000000..e2da693e1 --- /dev/null +++ b/src/advanced/components/layout/index.ts @@ -0,0 +1,5 @@ +import { Header } from "./Header"; +import { AdminPage } from "./AdminPage"; +import { CartPage } from "./CartPage"; + +export { Header, AdminPage, CartPage }; diff --git a/src/advanced/components/payment/PaymentContainer.tsx b/src/advanced/components/payment/PaymentContainer.tsx new file mode 100644 index 000000000..35aa79d85 --- /dev/null +++ b/src/advanced/components/payment/PaymentContainer.tsx @@ -0,0 +1,46 @@ +import { formatPrice } from "../../utils/formatters"; +import { useAtomValue, useSetAtom } from "jotai"; +import { totalsAtom } from "../../stores/atoms/cartAtoms"; +import { completeOrderAtom } from "../../stores/atoms/cartAtoms"; + +export const PaymentContainer = () => { + const totals = useAtomValue(totalsAtom); + const completeOrder = useSetAtom(completeOrderAtom); + + return ( +
+

결제 정보

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

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

+
+
+ ); +}; diff --git a/src/advanced/components/payment/index.ts b/src/advanced/components/payment/index.ts new file mode 100644 index 000000000..69e6b0528 --- /dev/null +++ b/src/advanced/components/payment/index.ts @@ -0,0 +1,3 @@ +import { PaymentContainer } from "./PaymentContainer"; + +export { PaymentContainer }; diff --git a/src/advanced/components/product/ProductAccordion.tsx b/src/advanced/components/product/ProductAccordion.tsx new file mode 100644 index 000000000..833e9c355 --- /dev/null +++ b/src/advanced/components/product/ProductAccordion.tsx @@ -0,0 +1,83 @@ +import { useCallback } from "react"; +import { formatPrice } from "../../utils/formatters"; +import type { ProductWithUI } from "../../hooks/useProducts"; +import { ProductInfo } from "./ProductInfo"; +import { productAtom } from "../../stores/atoms/productAtoms"; +import { useAtomValue } from "jotai"; +import { cartAtom } from "../../stores/atoms/cartAtoms"; +import { getRemainingStock } from "../../models/cart"; + +//상품 정보 표시 및 수정 +export const ProductAccordion = ({ + setEditingProduct, + setProductForm, + setShow, +}: { + setEditingProduct: (productId: string) => void; + setProductForm: (productForm: Omit) => void; + setShow: (show: boolean) => void; +}) => { + const products = useAtomValue(productAtom); + const cart = useAtomValue(cartAtom); + + const handleEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [], + }); + setShow(true); + }; + + const productPrice = useCallback( + (price: number, productId?: string) => { + if (productId) { + const product = products.find((p) => p.id === productId); + if (product && getRemainingStock(product, cart) <= 0) { + return "SOLD OUT"; + } + } + return formatPrice(price); + }, + [products, cart] + ); + + return ( +
+ + + + + + + + + + + + {products.map((product) => ( + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+
+ ); +}; diff --git a/src/advanced/components/product/ProductForm.tsx b/src/advanced/components/product/ProductForm.tsx new file mode 100644 index 000000000..d1ecc1182 --- /dev/null +++ b/src/advanced/components/product/ProductForm.tsx @@ -0,0 +1,182 @@ +import { ProductWithUI } from "../../hooks/useProducts"; +import { toast } from "../../utils/toast"; +import { XIcon } from "../icons"; + +// 새 상품 추가 폼 +export const ProductForm = ({ + editingProduct, + productForm, + setProductForm, + handleProductSubmit, + resetProductForm, +}: { + editingProduct: string | null; + productForm: Omit; + setProductForm: (productForm: Omit) => void; + handleProductSubmit: (e: React.FormEvent) => void; + resetProductForm: () => void; +}) => { + return ( +
+
+

+ {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) { + toast.error("가격은 0보다 커야 합니다"); + 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) { + toast.error("재고는 0보다 커야 합니다"); + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + toast.error("재고는 9999개를 초과할 수 없습니다"); + 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='%' + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/advanced/components/product/ProductInfo.tsx b/src/advanced/components/product/ProductInfo.tsx new file mode 100644 index 000000000..edd098e56 --- /dev/null +++ b/src/advanced/components/product/ProductInfo.tsx @@ -0,0 +1,56 @@ +import { useSetAtom } from "jotai"; +import type { ProductWithUI } from "../../hooks/useProducts"; +import { deleteProductAtom } from "../../stores/atoms/productAtoms"; + +export const ProductInfo = ({ + product, + productPrice, + handleEditProduct, +}: { + product: ProductWithUI; + productPrice: (price: number, productId?: string) => string; + handleEditProduct: (product: ProductWithUI) => void; +}) => { + const deleteProduct = useSetAtom(deleteProductAtom); + + return ( + + + {product.name} + + + {productPrice(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 || "-"} + + + + + + + ); +}; diff --git a/src/advanced/components/product/index.ts b/src/advanced/components/product/index.ts new file mode 100644 index 000000000..088bb929c --- /dev/null +++ b/src/advanced/components/product/index.ts @@ -0,0 +1,5 @@ +import { ProductAccordion } from "./ProductAccordion"; +import { ProductForm } from "./ProductForm"; +import { ProductInfo } from "./ProductInfo"; + +export { ProductAccordion, ProductForm, ProductInfo }; diff --git a/src/advanced/components/search/SearchBar.tsx b/src/advanced/components/search/SearchBar.tsx new file mode 100644 index 000000000..5d16e0ec9 --- /dev/null +++ b/src/advanced/components/search/SearchBar.tsx @@ -0,0 +1,28 @@ +import { useEffect } from "react"; +import { useAtom } from "jotai"; +import { debouncedSearchTermAtom, searchTermAtom } from "../../stores/atoms/uiAtoms"; + +export const SearchBar = () => { + const [searchTerm, setSearchTerm] = useAtom(searchTermAtom); + const [, setDebouncedTerm] = useAtom(debouncedSearchTermAtom); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedTerm(searchTerm); + }, 500); + + return () => clearTimeout(timer); + }, [searchTerm, setDebouncedTerm]); + + return ( +
+ setSearchTerm(e.target.value)} + placeholder='상품 검색...' + className='w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500' + /> +
+ ); +}; diff --git a/src/advanced/components/search/index.ts b/src/advanced/components/search/index.ts new file mode 100644 index 000000000..1f8c5be67 --- /dev/null +++ b/src/advanced/components/search/index.ts @@ -0,0 +1,3 @@ +import { SearchBar } from "./SearchBar"; + +export { SearchBar }; diff --git a/src/advanced/components/toast/ToastContainer.tsx b/src/advanced/components/toast/ToastContainer.tsx new file mode 100644 index 000000000..159f13a50 --- /dev/null +++ b/src/advanced/components/toast/ToastContainer.tsx @@ -0,0 +1,16 @@ +import { Toast } from "../ui/Toast"; +import { useNotification } from "../../utils/hooks/useNotification"; + +export const ToastContainer = () => { + const { notifications, remove } = useNotification(); + + return ( + notifications.length > 0 && ( +
+ {notifications.map((notif) => ( + + ))} +
+ ) + ); +}; diff --git a/src/advanced/components/toast/index.ts b/src/advanced/components/toast/index.ts new file mode 100644 index 000000000..7c483810b --- /dev/null +++ b/src/advanced/components/toast/index.ts @@ -0,0 +1,3 @@ +import { ToastContainer } from "./ToastContainer"; + +export { ToastContainer }; diff --git a/src/advanced/components/ui/Toast.tsx b/src/advanced/components/ui/Toast.tsx new file mode 100644 index 000000000..632e3c50a --- /dev/null +++ b/src/advanced/components/ui/Toast.tsx @@ -0,0 +1,23 @@ +import type { ToastMessage } from "../../utils/toast"; +import { XIcon } from "../icons"; + +export const Toast = ({ notif, remove }: { notif: ToastMessage; remove: (id: string) => void }) => { + const colors: Record = { + error: "bg-red-600", + warning: "bg-yellow-600", + success: "bg-green-600", + }; + + return ( +
+ {notif.message} + +
+ ); +}; diff --git a/src/advanced/components/ui/UIToast.ts b/src/advanced/components/ui/UIToast.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts new file mode 100644 index 000000000..c341eec20 --- /dev/null +++ b/src/advanced/hooks/useCart.ts @@ -0,0 +1,123 @@ +// TODO: 장바구니 관리 Hook +// 힌트: +// 1. 장바구니 상태 관리 (localStorage 연동) +// 2. 상품 추가/삭제/수량 변경 +// 3. 쿠폰 적용 +// 4. 총액 계산 +// 5. 재고 확인 +// + +import { useCallback, useMemo, useState } from "react"; +import { + addItemToCart, + removeItemFromCart, + updateCartItemQuantity, + calculateCartTotal, + getRemainingStock, + calculateItemTotal, +} from "../models/cart"; +import type { Coupon, CartItem } from "../../types"; +import type { ProductWithUI } from "./useProducts"; +import { toast } from "../utils/toast"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; + +export const useCart = () => { + const [cart, setCart, removeCart] = useLocalStorage("cart", initialCart); + + // 이게 여기 있어야만 하는지???? + const [selectedCoupon, setSelectedCoupon] = useState(null); + + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(product, cart); + if (remainingStock <= 0) { + toast.error("재고가 부족합니다!"); + return; + } + + setCart((prevCart) => addItemToCart(prevCart, product)); + + toast.success("장바구니에 담았습니다"); + }, + [cart, setCart] + ); + const remainingStock = useCallback( + (product: ProductWithUI) => getRemainingStock(product, cart), + [cart] + ); + + const removeFromCart = useCallback((productId: string) => { + setCart((prevCart) => removeItemFromCart(prevCart, productId)); + }, [setCart]); + + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal(cart, coupon).totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + toast.error("percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다."); + return; + } + + setSelectedCoupon(coupon); + toast.success("쿠폰이 적용되었습니다."); + }, + [cart] + ); + + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + const product = cart.find(({ product }) => product.id === productId)?.product; + if (!product) return; + + const maxStock = product.stock; + if (newQuantity > maxStock) { + toast.error(`재고는 ${maxStock}개까지만 있습니다.`); + return; + } + + setCart((prevCart) => updateCartItemQuantity(prevCart, productId, newQuantity)); + }, + [cart, setCart] + ); + + const totals = useMemo(() => { + return calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + const calcItemTotal = useCallback( + (item: CartItem) => { + return calculateItemTotal(item, cart); + }, + [cart] + ); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + toast.success(`주문이 완료되었습니다. 주문번호: ${orderNumber}`); + setCart(initialCart); + setSelectedCoupon(null); + }, [setCart]); + + const clearCart = useCallback(() => { + setCart(initialCart); + removeCart(); + }, [setCart, removeCart]); + + return { + cart, + selectedCoupon, + setSelectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + totals, + remainingStock, + calcItemTotal, + completeOrder, + clearCart, + }; +}; + +const initialCart: CartItem[] = []; diff --git a/src/advanced/hooks/useCoupons.ts b/src/advanced/hooks/useCoupons.ts new file mode 100644 index 000000000..b80a731e2 --- /dev/null +++ b/src/advanced/hooks/useCoupons.ts @@ -0,0 +1,66 @@ +// TODO: 쿠폰 관리 Hook +// 힌트: +// 1. 쿠폰 목록 상태 관리 (localStorage 연동 고려) +// 2. 쿠폰 추가/삭제 +// +// 반환할 값: +// - coupons: 쿠폰 배열 +// - addCoupon: 새 쿠폰 추가 +// - removeCoupon: 쿠폰 삭제 + +import { useCallback } from "react"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; +import { toast } from "../utils/toast"; +import type { Coupon } from "../../types"; + +export function useCoupons( + selectedCoupon: Coupon | null, + setSelectedCoupon: (coupon: Coupon | null) => void +) { + const [coupons, setCoupons] = useLocalStorage("coupons", initialCoupons); + + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + toast.error("이미 존재하는 쿠폰 코드입니다."); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + toast.success("쿠폰이 추가되었습니다."); + }, + [coupons, setCoupons] + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + toast.success("쿠폰이 삭제되었습니다."); + }, + [selectedCoupon, setSelectedCoupon, setCoupons] + ); + + return { + coupons, + addCoupon, + deleteCoupon, + }; +} + +const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; diff --git a/src/advanced/hooks/useProducts.ts b/src/advanced/hooks/useProducts.ts new file mode 100644 index 000000000..41bddebb0 --- /dev/null +++ b/src/advanced/hooks/useProducts.ts @@ -0,0 +1,114 @@ +// TODO: 상품 관리 Hook +// 힌트: +// 1. 상품 목록 상태 관리 (localStorage 연동 고려) +// 2. 상품 CRUD 작업 +// 3. 재고 업데이트 +// 4. 할인 규칙 추가/삭제 +// + +import { Product } from "../../types"; +import { useCallback } from "react"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; +import { toast } from "../utils/toast"; + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export const useProducts = () => { + const [products, setProducts] = useLocalStorage("products", initialProducts); + + /**상품 정보 수정 */ + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) + ); + toast.success("상품이 수정되었습니다."); + }, + [setProducts] + ); + + /**새 상품 추가 */ + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + toast.success("상품이 추가되었습니다."); + }, + [setProducts] + ); + + /**재고 수정 */ + const updateProductStock = () => { + return; + }; + + const deleteProduct = useCallback( + (productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + toast.success("상품이 삭제되었습니다."); + }, + [setProducts] + ); + + /**할인 규칙 추가 */ + const addProductDiscount = () => { + return; + }; + + /**할인 규칙 삭제 */ + const removeProductDiscount = () => { + return; + }; + + return { + addProduct, // C + products, // R + updateProduct, // U + deleteProduct, // D + + updateProductStock, + addProductDiscount, + removeProductDiscount, + }; +}; + +// 초기 데이터 +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: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; diff --git a/src/advanced/models/cart.ts b/src/advanced/models/cart.ts new file mode 100644 index 000000000..6023d3b35 --- /dev/null +++ b/src/advanced/models/cart.ts @@ -0,0 +1,127 @@ +import { CartItem, Coupon, Product } from "../../types"; +import { toast } from "../utils/toast"; +// TODO: 장바구니 비즈니스 로직 (순수 함수) +// 힌트: 모든 함수는 순수 함수로 구현 (부작용 없음, 같은 입력에 항상 같은 출력) +// +// 구현할 함수들: +// 1. calculateItemTotal(item): 개별 아이템의 할인 적용 후 총액 계산 +// 2. getMaxApplicableDiscount(item): 적용 가능한 최대 할인율 계산 +// 3. calculateCartTotal(cart, coupon): 장바구니 총액 계산 (할인 전/후, 할인액) +// 4. updateCartItemQuantity(cart, productId, quantity): 수량 변경 +// 5. addItemToCart(cart, product): 상품 추가 +// 6. removeItemFromCart(cart, productId): 상품 제거 +// 7. getRemainingStock(product, cart): 남은 재고 계산 +// +// 원칙: +// - UI와 관련된 로직 없음 +// - 외부 상태에 의존하지 않음 +// - 모든 필요한 데이터는 파라미터로 전달받음 + +/**개별 아이템의 할인 적용 후 총액 계산*/ +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 getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + } + + return baseDiscount; +}; + +/**장바구니 총액 계산 (할인 전/후, 할인액)*/ +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === "amount") { + totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; + +/**수량 변경*/ +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + quantity: number +): CartItem[] => { + if (quantity <= 0) { + return removeItemFromCart(cart, productId); + } + + return cart.map((item) => (item.product.id === productId ? { ...item, quantity } : item)); +}; + +/**상품 추가 */ +export const addItemToCart = (cart: CartItem[], product: Product) => { + const existingItem = cart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + toast.error(`재고는 ${product.stock}개까지만 있습니다.`); + return cart; + } + + return cart.map((item) => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item + ); + } + + return [...cart, { product, quantity: 1 }]; +}; + +/**상품 제거 */ +export const removeItemFromCart = (cart: CartItem[], productId: string) => { + const newCart = cart.filter((item) => item.product.id !== productId); + + return newCart; +}; + +/**남은 재고 계산 */ +export const getRemainingStock = (product: Product, cart: CartItem[]) => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +}; diff --git a/src/advanced/models/coupon.ts b/src/advanced/models/coupon.ts new file mode 100644 index 000000000..ea6397604 Binary files /dev/null and b/src/advanced/models/coupon.ts differ diff --git a/src/advanced/models/discount.ts b/src/advanced/models/discount.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/advanced/models/product.ts b/src/advanced/models/product.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/advanced/stores/atoms/cartAtoms.ts b/src/advanced/stores/atoms/cartAtoms.ts new file mode 100644 index 000000000..525c0a237 --- /dev/null +++ b/src/advanced/stores/atoms/cartAtoms.ts @@ -0,0 +1,78 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; +import { selectedCouponAtom } from "./couponAtoms"; + +import { + addItemToCart, + removeItemFromCart, + updateCartItemQuantity, + calculateCartTotal, + getRemainingStock, +} from "../../models/cart"; +import { toast } from "../../utils/toast"; +import { canApplyPercentageCoupon } from "../../models/coupon"; +import type { CartItem, Coupon } from "../../../types"; +import type { ProductWithUI } from "./productAtoms"; + +const initialCart: CartItem[] = []; +export const cartAtom = atomWithStorage("cart", initialCart); + +export const totalsAtom = atom((get) => { + const cart = get(cartAtom); + const selectedCoupon = get(selectedCouponAtom); + return calculateCartTotal(cart, selectedCoupon); +}); + +export const addToCartAtom = atom(null, (get, set, product: ProductWithUI) => { + const cart = get(cartAtom); + const remaining = getRemainingStock(product, cart); + + if (remaining <= 0) { + toast.error("재고가 부족합니다!"); + return; + } + + set(cartAtom, addItemToCart(cart, product)); + toast.success("장바구니에 담았습니다"); +}); + +export const removeFromCartAtom = atom(null, (get, set, productId: string) => { + set(cartAtom, removeItemFromCart(get(cartAtom), productId)); +}); + +export const updateQuantityAtom = atom( + null, + (get, set, { productId, newQuantity }: { productId: string; newQuantity: number }) => { + const cart = get(cartAtom); + const product = cart.find(({ product }) => product.id === productId)?.product; + + if (!product) return; + + if (newQuantity > product.stock) { + toast.error(`재고는 ${product.stock}개까지만 있습니다.`); + return; + } + + set(cartAtom, updateCartItemQuantity(cart, productId, newQuantity)); + } +); + +export const applyCouponAtom = atom(null, (get, set, coupon: Coupon) => { + const cart = get(cartAtom); + const currentTotal = calculateCartTotal(cart, coupon).totalAfterDiscount; + + if (coupon.discountType === "percentage" && !canApplyPercentageCoupon(currentTotal)) { + toast.error("percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다."); + return; + } + + set(selectedCouponAtom, coupon); + toast.success("쿠폰이 적용되었습니다."); +}); + +export const completeOrderAtom = atom(null, (_, set) => { + const orderNumber = `ORD-${Date.now()}`; + toast.success(`주문이 완료되었습니다. 주문번호: ${orderNumber}`); + set(cartAtom, []); + set(selectedCouponAtom, null); +}); diff --git a/src/advanced/stores/atoms/couponAtoms.ts b/src/advanced/stores/atoms/couponAtoms.ts new file mode 100644 index 000000000..253942eab --- /dev/null +++ b/src/advanced/stores/atoms/couponAtoms.ts @@ -0,0 +1,49 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; +import { toast } from "../../utils/toast"; +import { isCouponCodeExists, addCoupon, deleteCoupon } from "../../models/coupon"; +import type { Coupon } from "../../../types"; + +const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; + +export const couponsAtom = atomWithStorage("coupons", initialCoupons); +export const selectedCouponAtom = atom(null); + +export const addCouponAtom = atom(null, (get, set, newCoupon: Coupon) => { + const coupons = get(couponsAtom); + + if (isCouponCodeExists(coupons, newCoupon.code)) { + toast.error("이미 존재하는 쿠폰 코드입니다."); + return; + } + + set(couponsAtom, addCoupon(coupons, newCoupon)); + toast.success("쿠폰이 추가되었습니다."); +}); + +export const deleteCouponAtom = atom(null, (get, set, couponCode: string) => { + const selectedCoupon = get(selectedCouponAtom); + + set(couponsAtom, deleteCoupon(get(couponsAtom), couponCode)); + + if (selectedCoupon?.code === couponCode) { + set(selectedCouponAtom, null); + } + + toast.success("쿠폰이 삭제되었습니다."); +}); + +// applyCoupon diff --git a/src/advanced/stores/atoms/productAtoms.ts b/src/advanced/stores/atoms/productAtoms.ts new file mode 100644 index 000000000..01bed625b --- /dev/null +++ b/src/advanced/stores/atoms/productAtoms.ts @@ -0,0 +1,77 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; +import { toast } from "../../utils/toast"; +import type { Product } from "../../../types"; + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +// 초기 데이터 +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 productAtom = atomWithStorage("products", initialProducts); + +export const updateProductAtom = atom( + null, + (get, set, { productId, updates }: { productId: string; updates: Partial }) => { + const products = get(productAtom); + const updatedProducts = products.map((product) => + product.id === productId ? { ...product, ...updates } : product + ); + set(productAtom, updatedProducts); + toast.success("상품이 수정되었습니다."); + } +); + +export const addProductAtom = atom(null, (get, set, newProduct: Omit) => { + const products = get(productAtom); + const newProductWithId = { ...newProduct, id: `p${Date.now()}` }; + set(productAtom, [...products, newProductWithId]); + toast.success("상품이 추가되었습니다."); +}); + +export const deleteProductAtom = atom(null, (get, set, productId: string) => { + const products = get(productAtom); + const updatedProducts = products.filter((product) => product.id !== productId); + set(productAtom, updatedProducts); + toast.success("상품이 삭제되었습니다."); +}); + +// updateProductStock; +// addProductDiscount; +// removeProductDiscount; diff --git a/src/advanced/stores/atoms/uiAtoms.ts b/src/advanced/stores/atoms/uiAtoms.ts new file mode 100644 index 000000000..5243d34ab --- /dev/null +++ b/src/advanced/stores/atoms/uiAtoms.ts @@ -0,0 +1,5 @@ +import { atom } from "jotai"; + +export const isAdminAtom = atom(false); +export const searchTermAtom = atom(""); +export const debouncedSearchTermAtom = atom(""); diff --git a/src/advanced/stores/index.ts b/src/advanced/stores/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts new file mode 100644 index 000000000..9a38faefe --- /dev/null +++ b/src/advanced/utils/formatters.ts @@ -0,0 +1,26 @@ +// TODO: 포맷팅 유틸리티 함수들 + +/**가격을 한국 원화 형식으로 포맷*/ +export const formatPrice = ( + price: number | string, + unit: "₩" | "원" = "원", + locale: "ko-KR" | "en-US" = "ko-KR" +): string => { + let formattedPrice = price; + + if (typeof price === "string") { + formattedPrice = Number(price); + } + + return unit === "₩" + ? `₩${formattedPrice.toLocaleString(locale)}` + : `${formattedPrice.toLocaleString(locale)}원`; +}; +/**날짜를 YYYY-MM-DD 형식으로 포맷*/ +export const formatDate = (date: Date): string => { + return date.toISOString().split("T")[0]; +}; +/**소수를 퍼센트로 변환 (0.1 → 10%)*/ +export const formatPercentage = (rate: number): string => { + return (rate * 100).toFixed(0) + "%"; +}; diff --git a/src/advanced/utils/hooks/useDebounce.ts b/src/advanced/utils/hooks/useDebounce.ts new file mode 100644 index 000000000..aafced293 --- /dev/null +++ b/src/advanced/utils/hooks/useDebounce.ts @@ -0,0 +1,23 @@ +// TODO: 디바운스 Hook +// 힌트: +// 1. 값이 변경되어도 지정된 시간 동안 대기 +// 2. 대기 시간 동안 값이 다시 변경되면 타이머 리셋 +// 3. 최종적으로 안정된 값만 반환 +// +// 사용 예시: 검색어 입력 디바운싱 + +import { useEffect, useState } from "react"; + +export function useDebounce(initialValue: T, delay: number = 500): [T, T, (value: T) => void] { + const [value, setValue] = useState(initialValue); + const [debouncedValue, setDebouncedValue] = useState(initialValue); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return [value, debouncedValue, setValue]; +} diff --git a/src/advanced/utils/hooks/useLocalStorage.ts b/src/advanced/utils/hooks/useLocalStorage.ts new file mode 100644 index 000000000..aa4eb80b9 --- /dev/null +++ b/src/advanced/utils/hooks/useLocalStorage.ts @@ -0,0 +1,51 @@ +// TODO: LocalStorage Hook +// 힌트: +// 1. localStorage와 React state 동기화 +// 2. 초기값 로드 시 에러 처리 +// 3. 저장 시 JSON 직렬화/역직렬화 +// 4. 빈 배열이나 undefined는 삭제 +// +// 반환값: [저장된 값, 값 설정 함수] + +import { useCallback, useEffect, useState } from "react"; +import { storage } from "../storage"; + +// loose Autocomplete pattern +// https://x.com/mattpocockuk/status/1823380970147369171 +// https://lackluster.tistory.com/239 +type TKey = "products" | "coupons" | "cart"; + +export function useLocalStorage( + key: TKey | (string & {}), + initialValue: T +): [T, (value: T | ((val: T) => T)) => void, () => void] { + const [value, setValue] = useState(() => storage.get(key) ?? initialValue); + + useEffect(() => { + return storage.subscribe(key, (newValue) => { + setValue(newValue ?? initialValue); + }); + }, [key, initialValue]); + + const set = useCallback( + (newValue: T | ((val: T) => T)) => { + const valueToStore = + newValue instanceof Function ? newValue(storage.get(key) ?? initialValue) : newValue; + + // 빈 배열이나 undefined는 삭제 + if (valueToStore === undefined || (Array.isArray(valueToStore) && valueToStore.length === 0)) { + storage.remove(key); + return; + } + + storage.set(key, valueToStore); + }, + [key, initialValue] + ); + + const remove = useCallback(() => { + storage.remove(key); + }, [key]); + + return [value, set, remove]; +} diff --git a/src/advanced/utils/hooks/useNotification.ts b/src/advanced/utils/hooks/useNotification.ts new file mode 100644 index 000000000..debe888ca --- /dev/null +++ b/src/advanced/utils/hooks/useNotification.ts @@ -0,0 +1,23 @@ +import { useEffect, useState, useCallback } from "react"; +import { toast } from "../toast"; +import type { ToastMessage } from "../toast"; + +export const useNotification = (duration = 3000) => { + const [notifications, setNotifications] = useState([]); + + useEffect(() => { + return toast.subscribe((newToast) => { + setNotifications((prev) => [...prev, newToast]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== newToast.id)); + }, duration); + }); + }, [duration]); + + const remove = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return { notifications, remove }; +}; diff --git a/src/advanced/utils/hooks/useValidate.ts b/src/advanced/utils/hooks/useValidate.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/advanced/utils/storage.ts b/src/advanced/utils/storage.ts new file mode 100644 index 000000000..7906b6944 --- /dev/null +++ b/src/advanced/utils/storage.ts @@ -0,0 +1,31 @@ +type Listener = (value: T | null) => void; + +const listeners = new Map>>(); + +export const storage = { + get(key: string): T | null { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : null; + }, + set(key: string, value: T) { + localStorage.setItem(key, JSON.stringify(value)); + listeners.get(key)?.forEach((listener) => listener(value)); + }, + remove(key: string) { + localStorage.removeItem(key); + listeners.get(key)?.forEach((listener) => listener(null)); + }, + subscribe(key: string, listener: Listener) { + if (!listeners.has(key)) { + listeners.set(key, new Set()); + } + listeners.get(key)?.add(listener); + + return () => { + listeners.get(key)?.delete(listener); + if (listeners.get(key)?.size === 0) { + listeners.delete(key); + } + }; + }, +}; diff --git a/src/advanced/utils/toast.ts b/src/advanced/utils/toast.ts new file mode 100644 index 000000000..3c6a2f682 --- /dev/null +++ b/src/advanced/utils/toast.ts @@ -0,0 +1,36 @@ +export interface ToastMessage { + id: string; + message: string; + type: "error" | "success" | "warning"; +} + +type Listener = (message: ToastMessage) => void; + +const listeners = new Set(); + +const emit = (message: string, type: ToastMessage["type"]) => { + const toastState = { + id: Date.now().toString(), + message, + type, + }; + listeners.forEach((listener) => listener(toastState)); +}; + +export const toast = { + success: (message: string) => { + emit(message, "success"); + }, + error: (message: string) => { + emit(message, "error"); + }, + warning: (message: string) => { + emit(message, "warning"); + }, + subscribe: (listener: Listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, +}; diff --git a/src/advanced/utils/validators.ts b/src/advanced/utils/validators.ts new file mode 100644 index 000000000..32bbfa7ab --- /dev/null +++ b/src/advanced/utils/validators.ts @@ -0,0 +1,18 @@ +// TODO: 검증 유틸리티 함수들 + +/**쿠폰 코드 형식 검증 (4-12자 영문 대문자와 숫자)*/ +export const isValidCouponCode = (code: string): boolean => { + return /^[A-Z0-9]{4,12}$/.test(code); +}; +/**재고 수량 검증 (0 이상)*/ +export const isValidStock = (stock: number): boolean => { + return stock >= 0; +}; +/**가격 검증 (양수)*/ +export const isValidPrice = (price: number): boolean => { + return price > 0; +}; +/**문자열에서 숫자만 추출*/ +export const extractNumbers = (value: string): string => { + return value.replace(/[^0-9]/g, "") || ""; +}; diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..006922176 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,1124 +1,73 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; +import { useState } from "react"; -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +import { Header, AdminPage, CartPage } from "./components/layout"; +import { ToastContainer } from "./components/toast"; +import { useCart } from "./hooks/useCart"; +import { useProducts } from "./hooks/useProducts"; +import { useCoupons } from "./hooks/useCoupons"; +import { useDebounce } from "./utils/hooks/useDebounce"; +import { SearchBar } from "./components/search"; const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; - + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebounce(""); + + const { products, addProduct, updateProduct, deleteProduct } = useProducts(); + const { + cart, + selectedCoupon, + setSelectedCoupon, + calcItemTotal, + removeFromCart, + updateQuantity, + applyCoupon, + totals, + remainingStock, + addToCart, + completeOrder, + } = useCart(); + const { coupons, addCoupon, deleteCoupon } = useCoupons(selectedCoupon, setSelectedCoupon); return ( -
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

SHOP

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

관리자 대시보드

-

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

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

상품 목록

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

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

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

쿠폰 관리

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

{coupon.name}

-

{coupon.code}

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

새 쿠폰 생성

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

전체 상품

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

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

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

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

- - - - 장바구니 -

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

장바구니가 비어있습니다

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

{item.product.name}

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

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

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

쿠폰 할인

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

결제 정보

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

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

-
-
- - )} -
-
-
+ )}
); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/basic/components/admin/AdminHeader.tsx b/src/basic/components/admin/AdminHeader.tsx new file mode 100644 index 000000000..d1591fecc --- /dev/null +++ b/src/basic/components/admin/AdminHeader.tsx @@ -0,0 +1,40 @@ +export const AdminHeader = ({ + activeTab, + setActiveTab, +}: { + activeTab: "products" | "coupons"; + setActiveTab: (tab: "products" | "coupons") => void; +}) => { + return ( + <> +
+

관리자 대시보드

+

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

+
+
+ +
+ + ); +}; diff --git a/src/basic/components/admin/ManagementCoupon.tsx b/src/basic/components/admin/ManagementCoupon.tsx new file mode 100644 index 000000000..8346f0c61 --- /dev/null +++ b/src/basic/components/admin/ManagementCoupon.tsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import { CouponList, CouponForm } from "../coupon"; + +import type { Coupon } from "../../../types"; + +export const ManagementCoupon = ({ + coupons, + addCoupon, + deleteCoupon, +}: // selectedCoupon, +// setSelectedCoupon, +{ + coupons: Coupon[]; + addCoupon: (newCoupon: Omit) => void; + deleteCoupon: (couponCode: string) => void; + // selectedCoupon: Coupon | null; + // setSelectedCoupon: (coupon: Coupon | null) => void; +}) => { + const [show, setShow] = useState(false); + + const [couponForm, setCouponForm] = useState>(couponFormInit); + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addCoupon(couponForm); + setCouponForm(couponFormInit); + setShow(false); + }; + + return ( +
+
+

쿠폰 관리

+
+
+ + {show && ( + + )} +
+
+ ); +}; + +const couponFormInit: Omit = { + name: "", + code: "", + discountType: "amount", + discountValue: 0, +}; diff --git a/src/basic/components/admin/ManagementProduct.tsx b/src/basic/components/admin/ManagementProduct.tsx new file mode 100644 index 000000000..ff3abed82 --- /dev/null +++ b/src/basic/components/admin/ManagementProduct.tsx @@ -0,0 +1,92 @@ +import { useCallback, useState } from "react"; +import { ProductForm, ProductAccordion } from "../product"; +import type { ProductWithUI } from "../../hooks/useProducts"; + +export const ManagementProduct = ({ + products, + addProduct, + updateProduct, + deleteProduct, + remainingStock, +}: { + products: ProductWithUI[]; + addProduct: (newProduct: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; + remainingStock: (product: ProductWithUI) => number; +}) => { + const [show, setShow] = useState(false); + + const [productForm, setProductForm] = useState>(productFormInit); + + const [editingProduct, setEditingProduct] = useState(null); + + const resetProductForm = useCallback(() => { + setProductForm(productFormInit); + setEditingProduct(null); + setShow(false); + }, [setProductForm, setEditingProduct, setShow]); + + const handleProductSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== "new") { + updateProduct(editingProduct, productForm); + setEditingProduct(null); + } else { + addProduct({ + ...productForm, + discounts: productForm.discounts, + }); + } + resetProductForm(); + }, + [editingProduct, productForm, addProduct, updateProduct, resetProductForm] + ); + + return ( +
+
+
+

상품 목록

+ +
+
+ + + {show && ( + + )} +
+ ); +}; + +const productFormInit: Omit = { + name: "", + price: 0, + stock: 0, + description: "", + discounts: [], +}; diff --git a/src/basic/components/admin/index.ts b/src/basic/components/admin/index.ts new file mode 100644 index 000000000..a8569c84e --- /dev/null +++ b/src/basic/components/admin/index.ts @@ -0,0 +1,5 @@ +import { ManagementProduct } from "./ManagementProduct"; +import { ManagementCoupon } from "./ManagementCoupon"; +import { AdminHeader } from "./AdminHeader"; + +export { ManagementProduct, ManagementCoupon, AdminHeader }; diff --git a/src/basic/components/cart/Cart.tsx b/src/basic/components/cart/Cart.tsx new file mode 100644 index 000000000..5e51f8f60 --- /dev/null +++ b/src/basic/components/cart/Cart.tsx @@ -0,0 +1,80 @@ +import { BagIcon } from "../icons"; +import { CouponDiscount } from "../coupon/CouponDiscount"; +import { PaymentContainer } from "../payment/PaymentContainer"; +import { CartStockItem } from "./CartStockItem"; +import type { CartItem, Coupon } from "../../../types"; + +interface CartProps { + cart: CartItem[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + applyCoupon: (coupon: Coupon) => void; + calcItemTotal: (item: CartItem) => number; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; + totals: { totalBeforeDiscount: number; totalAfterDiscount: number }; + completeOrder: () => void; +} + +export const Cart = ({ + cart, + coupons, + selectedCoupon, + setSelectedCoupon, + applyCoupon, + calcItemTotal, + removeFromCart, + updateQuantity, + totals, + completeOrder, +}: CartProps) => { + const handleApplyCoupon = (e: React.ChangeEvent, coupons: Coupon[]) => { + const coupon = coupons.find((c) => c.code === e.target.value); + if (coupon) applyCoupon(coupon); + else setSelectedCoupon(null); + }; + + return ( +
+
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => { + const itemTotal = calcItemTotal(item); + return ( + + ); + })} +
+ )} +
+ + {cart.length > 0 && ( + <> + + + + )} +
+ ); +}; diff --git a/src/basic/components/cart/CartStockItem.tsx b/src/basic/components/cart/CartStockItem.tsx new file mode 100644 index 000000000..ac679a324 --- /dev/null +++ b/src/basic/components/cart/CartStockItem.tsx @@ -0,0 +1,58 @@ +import { XIcon } from "../icons"; +import { formatPrice } from "../../utils/formatters"; +import type { CartItem } from "../../../types"; + +export const CartStockItem = ({ + item, + itemTotal, + removeFromCart, + updateQuantity, +}: { + item: CartItem; + itemTotal: number; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; +}) => { + 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}% + )} +

+ {formatPrice(Math.round(itemTotal), "원")} +

+
+
+
+ ); +}; diff --git a/src/basic/components/cart/ProductItem.tsx b/src/basic/components/cart/ProductItem.tsx new file mode 100644 index 000000000..0f54e194a --- /dev/null +++ b/src/basic/components/cart/ProductItem.tsx @@ -0,0 +1,76 @@ +import { ProductIcon } from "../icons"; +import { ProductWithUI } from "../../hooks/useProducts"; + +export const ProductItem = ({ + product, + productPrice, + stock, + addToCart, +}: { + product: ProductWithUI; + productPrice: (price: number, productId?: string) => string; + stock: number; + addToCart: (product: ProductWithUI) => void; +}) => { + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

{product.description}

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

+ {productPrice(product.price, product.id)} +

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

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

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

품절임박! {stock}개 남음

+ )} + {stock > 5 &&

재고 {stock}개

} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; diff --git a/src/basic/components/cart/ProductList.tsx b/src/basic/components/cart/ProductList.tsx new file mode 100644 index 000000000..59ef9f249 --- /dev/null +++ b/src/basic/components/cart/ProductList.tsx @@ -0,0 +1,55 @@ +import { useCallback } from "react"; +import { formatPrice } from "../../utils/formatters"; +import { ProductWithUI } from "../../hooks/useProducts"; +import { ProductItem } from "./ProductItem"; + +export const ProductList = ({ + filteredProducts, + searchTerm, + remainingStock, + addToCart, +}: { + filteredProducts: ProductWithUI[]; + searchTerm: string; + remainingStock: (product: ProductWithUI) => number; + addToCart: (product: ProductWithUI) => void; +}) => { + const productPrice = useCallback( + (price: number, productId?: string) => { + if (productId) { + const product = filteredProducts.find((p) => p.id === productId); + if (product && remainingStock(product) <= 0) { + return "SOLD OUT"; + } + } + return formatPrice(price, "₩"); + }, + [filteredProducts, remainingStock] + ); + + return ( +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map((product) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/basic/components/cart/index.ts b/src/basic/components/cart/index.ts new file mode 100644 index 000000000..4d5fc3a5b --- /dev/null +++ b/src/basic/components/cart/index.ts @@ -0,0 +1,6 @@ +import { Cart } from "./Cart"; +import { ProductList } from "./ProductList"; +import { ProductItem } from "./ProductItem"; +import { CartStockItem } from "./CartStockItem"; + +export { Cart, ProductList, ProductItem, CartStockItem }; diff --git a/src/basic/components/coupon/CouponContainer.tsx b/src/basic/components/coupon/CouponContainer.tsx new file mode 100644 index 000000000..1f2760a32 --- /dev/null +++ b/src/basic/components/coupon/CouponContainer.tsx @@ -0,0 +1,38 @@ +import { Coupon } from "../../../types"; +import { TrashIcon } from "../icons"; + +export const CouponContainer = ({ + coupon, + deleteCoupon, +}: // selectedCoupon, +// setSelectedCoupon, +{ + coupon: Coupon; + deleteCoupon: (couponCode: string) => void; + // selectedCoupon: Coupon | null; + // setSelectedCoupon: (coupon: Coupon | null) => void; +}) => { + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +}; diff --git a/src/basic/components/coupon/CouponDiscount.tsx b/src/basic/components/coupon/CouponDiscount.tsx new file mode 100644 index 000000000..9cfd540f5 --- /dev/null +++ b/src/basic/components/coupon/CouponDiscount.tsx @@ -0,0 +1,39 @@ +import { formatPrice } from "../../utils/formatters"; +import type { Coupon } from "../../../types"; + +export const CouponDiscount = ({ + coupons, + selectedCoupon, + handleApplyCoupon, +}: { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + handleApplyCoupon: (e: React.ChangeEvent, coupons: Coupon[]) => void; +}) => { + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +}; diff --git a/src/basic/components/coupon/CouponForm.tsx b/src/basic/components/coupon/CouponForm.tsx new file mode 100644 index 000000000..f66523ebe --- /dev/null +++ b/src/basic/components/coupon/CouponForm.tsx @@ -0,0 +1,118 @@ +import type { Coupon } from "../../../types"; +import { toast } from "../../utils/toast"; + +// 새 쿠폰 추가 폼 +export const CouponForm = ({ + form, + setForm, + setShow, + handleCouponSubmit, +}: { + form: Coupon; + setForm: (form: Coupon) => void; + setShow: (show: boolean) => void; + handleCouponSubmit: (e: React.FormEvent) => void; +}) => { + return ( +
+
+

새 쿠폰 생성

+
+
+ + setForm({ ...form, 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 + /> +
+
+ + setForm({ ...form, 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)) { + setForm({ + ...form, + discountValue: value === "" ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + if (form.discountType === "percentage") { + if (value > 100) { + toast.error("할인율은 100%를 초과할 수 없습니다"); + + setForm({ ...form, discountValue: 100 }); + } else if (value < 0) { + setForm({ ...form, discountValue: 0 }); + } + } else { + if (value > 100000) { + toast.error("할인 금액은 100,000원을 초과할 수 없습니다"); + setForm({ ...form, discountValue: 100000 }); + } else if (value < 0) { + setForm({ ...form, 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={form.discountType === "amount" ? "5000" : "10"} + required + /> +
+
+
+ + +
+
+
+ ); +}; diff --git a/src/basic/components/coupon/CouponList.tsx b/src/basic/components/coupon/CouponList.tsx new file mode 100644 index 000000000..d21f5d58d --- /dev/null +++ b/src/basic/components/coupon/CouponList.tsx @@ -0,0 +1,42 @@ +import { PlusIcon } from "../icons"; +import type { Coupon } from "../../../types"; +import { CouponContainer } from "./CouponContainer"; + +// 쿠폰 목록 표시 +export const CouponList = ({ + coupons, + deleteCoupon, + setShow, +}: // selectedCoupon, +// setSelectedCoupon, +{ + coupons: Coupon[]; + deleteCoupon: (couponCode: string) => void; + setShow: (show: boolean) => void; + // selectedCoupon: Coupon | null; + // setSelectedCoupon: (coupon: Coupon | null) => void; +}) => { + return ( +
+ {coupons.map((coupon) => ( + + ))} + +
+ +
+
+ ); +}; diff --git a/src/basic/components/coupon/index.ts b/src/basic/components/coupon/index.ts new file mode 100644 index 000000000..c1f452ea1 --- /dev/null +++ b/src/basic/components/coupon/index.ts @@ -0,0 +1,6 @@ +import { CouponContainer } from "./CouponContainer"; +import { CouponDiscount } from "./CouponDiscount"; +import { CouponForm } from "./CouponForm"; +import { CouponList } from "./CouponList"; + +export { CouponContainer, CouponDiscount, CouponForm, CouponList }; diff --git a/src/basic/components/icons/AdminIcon.tsx b/src/basic/components/icons/AdminIcon.tsx new file mode 100644 index 000000000..636a5c235 --- /dev/null +++ b/src/basic/components/icons/AdminIcon.tsx @@ -0,0 +1,3 @@ +export const AdminIcon = () => { + return
AdminIcon
; +}; diff --git a/src/basic/components/icons/BagIcon.tsx b/src/basic/components/icons/BagIcon.tsx new file mode 100644 index 000000000..d91591a6f --- /dev/null +++ b/src/basic/components/icons/BagIcon.tsx @@ -0,0 +1,20 @@ +import { SvgBase } from "./SvgBase"; + +export const BagIcon = ({ + className = "w-16 h-16 text-gray-300 mx-auto mb-4", + strokeWidth = 2, +}: { + className?: string; + strokeWidth?: number; +}) => { + return ( + + + + ); +}; diff --git a/src/basic/components/icons/CartIcon.tsx b/src/basic/components/icons/CartIcon.tsx new file mode 100644 index 000000000..81374cce1 --- /dev/null +++ b/src/basic/components/icons/CartIcon.tsx @@ -0,0 +1,14 @@ +import { SvgBase } from "./SvgBase"; + +export const CartIcon = ({ className = "w-6 h-6 text-gray-700" }) => { + return ( + + + + ); +}; diff --git a/src/basic/components/icons/CheckIcon.tsx b/src/basic/components/icons/CheckIcon.tsx new file mode 100644 index 000000000..e15277594 --- /dev/null +++ b/src/basic/components/icons/CheckIcon.tsx @@ -0,0 +1,3 @@ +export const CheckIcon = () => { + return
CheckIcon
; +}; diff --git a/src/basic/components/icons/ChevronDownIcon.tsx b/src/basic/components/icons/ChevronDownIcon.tsx new file mode 100644 index 000000000..07233ea5d --- /dev/null +++ b/src/basic/components/icons/ChevronDownIcon.tsx @@ -0,0 +1,3 @@ +export const ChevronDownIcon = () => { + return
ChevronDownIcon
; +}; diff --git a/src/basic/components/icons/ChevronUpIcon.tsx b/src/basic/components/icons/ChevronUpIcon.tsx new file mode 100644 index 000000000..85367144f --- /dev/null +++ b/src/basic/components/icons/ChevronUpIcon.tsx @@ -0,0 +1,3 @@ +export const ChevronUpIcon = () => { + return
ChevronUpIcon
; +}; diff --git a/src/basic/components/icons/MinusIcon.tsx b/src/basic/components/icons/MinusIcon.tsx new file mode 100644 index 000000000..925584e48 --- /dev/null +++ b/src/basic/components/icons/MinusIcon.tsx @@ -0,0 +1,3 @@ +export const MinusIcon = () => { + return
MinusIcon
; +}; diff --git a/src/basic/components/icons/PlusIcon.tsx b/src/basic/components/icons/PlusIcon.tsx new file mode 100644 index 000000000..1ac1ea956 --- /dev/null +++ b/src/basic/components/icons/PlusIcon.tsx @@ -0,0 +1,9 @@ +import { SvgBase } from "./SvgBase"; + +export const PlusIcon = ({ className = "w-8 h-8" }) => { + return ( + + + + ); +}; diff --git a/src/basic/components/icons/ProductIcon.tsx b/src/basic/components/icons/ProductIcon.tsx new file mode 100644 index 000000000..cb520cb6d --- /dev/null +++ b/src/basic/components/icons/ProductIcon.tsx @@ -0,0 +1,14 @@ +import { SvgBase } from "./SvgBase"; + +export const ProductIcon = ({ className = "w-24 h-24 text-gray-300" }) => { + return ( + + + + ); +}; diff --git a/src/basic/components/icons/SvgBase.tsx b/src/basic/components/icons/SvgBase.tsx new file mode 100644 index 000000000..21b1c877e --- /dev/null +++ b/src/basic/components/icons/SvgBase.tsx @@ -0,0 +1,6 @@ +export const SvgBase = ({ + children, + ...props +}: React.PropsWithChildren>) => { + return {children}; +}; diff --git a/src/basic/components/icons/TrashIcon.tsx b/src/basic/components/icons/TrashIcon.tsx new file mode 100644 index 000000000..03f8c8fed --- /dev/null +++ b/src/basic/components/icons/TrashIcon.tsx @@ -0,0 +1,14 @@ +import { SvgBase } from "./SvgBase"; + +export const TrashIcon = ({ className = "w-5 h-5" }) => { + return ( + + + + ); +}; diff --git a/src/basic/components/icons/XIcon.tsx b/src/basic/components/icons/XIcon.tsx new file mode 100644 index 000000000..88a18f79d --- /dev/null +++ b/src/basic/components/icons/XIcon.tsx @@ -0,0 +1,9 @@ +import { SvgBase } from "./SvgBase"; + +export const XIcon = ({ className = "w-4 h-4" }) => { + return ( + + + + ); +}; diff --git a/src/basic/components/icons/index.ts b/src/basic/components/icons/index.ts new file mode 100644 index 000000000..56bbea3a9 --- /dev/null +++ b/src/basic/components/icons/index.ts @@ -0,0 +1,25 @@ +import { CartIcon } from "./CartIcon"; +import { AdminIcon } from "./AdminIcon"; +import { PlusIcon } from "./PlusIcon"; +import { MinusIcon } from "./MinusIcon"; +import { TrashIcon } from "./TrashIcon"; +import { ChevronDownIcon } from "./ChevronDownIcon"; +import { ChevronUpIcon } from "./ChevronUpIcon"; +import { CheckIcon } from "./CheckIcon"; +import { XIcon } from "./XIcon"; +import { BagIcon } from "./BagIcon"; +import { ProductIcon } from "./ProductIcon"; + +export { + CartIcon, + AdminIcon, + PlusIcon, + MinusIcon, + TrashIcon, + ChevronDownIcon, + ChevronUpIcon, + CheckIcon, + XIcon, + BagIcon, + ProductIcon, +}; diff --git a/src/basic/components/layout/AdminPage.tsx b/src/basic/components/layout/AdminPage.tsx new file mode 100644 index 000000000..612aea852 --- /dev/null +++ b/src/basic/components/layout/AdminPage.tsx @@ -0,0 +1,67 @@ +// TODO: 관리자 페이지 컴포넌트 +// 힌트: +// 1. 탭 UI로 상품 관리와 쿠폰 관리 분리 +// 2. 상품 추가/수정/삭제 기능 +// 3. 쿠폰 생성 기능 +// 4. 할인 규칙 설정 +// +// 필요한 hooks: +// - useProducts: 상품 CRUD +// - useCoupons: 쿠폰 CRUD +// + +import { useState } from "react"; +import { Coupon } from "../../../types"; +import { ManagementCoupon, AdminHeader, ManagementProduct } from "../admin"; +import type { ProductWithUI } from "../../hooks/useProducts"; + +interface AdminPageProps { + products: ProductWithUI[]; + coupons: Coupon[]; + addProduct: (newProduct: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; + addCoupon: (newCoupon: Omit) => void; + deleteCoupon: (couponCode: string) => void; + // selectedCoupon: Coupon | null; + // setSelectedCoupon: (coupon: Coupon | null) => void; + remainingStock: (product: ProductWithUI) => number; +} + +export function AdminPage({ + products, + addProduct, + updateProduct, + deleteProduct, + coupons, + addCoupon, + deleteCoupon, + remainingStock, +}: // selectedCoupon, +// setSelectedCoupon, +AdminPageProps) { + const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); + + return ( +
+ + {activeTab === "products" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/basic/components/layout/CartPage.tsx b/src/basic/components/layout/CartPage.tsx new file mode 100644 index 000000000..3c9aa9979 --- /dev/null +++ b/src/basic/components/layout/CartPage.tsx @@ -0,0 +1,93 @@ +// TODO: 장바구니 페이지 컴포넌트 +// 힌트: +// 1. 상품 목록 표시 (검색 기능 포함) +// 2. 장바구니 관리 +// 3. 쿠폰 적용 +// 4. 주문 처리 +// +// 필요한 hooks: +// - useProducts: 상품 목록 관리 +// - useCart: 장바구니 상태 관리 +// - useCoupons: 쿠폰 목록 관리 +// - useDebounce: 검색어 디바운싱 +// +// 하위 컴포넌트: +// - SearchBar: 검색 입력 +// - ProductList: 상품 목록 표시 +// - Cart: 장바구니 표시 및 결제 + +import { ProductList } from "../cart/ProductList"; +import { CartItem, Coupon } from "../../../types"; +import { Cart } from "../cart/Cart"; +import { ProductWithUI } from "../../hooks/useProducts"; +interface CartPageProps { + cart: CartItem[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + setSelectedCoupon: (coupon: Coupon | null) => void; + applyCoupon: (coupon: Coupon) => void; + calcItemTotal: (item: CartItem) => number; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => void; + totals: { totalBeforeDiscount: number; totalAfterDiscount: number }; + completeOrder: () => void; + searchTerm: string; + products: ProductWithUI[]; + addToCart: (product: ProductWithUI) => void; + remainingStock: (product: ProductWithUI) => number; +} + +export function CartPage({ + cart, + coupons, + selectedCoupon, + setSelectedCoupon, + applyCoupon, + calcItemTotal, + removeFromCart, + updateQuantity, + totals, + completeOrder, + searchTerm, + products, + addToCart, + remainingStock, +}: CartPageProps) { + const filteredProducts = searchTerm + ? products.filter( + (product) => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (product.description && + product.description.toLowerCase().includes(searchTerm.toLowerCase())) + ) + : products; + + return ( +
+
+ {/* 상품 목록 */} + +
+ +
+ +
+
+ ); +} diff --git a/src/basic/components/layout/Header.tsx b/src/basic/components/layout/Header.tsx new file mode 100644 index 000000000..35dd49852 --- /dev/null +++ b/src/basic/components/layout/Header.tsx @@ -0,0 +1,51 @@ +import { PropsWithChildren, useEffect, useState } from "react"; +import { CartItem } from "../../../types"; +import { CartIcon } from "../icons"; + +interface HeaderProps { + isAdmin: boolean; + setIsAdmin: (value: boolean) => void; + cart: CartItem[]; +} + +export const Header = ({ isAdmin, setIsAdmin, cart, children }: PropsWithChildren) => { + const [totalItemCount, setTotalItemCount] = useState(0); + + useEffect(() => { + const count = cart.reduce((sum, item) => sum + item.quantity, 0); + setTotalItemCount(count); + }, [cart]); + + return ( +
+
+
+
+

SHOP

+ {!isAdmin && children} +
+ +
+
+
+ ); +}; diff --git a/src/basic/components/layout/index.ts b/src/basic/components/layout/index.ts new file mode 100644 index 000000000..e2da693e1 --- /dev/null +++ b/src/basic/components/layout/index.ts @@ -0,0 +1,5 @@ +import { Header } from "./Header"; +import { AdminPage } from "./AdminPage"; +import { CartPage } from "./CartPage"; + +export { Header, AdminPage, CartPage }; diff --git a/src/basic/components/payment/PaymentContainer.tsx b/src/basic/components/payment/PaymentContainer.tsx new file mode 100644 index 000000000..52175f547 --- /dev/null +++ b/src/basic/components/payment/PaymentContainer.tsx @@ -0,0 +1,46 @@ +import { formatPrice } from "../../utils/formatters"; + +export const PaymentContainer = ({ + totals, + completeOrder, +}: { + totals: { totalBeforeDiscount: number; totalAfterDiscount: number }; + completeOrder: () => void; +}) => { + return ( +
+

결제 정보

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

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

+
+
+ ); +}; diff --git a/src/basic/components/payment/index.ts b/src/basic/components/payment/index.ts new file mode 100644 index 000000000..69e6b0528 --- /dev/null +++ b/src/basic/components/payment/index.ts @@ -0,0 +1,3 @@ +import { PaymentContainer } from "./PaymentContainer"; + +export { PaymentContainer }; diff --git a/src/basic/components/product/ProductAccordion.tsx b/src/basic/components/product/ProductAccordion.tsx new file mode 100644 index 000000000..54cd3a1ad --- /dev/null +++ b/src/basic/components/product/ProductAccordion.tsx @@ -0,0 +1,84 @@ +import { useCallback } from "react"; +import { formatPrice } from "../../utils/formatters"; +import type { ProductWithUI } from "../../hooks/useProducts"; +import { ProductInfo } from "./ProductInfo"; + +//상품 정보 표시 및 수정 +export const ProductAccordion = ({ + products, + deleteProduct, + setEditingProduct, + setProductForm, + setShow, + remainingStock, +}: { + products: ProductWithUI[]; + deleteProduct: (productId: string) => void; + setEditingProduct: (productId: string) => void; + setProductForm: (productForm: Omit) => void; + setShow: (show: boolean) => void; + remainingStock: (product: ProductWithUI) => number; +}) => { + const handleEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [], + }); + setShow(true); + }; + + const productPrice = useCallback( + (price: number, productId?: string) => { + if (productId) { + const product = products.find((p) => p.id === productId); + if (product && remainingStock(product) <= 0) { + return "SOLD OUT"; + } + } + // return formatPrice(price, "₩"); + return formatPrice(price); + }, + [products, remainingStock] + ); + + return ( +
+ + + + + + + + + + + + {products.map((product) => ( + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+
+ ); +}; diff --git a/src/basic/components/product/ProductForm.tsx b/src/basic/components/product/ProductForm.tsx new file mode 100644 index 000000000..d1ecc1182 --- /dev/null +++ b/src/basic/components/product/ProductForm.tsx @@ -0,0 +1,182 @@ +import { ProductWithUI } from "../../hooks/useProducts"; +import { toast } from "../../utils/toast"; +import { XIcon } from "../icons"; + +// 새 상품 추가 폼 +export const ProductForm = ({ + editingProduct, + productForm, + setProductForm, + handleProductSubmit, + resetProductForm, +}: { + editingProduct: string | null; + productForm: Omit; + setProductForm: (productForm: Omit) => void; + handleProductSubmit: (e: React.FormEvent) => void; + resetProductForm: () => void; +}) => { + return ( +
+
+

+ {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) { + toast.error("가격은 0보다 커야 합니다"); + 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) { + toast.error("재고는 0보다 커야 합니다"); + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + toast.error("재고는 9999개를 초과할 수 없습니다"); + 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='%' + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/basic/components/product/ProductInfo.tsx b/src/basic/components/product/ProductInfo.tsx new file mode 100644 index 000000000..3ddfb4d9f --- /dev/null +++ b/src/basic/components/product/ProductInfo.tsx @@ -0,0 +1,54 @@ +import type { ProductWithUI } from "../../hooks/useProducts"; + +export const ProductInfo = ({ + product, + productPrice, + handleEditProduct, + deleteProduct, +}: { + product: ProductWithUI; + productPrice: (price: number, productId?: string) => string; + handleEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; +}) => { + return ( + + + {product.name} + + + {productPrice(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 || "-"} + + + + + + + ); +}; diff --git a/src/basic/components/product/index.ts b/src/basic/components/product/index.ts new file mode 100644 index 000000000..088bb929c --- /dev/null +++ b/src/basic/components/product/index.ts @@ -0,0 +1,5 @@ +import { ProductAccordion } from "./ProductAccordion"; +import { ProductForm } from "./ProductForm"; +import { ProductInfo } from "./ProductInfo"; + +export { ProductAccordion, ProductForm, ProductInfo }; diff --git a/src/basic/components/search/SearchBar.tsx b/src/basic/components/search/SearchBar.tsx new file mode 100644 index 000000000..195d41e59 --- /dev/null +++ b/src/basic/components/search/SearchBar.tsx @@ -0,0 +1,18 @@ +interface SearchBarProps { + searchTerm: string; + setSearchTerm: (value: string) => void; +} + +export const SearchBar = ({ searchTerm, setSearchTerm }: SearchBarProps) => { + return ( +
+ setSearchTerm(e.target.value)} + placeholder='상품 검색...' + className='w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500' + /> +
+ ); +}; diff --git a/src/basic/components/search/index.ts b/src/basic/components/search/index.ts new file mode 100644 index 000000000..1f8c5be67 --- /dev/null +++ b/src/basic/components/search/index.ts @@ -0,0 +1,3 @@ +import { SearchBar } from "./SearchBar"; + +export { SearchBar }; diff --git a/src/basic/components/toast/ToastContainer.tsx b/src/basic/components/toast/ToastContainer.tsx new file mode 100644 index 000000000..159f13a50 --- /dev/null +++ b/src/basic/components/toast/ToastContainer.tsx @@ -0,0 +1,16 @@ +import { Toast } from "../ui/Toast"; +import { useNotification } from "../../utils/hooks/useNotification"; + +export const ToastContainer = () => { + const { notifications, remove } = useNotification(); + + return ( + notifications.length > 0 && ( +
+ {notifications.map((notif) => ( + + ))} +
+ ) + ); +}; diff --git a/src/basic/components/toast/index.ts b/src/basic/components/toast/index.ts new file mode 100644 index 000000000..7c483810b --- /dev/null +++ b/src/basic/components/toast/index.ts @@ -0,0 +1,3 @@ +import { ToastContainer } from "./ToastContainer"; + +export { ToastContainer }; diff --git a/src/basic/components/ui/Toast.tsx b/src/basic/components/ui/Toast.tsx new file mode 100644 index 000000000..632e3c50a --- /dev/null +++ b/src/basic/components/ui/Toast.tsx @@ -0,0 +1,23 @@ +import type { ToastMessage } from "../../utils/toast"; +import { XIcon } from "../icons"; + +export const Toast = ({ notif, remove }: { notif: ToastMessage; remove: (id: string) => void }) => { + const colors: Record = { + error: "bg-red-600", + warning: "bg-yellow-600", + success: "bg-green-600", + }; + + return ( +
+ {notif.message} + +
+ ); +}; diff --git a/src/basic/components/ui/UIToast.ts b/src/basic/components/ui/UIToast.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 000000000..c341eec20 --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,123 @@ +// TODO: 장바구니 관리 Hook +// 힌트: +// 1. 장바구니 상태 관리 (localStorage 연동) +// 2. 상품 추가/삭제/수량 변경 +// 3. 쿠폰 적용 +// 4. 총액 계산 +// 5. 재고 확인 +// + +import { useCallback, useMemo, useState } from "react"; +import { + addItemToCart, + removeItemFromCart, + updateCartItemQuantity, + calculateCartTotal, + getRemainingStock, + calculateItemTotal, +} from "../models/cart"; +import type { Coupon, CartItem } from "../../types"; +import type { ProductWithUI } from "./useProducts"; +import { toast } from "../utils/toast"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; + +export const useCart = () => { + const [cart, setCart, removeCart] = useLocalStorage("cart", initialCart); + + // 이게 여기 있어야만 하는지???? + const [selectedCoupon, setSelectedCoupon] = useState(null); + + const addToCart = useCallback( + (product: ProductWithUI) => { + const remainingStock = getRemainingStock(product, cart); + if (remainingStock <= 0) { + toast.error("재고가 부족합니다!"); + return; + } + + setCart((prevCart) => addItemToCart(prevCart, product)); + + toast.success("장바구니에 담았습니다"); + }, + [cart, setCart] + ); + const remainingStock = useCallback( + (product: ProductWithUI) => getRemainingStock(product, cart), + [cart] + ); + + const removeFromCart = useCallback((productId: string) => { + setCart((prevCart) => removeItemFromCart(prevCart, productId)); + }, [setCart]); + + const applyCoupon = useCallback( + (coupon: Coupon) => { + const currentTotal = calculateCartTotal(cart, coupon).totalAfterDiscount; + + if (currentTotal < 10000 && coupon.discountType === "percentage") { + toast.error("percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다."); + return; + } + + setSelectedCoupon(coupon); + toast.success("쿠폰이 적용되었습니다."); + }, + [cart] + ); + + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + const product = cart.find(({ product }) => product.id === productId)?.product; + if (!product) return; + + const maxStock = product.stock; + if (newQuantity > maxStock) { + toast.error(`재고는 ${maxStock}개까지만 있습니다.`); + return; + } + + setCart((prevCart) => updateCartItemQuantity(prevCart, productId, newQuantity)); + }, + [cart, setCart] + ); + + const totals = useMemo(() => { + return calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + const calcItemTotal = useCallback( + (item: CartItem) => { + return calculateItemTotal(item, cart); + }, + [cart] + ); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + toast.success(`주문이 완료되었습니다. 주문번호: ${orderNumber}`); + setCart(initialCart); + setSelectedCoupon(null); + }, [setCart]); + + const clearCart = useCallback(() => { + setCart(initialCart); + removeCart(); + }, [setCart, removeCart]); + + return { + cart, + selectedCoupon, + setSelectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + totals, + remainingStock, + calcItemTotal, + completeOrder, + clearCart, + }; +}; + +const initialCart: CartItem[] = []; diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts new file mode 100644 index 000000000..b80a731e2 --- /dev/null +++ b/src/basic/hooks/useCoupons.ts @@ -0,0 +1,66 @@ +// TODO: 쿠폰 관리 Hook +// 힌트: +// 1. 쿠폰 목록 상태 관리 (localStorage 연동 고려) +// 2. 쿠폰 추가/삭제 +// +// 반환할 값: +// - coupons: 쿠폰 배열 +// - addCoupon: 새 쿠폰 추가 +// - removeCoupon: 쿠폰 삭제 + +import { useCallback } from "react"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; +import { toast } from "../utils/toast"; +import type { Coupon } from "../../types"; + +export function useCoupons( + selectedCoupon: Coupon | null, + setSelectedCoupon: (coupon: Coupon | null) => void +) { + const [coupons, setCoupons] = useLocalStorage("coupons", initialCoupons); + + const addCoupon = useCallback( + (newCoupon: Coupon) => { + const existingCoupon = coupons.find((c) => c.code === newCoupon.code); + if (existingCoupon) { + toast.error("이미 존재하는 쿠폰 코드입니다."); + return; + } + setCoupons((prev) => [...prev, newCoupon]); + toast.success("쿠폰이 추가되었습니다."); + }, + [coupons, setCoupons] + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + toast.success("쿠폰이 삭제되었습니다."); + }, + [selectedCoupon, setSelectedCoupon, setCoupons] + ); + + return { + coupons, + addCoupon, + deleteCoupon, + }; +} + +const initialCoupons: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000, + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10, + }, +]; diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts new file mode 100644 index 000000000..fe19eeed5 --- /dev/null +++ b/src/basic/hooks/useProducts.ts @@ -0,0 +1,105 @@ +// TODO: 상품 관리 Hook +// 힌트: +// 1. 상품 목록 상태 관리 (localStorage 연동 고려) +// 2. 상품 CRUD 작업 +// 3. 재고 업데이트 +// 4. 할인 규칙 추가/삭제 +// + +import { Product } from "../../types"; +import { useCallback } from "react"; +import { useLocalStorage } from "../utils/hooks/useLocalStorage"; +import { toast } from "../utils/toast"; + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export const useProducts = () => { + const [products, setProducts] = useLocalStorage("products", initialProducts); + + /**상품 정보 수정 */ + const updateProduct = useCallback((productId: string, updates: Partial) => { + setProducts((prev) => + prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) + ); + toast.success("상품이 수정되었습니다."); + }, [setProducts]); + + /**새 상품 추가 */ + const addProduct = useCallback((newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + toast.success("상품이 추가되었습니다."); + }, [setProducts]); + + /**재고 수정 */ + const updateProductStock = () => { + return; + }; + + const deleteProduct = useCallback((productId: string) => { + setProducts((prev) => prev.filter((p) => p.id !== productId)); + toast.success("상품이 삭제되었습니다."); + }, [setProducts]); + + /**할인 규칙 추가 */ + const addProductDiscount = () => { + return; + }; + + /**할인 규칙 삭제 */ + const removeProductDiscount = () => { + return; + }; + + return { + addProduct, // C + products, // R + updateProduct, // U + deleteProduct, // D + + updateProductStock, + addProductDiscount, + removeProductDiscount, + }; +}; + +// 초기 데이터 +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: "대용량과 고성능을 자랑하는 상품입니다.", + }, +]; diff --git a/src/basic/models/cart.ts b/src/basic/models/cart.ts new file mode 100644 index 000000000..6023d3b35 --- /dev/null +++ b/src/basic/models/cart.ts @@ -0,0 +1,127 @@ +import { CartItem, Coupon, Product } from "../../types"; +import { toast } from "../utils/toast"; +// TODO: 장바구니 비즈니스 로직 (순수 함수) +// 힌트: 모든 함수는 순수 함수로 구현 (부작용 없음, 같은 입력에 항상 같은 출력) +// +// 구현할 함수들: +// 1. calculateItemTotal(item): 개별 아이템의 할인 적용 후 총액 계산 +// 2. getMaxApplicableDiscount(item): 적용 가능한 최대 할인율 계산 +// 3. calculateCartTotal(cart, coupon): 장바구니 총액 계산 (할인 전/후, 할인액) +// 4. updateCartItemQuantity(cart, productId, quantity): 수량 변경 +// 5. addItemToCart(cart, product): 상품 추가 +// 6. removeItemFromCart(cart, productId): 상품 제거 +// 7. getRemainingStock(product, cart): 남은 재고 계산 +// +// 원칙: +// - UI와 관련된 로직 없음 +// - 외부 상태에 의존하지 않음 +// - 모든 필요한 데이터는 파라미터로 전달받음 + +/**개별 아이템의 할인 적용 후 총액 계산*/ +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 getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 + } + + return baseDiscount; +}; + +/**장바구니 총액 계산 (할인 전/후, 할인액)*/ +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach((item) => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === "amount") { + totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; + +/**수량 변경*/ +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + quantity: number +): CartItem[] => { + if (quantity <= 0) { + return removeItemFromCart(cart, productId); + } + + return cart.map((item) => (item.product.id === productId ? { ...item, quantity } : item)); +}; + +/**상품 추가 */ +export const addItemToCart = (cart: CartItem[], product: Product) => { + const existingItem = cart.find((item) => item.product.id === product.id); + + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + + if (newQuantity > product.stock) { + toast.error(`재고는 ${product.stock}개까지만 있습니다.`); + return cart; + } + + return cart.map((item) => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item + ); + } + + return [...cart, { product, quantity: 1 }]; +}; + +/**상품 제거 */ +export const removeItemFromCart = (cart: CartItem[], productId: string) => { + const newCart = cart.filter((item) => item.product.id !== productId); + + return newCart; +}; + +/**남은 재고 계산 */ +export const getRemainingStock = (product: Product, cart: CartItem[]) => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +}; diff --git a/src/basic/models/coupon.ts b/src/basic/models/coupon.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/models/discount.ts b/src/basic/models/discount.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/models/product.ts b/src/basic/models/product.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/utils/formatters.ts b/src/basic/utils/formatters.ts new file mode 100644 index 000000000..9a38faefe --- /dev/null +++ b/src/basic/utils/formatters.ts @@ -0,0 +1,26 @@ +// TODO: 포맷팅 유틸리티 함수들 + +/**가격을 한국 원화 형식으로 포맷*/ +export const formatPrice = ( + price: number | string, + unit: "₩" | "원" = "원", + locale: "ko-KR" | "en-US" = "ko-KR" +): string => { + let formattedPrice = price; + + if (typeof price === "string") { + formattedPrice = Number(price); + } + + return unit === "₩" + ? `₩${formattedPrice.toLocaleString(locale)}` + : `${formattedPrice.toLocaleString(locale)}원`; +}; +/**날짜를 YYYY-MM-DD 형식으로 포맷*/ +export const formatDate = (date: Date): string => { + return date.toISOString().split("T")[0]; +}; +/**소수를 퍼센트로 변환 (0.1 → 10%)*/ +export const formatPercentage = (rate: number): string => { + return (rate * 100).toFixed(0) + "%"; +}; diff --git a/src/basic/utils/hooks/useDebounce.ts b/src/basic/utils/hooks/useDebounce.ts new file mode 100644 index 000000000..aafced293 --- /dev/null +++ b/src/basic/utils/hooks/useDebounce.ts @@ -0,0 +1,23 @@ +// TODO: 디바운스 Hook +// 힌트: +// 1. 값이 변경되어도 지정된 시간 동안 대기 +// 2. 대기 시간 동안 값이 다시 변경되면 타이머 리셋 +// 3. 최종적으로 안정된 값만 반환 +// +// 사용 예시: 검색어 입력 디바운싱 + +import { useEffect, useState } from "react"; + +export function useDebounce(initialValue: T, delay: number = 500): [T, T, (value: T) => void] { + const [value, setValue] = useState(initialValue); + const [debouncedValue, setDebouncedValue] = useState(initialValue); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return [value, debouncedValue, setValue]; +} diff --git a/src/basic/utils/hooks/useLocalStorage.ts b/src/basic/utils/hooks/useLocalStorage.ts new file mode 100644 index 000000000..aa4eb80b9 --- /dev/null +++ b/src/basic/utils/hooks/useLocalStorage.ts @@ -0,0 +1,51 @@ +// TODO: LocalStorage Hook +// 힌트: +// 1. localStorage와 React state 동기화 +// 2. 초기값 로드 시 에러 처리 +// 3. 저장 시 JSON 직렬화/역직렬화 +// 4. 빈 배열이나 undefined는 삭제 +// +// 반환값: [저장된 값, 값 설정 함수] + +import { useCallback, useEffect, useState } from "react"; +import { storage } from "../storage"; + +// loose Autocomplete pattern +// https://x.com/mattpocockuk/status/1823380970147369171 +// https://lackluster.tistory.com/239 +type TKey = "products" | "coupons" | "cart"; + +export function useLocalStorage( + key: TKey | (string & {}), + initialValue: T +): [T, (value: T | ((val: T) => T)) => void, () => void] { + const [value, setValue] = useState(() => storage.get(key) ?? initialValue); + + useEffect(() => { + return storage.subscribe(key, (newValue) => { + setValue(newValue ?? initialValue); + }); + }, [key, initialValue]); + + const set = useCallback( + (newValue: T | ((val: T) => T)) => { + const valueToStore = + newValue instanceof Function ? newValue(storage.get(key) ?? initialValue) : newValue; + + // 빈 배열이나 undefined는 삭제 + if (valueToStore === undefined || (Array.isArray(valueToStore) && valueToStore.length === 0)) { + storage.remove(key); + return; + } + + storage.set(key, valueToStore); + }, + [key, initialValue] + ); + + const remove = useCallback(() => { + storage.remove(key); + }, [key]); + + return [value, set, remove]; +} diff --git a/src/basic/utils/hooks/useNotification.ts b/src/basic/utils/hooks/useNotification.ts new file mode 100644 index 000000000..debe888ca --- /dev/null +++ b/src/basic/utils/hooks/useNotification.ts @@ -0,0 +1,23 @@ +import { useEffect, useState, useCallback } from "react"; +import { toast } from "../toast"; +import type { ToastMessage } from "../toast"; + +export const useNotification = (duration = 3000) => { + const [notifications, setNotifications] = useState([]); + + useEffect(() => { + return toast.subscribe((newToast) => { + setNotifications((prev) => [...prev, newToast]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== newToast.id)); + }, duration); + }); + }, [duration]); + + const remove = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return { notifications, remove }; +}; diff --git a/src/basic/utils/hooks/useValidate.ts b/src/basic/utils/hooks/useValidate.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/basic/utils/storage.ts b/src/basic/utils/storage.ts new file mode 100644 index 000000000..7906b6944 --- /dev/null +++ b/src/basic/utils/storage.ts @@ -0,0 +1,31 @@ +type Listener = (value: T | null) => void; + +const listeners = new Map>>(); + +export const storage = { + get(key: string): T | null { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : null; + }, + set(key: string, value: T) { + localStorage.setItem(key, JSON.stringify(value)); + listeners.get(key)?.forEach((listener) => listener(value)); + }, + remove(key: string) { + localStorage.removeItem(key); + listeners.get(key)?.forEach((listener) => listener(null)); + }, + subscribe(key: string, listener: Listener) { + if (!listeners.has(key)) { + listeners.set(key, new Set()); + } + listeners.get(key)?.add(listener); + + return () => { + listeners.get(key)?.delete(listener); + if (listeners.get(key)?.size === 0) { + listeners.delete(key); + } + }; + }, +}; diff --git a/src/basic/utils/toast.ts b/src/basic/utils/toast.ts new file mode 100644 index 000000000..3c6a2f682 --- /dev/null +++ b/src/basic/utils/toast.ts @@ -0,0 +1,36 @@ +export interface ToastMessage { + id: string; + message: string; + type: "error" | "success" | "warning"; +} + +type Listener = (message: ToastMessage) => void; + +const listeners = new Set(); + +const emit = (message: string, type: ToastMessage["type"]) => { + const toastState = { + id: Date.now().toString(), + message, + type, + }; + listeners.forEach((listener) => listener(toastState)); +}; + +export const toast = { + success: (message: string) => { + emit(message, "success"); + }, + error: (message: string) => { + emit(message, "error"); + }, + warning: (message: string) => { + emit(message, "warning"); + }, + subscribe: (listener: Listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, +}; diff --git a/src/basic/utils/validators.ts b/src/basic/utils/validators.ts new file mode 100644 index 000000000..32bbfa7ab --- /dev/null +++ b/src/basic/utils/validators.ts @@ -0,0 +1,18 @@ +// TODO: 검증 유틸리티 함수들 + +/**쿠폰 코드 형식 검증 (4-12자 영문 대문자와 숫자)*/ +export const isValidCouponCode = (code: string): boolean => { + return /^[A-Z0-9]{4,12}$/.test(code); +}; +/**재고 수량 검증 (0 이상)*/ +export const isValidStock = (stock: number): boolean => { + return stock >= 0; +}; +/**가격 검증 (양수)*/ +export const isValidPrice = (price: number): boolean => { + return price > 0; +}; +/**문자열에서 숫자만 추출*/ +export const extractNumbers = (value: string): string => { + return value.replace(/[^0-9]/g, "") || ""; +}; diff --git a/src/origin/App.tsx b/src/origin/App.tsx index a4369fe1d..a451c113d 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,58 @@ 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 +162,7 @@ const App = () => { const { price } = item.product; const { quantity } = item; const discount = getMaxApplicableDiscount(item); - + return Math.round(price * quantity * (1 - discount)); }; @@ -177,44 +173,48 @@ 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') { + 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 +222,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 +244,155 @@ 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] + ); - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); + 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'); + 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 +401,10 @@ const App = () => { e.preventDefault(); addCoupon(couponForm); setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 + name: "", + code: "", + discountType: "amount", + discountValue: 0, }); setShowCouponForm(false); }; @@ -399,8 +415,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,74 +424,91 @@ 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; return ( -
+
{notifications.length > 0 && ( -
- {notifications.map(notif => ( +
+ {notifications.map((notif) => (
- {notif.message} -
))}
)} -
-
-
-
-

SHOP

+
+
+
+
+

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" + placeholder='상품 검색...' + className='w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500' />
)}
-
-
+
{isAdmin ? ( -
-
-

관리자 대시보드

-

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

+
+
+

관리자 대시보드

+

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

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

상품 목록

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

상품 목록

+ +
-
-
- - - - - - - - - - - - {(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) }); + + + {(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, 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' + 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 - /> + 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="%" - /> - % 할인 - -
- ))} + { + 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 - /> -
-
-
- +
+
+

{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}개 상품 -
+
+

전체 상품

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

@@ -1051,62 +1250,72 @@ const App = () => { {cart.length > 0 && ( <> -
-
-

쿠폰 할인

- +
+
+

쿠폰 할인

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

결제 정보

-
-
- 상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 +
+

결제 정보

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

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

@@ -1121,4 +1330,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..26ed04514 100644 --- a/src/refactoring(hint)/utils/hooks/useDebounce.ts +++ b/src/refactoring(hint)/utils/hooks/useDebounce.ts @@ -6,6 +6,6 @@ // // 사용 예시: 검색어 입력 디바운싱 -export function useDebounce(value: T, delay: number): T { - // TODO: 구현 -} \ No newline at end of file +// export function useDebounce(value: T, delay: number): T { +// // TODO: 구현 +// } diff --git a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts b/src/refactoring(hint)/utils/hooks/useLocalStorage.ts index 5dc72c501..92fbd0cf1 100644 --- a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts +++ b/src/refactoring(hint)/utils/hooks/useLocalStorage.ts @@ -7,9 +7,9 @@ // // 반환값: [저장된 값, 값 설정 함수] -export function useLocalStorage( - key: string, - initialValue: T -): [T, (value: T | ((val: T) => T)) => void] { - // TODO: 구현 -} \ No newline at end of file +// export function useLocalStorage( +// key: string, +// initialValue: T +// ): [T, (value: T | ((val: T) => T)) => void] { +// // TODO: 구현 +// } diff --git a/vite.config.ts b/vite.config.ts index e6c4016bc..c03f6b2a1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,27 @@ -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 path from "path"; + +const base = process.env.NODE_ENV === "production" ? "/front_7th_chapter3-2/" : ""; export default mergeConfig( defineConfig({ plugins: [react()], + base, + build: { + rollupOptions: { + input: { + main: path.resolve(__dirname, "index.advanced.html"), + }, + }, + }, }), defineTestConfig({ test: { globals: true, - environment: 'jsdom', - setupFiles: './src/setupTests.ts' + environment: "jsdom", + setupFiles: "./src/setupTests.ts", }, }) -) +);