From 1d2d7c0b44c0014b81f6633afd4c689188f18e00 Mon Sep 17 00:00:00 2001 From: ayoooyh <127219927+ayoooyh@users.noreply.github.com> Date: Fri, 2 Aug 2024 00:38:49 +0900 Subject: [PATCH 1/9] =?UTF-8?q?package.json=20=EC=98=AE=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 113 ++++++++++++++++++++++++++++++++-------------- package.json | 9 ++++ 2 files changed, 87 insertions(+), 35 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1809cd010..7e4c134df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,12 +11,21 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "@types/jest": "^29.5.12", + "@types/node": "^22.0.2", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "date-fns": "^3.6.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.3", "react-scripts": "5.0.1", + "react-spinners": "^0.13.8", "styled-components": "^6.1.8", "web-vitals": "^2.1.4" + }, + "devDependencies": { + "typescript": "^5.5.4" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -4443,11 +4452,12 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" }, "node_modules/@types/node": { - "version": "20.12.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", - "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.0.2.tgz", + "integrity": "sha512-yPL6DyFwY5PiMVEwymNeqUTKsDczQBJ/5T7W/46RwLU/VH+AA8aT5TZkvBviLKLbbm0hlfftEkGrNzfRk/fofQ==", + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.11.1" } }, "node_modules/@types/node-forge": { @@ -4489,18 +4499,20 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, "node_modules/@types/react": { - "version": "18.2.78", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.78.tgz", - "integrity": "sha512-qOwdPnnitQY4xKlKayt42q5W5UQrSHjgoXNVEtxeqdITJ99k4VXJOP3vt8Rkm9HmgJpH50UNU+rlqfkfWOqp0A==", + "version": "18.3.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", + "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", + "license": "MIT", "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "18.2.25", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.25.tgz", - "integrity": "sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==", + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", + "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", + "license": "MIT", "dependencies": { "@types/react": "*" } @@ -6939,6 +6951,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -15270,6 +15292,16 @@ } } }, + "node_modules/react-spinners": { + "version": "0.13.8", + "resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.13.8.tgz", + "integrity": "sha512-3e+k56lUkPj0vb5NDXPVFAOkPC//XyhKPJjvcGjyMNPWsBKpplfeyialP74G7H7+It7KzhtET+MvGqbKgAqpZA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -17344,16 +17376,16 @@ } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "peer": true, + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/unbox-primitive": { @@ -17376,9 +17408,10 @@ "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==" }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.11.1.tgz", + "integrity": "sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ==", + "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", @@ -21617,11 +21650,11 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" }, "@types/node": { - "version": "20.12.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", - "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.0.2.tgz", + "integrity": "sha512-yPL6DyFwY5PiMVEwymNeqUTKsDczQBJ/5T7W/46RwLU/VH+AA8aT5TZkvBviLKLbbm0hlfftEkGrNzfRk/fofQ==", "requires": { - "undici-types": "~5.26.4" + "undici-types": "~6.11.1" } }, "@types/node-forge": { @@ -21663,18 +21696,18 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, "@types/react": { - "version": "18.2.78", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.78.tgz", - "integrity": "sha512-qOwdPnnitQY4xKlKayt42q5W5UQrSHjgoXNVEtxeqdITJ99k4VXJOP3vt8Rkm9HmgJpH50UNU+rlqfkfWOqp0A==", + "version": "18.3.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", + "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", "requires": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "@types/react-dom": { - "version": "18.2.25", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.25.tgz", - "integrity": "sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==", + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", + "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", "requires": { "@types/react": "*" } @@ -23442,6 +23475,11 @@ "is-data-view": "^1.0.1" } }, + "date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==" + }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -29240,6 +29278,12 @@ "workbox-webpack-plugin": "^6.4.1" } }, + "react-spinners": { + "version": "0.13.8", + "resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.13.8.tgz", + "integrity": "sha512-3e+k56lUkPj0vb5NDXPVFAOkPC//XyhKPJjvcGjyMNPWsBKpplfeyialP74G7H7+It7KzhtET+MvGqbKgAqpZA==", + "requires": {} + }, "read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -30758,10 +30802,9 @@ } }, "typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "peer": true + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==" }, "unbox-primitive": { "version": "1.0.2", @@ -30780,9 +30823,9 @@ "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==" }, "undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.11.1.tgz", + "integrity": "sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ==" }, "unicode-canonical-property-names-ecmascript": { "version": "2.0.0", diff --git a/package.json b/package.json index 2424a353d..290579109 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,16 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "@types/jest": "^29.5.12", + "@types/node": "^22.0.2", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "date-fns": "^3.6.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.22.3", "react-scripts": "5.0.1", + "react-spinners": "^0.13.8", "styled-components": "^6.1.8", "web-vitals": "^2.1.4" }, @@ -36,5 +42,8 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "typescript": "^5.5.4" } } From 088876283f543afc174aea9199ca2700e9cff49a Mon Sep 17 00:00:00 2001 From: ayoooyh <127219927+ayoooyh@users.noreply.github.com> Date: Fri, 2 Aug 2024 00:41:14 +0900 Subject: [PATCH 2/9] =?UTF-8?q?=EB=AF=B8=EC=99=84=EC=84=B1=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20sprint=207=20copy=20&=20=EC=9D=BC=EB=B6=80=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/{App.js => App.ts} | 10 +- src/api/{itemApi.js => itemApi.ts} | 34 ++-- src/assets/images/icons/ic_back.svg | 4 + src/assets/images/icons/ic_kebab.svg | 5 + src/assets/images/ui/empty-comments.svg | 17 ++ src/assets/images/ui/ic_profile.svg | 24 +++ src/components/Layout/Header.css | 2 +- .../Layout/{Header.jsx => Header.tsx} | 0 .../UI/{DeleteButton.jsx => DeleteButton.tsx} | 4 +- src/components/UI/DropdownMenu.css | 8 +- .../UI/{DropdownMenu.jsx => DropdownMenu.tsx} | 0 src/components/UI/Icon.tsx | 38 ++++ .../UI/{ImageUpload.jsx => ImageUpload.tsx} | 6 +- .../UI/{InputItem.jsx => InputItem.tsx} | 24 ++- src/components/UI/LoadingSpinner.tsx | 59 +++++++ src/components/UI/PaginationBar.css | 4 +- .../{PaginationBar.jsx => PaginationBar.tsx} | 0 .../UI/{TagInput.jsx => TagInput.tsx} | 4 +- src/{index.js => index.ts} | 0 .../{AddItemPage.jsx => AddItemPage.tsx} | 7 +- ...nityFeedPage.jsx => CommunityFeedPage.tsx} | 0 .../HomePage/{HomePage.jsx => HomePage.tsx} | 0 src/pages/ItemPage/ItemPage.tsx | 87 ++++++++++ .../ItemPage/components/CommentThread.tsx | 164 ++++++++++++++++++ .../components/ItemCommentSection.tsx | 88 ++++++++++ .../components/ItemProfileSection.tsx | 151 ++++++++++++++++ src/pages/ItemPage/components/LikeButton.tsx | 43 +++++ src/pages/ItemPage/components/TagDisplay.tsx | 34 ++++ .../{LoginPage.jsx => LoginPage.tsx} | 0 src/pages/MarketPage/MarketDetailComment.css | 53 ------ src/pages/MarketPage/MarketDetailComment.jsx | 35 ---- src/pages/MarketPage/MarketDetailContent.jsx | 40 ----- src/pages/MarketPage/MarketDetailPage.css | 90 ---------- src/pages/MarketPage/MarketDetailPage.jsx | 37 ---- src/pages/MarketPage/MarketPage.css | 10 +- .../{MarketPage.jsx => MarketPage.tsx} | 0 ...llItemsSection.jsx => AllItemsSection.tsx} | 76 ++++---- ...tItemsSection.jsx => BestItemsSection.tsx} | 31 +++- .../components/{ItemCard.jsx => ItemCard.tsx} | 19 +- src/styles/CommonStyles.js | 55 ------ src/styles/CommonStyles.ts | 93 ++++++++++ src/styles/{GlobalStyle.js => GlobalStyle.ts} | 0 src/styles/global.css | 26 +-- src/styles/theme.js | 19 -- src/styles/theme.ts | 31 ++++ src/utils/dateUtils.ts | 44 +++++ 46 files changed, 1043 insertions(+), 433 deletions(-) rename src/{App.js => App.ts} (66%) rename src/api/{itemApi.js => itemApi.ts} (50%) create mode 100644 src/assets/images/icons/ic_back.svg create mode 100644 src/assets/images/icons/ic_kebab.svg create mode 100644 src/assets/images/ui/empty-comments.svg create mode 100644 src/assets/images/ui/ic_profile.svg rename src/components/Layout/{Header.jsx => Header.tsx} (100%) rename src/components/UI/{DeleteButton.jsx => DeleteButton.tsx} (79%) rename src/components/UI/{DropdownMenu.jsx => DropdownMenu.tsx} (100%) create mode 100644 src/components/UI/Icon.tsx rename src/components/UI/{ImageUpload.jsx => ImageUpload.tsx} (94%) rename src/components/UI/{InputItem.jsx => InputItem.tsx} (74%) create mode 100644 src/components/UI/LoadingSpinner.tsx rename src/components/UI/{PaginationBar.jsx => PaginationBar.tsx} (100%) rename src/components/UI/{TagInput.jsx => TagInput.tsx} (96%) rename src/{index.js => index.ts} (100%) rename src/pages/AddItemPage/{AddItemPage.jsx => AddItemPage.tsx} (94%) rename src/pages/CommunityFeedPage/{CommunityFeedPage.jsx => CommunityFeedPage.tsx} (100%) rename src/pages/HomePage/{HomePage.jsx => HomePage.tsx} (100%) create mode 100644 src/pages/ItemPage/ItemPage.tsx create mode 100644 src/pages/ItemPage/components/CommentThread.tsx create mode 100644 src/pages/ItemPage/components/ItemCommentSection.tsx create mode 100644 src/pages/ItemPage/components/ItemProfileSection.tsx create mode 100644 src/pages/ItemPage/components/LikeButton.tsx create mode 100644 src/pages/ItemPage/components/TagDisplay.tsx rename src/pages/LoginPage/{LoginPage.jsx => LoginPage.tsx} (100%) delete mode 100644 src/pages/MarketPage/MarketDetailComment.css delete mode 100644 src/pages/MarketPage/MarketDetailComment.jsx delete mode 100644 src/pages/MarketPage/MarketDetailContent.jsx delete mode 100644 src/pages/MarketPage/MarketDetailPage.css delete mode 100644 src/pages/MarketPage/MarketDetailPage.jsx rename src/pages/MarketPage/{MarketPage.jsx => MarketPage.tsx} (100%) rename src/pages/MarketPage/components/{AllItemsSection.jsx => AllItemsSection.tsx} (52%) rename src/pages/MarketPage/components/{BestItemsSection.jsx => BestItemsSection.tsx} (59%) rename src/pages/MarketPage/components/{ItemCard.jsx => ItemCard.tsx} (59%) delete mode 100644 src/styles/CommonStyles.js create mode 100644 src/styles/CommonStyles.ts rename src/styles/{GlobalStyle.js => GlobalStyle.ts} (100%) delete mode 100644 src/styles/theme.js create mode 100644 src/styles/theme.ts create mode 100644 src/utils/dateUtils.ts diff --git a/src/App.js b/src/App.ts similarity index 66% rename from src/App.js rename to src/App.ts index 2cd3d61f3..5d66b0589 100644 --- a/src/App.js +++ b/src/App.ts @@ -2,10 +2,10 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import HomePage from "./pages/HomePage/HomePage"; import LoginPage from "./pages/LoginPage/LoginPage"; import MarketPage from "./pages/MarketPage/MarketPage"; -import MarketDetailPage from "./pages/MarketPage/MarketDetailPage"; import AddItemPage from "./pages/AddItemPage/AddItemPage"; import CommunityFeedPage from "./pages/CommunityFeedPage/CommunityFeedPage"; import Header from "./components/Layout/Header"; +import ItemPage from "./pages/ItemPage/ItemPage"; function App() { return ( @@ -15,11 +15,15 @@ function App() {
- {/* React Router v6부터는 path="/" 대신 간단하게 `index`라고 표기하면 돼요 */} } /> } /> } /> - } /> + {/* + 동적 라우팅 (Dynamic Routing) + - `:` 뒤에 상품 아이디를 `path parameter`로 추가해주어 각 상품의 상세 페이지를 생성할 수 있어요. + - 해당 페이지의 컴포넌트 내에서 useParams 훅을 이용하면 path parameter의 값을 사용할 수 있어요 + */} + } /> } /> } /> diff --git a/src/api/itemApi.js b/src/api/itemApi.ts similarity index 50% rename from src/api/itemApi.js rename to src/api/itemApi.ts index 582ce6199..03d904a56 100644 --- a/src/api/itemApi.js +++ b/src/api/itemApi.ts @@ -17,7 +17,12 @@ export async function getProducts(params = {}) { } } -export const getProduct = async (productId) => { +export async function getProductDetail(productId) { + // Parameter로 넣어줄 상품 아이디가 존재하는지 또는 정상적인지 확인 후에 호출하면 더 안전해요 + if (!productId) { + throw new Error("Invalid product ID"); + } + try { const response = await fetch( `https://panda-market-api.vercel.app/products/${productId}` @@ -25,26 +30,33 @@ export const getProduct = async (productId) => { if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } - const data = await response.json(); - return data; + const body = await response.json(); + return body; } catch (error) { - console.error("Failed to fetch products:", error); + console.error("Failed to fetch product detail:", error); throw error; } -}; +} + +// 상품 댓글 목록 조회 API에는 path parameter 'productId'와 함께 페이지당 보여줄 댓글 개수를 나타내는 'limit'을 query parameter로 보내주고 있어요. +export async function getProductComments({ productId, params }) { + // Parameter로 넣어줄 상품 아이디가 존재하는지 또는 정상적인지 확인 후에 호출하면 더 안전해요 + if (!productId) { + throw new Error("Invalid product ID"); + } -export const getComments = async (productId) => { try { + const query = new URLSearchParams(params).toString(); const response = await fetch( - `https://panda-market-api.vercel.app/products/${productId}/comments?limit=10` + `https://panda-market-api.vercel.app/products/${productId}/comments?${query}` ); if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } - const data = await response.json(); - return data; + const body = await response.json(); + return body; } catch (error) { - console.error("Failed to fetch products:", error); + console.error("Failed to fetch product comments:", error); throw error; } -}; +} diff --git a/src/assets/images/icons/ic_back.svg b/src/assets/images/icons/ic_back.svg new file mode 100644 index 000000000..f8d47f89d --- /dev/null +++ b/src/assets/images/icons/ic_back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/icons/ic_kebab.svg b/src/assets/images/icons/ic_kebab.svg new file mode 100644 index 000000000..dd7ed7f5e --- /dev/null +++ b/src/assets/images/icons/ic_kebab.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/images/ui/empty-comments.svg b/src/assets/images/ui/empty-comments.svg new file mode 100644 index 000000000..d9d2d590e --- /dev/null +++ b/src/assets/images/ui/empty-comments.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/ui/ic_profile.svg b/src/assets/images/ui/ic_profile.svg new file mode 100644 index 000000000..7445a45cf --- /dev/null +++ b/src/assets/images/ui/ic_profile.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Layout/Header.css b/src/components/Layout/Header.css index 1bc2b02c5..e65c9199a 100644 --- a/src/components/Layout/Header.css +++ b/src/components/Layout/Header.css @@ -13,7 +13,7 @@ gap: 8px; font-weight: bold; font-size: 16px; - color: #4b5563; + color: var(--gray-600); } .globalHeader nav ul li a:hover { diff --git a/src/components/Layout/Header.jsx b/src/components/Layout/Header.tsx similarity index 100% rename from src/components/Layout/Header.jsx rename to src/components/Layout/Header.tsx diff --git a/src/components/UI/DeleteButton.jsx b/src/components/UI/DeleteButton.tsx similarity index 79% rename from src/components/UI/DeleteButton.jsx rename to src/components/UI/DeleteButton.tsx index 825c567eb..8244426a0 100644 --- a/src/components/UI/DeleteButton.jsx +++ b/src/components/UI/DeleteButton.tsx @@ -3,7 +3,7 @@ import styled from "styled-components"; import { ReactComponent as CloseIcon } from "../../assets/images/icons/ic_x.svg"; const Button = styled.button` - background-color: ${({ theme }) => theme.colors.gray[0]}; + background-color: ${({ theme }) => theme.colors.gray[400]}; width: 20px; height: 20px; border-radius: 50%; @@ -12,7 +12,7 @@ const Button = styled.button` align-items: center; &:hover { - background-color: ${({ theme }) => theme.colors.blue[0]}; + background-color: ${({ theme }) => theme.colors.blue.primary}; } `; diff --git a/src/components/UI/DropdownMenu.css b/src/components/UI/DropdownMenu.css index da51c7900..1eeae960e 100644 --- a/src/components/UI/DropdownMenu.css +++ b/src/components/UI/DropdownMenu.css @@ -3,7 +3,7 @@ } .sortDropdownTriggerButton { - border: 1px solid #e5e7eb; + border: 1px solid var(--gray-200); border-radius: 12px; padding: 9px; margin-left: 8px; @@ -15,16 +15,16 @@ right: 0; background: #fff; border-radius: 8px; - border: 1px solid #e5e7eb; + border: 1px solid var(--gray-200); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); z-index: 99; } .dropdownItem { padding: 12px 44px; - border-bottom: 1px solid #e5e7eb; + border-bottom: 1px solid var(--gray-200); font-size: 16px; - color: #1f2937; + color: var(--gray-800); cursor: pointer; } diff --git a/src/components/UI/DropdownMenu.jsx b/src/components/UI/DropdownMenu.tsx similarity index 100% rename from src/components/UI/DropdownMenu.jsx rename to src/components/UI/DropdownMenu.tsx diff --git a/src/components/UI/Icon.tsx b/src/components/UI/Icon.tsx new file mode 100644 index 000000000..54b77a175 --- /dev/null +++ b/src/components/UI/Icon.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import styled from "styled-components"; + +interface IIconWrapper { + $fillColor?: string; + $size?: number; + $outlineColor?: string; +} + +const IconWrapper = styled.div` + display: inline-flex; + align-items: center; + justify-content: center; + + svg { + fill: ${({ $fillColor }) => $fillColor || "current"}; // 색 채움 + width: ${({ $size }) => ($size ? `${$size}px` : "auto")}; + height: ${({ $size }) => ($size ? `${$size}px` : "auto")}; + } + + svg path { + stroke: ${({ $fillColor, $outlineColor }) => + $fillColor || $outlineColor || "currentColor"}; + } +`; + +const Icon = ({ + iconComponent: IconComponent, + size, + fillColor, + outlineColor, +}) => ( + + + +); + +export default Icon; diff --git a/src/components/UI/ImageUpload.jsx b/src/components/UI/ImageUpload.tsx similarity index 94% rename from src/components/UI/ImageUpload.jsx rename to src/components/UI/ImageUpload.tsx index 4bf12e1c8..077ea9d84 100644 --- a/src/components/UI/ImageUpload.jsx +++ b/src/components/UI/ImageUpload.tsx @@ -35,8 +35,8 @@ const squareStyles = css` // file input과 연관 짓기 위해 버튼이 대신 label로 설정 const UploadButton = styled.label` - background-color: ${({ theme }) => theme.colors.gray[1]}; - color: ${({ theme }) => theme.colors.gray[0]}; + background-color: ${({ theme }) => theme.colors.gray[100]}; + color: ${({ theme }) => theme.colors.gray[400]}; display: flex; flex-direction: column; align-items: center; @@ -46,7 +46,7 @@ const UploadButton = styled.label` cursor: pointer; // 버튼이 아닌 label을 사용한 경우 별도로 추가해 주세요 &:hover { - background-color: ${({ theme }) => theme.colors.gray[2]}; + background-color: ${({ theme }) => theme.colors.gray[50]}; } ${squareStyles} diff --git a/src/components/UI/InputItem.jsx b/src/components/UI/InputItem.tsx similarity index 74% rename from src/components/UI/InputItem.jsx rename to src/components/UI/InputItem.tsx index 5b62734b4..151dd8b10 100644 --- a/src/components/UI/InputItem.jsx +++ b/src/components/UI/InputItem.tsx @@ -1,12 +1,12 @@ -import React from "react"; +import React, { ChangeEvent, KeyboardEvent } from "react"; import styled, { css } from "styled-components"; // input과 textarea의 스타일이 대부분 중복되기 때문에 styled-components의 css 헬퍼 함수를 사용해 공통 스타일을 정의했어요. // `${}`로 정의된 스타일을 삽입하면 여러 styled component 내에서 코드를 재사용할 수 있어요. const inputStyle = css` padding: 16px 24px; - background-color: ${({ theme }) => theme.colors.gray[1]}; - color: ${({ theme }) => theme.colors.black}; + background-color: ${({ theme }) => theme.colors.gray[100]}; + color: ${({ theme }) => theme.colors.gray[800]}; border: none; border-radius: 12px; font-size: 16px; @@ -14,11 +14,11 @@ const inputStyle = css` width: 100%; &::placeholder { - color: ${({ theme }) => theme.colors.gray[0]}; + color: ${({ theme }) => theme.colors.gray[400]}; } &:focus { - outline-color: ${({ theme }) => theme.colors.blue[0]}; + outline-color: ${({ theme }) => theme.colors.blue.primary}; } `; @@ -43,6 +43,18 @@ const TextArea = styled.textarea` resize: none; // 우측 하단 코너의 textarea 영역 크기 조절 기능을 없애줍니다 `; +interface InputItemProps { + id: string; + label: string; + value: string; + onChange: (e: ChangeEvent) => void; + placeholder: string; + onKeyDown?: ( + e: KeyboardEvent + ) => void; + isTextArea?: boolean; +} + function InputItem({ id, label, @@ -51,7 +63,7 @@ function InputItem({ placeholder, onKeyDown, isTextArea, -}) { +}: InputItemProps) { return (
{label && } diff --git a/src/components/UI/LoadingSpinner.tsx b/src/components/UI/LoadingSpinner.tsx new file mode 100644 index 000000000..ab6bbcbd3 --- /dev/null +++ b/src/components/UI/LoadingSpinner.tsx @@ -0,0 +1,59 @@ +import React, { useEffect, useState } from "react"; +import styled from "styled-components"; +import { PulseLoader } from "react-spinners"; + +// 렌더링 중인 컴포넌트들을 가리기 위해 불투명한 흰색 바탕 위에 반투명한 검정 바탕의 overlay를 씌웠어요 +const MaskedBackground = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #fff; + z-index: 9998; +`; + +const SpinnerOverlay = styled.div` + position: fixed; + top: 0; + left: 0; + background: rgba(0, 0, 0, 0.2); + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + z-index: 9999; +`; + +// 데이터 로딩 중임을 사용자에게 알려주는 UI +// - 대표적으로 로딩 스피너가 많이 사용되고, 라이브러리 또는 이미지(gif, lottie animation) 등으로 구현할 수 있어요. +// - 요소가 렌더링되기 전까지 비슷한 형태의 placeholder 레이아웃를 띄워주는 `skeleton` 로딩도 선호되는 방식이에요. +// - 이번 미션에서는 `react-spinners`라는 라이브러리를 이용해 간단한 로딩 스피너를 적용해 볼게요. + +const LoadingSpinner = ({ + size = 20, + color = "var(--blue)", + minLoadTime = 500, +}) => { + const [isVisible, setIsVisible] = useState(true); + + // 로딩이 너무 빨라서 로딩 스피너가 순간적으로 나타났다 사라지는 것을 방지하기 위해 설정된 최소시간 동안은 스피너가 떠있도록 했어요. + useEffect(() => { + const timer = setTimeout(() => { + setIsVisible(false); + }, minLoadTime); + + return () => clearTimeout(timer); + }, [minLoadTime]); + + return isVisible ? ( + + + + + + ) : null; +}; + +export default LoadingSpinner; diff --git a/src/components/UI/PaginationBar.css b/src/components/UI/PaginationBar.css index 1841297a9..f9f394f77 100644 --- a/src/components/UI/PaginationBar.css +++ b/src/components/UI/PaginationBar.css @@ -6,11 +6,11 @@ } .paginationButton { - border: 1px solid #e5e7eb; + border: 1px solid var(--gray-200); border-radius: 50%; width: 40px; height: 40px; - color: #6b7280; + color: var(--gray-500); font-weight: 600; font-size: 16px; display: flex; diff --git a/src/components/UI/PaginationBar.jsx b/src/components/UI/PaginationBar.tsx similarity index 100% rename from src/components/UI/PaginationBar.jsx rename to src/components/UI/PaginationBar.tsx diff --git a/src/components/UI/TagInput.jsx b/src/components/UI/TagInput.tsx similarity index 96% rename from src/components/UI/TagInput.jsx rename to src/components/UI/TagInput.tsx index 09fea6eaa..31abf34b3 100644 --- a/src/components/UI/TagInput.jsx +++ b/src/components/UI/TagInput.tsx @@ -12,8 +12,8 @@ const TagButtonsSection = styled.div` `; const Tag = styled(FlexContainer)` - background-color: ${({ theme }) => theme.colors.gray[2]}; - color: ${({ theme }) => theme.colors.black}; + background-color: ${({ theme }) => theme.colors.gray[50]}; + color: ${({ theme }) => theme.colors.gray[800]}; padding: 14px 14px 14px 16px; border-radius: 999px; min-width: 100px; diff --git a/src/index.js b/src/index.ts similarity index 100% rename from src/index.js rename to src/index.ts diff --git a/src/pages/AddItemPage/AddItemPage.jsx b/src/pages/AddItemPage/AddItemPage.tsx similarity index 94% rename from src/pages/AddItemPage/AddItemPage.jsx rename to src/pages/AddItemPage/AddItemPage.tsx index d6699aaf2..b302d38c2 100644 --- a/src/pages/AddItemPage/AddItemPage.jsx +++ b/src/pages/AddItemPage/AddItemPage.tsx @@ -23,21 +23,22 @@ const InputSection = styled.div` gap: 24px; } `; +type Tag = string; function AddItemPage() { const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [price, setPrice] = useState(""); - const [tags, setTags] = useState([]); + const [tags, setTags] = useState([]); // 중복 등록 막기 위해 tags 배열에 없는 것 확인하고 삽입 - const addTag = (tag) => { + const addTag = (tag: Tag) => { if (!tags.includes(tag)) { setTags([...tags, tag]); } }; - const removeTag = (tagToRemove) => { + const removeTag = (tagToRemove: Tag) => { setTags(tags.filter((tag) => tag !== tagToRemove)); }; diff --git a/src/pages/CommunityFeedPage/CommunityFeedPage.jsx b/src/pages/CommunityFeedPage/CommunityFeedPage.tsx similarity index 100% rename from src/pages/CommunityFeedPage/CommunityFeedPage.jsx rename to src/pages/CommunityFeedPage/CommunityFeedPage.tsx diff --git a/src/pages/HomePage/HomePage.jsx b/src/pages/HomePage/HomePage.tsx similarity index 100% rename from src/pages/HomePage/HomePage.jsx rename to src/pages/HomePage/HomePage.tsx diff --git a/src/pages/ItemPage/ItemPage.tsx b/src/pages/ItemPage/ItemPage.tsx new file mode 100644 index 000000000..6280619ab --- /dev/null +++ b/src/pages/ItemPage/ItemPage.tsx @@ -0,0 +1,87 @@ +import React, { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import styled from "styled-components"; +import { Container, LineDivider, StyledLink } from "../../styles/CommonStyles"; +import { getProductDetail } from "../../api/itemApi"; +import ItemProfileSection from "./components/ItemProfileSection"; +import ItemCommentSection from "./components/ItemCommentSection"; +import { ReactComponent as BackIcon } from "../../assets/images/icons/ic_back.svg"; +import LoadingSpinner from "../../components/UI/LoadingSpinner"; + +const BackToMarketPageLink = styled(StyledLink)` + display: flex; + align-items: center; + gap: 10px; + font-size: 18px; + font-weight: 600; + margin: 0 auto; +`; + +function ItemPage() { + const [product, setProduct] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // react-router-dom의 useParams 훅을 사용해 URL의 path parameter(상품 아이디)를 가져오세요. + // Route에서 정의한 path parameter의 이름과 useParams 훅에서 사용하는 변수명이 일치해야 정상적으로 추출돼요. + const { productId } = useParams(); + + useEffect(() => { + async function fetchProduct() { + if (!productId) { + setError("상품 아이디가 제공되지 않았어요."); + setIsLoading(false); + return; + } + + setIsLoading(true); + try { + const data = await getProductDetail(productId); + if (!data) { + throw new Error("해당 상품의 데이터를 찾을 수 없습니다."); + } + setProduct(data); + } catch (error) { + setError(error.message); + } finally { + setIsLoading(false); + } + } + + fetchProduct(); + }, [productId]); + + // 데이터 fetching 오류 발생 시 UI + // - 만약 서버에서 사용자 친화적으로 작성된 오류 메세지를 보내주지 않는다면 호출된 오류 메세지를 그대로 노출하는 것보다는 프론트단에서 별도의 내용을 넣어주는 것이 사용성에 좋아요 + if (error) { + alert(`오류: ${error}`); + } + + if (!productId || !product) return null; + + return ( + <> + {/* 데이터를 불러올 동안 로딩 스피너 표시 */} + + + + + + + + + + {/* Styled-components에 사용자 정의 prop을 전달해 스타일링 시 참고사항: + - 요소에 해당 HTML 태그의 기본 속성이 아닌 것이 추가되면 콘솔창에 "unknown prop"이 전달되고 있다는 경고가 뜰 수 있어요. + - prop 이름 앞에 `$`를 붙여주면 styled-components가 `transient props`로 인식하고 DOM 요소에 전달되지 않도록 필터링해요. + */} + + 목록으로 돌아가기 + + + + + ); +} + +export default ItemPage; diff --git a/src/pages/ItemPage/components/CommentThread.tsx b/src/pages/ItemPage/components/CommentThread.tsx new file mode 100644 index 000000000..f2f8f0e76 --- /dev/null +++ b/src/pages/ItemPage/components/CommentThread.tsx @@ -0,0 +1,164 @@ +import React, { useEffect, useState } from "react"; +import { getProductComments } from "../../../api/itemApi"; +import styled from "styled-components"; +import { ReactComponent as EmptyStateImage } from "../../../assets/images/ui/empty-comments.svg"; +import { ReactComponent as SeeMoreIcon } from "../../../assets/images/icons/ic_kebab.svg"; +// 참고: SVG 이미지 파일을 ReactComponent로 import하는 것을 추천하지만, DefaultProfileImage는 image source로 사용하기 위해 그대로 불러왔어요. +import DefaultProfileImage from "../../../assets/images/ui/ic_profile.svg"; +import { LineDivider } from "../../../styles/CommonStyles"; +import { formatUpdatedAt } from "../../../utils/dateUtils"; + +const CommentContainer = styled.div` + padding: 24px 0; + position: relative; +`; + +// 더보기 버튼을 댓글 아이템 우측 상단에 포지셔닝 +const SeeMoreButton = styled.button` + position: absolute; + right: 0; +`; + +const CommentContent = styled.p` + font-size: 16px; + line-height: 140%; + margin-bottom: 24px; +`; + +const AuthorProfile = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +// Mock data에서 보내주는 프로필 사진은 이미 원형이지만, 혹시 원이 아닌 이미지를 표시해야 하는 경우를 대비해 border-radius를 적용하고 이미지가 주어진 원 내에서 비율을 유지하면서 삽입되도록 함 +const UserProfileImage = styled.img` + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; +`; + +const Username = styled.p` + color: var(--gray-600); + font-size: 14px; + margin-bottom: 4px; +`; + +const Timestamp = styled.p` + color: ${({ theme }) => theme.colors.gray[400]}; + font-size: 12px; +`; + +const CommentItem = ({ item }) => { + const authorInfo = item.writer; + // 업데이트 시간 표기를 위한 util function을 만들었으니 dateUtils.js 파일에서 꼭 설명을 확인해 주세요! + const formattedTimestamp = formatUpdatedAt(item.updatedAt); + + return ( + <> + + {/* 참고: 더보기 버튼 기능은 추후 요구사항에 따라 추가 예정 */} + + + + + {item.content} + + + + +
+ {authorInfo.nickname} + {formattedTimestamp} +
+
+
+ + + + ); +}; + +const EmptyStateContainer = styled.div` + margin: 24px; + display: flex; + flex-direction: column; + align-items: center; // flex-direction이 column일 때는 main axis가 세로축이기 때문에 align-items: center; 를 적용해야 자식 요소들이 horizontally 가운데 정렬돼요. + gap: 24px; +`; + +const EmptyStateText = styled.p` + color: ${({ theme }) => theme.colors.gray[400]}; + font-size: 16px; + line-height: 24px; +`; + +// Empty States: 보여줄 데이터가 없을 때 placeholder 역할을 할 UI를 넣어주세요. +const EmptyState = () => { + return ( + + + 아직 문의가 없습니다. + + ); +}; + +const ThreadContainer = styled.div` + margin-bottom: 40px; +`; + +function CommentThread({ productId }) { + const [comments, setComments] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!productId) return; + + const fetchComments = async () => { + setIsLoading(true); + const params = { + limit: 10, // 페이지당 보여줄 댓글 개수 (참고: 요구사항에 아직 댓글란 pagination 기능이 없기 때문에 임의로 10으로 설정했어요.) + }; + + try { + const data = await getProductComments({ productId, params }); + setComments(data.list); + setError(null); + } catch (error) { + console.error("Error fetching comments:", error); + setError("상품의 댓글을 불러오지 못했어요."); + } finally { + setIsLoading(false); + } + }; + + fetchComments(); + }, [productId]); + + if (isLoading) { + return
상품 댓글 로딩중...
; + } + + if (error) { + return
오류: {error}
; + } + + if (comments && !comments.length) { + return ; + } else { + return ( + + {comments.map((item) => ( + + ))} + + ); + } +} + +export default CommentThread; diff --git a/src/pages/ItemPage/components/ItemCommentSection.tsx b/src/pages/ItemPage/components/ItemCommentSection.tsx new file mode 100644 index 000000000..42f1c0402 --- /dev/null +++ b/src/pages/ItemPage/components/ItemCommentSection.tsx @@ -0,0 +1,88 @@ +import React, { useState } from "react"; +import styled from "styled-components"; +import { Button } from "../../../styles/CommonStyles"; +import CommentThread from "./CommentThread"; + +const COMMENT_PLACEHOLDER = + "개인정보를 공유 및 요청하거나, 명예 훼손, 무단 광고, 불법 정보 유포시 모니터링 후 삭제될 수 있으며, 이에 대한 민형사상 책임은 게시자에게 있습니다."; + +const CommentInputSection = styled.section` + display: flex; + flex-direction: column; + gap: 16px; +`; + +const SectionTitle = styled.h1` + font-size: 16px; + font-weight: 600; +`; + +// TODO: InputItem 컴포넌트의 textarea와 겹치므로 common styles에 추가할 것 +const TextArea = styled.textarea` + background-color: ${({ theme }) => theme.colors.gray[100]}; + border: none; + border-radius: 12px; + padding: 16px 24px; + height: 104px; // 디자인에 맞춰 textarea 영역의 기본 높이를 설정해 주세요 + resize: none; // 우측 하단 코너의 textarea 영역 크기 조절 기능을 없애줍니다 + + &::placeholder { + color: ${({ theme }) => theme.colors.gray[400]}; + font-size: 14px; + line-height: 24px; + + @media ${({ theme }) => theme.mediaQuery.tablet} { + font-size: 16px; + } + } + + &:focus { + outline-color: ${({ theme }) => theme.colors.blue.primary}; + } +`; + +const PostCommentButton = styled(Button)` + align-self: flex-end; + font-weight: 600; + font-size: 14px; + + @media ${({ theme }) => theme.mediaQuery.tablet} { + font-size: 16px; + } +`; + +function ItemCommentSection({ productId }) { + const [comment, setComment] = useState(""); + + const handleInputChange = (e) => { + setComment(e.target.value); + }; + + const handlePostComment = () => {}; + + return ( + <> + + 문의하기 + +