diff --git a/.env b/.env new file mode 100644 index 00000000..cdbf5ee1 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +REACT_APP_BASE_URL=https://panda-market-api.vercel.app \ No newline at end of file diff --git a/src/api/authServices.js b/src/api/authServices.js new file mode 100644 index 00000000..ea255835 --- /dev/null +++ b/src/api/authServices.js @@ -0,0 +1,82 @@ +const BASE_URL = process.env.REACT_APP_BASE_URL || ""; + +const handleError = async (res) => { + try { + const errorData = await res.json(); + throw new Error(errorData.message || "요청 처리 중 오류가 발생했습니다."); + } catch { + throw new Error("알 수 없는 오류가 발생했습니다."); + } +}; + +// POST: 회원가입 +const postSignUp = async (signUpData) => { + try { + const res = await fetch(`${BASE_URL}/auth/signUp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(signUpData), + }); + + if (!res.ok) { + return handleError(res); + } + + return res.json(); + } catch (error) { + console.error("회원가입 요청 실패:", error); + throw new Error("회원가입 요청 중 네트워크 오류가 발생했습니다."); + } +}; + +// POST: 로그인 +const postSignIn = async (signInData) => { + try { + const res = await fetch(`${BASE_URL}/auth/signIn`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(signInData), + }); + + if (!res.ok) { + return handleError(res); + } + + return res.json(); + } catch (error) { + console.error("로그인 요청 실패:", error); + throw new Error("로그인 요청 중 네트워크 오류가 발생했습니다."); + } +}; + +// POST: 토큰 갱신 +const postRefreshToken = async (refreshToken) => { + try { + const res = await fetch(`${BASE_URL}/auth/refresh-token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ refreshToken }), + }); + + if (!res.ok) { + return handleError(res); + } + + return res.json(); + } catch (error) { + console.error("토큰 갱신 요청 실패:", error); + throw new Error("토큰 갱신 요청 중 네트워크 오류가 발생했습니다."); + } +}; + +export const authService = { + postSignUp, + postSignIn, + postRefreshToken, +}; diff --git a/src/api/commentService.js b/src/api/commentService.js deleted file mode 100644 index ffae59fa..00000000 --- a/src/api/commentService.js +++ /dev/null @@ -1,15 +0,0 @@ -const getComments = async (productId, limit = 5) => { - const response = await fetch( - `https://panda-market-api.vercel.app/products/${productId}/comments?limit=${limit}` - ); - - if (!response.ok) { - throw new Error("상품 목록 조회에 실패하였습니다."); - } - - return await response.json(); -}; - -export const commentServices = { - getComments, -}; diff --git a/src/api/commentServices.js b/src/api/commentServices.js new file mode 100644 index 00000000..7aaa951b --- /dev/null +++ b/src/api/commentServices.js @@ -0,0 +1,85 @@ +const BASE_URL = + process.env.REACT_APP_BASE_URL || "https://panda-market-api.vercel.app"; + +// POST: 댓글 작성 +const postComment = async (productId, content) => { + const accessToken = localStorage.getItem("accessToken"); + + const res = await fetch(`${BASE_URL}/products/${productId}/comments`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ content }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.message || "댓글 등록에 실패하였습니다."); + } + + return await res.json(); +}; + +// GET: 댓글 목록 조회 +const getComments = async (productId, limit = 5, cursor = null) => { + let url = `${BASE_URL}/products/${productId}/comments?limit=${limit}`; + if (cursor) url += `&cursor=${cursor}`; + + const res = await fetch(url); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.message || "댓글 목록 조회에 실패하였습니다."); + } + + return await res.json(); +}; + +// PATCH: 댓글 수정 +const patchComment = async (commentId, content) => { + const accessToken = localStorage.getItem("accessToken"); + + const res = await fetch(`${BASE_URL}/comments/${commentId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ content }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.message || "댓글 수정에 실패하였습니다."); + } + + return await res.json(); +}; + +// DELETE: 댓글 삭제 +const deleteComment = async (commentId) => { + const accessToken = localStorage.getItem("accessToken"); + + const res = await fetch(`${BASE_URL}/comments/${commentId}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.message || "댓글 삭제에 실패하였습니다."); + } + + return await res.json(); +}; + +export const commentServices = { + postComment, + getComments, + patchComment, + deleteComment, +}; diff --git a/src/asset/icon/back.svg b/src/asset/icon/back.svg new file mode 100644 index 00000000..9ba5ad94 --- /dev/null +++ b/src/asset/icon/back.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/asset/icon/kebab.svg b/src/asset/icon/kebab.svg new file mode 100644 index 00000000..dd7ed7f5 --- /dev/null +++ b/src/asset/icon/kebab.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/asset/icon/large_heart.svg b/src/asset/icon/large_heart.svg new file mode 100644 index 00000000..08f0092d --- /dev/null +++ b/src/asset/icon/large_heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/asset/image/inquiry_empty.png b/src/asset/image/inquiry_empty.png new file mode 100644 index 00000000..02c39826 Binary files /dev/null and b/src/asset/image/inquiry_empty.png differ diff --git a/src/pages/items/itemsDetail/ItemsDetail.jsx b/src/pages/items/itemsDetail/ItemsDetail.jsx new file mode 100644 index 00000000..6ee98d55 --- /dev/null +++ b/src/pages/items/itemsDetail/ItemsDetail.jsx @@ -0,0 +1,307 @@ +import { useParams, useNavigate } from "react-router-dom"; +import "./itemsDetail.css"; +import { useEffect, useState } from "react"; +import { productServices } from "../../../api/productServices"; +import Navbar from "../../../components/layout/Navbar"; +import profileImg from "../../../asset/icon/profile_icon.svg"; +import kebab from "../../../asset/icon/kebab.svg"; +import largeHeart from "../../../asset/icon/large_heart.svg"; +import inquiryEmpty from "../../../asset/image/inquiry_empty.png"; +import backIcon from "../../../asset/icon/back.svg"; +import { commentServices } from "../../../api/commentServices"; + +export default function ItemsDetail() { + const { productId } = useParams(); + const navigate = useNavigate(); + const [productDetail, setProductDetail] = useState({}); + const [commentInput, setCommentInput] = useState(""); + const [comments, setComments] = useState([]); + const [openDropdownId, setOpenDropdownId] = useState(null); + const [editingCommentId, setEditingCommentId] = useState(null); + const [editedContent, setEditedContent] = useState(""); + + const getProductDetail = async (productId) => { + const res = await productServices.getProductDetail(productId); + setProductDetail(res); + }; + + const getComments = async (productId) => { + const res = await commentServices.getComments(productId); + setComments(res.list); + console.log(res); + }; + + const formatPrice = (price) => { + if (!price) return "0"; + return price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); + }; + + const formatDate = (isoDate) => { + const date = new Date(isoDate); + 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}`; + }; + + const formatRelativeTime = (isoDate) => { + const now = new Date(); + const date = new Date(isoDate); + const diff = (now - date) / 1000; // 초 단위 차이 + + if (diff < 60) return "방금 전"; + if (diff < 3600) return `${Math.floor(diff / 60)}분 전`; + if (diff < 86400) return `${Math.floor(diff / 3600)}시간 전`; + if (diff < 172800) return "어제"; + if (diff < 604800) return `${Math.floor(diff / 86400)}일 전`; + + return formatDate(isoDate); + }; + + const toggleDropdown = (commentId) => { + setOpenDropdownId((prev) => (prev === commentId ? null : commentId)); + }; + + const handleDelete = async (commentId) => { + try { + await commentServices.deleteComment(commentId); + setComments((prev) => prev.filter((comment) => comment.id !== commentId)); + } catch (error) { + console.error("댓글 삭제 실패:", error); + alert("댓글 삭제에 실패했습니다."); + } + }; + + const handleCommentSubmit = async (e) => { + e.preventDefault(); + if (!commentInput.trim()) return; + await commentServices.postComment(productId, commentInput); + await getComments(productId); // 새로고침 + setCommentInput(""); // 입력창 초기화 + }; + + useEffect(() => { + getProductDetail(productId); + getComments(productId); + }, [productId]); + + return ( + <> + +
+
+ {productDetail && ( +
+
+ {productDetail.images && ( + 상품 이미지 + )} +
+
+
+
+

{productDetail.name}

+ 케밥 +
+

+ {formatPrice(productDetail.price)}원 +

+
+
+

상품 소개

+

+ {productDetail.description} +

+
+
+

상품 태그

+
+ {productDetail.tags && + productDetail.tags.map((tag) => ( +
+ #{tag} +
+ ))} +
+
+
+
+
+ 프로필 이미지 +
+
+
+ {productDetail.ownerNickname} +
+
+ {formatDate(productDetail.createdAt)} +
+
+
+
+
+ 좋아요 +

+ {productDetail.favoriteCount} +

+
+
+
+
+
+ )} +
+
+

문의하기

+