diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index d6c95379..00000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - root: true, - env: { browser: true, es2020: true }, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react-hooks/recommended', - ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parser: '@typescript-eslint/parser', - plugins: ['react-refresh'], - rules: { - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, -} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 65ba6d2d..93a62b3a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -39,7 +39,6 @@ - [ ] 도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거했나요? - [ ] 전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드가 되었나요? - ## 과제 셀프회고 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13336e27..61f46fba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,3 @@ - name: CI on: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..32a2ebe7 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,40 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: latest + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build:advanced + env: + NODE_ENV: production + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + if: github.ref == 'refs/heads/main' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..8fdd954d --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..92d76cf8 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,35 @@ +# Prettier 무시 파일들 +node_modules/ +dist/ +build/ +.vite/ +coverage/ +pnpm-lock.yaml +package-lock.json +yarn.lock + +# 바이너리 파일들 +*.png +*.jpg +*.jpeg +*.gif +*.svg +*.ico +*.woff +*.woff2 +*.ttf +*.eot + +# 테스트 파일들 +src/advanced/__tests__/ +src/basic/__tests__/ +src/origin/__tests__/ + +# 원본 파일들 +src/origin/ +src/refactoring(hint)/ +index.origin.html + +# 기타 +.env* +.DS_Store diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 00000000..6cb1f985 --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,12 @@ +printWidth: 100 +tabWidth: 2 +useTabs: false +singleQuote: false +semi: true +bracketSpacing: true +arrowParens: always +trailingComma: none +jsxSingleQuote: false +bracketSameLine: false +plugins: + - prettier-plugin-tailwindcss diff --git a/README.md b/README.md index 3198c545..4a19edbc 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,15 @@ 이번 과제는 단일책임원칙을 위반한 거대한 컴포넌트를 리팩토링 하는 것입니다. React의 컴포넌트는 단일 책임 원칙(Single Responsibility Principle, SRP)을 따르는 것이 좋습니다. 즉, 각 컴포넌트는 하나의 책임만을 가져야 합니다. 하지만 실제로는 여러 가지 기능을 가진 거대한 컴포넌트를 작성하는 경우가 많습니다. [목표] + ## 1. 취지 + - React의 추구미(!)를 이해해보아요! - 단일 책임 원칙(SRP)을 위반한 거대한 컴포넌트가 얼마나 안 좋은지 경험해보아요! - 단일 책임이라는 개념을 이해하기 상태, 순수함수, 컴포넌트, 훅 등 다양한 계층을 이해해합니다. - 엔티티와 UI를 구분하고 데이터, 상태, 비즈니스 로직 등의 특징이 다르다는 것을 이해해보세요. - 이를 통해 적절한 Custom Hook과 유틸리티 함수를 분리하고, 컴포넌트 계층 구조를 정리하는 능력을 갖춥니다! - ## 2. 목표 모든 소프트웨어에는 적절한 책임과 계층이 존재합니다. 하나의 계층(Component)만으로 소프트웨어를 구성하게 되면 나중에는 정리정돈이 되지 않은 코드를 만나게 됩니다. 예전에는 이러한 BestPractice에 대해서 혼돈의 시대였지만 FE가 진화를 거듭하는 과정에서 적절한 계측에 대한 합의가 이루어지고 있는 상태입니다. @@ -22,7 +23,7 @@ React의 주요 책임 계층은 Component, hook, function 등이 있습니다. - 엔티티를 다루는 상태와 그렇지 않은 상태 - cart, isCartFull vs isShowPopup - 엔티티를 다루는 컴포넌트와 훅 - CartItemView, useCart(), useProduct() - 엔티티를 다루지 않는 컴포넌트와 훅 - Button, useRoute, useEvent 등 -- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) +- 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str) 이번 과제의 목표는 이러한 계층을 이해하고 분리하여 정리정돈을 하는 기준이나 방법등을 습득하는데 있습니다. @@ -35,34 +36,34 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. #### 1) 장바구니 페이지 요구사항 - 상품 목록 - - 상품명, 가격, 재고 수량 등을 표시 - - 각 상품의 할인 정보 표시 - - 재고가 없는 경우 품절 표시가 되며 장바구니 추가가 불가능 + - 상품명, 가격, 재고 수량 등을 표시 + - 각 상품의 할인 정보 표시 + - 재고가 없는 경우 품절 표시가 되며 장바구니 추가가 불가능 - 장바구니 - - 장바구니 내 상품 수량 조절 가능 - - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시 - - 적용된 할인율 표시 (예: "10% 할인 적용") - - 장바구니 내 모든 상품의 총액을 계산해야 + - 장바구니 내 상품 수량 조절 가능 + - 각 상품의 이름, 가격, 수량과 적용된 할인율을 표시 + - 적용된 할인율 표시 (예: "10% 할인 적용") + - 장바구니 내 모든 상품의 총액을 계산해야 - 쿠폰 할인 - - 할인 쿠폰을 선택하면 적용하면 최종 결제 금액에 할인정보가 반영 + - 할인 쿠폰을 선택하면 적용하면 최종 결제 금액에 할인정보가 반영 - 주문요약 - - 할인 전 총 금액 - - 총 할인 금액 - - 최종 결제 금액 + - 할인 전 총 금액 + - 총 할인 금액 + - 최종 결제 금액 #### 2) 관리자 페이지 요구사항 - 상품 관리 - - 상품 정보 (상품명, 가격, 재고, 할인율) 수정 가능 - - 새로운 상품 추가 가능 - - 상품 제거 가능 + - 상품 정보 (상품명, 가격, 재고, 할인율) 수정 가능 + - 새로운 상품 추가 가능 + - 상품 제거 가능 - 할인 관리 - - 상품별 할인 정보 추가/수정/삭제 가능 - - 할인 조건 설정 (구매 수량에 따른 할인율) + - 상품별 할인 정보 추가/수정/삭제 가능 + - 할인 조건 설정 (구매 수량에 따른 할인율) - 쿠폰 관리 - - 전체 상품에 적용 가능한 쿠폰 생성 - - 쿠폰 정보 입력 (이름, 코드, 할인 유형, 할인 값) - - 할인 유형은 금액 또는 비율로 설정 가능 + - 전체 상품에 적용 가능한 쿠폰 생성 + - 쿠폰 정보 입력 (이름, 코드, 할인 유형, 할인 값) + - 할인 유형은 금액 또는 비율로 설정 가능 ### (2) 코드 개선 요구사항 @@ -88,9 +89,8 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. ### (3) 테스트 코드 통과하기 - - ## 심화과제: Props drilling + - 이번 심화과제는 Context나 Jotai를 사용해서 Props drilling을 없애는 것입니다. - 어떤 props는 남겨야 하는지, 어떤 props는 제거해야 하는지에 대한 기준을 세워보세요. - Context나 Jotai를 사용하여 상태를 관리하는 방법을 익히고, 이를 통해 컴포넌트 간의 데이터 전달을 효율적으로 처리할 수 있습니다. @@ -102,9 +102,8 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. - basic에서 열심히 컴포넌트를 분리해주었겠죠? - 아마 그 과정에서 container - presenter 패턴으로 만들어졌기에 props drilling이 상당히 불편했을거에요. - 그래서 심화과제에서는 props drilling을 제거하는 작업을 할거에요. - - 전역상태관리가 아직 낯설다 - jotai를 선택해주세요 (참고자료 참고) - - 나는 깊이를 공부해보고 싶아. - context를 선택해서 상태관리를 해보세요. - + - 전역상태관리가 아직 낯설다 - jotai를 선택해주세요 (참고자료 참고) + - 나는 깊이를 공부해보고 싶아. - context를 선택해서 상태관리를 해보세요. ### (1) 요구사항 @@ -112,15 +111,11 @@ basic의 경우 상태관리를 쓰지 않고 작업을 해주세요. - Context나 Jotai를 사용하여 상태를 관리합니다. - 테스트 코드를 통과합니다. - ### (2) 힌트 - UI 컴포넌트와 엔티티 컴포넌트는 각각 props를 다르게 받는게 좋습니다. - - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, + - UI 컴포넌트는 재사용과 독립성을 위해 상태를 최소화하고, - 엔티티 컴포넌트는 가급적 엔티티를 중심으로 전달받는 것이 좋습니다. - 특히 콜백의 경우, - UI 컴포넌트는 이벤트 핸들러를 props로 받아서 처리하도록 해서 재사용성을 높이지만, - 엔티티 컴포넌트는 props가 아닌 컴포넌트 내부에서 상태를 관리하는 것이 좋습니다. - - - diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..6dfe7b9b --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,88 @@ +import js from "@eslint/js"; +import tseslint from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import prettierConfig from "eslint-config-prettier"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import simpleImportSort from "eslint-plugin-simple-import-sort"; +import globals from "globals"; + +const ignoresConfig = { + ignores: [ + "node_modules/**", + "dist/**", + "build/**", + ".vite/**", + "coverage/**", + "src/**/__tests__/**", + "src/origin/", + "src/refactoring(hint)/", + "index.origin.html" + ] +}; + +const baseConfig = { + files: ["**/*.{js,jsx,ts,tsx}"], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + sourceType: "module" + }, + rules: { + ...js.configs.recommended.rules + } +}; + +const typescriptConfig = { + files: ["**/*.{ts,tsx}"], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module" + } + }, + plugins: { + "@typescript-eslint": tseslint + }, + rules: { + ...tseslint.configs.recommended.rules + } +}; + +const reactConfig = { + files: ["**/*.{jsx,tsx}"], + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { + allowConstantExport: true + } + ] + } +}; + +const importSortConfig = { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: { + "simple-import-sort": simpleImportSort + }, + rules: { + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error" + } +}; + +export default [ + ignoresConfig, + baseConfig, + typescriptConfig, + reactConfig, + importSortConfig, + prettierConfig +]; diff --git a/index.advanced.html b/index.advanced.html index 97a2b3e1..30c87546 100644 --- a/index.advanced.html +++ b/index.advanced.html @@ -1,13 +1,13 @@ - - - - 장바구니로 학습하는 디자인패턴 - - - -
- - - \ No newline at end of file + + + + 장바구니로 학습하는 디자인패턴 + + + +
+ + + diff --git a/index.basic.html b/index.basic.html index 67da41be..72a7adad 100644 --- a/index.basic.html +++ b/index.basic.html @@ -1,13 +1,13 @@ - - - - 장바구니로 학습하는 디자인패턴 - - - -
- - - \ No newline at end of file + + + + 장바구니로 학습하는 디자인패턴 + + + +
+ + + diff --git a/lefthook.yaml b/lefthook.yaml new file mode 100644 index 00000000..7750c9de --- /dev/null +++ b/lefthook.yaml @@ -0,0 +1,11 @@ +pre-commit: + parallel: true + commands: + lint: + glob: "**/*.{js,ts,tsx}" + run: pnpm lint:check + fail_text: "🚨 ESLint 검사 실패! 'pnpm lint'로 수정 후 다시 커밋하세요." + format: + glob: "**/*.{js,ts,tsx,html,json,yaml,yml,md}" + run: pnpm format:check + fail_text: "🎨 코드 포맷팅이 필요합니다! 'pnpm format'로 수정 후 다시 커밋하세요." diff --git a/package.json b/package.json index 79034acb..25e69819 100644 --- a/package.json +++ b/package.json @@ -13,16 +13,26 @@ "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" + "build:advanced": "vite build --mode advanced && mv dist/index.advanced.html dist/index.html", + "build:origin": "vite build --outDir dist-origin --emptyOutDir", + "build:basic": "vite build --outDir dist-basic --emptyOutDir", + "lint": "eslint . --fix", + "lint:check": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "dependencies": { + "jotai": "^2.13.0", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "tailwind-variants": "^2.1.0" }, "devDependencies": { + "@eslint/js": "^9.32.0", "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.2.0", "@types/react": "^19.1.9", "@types/react-dom": "^19.1.7", "@typescript-eslint/eslint-plugin": "^8.38.0", @@ -30,9 +40,15 @@ "@vitejs/plugin-react-swc": "^3.11.0", "@vitest/ui": "^3.2.4", "eslint": "^9.32.0", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", + "eslint-plugin-simple-import-sort": "^12.1.1", + "globals": "^16.3.0", "jsdom": "^26.1.0", + "lefthook": "^1.12.2", + "prettier": "^3.6.2", + "prettier-plugin-tailwindcss": "^0.6.14", "typescript": "^5.9.2", "vite": "^7.0.6", "vitest": "^3.2.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85..0ad47d23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,13 +8,22 @@ importers: .: dependencies: + jotai: + specifier: ^2.13.0 + version: 2.13.0(@types/react@19.1.9)(react@19.1.1) react: specifier: ^19.1.1 version: 19.1.1 react-dom: specifier: ^19.1.1 version: 19.1.1(react@19.1.1) + tailwind-variants: + specifier: ^2.1.0 + version: 2.1.0(tailwindcss@4.1.11) devDependencies: + '@eslint/js': + specifier: ^9.32.0 + version: 9.32.0 '@testing-library/jest-dom': specifier: ^6.6.4 version: 6.6.4 @@ -24,6 +33,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) + '@types/node': + specifier: ^24.2.0 + version: 24.2.0 '@types/react': specifier: ^19.1.9 version: 19.1.9 @@ -38,31 +50,49 @@ 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.2.0)) '@vitest/ui': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) eslint: specifier: ^9.32.0 version: 9.32.0 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.32.0) eslint-plugin-react-hooks: specifier: ^5.2.0 version: 5.2.0(eslint@9.32.0) eslint-plugin-react-refresh: specifier: ^0.4.20 version: 0.4.20(eslint@9.32.0) + eslint-plugin-simple-import-sort: + specifier: ^12.1.1 + version: 12.1.1(eslint@9.32.0) + globals: + specifier: ^16.3.0 + version: 16.3.0 jsdom: specifier: ^26.1.0 version: 26.1.0 + lefthook: + specifier: ^1.12.2 + version: 1.12.2 + prettier: + specifier: ^3.6.2 + version: 3.6.2 + prettier-plugin-tailwindcss: + specifier: ^0.6.14 + version: 0.6.14(prettier@3.6.2) typescript: specifier: ^5.9.2 version: 5.9.2 vite: specifier: ^7.0.6 - version: 7.0.6 + version: 7.0.6(@types/node@24.2.0) 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.2.0)(@vitest/ui@3.2.4)(jsdom@26.1.0) packages: @@ -583,6 +613,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@24.2.0': + resolution: {integrity: sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==} + '@types/react-dom@19.1.7': resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} peerDependencies: @@ -865,6 +898,12 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + eslint-plugin-react-hooks@5.2.0: resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} engines: {node: '>=10'} @@ -876,6 +915,11 @@ packages: peerDependencies: eslint: '>=8.40' + eslint-plugin-simple-import-sort@12.1.1: + resolution: {integrity: sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==} + peerDependencies: + eslint: '>=5.0.0' + eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -991,6 +1035,10 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globals@16.3.0: + resolution: {integrity: sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==} + engines: {node: '>=18'} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -1056,6 +1104,24 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jotai@2.13.0: + resolution: {integrity: sha512-H43zXdanNTdpfOEJ4NVbm4hgmrctpXLZagjJNcqAywhUv+sTE7esvFjwm5oBg/ywT9Qw63lIkM6fjrhFuW8UDg==} + 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==} @@ -1087,6 +1153,60 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + lefthook-darwin-arm64@1.12.2: + resolution: {integrity: sha512-fTxeI9tEskrHjc3QyEO+AG7impBXY2Ed8V5aiRc3fw9POfYtVh9b5jRx90fjk2+ld5hf+Z1DsyyLq/vOHDFskQ==} + cpu: [arm64] + os: [darwin] + + lefthook-darwin-x64@1.12.2: + resolution: {integrity: sha512-T1dCDKAAfdHgYZ8qtrS02SJSHoR52RFcrGArFNll9Mu4ZSV19Sp8BO+kTwDUOcLYdcPGNaqOp9PkRBQGZWQC7g==} + cpu: [x64] + os: [darwin] + + lefthook-freebsd-arm64@1.12.2: + resolution: {integrity: sha512-2n9z7Q4BKeMBoB9cuEdv0UBQH82Z4GgBQpCrfjCtyzpDnYQwrH8Tkrlnlko4qPh9MM6nLLGIYMKsA5nltzo8Cg==} + cpu: [arm64] + os: [freebsd] + + lefthook-freebsd-x64@1.12.2: + resolution: {integrity: sha512-1hNY/irY+/3kjRzKoJYxG+m3BYI8QxopJUK1PQnknGo1Wy5u302SdX+tR7pnpz6JM5chrNw4ozSbKKOvdZ5VEw==} + cpu: [x64] + os: [freebsd] + + lefthook-linux-arm64@1.12.2: + resolution: {integrity: sha512-1W4swYIVRkxq/LFTuuK4oVpd6NtTKY4E3VY2Uq2JDkIOJV46+8qGBF+C/QA9K3O9chLffgN7c+i+NhIuGiZ/Vw==} + cpu: [arm64] + os: [linux] + + lefthook-linux-x64@1.12.2: + resolution: {integrity: sha512-J6VGuMfhq5iCsg1Pv7xULbuXC63gP5LaikT0PhkyBNMi3HQneZFDJ8k/sp0Ue9HkQv6QfWIo3/FgB9gz38MCFw==} + cpu: [x64] + os: [linux] + + lefthook-openbsd-arm64@1.12.2: + resolution: {integrity: sha512-wncDRW3ml24DaOyH22KINumjvCohswbQqbxyH2GORRCykSnE859cTjOrRIchTKBIARF7PSeGPUtS7EK0+oDbaw==} + cpu: [arm64] + os: [openbsd] + + lefthook-openbsd-x64@1.12.2: + resolution: {integrity: sha512-2jDOkCHNnc/oK/vR62hAf3vZb1EQ6Md2GjIlgZ/V7A3ztOsM8QZ5IxwYN3D1UOIR5ZnwMBy7PtmTJC/HJrig5w==} + cpu: [x64] + os: [openbsd] + + lefthook-windows-arm64@1.12.2: + resolution: {integrity: sha512-ZMH/q6UNSidhHEG/1QoqIl1n4yPTBWuVmKx5bONtKHicoz4QCQ+QEiNjKsG5OO4C62nfyHGThmweCzZVUQECJw==} + cpu: [arm64] + os: [win32] + + lefthook-windows-x64@1.12.2: + resolution: {integrity: sha512-TqT2jIPcTQ9uwaw+v+DTmvnUHM/p7bbsSrPoPX+fRXSGLzFjyiY+12C9dObSwfCQq6rT70xqQJ9AmftJQsa5/Q==} + cpu: [x64] + os: [win32] + + lefthook@1.12.2: + resolution: {integrity: sha512-2CeTu5NcmoT9YnqsHTq/TF36MlqlzHzhivGx3DrXHwcff4TdvrkIwUTA56huM3Nlo5ODAF/0hlPzaKLmNHCBnQ==} + hasBin: true + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1211,6 +1331,72 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier-plugin-tailwindcss@0.6.14: + resolution: {integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==} + engines: {node: '>=14.21.3'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-import-sort: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-style-order: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-import-sort: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-style-order: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -1322,6 +1508,19 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-variants@2.1.0: + resolution: {integrity: sha512-82m0eRex0z6A3GpvfoTCpHr+wWJmbecfVZfP3mqLoDxeya5tN4mYJQZwa5Aw1hRZTedwpu1D2JizYenoEdyD8w==} + engines: {node: '>=16.x', pnpm: '>=7.x'} + peerDependencies: + tailwind-merge: '>=3.0.0' + tailwindcss: '*' + peerDependenciesMeta: + tailwind-merge: + optional: true + + tailwindcss@4.1.11: + resolution: {integrity: sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1382,6 +1581,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@7.10.0: + resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1886,6 +2088,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@24.2.0': + dependencies: + undici-types: 7.10.0 + '@types/react-dom@19.1.7(@types/react@19.1.9)': dependencies: '@types/react': 19.1.9 @@ -1987,11 +2193,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.2.0))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.27 '@swc/core': 1.13.3 - vite: 7.0.6 + vite: 7.0.6(@types/node@24.2.0) transitivePeerDependencies: - '@swc/helpers' @@ -2003,13 +2209,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.2.0))': 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.2.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -2040,7 +2246,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.2.0)(@vitest/ui@3.2.4)(jsdom@26.1.0) '@vitest/utils@3.2.4': dependencies: @@ -2216,6 +2422,10 @@ snapshots: escape-string-regexp@4.0.0: {} + eslint-config-prettier@10.1.8(eslint@9.32.0): + dependencies: + eslint: 9.32.0 + eslint-plugin-react-hooks@5.2.0(eslint@9.32.0): dependencies: eslint: 9.32.0 @@ -2224,6 +2434,10 @@ snapshots: dependencies: eslint: 9.32.0 + eslint-plugin-simple-import-sort@12.1.1(eslint@9.32.0): + dependencies: + eslint: 9.32.0 + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -2360,6 +2574,8 @@ snapshots: globals@14.0.0: {} + globals@16.3.0: {} + graphemer@1.4.0: {} has-flag@3.0.0: {} @@ -2413,6 +2629,11 @@ snapshots: isexe@2.0.0: {} + jotai@2.13.0(@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: {} @@ -2458,6 +2679,49 @@ snapshots: dependencies: json-buffer: 3.0.1 + lefthook-darwin-arm64@1.12.2: + optional: true + + lefthook-darwin-x64@1.12.2: + optional: true + + lefthook-freebsd-arm64@1.12.2: + optional: true + + lefthook-freebsd-x64@1.12.2: + optional: true + + lefthook-linux-arm64@1.12.2: + optional: true + + lefthook-linux-x64@1.12.2: + optional: true + + lefthook-openbsd-arm64@1.12.2: + optional: true + + lefthook-openbsd-x64@1.12.2: + optional: true + + lefthook-windows-arm64@1.12.2: + optional: true + + lefthook-windows-x64@1.12.2: + optional: true + + lefthook@1.12.2: + optionalDependencies: + lefthook-darwin-arm64: 1.12.2 + lefthook-darwin-x64: 1.12.2 + lefthook-freebsd-arm64: 1.12.2 + lefthook-freebsd-x64: 1.12.2 + lefthook-linux-arm64: 1.12.2 + lefthook-linux-x64: 1.12.2 + lefthook-openbsd-arm64: 1.12.2 + lefthook-openbsd-x64: 1.12.2 + lefthook-windows-arm64: 1.12.2 + lefthook-windows-x64: 1.12.2 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -2559,6 +2823,12 @@ snapshots: prelude-ls@1.2.1: {} + prettier-plugin-tailwindcss@0.6.14(prettier@3.6.2): + dependencies: + prettier: 3.6.2 + + prettier@3.6.2: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -2671,6 +2941,12 @@ snapshots: symbol-tree@3.2.4: {} + tailwind-variants@2.1.0(tailwindcss@4.1.11): + dependencies: + tailwindcss: 4.1.11 + + tailwindcss@4.1.11: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -2716,17 +2992,19 @@ snapshots: typescript@5.9.2: {} + undici-types@7.10.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 - vite-node@3.2.4: + vite-node@3.2.4(@types/node@24.2.0): 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.2.0) transitivePeerDependencies: - '@types/node' - jiti @@ -2741,7 +3019,7 @@ snapshots: - tsx - yaml - vite@7.0.6: + vite@7.0.6(@types/node@24.2.0): dependencies: esbuild: 0.25.8 fdir: 6.4.6(picomatch@4.0.3) @@ -2750,13 +3028,14 @@ snapshots: rollup: 4.46.2 tinyglobby: 0.2.14 optionalDependencies: + '@types/node': 24.2.0 fsevents: 2.3.3 - vitest@3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0): + vitest@3.2.4(@types/node@24.2.0)(@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.2.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -2774,10 +3053,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.2.0) + vite-node: 3.2.4(@types/node@24.2.0) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 24.2.0 '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 26.1.0 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0ba40649..8a4fd4d8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,3 @@ -ignoredBuiltDependencies: - - esbuild - onlyBuiltDependencies: - - '@swc/core' + - "@swc/core" + - lefthook diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx deleted file mode 100644 index a4369fe1..00000000 --- a/src/advanced/App.tsx +++ /dev/null @@ -1,1124 +0,0 @@ -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 - } -]; - -const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; - - return ( -
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

SHOP

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

관리자 대시보드

-

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

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

상품 목록

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

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

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

쿠폰 관리

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

{coupon.name}

-

{coupon.code}

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

새 쿠폰 생성

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

전체 상품

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

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

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

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

- - - - 장바구니 -

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

장바구니가 비어있습니다

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

{item.product.name}

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

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

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

쿠폰 할인

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

결제 정보

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

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

-
-
- - )} -
-
-
- )} -
-
- ); -}; - -export default App; \ No newline at end of file diff --git a/src/advanced/__tests__/origin.test.tsx b/src/advanced/__tests__/origin.test.tsx index 3f5c3d55..5f857960 100644 --- a/src/advanced/__tests__/origin.test.tsx +++ b/src/advanced/__tests__/origin.test.tsx @@ -1,8 +1,9 @@ // @ts-nocheck -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { vi } from 'vitest'; -import App from '../App'; + import '../../setupTests'; +import { App } from '../app'; describe('쇼핑몰 앱 통합 테스트', () => { beforeEach(() => { diff --git a/src/advanced/app/App.tsx b/src/advanced/app/App.tsx new file mode 100644 index 00000000..fa5560eb --- /dev/null +++ b/src/advanced/app/App.tsx @@ -0,0 +1,20 @@ +import { useAtomValue } from "jotai"; + +import { adminModeAtom, useNotifications } from "../shared"; +import { Header, NotificationList } from "./components"; +import { AdminPage, CartPage } from "./pages"; + +export function App() { + const { notifications, removeNotification } = useNotifications(); + const isAdminMode = useAtomValue(adminModeAtom); + + return ( +
+
+
+ {isAdminMode ? : } +
+ +
+ ); +} diff --git a/src/advanced/app/components/AdminTabs.tsx b/src/advanced/app/components/AdminTabs.tsx new file mode 100644 index 00000000..f6655dc8 --- /dev/null +++ b/src/advanced/app/components/AdminTabs.tsx @@ -0,0 +1,45 @@ +import { tv } from "tailwind-variants"; + +const tabButton = tv({ + base: "border-b-2 px-1 py-2 text-sm font-medium transition-colors", + variants: { + active: { + true: "border-gray-900 text-gray-900", + false: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" + } + } +}); + +type AdminTabsProps = { + activeTab: "products" | "coupons"; + onTabChange: (tab: "products" | "coupons") => void; +}; + +export function AdminTabs({ activeTab, onTabChange }: AdminTabsProps) { + const handleProductsTabClick = () => { + onTabChange("products"); + }; + + const handleCouponsTabClick = () => { + onTabChange("coupons"); + }; + + return ( +
+ +
+ ); +} diff --git a/src/advanced/app/components/AdminToggleButton.tsx b/src/advanced/app/components/AdminToggleButton.tsx new file mode 100644 index 00000000..be453102 --- /dev/null +++ b/src/advanced/app/components/AdminToggleButton.tsx @@ -0,0 +1,26 @@ +import { tv } from "tailwind-variants"; + +type AdminToggleButtonProps = { + isAdmin: boolean; + onToggleAdminMode: () => void; +}; + +const adminToggle = tv({ + base: "rounded px-3 py-1.5 text-sm transition-colors", + variants: { + mode: { + admin: "bg-gray-800 text-white", + cart: "text-gray-600 hover:text-gray-900" + } + } +}); + +export function AdminToggleButton({ isAdmin, onToggleAdminMode }: AdminToggleButtonProps) { + const buttonClassName = adminToggle({ mode: isAdmin ? "admin" : "cart" }); + + return ( + + ); +} diff --git a/src/advanced/app/components/CouponManagementSection.tsx b/src/advanced/app/components/CouponManagementSection.tsx new file mode 100644 index 00000000..7b00a2f1 --- /dev/null +++ b/src/advanced/app/components/CouponManagementSection.tsx @@ -0,0 +1,44 @@ +import { + AddCouponCard, + CouponCard, + CouponForm, + useCouponAtom, + useCouponForm +} from "../../domains/coupon"; + +export function CouponManagementSection() { + const { coupons, deleteCoupon } = useCouponAtom(); + const { + couponForm, + showCouponForm, + setCouponForm, + handleSubmit, + handleToggleForm, + handleCancel + } = useCouponForm(); + + return ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( + + ))} + +
+ + {showCouponForm && ( + + )} +
+
+ ); +} diff --git a/src/advanced/app/components/Header.tsx b/src/advanced/app/components/Header.tsx new file mode 100644 index 00000000..e1c76ab0 --- /dev/null +++ b/src/advanced/app/components/Header.tsx @@ -0,0 +1,52 @@ +import { useAtom, useAtomValue } from "jotai"; +import { type ChangeEvent } from "react"; + +import { totalItemCountAtom } from "../../domains/cart"; +import { adminModeAtom, BadgeContainer, CartIcon, SearchInput, searchTermAtom } from "../../shared"; +import { AdminToggleButton } from "./AdminToggleButton"; + +export function Header() { + const [isAdminMode, setIsAdminMode] = useAtom(adminModeAtom); + const [searchTerm, setSearchTerm] = useAtom(searchTermAtom); + const totalItemCount = useAtomValue(totalItemCountAtom); + + const handleSearchChange = (e: ChangeEvent) => { + setSearchTerm(e.target.value); + }; + + return ( +
+
+
+
+

SHOP

+ {!isAdminMode && ( +
+ +
+ )} +
+ + +
+
+
+ ); +} diff --git a/src/advanced/app/components/NotificationList.tsx b/src/advanced/app/components/NotificationList.tsx new file mode 100644 index 00000000..52b37c84 --- /dev/null +++ b/src/advanced/app/components/NotificationList.tsx @@ -0,0 +1,20 @@ +import { Notification, type NotificationItem } from "../../shared"; + +interface NotificationListProps { + notifications: NotificationItem[]; + onRemove: (id: string) => void; +} + +export function NotificationList({ notifications, onRemove }: NotificationListProps) { + if (notifications.length === 0) { + return null; + } + + return ( +
+ {notifications.map((notif) => ( + + ))} +
+ ); +} diff --git a/src/advanced/app/components/ProductManagementSection.tsx b/src/advanced/app/components/ProductManagementSection.tsx new file mode 100644 index 00000000..d6198bff --- /dev/null +++ b/src/advanced/app/components/ProductManagementSection.tsx @@ -0,0 +1,51 @@ +import { + ProductFormEditor, + ProductTable, + useProductAtom, + useProductForm +} from "../../domains/product"; +import { Button } from "../../shared"; + +export function ProductManagementSection() { + const { products, deleteProduct, formatPrice } = useProductAtom(); + const { + productForm, + editingProduct, + showProductForm, + setProductForm, + handleSubmit, + startEdit, + handleAddNew, + handleCancel + } = useProductForm(); + + return ( +
+
+
+

상품 목록

+ +
+
+ + + + {showProductForm && ( + + )} +
+ ); +} diff --git a/src/advanced/app/components/index.ts b/src/advanced/app/components/index.ts new file mode 100644 index 00000000..ae70cf87 --- /dev/null +++ b/src/advanced/app/components/index.ts @@ -0,0 +1,6 @@ +export * from "./AdminTabs"; +export * from "./AdminToggleButton"; +export * from "./CouponManagementSection"; +export * from "./Header"; +export * from "./NotificationList"; +export * from "./ProductManagementSection"; diff --git a/src/advanced/app/index.ts b/src/advanced/app/index.ts new file mode 100644 index 00000000..c8543026 --- /dev/null +++ b/src/advanced/app/index.ts @@ -0,0 +1 @@ +export * from "./App"; diff --git a/src/advanced/app/pages/AdminPage.tsx b/src/advanced/app/pages/AdminPage.tsx new file mode 100644 index 00000000..9e78dc44 --- /dev/null +++ b/src/advanced/app/pages/AdminPage.tsx @@ -0,0 +1,20 @@ +import { useState } from "react"; + +import { AdminTabs, CouponManagementSection, ProductManagementSection } from "../components"; + +export function AdminPage() { + const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); + + return ( +
+
+

관리자 대시보드

+

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

+
+ + + + {activeTab === "products" ? : } +
+ ); +} diff --git a/src/advanced/app/pages/CartPage.tsx b/src/advanced/app/pages/CartPage.tsx new file mode 100644 index 00000000..5fe9e4cf --- /dev/null +++ b/src/advanced/app/pages/CartPage.tsx @@ -0,0 +1,15 @@ +import { CartSidebar } from "../../domains/cart"; +import { ProductList } from "../../domains/product"; + +export function CartPage() { + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/src/advanced/app/pages/index.ts b/src/advanced/app/pages/index.ts new file mode 100644 index 00000000..dfb7b6b6 --- /dev/null +++ b/src/advanced/app/pages/index.ts @@ -0,0 +1,2 @@ +export * from "./AdminPage"; +export * from "./CartPage"; diff --git a/src/advanced/domains/cart/components/CartItemHeader.tsx b/src/advanced/domains/cart/components/CartItemHeader.tsx new file mode 100644 index 00000000..305cfd30 --- /dev/null +++ b/src/advanced/domains/cart/components/CartItemHeader.tsx @@ -0,0 +1,22 @@ +import { CloseIcon } from "../../../shared"; + +type CartItemHeaderProps = { + productName: string; + productId: string; + onRemove: (productId: string) => void; +}; + +export function CartItemHeader({ productName, productId, onRemove }: CartItemHeaderProps) { + const handleRemove = () => { + onRemove(productId); + }; + + return ( +
+

{productName}

+ +
+ ); +} diff --git a/src/advanced/domains/cart/components/CartItemInfo.tsx b/src/advanced/domains/cart/components/CartItemInfo.tsx new file mode 100644 index 00000000..54133ec0 --- /dev/null +++ b/src/advanced/domains/cart/components/CartItemInfo.tsx @@ -0,0 +1,48 @@ +import type { CartItem } from "../types"; +import { CartItemHeader } from "./CartItemHeader"; +import { QuantitySelector } from "./QuantitySelector"; + +type CartItemInfoProps = { + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + item: CartItem; + itemTotal: number; +}; + +export function CartItemInfo({ + updateQuantity, + removeFromCart, + item, + itemTotal +}: CartItemInfoProps) { + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; + + return ( +
+ + +
+ + +
+ {hasDiscount && ( + -{discountRate}% + )} +

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

+
+
+
+ ); +} diff --git a/src/advanced/domains/cart/components/CartItemList.tsx b/src/advanced/domains/cart/components/CartItemList.tsx new file mode 100644 index 00000000..a956906f --- /dev/null +++ b/src/advanced/domains/cart/components/CartItemList.tsx @@ -0,0 +1,44 @@ +import { ShoppingBagIcon } from "../../../shared"; +import type { CartItem } from "../types"; +import { CartItemInfo } from "./CartItemInfo"; + +type CartItemListProps = { + cart: CartItem[]; + calculateItemTotal: (item: CartItem) => number; + updateQuantity: (productId: string, newQuantity: number) => void; + removeFromCart: (productId: string) => void; +}; + +export function CartItemList({ + cart, + calculateItemTotal, + updateQuantity, + removeFromCart +}: CartItemListProps) { + return ( +
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/advanced/domains/cart/components/CartSidebar.tsx b/src/advanced/domains/cart/components/CartSidebar.tsx new file mode 100644 index 00000000..d20fb95b --- /dev/null +++ b/src/advanced/domains/cart/components/CartSidebar.tsx @@ -0,0 +1,35 @@ +import { useCouponAtom } from "../../coupon"; +import { useCartAtom } from "../hooks"; +import { calculateCartTotal } from "../utils"; +import { CartItemList } from "./CartItemList"; +import { CouponSelector } from "./CouponSelector"; +import { PaymentSummary } from "./PaymentSummary"; + +export function CartSidebar() { + const { cart, updateQuantity, removeFromCart, completeOrder, calculateItemTotal } = useCartAtom(); + const { coupons, selectedCoupon, applyCoupon, setSelectedCoupon } = useCouponAtom(); + + const totals = calculateCartTotal(cart, selectedCoupon); + + return ( +
+ + {cart.length > 0 && ( + <> + + + + )} +
+ ); +} diff --git a/src/advanced/domains/cart/components/CheckoutButton.tsx b/src/advanced/domains/cart/components/CheckoutButton.tsx new file mode 100644 index 00000000..bcf1f6eb --- /dev/null +++ b/src/advanced/domains/cart/components/CheckoutButton.tsx @@ -0,0 +1,20 @@ +import { Button } from "../../../shared"; + +type CheckoutButtonProps = { + totalAmount: number; + onCompleteOrder: () => void; +}; + +export function CheckoutButton({ totalAmount, onCompleteOrder }: CheckoutButtonProps) { + return ( + <> + + +
+

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

+
+ + ); +} diff --git a/src/advanced/domains/cart/components/CouponSelector.tsx b/src/advanced/domains/cart/components/CouponSelector.tsx new file mode 100644 index 00000000..29a0f45b --- /dev/null +++ b/src/advanced/domains/cart/components/CouponSelector.tsx @@ -0,0 +1,50 @@ +import type { ChangeEvent } from "react"; + +import type { Coupon } from "../../../../types"; + +type CouponSelectorProps = { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; +}; + +export function CouponSelector({ + coupons, + selectedCoupon, + applyCoupon, + setSelectedCoupon +}: CouponSelectorProps) { + const handleCouponChange = (event: ChangeEvent) => { + const coupon = coupons.find((coupon) => coupon.code === event.target.value); + if (coupon) applyCoupon(coupon); + else setSelectedCoupon(null); + }; + + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +} diff --git a/src/advanced/domains/cart/components/PaymentInfoLine.tsx b/src/advanced/domains/cart/components/PaymentInfoLine.tsx new file mode 100644 index 00000000..4a80502e --- /dev/null +++ b/src/advanced/domains/cart/components/PaymentInfoLine.tsx @@ -0,0 +1,46 @@ +import { tv } from "tailwind-variants"; + +const paymentInfoLine = tv({ + slots: { + container: "flex justify-between", + label: "", + value: "" + }, + variants: { + variant: { + default: { + label: "text-gray-600", + value: "font-medium" + }, + highlighted: { + label: "text-red-500", + value: "" + }, + total: { + container: "border-t border-gray-200 py-2", + label: "font-semibold", + value: "text-lg font-bold text-gray-900" + } + } + }, + defaultVariants: { + variant: "default" + } +}); + +type PaymentInfoLineProps = { + label: string; + value: string; + variant?: "default" | "highlighted" | "total"; +}; + +export function PaymentInfoLine({ label, value, variant = "default" }: PaymentInfoLineProps) { + const { container, label: labelClass, value: valueClass } = paymentInfoLine({ variant }); + + return ( +
+ {label} + {value} +
+ ); +} diff --git a/src/advanced/domains/cart/components/PaymentSummary.tsx b/src/advanced/domains/cart/components/PaymentSummary.tsx new file mode 100644 index 00000000..dc9cd361 --- /dev/null +++ b/src/advanced/domains/cart/components/PaymentSummary.tsx @@ -0,0 +1,47 @@ +import { CheckoutButton } from "./CheckoutButton"; +import { PaymentInfoLine } from "./PaymentInfoLine"; + +type PaymentSummaryProps = { + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + completeOrder: () => void; +}; + +export function PaymentSummary({ totals, completeOrder }: PaymentSummaryProps) { + const handleCompleteOrder = () => { + completeOrder(); + }; + + const discountAmount = totals.totalBeforeDiscount - totals.totalAfterDiscount; + + return ( +
+

결제 정보

+
+ + {discountAmount > 0 && ( + + )} + +
+ + +
+ ); +} diff --git a/src/advanced/domains/cart/components/QuantitySelector.tsx b/src/advanced/domains/cart/components/QuantitySelector.tsx new file mode 100644 index 00000000..ca52771e --- /dev/null +++ b/src/advanced/domains/cart/components/QuantitySelector.tsx @@ -0,0 +1,33 @@ +type QuantitySelectorProps = { + quantity: number; + productId: string; + onUpdateQuantity: (productId: string, newQuantity: number) => void; +}; + +export function QuantitySelector({ quantity, productId, onUpdateQuantity }: QuantitySelectorProps) { + const handleDecrease = () => { + onUpdateQuantity(productId, quantity - 1); + }; + + const handleIncrease = () => { + onUpdateQuantity(productId, quantity + 1); + }; + + return ( +
+ + {quantity} + +
+ ); +} diff --git a/src/advanced/domains/cart/components/index.ts b/src/advanced/domains/cart/components/index.ts new file mode 100644 index 00000000..36d1aeac --- /dev/null +++ b/src/advanced/domains/cart/components/index.ts @@ -0,0 +1,9 @@ +export * from "./CartItemHeader"; +export * from "./CartItemInfo"; +export * from "./CartItemList"; +export * from "./CartSidebar"; +export * from "./CheckoutButton"; +export * from "./CouponSelector"; +export * from "./PaymentInfoLine"; +export * from "./PaymentSummary"; +export * from "./QuantitySelector"; diff --git a/src/advanced/domains/cart/hooks/index.ts b/src/advanced/domains/cart/hooks/index.ts new file mode 100644 index 00000000..b89cf14b --- /dev/null +++ b/src/advanced/domains/cart/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useCartAtom"; diff --git a/src/advanced/domains/cart/hooks/useCartAtom.ts b/src/advanced/domains/cart/hooks/useCartAtom.ts new file mode 100644 index 00000000..b2b3ec9d --- /dev/null +++ b/src/advanced/domains/cart/hooks/useCartAtom.ts @@ -0,0 +1,55 @@ +import { useAtom, useAtomValue, useSetAtom } from "jotai"; + +import { useNotifications } from "../../../shared"; +import { selectedCouponAtom } from "../../coupon"; +import { type Product, productsAtom } from "../../product"; +import { cartApplicationService } from "../services"; +import { cartAtom } from "../store"; +import type { CartItem } from "../types"; +import { calculateItemTotal } from "../utils"; + +export function useCartAtom() { + const [cart, setCart] = useAtom(cartAtom); + const products = useAtomValue(productsAtom); + const setSelectedCoupon = useSetAtom(selectedCouponAtom); + const { addNotification } = useNotifications(); + + const addToCart = (product: Product) => { + cartApplicationService.addToCart(product, cart, setCart, addNotification); + }; + + const removeFromCart = (productId: string) => { + cartApplicationService.removeFromCart(productId, setCart); + }; + + const updateQuantity = (productId: string, newQuantity: number) => { + cartApplicationService.updateQuantity( + productId, + newQuantity, + products, + setCart, + addNotification + ); + }; + + const completeOrder = () => { + cartApplicationService.completeOrder( + () => setCart([]), + () => setSelectedCoupon(null), + addNotification + ); + }; + + const calculateItemTotalWithCart = (item: CartItem) => { + return calculateItemTotal(item, cart); + }; + + return { + cart, + addToCart, + removeFromCart, + updateQuantity, + completeOrder, + calculateItemTotal: calculateItemTotalWithCart + }; +} diff --git a/src/advanced/domains/cart/index.ts b/src/advanced/domains/cart/index.ts new file mode 100644 index 00000000..4bf6621e --- /dev/null +++ b/src/advanced/domains/cart/index.ts @@ -0,0 +1,6 @@ +export * from "./components"; +export * from "./hooks"; +export * from "./services"; +export * from "./store"; +export * from "./types"; +export * from "./utils"; diff --git a/src/advanced/domains/cart/services/cartApplicationService.ts b/src/advanced/domains/cart/services/cartApplicationService.ts new file mode 100644 index 00000000..4c5cd8a4 --- /dev/null +++ b/src/advanced/domains/cart/services/cartApplicationService.ts @@ -0,0 +1,85 @@ +import type { CartItem, Product } from "../../../../types"; +import type { NotificationFunction } from "../../../shared"; +import { cartNotificationService, cartValidationService, orderService } from "./index"; + +type CartUpdater = (updater: (prevCart: CartItem[]) => CartItem[]) => void; + +export const cartApplicationService = { + addToCart: ( + product: Product, + cart: CartItem[], + updateCart: CartUpdater, + addNotification: NotificationFunction + ) => { + const validation = cartValidationService.validateAddToCart(product, cart); + if (!validation.valid) { + cartNotificationService.showValidationError(validation.message!, addNotification); + return; + } + + updateCart((prevCart) => { + const existingItem = prevCart.find((item) => item.product.id === product.id); + + if (existingItem) { + const quantityValidation = cartValidationService.validateQuantityIncrease( + product, + existingItem.quantity + ); + if (!quantityValidation.valid) { + cartNotificationService.showValidationError(quantityValidation.message!, addNotification); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id ? { ...item, quantity: item.quantity + 1 } : item + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + cartNotificationService.showAddToCartSuccess(addNotification); + }, + + removeFromCart: (productId: string, updateCart: CartUpdater) => { + updateCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); + }, + + updateQuantity: ( + productId: string, + newQuantity: number, + products: Product[], + updateCart: CartUpdater, + addNotification: NotificationFunction + ) => { + const validation = cartValidationService.validateQuantityUpdate( + productId, + newQuantity, + products + ); + if (!validation.valid) { + cartNotificationService.showValidationError(validation.message!, addNotification); + return; + } + + if (newQuantity <= 0) { + cartApplicationService.removeFromCart(productId, updateCart); + return; + } + + updateCart((prevCart) => + prevCart.map((item) => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ) + ); + }, + + completeOrder: ( + clearCart: () => void, + clearSelectedCoupon: () => void, + addNotification: NotificationFunction + ) => { + const orderNumber = orderService.processOrder(clearCart, clearSelectedCoupon); + cartNotificationService.showOrderSuccess(orderNumber, addNotification); + } +}; diff --git a/src/advanced/domains/cart/services/cartNotificationService.ts b/src/advanced/domains/cart/services/cartNotificationService.ts new file mode 100644 index 00000000..ab9d1da6 --- /dev/null +++ b/src/advanced/domains/cart/services/cartNotificationService.ts @@ -0,0 +1,15 @@ +import type { NotificationFunction } from "../../../shared"; + +export const cartNotificationService = { + showAddToCartSuccess: (addNotification: NotificationFunction) => { + addNotification("장바구니에 담았습니다", "success"); + }, + + showValidationError: (message: string, addNotification: NotificationFunction) => { + addNotification(message, "error"); + }, + + showOrderSuccess: (orderNumber: string, addNotification: NotificationFunction) => { + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); + } +}; diff --git a/src/advanced/domains/cart/services/cartValidationService.ts b/src/advanced/domains/cart/services/cartValidationService.ts new file mode 100644 index 00000000..41dccd6e --- /dev/null +++ b/src/advanced/domains/cart/services/cartValidationService.ts @@ -0,0 +1,48 @@ +import type { CartItem, Product } from "../../../../types"; +import type { ValidationResult } from "../../../shared"; +import { getRemainingStock } from "../utils"; + +const createValidationResult = (valid: boolean, message?: string): ValidationResult => ({ + valid, + message +}); + +const validateStockAvailability = (requiredQuantity: number, availableStock: number) => { + return requiredQuantity > availableStock + ? createValidationResult(false, `재고는 ${availableStock}개까지만 있습니다.`) + : createValidationResult(true); +}; + +const findProductById = (productId: string, products: Product[]) => { + const product = products.find((p) => p.id === productId); + return product + ? { product, error: null } + : { product: null, error: createValidationResult(false, "상품을 찾을 수 없습니다.") }; +}; + +export const cartValidationService = { + validateAddToCart: (product: Product, cart: CartItem[]) => { + const remainingStock = getRemainingStock(product, cart); + return remainingStock <= 0 + ? createValidationResult(false, "재고가 부족합니다!") + : createValidationResult(true); + }, + + validateQuantityIncrease: (product: Product, currentQuantity: number) => { + const newQuantity = currentQuantity + 1; + return validateStockAvailability(newQuantity, product.stock); + }, + + validateQuantityUpdate: (productId: string, newQuantity: number, products: Product[]) => { + if (newQuantity <= 0) { + return createValidationResult(true); + } + + const { product, error } = findProductById(productId, products); + if (error) { + return error; + } + + return validateStockAvailability(newQuantity, product!.stock); + } +}; diff --git a/src/advanced/domains/cart/services/index.ts b/src/advanced/domains/cart/services/index.ts new file mode 100644 index 00000000..67ff0a26 --- /dev/null +++ b/src/advanced/domains/cart/services/index.ts @@ -0,0 +1,4 @@ +export * from "./cartApplicationService"; +export * from "./cartNotificationService"; +export * from "./cartValidationService"; +export * from "./orderService"; diff --git a/src/advanced/domains/cart/services/orderService.ts b/src/advanced/domains/cart/services/orderService.ts new file mode 100644 index 00000000..d9f799ad --- /dev/null +++ b/src/advanced/domains/cart/services/orderService.ts @@ -0,0 +1,13 @@ +export const orderService = { + createOrderNumber: () => { + return `ORD-${Date.now()}`; + }, + + processOrder: (clearCart: () => void, clearSelectedCoupon: () => void) => { + const orderNumber = orderService.createOrderNumber(); + clearCart(); + clearSelectedCoupon(); + + return orderNumber; + } +}; diff --git a/src/advanced/domains/cart/store/atoms.ts b/src/advanced/domains/cart/store/atoms.ts new file mode 100644 index 00000000..aa3ccf2f --- /dev/null +++ b/src/advanced/domains/cart/store/atoms.ts @@ -0,0 +1,11 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; + +import type { CartItem } from "../types"; + +export const cartAtom = atomWithStorage("cart", []); + +export const totalItemCountAtom = atom((get) => { + const cart = get(cartAtom); + return cart.reduce((sum, item) => sum + item.quantity, 0); +}); diff --git a/src/advanced/domains/cart/store/index.ts b/src/advanced/domains/cart/store/index.ts new file mode 100644 index 00000000..4e0d46d9 --- /dev/null +++ b/src/advanced/domains/cart/store/index.ts @@ -0,0 +1 @@ +export * from "./atoms"; diff --git a/src/advanced/domains/cart/types/entities.ts b/src/advanced/domains/cart/types/entities.ts new file mode 100644 index 00000000..184f5ea5 --- /dev/null +++ b/src/advanced/domains/cart/types/entities.ts @@ -0,0 +1,11 @@ +import type { Product } from "../../../../types"; + +export interface CartItem { + product: Product; + quantity: number; +} + +export interface CartTotals { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} diff --git a/src/advanced/domains/cart/types/index.ts b/src/advanced/domains/cart/types/index.ts new file mode 100644 index 00000000..fc8e74dd --- /dev/null +++ b/src/advanced/domains/cart/types/index.ts @@ -0,0 +1 @@ +export type * from "./entities"; diff --git a/src/advanced/domains/cart/utils/calculators.ts b/src/advanced/domains/cart/utils/calculators.ts new file mode 100644 index 00000000..502b96c6 --- /dev/null +++ b/src/advanced/domains/cart/utils/calculators.ts @@ -0,0 +1,61 @@ +import type { Coupon, Product } from "../../../../types"; +import type { CartItem } from "../types"; + +export function getMaxApplicableDiscount(item: CartItem, cart: CartItem[]) { + 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 function calculateItemTotal(item: CartItem, cart: CartItem[]) { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +} + +export function calculateCartTotal(cart: CartItem[], selectedCoupon: Coupon | null) { + 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 function 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/domains/cart/utils/index.ts b/src/advanced/domains/cart/utils/index.ts new file mode 100644 index 00000000..137f47a3 --- /dev/null +++ b/src/advanced/domains/cart/utils/index.ts @@ -0,0 +1 @@ +export * from "./calculators"; diff --git a/src/advanced/domains/coupon/components/AddCouponCard.tsx b/src/advanced/domains/coupon/components/AddCouponCard.tsx new file mode 100644 index 00000000..2858e653 --- /dev/null +++ b/src/advanced/domains/coupon/components/AddCouponCard.tsx @@ -0,0 +1,19 @@ +import { PlusIcon } from "../../../shared"; + +type AddCouponCardProps = { + onClick: () => void; +}; + +export function AddCouponCard({ onClick }: AddCouponCardProps) { + return ( +
+ +
+ ); +} diff --git a/src/advanced/domains/coupon/components/CouponCard.tsx b/src/advanced/domains/coupon/components/CouponCard.tsx new file mode 100644 index 00000000..e78abebe --- /dev/null +++ b/src/advanced/domains/coupon/components/CouponCard.tsx @@ -0,0 +1,37 @@ +import { TrashIcon } from "../../../shared"; +import type { Coupon } from "../types"; + +type CouponCardProps = { + coupon: Coupon; + onDelete: (code: string) => void; +}; + +export function CouponCard({ coupon, onDelete }: CouponCardProps) { + const handleDelete = () => { + onDelete(coupon.code); + }; + + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +} diff --git a/src/advanced/domains/coupon/components/CouponForm.tsx b/src/advanced/domains/coupon/components/CouponForm.tsx new file mode 100644 index 00000000..640858d8 --- /dev/null +++ b/src/advanced/domains/coupon/components/CouponForm.tsx @@ -0,0 +1,137 @@ +import type { ChangeEvent, FocusEvent, FormEvent } from "react"; + +import { Button, SearchInput, useNotifications } from "../../../shared"; + +type CouponFormType = { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +}; + +type CouponFormProps = { + couponForm: CouponFormType; + onSubmit: (e: FormEvent) => void; + onCancel: () => void; + onFormChange: (form: CouponFormType) => void; +}; + +export function CouponForm({ couponForm, onSubmit, onCancel, onFormChange }: CouponFormProps) { + const { addNotification } = useNotifications(); + const handleNameChange = (e: ChangeEvent) => { + onFormChange({ + ...couponForm, + name: e.target.value + }); + }; + + const handleCodeChange = (e: ChangeEvent) => { + onFormChange({ + ...couponForm, + code: e.target.value.toUpperCase() + }); + }; + + const handleDiscountTypeChange = (e: ChangeEvent) => { + onFormChange({ + ...couponForm, + discountType: e.target.value as "amount" | "percentage" + }); + }; + + const handleDiscountValueChange = (e: ChangeEvent) => { + const value = e.target.value; + + if (value === "" || /^\d+$/.test(value)) { + onFormChange({ + ...couponForm, + discountValue: value === "" ? 0 : parseInt(value) + }); + } + }; + + const handleDiscountValueBlur = (e: FocusEvent) => { + const value = e.target.value; + const numValue = parseInt(value) || 0; + + if (couponForm.discountType === "percentage") { + if (numValue > 100) { + addNotification("할인율은 100%를 초과할 수 없습니다", "error"); + onFormChange({ ...couponForm, discountValue: 100 }); + } else if (numValue < 0) { + onFormChange({ ...couponForm, discountValue: 0 }); + } + } else { + if (numValue > 100000) { + addNotification("할인 금액은 100,000원을 초과할 수 없습니다", "error"); + onFormChange({ ...couponForm, discountValue: 100000 }); + } else if (numValue < 0) { + onFormChange({ ...couponForm, discountValue: 0 }); + } + } + }; + + return ( +
+
+

새 쿠폰 생성

+
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/advanced/domains/coupon/components/index.ts b/src/advanced/domains/coupon/components/index.ts new file mode 100644 index 00000000..8d2317ef --- /dev/null +++ b/src/advanced/domains/coupon/components/index.ts @@ -0,0 +1,3 @@ +export * from "./AddCouponCard"; +export * from "./CouponCard"; +export * from "./CouponForm"; diff --git a/src/advanced/domains/coupon/hooks/index.ts b/src/advanced/domains/coupon/hooks/index.ts new file mode 100644 index 00000000..9dc8a5ee --- /dev/null +++ b/src/advanced/domains/coupon/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./useCouponAtom"; +export * from "./useCouponForm"; diff --git a/src/advanced/domains/coupon/hooks/useCouponAtom.ts b/src/advanced/domains/coupon/hooks/useCouponAtom.ts new file mode 100644 index 00000000..120cb886 --- /dev/null +++ b/src/advanced/domains/coupon/hooks/useCouponAtom.ts @@ -0,0 +1,53 @@ +import { useAtom, useAtomValue } from "jotai"; + +import { useNotifications } from "../../../shared"; +import { calculateCartTotal, cartAtom } from "../../cart"; +import { couponApplicationService } from "../services"; +import { couponsAtom, selectedCouponAtom } from "../store"; +import type { Coupon } from "../types"; + +export function useCouponAtom() { + const [coupons, setCoupons] = useAtom(couponsAtom); + const [selectedCoupon, setSelectedCoupon] = useAtom(selectedCouponAtom); + const cart = useAtomValue(cartAtom); + const { addNotification } = useNotifications(); + + const deleteCoupon = (couponCode: string) => { + couponApplicationService.deleteCoupon( + couponCode, + selectedCoupon, + setCoupons, + setSelectedCoupon, + addNotification + ); + }; + + const handleCouponSubmit = ( + couponForm: Coupon, + resetForm: () => void, + setShowForm: (show: boolean) => void + ) => { + couponApplicationService.handleCouponSubmit( + couponForm, + coupons, + setCoupons, + resetForm, + setShowForm, + addNotification + ); + }; + + const applyCoupon = (coupon: Coupon) => { + const currentTotal = calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; + couponApplicationService.applyCoupon(coupon, currentTotal, setSelectedCoupon, addNotification); + }; + + return { + coupons, + selectedCoupon, + deleteCoupon, + handleCouponSubmit, + applyCoupon, + setSelectedCoupon + }; +} diff --git a/src/advanced/domains/coupon/hooks/useCouponForm.ts b/src/advanced/domains/coupon/hooks/useCouponForm.ts new file mode 100644 index 00000000..3e178c7c --- /dev/null +++ b/src/advanced/domains/coupon/hooks/useCouponForm.ts @@ -0,0 +1,56 @@ +import { type FormEvent, useState } from "react"; + +import { useCouponAtom } from "./useCouponAtom"; + +type CouponFormType = { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +}; + +export function useCouponForm() { + const { handleCouponSubmit } = useCouponAtom(); + + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState({ + name: "", + code: "", + discountType: "amount", + discountValue: 0 + }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + handleCouponSubmit(couponForm, resetForm, setShowCouponForm); + }; + + const resetForm = () => { + setCouponForm({ + name: "", + code: "", + discountType: "amount", + discountValue: 0 + }); + }; + + const handleToggleForm = () => { + setShowCouponForm(!showCouponForm); + }; + + const handleCancel = () => { + setShowCouponForm(false); + }; + + return { + // 상태 + couponForm, + showCouponForm, + + // 액션 + setCouponForm, + handleSubmit, + handleToggleForm, + handleCancel + }; +} diff --git a/src/advanced/domains/coupon/index.ts b/src/advanced/domains/coupon/index.ts new file mode 100644 index 00000000..4bf6621e --- /dev/null +++ b/src/advanced/domains/coupon/index.ts @@ -0,0 +1,6 @@ +export * from "./components"; +export * from "./hooks"; +export * from "./services"; +export * from "./store"; +export * from "./types"; +export * from "./utils"; diff --git a/src/advanced/domains/coupon/services/couponApplicationService.ts b/src/advanced/domains/coupon/services/couponApplicationService.ts new file mode 100644 index 00000000..31538dfc --- /dev/null +++ b/src/advanced/domains/coupon/services/couponApplicationService.ts @@ -0,0 +1,71 @@ +import type { NotificationFunction } from "../../../shared"; +import type { Coupon } from "../types"; +import { couponNotificationService } from "./couponNotificationService"; +import { couponValidationService } from "./couponValidationService"; + +type CouponUpdater = (updater: (prev: Coupon[]) => Coupon[]) => void; + +export const couponApplicationService = { + addCoupon: ( + newCoupon: Coupon, + existingCoupons: Coupon[], + updateCoupons: CouponUpdater, + addNotification: NotificationFunction + ) => { + const validation = couponValidationService.validateCouponCode(newCoupon.code, existingCoupons); + if (!validation.valid) { + couponNotificationService.showValidationError(validation.message!, addNotification); + return; + } + + updateCoupons((prev) => [...prev, newCoupon]); + couponNotificationService.showAddSuccess(addNotification); + }, + + deleteCoupon: ( + couponCode: string, + selectedCoupon: Coupon | null, + updateCoupons: CouponUpdater, + setSelectedCoupon: (coupon: Coupon | null) => void, + addNotification: NotificationFunction + ) => { + updateCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + + couponNotificationService.showDeleteSuccess(addNotification); + }, + + applyCoupon: ( + coupon: Coupon, + totalAmount: number | undefined, + setSelectedCoupon: (coupon: Coupon | null) => void, + addNotification: NotificationFunction + ) => { + if (totalAmount !== undefined) { + const validation = couponValidationService.validateCouponUsage(coupon, totalAmount); + if (!validation.valid) { + couponNotificationService.showValidationError(validation.message!, addNotification); + return; + } + } + + setSelectedCoupon(coupon); + couponNotificationService.showApplySuccess(addNotification); + }, + + handleCouponSubmit: ( + couponForm: Coupon, + existingCoupons: Coupon[], + updateCoupons: CouponUpdater, + resetForm: () => void, + setShowForm: (show: boolean) => void, + addNotification: NotificationFunction + ) => { + couponApplicationService.addCoupon(couponForm, existingCoupons, updateCoupons, addNotification); + resetForm(); + setShowForm(false); + } +}; diff --git a/src/advanced/domains/coupon/services/couponNotificationService.ts b/src/advanced/domains/coupon/services/couponNotificationService.ts new file mode 100644 index 00000000..468dc341 --- /dev/null +++ b/src/advanced/domains/coupon/services/couponNotificationService.ts @@ -0,0 +1,19 @@ +import type { NotificationFunction } from "../../../shared"; + +export const couponNotificationService = { + showAddSuccess: (addNotification: NotificationFunction) => { + addNotification("쿠폰이 추가되었습니다.", "success"); + }, + + showDeleteSuccess: (addNotification: NotificationFunction) => { + addNotification("쿠폰이 삭제되었습니다.", "success"); + }, + + showApplySuccess: (addNotification: NotificationFunction) => { + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + + showValidationError: (message: string, addNotification: NotificationFunction) => { + addNotification(message, "error"); + } +}; diff --git a/src/advanced/domains/coupon/services/couponValidationService.ts b/src/advanced/domains/coupon/services/couponValidationService.ts new file mode 100644 index 00000000..4ff87b0c --- /dev/null +++ b/src/advanced/domains/coupon/services/couponValidationService.ts @@ -0,0 +1,17 @@ +import type { ValidationResult } from "../../../shared"; +import type { Coupon } from "../types"; + +export const couponValidationService = { + validateCouponCode: (code: string, existingCoupons: Coupon[]): ValidationResult => { + const existingCoupon = existingCoupons.find((c) => c.code === code); + return existingCoupon + ? { valid: false, message: "이미 존재하는 쿠폰 코드입니다." } + : { valid: true, message: "사용 가능한 쿠폰 코드입니다." }; + }, + + validateCouponUsage: (coupon: Coupon, totalAmount: number): ValidationResult => { + return totalAmount < 10000 && coupon.discountType === "percentage" + ? { valid: false, message: "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다." } + : { valid: true, message: "쿠폰이 적용되었습니다." }; + } +}; diff --git a/src/advanced/domains/coupon/services/index.ts b/src/advanced/domains/coupon/services/index.ts new file mode 100644 index 00000000..539a5052 --- /dev/null +++ b/src/advanced/domains/coupon/services/index.ts @@ -0,0 +1,3 @@ +export * from "./couponApplicationService"; +export * from "./couponNotificationService"; +export * from "./couponValidationService"; diff --git a/src/advanced/domains/coupon/store/atoms.ts b/src/advanced/domains/coupon/store/atoms.ts new file mode 100644 index 00000000..a22e8c89 --- /dev/null +++ b/src/advanced/domains/coupon/store/atoms.ts @@ -0,0 +1,22 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; + +import type { Coupon } from "../types"; + +const INITIAL_COUPONS: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000 + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10 + } +]; + +export const couponsAtom = atomWithStorage("coupons", INITIAL_COUPONS); +export const selectedCouponAtom = atom(null); diff --git a/src/advanced/domains/coupon/store/index.ts b/src/advanced/domains/coupon/store/index.ts new file mode 100644 index 00000000..4e0d46d9 --- /dev/null +++ b/src/advanced/domains/coupon/store/index.ts @@ -0,0 +1 @@ +export * from "./atoms"; diff --git a/src/advanced/domains/coupon/types/entities.ts b/src/advanced/domains/coupon/types/entities.ts new file mode 100644 index 00000000..5f575011 --- /dev/null +++ b/src/advanced/domains/coupon/types/entities.ts @@ -0,0 +1,6 @@ +export interface Coupon { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +} diff --git a/src/advanced/domains/coupon/types/index.ts b/src/advanced/domains/coupon/types/index.ts new file mode 100644 index 00000000..fc8e74dd --- /dev/null +++ b/src/advanced/domains/coupon/types/index.ts @@ -0,0 +1 @@ +export type * from "./entities"; diff --git a/src/advanced/domains/coupon/utils/index.ts b/src/advanced/domains/coupon/utils/index.ts new file mode 100644 index 00000000..58564490 --- /dev/null +++ b/src/advanced/domains/coupon/utils/index.ts @@ -0,0 +1 @@ +export * from "./validators"; diff --git a/src/advanced/domains/coupon/utils/validators.ts b/src/advanced/domains/coupon/utils/validators.ts new file mode 100644 index 00000000..3fdee023 --- /dev/null +++ b/src/advanced/domains/coupon/utils/validators.ts @@ -0,0 +1,30 @@ +import type { Coupon } from "../types"; + +export function validateCouponUsage(coupon: Coupon, totalAmount: number) { + if (totalAmount < 10000 && coupon.discountType === "percentage") { + return { + valid: false, + message: "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다." + }; + } + + return { + valid: true, + message: "쿠폰이 적용되었습니다." + }; +} + +export function validateCouponCode(code: string, existingCoupons: Coupon[]) { + const existingCoupon = existingCoupons.find((c) => c.code === code); + if (existingCoupon) { + return { + valid: false, + message: "이미 존재하는 쿠폰 코드입니다." + }; + } + + return { + valid: true, + message: "사용 가능한 쿠폰 코드입니다." + }; +} diff --git a/src/advanced/domains/product/components/DiscountItem.tsx b/src/advanced/domains/product/components/DiscountItem.tsx new file mode 100644 index 00000000..9576bdd0 --- /dev/null +++ b/src/advanced/domains/product/components/DiscountItem.tsx @@ -0,0 +1,54 @@ +import type { ChangeEvent } from "react"; + +import { CloseIcon } from "../../../shared"; +import { type Discount } from "../types"; + +type DiscountItemProps = { + discount: Discount; + index: number; + onChange: (index: number, field: "quantity" | "rate", value: number) => void; + onRemove: (index: number) => void; +}; + +export function DiscountItem({ discount, index, onChange, onRemove }: DiscountItemProps) { + const handleQuantityChange = (e: ChangeEvent) => { + const value = e.target.value; + onChange(index, "quantity", parseInt(value) || 0); + }; + + const handleRateChange = (e: ChangeEvent) => { + const value = e.target.value; + onChange(index, "rate", (parseInt(value) || 0) / 100); + }; + + const handleRemove = () => { + onRemove(index); + }; + + return ( +
+ + 개 이상 구매 시 + + % 할인 + +
+ ); +} diff --git a/src/advanced/domains/product/components/DiscountSection.tsx b/src/advanced/domains/product/components/DiscountSection.tsx new file mode 100644 index 00000000..ad735b70 --- /dev/null +++ b/src/advanced/domains/product/components/DiscountSection.tsx @@ -0,0 +1,48 @@ +import type { Discount } from "../types"; +import { DiscountItem } from "./DiscountItem"; + +type DiscountSectionProps = { + discounts: Discount[]; + onChange: (discounts: Discount[]) => void; +}; + +export function DiscountSection({ discounts, onChange }: DiscountSectionProps) { + const handleDiscountChange = (index: number, field: "quantity" | "rate", value: number) => { + const newDiscounts = [...discounts]; + newDiscounts[index][field] = value; + onChange(newDiscounts); + }; + + const handleRemoveDiscount = (index: number) => { + const newDiscounts = discounts.filter((_, i) => i !== index); + onChange(newDiscounts); + }; + + const handleAddDiscount = () => { + onChange([...discounts, { quantity: 10, rate: 0.1 }]); + }; + + return ( +
+ +
+ {discounts.map((discount, index) => ( + + ))} + +
+
+ ); +} diff --git a/src/advanced/domains/product/components/ProductCard.tsx b/src/advanced/domains/product/components/ProductCard.tsx new file mode 100644 index 00000000..053d1aa3 --- /dev/null +++ b/src/advanced/domains/product/components/ProductCard.tsx @@ -0,0 +1,58 @@ +import { Button } from "../../../shared"; +import type { ProductWithUI } from "../types"; +import { ProductImage } from "./ProductImage"; +import { StockStatus } from "./StockStatus"; + +type ProductCardProps = { + product: ProductWithUI; + remainingStock: number; + formatPrice: (price: number, productId?: string) => string; + onAddToCart: (product: ProductWithUI) => void; +}; + +export function ProductCard({ + product, + remainingStock, + formatPrice, + onAddToCart +}: ProductCardProps) { + const handleAddToCart = () => { + onAddToCart(product); + }; + + return ( +
+ + +
+

{product.name}

+ {product.description && ( +

{product.description}

+ )} + +
+

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

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

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

+ )} +
+ +
+ +
+ +
+
+ ); +} diff --git a/src/advanced/domains/product/components/ProductFormEditor.tsx b/src/advanced/domains/product/components/ProductFormEditor.tsx new file mode 100644 index 00000000..01858cfa --- /dev/null +++ b/src/advanced/domains/product/components/ProductFormEditor.tsx @@ -0,0 +1,139 @@ +import type { ChangeEvent, FocusEvent, FormEvent } from "react"; + +import { Button, SearchInput, useNotifications } from "../../../shared"; +import type { Discount, ProductForm } from "../types"; +import { DiscountSection } from "./DiscountSection"; + +type ProductFormEditorProps = { + productForm: ProductForm; + editingProduct: string | null; + onSubmit: (e: FormEvent) => void; + onCancel: () => void; + onFormChange: (form: ProductForm) => void; +}; + +export function ProductFormEditor({ + productForm, + editingProduct, + onSubmit, + onCancel, + onFormChange +}: ProductFormEditorProps) { + const { addNotification } = useNotifications(); + const handleNameChange = (e: ChangeEvent) => { + onFormChange({ ...productForm, name: e.target.value }); + }; + + const handleDescriptionChange = (e: ChangeEvent) => { + onFormChange({ ...productForm, description: e.target.value }); + }; + + const handleDiscountsChange = (discounts: Discount[]) => { + onFormChange({ ...productForm, discounts }); + }; + + const handlePriceChange = (e: ChangeEvent) => { + const value = e.target.value; + + if (value === "" || /^\d+$/.test(value)) { + onFormChange({ + ...productForm, + price: value === "" ? 0 : parseInt(value) + }); + } + }; + + const handlePriceBlur = (e: FocusEvent) => { + const value = e.target.value; + + if (value === "") { + onFormChange({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + addNotification("가격은 0보다 커야 합니다", "error"); + onFormChange({ ...productForm, price: 0 }); + } + }; + + const handleStockChange = (e: ChangeEvent) => { + const value = e.target.value; + + if (value === "" || /^\d+$/.test(value)) { + onFormChange({ + ...productForm, + stock: value === "" ? 0 : parseInt(value) + }); + } + }; + + const handleStockBlur = (e: FocusEvent) => { + const value = e.target.value; + + if (value === "") { + onFormChange({ ...productForm, stock: 0 }); + } else if (parseInt(value) < 0) { + addNotification("재고는 0보다 커야 합니다", "error"); + onFormChange({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + addNotification("재고는 9999개를 초과할 수 없습니다", "error"); + onFormChange({ ...productForm, stock: 9999 }); + } + }; + + return ( +
+
+

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

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + +
+ + +
+ +
+ ); +} diff --git a/src/advanced/domains/product/components/ProductImage.tsx b/src/advanced/domains/product/components/ProductImage.tsx new file mode 100644 index 00000000..c322dd72 --- /dev/null +++ b/src/advanced/domains/product/components/ProductImage.tsx @@ -0,0 +1,30 @@ +import { ImagePlaceholderIcon } from "../../../shared"; +import type { ProductWithUI } from "../types"; + +type ProductImageProps = { + product: ProductWithUI; +}; + +export function ProductImage({ product }: ProductImageProps) { + const maxDiscountRate = + product.discounts.length > 0 ? Math.max(...product.discounts.map((d) => d.rate)) * 100 : 0; + + return ( +
+
+ +
+ + {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{maxDiscountRate}% + + )} +
+ ); +} diff --git a/src/advanced/domains/product/components/ProductList.tsx b/src/advanced/domains/product/components/ProductList.tsx new file mode 100644 index 00000000..1020023f --- /dev/null +++ b/src/advanced/domains/product/components/ProductList.tsx @@ -0,0 +1,56 @@ +import { useAtomValue } from "jotai"; +import { useEffect, useState } from "react"; + +import { searchTermAtom } from "../../../shared"; +import { useCartAtom } from "../../cart"; +import { useProductAtom } from "../hooks"; +import type { Product } from "../types"; +import { filterProducts } from "../utils"; +import { ProductCard } from "./ProductCard"; + +export function ProductList() { + const { products, formatPrice, getRemainingStock } = useProductAtom(); + const { addToCart } = useCartAtom(); + const searchTerm = useAtomValue(searchTermAtom); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 500); + + return () => clearTimeout(timer); + }, [searchTerm]); + + const filteredProducts = filterProducts(products, debouncedSearchTerm); + + const handleAddToCart = (product: Product) => { + addToCart(product); + }; + + return ( +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map((product) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/advanced/domains/product/components/ProductTable.tsx b/src/advanced/domains/product/components/ProductTable.tsx new file mode 100644 index 00000000..2c1c851a --- /dev/null +++ b/src/advanced/domains/product/components/ProductTable.tsx @@ -0,0 +1,48 @@ +import type { ProductWithUI } from ".."; +import { ProductTableRow } from "./ProductTableRow"; + +type ProductTableProps = { + products: ProductWithUI[]; + formatPrice: (price: number, productId?: string) => string; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +}; + +export function ProductTable({ products, formatPrice, onEdit, onDelete }: ProductTableProps) { + return ( +
+ + + + + + + + + + + + {products.map((product) => ( + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+
+ ); +} diff --git a/src/advanced/domains/product/components/ProductTableRow.tsx b/src/advanced/domains/product/components/ProductTableRow.tsx new file mode 100644 index 00000000..b47ee488 --- /dev/null +++ b/src/advanced/domains/product/components/ProductTableRow.tsx @@ -0,0 +1,55 @@ +import { Button } from "../../../shared"; +import type { ProductWithUI } from "../types"; +import { StockBadge } from "./StockBadge"; + +type ProductTableRowProps = { + product: ProductWithUI; + formatPrice: (price: number, productId?: string) => string; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +}; + +export function ProductTableRow({ product, formatPrice, onEdit, onDelete }: ProductTableRowProps) { + const handleEdit = () => { + onEdit(product); + }; + + const handleDelete = () => { + onDelete(product.id); + }; + + return ( + + + {product.name} + + + {formatPrice(product.price, product.id)} + + + + + + {product.description || "-"} + + + + + + + ); +} diff --git a/src/advanced/domains/product/components/StockBadge.tsx b/src/advanced/domains/product/components/StockBadge.tsx new file mode 100644 index 00000000..4b2b271b --- /dev/null +++ b/src/advanced/domains/product/components/StockBadge.tsx @@ -0,0 +1,22 @@ +import { tv } from "tailwind-variants"; + +const stockBadge = tv({ + base: "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium", + variants: { + level: { + high: "bg-green-100 text-green-800", + low: "bg-yellow-100 text-yellow-800", + out: "bg-red-100 text-red-800" + } + } +}); + +type StockBadgeProps = { + stock: number; +}; + +export function StockBadge({ stock }: StockBadgeProps) { + const level = stock > 10 ? "high" : stock > 0 ? "low" : "out"; + + return {stock}개; +} diff --git a/src/advanced/domains/product/components/StockStatus.tsx b/src/advanced/domains/product/components/StockStatus.tsx new file mode 100644 index 00000000..6d22fbd3 --- /dev/null +++ b/src/advanced/domains/product/components/StockStatus.tsx @@ -0,0 +1,30 @@ +import { tv } from "tailwind-variants"; + +type StockStatusProps = { + remainingStock: number; + className?: string; +}; + +const LOW_STOCK_THRESHOLD = 5; + +const stockStatusText = tv({ + base: "text-xs", + variants: { + tone: { + normal: "text-gray-500", + low: "font-medium text-red-600" + } + }, + defaultVariants: { + tone: "normal" + } +}); + +export function StockStatus({ remainingStock, className }: StockStatusProps) { + if (remainingStock <= 0) return null; + + const tone = remainingStock <= LOW_STOCK_THRESHOLD ? "low" : "normal"; + const text = tone === "low" ? `품절임박! ${remainingStock}개 남음` : `재고 ${remainingStock}개`; + + return

{text}

; +} diff --git a/src/advanced/domains/product/components/index.ts b/src/advanced/domains/product/components/index.ts new file mode 100644 index 00000000..a6c680bc --- /dev/null +++ b/src/advanced/domains/product/components/index.ts @@ -0,0 +1,10 @@ +export * from "./DiscountItem"; +export * from "./DiscountSection"; +export * from "./ProductCard"; +export * from "./ProductFormEditor"; +export * from "./ProductImage"; +export * from "./ProductList"; +export * from "./ProductTable"; +export * from "./ProductTableRow"; +export * from "./StockBadge"; +export * from "./StockStatus"; diff --git a/src/advanced/domains/product/hooks/index.ts b/src/advanced/domains/product/hooks/index.ts new file mode 100644 index 00000000..350944db --- /dev/null +++ b/src/advanced/domains/product/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./useProductAtom"; +export * from "./useProductForm"; diff --git a/src/advanced/domains/product/hooks/useProductAtom.ts b/src/advanced/domains/product/hooks/useProductAtom.ts new file mode 100644 index 00000000..c990e213 --- /dev/null +++ b/src/advanced/domains/product/hooks/useProductAtom.ts @@ -0,0 +1,68 @@ +import { useAtom, useAtomValue } from "jotai"; + +import { adminModeAtom, useNotifications } from "../../../shared"; +import { cartAtom, getRemainingStock } from "../../cart"; +import { productApplicationService } from "../services"; +import { productsAtom } from "../store"; +import type { Product, ProductForm, ProductWithUI } from "../types"; +import { formatPrice } from "../utils"; + +export function useProductAtom() { + const [products, setProducts] = useAtom(productsAtom); + const cart = useAtomValue(cartAtom); + const isAdminMode = useAtomValue(adminModeAtom); + const { addNotification } = useNotifications(); + + const deleteProduct = (productId: string) => { + productApplicationService.deleteProduct(productId, setProducts, addNotification); + }; + + const handleProductSubmit = ( + productForm: ProductForm, + editingProduct: string | null, + resetForm: () => void, + setEditingProduct: (id: string | null) => void, + setShowForm: (show: boolean) => void + ) => { + productApplicationService.handleProductSubmit( + productForm, + editingProduct, + setProducts, + resetForm, + setEditingProduct, + setShowForm, + addNotification + ); + }; + + const startEditProduct = ( + product: ProductWithUI, + setEditingProduct: (id: string) => void, + setProductForm: (form: ProductForm) => void, + setShowForm: (show: boolean) => void + ) => { + productApplicationService.startEditProduct( + product, + setEditingProduct, + setProductForm, + setShowForm + ); + }; + + const formatPriceWithContext = (price: number, productId?: string) => { + return formatPrice(price, productId, products, cart, isAdminMode); + }; + + const getRemainingStockWithCart = (product: Product) => { + return getRemainingStock(product, cart); + }; + + return { + products, + deleteProduct, + handleProductSubmit, + startEditProduct, + formatPrice: formatPriceWithContext, + getRemainingStock: getRemainingStockWithCart + }; +} diff --git a/src/advanced/domains/product/hooks/useProductForm.ts b/src/advanced/domains/product/hooks/useProductForm.ts new file mode 100644 index 00000000..e5e729ef --- /dev/null +++ b/src/advanced/domains/product/hooks/useProductForm.ts @@ -0,0 +1,75 @@ +import { type FormEvent, useState } from "react"; + +import type { ProductForm, ProductWithUI } from "../types"; +import { useProductAtom } from "./useProductAtom"; + +export function useProductForm() { + const { handleProductSubmit, startEditProduct } = useProductAtom(); + + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] + }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + handleProductSubmit( + productForm, + editingProduct, + resetForm, + setEditingProduct, + setShowProductForm + ); + }; + + const startEdit = (product: ProductWithUI) => { + startEditProduct(product, setEditingProduct, setProductForm, setShowProductForm); + }; + + const handleAddNew = () => { + setEditingProduct("new"); + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] + }); + setShowProductForm(true); + }; + + const resetForm = () => { + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] + }); + }; + + const handleCancel = () => { + setEditingProduct(null); + resetForm(); + setShowProductForm(false); + }; + + return { + // 상태 + productForm, + editingProduct, + showProductForm, + + // 액션 + setProductForm, + handleSubmit, + startEdit, + handleAddNew, + handleCancel + }; +} diff --git a/src/advanced/domains/product/index.ts b/src/advanced/domains/product/index.ts new file mode 100644 index 00000000..4bf6621e --- /dev/null +++ b/src/advanced/domains/product/index.ts @@ -0,0 +1,6 @@ +export * from "./components"; +export * from "./hooks"; +export * from "./services"; +export * from "./store"; +export * from "./types"; +export * from "./utils"; diff --git a/src/advanced/domains/product/services/index.ts b/src/advanced/domains/product/services/index.ts new file mode 100644 index 00000000..38e141dd --- /dev/null +++ b/src/advanced/domains/product/services/index.ts @@ -0,0 +1,2 @@ +export * from "./productApplicationService"; +export * from "./productNotificationService"; diff --git a/src/advanced/domains/product/services/productApplicationService.ts b/src/advanced/domains/product/services/productApplicationService.ts new file mode 100644 index 00000000..4315faab --- /dev/null +++ b/src/advanced/domains/product/services/productApplicationService.ts @@ -0,0 +1,92 @@ +import type { NotificationFunction } from "../../../shared"; +import type { ProductForm, ProductWithUI } from "../types"; +import { productNotificationService } from "./productNotificationService"; + +type ProductUpdater = (updater: (prev: ProductWithUI[]) => ProductWithUI[]) => void; + +export const productApplicationService = { + addProduct: ( + newProduct: Omit, + updateProducts: ProductUpdater, + addNotification: NotificationFunction + ) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + + updateProducts((prev) => [...prev, product]); + productNotificationService.showAddSuccess(addNotification); + }, + + updateProduct: ( + productId: string, + updates: Partial, + updateProducts: ProductUpdater, + addNotification: NotificationFunction + ) => { + updateProducts((prev) => + prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) + ); + productNotificationService.showUpdateSuccess(addNotification); + }, + + deleteProduct: ( + productId: string, + updateProducts: ProductUpdater, + addNotification: NotificationFunction + ) => { + updateProducts((prev) => prev.filter((p) => p.id !== productId)); + productNotificationService.showDeleteSuccess(addNotification); + }, + + handleProductSubmit: ( + productForm: ProductForm, + editingProduct: string | null, + updateProducts: ProductUpdater, + resetForm: () => void, + setEditingProduct: (id: string | null) => void, + setShowForm: (show: boolean) => void, + addNotification: NotificationFunction + ) => { + if (editingProduct && editingProduct !== "new") { + productApplicationService.updateProduct( + editingProduct, + productForm, + updateProducts, + addNotification + ); + setEditingProduct(null); + } else { + productApplicationService.addProduct( + { + ...productForm, + discounts: productForm.discounts + }, + updateProducts, + addNotification + ); + } + + resetForm(); + setEditingProduct(null); + setShowForm(false); + }, + + startEditProduct: ( + product: ProductWithUI, + setEditingProduct: (id: string) => void, + setProductForm: (form: ProductForm) => void, + setShowForm: (show: boolean) => void + ) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [] + }); + setShowForm(true); + } +}; diff --git a/src/advanced/domains/product/services/productNotificationService.ts b/src/advanced/domains/product/services/productNotificationService.ts new file mode 100644 index 00000000..76765027 --- /dev/null +++ b/src/advanced/domains/product/services/productNotificationService.ts @@ -0,0 +1,15 @@ +import type { NotificationFunction } from "../../../shared"; + +export const productNotificationService = { + showAddSuccess: (addNotification: NotificationFunction) => { + addNotification("상품이 추가되었습니다.", "success"); + }, + + showUpdateSuccess: (addNotification: NotificationFunction) => { + addNotification("상품이 수정되었습니다.", "success"); + }, + + showDeleteSuccess: (addNotification: NotificationFunction) => { + addNotification("상품이 삭제되었습니다.", "success"); + } +}; diff --git a/src/advanced/domains/product/store/atoms.ts b/src/advanced/domains/product/store/atoms.ts new file mode 100644 index 00000000..a7c315bb --- /dev/null +++ b/src/advanced/domains/product/store/atoms.ts @@ -0,0 +1,39 @@ +import { atomWithStorage } from "jotai/utils"; + +import type { ProductWithUI } from "../types"; + +const INITIAL_PRODUCTS: 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 productsAtom = atomWithStorage("products", INITIAL_PRODUCTS); diff --git a/src/advanced/domains/product/store/index.ts b/src/advanced/domains/product/store/index.ts new file mode 100644 index 00000000..4e0d46d9 --- /dev/null +++ b/src/advanced/domains/product/store/index.ts @@ -0,0 +1 @@ +export * from "./atoms"; diff --git a/src/advanced/domains/product/types/entities.ts b/src/advanced/domains/product/types/entities.ts new file mode 100644 index 00000000..e8b461a3 --- /dev/null +++ b/src/advanced/domains/product/types/entities.ts @@ -0,0 +1,25 @@ +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface Discount { + quantity: number; + rate: number; +} + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export interface ProductForm { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} diff --git a/src/advanced/domains/product/types/index.ts b/src/advanced/domains/product/types/index.ts new file mode 100644 index 00000000..fc8e74dd --- /dev/null +++ b/src/advanced/domains/product/types/index.ts @@ -0,0 +1 @@ +export type * from "./entities"; diff --git a/src/advanced/domains/product/utils/formatters.ts b/src/advanced/domains/product/utils/formatters.ts new file mode 100644 index 00000000..3badfe26 --- /dev/null +++ b/src/advanced/domains/product/utils/formatters.ts @@ -0,0 +1,35 @@ +import type { CartItem } from "../../cart/types"; +import { getRemainingStock } from "../../cart/utils"; +import type { ProductWithUI } from "../types"; + +export function formatPrice( + price: number, + productId?: string, + products?: ProductWithUI[], + cart?: CartItem[], + isAdmin?: boolean +) { + if (productId && products && cart) { + const product = products.find((p) => p.id === productId); + if (product && getRemainingStock(product, cart) <= 0) { + return "SOLD OUT"; + } + } + + if (isAdmin) { + return `${price.toLocaleString()}원`; + } + + return `₩${price.toLocaleString()}`; +} + +export function filterProducts(products: ProductWithUI[], searchTerm: string) { + if (!searchTerm) return products; + + const lowerSearchTerm = searchTerm.toLowerCase(); + return products.filter( + (product) => + product.name.toLowerCase().includes(lowerSearchTerm) || + (product.description && product.description.toLowerCase().includes(lowerSearchTerm)) + ); +} diff --git a/src/advanced/domains/product/utils/index.ts b/src/advanced/domains/product/utils/index.ts new file mode 100644 index 00000000..96552da5 --- /dev/null +++ b/src/advanced/domains/product/utils/index.ts @@ -0,0 +1 @@ +export * from "./formatters"; diff --git a/src/advanced/main.tsx b/src/advanced/main.tsx index e63eef4a..d08c16bb 100644 --- a/src/advanced/main.tsx +++ b/src/advanced/main.tsx @@ -1,9 +1,16 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; -ReactDOM.createRoot(document.getElementById('root')!).render( - +import { App } from "./app"; + +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error('Root element with id "root" not found'); +} + +createRoot(rootElement).render( + - , -) + +); diff --git a/src/advanced/shared/components/icons/CartIcon.tsx b/src/advanced/shared/components/icons/CartIcon.tsx new file mode 100644 index 00000000..c4ba9a45 --- /dev/null +++ b/src/advanced/shared/components/icons/CartIcon.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from "react"; + +type CartIconProps = SVGProps; + +export function CartIcon({ className = "w-6 h-6", ...rest }: CartIconProps) { + return ( + + + + ); +} diff --git a/src/advanced/shared/components/icons/CloseIcon.tsx b/src/advanced/shared/components/icons/CloseIcon.tsx new file mode 100644 index 00000000..6e4cef6b --- /dev/null +++ b/src/advanced/shared/components/icons/CloseIcon.tsx @@ -0,0 +1,11 @@ +import type { SVGProps } from "react"; + +type CloseIconProps = SVGProps; + +export function CloseIcon({ className = "w-4 h-4", ...rest }: CloseIconProps) { + return ( + + + + ); +} diff --git a/src/advanced/shared/components/icons/ImagePlaceholderIcon.tsx b/src/advanced/shared/components/icons/ImagePlaceholderIcon.tsx new file mode 100644 index 00000000..f2090948 --- /dev/null +++ b/src/advanced/shared/components/icons/ImagePlaceholderIcon.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from "react"; + +type ImagePlaceholderIconProps = SVGProps; + +export function ImagePlaceholderIcon({ + className = "w-24 h-24", + ...rest +}: ImagePlaceholderIconProps) { + return ( + + + + ); +} diff --git a/src/advanced/shared/components/icons/PlusIcon.tsx b/src/advanced/shared/components/icons/PlusIcon.tsx new file mode 100644 index 00000000..6b014dd1 --- /dev/null +++ b/src/advanced/shared/components/icons/PlusIcon.tsx @@ -0,0 +1,11 @@ +import type { SVGProps } from "react"; + +type PlusIconProps = SVGProps; + +export function PlusIcon({ className = "w-6 h-6", ...rest }: PlusIconProps) { + return ( + + + + ); +} diff --git a/src/advanced/shared/components/icons/ShoppingBagIcon.tsx b/src/advanced/shared/components/icons/ShoppingBagIcon.tsx new file mode 100644 index 00000000..ada901f9 --- /dev/null +++ b/src/advanced/shared/components/icons/ShoppingBagIcon.tsx @@ -0,0 +1,22 @@ +import type { SVGProps } from "react"; + +type ShoppingBagIconProps = SVGProps & { + strokeWidth?: number; +}; + +export function ShoppingBagIcon({ + className = "w-5 h-5", + strokeWidth = 2, + ...rest +}: ShoppingBagIconProps) { + return ( + + + + ); +} diff --git a/src/advanced/shared/components/icons/TrashIcon.tsx b/src/advanced/shared/components/icons/TrashIcon.tsx new file mode 100644 index 00000000..d64db2f0 --- /dev/null +++ b/src/advanced/shared/components/icons/TrashIcon.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from "react"; + +type TrashIconProps = SVGProps; + +export function TrashIcon({ className = "w-5 h-5", ...rest }: TrashIconProps) { + return ( + + + + ); +} diff --git a/src/advanced/shared/components/icons/index.ts b/src/advanced/shared/components/icons/index.ts new file mode 100644 index 00000000..5394b7f8 --- /dev/null +++ b/src/advanced/shared/components/icons/index.ts @@ -0,0 +1,6 @@ +export * from "./CartIcon"; +export * from "./CloseIcon"; +export * from "./ImagePlaceholderIcon"; +export * from "./PlusIcon"; +export * from "./ShoppingBagIcon"; +export * from "./TrashIcon"; diff --git a/src/advanced/shared/components/index.ts b/src/advanced/shared/components/index.ts new file mode 100644 index 00000000..ac365264 --- /dev/null +++ b/src/advanced/shared/components/index.ts @@ -0,0 +1,2 @@ +export * from "./icons"; +export * from "./ui"; diff --git a/src/advanced/shared/components/ui/BadgeContainer.tsx b/src/advanced/shared/components/ui/BadgeContainer.tsx new file mode 100644 index 00000000..a489936d --- /dev/null +++ b/src/advanced/shared/components/ui/BadgeContainer.tsx @@ -0,0 +1,19 @@ +import { type PropsWithChildren } from "react"; + +type BadgeContainerProps = PropsWithChildren<{ + label: string; + visible: boolean; +}>; + +export function BadgeContainer({ label, visible, children }: BadgeContainerProps) { + return ( +
+ {children} + {visible && ( + + {label} + + )} +
+ ); +} diff --git a/src/advanced/shared/components/ui/Button.tsx b/src/advanced/shared/components/ui/Button.tsx new file mode 100644 index 00000000..d741d240 --- /dev/null +++ b/src/advanced/shared/components/ui/Button.tsx @@ -0,0 +1,34 @@ +import type { ComponentPropsWithRef } from "react"; +import { tv } from "tailwind-variants"; + +type ButtonProps = Omit, "size" | "color"> & { + size?: "lg" | "md" | "sm"; + color?: "primary" | "secondary" | "danger" | "dark" | "neutral" | "yellow"; +}; + +const buttonVariants = tv({ + base: "rounded font-medium transition-colors focus:outline-none disabled:cursor-not-allowed disabled:opacity-50", + variants: { + size: { + sm: "px-3 py-1.5 text-sm", + md: "px-4 py-2 text-base", + lg: "px-6 py-3 text-base" + }, + color: { + primary: "bg-indigo-600 text-white hover:bg-indigo-700 disabled:bg-indigo-300", + secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300 disabled:bg-gray-100", + danger: "bg-red-600 text-white hover:bg-red-700 disabled:bg-red-300", + dark: "bg-gray-900 text-white hover:bg-gray-800 disabled:bg-gray-400", + neutral: "bg-gray-800 text-white hover:bg-gray-700 disabled:bg-gray-400", + yellow: "bg-yellow-400 text-gray-900 hover:bg-yellow-500 disabled:bg-yellow-200" + } + }, + defaultVariants: { + size: "md", + color: "primary" + } +}); + +export function Button({ className, size, color, ...rest }: ButtonProps) { + return + + ); +} diff --git a/src/advanced/shared/components/ui/SearchInput.tsx b/src/advanced/shared/components/ui/SearchInput.tsx new file mode 100644 index 00000000..8da93f5e --- /dev/null +++ b/src/advanced/shared/components/ui/SearchInput.tsx @@ -0,0 +1,35 @@ +import type { ComponentPropsWithRef } from "react"; +import { tv } from "tailwind-variants"; + +type SearchInputProps = Omit, "size" | "color"> & { + label?: string; + size?: "lg" | "md"; + color?: "blue" | "indigo"; +}; + +const inputVariants = tv({ + base: "w-full border border-gray-300 py-2", + variants: { + size: { + md: "rounded-md px-3", + lg: "rounded-lg px-4" + }, + color: { + blue: "focus:border-blue-500 focus:outline-none", + indigo: "focus:border-indigo-500 focus:ring-indigo-500" + } + }, + defaultVariants: { + size: "md", + color: "indigo" + } +}); + +export function SearchInput({ className, label, size, color, ...rest }: SearchInputProps) { + return ( + <> + {label && } + + + ); +} diff --git a/src/advanced/shared/components/ui/index.ts b/src/advanced/shared/components/ui/index.ts new file mode 100644 index 00000000..7a06b0f1 --- /dev/null +++ b/src/advanced/shared/components/ui/index.ts @@ -0,0 +1,4 @@ +export * from "./BadgeContainer"; +export * from "./Button"; +export * from "./Notification"; +export * from "./SearchInput"; diff --git a/src/advanced/shared/hooks/index.ts b/src/advanced/shared/hooks/index.ts new file mode 100644 index 00000000..815d51bd --- /dev/null +++ b/src/advanced/shared/hooks/index.ts @@ -0,0 +1,5 @@ +export * from "./useDebounceState"; +export * from "./useDebounceValue"; +export * from "./useLocalStorageState"; +export * from "./useNotifications"; +export * from "./useToggle"; diff --git a/src/advanced/shared/hooks/useDebounceState.ts b/src/advanced/shared/hooks/useDebounceState.ts new file mode 100644 index 00000000..a5b9c1a6 --- /dev/null +++ b/src/advanced/shared/hooks/useDebounceState.ts @@ -0,0 +1,22 @@ +import { type Dispatch, type SetStateAction, useState } from "react"; + +import { useDebounceValue } from "./useDebounceValue"; + +type UseDebounceStateProps = { + delay: number; + initialValue: S; +}; + +export function useDebounceState({ + delay, + initialValue +}: UseDebounceStateProps): [S, Dispatch>, S] { + const [state, setState] = useState(initialValue); + + const debouncedState = useDebounceValue({ + delay, + value: state + }); + + return [state, setState, debouncedState]; +} diff --git a/src/advanced/shared/hooks/useDebounceValue.ts b/src/advanced/shared/hooks/useDebounceValue.ts new file mode 100644 index 00000000..b82e1fda --- /dev/null +++ b/src/advanced/shared/hooks/useDebounceValue.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +type UseDebounceValueProps = { + delay: number; + value: T; +}; + +export function useDebounceValue({ delay, value }: UseDebounceValueProps) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const debouncedTimeout = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(debouncedTimeout); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/advanced/shared/hooks/useLocalStorageState.ts b/src/advanced/shared/hooks/useLocalStorageState.ts new file mode 100644 index 00000000..43410b76 --- /dev/null +++ b/src/advanced/shared/hooks/useLocalStorageState.ts @@ -0,0 +1,36 @@ +import { type Dispatch, type SetStateAction, useEffect, useState } from "react"; + +type UseLocalStorageStateProps = { + key: string; + initialState: S; +}; + +export function useLocalStorageState({ + key, + initialState +}: UseLocalStorageStateProps): [S, Dispatch>] { + const readLocalStorage = () => { + try { + const item = localStorage.getItem(key); + return item ? (JSON.parse(item) as S) : initialState; + } catch { + return initialState; + } + }; + + const [state, setState] = useState(readLocalStorage); + + useEffect(() => { + const isEmpty = state === undefined || state === null; + const isEmptyObject = typeof state === "object" && Object.keys(state || {}).length === 0; + const isEmptyArray = Array.isArray(state) && state.length === 0; + + if (isEmpty || isEmptyObject || isEmptyArray) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(state)); + } + }, [state, key]); + + return [state, setState]; +} diff --git a/src/advanced/shared/hooks/useNotifications.ts b/src/advanced/shared/hooks/useNotifications.ts new file mode 100644 index 00000000..d4ec98b3 --- /dev/null +++ b/src/advanced/shared/hooks/useNotifications.ts @@ -0,0 +1,36 @@ +import { useAtom } from "jotai"; + +import { notificationsAtom } from "../store"; +import type { NotificationItem, NotificationType } from "../types"; + +export function useNotifications() { + const [notifications, setNotifications] = useAtom(notificationsAtom); + + const addNotification = (message: string, type: NotificationType = "success") => { + const id = Date.now().toString(); + const newNotification: NotificationItem = { id, message, type }; + + setNotifications((prev) => { + const hasExistingMessage = prev.some((n) => n.message === message); + if (hasExistingMessage) { + return prev; + } + + return [...prev, newNotification]; + }); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }; + + const removeNotification = (id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }; + + return { + notifications, + addNotification, + removeNotification + }; +} diff --git a/src/advanced/shared/hooks/useToggle.ts b/src/advanced/shared/hooks/useToggle.ts new file mode 100644 index 00000000..509dd0b1 --- /dev/null +++ b/src/advanced/shared/hooks/useToggle.ts @@ -0,0 +1,11 @@ +import { useState } from "react"; + +export function useToggle(defaultValue?: boolean) { + const [value, setValue] = useState(!!defaultValue); + + const toggle = () => { + setValue((prev) => !prev); + }; + + return [value, toggle, setValue] as const; +} diff --git a/src/advanced/shared/index.ts b/src/advanced/shared/index.ts new file mode 100644 index 00000000..6d51483d --- /dev/null +++ b/src/advanced/shared/index.ts @@ -0,0 +1,5 @@ +export * from "./components"; +export * from "./hooks"; +export * from "./store"; +export * from "./types"; +export * from "./utils"; diff --git a/src/advanced/shared/store/index.ts b/src/advanced/shared/store/index.ts new file mode 100644 index 00000000..83939924 --- /dev/null +++ b/src/advanced/shared/store/index.ts @@ -0,0 +1,2 @@ +export * from "./notificationAtoms"; +export * from "./uiAtoms"; diff --git a/src/advanced/shared/store/notificationAtoms.ts b/src/advanced/shared/store/notificationAtoms.ts new file mode 100644 index 00000000..a2f4d075 --- /dev/null +++ b/src/advanced/shared/store/notificationAtoms.ts @@ -0,0 +1,5 @@ +import { atom } from "jotai"; + +import type { NotificationItem } from "../types"; + +export const notificationsAtom = atom([]); diff --git a/src/advanced/shared/store/uiAtoms.ts b/src/advanced/shared/store/uiAtoms.ts new file mode 100644 index 00000000..eea561a0 --- /dev/null +++ b/src/advanced/shared/store/uiAtoms.ts @@ -0,0 +1,5 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; + +export const adminModeAtom = atomWithStorage("adminMode", false); +export const searchTermAtom = atom(""); diff --git a/src/advanced/shared/types/index.ts b/src/advanced/shared/types/index.ts new file mode 100644 index 00000000..a67dd4c1 --- /dev/null +++ b/src/advanced/shared/types/index.ts @@ -0,0 +1,2 @@ +export type * from "./notification"; +export type * from "./validation"; diff --git a/src/advanced/shared/types/notification.ts b/src/advanced/shared/types/notification.ts new file mode 100644 index 00000000..4a7e4727 --- /dev/null +++ b/src/advanced/shared/types/notification.ts @@ -0,0 +1,9 @@ +export type NotificationType = "error" | "success" | "warning"; + +export interface NotificationItem { + id: string; + message: string; + type: NotificationType; +} + +export type NotificationFunction = (message: string, type?: NotificationType) => void; diff --git a/src/advanced/shared/types/validation.ts b/src/advanced/shared/types/validation.ts new file mode 100644 index 00000000..a0ab9278 --- /dev/null +++ b/src/advanced/shared/types/validation.ts @@ -0,0 +1,4 @@ +export type ValidationResult = { + valid: boolean; + message?: string; +}; diff --git a/src/advanced/shared/utils/index.ts b/src/advanced/shared/utils/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/advanced/shared/utils/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/basic/App.tsx b/src/basic/App.tsx deleted file mode 100644 index a4369fe1..00000000 --- a/src/basic/App.tsx +++ /dev/null @@ -1,1124 +0,0 @@ -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 - } -]; - -const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; - - return ( -
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

SHOP

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

관리자 대시보드

-

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

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

상품 목록

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

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

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

쿠폰 관리

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

{coupon.name}

-

{coupon.code}

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

새 쿠폰 생성

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

전체 상품

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

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

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

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

- - - - 장바구니 -

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

장바구니가 비어있습니다

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

{item.product.name}

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

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

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

쿠폰 할인

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

결제 정보

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

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

-
-
- - )} -
-
-
- )} -
-
- ); -}; - -export default App; \ No newline at end of file diff --git a/src/basic/__tests__/origin.test.tsx b/src/basic/__tests__/origin.test.tsx index 3f5c3d55..5f857960 100644 --- a/src/basic/__tests__/origin.test.tsx +++ b/src/basic/__tests__/origin.test.tsx @@ -1,8 +1,9 @@ // @ts-nocheck -import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { vi } from 'vitest'; -import App from '../App'; + import '../../setupTests'; +import { App } from '../app'; describe('쇼핑몰 앱 통합 테스트', () => { beforeEach(() => { diff --git a/src/basic/app/App.tsx b/src/basic/app/App.tsx new file mode 100644 index 00000000..5921f47f --- /dev/null +++ b/src/basic/app/App.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from "react"; + +import { + calculateCartTotal, + calculateItemTotal, + cartApplicationService, + type CartItem, + getRemainingStock +} from "../domains/cart"; +import { type Coupon, couponApplicationService, INITIAL_COUPONS } from "../domains/coupon"; +import { + formatPrice, + INITIAL_PRODUCTS, + type Product, + type ProductWithUI +} from "../domains/product"; +import { useDebounceState, useLocalStorageState, useNotifications, useToggle } from "../shared"; +import { Header, NotificationList } from "./components"; +import { AdminPage, CartPage } from "./pages"; + +export function App() { + const [products, setProducts] = useLocalStorageState({ + key: "products", + initialState: INITIAL_PRODUCTS + }); + + const [cart, setCart] = useLocalStorageState({ + key: "cart", + initialState: [] + }); + + const [coupons, setCoupons] = useLocalStorageState({ + key: "coupons", + initialState: INITIAL_COUPONS + }); + + const [searchTerm, setSearchTerm, debouncedSearchTerm] = useDebounceState({ + delay: 500, + initialValue: "" + }); + + const { notifications, addNotification, removeNotification } = useNotifications(); + + const [isAdminMode, toggleAdminMode] = useToggle(false); + + const [selectedCoupon, setSelectedCoupon] = useState(null); + + const formatPriceWithContext = (price: number, productId?: string) => { + return formatPrice(price, productId, products, cart, isAdminMode); + }; + + const [totalItemCount, setTotalItemCount] = useState(0); + + const addToCart = (product: Product) => { + cartApplicationService.addToCart(product, cart, setCart, addNotification); + }; + + const removeFromCart = (productId: string) => { + cartApplicationService.removeFromCart(productId, setCart); + }; + + const updateQuantity = (productId: string, newQuantity: number) => { + cartApplicationService.updateQuantity( + productId, + newQuantity, + products, + setCart, + addNotification + ); + }; + + const completeOrder = () => { + cartApplicationService.completeOrder( + () => setCart([]), + () => setSelectedCoupon(null), + addNotification + ); + }; + + const applyCoupon = (coupon: Coupon) => { + const currentTotal = calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; + couponApplicationService.applyCoupon(coupon, currentTotal, setSelectedCoupon, addNotification); + }; + + const calculateItemTotalWithCart = (item: CartItem) => { + return calculateItemTotal(item, cart); + }; + + const getRemainingStockWithCart = (product: Product) => { + return getRemainingStock(product, cart); + }; + + useEffect(() => { + const count = cart.reduce((sum, item) => sum + item.quantity, 0); + setTotalItemCount(count); + }, [cart]); + + return ( +
+
+
+ {isAdminMode ? ( + + ) : ( + + )} +
+ +
+ ); +} diff --git a/src/basic/app/components/AdminTabs.tsx b/src/basic/app/components/AdminTabs.tsx new file mode 100644 index 00000000..f6655dc8 --- /dev/null +++ b/src/basic/app/components/AdminTabs.tsx @@ -0,0 +1,45 @@ +import { tv } from "tailwind-variants"; + +const tabButton = tv({ + base: "border-b-2 px-1 py-2 text-sm font-medium transition-colors", + variants: { + active: { + true: "border-gray-900 text-gray-900", + false: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" + } + } +}); + +type AdminTabsProps = { + activeTab: "products" | "coupons"; + onTabChange: (tab: "products" | "coupons") => void; +}; + +export function AdminTabs({ activeTab, onTabChange }: AdminTabsProps) { + const handleProductsTabClick = () => { + onTabChange("products"); + }; + + const handleCouponsTabClick = () => { + onTabChange("coupons"); + }; + + return ( +
+ +
+ ); +} diff --git a/src/basic/app/components/AdminToggleButton.tsx b/src/basic/app/components/AdminToggleButton.tsx new file mode 100644 index 00000000..be453102 --- /dev/null +++ b/src/basic/app/components/AdminToggleButton.tsx @@ -0,0 +1,26 @@ +import { tv } from "tailwind-variants"; + +type AdminToggleButtonProps = { + isAdmin: boolean; + onToggleAdminMode: () => void; +}; + +const adminToggle = tv({ + base: "rounded px-3 py-1.5 text-sm transition-colors", + variants: { + mode: { + admin: "bg-gray-800 text-white", + cart: "text-gray-600 hover:text-gray-900" + } + } +}); + +export function AdminToggleButton({ isAdmin, onToggleAdminMode }: AdminToggleButtonProps) { + const buttonClassName = adminToggle({ mode: isAdmin ? "admin" : "cart" }); + + return ( + + ); +} diff --git a/src/basic/app/components/CouponManagementSection.tsx b/src/basic/app/components/CouponManagementSection.tsx new file mode 100644 index 00000000..6b2599ca --- /dev/null +++ b/src/basic/app/components/CouponManagementSection.tsx @@ -0,0 +1,60 @@ +import { type FormEvent } from "react"; + +import { AddCouponCard, type Coupon, CouponCard, CouponForm } from "../../domains/coupon"; + +type CouponFormType = { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +}; + +type CouponManagementSectionProps = { + coupons: Coupon[]; + couponForm: CouponFormType; + showCouponForm: boolean; + onToggleForm: () => void; + onDelete: (code: string) => void; + onFormSubmit: (e: FormEvent) => void; + onFormCancel: () => void; + onFormChange: (form: CouponFormType) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +}; + +export function CouponManagementSection({ + coupons, + couponForm, + showCouponForm, + onToggleForm, + onDelete, + onFormSubmit, + onFormCancel, + onFormChange, + addNotification +}: CouponManagementSectionProps) { + return ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( + + ))} + +
+ + {showCouponForm && ( + + )} +
+
+ ); +} diff --git a/src/basic/app/components/Header.tsx b/src/basic/app/components/Header.tsx new file mode 100644 index 00000000..712dd1d7 --- /dev/null +++ b/src/basic/app/components/Header.tsx @@ -0,0 +1,61 @@ +import { type ChangeEvent } from "react"; + +import type { CartItem } from "../../domains/cart"; +import { BadgeContainer, CartIcon, SearchInput } from "../../shared"; +import { AdminToggleButton } from "./AdminToggleButton"; + +type HeaderProps = { + isAdminMode: boolean; + onToggleAdminMode: () => void; + cart: CartItem[]; + searchTerm: string; + setSearchTerm: (term: string) => void; + totalItemCount: number; +}; + +export function Header({ + isAdminMode, + onToggleAdminMode, + cart, + searchTerm, + setSearchTerm, + totalItemCount +}: HeaderProps) { + const handleSearchChange = (e: ChangeEvent) => { + setSearchTerm(e.target.value); + }; + + return ( +
+
+
+
+

SHOP

+ {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} + {!isAdminMode && ( +
+ +
+ )} +
+ + +
+
+
+ ); +} diff --git a/src/basic/app/components/NotificationList.tsx b/src/basic/app/components/NotificationList.tsx new file mode 100644 index 00000000..52b37c84 --- /dev/null +++ b/src/basic/app/components/NotificationList.tsx @@ -0,0 +1,20 @@ +import { Notification, type NotificationItem } from "../../shared"; + +interface NotificationListProps { + notifications: NotificationItem[]; + onRemove: (id: string) => void; +} + +export function NotificationList({ notifications, onRemove }: NotificationListProps) { + if (notifications.length === 0) { + return null; + } + + return ( +
+ {notifications.map((notif) => ( + + ))} +
+ ); +} diff --git a/src/basic/app/components/ProductManagementSection.tsx b/src/basic/app/components/ProductManagementSection.tsx new file mode 100644 index 00000000..7e146c16 --- /dev/null +++ b/src/basic/app/components/ProductManagementSection.tsx @@ -0,0 +1,70 @@ +import { type FormEvent } from "react"; + +import { + type ProductForm, + ProductFormEditor, + ProductTable, + type ProductWithUI +} from "../../domains/product"; +import { Button } from "../../shared"; + +type ProductManagementSectionProps = { + products: ProductWithUI[]; + productForm: ProductForm; + editingProduct: string | null; + showProductForm: boolean; + formatPrice: (price: number, productId?: string) => string; + onAddNew: () => void; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; + onFormSubmit: (e: FormEvent) => void; + onFormCancel: () => void; + onFormChange: (form: ProductForm) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +}; + +export function ProductManagementSection({ + products, + productForm, + editingProduct, + showProductForm, + formatPrice, + onAddNew, + onEdit, + onDelete, + onFormSubmit, + onFormCancel, + onFormChange, + addNotification +}: ProductManagementSectionProps) { + return ( +
+
+
+

상품 목록

+ +
+
+ + + + {showProductForm && ( + + )} +
+ ); +} diff --git a/src/basic/app/components/index.ts b/src/basic/app/components/index.ts new file mode 100644 index 00000000..ae70cf87 --- /dev/null +++ b/src/basic/app/components/index.ts @@ -0,0 +1,6 @@ +export * from "./AdminTabs"; +export * from "./AdminToggleButton"; +export * from "./CouponManagementSection"; +export * from "./Header"; +export * from "./NotificationList"; +export * from "./ProductManagementSection"; diff --git a/src/basic/app/index.ts b/src/basic/app/index.ts new file mode 100644 index 00000000..c8543026 --- /dev/null +++ b/src/basic/app/index.ts @@ -0,0 +1 @@ +export * from "./App"; diff --git a/src/basic/app/pages/AdminPage.tsx b/src/basic/app/pages/AdminPage.tsx new file mode 100644 index 00000000..f84e0372 --- /dev/null +++ b/src/basic/app/pages/AdminPage.tsx @@ -0,0 +1,209 @@ +import { type Dispatch, FormEvent, type SetStateAction, useState } from "react"; + +import type { CartItem, Coupon } from "../../../types"; +import { couponApplicationService } from "../../domains/coupon"; +import { + formatPrice, + productApplicationService, + type ProductForm, + type ProductWithUI +} from "../../domains/product"; +import { CouponManagementSection, ProductManagementSection } from "../components"; +import { AdminTabs } from "../components"; + +type AdminPageProps = { + products: ProductWithUI[]; + setProducts: Dispatch>; + coupons: Coupon[]; + setCoupons: Dispatch>; + cart: CartItem[]; + isAdminMode: boolean; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +}; + +export function AdminPage({ + products, + setProducts, + coupons, + setCoupons, + cart, + isAdminMode, + addNotification +}: AdminPageProps) { + const [activeTab, setActiveTab] = useState<"products" | "coupons">("products"); + const [showProductForm, setShowProductForm] = useState(false); + const [showCouponForm, setShowCouponForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] + }); + const [couponForm, setCouponForm] = useState({ + name: "", + code: "", + discountType: "amount" as "amount" | "percentage", + discountValue: 0 + }); + + const deleteProduct = (productId: string) => { + productApplicationService.deleteProduct(productId, setProducts, addNotification); + }; + + const handleProductSubmit = ( + productForm: ProductForm, + resetForm: () => void, + setEditingProduct: (id: string | null) => void, + setShowForm: (show: boolean) => void + ) => { + productApplicationService.handleProductSubmit( + productForm, + editingProduct, + setProducts, + resetForm, + setEditingProduct, + setShowForm, + addNotification + ); + }; + + const startEditProduct = ( + product: ProductWithUI, + setEditingProduct: (id: string) => void, + setProductForm: (form: ProductForm) => void, + setShowForm: (show: boolean) => void + ) => { + productApplicationService.startEditProduct( + product, + setEditingProduct, + setProductForm, + setShowForm + ); + }; + + const deleteCoupon = (couponCode: string) => { + couponApplicationService.deleteCoupon(couponCode, null, setCoupons, () => {}, addNotification); + }; + + const handleCouponSubmit = ( + couponForm: Coupon, + resetForm: () => void, + setShowForm: (show: boolean) => void + ) => { + couponApplicationService.handleCouponSubmit( + couponForm, + coupons, + setCoupons, + resetForm, + setShowForm, + addNotification + ); + }; + + const formatPriceWithContext = (price: number, productId?: string) => { + return formatPrice(price, productId, products, cart, isAdminMode); + }; + + const handleProductSubmitWrapper = (e: FormEvent) => { + e.preventDefault(); + handleProductSubmit( + productForm, + () => setProductForm({ name: "", price: 0, stock: 0, description: "", discounts: [] }), + setEditingProduct, + setShowProductForm + ); + }; + + const handleCouponSubmitWrapper = (e: FormEvent) => { + e.preventDefault(); + handleCouponSubmit( + couponForm, + () => + setCouponForm({ + name: "", + code: "", + discountType: "amount", + discountValue: 0 + }), + setShowCouponForm + ); + }; + + const startEditProductWrapper = (product: ProductWithUI) => { + startEditProduct(product, setEditingProduct, setProductForm, setShowProductForm); + }; + + const handleAddNewProduct = () => { + setEditingProduct("new"); + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] + }); + setShowProductForm(true); + }; + + const handleCancelProductForm = () => { + setEditingProduct(null); + setProductForm({ + name: "", + price: 0, + stock: 0, + description: "", + discounts: [] + }); + setShowProductForm(false); + }; + + const handleToggleCouponForm = () => { + setShowCouponForm(!showCouponForm); + }; + + const handleCancelCouponForm = () => { + setShowCouponForm(false); + }; + + return ( +
+
+

관리자 대시보드

+

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

+
+ + + + {activeTab === "products" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/basic/app/pages/CartPage.tsx b/src/basic/app/pages/CartPage.tsx new file mode 100644 index 00000000..86daf261 --- /dev/null +++ b/src/basic/app/pages/CartPage.tsx @@ -0,0 +1,74 @@ +import type { CartItem, Coupon, Product } from "../../../types"; +import { calculateCartTotal, CartSidebar } from "../../domains/cart"; +import { filterProducts, ProductList } from "../../domains/product"; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +type CartPageProps = { + products: ProductWithUI[]; + debouncedSearchTerm: string; + getRemainingStock: (product: Product) => number; + formatPrice: (price: number, productId?: string) => string; + addToCart: (product: ProductWithUI) => void; + cart: CartItem[]; + calculateItemTotal: (item: CartItem) => number; + updateQuantity: (productId: string, newQuantity: number) => void; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; + completeOrder: () => void; + removeFromCart: (productId: string) => void; +}; + +export function CartPage({ + addToCart, + applyCoupon, + calculateItemTotal, + cart, + completeOrder, + coupons, + debouncedSearchTerm, + formatPrice, + getRemainingStock, + products, + selectedCoupon, + setSelectedCoupon, + updateQuantity, + removeFromCart +}: CartPageProps) { + const filteredProducts = filterProducts(products, debouncedSearchTerm); + const totals = calculateCartTotal(cart, selectedCoupon); + + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/src/basic/app/pages/index.ts b/src/basic/app/pages/index.ts new file mode 100644 index 00000000..dfb7b6b6 --- /dev/null +++ b/src/basic/app/pages/index.ts @@ -0,0 +1,2 @@ +export * from "./AdminPage"; +export * from "./CartPage"; diff --git a/src/basic/domains/cart/components/CartItemHeader.tsx b/src/basic/domains/cart/components/CartItemHeader.tsx new file mode 100644 index 00000000..305cfd30 --- /dev/null +++ b/src/basic/domains/cart/components/CartItemHeader.tsx @@ -0,0 +1,22 @@ +import { CloseIcon } from "../../../shared"; + +type CartItemHeaderProps = { + productName: string; + productId: string; + onRemove: (productId: string) => void; +}; + +export function CartItemHeader({ productName, productId, onRemove }: CartItemHeaderProps) { + const handleRemove = () => { + onRemove(productId); + }; + + return ( +
+

{productName}

+ +
+ ); +} diff --git a/src/basic/domains/cart/components/CartItemInfo.tsx b/src/basic/domains/cart/components/CartItemInfo.tsx new file mode 100644 index 00000000..54133ec0 --- /dev/null +++ b/src/basic/domains/cart/components/CartItemInfo.tsx @@ -0,0 +1,48 @@ +import type { CartItem } from "../types"; +import { CartItemHeader } from "./CartItemHeader"; +import { QuantitySelector } from "./QuantitySelector"; + +type CartItemInfoProps = { + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + item: CartItem; + itemTotal: number; +}; + +export function CartItemInfo({ + updateQuantity, + removeFromCart, + item, + itemTotal +}: CartItemInfoProps) { + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; + + return ( +
+ + +
+ + +
+ {hasDiscount && ( + -{discountRate}% + )} +

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

+
+
+
+ ); +} diff --git a/src/basic/domains/cart/components/CartItemList.tsx b/src/basic/domains/cart/components/CartItemList.tsx new file mode 100644 index 00000000..a956906f --- /dev/null +++ b/src/basic/domains/cart/components/CartItemList.tsx @@ -0,0 +1,44 @@ +import { ShoppingBagIcon } from "../../../shared"; +import type { CartItem } from "../types"; +import { CartItemInfo } from "./CartItemInfo"; + +type CartItemListProps = { + cart: CartItem[]; + calculateItemTotal: (item: CartItem) => number; + updateQuantity: (productId: string, newQuantity: number) => void; + removeFromCart: (productId: string) => void; +}; + +export function CartItemList({ + cart, + calculateItemTotal, + updateQuantity, + removeFromCart +}: CartItemListProps) { + return ( +
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/basic/domains/cart/components/CartSidebar.tsx b/src/basic/domains/cart/components/CartSidebar.tsx new file mode 100644 index 00000000..ecefa997 --- /dev/null +++ b/src/basic/domains/cart/components/CartSidebar.tsx @@ -0,0 +1,55 @@ +import type { CartItem, Coupon } from "../../../../types"; +import { CartItemList } from "./CartItemList"; +import { CouponSelector } from "./CouponSelector"; +import { PaymentSummary } from "./PaymentSummary"; + +type CartSidebarProps = { + cart: CartItem[]; + calculateItemTotal: (item: CartItem) => number; + updateQuantity: (productId: string, newQuantity: number) => void; + removeFromCart: (productId: string) => void; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + completeOrder: () => void; +}; + +export function CartSidebar({ + cart, + calculateItemTotal, + updateQuantity, + removeFromCart, + coupons, + selectedCoupon, + applyCoupon, + setSelectedCoupon, + totals, + completeOrder +}: CartSidebarProps) { + return ( +
+ + {cart.length > 0 && ( + <> + + + + )} +
+ ); +} diff --git a/src/basic/domains/cart/components/CheckoutButton.tsx b/src/basic/domains/cart/components/CheckoutButton.tsx new file mode 100644 index 00000000..bcf1f6eb --- /dev/null +++ b/src/basic/domains/cart/components/CheckoutButton.tsx @@ -0,0 +1,20 @@ +import { Button } from "../../../shared"; + +type CheckoutButtonProps = { + totalAmount: number; + onCompleteOrder: () => void; +}; + +export function CheckoutButton({ totalAmount, onCompleteOrder }: CheckoutButtonProps) { + return ( + <> + + +
+

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

+
+ + ); +} diff --git a/src/basic/domains/cart/components/CouponSelector.tsx b/src/basic/domains/cart/components/CouponSelector.tsx new file mode 100644 index 00000000..29a0f45b --- /dev/null +++ b/src/basic/domains/cart/components/CouponSelector.tsx @@ -0,0 +1,50 @@ +import type { ChangeEvent } from "react"; + +import type { Coupon } from "../../../../types"; + +type CouponSelectorProps = { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + applyCoupon: (coupon: Coupon) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; +}; + +export function CouponSelector({ + coupons, + selectedCoupon, + applyCoupon, + setSelectedCoupon +}: CouponSelectorProps) { + const handleCouponChange = (event: ChangeEvent) => { + const coupon = coupons.find((coupon) => coupon.code === event.target.value); + if (coupon) applyCoupon(coupon); + else setSelectedCoupon(null); + }; + + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +} diff --git a/src/basic/domains/cart/components/PaymentInfoLine.tsx b/src/basic/domains/cart/components/PaymentInfoLine.tsx new file mode 100644 index 00000000..4a80502e --- /dev/null +++ b/src/basic/domains/cart/components/PaymentInfoLine.tsx @@ -0,0 +1,46 @@ +import { tv } from "tailwind-variants"; + +const paymentInfoLine = tv({ + slots: { + container: "flex justify-between", + label: "", + value: "" + }, + variants: { + variant: { + default: { + label: "text-gray-600", + value: "font-medium" + }, + highlighted: { + label: "text-red-500", + value: "" + }, + total: { + container: "border-t border-gray-200 py-2", + label: "font-semibold", + value: "text-lg font-bold text-gray-900" + } + } + }, + defaultVariants: { + variant: "default" + } +}); + +type PaymentInfoLineProps = { + label: string; + value: string; + variant?: "default" | "highlighted" | "total"; +}; + +export function PaymentInfoLine({ label, value, variant = "default" }: PaymentInfoLineProps) { + const { container, label: labelClass, value: valueClass } = paymentInfoLine({ variant }); + + return ( +
+ {label} + {value} +
+ ); +} diff --git a/src/basic/domains/cart/components/PaymentSummary.tsx b/src/basic/domains/cart/components/PaymentSummary.tsx new file mode 100644 index 00000000..dc9cd361 --- /dev/null +++ b/src/basic/domains/cart/components/PaymentSummary.tsx @@ -0,0 +1,47 @@ +import { CheckoutButton } from "./CheckoutButton"; +import { PaymentInfoLine } from "./PaymentInfoLine"; + +type PaymentSummaryProps = { + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + completeOrder: () => void; +}; + +export function PaymentSummary({ totals, completeOrder }: PaymentSummaryProps) { + const handleCompleteOrder = () => { + completeOrder(); + }; + + const discountAmount = totals.totalBeforeDiscount - totals.totalAfterDiscount; + + return ( +
+

결제 정보

+
+ + {discountAmount > 0 && ( + + )} + +
+ + +
+ ); +} diff --git a/src/basic/domains/cart/components/QuantitySelector.tsx b/src/basic/domains/cart/components/QuantitySelector.tsx new file mode 100644 index 00000000..ca52771e --- /dev/null +++ b/src/basic/domains/cart/components/QuantitySelector.tsx @@ -0,0 +1,33 @@ +type QuantitySelectorProps = { + quantity: number; + productId: string; + onUpdateQuantity: (productId: string, newQuantity: number) => void; +}; + +export function QuantitySelector({ quantity, productId, onUpdateQuantity }: QuantitySelectorProps) { + const handleDecrease = () => { + onUpdateQuantity(productId, quantity - 1); + }; + + const handleIncrease = () => { + onUpdateQuantity(productId, quantity + 1); + }; + + return ( +
+ + {quantity} + +
+ ); +} diff --git a/src/basic/domains/cart/components/index.ts b/src/basic/domains/cart/components/index.ts new file mode 100644 index 00000000..36d1aeac --- /dev/null +++ b/src/basic/domains/cart/components/index.ts @@ -0,0 +1,9 @@ +export * from "./CartItemHeader"; +export * from "./CartItemInfo"; +export * from "./CartItemList"; +export * from "./CartSidebar"; +export * from "./CheckoutButton"; +export * from "./CouponSelector"; +export * from "./PaymentInfoLine"; +export * from "./PaymentSummary"; +export * from "./QuantitySelector"; diff --git a/src/basic/domains/cart/index.ts b/src/basic/domains/cart/index.ts new file mode 100644 index 00000000..57488b06 --- /dev/null +++ b/src/basic/domains/cart/index.ts @@ -0,0 +1,4 @@ +export * from "./components"; +export * from "./services"; +export * from "./types"; +export * from "./utils"; diff --git a/src/basic/domains/cart/services/cartApplicationService.ts b/src/basic/domains/cart/services/cartApplicationService.ts new file mode 100644 index 00000000..4c5cd8a4 --- /dev/null +++ b/src/basic/domains/cart/services/cartApplicationService.ts @@ -0,0 +1,85 @@ +import type { CartItem, Product } from "../../../../types"; +import type { NotificationFunction } from "../../../shared"; +import { cartNotificationService, cartValidationService, orderService } from "./index"; + +type CartUpdater = (updater: (prevCart: CartItem[]) => CartItem[]) => void; + +export const cartApplicationService = { + addToCart: ( + product: Product, + cart: CartItem[], + updateCart: CartUpdater, + addNotification: NotificationFunction + ) => { + const validation = cartValidationService.validateAddToCart(product, cart); + if (!validation.valid) { + cartNotificationService.showValidationError(validation.message!, addNotification); + return; + } + + updateCart((prevCart) => { + const existingItem = prevCart.find((item) => item.product.id === product.id); + + if (existingItem) { + const quantityValidation = cartValidationService.validateQuantityIncrease( + product, + existingItem.quantity + ); + if (!quantityValidation.valid) { + cartNotificationService.showValidationError(quantityValidation.message!, addNotification); + return prevCart; + } + + return prevCart.map((item) => + item.product.id === product.id ? { ...item, quantity: item.quantity + 1 } : item + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + cartNotificationService.showAddToCartSuccess(addNotification); + }, + + removeFromCart: (productId: string, updateCart: CartUpdater) => { + updateCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); + }, + + updateQuantity: ( + productId: string, + newQuantity: number, + products: Product[], + updateCart: CartUpdater, + addNotification: NotificationFunction + ) => { + const validation = cartValidationService.validateQuantityUpdate( + productId, + newQuantity, + products + ); + if (!validation.valid) { + cartNotificationService.showValidationError(validation.message!, addNotification); + return; + } + + if (newQuantity <= 0) { + cartApplicationService.removeFromCart(productId, updateCart); + return; + } + + updateCart((prevCart) => + prevCart.map((item) => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ) + ); + }, + + completeOrder: ( + clearCart: () => void, + clearSelectedCoupon: () => void, + addNotification: NotificationFunction + ) => { + const orderNumber = orderService.processOrder(clearCart, clearSelectedCoupon); + cartNotificationService.showOrderSuccess(orderNumber, addNotification); + } +}; diff --git a/src/basic/domains/cart/services/cartNotificationService.ts b/src/basic/domains/cart/services/cartNotificationService.ts new file mode 100644 index 00000000..ab9d1da6 --- /dev/null +++ b/src/basic/domains/cart/services/cartNotificationService.ts @@ -0,0 +1,15 @@ +import type { NotificationFunction } from "../../../shared"; + +export const cartNotificationService = { + showAddToCartSuccess: (addNotification: NotificationFunction) => { + addNotification("장바구니에 담았습니다", "success"); + }, + + showValidationError: (message: string, addNotification: NotificationFunction) => { + addNotification(message, "error"); + }, + + showOrderSuccess: (orderNumber: string, addNotification: NotificationFunction) => { + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, "success"); + } +}; diff --git a/src/basic/domains/cart/services/cartValidationService.ts b/src/basic/domains/cart/services/cartValidationService.ts new file mode 100644 index 00000000..41dccd6e --- /dev/null +++ b/src/basic/domains/cart/services/cartValidationService.ts @@ -0,0 +1,48 @@ +import type { CartItem, Product } from "../../../../types"; +import type { ValidationResult } from "../../../shared"; +import { getRemainingStock } from "../utils"; + +const createValidationResult = (valid: boolean, message?: string): ValidationResult => ({ + valid, + message +}); + +const validateStockAvailability = (requiredQuantity: number, availableStock: number) => { + return requiredQuantity > availableStock + ? createValidationResult(false, `재고는 ${availableStock}개까지만 있습니다.`) + : createValidationResult(true); +}; + +const findProductById = (productId: string, products: Product[]) => { + const product = products.find((p) => p.id === productId); + return product + ? { product, error: null } + : { product: null, error: createValidationResult(false, "상품을 찾을 수 없습니다.") }; +}; + +export const cartValidationService = { + validateAddToCart: (product: Product, cart: CartItem[]) => { + const remainingStock = getRemainingStock(product, cart); + return remainingStock <= 0 + ? createValidationResult(false, "재고가 부족합니다!") + : createValidationResult(true); + }, + + validateQuantityIncrease: (product: Product, currentQuantity: number) => { + const newQuantity = currentQuantity + 1; + return validateStockAvailability(newQuantity, product.stock); + }, + + validateQuantityUpdate: (productId: string, newQuantity: number, products: Product[]) => { + if (newQuantity <= 0) { + return createValidationResult(true); + } + + const { product, error } = findProductById(productId, products); + if (error) { + return error; + } + + return validateStockAvailability(newQuantity, product!.stock); + } +}; diff --git a/src/basic/domains/cart/services/index.ts b/src/basic/domains/cart/services/index.ts new file mode 100644 index 00000000..67ff0a26 --- /dev/null +++ b/src/basic/domains/cart/services/index.ts @@ -0,0 +1,4 @@ +export * from "./cartApplicationService"; +export * from "./cartNotificationService"; +export * from "./cartValidationService"; +export * from "./orderService"; diff --git a/src/basic/domains/cart/services/orderService.ts b/src/basic/domains/cart/services/orderService.ts new file mode 100644 index 00000000..d9f799ad --- /dev/null +++ b/src/basic/domains/cart/services/orderService.ts @@ -0,0 +1,13 @@ +export const orderService = { + createOrderNumber: () => { + return `ORD-${Date.now()}`; + }, + + processOrder: (clearCart: () => void, clearSelectedCoupon: () => void) => { + const orderNumber = orderService.createOrderNumber(); + clearCart(); + clearSelectedCoupon(); + + return orderNumber; + } +}; diff --git a/src/basic/domains/cart/types/entities.ts b/src/basic/domains/cart/types/entities.ts new file mode 100644 index 00000000..184f5ea5 --- /dev/null +++ b/src/basic/domains/cart/types/entities.ts @@ -0,0 +1,11 @@ +import type { Product } from "../../../../types"; + +export interface CartItem { + product: Product; + quantity: number; +} + +export interface CartTotals { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} diff --git a/src/basic/domains/cart/types/index.ts b/src/basic/domains/cart/types/index.ts new file mode 100644 index 00000000..fc8e74dd --- /dev/null +++ b/src/basic/domains/cart/types/index.ts @@ -0,0 +1 @@ +export type * from "./entities"; diff --git a/src/basic/domains/cart/utils/calculators.ts b/src/basic/domains/cart/utils/calculators.ts new file mode 100644 index 00000000..502b96c6 --- /dev/null +++ b/src/basic/domains/cart/utils/calculators.ts @@ -0,0 +1,61 @@ +import type { Coupon, Product } from "../../../../types"; +import type { CartItem } from "../types"; + +export function getMaxApplicableDiscount(item: CartItem, cart: CartItem[]) { + 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 function calculateItemTotal(item: CartItem, cart: CartItem[]) { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +} + +export function calculateCartTotal(cart: CartItem[], selectedCoupon: Coupon | null) { + 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 function 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/domains/cart/utils/index.ts b/src/basic/domains/cart/utils/index.ts new file mode 100644 index 00000000..137f47a3 --- /dev/null +++ b/src/basic/domains/cart/utils/index.ts @@ -0,0 +1 @@ +export * from "./calculators"; diff --git a/src/basic/domains/coupon/components/AddCouponCard.tsx b/src/basic/domains/coupon/components/AddCouponCard.tsx new file mode 100644 index 00000000..2858e653 --- /dev/null +++ b/src/basic/domains/coupon/components/AddCouponCard.tsx @@ -0,0 +1,19 @@ +import { PlusIcon } from "../../../shared"; + +type AddCouponCardProps = { + onClick: () => void; +}; + +export function AddCouponCard({ onClick }: AddCouponCardProps) { + return ( +
+ +
+ ); +} diff --git a/src/basic/domains/coupon/components/CouponCard.tsx b/src/basic/domains/coupon/components/CouponCard.tsx new file mode 100644 index 00000000..e78abebe --- /dev/null +++ b/src/basic/domains/coupon/components/CouponCard.tsx @@ -0,0 +1,37 @@ +import { TrashIcon } from "../../../shared"; +import type { Coupon } from "../types"; + +type CouponCardProps = { + coupon: Coupon; + onDelete: (code: string) => void; +}; + +export function CouponCard({ coupon, onDelete }: CouponCardProps) { + const handleDelete = () => { + onDelete(coupon.code); + }; + + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === "amount" + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +} diff --git a/src/basic/domains/coupon/components/CouponForm.tsx b/src/basic/domains/coupon/components/CouponForm.tsx new file mode 100644 index 00000000..c7250441 --- /dev/null +++ b/src/basic/domains/coupon/components/CouponForm.tsx @@ -0,0 +1,143 @@ +import type { ChangeEvent, FocusEvent, FormEvent } from "react"; + +import { Button, SearchInput } from "../../../shared"; + +type CouponFormType = { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +}; + +type CouponFormProps = { + couponForm: CouponFormType; + onSubmit: (e: FormEvent) => void; + onCancel: () => void; + onFormChange: (form: CouponFormType) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +}; + +export function CouponForm({ + couponForm, + onSubmit, + onCancel, + onFormChange, + addNotification +}: CouponFormProps) { + const handleNameChange = (e: ChangeEvent) => { + onFormChange({ + ...couponForm, + name: e.target.value + }); + }; + + const handleCodeChange = (e: ChangeEvent) => { + onFormChange({ + ...couponForm, + code: e.target.value.toUpperCase() + }); + }; + + const handleDiscountTypeChange = (e: ChangeEvent) => { + onFormChange({ + ...couponForm, + discountType: e.target.value as "amount" | "percentage" + }); + }; + + const handleDiscountValueChange = (e: ChangeEvent) => { + const value = e.target.value; + + if (value === "" || /^\d+$/.test(value)) { + onFormChange({ + ...couponForm, + discountValue: value === "" ? 0 : parseInt(value) + }); + } + }; + + const handleDiscountValueBlur = (e: FocusEvent) => { + const value = e.target.value; + const numValue = parseInt(value) || 0; + + if (couponForm.discountType === "percentage") { + if (numValue > 100) { + addNotification("할인율은 100%를 초과할 수 없습니다", "error"); + onFormChange({ ...couponForm, discountValue: 100 }); + } else if (numValue < 0) { + onFormChange({ ...couponForm, discountValue: 0 }); + } + } else { + if (numValue > 100000) { + addNotification("할인 금액은 100,000원을 초과할 수 없습니다", "error"); + onFormChange({ ...couponForm, discountValue: 100000 }); + } else if (numValue < 0) { + onFormChange({ ...couponForm, discountValue: 0 }); + } + } + }; + + return ( +
+
+

새 쿠폰 생성

+
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/basic/domains/coupon/components/index.ts b/src/basic/domains/coupon/components/index.ts new file mode 100644 index 00000000..8d2317ef --- /dev/null +++ b/src/basic/domains/coupon/components/index.ts @@ -0,0 +1,3 @@ +export * from "./AddCouponCard"; +export * from "./CouponCard"; +export * from "./CouponForm"; diff --git a/src/basic/domains/coupon/constants/index.ts b/src/basic/domains/coupon/constants/index.ts new file mode 100644 index 00000000..b313b308 --- /dev/null +++ b/src/basic/domains/coupon/constants/index.ts @@ -0,0 +1 @@ +export * from "./initialData"; diff --git a/src/basic/domains/coupon/constants/initialData.ts b/src/basic/domains/coupon/constants/initialData.ts new file mode 100644 index 00000000..2ae94127 --- /dev/null +++ b/src/basic/domains/coupon/constants/initialData.ts @@ -0,0 +1,16 @@ +import type { Coupon } from "../types"; + +export const INITIAL_COUPONS: Coupon[] = [ + { + name: "5000원 할인", + code: "AMOUNT5000", + discountType: "amount", + discountValue: 5000 + }, + { + name: "10% 할인", + code: "PERCENT10", + discountType: "percentage", + discountValue: 10 + } +]; diff --git a/src/basic/domains/coupon/index.ts b/src/basic/domains/coupon/index.ts new file mode 100644 index 00000000..6e8050bd --- /dev/null +++ b/src/basic/domains/coupon/index.ts @@ -0,0 +1,5 @@ +export * from "./components"; +export * from "./constants"; +export * from "./services"; +export * from "./types"; +export * from "./utils"; diff --git a/src/basic/domains/coupon/services/couponApplicationService.ts b/src/basic/domains/coupon/services/couponApplicationService.ts new file mode 100644 index 00000000..31538dfc --- /dev/null +++ b/src/basic/domains/coupon/services/couponApplicationService.ts @@ -0,0 +1,71 @@ +import type { NotificationFunction } from "../../../shared"; +import type { Coupon } from "../types"; +import { couponNotificationService } from "./couponNotificationService"; +import { couponValidationService } from "./couponValidationService"; + +type CouponUpdater = (updater: (prev: Coupon[]) => Coupon[]) => void; + +export const couponApplicationService = { + addCoupon: ( + newCoupon: Coupon, + existingCoupons: Coupon[], + updateCoupons: CouponUpdater, + addNotification: NotificationFunction + ) => { + const validation = couponValidationService.validateCouponCode(newCoupon.code, existingCoupons); + if (!validation.valid) { + couponNotificationService.showValidationError(validation.message!, addNotification); + return; + } + + updateCoupons((prev) => [...prev, newCoupon]); + couponNotificationService.showAddSuccess(addNotification); + }, + + deleteCoupon: ( + couponCode: string, + selectedCoupon: Coupon | null, + updateCoupons: CouponUpdater, + setSelectedCoupon: (coupon: Coupon | null) => void, + addNotification: NotificationFunction + ) => { + updateCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + + couponNotificationService.showDeleteSuccess(addNotification); + }, + + applyCoupon: ( + coupon: Coupon, + totalAmount: number | undefined, + setSelectedCoupon: (coupon: Coupon | null) => void, + addNotification: NotificationFunction + ) => { + if (totalAmount !== undefined) { + const validation = couponValidationService.validateCouponUsage(coupon, totalAmount); + if (!validation.valid) { + couponNotificationService.showValidationError(validation.message!, addNotification); + return; + } + } + + setSelectedCoupon(coupon); + couponNotificationService.showApplySuccess(addNotification); + }, + + handleCouponSubmit: ( + couponForm: Coupon, + existingCoupons: Coupon[], + updateCoupons: CouponUpdater, + resetForm: () => void, + setShowForm: (show: boolean) => void, + addNotification: NotificationFunction + ) => { + couponApplicationService.addCoupon(couponForm, existingCoupons, updateCoupons, addNotification); + resetForm(); + setShowForm(false); + } +}; diff --git a/src/basic/domains/coupon/services/couponNotificationService.ts b/src/basic/domains/coupon/services/couponNotificationService.ts new file mode 100644 index 00000000..468dc341 --- /dev/null +++ b/src/basic/domains/coupon/services/couponNotificationService.ts @@ -0,0 +1,19 @@ +import type { NotificationFunction } from "../../../shared"; + +export const couponNotificationService = { + showAddSuccess: (addNotification: NotificationFunction) => { + addNotification("쿠폰이 추가되었습니다.", "success"); + }, + + showDeleteSuccess: (addNotification: NotificationFunction) => { + addNotification("쿠폰이 삭제되었습니다.", "success"); + }, + + showApplySuccess: (addNotification: NotificationFunction) => { + addNotification("쿠폰이 적용되었습니다.", "success"); + }, + + showValidationError: (message: string, addNotification: NotificationFunction) => { + addNotification(message, "error"); + } +}; diff --git a/src/basic/domains/coupon/services/couponValidationService.ts b/src/basic/domains/coupon/services/couponValidationService.ts new file mode 100644 index 00000000..4ff87b0c --- /dev/null +++ b/src/basic/domains/coupon/services/couponValidationService.ts @@ -0,0 +1,17 @@ +import type { ValidationResult } from "../../../shared"; +import type { Coupon } from "../types"; + +export const couponValidationService = { + validateCouponCode: (code: string, existingCoupons: Coupon[]): ValidationResult => { + const existingCoupon = existingCoupons.find((c) => c.code === code); + return existingCoupon + ? { valid: false, message: "이미 존재하는 쿠폰 코드입니다." } + : { valid: true, message: "사용 가능한 쿠폰 코드입니다." }; + }, + + validateCouponUsage: (coupon: Coupon, totalAmount: number): ValidationResult => { + return totalAmount < 10000 && coupon.discountType === "percentage" + ? { valid: false, message: "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다." } + : { valid: true, message: "쿠폰이 적용되었습니다." }; + } +}; diff --git a/src/basic/domains/coupon/services/index.ts b/src/basic/domains/coupon/services/index.ts new file mode 100644 index 00000000..539a5052 --- /dev/null +++ b/src/basic/domains/coupon/services/index.ts @@ -0,0 +1,3 @@ +export * from "./couponApplicationService"; +export * from "./couponNotificationService"; +export * from "./couponValidationService"; diff --git a/src/basic/domains/coupon/types/entities.ts b/src/basic/domains/coupon/types/entities.ts new file mode 100644 index 00000000..5f575011 --- /dev/null +++ b/src/basic/domains/coupon/types/entities.ts @@ -0,0 +1,6 @@ +export interface Coupon { + name: string; + code: string; + discountType: "amount" | "percentage"; + discountValue: number; +} diff --git a/src/basic/domains/coupon/types/index.ts b/src/basic/domains/coupon/types/index.ts new file mode 100644 index 00000000..fc8e74dd --- /dev/null +++ b/src/basic/domains/coupon/types/index.ts @@ -0,0 +1 @@ +export type * from "./entities"; diff --git a/src/basic/domains/coupon/utils/index.ts b/src/basic/domains/coupon/utils/index.ts new file mode 100644 index 00000000..58564490 --- /dev/null +++ b/src/basic/domains/coupon/utils/index.ts @@ -0,0 +1 @@ +export * from "./validators"; diff --git a/src/basic/domains/coupon/utils/validators.ts b/src/basic/domains/coupon/utils/validators.ts new file mode 100644 index 00000000..3fdee023 --- /dev/null +++ b/src/basic/domains/coupon/utils/validators.ts @@ -0,0 +1,30 @@ +import type { Coupon } from "../types"; + +export function validateCouponUsage(coupon: Coupon, totalAmount: number) { + if (totalAmount < 10000 && coupon.discountType === "percentage") { + return { + valid: false, + message: "percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다." + }; + } + + return { + valid: true, + message: "쿠폰이 적용되었습니다." + }; +} + +export function validateCouponCode(code: string, existingCoupons: Coupon[]) { + const existingCoupon = existingCoupons.find((c) => c.code === code); + if (existingCoupon) { + return { + valid: false, + message: "이미 존재하는 쿠폰 코드입니다." + }; + } + + return { + valid: true, + message: "사용 가능한 쿠폰 코드입니다." + }; +} diff --git a/src/basic/domains/product/components/DiscountItem.tsx b/src/basic/domains/product/components/DiscountItem.tsx new file mode 100644 index 00000000..9576bdd0 --- /dev/null +++ b/src/basic/domains/product/components/DiscountItem.tsx @@ -0,0 +1,54 @@ +import type { ChangeEvent } from "react"; + +import { CloseIcon } from "../../../shared"; +import { type Discount } from "../types"; + +type DiscountItemProps = { + discount: Discount; + index: number; + onChange: (index: number, field: "quantity" | "rate", value: number) => void; + onRemove: (index: number) => void; +}; + +export function DiscountItem({ discount, index, onChange, onRemove }: DiscountItemProps) { + const handleQuantityChange = (e: ChangeEvent) => { + const value = e.target.value; + onChange(index, "quantity", parseInt(value) || 0); + }; + + const handleRateChange = (e: ChangeEvent) => { + const value = e.target.value; + onChange(index, "rate", (parseInt(value) || 0) / 100); + }; + + const handleRemove = () => { + onRemove(index); + }; + + return ( +
+ + 개 이상 구매 시 + + % 할인 + +
+ ); +} diff --git a/src/basic/domains/product/components/DiscountSection.tsx b/src/basic/domains/product/components/DiscountSection.tsx new file mode 100644 index 00000000..ad735b70 --- /dev/null +++ b/src/basic/domains/product/components/DiscountSection.tsx @@ -0,0 +1,48 @@ +import type { Discount } from "../types"; +import { DiscountItem } from "./DiscountItem"; + +type DiscountSectionProps = { + discounts: Discount[]; + onChange: (discounts: Discount[]) => void; +}; + +export function DiscountSection({ discounts, onChange }: DiscountSectionProps) { + const handleDiscountChange = (index: number, field: "quantity" | "rate", value: number) => { + const newDiscounts = [...discounts]; + newDiscounts[index][field] = value; + onChange(newDiscounts); + }; + + const handleRemoveDiscount = (index: number) => { + const newDiscounts = discounts.filter((_, i) => i !== index); + onChange(newDiscounts); + }; + + const handleAddDiscount = () => { + onChange([...discounts, { quantity: 10, rate: 0.1 }]); + }; + + return ( +
+ +
+ {discounts.map((discount, index) => ( + + ))} + +
+
+ ); +} diff --git a/src/basic/domains/product/components/ProductCard.tsx b/src/basic/domains/product/components/ProductCard.tsx new file mode 100644 index 00000000..053d1aa3 --- /dev/null +++ b/src/basic/domains/product/components/ProductCard.tsx @@ -0,0 +1,58 @@ +import { Button } from "../../../shared"; +import type { ProductWithUI } from "../types"; +import { ProductImage } from "./ProductImage"; +import { StockStatus } from "./StockStatus"; + +type ProductCardProps = { + product: ProductWithUI; + remainingStock: number; + formatPrice: (price: number, productId?: string) => string; + onAddToCart: (product: ProductWithUI) => void; +}; + +export function ProductCard({ + product, + remainingStock, + formatPrice, + onAddToCart +}: ProductCardProps) { + const handleAddToCart = () => { + onAddToCart(product); + }; + + return ( +
+ + +
+

{product.name}

+ {product.description && ( +

{product.description}

+ )} + +
+

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

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

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

+ )} +
+ +
+ +
+ +
+
+ ); +} diff --git a/src/basic/domains/product/components/ProductFormEditor.tsx b/src/basic/domains/product/components/ProductFormEditor.tsx new file mode 100644 index 00000000..13879bef --- /dev/null +++ b/src/basic/domains/product/components/ProductFormEditor.tsx @@ -0,0 +1,140 @@ +import { type ChangeEvent, type FocusEvent, type FormEvent } from "react"; + +import { Button, SearchInput } from "../../../shared"; +import { Discount, type ProductForm } from "../types"; +import { DiscountSection } from "./DiscountSection"; + +type ProductFormEditorProps = { + productForm: ProductForm; + editingProduct: string | null; + onSubmit: (e: FormEvent) => void; + onCancel: () => void; + onFormChange: (form: ProductForm) => void; + addNotification: (message: string, type?: "error" | "success" | "warning") => void; +}; + +export function ProductFormEditor({ + productForm, + editingProduct, + onSubmit, + onCancel, + onFormChange, + addNotification +}: ProductFormEditorProps) { + const handleNameChange = (e: ChangeEvent) => { + onFormChange({ ...productForm, name: e.target.value }); + }; + + const handleDescriptionChange = (e: ChangeEvent) => { + onFormChange({ ...productForm, description: e.target.value }); + }; + + const handleDiscountsChange = (discounts: Discount[]) => { + onFormChange({ ...productForm, discounts }); + }; + + const handlePriceChange = (e: ChangeEvent) => { + const value = e.target.value; + + if (value === "" || /^\d+$/.test(value)) { + onFormChange({ + ...productForm, + price: value === "" ? 0 : parseInt(value) + }); + } + }; + + const handlePriceBlur = (e: FocusEvent) => { + const value = e.target.value; + + if (value === "") { + onFormChange({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + addNotification("가격은 0보다 커야 합니다", "error"); + onFormChange({ ...productForm, price: 0 }); + } + }; + + const handleStockChange = (e: ChangeEvent) => { + const value = e.target.value; + + if (value === "" || /^\d+$/.test(value)) { + onFormChange({ + ...productForm, + stock: value === "" ? 0 : parseInt(value) + }); + } + }; + + const handleStockBlur = (e: FocusEvent) => { + const value = e.target.value; + + if (value === "") { + onFormChange({ ...productForm, stock: 0 }); + } else if (parseInt(value) < 0) { + addNotification("재고는 0보다 커야 합니다", "error"); + onFormChange({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + addNotification("재고는 9999개를 초과할 수 없습니다", "error"); + onFormChange({ ...productForm, stock: 9999 }); + } + }; + + return ( +
+
+

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

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + +
+ + +
+ +
+ ); +} diff --git a/src/basic/domains/product/components/ProductImage.tsx b/src/basic/domains/product/components/ProductImage.tsx new file mode 100644 index 00000000..c322dd72 --- /dev/null +++ b/src/basic/domains/product/components/ProductImage.tsx @@ -0,0 +1,30 @@ +import { ImagePlaceholderIcon } from "../../../shared"; +import type { ProductWithUI } from "../types"; + +type ProductImageProps = { + product: ProductWithUI; +}; + +export function ProductImage({ product }: ProductImageProps) { + const maxDiscountRate = + product.discounts.length > 0 ? Math.max(...product.discounts.map((d) => d.rate)) * 100 : 0; + + return ( +
+
+ +
+ + {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{maxDiscountRate}% + + )} +
+ ); +} diff --git a/src/basic/domains/product/components/ProductList.tsx b/src/basic/domains/product/components/ProductList.tsx new file mode 100644 index 00000000..4b713fc1 --- /dev/null +++ b/src/basic/domains/product/components/ProductList.tsx @@ -0,0 +1,50 @@ +import type { Product, ProductWithUI } from "../types"; +import { ProductCard } from "./ProductCard"; + +type ProductListProps = { + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + getRemainingStock: (product: Product) => number; + formatPrice: (price: number, productId?: string) => string; + addToCart: (product: ProductWithUI) => void; +}; + +export function ProductList({ + products, + filteredProducts, + debouncedSearchTerm, + getRemainingStock, + formatPrice, + addToCart +}: ProductListProps) { + const handleAddToCart = (product: ProductWithUI) => { + addToCart(product); + }; + + return ( +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map((product) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/basic/domains/product/components/ProductTable.tsx b/src/basic/domains/product/components/ProductTable.tsx new file mode 100644 index 00000000..e2c88812 --- /dev/null +++ b/src/basic/domains/product/components/ProductTable.tsx @@ -0,0 +1,48 @@ +import type { ProductWithUI } from "../../product"; +import { ProductTableRow } from "./ProductTableRow"; + +type ProductTableProps = { + products: ProductWithUI[]; + formatPrice: (price: number, productId?: string) => string; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +}; + +export function ProductTable({ products, formatPrice, onEdit, onDelete }: ProductTableProps) { + return ( +
+ + + + + + + + + + + + {products.map((product) => ( + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+
+ ); +} diff --git a/src/basic/domains/product/components/ProductTableRow.tsx b/src/basic/domains/product/components/ProductTableRow.tsx new file mode 100644 index 00000000..b47ee488 --- /dev/null +++ b/src/basic/domains/product/components/ProductTableRow.tsx @@ -0,0 +1,55 @@ +import { Button } from "../../../shared"; +import type { ProductWithUI } from "../types"; +import { StockBadge } from "./StockBadge"; + +type ProductTableRowProps = { + product: ProductWithUI; + formatPrice: (price: number, productId?: string) => string; + onEdit: (product: ProductWithUI) => void; + onDelete: (productId: string) => void; +}; + +export function ProductTableRow({ product, formatPrice, onEdit, onDelete }: ProductTableRowProps) { + const handleEdit = () => { + onEdit(product); + }; + + const handleDelete = () => { + onDelete(product.id); + }; + + return ( + + + {product.name} + + + {formatPrice(product.price, product.id)} + + + + + + {product.description || "-"} + + + + + + + ); +} diff --git a/src/basic/domains/product/components/StockBadge.tsx b/src/basic/domains/product/components/StockBadge.tsx new file mode 100644 index 00000000..4b2b271b --- /dev/null +++ b/src/basic/domains/product/components/StockBadge.tsx @@ -0,0 +1,22 @@ +import { tv } from "tailwind-variants"; + +const stockBadge = tv({ + base: "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium", + variants: { + level: { + high: "bg-green-100 text-green-800", + low: "bg-yellow-100 text-yellow-800", + out: "bg-red-100 text-red-800" + } + } +}); + +type StockBadgeProps = { + stock: number; +}; + +export function StockBadge({ stock }: StockBadgeProps) { + const level = stock > 10 ? "high" : stock > 0 ? "low" : "out"; + + return {stock}개; +} diff --git a/src/basic/domains/product/components/StockStatus.tsx b/src/basic/domains/product/components/StockStatus.tsx new file mode 100644 index 00000000..6d22fbd3 --- /dev/null +++ b/src/basic/domains/product/components/StockStatus.tsx @@ -0,0 +1,30 @@ +import { tv } from "tailwind-variants"; + +type StockStatusProps = { + remainingStock: number; + className?: string; +}; + +const LOW_STOCK_THRESHOLD = 5; + +const stockStatusText = tv({ + base: "text-xs", + variants: { + tone: { + normal: "text-gray-500", + low: "font-medium text-red-600" + } + }, + defaultVariants: { + tone: "normal" + } +}); + +export function StockStatus({ remainingStock, className }: StockStatusProps) { + if (remainingStock <= 0) return null; + + const tone = remainingStock <= LOW_STOCK_THRESHOLD ? "low" : "normal"; + const text = tone === "low" ? `품절임박! ${remainingStock}개 남음` : `재고 ${remainingStock}개`; + + return

{text}

; +} diff --git a/src/basic/domains/product/components/index.ts b/src/basic/domains/product/components/index.ts new file mode 100644 index 00000000..a6c680bc --- /dev/null +++ b/src/basic/domains/product/components/index.ts @@ -0,0 +1,10 @@ +export * from "./DiscountItem"; +export * from "./DiscountSection"; +export * from "./ProductCard"; +export * from "./ProductFormEditor"; +export * from "./ProductImage"; +export * from "./ProductList"; +export * from "./ProductTable"; +export * from "./ProductTableRow"; +export * from "./StockBadge"; +export * from "./StockStatus"; diff --git a/src/basic/domains/product/constants/index.ts b/src/basic/domains/product/constants/index.ts new file mode 100644 index 00000000..b313b308 --- /dev/null +++ b/src/basic/domains/product/constants/index.ts @@ -0,0 +1 @@ +export * from "./initialData"; diff --git a/src/basic/domains/product/constants/initialData.ts b/src/basic/domains/product/constants/initialData.ts new file mode 100644 index 00000000..11d7f1aa --- /dev/null +++ b/src/basic/domains/product/constants/initialData.ts @@ -0,0 +1,35 @@ +import type { ProductWithUI } from "../types"; + +export const INITIAL_PRODUCTS: 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/domains/product/index.ts b/src/basic/domains/product/index.ts new file mode 100644 index 00000000..6e8050bd --- /dev/null +++ b/src/basic/domains/product/index.ts @@ -0,0 +1,5 @@ +export * from "./components"; +export * from "./constants"; +export * from "./services"; +export * from "./types"; +export * from "./utils"; diff --git a/src/basic/domains/product/services/index.ts b/src/basic/domains/product/services/index.ts new file mode 100644 index 00000000..38e141dd --- /dev/null +++ b/src/basic/domains/product/services/index.ts @@ -0,0 +1,2 @@ +export * from "./productApplicationService"; +export * from "./productNotificationService"; diff --git a/src/basic/domains/product/services/productApplicationService.ts b/src/basic/domains/product/services/productApplicationService.ts new file mode 100644 index 00000000..4315faab --- /dev/null +++ b/src/basic/domains/product/services/productApplicationService.ts @@ -0,0 +1,92 @@ +import type { NotificationFunction } from "../../../shared"; +import type { ProductForm, ProductWithUI } from "../types"; +import { productNotificationService } from "./productNotificationService"; + +type ProductUpdater = (updater: (prev: ProductWithUI[]) => ProductWithUI[]) => void; + +export const productApplicationService = { + addProduct: ( + newProduct: Omit, + updateProducts: ProductUpdater, + addNotification: NotificationFunction + ) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + + updateProducts((prev) => [...prev, product]); + productNotificationService.showAddSuccess(addNotification); + }, + + updateProduct: ( + productId: string, + updates: Partial, + updateProducts: ProductUpdater, + addNotification: NotificationFunction + ) => { + updateProducts((prev) => + prev.map((product) => (product.id === productId ? { ...product, ...updates } : product)) + ); + productNotificationService.showUpdateSuccess(addNotification); + }, + + deleteProduct: ( + productId: string, + updateProducts: ProductUpdater, + addNotification: NotificationFunction + ) => { + updateProducts((prev) => prev.filter((p) => p.id !== productId)); + productNotificationService.showDeleteSuccess(addNotification); + }, + + handleProductSubmit: ( + productForm: ProductForm, + editingProduct: string | null, + updateProducts: ProductUpdater, + resetForm: () => void, + setEditingProduct: (id: string | null) => void, + setShowForm: (show: boolean) => void, + addNotification: NotificationFunction + ) => { + if (editingProduct && editingProduct !== "new") { + productApplicationService.updateProduct( + editingProduct, + productForm, + updateProducts, + addNotification + ); + setEditingProduct(null); + } else { + productApplicationService.addProduct( + { + ...productForm, + discounts: productForm.discounts + }, + updateProducts, + addNotification + ); + } + + resetForm(); + setEditingProduct(null); + setShowForm(false); + }, + + startEditProduct: ( + product: ProductWithUI, + setEditingProduct: (id: string) => void, + setProductForm: (form: ProductForm) => void, + setShowForm: (show: boolean) => void + ) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || "", + discounts: product.discounts || [] + }); + setShowForm(true); + } +}; diff --git a/src/basic/domains/product/services/productNotificationService.ts b/src/basic/domains/product/services/productNotificationService.ts new file mode 100644 index 00000000..76765027 --- /dev/null +++ b/src/basic/domains/product/services/productNotificationService.ts @@ -0,0 +1,15 @@ +import type { NotificationFunction } from "../../../shared"; + +export const productNotificationService = { + showAddSuccess: (addNotification: NotificationFunction) => { + addNotification("상품이 추가되었습니다.", "success"); + }, + + showUpdateSuccess: (addNotification: NotificationFunction) => { + addNotification("상품이 수정되었습니다.", "success"); + }, + + showDeleteSuccess: (addNotification: NotificationFunction) => { + addNotification("상품이 삭제되었습니다.", "success"); + } +}; diff --git a/src/basic/domains/product/types/entities.ts b/src/basic/domains/product/types/entities.ts new file mode 100644 index 00000000..e8b461a3 --- /dev/null +++ b/src/basic/domains/product/types/entities.ts @@ -0,0 +1,25 @@ +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface Discount { + quantity: number; + rate: number; +} + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export interface ProductForm { + name: string; + price: number; + stock: number; + description: string; + discounts: Discount[]; +} diff --git a/src/basic/domains/product/types/index.ts b/src/basic/domains/product/types/index.ts new file mode 100644 index 00000000..fc8e74dd --- /dev/null +++ b/src/basic/domains/product/types/index.ts @@ -0,0 +1 @@ +export type * from "./entities"; diff --git a/src/basic/domains/product/utils/formatters.ts b/src/basic/domains/product/utils/formatters.ts new file mode 100644 index 00000000..3badfe26 --- /dev/null +++ b/src/basic/domains/product/utils/formatters.ts @@ -0,0 +1,35 @@ +import type { CartItem } from "../../cart/types"; +import { getRemainingStock } from "../../cart/utils"; +import type { ProductWithUI } from "../types"; + +export function formatPrice( + price: number, + productId?: string, + products?: ProductWithUI[], + cart?: CartItem[], + isAdmin?: boolean +) { + if (productId && products && cart) { + const product = products.find((p) => p.id === productId); + if (product && getRemainingStock(product, cart) <= 0) { + return "SOLD OUT"; + } + } + + if (isAdmin) { + return `${price.toLocaleString()}원`; + } + + return `₩${price.toLocaleString()}`; +} + +export function filterProducts(products: ProductWithUI[], searchTerm: string) { + if (!searchTerm) return products; + + const lowerSearchTerm = searchTerm.toLowerCase(); + return products.filter( + (product) => + product.name.toLowerCase().includes(lowerSearchTerm) || + (product.description && product.description.toLowerCase().includes(lowerSearchTerm)) + ); +} diff --git a/src/basic/domains/product/utils/index.ts b/src/basic/domains/product/utils/index.ts new file mode 100644 index 00000000..96552da5 --- /dev/null +++ b/src/basic/domains/product/utils/index.ts @@ -0,0 +1 @@ +export * from "./formatters"; diff --git a/src/basic/main.tsx b/src/basic/main.tsx index e63eef4a..d08c16bb 100644 --- a/src/basic/main.tsx +++ b/src/basic/main.tsx @@ -1,9 +1,16 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; -ReactDOM.createRoot(document.getElementById('root')!).render( - +import { App } from "./app"; + +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error('Root element with id "root" not found'); +} + +createRoot(rootElement).render( + - , -) + +); diff --git a/src/basic/shared/components/icons/CartIcon.tsx b/src/basic/shared/components/icons/CartIcon.tsx new file mode 100644 index 00000000..c4ba9a45 --- /dev/null +++ b/src/basic/shared/components/icons/CartIcon.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from "react"; + +type CartIconProps = SVGProps; + +export function CartIcon({ className = "w-6 h-6", ...rest }: CartIconProps) { + return ( + + + + ); +} diff --git a/src/basic/shared/components/icons/CloseIcon.tsx b/src/basic/shared/components/icons/CloseIcon.tsx new file mode 100644 index 00000000..6e4cef6b --- /dev/null +++ b/src/basic/shared/components/icons/CloseIcon.tsx @@ -0,0 +1,11 @@ +import type { SVGProps } from "react"; + +type CloseIconProps = SVGProps; + +export function CloseIcon({ className = "w-4 h-4", ...rest }: CloseIconProps) { + return ( + + + + ); +} diff --git a/src/basic/shared/components/icons/ImagePlaceholderIcon.tsx b/src/basic/shared/components/icons/ImagePlaceholderIcon.tsx new file mode 100644 index 00000000..f2090948 --- /dev/null +++ b/src/basic/shared/components/icons/ImagePlaceholderIcon.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from "react"; + +type ImagePlaceholderIconProps = SVGProps; + +export function ImagePlaceholderIcon({ + className = "w-24 h-24", + ...rest +}: ImagePlaceholderIconProps) { + return ( + + + + ); +} diff --git a/src/basic/shared/components/icons/PlusIcon.tsx b/src/basic/shared/components/icons/PlusIcon.tsx new file mode 100644 index 00000000..6b014dd1 --- /dev/null +++ b/src/basic/shared/components/icons/PlusIcon.tsx @@ -0,0 +1,11 @@ +import type { SVGProps } from "react"; + +type PlusIconProps = SVGProps; + +export function PlusIcon({ className = "w-6 h-6", ...rest }: PlusIconProps) { + return ( + + + + ); +} diff --git a/src/basic/shared/components/icons/ShoppingBagIcon.tsx b/src/basic/shared/components/icons/ShoppingBagIcon.tsx new file mode 100644 index 00000000..ada901f9 --- /dev/null +++ b/src/basic/shared/components/icons/ShoppingBagIcon.tsx @@ -0,0 +1,22 @@ +import type { SVGProps } from "react"; + +type ShoppingBagIconProps = SVGProps & { + strokeWidth?: number; +}; + +export function ShoppingBagIcon({ + className = "w-5 h-5", + strokeWidth = 2, + ...rest +}: ShoppingBagIconProps) { + return ( + + + + ); +} diff --git a/src/basic/shared/components/icons/TrashIcon.tsx b/src/basic/shared/components/icons/TrashIcon.tsx new file mode 100644 index 00000000..d64db2f0 --- /dev/null +++ b/src/basic/shared/components/icons/TrashIcon.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from "react"; + +type TrashIconProps = SVGProps; + +export function TrashIcon({ className = "w-5 h-5", ...rest }: TrashIconProps) { + return ( + + + + ); +} diff --git a/src/basic/shared/components/icons/index.ts b/src/basic/shared/components/icons/index.ts new file mode 100644 index 00000000..5394b7f8 --- /dev/null +++ b/src/basic/shared/components/icons/index.ts @@ -0,0 +1,6 @@ +export * from "./CartIcon"; +export * from "./CloseIcon"; +export * from "./ImagePlaceholderIcon"; +export * from "./PlusIcon"; +export * from "./ShoppingBagIcon"; +export * from "./TrashIcon"; diff --git a/src/basic/shared/components/index.ts b/src/basic/shared/components/index.ts new file mode 100644 index 00000000..ac365264 --- /dev/null +++ b/src/basic/shared/components/index.ts @@ -0,0 +1,2 @@ +export * from "./icons"; +export * from "./ui"; diff --git a/src/basic/shared/components/ui/BadgeContainer.tsx b/src/basic/shared/components/ui/BadgeContainer.tsx new file mode 100644 index 00000000..a489936d --- /dev/null +++ b/src/basic/shared/components/ui/BadgeContainer.tsx @@ -0,0 +1,19 @@ +import { type PropsWithChildren } from "react"; + +type BadgeContainerProps = PropsWithChildren<{ + label: string; + visible: boolean; +}>; + +export function BadgeContainer({ label, visible, children }: BadgeContainerProps) { + return ( +
+ {children} + {visible && ( + + {label} + + )} +
+ ); +} diff --git a/src/basic/shared/components/ui/Button.tsx b/src/basic/shared/components/ui/Button.tsx new file mode 100644 index 00000000..d741d240 --- /dev/null +++ b/src/basic/shared/components/ui/Button.tsx @@ -0,0 +1,34 @@ +import type { ComponentPropsWithRef } from "react"; +import { tv } from "tailwind-variants"; + +type ButtonProps = Omit, "size" | "color"> & { + size?: "lg" | "md" | "sm"; + color?: "primary" | "secondary" | "danger" | "dark" | "neutral" | "yellow"; +}; + +const buttonVariants = tv({ + base: "rounded font-medium transition-colors focus:outline-none disabled:cursor-not-allowed disabled:opacity-50", + variants: { + size: { + sm: "px-3 py-1.5 text-sm", + md: "px-4 py-2 text-base", + lg: "px-6 py-3 text-base" + }, + color: { + primary: "bg-indigo-600 text-white hover:bg-indigo-700 disabled:bg-indigo-300", + secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300 disabled:bg-gray-100", + danger: "bg-red-600 text-white hover:bg-red-700 disabled:bg-red-300", + dark: "bg-gray-900 text-white hover:bg-gray-800 disabled:bg-gray-400", + neutral: "bg-gray-800 text-white hover:bg-gray-700 disabled:bg-gray-400", + yellow: "bg-yellow-400 text-gray-900 hover:bg-yellow-500 disabled:bg-yellow-200" + } + }, + defaultVariants: { + size: "md", + color: "primary" + } +}); + +export function Button({ className, size, color, ...rest }: ButtonProps) { + return + + ); +} diff --git a/src/basic/shared/components/ui/SearchInput.tsx b/src/basic/shared/components/ui/SearchInput.tsx new file mode 100644 index 00000000..8da93f5e --- /dev/null +++ b/src/basic/shared/components/ui/SearchInput.tsx @@ -0,0 +1,35 @@ +import type { ComponentPropsWithRef } from "react"; +import { tv } from "tailwind-variants"; + +type SearchInputProps = Omit, "size" | "color"> & { + label?: string; + size?: "lg" | "md"; + color?: "blue" | "indigo"; +}; + +const inputVariants = tv({ + base: "w-full border border-gray-300 py-2", + variants: { + size: { + md: "rounded-md px-3", + lg: "rounded-lg px-4" + }, + color: { + blue: "focus:border-blue-500 focus:outline-none", + indigo: "focus:border-indigo-500 focus:ring-indigo-500" + } + }, + defaultVariants: { + size: "md", + color: "indigo" + } +}); + +export function SearchInput({ className, label, size, color, ...rest }: SearchInputProps) { + return ( + <> + {label && } + + + ); +} diff --git a/src/basic/shared/components/ui/index.ts b/src/basic/shared/components/ui/index.ts new file mode 100644 index 00000000..7a06b0f1 --- /dev/null +++ b/src/basic/shared/components/ui/index.ts @@ -0,0 +1,4 @@ +export * from "./BadgeContainer"; +export * from "./Button"; +export * from "./Notification"; +export * from "./SearchInput"; diff --git a/src/basic/shared/hooks/index.ts b/src/basic/shared/hooks/index.ts new file mode 100644 index 00000000..815d51bd --- /dev/null +++ b/src/basic/shared/hooks/index.ts @@ -0,0 +1,5 @@ +export * from "./useDebounceState"; +export * from "./useDebounceValue"; +export * from "./useLocalStorageState"; +export * from "./useNotifications"; +export * from "./useToggle"; diff --git a/src/basic/shared/hooks/useDebounceState.ts b/src/basic/shared/hooks/useDebounceState.ts new file mode 100644 index 00000000..a5b9c1a6 --- /dev/null +++ b/src/basic/shared/hooks/useDebounceState.ts @@ -0,0 +1,22 @@ +import { type Dispatch, type SetStateAction, useState } from "react"; + +import { useDebounceValue } from "./useDebounceValue"; + +type UseDebounceStateProps = { + delay: number; + initialValue: S; +}; + +export function useDebounceState({ + delay, + initialValue +}: UseDebounceStateProps): [S, Dispatch>, S] { + const [state, setState] = useState(initialValue); + + const debouncedState = useDebounceValue({ + delay, + value: state + }); + + return [state, setState, debouncedState]; +} diff --git a/src/basic/shared/hooks/useDebounceValue.ts b/src/basic/shared/hooks/useDebounceValue.ts new file mode 100644 index 00000000..b82e1fda --- /dev/null +++ b/src/basic/shared/hooks/useDebounceValue.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; + +type UseDebounceValueProps = { + delay: number; + value: T; +}; + +export function useDebounceValue({ delay, value }: UseDebounceValueProps) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const debouncedTimeout = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(debouncedTimeout); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/basic/shared/hooks/useLocalStorageState.ts b/src/basic/shared/hooks/useLocalStorageState.ts new file mode 100644 index 00000000..43410b76 --- /dev/null +++ b/src/basic/shared/hooks/useLocalStorageState.ts @@ -0,0 +1,36 @@ +import { type Dispatch, type SetStateAction, useEffect, useState } from "react"; + +type UseLocalStorageStateProps = { + key: string; + initialState: S; +}; + +export function useLocalStorageState({ + key, + initialState +}: UseLocalStorageStateProps): [S, Dispatch>] { + const readLocalStorage = () => { + try { + const item = localStorage.getItem(key); + return item ? (JSON.parse(item) as S) : initialState; + } catch { + return initialState; + } + }; + + const [state, setState] = useState(readLocalStorage); + + useEffect(() => { + const isEmpty = state === undefined || state === null; + const isEmptyObject = typeof state === "object" && Object.keys(state || {}).length === 0; + const isEmptyArray = Array.isArray(state) && state.length === 0; + + if (isEmpty || isEmptyObject || isEmptyArray) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(state)); + } + }, [state, key]); + + return [state, setState]; +} diff --git a/src/basic/shared/hooks/useNotifications.ts b/src/basic/shared/hooks/useNotifications.ts new file mode 100644 index 00000000..9db2fba7 --- /dev/null +++ b/src/basic/shared/hooks/useNotifications.ts @@ -0,0 +1,26 @@ +import { useState } from "react"; + +import type { NotificationItem } from "../types"; + +export function useNotifications() { + const [notifications, setNotifications] = useState([]); + + const addNotification = (message: string, type: "error" | "success" | "warning" = "success") => { + const id = Date.now().toString(); + setNotifications((prev) => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, 3000); + }; + + const removeNotification = (id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }; + + return { + notifications, + addNotification, + removeNotification + }; +} diff --git a/src/basic/shared/hooks/useToggle.ts b/src/basic/shared/hooks/useToggle.ts new file mode 100644 index 00000000..509dd0b1 --- /dev/null +++ b/src/basic/shared/hooks/useToggle.ts @@ -0,0 +1,11 @@ +import { useState } from "react"; + +export function useToggle(defaultValue?: boolean) { + const [value, setValue] = useState(!!defaultValue); + + const toggle = () => { + setValue((prev) => !prev); + }; + + return [value, toggle, setValue] as const; +} diff --git a/src/basic/shared/index.ts b/src/basic/shared/index.ts new file mode 100644 index 00000000..057ddce2 --- /dev/null +++ b/src/basic/shared/index.ts @@ -0,0 +1,4 @@ +export * from "./components"; +export * from "./hooks"; +export * from "./types"; +export * from "./utils"; diff --git a/src/basic/shared/types/index.ts b/src/basic/shared/types/index.ts new file mode 100644 index 00000000..a67dd4c1 --- /dev/null +++ b/src/basic/shared/types/index.ts @@ -0,0 +1,2 @@ +export type * from "./notification"; +export type * from "./validation"; diff --git a/src/basic/shared/types/notification.ts b/src/basic/shared/types/notification.ts new file mode 100644 index 00000000..4871b8ae --- /dev/null +++ b/src/basic/shared/types/notification.ts @@ -0,0 +1,10 @@ +export interface NotificationItem { + id: string; + message: string; + type: "error" | "success" | "warning"; +} + +export type NotificationFunction = ( + message: string, + type?: "error" | "success" | "warning" +) => void; diff --git a/src/basic/shared/types/validation.ts b/src/basic/shared/types/validation.ts new file mode 100644 index 00000000..a0ab9278 --- /dev/null +++ b/src/basic/shared/types/validation.ts @@ -0,0 +1,4 @@ +export type ValidationResult = { + valid: boolean; + message?: string; +}; diff --git a/src/basic/shared/utils/index.ts b/src/basic/shared/utils/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/basic/shared/utils/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/setupTests.ts b/src/setupTests.ts index 7b0828bf..d0de870d 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1 +1 @@ -import '@testing-library/jest-dom'; +import "@testing-library/jest-dom"; diff --git a/src/types.ts b/src/types.ts index 5489e296..aafe0aba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,6 @@ export interface CartItem { export interface Coupon { name: string; code: string; - discountType: 'amount' | 'percentage'; + discountType: "amount" | "percentage"; discountValue: number; } diff --git a/vite.config.ts b/vite.config.ts index e6c4016b..35318f70 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,16 +1,38 @@ -import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config'; -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react-swc'; +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "vite"; -export default mergeConfig( - defineConfig({ +export default defineConfig(({ mode }) => { + // CLI에서 --mode advanced 로 호출될 때 advanced 빌드 + if (mode === "advanced") { + const base = process.env.NODE_ENV === "production" ? "/front_6th_chapter2-2/" : ""; + + return { + base, + plugins: [react()], + build: { + rollupOptions: { + input: { + index: "index.advanced.html" + } + }, + outDir: "dist", + emptyOutDir: true + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: "./src/setupTests.ts" + } + }; + } + + // 기본 설정 + return { plugins: [react()], - }), - defineTestConfig({ test: { globals: true, - environment: 'jsdom', - setupFiles: './src/setupTests.ts' - }, - }) -) + environment: "jsdom", + setupFiles: "./src/setupTests.ts" + } + }; +});