diff --git a/next_mission/.eslintrc.json b/next_mission/.eslintrc.json new file mode 100644 index 000000000..4d765f281 --- /dev/null +++ b/next_mission/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "prettier"] +} diff --git a/next_mission/.prettierrc b/next_mission/.prettierrc new file mode 100644 index 000000000..b16a986d4 --- /dev/null +++ b/next_mission/.prettierrc @@ -0,0 +1,11 @@ +{ + "arrowParens": "always", + "endOfLine": "lf", + "printWidth": 80, + "quoteProps": "as-needed", + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "useTabs": false, + "trailingComma": "es5" +} \ No newline at end of file diff --git a/next_mission/api/itemApi.ts b/next_mission/api/itemApi.ts new file mode 100644 index 000000000..7aadd64d8 --- /dev/null +++ b/next_mission/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 res = await fetch( + `https://panda-market-api.vercel.app/products?${params}` + ); + + if (!res.ok) { + throw new Error(`HTTP error: ${res.status}`) + } + const body = await res.json(); + return body; + } catch (error) { + console.error('상품을 불러오는데 실패했습니다', error); + throw error; + } +} + +export async function getProductDetail(productId: number) { + if (!productId) { + throw new Error("Invalid product ID"); + } + + try { + const res = await fetch( + `https://panda-market-api.vercel.app/products/${productId}` + ); + if (!res.ok) { + throw new Error(`HTTP error: ${res.status}`); + } + const body = await res.json(); + return body; + } catch (error) { + console.error('상품정보를 불러오는데 실패했습니다', 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 res = await fetch( + `https://panda-market-api.vercel.app/products/${productId}/comments?${query}` + ); + if (!res.ok) { + throw new Error(`HTTP error: ${res.status}`); + } + const body = await res.json(); + return body; + } catch (error) { + console.error('상품 댓글을 불러오는데 실패했습니다', error); + throw error; + } +} \ No newline at end of file diff --git a/next_mission/components/boards/AllArticlesSection.tsx b/next_mission/components/boards/AllArticlesSection.tsx new file mode 100644 index 000000000..a2d27d129 --- /dev/null +++ b/next_mission/components/boards/AllArticlesSection.tsx @@ -0,0 +1,81 @@ +import { Article, ArticleSortOption } from '@/types/articleTypes'; +import Button from '../ui/Button'; +import SearchBar from '../ui/SearchBar'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import Empty from '../ui/Empty'; +import ArticleItem from './ArticleItem'; +import Dropdown from '../ui/Dropdown'; + +interface AllArticlesSectionProps { + initialArticles: Article[]; +} + +const AllArticlesSection: React.FC = ({ + initialArticles, +}) => { + const [articles, setArticles] = useState(initialArticles); + const [orderBy, setOrderBy] = useState('recent'); + + const router = useRouter(); + const keyword = (router.query.q as string) || ''; + + const handleSortSelection = (sortOption: ArticleSortOption) => { + setOrderBy(sortOption); + }; + + const handleInputKeyword = (inputKeyword: string) => { + const query = { ...router.query }; + if (inputKeyword.trim()) { + query.q = inputKeyword; + } else { + delete query.q; // 객체이므로 삭제 가능 + } + router.replace({ + pathname: router.pathname, + query, + }); + }; + + useEffect(() => { + const fetchArticles = async () => { + let url = `https://panda-market-api.vercel.app/articles?orderBy=${orderBy}`; + if (keyword.trim()) { + url += `&keyword=${encodeURIComponent(keyword)}`; + } + const res = await fetch(url); + const data = await res.json(); + setArticles(data.list); + }; + + fetchArticles(); + }, [orderBy, keyword]); + + return ( +
+
+
게시글
+ +
+ +
+ + +
+ + {articles.length + ? articles.map((article) => ( + + )) + : keyword && } +
+ ); +}; + +export default AllArticlesSection; diff --git a/next_mission/components/boards/ArticleItem.tsx b/next_mission/components/boards/ArticleItem.tsx new file mode 100644 index 000000000..2dc8756c8 --- /dev/null +++ b/next_mission/components/boards/ArticleItem.tsx @@ -0,0 +1,60 @@ +import { Article } from '@/types/articleTypes'; +import Image from 'next/image'; +import Link from 'next/link'; +import LikeCount from '../ui/LikeCount'; +import { format } from 'date-fns'; + +// Todo : LikeCount gap 동적으로 입력하기 + +interface ArticleProps { + article: Article; +} + +const ArticleItem: React.FC = ({ article }) => { + const dateString = format(article.createdAt, 'yyyy. MM. dd'); + return ( + <> + +
+
+

+ {article.title} +

+ {article.image && ( +
+ {`${article.id}번 +
+ )} +
+
+
+ 아바타 +
+ {article.writer.nickname} + {dateString} +
+
+ +
+
+ + + ); +}; + +export default ArticleItem; diff --git a/next_mission/components/boards/BestArticleCard.tsx b/next_mission/components/boards/BestArticleCard.tsx new file mode 100644 index 000000000..50957db60 --- /dev/null +++ b/next_mission/components/boards/BestArticleCard.tsx @@ -0,0 +1,49 @@ +import Link from 'next/link'; +import MedalIcon from '@/public/images/icons/ic_medal.svg'; +import Image from 'next/image'; +import { Article } from '@/types/articleTypes'; +import { format } from 'date-fns'; +import LikeCount from '../ui/LikeCount'; + +const BestArticleCard = ({ article }: { article: Article }) => { + const dateString = format(article.createdAt, 'yyyy. MM. dd'); + return ( + +
+
+ + Best +
+ +
+

+ {article.title} +

+ {article.image && ( +
+ {`${article.id}번 +
+ )} +
+ +
+
+ {article.writer.nickname} + +
+ {dateString} +
+
+ + ); +}; + +export default BestArticleCard; diff --git a/next_mission/components/boards/BestArticlesSection.tsx b/next_mission/components/boards/BestArticlesSection.tsx new file mode 100644 index 000000000..3f1da2f00 --- /dev/null +++ b/next_mission/components/boards/BestArticlesSection.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; +import BestArticleCard from './BestArticleCard'; +import { Article, ArticleListData } from '@/types/articleTypes'; +import useViewport from '@/hooks/useViewport'; + +const getPageSize = (width: number): number => { + if (width < 768) { + return 1; // Mobile + } else if (width < 1280) { + return 2; // Tablet + } else { + return 3; // Desktop + } +}; + +const BestArticlesSection = () => { + const [bestArticles, setBestArticles] = useState([]); + const [pageSize, setPageSize] = useState(null); + + const viewportWidth = useViewport(); + + useEffect(() => { + if (viewportWidth === 0) return; + + const newPageSize = getPageSize(viewportWidth); + + if (newPageSize !== pageSize) { + setPageSize(newPageSize); + + const fetchBestArticles = async (size: number) => { + try { + const res = await fetch( + `https://panda-market-api.vercel.app/articles?orderBy=like&pageSize=${size}` + ); + const data: ArticleListData = await res.json(); + setBestArticles(data.list); + } catch (error) { + console.error('베스트 게시물을 받아오는 데 실패했습니다.', error); + } + }; + + fetchBestArticles(newPageSize); + } + }, [viewportWidth, pageSize]); + + return ( +
+
+ 베스트 게시글 +
+ +
+ {bestArticles.map((article) => ( + + ))} +
+
+ ); +}; + +export default BestArticlesSection; diff --git a/next_mission/components/items/AllItemsSection.tsx b/next_mission/components/items/AllItemsSection.tsx new file mode 100644 index 000000000..0dffbfa79 --- /dev/null +++ b/next_mission/components/items/AllItemsSection.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react'; +import Button from '../ui/Button'; +import Dropdown from '../ui/Dropdown'; +import ItemCard from './ItemCard'; +import { + Product, + ProductListResponse, + ProductSortOption, +} from '@/types/productTypes'; +import useViewport from '@/hooks/useViewport'; +import { getProducts } from '@/api/itemApi'; +import SearchBar from '../ui/SearchBar'; +import { useRouter } from 'next/router'; +import LoadingSpinner from '../ui/LoadingSpinner'; + +const getPageSize = (width: number) => { + if (width < 768) { + return 4; + } else if (width < 1280) { + return 6; + } else { + return 10; + } +}; + +const AllItemsSection = () => { + const [orderBy, setOrderBy] = useState('recent'); + const [pageSize, setPageSize] = useState(null); + const [itemList, setItemList] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const router = useRouter(); + const viewportWidth = useViewport(); + + useEffect(() => { + if (viewportWidth === 0) return; + + const newPageSize = getPageSize(viewportWidth); + if (newPageSize !== pageSize) { + setPageSize(newPageSize); + } + }, [viewportWidth, pageSize]); + + useEffect(() => { + if (pageSize === null) return; + + const fetchSortedData = async () => { + setIsLoading; + try { + const data: ProductListResponse = await getProducts({ + orderBy, + pageSize, + }); + setItemList(data.list); + } catch (error) { + console.error('Error: ', (error as Error).message); + } finally { + setIsLoading(false); + } + }; + + fetchSortedData(); + }, [orderBy, pageSize]); + + const handleSortSelection = (sortOption: ProductSortOption) => { + setOrderBy(sortOption); + }; + + const handleSearch = (searchKeyword: string) => { + router.push({ + pathname: router.pathname, + query: { ...router.query, q: searchKeyword }, + }); + }; + + return ( + <> +
+
+
판매 중인 상품
+ + + +
+ {isLoading ? ( + + ) : ( +
+ {itemList?.map((item) => ( + + ))} +
+ )} +
페이지 네이션
+
+ + ); +}; + +export default AllItemsSection; diff --git a/next_mission/components/items/BestItemsSection.tsx b/next_mission/components/items/BestItemsSection.tsx new file mode 100644 index 000000000..11a8e9883 --- /dev/null +++ b/next_mission/components/items/BestItemsSection.tsx @@ -0,0 +1,68 @@ +import { getProducts } from '@/api/itemApi'; +import useViewport from '@/hooks/useViewport'; +import { Product, ProductListResponse } from '@/types/productTypes'; +import { useEffect, useState } from 'react'; +import ItemCard from './ItemCard'; + +const getPageSize = (width: number) => { + if (width < 768) { + return 1; + } else if (width < 1280) { + return 2; + } else { + return 4; + } +}; + +const BestItemsSection: React.FC = () => { + const [itemList, setItemList] = useState([]); + const [pageSize, setPageSize] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const viewportWidth = useViewport(); + + useEffect(() => { + if (viewportWidth === 0) return; + + const newPageSize = getPageSize(viewportWidth); + if (newPageSize !== pageSize) { + setPageSize(newPageSize); + } + }, [viewportWidth, pageSize]); + + useEffect(() => { + if (pageSize === null) return; + + const fetchSortedData = async () => { + setIsLoading(true); + try { + const data: ProductListResponse = await getProducts({ + orderBy: 'favorite', + pageSize, + }); + setItemList(data.list); + } catch (error) { + console.error('오류: ', (error as Error).message); + } finally { + setIsLoading(false); + } + }; + + fetchSortedData(); + }, [pageSize]); + + return ( + <> +
+
베스트 상품
+
+ {itemList?.map((item) => ( + + ))} +
+
+ + ); +}; + +export default BestItemsSection; diff --git a/next_mission/components/items/Comment.tsx b/next_mission/components/items/Comment.tsx new file mode 100644 index 000000000..3e338b97b --- /dev/null +++ b/next_mission/components/items/Comment.tsx @@ -0,0 +1,63 @@ +import { getProductComments } from '@/api/itemApi'; +import { + ProductComment, + ProductCommentListResponse, +} from '@/types/commentTypes'; +import { useEffect, useState } from 'react'; +import Empty from '../ui/Empty'; +import CommentItem from './CommentItem'; + +interface CommentProps { + productId: number; +} + +const Comment: 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 res: ProductCommentListResponse = await getProductComments({ + productId, + }); + setComments(res.list); + setError(null); + } catch (error) { + console.error('댓글을 불러오는 데 실패했습니다.', error); + setError('상품의 댓글을 불러오지 못했습니다.'); + } finally { + setIsLoading(false); + } + }; + + fetchComments(); + }, [productId]); + + if (isLoading) { + return
상품 댓글 로딩중...
; + } + + if (error) { + return
Error: {error}
; + } + + if (comments && !comments.length) { + return ; + } else { + return ( +
+ {comments.map((item) => ( + + ))} +
+ ); + } +}; + +export default Comment; diff --git a/next_mission/components/items/CommentItem.tsx b/next_mission/components/items/CommentItem.tsx new file mode 100644 index 000000000..b72fe96a1 --- /dev/null +++ b/next_mission/components/items/CommentItem.tsx @@ -0,0 +1,34 @@ +import { ProductComment } from '@/types/commentTypes'; +import { formatUpdatedAt } from '@/utils/dateUtils'; + +interface CommentItemProps { + item: ProductComment; +} + +const CommentItem: React.FC = ({ item }) => { + const authorInfo = item.writer; + const formattedTimestamp = formatUpdatedAt(item.updatedAt); + return ( + <> +
+ {/* 추후 추가 */} + + +

{item.content}

+
+ {`${authorInfo.nickname}님의 + +
+

{authorInfo.nickname}

+

{formattedTimestamp}

+
+
+
+ + ); +}; + +export default CommentItem; diff --git a/next_mission/components/items/ItemCard.tsx b/next_mission/components/items/ItemCard.tsx new file mode 100644 index 000000000..ff39d2ddc --- /dev/null +++ b/next_mission/components/items/ItemCard.tsx @@ -0,0 +1,39 @@ +import { Product } from '@/types/productTypes'; +import Image from 'next/image'; +import Link from 'next/link'; +import LikeCount from '../ui/LikeCount'; + +interface ItemCardProps { + item: Product; +} + +const ItemCard: React.FC = ({ item }) => { + return ( + +
+ {`${item.name} +
+
+

{item.name}

+

{item.price.toLocaleString()}원

+ +
+ + ); +}; + +export default ItemCard; diff --git a/next_mission/components/items/ItemCommentSection.tsx b/next_mission/components/items/ItemCommentSection.tsx new file mode 100644 index 000000000..2616b82b9 --- /dev/null +++ b/next_mission/components/items/ItemCommentSection.tsx @@ -0,0 +1,43 @@ +import { ChangeEvent, useState } from 'react'; +import Comment from './Comment'; + +const COMMENT_PLACEHOLDER = + '개인정보를 공유 및 요청하거나, 명예 훼손, 무단 광고, 불법 정보 유포시 모니터링 후 삭제될 수 있으며, 이에 대한 민형사상 책임은 게시자에게 있습니다.'; + +interface ItemCommentSectionProps { + productId: number; +} + +const ItemCommentSection: React.FC = ({ + productId, +}) => { + const [comment, setComment] = useState(''); + + const handleCommentChange = (e: ChangeEvent) => { + setComment(e.target.value); + }; + + const handlePostComment = () => {}; + + return ( +
+

문의하기

+