diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 15a3a274..2bb94ed1 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -32,7 +32,7 @@ **상품 정렬 기능** -- [ ] 상품을 가격순/이름순으로 오름차순/내림차순 정렬을 할 수 있다. +- [ ] 상품을 가격순/인기순으로 오름차순/내림차순 정렬을 할 수 있다. - [ ] 드롭다운을 통해 정렬 기준을 선택할 수 있다 - [ ] 정렬 변경 시 즉시 목록에 반영된다 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..839ed830 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,52 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + - feature-* + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "./dist" + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/index.html b/index.html index d43ffde2..ebbf51b3 100644 --- a/index.html +++ b/index.html @@ -1,26 +1,27 @@ - - - - 상품 쇼핑몰 - - - - - -
- - + + + + 상품 쇼핑몰 + + + + + +
+
+ + diff --git a/package.json b/package.json index 5ec7f3f3..7e9d6a69 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "dev": "vite", "dev:hash": "vite --open ./index.hash.html", "build": "vite build", + "type-check": "tsc --noEmit", + "type-check:watch": "tsc --noEmit --watch", "lint:fix": "eslint --fix", "prettier:write": "prettier --write ./src", "preview": "vite preview", @@ -16,7 +18,9 @@ "test:e2e:ui": "playwright test --ui", "test:e2e:report": "npx playwright show-report", "test:generate": "playwright codegen localhost:5173", - "prepare": "husky" + "prepare": "husky", + "deploy": "pnpm run build && gh-pages -d dist", + "predeploy": "pnpm run build" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ @@ -35,12 +39,14 @@ "eslint": "^9.16.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", + "gh-pages": "^6.3.0", "globals": "^15.13.0", "husky": "^9.1.7", "jsdom": "^25.0.1", "lint-staged": "^15.2.11", "msw": "^2.10.2", "prettier": "^3.4.2", + "typescript": "^5.7.2", "vite": "npm:rolldown-vite@latest", "vitest": "latest" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8137d4c8..512aede9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: version: 14.6.1(@testing-library/dom@10.4.0) '@vitest/coverage-v8': specifier: latest - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(vitest@3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2(typescript@5.9.3))) '@vitest/ui': specifier: ^2.1.8 version: 2.1.9(vitest@3.2.4) @@ -38,6 +38,9 @@ importers: eslint-plugin-prettier: specifier: ^5.2.1 version: 5.2.3(eslint-config-prettier@9.1.0(eslint@9.23.0))(eslint@9.23.0)(prettier@3.5.3) + gh-pages: + specifier: ^6.3.0 + version: 6.3.0 globals: specifier: ^15.13.0 version: 15.15.0 @@ -52,16 +55,19 @@ importers: version: 15.5.0 msw: specifier: ^2.10.2 - version: 2.10.2 + version: 2.10.2(typescript@5.9.3) prettier: specifier: ^3.4.2 version: 3.5.3 + typescript: + specifier: ^5.7.2 + version: 5.9.3 vite: specifier: npm:rolldown-vite@latest version: rolldown-vite@6.3.21(esbuild@0.25.1)(yaml@2.7.0) vitest: specifier: latest - version: 3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2) + version: 3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2(typescript@5.9.3)) packages: @@ -559,6 +565,18 @@ packages: '@napi-rs/wasm-runtime@0.2.11': resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==} + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -898,6 +916,10 @@ 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'} @@ -905,6 +927,9 @@ packages: ast-v8-to-istanbul@0.3.3: resolution: {integrity: sha512-MuXMrSLVVoA6sYN/6Hke18vMzrT4TZNbZIj/hvh0fnYFpO+/kFXcLIaiPwXXWaQUPg4yJD8fj+lfJ7/1EBconw==} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -987,6 +1012,9 @@ packages: 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==} @@ -1049,6 +1077,10 @@ packages: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -1062,6 +1094,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + email-addresses@5.0.0: + resolution: {integrity: sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==} + emoji-regex@10.4.0: resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} @@ -1112,6 +1147,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1198,12 +1237,19 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fdir@6.4.3: resolution: {integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==} peerDependencies: @@ -1227,10 +1273,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'} @@ -1250,6 +1312,10 @@ packages: resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} engines: {node: '>= 6'} + fs-extra@11.3.2: + resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} + engines: {node: '>=14.14'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1283,6 +1349,15 @@ packages: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} + 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'} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1299,10 +1374,17 @@ packages: resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} engines: {node: '>=18'} + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql@16.11.0: resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -1454,6 +1536,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==} @@ -1538,6 +1623,10 @@ packages: resolution: {integrity: sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==} engines: {node: '>=18.0.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'} @@ -1571,6 +1660,10 @@ packages: magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -1582,6 +1675,10 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -1668,14 +1765,26 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + 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'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -1705,6 +1814,10 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -1731,6 +1844,10 @@ packages: engines: {node: '>=0.10'} hasBin: true + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + playwright-core@1.53.2: resolution: {integrity: sha512-ox/OytMy+2w1jcYEYlOo1Hhp8hZkLCximMTUTMBXjGUA1KoFfiSZ+DU+3a739jsPY0yoKH2TFy9S2fsJas8yAw==} engines: {node: '>=18'} @@ -1772,6 +1889,9 @@ packages: querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -1797,6 +1917,10 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -1855,6 +1979,9 @@ packages: rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1862,6 +1989,10 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -1886,6 +2017,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'} + slice-ansi@5.0.0: resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} engines: {node: '>=12'} @@ -1950,6 +2085,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@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2022,6 +2161,10 @@ packages: resolution: {integrity: sha512-IUWnUK7ADYR5Sl1fZlO1INDUhVhatWl7BtJWsIhwJ0UAK7ilzzIa8uIqOO/aYVWHZPJkKbEL+362wrzoeRF7bw==} engines: {node: '>=18'} + trim-repeated@1.0.0: + resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} + engines: {node: '>=0.10.0'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2037,10 +2180,19 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + universalify@0.2.0: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} + 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==} @@ -2560,6 +2712,18 @@ snapshots: '@tybys/wasm-util': 0.9.0 optional: true + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': @@ -2729,7 +2893,7 @@ snapshots: '@types/tough-cookie@4.0.5': {} - '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2(typescript@5.9.3)))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -2744,7 +2908,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2) + vitest: 3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2(typescript@5.9.3)) transitivePeerDependencies: - supports-color @@ -2756,13 +2920,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(msw@2.10.2)(vite@5.4.14(lightningcss@1.30.1))': + '@vitest/mocker@3.2.4(msw@2.10.2(typescript@5.9.3))(vite@5.4.14(lightningcss@1.30.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - msw: 2.10.2 + msw: 2.10.2(typescript@5.9.3) vite: 5.4.14(lightningcss@1.30.1) '@vitest/pretty-format@2.1.9': @@ -2798,7 +2962,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.12 tinyrainbow: 1.2.0 - vitest: 3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2) + vitest: 3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2(typescript@5.9.3)) '@vitest/utils@2.1.9': dependencies: @@ -2857,6 +3021,8 @@ snapshots: aria-query@5.3.2: {} + array-union@2.1.0: {} + assertion-error@2.0.1: {} ast-v8-to-istanbul@0.3.3: @@ -2865,6 +3031,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 9.0.1 + async@3.2.6: {} + asynckit@0.4.0: {} balanced-match@1.0.2: {} @@ -2944,6 +3112,8 @@ snapshots: commander@13.1.0: {} + commondir@1.0.1: {} + concat-map@0.0.1: {} cookie@0.7.2: {} @@ -2986,6 +3156,10 @@ snapshots: detect-libc@2.0.4: {} + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -2998,6 +3172,8 @@ snapshots: eastasianwidth@0.2.0: {} + email-addresses@5.0.0: {} + emoji-regex@10.4.0: {} emoji-regex@8.0.0: {} @@ -3082,6 +3258,8 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@1.0.5: {} + escape-string-regexp@4.0.0: {} eslint-config-prettier@9.1.0(eslint@9.23.0): @@ -3188,10 +3366,22 @@ snapshots: fast-diff@1.3.0: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + fdir@6.4.3(picomatch@4.0.2): optionalDependencies: picomatch: 4.0.2 @@ -3206,10 +3396,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 @@ -3234,6 +3443,12 @@ snapshots: es-set-tostringtag: 2.1.0 mime-types: 2.1.35 + fs-extra@11.3.2: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fsevents@2.3.2: optional: true @@ -3266,6 +3481,20 @@ snapshots: get-stream@8.0.1: {} + 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 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -3283,8 +3512,19 @@ snapshots: globals@15.15.0: {} + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + graphql@16.11.0: {} has-flag@4.0.0: {} @@ -3433,6 +3673,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 @@ -3513,6 +3759,10 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -3547,6 +3797,10 @@ snapshots: '@babel/types': 7.28.0 source-map-js: 1.2.1 + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + make-dir@4.0.0: dependencies: semver: 7.7.2 @@ -3555,6 +3809,8 @@ snapshots: merge-stream@2.0.0: {} + merge2@1.4.1: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -3586,7 +3842,7 @@ snapshots: ms@2.1.3: {} - msw@2.10.2: + msw@2.10.2(typescript@5.9.3): dependencies: '@bundled-es-modules/cookie': 2.0.1 '@bundled-es-modules/statuses': 1.0.1 @@ -3606,6 +3862,8 @@ snapshots: strict-event-emitter: 0.5.1 type-fest: 4.41.0 yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - '@types/node' @@ -3640,14 +3898,24 @@ snapshots: outvariant@1.4.3: {} + 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: {} + package-json-from-dist@1.0.1: {} parent-module@1.0.1: @@ -3671,6 +3939,8 @@ snapshots: path-to-regexp@6.3.0: {} + path-type@4.0.0: {} + pathe@1.1.2: {} pathe@2.0.3: {} @@ -3685,6 +3955,10 @@ snapshots: pidtree@0.6.0: {} + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + playwright-core@1.53.2: {} playwright@1.53.2: @@ -3721,6 +3995,8 @@ snapshots: querystringify@2.2.0: {} + queue-microtask@1.2.3: {} + react-is@17.0.2: {} redent@3.0.0: @@ -3741,6 +4017,8 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 + reusify@1.1.0: {} + rfdc@1.4.1: {} rolldown-vite@6.3.21(esbuild@0.25.1)(yaml@2.7.0): @@ -3806,12 +4084,18 @@ snapshots: rrweb-cssom@0.8.0: {} + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + safer-buffer@2.1.2: {} saxes@6.0.0: dependencies: xmlchars: 2.2.0 + semver@6.3.1: {} + semver@7.7.2: {} shebang-command@2.0.0: @@ -3830,6 +4114,8 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + slash@3.0.0: {} + slice-ansi@5.0.0: dependencies: ansi-styles: 6.2.1 @@ -3890,6 +4176,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + strip-outer@1.0.1: + dependencies: + escape-string-regexp: 1.0.5 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3956,6 +4246,10 @@ snapshots: dependencies: punycode: 2.3.1 + trim-repeated@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + tslib@2.8.1: {} type-check@0.4.0: @@ -3966,8 +4260,12 @@ snapshots: type-fest@4.41.0: {} + typescript@5.9.3: {} + universalify@0.2.0: {} + universalify@2.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -4004,11 +4302,11 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.30.1 - vitest@3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2): + vitest@3.2.4(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.2(typescript@5.9.3)): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.10.2)(vite@5.4.14(lightningcss@1.30.1)) + '@vitest/mocker': 3.2.4(msw@2.10.2(typescript@5.9.3))(vite@5.4.14(lightningcss@1.30.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js index d2b72964..b1f186b6 100644 --- a/public/mockServiceWorker.js +++ b/public/mockServiceWorker.js @@ -100,10 +100,7 @@ addEventListener("fetch", function (event) { // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. - if ( - event.request.cache === "only-if-cached" && - event.request.mode !== "same-origin" - ) { + if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") { return; } @@ -219,9 +216,7 @@ async function getResponse(event, client, requestId) { const acceptHeader = headers.get("accept"); if (acceptHeader) { const values = acceptHeader.split(",").map((value) => value.trim()); - const filteredValues = values.filter( - (value) => value !== "msw/passthrough", - ); + const filteredValues = values.filter((value) => value !== "msw/passthrough"); if (filteredValues.length > 0) { headers.set("accept", filteredValues.join(", ")); @@ -291,10 +286,7 @@ function sendToClient(client, message, transferrables = []) { resolve(event.data); }; - client.postMessage(message, [ - channel.port2, - ...transferrables.filter(Boolean), - ]); + client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]); }); } diff --git a/requirement.md b/requirement.md index d450ef17..796709ef 100644 --- a/requirement.md +++ b/requirement.md @@ -20,6 +20,197 @@ ### 상품 정렬 기능 +- 상품을 가격순/인기순으로 오름차순/내림차순 정렬을 할 수 있다. +- 드롭다운을 통해 정렬 기준을 선택할 수 있다 +- 정렬 변경 시 즉시 목록에 반영된다 + +### 무한 스크롤 페이지네이션 + +- 페이지 하단 근처 도달 시 다음 페이지 데이터가 자동 로드된다 +- 스크롤에 따라 계속해서 새로운 상품들이 목록에 추가된다 +- 새 데이터 로드 중일 때 로딩 인디케이터와 스켈레톤 UI가 표시된다 +- 홈 페이지에서만 무한 스크롤이 활성화된다 + +### 상품을 장바구니에 담기 + +- 각 상품에 장바구니 추가 버튼이 있다 +- 버튼 클릭 시 해당 상품이 장바구니에 추가된다 +- 추가 완료 시 사용자에게 알림이 표시된다 + +### 상품 검색 + +- 상품명 기반 검색을 위한 텍스트 입력 필드가 있다 +- 검색 버튼 클릭으로 검색이 수행된다 +- Enter 키로 검색이 수행된다 +- 검색어와 일치하는 상품들만 목록에 표시된다 + +### 카테고리 선택 + +- 사용 가능한 카테고리들을 선택할 수 있는 UI가 제공된다 +- 선택된 카테고리에 해당하는 상품들만 표시된다 +- 전체 상품 보기로 돌아갈 수 있다 +- 2단계 카테고리 구조를 지원한다 (1depth, 2depth) + +### 카테고리 네비게이션 + +- 현재 선택된 카테고리 경로가 브레드크럼으로 표시된다 +- 브레드크럼의 각 단계를 클릭하여 상위 카테고리로 이동할 수 있다 +- "전체" > "1depth 카테고리" > "2depth 카테고리" 형태로 표시된다 + +### 현재 상품 수 표시 + +- 현재 조건에서 조회된 총 상품 수가 화면에 표시된다 +- 검색이나 필터 적용 시 상품 수가 실시간으로 업데이트된다 + +## 장바구니 + +### 장바구니 모달 + +- 장바구니 아이콘 클릭 시 모달 형태로 장바구니가 열린다 +- X 버튼이나 배경 클릭으로 모달을 닫을 수 있다 +- ESC 키로 모달을 닫을 수 있다 +- 모달에서 장바구니의 모든 기능을 사용할 수 있다 + +### 장바구니 수량 조절 + +- 각 장바구니 상품의 수량을 증가할 수 있다 +- 각 장바구니 상품의 수량을 감소할 수 있다 +- 수량 변경 시 총 금액이 실시간으로 업데이트된다 + +### 장바구니 삭제 + +- 각 상품에 삭제 버튼이 배치되어 있다 +- 삭제 버튼 클릭 시 해당 상품이 장바구니에서 제거된다 + +### 장바구니 선택 삭제 + +- 각 상품에 선택을 위한 체크박스가 제공된다 +- 선택 삭제 버튼이 있다 +- 체크된 상품들만 일괄 삭제된다 + +### 장바구니 전체 선택 + +- 모든 상품을 한 번에 선택할 수 있는 마스터 체크박스가 있다 +- 전체 선택 시 모든 상품의 체크박스가 선택된다 +- 전체 해제 시 모든 상품의 체크박스가 해제된다 + +### 장바구니 비우기 + +- 장바구니에 있는 모든 상품을 한 번에 삭제할 수 있다 + +## 상품 상세 + +### 상품 클릭시 상세 페이지 이동 + +- 상품 목록에서 상품 이미지나 상품 정보 클릭 시 상세 페이지로 이동한다 +- URL이 `/product/{productId}` 형태로 변경된다 +- 상품의 자세한 정보가 전용 페이지에서 표시된다 + +### 상품 상세 페이지 기능 + +- 상품 이미지, 설명, 가격 등의 상세 정보가 표시된다 +- 전체 화면을 활용한 상세 정보 레이아웃이 제공된다 + +### 상품 상세 - 장바구니 담기 + +- 상품 상세 페이지에서 해당 상품을 장바구니에 추가할 수 있다 +- 페이지 내에서 수량을 선택하여 장바구니에 추가할 수 있다 +- 수량 증가/감소 버튼이 제공된다 + +### 관련 상품 기능 + +- 상품 상세 페이지에서 관련 상품들이 표시된다 +- 같은 카테고리(category2)의 다른 상품들이 관련 상품으로 표시된다 +- 관련 상품 클릭 시 해당 상품의 상세 페이지로 이동한다 +- 현재 보고 있는 상품은 관련 상품에서 제외된다 + +### 상품 상세 페이지 내 네비게이션 + +- 상품 상세에서 상품 목록으로 돌아가는 버튼이 제공된다 +- 브레드크럼을 통해 카테고리별 상품 목록으로 이동할 수 있다 +- SPA 방식으로 페이지 간 이동이 부드럽게 처리된다 + +## 사용자 피드백 시스템 + +### 토스트 메시지 + +- 장바구니 추가 시 성공 메시지가 토스트로 표시된다 +- 장바구니 삭제, 선택 삭제, 전체 삭제 시 알림 메시지가 표시된다 +- 토스트는 3초 후 자동으로 사라진다 +- 토스트에 닫기 버튼이 제공된다 +- 토스트 타입별로 다른 스타일이 적용된다 (success, info, error) + +### 에러 처리 + +- 네트워크 오류 등 에러 발생 시 사용자에게 적절한 메시지가 표시된다 +- 에러 상황에서 재시도할 수 있는 버튼이 제공된다 +- 에러 상태가 UI에 적절히 반영된다 + +## SPA 네비게이션 및 URL 관리 + +### 페이지 이동 + +- 어플리케이션 내의 모든 페이지 이동(뒤로가기/앞으로가기를 포함)은 하여 새로고침이 발생하지 않아야 한다. + +### 상품 목록 - URL 쿼리 반영 + +- 검색어가 URL 쿼리 파라미터에 저장된다 +- 카테고리 선택이 URL 쿼리 파라미터에 저장된다 +- 상품 옵션이 URL 쿼리 파라미터에 저장된다 +- 정렬 조건이 URL 쿼리 파라미터에 저장된다 +- 조건 변경 시 URL이 자동으로 업데이트된다 +- URL을 통해 현재 검색/필터 상태를 공유할 수 있다 + +### 상품 목록 - 새로고침 시 상태 유지 + +- 새로고침 후 URL 쿼리에서 검색어가 복원된다 +- 새로고침 후 URL 쿼리에서 카테고리가 복원된다 +- 새로고침 후 URL 쿼리에서 옵션 설정이 복원된다 +- 새로고침 후 URL 쿼리에서 정렬 조건이 복원된다 +- 복원된 조건에 맞는 상품 데이터가 다시 로드된다 + +### 장바구니 - 새로고침 시 데이터 유지 + +- 장바구니 내용이 브라우저에 저장된다 +- 새로고침 후에도 이전 장바구니 내용이 유지된다 +- 장바구니의 선택 상태도 함께 유지된다 + +### 상품 상세 - URL에 ID 반영 + +- 상품 상세 페이지 이동 시 상품 ID가 URL 경로에 포함된다 (`/product/{productId}`) +- URL로 직접 접근 시 해당 상품의 상세 페이지가 자동으로 로드된다 + +### 상품 상세 - 새로고침시 유지 + +- 새로고침 후에도 URL의 상품 ID를 읽어서 해당 상품 상세 페이지가 유지된다 + +### 404 페이지 + +- 존재하지 않는 경로 접근 시 404 에러 페이지가 표시된다 +- 홈으로 돌아가기 버튼이 제공된다 + +# 요구사항 명세서 + +## 상품목록 + +### 상품 목록 로딩 + +- 페이지 접속 시 로딩 상태가 표시된다 +- 데이터 로드 완료 후 상품 목록이 렌더링된다 +- 로딩 실패 시 에러 상태가 표시된다 +- 에러 발생 시 재시도 버튼이 제공된다 + +### 상품 목록 조회 + +- 각 상품의 기본 정보(이미지, 상품명, 가격)가 카드 형태로 표시된다 + +### 한 페이지에 보여질 상품 수 선택 + +- 드롭다운에서 10, 20, 50, 100개 중 선택할 수 있으며 기본 값은 20개 이다. +- 선택 변경 시 즉시 목록에 반영된다 + +### 상품 정렬 기능 + - 상품을 가격순/이름순으로 오름차순/내림차순 정렬을 할 수 있다. - 드롭다운을 통해 정렬 기준을 선택할 수 있다 - 정렬 변경 시 즉시 목록에 반영된다 diff --git a/src/api/productApi.js b/src/api/productApi.js index bbdea046..d7305f9d 100644 --- a/src/api/productApi.js +++ b/src/api/productApi.js @@ -1,3 +1,7 @@ +import { ProductListResponseDTO } from "../dto/ProductListDTO"; +import { ProductDTO } from "../dto/ProductDTO"; +import { CategoryDTO } from "../dto/CategoryDTO"; + // 상품 목록 조회 export async function getProducts(params = {}) { const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; @@ -14,17 +18,20 @@ export async function getProducts(params = {}) { const response = await fetch(`/api/products?${searchParams}`); - return await response.json(); + const data = await response.json(); + return ProductListResponseDTO.fromApi(data); } // 상품 상세 조회 export async function getProduct(productId) { const response = await fetch(`/api/products/${productId}`); - return await response.json(); + const data = await response.json(); + return ProductDTO.fromApi(data); } // 카테고리 목록 조회 export async function getCategories() { const response = await fetch("/api/categories"); - return await response.json(); + const data = await response.json(); + return CategoryDTO.fromApi(data); } diff --git a/src/components/CartItem.js b/src/components/CartItem.js new file mode 100644 index 00000000..5b7b79a8 --- /dev/null +++ b/src/components/CartItem.js @@ -0,0 +1,113 @@ +import createComponent from "../core/component/create-component.js"; +import { formatNumber } from "../utils/formatter.js"; + +const CartItem = createComponent({ + id: "cart-item", + props: { + item: {}, + handleIncreaseQuantity: () => {}, + handleDecreaseQuantity: () => {}, + handleRemoveFromCart: () => {}, + handleSelectCartItem: () => {}, + }, + eventHandlers: { + "quantity-decrease": (props) => { + props.handleDecreaseQuantity(props.item.id); + }, + "quantity-increase": (props) => { + props.handleIncreaseQuantity(props.item.id); + }, + "remove-from-cart": (props) => { + props.handleRemoveFromCart(props.item.id); + }, + "select-cart-item": (props) => { + props.handleSelectCartItem(props.item.id); + }, + }, + templateFn: ({ item }) => { + return /* HTML */ ` +
+ + + +
+ ${item.title} +
+ +
+

+ ${item.title} +

+

${formatNumber(item.price)}원

+ +
+ + + +
+
+ +
+

${formatNumber(item.price * item.count)}원

+ +
+
+ `; + }, +}); + +export default CartItem; diff --git a/src/components/CartModal.js b/src/components/CartModal.js new file mode 100644 index 00000000..fe897d72 --- /dev/null +++ b/src/components/CartModal.js @@ -0,0 +1,293 @@ +import { CartViewModel } from "../view-models/CartViewModel"; +import createComponent from "../core/component/create-component.js"; +import CartItem from "./CartItem.js"; +import appStore from "../store/app-store.js"; +import { showToastMessage } from "../utils/toast-utils.js"; +import { TOAST_MESSAGE_MAP } from "../constants/toast-constant.js"; +import { formatNumber } from "../utils/formatter.js"; +/** + * @typedef {import('../types.js').CartModalProps} CartModalProps + * @typedef {import('../types.js').CartItem} CartItem + */ + +const CartModal = createComponent({ + id: "cart-modal", + props: { + onClose: () => {}, + }, + initialState: () => ({ + cart: appStore.getState().cart, + allSelected: appStore.getState().allSelected, + }), + eventHandlers: { + "cart-modal-close": (props) => { + props.onClose(); + }, + "cart-modal-overlay-click": (props) => { + props.onClose(); + }, + "remove-selected-cart-items": (props, getter, setter) => { + setter("cart", (currentCart) => currentCart.filter((_item) => !_item.isSelected)); + showToastMessage(TOAST_MESSAGE_MAP.REMOVE_SELECTED_CART_ITEMS, "info"); + appStore.setCart(getter("cart").filter((_item) => !_item.isSelected)); + }, + "clear-cart": (props, getter, setter) => { + setter("cart", []); + showToastMessage(TOAST_MESSAGE_MAP.REMOVE_SELECTED_CART_ITEMS, "info"); + appStore.removeAllCartItems(); + }, + "select-all-cart-items": (props, getter, setter) => { + const currentAllSelected = getter("allSelected"); + const currentCart = getter("cart"); + setter("allSelected", !currentAllSelected); + setter("cart", (_currentCart) => _currentCart.map((_item) => ({ ..._item, isSelected: !currentAllSelected }))); + appStore.setCart(currentCart.map((_item) => ({ ..._item, isSelected: !currentAllSelected }))); + appStore.setAllSelected(!currentAllSelected); + }, + }, + effects: { + onMount: ({ props }) => { + const handleKeyDownEscape = (event) => { + if (event.key === "Escape") { + props.onClose(); + } + }; + window.addEventListener("keydown", handleKeyDownEscape); + return () => window.removeEventListener("keydown", handleKeyDownEscape); + }, + }, + templateFn: (_, { cart, allSelected }, setState) => { + const cartViewModel = new CartViewModel(cart); + const totalPrice = cart.reduce((acc, _item) => acc + _item.price * _item.count, 0); + const selectedCart = cart.filter((_item) => _item.isSelected); + const selectedCartTotalPrice = selectedCart.reduce((acc, _item) => acc + _item.price * _item.count, 0); + + const handleIncreaseQuantity = (productId) => { + setState("cart", (currentCart) => + currentCart.map((_item) => + _item.id === productId ? { ..._item, count: Math.min(_item.count + 1, 999) } : _item, + ), + ); + appStore.setCart( + cart.map((_item) => (_item.id === productId ? { ..._item, count: Math.min(_item.count + 1, 999) } : _item)), + ); + }; + + const handleDecreaseQuantity = (productId) => { + setState("cart", (currentCart) => + currentCart.map((_item) => + _item.id === productId ? { ..._item, count: Math.max(_item.count - 1, 1) } : _item, + ), + ); + appStore.setCart( + cart.map((_item) => (_item.id === productId ? { ..._item, count: Math.max(_item.count - 1, 1) } : _item)), + ); + }; + + const handleRemoveFromCart = (productId) => { + setState("cart", (currentCart) => currentCart.filter((_item) => _item.id !== productId)); + appStore.setCart(cart.filter((_item) => _item.id !== productId)); + }; + + const handleSelectCartItem = (productId) => { + setState("cart", (currentCart) => + currentCart.map((_item) => (_item.id === productId ? { ..._item, isSelected: !_item.isSelected } : _item)), + ); + appStore.setCart( + cart.map((_item) => (_item.id === productId ? { ..._item, isSelected: !_item.isSelected } : _item)), + ); + + const changedCart = cart.map((_item) => + _item.id === productId ? { ..._item, isSelected: !_item.isSelected } : _item, + ); + if (changedCart.every((_item) => _item.isSelected)) { + setState("allSelected", true); + appStore.setAllSelected(true); + } else { + setState("allSelected", false); + appStore.setAllSelected(false); + } + }; + + return /* HTML */ ` +
+ +
+ +
+
+ +
+

+ + + + 장바구니 + ${cartViewModel.getTotalCount() > 0 + ? /* HTML */ `(${cartViewModel.getTotalCount()})` + : ""} +

+ +
+ +
+ + ${cart.length > 0 + ? /* HTML */ ` +
+ +
+ ` + : ""} + + ${cart.length > 0 + ? /* HTML */ ` +
+
+ ${cart + .map( + (/** @type {CartItem} */ item) => + CartItem.mount({ + key: item.id, + item, + handleIncreaseQuantity: (productId) => { + handleIncreaseQuantity(productId); + }, + handleDecreaseQuantity: (productId) => { + handleDecreaseQuantity(productId); + }, + handleRemoveFromCart: (productId) => { + handleRemoveFromCart(productId); + showToastMessage(TOAST_MESSAGE_MAP.REMOVE_SELECTED_CART_ITEMS, "info"); + }, + handleSelectCartItem: (productId) => { + handleSelectCartItem(productId); + }, + }).outerHTML, + ) + .join("")} +
+
+ ` + : ""} + + ${cart.length > 0 + ? /* HTML */ ` +
+ + ${selectedCart.length > 0 + ? /* HTML */ ` +
+ 선택한 상품 (${selectedCart.length}개) + ${formatNumber(selectedCartTotalPrice)}원 +
+ ` + : ""} + +
+ 총 금액 + ${formatNumber(totalPrice)}원 +
+ +
+ ${selectedCart.length > 0 + ? /* HTML */ ` + + ` + : ""} +
+ + +
+
+
+ ` + : ""} + + ${cart.length === 0 + ? /* HTML */ ` +
+
+
+ + + +
+

장바구니가 비어있습니다

+

원하는 상품을 담아보세요!

+
+
+ ` + : ""} +
+
+
+
+ `; + }, +}); + +export default CartModal; diff --git a/src/components/CategoryFilter.js b/src/components/CategoryFilter.js new file mode 100644 index 00000000..06ecbf74 --- /dev/null +++ b/src/components/CategoryFilter.js @@ -0,0 +1,113 @@ +// @ts-check +import createComponent from "../core/component/create-component"; +import { CategoryViewModel } from "../view-models/CategoryViewModel"; + +const CategoryFilter = createComponent({ + id: "category-filter", + props: { + categories: [], + selectedCategory1: "", + selectedCategory2: "", + handleSetSelectedCategory1: () => {}, + handleSetSelectedCategory2: () => {}, + }, + eventHandlers: { + "category1-filter": (props, getter, setter, event) => { + if (!event.target) return; + const value = event.target.dataset.category1; + props.handleSetSelectedCategory1(value); + if (props.selectedCategory2) { + props.handleSetSelectedCategory2(""); + } + }, + "category2-filter": (props, getter, setter, event) => { + if (!event.target) return; + const value = event.target.dataset.category2; + props.handleSetSelectedCategory2(value); + }, + "category-reset": (props, getter, setter, event) => { + if (!event.target) return; + props.handleSetSelectedCategory1(""); + props.handleSetSelectedCategory2(""); + }, + }, + templateFn: (props) => { + const viewModel = new CategoryViewModel(props.categories, props.selectedCategory1, props.selectedCategory2); + const firstDepthOptions = viewModel.getFirstDepthOptions(); + const secondDepthOptions = viewModel.getSecondDepthOptions(); + + return /* HTML */ ` +
+
+ + + ${props.selectedCategory1 && + /* HTML */ ` + > + + `} + ${props.selectedCategory2 && + /* HTML */ ` + > + ${props.selectedCategory2} + `} +
+ +
+ ${(!props.selectedCategory2 && !props.selectedCategory1 + ? firstDepthOptions.map( + (option) => /* HTML */ ` + + `, + ) + : secondDepthOptions.map( + (option) => /* HTML */ ` + + `, + ) + ).join("\n")} +
+
+ `; + }, +}); + +export default CategoryFilter; diff --git a/src/components/FilterToolbox.js b/src/components/FilterToolbox.js new file mode 100644 index 00000000..0fa8ba3c --- /dev/null +++ b/src/components/FilterToolbox.js @@ -0,0 +1,119 @@ +import createComponent from "../core/component/create-component"; +import { LIMIT_OPTIONS } from "../constants/filter-constant"; +import { SORT_OPTIONS } from "../constants/filter-constant"; +import CategoryFilter from "./CategoryFilter"; + +const FilterToolbox = createComponent({ + id: "filter-toolbox", + props: { + categories: [], + limit: 20, + sort: "price_asc", + search: "", + selectedCategory1: "", + selectedCategory2: "", + handleSetSort: () => {}, + handleSetLimit: () => {}, + handleSetSearch: () => {}, + handleSetSelectedCategory1: () => {}, + handleSetSelectedCategory2: () => {}, + }, + eventHandlers: { + "search-input": (props, getter, setter, event) => { + if (!event.target) return; + if (event.key === "Enter") { + const value = event.target.value; + props.handleSetSearch(value); + } + }, + "sort-select": (props, getter, setter, event) => { + const value = event.target.value; + props.handleSetSort(value); + }, + "limit-select": (props, getter, setter, event) => { + const value = parseInt(event.target.value); + props.handleSetLimit(value); + }, + }, + templateFn: (props) => { + return /* HTML */ ` +
+ +
+
+ +
+ + + +
+
+
+ +
+ + ${CategoryFilter.mount({ + categories: props.categories, + selectedCategory1: props.selectedCategory1, + selectedCategory2: props.selectedCategory2, + handleSetSelectedCategory1: props.handleSetSelectedCategory1, + handleSetSelectedCategory2: props.handleSetSelectedCategory2, + }).outerHTML} + +
+ +
+ + +
+ +
+ + +
+
+
+
+ `; + }, +}); + +export default FilterToolbox; diff --git a/src/components/Footer.js b/src/components/Footer.js new file mode 100644 index 00000000..647ac83a --- /dev/null +++ b/src/components/Footer.js @@ -0,0 +1,16 @@ +import createComponent from "../core/component/create-component"; + +const Footer = createComponent({ + id: "footer", + templateFn: () => { + return /* HTML */ ` + + `; + }, +}); + +export default Footer; diff --git a/src/components/Header.js b/src/components/Header.js new file mode 100644 index 00000000..a5354987 --- /dev/null +++ b/src/components/Header.js @@ -0,0 +1,107 @@ +import createComponent from "../core/component/create-component"; +import CartModal from "./CartModal"; +import Router from "../core/router/index.js"; +import appStore from "../store/app-store.js"; +const Header = createComponent({ + id: "header", + props: { isDetailPage: false }, + initialState: () => ({ + cart: appStore.getState().cart, + }), + eventHandlers: { + "open-cart-modal": (props, getter, setter, event) => { + if (!event.target) return; + // const $cartModalRoot = document.querySelector("#cart-modal-root"); + // if (!$cartModalRoot) return; + const $root = document.querySelector("#root"); + $root?.appendChild( + CartModal.mount({ + onClose: () => { + const modal = document.querySelector(".cart-modal"); + if (modal) { + modal.remove(); + } + }, + }), + ); + }, + "navigate-back": (props, getter, setter, event) => { + if (!event.target) return; + Router.goBack(); + }, + }, + effects: { + onMount: ({ setState }) => { + const unsubscribe = appStore.subscribe((state) => { + setState("cart", state.cart); + }); + return unsubscribe; + }, + }, + templateFn: ({ isDetailPage }, { cart }) => { + const cartItemCount = cart.reduce((acc, item) => acc + item.count, 0); + return /* HTML */ ` +
+
+
+ ${isDetailPage + ? /* HTML */ ` +
+ +

상품 상세

+
+ ` + : /* HTML */ ` +

+ 쇼핑몰 +

+ `} +
+ + +
+
+
+
+ `; + }, +}); + +export default Header; diff --git a/src/components/ProductCard.js b/src/components/ProductCard.js new file mode 100644 index 00000000..9cb17c12 --- /dev/null +++ b/src/components/ProductCard.js @@ -0,0 +1,79 @@ +// @ts-check + +import { formatNumber } from "../utils/formatter.js"; +import createComponent from "../core/component/create-component.js"; +import Router from "../core/router/index.js"; +import appStore from "../store/app-store.js"; +import { showToastMessage } from "../utils/toast-utils.js"; +import { TOAST_MESSAGE_MAP } from "../constants/toast-constant.js"; +const ProductCard = createComponent({ + id: "product-card", + props: { + productId: "", + image: "", + title: "", + brand: "", + lprice: 0, + }, + eventHandlers: { + "navigate-to-detail": (props, getter, setter, event) => { + if (!event.target) return; + Router.push(`/product/${props.productId}`); + }, + "add-to-cart": (props, getter, setter, event) => { + if (!event.target) return; + const productId = event.target.dataset.productId; + if (!productId) return; + appStore.addToCart({ + id: productId, + title: props.title, + image: props.image, + price: props.lprice, + selected: false, + }); + showToastMessage(TOAST_MESSAGE_MAP.ADD_TO_CART, "success"); + }, + }, + templateFn: ({ productId, image, title, brand, lprice }) => { + return /* HTML */ ` +
+ +
+ ${title} +
+ +
+
+

${title}

+

${brand}

+

${formatNumber(lprice)}원

+
+ + +
+
+ `; + }, +}); + +export default ProductCard; diff --git a/src/components/ProductDetail.js b/src/components/ProductDetail.js new file mode 100644 index 00000000..a5ae9de8 --- /dev/null +++ b/src/components/ProductDetail.js @@ -0,0 +1,193 @@ +import { formatNumber } from "../utils/formatter.js"; +import createComponent from "../core/component/create-component"; +import ProductDetailCounter from "./ProductDetailCounter.js"; +import Router from "../core/router/index.js"; + +const ProductDetail = createComponent({ + id: "product-detail", + props: { productDetailResponse: null, productDetailListResponse: null }, + eventHandlers: { + "navigate-to-home": (props, getter, setter, event) => { + if (!event.target) return; + Router.push("/"); + }, + "navigate-to-detail": (props, getter, setter, event) => { + if (!event.target) return; + const productId = event.target.closest("[data-link]")?.dataset.productId; + if (!productId) return; + Router.push(`/product/${productId}`); + }, + "navigate-to-category": (props, getter, setter, event) => { + if (!event.target) return; + const category1 = event.target.closest("[data-link]")?.dataset.category1; + console.log("[ProductDetail] category1", category1); + if (!category1) return; + Router.push(`/?category1=${category1}`); + }, + }, + templateFn: ({ productDetailResponse, productDetailListResponse }) => { + const categoryPath = [ + productDetailResponse?.category1, + productDetailResponse?.category2, + productDetailResponse?.category3, + productDetailResponse?.category4, + ].filter(Boolean); + console.log("[ProductDetail] categoryPath", categoryPath); + const { + productId = "", + brand = "", + image = "", + title = "", + description = "", + lprice = 0, + stock = 0, + rating = 0, + reviewCount = 0, + } = productDetailResponse || {}; + + const relatedProducts = productDetailListResponse?.products?.filter((product) => product.productId !== productId); + + return /* HTML */ ` +
+ + + +
+ +
+
+ ${title} +
+ +
+

${brand}

+

${title}

+ +
+
+ + + + + + + + + + + + + + + +
+ ${rating}.0 (${reviewCount}개 리뷰) +
+ +
+ ${formatNumber(lprice)}원 +
+ +
재고 ${stock}개
+ +
${description}
+
+
+ + ${ProductDetailCounter.mount({ productDetail: productDetailResponse }).outerHTML} +
+ +
+ +
+ +
+
+

관련 상품

+

같은 카테고리의 다른 상품들

+
+
+
+ ${(relatedProducts || []) + .map( + (product) => /* HTML */ ` + + `, + ) + .join("")} +
+
+
+
+ `; + }, +}); + +export default ProductDetail; diff --git a/src/components/ProductDetailCounter.js b/src/components/ProductDetailCounter.js new file mode 100644 index 00000000..f272d771 --- /dev/null +++ b/src/components/ProductDetailCounter.js @@ -0,0 +1,96 @@ +import createComponent from "../core/component/create-component"; +import appStore from "../store/app-store"; +import { showToastMessage } from "../utils/toast-utils"; +import { TOAST_MESSAGE_MAP } from "../constants/toast-constant"; +const ProductDetailCounter = createComponent({ + id: "product-detail-counter", + props: { + productDetail: null, + }, + initialState: () => ({ + count: 1, + }), + eventHandlers: { + "quantity-decrease": (props, getter, setter) => { + if (getter("count") <= 1) return; + setter("count", (/** @type {number} */ prev) => prev - 1); + }, + "quantity-increase": (props, getter, setter) => { + if (getter("count") >= 107) return; + setter("count", (/** @type {number} */ prev) => prev + 1); + }, + "add-to-cart": (props, getter, setter, event) => { + console.log("[ProductDetail] add-to-cart", props); + const productId = event.target.closest("[data-product-id]")?.dataset.productId; + if (!productId) return; + const count = getter("count"); + appStore.addToCart( + { + id: props.productDetail.productId, + title: props.productDetail.title, + image: props.productDetail.image, + price: props.productDetail.lprice, + selected: false, + }, + count, + ); + showToastMessage(TOAST_MESSAGE_MAP.ADD_TO_CART, "success"); + }, + }, + templateFn: ({ productDetail }, { count }) => { + const { productId } = productDetail || {}; + return /* HTML */ ` +
+
+ 수량 +
+ + + +
+
+ + +
+ `; + }, +}); + +export default ProductDetailCounter; diff --git a/src/components/ProductList.js b/src/components/ProductList.js new file mode 100644 index 00000000..efb6fe23 --- /dev/null +++ b/src/components/ProductList.js @@ -0,0 +1,94 @@ +import ProductCard from "./ProductCard"; +import createComponent from "../core/component/create-component"; +import FilterToolbox from "./FilterToolbox"; + +const ProductList = createComponent({ + id: "product-list", + props: { + products: [], + pagination: { + page: 1, + limit: 20, + total: 0, + totalPages: 0, + hasNext: true, + hasPrev: false, + }, + filters: { + search: "", + category1: "", + category2: "", + sort: "price_asc", + }, + categories: [], + handleSetSort: () => {}, + handleSetLimit: () => {}, + handleSetSearch: () => {}, + handleSetSelectedCategory1: () => {}, + handleSetSelectedCategory2: () => {}, + }, + templateFn: (props) => { + const handleSetSort = (value) => { + props.handleSetSort(value); + }; + const handleSetLimit = (value) => { + props.handleSetLimit(value); + }; + const handleSetSearch = (value) => { + props.handleSetSearch(value); + }; + const handleSetSelectedCategory1 = (value) => { + props.handleSetSelectedCategory1(value); + }; + const handleSetSelectedCategory2 = (value) => { + props.handleSetSelectedCategory2(value); + }; + + return /* HTML */ ` +
+ + ${FilterToolbox.mount({ + filters: props.filters, + pagination: props.pagination, + categories: props.categories, + limit: props.pagination?.limit, + sort: props.filters?.sort, + search: props.filters?.search, + selectedCategory1: props.filters?.category1, + selectedCategory2: props.filters?.category2, + handleSetSort, + handleSetLimit, + handleSetSearch, + handleSetSelectedCategory1, + handleSetSelectedCategory2, + }).outerHTML} + +
+ +
+ 총 ${props.pagination?.total}개의 상품 +
+ +
+ ${(props.products || []) + .map( + (product) => + ProductCard.mount({ + productId: product.productId, + image: product.image, + title: product.title, + brand: product.brand, + lprice: product.lprice, + }).outerHTML, + ) + .join("\n")} +
+ +
모든 상품을 확인했습니다
+
+
+ `; + }, +}); + +export default ProductList; diff --git a/src/components/ToastAlert.js b/src/components/ToastAlert.js new file mode 100644 index 00000000..d979418b --- /dev/null +++ b/src/components/ToastAlert.js @@ -0,0 +1,79 @@ +/** + * @param {import('../types').ToastAlertProps} props + */ + +export default function ToastAlert({ message, type, id }) { + /** + * @param {'success'|'info'|'error'} type + * @param {string} message + * @returns {string} + */ + const renderContent = (type, message) => { + switch (type) { + case "success": + return /* HTML */ ` +
+
+ + + +
+

${message}

+ +
+ `; + case "info": + return /* HTML */ ` +
+
+ + + +
+

${message}

+ +
+ `; + case "error": + return /* HTML */ ` +
+
+ + + +
+

${message}

+ +
+ `; + } + }; + + return /* HTML */ ` ${renderContent(type, message)} `; +} diff --git a/src/constants/component-constant.js b/src/constants/component-constant.js new file mode 100644 index 00000000..8cf56f48 --- /dev/null +++ b/src/constants/component-constant.js @@ -0,0 +1,134 @@ +export const HOME_PAGE_LOADING = /* HTML */ ` +
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + +
+ +
+
카테고리 로딩 중...
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + + 상품을 불러오는 중... +
+
+
+
+
+`; diff --git a/src/constants/filter-constant.js b/src/constants/filter-constant.js new file mode 100644 index 00000000..99cd66d3 --- /dev/null +++ b/src/constants/filter-constant.js @@ -0,0 +1,7 @@ +export const LIMIT_OPTIONS = Object.freeze([10, 20, 50, 100]); +export const SORT_OPTIONS = Object.freeze([ + { value: "price_asc", label: "가격 낮은순" }, + { value: "price_desc", label: "가격 높은순" }, + { value: "name_asc", label: "이름순" }, + { value: "name_desc", label: "이름 역순" }, +]); diff --git a/src/constants/toast-constant.js b/src/constants/toast-constant.js new file mode 100644 index 00000000..913327b4 --- /dev/null +++ b/src/constants/toast-constant.js @@ -0,0 +1,5 @@ +export const TOAST_MESSAGE_MAP = Object.freeze({ + ADD_TO_CART: "장바구니에 추가되었습니다", + REMOVE_SELECTED_CART_ITEMS: "선택된 상품들이 삭제되었습니다", + ERROR: "오류가 발생했습니다.", +}); diff --git a/src/core/component/create-component.js b/src/core/component/create-component.js new file mode 100644 index 00000000..8036bedd --- /dev/null +++ b/src/core/component/create-component.js @@ -0,0 +1,312 @@ +/* +- 컴포넌트가 각자의 container를 가져야함. 그 컨테이너는 query 식별자를 가져야함. + - 이 container 식별자로 component 내부 state의 변화로 이를 의존하는 컴포넌트만 렌더링이 발생하도록 해야함. +*/ + +/** + * @typedef {Record} Props + * @typedef {Record} State + * @typedef {(state: State) => void} RenderCallback + * @typedef {(key: string, value: any | ((prev: any) => any)) => void} Setter + * @typedef {(key: string) => any} Getter + * @typedef {(props: Props, state: State, setState: Setter, children: string) => string} TemplateFn + * @typedef {(context: { getState: Getter, setState: Setter, props: Props }) => void} EffectFn + * @typedef {Object} Effects + */ + +/** + * @param {any[]} arr1 + * @param {any[]} arr2 + * @returns {boolean} + */ +const arraysEqual = (arr1, arr2) => { + if (arr1.length !== arr2.length) return false; + return arr1.every((val, idx) => val === arr2[idx]); +}; + +/** + * @param {State} state + * @returns {{ getState: (key: string) => any, setState: (key: string, value: any | ((prev: any) => any)) => void, subscribe: (observer: (args: any) => void) => () => void }} + */ +const createStateMap = (state) => { + /** @type {Map} */ + const stateMap = new Map(Object.entries(state)); + + /** @type {Set<(args: any) => void>} */ + const observers = new Set(); + /** @type {boolean} */ + let pendingNotify = false; + + const getValueMap = () => { + /** @type {Record} */ + const valueMap = {}; + stateMap.forEach((value, key) => { + valueMap[key] = value; + }); + return valueMap; + }; + + const notifyObservers = () => { + if (pendingNotify) return; + pendingNotify = true; + + Promise.resolve().then(() => { + pendingNotify = false; + const currentState = getValueMap(); + observers.forEach((observer) => observer(currentState)); + }); + }; + + return { + getState: (/** @type {string} */ key) => { + return stateMap.get(key); + }, + setState: (/** @type {string} */ key, /** @type {any | ((prev: any) => any)} */ value) => { + if (value instanceof Function) { + value = value(stateMap.get(key)); + } + stateMap.set(key, value); + notifyObservers(); + }, + subscribe: (observer) => { + observers.add(observer); + return () => observers.delete(observer); + }, + }; +}; + +/** + * @typedef {Object} CreateComponentOptions + * @property {string} id + * @property {Props} [props={}] + * @property {(props: Props) => State} [initialState=() => ({})] + * @property {(props: Props, state: State, setState: Setter, children?: string) => string} templateFn + * @property {Record void>} [eventHandlers={}] + * @property {HTMLElement[]} [children=[]] + * @property {Effects} [effects={}] + * @returns {{ mount: (props: Props) => HTMLElement }} + */ + +/** + * @param {CreateComponentOptions} options + * @returns {{ mount: (props: Props) => HTMLElement }} + */ +export default function createComponent({ + id, + props = {}, // initial props + initialState = () => ({}), + templateFn, + eventHandlers = {}, + children = [], + effects = {}, // { onMount: fn, effectName: { dependencies: [], effect: fn } } +}) { + if (!window.__componentEventHandlers) { + window.__componentEventHandlers = new Map(); + } + + if (!window.__componentEventListenersRegistered) { + window.__componentEventListenersRegistered = true; + + const EVENT_TYPES = ["click", "change", "input", "submit", "keydown", "focus", "blur"]; + + EVENT_TYPES.forEach((eventType) => { + document.addEventListener( + eventType, + (/** @type {Event} */ event) => { + if (!event.target) return; + const eventTarget = event.target.closest("[data-event]"); + if (!eventTarget) return; + + const componentElement = eventTarget.closest("[data-component]"); + if (!componentElement) return; + + const componentId = componentElement.dataset.component; + const eventName = eventTarget.dataset.event; + + if (!componentId || !eventName) return; + + const eventTypeAttr = eventTarget.dataset.eventType; + if (eventTypeAttr !== event.type) return; + + const handlers = window.__componentEventHandlers.get(componentId); + if (handlers?.[eventName]) { + event.stopPropagation(); + event.stopImmediatePropagation(); + handlers[eventName](event); + } + }, + eventType === "focus" || eventType === "blur", + ); + }); + } + + return { + mount: (_props = props) => { + const instanceId = _props.key ? `${id}-${_props.key}` : `${id}-${Math.random().toString(36).substring(2, 15)}`; + let currentProps = _props; + let isRendering = false; + + /** + * @param {string} html + * @param {string} componentId + */ + const parseAndGetWrapperElement = (html, componentId) => { + const range = document.createRange(); + const parsedFragment = range.createContextualFragment(html); + + /** @type {HTMLElement | null} */ + const wrapperElement = parsedFragment.firstElementChild; + if (!wrapperElement) return null; + wrapperElement.dataset.component = componentId; + return wrapperElement; + }; + + const { getState, setState, subscribe } = createStateMap(initialState(currentProps)); + + // Effects setup + /** @type {Map} */ + const prevDepsMap = new Map(); + /** @type {Map} */ + const cleanupMap = new Map(); + let isMounted = false; + let onMountCleanup = null; + + const effectContext = { + getState, + setState, + props: currentProps, + }; + + const effectsUnsubscribe = subscribe((state) => { + if (!isMounted) return; + + Object.entries(effects).forEach(([name, config]) => { + if (name === "onMount") return; + + if (config.dependencies) { + const currentDeps = config.dependencies.map((key) => { + if (key.includes(".")) { + const parts = key.split("."); + let value = state; + for (const part of parts) { + value = value?.[part]; + } + return value; + } + return state[key]; + }); + const prevDeps = prevDepsMap.get(name); + + if (!prevDeps || !arraysEqual(prevDeps, currentDeps)) { + prevDepsMap.set(name, currentDeps); + + const prevCleanup = cleanupMap.get(name); + if (prevCleanup) { + prevCleanup(); + } + + const cleanup = config.effect(effectContext); + if (cleanup instanceof Function) { + cleanupMap.set(name, cleanup); + } + } + } + }); + }); + + /** @type {RenderCallback} */ + const render = (_state) => { + isRendering = true; + const targetElement = document.querySelector(`[data-component="${instanceId}"]`); + if (!targetElement) { + isRendering = false; + return; + } + const childrenHTML = children.map((child) => child.outerHTML).join(""); + const wrapperElement = parseAndGetWrapperElement( + templateFn(currentProps, _state, setState, childrenHTML), + instanceId, + ); + targetElement.replaceWith(wrapperElement); + + setTimeout(() => { + isRendering = false; + }, 0); + }; + + const unsubscribe = subscribe(render); + + window.__componentEventHandlers.set( + instanceId, + Object.fromEntries( + Object.entries(eventHandlers).map(([eventName, handler]) => [ + eventName, + (/** @type {Event} */ event) => { + if (isRendering) return; + handler(currentProps, getState, setState, event); + }, + ]), + ), + ); + + const childrenHTML = children.map((child) => child.outerHTML).join(""); + const html = templateFn(_props, initialState(_props), setState, childrenHTML); + const element = parseAndGetWrapperElement(html, instanceId); + + const observer = new MutationObserver(() => { + if (isRendering) return; + if (!document.contains(element)) { + if (onMountCleanup) { + onMountCleanup(); + } + cleanupMap.forEach((cleanup) => cleanup()); + cleanupMap.clear(); + + unsubscribe(); + effectsUnsubscribe(); + window.__componentEventHandlers.delete(instanceId); + observer.disconnect(); + } + }); + + setTimeout(() => { + if (element.parentNode) { + observer.observe(element.parentNode, { childList: true }); + } + + isMounted = true; + if (effects.onMount) { + const cleanup = effects.onMount(effectContext); + if (cleanup instanceof Function) { + onMountCleanup = cleanup; + } + } + }, 0); + + return element; + }, + }; +} + +// // example +// const CartCountComponent = createComponent( +// "cart-count-component", +// { count: 0 }, +// ({ count }) => { +// return /* HTML */ ` +//
+// +//

${count}

+// +//
+// `; +// }, +// { +// add: (getter, setter) => { +// setter("count", (/** @type {number} */ prev) => prev + 1); +// }, +// subtract: (getter, setter) => { +// setter("count", (/** @type {number} */ prev) => prev - 1); +// }, +// }, +// ); diff --git a/src/core/router/index.js b/src/core/router/index.js new file mode 100644 index 00000000..f5cafb2a --- /dev/null +++ b/src/core/router/index.js @@ -0,0 +1,201 @@ +import { extractParams } from "../../utils/route"; + +/** @typedef {import('../../types.js').Routes} Routes */ +/** @typedef {import('../../types.js').RouteConfig} RouteConfig */ + +export default class Router { + /** + * @type {Routes} + */ + static routes = {}; + /** + * @type {string} + */ + static basePath = ""; + /** + * @type {HTMLElement | null} + */ + static container = null; + + /** @type {boolean} */ + static initialized = false; + + /** + * @param {Routes} routes + * @param {string} basePath + * @param {HTMLElement} container + */ + static init(routes, basePath, container) { + if (Router.initialized) { + console.warn("[Router] Already initialized"); + return; + } + + Router.routes = routes; + Router.basePath = basePath; + Router.container = container; + + window.addEventListener("popstate", Router.#renderFromLocation); + } + + /** + * @param {string} path + */ + static push(path) { + // Separate path and query string + const [pathOnly, queryString] = path.split("?"); + const normalized = Router.#normalize(pathOnly); + const route = Router.#match(normalized); + if (!route) throw new Error("Route not found"); + + const fullUrl = queryString + ? `${Router.basePath}${pathOnly.replace("/", "")}?${queryString}` + : `${Router.basePath}${pathOnly.replace("/", "")}`; + + history.pushState(null, "", fullUrl); + + Router.#render(route, normalized); + } + + /** + * @param {string} path + */ + static replace(path) { + // Separate path and query string + const [pathOnly, queryString] = path.split("?"); + const normalized = Router.#normalize(pathOnly); + const route = Router.#match(normalized); + if (!route) throw new Error("Route not found"); + + const url = Router.#absolute(normalized); + const fullUrl = queryString ? `${url}?${queryString}` : url; + + history.replaceState(null, "", fullUrl); + + Router.#render(route, normalized); + } + + static goBack() { + history.back(); + } + + /** + * @returns {URLSearchParams} + */ + static getQueryParams() { + return new URLSearchParams(window.location.search); + } + + /** + * @param {Record} params + */ + static updateQueryParams(params) { + const searchParams = new URLSearchParams(window.location.search); + + Object.entries(params).forEach(([key, value]) => { + if (value === "" || value === null || value === undefined) { + searchParams.delete(key); + } else { + searchParams.set(key, String(value)); + } + }); + const queryString = searchParams.toString(); + const newUrl = queryString ? `${window.location.pathname}?${queryString}` : window.location.pathname; + + history.replaceState(null, "", newUrl); + } + + /** + * @returns {Record} + */ + static getQueryParamsObject() { + const params = {}; + const searchParams = new URLSearchParams(window.location.search); + searchParams.forEach((value, key) => { + params[key] = value; + }); + return params; + } + + static #renderFromLocation() { + const abs = window.location.pathname; + const rel = Router.#relative(abs); + const route = Router.#match(rel); + + if (!route) { + if (Router.routes.notFound) { + if (Router.container) { + Router.container.replaceChildren(Router.routes.notFound.render({})); + } + } + return; + } + Router.#render(route, rel); + } + + /** + * @param {RouteConfig} route + * @param {string} path + */ + static async #render(route, path) { + if (!Router.container) throw new Error("Router not initialized"); + + try { + const params = extractParams(route.path, path); + // const props = await route.loader(params); + Router.container.replaceChildren(route.render({ ...params })); + } catch (err) { + console.error(err); + Router.container.replaceChildren(Router.routes.notFound.render({})); + } + } + + // Utils + /** + * @param {string} path + * @returns {string} + */ + static #normalize(path) { + if (!path) return "/"; + return "/" + path.replace(/^\/+/, "").replace(/\/+$/, ""); + } + + /** + * @param {string} relativePath + * @returns {string} + */ + static #absolute(relativePath) { + const base = Router.basePath.replace(/\/+$/, ""); + return `${base}${relativePath}`; + } + + /** + * @param {string} absolutePath + * @returns {string} + */ + static #relative(absolutePath) { + let base = Router.basePath; + if (!base.endsWith("/")) { + base = base + "/"; + } + let relative = absolutePath; + if (absolutePath.startsWith(base)) { + relative = absolutePath.slice(base.length); + } + if (!relative.startsWith("/")) { + relative = "/" + relative; + } + if (relative !== "/") { + relative = relative.replace(/\/+$/, ""); + } + return relative; + } + /** + * @param {string} path + * @returns {RouteConfig | undefined} + */ + static #match(path) { + const route = Object.values(Router.routes).find((r) => r.pattern?.test(path)); + return route; + } +} diff --git a/src/dto/CategoryDTO.js b/src/dto/CategoryDTO.js new file mode 100644 index 00000000..0d49b167 --- /dev/null +++ b/src/dto/CategoryDTO.js @@ -0,0 +1,24 @@ +export class CategoryDTO { + /** + * @param {string} categoryId + * @param {CategoryDTO[]} children + * */ + constructor(categoryId, children = []) { + this.categoryId = categoryId; + this.children = children; + Object.freeze(this); + } + + static #buildTree(rootCategoryId, rawChildrenObject) { + const children = Object.entries(rawChildrenObject ?? {}).map(([childCategoryId, childObject]) => { + return CategoryDTO.#buildTree(childCategoryId, childObject); + }); + return new CategoryDTO(rootCategoryId, children); + } + + static fromApi(data) { + return Object.entries(data ?? {}).map(([rootCategoryId, rootObject]) => + CategoryDTO.#buildTree(rootCategoryId, rootObject), + ); + } +} diff --git a/src/dto/ProductDTO.js b/src/dto/ProductDTO.js new file mode 100644 index 00000000..f987bcbf --- /dev/null +++ b/src/dto/ProductDTO.js @@ -0,0 +1,37 @@ +/** + * @typedef {import('../types.js').Product} Product + */ + +export class ProductDTO { + /** + * @param {Product} data + */ + constructor(data) { + this.brand = data.brand; + this.category1 = data.category1; + this.category2 = data.category2; + this.category3 = data.category3; + this.category4 = data.category4; + this.description = data.description; + this.hprice = data.hprice; + this.image = data.image; + this.images = data.images; + this.link = data.link; + this.lprice = data.lprice; + this.maker = data.maker; + this.mallName = data.mallName; + this.productId = data.productId; + this.productType = data.productType; + this.rating = data.rating; + this.reviewCount = data.reviewCount; + this.stock = data.stock; + this.title = data.title; + } + + /** + * @param {Product} data + */ + static fromApi(data) { + return new ProductDTO(data); + } +} diff --git a/src/dto/ProductListDTO.js b/src/dto/ProductListDTO.js new file mode 100644 index 00000000..92c1998f --- /dev/null +++ b/src/dto/ProductListDTO.js @@ -0,0 +1,92 @@ +/** + * @typedef {import('../types.js').ProductListResponse} ProductListResponse + * @typedef {import('../types.js').ProductForList} ProductForList + * @typedef {import('../types.js').Pagination} Pagination + * @typedef {import('../types.js').Filters} Filters + */ + +export class ProductListResponseDTO { + /** + * @param {ProductListResponse} data + */ + constructor(data) { + this.products = (data.products || []).map(ProductForListDTO.fromApi); + this.filters = FiltersDTO.fromApi(data.filters || {}); + this.pagination = PaginationDTO.fromApi(data.pagination || {}); + } + + /** + * @param {ProductListResponse} data + */ + static fromApi(data) { + return new ProductListResponseDTO(data); + } +} + +class ProductForListDTO { + /** + * @param {ProductForList} data + */ + constructor(data) { + this.productId = data.productId; + this.image = data.image; + this.title = data.title; + this.brand = data.brand; + this.lprice = parseInt(data.lprice.toString()) || 0; + this.category1 = data.category1 || ""; + this.category2 = data.category2 || ""; + this.category3 = data.category3 || ""; + this.category4 = data.category4 || ""; + this.hprice = parseInt(data.hprice?.toString() || "0"); + this.maker = data.maker || ""; + this.mallName = data.mallName || ""; + this.productType = data.productType || ""; + } + + /** + * @param {ProductForList} data + */ + static fromApi(data) { + return new ProductForListDTO(data); + } +} + +class FiltersDTO { + /** + * @param {Filters} data + */ + constructor(data) { + this.search = data.search || ""; + this.category1 = data.category1 || ""; + this.category2 = data.category2 || ""; + this.sort = data.sort || "price_asc"; + } + + /** + * @param {Filters} data + */ + static fromApi(data) { + return new FiltersDTO(data); + } +} + +class PaginationDTO { + /** + * @param {Pagination} data + */ + constructor(data) { + this.page = parseInt(data.page.toString()) || 1; + this.limit = parseInt(data.limit.toString()) || 20; + this.total = parseInt(data.total.toString()) || 0; + this.totalPages = parseInt(data.totalPages.toString()) || 0; + this.hasNext = data.hasNext ?? false; + this.hasPrev = data.hasPrev ?? false; + } + + /** + * @param {Pagination} data + */ + static fromApi(data) { + return new PaginationDTO(data); + } +} diff --git a/src/layouts/PageLayout.js b/src/layouts/PageLayout.js new file mode 100644 index 00000000..ed9bfe6c --- /dev/null +++ b/src/layouts/PageLayout.js @@ -0,0 +1,34 @@ +import Header from "../components/Header"; +import Footer from "../components/Footer"; +import createComponent from "../core/component/create-component"; + +/** + * @typedef {import('../types.js').PageLayoutProps} PageLayoutProps + */ + +/** + * @param {PageLayoutProps} props + */ +// export default function PageLayout({ children, isDetailPage = false, cart = [] }) { +// return /* HTML */ ` +//
${Header({ isDetailPage, cart })} ${children} ${Footer()}
+// `; +// } +const PageLayout = createComponent({ + id: "page-layout", + props: { + children: "", + isDetailPage: false, + }, + templateFn: ({ children, isDetailPage, cart }) => { + const header = Header.mount({ isDetailPage, cart }); + const footer = Footer.mount({}); + + return /* HTML */ ` +
${header.outerHTML} ${children} ${footer.outerHTML}
+ `; + }, + children: [Header.mount({ isDetailPage: false }), Footer.mount({})], +}); + +export default PageLayout; diff --git a/src/main.js b/src/main.js index 4b055b89..87c71608 100644 --- a/src/main.js +++ b/src/main.js @@ -1,1152 +1,51 @@ +import { ROUTES } from "./route"; +import Router from "./core/router"; + +/** @type {string} */ +const basePath = import.meta.env.BASE_URL; + +/** @type {HTMLElement | null} */ +const $root = document.querySelector("#root"); + +/* Utils */ const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => worker.start({ + serviceWorker: { + url: `${basePath}mockServiceWorker.js`, + }, onUnhandledRequest: "bypass", }), ); -function main() { - const 상품목록_레이아웃_로딩 = ` -
-
-
-
-

- 쇼핑몰 -

-
- - -
-
-
-
-
- -
- -
-
- -
- - - -
-
-
- -
- -
-
- - -
- -
-
카테고리 로딩 중...
-
- -
- -
- -
- - -
- -
- - -
-
-
-
- -
-
- -
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
- - - - - 상품을 불러오는 중... -
-
-
-
-
-
-
-

© 2025 항해플러스 프론트엔드 쇼핑몰

-
-
-
- `; - - const 상품목록_레이아웃_로딩완료 = ` -
-
-
-
-

- 쇼핑몰 -

-
- - -
-
-
-
-
- -
- -
-
- -
- - - -
-
-
- -
- -
-
- - -
- -
- - -
- -
- -
- -
- - -
- -
- - -
-
-
-
- -
-
- -
- 총 340개의 상품 -
- -
-
- -
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -
- -
-
-

- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -

-

-

- 220원 -

-
- - -
-
-
- -
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -
- -
-
-

- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -

-

이지웨이건축자재

-

- 230원 -

-
- - -
-
-
- -
- 모든 상품을 확인했습니다 -
-
-
-
-
-
-

© 2025 항해플러스 프론트엔드 쇼핑몰

-
-
-
- `; - - const 상품목록_레이아웃_카테고리_1Depth = ` -
- -
- -
-
- -
- - - -
-
-
- - -
- - -
-
- - > -
-
-
- - - -
-
-
- - -
- -
- - -
- -
- - -
-
-
-
-
- `; - - const 상품목록_레이아웃_카테고리_2Depth = ` -
- -
- -
-
- -
- - - -
-
-
- - -
- - -
-
- - >>주방용품 -
-
-
- - - -
-
-
- - -
- -
- - -
- -
- - -
-
-
-
-
- `; - - const 토스트 = ` -
-
-
- - - -
-

장바구니에 추가되었습니다

- -
- -
-
- - - -
-

선택된 상품들이 삭제되었습니다

- -
- -
-
- - - -
-

오류가 발생했습니다.

- -
-
- `; - - const 장바구니_비어있음 = ` -
-
- -
-

- - - - 장바구니 -

- - -
- - -
- -
-
-
- - - -
-

장바구니가 비어있습니다

-

원하는 상품을 담아보세요!

-
-
-
-
-
- `; - - const 장바구니_선택없음 = ` -
-
- -
-

- - - - 장바구니 - (2) -

- -
- -
- -
- -
- -
-
-
- - - -
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -
- -
-

- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -

-

- 220원 -

- -
- - - -
-
- -
-

- 440원 -

- -
-
-
- - - -
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -
- -
-

- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -

-

- 230원 -

- -
- - - -
-
- -
-

- 230원 -

- -
-
-
-
-
- -
- - -
- 총 금액 - 670원 -
- -
-
- - -
-
-
-
-
- `; - - const 장바구니_선택있음 = ` -
-
- -
-

- - - - 장바구니 - (2) -

- -
- -
- -
- -
- -
-
-
- - - -
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -
- -
-

- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -

-

- 220원 -

- -
- - - -
-
- -
-

- 440원 -

- -
-
-
- - - -
- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -
- -
-

- 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 -

-

- 230원 -

- -
- - - -
-
- -
-

- 230원 -

- -
-
-
-
-
- -
- -
- 선택한 상품 (1개) - 440원 -
- -
- 총 금액 - 670원 -
- -
- -
- - -
-
-
-
-
- `; - - const 상세페이지_로딩 = ` -
-
-
-
-
- -

상품 상세

-
-
- - -
-
-
-
-
-
-
-
-

상품 정보를 불러오는 중...

-
-
-
-
-
-

© 2025 항해플러스 프론트엔드 쇼핑몰

-
-
-
- `; - - const 상세페이지_로딩완료 = ` -
-
-
-
-
- -

상품 상세

-
-
- - -
-
-
-
-
- - - -
- -
-
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 -
- -
-

-

PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장

- -
-
- - - - - - - - - - - - - - - -
- 4.0 (749개 리뷰) -
- -
- 220원 -
- -
- 재고 107개 -
- -
- PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장에 대한 상세 설명입니다. 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다. -
-
-
- -
-
- 수량 -
- - - -
-
- - -
-
- -
- -
- -
-
-

관련 상품

-

같은 카테고리의 다른 상품들

-
-
-
- - -
-
-
-
-
-
-

© 2025 항해플러스 프론트엔드 쇼핑몰

-
-
-
- `; - - const _404_ = ` -
-
- - - - - - - - - - - - - 404 - - - - - - - - - 페이지를 찾을 수 없습니다 - - - - - - 홈으로 -
-
- `; - - document.body.innerHTML = ` - ${상품목록_레이아웃_로딩} -
- ${상품목록_레이아웃_로딩완료} -
- ${상품목록_레이아웃_카테고리_1Depth} -
- ${상품목록_레이아웃_카테고리_2Depth} -
- ${토스트} -
- ${장바구니_비어있음} -
- ${장바구니_선택없음} -
- ${장바구니_선택있음} -
- ${상세페이지_로딩} -
- ${상세페이지_로딩완료} -
- ${_404_} - `; +async function main() { + if (!$root) throw new Error("Root element not found"); + + Router.init(ROUTES, basePath, $root); + + const pathName = window.location.pathname; + const relativePath = pathName.replace(basePath, "/").replace(/\/$/, "") || "/"; + + const homeRoute = ROUTES.home; + const productDetailRoute = ROUTES.productDetail; + + /* Initial Render */ + if (relativePath === homeRoute.path) { + $root.replaceChildren(homeRoute.render({})); + } else if (productDetailRoute.pattern.test(relativePath)) { + const id = relativePath.split("/")[2]; + console.log("[Initial Render] Product Detail", id); + $root.replaceChildren(productDetailRoute.render({ id })); + } else { + console.log("[Initial Render] Not Found", relativePath); + history.pushState(null, "", `${basePath}404`); + $root.replaceChildren(ROUTES.notFound.render({})); + } } // 애플리케이션 시작 if (import.meta.env.MODE !== "test") { - enableMocking().then(main); + enableMocking().then(() => main()); } else { main(); } diff --git a/src/pages/HomePage.js b/src/pages/HomePage.js new file mode 100644 index 00000000..3f2093d6 --- /dev/null +++ b/src/pages/HomePage.js @@ -0,0 +1,217 @@ +import PageLayout from "../layouts/PageLayout"; +import ProductList from "../components/ProductList"; +import createComponent from "../core/component/create-component"; +import { HOME_PAGE_LOADING } from "../constants/component-constant"; +import Router from "../core/router"; +import { getProducts, getCategories } from "../api/productApi"; + +const HomePage = createComponent({ + id: "home-page", + initialState: () => { + const queryParams = Router.getQueryParamsObject(); + const defaultFilters = { + search: queryParams?.search || "", + category1: queryParams?.category1 || "", + category2: queryParams?.category2 || "", + sort: queryParams?.sort || "price_asc", + }; + const defaultPagination = { + limit: queryParams?.limit ? parseInt(queryParams.limit) : 20, + total: 0, + totalPages: 0, + hasNext: true, + hasPrev: false, + page: queryParams?.current ? parseInt(queryParams.current) : 1, + }; + const defaultListResponse = { + products: [], + pagination: defaultPagination, + filters: defaultFilters, + }; + + return { + listResponse: defaultListResponse, + categories: [], + isLoading: true, + }; + }, + effects: { + fetchProducts: { + dependencies: [ + "listResponse.filters.sort", + "listResponse.filters.search", + "listResponse.filters.category1", + "listResponse.filters.category2", + "listResponse.pagination.limit", + ], + effect: async ({ getState, setState }) => { + const listResponse = getState("listResponse"); + const filters = listResponse?.filters || {}; + const pagination = listResponse?.pagination || {}; + + try { + setState("isLoading", true); + + const response = await getProducts({ + limit: pagination.limit || 20, + search: filters.search || "", + category1: filters.category1 || "", + category2: filters.category2 || "", + sort: filters.sort || "price_asc", + }); + + setState("listResponse", { + ...response, + pagination: { ...response.pagination, page: 1 }, + }); + + // 필터 변경 시 current 제거 + Router.updateQueryParams({ current: "" }); + } catch (error) { + console.error("[HomePage] fetchProducts error", error); + } finally { + setState("isLoading", false); + } + }, + }, + watchCurrentPage: { + dependencies: ["listResponse.pagination.page"], + effect: ({ getState }) => { + const listResponse = getState("listResponse"); + const pagination = listResponse?.pagination || {}; + Router.updateQueryParams({ current: pagination.page > 1 ? pagination.page : "" }); + return () => { + Router.updateQueryParams({ current: "" }); + }; + }, + }, + onMount: async ({ getState, setState }) => { + const listResponse = getState("listResponse"); + const filters = listResponse?.filters || {}; + const pagination = listResponse?.pagination || {}; + + try { + setState("isLoading", true); + const categoryResponse = await getCategories(); + const productListResponse = await getProducts({ + limit: pagination.limit || 20, + search: filters.search || "", + category1: filters.category1 || "", + category2: filters.category2 || "", + sort: filters.sort || "price_asc", + }); + setState("listResponse", productListResponse); + setState("categories", categoryResponse); + } catch (error) { + console.error("[HomePage] onMount error", error); + } finally { + setState("isLoading", false); + } + + /** @type {IntersectionObserver | null} */ + let io = null; + + setTimeout(() => { + const sentinel = document.querySelector("#sentinel"); + if (!sentinel) return; + + io = new IntersectionObserver( + async ([entry]) => { + if (!entry.isIntersecting) return; + + const currentListResponse = getState("listResponse"); + + if (!currentListResponse.pagination.hasNext) return; + + setState("isLoading", true); + + try { + const response = await getProducts({ + limit: currentListResponse.pagination.limit || 20, + search: currentListResponse.filters.search || "", + category1: currentListResponse.filters.category1 || "", + category2: currentListResponse.filters.category2 || "", + sort: currentListResponse.filters.sort || "price_asc", + page: currentListResponse.pagination.page + 1, + }); + + setState("listResponse", (current) => ({ + ...response, + products: [...current.products, ...response.products], + })); + } catch (error) { + console.error("[HomePage] Infinite scroll error", error); + } finally { + setState("isLoading", false); + } + }, + { + root: null, + rootMargin: "200px", + threshold: 0, + }, + ); + + io.observe(sentinel); + }, 100); + + return () => { + if (io) io.disconnect(); + }; + }, + }, + templateFn: (_, { listResponse, categories, isLoading }, setState) => { + const syncToUrl = (updates) => { + Router.updateQueryParams(updates); + }; + + return PageLayout.mount({ + children: isLoading + ? HOME_PAGE_LOADING + : ProductList.mount({ + products: listResponse?.products || [], + pagination: listResponse?.pagination || {}, + filters: listResponse?.filters || {}, + categories, + handleSetSort: (value) => { + // setState("filters", (currentFilters) => ({ ...currentFilters, sort: value })); + setState("listResponse", (currentListResponse) => ({ + ...currentListResponse, + filters: { ...currentListResponse.filters, sort: value }, + })); + syncToUrl({ sort: value }); + }, + handleSetLimit: (value) => { + setState("listResponse", (currentListResponse) => ({ + ...currentListResponse, + pagination: { ...currentListResponse.pagination, limit: value }, + })); + syncToUrl({ limit: value }); + }, + handleSetSearch: (value) => { + setState("listResponse", (currentListResponse) => ({ + ...currentListResponse, + filters: { ...currentListResponse.filters, search: value }, + })); + syncToUrl({ search: value }); + }, + handleSetSelectedCategory1: (value) => { + setState("listResponse", (currentListResponse) => ({ + ...currentListResponse, + filters: { ...currentListResponse.filters, category1: value, category2: "" }, + })); + syncToUrl({ category1: value, category2: "" }); + }, + handleSetSelectedCategory2: (value) => { + setState("listResponse", (currentListResponse) => ({ + ...currentListResponse, + filters: { ...currentListResponse.filters, category2: value }, + })); + syncToUrl({ category2: value }); + }, + }).outerHTML, + }).outerHTML; + }, +}); + +export default HomePage; diff --git a/src/pages/NotFoundPage.js b/src/pages/NotFoundPage.js new file mode 100644 index 00000000..9264c179 --- /dev/null +++ b/src/pages/NotFoundPage.js @@ -0,0 +1,69 @@ +import createComponent from "../core/component/create-component"; +import Router from "../core/router/index.js"; + +const NotFoundPage = createComponent({ + id: "not-found-page", + templateFn: () => { + return /* HTML */ ` +
+
+ + + + + + + + + + + + + + 404 + + + + + + + + + + + 페이지를 찾을 수 없습니다 + + + + + + + 홈으로 +
+
+ `; + }, +}); + +export default NotFoundPage; diff --git a/src/pages/ProductDetailPage.js b/src/pages/ProductDetailPage.js new file mode 100644 index 00000000..6bcaf10a --- /dev/null +++ b/src/pages/ProductDetailPage.js @@ -0,0 +1,59 @@ +import PageLayout from "../layouts/PageLayout"; +import ProductDetail from "../components/ProductDetail"; +import createComponent from "../core/component/create-component"; +import { getProduct, getProducts } from "../api/productApi"; + +export const 상세페이지_로딩 = /* HTML */ ` +
+
+
+
+

상품 정보를 불러오는 중...

+
+
+
+`; + +const ProductDetailPage = createComponent({ + id: "product-detail-page", + props: { + id: "", + }, + initialState: (props) => { + return { + id: props.id, + productDetailResponse: null, + productDetailListResponse: null, + isLoading: false, + }; + }, + effects: { + onMount: async ({ getState, setState }) => { + const id = getState("id"); + setState("isLoading", true); + try { + const response = await getProduct(id); + const listResponse = await getProducts({ + category1: response.category1, + category2: response.category2, + }); + setState("productDetailResponse", response); + setState("productDetailListResponse", listResponse); + } catch (error) { + console.error("[ProductDetailPage] onMount error", error); + } finally { + setState("isLoading", false); + } + }, + }, + templateFn: (_, { productDetailResponse, productDetailListResponse, isLoading }) => { + return PageLayout.mount({ + children: isLoading + ? 상세페이지_로딩 + : ProductDetail.mount({ productDetailResponse, productDetailListResponse }).outerHTML, + isDetailPage: true, + }).outerHTML; + }, +}); + +export default ProductDetailPage; diff --git a/src/route.js b/src/route.js new file mode 100644 index 00000000..a7ea9e62 --- /dev/null +++ b/src/route.js @@ -0,0 +1,28 @@ +import HomePage from "./pages/HomePage"; +import ProductDetailPage from "./pages/ProductDetailPage"; +import { pathToRegex } from "./utils/route"; +import NotFoundPage from "./pages/NotFoundPage"; + +/** @typedef {import('./types.js').HomePageProps} HomePageProps */ +/** @typedef {import('./types.js').ProductDetailPageProps} ProductDetailPageProps */ + +export const ROUTES = Object.freeze({ + home: { + name: "home", + path: "/", + pattern: pathToRegex("/"), + render: HomePage.mount, + }, + productDetail: { + name: "productDetail", + path: "/product/:id", + pattern: pathToRegex("/product/:id"), + render: ProductDetailPage.mount, + }, + notFound: { + name: "notFound", + path: "/404", + pattern: pathToRegex("/404"), + render: NotFoundPage.mount, + }, +}); diff --git a/src/store/app-store.js b/src/store/app-store.js new file mode 100644 index 00000000..9a2f7b21 --- /dev/null +++ b/src/store/app-store.js @@ -0,0 +1,137 @@ +/** + * @typedef {import('../types.js').ProductListResponse} ProductListResponse + * @typedef {import('../types.js').CategoryTreeNode} CategoryTreeNode + * @typedef {import('../types.js').Filters} Filters + * @typedef {import('../types.js').Pagination} Pagination + * @typedef {import('../types.js').SortOption} SortOption + * @typedef {import('../types.js').Product} Product + * @typedef {import('../types.js').CartItem} CartItem + */ + +/** + * @typedef {Object} AppState + * @property {CartItem[]} cart + * @property {boolean} allSelected + */ + +/** @type {AppState} */ +const initialAppState = { + cart: [], + allSelected: false, +}; + +const STORAGE_KEY = "shopping_cart"; + +const loadStateFromStorage = () => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + return JSON.parse(stored); + } + } catch (error) { + console.error("[Store] Failed to load from localStorage:", error); + } + return initialAppState; +}; + +const saveStateToStorage = (state) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (error) { + console.error("[Store] Failed to save to localStorage:", error); + } +}; + +let appState = loadStateFromStorage(); + +const observers = new Set(); + +const appStore = { + subscribe: (observer) => { + observers.add(observer); + return () => observers.delete(observer); + }, + _notify: () => { + observers.forEach((observer) => observer(appState)); + saveStateToStorage(appState); + }, + // State + getState: () => appState, + // Mutations + setCart: (/** @type {CartItem[]} */ newCart) => { + console.log("[Store - Mutation] setCart", { BEFORE: appState.cart, AFTER: newCart }); + appState.cart = newCart; + appStore._notify(); + }, + setAllSelected: (/** @type {boolean} */ newAllSelected) => { + console.log("[Store - Mutation] setAllSelected", { BEFORE: appState.allSelected, AFTER: newAllSelected }); + appState.allSelected = newAllSelected; + appStore._notify(); + }, + // Actions + /** + * @param {Omit} cartItem + * @param {number} [count=1] + */ + addToCart: (cartItem, count = 1) => { + console.log("[Store - Action] addToCart", { BEFORE: appState.cart, count, cartItem }); + const existingCartItem = appState.cart.find((item) => item.id === cartItem.id); + if (existingCartItem) { + appStore.setCart( + appState.cart.map((item) => + item.id === cartItem.id ? { ...item, count: existingCartItem.count + count } : item, + ), + ); + console.log("[Store - Action] addToCart - 2", existingCartItem.count); + } else { + // appState.cart = [...appState.cart, { ...cartItem, count }]; + appStore.setCart([...appState.cart, { ...cartItem, count }]); + console.log("[Store - Action] addToCart - 3", appState.cart); + } + }, + addCartItemCountByProductId: (/** @type {string} */ productId) => { + console.log("[Store - Action] addCartItemCountByProductId", { + BEFORE: appState.cart, + AFTER: appState.cart.find((item) => item.id === productId)?.count, + }); + appStore.setCart( + appState.cart.map((item) => (item.id === productId ? { ...item, count: Math.min(item.count + 1, 999) } : item)), + ); + }, + subtractCartItemCountByProductId: (/** @type {string} */ productId) => { + console.log("[Store - Action] subtractCartItemCountByProductId", { + BEFORE: appState.cart, + AFTER: appState.cart.find((item) => item.id === productId)?.count, + }); + appStore.setCart( + appState.cart.map((item) => (item.id === productId ? { ...item, count: Math.max(item.count - 1, 1) } : item)), + ); + }, + removeSelectedCartItems: () => { + console.log("[Store - Action] removeSelectedCartItems", { + BEFORE: appState.cart, + AFTER: appState.cart.map((item) => ({ ...item, selected: false })), + }); + appStore.setCart(appState.cart.map((item) => ({ ...item, selected: false }))); + }, + removeCartItemByProductId: (/** @type {string} */ productId) => { + console.log("[Store - Action] removeCartItemByProductId", { + BEFORE: appState.cart, + AFTER: appState.cart.filter((item) => item.id !== productId), + }); + appStore.setCart(appState.cart.filter((item) => item.id !== productId)); + }, + removeAllCartItems: () => { + console.log("[Store - Action] removeAllCartItems", { + BEFORE: appState.cart, + AFTER: [], + }); + appStore.setCart([]); + }, + reset: () => { + console.log("[Store - Action] reset", { BEFORE: appState, AFTER: initialAppState }); + appState = initialAppState; + }, +}; + +export default appStore; diff --git a/src/templates.js b/src/templates.js new file mode 100644 index 00000000..191c4f59 --- /dev/null +++ b/src/templates.js @@ -0,0 +1,1436 @@ +export const 상품목록_레이아웃_로딩 = /* HTML */ ` +
+
+
+
+

+ 쇼핑몰 +

+
+ + +
+
+
+
+
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + +
+ +
+
카테고리 로딩 중...
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + + 상품을 불러오는 중... +
+
+
+
+
+
+
+

© 2025 항해플러스 프론트엔드 쇼핑몰

+
+
+
+`; + +export const 상품목록_레이아웃_로딩완료 = /* HTML */ ` +
+
+
+
+

+ 쇼핑몰 +

+
+ + +
+
+
+
+
+ +
+ +
+
+ +
+ + + +
+
+
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+ +
+
+ +
340개의 상품
+ +
+
+ +
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +
+ +
+
+

+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +

+

+

220원

+
+ + +
+
+
+ +
+ 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +
+ +
+
+

+ 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +

+

이지웨이건축자재

+

230원

+
+ + +
+
+
+ +
모든 상품을 확인했습니다
+
+
+
+
+
+

© 2025 항해플러스 프론트엔드 쇼핑몰

+
+
+
+`; + +export const 상품목록_레이아웃_카테고리_1Depth = /* HTML */ ` +
+ +
+ +
+
+ +
+ + + +
+
+
+ + +
+ +
+
+ + > +
+
+
+ + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+`; + +export const 상품목록_레이아웃_카테고리_2Depth = /* HTML */ ` +
+ +
+ +
+
+ +
+ + + +
+
+
+ + +
+ +
+
+ + >>주방용품 +
+
+
+ + + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+
+`; + +export const 토스트 = /* HTML */ ` +
+
+
+ + + +
+

장바구니에 추가되었습니다

+ +
+ +
+
+ + + +
+

선택된 상품들이 삭제되었습니다

+ +
+ +
+
+ + + +
+

오류가 발생했습니다.

+ +
+
+`; + +export const 장바구니_비어있음 = /* HTML */ ` +
+
+ +
+

+ + + + 장바구니 +

+ + +
+ + +
+ +
+
+
+ + + +
+

장바구니가 비어있습니다

+

원하는 상품을 담아보세요!

+
+
+
+
+
+`; + +export const 장바구니_선택없음 = /* HTML */ ` +
+
+ +
+

+ + + + 장바구니 + (2) +

+ +
+ +
+ +
+ +
+ +
+
+
+ + + +
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +
+ +
+

+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +

+

220원

+ +
+ + + +
+
+ +
+

440원

+ +
+
+
+ + + +
+ 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +
+ +
+

+ 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +

+

230원

+ +
+ + + +
+
+ +
+

230원

+ +
+
+
+
+
+ +
+ + +
+ 총 금액 + 670원 +
+ +
+
+ + +
+
+
+
+
+`; + +export const 장바구니_선택있음 = /* HTML */ ` +
+
+ +
+

+ + + + 장바구니 + (2) +

+ +
+ +
+ +
+ +
+ +
+
+
+ + + +
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +
+ +
+

+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +

+

220원

+ +
+ + + +
+
+ +
+

440원

+ +
+
+
+ + + +
+ 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +
+ +
+

+ 샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이 +

+

230원

+ +
+ + + +
+
+ +
+

230원

+ +
+
+
+
+
+ +
+ +
+ 선택한 상품 (1개) + 440원 +
+ +
+ 총 금액 + 670원 +
+ +
+ +
+ + +
+
+
+
+
+`; + +export const 상세페이지_로딩 = /* HTML */ ` +
+
+
+
+
+ +

상품 상세

+
+
+ + +
+
+
+
+
+
+
+
+

상품 정보를 불러오는 중...

+
+
+
+
+
+

© 2025 항해플러스 프론트엔드 쇼핑몰

+
+
+
+`; + +export const 상세페이지_로딩완료 = /* HTML */ ` +
+
+
+
+
+ +

상품 상세

+
+
+ + +
+
+
+
+
+ + + +
+ +
+
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +
+ +
+

+

+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장 +

+ +
+
+ + + + + + + + + + + + + + + +
+ 4.0 (749개 리뷰) +
+ +
+ 220원 +
+ +
재고 107개
+ +
+ PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장에 대한 상세 설명입니다. + 브랜드의 우수한 품질을 자랑하는 상품으로, 고객 만족도가 높은 제품입니다. +
+
+
+ +
+
+ 수량 +
+ + + +
+
+ + +
+
+ +
+ +
+ +
+
+

관련 상품

+

같은 카테고리의 다른 상품들

+
+
+
+ + +
+
+
+
+
+
+

© 2025 항해플러스 프론트엔드 쇼핑몰

+
+
+
+`; + +export const _404_ = /* HTML */ ` +
+
+ + + + + + + + + + + + + + 404 + + + + + + + + + + + 페이지를 찾을 수 없습니다 + + + + + + + 홈으로 +
+
+`; diff --git a/src/types.js b/src/types.js new file mode 100644 index 00000000..ce409d01 --- /dev/null +++ b/src/types.js @@ -0,0 +1,211 @@ +/** + * @file 프로젝트 전역 타입 정의 + * 이 파일은 실행되지 않으며, JSDoc 타입 정의만을 위한 파일입니다. + */ + +// ============================================ +// Product 관련 타입 +// ============================================ + +/** + * @typedef {Object} Product + * @property {string} productId - 상품 ID + * @property {string} title - 상품명 + * @property {string} brand - 브랜드명 + * @property {string} image - 이미지 URL + * @property {number} lprice - 최저가 + * @property {number} [hprice] - 최고가 (optional) + * @property {string} [description] - 상품 설명 (optional) + * @property {string[]} [images] - 추가 이미지 목록 (optional) + * @property {string} link - 상품 링크 + * @property {string} category1 - 1depth 카테고리 + * @property {string} [category2] - 2depth 카테고리 (optional) + * @property {string} [category3] - 3depth 카테고리 (optional) + * @property {string} [category4] - 4depth 카테고리 (optional) + * @property {string} maker - 제조사 + * @property {string} mallName - 쇼핑몰명 + * @property {string} productType - 상품 타입 + * @property {number} [rating] - 평점 (optional) + * @property {number} [reviewCount] - 리뷰 수 (optional) + * @property {number} [stock] - 재고 (optional) + */ + +/** + * @typedef {Object} ProductForList - 상품 목록용 간소화된 타입 + * @property {string} productId - 상품 ID + * @property {string} title - 상품명 + * @property {string} brand - 브랜드명 + * @property {string} image - 이미지 URL + * @property {number} lprice - 최저가 + * @property {number} [hprice] - 최고가 (optional) + * @property {string} link - 상품 링크 + * @property {string} category1 - 1depth 카테고리 + * @property {string} [category2] - 2depth 카테고리 (optional) + * @property {string} [category3] - 3depth 카테고리 (optional) + * @property {string} [category4] - 4depth 카테고리 (optional) + * @property {string} maker - 제조사 + * @property {string} mallName - 쇼핑몰명 + * @property {string} productType - 상품 타입 + */ + +// ============================================ +// Pagination 관련 타입 +// ============================================ + +/** + * @typedef {Object} Pagination + * @property {number} page - 현재 페이지 + * @property {number} limit - 페이지당 개수 + * @property {number} total - 전체 아이템 수 + * @property {number} totalPages - 전체 페이지 수 + * @property {boolean} hasNext - 다음 페이지 존재 여부 + * @property {boolean} hasPrev - 이전 페이지 존재 여부 + */ + +// ============================================ +// Filter 관련 타입 +// ============================================ + +/** + * @typedef {Object} Filters + * @property {string} [search=""] - 검색어 + * @property {string} [category1=""] - 1depth 카테고리 필터 (optional) + * @property {string} [category2=""] - 2depth 카테고리 필터 (optional) + * @property {'price_asc'|'price_desc'|'name_asc'|'name_desc'} [sort="price_asc"] - 정렬 기준 + */ + +/** + * @typedef {Object} SortOption + * @property {'price_asc'|'price_desc'|'name_asc'|'name_desc'} value + * @property {string} label + */ + +// ============================================ +// API Response 타입 +// ============================================ + +/** + * @typedef {Object} ProductListResponse + * @property {ProductForList[]} products - 상품 목록 + * @property {Pagination} pagination - 페이지네이션 정보 + * @property {Filters} filters - 현재 필터 상태 + */ + +/** + * @typedef {Object} CategoryTreeNode + * @property {string} categoryId - 카테고리 ID + * @property {CategoryTreeNode[]} children - 하위 카테고리 + */ + +// ============================================ +// Cart 타입 +// ============================================ + +/** + * @typedef {Object} CartItem + * @property {string} id + * @property {string} image + * @property {number} price + * @property {boolean} selected + * @property {string} title + * @property {number} count + */ + +// ============================================ +// Component Props 타입 +// ============================================ + +/** + * @typedef {Object} PageLayoutProps + * @property {string} children + * @property {boolean} [isDetailPage] + * @property {CartItem[]} [cart] + */ + +/** + * @typedef {Object} ProductCardProps + * @property {string} productId + * @property {string} image + * @property {string} title + * @property {string} brand + * @property {number} lprice + */ + +/** + * @typedef {Object} ProductListProps + * @property {ProductListResponse} [productListResponse] + * @property {CategoryTreeNode[]} [categories] + */ + +/** + * @typedef {Object} HomePageProps + * @property {boolean} loading + * @property {ProductListResponse} [productListResponse] + * @property {CategoryTreeNode[]} [categories] + * @property {CartItem[]} [cart] + */ + +/** + * @typedef {Object} ProductDetailPageProps + * @property {boolean} loading + * @property {Product} [productDetailResponse] + * @property {ProductListResponse} [productDetailListResponse] + * @property {CartItem[]} [cart] + */ + +/** + * @typedef {Object} ProductDetailProps + * @property {Product} productDetailResponse + * @property {ProductListResponse} productDetailListResponse + */ + +/** + * @typedef {Object} HeaderProps + * @property {boolean} [isDetailPage] + * @property {CartItem[]} [cart] + */ + +/** + * @typedef {Object} CartModalProps + * @property {CartItem[]} [cart] + * @property {string[]} [selectedCartIds] + */ + +/** + * @typedef {Object} ToastAlertProps + * @property {string} message + * @property {'success'|'info'|'error'} type + * @property {string} id + */ + +// ============================================ +// Utility 타입 +// ============================================ + +/** + * @template T + * @typedef {T | null | undefined} Maybe + */ + +/** + * @template T + * @typedef {Promise} AsyncReturn + */ + +// ============================================ +// Route 타입 +// ============================================ + +/** + * @typedef {Record} Routes + */ +/** + * @typedef {Object} RouteConfig + * @property {string} name + * @property {string} path + * @property {RegExp} pattern + * @property {(props: any) => string} render + * @property {(params: any) => Promise} loader + */ + +export {}; diff --git a/src/utils/formatter.js b/src/utils/formatter.js new file mode 100644 index 00000000..0bb76c1d --- /dev/null +++ b/src/utils/formatter.js @@ -0,0 +1,7 @@ +/** + * @param {number} number + * @returns {string} + */ +export const formatNumber = (number) => { + return number.toLocaleString(); +}; diff --git a/src/utils/route.js b/src/utils/route.js new file mode 100644 index 00000000..7398c6b9 --- /dev/null +++ b/src/utils/route.js @@ -0,0 +1,41 @@ +/** + * @param {string} path + * @returns {RegExp} + */ +export function pathToRegex(path) { + const pattern = path.replace(/\//g, "\\/").replace(/:\w+/g, "([^/]+)"); + + return new RegExp(`^${pattern}$`); +} + +/** + * @param {string} routePath + * @param {string} actualPath + * @returns {Record} + */ +export function extractParams(routePath, actualPath) { + const paramNames = []; + const paramMatches = routePath.matchAll(/:(\w+)/g); + for (const match of paramMatches) { + paramNames.push(match[1]); + } + + if (paramNames.length === 0) { + return {}; + } + + const pattern = pathToRegex(routePath); + const match = actualPath.match(pattern); + + if (!match) { + return {}; + } + + /** @type {Record} */ + const params = {}; + paramNames.forEach((name, index) => { + params[name] = match[index + 1]; + }); + + return params; +} diff --git a/src/utils/toast-utils.js b/src/utils/toast-utils.js new file mode 100644 index 00000000..86220ab4 --- /dev/null +++ b/src/utils/toast-utils.js @@ -0,0 +1,46 @@ +import ToastAlert from "../components/ToastAlert"; + +/** + * @param {string} message + * @param {'success'|'info'|'error'} type + * @param {Object} options + * @param {number} [options.duration=3000] + */ +export const showToastMessage = (message, type, options = { duration: 3000 }) => { + const { duration = 3000 } = options; + const TOAST_ROOT_ID = "toast-root"; + + /** @type {HTMLElement | null} */ + let $toastRoot = document.getElementById(TOAST_ROOT_ID); + if (!$toastRoot) { + $toastRoot = document.createElement("div"); + $toastRoot.id = TOAST_ROOT_ID; + $toastRoot.className = + "toast-alert fixed bottom-4 left-1/2 transform -translate-x-1/2 flex flex-col gap-2 items-center justify-center mx-auto"; + $toastRoot.style.width = "fit-content"; + document.body.appendChild($toastRoot); + } + + if (!$toastRoot) throw new Error("Toast root element not found"); + + const toastId = `toast-${Date.now()}`; + + $toastRoot.insertAdjacentHTML("beforeend", ToastAlert({ message, type, id: toastId })); + + const toastAlert = document.querySelector(`[data-toast-id="${toastId}"]`); + + const timer = setTimeout(() => { + toastAlert?.remove(); + }, duration); + + /** + * @param {MouseEvent} event + */ + toastAlert?.addEventListener("click", (event) => { + if (!event.target) return; + if (event.target.closest("#toast-close-btn")) { + clearTimeout(timer); + toastAlert?.remove(); + } + }); +}; diff --git a/src/view-models/CartViewModel.js b/src/view-models/CartViewModel.js new file mode 100644 index 00000000..545ff9af --- /dev/null +++ b/src/view-models/CartViewModel.js @@ -0,0 +1,16 @@ +/** + * @typedef {import('../types.js').CartItem} CartItem + */ + +export class CartViewModel { + /** + * @param {CartItem[]} cart + */ + constructor(cart = []) { + this.cart = cart; + } + + getTotalCount() { + return this.cart.reduce((acc, item) => acc + item.count, 0); + } +} diff --git a/src/view-models/CategoryViewModel.js b/src/view-models/CategoryViewModel.js new file mode 100644 index 00000000..6a602e21 --- /dev/null +++ b/src/view-models/CategoryViewModel.js @@ -0,0 +1,112 @@ +// @ts-check + +/** + * @typedef {import('../types.js').CategoryTreeNode} CategoryTreeNode + */ + +/** + * @typedef {Object} CategoryOption + * @property {string} value + * @property {string} label + * @property {boolean} selected + */ + +export class CategoryViewModel { + /** + * @param {CategoryTreeNode[]} categories + * @param {string} [selectedCategory1=''] + * @param {string} [selectedCategory2=''] + */ + constructor(categories, selectedCategory1 = "", selectedCategory2 = "") { + this.categories = categories; + this.selectedCategory1 = selectedCategory1; + this.selectedCategory2 = selectedCategory2; + } + + /** + * @returns {CategoryOption[]} + */ + getFirstDepthOptions() { + return this.categories.map((cat) => ({ + value: cat.categoryId, + label: cat.categoryId, + selected: cat.categoryId === this.selectedCategory1, + })); + } + + /** + * @returns {CategoryOption[]} + */ + getSecondDepthOptions() { + if (!this.selectedCategory1) return []; + + const selectedCat1 = this.categories.find((cat) => cat.categoryId === this.selectedCategory1); + + if (!selectedCat1 || !selectedCat1.children.length) return []; + + return selectedCat1.children.map((cat) => ({ + value: cat.categoryId, + label: cat.categoryId, + selected: cat.categoryId === this.selectedCategory2, + })); + } + + // /** + // * @returns {string[]} + // */ + // getBreadcrumb() { + // const breadcrumb = []; + // if (this.selectedCategory1) breadcrumb.push(this.selectedCategory1); + // if (this.selectedCategory2) breadcrumb.push(this.selectedCategory2); + // return breadcrumb; + // } + + // /** + // * @returns {{category1: string, category2: string}} + // */ + // getFilters() { + // return { + // category1: this.selectedCategory1, + // category2: this.selectedCategory2, + // }; + // } + + // /** + // * @param {string} category1 + // * @returns {CategoryViewModel} + // */ + // selectCategory1(category1) { + // console.log("selectCategory1", category1); + // return new CategoryViewModel(this.categories, category1, ""); + // } + + // /** + // * @param {string} category2 + // * @returns {CategoryViewModel} + // */ + // selectCategory2(category2) { + // return new CategoryViewModel(this.categories, this.selectedCategory1, category2); + // } + + // /** + // * @returns {CategoryViewModel} + // */ + // reset() { + // return new CategoryViewModel(this.categories, "", ""); + // } + + // /** + // * @returns {boolean} + // */ + // hasSecondDepth() { + // return this.getSecondDepthOptions().length > 0; + // } + + // /** + // * @returns {string} + // */ + // getDisplayText() { + // const breadcrumb = this.getBreadcrumb(); + // return breadcrumb.length > 0 ? breadcrumb.join(" > ") : "전체"; + // } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..dccdb2a0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + // JavaScript 파일 검사 활성화 + "allowJs": true, + "checkJs": true, + + // 타입 체크 수준 + // "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + + // 모듈 시스템 + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ES2022", + + // 경로 설정 + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + + // 빌드 없이 타입 체크만 + "noEmit": true, + + // JSX (필요시) + "jsx": "preserve", + + // 기타 + "skipLibCheck": true, + "resolveJsonModule": true, + "esModuleInterop": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.spec.js"] +} diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 00000000..645cbc7f --- /dev/null +++ b/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; + +export default defineConfig(({ mode }) => ({ + base: mode === "production" ? "/front_7th_chapter2-1/" : "/", + build: { + outDir: "dist", + }, +}));