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}`; +};