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한 중고거래 물품을 판다 마켓에서 확인해 보세요
-
-
-
-
-
-
-
-
-
- 구매를 원하는 상품을 검색하세요
-
-
-
-
- 구매하고 싶은 물품은 검색해서 쉽게 찾아보세요
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 판매를 원하는 상품을 등록하세요
-
-
-
-
- 어떤 물건이든 판매하고 싶은 상품을 쉽게 등록하세요
-
-
-
-
-
-
-
-
-
-
-
- 믿을 수 있는 판다마켓 중고 거래
-
-
-
-
-
-
-
-
-
-
©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')}원
-
-
-
- {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')}원
+
+
+
+ {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')}원
+
+
+
+ {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')}원
-
-
-
- {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한 중고거래 물품을 판다 마켓에서 확인해 보세요
-
+
-
-
-
-
+
+
+
+
-
+
구매를 원하는 상품을 검색하세요
-
+
-
+
구매하고 싶은 물품은 검색해서 쉽게 찾아보세요
-
+
-
-
+
+
-
-
-
-
+
+
+
+
-
-
+
+
-
+
판매를 원하는 상품을 등록하세요
-
+
-
+
어떤 물건이든 판매하고 싶은 상품을 쉽게 등록하세요
-
+
+
+
+
+
+
>
)
}
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"]
}