diff --git a/package-lock.json b/package-lock.json index 25860c78..ba5672d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,21 @@ "version": "0.1.0", "dependencies": { "@babel/runtime": "^7.27.1", + "@hookform/resolvers": "^5.0.1", + "@tanstack/react-query": "^5.76.2", + "@tanstack/react-query-devtools": "^5.77.1", "@types/styled-components": "^5.1.34", "axios": "^1.9.0", "clsx": "^2.1.1", "next": "15.3.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.56.4", "react-router-dom": "^7.5.0", "sass": "^1.89.0", "styled-components": "^6.1.18", + "use-debounce": "^10.0.4", + "zod": "^3.25.28", "zustand": "^5.0.5" }, "devDependencies": { @@ -227,6 +233,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hookform/resolvers": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.0.1.tgz", + "integrity": "sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1246,6 +1264,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -1261,6 +1285,59 @@ "tslib": "^2.8.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.77.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.77.1.tgz", + "integrity": "sha512-nfxVhy4UynChMFfN4NxwI8pktV9R3Zt/ROxOAe6pdOf8CigDLn26p+ex1YW5uien26BBICLmN0dTvIELHCs5vw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.76.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.76.0.tgz", + "integrity": "sha512-1p92nqOBPYVqVDU0Ua5nzHenC6EGZNrLnB2OZphYw8CNA1exuvI97FVgIKON7Uug3uQqvH/QY8suUKpQo8qHNQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.77.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.77.1.tgz", + "integrity": "sha512-qBwpxFg0+MZF0fICQwgvzwrVbcs7TdQlLyEd1f1dN83oeIALofCIAJHV7sPWu+BCS5tcXkG5CvOuf7yla8GYqQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.77.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.77.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.77.1.tgz", + "integrity": "sha512-b4sOJYTjvCCfL1kQsJevDopqp8b+SpfjCVYr5CqGwAmYsKPIDk5US5wjoacaUHGF+GULjPFH4yT29Ce19kU3iQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.76.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.77.1", + "react": "^18 || ^19" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", @@ -5274,6 +5351,22 @@ "react": "^19.1.0" } }, + "node_modules/react-hook-form": { + "version": "7.56.4", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.4.tgz", + "integrity": "sha512-Rob7Ftz2vyZ/ZGsQZPaRdIefkgOSrQSPXfqBdvOPwJfoGnjwRJUs7EM7Kc1mcoDv3NOtqBzPGbcMB8CGn9CKgw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -6428,6 +6521,18 @@ "punycode": "^2.1.0" } }, + "node_modules/use-debounce": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.4.tgz", + "integrity": "sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -6563,10 +6668,9 @@ } }, "node_modules/zod": { - "version": "3.24.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", - "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", - "dev": true, + "version": "3.25.28", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.28.tgz", + "integrity": "sha512-/nt/67WYKnr5by3YS7LroZJbtcCBurDKKPBPWWzaxvVCGuG/NOsiKkrjoOhI8mJ+SQUXEbUzeB3S+6XDUEEj7Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 7a91c4f6..c6a396af 100644 --- a/package.json +++ b/package.json @@ -10,15 +10,21 @@ }, "dependencies": { "@babel/runtime": "^7.27.1", + "@hookform/resolvers": "^5.0.1", + "@tanstack/react-query": "^5.76.2", + "@tanstack/react-query-devtools": "^5.77.1", "@types/styled-components": "^5.1.34", "axios": "^1.9.0", "clsx": "^2.1.1", "next": "15.3.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.56.4", "react-router-dom": "^7.5.0", "sass": "^1.89.0", "styled-components": "^6.1.18", + "use-debounce": "^10.0.4", + "zod": "^3.25.28", "zustand": "^5.0.5" }, "devDependencies": { diff --git a/src/app/ClientLayout.tsx b/src/app/ClientLayout.tsx index abc71bd9..6e36eb06 100644 --- a/src/app/ClientLayout.tsx +++ b/src/app/ClientLayout.tsx @@ -1,7 +1,9 @@ 'use client' -import { ThemeProvider } from 'styled-components' -import { theme } from '../styles/theme' +import React, { useState } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' + import GlobalStyles from '../styles/GlobalStyles' export default function ClientLayout({ @@ -9,10 +11,13 @@ export default function ClientLayout({ }: { children: React.ReactNode }) { + const [queryClient] = useState(() => new QueryClient()) return ( - + {children} - + + + ) } diff --git a/src/app/Home/page.tsx b/src/app/Home/page.tsx deleted file mode 100644 index 3a3ad21a..00000000 --- a/src/app/Home/page.tsx +++ /dev/null @@ -1,168 +0,0 @@ -'use client' -import React from 'react' -import Link from 'next/link' - -import styles from './home.module.scss' -import Button from '../../components/common/Button' - -import Logo from '../../../public/assets/image/Logo.png' -import LogoFace from '../../../public/assets/image/LogoFace.png' -import HomeTop from '../../../public/assets/image/home_top.png' -import HomeBottom from '../../../public/assets/image/home_bottom.png' -import HomeHotItems from '../../../public/assets/image/home_hot_items.png' -import HomeSearch from '../../../public/assets/image/home_search.png' -import HomeRegister from '../../../public/assets/image/home_register.png' -import Facebook from '../../../public/assets/svg/facebook.svg' -import Instagram from '../../../public/assets/svg/instagram.svg' -import Twitter from '../../../public/assets/svg/twitter.svg' -import Youtube from '../../../public/ assets/svg/youtube.svg' -import Image from 'next/image' - -function Home() { - return ( - <> -
- -
-
-
-
-

- 일상의 모든 물건을 거래해 보세요 -

-
- -
-
- 판다마켓 백그라운드사진 -
-
-
-
-
- 판다마켓 인기 상품 사진 -
-
-
-
- Hot item -
-
-
-
- 인기 상품을 확인해 보세요 -
-
-
-
- 가장 HOT한 중고거래 물품을 판다 마켓에서 확인해 보세요 -
-
-
-
-
-
-
-
Search
-
-
-
- 구매를 원하는 상품을 검색하세요 -
-
-
-
- 구매하고 싶은 물품은 검색해서 쉽게 찾아보세요 -
-
-
-
- 판다마켓 상품 검색 사진 -
-
-
-
- 판다마켓 인기 상품 등록 -
-
-
-
- Register -
-
-
-
- 판매를 원하는 상품을 등록하세요 -
-
-
-
- 어떤 물건이든 판매하고 싶은  상품을 쉽게 등록하세요 -
-
-
-
-
-
-
-
-
-
-
- 믿을 수 있는 판다마켓 중고 거래 -
-
-
- 판다마켓 백그라운드사진 -
-
-
-
-
-
©codeit - 2024
-
- -
Privacy Policy
- - -
FAQ
- -
- -
-
-
- - ) -} - -export default Home diff --git a/src/app/Items/BestItems.tsx b/src/app/Items/BestItems.tsx index d9235641..940aa813 100644 --- a/src/app/Items/BestItems.tsx +++ b/src/app/Items/BestItems.tsx @@ -3,22 +3,25 @@ import React, { useEffect, useState } from 'react' import Link from 'next/link' import Image from 'next/image' +import { useItemsList } from '../../hooks/useItemsList' import { GetProductIdTypes } from '../../types/product' import HeartInactive from '../../../public/assets/image/heart_inactive.png' import styles from './BestItems.module.scss' -interface BestItemsProps { - products: GetProductIdTypes[] -} - -const BestItems = ({ products }: BestItemsProps) => { +const BestItems = () => { const [itemsDisplay, setItemsDisplay] = useState(1) - const list = products || [] - console.log(products) + const { productListAll } = useItemsList({ + itemsDisplay, + page: 1, + orderBy: 'favorite', + enabled: true, + }) + + // 화면 크기에 따라 표시할 아이템 수를 조정하는 useEffect useEffect(() => { - const handleReasize = () => { + const handleResize = () => { if (window.innerWidth <= 743) { setItemsDisplay(1) } else if (window.innerWidth <= 1023) { @@ -27,10 +30,10 @@ const BestItems = ({ products }: BestItemsProps) => { setItemsDisplay(4) } } - handleReasize() - window.addEventListener('resize', handleReasize) + handleResize() + window.addEventListener('resize', handleResize) - return () => window.removeEventListener('resize', handleReasize) + return () => window.removeEventListener('resize', handleResize) }, []) return ( @@ -39,31 +42,33 @@ const BestItems = ({ products }: BestItemsProps) => {
베스트 상품
- {list.slice(0, itemsDisplay).map((product) => ( - -
- 0 - ? product.images[0] - : '' - } - alt={product.name} - /> -
-
{product.name}
-
- {product.price.toLocaleString('ko-KR')}원 -
-
- HeartInactive - {product.favoriteCount} + {productListAll.data?.list + ?.slice(0, itemsDisplay) + .map((product: GetProductIdTypes) => ( + +
+ 0 + ? product.images[0] + : '' + } + alt={product.name} + /> +
+
{product.name}
+
+ {product.price.toLocaleString('ko-KR')}원 +
+
+ HeartInactive + {product.favoriteCount} +
-
- - ))} + + ))}
) diff --git a/src/app/Items/ProductListItems.module.scss b/src/app/Items/ProductListItems.module.scss new file mode 100644 index 00000000..6d650b51 --- /dev/null +++ b/src/app/Items/ProductListItems.module.scss @@ -0,0 +1,243 @@ +@import '../../styles//variables.scss'; + +.bone { + width: 120rem; + margin: 2.4rem auto; + @media (max-width: 1023px) { + width: 69.6rem; + } + @media (max-width: 743px) { + width: 34.4rem; + margin: 1rem auto; + } +} +.nav-var { + height: 4.2rem; + width: 100%; + margin: 0 auto 2.4rem; + display: flex; + align-items: center; + justify-content: space-between; + @media (max-width: 743px) { + flex-wrap: wrap; + margin: 0 auto 6.6rem; + } +} +.nav-title { + @include apply-font($font-20, 700); + color: $secondaryGray-900; + @media (max-width: 743px) { + height: 2.625rem; + display: flex; + align-items: center; + justify-content: center; + } +} +.nav-right-wrapper { + height: 100%; + display: flex; + align-items: center; + position: relative; + @media (max-width: 743px) { + flex-wrap: wrap; + } +} +.search-icon { + position: absolute; + right: 585px; + z-index: 10; + display: flex; + @media (max-width: 1023px) { + right: 494px; + } + @media (max-width: 743px) { + right: 308px; + top: 28px; + } +} +.register-button { + padding: 1.3rem 2.3rem; + width: max-content; + background-color: $primaryBlue-100; + color: white; + border-radius: 8px; + @include apply-font($font-16, 600); +} +.nav-search { + width: 32.5rem; + height: 100%; + padding: 0.9rem 10.7rem 0.9rem 4.4rem; + border-radius: 1.2rem; + @include apply-font($font-16, 400); + color: $secondaryGray-400; + background-color: $secondaryGray-100; + border: none; + margin-right: 1.2rem; + @media (max-width: 1023px) { + width: 24.2rem; + padding: 9px 24px 9px 44px; + } + @media (max-width: 743px) { + position: relative; + top: 19px; + padding: 9px 40px 9px 44px; + width: max-content; + margin: 0; + } +} +.search-input { + border: none; + background-color: $secondaryGray-100; +} + +.button-wrapper { + margin-right: 1.2rem; + @media (max-width: 743px) { + position: relative; + width: max-content; + top: -74px; + left: 206px; + } + button { + transition: all 0.3s ease-in-out; + + &:hover { + transform: scale(1.05); + } + + &:active { + transform: scale(0.95); + } + } +} + +.pagination { + width: 30.4rem; + height: 4rem; + display: flex; + align-items: center; + justify-content: space-between; + margin: 4.3rem auto 0 auto; + @media (max-width: 1023px) { + margin: 2.5rem auto 0 auto; + } +} +.arrow-button { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: 1px solid $secondaryGray-200; + background: #ffffff; + color: $secondaryGray-600; + border-radius: 40px; + height: 40px; + width: 40px; + padding: 12px; + + &:hover { + background: $primaryBlue-200; + } + + &:disabled { + background: $secondaryGray-400; + cursor: not-allowed; + } +} + +interface PageButtonProps { + $isActive: boolean; +} +.page-button { + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid $secondaryGray-100; + background: #fff; + color: $secondaryGray-900; + cursor: pointer; + transition: background 0.2s, color 0.2s; + display: flex; + align-items: center; + justify-content: center; + &:hover { + background: $primaryBlue-200; + } + + &.active { + background-color: $primaryBlue-100; + color: $secondaryGray-50; + } +} + +.recent-item { + height: 67.4rem; + @media (max-width: 743px) { + height: fit-content; + } +} +.recent-item-key { + display: flex; + height: 31.7rem; + width: 22.1rem; + justify-content: center; + flex-direction: column; + img { + width: 22.1rem; + height: 22.1rem; + border-radius: 1rem; + } + @media (max-width: 743px) { + height: 26.4rem; + width: 16.8rem; + img { + width: 16.8rem; + height: 16.8rem; + border-radius: 1rem; + } + } +} +.recent-item-image { + width: inherit; + height: 100%; + border-radius: 1rem; +} +.recent-items-display { + display: flex; + align-items: center; + justify-content: space-between; + flex-direction: row; + flex-wrap: wrap; + align-content: flex-start; + row-gap: 4rem; + @media (max-width: 743px) { + row-gap: 2rem; + } +} +.product-description { + width: 100%; + height: 8rem; + margin-top: 1.6rem; + display: flex; + flex-direction: column; + justify-content: space-between; +} +.product-name { + @include apply-font($font-14, 500); + color: $secondaryGray-800; +} +.product-price { + @include apply-font($font-16, 700); + color: $secondaryGray-800; +} +.product-favorite-count { + @include apply-font($font-12, 500); + color: $secondaryGray-600; + display: flex; + align-items: center; + gap: 0.4rem; + img { + width: 1.6rem; + height: 1.6rem; + } +} diff --git a/src/app/Items/ProductListItems.tsx b/src/app/Items/ProductListItems.tsx new file mode 100644 index 00000000..81d77e81 --- /dev/null +++ b/src/app/Items/ProductListItems.tsx @@ -0,0 +1,189 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import Link from 'next/link' +import Image from 'next/image' +import { useDebounce } from 'use-debounce' + +import { useItemsList } from '../../hooks/useItemsList' +import { GetProductIdTypes } from '../../types/product' +import DropDown from '@/components/common/DropDown' + +import HeartInactive from '../../../public/assets/image/heart_inactive.png' +import NoImage from '../../../public/assets/image/no_image.png' +import Search from '../../../public/assets/svg/search.svg' +import ArrowLeft from '../../../public/assets/svg/arrow_left.svg' +import ArrowRight from '../../../public/assets/svg/arrow_right.svg' + +import styles from './ProductListItems.module.scss' + +type SelectOption = { + value: string + name: string +} + +const ProductListItems = () => { + const [itemsDisplay, setItemsDisplay] = useState(1) + const [isMobile, setIsMobile] = useState(false) + + const selectList: SelectOption[] = [ + { value: 'recent', name: '최신순' }, + { value: 'favorite', name: '좋아요순' }, + ] + const itemsPerPage = 10 + const [selectedOption, setSelectedOption] = useState(selectList[0].value) + const [currentPage, setCurrentPage] = useState(1) + const [searchTerm, setSearchTerm] = useState('') + const [debouncedSearchTerm] = useDebounce(searchTerm, 300) + + // 상품리스트 가져오는 useQuery 훅 + const { productListAll } = useItemsList({ + page: currentPage, + itemsDisplay, + orderBy: selectedOption, + keyword: debouncedSearchTerm.trim(), + }) + + // 페이지네이션 관련 변수 설정 + const totalPages = Math.ceil(productListAll.data?.totalCount / itemsPerPage) + const pages = (() => { + if (totalPages <= 5) { + return Array.from({ length: totalPages }, (_, i) => i + 1) + } + if (currentPage <= 3) { + return [1, 2, 3, 4, 5] + } + if (currentPage >= totalPages - 2) { + return Array.from({ length: 5 }, (_, i) => totalPages - 4 + i) + } + return Array.from({ length: 5 }, (_, i) => currentPage - 2 + i) + })() + + const handleNextPage = () => { + if (currentPage < totalPages) setCurrentPage((prev) => prev + 1) + } + + const handlePrevPage = () => { + if (currentPage > 1) setCurrentPage((prev) => prev - 1) + } + + // 화면 크기에 따라 표시할 아이템 수를 조정하는 useEffect & 드롭다운 + useEffect(() => { + const handleResize = () => { + if (window.innerWidth <= 743) { + setItemsDisplay(4) + setIsMobile(true) + } else if (window.innerWidth >= 744 && window.innerWidth <= 1023) { + setItemsDisplay(6) + setIsMobile(false) + } else { + setItemsDisplay(10) + setIsMobile(false) + } + } + handleResize() + window.addEventListener('resize', handleResize) + + return () => window.removeEventListener('resize', handleResize) + }, []) + + if (productListAll.isLoading) return
로딩 중...
+ if (productListAll.isError) + return
에러 발생: {productListAll.error.message}
+ return ( + <> +
+
전체상품
+
+
+ 검색 아이콘 +
+
+ { + setSearchTerm(e.target.value) + setCurrentPage(1) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setCurrentPage(1) + } + }} + /> +
+
+ + 상품 등록하기 + +
+ { + setSelectedOption(value) + setCurrentPage(1) + }} + /> +
+
+
+
+ {productListAll.data?.list.map((product: GetProductIdTypes) => ( + +
+ 0 + ? product.images[0] + : NoImage.src + } + alt={product.name} + /> +
+
{product.name}
+
+ {product.price.toLocaleString('ko-KR')}원 +
+
+ HeartInactive + {product.favoriteCount} +
+
+
+ + ))} +
+
+
+
+ 왼쪽 페이지 화살표 +
+ {pages.map((page) => ( +
{ + setCurrentPage(page) + }} + > + {page} +
+ ))} +
+ 오른쪽 페이지 화살표 +
+
+ + ) +} + +export default ProductListItems diff --git a/src/app/Items/RecentItems.module.scss b/src/app/Items/RecentItems.module.scss deleted file mode 100644 index b20d5639..00000000 --- a/src/app/Items/RecentItems.module.scss +++ /dev/null @@ -1,73 +0,0 @@ -@import '../../styles//variables.scss'; - -.recent-item { - height: 67.4rem; - @media (max-width: 743px) { - height: fit-content; - } -} -.recent-item-key { - display: flex; - height: 31.7rem; - width: 22.1rem; - justify-content: center; - flex-direction: column; - img { - width: 22.1rem; - height: 22.1rem; - border-radius: 1rem; - } - @media (max-width: 743px) { - height: 26.4rem; - width: 16.8rem; - img { - width: 16.8rem; - height: 16.8rem; - border-radius: 1rem; - } - } -} -.recent-item-image { - width: inherit; - height: 100%; - border-radius: 1rem; -} -.recent-items-display { - display: flex; - align-items: center; - justify-content: space-between; - flex-direction: row; - flex-wrap: wrap; - align-content: flex-start; - row-gap: 4rem; - @media (max-width: 743px) { - row-gap: 2rem; - } -} -.product-description { - width: 100%; - height: 8rem; - margin-top: 1.6rem; - display: flex; - flex-direction: column; - justify-content: space-between; -} -.product-name { - @include apply-font($font-14, 500); - color: $secondaryGray-800; -} -.product-price { - @include apply-font($font-16, 700); - color: $secondaryGray-800; -} -.product-favorite-count { - @include apply-font($font-12, 500); - color: $secondaryGray-600; - display: flex; - align-items: center; - gap: 0.4rem; - img { - width: 1.6rem; - height: 1.6rem; - } -} diff --git a/src/app/Items/RecentItems.tsx b/src/app/Items/RecentItems.tsx deleted file mode 100644 index a7e5bcd0..00000000 --- a/src/app/Items/RecentItems.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client' - -import React, { useEffect, useState } from 'react' -import Link from 'next/link' -import Image from 'next/image' - -import { GetProductIdTypes } from '../../types/product' - -import HeartInactive from '../../../public/assets/image/heart_inactive.png' -import NoImage from '../../../public/assets/image/no_image.png' - -import styles from './RecentItems.module.scss' - -const RecentItems = ({ products }: { products: GetProductIdTypes[] }) => { - const [itemsDisplay, setItemsDisplay] = useState(1) - - useEffect(() => { - const handleReasize = () => { - if (window.innerWidth <= 743) { - setItemsDisplay(4) - } else if (window.innerWidth >= 744 && window.innerWidth <= 1023) { - setItemsDisplay(6) - } else { - setItemsDisplay(10) - } - } - handleReasize() - window.addEventListener('resize', handleReasize) - - return () => window.removeEventListener('resize', handleReasize) - }, []) - return ( -
-
- {products.slice(0, itemsDisplay).map((product) => ( - -
- 0 - ? product.images[0] - : NoImage.src - } - alt={product.name} - /> -
-
{product.name}
-
- {product.price.toLocaleString('ko-KR')}원 -
-
- HeartInactive - {product.favoriteCount} -
-
-
- - ))} -
-
- ) -} - -export default RecentItems diff --git a/src/app/Items/items.module.scss b/src/app/Items/items.module.scss index 471f8ce0..c25dc7ba 100644 --- a/src/app/Items/items.module.scss +++ b/src/app/Items/items.module.scss @@ -11,157 +11,3 @@ margin: 1rem auto; } } -.nav-var { - height: 4.2rem; - width: 100%; - margin: 0 auto 2.4rem; - display: flex; - align-items: center; - justify-content: space-between; - @media (max-width: 743px) { - flex-wrap: wrap; - margin: 0 auto 6.6rem; - } -} -.nav-title { - @include apply-font($font-20, 700); - color: $secondaryGray-900; - @media (max-width: 743px) { - height: 2.625rem; - display: flex; - align-items: center; - justify-content: center; - } -} -.nav-right-wrapper { - height: 100%; - display: flex; - align-items: center; - position: relative; - @media (max-width: 743px) { - flex-wrap: wrap; - } -} -.search-icon { - position: absolute; - right: 585px; - z-index: 10; - display: flex; - @media (max-width: 1023px) { - right: 494px; - } - @media (max-width: 743px) { - right: 308px; - top: 28px; - } -} -.register-button { - padding: 0.8rem 2.3rem; - width: max-content; -} -.nav-search { - width: 32.5rem; - height: 100%; - padding: 0.9rem 10.7rem 0.9rem 4.4rem; - border-radius: 1.2rem; - @include apply-font($font-16, 400); - color: $secondaryGray-400; - background-color: $secondaryGray-100; - border: none; - margin-right: 1.2rem; - @media (max-width: 1023px) { - width: 24.2rem; - padding: 9px 24px 9px 44px; - } - @media (max-width: 743px) { - position: relative; - top: 19px; - padding: 9px 40px 9px 44px; - width: max-content; - margin: 0; - } -} -.search-input { - border: none; - background-color: $secondaryGray-100; -} - -.button-wrapper { - margin-right: 1.2rem; - @media (max-width: 743px) { - position: relative; - width: max-content; - top: -74px; - left: 206px; - } - button { - transition: all 0.3s ease-in-out; - - &:hover { - transform: scale(1.05); - } - - &:active { - transform: scale(0.95); - } - } -} - -.pagination { - width: 30.4rem; - height: 4rem; - display: flex; - align-items: center; - justify-content: space-between; - margin: 4.3rem auto 0 auto; - @media (max-width: 1023px) { - margin: 2.5rem auto 0 auto; - } -} -.arrow-button { - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - border: 1px solid $secondaryGray-200; - background: #ffffff; - color: $secondaryGray-600; - border-radius: 40px; - height: 40px; - width: 40px; - padding: 12px; - - &:hover { - background: $primaryBlue-200; - } - - &:disabled { - background: $secondaryGray-400; - cursor: not-allowed; - } -} - -interface PageButtonProps { - $isActive: boolean; -} -.page-button { - width: 40px; - height: 40px; - border-radius: 50%; - border: 1px solid $secondaryGray-100; - background: #fff; - color: $secondaryGray-900; - cursor: pointer; - transition: background 0.2s, color 0.2s; - display: flex; - align-items: center; - justify-content: center; - &:hover { - background: $primaryBlue-200; - } - - &.active { - background-color: $primaryBlue-100; - color: $secondaryGray-50; - } -} diff --git a/src/app/Items/page.tsx b/src/app/Items/page.tsx index ea2d758f..2b5757b8 100644 --- a/src/app/Items/page.tsx +++ b/src/app/Items/page.tsx @@ -1,201 +1,21 @@ 'use client' -import React, { useEffect, useState } from 'react' -import Image from 'next/image' -import { useRouter } from 'next/navigation' - -import { GetProductIdTypes } from '../../types/product' +import React from 'react' import ItemsNavVar from '../../components/domain/Nav/ItemsNavVar' import BestItems from './BestItems' -import RecentItems from './RecentItems' -import DropDown from '../../components/common/DropDown' -import productService from '../../lib/api/service/productService' -import Button from '../../components/common/Button' - -import Search from '../../../public/assets/svg/search.svg' -import ArrowLeft from '../../../public/assets/svg/arrow_left.svg' -import ArrowRight from '../../../public/assets/svg/arrow_right.svg' +import ProductListItems from './ProductListItems' import styles from './items.module.scss' -type SelectOption = { - value: string - name: string -} - const Items = () => { - const [bestProducts, setBestProducts] = useState([]) - const [sortedProducts, setSortedProducts] = useState([]) - const [totalItems, setTotalItems] = useState(0) // 전체 상품 개수 저장 - const router = useRouter() - const selectList: SelectOption[] = [ - { value: 'recent', name: '최신순' }, - { value: 'favorite', name: '좋아요순' }, - ] - const [selectedOption, setSelectedOption] = useState(selectList[0].value) - const itemsPerPage = 10 // 페이지 네이션 - const [currentPage, setCurrentPage] = useState(1) - const [isMobile, setIsMobile] = useState(false) - - useEffect(() => { - const queryParams = new URLSearchParams() - const orderBy = queryParams.get('orderBy') ?? 'recent' - const page = parseInt(queryParams.get('page') ?? '1', 10) - - setSelectedOption(orderBy) - setCurrentPage(page) - }, []) - - const handleAdditem = () => { - router.push('/additem') - } - useEffect(() => { - productService - .getProduct(1, 5, 'favorite', '') - .then((response) => { - const sorted = [...(response.data.list || [])].sort((a, b) => { - if (selectedOption === 'recent') { - return ( - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ) // Date 쓸 때는 getTime을 써야 함함 - } else if (selectedOption === 'favorite') { - return b.favoriteCount - a.favoriteCount - } - return 0 - }) - console.log(sorted) - setBestProducts(sorted) - console.log('bestList:', sorted) - }) - .catch((error) => { - console.error('베스트 상품 불러오기 실패:', error) - }) - }, []) - // 페이지네이션 - useEffect(() => { - productService - .getProduct(currentPage, 10, selectedOption, '') - .then((response) => { - const sorted = [...(response.data.list || [])].sort((a, b) => { - if (selectedOption === 'recent') { - return ( - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ) // Date 쓸 때는 getTime을 써야 함함 - } else if (selectedOption === 'favorite') { - return b.favoriteCount - a.favoriteCount - } - return 0 - }) - - setSortedProducts(sorted) - setTotalItems(response.data.totalCount) - }) - }, [currentPage, selectedOption]) - - const totalPages = Math.ceil(totalItems / itemsPerPage) - - const pages = (() => { - if (totalPages <= 5) { - return Array.from({ length: totalPages }, (_, i) => i + 1) - } - if (currentPage <= 3) { - return [1, 2, 3, 4, 5] - } - if (currentPage >= totalPages - 2) { - return Array.from({ length: 5 }, (_, i) => totalPages - 4 + i) - } - return Array.from({ length: 5 }, (_, i) => currentPage - 2 + i) - })() - - const handleNextPage = () => { - setCurrentPage((prev) => { - const nextPage = prev + 1 - - return nextPage - }) - } - - const handlePrevPage = () => { - setCurrentPage((prev) => { - const prevPage = prev > 1 ? prev - 1 : 1 - - return prevPage - }) - } - useEffect(() => { - const checkIsMobile = () => { - setIsMobile(window.innerWidth <= 743) - } - - checkIsMobile() - window.addEventListener('resize', checkIsMobile) - - return () => window.removeEventListener('resize', checkIsMobile) - }, []) return ( <>
- - -
-
전체상품
-
-
- 검색 아이콘 -
-
- -
-
- -
- { - setSelectedOption(value) - }} - /> -
-
- - + -
-
- 왼쪽 페이지 화살표 -
- {pages.map((page) => ( -
{ - setCurrentPage(page) - }} - > - {page} -
- ))} -
- 오른쪽 페이지 화살표 -
-
+
) diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 531f752b..f829b6ba 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -5,6 +5,8 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' import Image from 'next/image' +import { SigninForm, baseSigninSchema } from '@/hooks/useSigninForm' +import { useSigninMutation } from '@/hooks/useSigninMutation' import LoginField from '../../components/domain/LoginAndSignup/LoginField' import Button from '../../components/common/Button' @@ -20,58 +22,91 @@ import styles from './login.module.scss' const Login = () => { const [email, setEmail] = useState('') const [password, setPassword] = useState('') - const [emailError, setEmailError] = useState('') - const [passwordError, setPasswordError] = useState('') const [showPassword, setShowPassword] = useState(false) - const [isState, setIsState] = useState(false) + const [isLoginState, setIsLoginState] = useState(false) const router = useRouter() + const { mutate: signin, isPending } = useSigninMutation() + const [signinFormError, setSigninFormError] = useState< + Partial> + >({}) const togglePasswordVisibility = () => { setShowPassword((prev) => !prev) } - const validateEmail = (email: string) => { - if (!email) return '이메일을 입력해주세요.' - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - return emailRegex.test(email) ? '' : '잘못된 이메일 형식입니다.' - } + const handleSignin = () => { + const form: SigninForm = { email, password } - const validatePassword = (password: string) => { - if (!password || password === '') { - return '비밀번호를 입력해주세요.' - } - if (password.length < 8) { - return '비밀번호를 8자 이상 입력해주세요.' + // 전체 유효성 검사 + const response = baseSigninSchema.safeParse(form) + if (!response.success) { + const fieldErrors: Partial> = {} + for (const issue of response.error.issues) { + const field = issue.path[0] as keyof SigninForm + fieldErrors[field] = issue.message + } + setSigninFormError(fieldErrors) + return } - return '' + // mutate 호출 시, onSuccess 콜백 등록해서 성공 시 리다이렉트 처리 + signin(form, { + onSuccess: () => { + router.push('/') + }, + }) } - const handleLogin = () => { - const emailValidation = validateEmail(email) - const passwordValidation = validatePassword(password) + // 부분검사용 스키마 (필드별 검사) + const partialSchemas = { + email: baseSigninSchema.pick({ email: true }), + password: baseSigninSchema.pick({ password: true }), + } - setEmailError(emailValidation) - setPasswordError(passwordValidation) + // 필드별 유효성 검사 함수 + function validateField(field: keyof SigninForm, value: string) { + const schema = partialSchemas[field] + if (!schema) return - if (!emailValidation && !passwordValidation) { - router.push('/items') + const result = schema.safeParse({ [field]: value }) + if (!result.success) { + setSigninFormError((prev) => ({ + ...prev, + [field]: result.error.issues[0].message, + })) + } else { + setSigninFormError((prev) => ({ + ...prev, + [field]: '', + })) } } + // 로그인 버튼 활성화 상태 관리 + useEffect(() => { + const hasError = Object.values(signinFormError).some((msg) => msg) + const hasEmpty = !email || !password + const valid = !hasError && !hasEmpty + setIsLoginState(valid) + }, [email, password, signinFormError]) + + // 페이지가 로드될 때 토큰이 있으면 홈으로 리다이렉트 useEffect(() => { - const valid = password.length >= 8 && !!email - setIsState(valid) - }, [email, password]) + const token = localStorage.getItem('accessToken') + if (token) { + router.replace('/') + } + }, [router]) + return (
- + 판다마켓 로고 사진
- + 판다마켓 로고 사진
@@ -81,12 +116,12 @@ const Login = () => { type="email" placeholder="이메일을 입력해주세요" id="email" - validate={validateEmail} value={email} onChange={(e) => { setEmail(e.target.value) }} - error={emailError} + onBlur={() => validateField('email', email)} + error={signinFormError.email} /> { id="password" icon={showPassword ? Visibillity : VisibillityOff} onIconClick={(e) => { - e.stopPropagation() + e.preventDefault() togglePasswordVisibility() }} - validate={validatePassword} value={password} onChange={(e) => { setPassword(e.target.value) }} - error={passwordError} + onBlur={() => validateField('password', password)} + error={signinFormError.password} />
@@ -129,9 +164,7 @@ const Login = () => {
판다마켓이 처음이신가요?  
- - 회원가입 - + 회원가입
diff --git a/src/app/Home/home.module.scss b/src/app/page.style.module.scss similarity index 76% rename from src/app/Home/home.module.scss rename to src/app/page.style.module.scss index 510a4363..aaa7f092 100644 --- a/src/app/Home/home.module.scss +++ b/src/app/page.style.module.scss @@ -1,42 +1,12 @@ -@import '../../styles/variables.scss'; +@import '../styles/variables.scss'; -.header-top { - display: flex; - justify-content: center; - position: sticky; - top: 0; - background-color: #ffffff; - position: sticky; - z-index: 10; -} - -.header-nav { - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; - padding: 0.6rem 0px; - margin: 0 13rem; - @media (max-width: 1023px) { - margin: 0 1.5rem; - } +.panda-background { + width: 74.6rem; + height: 30.4rem; + position: relative; @media (max-width: 743px) { - margin: 0 1rem; - } -} - -.ghader-logo { - width: 100%; - height: 100%; - display: flex; - align-items: center; -} - -.button-wrapper { - width: auto; - height: auto; - @media (max-width: 1023px) { - width: max-content; + width: 100%; + height: 20rem; } } @@ -59,7 +29,7 @@ .header-login { width: 10.16rem; height: 3rem; - background-color: $primaryBlue-100; + background-color: #3692ff; border-radius: 8px; @include apply-font($font-16, 600); color: $secondaryGray-50; @@ -78,12 +48,13 @@ .header-main { width: 100%; background-color: #cfe5ff; + position: relative; } .header-main-container { width: max-content; display: flex; - padding: 12.5rem 0px 0px 0px; + padding: 20rem 0px 0px 0px; margin: 0 auto; @media (max-width: 1023px) { width: 100%; @@ -113,15 +84,15 @@ justify-content: center; } @media (max-width: 743px) { - width: 15rem; - margin-bottom: 8.25rem; + width: 24rem; + margin-bottom: 13.2rem; } } .header-title-font { - width: 20rem; + width: 32.5rem; @include apply-font($font-40, 700); - margin: 0 4rem 2rem 0; + margin: 0 6.2rem 3.2rem 0; color: $secondaryGray-700; @media (max-width: 1023px) { margin-top: 84px; @@ -129,13 +100,28 @@ margin: 84px auto 24px; } @media (max-width: 743px) { - margin-bottom: 1.125rem; - text-align: center; @include apply-font($font-32, 700); - width: 254px; + width: 26rem; + margin-bottom: 1.8rem; + text-align: center; + } +} +.header-button { + padding: 1.2rem 12.4rem; + width: max-content; + + @media (max-width: 743px) { + padding: 1.1rem 7.1rem; } } +.items-button { + padding: 1.2rem 12.4rem; + width: max-content; + @media (max-width: 768px) { + padding: 1.1rem 7.1rem; + } +} .header-items { width: 7.25rem; background-color: $primaryBlue-100; @@ -158,68 +144,64 @@ flex-direction: column; align-items: center; @media (max-width: 1023px) { - margin: 24px; + margin: 2.4rem; } @media (max-width: 743px) { margin: 52px 16px 2.5rem 15px; - width: 344px; } } .main-theme { display: flex; background-color: #fcfcfc; - margin: 8.63rem auto; + margin: 13.8rem auto; @media (max-width: 1023px) { width: 100%; flex-direction: column; margin: 0; } } - +// 지우셈 .main-popular-sell-image { - width: auto; - height: 27.75rem; - @media (max-width: 1023px) { img { width: 100%; - height: 32.813rem; + height: 52.5rem; + border-radius: 1.4rem; } } @media (max-width: 743px) { - height: 16.187rem; + height: 25.9rem; img { - width: 100%; height: 100%; } } } .main-theme-basic { - width: 18.625rem; + width: 30.8rem; display: flex; justify-content: center; align-items: flex-start; background-color: #fcfcfc; flex-direction: column; - margin: auto 4rem; + margin: auto 2.3rem auto 6.4rem; @media (max-width: 1023px) { width: 100%; margin: 0; } @media (max-width: 743px) { height: 134px; - margin: 1.5rem auto 2.69rem 0; + margin: 2.4rem auto 4rem 0; } } .main-popular-sell-font-top { - margin: 0 0px 0.75rem 0; + margin: 0 0px 1.2rem 0; @include apply-font($font-18, 700); color: $primaryBlue-100; @media (max-width: 1023px) { - margin: 1.5rem auto 1rem; + margin: 2.4rem auto 1.6rem; } @media (max-width: 743px) { margin: 0 auto 0.5rem 0rem; @@ -227,9 +209,8 @@ } } -.main-poular-sell-font-middle { - width: 306px; - margin: 0 auto 1.5rem 0; +.main-popular-sell-font-middle { + margin: 0 auto 2.4rem 0; @include apply-font($font-40, 700); color: $secondaryGray-700; @media (max-width: 1023px) { @@ -239,19 +220,17 @@ @media (max-width: 743px) { @include apply-font($font-24, 700); margin-bottom: 1rem; - line-height: 2rem; } } .main-popular-sell-font-bottom { @include apply-font($font-24, 500); color: $secondaryGray-700; - width: 100%; + @media (max-width: 1023px) { @include apply-font($font-18, 500); - width: 100%; - width: 15.75rem; - margin-bottom: 3.25rem; + width: 23.6rem; + margin-bottom: 5.2rem; } @media (max-width: 743px) { margin: 0; @@ -259,10 +238,11 @@ width: 205px; } } + .main-theme-center { display: flex; background-color: #fcfcfc; - margin: 8.63rem auto; + margin: 13.8rem auto; @media (max-width: 1023px) { width: 100%; flex-direction: column-reverse; @@ -272,29 +252,42 @@ flex-wrap: wrap-reverse; } } + .main-theme-basic-middle { + width: 30.8rem; + display: flex; + justify-content: center; + background-color: #fcfcfc; + flex-direction: column; + margin: auto 2.3rem auto 6.4rem; align-items: flex-end; text-align: right; + + margin: auto 6.4rem auto 1.3rem; + @media (max-width: 1023px) { + margin: 0; + width: 100%; + } @media (max-width: 743px) { - width: 330px; - margin-right: 0; + width: 33rem; + margin: 2.4rem auto 4rem auto; } } .main-search-image { - width: 579px; - height: 444px; + height: 44.4rem; @media (max-width: 1023px) { width: 100%; height: 100%; margin: 0; img { width: 100%; - height: 32.5rem; + height: 52rem; + border-radius: 1.4rem; } } @media (max-width: 743px) { - height: 16.187rem; + height: 25.9rem; img { width: 100%; height: 100%; @@ -311,7 +304,7 @@ .footer-empty { width: 100%; - height: 138px; + height: 13.8rem; background-color: #fcfcfc; @media (max-width: 1023px) { width: 0; @@ -325,18 +318,11 @@ align-items: center; width: 100%; background-color: #cfe5ff; - @media (max-width: 1023px) { - } - @media (max-width: 743px) { - height: 33.75rem; - } } .footer-background { display: flex; align-items: center; - height: 397px; - margin-top: 9rem; - width: 69.375rem; + margin-top: 14.3rem; @media (max-width: 1023px) { flex-direction: column; justify-content: space-between; @@ -348,27 +334,32 @@ .footer-font { @include apply-font($font-40, 700); + width: 32.5rem; + height: 17.2rem; + margin-right: 6.5rem; @media (max-width: 1023px) { - margin: 12.562rem auto 13.562rem; - width: 20.438rem; + margin: 20.1rem auto 21.7rem; + width: 32.5rem; text-align: center; line-height: 56px; } @media (max-width: 743px) { @include apply-font($font-32, 700); - margin: 121px auto 131px; + margin: 12rem auto 13rem; width: 254px; - height: 90px; + height: fit-content; text-align: center; } } .footer-image { - width: 46.63rem; - height: 24.81rem; + width: 74.6rem; + height: 39rem; @media (max-width: 743px) { width: 100%; + height: 19.8rem; img { + height: 19.8rem; width: 100%; } } @@ -381,16 +372,16 @@ justify-content: center; } -.footern-nav-main { - height: 1.25rem; - width: 70rem; +.footer-nav-main { + height: 2rem; + width: 112rem; display: flex; justify-content: space-between; align-items: center; - margin: 2rem 0 6.75rem 0; + margin: 3.2rem 0 10.8rem 0; @media (max-width: 1023px) { - width: 33.5rem; - margin: 2rem 8rem 6.75rem 8rem; + width: 53.6rem; + margin: 3.2rem auto 10.8rem auto; } @media (max-width: 743px) { margin: 2rem; @@ -410,17 +401,12 @@ } @media (max-width: 743px) { grid-area: ct; - font-weight: 400; - font-size: 16px; - width: 6.88rem; - height: 1rem; - font-size: 1rem; line-height: 18.4px; - text-align: center; } } .privacy-faq { + display: flex; margin-right: 13px; @media (max-width: 743px) { grid-area: hd; @@ -432,11 +418,11 @@ } .social { - width: 7.25rem; - height: 20px; + width: 11.6rem; + height: 2rem; display: flex; justify-content: space-between; - image { + @image { width: 18px; height: 18px; } @@ -447,7 +433,9 @@ color: $secondaryGray-200; text-decoration: none; @media (max-width: 743px) { - margin: 0 30px 0 0; + @include apply-font($font-16, 400); + margin: 0 3rem 0 0; + width: max-content; } } diff --git a/src/app/page.style.ts b/src/app/page.style.ts deleted file mode 100644 index 17719a23..00000000 --- a/src/app/page.style.ts +++ /dev/null @@ -1,476 +0,0 @@ -import styled from 'styled-components' -import { theme } from '../styles/theme' -import { textStyle } from '../styles/textStyle' -import Button from '../components/common/Button' - -export const HeaderTop = styled.header` - display: flex; - justify-content: center; - - top: 0; - background-color: #ffffff; -` - -export const HeaderNav = styled.nav` - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; - padding: 0.95rem 0px; - margin: 0 40rem; - @media (max-width: 1023px) { - margin: 0 2.4rem; - } - @media (max-width: 743px) { - margin: 0 1rem; - } -` -export const PandaLogo = styled.div` - display: flex; - @media (max-width: 743px) { - display: none; - } -` -export const PandaLogoName = styled.div` - display: flex; -` -export const HeaderLogo = styled.div` - width: 100%; - height: 100%; - display: flex; - align-items: center; - gap: 0.85rem; -` - -export const PandaBackground = styled.div` - width: 74.6rem; - height: 30.4rem; - position: relative; - @media (max-width: 743px) { - width: 100%; - height: 20rem; - } -` - -export const HeaderLogoFace = styled.img` - width: 2.5rem; - height: 2.5rem; - margin-right: 0.5rem; - display: flex; - @media (max-width: 743px) { - width: 0; - } -` - -export const HeaderLogoName = styled.img` - width: 6.44rem; - height: 2.19rem; - display: flex; -` - -export const HeaderLogin = styled.a` - width: 10.16rem; - height: 3rem; - background-color: #3692ff; - border-radius: 8px; - ${(props) => textStyle(16, 600)(props)} - color: ${theme.colors.SecondaryGray[50]}; - text-decoration: none; - display: flex; - justify-content: center; - align-items: center; - @media (max-width: 1023px) { - width: 9.73rem; - } - @media (max-width: 743px) { - width: 9.73rem; - } -` - -export const HeaderMain = styled.div` - width: 100%; - background-color: #cfe5ff; - position: relative; -` - -export const HeaderMainContainer = styled.div` - width: max-content; - display: flex; - padding: 20rem 0px 0px 0px; - margin: 0 auto; - @media (max-width: 1023px) { - width: 100%; - height: 771px; - flex-direction: column; - align-items: center; - justify-content: space-between; - padding: 0; - } - @media (max-width: 743px) { - width: 100%; - height: auto; - img { - width: 100%; - } - } -` - -export const HeaderMainTitle = styled.div` - width: auto; - margin: auto 0; - @media (max-width: 1023px) { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - } - @media (max-width: 743px) { - width: 24rem; - margin-bottom: 13.2rem; - } -` - -export const HeaderTitleFont = styled.h1` - width: 32.5rem; - ${(props) => textStyle(40, 700)(props)} - margin: 0 6.2rem 3.2rem 0; - color: ${theme.colors.SecondaryGray[700]}; - @media (max-width: 1023px) { - margin-top: 84px; - width: auto; - margin: 84px auto 24px; - } - @media (max-width: 743px) { - ${(props) => textStyle(32, 700)(props)} - width: 26rem; - margin-bottom: 1.8rem; - text-align: center; - } -` - -export const ItemsButton = styled(Button)` - padding: 1.2rem 12.4rem; - width: max-content; - @media (max-width: 768px) { - padding: 1.1rem 7.1rem; - } -` -export const HeaderItems = styled.a` - width: 7.25rem; - background-color: ${theme.colors.PrimaryBlue[100]}; - border-radius: 40px; - ${(props) => textStyle(20, 600)(props)} - color: ${theme.colors.SecondaryGray[50]}; - text-decoration: none; - padding: 0.75rem 7.75rem; - display: inline-block; - @media (max-width: 743px) { - padding: 0.5rem 4rem; - margin-top: 1.13rem; - } -` - -/* 메인 */ - -export const MainBasic = styled.main` - display: flex; - flex-direction: column; - align-items: center; - @media (max-width: 1023px) { - margin: 2.4rem; - } - @media (max-width: 743px) { - margin: 52px 16px 2.5rem 15px; - } -` - -export const MainTheme = styled.div` - display: flex; - background-color: #fcfcfc; - margin: 13.8rem auto; - @media (max-width: 1023px) { - width: 100%; - flex-direction: column; - margin: 0; - } -` -// 지우셈 -export const MainPopularSellImage = styled.div` - @media (max-width: 1023px) { - img { - width: 100%; - height: 52.5rem; - border-radius: 1.4rem; - } - } - @media (max-width: 743px) { - height: 25.9rem; - img { - height: 100%; - } - } -` - -export const MainThemeBasic = styled.div` - width: 30.8rem; - display: flex; - justify-content: center; - align-items: flex-start; - background-color: #fcfcfc; - flex-direction: column; - margin: auto 2.3rem auto 6.4rem; - @media (max-width: 1023px) { - width: 100%; - margin: 0; - } - @media (max-width: 743px) { - height: 134px; - margin: 2.4rem auto 4rem 0; - } -` - -export const MainPopularSellFontTop = styled.p` - margin: 0 0px 1.2rem 0; - ${(props) => textStyle(18, 700)(props)} - color: ${theme.colors.PrimaryBlue[100]}; - @media (max-width: 1023px) { - margin: 2.4rem auto 1.6rem; - } - @media (max-width: 743px) { - margin: 0 auto 0.5rem 0rem; - ${(props) => textStyle(16, 700)(props)} - } -` - -export const MainPopularSellFontMiddle = styled.h2` - margin: 0 auto 2.4rem 0; - ${(props) => textStyle(40, 700)(props)} - color: ${theme.colors.SecondaryGray[700]}; - @media (max-width: 1023px) { - ${(props) => textStyle(32, 700)(props)} - width:100%; - } - @media (max-width: 743px) { - ${(props) => textStyle(24, 700)(props)} - margin-bottom: 1rem; - } -` - -export const MainPopularSellFontBottom = styled.p` - ${(props) => textStyle(24, 500)(props)} - color: ${theme.colors.SecondaryGray[700]}; - - @media (max-width: 1023px) { - ${(props) => textStyle(18, 500)(props)} - width: 23.6rem; - margin-bottom: 5.2rem; - } - @media (max-width: 743px) { - margin: 0; - ${(props) => textStyle(16, 500)(props)} - width: 205px; - } -` - -export const MainThemeCenter = styled.div` - display: flex; - background-color: #fcfcfc; - margin: 13.8rem auto; - @media (max-width: 1023px) { - width: 100%; - flex-direction: column-reverse; - margin: 0; - } - @media (max-width: 743px) { - flex-wrap: wrap-reverse; - } -` - -export const MainThemeBasicMiddle = styled(MainThemeBasic)` - align-items: flex-end; - text-align: right; - - margin: auto 6.4rem auto 1.3rem; - @media (max-width: 1023px) { - margin: 0; - } - @media (max-width: 743px) { - width: 330px; - margin: 2.4rem auto 4rem auto; - } -` - -export const MainSearchImage = styled.picture` - height: 44.4rem; - @media (max-width: 1023px) { - width: 100%; - height: 100%; - margin: 0; - img { - width: 100%; - height: 52rem; - border-radius: 1.4rem; - } - } - @media (max-width: 743px) { - height: 25.9rem; - img { - width: 100%; - height: 100%; - } - } -` -/* footer */ - -export const FooterMainContainer = styled.footer` - display: flex; - flex-direction: column; - align-items: center; -` - -export const FooterEmpty = styled.div` - width: 100%; - height: 13.8rem; - background-color: #fcfcfc; - @media (max-width: 1023px) { - width: 0; - height: 0; - } -` - -export const FooterContainer = styled.div` - display: flex; - justify-content: center; - align-items: center; - width: 100%; - background-color: #cfe5ff; -` -export const FooterBackground = styled.div` - display: flex; - align-items: center; - margin-top: 14.3rem; - @media (max-width: 1023px) { - flex-direction: column; - justify-content: space-between; - width: 100%; - margin-top: 0; - height: 100%; - } -` - -export const FooterFont = styled.div` - ${(props) => textStyle(40, 700)(props)} - width: 32.5rem; - height: 17.2rem; - margin-right: 6.5rem; - @media (max-width: 1023px) { - margin: 20.1rem auto 21.7rem; - width: 32.5rem; - text-align: center; - line-height: 56px; - } - @media (max-width: 743px) { - ${(props) => textStyle(32, 700)(props)} - margin: 12rem auto 13rem; - width: 254px; - height: fit-content; - text-align: center; - } -` - -export const FooterImage = styled.picture` - width: 74.6rem; - height: 39rem; - @media (max-width: 743px) { - width: 100%; - height: 19.8rem; - img { - height: 19.8rem; - width: 100%; - } - } -` - -export const FooterNav = styled.div` - width: 100%; - background-color: ${theme.colors.SecondaryGray[900]}; - display: flex; - justify-content: center; -` - -export const FooterNavMain = styled.div` - height: 2rem; - width: 112rem; - display: flex; - justify-content: space-between; - align-items: center; - margin: 3.2rem 0 10.8rem 0; - @media (max-width: 1023px) { - width: 53.6rem; - margin: 3.2rem auto 10.8rem auto; - } - @media (max-width: 743px) { - margin: 2rem; - display: grid; - grid-template-areas: - 'hd hdt' - 'ct ctt'; - width: 100%; - height: 98px; - } -` - -export const Codeit = styled.p` - color: ${theme.colors.SecondaryGray[400]}; - @media (max-width: 1023px) { - ${(props) => textStyle(16, 400)(props)} - } - @media (max-width: 743px) { - grid-area: ct; - line-height: 18.4px; - } -` - -export const PrivacyFaq = styled.div` - display: flex; - margin-right: 13px; - @media (max-width: 743px) { - grid-area: hd; - align-items: center; - justify-items: center; - margin: 0; - width: 12.32rem; - } -` - -export const Social = styled.div` - width: 11.6rem; - height: 2rem; - display: flex; - justify-content: space-between; - @image { - width: 18px; - height: 18px; - } -` - -export const Privacy = styled.div` - margin: auto 2rem auto 0; - color: ${theme.colors.SecondaryGray[200]}; - text-decoration: none; - @media (max-width: 743px) { - ${(props) => textStyle(16, 400)(props)} - margin: 0 3rem 0 0; - width: max-content; - } -` - -export const Faq = styled.div` - color: ${theme.colors.SecondaryGray[200]}; - text-decoration: none; - margin: auto 0; - @media (max-width: 743px) { - margin-right: 2rem; - } -` diff --git a/src/app/page.tsx b/src/app/page.tsx index cd10a712..1a5a6ad6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,11 +3,9 @@ import Image from 'next/image' import Link from 'next/link' -import * as S from './page.style' import Button from '../components/common/Button' +import HomeNavVar from '@/components/domain/Nav/HomeNavVar' -import Logo from '../../public/assets/image/logo_text.png' -import LogoFace from '../../public/assets/image/logo_face.png' import HomeTop from '../../public/assets/image/home_top.png' import HomeBottom from '../../public/assets/image/home_bottom.png' import HomeHotItems from '../../public/assets/image/home_hot_items.png' @@ -18,144 +16,114 @@ import Instagram from '../../public/assets/svg/instagram.svg' import Twitter from '../../public/assets/svg/twitter.svg' import Youtube from '../../public/assets/svg/youtube.svg' +import styles from './page.style.module.scss' + function Home() { return ( <> - - - - - - 판다마켓 로고 사진 - - - - - 판다마켓 로고 사진 - - - - - - - - - - - + +
+
+
+

일상의 모든 물건을 거래해 보세요 - +

- + +
+
판다마켓 백그라운드사진 - - - - - - +
+
+
+
+
+
판다마켓 인기 상품 사진 - - +
+
- Hot item +

Hot item

- +

인기 상품을 확인해 보세요 - +

- +

가장 HOT한 중고거래 물품을 판다 마켓에서 확인해 보세요 - +

- - - - +
+
+
+
- Search +

Search

- +

구매를 원하는 상품을 검색하세요 - +

- +

구매하고 싶은 물품은 검색해서 쉽게 찾아보세요 - +

- - +
+
판다마켓 상품 검색 사진 - - - - +
+
+
+
판다마켓 인기 상품 등록 - - +
+
- Register +

Register

- +

판매를 원하는 상품을 등록하세요 - +

- +

어떤 물건이든 판매하고 싶은 상품을 쉽게 등록하세요 - +

+
+
+
+
+
+
+
+
+
+ 믿을 수 있는 판다마켓 중고 거래
- - - - - - - - 믿을 수 있는 판다마켓 중고 거래 - +
판다마켓 백그라운드사진 - - - - - - ©codeit - 2024 - - - Privacy Policy +
+
+
+
+
+

©codeit - 2024

+
+ +

Privacy Policy

- - FAQ + +

FAQ

- - +
+
페이스북 로고 사진 @@ -168,10 +136,10 @@ function Home() { 인스타그램 로고 사진 - - - - +
+
+
+
) } diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index e3d62d2d..26e4acd2 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -5,6 +5,12 @@ import Image from 'next/image' import { useRouter } from 'next/navigation' import Link from 'next/link' +import { + SignupForm, + signupSchema, + baseSignupSchema, +} from '@/hooks/useSignupForm' +import { useSignupMutation } from '@/hooks/useSignupMutation' import LoginField from '../../components/domain/LoginAndSignup/LoginField' import Button from '../../components/common/Button' @@ -20,17 +26,20 @@ import styles from './signup.module.scss' const Signup = () => { const [email, setEmail] = useState('') const [password, setPassword] = useState('') - const [passwordConfirm, setPasswordConfirm] = useState('') - const [name, setName] = useState('') - const [emailError, setEmailError] = useState('') - const [passwordError, setPasswordError] = useState('') - const [passwordConfirmError, setPasswordConfirmError] = useState('') + const [passwordConfirmation, setPasswordConfirmation] = useState('') + const [nickname, setNickname] = useState('') const [isState, setIsState] = useState(false) + const { mutate: signup, isPending } = useSignupMutation() const [passwordVisibility, setPasswordVisibility] = useState({ password: false, confirmPassword: false, }) const router = useRouter() + + const [signupFormError, setSignupFormError] = useState< + Partial> + >({}) + const togglePasswordVisibility = (field: keyof typeof passwordVisibility) => { setPasswordVisibility((prev) => ({ ...prev, @@ -38,55 +47,83 @@ const Signup = () => { })) } - const validateEmail = (email: string) => { - if (!email) return '이메일을 입력해주세요.' - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - return emailRegex.test(email) ? '' : '잘못된 이메일 형식입니다.' - } - - const validatePassword = (password: string) => { - if (!password || password === '') { - return '비밀번호를 입력해주세요.' + const handleSignup = async () => { + const form: SignupForm = { + email, + nickname, + password, + passwordConfirmation, } - if (password.length < 8) { - return '비밀번호를 8자 이상 입력해주세요.' + + const response = signupSchema.safeParse(form) + + if (!response.success) { + const fieldErrors: Partial> = {} + for (const issue of response.error.issues) { + const field = issue.path[0] as keyof SignupForm + fieldErrors[field] = issue.message + } + setSignupFormError(fieldErrors) + return } - return '' + signup(form, { + onSuccess: () => { + router.push('/login') + }, + }) } - const validatePasswordConfirm = (passwordConfirm: string) => { - if (passwordConfirm !== password) { - return '비밀번호가 일치하지 않습니다.' - } - return '' + + // 부분검사용 스키마 (필드별 검사) + const partialSchemas = { + email: baseSignupSchema.pick({ email: true }), + password: baseSignupSchema.pick({ password: true }), + passwordConfirmation: baseSignupSchema.pick({ passwordConfirmation: true }), + nickname: baseSignupSchema.pick({ nickname: true }), } - const handleLogin = () => { - const emailValidation = validateEmail(email) - const passwordValidation = validatePassword(password) - const passwordConfirmValidation = validatePasswordConfirm(passwordConfirm) - - setEmailError(emailValidation) - setPasswordError(passwordValidation) - setPasswordConfirmError(passwordConfirmValidation) - if (!emailValidation && !passwordValidation && !passwordValidation) { - router.push('/login') + + // 필드별 유효성 검사 함수 예 + function validateField(field: keyof SignupForm, value: string) { + const schema = partialSchemas[field] + if (!schema) return + + const result = schema.safeParse({ [field]: value }) + if (!result.success) { + setSignupFormError((prev) => ({ + ...prev, + [field]: result.error.issues[0].message, + })) + } else { + setSignupFormError((prev) => ({ + ...prev, + [field]: '', + })) } } useEffect(() => { - const valid = - password.length >= 8 && !!email && passwordConfirm === password + const hasError = Object.values(signupFormError).some((msg) => msg) + const hasEmpty = !email || !password || !passwordConfirmation || !nickname + const valid = !hasError && !hasEmpty setIsState(valid) - }, [email, password, passwordConfirm]) + }, [email, password, passwordConfirmation, nickname, signupFormError]) + + // 페이지가 로드될 때 토큰이 있으면 홈으로 리다이렉트 + useEffect(() => { + const token = localStorage.getItem('accessToken') + if (token) { + router.replace('/') + } + }, [router]) return (
- + 판다마켓 로고 사진
- + 판다마켓 로고 사진
@@ -96,23 +133,21 @@ const Signup = () => { type="email" placeholder="이메일을 입력해주세요" id="email" - validate={validateEmail} value={email} - onChange={(e) => { - setEmail(e.target.value) - }} - error={emailError} + onChange={(e) => setEmail(e.target.value)} + onBlur={() => validateField('email', email)} + error={signupFormError.email} /> { - setName(e.target.value) - }} - > + id="nickname" + value={nickname} + onChange={(e) => setNickname(e.target.value)} + onBlur={() => validateField('nickname', nickname)} + error={signupFormError.nickname} + /> { id="password" icon={passwordVisibility.password ? Visibillity : VisibillityOff} onIconClick={() => togglePasswordVisibility('password')} - validate={validatePassword} value={password} - onChange={(e) => { - setPassword(e.target.value) - }} - error={passwordError} + onChange={(e) => setPassword(e.target.value)} + onBlur={() => validateField('password', password)} + error={signupFormError.password} /> togglePasswordVisibility('confirmPassword')} - validate={validatePasswordConfirm} - value={passwordConfirm} - onChange={(e) => { - setPasswordConfirm(e.target.value) - }} - error={passwordConfirmError} + value={passwordConfirmation} + onChange={(e) => setPasswordConfirmation(e.target.value)} + onBlur={() => + validateField('passwordConfirmation', passwordConfirmation) + } + error={signupFormError.passwordConfirmation} />
@@ -165,7 +198,7 @@ const Signup = () => {
이미 회원이신가요?  
- + 로그인
diff --git a/src/components/domain/LoginAndSignup/LoginField.tsx b/src/components/domain/LoginAndSignup/LoginField.tsx index ddee0b73..03f1cb15 100644 --- a/src/components/domain/LoginAndSignup/LoginField.tsx +++ b/src/components/domain/LoginAndSignup/LoginField.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React from 'react' import Image from 'next/image' import styles from './LoginField.module.scss' @@ -11,7 +11,7 @@ interface LoginFieldtProps { placeholder: string icon?: string | null onIconClick?: (e: React.MouseEvent) => void - validate?: (value: string) => string + onBlur?: (e: React.FocusEvent) => void value: string onChange?: (e: React.ChangeEvent) => void error?: string @@ -23,18 +23,11 @@ const LoginField = ({ placeholder, icon = null, onIconClick, - validate, + onBlur, value, onChange, + error, }: LoginFieldtProps) => { - const [error, setError] = useState('') - - const handleBlur = () => { - if (validate) { - setError(validate(value)) - } - } - return (
{/* label 하지 않은 이유는 아이콘 클릭시 input에 포커스 가기 때문에 label을 삭제하고 div를 넣음*/} @@ -50,7 +43,7 @@ const LoginField = ({ id={id} value={value} onChange={onChange} - onBlur={handleBlur} + onBlur={onBlur} /> {icon && (
(null) + const profileRef = useRef(null) + + // 드롭다운 메뉴 토글 핸들러 + const toggleDropLogout = () => { + setIsDropLogout((prev) => !prev) + } + // 로그아웃 핸들러 + const handleLogout = () => { + localStorage.removeItem('accessToken') + setIsLogin(false) + setIsDropLogout(false) + } + // 컴포넌트가 마운트될 때 로컬 스토리지에서 토큰을 확인하여 로그인 상태 설정 + useEffect(() => { + const token = localStorage.getItem('accessToken') + if (token) { + setIsLogin(true) + } + }, []) + // 마우스 클릭 이벤트를 감지하여 드롭다운 외부 클릭 시 드롭다운 닫기 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + profileRef.current && + !profileRef.current.contains(event.target as Node) + ) { + setIsDropLogout(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [dropdownRef, profileRef]) + return ( + <> +
+
+
+ +
+ 판다마켓 로고 사진 +
+ + +
+ 판다마켓 로고 사진 +
+ +
+ {isLogin ? ( + 프로필 이미지 + ) : ( + + )} +
+ {isDropLogout && ( +
+
    +
  • 로그아웃
  • +
+
+ )} +
+ + ) +} + +export default HomeNavVar diff --git a/src/components/domain/Nav/ItemsNavVar.module.scss b/src/components/domain/Nav/ItemsNavVar.module.scss new file mode 100644 index 00000000..dc4096a8 --- /dev/null +++ b/src/components/domain/Nav/ItemsNavVar.module.scss @@ -0,0 +1,126 @@ +@import '../../../styles/variables.scss'; + +.bone { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20rem 0 20rem; + position: sticky; + border-bottom: 1px solid #dfdfdf; + height: 7rem; + @media (max-width: 1023px) { + padding: 0 2.4rem; + } + @media (max-width: 743px) { + padding: 0 1.6rem; + } +} +.left-wrapper { + display: flex; + align-items: center; + justify-content: space-between; + width: 38.6rem; + @include apply-font($font-18, 700); + color: $secondaryGray-600; + @media (max-width: 1023px) { + width: 23.375rem; + } + @media (max-width: 743px) { + width: 22.4rem; + } +} +.header-logo { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; + @media (max-width: 743px) { + position: relative; + left: -8px; + } +} +.panda-logo-wrapper { + img { + width: 4rem; + height: 4rem; + display: flex; + } + + @media (max-width: 743px) { + img { + display: none; + } + } +} +.panda-text-wrapper { + img { + width: 10.3rem; + height: 3.5rem; + display: flex; + } + @media (max-width: 1023px) { + } + @media (max-width: 743px) { + img { + width: 8.1rem; + height: 2.3rem; + } + } +} +.nav-content { + display: flex; + align-items: center; + justify-content: space-around; + width: 20rem; + @media (max-width: 743px) { + width: 13.5rem; + justify-content: space-between; + } +} +interface LinkProps { + $isActive: boolean; +} + +.market-link { + display: flex; + align-items: center; + justify-content: space-around; + padding: 21px 15px; + font-size: 18px; + font-weight: 700; + color: $secondaryGray-600; + + &.active { + color: $primaryBlue-100; + } + + &:hover { + color: $secondaryGray-600; + } + + @media (max-width: 743px) { + font-size: 16px; + font-weight: 700; + padding: 21px 0px; + } +} +.free-board-link { + display: flex; + align-items: center; + justify-content: space-around; + padding: 21px 15px; + @include apply-font($font-18, 700); + + &.active { + color: $primaryBlue-100; + } + + &:hover { + color: $secondaryGray-600; + } + + @media (max-width: 743px) { + @include apply-font($font-16, 700); + padding: 21px 0px; + } +} diff --git a/src/components/domain/Nav/ItemsNavVar.tsx b/src/components/domain/Nav/ItemsNavVar.tsx index e447af8e..61aefa02 100644 --- a/src/components/domain/Nav/ItemsNavVar.tsx +++ b/src/components/domain/Nav/ItemsNavVar.tsx @@ -8,9 +8,8 @@ import LogoFace from '../../../../public/assets/image/logo_face.png' import Logo from '../../../../public/assets/image/logo_text.png' import ProfileIcon from '../../../../public/assets/svg/profile_icon.svg' -import styled from 'styled-components' -import { theme } from '../../../styles/theme' -import { textStyle } from '../../../styles/textStyle' +import styles from './ItemsNavVar.module.scss' +import clsx from 'clsx' interface ItemsNavVarProps { isItemsPage: boolean @@ -20,150 +19,45 @@ interface ItemsNavVarProps { const ItemsNavVar = ({ isItemsPage, isBoardsPage }: ItemsNavVarProps) => { return ( <> - - - - - +
+
+
+ +
판다마켓 로고 사진 - +
- - + +
판다마켓 로고 사진 - +
- - - - 자유게시판 +
+
+ +
+ 자유게시판 +
- - 중고마켓 + +
+ 중고마켓 +
- - +
+
프로필 아이콘 - +
) } export default ItemsNavVar - -const Bone = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 20rem 0 20rem; - position: sticky; - border-bottom: 1px solid #dfdfdf; - height: 7rem; - @media (max-width: 1023px) { - padding: 0 2.4rem; - } - @media (max-width: 743px) { - padding: 0 1.6rem; - } -` -const LeftWrapper = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - width: 38.6rem; - ${(props) => textStyle(18, 700)(props)} - color: ${theme.colors.SecondaryGray[600]}; - @media (max-width: 1023px) { - width: 23.375rem; - } - @media (max-width: 743px) { - width: 22.4rem; - } -` -const HeaderLogo = styled.div` - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.8rem; - @media (max-width: 743px) { - position: relative; - left: -8px; - } -` -const PandaLogoWrapper = styled.div` - img { - width: 4rem; - height: 4rem; - display: flex; - } - - @media (max-width: 743px) { - img { - display: none; - } - } -` -const PandaTextWrapper = styled.div` - img { - width: 10.3rem; - height: 3.5rem; - display: flex; - } - @media (max-width: 1023px) { - } - @media (max-width: 743px) { - img { - width: 8.1rem; - height: 2.3rem; - } - } -` -const NavContent = styled.div` - display: flex; - align-items: center; - justify-content: space-around; - width: 20rem; - @media (max-width: 743px) { - width: 13.5rem; - justify-content: space-between; - } -` -interface LinkProps { - $isActive: boolean -} -const MarketLink = styled.div` - display: flex; - align-items: center; - justify-content: space-around; - padding: 21px 15px; - ${(props) => textStyle(18, 700)(props)} - &:hover { - color: ${({ theme }) => theme.colors.SecondaryGray[600]}; - } - color: ${({ $isActive, theme }) => - $isActive - ? theme.colors.PrimaryBlue[100] - : theme.colors.SecondaryGray[600]}; - @media (max-width: 743px) { - ${(props) => textStyle(16, 700)(props)} - padding: 21px 0px; - } -` -const FreeBordLink = styled.div` - display: flex; - align-items: center; - justify-content: space-around; - padding: 21px 15px; - ${(props) => textStyle(18, 700)(props)} - &:hover { - color: ${({ theme }) => theme.colors.SecondaryGray[600]}; - } - color: ${({ $isActive, theme }) => - $isActive - ? theme.colors.PrimaryBlue[100] - : theme.colors.SecondaryGray[600]}; - @media (max-width: 743px) { - ${(props) => textStyle(16, 700)(props)} - padding: 21px 0px; - } -` diff --git a/src/hooks/useItemsList.ts b/src/hooks/useItemsList.ts new file mode 100644 index 00000000..2655d61a --- /dev/null +++ b/src/hooks/useItemsList.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query' + +import productService from '@/lib/api/service/productService' + +interface UseItemsListOptions { + page: number + itemsDisplay: number + orderBy?: string + keyword?: string + enabled?: boolean +} + +export const useItemsList = ({ + page, + itemsDisplay, + orderBy = 'recent', + keyword, + enabled = true, +}: UseItemsListOptions) => { + const productListAll = useQuery({ + queryKey: ['productListAll', page, itemsDisplay, orderBy, keyword], + queryFn: () => + productService + .getProduct(page, itemsDisplay, orderBy, keyword) + .then((res) => res?.data ?? { totalCount: 0, list: [] }), + placeholderData: (prev) => prev, + enabled, + }) + + return { + productListAll, + } +} diff --git a/src/hooks/useSigninForm.ts b/src/hooks/useSigninForm.ts new file mode 100644 index 00000000..d83e712f --- /dev/null +++ b/src/hooks/useSigninForm.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' + +export const baseSigninSchema = z.object({ + email: z + .string() + .nonempty('이메일을 입력해주세요.') + .email('잘못된 이메일 형식입니다.'), + password: z + .string() + .nonempty('비밀번호를 입력해주세요.') + .min(8, '비밀번호는 8자 이상이어야 합니다.') + .regex( + /^(?=.*[A-Za-z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,}$/, + '비밀번호는 영문, 숫자, 특수문자를 포함해야 합니다.' + ), +}) + +// 타입 자동 추론 +export type SigninForm = z.infer + +// 개별 필드만 부분검증할 때는 baseSchema에서 pick을 사용 가능 +export const emailSchema = baseSigninSchema.pick({ email: true }) +export const passwordSchema = baseSigninSchema.pick({ password: true }) diff --git a/src/hooks/useSigninMutation.ts b/src/hooks/useSigninMutation.ts new file mode 100644 index 00000000..1abeaad1 --- /dev/null +++ b/src/hooks/useSigninMutation.ts @@ -0,0 +1,30 @@ +import { useMutation } from '@tanstack/react-query' +import authService from '@/lib/api/service/authService' +import { useAuthStore } from '@/lib/stores/useAuthStore' +import { PostAuthSignInRequest } from '@/types/auth' + +export const useSigninMutation = () => { + // useAuthStore에서 setAccessToken 함수를 가져옵니다. + const setAccessToken = useAuthStore((state) => state.setAccessToken) + // useMutation 훅을 사용하여 로그인 요청을 처리합니다. + return useMutation({ + // 로그인 API를 호출하는 함수입니다. + mutationFn: (form: PostAuthSignInRequest) => + authService.postAuthSignIn(form), + + // 로그인 요청이 성공했을 때 실행되는 콜백 함수입니다. + onSuccess: (res) => { + const { accessToken } = res.data + if (accessToken) { + localStorage.setItem('accessToken', accessToken) + setAccessToken(accessToken) + } + }, + + // 로그인 요청이 실패했을 때 실행되는 콜백 함수입니다. + onError: (error) => { + console.error('로그인 실패:', error) + alert('로그인에 실패했습니다. 다시 시도해주세요.') + }, + }) +} diff --git a/src/hooks/useSignupForm.ts b/src/hooks/useSignupForm.ts new file mode 100644 index 00000000..5a6a7857 --- /dev/null +++ b/src/hooks/useSignupForm.ts @@ -0,0 +1,48 @@ +import { z } from 'zod' + +export const baseSignupSchema = z.object({ + email: z + .string() + .nonempty('이메일을 입력해주세요.') + .email('잘못된 이메일 형식입니다.'), + password: z + .string() + .nonempty('비밀번호를 입력해주세요.') + .min(8, '비밀번호는 8자 이상이어야 합니다.') + .regex( + /^(?=.*[A-Za-z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,}$/, + '비밀번호는 영문, 숫자, 특수문자를 포함해야 합니다.' + ), + passwordConfirmation: z + .string() + .nonempty('비밀번호 확인을 입력해주세요.') + .min(8, '비밀번호는 8자 이상이어야 합니다.') + .regex( + /^(?=.*[A-Za-z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,}$/, + '비밀번호는 영문, 숫자, 특수문자를 포함해야 합니다.' + ), + nickname: z + .string() + .nonempty('닉네임을 입력해주세요.') + .min(2, '닉네임은 2자 이상이어야 합니다.'), +}) + +// 최종 전체 유효성 검사 스키마 (비밀번호 일치 검사 포함) +export const signupSchema = baseSignupSchema.refine( + (data) => data.password === data.passwordConfirmation, + { + message: '비밀번호가 일치하지 않습니다.', + path: ['passwordConfirm'], + } +) + +// 타입 자동 추론 +export type SignupForm = z.infer + +// 개별 필드만 부분검증할 때는 baseSchema에서 pick을 사용 가능 +export const emailSchema = baseSignupSchema.pick({ email: true }) +export const passwordSchema = baseSignupSchema.pick({ password: true }) +export const passwordConfirmSchema = baseSignupSchema.pick({ + passwordConfirmation: true, +}) +export const nicknameSchema = baseSignupSchema.pick({ nickname: true }) diff --git a/src/hooks/useSignupMutation.ts b/src/hooks/useSignupMutation.ts new file mode 100644 index 00000000..8ef1a3bc --- /dev/null +++ b/src/hooks/useSignupMutation.ts @@ -0,0 +1,21 @@ +import { useMutation } from '@tanstack/react-query' +import authService from '@/lib/api/service/authService' + +import { PostAuthSignupRequest } from '@/types/auth' + +export const useSignupMutation = () => { + // useMutation 훅을 사용하여 회원가입 요청을 처리 + return useMutation({ + mutationFn: (form: PostAuthSignupRequest) => + authService.postAuthSignup(form), + + // 회원가입 요청이 실패했을 때 실행되는 콜백 함수 + onError: (error: any) => { + console.error('회원가입 실패:', error) + if (error.response) { + console.error('서버 응답:', error.response.data) + } + alert('회원가입에 실패했습니다. 다시 시도해주세요.') + }, + }) +} diff --git a/src/lib/api/client/interceptors.tsx b/src/lib/api/client/interceptors.tsx index a01d4bc7..0a357601 100644 --- a/src/lib/api/client/interceptors.tsx +++ b/src/lib/api/client/interceptors.tsx @@ -1,12 +1,12 @@ -import { AxiosRequestConfig } from 'axios' +import { InternalAxiosRequestConfig } from 'axios' import { useAuthStore } from '../../stores/useAuthStore' export const requestInterceptor = ( - config: AxiosRequestConfig -): AxiosRequestConfig => { - // Zustand에서 토큰 직접 가져오기 + config: InternalAxiosRequestConfig +): InternalAxiosRequestConfig => { + // 토큰 가져오기 const token = useAuthStore.getState().accessToken - + // const isPublicEndpoint = config.url?.startsWith('/products') && !config.url?.includes('/favorite') diff --git a/src/lib/api/client/requestor.tsx b/src/lib/api/client/requestor.tsx index 1d63f2de..a9845602 100644 --- a/src/lib/api/client/requestor.tsx +++ b/src/lib/api/client/requestor.tsx @@ -1,7 +1,16 @@ +// libs/requestor.ts import axios from 'axios' +import { useAuthStore } from '../../stores/useAuthStore' -const requestor = axios.create({ - withCredentials: true, +const requestor = axios.create() + +requestor.interceptors.request.use((config) => { + const token = useAuthStore.getState().accessToken + if (token) { + config.headers = config.headers || {} + config.headers['Authorization'] = `Bearer ${token}` + } + return config }) export default requestor diff --git a/src/lib/api/service/authService.jsx b/src/lib/api/service/authService.jsx deleted file mode 100644 index 6cf726b4..00000000 --- a/src/lib/api/service/authService.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import requestor from '../client/requestor' - -class AuthService { - // 틀릴 수도 - postAuthSignUp(body) { - const requestBody = { - ...body, - } - return requestor.post(`/auth/signUp`, requestBody) - } - - postAuthSignIn(body) { - const requestBody = { - ...body, - } - return requestor.post(`/auth/signIn`, requestBody) - } - - postAuthRefresh(body) { - const requestBody = { - ...body, - } - return requestor.post(`/auth/refresh-token`, requestBody) - } -} - -const authService = new AuthService() - -export default authService diff --git a/src/lib/api/service/authService.tsx b/src/lib/api/service/authService.tsx new file mode 100644 index 00000000..4fa67ca0 --- /dev/null +++ b/src/lib/api/service/authService.tsx @@ -0,0 +1,65 @@ +import requestor from '../client/requestor' +import { AxiosResponse } from 'axios' + +import { + PostAuthSignupRequest, + PostAuthSignupResponse, + PostAuthSignInRequest, + PostAuthSignInResponse, + PostAuthRefreshRequest, + PostAuthRefreshResponse, +} from '@/types/auth' + +class AuthService { + postAuthSignup( + body: PostAuthSignupRequest + ): Promise> { + return requestor.post( + `/api/proxy/auth/signUp`, + body + ) + } + + postAuthSignIn( + body: PostAuthSignInRequest + ): Promise> { + return requestor.post( + `/api/proxy/auth/signIn`, + body + ) + } + postAuthRefresh( + body: PostAuthRefreshRequest + ): Promise> { + return requestor.post( + `/api/proxy/auth/refresh-token`, + body + ) + } +} + +const authService = new AuthService() + +export default authService + +// 초기 코드 +// postAuthSignUp(body: PostAuthSignupRequest) { +// const requestBody = { +// ...body, +// } +// return requestor.post(`/auth/signUp`, requestBody) +// } + +// postAuthSignIn(body: PostAuthSignInRequest) { +// const requestBody = { +// ...body, +// } +// return requestor.post(`/auth/signIn`, requestBody) +// } + +// postAuthRefresh(body: PostAuthRefreshRequest) { +// const requestBody = { +// ...body, +// } +// return requestor.post(`/auth/refresh-token`, requestBody) +// } diff --git a/src/lib/stores/useAuthStore.ts b/src/lib/stores/useAuthStore.ts index 4a7701f3..8a2bc975 100644 --- a/src/lib/stores/useAuthStore.ts +++ b/src/lib/stores/useAuthStore.ts @@ -3,6 +3,7 @@ import { create } from 'zustand' interface AuthState { accessToken: string | null setAccessToken: (token: string) => void + clearAccessToken: () => void } export const useAuthStore = create((set) => ({ @@ -13,4 +14,5 @@ export const useAuthStore = create((set) => ({ localStorage.setItem('token', token) } }, + clearAccessToken: () => set({ accessToken: null }), })) diff --git a/src/styles/variables.scss b/src/styles/variables.scss index 2609a9e2..a6e3bcac 100644 --- a/src/styles/variables.scss +++ b/src/styles/variables.scss @@ -1,5 +1,5 @@ // colors -$primaryBlue-100: #3498db; +$primaryBlue-100: #3692ff; $primaryBlue-200: #1967d6; $primaryBlue-300: #1251aa; diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 00000000..0acd617f --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,45 @@ +export interface PostAuthSignupRequest { + email: string + nickname: string + password: string + passwordConfirmation: string +} + +export interface PostAuthSignupResponse { + accessToken: string + refreshToken: string + user: { + id: number + email: string + image: null | string + nickname: string + createdAt: string + updatedAt: string + } +} + +export interface PostAuthSignInRequest { + email: string + password: string +} + +export interface PostAuthSignInResponse { + accessToken: string + refreshToken: string + user: { + id: number + email: string + image: null | string + nickname: string + createdAt: string + updatedAt: string + } +} + +export interface PostAuthRefreshRequest { + refreshToken: string +} + +export interface PostAuthRefreshResponse { + accessToken: string +} diff --git a/tsconfig.json b/tsconfig.json index 7355788f..8293bd8d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2017", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -17,19 +13,14 @@ "isolatedModules": true, "jsx": "preserve", "incremental": true, - "typeRoots": [ - "./node_modules/@types", - "src/types" - ], + "typeRoots": ["./node_modules/@types", "src/types"], "plugins": [ { "name": "next" } ], "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } }, "include": [ @@ -41,13 +32,11 @@ ".next/types/**/*.ts", "src/styles", "src/utils", - "src/lib/api/service/authService.jsx", + "src/lib/api/service/authService.tsx", "src/lib/api/service/imageService.jsx", "src/lib/api/service/userService.jsx", "src/components/domain/LoginAndSignup", "babel.config.js" ], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] }