diff --git a/README.md b/README.md index 3eaeec280..b3bada84f 100644 --- a/README.md +++ b/README.md @@ -1 +1,38 @@ -# react-deploy \ No newline at end of file +# react-deploy +FE 카카오 선물하기 6주차 과제: 배포 & 협업 +### 🌱 1단계 - API 명세 협의 & 반영 +작성한 API 문서를 기반으로 팀 내에서 지금까지 만든 API를 검토하고 통일하여 변경 사항을 반영 +- 팀 내에서 일관된 기준을 정하여 API 명세를 결정 +- 때로는 클라이언트의 편의를 위해 API 명세를 결정하는 것이 좋음 +

+- [X] 팀 내에 배포될 API가 여러 개일 경우 상단 내비게이션 바에서 선택 가능 + - 프론트엔드의 경우 사용자가 팀 내 여러 서버 중 하나를 선택하여 서비스를 이용 + - [X] 팀 내 백엔드 엔지니어의 이름을 넣고, 이름을 선택하면 해당 엔지니어의 API로 API 통신을 하도록 구현 + - [X] 기본 선택은 제일 첫 번째 이름 +- [X] 백엔드에서 협의된 API를 배포하기 전까지는 MSW로 동작 가능하도록 구현 + +### 🌿 2단계 - 배포하기 +- github pages를 사용하여 배포 + +### 🪴 3단계 - 포인트 +- 포인트 기능 구현 +- API 명세는 팀과 협의하여 결정하고 구현

+ + - **포인트 조회**
+ 해당하는 멤버의 포인트를 리턴
+ **URL** : /api/member/point
+ **Request** : Header: Authorization: Bearer {token}
+ **Response** : 200 OK { "point": "number" }
+ +### 🌳 4단계 - 질문에 대한 답변 +**질문 1.** SPA 페이지를 정적 배포를 하려고 할 때 Vercel을 사용하지 않고 한다면 어떻게 할 수 있을까요? + +> 리포지토리에 SPA 페이지를 업로드하고 GitHub Pages를 설정하는 방법이나, AWS S3에 프로젝트를 올리고 CloudFront을 CDN으로 사용하는 방법을 사용해 배포할 수 있습니다. + +**질문 2.** CSRF나 XSS 공격을 막는 방법은 무엇일까요? + +> CSRF는 서버에서 생성한 토큰을 사용해 요청을 검증하거나, 쿠키에 SameSite 속성을 설정해 동일 출처에서만 쿠키가 전송되도록 하여 방어할 수 있습니다. XSS는 CSP(콘텐츠 보안 정책)을 설정하거나, HTML 인코딩을 사용하여 <, >와 같은 문자를 안전한 형식으로 변환하는 등 사용자 데이터를 적절히 인코딩하여 방어할 수 있습니다. + +**질문 3.** 브라우저 렌더링 원리에 대해 설명해주세요. + +> 브라우저는 서버로부터 HTML 파일을 받아 파싱하여 DOM 트리를 생성하고, CSS 파일을 파싱하여 CSSOM 트리가 생성합니다. DOM 트리와 CSSOM 트리가 결합하여 렌더 트리가 형성됩니다. 이후 레이아웃과 페인팅 과정을 거치면서 각 요소의 위치와 크기가 정확한 좌표로 변환되고, 배경색, 이미지 등이 화면에 그려집니다. 여러 레이어가 겹쳐지는 경우 브라우저는 각 레이어를 별도로 페인팅하고 최종적으로 이를 합성하여 최종 이미지를 생성하여 화면에 표시합니다. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0609bb9c3..48a09e167 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "@emotion/styled": "^11.11.0", "@tanstack/react-query": "^5.24.1", "axios": "^1.6.7", + "dotenv": "^16.4.5", "framer-motion": "^11.0.6", + "gh-pages": "^6.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.12", @@ -10949,6 +10951,15 @@ "node": ">=8" } }, + "node_modules/array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/array.prototype.filter": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/array.prototype.filter/-/array.prototype.filter-1.0.3.tgz", @@ -11126,8 +11137,7 @@ "node_modules/async": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", - "dev": true + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" }, "node_modules/async-limiter": { "version": "1.0.1", @@ -11834,8 +11844,7 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/base64-js": { "version": "1.5.1", @@ -12743,8 +12752,7 @@ "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "dev": true + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==" }, "node_modules/compressible": { "version": "2.0.18", @@ -12814,8 +12822,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/concat-stream": { "version": "1.6.2", @@ -14204,7 +14211,7 @@ "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", - "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -14309,6 +14316,12 @@ "integrity": "sha512-1PpuqJUFWoXZ1E54m8bsLPVYwIVCRzvaL+n5cjigGga4z854abDnFRc+cTa2th4S79kyGqya/1xoR7h+Y5G5lg==", "dev": true }, + "node_modules/email-addresses": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/email-addresses/-/email-addresses-5.0.0.tgz", + "integrity": "sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==", + "license": "MIT" + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -16732,6 +16745,32 @@ "node": ">=10" } }, + "node_modules/filename-reserved-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz", + "integrity": "sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/filenamify": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-4.3.0.tgz", + "integrity": "sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==", + "license": "MIT", + "dependencies": { + "filename-reserved-regex": "^2.0.0", + "strip-outer": "^1.0.1", + "trim-repeated": "^1.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/filesize": { "version": "8.0.7", "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", @@ -16790,7 +16829,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", - "dev": true, "dependencies": { "commondir": "^1.0.1", "make-dir": "^3.0.2", @@ -16807,7 +16845,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -16820,7 +16857,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, "dependencies": { "p-locate": "^4.1.0" }, @@ -16832,7 +16868,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, "dependencies": { "p-try": "^2.0.0" }, @@ -16847,7 +16882,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, "dependencies": { "p-limit": "^2.2.0" }, @@ -16859,7 +16893,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, "dependencies": { "find-up": "^4.0.0" }, @@ -17247,7 +17280,6 @@ "version": "11.2.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", - "dev": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -17296,8 +17328,7 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.3", @@ -17487,6 +17518,108 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gh-pages": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gh-pages/-/gh-pages-6.1.1.tgz", + "integrity": "sha512-upnohfjBwN5hBP9w2dPE7HO5JJTHzSGMV1JrLrHvNuqmjoYHg6TBrCcnEoorjG/e0ejbuvnwyKMdTyM40PEByw==", + "license": "MIT", + "dependencies": { + "async": "^3.2.4", + "commander": "^11.0.0", + "email-addresses": "^5.0.0", + "filenamify": "^4.3.0", + "find-cache-dir": "^3.3.1", + "fs-extra": "^11.1.1", + "globby": "^6.1.0" + }, + "bin": { + "gh-pages": "bin/gh-pages.js", + "gh-pages-clean": "bin/gh-pages-clean.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gh-pages/node_modules/array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", + "license": "MIT", + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gh-pages/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/gh-pages/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/gh-pages/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gh-pages/node_modules/globby": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz", + "integrity": "sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==", + "license": "MIT", + "dependencies": { + "array-union": "^1.0.1", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gh-pages/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/giget": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.1.tgz", @@ -17658,8 +17791,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -18367,7 +18499,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -18376,8 +18507,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "1.3.8", @@ -23203,7 +23333,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, "dependencies": { "universalify": "^2.0.0" }, @@ -23606,7 +23735,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, "dependencies": { "semver": "^6.0.0" }, @@ -24692,7 +24820,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -24898,7 +25025,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "engines": { "node": ">=6" } @@ -25002,7 +25128,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -25011,7 +25136,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -25141,7 +25265,27 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "license": "MIT", + "dependencies": { + "pinkie": "^2.0.0" + }, "engines": { "node": ">=0.10.0" } @@ -30070,7 +30214,6 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "bin": { "semver": "bin/semver.js" } @@ -31062,6 +31205,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-outer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz", + "integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-outer/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/style-inject": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/style-inject/-/style-inject-0.3.0.tgz", @@ -31947,6 +32111,27 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "dev": true }, + "node_modules/trim-repeated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", + "integrity": "sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/trim-repeated/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/tryer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", @@ -32568,7 +32753,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "engines": { "node": ">= 10.0.0" } @@ -34044,8 +34228,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/write-file-atomic": { "version": "4.0.2", diff --git a/package.json b/package.json index 7e5bdc463..4998ab257 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "build": "craco build", "test": "craco test --transformIgnorePatterns \"node_modules/(?!axios)/\"", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "predeploy": "npm run build", + "deploy": "gh-pages -d build" }, "browserslist": { "production": [ @@ -29,7 +31,9 @@ "@emotion/styled": "^11.11.0", "@tanstack/react-query": "^5.24.1", "axios": "^1.6.7", + "dotenv": "^16.4.5", "framer-motion": "^11.0.6", + "gh-pages": "^6.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.12", @@ -89,5 +93,6 @@ }, "overrides": { "react-refresh": "0.11.0" - } + }, + "homepage": "https://KimJi-An.github.io/react-deploy" } diff --git a/src/App.tsx b/src/App.tsx index 24715e671..45491ffb1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { ChakraProvider } from '@chakra-ui/react'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from './api/instance'; +import { ApiProvider } from './provider/Api'; import { AuthProvider } from './provider/Auth'; import { Routes } from './routes'; @@ -9,9 +10,11 @@ const App = () => { return ( - - - + + + + + ); diff --git a/src/api/auth.ts b/src/api/auth.ts index 42791565a..b945f1d2a 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,7 +1,7 @@ import { fetchInstance } from './instance'; export const register = async (email: string, password: string) => { - const response = await fetchInstance.post('/api/members/register', { + const response = await fetchInstance.post(`/api/members/register`, { email, password, }); @@ -9,7 +9,7 @@ export const register = async (email: string, password: string) => { }; export const login = async (email: string, password: string) => { - const response = await fetchInstance.post('/api/members/login', { + const response = await fetchInstance.post(`/api/members/login`, { email, password, }); diff --git a/src/api/hooks/auth.mock.ts b/src/api/hooks/auth.mock.ts index 4853553fe..7094dce07 100644 --- a/src/api/hooks/auth.mock.ts +++ b/src/api/hooks/auth.mock.ts @@ -3,21 +3,29 @@ import { rest } from 'msw'; const VALID_EMAIL = 'test@example.com'; const VALID_PASSWORD = 'password1234'; -export const authMockHandler = [ - rest.post('https://api.example.com/api/members/register', async (req, res, ctx) => { - const { email, password } = await req.json<{ email: string; password: string }>(); +type RegisterResponse = { + message: string; +} - if (!email || !password) { - return res(ctx.status(400), ctx.json({ message: 'Invalid input' })); - } +type LoginRequest = { + email: string; + password: string; +} - return res(ctx.status(201), ctx.json({ email, token: 'fake-token' })); +type LoginResponse = { + token: string; +} + +export const authMockHandler = [ + rest.post(`/api/members/register`, async (_, res, ctx) => { + alert('회원가입이 완료되었습니다.'); + return res(ctx.status(201), ctx.json({ message: 'User registered successfully' })); }), - rest.post('https://api.example.com/api/members/login', async (req, res, ctx) => { - const { email, password } = await req.json<{ email: string; password: string }>(); + rest.post('https://api.example.com/api/members/login', async (req, res, ctx) => { + const { email, password } = await req.json(); if (email === VALID_EMAIL && password === VALID_PASSWORD) { - return res(ctx.status(200), ctx.json({ email, token: 'fake-token' })); + return res(ctx.status(200), ctx.json({ token: 'fake-token' })); } return res(ctx.status(403), ctx.json({ message: 'Invalid email or password' })); diff --git a/src/api/hooks/categories.mock.ts b/src/api/hooks/categories.mock.ts index f8d65843c..5ff47d3f0 100644 --- a/src/api/hooks/categories.mock.ts +++ b/src/api/hooks/categories.mock.ts @@ -2,13 +2,21 @@ import { rest } from 'msw'; import { getCategoriesPath } from './useGetCategorys'; +type Category = { + id: number; + name: string; + description: string; + color: string; + imageUrl: string; +} + export const categoriesMockHandler = [ rest.get(getCategoriesPath(), (_, res, ctx) => { - return res(ctx.json(CATEGORIES_RESPONSE_DATA)); + return res(ctx.status(200), ctx.json(CATEGORIES_RESPONSE_DATA)); }), ]; -const CATEGORIES_RESPONSE_DATA = [ +const CATEGORIES_RESPONSE_DATA: Category[] = [ { id: 2920, name: '생일', diff --git a/src/api/hooks/points.mock.ts b/src/api/hooks/points.mock.ts new file mode 100644 index 000000000..69ba17487 --- /dev/null +++ b/src/api/hooks/points.mock.ts @@ -0,0 +1,16 @@ +import { rest } from 'msw'; + +type PointResponse = { + point: number; +} + +export const pointMockHandlers = [ + rest.get('https://api.example.com/api/member/point', (req, res, ctx) => { + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return res(ctx.status(401)); + } + + return res(ctx.status(200), ctx.json({ point: 1000 })); + }), +]; diff --git a/src/api/hooks/products.mock.ts b/src/api/hooks/products.mock.ts index 6cef11235..1f6a72b47 100644 --- a/src/api/hooks/products.mock.ts +++ b/src/api/hooks/products.mock.ts @@ -4,13 +4,47 @@ import { getProductDetailPath } from './useGetProductDetail'; import { getProductOptionsPath } from './useGetProductOptions'; import { getProductsPath } from './useGetProducts'; +type Product = { + id: number; + name: string; + imageUrl: string; + price: number; + categoryId: string; +} + +type Pageable = { + offset: number; + pageNumber: number; + pageSize: number; + unpaged: boolean; + paged: boolean; + sort: { + empty: boolean; + unsorted: boolean; + sorted: boolean; + }; + last: boolean; + totalPages: number; + totalElements: number; + size: number; + number: number; + first: boolean; + numberOfElements: number; + empty: boolean; +} + +type ProductsResponse = { + content: Product[]; + pageable: Pageable; +} + export const productsMockHandler = [ rest.get( getProductsPath({ categoryId: '2920', }), (_, res, ctx) => { - return res(ctx.json(PRODUCTS_MOCK_DATA)); + return res(ctx.status(200), ctx.json(PRODUCTS_MOCK_DATA)); }, ), rest.get( @@ -18,33 +52,32 @@ export const productsMockHandler = [ categoryId: '2930', }), (_, res, ctx) => { - return res(ctx.json(PRODUCTS_MOCK_DATA)); + return res(ctx.status(200), ctx.json(PRODUCTS_MOCK_DATA)); }, ), rest.get(getProductDetailPath(':productId'), (_, res, ctx) => { - return res(ctx.json(PRODUCTS_MOCK_DATA.content[0])); + return res(ctx.status(200), ctx.json(PRODUCTS_MOCK_DATA.content[0])); }), rest.get(getProductOptionsPath(':productId'), (_, res, ctx) => { return res( + ctx.status(200), ctx.json([ { id: 1, name: 'Option A', quantity: 10, - productId: 1, }, { id: 2, name: 'Option B', quantity: 20, - productId: 1, }, ]), ); }), ]; -const PRODUCTS_MOCK_DATA = { +const PRODUCTS_MOCK_DATA: ProductsResponse = { content: [ { id: 3245119, @@ -52,6 +85,7 @@ const PRODUCTS_MOCK_DATA = { imageUrl: 'https://st.kakaocdn.net/product/gift/product/20240215083306_8e1db057580145829542463a84971ae3.png', price: 145000, + categoryId: '2920', }, { id: 2263833, @@ -59,6 +93,7 @@ const PRODUCTS_MOCK_DATA = { imageUrl: 'https://st.kakaocdn.net/product/gift/product/20200513102805_4867c1e4a7ae43b5825e9ae14e2830e3.png', price: 100000, + categoryId: '2920', }, { id: 6502823, @@ -66,6 +101,7 @@ const PRODUCTS_MOCK_DATA = { imageUrl: 'https://st.kakaocdn.net/product/gift/product/20240215112140_11f857e972bc4de6ac1d2f1af47ce182.jpg', price: 108000, + categoryId: '2920', }, { id: 1181831, @@ -73,6 +109,7 @@ const PRODUCTS_MOCK_DATA = { imageUrl: 'https://st.kakaocdn.net/product/gift/product/20240214150740_ad25267defa64912a7c030a7b57dc090.jpg', price: 122000, + categoryId: '2920', }, { id: 1379982, @@ -80,10 +117,27 @@ const PRODUCTS_MOCK_DATA = { imageUrl: 'https://st.kakaocdn.net/product/gift/product/20240118135914_a6e1a7442ea04aa49add5e02ed62b4c3.jpg', price: 133000, + categoryId: '2920', }, ], - number: 0, - totalElements: 5, - size: 10, - last: true, + pageable: { + offset: 0, + pageNumber: 0, + pageSize: 10, + unpaged: false, + paged: true, + sort: { + empty: false, + unsorted: true, + sorted: false, + }, + last: true, + totalPages: 1, + totalElements: 5, + size: 10, + number: 0, + first: true, + numberOfElements: 5, + empty: false, + }, }; diff --git a/src/api/hooks/useGetCategorys.ts b/src/api/hooks/useGetCategorys.ts index d93e4fc95..acb2b6997 100644 --- a/src/api/hooks/useGetCategorys.ts +++ b/src/api/hooks/useGetCategorys.ts @@ -2,11 +2,11 @@ import { useQuery } from '@tanstack/react-query'; import type { CategoryData } from '@/types'; -import { BASE_URL, fetchInstance } from '../instance'; +import { fetchInstance } from '../instance'; export type CategoryResponseData = CategoryData[]; -export const getCategoriesPath = () => `${BASE_URL}/api/categories`; +export const getCategoriesPath = () => `/api/categories`; const categoriesQueryKey = [getCategoriesPath()]; export const getCategories = async () => { diff --git a/src/api/hooks/useGetPoints.ts b/src/api/hooks/useGetPoints.ts new file mode 100644 index 000000000..c6e128438 --- /dev/null +++ b/src/api/hooks/useGetPoints.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; + +import { fetchInstance } from '../instance'; + +export const useGetPoints = () => { + const [points, setPoints] = useState(() => { + const savedPoints = sessionStorage.getItem('points'); + return savedPoints ? parseInt(savedPoints, 10) : 0; + }); + + useEffect(() => { + if (!sessionStorage.getItem('points')) { + const fetchPoints = async () => { + try { + const response = await fetchInstance.get('/api/member/point'); + setPoints(response.data.point); + sessionStorage.setItem('points', response.data.point); + } catch (error) { + console.error('Failed to fetch points', error); + } + }; + fetchPoints(); + } + }, []); + + return points; +}; diff --git a/src/api/hooks/useGetProductDetail.ts b/src/api/hooks/useGetProductDetail.ts index 539de0196..5d0e92df9 100644 --- a/src/api/hooks/useGetProductDetail.ts +++ b/src/api/hooks/useGetProductDetail.ts @@ -2,7 +2,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import type { ProductData } from '@/types'; -import { BASE_URL, fetchInstance } from '../instance'; +import { fetchInstance } from '../instance'; export type ProductDetailRequestParams = { productId: string; @@ -12,7 +12,7 @@ type Props = ProductDetailRequestParams; export type GoodsDetailResponseData = ProductData; -export const getProductDetailPath = (productId: string) => `${BASE_URL}/api/products/${productId}`; +export const getProductDetailPath = (productId: string) => `/api/products/${productId}`; export const getProductDetail = async (params: ProductDetailRequestParams) => { const response = await fetchInstance.get( diff --git a/src/api/hooks/useGetProductOptions.ts b/src/api/hooks/useGetProductOptions.ts index a3bdc538f..dda023a2c 100644 --- a/src/api/hooks/useGetProductOptions.ts +++ b/src/api/hooks/useGetProductOptions.ts @@ -2,15 +2,14 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import type { ProductOptionsData } from '@/types'; -import { BASE_URL, fetchInstance } from '../instance'; +import { fetchInstance } from '../instance'; import type { ProductDetailRequestParams } from './useGetProductDetail'; type Props = ProductDetailRequestParams; export type ProductOptionsResponseData = ProductOptionsData[]; -export const getProductOptionsPath = (productId: string) => - `${BASE_URL}/api/products/${productId}/options`; +export const getProductOptionsPath = (productId: string) => `/api/products/${productId}/options`; export const getProductOptions = async (params: ProductDetailRequestParams) => { const response = await fetchInstance.get( diff --git a/src/api/hooks/useGetProducts.ts b/src/api/hooks/useGetProducts.ts index 432f90d93..d917d43fe 100644 --- a/src/api/hooks/useGetProducts.ts +++ b/src/api/hooks/useGetProducts.ts @@ -6,7 +6,6 @@ import { import type { ProductData } from '@/types'; -import { BASE_URL } from '../instance'; import { fetchInstance } from './../instance/index'; type RequestParams = { @@ -26,10 +25,31 @@ type ProductsResponseData = { type ProductsResponseRawData = { content: ProductData[]; - number: number; + pageable: { + offset: number; + sort: { + empty: boolean; + unsorted: boolean; + sorted: boolean; + }; + unpaged: boolean; + paged: boolean; + pageSize: number; + pageNumber: number; + }; + last: boolean; + totalPages: number; totalElements: number; size: number; - last: boolean; + number: number; + sort: { + empty: boolean; + unsorted: boolean; + sorted: boolean; + }; + first: boolean; + numberOfElements: number; + empty: boolean; }; export const getProductsPath = ({ categoryId, pageToken, maxResults }: RequestParams) => { @@ -40,7 +60,7 @@ export const getProductsPath = ({ categoryId, pageToken, maxResults }: RequestPa if (pageToken) params.append('page', pageToken); if (maxResults) params.append('size', maxResults.toString()); - return `${BASE_URL}/api/products?${params.toString()}`; + return `/api/products?${params.toString()}`; }; export const getProducts = async (params: RequestParams): Promise => { diff --git a/src/api/hooks/useGetWishlist.ts b/src/api/hooks/useGetWishlist.ts new file mode 100644 index 000000000..5256040df --- /dev/null +++ b/src/api/hooks/useGetWishlist.ts @@ -0,0 +1,93 @@ +import { + type InfiniteData, + useInfiniteQuery, + type UseInfiniteQueryResult, +} from '@tanstack/react-query'; +import axios from 'axios'; + +import { useApi } from '@/provider/Api'; +import { useAuth } from '@/provider/Auth'; + +type WishlistItem = { + id: number; + name: string; + price: number; + imageUrl: string; +}; + +type WishlistResponseData = { + content: WishlistItem[]; + pageable: { + sort: { + sorted: boolean; + empty: boolean; + }; + pageNumber: number; + pageSize: number; + offset: number; + unpaged: boolean; + paged: boolean; + }; + totalPages: number; + totalElements: number; + last: boolean; + number: number; + size: number; + numberOfElements: number; + first: boolean; + empty: boolean; +}; + +type RequestParams = { + pageToken?: string; + maxResults?: number; +}; + +export const getWishlistPath = ({ pageToken, maxResults }: RequestParams, apiUrl: string) => { + const params = new URLSearchParams(); + params.append('sort', 'createdDate,desc'); + if (pageToken) params.append('page', pageToken); + if (maxResults) params.append('size', maxResults.toString()); + + return `${apiUrl}api/wishes?${params.toString()}`; +}; + +export const getWishlist = async ( + params: RequestParams, + token: string, + apiUrl: string, +): Promise => { + const url = getWishlistPath(params, apiUrl); + + const response = await axios.get(url, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${JSON.parse(token).token}`, + }, + }); + + return response.data; +}; + +export const useGetWishlist = ({ + maxResults = 10, + initPageToken, +}: { + maxResults?: number; + initPageToken?: string; +}): UseInfiniteQueryResult> => { + const authInfo = useAuth(); + const { apiUrl } = useApi(); + + return useInfiniteQuery({ + queryKey: ['wishlist', maxResults, initPageToken], + queryFn: async ({ pageParam = initPageToken }) => { + if (!authInfo?.token) { + throw new Error('Authentication token is missing'); + } + return getWishlist({ pageToken: pageParam, maxResults }, JSON.parse(authInfo.token).token, apiUrl); + }, + initialPageParam: initPageToken, + getNextPageParam: (lastPage) => (lastPage.last ? undefined : (lastPage.number + 1).toString()), + }); +}; diff --git a/src/api/hooks/wishlist.mock.ts b/src/api/hooks/wishlist.mock.ts index fa1d155c3..3ac4e7703 100644 --- a/src/api/hooks/wishlist.mock.ts +++ b/src/api/hooks/wishlist.mock.ts @@ -1,67 +1,213 @@ import { rest } from 'msw'; +type Product = { + id: number; + name: string; + price: number; + imageUrl: string; +} + +type WishlistItem = { + id: number; + product: Product; +} + +type PageableResponse = { + content: T[]; + totalPages: number; + totalElements: number; + number: number; + size: number; + first: boolean; + last: boolean; +} + +const WISHLIST_MOCK_DATA: WishlistItem[] = [ + { + id: 1, + product: { + id: 1, + name: 'Product A', + price: 100, + imageUrl: + 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Ft1.daumcdn.net%2Fgift%2Fhome%2Ftheme%2F292020231106_MXMUB.png', + }, + }, + { + id: 2, + product: { + id: 2, + name: 'Product B', + price: 150, + imageUrl: + 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Ft1.daumcdn.net%2Fgift%2Fhome%2Ftheme%2F292020231106_MXMUB.png', + }, + }, + { + id: 3, + product: { + id: 3, + name: 'Product C', + price: 150, + imageUrl: + 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Ft1.daumcdn.net%2Fgift%2Fhome%2Ftheme%2F292020231106_MXMUB.png', + }, + }, + { + id: 4, + product: { + id: 4, + name: 'Product D', + price: 150, + imageUrl: + 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Ft1.daumcdn.net%2Fgift%2Fhome%2Ftheme%2F292020231106_MXMUB.png', + }, + }, + { + id: 5, + product: { + id: 5, + name: 'Product E', + price: 150, + imageUrl: + 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Ft1.daumcdn.net%2Fgift%2Fhome%2Ftheme%2F292020231106_MXMUB.png', + }, + }, + { + id: 6, + product: { + id: 6, + name: 'Product F', + price: 150, + imageUrl: + 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Ft1.daumcdn.net%2Fgift%2Fhome%2Ftheme%2F292020231106_MXMUB.png', + }, + }, + { + id: 7, + product: { + id: 7, + name: 'Product G', + price: 150, + imageUrl: + 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Ft1.daumcdn.net%2Fgift%2Fhome%2Ftheme%2F292020231106_MXMUB.png', + }, + }, + { + id: 8, + product: { + id: 8, + name: 'Product H', + price: 150, + imageUrl: + 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Ft1.daumcdn.net%2Fgift%2Fhome%2Ftheme%2F292020231106_MXMUB.png', + }, + }, + { + id: 9, + product: { + id: 9, + name: 'Product I', + price: 150, + imageUrl: + 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Ft1.daumcdn.net%2Fgift%2Fhome%2Ftheme%2F292020231106_MXMUB.png', + }, + }, + { + id: 10, + product: { + id: 10, + name: 'Product J', + price: 150, + imageUrl: + 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Ft1.daumcdn.net%2Fgift%2Fhome%2Ftheme%2F292020231106_MXMUB.png', + }, + }, + { + id: 11, + product: { + id: 11, + name: 'Product K', + price: 150, + imageUrl: + 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Ft1.daumcdn.net%2Fgift%2Fhome%2Ftheme%2F292020231106_MXMUB.png', + }, + }, +]; + +const getPagedData = (page: number, size: number) => { + const start = page * size; + const end = start + size; + return WISHLIST_MOCK_DATA.slice(start, end); +}; + export const wishlistMockHandler = [ - rest.get('/api/wishes', async (req, res, ctx) => { - const token = req.headers.get('Authorization'); + rest.get>('/api/wishes', (req, res, ctx) => { + const page = parseInt(req.url.searchParams.get('page') || '0', 10); + const size = parseInt(req.url.searchParams.get('size') || '10', 10); - if (!token) { - return res(ctx.status(401), ctx.json({ message: 'Invalid or missing token' })); - } + const pagedContent = getPagedData(page, size); + const totalElements = WISHLIST_MOCK_DATA.length; + const totalPages = Math.ceil(totalElements / size); - return res(ctx.status(200), ctx.json(WISHLIST_MOCK_DATA)); + return res( + ctx.status(200), + ctx.json({ + content: pagedContent.map(item => ({ + id: item.id, + name: item.product.name, + price: item.product.price, + imageUrl: item.product.imageUrl, + })), + totalPages: totalPages, + totalElements: totalElements, + number: page, + size: size, + first: page === 0, + last: page === totalPages - 1, + }) + ); }), rest.delete('/api/wishes/:wishId', (req, res, ctx) => { const { wishId } = req.params; - const wishIndex = WISHLIST_MOCK_DATA.content.findIndex((item) => item.id === Number(wishId)); - if (wishIndex !== -1) { - WISHLIST_MOCK_DATA.content.splice(wishIndex, 1); - return res(ctx.status(204)); + const index = WISHLIST_MOCK_DATA.findIndex(item => item.id.toString() === wishId); + + if (index === -1) { + return res(ctx.status(404), ctx.json({ message: 'Wish not found' })); } - return res(ctx.status(404), ctx.json({ message: 'Wish not found' })); + + WISHLIST_MOCK_DATA.splice(index, 1); + return res(ctx.status(204)); }), -]; + rest.post(`/api/wishes/:productId`, (req, res, ctx) => { + const { productId } = req.params; + const token = req.headers.get('Authorization'); -const WISHLIST_MOCK_DATA = { - content: [ - { - id: 1, - product: { - id: 1, - name: 'Product A', - price: 100, - imageUrl: - 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Ft1.daumcdn.net%2Fgift%2Fhome%2Ftheme%2F292020231106_MXMUB.png', - }, - }, - { - id: 2, + if (!token) { + return res(ctx.status(401), ctx.json({ message: 'Invalid or missing token' })); + } + + const existingWish = WISHLIST_MOCK_DATA.find( + (item) => item.product.id === Number(productId), + ); + + if (existingWish) { + return res(ctx.status(400), ctx.json({ message: 'Product already in wishlist' })); + } + + const newProduct = { + id: WISHLIST_MOCK_DATA.length + 1, product: { - id: 2, - name: 'Product B', + id: Number(productId), + name: `Product ${String.fromCharCode(65 + WISHLIST_MOCK_DATA.length)}`, price: 150, imageUrl: 'https://img1.daumcdn.net/thumb/S104x104/?fname=https%3A%2F%2Ft1.daumcdn.net%2Fgift%2Fhome%2Ftheme%2F292020231106_MXMUB.png', }, - }, - ], - pageable: { - sort: { - sorted: true, - unsorted: false, - empty: false, - }, - pageNumber: 0, - pageSize: 10, - offset: 0, - unpaged: false, - paged: true, - }, - totalPages: 5, - totalElements: 50, - last: false, - number: 0, - size: 10, - numberOfElements: 2, - first: true, - empty: false, -}; + }; + + WISHLIST_MOCK_DATA.push(newProduct); + + return res(ctx.status(201), ctx.json(newProduct)); + }), +]; diff --git a/src/api/instance/index.ts b/src/api/instance/index.ts index b83ca1407..c301ec70b 100644 --- a/src/api/instance/index.ts +++ b/src/api/instance/index.ts @@ -1,26 +1,24 @@ import { QueryClient } from '@tanstack/react-query'; -import type { AxiosInstance, AxiosRequestConfig } from 'axios'; import axios from 'axios'; -const initInstance = (config: AxiosRequestConfig): AxiosInstance => { +import { backend } from '@/config/backendConfig'; + +const initInstance = () => { + const apiUrl = localStorage.getItem('apiUrl') || backend.backend3; + const instance = axios.create({ + baseURL: apiUrl, timeout: 5000, - ...config, headers: { Accept: 'application/json', 'Content-Type': 'application/json', - ...config.headers, }, }); return instance; }; -export const BASE_URL = 'https://api.example.com'; -// TODO: 추후 서버 API 주소 변경 필요 -export const fetchInstance = initInstance({ - baseURL: 'https://api.example.com', -}); +export const fetchInstance = initInstance(); export const queryClient = new QueryClient({ defaultOptions: { diff --git a/src/components/features/Goods/Detail/OptionSection.tsx b/src/components/features/Goods/Detail/OptionSection.tsx index 74a6b61da..c6bb400aa 100644 --- a/src/components/features/Goods/Detail/OptionSection.tsx +++ b/src/components/features/Goods/Detail/OptionSection.tsx @@ -8,6 +8,7 @@ import { } from '@/api/hooks/useGetProductDetail'; import { useGetProductOptions } from '@/api/hooks/useGetProductOptions'; import { Button } from '@/components/common/Button'; +import { useApi } from '@/provider/Api'; import { useAuth } from '@/provider/Auth'; import { getDynamicPath, RouterPath } from '@/routes/path'; import { orderHistorySessionStorage } from '@/utils/storage'; @@ -19,7 +20,7 @@ type Props = ProductDetailRequestParams; export const OptionSection = ({ productId }: Props) => { const { data: detail } = useGetProductDetail({ productId }); const { data: options } = useGetProductOptions({ productId }); - + const { apiUrl } = useApi(); const [countAsString, setCountAsString] = useState('1'); const totalPrice = useMemo(() => { return detail.price * Number(countAsString); @@ -47,14 +48,22 @@ export const OptionSection = ({ productId }: Props) => { const handleAddToWishlist = async () => { try { - await fetch('/api/wishes', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ productId }), - }); - alert('관심 등록 완료'); + const tokenString = sessionStorage.getItem('authToken'); + + if (tokenString) { + const token = JSON.parse(tokenString).token; + + await fetch(`${apiUrl}api/wishes/${productId}`, { + credentials: 'include', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ productId }), + }); + alert('관심 등록 완료'); + } } catch (error) { console.error(error); alert('관심 상품 등록에 실패했습니다.'); diff --git a/src/components/features/Layout/Header.tsx b/src/components/features/Layout/Header.tsx index eeac55071..9fbb2466e 100644 --- a/src/components/features/Layout/Header.tsx +++ b/src/components/features/Layout/Header.tsx @@ -2,17 +2,24 @@ import styled from '@emotion/styled'; import { Link, useNavigate } from 'react-router-dom'; import { Container } from '@/components/common/layouts/Container'; +import { backend } from '@/config/backendConfig'; +import { useApi } from '@/provider/Api'; import { useAuth } from '@/provider/Auth'; import { getDynamicPath, RouterPath } from '@/routes/path'; export const Header = () => { const navigate = useNavigate(); const authInfo = useAuth(); + const { apiUrl, setApiUrl } = useApi(); const handleLogin = () => { navigate(getDynamicPath.login()); }; + const handleApiChange = (event: React.ChangeEvent) => { + setApiUrl(event.target.value); + }; + return ( @@ -23,6 +30,11 @@ export const Header = () => { /> + + + + + {authInfo ? ( navigate(RouterPath.myAccount)}>내 계정 ) : ( @@ -49,7 +61,13 @@ export const Wrapper = styled.header` const Logo = styled.img` height: ${HEADER_HEIGHT}; `; -const RightWrapper = styled.div``; +const RightWrapper = styled.div` + width: 100%; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 20px; +`; const LinkButton = styled.p` align-items: center; @@ -58,3 +76,17 @@ const LinkButton = styled.p` text-decoration: none; cursor: pointer; `; + +const ApiSelector = styled.select` + width: 200px; + height: 40px; + border: 2px solid #444; + border-radius: 4px; + padding: 5px 10px; + font-size: 16px; + cursor: pointer; + + &:hover { + border-color: #888; + } +`; diff --git a/src/components/features/Wishlist/index.tsx b/src/components/features/Wishlist/index.tsx index 9227cc3a3..b5e590abf 100644 --- a/src/components/features/Wishlist/index.tsx +++ b/src/components/features/Wishlist/index.tsx @@ -1,52 +1,44 @@ import { Box, Button, Heading, List, ListItem, Text } from '@chakra-ui/react'; import { useEffect, useState } from 'react'; +import { useGetWishlist } from '@/api/hooks/useGetWishlist'; +import { VisibilityLoader } from '@/components/common/VisibilityLoader'; +import { useApi } from '@/provider/Api'; import { useAuth } from '@/provider/Auth'; type WishlistItem = { id: number; - product: { - id: number; - name: string; - price: number; - imageUrl: string; - }; + name: string; + price: number; + imageUrl: string; }; export const Wishlist = () => { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useGetWishlist({ + maxResults: 10, + }); const authInfo = useAuth(); - const [wishlist, setWishList] = useState([]); + const { apiUrl } = useApi(); + const [wishlist, setWishlist] = useState([]); useEffect(() => { - const fetchData = async () => { - if (authInfo?.token) { - try { - const response = await fetch('/api/wishes', { - headers: { - Authorization: `Bearer ${authInfo.token}`, - }, - }); - const wishData = await response.json(); - setWishList(wishData.content); - } catch (error) { - console.error(error); - } - } - }; - - fetchData(); - }, [authInfo]); + if (data) { + const flattenWishlist = data.pages.flatMap((page) => page.content).flat(); + setWishlist(flattenWishlist); + } + }, [data]); const handleDelete = async (wishId: number) => { if (authInfo?.token) { - const response = await fetch(`/api/wishes/${wishId}`, { + const token = JSON.parse(authInfo.token).token; + const response = await fetch(`${apiUrl}api/wishes/${wishId}`, { method: 'DELETE', headers: { - Authorization: `Bearer ${authInfo.token}`, + Authorization: `Bearer ${token}`, }, }); if (response.status === 204) { - setWishList((prev) => prev.filter((item) => item.id !== wishId)); + setWishlist((prevWishlist) => prevWishlist.filter((item) => item.id !== wishId)); } else { alert('삭제 중 오류가 발생했습니다.'); } @@ -67,12 +59,12 @@ export const Wishlist = () => { - {item.product.name} + {item.name} - {item.product.name} + {item.name} - {item.product.price}원 + {item.price}원 @@ -81,6 +73,15 @@ export const Wishlist = () => { ))} )} + {hasNextPage && ( + { + if (!isFetchingNextPage) { + fetchNextPage(); + } + }} + /> + )} ); diff --git a/src/config/backendConfig.ts b/src/config/backendConfig.ts new file mode 100644 index 000000000..45700b7e6 --- /dev/null +++ b/src/config/backendConfig.ts @@ -0,0 +1,5 @@ +export const backend: { [key: string]: string } = { + backend1: 'http://52.78.23.209:8080/', + backend2: 'http://3.38.169.232:8080/', + backend3: 'http://43.202.41.105:8080/', +}; diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts index d3454c161..8d06b1dd1 100644 --- a/src/mocks/browser.ts +++ b/src/mocks/browser.ts @@ -2,6 +2,7 @@ import { setupWorker } from 'msw'; import { authMockHandler } from '@/api/hooks/auth.mock'; import { categoriesMockHandler } from '@/api/hooks/categories.mock'; +import { pointMockHandlers } from '@/api/hooks/points.mock'; import { productsMockHandler } from '@/api/hooks/products.mock'; import { wishlistMockHandler } from '@/api/hooks/wishlist.mock'; @@ -10,4 +11,5 @@ export const worker = setupWorker( ...productsMockHandler, ...authMockHandler, ...wishlistMockHandler, + ...pointMockHandlers, ); diff --git a/src/mocks/server.ts b/src/mocks/server.ts index e4c6ea86a..3d22e3712 100644 --- a/src/mocks/server.ts +++ b/src/mocks/server.ts @@ -2,6 +2,7 @@ import { setupServer } from 'msw/node'; import { authMockHandler } from '@/api/hooks/auth.mock'; import { categoriesMockHandler } from '@/api/hooks/categories.mock'; +import { pointMockHandlers } from '@/api/hooks/points.mock'; import { productsMockHandler } from '@/api/hooks/products.mock'; import { wishlistMockHandler } from '@/api/hooks/wishlist.mock'; @@ -10,4 +11,5 @@ export const server = setupServer( ...productsMockHandler, ...authMockHandler, ...wishlistMockHandler, + ...pointMockHandlers, ); diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index 96970fc23..cc7dbc196 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -1,4 +1,5 @@ import styled from '@emotion/styled'; +import axios from 'axios'; import { useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; @@ -7,6 +8,7 @@ import KAKAO_LOGO from '@/assets/kakao_logo.svg'; import { Button } from '@/components/common/Button'; import { UnderlineTextField } from '@/components/common/Form/Input/UnderlineTextField'; import { Spacing } from '@/components/common/layouts/Spacing'; +import { useApi } from '@/provider/Api'; import { RouterPath } from '@/routes/path'; import { breakpoints } from '@/styles/variants'; import { authSessionStorage } from '@/utils/storage'; @@ -16,6 +18,7 @@ export const LoginPage = () => { const [password, setPassword] = useState(''); const [queryParams] = useSearchParams(); const navigate = useNavigate(); + const { apiUrl } = useApi(); const handleConfirm = async () => { if (!email || !password) { @@ -24,10 +27,14 @@ export const LoginPage = () => { } try { - const data = await login(email, password); + const token = await login(email, password); - sessionStorage.setItem('authEmail', data.email); - authSessionStorage.set(data.token); + sessionStorage.setItem('authEmail', email); + sessionStorage.setItem('authToken', token); + authSessionStorage.set(token); + + const points = await fetchPoints(token); + sessionStorage.setItem('points', points.toString()); const redirectUrl = queryParams.get('redirect') ?? `${window.location.origin}/`; window.location.replace(redirectUrl); @@ -37,6 +44,21 @@ export const LoginPage = () => { } }; + const fetchPoints = async (token: string) => { + try { + const response = await axios.get(`${apiUrl}api/member/point`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + return response.data.point; + } catch (error) { + console.error('Failed to fetch points', error); + return 0; + } + }; + return ( diff --git a/src/pages/MyAccount/index.tsx b/src/pages/MyAccount/index.tsx index 5c59297f7..62b13f02b 100644 --- a/src/pages/MyAccount/index.tsx +++ b/src/pages/MyAccount/index.tsx @@ -1,5 +1,6 @@ import styled from '@emotion/styled'; +import { useGetPoints } from '@/api/hooks/useGetPoints'; import { Button } from '@/components/common/Button'; import { Spacing } from '@/components/common/layouts/Spacing'; import { Wishlist } from '@/components/features/Wishlist'; @@ -9,9 +10,12 @@ import { authSessionStorage } from '@/utils/storage'; export const MyAccountPage = () => { const authInfo = useAuth(); + const points = useGetPoints(); const handleLogout = () => { authSessionStorage.set(undefined); + sessionStorage.removeItem('authEmail'); + sessionStorage.removeItem('points'); const redirectURL = `${window.location.origin}${RouterPath.home}`; window.location.replace(redirectURL); @@ -19,7 +23,8 @@ export const MyAccountPage = () => { return ( - {authInfo?.name}님 안녕하세요! + {authInfo?.name}님 안녕하세요! + 보유 포인트 : {points}p