diff --git a/src/App.js b/src/App.js index 650bd69f4..f239b08f2 100644 --- a/src/App.js +++ b/src/App.js @@ -2,12 +2,14 @@ import "./App.css"; import { BrowserRouter as Router, Route, Routes } from "react-router-dom"; import ItemsPage from "./pages/ItemPage/ItemsPage"; import AddItemPage from "./pages/AddItemPage/AddItemPage"; +import ItemDetailPage from "./pages/ItemDetailPage/ItemDetailPage"; function App() { return ( } /> + } /> } /> diff --git a/src/api/product.js b/src/api/product.js index 574b03dc7..b4787bc0e 100644 --- a/src/api/product.js +++ b/src/api/product.js @@ -28,3 +28,55 @@ export const fetchProducts = async (orderBy, pageSize) => { } } }; + +export const fetchProductById = async (productId) => { + try { + const response = await fetch(`${BASE_URL}/${productId}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + handleResponseError(response); + } + + const data = await response.json(); + return data; + } catch (error) { + if (error instanceof HttpException) { + throw error; + } else { + console.error("네트워크 오류", error); + throw new Error("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + } + } +}; + +export const fetchProductCommentById = async (productId, limit = 10) => { + const params = new URLSearchParams({ limit: limit }); + + try { + const response = await fetch(`${BASE_URL}/${productId}/comments?${params.toString()}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + handleResponseError(response); + } + + const data = await response.json(); + return data; + } catch (error) { + if (error instanceof HttpException) { + throw error; + } else { + console.error("네트워크 오류", error); + throw new Error("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + } + } +}; diff --git a/src/assets/icons/ic_back.svg b/src/assets/icons/ic_back.svg new file mode 100644 index 000000000..014359ce4 --- /dev/null +++ b/src/assets/icons/ic_back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/ic_kebab.svg b/src/assets/icons/ic_kebab.svg new file mode 100644 index 000000000..51b03fba0 --- /dev/null +++ b/src/assets/icons/ic_kebab.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/images/Img_inquiry_empty.svg b/src/assets/images/Img_inquiry_empty.svg new file mode 100644 index 000000000..5444cbbbc --- /dev/null +++ b/src/assets/images/Img_inquiry_empty.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/components/section/CommentList/CommentList.css b/src/components/section/CommentList/CommentList.css new file mode 100644 index 000000000..ceec17ab6 --- /dev/null +++ b/src/components/section/CommentList/CommentList.css @@ -0,0 +1,59 @@ +.comments-list { + display: flex; + flex-direction: column; +} + +.comment-wrapper { + display: flex; + flex-direction: column; + padding-bottom: 12px; + border-bottom: 1px solid #e5e7eb; +} + +.comment-author { + display: flex; +} + +.comment-options { + width: 16px; + height: 16px; + cursor: pointer; + margin-left: auto; +} + +.comment-content { + font-size: 14px; + color: #1f2937; + padding-top: 14px; +} + +.comment-profile-image { + padding-right: 15px; +} + +.comment-author-detail { + display: flex; + flex-direction: column; + gap: 4px; +} + +.comment-nickname { + font-size: 12px; + color: #4b5563; + line-height: 18px; +} + +.comment-date { + font-size: 12px; + line-height: 18px; + color: #9ca3af; +} + +.comment-empty { + margin: 0 auto; + text-align: center; +} + +.comment-empty-text { + color: #9ca3af; +} diff --git a/src/components/section/CommentList/CommentList.js b/src/components/section/CommentList/CommentList.js new file mode 100644 index 000000000..f355844b0 --- /dev/null +++ b/src/components/section/CommentList/CommentList.js @@ -0,0 +1,41 @@ +import { formatRelativeTime } from "../../../utils/formatRelativeTime"; +import "./CommentList.css"; +import profile from "../../../assets/images/profile.svg"; +import ic_kebab from "../../../assets/icons/ic_kebab.svg"; +import inquiry_empty from "../../../assets/images/Img_inquiry_empty.svg"; + +function CommentList({ comments }) { + const getRelativeTime = (createdAt, updatedAt) => { + if (updatedAt && updatedAt !== createdAt) { + return formatRelativeTime(updatedAt); + } + return formatRelativeTime(createdAt); + }; + + return ( +
+ {comments?.length > 0 ? ( + comments.map((comment) => ( +
+

{comment.content}

+ 수정 삭제 버튼 +
+ {`${comment.writer.nickname} +
+

{comment.writer.nickname}

+

{getRelativeTime(comment.createdAt, comment.updatedAt)}

+
+
+
+ )) + ) : ( +
+ 댓글 없음 +

아직 문의가 없어요

+
+ )} +
+ ); +} + +export default CommentList; diff --git a/src/components/section/InquiryForm/InquiryForm.css b/src/components/section/InquiryForm/InquiryForm.css new file mode 100644 index 000000000..fcdfaa8b2 --- /dev/null +++ b/src/components/section/InquiryForm/InquiryForm.css @@ -0,0 +1,29 @@ +.inquiry-form { + width: 100%; + max-width: 1200px; + margin: 0 auto; + position: relative; + padding: 30px 0; +} + +.custom-inquiry-input { + height: 104px; +} + +.inquiry-button { + padding: 12px 23px; + border-radius: 8px; + border: none; + background-color: #9ca3af; + color: #f3f4f6; + transition: background-color 0.3s ease; +} + +.inquiry-button.active { + background-color: #3692ff; +} + +.inquiry-button-wrapper { + display: flex; + justify-content: flex-end; +} diff --git a/src/components/section/InquiryForm/InquiryForm.js b/src/components/section/InquiryForm/InquiryForm.js new file mode 100644 index 000000000..5b0b66375 --- /dev/null +++ b/src/components/section/InquiryForm/InquiryForm.js @@ -0,0 +1,34 @@ +import { useState } from "react"; +import InputItem from "../../ui/Input/InputItem"; +import "./InquiryForm.css"; + +const inquiryPlacholder = + "개인정보를 공유 및 요청하거나, 명예훼손, 무단 광고, 불법 정보 유포시 모니터링 후 삭제될 수 있으며, 이에 대한 민형사상 책임은 게시자에게 있습니다."; + +function InquiryForm() { + const [inputValue, setInputValue] = useState(""); + + const handleInputChange = (e) => { + setInputValue(e.target.value); + }; + + return ( +
+ +
+ +
+ + ); +} + +export default InquiryForm; diff --git a/src/components/section/ProductInfo/ProductInfo.css b/src/components/section/ProductInfo/ProductInfo.css new file mode 100644 index 000000000..132bc5410 --- /dev/null +++ b/src/components/section/ProductInfo/ProductInfo.css @@ -0,0 +1,114 @@ +.item-detail-info { + display: flex; + width: 100%; + gap: 24px; + padding-bottom: 25px; + border-bottom: 1px solid #e5e7eb; +} + +.item-detail-thumbnail { + width: 100%; + max-width: 486px; + height: auto; + object-fit: cover; + overflow: hidden; + aspect-ratio: 1; + border-radius: 16px; + margin-bottom: 16px; +} + +.item-detail-options { + width: 3px; + height: 13px; +} + +.item-detail-info-section { + width: 100%; + max-width: 690px; + display: flex; + flex-direction: column; + justify-content: space-around; +} + +.item-detail-header { + display: flex; + justify-content: space-between; + padding-bottom: 10px; + border-bottom: 1px solid #e5e7eb; + width: 100%; +} + +.item-detail-name { + font-size: 24px; + font-weight: 600; + color: #1f2937; + padding-bottom: 8px; +} + +.item-detail-price { + font-size: 40px; + font-weight: 600; + line-height: 47px; + color: #1f2937; +} + +.item-detail-description { + display: flex; + flex-direction: column; + padding: 14px 0; + gap: 20px; +} + +.item-detail-description h3 { + font-size: 16px; + font-weight: 600; + color: #4b5563; +} + +.item-detail-description p { + color: #4b5563; +} + +.item-detail-tags { + display: flex; + flex-direction: column; + padding: 14px 0; + gap: 20px; +} + +.item-detail-tags h3 { + font-size: 16px; + font-weight: 600; + color: #4b5563; +} + +.item-detail-tags-wrapper { + display: flex; + list-style: none; + gap: 10px; +} + +.item-detail-tag { + list-style: none; + padding: 6px 16px; + background-color: #f3f4f6; + border-radius: 26px; + color: #1f2937; +} + +@media (max-width: 1199px) { + .item-detail-thumbnail { + width: 100%; + } +} + +@media (max-width: 767px) { + .item-detail-info { + flex-direction: column; + } + + .item-detail-thumbnail { + max-width: 100%; + width: 100%; + } +} diff --git a/src/components/section/ProductInfo/ProductInfo.js b/src/components/section/ProductInfo/ProductInfo.js new file mode 100644 index 000000000..d33809a27 --- /dev/null +++ b/src/components/section/ProductInfo/ProductInfo.js @@ -0,0 +1,44 @@ +import "./ProductInfo.css"; +import ic_kebab from "../../../assets/icons/ic_kebab.svg"; +import ProductMeta from "./ProductMeta"; + +function ProductInfo({ images, name, price, description, tags, meta }) { + return ( +
+ {name + +
+
+
+

{name}

+

{price.toLocaleString()}원

+
+ 수정 삭제 버튼 +
+ +
+

상품 소개

+

{description}

+
+ +
+

상품 태그

+
+ {tags && tags.length > 0 ? ( + tags.map((tag) => ( +
  • + #{tag} +
  • + )) + ) : ( +
  • + )} +
    +
    + +
    +
    + ); +} + +export default ProductInfo; diff --git a/src/components/section/ProductInfo/ProductMeta.css b/src/components/section/ProductInfo/ProductMeta.css new file mode 100644 index 000000000..9bfe681b9 --- /dev/null +++ b/src/components/section/ProductInfo/ProductMeta.css @@ -0,0 +1,52 @@ +.item-detail-meta { + display: flex; + justify-content: space-between; + padding-top: 30px; +} + +.item-detail-meta-section { + display: flex; + gap: 16px; +} + +.item-detail-meta-wrapper { + display: flex; + flex-direction: column; + gap: 5px; +} + +.item-detail-meta-wrapper p:nth-child(1) { + font-size: 14px; + font-weight: 500; + color: #4b5563; +} + +.item-detail-meta-wrapper p:nth-child(2) { + font-size: 14px; + color: #9ca3af; +} + +.likebutton-wrapper { + padding-left: 16px; + border-left: 1px solid #e5e7eb; +} + +.item-detail-likebutton { + display: flex; + justify-content: center; + align-items: center; + gap: 5px; + width: 87px; + height: 40px; + padding: 4px 12px; + background-color: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 35px; + color: #6b7280; + font-size: 20px; +} + +.item-detail-likebutton img { + width: 20px; + vertical-align: top; +} diff --git a/src/components/section/ProductInfo/ProductMeta.js b/src/components/section/ProductInfo/ProductMeta.js new file mode 100644 index 000000000..963fce446 --- /dev/null +++ b/src/components/section/ProductInfo/ProductMeta.js @@ -0,0 +1,34 @@ +import React from "react"; +import "./ProductMeta.css"; +import profile from "../../../assets/images/profile.svg"; +import heart_icon from "../../../assets/icons/heart-icon.svg"; + +function ProductMeta({ ownerNickname, createdAt, favoriteCount }) { + const formatDate = (dateString) => { + 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"); + return `${year} ${month}.${day}`; + }; + + return ( +
    +
    + 프로필 이미지 +
    +

    {ownerNickname}

    +

    {formatDate(createdAt)}

    +
    +
    +
    + +
    +
    + ); +} + +export default ProductMeta; diff --git a/src/components/ui/Header/Header.css b/src/components/ui/Header/Header.css index 7827ee065..2afbb949e 100644 --- a/src/components/ui/Header/Header.css +++ b/src/components/ui/Header/Header.css @@ -22,7 +22,13 @@ header { padding: 21px 15px; } +.header-logo-link { + text-decoration: none; + display: inline-block; +} + .header-logo { + display: block; width: 153px; height: 51px; } diff --git a/src/components/ui/Header/Header.js b/src/components/ui/Header/Header.js index 3c5ce3d40..c06558c77 100644 --- a/src/components/ui/Header/Header.js +++ b/src/components/ui/Header/Header.js @@ -7,7 +7,9 @@ function Header() { return (
    - 로고 + + 로고 +
    자유게시판 중고마켓 diff --git a/src/components/ui/Input/InputItem.js b/src/components/ui/Input/InputItem.js index 754738b02..44fc08a7e 100644 --- a/src/components/ui/Input/InputItem.js +++ b/src/components/ui/Input/InputItem.js @@ -1,6 +1,6 @@ import "./InputItem.css"; -function InputItem({ title, placeholder, id, value, onChange, isTextArea }) { +function InputItem({ title, placeholder, id, value, onChange, isTextArea, className }) { return (
    @@ -13,7 +13,7 @@ function InputItem({ title, placeholder, id, value, onChange, isTextArea }) { value={value} onChange={onChange} placeholder={placeholder} - className="input-description" + className={`input-description ${className}`} /> ) : ( diff --git a/src/components/ui/Item/ItemCard.js b/src/components/ui/Item/ItemCard.js index 21dbd6911..6d00f7b8b 100644 --- a/src/components/ui/Item/ItemCard.js +++ b/src/components/ui/Item/ItemCard.js @@ -1,21 +1,23 @@ import heartIcon from "../../../assets/icons/heart-icon.svg"; import defaultImg from "../../../assets/images/item-default-img-md.svg"; - +import { Link } from "react-router-dom"; import "./ItemCard.css"; function ItemCard({ item }) { return ( -
    - 0 ? item.images[0] : defaultImg} className="itemCardThumbnail" /> -
    -

    {item.name}

    -

    {item.price.toLocaleString()}원

    -
    - 좋아요 -  {item.favoriteCount} + +
    + 0 ? item.images[0] : defaultImg} className="itemCardThumbnail" /> +
    +

    {item.name}

    +

    {item.price.toLocaleString()}원

    +
    + 좋아요 +  {item.favoriteCount} +
    -
    + ); } diff --git a/src/hooks/useProductDetails.js b/src/hooks/useProductDetails.js new file mode 100644 index 000000000..df2b516c2 --- /dev/null +++ b/src/hooks/useProductDetails.js @@ -0,0 +1,49 @@ +import { useState, useEffect } from "react"; +import { fetchProductById, fetchProductCommentById } from "../api/product"; +import { HttpException } from "../utils/exceptions"; + +export function useProductDetails(productId) { + const [item, setItem] = useState(null); + const [comments, setComments] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const getProductById = async (productId) => { + try { + const data = await fetchProductById(productId); + setItem(data); + } catch (error) { + if (error instanceof HttpException) { + setError(error.message); + } else { + setError("알 수 없는 오류가 발생했습니다."); + } + } finally { + setLoading(false); + } + }; + + const getProductCommentById = async (productId) => { + try { + const { list } = await fetchProductCommentById(productId); + setComments(list); + } catch (error) { + if (error instanceof HttpException) { + setError(error.message); + } else { + setError("알 수 없는 오류가 발생했습니다"); + } + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (productId) { + getProductById(productId); + getProductCommentById(productId); + } + }, [productId]); + + return { item, comments, error, loading }; +} diff --git a/src/pages/ItemDetailPage/ItemDetailPage.css b/src/pages/ItemDetailPage/ItemDetailPage.css new file mode 100644 index 000000000..df3c129fb --- /dev/null +++ b/src/pages/ItemDetailPage/ItemDetailPage.css @@ -0,0 +1,35 @@ +.item-detail-wrapper { + margin: 0 auto; + padding: 45px 0; + max-width: 1200px; + width: 100%; +} + +.back-to-list { + display: block; + padding: 12px 43px; + margin: 50px auto; + background-color: #3692ff; + border: none; + border-radius: 40px; + color: #f3f4f6; + font-size: 18px; + font-weight: 600; + text-align: center; + transition: background-color 0.3s ease; +} + +.back-to-list:hover { + background-color: #2a7ae4; +} + +.back-to-list-img { + padding-left: 5px; + vertical-align: middle; +} + +@media (max-width: 1199px) { + .item-detail-wrapper { + padding: 45px 20px; + } +} diff --git a/src/pages/ItemDetailPage/ItemDetailPage.js b/src/pages/ItemDetailPage/ItemDetailPage.js new file mode 100644 index 000000000..973cd64a7 --- /dev/null +++ b/src/pages/ItemDetailPage/ItemDetailPage.js @@ -0,0 +1,73 @@ +import "./ItemDetailPage.css"; +import ic_back from "../../assets/icons/ic_back.svg"; +import { useParams } from "react-router-dom"; +import { useProductDetails } from "../../hooks/useProductDetails"; +import { useNavigate } from "react-router-dom"; +import Header from "../../components/ui/Header/Header"; +import ProductInfo from "../../components/section/ProductInfo/ProductInfo"; +import InquiryForm from "../../components/section/InquiryForm/InquiryForm"; +import CommentList from "../../components/section/CommentList/CommentList"; + +function ItemDetailPage() { + const navigate = useNavigate(); + const { productId } = useParams(); + const { item, comments, error, loading } = useProductDetails(productId); + + if (loading) { + return ( +
    +
    +

    로딩 중...

    +
    + ); + } + + if (error) { + return ( +
    +
    +

    오류 발생: {error}

    +
    + ); + } + + if (!item) { + return ( +
    +
    +

    상품 정보를 찾을 수 없습니다.

    +
    + ); + } + + return ( +
    +
    +
    + + + + + + +
    +
    + ); +} + +export default ItemDetailPage; diff --git a/src/utils/formatRelativeTime.js b/src/utils/formatRelativeTime.js new file mode 100644 index 000000000..81f82482d --- /dev/null +++ b/src/utils/formatRelativeTime.js @@ -0,0 +1,27 @@ +export function formatRelativeTime(dateString) { + const now = new Date(); + const past = new Date(dateString); + const diffInSeconds = Math.floor((now - past) / 1000); + + if (diffInSeconds < 0) { + return "방금 전"; + } + + const intervals = [ + { label: "년", seconds: 365 * 24 * 60 * 60 }, + { label: "개월", seconds: 30 * 24 * 60 * 60 }, + { label: "일", seconds: 24 * 60 * 60 }, + { label: "시간", seconds: 60 * 60 }, + { label: "분", seconds: 60 }, + { label: "초", seconds: 1 }, + ]; + + for (let interval of intervals) { + const count = Math.floor(diffInSeconds / interval.seconds); + if (count >= 1) { + return `${count}${interval.label} 전`; + } + } + + return "방금 전"; +}