diff --git a/README.md b/README.md index a32f37fb..9ad850b3 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,16 @@ -# 스프린트 미션 5 +# 스프린트 미션 6 ## [기본 요구사항] -- [x] 중고마켓 페이지 주소는 "/items"입니다. -- [x] 페이지 주소가 “/items” 일때 상단네비게이션바의 “중고마켓" 버튼의 색상은 “3692FF”입니다. -- [x] 상단 네비게이션 바는 이전 미션에서 구현한 랜딩 페이지와 동일한 스타일로 만들어 주세요. -- [x] 전체 상품에서 드롭 다운으로 “최신 순” 또는 “좋아요 순”을 선택해서 정렬을 할 수 있습니다. -- [x] '상품 등록하기’ 버튼을 누르면 “/additem” 로 이동합니다 ( 빈 페이지 ) -- [x] 카드 데이터는 제공된 백엔드 API 페이지의 GET 메소드인 “/products”를 사용해주세요 -- [x] 미디어 쿼리를 사용하여 반응형 view 마다 물품 개수를 다르게 보여줍니다 (서버로 요청하는 값은 동일) - -### 베스트 상품 기준 - -- [x] 정렬: favorite -- [x] favorite가 가장 높은 상품 4가지 - -### 베스트 상품 - -- [x] Desktop : 4개 보이기 -- [x] Tablet : 2개 보이기 -- [x] Mobile : 1개 보이기 - -### 전체 상품 - -- [x] Desktop : 10개 보이기 -- [x] Tablet : 6개 보이기 -- [x] Mobile : 4개 보이기 +- [x] 상품 등록 페이지 주소는 “/additem” 입니다. +- [x] 페이지 주소가 “/additem” 일때 상단네비게이션바의 '중고마켓' 버튼의 색상은 “3692FF”입니다. +- [x] 상품 이미지는 최대 한개 업로드가 가능합니다 +- [x] 각 input의 placeholder 값을 정확히 입력해주세요. +- [x] 이미지를 제외하고 input 에 모든 값을 입력하면 ‘등록' 버튼이 활성화 됩니다. --- ## [심화 요구사항] -- [x] 페이지 네이션 기능을 구현합니다. -- [ ] 반응형으로 보여지는 물품들의 개수를 다르게 설정할때 서버에 보내는 pageSize값을 적절하게 설정합니다. +- [x] 이미지 안의 X 버튼을 누르면 이미지가 삭제됩니다. +- [x] 추가된 태그 안의 X 버튼을 누르면 해당 태그는 삭제됩니다. diff --git a/src/App.css b/src/App.css index e6d9159a..27d2f768 100644 --- a/src/App.css +++ b/src/App.css @@ -1,61 +1,31 @@ -.header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 16px; - width: 100%; - height: 70px; -} - -.header__logo { - display: flex; - align-items: center; - margin-left: 200px; +/* Reset CSS */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; } -.header__logo-img { - width: 153px; - height: 51px; -} - -.header__nav-container { - margin-left: 24px; +html, +body { + width: 100%; + height: 100%; + font-family: Arial, sans-serif; } -.header__nav { - display: flex; - gap: 16px; +img { + display: block; + max-width: 100%; + height: auto; } -.header__nav a { - font-size: 18px; - font-weight: 700; - line-height: 26px; +a { + color: inherit; text-decoration: none; - color: #4b5563; -} - -.active { - font-weight: bold; - color: #3692ff; -} - -.header__right .header__user-img { - width: 40px; - height: 40px; - margin-right: 40px; -} - -@media (max-width: 1199px) { - .header__logo { - display: flex; - margin-left: 0; - } } -@media (max-width: 767px) { - .header__logo { - display: flex; - margin-left: 0; - } +button { + background: none; + border: none; + cursor: pointer; + font: inherit; } diff --git a/src/App.jsx b/src/App.jsx index d4e1ed87..5b686173 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,8 +1,7 @@ -import { BrowserRouter, Routes, Route, NavLink, Navigate } from "react-router-dom"; +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import Items from "./pages/Items"; import "./App.css"; -import userImage from "./assets/userImage.png"; -import logo from "./assets/pandaLogo.png"; +import Header from "./components/Header"; import AddItem from "./pages/AddItem"; /** @@ -13,27 +12,7 @@ import AddItem from "./pages/AddItem"; function App() { return ( -
-
-
- - logo - -
- -
- -
-
- -
- user -
-
-
+
} /> 자유게시판} /> diff --git a/src/api/productApi.js b/src/api/productApi.js index 1b964ba5..173da04d 100644 --- a/src/api/productApi.js +++ b/src/api/productApi.js @@ -1,5 +1,6 @@ -export const getProducts = async (query = "") => { - const response = await fetch(`https://panda-market-api.vercel.app/products${query}`); +export const getProducts = async (params = {}) => { + const query = new URLSearchParams(params).toString(); + const response = await fetch(`https://panda-market-api.vercel.app/products?${query}`); const data = await response.json(); return data; }; diff --git a/src/assets/dropdown.png b/src/assets/dropdown.png new file mode 100644 index 00000000..8d0254ea Binary files /dev/null and b/src/assets/dropdown.png differ diff --git a/src/assets/mobileFilter.png b/src/assets/mobileFilter.png new file mode 100644 index 00000000..53569763 Binary files /dev/null and b/src/assets/mobileFilter.png differ diff --git a/src/assets/mobileLogo.png b/src/assets/mobileLogo.png new file mode 100644 index 00000000..1417f57d Binary files /dev/null and b/src/assets/mobileLogo.png differ diff --git a/src/components/AddItem.jsx b/src/components/AddItem.jsx deleted file mode 100644 index e69de29b..00000000 diff --git a/src/components/BestProducts.css b/src/components/BestProducts.css index dd8e7999..5d410462 100644 --- a/src/components/BestProducts.css +++ b/src/components/BestProducts.css @@ -20,6 +20,7 @@ align-items: center; width: 100%; max-width: none; + gap: 16px; } .best-products__image-wrapper { @@ -38,17 +39,17 @@ .best-products__text-group { display: flex; - margin-top: 8px; + /* margin-top: 8px; */ flex-direction: column; + align-items: flex-start; width: 100%; text-align: left; - align-items: flex-start; + gap: 10px; } .best-products__title-text { font-size: 14px; font-weight: 500; - margin-bottom: -5px; color: #1f2937; } @@ -56,7 +57,6 @@ font-size: 16px; font-weight: 700; color: #1f2937; - margin-bottom: -3px; } .best-products__favorite { @@ -68,13 +68,34 @@ /* 태블릿 사이즈*/ @media (max-width: 1199px) { .best-products__grid { - grid-template-columns: repeat(2, 1fr); + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 16px; + } + + .best-products__card { + width: 343px; + } + .best-products__image-wrapper { + width: 343px; + height: 343px; } } /* 모바일 사이즈 */ @media (max-width: 767px) { .best-products__grid { - grid-template-columns: repeat(1, 1fr); + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 16px; + } + + .best-products__card { + width: 343px; + } + .best-products__image-wrapper { + width: 343px; } } diff --git a/src/components/BestProducts.jsx b/src/components/BestProducts.jsx index 99d0e9e2..27a29ab9 100644 --- a/src/components/BestProducts.jsx +++ b/src/components/BestProducts.jsx @@ -1,5 +1,5 @@ import { getProducts } from "../api/productApi"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import "./BestProducts.css"; /** @@ -12,32 +12,28 @@ const BestProduct = () => { const [bestProducts, setBestProducts] = useState([]); const [visibleCount, setVisibleCount] = useState(4); // default pc + const updateVisibleCount = useCallback(() => { + const width = window.innerWidth; + if (width <= 767) { + setVisibleCount(1); + } else if (width <= 1199) { + setVisibleCount(2); + } else { + setVisibleCount(4); + } + }, []); + useEffect(() => { - getProducts("?page=1&pageSize=4&orderBy=favorite").then((data) => { + getProducts({ page: 1, pageSize: 4, orderBy: "favorite" }).then((data) => { setBestProducts(data.list); }); }, []); useEffect(() => { - const updateVisibleCount = () => { - const width = window.innerWidth; - - if (width <= 767) { - // mobile - setVisibleCount(1); - } else if (width <= 1199) { - // tablet - setVisibleCount(2); - } else { - setVisibleCount(4); - } - }; - updateVisibleCount(); - window.addEventListener("resize", updateVisibleCount); // resize 시 eventListener - + window.addEventListener("resize", updateVisibleCount); return () => window.removeEventListener("resize", updateVisibleCount); - }, []); + }, [updateVisibleCount]); return (
diff --git a/src/components/Dropdown.css b/src/components/Dropdown.css new file mode 100644 index 00000000..7555f448 --- /dev/null +++ b/src/components/Dropdown.css @@ -0,0 +1,95 @@ +.dropdown { + position: relative; + display: inline-block; + font-family: inherit; +} + +.dropdown__toggle { + width: 130px; + height: 42px; + padding: 0 16px; + background: white; + border: 2px solid #e5e7eb; + border-radius: 16px; + font-size: 16px; + font-weight: 400; + line-height: 26px; + color: #1f2937; + text-align: left; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; +} + +.dropdown__arrow { + width: 24px; + height: 24px; + top: -3px; + left: 66px; +} + +.dropdown__menu { + position: absolute; + top: 50px; + right: 20; + min-width: 130px; + width: auto; + background: white; + border: 1px solid #e5e7eb; + border-radius: 16px; + overflow: hidden; + z-index: 10; +} + +.dropdown__menu li { + padding: 12px 20px; + font-size: 16px; + font-weight: 400; + line-height: 26px; + color: #1f2937; + text-align: center; + cursor: pointer; + border-bottom: 1px solid #e5e7eb; + white-space: nowrap; +} + +.dropdown__menu li:last-child { + border-bottom: none; +} + +.dropdown__menu li:hover { + background-color: #f3f4f6; +} + +@media (max-width: 1199px) { + .dropdown { + width: 130px; + height: 42px; + } +} + +@media (max-width: 767px) { + .dropdown { + width: auto; + position: relative; + } + + .dropdown__toggle { + width: 42px; + height: 42px; + padding: 0; + justify-content: center; + } + + .dropdown__arrow { + width: 20px; + height: 20px; + } + + .dropdown__menu { + top: 48px; + min-width: 120px; + right: 0; + } +} diff --git a/src/components/Dropdown.jsx b/src/components/Dropdown.jsx new file mode 100644 index 00000000..5b574152 --- /dev/null +++ b/src/components/Dropdown.jsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import "./Dropdown.css"; +import dropdown from "../assets/dropdown.png"; +import mobileFilter from "../assets/mobileFilter.png"; + +// pc/tablet 과 mobile 버튼을 눌렀을 때, dropdown가 오픈/닫히게 하는 함수 +const Dropdown = ({ value, options, onSelect }) => { + const [isOpen, setIsOpen] = useState(false); + + const selectedLabel = options.find((opt) => opt.value === value)?.label || ""; + + const isMobile = window.innerWidth <= 767; + + return ( +
+ + {isOpen && ( +
    + {options.map((option) => ( +
  • { + onSelect(option.value); + setIsOpen(false); + }} + > + {option.label} +
  • + ))} +
+ )} +
+ ); +}; + +export default Dropdown; diff --git a/src/components/FormInput.css b/src/components/FormInput.css new file mode 100644 index 00000000..2727f028 --- /dev/null +++ b/src/components/FormInput.css @@ -0,0 +1,62 @@ +.input-field { + margin-bottom: 32px; + display: flex; + flex-direction: column; +} + +.input-field__label { + font-size: 18px; + font-weight: 700; + margin-bottom: 12px; + width: 100%; +} + +.input-field__input { + width: 100%; + padding: 16px 20px; + font-size: 16px; + font-weight: 400; + border-radius: 12px; + border: none; + background-color: #f3f4f6; + color: #9ca3af; + resize: none; + line-height: 26px; +} + +.input-field textarea.input-field__input { + min-height: 282px; +} + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 12px; +} + +.tag-item { + background-color: #f3f4f6; + color: #1f2937; + padding: 8px 12px; + border-radius: 9999px; + display: flex; + align-items: center; + font-size: 16px; + font-weight: 400; +} + +.tag-item .tag-remove { + background: #9ca3af; + color: #fff; + border: none; + border-radius: 50%; + margin-left: 8px; + width: 20px; + height: 20px; + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/components/FormInput.jsx b/src/components/FormInput.jsx new file mode 100644 index 00000000..b35ade01 --- /dev/null +++ b/src/components/FormInput.jsx @@ -0,0 +1,81 @@ +import { useState, useEffect } from "react"; +import "./FormInput.css"; + +const FormInput = ({ onFormValidChange }) => { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [price, setPrice] = useState(""); + const [tagInput, setTagInput] = useState(""); + const [tags, setTags] = useState([]); + + // tag input에 입력된 값을 저장 + const handleTagKeyDown = (e) => { + if (e.nativeEvent.isComposing) return; // 한글 입력시 마지막 글자 복사 문제 방지 + if (e.key === "Enter" && tagInput.trim()) { + e.preventDefault(); + if (!tags.includes(tagInput.trim())) { + setTags([...tags, tagInput.trim()]); + } + setTagInput(""); + } + }; + + // 태그 삭제 + const handleTagRemove = (removeTag) => { + setTags(tags.filter((tag) => tag !== removeTag)); + }; + + // 입력 폼 Input 값이 변경될 때마다 호출 (input에 값들이 다 들어가있는지) + useEffect(() => { + const isValid = title.trim() && description.trim() && price.trim() && tags.length > 0; + onFormValidChange(!!isValid); + }, [title, description, price, tags, onFormValidChange]); + + return ( +
+
+ + setTitle(e.target.value)} /> +
+ +
+ +