diff --git a/README.md b/README.md
index 1bccddac..1a292c7d 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-## 판다마켓 6
+## 판다마켓 7
-**🌐 배포 url: https://myungjiwoo-pandamarket.netlify.app/additem**
+**🌐 배포 url: https://myungjiwoo-pandamarket.netlify.app/items**
### 기본 요구사항
@@ -10,24 +10,14 @@
### 체크 리스트 (기본)
-- [x] 상품 등록 페이지 주소는 "/additem"이다.
-- [x] 페이지 주소가 "/additem"일 때 상단 네비게이션바의 "중고마켓" 버튼의 색상은 "3692FF"이다.
-- [x] 상품 이미지는 최대 한 개까지 업로드할 수 있다.
-- [x] 각 input의 placeholder 값을 정확히 입력한다.
-- [x] 이미지를 제외하고 input에 모든 값을 입력하면 '등록' 버튼이 활성화 된다. (api를 통한 상품 등록은 추후 미션에서 적용)
+- [x] 상품 상세 페이지 주소는 “/items/{productId}” 이다.
+- [x] 상세 정보 : response 로 받은 아래의 데이터로 화면을 구현한다. (favoriteCount, images, tags, name, description)
+- [x] 목록으로 돌아가기 버튼을 클릭하면 중고마켓 페이지 주소인 “/items” 으로 이동한다.
+- [x] 문의하기에 내용을 입력하면 등록 버튼의 색상은 “3692FF”로 변경된다.
+- [x] 문의 : response 로 받은 아래의 데이터로 화면을 구현한다. (image, nickname, content, description, updatedAt)
+- [x] 문의를 수정하려면 기존 문의글이 input으로 바뀐다.
+- [x] 아무 문의가 없을때는 적절한 안내 문구를 띄워준다.
### 체크 리스트 (심화)
-- [x] 이미지 안의 x 버튼을 누르면 이미지가 삭제된다.
-- [x] 추가된 태그 안의 x 버튼을 누르면 해당 태그는 삭제된다.
-
-### 추가 기능
-
-- [x] 오류 메시지를 토스트 메시지로 구현 (react-toastify 라이브러리 사용)
-
-### 구현 포인트
-
-- [x] 입력 컴포넌트 계층화 및 재사용
- - `Base~ 컴포넌트` : 최소 단위 입력 컴포넌트
- - `~Field 컴포넌트` : 공통 인터페이스를 추가한 확장 컴포넌트 (label, error messge 등)
- - `Item~Field 컴포넌트` : 도메인 전용 컴포넌트
+- [x] 모든 버튼에 자유롭게 Hover 효과를 적용한다.
diff --git a/src/GlobalStyle.jsx b/src/GlobalStyle.jsx
index 5889d7fe..9ffa4fa1 100644
--- a/src/GlobalStyle.jsx
+++ b/src/GlobalStyle.jsx
@@ -24,6 +24,7 @@ const baseStyle = css`
padding: 0;
box-sizing: border-box;
transition: all 100ms ease-in-out;
+ font-family: "Pretendard", sans-serif;
}
html,
diff --git a/src/apis/productApi.js b/src/apis/productApi.js
new file mode 100644
index 00000000..e4c45f3e
--- /dev/null
+++ b/src/apis/productApi.js
@@ -0,0 +1,26 @@
+import { instance } from "@apis/instance";
+
+const getProduct = async (productId) => {
+ try {
+ const { data } = await instance.get(`/products/${productId}`);
+ return data;
+ } catch (error) {
+ throw new Error(`상품 상세 조회 불러오기 실패: ${error.message}`);
+ }
+};
+
+const getProductComments = async (productId, limit = 10, cursor = null) => {
+ try {
+ let url = `/products/${productId}/comments?limit=${limit}`;
+ if (cursor) {
+ url += `&cursor=${cursor}`;
+ }
+
+ const { data } = await instance.get(url);
+ return data;
+ } catch (error) {
+ throw new Error(`상품의 댓글 정보 불러오기 실패: ${error.message}`);
+ }
+};
+
+export { getProduct, getProductComments };
diff --git a/src/assets/icons/back.jsx b/src/assets/icons/back.jsx
new file mode 100644
index 00000000..1bb6e9db
--- /dev/null
+++ b/src/assets/icons/back.jsx
@@ -0,0 +1,19 @@
+const BackIcon = () => {
+ return (
+
+ );
+};
+
+export default BackIcon;
diff --git a/src/assets/icons/more.jsx b/src/assets/icons/more.jsx
new file mode 100644
index 00000000..ab234f5d
--- /dev/null
+++ b/src/assets/icons/more.jsx
@@ -0,0 +1,17 @@
+const MoreIcon = () => {
+ return (
+
+ );
+};
+
+export default MoreIcon;
diff --git a/src/assets/imgs/CommentEmpty@2x.png b/src/assets/imgs/CommentEmpty@2x.png
new file mode 100644
index 00000000..76fe524d
Binary files /dev/null and b/src/assets/imgs/CommentEmpty@2x.png differ
diff --git a/src/components/DropdownMenu.jsx b/src/components/DropdownMenu.jsx
new file mode 100644
index 00000000..ff51683e
--- /dev/null
+++ b/src/components/DropdownMenu.jsx
@@ -0,0 +1,89 @@
+import { useState } from "react";
+import styled from "@emotion/styled";
+import BaseButton from "@/components/common/BaseButton";
+import MoreIcon from "@assets/icons/more";
+
+const DROPDOWN_LIST_POSITION = {
+ left: "right: 0; left: auto;",
+ right: "left: 0; right: auto;",
+};
+
+const DropdownMenu = ({
+ dropdownItem1,
+ onDropdownItem1Click,
+ dropdownItem2,
+ onDropdownItem2Click,
+ position = "left",
+}) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const handleToggle = () => setIsOpen((prev) => !prev);
+
+ const handleDropdownItem1Click = () => {
+ onDropdownItem1Click();
+ setIsOpen(false);
+ };
+
+ const handleDropdownItem2Click = () => {
+ onDropdownItem2Click();
+ setIsOpen(false);
+ };
+
+ return (
+
+
+
+
+
+ {isOpen && (
+
+
+ {dropdownItem1}
+
+
+ {dropdownItem2}
+
+
+ )}
+
+ );
+};
+
+export default DropdownMenu;
+
+const DropdownMenuContainer = styled.div`
+ position: relative;
+`;
+
+const StyledButton = styled(BaseButton)`
+ width: 2.4rem;
+ height: 2.4rem;
+`;
+
+const DropdownList = styled.ul`
+ width: fit-content;
+ position: absolute;
+ top: 3rem;
+ ${(props) => props.listPosition};
+ border-radius: 0.7rem;
+ border: 1px solid var(--gray200);
+ background-color: var(--white);
+`;
+
+const DropdownItem = styled.li`
+ width: 10rem;
+ padding: 0.8rem 1rem;
+ text-align: center;
+ list-style: none;
+ font-size: 1.4rem;
+ color: var(--gray500);
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--gray100);
+ }
+
+ &:first-of-type {
+ border-bottom: 1px solid var(--gray200);
+ }
+`;
diff --git a/src/components/ImageInputField.jsx b/src/components/ImageInputField.jsx
index 5ef8f35e..35468498 100644
--- a/src/components/ImageInputField.jsx
+++ b/src/components/ImageInputField.jsx
@@ -1,6 +1,6 @@
import { memo } from "react";
import styled from "@emotion/styled";
-import BaseImageInput from "@components/BaseImageInput";
+import BaseImageInput from "@/components/common/BaseImageInput";
import DeleteButton from "@components/DeleteButton";
import PlusIcon from "@assets/icons/plus";
diff --git a/src/components/InputField.jsx b/src/components/InputField.jsx
index ccb082eb..0af68548 100644
--- a/src/components/InputField.jsx
+++ b/src/components/InputField.jsx
@@ -1,6 +1,6 @@
import { memo } from "react";
import styled from "@emotion/styled";
-import BaseInput from "@components/BaseInput";
+import BaseInput from "@/components/common/BaseInput";
const InputField = ({ id, label, value, onChange, errorMessage, ...props }) => {
return (
diff --git a/src/components/ProductCard.jsx b/src/components/ProductCard.jsx
index 3b516937..a3d45fe7 100644
--- a/src/components/ProductCard.jsx
+++ b/src/components/ProductCard.jsx
@@ -2,6 +2,7 @@ import { useContext, createContext, useRef } from "react";
import styled from "@emotion/styled";
import HeartIcon from "@assets/icons/heart";
import NotFoundImg from "@assets/imgs/notFoundImage@2x.png";
+import { Link } from "react-router-dom";
const ProductContext = createContext({
id: null,
@@ -22,13 +23,15 @@ const ProductCard = ({ id, src, title, price = 0, like = 0, children }) => {
return (
-
-
-
-
-
- {children}
-
+
+
+
+
+
+
+ {children}
+
+
);
};
diff --git a/src/components/RightIconButton.jsx b/src/components/RightIconButton.jsx
new file mode 100644
index 00000000..943ebe1b
--- /dev/null
+++ b/src/components/RightIconButton.jsx
@@ -0,0 +1,58 @@
+import styled from "@emotion/styled";
+import BaseButton from "@/components/common/BaseButton";
+
+const BUTTON_SIZE = {
+ s: "1.2rem",
+ m: "1.4rem",
+ l: "1.6rem",
+};
+
+const BUTTON_TYPE = {
+ primary: "background-color: var(--blue); color: var(--white);",
+ cancel: "background-color: var(--white); var(--gray500);",
+ danger: "background-color: red; color: var(--white);",
+};
+
+const BUTTON_ROUNDED = {
+ true: "100rem",
+ false: "1rem",
+};
+
+const RightIconButton = ({
+ text,
+ onClick,
+ icon,
+ disabled = false,
+ size = "m",
+ type = "primary",
+ rounded = false,
+}) => {
+ return (
+
+ {text} {icon}
+
+ );
+};
+
+export default RightIconButton;
+
+const StyledButton = styled(BaseButton)`
+ padding: 0.8rem 2rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ border-radius: ${(props) => props.rounded};
+ font-size: ${(props) => props.size};
+ ${(props) => props.type};
+
+ &:disabled {
+ background-color: var(--gray300);
+ cursor: not-allowed;
+ }
+`;
diff --git a/src/components/TextButton.jsx b/src/components/TextButton.jsx
new file mode 100644
index 00000000..c79f42df
--- /dev/null
+++ b/src/components/TextButton.jsx
@@ -0,0 +1,47 @@
+import styled from "@emotion/styled";
+import BaseButton from "@/components/common/BaseButton";
+
+const BUTTON_SIZE = {
+ s: "1.2rem",
+ m: "1.4rem",
+ l: "1.6rem",
+};
+
+const BUTTON_TYPE = {
+ primary: "background-color: var(--blue); color: var(--white);",
+ cancel: "background-color: var(--white); var(--gray500);",
+ danger: "background-color: red; color: var(--white);",
+};
+
+const TextButton = ({
+ text,
+ onClick,
+ disabled = false,
+ size = "m",
+ type = "primary",
+}) => {
+ return (
+
+ {text}
+
+ );
+};
+
+export default TextButton;
+
+const StyledButton = styled(BaseButton)`
+ padding: 0.8rem 2rem;
+ border-radius: 1rem;
+ font-size: ${(props) => props.size};
+ ${(props) => props.type};
+
+ &:disabled {
+ background-color: var(--gray300);
+ cursor: not-allowed;
+ }
+`;
diff --git a/src/components/TextareaField.jsx b/src/components/TextareaField.jsx
index 0a00601b..f24e4873 100644
--- a/src/components/TextareaField.jsx
+++ b/src/components/TextareaField.jsx
@@ -1,15 +1,20 @@
import { memo } from "react";
import styled from "@emotion/styled";
-import BaseTextarea from "@components/BaseTextarea";
+import BaseTextarea from "@/components/common/BaseTextarea";
+// styleType : default | addItem
const TextareaField = ({
id,
label,
value,
onChange,
errorMessage,
+ styleType = "default",
...props
}) => {
+ const { InputSection, Label } =
+ textareaStyleMap[styleType] || textareaStyleMap.default;
+
return (
@@ -21,11 +26,15 @@ const TextareaField = ({
export default memo(TextareaField);
-const InputSection = styled.div`
+const LargeInputSection = styled.div`
margin: 2rem 0;
`;
-const Label = styled.label`
+const InputSection = styled.div`
+ margin: 1rem 0;
+`;
+
+const BoldLabel = styled.label`
display: inline-block;
margin-bottom: 1rem;
font-weight: bold;
@@ -34,7 +43,27 @@ const Label = styled.label`
font-size: 1.8rem;
`;
+const Label = styled.label`
+ display: inline-block;
+ margin-bottom: 1rem;
+ line-height: 2.6rem;
+ color: var(--gray900);
+ font-size: 1.6rem;
+`;
+
const ErrorMessage = styled.p`
color: red;
font-size: 1.4rem;
`;
+
+// styleType에 따른 스타일 매핑
+const textareaStyleMap = {
+ default: {
+ InputSection: InputSection,
+ Label: Label,
+ },
+ addItem: {
+ InputSection: LargeInputSection,
+ Label: BoldLabel,
+ },
+};
diff --git a/src/components/common/BaseButton.jsx b/src/components/common/BaseButton.jsx
new file mode 100644
index 00000000..c8292faa
--- /dev/null
+++ b/src/components/common/BaseButton.jsx
@@ -0,0 +1,24 @@
+import styled from "@emotion/styled";
+
+const BaseButton = ({ onClick, children, ...props }) => {
+ return (
+
+ );
+};
+
+export default BaseButton;
+
+const Button = styled.button`
+ border: none;
+ background: none;
+
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ &:hover {
+ opacity: 0.7;
+ }
+`;
diff --git a/src/components/BaseForm.jsx b/src/components/common/BaseForm.jsx
similarity index 100%
rename from src/components/BaseForm.jsx
rename to src/components/common/BaseForm.jsx
diff --git a/src/components/BaseImageInput.jsx b/src/components/common/BaseImageInput.jsx
similarity index 100%
rename from src/components/BaseImageInput.jsx
rename to src/components/common/BaseImageInput.jsx
diff --git a/src/components/BaseInput.jsx b/src/components/common/BaseInput.jsx
similarity index 100%
rename from src/components/BaseInput.jsx
rename to src/components/common/BaseInput.jsx
diff --git a/src/components/BaseTextarea.jsx b/src/components/common/BaseTextarea.jsx
similarity index 96%
rename from src/components/BaseTextarea.jsx
rename to src/components/common/BaseTextarea.jsx
index a000f67d..08bc3e9e 100644
--- a/src/components/BaseTextarea.jsx
+++ b/src/components/common/BaseTextarea.jsx
@@ -17,6 +17,7 @@ const Textarea = styled.textarea`
border-radius: 1.2rem;
border: 2px solid var(--gray100);
font-size: 1.4rem;
+ line-height: 2.4rem;
&:focus {
outline: none;
diff --git a/src/hooks/useAutoResizeTextarea.js b/src/hooks/useAutoResizeTextarea.js
new file mode 100644
index 00000000..d7eb4436
--- /dev/null
+++ b/src/hooks/useAutoResizeTextarea.js
@@ -0,0 +1,16 @@
+import { useRef } from "react";
+
+export const useAutoResizeTextarea = (onChange) => {
+ const ref = useRef(null);
+
+ const handleChange = (event) => {
+ const textarea = ref.current;
+ if (textarea) {
+ textarea.style.height = "auto";
+ textarea.style.height = `${textarea.scrollHeight}px`;
+ }
+ onChange(event.target.value);
+ };
+
+ return { ref, handleChange };
+};
diff --git a/src/hooks/useObserver.js b/src/hooks/useObserver.js
new file mode 100644
index 00000000..b6054a2d
--- /dev/null
+++ b/src/hooks/useObserver.js
@@ -0,0 +1,25 @@
+import { useEffect } from "react";
+
+/**
+ * Observer Hook
+ * @param {React.RefObject} ref - 관찰 대상 ref
+ * @param {Function} callback - 감지 시 실행할 콜백
+ */
+export const useObserver = (ref, callback) => {
+ useEffect(() => {
+ const target = ref?.current;
+ if (!target) return;
+
+ const observer = new IntersectionObserver((entries) => {
+ if (entries[0].isIntersecting) {
+ callback();
+ }
+ });
+
+ observer.observe(target);
+
+ return () => {
+ if (target) observer.unobserve(target);
+ };
+ }, [ref, callback]);
+};
diff --git a/src/layouts/Header.jsx b/src/layouts/Header.jsx
index 60e9e8ce..e6c0d795 100644
--- a/src/layouts/Header.jsx
+++ b/src/layouts/Header.jsx
@@ -36,7 +36,7 @@ export default Header;
const HeaderContainer = styled.div`
width: 100%;
- padding: 1rem 2.5vw;
+ padding: 1rem 5vw;
display: flex;
justify-content: space-between;
align-items: center;
@@ -46,6 +46,11 @@ const HeaderContainer = styled.div`
top: 0;
z-index: 100;
+ // 태블릿
+ @media (min-width: ${breakpoints.mobile}) {
+ padding: 1rem 2.5vw;
+ }
+
// 데스크탑
@media (min-width: ${breakpoints.desktop}) {
padding: 1rem 10vw;
diff --git a/src/pages/add-item-page/components/ItemDescriptionTextareaField.jsx b/src/pages/add-item-page/components/ItemDescriptionTextareaField.jsx
index 89e9a9ed..592b3b63 100644
--- a/src/pages/add-item-page/components/ItemDescriptionTextareaField.jsx
+++ b/src/pages/add-item-page/components/ItemDescriptionTextareaField.jsx
@@ -21,6 +21,7 @@ const ItemDescriptionInputField = ({ value, onChange }) => {
placeholder="상품 소개를 입력해주세요"
value={value}
onChange={handleInputChange}
+ styleType="addItem"
/>
);
};
diff --git a/src/pages/add-item-page/index.jsx b/src/pages/add-item-page/index.jsx
index eb066f02..f913cca7 100644
--- a/src/pages/add-item-page/index.jsx
+++ b/src/pages/add-item-page/index.jsx
@@ -1,5 +1,5 @@
import { useState } from "react";
-import BaseForm from "@components/BaseForm";
+import BaseForm from "@/components/common/BaseForm";
import HeaderSection from "@pages/add-item-page/sections/HeaderSection";
import ItemImageInputField from "@pages/add-item-page/components/ItemImageInputField";
import ItemNameInputField from "@pages/add-item-page/components/ItemNameInputField";
diff --git a/src/pages/products-detail-page/components/CommentItem.jsx b/src/pages/products-detail-page/components/CommentItem.jsx
new file mode 100644
index 00000000..c09639a9
--- /dev/null
+++ b/src/pages/products-detail-page/components/CommentItem.jsx
@@ -0,0 +1,93 @@
+import { useState } from "react";
+import styled from "@emotion/styled";
+import CommentTextareaField from "@pages/products-detail-page/components/CommentTextareaField";
+import DropdownMenu from "@/components/DropdownMenu";
+import WriterInfo from "@pages/products-detail-page/components/WriterInfo";
+import TextButton from "@/components/TextButton";
+import { useCommentItemHandlers } from "@pages/products-detail-page/hooks/useCommentItemHandlers";
+
+const CommentItem = ({ data }) => {
+ const [comment, setComment] = useState(data.content);
+ const [isEdit, setIsEdit] = useState(false);
+
+ const { cancelEdit, confirmEdit, handleEditClick, handleDeleteClick } =
+ useCommentItemHandlers(setIsEdit);
+
+ return (
+
+ {isEdit ? (
+
+ ) : (
+
+ {comment}
+
+
+ )}
+
+
+
+ {isEdit && (
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default CommentItem;
+
+const CommentItemLayout = styled.div`
+ padding: 2rem 0;
+ border-bottom: 1px solid var(--gray200);
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+`;
+
+const CommentContainer = styled.div`
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+`;
+
+const Comment = styled.p`
+ font-size: 1.4rem;
+ line-height: 2.4rem;
+ flex: 1;
+`;
+
+const ProductMetaSection = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+`;
+
+const ActionButtons = styled.div`
+ display: flex;
+ flex-shrink: 0;
+ gap: 1rem;
+`;
diff --git a/src/pages/products-detail-page/components/CommentTextareaField.jsx b/src/pages/products-detail-page/components/CommentTextareaField.jsx
new file mode 100644
index 00000000..85ebb262
--- /dev/null
+++ b/src/pages/products-detail-page/components/CommentTextareaField.jsx
@@ -0,0 +1,24 @@
+import { memo } from "react";
+import TextareaField from "@components/TextareaField";
+import { useAutoResizeTextarea } from "@/hooks/useAutoResizeTextarea";
+
+const PLACEHOLDER =
+ "개인정보를 공유 및 요청하거나, 명예 훼손, 무단 광고, 불법 정보 유포시 모니터링 후 삭제될 수 있으며, 이에 대한 민형사상 책임은 게시자에게 있습니다.";
+
+const CommentTextareaField = ({ value, onChange, isEdit }) => {
+ const { ref, handleChange } = useAutoResizeTextarea(onChange);
+
+ return (
+
+ );
+};
+
+export default memo(CommentTextareaField);
diff --git a/src/pages/products-detail-page/components/LikeButtonGroup.jsx b/src/pages/products-detail-page/components/LikeButtonGroup.jsx
new file mode 100644
index 00000000..c4250d40
--- /dev/null
+++ b/src/pages/products-detail-page/components/LikeButtonGroup.jsx
@@ -0,0 +1,32 @@
+import styled from "@emotion/styled";
+import HeartIcon from "@assets/icons/heart";
+
+const LikeButtonGroup = ({ likeCount }) => {
+ return (
+
+
+ {Number(likeCount).toLocaleString()}
+
+ );
+};
+export default LikeButtonGroup;
+
+const LikeContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ border-radius: 50rem;
+ background-color: var(--white);
+ border: 1px solid var(--gray200);
+ cursor: pointer;
+
+ &:hover svg {
+ // todo: hover ui
+ }
+`;
+
+const LikeCount = styled.p`
+ font-size: 1.6rem;
+ color: var(--gray500);
+`;
diff --git a/src/pages/products-detail-page/components/Tag.jsx b/src/pages/products-detail-page/components/Tag.jsx
new file mode 100644
index 00000000..383f226e
--- /dev/null
+++ b/src/pages/products-detail-page/components/Tag.jsx
@@ -0,0 +1,16 @@
+import styled from "@emotion/styled";
+
+const Tag = ({ tag }) => {
+ return #{tag};
+};
+
+export default Tag;
+
+const TagWrapper = styled.p`
+ width: fit-content;
+ padding: 0.5rem 1.6rem;
+ background-color: var(--gray100);
+ font-size: 1.6rem;
+ font-weight: 400;
+ border-radius: 50rem;
+`;
diff --git a/src/pages/products-detail-page/components/WriterInfo.jsx b/src/pages/products-detail-page/components/WriterInfo.jsx
new file mode 100644
index 00000000..8791add2
--- /dev/null
+++ b/src/pages/products-detail-page/components/WriterInfo.jsx
@@ -0,0 +1,66 @@
+import styled from "@emotion/styled";
+import { formatDate } from "@/utils/formatDate";
+import DefaultProfile from "/profile@3x.png";
+
+const WRITER_INFO_PROFILE_IMG_SIZE = {
+ s: "3.5rem",
+ m: "4rem",
+};
+
+const WRITER_INFO_TEXT_SIZE = {
+ s: "1.2rem",
+ m: "1.4rem",
+};
+
+const WriterInfo = ({ profileImg, name, updatedAt, size = "m" }) => {
+ return (
+
+
+
+
+
+ {name}
+
+ {formatDate(updatedAt)}
+
+
+
+ );
+};
+
+export default WriterInfo;
+
+const WriterInfoContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+`;
+
+const ProfileImageWrapper = styled.div`
+ width: ${(props) => props.size};
+ height: ${(props) => props.size};
+`;
+
+const ProfileImage = styled.img`
+ width: 100%;
+ height: 100%;
+ border-radius: 100%;
+ aspect-ratio: 1/1;
+ object-fit: cover;
+`;
+
+const WriterTextInfoContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+`;
+
+const Name = styled.p`
+ color: var(--gray600);
+ font-size: ${(props) => props.size};
+`;
+
+const UpdatedAt = styled.p`
+ color: var(--gray300);
+ font-size: ${(props) => props.size};
+`;
diff --git a/src/pages/products-detail-page/hooks/useCommentItemHandlers.js b/src/pages/products-detail-page/hooks/useCommentItemHandlers.js
new file mode 100644
index 00000000..f7de71d1
--- /dev/null
+++ b/src/pages/products-detail-page/hooks/useCommentItemHandlers.js
@@ -0,0 +1,8 @@
+export const useCommentItemHandlers = (setIsEdit) => {
+ const cancelEdit = () => setIsEdit(false);
+ const confirmEdit = () => setIsEdit(false);
+ const handleEditClick = () => setIsEdit(true);
+ const handleDeleteClick = () => console.log("댓글 삭제 로직");
+
+ return { cancelEdit, confirmEdit, handleEditClick, handleDeleteClick };
+};
diff --git a/src/pages/products-detail-page/hooks/useFormValidation.js b/src/pages/products-detail-page/hooks/useFormValidation.js
new file mode 100644
index 00000000..2ebb03bb
--- /dev/null
+++ b/src/pages/products-detail-page/hooks/useFormValidation.js
@@ -0,0 +1,9 @@
+import { useEffect } from "react";
+
+export const useFormValidation = (comment, setBtnAvailable) => {
+ useEffect(() => {
+ const isValid = comment.trim() !== "";
+
+ setBtnAvailable(isValid);
+ }, [comment, setBtnAvailable]);
+};
diff --git a/src/pages/products-detail-page/hooks/useProductDetailHandlers.js b/src/pages/products-detail-page/hooks/useProductDetailHandlers.js
new file mode 100644
index 00000000..46746e16
--- /dev/null
+++ b/src/pages/products-detail-page/hooks/useProductDetailHandlers.js
@@ -0,0 +1,23 @@
+import NotFoundImg from "@assets/imgs/notFoundImage@2x.png";
+
+export const useProductDetailHandlers = (imgRef) => {
+ const handleImgError = () => {
+ if (imgRef?.current && imgRef.current.src !== NotFoundImg) {
+ imgRef.current.src = NotFoundImg;
+ }
+ };
+
+ const handleEditClick = () => {
+ console.log("수정 클릭");
+ };
+
+ const handleDeleteClick = () => {
+ console.log("삭제 클릭");
+ };
+
+ return {
+ handleImgError,
+ handleEditClick,
+ handleDeleteClick,
+ };
+};
diff --git a/src/pages/products-detail-page/index.jsx b/src/pages/products-detail-page/index.jsx
new file mode 100644
index 00000000..3af4ca67
--- /dev/null
+++ b/src/pages/products-detail-page/index.jsx
@@ -0,0 +1,40 @@
+import { useParams, useNavigate } from "react-router-dom";
+import styled from "@emotion/styled";
+import ProductDetailSection from "@/pages/products-detail-page/sections/ProductDetailSection";
+import CommentFormSection from "@/pages/products-detail-page/sections/CommentFormSection";
+import CommentItemsSection from "@/pages/products-detail-page/sections/CommentItemsSection";
+import BackIcon from "@assets/icons/back";
+import RightIconButton from "@/components/RightIconButton";
+
+const ProductsDetailPage = () => {
+ const navigate = useNavigate();
+ const { id: productId } = useParams();
+
+ const handleNavigateToList = () => {
+ navigate("/items");
+ };
+
+ return (
+ <>
+
+
+
+
+ }
+ size="l"
+ rounded
+ />
+
+ >
+ );
+};
+
+export default ProductsDetailPage;
+
+const ButtonWrapper = styled.div`
+ width: fit-content;
+ margin: 2rem auto 0 auto;
+`;
diff --git a/src/pages/products-detail-page/sections/CommentFormSection.jsx b/src/pages/products-detail-page/sections/CommentFormSection.jsx
new file mode 100644
index 00000000..16b22c55
--- /dev/null
+++ b/src/pages/products-detail-page/sections/CommentFormSection.jsx
@@ -0,0 +1,42 @@
+import { useState } from "react";
+import styled from "@emotion/styled";
+import BaseForm from "@/components/common/BaseForm";
+import CommentTextareaField from "@pages/products-detail-page/components/CommentTextareaField";
+import { useFormValidation } from "@pages/products-detail-page/hooks/useFormValidation";
+import TextButton from "@/components/TextButton";
+
+const CommentFormSection = () => {
+ const [btnAvailable, setBtnAvailable] = useState(false);
+ const [comment, setComment] = useState("");
+
+ useFormValidation(comment, setBtnAvailable);
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ console.log(comment);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CommentFormSection;
+
+const CommentFormLayout = styled.div`
+ margin-top: 4rem;
+`;
+
+const ButtonWrapper = styled.div`
+ width: fit-content;
+ margin-left: auto;
+ display: block;
+`;
diff --git a/src/pages/products-detail-page/sections/CommentItemsSection.jsx b/src/pages/products-detail-page/sections/CommentItemsSection.jsx
new file mode 100644
index 00000000..6b6cbc54
--- /dev/null
+++ b/src/pages/products-detail-page/sections/CommentItemsSection.jsx
@@ -0,0 +1,59 @@
+import { useState, useEffect, useRef, useCallback } from "react";
+import styled from "@emotion/styled";
+import CommentItem from "@pages/products-detail-page/components/CommentItem";
+import CommentEmptyImage from "@assets/imgs/CommentEmpty@2x.png";
+import { getProductComments } from "@apis/productApi";
+import { useObserver } from "@/hooks/useObserver";
+
+const CommentItemsSection = ({ productId }) => {
+ const [comments, setComments] = useState([]);
+ const [cursor, setCursor] = useState(null);
+ const [hasNext, setHasNext] = useState(true);
+ const observerRef = useRef();
+
+ const loadComments = useCallback(async () => {
+ if (!hasNext) return;
+
+ const data = await getProductComments(productId, 10, cursor);
+ setComments((prev) => [...prev, ...data.list]);
+ setCursor(data.nextCursor);
+ setHasNext(!!data.nextCursor);
+ }, [productId, cursor, hasNext]);
+
+ useEffect(() => {
+ loadComments();
+ }, [loadComments]);
+
+ // 마지막 요소를 감지하는 observer
+ useObserver(observerRef, loadComments);
+
+ return comments.length > 0 ? (
+ comments.map((comment) => )
+ ) : (
+
+
+ 아직 문의가 없어요
+
+ );
+};
+
+export default CommentItemsSection;
+
+const CommentEmptyContainer = styled.div`
+ margin: 4.5rem 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+
+ img {
+ width: 25%;
+ min-width: 14rem;
+ height: auto;
+ }
+
+ p {
+ font-size: 1.4rem;
+ color: var(--gray300);
+ }
+`;
diff --git a/src/pages/products-detail-page/sections/ProductDetailSection.jsx b/src/pages/products-detail-page/sections/ProductDetailSection.jsx
new file mode 100644
index 00000000..9c27b99a
--- /dev/null
+++ b/src/pages/products-detail-page/sections/ProductDetailSection.jsx
@@ -0,0 +1,192 @@
+import { useState, useEffect, useRef } from "react";
+import styled from "@emotion/styled";
+import { breakpoints } from "@constants/breakpoints";
+import { getProduct } from "@apis/productApi";
+import NotFoundImg from "@assets/imgs/notFoundImage@2x.png";
+import Tag from "@pages/products-detail-page/components/Tag";
+import WriterInfo from "@pages/products-detail-page/components/WriterInfo";
+import DropdownMenu from "@/components/DropdownMenu";
+import LikeButtonGroup from "../components/LikeButtonGroup";
+import { useProductDetailHandlers } from "../hooks/useProductDetailHandlers";
+
+const ProductDetailSection = ({ productId }) => {
+ const imgRef = useRef(null);
+ const [detailData, setDetailData] = useState({});
+ const {
+ name,
+ price,
+ description,
+ tags,
+ ownerNickname,
+ updatedAt,
+ favoriteCount,
+ images,
+ } = detailData;
+
+ const { handleImgError, handleEditClick, handleDeleteClick } =
+ useProductDetailHandlers(imgRef);
+
+ useEffect(() => {
+ const fetchProduct = async () => {
+ try {
+ const data = await getProduct(productId);
+ setDetailData(data);
+ } catch (error) {
+ console.error(error.message);
+ }
+ };
+
+ fetchProduct();
+ }, [productId]);
+
+ return (
+
+
+
+
+
+ {name}
+ {Number(price).toLocaleString()}원
+
+
+
+
+
+
+ 상품 소개
+ {description}
+
+
+
+ 상품 태그
+
+ {tags?.map((tag) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ProductDetailSection;
+
+const ResponsiveLayout = styled.div`
+ padding-bottom: 2rem;
+ display: flex;
+ gap: 2rem;
+ border-bottom: 1px solid var(--gray200);
+
+ @media (max-width: ${breakpoints.tablet}) {
+ flex-direction: column;
+ }
+`;
+
+const ProductImage = styled.img`
+ width: 50%;
+ height: 50%;
+ max-width: 40rem;
+ max-height: 40rem;
+ border-radius: 1.6rem;
+ object-fit: cover;
+ aspect-ratio: 1/1;
+ flex-shrink: 0;
+
+ @media (max-width: ${breakpoints.tablet}) {
+ width: 100%;
+ max-width: 100%;
+ max-height: 100%;
+ }
+`;
+
+const ProductContentSection = styled.div`
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 3rem;
+`;
+
+const ProductHeader = styled.div`
+ padding-bottom: 2rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ position: relative;
+ border-bottom: 1px solid var(--gray200);
+`;
+
+const Title = styled.h1`
+ max-width: calc(100% - 4rem);
+ margin: 0;
+ padding: 0;
+ font-size: 2rem;
+ font-weight: 600;
+`;
+
+const Price = styled.h2`
+ margin: 0;
+ padding: 0;
+ font-size: 3.2rem;
+ font-weight: 600;
+`;
+
+const DropdownMenuWrapper = styled.div`
+ width: fit-content;
+ position: absolute;
+ top: 0;
+ right: 0;
+`;
+
+const ProductDescriptionSection = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+`;
+
+const ProductDetailsTitle = styled.h3`
+ color: var(--gray600);
+ font-size: 1.6rem;
+ font-weight: 600;
+`;
+
+const ProductDetailsContent = styled.p`
+ font-size: 1.6rem;
+ font-weight: 400;
+ margin-bottom: 0.5rem;
+ line-height: 2.6rem;
+ word-break: break-word;
+`;
+
+const ProductTagsContainer = styled.div`
+ display: flex;
+ gap: 0.7rem 0.5rem;
+ flex-wrap: wrap;
+`;
+
+const ProductMetaSection = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 2rem;
+`;
diff --git a/src/routes/index.jsx b/src/routes/index.jsx
index 67136343..b2a74e4f 100644
--- a/src/routes/index.jsx
+++ b/src/routes/index.jsx
@@ -5,6 +5,7 @@ import ItemsPage from "@pages/items-page";
import LandingPage from "@pages/landing-page";
import BoardsPage from "@pages/boards-page";
import AddItemPage from "@pages/add-item-page";
+import ProductsDetailPage from "@pages/products-detail-page";
const router = createBrowserRouter([
{
@@ -19,6 +20,10 @@ const router = createBrowserRouter([
path: "items",
element: ,
},
+ {
+ path: "items/:id",
+ element: ,
+ },
{
path: "boards",
element: ,
diff --git a/src/utils/formatDate.js b/src/utils/formatDate.js
new file mode 100644
index 00000000..a96bef89
--- /dev/null
+++ b/src/utils/formatDate.js
@@ -0,0 +1,12 @@
+export const formatDate = (dateString) => {
+ if (!dateString) return "";
+
+ const date = new Date(dateString);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ const hours = String(date.getHours()).padStart(2, "0");
+ const minutes = String(date.getMinutes()).padStart(2, "0");
+
+ return `${year}.${month}.${day} ${hours}:${minutes}`;
+};