diff --git a/package-lock.json b/package-lock.json index 80568c27..72d9de22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "react-dom": "^19.0.0", "react-responsive": "^10.0.1", "react-router": "^7.5.3", - "react-router-dom": "^7.5.3" + "react-router-dom": "^7.5.3", + "styled-components": "^6.1.18" }, "devDependencies": { "@eslint/js": "^9.22.0", @@ -322,6 +323,27 @@ "node": ">=6.9.0" } }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", + "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.8.1" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz", @@ -1376,6 +1398,12 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/stylis": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", + "integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz", @@ -1520,6 +1548,15 @@ "node": ">=6" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001715", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz", @@ -1616,17 +1653,36 @@ "node": ">= 8" } }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, "node_modules/css-mediaquery": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==", "license": "BSD" }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -2292,7 +2348,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -2417,7 +2472,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -2462,6 +2516,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2665,6 +2725,12 @@ "integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==", "license": "MIT" }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2692,7 +2758,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -2711,6 +2776,68 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/styled-components": { + "version": "6.1.18", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.18.tgz", + "integrity": "sha512-Mvf3gJFzZCkhjY2Y/Fx9z1m3dxbza0uI9H1CbNZm/jSHCojzJhQ0R7bByrlFJINnMzz/gPulpoFFGymNwrsMcw==", + "license": "MIT", + "dependencies": { + "@emotion/is-prop-valid": "1.2.2", + "@emotion/unitless": "0.8.1", + "@types/stylis": "4.2.5", + "css-to-react-native": "3.2.0", + "csstype": "3.1.3", + "postcss": "8.4.49", + "shallowequal": "1.1.0", + "stylis": "4.3.2", + "tslib": "2.6.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + } + }, + "node_modules/styled-components/node_modules/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/stylis": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", + "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -2741,6 +2868,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "license": "0BSD" + }, "node_modules/turbo-stream": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", diff --git a/package.json b/package.json index 4796bcc1..8b7645a6 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "react-dom": "^19.0.0", "react-responsive": "^10.0.1", "react-router": "^7.5.3", - "react-router-dom": "^7.5.3" + "react-router-dom": "^7.5.3", + "styled-components": "^6.1.18" }, "devDependencies": { "@eslint/js": "^9.22.0", diff --git a/public/images/ic_X.png b/public/images/ic_X.png new file mode 100644 index 00000000..ce355240 Binary files /dev/null and b/public/images/ic_X.png differ diff --git a/public/images/ic_plus.png b/public/images/ic_plus.png new file mode 100644 index 00000000..5cd8fc68 Binary files /dev/null and b/public/images/ic_plus.png differ diff --git a/public/images/icon_password_invisible.png b/public/images/icon_password_invisible.png index 69a949ba..57e303f5 100644 Binary files a/public/images/icon_password_invisible.png and b/public/images/icon_password_invisible.png differ diff --git a/public/images/icon_password_visible.png b/public/images/icon_password_visible.png index e8a0fba8..5263589a 100644 Binary files a/public/images/icon_password_visible.png and b/public/images/icon_password_visible.png differ diff --git a/src/App.jsx b/src/App.jsx index eb06a0fd..6b383f85 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,27 +1,29 @@ -import { BrowserRouter, Routes, Route } from 'react-router-dom'; -import Home from './pages/Home/Home'; -import Login from './pages/Auth/Login'; -import Signup from './pages/Auth/Signup'; -import Items from './pages/Items/Items'; -import Privacy from './pages/Privacy/Privacy'; -import Faq from './pages/Faq/Faq'; -import AddItem from './pages/AddItem/AddItem'; -import { LoginStateProvider } from './contexts/LoginStateContext'; -import Board from './pages/Board/Board'; +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { LoginStateProvider } from "./contexts/LoginStateContext"; +import HomePage from "./pages/HomePage/HomePage"; +import LoginPage from "./pages/AuthPage/LoginPage"; +import SignupPage from "./pages/AuthPage/SignupPage"; +import ItemsPage from "./pages/ItemsPage/ItemsPage"; +import AddItemPage from "./pages/AddItemPage/AddItemPage"; +import PrivacyPage from "./pages/PrivacyPage/PrivacyPage"; +import FaqPage from "./pages/FaqPage/FaqPage"; +import BoardPage from "./pages/BoardPage/BoardPage"; function App() { return ( - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> diff --git a/src/components/BestItemsSection.jsx b/src/components/BestItemsSection.jsx new file mode 100644 index 00000000..7e4665d1 --- /dev/null +++ b/src/components/BestItemsSection.jsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import { getItems } from "../utils/api"; +import ItemsContainer from "./ItemsContainer"; +import styles from "./ItemsSection.module.css"; + +const LIST_TYPE = "best"; + +const BestItemsSection = ({ pageSize }) => { + const [bestItemList, setBestItemList] = useState([]); + + const loadBestItemList = async (options) => { + const result = await getItems(options); + if (!result) return; + const { list } = result; + setBestItemList(list); + }; + + useEffect(() => { + if (!pageSize) return; + (async () => { + await loadBestItemList({ + offset: 1, + pageSize: pageSize, + orderBy: "favorite", + keyword: "", + }); + })(); + }, [pageSize]); + + return ( +
+
+

베스트 상품

+
+ +
+ ); +}; + +export default BestItemsSection; diff --git a/src/components/CurrentItemsSection.jsx b/src/components/CurrentItemsSection.jsx new file mode 100644 index 00000000..20f5179d --- /dev/null +++ b/src/components/CurrentItemsSection.jsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from "react"; +import { getItems } from "../utils/api"; +import ItemsContainer from "./ItemsContainer"; +import styles from "./ItemsSection.module.css"; +import { usePaginationByOffset } from "../hooks/usePaginationByOffset"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import Pagination from "./Pagination"; +import { useIsLogin } from "../contexts/LoginStateContext"; + +const LIST_TYPE = "current"; +const VISIBLE_PAGE_LENGTH = 5; + +const CurrentItemsSection = ({ pageSize }) => { + //prettier-ignore + const isLogin = useIsLogin(); + + const [searchParams, setSearchParams] = useSearchParams(); + const initKeyword = searchParams.get("keyword") || ""; + const [keyword, setKeyword] = useState(initKeyword); + + const [offset, setOffset] = useState(1); + const [totalDataCount, setTotalDataCount] = useState(1); + const [order, setOrder] = useState("recent"); + + const [currentItemList, setCurrentItemList] = useState([]); + + const { totalPagesCount, currentPageNumber, visiblePageNumbers } = + usePaginationByOffset( + offset, + pageSize, + totalDataCount, + VISIBLE_PAGE_LENGTH + ); + + const onCreateNewItemNavigate = useNavigate(); + + const handleSearchOrderChange = (e) => { + setOffset(1); + setOrder(e.target.value); + }; + + const loadCurrentItemList = async (option) => { + const result = await getItems(option); + if (!result) return; + const { list, totalCount } = result; + setCurrentItemList(list); + setTotalDataCount(totalCount); + }; + + const handleSearchInputChange = (e) => setKeyword(e.target.value); + const handleSearchInputEnterPress = (e) => { + if (e.key === "Enter") { + setOffset(1); + setSearchParams(keyword ? { keyword } : {}); + } + }; + + const handleCreateNewItemClick = (e) => { + e.preventDefault(); + onCreateNewItemNavigate("/additem"); + }; + + //prettier-ignore + const handlePageNumberClick = (e) => onPaginationButtonClick(Number(e.target.value)); + const handlePagePrev = () => onPaginationButtonClick(visiblePageNumbers[0] - 1); //prettier-ignore + const handlePageNext = () => onPaginationButtonClick(visiblePageNumbers[visiblePageNumbers.length - 1] + 1); //prettier-ignore + const onPaginationButtonClick = (nextPageNumber) => setOffset((nextPageNumber - 1) * pageSize + 1); //prettier-ignore + + const prevPageEnable = visiblePageNumbers[0] > 1; + const nextPageEnable = + visiblePageNumbers[visiblePageNumbers.length - 1] < totalPagesCount; + + const handlers = { + handlePageNumberClick, + handlePagePrev, + handlePageNext, + }; + + const pageControlEnabled = { + prevPageEnable, + nextPageEnable, + }; + + useEffect(() => { + if (!pageSize) return; + (async () => { + await loadCurrentItemList({ + offset: offset, + pageSize: pageSize, + orderBy: order, + keyword: initKeyword, + }); + })(); + }, [pageSize, order, offset, initKeyword]); + + return ( + <> +
+
+

전체 상품

+
+ + +
+ {isLogin && ( + + )} + +
+ + +
+ + ); +}; + +export default CurrentItemsSection; diff --git a/src/components/Field.jsx b/src/components/Field.jsx new file mode 100644 index 00000000..0cbd61e1 --- /dev/null +++ b/src/components/Field.jsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import styles from "./Field.module.css"; + +const PASSWORD_ICON_CONFIG = { + false: { + src: "./images/icon_password_invisible.png", + className: "password-icon hidden", + }, + true: { + src: "./images/icon_password_visible.png", + className: "password-icon", + }, +}; + +const INPUT_CONTAINER_CLASSNAME = { + null: "", + false: "invalid", + true: "valid", +}; + +const Field = ({ + fieldConfig, + value, + valid, + hint, + handleInputChange, + handleInputBlur, +}) => { + //prettier-ignore + const [isVisible, setIsVisible] = useState(false); + const handlePasswordIconClick = () => setIsVisible((prev) => !prev); + + const inputContainerClass = INPUT_CONTAINER_CLASSNAME[valid]; + + return ( + + ); +}; + +export default Field; diff --git a/src/components/Field.module.css b/src/components/Field.module.css new file mode 100644 index 00000000..30745c8c --- /dev/null +++ b/src/components/Field.module.css @@ -0,0 +1,69 @@ +.label { + display: flex; + flex-direction: column; + font-weight: 700; + font-size: var(--label-font-size); + line-height: 24px; + margin-bottom: var(--label-margin-bottom); +} + +.input-container { + display: flex; + flex-direction: row; + align-items: center; + padding: 15px 24px; + background-color: var(--color-gray200); + border-radius: 12px; + margin-top: var(--input-container-margin-top); + margin-bottom: 8px; +} + +.input-container.valid { + border: 1px solid var(--color-blue400); +} +.input-container.invalid { + border: 1px solid var(--color-error); +} + +.input { + flex-grow: 1; + font-weight: 400; + font-size: 16px; + line-height: 26px; + background-color: var(--color-gray200); + border: none; + outline: none; + color: var(--color-gray800); +} + +.input::placeholder { + color: var(--color-gray400); +} + +.password-icon { + aspect-ratio: 1 / 1; + width: 24px; +} + +.input-hint { + color: var(--color-error); + margin-left: 16px; + font-weight: 600; + font-size: 14px; + line-height: 24px; +} + +:root { + --label-margin-bottom: 16px; + --label-font-size: 14px; + --input-container-margin-top: 8px; +} + +/* Tablet, PC */ +@media (min-width: 768px) { + :root { + --label-margin-bottom: 24px; + --label-font-size: 18px; + --input-container-margin-top: 16px; + } +} diff --git a/src/components/ItemCard.jsx b/src/components/ItemCard.jsx new file mode 100644 index 00000000..4242c9b8 --- /dev/null +++ b/src/components/ItemCard.jsx @@ -0,0 +1,37 @@ +import { useState } from 'react'; +import { formatPriceKRW } from '../utils/formatPrice'; +import styles from './ItemCard.module.css'; + +const IMAGE_DEFAULT_URL = './images/img_items_default_md.png'; + +const ItemCard = ({ id, imageUrl, name, price, favoriteCount }) => { + const [isImageValid, setIsImageValid] = useState(true); + + const imgSrc = isImageValid && imageUrl ? imageUrl : IMAGE_DEFAULT_URL; + + return ( +
+ setIsImageValid(false)} + alt={name} + width={282} + /> +
+

{name}

+

{formatPriceKRW(price)}

+
+ +

{favoriteCount}

+
+
+
+ ); +}; + +export default ItemCard; diff --git a/src/components/ItemCard.module.css b/src/components/ItemCard.module.css new file mode 100644 index 00000000..6e48859b --- /dev/null +++ b/src/components/ItemCard.module.css @@ -0,0 +1,50 @@ +.container { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; +} + +.context { + display: flex; + flex-direction: column; + gap: 6px; +} + +.image { + aspect-ratio: 1/1; + width: 100%; + border-radius: 16px; +} + +.title { + font-weight: 500; + font-size: 14px; + line-height: 24px; + min-height: 24px; +} +.price { + font-weight: 700; + font-size: 16px; + line-height: 26px; + min-height: 26px; +} + +.favorite-container { + display: flex; + flex-direction: row; + align-items: center; + gap: 3.35px; + min-height: 18px; +} + +.favorite-image { + aspect-ratio: 1/1; + width: 16px; +} + +.favorite-count { + font-weight: 500; + font-size: 12px; + line-height: 18px; +} diff --git a/src/components/ItemCardSkeleton.jsx b/src/components/ItemCardSkeleton.jsx new file mode 100644 index 00000000..ed249076 --- /dev/null +++ b/src/components/ItemCardSkeleton.jsx @@ -0,0 +1,16 @@ +import styles from "./ItemCardSkeleton.module.css"; + +const ItemCardSkeleton = () => { + return ( +
+
+
+
+
+
+
+
+ ); +}; + +export default ItemCardSkeleton; diff --git a/src/components/ItemCardSkeleton.module.css b/src/components/ItemCardSkeleton.module.css new file mode 100644 index 00000000..286f5cb2 --- /dev/null +++ b/src/components/ItemCardSkeleton.module.css @@ -0,0 +1,69 @@ +@keyframes skeleton-gradient { + 0% { + background-position: 0% 50%; + } + 100% { + background-position: 100% 50%; + } +} +:root { + --skeleton-dark: #e3e3e3; + --skeleton-light: #f0f0f0; +} +.image, +.title, +.price, +.favorite-container { + background: linear-gradient(90deg, var(--skeleton-dark), var(--skeleton-dark), var(--skeleton-dark), var(--skeleton-light), var(--skeleton-dark), var(--skeleton-dark), var(--skeleton-dark) ); + background-size: 800% 800%; + animation: skeleton-gradient 1.5s infinite ease-in-out; +} + +.container { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; +} + +.context { + display: flex; + flex-direction: column; + gap: 6px; +} + +.image { + aspect-ratio: 1/1; + width: 100%; + border-radius: 16px; +} + +.title { + font-weight: 500; + font-size: 14px; + line-height: 24px; + margin: 4px 0; + min-height: 16px; + width: 70%; + border-radius: 10px; +} +.price { + font-weight: 700; + font-size: 16px; + line-height: 26px; + margin: 5px 0; + min-height: 16px; + width: 50%; + border-radius: 10px; +} + +.favorite-container { + display: flex; + flex-direction: row; + align-items: center; + gap: 3.35px; + width: 40%; + margin: 3px 0; + min-height: 12px; + border-radius: 10px; +} \ No newline at end of file diff --git a/src/components/ItemsContainer.jsx b/src/components/ItemsContainer.jsx new file mode 100644 index 00000000..21979297 --- /dev/null +++ b/src/components/ItemsContainer.jsx @@ -0,0 +1,28 @@ +import ItemCard from "./ItemCard"; +import ItemCardSkeleton from "./ItemCardSkeleton"; +import styles from "./ItemsContainer.module.css"; + +const ItemsContainer = ({ listName, itemList, pageSize }) => { + return ( +
+ {itemList.length === 0 + ? Array.from({ length: pageSize }, (_, i) => { + return ; + }) + : itemList.map((item) => { + return ( + + ); + })} +
+ ); +}; + +export default ItemsContainer; diff --git a/src/components/ItemsContainer.module.css b/src/components/ItemsContainer.module.css new file mode 100644 index 00000000..3119412d --- /dev/null +++ b/src/components/ItemsContainer.module.css @@ -0,0 +1,43 @@ +.items-container { + display: grid; + row-gap: 40px; +} + +.items-container.best { + column-gap: 0; + grid-template-columns: repeat(1, 1fr); + grid-template-rows: repeat(1, 1fr); +} +.items-container.current { + column-gap: 8px; + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(2, 1fr); +} + +/* Tablet */ +@media screen and (min-width: 768px) and (max-width: 1199px) { + .items-container.best { + column-gap: 10px; + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(1, 1fr); + } + .items-container.current { + column-gap: 16px; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(2, 1fr); + } +} + +/* PC */ +@media (min-width: 1200px) { + .items-container.best { + column-gap: 24px; + grid-template-columns: repeat(4, 1fr); + grid-template-rows: repeat(1, 1fr); + } + .items-container.current { + column-gap: 24px; + grid-template-columns: repeat(5, 1fr); + grid-template-rows: repeat(2, 1fr); + } +} diff --git a/src/components/ItemsSection.module.css b/src/components/ItemsSection.module.css new file mode 100644 index 00000000..c262c86f --- /dev/null +++ b/src/components/ItemsSection.module.css @@ -0,0 +1,97 @@ +.cards-section { + display: flex; + flex-direction: column; + max-width: 1250px; + width: 100%; + margin: 0 auto; + padding: 0 24px; +} + +.cards-section.best { + gap: 16px; +} + +.cards-section.current { + gap: 24px; +} + +.section-header-container { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.section-title { + font-weight: 700; + font-size: 20px; + line-height: 32px; + color: var(--color-gray900); + flex-grow: 1; + order: var(--search-title-order); + min-width: 200px; +} + +.search-input-container { + display: flex; + flex-direction: row; + align-items: center; + background-color: var(--color-gray200); + width: var(--search-input-container-width); + padding: 0 9px; + height: 42px; + border-radius: 12px; + order: var(--search-input-order); + flex-grow: var(--search-input-flex-grow); +} + +.search-input-icon { + aspect-ratio: 1/1; +} + +.search-input { + border: none; + background-color: var(--color-gray200); + flex-grow: 1; + font-weight: 400; + font-size: 16px; + line-height: 26px; + outline: none; +} + +.search-input::placeholder { + color: var(--color-gray400); +} + +.search-submit { + border-radius: 8px; + font-weight: 600; + font-size: 16px; + line-height: 26px; + padding: 0 23px; + color: var(--color-gray100); + height: 42px; + order: var(--search-submit-order); +} + +.search-select { + width: var(--search-select-width); + padding: 0 20px; + border-radius: 12px; + border: 1px solid var(--color-gray300); + appearance: none; /* 기본 브라우저 UI 제거 */ + -webkit-appearance: none; + -moz-appearance: none; + background-image: var(--search-select-image); + background-repeat: no-repeat; + background-position: var(--search-select-image-position); + background-size: 24px; + color: var(--color-gray800); + font-weight: 400; + font-size: 16px; + line-height: 26px; + height: 42px; + order: var(--search-select-order); +} diff --git a/src/components/LogoHeader.jsx b/src/components/LogoHeader.jsx new file mode 100644 index 00000000..725a9bf2 --- /dev/null +++ b/src/components/LogoHeader.jsx @@ -0,0 +1,17 @@ +import { Link } from 'react-router-dom'; +import styles from './LogoHeader.module.css'; + +const LogoHeader = () => { + return ( + + +

판다마켓

+ + ); +}; + +export default LogoHeader; diff --git a/src/components/LogoHeader.module.css b/src/components/LogoHeader.module.css new file mode 100644 index 00000000..05de70c5 --- /dev/null +++ b/src/components/LogoHeader.module.css @@ -0,0 +1,43 @@ +.logo-container { + text-decoration: none; + display: flex; + flex-direction: row; + gap: var(--logo-container-gap); + align-items: center; + justify-content: center; + height: var(--logo-container-height); + margin-bottom: var(--logo-container-margin-bottom); +} + +.logo-image { + aspect-ratio: 51.76 / 51.94; + width: var(--logo-image-width); +} + +.logo-text { + font-family: ''; + font-family: 'ROKAFSans'; + font-weight: 700; + font-size: var(--logo-text-font-size); + line-height: 100%; + color: var(--color-blue400); +} + +:root { + --logo-text-font-size: 33.17px; + --logo-container-margin-bottom: 24px; + --logo-image-width: 51.76px; + --logo-container-height: 66px; + --logo-container-gap: 11.12px; +} + +/* Tablet, PC */ +@media (min-width: 768px) { + :root { + --logo-text-font-size: 66.34px; + --logo-container-margin-bottom: 40px; + --logo-image-width: 103.53px; + --logo-container-height: 132px; + --logo-container-gap: 22.24px; + } +} diff --git a/src/components/Nav.jsx b/src/components/Nav.jsx new file mode 100644 index 00000000..7eb5879a --- /dev/null +++ b/src/components/Nav.jsx @@ -0,0 +1,67 @@ +import { Link } from "react-router-dom"; +import styles from "./Nav.module.css"; +import { useIsLogin } from "../contexts/LoginStateContext"; + +const LINK_CLASSNAME = { + false: "", + true: "active", +}; + +const Nav = ({ currentSection }) => { + const isLogin = useIsLogin(); + + return ( +
+ +
+ ); +}; + +export default Nav; diff --git a/src/pages/components/Header.css b/src/components/Nav.module.css similarity index 100% rename from src/pages/components/Header.css rename to src/components/Nav.module.css diff --git a/src/components/Pagination.jsx b/src/components/Pagination.jsx new file mode 100644 index 00000000..2bb2a4c8 --- /dev/null +++ b/src/components/Pagination.jsx @@ -0,0 +1,62 @@ +import styles from './Pagination.module.css'; + +const Pagination = ({ + visiblePageNumbers, + currentPageNumber, + handlers, + pageControlEnabled, +}) => { + const { handlePageNumberClick, handlePagePrev, handlePageNext } = handlers; + const { prevPageEnable, nextPageEnable } = pageControlEnabled; + + return ( + + ); +}; + +export default Pagination; diff --git a/src/components/Pagination.module.css b/src/components/Pagination.module.css new file mode 100644 index 00000000..9ea6a535 --- /dev/null +++ b/src/components/Pagination.module.css @@ -0,0 +1,38 @@ +.pagination-container { + display: flex; + flex-direction: row; + padding-top: var(--items-pagination-padding-top); + padding-bottom: var(--items-pagination-padding-bottom); + padding: 43px 0 58px; + justify-content: center; + align-items: center; + background-color: var(--color-gray050); + gap: 4px; +} + +.pagination-button { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid var(--color-gray300); + font-weight: 600; + font-size: 16px; + line-height: 26px; + border-radius: 40px; + color: var(--color-gray500); + background-color: var(--color-white); + cursor: pointer; +} + +.pagination-button:disabled { + cursor: auto; +} + +.pagination-button.selected { + border: none; + background-color: #2f80ed; + color: var(--color-gray100); +} diff --git a/src/components/SocialLogin.jsx b/src/components/SocialLogin.jsx new file mode 100644 index 00000000..a94bc074 --- /dev/null +++ b/src/components/SocialLogin.jsx @@ -0,0 +1,33 @@ +import styles from './SocialLogin.module.css'; + +const SocialLogin = () => { + return ( +
+ 간편 로그인하기 + + {'구글 + + + {'카카오 + +
+ ); +}; + +export default SocialLogin; diff --git a/src/components/SocialLogin.module.css b/src/components/SocialLogin.module.css new file mode 100644 index 00000000..38432eb9 --- /dev/null +++ b/src/components/SocialLogin.module.css @@ -0,0 +1,17 @@ +.social-container { + padding: 16px 23px; + border-radius: 8px; + display: flex; + flex-direction: row; + background-color: #e6f2ff; + align-items: center; + gap: 16px; + margin: 24px 0; +} + +.social-text { + font-weight: 500; + font-size: 16px; + line-height: 26px; + flex-grow: 1; +} diff --git a/src/constants/fieldsConfig.js b/src/constants/fieldsConfig.js new file mode 100644 index 00000000..de88662a --- /dev/null +++ b/src/constants/fieldsConfig.js @@ -0,0 +1,34 @@ +export const FIELDS_CONFIG = { + email: { + id: 'email', + labelText: '이메일', + placeholder: '이메일을 입력해주세요', + ariaLabel: '이메일 입력 칸', + type: 'email', + autoComplete: 'email', + }, + nickname: { + id: 'nickname', + labelText: '닉네임', + placeholder: '닉네임을 입력해주세요', + ariaLabel: '닉네임 입력 칸', + type: 'text', + autoComplete: 'on', + }, + password: { + id: 'password', + labelText: '비밀번호', + placeholder: '비밀번호를 입력해주세요', + ariaLabel: '비밀번호 입력 칸', + type: 'password', + autoComplete: 'current-password', + }, + passwordVerify: { + id: 'passwordVerify', + labelText: '비밀번호 확인', + placeholder: '비밀번호를 다시 한 번 입력해주세요', + ariaLabel: '비밀번호 확인 입력 칸', + type: 'password', + autoComplete: 'new-password', + }, +}; diff --git a/src/hooks/useFormFields.jsx b/src/hooks/useFormFields.jsx index b0e52314..cd720a9c 100644 --- a/src/hooks/useFormFields.jsx +++ b/src/hooks/useFormFields.jsx @@ -1,24 +1,27 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState } from "react"; +import { validators } from "../utils/validators"; -export const useFormFields = ({ FIELDS }) => { +export const useFormFields = (FIELD_KEYS) => { const createInitialStates = (initialFields, initialValue) => { const returnFields = {}; - for (const field in initialFields) { + for (const field of initialFields) { returnFields[field] = initialValue; } return returnFields; }; - //prettier-ignore - const [values, setValues] = useState(createInitialStates(FIELDS, '')); - //prettier-ignore - const [valids, setValids] = useState(createInitialStates(FIELDS, null)); - //prettier-ignore - const [hints, setHints] = useState(createInitialStates(FIELDS, '')); - //prettier-ignore - const [isVisibles, setIsVisibles] = useState(createInitialStates(FIELDS, false)); + const [values, setValues] = useState(createInitialStates(FIELD_KEYS, '')); //prettier-ignore + const [valids, setValids] = useState(createInitialStates(FIELD_KEYS, null)); //prettier-ignore + const [hints, setHints] = useState(createInitialStates(FIELD_KEYS, '')); //prettier-ignore const [isSubmitEnabled, setIsSubmitEnabled] = useState(false); + const getValidateResults = { + email: validators.email(values.email), + nickname: validators.nickname(values.nickname), + password: validators.password(values.password, values.passwordVerify), + passwordVerify: validators.passwordVerify(values.passwordVerify, values.password), //prettier-ignore + }; + useEffect(() => { setIsSubmitEnabled(Object.values(valids).every((v) => v === true)); }, [valids]); @@ -29,8 +32,8 @@ export const useFormFields = ({ FIELDS }) => { }; const handleInputBlur = (e) => { - const { name, value } = e.target; - const validResults = getValidResults(name, value); + const { name } = e.target; + const validResults = getValidateResults[name]; updateValidResults(validResults); }; @@ -41,11 +44,6 @@ export const useFormFields = ({ FIELDS }) => { })); }; - const getValidResults = (name, value) => { - const validFunction = FIELDS[name]; - return validFunction(value); - }; - const updateValidResults = (validResults) => { const nextInputValids = {}; const nextHints = {}; @@ -63,21 +61,12 @@ export const useFormFields = ({ FIELDS }) => { })); }; - const handlePasswordIconClick = (name) => { - setIsVisibles((prev) => ({ - ...prev, - [name]: !isVisibles[name], - })); - }; - return { values, valids, hints, - isVisibles, isSubmitEnabled, handleInputChange, handleInputBlur, - handlePasswordIconClick, }; }; diff --git a/src/hooks/usePageSizeByBreakPoint.jsx b/src/hooks/usePageSizeByBreakPoint.jsx new file mode 100644 index 00000000..9252fafc --- /dev/null +++ b/src/hooks/usePageSizeByBreakPoint.jsx @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; +import { useScreenBreakpoint } from './useScreenBreakpoint'; + +const PAGE_SIZE_LIST = { + best: { lg: 4, md: 2, sm: 1 }, + current: { lg: 10, md: 6, sm: 4 }, +}; + +export const usePageSizeByBreakPoint = () => { + const { breakPoint } = useScreenBreakpoint(); + const [pageSizeList, setPageSizeList] = useState({}); + + useEffect(() => { + const nextPageSizeList = {}; + + for (const [key, sizeList] of Object.entries(PAGE_SIZE_LIST)) { + nextPageSizeList[key] = sizeList[breakPoint]; + } + + setPageSizeList((prev) => { + return JSON.stringify(prev) === JSON.stringify(nextPageSizeList) + ? prev + : nextPageSizeList; + }); + }, [breakPoint]); + + return { pageSizeList }; +}; diff --git a/src/hooks/usePaginationByOffset.jsx b/src/hooks/usePaginationByOffset.jsx new file mode 100644 index 00000000..c70da2f5 --- /dev/null +++ b/src/hooks/usePaginationByOffset.jsx @@ -0,0 +1,39 @@ +const getCurrentPageState = ( + offset, + pageSize = 1, + totalDataCount, + maxVisiblePagelength +) => { + const nextCurrentPageNumber = Math.ceil(offset / pageSize); + + const nextTotalPagesCount = Math.ceil(totalDataCount / pageSize); + //prettier-ignore + const paginationStartPage = Math.floor((nextCurrentPageNumber - 1) / maxVisiblePagelength) * maxVisiblePagelength + 1; + const remainingPageCount = nextTotalPagesCount - paginationStartPage + 1; + const visiblePageCount = + remainingPageCount >= maxVisiblePagelength + ? maxVisiblePagelength + : remainingPageCount; + const nextVisiblePageNumbers = new Array(visiblePageCount) + .fill(0) + .map((v, i) => v + i + paginationStartPage); + return { nextCurrentPageNumber, nextVisiblePageNumbers, nextTotalPagesCount }; +}; + +export const usePaginationByOffset = ( + offset, + pageSize, + totalDataCount, + visiblePageLength = 5 +) => { + //prettier-ignore + const { nextTotalPagesCount, nextCurrentPageNumber, nextVisiblePageNumbers } = + getCurrentPageState(offset, pageSize, totalDataCount, visiblePageLength); + + //prettier-ignore + return { + totalPagesCount: nextTotalPagesCount, + currentPageNumber: nextCurrentPageNumber, + visiblePageNumbers: nextVisiblePageNumbers, + }; +}; diff --git a/src/hooks/useDeviceType.jsx b/src/hooks/useScreenBreakpoint.jsx similarity index 56% rename from src/hooks/useDeviceType.jsx rename to src/hooks/useScreenBreakpoint.jsx index 72b9845e..94c35116 100644 --- a/src/hooks/useDeviceType.jsx +++ b/src/hooks/useScreenBreakpoint.jsx @@ -1,21 +1,24 @@ import { useEffect, useState } from 'react'; -export const useDeviceType = () => { - const getDeviceType = (width) => { +const DEFAULT_BREAKPOINT = 'sm'; + +export const useScreenBreakpoint = () => { + const getBreakPoint = (width) => { if (width >= 1200) return 'lg'; else if (width >= 768) return 'md'; else return 'sm'; }; //prettier-ignore - const [deviceType, setDeviceType] = useState(getDeviceType(window.innerWidth)); + const [breakPoint, setBreakPoint] = useState(null); useEffect(() => { - const handleResize = () => setDeviceType(getDeviceType(window.innerWidth)); + const handleResize = () => setBreakPoint(getBreakPoint(window.innerWidth)); + handleResize(); window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); }; }, []); - return { deviceType }; + return { breakPoint }; }; diff --git a/src/modules/formatPrice.js b/src/modules/formatPrice.js deleted file mode 100644 index 6fb6374b..00000000 --- a/src/modules/formatPrice.js +++ /dev/null @@ -1,3 +0,0 @@ -export const formatPriceKRW = (price) => { - return price.toLocaleString('ko-KR') + '원'; -}; diff --git a/src/pages/AddItem/AddItem.jsx b/src/pages/AddItem/AddItem.jsx deleted file mode 100644 index daa2c862..00000000 --- a/src/pages/AddItem/AddItem.jsx +++ /dev/null @@ -1,5 +0,0 @@ -const AddItem = () => { - return <>; -}; - -export default AddItem; diff --git a/src/pages/AddItemPage/AddItemPage.jsx b/src/pages/AddItemPage/AddItemPage.jsx new file mode 100644 index 00000000..3cbad1a1 --- /dev/null +++ b/src/pages/AddItemPage/AddItemPage.jsx @@ -0,0 +1,197 @@ +import { useEffect, useRef, useState } from "react"; +import Nav from "../../components/Nav"; +import styles from "./AddItemPage.module.css"; +import { formatNumber } from "../../utils/formatPrice"; + +const AddItemPage = () => { + const [preview, setPreview] = useState(null); + + const [hintVisible, setHintVisible] = useState(false); + const [tag, setTag] = useState(""); + + const [imgUrl, setImgUrl] = useState(null); + const [product, setProduct] = useState(""); + const [description, setDescription] = useState(""); + const [price, setPrice] = useState(""); + const [tagList, setTagList] = useState([]); + + //prettier-ignore + const isSubmitEnabled = imgUrl && product && description && price && tagList.length; + + const fileInputRef = useRef(); + + const handleAddItemClick = () => { + if (!imgUrl) fileInputRef.current.click(); + else setHintVisible(true); + }; + + const handleInputFileChange = (e) => { + const file = e.target.files[0]; + if (!file) return; + setImgUrl(file); + }; + + const handleInputFileDeleteClick = () => { + const inputNode = fileInputRef.current; + if (!inputNode) return; + inputNode.value = ""; + setImgUrl(null); + setHintVisible(false); + }; + + const handleInputProductChange = (e) => setProduct(e.target.value); + + const handleInputDescriptionChange = (e) => setDescription(e.target.value); + + const handleInputPriceChange = (e) => { + const numericValue = e.target.value.replace(/\D/g, ""); + const formattedValue = formatNumber(numericValue); + setPrice(formattedValue); + }; + + const handleInputTagChange = (e) => setTag(e.target.value); + + const handleTagInputEnterPress = (e) => { + if (e.key === "Enter") { + if (tagList.indexOf(e.target.value) !== -1) return; + setTagList((prev) => [...prev, e.target.value]); + setTag(""); + } + }; + + const handleTagDelete = (e) => { + setTagList((prev) => { + const tagIndex = prev.indexOf(e.target.name); + return [...prev.slice(0, tagIndex), ...prev.slice(tagIndex + 1)]; + }); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + }; + + useEffect(() => { + if (!imgUrl) return; + const nextPreview = URL.createObjectURL(imgUrl); + setPreview(nextPreview); + + return () => { + setPreview(); + URL.revokeObjectURL(nextPreview); + }; + }, [imgUrl]); + + return ( + <> +