diff --git a/src/App.js b/src/App.js index 74736b32..b39d2dee 100644 --- a/src/App.js +++ b/src/App.js @@ -3,26 +3,32 @@ import HomePage from "./pages/HomePage/HomePage"; import LoginPage from "./pages/LoginPage/LoginPage"; import MarketPage from "./pages/MarketPage/MarketPage"; import AddItemPage from "./pages/AddItemPage/AddItemPage"; +import ItemDetailPage from "./pages/ItemsDetailPage/ItemsDetailPage"; import CommunityFeedPage from "./pages/CommunityFeedPage/CommunityFeedPage"; +import { ThemeProvider } from "styled-components"; import Header from "./components/Layout/Header"; +import theme from "./styles/theme"; function App() { return ( - - {/* Global Navigation Bar */} -
+ + + {/* Global Navigation Bar */} +
-
- - {/* React Router v6부터는 path="/" 대신 간단하게 `index`라고 표기하면 돼요 */} - } /> - } /> - } /> - } /> - } /> - -
- +
+ + {/* React Router v6부터는 path="/" 대신 간단하게 `index`라고 표기하면 돼요 */} + } /> + } /> + } /> + } /> + } /> + } /> + +
+ + ); } diff --git a/src/api/commentApi.js b/src/api/commentApi.js new file mode 100644 index 00000000..7b827092 --- /dev/null +++ b/src/api/commentApi.js @@ -0,0 +1,12 @@ +//commentApi.js +import axios from "axios"; + +const BASE_URL = process.env.REACT_APP_BASE_URL; + +export async function getComments(productId) { + const response = await axios.get( + `${BASE_URL}/products/${productId}/comments?limit=3` + ); + + return response.data.list || []; +} diff --git a/src/api/itemApi.js b/src/api/itemApi.js index 0a086253..5ebf24ec 100644 --- a/src/api/itemApi.js +++ b/src/api/itemApi.js @@ -14,3 +14,9 @@ export async function getProducts({ page, pageSize, orderBy, keyword }) { throw new Error(`HTTP error: ${error.response?.status || error.message}`); } } + +export async function getProductInfo(productId) { + const response = await axios.get(`${BASE_URL}/products/${productId}`); + + return response.data; +} diff --git a/src/assets/images/icons/backToListBtn.svg b/src/assets/images/icons/backToListBtn.svg new file mode 100644 index 00000000..756c9556 --- /dev/null +++ b/src/assets/images/icons/backToListBtn.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/images/icons/ic_kebab.svg b/src/assets/images/icons/ic_kebab.svg new file mode 100644 index 00000000..63a0344c --- /dev/null +++ b/src/assets/images/icons/ic_kebab.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/images/icons/ic_user.svg b/src/assets/images/icons/ic_user.svg new file mode 100644 index 00000000..0480454d --- /dev/null +++ b/src/assets/images/icons/ic_user.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/icons/img_default.svg b/src/assets/images/icons/img_default.svg new file mode 100644 index 00000000..5304bee9 --- /dev/null +++ b/src/assets/images/icons/img_default.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/components/Comments.jsx b/src/components/Comments.jsx new file mode 100644 index 00000000..e03cf76c --- /dev/null +++ b/src/components/Comments.jsx @@ -0,0 +1,45 @@ +import * as S from "./Comments.styles"; +import ic_user from "../assets/images/icons/ic_user.svg"; +import EditDropdown from "./common/EditDropdown"; + +export default function Comments({ comments }) { + return ( + + {comments.map((comment) => ( + + + {comment.content} + + + + + + + {comment.writer?.nickname || "알 수 없음"} + + {getTimeAgo(comment.createdAt)} + + + + ))} + + ); +} + +function getTimeAgo(createdAt) { + if (!createdAt) return "방금 전"; + + const now = new Date(); + const createdDate = new Date(createdAt); + const diffInMs = now - createdDate; // 밀리초 단위 차이 + const diffInHours = diffInMs / (1000 * 60 * 60); // 시간 단위 변환 + + if (diffInHours < 1) { + return "방금 전"; + } else if (diffInHours < 24) { + return `${Math.floor(diffInHours)}시간 전`; // 24시간 미만이면 "n시간 전" + } else { + const diffInDays = Math.floor(diffInHours / 24); + return `${diffInDays}일 전`; // 1일 이상이면 "n일 전" + } +} diff --git a/src/components/Comments.styles.jsx b/src/components/Comments.styles.jsx new file mode 100644 index 00000000..a7743b5b --- /dev/null +++ b/src/components/Comments.styles.jsx @@ -0,0 +1,55 @@ +import styled from "styled-components"; +import theme from "../styles/theme"; + +export const CommentContainer = styled.div` + margin-top: 16px; +`; + +export const InquiryItem = styled.div` + padding: 14px; + border-bottom: 1px solid ${theme.colors.Gray200}; + display: flex; + flex-direction: column; + gap: 12px; +`; + +export const CommentHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 10px; +`; + +export const CommentText = styled.p` + font: ${theme.fonts.H6Regular}; + color: ${theme.colors.Gray800}; +`; + +export const UserInfo = styled.div` + display: flex; + align-items: center; + gap: 12px; +`; + +export const ProfileImage = styled.img` + width: 32px; + height: 32px; + border-radius: 50%; +`; + +export const UserDetails = styled.div` + display: flex; + flex-direction: column; +`; + +export const Nickname = styled.span` + font: ${theme.fonts.H8}; + color: ${theme.colors.Gray600}; +`; + +export const TimeAgo = styled.span` + font-size: 12px; + color: ${theme.colors.Gray400}; + margin-top: 4px; +`; diff --git a/src/components/ItemDetail.jsx b/src/components/ItemDetail.jsx new file mode 100644 index 00000000..ee19a695 --- /dev/null +++ b/src/components/ItemDetail.jsx @@ -0,0 +1,91 @@ +// itemDetail.jsx +import * as S from "./ItemDetail.styles"; +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { getProductInfo } from "../api/itemApi"; +import img_default from "../assets/images/icons/img_default.svg"; +import ic_heart from "../assets/images/icons/ic_heart.svg"; +import ic_user from "../assets/images/icons/ic_user.svg"; +import EditDropdown from "./common/EditDropdown"; + +export default function ItemDetail() { + const { productId } = useParams(); + const [product, setProduct] = useState({ + name: "", + description: "", + price: 0, + tags: [], + images: null, + favoriteCount: 0, + createdAt: "", + updatedAt: "", + ownerNickname: "", + }); + const [isImgError, setIsImgError] = useState(false); + + useEffect(() => { + getProductInfo(productId) + .then((result) => setProduct(result)) + .catch((error) => console.error(error)); + }, [productId]); + + return ( + + {/* 아이템 이미지 */} + {product.images && !isImgError ? ( + setIsImgError(false)} + onError={() => setIsImgError(true)} + /> + ) : ( + + + + )} + + {/* 아이템 정보 */} + +
+ + + {product.name} + + + {product.price.toLocaleString()}원 + + +
+ 상품소개 + {product.description} +
+ + 상품태그 + + {product.tags.map((tag) => ( + #{tag} + ))} + + +
+
+ + + {/* 판매자 정보 */} + + 판매자 프로필이미지 + + {product.ownerNickname} + {product.createdAt} + + + {/* 좋아요버튼 */} + + + {product.favoriteCount} + + +
+
+ ); +} diff --git a/src/components/ItemDetail.styles.jsx b/src/components/ItemDetail.styles.jsx new file mode 100644 index 00000000..87f91321 --- /dev/null +++ b/src/components/ItemDetail.styles.jsx @@ -0,0 +1,173 @@ +//itemDetail.styles.jsx +import styled from "styled-components"; +import theme from "../styles/theme"; + +export const DetailContainer = styled.div` + max-width: 1200px; + width: 100%; + display: flex; + justify-content: flex-start; + align-items: center; + gap: 24px; + padding-bottom: 40px; + border-bottom: 1px solid #e5e7eb; +`; + +export const Image = styled.img` + max-width: 500px; + width: 100%; + height: auto; + aspect-ratio: 1/1; + border-radius: 16px; + background-color: ${theme.colors.Gray200}; +`; + +export const NoneImageContainer = styled.div` + max-width: 500px; + width: 100%; + flex-shrink: 0; + aspect-ratio: 1/1; + border-radius: 16px; + background-color: ${theme.colors.Gray200}; + display: flex; + justify-content: center; + align-items: center; +`; + +export const NoneImage = styled.img` + width: 60px; + height: 60px; +`; + +export const Detail = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 62px; +`; + +export const Header = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + padding-bottom: 16px; + margin-bottom: 16px; + border-bottom: 1px solid ${theme.colors.Gray200}; +`; + +export const TitleWrapper = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const Title = styled.h1` + font: ${theme.fonts.H2Bold}; + color: ${theme.colors.Gray800}; +`; + +export const Price = styled.h1` + font: ${theme.fonts.H0}; + color: ${theme.colors.Gray800}; +`; + +export const ItemInfo = styled.div` + display: flex; + flex-direction: column; + gap: 24px; + font: ${theme.fonts.H5Regular}; + color: ${theme.colors.Gray600}; + min-height: 200px; /* 상품 정보 섹션 최소 높이 유지 */ +`; + +export const Label = styled.p` + font: ${theme.fonts.H5Bold}; + color: ${theme.colors.Gray600}; + padding-bottom: 16px; + max-height: 5em; /* 최대 5줄까지만 표시 */ + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 5; /* 5줄 이상이면 숨김 */ + -webkit-box-orient: vertical; + white-space: normal; /* 줄바꿈 유지 */ +`; + +export const Content = styled.p` + font: ${theme.fonts.H5Regular}; + color: ${theme.colors.Gray600}; +`; + +export const TagContainer = styled.div` + display: flex; + flex-direction: column; + gap: 14px; +`; + +export const TagWrapper = styled.div` + display: flex; + gap: 10px; +`; + +export const Tag = styled.span` + display: flex; + align-items: center; + justify-content: center; + height: 36px; + padding: 6px 12px 6px 16px; + border-radius: 26px; + background-color: ${theme.colors.Gray100}; + color: ${theme.fonts.H5Regular}; + gap: 10px; +`; + +export const UserWrapper = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 16px 0; +`; + +export const SellerContainer = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +export const SellerInfo = styled.div` + display: flex; + flex-direction: column; +`; + +export const SellerName = styled.p` + font: ${theme.fonts.H7Regular}; + color: ${theme.colors.Gray600}; +`; + +export const Updated = styled.p` + font: ${theme.fonts.H7Regular}; + color: ${theme.colors.Gray400}; +`; + +export const LikeWrapper = styled.div` + display: flex; + align-items: center; + gap: 10px; + border: 1px solid ${theme.colors.Gray200}; + border-radius: 20px; + padding: 6px 12px; + background: none; + cursor: pointer; + transition: 0.2s; +`; + +export const Like = styled.img` + width: 24px; + height: 24px; +`; + +export const LikeCount = styled.p` + font: ${theme.fonts.H5Regular}; + color: ${theme.colors.Gray500}; +`; diff --git a/src/components/ProductInquiry.jsx b/src/components/ProductInquiry.jsx new file mode 100644 index 00000000..15ee47ac --- /dev/null +++ b/src/components/ProductInquiry.jsx @@ -0,0 +1,98 @@ +// ProductInquiry.jsx +import { useState, useEffect } from "react"; +import styled from "styled-components"; +import theme from "../styles/theme"; +import Comments from "./Comments"; + +export default function ProductInquiry({ productId }) { + const [comment, setComment] = useState(""); + const [comments, setComments] = useState([]); + + const handleKeyDown = (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + setComment(""); + } + }; + + useEffect(() => { + if (!productId) return; + + fetch( + `https://panda-market-api.vercel.app/products/${productId}/comments?limit=10` + ) + .then((res) => res.json()) + .then((data) => { + setComments(data.list || []); + }) + .catch(console.error); + }, [productId]); + + return ( + + 문의하기 + + setComment(e.target.value)} + onKeyDown={handleKeyDown} + /> + + + + + + ); +} + +const Container = styled.div` + padding: 16px; + width: 1200px; +`; + +const Title = styled.h2` + font-size: 18px; + font-weight: bold; + margin-bottom: 12px; +`; + +const FormContainer = styled.div` + display: flex; + flex-direction: column; + align-items: flex-end; + width: 100%; + position: relative; + gap: 24px; +`; + +const Input = styled.textarea` + height: 104px; + width: 100%; + padding: 16px 24px; + border: none; + border-radius: 12px; + resize: none; + min-height: 80px; + font-size: 14px; + color: ${theme.colors.Gray400}; + background-color: ${theme.colors.Gray100}; + outline: none; +`; + +const Button = styled.button.attrs(({ $active }) => ({ + "data-active": $active || undefined, +}))` + width: 74px; + height: 42px; + padding: 10px; + border: none; + border-radius: 12px; + background-color: ${({ $active }) => + $active ? theme.colors.Primary200 : theme.colors.Gray400}; + color: white; + font-size: 16px; + cursor: ${({ $active }) => ($active ? "pointer" : "default")}; +`; diff --git a/src/components/common/EditDropdown.jsx b/src/components/common/EditDropdown.jsx new file mode 100644 index 00000000..641bff31 --- /dev/null +++ b/src/components/common/EditDropdown.jsx @@ -0,0 +1,53 @@ +// EditDropdown.jsx +import { useState } from "react"; +import styled from "styled-components"; +import theme from "../../styles/theme"; +import ic_kebab from "../../assets/images/icons/ic_kebab.svg"; + +export default function EditDropdown({ sortOption, setSortOption }) { + const [isOpen, setIsOpen] = useState(false); + + return ( + + setIsOpen(!isOpen)}> + + + + {isOpen && ( + + 수정하기 + 삭제하기 + + )} + + ); +} + +const DropdownWrapper = styled.div` + position: relative; +`; + +const DropdownButton = styled.button``; + +const DropdownListWrapper = styled.div` + position: absolute; + top: 30px; + right: 0; + background: white; + border-radius: 12px; + border: 1px solid ${theme.colors.Gray200}; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); + width: 130px; + z-index: 10; +`; + +const DropdownItem = styled.div` + padding: 12px 16px; + font: ${theme.fonts.H5Regular}; + cursor: pointer; + text-align: center; + + &:hover { + background-color: ${theme.colors.Gray100}; + } +`; diff --git a/src/pages/ItemsDetailPage/ItemsDetailPage.jsx b/src/pages/ItemsDetailPage/ItemsDetailPage.jsx new file mode 100644 index 00000000..b8e93ef0 --- /dev/null +++ b/src/pages/ItemsDetailPage/ItemsDetailPage.jsx @@ -0,0 +1,61 @@ +// itemsDetailPage.jsx +import styled from "styled-components"; +import ItemDetail from "../../components/ItemDetail"; +import theme from "../../styles/theme"; +import { useNavigate, useParams } from "react-router-dom"; +import backToListBtn from "../../assets/images/icons/backToListBtn.svg"; +import ProductInquiry from "../../components/ProductInquiry"; + +export default function ItemsPage() { + const navigate = useNavigate(); + const { productId } = useParams(); + + return ( + + + + + + navigate("/items")}> + 목록으로 돌아가기 + + + ); +} + +export const ItemContainer = styled.div` + width: 100%; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + padding: 24px; + margin: 24px 0 60px; + gap: 62px; +`; + +export const Item = styled.div` + width: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 40px; +`; + +export const BackToList = styled.div` + width: 240px; + height: 48px; + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + border-radius: 40px; + font: ${theme.fonts.H4Bold}; + color: ${theme.colors.Gray100}; + cursor: pointer; + img { + width: 240px; + height: 48px; + } +`; diff --git a/src/pages/LoginPage/LoginPage.css b/src/pages/LoginPage/LoginPage.css deleted file mode 100644 index e69de29b..00000000 diff --git a/src/pages/LoginPage/LoginPage.jsx b/src/pages/LoginPage/LoginPage.jsx index 15f72376..d0497b63 100644 --- a/src/pages/LoginPage/LoginPage.jsx +++ b/src/pages/LoginPage/LoginPage.jsx @@ -1,7 +1,14 @@ -import React from "react"; +import logo from "../../assets/images/logo/logo.svg"; -function LoginPage() { - return
LoginPage
; +export default function LoginPage() { + return ( +
+
판다마켓
+
이메일
+ +
비밀번호
+ + +
+ ); } - -export default LoginPage; diff --git a/src/styles/theme.jsx b/src/styles/theme.jsx index d322feee..edba4941 100644 --- a/src/styles/theme.jsx +++ b/src/styles/theme.jsx @@ -2,6 +2,7 @@ const theme = { fonts: { //weight , size , height , fontfamily + H0: "400 40px/42px 'Pretendard', sans-serif", H1: "700 28px/42px 'Pretendard', sans-serif", H2Bold: "700 24px/36px 'Pretendard', sans-serif", H2Regular: "400 24px/36px 'Pretendard', sans-serif",