diff --git a/README.md b/README.md index 7059a962..ff04d6a7 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,182 @@ -# React + Vite -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +``` +16-Sprint-Mission +├─ eslint.config.js +├─ index.html +├─ package-lock.json +├─ package.json +├─ public +│ ├─ images +│ │ ├─ icon_google.png +│ │ ├─ icon_kakao.png +│ │ ├─ icon_password_invisible.png +│ │ ├─ icon_password_visible.png +│ │ ├─ icon_profile.png +│ │ ├─ ic_arrow_down.png +│ │ ├─ ic_back.png +│ │ ├─ ic_facebook.png +│ │ ├─ ic_instagram.png +│ │ ├─ ic_kebab.png +│ │ ├─ ic_nextPageClick_active.png +│ │ ├─ ic_nextPageClick_inactive.png +│ │ ├─ ic_plus.png +│ │ ├─ ic_prevPageClick_active.png +│ │ ├─ ic_prevPageClick_inactive.png +│ │ ├─ ic_search.png +│ │ ├─ ic_sort.png +│ │ ├─ ic_twitter.png +│ │ ├─ ic_X.png +│ │ ├─ ic_youtube.png +│ │ ├─ img_comment_none.png +│ │ ├─ img_favorite_inactive.png +│ │ ├─ Img_home_01 +│ │ │ ├─ Img_home_01@0.5x.png +│ │ │ ├─ Img_home_01@1.5x.png +│ │ │ ├─ Img_home_01@1x.png +│ │ │ └─ Img_home_01@2x.png +│ │ ├─ Img_home_02 +│ │ │ ├─ Img_home_02@0.5x.png +│ │ │ ├─ Img_home_02@1.5x.png +│ │ │ ├─ Img_home_02@1x.png +│ │ │ └─ Img_home_02@2x.png +│ │ ├─ Img_home_03 +│ │ │ ├─ Img_home_03@0.5x.png +│ │ │ ├─ Img_home_03@1.5x.png +│ │ │ ├─ Img_home_03@1x.png +│ │ │ └─ Img_home_03@2x.png +│ │ ├─ Img_home_bottom +│ │ │ ├─ Img_home_bottom@0.5x.png +│ │ │ ├─ Img_home_bottom@1.5x.png +│ │ │ ├─ Img_home_bottom@1x.png +│ │ │ └─ Img_home_bottom@2x.png +│ │ ├─ Img_home_top +│ │ │ ├─ Img_home_top@0.5x.png +│ │ │ ├─ Img_home_top@1.5x.png +│ │ │ ├─ Img_home_top@1x.png +│ │ │ └─ Img_home_top@2x.png +│ │ ├─ img_items_default_md.png +│ │ ├─ Img_logo.png +│ │ └─ Img_openGraph.png +│ └─ _redirects +├─ README.md +├─ src +│ ├─ App.jsx +│ ├─ common.css +│ ├─ components +│ │ ├─ comments +│ │ │ ├─ CommentCard.jsx +│ │ │ ├─ CommentCard.module.css +│ │ │ ├─ CommentEditForm.jsx +│ │ │ ├─ CommentEditForm.module.css +│ │ │ ├─ CommentRequireForm.jsx +│ │ │ ├─ CommentRequireForm.module.css +│ │ │ ├─ CommentsContainer.jsx +│ │ │ ├─ CommentsContainer.module.css +│ │ │ ├─ CommentView.jsx +│ │ │ └─ CommentView.module.css +│ │ ├─ common +│ │ │ ├─ AuthField +│ │ │ │ ├─ AuthField.jsx +│ │ │ │ └─ AuthField.module.css +│ │ │ ├─ Button +│ │ │ ├─ ItemCard +│ │ │ │ ├─ ItemCard.jsx +│ │ │ │ └─ ItemCard.module.css +│ │ │ ├─ ItemCardSkeleton +│ │ │ │ ├─ ItemCardSkeleton.jsx +│ │ │ │ └─ ItemCardSkeleton.module.css +│ │ │ ├─ ItemsContainer +│ │ │ │ ├─ ItemsContainer.jsx +│ │ │ │ └─ ItemsContainer.module.css +│ │ │ ├─ KebabMenu +│ │ │ │ ├─ KebabMenu.jsx +│ │ │ │ └─ KebabMenu.module.css +│ │ │ ├─ Pagination +│ │ │ │ ├─ Pagination.jsx +│ │ │ │ ├─ Pagination.module.css +│ │ │ │ ├─ PaginationButton.jsx +│ │ │ │ └─ PaginationButton.module.css +│ │ │ ├─ PaginationButton +│ │ │ ├─ SearchInput +│ │ │ │ ├─ SearchInput.jsx +│ │ │ │ └─ SearchInput.module.css +│ │ │ └─ SelectDropdown +│ │ │ ├─ SelectDropdown.jsx +│ │ │ └─ SelectDropdown.module.css +│ │ └─ layout +│ │ ├─ LogoHeader +│ │ │ ├─ LogoHeader.jsx +│ │ │ └─ LogoHeader.module.css +│ │ ├─ Nav +│ │ │ ├─ Nav.jsx +│ │ │ └─ Nav.module.css +│ │ └─ profileCard +│ │ ├─ ProfileCard.jsx +│ │ └─ ProfileCard.module.css +│ ├─ constants +│ ├─ contexts +│ │ └─ LoginContext.jsx +│ ├─ fonts +│ │ └─ rokafsansmedium-normal.woff +│ ├─ hooks +│ │ ├─ useAsync.jsx +│ │ ├─ useFormFields.jsx +│ │ ├─ usePageSizeByBreakPoint.jsx +│ │ ├─ usePaginationByOffset.jsx +│ │ ├─ useScreenBreakpoint.jsx +│ │ └─ useSearchQueryString.jsx +│ ├─ main.jsx +│ ├─ pages +│ │ ├─ AddItemPage +│ │ │ ├─ AddItemPage.jsx +│ │ │ └─ AddItemPage.module.css +│ │ ├─ AuthPage +│ │ │ ├─ fieldsConfig.js +│ │ │ ├─ FormAuth.css +│ │ │ ├─ LoginPage.jsx +│ │ │ ├─ sections +│ │ │ │ ├─ SocialLogin.jsx +│ │ │ │ └─ SocialLogin.module.css +│ │ │ └─ SignupPage.jsx +│ │ ├─ BoardPage +│ │ │ └─ BoardPage.jsx +│ │ ├─ FaqPage +│ │ │ └─ FaqPage.jsx +│ │ ├─ HomePage +│ │ │ ├─ Banner.css +│ │ │ ├─ BannerBottom.css +│ │ │ ├─ Card.css +│ │ │ ├─ Cards.css +│ │ │ ├─ Footer.css +│ │ │ ├─ Home.css +│ │ │ ├─ HomePage.jsx +│ │ │ └─ Main.css +│ │ ├─ ItemDetailsPage +│ │ │ ├─ ItemDetailsPage.jsx +│ │ │ ├─ ItemDetailsPage.module.css +│ │ │ └─ sections +│ │ │ ├─ ItemComments.jsx +│ │ │ ├─ ItemComments.module.css +│ │ │ ├─ ItemDetailsSection.jsx +│ │ │ └─ ItemDetailsSection.module.css +│ │ ├─ ItemsPage +│ │ │ ├─ ItemsPage.css +│ │ │ ├─ ItemsPage.jsx +│ │ │ └─ sections +│ │ │ ├─ BestItemsSection.jsx +│ │ │ ├─ CurrentItemsSection +│ │ │ │ ├─ ItemsSearchHeader.jsx +│ │ │ │ └─ ItemsSearchHeader.module.css +│ │ │ ├─ CurrentItemsSection.jsx +│ │ │ └─ ItemsSection.module.css +│ │ └─ PrivacyPage +│ │ └─ PrivacyPage.jsx +│ ├─ reset.css +│ └─ utils +│ ├─ api.js +│ ├─ debounce.js +│ ├─ formatPrice.js +│ └─ validators.js +└─ vite.config.js -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. +``` \ No newline at end of file diff --git a/public/images/ic_back.png b/public/images/ic_back.png new file mode 100644 index 00000000..6671f152 Binary files /dev/null and b/public/images/ic_back.png differ diff --git a/public/images/ic_kebab.png b/public/images/ic_kebab.png new file mode 100644 index 00000000..b390f973 Binary files /dev/null and b/public/images/ic_kebab.png differ diff --git a/public/images/img_comment_none.png b/public/images/img_comment_none.png new file mode 100644 index 00000000..115ee43c Binary files /dev/null and b/public/images/img_comment_none.png differ diff --git a/src/App.jsx b/src/App.jsx index 6b383f85..11120814 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,5 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; -import { LoginStateProvider } from "./contexts/LoginStateContext"; +import { LoginProvider } from "./contexts/LoginContext"; import HomePage from "./pages/HomePage/HomePage"; import LoginPage from "./pages/AuthPage/LoginPage"; import SignupPage from "./pages/AuthPage/SignupPage"; @@ -8,15 +8,19 @@ import AddItemPage from "./pages/AddItemPage/AddItemPage"; import PrivacyPage from "./pages/PrivacyPage/PrivacyPage"; import FaqPage from "./pages/FaqPage/FaqPage"; import BoardPage from "./pages/BoardPage/BoardPage"; +import ItemDetailsPage from "./pages/ItemDetailsPage/ItemDetailsPage"; function App() { return ( - + } /> - } /> + + } /> + } /> + } /> } /> } /> @@ -25,7 +29,7 @@ function App() { } /> } /> - + ); } diff --git a/src/common.css b/src/common.css index 7971659d..788eaa17 100644 --- a/src/common.css +++ b/src/common.css @@ -34,6 +34,7 @@ body { font-weight: 600; font-family: 'Pretendard'; border: none; + cursor: pointer; } .button-style:hover { @@ -42,6 +43,7 @@ body { .button-style:disabled { background-color: var(--color-gray400); + cursor: auto; } @font-face { diff --git a/src/components/BestItemsSection.jsx b/src/components/BestItemsSection.jsx deleted file mode 100644 index 7e4665d1..00000000 --- a/src/components/BestItemsSection.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useEffect, useState } from "react"; -import { getItems } from "../utils/api"; -import ItemsContainer from "./ItemsContainer"; -import styles from "./ItemsSection.module.css"; - -const LIST_TYPE = "best"; - -const BestItemsSection = ({ pageSize }) => { - const [bestItemList, setBestItemList] = useState([]); - - const loadBestItemList = async (options) => { - const result = await getItems(options); - if (!result) return; - const { list } = result; - setBestItemList(list); - }; - - useEffect(() => { - if (!pageSize) return; - (async () => { - await loadBestItemList({ - offset: 1, - pageSize: pageSize, - orderBy: "favorite", - keyword: "", - }); - })(); - }, [pageSize]); - - return ( -
-
-

베스트 상품

-
- -
- ); -}; - -export default BestItemsSection; diff --git a/src/components/CurrentItemsSection.jsx b/src/components/CurrentItemsSection.jsx deleted file mode 100644 index 20f5179d..00000000 --- a/src/components/CurrentItemsSection.jsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useEffect, useState } from "react"; -import { getItems } from "../utils/api"; -import ItemsContainer from "./ItemsContainer"; -import styles from "./ItemsSection.module.css"; -import { usePaginationByOffset } from "../hooks/usePaginationByOffset"; -import { useNavigate, useSearchParams } from "react-router-dom"; -import Pagination from "./Pagination"; -import { useIsLogin } from "../contexts/LoginStateContext"; - -const LIST_TYPE = "current"; -const VISIBLE_PAGE_LENGTH = 5; - -const CurrentItemsSection = ({ pageSize }) => { - //prettier-ignore - const isLogin = useIsLogin(); - - const [searchParams, setSearchParams] = useSearchParams(); - const initKeyword = searchParams.get("keyword") || ""; - const [keyword, setKeyword] = useState(initKeyword); - - const [offset, setOffset] = useState(1); - const [totalDataCount, setTotalDataCount] = useState(1); - const [order, setOrder] = useState("recent"); - - const [currentItemList, setCurrentItemList] = useState([]); - - const { totalPagesCount, currentPageNumber, visiblePageNumbers } = - usePaginationByOffset( - offset, - pageSize, - totalDataCount, - VISIBLE_PAGE_LENGTH - ); - - const onCreateNewItemNavigate = useNavigate(); - - const handleSearchOrderChange = (e) => { - setOffset(1); - setOrder(e.target.value); - }; - - const loadCurrentItemList = async (option) => { - const result = await getItems(option); - if (!result) return; - const { list, totalCount } = result; - setCurrentItemList(list); - setTotalDataCount(totalCount); - }; - - const handleSearchInputChange = (e) => setKeyword(e.target.value); - const handleSearchInputEnterPress = (e) => { - if (e.key === "Enter") { - setOffset(1); - setSearchParams(keyword ? { keyword } : {}); - } - }; - - const handleCreateNewItemClick = (e) => { - e.preventDefault(); - onCreateNewItemNavigate("/additem"); - }; - - //prettier-ignore - const handlePageNumberClick = (e) => onPaginationButtonClick(Number(e.target.value)); - const handlePagePrev = () => onPaginationButtonClick(visiblePageNumbers[0] - 1); //prettier-ignore - const handlePageNext = () => onPaginationButtonClick(visiblePageNumbers[visiblePageNumbers.length - 1] + 1); //prettier-ignore - const onPaginationButtonClick = (nextPageNumber) => setOffset((nextPageNumber - 1) * pageSize + 1); //prettier-ignore - - const prevPageEnable = visiblePageNumbers[0] > 1; - const nextPageEnable = - visiblePageNumbers[visiblePageNumbers.length - 1] < totalPagesCount; - - const handlers = { - handlePageNumberClick, - handlePagePrev, - handlePageNext, - }; - - const pageControlEnabled = { - prevPageEnable, - nextPageEnable, - }; - - useEffect(() => { - if (!pageSize) return; - (async () => { - await loadCurrentItemList({ - offset: offset, - pageSize: pageSize, - orderBy: order, - keyword: initKeyword, - }); - })(); - }, [pageSize, order, offset, initKeyword]); - - return ( - <> -
-
-

전체 상품

-
- - -
- {isLogin && ( - - )} - -
- - -
- - ); -}; - -export default CurrentItemsSection; diff --git a/src/components/ItemCard.jsx b/src/components/ItemCard.jsx deleted file mode 100644 index 4242c9b8..00000000 --- a/src/components/ItemCard.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useState } from 'react'; -import { formatPriceKRW } from '../utils/formatPrice'; -import styles from './ItemCard.module.css'; - -const IMAGE_DEFAULT_URL = './images/img_items_default_md.png'; - -const ItemCard = ({ id, imageUrl, name, price, favoriteCount }) => { - const [isImageValid, setIsImageValid] = useState(true); - - const imgSrc = isImageValid && imageUrl ? imageUrl : IMAGE_DEFAULT_URL; - - return ( -
- setIsImageValid(false)} - alt={name} - width={282} - /> -
-

{name}

-

{formatPriceKRW(price)}

-
- -

{favoriteCount}

-
-
-
- ); -}; - -export default ItemCard; diff --git a/src/components/ItemDetails/ItemDescription.jsx b/src/components/ItemDetails/ItemDescription.jsx new file mode 100644 index 00000000..5a78b1f2 --- /dev/null +++ b/src/components/ItemDetails/ItemDescription.jsx @@ -0,0 +1,26 @@ +import styles from "./ItemDescription.module.css"; + +const ItemDescription = ({ description, tags = [] }) => { + return ( +
+
+

상품 소개

+ {description} +
+
+

상품 태그

+
+ {tags.map((tag) => { + return ( +
+ {`#${tag}`} +
+ ); + })} +
+
+
+ ); +}; + +export default ItemDescription; diff --git a/src/components/ItemDetails/ItemDescription.module.css b/src/components/ItemDetails/ItemDescription.module.css new file mode 100644 index 00000000..cffe8920 --- /dev/null +++ b/src/components/ItemDetails/ItemDescription.module.css @@ -0,0 +1,50 @@ + +.description-container { + display: flex; + flex-direction: column; + gap: 24px; + margin-bottom: var(--description-margin-bottom); +} + +.subtitle-container { + display: flex; + flex-direction: column; + gap: var(--subtitle-gap); +} + +.subtitle { + font-weight: 600; + font-size: 16px; + line-height: 26px; +} + +.description { + font-weight: 400; + font-size: 16px; + line-height: 26px; +} + +.tag-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 8px; +} + +.tag { + border-radius: 26px; + padding: 5px 16px; + background-color: var(--color-gray200); + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + font-weight: 400; + font-size: 16px; + line-height: 26px; + cursor: pointer; +} + +.tag:hover { + background-color: var(--color-gray300); +} diff --git a/src/components/ItemDetails/ItemMetaData.jsx b/src/components/ItemDetails/ItemMetaData.jsx new file mode 100644 index 00000000..1d08bbe0 --- /dev/null +++ b/src/components/ItemDetails/ItemMetaData.jsx @@ -0,0 +1,15 @@ +import styles from "./ItemMetaData.module.css"; +import ProfileCard from "../layout/ProfileCard/ProfileCard"; +import FavoriteButton from "../common/FavoriteButton/FavoriteButton"; + +const ItemMetaData = ({ ownerNickname, updatedAt, favoriteCount }) => { + return ( +
+ +
+ +
+ ); +}; + +export default ItemMetaData; diff --git a/src/components/ItemDetails/ItemMetaData.module.css b/src/components/ItemDetails/ItemMetaData.module.css new file mode 100644 index 00000000..ed1a38c4 --- /dev/null +++ b/src/components/ItemDetails/ItemMetaData.module.css @@ -0,0 +1,27 @@ +.metadata-container { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.nickname { + font-weight: 500; + font-size: 14px; + line-height: 24px; + color: var(--color-gray600); +} + +.updatedAt { + font-weight: 400; + font-size: 14px; + line-height: 24px; + color: var(--color-gray400); +} + +.vertical-line-box { + flex-grow: 1; + height: 34px; + border-right: 1px solid var(--color-gray300); +} \ No newline at end of file diff --git a/src/components/ItemDetails/ItemTitleHeader.jsx b/src/components/ItemDetails/ItemTitleHeader.jsx new file mode 100644 index 00000000..60e930da --- /dev/null +++ b/src/components/ItemDetails/ItemTitleHeader.jsx @@ -0,0 +1,16 @@ +import KebabMenu from "../common/KebabMenu/KebabMenu"; +import styles from "./ItemTitleHeader.module.css"; + +const ItemTitleHeader = ({ name, price, id, menuItems }) => { + return ( +
+
+

{name}

+ {price} +
+ +
+ ); +}; + +export default ItemTitleHeader; diff --git a/src/components/ItemDetails/ItemTitleHeader.module.css b/src/components/ItemDetails/ItemTitleHeader.module.css new file mode 100644 index 00000000..a026d0e0 --- /dev/null +++ b/src/components/ItemDetails/ItemTitleHeader.module.css @@ -0,0 +1,30 @@ +.header-container { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items:start; + padding-bottom: 16px; + border-bottom: 1px solid var(--color-gray300); + margin-bottom: 24px; +} + +.title-container { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; +} + +.title { + font-weight: 600; + font-size: 24px; + line-height: 32px; + color: var(--color-gray800); +} + +.price { + font-weight: 600; + font-size: 40px; + line-height: 100%; + color: var(--color-gray800); +} \ No newline at end of file diff --git a/src/components/LogoHeader.jsx b/src/components/LogoHeader.jsx deleted file mode 100644 index 725a9bf2..00000000 --- a/src/components/LogoHeader.jsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Link } from 'react-router-dom'; -import styles from './LogoHeader.module.css'; - -const LogoHeader = () => { - return ( - - -

판다마켓

- - ); -}; - -export default LogoHeader; diff --git a/src/components/Pagination.jsx b/src/components/Pagination.jsx deleted file mode 100644 index 2bb2a4c8..00000000 --- a/src/components/Pagination.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import styles from './Pagination.module.css'; - -const Pagination = ({ - visiblePageNumbers, - currentPageNumber, - handlers, - pageControlEnabled, -}) => { - const { handlePageNumberClick, handlePagePrev, handlePageNext } = handlers; - const { prevPageEnable, nextPageEnable } = pageControlEnabled; - - return ( - - ); -}; - -export default Pagination; diff --git a/src/components/comments/CommentCard.jsx b/src/components/comments/CommentCard.jsx new file mode 100644 index 00000000..375f8904 --- /dev/null +++ b/src/components/comments/CommentCard.jsx @@ -0,0 +1,61 @@ +import { useState } from "react"; +import styles from "./CommentCard.module.css"; +import CommentEditForm from "./CommentEditForm"; +import CommentView from "./CommentView"; + +const CommentCard = ({ comment }) => { + const [isEditing, setIsEditing] = useState(false); + const [zIndex, setZIndex] = useState(0); + const handleEditClick = (e) => { + e.preventDefault(); + setIsEditing(true); + }; + + const handleDeleteClick = (e) => { + e.preventDefault(); + }; + + const handleSubmitEdit = (e) => { + e.preventDefault(); + setIsEditing(false); + }; + + const handleCancelEdit = (e) => { + e.preventDefault(); + setIsEditing(false); + }; + + const handleKebabOpen = () => { + setZIndex(1); + }; + + const handleKebabClose = () => { + setZIndex(0); + }; + + const containerStyle = { + zIndex: zIndex, + }; + + return ( +
+ {isEditing ? ( + + ) : ( + + )} +
+ ); +}; + +export default CommentCard; diff --git a/src/components/comments/CommentCard.module.css b/src/components/comments/CommentCard.module.css new file mode 100644 index 00000000..e69de29b diff --git a/src/components/comments/CommentEditForm.jsx b/src/components/comments/CommentEditForm.jsx new file mode 100644 index 00000000..e0e150e9 --- /dev/null +++ b/src/components/comments/CommentEditForm.jsx @@ -0,0 +1,48 @@ +import styles from "./CommentEditForm.module.css"; +import { useState } from "react"; +import ProfileCard from "../layout/ProfileCard/ProfileCard"; + +const INQUIRE_PLACEHOLDER = + "개인정보를 공유 및 요청하거나, 명예 훼손, 무단 광고, 불법 정보 유포시 모니터링 후 삭제될 수 있으며, 이에 대한 민형사상 책임은 게시자에게 있습니다."; + +const CommentEditForm = ({ comment, onSubmit, onCancel }) => { + const [inquireText, setInquireText] = useState(comment?.content); + + const handleInquireTextChange = (e) => { + setInquireText(e.target.value); + }; + + return ( +
+