From 5b582e9839d37d2870c9457f2267f8b441fbcdd1 Mon Sep 17 00:00:00 2001 From: nulllife2510 <122213489+nulllife2510@users.noreply.github.com> Date: Fri, 16 Aug 2024 23:28:01 +0900 Subject: [PATCH] feat: --- .babelrc | 4 + .gitignore | 1 + api/itemApi.ts | 79 + components/boards/AllArticlesSection.tsx | 160 + components/boards/BestArticlesSection.tsx | 166 + components/items/itemPage/CommentThread.tsx | 147 + .../items/itemPage/ItemCommentSection.tsx | 94 + .../items/itemPage/ItemProfileSection.tsx | 160 + components/items/itemPage/LikeButton.tsx | 55 + components/items/itemPage/TagDisplay.tsx | 33 + .../items/marketPage/AllItemsSection.tsx | 148 + .../items/marketPage/BestItemsSection.tsx | 101 + components/items/marketPage/ItemCard.tsx | 62 + components/layout/Header.tsx | 94 + components/layout/Layout.tsx | 22 + components/ui/DropdownMenu.tsx | 79 + components/ui/EmptyState.tsx | 35 + components/ui/Icon.tsx | 51 + components/ui/LikeCountDisplay.tsx | 46 + components/ui/LoadingSpinner.tsx | 67 + components/ui/PaginationBar.tsx | 84 + components/ui/SearchBar.tsx | 71 + declarations.d.ts | 15 + hooks/useViewport.ts | 16 + next.config.mjs | 20 + package-lock.json | 13229 +++++++++++++--- package.json | 14 +- pages/_app.tsx | 18 +- pages/_document.tsx | 59 +- pages/addArticle/index.tsx | 7 + pages/additem/index.tsx | 7 + pages/boards/[id].tsx | 12 + pages/boards/index.tsx | 41 + pages/index.tsx | 106 +- pages/items/[id].tsx | 90 + pages/items/index.tsx | 14 + public/favicon.ico | Bin 25931 -> 15406 bytes public/images/home/bottom-banner-image.png | Bin 0 -> 72091 bytes public/images/home/feature1-image.png | Bin 0 -> 25334 bytes public/images/home/feature2-image.png | Bin 0 -> 30978 bytes public/images/home/feature3-image.png | Bin 0 -> 21270 bytes public/images/home/hero-image.png | Bin 0 -> 71399 bytes public/images/icons/arrow_left.svg | 3 + public/images/icons/arrow_right.svg | 3 + public/images/icons/eye-invisible.svg | 10 + public/images/icons/eye-visible.svg | 3 + public/images/icons/ic_back.svg | 4 + public/images/icons/ic_heart.svg | 3 + public/images/icons/ic_kebab.svg | 5 + public/images/icons/ic_medal.svg | 4 + public/images/icons/ic_plus.svg | 4 + public/images/icons/ic_search.svg | 3 + public/images/icons/ic_sort.svg | 6 + public/images/icons/ic_x.svg | 4 + public/images/logo/logo.svg | 15 + public/images/social/facebook-logo.svg | 3 + public/images/social/google-logo.png | Bin 0 -> 2266 bytes public/images/social/instagram-logo.svg | 3 + public/images/social/kakao-logo.png | Bin 0 -> 1580 bytes public/images/social/twitter-logo.svg | 3 + public/images/social/youtube-logo.svg | 10 + public/images/ui/empty-comments.svg | 17 + public/images/ui/ic_profile.svg | 24 + public/index.html | 32 + src/App.tsx | 62 + src/api/itemApi.js | 62 + .../images/home/bottom-banner-image.png | Bin 0 -> 72091 bytes src/assets/images/home/feature1-image.png | Bin 0 -> 25334 bytes src/assets/images/home/feature2-image.png | Bin 0 -> 30978 bytes src/assets/images/home/feature3-image.png | Bin 0 -> 21270 bytes src/assets/images/home/hero-image.png | Bin 0 -> 71399 bytes src/assets/images/icons/arrow_left.svg | 3 + src/assets/images/icons/arrow_right.svg | 3 + src/assets/images/icons/eye-invisible.svg | 10 + src/assets/images/icons/eye-visible.svg | 3 + src/assets/images/icons/ic_back.svg | 4 + src/assets/images/icons/ic_heart.svg | 3 + src/assets/images/icons/ic_kebab.svg | 5 + src/assets/images/icons/ic_plus.svg | 4 + src/assets/images/icons/ic_search.svg | 3 + src/assets/images/icons/ic_sort.svg | 6 + src/assets/images/icons/ic_x.svg | 4 + src/assets/images/logo/logo.svg | 15 + src/assets/images/social/facebook-logo.svg | 3 + src/assets/images/social/google-logo.png | Bin 0 -> 2266 bytes src/assets/images/social/instagram-logo.svg | 3 + src/assets/images/social/kakao-logo.png | Bin 0 -> 1580 bytes src/assets/images/social/twitter-logo.svg | 3 + src/assets/images/social/youtube-logo.svg | 10 + src/assets/images/ui/empty-comments.svg | 17 + src/assets/images/ui/ic_profile.svg | 24 + src/components/Layout/Footer.tsx | 95 + src/components/Layout/Header.tsx | 93 + src/components/UI/DeleteButton.tsx | 31 + src/components/UI/DropdownMenu.tsx | 81 + src/components/UI/Icon.tsx | 51 + src/components/UI/ImageUpload.tsx | 121 + src/components/UI/InputItem.tsx | 109 + src/components/UI/LoadingSpinner.tsx | 67 + src/components/UI/PaginationBar.tsx | 83 + src/components/UI/TagInput.tsx | 81 + src/hooks/useDebounce.ts | 19 + src/index.tsx | 27 + src/pages/AddItemPage/AddItemPage.tsx | 97 + .../CommunityFeedPage/CommunityFeedPage.tsx | 5 + src/pages/FaqPage/FaqPage.tsx | 5 + src/pages/HomePage/HomePage.tsx | 152 + src/pages/HomePage/components/Feature.tsx | 116 + src/pages/ItemPage/ItemPage.tsx | 88 + .../ItemPage/components/CommentThread.tsx | 174 + .../components/ItemCommentSection.tsx | 94 + .../components/ItemProfileSection.tsx | 148 + src/pages/ItemPage/components/LikeButton.tsx | 55 + src/pages/ItemPage/components/TagDisplay.tsx | 33 + src/pages/MarketPage/MarketPage.tsx | 14 + src/pages/MarketPage/MarketStyles.tsx | 8 + .../MarketPage/components/AllItemsSection.tsx | 186 + .../components/BestItemsSection.tsx | 114 + src/pages/MarketPage/components/ItemCard.tsx | 74 + src/pages/PolicyPage/PolicyPage.tsx | 5 + src/pages/auth/AuthStyles.tsx | 77 + src/pages/auth/LoginPage.tsx | 120 + src/pages/auth/SignupPage.tsx | 170 + src/pages/auth/authUtils.ts | 41 + src/pages/auth/components/PasswordInput.tsx | 83 + src/pages/auth/components/SocialLogin.tsx | 65 + src/styles/CommonStyles.ts | 91 + src/styles/GlobalStyle.ts | 137 + src/styles/theme.ts | 31 + src/types/commentTypes.ts | 16 + src/types/productTypes.ts | 23 + src/utils/dateUtils.ts | 44 + styles/BoardsStyles.ts | 52 + styles/CommonStyles.ts | 101 + styles/GlobalStyle.ts | 137 + styles/MarketStyles.ts | 8 + styles/theme.ts | 31 + tsconfig.json | 1 - types/articleTypes.ts | 17 + types/commentTypes.ts | 16 + types/productTypes.ts | 29 + utils/dateUtils.ts | 44 + 142 files changed, 17054 insertions(+), 2319 deletions(-) create mode 100644 .babelrc create mode 100644 api/itemApi.ts create mode 100644 components/boards/AllArticlesSection.tsx create mode 100644 components/boards/BestArticlesSection.tsx create mode 100644 components/items/itemPage/CommentThread.tsx create mode 100644 components/items/itemPage/ItemCommentSection.tsx create mode 100644 components/items/itemPage/ItemProfileSection.tsx create mode 100644 components/items/itemPage/LikeButton.tsx create mode 100644 components/items/itemPage/TagDisplay.tsx create mode 100644 components/items/marketPage/AllItemsSection.tsx create mode 100644 components/items/marketPage/BestItemsSection.tsx create mode 100644 components/items/marketPage/ItemCard.tsx create mode 100644 components/layout/Header.tsx create mode 100644 components/layout/Layout.tsx create mode 100644 components/ui/DropdownMenu.tsx create mode 100644 components/ui/EmptyState.tsx create mode 100644 components/ui/Icon.tsx create mode 100644 components/ui/LikeCountDisplay.tsx create mode 100644 components/ui/LoadingSpinner.tsx create mode 100644 components/ui/PaginationBar.tsx create mode 100644 components/ui/SearchBar.tsx create mode 100644 declarations.d.ts create mode 100644 hooks/useViewport.ts create mode 100644 next.config.mjs create mode 100644 pages/addArticle/index.tsx create mode 100644 pages/additem/index.tsx create mode 100644 pages/boards/[id].tsx create mode 100644 pages/boards/index.tsx create mode 100644 pages/items/[id].tsx create mode 100644 pages/items/index.tsx create mode 100644 public/images/home/bottom-banner-image.png create mode 100644 public/images/home/feature1-image.png create mode 100644 public/images/home/feature2-image.png create mode 100644 public/images/home/feature3-image.png create mode 100644 public/images/home/hero-image.png create mode 100644 public/images/icons/arrow_left.svg create mode 100644 public/images/icons/arrow_right.svg create mode 100644 public/images/icons/eye-invisible.svg create mode 100644 public/images/icons/eye-visible.svg create mode 100644 public/images/icons/ic_back.svg create mode 100644 public/images/icons/ic_heart.svg create mode 100644 public/images/icons/ic_kebab.svg create mode 100644 public/images/icons/ic_medal.svg create mode 100644 public/images/icons/ic_plus.svg create mode 100644 public/images/icons/ic_search.svg create mode 100644 public/images/icons/ic_sort.svg create mode 100644 public/images/icons/ic_x.svg create mode 100644 public/images/logo/logo.svg create mode 100644 public/images/social/facebook-logo.svg create mode 100644 public/images/social/google-logo.png create mode 100644 public/images/social/instagram-logo.svg create mode 100644 public/images/social/kakao-logo.png create mode 100644 public/images/social/twitter-logo.svg create mode 100644 public/images/social/youtube-logo.svg create mode 100644 public/images/ui/empty-comments.svg create mode 100644 public/images/ui/ic_profile.svg create mode 100644 public/index.html create mode 100644 src/App.tsx create mode 100644 src/api/itemApi.js create mode 100644 src/assets/images/home/bottom-banner-image.png create mode 100644 src/assets/images/home/feature1-image.png create mode 100644 src/assets/images/home/feature2-image.png create mode 100644 src/assets/images/home/feature3-image.png create mode 100644 src/assets/images/home/hero-image.png create mode 100644 src/assets/images/icons/arrow_left.svg create mode 100644 src/assets/images/icons/arrow_right.svg create mode 100644 src/assets/images/icons/eye-invisible.svg create mode 100644 src/assets/images/icons/eye-visible.svg create mode 100644 src/assets/images/icons/ic_back.svg create mode 100644 src/assets/images/icons/ic_heart.svg create mode 100644 src/assets/images/icons/ic_kebab.svg create mode 100644 src/assets/images/icons/ic_plus.svg create mode 100644 src/assets/images/icons/ic_search.svg create mode 100644 src/assets/images/icons/ic_sort.svg create mode 100644 src/assets/images/icons/ic_x.svg create mode 100644 src/assets/images/logo/logo.svg create mode 100644 src/assets/images/social/facebook-logo.svg create mode 100644 src/assets/images/social/google-logo.png create mode 100644 src/assets/images/social/instagram-logo.svg create mode 100644 src/assets/images/social/kakao-logo.png create mode 100644 src/assets/images/social/twitter-logo.svg create mode 100644 src/assets/images/social/youtube-logo.svg create mode 100644 src/assets/images/ui/empty-comments.svg create mode 100644 src/assets/images/ui/ic_profile.svg create mode 100644 src/components/Layout/Footer.tsx create mode 100644 src/components/Layout/Header.tsx create mode 100644 src/components/UI/DeleteButton.tsx create mode 100644 src/components/UI/DropdownMenu.tsx create mode 100644 src/components/UI/Icon.tsx create mode 100644 src/components/UI/ImageUpload.tsx create mode 100644 src/components/UI/InputItem.tsx create mode 100644 src/components/UI/LoadingSpinner.tsx create mode 100644 src/components/UI/PaginationBar.tsx create mode 100644 src/components/UI/TagInput.tsx create mode 100644 src/hooks/useDebounce.ts create mode 100644 src/index.tsx create mode 100644 src/pages/AddItemPage/AddItemPage.tsx create mode 100644 src/pages/CommunityFeedPage/CommunityFeedPage.tsx create mode 100644 src/pages/FaqPage/FaqPage.tsx create mode 100644 src/pages/HomePage/HomePage.tsx create mode 100644 src/pages/HomePage/components/Feature.tsx create mode 100644 src/pages/ItemPage/ItemPage.tsx create mode 100644 src/pages/ItemPage/components/CommentThread.tsx create mode 100644 src/pages/ItemPage/components/ItemCommentSection.tsx create mode 100644 src/pages/ItemPage/components/ItemProfileSection.tsx create mode 100644 src/pages/ItemPage/components/LikeButton.tsx create mode 100644 src/pages/ItemPage/components/TagDisplay.tsx create mode 100644 src/pages/MarketPage/MarketPage.tsx create mode 100644 src/pages/MarketPage/MarketStyles.tsx create mode 100644 src/pages/MarketPage/components/AllItemsSection.tsx create mode 100644 src/pages/MarketPage/components/BestItemsSection.tsx create mode 100644 src/pages/MarketPage/components/ItemCard.tsx create mode 100644 src/pages/PolicyPage/PolicyPage.tsx create mode 100644 src/pages/auth/AuthStyles.tsx create mode 100644 src/pages/auth/LoginPage.tsx create mode 100644 src/pages/auth/SignupPage.tsx create mode 100644 src/pages/auth/authUtils.ts create mode 100644 src/pages/auth/components/PasswordInput.tsx create mode 100644 src/pages/auth/components/SocialLogin.tsx create mode 100644 src/styles/CommonStyles.ts create mode 100644 src/styles/GlobalStyle.ts create mode 100644 src/styles/theme.ts create mode 100644 src/types/commentTypes.ts create mode 100644 src/types/productTypes.ts create mode 100644 src/utils/dateUtils.ts create mode 100644 styles/BoardsStyles.ts create mode 100644 styles/CommonStyles.ts create mode 100644 styles/GlobalStyle.ts create mode 100644 styles/MarketStyles.ts create mode 100644 styles/theme.ts create mode 100644 types/articleTypes.ts create mode 100644 types/commentTypes.ts create mode 100644 types/productTypes.ts create mode 100644 utils/dateUtils.ts diff --git a/.babelrc b/.babelrc new file mode 100644 index 000000000..854cb73a8 --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["next/babel"], + "plugins": [["styled-components", { "ssr": true }]] +} diff --git a/.gitignore b/.gitignore index 8f322f0d8..fd3dbb571 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /node_modules /.pnp .pnp.js +.yarn/install-state.gz # testing /coverage 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 ( + <> + + 문의하기 + +