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