diff --git a/.env b/.env
new file mode 100644
index 0000000..7bc5ba9
--- /dev/null
+++ b/.env
@@ -0,0 +1 @@
+VITE_BASE_URL=https://panda-market-api.vercel.app
\ No newline at end of file
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..0a72520
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,6 @@
+{
+ "trailingComma": "es5",
+ "tabWidth": 2,
+ "semi": true,
+ "singleQuote": true
+}
diff --git a/eslint.config.js b/eslint.config.js
index 4fa125d..5fedac7 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,29 +1,55 @@
-import js from '@eslint/js'
-import globals from 'globals'
-import reactHooks from 'eslint-plugin-react-hooks'
-import reactRefresh from 'eslint-plugin-react-refresh'
-import { defineConfig, globalIgnores } from 'eslint/config'
+import js from '@eslint/js';
+import globals from 'globals';
+import reactHooks from 'eslint-plugin-react-hooks';
+import reactRefresh from 'eslint-plugin-react-refresh';
-export default defineConfig([
- globalIgnores(['dist']),
+export default [
+ { ignores: ['dist', 'node_modules'] },
{
files: ['**/*.{js,jsx}'],
- extends: [
- js.configs.recommended,
- reactHooks.configs.flat.recommended,
- reactRefresh.configs.vite,
- ],
languageOptions: {
- ecmaVersion: 2020,
- globals: globals.browser,
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ globals: {
+ ...globals.browser,
+ ...globals.es2021,
+ ...globals.node,
+ },
parserOptions: {
- ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
- sourceType: 'module',
},
},
+ plugins: {
+ 'react-hooks': reactHooks,
+ 'react-refresh': reactRefresh,
+ },
rules: {
- 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
+ // 1. 리액트 권장 규칙
+ ...js.configs.recommended.rules,
+ ...reactHooks.configs.recommended.rules,
+
+ // 2. Vite 전용 규칙 (HMR 유지용)
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+
+ // 3. 실무형 커스텀 규칙
+ 'no-unused-vars': [
+ 'warn',
+ {
+ varsIgnorePattern: '^[A-Z_]', // 대문자로 시작하는 변수(컴포넌트 등) 무시
+ argsIgnorePattern: '^_', // _로 시작하는 인자 무시
+ },
+ ],
+ 'no-console': 'warn', // 배포 전 console.log 체크용
+
+ // 4. Prettier와 싸우지 않기 위한 설정
+ // 스타일 관련은 에러를 내지 않고 Prettier가 알아서 하게 둡니다.
+ indent: 'off',
+ quotes: 'off',
+ semi: 'off',
+ 'comma-dangle': 'off',
},
},
-])
+];
diff --git a/package-lock.json b/package-lock.json
index 7cbc53d..29a1955 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23,6 +23,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
+ "husky": "^9.1.7",
"vite": "^7.3.1"
}
},
@@ -2496,6 +2497,22 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
+ "node_modules/husky": {
+ "version": "9.1.7",
+ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
+ "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "husky": "bin.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/typicode"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
diff --git a/package.json b/package.json
index c62b76f..b8331c1 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "prepare": "husky install"
},
"dependencies": {
"axios": "^1.13.5",
@@ -25,6 +26,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
+ "husky": "^9.1.7",
"vite": "^7.3.1"
}
}
diff --git a/src/apis/axiosInstance.js b/src/apis/axiosInstance.js
index 424251d..0394e91 100644
--- a/src/apis/axiosInstance.js
+++ b/src/apis/axiosInstance.js
@@ -1,7 +1,8 @@
-import axios from "axios";
+// src/apis/axiosInstance.js
+import axios from 'axios';
const axiosInstance = axios.create({
- baseURL: "https://panda-market-api.vercel.app",
+ baseURL: import.meta.env.VITE_BASE_URL,
});
export default axiosInstance;
diff --git a/src/assets/Delete.svg b/src/assets/Delete.svg
new file mode 100644
index 0000000..1af75be
--- /dev/null
+++ b/src/assets/Delete.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/assets/plus.svg b/src/assets/plus.svg
new file mode 100644
index 0000000..5bb9abf
--- /dev/null
+++ b/src/assets/plus.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/components/AddItemInput.jsx b/src/components/AddItemInput.jsx
new file mode 100644
index 0000000..15b3b0a
--- /dev/null
+++ b/src/components/AddItemInput.jsx
@@ -0,0 +1,77 @@
+import styled from 'styled-components';
+import Tag from './Tag';
+
+const AddItemInput = ({
+ children,
+ height = 'short',
+ placeholder,
+ handleTagAdd,
+ tag,
+ handleTagDelete,
+ name,
+ value,
+ onChange,
+}) => {
+ const isLong = height === 'long';
+ const tagInput = children === '태그';
+
+ return (
+
+
+
+ {tagInput ? (
+
+ {tag.map((title) => (
+
+ {title}
+
+ ))}
+
+ ) : null}
+
+ );
+};
+
+export default AddItemInput;
+
+const InputContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ margin-bottom: 24px;
+`;
+
+const Label = styled.label`
+ font-weight: 700;
+ font-size: 18px;
+ line-height: 26px;
+`;
+
+const Input = styled.input`
+ border-radius: 12px;
+ padding: 16px 24px;
+ border: none;
+ color: var(--secondary-800);
+ background-color: var(--coolGray-100);
+ height: ${({ $height }) => ($height === 'long' ? '282px' : '56px')};
+ outline: none;
+
+ &::placeholder {
+ color: var(--secondary-400);
+ }
+`;
+
+const TagContainer = styled.div`
+ display: flex;
+ justify-content: flex-start;
+ align-items: center;
+ gap: 12px;
+`;
diff --git a/src/components/AllProduct.jsx b/src/components/AllProduct.jsx
index da12822..e8beeca 100644
--- a/src/components/AllProduct.jsx
+++ b/src/components/AllProduct.jsx
@@ -1,11 +1,7 @@
-import styles from "./AllProduct.module.css";
-import ProductItem from "./ProductItem";
-
-const AllProduct = ({products, isLoading}) => {
- if (isLoading) {
- return null;
- }
+import styles from './AllProduct.module.css';
+import ProductItem from './ProductItem';
+const AllProduct = ({ products }) => {
return (
diff --git a/src/components/BestProduct.jsx b/src/components/BestProduct.jsx
index c8d72be..fdbcbbf 100644
--- a/src/components/BestProduct.jsx
+++ b/src/components/BestProduct.jsx
@@ -1,11 +1,7 @@
-import styles from "./BestProduct.module.css";
-import ProductItem from "./ProductItem";
-
-const BestProduct = ({bestProducts, isBestLoading}) => {
- if (isBestLoading) {
- return null;
- }
+import styles from './BestProduct.module.css';
+import ProductItem from './ProductItem';
+const BestProduct = ({ bestProducts }) => {
return (
diff --git a/src/components/Button.jsx b/src/components/Button.jsx
index 3d47212..9ee49e8 100644
--- a/src/components/Button.jsx
+++ b/src/components/Button.jsx
@@ -1,7 +1,24 @@
-import styles from "./Button.module.css";
+import styled from 'styled-components';
-const Button = ({children}) => {
- return
{children}
;
+const Button = ({ children, type = 'button', isActive = false }) => {
+ return (
+
+ {children}
+
+ );
};
export default Button;
+
+const ButtonS = styled.button`
+ background-color: ${({ $isActive }) =>
+ $isActive ? 'var(--primary-100)' : 'var(--coolGray-400)'};
+ color: var(--coolGray-100);
+ padding: 12px 23px;
+ border-radius: 10px;
+ font-weight: 600;
+ font-size: 16px;
+ line-height: 26px;
+ white-space: nowrap;
+ flex-shrink: 0;
+`;
diff --git a/src/components/Button.module.css b/src/components/Button.module.css
deleted file mode 100644
index f27075f..0000000
--- a/src/components/Button.module.css
+++ /dev/null
@@ -1,11 +0,0 @@
-.button {
- background-color: var(--primary-100);
- color: var(--coolGray-100);
- padding: 12px 23px;
- border-radius: 10px;
- font-weight: 600;
- font-size: 16px;
- line-height: 26px;
- white-space: nowrap;
- flex-shrink: 0;
-}
diff --git a/src/components/DropDown.jsx b/src/components/DropDown.jsx
index dea4985..8ada6cb 100644
--- a/src/components/DropDown.jsx
+++ b/src/components/DropDown.jsx
@@ -1,12 +1,17 @@
-import styles from "./DropDown.module.css";
+import styles from './DropDown.module.css';
-const DropDown = ({handleProductsSort}) => {
+const DropDown = ({ handleProductsSort, children }) => {
return (
);
};
+const Option = ({ value, children }) => {
+ return
;
+};
+
+DropDown.Option = Option;
+
export default DropDown;
diff --git a/src/components/ImageRegistration.jsx b/src/components/ImageRegistration.jsx
new file mode 100644
index 0000000..a00adb9
--- /dev/null
+++ b/src/components/ImageRegistration.jsx
@@ -0,0 +1,67 @@
+import styled from 'styled-components';
+import plusIcon from '../assets/plus.svg';
+import ImgBox from './ImgBox';
+
+const ImageRegistration = ({
+ name,
+ handleImageRegis,
+ preview,
+ handleImageDelete,
+ handleErrorMessage,
+}) => {
+ return (
+
+
+
+
+
+ );
+};
+
+export default ImageRegistration;
+
+const Container = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 24px;
+
+ @media (max-width: 1199px) {
+ gap: 10px;
+ }
+`;
+
+const Label = styled.label`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--secondary-100);
+ width: 282px;
+ height: 282px;
+ border-radius: 12px;
+ cursor: pointer;
+
+ @media (max-width: 1199px) {
+ width: 168px;
+ height: 168px;
+ }
+`;
+
+const HiddenInput = styled.input`
+ display: none;
+`;
+
+const P = styled.p`
+ font-weight: 400;
+ line-height: 26px;
+ color: var(--coolGray-400);
+`;
diff --git a/src/components/ImgBox.jsx b/src/components/ImgBox.jsx
new file mode 100644
index 0000000..1f271cd
--- /dev/null
+++ b/src/components/ImgBox.jsx
@@ -0,0 +1,47 @@
+import styled from 'styled-components';
+import deleteImg from '../assets/Delete.svg';
+
+const ImgBox = ({ preview, handleImageDelete }) => {
+ if (!preview) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ );
+};
+
+export default ImgBox;
+
+const Container = styled.div`
+ position: relative;
+`;
+
+const Button = styled.button`
+ position: absolute;
+ top: 12px;
+ left: 248px;
+
+ @media (max-width: 1199px) {
+ left: 134px;
+ }
+`;
+
+const Image = styled.img`
+ width: 282px;
+ height: 282px;
+ border-radius: 12px;
+ object-fit: cover;
+ object-position: center;
+ border: 1px solid var(--coolGray-50);
+
+ @media (max-width: 1199px) {
+ width: 168px;
+ height: 168px;
+ }
+`;
diff --git a/src/components/NavBar.jsx b/src/components/NavBar.jsx
index a5c14f4..f4c3f0f 100644
--- a/src/components/NavBar.jsx
+++ b/src/components/NavBar.jsx
@@ -1,10 +1,15 @@
+import {NavLink, useLocation} from "react-router-dom";
import styles from "./NavBar.module.css";
import NavBarButton from "./NavBarButton";
import logo from "../assets/logo.svg";
import myPageLogo from "../assets/myPageLogo.svg";
-import {NavLink} from "react-router-dom";
const Navbar = () => {
+ const location = useLocation();
+
+ const marketActive =
+ location.pathname === "/items" || location.pathname === "/additem";
+
return (
@@ -21,7 +26,7 @@ const Navbar = () => {
(isActive ? styles.active : null)}
+ className={marketActive ? styles.active : null}
>
중고마켓
diff --git a/src/components/NavBar.module.css b/src/components/NavBar.module.css
index 1ae355b..1099e15 100644
--- a/src/components/NavBar.module.css
+++ b/src/components/NavBar.module.css
@@ -42,7 +42,8 @@
}
@media (max-width: 1199px) {
-}
-
-@media (max-width: 767px) {
+ .buttonContainer,
+ .navBarLeftContainer {
+ gap: 10px;
+ }
}
diff --git a/src/components/Tag.jsx b/src/components/Tag.jsx
new file mode 100644
index 0000000..4afede0
--- /dev/null
+++ b/src/components/Tag.jsx
@@ -0,0 +1,33 @@
+import styled from 'styled-components';
+import deleteImg from '../assets/delete.svg';
+
+const Tag = ({ children, handleTagDelete }) => {
+ return (
+
+ #{children}
+
+
+ );
+};
+
+export default Tag;
+
+const Container = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: auto;
+ gap: 8px;
+ height: 36px;
+ background-color: var(--coolGray-100);
+ padding: 6px 12px;
+ border-radius: 26px;
+`;
+
+const Text = styled.p`
+ line-height: 26px;
+ font-weight: 400;
+ color: var(--secondary-800);
+`;
diff --git a/src/index.css b/src/index.css
index 5770ee2..ccdc36c 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,14 +1,19 @@
:root {
--secondary-100: #f3f4f6;
+ --secondary-400: #9ca3af;
--secondary-600: #4b5563;
--secondary-800: #1f2937;
--secondary-900: #111827;
--primary-100: #3692ff;
+ --coolGray-50: #f9fafb;
--coolGray-100: #f3f4f6;
--coolGray-200: #e5e7eb;
+ --coolGray-400: #9ca3af;
--coolGray-500: #6b7280;
+
+ --errorRed: #f74747;
}
* {
diff --git a/src/pages/AddItemPage.jsx b/src/pages/AddItemPage.jsx
index 77d2c39..0f8132d 100644
--- a/src/pages/AddItemPage.jsx
+++ b/src/pages/AddItemPage.jsx
@@ -1,5 +1,185 @@
+import { useMemo, useState } from 'react';
+import styled from 'styled-components';
+import NavBar from '../components/NavBar';
+import Button from '../components/Button';
+import ImageRegistration from '../components/ImageRegistration';
+import AddItemInput from '../components/AddItemInput';
+
const AddItemPage = () => {
- return
AddItemPage
;
+ const [file, setFile] = useState();
+ const [isErrorMessage, setIsErrorMessage] = useState(false);
+ const [tag, setTag] = useState([]);
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [price, setPrice] = useState('');
+
+ const isActive = file && name && description && price;
+
+ const preview = useMemo(() => {
+ if (!file) return null;
+ return URL.createObjectURL(file);
+ }, [file]);
+
+ //폼데이터 보내기
+ const handleSubmit = (formData) => {
+ const finalData = {
+ images: file,
+ tags: tag,
+ price: Number(price),
+ description,
+ name,
+ };
+
+ console.log(finalData);
+ };
+
+ //이미지 등록
+ const handleImageRegis = (e) => {
+ setFile(e.target.files[0]);
+ };
+
+ //이미지 삭제
+ const handleImageDelete = () => {
+ setFile(null);
+ setIsErrorMessage(false);
+ };
+
+ //에러 메시지 띄우기
+ const handleErrorMessage = (e) => {
+ if (file) {
+ e.preventDefault();
+ setIsErrorMessage(true);
+ } else {
+ setIsErrorMessage(false);
+ }
+ };
+
+ // 태그 추가
+ const handleTagAdd = (e) => {
+ if (e.key === 'Enter' && e.nativeEvent.isComposing === false) {
+ e.preventDefault();
+ if (tag.includes(e.target.value)) {
+ e.target.value = '';
+ return;
+ }
+ setTag([...tag, e.target.value]);
+ e.target.value = '';
+ }
+ };
+
+ //태그 삭제
+ const handleTagDelete = (targetTitle) => {
+ setTag(tag.filter((title) => title !== targetTitle));
+ };
+
+ return (
+
+
+
+
+
+ 상품 이미지
+
+
+ *이미지 등록은 최대 1개까지 가능합니다.
+
+
+ setName(e.target.value)}
+ >
+ 상품명
+
+ setDescription(e.target.value)}
+ >
+ 상품 소개
+
+ setPrice(e.target.value)}
+ >
+ 판매가격
+
+
+ 태그
+
+
+
+ );
};
export default AddItemPage;
+
+//styled-components
+
+const Container = styled.form`
+ max-width: 1200px;
+ margin: 94px auto;
+ padding-right: 24px;
+ padding-left: 24px;
+
+ @media (max-width: 767px) {
+ min-width: 450px;
+ padding-right: 14px;
+ padding-left: 14px;
+ }
+`;
+
+const Header = styled.div`
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 24px;
+`;
+
+const Title = styled.h1`
+ color: var(--secondary-800);
+ font-weight: 700;
+ font-size: 20px;
+ line-height: 32px;
+`;
+
+const SemiTitle = styled.h2`
+ color: var(--secondary-800);
+ font-weight: 700;
+ font-size: 18px;
+ line-height: 26px;
+`;
+
+const ProductImageContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ margin-bottom: 24px;
+`;
+
+const ErrorMessage = styled.p`
+ display: ${({ $isErrorMessage }) => ($isErrorMessage ? '' : 'none')};
+ font-weight: 400;
+ line-height: 26px;
+ color: var(--errorRed);
+`;
diff --git a/src/pages/ItemsPage.jsx b/src/pages/ItemsPage.jsx
index 4e65dcd..da0c1fb 100644
--- a/src/pages/ItemsPage.jsx
+++ b/src/pages/ItemsPage.jsx
@@ -1,21 +1,21 @@
-import {useEffect, useState} from "react";
-import {Link} from "react-router-dom";
-import styles from "./ItemsPage.module.css";
-import NavBar from "../components/NavBar";
-import BestProduct from "../components/BestProduct";
-import AllProduct from "../components/AllProduct";
-import Pagination from "../components/Pagination";
-import Button from "../components/Button";
-import DropDown from "../components/DropDown";
-import SearchBar from "../components/SearchBar";
-import {getProducts} from "../apis/productApi";
+import { useEffect, useState } from 'react';
+import { Link } from 'react-router-dom';
+import styles from './ItemsPage.module.css';
+import NavBar from '../components/NavBar';
+import BestProduct from '../components/BestProduct';
+import AllProduct from '../components/AllProduct';
+import Pagination from '../components/Pagination';
+import Button from '../components/Button';
+import DropDown from '../components/DropDown';
+import SearchBar from '../components/SearchBar';
+import { getProducts } from '../apis/productApi';
const ItemsPage = () => {
const [products, setProducts] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [bestProducts, setBestProducts] = useState([]);
const [isBestLoading, setIsBestLoading] = useState(false);
- const [productsSort, setProductsSort] = useState("recent");
+ const [productsSort, setProductsSort] = useState('recent');
const [page, setPage] = useState(1);
// const [pageSize, setPageSize] = useState(10);
@@ -38,7 +38,7 @@ const ItemsPage = () => {
const fetchBestProducts = async () => {
try {
setIsBestLoading(true);
- const data = await getProducts({pageSize: 4, orderBy: "favorite"});
+ const data = await getProducts({ pageSize: 4, orderBy: 'favorite' });
setBestProducts(data.list);
} catch (error) {
console.error(error);
@@ -80,10 +80,7 @@ const ItemsPage = () => {
판다마켓 상품 목록
{/* h2태그를 쓰기 위한 */}
베스트 상품
-
+ {!isBestLoading &&
}
전체 상품
@@ -91,12 +88,15 @@ const ItemsPage = () => {
-
+
-
+
+ 최신순
+ 좋아요순
+
-
+ {!isLoading &&
}