diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..673444457 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ + +panda/server/node_modules/ +s6_clean.patch + +build/ +node_modules/ + diff --git a/panda/panda-market-react/.env b/panda/panda-market-react/.env new file mode 100644 index 000000000..deda28b8d --- /dev/null +++ b/panda/panda-market-react/.env @@ -0,0 +1 @@ +REACT_APP_API_BASE_URL=https://panda-market-api.vercel.app \ No newline at end of file diff --git a/panda/panda-market-react/public/images/home/feature1-image.svg b/panda/panda-market-react/public/images/home/feature1-image.svg new file mode 100644 index 000000000..8786f982c --- /dev/null +++ b/panda/panda-market-react/public/images/home/feature1-image.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/panda/panda-market-react/public/images/home/feature2-image.svg b/panda/panda-market-react/public/images/home/feature2-image.svg new file mode 100644 index 000000000..a5e34a920 --- /dev/null +++ b/panda/panda-market-react/public/images/home/feature2-image.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/panda/panda-market-react/public/images/home/feature3-image.svg b/panda/panda-market-react/public/images/home/feature3-image.svg new file mode 100644 index 000000000..acad3456f --- /dev/null +++ b/panda/panda-market-react/public/images/home/feature3-image.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/panda/panda-market-react/src/App.js b/panda/panda-market-react/src/App.js new file mode 100644 index 000000000..d87aadf4b --- /dev/null +++ b/panda/panda-market-react/src/App.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; + +import LandingPage from './pages/LandingPage'; +import HomePage from './pages/HomePage'; +import ItemsPage from './pages/ItemsPage'; +import Registration from './pages/Registration'; +import ItemDetailPage from './pages/ItemDetailPage'; + +import HomePage from './pages/HomePage'; +import './styles/reset.css'; + + +function App() { + return ( + + + + } /> + } /> + } /> + } /> + } /> + + } /> + + + + ); +} + +export default App; \ No newline at end of file diff --git a/panda/panda-market-react/src/assets/home/bottom.svg b/panda/panda-market-react/src/assets/home/bottom.svg new file mode 100644 index 000000000..a814bce99 --- /dev/null +++ b/panda/panda-market-react/src/assets/home/bottom.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/panda/panda-market-react/src/assets/home/hero-img.svg b/panda/panda-market-react/src/assets/home/hero-img.svg new file mode 100644 index 000000000..0a7feb2b5 --- /dev/null +++ b/panda/panda-market-react/src/assets/home/hero-img.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/panda/panda-market-react/src/assets/tag-close.svg b/panda/panda-market-react/src/assets/tag-close.svg new file mode 100644 index 000000000..1daecf709 --- /dev/null +++ b/panda/panda-market-react/src/assets/tag-close.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/panda/panda-market-react/src/components/BestProductList.js b/panda/panda-market-react/src/components/BestProductList.js new file mode 100644 index 000000000..d699f7b66 --- /dev/null +++ b/panda/panda-market-react/src/components/BestProductList.js @@ -0,0 +1,21 @@ +import React from 'react'; +import ProductCard from './ProductCard'; + +import '../styles/BestProductList.css'; + + +const BestProductList = ({ products }) => { + return ( +
+

베스트 상품

+
+ {products.map(product => ( + + ))} +
+
+ ); +}; + + +export default BestProductList; \ No newline at end of file diff --git a/panda/panda-market-react/src/components/Header.js b/panda/panda-market-react/src/components/Header.js new file mode 100644 index 000000000..6854c5ce8 --- /dev/null +++ b/panda/panda-market-react/src/components/Header.js @@ -0,0 +1,59 @@ +import React from 'react'; + +import { Link, NavLink, useLocation } from 'react-router-dom'; +import logoImg from '../assets/logo.svg'; +import '../styles/header.css'; + +function Header() { + const { pathname } = useLocation(); + const isItems = pathname.startsWith('/items'); + + +import '../styles/header.css'; +import logoImg from '../assets/logo.svg'; + +function Header() { + + return ( +
+
+
+ + + 판다마켓로고 + + + +
+ + + 로그인 + + + + 판다마켓로고 + + +
+ + + +
+ ); +} + +export default Header; \ No newline at end of file diff --git a/panda/panda-market-react/src/components/Pagination.js b/panda/panda-market-react/src/components/Pagination.js new file mode 100644 index 000000000..323a12736 --- /dev/null +++ b/panda/panda-market-react/src/components/Pagination.js @@ -0,0 +1,49 @@ +import React from 'react'; +import '../styles/pagination.css'; + +function Pagination({ currentPage, totalPages, onPageChange }) { + + const Max = 5; + + const windowIndex = Math.floor((currentPage - 1) / Max); + const start = windowIndex * Max + 1; + const end = Math.min(start + Max - 1, totalPages); + + const count = Math.max(0, end - start + 1); + const pages = Array.from({ length: count }, (_, i) => start + i); + + const pages = Array.from({ length: totalPages }, (_, i) => i + 1); + + + const goToPrev = () => { + if (currentPage > 1) onPageChange(currentPage - 1); + }; + + const goToNext = () => { + if (currentPage < totalPages) onPageChange(currentPage + 1); + };; + + return ( +
+ + + {pages.map((num) => ( + + ))} + + +
+ ); +} + +export default Pagination; \ No newline at end of file diff --git a/panda/panda-market-react/src/components/ProductList.js b/panda/panda-market-react/src/components/ProductList.js new file mode 100644 index 000000000..2be569f08 --- /dev/null +++ b/panda/panda-market-react/src/components/ProductList.js @@ -0,0 +1,17 @@ +import React from 'react'; +import ProductCard from './ProductCard'; + +import '../styles/ProductList.css'; + + +function ProductList({ products }) { + return ( +
+ {products.map((item) => ( + + ))} +
+ ); +} + +export default ProductList; \ No newline at end of file diff --git a/panda/panda-market-react/src/hooks/useProductRegistrationForm.js b/panda/panda-market-react/src/hooks/useProductRegistrationForm.js new file mode 100644 index 000000000..27da5d3fc --- /dev/null +++ b/panda/panda-market-react/src/hooks/useProductRegistrationForm.js @@ -0,0 +1,108 @@ +import { useCallback, useMemo, useState } from 'react'; + +export default function useProductRegistrationForm(initial = {}) { + const [name, setName] = useState(initial.name ?? ''); + const [desc, setDesc] = useState(initial.desc ?? ''); + const [price, setPrice] = useState(initial.price ?? ''); + const [tagInput, setTagInput] = useState(''); + const [tags, setTags] = useState(Array.isArray(initial.tags) ? initial.tags : []); + + const [tagInputError, setTagInputError] = useState(''); + + const [touched, setTouched] = useState({ name: false, desc: false, price: false, tags: false }); + const [submitted, setSubmitted] = useState(false); + + const addTag = useCallback(() => { + let raw = tagInput.trim(); + if (!raw) return false; + + if (raw.startsWith('#')) raw = raw.slice(1).trim(); + + const clean = raw.replace(/\s+/g, ' '); + + if (clean.length < 1 || clean.length > 5) { + setTagInputError('태그는 1~5자 이내로 입력해주세요.'); + return false; + } + if (tags.includes(clean)) { + setTagInputError('이미 추가된 태그입니다.'); + return false; + } + + setTags(prev => [...prev, clean]); + setTagInput(''); + setTagInputError(''); + return true; + }, [tagInput, tags]); + + const removeTag = useCallback((t) => { + setTags(prev => prev.filter(x => x !== t)); + }, []); + + const onKeyDownTagInput = useCallback((e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addTag(); + } + }, [addTag]); + + const errors = useMemo(() => { + const e = {}; + + const n = name.trim().length; + if (n < 1 || n > 10) { + e.name = '10자 이내로 입력해주세요'; + } + + const d = desc.trim().length; + if (d < 10 || d > 100) { + e.desc = '10자 이상 입력해주세요'; + } + + const s = String(price).trim(); + const isDigits = /^[0-9]+$/.test(s); + if (!s || !isDigits) { + e.price = '숫자로 입력해주세요'; + } + + if (tags.length === 0) { + e.tags = '5글자 이내로 입력해주세요'; + } else if (tags.some(t => t.length === 0 || t.length > 5)) { + e.tags = '태그는 1~5자 이내로 입력해주세요.'; + } + + return e; + }, [name, desc, price, tags]); + + const isValid = useMemo(() => Object.keys(errors).length === 0, [errors]); + + const touchField = useCallback((field) => { + setTouched(prev => (prev[field] ? prev : { ...prev, [field]: true })); + }, []); + + const onFieldFocus = useCallback((field) => () => touchField(field), [touchField]); + const onFieldBlur = useCallback((field) => () => touchField(field), [touchField]); + + const shouldShowError = useCallback( + (field) => (submitted || touched[field]) && !!errors[field], + [submitted, touched, errors] + ); + + return { + values: { name, desc, price, tagInput, tags }, + + setters: { setName, setDesc, setPrice, setTagInput, setTags }, + + addTag, + removeTag, + onKeyDownTagInput, + + errors, + tagInputError, + isValid, + setSubmitted, + onFieldFocus, + onFieldBlur, + shouldShowError, + }; +} \ No newline at end of file diff --git a/panda/panda-market-react/src/hooks/useResponsive.js b/panda/panda-market-react/src/hooks/useResponsive.js new file mode 100644 index 000000000..ec180ff78 --- /dev/null +++ b/panda/panda-market-react/src/hooks/useResponsive.js @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react'; + +export default function useResponsive() { + const queryDesktop = '(min-width: 1200px)'; + const queryTablet = '(min-width: 768px) and (max-width: 1199px)'; + const queryMobile = '(max-width: 767px)'; + + + const calculateBreakpoint = () => { +======= + const getBP = () => { + + if (window.matchMedia(queryDesktop).matches) return 'desktop'; + if (window.matchMedia(queryTablet).matches) return 'tablet'; + return 'mobile'; + }; + + + const [breakpoint, setBreakpoint] = useState(calculateBreakpoint()); + + useEffect(() => { + const handler = () => setBreakpoint(calculateBreakpoint()); + + window.addEventListener('resize', handler); + window.addEventListener('orientationchange', handler); + + return () => { + window.removeEventListener('resize',handler); + + const [breakpoint, setBreakpoint] = useState(getBP()); + + useEffect(() => { + const handler = () => setBreakpoint(getBP()); + const m1 = window.matchMedia(queryDesktop); + const m2 = window.matchMedia(queryTablet); + const m3 = window.matchMedia(queryMobile); + + m1.addEventListener('change', handler); + m2.addEventListener('change', handler); + m3.addEventListener('change', handler); + window.addEventListener('orientationchange', handler); + + return () => { + m1.removeEventListener('change', handler); + m2.removeEventListener('change', handler); + m3.removeEventListener('change', handler); + + window.removeEventListener('orientationchange', handler); + }; + }, []); + + return breakpoint; +} \ No newline at end of file diff --git a/panda/panda-market-react/src/index.js b/panda/panda-market-react/src/index.js new file mode 100644 index 000000000..a6fafa68d --- /dev/null +++ b/panda/panda-market-react/src/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +import './styles/reset.css'; +import './styles/global.css'; +import './styles/home.css'; + + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render(); \ No newline at end of file diff --git a/panda/panda-market-react/src/pages/HomePage.js b/panda/panda-market-react/src/pages/HomePage.js new file mode 100644 index 000000000..68ac915ff --- /dev/null +++ b/panda/panda-market-react/src/pages/HomePage.js @@ -0,0 +1,233 @@ +import React, { useState, useEffect } from 'react'; + +import { Link } from 'react-router-dom'; + +import Header from '../components/Header'; +import Footer from '../components/Footer'; +import BestProductList from '../components/BestProductList'; +import ProductList from '../components/ProductList'; +import Pagination from '../components/Pagination'; +import { getProductList } from '../services/productService'; +import useResponsive from '../hooks/useResponsive'; + +import '../styles/homepage.css'; + +import '../styles/home.css'; +import searchIcon from '../assets/search-icon.svg'; + + +const LIST_PAGE_SIZE = { + + desktop: 10, + tablet: 6, + mobile: 4, +}; + +const BEST_PAGE_SIZE = { + desktop: 4, + tablet: 2, + mobile: 1, + + desktop: 20, + tablet: 12, + mobile: 10, +}; + +const BEST_PAGE_SIZE = { + desktop: 8, + tablet: 4, + mobile: 2, + +}; + + +function HomePage() { + const breakpoint = useResponsive(); + const [products, setProducts] = useState([]); + const [bestProducts, setBestProducts] = useState([]); + + const [orderBy, setOrderBy] = useState('recent'); + const [rawKeyword, setRawKeyword] = useState(''); + + const [sort, setSort] = useState('recent'); + + const [keyword, setKeyword] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + + + const listPageSize = LIST_PAGE_SIZE[breakpoint] ?? LIST_PAGE_SIZE.mobile; + const bestPageSize = BEST_PAGE_SIZE[breakpoint] ?? BEST_PAGE_SIZE.mobile; + + useEffect(() => { + const timeoutId = setTimeout(() => { + setKeyword(rawKeyword.trim()); + }, 300); + return () => clearTimeout(timeoutId); + }, [rawKeyword]); + + useEffect(() => { + setCurrentPage(1); + }, [breakpoint, orderBy, keyword]); + + const listPageSize = LIST_PAGE_SIZE[breakpoint]; + const bestPageSize = BEST_PAGE_SIZE[breakpoint]; + + useEffect(() => { + setCurrentPage(1); + }, [breakpoint]); + + useEffect(() => { + setCurrentPage(1); + }, [sort, keyword]); + + + useEffect(() => { + let cancelled = false; + + (async () => { + try { + const data = await getProductList({ + page: currentPage, + pageSize: listPageSize, + + orderBy, + keyword, + }); + if (!cancelled) { + setProducts(data?.products || []); + + sort, + keyword, + }); + if (!cancelled) { + setProducts(data.products || []); + + setTotalPages(data.totalPages || 1); + } + } catch (err) { + console.error('[상품 목록 불러오기 실패]', err); + } + })(); + + return () => { cancelled = true; }; + + }, [currentPage, listPageSize, orderBy, keyword]); + + }, [currentPage, listPageSize, sort, keyword]); + + + useEffect(() => { + let cancelled = false; + + (async () => { + try{ + const data = await getProductList({ + page: 1, + pageSize: bestPageSize, + + orderBy: 'favorite', + keyword: '', + }); + if (!cancelled) { + setBestProducts(data?.products || []); + + sort: 'favorite', + keyword: '', + }); + if (!cancelled) { + setBestProducts(data.products || []); + + } + } catch (err) { + console.error('[베스트 상품 불러오기 실패]', err); + } + })(); + + return () => { cancelled = true; }; + }, [bestPageSize]); + + return ( + <> +
+ +
+
+ +
+ +
+ + + +
+

판매 중인 상품

+ +
+
+ 검색 아이콘 + setRawKeyword(e.target.value)} + + value={keyword} + onChange={(e) => setKeyword(e.target.value)} + + placeholder="검색할 상품을 입력해주세요" + className="search-input" + /> +
+ + + 상품 등록하기 + + setSort(e.target.value)} + + className="sort-select" + > + + + +
+
+ + +
+ + +
+ +
+
+ +
+ +
+ + setCurrentPage(page)} + /> + +
+