diff --git a/next.config.js b/next.config.js index 6fb7ac30..eba6c8ab 100644 --- a/next.config.js +++ b/next.config.js @@ -1,9 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + // output: "export", reactStrictMode: true, - experimental: { - appDir: true, // ✅ App Router 활성화 (Next 13 이상) - }, images: { remotePatterns: [ { diff --git a/package-lock.json b/package-lock.json index a7b7efcf..4fd13466 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "framer-motion": "^12.7.4", "next": "^15.3.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-hook-form": "^7.56.3" }, "devDependencies": { "@types/node": "^20.17.30", @@ -4334,6 +4335,22 @@ "react": "^18.2.0" } }, + "node_modules/react-hook-form": { + "version": "7.56.3", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.3.tgz", + "integrity": "sha512-IK18V6GVbab4TAo1/cz3kqajxbDPGofdF0w7VHdCo0Nt8PrPlOZcuuDq9YYIV1BtjcX78x0XsldbQRQnQXWXmw==", + "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", diff --git a/package.json b/package.json index d53c60e7..6a944f84 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "framer-motion": "^12.7.4", "next": "^15.3.1", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-hook-form": "^7.56.3" }, "devDependencies": { "@types/node": "^20.17.30", diff --git a/public/assets/ic_medal.svg b/public/assets/ic_medal.svg new file mode 100644 index 00000000..d650c401 --- /dev/null +++ b/public/assets/ic_medal.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/assets/reply_empty.svg b/public/assets/reply_empty.svg new file mode 100644 index 00000000..dbac5014 --- /dev/null +++ b/public/assets/reply_empty.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/app/addboard/page.tsx b/src/app/addboard/page.tsx new file mode 100644 index 00000000..c9a9ca1b --- /dev/null +++ b/src/app/addboard/page.tsx @@ -0,0 +1,98 @@ +'use client'; +import Container from '@/components/layout/Container'; +import Button from '@/components/ui/Button'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import FormField from '@/components/ui/form/FormField'; +import ImageFileBox from '@/components/ui/form/ImageFileBox'; +import { TextAreaField } from '@/components/ui/form/InputBox'; +import Title from '@/components/ui/Title'; +import { ArticleCreateRequest, usePostArticles } from '@/hooks/useArticles'; +import { useConfirmModal } from '@/hooks/useModal'; +import { validationRules } from '@/utils/validate'; +import { useRouter } from 'next/navigation'; +import React, { useState } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; + + +const INITIAL_Article: ArticleCreateRequest = { + image: "", + content: "", + title: "" +} +type FormValues = { + title: string; + content: string; + image: string; +}; +function PostArticles() { + const router = useRouter(); + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + + const { + register, + handleSubmit, + getValues, + formState: { errors, isValid, isDirty }, + } = useForm({ + mode: 'onBlur', + }); + + const [addArticles, setAddArticles] = useState(INITIAL_Article); + + const { mutate: postArticles} = usePostArticles(openConfirmModal,router); + + const handleFieldBlur = () => { + const values = getValues(); // 모든 필드 값 가져오기 + setAddArticles((prev) => { + const updated = { ...prev, ...values }; + return updated; + }); + }; + + const onSubmit: SubmitHandler = (data) => { + setAddArticles((prev) => ({ + ...prev, + ...addArticles, // form에서 온 title, content 등 + })); + postArticles(addArticles); + }; + + function handleInputBlur(e: React.FocusEvent){ + const value = e.target.value; + setAddArticles((prev) => ({ + ...prev, + [e.target.id]: value + })); + } + + return ( + + + +
+ + + + setForm={setAddArticles} /> + + +
+ ); +} + +export default PostArticles; \ No newline at end of file diff --git a/src/app/boards/[id]/page.tsx b/src/app/boards/[id]/page.tsx new file mode 100644 index 00000000..dfce5707 --- /dev/null +++ b/src/app/boards/[id]/page.tsx @@ -0,0 +1,65 @@ +'use client'; + +import CommentSection from '@/components/Article/comment/CommentSection'; +import Container from '@/components/layout/Container'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import LikeButton from '@/components/ui/LikeButton'; +import UserInfo from '@/components/ui/UserInfo'; +import { useArticleDetails, useToggleArticlesFavorite } from '@/hooks/useArticles'; +import { useConfirmModal } from '@/hooks/useModal'; +import { formatDate } from '@/utils/date'; +import { useParams } from 'next/navigation'; +import React from 'react'; + +function PostDetail() { + + const { id } = useParams(); // URL에서 [id] 추출 + const articleId = Number(id); + + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + const { mutate: toggleFavorite } = useToggleArticlesFavorite(openConfirmModal, { + onSuccess: (data) => { + openConfirmModal(data.isFavorited ? "관심상품 등록되었습니다" : "관심상품 취소되었습니다"); + }, + }); + const { data } = useArticleDetails(articleId); + if ( data === undefined) return; + + const createdAtString = formatDate( data.createdAt ); + + return ( +
+
+ + { data && ( + <> +
+ {data.title} +
+ + + +
+
+
+ {data.content} +
+ + )} +
+ + + + +
+
+ ); +} + +export default PostDetail; \ No newline at end of file diff --git a/src/app/boards/page.tsx b/src/app/boards/page.tsx index 69b3cc18..563f66cc 100644 --- a/src/app/boards/page.tsx +++ b/src/app/boards/page.tsx @@ -1,14 +1,5 @@ +import BoardsClient from "@/components/Article/BoardsClient"; - - -import React from 'react'; - -function Boards() { - return ( - <> -

Boards

- - ); -} - -export default Boards; \ No newline at end of file +export default function BoardsPage() { + return ; +} \ No newline at end of file diff --git a/src/app/faq/page.tsx b/src/app/faq/page.tsx index ae9957dd..e3c1f554 100644 --- a/src/app/faq/page.tsx +++ b/src/app/faq/page.tsx @@ -1,3 +1,4 @@ +'use client'; import React from 'react'; function Faq() { diff --git a/src/app/items/[id]/ItemsDetail.module.css b/src/app/items/[id]/ItemsDetail.module.css deleted file mode 100644 index c75d5e57..00000000 --- a/src/app/items/[id]/ItemsDetail.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.items_detail { - margin-bottom: 150px; -} diff --git a/src/app/items/[id]/page.tsx b/src/app/items/[id]/page.tsx index 237bb5e4..cbc4ff8c 100644 --- a/src/app/items/[id]/page.tsx +++ b/src/app/items/[id]/page.tsx @@ -1,16 +1,33 @@ 'use client'; import React from 'react'; -import styles from './ItemsDetail.module.css'; -import ProductDetails from '@/components/ItemsDetail/ProductDetails'; +import { useParams } from 'next/navigation'; +import ProductDescription from '@/components/productDetail/ProductDescription'; +import ProductOverview from '@/components/productDetail/ProductOverview'; +import Container from '@/components/layout/Container'; +import CommentSection from '@/components/productDetail/comment/CommentSection'; +import { useProductsDetails } from '@/hooks/useItems'; function ItemsDetail() { + + const { id } = useParams(); // URL에서 [id] 추출 + const productId = Number(id); + + const { data } = useProductsDetails(productId); return ( - <> -
- -
- +
+ + { data && ( + <> + + + + )} + + + + +
); } diff --git a/src/app/items/apply/page.tsx b/src/app/items/apply/page.tsx index 04609a53..6f5310ca 100644 --- a/src/app/items/apply/page.tsx +++ b/src/app/items/apply/page.tsx @@ -1,6 +1,5 @@ 'use client'; - -import React, { useEffect } from 'react'; +import React from 'react'; import { useState } from 'react'; import styles from './Additem.module.css'; import Container from 'components/layout/Container'; @@ -8,7 +7,7 @@ import Button from 'components/ui/Button'; import Title from 'components/ui/Title'; import { InputField, TextAreaField } from '@/components/ui/form/InputBox'; import TagBox from '@/components/ui/TagBox'; -import { CreateProductRequest, ProductSummary, usePostProduct } from '@/hooks/useItems'; +import { CreateProductRequest, usePostProduct } from '@/hooks/useItems'; import ImageFileBox from '@/components/ui/form/ImageFileBox'; import { useConfirmModal } from '@/hooks/useModal'; import ConfirmModal from '@/components/ui/ConfirmModal'; @@ -53,7 +52,7 @@ function Additem() {
- + setForm={setAddProduct} /> diff --git a/src/app/items/page.tsx b/src/app/items/page.tsx index 8a49f4f1..1ef03243 100644 --- a/src/app/items/page.tsx +++ b/src/app/items/page.tsx @@ -1,23 +1,5 @@ -'use client'; +import ProductClient from "@/components/Product/ProductClient"; -import React from 'react'; -import Container from 'components/layout/Container'; -import Title from 'components/ui/Title'; -import { BestItems } from '@/components/Product/BestItems'; -import { AllItems } from '@/components/Product/AllItems'; - - -function ItemsBox() { - - return ( - <> - - - </Container> - <BestItems /> - <AllItems /> - </> - ); -} - -export default ItemsBox; \ No newline at end of file +export default function BoardsPage() { + return <ProductClient />; +} \ No newline at end of file diff --git a/src/app/login/Login.module.css b/src/app/login/Login.module.css index ee77d19c..c641e10e 100644 --- a/src/app/login/Login.module.css +++ b/src/app/login/Login.module.css @@ -13,66 +13,6 @@ .login_box { text-align: left; } -.login_box label { - position: relative; - display: block; - font-weight: 700; - font-size: 18px; - margin-bottom: 24px; -} -.login_box input[type="email"], -.login_box input[type="password"], -.login_box input[type="text"] { - display: block; - width: 100%; - margin-top: 16px; - padding: 15px 24px; - background-color: var(--Cool_Gray_100); - border-radius: 12px; - border: 0; -} - -/* error */ -.error_box input[type="email"], -.error_box input[type="password"], -.error_box input[type="text"] { - outline: 1px solid #f74747; -} - -.error { - display: block; - color: #f74747; - font-size: 14px; - font-weight: 500; - line-height: 24px; - margin-left: 16px; - margin-top: 8px; -} - -.login_box .eye { - position: absolute; - right: 18px; - top: 48px; - cursor: pointer; -} - -.login_box #submit { - display: block; - width: 100%; - border-radius: 9999px; - outline: 0; - padding: 12px 0; - color: var(--Cool_Gray_100); - font-size: 20px; - font-weight: 600; - line-height: 32px; - background-color: var(--Primary); - border: 0; - cursor: pointer; -} -.login_box #submit:disabled { - background-color: #9ca3af; -} .login_wrap .member_sub_box span { font-size: 14px; @@ -81,24 +21,6 @@ color: var(--Primary); text-decoration: underline; } -.submit { - display: block; - width: 100%; - border-radius: 9999px; - outline: 0; - padding: 12px 0; - color: var(--Cool_Gray_100); - font-size: 20px; - font-weight: 600; - line-height: 32px; - background-color: var(--Primary); - border: 0; - cursor: pointer; -} -.submit:disabled { - background-color: #9ca3af; -} - /* Mobile */ @media (max-width: 767px) { .login_body { diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index a4a427f3..313d5e23 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,82 +1,74 @@ 'use client'; import React from 'react'; -import { useState ,useEffect, useMemo } from 'react'; +import { useState } from 'react'; import Link from 'next/link'; import styles from './Login.module.css'; -import { memberCheck } from 'utils/auth'; import Button from 'components/ui/Button'; import MembersLogo from '@/components/members/MembersLogo'; import SnsLogin from '@/components/members/SnsLogin'; import { useLoginMutation } from '@/hooks/useAuth'; -import { useConfirmModal, useModal } from '@/hooks/useModal'; +import { useConfirmModal } from '@/hooks/useModal'; import ConfirmModal from '@/components/ui/ConfirmModal'; import FormField from '@/components/ui/form/FormField'; +import { useForm, SubmitHandler } from 'react-hook-form'; +import { validationRules } from '@/utils/validate'; + +type FormValues = { + email: string; + password: string; +}; function Login() { - const [email, setEmail] = useState('user@mail.com'); - const [password, setPassword] = useState('12345678'); - const [passwordBoxType, setPasswordBoxType] = useState(true); - const [errorCase, setErrorCase] = useState({ email:'', password:'' }); + const [passwordBoxType, setPasswordBoxType] = useState(false); const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); const { mutate: login, isPending } = useLoginMutation(openConfirmModal); + + const { + register, + handleSubmit, + formState: { errors, isValid, isDirty }, + } = useForm<FormValues>({ + mode: 'onBlur', // blur 시 유효성 검사 + }); - const handleLogin = () => { - login({ email, password }); + const onSubmit: SubmitHandler<FormValues> = (data) => { + login(data); }; - // input이 Blur될때 email,password state 변경 및 UserChecked state 표시 - const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => { - if(e.target.id === 'login_email') { - setEmail(e.target.value); - } else if(e.target.id === 'login_pwd') { - setPassword(e.target.value); - } - } - - const idCheck = useMemo(() => memberCheck.EmailChecked(email), [email]); - const passwordCheck = useMemo(() => memberCheck.passwordChecked(password), [password]); - - useEffect(() => { - setErrorCase({ - email: idCheck, - password: passwordCheck, - }); - }, [idCheck, passwordCheck]); - const handleEyeClick = () => setPasswordBoxType(!passwordBoxType); - const isFormValid = email && password && errorCase.email === '' && errorCase.password === ''; - return ( <div className={styles.login_body}> <div className={styles.login_wrap}> <MembersLogo /> <div className={styles.login_box}> + <form onSubmit={handleSubmit(onSubmit)}> <FormField id="login_email" label="이메일" type="email" placeholder="이메일을 입력해주세요" - error={errorCase.email} - onBlur={handleInputBlur} + error={errors.email?.message} + {...register('email', validationRules.email)} /> <FormField id="login_pwd" label="비밀번호" - type={passwordBoxType ? "password" : "text"} + type={passwordBoxType ? "text" : "password" } placeholder="비밀번호를 입력해주세요" - error={errorCase.password} - onBlur={handleInputBlur} + error={errors.password?.message} withEyeToggle - eyeState={passwordBoxType} + eyeState={passwordBoxType} // eye 토글 상태 관리 필요시 별도 상태 선언 onEyeToggle={handleEyeClick} + {...register('password', validationRules.password)} /> - <Button variant='roundedXL' className={styles.submit} onClick={handleLogin} disabled={!isFormValid}>로그인</Button> + <Button type="submit" variant='roundedXL' className="w-full" disabled={!isValid || !isDirty}>로그인</Button> + </form> </div> <SnsLogin /> <div className={styles.member_sub_box}> diff --git a/src/app/page.tsx b/src/app/page.tsx index e5ef4c96..5deaf90b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -15,7 +15,7 @@ function HomePage() { absolute bottom-0 right-0 w-[745px] tablet:right-[50%] tablet:translate-x-[50%] tablet:w-[100%] mobile:w-[110%] "> - <Image src={imgHome_top} width={745} height={345} className="w-full h-auto" alt="인트로 이미지" /> + <Image src={imgHome_top} width={745} height={345} unoptimized className="w-full h-auto" alt="인트로 이미지" /> </div> <div className="absolute left-0 top-[240px] tablet:left-[50%] tablet:top-[84px] tablet:-translate-x-[50%] tablet:text-center mobile:top-[68px]"> <h1 className="font-bold text-4xl mobile:text-3xl"> @@ -26,7 +26,7 @@ function HomePage() { </VisualSelection> <MotionSelection> <div className="relative w-[579px] tablet:w-[100%] "> - <Image src={imgHome1} width={579} height={444} className="w-full h-auto" alt='인기상품' /> + <Image src={imgHome1} width={579} height={444} unoptimized className="w-full h-auto" alt='인기상품' /> </div> <div className="tablet:w-[100%]"> <span className='font-bold text-primary-100 mb-3 text-lg mobile:text-base'>Hot item</span> @@ -36,7 +36,7 @@ function HomePage() { </MotionSelection> <MotionSelection className="flex-row-reverse"> <div className="relative w-[579px] tablet:w-[100%] "> - <Image src={imgHome2} width={579} height={444} className="w-full h-auto" alt='상품검색' /> + <Image src={imgHome2} width={579} height={444} unoptimized className="w-full h-auto" alt='상품검색' /> </div> <div className="tablet:w-[100%] text-end"> <span className='font-bold text-primary-100 mb-3 text-lg mobile:text-base'>Search</span> @@ -46,7 +46,7 @@ function HomePage() { </MotionSelection> <MotionSelection> <div className="relative w-[579px] tablet:w-[100%] "> - <Image src={imgHome3} width={579} height={444} className="w-full h-auto" alt='상품등록' /> + <Image src={imgHome3} width={579} height={444} unoptimized className="w-full h-auto" alt='상품등록' /> </div> <div className="tablet:w-[100%]"> <span className='font-bold text-primary-100 mb-3 text-lg mobile:text-base'>Register</span> @@ -58,7 +58,7 @@ function HomePage() { <div className=" absolute bottom-0 right-0 w-[745px] tablet:right-[50%] tablet:translate-x-[50%] tablet:w-[100%] "> - <Image src={imgHome_bottom} width={746} height={397} className="w-full h-auto" alt="아웃트로 이미지" /> + <Image src={imgHome_bottom} width={746} height={397} unoptimized className="w-full h-auto" alt="아웃트로 이미지" /> </div> <div className=" absolute left-0 top-[240px] diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 8560e56b..c70af1d7 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -2,132 +2,107 @@ 'use client'; import React from 'react'; import Link from 'next/link'; -import { useState ,useEffect,useMemo } from 'react'; +import { useState } from 'react'; import styles from '../login/Login.module.css'; -import { memberCheck } from 'utils/auth'; import Button from 'components/ui/Button'; import MembersLogo from '@/components/members/MembersLogo'; import SnsLogin from '@/components/members/SnsLogin'; import FormField from '@/components/ui/form/FormField'; import { useSignUp } from '@/hooks/useAuth'; -import { useConfirmModal, useModal } from '@/hooks/useModal'; +import { useConfirmModal } from '@/hooks/useModal'; import ConfirmModal from '@/components/ui/ConfirmModal'; +import { useForm, SubmitHandler } from 'react-hook-form'; +import { validationRules } from '@/utils/validate'; + +type FormValues = { + email: string; + nickname: string; + password: string; + passwordConfirmation: string; +}; function Login() { - const [email, setEmail] = useState(''); - const [nickname, setNickname] = useState(''); - const [password, setPassword] = useState(''); - const [pwdCheck, setPwdCheck] = useState(''); - const [passwordBoxType, setPasswordBoxType] = useState(true); - const [pwdCheckBoxType, setPwdCheckBoxType] = useState(true); - const [errorCase, setErrorCase] = useState({ email:'', name:'', password:'',pwdCheck:'' }); + const [passwordBoxType, setPasswordBoxType] = useState(false); + const [pwdCheckBoxType, setPwdCheckBoxType] = useState(false); const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); const { mutate: signUp, isPending } = useSignUp(openConfirmModal); - -const handleSignUp = () => { - signUp({ - email, - nickname, - password, - passwordConfirmation: pwdCheck, - }); -}; - const setters: Record<string, React.Dispatch<React.SetStateAction<string>>> = { - login_email: setEmail, - login_name: setNickname, - login_pwd: setPassword, - login_pwd_check: setPwdCheck, - }; - // input이 Blur될때 email,password state 변경 및 UserChecked state 표시 - const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => { - const { id, value } = e.target; - - const setter = setters[id]; - if (setter) setter(value); - - let error = ''; - if (id === 'login_email') error = memberCheck.EmailChecked(value); - else if (id === 'login_name') error = memberCheck.NameChecked(value); - else if (id === 'login_pwd') error = memberCheck.passwordChecked(value); - else if (id === 'login_pwd_check') error = memberCheck.passwordDoubleChecked(password, value); // password 상태 사용 + const { + register, + handleSubmit, + watch, + formState: { errors, isValid, isDirty }, + } = useForm<FormValues>({ + mode: 'onBlur', // blur 시 유효성 검사 + }); - setErrorCase(prev => ({ - ...prev, - [id.replace('login_', '')]: error, // email, name, password, pwdCheck에 매핑 - })); + const password = watch('password'); + + const onSubmit: SubmitHandler<FormValues> = (data) => { + signUp(data); }; - const handleEyeClick = () => setPasswordBoxType(!passwordBoxType); const handleEyePwdCheck = () => setPwdCheckBoxType(!pwdCheckBoxType); - const isFormValid = - email && - nickname && - password && - pwdCheck && - errorCase.email === '' && - errorCase.name === '' && - errorCase.password === '' && - errorCase.pwdCheck === ''; - return ( <div className={styles.login_body}> <div className={styles.login_wrap}> <MembersLogo /> <div className={styles.login_box}> - <FormField - id="login_email" - label="이메일" - type="email" - placeholder="이메일을 입력해주세요" - error={errorCase.email} - onBlur={handleInputBlur} - /> + + <form onSubmit={handleSubmit(onSubmit)}> + <FormField + id="login_email" + label="이메일" + type="email" + placeholder="이메일을 입력해주세요" + error={errors.email?.message} + {...register('email', validationRules.email)} + /> - <FormField - id="login_name" - label="닉네임" - type="text" - placeholder="닉네임을 입력해주세요" - error={errorCase.name} - onBlur={handleInputBlur} - /> + <FormField + id="login_name" + label="닉네임" + type="text" + placeholder="닉네임을 입력해주세요" + error={errors.nickname?.message} + {...register('nickname', validationRules.nickname)} + /> - <FormField - id="login_pwd" - label="비밀번호" - type={passwordBoxType ? "password" : "text"} - placeholder="비밀번호를 입력해주세요" - error={errorCase.password} - onBlur={handleInputBlur} - withEyeToggle - eyeState={passwordBoxType} - onEyeToggle={handleEyeClick} - /> - - <FormField - id="login_pwd_check" - label="비밀번호 확인" - type={pwdCheckBoxType ? "password" : "text"} - placeholder="비밀번호를 다시 입력해주세요" - error={errorCase.pwdCheck} - onBlur={handleInputBlur} - withEyeToggle - eyeState={pwdCheckBoxType} - onEyeToggle={handleEyePwdCheck} - /> - - <Button - variant='roundedXL' - className={styles.submit} - onClick={handleSignUp} - disabled={!isFormValid} - > - 회원가입 - </Button> + <FormField + id="login_pwd" + label="비밀번호" + type={passwordBoxType ? "text" : "password" } + placeholder="비밀번호를 입력해주세요" + error={errors.password?.message} + withEyeToggle + eyeState={passwordBoxType} // eye 토글 상태 관리 필요시 별도 상태 선언 + onEyeToggle={handleEyeClick} + {...register('password', validationRules.password)} + /> + <FormField + id="login_pwd_check" + label="비밀번호 확인" + type={pwdCheckBoxType ? "text" : "password" } + placeholder="비밀번호를 다시 입력해주세요" + error={errors.passwordConfirmation?.message} + withEyeToggle + eyeState={pwdCheckBoxType} // eye 토글 상태 관리 필요시 별도 상태 선언 + onEyeToggle={handleEyePwdCheck} + {...register('passwordConfirmation', validationRules.passwordConfirmation(password))} + /> + + <Button + type="submit" + variant='roundedXL' + className="w-full" + disabled={!isValid || !isDirty} + > + 회원가입 + </Button> + </form> </div> <SnsLogin /> <div className={styles.member_sub_box}> diff --git a/src/components/Article/ArticleList.tsx b/src/components/Article/ArticleList.tsx new file mode 100644 index 00000000..9311664d --- /dev/null +++ b/src/components/Article/ArticleList.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { orderByType, POST_OPTIONS, postByType } from "@/constants/product.constants"; +import { useAuth } from "@/contexts/AuthContext"; +import { useBreakpoint } from "@/hooks/useBreakpoint"; +import { usePushQueryToURL } from "@/hooks/useItemQuery"; +import { useConfirmModal } from "@/hooks/useModal"; +import { useRouter, useSearchParams } from "next/navigation"; +import Container from "../layout/Container"; +import Title from "../ui/Title"; +import SearchBox from "../ui/form/SearchBox"; +import Button from "../ui/Button"; +import SelectBox from "../ui/SelectBox"; +import LoadingBox from "../ui/LoadingBox"; +import EmptyBox from "../ui/EmptyBox"; +import ConfirmModal from "../ui/ConfirmModal"; +import ArticleListItem from "./ArticleListItem"; +import { PostListQuery, useInfiniteArticles } from "@/hooks/useArticles"; + + +export function ArticleList() { + + const { user } = useAuth(); + const router = useRouter(); + const searchParams = useSearchParams(); + const breakpoint = useBreakpoint(); + + + const query: PostListQuery = { + page: Number(searchParams.get("page") ?? 1), + pageSize: Number(searchParams.get("pageSize") ?? 10), + orderBy: (searchParams.get("orderBy") ?? 'recent')as postByType, + keyword: searchParams.get("keyword") ?? '', + }; + + const pushQueryToURL = usePushQueryToURL(); + const { data, handleLoadMore, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteArticles(query); + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + + // SelectBox handle + const handleSelectBoxClick = (value: string) => { + const next = { ...query, orderBy: value as orderByType, page: 1 }; + pushQueryToURL(next); + }; + + // Keyword handle + const handleKeywordChange = (keyword: string) => { + const next = { ...query, keyword,page: 1}; + pushQueryToURL(next); + }; + + const handleApplyClick = () => { + if(!user) { + openConfirmModal('로그인 후 이용 가능합니다.'); + return; + } + router.push('addboard'); + }; + return ( + <> + <Container className='relative z-20'> + <Title titleTag='h2' text='게시글'> + <Button + className="absolute right-0 top-0" + variant="roundedSS" + onClick={handleApplyClick} + > + 글쓰기 + </Button> + +
+ handleKeywordChange(keyword)} /> + +
+
+ + + {isLoading ? ( + + ) : ( + data ? ( +
+
    + {data?.pages.flatMap((page) => ( + page.list.map((article) => ( + + )) + ))} +
+ {isFetchingNextPage && } + {hasNextPage && ( +
+ +
+ )} +
+ ) : ( + + ) + )} +
+ + + ); +} + diff --git a/src/components/Article/ArticleListItem.tsx b/src/components/Article/ArticleListItem.tsx new file mode 100644 index 00000000..0421811d --- /dev/null +++ b/src/components/Article/ArticleListItem.tsx @@ -0,0 +1,62 @@ +import React, { useEffect } from 'react'; +import LikeButton from '../ui/LikeButton'; +import UserInfo from '../ui/UserInfo'; +import { formatDate } from '@/utils/date'; +import { FallbackImage } from '../FallbackImage/FallbackImage'; +import { PostItem, useToggleArticlesFavorite } from '@/hooks/useArticles'; +import ConfirmModal from '../ui/ConfirmModal'; +import { useConfirmModal } from '@/hooks/useModal'; +import { useRouter } from 'next/navigation'; + +interface ArticleListItemProps { + postItem: PostItem +} +function ArticleListItem({ postItem }: ArticleListItemProps) { + const router = useRouter(); + const href = `boards/${postItem.id}`; + useEffect(() => { + router.prefetch(href); + }, [href, router]); + + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + // '2025-04-08T01:00:06+09:00' '2025-04-07T01:00:06+09:00' + const createdAtString = formatDate(postItem.createdAt); + // console.log(createdAtString); + const { mutate: toggleFavorite } = useToggleArticlesFavorite(openConfirmModal, { + onSuccess: (data) => { + openConfirmModal(data.isFavorited ? "관심상품 등록되었습니다" : "관심상품 취소되었습니다"); + }, + }); + return ( + <> +
  • +
    +
    + +
    +
    +
    +
    + + +
    + +
    +
  • + + ); +} + +export default ArticleListItem; \ No newline at end of file diff --git a/src/components/Article/BestArticleItem.tsx b/src/components/Article/BestArticleItem.tsx new file mode 100644 index 00000000..eacd19d9 --- /dev/null +++ b/src/components/Article/BestArticleItem.tsx @@ -0,0 +1,72 @@ +import { PostItem, useToggleArticlesFavorite } from "@/hooks/useArticles"; +import { useConfirmModal } from "@/hooks/useModal"; +import { formatDate } from "@/utils/date"; +import { FallbackImage } from "../FallbackImage/FallbackImage"; +import LikeButton from "../ui/LikeButton"; +import ConfirmModal from "../ui/ConfirmModal"; +import BestBadge from "../ui/BestBadge"; +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + + +interface ArticleListItemProps { + postItem: PostItem +} +function BestArticleItem({ postItem }: ArticleListItemProps) { + const router = useRouter(); + const href = `boards/${postItem.id}`; + + useEffect(() => { + router.prefetch(href); + }, [href, router]); + + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + // '2025-04-08T01:00:06+09:00' '2025-04-07T01:00:06+09:00' + const createdAtString = formatDate(postItem.createdAt); + // console.log(createdAtString); + const { mutate: toggleFavorite } = useToggleArticlesFavorite(openConfirmModal, { + onSuccess: (data) => { + openConfirmModal(data.isFavorited ? "관심상품 등록되었습니다" : "관심상품 취소되었습니다"); + }, + }); + + return ( + <> +
  • + +
    + +
    + +
    +
    +
    +
    +
    + {postItem.writer.nickname} + +
    +
    + {createdAtString} +
    +
    + +
    +
  • + + ); +} + +export default BestArticleItem; diff --git a/src/components/Article/BestArticleList.tsx b/src/components/Article/BestArticleList.tsx new file mode 100644 index 00000000..37e1177d --- /dev/null +++ b/src/components/Article/BestArticleList.tsx @@ -0,0 +1,54 @@ +'use client'; +import { BEST_POST_ITEMS, BEST_VISIBLE_ITEMS } from "@/constants/product.constants"; +import { useBreakpoint } from "@/hooks/useBreakpoint"; +import { useEffect, useState } from "react"; +import Container from "../layout/Container"; +import LoadingBox from "../ui/LoadingBox"; +import EmptyBox from "../ui/EmptyBox"; +import { PostListQuery, useArticlesList } from "@/hooks/useArticles"; +import BestArticleItem from "./BestArticleItem"; + + +function BestArticleList() { + const breakpoint = useBreakpoint(); + const INITIAL_QUERY: PostListQuery = { + page: 1, + pageSize: BEST_POST_ITEMS.length[breakpoint], + orderBy: 'like', + keyword: '', + }; + const [query, setQuery] = useState(INITIAL_QUERY); + + const { data , isLoading } = useArticlesList(query); + // 페이지 반응형 달라질때마다 pageSize 수정 + useEffect(() => { + setQuery((prev: typeof INITIAL_QUERY) => ({ + ...prev, + pageSize: BEST_POST_ITEMS.length[breakpoint], + })); + }, [breakpoint]); + + return ( + + + {isLoading ? ( + + ) : ( + data ? ( +
    +
      + {data.list.map((article) => ( + + )) + } +
    +
    + ) : ( + + ) + )} +
    + ); +} + +export default BestArticleList; diff --git a/src/components/Article/BoardsClient.tsx b/src/components/Article/BoardsClient.tsx new file mode 100644 index 00000000..90ea99d6 --- /dev/null +++ b/src/components/Article/BoardsClient.tsx @@ -0,0 +1,21 @@ +'use client' +import React from 'react'; +import Container from 'components/layout/Container'; +import Title from 'components/ui/Title'; +import BestArticleList from '@/components/Article/BestArticleList'; +import { ArticleList } from '@/components/Article/ArticleList'; + +function BoardsClient() { + return ( + <> + + + </Container> + <BestArticleList /> + <ArticleList /> + </> + ); +} + +export default BoardsClient; + diff --git a/src/components/Article/comment/CommentForm.tsx b/src/components/Article/comment/CommentForm.tsx new file mode 100644 index 00000000..14de57c4 --- /dev/null +++ b/src/components/Article/comment/CommentForm.tsx @@ -0,0 +1,45 @@ + +import Button from 'components/ui/Button'; +import { TextAreaBox } from '@/components/ui/form/InputBox'; +import React, { useState } from 'react'; +import { useConfirmModal, useModal } from '@/hooks/useModal'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import { usePostArticleComment } from '@/hooks/useArticles'; +import { useAuth } from '@/contexts/AuthContext'; + +type CommentFormProps = { + articleId: number; +}; + +function CommentForm({articleId}: CommentFormProps) { + const { user } = useAuth(); + const [requestCommentValue, setRequestCommentValue] = useState<string>(''); + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + + const { mutate: postComment } = usePostArticleComment(articleId, openConfirmModal); + + const handleClick = () => { + if(!user) { + openConfirmModal('로그인 후 이용 가능합니다.'); + return; + } + postComment(requestCommentValue); + setRequestCommentValue(''); + }; + + return ( + <div className='w-full mb-6'> + <h5 className='text-cool-gray-900 mb-2 font-bold ml-1'>댓글달기</h5> + <TextAreaBox + placeholder='댓글을 입력해주세요.' + value={requestCommentValue} + onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setRequestCommentValue(e.target.value)} + /> + <div className='flex justify-end'> + <Button variant="roundedSS" disabled={!requestCommentValue} onClick={handleClick} >등록</Button> + </div> + <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> + </div> + ); +} +export default CommentForm; \ No newline at end of file diff --git a/src/components/Article/comment/CommentItem.tsx b/src/components/Article/comment/CommentItem.tsx new file mode 100644 index 00000000..73f9eb2a --- /dev/null +++ b/src/components/Article/comment/CommentItem.tsx @@ -0,0 +1,111 @@ + +import React, { useState } from 'react'; +import { formatDate } from 'utils/date'; +import UserInfo from 'components/ui/UserInfo'; +import clsx from 'clsx'; +import { TextAreaBox } from '@/components/ui/form/InputBox'; +import Button from 'components/ui/Button'; +import DropdownMenu from 'components/ui/DropdownMenu'; +import Modal from '@/components/ui/Modal'; +import { useConfirmModal, useModal } from '@/hooks/useModal'; +import ConfirmModal from '@/components/ui/ConfirmModal'; +import { useAuth } from '@/contexts/AuthContext'; +import { useDeleteArticleComment, usePatchArticleComment } from '@/hooks/useArticles'; +import { CommentItemUnit } from '@/hooks/useItems'; + +interface CommentItemProps { + articleId: number; + commentItem: CommentItemUnit; +} + +function CommentItem({articleId,commentItem}:CommentItemProps) { + const { + id:commentId, + content, + updatedAt, + } = commentItem; + + const createdAtString = formatDate(updatedAt); + + const [editMode, setEditMode] = useState(false); + const [requestCommentValue, setRequestCommentValue] = useState<string>(content); + + const { isModalOpen, modalMessage, openModal, closeModal } = useModal(); + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + + const { mutate: deleteProduct } = useDeleteArticleComment(articleId,openConfirmModal); + const { mutate: patchComment } = usePatchArticleComment(articleId,openConfirmModal); + const { user } = useAuth(); + + const handleOpenModal = () =>{ + if(!user) { + openConfirmModal('로그인 후 이용 가능합니다.'); + return; + } + if(commentItem.writer.id !== user?.id) { + openConfirmModal('본인의 댓글만 삭제할 수 있습니다.'); + return; + } + openModal('정말 삭제하시겠습니까?'); + }; + const handleOpenEdit = () =>{ + if(!user) { + openConfirmModal('로그인 후 이용 가능합니다.'); + return; + } + if(commentItem.writer.id !== user?.id) { + openConfirmModal('본인의 댓글만 수정할 수 있습니다.'); + return; + } + setEditMode(true); + }; + + const handleConfirmDelete = () => { + deleteProduct(commentId); + closeConfirmModal(); + }; + const handleUpdate = () =>{ + patchComment({ commentId, requestCommentValue }); + setEditMode(false) + }; + + const dropdownActions = [ + { + label: '삭제하기', + onClick: handleOpenModal, + }, + { + label: '수정하기', + onClick:handleOpenEdit, + }, + ]; + + + return ( + <li className={clsx('flex gap-4 flex-col border-b border-b-[var(--Cool_Gray_200)] pb-3 relative fade-in' )}> + {editMode === true ? ( + <div> + <TextAreaBox + height='80px' + placeholder='내용을 입력해주세요' + value={requestCommentValue} + onChange={({ target }: React.ChangeEvent<HTMLTextAreaElement>) => setRequestCommentValue(target.value)} + /> + <div className='absolute bottom-4 right-0'> + <Button onClick={() => setEditMode(false)} variant="none">취소</Button> + <Button onClick={handleUpdate} variant="roundedSS">수정 완료</Button> + </div> + </div> + ):( + <div> + <span>{requestCommentValue}</span> + <DropdownMenu dropdownActions={dropdownActions} className='' /> + </div> + )} + <UserInfo userImg={commentItem.writer.image} ownerNickname={commentItem.writer.nickname} createdAtString={createdAtString} className="text-xs"/> + <Modal isOpen={isModalOpen} closeModal={closeModal} onclick={handleConfirmDelete} message={modalMessage}/> + <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> + </li> + ); +} +export default CommentItem; \ No newline at end of file diff --git a/src/components/Article/comment/CommentList.tsx b/src/components/Article/comment/CommentList.tsx new file mode 100644 index 00000000..712d822b --- /dev/null +++ b/src/components/Article/comment/CommentList.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import CommentItem from './CommentItem'; +import LoadingBox from '@/components/ui/LoadingBox'; +import EmptyBox from '@/components/ui/EmptyBox'; +import { useInfiniteArticleCommentsWithObserver } from '@/hooks/useArticles'; +import { replyEmptyImg } from '@/lib/imageAssets'; + + +interface CommentListProps { + articleId: number; + className?: string; +} + +function CommentList({ articleId,className, ...rest }: CommentListProps) { + + const { + data, + isLoading, + isFetchingNextPage, + loadMoreRef } + = useInfiniteArticleCommentsWithObserver(articleId); + + return ( + <> + {isLoading ? <LoadingBox className="h-[202px] mt-12 mb-20"/> : data?.pages?.[0].list.length ? ( + <div className={`${className}`} {...rest}> + {data?.pages.map((page, i) => ( + <React.Fragment key={page.nextCursor}> + {page.list.map((comment) => ( + <div key={comment.id} className='mb-6'> + <CommentItem articleId={articleId} commentItem={comment}/> + </div> + ))} + </React.Fragment> + ))} + <div ref={loadMoreRef} style={{ height: '20px' }} /> + {isFetchingNextPage && <div>로딩 중...</div>} + </div> + ):( + <EmptyBox context="아직 댓글이 없어요." subText="지금 댓글을 달아 보세요!" className='h-[202px]' imageName={replyEmptyImg}/> + ) + } + </> + + ); +} +export default CommentList; diff --git a/src/components/Article/comment/CommentSection.tsx b/src/components/Article/comment/CommentSection.tsx new file mode 100644 index 00000000..4a863b85 --- /dev/null +++ b/src/components/Article/comment/CommentSection.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import Button from 'components/ui/Button'; +import Icon from 'components/ui/Icon'; +import CommentList from './CommentList'; +import CommentForm from './CommentForm'; +import { useRouter } from 'next/navigation'; + + +type CommentSectionProps = { + articleId: number; +}; + +function CommentSection({articleId}: CommentSectionProps) { + const router = useRouter(); + + const handleGoBack = () => { + router.back(); // ← 이전 페이지로 이동 + }; + + return ( + <> + <CommentForm articleId={articleId} /> + <CommentList articleId={articleId} className='w-full mb-16'/> + <div className='flex justify-center align-middle w-full '> + <Button onClick={handleGoBack} variant="roundedL" childrenClassName='gap-2'> + <span>목록으로 돌아가기</span> + <Icon iconName='back' alt='back icon'/> + </Button> + </div> + + </> + ); +} +export default CommentSection; diff --git a/src/components/FallbackImage/FallbackImage.tsx b/src/components/FallbackImage/FallbackImage.tsx index b66e6c57..26de11a8 100644 --- a/src/components/FallbackImage/FallbackImage.tsx +++ b/src/components/FallbackImage/FallbackImage.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState } from "react"; import Image, { ImageProps } from "next/image"; import { allowedImageDomains, defaultImg, imageExtensionRegex } from "@/lib/imageAssets"; +import { useState } from "react"; interface ImageWithFadeProps extends ImageProps { fallbackSrc?: string; @@ -45,8 +45,9 @@ export const FallbackImage = ({ alt={alt} fill priority + unoptimized sizes="sm:100vw, 33vw" - className={`absolute inset-0 object-cover blur-sm scale-105 transition-opacity duration-300 ${ + className={`absolute inset-0 object-cover scale-105 transition-opacity duration-300 ${ isLoaded ? "opacity-0" : "opacity-100" }`} aria-hidden="true" @@ -65,23 +66,6 @@ export const FallbackImage = ({ {...props} /> </div> - - // <Image - // src={finalSrc} - // alt={alt} - // onLoad={() => setIsLoaded(true)} - // onError={() => setHasError(true)} - // placeholder={blurDataURL ? "blur" : undefined} - // blurDataURL={blurDataURL} - // fill - // priority - // unoptimized - // sizes="(max-width: 768px) 100vw, 33vw" - // className={`transition-opacity duration-700 ease-in-out ${ - // isLoaded ? "opacity-100" : "opacity-0" - // } ${className}`} - // {...props} - // /> ); }; diff --git a/src/components/ItemsDetail/ProductDetails.module.css b/src/components/ItemsDetail/ProductDetails.module.css deleted file mode 100644 index 219667a9..00000000 --- a/src/components/ItemsDetail/ProductDetails.module.css +++ /dev/null @@ -1,8 +0,0 @@ -.container { - display: flex; - gap: 24px; - margin-top: 29px; - border-bottom: 1px solid var(--Cool_Gray_200); - padding-bottom: 40px; - margin-bottom: 40px; -} diff --git a/src/components/ItemsDetail/ProductDetails.tsx b/src/components/ItemsDetail/ProductDetails.tsx deleted file mode 100644 index 20e8123e..00000000 --- a/src/components/ItemsDetail/ProductDetails.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client'; - -import React, { useState } from 'react'; -import ProductOverview from './ProductOverview'; -import ProductDescription from './ProductDescription'; -import Container from 'components/layout/Container'; -import CommentSection from './comment/CommentSection'; -import { useProductsDetails } from '@/hooks/useProductsDetail'; -import { useParams } from 'next/navigation'; - - -function ProductDetails( ) { - - const { id } = useParams(); // URL에서 [id] 추출 - const productId = Number(id); - - const { data, isLoading, isError } = useProductsDetails(productId); - - return ( - <div className='flex flex-col mt-8'> - <Container className='flex flex-row w-full gap-6 pb-[40px] mb-[40px] border-b border-b-[var(--Cool_Gray_200)] mobile:flex-col'> - { data && ( - <> - <ProductOverview img={data?.images}/> - <ProductDescription {...data} /> - </> - )} - </Container> - <Container> - <CommentSection productId={productId} /> - </Container> - </div> - ); -} - -export default ProductDetails; \ No newline at end of file diff --git a/src/components/Product/AllItems.module.css b/src/components/Product/AllItems.module.css deleted file mode 100644 index caae54af..00000000 --- a/src/components/Product/AllItems.module.css +++ /dev/null @@ -1,85 +0,0 @@ -.title { - font-size: 20px; - line-height: 42px; - font-weight: 700; -} -.bestProdListTitle { - margin-top: 24px; - font-size: 20px; - margin-bottom: 16px; - font-weight: 700; -} -.prodListTitle { - display: flex; - position: relative; - justify-content: space-between; - align-items: center; - margin-top: 40px; - margin-bottom: 24px; - z-index: 99; -} - -.prodListTitle > div { - display: flex; - gap: 12px; -} - -.prodSearchWrap { - position: relative; -} -.prodSearch input[type="text"] { - border: 0; - line-height: 26px; - height: 42px; - width: 325px; - background-color: #f3f4f6; - padding: 9px 24px; - padding-left: 44px; - font-size: 16px; - border-radius: 12px; - border: 0; -} -.prodSearch input[type="text"]:focus, -.prodSearch input[type="text"]:hover, -.prodSearch input[type="text"]:active { - background-color: #eaedf1; - border: 0; - outline: 0; -} - -.prodSearch img { - position: absolute; - left: 16px; - top: 10px; - width: 24px; -} - -/* tablet */ -@media (max-width: 1199px) { -} -/* Mobile */ -@media (max-width: 767px) { - .prodListTitle { - position: relative; - align-items: flex-start; - flex-direction: column; - gap: 8px; - } - .prodListTitle > div { - width: 100%; - } - .prodSearch { - width: 100%; - } - .prodSearchWrap { - width: 100%; - } - .prodAddBtn { - position: absolute; - top: 0; - right: 0; - } - .prodSearch input[type="text"] { - width: 100%; - } -} diff --git a/src/components/Product/AllItems.tsx b/src/components/Product/AllItems.tsx index ff4c6f06..5c6d5479 100644 --- a/src/components/Product/AllItems.tsx +++ b/src/components/Product/AllItems.tsx @@ -1,59 +1,41 @@ 'use client'; -import React, { useEffect, useLayoutEffect, useState } from "react"; -import styles from "./AllItems.module.css"; +import React, { useLayoutEffect } from "react"; import Container from "components/layout/Container"; -import Icon from "components/ui/Icon"; import Button from "components/ui/Button"; import SelectBox from "components/ui/SelectBox"; import PageNation from "components/ui/PageNation"; import LoadingBox from "../ui/LoadingBox"; -import { useItemService, useParsedItemQuery, useSetItemQuery } from "@/hooks/useItemQuery"; import { useRouter, useSearchParams } from "next/navigation"; -import { ProductQuery } from "@/hooks/useItems"; -import { useScreenType } from "@/hooks/useScreenType"; +import { ProductQuery, useItemList } from "@/hooks/useItems"; import { ProdListAll } from "./ProdListAll"; import { useBreakpoint } from "@/hooks/useBreakpoint"; import EmptyBox from "../ui/EmptyBox"; import { useAuth } from "@/contexts/AuthContext"; import { useConfirmModal } from "@/hooks/useModal"; import ConfirmModal from "../ui/ConfirmModal"; +import Title from "../ui/Title"; +import { ORDER_OPTIONS, orderByType, VISIBLE_ITEMS } from "@/constants/product.constants"; +import { usePushQueryToURL } from "@/hooks/useItemQuery"; +import SearchBox from "../ui/form/SearchBox"; -type orderByType = "recent" | "favorite"; - - -const ORDER_OPTIONS = [ - { value: 'recent', label: '최신순' }, - { value: 'favorite', label: '좋아요순' }, -]; - export function AllItems() { - + const { user } = useAuth(); + const router = useRouter(); const searchParams = useSearchParams(); - const screenType = useScreenType(); // 0: 모바일, 1: 태블릿, 2: 데스크탑 const breakpoint = useBreakpoint(); - const VISIBLE_ITEMS = { - length: {mobile:4, tablet:6, desktop:10}, - column: {mobile:2, tablet:3, desktop:5}, + const query: ProductQuery = { + page: Number(searchParams.get("page") ?? 1), + pageSize: Number(searchParams.get("pageSize") ?? VISIBLE_ITEMS.length[breakpoint]), + orderBy: (searchParams.get("orderBy") ?? 'recent') as orderByType, + keyword: searchParams.get("keyword") ?? '', }; - const INITIAL_QUERY : ProductQuery = { - page: 1, - pageSize: VISIBLE_ITEMS.length[breakpoint], - orderBy: 'recent', - keyword: '', - }; - - const setQueryToURL = useSetItemQuery(); - - const parsedQuery = useParsedItemQuery(INITIAL_QUERY, searchParams); - const [query, setQuery] = useState(parsedQuery); - const { data , isLoading } = useItemService( query , searchParams); + const pushQueryToURL = usePushQueryToURL(); + const { data , isLoading } = useItemList( query ); const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); - const { user } = useAuth(); - const router = useRouter(); // 페이지 반응형 달라질때마다 pageSize 수정 useLayoutEffect(() => { @@ -66,30 +48,26 @@ export function AllItems() { pageSize, }; - setQuery(next); - setQueryToURL(next); + pushQueryToURL(next); }, [breakpoint]); // PageNation handle const handlePageNationClick = (num: number) => { const next = { ...query, page: (num) }; - setQuery(next); - setQueryToURL(next); + pushQueryToURL(next); }; // SelectBox handle const handleSelectBoxClick = (value: string) => { const next = { ...query, orderBy: value as orderByType, page: 1 }; - setQuery(next); - setQueryToURL(next); + pushQueryToURL(next); }; // Keyword handle const handleKeywordChange = (keyword: string) => { const next = { ...query, keyword,page: 1}; - setQuery(next); - setQueryToURL(next); + pushQueryToURL(next); }; const handleApplyClick = () => { @@ -102,46 +80,24 @@ export function AllItems() { return ( <> - <Container className='relative z-20'> - <div className={styles.prodListTitle}> - <div className="left"> - <div className={styles.title}>전체상품</div> - </div> - <div className="right"> - <form className={styles.prodSearch}> - <div className={styles.prodSearchWrap}> - <Icon iconName="search" alt="search box" /> - <input - name="keyword" - type="text" - placeholder="검색할 상품을 입력해주세요" - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleKeywordChange((e.target as HTMLInputElement).value); - } - }} - /> - </div> - </form> - - <Button - variant="roundedSS" - className={styles.prodAddBtn} - heightError='true' - onClick={handleApplyClick} - > - 상품 등록하기 - </Button> - - <SelectBox - options={ORDER_OPTIONS} - screenType={Number(screenType)} - current={query.orderBy} - clickEvent={handleSelectBoxClick} - /> - </div> - </div> + <Container className='relative z-20'> + <Title titleTag='h2' text='전체상품'> + <SearchBox onSearch={(keyword) => handleKeywordChange(keyword)} /> + <Button + className="absolute right-0 top-0" + variant="roundedSS" + onClick={handleApplyClick} + > + 상품 등록하기 + </Button> + + <SelectBox + options={ORDER_OPTIONS} + screenType={breakpoint} + current={query.orderBy} + clickEvent={handleSelectBoxClick} + /> + {/* 🔹 로딩 중이면 LoadingBox 표시 */} @@ -154,7 +110,7 @@ export function AllItems() { className={Number(data?.list?.length) < Number(query.pageSize) ? 'mb-[141px]' : 'mb-0'} /> ) : ( - + )} {/* 🔹 페이지네이션 */} diff --git a/src/components/Product/BestItems.tsx b/src/components/Product/BestItems.tsx index e6b1bd3e..2a7f98c4 100644 --- a/src/components/Product/BestItems.tsx +++ b/src/components/Product/BestItems.tsx @@ -2,36 +2,28 @@ import React, { useEffect, useState } from 'react'; import LoadingBox from '../ui/LoadingBox'; import { ProductQuery, useItemList } from '@/hooks/useItems'; -import { useScreenType } from '@/hooks/useScreenType'; import { ProdListAll } from './ProdListAll'; +import { BEST_VISIBLE_ITEMS } from '@/constants/product.constants'; +import { useBreakpoint } from '@/hooks/useBreakpoint'; export function BestItems() { -const screenType = useScreenType(); // 0: 모바일, 1: 태블릿, 2: 데스크탑 - -// 베스트 상품 반응형 width 기준값 -const VISIBLE_ITEMS = { - length: [1, 2, 4], // 상품 갯수 (mobile, tablet, desktop) - column: { mobile: 1, tablet: 2, desktop: 4 }, // 열 갯수 (mobile, tablet, desktop) -}; - -// 베스트 상품 쿼리 초기값 -const INITIAL_QUERY: ProductQuery = { - page: 1, - pageSize: VISIBLE_ITEMS.length[screenType], - orderBy: 'favorite', - keyword: '', -}; - + const breakpoint = useBreakpoint(); + const INITIAL_QUERY: ProductQuery = { + page: 1, + pageSize: BEST_VISIBLE_ITEMS.length[breakpoint], + orderBy: 'favorite', + keyword: '', + }; const [query, setQuery] = useState(INITIAL_QUERY); // 페이지 반응형 달라질때마다 pageSize 수정 useEffect(() => { setQuery((prev: typeof INITIAL_QUERY) => ({ ...prev, - pageSize: VISIBLE_ITEMS.length[screenType], + pageSize: BEST_VISIBLE_ITEMS.length[breakpoint], })); - }, [screenType]); + }, [breakpoint]); const { data , isLoading} = useItemList(query); diff --git a/src/components/Product/ProductClient.tsx b/src/components/Product/ProductClient.tsx new file mode 100644 index 00000000..9f60e0ab --- /dev/null +++ b/src/components/Product/ProductClient.tsx @@ -0,0 +1,22 @@ +'use client' +import React from 'react'; +import Container from 'components/layout/Container'; +import Title from 'components/ui/Title'; +import { BestItems } from '@/components/Product/BestItems'; +import { AllItems } from '@/components/Product/AllItems'; + + +function ProductClient() { + + return ( + <> + + + </Container> + <BestItems /> + <AllItems /> + </> + ); +} + +export default ProductClient; \ No newline at end of file diff --git a/src/components/Product/ProductItem.tsx b/src/components/Product/ProductItem.tsx index ecfd7aa3..e5e3ccf0 100644 --- a/src/components/Product/ProductItem.tsx +++ b/src/components/Product/ProductItem.tsx @@ -1,18 +1,14 @@ 'use client'; -import React, { useEffect, useMemo } from 'react'; +import React from 'react'; import Link from 'next/link'; -import { useState } from 'react'; import styles from './ProductItem.module.css'; -import Icon from 'components/ui/Icon'; -import Button from 'components/ui/Button'; import clsx from 'clsx'; import { ProductSummary, useToggleProductFavorite } from '@/hooks/useItems'; import { FallbackImage } from '../FallbackImage/FallbackImage'; import { defaultImg } from '@/lib/imageAssets'; import { useConfirmModal, useModal } from '@/hooks/useModal'; import ConfirmModal from '../ui/ConfirmModal'; -import { useAuth } from '@/contexts/AuthContext'; import { useGetUserFavorites } from '@/hooks/useUser'; import LikeButton from '../ui/LikeButton'; @@ -27,7 +23,14 @@ function ProductItem({productItem}: ProductItemProps) { const productId = productItem.id ?? 0; + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); const { data } = useGetUserFavorites({}); + + const { mutate: toggleFavorite } = useToggleProductFavorite(openConfirmModal, { + onSuccess: (data) => { + openConfirmModal(data.isFavorited ? "관심상품 등록되었습니다" : "관심상품 취소되었습니다"); + }, + }); const isFavorite = data?.list.some((item) => item.id === productId) ?? false; return ( @@ -44,8 +47,14 @@ function ProductItem({productItem}: ProductItemProps) { <div className={styles.name}>{productItem.name}</div> <div className={styles.price}>{productItem.price?.toLocaleString()}원</div> - <LikeButton productId={productId} favoriteCount={productItem.favoriteCount} isFavorite={isFavorite} /> + <LikeButton + id={productId} + favoriteCount={productItem.favoriteCount} + toggleFavorite={toggleFavorite} + isFavorite={isFavorite} + /> </div> + <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> </li> ); } diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index aefa031a..8adc489b 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -23,16 +23,16 @@ function Footer() { <div className='flex gap-3'> {/* 외부링크는 a로? */} <a href="https://www.facebook.com/" target="_blank" rel="noopener noreferrer"> - <span><Image src={facebookIcon} width={20} height={20} alt="페이스북 바로가기" /></span> + <span><Image src={facebookIcon} unoptimized width={20} height={20} alt="페이스북 바로가기" /></span> </a> <a href="https://x.com/" target="_blank" rel="noopener noreferrer"> - <span><Image src={twitterIcon} width={20} height={20} alt="트위터 바로가기" /></span> + <span><Image src={twitterIcon} unoptimized width={20} height={20} alt="트위터 바로가기" /></span> </a> <a href="https://www.youtube.com/" target="_blank" rel="noopener noreferrer"> - <span><Image src={youtubeIcon} width={20} height={20} alt="유튜브 바로가기" /></span> + <span><Image src={youtubeIcon} unoptimized width={20} height={20} alt="유튜브 바로가기" /></span> </a> <a href="https://www.instagram.com/" target="_blank" rel="noopener noreferrer"> - <span><Image src={instagramIcon} width={20} height={20} alt="인스타그램 바로가기" /></span> + <span><Image src={instagramIcon} unoptimized width={20} height={20} alt="인스타그램 바로가기" /></span> </a> </div> </Container> diff --git a/src/components/layout/Nav.tsx b/src/components/layout/Nav.tsx index 26b1e0b8..1960d425 100644 --- a/src/components/layout/Nav.tsx +++ b/src/components/layout/Nav.tsx @@ -26,10 +26,10 @@ function Nav() { <div className='relative flex items-center'> <Link href="/" className='gap-2 flex items-center'> <span className='relative inline-flex h-[40px] mobile:hidden'> - <Image src={logoImg1} width={110} height={110} className="w-full h-auto" priority alt="로고이미지" /> + <Image src={logoImg1} width={110} height={110} unoptimized className="w-full h-auto" priority alt="로고이미지" /> </span> <span className='relative inline-flex h-[35px]'> - <Image src={logoImg2} width={266} height={90} className="w-full h-auto" priority alt="판다마켓" /> + <Image src={logoImg2} width={266} height={90} unoptimized className="w-full h-auto" priority alt="판다마켓" /> </span> </Link> </div> diff --git a/src/components/layout/ProductNav.tsx b/src/components/layout/ProductNav.tsx index 0ab2b342..ad8010ab 100644 --- a/src/components/layout/ProductNav.tsx +++ b/src/components/layout/ProductNav.tsx @@ -1,7 +1,6 @@ 'use client'; import React from 'react'; -import styles from './ProductNav.module.css'; import Image from 'next/image'; import Container from './Container'; import Button from '../ui/Button'; @@ -10,7 +9,7 @@ import { useSelectedLayoutSegments } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import { logoImg1, logoImg2 } from '@/lib/imageAssets'; import ConfirmModal from '../ui/ConfirmModal'; -import { useConfirmModal, useModal } from '@/hooks/useModal'; +import { useConfirmModal } from '@/hooks/useModal'; function ProductNav() { const segments = useSelectedLayoutSegments(); @@ -31,10 +30,10 @@ function ProductNav() { <div className='relative flex items-center'> <Link href="/" className='gap-2 flex items-center'> <span className='relative inline-flex h-[40px] mobile:hidden'> - <Image src={logoImg1} width={110} height={110} className="w-full h-auto" priority alt="로고이미지" /> + <Image src={logoImg1} width={110} height={110} unoptimized className="w-full h-auto" priority alt="로고이미지" /> </span> <span className='relative inline-flex h-[35px]'> - <Image src={logoImg2} width={266} height={90} className="w-full h-auto" priority alt="판다마켓" /> + <Image src={logoImg2} width={266} height={90} unoptimized className="w-full h-auto" priority alt="판다마켓" /> </span> </Link> <div className='ml-12 flex gap-7 text-lg tablet:ml-8 mobile:ml-4 mobile:gap-2 mobile:text-base'> diff --git a/src/components/members/MembersLogo.tsx b/src/components/members/MembersLogo.tsx index 4e7d8e28..3379f1cd 100644 --- a/src/components/members/MembersLogo.tsx +++ b/src/components/members/MembersLogo.tsx @@ -10,10 +10,10 @@ function MembersLogo() { <div> <Link href="/" className='flex items-center justify-center mb-10 gap-6'> <span className='relative inline-flex h-[84px] mobile:hidden'> - <Image src={logoImg1} width={110} height={110} className="w-full h-auto" priority alt="로고이미지" /> + <Image src={logoImg1} width={110} height={110} unoptimized className="w-full h-auto" priority alt="로고이미지" /> </span> <span className='relative inline-flex h-[60px]'> - <Image src={logoImg2} width={266} height={90} className="w-full h-auto" priority alt="판다마켓" /> + <Image src={logoImg2} width={266} height={90} unoptimized className="w-full h-auto" priority alt="판다마켓" /> </span> </Link> </div> diff --git a/src/components/members/SnsLogin.tsx b/src/components/members/SnsLogin.tsx index 45d2b438..172f0d37 100644 --- a/src/components/members/SnsLogin.tsx +++ b/src/components/members/SnsLogin.tsx @@ -10,10 +10,10 @@ function SnsLogin() { <div className="sns_txt">간편 로그인하기</div> <div className={styles.sns_icon}> <Link href="https://www.google.com/" className="sns_gg" target="_blank" rel="noopener noreferrer"> - <Image src={sns_google} width={42} height={42} alt="sns_login_google" /> + <Image src={sns_google} width={42} height={42} unoptimized alt="sns_login_google" /> </Link> <Link href="https://www.kakaocorp.com/page/" className="sns_kt" target="_blank" rel="noopener noreferrer"> - <Image src={sns_kakao} width={42} height={42} alt="sns_login_kakao" /> + <Image src={sns_kakao} width={42} height={42} unoptimized alt="sns_login_kakao" /> </Link> </div> </div> diff --git a/src/components/ItemsDetail/ProductDescription.module.css b/src/components/productDetail/ProductDescription.module.css similarity index 100% rename from src/components/ItemsDetail/ProductDescription.module.css rename to src/components/productDetail/ProductDescription.module.css diff --git a/src/components/ItemsDetail/ProductDescription.tsx b/src/components/productDetail/ProductDescription.tsx similarity index 64% rename from src/components/ItemsDetail/ProductDescription.tsx rename to src/components/productDetail/ProductDescription.tsx index 72ef83c9..167f6484 100644 --- a/src/components/ItemsDetail/ProductDescription.tsx +++ b/src/components/productDetail/ProductDescription.tsx @@ -1,13 +1,13 @@ -import React, { useState } from 'react'; +import React from 'react'; import styles from './ProductDescription.module.css'; import UserInfo from 'components/ui/UserInfo'; import { formatDate } from 'utils/date'; -import Icon from 'components/ui/Icon'; -import Button from 'components/ui/Button'; import clsx from 'clsx'; -import { ProductDetail } from '@/hooks/useProductsDetail'; import LikeButton from '../ui/LikeButton'; import { useGetUserFavorites } from '@/hooks/useUser'; +import { ProductDetail, useToggleProductFavorite } from '@/hooks/useItems'; +import { useConfirmModal } from '@/hooks/useModal'; +import ConfirmModal from '../ui/ConfirmModal'; function ProductDescription(detailData:ProductDetail) { @@ -29,6 +29,12 @@ function ProductDescription(detailData:ProductDetail) { const createdAtString = formatDate(createdAt); // console.log(createdAtString); + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + const { mutate: toggleFavorite } = useToggleProductFavorite(openConfirmModal, { + onSuccess: (data) => { + openConfirmModal(data.isFavorited ? "관심상품 등록되었습니다" : "관심상품 취소되었습니다"); + }, +}); return ( <div className={styles.description}> <div className='mobile:mb-10'> @@ -54,11 +60,17 @@ function ProductDescription(detailData:ProductDetail) { </ul> </div> <div className={styles.UserInfo}> - <UserInfo ownerNickname={ownerNickname} createdAtString={createdAtString}/> + <UserInfo ownerNickname={ownerNickname} createdAtString={createdAtString} className="text-sm"/> <div className={styles.likeBtnBox}> - <LikeButton variant="btn-heart_L" productId={detailData.id} favoriteCount={detailData.favoriteCount} isFavorite={isFavorite} childrenClassName='gap-2' width="24" height="24"/> + <LikeButton variant="btn-heart_L" + productId={detailData.id} + favoriteCount={detailData.favoriteCount} + isFavorite={isFavorite} + toggleFavorite={toggleFavorite} + childrenClassName='gap-2' width="24" height="24"/> </div> </div> + <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> </div> ); } diff --git a/src/components/ItemsDetail/ProductOverview.module.css b/src/components/productDetail/ProductOverview.module.css similarity index 100% rename from src/components/ItemsDetail/ProductOverview.module.css rename to src/components/productDetail/ProductOverview.module.css diff --git a/src/components/ItemsDetail/ProductOverview.tsx b/src/components/productDetail/ProductOverview.tsx similarity index 100% rename from src/components/ItemsDetail/ProductOverview.tsx rename to src/components/productDetail/ProductOverview.tsx diff --git a/src/components/ItemsDetail/comment/CommentForm.tsx b/src/components/productDetail/comment/CommentForm.tsx similarity index 86% rename from src/components/ItemsDetail/comment/CommentForm.tsx rename to src/components/productDetail/comment/CommentForm.tsx index 4dc27869..4334f7d8 100644 --- a/src/components/ItemsDetail/comment/CommentForm.tsx +++ b/src/components/productDetail/comment/CommentForm.tsx @@ -2,9 +2,10 @@ import Button from 'components/ui/Button'; import { TextAreaBox } from '@/components/ui/form/InputBox'; import React, { useState } from 'react'; -import { usePostProductComment } from '@/hooks/useProductsComments'; import { useConfirmModal, useModal } from '@/hooks/useModal'; import ConfirmModal from '@/components/ui/ConfirmModal'; +import { usePostProductComment } from '@/hooks/useItems'; +import { useAuth } from '@/contexts/AuthContext'; type CommentFormProps = { productId: number; @@ -12,11 +13,16 @@ type CommentFormProps = { function CommentForm({productId}: CommentFormProps) { + const { user } = useAuth(); const [requestCommentValue, setRequestCommentValue] = useState<string>(''); const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); const { mutate: postComment } = usePostProductComment(productId, openConfirmModal); const handleClick = () => { + if(!user) { + openConfirmModal('로그인 후 이용 가능합니다.'); + return; + } postComment(requestCommentValue); setRequestCommentValue(''); }; diff --git a/src/components/ItemsDetail/comment/CommentItem.tsx b/src/components/productDetail/comment/CommentItem.tsx similarity index 94% rename from src/components/ItemsDetail/comment/CommentItem.tsx rename to src/components/productDetail/comment/CommentItem.tsx index df2e88ff..7df1c61c 100644 --- a/src/components/ItemsDetail/comment/CommentItem.tsx +++ b/src/components/productDetail/comment/CommentItem.tsx @@ -7,10 +7,10 @@ import { TextAreaBox } from '@/components/ui/form/InputBox'; import Button from 'components/ui/Button'; import DropdownMenu from 'components/ui/DropdownMenu'; import Modal from '@/components/ui/Modal'; -import { CommentItemUnit, useDeleteCommentMutation, usePatchProductComment } from '@/hooks/useProductsComments'; import { useConfirmModal, useModal } from '@/hooks/useModal'; import ConfirmModal from '@/components/ui/ConfirmModal'; import { useAuth } from '@/contexts/AuthContext'; +import { CommentItemUnit, useDeleteProductComment, usePatchProductComment } from '@/hooks/useItems'; interface CommentItemProps { productId: number; @@ -31,7 +31,7 @@ function CommentItem({productId,commentItem}:CommentItemProps) { const { isModalOpen, modalMessage, openModal, closeModal } = useModal(); const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); - const { mutate: deleteProduct } = useDeleteCommentMutation(productId,openConfirmModal); + const { mutate: deleteProduct } = useDeleteProductComment(productId,openConfirmModal); const { mutate: patchComment } = usePatchProductComment(productId,openConfirmModal); const { user } = useAuth(); @@ -100,7 +100,7 @@ function CommentItem({productId,commentItem}:CommentItemProps) { <DropdownMenu dropdownActions={dropdownActions} className='' /> </div> )} - <UserInfo userImg={commentItem.writer.image} ownerNickname={commentItem.writer.nickname} createdAtString={createdAtString} fontSize='12px'/> + <UserInfo userImg={commentItem.writer.image} ownerNickname={commentItem.writer.nickname} createdAtString={createdAtString} className="text-xs"/> <Modal isOpen={isModalOpen} closeModal={closeModal} onclick={handleConfirmDelete} message={modalMessage}/> <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> </li> diff --git a/src/components/ItemsDetail/comment/CommentList.tsx b/src/components/productDetail/comment/CommentList.tsx similarity index 56% rename from src/components/ItemsDetail/comment/CommentList.tsx rename to src/components/productDetail/comment/CommentList.tsx index b0b58379..1f10e988 100644 --- a/src/components/ItemsDetail/comment/CommentList.tsx +++ b/src/components/productDetail/comment/CommentList.tsx @@ -1,48 +1,23 @@ -import React, { useEffect, useRef } from 'react'; -import { useInfiniteProductsComments } from '@/hooks/useProductsComments'; +import React from 'react'; import CommentItem from './CommentItem'; import LoadingBox from '@/components/ui/LoadingBox'; import EmptyBox from '@/components/ui/EmptyBox'; +import { useInfiniteProductsCommentsWithObserver } from '@/hooks/useItems'; interface CommentListProps { productId: number; className?: string; - [key: string]: any; } function CommentList({ productId,className, ...rest }: CommentListProps) { - const { - data, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading, - isError, - } = useInfiniteProductsComments(productId); - - const loadMoreRef = useRef<HTMLDivElement>(null); - - - useEffect(() => { - if (!loadMoreRef.current || !hasNextPage) return; - - const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && hasNextPage) { - fetchNextPage(); - } - }, - { threshold: 1.0 } - ); - - observer.observe(loadMoreRef.current); - - return () => { - if (loadMoreRef.current) observer.unobserve(loadMoreRef.current); - }; - }, [hasNextPage, fetchNextPage]); + const { + data, + isLoading, + isFetchingNextPage, + loadMoreRef } + = useInfiniteProductsCommentsWithObserver(productId); return ( <> @@ -61,7 +36,7 @@ function CommentList({ productId,className, ...rest }: CommentListProps) { {isFetchingNextPage && <div>로딩 중...</div>} </div> ):( - <EmptyBox context="아직 문의가 없어요" className='h-[372px]' /> + <EmptyBox context="아직 문의가 없어요" className='h-[372px]'/> ) } </> diff --git a/src/components/ItemsDetail/comment/CommentSection.tsx b/src/components/productDetail/comment/CommentSection.tsx similarity index 100% rename from src/components/ItemsDetail/comment/CommentSection.tsx rename to src/components/productDetail/comment/CommentSection.tsx diff --git a/src/components/ui/BestBadge.tsx b/src/components/ui/BestBadge.tsx new file mode 100644 index 00000000..cbaf736f --- /dev/null +++ b/src/components/ui/BestBadge.tsx @@ -0,0 +1,20 @@ + +import clsx from 'clsx'; +import React from 'react'; +import Icon from './Icon'; + +interface BestBadgeProps { + className?: string; +} + +function BestBadge({ className }: BestBadgeProps) { + return ( + <div className={clsx("bg-primary-100 rounded-b-[16px] flex gap-[4px] items-center justify-center h-[30px] w-[102px]",className)}> + <Icon iconName="medal" width="16" height="16" alt="베스트 상품 아이콘" /> + <span className="text-base text-white"> + Best + </span> + </div> + ) +} +export default BestBadge; \ No newline at end of file diff --git a/src/components/ui/Button.module.css b/src/components/ui/Button.module.css index 9ec84004..3b9683b3 100644 --- a/src/components/ui/Button.module.css +++ b/src/components/ui/Button.module.css @@ -191,7 +191,6 @@ } .btn.btn-heart_S img { - width: 16px; margin-right: 4px; } diff --git a/src/components/ui/EmptyBox.tsx b/src/components/ui/EmptyBox.tsx index 3db0a251..07187737 100644 --- a/src/components/ui/EmptyBox.tsx +++ b/src/components/ui/EmptyBox.tsx @@ -8,14 +8,16 @@ import clsx from 'clsx'; interface EmptyBoxProps { context?: string; className?: string; + imageName?: string; + subText?: string; } -function EmptyBox({context, className}: EmptyBoxProps) { +function EmptyBox({context, subText, className , imageName = emptyImg }: EmptyBoxProps) { return ( <Container className={clsx('mt-12 mb-20 text-center',className)}> <div className='flex flex-col justify-center items-center h-full rounded-[8px]'> - <Image src={emptyImg} width={176} height={176} className='mx-auto' alt='빈페이지' /> - <span className='text-center mx-auto text-cool-gray-400'>{context}</span> + <Image src={imageName} width={176} unoptimized height={176} className='mx-auto' alt='빈페이지' /> + <span className='text-center mx-auto text-secondary-400'>{context}<br/>{subText}</span> </div> </Container> ); diff --git a/src/components/ui/Icon.tsx b/src/components/ui/Icon.tsx index e926f745..9bbd3f0e 100644 --- a/src/components/ui/Icon.tsx +++ b/src/components/ui/Icon.tsx @@ -25,6 +25,8 @@ const statusWhiteR = '/assets/status_white-1.svg'; const ic_kebab = '/assets/ic_kebab.svg'; +const medal = '/assets/ic_medal.svg'; + const ICON = { eyeOpen, eyeClose, @@ -45,6 +47,7 @@ const ICON = { statusInactiveR, statusWhiteL, statusWhiteR, + medal, }; interface IconProps { diff --git a/src/components/ui/LikeButton.tsx b/src/components/ui/LikeButton.tsx index d68843ad..8c7d1d95 100644 --- a/src/components/ui/LikeButton.tsx +++ b/src/components/ui/LikeButton.tsx @@ -2,10 +2,10 @@ import Button from "./Button"; import Icon from "./Icon"; -import { useState } from "react"; -import { useToggleProductFavorite } from "@/hooks/useItems"; import ConfirmModal from "./ConfirmModal"; import { useConfirmModal } from "@/hooks/useModal"; +import { useAuth } from "@/contexts/AuthContext"; +import { useState } from "react"; interface LikeButtonProps { className?: string; @@ -14,33 +14,41 @@ interface LikeButtonProps { [key: string]: any; } function LikeButton({ - productId, + id, className, childrenClassName, favoriteCount, isFavorite, variant="btn-heart_S", + toggleFavorite, width = 16, height = 16 , ...restProps } : LikeButtonProps) { + const { user } = useAuth(); const [isFavorited, setIsFavorited] = useState(isFavorite); const [count, setCount ] = useState(favoriteCount); const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); - const { mutate: toggleFavorite } = useToggleProductFavorite(openConfirmModal); + const handleClick = () => { - toggleFavorite({ productId, isFavorited ,setIsFavorited, setCount}); + if(!user) { + openConfirmModal('로그인 후 이용 가능합니다.'); + return; + } + toggleFavorite({ id, isFavorited ,setIsFavorited, setCount}); }; return ( <> - <Button onClick={handleClick} variant={variant} childrenClassName={childrenClassName}> - <Icon iconName={isFavorited === false ? 'heartOpen' : 'heartClose'} width={width} height={height} alt='Like icon' /> - <span>{count}</span> - </Button> + <div className={className}> + <Button onClick={handleClick} variant={variant} childrenClassName={childrenClassName}> + <Icon iconName={isFavorited === false ? 'heartOpen' : 'heartClose'} width={width} height={height} alt='Like icon' /> + <span>{count}</span> + </Button> + </div> <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> </> ) diff --git a/src/components/ui/SelectBox.tsx b/src/components/ui/SelectBox.tsx index cab7d204..e7cf1ab6 100644 --- a/src/components/ui/SelectBox.tsx +++ b/src/components/ui/SelectBox.tsx @@ -22,7 +22,7 @@ interface SelectBoxProps { options: { value: string; label: string }[]; current: string; clickEvent: (value: string) => void; - screenType: number; + screenType: string; } function SelectBox({ options, current, clickEvent, screenType }: SelectBoxProps) { @@ -35,7 +35,7 @@ function SelectBox({ options, current, clickEvent, screenType }: SelectBoxProps) return ( <section className={styles.selectBox} > <button type="button" className={styles.selectBtn} onClick={handleClickToggle}> - { screenType === 0 ? + { screenType === 'mobile' ? <Icon iconName='sort' alt='select box'/> : <> diff --git a/src/components/ui/Title.module.css b/src/components/ui/Title.module.css deleted file mode 100644 index eeef88e3..00000000 --- a/src/components/ui/Title.module.css +++ /dev/null @@ -1,11 +0,0 @@ -.title { - display: flex; - justify-content: space-between; - margin: 24px auto; -} - -.titleTag { - font-size: 20px; - line-height: 42px; - font-weight: 700; -} diff --git a/src/components/ui/Title.tsx b/src/components/ui/Title.tsx index 81bf2c34..38c8acae 100644 --- a/src/components/ui/Title.tsx +++ b/src/components/ui/Title.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import styles from './Title.module.css'; interface TitleProps { titleTag?: keyof JSX.IntrinsicElements; @@ -12,11 +11,11 @@ interface TitleProps { function Title ({ titleTag = 'h1', text, children, ...rest }: TitleProps) { const TitleComponent = titleTag; return ( - <div className={styles.title} {...rest} > - <div className={styles.left}> - <TitleComponent className={styles.titleTag}>{text}</TitleComponent> + <div className="flex justify-between w-full my-6 mx-auto mobile:flex-col mobile:gap-3" {...rest} > + <div className="text-xl font-bold leading-10"> + <TitleComponent>{text}</TitleComponent> </div> - <div className={styles.right}> + <div className="flex items-center gap-3"> {children} </div> </div> diff --git a/src/components/ui/UserInfo.module.css b/src/components/ui/UserInfo.module.css deleted file mode 100644 index bd3dfd41..00000000 --- a/src/components/ui/UserInfo.module.css +++ /dev/null @@ -1,34 +0,0 @@ -.userInfo { - display: flex; - align-items: center; - gap: 16px; -} -.userInfo > div { - display: flex; - flex-direction: column; - gap: 3px; -} -.userInfo > span { - width: 40px; - padding-top: 40px; - position: relative; - overflow: hidden; - border-radius: 999px; - border: 1px solid var(--Cool_Gray_200); -} -.userInfo img { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - object-fit: cover; -} -.userInfo > div span:first-child { - color: var(--Secondary_600); - font-size: 14px; -} -.userInfo > div span:last-child { - color: var(--Cool_Gray_400); - font-size: 14px; -} diff --git a/src/components/ui/UserInfo.tsx b/src/components/ui/UserInfo.tsx index 8cc98acf..1371b25b 100644 --- a/src/components/ui/UserInfo.tsx +++ b/src/components/ui/UserInfo.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import styles from './UserInfo.module.css'; import clsx from 'clsx'; import Image from 'next/image'; import { tempUserImg } from '@/lib/imageAssets'; @@ -9,17 +8,23 @@ interface UserInfoProps { userImg?: string | null; ownerNickname: string; createdAtString: string; + width?: number; fontSize?: string; + childrenClassName?: string; + className?: string; + noImage?: boolean; } -function UserInfo({userImg = '', ownerNickname, createdAtString, fontSize = '14px'}: UserInfoProps) { +function UserInfo({userImg = '', ownerNickname, createdAtString, width=40, noImage = false, childrenClassName ,className ="gap-[16px]"}: UserInfoProps) { const userImageSrc = userImg === '' || userImg === null ? tempUserImg : userImg; return ( - <div className={clsx(styles.userInfo, `text-[${fontSize}]`)}> - <span><Image src={userImageSrc} fill alt="작성자이미지"/></span> - <div> - <span>{ownerNickname}</span> - <span>{createdAtString}</span> + <div className={clsx("flex items-center gap-4", className)}> + {noImage === true ? null : + <span><Image src={userImageSrc} width={width} height={width} unoptimized alt="작성자이미지"/></span> + } + <div className={clsx('flex flex-col gap-1',childrenClassName)}> + <span className="text-secondary-600">{ownerNickname}</span> + <span className="text-secondary-400">{createdAtString}</span> </div> </div> ); diff --git a/src/components/ui/form/FormField.tsx b/src/components/ui/form/FormField.tsx index 342be083..75319c95 100644 --- a/src/components/ui/form/FormField.tsx +++ b/src/components/ui/form/FormField.tsx @@ -1,62 +1,49 @@ import React from 'react'; -import Image from 'next/image'; -import { eyeClose, eyeOpen } from '@/lib/imageAssets'; +import Icon from '../Icon'; +import clsx from 'clsx'; -interface FormFieldProps { +type FormFieldProps = { id: string; label: string; type: string; - placeholder: string; - error: string; - onBlur: (e: React.FocusEvent<HTMLInputElement>) => void; + placeholder?: string; + error?: string; withEyeToggle?: boolean; eyeState?: boolean; onEyeToggle?: () => void; - eyeIconOpen?: string; - eyeIconClose?: string; -} +} & React.InputHTMLAttributes<HTMLInputElement>; export default function FormField({ id, label, - type, - placeholder, error, - onBlur, - withEyeToggle = false, - eyeState = true, + withEyeToggle, + eyeState, onEyeToggle, - eyeIconOpen = eyeOpen, - eyeIconClose = eyeClose, + ...inputProps }: FormFieldProps) { + + return ( - <label htmlFor={id} className="relative"> - {label} - <div> - <input - id={id} - type={type} - className={error && 'outline outline-1 outline-red-500'} - placeholder={placeholder} - onBlur={onBlur} - /> - {withEyeToggle && onEyeToggle && ( - <button - type="button" - onClick={onEyeToggle} + <div className="relative mb-6"> + <label className="text-lg font-bold mobile:text-sm" htmlFor={id}>{label}</label> + <div className='mt-4'> + <input id={id} className={clsx("bg-secondary-100 h-[56px] rounded-xl w-full px-6",error && 'outline outline-1 outline-red-500')} {...inputProps}/> + {withEyeToggle && ( + <button type="button" onClick={onEyeToggle} className="absolute bottom-4 right-6" aria-label="비밀번호 표시 전환" > - <Image - src={eyeState ? eyeIconOpen : eyeIconClose} + <Icon + iconName={eyeState ? 'eyeOpen' : 'eyeClose'} + alt="toggle password" width={24} height={24} - alt="toggle password" /> </button> )} </div> {error && <span className="absolute top-3 right-3 text-error_red-50 text-base font-normal">{error}</span>} - </label> + </div> ); } diff --git a/src/components/ui/form/ImageFile.tsx b/src/components/ui/form/ImageFile.tsx index 6ac7ce1c..5fde54b6 100644 --- a/src/components/ui/form/ImageFile.tsx +++ b/src/components/ui/form/ImageFile.tsx @@ -1,7 +1,6 @@ import React from 'react'; import styles from './ImageFile.module.css'; -import Image from 'next/image'; import Icon from '../Icon'; import { FallbackImage } from '@/components/FallbackImage/FallbackImage'; diff --git a/src/components/ui/form/ImageFileBox.tsx b/src/components/ui/form/ImageFileBox.tsx index 35361087..fd247fee 100644 --- a/src/components/ui/form/ImageFileBox.tsx +++ b/src/components/ui/form/ImageFileBox.tsx @@ -1,33 +1,38 @@ -import React from 'react'; -import { useState } from 'react'; +import React, { useState } from 'react'; import ImageFile from './ImageFile'; -import { CreateProductRequest } from '@/hooks/useItems'; import { useUploadImage } from '@/hooks/useUploadImage'; import { useConfirmModal } from '@/hooks/useModal'; import ConfirmModal from '../ConfirmModal'; +interface ImageUpdatable { + images?: string[]; + image?: string; +} -interface ImageFileBoxProps { - product: CreateProductRequest; - setProduct: React.Dispatch<React.SetStateAction<CreateProductRequest>>; +interface ImageFileBoxProps<T extends ImageUpdatable> { + setForm: React.Dispatch<React.SetStateAction<T>>; } -function ImageFileBox({ product, setProduct }: ImageFileBoxProps) { - - const [preview, setPreview] = useState<(string | null)[]>([]); // 미리보기 이미지 상태 +function ImageFileBox<T extends ImageUpdatable>({ setForm }: ImageFileBoxProps<T>) { + const [preview, setPreview] = useState<(string | null)[]>([]); const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); const [errorCase, setErrorCase] = useState(''); const MAX_IMAGE_COUNT = 1; - // console.log('업로드된 이미지:', product.images); const { mutate: uploadImage } = useUploadImage( (msg) => openConfirmModal(msg), (url) => { - // console.log('업로드 완료 URL:', url); setPreview((prev) => [...prev, url]); } ); - function getFilesValue(e: React.ChangeEvent<HTMLInputElement>) { + + const getImageKey = (obj: any): 'images' | 'image' => { + if ('images' in obj) return 'images'; + if ('image' in obj) return 'image'; + throw new Error('No image key found'); + }; + + const getFilesValue = (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (!file) return; @@ -37,46 +42,64 @@ function ImageFileBox({ product, setProduct }: ImageFileBoxProps) { return; } - // 2. 서버 업로드 요청 → 성공 시 URL 저장 - uploadImage(file, { - onSuccess: (url) => { - setProduct((prev) => ({ - ...prev, - images: [...(prev.images || []), url], - })); - }, - onError: (msg) => { - setErrorCase(msg); - setTimeout(() => setErrorCase(''), 2000); - }, - }); -} + uploadImage(file, { + onSuccess: (url) => { + setForm((prev) => { + const key = getImageKey(prev); -// 미리보기 & URL 동기화 삭제 -function handleClickImgDelete(index: number) { - if (preview.length > 0) setErrorCase(''); + return { + ...prev, + [key]: key === 'images' + ? [...(prev.images || []), url] + : url, // image는 string으로 바로 저장 + }; + }); + }, + onError: (msg) => { + setErrorCase(msg); + setTimeout(() => setErrorCase(''), 2000); + }, + }); + }; - // 삭제 시 preview와 images 모두 index 기준 삭제 - setPreview((prev) => prev.filter((_, i) => i !== index)); - setProduct((prev) => ({ - ...prev, - images: (prev.images || []).filter((_, i) => i !== index), - })); -} + const handleClickImgDelete = (index: number) => { + if (preview.length > 0) setErrorCase(''); + setPreview((prev) => prev.filter((_, i) => i !== index)); + + setForm((prev) => { + const key = getImageKey(prev); + + if (key === 'images') { + return { + ...prev, + images: (prev.images || []).filter((_, i) => i !== index), + }; + } else { + return { + ...prev, + image: '', // 단일 이미지 삭제는 빈 문자열로 대체 + }; + } + }); + }; - return( + return ( <> - <ImageFile - label='상품 이미지' - text='이미지 등록' - images={preview} - errorCase={errorCase} - onChange={getFilesValue} + <ImageFile + label="상품 이미지" + text="이미지 등록" + images={preview} + errorCase={errorCase} + onChange={getFilesValue} onClickDelete={handleClickImgDelete} /> - <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> + <ConfirmModal + isOpen={isConfirmOpen} + onClose={closeConfirmModal} + errorMessage={confirmMessage} + /> </> - ) + ); } -export default ImageFileBox; \ No newline at end of file +export default ImageFileBox; diff --git a/src/components/ui/form/SearchBox.tsx b/src/components/ui/form/SearchBox.tsx new file mode 100644 index 00000000..7e4bf2e1 --- /dev/null +++ b/src/components/ui/form/SearchBox.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import Icon from '../Icon'; +import clsx from 'clsx'; + +type ProductSearchBoxProps = { + onSearch: (keyword: string) => void; + className?: string; +}; + +export default function SearchBox({ onSearch , className}: ProductSearchBoxProps) { + const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === 'Enter') { + e.preventDefault(); + onSearch((e.target as HTMLInputElement).value); + } + }; + + return ( + <form className={clsx('w-[325px] mobile:w-full',className)}> + <div className='relative mobile:w-full'> + <Icon iconName="search" alt="search box" className="absolute left-[16px] top-[10px] w-[24px]" /> + <input + name="keyword" + type="text" + placeholder="검색할 상품을 입력해주세요" + onKeyDown={handleKeyDown} + className=" + border-0 leading-[26px] + h-[42px] bg-gray-100 + pl-[44px] pr-6 py-[9px] text-[16px] rounded-[12px] + focus:bg-gray-200 hover:bg-gray-200 active:bg-gray-200 + focus:outline-0 hover:outline-0 active:outline-0 w-full + " + /> + </div> + </form> + ); +} diff --git a/src/constants/product.constants.ts b/src/constants/product.constants.ts new file mode 100644 index 00000000..489e27d4 --- /dev/null +++ b/src/constants/product.constants.ts @@ -0,0 +1,31 @@ + + // 일반 상품리스트 기본값 + export type orderByType = "recent" | "favorite"; + + export const ORDER_OPTIONS = [ + { value: 'recent', label: '최신순' }, + { value: 'favorite', label: '좋아요순' }, + ]; + + export const VISIBLE_ITEMS = { + length: {mobile:4, tablet:6, desktop:10}, + column: {mobile:2, tablet:3, desktop:5}, + }; + + // 베스트 상품 기본값 + export const BEST_VISIBLE_ITEMS = { + length: { mobile:1, tablet:2, desktop:4}, // 상품 갯수 (mobile, tablet, desktop) + column: { mobile: 1, tablet: 2, desktop: 4 }, // 열 갯수 (mobile, tablet, desktop) + }; + + export type postByType = "recent" | "like"; + + export const POST_OPTIONS = [ + { value: 'recent', label: '최신순' }, + { value: 'like', label: '좋아요순' }, + ]; + export const BEST_POST_ITEMS = { + length: { mobile:1, tablet:2, desktop:3}, + column: { mobile:1, tablet:2, desktop:3 }, + }; + \ No newline at end of file diff --git a/src/hooks/useArticles.ts b/src/hooks/useArticles.ts new file mode 100644 index 00000000..97bb24e2 --- /dev/null +++ b/src/hooks/useArticles.ts @@ -0,0 +1,338 @@ +import { requestor } from "@/lib/requestor"; +import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import { CommentListResponse } from "./useItems"; + + +export interface PostListQuery { + page: number; // 기본값 1 + pageSize: number; // 기본값 10 + orderBy: 'like' | 'recent'; // 기본값 'recent' + keyword?: string; // optional +} + +export type PostListResponse = { + totalCount: number; + list: PostItem[]; +}; + +export type PostItem = { + id: number; + title: string; + content: string; + image: string; + likeCount: number; + createdAt: string; // ISO 형식 (Date string) + updatedAt: string; + writer: PostWriter; + isLiked?: boolean; +}; + +export type PostWriter = { + id: number; + nickname: string; +}; + +export type PostDetail = { + id: number; + title: string; + content: string; + image: string; + likeCount: number; + createdAt: string; // ISO 날짜 문자열 + updatedAt: string; // ISO 날짜 문자열 + writer: { + id: number; + nickname: string; + }; +}; + +interface ProductFavoriteResponse { + id: number; + isFavorited: boolean; + setIsFavorited: (value: boolean) => void, + setCount: (value: number | ((prev: number) => number)) => void +} + +export interface ArticleCreateRequest { + title?: string; + content?: string; + image?:string; +} + +//게시물 리스트 불러오기 +export const useArticlesList = (query:PostListQuery) => { + return useQuery({ + queryKey: ['articles', query], + queryFn: async () => { + const res = await requestor.get<PostListResponse>('/articles', { + params: query, + }); + return res.data ; + }, + placeholderData: keepPreviousData, + }); +}; + +// 게시물 더보기 무한로딩 +export function useInfiniteArticles(query: Omit<PostListQuery, 'page'>) { + const queryResult = useInfiniteQuery<PostListResponse, Error>({ + queryKey: ['infiniteArticles', query], + queryFn: async ({ pageParam = 1 }) => { + const res = await requestor.get<PostListResponse>('/articles', { + params: { + ...query, + page: pageParam, + }, + }); + return res.data; + }, + initialPageParam: 1, + getNextPageParam: (lastPage, allPages) => { + const loadedCount = allPages.reduce((acc, page) => acc + page.list.length, 0); + if (loadedCount >= lastPage.totalCount) { + return undefined; + } + return allPages.length + 1; + }, + }); + + const handleLoadMore = () => { + if (queryResult.hasNextPage && !queryResult.isFetchingNextPage) { + queryResult.fetchNextPage(); + } + }; + + return { + ...queryResult, + handleLoadMore, + }; +} + +// 게시물 상세보기 +export const useArticleDetails = (articleId:number) => { + return useQuery({ + queryKey: ['articleDetails', articleId], + queryFn: async () => { + const res = await requestor.get<PostDetail>(`/articles/${articleId}`); + return res.data; + }, + placeholderData: keepPreviousData, + }); +}; + + +// 게시물 좋아요 토글 +export const useToggleArticlesFavorite = +(openModal: (msg: string) => void, options?: { onSuccess?: (data: any) => void }) => { + + return useMutation({ + mutationFn: async ({ id, isFavorited, setIsFavorited, setCount }:ProductFavoriteResponse ) => { + const token = localStorage.getItem('accessToken'); + if (!token) { + openModal('로그인이 필요합니다.'); + return Promise.reject('No accessToken'); + } + + setIsFavorited(!isFavorited); + setCount((prev: number) => isFavorited ? prev - 1 : prev + 1); + + if (isFavorited) { + return requestor.delete(`/articles/${id}/like`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + } else { + return requestor.post( + `/articles/${id}/like`, + {}, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + } + }, + onError: (error) => { + const message = (error as any)?.response?.data?.message; + if (message?.includes('jwt malformed')) { + openModal('로그인 후 등록 가능합니다!'); + } else { + openModal(message || '관심 게시물 처리 실패'); + } + }, + }); +}; + + +// 게시물 등록 +export function usePostArticles(openModal: (msg: string) => void, router: { push: (path: string) => void }) { + + return useMutation({ + mutationFn: async (articlesData: ArticleCreateRequest) => { + const res = await requestor.post<PostDetail>('/articles', articlesData); + return res.data; + }, + onSuccess: (data) => { + openModal('게시물 등록이 완료되었습니다!'); + setTimeout(() => { + router.push(`/boards/${data.id}`); + }, 1300); + }, + onError: (error: any) => { + openModal(error?.response?.data?.message || '게시물 등록 실패'); + }, + }); +}; + +// 게시물 코멘트 리스트 무한로딩 +export function useInfiniteArticleCommentsWithObserver(articleId: number, limit = 5) { // 무한로딩 + const queryResult = useInfiniteQuery<CommentListResponse, Error>({ + queryKey: ['articleComments', articleId], + queryFn: async ({ pageParam }) => { + const res = await requestor.get<CommentListResponse>(`/articles/${articleId}/comments`, { + params: { + limit, + cursor: pageParam ?? null, + }, + }); + return res.data; + }, + initialPageParam: null, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? null, + }); + + const loadMoreRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!loadMoreRef.current || !queryResult.hasNextPage) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && queryResult.hasNextPage) { + queryResult.fetchNextPage(); + } + }, + { threshold: 1.0 } + ); + + observer.observe(loadMoreRef.current); + + return () => { + if (loadMoreRef.current) observer.unobserve(loadMoreRef.current); + }; + }, [queryResult.hasNextPage, queryResult.fetchNextPage]); + + return { + ...queryResult, + loadMoreRef, + }; +} + +// 게시물 코멘트 등록 +export const usePostArticleComment = (articleId: number, openModal: (msg: string) => void) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (requestCommentValue: string | undefined) => { + const token = localStorage.getItem('accessToken'); + if (!token) { + openModal('로그인이 필요합니다.'); + return Promise.reject('No accessToken'); + } + return requestor.post( + `/articles/${articleId}/comments`, + { content: requestCommentValue }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + }, + onSuccess: () => { + openModal('댓글이 등록되었습니다!'); + queryClient.invalidateQueries({ queryKey: ['articleComments', articleId] }); + }, + onError: (error: any) => { + const message = error?.response?.data?.message; + if (message?.includes('jwt malformed')) { + openModal('로그인 후 등록 가능합니다!'); + } else { + openModal(message || '댓글 등록 실패'); + } + }, + }); +}; + +// 게시물 코맨트 삭제 +export const useDeleteArticleComment = (articleId: number, openModal: (msg: string) => void) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (commentId: number) => { + const token = localStorage.getItem('accessToken'); + if (!token) { + openModal('로그인이 필요합니다.'); + return Promise.reject('No accessToken'); + } + return requestor.delete(`/comments/${commentId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + }, + onSuccess: () => { + openModal('댓글이 삭제되었습니다!'); + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ['articleComments', articleId] }); + }, 1300); + }, + onError: (error: any) => { + const message = error?.response?.data?.message; + if (message?.includes('jwt malformed')) { + openModal('로그인 후 등록 가능합니다!'); + } else { + openModal(message || '댓글 삭제 실패'); + } + }, + }); +}; + +// 게시물 코멘트 수정 +export const usePatchArticleComment = (articleId: number, openModal: (msg: string) => void) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ commentId, requestCommentValue }: { commentId: number; requestCommentValue: string }) => { + const token = localStorage.getItem('accessToken'); + if (!token) { + openModal('로그인이 필요합니다.'); + return Promise.reject('No accessToken'); + } + return requestor.patch( + `/comments/${commentId}`, + { content: requestCommentValue }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + }, + onSuccess: () => { + openModal('댓글이 수정되었습니다!'); + queryClient.invalidateQueries({ queryKey: ['productComments', articleId] }); + }, + onError: (error: any) => { + const message = error?.response?.data?.message; + if (message?.includes('jwt malformed')) { + openModal('로그인 후 수정 가능합니다!'); + } else { + openModal(error?.response?.data?.message || '댓글 수정 실패'); + } + }, + }); +}; \ No newline at end of file diff --git a/src/hooks/useItemQuery.tsx b/src/hooks/useItemQuery.tsx index 847f816c..61eac9d3 100644 --- a/src/hooks/useItemQuery.tsx +++ b/src/hooks/useItemQuery.tsx @@ -1,48 +1,10 @@ -import { useMemo } from "react"; import { useRouter } from "next/navigation"; -import { ProductQuery, useItemList } from "./useItems"; -export const useItemService = ( - defaultQuery :ProductQuery, - searchParams: URLSearchParams -) => { - const parsedQuery = useParsedItemQuery(defaultQuery, searchParams); - const queryResult = useItemList(parsedQuery); - return { - ...queryResult, - }; -}; - -export const useParsedItemQuery = ( - defaultQuery :ProductQuery, - searchParams: URLSearchParams -) => { - if (!searchParams) return defaultQuery; - const parsedQuery = useMemo(() => { - const obj: Record<string, any> = {}; - - for (const [key, value] of searchParams.entries()) { - // console.log("searchParams", `${key} = ${value}`); - if (value === "true") obj[key] = true; - else if (value === "false") obj[key] = false; - else if (!isNaN(Number(value))) obj[key] = Number(value); - else obj[key] = value; - } - - return { - ...defaultQuery, - ...obj, - }; - }, [searchParams.toString()]); // URLSearchParams는 얕은 비교가 안 되므로 .toString()을 기준으로 해야 변경을 감지? - - return parsedQuery; -}; - -export const useSetItemQuery = () => { +export const usePushQueryToURL = () => { const router = useRouter(); - const setQueryToURL = (query: Record<string, any>) => { + const pushQueryToURL = (query: Record<string, any>) => { const queryString = toQueryString(query); if (router) { @@ -50,7 +12,7 @@ export const useSetItemQuery = () => { } }; - return setQueryToURL; + return pushQueryToURL; }; export const toQueryString = (params: Record<string, any>): string => { diff --git a/src/hooks/useItems.tsx b/src/hooks/useItems.tsx index 7e59f207..8a3e41e9 100644 --- a/src/hooks/useItems.tsx +++ b/src/hooks/useItems.tsx @@ -1,7 +1,7 @@ import { requestor } from "@/lib/requestor"; -import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; export interface ProductQuery { page: number; // 기본값 1 @@ -27,19 +27,6 @@ export interface ProductListResponse { list: ProductSummary[]; } -export const useItemList = (query:ProductQuery) => { - return useQuery({ - queryKey: ['Items', query], - queryFn: async () => { - const res = await requestor.get<ProductListResponse>('/products', { - params: query, - }); - return res.data ; - }, - placeholderData: keepPreviousData, - }); -}; - // 상품 등록 요청 타입 export interface CreateProductRequest { images: string[]; @@ -63,6 +50,40 @@ export interface CreateProductResponse { id: number; } +interface ProductFavoriteResponse { + id: number; + isFavorited: boolean; + setIsFavorited: (value: boolean) => void, + setCount: (value: number | ((prev: number) => number)) => void +} + +export interface ProductDetail { + id: number; + name: string; + description: string; + price: number; + images: string[]; // 이미지 URL 리스트 + tags: string[]; // 태그 리스트 (예: ["전자제품"]) + isFavorite: boolean; // 사용자가 찜한 여부 + favoriteCount: number; // 총 찜 수 + createdAt: string; // ISO 시간 문자열 + ownerId: number; + ownerNickname: string; +} +// 상품 리스트 불러오기 +export const useItemList = (query:ProductQuery) => { + return useQuery({ + queryKey: ['Items', query], + queryFn: async () => { + const res = await requestor.get<ProductListResponse>('/products', { + params: query, + }); + return res.data ; + }, + placeholderData: keepPreviousData, + }); +}; + // 상품 등록 export function usePostProduct(openModal: (msg: string) => void, router: AppRouterInstance ) { // 사용에따라 router를 인자로 받음 @@ -71,9 +92,11 @@ export function usePostProduct(openModal: (msg: string) => void, router: AppRout const res = await requestor.post<CreateProductResponse>('/products', productData); return res.data; }, - onSuccess: (product) => { + onSuccess: (data) => { openModal('상품 등록이 완료되었습니다!'); - router.push('/items'); + setTimeout(() => { + router.push(`/items${data.id}`); + }, 1300); }, onError: (error: any) => { openModal(error?.response?.data?.message || '상품 등록 실패'); @@ -81,19 +104,12 @@ export function usePostProduct(openModal: (msg: string) => void, router: AppRout }); }; - -interface ProductFavoriteResponse { - productId: number; - isFavorited: boolean; - setIsFavorited: (value: boolean) => void, - setCount: (value: number | ((prev: number) => number)) => void -} -export const useToggleProductFavorite = ( - openModal: (msg: string) => void -) => { +// 상품 좋아요 토글 +export const useToggleProductFavorite = +(openModal: (msg: string) => void, options?: { onSuccess?: (data: any) => void }) => { return useMutation({ - mutationFn: async ({ productId, isFavorited, setIsFavorited, setCount }:ProductFavoriteResponse ) => { + mutationFn: async ({ id, isFavorited, setIsFavorited, setCount }:ProductFavoriteResponse ) => { const token = localStorage.getItem('accessToken'); if (!token) { openModal('로그인이 필요합니다.'); @@ -104,14 +120,14 @@ export const useToggleProductFavorite = ( setCount((prev: number) => isFavorited ? prev - 1 : prev + 1); if (isFavorited) { - return requestor.delete(`/products/${productId}/favorite`, { + return requestor.delete(`/products/${id}/favorite`, { headers: { Authorization: `Bearer ${token}`, }, }); } else { return requestor.post( - `/products/${productId}/favorite`, + `/products/${id}/favorite`, {}, { headers: { @@ -121,9 +137,6 @@ export const useToggleProductFavorite = ( ); } }, - onSuccess: (_, variables) => { - openModal(variables.isFavorited ? '관심상품이 해제되었습니다!' : '관심상품이 등록되었습니다!'); - }, onError: (error) => { const message = (error as any)?.response?.data?.message; if (message?.includes('jwt malformed')) { @@ -134,3 +147,191 @@ export const useToggleProductFavorite = ( }, }); }; +// 상품 상세보기 +export const useProductsDetails = (productId:number) => { + return useQuery({ + queryKey: ['ItemsDetails', productId], + queryFn: async () => { + const res = await requestor.get<ProductDetail>(`/products/${productId}`); + return res.data; + }, + placeholderData: keepPreviousData, + }); +}; + +// 댓글 작성자 정보 +export interface CommentWriter { + id: number; + nickname: string; + image: string | null; +} + +// 댓글 아이템 +export interface CommentItemUnit { + id: number; + content: string; + createdAt: string; + updatedAt: string; + writer: CommentWriter; +} + +// 전체 응답 타입 +export interface CommentListResponse { + list: CommentItemUnit[]; + nextCursor: number; +} + +export interface GetCommentsQuery { + limit: number; + cursor?: number; +} + +// 상품 댓글리스트 무한로딩 +export function useInfiniteProductsCommentsWithObserver(productId: number, limit = 10) { // 무한로딩 + const queryResult = useInfiniteQuery<CommentListResponse, Error>({ + queryKey: ['productComments', productId], + queryFn: async ({ pageParam }) => { + const res = await requestor.get<CommentListResponse>(`/products/${productId}/comments`, { + params: { + limit, + cursor: pageParam ?? null, + }, + }); + return res.data; + }, + initialPageParam: null, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? null, + }); + + const loadMoreRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + if (!loadMoreRef.current || !queryResult.hasNextPage) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && queryResult.hasNextPage) { + queryResult.fetchNextPage(); + } + }, + { threshold: 1.0 } + ); + + observer.observe(loadMoreRef.current); + + return () => { + if (loadMoreRef.current) observer.unobserve(loadMoreRef.current); + }; + }, [queryResult.hasNextPage, queryResult.fetchNextPage]); + + return { + ...queryResult, + loadMoreRef, + }; +} + +// 상품 댓글 등록 +export const usePostProductComment = (productId: number, openModal: (msg: string) => void) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (requestCommentValue: string | undefined) => { + const token = localStorage.getItem('accessToken'); + if (!token) { + openModal('로그인이 필요합니다.'); + return Promise.reject('No accessToken'); + } + return requestor.post( + `/products/${productId}/comments`, + { content: requestCommentValue }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + }, + onSuccess: () => { + openModal('댓글이 등록되었습니다!'); + queryClient.invalidateQueries({ queryKey: ['productComments', productId] }); + }, + onError: (error: any) => { + const message = error?.response?.data?.message; + if (message?.includes('jwt malformed')) { + openModal('로그인 후 등록 가능합니다!'); + } else { + openModal(message || '댓글 등록 실패'); + } + }, + }); +}; + +// 상품 댓글 수정 +export const usePatchProductComment = (productId: number, openModal: (msg: string) => void) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ commentId, requestCommentValue }: { commentId: number; requestCommentValue: string }) => { + const token = localStorage.getItem('accessToken'); + if (!token) { + openModal('로그인이 필요합니다.'); + return Promise.reject('No accessToken'); + } + return requestor.patch( + `/comments/${commentId}`, + { content: requestCommentValue }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + }, + onSuccess: () => { + openModal('댓글이 수정되었습니다!'); + queryClient.invalidateQueries({ queryKey: ['productComments', productId] }); + }, + onError: (error: any) => { + const message = error?.response?.data?.message; + if (message?.includes('jwt malformed')) { + openModal('로그인 후 수정 가능합니다!'); + } else { + openModal(error?.response?.data?.message || '댓글 수정 실패'); + } + }, + }); +}; + +// 상품 댓글 삭제 +export const useDeleteProductComment = (productId: number, openModal: (msg: string) => void) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (commentId: number) => { + const token = localStorage.getItem('accessToken'); + if (!token) { + openModal('로그인이 필요합니다.'); + return Promise.reject('No accessToken'); + } + return requestor.delete(`/comments/${commentId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + }, + onSuccess: () => { + openModal('댓글이 삭제되었습니다!'); + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ['productComments', productId] }); + }, 1300); + }, + onError: (error: any) => { + const message = error?.response?.data?.message; + if (message?.includes('jwt malformed')) { + openModal('로그인 후 등록 가능합니다!'); + } else { + openModal(message || '댓글 삭제 실패'); + } + }, + }); +}; diff --git a/src/hooks/useProductsComments.tsx b/src/hooks/useProductsComments.tsx deleted file mode 100644 index f1ae10ff..00000000 --- a/src/hooks/useProductsComments.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { requestor } from '@/lib/requestor'; -import {useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; - - -// 댓글 작성자 정보 -export interface CommentWriter { - id: number; - nickname: string; - image: string | null; -} - -// 댓글 아이템 -export interface CommentItemUnit { - id: number; - content: string; - createdAt: string; - updatedAt: string; - writer: CommentWriter; -} - -// 전체 응답 타입 -export interface CommentListResponse { - list: CommentItemUnit[]; - nextCursor: number; -} - -export interface GetCommentsQuery { - limit: number; - cursor?: number; -} - -export const useInfiniteProductsComments = (productId: number, limit = 10) => { - return useInfiniteQuery<CommentListResponse, Error>({ - queryKey: ['productComments', productId], - queryFn: async ({ pageParam }) => { - const res = await requestor.get<CommentListResponse>(`/products/${productId}/comments`, { - params: { - limit, - cursor: pageParam ?? null, - }, - }); - return res.data; - }, - initialPageParam: null, - getNextPageParam: (lastPage) => lastPage.nextCursor ?? null, - }); -}; - -export const usePostProductComment = (productId: number, openModal: (msg: string) => void) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (requestCommentValue: string | undefined) => { - const token = localStorage.getItem('accessToken'); - if (!token) { - openModal('로그인이 필요합니다.'); - return Promise.reject('No accessToken'); - } - return requestor.post( - `/products/${productId}/comments`, - { content: requestCommentValue }, - { - headers: { - Authorization: `Bearer ${token}`, - }, - } - ); - }, - onSuccess: () => { - openModal('댓글이 등록되었습니다!'); - queryClient.invalidateQueries({ queryKey: ['productComments', productId] }); - }, - onError: (error: any) => { - const message = error?.response?.data?.message; - if (message?.includes('jwt malformed')) { - openModal('로그인 후 등록 가능합니다!'); - } else { - openModal(message || '댓글 등록 실패'); - } - }, - }); -}; - -export const usePatchProductComment = (productId: number, openModal: (msg: string) => void) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: ({ commentId, requestCommentValue }: { commentId: number; requestCommentValue: string }) => { - const token = localStorage.getItem('accessToken'); - if (!token) { - openModal('로그인이 필요합니다.'); - return Promise.reject('No accessToken'); - } - return requestor.patch( - `/comments/${commentId}`, - { content: requestCommentValue }, - { - headers: { - Authorization: `Bearer ${token}`, - }, - } - ); - }, - onSuccess: () => { - openModal('댓글이 수정되었습니다!'); - queryClient.invalidateQueries({ queryKey: ['productComments', productId] }); - }, - onError: (error: any) => { - const message = error?.response?.data?.message; - if (message?.includes('jwt malformed')) { - openModal('로그인 후 수정 가능합니다!'); - } else { - openModal(error?.response?.data?.message || '댓글 수정 실패'); - } - }, - }); -}; -export const useDeleteCommentMutation = (productId: number, openModal: (msg: string) => void) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (commentId: number) => { - const token = localStorage.getItem('accessToken'); - if (!token) { - openModal('로그인이 필요합니다.'); - return Promise.reject('No accessToken'); - } - return requestor.delete(`/comments/${commentId}`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['productComments', productId] }); - }, - onError: (error: any) => { - const message = error?.response?.data?.message; - if (message?.includes('jwt malformed')) { - openModal('로그인 후 등록 가능합니다!'); - } else { - openModal(message || '댓글 삭제 실패'); - } - }, - }); -}; diff --git a/src/hooks/useProductsDetail.tsx b/src/hooks/useProductsDetail.tsx deleted file mode 100644 index 8314095b..00000000 --- a/src/hooks/useProductsDetail.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { requestor } from "@/lib/requestor"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; - -export interface ProductDetail { - id: number; - name: string; - description: string; - price: number; - images: string[]; // 이미지 URL 리스트 - tags: string[]; // 태그 리스트 (예: ["전자제품"]) - isFavorite: boolean; // 사용자가 찜한 여부 - favoriteCount: number; // 총 찜 수 - createdAt: string; // ISO 시간 문자열 - ownerId: number; - ownerNickname: string; -} - -export const useProductsDetails = (productId:number) => { - return useQuery({ - queryKey: ['ItemsDetails', productId], - queryFn: async () => { - const res = await requestor.get<ProductDetail>(`/products/${productId}`); - return res.data; - }, - placeholderData: keepPreviousData, - }); -}; diff --git a/src/lib/imageAssets.ts b/src/lib/imageAssets.ts index e28d04cc..22bb2bdc 100644 --- a/src/lib/imageAssets.ts +++ b/src/lib/imageAssets.ts @@ -26,6 +26,7 @@ export const logoImg1 = '/assets/logo_01.svg'; export const logoImg2 = '/assets/logo_03.svg'; export const emptyImg = '/assets/img/Img_inquiry_empty_2x.png'; +export const replyEmptyImg = '/assets/reply_empty.svg'; export const imgHome_top = '/assets/Img_home_top.png'; export const imgHome1 = '/assets/Img_home_01.png'; export const imgHome2 = '/assets/Img_home_02.png'; diff --git a/src/utils/auth.ts b/src/utils/auth.ts deleted file mode 100644 index 1d8cd7df..00000000 --- a/src/utils/auth.ts +++ /dev/null @@ -1,62 +0,0 @@ - -export const MEMBER_MESSAGE = { - enterEmail : '이메일을 입력해주세요.', - wrongEmail : '잘못된 이메일 형식입니다.', - enterName : '닉네임을 입력해주세요.', - enterPassword : '비밀번호를 입력해주세요.', - checkPassword : '비밀번호를 8자 이상 입력해주세요.', - wrongPassword : '비밀번호가 일치하지 않습니다.', -} - -export const memberCheck = { - PasswordLength : 8, - checkEmail : (email: string) => { - const emailRegex = /^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/; - return emailRegex.test(email); - }, - checkPassword : (password: string) => { - return password.length >= memberCheck.PasswordLength; - }, - EmailChecked : (email: string) => { - let errorMessage = ''; - if(email === '') { - errorMessage = MEMBER_MESSAGE.enterEmail; - } else if ( !memberCheck.checkEmail(email)) { - errorMessage = MEMBER_MESSAGE.wrongEmail; - } else { - errorMessage = ''; - } - return errorMessage; - }, - NameChecked: (name: string) => { - let errorMessage = ''; - if(name === '') { - errorMessage = MEMBER_MESSAGE.enterName; - } else { - errorMessage = ''; - } - return errorMessage; - }, - passwordChecked: (password: string) => { - let errorMessage = ''; - if(password === '') { - errorMessage = MEMBER_MESSAGE.enterPassword; - } else if ( !memberCheck.checkPassword(password)) { - errorMessage = MEMBER_MESSAGE.checkPassword; - } else { - errorMessage = ''; - } - return errorMessage; - }, - passwordDoubleChecked: (password: string, passwordDouble: string) => { - let errorMessage = ''; - if(password === '') { - errorMessage = MEMBER_MESSAGE.enterPassword; - } else if(password !== passwordDouble) { - errorMessage = MEMBER_MESSAGE.wrongPassword; - } else { - errorMessage = ''; - } - return errorMessage; - }, -} \ No newline at end of file diff --git a/src/utils/validate.ts b/src/utils/validate.ts new file mode 100644 index 00000000..90fdbf3b --- /dev/null +++ b/src/utils/validate.ts @@ -0,0 +1,68 @@ +export const validationRules = { + email: { + required: '이메일은 필수입니다.', + pattern: { + value: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, + message: '유효한 이메일을 입력해주세요.', + }, + }, + nickname: { + required: '닉네임을 입력해주세요.', + pattern: { + value: /^([a-z]|[A-Z]|[0-9]|[!@#$%^&*])+$/, + message: '영문, 숫자, 특수문자(!@#$%^&*)만 입력 가능합니다.', + }, + minLength: { + value: 1, + message: '1자 이상 입력해주세요.', + }, + maxLength: { + value: 20, + message: '20자 이하로 입력해주세요.', + }, + }, + password: { + required: '비밀번호는 필수입니다.', + minLength: { + value: 8, + message: '8자 이상 입력해주세요.', + }, + maxLength: { + value: 20, + message: '20자 이하로 입력해주세요.', + }, + }, + passwordConfirmation: (password: string) => ({ + required: '비밀번호 확인은 필수입니다.', + validate: (value: string) => value === password || '비밀번호가 일치하지 않습니다.', + }), + productName: { + required: '상품 이름은 필수입니다.', + minLength: { + value: 1, + message: '상품 이름은 1자 이상 입력해주세요.', + }, + maxLength: { + value: 30, + message: '상품 이름은 30자 이하로 입력해주세요.', + }, + }, + content: { + required: '내용은 필수입니다.', + minLength: { + value: 1, + message: '내용을 입력해주세요.', + }, + }, + title: { + required: '제목은 필수입니다.', + minLength: { + value: 1, + message: '제목은 1자 이상 입력해주세요.', + }, + maxLength: { + value: 50, + message: '제목은 50자 이하로 입력해주세요.', + }, + }, +};