diff --git a/api/itemApi.ts b/api/itemApi.ts new file mode 100644 index 000000000..2c7109378 --- /dev/null +++ b/api/itemApi.ts @@ -0,0 +1,79 @@ +import { ProductListFetcherParams } from "@/types/productTypes"; + +export async function getProducts({ + orderBy, + pageSize, + page = 1, +}: ProductListFetcherParams) { + const params = new URLSearchParams({ + orderBy, + pageSize: String(pageSize), + page: String(page), + }); + + try { + const response = await fetch( + `https://panda-market-api.vercel.app/products?${params}` + ); + + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`); + } + const body = await response.json(); + return body; + } catch (error) { + console.error("Failed to fetch products:", error); + throw error; + } +} + +export async function getProductDetail(productId: number) { + if (!productId) { + throw new Error("Invalid product ID"); + } + + try { + const response = await fetch( + `https://panda-market-api.vercel.app/products/${productId}` + ); + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`); + } + const body = await response.json(); + return body; + } catch (error) { + console.error("Failed to fetch product detail:", error); + throw error; + } +} + +export async function getProductComments({ + productId, + limit = 10, +}: { + productId: number; + limit?: number; +}) { + if (!productId) { + throw new Error("Invalid product ID"); + } + + const params = { + limit: String(limit), + }; + + try { + const query = new URLSearchParams(params).toString(); + const response = await fetch( + `https://panda-market-api.vercel.app/products/${productId}/comments?${query}` + ); + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`); + } + const body = await response.json(); + return body; + } catch (error) { + console.error("Failed to fetch product comments:", error); + throw error; + } +} diff --git a/components/boards/AllArticlesSection.tsx b/components/boards/AllArticlesSection.tsx new file mode 100644 index 000000000..40034e82b --- /dev/null +++ b/components/boards/AllArticlesSection.tsx @@ -0,0 +1,160 @@ +import { + FlexRowCentered, + LineDivider, + SectionHeader, + SectionTitle, + StyledLink, +} from "@/styles/CommonStyles"; +import { Article, ArticleSortOption } from "@/types/articleTypes"; +import styled from "styled-components"; +import { + ArticleInfo, + ArticleThumbnail, + ArticleTitle, + ImageWrapper, + MainContent, + Timestamp, +} from "@/styles/BoardsStyles"; +import Image from "next/image"; +import { format } from "date-fns"; +import Link from "next/link"; +import ProfilePlaceholder from "@/public/images/ui/ic_profile.svg"; +import SearchBar from "@/components/ui/SearchBar"; +import DropdownMenu from "@/components/ui/DropdownMenu"; +import { useEffect, useState } from "react"; +import LikeCountDisplay from "@/components/ui/LikeCountDisplay"; +import EmptyState from "@/components/ui/EmptyState"; +import { useRouter } from "next/router"; + +const ItemContainer = styled(Link)``; + +const ArticleInfoDiv = styled(FlexRowCentered)` + gap: 8px; + color: var(--gray-600); + font-size: 14px; +`; + +interface ArticleItemProps { + article: Article; +} + +const ArticleItem: React.FC = ({ article }) => { + const dateString = format(article.createdAt, "yyyy. MM. dd"); + + return ( + <> + + + {article.title} + {article.image && ( + + {/* Next Image의 width, height을 설정해줄 것이 아니라면 부모 div 내에서 fill, objectFit 설정으로 비율 유지하면서 유연하게 크기 조정 */} + {/* 프로젝트 내에 있는 이미지 파일을 사용하는 게 아니라면 next.config.mjs에 이미지 주소 설정 필요 */} + + {`${article.id}번 + + + )} + + + + + {/* ProfilePlaceholder 아이콘의 SVG 파일에서 고정된 width, height을 삭제했어요 */} + {/* */} + {article.writer.nickname} {dateString} + + + + + + + + + ); +}; + +const AddArticleLink = styled(StyledLink)``; + +interface AllArticlesSectionProps { + initialArticles: Article[]; +} + +const AllArticlesSection: React.FC = ({ + initialArticles, +}) => { + const [orderBy, setOrderBy] = useState("recent"); + const [articles, setArticles] = useState(initialArticles); + + const router = useRouter(); + const keyword = (router.query.q as string) || ""; + + const handleSortSelection = (sortOption: ArticleSortOption) => { + setOrderBy(sortOption); + }; + + const handleSearch = (searchKeyword: string) => { + const query = { ...router.query }; + if (searchKeyword.trim()) { + query.q = searchKeyword; + } else { + delete query.q; // Optional: 키워드가 빈 문자열일 때 URL에서 query string 없애주기 + } + router.replace({ + pathname: router.pathname, + query, + }); + }; + + useEffect(() => { + const fetchArticles = async () => { + let url = `https://panda-market-api.vercel.app/articles?orderBy=${orderBy}`; + if (keyword.trim()) { + // encodeURIComponent는 공백이나 특수 문자 등 URL에 포함될 수 없는 문자열을 안전하게 전달할 수 있도록 인코딩하는 자바스크립트 함수예요. + url += `&keyword=${encodeURIComponent(keyword)}`; + } + const response = await fetch(url); + const data = await response.json(); + setArticles(data.list); + }; + + fetchArticles(); + }, [orderBy, keyword]); + + return ( +
+ + 게시글 + {/* 참고: 임의로 /addArticle 이라는 pathname으로 게시글 작성 페이지를 추가했어요 */} + 글쓰기 + + + + + + + + {articles.length + ? articles.map((article) => ( + + )) + : // 참고: 요구사항에는 없었지만 항상 Empty State UI 구현하는 걸 잊지 마세요! Empty State을 재사용 가능한 컴포넌트로 만들었어요. + // 키워드가 입력되지 않은 상태에서 검색 시 Empty State이 보이지 않도록 조건 추가 + keyword && ( + + )} +
+ ); +}; + +export default AllArticlesSection; diff --git a/components/boards/BestArticlesSection.tsx b/components/boards/BestArticlesSection.tsx new file mode 100644 index 000000000..d6c7ebd70 --- /dev/null +++ b/components/boards/BestArticlesSection.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from "react"; +import styled from "styled-components"; +import Image from "next/image"; +import Link from "next/link"; +import { format } from "date-fns"; +import { + FlexRowCentered, + SectionHeader, + SectionTitle, +} from "@/styles/CommonStyles"; +import { Article, ArticleListResponse } from "@/types/articleTypes"; +import { + ArticleInfo, + ArticleInfoDiv, + ArticleThumbnail, + ArticleTitle, + ImageWrapper, + MainContent, + Timestamp, +} from "@/styles/BoardsStyles"; +import MedalIcon from "@/public/images/icons/ic_medal.svg"; +import useViewport from "@/hooks/useViewport"; +import LikeCountDisplay from "@/components/ui/LikeCountDisplay"; + +const CardContainer = styled(Link)` + background-color: var(--gray-50); + border-radius: 8px; +`; + +const ContentWrapper = styled.div` + padding: 16px 24px; +`; + +const BestSticker = styled(FlexRowCentered)` + background-color: var(--blue); + border-radius: 0 0 32px 32px; + font-size: 16px; + font-weight: 600; + color: #fff; + gap: 4px; + padding: 6px 24px 8px 24px; + margin-left: 24px; + display: inline-flex; +`; + +const BestArticleCard = ({ article }: { article: Article }) => { + const dateString = format(article.createdAt, "yyyy. MM. dd"); + + return ( + + + + Best + + + + + {article.title} + {article.image && ( + + {/* Next Image의 width, height을 설정해줄 것이 아니라면 부모 div 내에서 fill, objectFit 설정으로 비율 유지하면서 유연하게 크기 조정 */} + {/* 프로젝트 내에 있는 이미지 파일을 사용하는 게 아니라면 next.config.mjs에 이미지 주소 설정 필요 */} + + {`${article.id}번 + + + )} + + + + + {article.writer.nickname} + + + {dateString} + + + + ); +}; + +const BestArticlesCardSection = styled.div` + display: grid; + grid-template-columns: repeat(1, 1fr); + + @media ${({ theme }) => theme.mediaQuery.tablet} { + grid-template-columns: repeat(2, 1fr); + gap: 16px; + } + + @media ${({ theme }) => theme.mediaQuery.desktop} { + grid-template-columns: repeat(3, 1fr); + gap: 24px; + } +`; + +const getPageSize = (width: number): number => { + if (width < 768) { + return 1; // Mobile viewport + } else if (width < 1280) { + return 2; // Tablet viewport + } else { + return 3; // Desktop viewport + } +}; + +const BestArticlesSection = () => { + const [articles, setArticles] = useState([]); + const [pageSize, setPageSize] = useState(null); // 초기 값을 실제 사용되는 값인 1 또는 3으로 설정해도 되지만, 이 경우에는 화면 크기가 파악되기 전에는 pageSize가 설정되지 않았음을 명확히 하기 위해 null로 두었어요. + + // Server-side rendering을 기본으로 하는 Next.js에서는 일반 리액트에서처럼 바로 window 객체를 사용하지 못하기 때문에, 별도의 useViewport 커스텀 훅을 만들었어요. + const viewportWidth = useViewport(); + + // 베스트 게시글 섹션의 요구사항에 따르면, 화면 크기에 따라 몇 개의 데이터를 보여줄지 여부(pageSize)를 결정하고 해당 값을 query parameter로 넣어 데이터를 호출해야 해요. + // 화면 크기가 파악된 후에 처리해야 하는 방식이기 때문에 client-side에서 호출해야 하고, 따라서 server-side에서 미리 내용을 받아오는 Next.js의 prefetching 기능을 사용할 수 없어요. + // (참고: 요구사항을 유연하게 해석한다면, pageSize을 필요한 데이터 길이의 최대값인 3으로 두어 prefetching한 후에 client-side에서 화면 크기에 따라 데이터 배열을 절삭해 사용하는 방법도 있어요.) + useEffect(() => { + // 화면 크기가 파악되기 전까지 pageSize 계산이나 데이터를 호출하지 않도록 처리 + if (viewportWidth === 0) return; + + // 화면 크기가 바뀔 때마다 불필요하게 데이터를 호출하는 것을 막기 위해, 화면 크기에 따른 pageSize 범위가 바뀔 때만 호출하도록 처리 + const newPageSize = getPageSize(viewportWidth); + + if (newPageSize !== pageSize) { + setPageSize(newPageSize); + + const fetchBestArticles = async (size: number) => { + try { + const response = await fetch( + `https://panda-market-api.vercel.app/articles?orderBy=like&pageSize=${size}` + ); + const data: ArticleListResponse = await response.json(); + setArticles(data.list); + } catch (error) { + console.error("Failed to fetch best articles:", error); + } + }; + + fetchBestArticles(newPageSize); + } + }, [viewportWidth, pageSize]); + + return ( +
+ + 베스트 게시글 + + + + {articles.map((article) => ( + + ))} + +
+ ); +}; + +export default BestArticlesSection; diff --git a/components/items/itemPage/CommentThread.tsx b/components/items/itemPage/CommentThread.tsx new file mode 100644 index 000000000..5457fe76e --- /dev/null +++ b/components/items/itemPage/CommentThread.tsx @@ -0,0 +1,147 @@ +import { useEffect, useState } from "react"; +import { getProductComments } from "@/api/itemApi"; +import styled from "styled-components"; +import SeeMoreIcon from "@/public/images/icons/ic_kebab.svg"; +import DefaultProfileImage from "@/public/images/ui/ic_profile.svg"; +import { LineDivider } from "@/styles/CommonStyles"; +import { formatUpdatedAt } from "@/utils/dateUtils"; +import { + ProductComment, + ProductCommentListResponse, +} from "@/types/commentTypes"; +import EmptyState from "@/components/ui/EmptyState"; + +const CommentContainer = styled.div` + padding: 24px 0; + position: relative; +`; + +const SeeMoreButton = styled.button` + position: absolute; + right: 0; +`; + +const CommentContent = styled.p` + font-size: 16px; + line-height: 140%; + margin-bottom: 24px; +`; + +const AuthorProfile = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const UserProfileImage = styled.img` + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; +`; + +const Username = styled.p` + color: var(--gray-600); + font-size: 14px; + margin-bottom: 4px; +`; + +const Timestamp = styled.p` + color: ${({ theme }) => theme.colors.gray[400]}; + font-size: 12px; +`; + +interface CommentItemProps { + item: ProductComment; +} + +const CommentItem: React.FC = ({ item }) => { + const authorInfo = item.writer; + const formattedTimestamp = formatUpdatedAt(item.updatedAt); + + return ( + <> + + {/* 참고: 더보기 버튼 기능은 추후 요구사항에 따라 추가 예정 */} + + + + + {item.content} + + + + +
+ {authorInfo.nickname} + {formattedTimestamp} +
+
+
+ + + + ); +}; + +const ThreadContainer = styled.div` + margin-bottom: 40px; +`; + +interface CommentThreadProps { + productId: number; +} + +const CommentThread: React.FC = ({ productId }) => { + const [comments, setComments] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!productId) return; + + const fetchComments = async () => { + setIsLoading(true); + + try { + const response: ProductCommentListResponse = await getProductComments({ + productId, + }); + setComments(response.list); + setError(null); + } catch (error) { + console.error("Error fetching comments:", error); + setError("상품의 댓글을 불러오지 못했어요."); + } finally { + setIsLoading(false); + } + }; + + fetchComments(); + }, [productId]); + + if (isLoading) { + return
상품 댓글 로딩중...
; + } + + if (error) { + return
오류: {error}
; + } + + if (comments && !comments.length) { + return ; + } else { + return ( + + {comments.map((item) => ( + + ))} + + ); + } +}; + +export default CommentThread; diff --git a/components/items/itemPage/ItemCommentSection.tsx b/components/items/itemPage/ItemCommentSection.tsx new file mode 100644 index 000000000..ef213e3a9 --- /dev/null +++ b/components/items/itemPage/ItemCommentSection.tsx @@ -0,0 +1,94 @@ +import { ChangeEvent, useState } from "react"; +import styled from "styled-components"; +import { Button } from "@/styles/CommonStyles"; +import CommentThread from "./CommentThread"; + +const COMMENT_PLACEHOLDER = + "개인정보를 공유 및 요청하거나, 명예 훼손, 무단 광고, 불법 정보 유포시 모니터링 후 삭제될 수 있으며, 이에 대한 민형사상 책임은 게시자에게 있습니다."; + +const CommentInputSection = styled.section` + display: flex; + flex-direction: column; + gap: 16px; +`; + +const SectionTitle = styled.h1` + font-size: 16px; + font-weight: 600; +`; + +// TODO: InputItem 컴포넌트의 textarea와 겹치므로 common styles에 추가할 것 +const TextArea = styled.textarea` + background-color: ${({ theme }) => theme.colors.gray[100]}; + border: none; + border-radius: 12px; + padding: 16px 24px; + height: 104px; + resize: none; + + &::placeholder { + color: ${({ theme }) => theme.colors.gray[400]}; + font-size: 14px; + line-height: 24px; + + @media ${({ theme }) => theme.mediaQuery.tablet} { + font-size: 16px; + } + } + + &:focus { + outline-color: ${({ theme }) => theme.colors.blue.primary}; + } +`; + +const PostCommentButton = styled(Button)` + align-self: flex-end; + font-weight: 600; + font-size: 14px; + + @media ${({ theme }) => theme.mediaQuery.tablet} { + font-size: 16px; + } +`; + +interface ItemCommentSectionProps { + productId: number; +} + +const ItemCommentSection: React.FC = ({ + productId, +}) => { + const [comment, setComment] = useState(""); + + const handleInputChange = (e: ChangeEvent) => { + setComment(e.target.value); + }; + + const handlePostComment = () => {}; + + return ( + <> + + 문의하기 + +