diff --git a/src/app/detail/[postingId]/page.tsx b/src/app/detail/[postingId]/page.tsx index 2c8f686e..fea3fddd 100644 --- a/src/app/detail/[postingId]/page.tsx +++ b/src/app/detail/[postingId]/page.tsx @@ -1,269 +1,32 @@ "use client"; -import { useState, useEffect } from "react"; -import { useRouter, useParams } from "next/navigation"; - -import { apiFetch } from "@/shared/api/fetcher"; -import { POST_PAGE_SIZE } from "@/entities/post/model/constants/api"; -import { useInfiniteScroll } from "@/shared/lib/useInfiniteScroll"; - -import { useAuthStore } from "@/features/auth/model/auth.store"; -import { useModalStore } from "@/shared/model/modal.store"; -import { usePostEditModal } from "@/features/editPost/lib/usePostEditModal"; -import { useLike } from "@/features/like/lib/useLike"; -import { useChatStore } from "@/features/chat/model/chat.store"; - -import PostCard from "@/entities/post/ui/card/PostCard"; -import PostCarousel from "@/entities/post/ui/carousel/PostCarousel"; -import { SellerInfo } from "@/widgets/postDetail/ui/SellerInfo"; -import { PostActionBar } from "@/widgets/postDetail/ui/PostActionBar"; - -import type { PostDetail, Post } from "@/entities/post/model/types/post"; -import type { User } from "@/entities/user/model/types/user"; - -export default function DetailPage() { - const router = useRouter(); - const params = useParams(); - const postingId = Number(params?.postingId); - - const isLogined = useAuthStore((state) => state.isLogined); - const { openModal, closeModal } = useModalStore(); - const openChat = useChatStore((state) => state.mount); - - const [post, setPost] = useState(null); - const [isPostLoading, setIsPostLoading] = useState(true); - - const [likeCount, setLikeCount] = useState(0); - const [liked, setLiked] = useState(false); - - const [relatedPosts, setRelatedPosts] = useState([]); - const [page, setPage] = useState(1); - const [hasMore, setHasMore] = useState(true); - const [isRelatedLoading, setIsRelatedLoading] = useState(false); - - const [seller, setSeller] = useState<{ - userId: number; - nickname: string; - imageUrl?: string; - } | null>(null); - - const { loading, handleLikeToggle } = useLike({ - postingId: post?.postingId, - liked, - onLikedChange: setLiked, - onCountChange: (delta) => setLikeCount((prev) => prev + delta), - }); - - const { openPostEditModal } = usePostEditModal({ - onSuccess: () => { - fetchPost(); - }, +import { useParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { getPostDetail } from "@/entities/post/api/getPostDetail"; +import { PostDetailSection } from "@/widgets/postDetail/ui/PostDetailSection"; +import { SellerPostsSection } from "@/widgets/postDetail/ui/SellerPostsSection"; + +export default function PostDetailPage() { + const { postingId } = useParams<{ postingId: string }>(); + const id = Number(postingId); + + const { + data: post, + isLoading, + isError, + } = useQuery({ + queryKey: ["postDetail", id], + queryFn: () => getPostDetail(id), }); - const fetchPost = async () => { - try { - setIsPostLoading(true); - const data = await apiFetch(`/api/postings/${postingId}`, { - method: "GET", - }); - setPost(data); - setLikeCount(data.likeCount); - setLiked(data.isFavorite); - } catch (err) { - console.error("게시글 로드 실패:", err); - } finally { - setIsPostLoading(false); - } - }; - - const fetchSeller = async (userId: number) => { - try { - const data = await apiFetch(`/api/users/${userId}`, { - method: "GET", - }); - setSeller(data); - } catch (err) { - console.error("판매자 정보 조회 실패:", err); - } - }; - - const fetchRelatedPosts = async (pageNum: number, reset = false) => { - if (!post?.sellerId || isRelatedLoading) return; - setIsRelatedLoading(true); - - try { - const query = new URLSearchParams({ - page: String(pageNum), - size: String(POST_PAGE_SIZE), - }); - - const data = await apiFetch<{ data: Post[] }>( - `/api/postings/user/${post.sellerId}?${query.toString()}`, - { method: "GET" }, - ); - - if (reset) setRelatedPosts(data.data); - else setRelatedPosts((prev) => [...prev, ...data.data]); - - setHasMore(data.data.length === POST_PAGE_SIZE); - } catch (err) { - console.error("판매한 상품 불러오기 실패:", err); - } finally { - setIsRelatedLoading(false); - } - }; - - const handleDeleteClick = async () => { - if (!post) return; - - openModal("confirm", { - message: "정말 이 게시물을 삭제하시겠습니까?", - onConfirm: async () => { - try { - await apiFetch(`/api/postings/${post.postingId}`, { - method: "DELETE", - }); - closeModal(); - openModal("normal", { - message: "삭제가 완료되었습니다.", - onClick: () => { - closeModal(); - router.push("/"); - }, - }); - } catch (err) { - console.error("게시물 삭제 실패:", err); - } - }, - onCancel: () => { - closeModal(); - }, - }); - }; - - const lastPostRef = useInfiniteScroll( - () => setPage((prev) => prev + 1), - isRelatedLoading, - hasMore, - ); - - useEffect(() => { - if (!postingId) return; - fetchPost(); - }, [postingId]); - - useEffect(() => { - if (!post?.sellerId) return; - fetchSeller(post.sellerId); - setPage(1); - setHasMore(true); - fetchRelatedPosts(1, true); - }, [post?.sellerId]); - - useEffect(() => { - if (page === 1) return; - fetchRelatedPosts(page); - }, [page]); - - const handleChatClick = () => { - if (!isLogined) { - router.push("/login"); - return; - } - if (!post) return; - openChat({ postingId, otherId: post.sellerId }); - }; - - const handleEditClick = () => { - if (!post) return; - openPostEditModal( - post.postingId, - post.title, - post.price, - post.category, - post.content, - post.images, - ); - }; - - if (isPostLoading) - return

로딩 중...

; - if (!post) + if (isLoading) return

로딩 중...

; + if (isError || !post) return

게시글을 찾을 수 없습니다.

; return ( -
-
-
-
- - - -
-
- -
-
-
-

{post.title}

-

- {post.price.toLocaleString()} -

-
- -

- {post.content} -

-
-
- - 채팅 {post.chatCount} - - ·관심 {likeCount} - - - ·조회 {post.viewCount} - - - -
-
-
- -
-
-

- 판매한 상품 -

-
- {relatedPosts.map((item, idx) => - relatedPosts.length === idx + 1 ? ( -
- -
- ) : ( - - ), - )} -
- - {isRelatedLoading && ( -

불러오는 중...

- )} -
-
+
+ +
); } diff --git a/src/entities/post/api/getPostDetail.ts b/src/entities/post/api/getPostDetail.ts new file mode 100644 index 00000000..0fd42979 --- /dev/null +++ b/src/entities/post/api/getPostDetail.ts @@ -0,0 +1,10 @@ +import { apiFetch } from "@/shared/api/fetcher"; +import type { PostDetail } from "../model/types/post"; + +export async function getPostDetail(postingId: number): Promise { + if (!postingId) throw new Error("Invalid postingId"); + + return apiFetch(`/api/postings/${postingId}`, { + method: "GET", + }); +} diff --git a/src/entities/post/api/getSellerPosts.ts b/src/entities/post/api/getSellerPosts.ts new file mode 100644 index 00000000..5799c7a7 --- /dev/null +++ b/src/entities/post/api/getSellerPosts.ts @@ -0,0 +1,27 @@ +import { apiFetch } from "@/shared/api/fetcher"; +import { POST_PAGE_SIZE } from "../model/constants/api"; +import type { Post } from "../model/types/post"; + +interface GetSellerPostsParams { + userId: number; + page?: number; + size?: number; +} + +export async function getSellerPosts({ + userId, + page = 1, + size = POST_PAGE_SIZE, +}: GetSellerPostsParams): Promise<{ data: Post[] }> { + if (!userId) throw new Error("Invalid userId"); + + const query = new URLSearchParams({ + page: String(page), + size: String(size), + }); + + return apiFetch<{ data: Post[] }>( + `/api/postings/user/${userId}?${query.toString()}`, + { method: "GET" }, + ); +} diff --git a/src/entities/user/api/getUser.ts b/src/entities/user/api/getUser.ts new file mode 100644 index 00000000..8817f46e --- /dev/null +++ b/src/entities/user/api/getUser.ts @@ -0,0 +1,10 @@ +import { apiFetch } from "@/shared/api/fetcher"; +import type { User } from "../model/types/user"; + +export async function getUser(userId: number): Promise { + if (!userId) throw new Error("Invalid userId"); + + return apiFetch(`/api/users/${userId}`, { + method: "GET", + }); +} diff --git a/src/features/like/lib/useLike.ts b/src/features/like/lib/useLike.ts index 1f76d973..930b92bf 100644 --- a/src/features/like/lib/useLike.ts +++ b/src/features/like/lib/useLike.ts @@ -1,39 +1,49 @@ -import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { updateFavorite } from "../api/updateFavorite"; -interface UseLikeOptions { - postingId?: number; - liked: boolean; - onLikedChange: (liked: boolean) => void; - onCountChange: (delta: number) => void; -} +export function useLike(postingId?: number) { + const queryClient = useQueryClient(); -export function useLike({ - postingId, - liked, - onLikedChange, - onCountChange, -}: UseLikeOptions) { - const [loading, setLoading] = useState(false); - - const handleLikeToggle = async () => { - if (!postingId || loading) return; - setLoading(true); - - const optimisticLiked = !liked; - onLikedChange(optimisticLiked); - onCountChange(optimisticLiked ? +1 : -1); - - try { - const res = await updateFavorite(postingId, optimisticLiked); - onLikedChange(res.favorited); - } catch { - onLikedChange(!optimisticLiked); - onCountChange(optimisticLiked ? -1 : +1); - } finally { - setLoading(false); - } - }; + const mutation = useMutation({ + mutationFn: async (liked: boolean) => { + if (!postingId) throw new Error("Invalid postingId"); + const res = await updateFavorite(postingId, liked); + return res.favorited; + }, + + onMutate: async (newLiked) => { + if (!postingId) return; + + await queryClient.cancelQueries({ queryKey: ["postDetail", postingId] }); + const previousData = queryClient.getQueryData<{ + isFavorite: boolean; + likeCount: number; + }>(["postDetail", postingId]); + + if (previousData) { + queryClient.setQueryData(["postDetail", postingId], { + ...previousData, + isFavorite: newLiked, + likeCount: previousData.likeCount + (newLiked ? +1 : -1), + }); + } - return { liked, loading, handleLikeToggle }; + return { previousData }; + }, + + onError: (err, newLiked, context) => { + if (!postingId || !context?.previousData) return; + queryClient.setQueryData(["postDetail", postingId], context.previousData); + }, + + onSettled: () => { + if (!postingId) return; + queryClient.invalidateQueries({ queryKey: ["postDetail", postingId] }); + }, + }); + + return { + toggleLike: (liked: boolean) => mutation.mutate(liked), + isLoading: mutation.isPending, + }; } diff --git a/src/widgets/postDetail/ui/PostActionBar.tsx b/src/widgets/postDetail/ui/PostActionBar.tsx deleted file mode 100644 index ac3e36cc..00000000 --- a/src/widgets/postDetail/ui/PostActionBar.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"use client"; -import Button from "@/shared/ui/Button/Button"; -import LikeButton from "@/features/like/ui/LikeButton"; - -interface Props { - isOwner: boolean; - liked: boolean; - loading: boolean; - onEdit: () => void; - onDelete: () => void; - onChat: () => void; - onToggleLike: () => void; -} - -export function PostActionBar({ - isOwner, - liked, - loading, - onEdit, - onDelete, - onChat, - onToggleLike, -}: Props) { - return ( -
- {isOwner ? ( - <> - - - - ) : ( - <> - - - - )} -
- ); -} diff --git a/src/widgets/postDetail/ui/PostDetailSection.tsx b/src/widgets/postDetail/ui/PostDetailSection.tsx new file mode 100644 index 00000000..b4ca6217 --- /dev/null +++ b/src/widgets/postDetail/ui/PostDetailSection.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { getUser } from "@/entities/user/api/getUser"; +import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; + +import PostCarousel from "@/entities/post/ui/carousel/PostCarousel"; +import LikeButton from "@/features/like/ui/LikeButton"; +import Button from "@/shared/ui/Button/Button"; +import UserIcon from "@/shared/images/user.svg"; +import Link from "next/link"; + +import { useLike } from "@/features/like/lib/useLike"; +import { usePostEditModal } from "@/features/editPost/lib/usePostEditModal"; +import { useChatStore } from "@/features/chat/model/chat.store"; +import { useModalStore } from "@/shared/model/modal.store"; +import { PostDetail } from "@/entities/post/model/types/post"; + +export function PostDetailSection({ post }: { post: PostDetail }) { + const router = useRouter(); + const queryClient = useQueryClient(); + const { openModal, closeModal } = useModalStore(); + const openChat = useChatStore((s) => s.mount); + + const { data: seller } = useQuery({ + queryKey: ["seller", post.sellerId], + queryFn: () => getUser(post.sellerId), + enabled: !!post.sellerId, + }); + + const { toggleLike, isLoading: isLikeLoading } = useLike(post.postingId); + const handleToggleLike = () => toggleLike(!post.isFavorite); + + const { openPostEditModal } = usePostEditModal({ + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ["postDetail", post.postingId], + }); + }, + }); + + const handleEditClick = () => { + openPostEditModal( + post.postingId, + post.title, + post.price, + post.category, + post.content, + post.images, + ); + }; + + const handleChatClick = () => { + openChat({ postingId: post.postingId, otherId: post.sellerId }); + }; + + const handleDeleteClick = () => { + openModal("confirm", { + message: "정말 이 게시물을 삭제하시겠습니까?", + onConfirm: async () => { + try { + await fetch(`/api/postings/${post.postingId}`, { method: "DELETE" }); + queryClient.removeQueries({ + queryKey: ["postDetail", post.postingId], + }); + queryClient.invalidateQueries({ + queryKey: ["sellerPosts", post.sellerId], + }); + closeModal(); + openModal("normal", { + message: "삭제가 완료되었습니다.", + onClick: () => { + closeModal(); + router.push("/"); + }, + }); + } catch (err) { + console.error("게시물 삭제 실패:", err); + } + }, + onCancel: closeModal, + }); + }; + + return ( +
+
+ +
+
+ + {seller?.imageUrl ? ( + {`${seller.nickname} + ) : ( + + )} + +
+ + {seller?.nickname || "판매자"} + +
+
+
+
+
+

{post.title}

+

{post.price.toLocaleString()}

+

+ {post.content} +

+
+
+ + 채팅 {post.chatCount}· + 관심 {post.likeCount}· + 조회 {post.viewCount} + +
+ {post.isOwner ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+
+ ); +} diff --git a/src/widgets/postDetail/ui/SellerInfo.tsx b/src/widgets/postDetail/ui/SellerInfo.tsx deleted file mode 100644 index 6a530bef..00000000 --- a/src/widgets/postDetail/ui/SellerInfo.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; -import Link from "next/link"; -import UserIcon from "@/shared/images/user.svg"; - -interface SellerInfoProps { - userId?: number; - nickname: string; - imageUrl?: string; -} - -export function SellerInfo({ userId, nickname, imageUrl }: SellerInfoProps) { - return ( -
-
- - {imageUrl ? ( - {`${nickname} - ) : ( - - )} - -
- - {nickname} - -
- ); -} diff --git a/src/widgets/postDetail/ui/SellerPostsSection.tsx b/src/widgets/postDetail/ui/SellerPostsSection.tsx new file mode 100644 index 00000000..d4a7258d --- /dev/null +++ b/src/widgets/postDetail/ui/SellerPostsSection.tsx @@ -0,0 +1,61 @@ +"use client"; + +import PostCard from "@/entities/post/ui/card/PostCard"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useInfiniteScroll } from "@/shared/lib/useInfiniteScroll"; +import { getSellerPosts } from "@/entities/post/api/getSellerPosts"; +import { POST_PAGE_SIZE } from "@/entities/post/model/constants/api"; + +export function SellerPostsSection({ sellerId }: { sellerId: number }) { + const { + data: sellerPostsData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey: ["sellerPosts", sellerId], + queryFn: ({ pageParam = 1 }) => + getSellerPosts({ + userId: sellerId, + page: pageParam, + size: POST_PAGE_SIZE, + }), + enabled: !!sellerId, + getNextPageParam: (lastPage, allPages) => + lastPage.data.length < POST_PAGE_SIZE ? undefined : allPages.length + 1, + initialPageParam: 1, + }); + + const lastPostRef = useInfiniteScroll( + () => { + if (hasNextPage && !isFetchingNextPage) fetchNextPage(); + }, + isFetchingNextPage, + !!hasNextPage, + ); + + const sellerPosts = sellerPostsData?.pages.flatMap((p) => p.data) ?? []; + + return ( +
+
+

판매한 상품

+ +
+ {sellerPosts.map((item, idx) => + idx === sellerPosts.length - 1 ? ( +
+ +
+ ) : ( + + ), + )} +
+ + {isFetchingNextPage && ( +

불러오는 중...

+ )} +
+ ); +}