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 && }