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/.prettierignore b/.prettierignore new file mode 100644 index 00000000..1fb5ff9b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules/ +pnpm-lock.yaml +package-lock.json + +# Build output +dist/ +build/ + +# Logs +*.log + +# Coverage +coverage/ + +# Environment variables +.env +.env.local +.env.production + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Git +.git/ + +# ESLint config +eslint.config.js \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..d3560dea --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "printWidth": 120 +} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..a2461842 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist', '.eslintrc.cjs', 'eslint.config.js'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) \ No newline at end of file diff --git a/package.json b/package.json index 79034acb..40bd3266 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,19 @@ "test:advanced": "vitest src/advanced", "test:ui": "vitest --ui", "build": "tsc -b && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "lint:fix": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0 --fix", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json}\"", + "deploy:advanced": "pnpm run build:advanced && gh-pages -d dist", + "build:advanced": "tsc --noEmit --skipLibCheck && vite build --config vite.config.advanced.ts && cp dist/index.advanced.html dist/index.html" }, "dependencies": { + "jotai": "^2.13.0", "react": "^19.1.1", "react-dom": "^19.1.1" }, "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", @@ -32,8 +38,12 @@ "eslint": "^9.32.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", + "gh-pages": "^6.3.0", + "globals": "^16.3.0", "jsdom": "^26.1.0", + "prettier": "^3.6.2", "typescript": "^5.9.2", + "typescript-eslint": "^8.39.0", "vite": "^7.0.6", "vitest": "^3.2.4" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85..f35783c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ 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 @@ -15,6 +18,9 @@ importers: specifier: ^19.1.1 version: 19.1.1(react@19.1.1) devDependencies: + '@eslint/js': + specifier: ^9.32.0 + version: 9.32.0 '@testing-library/jest-dom': specifier: ^6.6.4 version: 6.6.4 @@ -51,12 +57,24 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.20 version: 0.4.20(eslint@9.32.0) + gh-pages: + specifier: ^6.3.0 + version: 6.3.0 + globals: + specifier: ^16.3.0 + version: 16.3.0 jsdom: specifier: ^26.1.0 version: 26.1.0 + prettier: + specifier: ^3.6.2 + version: 3.6.2 typescript: specifier: ^5.9.2 version: 5.9.2 + typescript-eslint: + specifier: ^8.39.0 + version: 8.39.0(eslint@9.32.0)(typescript@5.9.2) vite: specifier: ^7.0.6 version: 7.0.6 @@ -599,6 +617,14 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/eslint-plugin@8.39.0': + resolution: {integrity: sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.39.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/parser@8.38.0': resolution: {integrity: sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -606,22 +632,45 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/parser@8.39.0': + resolution: {integrity: sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/project-service@8.38.0': resolution: {integrity: sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/project-service@8.39.0': + resolution: {integrity: sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/scope-manager@8.38.0': resolution: {integrity: sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.39.0': + resolution: {integrity: sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.38.0': resolution: {integrity: sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/tsconfig-utils@8.39.0': + resolution: {integrity: sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/type-utils@8.38.0': resolution: {integrity: sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -629,16 +678,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/type-utils@8.39.0': + resolution: {integrity: sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/types@8.38.0': resolution: {integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.39.0': + resolution: {integrity: sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.38.0': resolution: {integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/typescript-estree@8.39.0': + resolution: {integrity: sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.38.0': resolution: {integrity: sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -646,10 +712,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/utils@8.39.0': + resolution: {integrity: sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/visitor-keys@8.38.0': resolution: {integrity: sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.39.0': + resolution: {integrity: sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitejs/plugin-react-swc@3.11.0': resolution: {integrity: sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==} peerDependencies: @@ -732,10 +809,17 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -786,6 +870,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -839,12 +930,19 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + email-addresses@5.0.0: + resolution: {integrity: sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -956,10 +1054,26 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + filename-reserved-regex@2.0.0: + resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} + engines: {node: '>=4'} + + filenamify@4.3.0: + resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==} + engines: {node: '>=8'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -974,11 +1088,20 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + fs-extra@11.3.1: + resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} + engines: {node: '>=14.14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + gh-pages@6.3.0: + resolution: {integrity: sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==} + engines: {node: '>=10'} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -991,6 +1114,17 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globals@16.3.0: + resolution: {integrity: sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==} + engines: {node: '>=18'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -1056,6 +1190,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==} @@ -1084,6 +1236,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1091,6 +1246,10 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1117,6 +1276,10 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1158,14 +1321,26 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1181,6 +1356,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1203,6 +1382,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1211,6 +1394,11 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + 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} @@ -1270,6 +1458,10 @@ packages: scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.6.3: resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} @@ -1290,6 +1482,10 @@ packages: resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} engines: {node: '>=18'} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1311,6 +1507,10 @@ packages: strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + strip-outer@1.0.1: + resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} + engines: {node: '>=0.10.0'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -1367,6 +1567,10 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + trim-repeated@1.0.0: + resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} + engines: {node: '>=0.10.0'} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -1377,11 +1581,22 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + typescript-eslint@8.39.0: + resolution: {integrity: sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} hasBin: true + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1911,6 +2126,23 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/eslint-plugin@8.39.0(@typescript-eslint/parser@8.39.0(eslint@9.32.0)(typescript@5.9.2))(eslint@9.32.0)(typescript@5.9.2)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 8.39.0(eslint@9.32.0)(typescript@5.9.2) + '@typescript-eslint/scope-manager': 8.39.0 + '@typescript-eslint/type-utils': 8.39.0(eslint@9.32.0)(typescript@5.9.2) + '@typescript-eslint/utils': 8.39.0(eslint@9.32.0)(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 8.39.0 + eslint: 9.32.0 + graphemer: 1.4.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.38.0(eslint@9.32.0)(typescript@5.9.2)': dependencies: '@typescript-eslint/scope-manager': 8.38.0 @@ -1923,6 +2155,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.39.0(eslint@9.32.0)(typescript@5.9.2)': + dependencies: + '@typescript-eslint/scope-manager': 8.39.0 + '@typescript-eslint/types': 8.39.0 + '@typescript-eslint/typescript-estree': 8.39.0(typescript@5.9.2) + '@typescript-eslint/visitor-keys': 8.39.0 + debug: 4.4.1 + eslint: 9.32.0 + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/project-service@8.38.0(typescript@5.9.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.9.2) @@ -1932,15 +2176,33 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.39.0(typescript@5.9.2)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.39.0(typescript@5.9.2) + '@typescript-eslint/types': 8.39.0 + debug: 4.4.1 + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.38.0': dependencies: '@typescript-eslint/types': 8.38.0 '@typescript-eslint/visitor-keys': 8.38.0 + '@typescript-eslint/scope-manager@8.39.0': + dependencies: + '@typescript-eslint/types': 8.39.0 + '@typescript-eslint/visitor-keys': 8.39.0 + '@typescript-eslint/tsconfig-utils@8.38.0(typescript@5.9.2)': dependencies: typescript: 5.9.2 + '@typescript-eslint/tsconfig-utils@8.39.0(typescript@5.9.2)': + dependencies: + typescript: 5.9.2 + '@typescript-eslint/type-utils@8.38.0(eslint@9.32.0)(typescript@5.9.2)': dependencies: '@typescript-eslint/types': 8.38.0 @@ -1953,8 +2215,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.39.0(eslint@9.32.0)(typescript@5.9.2)': + dependencies: + '@typescript-eslint/types': 8.39.0 + '@typescript-eslint/typescript-estree': 8.39.0(typescript@5.9.2) + '@typescript-eslint/utils': 8.39.0(eslint@9.32.0)(typescript@5.9.2) + debug: 4.4.1 + eslint: 9.32.0 + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.38.0': {} + '@typescript-eslint/types@8.39.0': {} + '@typescript-eslint/typescript-estree@8.38.0(typescript@5.9.2)': dependencies: '@typescript-eslint/project-service': 8.38.0(typescript@5.9.2) @@ -1971,6 +2247,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.39.0(typescript@5.9.2)': + dependencies: + '@typescript-eslint/project-service': 8.39.0(typescript@5.9.2) + '@typescript-eslint/tsconfig-utils': 8.39.0(typescript@5.9.2) + '@typescript-eslint/types': 8.39.0 + '@typescript-eslint/visitor-keys': 8.39.0 + debug: 4.4.1 + fast-glob: 3.3.2 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.6.3 + ts-api-utils: 2.1.0(typescript@5.9.2) + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.38.0(eslint@9.32.0)(typescript@5.9.2)': dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0) @@ -1982,11 +2274,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.39.0(eslint@9.32.0)(typescript@5.9.2)': + dependencies: + '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0) + '@typescript-eslint/scope-manager': 8.39.0 + '@typescript-eslint/types': 8.39.0 + '@typescript-eslint/typescript-estree': 8.39.0(typescript@5.9.2) + eslint: 9.32.0 + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.38.0': dependencies: '@typescript-eslint/types': 8.38.0 eslint-visitor-keys: 4.2.1 + '@typescript-eslint/visitor-keys@8.39.0': + dependencies: + '@typescript-eslint/types': 8.39.0 + eslint-visitor-keys: 4.2.1 + '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6)': dependencies: '@rolldown/pluginutils': 1.0.0-beta.27 @@ -2083,8 +2391,12 @@ snapshots: aria-query@5.3.2: {} + array-union@2.1.0: {} + assertion-error@2.0.1: {} + async@3.2.6: {} + balanced-match@1.0.2: {} brace-expansion@1.1.11: @@ -2137,6 +2449,10 @@ snapshots: color-name@1.1.4: {} + commander@13.1.0: {} + + commondir@1.0.1: {} + concat-map@0.0.1: {} cross-spawn@7.0.6: @@ -2175,10 +2491,16 @@ snapshots: dequal@2.0.3: {} + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} + email-addresses@5.0.0: {} + entities@4.5.0: {} es-module-lexer@1.7.0: {} @@ -2329,10 +2651,29 @@ snapshots: dependencies: flat-cache: 4.0.1 + filename-reserved-regex@2.0.0: {} + + filenamify@4.3.0: + dependencies: + filename-reserved-regex: 2.0.0 + strip-outer: 1.0.1 + trim-repeated: 1.0.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -2347,9 +2688,25 @@ snapshots: flatted@3.3.3: {} + fs-extra@11.3.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + fsevents@2.3.3: optional: true + gh-pages@6.3.0: + dependencies: + async: 3.2.6 + commander: 13.1.0 + email-addresses: 5.0.0 + filenamify: 4.3.0 + find-cache-dir: 3.3.2 + fs-extra: 11.3.1 + globby: 11.1.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2360,6 +2717,19 @@ snapshots: globals@14.0.0: {} + globals@16.3.0: {} + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + graceful-fs@4.2.11: {} + graphemer@1.4.0: {} has-flag@3.0.0: {} @@ -2413,6 +2783,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: {} @@ -2454,6 +2829,12 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -2463,6 +2844,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -2483,6 +2868,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + merge2@1.4.1: {} micromatch@4.0.8: @@ -2519,14 +2908,24 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -2539,6 +2938,8 @@ snapshots: path-key@3.1.1: {} + path-type@4.0.0: {} + pathe@2.0.3: {} pathval@2.0.0: {} @@ -2551,6 +2952,10 @@ snapshots: picomatch@4.0.3: {} + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -2559,6 +2964,8 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.6.2: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -2629,6 +3036,8 @@ snapshots: scheduler@0.26.0: {} + semver@6.3.1: {} + semver@7.6.3: {} shebang-command@2.0.0: @@ -2645,6 +3054,8 @@ snapshots: mrmime: 2.0.0 totalist: 3.0.1 + slash@3.0.0: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} @@ -2661,6 +3072,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + strip-outer@1.0.1: + dependencies: + escape-string-regexp: 1.0.5 + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -2706,6 +3121,10 @@ snapshots: dependencies: punycode: 2.3.1 + trim-repeated@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + ts-api-utils@2.1.0(typescript@5.9.2): dependencies: typescript: 5.9.2 @@ -2714,8 +3133,21 @@ snapshots: dependencies: prelude-ls: 1.2.1 + typescript-eslint@8.39.0(eslint@9.32.0)(typescript@5.9.2): + dependencies: + '@typescript-eslint/eslint-plugin': 8.39.0(@typescript-eslint/parser@8.39.0(eslint@9.32.0)(typescript@5.9.2))(eslint@9.32.0)(typescript@5.9.2) + '@typescript-eslint/parser': 8.39.0(eslint@9.32.0)(typescript@5.9.2) + '@typescript-eslint/typescript-estree': 8.39.0(typescript@5.9.2) + '@typescript-eslint/utils': 8.39.0(eslint@9.32.0)(typescript@5.9.2) + eslint: 9.32.0 + typescript: 5.9.2 + transitivePeerDependencies: + - supports-color + typescript@5.9.2: {} + universalify@2.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1..0e4ba171 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,226 +1,49 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +import { useState, useEffect } from 'react'; +import { useCart } from './hooks/useCart'; +import { useCoupons } from './hooks/useCoupons'; +import { useProducts } from './hooks/useProducts'; +import { useProductSearch } from './hooks/useProductSearch'; +import { SEARCH_DELAY } from './shared/constants/toast'; +import { ToastContainer } from './components/ui/UIToast'; +import Header from './components/Header'; +import { AdminPage } from './pages/AdminPage'; +import { CartPage } from './pages/CartPage'; const App = () => { + const { + cart, + selectedCoupon, + selectCoupon, + addToCart: addToCartHook, + removeFromCart, + updateQuantity: updateQuantityHook, + applyCoupon: applyCouponHook, + calculateTotal, + getRemainingStock, + clearCart, + } = useCart(); + + const { + products, + addProduct: addProductHook, + updateProduct: updateProductHook, + deleteProduct: deleteProductHook, + } = useProducts(); + + const { coupons, addCoupon: addCouponHook, deleteCoupon: deleteCouponHook } = useCoupons(); + + const { searchTerm, handleSearchTermChange, filteredProducts } = useProductSearch(products, SEARCH_DELAY); - 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]); @@ -237,888 +60,53 @@ const App = () => { } }, [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 +export default App; diff --git a/src/advanced/__tests__/origin.test.tsx b/src/advanced/__tests__/origin.test.tsx index 3f5c3d55..8e429656 100644 --- a/src/advanced/__tests__/origin.test.tsx +++ b/src/advanced/__tests__/origin.test.tsx @@ -1,9 +1,15 @@ // @ts-nocheck import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; import { vi } from 'vitest'; +import { Provider } from 'jotai'; import App from '../App'; import '../../setupTests'; +// 각 테스트마다 새로운 Jotai Provider로 격리 +const renderWithProvider = (component: React.ReactElement) => { + return render({component}); +}; + describe('쇼핑몰 앱 통합 테스트', () => { beforeEach(() => { // localStorage 초기화 @@ -19,61 +25,64 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('고객 쇼핑 플로우', () => { test('상품을 검색하고 장바구니에 추가할 수 있다', async () => { - render(); - + renderWithProvider(); + // 검색창에 "프리미엄" 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 디바운스 대기 - await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - }, { timeout: 600 }); - + await waitFor( + () => { + expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); + }, + { timeout: 600 }, + ); + // 검색된 상품을 장바구니에 추가 (첫 번째 버튼 선택) const addButtons = screen.getAllByText('장바구니 담기'); fireEvent.click(addButtons[0]); - + // 알림 메시지 확인 await waitFor(() => { expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); }); - + // 장바구니에 추가됨 확인 (장바구니 섹션에서) const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); }); test('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { - render(); - + renderWithProvider(); + // 상품1을 장바구니에 추가 const product1 = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(product1); - + // 수량을 10개로 증가 (10% 할인 적용) const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 9; i++) { fireEvent.click(plusButton); } - + // 10% 할인 적용 확인 - 15% (대량 구매 시 추가 5% 포함) expect(screen.getByText('-15%')).toBeInTheDocument(); }); test('쿠폰을 선택하고 적용할 수 있다', () => { - render(); - + renderWithProvider(); + // 상품 추가 const addButton = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(addButton); - + // 쿠폰 선택 const couponSelect = screen.getByRole('combobox'); fireEvent.change(couponSelect, { target: { value: 'AMOUNT5000' } }); - + // 결제 정보에서 할인 금액 확인 const paymentSection = screen.getByText('결제 정보').closest('section'); const discountRow = within(paymentSection).getByText('할인 금액').closest('div'); @@ -81,29 +90,29 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('품절 임박 상품에 경고가 표시된다', async () => { - render(); - + renderWithProvider(); + // 관리자 모드로 전환 fireEvent.click(screen.getByText('관리자 페이지로')); - + // 상품 수정 const editButton = screen.getAllByText('수정')[0]; fireEvent.click(editButton); - + // 재고를 5개로 변경 const stockInputs = screen.getAllByPlaceholderText('숫자만 입력'); const stockInput = stockInputs[1]; // 재고 입력 필드는 두 번째 fireEvent.change(stockInput, { target: { value: '5' } }); fireEvent.blur(stockInput); - + // 수정 완료 버튼 클릭 const editButtons = screen.getAllByText('수정'); const completeEditButton = editButtons[editButtons.length - 1]; // 마지막 수정 버튼 (완료 버튼) fireEvent.click(completeEditButton); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 품절임박 메시지 확인 - 재고가 5개 이하면 품절임박 표시 await waitFor(() => { expect(screen.getByText('품절임박! 5개 남음')).toBeInTheDocument(); @@ -111,66 +120,66 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('주문을 완료할 수 있다', () => { - render(); - + renderWithProvider(); + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 결제하기 버튼 클릭 const orderButton = screen.getByText(/원 결제하기/); fireEvent.click(orderButton); - + // 주문 완료 알림 확인 expect(screen.getByText(/주문이 완료되었습니다/)).toBeInTheDocument(); - + // 장바구니가 비어있는지 확인 expect(screen.getByText('장바구니가 비어있습니다')).toBeInTheDocument(); }); test('장바구니에서 상품을 삭제할 수 있다', () => { - render(); - + renderWithProvider(); + // 상품 2개 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 장바구니 섹션 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); - + // 첫 번째 상품 삭제 (X 버튼) - const deleteButtons = within(cartSection).getAllByRole('button').filter( - button => button.querySelector('svg') - ); + const deleteButtons = within(cartSection) + .getAllByRole('button') + .filter((button) => button.querySelector('svg')); fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되고 상품2만 남음 expect(within(cartSection).queryByText('상품1')).not.toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); }); test('재고를 초과하여 구매할 수 없다', async () => { - render(); - + renderWithProvider(); + // 상품1 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 재고(20개) 이상으로 증가 시도 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + // 19번 클릭하여 총 20개로 만듦 for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 한 번 더 클릭 시도 (21개가 되려고 함) fireEvent.click(plusButton); - + // 수량이 20개에서 멈춰있어야 함 expect(within(cartSection).getByText('20')).toBeInTheDocument(); - + // 재고 부족 메시지 확인 await waitFor(() => { expect(screen.getByText(/재고는.*개까지만 있습니다/)).toBeInTheDocument(); @@ -178,28 +187,28 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('장바구니에서 수량을 감소시킬 수 있다', () => { - render(); - + renderWithProvider(); + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); const minusButton = within(cartSection).getByText('−'); // U+2212 마이너스 기호 - + // 수량 3개로 증가 fireEvent.click(plusButton); fireEvent.click(plusButton); expect(within(cartSection).getByText('3')).toBeInTheDocument(); - + // 수량 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('2')).toBeInTheDocument(); - + // 1개로 더 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('1')).toBeInTheDocument(); - + // 1개에서 한 번 더 감소하면 장바구니에서 제거될 수도 있음 fireEvent.click(minusButton); // 장바구니가 비었는지 확인 @@ -213,32 +222,32 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('20개 이상 구매 시 최대 할인이 적용된다', async () => { - render(); - + renderWithProvider(); + // 관리자 모드로 전환하여 상품1의 재고를 늘림 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getAllByText('수정')[0]); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '30' } }); - + const editButtons = screen.getAllByText('수정'); fireEvent.click(editButtons[editButtons.length - 1]); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 상품1을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 20개로 증가 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 25% 할인 적용 확인 (또는 대량 구매 시 30%) await waitFor(() => { const discount25 = screen.queryByText('-25%'); @@ -250,7 +259,7 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('관리자 기능', () => { beforeEach(() => { - render(); + renderWithProvider(); // 관리자 모드로 전환 fireEvent.click(screen.getByText('관리자 페이지로')); }); @@ -258,27 +267,27 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('새 상품을 추가할 수 있다', () => { // 새 상품 추가 버튼 클릭 fireEvent.click(screen.getByText('새 상품 추가')); - + // 폼 입력 - 상품명 입력 const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const nameLabel = labels.find((el) => el.tagName === 'LABEL'); const nameInput = nameLabel.closest('div').querySelector('input'); fireEvent.change(nameInput, { target: { value: '테스트 상품' } }); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; fireEvent.change(priceInput, { target: { value: '25000' } }); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '50' } }); - + const descLabels = screen.getAllByText('설명'); - const descLabel = descLabels.find(el => el.tagName === 'LABEL'); + const descLabel = descLabels.find((el) => el.tagName === 'LABEL'); const descInput = descLabel.closest('div').querySelector('input'); fireEvent.change(descInput, { target: { value: '테스트 설명' } }); - + // 저장 fireEvent.click(screen.getByText('추가')); - + // 추가된 상품 확인 expect(screen.getByText('테스트 상품')).toBeInTheDocument(); expect(screen.getByText('25,000원')).toBeInTheDocument(); @@ -287,21 +296,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 버튼 클릭 const addCouponButton = screen.getByText('새 쿠폰 추가'); fireEvent.click(addCouponButton); - + // 쿠폰 정보 입력 - fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { target: { value: '테스트 쿠폰' } }); - fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { target: { value: 'TEST2024' } }); - + fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { + target: { value: '테스트 쿠폰' }, + }); + fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { + target: { value: 'TEST2024' }, + }); + const discountInput = screen.getByPlaceholderText('5000'); fireEvent.change(discountInput, { target: { value: '7000' } }); - + // 쿠폰 생성 fireEvent.click(screen.getByText('쿠폰 생성')); - + // 생성된 쿠폰 확인 expect(screen.getByText('테스트 쿠폰')).toBeInTheDocument(); expect(screen.getByText('TEST2024')).toBeInTheDocument(); @@ -311,25 +324,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('상품의 가격 입력 시 숫자만 허용된다', async () => { // 상품 수정 fireEvent.click(screen.getAllByText('수정')[0]); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - + // 문자와 숫자 혼합 입력 시도 - 숫자만 남음 fireEvent.change(priceInput, { target: { value: 'abc123def' } }); expect(priceInput.value).toBe('10000'); // 유효하지 않은 입력은 무시됨 - + // 숫자만 입력 fireEvent.change(priceInput, { target: { value: '123' } }); expect(priceInput.value).toBe('123'); - + // 음수 입력 시도 - regex가 매치되지 않아 값이 변경되지 않음 fireEvent.change(priceInput, { target: { value: '-100' } }); expect(priceInput.value).toBe('123'); // 이전 값 유지 - + // 유효한 음수 입력하기 위해 먼저 1 입력 후 앞에 - 추가는 불가능 // 대신 blur 이벤트를 통해 음수 검증을 테스트 // parseInt()는 실제로 음수를 파싱할 수 있으므로 다른 방법으로 테스트 - + // 공백 입력 시도 fireEvent.change(priceInput, { target: { value: ' ' } }); expect(priceInput.value).toBe('123'); // 유효하지 않은 입력은 무시됨 @@ -338,20 +351,20 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 할인율 검증이 작동한다', async () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 fireEvent.click(screen.getByText('새 쿠폰 추가')); - + // 퍼센트 타입으로 변경 - 쿠폰 폼 내의 select 찾기 const couponFormSelects = screen.getAllByRole('combobox'); const typeSelect = couponFormSelects[couponFormSelects.length - 1]; // 마지막 select가 타입 선택 fireEvent.change(typeSelect, { target: { value: 'percentage' } }); - + // 100% 초과 할인율 입력 const discountInput = screen.getByPlaceholderText('10'); fireEvent.change(discountInput, { target: { value: '150' } }); fireEvent.blur(discountInput); - + // 에러 메시지 확인 await waitFor(() => { expect(screen.getByText('할인율은 100%를 초과할 수 없습니다')).toBeInTheDocument(); @@ -362,15 +375,15 @@ describe('쇼핑몰 앱 통합 테스트', () => { // 초기 상품명들 확인 (테이블에서) const productTable = screen.getByRole('table'); expect(within(productTable).getByText('상품1')).toBeInTheDocument(); - + // 삭제 버튼들 찾기 - const deleteButtons = within(productTable).getAllByRole('button').filter( - button => button.textContent === '삭제' - ); - + const deleteButtons = within(productTable) + .getAllByRole('button') + .filter((button) => button.textContent === '삭제'); + // 첫 번째 상품 삭제 fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되었는지 확인 expect(within(productTable).queryByText('상품1')).not.toBeInTheDocument(); expect(within(productTable).getByText('상품2')).toBeInTheDocument(); @@ -379,76 +392,74 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰을 삭제할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 초기 쿠폰들 확인 (h3 제목에서) const couponTitles = screen.getAllByRole('heading', { level: 3 }); - const coupon5000 = couponTitles.find(el => el.textContent === '5000원 할인'); - const coupon10 = couponTitles.find(el => el.textContent === '10% 할인'); + const coupon5000 = couponTitles.find((el) => el.textContent === '5000원 할인'); + const coupon10 = couponTitles.find((el) => el.textContent === '10% 할인'); expect(coupon5000).toBeInTheDocument(); expect(coupon10).toBeInTheDocument(); - + // 삭제 버튼 찾기 (SVG 아이콘을 포함한 버튼) - const deleteButtons = screen.getAllByRole('button').filter(button => { - return button.querySelector('svg') && - button.querySelector('path[d*="M19 7l"]'); // 삭제 아이콘 path + const deleteButtons = screen.getAllByRole('button').filter((button) => { + return button.querySelector('svg') && button.querySelector('path[d*="M19 7l"]'); // 삭제 아이콘 path }); - + // 첫 번째 쿠폰 삭제 fireEvent.click(deleteButtons[0]); - + // 쿠폰이 삭제되었는지 확인 expect(screen.queryByText('5000원 할인')).not.toBeInTheDocument(); }); - }); describe('로컬스토리지 동기화', () => { test('상품, 장바구니, 쿠폰이 localStorage에 저장된다', () => { - render(); - + renderWithProvider(); + // 상품을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // localStorage 확인 expect(localStorage.getItem('cart')).toBeTruthy(); expect(JSON.parse(localStorage.getItem('cart'))).toHaveLength(1); - + // 관리자 모드로 전환하여 새 상품 추가 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getByText('새 상품 추가')); - + const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const nameLabel = labels.find((el) => el.tagName === 'LABEL'); const nameInput = nameLabel.closest('div').querySelector('input'); fireEvent.change(nameInput, { target: { value: '저장 테스트' } }); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; fireEvent.change(priceInput, { target: { value: '10000' } }); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '10' } }); - + fireEvent.click(screen.getByText('추가')); - + // localStorage에 products가 저장되었는지 확인 expect(localStorage.getItem('products')).toBeTruthy(); const products = JSON.parse(localStorage.getItem('products')); - expect(products.some(p => p.name === '저장 테스트')).toBe(true); + expect(products.some((p) => p.name === '저장 테스트')).toBe(true); }); test('페이지 새로고침 후에도 데이터가 유지된다', () => { - const { unmount } = render(); - + const { unmount } = renderWithProvider(); + // 장바구니에 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 컴포넌트 unmount unmount(); - + // 다시 mount - render(); - + renderWithProvider(); + // 장바구니 아이템이 유지되는지 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -458,14 +469,14 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('UI 상태 관리', () => { test('할인이 있을 때 할인율이 표시된다', async () => { - render(); - + renderWithProvider(); + // 상품을 10개 담아서 할인 발생 const addButton = screen.getAllByText('장바구니 담기')[0]; for (let i = 0; i < 10; i++) { fireEvent.click(addButton); } - + // 할인율 표시 확인 - 대량 구매로 15% 할인 await waitFor(() => { expect(screen.getByText('-15%')).toBeInTheDocument(); @@ -473,35 +484,35 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('장바구니 아이템 개수가 헤더에 표시된다', () => { - render(); - + renderWithProvider(); + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 헤더의 장바구니 아이콘 옆 숫자 확인 const cartCount = screen.getByText('3'); expect(cartCount).toBeInTheDocument(); }); test('검색을 초기화할 수 있다', async () => { - render(); - + renderWithProvider(); + // 검색어 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 검색 결과 확인 await waitFor(() => { expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); // 다른 상품들은 보이지 않음 expect(screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.')).not.toBeInTheDocument(); }); - + // 검색어 초기화 fireEvent.change(searchInput, { target: { value: '' } }); - + // 모든 상품이 다시 표시됨 await waitFor(() => { expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); @@ -511,18 +522,21 @@ describe('쇼핑몰 앱 통합 테스트', () => { }); test('알림 메시지가 자동으로 사라진다', async () => { - render(); - + renderWithProvider(); + // 상품 추가하여 알림 발생 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 알림 메시지 확인 expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); - + // 3초 후 알림이 사라짐 - await waitFor(() => { - expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); - }, { timeout: 4000 }); + await waitFor( + () => { + expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); + }, + { timeout: 4000 }, + ); }); }); -}); \ No newline at end of file +}); diff --git a/src/advanced/atoms/couponFormAtoms.ts b/src/advanced/atoms/couponFormAtoms.ts new file mode 100644 index 00000000..b1329b2d --- /dev/null +++ b/src/advanced/atoms/couponFormAtoms.ts @@ -0,0 +1,110 @@ +import { atom } from 'jotai'; +import type { Coupon as CouponType, CouponFormData as CouponFormDataType } from '../../types'; +import { DEFAULT_COUPON_FORM } from '../constants/couponForm'; +import { + normalizeCode, + updateFormDiscountType, + parseDiscountValue, + validateDiscountValue as validateDiscountValuePure, + formToCoupon, + resetCouponForm, +} from '../models/couponForm'; + +/** + * 쿠폰 폼 데이터 atom + */ +export const couponFormAtom = atom(DEFAULT_COUPON_FORM); + +/** + * 쿠폰 폼 표시 여부 atom + */ +export const showCouponFormAtom = atom(false); + +// === 파생 atoms (derived atoms) === + +/** + * 쿠폰명 업데이트 액션 atom + */ +export const updateCouponNameAtom = atom(null, (get, set, name: string) => { + const currentForm = get(couponFormAtom); + set(couponFormAtom, { ...currentForm, name }); +}); + +/** + * 쿠폰 코드 업데이트 액션 atom + */ +export const updateCouponCodeAtom = atom(null, (get, set, code: string) => { + const currentForm = get(couponFormAtom); + set(couponFormAtom, { ...currentForm, code: normalizeCode(code) }); +}); + +/** + * 할인 타입 업데이트 액션 atom + */ +export const updateCouponDiscountTypeAtom = atom(null, (get, set, discountType: 'amount' | 'percentage') => { + const currentForm = get(couponFormAtom); + set(couponFormAtom, updateFormDiscountType(currentForm, discountType)); +}); + +/** + * 할인 값 업데이트 액션 atom + */ +export const updateCouponDiscountValueAtom = atom(null, (get, set, value: string) => { + const parsedValue = parseDiscountValue(value); + if (parsedValue !== null) { + const currentForm = get(couponFormAtom); + set(couponFormAtom, { ...currentForm, discountValue: parsedValue }); + } +}); + +/** + * 할인 값 유효성 검증 액션 atom + */ +export const validateCouponDiscountValueAtom = atom( + null, + (get, set, value: string, onError: (message: string) => void) => { + const currentForm = get(couponFormAtom); + const validation = validateDiscountValuePure(Number(value), currentForm.discountType); + + if (!validation.isValid) { + onError(validation.message); + } + + set(couponFormAtom, { ...currentForm, discountValue: validation.correctedValue }); + }, +); + +/** + * 쿠폰 폼 토글 액션 atom + */ +export const toggleCouponFormAtom = atom(null, (get, set) => { + const currentShow = get(showCouponFormAtom); + set(showCouponFormAtom, !currentShow); + + // 폼을 닫을 때 리셋 + if (currentShow) { + set(couponFormAtom, resetCouponForm()); + } +}); + +/** + * 쿠폰 폼 리셋 액션 atom + */ +export const resetCouponFormAtom = atom(null, (get, set) => { + set(couponFormAtom, resetCouponForm()); + set(showCouponFormAtom, false); +}); + +/** + * 쿠폰 폼 제출 액션 atom + */ +export const submitCouponFormAtom = atom(null, (get, set, onSubmit: (coupon: CouponType) => void) => { + const currentForm = get(couponFormAtom); + const couponData = formToCoupon(currentForm); + + onSubmit(couponData); + + // 폼 리셋 + set(couponFormAtom, resetCouponForm()); + set(showCouponFormAtom, false); +}); diff --git a/src/advanced/atoms/notificationAtoms.ts b/src/advanced/atoms/notificationAtoms.ts new file mode 100644 index 00000000..3751714d --- /dev/null +++ b/src/advanced/atoms/notificationAtoms.ts @@ -0,0 +1,32 @@ +import { atom } from 'jotai'; +import type { Notification } from '../shared/types'; +import { NOTIFICATION_DURATION } from '../shared/constants/toast'; + +/** + * 알림 목록을 관리하는 atom + */ +export const notificationsAtom = atom([]); + +/** + * 알림 추가 액션 atom + */ +export const addNotificationAtom = atom( + null, + (get, set, { message, type = 'success' }: { message: string; type?: 'error' | 'success' | 'warning' }) => { + const id = Date.now().toString(); + const newNotification: Notification = { id, message, type }; + + set(notificationsAtom, (prev) => [...prev, newNotification]); + + setTimeout(() => { + set(notificationsAtom, (prev) => prev.filter((n) => n.id !== id)); + }, NOTIFICATION_DURATION); + }, +); + +/** + * 알림 제거 액션 atom + */ +export const removeNotificationAtom = atom(null, (get, set, notificationId: string) => { + set(notificationsAtom, (prev) => prev.filter((n) => n.id !== notificationId)); +}); diff --git a/src/advanced/atoms/productFormAtoms.ts b/src/advanced/atoms/productFormAtoms.ts new file mode 100644 index 00000000..71254504 --- /dev/null +++ b/src/advanced/atoms/productFormAtoms.ts @@ -0,0 +1,191 @@ +import { atom } from 'jotai'; +import type { ProductFormData as ProductFormDataType } from '../../types'; +import type { ProductWithUI } from '../shared/types'; +import { DEFAULT_PRODUCT_FORM } from '../constants/productForm'; +import { + parseNumericValue, + validatePrice, + validateStock, + addDiscount, + removeDiscount, + updateDiscount, + formToProduct, + productToForm, + resetProductForm, +} from '../models/productForm'; + +/** + * 상품 폼 데이터 atom + */ +export const productFormAtom = atom(DEFAULT_PRODUCT_FORM); + +/** + * 편집 중인 상품 ID atom (null이면 새 상품, 'new'는 추가 모드) + */ +export const editingProductAtom = atom(null); + +/** + * 상품 폼 표시 여부 atom + */ +export const showProductFormAtom = atom(false); + +// === 파생 atoms (derived atoms) === + +/** + * 상품명 업데이트 액션 atom + */ +export const updateProductNameAtom = atom(null, (get, set, name: string) => { + const currentForm = get(productFormAtom); + set(productFormAtom, { ...currentForm, name }); +}); + +/** + * 상품 설명 업데이트 액션 atom + */ +export const updateProductDescriptionAtom = atom(null, (get, set, description: string) => { + const currentForm = get(productFormAtom); + set(productFormAtom, { ...currentForm, description }); +}); + +/** + * 상품 가격 업데이트 액션 atom + */ +export const updateProductPriceAtom = atom(null, (get, set, value: string) => { + const parsedValue = parseNumericValue(value); + if (parsedValue !== null) { + const currentForm = get(productFormAtom); + set(productFormAtom, { ...currentForm, price: parsedValue }); + } +}); + +/** + * 상품 재고 업데이트 액션 atom + */ +export const updateProductStockAtom = atom(null, (get, set, value: string) => { + const parsedValue = parseNumericValue(value); + if (parsedValue !== null) { + const currentForm = get(productFormAtom); + set(productFormAtom, { ...currentForm, stock: parsedValue }); + } +}); + +/** + * 가격 유효성 검증 액션 atom + */ +export const validatePriceAtom = atom(null, (get, set, value: string, callback: (message: string) => void) => { + const parsedValue = parseNumericValue(value); + if (parsedValue !== null) { + const result = validatePrice(parsedValue); + if (!result.isValid) { + callback(result.message); + } + if (result.correctedValue !== parsedValue) { + const currentForm = get(productFormAtom); + set(productFormAtom, { ...currentForm, price: result.correctedValue }); + } + } +}); + +/** + * 재고 유효성 검증 액션 atom + */ +export const validateStockAtom = atom(null, (get, set, value: string, callback: (message: string) => void) => { + const parsedValue = parseNumericValue(value); + if (parsedValue !== null) { + const result = validateStock(parsedValue); + if (!result.isValid) { + callback(result.message); + } + if (result.correctedValue !== parsedValue) { + const currentForm = get(productFormAtom); + set(productFormAtom, { ...currentForm, stock: result.correctedValue }); + } + } +}); + +/** + * 할인 정책 추가 액션 atom + */ +export const addDiscountPolicyAtom = atom(null, (get, set) => { + const currentForm = get(productFormAtom); + const updatedDiscounts = addDiscount(currentForm.discounts); + set(productFormAtom, { ...currentForm, discounts: updatedDiscounts }); +}); + +/** + * 할인 정책 제거 액션 atom + */ +export const removeDiscountPolicyAtom = atom(null, (get, set, index: number) => { + const currentForm = get(productFormAtom); + const updatedDiscounts = removeDiscount(currentForm.discounts, index); + set(productFormAtom, { ...currentForm, discounts: updatedDiscounts }); +}); + +/** + * 할인 수량 업데이트 액션 atom + */ +export const updateDiscountQuantityAtom = atom(null, (get, set, index: number, quantity: number) => { + const currentForm = get(productFormAtom); + const updatedDiscounts = updateDiscount(currentForm.discounts, index, { quantity }); + set(productFormAtom, { ...currentForm, discounts: updatedDiscounts }); +}); + +/** + * 할인 비율 업데이트 액션 atom + */ +export const updateDiscountRateAtom = atom(null, (get, set, index: number, rate: number) => { + const currentForm = get(productFormAtom); + const updatedDiscounts = updateDiscount(currentForm.discounts, index, { rate }); + set(productFormAtom, { ...currentForm, discounts: updatedDiscounts }); +}); + +/** + * 상품 편집 시작 액션 atom + */ +export const startEditProductAtom = atom(null, (get, set, product: ProductWithUI) => { + set(editingProductAtom, product.id); + set(productFormAtom, productToForm(product)); + set(showProductFormAtom, true); +}); + +/** + * 새 상품 추가 시작 액션 atom + */ +export const startAddProductAtom = atom(null, (get, set) => { + set(editingProductAtom, 'new'); + set(productFormAtom, resetProductForm()); + set(showProductFormAtom, true); +}); + +/** + * 상품 폼 리셋 액션 atom + */ +export const resetProductFormAtom = atom(null, (get, set) => { + set(productFormAtom, resetProductForm()); + set(editingProductAtom, null); + set(showProductFormAtom, false); +}); + +/** + * 상품 폼 제출 액션 atom + */ +export const submitProductFormAtom = atom( + null, + (get, set, onSubmit: (product: Omit, productId?: string) => void) => { + const currentForm = get(productFormAtom); + const editingProduct = get(editingProductAtom); + + const productData = formToProduct(currentForm); + + if (editingProduct && editingProduct !== 'new') { + onSubmit(productData, editingProduct); + } else { + onSubmit(productData); + } + + // 폼 리셋 + set(productFormAtom, resetProductForm()); + set(editingProductAtom, null); + set(showProductFormAtom, false); + }, +); diff --git a/src/advanced/components/Header.tsx b/src/advanced/components/Header.tsx new file mode 100644 index 00000000..fb8ee271 --- /dev/null +++ b/src/advanced/components/Header.tsx @@ -0,0 +1,49 @@ +import type { CartItem as CartItemType } from '../../types'; +import { SearchBar } from './cart/SearchBar'; +import { CartIcon } from './icons'; + +interface HeaderProps { + isAdmin: boolean; + searchTerm: string; + handleSearchTermChange: (e: React.ChangeEvent) => void; + cart: CartItemType[]; + totalItemCount: number; + setIsAdmin: (value: boolean) => void; +} + +const Header = ({ isAdmin, searchTerm, handleSearchTermChange, cart, totalItemCount, setIsAdmin }: HeaderProps) => { + return ( +
+
+
+
+

SHOP

+ {!isAdmin && } +
+ +
+
+
+ ); +}; + +export default Header; diff --git a/src/advanced/components/admin/CouponForm.tsx b/src/advanced/components/admin/CouponForm.tsx new file mode 100644 index 00000000..9c335ee5 --- /dev/null +++ b/src/advanced/components/admin/CouponForm.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { useAtom } from 'jotai'; +import type { Coupon as CouponType } from '../../../types'; +import { useNotificationActions } from '../../shared/hooks'; +import { + couponFormAtom, + updateCouponNameAtom, + updateCouponCodeAtom, + updateCouponDiscountTypeAtom, + updateCouponDiscountValueAtom, + validateCouponDiscountValueAtom, + submitCouponFormAtom, + resetCouponFormAtom, +} from '../../atoms/couponFormAtoms'; + +interface CouponFormProps { + onSubmit: (coupon: CouponType) => void; +} + +export function CouponForm({ onSubmit }: CouponFormProps) { + const { addNotification } = useNotificationActions(); + + // Atoms에서 상태와 액션들 가져오기 + const [couponForm] = useAtom(couponFormAtom); + const [, updateName] = useAtom(updateCouponNameAtom); + const [, updateCode] = useAtom(updateCouponCodeAtom); + const [, updateDiscountType] = useAtom(updateCouponDiscountTypeAtom); + const [, updateDiscountValue] = useAtom(updateCouponDiscountValueAtom); + const [, validateDiscountValue] = useAtom(validateCouponDiscountValueAtom); + const [, submitForm] = useAtom(submitCouponFormAtom); + const [, resetForm] = useAtom(resetCouponFormAtom); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + submitForm(onSubmit); + }; + + const handleCancel = () => { + resetForm(); + }; + + return ( +
+
+

새 쿠폰 생성

+
+
+ + updateName(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 + /> +
+
+ + updateCode(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 font-mono" + placeholder="WELCOME2024" + required + /> +
+
+ + +
+
+ + updateDiscountValue(e.target.value)} + onBlur={(e) => + validateDiscountValue(e.target.value, (message) => addNotification({ message, type: 'error' })) + } + 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 + /> +
+
+
+ + +
+
+
+ ); +} diff --git a/src/advanced/components/admin/CouponList.tsx b/src/advanced/components/admin/CouponList.tsx new file mode 100644 index 00000000..3b97e14b --- /dev/null +++ b/src/advanced/components/admin/CouponList.tsx @@ -0,0 +1,55 @@ +import { formatKoreanPrice } from '../../shared/utils/formatters'; +import { formatPercentage } from '../../shared/utils/formatters'; +import type { Coupon as CouponType } from '../../../types'; +import { TrashIcon, PlusIcon } from '../icons'; + +interface CouponListProps { + coupons: CouponType[]; + deleteCoupon: (code: string) => void; + toggleCouponForm: () => void; +} + +function CouponList({ coupons, deleteCoupon, toggleCouponForm }: CouponListProps) { + return ( +
+ {coupons.map((coupon) => ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === 'amount' + ? `${formatKoreanPrice(coupon.discountValue)} 할인` + : `${formatPercentage(coupon.discountValue / 100)} 할인`} + +
+
+ +
+
+ ))} + +
+ +
+
+ ); +} + +export default CouponList; diff --git a/src/advanced/components/admin/ProductForm.tsx b/src/advanced/components/admin/ProductForm.tsx new file mode 100644 index 00000000..ac20cdb5 --- /dev/null +++ b/src/advanced/components/admin/ProductForm.tsx @@ -0,0 +1,185 @@ +import React from 'react'; +import { useAtom } from 'jotai'; +import { useNotificationActions } from '../../shared/hooks'; +import { CloseIcon } from '../icons'; +import { + productFormAtom, + editingProductAtom, + updateProductNameAtom, + updateProductDescriptionAtom, + updateProductPriceAtom, + updateProductStockAtom, + validatePriceAtom, + validateStockAtom, + addDiscountPolicyAtom, + removeDiscountPolicyAtom, + updateDiscountQuantityAtom, + updateDiscountRateAtom, + resetProductFormAtom, +} from '../../atoms/productFormAtoms'; +import type { ProductWithUI } from '../../shared/types'; +import { formToProduct } from '../../models/productForm'; + +interface ProductFormProps { + onSubmit: (product: Omit, productId?: string) => void; +} + +export function ProductForm({ onSubmit }: ProductFormProps) { + const { addNotification } = useNotificationActions(); + + // Atoms에서 상태와 액션들 가져오기 + const [productForm] = useAtom(productFormAtom); + const [editingProduct] = useAtom(editingProductAtom); + const [, updateProductName] = useAtom(updateProductNameAtom); + const [, updateDescription] = useAtom(updateProductDescriptionAtom); + const [, updatePrice] = useAtom(updateProductPriceAtom); + const [, updateStock] = useAtom(updateProductStockAtom); + const [, validatePriceValue] = useAtom(validatePriceAtom); + const [, validateStockValue] = useAtom(validateStockAtom); + const [, addDiscountPolicy] = useAtom(addDiscountPolicyAtom); + const [, removeDiscountPolicy] = useAtom(removeDiscountPolicyAtom); + const [, updateDiscountQuantity] = useAtom(updateDiscountQuantityAtom); + const [, updateDiscountRate] = useAtom(updateDiscountRateAtom); + const [, resetForm] = useAtom(resetProductFormAtom); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const productData = formToProduct(productForm); + + if (editingProduct && editingProduct !== 'new') { + onSubmit(productData, editingProduct); + } else { + onSubmit(productData); + } + + resetForm(); + }; + + const handleCancel = () => { + resetForm(); + }; + + return ( +
+
+

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

+ +
+ {/* 상품명 입력 */} +
+ + updateProductName(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 + /> +
+ + {/* 설명 입력 */} +
+ + updateDescription(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" + /> +
+ + {/* 가격 입력 */} +
+ + updatePrice(e.target.value)} + onBlur={(e) => + validatePriceValue(e.target.value, (message) => addNotification({ message, type: 'error' })) + } + 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 + /> +
+ + {/* 재고 입력 */} +
+ + updateStock(e.target.value)} + onBlur={(e) => + validateStockValue(e.target.value, (message) => addNotification({ message, type: 'error' })) + } + 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) => ( +
+ updateDiscountQuantity(index, parseInt(e.target.value) || 0)} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + updateDiscountRate(index, (parseInt(e.target.value) || 0) / 100)} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ + {/* 폼 버튼들 */} +
+ + +
+
+
+ ); +} + +export default ProductForm; diff --git a/src/advanced/components/admin/ProductList.tsx b/src/advanced/components/admin/ProductList.tsx new file mode 100644 index 00000000..867e4058 --- /dev/null +++ b/src/advanced/components/admin/ProductList.tsx @@ -0,0 +1,60 @@ +import type { ProductWithUI } from '../../shared/types'; +import { formatKoreanPrice } from '../../shared/utils'; + +interface ProductListProps { + products: ProductWithUI[]; + activeTab: 'products' | 'coupons'; + handleStartEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; +} + +export function ProductList({ products, activeTab, handleStartEditProduct, deleteProduct }: ProductListProps) { + return ( +
+ + + + + + + + + + + + {(activeTab === 'products' ? products : products).map((product) => ( + + + + + + + + ))} + +
상품명가격재고설명작업
{product.name}{formatKoreanPrice(product.price)} + 10 + ? 'bg-green-100 text-green-800' + : product.stock > 0 + ? 'bg-yellow-100 text-yellow-800' + : 'bg-red-100 text-red-800' + }`} + > + {product.stock}개 + + {product.description || '-'} + + +
+
+ ); +} diff --git a/src/advanced/components/cart/Cart.tsx b/src/advanced/components/cart/Cart.tsx new file mode 100644 index 00000000..a6bd4347 --- /dev/null +++ b/src/advanced/components/cart/Cart.tsx @@ -0,0 +1,71 @@ +import type { ProductWithUI } from '../../shared/types'; +import { formatKoreanPrice, formatPercentage } from '../../shared/utils'; +import { CloseIcon, SmallBagIcon, LargeBagIcon } from '../icons'; + +interface CartProps { + cart: Array<{ product: ProductWithUI; quantity: number }>; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + calculateTotal: () => { totalBeforeDiscount: number; totalAfterDiscount: number }; +} + +export function Cart({ cart, removeFromCart, updateQuantity, calculateTotal }: CartProps) { + return ( +
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => { + const itemTotal = calculateTotal().totalAfterDiscount; + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount ? formatPercentage(1 - itemTotal / originalPrice) : 0; + + return ( +
+
+

{item.product.name}

+ +
+
+
+ + {item.quantity} + +
+
+ {hasDiscount && -{discountRate}} +

{formatKoreanPrice(itemTotal)}

+
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/advanced/components/cart/Coupon.tsx b/src/advanced/components/cart/Coupon.tsx new file mode 100644 index 00000000..b7ee9803 --- /dev/null +++ b/src/advanced/components/cart/Coupon.tsx @@ -0,0 +1,44 @@ +import type { Coupon as CouponType } from '../../../types'; +import { formatKoreanPrice } from '../../shared/utils'; + +interface CouponProps { + coupons: CouponType[]; + selectedCoupon: CouponType | null; + selectCoupon: (coupon: CouponType | null) => void; + applyCoupon: (coupon: CouponType) => void; +} + +const Coupon = ({ coupons, selectedCoupon, selectCoupon, applyCoupon }: CouponProps) => { + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +}; + +export default Coupon; diff --git a/src/advanced/components/cart/Payment.tsx b/src/advanced/components/cart/Payment.tsx new file mode 100644 index 00000000..d17f8e19 --- /dev/null +++ b/src/advanced/components/cart/Payment.tsx @@ -0,0 +1,43 @@ +import { formatKoreanPrice } from '../../shared/utils'; + +interface PaymentProps { + totals: { totalBeforeDiscount: number; totalAfterDiscount: number }; + completeOrder: () => void; +} + +const Payment = ({ totals, completeOrder }: PaymentProps) => { + return ( +
+

결제 정보

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

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

+
+
+ ); +}; + +export default Payment; diff --git a/src/advanced/components/cart/ProductList.tsx b/src/advanced/components/cart/ProductList.tsx new file mode 100644 index 00000000..a21d638f --- /dev/null +++ b/src/advanced/components/cart/ProductList.tsx @@ -0,0 +1,105 @@ +import type { ProductWithUI } from '../../shared/types'; +import { STOCK } from '../../constants/product'; +import { MESSAGES } from '../../constants/message'; +import { ImageIcon } from '../icons'; + +interface ProductListProps { + products: ProductWithUI[]; + searchTerm: string; + filteredProducts: ProductWithUI[]; + formatPrice: (price: number, productId?: string) => string; + getRemainingStock: (product: ProductWithUI) => number; + addToCart: (product: ProductWithUI) => void; +} + +export function ProductList({ + products, + searchTerm, + filteredProducts, + formatPrice, + getRemainingStock, + addToCart, +}: ProductListProps) { + return ( +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map((product) => { + const remainingStock = getRemainingStock(product); + + return ( +
+ {/* 상품 이미지 영역 */} +
+
+ +
+ {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 <= STOCK.LOW_THRESHOLD && remainingStock > 0 && ( +

{MESSAGES.PRODUCT.LOW_STOCK(remainingStock)}

+ )} + {remainingStock > STOCK.LOW_THRESHOLD && ( +

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/advanced/components/cart/SearchBar.tsx b/src/advanced/components/cart/SearchBar.tsx new file mode 100644 index 00000000..802cddfb --- /dev/null +++ b/src/advanced/components/cart/SearchBar.tsx @@ -0,0 +1,18 @@ +interface SearchBarProps { + searchTerm: string; + handleSearchTermChange: (e: React.ChangeEvent) => void; +} + +export function SearchBar({ searchTerm, handleSearchTermChange }: SearchBarProps) { + return ( +
+ +
+ ); +} diff --git a/src/advanced/components/icons/index.tsx b/src/advanced/components/icons/index.tsx new file mode 100644 index 00000000..6fdb185b --- /dev/null +++ b/src/advanced/components/icons/index.tsx @@ -0,0 +1,63 @@ +// x 버튼 아이콘 +export const CloseIcon = () => ( + + + +); + +// 카트 아이콘 +export const CartIcon = () => ( + + + +); + +// 휴지통 아이콘 +export const TrashIcon = () => ( + + + +); + +// + 버튼 아이콘 +export const PlusIcon = () => ( + + + +); + +// 이미지 아이콘 +export const ImageIcon = () => ( + + + +); + +// 장바구니 아이콘 (small) +export const SmallBagIcon = () => ( + + + +); + +// 장바구니 아이콘 (large) +export const LargeBagIcon = () => ( + + + +); diff --git a/src/advanced/components/ui/UIToast/constants.ts b/src/advanced/components/ui/UIToast/constants.ts new file mode 100644 index 00000000..25acbbfd --- /dev/null +++ b/src/advanced/components/ui/UIToast/constants.ts @@ -0,0 +1,44 @@ +/** + * 토스트 설정 기본값 + */ +export const TOAST_DEFAULTS = { + position: 'top-right' as const, + maxWidth: 'max-w-sm', + spacing: 'space-y-2', + zIndex: 'z-50', +} as const; + +/** + * 토스트 애니메이션 클래스 (Tailwind CSS 기반) + */ +export const TOAST_ANIMATION_CLASSES = { + enter: 'transition-all duration-300 ease-in-out transform translate-x-0 opacity-100', + exit: 'transition-all duration-300 ease-in-out transform translate-x-full opacity-0', + hover: 'hover:shadow-lg hover:scale-105', +} as const; + +/** + * 토스트 테마 색상 + */ +export const TOAST_THEMES = { + success: { + background: 'bg-green-600', + text: 'text-white', + icon: '✓', + }, + error: { + background: 'bg-red-600', + text: 'text-white', + icon: '✕', + }, + warning: { + background: 'bg-yellow-600', + text: 'text-white', + icon: '⚠', + }, + info: { + background: 'bg-blue-600', + text: 'text-white', + icon: 'ℹ', + }, +} as const; diff --git a/src/advanced/components/ui/UIToast/helper.ts b/src/advanced/components/ui/UIToast/helper.ts new file mode 100644 index 00000000..ae14c018 --- /dev/null +++ b/src/advanced/components/ui/UIToast/helper.ts @@ -0,0 +1,30 @@ +import type { Notification } from '../../../shared/types'; + +/** + * 토스트 알림을 위한 헬퍼 함수들 + */ +export const ToastHelpers = { + /** + * 토스트 알림이 표시되어야 하는지 확인 + * @param notifications - 알림 목록 + * @returns 표시 여부 + */ + shouldShowToasts: (notifications: Notification[]): boolean => notifications.length > 0, + + /** + * 특정 타입의 알림 개수 반환 + * @param notifications - 알림 목록 + * @param type - 알림 타입 + * @returns 해당 타입의 알림 개수 + */ + getNotificationCountByType: (notifications: Notification[], type: Notification['type']): number => + notifications.filter((n) => n.type === type).length, + + /** + * 가장 최근 알림 반환 + * @param notifications - 알림 목록 + * @returns 가장 최근 알림 또는 null + */ + getLatestNotification: (notifications: Notification[]): Notification | null => + notifications.length > 0 ? notifications[notifications.length - 1] : null, +}; diff --git a/src/advanced/components/ui/UIToast/index.ts b/src/advanced/components/ui/UIToast/index.ts new file mode 100644 index 00000000..54942c58 --- /dev/null +++ b/src/advanced/components/ui/UIToast/index.ts @@ -0,0 +1,3 @@ +import { ToastContainer } from './ui'; + +export { ToastContainer }; diff --git a/src/advanced/components/ui/UIToast/model.ts b/src/advanced/components/ui/UIToast/model.ts new file mode 100644 index 00000000..fa96467b --- /dev/null +++ b/src/advanced/components/ui/UIToast/model.ts @@ -0,0 +1,38 @@ +import type { ToastContainerProps } from './type'; +import type { Notification } from '../../../shared/types'; + +/** + * 토스트 알림의 배경색을 결정하는 순수함수 + * @param type - 알림 타입 + * @returns CSS 클래스명 + */ +export const getToastBackgroundClass = (type: Notification['type']): string => { + switch (type) { + case 'error': + return 'bg-red-600'; + case 'warning': + return 'bg-yellow-600'; + case 'success': + default: + return 'bg-green-600'; + } +}; + +/** + * 토스트 컨테이너의 위치 클래스를 반환하는 순수함수 + * @param position - 토스트 위치 + * @returns CSS 위치 클래스명 + */ +export const getToastPositionClass = (position: ToastContainerProps['position'] = 'top-right'): string => { + switch (position) { + case 'top-left': + return 'fixed top-20 left-4 z-50'; + case 'bottom-right': + return 'fixed bottom-4 right-4 z-50'; + case 'bottom-left': + return 'fixed bottom-4 left-4 z-50'; + case 'top-right': + default: + return 'fixed top-20 right-4 z-50'; + } +}; diff --git a/src/advanced/components/ui/UIToast/type.ts b/src/advanced/components/ui/UIToast/type.ts new file mode 100644 index 00000000..669057fe --- /dev/null +++ b/src/advanced/components/ui/UIToast/type.ts @@ -0,0 +1,25 @@ +import type { Notification } from '../../../shared/types'; + +/** + * 토스트 알림 컨테이너의 props 타입 + * @interface ToastContainerProps + * @property {Notification[]} notifications - 알림 목록 + * @property {(id: string) => void} onRemove - 알림 제거 함수 + * @property {'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'} position - 알림 위치 + * @property {string} className - 추가 클래스명 + */ +export interface ToastContainerProps { + position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'; + className?: string; +} + +/** + * 개별 토스트 알림의 props 타입 + * @interface ToastItemProps + * @property {Notification} notification - 알림 데이터 + * @property {(id: string) => void} onRemove - 알림 제거 함수 + */ +export interface ToastItemProps { + notification: Notification; + onRemove: (id: string) => void; +} diff --git a/src/advanced/components/ui/UIToast/ui.ts b/src/advanced/components/ui/UIToast/ui.ts new file mode 100644 index 00000000..8969de04 --- /dev/null +++ b/src/advanced/components/ui/UIToast/ui.ts @@ -0,0 +1,60 @@ +import React from 'react'; +import { useAtom } from 'jotai'; +import type { ToastItemProps, ToastContainerProps } from './type'; +import { getToastBackgroundClass, getToastPositionClass } from './model'; +import { notificationsAtom, removeNotificationAtom } from '../../../atoms/notificationAtoms'; +import { CloseIcon } from '../../icons'; + +/** + * 개별 토스트 알림 아이템 컴포넌트 + */ +export function ToastItem({ notification, onRemove }: ToastItemProps) { + const backgroundClass = getToastBackgroundClass(notification.type); + + return React.createElement( + 'div', + { + key: notification.id, + className: `p-4 rounded-md shadow-md text-white flex justify-between items-center ${backgroundClass}`, + }, + [ + React.createElement('span', { key: 'message', className: 'mr-2' }, notification.message), + React.createElement( + 'button', + { + key: 'close', + onClick: () => onRemove(notification.id), + className: 'text-white hover:text-gray-200', + }, + React.createElement(CloseIcon), + ), + ], + ); +} + +/** + * 토스트 알림 컨테이너 컴포넌트 + */ +export function ToastContainer({ position = 'top-right', className = '' }: ToastContainerProps) { + const [notifications] = useAtom(notificationsAtom); + const [, removeNotification] = useAtom(removeNotificationAtom); + + if (notifications.length === 0) { + return null; + } + + const positionClass = getToastPositionClass(position); + const containerClass = `${positionClass} space-y-2 max-w-sm ${className}`.trim(); + + return React.createElement( + 'div', + { className: containerClass }, + notifications.map((notification) => + React.createElement(ToastItem, { + key: notification.id, + notification, + onRemove: removeNotification, + }), + ), + ); +} diff --git a/src/advanced/constants/couponForm.ts b/src/advanced/constants/couponForm.ts new file mode 100644 index 00000000..9b7a705f --- /dev/null +++ b/src/advanced/constants/couponForm.ts @@ -0,0 +1,8 @@ +import type { CouponFormData as CouponFormDataType } from '../../types'; + +export const DEFAULT_COUPON_FORM: CouponFormDataType = { + name: '', + code: '', + discountType: 'amount', + discountValue: 0, +}; diff --git a/src/advanced/constants/discount.ts b/src/advanced/constants/discount.ts new file mode 100644 index 00000000..c7ac50f3 --- /dev/null +++ b/src/advanced/constants/discount.ts @@ -0,0 +1,7 @@ +export const DISCOUNT = { + BULK_THRESHOLD: 10, // 대량 구매 기준 + MAX_RATE: 0.5, // 최대 할인율 50% + MAX_PERCENTAGE: 100, // 최대 할인율 100% + BULK_BONUS: 0.05, // 대량 구매 추가 할인 5% + MAX_COUPON_AMOUNT: 100000, // 최대 쿠폰 할인 금액 +} as const; diff --git a/src/advanced/constants/initialData.ts b/src/advanced/constants/initialData.ts new file mode 100644 index 00000000..39ad1611 --- /dev/null +++ b/src/advanced/constants/initialData.ts @@ -0,0 +1,53 @@ +import type { Coupon } from '../../types'; +import type { ProductWithUI } from '../shared/types'; + +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, + }, +]; + +export { initialProducts, initialCoupons }; diff --git a/src/advanced/constants/message.ts b/src/advanced/constants/message.ts new file mode 100644 index 00000000..78043ac0 --- /dev/null +++ b/src/advanced/constants/message.ts @@ -0,0 +1,45 @@ +export const MESSAGES = { + COUPON: { + ADDED: '쿠폰이 추가되었습니다.', + DELETED: '쿠폰이 삭제되었습니다.', + + APPLIED: '쿠폰이 적용되었습니다.', + ALREADY_EXISTS: '이미 존재하는 쿠폰 코드입니다.', + MIN_PRICE: 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + }, + + PRODUCT: { + ADDED: '상품이 추가되었습니다.', + UPDATED: '상품이 수정되었습니다.', + DELETED: '상품이 삭제되었습니다.', + + OUT_OF_STOCK: '재고가 부족합니다!', + MAX_STOCK: (maxStock: number) => `재고는 ${maxStock}개까지만 있습니다.`, + ADDED_TO_CART: '장바구니에 담았습니다', + LOW_STOCK: (remainingStock: number) => `품절임박! ${remainingStock}개 남음`, + INVALID_QUANTITY: '수량은 0 이상의 정수여야 합니다.', + }, + + ORDER: { + COMPLETED: (orderNumber: string) => `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + }, + + STOCK: { + MIN: '재고는 0보다 커야 합니다', + MAX: '재고는 9999개를 초과할 수 없습니다', + }, + + PRICE: { + MIN: '가격은 0보다 커야 합니다', + }, + + DISCOUNT_PERCENTAGE: { + MIN: '할인율은 0% 이상이어야 합니다', + MAX: '할인율은 100%를 초과할 수 없습니다', + }, + + DISCOUNT_AMOUNT: { + MIN: '할인 금액은 0원 이상이어야 합니다', + MAX: '할인 금액은 100,000원을 초과할 수 없습니다', + }, +} as const; diff --git a/src/advanced/constants/order.ts b/src/advanced/constants/order.ts new file mode 100644 index 00000000..ad025c7b --- /dev/null +++ b/src/advanced/constants/order.ts @@ -0,0 +1,3 @@ +export const ORDER = { + MIN_FOR_COUPON: 10000, // 퍼센트 쿠폰 사용 최소 금액 +} as const; diff --git a/src/advanced/constants/product.ts b/src/advanced/constants/product.ts new file mode 100644 index 00000000..4dc27b4c --- /dev/null +++ b/src/advanced/constants/product.ts @@ -0,0 +1,5 @@ +export const STOCK = { + LOW_THRESHOLD: 5, // 품절 임박 기준 + MEDIUM_THRESHOLD: 10, // 적정 재고 기준 + MAX_QUANTITY: 9999, // 최대 재고 수량 +} as const; diff --git a/src/advanced/constants/productForm.ts b/src/advanced/constants/productForm.ts new file mode 100644 index 00000000..bcbb1791 --- /dev/null +++ b/src/advanced/constants/productForm.ts @@ -0,0 +1,9 @@ +import type { ProductFormData as ProductFormDataType } from '../../types'; + +export const DEFAULT_PRODUCT_FORM: ProductFormDataType = { + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], +}; diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts new file mode 100644 index 00000000..97e2a34a --- /dev/null +++ b/src/advanced/hooks/useCart.ts @@ -0,0 +1,147 @@ +import { useState, useCallback } from 'react'; +import { CartItem, Coupon, Product } from '../../types'; +import { ProductWithUI } from '../shared/types'; +import { useLocalStorage } from '../shared/hooks'; +import * as cartModel from '../models/cart'; +import { ORDER } from '../constants/order'; +import { MESSAGES } from '../constants/message'; + +/** + * 장바구니 관리 Hook + */ +export function useCart() { + const [cart, setCart] = useLocalStorage('cart', []); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + /** + * 상품을 장바구니에 추가 + * @param product - 추가할 상품 + * @param onSuccess - 성공 콜백 + * @param onError - 에러 콜백 + */ + const addToCart = useCallback( + (product: ProductWithUI, onSuccess?: (message: string) => void, onError?: (message: string) => void) => { + const remainingStock = cartModel.getRemainingStock(product, cart); + + if (remainingStock <= 0) { + onError?.(MESSAGES.PRODUCT.OUT_OF_STOCK); + return; + } + + const existingItem = cart.find((item) => item.product.id === product.id); + if (existingItem && existingItem.quantity + 1 > product.stock) { + onError?.(MESSAGES.PRODUCT.MAX_STOCK(product.stock)); + return; + } + + const updatedCart = cartModel.addItemToCart(cart, product); + setCart(updatedCart); + onSuccess?.(MESSAGES.PRODUCT.ADDED_TO_CART); + }, + [cart, setCart], + ); + + /** + * 장바구니에서 상품 제거 + * @param productId - 제거할 상품 ID + */ + const removeFromCart = useCallback( + (productId: string) => { + const updatedCart = cartModel.removeItemFromCart(cart, productId); + setCart(updatedCart); + }, + [cart, setCart], + ); + + /** + * 장바구니 상품 수량 변경 + * @param productId - 상품 ID + * @param newQuantity - 새로운 수량 + * @param products - 상품 목록 (재고 확인용) + * @param onError - 에러 콜백 + */ + const updateQuantity = useCallback( + (productId: string, newQuantity: number, products: ProductWithUI[], onError?: (message: string) => void) => { + const product = products.find((p) => p.id === productId); + if (!product) { + onError?.('상품을 찾을 수 없습니다.'); + return; + } + + const result = cartModel.processQuantityUpdate(cart, productId, newQuantity, product.stock); + + if (result.success) { + setCart(result.updatedCart); + } else { + onError?.(result.error); + } + }, + [cart, setCart], + ); + + /** + * 쿠폰 적용 + * @param coupon - 적용할 쿠폰 + * @param onSuccess - 성공 콜백 + * @param onError - 에러 콜백 + */ + const applyCoupon = useCallback( + (coupon: Coupon, onSuccess?: (message: string) => void, onError?: (message: string) => void) => { + const currentTotal = cartModel.calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; + + if (currentTotal < ORDER.MIN_FOR_COUPON && coupon.discountType === 'percentage') { + onError?.(MESSAGES.COUPON.MIN_PRICE); + return; + } + + setSelectedCoupon(coupon); + onSuccess?.(MESSAGES.COUPON.APPLIED); + }, + [cart, selectedCoupon], + ); + + /** + * 총액 계산 + * @returns 할인 전/후 총액 + */ + const calculateTotal = useCallback(() => { + return cartModel.calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + /** + * 남은 재고 조회 + * @param product - 상품 + * @returns 남은 재고 + */ + const getRemainingStock = useCallback( + (product: Product) => { + return cartModel.getRemainingStock(product, cart); + }, + [cart], + ); + + /** + * 장바구니 비우기 + */ + const clearCart = useCallback(() => { + setCart([]); + setSelectedCoupon(null); + }, [setCart]); + + const selectCoupon = (coupon: Coupon | null) => { + setSelectedCoupon(coupon); + }; + + return { + cart, + selectedCoupon, + selectCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + calculateTotal, + getRemainingStock, + clearCart, + }; +} diff --git a/src/advanced/hooks/useCoupons.ts b/src/advanced/hooks/useCoupons.ts new file mode 100644 index 00000000..5ce23557 --- /dev/null +++ b/src/advanced/hooks/useCoupons.ts @@ -0,0 +1,30 @@ +import { Coupon } from '../../types'; +import { initialCoupons } from '../constants/initialData'; +import { useLocalStorage } from '../shared/hooks'; +import { couponModel } from '../models/coupon'; + +export const useCoupons = () => { + const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); + + /** + * 쿠폰 추가 + * @param newCoupon - 추가할 쿠폰 + */ + const addCoupon = (newCoupon: Coupon) => { + setCoupons((prev) => couponModel.addCoupon(prev, newCoupon)); + }; + + /** + * 쿠폰 삭제 + * @param couponCode - 삭제할 쿠폰 코드 + */ + const deleteCoupon = (couponCode: string) => { + setCoupons((prev) => couponModel.deleteCoupon(prev, couponCode)); + }; + + return { + coupons, + addCoupon, + deleteCoupon, + }; +}; diff --git a/src/advanced/hooks/useProductSearch.ts b/src/advanced/hooks/useProductSearch.ts new file mode 100644 index 00000000..ece5b7d7 --- /dev/null +++ b/src/advanced/hooks/useProductSearch.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; +import { ProductWithUI } from '../shared/types'; +import { useSearch } from '../shared/hooks'; +import { filterProductsBySearchTerm } from '../models/productSearch'; +import { SEARCH_DELAY } from '../shared/constants/toast'; + +/** + * 상품 검색을 위한 전용 훅 + * @param products - 검색할 상품 목록 + * @param delay - 디바운스 지연 시간 + * @returns 검색 관련 상태와 필터링된 상품들 + */ +export function useProductSearch(products: ProductWithUI[], delay: number = SEARCH_DELAY) { + const { searchTerm, handleSearchTermChange, debouncedSearchTerm, clearSearch, hasSearchTerm } = useSearch(delay); + + /** + * 검색어로 필터링된 상품 목록 + */ + const filteredProducts = useMemo(() => { + return filterProductsBySearchTerm(products, debouncedSearchTerm); + }, [products, debouncedSearchTerm]); + + return { + searchTerm, + handleSearchTermChange, + debouncedSearchTerm, + clearSearch, + hasSearchTerm, + filteredProducts, + }; +} diff --git a/src/advanced/hooks/useProducts.ts b/src/advanced/hooks/useProducts.ts new file mode 100644 index 00000000..668c9022 --- /dev/null +++ b/src/advanced/hooks/useProducts.ts @@ -0,0 +1,60 @@ +import { useCallback } from 'react'; +import { useLocalStorage } from '../shared/hooks'; +import { ProductWithUI } from '../shared/types'; +import { productModel } from '../models/product'; +import { initialProducts } from '../constants/initialData'; +import { MESSAGES } from '../constants/message'; + +export function useProducts() { + const [products, setProducts] = useLocalStorage('products', initialProducts); + + /** + * 상품 추가 + * @param newProduct - 추가할 상품 + * @param onSuccess - 성공 콜백 + */ + const addProduct = useCallback( + (newProduct: Omit, onSuccess?: (message: string) => void) => { + const updatedProducts = productModel.addProduct(products, newProduct); + setProducts(updatedProducts); + onSuccess?.(MESSAGES.PRODUCT.ADDED); + }, + [products, setProducts], + ); + + /** + * 상품 수정 + * @param productId - 상품 ID + * @param updates - 수정할 상품 정보 + * @param onSuccess - 성공 콜백 + */ + const updateProduct = useCallback( + (productId: string, updates: Partial, onSuccess?: (message: string) => void) => { + const updatedProducts = productModel.updateProduct(products, productId, updates); + setProducts(updatedProducts); + onSuccess?.(MESSAGES.PRODUCT.UPDATED); + }, + [products, setProducts], + ); + + /** + * 상품 삭제 + * @param productId - 상품 ID + * @param onSuccess - 성공 콜백 + */ + const deleteProduct = useCallback( + (productId: string, onSuccess?: (message: string) => void) => { + const updatedProducts = productModel.deleteProduct(products, productId); + setProducts(updatedProducts); + onSuccess?.(MESSAGES.PRODUCT.DELETED); + }, + [products, setProducts], + ); + + return { + products, + addProduct, + updateProduct, + deleteProduct, + }; +} diff --git a/src/advanced/main.tsx b/src/advanced/main.tsx index e63eef4a..0ad9d2cf 100644 --- a/src/advanced/main.tsx +++ b/src/advanced/main.tsx @@ -1,9 +1,12 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { Provider } from 'jotai'; +import App from './App.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , -) +); diff --git a/src/advanced/models/cart.ts b/src/advanced/models/cart.ts new file mode 100644 index 00000000..85bce155 --- /dev/null +++ b/src/advanced/models/cart.ts @@ -0,0 +1,205 @@ +import { CartItem, Coupon, Product, Discount } from '../../types'; +import { ProductWithUI } from '../shared/types'; +import { DISCOUNT } from '../constants/discount'; +import { MESSAGES } from '../constants/message'; + +/** + * 수량 기반 할인 계산 + * @param item - 장바구니 상품 + * @returns 수량 기반 할인 + */ +export const calculateQuantityDiscount = (item: CartItem): number => { + const { discounts } = item.product; + const { quantity } = item; + + return discounts.reduce((maxDiscount: number, discount: Discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount ? discount.rate : maxDiscount; + }, 0); +}; + +/** + * 대량 구매 여부 확인 + * @param cart - 장바구니 상품 + * @param bulkPurchaseQuantity - 대량 구매 수량, 기본값 10 + * @returns 대량 구매 여부 + */ +export const hasBulkPurchase = (cart: CartItem[], bulkPurchaseQuantity: number = DISCOUNT.BULK_THRESHOLD): boolean => { + return cart.some((cartItem) => cartItem.quantity >= bulkPurchaseQuantity); +}; + +/** + * 최종 할인율 계산 + * @param item - 장바구니에 담긴 상품 + * @param cart - 장바구니 상품 목록 + * @returns 최종 할인율 + */ +export const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number => { + const baseDiscount = calculateQuantityDiscount(item); + + if (hasBulkPurchase(cart)) { + return Math.min(baseDiscount + DISCOUNT.BULK_BONUS, DISCOUNT.MAX_RATE); + } + + return baseDiscount; +}; + +/** + * 아이템 총액 계산 + * @param item - 장바구니에 담긴 상품 + * @param cart - 장바구니 상품 목록 + * @returns 아이템 총액 + */ +export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +/** + * 할인 전 총액 계산 + * @param cart - 장바구니 아이템들 + * @returns 할인 전 총액 + */ +export const calculateCartSubtotal = (cart: CartItem[]): number => { + return cart.reduce((total, item) => { + return total + item.product.price * item.quantity; + }, 0); +}; + +/** + * 상품 할인 적용된 총액 계산 + * @param cart - 장바구니 아이템들 + * @returns 상품 할인 적용된 총액 + */ +export const calculateCartDiscountedTotal = (cart: CartItem[]): number => { + return cart.reduce((total, item) => { + return total + calculateItemTotal(item, cart); + }, 0); +}; + +/** + * 쿠폰 할인 적용 + * @param amount - 할인 전 금액 + * @param coupon - 적용할 쿠폰 + * @returns 쿠폰 할인 적용된 금액 + */ +export const applyCouponDiscount = (amount: number, coupon: Coupon | null): number => { + if (!coupon) return amount; + + if (coupon.discountType === 'amount') { + return Math.max(0, amount - coupon.discountValue); + } else { + return Math.round(amount * (1 - coupon.discountValue / DISCOUNT.MAX_PERCENTAGE)); + } +}; + +/** + * 최종 장바구니 총액 계산 + * @param cart - 장바구니 아이템들 + * @param selectedCoupon - 선택된 쿠폰 + * @returns 할인 전/후 총액 + */ +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null, +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + const totalBeforeDiscount = calculateCartSubtotal(cart); + const itemDiscountedTotal = calculateCartDiscountedTotal(cart); + const totalAfterDiscount = applyCouponDiscount(itemDiscountedTotal, selectedCoupon); + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; + +/** + * 장바구니에서 특정 상품 제거 + * @param cart - 현재 장바구니 + * @param productId - 제거할 상품 ID + * @returns 상품이 제거된 새로운 장바구니 + */ +export const removeItemFromCart = (cart: CartItem[], productId: string): CartItem[] => { + return cart.filter((item) => item.product.id !== productId); +}; + +/** + * 장바구니 아이템 수량 업데이트 + * @param cart - 현재 장바구니 + * @param productId - 업데이트할 상품 ID + * @param newQuantity - 새로운 수량 + * @returns 수량이 업데이트된 새로운 장바구니 + */ +export const updateCartItemQuantity = (cart: CartItem[], productId: string, newQuantity: number): CartItem[] => { + return cart.map((item) => (item.product.id === productId ? { ...item, quantity: newQuantity } : item)); +}; + +/** + * 장바구니에 상품 추가 + * @param cart - 현재 장바구니 + * @param product - 추가할 상품 + * @returns 상품이 추가된 새로운 장바구니 + */ +export const addItemToCart = (cart: CartItem[], product: ProductWithUI): CartItem[] => { + const existingItem = cart.find((item) => item.product.id === product.id); + + if (existingItem) { + return updateCartItemQuantity(cart, product.id, existingItem.quantity + 1); + } + + return [...cart, { product, quantity: 1 }]; +}; + +/** + * 장바구니에 담긴 상품의 재고를 조회 + * @param product - 상품 + * @param cart - 장바구니 + * @returns 재고 + */ +export const getRemainingStock = (product: Product, cart: CartItem[]): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +}; + +/** + * 수량 업데이트 결과 타입 + */ +export type QuantityUpdateResult = + | { success: true; updatedCart: CartItem[] } + | { success: false; error: string; errorType: 'INVALID_QUANTITY' | 'PRODUCT_NOT_FOUND' | 'INSUFFICIENT_STOCK' }; + +/** + * 수량 업데이트 로직 + * @param cart - 현재 장바구니 + * @param productId - 업데이트할 상품 ID + * @param newQuantity - 새로운 수량 + * @param availableStock - 사용 가능한 재고 + * @returns 수량 업데이트 결과 + */ +export const processQuantityUpdate = ( + cart: CartItem[], + productId: string, + newQuantity: number, + availableStock: number, +): QuantityUpdateResult => { + if (!Number.isInteger(newQuantity) || newQuantity < 0) { + return { success: false, error: MESSAGES.PRODUCT.INVALID_QUANTITY, errorType: 'INVALID_QUANTITY' }; + } + + if (newQuantity === 0) { + return { success: true, updatedCart: removeItemFromCart(cart, productId) }; + } + + if (newQuantity > availableStock) { + return { success: false, error: MESSAGES.PRODUCT.MAX_STOCK(availableStock), errorType: 'INSUFFICIENT_STOCK' }; + } + + return { success: true, updatedCart: updateCartItemQuantity(cart, productId, newQuantity) }; +}; diff --git a/src/advanced/models/coupon.ts b/src/advanced/models/coupon.ts new file mode 100644 index 00000000..c04e9e10 --- /dev/null +++ b/src/advanced/models/coupon.ts @@ -0,0 +1,29 @@ +import { Coupon } from '../../types'; + +export const couponModel = { + /** + * 쿠폰 추가 + * @param coupons - 쿠폰 목록 + * @param newCoupon - 추가할 쿠폰 + * @returns 쿠폰 목록 + */ + addCoupon: (coupons: Coupon[], newCoupon: Coupon): Coupon[] => { + const exists = coupons.some((coupon) => coupon.code === newCoupon.code); + // 이미 존재하면 그대로 반환 + if (exists) return coupons; + + return [...coupons, newCoupon]; + }, + + /** + * 쿠폰 삭제 + * @param coupons - 쿠폰 목록 + * @param couponCode - 삭제할 쿠폰 코드 + * @returns 쿠폰 목록 + */ + deleteCoupon: (coupons: Coupon[], couponCode: string): Coupon[] => { + const updatedCoupons = coupons.filter((c) => c.code !== couponCode); + + return updatedCoupons; + }, +}; diff --git a/src/advanced/models/couponForm.ts b/src/advanced/models/couponForm.ts new file mode 100644 index 00000000..ef5a4834 --- /dev/null +++ b/src/advanced/models/couponForm.ts @@ -0,0 +1,70 @@ +import type { Coupon as CouponType } from '../../types'; +import type { CouponFormData as CouponFormDataType } from '../../types'; +import { DEFAULT_COUPON_FORM } from '../constants/couponForm'; +import { validator } from '../shared/utils/validators'; + +/** + * 쿠폰 코드를 대문자로 변환하는 순수함수 + * @param code - 입력된 코드 + * @returns 대문자로 변환된 코드 + */ +export const normalizeCode = (code: string): string => code.toUpperCase(); + +/** + * 할인 타입 변경 시 새로운 폼 상태를 반환하는 순수함수 + * @param currentForm - 현재 폼 상태 + * @param discountType - 새로운 할인 타입 + * @returns 업데이트된 폼 상태 + */ +export const updateFormDiscountType = ( + currentForm: CouponFormDataType, + discountType: 'amount' | 'percentage', +): CouponFormDataType => ({ + ...currentForm, + discountType, + discountValue: 0, +}); + +/** + * 입력된 값을 숫자로 파싱하는 순수함수 + * @param value - 입력 문자열 + * @returns 파싱된 숫자 값 또는 null (유효하지 않은 경우) + */ +export const parseDiscountValue = (value: string): number | null => { + const numericString = validator.validateNumericString(value); + if (numericString === null) return null; + return numericString === '' ? 0 : parseInt(numericString); +}; + +/** + * 할인 값 유효성 검증하는 순수함수 + * @param value - 검증할 값 + * @param discountType - 할인 타입 + * @returns 검증 결과 객체 + */ +export const validateDiscountValue = ( + value: number, + discountType: 'amount' | 'percentage', +): { isValid: boolean; message: string; correctedValue: number } => { + const isPercentageType = discountType === 'percentage'; + + return isPercentageType ? validator.isValidDiscountPercentage(value) : validator.isValidDiscountAmount(value); +}; + +/** + * 폼 데이터를 쿠폰 객체로 변환하는 순수함수 + * @param formData - 폼 데이터 + * @returns 쿠폰 객체 + */ +export const formToCoupon = (formData: CouponFormDataType): CouponType => ({ + name: formData.name, + code: formData.code, + discountType: formData.discountType, + discountValue: formData.discountValue, +}); + +/** + * 폼을 기본값으로 리셋하는 순수함수 + * @returns 기본 폼 상태 + */ +export const resetCouponForm = (): CouponFormDataType => ({ ...DEFAULT_COUPON_FORM }); diff --git a/src/advanced/models/product.ts b/src/advanced/models/product.ts new file mode 100644 index 00000000..c5216622 --- /dev/null +++ b/src/advanced/models/product.ts @@ -0,0 +1,32 @@ +import { ProductWithUI } from '../shared/types'; + +export const productModel = { + /** + * 상품 추가 로직 + * @param newProduct - 추가할 상품 + */ + addProduct: (products: ProductWithUI[], newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + return [...products, product]; + }, + + /** + * 상품 수정 로직 + * @param productId - 상품 ID + * @param updates - 수정할 상품 정보 + */ + updateProduct: (products: ProductWithUI[], productId: string, updates: Partial) => { + return products.map((product) => (product.id === productId ? { ...product, ...updates } : product)); + }, + + /** + * 상품 삭제 로직 + * @param productId - 상품 ID + */ + deleteProduct: (products: ProductWithUI[], productId: string) => { + return products.filter((p) => p.id !== productId); + }, +}; diff --git a/src/advanced/models/productForm.ts b/src/advanced/models/productForm.ts new file mode 100644 index 00000000..9ade2abd --- /dev/null +++ b/src/advanced/models/productForm.ts @@ -0,0 +1,101 @@ +import { ProductWithUI } from '../shared/types'; +import { validator } from '../shared/utils/validators'; +import type { ProductFormData as ProductFormDataType } from '../../types'; +import { DEFAULT_PRODUCT_FORM } from '../constants/productForm'; + +/** + * 입력된 값을 숫자로 파싱하는 순수함수 + * @param value - 입력 문자열 + * @returns 파싱된 숫자 값 또는 null (유효하지 않은 경우) + */ +export const parseNumericValue = (value: string): number | null => { + const numericString = validator.validateNumericString(value); + if (numericString === null) return null; + return numericString === '' ? 0 : parseInt(numericString); +}; + +/** + * 가격 유효성 검증하는 순수함수 + * @param price - 검증할 가격 + * @returns 검증 결과 객체 + */ +export const validatePrice = (price: number): { isValid: boolean; message: string; correctedValue: number } => { + return validator.isValidPrice(price); +}; + +/** + * 재고 유효성 검증하는 순수함수 + * @param stock - 검증할 재고 + * @returns 검증 결과 객체 + */ +export const validateStock = (stock: number): { isValid: boolean; message: string; correctedValue: number } => { + return validator.isValidStock(stock); +}; + +/** + * 할인 정책을 추가하는 순수함수 + * @param discounts - 현재 할인 목록 + * @param newDiscount - 새로운 할인 정책 + * @returns 업데이트된 할인 목록 + */ +export const addDiscount = ( + discounts: Array<{ quantity: number; rate: number }>, + newDiscount: { quantity: number; rate: number } = { quantity: 10, rate: 0.1 }, +): Array<{ quantity: number; rate: number }> => [...discounts, newDiscount]; + +/** + * 할인 정책을 제거하는 순수함수 + * @param discounts - 현재 할인 목록 + * @param index - 제거할 할인 인덱스 + * @returns 업데이트된 할인 목록 + */ +export const removeDiscount = ( + discounts: Array<{ quantity: number; rate: number }>, + index: number, +): Array<{ quantity: number; rate: number }> => discounts.filter((_, i) => i !== index); + +/** + * 할인 정책을 업데이트하는 순수함수 + * @param discounts - 현재 할인 목록 + * @param index - 업데이트할 할인 인덱스 + * @param updates - 업데이트할 값들 + * @returns 업데이트된 할인 목록 + */ +export const updateDiscount = ( + discounts: Array<{ quantity: number; rate: number }>, + index: number, + updates: Partial<{ quantity: number; rate: number }>, +): Array<{ quantity: number; rate: number }> => + discounts.map((discount, i) => (i === index ? { ...discount, ...updates } : discount)); + +/** + * 폼 데이터를 상품 객체로 변환하는 순수함수 + * @param formData - 폼 데이터 + * @returns 상품 객체 (id 제외) + */ +export const formToProduct = (formData: ProductFormDataType): Omit => ({ + name: formData.name, + price: formData.price, + stock: formData.stock, + description: formData.description, + discounts: formData.discounts, +}); + +/** + * 상품 객체를 폼 데이터로 변환하는 순수함수 + * @param product - 상품 객체 + * @returns 폼 데이터 + */ +export const productToForm = (product: ProductWithUI): ProductFormDataType => ({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [], +}); + +/** + * 폼을 기본값으로 리셋하는 순수함수 + * @returns 기본 폼 상태 + */ +export const resetProductForm = (): ProductFormDataType => ({ ...DEFAULT_PRODUCT_FORM }); diff --git a/src/advanced/models/productSearch.ts b/src/advanced/models/productSearch.ts new file mode 100644 index 00000000..f0180ffc --- /dev/null +++ b/src/advanced/models/productSearch.ts @@ -0,0 +1,39 @@ +import { ProductWithUI } from '../shared/types'; + +/** + * 검색어를 정규화 (소문자 변환, 공백 제거) + * @param searchTerm - 원본 검색어 + * @returns 정규화된 검색어 + */ +export const normalizeSearchTerm = (searchTerm: string): string => { + return searchTerm.trim().toLowerCase(); +}; + +/** + * 상품이 검색어와 매칭되는지 확인 + * @param product - 상품 + * @param normalizedSearchTerm - 정규화된 검색어 + * @returns 매칭 여부 + */ +export const matchesProduct = (product: ProductWithUI, normalizedSearchTerm: string): boolean => { + const nameMatches = product.name.toLowerCase().includes(normalizedSearchTerm); + const descriptionMatches = product.description?.toLowerCase().includes(normalizedSearchTerm) || false; + + return nameMatches || descriptionMatches; +}; + +/** + * 상품 검색 필터링 로직 + * @param products - 검색할 상품 목록 + * @param searchTerm - 검색어 + * @returns 필터링된 상품 목록 + */ +export const filterProductsBySearchTerm = (products: ProductWithUI[], searchTerm: string): ProductWithUI[] => { + const normalized = normalizeSearchTerm(searchTerm); + + if (!normalized) { + return products; + } + + return products.filter((product) => matchesProduct(product, normalized)); +}; diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx new file mode 100644 index 00000000..246477ca --- /dev/null +++ b/src/advanced/pages/AdminPage.tsx @@ -0,0 +1,185 @@ +import { useState, useCallback } from 'react'; +import { useAtom } from 'jotai'; +import type { Coupon as CouponType } from '../../types'; +import type { ProductWithUI } from '../shared/types'; +import { MESSAGES } from '../constants/message'; +import { CouponForm } from '../components/admin/CouponForm'; +import CouponList from '../components/admin/CouponList'; +import ProductForm from '../components/admin/ProductForm'; +import { ProductList } from '../components/admin/ProductList'; +import { useNotificationActions } from '../shared/hooks'; +import { showProductFormAtom, startEditProductAtom, startAddProductAtom } from '../atoms/productFormAtoms'; +import { showCouponFormAtom, toggleCouponFormAtom } from '../atoms/couponFormAtoms'; + +interface AdminPageProps { + products: ProductWithUI[]; + coupons: CouponType[]; + addProduct: (newProduct: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; + addCoupon: (newCoupon: CouponType) => void; + deleteCoupon: (couponCode: string) => void; + selectedCoupon: CouponType | null; + selectCoupon: (coupon: CouponType | null) => void; +} + +export function AdminPage({ + products, + coupons, + addProduct: addProductHook, + updateProduct: updateProductHook, + deleteProduct: deleteProductHook, + addCoupon: addCouponHook, + deleteCoupon: deleteCouponHook, + selectedCoupon, + selectCoupon, +}: AdminPageProps) { + const { addNotification } = useNotificationActions(); + + // ProductForm 관련 atoms + const [showProductForm] = useAtom(showProductFormAtom); + const [, startEditProduct] = useAtom(startEditProductAtom); + const [, startAddProduct] = useAtom(startAddProductAtom); + + // CouponForm 관련 atoms + const [showCouponForm] = useAtom(showCouponFormAtom); + const [, toggleCouponForm] = useAtom(toggleCouponFormAtom); + + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + + const addProduct = useCallback( + (newProduct: Omit) => { + addProductHook(newProduct); + addNotification({ message: MESSAGES.PRODUCT.ADDED, type: 'success' }); + }, + [addProductHook, addNotification], + ); + + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + updateProductHook(productId, updates); + addNotification({ message: MESSAGES.PRODUCT.UPDATED, type: 'success' }); + }, + [updateProductHook, addNotification], + ); + + const deleteProduct = useCallback( + (productId: string) => { + deleteProductHook(productId); + addNotification({ message: MESSAGES.PRODUCT.DELETED, type: 'success' }); + }, + [deleteProductHook, addNotification], + ); + + const addCoupon = useCallback( + (newCoupon: CouponType) => { + addCouponHook(newCoupon); + addNotification({ message: MESSAGES.COUPON.ADDED, type: 'success' }); + }, + [addCouponHook, addNotification], + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + deleteCouponHook(couponCode); + if (selectedCoupon?.code === couponCode) { + selectCoupon(null); + } + addNotification({ message: MESSAGES.COUPON.DELETED, type: 'success' }); + }, + [deleteCouponHook, selectedCoupon, selectCoupon, addNotification], + ); + + // 상품 폼 제출 핸들러 + const handleProductSubmit = useCallback( + (productData: Omit, productId?: string) => { + if (productId) { + updateProduct(productId, productData); + } else { + addProduct(productData); + } + }, + [addProduct, updateProduct], + ); + + // 쿠폰 폼 제출 핸들러 - 대폭 단순화! + const handleCouponSubmit = useCallback( + (couponData: CouponType) => { + addCoupon(couponData); + }, + [addCoupon], + ); + + return ( +
+
+

관리자 대시보드

+

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

+
+ +
+ +
+ + {activeTab === 'products' ? ( +
+
+
+

상품 목록

+ +
+
+ + + + {showProductForm && } +
+ ) : ( +
+
+

쿠폰 관리

+
+
+ + + {/* CouponForm - 이제 매우 단순한 인터페이스! */} + {showCouponForm && } +
+
+ )} +
+ ); +} + +export default AdminPage; diff --git a/src/advanced/pages/CartPage.tsx b/src/advanced/pages/CartPage.tsx new file mode 100644 index 00000000..fca310f6 --- /dev/null +++ b/src/advanced/pages/CartPage.tsx @@ -0,0 +1,141 @@ +import { useCallback } from 'react'; +import type { Coupon as CouponType } from '../../types'; +import type { ProductWithUI } from '../shared/types'; +import { MESSAGES } from '../constants/message'; +import { ProductList } from '../components/cart/ProductList'; +import { Cart } from '../components/cart/Cart'; +import Payment from '../components/cart/Payment'; +import Coupon from '../components/cart/Coupon'; +import { useNotificationActions } from '../shared/hooks'; + +interface CartPageProps { + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + searchTerm: string; + cart: Array<{ product: ProductWithUI; quantity: number }>; + coupons: CouponType[]; + selectedCoupon: CouponType | null; + selectCoupon: (coupon: CouponType | null) => void; + addToCart: (product: ProductWithUI, onSuccess: (message: string) => void, onError: (message: string) => void) => void; + removeFromCart: (productId: string) => void; + updateQuantity: ( + productId: string, + newQuantity: number, + products: ProductWithUI[], + onError: (message: string) => void, + ) => void; + applyCoupon: (coupon: CouponType, onSuccess: (message: string) => void, onError: (message: string) => void) => void; + calculateTotal: () => { totalBeforeDiscount: number; totalAfterDiscount: number }; + getRemainingStock: (product: ProductWithUI) => number; + clearCart: () => void; +} + +export function CartPage({ + products, + filteredProducts, + searchTerm, + cart, + coupons, + selectedCoupon, + selectCoupon, + addToCart: addToCartHook, + removeFromCart, + updateQuantity: updateQuantityHook, + applyCoupon: applyCouponHook, + calculateTotal, + getRemainingStock, + clearCart, +}: CartPageProps) { + const { addNotification } = useNotificationActions(); + + 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'; + } + } + return `₩${price.toLocaleString()}`; + }; + + const addToCart = useCallback( + (product: ProductWithUI) => { + addToCartHook( + product, + (message) => addNotification({ message, type: 'success' }), + (message) => addNotification({ message, type: 'error' }), + ); + }, + [addToCartHook, addNotification], + ); + + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + updateQuantityHook(productId, newQuantity, products, (message) => addNotification({ message, type: 'error' })); + }, + [updateQuantityHook, products, addNotification], + ); + + const applyCoupon = useCallback( + (coupon: CouponType) => { + applyCouponHook( + coupon, + (message) => addNotification({ message, type: 'success' }), + (message) => addNotification({ message, type: 'error' }), + ); + }, + [applyCouponHook, addNotification], + ); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification({ message: MESSAGES.ORDER.COMPLETED(orderNumber), type: 'success' }); + clearCart(); + }, [addNotification, clearCart]); + + const totals = calculateTotal(); + + return ( +
+
+ {/* 상품 목록 */} + +
+ +
+
+ {/* 장바구니 */} + + + {cart.length > 0 && ( + <> + {/* 쿠폰 섹션 */} + + {/* 결제 정보 */} + + + )} +
+
+
+ ); +} + +export default CartPage; diff --git a/src/advanced/shared/constants/toast.ts b/src/advanced/shared/constants/toast.ts new file mode 100644 index 00000000..0e33b772 --- /dev/null +++ b/src/advanced/shared/constants/toast.ts @@ -0,0 +1,2 @@ +export const NOTIFICATION_DURATION = 3000; +export const SEARCH_DELAY = 500; diff --git a/src/advanced/shared/hooks/index.ts b/src/advanced/shared/hooks/index.ts new file mode 100644 index 00000000..ae870680 --- /dev/null +++ b/src/advanced/shared/hooks/index.ts @@ -0,0 +1,6 @@ +import { useLocalStorage } from './useLocalStorage'; +import { useDebounce } from './useDebounce'; +import { useSearch } from './useSearch'; +import { useNotificationActions } from './useNotificationActions'; + +export { useLocalStorage, useDebounce, useSearch, useNotificationActions }; diff --git a/src/advanced/shared/hooks/useDebounce.ts b/src/advanced/shared/hooks/useDebounce.ts new file mode 100644 index 00000000..9b9256b0 --- /dev/null +++ b/src/advanced/shared/hooks/useDebounce.ts @@ -0,0 +1,23 @@ +import { useState, useEffect } from 'react'; + +/** + * 디바운스 Hook + * 값이 변경되어도 지정된 시간 동안 대기하고, 최종적으로 안정된 값만 반환 + * + * @param value - 디바운스할 값 + * @param delay - 지연 시간 (밀리초) + * @returns 디바운스된 값 + */ +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/advanced/shared/hooks/useLocalStorage.ts b/src/advanced/shared/hooks/useLocalStorage.ts new file mode 100644 index 00000000..ee618b3d --- /dev/null +++ b/src/advanced/shared/hooks/useLocalStorage.ts @@ -0,0 +1,72 @@ +import { useState, useEffect, useCallback } from 'react'; + +/** + * LocalStorage 훅 구현 + * @param key - localStorage에 저장할 키 + * @param initialValue - 초기값 + * @returns [저장된 값, 값 설정 함수] + */ +export function useLocalStorage(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] { + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(`localStorage에서 ${key}를 읽는 중 오류 발생:`, error); + return initialValue; + } + }); + + /** + * 빈 배열이나 undefined인지 확인 + * @param value - 저장할 값 + * @returns 빈 배열이나 undefined인지 여부 + */ + const isEmpty = (value: T): boolean => { + if (value === null || value === undefined) return true; + if (Array.isArray(value) && value.length === 0) return true; + return false; + }; + + /** + * localStorage에 값 저장 + * @param value - 저장할 값 + */ + const persistToStorage = useCallback( + (value: T): void => { + try { + if (isEmpty(value)) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(value)); + } + } catch (error) { + console.warn(`localStorage에 ${key}를 저장하는 중 오류 발생:`, error); + } + }, + [key], + ); + + /** + * 상태 업데이트 함수 - 함수형 업데이트 지원 + * @param value - 저장할 값 + */ + const setValue = useCallback((value: T | ((val: T) => T)): void => { + setStoredValue((prevValue) => { + // 함수형 업데이트인 경우 현재 값을 인자로 전달 + const newValue = typeof value === 'function' ? (value as (val: T) => T)(prevValue) : value; + + // localStorage에 저장 (side effect를 명시적으로 분리) + setStoredValue(newValue); + + return newValue; + }); + }, []); + + // localStorage와 React state 동기화 + useEffect(() => { + persistToStorage(storedValue); + }, [storedValue, persistToStorage]); + + return [storedValue, setValue]; +} diff --git a/src/advanced/shared/hooks/useNotificationActions.ts b/src/advanced/shared/hooks/useNotificationActions.ts new file mode 100644 index 00000000..f99ca9bd --- /dev/null +++ b/src/advanced/shared/hooks/useNotificationActions.ts @@ -0,0 +1,15 @@ +import { useAtom } from 'jotai'; +import { addNotificationAtom, removeNotificationAtom } from '../../atoms/notificationAtoms'; + +/** + * 알림 액션을 위한 hook + */ +export function useNotificationActions() { + const [, addNotification] = useAtom(addNotificationAtom); + const [, removeNotification] = useAtom(removeNotificationAtom); + + return { + addNotification, + removeNotification, + }; +} diff --git a/src/advanced/shared/hooks/useSearch.ts b/src/advanced/shared/hooks/useSearch.ts new file mode 100644 index 00000000..1c5d6dac --- /dev/null +++ b/src/advanced/shared/hooks/useSearch.ts @@ -0,0 +1,37 @@ +import { useState } from 'react'; +import { useDebounce } from './useDebounce'; +import { SEARCH_DELAY } from '../constants/toast'; + +/** + * 검색 기능을 위한 커스텀 훅 + * @param delay - 디바운스 지연 시간 (기본값: 500ms) + * @returns 검색 관련 상태와 함수들 + */ +export function useSearch(delay: number = SEARCH_DELAY) { + const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearchTerm = useDebounce(searchTerm, delay); + + const handleSearchTermChange = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value); + }; + + /** + * 검색어 초기화 + */ + const clearSearch = () => { + setSearchTerm(''); + }; + + /** + * 검색어가 있는지 확인 + */ + const hasSearchTerm = debouncedSearchTerm.trim().length > 0; + + return { + searchTerm, + handleSearchTermChange, + debouncedSearchTerm, + clearSearch, + hasSearchTerm, + }; +} diff --git a/src/advanced/shared/types/index.ts b/src/advanced/shared/types/index.ts new file mode 100644 index 00000000..1fea582d --- /dev/null +++ b/src/advanced/shared/types/index.ts @@ -0,0 +1,14 @@ +import { Product } from '../../../types'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export type { ProductWithUI, Notification }; diff --git a/src/advanced/shared/utils/formatters.ts b/src/advanced/shared/utils/formatters.ts new file mode 100644 index 00000000..42f8bc17 --- /dev/null +++ b/src/advanced/shared/utils/formatters.ts @@ -0,0 +1,15 @@ +/** + * 가격을 한국 원화 형식으로 포맷 + * @param price - 포맷할 가격 (숫자) + * @returns 포맷된 가격 문자열 (예: "10,000원") + * @example formatKoreanPrice(10000) // "10,000원" + */ +export const formatKoreanPrice = (price: number): string => `${price.toLocaleString()}원`; + +/** + * 소수를 퍼센트로 변환하여 포맷 + * @param rate - 변환할 소수 (0.1 = 10%) + * @returns 퍼센트 문자열 (예: "10%") + * @example formatPercentage(0.1) // "10%" + */ +export const formatPercentage = (rate: number): string => `${Math.round(rate * 100)}%`; diff --git a/src/advanced/shared/utils/index.ts b/src/advanced/shared/utils/index.ts new file mode 100644 index 00000000..ca032fb6 --- /dev/null +++ b/src/advanced/shared/utils/index.ts @@ -0,0 +1,2 @@ +export * from './formatters'; +export * from './validators'; diff --git a/src/advanced/shared/utils/validators.ts b/src/advanced/shared/utils/validators.ts new file mode 100644 index 00000000..603a74ee --- /dev/null +++ b/src/advanced/shared/utils/validators.ts @@ -0,0 +1,97 @@ +import { DISCOUNT } from '../../constants/discount'; +import { STOCK } from '../../constants/product'; +import { MESSAGES } from '../../constants/message'; + +export const validator = { + /** + * 문자열 전체가 숫자인지 확인 + * @param value - 검증할 문자열 + * @returns 숫자 문자열 또는 null + */ + validateNumericString: (value: string): string | null => { + if (value === '') return ''; + if (/^\d+$/.test(value)) return value; + + return null; + }, + + /** + * 재고 수량 검증 (0 ~ 9999) + * @param stock - 검증할 재고 수량 + * @returns 검증 결과 객체 + */ + isValidStock: (stock: number): { isValid: boolean; message: string; correctedValue: number } => { + if (stock <= 0) + return { + isValid: false, + message: MESSAGES.STOCK.MIN, + correctedValue: 0, + }; + if (stock > STOCK.MAX_QUANTITY) + return { + isValid: false, + message: MESSAGES.STOCK.MAX, + correctedValue: STOCK.MAX_QUANTITY, + }; + + return { isValid: true, message: '', correctedValue: stock }; + }, + + /** + * 가격 검증 (0 이상) + * @param price - 검증할 가격 + * @returns 검증 결과 객체 + */ + isValidPrice: (price: number): { isValid: boolean; message: string; correctedValue: number } => { + if (price <= 0) + return { + isValid: false, + message: MESSAGES.PRICE.MIN, + correctedValue: 0, + }; + + return { isValid: true, message: '', correctedValue: price }; + }, + + /** + * 쿠폰 할인율 검증 (0 ~ 100) + * @param value - 검증할 쿠폰 할인율 + * @returns 검증 결과 객체 + */ + isValidDiscountPercentage: (value: number): { isValid: boolean; message: string; correctedValue: number } => { + if (value < 0) + return { + isValid: false, + message: MESSAGES.DISCOUNT_PERCENTAGE.MIN, + correctedValue: 0, + }; + if (value > DISCOUNT.MAX_PERCENTAGE) + return { + isValid: false, + message: MESSAGES.DISCOUNT_PERCENTAGE.MAX, + correctedValue: DISCOUNT.MAX_PERCENTAGE, + }; + return { isValid: true, message: '', correctedValue: value }; + }, + + /** + * 쿠폰 할인 금액 검증 (0 ~ 100000) + * @param value - 검증할 쿠폰 할인 금액 + * @returns 검증 결과 객체 + */ + isValidDiscountAmount: (value: number): { isValid: boolean; message: string; correctedValue: number } => { + if (value < 0) + return { + isValid: false, + message: MESSAGES.DISCOUNT_AMOUNT.MIN, + correctedValue: 0, + }; + if (value > DISCOUNT.MAX_COUPON_AMOUNT) + return { + isValid: false, + message: MESSAGES.DISCOUNT_AMOUNT.MAX, + correctedValue: DISCOUNT.MAX_COUPON_AMOUNT, + }; + return { isValid: true, message: '', correctedValue: value }; + }, +}; diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1..8a56b5c2 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,226 +1,52 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +import { useState, useEffect } from 'react'; +import { useCart } from './hooks/useCart'; +import { useCoupons } from './hooks/useCoupons'; +import { useProducts } from './hooks/useProducts'; +import { useNotification } from './hooks/useNotification'; +import { useProductSearch } from './hooks/useProductSearch'; +import { SEARCH_DELAY } from './shared/constants/toast'; +import { ToastContainer } from './components/ui/UIToast'; +import Header from './components/Header'; +import { AdminPage } from './pages/AdminPage'; +import { CartPage } from './pages/CartPage'; const App = () => { + const { + cart, + selectedCoupon, + selectCoupon, + addToCart: addToCartHook, + removeFromCart, + updateQuantity: updateQuantityHook, + applyCoupon: applyCouponHook, + calculateTotal, + getRemainingStock, + clearCart, + } = useCart(); + + const { + products, + addProduct: addProductHook, + updateProduct: updateProductHook, + deleteProduct: deleteProductHook, + } = useProducts(); + + const { coupons, addCoupon: addCouponHook, deleteCoupon: deleteCouponHook } = useCoupons(); + + const { notifications, addNotification, removeNotification } = useNotification(); + + const { searchTerm, handleSearchTermChange, filteredProducts } = useProductSearch(products, SEARCH_DELAY); - 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]); @@ -237,888 +63,55 @@ const App = () => { } }, [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 +export default App; diff --git a/src/basic/__tests__/origin.test.tsx b/src/basic/__tests__/origin.test.tsx index 3f5c3d55..7c78d414 100644 --- a/src/basic/__tests__/origin.test.tsx +++ b/src/basic/__tests__/origin.test.tsx @@ -20,25 +20,28 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('고객 쇼핑 플로우', () => { test('상품을 검색하고 장바구니에 추가할 수 있다', async () => { render(); - + // 검색창에 "프리미엄" 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 디바운스 대기 - await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - }, { timeout: 600 }); - + await waitFor( + () => { + expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); + }, + { timeout: 600 }, + ); + // 검색된 상품을 장바구니에 추가 (첫 번째 버튼 선택) const addButtons = screen.getAllByText('장바구니 담기'); fireEvent.click(addButtons[0]); - + // 알림 메시지 확인 await waitFor(() => { expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); }); - + // 장바구니에 추가됨 확인 (장바구니 섹션에서) const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -46,34 +49,34 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { render(); - + // 상품1을 장바구니에 추가 const product1 = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(product1); - + // 수량을 10개로 증가 (10% 할인 적용) const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 9; i++) { fireEvent.click(plusButton); } - + // 10% 할인 적용 확인 - 15% (대량 구매 시 추가 5% 포함) expect(screen.getByText('-15%')).toBeInTheDocument(); }); test('쿠폰을 선택하고 적용할 수 있다', () => { render(); - + // 상품 추가 const addButton = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(addButton); - + // 쿠폰 선택 const couponSelect = screen.getByRole('combobox'); fireEvent.change(couponSelect, { target: { value: 'AMOUNT5000' } }); - + // 결제 정보에서 할인 금액 확인 const paymentSection = screen.getByText('결제 정보').closest('section'); const discountRow = within(paymentSection).getByText('할인 금액').closest('div'); @@ -82,28 +85,28 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('품절 임박 상품에 경고가 표시된다', async () => { render(); - + // 관리자 모드로 전환 fireEvent.click(screen.getByText('관리자 페이지로')); - + // 상품 수정 const editButton = screen.getAllByText('수정')[0]; fireEvent.click(editButton); - + // 재고를 5개로 변경 const stockInputs = screen.getAllByPlaceholderText('숫자만 입력'); const stockInput = stockInputs[1]; // 재고 입력 필드는 두 번째 fireEvent.change(stockInput, { target: { value: '5' } }); fireEvent.blur(stockInput); - + // 수정 완료 버튼 클릭 const editButtons = screen.getAllByText('수정'); const completeEditButton = editButtons[editButtons.length - 1]; // 마지막 수정 버튼 (완료 버튼) fireEvent.click(completeEditButton); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 품절임박 메시지 확인 - 재고가 5개 이하면 품절임박 표시 await waitFor(() => { expect(screen.getByText('품절임박! 5개 남음')).toBeInTheDocument(); @@ -112,39 +115,39 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('주문을 완료할 수 있다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 결제하기 버튼 클릭 const orderButton = screen.getByText(/원 결제하기/); fireEvent.click(orderButton); - + // 주문 완료 알림 확인 expect(screen.getByText(/주문이 완료되었습니다/)).toBeInTheDocument(); - + // 장바구니가 비어있는지 확인 expect(screen.getByText('장바구니가 비어있습니다')).toBeInTheDocument(); }); test('장바구니에서 상품을 삭제할 수 있다', () => { render(); - + // 상품 2개 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 장바구니 섹션 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); - + // 첫 번째 상품 삭제 (X 버튼) - const deleteButtons = within(cartSection).getAllByRole('button').filter( - button => button.querySelector('svg') - ); + const deleteButtons = within(cartSection) + .getAllByRole('button') + .filter((button) => button.querySelector('svg')); fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되고 상품2만 남음 expect(within(cartSection).queryByText('상품1')).not.toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); @@ -152,25 +155,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('재고를 초과하여 구매할 수 없다', async () => { render(); - + // 상품1 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 재고(20개) 이상으로 증가 시도 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + // 19번 클릭하여 총 20개로 만듦 for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 한 번 더 클릭 시도 (21개가 되려고 함) fireEvent.click(plusButton); - + // 수량이 20개에서 멈춰있어야 함 expect(within(cartSection).getByText('20')).toBeInTheDocument(); - + // 재고 부족 메시지 확인 await waitFor(() => { expect(screen.getByText(/재고는.*개까지만 있습니다/)).toBeInTheDocument(); @@ -179,27 +182,27 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니에서 수량을 감소시킬 수 있다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); const minusButton = within(cartSection).getByText('−'); // U+2212 마이너스 기호 - + // 수량 3개로 증가 fireEvent.click(plusButton); fireEvent.click(plusButton); expect(within(cartSection).getByText('3')).toBeInTheDocument(); - + // 수량 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('2')).toBeInTheDocument(); - + // 1개로 더 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('1')).toBeInTheDocument(); - + // 1개에서 한 번 더 감소하면 장바구니에서 제거될 수도 있음 fireEvent.click(minusButton); // 장바구니가 비었는지 확인 @@ -214,31 +217,31 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('20개 이상 구매 시 최대 할인이 적용된다', async () => { render(); - + // 관리자 모드로 전환하여 상품1의 재고를 늘림 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getAllByText('수정')[0]); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '30' } }); - + const editButtons = screen.getAllByText('수정'); fireEvent.click(editButtons[editButtons.length - 1]); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 상품1을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 20개로 증가 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 25% 할인 적용 확인 (또는 대량 구매 시 30%) await waitFor(() => { const discount25 = screen.queryByText('-25%'); @@ -258,27 +261,27 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('새 상품을 추가할 수 있다', () => { // 새 상품 추가 버튼 클릭 fireEvent.click(screen.getByText('새 상품 추가')); - + // 폼 입력 - 상품명 입력 const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const nameLabel = labels.find((el) => el.tagName === 'LABEL'); const nameInput = nameLabel.closest('div').querySelector('input'); fireEvent.change(nameInput, { target: { value: '테스트 상품' } }); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; fireEvent.change(priceInput, { target: { value: '25000' } }); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '50' } }); - + const descLabels = screen.getAllByText('설명'); - const descLabel = descLabels.find(el => el.tagName === 'LABEL'); + const descLabel = descLabels.find((el) => el.tagName === 'LABEL'); const descInput = descLabel.closest('div').querySelector('input'); fireEvent.change(descInput, { target: { value: '테스트 설명' } }); - + // 저장 fireEvent.click(screen.getByText('추가')); - + // 추가된 상품 확인 expect(screen.getByText('테스트 상품')).toBeInTheDocument(); expect(screen.getByText('25,000원')).toBeInTheDocument(); @@ -287,21 +290,21 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 버튼 클릭 const addCouponButton = screen.getByText('새 쿠폰 추가'); fireEvent.click(addCouponButton); - + // 쿠폰 정보 입력 fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { target: { value: '테스트 쿠폰' } }); fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { target: { value: 'TEST2024' } }); - + const discountInput = screen.getByPlaceholderText('5000'); fireEvent.change(discountInput, { target: { value: '7000' } }); - + // 쿠폰 생성 fireEvent.click(screen.getByText('쿠폰 생성')); - + // 생성된 쿠폰 확인 expect(screen.getByText('테스트 쿠폰')).toBeInTheDocument(); expect(screen.getByText('TEST2024')).toBeInTheDocument(); @@ -311,25 +314,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('상품의 가격 입력 시 숫자만 허용된다', async () => { // 상품 수정 fireEvent.click(screen.getAllByText('수정')[0]); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - + // 문자와 숫자 혼합 입력 시도 - 숫자만 남음 fireEvent.change(priceInput, { target: { value: 'abc123def' } }); expect(priceInput.value).toBe('10000'); // 유효하지 않은 입력은 무시됨 - + // 숫자만 입력 fireEvent.change(priceInput, { target: { value: '123' } }); expect(priceInput.value).toBe('123'); - + // 음수 입력 시도 - regex가 매치되지 않아 값이 변경되지 않음 fireEvent.change(priceInput, { target: { value: '-100' } }); expect(priceInput.value).toBe('123'); // 이전 값 유지 - + // 유효한 음수 입력하기 위해 먼저 1 입력 후 앞에 - 추가는 불가능 // 대신 blur 이벤트를 통해 음수 검증을 테스트 // parseInt()는 실제로 음수를 파싱할 수 있으므로 다른 방법으로 테스트 - + // 공백 입력 시도 fireEvent.change(priceInput, { target: { value: ' ' } }); expect(priceInput.value).toBe('123'); // 유효하지 않은 입력은 무시됨 @@ -338,20 +341,20 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 할인율 검증이 작동한다', async () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 fireEvent.click(screen.getByText('새 쿠폰 추가')); - + // 퍼센트 타입으로 변경 - 쿠폰 폼 내의 select 찾기 const couponFormSelects = screen.getAllByRole('combobox'); const typeSelect = couponFormSelects[couponFormSelects.length - 1]; // 마지막 select가 타입 선택 fireEvent.change(typeSelect, { target: { value: 'percentage' } }); - + // 100% 초과 할인율 입력 const discountInput = screen.getByPlaceholderText('10'); fireEvent.change(discountInput, { target: { value: '150' } }); fireEvent.blur(discountInput); - + // 에러 메시지 확인 await waitFor(() => { expect(screen.getByText('할인율은 100%를 초과할 수 없습니다')).toBeInTheDocument(); @@ -362,15 +365,15 @@ describe('쇼핑몰 앱 통합 테스트', () => { // 초기 상품명들 확인 (테이블에서) const productTable = screen.getByRole('table'); expect(within(productTable).getByText('상품1')).toBeInTheDocument(); - + // 삭제 버튼들 찾기 - const deleteButtons = within(productTable).getAllByRole('button').filter( - button => button.textContent === '삭제' - ); - + const deleteButtons = within(productTable) + .getAllByRole('button') + .filter((button) => button.textContent === '삭제'); + // 첫 번째 상품 삭제 fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되었는지 확인 expect(within(productTable).queryByText('상품1')).not.toBeInTheDocument(); expect(within(productTable).getByText('상품2')).toBeInTheDocument(); @@ -379,76 +382,74 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰을 삭제할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 초기 쿠폰들 확인 (h3 제목에서) const couponTitles = screen.getAllByRole('heading', { level: 3 }); - const coupon5000 = couponTitles.find(el => el.textContent === '5000원 할인'); - const coupon10 = couponTitles.find(el => el.textContent === '10% 할인'); + const coupon5000 = couponTitles.find((el) => el.textContent === '5000원 할인'); + const coupon10 = couponTitles.find((el) => el.textContent === '10% 할인'); expect(coupon5000).toBeInTheDocument(); expect(coupon10).toBeInTheDocument(); - + // 삭제 버튼 찾기 (SVG 아이콘을 포함한 버튼) - const deleteButtons = screen.getAllByRole('button').filter(button => { - return button.querySelector('svg') && - button.querySelector('path[d*="M19 7l"]'); // 삭제 아이콘 path + const deleteButtons = screen.getAllByRole('button').filter((button) => { + return button.querySelector('svg') && button.querySelector('path[d*="M19 7l"]'); // 삭제 아이콘 path }); - + // 첫 번째 쿠폰 삭제 fireEvent.click(deleteButtons[0]); - + // 쿠폰이 삭제되었는지 확인 expect(screen.queryByText('5000원 할인')).not.toBeInTheDocument(); }); - }); describe('로컬스토리지 동기화', () => { test('상품, 장바구니, 쿠폰이 localStorage에 저장된다', () => { render(); - + // 상품을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // localStorage 확인 expect(localStorage.getItem('cart')).toBeTruthy(); expect(JSON.parse(localStorage.getItem('cart'))).toHaveLength(1); - + // 관리자 모드로 전환하여 새 상품 추가 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getByText('새 상품 추가')); - + const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const nameLabel = labels.find((el) => el.tagName === 'LABEL'); const nameInput = nameLabel.closest('div').querySelector('input'); fireEvent.change(nameInput, { target: { value: '저장 테스트' } }); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; fireEvent.change(priceInput, { target: { value: '10000' } }); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '10' } }); - + fireEvent.click(screen.getByText('추가')); - + // localStorage에 products가 저장되었는지 확인 expect(localStorage.getItem('products')).toBeTruthy(); const products = JSON.parse(localStorage.getItem('products')); - expect(products.some(p => p.name === '저장 테스트')).toBe(true); + expect(products.some((p) => p.name === '저장 테스트')).toBe(true); }); test('페이지 새로고침 후에도 데이터가 유지된다', () => { const { unmount } = render(); - + // 장바구니에 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 컴포넌트 unmount unmount(); - + // 다시 mount render(); - + // 장바구니 아이템이 유지되는지 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -459,13 +460,13 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('UI 상태 관리', () => { test('할인이 있을 때 할인율이 표시된다', async () => { render(); - + // 상품을 10개 담아서 할인 발생 const addButton = screen.getAllByText('장바구니 담기')[0]; for (let i = 0; i < 10; i++) { fireEvent.click(addButton); } - + // 할인율 표시 확인 - 대량 구매로 15% 할인 await waitFor(() => { expect(screen.getByText('-15%')).toBeInTheDocument(); @@ -474,12 +475,12 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니 아이템 개수가 헤더에 표시된다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 헤더의 장바구니 아이콘 옆 숫자 확인 const cartCount = screen.getByText('3'); expect(cartCount).toBeInTheDocument(); @@ -487,21 +488,21 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('검색을 초기화할 수 있다', async () => { render(); - + // 검색어 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 검색 결과 확인 await waitFor(() => { expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); // 다른 상품들은 보이지 않음 expect(screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.')).not.toBeInTheDocument(); }); - + // 검색어 초기화 fireEvent.change(searchInput, { target: { value: '' } }); - + // 모든 상품이 다시 표시됨 await waitFor(() => { expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); @@ -512,17 +513,20 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('알림 메시지가 자동으로 사라진다', async () => { render(); - + // 상품 추가하여 알림 발생 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 알림 메시지 확인 expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); - + // 3초 후 알림이 사라짐 - await waitFor(() => { - expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); - }, { timeout: 4000 }); + await waitFor( + () => { + expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); + }, + { timeout: 4000 }, + ); }); }); -}); \ No newline at end of file +}); diff --git a/src/basic/components/Header.tsx b/src/basic/components/Header.tsx new file mode 100644 index 00000000..fb8ee271 --- /dev/null +++ b/src/basic/components/Header.tsx @@ -0,0 +1,49 @@ +import type { CartItem as CartItemType } from '../../types'; +import { SearchBar } from './cart/SearchBar'; +import { CartIcon } from './icons'; + +interface HeaderProps { + isAdmin: boolean; + searchTerm: string; + handleSearchTermChange: (e: React.ChangeEvent) => void; + cart: CartItemType[]; + totalItemCount: number; + setIsAdmin: (value: boolean) => void; +} + +const Header = ({ isAdmin, searchTerm, handleSearchTermChange, cart, totalItemCount, setIsAdmin }: HeaderProps) => { + return ( +
+
+
+
+

SHOP

+ {!isAdmin && } +
+ +
+
+
+ ); +}; + +export default Header; diff --git a/src/basic/components/admin/CouponForm.tsx b/src/basic/components/admin/CouponForm.tsx new file mode 100644 index 00000000..83d8b9a7 --- /dev/null +++ b/src/basic/components/admin/CouponForm.tsx @@ -0,0 +1,97 @@ +import type { CouponFormData as CouponFormDataType } from '../../../types'; + +interface CouponFormProps { + couponForm: CouponFormDataType; + updateName: (name: string) => void; + updateCode: (code: string) => void; + updateDiscountType: (discountType: 'amount' | 'percentage') => void; + updateDiscountValue: (value: string) => void; + validateDiscountValue: (value: string, onError: (message: string) => void) => void; + handleCouponSubmit: (e: React.FormEvent) => void; + addNotification: (message: string, type: 'success' | 'error' | 'warning') => void; + toggleCouponForm: () => void; +} + +export function CouponForm({ + couponForm, + updateName, + updateCode, + updateDiscountType, + updateDiscountValue, + validateDiscountValue, + handleCouponSubmit, + addNotification, + toggleCouponForm, +}: CouponFormProps) { + return ( +
+
+

새 쿠폰 생성

+
+
+ + updateName(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 + /> +
+
+ + updateCode(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 font-mono" + placeholder="WELCOME2024" + required + /> +
+
+ + +
+
+ + updateDiscountValue(e.target.value)} + onBlur={(e) => validateDiscountValue(e.target.value, (message) => addNotification(message, 'error'))} + 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 + /> +
+
+
+ + +
+
+
+ ); +} diff --git a/src/basic/components/admin/CouponList.tsx b/src/basic/components/admin/CouponList.tsx new file mode 100644 index 00000000..3b97e14b --- /dev/null +++ b/src/basic/components/admin/CouponList.tsx @@ -0,0 +1,55 @@ +import { formatKoreanPrice } from '../../shared/utils/formatters'; +import { formatPercentage } from '../../shared/utils/formatters'; +import type { Coupon as CouponType } from '../../../types'; +import { TrashIcon, PlusIcon } from '../icons'; + +interface CouponListProps { + coupons: CouponType[]; + deleteCoupon: (code: string) => void; + toggleCouponForm: () => void; +} + +function CouponList({ coupons, deleteCoupon, toggleCouponForm }: CouponListProps) { + return ( +
+ {coupons.map((coupon) => ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === 'amount' + ? `${formatKoreanPrice(coupon.discountValue)} 할인` + : `${formatPercentage(coupon.discountValue / 100)} 할인`} + +
+
+ +
+
+ ))} + +
+ +
+
+ ); +} + +export default CouponList; diff --git a/src/basic/components/admin/ProductForm.tsx b/src/basic/components/admin/ProductForm.tsx new file mode 100644 index 00000000..7ec33cea --- /dev/null +++ b/src/basic/components/admin/ProductForm.tsx @@ -0,0 +1,158 @@ +import type { ProductFormData as ProductFormDataType } from '../../../types'; +import { CloseIcon } from '../icons'; + +interface ProductFormProps { + editingProduct: string | null; + productForm: ProductFormDataType; + updateProductName: (name: string) => void; + updateDescription: (description: string) => void; + updatePrice: (price: string) => void; + updateStock: (stock: string) => void; + validatePriceValue: (price: string, callback: (message: string) => void) => void; + validateStockValue: (stock: string, callback: (message: string) => void) => void; + addDiscountPolicy: () => void; + removeDiscountPolicy: (index: number) => void; + updateDiscountQuantity: (index: number, quantity: number) => void; + updateDiscountRate: (index: number, rate: number) => void; + handleCancelProductForm: () => void; + handleProductSubmit: (e: React.FormEvent) => void; + addNotification: (message: string, type: 'success' | 'error' | 'warning') => void; +} + +export function ProductForm({ + editingProduct, + productForm, + updateProductName, + updateDescription, + updatePrice, + updateStock, + validatePriceValue, + validateStockValue, + addDiscountPolicy, + removeDiscountPolicy, + updateDiscountQuantity, + updateDiscountRate, + handleCancelProductForm, + handleProductSubmit, + addNotification, +}: ProductFormProps) { + return ( +
+
+

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

+ +
+ {/* 상품명 입력 */} +
+ + updateProductName(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 + /> +
+ + {/* 설명 입력 */} +
+ + updateDescription(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" + /> +
+ + {/* 가격 입력 */} +
+ + updatePrice(e.target.value)} + onBlur={(e) => validatePriceValue(e.target.value, (message) => addNotification(message, 'error'))} + 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 + /> +
+ + {/* 재고 입력 */} +
+ + updateStock(e.target.value)} + onBlur={(e) => validateStockValue(e.target.value, (message) => addNotification(message, 'error'))} + 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) => ( +
+ updateDiscountQuantity(index, parseInt(e.target.value) || 0)} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + updateDiscountRate(index, (parseInt(e.target.value) || 0) / 100)} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ + {/* 폼 버튼들 */} +
+ + +
+
+
+ ); +} + +export default ProductForm; diff --git a/src/basic/components/admin/ProductList.tsx b/src/basic/components/admin/ProductList.tsx new file mode 100644 index 00000000..867e4058 --- /dev/null +++ b/src/basic/components/admin/ProductList.tsx @@ -0,0 +1,60 @@ +import type { ProductWithUI } from '../../shared/types'; +import { formatKoreanPrice } from '../../shared/utils'; + +interface ProductListProps { + products: ProductWithUI[]; + activeTab: 'products' | 'coupons'; + handleStartEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; +} + +export function ProductList({ products, activeTab, handleStartEditProduct, deleteProduct }: ProductListProps) { + return ( +
+ + + + + + + + + + + + {(activeTab === 'products' ? products : products).map((product) => ( + + + + + + + + ))} + +
상품명가격재고설명작업
{product.name}{formatKoreanPrice(product.price)} + 10 + ? 'bg-green-100 text-green-800' + : product.stock > 0 + ? 'bg-yellow-100 text-yellow-800' + : 'bg-red-100 text-red-800' + }`} + > + {product.stock}개 + + {product.description || '-'} + + +
+
+ ); +} diff --git a/src/basic/components/cart/Cart.tsx b/src/basic/components/cart/Cart.tsx new file mode 100644 index 00000000..a6bd4347 --- /dev/null +++ b/src/basic/components/cart/Cart.tsx @@ -0,0 +1,71 @@ +import type { ProductWithUI } from '../../shared/types'; +import { formatKoreanPrice, formatPercentage } from '../../shared/utils'; +import { CloseIcon, SmallBagIcon, LargeBagIcon } from '../icons'; + +interface CartProps { + cart: Array<{ product: ProductWithUI; quantity: number }>; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + calculateTotal: () => { totalBeforeDiscount: number; totalAfterDiscount: number }; +} + +export function Cart({ cart, removeFromCart, updateQuantity, calculateTotal }: CartProps) { + return ( +
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => { + const itemTotal = calculateTotal().totalAfterDiscount; + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount ? formatPercentage(1 - itemTotal / originalPrice) : 0; + + return ( +
+
+

{item.product.name}

+ +
+
+
+ + {item.quantity} + +
+
+ {hasDiscount && -{discountRate}} +

{formatKoreanPrice(itemTotal)}

+
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/basic/components/cart/Coupon.tsx b/src/basic/components/cart/Coupon.tsx new file mode 100644 index 00000000..b7ee9803 --- /dev/null +++ b/src/basic/components/cart/Coupon.tsx @@ -0,0 +1,44 @@ +import type { Coupon as CouponType } from '../../../types'; +import { formatKoreanPrice } from '../../shared/utils'; + +interface CouponProps { + coupons: CouponType[]; + selectedCoupon: CouponType | null; + selectCoupon: (coupon: CouponType | null) => void; + applyCoupon: (coupon: CouponType) => void; +} + +const Coupon = ({ coupons, selectedCoupon, selectCoupon, applyCoupon }: CouponProps) => { + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +}; + +export default Coupon; diff --git a/src/basic/components/cart/Payment.tsx b/src/basic/components/cart/Payment.tsx new file mode 100644 index 00000000..d17f8e19 --- /dev/null +++ b/src/basic/components/cart/Payment.tsx @@ -0,0 +1,43 @@ +import { formatKoreanPrice } from '../../shared/utils'; + +interface PaymentProps { + totals: { totalBeforeDiscount: number; totalAfterDiscount: number }; + completeOrder: () => void; +} + +const Payment = ({ totals, completeOrder }: PaymentProps) => { + return ( +
+

결제 정보

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

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

+
+
+ ); +}; + +export default Payment; diff --git a/src/basic/components/cart/ProductList.tsx b/src/basic/components/cart/ProductList.tsx new file mode 100644 index 00000000..a21d638f --- /dev/null +++ b/src/basic/components/cart/ProductList.tsx @@ -0,0 +1,105 @@ +import type { ProductWithUI } from '../../shared/types'; +import { STOCK } from '../../constants/product'; +import { MESSAGES } from '../../constants/message'; +import { ImageIcon } from '../icons'; + +interface ProductListProps { + products: ProductWithUI[]; + searchTerm: string; + filteredProducts: ProductWithUI[]; + formatPrice: (price: number, productId?: string) => string; + getRemainingStock: (product: ProductWithUI) => number; + addToCart: (product: ProductWithUI) => void; +} + +export function ProductList({ + products, + searchTerm, + filteredProducts, + formatPrice, + getRemainingStock, + addToCart, +}: ProductListProps) { + return ( +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {filteredProducts.map((product) => { + const remainingStock = getRemainingStock(product); + + return ( +
+ {/* 상품 이미지 영역 */} +
+
+ +
+ {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 <= STOCK.LOW_THRESHOLD && remainingStock > 0 && ( +

{MESSAGES.PRODUCT.LOW_STOCK(remainingStock)}

+ )} + {remainingStock > STOCK.LOW_THRESHOLD && ( +

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/basic/components/cart/SearchBar.tsx b/src/basic/components/cart/SearchBar.tsx new file mode 100644 index 00000000..802cddfb --- /dev/null +++ b/src/basic/components/cart/SearchBar.tsx @@ -0,0 +1,18 @@ +interface SearchBarProps { + searchTerm: string; + handleSearchTermChange: (e: React.ChangeEvent) => void; +} + +export function SearchBar({ searchTerm, handleSearchTermChange }: SearchBarProps) { + return ( +
+ +
+ ); +} diff --git a/src/basic/components/icons/index.tsx b/src/basic/components/icons/index.tsx new file mode 100644 index 00000000..6fdb185b --- /dev/null +++ b/src/basic/components/icons/index.tsx @@ -0,0 +1,63 @@ +// x 버튼 아이콘 +export const CloseIcon = () => ( + + + +); + +// 카트 아이콘 +export const CartIcon = () => ( + + + +); + +// 휴지통 아이콘 +export const TrashIcon = () => ( + + + +); + +// + 버튼 아이콘 +export const PlusIcon = () => ( + + + +); + +// 이미지 아이콘 +export const ImageIcon = () => ( + + + +); + +// 장바구니 아이콘 (small) +export const SmallBagIcon = () => ( + + + +); + +// 장바구니 아이콘 (large) +export const LargeBagIcon = () => ( + + + +); diff --git a/src/basic/components/ui/UIToast/constants.ts b/src/basic/components/ui/UIToast/constants.ts new file mode 100644 index 00000000..25acbbfd --- /dev/null +++ b/src/basic/components/ui/UIToast/constants.ts @@ -0,0 +1,44 @@ +/** + * 토스트 설정 기본값 + */ +export const TOAST_DEFAULTS = { + position: 'top-right' as const, + maxWidth: 'max-w-sm', + spacing: 'space-y-2', + zIndex: 'z-50', +} as const; + +/** + * 토스트 애니메이션 클래스 (Tailwind CSS 기반) + */ +export const TOAST_ANIMATION_CLASSES = { + enter: 'transition-all duration-300 ease-in-out transform translate-x-0 opacity-100', + exit: 'transition-all duration-300 ease-in-out transform translate-x-full opacity-0', + hover: 'hover:shadow-lg hover:scale-105', +} as const; + +/** + * 토스트 테마 색상 + */ +export const TOAST_THEMES = { + success: { + background: 'bg-green-600', + text: 'text-white', + icon: '✓', + }, + error: { + background: 'bg-red-600', + text: 'text-white', + icon: '✕', + }, + warning: { + background: 'bg-yellow-600', + text: 'text-white', + icon: '⚠', + }, + info: { + background: 'bg-blue-600', + text: 'text-white', + icon: 'ℹ', + }, +} as const; diff --git a/src/basic/components/ui/UIToast/helper.ts b/src/basic/components/ui/UIToast/helper.ts new file mode 100644 index 00000000..ae14c018 --- /dev/null +++ b/src/basic/components/ui/UIToast/helper.ts @@ -0,0 +1,30 @@ +import type { Notification } from '../../../shared/types'; + +/** + * 토스트 알림을 위한 헬퍼 함수들 + */ +export const ToastHelpers = { + /** + * 토스트 알림이 표시되어야 하는지 확인 + * @param notifications - 알림 목록 + * @returns 표시 여부 + */ + shouldShowToasts: (notifications: Notification[]): boolean => notifications.length > 0, + + /** + * 특정 타입의 알림 개수 반환 + * @param notifications - 알림 목록 + * @param type - 알림 타입 + * @returns 해당 타입의 알림 개수 + */ + getNotificationCountByType: (notifications: Notification[], type: Notification['type']): number => + notifications.filter((n) => n.type === type).length, + + /** + * 가장 최근 알림 반환 + * @param notifications - 알림 목록 + * @returns 가장 최근 알림 또는 null + */ + getLatestNotification: (notifications: Notification[]): Notification | null => + notifications.length > 0 ? notifications[notifications.length - 1] : null, +}; diff --git a/src/basic/components/ui/UIToast/index.ts b/src/basic/components/ui/UIToast/index.ts new file mode 100644 index 00000000..54942c58 --- /dev/null +++ b/src/basic/components/ui/UIToast/index.ts @@ -0,0 +1,3 @@ +import { ToastContainer } from './ui'; + +export { ToastContainer }; diff --git a/src/basic/components/ui/UIToast/model.ts b/src/basic/components/ui/UIToast/model.ts new file mode 100644 index 00000000..fa96467b --- /dev/null +++ b/src/basic/components/ui/UIToast/model.ts @@ -0,0 +1,38 @@ +import type { ToastContainerProps } from './type'; +import type { Notification } from '../../../shared/types'; + +/** + * 토스트 알림의 배경색을 결정하는 순수함수 + * @param type - 알림 타입 + * @returns CSS 클래스명 + */ +export const getToastBackgroundClass = (type: Notification['type']): string => { + switch (type) { + case 'error': + return 'bg-red-600'; + case 'warning': + return 'bg-yellow-600'; + case 'success': + default: + return 'bg-green-600'; + } +}; + +/** + * 토스트 컨테이너의 위치 클래스를 반환하는 순수함수 + * @param position - 토스트 위치 + * @returns CSS 위치 클래스명 + */ +export const getToastPositionClass = (position: ToastContainerProps['position'] = 'top-right'): string => { + switch (position) { + case 'top-left': + return 'fixed top-20 left-4 z-50'; + case 'bottom-right': + return 'fixed bottom-4 right-4 z-50'; + case 'bottom-left': + return 'fixed bottom-4 left-4 z-50'; + case 'top-right': + default: + return 'fixed top-20 right-4 z-50'; + } +}; diff --git a/src/basic/components/ui/UIToast/type.ts b/src/basic/components/ui/UIToast/type.ts new file mode 100644 index 00000000..16c4a0c5 --- /dev/null +++ b/src/basic/components/ui/UIToast/type.ts @@ -0,0 +1,27 @@ +import type { Notification } from '../../../shared/types'; + +/** + * 토스트 알림 컨테이너의 props 타입 + * @interface ToastContainerProps + * @property {Notification[]} notifications - 알림 목록 + * @property {(id: string) => void} onRemove - 알림 제거 함수 + * @property {'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'} position - 알림 위치 + * @property {string} className - 추가 클래스명 + */ +export interface ToastContainerProps { + notifications: Notification[]; + onRemove: (id: string) => void; + position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'; + className?: string; +} + +/** + * 개별 토스트 알림의 props 타입 + * @interface ToastItemProps + * @property {Notification} notification - 알림 데이터 + * @property {(id: string) => void} onRemove - 알림 제거 함수 + */ +export interface ToastItemProps { + notification: Notification; + onRemove: (id: string) => void; +} diff --git a/src/basic/components/ui/UIToast/ui.ts b/src/basic/components/ui/UIToast/ui.ts new file mode 100644 index 00000000..3c0447b3 --- /dev/null +++ b/src/basic/components/ui/UIToast/ui.ts @@ -0,0 +1,60 @@ +import React from 'react'; +import type { ToastItemProps, ToastContainerProps } from './type'; +import { getToastBackgroundClass, getToastPositionClass } from './model'; +import { CloseIcon } from '../../icons'; + +/** + * 개별 토스트 알림 아이템 컴포넌트 + */ +export function ToastItem({ notification, onRemove }: ToastItemProps) { + const backgroundClass = getToastBackgroundClass(notification.type); + + return React.createElement( + 'div', + { + key: notification.id, + className: `p-4 rounded-md shadow-md text-white flex justify-between items-center ${backgroundClass}`, + }, + [ + React.createElement('span', { key: 'message', className: 'mr-2' }, notification.message), + React.createElement( + 'button', + { + key: 'close', + onClick: () => onRemove(notification.id), + className: 'text-white hover:text-gray-200', + }, + React.createElement(CloseIcon), + ), + ], + ); +} + +/** + * 토스트 알림 컨테이너 컴포넌트 + */ +export function ToastContainer({ + notifications, + onRemove, + position = 'top-right', + className = '', +}: ToastContainerProps) { + if (notifications.length === 0) { + return null; + } + + const positionClass = getToastPositionClass(position); + const containerClass = `${positionClass} space-y-2 max-w-sm ${className}`.trim(); + + return React.createElement( + 'div', + { className: containerClass }, + notifications.map((notification) => + React.createElement(ToastItem, { + key: notification.id, + notification, + onRemove, + }), + ), + ); +} diff --git a/src/basic/constants/couponForm.ts b/src/basic/constants/couponForm.ts new file mode 100644 index 00000000..9b7a705f --- /dev/null +++ b/src/basic/constants/couponForm.ts @@ -0,0 +1,8 @@ +import type { CouponFormData as CouponFormDataType } from '../../types'; + +export const DEFAULT_COUPON_FORM: CouponFormDataType = { + name: '', + code: '', + discountType: 'amount', + discountValue: 0, +}; diff --git a/src/basic/constants/discount.ts b/src/basic/constants/discount.ts new file mode 100644 index 00000000..c7ac50f3 --- /dev/null +++ b/src/basic/constants/discount.ts @@ -0,0 +1,7 @@ +export const DISCOUNT = { + BULK_THRESHOLD: 10, // 대량 구매 기준 + MAX_RATE: 0.5, // 최대 할인율 50% + MAX_PERCENTAGE: 100, // 최대 할인율 100% + BULK_BONUS: 0.05, // 대량 구매 추가 할인 5% + MAX_COUPON_AMOUNT: 100000, // 최대 쿠폰 할인 금액 +} as const; diff --git a/src/basic/constants/initialData.ts b/src/basic/constants/initialData.ts new file mode 100644 index 00000000..39ad1611 --- /dev/null +++ b/src/basic/constants/initialData.ts @@ -0,0 +1,53 @@ +import type { Coupon } from '../../types'; +import type { ProductWithUI } from '../shared/types'; + +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, + }, +]; + +export { initialProducts, initialCoupons }; diff --git a/src/basic/constants/message.ts b/src/basic/constants/message.ts new file mode 100644 index 00000000..78043ac0 --- /dev/null +++ b/src/basic/constants/message.ts @@ -0,0 +1,45 @@ +export const MESSAGES = { + COUPON: { + ADDED: '쿠폰이 추가되었습니다.', + DELETED: '쿠폰이 삭제되었습니다.', + + APPLIED: '쿠폰이 적용되었습니다.', + ALREADY_EXISTS: '이미 존재하는 쿠폰 코드입니다.', + MIN_PRICE: 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', + }, + + PRODUCT: { + ADDED: '상품이 추가되었습니다.', + UPDATED: '상품이 수정되었습니다.', + DELETED: '상품이 삭제되었습니다.', + + OUT_OF_STOCK: '재고가 부족합니다!', + MAX_STOCK: (maxStock: number) => `재고는 ${maxStock}개까지만 있습니다.`, + ADDED_TO_CART: '장바구니에 담았습니다', + LOW_STOCK: (remainingStock: number) => `품절임박! ${remainingStock}개 남음`, + INVALID_QUANTITY: '수량은 0 이상의 정수여야 합니다.', + }, + + ORDER: { + COMPLETED: (orderNumber: string) => `주문이 완료되었습니다. 주문번호: ${orderNumber}`, + }, + + STOCK: { + MIN: '재고는 0보다 커야 합니다', + MAX: '재고는 9999개를 초과할 수 없습니다', + }, + + PRICE: { + MIN: '가격은 0보다 커야 합니다', + }, + + DISCOUNT_PERCENTAGE: { + MIN: '할인율은 0% 이상이어야 합니다', + MAX: '할인율은 100%를 초과할 수 없습니다', + }, + + DISCOUNT_AMOUNT: { + MIN: '할인 금액은 0원 이상이어야 합니다', + MAX: '할인 금액은 100,000원을 초과할 수 없습니다', + }, +} as const; diff --git a/src/basic/constants/order.ts b/src/basic/constants/order.ts new file mode 100644 index 00000000..ad025c7b --- /dev/null +++ b/src/basic/constants/order.ts @@ -0,0 +1,3 @@ +export const ORDER = { + MIN_FOR_COUPON: 10000, // 퍼센트 쿠폰 사용 최소 금액 +} as const; diff --git a/src/basic/constants/product.ts b/src/basic/constants/product.ts new file mode 100644 index 00000000..4dc27b4c --- /dev/null +++ b/src/basic/constants/product.ts @@ -0,0 +1,5 @@ +export const STOCK = { + LOW_THRESHOLD: 5, // 품절 임박 기준 + MEDIUM_THRESHOLD: 10, // 적정 재고 기준 + MAX_QUANTITY: 9999, // 최대 재고 수량 +} as const; diff --git a/src/basic/constants/productForm.ts b/src/basic/constants/productForm.ts new file mode 100644 index 00000000..bcbb1791 --- /dev/null +++ b/src/basic/constants/productForm.ts @@ -0,0 +1,9 @@ +import type { ProductFormData as ProductFormDataType } from '../../types'; + +export const DEFAULT_PRODUCT_FORM: ProductFormDataType = { + name: '', + price: 0, + stock: 0, + description: '', + discounts: [], +}; diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 00000000..97e2a34a --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,147 @@ +import { useState, useCallback } from 'react'; +import { CartItem, Coupon, Product } from '../../types'; +import { ProductWithUI } from '../shared/types'; +import { useLocalStorage } from '../shared/hooks'; +import * as cartModel from '../models/cart'; +import { ORDER } from '../constants/order'; +import { MESSAGES } from '../constants/message'; + +/** + * 장바구니 관리 Hook + */ +export function useCart() { + const [cart, setCart] = useLocalStorage('cart', []); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + /** + * 상품을 장바구니에 추가 + * @param product - 추가할 상품 + * @param onSuccess - 성공 콜백 + * @param onError - 에러 콜백 + */ + const addToCart = useCallback( + (product: ProductWithUI, onSuccess?: (message: string) => void, onError?: (message: string) => void) => { + const remainingStock = cartModel.getRemainingStock(product, cart); + + if (remainingStock <= 0) { + onError?.(MESSAGES.PRODUCT.OUT_OF_STOCK); + return; + } + + const existingItem = cart.find((item) => item.product.id === product.id); + if (existingItem && existingItem.quantity + 1 > product.stock) { + onError?.(MESSAGES.PRODUCT.MAX_STOCK(product.stock)); + return; + } + + const updatedCart = cartModel.addItemToCart(cart, product); + setCart(updatedCart); + onSuccess?.(MESSAGES.PRODUCT.ADDED_TO_CART); + }, + [cart, setCart], + ); + + /** + * 장바구니에서 상품 제거 + * @param productId - 제거할 상품 ID + */ + const removeFromCart = useCallback( + (productId: string) => { + const updatedCart = cartModel.removeItemFromCart(cart, productId); + setCart(updatedCart); + }, + [cart, setCart], + ); + + /** + * 장바구니 상품 수량 변경 + * @param productId - 상품 ID + * @param newQuantity - 새로운 수량 + * @param products - 상품 목록 (재고 확인용) + * @param onError - 에러 콜백 + */ + const updateQuantity = useCallback( + (productId: string, newQuantity: number, products: ProductWithUI[], onError?: (message: string) => void) => { + const product = products.find((p) => p.id === productId); + if (!product) { + onError?.('상품을 찾을 수 없습니다.'); + return; + } + + const result = cartModel.processQuantityUpdate(cart, productId, newQuantity, product.stock); + + if (result.success) { + setCart(result.updatedCart); + } else { + onError?.(result.error); + } + }, + [cart, setCart], + ); + + /** + * 쿠폰 적용 + * @param coupon - 적용할 쿠폰 + * @param onSuccess - 성공 콜백 + * @param onError - 에러 콜백 + */ + const applyCoupon = useCallback( + (coupon: Coupon, onSuccess?: (message: string) => void, onError?: (message: string) => void) => { + const currentTotal = cartModel.calculateCartTotal(cart, selectedCoupon).totalAfterDiscount; + + if (currentTotal < ORDER.MIN_FOR_COUPON && coupon.discountType === 'percentage') { + onError?.(MESSAGES.COUPON.MIN_PRICE); + return; + } + + setSelectedCoupon(coupon); + onSuccess?.(MESSAGES.COUPON.APPLIED); + }, + [cart, selectedCoupon], + ); + + /** + * 총액 계산 + * @returns 할인 전/후 총액 + */ + const calculateTotal = useCallback(() => { + return cartModel.calculateCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + /** + * 남은 재고 조회 + * @param product - 상품 + * @returns 남은 재고 + */ + const getRemainingStock = useCallback( + (product: Product) => { + return cartModel.getRemainingStock(product, cart); + }, + [cart], + ); + + /** + * 장바구니 비우기 + */ + const clearCart = useCallback(() => { + setCart([]); + setSelectedCoupon(null); + }, [setCart]); + + const selectCoupon = (coupon: Coupon | null) => { + setSelectedCoupon(coupon); + }; + + return { + cart, + selectedCoupon, + selectCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + calculateTotal, + getRemainingStock, + clearCart, + }; +} diff --git a/src/basic/hooks/useCouponForm.ts b/src/basic/hooks/useCouponForm.ts new file mode 100644 index 00000000..0d5d0349 --- /dev/null +++ b/src/basic/hooks/useCouponForm.ts @@ -0,0 +1,115 @@ +import { useState, useCallback } from 'react'; +import type { Coupon as CouponType, CouponFormData as CouponFormDataType } from '../../types'; +import { + normalizeCode, + updateFormDiscountType, + parseDiscountValue, + validateDiscountValue as validateDiscountValuePure, + formToCoupon, + resetCouponForm, +} from '../models/couponForm'; +import { DEFAULT_COUPON_FORM } from '../constants/couponForm'; + +/** + * 쿠폰 폼 상태와 핸들러를 관리하는 커스텀 훅 + * @returns 쿠폰 폼 데이터와 핸들러 함수들 + */ +export function useCouponForm() { + const [couponForm, setCouponForm] = useState(DEFAULT_COUPON_FORM); + + /** + * 쿠폰 이름 업데이트 + * @param name - 쿠폰 이름 + */ + const updateName = useCallback((name: string) => { + setCouponForm((prev) => ({ ...prev, name })); + }, []); + + /** + * 쿠폰 코드 업데이트 + * @param code - 쿠폰 코드 + */ + const updateCode = useCallback((code: string) => { + setCouponForm((prev) => ({ ...prev, code: normalizeCode(code) })); + }, []); + + /** + * 할인 타입 업데이트 + * @param discountType - 할인 타입 + */ + const updateDiscountType = useCallback((discountType: 'amount' | 'percentage') => { + setCouponForm((prev) => updateFormDiscountType(prev, discountType)); + }, []); + + /** + * 할인 값 입력 처리 (숫자 입력 검증) + * @param value - 할인 값 + */ + const updateDiscountValue = useCallback((value: string) => { + const parsedValue = parseDiscountValue(value); + if (parsedValue !== null) { + setCouponForm((prev) => ({ ...prev, discountValue: parsedValue })); + } + }, []); + + /** + * 할인 값 유효성 검증 (onBlur 시 호출) + * @param value - 할인 값 + * @param onError - 유효성 검증 실패 시 호출되는 콜백 + */ + const validateDiscountValue = useCallback( + (value: string, onError: (message: string) => void) => { + const numericValue = parseInt(value) || 0; + const validation = validateDiscountValuePure(numericValue, couponForm.discountType); + + // 유효성 검증 실패 시 콜백 호출 + if (!validation.isValid) { + onError(validation.message); + } + + setCouponForm((prev) => ({ ...prev, discountValue: validation.correctedValue })); + }, + [couponForm.discountType], + ); + + /** + * 쿠폰 폼 제출 처리 + * @param e - 이벤트 + * @param onSubmit - 쿠폰 제출 콜백 + * @param onClose - 폼 닫기 콜백 + */ + const submitForm = useCallback( + (e: React.FormEvent, onSubmit: (coupon: CouponType) => void, onClose: () => void) => { + e.preventDefault(); + onSubmit(formToCoupon(couponForm)); + setCouponForm(resetCouponForm()); + onClose(); + }, + [couponForm], + ); + + /** + * 쿠폰 폼 초기화 + */ + const resetForm = useCallback(() => { + setCouponForm(resetCouponForm()); + }, []); + + /** + * 쿠폰 폼 변경 핸들러 + * @param form - 변경된 쿠폰 폼 데이터 + */ + const handleCouponFormChange = (form: CouponFormDataType) => setCouponForm(form); + + return { + couponForm, + updateName, + updateCode, + updateDiscountType, + updateDiscountValue, + validateDiscountValue, + submitForm, + resetForm, + handleCouponFormChange, + }; +} diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts new file mode 100644 index 00000000..5ce23557 --- /dev/null +++ b/src/basic/hooks/useCoupons.ts @@ -0,0 +1,30 @@ +import { Coupon } from '../../types'; +import { initialCoupons } from '../constants/initialData'; +import { useLocalStorage } from '../shared/hooks'; +import { couponModel } from '../models/coupon'; + +export const useCoupons = () => { + const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); + + /** + * 쿠폰 추가 + * @param newCoupon - 추가할 쿠폰 + */ + const addCoupon = (newCoupon: Coupon) => { + setCoupons((prev) => couponModel.addCoupon(prev, newCoupon)); + }; + + /** + * 쿠폰 삭제 + * @param couponCode - 삭제할 쿠폰 코드 + */ + const deleteCoupon = (couponCode: string) => { + setCoupons((prev) => couponModel.deleteCoupon(prev, couponCode)); + }; + + return { + coupons, + addCoupon, + deleteCoupon, + }; +}; diff --git a/src/basic/hooks/useNotification.ts b/src/basic/hooks/useNotification.ts new file mode 100644 index 00000000..3295b240 --- /dev/null +++ b/src/basic/hooks/useNotification.ts @@ -0,0 +1,38 @@ +import { useState, useCallback } from 'react'; +import type { Notification } from '../shared/types'; +import { NOTIFICATION_DURATION } from '../shared/constants/toast'; + +/** + * 알림 관리 Hook + */ +export function useNotification() { + const [notifications, setNotifications] = useState([]); + + /** + * 알림 메시지를 추가하고, 3초 후에 알림을 삭제 + * @param message - 알림 메시지 + * @param type - 알림 메시지 타입 + */ + 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)); + }, NOTIFICATION_DURATION); + }, []); + + /** + * 특정 알림 제거 + * @param id - 제거할 알림 ID + */ + const removeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return { + notifications, + addNotification, + removeNotification, + }; +} diff --git a/src/basic/hooks/useProductForm.ts b/src/basic/hooks/useProductForm.ts new file mode 100644 index 00000000..e882f37f --- /dev/null +++ b/src/basic/hooks/useProductForm.ts @@ -0,0 +1,211 @@ +import { useState, useCallback } from 'react'; +import { ProductWithUI } from '../shared/types'; +import { + parseNumericValue, + validatePrice, + validateStock, + addDiscount, + removeDiscount, + updateDiscount, + formToProduct, + productToForm, + resetProductForm, +} from '../models/productForm'; +import type { ProductFormData as ProductFormDataType } from '../../types'; +import { DEFAULT_PRODUCT_FORM } from '../constants/productForm'; + +/** + * 상품 폼 상태와 핸들러를 관리하는 커스텀 훅 + * @returns 상품 폼 데이터와 핸들러 함수들 + */ +export function useProductForm() { + const [productForm, setProductForm] = useState(DEFAULT_PRODUCT_FORM); + const [editingProduct, setEditingProduct] = useState(null); + + /** + * 상품 이름 업데이트 + * @param name - 상품 이름 + */ + const updateName = useCallback((name: string) => { + setProductForm((prev) => ({ ...prev, name })); + }, []); + + /** + * 상품 설명 업데이트 + * @param description - 상품 설명 + */ + const updateDescription = useCallback((description: string) => { + setProductForm((prev) => ({ ...prev, description })); + }, []); + + /** + * 상품 가격 업데이트 (숫자 입력 검증) + * @param value - 가격 값 + */ + const updatePrice = useCallback((value: string) => { + const parsedValue = parseNumericValue(value); + if (parsedValue !== null) { + setProductForm((prev) => ({ ...prev, price: parsedValue })); + } + }, []); + + /** + * 상품 재고 업데이트 (숫자 입력 검증) + * @param value - 재고 값 + */ + const updateStock = useCallback((value: string) => { + const parsedValue = parseNumericValue(value); + if (parsedValue !== null) { + setProductForm((prev) => ({ ...prev, stock: parsedValue })); + } + }, []); + + /** + * 가격 유효성 검증 (onBlur 시 호출) + * @param value - 가격 값 + * @param onError - 유효성 검증 실패 시 호출되는 콜백 + */ + const validatePriceValue = useCallback((value: string, onError: (message: string) => void) => { + const price = value === '' ? 0 : parseInt(value); + const validation = validatePrice(price); + + if (!validation.isValid) { + onError(validation.message); + } + + setProductForm((prev) => ({ ...prev, price: validation.correctedValue })); + }, []); + + /** + * 재고 유효성 검증 (onBlur 시 호출) + * @param value - 재고 값 + * @param onError - 유효성 검증 실패 시 호출되는 콜백 + */ + const validateStockValue = useCallback((value: string, onError: (message: string) => void) => { + const stock = value === '' ? 0 : parseInt(value); + const validation = validateStock(stock); + + if (!validation.isValid) { + onError(validation.message); + } + + setProductForm((prev) => ({ ...prev, stock: validation.correctedValue })); + }, []); + + /** + * 할인 정책 추가 + */ + const addDiscountPolicy = useCallback(() => { + setProductForm((prev) => ({ + ...prev, + discounts: addDiscount(prev.discounts), + })); + }, []); + + /** + * 할인 정책 제거 + * @param index - 제거할 할인 인덱스 + */ + const removeDiscountPolicy = useCallback((index: number) => { + setProductForm((prev) => ({ + ...prev, + discounts: removeDiscount(prev.discounts, index), + })); + }, []); + + /** + * 할인 정책 수량 업데이트 + * @param index - 할인 인덱스 + * @param quantity - 새로운 수량 + */ + const updateDiscountQuantity = useCallback((index: number, quantity: number) => { + setProductForm((prev) => ({ + ...prev, + discounts: updateDiscount(prev.discounts, index, { quantity }), + })); + }, []); + + /** + * 할인 정책 비율 업데이트 + * @param index - 할인 인덱스 + * @param rate - 새로운 비율 + */ + const updateDiscountRate = useCallback((index: number, rate: number) => { + setProductForm((prev) => ({ + ...prev, + discounts: updateDiscount(prev.discounts, index, { rate }), + })); + }, []); + + /** + * 상품 편집 시작 + * @param product - 편집할 상품 + */ + const startEditProduct = useCallback((product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm(productToForm(product)); + }, []); + + /** + * 새 상품 추가 모드 시작 + */ + const startAddProduct = useCallback(() => { + setEditingProduct('new'); + setProductForm(resetProductForm()); + }, []); + + /** + * 상품 폼 제출 처리 + * @param e - 이벤트 + * @param onSubmit - 상품 제출 콜백 (추가/수정) + * @param onClose - 폼 닫기 콜백 + */ + const submitForm = useCallback( + ( + e: React.FormEvent, + onSubmit: (product: Omit, productId?: string) => void, + onClose: () => void, + ) => { + e.preventDefault(); + const productData = formToProduct(productForm); + + if (editingProduct && editingProduct !== 'new') { + onSubmit(productData, editingProduct); + } else { + onSubmit(productData); + } + + setProductForm(resetProductForm()); + setEditingProduct(null); + onClose(); + }, + [productForm, editingProduct], + ); + + /** + * 상품 폼 초기화 + */ + const resetForm = useCallback(() => { + setProductForm(resetProductForm()); + setEditingProduct(null); + }, []); + + return { + productForm, + editingProduct, + updateName, + updateDescription, + updatePrice, + updateStock, + validatePriceValue, + validateStockValue, + addDiscountPolicy, + removeDiscountPolicy, + updateDiscountQuantity, + updateDiscountRate, + startEditProduct, + startAddProduct, + submitForm, + resetForm, + }; +} diff --git a/src/basic/hooks/useProductSearch.ts b/src/basic/hooks/useProductSearch.ts new file mode 100644 index 00000000..ece5b7d7 --- /dev/null +++ b/src/basic/hooks/useProductSearch.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; +import { ProductWithUI } from '../shared/types'; +import { useSearch } from '../shared/hooks'; +import { filterProductsBySearchTerm } from '../models/productSearch'; +import { SEARCH_DELAY } from '../shared/constants/toast'; + +/** + * 상품 검색을 위한 전용 훅 + * @param products - 검색할 상품 목록 + * @param delay - 디바운스 지연 시간 + * @returns 검색 관련 상태와 필터링된 상품들 + */ +export function useProductSearch(products: ProductWithUI[], delay: number = SEARCH_DELAY) { + const { searchTerm, handleSearchTermChange, debouncedSearchTerm, clearSearch, hasSearchTerm } = useSearch(delay); + + /** + * 검색어로 필터링된 상품 목록 + */ + const filteredProducts = useMemo(() => { + return filterProductsBySearchTerm(products, debouncedSearchTerm); + }, [products, debouncedSearchTerm]); + + return { + searchTerm, + handleSearchTermChange, + debouncedSearchTerm, + clearSearch, + hasSearchTerm, + filteredProducts, + }; +} diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts new file mode 100644 index 00000000..668c9022 --- /dev/null +++ b/src/basic/hooks/useProducts.ts @@ -0,0 +1,60 @@ +import { useCallback } from 'react'; +import { useLocalStorage } from '../shared/hooks'; +import { ProductWithUI } from '../shared/types'; +import { productModel } from '../models/product'; +import { initialProducts } from '../constants/initialData'; +import { MESSAGES } from '../constants/message'; + +export function useProducts() { + const [products, setProducts] = useLocalStorage('products', initialProducts); + + /** + * 상품 추가 + * @param newProduct - 추가할 상품 + * @param onSuccess - 성공 콜백 + */ + const addProduct = useCallback( + (newProduct: Omit, onSuccess?: (message: string) => void) => { + const updatedProducts = productModel.addProduct(products, newProduct); + setProducts(updatedProducts); + onSuccess?.(MESSAGES.PRODUCT.ADDED); + }, + [products, setProducts], + ); + + /** + * 상품 수정 + * @param productId - 상품 ID + * @param updates - 수정할 상품 정보 + * @param onSuccess - 성공 콜백 + */ + const updateProduct = useCallback( + (productId: string, updates: Partial, onSuccess?: (message: string) => void) => { + const updatedProducts = productModel.updateProduct(products, productId, updates); + setProducts(updatedProducts); + onSuccess?.(MESSAGES.PRODUCT.UPDATED); + }, + [products, setProducts], + ); + + /** + * 상품 삭제 + * @param productId - 상품 ID + * @param onSuccess - 성공 콜백 + */ + const deleteProduct = useCallback( + (productId: string, onSuccess?: (message: string) => void) => { + const updatedProducts = productModel.deleteProduct(products, productId); + setProducts(updatedProducts); + onSuccess?.(MESSAGES.PRODUCT.DELETED); + }, + [products, setProducts], + ); + + return { + products, + addProduct, + updateProduct, + deleteProduct, + }; +} diff --git a/src/basic/main.tsx b/src/basic/main.tsx index e63eef4a..e5775c05 100644 --- a/src/basic/main.tsx +++ b/src/basic/main.tsx @@ -1,9 +1,9 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( , -) +); diff --git a/src/basic/models/cart.ts b/src/basic/models/cart.ts new file mode 100644 index 00000000..85bce155 --- /dev/null +++ b/src/basic/models/cart.ts @@ -0,0 +1,205 @@ +import { CartItem, Coupon, Product, Discount } from '../../types'; +import { ProductWithUI } from '../shared/types'; +import { DISCOUNT } from '../constants/discount'; +import { MESSAGES } from '../constants/message'; + +/** + * 수량 기반 할인 계산 + * @param item - 장바구니 상품 + * @returns 수량 기반 할인 + */ +export const calculateQuantityDiscount = (item: CartItem): number => { + const { discounts } = item.product; + const { quantity } = item; + + return discounts.reduce((maxDiscount: number, discount: Discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount ? discount.rate : maxDiscount; + }, 0); +}; + +/** + * 대량 구매 여부 확인 + * @param cart - 장바구니 상품 + * @param bulkPurchaseQuantity - 대량 구매 수량, 기본값 10 + * @returns 대량 구매 여부 + */ +export const hasBulkPurchase = (cart: CartItem[], bulkPurchaseQuantity: number = DISCOUNT.BULK_THRESHOLD): boolean => { + return cart.some((cartItem) => cartItem.quantity >= bulkPurchaseQuantity); +}; + +/** + * 최종 할인율 계산 + * @param item - 장바구니에 담긴 상품 + * @param cart - 장바구니 상품 목록 + * @returns 최종 할인율 + */ +export const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number => { + const baseDiscount = calculateQuantityDiscount(item); + + if (hasBulkPurchase(cart)) { + return Math.min(baseDiscount + DISCOUNT.BULK_BONUS, DISCOUNT.MAX_RATE); + } + + return baseDiscount; +}; + +/** + * 아이템 총액 계산 + * @param item - 장바구니에 담긴 상품 + * @param cart - 장바구니 상품 목록 + * @returns 아이템 총액 + */ +export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +/** + * 할인 전 총액 계산 + * @param cart - 장바구니 아이템들 + * @returns 할인 전 총액 + */ +export const calculateCartSubtotal = (cart: CartItem[]): number => { + return cart.reduce((total, item) => { + return total + item.product.price * item.quantity; + }, 0); +}; + +/** + * 상품 할인 적용된 총액 계산 + * @param cart - 장바구니 아이템들 + * @returns 상품 할인 적용된 총액 + */ +export const calculateCartDiscountedTotal = (cart: CartItem[]): number => { + return cart.reduce((total, item) => { + return total + calculateItemTotal(item, cart); + }, 0); +}; + +/** + * 쿠폰 할인 적용 + * @param amount - 할인 전 금액 + * @param coupon - 적용할 쿠폰 + * @returns 쿠폰 할인 적용된 금액 + */ +export const applyCouponDiscount = (amount: number, coupon: Coupon | null): number => { + if (!coupon) return amount; + + if (coupon.discountType === 'amount') { + return Math.max(0, amount - coupon.discountValue); + } else { + return Math.round(amount * (1 - coupon.discountValue / DISCOUNT.MAX_PERCENTAGE)); + } +}; + +/** + * 최종 장바구니 총액 계산 + * @param cart - 장바구니 아이템들 + * @param selectedCoupon - 선택된 쿠폰 + * @returns 할인 전/후 총액 + */ +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null, +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + const totalBeforeDiscount = calculateCartSubtotal(cart); + const itemDiscountedTotal = calculateCartDiscountedTotal(cart); + const totalAfterDiscount = applyCouponDiscount(itemDiscountedTotal, selectedCoupon); + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; + +/** + * 장바구니에서 특정 상품 제거 + * @param cart - 현재 장바구니 + * @param productId - 제거할 상품 ID + * @returns 상품이 제거된 새로운 장바구니 + */ +export const removeItemFromCart = (cart: CartItem[], productId: string): CartItem[] => { + return cart.filter((item) => item.product.id !== productId); +}; + +/** + * 장바구니 아이템 수량 업데이트 + * @param cart - 현재 장바구니 + * @param productId - 업데이트할 상품 ID + * @param newQuantity - 새로운 수량 + * @returns 수량이 업데이트된 새로운 장바구니 + */ +export const updateCartItemQuantity = (cart: CartItem[], productId: string, newQuantity: number): CartItem[] => { + return cart.map((item) => (item.product.id === productId ? { ...item, quantity: newQuantity } : item)); +}; + +/** + * 장바구니에 상품 추가 + * @param cart - 현재 장바구니 + * @param product - 추가할 상품 + * @returns 상품이 추가된 새로운 장바구니 + */ +export const addItemToCart = (cart: CartItem[], product: ProductWithUI): CartItem[] => { + const existingItem = cart.find((item) => item.product.id === product.id); + + if (existingItem) { + return updateCartItemQuantity(cart, product.id, existingItem.quantity + 1); + } + + return [...cart, { product, quantity: 1 }]; +}; + +/** + * 장바구니에 담긴 상품의 재고를 조회 + * @param product - 상품 + * @param cart - 장바구니 + * @returns 재고 + */ +export const getRemainingStock = (product: Product, cart: CartItem[]): number => { + const cartItem = cart.find((item) => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + + return remaining; +}; + +/** + * 수량 업데이트 결과 타입 + */ +export type QuantityUpdateResult = + | { success: true; updatedCart: CartItem[] } + | { success: false; error: string; errorType: 'INVALID_QUANTITY' | 'PRODUCT_NOT_FOUND' | 'INSUFFICIENT_STOCK' }; + +/** + * 수량 업데이트 로직 + * @param cart - 현재 장바구니 + * @param productId - 업데이트할 상품 ID + * @param newQuantity - 새로운 수량 + * @param availableStock - 사용 가능한 재고 + * @returns 수량 업데이트 결과 + */ +export const processQuantityUpdate = ( + cart: CartItem[], + productId: string, + newQuantity: number, + availableStock: number, +): QuantityUpdateResult => { + if (!Number.isInteger(newQuantity) || newQuantity < 0) { + return { success: false, error: MESSAGES.PRODUCT.INVALID_QUANTITY, errorType: 'INVALID_QUANTITY' }; + } + + if (newQuantity === 0) { + return { success: true, updatedCart: removeItemFromCart(cart, productId) }; + } + + if (newQuantity > availableStock) { + return { success: false, error: MESSAGES.PRODUCT.MAX_STOCK(availableStock), errorType: 'INSUFFICIENT_STOCK' }; + } + + return { success: true, updatedCart: updateCartItemQuantity(cart, productId, newQuantity) }; +}; diff --git a/src/basic/models/coupon.ts b/src/basic/models/coupon.ts new file mode 100644 index 00000000..c04e9e10 --- /dev/null +++ b/src/basic/models/coupon.ts @@ -0,0 +1,29 @@ +import { Coupon } from '../../types'; + +export const couponModel = { + /** + * 쿠폰 추가 + * @param coupons - 쿠폰 목록 + * @param newCoupon - 추가할 쿠폰 + * @returns 쿠폰 목록 + */ + addCoupon: (coupons: Coupon[], newCoupon: Coupon): Coupon[] => { + const exists = coupons.some((coupon) => coupon.code === newCoupon.code); + // 이미 존재하면 그대로 반환 + if (exists) return coupons; + + return [...coupons, newCoupon]; + }, + + /** + * 쿠폰 삭제 + * @param coupons - 쿠폰 목록 + * @param couponCode - 삭제할 쿠폰 코드 + * @returns 쿠폰 목록 + */ + deleteCoupon: (coupons: Coupon[], couponCode: string): Coupon[] => { + const updatedCoupons = coupons.filter((c) => c.code !== couponCode); + + return updatedCoupons; + }, +}; diff --git a/src/basic/models/couponForm.ts b/src/basic/models/couponForm.ts new file mode 100644 index 00000000..ef5a4834 --- /dev/null +++ b/src/basic/models/couponForm.ts @@ -0,0 +1,70 @@ +import type { Coupon as CouponType } from '../../types'; +import type { CouponFormData as CouponFormDataType } from '../../types'; +import { DEFAULT_COUPON_FORM } from '../constants/couponForm'; +import { validator } from '../shared/utils/validators'; + +/** + * 쿠폰 코드를 대문자로 변환하는 순수함수 + * @param code - 입력된 코드 + * @returns 대문자로 변환된 코드 + */ +export const normalizeCode = (code: string): string => code.toUpperCase(); + +/** + * 할인 타입 변경 시 새로운 폼 상태를 반환하는 순수함수 + * @param currentForm - 현재 폼 상태 + * @param discountType - 새로운 할인 타입 + * @returns 업데이트된 폼 상태 + */ +export const updateFormDiscountType = ( + currentForm: CouponFormDataType, + discountType: 'amount' | 'percentage', +): CouponFormDataType => ({ + ...currentForm, + discountType, + discountValue: 0, +}); + +/** + * 입력된 값을 숫자로 파싱하는 순수함수 + * @param value - 입력 문자열 + * @returns 파싱된 숫자 값 또는 null (유효하지 않은 경우) + */ +export const parseDiscountValue = (value: string): number | null => { + const numericString = validator.validateNumericString(value); + if (numericString === null) return null; + return numericString === '' ? 0 : parseInt(numericString); +}; + +/** + * 할인 값 유효성 검증하는 순수함수 + * @param value - 검증할 값 + * @param discountType - 할인 타입 + * @returns 검증 결과 객체 + */ +export const validateDiscountValue = ( + value: number, + discountType: 'amount' | 'percentage', +): { isValid: boolean; message: string; correctedValue: number } => { + const isPercentageType = discountType === 'percentage'; + + return isPercentageType ? validator.isValidDiscountPercentage(value) : validator.isValidDiscountAmount(value); +}; + +/** + * 폼 데이터를 쿠폰 객체로 변환하는 순수함수 + * @param formData - 폼 데이터 + * @returns 쿠폰 객체 + */ +export const formToCoupon = (formData: CouponFormDataType): CouponType => ({ + name: formData.name, + code: formData.code, + discountType: formData.discountType, + discountValue: formData.discountValue, +}); + +/** + * 폼을 기본값으로 리셋하는 순수함수 + * @returns 기본 폼 상태 + */ +export const resetCouponForm = (): CouponFormDataType => ({ ...DEFAULT_COUPON_FORM }); diff --git a/src/basic/models/product.ts b/src/basic/models/product.ts new file mode 100644 index 00000000..c5216622 --- /dev/null +++ b/src/basic/models/product.ts @@ -0,0 +1,32 @@ +import { ProductWithUI } from '../shared/types'; + +export const productModel = { + /** + * 상품 추가 로직 + * @param newProduct - 추가할 상품 + */ + addProduct: (products: ProductWithUI[], newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + return [...products, product]; + }, + + /** + * 상품 수정 로직 + * @param productId - 상품 ID + * @param updates - 수정할 상품 정보 + */ + updateProduct: (products: ProductWithUI[], productId: string, updates: Partial) => { + return products.map((product) => (product.id === productId ? { ...product, ...updates } : product)); + }, + + /** + * 상품 삭제 로직 + * @param productId - 상품 ID + */ + deleteProduct: (products: ProductWithUI[], productId: string) => { + return products.filter((p) => p.id !== productId); + }, +}; diff --git a/src/basic/models/productForm.ts b/src/basic/models/productForm.ts new file mode 100644 index 00000000..9ade2abd --- /dev/null +++ b/src/basic/models/productForm.ts @@ -0,0 +1,101 @@ +import { ProductWithUI } from '../shared/types'; +import { validator } from '../shared/utils/validators'; +import type { ProductFormData as ProductFormDataType } from '../../types'; +import { DEFAULT_PRODUCT_FORM } from '../constants/productForm'; + +/** + * 입력된 값을 숫자로 파싱하는 순수함수 + * @param value - 입력 문자열 + * @returns 파싱된 숫자 값 또는 null (유효하지 않은 경우) + */ +export const parseNumericValue = (value: string): number | null => { + const numericString = validator.validateNumericString(value); + if (numericString === null) return null; + return numericString === '' ? 0 : parseInt(numericString); +}; + +/** + * 가격 유효성 검증하는 순수함수 + * @param price - 검증할 가격 + * @returns 검증 결과 객체 + */ +export const validatePrice = (price: number): { isValid: boolean; message: string; correctedValue: number } => { + return validator.isValidPrice(price); +}; + +/** + * 재고 유효성 검증하는 순수함수 + * @param stock - 검증할 재고 + * @returns 검증 결과 객체 + */ +export const validateStock = (stock: number): { isValid: boolean; message: string; correctedValue: number } => { + return validator.isValidStock(stock); +}; + +/** + * 할인 정책을 추가하는 순수함수 + * @param discounts - 현재 할인 목록 + * @param newDiscount - 새로운 할인 정책 + * @returns 업데이트된 할인 목록 + */ +export const addDiscount = ( + discounts: Array<{ quantity: number; rate: number }>, + newDiscount: { quantity: number; rate: number } = { quantity: 10, rate: 0.1 }, +): Array<{ quantity: number; rate: number }> => [...discounts, newDiscount]; + +/** + * 할인 정책을 제거하는 순수함수 + * @param discounts - 현재 할인 목록 + * @param index - 제거할 할인 인덱스 + * @returns 업데이트된 할인 목록 + */ +export const removeDiscount = ( + discounts: Array<{ quantity: number; rate: number }>, + index: number, +): Array<{ quantity: number; rate: number }> => discounts.filter((_, i) => i !== index); + +/** + * 할인 정책을 업데이트하는 순수함수 + * @param discounts - 현재 할인 목록 + * @param index - 업데이트할 할인 인덱스 + * @param updates - 업데이트할 값들 + * @returns 업데이트된 할인 목록 + */ +export const updateDiscount = ( + discounts: Array<{ quantity: number; rate: number }>, + index: number, + updates: Partial<{ quantity: number; rate: number }>, +): Array<{ quantity: number; rate: number }> => + discounts.map((discount, i) => (i === index ? { ...discount, ...updates } : discount)); + +/** + * 폼 데이터를 상품 객체로 변환하는 순수함수 + * @param formData - 폼 데이터 + * @returns 상품 객체 (id 제외) + */ +export const formToProduct = (formData: ProductFormDataType): Omit => ({ + name: formData.name, + price: formData.price, + stock: formData.stock, + description: formData.description, + discounts: formData.discounts, +}); + +/** + * 상품 객체를 폼 데이터로 변환하는 순수함수 + * @param product - 상품 객체 + * @returns 폼 데이터 + */ +export const productToForm = (product: ProductWithUI): ProductFormDataType => ({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [], +}); + +/** + * 폼을 기본값으로 리셋하는 순수함수 + * @returns 기본 폼 상태 + */ +export const resetProductForm = (): ProductFormDataType => ({ ...DEFAULT_PRODUCT_FORM }); diff --git a/src/basic/models/productSearch.ts b/src/basic/models/productSearch.ts new file mode 100644 index 00000000..f0180ffc --- /dev/null +++ b/src/basic/models/productSearch.ts @@ -0,0 +1,39 @@ +import { ProductWithUI } from '../shared/types'; + +/** + * 검색어를 정규화 (소문자 변환, 공백 제거) + * @param searchTerm - 원본 검색어 + * @returns 정규화된 검색어 + */ +export const normalizeSearchTerm = (searchTerm: string): string => { + return searchTerm.trim().toLowerCase(); +}; + +/** + * 상품이 검색어와 매칭되는지 확인 + * @param product - 상품 + * @param normalizedSearchTerm - 정규화된 검색어 + * @returns 매칭 여부 + */ +export const matchesProduct = (product: ProductWithUI, normalizedSearchTerm: string): boolean => { + const nameMatches = product.name.toLowerCase().includes(normalizedSearchTerm); + const descriptionMatches = product.description?.toLowerCase().includes(normalizedSearchTerm) || false; + + return nameMatches || descriptionMatches; +}; + +/** + * 상품 검색 필터링 로직 + * @param products - 검색할 상품 목록 + * @param searchTerm - 검색어 + * @returns 필터링된 상품 목록 + */ +export const filterProductsBySearchTerm = (products: ProductWithUI[], searchTerm: string): ProductWithUI[] => { + const normalized = normalizeSearchTerm(searchTerm); + + if (!normalized) { + return products; + } + + return products.filter((product) => matchesProduct(product, normalized)); +}; diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx new file mode 100644 index 00000000..fcd91d34 --- /dev/null +++ b/src/basic/pages/AdminPage.tsx @@ -0,0 +1,264 @@ +import { useState, useCallback } from 'react'; +import type { Coupon as CouponType } from '../../types'; +import type { ProductWithUI } from '../shared/types'; +import { MESSAGES } from '../constants/message'; +import { useCouponForm } from '../hooks/useCouponForm'; +import { CouponForm } from '../components/admin/CouponForm'; +import CouponList from '../components/admin/CouponList'; +import { useProductForm } from '../hooks/useProductForm'; +import ProductForm from '../components/admin/ProductForm'; +import { ProductList } from '../components/admin/ProductList'; + +interface AdminPageProps { + products: ProductWithUI[]; + coupons: CouponType[]; + addProduct: (newProduct: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; + addCoupon: (newCoupon: CouponType) => void; + deleteCoupon: (couponCode: string) => void; + addNotification: (message: string, type: 'success' | 'error' | 'warning') => void; + selectedCoupon: CouponType | null; + selectCoupon: (coupon: CouponType | null) => void; +} + +export function AdminPage({ + products, + coupons, + addProduct: addProductHook, + updateProduct: updateProductHook, + deleteProduct: deleteProductHook, + addCoupon: addCouponHook, + deleteCoupon: deleteCouponHook, + addNotification, + selectedCoupon, + selectCoupon, +}: AdminPageProps) { + // 쿠폰 폼 훅 + const { + couponForm, + updateName: updateCouponName, + updateCode, + updateDiscountType, + updateDiscountValue, + validateDiscountValue, + submitForm: submitCouponForm, + } = useCouponForm(); + + const { + productForm, + editingProduct, + updateName: updateProductName, + updateDescription, + updatePrice, + updateStock, + validatePriceValue, + validateStockValue, + addDiscountPolicy, + removeDiscountPolicy, + updateDiscountQuantity, + updateDiscountRate, + startEditProduct: startEditProductHook, + startAddProduct, + submitForm: submitProductForm, + resetForm: resetProductForm, + } = useProductForm(); + + const [showCouponForm, setShowCouponForm] = useState(false); + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [showProductForm, setShowProductForm] = useState(false); + + const toggleCouponForm = () => setShowCouponForm((prev) => !prev); + const toggleProductForm = () => setShowProductForm((prev) => !prev); + + const addProduct = useCallback( + (newProduct: Omit) => { + addProductHook(newProduct); + addNotification(MESSAGES.PRODUCT.ADDED, 'success'); + }, + [addProductHook, addNotification], + ); + + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + updateProductHook(productId, updates); + addNotification(MESSAGES.PRODUCT.UPDATED, 'success'); + }, + [updateProductHook, addNotification], + ); + + const deleteProduct = useCallback( + (productId: string) => { + deleteProductHook(productId); + addNotification(MESSAGES.PRODUCT.DELETED, 'success'); + }, + [deleteProductHook, addNotification], + ); + + const addCoupon = useCallback( + (newCoupon: CouponType) => { + addCouponHook(newCoupon); + addNotification(MESSAGES.COUPON.ADDED, 'success'); + }, + [addCouponHook, addNotification], + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + deleteCouponHook(couponCode); + if (selectedCoupon?.code === couponCode) { + selectCoupon(null); + } + addNotification(MESSAGES.COUPON.DELETED, 'success'); + }, + [deleteCouponHook, selectedCoupon, selectCoupon, addNotification], + ); + + // 상품 폼 제출 핸들러 - Hook의 submitForm 사용 + const handleProductSubmit = useCallback( + (e: React.FormEvent) => { + submitProductForm( + e, + (productData, productId) => { + if (productId) { + updateProduct(productId, productData); + } else { + addProduct(productData); + } + }, + () => setShowProductForm(false), + ); + }, + [submitProductForm, addProduct, updateProduct], + ); + + // 쿠폰 폼 제출 핸들러 + const handleCouponSubmit = useCallback( + (e: React.FormEvent) => { + submitCouponForm(e, addCoupon, () => setShowCouponForm(false)); + }, + [submitCouponForm, addCoupon], + ); + + // 상품 편집 시작 핸들러 + const handleStartEditProduct = useCallback( + (product: ProductWithUI) => { + startEditProductHook(product); + setShowProductForm(true); + }, + [startEditProductHook], + ); + + // 새 상품 추가 시작 핸들러 + const handleStartAddProduct = useCallback(() => { + startAddProduct(); + setShowProductForm(true); + }, [startAddProduct]); + + // 상품 폼 취소 핸들러 + const handleCancelProductForm = useCallback(() => { + resetProductForm(); + setShowProductForm(false); + }, [resetProductForm]); + + return ( +
+
+

관리자 대시보드

+

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

+
+
+ +
+ + {activeTab === 'products' ? ( +
+
+
+

상품 목록

+ +
+
+ + + + {showProductForm && ( + + )} +
+ ) : ( +
+
+

쿠폰 관리

+
+
+ + + {showCouponForm && ( + + )} +
+
+ )} +
+ ); +} + +export default AdminPage; diff --git a/src/basic/pages/CartPage.tsx b/src/basic/pages/CartPage.tsx new file mode 100644 index 00000000..e04a4017 --- /dev/null +++ b/src/basic/pages/CartPage.tsx @@ -0,0 +1,140 @@ +import { useCallback } from 'react'; +import type { Coupon as CouponType } from '../../types'; +import type { ProductWithUI } from '../shared/types'; +import { MESSAGES } from '../constants/message'; +import { ProductList } from '../components/cart/ProductList'; +import { Cart } from '../components/cart/Cart'; +import Payment from '../components/cart/Payment'; +import Coupon from '../components/cart/Coupon'; + +interface CartPageProps { + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + searchTerm: string; + cart: Array<{ product: ProductWithUI; quantity: number }>; + coupons: CouponType[]; + selectedCoupon: CouponType | null; + selectCoupon: (coupon: CouponType | null) => void; + addToCart: (product: ProductWithUI, onSuccess: (message: string) => void, onError: (message: string) => void) => void; + removeFromCart: (productId: string) => void; + updateQuantity: ( + productId: string, + newQuantity: number, + products: ProductWithUI[], + onError: (message: string) => void, + ) => void; + applyCoupon: (coupon: CouponType, onSuccess: (message: string) => void, onError: (message: string) => void) => void; + calculateTotal: () => { totalBeforeDiscount: number; totalAfterDiscount: number }; + getRemainingStock: (product: ProductWithUI) => number; + clearCart: () => void; + addNotification: (message: string, type: 'success' | 'error' | 'warning') => void; +} + +export function CartPage({ + products, + filteredProducts, + searchTerm, + cart, + coupons, + selectedCoupon, + selectCoupon, + addToCart: addToCartHook, + removeFromCart, + updateQuantity: updateQuantityHook, + applyCoupon: applyCouponHook, + calculateTotal, + getRemainingStock, + clearCart, + addNotification, +}: CartPageProps) { + 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'; + } + } + return `₩${price.toLocaleString()}`; + }; + + const addToCart = useCallback( + (product: ProductWithUI) => { + addToCartHook( + product, + (message) => addNotification(message, 'success'), + (message) => addNotification(message, 'error'), + ); + }, + [addToCartHook, addNotification], + ); + + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + updateQuantityHook(productId, newQuantity, products, (message) => addNotification(message, 'error')); + }, + [updateQuantityHook, products, addNotification], + ); + + const applyCoupon = useCallback( + (coupon: CouponType) => { + applyCouponHook( + coupon, + (message) => addNotification(message, 'success'), + (message) => addNotification(message, 'error'), + ); + }, + [applyCouponHook, addNotification], + ); + + const completeOrder = useCallback(() => { + const orderNumber = `ORD-${Date.now()}`; + addNotification(MESSAGES.ORDER.COMPLETED(orderNumber), 'success'); + clearCart(); + }, [addNotification, clearCart]); + + const totals = calculateTotal(); + + return ( +
+
+ {/* 상품 목록 */} + +
+ +
+
+ {/* 장바구니 */} + + + {cart.length > 0 && ( + <> + {/* 쿠폰 섹션 */} + + {/* 결제 정보 */} + + + )} +
+
+
+ ); +} + +export default CartPage; diff --git a/src/basic/shared/constants/toast.ts b/src/basic/shared/constants/toast.ts new file mode 100644 index 00000000..0e33b772 --- /dev/null +++ b/src/basic/shared/constants/toast.ts @@ -0,0 +1,2 @@ +export const NOTIFICATION_DURATION = 3000; +export const SEARCH_DELAY = 500; diff --git a/src/basic/shared/hooks/index.ts b/src/basic/shared/hooks/index.ts new file mode 100644 index 00000000..1b465d5e --- /dev/null +++ b/src/basic/shared/hooks/index.ts @@ -0,0 +1,5 @@ +import { useLocalStorage } from './useLocalStorage'; +import { useDebounce } from './useDebounce'; +import { useSearch } from './useSearch'; + +export { useLocalStorage, useDebounce, useSearch }; diff --git a/src/basic/shared/hooks/useDebounce.ts b/src/basic/shared/hooks/useDebounce.ts new file mode 100644 index 00000000..9b9256b0 --- /dev/null +++ b/src/basic/shared/hooks/useDebounce.ts @@ -0,0 +1,23 @@ +import { useState, useEffect } from 'react'; + +/** + * 디바운스 Hook + * 값이 변경되어도 지정된 시간 동안 대기하고, 최종적으로 안정된 값만 반환 + * + * @param value - 디바운스할 값 + * @param delay - 지연 시간 (밀리초) + * @returns 디바운스된 값 + */ +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/basic/shared/hooks/useLocalStorage.ts b/src/basic/shared/hooks/useLocalStorage.ts new file mode 100644 index 00000000..ee618b3d --- /dev/null +++ b/src/basic/shared/hooks/useLocalStorage.ts @@ -0,0 +1,72 @@ +import { useState, useEffect, useCallback } from 'react'; + +/** + * LocalStorage 훅 구현 + * @param key - localStorage에 저장할 키 + * @param initialValue - 초기값 + * @returns [저장된 값, 값 설정 함수] + */ +export function useLocalStorage(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] { + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(`localStorage에서 ${key}를 읽는 중 오류 발생:`, error); + return initialValue; + } + }); + + /** + * 빈 배열이나 undefined인지 확인 + * @param value - 저장할 값 + * @returns 빈 배열이나 undefined인지 여부 + */ + const isEmpty = (value: T): boolean => { + if (value === null || value === undefined) return true; + if (Array.isArray(value) && value.length === 0) return true; + return false; + }; + + /** + * localStorage에 값 저장 + * @param value - 저장할 값 + */ + const persistToStorage = useCallback( + (value: T): void => { + try { + if (isEmpty(value)) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(value)); + } + } catch (error) { + console.warn(`localStorage에 ${key}를 저장하는 중 오류 발생:`, error); + } + }, + [key], + ); + + /** + * 상태 업데이트 함수 - 함수형 업데이트 지원 + * @param value - 저장할 값 + */ + const setValue = useCallback((value: T | ((val: T) => T)): void => { + setStoredValue((prevValue) => { + // 함수형 업데이트인 경우 현재 값을 인자로 전달 + const newValue = typeof value === 'function' ? (value as (val: T) => T)(prevValue) : value; + + // localStorage에 저장 (side effect를 명시적으로 분리) + setStoredValue(newValue); + + return newValue; + }); + }, []); + + // localStorage와 React state 동기화 + useEffect(() => { + persistToStorage(storedValue); + }, [storedValue, persistToStorage]); + + return [storedValue, setValue]; +} diff --git a/src/basic/shared/hooks/useSearch.ts b/src/basic/shared/hooks/useSearch.ts new file mode 100644 index 00000000..1c5d6dac --- /dev/null +++ b/src/basic/shared/hooks/useSearch.ts @@ -0,0 +1,37 @@ +import { useState } from 'react'; +import { useDebounce } from './useDebounce'; +import { SEARCH_DELAY } from '../constants/toast'; + +/** + * 검색 기능을 위한 커스텀 훅 + * @param delay - 디바운스 지연 시간 (기본값: 500ms) + * @returns 검색 관련 상태와 함수들 + */ +export function useSearch(delay: number = SEARCH_DELAY) { + const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearchTerm = useDebounce(searchTerm, delay); + + const handleSearchTermChange = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value); + }; + + /** + * 검색어 초기화 + */ + const clearSearch = () => { + setSearchTerm(''); + }; + + /** + * 검색어가 있는지 확인 + */ + const hasSearchTerm = debouncedSearchTerm.trim().length > 0; + + return { + searchTerm, + handleSearchTermChange, + debouncedSearchTerm, + clearSearch, + hasSearchTerm, + }; +} diff --git a/src/basic/shared/types/index.ts b/src/basic/shared/types/index.ts new file mode 100644 index 00000000..1fea582d --- /dev/null +++ b/src/basic/shared/types/index.ts @@ -0,0 +1,14 @@ +import { Product } from '../../../types'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export type { ProductWithUI, Notification }; diff --git a/src/basic/shared/utils/formatters.ts b/src/basic/shared/utils/formatters.ts new file mode 100644 index 00000000..42f8bc17 --- /dev/null +++ b/src/basic/shared/utils/formatters.ts @@ -0,0 +1,15 @@ +/** + * 가격을 한국 원화 형식으로 포맷 + * @param price - 포맷할 가격 (숫자) + * @returns 포맷된 가격 문자열 (예: "10,000원") + * @example formatKoreanPrice(10000) // "10,000원" + */ +export const formatKoreanPrice = (price: number): string => `${price.toLocaleString()}원`; + +/** + * 소수를 퍼센트로 변환하여 포맷 + * @param rate - 변환할 소수 (0.1 = 10%) + * @returns 퍼센트 문자열 (예: "10%") + * @example formatPercentage(0.1) // "10%" + */ +export const formatPercentage = (rate: number): string => `${Math.round(rate * 100)}%`; diff --git a/src/basic/shared/utils/index.ts b/src/basic/shared/utils/index.ts new file mode 100644 index 00000000..ca032fb6 --- /dev/null +++ b/src/basic/shared/utils/index.ts @@ -0,0 +1,2 @@ +export * from './formatters'; +export * from './validators'; diff --git a/src/basic/shared/utils/validators.ts b/src/basic/shared/utils/validators.ts new file mode 100644 index 00000000..603a74ee --- /dev/null +++ b/src/basic/shared/utils/validators.ts @@ -0,0 +1,97 @@ +import { DISCOUNT } from '../../constants/discount'; +import { STOCK } from '../../constants/product'; +import { MESSAGES } from '../../constants/message'; + +export const validator = { + /** + * 문자열 전체가 숫자인지 확인 + * @param value - 검증할 문자열 + * @returns 숫자 문자열 또는 null + */ + validateNumericString: (value: string): string | null => { + if (value === '') return ''; + if (/^\d+$/.test(value)) return value; + + return null; + }, + + /** + * 재고 수량 검증 (0 ~ 9999) + * @param stock - 검증할 재고 수량 + * @returns 검증 결과 객체 + */ + isValidStock: (stock: number): { isValid: boolean; message: string; correctedValue: number } => { + if (stock <= 0) + return { + isValid: false, + message: MESSAGES.STOCK.MIN, + correctedValue: 0, + }; + if (stock > STOCK.MAX_QUANTITY) + return { + isValid: false, + message: MESSAGES.STOCK.MAX, + correctedValue: STOCK.MAX_QUANTITY, + }; + + return { isValid: true, message: '', correctedValue: stock }; + }, + + /** + * 가격 검증 (0 이상) + * @param price - 검증할 가격 + * @returns 검증 결과 객체 + */ + isValidPrice: (price: number): { isValid: boolean; message: string; correctedValue: number } => { + if (price <= 0) + return { + isValid: false, + message: MESSAGES.PRICE.MIN, + correctedValue: 0, + }; + + return { isValid: true, message: '', correctedValue: price }; + }, + + /** + * 쿠폰 할인율 검증 (0 ~ 100) + * @param value - 검증할 쿠폰 할인율 + * @returns 검증 결과 객체 + */ + isValidDiscountPercentage: (value: number): { isValid: boolean; message: string; correctedValue: number } => { + if (value < 0) + return { + isValid: false, + message: MESSAGES.DISCOUNT_PERCENTAGE.MIN, + correctedValue: 0, + }; + if (value > DISCOUNT.MAX_PERCENTAGE) + return { + isValid: false, + message: MESSAGES.DISCOUNT_PERCENTAGE.MAX, + correctedValue: DISCOUNT.MAX_PERCENTAGE, + }; + return { isValid: true, message: '', correctedValue: value }; + }, + + /** + * 쿠폰 할인 금액 검증 (0 ~ 100000) + * @param value - 검증할 쿠폰 할인 금액 + * @returns 검증 결과 객체 + */ + isValidDiscountAmount: (value: number): { isValid: boolean; message: string; correctedValue: number } => { + if (value < 0) + return { + isValid: false, + message: MESSAGES.DISCOUNT_AMOUNT.MIN, + correctedValue: 0, + }; + if (value > DISCOUNT.MAX_COUPON_AMOUNT) + return { + isValid: false, + message: MESSAGES.DISCOUNT_AMOUNT.MAX, + correctedValue: DISCOUNT.MAX_COUPON_AMOUNT, + }; + return { isValid: true, message: '', correctedValue: value }; + }, +}; diff --git a/src/origin/App.tsx b/src/origin/App.tsx index a4369fe1..9c9426dd 100644 --- a/src/origin/App.tsx +++ b/src/origin/App.tsx @@ -1,11 +1,23 @@ import { useState, useCallback, useEffect } from 'react'; import { CartItem, Coupon, Product } from '../types'; +/** + * @interface ProductWithUI + * @extends {Product} + * @property {string} description - 상품 설명 + * @property {boolean} isRecommended - 추천 상품 여부 + */ interface ProductWithUI extends Product { description?: string; isRecommended?: boolean; } +/** + * @interface Notification + * @property {string} id - 알림 메시지 ID + * @property {string} message - 알림 메시지 내용 + * @property {'error' | 'success' | 'warning'} type - 알림 메시지 타입 + */ interface Notification { id: string; message: string; @@ -21,20 +33,18 @@ const initialProducts: ProductWithUI[] = [ stock: 20, discounts: [ { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } + { quantity: 20, rate: 0.2 }, ], - description: '최고급 품질의 프리미엄 상품입니다.' + description: '최고급 품질의 프리미엄 상품입니다.', }, { id: 'p2', name: '상품2', price: 20000, stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], + discounts: [{ quantity: 10, rate: 0.15 }], description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true + isRecommended: true, }, { id: 'p3', @@ -43,10 +53,10 @@ const initialProducts: ProductWithUI[] = [ stock: 20, discounts: [ { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } + { quantity: 30, rate: 0.25 }, ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } + description: '대용량과 고성능을 자랑하는 상품입니다.', + }, ]; const initialCoupons: Coupon[] = [ @@ -54,18 +64,22 @@ const initialCoupons: Coupon[] = [ name: '5000원 할인', code: 'AMOUNT5000', discountType: 'amount', - discountValue: 5000 + discountValue: 5000, }, { name: '10% 할인', code: 'PERCENT10', discountType: 'percentage', - discountValue: 10 - } + discountValue: 10, + }, ]; const App = () => { - + /** + * 상품 목록 + * 로컬 스토리지에 저장된 상품 목록을 사용하거나, 초기 데이터를 사용 + * @TODO 상태 초기값 로직에서 localStorage를 확인하는 로직이 있는데, 이 부분을 분리하여 사용하는 것이 좋을 듯. + */ const [products, setProducts] = useState(() => { const saved = localStorage.getItem('products'); if (saved) { @@ -78,6 +92,11 @@ const App = () => { return initialProducts; }); + /** + * 장바구니 + * 로컬 스토리지에 저장된 장바구니 목록을 사용하거나, 빈 배열을 사용 + * @TODO 상태 초기값 로직에서 localStorage를 확인하는 로직이 있는데, 이 부분을 분리하여 사용하는 것이 좋을 듯. + */ const [cart, setCart] = useState(() => { const saved = localStorage.getItem('cart'); if (saved) { @@ -90,6 +109,11 @@ const App = () => { return []; }); + /** + * 쿠폰 목록 + * 로컬 스토리지에 저장된 쿠폰 목록을 사용하거나, 초기 데이터를 사용 + * @TODO 상태 초기값 로직에서 localStorage를 확인하는 로직이 있는데, 이 부분을 분리하여 사용하는 것이 좋을 듯. + */ const [coupons, setCoupons] = useState(() => { const saved = localStorage.getItem('coupons'); if (saved) { @@ -118,20 +142,25 @@ const App = () => { price: 0, stock: 0, description: '', - discounts: [] as Array<{ quantity: number; rate: number }> + discounts: [] as Array<{ quantity: number; rate: number }>, }); const [couponForm, setCouponForm] = useState({ name: '', code: '', discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 + discountValue: 0, }); - + /** + * 상품 가격을 포맷팅하여 반환 + * @param price - 상품 가격 + * @param productId - 상품 ID + * @returns 포맷팅된 가격 + */ const formatPrice = (price: number, productId?: string): string => { if (productId) { - const product = products.find(p => p.id === productId); + const product = products.find((p) => p.id === productId); if (product && getRemainingStock(product) <= 0) { return 'SOLD OUT'; } @@ -140,36 +169,48 @@ const App = () => { if (isAdmin) { return `${price.toLocaleString()}원`; } - + return `₩${price.toLocaleString()}`; }; + /** + * 장바구니에 담긴 상품의 최대 적용 가능 할인을 계산 + * @param item - 장바구니 상품 + * @returns 최대 적용 가능 할인 + */ 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; + return quantity >= discount.quantity && discount.rate > maxDiscount ? discount.rate : maxDiscount; }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + + const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10); if (hasBulkPurchase) { return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 } - + return baseDiscount; }; + /** + * 장바구니에 담긴 상품의 총 가격을 계산 + * @param item - 장바구니 상품 + * @returns 총 가격 + */ const calculateItemTotal = (item: CartItem): number => { const { price } = item.product; const { quantity } = item; const discount = getMaxApplicableDiscount(item); - + return Math.round(price * quantity * (1 - discount)); }; + /** + * 결제 총 가격 계산 + * @returns 할인 전 총 가격, 할인 후 총 가격 + */ const calculateCartTotal = (): { totalBeforeDiscount: number; totalAfterDiscount: number; @@ -177,7 +218,7 @@ const App = () => { let totalBeforeDiscount = 0; let totalAfterDiscount = 0; - cart.forEach(item => { + cart.forEach((item) => { const itemPrice = item.product.price * item.quantity; totalBeforeDiscount += itemPrice; totalAfterDiscount += calculateItemTotal(item); @@ -193,29 +234,41 @@ const App = () => { return { totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) + totalAfterDiscount: Math.round(totalAfterDiscount), }; }; + /** + * 장바구니에 담긴 상품의 재고를 조회 + * @param product - 상품 + * @returns 재고 + */ const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); + const cartItem = cart.find((item) => item.product.id === product.id); const remaining = product.stock - (cartItem?.quantity || 0); - + return remaining; }; + /** + * 알림 메시지를 추가하고, 3초 후에 알림을 삭제 + * @param message - 알림 메시지 + * @param type - 알림 메시지 타입 + */ const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - + setNotifications((prev) => [...prev, { id, message, type }]); + setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); + 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); @@ -229,6 +282,9 @@ const App = () => { localStorage.setItem('coupons', JSON.stringify(coupons)); }, [coupons]); + /** + * 장바구니 상품 목록 로컬 스토리지 동기화 + */ useEffect(() => { if (cart.length > 0) { localStorage.setItem('cart', JSON.stringify(cart)); @@ -237,6 +293,9 @@ const App = () => { } }, [cart]); + /** + * 검색어 디바운스 + */ useEffect(() => { const timer = setTimeout(() => { setDebouncedSearchTerm(searchTerm); @@ -244,76 +303,96 @@ const App = () => { return () => clearTimeout(timer); }, [searchTerm]); - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } + /** + * 장바구니 상품 추가 로직 + * @param product - 상품 + * @returns 장바구니 상품 추가 + * @TODO 재고가 모자랄 때 + */ + 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; + 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.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); + return [...prevCart, { product, quantity: 1 }]; + }); + + addNotification('장바구니에 담았습니다', 'success'); + }, + [cart, addNotification, getRemainingStock], + ); + /** + * 장바구니 상품 삭제 로직 + * @param productId - 상품 ID + */ const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + setCart((prevCart) => prevCart.filter((item) => item.product.id !== productId)); }, []); - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } + /** + * 장바구니 상품 수량 변경 로직 + * @param productId - 상품 ID + * @param newQuantity - 새로운 수량 + */ + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return; + } - const product = products.find(p => p.id === productId); - if (!product) return; + const product = products.find((p) => p.id === productId); + if (!product) return; - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } + const maxStock = product.stock; + if (newQuantity > maxStock) { + addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); + return; + } - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } + setCart((prevCart) => + prevCart.map((item) => (item.product.id === productId ? { ...item, quantity: newQuantity } : item)), + ); + }, + [products, removeFromCart, addNotification, getRemainingStock], + ); - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); + /** + * 쿠폰 적용 로직 + * @param coupon - 쿠폰 + */ + 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()}`; @@ -322,49 +401,79 @@ const App = () => { setSelectedCoupon(null); }, [addNotification]); - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts((prev) => [...prev, product]); + addNotification('상품이 추가되었습니다.', 'success'); + }, + [addNotification], + ); - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); + /** + * 상품 수정 로직 + * @param productId - 상품 ID + * @param updates - 수정할 상품 정보 + */ + 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]); + /** + * 상품 삭제 로직 + * @param productId - 상품 ID + */ + 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]); + /** + * 쿠폰 추가 로직 + * @param newCoupon - 추가할 쿠폰 + */ + 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], + ); + + /** + * 쿠폰 삭제 로직 + * @param couponCode - 쿠폰 코드 + */ + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons((prev) => prev.filter((c) => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }, + [selectedCoupon, addNotification], + ); + /** + * 상품 추가/수정 폼 제출 로직 + * @param e - 이벤트 + */ const handleProductSubmit = (e: React.FormEvent) => { e.preventDefault(); if (editingProduct && editingProduct !== 'new') { @@ -373,7 +482,7 @@ const App = () => { } else { addProduct({ ...productForm, - discounts: productForm.discounts + discounts: productForm.discounts, }); } setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); @@ -381,6 +490,10 @@ const App = () => { setShowProductForm(false); }; + /** + * 쿠폰 추가/수정 폼 제출 로직 + * @param e - 이벤트 + */ const handleCouponSubmit = (e: React.FormEvent) => { e.preventDefault(); addCoupon(couponForm); @@ -388,11 +501,15 @@ const App = () => { name: '', code: '', discountType: 'amount', - discountValue: 0 + discountValue: 0, }); setShowCouponForm(false); }; + /** + * 상품 수정 시작 로직 + * @param product - 상품 + */ const startEditProduct = (product: ProductWithUI) => { setEditingProduct(product.id); setProductForm({ @@ -400,17 +517,22 @@ const App = () => { price: product.price, stock: product.stock, description: product.description || '', - discounts: product.discounts || [] + discounts: product.discounts || [], }); setShowProductForm(true); }; const totals = calculateCartTotal(); + /** + * 상품 검색 로직 + * @returns 검색된 상품 + */ const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) + ? products.filter( + (product) => + product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || + (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())), ) : products; @@ -418,18 +540,16 @@ const App = () => {
{notifications.length > 0 && (
- {notifications.map(notif => ( + {notifications.map((notif) => (
{notif.message} - - +
+
+

상품 목록

+ +
-
-
- - - - - - - - - - - - {(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="%" - /> - % 할인 + + + {(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}% 할인`} - +
+

쿠폰 관리

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

{coupon.name}

+

{coupon.code}

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

새 쿠폰 생성

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

새 쿠폰 생성

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

전체 상품

-
- 총 {products.length}개 상품 -
+
총 {products.length}개 상품
{filteredProducts.length === 0 ? (
@@ -908,126 +1064,159 @@ const App = () => {
) : (
- {filteredProducts.map(product => { - const remainingStock = getRemainingStock(product); - - return ( -
- {/* 상품 이미지 영역 (placeholder) */} -
-
- - - -
- {product.isRecommended && ( - - BEST - - )} - {product.discounts.length > 0 && ( - - ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% - - )} -
- - {/* 상품 정보 */} -
-

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

{product.name}

+ {product.description && ( +

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

+ )} + {remainingStock > 5 &&

재고 {remainingStock}개

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

- + 장바구니

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

장바구니가 비어있습니다

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

{item.product.name}

-
- {item.quantity} - +
{coupons.length > 0 && ( - @@ -1095,17 +1284,19 @@ const App = () => { )}
결제 예정 금액 - {totals.totalAfterDiscount.toLocaleString()}원 + + {totals.totalAfterDiscount.toLocaleString()}원 +
- + - +

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

@@ -1121,4 +1312,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/origin/__tests__/origin.test.tsx b/src/origin/__tests__/origin.test.tsx index 3f5c3d55..7c78d414 100644 --- a/src/origin/__tests__/origin.test.tsx +++ b/src/origin/__tests__/origin.test.tsx @@ -20,25 +20,28 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('고객 쇼핑 플로우', () => { test('상품을 검색하고 장바구니에 추가할 수 있다', async () => { render(); - + // 검색창에 "프리미엄" 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 디바운스 대기 - await waitFor(() => { - expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); - }, { timeout: 600 }); - + await waitFor( + () => { + expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); + }, + { timeout: 600 }, + ); + // 검색된 상품을 장바구니에 추가 (첫 번째 버튼 선택) const addButtons = screen.getAllByText('장바구니 담기'); fireEvent.click(addButtons[0]); - + // 알림 메시지 확인 await waitFor(() => { expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); }); - + // 장바구니에 추가됨 확인 (장바구니 섹션에서) const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -46,34 +49,34 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니에서 수량을 조절하고 할인을 확인할 수 있다', () => { render(); - + // 상품1을 장바구니에 추가 const product1 = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(product1); - + // 수량을 10개로 증가 (10% 할인 적용) const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 9; i++) { fireEvent.click(plusButton); } - + // 10% 할인 적용 확인 - 15% (대량 구매 시 추가 5% 포함) expect(screen.getByText('-15%')).toBeInTheDocument(); }); test('쿠폰을 선택하고 적용할 수 있다', () => { render(); - + // 상품 추가 const addButton = screen.getAllByText('장바구니 담기')[0]; fireEvent.click(addButton); - + // 쿠폰 선택 const couponSelect = screen.getByRole('combobox'); fireEvent.change(couponSelect, { target: { value: 'AMOUNT5000' } }); - + // 결제 정보에서 할인 금액 확인 const paymentSection = screen.getByText('결제 정보').closest('section'); const discountRow = within(paymentSection).getByText('할인 금액').closest('div'); @@ -82,28 +85,28 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('품절 임박 상품에 경고가 표시된다', async () => { render(); - + // 관리자 모드로 전환 fireEvent.click(screen.getByText('관리자 페이지로')); - + // 상품 수정 const editButton = screen.getAllByText('수정')[0]; fireEvent.click(editButton); - + // 재고를 5개로 변경 const stockInputs = screen.getAllByPlaceholderText('숫자만 입력'); const stockInput = stockInputs[1]; // 재고 입력 필드는 두 번째 fireEvent.change(stockInput, { target: { value: '5' } }); fireEvent.blur(stockInput); - + // 수정 완료 버튼 클릭 const editButtons = screen.getAllByText('수정'); const completeEditButton = editButtons[editButtons.length - 1]; // 마지막 수정 버튼 (완료 버튼) fireEvent.click(completeEditButton); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 품절임박 메시지 확인 - 재고가 5개 이하면 품절임박 표시 await waitFor(() => { expect(screen.getByText('품절임박! 5개 남음')).toBeInTheDocument(); @@ -112,39 +115,39 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('주문을 완료할 수 있다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 결제하기 버튼 클릭 const orderButton = screen.getByText(/원 결제하기/); fireEvent.click(orderButton); - + // 주문 완료 알림 확인 expect(screen.getByText(/주문이 완료되었습니다/)).toBeInTheDocument(); - + // 장바구니가 비어있는지 확인 expect(screen.getByText('장바구니가 비어있습니다')).toBeInTheDocument(); }); test('장바구니에서 상품을 삭제할 수 있다', () => { render(); - + // 상품 2개 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 장바구니 섹션 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); - + // 첫 번째 상품 삭제 (X 버튼) - const deleteButtons = within(cartSection).getAllByRole('button').filter( - button => button.querySelector('svg') - ); + const deleteButtons = within(cartSection) + .getAllByRole('button') + .filter((button) => button.querySelector('svg')); fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되고 상품2만 남음 expect(within(cartSection).queryByText('상품1')).not.toBeInTheDocument(); expect(within(cartSection).getByText('상품2')).toBeInTheDocument(); @@ -152,25 +155,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('재고를 초과하여 구매할 수 없다', async () => { render(); - + // 상품1 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 재고(20개) 이상으로 증가 시도 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + // 19번 클릭하여 총 20개로 만듦 for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 한 번 더 클릭 시도 (21개가 되려고 함) fireEvent.click(plusButton); - + // 수량이 20개에서 멈춰있어야 함 expect(within(cartSection).getByText('20')).toBeInTheDocument(); - + // 재고 부족 메시지 확인 await waitFor(() => { expect(screen.getByText(/재고는.*개까지만 있습니다/)).toBeInTheDocument(); @@ -179,27 +182,27 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니에서 수량을 감소시킬 수 있다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); const minusButton = within(cartSection).getByText('−'); // U+2212 마이너스 기호 - + // 수량 3개로 증가 fireEvent.click(plusButton); fireEvent.click(plusButton); expect(within(cartSection).getByText('3')).toBeInTheDocument(); - + // 수량 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('2')).toBeInTheDocument(); - + // 1개로 더 감소 fireEvent.click(minusButton); expect(within(cartSection).getByText('1')).toBeInTheDocument(); - + // 1개에서 한 번 더 감소하면 장바구니에서 제거될 수도 있음 fireEvent.click(minusButton); // 장바구니가 비었는지 확인 @@ -214,31 +217,31 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('20개 이상 구매 시 최대 할인이 적용된다', async () => { render(); - + // 관리자 모드로 전환하여 상품1의 재고를 늘림 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getAllByText('수정')[0]); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '30' } }); - + const editButtons = screen.getAllByText('수정'); fireEvent.click(editButtons[editButtons.length - 1]); - + // 쇼핑몰로 돌아가기 fireEvent.click(screen.getByText('쇼핑몰로 돌아가기')); - + // 상품1을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 수량을 20개로 증가 const cartSection = screen.getByText('장바구니').closest('section'); const plusButton = within(cartSection).getByText('+'); - + for (let i = 0; i < 19; i++) { fireEvent.click(plusButton); } - + // 25% 할인 적용 확인 (또는 대량 구매 시 30%) await waitFor(() => { const discount25 = screen.queryByText('-25%'); @@ -258,27 +261,27 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('새 상품을 추가할 수 있다', () => { // 새 상품 추가 버튼 클릭 fireEvent.click(screen.getByText('새 상품 추가')); - + // 폼 입력 - 상품명 입력 const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const nameLabel = labels.find((el) => el.tagName === 'LABEL'); const nameInput = nameLabel.closest('div').querySelector('input'); fireEvent.change(nameInput, { target: { value: '테스트 상품' } }); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; fireEvent.change(priceInput, { target: { value: '25000' } }); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '50' } }); - + const descLabels = screen.getAllByText('설명'); - const descLabel = descLabels.find(el => el.tagName === 'LABEL'); + const descLabel = descLabels.find((el) => el.tagName === 'LABEL'); const descInput = descLabel.closest('div').querySelector('input'); fireEvent.change(descInput, { target: { value: '테스트 설명' } }); - + // 저장 fireEvent.click(screen.getByText('추가')); - + // 추가된 상품 확인 expect(screen.getByText('테스트 상품')).toBeInTheDocument(); expect(screen.getByText('25,000원')).toBeInTheDocument(); @@ -287,21 +290,21 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 탭으로 전환하고 새 쿠폰을 추가할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 버튼 클릭 const addCouponButton = screen.getByText('새 쿠폰 추가'); fireEvent.click(addCouponButton); - + // 쿠폰 정보 입력 fireEvent.change(screen.getByPlaceholderText('신규 가입 쿠폰'), { target: { value: '테스트 쿠폰' } }); fireEvent.change(screen.getByPlaceholderText('WELCOME2024'), { target: { value: 'TEST2024' } }); - + const discountInput = screen.getByPlaceholderText('5000'); fireEvent.change(discountInput, { target: { value: '7000' } }); - + // 쿠폰 생성 fireEvent.click(screen.getByText('쿠폰 생성')); - + // 생성된 쿠폰 확인 expect(screen.getByText('테스트 쿠폰')).toBeInTheDocument(); expect(screen.getByText('TEST2024')).toBeInTheDocument(); @@ -311,25 +314,25 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('상품의 가격 입력 시 숫자만 허용된다', async () => { // 상품 수정 fireEvent.click(screen.getAllByText('수정')[0]); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; - + // 문자와 숫자 혼합 입력 시도 - 숫자만 남음 fireEvent.change(priceInput, { target: { value: 'abc123def' } }); expect(priceInput.value).toBe('10000'); // 유효하지 않은 입력은 무시됨 - + // 숫자만 입력 fireEvent.change(priceInput, { target: { value: '123' } }); expect(priceInput.value).toBe('123'); - + // 음수 입력 시도 - regex가 매치되지 않아 값이 변경되지 않음 fireEvent.change(priceInput, { target: { value: '-100' } }); expect(priceInput.value).toBe('123'); // 이전 값 유지 - + // 유효한 음수 입력하기 위해 먼저 1 입력 후 앞에 - 추가는 불가능 // 대신 blur 이벤트를 통해 음수 검증을 테스트 // parseInt()는 실제로 음수를 파싱할 수 있으므로 다른 방법으로 테스트 - + // 공백 입력 시도 fireEvent.change(priceInput, { target: { value: ' ' } }); expect(priceInput.value).toBe('123'); // 유효하지 않은 입력은 무시됨 @@ -338,20 +341,20 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰 할인율 검증이 작동한다', async () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 새 쿠폰 추가 fireEvent.click(screen.getByText('새 쿠폰 추가')); - + // 퍼센트 타입으로 변경 - 쿠폰 폼 내의 select 찾기 const couponFormSelects = screen.getAllByRole('combobox'); const typeSelect = couponFormSelects[couponFormSelects.length - 1]; // 마지막 select가 타입 선택 fireEvent.change(typeSelect, { target: { value: 'percentage' } }); - + // 100% 초과 할인율 입력 const discountInput = screen.getByPlaceholderText('10'); fireEvent.change(discountInput, { target: { value: '150' } }); fireEvent.blur(discountInput); - + // 에러 메시지 확인 await waitFor(() => { expect(screen.getByText('할인율은 100%를 초과할 수 없습니다')).toBeInTheDocument(); @@ -362,15 +365,15 @@ describe('쇼핑몰 앱 통합 테스트', () => { // 초기 상품명들 확인 (테이블에서) const productTable = screen.getByRole('table'); expect(within(productTable).getByText('상품1')).toBeInTheDocument(); - + // 삭제 버튼들 찾기 - const deleteButtons = within(productTable).getAllByRole('button').filter( - button => button.textContent === '삭제' - ); - + const deleteButtons = within(productTable) + .getAllByRole('button') + .filter((button) => button.textContent === '삭제'); + // 첫 번째 상품 삭제 fireEvent.click(deleteButtons[0]); - + // 상품1이 삭제되었는지 확인 expect(within(productTable).queryByText('상품1')).not.toBeInTheDocument(); expect(within(productTable).getByText('상품2')).toBeInTheDocument(); @@ -379,76 +382,74 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('쿠폰을 삭제할 수 있다', () => { // 쿠폰 관리 탭으로 전환 fireEvent.click(screen.getByText('쿠폰 관리')); - + // 초기 쿠폰들 확인 (h3 제목에서) const couponTitles = screen.getAllByRole('heading', { level: 3 }); - const coupon5000 = couponTitles.find(el => el.textContent === '5000원 할인'); - const coupon10 = couponTitles.find(el => el.textContent === '10% 할인'); + const coupon5000 = couponTitles.find((el) => el.textContent === '5000원 할인'); + const coupon10 = couponTitles.find((el) => el.textContent === '10% 할인'); expect(coupon5000).toBeInTheDocument(); expect(coupon10).toBeInTheDocument(); - + // 삭제 버튼 찾기 (SVG 아이콘을 포함한 버튼) - const deleteButtons = screen.getAllByRole('button').filter(button => { - return button.querySelector('svg') && - button.querySelector('path[d*="M19 7l"]'); // 삭제 아이콘 path + const deleteButtons = screen.getAllByRole('button').filter((button) => { + return button.querySelector('svg') && button.querySelector('path[d*="M19 7l"]'); // 삭제 아이콘 path }); - + // 첫 번째 쿠폰 삭제 fireEvent.click(deleteButtons[0]); - + // 쿠폰이 삭제되었는지 확인 expect(screen.queryByText('5000원 할인')).not.toBeInTheDocument(); }); - }); describe('로컬스토리지 동기화', () => { test('상품, 장바구니, 쿠폰이 localStorage에 저장된다', () => { render(); - + // 상품을 장바구니에 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // localStorage 확인 expect(localStorage.getItem('cart')).toBeTruthy(); expect(JSON.parse(localStorage.getItem('cart'))).toHaveLength(1); - + // 관리자 모드로 전환하여 새 상품 추가 fireEvent.click(screen.getByText('관리자 페이지로')); fireEvent.click(screen.getByText('새 상품 추가')); - + const labels = screen.getAllByText('상품명'); - const nameLabel = labels.find(el => el.tagName === 'LABEL'); + const nameLabel = labels.find((el) => el.tagName === 'LABEL'); const nameInput = nameLabel.closest('div').querySelector('input'); fireEvent.change(nameInput, { target: { value: '저장 테스트' } }); - + const priceInput = screen.getAllByPlaceholderText('숫자만 입력')[0]; fireEvent.change(priceInput, { target: { value: '10000' } }); - + const stockInput = screen.getAllByPlaceholderText('숫자만 입력')[1]; fireEvent.change(stockInput, { target: { value: '10' } }); - + fireEvent.click(screen.getByText('추가')); - + // localStorage에 products가 저장되었는지 확인 expect(localStorage.getItem('products')).toBeTruthy(); const products = JSON.parse(localStorage.getItem('products')); - expect(products.some(p => p.name === '저장 테스트')).toBe(true); + expect(products.some((p) => p.name === '저장 테스트')).toBe(true); }); test('페이지 새로고침 후에도 데이터가 유지된다', () => { const { unmount } = render(); - + // 장바구니에 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 컴포넌트 unmount unmount(); - + // 다시 mount render(); - + // 장바구니 아이템이 유지되는지 확인 const cartSection = screen.getByText('장바구니').closest('section'); expect(within(cartSection).getByText('상품1')).toBeInTheDocument(); @@ -459,13 +460,13 @@ describe('쇼핑몰 앱 통합 테스트', () => { describe('UI 상태 관리', () => { test('할인이 있을 때 할인율이 표시된다', async () => { render(); - + // 상품을 10개 담아서 할인 발생 const addButton = screen.getAllByText('장바구니 담기')[0]; for (let i = 0; i < 10; i++) { fireEvent.click(addButton); } - + // 할인율 표시 확인 - 대량 구매로 15% 할인 await waitFor(() => { expect(screen.getByText('-15%')).toBeInTheDocument(); @@ -474,12 +475,12 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('장바구니 아이템 개수가 헤더에 표시된다', () => { render(); - + // 상품 추가 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[0]); fireEvent.click(screen.getAllByText('장바구니 담기')[1]); - + // 헤더의 장바구니 아이콘 옆 숫자 확인 const cartCount = screen.getByText('3'); expect(cartCount).toBeInTheDocument(); @@ -487,21 +488,21 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('검색을 초기화할 수 있다', async () => { render(); - + // 검색어 입력 const searchInput = screen.getByPlaceholderText('상품 검색...'); fireEvent.change(searchInput, { target: { value: '프리미엄' } }); - + // 검색 결과 확인 await waitFor(() => { expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); // 다른 상품들은 보이지 않음 expect(screen.queryByText('다양한 기능을 갖춘 실용적인 상품입니다.')).not.toBeInTheDocument(); }); - + // 검색어 초기화 fireEvent.change(searchInput, { target: { value: '' } }); - + // 모든 상품이 다시 표시됨 await waitFor(() => { expect(screen.getByText('최고급 품질의 프리미엄 상품입니다.')).toBeInTheDocument(); @@ -512,17 +513,20 @@ describe('쇼핑몰 앱 통합 테스트', () => { test('알림 메시지가 자동으로 사라진다', async () => { render(); - + // 상품 추가하여 알림 발생 fireEvent.click(screen.getAllByText('장바구니 담기')[0]); - + // 알림 메시지 확인 expect(screen.getByText('장바구니에 담았습니다')).toBeInTheDocument(); - + // 3초 후 알림이 사라짐 - await waitFor(() => { - expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); - }, { timeout: 4000 }); + await waitFor( + () => { + expect(screen.queryByText('장바구니에 담았습니다')).not.toBeInTheDocument(); + }, + { timeout: 4000 }, + ); }); }); -}); \ No newline at end of file +}); diff --git a/src/origin/main.tsx b/src/origin/main.tsx index e63eef4a..e5775c05 100644 --- a/src/origin/main.tsx +++ b/src/origin/main.tsx @@ -1,9 +1,9 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( , -) +); diff --git a/src/refactoring(hint)/App.tsx b/src/refactoring(hint)/App.tsx index d8cc004c..1eec17d9 100644 --- a/src/refactoring(hint)/App.tsx +++ b/src/refactoring(hint)/App.tsx @@ -9,4 +9,4 @@ export function App() { // TODO: 구현 } -export default App; \ No newline at end of file +export default App; diff --git a/src/refactoring(hint)/components/AdminPage.tsx b/src/refactoring(hint)/components/AdminPage.tsx index afb5b1ae..6ec55e88 100644 --- a/src/refactoring(hint)/components/AdminPage.tsx +++ b/src/refactoring(hint)/components/AdminPage.tsx @@ -17,4 +17,4 @@ export function AdminPage() { // TODO: 구현 -} \ No newline at end of file +} diff --git a/src/refactoring(hint)/components/CartPage.tsx b/src/refactoring(hint)/components/CartPage.tsx index 069edafc..e6ff46a3 100644 --- a/src/refactoring(hint)/components/CartPage.tsx +++ b/src/refactoring(hint)/components/CartPage.tsx @@ -4,7 +4,7 @@ // 2. 장바구니 관리 // 3. 쿠폰 적용 // 4. 주문 처리 -// +// // 필요한 hooks: // - useProducts: 상품 목록 관리 // - useCart: 장바구니 상태 관리 @@ -18,4 +18,4 @@ export function CartPage() { // TODO: 구현 -} \ No newline at end of file +} diff --git a/src/refactoring(hint)/components/icons/index.tsx b/src/refactoring(hint)/components/icons/index.tsx index 1609d774..aaec6395 100644 --- a/src/refactoring(hint)/components/icons/index.tsx +++ b/src/refactoring(hint)/components/icons/index.tsx @@ -9,4 +9,4 @@ // - ChevronUpIcon: 위 화살표 // - CheckIcon: 체크 아이콘 -// TODO: 구현 \ No newline at end of file +// TODO: 구현 diff --git a/src/refactoring(hint)/constants/index.ts b/src/refactoring(hint)/constants/index.ts index bef3834f..9f891b90 100644 --- a/src/refactoring(hint)/constants/index.ts +++ b/src/refactoring(hint)/constants/index.ts @@ -5,4 +5,4 @@ // // 참고: origin/App.tsx의 초기 데이터 구조를 참조 -// TODO: 구현 \ No newline at end of file +// TODO: 구현 diff --git a/src/refactoring(hint)/hooks/useCart.ts b/src/refactoring(hint)/hooks/useCart.ts index 6db309aa..a9c7290d 100644 --- a/src/refactoring(hint)/hooks/useCart.ts +++ b/src/refactoring(hint)/hooks/useCart.ts @@ -26,4 +26,4 @@ export function useCart() { // TODO: 구현 -} \ No newline at end of file +} diff --git a/src/refactoring(hint)/hooks/useCoupons.ts b/src/refactoring(hint)/hooks/useCoupons.ts index d2ad82ab..779ecf6d 100644 --- a/src/refactoring(hint)/hooks/useCoupons.ts +++ b/src/refactoring(hint)/hooks/useCoupons.ts @@ -10,4 +10,4 @@ export function useCoupons() { // TODO: 구현 -} \ No newline at end of file +} diff --git a/src/refactoring(hint)/hooks/useProducts.ts b/src/refactoring(hint)/hooks/useProducts.ts index f4bef103..5c73269e 100644 --- a/src/refactoring(hint)/hooks/useProducts.ts +++ b/src/refactoring(hint)/hooks/useProducts.ts @@ -15,4 +15,4 @@ export function useProducts() { // TODO: 구현 -} \ No newline at end of file +} diff --git a/src/refactoring(hint)/main.tsx b/src/refactoring(hint)/main.tsx index 589b1645..0a6203a0 100644 --- a/src/refactoring(hint)/main.tsx +++ b/src/refactoring(hint)/main.tsx @@ -1,4 +1,4 @@ // TODO: React 앱 엔트리 포인트 // App 컴포넌트를 root DOM 요소에 렌더링 -// TODO: 구현 \ No newline at end of file +// TODO: 구현 diff --git a/src/refactoring(hint)/models/cart.ts b/src/refactoring(hint)/models/cart.ts index 5c681048..590feb7a 100644 --- a/src/refactoring(hint)/models/cart.ts +++ b/src/refactoring(hint)/models/cart.ts @@ -15,4 +15,4 @@ // - 외부 상태에 의존하지 않음 // - 모든 필요한 데이터는 파라미터로 전달받음 -// TODO: 구현 \ No newline at end of file +// TODO: 구현 diff --git a/src/refactoring(hint)/utils/formatters.ts b/src/refactoring(hint)/utils/formatters.ts index ff157f5c..6fb3aae7 100644 --- a/src/refactoring(hint)/utils/formatters.ts +++ b/src/refactoring(hint)/utils/formatters.ts @@ -4,4 +4,4 @@ // - formatDate(date: Date): string - 날짜를 YYYY-MM-DD 형식으로 포맷 // - formatPercentage(rate: number): string - 소수를 퍼센트로 변환 (0.1 → 10%) -// TODO: 구현 \ No newline at end of file +// TODO: 구현 diff --git a/src/refactoring(hint)/utils/hooks/useDebounce.ts b/src/refactoring(hint)/utils/hooks/useDebounce.ts index 53c8a374..6e2b08e8 100644 --- a/src/refactoring(hint)/utils/hooks/useDebounce.ts +++ b/src/refactoring(hint)/utils/hooks/useDebounce.ts @@ -8,4 +8,4 @@ export function useDebounce(value: T, delay: number): T { // TODO: 구현 -} \ No newline at end of file +} diff --git a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts b/src/refactoring(hint)/utils/hooks/useLocalStorage.ts index 5dc72c50..b845e660 100644 --- a/src/refactoring(hint)/utils/hooks/useLocalStorage.ts +++ b/src/refactoring(hint)/utils/hooks/useLocalStorage.ts @@ -7,9 +7,6 @@ // // 반환값: [저장된 값, 값 설정 함수] -export function useLocalStorage( - key: string, - initialValue: T -): [T, (value: T | ((val: T) => T)) => void] { +export function useLocalStorage(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] { // TODO: 구현 -} \ No newline at end of file +} diff --git a/src/refactoring(hint)/utils/validators.ts b/src/refactoring(hint)/utils/validators.ts index 7d2dda44..32d99d9d 100644 --- a/src/refactoring(hint)/utils/validators.ts +++ b/src/refactoring(hint)/utils/validators.ts @@ -5,4 +5,4 @@ // - isValidPrice(price: number): boolean - 가격 검증 (양수) // - extractNumbers(value: string): string - 문자열에서 숫자만 추출 -// TODO: 구현 \ No newline at end of file +// TODO: 구현 diff --git a/src/types.ts b/src/types.ts index 5489e296..8d56eaa2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,11 @@ +/** + * @interface Product + * @property {string} id - 상품 ID + * @property {string} name - 상품 이름 + * @property {number} price - 상품 가격 + * @property {number} stock - 상품 재고 + * @property {Discount[]} discounts - 상품 할인 정보 + */ export interface Product { id: string; name: string; @@ -6,19 +14,66 @@ export interface Product { discounts: Discount[]; } +/** + * @interface Discount + * @property {number} quantity - 할인 적용 수량 + * @property {number} rate - 할인 비율 + */ export interface Discount { quantity: number; rate: number; } +/** + * @interface CartItem + * @property {Product} product - 상품 + * @property {number} quantity - 수량 + */ export interface CartItem { product: Product; quantity: number; } +/** + * @interface Coupon + * @property {string} name - 쿠폰 이름 + * @property {string} code - 쿠폰 코드 + * @property {'amount' | 'percentage'} discountType - 할인 타입 + * @property {number} discountValue - 할인 값 + */ export interface Coupon { name: string; code: string; discountType: 'amount' | 'percentage'; discountValue: number; } + +/** + * @interface CouponFormData + * @property {string} name - 쿠폰 이름 + * @property {string} code - 쿠폰 코드 + * @property {'amount' | 'percentage'} discountType - 할인 타입 + * @property {number} discountValue - 할인 값 + */ +export interface CouponFormData { + name: string; + code: string; + discountType: 'amount' | 'percentage'; + discountValue: number; +} + +/** + * @interface ProductFormData + * @property {string} name - 상품 이름 + * @property {number} price - 상품 가격 + * @property {number} stock - 상품 재고 + * @property {string} description - 상품 설명 + * @property {Array<{ quantity: number; rate: number }>} discounts - 상품 할인 정보 + */ +export interface ProductFormData { + name: string; + price: number; + stock: number; + description: string; + discounts: Array<{ quantity: number; rate: number }>; +} diff --git a/vite.config.advanced.ts b/vite.config.advanced.ts new file mode 100644 index 00000000..6f1cf21f --- /dev/null +++ b/vite.config.advanced.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; + +export default defineConfig({ + plugins: [react()], + base: "/front_6th_chapter2-2/", + build: { + rollupOptions: { + input: "index.advanced.html", + output: { + entryFileNames: "assets/[name]-[hash].js", + chunkFileNames: "assets/[name]-[hash].js", + assetFileNames: "assets/[name]-[hash].[ext]", + }, + }, + }, +}); \ No newline at end of file