diff --git a/README.md b/README.md index 52c2e8f4..1bccddac 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -## 판다마켓 5 +## 판다마켓 6 -**🌐 배포 url: https://myungjiwoo-pandamarket.netlify.app/items** +**🌐 배포 url: https://myungjiwoo-pandamarket.netlify.app/additem** ### 기본 요구사항 @@ -10,35 +10,24 @@ ### 체크 리스트 (기본) -- [x] 중고마켓 페이지 주소는 "/items"이다. -- [x] 페이지 주소가 "/items"일 때 상단 네비게이션바의 "중고마켓" 버튼의 색상은 "3692FF"이다. -- [x] 상단 네비게이션 바는 이전 미션에서 구현한 랜딩 페이지와 동일한 스타일로 제작한다. +- [x] 상품 등록 페이지 주소는 "/additem"이다. +- [x] 페이지 주소가 "/additem"일 때 상단 네비게이션바의 "중고마켓" 버튼의 색상은 "3692FF"이다. +- [x] 상품 이미지는 최대 한 개까지 업로드할 수 있다. +- [x] 각 input의 placeholder 값을 정확히 입력한다. +- [x] 이미지를 제외하고 input에 모든 값을 입력하면 '등록' 버튼이 활성화 된다. (api를 통한 상품 등록은 추후 미션에서 적용) -**데이터 불러오기** - -- [x] 카드 데이터는 제공된 백엔드 API 페이지의 GET 메소드인 "/products"를 사용한다. - -**상품 정렬** +### 체크 리스트 (심화) -- [x] 전체 상품에서 드롭 다운으로 "최신 순" 또는 "좋아요 순"을 선택해서 정렬할 수 있다. -- [x] "상품 등록하기" 버튼을 누르면 "/additem"으로 이동한다. (빈 페이지) -- [x] 베스트 상품 기준 - - 정렬 : favorite - - favorite가 가장 높은 상품 4가지 +- [x] 이미지 안의 x 버튼을 누르면 이미지가 삭제된다. +- [x] 추가된 태그 안의 x 버튼을 누르면 해당 태그는 삭제된다. -**반응형 디자인** +### 추가 기능 -- [x] 미디어 쿼리를 사용해서 반응형 view마다 물품의 개수를 다르게 보여준다. (서버로 요청하는 값은 동일) -- [x] 베스트 상품 - - Desktop : 4개 보이기 - - Tablet : 2개 보이기 - - Mobile : 1개 보이기 -- [x] 전체 상품 - - Desktop : 10개 보이기 - - Tablet : 6개 보이기 - - Mobile : 4개 보이기 +- [x] 오류 메시지를 토스트 메시지로 구현 (react-toastify 라이브러리 사용) -### 체크 리스트 (심화) +### 구현 포인트 -- [x] 페이지네이션 기능을 구현한다. -- [x] 반응형으로 보여지는 물품들의 개수를 다르게 설정할 때 서버에 보내는 pageSize 값을 적절하게 설정한다. +- [x] 입력 컴포넌트 계층화 및 재사용 + - `Base~ 컴포넌트` : 최소 단위 입력 컴포넌트 + - `~Field 컴포넌트` : 공통 인터페이스를 추가한 확장 컴포넌트 (label, error messge 등) + - `Item~Field 컴포넌트` : 도메인 전용 컴포넌트 diff --git a/package-lock.json b/package-lock.json index c4200e95..b7d48e8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "axios": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.5.0" + "react-router-dom": "^7.5.0", + "react-toastify": "^11.0.5" }, "devDependencies": { "@eslint/js": "^9.21.0", @@ -1774,6 +1775,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3060,6 +3070,19 @@ "react-dom": ">=18" } }, + "node_modules/react-toastify": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz", + "integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", diff --git a/package.json b/package.json index 868c6086..1cbc9bd0 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "axios": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.5.0" + "react-router-dom": "^7.5.0", + "react-toastify": "^11.0.5" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/src/App.jsx b/src/App.jsx index 7825955d..eecf0350 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,6 +3,9 @@ import styled from "@emotion/styled"; import GlobalStyle from "./GlobalStyle"; import Header from "@/layouts/Header"; import { breakpoints } from "@constants/breakpoints"; +import useDeviceSize from "@hooks/useDeviceSize"; +import { ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; const Layout = styled.div` display: flex; @@ -13,7 +16,8 @@ const Layout = styled.div` const ResponsiveMain = styled.main` // 모바일 & 전체 - width: 95vw; + width: 90vw; + margin: 2.5rem 0; // 태블릿 @media (min-width: ${breakpoints.mobile}) { @@ -27,13 +31,25 @@ const ResponsiveMain = styled.main` `; const App = () => { + const { isMobile } = useDeviceSize(); + return ( +
+ + ); }; diff --git a/src/GlobalStyle.jsx b/src/GlobalStyle.jsx index f7c9e66d..5889d7fe 100644 --- a/src/GlobalStyle.jsx +++ b/src/GlobalStyle.jsx @@ -1,5 +1,6 @@ import React from "react"; import { Global, css } from "@emotion/react"; +import { breakpoints } from "@constants/breakpoints"; const baseStyle = css` :root { @@ -31,6 +32,7 @@ const baseStyle = css` background-color: var(#fcfcfc); font-family: "Pretendard", sans-serif; font-size: 10px; + color: var(--gray800); } div, @@ -47,6 +49,26 @@ const baseStyle = css` button { cursor: pointer; } + + // 토스트 메시지 스타일 + .custom-toast { + background-color: var(--white); + font-size: 1.4rem; + border-radius: 1rem; + overflow: hidden; + } + + .custom-progress-bar { + background: linear-gradient(to left, #00c6ff, #0072ff); + } + + // 모바일 토스트 메시지 스타일 + @media (max-width: ${breakpoints.tablet}) { + .custom-toast { + margin: 0.3rem 0; + width: 70vw; + } + } `; const GlobalStyle = () => ; diff --git a/src/assets/icons/delete.jsx b/src/assets/icons/delete.jsx new file mode 100644 index 00000000..ff9e2217 --- /dev/null +++ b/src/assets/icons/delete.jsx @@ -0,0 +1,26 @@ +const DeleteIcon = () => { + return ( + + + + + ); +}; + +export default DeleteIcon; diff --git a/src/assets/icons/plus.jsx b/src/assets/icons/plus.jsx new file mode 100644 index 00000000..b7bcc838 --- /dev/null +++ b/src/assets/icons/plus.jsx @@ -0,0 +1,26 @@ +const PlusIcon = () => { + return ( + + + + + ); +}; + +export default PlusIcon; diff --git a/src/assets/imgs/notFoundImage@2x.png b/src/assets/imgs/notFoundImage@2x.png index 9595a0a6..4e2001db 100644 Binary files a/src/assets/imgs/notFoundImage@2x.png and b/src/assets/imgs/notFoundImage@2x.png differ diff --git a/src/components/BaseForm.jsx b/src/components/BaseForm.jsx new file mode 100644 index 00000000..f994b4db --- /dev/null +++ b/src/components/BaseForm.jsx @@ -0,0 +1,9 @@ +const BaseForm = ({ children, onSubmit, ...props }) => { + return ( +
+ {children} +
+ ); +}; + +export default BaseForm; diff --git a/src/components/BaseImageInput.jsx b/src/components/BaseImageInput.jsx new file mode 100644 index 00000000..6638cbcc --- /dev/null +++ b/src/components/BaseImageInput.jsx @@ -0,0 +1,11 @@ +import styled from "@emotion/styled"; + +const BaseImageInput = ({ type = "file", onChange, ...props }) => { + return ; +}; + +export default BaseImageInput; + +const Input = styled.input` + display: none; +`; diff --git a/src/components/BaseInput.jsx b/src/components/BaseInput.jsx new file mode 100644 index 00000000..0e53f108 --- /dev/null +++ b/src/components/BaseInput.jsx @@ -0,0 +1,21 @@ +import styled from "@emotion/styled"; + +const BaseInput = ({ type = "text", value, onChange, ...props }) => { + return ; +}; + +export default BaseInput; + +const Input = styled.input` + width: 100%; + padding: 1.4rem 2.4rem; + background-color: var(--gray100); + border-radius: 1.2rem; + border: 2px solid var(--gray100); + font-size: 1.4rem; + + &:focus { + outline: none; + border: 2px solid var(--blue); + } +`; diff --git a/src/components/BaseTextarea.jsx b/src/components/BaseTextarea.jsx new file mode 100644 index 00000000..a000f67d --- /dev/null +++ b/src/components/BaseTextarea.jsx @@ -0,0 +1,25 @@ +import styled from "@emotion/styled"; + +const BaseTextarea = ({ ref, value, onChange, ...props }) => { + return