Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/apis/commentApi.js
Original file line number Diff line number Diff line change
@@ -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}`);
}
};
11 changes: 11 additions & 0 deletions src/apis/detailApi.js
Original file line number Diff line number Diff line change
@@ -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}`);
}
};
17 changes: 17 additions & 0 deletions src/assets/icons/icon_return.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export default function ReturnIcon() {
return (
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.53333 3.60012C6.03627 3.60012 5.63333 4.00307 5.63333 4.50012C5.63333 4.99718 6.03627 5.40012 6.53333 5.40012V3.60012ZM6.53333 5.40012H16.6667V3.60012H6.53333V5.40012ZM21.1 9.83345V10.9001H22.9V9.83345H21.1ZM16.6667 15.3335H6.53333V17.1335H16.6667V15.3335ZM21.1 10.9001C21.1 13.3486 19.1151 15.3335 16.6667 15.3335V17.1335C20.1092 17.1335 22.9 14.3427 22.9 10.9001H21.1ZM16.6667 5.40012C19.1151 5.40012 21.1 7.38499 21.1 9.83345H22.9C22.9 6.39088 20.1092 3.60012 16.6667 3.60012V5.40012Z"
fill="white"
/>
<path d="M3 16.2335L10.2 12.5384L10.2 19.9285L3 16.2335Z" fill="white" />
</svg>
);
}
15 changes: 15 additions & 0 deletions src/assets/icons/icon_vertical-ellipsis.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export default function VerticalEllipsis() {
return (
<svg
width="3"
height="13"
viewBox="0 0 3 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="1.5" cy="1.5" r="1.5" fill="#9CA3AF" />
<circle cx="1.5" cy="6.5" r="1.5" fill="#9CA3AF" />
<circle cx="1.5" cy="11.5" r="1.5" fill="#9CA3AF" />
</svg>
);
}
Binary file added src/assets/images/no-comments.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions src/assets/utils.js
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 칭찬
이런 formatting 로직들은 사이트에서 재사용되는 경우가 대부분이니 따로 분리해주신 점 좋아요!

Original file line number Diff line number Diff line change
@@ -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");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 칭찬
내장 메소드로 간단하게 코드 작성하신 것 너무 좋아요~
다만 지금 코드의 경우 ${day}일 전의 형태로만 나오고 있어요.
디자인을 확인해보시면, days, months 등의 다양한 형태로 나오고 있으니
추후 기획대로 변경해보시면 더 좋을 것 같아요!


return formatter.format(diff, "days");
}

export function formatDateToYMD(dateString) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💊 제안
formatToTimeAgo 함수에서 Intl 을 이용하신 것처럼 지금 함수에서도 Intl.DateTimeFormat을 이용해서 작성하실 수 있습니다~

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat

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}`;
}
6 changes: 4 additions & 2 deletions src/components/AllProductList.jsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -20,7 +21,8 @@ export default function AllProductList({ data = [] }) {
if (index >= display.desktop) hiddenClass += " xl:hidden";

return (
<div
<Link
to={`/items/${item.id}`}
key={item.id}
className={`flex flex-col gap-2 w-full h-full ${hiddenClass}`}
>
Expand All @@ -39,7 +41,7 @@ export default function AllProductList({ data = [] }) {
<HeartIcon />
{item.favoriteCount}
</p>
</div>
</Link>
);
})}
</div>
Expand Down
7 changes: 4 additions & 3 deletions src/components/BestProducts.jsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -27,7 +27,8 @@ export default function BestProductList({ data = [] }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-5 w-full mt-10 text-[1.6rem] text-[#1F2937]">
{filteredData.map((item) => (
<div
<Link
to={`/items/${item.id}`}
key={item.id}
className={`flex flex-col gap-2 w-full ${item.hiddenClass}`}
>
Expand All @@ -38,7 +39,7 @@ export default function BestProductList({ data = [] }) {
<HeartIcon />
{item.favoriteCount}
</p>
</div>
</Link>
))}
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion src/components/Nav.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
"
Expand Down
244 changes: 244 additions & 0 deletions src/pages/ProductDetail.jsx
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💊 제안
하나의 파일이 너무 큰 것 같아요~
내부적으로 util,custom hook으로 로직을 분리하고 UI 들도 컴포넌트로 분리되면, 가독성과 재사용성 측면에서 더 좋을 것 같아요.
또한 코드를 분리하는 것은 생각의 단위를 나누는 효과도 있습니다.
다른 파일들처럼 분리하시는 것을 고려해보세요~

Original file line number Diff line number Diff line change
@@ -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 <p className="text-center animate-bounce ">로딩 중..</p>;
}

if (!item) {
return <p className="text-center animate-bounce">상품 정보가 없습니다.</p>;
}

return (
<div className="max-w-[120rem] mx-auto p-7">
<div className="flex flex-col md:flex-row w-full h-[25%] gap-10 mx-auto text-[#4B5563] border-b-gray-200 border-b pb-10">
<div className="w-full md:w-[35%] h-full">
<img
className="h-full aspect-1/1 rounded-3xl"
src={item.images[0] || noImage}
/>
</div>
<div className="flex flex-col w-full md:w-[65%] justify-between">
<div className="flex flex-col gap-5 mb-5 text-[#1F2937]">
<div className="flex justify-between pr-5 ">
<h3 className="text-[2rem] font-[600]">{item.name}</h3>
<VerticalEllipsis />
</div>
<h1 className="text-[3.2rem] font-[600]">
{item.price.toLocaleString()}원
</h1>
</div>
<div className="flex flex-col gap-5 mb-5">
<h5 className="text-[1.4rem] font-[600]">상품 소개</h5>
<p className="text-[1.2rem] font-[400] h-[10vh]">
{item.description}
</p>
</div>
<div className="flex flex-col gap-5 mb-5">
<h5 className="text-[1.4rem] font-[600]">상품 태그</h5>
<div className="flex gap-2 flex-wrap mt-2">
{item.tags &&
item.tags.map((tag) => (
Comment on lines +96 to +97
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💊 제안
item 의 기본값이 null이므로 아래처럼 optional chaining을 이용해서 작성하셔도 됩니다~

Suggested change
{item.tags &&
item.tags.map((tag) => (
{item?.tags?.map((tag) => (

<div
key={tag}
className="bg-gray-100 text-black px-3 py-2 rounded-full flex items-center text-[1.2rem]"
>
#{tag}
</div>
))}
</div>
</div>
<div className="flex w-full justify-between items-center bg-transparent">
<div className="flex h-full items-center gap-3">
<img
src={avatar}
className="bg-gray-300 rounded-full size-[4rem]"
alt="Owner Avatar"
/>
<div className="flex flex-col py-1 justify-between h-full">
<p className="text-[#4B5563]">{item.ownerNickname}</p>
<p className="text-[#9CA3AF]">
{formatDateToYMD(item.updatedAt)}
</p>
</div>
</div>
<div className="flex border border-[#E5E7EB] rounded-full px-2 py-0.5">
<HeartIcon />
<p>{item.favoriteCount}</p>
</div>
Comment on lines +121 to +124
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💊 제안
추후 좋아요 버튼으로 활용될 영역이니 button 태그가 더 적절할 것 같아요~

</div>
</div>
</div>
<div className="flex flex-col gap-5 mt-10">
<h3 className="text-[1.4rem] font-[600]">문의하기</h3>
<textarea
id="productDescription"
placeholder="개인정보를 공유 및 요청하시거나, 명예 훼손, 무단 광고, 불법 정보 유포시 모니터링 후 삭제될 수 있으며, 이에 대한 민형사상 책임은 게시자에게 있습니다."
rows="5"
className="w-full p-4 bg-[#F3F4F6] placeholder:text-[#9CA3AF] placeholder:text-[1.4rem]"
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
Comment on lines +135 to +136
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💊 제안
지금처럼 textarea 값을 useState로 제어하면 사용자가 입력할 때마다 컴포넌트가 리렌더링됩니다.
지금 코드상에서는 textarea 값을 바꿔주는 것이 아니라 제어 컴포넌트로 관리할 필요가 없을 것 같아요!
이를 비제어 컴포넌트로 바꾸셔서 불필요한 리렌더링을 줄이시는 것을 추천드려요!

등록 버튼의 활성화를 위해서는 isValidComment와 같은 다른 props를 만드시는게 더 적절할 것 같아요.

/>
<div className="flex justify-end ">
<button
type="submit"
className={`py-2.5 px-4 rounded-md transition-all text-white cursor-pointer
${
commentText.trim() === ""
? "bg-gray-300 cursor-not-allowed"
: "bg-[#3692FF] hover:scale-120"
}
`}
Comment on lines +139 to +147
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💊 제안

Suggested change
<button
type="submit"
className={`py-2.5 px-4 rounded-md transition-all text-white cursor-pointer
${
commentText.trim() === ""
? "bg-gray-300 cursor-not-allowed"
: "bg-[#3692FF] hover:scale-120"
}
`}
<button
type="submit"
className={`py-2.5 px-4 rounded-md transition-all text-white cursor-pointer ${commentText.trim() === "" ? "bg-gray-300 cursor-not-allowed" : "bg-[#3692FF] hover:scale-120"}`}

disabled={commentText.trim() === ""} // 입력 없으면 비활성화
>
등록
</button>
</div>
</div>
<div className="flex flex-col gap-10 my-10">
{comments.length !== 0 ? (
comments.map((comment) => (
<div className="flex flex-col gap-10 border-b border-b-gray-200 pb-5">
Comment on lines +156 to +157
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❗️ 수정요청

Suggested change
comments.map((comment) => (
<div className="flex flex-col gap-10 border-b border-b-gray-200 pb-5">
comments.map((comment) => (
<div key={comment.id} className="flex flex-col gap-10 border-b border-b-gray-200 pb-5">

{/* ✅ 수정 input 영역 */}
{editingCommentId === comment.id && (
<div className="flex flex-col gap-2 mb-2">
<textarea
type="text"
value={editedContent}
rows="2"
onChange={(e) => setEditedContent(e.target.value)}
className="border p-2 rounded text-[1.4rem] bg-[#F3F4F6] text-[#1F2937]"
placeholder="수정할 내용을 입력하세요"
/>
<div className="flex justify-end gap-2">
<button
className="text-[1rem] text-[#737373] font-[600] px-5 py-2 border border-gray-300 rounded-xl"
onClick={() => setEditingCommentId(null)}
>
취소
</button>
<button
className="text-[1rem] font-[600] px-5 py-2 bg-[#3692FF] text-white rounded-xl"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❗️ 수정요청

Suggested change
className="text-[1rem] font-[600] px-5 py-2 bg-[#3692FF] text-white rounded-xl"
className="text-[1rem] font-[600] px-5 py-2 bg-[#3692FF] text-white rounded-xl cursor-pointer"

onClick={() => setEditingCommentId(null)}
>
수정 완료
</button>
</div>
</div>
)}
<div className="flex justify-between pr-5">
<p className="text-[1.2rem] font-[400]">{comment.content}</p>
<div className="relative">
<button
className="cursor-pointer"
onClick={() =>
setOpenMenuId(
openMenuId === comment.id ? null : comment.id
)
}
>
<VerticalEllipsis />
</button>
{openMenuId === comment.id && (
<div className="absolute right-0 mt-2 w-32 bg-white border border-gray-200 rounded shadow-md z-10">
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💊 제안
메뉴 외의 영역을 눌렀을때도 닫히게 해주시면 더 사용성이 좋아질 것 같아요.

<button
className="w-full text-left px-4 py-2 text-sm hover:bg-[#3692FF] hover:text-white"
onClick={() => handleEdit(comment.id, comment.content)}
>
수정하기
</button>
<button className="w-full text-left px-4 py-2 text-sm hover:bg-[#3692FF] hover:text-white">
삭제하기
</button>
</div>
)}
</div>
</div>
<div className="flex h-full items-center gap-3">
<img
src={avatar}
className="bg-gray-300 rounded-full size-[4rem]"
alt="Owner Avatar"
/>
<div className="flex flex-col py-1 justify-between h-full">
<p>{comment.writer.nickname}</p>
<p className="text-gray-400">
{formatToTimeAgo(comment.updatedAt)}
</p>
</div>
</div>
</div>
))
) : (
<div className="flex justify-center items-center w-full h-full">
<img className="aspect-square w-1/6" src={noComment} />
</div>
)}
</div>
<div className="flex justify-center">
<Link
to="/items"
className="bg-[#3692FF] flex text-white items-center text-[1.6rem] py-3 px-5 rounded-full transition-all hover:scale-120"
>
목록으로 돌아가기 <ReturnIcon />
</Link>
</div>
</div>
);
}
2 changes: 1 addition & 1 deletion src/pages/Products.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default function Products() {
page: 1,
pageSize: 4,
});
console.log("best", data);

setBestItems(data.list);
} catch (error) {
setError(error.message);
Expand Down
Loading
Loading