diff --git a/.gitignore b/.gitignore index 8f322f0d8..46f39de70 100644 --- a/.gitignore +++ b/.gitignore @@ -1,35 +1,73 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +# Created by https://www.toptal.com/developers/gitignore/api/macos,git,react +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,git,react -# dependencies -/node_modules -/.pnp -.pnp.js +### Git ### +# Created by git for backups. To disable backups in Git: +# $ git config --global mergetool.keepBackup false +*.orig -# testing -/coverage +# Created by git when using merge tools for conflicts +*.BACKUP.* +*.BASE.* +*.LOCAL.* +*.REMOTE.* +*_BACKUP_*.txt +*_BASE_*.txt +*_LOCAL_*.txt +*_REMOTE_*.txt -# next.js -/.next/ -/out/ +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride -# production -/build +# Icon must end with two \r +Icon -# misc -.DS_Store -*.pem -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### react ### +.DS_* +*.log +logs +**/*.backup.* +**/*.back.* + +node_modules +node_modules/ +node_modules/.cache +bower_components + +*.sublime* -# local env files -.env*.local +psd +thumb +sketch -# vercel -.vercel +### next ### +.next -# typescript -*.tsbuildinfo -next-env.d.ts +# End of https://www.toptal.com/developers/gitignore/api/macos,git,react diff --git a/README.md b/README.md index a75ac5248..b13f44391 100644 --- a/README.md +++ b/README.md @@ -1,40 +1 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. - -[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. - -The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. - -This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +## Next.js Project 시작 diff --git a/api/api.tsx b/api/api.tsx new file mode 100644 index 000000000..898b0b0ed --- /dev/null +++ b/api/api.tsx @@ -0,0 +1,60 @@ +import { CommentDataProps, ProductDataProps } from "../api/types"; +const BASE_URL = "https://panda-market-api.vercel.app"; + +// 베스트/전체 상품 리스트 +export async function getProductData(params = {}) { + const query = new URLSearchParams(params).toString(); + const response = await fetch(`${BASE_URL}/products?${query}`); + + if (!response.ok) { + throw new Error("상품을 불러오는 데 실패했습니다."); + } + const body = await response.json(); + return body; +} + +// 디테일 상품 정보 +export async function getProductId( + productId: string | string[], + setProductData: React.Dispatch< + React.SetStateAction + >, + setLoading: React.Dispatch> +) { + const response = await fetch(`${BASE_URL}/products/${productId}`); + + try { + const body = await response.json(); + setProductData(body); + } catch (error) { + console.log(error); + } + + if (!response.ok) { + throw new Error("정보를 불러오는 데 실패했습니다."); + } + + setLoading(false); +} + +// 디테일 댓글 +export async function getComments( + productId: string | string[], + setCommentsData: React.Dispatch< + React.SetStateAction + > +) { + const response = await fetch( + `${BASE_URL}/products/${productId}/comments?limit=10` + ); + try { + const body = await response.json(); + setCommentsData(body); + } catch (error) { + console.log(error); + } + + if (!response.ok) { + throw new Error("정보를 불러오는 데 실패했습니다."); + } +} diff --git a/api/types.ts b/api/types.ts new file mode 100644 index 000000000..c420ea42f --- /dev/null +++ b/api/types.ts @@ -0,0 +1,35 @@ +import { StaticImport, StaticRequire } from "next/dist/shared/lib/get-img-props"; + +export interface ProductDataProps { + createdAt: string; + description: string; + favoriteCount: number; + id: number; + images:string | StaticImport; + isFavorite: boolean; + name: string; + ownerId: number; + ownerNickname: string; + price: number; + tags: string[]; + updatedAt: string; +} + +interface ListWriter { + id: number; + image?: string | null; + nickname: string; +} + +interface ListComment { + content: string; + createdAt: string; + id: number; + updatedAt: string; + writer: ListWriter; +} + +export interface CommentDataProps { + list: ListComment[]; + nextCursor: number; +} diff --git a/components/app/Dayjs.tsx b/components/app/Dayjs.tsx new file mode 100644 index 000000000..bf2a7006b --- /dev/null +++ b/components/app/Dayjs.tsx @@ -0,0 +1,31 @@ +import dayjs from "dayjs"; +import duration, { Duration } from "dayjs/plugin/duration"; +dayjs.extend(duration); + +export function getTimeDiff(timeToCompare: string) { + const timeDiffDuration: Duration = dayjs.duration( + dayjs().diff(timeToCompare) + ); + const yearDiff: number = parseInt(timeDiffDuration.format("Y")); + const monthDiff: number = parseInt(timeDiffDuration.format("M")); + const dateDiff: number = parseInt(timeDiffDuration.format("D")); + const hourDiff: number = parseInt(timeDiffDuration.format("H")); + const minuteDiff: number = parseInt(timeDiffDuration.format("m")); + const secondDiff: number = parseInt(timeDiffDuration.format("s")); + + if (yearDiff > 0) { + return `${yearDiff}년 전`; + } else if (monthDiff > 0) { + return `${monthDiff}달 전`; + } else if (dateDiff > 0) { + return `${dateDiff}일 전`; + } else if (hourDiff > 0) { + return `${hourDiff}시간 전`; + } else if (minuteDiff > 0) { + return `${minuteDiff}분 전`; + } else if (secondDiff > 0) { + return `${secondDiff}초 전`; + } else { + return ""; + } +} diff --git a/components/app/Footer.tsx b/components/app/Footer.tsx new file mode 100644 index 000000000..f94d8d99f --- /dev/null +++ b/components/app/Footer.tsx @@ -0,0 +1,60 @@ +import styles from "../../styles/app/footer.module.css"; +import IconInsta from "@/public/assets/images/app/home/ic_instagram.svg"; +import IconFacebook from "@/public/assets/images/app/home/ic_facebook.svg"; +import IconYoutube from "@/public/assets/images/app/home/ic_youtube.svg"; +import IconTwitter from "@/public/assets/images/app/home/ic_twitter.svg"; +import Link from "next/link"; + +function Footer() { + return ( +
+
+

©codeit - 2024

+
+ Privacy Policy + FAQ +
+
    +
  • + + + +
  • +
  • + + + +
  • +
  • + + + +
  • +
  • + + + +
  • +
+
+
+ ); +} + +export default Footer; diff --git a/components/app/ItemListNav.tsx b/components/app/ItemListNav.tsx new file mode 100644 index 000000000..ace31866d --- /dev/null +++ b/components/app/ItemListNav.tsx @@ -0,0 +1,54 @@ +import { useRouter } from "next/router"; +import Link from "next/link"; +import Image from "next/image"; +import styles from "../../styles/app/navi.module.css"; +import NavLogoImg from "@/public/assets/images/app/navi/logo.svg"; +import profileDefaultImg from "@/public/assets/images/app/navi/profile_default.png"; + +const menuData = [ + { id: 1, name: "자유게시판", path: "/boards" }, + { id: 2, name: "중고마켓", path: "/items" }, +]; + +function ItemListNav() { + const router = useRouter(); + + return ( +
+ +
+ ); +} + +export default ItemListNav; diff --git a/components/app/Loading.tsx b/components/app/Loading.tsx new file mode 100644 index 000000000..618862bc8 --- /dev/null +++ b/components/app/Loading.tsx @@ -0,0 +1,11 @@ +import styles from "../../styles/app/loading.module.css"; + +export const Loading = () => { + return ( +
+ +
+ ); +}; + +export default Loading; diff --git a/components/app/Pagination.tsx b/components/app/Pagination.tsx new file mode 100644 index 000000000..f82ddb356 --- /dev/null +++ b/components/app/Pagination.tsx @@ -0,0 +1,64 @@ +import calculatorPagination from "../../utils/calculatorPagination"; +import styles from "../../styles/app/pagination.module.css"; +import ArrowPrevImg from "@/public/assets/images/app/pagination/arrow_left.svg"; +import ArrowNextImg from "@/public/assets/images/app/pagination/arrow_right.svg"; +import { ArrowButton, Pagination } from "@/components/app/types"; + +function PaginationContainer({ + page, + setPage, + pageCount, + isDataCount, +}: Pagination) { + const itemCountPerPage = Math.ceil(pageCount / isDataCount); // 페이지 당 보여줄 데이터 개수 + const ITEMS_PER_PAGINATION = 5; // 한 페이지당 pagination 5개 출력 + + const { totalPages, currentSet, startPage, endPage } = calculatorPagination({ + page, + pageCount, + isDataCount, + ITEMS_PER_PAGINATION, + }); + + const noPrev = page === 1; + const noNext = page + itemCountPerPage - 1 >= totalPages; + + function generatePageNumbers({ startPage, endPage }: ArrowButton) { + return Array.from( + { length: endPage - startPage + 1 }, + (_, i) => startPage + i + ); + } + + return ( +
    + {currentSet > 1 && ( +
  • setPage(1)} + > + +
  • + )} + {generatePageNumbers({ startPage, endPage }).map((pageNumber) => ( +
  • setPage(pageNumber)} + > + {pageNumber} +
  • + ))} + {currentSet < Math.ceil(totalPages / ITEMS_PER_PAGINATION) && ( +
  • setPage(endPage + 1)} + > + +
  • + )} +
+ ); +} + +export default PaginationContainer; diff --git a/components/app/ScrollToTop.tsx b/components/app/ScrollToTop.tsx new file mode 100644 index 000000000..8365f7ff7 --- /dev/null +++ b/components/app/ScrollToTop.tsx @@ -0,0 +1,13 @@ +import { useEffect } from "react"; +import { Props } from "@/components/app/types"; +import { useRouter } from "next/router"; + +export default function ScrollToTop(props: Props) { + const { pathname } = useRouter(); + + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + + return <>{props.children}; +} diff --git a/components/app/types.ts b/components/app/types.ts new file mode 100644 index 000000000..d38a47136 --- /dev/null +++ b/components/app/types.ts @@ -0,0 +1,19 @@ +import { ReactNode } from "react"; + +// ScrollToTop.jsx +export interface Props { + children?: ReactNode; +} + +// Pagination.jsx +export interface Pagination { + page: number; + setPage: React.Dispatch>; + pageCount: number; + isDataCount: number; +} + +export interface ArrowButton { + startPage: number; + endPage: number; +} diff --git a/components/boards/AllPost.tsx b/components/boards/AllPost.tsx new file mode 100644 index 000000000..353ea1a26 --- /dev/null +++ b/components/boards/AllPost.tsx @@ -0,0 +1,55 @@ +import Image from "next/image"; +import styles from "../../styles/boards/postList.module.css"; +import noImg from "@/public/assets/images/app/common/no_img.jpg"; +import WishHeartImg from "@/public/assets/images/boards/ic_heart.svg"; +import { useState } from "react"; +import { Item } from "./types"; + +function AllPost({ item }:{item:Item}) { + const [isImgError, setIsImgError] = useState(false); + + return ( + <> +
+

{item.title}

+
+ {item?.image === null || isImgError ? ( + 게시글 이미지 setIsImgError(true)} + /> + ) : ( + 게시글 이미지 setIsImgError(true)} + /> + )} +
+
+
+
+
+ 프로필 이미지 +
+

{item.writer.nickname}

+

{item.createdAt.slice(0, 10)}

+
+
+ +

{item.likeCount}+

+
+
+ + ); +} + +export default AllPost; diff --git a/components/boards/AllPostsList.tsx b/components/boards/AllPostsList.tsx new file mode 100644 index 000000000..41843592a --- /dev/null +++ b/components/boards/AllPostsList.tsx @@ -0,0 +1,144 @@ +import Link from "next/link"; +import Image from "next/image"; +import styles from "../../styles/boards/postList.module.css"; +import searchImg from "@/public/assets/images/boards/ic_search.png"; +import SelectArrowImg from "@/public/assets/images/boards/select_down.svg"; +import AllPost from "./AllPost"; +import { + ChangeEvent, + Dispatch, + SetStateAction, + useEffect, + useRef, + useState, +} from "react"; +import { Item } from "./types"; +import Section2Skeleton from "./Section2Skeleton"; + +type Destructuring = { + list: Item[]; + totalCount: number; +}; + +type AllPostsListProps = { + recentPost: Destructuring | null; + setKeyword: Dispatch>; + setOrder: Dispatch>; + recentLoading: boolean; +}; + +function AllPostsList({ + recentPost, + setKeyword, + setOrder, + recentLoading, +}: AllPostsListProps) { + const [isFilter, setIsFilter] = useState("최신순"); + const [isSelectbox, setIsSelecBox] = useState(false); + const outRef = useRef(null); + const { list } = recentPost || {}; + + const handleSelectDropDown = () => { + isSelectbox ? setIsSelecBox(false) : setIsSelecBox(true); + }; + + const handleOrderChange = (order: string) => { + setOrder(order); + }; + + const handleValueChange = (e: ChangeEvent) => { + setKeyword(e.target.value); + }; + + useEffect(() => { + const handleClickOutside = (e: { target: any }) => { + // 해당 이벤트가 영역 밖이라면 케밥 버튼 비활성화 + if (outRef.current && !outRef.current.contains(e.target)) { + setIsSelecBox(false); + } + }; + + document.addEventListener("click", handleClickOutside, true); + return () => { + document.removeEventListener("click", handleClickOutside, true); + }; + }, []); + + return ( + <> +
+
+
+

게시글

+ + + +
+
+
+
+
+ 검색하기 + +
+
+
+
+

{isFilter}

+ +
+
    +
  • { + handleOrderChange("recent"); + setIsFilter("최신순"); + }} + > + 최신순 +
  • +
  • { + handleOrderChange("like"); + setIsFilter("좋아요순"); + }} + > + 좋아요순 +
  • +
+
+
+
+
+ {/* 무한스크롤 기능 필요. 현재는 최대 10개만 불러오고 있음 */} + {recentLoading ? ( + + ) : ( +
    + {list && + list.map((item) => { + return ( +
  • + +
  • + ); + })} +
+ )} +
+ + ); +} + +export default AllPostsList; diff --git a/components/boards/BestPost.tsx b/components/boards/BestPost.tsx new file mode 100644 index 000000000..aef5857e1 --- /dev/null +++ b/components/boards/BestPost.tsx @@ -0,0 +1,55 @@ +import Image from "next/image"; +import styles from "../../styles/boards/postList.module.css"; +import noImg from "@/public/assets/images/app/common/no_img.jpg"; +import BestBadgeImg from "@/public/assets/images/boards/ic_medal.svg"; +import WishHeartImg from "@/public/assets/images/boards/ic_heart.svg"; +import { useState } from "react"; +import { Item } from "./types"; + +function BestPost({ item }: { item: Item }) { + const [isImgError, setIsImgError] = useState(false); + + return ( + <> +
+ + Best +
+
+

{item.title}

+
+ {item?.image === null || isImgError ? ( + 게시글 이미지 setIsImgError(true)} + /> + ) : ( + 게시글 이미지 setIsImgError(true)} + /> + )} +
+
+
+
+

{item.writer.nickname}

+
+ +

{item.likeCount}+

+
+
+
+

{item.createdAt.slice(0, 10)}

+
+
+ + ); +} + +export default BestPost; diff --git a/components/boards/BestPostsList.tsx b/components/boards/BestPostsList.tsx new file mode 100644 index 000000000..087701213 --- /dev/null +++ b/components/boards/BestPostsList.tsx @@ -0,0 +1,42 @@ +import styles from "../../styles/boards/postList.module.css"; +import BestPost from "./BestPost"; +import Section1Skeleton from "./Section1Skeleton"; +import { Item } from "./types"; + +type Destructuring = { + list: Item[]; + totalCount: number; +}; + +type BestPostsList = { + likePost: Destructuring | null; + likeLoading: boolean; +}; + +function BestPostsList({ likePost, likeLoading }: BestPostsList) { + const { list } = likePost || {}; + + return ( + <> +
+

베스트 게시글

+ + {likeLoading ? ( + + ) : ( +
    + {list?.map((item) => { + return ( +
  • + +
  • + ); + })} +
+ )} +
+ + ); +} + +export default BestPostsList; diff --git a/components/boards/Section1Skeleton.tsx b/components/boards/Section1Skeleton.tsx new file mode 100644 index 000000000..e78a7b7b0 --- /dev/null +++ b/components/boards/Section1Skeleton.tsx @@ -0,0 +1,17 @@ +import styles from "./Skeleton.module.css"; + +const repeat = [{ id: 1 }, { id: 2 }, { id: 3 }]; + +export default function Section1Skeleton() { + return ( +
    + {repeat.map((item) => { + return ( +
  • +
    +
  • + ); + })} +
+ ); +} diff --git a/components/boards/Section2Skeleton.tsx b/components/boards/Section2Skeleton.tsx new file mode 100644 index 000000000..31d312892 --- /dev/null +++ b/components/boards/Section2Skeleton.tsx @@ -0,0 +1,17 @@ +import styles from "./Skeleton.module.css"; + +const repeat = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + +export default function Section2Skeleton() { + return ( +
    + {repeat.map((item) => { + return ( +
  • +
    +
  • + ); + })} +
+ ); +} diff --git a/components/boards/Skeleton.module.css b/components/boards/Skeleton.module.css new file mode 100644 index 000000000..793eaab51 --- /dev/null +++ b/components/boards/Skeleton.module.css @@ -0,0 +1,88 @@ +/* 공통 */ +.skeletonBox { + position: relative; + flex: 1; + max-width: 33.33333%; + height: 182px; + border-radius: 10px; + border: 1px solid var(--gray-50); + box-sizing: border-box; + overflow: hidden; + background: var(--gray-200); + + @media (max-width: 1200px) { + max-width: 100%; + } + + /* Mobile */ + @media (max-width: 768px) { + max-width: 100%; + } +} + +.skeletonLoading { + position: absolute; + width: 100%; + height: 100%; + background: linear-gradient( + 120deg, + #e5e5e5 30%, + #f0f0f0 38%, + #f0f0f0 40%, + #e5e5e5 48% + ); + border-radius: 1rem; + background-size: 200% 100%; + background-position: 100% 0; + animation: load 0.8s infinite; +} + +@keyframes load { + 100% { + background-position: -100% 0; + } +} + +/* Srction1 */ +.section1Wrap { + -webkit-dispaly: flex; + display: flex; + gap: 24px; + flex-wrap: wrap; + + @media (max-width: 1200px) { + gap: 24; + } +} +.section1Wrap .skeletonBox:nth-of-type(3), +.section1Wrap .skeletonBox:nth-of-type(4) { + @media (max-width: 1200px) { + display: none; + } +} +.section1Wrap .skeletonBox:nth-of-type(2), +.section1Wrap .skeletonBox:nth-of-type(3), +.section1Wrap .skeletonBox:nth-of-type(4) { + @media (max-width: 768px) { + display: none; + } +} + +/* Srction2 */ +.section2Wrap { + -webkit-dispaly: flex; + display: flex; + -webkit-flex-direction: column; + flex-direction: column; + gap: 24px; + flex-wrap: wrap; + + @media (max-width: 1200px) { + gap: 24; + } +} + +.section2Wrap .skeletonBox { + max-width: 100%; + min-height: 147px; +} diff --git a/components/boards/types.ts b/components/boards/types.ts new file mode 100644 index 000000000..87c5cc67e --- /dev/null +++ b/components/boards/types.ts @@ -0,0 +1,17 @@ +import { StaticImport } from "next/dist/shared/lib/get-img-props"; + +interface ItemWriter { + id: number; + nickname: string; +} + +export interface Item { + id: number; + title: string; + content: string; + createdAt: string; + updatedAt: string; + image: string | StaticImport; + likeCount: number; + writer: ItemWriter; +} diff --git a/components/items/AllItem.tsx b/components/items/AllItem.tsx new file mode 100644 index 000000000..dc1b85fd3 --- /dev/null +++ b/components/items/AllItem.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import Image from "next/image"; +import styles from "../../styles/items/productList.module.css"; +import btnWishImg from "@/public/assets/images/items/btn_wish.png"; +import NoImg from "@/public/assets/images/app/common/no_img.jpg"; +import { Props } from "./types"; + +function AllItem({ item }: { item: Props }) { + const [isImgError, setIsImgError] = useState(false); + const formattedPrice = item.price.toLocaleString(); + + return ( + <> +
+ {`${item.images}`.length === 0 ? ( + {item.name} setIsImgError(true)} + /> + ) : ( + {item.name} setIsImgError(true)} + /> + )} +
+
+

{item.name}

+

{formattedPrice}원

+
+ 찜하기 +

{item.favoriteCount}

+
+
+ + ); +} + +export default AllItem; diff --git a/components/items/AllItemsContainer.tsx b/components/items/AllItemsContainer.tsx new file mode 100644 index 000000000..6aebe41fd --- /dev/null +++ b/components/items/AllItemsContainer.tsx @@ -0,0 +1,144 @@ +import { useState, useEffect, ChangeEvent } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import styles from "../../styles/items/productList.module.css"; +import ArrowDownImg from "@/public/assets/images/items/select_down.svg"; +import productSearchImg from "@/public/assets/images/items/pd_search.png"; +import { getProductData } from "@/api/api"; +import calculatorMediaQuery from "../../utils/calculatormediaQuery"; +import { ApiOptions, SearchForm } from "./types"; + +function AllItemsContainer({ + setProductContainer, + page, + setPage, + setPageCount, + setIsDataCount, + setLoading, +}: SearchForm) { + const { isTablet, isMobile } = calculatorMediaQuery(); + const [isResponsive, setIsResponsive] = useState(0); + const [isItemCount, setIsItemCount] = useState( + isMobile ? 4 : isTablet ? 6 : 10 + ); + + const [orderBy, setOrderBy] = useState("recent"); + const [keyword, setKeyword] = useState(""); + const [filter, setFilter] = useState("최신순"); + const [toggle, setToggle] = useState(true); + + // 첫 렌더링 시 현재 유저의 디바이스 크기를 계산해 페이지네이션 출력 + useEffect(() => { + setIsDataCount(isItemCount); + }, [isItemCount, setIsDataCount]); + + const handleFilterToggle = () => { + toggle ? setToggle(false) : setToggle(true); + }; + + const handleNewsetClick = (e: React.MouseEvent) => { + const filterText = (e?.target as HTMLLIElement).textContent!; + setFilter(filterText); + setToggle(true); + setOrderBy("recent"); + setPage(1); + }; + + const handleBestClick = (e: React.MouseEvent) => { + const filterText = (e?.target as HTMLLIElement).textContent!; + setFilter(filterText); + setToggle(true); + setOrderBy("favorite"); + setPage(1); + }; + + useEffect(() => { + const handleResize = () => { + setIsResponsive(window.innerWidth); + isMobile + ? setIsItemCount(4) + : isTablet + ? setIsItemCount(6) + : setIsItemCount(10); + setIsDataCount(isItemCount); + // 페이지 창 크기 조절 시 pagination 1로 초기화(추후 불필요하단 판단 시 아래 코드만 삭제) + setPage(1); + }; + + window.addEventListener("resize", handleResize); + return () => { + // cleanup + window.removeEventListener("resize", handleResize); + }; + }, [isItemCount, isResponsive, isMobile, isTablet, setIsDataCount, setPage]); + + useEffect(() => { + const handleLoad = async (options: ApiOptions) => { + try { + const { list, totalCount } = await getProductData(options); + setProductContainer(list); + setPageCount(totalCount); + } catch (error) { + console.log(error); + } + setLoading(false); + }; + + handleLoad({ + page, + orderBy, + pageSize: isItemCount, + keyword: keyword, + }); + }, [ + orderBy, + keyword, + page, + isItemCount, + setLoading, + setPageCount, + setProductContainer, + ]); + + const handleSearch = (e: ChangeEvent) => { + e.preventDefault(); + setKeyword(e.target.value); + setPage(1); + }; + return ( + <> +
+

전체 상품

+
+
+
+
+ 상품검색 + +
+ + + +
+
+
+
+

{filter}

+ +
+
    +
  • 최신순
  • +
  • 좋아요순
  • +
+
+
+
+ + ); +} + +export default AllItemsContainer; diff --git a/components/items/AllItemsList.tsx b/components/items/AllItemsList.tsx new file mode 100644 index 000000000..e1b99b27c --- /dev/null +++ b/components/items/AllItemsList.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import styles from "../../styles/items/productList.module.css"; +import notFoundImg from "@/public/assets/images/items/not_found.png"; +import AllItemsContainer from "./AllItemsContainer"; +import AllItem from "./AllItem"; +import { ItemsList, Props } from "./types"; +import Section2Skeleton from "./Section2Skeleton"; + +function EmptyPlaceholder() { + return ( +
+ Not Found +

검색 결과가 없습니다

+
+ ); +} + +function AllItemsList({ + page, + setPage, + setPageCount, + setIsDataCount, +}: ItemsList) { + const [productContainer, setProductContainer] = useState([]); + const [loading, setLoading] = useState(true); + + return ( +
+ + {loading ? ( + + ) : ( +
    + {productContainer.length === 0 && } + {productContainer.length > 0 && + productContainer.map((item) => { + return ( +
  • + + + +
  • + ); + })} +
+ )} +
+ ); +} + +export default AllItemsList; diff --git a/components/items/BestItem.tsx b/components/items/BestItem.tsx new file mode 100644 index 000000000..8f2069616 --- /dev/null +++ b/components/items/BestItem.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; +import Image from "next/image"; +import styles from "../../styles/items/productList.module.css"; +import btnWishImg from "@/public/assets/images/items/btn_wish.png"; +import NoImg from "@/public/assets/images/app/common/no_img.jpg"; +import { Props } from "./types"; + +function BestItem({ item }: { item: Props }) { + const [isImgError, setIsImgError] = useState(false); + const formattedPrice = item.price.toLocaleString(); + + return ( + <> +
+ {item.name} setIsImgError(true)} + fill + priority={true} + sizes="100%" + /> +
+
+

{item.name}

+

{formattedPrice}

+
+ 찜하기 +

{item.favoriteCount}

+
+
+ + ); +} + +export default BestItem; diff --git a/components/items/BestItemsContainer.tsx b/components/items/BestItemsContainer.tsx new file mode 100644 index 000000000..6ba37f399 --- /dev/null +++ b/components/items/BestItemsContainer.tsx @@ -0,0 +1,58 @@ +import { useState, useEffect } from "react"; +import { getProductData } from "@/api/api"; +import calculatorMediaQuery from "../../utils/calculatormediaQuery"; +import { ApiOptions, Props } from "./types"; + +function BestItemsContainer({ + setProductList, + setLoading, +}: { + setProductList: React.Dispatch>; + setLoading: React.Dispatch>; +}) { + const { isTablet, isMobile } = calculatorMediaQuery(); + const [isResponsive, setIsResponsive] = useState(0); + const [isItemCount, setIsItemCount] = useState( + isMobile ? 1 : isTablet ? 2 : 4 + ); + const [orderBy, setOrderby] = useState("favorite"); + const [page, setPage] = useState(1); + const [keyword, setKeyword] = useState(""); + + useEffect(() => { + const handleResize = () => { + setIsResponsive(window.innerWidth); + isMobile + ? setIsItemCount(1) + : isTablet + ? setIsItemCount(2) + : setIsItemCount(4); + }; + window.addEventListener("resize", handleResize); + return () => { + // cleanup + window.removeEventListener("resize", handleResize); + }; + }, [isResponsive, isMobile, isTablet]); + + useEffect(() => { + const handleLoad = async (options: ApiOptions) => { + try { + const { list } = await getProductData(options); + setProductList(list); + } catch (error) { + console.log(error); + } + setLoading(false); + }; + + handleLoad({ + page, + orderBy, + pageSize: isItemCount, + keyword, + }); + }, [orderBy, keyword, page, isItemCount, setLoading, setProductList]); +} + +export default BestItemsContainer; diff --git a/components/items/BestItemsList.tsx b/components/items/BestItemsList.tsx new file mode 100644 index 000000000..8131746d1 --- /dev/null +++ b/components/items/BestItemsList.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import Link from "next/link"; +import styles from "../../styles/items/productList.module.css"; +import BestItemsContainer from "./BestItemsContainer"; +import BestItem from "./BestItem"; +import { Props } from "./types"; +import Section1Skeleton from "./Section1Skeleton"; + +function BestItemsList() { + const [productList, setProductList] = useState([]); + const [loading, setLoading] = useState(true); + + return ( + <> + {/* @ts-expect-error Async Server Component */} + +
+

베스트 상품

+ {loading ? ( + + ) : ( +
    + {productList.map((item) => { + return ( +
  • + + + +
  • + ); + })} +
+ )} +
+ + ); +} + +export { BestItemsList }; diff --git a/components/items/Section1Skeleton.tsx b/components/items/Section1Skeleton.tsx new file mode 100644 index 000000000..bf845273f --- /dev/null +++ b/components/items/Section1Skeleton.tsx @@ -0,0 +1,17 @@ +import styles from "./Skeleton.module.css"; + +const repeat = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + +export default function Section1Skeleton() { + return ( +
    + {repeat.map((item) => { + return ( +
  • +
    +
  • + ); + })} +
+ ); +} diff --git a/components/items/Section2Skeleton.tsx b/components/items/Section2Skeleton.tsx new file mode 100644 index 000000000..8e6abe4d8 --- /dev/null +++ b/components/items/Section2Skeleton.tsx @@ -0,0 +1,28 @@ +import styles from "./Skeleton.module.css"; + +const repeat = [ + { id: 1 }, + { id: 2 }, + { id: 3 }, + { id: 4 }, + { id: 5 }, + { id: 6 }, + { id: 7 }, + { id: 8 }, + { id: 9 }, + { id: 10 }, +]; + +export default function Section2Skeleton() { + return ( +
    + {repeat.map((item) => { + return ( +
  • +
    +
  • + ); + })} +
+ ); +} diff --git a/components/items/Skeleton.module.css b/components/items/Skeleton.module.css new file mode 100644 index 000000000..97d3a3809 --- /dev/null +++ b/components/items/Skeleton.module.css @@ -0,0 +1,119 @@ +/* 공통 */ +.skeletonBox { + position: relative; + flex: 1; + max-width: 25%; + height: max-content; + border-radius: 10px; + border: 1px solid var(--gray-50); + box-sizing: border-box; + overflow: hidden; + background: var(--gray-200); + + @media (max-width: 1200px) { + max-width: 100%; + } + + /* Mobile */ + @media (max-width: 768px) { + max-width: 100%; + } +} +.skeletonBox:after { + content: ""; + display: block; + padding-bottom: 100%; +} +.skeletonLoading { + position: absolute; + width: 100%; + height: 100%; + background: linear-gradient( + 120deg, + #e5e5e5 30%, + #f0f0f0 38%, + #f0f0f0 40%, + #e5e5e5 48% + ); + border-radius: 1rem; + background-size: 200% 100%; + background-position: 100% 0; + animation: load 0.8s infinite; +} + +@keyframes load { + 100% { + background-position: -100% 0; + } +} + +/* Srction1 */ +.section1Wrap { + -webkit-dispaly: flex; + display: flex; + gap: 40px 2.5%; + flex-wrap: wrap; + + @media (max-width: 1200px) { + gap: 40px 5%; + } +} +.section1Wrap .skeletonBox:nth-of-type(3), +.section1Wrap .skeletonBox:nth-of-type(4) { + @media (max-width: 1200px) { + display: none; + } +} +.section1Wrap .skeletonBox:nth-of-type(2), +.section1Wrap .skeletonBox:nth-of-type(3), +.section1Wrap .skeletonBox:nth-of-type(4) { + @media (max-width: 768px) { + display: none; + } +} + +/* Srction2 */ +.section2Wrap { + -webkit-dispaly: flex; + display: flex; + gap: 40px 2.5%; + flex-wrap: wrap; + + @media (max-width: 1200px) { + gap: 40px 5%; + } + @media (max-width: 768px) { + gap: 40px 4%; + } +} +.section2Wrap .skeletonBox { + max-width: 18%; + min-width: 18%; + + @media (max-width: 1200px) { + min-width: 30%; + max-width: 30%; + } + @media (max-width: 768px) { + min-width: 48%; + max-width: 48%; + } +} +.section2Wrap .skeletonBox:nth-of-type(7), +.section2Wrap .skeletonBox:nth-of-type(8), +.section2Wrap .skeletonBox:nth-of-type(9), +.section2Wrap .skeletonBox:nth-of-type(10) { + @media (max-width: 1200px) { + display: none; + } +} +.section2Wrap .skeletonBox:nth-of-type(5), +.section2Wrap .skeletonBox:nth-of-type(6), +.section2Wrap .skeletonBox:nth-of-type(7), +.section2Wrap .skeletonBox:nth-of-type(8), +.section2Wrap .skeletonBox:nth-of-type(9), +.section2Wrap .skeletonBox:nth-of-type(10) { + @media (max-width: 768px) { + display: none; + } +} diff --git a/components/items/additem/ChooseImgFile.tsx b/components/items/additem/ChooseImgFile.tsx new file mode 100644 index 000000000..cddd7204b --- /dev/null +++ b/components/items/additem/ChooseImgFile.tsx @@ -0,0 +1,83 @@ +import { ChangeEvent, MouseEvent, useEffect, useRef, useState } from "react"; +import styles from "../../../styles/items/additem.module.css"; +import UploadImg from "@/public/assets/images/items/upload.svg"; +import ImgPreview from "./ImgPreview"; +import { ImgFileProps } from "./types"; + +// 상품 이미지 등록 +function ChooseImgFile({ imgFile, setImgFile }: ImgFileProps) { + const [preview, setPreview] = useState(""); + const [isImgChk, setIsImgChk] = useState(false); + const inputRef = useRef(null); + + const handleChange = (e: ChangeEvent) => { + const nextValue = (e?.target as HTMLInputElement).files![0]; + setImgFile(nextValue); + // 같은 파일을 재업로드 할 경우 event가 trigger되지 않는 버그 방지 + e.target.value = ""; + }; + + const handlePreventionClick = (e: MouseEvent) => { + if (imgFile) { + e.preventDefault(); + setIsImgChk(true); + } else { + setIsImgChk(false); + } + }; + + const handleDeleteClick = () => { + setIsImgChk(false); + setImgFile(null); + setPreview(""); + }; + + useEffect(() => { + if (!imgFile) return; + const nextPreview = URL.createObjectURL(imgFile); + setPreview(nextPreview); + }, [imgFile]); + + return ( +
+

상품 이미지

+
+
+ + +
+ {preview && ( +
+ +
+ )} +
+ {isImgChk ? ( +

+ *이미지 등록은 최대 1개까지 가능합니다. +

+ ) : ( + "" + )} +
+ ); +} + +export default ChooseImgFile; diff --git a/components/items/additem/ImgPreview.tsx b/components/items/additem/ImgPreview.tsx new file mode 100644 index 000000000..1f41893fb --- /dev/null +++ b/components/items/additem/ImgPreview.tsx @@ -0,0 +1,17 @@ +import styles from "../../../styles/items/additem.module.css"; +import DeleteBtnImg from "@/public/assets/images/items/cancel.svg"; +import { PreviewProps } from "./types"; +import Image from "next/image"; + +function ImgPreview({ preview, handleDeleteClick }: PreviewProps) { + return ( + <> + 상품 이미지 미리보기 +
+ +
+ + ); +} + +export default ImgPreview; diff --git a/components/items/additem/InputContainer.tsx b/components/items/additem/InputContainer.tsx new file mode 100644 index 000000000..e7940abf0 --- /dev/null +++ b/components/items/additem/InputContainer.tsx @@ -0,0 +1,165 @@ +import { ChangeEvent, KeyboardEvent, useEffect, useRef, useState } from "react"; +// import { NumericFormat } from "react-number-format"; +import styles from "../../../styles/items/additem.module.css"; +import DeleteBtnImg from "@/public/assets/images/items/cancel.svg"; +import { InitialValues } from "./types"; + +// 상품 정보 등록 +function InputContainer({ setValues }: InitialValues) { + const [tag, setTag] = useState(""); + const [tagList, setTagList] = useState([]); + const [enteredNum, setEnterdNum] = useState(""); + const [price, setPrice] = useState(0); + const priceInput = useRef(null); + + useEffect(() => { + if (typeof enteredNum === "string") { + const numberPrice = enteredNum.replace(/,/g, ""); + setPrice(Number(numberPrice)); + } + if (priceInput.current) + if (priceInput.current.value === "") { + setPrice(0); + } + + setValues((prevValue) => ({ + ...prevValue, + ["price"]: price, + })); + }, [enteredNum, price, setValues]); + + const handleChange = (name: string, value: string | number | string[]) => { + setValues((prevValue) => ({ + ...prevValue, + [name]: value, + })); + }; + + const handleInputChange = ( + e: ChangeEvent + ) => { + let { name, value }: { name: string; value: string | number | string[] } = + e.target; + handleChange(name, value); + }; + + const changeEnteredNum = (e: ChangeEvent) => { + const value: string = e.target.value; + const removedCommaValue = Number(value.replaceAll(",", "")); + setEnterdNum(removedCommaValue.toLocaleString()); + + if (isNaN(removedCommaValue) || removedCommaValue === 0) { + setEnterdNum(""); + } + }; + + // onKeyDown 이벤트 키가 Enter와 일치하면 실행 + const activeEnter = (e: KeyboardEvent) => { + const regExp = /[ \{\}\[\]\/?.,;:|\)*~`!^\+┼<>@\#$%&\'\"\\\(\=]/gi; + // onKeyDown 이벤트의 한글 입력 시 이벤트가 두 번 호출 되는 버그 방지 + if (e.nativeEvent.isComposing) { + return; + } + // 특수문자 및 스페이스바 입력 방지 + if (regExp.test(e.key)) { + e.preventDefault(); + } + if (e.key === "Enter") { + if ((e.target as HTMLInputElement).value === "") { + // 빈 칸 엔터 방지 + e.preventDefault(); + } else { + handleChange("tag", tagList); + tagList.push(tag); + (e.target as HTMLInputElement).value = ""; + } + } + }; + + const handleAddValue = (e: ChangeEvent) => { + setTag(e.target.value); + }; + + // 클릭한 태그 삭제 + const handleDeleteClick = (i: number) => { + tagList.splice(i, 1); + handleChange("tag", tagList); + }; + + return ( + <> +
+

상품명

+ +
+
+

상품 소개

+