diff --git a/src/apis/commentApi.js b/src/apis/commentApi.js
new file mode 100644
index 00000000..deba3fbd
--- /dev/null
+++ b/src/apis/commentApi.js
@@ -0,0 +1,20 @@
+import { instance } from "./instance";
+
+export const getComments = async ({
+ productId,
+ limit = "3",
+ cursor = null,
+} = {}) => {
+ if (!productId) {
+ throw new Error("productId가 필요합니다.");
+ }
+ try {
+ const params = { limit, cursor };
+ const { data } = await instance.get(`products/${productId}/comments`, {
+ params,
+ });
+ return data;
+ } catch (error) {
+ throw new Error(`데이터 불러오기 실패: ${error.message}`);
+ }
+};
diff --git a/src/apis/detailApi.js b/src/apis/detailApi.js
new file mode 100644
index 00000000..904a364a
--- /dev/null
+++ b/src/apis/detailApi.js
@@ -0,0 +1,11 @@
+import { instance } from "./instance";
+
+export const getItemDetail = async (productId) => {
+ try {
+ const id = Number(productId);
+ const { data } = await instance.get(`products/${id}`);
+ return data;
+ } catch (error) {
+ throw new Error(`데이터 불러오기 실패: ${error.message}`);
+ }
+};
diff --git a/src/assets/icons/icon_return.jsx b/src/assets/icons/icon_return.jsx
new file mode 100644
index 00000000..880b5e10
--- /dev/null
+++ b/src/assets/icons/icon_return.jsx
@@ -0,0 +1,17 @@
+export default function ReturnIcon() {
+ return (
+
+ );
+}
diff --git a/src/assets/icons/icon_vertical-ellipsis.jsx b/src/assets/icons/icon_vertical-ellipsis.jsx
new file mode 100644
index 00000000..37b738a1
--- /dev/null
+++ b/src/assets/icons/icon_vertical-ellipsis.jsx
@@ -0,0 +1,15 @@
+export default function VerticalEllipsis() {
+ return (
+
+ );
+}
diff --git a/src/assets/images/no-comments.png b/src/assets/images/no-comments.png
new file mode 100644
index 00000000..e7287b95
Binary files /dev/null and b/src/assets/images/no-comments.png differ
diff --git a/src/assets/utils.js b/src/assets/utils.js
new file mode 100644
index 00000000..c33d87e7
--- /dev/null
+++ b/src/assets/utils.js
@@ -0,0 +1,18 @@
+export function formatToTimeAgo(date) {
+ const dayInMs = 1000 * 60 * 60 * 24;
+ const time = new Date(date).getTime();
+ const now = new Date().getTime();
+ const diff = Math.round((time - now) / dayInMs);
+
+ const formatter = new Intl.RelativeTimeFormat("ko");
+
+ return formatter.format(diff, "days");
+}
+
+export function formatDateToYMD(dateString) {
+ const date = new Date(dateString);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0"); // 0-indexed
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}.${month}.${day}`;
+}
diff --git a/src/components/AllProductList.jsx b/src/components/AllProductList.jsx
index cbc4175e..12a40829 100644
--- a/src/components/AllProductList.jsx
+++ b/src/components/AllProductList.jsx
@@ -1,3 +1,4 @@
+import { Link } from "react-router-dom";
import HeartIcon from "../assets/icons/icon_heart";
import noImage from "../assets/images/no-image.png";
@@ -20,7 +21,8 @@ export default function AllProductList({ data = [] }) {
if (index >= display.desktop) hiddenClass += " xl:hidden";
return (
-
@@ -39,7 +41,7 @@ export default function AllProductList({ data = [] }) {
{item.favoriteCount}
-
+
);
})}
diff --git a/src/components/BestProducts.jsx b/src/components/BestProducts.jsx
index 9a627115..d6de92ff 100644
--- a/src/components/BestProducts.jsx
+++ b/src/components/BestProducts.jsx
@@ -1,5 +1,5 @@
+import { Link } from "react-router-dom";
import HeartIcon from "../assets/icons/icon_heart";
-import ProductList from "./AllProductList";
export default function BestProductList({ data = [] }) {
const display = {
@@ -27,7 +27,8 @@ export default function BestProductList({ data = [] }) {
return (
{filteredData.map((item) => (
-
@@ -38,7 +39,7 @@ export default function BestProductList({ data = [] }) {
{item.favoriteCount}
-
+
))}
);
diff --git a/src/components/Nav.jsx b/src/components/Nav.jsx
index 1fff8130..3d0d6ac7 100644
--- a/src/components/Nav.jsx
+++ b/src/components/Nav.jsx
@@ -13,7 +13,7 @@ export default function Nav() {
className="
max-w-[1920px]
mx-auto
- px-[2rem] sm:px-[5rem] md:px-[10rem] lg:px-[20rem]
+ px-[2rem] sm:px-[2rem] md:px-[2rem] lg:px-[20rem]
py-4
flex justify-between
"
diff --git a/src/pages/ProductDetail.jsx b/src/pages/ProductDetail.jsx
new file mode 100644
index 00000000..f45d4bae
--- /dev/null
+++ b/src/pages/ProductDetail.jsx
@@ -0,0 +1,244 @@
+import { Link, useParams } from "react-router-dom";
+import { getItemDetail } from "../apis/detailApi";
+import { useEffect, useState } from "react";
+import noImage from "../assets/images/no-image.png";
+import avatar from "../assets/images/avatar.png";
+import HeartIcon from "../assets/icons/icon_heart";
+import VerticalEllipsis from "../assets/icons/icon_vertical-ellipsis";
+import { getComments } from "../apis/commentApi";
+import { formatDateToYMD, formatToTimeAgo } from "../assets/utils";
+import ReturnIcon from "../assets/icons/icon_return";
+import noComment from "../assets/images/no-comments.png";
+
+export default function ProductDetail() {
+ const [item, setItem] = useState(null);
+ const [comments, setComments] = useState([]);
+ const [openMenuId, setOpenMenuId] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [editingCommentId, setEditingCommentId] = useState(null);
+ const [editedContent, setEditedContent] = useState("");
+ const [commentText, setCommentText] = useState("");
+ const { productId } = useParams();
+
+ const getDetailData = async () => {
+ try {
+ setIsLoading(true);
+ const data = await getItemDetail(productId);
+ setItem(data);
+ } catch (error) {
+ console.error("상품 상세 데이터 오류", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const getCommentsData = async () => {
+ try {
+ setIsLoading(true);
+ const data = await getComments({ productId });
+ setComments(data.list);
+ } catch (error) {
+ console.error("코멘트 데이터 오류", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (productId) {
+ getDetailData();
+ getCommentsData();
+ }
+ }, [productId]);
+
+ const handleEdit = (id, content) => {
+ setEditingCommentId(id);
+ setEditedContent(content);
+ setOpenMenuId(null); // 드롭다운 닫기
+ };
+
+ if (isLoading) {
+ return 로딩 중..
;
+ }
+
+ if (!item) {
+ return 상품 정보가 없습니다.
;
+ }
+
+ return (
+
+
+
+

+
+
+
+
+
{item.name}
+
+
+
+ {item.price.toLocaleString()}원
+
+
+
+
상품 소개
+
+ {item.description}
+
+
+
+
상품 태그
+
+ {item.tags &&
+ item.tags.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+
+
+
+

+
+
{item.ownerNickname}
+
+ {formatDateToYMD(item.updatedAt)}
+
+
+
+
+
+
{item.favoriteCount}
+
+
+
+
+
+
+ {comments.length !== 0 ? (
+ comments.map((comment) => (
+
+ {/* ✅ 수정 input 영역 */}
+ {editingCommentId === comment.id && (
+
+ )}
+
+
{comment.content}
+
+
+ {openMenuId === comment.id && (
+
+
+
+
+ )}
+
+
+
+

+
+
{comment.writer.nickname}
+
+ {formatToTimeAgo(comment.updatedAt)}
+
+
+
+
+ ))
+ ) : (
+
+

+
+ )}
+
+
+
+ 목록으로 돌아가기
+
+
+
+ );
+}
diff --git a/src/pages/Products.jsx b/src/pages/Products.jsx
index 457c1191..bb531afb 100644
--- a/src/pages/Products.jsx
+++ b/src/pages/Products.jsx
@@ -38,7 +38,7 @@ export default function Products() {
page: 1,
pageSize: 4,
});
- console.log("best", data);
+
setBestItems(data.list);
} catch (error) {
setError(error.message);
diff --git a/src/router.jsx b/src/router.jsx
index 84735d09..d1875d1f 100644
--- a/src/router.jsx
+++ b/src/router.jsx
@@ -3,6 +3,7 @@ import App from "./App";
import Products from "./pages/Products";
import "./index.css";
import AddItem from "./pages/AddItem";
+import ProductDetail from "./pages/ProductDetail";
const router = createBrowserRouter([
{
@@ -13,6 +14,10 @@ const router = createBrowserRouter([
path: "items",
element: ,
},
+ {
+ path: "items/:productId",
+ element: ,
+ },
{
path: "additem",
element: ,
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 00000000..ad4a4996
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,3 @@
+{
+ "rewrites": [{ "source": "/(.*)", "destination": "/" }]
+}