diff --git a/package-lock.json b/package-lock.json index a1e590ee..5958b171 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@testing-library/user-event": "^13.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.30.0", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" } @@ -3241,6 +3242,14 @@ } } }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -14671,6 +14680,36 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.30.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz", + "integrity": "sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.0.tgz", + "integrity": "sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", diff --git a/package.json b/package.json index 7ff0d6b5..3e269e4a 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@testing-library/user-event": "^13.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.30.0", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" }, diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index a11777cc..00000000 Binary files a/public/favicon.ico and /dev/null differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 00000000..0cdcf232 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/index.html b/public/index.html index aa069f27..69556b30 100644 --- a/public/index.html +++ b/public/index.html @@ -2,14 +2,10 @@ - + - - + - React App + 판다마켓 diff --git a/public/logo192.png b/public/logo192.png deleted file mode 100644 index fc44b0a3..00000000 Binary files a/public/logo192.png and /dev/null differ diff --git a/public/logo512.png b/public/logo512.png deleted file mode 100644 index a4e47a65..00000000 Binary files a/public/logo512.png and /dev/null differ diff --git a/public/manifest.json b/public/manifest.json index 080d6c77..1f2f141f 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -6,16 +6,6 @@ "src": "favicon.ico", "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" - }, - { - "src": "logo192.png", - "type": "image/png", - "sizes": "192x192" - }, - { - "src": "logo512.png", - "type": "image/png", - "sizes": "512x512" } ], "start_url": ".", diff --git a/src/App.css b/src/App.css deleted file mode 100644 index 74b5e053..00000000 --- a/src/App.css +++ /dev/null @@ -1,38 +0,0 @@ -.App { - text-align: center; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/src/App.js b/src/App.js deleted file mode 100644 index 37845757..00000000 --- a/src/App.js +++ /dev/null @@ -1,25 +0,0 @@ -import logo from './logo.svg'; -import './App.css'; - -function App() { - return ( -
-
- logo -

- Edit src/App.js and save to reload. -

- - Learn React - -
-
- ); -} - -export default App; diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 00000000..50530f7a --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,13 @@ +import { Outlet } from "react-router-dom"; +import Header from "./components/Header"; + +function App() { + return ( + <> +
+ + + ); +} + +export default App; diff --git a/src/App.test.js b/src/App.test.js deleted file mode 100644 index 1f03afee..00000000 --- a/src/App.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/src/Main.jsx b/src/Main.jsx new file mode 100644 index 00000000..016c4b2f --- /dev/null +++ b/src/Main.jsx @@ -0,0 +1,22 @@ +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import App from "./App"; +import ItemsPage from "./pages/ItemsPage"; +import BoardPage from "./pages/BoardPage"; +import AddItemPage from "./pages/AddItemPage"; + +const Main = () => { + return ( + + + }> + } /> + } /> + } /> + } /> + + + + ); +}; + +export default Main; diff --git a/src/assets/fonts/Pretendard-Bold.woff2 b/src/assets/fonts/Pretendard-Bold.woff2 new file mode 100644 index 00000000..1c6457ad Binary files /dev/null and b/src/assets/fonts/Pretendard-Bold.woff2 differ diff --git a/src/assets/fonts/Pretendard-Medium.woff2 b/src/assets/fonts/Pretendard-Medium.woff2 new file mode 100644 index 00000000..e03e9325 Binary files /dev/null and b/src/assets/fonts/Pretendard-Medium.woff2 differ diff --git a/src/assets/fonts/Pretendard-Regular.woff2 b/src/assets/fonts/Pretendard-Regular.woff2 new file mode 100644 index 00000000..5f582a86 Binary files /dev/null and b/src/assets/fonts/Pretendard-Regular.woff2 differ diff --git a/src/assets/images/ic-like.svg b/src/assets/images/ic-like.svg new file mode 100644 index 00000000..576d3a17 --- /dev/null +++ b/src/assets/images/ic-like.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/ic-search.svg b/src/assets/images/ic-search.svg new file mode 100644 index 00000000..d9cc31ea --- /dev/null +++ b/src/assets/images/ic-search.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/ic_arrow_down.png b/src/assets/images/ic_arrow_down.png new file mode 100644 index 00000000..7a907603 Binary files /dev/null and b/src/assets/images/ic_arrow_down.png differ diff --git a/src/assets/images/ic_arrow_sm_left.svg b/src/assets/images/ic_arrow_sm_left.svg new file mode 100644 index 00000000..6b3286f2 --- /dev/null +++ b/src/assets/images/ic_arrow_sm_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/ic_arrow_sm_right.svg b/src/assets/images/ic_arrow_sm_right.svg new file mode 100644 index 00000000..4261e52d --- /dev/null +++ b/src/assets/images/ic_arrow_sm_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/ic_sort.svg b/src/assets/images/ic_sort.svg new file mode 100644 index 00000000..657b44f9 --- /dev/null +++ b/src/assets/images/ic_sort.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/images/ic_visibility_off.svg b/src/assets/images/ic_visibility_off.svg new file mode 100644 index 00000000..a32d564d --- /dev/null +++ b/src/assets/images/ic_visibility_off.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/images/ic_visibility_on.svg b/src/assets/images/ic_visibility_on.svg new file mode 100644 index 00000000..7d615b19 --- /dev/null +++ b/src/assets/images/ic_visibility_on.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/img-avatar.png b/src/assets/images/img-avatar.png new file mode 100644 index 00000000..0844dd1d Binary files /dev/null and b/src/assets/images/img-avatar.png differ diff --git a/src/assets/images/logo-panda.svg b/src/assets/images/logo-panda.svg new file mode 100644 index 00000000..d1f6f15d --- /dev/null +++ b/src/assets/images/logo-panda.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/assets/images/logo-txt.svg b/src/assets/images/logo-txt.svg new file mode 100644 index 00000000..764bbf43 --- /dev/null +++ b/src/assets/images/logo-txt.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/Avatar/Avatar.jsx b/src/components/Avatar/Avatar.jsx new file mode 100644 index 00000000..91a7ea9a --- /dev/null +++ b/src/components/Avatar/Avatar.jsx @@ -0,0 +1,11 @@ +import styles from "./Avatar.module.css"; + +const Avatar = ({ imgSrc, onClick, className }) => { + return ( + + ); +}; + +export default Avatar; diff --git a/src/components/Avatar/Avatar.module.css b/src/components/Avatar/Avatar.module.css new file mode 100644 index 00000000..b3f92e4b --- /dev/null +++ b/src/components/Avatar/Avatar.module.css @@ -0,0 +1,4 @@ +.avatar { + width: 40px; + height: 40px; +} diff --git a/src/components/Avatar/index.js b/src/components/Avatar/index.js new file mode 100644 index 00000000..279f4682 --- /dev/null +++ b/src/components/Avatar/index.js @@ -0,0 +1 @@ +export { default } from "./Avatar"; diff --git a/src/components/Header/Header.jsx b/src/components/Header/Header.jsx new file mode 100644 index 00000000..e31e645a --- /dev/null +++ b/src/components/Header/Header.jsx @@ -0,0 +1,39 @@ +import styles from "./Header.module.css"; +import logoPandaImg from "../../assets/images/logo-panda.svg"; +import logoTxtImg from "../../assets/images/logo-txt.svg"; +import avatarImg from "../../assets/images/img-avatar.png"; +import Nav from "../Nav"; +import Avatar from "../Avatar"; + +const Header = () => { + const handleAvatarClick = () => { + console.log("clicked user avatar"); + }; + + return ( +
+
+
+ 판다마켓 로고 이미지 + 판다마켓 로고 텍스트 +
+
+
+ ); +}; + +export default Header; diff --git a/src/components/Header/Header.module.css b/src/components/Header/Header.module.css new file mode 100644 index 00000000..25c581e4 --- /dev/null +++ b/src/components/Header/Header.module.css @@ -0,0 +1,58 @@ +.header { + position: sticky; + left: 0; + top: 0; + width: 100%; + height: 70px; + padding: 0 var(--header-padding-lr); + z-index: 9; + background: #fff; + display: flex; + border-bottom: 1px solid #dfdfdf; +} + +.header-container { + display: flex; + align-items: center; + width: 100%; + padding: 0.714rem 0; +} + +.logo { + display: flex; + align-items: center; + gap: 8px; + margin-right: 8px; +} + +.logo-img { + display: none; + width: 40px; + margin-right: 8px; +} + +.header-avatar { + margin-left: auto; +} + +@media (min-width: 600px) { + .logo { + margin-right: 32px; + } + + .logo-img { + display: inline-block; + } +} + +@media (min-width: 768px) { + :root { + --header-padding-lr: 24px; + } +} + +@media (min-width: 1200px) { + :root { + --header-padding-lr: 12.5rem; + } +} diff --git a/src/components/Header/index.js b/src/components/Header/index.js new file mode 100644 index 00000000..2764567d --- /dev/null +++ b/src/components/Header/index.js @@ -0,0 +1 @@ +export { default } from "./Header"; diff --git a/src/components/ItemCard/ItemCard.jsx b/src/components/ItemCard/ItemCard.jsx new file mode 100644 index 00000000..0096d550 --- /dev/null +++ b/src/components/ItemCard/ItemCard.jsx @@ -0,0 +1,37 @@ +import { Link } from "react-router-dom"; +import styles from "./ItemCard.module.css"; +import likeImg from "../../assets/images/ic-like.svg"; +import pandaLogoImg from "../../assets/images/logo-panda.svg"; + +const ItemCard = ({ data, loading = "lazy" }) => { + const { images, name, description, price, favoriteCount } = data; + + return ( + + + {name} { + e.currentTarget.src = pandaLogoImg; + }} + loading={loading} + /> + + +
{description}
+

+ {price.toLocaleString("ko-KR")}원 +

+ + + ); +}; + +export default ItemCard; diff --git a/src/components/ItemCard/ItemCard.module.css b/src/components/ItemCard/ItemCard.module.css new file mode 100644 index 00000000..57af9594 --- /dev/null +++ b/src/components/ItemCard/ItemCard.module.css @@ -0,0 +1,38 @@ +.item-img { + object-fit: cover; + border-radius: var(--thumb-border-radius); + margin-bottom: 16px; +} + +.img-wrap { + display: block; +} + +.item-desc { + margin-bottom: 8px; + font-size: 14px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.item-price { + margin-bottom: 8px; +} + +.btn-like { + display: flex; + align-items: center; + gap: 4px; +} + +.btn-like-ico { + width: 16px; + height: 16px; +} + +.btn-like-ico .ico-img { + height: 100%; + object-fit: contain; +} diff --git a/src/components/ItemCard/index.js b/src/components/ItemCard/index.js new file mode 100644 index 00000000..59ad7924 --- /dev/null +++ b/src/components/ItemCard/index.js @@ -0,0 +1 @@ +export { default } from "./ItemCard"; diff --git a/src/components/ItemList/BestItemList.jsx b/src/components/ItemList/BestItemList.jsx new file mode 100644 index 00000000..e2c94f0b --- /dev/null +++ b/src/components/ItemList/BestItemList.jsx @@ -0,0 +1,47 @@ +import { useEffect, useState, useCallback } from "react"; +import { getItems } from "../../services/api"; +import styles from "./ItemList.module.css"; +import useAsync from "../../hooks/useAsync"; +import ItemListResults from "./ItemListResults"; + +const ORDER_BY = "favorite"; +const LIST_TYPE = "best"; + +const BestItemList = ({ pageSize, title }) => { + const [items, setItems] = useState([]); + const { + isLoading, + loadingError, + runAsync: getItemsAsync, + } = useAsync(getItems); + + const handleLoad = useCallback( + async (options) => { + const result = await getItemsAsync(options); + if (!result) return; + + const { list } = result; + setItems(list); + }, + [getItemsAsync] + ); + + useEffect(() => { + handleLoad({ pageSize, orderBy: ORDER_BY }); + }, [pageSize, handleLoad]); + + return ( +
+

{title}

+ +
+ ); +}; + +export default BestItemList; diff --git a/src/components/ItemList/ItemList.jsx b/src/components/ItemList/ItemList.jsx new file mode 100644 index 00000000..ed3056c7 --- /dev/null +++ b/src/components/ItemList/ItemList.jsx @@ -0,0 +1,98 @@ +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useEffect, useState, useCallback } from "react"; +import { getItems } from "../../services/api"; +import styles from "./ItemList.module.css"; +import Button from "../../ui/Button"; +import Dropdown from "../../ui/Dropdown"; +import InputSearch from "../../ui/Input/InputSearch"; +import Pagination from "../Pagination"; +import useAsync from "../../hooks/useAsync"; +import ItemListResults from "./ItemListResults"; +import { DEFAULT_ITEM_PAGE_SIZE } from "../../constants/pagesize"; +import { ITEMS_ORDER_MAP } from "../../constants/sortOptions"; + +const DEFAULT_ORDER = Object.keys(ITEMS_ORDER_MAP)[0]; +const dropdownMenuItems = Object.keys(ITEMS_ORDER_MAP); + +const ItemList = ({ title, pageSize = DEFAULT_ITEM_PAGE_SIZE }) => { + const [items, setItems] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [order, setOrder] = useState(DEFAULT_ORDER); + const navigate = useNavigate(); + const { + isLoading, + loadingError, + runAsync: getItemsAsync, + } = useAsync(getItems); + + const [searchParams, setSearchParams] = useSearchParams(); + const keyword = searchParams.get("keyword") || ""; + + const handleLoad = useCallback( + async (options) => { + const result = await getItemsAsync(options); + if (!result) return; + + setItems(result.list); + setTotalCount(result.totalCount); + }, + [getItemsAsync] + ); + + const handleDropdownSelect = (selectedOrder) => { + setOrder(selectedOrder); + }; + + useEffect(() => { + handleLoad({ + pageSize, + orderBy: ITEMS_ORDER_MAP[order], + keyword, + }); + }, [pageSize, order, keyword, handleLoad]); + + return ( +
+
+

{title}

+ + + +
+ setSearchParams("")} + /> + +
+ ); +}; + +export default ItemList; diff --git a/src/components/ItemList/ItemList.module.css b/src/components/ItemList/ItemList.module.css new file mode 100644 index 00000000..acda9ce6 --- /dev/null +++ b/src/components/ItemList/ItemList.module.css @@ -0,0 +1,112 @@ +.item-list-wrap { + position: relative; +} + +.item-list-ul { + display: grid; + grid-template-columns: repeat(2, minmax(48%, 1fr)); + gap: 32px 8px; +} + +.item-list img { + width: 100%; + aspect-ratio: 1/1; + object-fit: cover; +} + +.item-list-ul.best { + display: flex; + flex-wrap: wrap; + gap: 40px 24px; +} + +.item-list-ul.best .item-list { + flex-grow: 1; +} + +.item-list-area { + padding-bottom: 40px; +} + +.item-list-title { + width: auto; + margin-bottom: 1rem; + margin-right: auto; + font-size: 20px; + font-weight: 700; +} + +.item-list-header .item-list-title { + margin-bottom: 0; +} + +.item-list-header { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--list-header-gap); + margin-bottom: 24px; +} + +.item-list-header-search { + width: calc(100% - var(--dropdown-min-width) - var(--list-header-gap)); +} + +.item-list-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 300px; + gap: 8px; +} + +@media (min-width: 600px) { + :root { + --dropdown-min-width: 130px; + } + + .item-list-ul.best { + gap: 40px 10px; + } + + .item-list-ul.best .item-list { + width: calc(50% - 10px); + } +} + +@media (min-width: 720px) { + .item-list-ul { + grid-template-columns: repeat(3, minmax(30%, 1fr)); + gap: 40px 16px; + } + + .item-list-header { + flex-wrap: nowrap; + } + + .item-list-header-search { + width: 242px; + } +} + +@media (min-width: 1200px) { + .item-list-ul { + grid-template-columns: repeat(5, minmax(220px, 1fr)); + gap: 40px 24px; + } + + .item-list-ul.best { + gap: 40px 24px; + } + + .item-list-ul.best .item-list { + width: calc(25% - 24px); + flex-grow: 1; + } + + .item-list-header-search { + width: 324px; + } +} diff --git a/src/components/ItemList/ItemListResults.jsx b/src/components/ItemList/ItemListResults.jsx new file mode 100644 index 00000000..a357d834 --- /dev/null +++ b/src/components/ItemList/ItemListResults.jsx @@ -0,0 +1,53 @@ +import styles from "./ItemList.module.css"; +import ItemCard from "../ItemCard"; +import ItemCardSkeleton from "../../ui/Skeletons/ItemCardSkeleton"; +import Button from "../../ui/Button"; + +const ItemListResults = ({ + isLoading, + isError, + isEmpty, + items, + pageSize, + listType, +}) => { + const isLoadingError = !isLoading && isError; + + if (isLoading) { + return ( +
    + {Array.from({ length: pageSize }).map((_, index) => ( +
  • + +
  • + ))} +
+ ); + } + + if (isLoadingError) return

상품 목록을 가져오지 못했습니다.

; + + if (items.length === 0) + return ( +
+

상품이 없습니다.

+ +
+ ); + + return ( +
    + {items.slice(0, pageSize).map(({ id, ...itemData }) => { + return ( +
  • + +
  • + ); + })} +
+ ); +}; + +export default ItemListResults; diff --git a/src/components/ItemList/index.js b/src/components/ItemList/index.js new file mode 100644 index 00000000..a3066ab4 --- /dev/null +++ b/src/components/ItemList/index.js @@ -0,0 +1 @@ +export { default } from "./ItemList"; diff --git a/src/components/Nav/Nav.jsx b/src/components/Nav/Nav.jsx new file mode 100644 index 00000000..d173333b --- /dev/null +++ b/src/components/Nav/Nav.jsx @@ -0,0 +1,33 @@ +import { NavLink } from "react-router-dom"; +import styles from "./Nav.module.css"; + +const Nav = () => { + const activeLinkStyle = ({ isActive }) => { + return isActive ? { color: "#3692FF" } : undefined; + }; + + return ( +
    +
  • + + 자유게시판 + +
  • +
  • + + 중고마켓 + +
  • +
+ ); +}; + +export default Nav; diff --git a/src/components/Nav/Nav.module.css b/src/components/Nav/Nav.module.css new file mode 100644 index 00000000..71730e9f --- /dev/null +++ b/src/components/Nav/Nav.module.css @@ -0,0 +1,17 @@ +.nav { + display: flex; +} + +.nav-link { + padding: 14px 8px; + font-size: 16px; + font-weight: 700; + color: var(--gray600); +} + +@media (min-width: 600px) { + .nav-link { + padding: 14px 20px; + font-size: 18px; + } +} diff --git a/src/components/Nav/index.js b/src/components/Nav/index.js new file mode 100644 index 00000000..05c3d469 --- /dev/null +++ b/src/components/Nav/index.js @@ -0,0 +1 @@ +export { default } from "./Nav"; diff --git a/src/components/Pagination/Pagination.jsx b/src/components/Pagination/Pagination.jsx new file mode 100644 index 00000000..d9d04fae --- /dev/null +++ b/src/components/Pagination/Pagination.jsx @@ -0,0 +1,81 @@ +import { useCallback, useEffect } from "react"; +import styles from "./Pagination.module.css"; +import arrowLeft from "../../assets/images/ic_arrow_sm_left.svg"; +import arrowRight from "../../assets/images/ic_arrow_sm_right.svg"; +import usePagination from "../../hooks/usePagination"; +import { DEFAULT_ITEM_PAGE_SIZE } from "../../constants/pagesize.js"; + +const PAGINATION_SIZE = 5; + +const Pagination = ({ + pageSize = DEFAULT_ITEM_PAGE_SIZE, + totalCount, + paginationSize = PAGINATION_SIZE, + handleLoad, + orderStatus, + searchKeyword, +}) => { + const { pageData, pageActions } = usePagination({ + totalCount, + pageSize, + paginationSize, + onPageChange: useCallback( + (page) => { + handleLoad({ + page, + pageSize, + orderBy: orderStatus, + keyword: searchKeyword, + }); + }, + [handleLoad, pageSize, orderStatus, searchKeyword] + ), + }); + + const { currentPage, currentPages, hasPrev, hasNext } = pageData; + const { goToPage, goPrevPages, goNextPages, updatePageWithResize } = + pageActions; + + // 정렬 바뀌면 첫번째 페이지로 이동 + useEffect(() => { + goToPage(1); + }, [orderStatus, goToPage]); + + // resize시 보고 있던 페이지 유지 + useEffect(() => { + updatePageWithResize(pageSize); + }, [pageSize, updatePageWithResize]); + + return ( +
+ + {currentPages.map((page) => ( + + ))} + +
+ ); +}; + +export default Pagination; diff --git a/src/components/Pagination/Pagination.module.css b/src/components/Pagination/Pagination.module.css new file mode 100644 index 00000000..c70127f5 --- /dev/null +++ b/src/components/Pagination/Pagination.module.css @@ -0,0 +1,35 @@ +.pagination { + display: flex; + justify-content: center; + gap: 4px; + margin-top: 44px; + padding-bottom: 20px; +} + +.pagination-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid var(--border-color); + color: var(--gray500); + font-size: 1rem; + font-weight: 600; +} +.pagination-btn:hover { + box-shadow: 0px 3px 6px rgba(0, 0, 0, 0.15); +} + +.pagination-btn:disabled { + background: var(--gray200); + opacity: 0.5; + pointer-events: none; +} + +.pagination-btn.active { + background: var(--primary-color); + border-color: var(--primary-color); + color: #fff; +} diff --git a/src/components/Pagination/index.js b/src/components/Pagination/index.js new file mode 100644 index 00000000..34fcdf47 --- /dev/null +++ b/src/components/Pagination/index.js @@ -0,0 +1 @@ +export { default } from "./Pagination"; diff --git a/src/constants/pagesize.js b/src/constants/pagesize.js new file mode 100644 index 00000000..4af1960c --- /dev/null +++ b/src/constants/pagesize.js @@ -0,0 +1 @@ +export const DEFAULT_ITEM_PAGE_SIZE = 10; diff --git a/src/constants/responsive.js b/src/constants/responsive.js new file mode 100644 index 00000000..4f2b6f5c --- /dev/null +++ b/src/constants/responsive.js @@ -0,0 +1,16 @@ +export const BREAKPOINTS = { + desktop: 1200, + tablet: 600, +}; + +export const BEST_ITEMS_PAGESIZE = { + desktop: 4, + tablet: 2, + mobile: 1, +}; + +export const ALL_ITEMS_PAGESIZE = { + desktop: 10, + tablet: 6, + mobile: 4, +}; diff --git a/src/constants/sortOptions.js b/src/constants/sortOptions.js new file mode 100644 index 00000000..a1b28988 --- /dev/null +++ b/src/constants/sortOptions.js @@ -0,0 +1,4 @@ +export const ITEMS_ORDER_MAP = { + 최신순: "recent", + 좋아요순: "favorite", +}; diff --git a/src/constants/titles.js b/src/constants/titles.js new file mode 100644 index 00000000..5a6bcf68 --- /dev/null +++ b/src/constants/titles.js @@ -0,0 +1,2 @@ +export const BEST_ITEMS_TITLE = "베스트 상품"; +export const ALL_ITEMS_TITLE = "전체 상품"; diff --git a/src/hooks/useAsync.js b/src/hooks/useAsync.js new file mode 100644 index 00000000..2a06d7b2 --- /dev/null +++ b/src/hooks/useAsync.js @@ -0,0 +1,26 @@ +import { useState, useCallback } from "react"; + +const useAsync = (asyncFunc) => { + const [loading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const runAsync = useCallback( + async (...args) => { + try { + setIsLoading(true); + setError(null); + return await asyncFunc(...args); + } catch (err) { + setError(err); + return; + } finally { + setIsLoading(false); + } + }, + [asyncFunc] + ); + + return { loading, error, runAsync }; +}; + +export default useAsync; diff --git a/src/hooks/usePagination.js b/src/hooks/usePagination.js new file mode 100644 index 00000000..1f51aa94 --- /dev/null +++ b/src/hooks/usePagination.js @@ -0,0 +1,76 @@ +import { useState, useMemo, useCallback, useRef } from "react"; + +const usePagination = ({ + pageSize = 10, + totalCount, + paginationSize = 5, + onPageChange, +}) => { + const totalPage = useMemo( + () => Math.ceil(totalCount / pageSize), + [totalCount, pageSize] + ); + + const [currentPage, setCurrentPage] = useState(1); + const currentGroupIndex = Math.floor((currentPage - 1) / paginationSize); + const currentPages = useMemo(() => { + const start = currentGroupIndex * paginationSize + 1; + return Array.from({ length: paginationSize }, (_, i) => start + i).filter( + (p) => p <= totalPage + ); + }, [currentGroupIndex, paginationSize, totalPage]); + + const hasPrev = currentGroupIndex > 0; + const hasNext = currentPages[currentPages.length - 1] < totalPage; + + const pageSizeRef = useRef(pageSize); + + const goToPage = useCallback( + (page) => { + setCurrentPage(page); + onPageChange(page); + }, + [onPageChange] + ); + + const goPrevPages = () => { + if (!hasPrev) return; + const prevPage = currentPages[0] - 1; + goToPage(prevPage); + }; + + const goNextPages = () => { + if (!hasNext) return; + const nextPage = currentPages[currentPages.length - 1] + 1; + goToPage(nextPage); + }; + + const updatePageWithResize = (newPageSize) => { + const prevPageSize = pageSizeRef.current; + + if (newPageSize !== prevPageSize) { + const prevItemIdx = (currentPage - 1) * prevPageSize; + const newCurrentPage = Math.floor(prevItemIdx / pageSize) + 1; + goToPage(newCurrentPage); + } + + pageSizeRef.current = newPageSize; + }; + + return { + pageData: { + currentPage, + currentPages, + hasPrev, + hasNext, + }, + pageActions: { + goToPage, + goPrevPages, + goNextPages, + updatePageWithResize, + }, + }; +}; + +export default usePagination; diff --git a/src/hooks/useWindowDimensions.js b/src/hooks/useWindowDimensions.js new file mode 100644 index 00000000..ffef2a6c --- /dev/null +++ b/src/hooks/useWindowDimensions.js @@ -0,0 +1,26 @@ +import { useState, useEffect } from "react"; +import debounce from "../utils/debounce"; + +const RESIZE_DEBOUNCE_MS = 300; + +function getWindowDimensions() { + const { innerWidth: width, innerHeight: height } = window; + return { width, height }; +} + +export default function useWindowDimensions() { + const [windowDimensions, setWindowDimensions] = useState( + getWindowDimensions() + ); + + useEffect(() => { + const debouncedHandleResize = debounce(() => { + setWindowDimensions(getWindowDimensions()); + }, RESIZE_DEBOUNCE_MS); + + window.addEventListener("resize", debouncedHandleResize); + return () => window.removeEventListener("resize", debouncedHandleResize); + }, []); + + return windowDimensions; +} diff --git a/src/index.css b/src/index.css deleted file mode 100644 index ec2585e8..00000000 --- a/src/index.css +++ /dev/null @@ -1,13 +0,0 @@ -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} diff --git a/src/index.js b/src/index.js index d563c0fb..8825d965 100644 --- a/src/index.js +++ b/src/index.js @@ -1,17 +1,6 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import './index.css'; -import App from './App'; -import reportWebVitals from './reportWebVitals'; +import ReactDOM from "react-dom/client"; +import "./styles/global.css"; +import Main from "./Main"; -const root = ReactDOM.createRoot(document.getElementById('root')); -root.render( - - - -); - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); +const root = ReactDOM.createRoot(document.getElementById("root")); +root.render(
); diff --git a/src/logo.svg b/src/logo.svg deleted file mode 100644 index 9dfc1c05..00000000 --- a/src/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/pages/AddItemPage.jsx b/src/pages/AddItemPage.jsx new file mode 100644 index 00000000..0d8eab09 --- /dev/null +++ b/src/pages/AddItemPage.jsx @@ -0,0 +1,5 @@ +const AddItemPage = () => { + return
상품 등록 페이지
; +}; + +export default AddItemPage; diff --git a/src/pages/BoardPage.jsx b/src/pages/BoardPage.jsx new file mode 100644 index 00000000..9661319e --- /dev/null +++ b/src/pages/BoardPage.jsx @@ -0,0 +1,5 @@ +const BoardPage = () => { + return
자유게시판
; +}; + +export default BoardPage; diff --git a/src/pages/ItemsPage.jsx b/src/pages/ItemsPage.jsx new file mode 100644 index 00000000..e7543a28 --- /dev/null +++ b/src/pages/ItemsPage.jsx @@ -0,0 +1,40 @@ +import ItemList from "../components/ItemList"; +import BestItemList from "../components/ItemList/BestItemList"; +import useWindowDimensions from "../hooks/useWindowDimensions"; +import { BEST_ITEMS_TITLE, ALL_ITEMS_TITLE } from "../constants/titles"; +import { + BREAKPOINTS, + BEST_ITEMS_PAGESIZE, + ALL_ITEMS_PAGESIZE, +} from "../constants/responsive"; + +const ItemsPage = () => { + const { width } = useWindowDimensions(); + + return ( +
+ = BREAKPOINTS.desktop + ? BEST_ITEMS_PAGESIZE.desktop + : width >= BREAKPOINTS.tablet + ? BEST_ITEMS_PAGESIZE.tablet + : BEST_ITEMS_PAGESIZE.mobile + } + /> + = BREAKPOINTS.desktop + ? ALL_ITEMS_PAGESIZE.desktop + : width >= BREAKPOINTS.tablet + ? ALL_ITEMS_PAGESIZE.tablet + : ALL_ITEMS_PAGESIZE.mobile + } + /> +
+ ); +}; + +export default ItemsPage; diff --git a/src/reportWebVitals.js b/src/reportWebVitals.js deleted file mode 100644 index 5253d3ad..00000000 --- a/src/reportWebVitals.js +++ /dev/null @@ -1,13 +0,0 @@ -const reportWebVitals = onPerfEntry => { - if (onPerfEntry && onPerfEntry instanceof Function) { - import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { - getCLS(onPerfEntry); - getFID(onPerfEntry); - getFCP(onPerfEntry); - getLCP(onPerfEntry); - getTTFB(onPerfEntry); - }); - } -}; - -export default reportWebVitals; diff --git a/src/services/api.js b/src/services/api.js new file mode 100644 index 00000000..3effaa48 --- /dev/null +++ b/src/services/api.js @@ -0,0 +1,18 @@ +const BASE_URL = "https://panda-market-api.vercel.app"; + +export async function getItems({ + page = 1, + pageSize = 10, + orderBy = "recent", + keyword = "", +}) { + const query = `page=${page}&pageSize=${pageSize}&orderBy=${orderBy}&keyword=${keyword}`; + const response = await fetch(`${BASE_URL}/products?${query}`); + + if (!response.ok) { + throw new Error("상품 목록을 불러오지 못했어요!"); + } + + const body = await response.json(); + return body; +} diff --git a/src/setupTests.js b/src/setupTests.js deleted file mode 100644 index 8f2609b7..00000000 --- a/src/setupTests.js +++ /dev/null @@ -1,5 +0,0 @@ -// jest-dom adds custom jest matchers for asserting on DOM nodes. -// allows you to do things like: -// expect(element).toHaveTextContent(/react/i) -// learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom'; diff --git a/src/styles/common.css b/src/styles/common.css new file mode 100644 index 00000000..8693353a --- /dev/null +++ b/src/styles/common.css @@ -0,0 +1,65 @@ +/* fonts */ +@font-face { + font-family: "Pretendard"; + font-weight: 400; + src: url("../assets/fonts/Pretendard-Regular.woff2") format("woff2"); + font-display: swap; +} +@font-face { + font-family: "Pretendard"; + font-weight: 500; + src: url("../assets/fonts/Pretendard-Medium.woff2") format("woff2"); + font-display: swap; +} +@font-face { + font-family: "Pretendard"; + font-weight: 700; + src: url("../assets/fonts/Pretendard-Bold.woff2") format("woff2"); + font-display: swap; +} + +/* layout */ +.page-content { + width: 100%; + margin: 24px auto; + padding: 16px; +} + +/*================ 반응형 ================*/ +/* tablet */ +@media (min-width: 600px) { + .page-content { + padding: 24px; + } +} + +@media (min-width: 768px) { + html { + font-size: 16px; + } +} + +/* desktop */ +@media (min-width: 1200px) { + .page-content { + width: var(--page-content-width); + padding: 0; + } +} + +/* wide desktop */ +@media (min-width: 1921px) { + html { + font-size: 18px; + } +} + +/* 반응형 요소에 clamp() 적용 */ +@supports (font-size: clamp(1rem, 2vw, 3rem)) { + :root { + --heading-font-size: clamp(24px, 3vw, 40px); + --description-font-size: clamp(16px, 2vw, 24px); + --label-font-size: clamp(16px, 1.5vw, 18px); + --form-label-font-size: clamp(14px, 1.25vw, 18px); + } +} diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 00000000..3ffb6056 --- /dev/null +++ b/src/styles/global.css @@ -0,0 +1,3 @@ +@import "./variables.css"; +@import "./reset.css"; +@import "./common.css"; diff --git a/src/styles/reset.css b/src/styles/reset.css new file mode 100644 index 00000000..1938fbae --- /dev/null +++ b/src/styles/reset.css @@ -0,0 +1,68 @@ +* { + margin: 0; + padding: 0; + color: inherit; +} +*, +:after, +:before { + box-sizing: border-box; +} +:root { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; + cursor: default; + line-height: 1.5; + overflow-wrap: break-word; + -moz-tab-size: 4; + tab-size: 4; +} +html { + font-size: 14px; +} +body { + font-family: "Pretendard", sans-serif; + background: var(--background-light); + overflow-x: hidden; +} +html, +body { + height: 100%; +} +img, +picture, +video, +canvas, +svg { + display: block; + max-width: 100%; +} +input, +button, +textarea, +select { + font-family: inherit; +} +button { + background: none; + border: 0; + cursor: pointer; +} +button:disabled { + cursor: default; + background-color: var(--gray400); +} +button:disabled:hover { + background-color: var(--gray400); +} +a { + text-decoration: none; + color: inherit; + cursor: pointer; +} +input { + border: none; +} +li { + list-style: none; +} diff --git a/src/styles/variables.css b/src/styles/variables.css new file mode 100644 index 00000000..fa5c4b02 --- /dev/null +++ b/src/styles/variables.css @@ -0,0 +1,51 @@ +:root { + /* colors */ + --gray50: #f9fafb; + --gray100: #f3f4f6; + --gray200: #e5e7eb; + --gray400: #9ca3af; + --gray500: #6b7280; + --gray600: #4b5563; + --gray700: #374151; + --gray800: #1f2937; + --gray900: #111827; + --blue: #3692ff; + --primary-color: var(--blue); + --primary-hover-color: #1967d6; + --primary-click-color: #1251aa; + --secondary-color: var(--gray800); + --text-primary: var(--gray600); + --background-light: #fcfcfc; + --background-blue: #cfe5ff; + --background-blue-light: #e6f2ff; + --error-color: #f74747; + --border-color: var(--gray200); + + /* button */ + --banner-btn-font-size: 18px; + + /* layout */ + --header-padding-lr: 16px; + --container-width: 70rem; + --container-width-small: 61.75rem; + --page-content-width: 75rem; + --sections-padding: 24px 16px 84px; + --section-margin-bottom: 16px; + --footer-padding: 32px; + --list-header-gap: 12px; + + /* form */ + --form-padding-top: 24px; + --form-label-margin-bottom: 8px; + --form-contents-gap: 16px; + + /* border-radius */ + --border-radius-xs: 8px; + --border-radius-sm: 12px; + --border-radius-md: 20px; + --border-radius-lg: 40px; + --thumb-border-radius: 16px; + + /* dropdown */ + --dropdown-min-width: 48px; +} diff --git a/src/ui/Button/Button.jsx b/src/ui/Button/Button.jsx new file mode 100644 index 00000000..5c6b804a --- /dev/null +++ b/src/ui/Button/Button.jsx @@ -0,0 +1,24 @@ +import styles from "./Button.module.css"; + +const Button = ({ + type = "button", + variant, + size, + children, + className = "", + onClick, +}) => { + const btnVariant = `btn-${variant}`; + const btnSize = `btn-${size}`; + return ( + + ); +}; + +export default Button; diff --git a/src/ui/Button/Button.module.css b/src/ui/Button/Button.module.css new file mode 100644 index 00000000..a3406871 --- /dev/null +++ b/src/ui/Button/Button.module.css @@ -0,0 +1,24 @@ +.btn-lg { + padding: 12px; + border-radius: var(--border-radius-lg); + font-size: 20px; + font-weight: 600; + line-height: 32px; +} + +.btn-sm { + padding: 12px 24px; + font-size: 16px; + border-radius: var(--border-radius-xs); +} + +.btn-primary { + background: var(--primary-color); + color: #fff; +} +.btn-primary:hover { + background: var(--primary-hover-color); +} +.btn-primary:active { + background: var(--primary-click-color); +} diff --git a/src/ui/Button/index.js b/src/ui/Button/index.js new file mode 100644 index 00000000..c4719be7 --- /dev/null +++ b/src/ui/Button/index.js @@ -0,0 +1 @@ +export { default } from "./Button"; diff --git a/src/ui/Dropdown/Dropdown.jsx b/src/ui/Dropdown/Dropdown.jsx new file mode 100644 index 00000000..4e268a17 --- /dev/null +++ b/src/ui/Dropdown/Dropdown.jsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import styles from "./Dropdown.module.css"; +import DropdownBtn from "./DropdownBtn"; +import DropdownMenu from "./DropdownMenu"; + +const Dropdown = ({ menu, onClickMenu, defaultSelected, iconType }) => { + const [selected, setSelected] = useState(defaultSelected); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isDropdownBtnActive, setIsDropdownBtnActive] = useState(false); + + const handleMenuClick = (e) => { + const selectedValue = e.target.textContent; + setSelected(selectedValue); + onClickMenu(selectedValue); + setIsDropdownOpen((prev) => !prev); + setIsDropdownBtnActive((prev) => !prev); + }; + + const handleDropdown = () => { + setIsDropdownOpen((prev) => !prev); + setIsDropdownBtnActive((prev) => !prev); + }; + + return ( +
+ + +
+ ); +}; + +export default Dropdown; diff --git a/src/ui/Dropdown/Dropdown.module.css b/src/ui/Dropdown/Dropdown.module.css new file mode 100644 index 00000000..46c10604 --- /dev/null +++ b/src/ui/Dropdown/Dropdown.module.css @@ -0,0 +1,3 @@ +.dropdown { + position: relative; +} diff --git a/src/ui/Dropdown/DropdownBtn.jsx b/src/ui/Dropdown/DropdownBtn.jsx new file mode 100644 index 00000000..b5130790 --- /dev/null +++ b/src/ui/Dropdown/DropdownBtn.jsx @@ -0,0 +1,35 @@ +import styles from "./DropdownBtn.module.css"; +import arrowDownImg from "../../assets/images/ic_arrow_down.png"; +import sortImg from "../../assets/images/ic_sort.svg"; +import useWindowDimensions from "../../hooks/useWindowDimensions"; +import { BREAKPOINTS } from "../../constants/responsive"; + +const DropdownBtn = ({ selected, onClickDropdownBtn, isActive, iconType }) => { + const { width } = useWindowDimensions(); + const isMobile = width < BREAKPOINTS.tablet; + + return ( + + ); +}; + +export default DropdownBtn; diff --git a/src/ui/Dropdown/DropdownBtn.module.css b/src/ui/Dropdown/DropdownBtn.module.css new file mode 100644 index 00000000..2f4ddcef --- /dev/null +++ b/src/ui/Dropdown/DropdownBtn.module.css @@ -0,0 +1,35 @@ +.dropdown-btn { + display: flex; + align-items: center; + justify-content: center; + width: auto; + height: 100%; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + color: var(--secondary-color); + text-align: left; + font-size: 1rem; + min-width: 48px; + aspect-ratio: 1/1; +} +.dropdown-btn:hover, +.dropdown-btn.active { + border-color: var(--primary-color); +} + +.dropdown-btn-container { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +@media (min-width: 600px) { + .dropdown-btn { + width: 130px; + padding: 8px 20px; + justify-content: space-between; + aspect-ratio: initial; + } +} diff --git a/src/ui/Dropdown/DropdownMenu.jsx b/src/ui/Dropdown/DropdownMenu.jsx new file mode 100644 index 00000000..6aa7483b --- /dev/null +++ b/src/ui/Dropdown/DropdownMenu.jsx @@ -0,0 +1,22 @@ +import styles from "./DropdownMenu.module.css"; + +const DropdownMenu = ({ items, onClick, isDropdownOpen }) => { + return ( +
    + {items.map((item) => ( +
  • + +
  • + ))} +
+ ); +}; + +export default DropdownMenu; diff --git a/src/ui/Dropdown/DropdownMenu.module.css b/src/ui/Dropdown/DropdownMenu.module.css new file mode 100644 index 00000000..262190a6 --- /dev/null +++ b/src/ui/Dropdown/DropdownMenu.module.css @@ -0,0 +1,32 @@ +.dropdown-menu { + position: absolute; + right: 0; + top: 110%; + width: 100%; + min-width: 130px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + overflow: hidden; + display: none; +} +.dropdown-menu.active { + display: block; +} + +.dropdown-menu li { + width: 100%; + background: #fff; +} +.dropdown-menu li:hover { + color: var(--primary-color); +} + +.dropdown-menu li + li { + border-top: 1px solid var(--border-color); +} + +.dropdown-menu-btn { + width: 100%; + padding: 12px 8px; + font-size: 1rem; +} diff --git a/src/ui/Dropdown/index.js b/src/ui/Dropdown/index.js new file mode 100644 index 00000000..4d208f48 --- /dev/null +++ b/src/ui/Dropdown/index.js @@ -0,0 +1 @@ +export { default } from "./Dropdown"; diff --git a/src/ui/Input/Input.jsx b/src/ui/Input/Input.jsx new file mode 100644 index 00000000..cb245a00 --- /dev/null +++ b/src/ui/Input/Input.jsx @@ -0,0 +1,16 @@ +import styles from "./Input.module.css"; + +const Input = ({ ...props }) => { + const { type, name, placeholder, onChange } = props; + return ( + + ); +}; + +export default Input; diff --git a/src/ui/Input/Input.module.css b/src/ui/Input/Input.module.css new file mode 100644 index 00000000..b761b994 --- /dev/null +++ b/src/ui/Input/Input.module.css @@ -0,0 +1,5 @@ +.input { + padding: 14px 20px; + background: var(--gray100); + border-radius: var(--border-radius-sm); +} diff --git a/src/ui/Input/InputSearch.jsx b/src/ui/Input/InputSearch.jsx new file mode 100644 index 00000000..1761b29f --- /dev/null +++ b/src/ui/Input/InputSearch.jsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import styles from "./InputSearch.module.css"; +import searchImg from "../../assets/images/ic-search.svg"; + +const InputSearch = ({ keyword, onSearch, className, placeholder }) => { + const [value, setValue] = useState(keyword); + + const handleSearch = () => { + onSearch({ keyword: value }); + }; + + useEffect(() => { + setValue(keyword); + }, [keyword]); + + return ( +
+ 상품 검색 + setValue(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + /> +
+ ); +}; + +export default InputSearch; diff --git a/src/ui/Input/InputSearch.module.css b/src/ui/Input/InputSearch.module.css new file mode 100644 index 00000000..9457f5db --- /dev/null +++ b/src/ui/Input/InputSearch.module.css @@ -0,0 +1,29 @@ +.search-area { + display: flex; + align-items: center; + padding: 14px 10px 14px 20px; + gap: 8px; + background: var(--gray100); + border-radius: var(--border-radius-sm); +} + +.search-area input { + width: 100%; + font-size: 16px; + background: transparent; +} + +.search-area input::placeholder { + color: var(--gray400); +} + +.search-ico { + width: 16px; + height: 16px; +} + +@media (min-width: 1200px) { + .search-area { + padding: 14px 20px; + } +} diff --git a/src/ui/Input/index.js b/src/ui/Input/index.js new file mode 100644 index 00000000..1efa4bc0 --- /dev/null +++ b/src/ui/Input/index.js @@ -0,0 +1 @@ +export { default } from ".Input"; diff --git a/src/ui/Skeletons/ItemCardSkeleton.jsx b/src/ui/Skeletons/ItemCardSkeleton.jsx new file mode 100644 index 00000000..90852267 --- /dev/null +++ b/src/ui/Skeletons/ItemCardSkeleton.jsx @@ -0,0 +1,15 @@ +import styles from "../../components/ItemCard/ItemCard.module.css"; +import skeletonStyles from "./ItemCardSkeleton.module.css"; + +const ItemCardSkeleton = () => { + return ( +
+
+
+
+
+
+ ); +}; + +export default ItemCardSkeleton; diff --git a/src/ui/Skeletons/ItemCardSkeleton.module.css b/src/ui/Skeletons/ItemCardSkeleton.module.css new file mode 100644 index 00000000..31b846b5 --- /dev/null +++ b/src/ui/Skeletons/ItemCardSkeleton.module.css @@ -0,0 +1,29 @@ +.skeleton-img { + width: 100%; + aspect-ratio: 1/1; + background-color: #e0e0e0; + border-radius: var(--thumb-border-radius); +} + +.skeleton-text { + width: 80%; + height: 18px; + margin: 12px 0 8px; + background-color: #e0e0e0; + border-radius: 4px; +} + +.skeleton-price { + width: 60%; + height: 20px; + background-color: #e0e0e0; + border-radius: 4px; + margin-bottom: 8px; +} + +.skeleton-like { + width: 50px; + height: 24px; + background-color: #e0e0e0; + border-radius: var(--border-radius-sm); +} diff --git a/src/utils/debounce.js b/src/utils/debounce.js new file mode 100644 index 00000000..ac94b884 --- /dev/null +++ b/src/utils/debounce.js @@ -0,0 +1,11 @@ +const DEFAULT_DEBOUNCE_MS = 300; + +export default function debounce(func, timeout = DEFAULT_DEBOUNCE_MS) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => { + func.apply(this, args); + }, timeout); + }; +}