diff --git a/api/api.ts b/api/api.ts new file mode 100644 index 000000000..d5eb32bcb --- /dev/null +++ b/api/api.ts @@ -0,0 +1,84 @@ +import { Product, ProductResponse, Comment } from "@/types"; + +interface GetProductsParams { + page?: number; + pageSize?: number; + orderBy?: string; + keyword?: string; + totalItems?: number; +} + +interface GetProductCommentsParams { + productId: string; + limit?: number; +} + +export async function getProducts({ + page = 1, + pageSize = 10, + orderBy, + keyword = "", + totalItems, +}: GetProductsParams): Promise { + const params = new URLSearchParams({ + page: page.toString(), + pageSize: pageSize.toString(), + ...(keyword && { keyword }), + }); + + if (orderBy) { + params.append("orderBy", orderBy); + } + + try { + const response = await fetch( + `https://panda-market-api.vercel.app/products?${params.toString()}` + ); + + if (!response.ok) { + throw new Error("서버 오류"); + } + + const body = await response.json(); + return body; + } catch (error) { + console.error(error); + throw new Error("데이터 오류"); + } +} + +export async function getProductDetails(productId: string): Promise { + try { + const response = await fetch( + `https://panda-market-api.vercel.app/products/${productId}` + ); + if (!response.ok) { + throw new Error("상품을 찾을 수 없습니다."); + } + const data = await response.json(); + return data; + } catch (error) { + console.error("상품 세부 정보를 가져오는 중 오류 발생:", error); + throw error; + } +} + +export async function getProductComments({ + productId, + limit = 10, +}: GetProductCommentsParams): Promise { + const params = new URLSearchParams({ limit: limit.toString() }); + try { + const response = await fetch( + `https://panda-market-api.vercel.app/products/${productId}/comments?${params}` + ); + if (!response.ok) { + throw new Error("댓글을 찾을 수 없습니다."); + } + const data = await response.json(); + return data; + } catch (error) { + console.error("상품 댓글을 가져오는 중 오류 발생:", error); + throw error; + } +} diff --git a/components/AddItem/addItem.module.css b/components/AddItem/addItem.module.css new file mode 100644 index 000000000..4a93d66b2 --- /dev/null +++ b/components/AddItem/addItem.module.css @@ -0,0 +1,210 @@ +:root { + --blue: #3692ff; + --gray900: #111827; + --gray800: #1f2937; + --gray700: #374151; + --gray600: #4b5563; + --gray500: #6b7280; + --gray400: #9ca3af; + --gray200: #e5e7eb; + --gray100: #f3f4f6; + --gray50: #f9fafb; +} + +body { + box-sizing: border-box; + margin: 0; + font-family: "Pretendard Variable", Pretendard, -apple-system, + BlinkMacSystemFont, system-ui, Roboto, "Helvetica Neue", "Segoe UI", + "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol", sans-serif; +} + +section { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + /* 임시 여백 */ + padding-top: 12rem; /* 16px * 12 */ + padding-bottom: 8rem; +} + +/* 상품 등록 전체 스타일 */ +.add_items { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; + min-width: 1200px; +} + +.add_items_form { + display: flex; + flex-direction: column; + gap: 32px; + width: 100%; +} + +.product_img, +.product_title, +.product_info, +.sale_cost, +.tag { + display: flex; + flex-direction: column; + gap: 16px; +} + +.product_add { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + width: 100%; +} + +.product_img { + display: flex; + flex-direction: column; +} + +.add_items h2 { + margin: 0; + font-size: 18px; +} + +.add_items input { + box-sizing: border-box; + font-family: Pretendard; + width: 100%; + padding: 16px 24px; + border: none; + border-radius: 12px; + font-size: 16px; + background-color: var(--gray100); + color: var(--gray400); +} + +.add_items textarea { + font-family: Pretendard; + width: 100%; + padding: 16px 24px; + min-height: 282px; + border: none; + border-radius: 12px; + background-color: var(--gray100); + color: var(--gray400); + font-size: 16px; + resize: none; /* 세로 크기만 조정 가능 */ + overflow-y: auto; /* 내용이 많을 경우 스크롤 */ + box-sizing: border-box; /* 패딩 포함 크기 계산 */ +} + +.add_items textarea:focus { + outline: none; + border-color: var(--blue); + box-shadow: 0 0 0 2px rgba(54, 146, 255, 0.3); +} + +.add_items input:focus { + outline: none; + border-color: var(--blue); + box-shadow: 0 0 0 2px rgba(54, 146, 255, 0.3); +} + +.product_add button { + padding: 10px 20px; + background-color: var(--blue); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.product_add button:hover { + background-color: #2873cc; +} + +/* 상품 이미지 등록 */ +.image_container { + display: flex; + justify-content: center; + align-items: center; + width: 282px; + height: 282px; + border: 1px dashed var(--gray400); + border-radius: 4px; +} + +.upload_label { + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +} + +.file_input { + display: none; +} + +.upload_placeholder { + color: var(--gray400); + font-size: 16px; +} + +.upload_placeholder_plus { + color: var(--gray400); + font-size: 68px; + text-align: center; +} + +.image_preview { + position: relative; + width: 282px; + height: 282px; +} + +.uploaded_image { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 4px; +} + +.delete_img_btn { + position: absolute; + top: 8px; + right: 8px; + background: var(--gray400); + color: white; + border: none; + border-radius: 50%; + width: 24px; + height: 24px; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; +} + +/* 태그 */ +.tag_item { + margin: 5px; + padding: 5px 10px; + background-color: #f0f0f0; + border-radius: 20px; + font-size: 14px; + display: inline-flex; + align-items: center; +} + +.delete_tag_btn { + background: var(--gray400); + border: none; + border-radius: 9999px; + color: white; + margin-left: 5px; + cursor: pointer; +} diff --git a/components/AddItem/index.tsx b/components/AddItem/index.tsx new file mode 100644 index 000000000..ca54c3adf --- /dev/null +++ b/components/AddItem/index.tsx @@ -0,0 +1,143 @@ +import React, { ChangeEvent, useState } from "react"; +import Header from "../Header/index"; +import styles from "./addItem.module.css"; + +function AddItems() { + const [productImg, setProductImg] = useState(null); // 이미지 URL 또는 null + const [title, setTitle] = useState(""); // 상품명 + const [info, setInfo] = useState(""); // 상품 소개 + const [price, setPrice] = useState(""); // 판매 가격 + const [tagInput, setTagInput] = useState(""); // 태그 입력 + const [tags, setTags] = useState([]); // 태그 배열 + + // 이미지 업로드 핸들러 + const handleImageUpload = (e: ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + setProductImg(URL.createObjectURL(e.target.files[0])); + } + }; + + // 이미지 삭제 핸들러 + const handleImageDelete = () => { + setProductImg(null); + }; + + // 태그 추가 핸들러 + const handleTagAdd = () => { + const tag = tagInput.trim(); + if (tag && !tags.includes(tag)) { + setTags([...tags, tag]); + setTagInput(""); + } + }; + + // 태그 삭제 핸들러 + const handleTagDelete = (tagToDelete: string) => { + setTags(tags.filter((tag) => tag !== tagToDelete)); + }; + + // 제출 가능 여부 체크 + const isSubmitEnabled = + title.trim() && info.trim() && price.trim() && tags.length > 0; + + return ( +
+
+
+
+
+

상품 등록하기

+ +
+
+

이미지 등록하기

+
+ {!productImg ? ( + + ) : ( +
+ 상품 이미지 미리보기 + +
+ )} +
+
+

상품명

+ setTitle(e.target.value)} + /> +
+
+

상품 소개

+ +
+
+

판매 가격

+ setPrice(e.target.value)} + /> +
+
+

태그

+ setTagInput(e.target.value)} + onKeyPress={(e) => e.key === "Enter" && handleTagAdd()} + /> +
+ {tags.map((tag) => ( + + #{tag} + + + ))} +
+
+
+
+
+
+ ); +} + +export default AddItems; diff --git a/components/AllProducts/allProduct.module.css b/components/AllProducts/allProduct.module.css new file mode 100644 index 000000000..2e89d08d9 --- /dev/null +++ b/components/AllProducts/allProduct.module.css @@ -0,0 +1,318 @@ +.product_link { + text-decoration: none; + color: black; +} + +.all_Container { + padding-top: 40px; + min-height: 740px; + display: flex; + flex-direction: column; + align-content: center; + flex-wrap: wrap; +} + +@media screen and (max-width: 1199px) { + .all_Container { + padding-top: 100px; + } +} + +.title { + font-weight: 700; + color: var(--gray900); + font-size: 20px; + padding-right: 31rem; +} + +.all_list { + margin: 0; + padding: 0; + display: grid; + grid-template: + "a b c d e" + "f g h i j"; + list-style-type: none; + min-height: 674px; + gap: 24px; +} + +.best_card { + width: 221px; + height: 317px; +} + +@media screen and (max-width: 1199px) { + .all_list { + grid-template: + "a b c" + "d e f"; + } +} + +@media screen and (max-width: 767px) { + .all_list { + grid-template: + "a b" + "c d"; + } + + .item_card { + width: 168px; + height: 264px; + } +} + +.product_image_box { + position: relative; + width: 221px; + height: 221px; + margin-bottom: 16px; +} + +@media screen and (max-width: 767px) { + .product_image_box { + width: 168px; + height: 168px; + } +} + +.product_image { + position: absolute; + width: 100%; + height: 100%; +} + +.product_name { + margin: 0; + margin-bottom: 6px; + font-size: 14px; + font-weight: 500; + color: var(--gray800); + line-height: 24px; +} + +.product_price { + margin: 0; + margin-bottom: 6px; + font-size: 16px; + font-weight: 700; + color: var(--gray800); + line-height: 26px; +} + +.heart_line { + height: 18px; +} + +.heart_image { + width: 16px; + height: 16px; + vertical-align: middle; +} + +.heart_num { + padding: 1px 0 1px 4px; + font-size: 12px; + line-height: 18px; + font-weight: 500; + color: var(--gray600); + text-align: left; + top: 2px; +} + +.all_Container_bar { + position: relative; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding-bottom: 0.625rem; +} + +.search_input { + font-family: Pretendard; + padding: 9px 20px 9px 44px; + border-radius: 12px; + border: none; + width: 289px; + height: 24px; + gap: 10px; + background-color: var(--gray100); +} + +.search_icon { + position: absolute; + top: 22%; + left: 50%; +} + +@media screen and (max-width: 1199px) { + .search_input { + width: 178px; + } +} + +.register_button { + font-family: Pretendard; + background-color: var(--blue); + border-radius: 8px; + border: 0px; + height: 42px; + padding: 12px 23px; + color: white; + font-size: 16px; + font-weight: 600; + line-height: 16px; + cursor: pointer; +} + +.dropdown { + position: relative; + display: inline-block; +} + +.dropdown_btn { + font-family: Pretendard; + background-color: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 12px 20px; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + width: 130px; + height: 42px; +} + +.dropdown_btn .arrow { + margin-left: 10px; +} + +.dropdown_list { + position: absolute; + top: 88%; + left: 89%; + background-color: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; + list-style: none; + width: 130px; + height: 82px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + z-index: 1; + display: flex; + flex-direction: column; + justify-content: center; +} + +.dropdown_item { + padding: 0; + height: 26px; + color: var(--gray800); + font-weight: 400; + font-size: 16px; + line-height: 26px; + cursor: pointer; + flex: 1; + display: flex; + justify-content: center; + align-items: center; +} + +.dropdown_item:not(:last-child) { + border-bottom: 1px solid #e5e7eb; +} + +.dropdown_item:hover { + background-color: #f1f1f1; +} + +.pagination { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 43px; + margin-bottom: 58px; +} + +.page_btn { + width: 40px; + height: 40px; + border: none; + border-radius: 50%; + background-color: #fff; + color: #666; + cursor: pointer; + font-size: 14px; + font-weight: bold; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + transition: all 0.2s; +} + +.page_btn.active { + background-color: #007bff; + color: white; +} + +.page_btn:disabled { + background-color: #f5f5f5; + color: #ccc; + cursor: not-allowed; +} + +@media screen and (min-width: 768px) and (max-width: 1199px) { + .title { + padding-right: 8rem; + } + + .search_icon { + top: 21%; + left: 32%; + } + + .dropdown_list { + top: 89%; + left: 81.5%; + } +} + +@media screen and (max-width: 767px) { + .all_Container_bar { + flex-wrap: wrap; + padding-bottom: 16px; + align-items: center; + max-width: 375px; + } + + .title { + padding: 0; + order: 0; + width: 35%; + } + + .search_input { + order: 1; + } + + .register_button { + order: 2; + width: 100%; + } + + .dropdown_btn { + order: 3; + } + + .search_icon { + top: 52%; + left: 2%; + } + + .dropdown_list { + top: 89%; + left: 65%; + } +} diff --git a/components/AllProducts/index.tsx b/components/AllProducts/index.tsx new file mode 100644 index 000000000..b7adc3f7a --- /dev/null +++ b/components/AllProducts/index.tsx @@ -0,0 +1,110 @@ +import React, { useState, useEffect } from "react"; +import { getProducts } from "@/api/api"; +import NavBar from "./navBar"; +import ProductList from "./products"; +import Pagination from "./pagination"; +import styles from "./allProduct.module.css"; +import windowView from "@/hooks/windowView"; // 커스텀 훅 임포트 + +// Product 타입 정의 +interface Product { + id: string; // 상품 ID + name: string; // 상품 이름 + price: number; // 상품 가격 + favoriteCount: number; // 좋아요 개수 + images: string[]; // 상품 이미지 URL 배열 +} + +// API에서 반환되는 데이터 타입 정의 +interface ProductResponse { + list: Product[]; + totalCount: number; +} + +// 페이지 크기 설정을 위한 상수 +const PAGE_SIZES = { + small: { max: 767, size: 4 }, + medium: { max: 1199, size: 6 }, + large: { size: 10 }, // max가 없으므로 이 조건은 기본값으로 작동 +}; + +// 페이지 크기 계산 함수 +function getPageSize(width: number): number { + if (width <= PAGE_SIZES.small.max) { + return PAGE_SIZES.small.size; + } else if (width <= PAGE_SIZES.medium.max) { + return PAGE_SIZES.medium.size; + } else { + return PAGE_SIZES.large.size; + } +} + +const AllProducts = () => { + const [products, setProducts] = useState([]); // 상품 데이터 상태 + const [pageSize, setPageSize] = useState(4); // 초기 페이지 크기 설정 (10개로 설정) + const [currentPage, setCurrentPage] = useState(1); // 현재 페이지 상태 + const [orderBy, setOrderBy] = useState<"recent" | "favorite">("recent"); // 정렬 기준 + const [totalItems, setTotalItems] = useState(0); // 전체 상품 개수 상태 + const [keyword, setKeyword] = useState(""); // 검색어 상태 + const [isLoading, setIsLoading] = useState(false); // 로딩 상태 + + const windowWidth = windowView(); // 화면 크기 추적 + + // 화면 크기 변경에 따라 페이지 크기 조정 + useEffect(() => { + if (windowWidth === 0) return; // windowWidth가 0이면 return + + const newPageSize = getPageSize(windowWidth); // 화면 크기 기반 페이지 크기 계산 + setPageSize(newPageSize); // 페이지 크기 업데이트 + }, [windowWidth]); // windowWidth가 변경될 때마다 실행 + + // 상품 데이터 가져오는 함수 + useEffect(() => { + const fetchProducts = async () => { + setIsLoading(true); // 로딩 시작 + try { + const result: ProductResponse = await getProducts({ + page: currentPage, // 현재 페이지 + pageSize, // 페이지 크기 + orderBy, // 정렬 기준 + keyword, // 검색어 + }); + setProducts(result.list); // 상품 목록 설정 + setTotalItems(result.totalCount); // 전체 상품 개수 설정 + } catch (error) { + if (error instanceof Error) { + console.error("데이터 로드 중 오류 발생:", error.message); + setProducts([]); // 오류 발생 시 빈 배열로 설정 + } + } finally { + setIsLoading(false); // 로딩 완료 + } + }; + + fetchProducts(); + }, [currentPage, pageSize, orderBy, keyword]); // 의존성 배열 + + return ( +
+ + + {isLoading ? ( +
Loading...
+ ) : ( + + )} +
+ ); +}; + +export default AllProducts; diff --git a/components/AllProducts/navBar.tsx b/components/AllProducts/navBar.tsx new file mode 100644 index 000000000..16653ec5f --- /dev/null +++ b/components/AllProducts/navBar.tsx @@ -0,0 +1,83 @@ +import React, { useState, ChangeEvent } from "react"; +import searchIcon from "@/public/assets/img/logo/searchIcon.svg"; +import styles from "./allProduct.module.css"; +import Link from "next/link"; +import Image from "next/image"; + +// Props 타입 정의 +interface NavBarProps { + orderBy: "recent" | "favorite"; // 'recent' 또는 'favorite'만 가능 + setOrderBy: (order: "recent" | "favorite") => void; // setOrderBy는 'recent' 또는 'favorite'만 받을 수 있음 + keyword: string; + setKeyword: (keyword: string) => void; +} + +function NavBar({ orderBy, setOrderBy, keyword, setKeyword }: NavBarProps) { + const [isOpen, setIsOpen] = useState(false); // 드롭다운 메뉴 열림 상태 관리 + + // 드롭다운 메뉴의 열림/닫힘 상태를 토글하는 함수 + const toggleDropdown = () => setIsOpen((prev) => !prev); + + // 드롭다운에서 옵션을 선택했을 때 정렬 기준을 변경하고 드롭다운을 닫는 함수 + const selectOption = (option: string) => { + const order = option === "최신순" ? "recent" : "favorite"; + setOrderBy(order); // 정렬 기준을 선택한 값으로 업데이트 + setIsOpen(false); + }; + + // 검색어 입력 시 부모 컴포넌트로 전달 + const handleSearchChange = (e: ChangeEvent) => { + setKeyword(e.target.value); // 검색어 상태 업데이트 + }; + + return ( +
+

전체 상품

+ + {/* 검색 아이콘 */} + search-icon + {/* 검색 input */} + + {/* 상품 등록 버튼 */} + + + + {/* 드롭다운 버튼 */} + + {/* 드롭다운 목록 */} + {isOpen && ( +
    +
  • selectOption("최신순")} + id={styles.dropdown_item_new} + > + 최신순 +
  • +
  • selectOption("좋아요순")} + id={styles.dropdown_item_favorite} + > + 좋아요순 +
  • +
+ )} +
+ ); +} + +export default NavBar; diff --git a/components/AllProducts/pagination.tsx b/components/AllProducts/pagination.tsx new file mode 100644 index 000000000..c3827736e --- /dev/null +++ b/components/AllProducts/pagination.tsx @@ -0,0 +1,111 @@ +import React, { useState, useEffect } from "react"; +import styles from "./allProduct.module.css"; + +// props에 대한 타입 정의 +interface PaginationProps { + currentPage: number; // 현재 페이지 번호 + totalItems: number; // 전체 아이템 개수 + itemsPerPage: number; // 페이지당 아이템 개수 + onPageChange: (page: number) => void; // 페이지 변경 시 호출되는 함수 +} + +const pageRange = 5; // 한 번에 보여줄 페이지 버튼 개수 + +function Pagination({ + currentPage, + totalItems, + itemsPerPage, + onPageChange, +}: PaginationProps) { + const totalPages = Math.ceil(totalItems / itemsPerPage); // 전체 페이지 수 계산 + + // 페이지 범위 계산 + const [startPage, setStartPage] = useState(1); + const [endPage, setEndPage] = useState( + Math.min(totalPages, pageRange) + ); + + // 페이지 목록 생성 + const pages = Array.from( + { length: endPage - startPage + 1 }, + (_, index) => startPage + index + ); + + // 페이지 번호 클릭 시 처리 + const handlePageClick = (page: number) => { + onPageChange(page); + }; + + // 이전 버튼 클릭 시 + const handlePrevClick = () => { + if (startPage > 1) { + const newStartPage = Math.max(1, startPage - pageRange); + setStartPage(newStartPage); + setEndPage(newStartPage + pageRange - 1); + onPageChange(newStartPage); // 페이지 변경 + } + }; + + // 다음 버튼 클릭 시 + const handleNextClick = () => { + if (endPage < totalPages) { + const newStartPage = Math.min( + totalPages - pageRange + 1, + startPage + pageRange + ); + setStartPage(newStartPage); + setEndPage(newStartPage + pageRange - 1); + onPageChange(newStartPage); // 페이지 변경 + } + }; + + // 페이지 범위가 변경될 때마다 효과 처리 + useEffect(() => { + // 첫 렌더링 시에도 범위가 보이도록 함 + if (currentPage < startPage || currentPage > endPage) { + onPageChange(startPage); // 범위에 맞게 페이지 업데이트 + } + }, [currentPage, startPage, endPage, onPageChange]); + + // 페이지 범위가 업데이트되면, 페이지 번호가 보이도록 설정 + useEffect(() => { + setEndPage(Math.min(totalPages, startPage + pageRange - 1)); + }, [totalPages, startPage]); + + return ( +
+ {/* 이전 버튼 */} + + + {/* 페이지 번호 버튼 */} + {pages.map((page) => ( + + ))} + + {/* 다음 버튼 */} + +
+ ); +} + +export default Pagination; diff --git a/components/AllProducts/products.tsx b/components/AllProducts/products.tsx new file mode 100644 index 000000000..9df2a6997 --- /dev/null +++ b/components/AllProducts/products.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import heartIcon from "@/public/assets/img/logo/heartIcon.svg"; +import styles from "./allProduct.module.css"; +import EMPTY_IMAGE_URL from "@/public/assets/img/landing/Img_home_02.png"; +import Link from "next/link"; +import Image from "next/image"; // next/image 사용 + +// Product 타입 정의 +interface Product { + id: string; // 상품 ID + name: string; // 상품 이름 + price: number; // 상품 가격 + favoriteCount: number; // 좋아요 개수 + images: string[]; // 상품 이미지 URL 배열 +} + +// props 타입 정의 +interface ProductListProps { + products: Product[]; // products 배열 +} + +function ProductList({ products }: ProductListProps) { + return ( +
    + {/* 상품 목록이 있을 경우, 상품 정보를 렌더링 */} + {products.length > 0 ? ( + products.map((product) => ( +
  • + +
    + {/* 상품 이미지 */} + 0 + ? product.images[0] + : EMPTY_IMAGE_URL + } + width={500} + height={500} + alt={product.name || "기본 이미지"} + unoptimized // 최적화 비활성화 + /> +
    + {/* 상품 이름 */} +

    {product.name}

    + {/* 상품 가격 */} +

    {product.price}원

    +
    + {/* 좋아요 아이콘 */} + 좋아요 하트 기호 + {/* 좋아요 개수 */} + + {product.favoriteCount} + +
    + +
  • + )) + ) : ( +

    No Products

    /* 상품이 없으면 'No Products' 메시지 출력 */ + )} +
+ ); +} + +export default ProductList; diff --git a/components/Best/best.module.css b/components/Best/best.module.css new file mode 100644 index 000000000..4aab4087c --- /dev/null +++ b/components/Best/best.module.css @@ -0,0 +1,87 @@ +.best_Container { + padding-top: 94px; + display: flex; + flex-direction: column; + align-content: center; + flex-wrap: wrap; + gap: 0.625rem; +} + +.title { + font-weight: 700; + color: var(--gray900); + font-size: 20px; +} + +.best_list { + margin: 0; + padding: 0; + display: flex; + flex-direction: row; + justify-content: center; + list-style-type: none; + height: 378px; + gap: 24px; +} + +.product_link { + text-decoration: none; +} + +.product_image_box { + position: relative; + width: 282px; + height: 282px; + margin-bottom: 16px; +} + +@media screen and (max-width: 1199px) { + .product_image_box { + width: 343px; + height: 343px; + margin-bottom: 16px; + } +} + +.product_image { + position: absolute; + width: 100%; +} + +.product_name { + margin: 0; + margin-bottom: 6px; + font-size: 14px; + font-weight: 500; + color: var(--gray800); + line-height: 24px; +} + +.product_price { + margin: 0; + margin-bottom: 6px; + font-size: 16px; + font-weight: 700; + color: var(--gray800); + line-height: 26px; +} + +.heart_line { + height: 18px; +} + +.heart_image { + width: 16px; + height: 16px; + vertical-align: middle; +} + +.heart_num { + padding: 1px 0 1px 4px; + font-size: 12px; + line-height: 18px; + font-weight: 500; + color: var(--gray600); + text-align: left; + top: 2px; +} diff --git a/components/Best/index.tsx b/components/Best/index.tsx new file mode 100644 index 000000000..01c621d5c --- /dev/null +++ b/components/Best/index.tsx @@ -0,0 +1,114 @@ +import styles from "./best.module.css"; +import heartIcon from "@/public/assets/img/logo/heartIcon.svg"; +import { getProducts } from "@/api/api"; +import { useState, useEffect } from "react"; +import Link from "next/link"; +import Image from "next/image"; // next/image 사용 +import { Product } from "@/types"; +import React from "react"; +import windowView from "@/hooks/windowView"; // debounce 적용된 windowView 훅 사용 + +const PAGE_SIZES = { + mobile: { max: 767, size: 1 }, + tablet: { max: 1199, size: 2 }, + desktop: { size: 4 }, // 화면 크기에 따라 상품 개수를 결정 +}; + +// 페이지 크기 계산 함수 +const getPageSize = (width: number) => { + if (width <= PAGE_SIZES.mobile.max) { + return PAGE_SIZES.mobile.size; + } else if (width <= PAGE_SIZES.tablet.max) { + return PAGE_SIZES.tablet.size; + } else { + return PAGE_SIZES.desktop.size; + } +}; + +const Best: React.FC = () => { + const [products, setProducts] = useState([]); + const [pageSize, setPageSize] = useState(1); // 기본값을 1로 설정 (모바일에서 1개) + const [isError, setIsError] = useState(false); + + const windowWidth = windowView(); // debounce 적용된 windowWidth 값 받아오기 + + // 화면 크기 변화에 따라 페이지 크기 설정 + useEffect(() => { + if (windowWidth === 0) return; // windowWidth가 0이면 return + + const newPageSize = getPageSize(windowWidth); // 화면 크기에 맞는 페이지 크기 계산 + setPageSize(newPageSize); // 페이지 크기 업데이트 + }, [windowWidth]); // windowWidth가 변경될 때마다 실행 + + // pageSize가 변경될 때마다 상품 데이터를 다시 불러옴 + useEffect(() => { + const fetchBestProducts = async () => { + try { + const result = await getProducts({ + page: 1, // 페이지는 1로 고정 + pageSize, // 화면 크기에 맞춘 pageSize 전달 + orderBy: "favorite", // 인기 순으로 정렬 + }); + setProducts(result.list); // 상품 데이터 설정 + } catch (error) { + if (error instanceof Error) { + console.error("데이터 로드 중 오류 발생:", error.message); + setIsError(true); // 오류 발생 시 상태 변경 + setProducts([]); // 오류 발생 시 빈 배열로 설정 + } + } + }; + + fetchBestProducts(); // 페이지 크기 변경 후 상품 데이터 다시 가져오기 + }, [pageSize]); // pageSize가 변경될 때마다 데이터를 다시 가져옴 + + if (isError) { + return
상품을 불러오는 데 문제가 발생했습니다.
; + } + + return ( +
+

베스트 상품

+
    + {products.length > 0 ? ( + products.map((product) => ( +
  • + +
    + {product.images.length > 0 && ( +
    + {product.name} +
    + )} + +

    {product.name}

    +

    {product.price}원

    +
    + 좋아요 하트 기호 + + {product.favoriteCount} + +
    +
    + +
  • + )) + ) : ( +

    상품이 없습니다.

    // 상품이 없을 경우 + )} +
+
+ ); +}; + +export default Best; diff --git a/components/Board/index.tsx b/components/Board/index.tsx new file mode 100644 index 000000000..334546d5a --- /dev/null +++ b/components/Board/index.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import Header from "../Header/index"; +import styles from "./styles.module.css"; + +const Board: React.FC = () => { + return ( +
+
+
+
+

준비중

+
+
+
+ ); +}; + +export default Board; diff --git a/components/Board/styles.module.css b/components/Board/styles.module.css new file mode 100644 index 000000000..42b79aa83 --- /dev/null +++ b/components/Board/styles.module.css @@ -0,0 +1,12 @@ +.board_body { + margin: 0; + padding-top: 140px; + padding-left: 140px; +} + +.h1 { + margin: 0; + font-size: 100px; + font-weight: 800; + text-align: center; +} diff --git a/components/Header/index.tsx b/components/Header/index.tsx new file mode 100644 index 000000000..62e30954d --- /dev/null +++ b/components/Header/index.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import Image from "next/image"; // Next.js Image 컴포넌트 +import Logo from "./logo"; +import styles from "./styles.module.css"; +import loginImg from "@/public/assets/img/logo/login.svg"; // 이미지 경로 + +const Header: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default Header; diff --git a/components/Header/logo.tsx b/components/Header/logo.tsx new file mode 100644 index 000000000..9cfdd9d9c --- /dev/null +++ b/components/Header/logo.tsx @@ -0,0 +1,77 @@ +import React, { useEffect, useState } from "react"; +import Image from "next/image"; // Next.js의 Image 컴포넌트 사용 +import Link from "next/link"; // Next.js의 Link 컴포넌트 사용 +import { useRouter } from "next/router"; // Next.js의 useRouter 훅 +import logoImg from "@/public/assets/img/logo/logo.png"; // 이미지 경로 +import styles from "./styles.module.css"; // 스타일 파일 경로 + +const Logo: React.FC = () => { + const [isMobile, setIsMobile] = useState(false); + const router = useRouter(); // 현재 경로 가져오기 + + // 각 경로에 있을 때의 스타일을 조건부로 적용 + const isItemsPage = router.pathname === "/items"; + const isBoardPage = router.pathname === "/boards"; + const isAddItemPage = router.pathname === "/additem"; + const isMainPage = router.pathname === "/"; + const isItemsDetailPage = router.pathname.startsWith("/items/"); + + useEffect(() => { + // 창 크기 변경에 따라 상태 업데이트 + const handleResize = () => { + setIsMobile(window.innerWidth < 768); + }; + + // 최초 실행 시 창 크기 확인 + handleResize(); + + // resize 이벤트 리스너 등록 + window.addEventListener("resize", handleResize); + + // 컴포넌트 언마운트 시 이벤트 리스너 정리 + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + return ( +
+ {!isMobile && ( + 판다마켓로고 + )} +

+ 판다마켓 +

+
+ + 자유게시판 + + + 중고마켓 + +
+
+ ); +}; + +export default Logo; diff --git a/components/Header/styles.module.css b/components/Header/styles.module.css new file mode 100644 index 000000000..c117b36e0 --- /dev/null +++ b/components/Header/styles.module.css @@ -0,0 +1,122 @@ +/***************** header ******************/ +/* header { + box-sizing: border-box; + position: fixed; + z-index: 1; + width: 100%; + height: 70px; +} */ + +.header_bar, +.logo { + box-sizing: border-box; + display: flex; + align-items: center; + height: 70px; +} + +.logo { + gap: 10px; +} + +.header_bar { + margin: auto; + padding: 0 12.5rem; + background-color: #ffffff; + border: 1px solid #dfdfdf; + width: 100%; + flex-direction: row; + justify-content: space-between; +} + +.menu { + margin-left: 2rem; +} + +.menuText_default { + font-weight: 700; + color: var(--gray600); + font-size: 18px; + text-decoration: none; + padding: 21px 15px; + line-height: 26px; +} + +.menuText_focus { + font-weight: 700; + color: var(--blue); + font-size: 18px; + text-decoration: none; + padding: 21px 15px; + line-height: 26px; +} + +/* tablet */ +@media screen and (max-width: 1199px) { + .header_bar { + padding: 0 1.5rem; + } + + .menu { + margin-left: 1.5rem; + } +} + +/* mobile */ +@media screen and (max-width: 767px) and (min-width: 375px) { + .header_bar { + padding: 0 1rem; + min-width: 375px; + max-width: 767px; + margin: 0; + } + + .menu { + margin-left: 0.2rem; + } + + .menuText { + padding: 1.5rem 0.2rem; + } +} + +.header_bar_img { + width: 40px; + height: 40px; +} + +.header_bar .login { + position: relative; + border-radius: 8px; + background-color: var(--blue); + width: 128px; + height: 48px; +} + +.login a { + position: absolute; + width: 82px; + height: 24px; + padding: 12px 23px; + font-size: 16px; + font-weight: 600; + line-height: 26px; + color: #ffffff; + text-decoration: none; + text-align: center; +} + +.header_bar > .login:hover { + cursor: pointer; + background: #1967d6; +} + +.logo_title a { + font-family: "ROKAF Sans"; + text-decoration: none; + color: var(--blue); + font-weight: 800; + font-size: 25px; + line-height: 36px; + text-align: left; +} diff --git a/components/Home/index.tsx b/components/Home/index.tsx new file mode 100644 index 000000000..2be9a85e1 --- /dev/null +++ b/components/Home/index.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import Header from "../Header/index"; +import styles from "./styles.module.css"; + +const Home: React.FC = () => { + return ( +
+
+
+
+

준비중

+
+
+
+ ); +}; + +export default Home; diff --git a/components/Home/styles.module.css b/components/Home/styles.module.css new file mode 100644 index 000000000..64f11f1f4 --- /dev/null +++ b/components/Home/styles.module.css @@ -0,0 +1,12 @@ +.home_body { + margin: 0; + padding-top: 140px; + padding-left: 140px; +} + +.h1 { + margin: 0; + font-size: 100px; + font-weight: 800; + text-align: center; +} diff --git a/components/Items/index.tsx b/components/Items/index.tsx new file mode 100644 index 000000000..9457e96ec --- /dev/null +++ b/components/Items/index.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import Header from "../Header/index"; +import Best from "../Best/index"; +import AllProducts from "../AllProducts/index"; + +const Items: React.FC = () => { + return ( +
+
+ + +
+ ); +}; + +export default Items; diff --git a/components/product/index.tsx b/components/product/index.tsx new file mode 100644 index 000000000..239ed8e79 --- /dev/null +++ b/components/product/index.tsx @@ -0,0 +1,190 @@ +import React, { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { getProductComments, getProductDetails } from "@/api/api"; +import styles from "./product.module.css"; +import Header from "../Header/index"; +import { Product, Comment } from "@/types"; // 타입 임포트 +import { formatDate } from "@/utils/formatDate"; // formatDate 함수 임포트 + +const ProductDetail: React.FC = () => { + const { productId } = useParams<{ productId: string }>(); + const navigate = useNavigate(); + const [product, setProduct] = useState(null); + const [comments, setComments] = useState([]); + const [newComment, setNewComment] = useState(""); + const [isCommentValid, setIsCommentValid] = useState(false); + + // Fetch product details + useEffect(() => { + async function fetchProduct() { + try { + if (productId) { + const data = await getProductDetails(productId); + setProduct(data); // 상품 데이터 설정 + } + } catch (error) { + console.error("Error fetching product details:", error); + } + } + + fetchProduct(); + }, [productId]); + + // Fetch comments + useEffect(() => { + async function fetchComments() { + try { + if (productId) { + const data = await getProductComments({ productId, limit: 10 }); + if (data && Array.isArray(data)) { + setComments(data); // list 안에 댓글 배열이 있을 경우 + } else { + setComments([]); // list가 없으면 빈 배열 설정 + } + } + } catch (error) { + console.error("Error fetching comments:", error); + } + } + + fetchComments(); + }, [productId]); + + const handleCommentChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setNewComment(value); + setIsCommentValid(value.trim().length > 0); + }; + + const handleCommentSubmit = async () => { + if (!isCommentValid) return; + + try { + // 예시로 로그인된 사용자 정보를 가져온다고 가정 + const user = { + id: 1, + nickname: "nickname", + image: "user-image-url.jpg", + }; + + const newCommentData = { + content: newComment, + writer: user, // 로그인된 사용자 정보 추가 + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + id: Date.now().toString(), // 새로운 댓글 ID 생성 (서버에서 관리될 경우 제외) + }; + + const token = localStorage.getItem("authToken"); + + const response = await fetch( + `https://panda-market-api.vercel.app/products/${productId}/comments`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, // 인증 토큰 추가 + }, + body: JSON.stringify(newCommentData), + } + ); + + if (response.ok) { + const result = await response.json(); + setComments((prevComments) => [...prevComments, result]); + setNewComment(""); + setIsCommentValid(false); + } else { + console.error("Unauthorized: Check your login or token."); + } + } catch (error) { + console.error("Error submitting comment:", error); + } + }; + + if (!product) { + return
Loading...
; + } + + return ( +
+
+
+
+
+
+ {product.images.map((img, index) => ( + {`상품 + ))} +
+
+

{product.name}

+ {product.price}원 +
+

상품 소개

+

+ {product.description} +

+

상품 태그

+
+ {product.tags.map((tag, index) => ( + + #{tag} + + ))} +
+

+ ❤️ {product.favoriteCount} +

+
+
+
+
+

문의하기

+