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 (
-
- );
-}
-
-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 (
+
+
+
{
+ 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);
+ };
+}