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 (
+ <>
+
+ 문의하기
+
+
+
+ {/* 참고: 요구사항에 따라 추후 댓글 등록 API 추가 예정 */}
+
+ 등록
+
+
+
+
+ >
+ );
+}
+
+export default ItemCommentSection;
diff --git a/src/pages/ItemPage/components/ItemProfileSection.tsx b/src/pages/ItemPage/components/ItemProfileSection.tsx
new file mode 100644
index 000000000..a41212ea4
--- /dev/null
+++ b/src/pages/ItemPage/components/ItemProfileSection.tsx
@@ -0,0 +1,151 @@
+import React from "react";
+import styled from "styled-components";
+import { LineDivider } from "../../../styles/CommonStyles";
+import TagDisplay from "./TagDisplay";
+import LikeButton from "./LikeButton";
+import { ReactComponent as SeeMoreIcon } from "../../../assets/images/icons/ic_kebab.svg";
+
+const SectionContainer = styled.section`
+ /* 모바일에선 이미지와 상세내용 섹션이 위아래로 배치되어 있다가, 타블렛부터는 같은 줄로 flex */
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ @media ${({ theme }) => theme.mediaQuery.tablet} {
+ flex-direction: row;
+ }
+
+ @media ${({ theme }) => theme.mediaQuery.desktop} {
+ gap: 24px;
+ }
+`;
+
+const ItemImage = styled.div`
+ width: 100%;
+ height: 100%;
+
+ img {
+ border-radius: 12px; // 방법 1: 이미지에 직접 border-radius 적용 OR 방법 2: 이미지 wrapper div에 border-radius 적용하는 경우엔 overflow: hidden 필요
+ width: 100%;
+ height: auto; // 이미지 사이즈 변경 시 비율 유지
+ }
+
+ @media ${({ theme }) => theme.mediaQuery.tablet} {
+ width: 40%;
+ max-width: 486px;
+ }
+`;
+
+const ItemDetailsContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ flex: 1;
+ /* 만약 LikeButton이 가로로 스트레칭되는 이슈가 있었다면?
+ - Flexbox에서 부모 요소에 flex-direction: column;이 설정되어 있고, align-items가 명시적으로 지정되지 않았다면 기본값인 stretch가 적용되어 자식 요소들이 교차축(가로축)을 따라 스트레칭돼요.
+ - 자식 요소에 width가 지정되지 않았다면 자식 요소는 교차축에 맞춰 부모 컨테이너의 전체 너비를 채우려고 하기 때문에 자식 요소에 width 또는 max-width를 명시해주는 방법으로 해결 가능해요. 하지만 우리는 반응형 디자인을 추구하기 때문에 요소의 width를 지정해주는 것 보다는 max-width만 정해주거나 부모 요소에 align-items: flex-start;를 추가하는 방법을 추천해요.
+ */
+ align-items: flex-start;
+`;
+
+const MainDetails = styled.div`
+ width: 100%;
+ position: relative;
+`;
+
+// 더보기 버튼을 MainDetails 우측 상단에 포지셔닝
+const SeeMoreButton = styled.button`
+ position: absolute;
+ right: 0;
+`;
+
+const ItemTitle = styled.h1`
+ font-size: 16px;
+ font-weight: 600;
+ margin-bottom: 8px;
+
+ @media ${({ theme }) => theme.mediaQuery.tablet} {
+ font-size: 20px;
+ margin-bottom: 12px;
+ }
+
+ @media ${({ theme }) => theme.mediaQuery.desktop} {
+ font-size: 24px;
+ margin-bottom: 16px;
+ }
+`;
+
+const ItemPrice = styled.h2`
+ font-size: 24px;
+ font-weight: 600;
+
+ @media ${({ theme }) => theme.mediaQuery.tablet} {
+ font-size: 32px;
+ }
+
+ @media ${({ theme }) => theme.mediaQuery.desktop} {
+ font-size: 40px;
+ }
+`;
+
+const Description = styled.p`
+ font-size: 16px;
+ line-height: 140%;
+`;
+
+const SectionLabel = styled.h3`
+ color: var(--gray-600);
+ font-size: 14px;
+ font-weight: 500;
+ margin-bottom: 8px;
+`;
+
+const TagDisplaySection = styled.div`
+ margin: 24px 0;
+`;
+
+function ItemProfileSection({ product }) {
+ return (
+
+ {/* 참고: 호출된 상품 데이터의 images는 여러 개의 이미지 URL 문자열로 구성된 배열의 형태이지만, 디자인 요구사항에 슬라이더 등 여러 장의 사진을 보여주는 UI가 없기 때문에 가장 첫 번째 이미지만 선택해서 보여줄게요 */}
+
+
+
+
+
+
+ {/* 참고: 더보기 버튼 기능은 추후 요구사항에 따라 추가 예정 */}
+
+
+
+
+
+ {product.name}
+ {/* 숫자를 표시할 때는 잊지 말고 항상 toLocaleString()을 사용해 thousand separator를 추가해 주세요! */}
+ {product.price.toLocaleString()}원
+
+
+
+
+
+ 상품 소개
+ {product.description}
+
+
+
+ 상품 태그
+
+
+
+
+
+
+
+ );
+}
+
+export default ItemProfileSection;
diff --git a/src/pages/ItemPage/components/LikeButton.tsx b/src/pages/ItemPage/components/LikeButton.tsx
new file mode 100644
index 000000000..f550122f3
--- /dev/null
+++ b/src/pages/ItemPage/components/LikeButton.tsx
@@ -0,0 +1,43 @@
+import React from "react";
+import styled from "styled-components";
+import { ReactComponent as HeartSvg } from "../../../assets/images/icons/ic_heart.svg";
+import { FlexContainer } from "../../../styles/CommonStyles";
+import Icon from "../../../components/UI/Icon";
+
+// 참고: 추후 요구사항에 따라 기능 추가 예정
+
+const PillButton = styled.button`
+ color: var(--gray-500);
+ font-size: 16px;
+ padding: 4px 12px;
+ border-radius: 999px;
+ border: 1px solid var(--gray-200);
+
+ /* 버튼 hover 시 아이콘의 아웃라인과 채움색을 변경 */
+ &:hover svg path {
+ fill: var(--red);
+ stroke: var(--red);
+ }
+`;
+
+const ButtonContent = styled(FlexContainer)`
+ gap: 4px;
+`;
+
+function LikeButton({ productId, isFavorite, favoriteCount }) {
+ return (
+
+
+ {/* SVG의 사이즈 및 색상을 동적으로 변경할 수 있는 컴포넌트를 만들었어요 */}
+
+ {favoriteCount.toLocaleString()}
+
+
+ );
+}
+
+export default LikeButton;
diff --git a/src/pages/ItemPage/components/TagDisplay.tsx b/src/pages/ItemPage/components/TagDisplay.tsx
new file mode 100644
index 000000000..8d3383580
--- /dev/null
+++ b/src/pages/ItemPage/components/TagDisplay.tsx
@@ -0,0 +1,34 @@
+import React from "react";
+import styled from "styled-components";
+
+const TagsDisplaySection = styled.div`
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+`;
+
+const Tag = styled.div`
+ background-color: ${({ theme }) => theme.colors.gray[50]};
+ color: ${({ theme }) => theme.colors.gray[800]};
+ padding: 6px 16px;
+ border-radius: 999px;
+ font-size: 16px;
+`;
+
+// 이 컴포넌트는 상품 등록 페이지의 TagInput 컴포넌트에서 구현했던 것과 디자인은 유사하지만, 기능적으로 큰 차이가 있기 때문에 별도의 컴포넌트로 분리했어요 (인터랙티브 기능의 유무).
+// D.R.Y(Don't Repeat Yourself) 원칙에 따라 비슷한 컴포넌트를 재사용하는 것도 중요하지만, 여러 상황에 같은 컴포넌트를 적용하려다 보면 코드가 너무 복잡해지고 유지보수(maintainability)가 어려워지는 역효과가 있기 때문에 기능적 관심사의 명확한 분리가 필요해요.
+// 코드를 컴포넌트화 할 때는 단순히 겉모습을 기준으로 하지 않고 코드의 범용성, 간결성, 가독성, 유지보수성 등을 고려해 적절한 밸런스를 찾는 것이 중요해요. 하나의 컴포넌트에 여러 책임을 지우는 것을 피하고 각 컴포넌트가 하나의 기능에 집중하도록 하는 것도 좋은 가이드라인이 될 수 있어요.
+
+function TagDisplay({ tags }) {
+ if (!tags || tags.length === 0) return null;
+
+ return (
+
+ {tags.map((tag, index) => (
+ #{tag}
+ ))}
+
+ );
+}
+
+export default TagDisplay;
diff --git a/src/pages/LoginPage/LoginPage.jsx b/src/pages/LoginPage/LoginPage.tsx
similarity index 100%
rename from src/pages/LoginPage/LoginPage.jsx
rename to src/pages/LoginPage/LoginPage.tsx
diff --git a/src/pages/MarketPage/MarketDetailComment.css b/src/pages/MarketPage/MarketDetailComment.css
deleted file mode 100644
index 0525f5d1e..000000000
--- a/src/pages/MarketPage/MarketDetailComment.css
+++ /dev/null
@@ -1,53 +0,0 @@
-.line2 {
- border: 1px solid #e5e7eb;
-
- margin-top: 32px;
- margin-bottom: 24px;
-
- /* width: 1200px; */
-}
-
-.p {
- font-size: 16px;
- color: #111827;
-}
-
-.question-container {
- /* justify-content: center; */
- /* justify-items: center; */
- /* justify-items: auto; */
- /* align-items: center; */
- /* justify-content: space-between; */
- /* align-content: center; */
- display: flex;
- flex-direction: column;
- padding: 10px;
- align-items: center;
- /* justify-content: center; */
-}
-
-.question-box {
- height: 104px;
- padding: 16px 24px 16px 24px;
- border-radius: 12px;
- background: #f3f4f6;
-
- margin-top: 16px;
-
- font-size: 16px;
- color: #9ca3af;
- padding: 16px, 24px, 16px, 24px;
-}
-
-.register {
- padding: 12px 23px 12px 23px;
- gap: 10px;
- border-radius: 8px;
-
- background: #9ca3af;
-
- font-size: 16px;
- color: #ffffff;
-
- margin-top: 10px;
-}
diff --git a/src/pages/MarketPage/MarketDetailComment.jsx b/src/pages/MarketPage/MarketDetailComment.jsx
deleted file mode 100644
index eb371c3be..000000000
--- a/src/pages/MarketPage/MarketDetailComment.jsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import React from "react";
-
-export default function MarketDetailComment({ comments }) {
- return (
-
-
-
-
-
문의하기
-
-
- 개인정보를 공유 및 요청하거나, 명예 훼손, 무단 광고, 불법 정보
- 유포시 모니터링 후 삭제될 수 있으며, 이에 대한 민형사상 책임은
- 게시자에게 있습니다.
-
-
-
-
-
-
- {comments.length > 0 &&
- comments.map((q, i) => {
- return (
-
-
{q.content}
-
{q.createdAt}
-
{q.writer.nickname}
-
- );
- })}
-
-
-
- );
-}
diff --git a/src/pages/MarketPage/MarketDetailContent.jsx b/src/pages/MarketPage/MarketDetailContent.jsx
deleted file mode 100644
index 504566fe5..000000000
--- a/src/pages/MarketPage/MarketDetailContent.jsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import React from "react";
-import { ReactComponent as Heart } from "../../assets/images/icons/ic_heart.svg";
-
-export default function MarketDetailContent({ data }) {
- return (
-
-
-
-
-
{data.name}
-
{data.price.toLocaleString()}원
-
-
상품소개
-
{data.description}
-
상품 태그
-
- {data.tags &&
- data.tags.map((v, i) => {
- return (
-
- # {v}
-
- );
- })}
-
-
-
-
-
- {data.favoriteCount}
-
-
-
-
- );
-}
diff --git a/src/pages/MarketPage/MarketDetailPage.css b/src/pages/MarketPage/MarketDetailPage.css
deleted file mode 100644
index a707637bf..000000000
--- a/src/pages/MarketPage/MarketDetailPage.css
+++ /dev/null
@@ -1,90 +0,0 @@
-.MarketDetailItem {
- display: flex;
- padding: 10px;
- align-items: center;
- justify-content: center;
-}
-
-.MarketDetailItem-img {
- width: 486px;
- height: 486px;
- border-radius: 16px;
-}
-
-.MarketMarketDetailContent {
- margin-bottom: 124px;
- margin-left: 24px;
-}
-
-.p {
- color: #1f2937;
-}
-
-.itemName {
- font-size: 24px;
-}
-
-.itemPrice {
- font-size: 40px;
- padding-top: 16px;
-}
-
-.line {
- border: 1px solid #e5e7eb;
- margin-top: 16px;
- margin-bottom: 16px;
-}
-
-.itemIntroduce {
- font-size: 14px;
- color: #4b5563;
-}
-
-.itemDescription {
- font-size: 16px;
- margin-top: 25px;
-}
-
-.itemTagName {
- font-size: 14px;
- color: #4b5563;
- margin-top: 24px;
- margin-bottom: 8px;
-}
-
-.itemTag {
- padding: 6px 16px 6px 16px;
- border-radius: 26px;
- background: #f3f4f6;
-}
-
-.like {
- padding: 4px 12px 4px 12px;
- gap: 10px;
- border-radius: 35px;
-
- border: 1px solid #e5e7eb;
-
- margin-left: 24px;
-}
-
-.heart-icon {
- width: 26.8px;
- height: 23.3px;
- top: 4px;
- left: 2.67px;
- border: 1.8px;
-
- /* Icon */
-
- box-sizing: border-box;
-
- position: absolute;
- left: 8.33%;
- right: 7.92%;
- top: 12.5%;
- bottom: 14.69%;
-
- /* Cool Gray/500 */
- border: 1.8px solid #6b7280;
-}
diff --git a/src/pages/MarketPage/MarketDetailPage.jsx b/src/pages/MarketPage/MarketDetailPage.jsx
deleted file mode 100644
index f4aa45590..000000000
--- a/src/pages/MarketPage/MarketDetailPage.jsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import React, { useEffect, useState } from "react";
-import { getProduct, getComments } from "../../api/itemApi";
-import { useParams } from "react-router-dom";
-import "./MarketDetailPage.css";
-import "./MarketDetailComment.css";
-import MarketDetailContent from "./MarketDetailContent";
-import MarketDetailComment from "./MarketDetailComment";
-
-function MarketDetailPage() {
- const parm = useParams();
- const [data, setData] = useState({});
- const [comments, setComments] = useState({});
-
- useEffect(() => {
- getProduct(parm.id).then((data) => {
- if (data) {
- setData(data);
- }
- });
- getComments(parm.id).then((comments) => {
- if (comments && comments.list.length > 0) {
- setComments(comments.list);
- }
- });
- }, [parm.id]);
-
- return (
- Object.keys(data).length >= 1 && (
-
-
-
-
- )
- );
-}
-
-export default MarketDetailPage;
diff --git a/src/pages/MarketPage/MarketPage.css b/src/pages/MarketPage/MarketPage.css
index 1a1dcbc8d..557553c80 100644
--- a/src/pages/MarketPage/MarketPage.css
+++ b/src/pages/MarketPage/MarketPage.css
@@ -1,12 +1,12 @@
.sectionTitle {
- color: #111827;
+ color: var(--gray-900);
font-weight: bold;
font-size: 20px;
line-height: normal;
}
.itemCard {
- color: #1f2937;
+ color: var(--gray-800);
overflow: hidden;
cursor: pointer;
}
@@ -46,7 +46,7 @@
display: flex;
align-items: center;
gap: 4px;
- color: #4b5563;
+ color: var(--gray-600);
font-size: 12px;
}
@@ -77,7 +77,7 @@
.searchBarWrapper {
display: flex;
- background-color: #f3f4f6;
+ background-color: var(--gray-100);
border-radius: 12px;
padding: 9px 16px;
flex: 1;
@@ -92,7 +92,7 @@
}
.searchBarInput::placeholder {
- color: #9ca3af;
+ color: var(--gray-400);
font-size: 16px;
}
diff --git a/src/pages/MarketPage/MarketPage.jsx b/src/pages/MarketPage/MarketPage.tsx
similarity index 100%
rename from src/pages/MarketPage/MarketPage.jsx
rename to src/pages/MarketPage/MarketPage.tsx
diff --git a/src/pages/MarketPage/components/AllItemsSection.jsx b/src/pages/MarketPage/components/AllItemsSection.tsx
similarity index 52%
rename from src/pages/MarketPage/components/AllItemsSection.jsx
rename to src/pages/MarketPage/components/AllItemsSection.tsx
index 995d9acf6..c253f1ee3 100644
--- a/src/pages/MarketPage/components/AllItemsSection.jsx
+++ b/src/pages/MarketPage/components/AllItemsSection.tsx
@@ -1,11 +1,11 @@
import React, { useEffect, useState } from "react";
import { getProducts } from "../../../api/itemApi";
import ItemCard from "./ItemCard";
-import { ReactComponent as SortIcon } from "../../../assets/images/icons/ic_sort.svg";
import { ReactComponent as SearchIcon } from "../../../assets/images/icons/ic_search.svg";
import { Link } from "react-router-dom";
import DropdownMenu from "../../../components/UI/DropdownMenu";
import PaginationBar from "../../../components/UI/PaginationBar";
+import LoadingSpinner from "../../../components/UI/LoadingSpinner";
const getPageSize = () => {
const width = window.innerWidth;
@@ -27,11 +27,19 @@ function AllItemsSection() {
const [pageSize, setPageSize] = useState(getPageSize());
const [itemList, setItemList] = useState([]);
const [totalPageNum, setTotalPageNum] = useState();
+ const [isLoading, setIsLoading] = useState(true);
const fetchSortedData = async ({ orderBy, page, pageSize }) => {
- const products = await getProducts({ orderBy, page, pageSize });
- setItemList(products.list);
- setTotalPageNum(Math.ceil(products.totalCount / pageSize));
+ setIsLoading(true);
+ try {
+ const products = await getProducts({ orderBy, page, pageSize });
+ setItemList(products.list);
+ setTotalPageNum(Math.ceil(products.totalCount / pageSize));
+ } catch (error) {
+ console.error("오류: ", error.message);
+ } finally {
+ setIsLoading(false);
+ }
};
const handleSortSelection = (sortOption) => {
@@ -58,39 +66,43 @@ function AllItemsSection() {
};
return (
-
-
-
판매 중인 상품
-
- 상품 등록하기
-
-
+ <>
+
-
-
-
-
+
+
+
판매 중인 상품
+
+ 상품 등록하기
+
-
-
-
- {itemList?.map((item) => (
-
- ))}
-
+
-
-
+
+ {itemList?.map((item) => (
+
+ ))}
+
+
+
-
+ >
);
}
diff --git a/src/pages/MarketPage/components/BestItemsSection.jsx b/src/pages/MarketPage/components/BestItemsSection.tsx
similarity index 59%
rename from src/pages/MarketPage/components/BestItemsSection.jsx
rename to src/pages/MarketPage/components/BestItemsSection.tsx
index a2072fbb9..20578f740 100644
--- a/src/pages/MarketPage/components/BestItemsSection.jsx
+++ b/src/pages/MarketPage/components/BestItemsSection.tsx
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from "react";
import ItemCard from "./ItemCard";
import { getProducts } from "../../../api/itemApi";
+import LoadingSpinner from "../../../components/UI/LoadingSpinner";
const getPageSize = () => {
const width = window.innerWidth;
@@ -19,10 +20,18 @@ const getPageSize = () => {
function BestItemsSection() {
const [itemList, setItemList] = useState([]);
const [pageSize, setPageSize] = useState(getPageSize());
+ const [isLoading, setIsLoading] = useState(true);
const fetchSortedData = async ({ orderBy, pageSize }) => {
- const products = await getProducts({ orderBy, pageSize });
- setItemList(products.list);
+ setIsLoading(true);
+ try {
+ const products = await getProducts({ orderBy, pageSize });
+ setItemList(products.list);
+ } catch (error) {
+ console.error("오류: ", error.message);
+ } finally {
+ setIsLoading(false);
+ }
};
useEffect(() => {
@@ -41,15 +50,19 @@ function BestItemsSection() {
}, [pageSize]);
return (
-
-
베스트 상품
+ <>
+
-
- {itemList?.map((item) => (
-
- ))}
+
+
베스트 상품
+
+
+ {itemList?.map((item) => (
+
+ ))}
+
-
+ >
);
}
diff --git a/src/pages/MarketPage/components/ItemCard.jsx b/src/pages/MarketPage/components/ItemCard.tsx
similarity index 59%
rename from src/pages/MarketPage/components/ItemCard.jsx
rename to src/pages/MarketPage/components/ItemCard.tsx
index 42d91ebb2..0e1da2fe8 100644
--- a/src/pages/MarketPage/components/ItemCard.jsx
+++ b/src/pages/MarketPage/components/ItemCard.tsx
@@ -1,17 +1,16 @@
import React from "react";
-import { useNavigate } from "react-router-dom";
import { ReactComponent as HeartIcon } from "../../../assets/images/icons/ic_heart.svg";
+import { Link } from "react-router-dom";
function ItemCard({ item }) {
- const navigate = useNavigate();
-
- const onClickCard = () => {
- navigate(`/items/${item.id}`);
- };
-
return (
-
-
+ // 상품 카드 클릭 시 해당 상품의 상세페이지로 이동
+
+
{item.name}
{item.price.toLocaleString()}원
@@ -20,7 +19,7 @@ function ItemCard({ item }) {
{item.favoriteCount}
-
+
);
}
diff --git a/src/styles/CommonStyles.js b/src/styles/CommonStyles.js
deleted file mode 100644
index da7807691..000000000
--- a/src/styles/CommonStyles.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import styled from "styled-components";
-
-export const Container = styled.div`
- padding: 16px;
-
- @media ${({ theme }) => theme.mediaQuery.tablet} {
- padding: 16px 24px;
- }
-
- @media ${({ theme }) => theme.mediaQuery.desktop} {
- max-width: 1200px;
- padding: 24px 0;
- margin: 0 auto;
- }
-`;
-
-export const SectionTitle = styled.h1`
- font-size: 20px;
- font-weight: bold;
- color: ${({ theme }) => theme.colors.black};
-
- @media ${({ theme }) => theme.mediaQuery.tablet} {
- font-size: 28px;
- }
-`;
-
-export const FlexContainer = styled.div`
- display: flex;
- justify-content: space-between;
- align-items: center;
-`;
-
-export const Button = styled.button`
- background-color: ${({ theme }) => theme.colors.blue[0]};
- color: ${({ theme }) => theme.colors.white};
- padding: 11.5px 23px;
- border-radius: 8px;
- font-size: 16px;
- font-weight: bold;
- cursor: pointer;
-
- &:hover {
- background-color: ${({ theme }) => theme.colors.blue[1]};
- }
-
- &:focus {
- background-color: ${({ theme }) => theme.colors.blue[2]};
- }
-
- &:disabled {
- background-color: ${({ theme }) => theme.colors.gray[0]};
- cursor: default;
- pointer-events: none;
- }
-`;
diff --git a/src/styles/CommonStyles.ts b/src/styles/CommonStyles.ts
new file mode 100644
index 000000000..f7855e6f7
--- /dev/null
+++ b/src/styles/CommonStyles.ts
@@ -0,0 +1,93 @@
+import { Link } from "react-router-dom";
+import styled from "styled-components";
+
+export const Container = styled.div`
+ /* 참고: 최상단 wrapper div를 flexbox로 만들어줬어요 */
+ display: flex;
+ flex-direction: column;
+ padding: 16px;
+
+ @media ${({ theme }) => theme.mediaQuery.tablet} {
+ padding: 16px 24px;
+ }
+
+ @media ${({ theme }) => theme.mediaQuery.desktop} {
+ max-width: 1200px;
+ padding: 24px 0;
+ margin: 0 auto;
+ }
+`;
+
+export const SectionTitle = styled.h1`
+ font-size: 20px;
+ font-weight: bold;
+ color: ${({ theme }) => theme.colors.gray[800]};
+
+ @media ${({ theme }) => theme.mediaQuery.tablet} {
+ font-size: 28px;
+ }
+`;
+
+export const FlexContainer = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+`;
+
+export const Button = styled.button`
+ background-color: ${({ theme }) => theme.colors.blue.primary};
+ color: ${({ theme }) => theme.colors.white};
+ padding: 11.5px 23px;
+ border-radius: 8px;
+ font-size: 16px;
+ font-weight: bold;
+ cursor: pointer;
+
+ &:hover {
+ background-color: ${({ theme }) => theme.colors.blue.hover};
+ }
+
+ &:focus {
+ background-color: ${({ theme }) => theme.colors.blue.focus};
+ }
+
+ &:disabled {
+ background-color: ${({ theme }) => theme.colors.gray[400]};
+ cursor: default;
+ pointer-events: none;
+ }
+`;
+
+export const StyledLink = styled(Link)`
+ background-color: ${({ theme }) => theme.colors.blue.primary};
+ color: ${({ theme }) => theme.colors.white};
+ padding: 11.5px 23px;
+ border-radius: ${(props) => (props.$pill ? "999px" : "8px")};
+ font-size: 16px;
+ font-weight: bold;
+ cursor: pointer;
+
+ &:hover {
+ background-color: ${({ theme }) => theme.colors.blue.hover};
+ }
+
+ &:focus {
+ background-color: ${({ theme }) => theme.colors.blue.focus};
+ }
+
+ &:disabled {
+ background-color: ${({ theme }) => theme.colors.gray[400]};
+ cursor: default;
+ pointer-events: none;
+ }
+`;
+
+// 구분선을 만들 때
를 사용할 수도 있지만 thematic break의 의미를 내포하고 있는
태그를 사용하면 보다 semantic하고 접근성을 고려한 코드가 됩니다.
+export const LineDivider = styled.hr`
+ width: 100%;
+ border: none;
+ height: 1px;
+ background-color: var(--gray-200);
+ margin: ${(props) =>
+ props.$margin || "16px 0"}; // margin을 optional prop으로 받기
+`;
diff --git a/src/styles/GlobalStyle.js b/src/styles/GlobalStyle.ts
similarity index 100%
rename from src/styles/GlobalStyle.js
rename to src/styles/GlobalStyle.ts
diff --git a/src/styles/global.css b/src/styles/global.css
index ee71e72ab..44f5f2ecf 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -1,19 +1,23 @@
/* Mobile styles */
+/* Updated color palette */
:root {
/* Gray scale */
- --gray-900: #1b1d1f;
- --gray-800: #26282b;
- --gray-600: #454c53;
- --gray-500: #72787f;
- --gray-400: #9ea4a8;
+ --gray-900: #111827;
+ --gray-800: #1F2937;
+ --gray-700: #374151;
+ --gray-600: #4b5563;
+ --gray-500: #6b7280;
+ --gray-400: #9ca3af;
--gray-200: #e5e7eb;
- --gray-100: #e8ebed;
- --gray-50: #f7f7f8;
+ --gray-100: #f3f4f6;
+ --gray-50: #f9fafb;
/* Primary color */
--blue: #3692ff;
+ --red: #f74747;
+
/* Layout dimensions */
--header-height: 70px;
}
@@ -56,7 +60,7 @@ svg {
}
body {
- color: #374151;
+ color: var(--gray-700);
word-break: keep-all;
font-family: "Pretendard", sans-serif;
}
@@ -81,8 +85,8 @@ header {
}
footer {
- background-color: #111827;
- color: #9ca3af;
+ background-color: var(--gray-900);
+ color: var(--gray-400);
font-size: 16px;
padding: 32px;
display: flex;
@@ -130,7 +134,7 @@ footer {
}
.button:disabled {
- background-color: #9ca3af;
+ background-color: var(--gray-400);
cursor: default;
pointer-events: none;
}
diff --git a/src/styles/theme.js b/src/styles/theme.js
deleted file mode 100644
index 69d8bd895..000000000
--- a/src/styles/theme.js
+++ /dev/null
@@ -1,19 +0,0 @@
-const colors = {
- blue: ["#3692FF", "#1967D6", "#1251AA"],
- white: "#FFF",
- black: "#1F2937",
- gray: ["#9CA3AF", "#F3F4F6", "#F9FAFB"],
-};
-
-const mediaQuery = {
- mobile: "screen and (max-width: 767px)",
- tablet: "screen and (min-width: 768px)",
- desktop: "screen and (min-width: 1280px)",
-};
-
-const theme = {
- colors,
- mediaQuery,
-};
-
-export default theme;
diff --git a/src/styles/theme.ts b/src/styles/theme.ts
new file mode 100644
index 000000000..36e563d03
--- /dev/null
+++ b/src/styles/theme.ts
@@ -0,0 +1,31 @@
+// Updated color palette
+const colors = {
+ gray: {
+ 900: "#111827",
+ 800: "#1F2937",
+ 700: "#374151",
+ 600: "#4b5563",
+ 500: "#6b7280",
+ 400: "#9ca3af",
+ 200: "#e5e7eb",
+ 100: "#f3f4f6",
+ 50: "#f9fafb",
+ },
+ blue: { primary: "#3692ff", hover: "#1967D6", focus: "#1251AA" },
+ red: "#f74747",
+ white: "#FFF",
+ black: "#000",
+};
+
+const mediaQuery = {
+ mobile: "screen and (max-width: 767px)",
+ tablet: "screen and (min-width: 768px)",
+ desktop: "screen and (min-width: 1280px)",
+};
+
+const theme = {
+ colors,
+ mediaQuery,
+};
+
+export default theme;
diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts
new file mode 100644
index 000000000..ac09876cb
--- /dev/null
+++ b/src/utils/dateUtils.ts
@@ -0,0 +1,44 @@
+// 날짜 관련 Util Functions
+
+// Javascript에서 날짜를 다룰 때는 library 사용을 추천해요.
+// 이번 프로젝트에서는 가장 많이 사용되면서도 가벼운 "date-fns" 패키지를 사용해 볼게요.
+// 참고 링크: https://date-fns.org/
+
+import {
+ format,
+ differenceInDays,
+ differenceInHours,
+ differenceInMinutes,
+ differenceInSeconds,
+} from "date-fns";
+
+// updatedAt 표시: Date string을 현재 시간과 비교한 형식으로 변환해주는 함수
+// 7일 이내까지는 시간이 얼마나 흘렀는지를 분, 시간, 일 단위로 나타내고, 그보다 오래된 날짜는 포맷팅한 문자열로 리턴함
+export const formatUpdatedAt = (dateString) => {
+ const date = new Date(dateString); // 입력된 날짜 문자열을 Date 객체로 변환
+ const now = new Date(); // 현재 기준 Date 객체 생성
+
+ const diffInDays = differenceInDays(now, date); // 현재 시간과 입력된 날짜의 차이를 일(day) 단위로 계산
+ const diffInHours = differenceInHours(now, date); // 현재 시간과 입력된 날짜의 차이를 시간(hour) 단위로 계산
+ const diffInMinutes = differenceInMinutes(now, date); // 현재 시간과 입력된 날짜의 차이를 분(minute) 단위로 계산
+ const diffInSeconds = differenceInSeconds(now, date); // 현재 시간과 입력된 날짜의 차이를 초(second) 단위로 계산
+
+ if (diffInSeconds < 60) {
+ return "방금 전"; // 차이가 1분 미만인 경우 "방금 전" 형식으로 출력
+ } else if (diffInMinutes < 60) {
+ return `${diffInMinutes}분 전`; // 차이가 1시간 미만인 경우 "N분 전" 형식으로 출력
+ } else if (diffInHours < 24) {
+ return `${diffInHours}시간 전`; // 차이가 1일 미만인 경우 "N시간 전" 형식으로 출력
+ } else if (diffInDays < 7) {
+ return `${diffInDays}일 전`; // 차이가 7일 이내인 경우 "N일 전" 형식으로 출력
+ } else {
+ // 차이가 7일 이상인 경우 포맷팅된 날짜 출력
+ return format(date, "yyyy.MM.dd hh:mm a");
+ }
+};
+
+// 날짜 포맷팅 참고:
+// - 대문자 'M'은 month, 소문자 'm'은 minute을 뜻해요.
+// - 'MM'은 month를 두 자리 숫자로 나타낸 것, 'M'은 한자리 숫자로 나타낸 것(예: 5월을 '05'가 아닌 '5'로 출력)
+// - 대문자 'HH'는 24시 체계 (예: 14시), 소문자 'hh'는 12시 체계 (예: 2시)
+// - 소문자 'hh'를 사용해 시간을 나타낼 경우, 'a'를 추가해 AM/PM까지 함께 표기해 주세요. (예: "yyyy.MM.dd hh:mm a")
From 01d356b6ed244c64dd73b0bafd3c153212461bd4 Mon Sep 17 00:00:00 2001
From: ayoooyh <127219927+ayoooyh@users.noreply.github.com>
Date: Fri, 2 Aug 2024 02:35:02 +0900
Subject: [PATCH 3/9] =?UTF-8?q?deleteButton=20=ED=83=80=EC=9E=85=20?=
=?UTF-8?q?=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
package-lock.json | 3 ++-
package.json | 2 +-
src/components/UI/DeleteButton.tsx | 9 +++++++--
src/components/UI/ImageUpload.tsx | 8 +++++---
4 files changed, 15 insertions(+), 7 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 7e4c134df..a32fe0245 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,7 +19,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3",
- "react-scripts": "5.0.1",
+ "react-scripts": "^5.0.1",
"react-spinners": "^0.13.8",
"styled-components": "^6.1.8",
"web-vitals": "^2.1.4"
@@ -15224,6 +15224,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz",
"integrity": "sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ==",
+ "license": "MIT",
"dependencies": {
"@babel/core": "^7.16.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.3",
diff --git a/package.json b/package.json
index 290579109..02a9bccb5 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3",
- "react-scripts": "5.0.1",
+ "react-scripts": "^5.0.1",
"react-spinners": "^0.13.8",
"styled-components": "^6.1.8",
"web-vitals": "^2.1.4"
diff --git a/src/components/UI/DeleteButton.tsx b/src/components/UI/DeleteButton.tsx
index 8244426a0..6409dc9ea 100644
--- a/src/components/UI/DeleteButton.tsx
+++ b/src/components/UI/DeleteButton.tsx
@@ -1,4 +1,4 @@
-import React from "react";
+import React, { MouseEvent } from "react";
import styled from "styled-components";
import { ReactComponent as CloseIcon } from "../../assets/images/icons/ic_x.svg";
@@ -16,7 +16,12 @@ const Button = styled.button`
}
`;
-function DeleteButton({ onClick, label }) {
+interface DeleteButtonProps {
+ onClick: (e: MouseEvent
) => void;
+ label: string;
+}
+
+function DeleteButton({ onClick, label }: DeleteButtonProps) {
return (