diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 65ba6d2d6..e117392aa 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -50,4 +50,4 @@ ### 이번 과제를 통해 앞으로 해보고 싶은게 있다면 알려주세요! -### 리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 편하게 남겨주세요 :) +### 리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 편하게 남겨주세요 : diff --git a/package.json b/package.json index 17b18de25..a924d48b8 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "test:advanced": "vitest src/advanced", "test:ui": "vitest --ui", "build": "tsc -b && vite build", + "deploy": "pnpm run build && gh-pages -d dist -b gh-pages", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "jotai": "^2.15.2", "react": "^19.1.1", "react-dom": "^19.1.1" }, @@ -23,6 +25,7 @@ "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.1", "@types/react": "^19.1.9", "@types/react-dom": "^19.1.7", "@typescript-eslint/eslint-plugin": "^8.38.0", @@ -32,6 +35,7 @@ "eslint": "^9.32.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", + "gh-pages": "^6.3.0", "jsdom": "^26.1.0", "typescript": "^5.9.2", "vite": "^7.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85f..4f4accd8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + jotai: + specifier: ^2.15.2 + version: 2.15.2(@types/react@19.1.9)(react@19.1.1) react: specifier: ^19.1.1 version: 19.1.1 @@ -24,6 +27,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 '@types/react': specifier: ^19.1.9 version: 19.1.9 @@ -38,7 +44,7 @@ importers: version: 8.38.0(eslint@9.32.0)(typescript@5.9.2) '@vitejs/plugin-react-swc': specifier: ^3.11.0 - version: 3.11.0(vite@7.0.6) + version: 3.11.0(vite@7.0.6(@types/node@24.10.1)) '@vitest/ui': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) @@ -51,6 +57,9 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.20 version: 0.4.20(eslint@9.32.0) + gh-pages: + specifier: ^6.3.0 + version: 6.3.0 jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -59,10 +68,10 @@ importers: version: 5.9.2 vite: specifier: ^7.0.6 - version: 7.0.6 + version: 7.0.6(@types/node@24.10.1) vitest: specifier: ^3.2.4 - version: 3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0) + version: 3.2.4(@types/node@24.10.1)(@vitest/ui@3.2.4)(jsdom@26.1.0) packages: @@ -583,6 +592,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + '@types/react-dom@19.1.7': resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} peerDependencies: @@ -732,10 +744,17 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -786,6 +805,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -839,12 +865,19 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + email-addresses@5.0.0: + resolution: {integrity: sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -956,10 +989,26 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + filename-reserved-regex@2.0.0: + resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} + engines: {node: '>=4'} + + filenamify@4.3.0: + resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==} + engines: {node: '>=8'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -974,11 +1023,20 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + fs-extra@11.3.2: + resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} + engines: {node: '>=14.14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + gh-pages@6.3.0: + resolution: {integrity: sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==} + engines: {node: '>=10'} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -991,6 +1049,13 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -1056,6 +1121,24 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jotai@2.15.2: + resolution: {integrity: sha512-El86CCfXNMEOytp20NPfppqGGmcp6H6kIA+tJHdmASEUURJCYW4fh8nTHEnB8rUXEFAY1pm8PdHPwnrcPGwdEg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1084,6 +1167,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1091,6 +1177,10 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1117,6 +1207,10 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1158,14 +1252,26 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1181,6 +1287,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1203,6 +1313,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1270,6 +1384,10 @@ packages: scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.6.3: resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} @@ -1290,6 +1408,10 @@ packages: resolution: {integrity: sha512-FoqMu0NCGBLCcAkS1qA+XJIQTR6/JHfQXl+uGteNCQ76T91DMUjPa9xfmeqMY3z80nLSg9yQmNjK0Px6RWsH/A==} engines: {node: '>=18'} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1311,6 +1433,10 @@ packages: strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + strip-outer@1.0.1: + resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} + engines: {node: '>=0.10.0'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -1367,6 +1493,10 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + trim-repeated@1.0.0: + resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} + engines: {node: '>=0.10.0'} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -1382,6 +1512,13 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1886,6 +2023,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + '@types/react-dom@19.1.7(@types/react@19.1.9)': dependencies: '@types/react': 19.1.9 @@ -1987,11 +2128,11 @@ snapshots: '@typescript-eslint/types': 8.38.0 eslint-visitor-keys: 4.2.1 - '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6)': + '@vitejs/plugin-react-swc@3.11.0(vite@7.0.6(@types/node@24.10.1))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.27 '@swc/core': 1.13.3 - vite: 7.0.6 + vite: 7.0.6(@types/node@24.10.1) transitivePeerDependencies: - '@swc/helpers' @@ -2003,13 +2144,13 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.0.6)': + '@vitest/mocker@3.2.4(vite@7.0.6(@types/node@24.10.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.0.6 + vite: 7.0.6(@types/node@24.10.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -2040,7 +2181,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0) + vitest: 3.2.4(@types/node@24.10.1)(@vitest/ui@3.2.4)(jsdom@26.1.0) '@vitest/utils@3.2.4': dependencies: @@ -2083,8 +2224,12 @@ snapshots: aria-query@5.3.2: {} + array-union@2.1.0: {} + assertion-error@2.0.1: {} + async@3.2.6: {} + balanced-match@1.0.2: {} brace-expansion@1.1.11: @@ -2137,6 +2282,10 @@ snapshots: color-name@1.1.4: {} + commander@13.1.0: {} + + commondir@1.0.1: {} + concat-map@0.0.1: {} cross-spawn@7.0.6: @@ -2175,10 +2324,16 @@ snapshots: dequal@2.0.3: {} + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} + email-addresses@5.0.0: {} + entities@4.5.0: {} es-module-lexer@1.7.0: {} @@ -2329,10 +2484,29 @@ snapshots: dependencies: flat-cache: 4.0.1 + filename-reserved-regex@2.0.0: {} + + filenamify@4.3.0: + dependencies: + filename-reserved-regex: 2.0.0 + strip-outer: 1.0.1 + trim-repeated: 1.0.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -2347,9 +2521,25 @@ snapshots: flatted@3.3.3: {} + fs-extra@11.3.2: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fsevents@2.3.3: optional: true + gh-pages@6.3.0: + dependencies: + async: 3.2.6 + commander: 13.1.0 + email-addresses: 5.0.0 + filenamify: 4.3.0 + find-cache-dir: 3.3.2 + fs-extra: 11.3.2 + globby: 11.1.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2360,6 +2550,17 @@ snapshots: globals@14.0.0: {} + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + graceful-fs@4.2.11: {} + graphemer@1.4.0: {} has-flag@3.0.0: {} @@ -2413,6 +2614,11 @@ snapshots: isexe@2.0.0: {} + jotai@2.15.2(@types/react@19.1.9)(react@19.1.1): + optionalDependencies: + '@types/react': 19.1.9 + react: 19.1.1 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -2454,6 +2660,12 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -2463,6 +2675,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -2483,6 +2699,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + merge2@1.4.1: {} micromatch@4.0.8: @@ -2519,14 +2739,24 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -2539,6 +2769,8 @@ snapshots: path-key@3.1.1: {} + path-type@4.0.0: {} + pathe@2.0.3: {} pathval@2.0.0: {} @@ -2551,6 +2783,10 @@ snapshots: picomatch@4.0.3: {} + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -2629,6 +2865,8 @@ snapshots: scheduler@0.26.0: {} + semver@6.3.1: {} + semver@7.6.3: {} shebang-command@2.0.0: @@ -2645,6 +2883,8 @@ snapshots: mrmime: 2.0.0 totalist: 3.0.1 + slash@3.0.0: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} @@ -2661,6 +2901,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + strip-outer@1.0.1: + dependencies: + escape-string-regexp: 1.0.5 + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -2706,6 +2950,10 @@ snapshots: dependencies: punycode: 2.3.1 + trim-repeated@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + ts-api-utils@2.1.0(typescript@5.9.2): dependencies: typescript: 5.9.2 @@ -2716,17 +2964,21 @@ snapshots: typescript@5.9.2: {} + undici-types@7.16.0: {} + + universalify@2.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 - vite-node@3.2.4: + vite-node@3.2.4(@types/node@24.10.1): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.0.6 + vite: 7.0.6(@types/node@24.10.1) transitivePeerDependencies: - '@types/node' - jiti @@ -2741,7 +2993,7 @@ snapshots: - tsx - yaml - vite@7.0.6: + vite@7.0.6(@types/node@24.10.1): dependencies: esbuild: 0.25.8 fdir: 6.4.6(picomatch@4.0.3) @@ -2750,13 +3002,14 @@ snapshots: rollup: 4.46.2 tinyglobby: 0.2.14 optionalDependencies: + '@types/node': 24.10.1 fsevents: 2.3.3 - vitest@3.2.4(@vitest/ui@3.2.4)(jsdom@26.1.0): + vitest@3.2.4(@types/node@24.10.1)(@vitest/ui@3.2.4)(jsdom@26.1.0): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.0.6) + '@vitest/mocker': 3.2.4(vite@7.0.6(@types/node@24.10.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -2774,10 +3027,11 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.0.6 - vite-node: 3.2.4 + vite: 7.0.6(@types/node@24.10.1) + vite-node: 3.2.4(@types/node@24.10.1) why-is-node-running: 2.3.0 optionalDependencies: + '@types/node': 24.10.1 '@vitest/ui': 3.2.4(vitest@3.2.4) jsdom: 26.1.0 transitivePeerDependencies: diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1d..148a27a35 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,16 +1,22 @@ -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'; -} +import { useState } from 'react'; +import { Provider, createStore, useAtomValue } from 'jotai'; +import Header from './widgets/Header/Header'; +import AdminPage, { ProductWithUI } from './pages/AdminPage'; +import CartPage from './pages/CartPage'; +import { Coupon } from './entities/coupon/model'; +import { useToast } from './shared/hooks/useToast'; +import { ToastContainer } from './shared/ui/Toast/ToastContainer'; +import type { CartItem } from './entities/cart/model'; +import { + cartAtom, + couponsAtom, + isAdminAtom, + productsAtom, + searchTermAtom, + selectedCouponAtom, + toastsAtom +} from './shared/store/atoms'; +import type { Toast } from './shared/store/atoms'; // 초기 데이터 const initialProducts: ProductWithUI[] = [ @@ -30,9 +36,7 @@ const initialProducts: ProductWithUI[] = [ name: '상품2', price: 20000, stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], + discounts: [{ quantity: 10, rate: 0.15 }], description: '다양한 기능을 갖춘 실용적인 상품입니다.', isRecommended: true }, @@ -64,1060 +68,79 @@ const initialCoupons: Coupon[] = [ } ]; -const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; +const STORAGE_KEYS = { + products: 'products', + coupons: 'coupons', + cart: 'cart' +} as const; - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); +const loadPersistedOrDefault = (key: string, fallback: T): T => { + if (typeof window === 'undefined') { + return fallback; + } - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; + try { + const rawValue = window.localStorage.getItem(key); + if (!rawValue) { + return fallback; } - 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; - } + return JSON.parse(rawValue) as T; + } catch (error) { + console.warn(`[storage] Failed to parse key "${key}"`, error); + return fallback; + } +}; - const product = products.find(p => p.id === productId); - if (!product) return; +const AppContent = () => { + const isAdmin = useAtomValue(isAdminAtom); + const { toasts, removeToast } = useToast(); - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } + return ( +
+ +
+
+ {isAdmin ? : } +
+
+ ); +}; - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item +const App = () => { + const [store] = useState(() => { + const jotaiStore = createStore(); + + jotaiStore.set( + productsAtom, + loadPersistedOrDefault( + STORAGE_KEYS.products, + initialProducts ) ); - }, [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 - ) + jotaiStore.set( + couponsAtom, + loadPersistedOrDefault(STORAGE_KEYS.coupons, initialCoupons) ); - 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); - }; + jotaiStore.set( + cartAtom, + loadPersistedOrDefault(STORAGE_KEYS.cart, [] as CartItem[]) + ); - const totals = calculateCartTotal(); + jotaiStore.set(toastsAtom, [] as Toast[]); + jotaiStore.set(selectedCouponAtom, null); + jotaiStore.set(isAdminAtom, false); + jotaiStore.set(searchTermAtom, ''); - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + return jotaiStore; + }); 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()}원 -
-
- - - -
-

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

-
-
- - )} -
-
-
- )} -
-
+ + + ); }; diff --git a/src/advanced/entities/cart/model.ts b/src/advanced/entities/cart/model.ts new file mode 100644 index 000000000..38a295a61 --- /dev/null +++ b/src/advanced/entities/cart/model.ts @@ -0,0 +1,12 @@ +/** + * Cart Entity - 타입 정의 + * + * 장바구니 관련 도메인 타입 + */ + +import { Product } from '../product/model'; + +export interface CartItem { + product: Product; + quantity: number; +} diff --git a/src/advanced/entities/cart/utils.ts b/src/advanced/entities/cart/utils.ts new file mode 100644 index 000000000..0251324cc --- /dev/null +++ b/src/advanced/entities/cart/utils.ts @@ -0,0 +1,142 @@ +/** + * Cart Entity - 계산 로직 + * + * 장바구니 관련 순수 함수 (비즈니스 로직) + */ + +import { CartItem } from './model'; +import { Coupon } from '../coupon/model'; + +/** + * 아이템에 적용 가능한 최대 할인율 계산 + * - 수량 기반 할인 + * - 대량 구매 시 추가 5% 할인 (10개 이상 구매 시) + */ +export const getMaxApplicableDiscount = ( + item: CartItem, + cart: CartItem[] +): number => { + const { discounts } = item.product; + const { quantity } = item; + + // 수량 기반 할인율 계산 + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + // 대량 구매 보너스 (장바구니에 10개 이상 구매 항목이 있으면 추가 5%) + const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 최대 50% 제한 + } + + return baseDiscount; +}; + +/** + * 개별 아이템의 총 가격 계산 (할인 적용 후) + */ +export const calculateItemTotal = ( + item: CartItem, + cart: CartItem[] +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +/** + * 장바구니 전체 금액 계산 + * - 할인 전 총액 + * - 할인 후 총액 (쿠폰 적용 포함) + */ +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach(item => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + // 쿠폰 적용 + if (selectedCoupon) { + if (selectedCoupon.discountType === 'amount') { + totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); + } else { + totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount) + }; +}; + +/** + * 장바구니 아이템 수량 업데이트 + */ +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + newQuantity: number +): CartItem[] => { + if (newQuantity <= 0) { + return cart.filter(item => item.product.id !== productId); + } + + return cart.map(item => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ); +}; + +/** + * 장바구니에 상품 추가 + */ +export const addProductToCart = ( + cart: CartItem[], + product: CartItem['product'] +): CartItem[] => { + const existingItem = cart.find(item => item.product.id === product.id); + + if (existingItem) { + return cart.map(item => + item.product.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + ); + } + + return [...cart, { product, quantity: 1 }]; +}; + +/** + * 장바구니에서 상품 제거 + */ +export const removeProductFromCart = ( + cart: CartItem[], + productId: string +): CartItem[] => { + return cart.filter(item => item.product.id !== productId); +}; + +/** + * 장바구니의 총 아이템 개수 계산 + */ +export const getTotalCartQuantity = (cart: CartItem[]): number => { + return cart.reduce((total, item) => total + item.quantity, 0); +}; diff --git a/src/advanced/entities/coupon/model.ts b/src/advanced/entities/coupon/model.ts new file mode 100644 index 000000000..7b3fe54be --- /dev/null +++ b/src/advanced/entities/coupon/model.ts @@ -0,0 +1,12 @@ +/** + * Coupon Entity - 타입 정의 + * + * 쿠폰 관련 도메인 타입 + */ + +export interface Coupon { + name: string; + code: string; + discountType: 'amount' | 'percentage'; + discountValue: number; +} diff --git a/src/advanced/entities/coupon/utils.ts b/src/advanced/entities/coupon/utils.ts new file mode 100644 index 000000000..61ee79025 --- /dev/null +++ b/src/advanced/entities/coupon/utils.ts @@ -0,0 +1,35 @@ +/** + * Coupon Entity - 검증 로직 + * + * 쿠폰 관련 순수 함수 (비즈니스 로직) + */ + +import { Coupon } from './model'; + +/** + * 쿠폰 적용 가능 여부 검증 + * - percentage 쿠폰은 10,000원 이상 구매 시에만 사용 가능 + */ +export const canApplyCoupon = ( + coupon: Coupon, + totalAmount: number +): { canApply: boolean; reason?: string } => { + if (coupon.discountType === 'percentage' && totalAmount < 10000) { + return { + canApply: false, + reason: 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.' + }; + } + + return { canApply: true }; +}; + +/** + * 쿠폰 중복 여부 검증 + */ +export const isDuplicateCoupon = ( + coupons: Coupon[], + couponCode: string +): boolean => { + return coupons.some(c => c.code === couponCode); +}; diff --git a/src/advanced/entities/product/model.ts b/src/advanced/entities/product/model.ts new file mode 100644 index 000000000..4f2e08bbc --- /dev/null +++ b/src/advanced/entities/product/model.ts @@ -0,0 +1,23 @@ +/** + * Product Entity - 타입 정의 + * + * 상품 관련 도메인 타입 + */ + +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface ProductWithUI extends Product { + showAddProductForm: boolean; + editingProductId: string | null; +} + +export interface Discount { + quantity: number; + rate: number; +} diff --git a/src/advanced/entities/product/utils.ts b/src/advanced/entities/product/utils.ts new file mode 100644 index 000000000..eba7ac377 --- /dev/null +++ b/src/advanced/entities/product/utils.ts @@ -0,0 +1,65 @@ +/** + * Product Entity - 계산 로직 + * + * 상품 관련 순수 함수 (비즈니스 로직) + */ + +import { Product } from './model'; +import { CartItem } from '../cart/model'; + +/** + * 상품의 남은 재고 계산 + * (전체 재고 - 장바구니에 담긴 수량) + */ +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 const canAddToCart = ( + product: Product, + cart: CartItem[] +): boolean => { + return getRemainingStock(product, cart) > 0; +}; + +/** + * 상품의 재고가 충분한지 검증 + */ +export const hasEnoughStock = ( + product: Product, + requestedQuantity: number, + cart: CartItem[] +): boolean => { + const remainingStock = getRemainingStock(product, cart); + return remainingStock >= requestedQuantity; +}; + +/** + * 상품 목록 검색 필터링 + */ +export const filterProducts = ( + products: Product[], + searchTerm: string +): Product[] => { + if (!searchTerm) return products; + + const lowerSearchTerm = searchTerm.toLowerCase(); + + return products.filter(product => { + const nameMatch = product.name.toLowerCase().includes(lowerSearchTerm); + const descriptionMatch = 'description' in product && + typeof (product as any).description === 'string' && + (product as any).description.toLowerCase().includes(lowerSearchTerm); + + return nameMatch || descriptionMatch; + }); +}; diff --git a/src/advanced/hooks/useCart.ts b/src/advanced/hooks/useCart.ts new file mode 100644 index 000000000..bb49a6d56 --- /dev/null +++ b/src/advanced/hooks/useCart.ts @@ -0,0 +1,147 @@ +/** + * Cart Feature - useCart Hook + * + * 장바구니 상태 관리 및 비즈니스 로직 + */ +import { useCallback, useMemo } from 'react'; +import { useAtom, useAtomValue } from 'jotai'; +import { CartItem } from '../entities/cart/model'; +import { Coupon } from '../entities/coupon/model'; +import { Product } from '../entities/product/model'; +import { + calculateCartTotal as calcCartTotal, + calculateItemTotal as calcItemTotal +} from '../entities/cart/utils'; +import { getRemainingStock as getStock } from '../entities/product/utils'; +import { canApplyCoupon } from '../entities/coupon/utils'; +import { generateOrderNumber } from '../shared/utils/format'; +import { cartAtom, productsAtom, selectedCouponAtom } from '../shared/store/atoms'; +import { useToast } from '../shared/hooks/useToast'; + +export const useCart = () => { + const products = useAtomValue(productsAtom); + const [cart, setCart] = useAtom(cartAtom); + const [selectedCoupon, setSelectedCoupon] = useAtom(selectedCouponAtom); + const { addToast } = useToast(); + + // 장바구니에 상품 추가 + const addToCart = useCallback((product: Product) => { + const remainingStock = getStock(product, cart); + if (remainingStock <= 0) { + addToast('재고가 부족합니다!', '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) { + addToast(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); + return prevCart; + } + } + + // 재고 검증 통과 + if (existingItem) { + return prevCart.map(item => + item.product.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + addToast('장바구니에 담았습니다', 'success'); + }, [addToast, cart, setCart]); + + // 장바구니에서 상품 제거 + const removeFromCart = useCallback((productId: string) => { + setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + }, [setCart]); + + // 수량 업데이트 + 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) { + addToast(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); + return; + } + + setCart(prevCart => + prevCart.map(item => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ) + ); + }, [addToast, products, removeFromCart, setCart]); + + // 쿠폰 적용 + const applyCoupon = useCallback((coupon: Coupon) => { + const currentTotal = calcCartTotal(cart, selectedCoupon).totalAfterDiscount; + + const { canApply: isValid, reason } = canApplyCoupon(coupon, currentTotal); + if (!isValid && reason) { + addToast(reason, 'error'); + return; + } + + setSelectedCoupon(coupon); + addToast('쿠폰이 적용되었습니다.', 'success'); + }, [addToast, cart, selectedCoupon]); + + // 쿠폰 제거 + const removeCoupon = useCallback(() => { + setSelectedCoupon(null); + }, []); + + // 주문 완료 + const completeOrder = useCallback(() => { + const orderNumber = generateOrderNumber(); + addToast(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + setCart([]); + setSelectedCoupon(null); + }, [addToast, setCart]); + + // 남은 재고 계산 + const getRemainingStock = useCallback((product: Product): number => { + return getStock(product, cart); + }, [cart]); + + // 개별 아이템 총액 계산 + const calculateItemTotal = useCallback((item: CartItem): number => { + return calcItemTotal(item, cart); + }, [cart]); + + // 장바구니 총액 계산 (메모이제이션) + const totals = useMemo(() => { + return calcCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + return { + cart, + selectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + removeCoupon, + completeOrder, + getRemainingStock, + calculateItemTotal, + totals + }; +}; diff --git a/src/advanced/hooks/useCoupon.ts b/src/advanced/hooks/useCoupon.ts new file mode 100644 index 000000000..f1bddd31d --- /dev/null +++ b/src/advanced/hooks/useCoupon.ts @@ -0,0 +1,38 @@ +/** + * Coupon Feature - useCoupon Hook + * + * 쿠폰 상태 관리 및 CRUD 로직 + */ +import { useCallback } from 'react'; +import { useAtom } from 'jotai'; +import { Coupon } from '../entities/coupon/model'; +import { isDuplicateCoupon } from '../entities/coupon/utils'; +import { couponsAtom } from '../shared/store/atoms'; +import { useToast } from '../shared/hooks/useToast'; + +export const useCoupon = () => { + const [coupons, setCoupons] = useAtom(couponsAtom); + const { addToast } = useToast(); + + // 쿠폰 추가 + const addCoupon = useCallback((newCoupon: Coupon) => { + if (isDuplicateCoupon(coupons, newCoupon.code)) { + addToast('이미 존재하는 쿠폰 코드입니다.', 'error'); + return; + } + setCoupons(prev => [...prev, newCoupon]); + addToast('쿠폰이 추가되었습니다.', 'success'); + }, [addToast, coupons, setCoupons]); + + // 쿠폰 삭제 + const deleteCoupon = useCallback((couponCode: string) => { + setCoupons(prev => prev.filter(c => c.code !== couponCode)); + addToast('쿠폰이 삭제되었습니다.', 'success'); + }, [addToast, setCoupons]); + + return { + coupons, + addCoupon, + deleteCoupon + }; +}; diff --git a/src/advanced/hooks/useProduct.ts b/src/advanced/hooks/useProduct.ts new file mode 100644 index 000000000..ebdf685f0 --- /dev/null +++ b/src/advanced/hooks/useProduct.ts @@ -0,0 +1,50 @@ +/** + * Product Feature - useProduct Hook + * + * 상품 상태 관리 및 CRUD 로직 + */ +import { useCallback } from 'react'; +import { useAtom } from 'jotai'; +import { ProductWithUI } from '../pages/AdminPage'; +import { productsAtom } from '../shared/store/atoms'; +import { useToast } from '../shared/hooks/useToast'; + +export const useProduct = () => { + const [products, setProducts] = useAtom(productsAtom); + const { addToast } = useToast(); + + // 상품 추가 + const addProduct = useCallback((newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + setProducts(prev => [...prev, product]); + addToast('상품이 추가되었습니다.', 'success'); + }, [addToast, setProducts]); + + // 상품 수정 + const updateProduct = useCallback((productId: string, updates: Partial) => { + setProducts(prev => + prev.map(product => + product.id === productId + ? { ...product, ...updates } + : product + ) + ); + addToast('상품이 수정되었습니다.', 'success'); + }, [addToast, setProducts]); + + // 상품 삭제 + const deleteProduct = useCallback((productId: string) => { + setProducts(prev => prev.filter(p => p.id !== productId)); + addToast('상품이 삭제되었습니다.', 'success'); + }, [addToast, setProducts]); + + return { + products, + addProduct, + updateProduct, + deleteProduct + }; +}; diff --git a/src/advanced/pages/AdminPage.tsx b/src/advanced/pages/AdminPage.tsx new file mode 100644 index 000000000..f65c1dd9a --- /dev/null +++ b/src/advanced/pages/AdminPage.tsx @@ -0,0 +1,503 @@ +/** + * AdminPage 컴포넌트 + * + * 책임: 관리자 페이지 UI 렌더링 + * - 상품 관리 탭 (추가/수정/삭제) + * - 쿠폰 관리 탭 (추가/삭제) + * + * 특징: + * - 상태 관리는 부모 컴포넌트(App)에서 처리 + * - Props를 통해 데이터와 액션을 받아 UI만 담당 + * - 폼 상태는 로컬에서 관리 (UI 상태) + */ + +import { useState } from 'react'; +import { Product } from '../../types'; +import { useProduct } from '../hooks/useProduct'; +import { useCoupon } from '../hooks/useCoupon'; +import { usePriceFormatter } from '../shared/hooks/usePriceFormatter'; +import { useToast } from '../shared/hooks/useToast'; +import { + validatePrice, + validateStock, + validateDiscountRate, + validateDiscountAmount, + isNumericString, + parseNumberInput +} from '../shared/utils/validators'; +import { CloseIcon, PlusIcon, TrashIcon } from '../shared/assets/icons/Icons'; + +// ProductWithUI 타입 확장 (UI 전용 속성 추가) +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +const AdminPage = () => { + // 탭 상태 + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + + // 상품 폼 상태 + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [] as Array<{ quantity: number; rate: number }>, + }); + + // 쿠폰 폼 상태 + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState({ + name: '', + code: '', + discountType: 'amount' as 'amount' | 'percentage', + discountValue: 0, + }); + + const { products, addProduct, updateProduct, deleteProduct } = useProduct(); + const { coupons, addCoupon, deleteCoupon } = useCoupon(); + const formatPrice = usePriceFormatter(); + const { addToast } = useToast(); + + // 상품 폼 제출 + const handleProductSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== 'new') { + updateProduct(editingProduct, productForm); + } else { + addProduct(productForm); + } + 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); + }; + + return ( +
+ {/* 헤더 */} +
+

관리자 대시보드

+

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

+
+ + {/* 탭 네비게이션 */} +
+ +
+ + {/* 상품 관리 탭 */} + {activeTab === '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 === '' || isNumericString(value)) { + setProductForm({ ...productForm, price: parseNumberInput(value, 0) }); + } + }} + onBlur={(e) => { + const price = parseNumberInput(e.target.value, 0); + const validation = validatePrice(price); + if (!validation.isValid) { + addToast(validation.errorMessage!, '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 === '' || isNumericString(value)) { + setProductForm({ ...productForm, stock: parseNumberInput(value, 0) }); + } + }} + onBlur={(e) => { + const stock = parseNumberInput(e.target.value, 0); + const validation = validateStock(stock); + if (!validation.isValid) { + addToast(validation.errorMessage!, 'error'); + setProductForm({ ...productForm, stock: 0 }); + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder="숫자만 입력" + required + /> +
+
+ +
+ +
+ {productForm.discounts.map((discount, index) => ( +
+ { + 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 === 'coupons' && ( +
+
+

쿠폰 관리

+
+
+
+ {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 === '' || isNumericString(value)) { + setCouponForm({ ...couponForm, discountValue: parseNumberInput(value, 0) }); + } + }} + onBlur={(e) => { + const value = parseNumberInput(e.target.value, 0); + + if (couponForm.discountType === 'percentage') { + const validation = validateDiscountRate(value); + if (!validation.isValid) { + addToast(validation.errorMessage!, 'error'); + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } else { + const validation = validateDiscountAmount(value); + if (!validation.isValid) { + addToast(validation.errorMessage!, 'error'); + 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 + /> +
+
+
+ + +
+
+
+ )} +
+
+ )} +
+ ); +}; + +export default AdminPage; diff --git a/src/advanced/pages/CartPage.tsx b/src/advanced/pages/CartPage.tsx new file mode 100644 index 000000000..0fec37fad --- /dev/null +++ b/src/advanced/pages/CartPage.tsx @@ -0,0 +1,76 @@ +/** + * CartPage Component + * + * 전역 상태(atom)를 구독하여 쇼핑 페이지를 구성합니다. + */ + +import { useCallback, useMemo } from 'react'; +import { useAtomValue } from 'jotai'; +import { filterProducts } from '../entities/product/utils'; +import { useProduct } from '../hooks/useProduct'; +import { useCart } from '../hooks/useCart'; +import { useCoupon } from '../hooks/useCoupon'; +import { useDebounce } from '../shared/hooks/useDebounce'; +import { usePriceFormatter } from '../shared/hooks/usePriceFormatter'; +import { searchTermAtom } from '../shared/store/atoms'; +import { ProductList } from '../widgets/Product/ProductList'; +import { Cart } from '../widgets/Cart/Cart'; + +const CartPage = () => { + const { products } = useProduct(); + const { + cart, + selectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + removeCoupon, + completeOrder, + getRemainingStock, + calculateItemTotal, + totals + } = useCart(); + const { coupons } = useCoupon(); + const formatPrice = usePriceFormatter(); + const searchTerm = useAtomValue(searchTermAtom); + const debouncedSearchTerm = useDebounce(searchTerm, 500); + + const filteredProducts = useMemo( + () => filterProducts(products, debouncedSearchTerm), + [products, debouncedSearchTerm] + ); + + const calculateCartTotal = useCallback(() => totals, [totals]); + + return ( +
+
+ +
+ +
+ +
+
+ ); +}; + +export default CartPage; diff --git a/src/advanced/shared/assets/icons/Icons.tsx b/src/advanced/shared/assets/icons/Icons.tsx new file mode 100644 index 000000000..9c443fae6 --- /dev/null +++ b/src/advanced/shared/assets/icons/Icons.tsx @@ -0,0 +1,82 @@ +/** + * Shared Assets - Icons + * + * SVG 아이콘 컴포넌트 모음 + * + * 위치: shared/assets/icons + * - UI 컴포넌트 내부에서 사용되는 보조 컴포넌트 + * - 이미지, 폰트와 같은 정적 리소스로 분류 + * - 직접 페이지에 렌더링되지 않고 다른 컴포넌트 안에서만 사용 + */ + +interface IconProps { + className?: string; +} + +/** + * 닫기 (X) 아이콘 + */ +export const CloseIcon = ({ className = "w-4 h-4" }: IconProps) => ( + + + +); + +/** + * 장바구니 아이콘 + */ +export const CartIcon = ({ className = "w-5 h-5" }: IconProps) => ( + + + +); + +/** + * 이미지 플레이스홀더 아이콘 + */ +export const ImageIcon = ({ className = "w-24 h-24" }: IconProps) => ( + + + +); + +/** + * 검색 아이콘 + */ +export const SearchIcon = ({ className = "w-6 h-6" }: IconProps) => ( + + + +); + +/** + * 플러스 아이콘 + */ +export const PlusIcon = ({ className = "w-8 h-8" }: IconProps) => ( + + + +); + +/** + * 휴지통 아이콘 + */ +export const TrashIcon = ({ className = "w-5 h-5" }: IconProps) => ( + + + +); + +/** + * 마이너스 아이콘 + */ +export const MinusIcon = ({ className = "text-xs" }: IconProps) => ( + +); + +/** + * 플러스 텍스트 아이콘 + */ +export const PlusTextIcon = ({ className = "text-xs" }: IconProps) => ( + + +); diff --git a/src/advanced/shared/hooks/useDebounce.ts b/src/advanced/shared/hooks/useDebounce.ts new file mode 100644 index 000000000..3369a51c5 --- /dev/null +++ b/src/advanced/shared/hooks/useDebounce.ts @@ -0,0 +1,23 @@ +import { useState, useEffect } from 'react'; + +/** + * 값이 변경된 후 지정된 시간 동안 변경이 없을 때만 업데이트되는 Hook + * @param value - debounce할 값 + * @param delay - 지연 시간 (밀리초) + * @returns debounced된 값 + */ +export const useDebounce = (value: T, delay: number = 500): 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/useFormValidation.ts b/src/advanced/shared/hooks/useFormValidation.ts new file mode 100644 index 000000000..e051d71cc --- /dev/null +++ b/src/advanced/shared/hooks/useFormValidation.ts @@ -0,0 +1,87 @@ +/** + * Shared Hook - useFormValidation + * + * 폼 전체 유효성 검증 + 에러 상태 관리 + */ + +import { useState, useCallback } from 'react'; +import { ValidationResult } from '../utils/validators'; + +interface FormErrors { + [key: string]: string | undefined; +} + +export const useFormValidation = () => { + const [errors, setErrors] = useState({}); + + /** + * 특정 필드 검증 + */ + const validateField = useCallback( + (fieldName: string, validator: () => ValidationResult) => { + const result = validator(); + + setErrors(prev => ({ + ...prev, + [fieldName]: result.isValid ? undefined : result.errorMessage + })); + + return result.isValid; + }, + [] + ); + + /** + * 여러 필드 동시 검증 + */ + const validateFields = useCallback( + (validations: Array<{ fieldName: string; validator: () => ValidationResult }>) => { + const newErrors: FormErrors = {}; + let isValid = true; + + validations.forEach(({ fieldName, validator }) => { + const result = validator(); + if (!result.isValid) { + newErrors[fieldName] = result.errorMessage; + isValid = false; + } + }); + + setErrors(newErrors); + return isValid; + }, + [] + ); + + /** + * 에러 초기화 + */ + const clearErrors = useCallback(() => { + setErrors({}); + }, []); + + /** + * 특정 필드 에러 제거 + */ + const clearFieldError = useCallback((fieldName: string) => { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors[fieldName]; + return newErrors; + }); + }, []); + + /** + * 폼이 유효한지 확인 + */ + const hasErrors = Object.values(errors).some(error => error !== undefined); + + return { + errors, + validateField, + validateFields, + clearErrors, + clearFieldError, + hasErrors, + }; +}; diff --git a/src/advanced/shared/hooks/useLocalStorage.ts b/src/advanced/shared/hooks/useLocalStorage.ts new file mode 100644 index 000000000..778a22680 --- /dev/null +++ b/src/advanced/shared/hooks/useLocalStorage.ts @@ -0,0 +1,34 @@ +import { useState, useEffect } from 'react'; + +/** + * localStorage와 동기화되는 상태를 관리하는 Hook + * @param key - localStorage 키 + * @param initialValue - 초기값 + * @returns [저장된 값, 값을 업데이트하는 함수] + */ +export const useLocalStorage = ( + key: string, + initialValue: T +): [T, (value: T | ((prevValue: T) => T)) => void] => { + // 초기값 설정: localStorage에서 가져오거나 initialValue 사용 + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(`Error loading ${key} from localStorage:`, error); + return initialValue; + } + }); + + // 값이 변경될 때마다 localStorage에 저장 + useEffect(() => { + try { + window.localStorage.setItem(key, JSON.stringify(storedValue)); + } catch (error) { + console.error(`Error saving ${key} to localStorage:`, error); + } + }, [key, storedValue]); + + return [storedValue, setStoredValue]; +}; diff --git a/src/advanced/shared/hooks/usePriceFormatter.ts b/src/advanced/shared/hooks/usePriceFormatter.ts new file mode 100644 index 000000000..29f815e50 --- /dev/null +++ b/src/advanced/shared/hooks/usePriceFormatter.ts @@ -0,0 +1,24 @@ +/** + * usePriceFormatter Hook + * + * 공용 가격 포맷 함수 제공 + */ + +import { useCallback } from 'react'; +import { useAtomValue } from 'jotai'; +import { cartAtom, isAdminAtom, productsAtom } from '../store/atoms'; +import { formatPrice as formatPriceUtil } from '../utils/format'; + +export const usePriceFormatter = () => { + const isAdmin = useAtomValue(isAdminAtom); + const products = useAtomValue(productsAtom); + const cart = useAtomValue(cartAtom); + + return useCallback( + (price: number, productId?: string) => { + const product = productId ? products.find(p => p.id === productId) : undefined; + return formatPriceUtil(price, isAdmin, product, cart); + }, + [cart, isAdmin, products] + ); +}; diff --git a/src/advanced/shared/hooks/useToast.ts b/src/advanced/shared/hooks/useToast.ts new file mode 100644 index 000000000..a2a09ffda --- /dev/null +++ b/src/advanced/shared/hooks/useToast.ts @@ -0,0 +1,45 @@ +/** + * Toast Feature - useToast Hook + * + * 토스트 메시지 관리 + * + * Features: + * - 토스트 추가/제거 + * - 자동 제거 타이머 (3초) + * - 타입별 메시지 (success, error, warning) + */ + +import { useCallback } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { toastsAtom } from '../store/atoms'; +import type { Toast } from '../store/atoms'; + +export type { Toast }; + +export const useToast = () => { + const toasts = useAtomValue(toastsAtom); + const setToasts = useSetAtom(toastsAtom); + + const addToast = useCallback(( + message: string, + type: 'error' | 'success' | 'warning' = 'success' + ) => { + const id = Date.now().toString(); + setToasts(prev => [...prev, { id, message, type }]); + + // 3초 후 자동 제거 + setTimeout(() => { + setToasts(prev => prev.filter(n => n.id !== id)); + }, 3000); + }, []); + + const removeToast = useCallback((id: string) => { + setToasts(prev => prev.filter(n => n.id !== id)); + }, []); + + return { + toasts, + addToast, + removeToast + }; +}; diff --git a/src/advanced/shared/store/atoms.ts b/src/advanced/shared/store/atoms.ts new file mode 100644 index 000000000..0b50c557e --- /dev/null +++ b/src/advanced/shared/store/atoms.ts @@ -0,0 +1,54 @@ +/** + * Shared Store - Jotai Atoms + * + * 전역 상태 관리 + * - Jotai atom을 사용하여 Props drilling 제거 + */ + +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; +import { CartItem } from '../../entities/cart/model'; +import { Coupon } from '../../entities/coupon/model'; +import { ProductWithUI } from '../../pages/AdminPage'; + +/** + * Toast Atom + * - 토스트 메시지 상태 관리 + */ +export interface Toast { + id: string; + message: string; + type: 'success' | 'error' | 'warning'; +} + +export const toastsAtom = atom([]); + +/** + * Products Atom + * - localStorage에 자동 동기화 + */ +export const productsAtom = atomWithStorage('products', []); + +/** + * Coupons Atom + * - localStorage에 자동 동기화 + */ +export const couponsAtom = atomWithStorage('coupons', []); + +/** + * Cart Atom + * - localStorage에 자동 동기화 + */ +export const cartAtom = atomWithStorage('cart', []); + +/** + * Selected Coupon Atom + * - 현재 선택된 쿠폰 + */ +export const selectedCouponAtom = atom(null); + +/** + * UI State Atoms + */ +export const isAdminAtom = atom(false); +export const searchTermAtom = atom(''); diff --git a/src/advanced/shared/store/atoms/cart.ts b/src/advanced/shared/store/atoms/cart.ts new file mode 100644 index 000000000..59c41d1c6 --- /dev/null +++ b/src/advanced/shared/store/atoms/cart.ts @@ -0,0 +1,19 @@ +/** + * Cart Atom + * - localStorage에 자동 동기화 + */ +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; +import { CartItem } from '../../../entities/cart/model'; +import { Coupon } from '../../../entities/coupon/model'; + +export const cartAtom = atomWithStorage( + 'cart', + [] +); + +/** + * Selected Coupon Atom + * - 현재 선택된 쿠폰 + */ +export const selectedCouponAtom = atom(null); diff --git a/src/advanced/shared/store/atoms/coupon.ts b/src/advanced/shared/store/atoms/coupon.ts new file mode 100644 index 000000000..dcb70a7c6 --- /dev/null +++ b/src/advanced/shared/store/atoms/coupon.ts @@ -0,0 +1,11 @@ +/** + * Coupons Atom + * - localStorage에 자동 동기화 + */ +import { atomWithStorage } from 'jotai/utils'; +import { Coupon } from '../../../entities/coupon/model'; + +export const couponsAtom = atomWithStorage( + 'coupons', + [] +); diff --git a/src/advanced/shared/store/atoms/index.ts b/src/advanced/shared/store/atoms/index.ts new file mode 100644 index 000000000..4c08c8b20 --- /dev/null +++ b/src/advanced/shared/store/atoms/index.ts @@ -0,0 +1,5 @@ +export * from './toast'; +export * from './product'; +export * from './coupon'; +export * from './cart'; +export * from './ui'; diff --git a/src/advanced/shared/store/atoms/product.ts b/src/advanced/shared/store/atoms/product.ts new file mode 100644 index 000000000..a16fa780c --- /dev/null +++ b/src/advanced/shared/store/atoms/product.ts @@ -0,0 +1,11 @@ +/** + * Products Atom + * - localStorage에 자동 동기화 + */ +import { atomWithStorage } from 'jotai/utils'; +import { ProductWithUI } from '../../../pages/AdminPage'; + +export const productsAtom = atomWithStorage( + 'products', + [] +); diff --git a/src/advanced/shared/store/atoms/toast.ts b/src/advanced/shared/store/atoms/toast.ts new file mode 100644 index 000000000..021868505 --- /dev/null +++ b/src/advanced/shared/store/atoms/toast.ts @@ -0,0 +1,13 @@ +/** + * Toast Atom + * - 토스트 메시지 상태 관리 + */ +import { atom } from 'jotai'; + +export interface Toast { + id: string; + message: string; + type: 'success' | 'error' | 'warning'; +} + +export const toastsAtom = atom([]); diff --git a/src/advanced/shared/store/atoms/ui.ts b/src/advanced/shared/store/atoms/ui.ts new file mode 100644 index 000000000..2de71fb3d --- /dev/null +++ b/src/advanced/shared/store/atoms/ui.ts @@ -0,0 +1,7 @@ +/** + * UI State Atoms + */ +import { atom } from 'jotai'; + +export const isAdminAtom = atom(false); +export const searchTermAtom = atom(''); diff --git a/src/advanced/shared/ui/Badge/Badge.tsx b/src/advanced/shared/ui/Badge/Badge.tsx new file mode 100644 index 000000000..7d12d3b6a --- /dev/null +++ b/src/advanced/shared/ui/Badge/Badge.tsx @@ -0,0 +1,43 @@ +/** + * Shared UI - Badge Component + * + * 숫자나 상태를 표시하는 배지 컴포넌트 + */ + +import { ReactNode } from 'react'; + +interface BadgeProps { + children: ReactNode; + variant?: 'primary' | 'success' | 'warning' | 'danger' | 'info'; + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +export const Badge = ({ + children, + variant = 'primary', + size = 'md', + className = '' +}: BadgeProps) => { + const baseStyles = 'inline-flex items-center justify-center font-semibold rounded-full'; + + const variantStyles = { + primary: 'bg-blue-500 text-white', + success: 'bg-green-500 text-white', + warning: 'bg-yellow-500 text-white', + danger: 'bg-red-500 text-white', + info: 'bg-gray-500 text-white' + }; + + const sizeStyles = { + sm: 'px-2 py-0.5 text-xs min-w-[1.25rem] h-5', + md: 'px-2.5 py-1 text-sm min-w-[1.5rem] h-6', + lg: 'px-3 py-1.5 text-base min-w-[2rem] h-8' + }; + + return ( + + {children} + + ); +}; diff --git a/src/advanced/shared/ui/Button/Button.tsx b/src/advanced/shared/ui/Button/Button.tsx new file mode 100644 index 000000000..5fb1c32cd --- /dev/null +++ b/src/advanced/shared/ui/Button/Button.tsx @@ -0,0 +1,49 @@ +/** + * Shared UI - Button Component + * + * 재사용 가능한 버튼 컴포넌트 + */ + +import { ButtonHTMLAttributes, ReactNode } from 'react'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; + children: ReactNode; +} + +export const Button = ({ + variant = 'primary', + size = 'md', + children, + className = '', + disabled, + ...props +}: ButtonProps) => { + const baseStyles = 'font-medium rounded transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2'; + + const variantStyles = { + primary: 'bg-blue-500 hover:bg-blue-600 text-white focus:ring-blue-500 disabled:bg-blue-300', + secondary: 'bg-gray-500 hover:bg-gray-600 text-white focus:ring-gray-500 disabled:bg-gray-300', + danger: 'bg-red-500 hover:bg-red-600 text-white focus:ring-red-500 disabled:bg-red-300', + ghost: 'bg-transparent hover:bg-gray-100 text-gray-700 border border-gray-300 focus:ring-gray-500' + }; + + const sizeStyles = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-base', + lg: 'px-6 py-3 text-lg' + }; + + const disabledStyles = disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'; + + return ( + + ); +}; diff --git a/src/advanced/shared/ui/Card/Card.tsx b/src/advanced/shared/ui/Card/Card.tsx new file mode 100644 index 000000000..91c1dc8c9 --- /dev/null +++ b/src/advanced/shared/ui/Card/Card.tsx @@ -0,0 +1,43 @@ +/** + * Shared UI - Card Component + * + * 콘텐츠를 담는 카드 컴포넌트 + */ + +import { ReactNode } from 'react'; + +interface CardProps { + children: ReactNode; + className?: string; + padding?: 'none' | 'sm' | 'md' | 'lg'; + shadow?: 'none' | 'sm' | 'md' | 'lg'; +} + +export const Card = ({ + children, + className = '', + padding = 'md', + shadow = 'md' +}: CardProps) => { + const baseStyles = 'bg-white rounded-lg border border-gray-200'; + + const paddingStyles = { + none: '', + sm: 'p-3', + md: 'p-4', + lg: 'p-6' + }; + + const shadowStyles = { + none: '', + sm: 'shadow-sm', + md: 'shadow-md', + lg: 'shadow-lg' + }; + + return ( +
+ {children} +
+ ); +}; diff --git a/src/advanced/shared/ui/Input/Input.tsx b/src/advanced/shared/ui/Input/Input.tsx new file mode 100644 index 000000000..58c12933c --- /dev/null +++ b/src/advanced/shared/ui/Input/Input.tsx @@ -0,0 +1,46 @@ +/** + * Shared UI - Input Component + * + * 재사용 가능한 입력 컴포넌트 + */ + +import { InputHTMLAttributes, forwardRef } from 'react'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; + fullWidth?: boolean; +} + +export const Input = forwardRef(({ + label, + error, + fullWidth = false, + className = '', + ...props +}, ref) => { + const baseStyles = 'px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors'; + const errorStyles = error ? 'border-red-500 focus:ring-red-500' : 'border-gray-300'; + const widthStyles = fullWidth ? 'w-full' : ''; + const disabledStyles = props.disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'; + + return ( +
+ {label && ( + + )} + + {error && ( +

{error}

+ )} +
+ ); +}); + +Input.displayName = 'Input'; diff --git a/src/advanced/shared/ui/Toast/Toast.tsx b/src/advanced/shared/ui/Toast/Toast.tsx new file mode 100644 index 000000000..d426d7622 --- /dev/null +++ b/src/advanced/shared/ui/Toast/Toast.tsx @@ -0,0 +1,42 @@ +/** + * Shared UI - Toast Component + * + * 개별 토스트 메시지 UI + * + * Features: + * - 3가지 타입 (success, error, warning) + * - 닫기 버튼 + * - 애니메이션 (slide-in) + */ + +import { CloseIcon } from '../../assets/icons/Icons'; + +export interface ToastProps { + id: string; + message: string; + type: 'success' | 'error' | 'warning'; + onClose: (id: string) => void; +} + +export const Toast = ({ id, message, type, onClose }: ToastProps) => { + const bgColor = + type === 'error' ? 'bg-red-600' : + type === 'warning' ? 'bg-yellow-600' : + 'bg-green-600'; + + return ( +
+ {message} + +
+ ); +}; diff --git a/src/advanced/shared/ui/Toast/ToastContainer.tsx b/src/advanced/shared/ui/Toast/ToastContainer.tsx new file mode 100644 index 000000000..4dbd6131c --- /dev/null +++ b/src/advanced/shared/ui/Toast/ToastContainer.tsx @@ -0,0 +1,31 @@ +/** + * Shared UI - ToastContainer Component + * + * 토스트 메시지 목록 컨테이너 + * + * Features: + * - 화면 우측 상단에 고정 + * - 여러 토스트를 세로로 표시 + * - 자동 제거 (부모에서 타이머 관리) + */ + +import { Toast, ToastProps } from './Toast'; + +interface ToastContainerProps { + toasts: Omit[]; + onClose: (id: string) => void; +} + +export const ToastContainer = ({ toasts, onClose }: ToastContainerProps) => { + if (toasts.length === 0) { + return null; + } + + return ( +
+ {toasts.map(toast => ( + + ))} +
+ ); +}; diff --git a/src/advanced/shared/utils/format.ts b/src/advanced/shared/utils/format.ts new file mode 100644 index 000000000..ee5e38711 --- /dev/null +++ b/src/advanced/shared/utils/format.ts @@ -0,0 +1,50 @@ +/** + * Shared Utils - 포맷팅 함수 + * + * 화면에 표시되는 데이터 포맷팅 유틸리티 + */ + +import { Product } from '../../entities/product/model'; +import { CartItem } from '../../entities/cart/model'; +import { getRemainingStock } from '../../entities/product/utils'; + +/** + * 가격 포맷팅 + * - 관리자 모드: "10,000원" + * - 사용자 모드: "₩10,000" + * - 품절 상품: "SOLD OUT" + */ +export const formatPrice = ( + price: number, + isAdmin: boolean, + product?: Product, + cart?: CartItem[] +): string => { + if (product && cart) { + const remainingStock = getRemainingStock(product, cart); + if (remainingStock <= 0) { + return 'SOLD OUT'; + } + } + + if (isAdmin) { + return `${price.toLocaleString()}원`; + } + + return `₩${price.toLocaleString()}`; +}; + +/** + * 할인율 포맷팅 + * 예: 0.1 → "10%" + */ +export const formatDiscount = (rate: number): string => { + return `${(rate * 100).toFixed(0)}%`; +}; + +/** + * 주문 번호 생성 + */ +export const generateOrderNumber = (): string => { + return `ORD-${Date.now()}`; +}; diff --git a/src/advanced/shared/utils/validators.ts b/src/advanced/shared/utils/validators.ts new file mode 100644 index 000000000..78eeefeda --- /dev/null +++ b/src/advanced/shared/utils/validators.ts @@ -0,0 +1,209 @@ +/** + * Shared Utils - Validators + * + * 비즈니스 규칙에 따른 유효성 검증 순수 함수 + */ + +export interface ValidationResult { + isValid: boolean; + errorMessage?: string; +} + +/** + * 가격 유효성 검증 + */ +export const validatePrice = (price: number): ValidationResult => { + if (price < 0) { + return { + isValid: false, + errorMessage: '가격은 0보다 커야 합니다' + }; + } + + if (price > 10000000) { + return { + isValid: false, + errorMessage: '가격은 10,000,000원을 초과할 수 없습니다' + }; + } + + return { isValid: true }; +}; + +/** + * 재고 유효성 검증 + */ +export const validateStock = (stock: number): ValidationResult => { + if (stock < 0) { + return { + isValid: false, + errorMessage: '재고는 0보다 커야 합니다' + }; + } + + if (stock > 9999) { + return { + isValid: false, + errorMessage: '재고는 9999개를 초과할 수 없습니다' + }; + } + + return { isValid: true }; +}; + +/** + * 할인율 유효성 검증 (백분율) + */ +export const validateDiscountRate = (rate: number): ValidationResult => { + if (rate < 0) { + return { + isValid: false, + errorMessage: '할인율은 0% 이상이어야 합니다' + }; + } + + if (rate > 100) { + return { + isValid: false, + errorMessage: '할인율은 100%를 초과할 수 없습니다' + }; + } + + return { isValid: true }; +}; + +/** + * 할인 금액 유효성 검증 + */ +export const validateDiscountAmount = (amount: number): ValidationResult => { + if (amount < 0) { + return { + isValid: false, + errorMessage: '할인 금액은 0원 이상이어야 합니다' + }; + } + + if (amount > 100000) { + return { + isValid: false, + errorMessage: '할인 금액은 100,000원을 초과할 수 없습니다' + }; + } + + return { isValid: true }; +}; + +/** + * 할인 수량 유효성 검증 + */ +export const validateDiscountQuantity = (quantity: number): ValidationResult => { + if (quantity < 1) { + return { + isValid: false, + errorMessage: '할인 수량은 1개 이상이어야 합니다' + }; + } + + if (quantity > 9999) { + return { + isValid: false, + errorMessage: '할인 수량은 9999개를 초과할 수 없습니다' + }; + } + + return { isValid: true }; +}; + +/** + * 상품명 유효성 검증 + */ +export const validateProductName = (name: string): ValidationResult => { + if (!name || name.trim().length === 0) { + return { + isValid: false, + errorMessage: '상품명을 입력해주세요' + }; + } + + if (name.length > 100) { + return { + isValid: false, + errorMessage: '상품명은 100자를 초과할 수 없습니다' + }; + } + + return { isValid: true }; +}; + +/** + * 쿠폰명 유효성 검증 + */ +export const validateCouponName = (name: string): ValidationResult => { + if (!name || name.trim().length === 0) { + return { + isValid: false, + errorMessage: '쿠폰명을 입력해주세요' + }; + } + + if (name.length > 50) { + return { + isValid: false, + errorMessage: '쿠폰명은 50자를 초과할 수 없습니다' + }; + } + + return { isValid: true }; +}; + +/** + * 쿠폰 코드 유효성 검증 + */ +export const validateCouponCode = (code: string): ValidationResult => { + if (!code || code.trim().length === 0) { + return { + isValid: false, + errorMessage: '쿠폰 코드를 입력해주세요' + }; + } + + // 영문, 숫자만 허용 + if (!/^[A-Z0-9]+$/.test(code)) { + return { + isValid: false, + errorMessage: '쿠폰 코드는 영문 대문자와 숫자만 사용 가능합니다' + }; + } + + if (code.length < 4) { + return { + isValid: false, + errorMessage: '쿠폰 코드는 최소 4자 이상이어야 합니다' + }; + } + + if (code.length > 20) { + return { + isValid: false, + errorMessage: '쿠폰 코드는 20자를 초과할 수 없습니다' + }; + } + + return { isValid: true }; +}; + +/** + * 숫자만 포함되어 있는지 검증 + */ +export const isNumericString = (value: string): boolean => { + return /^\d+$/.test(value); +}; + +/** + * 빈 문자열을 0으로 변환하거나 숫자로 파싱 + */ +export const parseNumberInput = (value: string, defaultValue: number = 0): number => { + if (value === '') return defaultValue; + const parsed = parseInt(value); + return isNaN(parsed) ? defaultValue : parsed; +}; diff --git a/src/advanced/widgets/Cart/Cart.tsx b/src/advanced/widgets/Cart/Cart.tsx new file mode 100644 index 000000000..d6ddb3b60 --- /dev/null +++ b/src/advanced/widgets/Cart/Cart.tsx @@ -0,0 +1,122 @@ +/** + * Cart Feature - Cart Component + * + * 장바구니, 쿠폰, 결제 정보 표시 컴포넌트 + */ + +import { CartItem as CartItemType, Coupon } from '../../../types'; +import { CartItem } from './CartItem'; +import { CouponList } from '../Coupon/CouponList'; +import { CartIcon } from '../../shared/assets/icons/Icons'; + +interface CartProps { + cart: CartItemType[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + onRemoveFromCart: (productId: string) => void; + onUpdateQuantity: (productId: string, quantity: number) => void; + onApplyCoupon: (coupon: Coupon) => void; + onRemoveCoupon: () => void; + onCompleteOrder: () => void; + calculateItemTotal: (item: CartItemType) => number; + calculateCartTotal: () => { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; +} + +export const Cart = ({ + cart, + coupons, + selectedCoupon, + onRemoveFromCart, + onUpdateQuantity, + onApplyCoupon, + onRemoveCoupon, + onCompleteOrder, + calculateItemTotal, + calculateCartTotal +}: CartProps) => { + const totals = calculateCartTotal(); + + return ( +
+ {/* 장바구니 */} +
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map(item => ( + + ))} +
+ )} +
+ + {cart.length > 0 && ( + <> + {/* 쿠폰 선택 */} + + + {/* 결제 정보 */} +
+

결제 정보

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

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

+
+
+ + )} +
+ ); +}; diff --git a/src/advanced/widgets/Cart/CartItem.tsx b/src/advanced/widgets/Cart/CartItem.tsx new file mode 100644 index 000000000..f112f9125 --- /dev/null +++ b/src/advanced/widgets/Cart/CartItem.tsx @@ -0,0 +1,74 @@ +/** + * Cart Feature - CartItem Component + * + * 장바구니 개별 아이템 컴포넌트 + */ + +import { CartItem as CartItemType } from '../../../types'; +import { CloseIcon, MinusIcon, PlusTextIcon } from '../../shared/assets/icons/Icons'; + +interface CartItemProps { + item: CartItemType; + onRemove: (productId: string) => void; + onUpdateQuantity: (productId: string, quantity: number) => void; + calculateItemTotal: (item: CartItemType) => number; +} + +export const CartItem = ({ + item, + onRemove, + onUpdateQuantity, + calculateItemTotal +}: CartItemProps) => { + 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()}원 +

+
+
+
+ ); +}; diff --git a/src/advanced/widgets/Coupon/CouponList.tsx b/src/advanced/widgets/Coupon/CouponList.tsx new file mode 100644 index 000000000..b3cb4f68d --- /dev/null +++ b/src/advanced/widgets/Coupon/CouponList.tsx @@ -0,0 +1,55 @@ +/** + * Coupon Feature - CouponList Component + * + * 쿠폰 선택 컴포넌트 + */ + +import { Coupon } from '../../../types'; + +interface CouponListProps { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + onApply: (coupon: Coupon) => void; + onRemove: () => void; +} + +export const CouponList = ({ + coupons, + selectedCoupon, + onApply, + onRemove +}: CouponListProps) => { + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +}; diff --git a/src/advanced/widgets/Header/Header.tsx b/src/advanced/widgets/Header/Header.tsx new file mode 100644 index 000000000..06070a913 --- /dev/null +++ b/src/advanced/widgets/Header/Header.tsx @@ -0,0 +1,74 @@ +/** + * Header Widget + * + * 전역 상태(atom)를 이용해 헤더 UI를 렌더링합니다. + */ + +import { useMemo } from 'react'; +import { useAtom } from 'jotai'; +import { useCart } from '../../hooks/useCart'; +import { isAdminAtom, searchTermAtom } from '../../shared/store/atoms'; + +const Header = () => { + const { cart } = useCart(); + const [isAdmin, setIsAdmin] = useAtom(isAdminAtom); + const [searchTerm, setSearchTerm] = useAtom(searchTermAtom); + + const totalItemCount = useMemo(() => { + return cart.reduce((sum, item) => sum + item.quantity, 0); + }, [cart]); + + return ( +
+
+
+
+

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" + /> +
+ )} +
+ +
+
+
+ ); +}; + +export default Header; \ No newline at end of file diff --git a/src/advanced/widgets/Product/ProductCard.tsx b/src/advanced/widgets/Product/ProductCard.tsx new file mode 100644 index 000000000..159c54a84 --- /dev/null +++ b/src/advanced/widgets/Product/ProductCard.tsx @@ -0,0 +1,95 @@ +/** + * Product Feature - ProductCard Component + * + * 개별 상품 카드 UI 컴포넌트 + */ + +import { ProductWithUI } from '../../pages/AdminPage'; +import { ImageIcon } from '../../shared/assets/icons/Icons'; + +interface ProductCardProps { + product: ProductWithUI; + onAddToCart: (product: ProductWithUI) => void; + formatPrice: (price: number, productId?: string) => string; + getRemainingStock: (product: ProductWithUI) => number; +} + +export const ProductCard = ({ + product, + onAddToCart, + formatPrice, + getRemainingStock +}: ProductCardProps) => { + const remainingStock = getRemainingStock(product); + const maxDiscountRate = product.discounts.length > 0 + ? Math.max(...product.discounts.map(d => d.rate)) + : 0; + + return ( +
+ {/* 상품 이미지 영역 */} +
+
+ +
+ + {/* 뱃지들 */} + {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{maxDiscountRate * 100}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

{product.description}

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

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

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

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

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

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

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

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; diff --git a/src/advanced/widgets/Product/ProductList.tsx b/src/advanced/widgets/Product/ProductList.tsx new file mode 100644 index 000000000..538a85eae --- /dev/null +++ b/src/advanced/widgets/Product/ProductList.tsx @@ -0,0 +1,53 @@ +/** + * Product Feature - ProductList Component + * + * 상품 목록 표시 컴포넌트 + */ + +import { ProductWithUI } from '../../pages/AdminPage'; +import { ProductCard } from './ProductCard'; + +interface ProductListProps { + products: ProductWithUI[]; + searchTerm: string; + onAddToCart: (product: ProductWithUI) => void; + formatPrice: (price: number, productId?: string) => string; + getRemainingStock: (product: ProductWithUI) => number; +} + +export const ProductList = ({ + products, + searchTerm, + onAddToCart, + formatPrice, + getRemainingStock +}: ProductListProps) => { + return ( +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {products.map(product => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..7257527b3 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,16 +1,16 @@ -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'; -} +import { useState, useMemo } from 'react'; +import Header from './widgets/Header/Header'; +import AdminPage, { ProductWithUI } from './pages/AdminPage'; +import CartPage from './pages/CartPage'; +import { Coupon } from './entities/coupon/model'; +import { filterProducts } from './entities/product/utils'; +import { formatPrice as formatPriceUtil } from './shared/utils/format'; +import { useDebounce } from './shared/hooks/useDebounce'; +import { useToast } from './shared/hooks/useToast'; +import { useCart } from './hooks/useCart'; +import { useProduct } from './hooks/useProduct'; +import { useCoupon } from './hooks/useCoupon'; +import { ToastContainer } from './shared/ui/Toast/ToastContainer'; // 초기 데이터 const initialProducts: ProductWithUI[] = [ @@ -65,1056 +65,95 @@ const initialCoupons: Coupon[] = [ ]; const App = () => { + // UI 상태 + const [isAdmin, setIsAdmin] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearchTerm = useDebounce(searchTerm, 500); - 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 []; - }); + // Toast Hook + const { toasts, addToast, removeToast } = useToast(); - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; + // Product Hook + const productHook = useProduct({ + initialProducts, + onNotify: addToast }); - 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 }> + // Coupon Hook + const couponHook = useCoupon({ + initialCoupons, + onNotify: addToast }); - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 + // Cart Hook + const cartHook = useCart({ + products: productHook.products, + onNotify: addToast }); + // 검색된 상품 필터링 + const filteredProducts = useMemo(() => + filterProducts(productHook.products, debouncedSearchTerm), + [productHook.products, debouncedSearchTerm] + ); + // 가격 포맷팅 (관리자 모드에 따라) const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) + return formatPriceUtil( + price, + isAdmin, + productId ? productHook.products.find(p => p.id === productId) : undefined, + cartHook.cart ); - }, [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; + // calculateCartTotal 함수 (CartPage props 호환) + const calculateCartTotal = () => cartHook.totals; return (
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

SHOP

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

관리자 대시보드

-

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

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

상품 목록

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

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

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

쿠폰 관리

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

{coupon.name}

-

{coupon.code}

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

새 쿠폰 생성

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

전체 상품

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

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

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

{product.name}

- {product.description && ( -

{product.description}

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

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

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

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

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

품절임박! {remainingStock}개 남음

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

재고 {remainingStock}개

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

- - - - 장바구니 -

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

장바구니가 비어있습니다

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

{item.product.name}

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

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

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

쿠폰 할인

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

결제 정보

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

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

-
-
- - )} -
-
-
+ )}
diff --git a/src/basic/entities/cart/model.ts b/src/basic/entities/cart/model.ts new file mode 100644 index 000000000..38a295a61 --- /dev/null +++ b/src/basic/entities/cart/model.ts @@ -0,0 +1,12 @@ +/** + * Cart Entity - 타입 정의 + * + * 장바구니 관련 도메인 타입 + */ + +import { Product } from '../product/model'; + +export interface CartItem { + product: Product; + quantity: number; +} diff --git a/src/basic/entities/cart/utils.ts b/src/basic/entities/cart/utils.ts new file mode 100644 index 000000000..0251324cc --- /dev/null +++ b/src/basic/entities/cart/utils.ts @@ -0,0 +1,142 @@ +/** + * Cart Entity - 계산 로직 + * + * 장바구니 관련 순수 함수 (비즈니스 로직) + */ + +import { CartItem } from './model'; +import { Coupon } from '../coupon/model'; + +/** + * 아이템에 적용 가능한 최대 할인율 계산 + * - 수량 기반 할인 + * - 대량 구매 시 추가 5% 할인 (10개 이상 구매 시) + */ +export const getMaxApplicableDiscount = ( + item: CartItem, + cart: CartItem[] +): number => { + const { discounts } = item.product; + const { quantity } = item; + + // 수량 기반 할인율 계산 + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + // 대량 구매 보너스 (장바구니에 10개 이상 구매 항목이 있으면 추가 5%) + const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); // 최대 50% 제한 + } + + return baseDiscount; +}; + +/** + * 개별 아이템의 총 가격 계산 (할인 적용 후) + */ +export const calculateItemTotal = ( + item: CartItem, + cart: CartItem[] +): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +/** + * 장바구니 전체 금액 계산 + * - 할인 전 총액 + * - 할인 후 총액 (쿠폰 적용 포함) + */ +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach(item => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + // 쿠폰 적용 + if (selectedCoupon) { + if (selectedCoupon.discountType === 'amount') { + totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); + } else { + totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount) + }; +}; + +/** + * 장바구니 아이템 수량 업데이트 + */ +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + newQuantity: number +): CartItem[] => { + if (newQuantity <= 0) { + return cart.filter(item => item.product.id !== productId); + } + + return cart.map(item => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ); +}; + +/** + * 장바구니에 상품 추가 + */ +export const addProductToCart = ( + cart: CartItem[], + product: CartItem['product'] +): CartItem[] => { + const existingItem = cart.find(item => item.product.id === product.id); + + if (existingItem) { + return cart.map(item => + item.product.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + ); + } + + return [...cart, { product, quantity: 1 }]; +}; + +/** + * 장바구니에서 상품 제거 + */ +export const removeProductFromCart = ( + cart: CartItem[], + productId: string +): CartItem[] => { + return cart.filter(item => item.product.id !== productId); +}; + +/** + * 장바구니의 총 아이템 개수 계산 + */ +export const getTotalCartQuantity = (cart: CartItem[]): number => { + return cart.reduce((total, item) => total + item.quantity, 0); +}; diff --git a/src/basic/entities/coupon/model.ts b/src/basic/entities/coupon/model.ts new file mode 100644 index 000000000..7b3fe54be --- /dev/null +++ b/src/basic/entities/coupon/model.ts @@ -0,0 +1,12 @@ +/** + * Coupon Entity - 타입 정의 + * + * 쿠폰 관련 도메인 타입 + */ + +export interface Coupon { + name: string; + code: string; + discountType: 'amount' | 'percentage'; + discountValue: number; +} diff --git a/src/basic/entities/coupon/utils.ts b/src/basic/entities/coupon/utils.ts new file mode 100644 index 000000000..61ee79025 --- /dev/null +++ b/src/basic/entities/coupon/utils.ts @@ -0,0 +1,35 @@ +/** + * Coupon Entity - 검증 로직 + * + * 쿠폰 관련 순수 함수 (비즈니스 로직) + */ + +import { Coupon } from './model'; + +/** + * 쿠폰 적용 가능 여부 검증 + * - percentage 쿠폰은 10,000원 이상 구매 시에만 사용 가능 + */ +export const canApplyCoupon = ( + coupon: Coupon, + totalAmount: number +): { canApply: boolean; reason?: string } => { + if (coupon.discountType === 'percentage' && totalAmount < 10000) { + return { + canApply: false, + reason: 'percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.' + }; + } + + return { canApply: true }; +}; + +/** + * 쿠폰 중복 여부 검증 + */ +export const isDuplicateCoupon = ( + coupons: Coupon[], + couponCode: string +): boolean => { + return coupons.some(c => c.code === couponCode); +}; diff --git a/src/basic/entities/product/model.ts b/src/basic/entities/product/model.ts new file mode 100644 index 000000000..4f2e08bbc --- /dev/null +++ b/src/basic/entities/product/model.ts @@ -0,0 +1,23 @@ +/** + * Product Entity - 타입 정의 + * + * 상품 관련 도메인 타입 + */ + +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface ProductWithUI extends Product { + showAddProductForm: boolean; + editingProductId: string | null; +} + +export interface Discount { + quantity: number; + rate: number; +} diff --git a/src/basic/entities/product/utils.ts b/src/basic/entities/product/utils.ts new file mode 100644 index 000000000..eba7ac377 --- /dev/null +++ b/src/basic/entities/product/utils.ts @@ -0,0 +1,65 @@ +/** + * Product Entity - 계산 로직 + * + * 상품 관련 순수 함수 (비즈니스 로직) + */ + +import { Product } from './model'; +import { CartItem } from '../cart/model'; + +/** + * 상품의 남은 재고 계산 + * (전체 재고 - 장바구니에 담긴 수량) + */ +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 const canAddToCart = ( + product: Product, + cart: CartItem[] +): boolean => { + return getRemainingStock(product, cart) > 0; +}; + +/** + * 상품의 재고가 충분한지 검증 + */ +export const hasEnoughStock = ( + product: Product, + requestedQuantity: number, + cart: CartItem[] +): boolean => { + const remainingStock = getRemainingStock(product, cart); + return remainingStock >= requestedQuantity; +}; + +/** + * 상품 목록 검색 필터링 + */ +export const filterProducts = ( + products: Product[], + searchTerm: string +): Product[] => { + if (!searchTerm) return products; + + const lowerSearchTerm = searchTerm.toLowerCase(); + + return products.filter(product => { + const nameMatch = product.name.toLowerCase().includes(lowerSearchTerm); + const descriptionMatch = 'description' in product && + typeof (product as any).description === 'string' && + (product as any).description.toLowerCase().includes(lowerSearchTerm); + + return nameMatch || descriptionMatch; + }); +}; diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 000000000..639c9e2c6 --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,149 @@ +/** + * Cart Feature - useCart Hook + * + * 장바구니 상태 관리 및 비즈니스 로직 + */ + +import { useState, useCallback, useMemo } from 'react'; +import { CartItem } from '../entities/cart/model'; +import { Coupon } from '../entities/coupon/model'; +import { Product } from '../entities/product/model'; +import { + calculateCartTotal as calcCartTotal, + calculateItemTotal as calcItemTotal +} from '../entities/cart/utils'; +import { getRemainingStock as getStock } from '../entities/product/utils'; +import { canApplyCoupon } from '../entities/coupon/utils'; +import { generateOrderNumber } from '../shared/utils/format'; +import { useLocalStorage } from '../shared/hooks/useLocalStorage'; + +interface UseCartProps { + products: Product[]; + onNotify: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export const useCart = ({ products, onNotify }: UseCartProps) => { + const [cart, setCart] = useLocalStorage('cart', []); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + // 장바구니에 상품 추가 + const addToCart = useCallback((product: Product) => { + const remainingStock = getStock(product, cart); + if (remainingStock <= 0) { + onNotify('재고가 부족합니다!', '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) { + onNotify(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); + return prevCart; + } + } + + // 재고 검증 통과 + if (existingItem) { + return prevCart.map(item => + item.product.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + ); + } + + return [...prevCart, { product, quantity: 1 }]; + }); + + onNotify('장바구니에 담았습니다', 'success'); + }, [cart, onNotify, setCart]); + + // 장바구니에서 상품 제거 + const removeFromCart = useCallback((productId: string) => { + setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + }, [setCart]); + + // 수량 업데이트 + 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) { + onNotify(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); + return; + } + + setCart(prevCart => + prevCart.map(item => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ) + ); + }, [products, removeFromCart, onNotify, setCart]); + + // 쿠폰 적용 + const applyCoupon = useCallback((coupon: Coupon) => { + const currentTotal = calcCartTotal(cart, selectedCoupon).totalAfterDiscount; + + const { canApply: isValid, reason } = canApplyCoupon(coupon, currentTotal); + if (!isValid && reason) { + onNotify(reason, 'error'); + return; + } + + setSelectedCoupon(coupon); + onNotify('쿠폰이 적용되었습니다.', 'success'); + }, [cart, selectedCoupon, onNotify]); + + // 쿠폰 제거 + const removeCoupon = useCallback(() => { + setSelectedCoupon(null); + }, []); + + // 주문 완료 + const completeOrder = useCallback(() => { + const orderNumber = generateOrderNumber(); + onNotify(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + setCart([]); + setSelectedCoupon(null); + }, [onNotify, setCart]); + + // 남은 재고 계산 + const getRemainingStock = useCallback((product: Product): number => { + return getStock(product, cart); + }, [cart]); + + // 개별 아이템 총액 계산 + const calculateItemTotal = useCallback((item: CartItem): number => { + return calcItemTotal(item, cart); + }, [cart]); + + // 장바구니 총액 계산 (메모이제이션) + const totals = useMemo(() => { + return calcCartTotal(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + return { + cart, + selectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + removeCoupon, + completeOrder, + getRemainingStock, + calculateItemTotal, + totals + }; +}; diff --git a/src/basic/hooks/useCoupon.ts b/src/basic/hooks/useCoupon.ts new file mode 100644 index 000000000..1076ea2ec --- /dev/null +++ b/src/basic/hooks/useCoupon.ts @@ -0,0 +1,41 @@ +/** + * Coupon Feature - useCoupon Hook + * + * 쿠폰 상태 관리 및 CRUD 로직 + */ + +import { useCallback } from 'react'; +import { Coupon } from '../entities/coupon/model'; +import { isDuplicateCoupon } from '../entities/coupon/utils'; +import { useLocalStorage } from '../shared/hooks/useLocalStorage'; + +interface UseCouponProps { + initialCoupons: Coupon[]; + onNotify: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export const useCoupon = ({ initialCoupons, onNotify }: UseCouponProps) => { + const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); + + // 쿠폰 추가 + const addCoupon = useCallback((newCoupon: Coupon) => { + if (isDuplicateCoupon(coupons, newCoupon.code)) { + onNotify('이미 존재하는 쿠폰 코드입니다.', 'error'); + return; + } + setCoupons(prev => [...prev, newCoupon]); + onNotify('쿠폰이 추가되었습니다.', 'success'); + }, [coupons, onNotify, setCoupons]); + + // 쿠폰 삭제 + const deleteCoupon = useCallback((couponCode: string) => { + setCoupons(prev => prev.filter(c => c.code !== couponCode)); + onNotify('쿠폰이 삭제되었습니다.', 'success'); + }, [onNotify, setCoupons]); + + return { + coupons, + addCoupon, + deleteCoupon + }; +}; diff --git a/src/basic/hooks/useProduct.ts b/src/basic/hooks/useProduct.ts new file mode 100644 index 000000000..2fc36cffb --- /dev/null +++ b/src/basic/hooks/useProduct.ts @@ -0,0 +1,53 @@ +/** + * Product Feature - useProduct Hook + * + * 상품 상태 관리 및 CRUD 로직 + */ + +import { useCallback } from 'react'; +import { ProductWithUI } from '../pages/AdminPage'; +import { useLocalStorage } from '../shared/hooks/useLocalStorage'; + +interface UseProductProps { + initialProducts: ProductWithUI[]; + onNotify: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export const useProduct = ({ initialProducts, onNotify }: UseProductProps) => { + const [products, setProducts] = useLocalStorage('products', initialProducts); + + // 상품 추가 + const addProduct = useCallback((newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}` + }; + setProducts(prev => [...prev, product]); + onNotify('상품이 추가되었습니다.', 'success'); + }, [onNotify, setProducts]); + + // 상품 수정 + const updateProduct = useCallback((productId: string, updates: Partial) => { + setProducts(prev => + prev.map(product => + product.id === productId + ? { ...product, ...updates } + : product + ) + ); + onNotify('상품이 수정되었습니다.', 'success'); + }, [onNotify, setProducts]); + + // 상품 삭제 + const deleteProduct = useCallback((productId: string) => { + setProducts(prev => prev.filter(p => p.id !== productId)); + onNotify('상품이 삭제되었습니다.', 'success'); + }, [onNotify, setProducts]); + + return { + products, + addProduct, + updateProduct, + deleteProduct + }; +}; diff --git a/src/basic/pages/AdminPage.tsx b/src/basic/pages/AdminPage.tsx new file mode 100644 index 000000000..daeb3e00e --- /dev/null +++ b/src/basic/pages/AdminPage.tsx @@ -0,0 +1,516 @@ +/** + * AdminPage 컴포넌트 + * + * 책임: 관리자 페이지 UI 렌더링 + * - 상품 관리 탭 (추가/수정/삭제) + * - 쿠폰 관리 탭 (추가/삭제) + * + * 특징: + * - 상태 관리는 부모 컴포넌트(App)에서 처리 + * - Props를 통해 데이터와 액션을 받아 UI만 담당 + * - 폼 상태는 로컬에서 관리 (UI 상태) + */ + +import { useState } from 'react'; +import { Product, Coupon } from '../../types'; +import { + validatePrice, + validateStock, + validateDiscountRate, + validateDiscountAmount, + isNumericString, + parseNumberInput +} from '../shared/utils/validators'; +import { CloseIcon, PlusIcon, TrashIcon } from '../shared/assets/icons/Icons'; + +// ProductWithUI 타입 확장 (UI 전용 속성 추가) +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface AdminPageProps { + products: ProductWithUI[]; + coupons: Coupon[]; + onAddProduct: (product: Omit) => void; + onUpdateProduct: (productId: string, updates: Partial) => void; + onDeleteProduct: (productId: string) => void; + onAddCoupon: (coupon: Coupon) => void; + onDeleteCoupon: (couponCode: string) => void; + formatPrice: (price: number, productId?: string) => string; + onNotify: (message: string, type: 'error' | 'success' | 'warning') => void; +} + +const AdminPage = ({ + products, + coupons, + onAddProduct, + onUpdateProduct, + onDeleteProduct, + onAddCoupon, + onDeleteCoupon, + formatPrice, + onNotify, +}: AdminPageProps) => { + // 탭 상태 + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + + // 상품 폼 상태 + const [showProductForm, setShowProductForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [] as Array<{ quantity: number; rate: number }>, + }); + + // 쿠폰 폼 상태 + const [showCouponForm, setShowCouponForm] = useState(false); + const [couponForm, setCouponForm] = useState({ + name: '', + code: '', + discountType: 'amount' as 'amount' | 'percentage', + discountValue: 0, + }); + + // 상품 폼 제출 + const handleProductSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== 'new') { + onUpdateProduct(editingProduct, productForm); + } else { + onAddProduct(productForm); + } + setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); + setEditingProduct(null); + setShowProductForm(false); + }; + + // 쿠폰 폼 제출 + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onAddCoupon(couponForm); + setCouponForm({ name: '', code: '', discountType: 'amount', discountValue: 0 }); + setShowCouponForm(false); + }; + + // 상품 수정 시작 + const startEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [], + }); + setShowProductForm(true); + }; + + return ( +
+ {/* 헤더 */} +
+

관리자 대시보드

+

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

+
+ + {/* 탭 네비게이션 */} +
+ +
+ + {/* 상품 관리 탭 */} + {activeTab === '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 === '' || isNumericString(value)) { + setProductForm({ ...productForm, price: parseNumberInput(value, 0) }); + } + }} + onBlur={(e) => { + const price = parseNumberInput(e.target.value, 0); + const validation = validatePrice(price); + if (!validation.isValid) { + onNotify(validation.errorMessage!, '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 === '' || isNumericString(value)) { + setProductForm({ ...productForm, stock: parseNumberInput(value, 0) }); + } + }} + onBlur={(e) => { + const stock = parseNumberInput(e.target.value, 0); + const validation = validateStock(stock); + if (!validation.isValid) { + onNotify(validation.errorMessage!, 'error'); + setProductForm({ ...productForm, stock: 0 }); + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder="숫자만 입력" + required + /> +
+
+ +
+ +
+ {productForm.discounts.map((discount, index) => ( +
+ { + 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 === 'coupons' && ( +
+
+

쿠폰 관리

+
+
+
+ {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 === '' || isNumericString(value)) { + setCouponForm({ ...couponForm, discountValue: parseNumberInput(value, 0) }); + } + }} + onBlur={(e) => { + const value = parseNumberInput(e.target.value, 0); + + if (couponForm.discountType === 'percentage') { + const validation = validateDiscountRate(value); + if (!validation.isValid) { + onNotify(validation.errorMessage!, 'error'); + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } else { + const validation = validateDiscountAmount(value); + if (!validation.isValid) { + onNotify(validation.errorMessage!, 'error'); + 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 + /> +
+
+
+ + +
+
+
+ )} +
+
+ )} +
+ ); +}; + +export default AdminPage; diff --git a/src/basic/pages/CartPage.tsx b/src/basic/pages/CartPage.tsx new file mode 100644 index 000000000..bcf2e87d5 --- /dev/null +++ b/src/basic/pages/CartPage.tsx @@ -0,0 +1,93 @@ +/** + * CartPage Component + * + * 책임: 쇼핑 페이지 UI 조합 + * - ProductList와 Cart 컴포넌트 조합 + * - Props 전달만 담당 + */ + +import { CartItem, Coupon } from '../../types'; +import { ProductWithUI } from './AdminPage'; +import { ProductList } from '../widgets/Product/ProductList'; +import { Cart } from '../widgets/Cart/Cart'; + +interface CartPageProps { + // 데이터 + products: ProductWithUI[]; + cart: CartItem[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + searchTerm: string; + + // 장바구니 액션 + onAddToCart: (product: ProductWithUI) => void; + onRemoveFromCart: (productId: string) => void; + onUpdateQuantity: (productId: string, quantity: number) => void; + + // 쿠폰 액션 + onApplyCoupon: (coupon: Coupon) => void; + onRemoveCoupon: () => void; + + // 주문 액션 + onCompleteOrder: () => void; + + // 유틸 함수 + formatPrice: (price: number, productId?: string) => string; + getRemainingStock: (product: ProductWithUI) => number; + calculateItemTotal: (item: CartItem) => number; + calculateCartTotal: () => { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; +} + +const CartPage = ({ + products, + cart, + coupons, + selectedCoupon, + searchTerm, + onAddToCart, + onRemoveFromCart, + onUpdateQuantity, + onApplyCoupon, + onRemoveCoupon, + onCompleteOrder, + formatPrice, + getRemainingStock, + calculateItemTotal, + calculateCartTotal, +}: CartPageProps) => { + return ( +
+ {/* 상품 목록 */} +
+ +
+ + {/* 장바구니 & 결제 */} +
+ +
+
+ ); +}; + +export default CartPage; diff --git a/src/basic/shared/assets/icons/Icons.tsx b/src/basic/shared/assets/icons/Icons.tsx new file mode 100644 index 000000000..9c443fae6 --- /dev/null +++ b/src/basic/shared/assets/icons/Icons.tsx @@ -0,0 +1,82 @@ +/** + * Shared Assets - Icons + * + * SVG 아이콘 컴포넌트 모음 + * + * 위치: shared/assets/icons + * - UI 컴포넌트 내부에서 사용되는 보조 컴포넌트 + * - 이미지, 폰트와 같은 정적 리소스로 분류 + * - 직접 페이지에 렌더링되지 않고 다른 컴포넌트 안에서만 사용 + */ + +interface IconProps { + className?: string; +} + +/** + * 닫기 (X) 아이콘 + */ +export const CloseIcon = ({ className = "w-4 h-4" }: IconProps) => ( + + + +); + +/** + * 장바구니 아이콘 + */ +export const CartIcon = ({ className = "w-5 h-5" }: IconProps) => ( + + + +); + +/** + * 이미지 플레이스홀더 아이콘 + */ +export const ImageIcon = ({ className = "w-24 h-24" }: IconProps) => ( + + + +); + +/** + * 검색 아이콘 + */ +export const SearchIcon = ({ className = "w-6 h-6" }: IconProps) => ( + + + +); + +/** + * 플러스 아이콘 + */ +export const PlusIcon = ({ className = "w-8 h-8" }: IconProps) => ( + + + +); + +/** + * 휴지통 아이콘 + */ +export const TrashIcon = ({ className = "w-5 h-5" }: IconProps) => ( + + + +); + +/** + * 마이너스 아이콘 + */ +export const MinusIcon = ({ className = "text-xs" }: IconProps) => ( + +); + +/** + * 플러스 텍스트 아이콘 + */ +export const PlusTextIcon = ({ className = "text-xs" }: IconProps) => ( + + +); diff --git a/src/basic/shared/hooks/useDebounce.ts b/src/basic/shared/hooks/useDebounce.ts new file mode 100644 index 000000000..3369a51c5 --- /dev/null +++ b/src/basic/shared/hooks/useDebounce.ts @@ -0,0 +1,23 @@ +import { useState, useEffect } from 'react'; + +/** + * 값이 변경된 후 지정된 시간 동안 변경이 없을 때만 업데이트되는 Hook + * @param value - debounce할 값 + * @param delay - 지연 시간 (밀리초) + * @returns debounced된 값 + */ +export const useDebounce = (value: T, delay: number = 500): 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/useFormValidation.ts b/src/basic/shared/hooks/useFormValidation.ts new file mode 100644 index 000000000..e051d71cc --- /dev/null +++ b/src/basic/shared/hooks/useFormValidation.ts @@ -0,0 +1,87 @@ +/** + * Shared Hook - useFormValidation + * + * 폼 전체 유효성 검증 + 에러 상태 관리 + */ + +import { useState, useCallback } from 'react'; +import { ValidationResult } from '../utils/validators'; + +interface FormErrors { + [key: string]: string | undefined; +} + +export const useFormValidation = () => { + const [errors, setErrors] = useState({}); + + /** + * 특정 필드 검증 + */ + const validateField = useCallback( + (fieldName: string, validator: () => ValidationResult) => { + const result = validator(); + + setErrors(prev => ({ + ...prev, + [fieldName]: result.isValid ? undefined : result.errorMessage + })); + + return result.isValid; + }, + [] + ); + + /** + * 여러 필드 동시 검증 + */ + const validateFields = useCallback( + (validations: Array<{ fieldName: string; validator: () => ValidationResult }>) => { + const newErrors: FormErrors = {}; + let isValid = true; + + validations.forEach(({ fieldName, validator }) => { + const result = validator(); + if (!result.isValid) { + newErrors[fieldName] = result.errorMessage; + isValid = false; + } + }); + + setErrors(newErrors); + return isValid; + }, + [] + ); + + /** + * 에러 초기화 + */ + const clearErrors = useCallback(() => { + setErrors({}); + }, []); + + /** + * 특정 필드 에러 제거 + */ + const clearFieldError = useCallback((fieldName: string) => { + setErrors(prev => { + const newErrors = { ...prev }; + delete newErrors[fieldName]; + return newErrors; + }); + }, []); + + /** + * 폼이 유효한지 확인 + */ + const hasErrors = Object.values(errors).some(error => error !== undefined); + + return { + errors, + validateField, + validateFields, + clearErrors, + clearFieldError, + hasErrors, + }; +}; diff --git a/src/basic/shared/hooks/useLocalStorage.ts b/src/basic/shared/hooks/useLocalStorage.ts new file mode 100644 index 000000000..778a22680 --- /dev/null +++ b/src/basic/shared/hooks/useLocalStorage.ts @@ -0,0 +1,34 @@ +import { useState, useEffect } from 'react'; + +/** + * localStorage와 동기화되는 상태를 관리하는 Hook + * @param key - localStorage 키 + * @param initialValue - 초기값 + * @returns [저장된 값, 값을 업데이트하는 함수] + */ +export const useLocalStorage = ( + key: string, + initialValue: T +): [T, (value: T | ((prevValue: T) => T)) => void] => { + // 초기값 설정: localStorage에서 가져오거나 initialValue 사용 + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(`Error loading ${key} from localStorage:`, error); + return initialValue; + } + }); + + // 값이 변경될 때마다 localStorage에 저장 + useEffect(() => { + try { + window.localStorage.setItem(key, JSON.stringify(storedValue)); + } catch (error) { + console.error(`Error saving ${key} to localStorage:`, error); + } + }, [key, storedValue]); + + return [storedValue, setStoredValue]; +}; diff --git a/src/basic/shared/hooks/useToast.ts b/src/basic/shared/hooks/useToast.ts new file mode 100644 index 000000000..43839bd2d --- /dev/null +++ b/src/basic/shared/hooks/useToast.ts @@ -0,0 +1,45 @@ +/** + * Toast Feature - useToast Hook + * + * 토스트 메시지 관리 + * + * Features: + * - 토스트 추가/제거 + * - 자동 제거 타이머 (3초) + * - 타입별 메시지 (success, error, warning) + */ + +import { useState, useCallback } from 'react'; + +export interface Toast { + id: string; + message: string; + type: 'success' | 'error' | 'warning'; +} + +export const useToast = () => { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback(( + message: string, + type: 'error' | 'success' | 'warning' = 'success' + ) => { + const id = Date.now().toString(); + setToasts(prev => [...prev, { id, message, type }]); + + // 3초 후 자동 제거 + setTimeout(() => { + setToasts(prev => prev.filter(n => n.id !== id)); + }, 3000); + }, []); + + const removeToast = useCallback((id: string) => { + setToasts(prev => prev.filter(n => n.id !== id)); + }, []); + + return { + toasts, + addToast, + removeToast + }; +}; diff --git a/src/basic/shared/ui/Badge/Badge.tsx b/src/basic/shared/ui/Badge/Badge.tsx new file mode 100644 index 000000000..7d12d3b6a --- /dev/null +++ b/src/basic/shared/ui/Badge/Badge.tsx @@ -0,0 +1,43 @@ +/** + * Shared UI - Badge Component + * + * 숫자나 상태를 표시하는 배지 컴포넌트 + */ + +import { ReactNode } from 'react'; + +interface BadgeProps { + children: ReactNode; + variant?: 'primary' | 'success' | 'warning' | 'danger' | 'info'; + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +export const Badge = ({ + children, + variant = 'primary', + size = 'md', + className = '' +}: BadgeProps) => { + const baseStyles = 'inline-flex items-center justify-center font-semibold rounded-full'; + + const variantStyles = { + primary: 'bg-blue-500 text-white', + success: 'bg-green-500 text-white', + warning: 'bg-yellow-500 text-white', + danger: 'bg-red-500 text-white', + info: 'bg-gray-500 text-white' + }; + + const sizeStyles = { + sm: 'px-2 py-0.5 text-xs min-w-[1.25rem] h-5', + md: 'px-2.5 py-1 text-sm min-w-[1.5rem] h-6', + lg: 'px-3 py-1.5 text-base min-w-[2rem] h-8' + }; + + return ( + + {children} + + ); +}; diff --git a/src/basic/shared/ui/Button/Button.tsx b/src/basic/shared/ui/Button/Button.tsx new file mode 100644 index 000000000..5fb1c32cd --- /dev/null +++ b/src/basic/shared/ui/Button/Button.tsx @@ -0,0 +1,49 @@ +/** + * Shared UI - Button Component + * + * 재사용 가능한 버튼 컴포넌트 + */ + +import { ButtonHTMLAttributes, ReactNode } from 'react'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; + children: ReactNode; +} + +export const Button = ({ + variant = 'primary', + size = 'md', + children, + className = '', + disabled, + ...props +}: ButtonProps) => { + const baseStyles = 'font-medium rounded transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2'; + + const variantStyles = { + primary: 'bg-blue-500 hover:bg-blue-600 text-white focus:ring-blue-500 disabled:bg-blue-300', + secondary: 'bg-gray-500 hover:bg-gray-600 text-white focus:ring-gray-500 disabled:bg-gray-300', + danger: 'bg-red-500 hover:bg-red-600 text-white focus:ring-red-500 disabled:bg-red-300', + ghost: 'bg-transparent hover:bg-gray-100 text-gray-700 border border-gray-300 focus:ring-gray-500' + }; + + const sizeStyles = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-base', + lg: 'px-6 py-3 text-lg' + }; + + const disabledStyles = disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'; + + return ( + + ); +}; diff --git a/src/basic/shared/ui/Card/Card.tsx b/src/basic/shared/ui/Card/Card.tsx new file mode 100644 index 000000000..91c1dc8c9 --- /dev/null +++ b/src/basic/shared/ui/Card/Card.tsx @@ -0,0 +1,43 @@ +/** + * Shared UI - Card Component + * + * 콘텐츠를 담는 카드 컴포넌트 + */ + +import { ReactNode } from 'react'; + +interface CardProps { + children: ReactNode; + className?: string; + padding?: 'none' | 'sm' | 'md' | 'lg'; + shadow?: 'none' | 'sm' | 'md' | 'lg'; +} + +export const Card = ({ + children, + className = '', + padding = 'md', + shadow = 'md' +}: CardProps) => { + const baseStyles = 'bg-white rounded-lg border border-gray-200'; + + const paddingStyles = { + none: '', + sm: 'p-3', + md: 'p-4', + lg: 'p-6' + }; + + const shadowStyles = { + none: '', + sm: 'shadow-sm', + md: 'shadow-md', + lg: 'shadow-lg' + }; + + return ( +
+ {children} +
+ ); +}; diff --git a/src/basic/shared/ui/Input/Input.tsx b/src/basic/shared/ui/Input/Input.tsx new file mode 100644 index 000000000..58c12933c --- /dev/null +++ b/src/basic/shared/ui/Input/Input.tsx @@ -0,0 +1,46 @@ +/** + * Shared UI - Input Component + * + * 재사용 가능한 입력 컴포넌트 + */ + +import { InputHTMLAttributes, forwardRef } from 'react'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + error?: string; + fullWidth?: boolean; +} + +export const Input = forwardRef(({ + label, + error, + fullWidth = false, + className = '', + ...props +}, ref) => { + const baseStyles = 'px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors'; + const errorStyles = error ? 'border-red-500 focus:ring-red-500' : 'border-gray-300'; + const widthStyles = fullWidth ? 'w-full' : ''; + const disabledStyles = props.disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white'; + + return ( +
+ {label && ( + + )} + + {error && ( +

{error}

+ )} +
+ ); +}); + +Input.displayName = 'Input'; diff --git a/src/basic/shared/ui/Toast/Toast.tsx b/src/basic/shared/ui/Toast/Toast.tsx new file mode 100644 index 000000000..d426d7622 --- /dev/null +++ b/src/basic/shared/ui/Toast/Toast.tsx @@ -0,0 +1,42 @@ +/** + * Shared UI - Toast Component + * + * 개별 토스트 메시지 UI + * + * Features: + * - 3가지 타입 (success, error, warning) + * - 닫기 버튼 + * - 애니메이션 (slide-in) + */ + +import { CloseIcon } from '../../assets/icons/Icons'; + +export interface ToastProps { + id: string; + message: string; + type: 'success' | 'error' | 'warning'; + onClose: (id: string) => void; +} + +export const Toast = ({ id, message, type, onClose }: ToastProps) => { + const bgColor = + type === 'error' ? 'bg-red-600' : + type === 'warning' ? 'bg-yellow-600' : + 'bg-green-600'; + + return ( +
+ {message} + +
+ ); +}; diff --git a/src/basic/shared/ui/Toast/ToastContainer.tsx b/src/basic/shared/ui/Toast/ToastContainer.tsx new file mode 100644 index 000000000..4dbd6131c --- /dev/null +++ b/src/basic/shared/ui/Toast/ToastContainer.tsx @@ -0,0 +1,31 @@ +/** + * Shared UI - ToastContainer Component + * + * 토스트 메시지 목록 컨테이너 + * + * Features: + * - 화면 우측 상단에 고정 + * - 여러 토스트를 세로로 표시 + * - 자동 제거 (부모에서 타이머 관리) + */ + +import { Toast, ToastProps } from './Toast'; + +interface ToastContainerProps { + toasts: Omit[]; + onClose: (id: string) => void; +} + +export const ToastContainer = ({ toasts, onClose }: ToastContainerProps) => { + if (toasts.length === 0) { + return null; + } + + return ( +
+ {toasts.map(toast => ( + + ))} +
+ ); +}; diff --git a/src/basic/shared/utils/format.ts b/src/basic/shared/utils/format.ts new file mode 100644 index 000000000..ee5e38711 --- /dev/null +++ b/src/basic/shared/utils/format.ts @@ -0,0 +1,50 @@ +/** + * Shared Utils - 포맷팅 함수 + * + * 화면에 표시되는 데이터 포맷팅 유틸리티 + */ + +import { Product } from '../../entities/product/model'; +import { CartItem } from '../../entities/cart/model'; +import { getRemainingStock } from '../../entities/product/utils'; + +/** + * 가격 포맷팅 + * - 관리자 모드: "10,000원" + * - 사용자 모드: "₩10,000" + * - 품절 상품: "SOLD OUT" + */ +export const formatPrice = ( + price: number, + isAdmin: boolean, + product?: Product, + cart?: CartItem[] +): string => { + if (product && cart) { + const remainingStock = getRemainingStock(product, cart); + if (remainingStock <= 0) { + return 'SOLD OUT'; + } + } + + if (isAdmin) { + return `${price.toLocaleString()}원`; + } + + return `₩${price.toLocaleString()}`; +}; + +/** + * 할인율 포맷팅 + * 예: 0.1 → "10%" + */ +export const formatDiscount = (rate: number): string => { + return `${(rate * 100).toFixed(0)}%`; +}; + +/** + * 주문 번호 생성 + */ +export const generateOrderNumber = (): string => { + return `ORD-${Date.now()}`; +}; diff --git a/src/basic/shared/utils/validators.ts b/src/basic/shared/utils/validators.ts new file mode 100644 index 000000000..78eeefeda --- /dev/null +++ b/src/basic/shared/utils/validators.ts @@ -0,0 +1,209 @@ +/** + * Shared Utils - Validators + * + * 비즈니스 규칙에 따른 유효성 검증 순수 함수 + */ + +export interface ValidationResult { + isValid: boolean; + errorMessage?: string; +} + +/** + * 가격 유효성 검증 + */ +export const validatePrice = (price: number): ValidationResult => { + if (price < 0) { + return { + isValid: false, + errorMessage: '가격은 0보다 커야 합니다' + }; + } + + if (price > 10000000) { + return { + isValid: false, + errorMessage: '가격은 10,000,000원을 초과할 수 없습니다' + }; + } + + return { isValid: true }; +}; + +/** + * 재고 유효성 검증 + */ +export const validateStock = (stock: number): ValidationResult => { + if (stock < 0) { + return { + isValid: false, + errorMessage: '재고는 0보다 커야 합니다' + }; + } + + if (stock > 9999) { + return { + isValid: false, + errorMessage: '재고는 9999개를 초과할 수 없습니다' + }; + } + + return { isValid: true }; +}; + +/** + * 할인율 유효성 검증 (백분율) + */ +export const validateDiscountRate = (rate: number): ValidationResult => { + if (rate < 0) { + return { + isValid: false, + errorMessage: '할인율은 0% 이상이어야 합니다' + }; + } + + if (rate > 100) { + return { + isValid: false, + errorMessage: '할인율은 100%를 초과할 수 없습니다' + }; + } + + return { isValid: true }; +}; + +/** + * 할인 금액 유효성 검증 + */ +export const validateDiscountAmount = (amount: number): ValidationResult => { + if (amount < 0) { + return { + isValid: false, + errorMessage: '할인 금액은 0원 이상이어야 합니다' + }; + } + + if (amount > 100000) { + return { + isValid: false, + errorMessage: '할인 금액은 100,000원을 초과할 수 없습니다' + }; + } + + return { isValid: true }; +}; + +/** + * 할인 수량 유효성 검증 + */ +export const validateDiscountQuantity = (quantity: number): ValidationResult => { + if (quantity < 1) { + return { + isValid: false, + errorMessage: '할인 수량은 1개 이상이어야 합니다' + }; + } + + if (quantity > 9999) { + return { + isValid: false, + errorMessage: '할인 수량은 9999개를 초과할 수 없습니다' + }; + } + + return { isValid: true }; +}; + +/** + * 상품명 유효성 검증 + */ +export const validateProductName = (name: string): ValidationResult => { + if (!name || name.trim().length === 0) { + return { + isValid: false, + errorMessage: '상품명을 입력해주세요' + }; + } + + if (name.length > 100) { + return { + isValid: false, + errorMessage: '상품명은 100자를 초과할 수 없습니다' + }; + } + + return { isValid: true }; +}; + +/** + * 쿠폰명 유효성 검증 + */ +export const validateCouponName = (name: string): ValidationResult => { + if (!name || name.trim().length === 0) { + return { + isValid: false, + errorMessage: '쿠폰명을 입력해주세요' + }; + } + + if (name.length > 50) { + return { + isValid: false, + errorMessage: '쿠폰명은 50자를 초과할 수 없습니다' + }; + } + + return { isValid: true }; +}; + +/** + * 쿠폰 코드 유효성 검증 + */ +export const validateCouponCode = (code: string): ValidationResult => { + if (!code || code.trim().length === 0) { + return { + isValid: false, + errorMessage: '쿠폰 코드를 입력해주세요' + }; + } + + // 영문, 숫자만 허용 + if (!/^[A-Z0-9]+$/.test(code)) { + return { + isValid: false, + errorMessage: '쿠폰 코드는 영문 대문자와 숫자만 사용 가능합니다' + }; + } + + if (code.length < 4) { + return { + isValid: false, + errorMessage: '쿠폰 코드는 최소 4자 이상이어야 합니다' + }; + } + + if (code.length > 20) { + return { + isValid: false, + errorMessage: '쿠폰 코드는 20자를 초과할 수 없습니다' + }; + } + + return { isValid: true }; +}; + +/** + * 숫자만 포함되어 있는지 검증 + */ +export const isNumericString = (value: string): boolean => { + return /^\d+$/.test(value); +}; + +/** + * 빈 문자열을 0으로 변환하거나 숫자로 파싱 + */ +export const parseNumberInput = (value: string, defaultValue: number = 0): number => { + if (value === '') return defaultValue; + const parsed = parseInt(value); + return isNaN(parsed) ? defaultValue : parsed; +}; diff --git a/src/basic/widgets/Cart/Cart.tsx b/src/basic/widgets/Cart/Cart.tsx new file mode 100644 index 000000000..d6ddb3b60 --- /dev/null +++ b/src/basic/widgets/Cart/Cart.tsx @@ -0,0 +1,122 @@ +/** + * Cart Feature - Cart Component + * + * 장바구니, 쿠폰, 결제 정보 표시 컴포넌트 + */ + +import { CartItem as CartItemType, Coupon } from '../../../types'; +import { CartItem } from './CartItem'; +import { CouponList } from '../Coupon/CouponList'; +import { CartIcon } from '../../shared/assets/icons/Icons'; + +interface CartProps { + cart: CartItemType[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + onRemoveFromCart: (productId: string) => void; + onUpdateQuantity: (productId: string, quantity: number) => void; + onApplyCoupon: (coupon: Coupon) => void; + onRemoveCoupon: () => void; + onCompleteOrder: () => void; + calculateItemTotal: (item: CartItemType) => number; + calculateCartTotal: () => { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; +} + +export const Cart = ({ + cart, + coupons, + selectedCoupon, + onRemoveFromCart, + onUpdateQuantity, + onApplyCoupon, + onRemoveCoupon, + onCompleteOrder, + calculateItemTotal, + calculateCartTotal +}: CartProps) => { + const totals = calculateCartTotal(); + + return ( +
+ {/* 장바구니 */} +
+

+ + 장바구니 +

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

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map(item => ( + + ))} +
+ )} +
+ + {cart.length > 0 && ( + <> + {/* 쿠폰 선택 */} + + + {/* 결제 정보 */} +
+

결제 정보

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

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

+
+
+ + )} +
+ ); +}; diff --git a/src/basic/widgets/Cart/CartItem.tsx b/src/basic/widgets/Cart/CartItem.tsx new file mode 100644 index 000000000..f112f9125 --- /dev/null +++ b/src/basic/widgets/Cart/CartItem.tsx @@ -0,0 +1,74 @@ +/** + * Cart Feature - CartItem Component + * + * 장바구니 개별 아이템 컴포넌트 + */ + +import { CartItem as CartItemType } from '../../../types'; +import { CloseIcon, MinusIcon, PlusTextIcon } from '../../shared/assets/icons/Icons'; + +interface CartItemProps { + item: CartItemType; + onRemove: (productId: string) => void; + onUpdateQuantity: (productId: string, quantity: number) => void; + calculateItemTotal: (item: CartItemType) => number; +} + +export const CartItem = ({ + item, + onRemove, + onUpdateQuantity, + calculateItemTotal +}: CartItemProps) => { + 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()}원 +

+
+
+
+ ); +}; diff --git a/src/basic/widgets/Coupon/CouponList.tsx b/src/basic/widgets/Coupon/CouponList.tsx new file mode 100644 index 000000000..b3cb4f68d --- /dev/null +++ b/src/basic/widgets/Coupon/CouponList.tsx @@ -0,0 +1,55 @@ +/** + * Coupon Feature - CouponList Component + * + * 쿠폰 선택 컴포넌트 + */ + +import { Coupon } from '../../../types'; + +interface CouponListProps { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + onApply: (coupon: Coupon) => void; + onRemove: () => void; +} + +export const CouponList = ({ + coupons, + selectedCoupon, + onApply, + onRemove +}: CouponListProps) => { + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +}; diff --git a/src/basic/widgets/Header/Header.tsx b/src/basic/widgets/Header/Header.tsx new file mode 100644 index 000000000..d1bfcc09b --- /dev/null +++ b/src/basic/widgets/Header/Header.tsx @@ -0,0 +1,74 @@ +import { useMemo } from 'react'; +import { CartItem } from '../../../types'; + +interface HeaderProps { + cart: CartItem[]; + isAdmin: boolean; + onAdminToggle: () => void; + searchTerm: string; + onSearchChange: (term: string) => void; +} + +const Header = ({ + cart, + isAdmin, + onAdminToggle, + searchTerm, + onSearchChange +}: HeaderProps) => { + // 장바구니 총 아이템 수 계산 (메모이제이션) + const totalItemCount = useMemo(() => { + return cart.reduce((sum, item) => sum + item.quantity, 0); + }, [cart]); + + + return ( +
+
+
+
+

SHOP

+ {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} + {!isAdmin && ( +
+ onSearchChange(e.target.value)} + placeholder="상품 검색..." + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ )} +
+ +
+
+
+ ); +}; + +export default Header; \ No newline at end of file diff --git a/src/basic/widgets/Product/ProductCard.tsx b/src/basic/widgets/Product/ProductCard.tsx new file mode 100644 index 000000000..159c54a84 --- /dev/null +++ b/src/basic/widgets/Product/ProductCard.tsx @@ -0,0 +1,95 @@ +/** + * Product Feature - ProductCard Component + * + * 개별 상품 카드 UI 컴포넌트 + */ + +import { ProductWithUI } from '../../pages/AdminPage'; +import { ImageIcon } from '../../shared/assets/icons/Icons'; + +interface ProductCardProps { + product: ProductWithUI; + onAddToCart: (product: ProductWithUI) => void; + formatPrice: (price: number, productId?: string) => string; + getRemainingStock: (product: ProductWithUI) => number; +} + +export const ProductCard = ({ + product, + onAddToCart, + formatPrice, + getRemainingStock +}: ProductCardProps) => { + const remainingStock = getRemainingStock(product); + const maxDiscountRate = product.discounts.length > 0 + ? Math.max(...product.discounts.map(d => d.rate)) + : 0; + + return ( +
+ {/* 상품 이미지 영역 */} +
+
+ +
+ + {/* 뱃지들 */} + {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{maxDiscountRate * 100}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

{product.description}

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

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

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

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

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

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

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

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +}; diff --git a/src/basic/widgets/Product/ProductList.tsx b/src/basic/widgets/Product/ProductList.tsx new file mode 100644 index 000000000..538a85eae --- /dev/null +++ b/src/basic/widgets/Product/ProductList.tsx @@ -0,0 +1,53 @@ +/** + * Product Feature - ProductList Component + * + * 상품 목록 표시 컴포넌트 + */ + +import { ProductWithUI } from '../../pages/AdminPage'; +import { ProductCard } from './ProductCard'; + +interface ProductListProps { + products: ProductWithUI[]; + searchTerm: string; + onAddToCart: (product: ProductWithUI) => void; + formatPrice: (price: number, productId?: string) => string; + getRemainingStock: (product: ProductWithUI) => number; +} + +export const ProductList = ({ + products, + searchTerm, + onAddToCart, + formatPrice, + getRemainingStock +}: ProductListProps) => { + return ( +
+
+

전체 상품

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

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

+
+ ) : ( +
+ {products.map(product => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/types.ts b/src/types.ts index 5489e296e..7beefac1e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,3 +22,9 @@ export interface Coupon { discountType: 'amount' | 'percentage'; discountValue: number; } + +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} diff --git a/tsconfig.app.json b/tsconfig.app.json index d739292ae..053baa742 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -23,5 +23,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/refactoring(hint)/**"] } diff --git a/vite.config.ts b/vite.config.ts index e6c4016bc..6c1b561d5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,23 @@ import { defineConfig as defineTestConfig, mergeConfig } from 'vitest/config'; import { defineConfig } from 'vite'; +import path from 'path'; +import { fileURLToPath } from 'node:url'; import react from '@vitejs/plugin-react-swc'; +const rootDir = fileURLToPath(new URL('.', import.meta.url)); + export default mergeConfig( defineConfig({ + base: '/front_7th_chapter3-2/', plugins: [react()], + build: { + rollupOptions: { + input: { + advanced: path.resolve(rootDir, 'index.advanced.html'), + basic: path.resolve(rootDir, 'index.basic.html') + } + } + } }), defineTestConfig({ test: {