diff --git a/package-lock.json b/package-lock.json
index 4aab955e..8841632b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,10 +15,14 @@
"react-dom": "^18.2.0",
"react-router-dom": "^7.6.0",
"react-scripts": "5.0.1",
- "web-vitals": "^2.1.4"
+ "web-vitals": "^2.1.4",
+ "zustand": "^5.0.6"
},
"devDependencies": {
- "eslint-config-prettier": "^10.1.5"
+ "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
+ "@types/node": "^24.0.14",
+ "eslint-config-prettier": "^10.1.5",
+ "typescript": "^4.9.5"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -634,9 +638,17 @@
}
},
"node_modules/@babel/plugin-proposal-private-property-in-object": {
- "version": "7.21.0-placeholder-for-preset-env.2",
- "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
- "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==",
+ "version": "7.21.11",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz",
+ "integrity": "sha512-0QZ8qP/3RLDVBwBFoWAwCtgcDZJVwA5LUJRZU8x2YFfKNuFq161wK3cuGrALu5yiPu+vzwTAg/sMWVNeWeNyaw==",
+ "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-property-in-object instead.",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.18.6",
+ "@babel/helper-create-class-features-plugin": "^7.21.0",
+ "@babel/helper-plugin-utils": "^7.20.2",
+ "@babel/plugin-syntax-private-property-in-object": "^7.14.5"
+ },
"engines": {
"node": ">=6.9.0"
},
@@ -1879,6 +1891,17 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/preset-env/node_modules/@babel/plugin-proposal-private-property-in-object": {
+ "version": "7.21.0-placeholder-for-preset-env.2",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
+ "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==",
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
"node_modules/@babel/preset-env/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -3564,22 +3587,22 @@
}
},
"node_modules/@testing-library/dom": {
- "version": "9.3.1",
- "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.1.tgz",
- "integrity": "sha512-0DGPd9AR3+iDTjGoMpxIkAsUihHZ3Ai6CneU6bRRrffXMgzCdlNk43jTrD2/5LT6CBb3MWTP8v510JzYtahD2w==",
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
+ "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
- "aria-query": "5.1.3",
+ "aria-query": "5.3.0",
"chalk": "^4.1.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"pretty-format": "^27.0.2"
},
"engines": {
- "node": ">=14"
+ "node": ">=18"
}
},
"node_modules/@testing-library/dom/node_modules/ansi-styles": {
@@ -3597,15 +3620,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/@testing-library/dom/node_modules/aria-query": {
- "version": "5.1.3",
- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
- "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
- "peer": true,
- "dependencies": {
- "deep-equal": "^2.0.5"
- }
- },
"node_modules/@testing-library/dom/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -4301,9 +4315,12 @@
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw=="
},
"node_modules/@types/node": {
- "version": "20.5.9",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.9.tgz",
- "integrity": "sha512-PcGNd//40kHAS3sTlzKB9C9XL4K0sTup8nbG5lC14kzEteTNuAFh9u5nA0o5TWnSG2r/JNPRXFVcHJIIeRlmqQ=="
+ "version": "24.0.14",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.14.tgz",
+ "integrity": "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw==",
+ "dependencies": {
+ "undici-types": "~7.8.0"
+ }
},
"node_modules/@types/parse-json": {
"version": "4.0.0",
@@ -5842,9 +5859,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001528",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001528.tgz",
- "integrity": "sha512-0Db4yyjR9QMNlsxh+kKWzQtkyflkG/snYheSzkjmvdEtEXB1+jt7A2HmSEiO6XIJPIbo92lHNGNySvE5pZcs5Q==",
+ "version": "1.0.30001727",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
+ "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
"funding": [
{
"type": "opencollective",
@@ -16700,7 +16717,6 @@
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16728,6 +16744,11 @@
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz",
"integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw=="
},
+ "node_modules/undici-types": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
+ "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="
+ },
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz",
@@ -17880,6 +17901,34 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.6.tgz",
+ "integrity": "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A==",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index cacba980..33b68aa1 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,8 @@
"react-dom": "^18.2.0",
"react-router-dom": "^7.6.0",
"react-scripts": "5.0.1",
- "web-vitals": "^2.1.4"
+ "web-vitals": "^2.1.4",
+ "zustand": "^5.0.6"
},
"scripts": {
"start": "react-scripts start",
@@ -37,6 +38,9 @@
]
},
"devDependencies": {
- "eslint-config-prettier": "^10.1.5"
+ "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
+ "@types/node": "^24.0.14",
+ "eslint-config-prettier": "^10.1.5",
+ "typescript": "^4.9.5"
}
}
diff --git a/src/App.js b/src/App.js
index 9aad1263..b1fa8e68 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,5 +1,6 @@
import { Outlet } from "react-router-dom";
import Nav from "./components/Nav";
+import Footer from "./components/Footer";
function App() {
return (
@@ -8,6 +9,7 @@ function App() {
+
>
);
}
diff --git a/src/Main.js b/src/Main.js
index 72271d8e..d4be3d3b 100644
--- a/src/Main.js
+++ b/src/Main.js
@@ -6,17 +6,23 @@ import "./css/base/common.css";
import ItemsPage from "./pages/ItemsPage";
import ItemRegisterPage from "./pages/ItemRegisterPage";
import ItemDetailPage from "./pages/ItemDetailPage";
+import HomePage from "./pages/HomePage";
+import SignupPage from "./pages/SignupPage";
+import LoginPage from "./pages/LoginPage";
const Main = () => {
return (
}>
+ } />
}>
}>
}>
+ }>
+ }>
diff --git a/src/components/Button.js b/src/components/Button.tsx
similarity index 70%
rename from src/components/Button.js
rename to src/components/Button.tsx
index 72939e5f..3b2f045f 100644
--- a/src/components/Button.js
+++ b/src/components/Button.tsx
@@ -1,13 +1,22 @@
import "./css/Button.css";
import backIcon from "../img/back.svg";
+import { ReactNode } from "react";
+
+interface ButtonProps {
+ className?: string;
+ type?: "register" | "return" | "cancel" | "small" | "large";
+ disabled?: boolean;
+ children?: ReactNode;
+ onClick?: () => void;
+}
const Button = ({
- className,
- type,
- disabled,
+ className = "",
+ type = "small",
+ disabled = false,
children,
onClick = () => {},
-}) => {
+}: ButtonProps) => {
const btnStyleClass = {
register: "btn-register",
return: "btn-return",
diff --git a/src/components/Comment.js b/src/components/Comment.js
index beb32052..10304fbe 100644
--- a/src/components/Comment.js
+++ b/src/components/Comment.js
@@ -11,7 +11,7 @@ const Comment = ({ data }) => {
const [showDropdown, setShowDropdown] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
- const onClickDropdown = () => {
+ const toggleDropdown = () => {
setShowDropdown(!showDropdown);
};
@@ -20,7 +20,7 @@ const Comment = ({ data }) => {
{ name: "삭제하기", value: "delete" },
];
- const onClickDropdownItem = (item) => {
+ const handleDropdownItemClick = (item) => {
// 동작 실행 후 닫기
switch (item.value) {
case "edit":
@@ -52,8 +52,8 @@ const Comment = ({ data }) => {
{content}
{},
dropdownList,
showDropdown,
value,
}) => {
const dropdownRef = useRef(null);
-
- // 외부 클릭 시 드롭다운 닫기
- useEffect(() => {
- const handleClickOutside = (e) => {
- if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
- onCloseDropdown();
- }
- };
- window.addEventListener("click", handleClickOutside);
- return () => window.removeEventListener("click", handleClickOutside);
- }, [onCloseDropdown]);
+ useClickOutside(dropdownRef, onCloseDropdown);
return (
{
- const { value, onClickDropdown } = useContext(DropdownContext);
+ const { value, onToggleDropdown } = useContext(DropdownContext);
return (
-
+
{value.name}

{
};
const List = ({ listClassName = "" }) => {
- const { showDropdown, dropdownList, onClickDropdownItem } =
+ const { showDropdown, dropdownList, onDropdownItemClick } =
useContext(DropdownContext);
if (!showDropdown) return null;
return (
@@ -70,7 +61,7 @@ const List = ({ listClassName = "" }) => {
{dropdownList?.map((item, index) => (
onClickDropdownItem(item)}
+ onClick={() => onDropdownItemClick(item)}
>
{item.name}
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
new file mode 100644
index 00000000..987001a7
--- /dev/null
+++ b/src/components/Footer.tsx
@@ -0,0 +1,59 @@
+import facebookLogo from "../img/ic_facebook.svg";
+import twitterLogo from "../img/ic_twitter.svg";
+import youtubeLogo from "../img/ic_youtube.svg";
+import instagramLogo from "../img/ic_instagram.svg";
+import "./css/Footer.css";
+import { Link } from "react-router-dom";
+import SnsLink from "./SnsLink";
+
+const Footer = () => {
+ const snsLinks = [
+ {
+ href: "https://www.facebook.com",
+ logo: facebookLogo,
+ alt: "페이스북 로고",
+ },
+ {
+ href: "https://x.com",
+ logo: twitterLogo,
+ alt: "트위터 로고",
+ },
+ {
+ href: "https://www.youtube.com",
+ logo: youtubeLogo,
+ alt: "유튜브 로고",
+ },
+ {
+ href: "https://www.instagram.com",
+ logo: instagramLogo,
+ alt: "인스타그램 로고",
+ },
+ ];
+
+ return (
+
+ );
+};
+
+export default Footer;
diff --git a/src/components/Label.js b/src/components/Label.js
deleted file mode 100644
index f8d60f27..00000000
--- a/src/components/Label.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import "./css/Label.css";
-
-const Label = ({ children }) => {
- return
;
-};
-
-export default Label;
diff --git a/src/components/Label.tsx b/src/components/Label.tsx
new file mode 100644
index 00000000..08758888
--- /dev/null
+++ b/src/components/Label.tsx
@@ -0,0 +1,17 @@
+import { LabelHTMLAttributes, ReactNode } from "react";
+import "./css/Label.css";
+
+interface LabelProps extends LabelHTMLAttributes
{
+ children?: ReactNode;
+ className?: string;
+}
+
+const Label = ({ children, className, ...rest }: LabelProps) => {
+ return (
+
+ );
+};
+
+export default Label;
diff --git a/src/components/MoreDropdown.js b/src/components/MoreDropdown.js
index 95f9b0c3..d52a2df5 100644
--- a/src/components/MoreDropdown.js
+++ b/src/components/MoreDropdown.js
@@ -4,8 +4,8 @@ import moreIcon from "../img/more.svg";
const MoreDropdown = ({
className,
- onClickDropdown,
- onClickDropdownItem,
+ onToggleDropdown,
+ onDropdownItemClick,
onCloseDropdown,
dropdownList,
showDropdown,
@@ -14,8 +14,8 @@ const MoreDropdown = ({
return (
diff --git a/src/components/Nav.js b/src/components/Nav.js
index 7027986e..84a427ab 100644
--- a/src/components/Nav.js
+++ b/src/components/Nav.js
@@ -1,63 +1,73 @@
-import { Link, NavLink, useLocation } from "react-router-dom";
-import textLogoIcon from "../img/logo_text.jpg";
-import logoIcon from "../img/logo.svg";
-import userIcon from "../img/user.svg";
-import "./css/Nav.css";
-
-const Nav = () => {
- const location = useLocation();
-
- return (
-
-
-
-
-

-
-
- -
-
- isActive ? "header__content__link--active" : ""
- }
- >
- 자유게시판
-
-
-
- -
-
- isActive || location.pathname === "/additem"
- ? "header__content__link--active"
- : ""
- }
- >
- 중고마켓
-
-
-
-
-
-
-

-
-
-
-
- );
-};
-
-export default Nav;
+import { Link, NavLink, useLocation } from "react-router-dom";
+import textLogoIcon from "../img/logo_text.jpg";
+import logoIcon from "../img/logo.svg";
+import userIcon from "../img/user.svg";
+import "./css/Nav.css";
+import Button from "./Button";
+
+const Nav = () => {
+ const location = useLocation();
+ const isLogin = false;
+
+ return (
+
+
+
+
+

+
+
+ -
+
+ isActive ? "header__content__link--active" : ""
+ }
+ >
+ 자유게시판
+
+
+
+ -
+
+ isActive || location.pathname === "/additem"
+ ? "header__content__link--active"
+ : ""
+ }
+ >
+ 중고마켓
+
+
+
+
+ {isLogin ? (
+
+
+

+
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default Nav;
diff --git a/src/components/SearchInput.js b/src/components/SearchInput.js
index fff75433..709ef051 100644
--- a/src/components/SearchInput.js
+++ b/src/components/SearchInput.js
@@ -1,24 +1,24 @@
-import searchIcon from "../img/search.svg";
-import "./css/SearchInput.css";
-
-const SearchInput = ({ value, onInput, onKeyDown, onClick, className }) => {
- return (
-
-
-

-
- );
-};
-
-export default SearchInput;
+import searchIcon from "../img/search.svg";
+import "./css/SearchInput.css";
+
+const SearchInput = ({ value, onInput, onKeyDown, onClick, className }) => {
+ return (
+
+
+

+
+ );
+};
+
+export default SearchInput;
diff --git a/src/components/SnsLink.tsx b/src/components/SnsLink.tsx
new file mode 100644
index 00000000..ce818d08
--- /dev/null
+++ b/src/components/SnsLink.tsx
@@ -0,0 +1,15 @@
+interface SnsLinkProps {
+ href: string;
+ logo: string;
+ alt: string;
+}
+
+const SnsLink = ({ href, logo, alt }: SnsLinkProps) => {
+ return (
+
+
+
+ );
+};
+
+export default SnsLink;
diff --git a/src/components/SortDropdown.js b/src/components/SortDropdown.js
index ab6e627f..bf40cfab 100644
--- a/src/components/SortDropdown.js
+++ b/src/components/SortDropdown.js
@@ -2,8 +2,8 @@ import Dropdown from "./Dropdown";
const SortDropdown = ({
className,
- onClickDropdown,
- onClickDropdownItem,
+ onToggleDropdown,
+ onDropdownItemClick,
onCloseDropdown,
dropdownList,
showDropdown,
@@ -12,8 +12,8 @@ const SortDropdown = ({
return (
{},
- onAdd = () => {},
- onDelete = () => {},
-}) => {
- const [tagInputVal, setTagInputVal] = useState("");
- const onKeyDown = (e) => {
- // 엔터 입력 시 추가
- if (e.key === "Enter") {
- const newVal = e.target.value.replaceAll(" ", "");
- onAdd(newVal);
- setTagInputVal("");
- }
- };
-
- const onChangeVal = (name, value) => {
- setTagInputVal(value);
- onChange(value);
- };
-
- const handleOnClickDelete = (index) => {
- onDelete(index);
- };
-
- return (
-
-
-
- {tagList.map((tag, index) => (
- handleOnClickDelete(index)}
- >
- {tag}
-
- ))}
-
-
- );
-};
-
-export default TagInput;
+import { useState } from "react";
+import Textfield from "./Textfield";
+import Tag from "./Tag";
+import "./css/TagInput.css";
+
+const TagInput = ({
+ tagList,
+ isValid,
+ message,
+ onChange = () => {},
+ onAdd = () => {},
+ onDelete = () => {},
+}) => {
+ const [tagInputVal, setTagInputVal] = useState("");
+ const onKeyDown = (e) => {
+ // 엔터 입력 시 추가
+ if (e.key === "Enter") {
+ const newVal = e.target.value.replaceAll(" ", "");
+ onAdd(newVal);
+ setTagInputVal("");
+ }
+ };
+
+ const onChangeVal = (name, value) => {
+ setTagInputVal(value);
+ onChange(value);
+ };
+
+ const handleOnClickDelete = (index) => {
+ onDelete(index);
+ };
+
+ return (
+
+
onChangeVal(e.target.name, e.target.value)}
+ className="taginput__textfield"
+ />
+
+ {tagList.map((tag, index) => (
+ handleOnClickDelete(index)}
+ >
+ {tag}
+
+ ))}
+
+
+ );
+};
+
+export default TagInput;
diff --git a/src/components/TextArea.js b/src/components/TextArea.js
deleted file mode 100644
index bc8edef8..00000000
--- a/src/components/TextArea.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import "./css/InputCommon.css";
-import "./css/TextArea.css";
-
-/*
- [Textfield 필수 속성]
- - value: 텍스트필드 값
- - placeholder: 플레이스홀더 텍스트
- - onChange: 컴포넌트 밖에서 텍스트필드 값을 변경하는 메소드 전달 필요
-
- [TextArea 상태 속성]
- - isValid: 에러 상태 표시 => null(초기상태). true 값을 넘기면 메시지 출력
- - message: 메세지가 있는 경우 출력
-*/
-
-const TextArea = ({
- value,
- name,
- placeholder = "입력해주세요",
- onChange = () => {},
- isValid = null,
- message,
- className,
- ...rest
-}) => {
- const statusMessageClass = {
- null: "",
- false: "input__message--error",
- };
-
- const hasMessage = !!message;
- const isInvalid = isValid === false;
- const showMessage = hasMessage && isInvalid;
-
- return (
-
- );
-};
-
-export default TextArea;
diff --git a/src/components/TextArea.tsx b/src/components/TextArea.tsx
new file mode 100644
index 00000000..3f53f480
--- /dev/null
+++ b/src/components/TextArea.tsx
@@ -0,0 +1,71 @@
+import { TextareaHTMLAttributes } from "react";
+import "./css/InputCommon.css";
+import "./css/TextArea.css";
+
+/**
+ * TextArea 컴포넌트
+ *
+ * @param {string} value - TextArea value
+ * @param {string} [name] - TextArea name
+ * @param {string} [placeholder] - TextArea 플레이스홀더 (기본값은 입력해주세요)
+ * @param {boolean|null} [isValid=null] - 에러 상태 표시 (null: 초기상태, false: 에러 상태)
+ * @param {string} [message] - 에러 메시지 (isValid가 false일 때 출력됨)
+ * @param {string} [className] - 추가할 CSS 클래스명
+ * @returns {JSX.Element} TextArea 컴포넌트
+ *
+ * @example
+ * // 기본 사용 예시
+
+ **/
+
+interface TextAreaProps extends TextareaHTMLAttributes {
+ value: string;
+ name?: string;
+ placeholder?: string;
+ isValid?: boolean | null;
+ message?: string;
+ className?: string;
+}
+
+const TextArea = ({
+ value,
+ name,
+ placeholder = "입력해주세요",
+ isValid = null,
+ message,
+ className,
+ ...rest
+}: TextAreaProps) => {
+ const getErrorMessageClass = (isValid: boolean | null): string => {
+ return isValid === false ? "input__message--error" : "";
+ };
+
+ const hasMessage = !!message;
+ const isInvalid = isValid === false;
+ const showMessage = hasMessage && isInvalid;
+
+ return (
+
+
+ {showMessage && (
+
{message}
+ )}
+
+ );
+};
+
+export default TextArea;
diff --git a/src/components/Textfield.js b/src/components/Textfield.js
deleted file mode 100644
index c8e4bf30..00000000
--- a/src/components/Textfield.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import "./css/InputCommon.css";
-
-/*
- [Textfield 필수 속성]
- - value: 텍스트필드 값
- - placeholder: 플레이스홀더 텍스트
- - onChange: 컴포넌트 밖에서 텍스트필드 값을 변경하는 메소드 전달 필요
-
- [Textfield 상태 속성]
- - isValid: 에러 상태 표시 => null(초기상태). true 값을 넘기면 메시지 출력
- - message: 메세지가 있는 경우 출력
-*/
-
-const Textfield = ({
- value,
- type,
- name,
- placeholder = "입력해주세요",
- onChange = () => {},
- isValid = null,
- message,
- className,
- ...rest
-}) => {
- const statusMessageClass = {
- null: "",
- false: "input__message--error",
- };
-
- const hasMessage = !!message;
- const isInvalid = isValid === false;
- const showMessage = hasMessage && isInvalid;
-
- return (
-
-
onChange(e.target.name, e.target.value)}
- className={`input textfield ${className}`}
- {...rest}
- />
- {showMessage && (
-
{message}
- )}
-
- );
-};
-
-export default Textfield;
diff --git a/src/components/Textfield.tsx b/src/components/Textfield.tsx
new file mode 100644
index 00000000..a3c5474f
--- /dev/null
+++ b/src/components/Textfield.tsx
@@ -0,0 +1,104 @@
+import { InputHTMLAttributes, useState } from "react";
+import "./css/InputCommon.css";
+import "./css/Textfield.css";
+import passwordIconOff from "../img/visibility_off.svg";
+import passwordIconOn from "../img/visibility_on.svg";
+
+/**
+ * Textfield 컴포넌트
+ *
+ * @param {boolean|null} [isValid=null] - 에러 상태 표시 (null: 초기상태, false: 에러 상태)
+ * @param {string} [message] - 에러 메시지 (isValid가 false일 때 출력됨)
+ * @param {string} [className] - 추가할 CSS 클래스명
+ * @param {string} [type] - input 태그의 type (password인 경우 비밀번호 표시/숨김 기능 제공)
+ * @param {...InputHTMLAttributes} rest - 기타 HTML input 기본 속성
+ * @returns {JSX.Element} Textfield 컴포넌트
+ *
+ * @example
+ * // 기본 사용 예시
+ * onChangeTextfield(e.target.name, e.target.value)}
+ />
+ *
+ * @example
+ * // 비밀번호 사용 예시
+ * onChangeTextfield(e.target.name, e.target.value)}
+ />
+ **/
+
+interface TextfieldProps extends InputHTMLAttributes {
+ isValid?: boolean | null;
+ message?: string;
+ className?: string;
+ type?: string;
+}
+
+const Textfield = ({
+ isValid = null,
+ message,
+ className,
+ type,
+ ...rest
+}: TextfieldProps) => {
+ const getErrorMessageClass = (isValid: boolean | null): string => {
+ return isValid === false ? "input__message--error" : "";
+ };
+
+ const hasMessage = !!message;
+ const isInvalid = isValid === false;
+ const showMessage = hasMessage && isInvalid;
+ const [showPassword, setShowPassword] = useState(false);
+ const isPasswordType = type === "password";
+ const textfieldType = isPasswordType
+ ? showPassword
+ ? "text"
+ : "password"
+ : type;
+
+ const onTogglePassword = () => {
+ setShowPassword((prev) => !prev);
+ };
+
+ return (
+
+
+
+ {isPasswordType && (
+

+ )}
+
+ {showMessage && (
+
{message}
+ )}
+
+ );
+};
+
+export default Textfield;
diff --git a/src/components/css/Button.css b/src/components/css/Button.css
index 23191751..153d9e87 100644
--- a/src/components/css/Button.css
+++ b/src/components/css/Button.css
@@ -1,6 +1,7 @@
.btn {
display: flex;
justify-content: center;
+ align-items: center;
background-color: var(--color-primary-100);
text-decoration: none;
color: var(--color-secondary-100);
@@ -8,6 +9,7 @@
font-weight: 600;
gap: 8px;
transition: background-color 0.2s;
+ cursor: pointer;
}
.btn-register {
diff --git a/src/components/css/Footer.css b/src/components/css/Footer.css
new file mode 100644
index 00000000..0d3f005b
--- /dev/null
+++ b/src/components/css/Footer.css
@@ -0,0 +1,72 @@
+/* 푸터 */
+footer {
+ background-color: #111827;
+ padding: 32px 32px 30px 32px;
+ height: 160px;
+}
+
+@media screen and (min-width: 768px) {
+ .footer {
+ padding: 32px 104px 104px 108px;
+ }
+}
+
+.footer__content {
+ max-width: 311px;
+ margin: 0 auto;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+}
+
+@media screen and (min-width: 768px) {
+ .footer__content {
+ max-width: 536px;
+ }
+}
+
+@media screen and (min-width: 1200px) {
+ .footer__content {
+ max-width: 1120px;
+ }
+}
+
+.footer__content__menu {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ flex-direction: column-reverse;
+ gap: 60px;
+ font-size: var(--font-size-xs);
+}
+
+@media screen and (min-width: 768px) {
+ .footer__content__menu {
+ flex-direction: row;
+ }
+}
+
+@media screen and (min-width: 1200px) {
+ .footer__content__menu {
+ gap: 366.5px;
+ }
+}
+
+.footer__content__copyright {
+ color: #9ca3af;
+}
+
+.footer__content__link a {
+ text-decoration: none;
+ color: #e5e7eb;
+}
+
+.footer__content__link {
+ display: flex;
+ gap: 30px;
+}
+
+.footer__content__sns {
+ display: flex;
+ gap: 12px;
+}
diff --git a/src/components/css/InputCommon.css b/src/components/css/InputCommon.css
index 4f9ebaac..d7add18f 100644
--- a/src/components/css/InputCommon.css
+++ b/src/components/css/InputCommon.css
@@ -2,6 +2,10 @@
width: 100%;
}
+.input__wrapper {
+ position: relative;
+}
+
.input {
width: 100%;
font-family: "Pretendard Variable";
diff --git a/src/components/css/Textfield.css b/src/components/css/Textfield.css
new file mode 100644
index 00000000..9d3fc862
--- /dev/null
+++ b/src/components/css/Textfield.css
@@ -0,0 +1,6 @@
+.input__password__icon {
+ position: absolute;
+ top: 50%;
+ right: 20px;
+ transform: translate(0, -50%);
+}
diff --git a/src/custom.d.ts b/src/custom.d.ts
new file mode 100644
index 00000000..0fdfa8b3
--- /dev/null
+++ b/src/custom.d.ts
@@ -0,0 +1,11 @@
+declare module "*.svg" {
+ import * as React from "react";
+ export const ReactComponent: React.FC>;
+ const src: string;
+ export default src;
+}
+
+declare module "*.png" {
+ const value: string;
+ export default value;
+}
diff --git a/src/hooks/useClickOutside.js b/src/hooks/useClickOutside.js
new file mode 100644
index 00000000..983aa22a
--- /dev/null
+++ b/src/hooks/useClickOutside.js
@@ -0,0 +1,20 @@
+import { useEffect } from "react";
+
+const useClickOutside = (ref, callback) => {
+ // 외부 클릭 시 드롭다운 닫기
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (!ref.current || ref.current.contains(event.target)) {
+ return;
+ }
+ callback(event);
+ };
+ window.addEventListener("touchstart", handleClickOutside);
+ window.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ window.removeEventListener("touchstart", handleClickOutside);
+ window.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, [ref, callback]);
+};
+export default useClickOutside;
diff --git a/src/img/Img_home_01.png b/src/img/Img_home_01.png
new file mode 100644
index 00000000..249652e3
Binary files /dev/null and b/src/img/Img_home_01.png differ
diff --git a/src/img/Img_home_02.png b/src/img/Img_home_02.png
new file mode 100644
index 00000000..84d8629f
Binary files /dev/null and b/src/img/Img_home_02.png differ
diff --git a/src/img/Img_home_03.png b/src/img/Img_home_03.png
new file mode 100644
index 00000000..eb0d6cd2
Binary files /dev/null and b/src/img/Img_home_03.png differ
diff --git a/src/img/Img_home_promotion.png b/src/img/Img_home_promotion.png
new file mode 100644
index 00000000..58b29043
Binary files /dev/null and b/src/img/Img_home_promotion.png differ
diff --git a/src/img/Img_home_top.png b/src/img/Img_home_top.png
new file mode 100644
index 00000000..7ce56caa
Binary files /dev/null and b/src/img/Img_home_top.png differ
diff --git a/src/img/google.png b/src/img/google.png
new file mode 100644
index 00000000..f75dc761
Binary files /dev/null and b/src/img/google.png differ
diff --git a/src/img/ic_facebook.svg b/src/img/ic_facebook.svg
new file mode 100644
index 00000000..b9c9d493
--- /dev/null
+++ b/src/img/ic_facebook.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/img/ic_instagram.svg b/src/img/ic_instagram.svg
new file mode 100644
index 00000000..0b9337b0
--- /dev/null
+++ b/src/img/ic_instagram.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/img/ic_twitter.svg b/src/img/ic_twitter.svg
new file mode 100644
index 00000000..14a6069a
--- /dev/null
+++ b/src/img/ic_twitter.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/img/ic_youtube.svg b/src/img/ic_youtube.svg
new file mode 100644
index 00000000..699b5380
--- /dev/null
+++ b/src/img/ic_youtube.svg
@@ -0,0 +1,10 @@
+
diff --git a/src/img/kakao.png b/src/img/kakao.png
new file mode 100644
index 00000000..bd767800
Binary files /dev/null and b/src/img/kakao.png differ
diff --git a/src/img/link_thumb.png b/src/img/link_thumb.png
new file mode 100644
index 00000000..c402e6bb
Binary files /dev/null and b/src/img/link_thumb.png differ
diff --git a/src/img/logo.png b/src/img/logo.png
new file mode 100644
index 00000000..8248f602
Binary files /dev/null and b/src/img/logo.png differ
diff --git a/src/img/logo_text.png b/src/img/logo_text.png
new file mode 100644
index 00000000..82c95005
Binary files /dev/null and b/src/img/logo_text.png differ
diff --git a/src/img/visibility_off.svg b/src/img/visibility_off.svg
new file mode 100644
index 00000000..febddd10
--- /dev/null
+++ b/src/img/visibility_off.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/img/visibility_on.svg b/src/img/visibility_on.svg
new file mode 100644
index 00000000..c1f833ed
--- /dev/null
+++ b/src/img/visibility_on.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx
new file mode 100644
index 00000000..f99bb96d
--- /dev/null
+++ b/src/pages/HomePage.tsx
@@ -0,0 +1,126 @@
+import "./css/HomePage.css";
+import Button from "../components/Button";
+import { useNavigate } from "react-router-dom";
+import mainBannerImg from "../img/Img_home_top.png";
+import featureBannerImg1 from "../img/Img_home_01.png";
+import featureBannerImg2 from "../img/Img_home_02.png";
+import featureBannerImg3 from "../img/Img_home_03.png";
+import promotionBannerImg from "../img/Img_home_promotion.png";
+
+const HomePage = () => {
+ const navigate = useNavigate();
+ const onClickVisit = () => {
+ navigate("/items");
+ };
+
+ return (
+
+ {/* 상단 배너*/}
+
+
+
+
+ 일상의 모든 물건을
+
+ 거래해 보세요
+
+
+
+

+
+
+
+ {/* 섹션 1 */}
+
+ {/* 1 */}
+
+

+
+ Hot item
+
+ 인기 상품을
+
+ 확인해 보세요
+
+
+ 가장 HOT한 중고거래 물품을
+
+ 판다 마켓에서 확인해 보세요
+
+
+
+ {/* 2 */}
+
+
+ Search
+
+ 구매를 원하는
+
+ 상품을 검색하세요
+
+
+ 구매하고 싶은 물품은 검색해서
+
+ 쉽게 찾아보세요
+
+
+

+
+ {/* 3 */}
+
+

+
+ Register
+
+ 판매를 원하는
+
+ 상품을 등록하세요
+
+
+ 어떤 물건이든 판매하고 싶은 상품을
+
+ 쉽게 등록하세요
+
+
+
+
+
+ {/* 하단 배너 */}
+
+
+
+ 믿을 수 있는
+
+ 판다마켓 중고 거래
+
+

+
+
+
+ );
+};
+
+export default HomePage;
diff --git a/src/pages/ItemDetailPage.js b/src/pages/ItemDetailPage.js
index db8736d4..877ca631 100644
--- a/src/pages/ItemDetailPage.js
+++ b/src/pages/ItemDetailPage.js
@@ -1,243 +1,245 @@
-import "./css/ItemDetailPage.css";
-import { useNavigate } from "react-router-dom";
-import { useEffect, useState, useReducer } from "react";
-import { useParams } from "react-router-dom";
-import { getItemDetail, getItemDetailComments } from "../api/Items";
-import { formatDate, formatPrice } from "../utils/formatUtil";
-import Tag from "../components/Tag";
-import Button from "../components/Button";
-import userIcon from "../img/user.svg";
-import inquiryEmpty from "../img/inquiry_empty.jpg";
-import HeartButton from "../components/HeartButton";
-import TextArea from "../components/TextArea";
-import MoreDropdown from "../components/MoreDropdown";
-import Comment from "../components/Comment";
-import { validateInput } from "../utils/formValidation";
-
-const ItemDetailPage = () => {
- const navigate = useNavigate();
- const { id } = useParams();
- const [detail, setDetail] = useState({
- favoriteCount: 0,
- images: [],
- tags: [],
- name: "",
- description: "",
- });
- const [comments, setComments] = useState([
- {
- id: 0,
- content: "",
- updatedAt: "",
- writer: {
- id: 0,
- nickname: "",
- image: null,
- },
- },
- ]);
-
- // 리듀서 관련
- const initialCommentReducerState = {
- inquiry: { value: "", validInfo: { isValid: null, message: "" } },
- };
-
- const commentReducer = (state, action) => {
- switch (action.type) {
- case "CHANGE_FIELD":
- return {
- ...state,
- [action.field]: {
- ...state[action.field],
- value: action.value,
- },
- };
- case "UPDATE_VALIDATE":
- return {
- ...state,
- [action.field]: {
- ...state[action.field],
- validInfo: {
- isValid: action.value.isValid,
- message: action.value.message,
- },
- },
- };
- default:
- return state;
- }
- };
-
- const [commentState, dispatchForm] = useReducer(
- commentReducer,
- initialCommentReducerState
- );
-
- /* 유효성 체크 */
- const updateValidate = (name, val) => {
- const validateResult = validateInput(name, val);
- dispatchForm({
- type: "UPDATE_VALIDATE",
- field: name,
- value: validateResult,
- });
- };
-
- /* 문의하기 값 업데이트 */
- const onChangeComment = (name, value) => {
- let trimVal = value.trim();
- dispatchForm({
- type: "CHANGE_FIELD",
- field: name,
- value,
- });
- updateValidate(name, trimVal);
- };
-
- const disableInquiryRegisterButton = () => {
- return !commentState.inquiry.validInfo.isValid;
- };
-
- const fetchItemDetail = async (params) => {
- try {
- const data = await getItemDetail(params);
- setDetail(data);
- } catch (error) {
- } finally {
- }
- };
-
- const fetchItemDetailComments = async (params) => {
- try {
- const { list } = await getItemDetailComments(params);
- setComments(list);
- } catch (error) {
- } finally {
- }
- };
-
- const onClickReturn = () => {
- navigate("/items");
- };
-
- useEffect(() => {
- fetchItemDetail({ productId: id });
- fetchItemDetailComments({ productId: id });
- }, [id]);
-
- return (
-
-
-
-

-
-
-
-
-
- {detail.name}
-
- {formatPrice(detail.price)}원
-
-
-
-
-
- 상품 소개
-
- {detail.description}
-
-
-
-
상품 태그
-
- {detail.tags.map((tag, index) => (
-
- {tag}
-
- ))}
-
-
-
-
-
-

-
-
- {detail.ownerNickname}
-
-
- {formatDate(detail.updatedAt)}
-
-
-
-
-
- {detail.favoriteCount}
-
-
-
-
-
-
-
-
-
-
-
- {!comments.length && (
-
-

-
아직 문의가 없어요
-
- )}
-
- {comments.map((comment) => (
-
- ))}
-
-
-
-
-
-
- );
-};
-
-export default ItemDetailPage;
+import "./css/ItemDetailPage.css";
+import { useNavigate } from "react-router-dom";
+import { useEffect, useState, useReducer } from "react";
+import { useParams } from "react-router-dom";
+import { getItemDetail, getItemDetailComments } from "../api/Items";
+import { formatDate, formatPrice } from "../utils/formatUtil";
+import Tag from "../components/Tag";
+import Button from "../components/Button";
+import userIcon from "../img/user.svg";
+import inquiryEmpty from "../img/inquiry_empty.jpg";
+import HeartButton from "../components/HeartButton";
+import TextArea from "../components/TextArea";
+import MoreDropdown from "../components/MoreDropdown";
+import Comment from "../components/Comment";
+import { validateInput } from "../utils/formValidation";
+
+const ItemDetailPage = () => {
+ const navigate = useNavigate();
+ const { id } = useParams();
+ const [detail, setDetail] = useState({
+ favoriteCount: 0,
+ images: [],
+ tags: [],
+ name: "",
+ description: "",
+ });
+ const [comments, setComments] = useState([
+ {
+ id: 0,
+ content: "",
+ updatedAt: "",
+ writer: {
+ id: 0,
+ nickname: "",
+ image: null,
+ },
+ },
+ ]);
+
+ // 리듀서 관련
+ const initialCommentReducerState = {
+ inquiry: { value: "", validInfo: { isValid: null, message: "" } },
+ };
+
+ const commentReducer = (state, action) => {
+ switch (action.type) {
+ case "CHANGE_FIELD":
+ return {
+ ...state,
+ [action.field]: {
+ ...state[action.field],
+ value: action.value,
+ },
+ };
+ case "UPDATE_VALIDATE":
+ return {
+ ...state,
+ [action.field]: {
+ ...state[action.field],
+ validInfo: {
+ isValid: action.value.isValid,
+ message: action.value.message,
+ },
+ },
+ };
+ default:
+ return state;
+ }
+ };
+
+ const [commentState, dispatchForm] = useReducer(
+ commentReducer,
+ initialCommentReducerState
+ );
+
+ /* 유효성 체크 */
+ const updateValidate = (name, val) => {
+ const validateResult = validateInput(name, val);
+ dispatchForm({
+ type: "UPDATE_VALIDATE",
+ field: name,
+ value: validateResult,
+ });
+ };
+
+ /* 문의하기 값 업데이트 */
+ const onChangeComment = (name, value) => {
+ let trimVal = value.trim();
+ dispatchForm({
+ type: "CHANGE_FIELD",
+ field: name,
+ value,
+ });
+ updateValidate(name, trimVal);
+ };
+
+ const disableInquiryRegisterButton = () => {
+ return !commentState.inquiry.validInfo.isValid;
+ };
+
+ const fetchItemDetail = async (params) => {
+ try {
+ const data = await getItemDetail(params);
+ setDetail(data);
+ } catch (error) {
+ } finally {
+ }
+ };
+
+ const fetchItemDetailComments = async (params) => {
+ try {
+ const { list } = await getItemDetailComments(params);
+ setComments(list);
+ } catch (error) {
+ } finally {
+ }
+ };
+
+ const onClickReturn = () => {
+ navigate("/items");
+ };
+
+ useEffect(() => {
+ fetchItemDetail({ productId: id });
+ fetchItemDetailComments({ productId: id });
+ }, [id]);
+
+ return (
+
+
+
+

+
+
+
+
+
+ {detail.name}
+
+ {formatPrice(detail.price)}원
+
+
+
+
+
+ 상품 소개
+
+ {detail.description}
+
+
+
+
상품 태그
+
+ {detail.tags.map((tag, index) => (
+
+ {tag}
+
+ ))}
+
+
+
+
+
+

+
+
+ {detail.ownerNickname}
+
+
+ {formatDate(detail.updatedAt)}
+
+
+
+
+
+ {detail.favoriteCount}
+
+
+
+
+
+
+
+
+
+
+
+ {!comments.length && (
+
+

+
아직 문의가 없어요
+
+ )}
+
+ {comments.map((comment) => (
+
+ ))}
+
+
+
+
+
+
+ );
+};
+
+export default ItemDetailPage;
diff --git a/src/pages/ItemRegisterPage.js b/src/pages/ItemRegisterPage.js
index 454393c5..639197b4 100644
--- a/src/pages/ItemRegisterPage.js
+++ b/src/pages/ItemRegisterPage.js
@@ -1,218 +1,222 @@
-import "./css/ItemRegisterPage.css";
-import { useReducer } from "react";
-import Button from "../components/Button";
-import Label from "../components/Label";
-import Textfield from "../components/Textfield";
-import TextArea from "../components/TextArea";
-import ImageUploader from "../components/ImageUploader";
-import TagInput from "../components/TagInput";
-import { validateInput } from "../utils/formValidation";
-
-const ItemRegisterPage = () => {
- // 폼 리듀서 관련
- const initialReducerState = {
- image: { value: "", validInfo: { isValid: null, message: "" } },
- title: { value: "", validInfo: { isValid: null, message: "" } },
- content: { value: "", validInfo: { isValid: null, message: "" } },
- price: { value: "", validInfo: { isValid: null, message: "" } },
- tagList: { value: [], validInfo: { isValid: null, message: "" } },
- };
-
- const formReducer = (state, action) => {
- switch (action.type) {
- case "CHANGE_FIELD":
- return {
- ...state,
- [action.field]: {
- ...state[action.field],
- value: action.value,
- },
- };
- case "UPDATE_VALIDATE":
- return {
- ...state,
- [action.field]: {
- ...state[action.field],
- validInfo: {
- isValid: action.value.isValid,
- message: action.value.message,
- },
- },
- };
- default:
- return state;
- }
- };
-
- const [formState, dispatchForm] = useReducer(
- formReducer,
- initialReducerState
- );
-
- /* 유효성 체크 */
- const updateValidate = (name, val) => {
- const validateResult = validateInput(name, val);
- dispatchForm({
- type: "UPDATE_VALIDATE",
- field: name,
- value: validateResult,
- });
- };
-
- /* 이미지 컴포넌트 관련 */
- const onClickAddImage = () => {
- updateValidate("image", formState.image.value);
- };
-
- const onChangeImage = (value) => {
- dispatchForm({
- type: "CHANGE_FIELD",
- field: "image",
- value,
- });
- };
-
- const onDeleteImage = () => {
- dispatchForm({
- type: "CHANGE_FIELD",
- field: "image",
- value: "",
- });
- updateValidate("image", "");
- };
-
- /* 텍스트필드 데이터 세팅과 유효성 체크 */
- const onChangeTextfield = (name, value) => {
- let trimVal = value.trim();
- dispatchForm({
- type: "CHANGE_FIELD",
- field: name,
- value,
- });
- updateValidate(name, trimVal);
- };
-
- /* 가격 입력 시 숫자만 입력 가능하게 */
- /* type=number로 했을 때 한글 입력 시 오류 발생하여 type=text로 처리 */
- const onPriceChange = (name, value) => {
- let changedVal = value.replace(/[^0-9]/g, "");
-
- if (changedVal !== "") {
- changedVal = changedVal.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
- }
-
- onChangeTextfield(name, changedVal);
- };
-
- /* 태그 컴포넌트 관련 */
- const onAddTag = (tagVal) => {
- const newTagList = [...formState.tagList.value, tagVal];
- dispatchForm({
- type: "CHANGE_FIELD",
- field: "tagList",
- value: newTagList,
- });
- updateValidate("tagList", newTagList);
- };
-
- const onDeleteTag = (index) => {
- const changedTagList = formState.tagList.value.filter(
- (_, i) => i !== index
- );
- dispatchForm({
- type: "CHANGE_FIELD",
- field: "tagList",
- value: changedTagList,
- });
- updateValidate("tagList", changedTagList);
- };
-
- /* 등록 버튼 활성화 여부 */
- const disableRegisterButton = () => {
- const isAllValid =
- formState.title.validInfo.isValid &&
- formState.content.validInfo.isValid &&
- formState.price.validInfo.isValid &&
- formState.tagList.validInfo.isValid;
- return !Boolean(isAllValid);
- };
-
- return (
- <>
-
-
-
- 상품 등록하기
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- );
-};
-
-export default ItemRegisterPage;
+import "./css/ItemRegisterPage.css";
+import { useReducer } from "react";
+import Button from "../components/Button";
+import Label from "../components/Label";
+import Textfield from "../components/Textfield";
+import TextArea from "../components/TextArea";
+import ImageUploader from "../components/ImageUploader";
+import TagInput from "../components/TagInput";
+import { validateInput } from "../utils/formValidation";
+
+const ItemRegisterPage = () => {
+ // 폼 리듀서 관련
+ const initialReducerState = {
+ image: { value: "", validInfo: { isValid: null, message: "" } },
+ title: { value: "", validInfo: { isValid: null, message: "" } },
+ content: { value: "", validInfo: { isValid: null, message: "" } },
+ price: { value: "", validInfo: { isValid: null, message: "" } },
+ tagList: { value: [], validInfo: { isValid: null, message: "" } },
+ };
+
+ const formReducer = (state, action) => {
+ switch (action.type) {
+ case "CHANGE_FIELD":
+ return {
+ ...state,
+ [action.field]: {
+ ...state[action.field],
+ value: action.value,
+ },
+ };
+ case "UPDATE_VALIDATE":
+ return {
+ ...state,
+ [action.field]: {
+ ...state[action.field],
+ validInfo: {
+ isValid: action.value.isValid,
+ message: action.value.message,
+ },
+ },
+ };
+ default:
+ return state;
+ }
+ };
+
+ const [formState, dispatchForm] = useReducer(
+ formReducer,
+ initialReducerState
+ );
+
+ /* 유효성 체크 */
+ const updateValidate = (name, val) => {
+ const validateResult = validateInput(name, val);
+ dispatchForm({
+ type: "UPDATE_VALIDATE",
+ field: name,
+ value: validateResult,
+ });
+ };
+
+ /* 이미지 컴포넌트 관련 */
+ const onClickAddImage = () => {
+ updateValidate("image", formState.image.value);
+ };
+
+ const onChangeImage = (value) => {
+ dispatchForm({
+ type: "CHANGE_FIELD",
+ field: "image",
+ value,
+ });
+ };
+
+ const onDeleteImage = () => {
+ dispatchForm({
+ type: "CHANGE_FIELD",
+ field: "image",
+ value: "",
+ });
+ updateValidate("image", "");
+ };
+
+ /* 텍스트필드 데이터 세팅과 유효성 체크 */
+ const onChangeTextfield = (name, value) => {
+ let trimVal = value.trim();
+ dispatchForm({
+ type: "CHANGE_FIELD",
+ field: name,
+ value,
+ });
+ updateValidate(name, trimVal);
+ };
+
+ /* 가격 입력 시 숫자만 입력 가능하게 */
+ /* type=number로 했을 때 한글 입력 시 오류 발생하여 type=text로 처리 */
+ const onPriceChange = (name, value) => {
+ let changedVal = value.replace(/[^0-9]/g, "");
+
+ if (changedVal !== "") {
+ changedVal = changedVal.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
+ }
+
+ onChangeTextfield(name, changedVal);
+ };
+
+ /* 태그 컴포넌트 관련 */
+ const onAddTag = (tagVal) => {
+ const newTagList = [...formState.tagList.value, tagVal];
+ dispatchForm({
+ type: "CHANGE_FIELD",
+ field: "tagList",
+ value: newTagList,
+ });
+ updateValidate("tagList", newTagList);
+ };
+
+ const onDeleteTag = (index) => {
+ const changedTagList = formState.tagList.value.filter(
+ (_, i) => i !== index
+ );
+ dispatchForm({
+ type: "CHANGE_FIELD",
+ field: "tagList",
+ value: changedTagList,
+ });
+ updateValidate("tagList", changedTagList);
+ };
+
+ /* 등록 버튼 활성화 여부 */
+ const disableRegisterButton = () => {
+ const isAllValid =
+ formState.title.validInfo.isValid &&
+ formState.content.validInfo.isValid &&
+ formState.price.validInfo.isValid &&
+ formState.tagList.validInfo.isValid;
+ return !Boolean(isAllValid);
+ };
+
+ return (
+ <>
+
+
+
+ 상품 등록하기
+
+
+
+
+
+
+
+
+
+
+
+ onChangeTextfield(e.target.name, e.target.value)
+ }
+ />
+
+
+
+
+
+
+
+
+ onPriceChange(e.target.name, e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default ItemRegisterPage;
diff --git a/src/pages/ItemsPage.js b/src/pages/ItemsPage.js
index e14abd7b..ff20bc99 100644
--- a/src/pages/ItemsPage.js
+++ b/src/pages/ItemsPage.js
@@ -1,13 +1,13 @@
import "./css/ItemListPage.css";
import { useNavigate } from "react-router-dom";
import { useState, useEffect } from "react";
-import { getFavoriteItems, getAllItems } from "../api/Items.js";
+import { getFavoriteItems, getAllItems } from "../api/Items";
import Card from "../components/Card";
-import SearchInput from "../components/SearchInput.js";
-import Button from "../components/Button.js";
-import Pagination from "../components/Pagination.js";
-import usePagination from "../hooks/usePagination.js";
-import SortDropdown from "../components/SortDropdown.js";
+import SearchInput from "../components/SearchInput";
+import Button from "../components/Button";
+import Pagination from "../components/Pagination";
+import usePagination from "../hooks/usePagination";
+import SortDropdown from "../components/SortDropdown";
const ItemListPage = () => {
const navigate = useNavigate();
@@ -77,11 +77,11 @@ const ItemListPage = () => {
}
};
- const onClickDropdown = () => {
+ const toggleDropdown = () => {
setShowDropdown(!showDropdown);
};
- const onClickDropdownItem = (order) => {
+ const handleDropdownItemClick = (order) => {
if (order.value !== orderBy.value) {
setOrderBy(order);
setPaginationCurrentPage(1);
@@ -216,8 +216,8 @@ const ItemListPage = () => {
{
+ const { email, password, setField, validateField, resetFields } =
+ useLoginStore();
+
+ /* 유효성 체크 */
+ const updateValidate = (name: string, value: string) => {
+ validateField(name as LoginFieldName, value);
+ };
+
+ /* 텍스트필드 데이터 세팅과 유효성 체크 */
+ const onChangeTextfield = (name: string, value: string) => {
+ let trimVal = value.trim();
+ setField(name as LoginFieldName, trimVal);
+ updateValidate(name, trimVal);
+ };
+
+ const isLoginFormValid =
+ email.validInfo.isValid && password.validInfo.isValid;
+
+ useEffect(() => {
+ resetFields();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+ );
+};
+
+export default LoginPage;
diff --git a/src/pages/SignupPage.tsx b/src/pages/SignupPage.tsx
new file mode 100644
index 00000000..7192279d
--- /dev/null
+++ b/src/pages/SignupPage.tsx
@@ -0,0 +1,150 @@
+import "./css/Auth.css";
+import "./css/SignupPage.css";
+import { Link } from "react-router-dom";
+import { FieldName } from "../types/field";
+import logo from "../img/logo.png";
+import Label from "../components/Label";
+import Textfield from "../components/Textfield";
+import Button from "../components/Button";
+import kakakoIcon from "../img/kakao.png";
+import googleIcon from "../img/google.png";
+import { useEffect } from "react";
+import useSignupStore from "../stores/useSignupStore";
+
+const SignupPage = () => {
+ const {
+ email,
+ password,
+ passwordCheck,
+ nickname,
+ setField,
+ validateField,
+ resetFields,
+ } = useSignupStore();
+
+ /* 유효성 체크 */
+ const updateValidate = (
+ name: string,
+ value: string,
+ compareValue?: string
+ ) => {
+ if (compareValue) {
+ validateField(name as FieldName, value, compareValue);
+ } else {
+ validateField(name as FieldName, value);
+ }
+ };
+
+ /* 텍스트필드 데이터 세팅과 유효성 체크 */
+ const onChangeTextfield = (name: string, value: string) => {
+ let trimVal = value.trim();
+ setField(name as FieldName, trimVal);
+
+ if (name === "passwordCheck") {
+ updateValidate(name, trimVal, password.value);
+ } else {
+ updateValidate(name, trimVal);
+ }
+ };
+
+ const isSingupFormValid =
+ email.validInfo.isValid &&
+ password.validInfo.isValid &&
+ passwordCheck.validInfo.isValid &&
+ nickname.validInfo.isValid;
+
+ useEffect(() => {
+ resetFields();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+ );
+};
+
+export default SignupPage;
diff --git a/src/pages/css/Auth.css b/src/pages/css/Auth.css
new file mode 100644
index 00000000..6087589c
--- /dev/null
+++ b/src/pages/css/Auth.css
@@ -0,0 +1,95 @@
+/* 폼 관련 공통 CSS */
+.form__container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 16px;
+}
+
+@media screen and (min-width: 1200px) {
+ .form__container {
+ padding: 0;
+ }
+}
+
+.form__container__content {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ max-width: 400px;
+ width: 100%;
+}
+
+@media screen and (min-width: 768px) {
+ .form__container__content {
+ max-width: 640px;
+ }
+}
+
+.form__container__content a {
+ display: block;
+ text-align: center;
+}
+
+.form__container__logo {
+ margin-bottom: 20px;
+}
+
+.form_container__group {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 24px;
+ gap: 16px;
+}
+
+.form__container__input {
+ width: 100%;
+ flex-grow: 1;
+}
+
+.form__container__input__wrap {
+ position: relative;
+}
+
+.form__container__input__wrap img {
+ position: absolute;
+ top: 50%;
+ right: 20px;
+ transform: translate(0, -50%);
+}
+
+.form__container button {
+ margin-bottom: 24px;
+ width: 100%;
+}
+
+.form__container__banner {
+ background-color: var(--color-background-light-blue);
+ border-radius: 8px;
+ padding: 16px 23px;
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ color: var(--color-secondary-800);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 24px;
+}
+
+.form__container__banner__icon {
+ display: flex;
+ gap: 16px;
+}
+
+.form__container__signup {
+ display: flex;
+ justify-content: center;
+ gap: 4px;
+ font-size: var(--font-size-xxs);
+ font-weight: 500;
+ color: var(--color-secondary-800);
+}
+
+.form__container__signup a {
+ color: var(--color-primary-100);
+}
diff --git a/src/pages/css/HomePage.css b/src/pages/css/HomePage.css
new file mode 100644
index 00000000..dbeadacd
--- /dev/null
+++ b/src/pages/css/HomePage.css
@@ -0,0 +1,303 @@
+header {
+ padding: 9.5px 0;
+ background-color: white;
+ position: sticky;
+ top: 0;
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.15);
+}
+
+.header__content {
+ padding: 0 24px;
+ max-width: 1520px;
+ margin: 0 auto;
+ color: #ffffff;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+@media screen and (min-width: 1200px) {
+ .header__content {
+ padding: 0 200px;
+ }
+}
+
+.header__content__logo {
+ width: 103px;
+ vertical-align: middle;
+}
+
+@media screen and (min-width: 768px) {
+ .header__content__logo {
+ width: 153px;
+ }
+}
+
+.banner {
+ background-color: var(--color-background-blue);
+}
+
+.banner__content {
+ max-width: 1110px;
+ padding-top: 48px;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+@media screen and (min-width: 768px) {
+ .banner__content {
+ padding-top: 84px;
+ }
+}
+
+@media screen and (min-width: 1200px) {
+ .banner__content {
+ padding-top: 200px;
+ justify-content: space-between;
+ align-items: flex-end;
+ flex-direction: row;
+ }
+}
+
+.banner__content__text {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 132px;
+ text-align: center;
+ align-items: center;
+}
+
+@media screen and (min-width: 768px) {
+ .banner__content__text {
+ margin-bottom: 211px;
+ }
+}
+
+@media screen and (min-width: 1200px) {
+ .banner__content__text {
+ margin-bottom: 100px;
+ align-items: flex-start;
+ text-align: left;
+ }
+}
+
+.banner__content__text__title {
+ font-size: var(--font-size-2xl);
+ font-weight: 700;
+ margin-bottom: 18px;
+ color: var(--color-secondary-700);
+}
+
+@media screen and (min-width: 768px) {
+ .banner__content__text__title {
+ font-size: var(--font-size-3xl);
+ margin-bottom: 24px;
+ }
+}
+
+.banner__content__img {
+ max-width: 746px;
+ width: 120%;
+}
+
+.feature {
+ padding: 52px 16px 43px;
+}
+
+@media screen and (min-width: 768px) {
+ .feature {
+ padding: 24px 24px 4px;
+ }
+}
+
+@media screen and (min-width: 1200px) {
+ .feature {
+ padding: 138px 0px 0px 0px;
+ }
+}
+
+.feature__content {
+ max-width: 988px;
+ margin: 0 auto;
+ border-radius: var(--border-radius-md);
+ display: flex;
+ gap: 24px;
+ flex-direction: column;
+ margin-bottom: 40px;
+}
+
+@media screen and (min-width: 768px) {
+ .feature__content {
+ margin-bottom: 52px;
+ }
+}
+
+@media screen and (min-width: 1200px) {
+ .feature__content {
+ flex-direction: row;
+ background-color: #fcfcfc;
+ align-items: center;
+ margin: 0 auto 276px auto;
+ gap: 64px;
+ }
+}
+
+.feature__content__img {
+ width: 100%;
+}
+
+@media screen and (min-width: 1200px) {
+ .feature__content__img {
+ max-width: 579px;
+ }
+}
+
+.feature__content__text {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+.feature__content__text__point {
+ font-size: var(--font-size-xs);
+ font-weight: 700;
+ color: var(--color-primary-100);
+}
+
+@media screen and (min-width: 768px) {
+ .feature__content__text__point {
+ font-size: var(--font-size-sm);
+ }
+}
+
+.feature__content__text__title {
+ font-size: var(--font-size-xl);
+ font-weight: 700;
+ color: var(--color-secondary-700);
+ margin-top: 8px;
+ margin-bottom: 16px;
+}
+
+@media screen and (min-width: 768px) {
+ .feature__content__text__title {
+ font-size: var(--font-size-2xl);
+ margin-top: 16px;
+ margin-bottom: 24px;
+ }
+}
+
+@media screen and (min-width: 1200px) {
+ .feature__content__text__title {
+ margin-top: 12px;
+ font-size: var(--font-size-3xl);
+ }
+}
+
+.feature__content__text__title br {
+ display: inline-block;
+ content: " ";
+}
+
+.feature__content__text__subtitle {
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ color: var(--color-secondary-700);
+}
+
+@media screen and (min-width: 1200px) {
+ .feature__content__text__subtitle {
+ font-size: var(--font-size-xl);
+ }
+}
+
+.feature__content__reverse {
+ flex-direction: column-reverse;
+}
+
+@media screen and (min-width: 1200px) {
+ .feature__content__reverse {
+ flex-direction: row;
+ }
+}
+
+.search-text {
+ text-align: right;
+}
+
+/* 하단 배너 */
+.promotion__banner {
+ background-color: var(--color-background-blue);
+}
+
+.promotion__banner__content {
+ max-width: 1110px;
+ padding-top: 121px;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+@media screen and (min-width: 768px) {
+ .promotion__banner__content {
+ padding-top: 217px;
+ }
+}
+
+@media screen and (min-width: 1200px) {
+ .promotion__banner__content {
+ justify-content: space-between;
+ align-items: flex-end;
+ flex-direction: row;
+ padding-top: 143px;
+ }
+}
+
+.promotion__banner__content__title {
+ font-size: var(--font-size-2xl);
+ font-weight: 700;
+ color: var(--color-secondary-700);
+ margin-bottom: 131px;
+ text-align: center;
+}
+
+@media screen and (min-width: 768px) {
+ .promotion__banner__content__title {
+ font-size: var(--font-size-3xl);
+ }
+}
+
+@media screen and (min-width: 1200px) {
+ .promotion__banner__content__title {
+ margin-bottom: 172.5px;
+ text-align: left;
+ }
+}
+
+.promotion__banner__content__img {
+ max-width: 746px;
+ width: 100%;
+}
+
+/* 태블릿 사이즈 이상에서 반응형 */
+@media screen and (min-width: 768px) {
+ /* 첫번째 배너 br 태그 기능 없애기 */
+ .banner__content br {
+ display: inline-block;
+ content: " ";
+ }
+}
+
+/* PC 반응형 */
+@media screen and (min-width: 1200px) {
+ /* 첫번째 배너 br 태그 다시 추가 */
+ .banner__content br {
+ display: block;
+ }
+
+ .feature__content__text__title br {
+ display: block;
+ }
+}
diff --git a/src/pages/css/LoginPage.css b/src/pages/css/LoginPage.css
new file mode 100644
index 00000000..edca6b69
--- /dev/null
+++ b/src/pages/css/LoginPage.css
@@ -0,0 +1,3 @@
+.login__container {
+ min-height: 100dvh;
+}
diff --git a/src/pages/css/SignupPage.css b/src/pages/css/SignupPage.css
new file mode 100644
index 00000000..7a1741e6
--- /dev/null
+++ b/src/pages/css/SignupPage.css
@@ -0,0 +1,3 @@
+.signup__container {
+ margin: 60px auto;
+}
diff --git a/src/stores/useLoginStore.ts b/src/stores/useLoginStore.ts
new file mode 100644
index 00000000..ab4c4bcf
--- /dev/null
+++ b/src/stores/useLoginStore.ts
@@ -0,0 +1,56 @@
+import { create } from "zustand";
+import { validateInput } from "../utils/formValidation";
+
+interface LoginState {
+ email: {
+ value: string;
+ validInfo: {
+ isValid: boolean | null;
+ message: string;
+ };
+ };
+ password: {
+ value: string;
+ validInfo: {
+ isValid: boolean | null;
+ message: string;
+ };
+ };
+ setField: (name: "email" | "password", value: string) => void;
+ validateField: (name: "email" | "password", ...value: [string]) => void;
+ resetFields: () => void;
+}
+
+const useLoginStore = create((set) => ({
+ email: {
+ value: "",
+ validInfo: { isValid: null, message: "" },
+ },
+ password: {
+ value: "",
+ validInfo: { isValid: null, message: "" },
+ },
+ setField: (name, value) =>
+ set((state) => ({
+ [name]: { ...state[name], value },
+ })),
+ validateField: (name, ...values) => {
+ const { isValid, message } = validateInput(name, ...values);
+ set((state) => ({
+ [name]: {
+ ...state[name],
+ validInfo: {
+ isValid,
+ message,
+ },
+ },
+ }));
+ },
+ resetFields: () =>
+ set({
+ email: { value: "", validInfo: { isValid: null, message: "" } },
+ password: { value: "", validInfo: { isValid: null, message: "" } },
+ }),
+}));
+
+export default useLoginStore;
diff --git a/src/stores/useSignupStore.ts b/src/stores/useSignupStore.ts
new file mode 100644
index 00000000..969c6c53
--- /dev/null
+++ b/src/stores/useSignupStore.ts
@@ -0,0 +1,84 @@
+import { create } from "zustand";
+import { validateInput } from "../utils/formValidation";
+
+interface SignupState {
+ email: {
+ value: string;
+ validInfo: {
+ isValid: boolean | null;
+ message: string;
+ };
+ };
+ password: {
+ value: string;
+ validInfo: {
+ isValid: boolean | null;
+ message: string;
+ };
+ };
+ passwordCheck: {
+ value: string;
+ validInfo: {
+ isValid: boolean | null;
+ message: string;
+ };
+ };
+ nickname: {
+ value: string;
+ validInfo: {
+ isValid: boolean | null;
+ message: string;
+ };
+ };
+ setField: (
+ name: "email" | "password" | "passwordCheck" | "nickname",
+ value: string
+ ) => void;
+ validateField: (
+ name: "email" | "password" | "passwordCheck" | "nickname",
+ ...value: [string] | [string, string]
+ ) => void;
+ resetFields: () => void;
+}
+
+const useSignupStore = create((set) => ({
+ email: {
+ value: "",
+ validInfo: { isValid: null, message: "" },
+ },
+ password: {
+ value: "",
+ validInfo: { isValid: null, message: "" },
+ },
+ passwordCheck: {
+ value: "",
+ validInfo: { isValid: null, message: "" },
+ },
+ nickname: {
+ value: "",
+ validInfo: { isValid: null, message: "" },
+ },
+ setField: (name, value) =>
+ set((state) => ({
+ [name]: { ...state[name], value },
+ })),
+ validateField: (name, ...values) => {
+ const { isValid, message } = validateInput(name, ...values);
+ set((state) => ({
+ [name]: {
+ ...state[name],
+ validInfo: {
+ isValid,
+ message,
+ },
+ },
+ }));
+ },
+ resetFields: () =>
+ set({
+ email: { value: "", validInfo: { isValid: null, message: "" } },
+ password: { value: "", validInfo: { isValid: null, message: "" } },
+ }),
+}));
+
+export default useSignupStore;
diff --git a/src/types/field.ts b/src/types/field.ts
new file mode 100644
index 00000000..c38aa5ba
--- /dev/null
+++ b/src/types/field.ts
@@ -0,0 +1,22 @@
+export interface LoginFields {
+ email: FieldState;
+ password: FieldState;
+}
+
+export interface SignupFields extends LoginFields {
+ passwordCheck: FieldState;
+ nickname: FieldState;
+}
+
+export type FormFieldName = keyof SignupFields;
+
+export type FieldName = keyof SignupFields;
+export type LoginFieldName = keyof LoginFields;
+
+export interface FieldState {
+ value: string;
+ validInfo: {
+ isValid: boolean | null;
+ message: string;
+ };
+}
diff --git a/src/types/form.ts b/src/types/form.ts
new file mode 100644
index 00000000..8777c66a
--- /dev/null
+++ b/src/types/form.ts
@@ -0,0 +1,120 @@
+export interface ValidatorResult {
+ isValid: boolean;
+ message: string;
+}
+
+export interface ValidateFields {
+ image: ValidatorResult;
+ title: ValidatorResult;
+ content: ValidatorResult;
+ price: ValidatorResult;
+ tagList: ValidatorResult;
+ inquiry: ValidatorResult;
+ email: ValidatorResult;
+ password: ValidatorResult;
+ passwordCheck: ValidatorResult;
+ nickname: ValidatorResult;
+}
+
+export type FormFieldName = keyof ValidateFields;
+
+export type ValidatorParams = {
+ image: [value: string];
+ title: [value: string];
+ content: [value: string];
+ price: [value: string];
+ tagList: [value: string[]];
+ inquiry: [value: string];
+ email: [value: string];
+ password: [value: string];
+ passwordCheck: [value: string, password: string];
+ nickname: [value: string];
+};
+
+export interface ValidatorRules {
+ image: (value: string) => ValidatorResult;
+ title: (value: string) => ValidatorResult;
+ content: (value: string) => ValidatorResult;
+ price: (value: string) => ValidatorResult;
+ tagList: (value: string[]) => ValidatorResult;
+ inquiry: (value: string) => ValidatorResult;
+ email: (value: string) => ValidatorResult;
+ password: (value: string) => ValidatorResult;
+ passwordCheck: (value: string, password: string) => ValidatorResult;
+ nickname: (value: string) => ValidatorResult;
+}
+
+/* 공통 유효성 체크 */
+export const validators: ValidatorRules = {
+ image: (value) => {
+ if (value)
+ return {
+ isValid: false,
+ message: "*이미지 등록은 최대 1개까지 가능합니다.",
+ };
+ return { isValid: true, message: "" };
+ },
+ title: (value) => {
+ if (!value) return { isValid: false, message: "상품명을 입력해주세요" };
+ return { isValid: true, message: "" };
+ },
+ content: (value) => {
+ if (!value) return { isValid: false, message: "상품 소개를 입력해주세요" };
+ return { isValid: true, message: "" };
+ },
+ price: (value) => {
+ const unformattedVal = value.replace(/,/g, "");
+ const regex = /^[1-9]\d*$/;
+ if (!value) return { isValid: false, message: "판매 가격을 입력해주세요" };
+ if (!regex.test(unformattedVal))
+ return {
+ isValid: false,
+ message: "판매 가격 형식을 올바르게 입력해주세요",
+ };
+
+ return { isValid: true, message: "" };
+ },
+ tagList: (value) => {
+ if (value.length === 0)
+ return { isValid: false, message: "태그를 입력해주세요" };
+ return { isValid: true, message: "" };
+ },
+ inquiry: (value) => {
+ if (!value) return { isValid: false, message: "문의 내용을 입력해주세요" };
+ return { isValid: true, message: "" };
+ },
+ email: (value) => {
+ const regex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/i;
+ if (!value) return { isValid: false, message: "이메일을 입력해주세요" };
+ if (!regex.test(value))
+ return {
+ isValid: false,
+ message: "잘못된 이메일 형식입니다",
+ };
+
+ return { isValid: true, message: "" };
+ },
+ password: (value) => {
+ if (!value) return { isValid: false, message: "비밀번호를 입력해주세요" };
+ if (value.length < 8)
+ return {
+ isValid: false,
+ message: "비밀번호를 8자 이상 입력해주세요",
+ };
+
+ return { isValid: true, message: "" };
+ },
+ passwordCheck: (value, password) => {
+ if (!password)
+ return { isValid: false, message: "비밀번호를 먼저 입력해주세요" };
+ if (value.length < 8)
+ return { isValid: false, message: "비밀번호를 8자 이상 입력해주세요" };
+ if (value !== password)
+ return { isValid: false, message: "비밀번호가 일치하지 않습니다" };
+ return { isValid: true, message: "" };
+ },
+ nickname: (value) => {
+ if (!value) return { isValid: false, message: "닉네임을 입력해주세요" };
+ return { isValid: true, message: "" };
+ },
+};
diff --git a/src/utils/formValidation.js b/src/utils/formValidation.js
deleted file mode 100644
index 7b7375d7..00000000
--- a/src/utils/formValidation.js
+++ /dev/null
@@ -1,51 +0,0 @@
-/* 공통 유효성 체크 */
-export const validators = {
- image: (value) => {
- if (value)
- return {
- isValid: false,
- message: "*이미지 등록은 최대 1개까지 가능합니다.",
- };
- return { isValid: true, message: "" };
- },
- title: (value) => {
- if (!value) return { isValid: false, message: "상품명을 입력해주세요" };
- return { isValid: true, message: "" };
- },
- content: (value) => {
- if (!value) return { isValid: false, message: "상품 소개를 입력해주세요" };
- return { isValid: true, message: "" };
- },
- price: (value) => {
- const unformattedVal = value.replace(/,/g, "");
- const regex = /^[1-9]\d*$/;
- if (!value) return { isValid: false, message: "판매 가격을 입력해주세요" };
- if (!regex.test(unformattedVal))
- return {
- isValid: false,
- message: "판매 가격 형식을 올바르게 입력해주세요",
- };
-
- return { isValid: true, message: "" };
- },
- tagList: (value) => {
- if (value.length === 0)
- return { isValid: false, message: "태그를 입력해주세요" };
- return { isValid: true, message: "" };
- },
- inquiry: (value) => {
- if (!value) return { isValid: false, message: "문의 내용을 입력해주세요" };
- return { isValid: true, message: "" };
- },
-};
-
-export const validateInput = (validatorType, value) => {
- const errorValidator = () => ({
- isValid: false,
- message: "유효성 체크가 정의되지 않았습니다.",
- });
-
- const validator = validators[validatorType] || errorValidator;
- const { isValid, message } = validator(value);
- return { isValid, message };
-};
diff --git a/src/utils/formValidation.ts b/src/utils/formValidation.ts
new file mode 100644
index 00000000..56ae51de
--- /dev/null
+++ b/src/utils/formValidation.ts
@@ -0,0 +1,22 @@
+import {
+ ValidatorResult,
+ FormFieldName,
+ validators,
+ ValidatorParams,
+} from "../types/form";
+
+export const validateInput = (
+ validatorType: T,
+ ...values: ValidatorParams[T]
+): ValidatorResult => {
+ const errorValidator = () => ({
+ isValid: false,
+ message: "유효성 체크가 정의되지 않았습니다.",
+ });
+
+ const validator =
+ (validators[validatorType] as (
+ ...args: ValidatorParams[T]
+ ) => ValidatorResult) || errorValidator;
+ return validator(...values);
+};
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 00000000..cc663b6e
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,114 @@
+{
+ "compilerOptions": {
+ /* Visit https://aka.ms/tsconfig to read more about this file */
+
+ /* Projects */
+ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
+ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
+ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
+ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
+ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
+ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
+
+ /* Language and Environment */
+ "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
+ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+ "jsx": "react-jsx" /* Specify what JSX code is generated. */,
+ // "libReplacement": true, /* Enable lib replacement. */
+ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
+ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
+ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
+ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
+ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
+ // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
+ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
+ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
+ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
+
+ /* Modules */
+ "module": "commonjs" /* Specify what module code is generated. */,
+ // "rootDir": "./", /* Specify the root folder within your source files. */
+ // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
+ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
+ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
+ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
+ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
+ // "types": [], /* Specify type package names to be included without being referenced in a source file. */
+ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
+ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
+ // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
+ // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
+ // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
+ // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
+ // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
+ // "noUncheckedSideEffectImports": true, /* Check side effect imports. */
+ // "resolveJsonModule": true, /* Enable importing .json files. */
+ // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
+ // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
+
+ /* JavaScript Support */
+ // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
+ // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
+ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
+
+ /* Emit */
+ // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
+ // "declarationMap": true, /* Create sourcemaps for d.ts files. */
+ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
+ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
+ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
+ // "noEmit": true, /* Disable emitting files from a compilation. */
+ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
+ // "outDir": "./", /* Specify an output folder for all emitted files. */
+ // "removeComments": true, /* Disable emitting comments. */
+ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
+ // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
+ // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
+ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
+ // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
+ // "newLine": "crlf", /* Set the newline character for emitting files. */
+ // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
+ // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
+ // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
+ // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
+ // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
+
+ /* Interop Constraints */
+ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
+ // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
+ // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
+ // "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */
+ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
+ "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
+ // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
+ "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
+
+ /* Type Checking */
+ "strict": true /* Enable all strict type-checking options. */,
+ // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
+ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
+ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
+ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
+ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
+ // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
+ // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
+ // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
+ // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
+ // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
+ // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
+ // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
+ // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
+ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
+ // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
+ // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
+ // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
+ // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
+ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
+
+ /* Completeness */
+ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
+ "skipLibCheck": true /* Skip type checking all .d.ts files. */
+ },
+ "include": ["src/**/*"]
+}