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 (
+
+ );
+};
+
+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 ;
+};
+
+export default BaseTextarea;
+
+const Textarea = styled.textarea`
+ width: 100%;
+ height: auto;
+ min-height: 8rem;
+ resize: none;
+ overflow: hidden;
+ padding: 1.6rem 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/DeleteButton.jsx b/src/components/DeleteButton.jsx
new file mode 100644
index 00000000..4d6e743e
--- /dev/null
+++ b/src/components/DeleteButton.jsx
@@ -0,0 +1,34 @@
+import styled from "@emotion/styled";
+import DeleteIcon from "@assets/icons/delete";
+
+const BUTTON_SIZE = {
+ s: "1.6rem",
+ m: "2rem",
+ l: "2.4rem",
+};
+
+const DeleteButton = ({ onClick, size = "s" }) => {
+ return (
+
+
+
+ );
+};
+
+export default DeleteButton;
+
+const DeleteButtonWrapper = styled.div`
+ width: ${(props) => props.size};
+ height: ${(props) => props.size};
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 100%;
+ background-color: var(--gray300);
+ cursor: pointer;
+
+ &:hover {
+ opacity: 0.7;
+ }
+`;
diff --git a/src/components/ImageInputField.jsx b/src/components/ImageInputField.jsx
new file mode 100644
index 00000000..5ef8f35e
--- /dev/null
+++ b/src/components/ImageInputField.jsx
@@ -0,0 +1,113 @@
+import { memo } from "react";
+import styled from "@emotion/styled";
+import BaseImageInput from "@components/BaseImageInput";
+import DeleteButton from "@components/DeleteButton";
+import PlusIcon from "@assets/icons/plus";
+
+const ImageInputField = ({
+ id,
+ label,
+ imageUrl,
+ onChange,
+ onDelete,
+ ...props
+}) => {
+ return (
+
+
+
+
+
+ 이미지 등록
+
+
+
+ {imageUrl && (
+
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default memo(ImageInputField);
+
+const InputSection = styled.div`
+ margin: 2rem 0;
+`;
+
+const Label = styled.label`
+ display: inline-block;
+ margin-bottom: 1rem;
+ font-weight: bold;
+ line-height: 3.2rem;
+ color: var(--gray900);
+ font-size: 1.8rem;
+`;
+
+const ImageLabel = styled.label`
+ width: 45%;
+ height: 45%;
+ max-width: 20rem;
+ max-height: 20rem;
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ aspect-ratio: 1 / 1;
+ border-radius: 1.2rem;
+ background-color: var(--gray100);
+ cursor: pointer;
+
+ p {
+ font-size: 1.4rem;
+ color: var(--gray300);
+ }
+
+ &:hover {
+ opacity: 0.7;
+ }
+`;
+
+const ImageInputContainer = styled.div`
+ display: flex;
+ gap: 2rem;
+`;
+
+const PreviewImageContainer = styled.div`
+ width: 45%;
+ height: 45%;
+ max-width: 20rem;
+ max-height: 20rem;
+ padding: 1rem;
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ aspect-ratio: 1 / 1;
+ overflow: hidden;
+ background-color: var(--white);
+ border: 1px solid var(--gray100);
+ border-radius: 1.2rem;
+`;
+
+const PreviewImage = styled.img`
+ width: 100%;
+ height: 100%;
+ aspect-ratio: 1/1;
+ object-fit: cover;
+ border-radius: 0.5rem;
+`;
+
+const DeleteButtonWrapper = styled.div`
+ position: absolute;
+ top: 1.5rem;
+ right: 1.5rem;
+`;
diff --git a/src/components/InputField.jsx b/src/components/InputField.jsx
new file mode 100644
index 00000000..ccb082eb
--- /dev/null
+++ b/src/components/InputField.jsx
@@ -0,0 +1,33 @@
+import { memo } from "react";
+import styled from "@emotion/styled";
+import BaseInput from "@components/BaseInput";
+
+const InputField = ({ id, label, value, onChange, errorMessage, ...props }) => {
+ return (
+
+
+
+ {errorMessage}
+
+ );
+};
+
+export default memo(InputField);
+
+const InputSection = styled.div`
+ margin: 2rem 0;
+`;
+
+const Label = styled.label`
+ display: inline-block;
+ margin-bottom: 1rem;
+ font-weight: bold;
+ line-height: 3.2rem;
+ color: var(--gray900);
+ font-size: 1.8rem;
+`;
+
+const ErrorMessage = styled.p`
+ color: red;
+ font-size: 1.4rem;
+`;
diff --git a/src/components/TextareaField.jsx b/src/components/TextareaField.jsx
new file mode 100644
index 00000000..0a00601b
--- /dev/null
+++ b/src/components/TextareaField.jsx
@@ -0,0 +1,40 @@
+import { memo } from "react";
+import styled from "@emotion/styled";
+import BaseTextarea from "@components/BaseTextarea";
+
+const TextareaField = ({
+ id,
+ label,
+ value,
+ onChange,
+ errorMessage,
+ ...props
+}) => {
+ return (
+
+
+
+ {errorMessage}
+
+ );
+};
+
+export default memo(TextareaField);
+
+const InputSection = styled.div`
+ margin: 2rem 0;
+`;
+
+const Label = styled.label`
+ display: inline-block;
+ margin-bottom: 1rem;
+ font-weight: bold;
+ line-height: 3.2rem;
+ color: var(--gray900);
+ font-size: 1.8rem;
+`;
+
+const ErrorMessage = styled.p`
+ color: red;
+ font-size: 1.4rem;
+`;
diff --git a/src/hooks/useDebounce.js b/src/hooks/useDebounce.js
new file mode 100644
index 00000000..811c4e73
--- /dev/null
+++ b/src/hooks/useDebounce.js
@@ -0,0 +1,17 @@
+import { useState, useEffect } from "react";
+
+export const useDebounce = (value, delay) => {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+};
diff --git a/src/layouts/Header.jsx b/src/layouts/Header.jsx
index a46772b0..60e9e8ce 100644
--- a/src/layouts/Header.jsx
+++ b/src/layouts/Header.jsx
@@ -1,11 +1,14 @@
-import { Link } from "react-router-dom";
-import { NavLink } from "react-router-dom";
+import { Link, NavLink, useLocation } from "react-router-dom";
import styled from "@emotion/styled";
import Logo from "/logo@2x.png";
import Profile from "/profile@3x.png";
import { breakpoints } from "@constants/breakpoints";
const Header = () => {
+ const location = useLocation();
+ const isMarketplaceActive =
+ location.pathname.startsWith("/items") || location.pathname === "/additem";
+
return (
@@ -14,7 +17,12 @@ const Header = () => {
자유게시판
- 중고마켓
+
+ 중고마켓
+
diff --git a/src/pages/add-item-page/components/ItemDescriptionTextareaField.jsx b/src/pages/add-item-page/components/ItemDescriptionTextareaField.jsx
new file mode 100644
index 00000000..89e9a9ed
--- /dev/null
+++ b/src/pages/add-item-page/components/ItemDescriptionTextareaField.jsx
@@ -0,0 +1,28 @@
+import { memo, useRef } from "react";
+import TextareaField from "@components/TextareaField";
+
+const ItemDescriptionInputField = ({ value, onChange }) => {
+ const ref = useRef(null);
+
+ const handleInputChange = (event) => {
+ const textarea = ref.current; // 사용자 입력에 따라 textarea 높이 조절
+ textarea.style.height = "auto"; // 지워졌을때 다시 크기가 줄어들기
+ textarea.style.height = `${textarea.scrollHeight}px`; // scrollHeight만큼 다시 설정
+
+ onChange(event.target.value);
+ };
+
+ return (
+
+ );
+};
+
+export default memo(ItemDescriptionInputField);
diff --git a/src/pages/add-item-page/components/ItemImageInputField.jsx b/src/pages/add-item-page/components/ItemImageInputField.jsx
new file mode 100644
index 00000000..117332f6
--- /dev/null
+++ b/src/pages/add-item-page/components/ItemImageInputField.jsx
@@ -0,0 +1,16 @@
+import { memo } from "react";
+import ImageInputField from "@components/ImageInputField";
+
+const ItemNameInputField = ({ imageUrl, onChange, onDelete }) => {
+ return (
+
+ );
+};
+
+export default memo(ItemNameInputField);
diff --git a/src/pages/add-item-page/components/ItemNameInputField.jsx b/src/pages/add-item-page/components/ItemNameInputField.jsx
new file mode 100644
index 00000000..1e83e5d2
--- /dev/null
+++ b/src/pages/add-item-page/components/ItemNameInputField.jsx
@@ -0,0 +1,22 @@
+import { memo } from "react";
+import InputField from "@components/InputField";
+
+const ItemNameInputField = ({ value, onChange }) => {
+ const handleInputChange = (e) => {
+ onChange(e.target.value);
+ };
+
+ return (
+
+ );
+};
+
+export default memo(ItemNameInputField);
diff --git a/src/pages/add-item-page/components/ItemPriceInputField.jsx b/src/pages/add-item-page/components/ItemPriceInputField.jsx
new file mode 100644
index 00000000..ccb8a628
--- /dev/null
+++ b/src/pages/add-item-page/components/ItemPriceInputField.jsx
@@ -0,0 +1,28 @@
+import { memo } from "react";
+import InputField from "@components/InputField";
+
+const ItemPriceInputField = ({ value, onChange }) => {
+ const formatNumber = (num) => {
+ return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
+ };
+
+ const handleInputChange = (e) => {
+ const numericValue = e.target.value.replace(/[^\d]/g, "");
+ onChange(numericValue);
+ };
+
+ return (
+
+ );
+};
+
+export default memo(ItemPriceInputField);
diff --git a/src/pages/add-item-page/components/ItemTagInputField.jsx b/src/pages/add-item-page/components/ItemTagInputField.jsx
new file mode 100644
index 00000000..2ae0e122
--- /dev/null
+++ b/src/pages/add-item-page/components/ItemTagInputField.jsx
@@ -0,0 +1,23 @@
+import { memo } from "react";
+import InputField from "@components/InputField";
+
+const ItemTagInputField = ({ value, onChange, onKeyUp }) => {
+ const handleInputChange = (e) => {
+ onChange(e.target.value);
+ };
+
+ return (
+
+ );
+};
+
+export default memo(ItemTagInputField);
diff --git a/src/pages/add-item-page/hooks/useFormValidation.js b/src/pages/add-item-page/hooks/useFormValidation.js
new file mode 100644
index 00000000..94abad66
--- /dev/null
+++ b/src/pages/add-item-page/hooks/useFormValidation.js
@@ -0,0 +1,19 @@
+import { useEffect } from "react";
+
+export const useFormValidation = (
+ itemName,
+ itemDescription,
+ itemPrice,
+ itemTags,
+ setBtnAvailable
+) => {
+ useEffect(() => {
+ const isValid =
+ itemName.trim() !== "" &&
+ itemDescription.trim() !== "" &&
+ itemPrice.trim() !== "" &&
+ itemTags.size > 0;
+
+ setBtnAvailable(isValid);
+ }, [itemName, itemDescription, itemPrice, itemTags, setBtnAvailable]);
+};
diff --git a/src/pages/add-item-page/hooks/useImageHandler.js b/src/pages/add-item-page/hooks/useImageHandler.js
new file mode 100644
index 00000000..b0fde1e6
--- /dev/null
+++ b/src/pages/add-item-page/hooks/useImageHandler.js
@@ -0,0 +1,28 @@
+import { useCallback } from "react";
+import { toast } from "react-toastify";
+
+const IMAGE_ALREADY_EXISTS_MESSAGE = "이미지 등록은 최대 1개까지 가능합니다.";
+
+export const useImageHandler = (setImageUrl) => {
+ const handleImageChange = useCallback(
+ (e) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ const imageUrl = URL.createObjectURL(file);
+ setImageUrl((prevUrl) => {
+ if (prevUrl) {
+ toast(IMAGE_ALREADY_EXISTS_MESSAGE);
+ }
+ return imageUrl;
+ });
+ }
+ },
+ [setImageUrl]
+ );
+
+ const handleImageDelete = useCallback(() => {
+ setImageUrl("");
+ }, [setImageUrl]);
+
+ return { handleImageChange, handleImageDelete };
+};
diff --git a/src/pages/add-item-page/hooks/useSubmitHandler.js b/src/pages/add-item-page/hooks/useSubmitHandler.js
new file mode 100644
index 00000000..6e4dbf74
--- /dev/null
+++ b/src/pages/add-item-page/hooks/useSubmitHandler.js
@@ -0,0 +1,26 @@
+import { useCallback } from "react";
+
+export const useSubmitHandler = (formState) => {
+ const preventSubmitOnEnter = useCallback((e) => {
+ // enter로 폼 제출 방지 (태그 생성시 enter로 구분하기 때문이다. 단 textarea에서는 줄바꿈을 허용한다.)
+ if (e.key === "Enter" && e.target.tagName !== "TEXTAREA") {
+ e.preventDefault();
+ }
+ }, []);
+
+ const submitForm = useCallback(
+ (e) => {
+ e.preventDefault();
+ console.log({
+ itemImage: formState.itemImage,
+ itemName: formState.itemName,
+ itemDescription: formState.itemDescription,
+ itemPrice: formState.itemPrice,
+ tags: Array.from(formState.itemTags),
+ });
+ },
+ [formState]
+ );
+
+ return { preventSubmitOnEnter, submitForm };
+};
diff --git a/src/pages/add-item-page/hooks/useTagHandler.js b/src/pages/add-item-page/hooks/useTagHandler.js
new file mode 100644
index 00000000..cce74605
--- /dev/null
+++ b/src/pages/add-item-page/hooks/useTagHandler.js
@@ -0,0 +1,48 @@
+import { useCallback } from "react";
+import { toast } from "react-toastify";
+
+const TAG_ALREADY_EXISTS_MESSAGE = "이미 추가된 태그입니다.";
+
+export const useTagHandler = (tag, setTag, itemTags, setItemTags) => {
+ const addToTag = useCallback(
+ (tagValue) => {
+ if (itemTags.has(tagValue)) {
+ toast(TAG_ALREADY_EXISTS_MESSAGE);
+ }
+
+ setItemTags((prev) => {
+ const updatedTags = new Set(prev);
+ updatedTags.add(tagValue);
+ return updatedTags;
+ });
+
+ setTag("");
+ },
+ [itemTags, setItemTags, setTag]
+ );
+
+ const handleTagsKeyUp = useCallback(
+ (e) => {
+ if (e.key === "Enter" && tag.trim() !== "") {
+ addToTag(tag.trim());
+ }
+ },
+ [tag, addToTag]
+ );
+
+ const deleteTag = useCallback(
+ (tagToDelete) => {
+ setItemTags((prev) => {
+ const updatedTags = new Set(prev);
+ updatedTags.delete(tagToDelete);
+ return updatedTags;
+ });
+ },
+ [setItemTags]
+ );
+
+ return {
+ handleTagsKeyUp,
+ deleteTag,
+ };
+};
diff --git a/src/pages/add-item-page/index.jsx b/src/pages/add-item-page/index.jsx
index 3b087902..eb066f02 100644
--- a/src/pages/add-item-page/index.jsx
+++ b/src/pages/add-item-page/index.jsx
@@ -1,11 +1,80 @@
-import styled from "@emotion/styled";
-
-const Title = styled.h1`
- color: var(--gray800);
-`;
+import { useState } from "react";
+import BaseForm from "@components/BaseForm";
+import HeaderSection from "@pages/add-item-page/sections/HeaderSection";
+import ItemImageInputField from "@pages/add-item-page/components/ItemImageInputField";
+import ItemNameInputField from "@pages/add-item-page/components/ItemNameInputField";
+import ItemDescriptionTextareaField from "@pages/add-item-page/components/ItemDescriptionTextareaField";
+import ItemPriceInputField from "@pages/add-item-page/components/ItemPriceInputField";
+import ItemTagInputField from "@pages/add-item-page/components/ItemTagInputField";
+import TagsSection from "@pages/add-item-page/sections/TagsSection";
+import { useImageHandler } from "@pages/add-item-page/hooks/useImageHandler";
+import { useTagHandler } from "@pages/add-item-page/hooks/useTagHandler";
+import { useFormValidation } from "@pages/add-item-page/hooks/useFormValidation";
+import { useSubmitHandler } from "@pages/add-item-page/hooks/useSubmitHandler";
const AddItemPage = () => {
- return add item;
+ const [btnAvailable, setBtnAvailable] = useState(false);
+ const [itemImage, setItemImage] = useState(null);
+ const [itemName, setItemName] = useState("");
+ const [itemDescription, setItemDescription] = useState("");
+ const [itemPrice, setItemPrice] = useState("");
+ const [tag, setTag] = useState("");
+ const [itemTags, setItemTags] = useState(new Set());
+
+ const { handleImageChange, handleImageDelete } =
+ useImageHandler(setItemImage);
+
+ const { handleTagsKeyUp, deleteTag } = useTagHandler(
+ tag,
+ setTag,
+ itemTags,
+ setItemTags
+ );
+
+ useFormValidation(
+ itemName,
+ itemDescription,
+ itemPrice,
+ itemTags,
+ setBtnAvailable
+ );
+
+ const formState = {
+ itemImage,
+ itemName,
+ itemDescription,
+ itemPrice,
+ itemTags,
+ };
+ const { preventSubmitOnEnter, submitForm } = useSubmitHandler(formState);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
};
export default AddItemPage;
diff --git a/src/pages/add-item-page/sections/HeaderSection.jsx b/src/pages/add-item-page/sections/HeaderSection.jsx
new file mode 100644
index 00000000..ecbc3d99
--- /dev/null
+++ b/src/pages/add-item-page/sections/HeaderSection.jsx
@@ -0,0 +1,42 @@
+import { memo } from "react";
+import styled from "@emotion/styled";
+
+const HeaderSection = ({ btnAvailable }) => {
+ return (
+
+ 상품 등록하기
+ 등록
+
+ );
+};
+
+export default memo(HeaderSection);
+
+const HeaderSectionContainer = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid var(--gray100);
+`;
+
+const Title = styled.h1`
+ font-weight: bold;
+ font-size: 2rem;
+ line-height: 3.2rem;
+ color: var(--gray900);
+`;
+
+const SubmitBtn = styled.button`
+ padding: 0.8rem 2rem;
+ border-radius: 1.2rem;
+ border: none;
+ background-color: var(--blue);
+ font-size: 1.6rem;
+ color: var(--white);
+
+ &:disabled {
+ background-color: var(--gray300);
+ cursor: not-allowed;
+ }
+`;
diff --git a/src/pages/add-item-page/sections/TagsSection.jsx b/src/pages/add-item-page/sections/TagsSection.jsx
new file mode 100644
index 00000000..3fed17b2
--- /dev/null
+++ b/src/pages/add-item-page/sections/TagsSection.jsx
@@ -0,0 +1,36 @@
+import styled from "@emotion/styled";
+import DeleteButton from "@components/DeleteButton";
+
+const TagsSection = ({ tags, deleteTag }) => {
+ return (
+
+ {Array.from(tags).map((tag) => (
+
+ #{tag}
+ deleteTag(tag)} size="s" />
+
+ ))}
+
+ );
+};
+
+export default TagsSection;
+
+const TagsContainer = styled.div`
+ margin-top: 1rem;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+`;
+
+const Tag = styled.div`
+ width: fit-content;
+ padding: 0.5rem 1.5rem;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ border-radius: 3rem;
+ background-color: var(--gray100);
+ font-size: 1.4rem;
+`;
diff --git a/src/pages/items-page/components/AllProductionsHeader.jsx b/src/pages/items-page/components/AllProductionsHeader.jsx
index 8725af83..905a4fcd 100644
--- a/src/pages/items-page/components/AllProductionsHeader.jsx
+++ b/src/pages/items-page/components/AllProductionsHeader.jsx
@@ -66,7 +66,9 @@ const AllProductionsHeader = ({
<>
전체 상품
- 상품 등록하기
+
+ 상품 등록하기
+
diff --git a/src/pages/items-page/index.jsx b/src/pages/items-page/index.jsx
index aaf9c029..6c9c7bc7 100644
--- a/src/pages/items-page/index.jsx
+++ b/src/pages/items-page/index.jsx
@@ -6,7 +6,6 @@ const Layout = styled.div`
display: flex;
flex-direction: column;
gap: 4rem;
- margin: 2.5rem 0;
`;
const ItemsPage = () => {