diff --git a/package-lock.json b/package-lock.json index 02ab4f7e..06937e9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@vitejs/plugin-react": "^4.3.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.55.0", "react-router-dom": "^7.5.0", "sass": "^1.86.3", "web-vitals": "^2.1.4" @@ -3013,6 +3014,22 @@ "react": "^18.2.0" } }, + "node_modules/react-hook-form": { + "version": "7.55.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.55.0.tgz", + "integrity": "sha512-XRnjsH3GVMQz1moZTW53MxfoWN7aDpUg/GpVNc4A3eXRVNdGXfbzJ4vM4aLQ8g6XCUh1nIbx70aaNCl7kxnjog==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index bc90c7b4..b43d0b99 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@vitejs/plugin-react": "^4.3.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.55.0", "react-router-dom": "^7.5.0", "sass": "^1.86.3", "web-vitals": "^2.1.4" diff --git a/src/App.jsx b/src/App.jsx index 52b1bcff..5f1e45b5 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,6 +4,7 @@ import Login from "./pages/Sign/Login"; import Signup from "./pages/Sign/Signup"; import Main from "./pages/main/Main"; import Items from "./pages/Items/Items"; +import AddItem from "./pages/AddItem/AddItem"; function App() { return ( @@ -13,6 +14,7 @@ function App() { } /> } /> } /> + } /> ); diff --git a/src/components/Input/ImageUploader/ImageUploader.jsx b/src/components/Input/ImageUploader/ImageUploader.jsx new file mode 100644 index 00000000..ce89419b --- /dev/null +++ b/src/components/Input/ImageUploader/ImageUploader.jsx @@ -0,0 +1,66 @@ +import React, { useRef, useState } from "react"; +import "./ImageUploader.scss"; +import plusIcon from "../../../images/ic_plus.png"; + +function ImageUploader({ value, onChange }) { + const fileInputRef = useRef(null); + const [error, setError] = useState(""); + + const handleFileChange = (e) => { + const file = e.target.files?.[0]; + + if (value) { + setError("*이미지 등록은 최대 1개까지 가능합니다."); + if (fileInputRef.current) fileInputRef.current.value = ""; + return; + } + + if (file) { + const imageUrl = URL.createObjectURL(file); + onChange({ file, preview: imageUrl }); + setError(""); + } + }; + + const handleRemove = () => { + onChange(null); + setError(""); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + return ( +
+ +
+
+ + + + 이미지 등록 + +
+ {value && ( +
+ 미리보기 + +
+ )} +
+ {error &&

{error}

} +
+ ); +} + +export default ImageUploader; diff --git a/src/components/Input/ImageUploader/ImageUploader.scss b/src/components/Input/ImageUploader/ImageUploader.scss new file mode 100644 index 00000000..43446428 --- /dev/null +++ b/src/components/Input/ImageUploader/ImageUploader.scss @@ -0,0 +1,75 @@ +@charset 'uft-8'; + +.image-uploader { + .image-preview-container { + display: flex; + gap: 24px; + + .upload-box { + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 282px; + height: 282px; + background: var(--color-gray100); + border-radius: 12px; + color: var(--color-gray400); + font-size: var(--text-lg); + + input { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + cursor: pointer; + opacity: 0; + } + } + + .upload-image { + position: relative; + width: 282px; + height: 282px; + + img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 12px; + } + + .el-btn.btn-remove { + position: absolute; + top: 12px; + right: 12px; + } + } + } + + .error-msg { + margin-top: 16px; + font-size: var(--text-lg); + color: var(--color-error); + } +} + +@media (width < 1200px) { + .image-uploader { + .image-preview-container { + gap: 10px; + + .upload-box { + width: 168px; + height: 168px; + } + + .upload-image { + width: 168px; + height: 168px; + } + } + } +} diff --git a/src/components/Input/PwInput.jsx b/src/components/Input/PwInput.jsx deleted file mode 100644 index 48b97a85..00000000 --- a/src/components/Input/PwInput.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import React, { useRef, useState } from "react"; - -function PwInput({ - id, - name, - label, - placeholder, - value, - error, - onChange, - handleValidate, -}) { - const inputRef = useRef(null); - const [visible, setVisible] = useState(false); - - const handleBlur = (e) => { - const { name, value } = e.target; - - handleValidate(name, value); - }; - - const togglePasswordVisible = () => { - if (inputRef.current) { - const nextVisible = !visible; - inputRef.current.type = visible ? "password" : "text"; - setVisible(nextVisible); - } - }; - - return ( -
- -
- - -
- {error &&

{error}

} -
- ); -} - -export default PwInput; diff --git a/src/components/Input/SearchInput.jsx b/src/components/Input/SearchInput.jsx deleted file mode 100644 index 569c3bf5..00000000 --- a/src/components/Input/SearchInput.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from "react"; - -function SearchInput({ placeholder, value, onChange }) { - return ( -
- -
- ); -} - -export default SearchInput; diff --git a/src/components/Input/TageInput/TagInput.jsx b/src/components/Input/TageInput/TagInput.jsx new file mode 100644 index 00000000..d49ad1a4 --- /dev/null +++ b/src/components/Input/TageInput/TagInput.jsx @@ -0,0 +1,50 @@ +import React, { useState } from "react"; +import "./TagInput.scss"; + +function TagInput({ tags, onChange }) { + const [inputValue, setInputValue] = useState(""); + + const handleKeyDown = (e) => { + if ((e.key === "Enter" || e.key === ",") && inputValue.trim()) { + e.preventDefault(); + const newTag = inputValue.trim(); + if (!tags.includes(newTag)) { + onChange([...tags, newTag]); + } + setInputValue(""); + } + }; + + const removeTag = (indexToRemove) => { + const updatedTag = tags.filter((_, index) => index !== indexToRemove); + onChange(updatedTag); + }; + + return ( +
+ +
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+
+ {tags.map((tag, index) => ( +
+ {tag} + +
+ ))} +
+
+ ); +} + +export default TagInput; diff --git a/src/components/Input/TageInput/TagInput.scss b/src/components/Input/TageInput/TagInput.scss new file mode 100644 index 00000000..a0f87730 --- /dev/null +++ b/src/components/Input/TageInput/TagInput.scss @@ -0,0 +1,30 @@ +@charset 'uft-8'; + +.tag-input-container { + .tag-list { + margin-top: 14px; + display: flex; + align-items: center; + gap: 12px; + + .tag-item { + position: relative; + background: var(--color-gray100); + border-radius: 26px; + padding: 0 42px 0 16px; + font-size: var(--text-lg); + color: var(--color-gray800); + line-height: 36px; + + &::before { + content: "#"; + } + + .el-btn.btn-remove { + top: 50%; + right: 12px; + transform: translateY(-50%); + } + } + } +} diff --git a/src/components/Input/TextFiled.jsx b/src/components/Input/TextFiled.jsx new file mode 100644 index 00000000..d7d59408 --- /dev/null +++ b/src/components/Input/TextFiled.jsx @@ -0,0 +1,82 @@ +import React, { useRef, useState } from "react"; + +function TextFiled({ + id, + name, + label, + type = "text", + placeholder, + value, + visibleBtn = false, + error, + onChange, + onBlur, + onSearch, +}) { + const inputRef = useRef(null); + const [visible, setVisible] = useState(false); + + const togglePasswordVisible = () => { + if (inputRef.current) { + const nextVisible = !visible; + inputRef.current.type = visible ? "password" : "text"; + setVisible(nextVisible); + } + }; + + return ( +
+ {label && ( + + )} +
+ {type === "search" && ( + + )} + {type === "textarea" ? ( + + ) : ( + + )} + {visibleBtn && ( + + )} +
+ + {error &&

{error}

} +
+ ); +} + +export default TextFiled; diff --git a/src/components/Input/TextInput.jsx b/src/components/Input/TextInput.jsx deleted file mode 100644 index 134e89c2..00000000 --- a/src/components/Input/TextInput.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from "react"; - -function TextInput({ - id, - name, - label, - type, - placeholder, - value, - error, - onChange, - handleValidate, -}) { - const handleBlur = (e) => { - const { name, value } = e.target; - - handleValidate(name, value); - }; - return ( -
- - - {error &&

{error}

} -
- ); -} - -export default TextInput; diff --git a/src/components/Items/AllItems.jsx b/src/components/Items/AllItems.jsx index dedb2bcb..6ef96c9b 100644 --- a/src/components/Items/AllItems.jsx +++ b/src/components/Items/AllItems.jsx @@ -1,70 +1,92 @@ import React, { useEffect, useState } from "react"; -import SearchInput from "../Input/SearchInput"; -import { Link } from "react-router-dom"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; import OrderSelect from "../OrderSelect/OrderSelect"; import ItemList from "./ItemList"; import Pagenation from "../Pagenation/Pagenation"; import { getItems } from "../../api/api"; import "./ItemComponent.scss"; +import useItemFetcher from "../../hooks/useItemFetcher"; +import TextFiled from "../Input/TextFiled"; -function AllItems({ itemCount, onLoading, onError }) { - const [items, setItems] = useState([]); - const [search, setSearch] = useState(""); - const [order, setOrder] = useState("recent"); - const [page, setPage] = useState(1); +function AllItems({ itemCount }) { + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + + const searchQuery = searchParams.get("search") || ""; + const order = searchParams.get("order") || "recent"; + const page = parseInt(searchParams.get("page") || 1, 10); + + const [localSearch, setLocalSearch] = useState(searchQuery); + + const params = { search: searchQuery, order, page, pageSize: itemCount }; + const { items, isLoading, error } = useItemFetcher(getItems, params); + + useEffect(() => { + setLocalSearch(searchQuery); + }, [searchQuery]); const handleSearch = (e) => { - const { value } = e.target; - setSearch(value); + e.preventDefault(); + setSearchParams({ + search: localSearch, + order, + page: 1, + }); }; const handleOrder = (value) => { - setOrder(value); + setSearchParams({ + search: searchQuery, + order: value, + page: 1, + }); }; - const handleGetItmes = async () => { - try { - const result = await getItems({ - search, - order, - page, - pageSize: itemCount, - }); - setItems(result); - onLoading(false); - } catch (error) { - onLoading(false); - onError(error.message); - } + const handlePageChange = (newPage) => { + setSearchParams({ + search: searchQuery, + order, + page: newPage, + }); }; - useEffect(() => { - handleGetItmes(); - }, [search, order, page, itemCount]); + if (error) { + alert("전체 상품 로딩 에러"); + } return (

전체 상품

- +
+ setLocalSearch(e.target.value)} + /> + + 상품 등록하기
- - + {isLoading ? ( +
로딩중....
+ ) : ( + <> + + + + )}
); } diff --git a/src/components/Items/BestItems.jsx b/src/components/Items/BestItems.jsx index da29947b..107fd96e 100644 --- a/src/components/Items/BestItems.jsx +++ b/src/components/Items/BestItems.jsx @@ -2,29 +2,22 @@ import React, { useEffect, useState } from "react"; import ItemList from "./ItemList"; import { getBestItems } from "../../api/api"; import "./ItemComponent.scss"; +import useItemFetcher from "../../hooks/useItemFetcher"; +import Loading from "../Loading/Loading"; -function BestItems({ itemCount, onLoading, onError }) { - const [items, setItems] = useState([]); +function BestItems({ itemCount }) { + const { items, isLoading, error } = useItemFetcher(getBestItems); - const handleGetBestItem = async () => { - try { - const result = await getBestItems(); - setItems(result); - onLoading(false); - } catch (error) { - onLoading(false); - onError(error.message); - } - }; + if (isLoading) return ; - useEffect(() => { - handleGetBestItem(); - }, []); + if (error) { + alert("베스트 상품 로딩 에러"); + } return (

베스트 상품

- +
); } diff --git a/src/hooks/useItemFetcher.jsx b/src/hooks/useItemFetcher.jsx new file mode 100644 index 00000000..05ab32a7 --- /dev/null +++ b/src/hooks/useItemFetcher.jsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; + +function useItemFetcher(fetchFunction, params = {}) { + const [items, setItems] = useState([]); + const [isLoading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchItems = async () => { + setLoading(true); + setError(null); + try { + const result = await fetchFunction(params); + setItems(result); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchItems(); + }, [JSON.stringify(params)]); + + return { + items, + isLoading, + error, + }; +} + +export default useItemFetcher; diff --git a/src/utils/useResponsiveCount.jsx b/src/hooks/useResponsiveCount.jsx similarity index 100% rename from src/utils/useResponsiveCount.jsx rename to src/hooks/useResponsiveCount.jsx diff --git a/src/images/ic_X.png b/src/images/ic_X.png new file mode 100644 index 00000000..d017fd6f Binary files /dev/null and b/src/images/ic_X.png differ diff --git a/src/images/ic_plus.png b/src/images/ic_plus.png new file mode 100644 index 00000000..71105221 Binary files /dev/null and b/src/images/ic_plus.png differ diff --git a/src/pages/AddItem/AddItem.jsx b/src/pages/AddItem/AddItem.jsx new file mode 100644 index 00000000..db6bc50f --- /dev/null +++ b/src/pages/AddItem/AddItem.jsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import SubHeader from "../../components/Header/SubHeader/SubHeader"; +import TextFiled from "../../components/Input/TextFiled"; +import ImageUploader from "../../components/Input/ImageUploader/ImageUploader"; +import TagInput from "../../components/Input/TageInput/TagInput"; +import "./AddItem.scss"; + +function AddItem() { + const [imageData, setImageData] = useState(null); + const [values, setValues] = useState({ + productName: "", + productInfo: "", + price: "", + tags: [], + }); + const isFormValid = Object.values({ + productName: values.productName.trim(), + productInfo: values.productInfo.trim(), + price: values.price.trim(), + tags: values.tags.length ? true : false, + }).every(Boolean); + + const handleSubmit = (e) => { + e.preventDefault(); + }; + + const handleInputChange = (eOrValue, name, isDirectValue = false) => { + const value = isDirectValue ? eOrValue : eOrValue.target.value; + setValues((prev) => { + return { + ...prev, + [name]: value, + }; + }); + }; + + return ( +
+ +
+
+
+

상품 등록하기

+ +
+ + handleInputChange(e, "productName")} + /> + handleInputChange(e, "productInfo")} + /> + handleInputChange(e, "price")} + /> + handleInputChange(newTags, "tags", true)} + /> + +
+
+ ); +} + +export default AddItem; diff --git a/src/pages/AddItem/AddItem.scss b/src/pages/AddItem/AddItem.scss new file mode 100644 index 00000000..ed172786 --- /dev/null +++ b/src/pages/AddItem/AddItem.scss @@ -0,0 +1,43 @@ +@charset 'uft-8'; + +.form-additem { + .form-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + + .title { + font-weight: bold; + font-size: var(--text-xl); + } + + .el-btn.btn-s { + width: 74px; + } + } + + .image-uploader { + margin-bottom: 32px; + + &.error { + margin-bottom: 24px; + } + } + + .el-txt-input + .el-txt-input { + margin-top: 32px; + } +} + +@media (width < 1200px) { + .form-additem { + .image-uploader { + margin-bottom: 24px; + } + + .el-txt-input + .el-txt-input { + margin-top: 24px; + } + } +} diff --git a/src/pages/Items/Items.jsx b/src/pages/Items/Items.jsx index af7c397a..ebce58b4 100644 --- a/src/pages/Items/Items.jsx +++ b/src/pages/Items/Items.jsx @@ -3,16 +3,10 @@ import SubHeader from "../../components/Header/SubHeader/SubHeader"; import BestItems from "../../components/Items/BestItems"; import AllItems from "../../components/Items/AllItems"; import "./Items.scss"; -import useResponsiveCount from "../../utils/useResponsiveCount"; +import useResponsiveCount from "../../hooks/useResponsiveCount"; import Loading from "../../components/Loading/Loading"; function Items() { - const [bestLoading, setBestLoading] = useState(true); - const [allLoading, setAllLoading] = useState(true); - const [error, setError] = useState(null); - - const isLoading = bestLoading || allLoading; - const allItemVisible = useResponsiveCount( { desktop: 10, @@ -30,27 +24,12 @@ function Items() { 4 ); - useEffect(() => { - if (error) { - alert("오류가 발생했습니다."); - } - }, [error]); - return (
- {isLoading && } - - + +
); diff --git a/src/pages/Items/Items.scss b/src/pages/Items/Items.scss index e8ff931f..7893c724 100644 --- a/src/pages/Items/Items.scss +++ b/src/pages/Items/Items.scss @@ -2,7 +2,6 @@ .contents { .items-wrap { - padding-bottom: 58px; &.best { padding-bottom: 40px; .title { @@ -20,14 +19,6 @@ } } -@media (width < 1200px) { - .contents { - .items-wrap { - padding-bottom: 70px; - } - } -} - @media (width < 768px) { .contents { .items-wrap { diff --git a/src/pages/Sign/Login.jsx b/src/pages/Sign/Login.jsx index 2e4252e5..bb3a6ed6 100644 --- a/src/pages/Sign/Login.jsx +++ b/src/pages/Sign/Login.jsx @@ -1,7 +1,6 @@ import React, { useEffect, useState } from "react"; import BigLogo from "../../components/BigLogo/BigLogo"; -import TextInput from "../../components/Input/TextInput"; -import PwInput from "../../components/Input/PwInput"; +import TextFiled from "../../components/Input/TextFiled"; import SNSLogin from "../../components/SNSLogin/SNSLogin"; import { Link, useNavigate } from "react-router-dom"; import "./Sign.scss"; @@ -16,7 +15,9 @@ function Login() { email: "", password: "", }); - const [isValid, setIsValid] = useState(false); + const hasError = Object.values(error).some(Boolean); + const hasEmpty = Object.values(form).some((val) => val === ""); + const isValid = !hasEmpty && !hasError; const navigate = useNavigate(); const handleChange = (e) => { @@ -28,6 +29,12 @@ function Login() { })); }; + const handleBlur = (e) => { + const { name, value } = e.target; + + handleValidate(name, value); + }; + const handleValidate = (name, value) => { let errMsg = ""; @@ -50,13 +57,6 @@ function Login() { })); }; - useEffect(() => { - const hasError = Object.values(error).some(Boolean); - const hasEmpty = Object.values(form).some((val) => val === ""); - - setIsValid(!hasError && !hasEmpty); - }, [form, error]); - const handleSubmit = (e) => { e.preventDefault(); if (!isValid) return; @@ -70,7 +70,7 @@ function Login() {
- -