From 140be265daa20cecbe800eebedd33084d9f7e5ad Mon Sep 17 00:00:00 2001 From: song mijin Date: Thu, 15 May 2025 18:25:07 +0900 Subject: [PATCH 1/9] =?UTF-8?q?refactor:=20searchParams=EB=A5=BC=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20=EA=B4=80=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 19 ++- package.json | 3 +- src/app/items/page.tsx | 2 - src/app/login/page.tsx | 42 ++++--- src/app/signup/page.tsx | 21 ++-- .../ItemsDetail/comment/CommentList.tsx | 41 ++---- src/components/Product/AllItems.module.css | 85 ------------- src/components/Product/AllItems.tsx | 118 ++++++------------ src/components/Product/BestItems.tsx | 30 ++--- src/components/Product/ProductSearchBox.tsx | 37 ++++++ src/components/ui/LikeButton.tsx | 6 + src/components/ui/SelectBox.tsx | 4 +- src/components/ui/Title.module.css | 11 -- src/components/ui/Title.tsx | 9 +- src/constants/product.constants.ts | 21 ++++ src/hooks/useItemQuery.tsx | 44 +------ src/hooks/useProductsComments.tsx | 33 ++++- 17 files changed, 213 insertions(+), 313 deletions(-) delete mode 100644 src/components/Product/AllItems.module.css create mode 100644 src/components/Product/ProductSearchBox.tsx delete mode 100644 src/components/ui/Title.module.css create mode 100644 src/constants/product.constants.ts 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/src/app/items/page.tsx b/src/app/items/page.tsx index 8a49f4f1..1dc599ea 100644 --- a/src/app/items/page.tsx +++ b/src/app/items/page.tsx @@ -1,5 +1,3 @@ -'use client'; - import React from 'react'; import Container from 'components/layout/Container'; import Title from 'components/ui/Title'; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index a4a427f3..b734e577 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -26,29 +26,35 @@ function Login() { login({ email, password }); }; - // input이 Blur될때 email,password state 변경 및 UserChecked state 표시 + const setters: Record>> = { + login_email: setEmail, + login_pwd: setPassword, + }; + + // input이 Blur될때 email,password state 변경 및 UserChecked state 표시 const handleInputBlur = (e: React.FocusEvent) => { - 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 { 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_pwd') error = memberCheck.passwordChecked(value); + + setErrorCase(prev => ({ + ...prev, + [id.replace('login_', '')]: error, // email, name, password, pwdCheck에 매핑 + })); + }; - const handleEyeClick = () => setPasswordBoxType(!passwordBoxType); + const handleEyeClick = () => setPasswordBoxType(!passwordBoxType); const isFormValid = email && password && errorCase.email === '' && errorCase.password === ''; + + + return (
diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 8560e56b..1dc8c57a 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -2,7 +2,7 @@ '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'; @@ -10,7 +10,7 @@ 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'; function Login() { @@ -25,14 +25,14 @@ function Login() { const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); const { mutate: signUp, isPending } = useSignUp(openConfirmModal); -const handleSignUp = () => { - signUp({ - email, - nickname, - password, - passwordConfirmation: pwdCheck, - }); -}; + const handleSignUp = () => { + signUp({ + email, + nickname, + password, + passwordConfirmation: pwdCheck, + }); + }; const setters: Record>> = { login_email: setEmail, login_name: setNickname, @@ -59,7 +59,6 @@ const handleSignUp = () => { })); }; - const handleEyeClick = () => setPasswordBoxType(!passwordBoxType); const handleEyePwdCheck = () => setPwdCheckBoxType(!pwdCheckBoxType); diff --git a/src/components/ItemsDetail/comment/CommentList.tsx b/src/components/ItemsDetail/comment/CommentList.tsx index b0b58379..fcab1f11 100644 --- a/src/components/ItemsDetail/comment/CommentList.tsx +++ b/src/components/ItemsDetail/comment/CommentList.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useRef } from 'react'; -import { useInfiniteProductsComments } from '@/hooks/useProductsComments'; +import React from 'react'; +import { useInfiniteProductsCommentsWithObserver } from '@/hooks/useProductsComments'; import CommentItem from './CommentItem'; import LoadingBox from '@/components/ui/LoadingBox'; import EmptyBox from '@/components/ui/EmptyBox'; @@ -8,41 +8,16 @@ import EmptyBox from '@/components/ui/EmptyBox'; 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(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 ( <> 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..3ae1c693 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 ProductSearchBox from "./ProductSearchBox"; +import Title from "../ui/Title"; +import { ORDER_OPTIONS, orderByType, VISIBLE_ITEMS } from "@/constants/product.constants"; +import { usePushQueryToURL } from "@/hooks/useItemQuery"; -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 ( <> - -
-
-
전체상품
-
-
-
-
- - { - if (e.key === "Enter") { - e.preventDefault(); - handleKeywordChange((e.target as HTMLInputElement).value); - } - }} - /> -
-
- - - - -
-
+ + + <ProductSearchBox 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 표시 */} 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/ProductSearchBox.tsx b/src/components/Product/ProductSearchBox.tsx new file mode 100644 index 00000000..4041f688 --- /dev/null +++ b/src/components/Product/ProductSearchBox.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import styles from './ProductSearchBox.module.css'; // 필요하다면 별도 스타일 분리 가능 +import Icon from '../ui/Icon'; + +type ProductSearchBoxProps = { + onSearch: (keyword: string) => void; +}; + +export default function ProductSearchBox({ onSearch }: ProductSearchBoxProps) { + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + onSearch((e.target as HTMLInputElement).value); + } + }; + + return ( +
+
+ + +
+
+ ); +} diff --git a/src/components/ui/LikeButton.tsx b/src/components/ui/LikeButton.tsx index d68843ad..f663ea86 100644 --- a/src/components/ui/LikeButton.tsx +++ b/src/components/ui/LikeButton.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import { useToggleProductFavorite } from "@/hooks/useItems"; import ConfirmModal from "./ConfirmModal"; import { useConfirmModal } from "@/hooks/useModal"; +import { useAuth } from "@/contexts/AuthContext"; interface LikeButtonProps { className?: string; @@ -24,6 +25,7 @@ function LikeButton({ ...restProps } : LikeButtonProps) { + const { user } = useAuth(); const [isFavorited, setIsFavorited] = useState(isFavorite); const [count, setCount ] = useState(favoriteCount); @@ -32,6 +34,10 @@ function LikeButton({ const { mutate: toggleFavorite } = useToggleProductFavorite(openConfirmModal); const handleClick = () => { + if(!user) { + openConfirmModal('로그인 후 이용 가능합니다.'); + return; + } toggleFavorite({ productId, isFavorited ,setIsFavorited, setCount}); }; 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 (
+ +
diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 1dc8c57a..de83cdf8 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -4,7 +4,6 @@ import React from 'react'; import Link from 'next/link'; 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'; @@ -12,121 +11,98 @@ import FormField from '@/components/ui/form/FormField'; import { useSignUp } from '@/hooks/useAuth'; import { useConfirmModal } from '@/hooks/useModal'; import ConfirmModal from '@/components/ui/ConfirmModal'; +import { useForm, SubmitHandler } from 'react-hook-form'; +import { validationRules } from '@/utils/auth'; + +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>> = { - 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) => { - const { id, value } = e.target; + const { + register, + handleSubmit, + watch, + formState: { errors, isValid, isDirty }, + } = useForm({ + mode: 'onBlur', // blur 시 유효성 검사 + }); - 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 상태 사용 - - setErrorCase(prev => ({ - ...prev, - [id.replace('login_', '')]: error, // email, name, password, pwdCheck에 매핑 - })); + const password = watch('password'); + + const onSubmit: SubmitHandler = (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 (
- + +
+ - + - - - - - + + + + +
diff --git a/src/components/ItemsDetail/ProductDetails.tsx b/src/components/ItemsDetail/ProductDetails.tsx index 20e8123e..7d473fdf 100644 --- a/src/components/ItemsDetail/ProductDetails.tsx +++ b/src/components/ItemsDetail/ProductDetails.tsx @@ -14,7 +14,7 @@ function ProductDetails( ) { const { id } = useParams(); // URL에서 [id] 추출 const productId = Number(id); - const { data, isLoading, isError } = useProductsDetails(productId); + const { data } = useProductsDetails(productId); return (
diff --git a/src/components/ui/form/FormField.tsx b/src/components/ui/form/FormField.tsx index 342be083..27b2ad2e 100644 --- a/src/components/ui/form/FormField.tsx +++ b/src/components/ui/form/FormField.tsx @@ -1,62 +1,51 @@ 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) => void; + placeholder?: string; + error?: string; withEyeToggle?: boolean; eyeState?: boolean; onEyeToggle?: () => void; - eyeIconOpen?: string; - eyeIconClose?: string; -} +} & React.InputHTMLAttributes; export default function FormField({ id, label, - type, - placeholder, error, - onBlur, - withEyeToggle = false, - eyeState = true, + withEyeToggle, + eyeState, onEyeToggle, - eyeIconOpen = eyeOpen, - eyeIconClose = eyeClose, + ...inputProps }: FormFieldProps) { + + return ( - +
); } 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/hooks/useProductsComments.tsx b/src/hooks/useProductsComments.tsx index 62200e9c..c55b0ab2 100644 --- a/src/hooks/useProductsComments.tsx +++ b/src/hooks/useProductsComments.tsx @@ -30,7 +30,7 @@ export interface GetCommentsQuery { cursor?: number; } -export function useInfiniteProductsCommentsWithObserver(productId: number, limit = 10) { +export function useInfiniteProductsCommentsWithObserver(productId: number, limit = 10) { // 무한로딩 const queryResult = useInfiniteQuery({ queryKey: ['productComments', productId], queryFn: async ({ pageParam }) => { diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 1d8cd7df..90fdbf3b 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1,62 +1,68 @@ - -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; +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자 이하로 입력해주세요.', + }, }, - EmailChecked : (email: string) => { - let errorMessage = ''; - if(email === '') { - errorMessage = MEMBER_MESSAGE.enterEmail; - } else if ( !memberCheck.checkEmail(email)) { - errorMessage = MEMBER_MESSAGE.wrongEmail; - } else { - errorMessage = ''; - } - return errorMessage; + password: { + required: '비밀번호는 필수입니다.', + minLength: { + value: 8, + message: '8자 이상 입력해주세요.', + }, + maxLength: { + value: 20, + message: '20자 이하로 입력해주세요.', + }, }, - NameChecked: (name: string) => { - let errorMessage = ''; - if(name === '') { - errorMessage = MEMBER_MESSAGE.enterName; - } else { - errorMessage = ''; - } - return errorMessage; + passwordConfirmation: (password: string) => ({ + required: '비밀번호 확인은 필수입니다.', + validate: (value: string) => value === password || '비밀번호가 일치하지 않습니다.', + }), + productName: { + required: '상품 이름은 필수입니다.', + minLength: { + value: 1, + message: '상품 이름은 1자 이상 입력해주세요.', + }, + maxLength: { + value: 30, + message: '상품 이름은 30자 이하로 입력해주세요.', + }, }, - passwordChecked: (password: string) => { - let errorMessage = ''; - if(password === '') { - errorMessage = MEMBER_MESSAGE.enterPassword; - } else if ( !memberCheck.checkPassword(password)) { - errorMessage = MEMBER_MESSAGE.checkPassword; - } else { - errorMessage = ''; - } - return errorMessage; + content: { + required: '내용은 필수입니다.', + minLength: { + value: 1, + message: '내용을 입력해주세요.', + }, }, - 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; + title: { + required: '제목은 필수입니다.', + minLength: { + value: 1, + message: '제목은 1자 이상 입력해주세요.', + }, + maxLength: { + value: 50, + message: '제목은 50자 이하로 입력해주세요.', + }, }, -} \ No newline at end of file +}; From b1c72be7322f93b3f9cc7ded9ce3b43acbb2eb72 Mon Sep 17 00:00:00 2001 From: song mijin Date: Sun, 18 May 2025 01:08:49 +0900 Subject: [PATCH 3/9] =?UTF-8?q?wip:=20CSR=20=EA=B5=AC=EC=A1=B0=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=20=EC=A0=84=20=EB=B0=B1=EC=97=85=20=EC=A4=80=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/boards/apply/page.tsx | 11 ++ src/app/boards/page.tsx | 17 ++- src/app/items/[id]/page.tsx | 2 +- src/app/login/page.tsx | 2 +- src/app/signup/page.tsx | 2 +- src/components/Article/ArticleList.tsx | 107 ++++++++++++++++++ src/components/Article/ArticleListItem.tsx | 48 ++++++++ src/components/Article/BestArticleItem.tsx | 11 ++ src/components/Article/BestArticleList.tsx | 47 ++++++++ src/components/Product/AllItems.tsx | 9 +- src/components/Product/ProductItem.tsx | 8 +- .../ProductDescription.module.css | 0 .../ProductDescription.tsx | 8 +- .../ProductDetails.module.css | 0 .../ProductDetails.tsx | 2 +- .../ProductOverview.module.css | 0 .../ProductOverview.tsx | 0 .../comment/CommentForm.tsx | 0 .../comment/CommentItem.tsx | 0 .../comment/CommentList.tsx | 0 .../comment/CommentSection.tsx | 0 src/components/ui/LikeButton.tsx | 8 +- .../form/SearchBox.tsx} | 13 ++- src/constants/product.constants.ts | 12 +- src/hooks/useArticles.ts | 103 +++++++++++++++++ src/hooks/useItems.tsx | 9 +- src/utils/{auth.ts => validate.ts} | 0 27 files changed, 390 insertions(+), 29 deletions(-) create mode 100644 src/app/boards/apply/page.tsx create mode 100644 src/components/Article/ArticleList.tsx create mode 100644 src/components/Article/ArticleListItem.tsx create mode 100644 src/components/Article/BestArticleItem.tsx create mode 100644 src/components/Article/BestArticleList.tsx rename src/components/{ItemsDetail => productDetail}/ProductDescription.module.css (100%) rename src/components/{ItemsDetail => productDetail}/ProductDescription.tsx (84%) rename src/components/{ItemsDetail => productDetail}/ProductDetails.module.css (100%) rename src/components/{ItemsDetail => productDetail}/ProductDetails.tsx (96%) rename src/components/{ItemsDetail => productDetail}/ProductOverview.module.css (100%) rename src/components/{ItemsDetail => productDetail}/ProductOverview.tsx (100%) rename src/components/{ItemsDetail => productDetail}/comment/CommentForm.tsx (100%) rename src/components/{ItemsDetail => productDetail}/comment/CommentItem.tsx (100%) rename src/components/{ItemsDetail => productDetail}/comment/CommentList.tsx (100%) rename src/components/{ItemsDetail => productDetail}/comment/CommentSection.tsx (100%) rename src/components/{Product/ProductSearchBox.tsx => ui/form/SearchBox.tsx} (70%) create mode 100644 src/hooks/useArticles.ts rename src/utils/{auth.ts => validate.ts} (100%) diff --git a/src/app/boards/apply/page.tsx b/src/app/boards/apply/page.tsx new file mode 100644 index 00000000..3d281f67 --- /dev/null +++ b/src/app/boards/apply/page.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +function PostArticles() { + return ( + <> +

PostArticles

+ + ); +} + +export default PostArticles; \ No newline at end of file diff --git a/src/app/boards/page.tsx b/src/app/boards/page.tsx index 69b3cc18..bb7d354f 100644 --- a/src/app/boards/page.tsx +++ b/src/app/boards/page.tsx @@ -1,14 +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 Boards() { return ( <> -

Boards

+ + + </Container> + <BestArticleList /> + <ArticleList /> </> ); } -export default Boards; \ No newline at end of file +export default Boards; + diff --git a/src/app/items/[id]/page.tsx b/src/app/items/[id]/page.tsx index 237bb5e4..2851d653 100644 --- a/src/app/items/[id]/page.tsx +++ b/src/app/items/[id]/page.tsx @@ -1,7 +1,7 @@ 'use client'; import React from 'react'; import styles from './ItemsDetail.module.css'; -import ProductDetails from '@/components/ItemsDetail/ProductDetails'; +import ProductDetails from '@/components/productDetail/ProductDetails'; function ItemsDetail() { diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 363220a6..313d5e23 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -12,7 +12,7 @@ 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/auth'; +import { validationRules } from '@/utils/validate'; type FormValues = { email: string; diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index de83cdf8..c70af1d7 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -12,7 +12,7 @@ import { useSignUp } from '@/hooks/useAuth'; import { useConfirmModal } from '@/hooks/useModal'; import ConfirmModal from '@/components/ui/ConfirmModal'; import { useForm, SubmitHandler } from 'react-hook-form'; -import { validationRules } from '@/utils/auth'; +import { validationRules } from '@/utils/validate'; type FormValues = { email: string; diff --git a/src/components/Article/ArticleList.tsx b/src/components/Article/ArticleList.tsx new file mode 100644 index 00000000..59560fdc --- /dev/null +++ b/src/components/Article/ArticleList.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { orderByType, POST_OPTIONS, postByType } from "@/constants/product.constants"; +import { useAuth } from "@/contexts/AuthContext"; +import { PostListQuery, useInfiniteArticles } from "@/hooks/useArticles"; +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"; + + +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 } = 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('articles/apply'); + }; + return ( + <> + <Container className='relative z-20'> + <Title titleTag='h2' text='게시글'> + <Button + className="absolute right-0 top-0" + variant="roundedSS" + onClick={handleApplyClick} + > + 글쓰기 + </Button> + +
+ handleKeywordChange(keyword)} /> + +
+
+ + + { 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..43b60d78 --- /dev/null +++ b/src/components/Article/ArticleListItem.tsx @@ -0,0 +1,48 @@ +import { PostItem } from '@/hooks/useArticles'; +import React from 'react'; +import LikeButton from '../ui/LikeButton'; +import UserInfo from '../ui/UserInfo'; +import { formatDate } from '@/utils/date'; +import { FallbackImage } from '../FallbackImage/FallbackImage'; + +interface ArticleListItemProps { + postItem: PostItem +} +function ArticleListItem({ postItem }: ArticleListItemProps) { + + // '2025-04-08T01:00:06+09:00' '2025-04-07T01:00:06+09:00' + const createdAtString = formatDate(postItem.createdAt); + // console.log(createdAtString); + return ( + <> +
  • +
    +
    +
    {postItem.title}
    +
    +
    +
    +
    + + +
    +
    +
  • + + ); +} + +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..87597c7a --- /dev/null +++ b/src/components/Article/BestArticleItem.tsx @@ -0,0 +1,11 @@ + + +function BestArticleItem() { + + return ( +
  • +
  • + ); +} + +export default BestArticleItem; diff --git a/src/components/Article/BestArticleList.tsx b/src/components/Article/BestArticleList.tsx new file mode 100644 index 00000000..ec765f3a --- /dev/null +++ b/src/components/Article/BestArticleList.tsx @@ -0,0 +1,47 @@ + +import { BEST_POST_ITEMS, BEST_VISIBLE_ITEMS } from "@/constants/product.constants"; +import { PostListQuery, useArticlesList } from "@/hooks/useArticles"; +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"; + + +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) => ( +
    {article.title}----{article.likeCount}
    + )) + ) : ( + + ) + )} +
    + ); +} + +export default BestArticleList; diff --git a/src/components/Product/AllItems.tsx b/src/components/Product/AllItems.tsx index 3ae1c693..282f3462 100644 --- a/src/components/Product/AllItems.tsx +++ b/src/components/Product/AllItems.tsx @@ -13,10 +13,11 @@ import EmptyBox from "../ui/EmptyBox"; import { useAuth } from "@/contexts/AuthContext"; import { useConfirmModal } from "@/hooks/useModal"; import ConfirmModal from "../ui/ConfirmModal"; -import ProductSearchBox from "./ProductSearchBox"; import Title from "../ui/Title"; import { ORDER_OPTIONS, orderByType, VISIBLE_ITEMS } from "@/constants/product.constants"; import { usePushQueryToURL } from "@/hooks/useItemQuery"; +import ProductSearchBox from "../ui/form/SearchBox"; +import SearchBox from "../ui/form/SearchBox"; export function AllItems() { @@ -29,7 +30,7 @@ export function AllItems() { 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, + orderBy: (searchParams.get("orderBy") ?? 'recent') as orderByType, keyword: searchParams.get("keyword") ?? '', }; @@ -82,7 +83,7 @@ export function AllItems() { <> - <ProductSearchBox onSearch={(keyword) => handleKeywordChange(keyword)} /> + <SearchBox onSearch={(keyword) => handleKeywordChange(keyword)} /> <Button className="absolute right-0 top-0" variant="roundedSS" @@ -110,7 +111,7 @@ export function AllItems() { className={Number(data?.list?.length) < Number(query.pageSize) ? 'mb-[141px]' : 'mb-0'} /> ) : ( - <EmptyBox context="아직 해당 상품이 없습니다." className="h-[572px] mb-[141px]" /> + <EmptyBox context="해당 상품이 없습니다." className="h-[572px] mb-[141px]" /> )} {/* 🔹 페이지네이션 */} diff --git a/src/components/Product/ProductItem.tsx b/src/components/Product/ProductItem.tsx index ecfd7aa3..af7a650d 100644 --- a/src/components/Product/ProductItem.tsx +++ b/src/components/Product/ProductItem.tsx @@ -44,7 +44,13 @@ 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 + productId={productId} + favoriteCount={productItem.favoriteCount} + likedMessage = "관심상품 등록되었습니다" + unLikedMessage = "관심상품 취소되었습니다" + isFavorite={isFavorite} + /> </div> </li> ); 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 84% rename from src/components/ItemsDetail/ProductDescription.tsx rename to src/components/productDetail/ProductDescription.tsx index 72ef83c9..cf1599bb 100644 --- a/src/components/ItemsDetail/ProductDescription.tsx +++ b/src/components/productDetail/ProductDescription.tsx @@ -56,7 +56,13 @@ function ProductDescription(detailData:ProductDetail) { <div className={styles.UserInfo}> <UserInfo ownerNickname={ownerNickname} createdAtString={createdAtString}/> <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" + likedMessage = "관심상품 등록되었습니다" + unLikedMessage = "관심상품 취소되었습니다" + productId={detailData.id} + favoriteCount={detailData.favoriteCount} + isFavorite={isFavorite} + childrenClassName='gap-2' width="24" height="24"/> </div> </div> </div> diff --git a/src/components/ItemsDetail/ProductDetails.module.css b/src/components/productDetail/ProductDetails.module.css similarity index 100% rename from src/components/ItemsDetail/ProductDetails.module.css rename to src/components/productDetail/ProductDetails.module.css diff --git a/src/components/ItemsDetail/ProductDetails.tsx b/src/components/productDetail/ProductDetails.tsx similarity index 96% rename from src/components/ItemsDetail/ProductDetails.tsx rename to src/components/productDetail/ProductDetails.tsx index 7d473fdf..ef509a71 100644 --- a/src/components/ItemsDetail/ProductDetails.tsx +++ b/src/components/productDetail/ProductDetails.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState } from 'react'; +import React from 'react'; import ProductOverview from './ProductOverview'; import ProductDescription from './ProductDescription'; import Container from 'components/layout/Container'; 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 100% rename from src/components/ItemsDetail/comment/CommentForm.tsx rename to src/components/productDetail/comment/CommentForm.tsx diff --git a/src/components/ItemsDetail/comment/CommentItem.tsx b/src/components/productDetail/comment/CommentItem.tsx similarity index 100% rename from src/components/ItemsDetail/comment/CommentItem.tsx rename to src/components/productDetail/comment/CommentItem.tsx diff --git a/src/components/ItemsDetail/comment/CommentList.tsx b/src/components/productDetail/comment/CommentList.tsx similarity index 100% rename from src/components/ItemsDetail/comment/CommentList.tsx rename to src/components/productDetail/comment/CommentList.tsx 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/LikeButton.tsx b/src/components/ui/LikeButton.tsx index f663ea86..8233059f 100644 --- a/src/components/ui/LikeButton.tsx +++ b/src/components/ui/LikeButton.tsx @@ -21,6 +21,8 @@ function LikeButton({ favoriteCount, isFavorite, variant="btn-heart_S", + likedMessage = "", + unLikedMessage = "", width = 16, height = 16 , ...restProps } : LikeButtonProps) { @@ -31,7 +33,11 @@ function LikeButton({ const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); - const { mutate: toggleFavorite } = useToggleProductFavorite(openConfirmModal); + const { mutate: toggleFavorite } = useToggleProductFavorite(openConfirmModal, { + onSuccess: (data) => { + openConfirmModal(data.isFavorited ? likedMessage : unLikedMessage); + }, +}); const handleClick = () => { if(!user) { diff --git a/src/components/Product/ProductSearchBox.tsx b/src/components/ui/form/SearchBox.tsx similarity index 70% rename from src/components/Product/ProductSearchBox.tsx rename to src/components/ui/form/SearchBox.tsx index 4041f688..7e4bf2e1 100644 --- a/src/components/Product/ProductSearchBox.tsx +++ b/src/components/ui/form/SearchBox.tsx @@ -1,12 +1,13 @@ import React from 'react'; -import styles from './ProductSearchBox.module.css'; // 필요하다면 별도 스타일 분리 가능 -import Icon from '../ui/Icon'; +import Icon from '../Icon'; +import clsx from 'clsx'; type ProductSearchBoxProps = { onSearch: (keyword: string) => void; + className?: string; }; -export default function ProductSearchBox({ onSearch }: ProductSearchBoxProps) { +export default function SearchBox({ onSearch , className}: ProductSearchBoxProps) { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === 'Enter') { e.preventDefault(); @@ -15,7 +16,7 @@ export default function ProductSearchBox({ onSearch }: ProductSearchBoxProps) { }; return ( - <form className='mobile:w-full'> + <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 @@ -25,10 +26,10 @@ export default function ProductSearchBox({ onSearch }: ProductSearchBoxProps) { onKeyDown={handleKeyDown} className=" border-0 leading-[26px] - h-[42px] w-[325px] bg-gray-100 + 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 mobile:w-full + focus:outline-0 hover:outline-0 active:outline-0 w-full " /> </div> diff --git a/src/constants/product.constants.ts b/src/constants/product.constants.ts index 09898efb..489e27d4 100644 --- a/src/constants/product.constants.ts +++ b/src/constants/product.constants.ts @@ -1,4 +1,3 @@ -import { ProductQuery } from "@/hooks/useItems"; // 일반 상품리스트 기본값 export type orderByType = "recent" | "favorite"; @@ -18,4 +17,15 @@ import { ProductQuery } from "@/hooks/useItems"; 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..9dc39e9b --- /dev/null +++ b/src/hooks/useArticles.ts @@ -0,0 +1,103 @@ +import { requestor } from "@/lib/requestor"; +import { keepPreviousData, useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; + + +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 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 type PostDetail = { + id: number; + title: string; + content: string; + image: string; + likeCount: number; + isLiked: boolean; + createdAt: string; // ISO 날짜 문자열 + updatedAt: string; // ISO 날짜 문자열 + writer: { + id: number; + nickname: string; + }; +}; + +interface ProductFavoriteResponse { + productId: number; + isFavorited: boolean; + setIsFavorited: (value: boolean) => void, + setCount: (value: number | ((prev: number) => number)) => void +} diff --git a/src/hooks/useItems.tsx b/src/hooks/useItems.tsx index 7e59f207..70b38c92 100644 --- a/src/hooks/useItems.tsx +++ b/src/hooks/useItems.tsx @@ -88,9 +88,9 @@ interface ProductFavoriteResponse { 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 ) => { @@ -121,9 +121,6 @@ export const useToggleProductFavorite = ( ); } }, - onSuccess: (_, variables) => { - openModal(variables.isFavorited ? '관심상품이 해제되었습니다!' : '관심상품이 등록되었습니다!'); - }, onError: (error) => { const message = (error as any)?.response?.data?.message; if (message?.includes('jwt malformed')) { diff --git a/src/utils/auth.ts b/src/utils/validate.ts similarity index 100% rename from src/utils/auth.ts rename to src/utils/validate.ts From 7b3066167e37532b3c58a1f4bf5e94ab9f37654d Mon Sep 17 00:00:00 2001 From: song mijin <alwls824@gmail.com> Date: Sun, 18 May 2025 03:57:51 +0900 Subject: [PATCH 4/9] feat: misson9 complete --- public/assets/ic_medal.svg | 4 ++ src/app/boards/[id]/page.tsx | 11 +++ src/components/Article/ArticleList.tsx | 48 +++++++------ src/components/Article/ArticleListItem.tsx | 44 ++++++++---- src/components/Article/BestArticleItem.tsx | 69 +++++++++++++++++-- src/components/Article/BestArticleList.tsx | 23 ++++--- .../FallbackImage/FallbackImage.tsx | 21 +----- src/components/Product/ProductItem.tsx | 15 ++-- .../productDetail/ProductDescription.tsx | 15 +++- src/components/ui/BestBadge.tsx | 20 ++++++ src/components/ui/Icon.tsx | 3 + src/components/ui/LikeButton.tsx | 15 ++-- src/components/ui/UserInfo.module.css | 22 ------ src/components/ui/UserInfo.tsx | 14 ++-- src/hooks/useArticles.ts | 45 +++++++++++- src/hooks/useItems.tsx | 8 +-- 16 files changed, 262 insertions(+), 115 deletions(-) create mode 100644 public/assets/ic_medal.svg create mode 100644 src/app/boards/[id]/page.tsx create mode 100644 src/components/ui/BestBadge.tsx 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 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect x="4" y="3" width="8" height="7" rx="2.5" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M11.0912 5.78199L7.35789 9.51532L4.90923 7.06665C4.80464 6.95255 4.74815 6.80247 4.75151 6.64773C4.75488 6.49298 4.81786 6.34551 4.9273 6.23606C5.03675 6.12661 5.18422 6.06364 5.33897 6.06027C5.49371 6.0569 5.64379 6.1134 5.75789 6.21798L7.35789 7.81799L10.2426 4.93332C10.2983 4.87759 10.3644 4.83339 10.4372 4.80323C10.5101 4.77308 10.5881 4.75755 10.6669 4.75755C10.7457 4.75755 10.8237 4.77308 10.8965 4.80323C10.9693 4.83339 11.0355 4.87759 11.0912 4.93332C11.147 4.98904 11.1912 5.0552 11.2213 5.128C11.2515 5.20081 11.267 5.27885 11.267 5.35765C11.267 5.43646 11.2515 5.51449 11.2213 5.5873C11.1912 5.66011 11.147 5.72626 11.0912 5.78199ZM14.0979 7.04665C14.1604 6.98414 14.1955 6.89937 14.1955 6.81099C14.1955 6.7226 14.1604 6.63783 14.0979 6.57532L13.1312 5.60865C13.0898 5.56732 13.06 5.51577 13.0449 5.45922C13.0298 5.40268 13.0299 5.34314 13.0452 5.28665L13.3986 3.96665C13.4214 3.88125 13.4093 3.7903 13.365 3.71378C13.3208 3.63727 13.248 3.58147 13.1626 3.55865L11.8426 3.20532C11.7859 3.19014 11.7342 3.16026 11.6928 3.1187C11.6513 3.07714 11.6216 3.02538 11.6066 2.96865L11.2532 1.64865C11.2303 1.56321 11.1743 1.49037 11.0977 1.44612C11.0211 1.40188 10.93 1.38984 10.8446 1.41265L9.52389 1.76665C9.46744 1.78176 9.408 1.78171 9.35158 1.76649C9.29516 1.75127 9.24376 1.72143 9.20256 1.67998L8.23589 0.713318C8.17338 0.650828 8.08861 0.615723 8.00023 0.615723C7.91184 0.615723 7.82707 0.650828 7.76456 0.713318L6.79789 1.68065C6.75652 1.72204 6.705 1.75183 6.64848 1.76704C6.59197 1.78225 6.53245 1.78235 6.47589 1.76732L5.15589 1.41265C5.11356 1.40126 5.0694 1.39834 5.02594 1.40404C4.98248 1.40974 4.94057 1.42396 4.90261 1.44588C4.86465 1.46781 4.83138 1.497 4.80472 1.53179C4.77806 1.56659 4.75852 1.6063 4.74723 1.64865L4.39323 2.96865C4.37816 3.02518 4.34848 3.07675 4.30717 3.11817C4.26586 3.1596 4.21438 3.18943 4.15789 3.20465L2.83723 3.55865C2.75196 3.58161 2.67928 3.63748 2.63516 3.71397C2.59104 3.79047 2.57907 3.88134 2.60189 3.96665L2.95523 5.28665C2.97055 5.34314 2.97066 5.40268 2.95556 5.45922C2.94046 5.51577 2.91067 5.56732 2.86923 5.60865L1.90256 6.57532C1.87154 6.60623 1.84692 6.64295 1.83013 6.68339C1.81333 6.72384 1.80469 6.76719 1.80469 6.81099C1.80469 6.85478 1.81333 6.89813 1.83013 6.93858C1.84692 6.97902 1.87154 7.01574 1.90256 7.04665L2.86923 8.01332C2.91067 8.05479 2.94045 8.10645 2.95555 8.16311C2.97064 8.21976 2.97053 8.27939 2.95523 8.33598L2.60189 9.65598C2.57907 9.74129 2.59104 9.83217 2.63516 9.90867C2.67928 9.98516 2.75196 10.041 2.83723 10.064L4.15789 10.418C4.21438 10.4332 4.26586 10.463 4.30717 10.5045C4.34848 10.5459 4.37816 10.5975 4.39323 10.654L4.74656 11.974C4.75825 12.0168 4.77837 12.0568 4.80573 12.0917C4.83309 12.1266 4.86714 12.1557 4.90589 12.1773L3.66523 15.522H7.12056L7.99989 13.15L8.88056 15.522H12.3352L11.0946 12.1773C11.1333 12.1558 11.1673 12.1267 11.1946 12.0918C11.2218 12.0569 11.2418 12.0168 11.2532 11.974L11.6066 10.654C11.6216 10.5973 11.6514 10.5457 11.6928 10.5042C11.7343 10.4628 11.7859 10.433 11.8426 10.418L13.1626 10.0647C13.2049 10.0534 13.2446 10.0338 13.2794 10.0072C13.3142 9.98049 13.3434 9.94723 13.3653 9.90927C13.3872 9.87131 13.4015 9.8294 13.4072 9.78594C13.4129 9.74248 13.4099 9.69832 13.3986 9.65598L13.0452 8.33598C13.0299 8.2795 13.0298 8.21996 13.0449 8.16341C13.06 8.10686 13.0898 8.05531 13.1312 8.01398L14.0979 7.04732V7.04665Z" fill="#FFC117"/> +</svg> diff --git a/src/app/boards/[id]/page.tsx b/src/app/boards/[id]/page.tsx new file mode 100644 index 00000000..53d359a2 --- /dev/null +++ b/src/app/boards/[id]/page.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +function PostDetail() { + return ( + <> + <h2>PostDetail</h2> + </> + ); +} + +export default PostDetail; \ No newline at end of file diff --git a/src/components/Article/ArticleList.tsx b/src/components/Article/ArticleList.tsx index 59560fdc..d1ecd2d1 100644 --- a/src/components/Article/ArticleList.tsx +++ b/src/components/Article/ArticleList.tsx @@ -2,7 +2,6 @@ import { orderByType, POST_OPTIONS, postByType } from "@/constants/product.constants"; import { useAuth } from "@/contexts/AuthContext"; -import { PostListQuery, useInfiniteArticles } from "@/hooks/useArticles"; import { useBreakpoint } from "@/hooks/useBreakpoint"; import { usePushQueryToURL } from "@/hooks/useItemQuery"; import { useConfirmModal } from "@/hooks/useModal"; @@ -16,6 +15,7 @@ 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() { @@ -34,7 +34,7 @@ export function ArticleList() { }; const pushQueryToURL = usePushQueryToURL(); - const { data, handleLoadMore, hasNextPage, isFetchingNextPage } = useInfiniteArticles(query); + const { data, handleLoadMore, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteArticles(query); const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); // SelectBox handle @@ -79,26 +79,32 @@ export function ArticleList() { </div> </Container> - <Container className="mb-[141px]"> - { data ? ( - <div> - <ul> - {data?.pages.flatMap((page) => ( - page.list.map((article) => ( - <ArticleListItem key={article.id} postItem={article}/> - )) - ))} - </ul> - {isFetchingNextPage && <LoadingBox className="h-[572px]" />} - {hasNextPage && ( - <button onClick={handleLoadMore} disabled={isFetchingNextPage} className="mt-4 bg-blue-500 text-white px-4 py-2 rounded"> - 더보기 - </button> - )} - </div> - ) : ( + <Container className="mb-[141px]"> + {isLoading ? ( + <LoadingBox className="h-[572px] mb-[141px]"/> + ) : ( + data ? ( + <div> + <ul> + {data?.pages.flatMap((page) => ( + page.list.map((article) => ( + <ArticleListItem key={article.id} postItem={article}/> + )) + ))} + </ul> + {isFetchingNextPage && <LoadingBox className="h-[572px]" />} + {hasNextPage && ( + <div className="text-center"> + <button onClick={handleLoadMore} disabled={isFetchingNextPage} className="mt-4 bg-blue-500 text-white px-10 py-3 rounded"> + 게시물 더보기 + </button> + </div> + )} + </div> + ) : ( <EmptyBox context="해당 게시물이 없습니다." className="h-[572px]" /> - )} + ) + )} </Container> <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> </> diff --git a/src/components/Article/ArticleListItem.tsx b/src/components/Article/ArticleListItem.tsx index 43b60d78..30435caf 100644 --- a/src/components/Article/ArticleListItem.tsx +++ b/src/components/Article/ArticleListItem.tsx @@ -1,44 +1,58 @@ -import { PostItem } from '@/hooks/useArticles'; -import React from 'react'; +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 ( <> - <li> - <div className="flex justify-between w-full my-6 mx-auto"> - <div> - <div>{postItem.title}</div> - <div className="relative w-[72px] border border-secondary-100 rounded-lg aspect-[1/1]"> + <li className="border-b border-secondary-200 my-6 pb-6"> + <div className="flex w-full flex-col gap-4"> + <div className="flex justify-between w-full"> + <div className="font-semibold text-lg"><a href={href}>{postItem.title}</a></div> + <div className="relative w-[72px] border border-secondary-100 rounded-lg overflow-hidden aspect-[1/1]"> <FallbackImage src={postItem.image} alt={postItem.content} - fill - priority 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 $`} aria-hidden="true" /> </div> </div> - <div> - <UserInfo ownerNickname={postItem.writer.nickname} createdAtString={createdAtString} fontSize='12px'/> + <div className="flex justify-between w-full"> + <UserInfo ownerNickname={postItem.writer.nickname} createdAtString={createdAtString} width={24} className="gap-[8px]" childrenClassName="!flex-row items-center" fontSize='12px'/> <LikeButton - productId={postItem.id} + id={postItem.id} favoriteCount={postItem.likeCount} - likedMessage = "관심 게시물 등록되었습니다" - unLikedMessage = "관심 게시물 취소되었습니다"/> + toggleFavorite={toggleFavorite} + isFavorite={false} + /> </div> + <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> </div> </li> </> diff --git a/src/components/Article/BestArticleItem.tsx b/src/components/Article/BestArticleItem.tsx index 87597c7a..eacd19d9 100644 --- a/src/components/Article/BestArticleItem.tsx +++ b/src/components/Article/BestArticleItem.tsx @@ -1,10 +1,71 @@ +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"; -function BestArticleItem() { - +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 ( - <li className=""> - </li> + <> + <li className="relative flex-1 bg-secondary-50 rounded-lg pt-[46px] pb-[9px] px-6"> + + <div className="flex w-full flex-col gap-4"> + <BestBadge className="absolute top-0 left-6"/> + <div className="flex justify-between w-full"> + <div className="font-semibold text-lg"> <a href={href}>{postItem.title}</a></div> + <div className="relative w-[72px] border border-secondary-100 rounded-lg overflow-hidden aspect-[1/1]"> + <FallbackImage + src={postItem.image} + alt={postItem.content} + sizes="sm:100vw, 33vw" + className={`absolute inset-0 object-cover scale-105 transition-opacity duration-300 $`} + aria-hidden="true" + /> + </div> + </div> + <div className="flex justify-between w-full"> + <div className="flex gap-2"> + <span className="text-sm text-secondary-600">{postItem.writer.nickname}</span> + <LikeButton + id={postItem.id} + favoriteCount={postItem.likeCount} + toggleFavorite={toggleFavorite} + isFavorite={false} + /> + </div> + <div className="text-sm text-secondary-400"> + {createdAtString} + </div> + </div> + <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> + </div> + </li> + </> ); } diff --git a/src/components/Article/BestArticleList.tsx b/src/components/Article/BestArticleList.tsx index ec765f3a..37e1177d 100644 --- a/src/components/Article/BestArticleList.tsx +++ b/src/components/Article/BestArticleList.tsx @@ -1,11 +1,12 @@ - +'use client'; import { BEST_POST_ITEMS, BEST_VISIBLE_ITEMS } from "@/constants/product.constants"; -import { PostListQuery, useArticlesList } from "@/hooks/useArticles"; 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() { @@ -28,15 +29,21 @@ function BestArticleList() { }, [breakpoint]); return ( - <Container className="mb-[141px]"> + <Container className="mb-[24px]"> {isLoading ? ( <LoadingBox className="h-[178px]"/> - ) : ( - data ? ( data.list.map((article) => ( - <div key={article.id}>{article.title}----{article.likeCount}</div> - )) - ) : ( + ) : ( + data ? ( + <div> + <ul className="flex gap-6"> + {data.list.map((article) => ( + <BestArticleItem key={article.id} postItem={article}/> + )) + } + </ul> + </div> + ) : ( <EmptyBox context="해당 게시물이 없습니다." className="h-[572px]" /> ) )} diff --git a/src/components/FallbackImage/FallbackImage.tsx b/src/components/FallbackImage/FallbackImage.tsx index b66e6c57..e50ffba6 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; @@ -46,7 +46,7 @@ export const FallbackImage = ({ fill priority 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 +65,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/Product/ProductItem.tsx b/src/components/Product/ProductItem.tsx index af7a650d..5ac4c33a 100644 --- a/src/components/Product/ProductItem.tsx +++ b/src/components/Product/ProductItem.tsx @@ -27,7 +27,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 ( @@ -45,13 +52,13 @@ function ProductItem({productItem}: ProductItemProps) { <div className={styles.price}>{productItem.price?.toLocaleString()}원</div> <LikeButton - productId={productId} + id={productId} favoriteCount={productItem.favoriteCount} - likedMessage = "관심상품 등록되었습니다" - unLikedMessage = "관심상품 취소되었습니다" - isFavorite={isFavorite} + toggleFavorite={toggleFavorite} + isFavorite={isFavorite} /> </div> + <ConfirmModal isOpen={isConfirmOpen} onClose={closeConfirmModal} errorMessage={confirmMessage} /> </li> ); } diff --git a/src/components/productDetail/ProductDescription.tsx b/src/components/productDetail/ProductDescription.tsx index cf1599bb..94cbfdfa 100644 --- a/src/components/productDetail/ProductDescription.tsx +++ b/src/components/productDetail/ProductDescription.tsx @@ -8,6 +8,9 @@ import clsx from 'clsx'; import { ProductDetail } from '@/hooks/useProductsDetail'; import LikeButton from '../ui/LikeButton'; import { useGetUserFavorites } from '@/hooks/useUser'; +import { useToggleProductFavorite } from '@/hooks/useItems'; +import { useConfirmModal } from '@/hooks/useModal'; +import ConfirmModal from '../ui/ConfirmModal'; function ProductDescription(detailData:ProductDetail) { @@ -28,6 +31,12 @@ function ProductDescription(detailData:ProductDetail) { // '2025-04-08T01:00:06+09:00' '2025-04-07T01:00:06+09:00' 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}> @@ -57,14 +66,14 @@ function ProductDescription(detailData:ProductDetail) { <UserInfo ownerNickname={ownerNickname} createdAtString={createdAtString}/> <div className={styles.likeBtnBox}> <LikeButton variant="btn-heart_L" - likedMessage = "관심상품 등록되었습니다" - unLikedMessage = "관심상품 취소되었습니다" - productId={detailData.id} + id={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/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/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 8233059f..fd735489 100644 --- a/src/components/ui/LikeButton.tsx +++ b/src/components/ui/LikeButton.tsx @@ -2,11 +2,11 @@ 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; @@ -15,14 +15,13 @@ interface LikeButtonProps { [key: string]: any; } function LikeButton({ - productId, + id, className, childrenClassName, favoriteCount, isFavorite, variant="btn-heart_S", - likedMessage = "", - unLikedMessage = "", + toggleFavorite, width = 16, height = 16 , ...restProps } : LikeButtonProps) { @@ -33,18 +32,14 @@ function LikeButton({ const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); - const { mutate: toggleFavorite } = useToggleProductFavorite(openConfirmModal, { - onSuccess: (data) => { - openConfirmModal(data.isFavorited ? likedMessage : unLikedMessage); - }, -}); + const handleClick = () => { if(!user) { openConfirmModal('로그인 후 이용 가능합니다.'); return; } - toggleFavorite({ productId, isFavorited ,setIsFavorited, setCount}); + toggleFavorite({ id, isFavorited ,setIsFavorited, setCount}); }; return ( diff --git a/src/components/ui/UserInfo.module.css b/src/components/ui/UserInfo.module.css index bd3dfd41..7669547a 100644 --- a/src/components/ui/UserInfo.module.css +++ b/src/components/ui/UserInfo.module.css @@ -1,28 +1,6 @@ .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); diff --git a/src/components/ui/UserInfo.tsx b/src/components/ui/UserInfo.tsx index 8cc98acf..9d05ef6e 100644 --- a/src/components/ui/UserInfo.tsx +++ b/src/components/ui/UserInfo.tsx @@ -9,15 +9,21 @@ 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, fontSize = "14px", 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> + <div className={clsx(styles.userInfo, `text-[${fontSize}]`,className)}> + {noImage === true ? null : + <span><Image src={userImageSrc} width={width} height={width} alt="작성자이미지"/></span> + } + <div className={clsx('flex flex-col gap-1',childrenClassName)}> <span>{ownerNickname}</span> <span>{createdAtString}</span> </div> diff --git a/src/hooks/useArticles.ts b/src/hooks/useArticles.ts index 9dc39e9b..9e7451cf 100644 --- a/src/hooks/useArticles.ts +++ b/src/hooks/useArticles.ts @@ -96,8 +96,51 @@ export type PostDetail = { }; interface ProductFavoriteResponse { - productId: number; + id: number; isFavorited: boolean; setIsFavorited: (value: boolean) => void, setCount: (value: number | ((prev: number) => number)) => void } + +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 || '관심 게시물 처리 실패'); + } + }, + }); +}; diff --git a/src/hooks/useItems.tsx b/src/hooks/useItems.tsx index 70b38c92..27875558 100644 --- a/src/hooks/useItems.tsx +++ b/src/hooks/useItems.tsx @@ -83,7 +83,7 @@ export function usePostProduct(openModal: (msg: string) => void, router: AppRout interface ProductFavoriteResponse { - productId: number; + id: number; isFavorited: boolean; setIsFavorited: (value: boolean) => void, setCount: (value: number | ((prev: number) => number)) => void @@ -93,7 +93,7 @@ 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 +104,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: { From 476318fc2f84a339ae946be92d7df958f781cccd Mon Sep 17 00:00:00 2001 From: song mijin <alwls824@gmail.com> Date: Sun, 18 May 2025 06:00:08 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/boards/apply/page.tsx | 96 +++++++++++++++++++- src/app/items/apply/page.tsx | 2 +- src/components/Article/ArticleList.tsx | 2 +- src/components/ui/form/FormField.tsx | 2 - src/components/ui/form/ImageFileBox.tsx | 115 ++++++++++++++---------- src/hooks/useArticles.ts | 24 +++++ 6 files changed, 187 insertions(+), 54 deletions(-) diff --git a/src/app/boards/apply/page.tsx b/src/app/boards/apply/page.tsx index 3d281f67..dfae0a2a 100644 --- a/src/app/boards/apply/page.tsx +++ b/src/app/boards/apply/page.tsx @@ -1,10 +1,98 @@ -import React from 'react'; +'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 { InputField, 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, { useEffect, 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<FormValues>({ + mode: 'onBlur', + }); + + const [addArticles, setAddArticles] = useState<ArticleCreateRequest>(INITIAL_Article); + + const { mutate: postArticles} = usePostArticles(openConfirmModal,router); + + const handleFieldBlur = () => { + const values = getValues(); // 모든 필드 값 가져오기 + setAddArticles((prev) => { + const updated = { ...prev, ...values }; + return updated; + }); + }; + + const onSubmit: SubmitHandler<FormValues> = (data) => { + setAddArticles((prev) => ({ + ...prev, + ...addArticles, // form에서 온 title, content 등 + })); + postArticles(addArticles); + }; + + function handleInputBlur(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>){ + const value = e.target.value; + setAddArticles((prev) => ({ + ...prev, + [e.target.id]: value + })); + } + return ( - <> - <h2>PostArticles</h2> - </> + <Container className="relative mb-[130px]"> + <Title titleTag='h1' text='게시물 쓰기'> + +
    + + + + setForm={setAddArticles} /> + + +
    ); } diff --git a/src/app/items/apply/page.tsx b/src/app/items/apply/page.tsx index 04609a53..013606c3 100644 --- a/src/app/items/apply/page.tsx +++ b/src/app/items/apply/page.tsx @@ -53,7 +53,7 @@ function Additem() {
    - + setForm={setAddProduct} /> diff --git a/src/components/Article/ArticleList.tsx b/src/components/Article/ArticleList.tsx index d1ecd2d1..24ab9e74 100644 --- a/src/components/Article/ArticleList.tsx +++ b/src/components/Article/ArticleList.tsx @@ -54,7 +54,7 @@ export function ArticleList() { openConfirmModal('로그인 후 이용 가능합니다.'); return; } - router.push('articles/apply'); + router.push('boards/apply'); }; return ( <> diff --git a/src/components/ui/form/FormField.tsx b/src/components/ui/form/FormField.tsx index 27b2ad2e..75319c95 100644 --- a/src/components/ui/form/FormField.tsx +++ b/src/components/ui/form/FormField.tsx @@ -1,6 +1,4 @@ import React from 'react'; -import Image from 'next/image'; -import { eyeClose, eyeOpen } from '@/lib/imageAssets'; import Icon from '../Icon'; import clsx from 'clsx'; 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>; +interface ImageFileBoxProps { + setForm: React.Dispatch>; } -function ImageFileBox({ product, setProduct }: ImageFileBoxProps) { - - const [preview, setPreview] = useState<(string | null)[]>([]); // 미리보기 이미지 상태 +function ImageFileBox({ setForm }: ImageFileBoxProps) { + 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) { + + 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) => { 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 ( <> - - + - ) + ); } -export default ImageFileBox; \ No newline at end of file +export default ImageFileBox; diff --git a/src/hooks/useArticles.ts b/src/hooks/useArticles.ts index 9e7451cf..b3c30936 100644 --- a/src/hooks/useArticles.ts +++ b/src/hooks/useArticles.ts @@ -144,3 +144,27 @@ export const useToggleArticlesFavorite = }, }); }; + +export interface ArticleCreateRequest { + title?: string; + content?: string; + image?:string; +} + +// 상품 등록 +export function usePostArticles(openModal: (msg: string) => void, router: { push: (path: string) => void }) { + + return useMutation({ + mutationFn: async (ArticlesData: ArticleCreateRequest) => { + const res = await requestor.post('/articles', ArticlesData); + return res.data; + }, + onSuccess: () => { + openModal('게시물 등록이 완료되었습니다!'); + router.push('/boards'); + }, + onError: (error: any) => { + openModal(error?.response?.data?.message || '게시물 등록 실패'); + }, + }); +}; From 1ca9a356b009cfe218dfdf5b6c596438459de60f Mon Sep 17 00:00:00 2001 From: song mijin Date: Tue, 20 May 2025 03:54:25 +0900 Subject: [PATCH 6/9] feat: misson10 complete --- public/assets/reply_empty.svg | 10 + src/app/boards/[id]/page.tsx | 63 ++++- src/app/boards/apply/page.tsx | 7 +- src/app/items/[id]/ItemsDetail.module.css | 3 - src/app/items/[id]/page.tsx | 31 ++- src/components/Article/ArticleListItem.tsx | 2 +- .../Article/comment/CommentForm.tsx | 45 ++++ .../Article/comment/CommentItem.tsx | 111 ++++++++ .../Article/comment/CommentList.tsx | 47 ++++ .../Article/comment/CommentSection.tsx | 34 +++ .../productDetail/ProductDescription.tsx | 13 +- .../productDetail/ProductDetails.module.css | 8 - .../productDetail/ProductDetails.tsx | 36 --- .../productDetail/comment/CommentForm.tsx | 8 +- .../productDetail/comment/CommentItem.tsx | 6 +- .../productDetail/comment/CommentList.tsx | 4 +- src/components/ui/Button.module.css | 1 - src/components/ui/EmptyBox.tsx | 8 +- src/components/ui/LikeButton.tsx | 11 +- src/components/ui/UserInfo.module.css | 12 - src/components/ui/UserInfo.tsx | 9 +- src/hooks/useArticles.ts | 226 +++++++++++++--- src/hooks/useItems.tsx | 248 ++++++++++++++++-- src/hooks/useProductsComments.tsx | 2 +- src/hooks/useProductsDetail.tsx | 27 -- src/lib/imageAssets.ts | 1 + 26 files changed, 788 insertions(+), 185 deletions(-) create mode 100644 public/assets/reply_empty.svg delete mode 100644 src/app/items/[id]/ItemsDetail.module.css create mode 100644 src/components/Article/comment/CommentForm.tsx create mode 100644 src/components/Article/comment/CommentItem.tsx create mode 100644 src/components/Article/comment/CommentList.tsx create mode 100644 src/components/Article/comment/CommentSection.tsx delete mode 100644 src/components/productDetail/ProductDetails.module.css delete mode 100644 src/components/productDetail/ProductDetails.tsx delete mode 100644 src/components/ui/UserInfo.module.css delete mode 100644 src/hooks/useProductsDetail.tsx 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/boards/[id]/page.tsx b/src/app/boards/[id]/page.tsx index 53d359a2..6d65dc50 100644 --- a/src/app/boards/[id]/page.tsx +++ b/src/app/boards/[id]/page.tsx @@ -1,11 +1,64 @@ +'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() { - return ( - <> -

    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/apply/page.tsx b/src/app/boards/apply/page.tsx index dfae0a2a..c9a9ca1b 100644 --- a/src/app/boards/apply/page.tsx +++ b/src/app/boards/apply/page.tsx @@ -4,13 +4,13 @@ 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 { InputField, TextAreaField } from '@/components/ui/form/InputBox'; +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, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { SubmitHandler, useForm } from 'react-hook-form'; @@ -74,7 +74,7 @@ function PostArticles() { type="submit" variant="roundedSS" className="!absolute top-0 right-0" - disabled = { !isValid || !isDirty || !addArticles.content } + disabled = { !isValid || !isDirty || !addArticles.content || !addArticles.image || !addArticles.title } >등록 setForm={setAddArticles} /> 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 2851d653..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/productDetail/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/components/Article/ArticleListItem.tsx b/src/components/Article/ArticleListItem.tsx index 30435caf..0421811d 100644 --- a/src/components/Article/ArticleListItem.tsx +++ b/src/components/Article/ArticleListItem.tsx @@ -44,7 +44,7 @@ function ArticleListItem({ postItem }: ArticleListItemProps) {
    - + (''); + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); + + const { mutate: postComment } = usePostArticleComment(articleId, openConfirmModal); + + const handleClick = () => { + if(!user) { + openConfirmModal('로그인 후 이용 가능합니다.'); + return; + } + postComment(requestCommentValue); + setRequestCommentValue(''); + }; + + return ( +
    +
    댓글달기
    + ) => setRequestCommentValue(e.target.value)} + /> +
    + +
    + +
    + ); +} +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(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 ( +
  • + {editMode === true ? ( +
    + ) => setRequestCommentValue(target.value)} + /> +
    + + +
    +
    + ):( +
    + {requestCommentValue} + +
    + )} + + + +
  • + ); +} +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 ? : data?.pages?.[0].list.length ? ( +
    + {data?.pages.map((page, i) => ( + + {page.list.map((comment) => ( +
    + +
    + ))} +
    + ))} +
    + {isFetchingNextPage &&
    로딩 중...
    } +
    + ):( + + ) + } + + + ); +} +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 ( + <> + + +
    + +
    + + + ); +} +export default CommentSection; diff --git a/src/components/productDetail/ProductDescription.tsx b/src/components/productDetail/ProductDescription.tsx index 94cbfdfa..167f6484 100644 --- a/src/components/productDetail/ProductDescription.tsx +++ b/src/components/productDetail/ProductDescription.tsx @@ -1,14 +1,11 @@ -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 { useToggleProductFavorite } from '@/hooks/useItems'; +import { ProductDetail, useToggleProductFavorite } from '@/hooks/useItems'; import { useConfirmModal } from '@/hooks/useModal'; import ConfirmModal from '../ui/ConfirmModal'; @@ -31,13 +28,13 @@ function ProductDescription(detailData:ProductDetail) { // '2025-04-08T01:00:06+09:00' '2025-04-07T01:00:06+09:00' const createdAtString = formatDate(createdAt); // console.log(createdAtString); + const { isConfirmOpen, confirmMessage, openConfirmModal, closeConfirmModal } = useConfirmModal(); const { mutate: toggleFavorite } = useToggleProductFavorite(openConfirmModal, { onSuccess: (data) => { openConfirmModal(data.isFavorited ? "관심상품 등록되었습니다" : "관심상품 취소되었습니다"); }, }); - return (
    @@ -63,10 +60,10 @@ function ProductDescription(detailData:ProductDetail) {
    - +
    - - { data && ( - <> - - - - )} - - - - -
    - ); -} - -export default ProductDetails; \ No newline at end of file diff --git a/src/components/productDetail/comment/CommentForm.tsx b/src/components/productDetail/comment/CommentForm.tsx index 4dc27869..4334f7d8 100644 --- a/src/components/productDetail/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(''); 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/productDetail/comment/CommentItem.tsx b/src/components/productDetail/comment/CommentItem.tsx index df2e88ff..7df1c61c 100644 --- a/src/components/productDetail/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) {
    )} - + diff --git a/src/components/productDetail/comment/CommentList.tsx b/src/components/productDetail/comment/CommentList.tsx index fcab1f11..1f10e988 100644 --- a/src/components/productDetail/comment/CommentList.tsx +++ b/src/components/productDetail/comment/CommentList.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { useInfiniteProductsCommentsWithObserver } from '@/hooks/useProductsComments'; import CommentItem from './CommentItem'; import LoadingBox from '@/components/ui/LoadingBox'; import EmptyBox from '@/components/ui/EmptyBox'; +import { useInfiniteProductsCommentsWithObserver } from '@/hooks/useItems'; interface CommentListProps { @@ -36,7 +36,7 @@ function CommentList({ productId,className, ...rest }: CommentListProps) { {isFetchingNextPage &&
    로딩 중...
    }
    ):( - + ) } 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..44a1fd65 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 (
    - 빈페이지 - {context} + 빈페이지 + {context}
    {subText}
    ); diff --git a/src/components/ui/LikeButton.tsx b/src/components/ui/LikeButton.tsx index fd735489..8c7d1d95 100644 --- a/src/components/ui/LikeButton.tsx +++ b/src/components/ui/LikeButton.tsx @@ -2,7 +2,6 @@ import Button from "./Button"; import Icon from "./Icon"; -import { useToggleProductFavorite } from "@/hooks/useItems"; import ConfirmModal from "./ConfirmModal"; import { useConfirmModal } from "@/hooks/useModal"; import { useAuth } from "@/contexts/AuthContext"; @@ -44,10 +43,12 @@ function LikeButton({ return ( <> - +
    + +
    ) diff --git a/src/components/ui/UserInfo.module.css b/src/components/ui/UserInfo.module.css deleted file mode 100644 index 7669547a..00000000 --- a/src/components/ui/UserInfo.module.css +++ /dev/null @@ -1,12 +0,0 @@ -.userInfo { - display: flex; - align-items: center; -} -.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 9d05ef6e..de0c3410 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'; @@ -16,16 +15,16 @@ interface UserInfoProps { noImage?: boolean; } -function UserInfo({userImg = '', ownerNickname, createdAtString, width=40, fontSize = "14px", noImage = false, childrenClassName ,className ="gap-[16px]"}: UserInfoProps) { +function UserInfo({userImg = '', ownerNickname, createdAtString, width=40, noImage = false, childrenClassName ,className ="gap-[16px]"}: UserInfoProps) { const userImageSrc = userImg === '' || userImg === null ? tempUserImg : userImg; return ( -
    +
    {noImage === true ? null : 작성자이미지 }
    - {ownerNickname} - {createdAtString} + {ownerNickname} + {createdAtString}
    ); diff --git a/src/hooks/useArticles.ts b/src/hooks/useArticles.ts index b3c30936..bc48dfca 100644 --- a/src/hooks/useArticles.ts +++ b/src/hooks/useArticles.ts @@ -1,5 +1,7 @@ import { requestor } from "@/lib/requestor"; -import { keepPreviousData, useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import { CommentListResponse } from "./useItems"; export interface PostListQuery { @@ -31,6 +33,34 @@ export type PostWriter = { 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], @@ -44,7 +74,7 @@ export const useArticlesList = (query:PostListQuery) => { }); }; - +// 게시물 더보기 무한로딩 export function useInfiniteArticles(query: Omit) { const queryResult = useInfiniteQuery({ queryKey: ['infiniteArticles', query], @@ -79,29 +109,20 @@ export function useInfiniteArticles(query: Omit) { }; } - -export type PostDetail = { - id: number; - title: string; - content: string; - image: string; - likeCount: number; - isLiked: boolean; - createdAt: string; // ISO 날짜 문자열 - updatedAt: string; // ISO 날짜 문자열 - writer: { - id: number; - nickname: string; - }; +// 게시물 상세보기 +export const useArticleDetails = (articleId:number) => { + return useQuery({ + queryKey: ['articleDetails', articleId], + queryFn: async () => { + const res = await requestor.get(`/articles/${articleId}`); + return res.data; + }, + placeholderData: keepPreviousData, + }); }; -interface ProductFavoriteResponse { - id: number; - isFavorited: boolean; - setIsFavorited: (value: boolean) => void, - setCount: (value: number | ((prev: number) => number)) => void -} +// 게시물 좋아요 토글 export const useToggleArticlesFavorite = (openModal: (msg: string) => void, options?: { onSuccess?: (data: any) => void }) => { @@ -145,18 +166,13 @@ export const useToggleArticlesFavorite = }); }; -export interface ArticleCreateRequest { - title?: string; - content?: string; - image?:string; -} -// 상품 등록 +// 게시물 등록 export function usePostArticles(openModal: (msg: string) => void, router: { push: (path: string) => void }) { return useMutation({ - mutationFn: async (ArticlesData: ArticleCreateRequest) => { - const res = await requestor.post('/articles', ArticlesData); + mutationFn: async (articlesData: ArticleCreateRequest) => { + const res = await requestor.post('/articles', articlesData); return res.data; }, onSuccess: () => { @@ -168,3 +184,153 @@ export function usePostArticles(openModal: (msg: string) => void, router: { push }, }); }; + +// 게시물 코멘트 리스트 무한로딩 +export function useInfiniteArticleCommentsWithObserver(articleId: number, limit = 5) { // 무한로딩 + const queryResult = useInfiniteQuery({ + queryKey: ['articleComments', articleId], + queryFn: async ({ pageParam }) => { + const res = await requestor.get(`/articles/${articleId}/comments`, { + params: { + limit, + cursor: pageParam ?? null, + }, + }); + return res.data; + }, + initialPageParam: null, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? null, + }); + + const loadMoreRef = useRef(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/useItems.tsx b/src/hooks/useItems.tsx index 27875558..2a836a1c 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('/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('/products', { + params: query, + }); + return res.data ; + }, + placeholderData: keepPreviousData, + }); +}; + // 상품 등록 export function usePostProduct(openModal: (msg: string) => void, router: AppRouterInstance ) { // 사용에따라 router를 인자로 받음 @@ -81,14 +102,7 @@ export function usePostProduct(openModal: (msg: string) => void, router: AppRout }); }; - -interface ProductFavoriteResponse { - id: number; - isFavorited: boolean; - setIsFavorited: (value: boolean) => void, - setCount: (value: number | ((prev: number) => number)) => void -} - +// 상품 좋아요 토글 export const useToggleProductFavorite = (openModal: (msg: string) => void, options?: { onSuccess?: (data: any) => void }) => { @@ -131,3 +145,191 @@ export const useToggleProductFavorite = }, }); }; +// 상품 상세보기 +export const useProductsDetails = (productId:number) => { + return useQuery({ + queryKey: ['ItemsDetails', productId], + queryFn: async () => { + const res = await requestor.get(`/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({ + queryKey: ['productComments', productId], + queryFn: async ({ pageParam }) => { + const res = await requestor.get(`/products/${productId}/comments`, { + params: { + limit, + cursor: pageParam ?? null, + }, + }); + return res.data; + }, + initialPageParam: null, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? null, + }); + + const loadMoreRef = useRef(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 index c55b0ab2..085e7951 100644 --- a/src/hooks/useProductsComments.tsx +++ b/src/hooks/useProductsComments.tsx @@ -142,7 +142,7 @@ export const usePatchProductComment = (productId: number, openModal: (msg: strin }, }); }; -export const useDeleteCommentMutation = (productId: number, openModal: (msg: string) => void) => { +export const useDeleteProductComment = (productId: number, openModal: (msg: string) => void) => { const queryClient = useQueryClient(); return useMutation({ 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(`/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'; From f70a8b3a523bc7597a489b1aca1d34a702b658e2 Mon Sep 17 00:00:00 2001 From: song mijin Date: Tue, 20 May 2025 04:08:26 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20next.config.js=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=98=B5=EC=85=98=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B9=8C=EB=93=9C=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.js | 4 +--- src/app/boards/page.tsx | 14 ++++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/next.config.js b/next.config.js index 6fb7ac30..47f4c429 100644 --- a/next.config.js +++ b/next.config.js @@ -1,9 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, - experimental: { - appDir: true, // ✅ App Router 활성화 (Next 13 이상) - }, + experimental: {}, images: { remotePatterns: [ { diff --git a/src/app/boards/page.tsx b/src/app/boards/page.tsx index bb7d354f..c9786143 100644 --- a/src/app/boards/page.tsx +++ b/src/app/boards/page.tsx @@ -1,5 +1,5 @@ 'use client'; -import React from 'react'; +import React, { SuspenseList } from 'react'; import Container from 'components/layout/Container'; import Title from 'components/ui/Title'; import BestArticleList from '@/components/Article/BestArticleList'; @@ -8,11 +8,13 @@ import { ArticleList } from '@/components/Article/ArticleList'; function Boards() { return ( <> - - - </Container> - <BestArticleList /> - <ArticleList /> + <SuspenseList> + <Container> + <Title titleTag='h1' text='베스트 게시글' /> + </Container> + <BestArticleList /> + <ArticleList /> + </SuspenseList> </> ); } From 20f0882d8a1c8d3272d88a984ac52247183d0bd1 Mon Sep 17 00:00:00 2001 From: song mijin <alwls824@gmail.com> Date: Tue, 20 May 2025 04:24:27 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=EB=B9=8C=EB=93=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/boards/page.tsx | 6 +++--- src/app/items/apply/page.tsx | 5 ++--- src/app/items/page.tsx | 15 +++++++++------ src/components/Product/AllItems.tsx | 1 - src/components/Product/ProductItem.tsx | 6 +----- src/components/layout/ProductNav.tsx | 3 +-- 6 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/app/boards/page.tsx b/src/app/boards/page.tsx index c9786143..db4b82bc 100644 --- a/src/app/boards/page.tsx +++ b/src/app/boards/page.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { SuspenseList } from 'react'; +import React, { Suspense } from 'react'; import Container from 'components/layout/Container'; import Title from 'components/ui/Title'; import BestArticleList from '@/components/Article/BestArticleList'; @@ -8,13 +8,13 @@ import { ArticleList } from '@/components/Article/ArticleList'; function Boards() { return ( <> - <SuspenseList> + <Suspense> <Container> <Title titleTag='h1' text='베스트 게시글' /> </Container> <BestArticleList /> <ArticleList /> - </SuspenseList> + </Suspense> </> ); } diff --git a/src/app/items/apply/page.tsx b/src/app/items/apply/page.tsx index 013606c3..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'; diff --git a/src/app/items/page.tsx b/src/app/items/page.tsx index 1dc599ea..b2506e85 100644 --- a/src/app/items/page.tsx +++ b/src/app/items/page.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +'use client'; +import React, { Suspense } from 'react'; import Container from 'components/layout/Container'; import Title from 'components/ui/Title'; import { BestItems } from '@/components/Product/BestItems'; @@ -9,11 +10,13 @@ function ItemsBox() { return ( <> - <Container> - <Title titleTag='h1' text='베스트 상품' /> - </Container> - <BestItems /> - <AllItems /> + <Suspense> + <Container> + <Title titleTag='h1' text='베스트 상품' /> + </Container> + <BestItems /> + <AllItems /> + </Suspense> </> ); } diff --git a/src/components/Product/AllItems.tsx b/src/components/Product/AllItems.tsx index 282f3462..5c6d5479 100644 --- a/src/components/Product/AllItems.tsx +++ b/src/components/Product/AllItems.tsx @@ -16,7 +16,6 @@ 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 ProductSearchBox from "../ui/form/SearchBox"; import SearchBox from "../ui/form/SearchBox"; diff --git a/src/components/Product/ProductItem.tsx b/src/components/Product/ProductItem.tsx index 5ac4c33a..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'; diff --git a/src/components/layout/ProductNav.tsx b/src/components/layout/ProductNav.tsx index 0ab2b342..58d47baf 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(); From 60ee30891c8e96be3c47c1b1fce7b2cc4b6b9c44 Mon Sep 17 00:00:00 2001 From: song mijin <alwls824@gmail.com> Date: Tue, 20 May 2025 05:37:27 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=94=EC=96=B4=20app=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EB=94=94=ED=85=8C=EC=9D=BC?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.js | 2 +- src/app/{boards/apply => addboard}/page.tsx | 0 src/app/boards/[id]/page.tsx | 1 + src/app/boards/page.tsx | 26 +-- src/app/faq/page.tsx | 1 + src/app/items/page.tsx | 27 +-- src/app/page.tsx | 10 +- src/components/Article/ArticleList.tsx | 2 +- src/components/Article/BoardsClient.tsx | 21 +++ .../FallbackImage/FallbackImage.tsx | 1 + src/components/Product/ProductClient.tsx | 22 +++ src/components/layout/Footer.tsx | 8 +- src/components/layout/Nav.tsx | 4 +- src/components/layout/ProductNav.tsx | 4 +- src/components/members/MembersLogo.tsx | 4 +- src/components/members/SnsLogin.tsx | 4 +- src/components/ui/EmptyBox.tsx | 2 +- src/components/ui/UserInfo.tsx | 2 +- src/hooks/useArticles.ts | 6 +- src/hooks/useItems.tsx | 6 +- src/hooks/useProductsComments.tsx | 173 ------------------ 21 files changed, 83 insertions(+), 243 deletions(-) rename src/app/{boards/apply => addboard}/page.tsx (100%) create mode 100644 src/components/Article/BoardsClient.tsx create mode 100644 src/components/Product/ProductClient.tsx delete mode 100644 src/hooks/useProductsComments.tsx diff --git a/next.config.js b/next.config.js index 47f4c429..eba6c8ab 100644 --- a/next.config.js +++ b/next.config.js @@ -1,7 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + // output: "export", reactStrictMode: true, - experimental: {}, images: { remotePatterns: [ { diff --git a/src/app/boards/apply/page.tsx b/src/app/addboard/page.tsx similarity index 100% rename from src/app/boards/apply/page.tsx rename to src/app/addboard/page.tsx diff --git a/src/app/boards/[id]/page.tsx b/src/app/boards/[id]/page.tsx index 6d65dc50..dfce5707 100644 --- a/src/app/boards/[id]/page.tsx +++ b/src/app/boards/[id]/page.tsx @@ -1,4 +1,5 @@ 'use client'; + import CommentSection from '@/components/Article/comment/CommentSection'; import Container from '@/components/layout/Container'; import ConfirmModal from '@/components/ui/ConfirmModal'; diff --git a/src/app/boards/page.tsx b/src/app/boards/page.tsx index db4b82bc..563f66cc 100644 --- a/src/app/boards/page.tsx +++ b/src/app/boards/page.tsx @@ -1,23 +1,5 @@ -'use client'; -import React, { Suspense } 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 Boards() { - return ( - <> - <Suspense> - <Container> - <Title titleTag='h1' text='베스트 게시글' /> - </Container> - <BestArticleList /> - <ArticleList /> - </Suspense> - </> - ); -} - -export default Boards; +import BoardsClient from "@/components/Article/BoardsClient"; +export default function BoardsPage() { + return <BoardsClient />; +} \ 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/page.tsx b/src/app/items/page.tsx index b2506e85..1ef03243 100644 --- a/src/app/items/page.tsx +++ b/src/app/items/page.tsx @@ -1,24 +1,5 @@ -'use client'; -import React, { Suspense } 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'; +import ProductClient from "@/components/Product/ProductClient"; - -function ItemsBox() { - - return ( - <> - <Suspense> - <Container> - <Title titleTag='h1' text='베스트 상품' /> - </Container> - <BestItems /> - <AllItems /> - </Suspense> - </> - ); -} - -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/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/components/Article/ArticleList.tsx b/src/components/Article/ArticleList.tsx index 24ab9e74..9311664d 100644 --- a/src/components/Article/ArticleList.tsx +++ b/src/components/Article/ArticleList.tsx @@ -54,7 +54,7 @@ export function ArticleList() { openConfirmModal('로그인 후 이용 가능합니다.'); return; } - router.push('boards/apply'); + router.push('addboard'); }; return ( <> 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> + <Title titleTag='h1' text='베스트 게시글' /> + </Container> + <BestArticleList /> + <ArticleList /> + </> + ); +} + +export default BoardsClient; + diff --git a/src/components/FallbackImage/FallbackImage.tsx b/src/components/FallbackImage/FallbackImage.tsx index e50ffba6..26de11a8 100644 --- a/src/components/FallbackImage/FallbackImage.tsx +++ b/src/components/FallbackImage/FallbackImage.tsx @@ -45,6 +45,7 @@ export const FallbackImage = ({ alt={alt} fill priority + unoptimized sizes="sm:100vw, 33vw" className={`absolute inset-0 object-cover scale-105 transition-opacity duration-300 ${ isLoaded ? "opacity-0" : "opacity-100" 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> + <Title titleTag='h1' text='베스트 상품' /> + </Container> + <BestItems /> + <AllItems /> + </> + ); +} + +export default ProductClient; \ No newline at end of file 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 58d47baf..ad8010ab 100644 --- a/src/components/layout/ProductNav.tsx +++ b/src/components/layout/ProductNav.tsx @@ -30,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/ui/EmptyBox.tsx b/src/components/ui/EmptyBox.tsx index 44a1fd65..07187737 100644 --- a/src/components/ui/EmptyBox.tsx +++ b/src/components/ui/EmptyBox.tsx @@ -16,7 +16,7 @@ function EmptyBox({context, subText, className , imageName = emptyImg }: EmptyBo 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={imageName} width={176} height={176} className='mx-auto' alt='빈페이지' /> + <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/UserInfo.tsx b/src/components/ui/UserInfo.tsx index de0c3410..1371b25b 100644 --- a/src/components/ui/UserInfo.tsx +++ b/src/components/ui/UserInfo.tsx @@ -20,7 +20,7 @@ function UserInfo({userImg = '', ownerNickname, createdAtString, width=40, noIma return ( <div className={clsx("flex items-center gap-4", className)}> {noImage === true ? null : - <span><Image src={userImageSrc} width={width} height={width} alt="작성자이미지"/></span> + <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> diff --git a/src/hooks/useArticles.ts b/src/hooks/useArticles.ts index bc48dfca..97bb24e2 100644 --- a/src/hooks/useArticles.ts +++ b/src/hooks/useArticles.ts @@ -175,9 +175,11 @@ export function usePostArticles(openModal: (msg: string) => void, router: { push const res = await requestor.post<PostDetail>('/articles', articlesData); return res.data; }, - onSuccess: () => { + onSuccess: (data) => { openModal('게시물 등록이 완료되었습니다!'); - router.push('/boards'); + setTimeout(() => { + router.push(`/boards/${data.id}`); + }, 1300); }, onError: (error: any) => { openModal(error?.response?.data?.message || '게시물 등록 실패'); diff --git a/src/hooks/useItems.tsx b/src/hooks/useItems.tsx index 2a836a1c..8a3e41e9 100644 --- a/src/hooks/useItems.tsx +++ b/src/hooks/useItems.tsx @@ -92,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 || '상품 등록 실패'); diff --git a/src/hooks/useProductsComments.tsx b/src/hooks/useProductsComments.tsx deleted file mode 100644 index 085e7951..00000000 --- a/src/hooks/useProductsComments.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { requestor } from '@/lib/requestor'; -import {useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useReducer, useRef } from 'react'; - - -// 댓글 작성자 정보 -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: () => { - queryClient.invalidateQueries({ queryKey: ['productComments', productId] }); - }, - onError: (error: any) => { - const message = error?.response?.data?.message; - if (message?.includes('jwt malformed')) { - openModal('로그인 후 등록 가능합니다!'); - } else { - openModal(message || '댓글 삭제 실패'); - } - }, - }); -};