diff --git a/src/app/wines/[id]/_components/wine-detail/wine-detail-content.tsx b/src/app/wines/[id]/_components/wine-detail/wine-detail-content.tsx index d646a39b..ec6f86af 100644 --- a/src/app/wines/[id]/_components/wine-detail/wine-detail-content.tsx +++ b/src/app/wines/[id]/_components/wine-detail/wine-detail-content.tsx @@ -68,7 +68,7 @@ const WineDetailContent = ({ wineId }: WineDetailContentProps) => { {/* 구분선 */}
-
+
{/* 리뷰 섹션 */} diff --git a/src/app/wines/[id]/_components/wine-flavor/wine-flavor-section.tsx b/src/app/wines/[id]/_components/wine-flavor/wine-flavor-section.tsx index 1a573f31..6c3fe013 100644 --- a/src/app/wines/[id]/_components/wine-flavor/wine-flavor-section.tsx +++ b/src/app/wines/[id]/_components/wine-flavor/wine-flavor-section.tsx @@ -2,29 +2,35 @@ import Flavor from "@/components/flavor/flavor"; import type { AromaKey } from "@/types/aroma-type"; import type { Review } from "@/types/wine"; import { cn } from "@/lib/utils"; + interface FlavorSectionProps { reviews: Review[]; reviewCount: number; } + const getTopAromas = (reviews: Review[]): AromaKey[] => { const aromaCount = new Map(); + reviews.forEach((review) => { review.aroma.forEach((aroma) => { aromaCount.set(aroma, (aromaCount.get(aroma) || 0) + 1); }); }); - const sortedAromas = Array.from(aromaCount.entries()) + + return Array.from(aromaCount.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 4) .map(([aroma]) => aroma); - return sortedAromas; }; + const FlavorSection = ({ reviews, reviewCount }: FlavorSectionProps) => { const topAromas = getTopAromas(reviews); const displayAromas: AromaKey[] = [...topAromas]; + while (displayAromas.length < 4) { displayAromas.push("EMPTY"); } + return (
{ "pc:flex pc:flex-col pc:gap-6" )} > - {/* 제목 + 참여 인원 */}
{

어떤 향이 있나요?

- + ({reviewCount}명 참여)
- {/* Flavor 컴포넌트 */} -
-
-
- -
+ +
+
+
); }; + export default FlavorSection; diff --git a/src/app/wines/[id]/_components/wine-header/wine-header.tsx b/src/app/wines/[id]/_components/wine-header/wine-header.tsx index 9a97840d..7b15f704 100644 --- a/src/app/wines/[id]/_components/wine-header/wine-header.tsx +++ b/src/app/wines/[id]/_components/wine-header/wine-header.tsx @@ -12,7 +12,7 @@ const reviewLabel = (count: number) => const WineHeader = ({ wine }: WineHeaderProps) => { return ( -
+
{ - return typeof review.isLiked === "boolean" - ? review.isLiked - : Object.keys(review.isLiked).length > 0; + const derivedIsLiked = useMemo(() => { + if (typeof review.isLiked === "boolean") { + return review.isLiked; + } + if (!review.isLiked) { + return false; + } + return Object.keys(review.isLiked).length > 0; }, [review.isLiked]); const [isTasteOpen, setIsTasteOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [liked, setLiked] = useState(derivedIsLiked); const { isOn: isMenuOpen, @@ -97,8 +102,7 @@ const WineReviewItem = ({ const { mutate: toggleLike, isPending: likePending } = useReviewLike( review.id, - wineId, - isLiked + wineId ); const { mutate: deleteReview, isPending: deletePending } = useReviewDelete({ @@ -131,12 +135,9 @@ const WineReviewItem = ({ }; const handleDeleteConfirm = () => { - const scrollYBeforeDelete = window.scrollY; - deleteReview(undefined, { onSuccess: () => { setIsDeleteModalOpen(false); - requestAnimationFrame(() => window.scrollTo(0, scrollYBeforeDelete)); reviewDeleteSuccess(); }, onError: () => { @@ -145,11 +146,22 @@ const WineReviewItem = ({ }); }; + const handleLikeClick = () => { + const next = !liked; + setLiked(next); + + toggleLike(next, { + onError: () => { + setLiked((prev) => !prev); + }, + }); + }; + return ( <>
{!isMyReview && ( { - toggleLike(); - }} - aria-label={isLiked ? "좋아요 취소" : "좋아요"} + onClick={handleLikeClick} + aria-label={liked ? "좋아요 취소" : "좋아요"} /> )} {isMyReview && ( diff --git a/src/app/wines/[id]/_components/wine-review/wine-review-list-header.tsx b/src/app/wines/[id]/_components/wine-review/wine-review-list-header.tsx index 76cb3b7c..630b9fe1 100644 --- a/src/app/wines/[id]/_components/wine-review/wine-review-list-header.tsx +++ b/src/app/wines/[id]/_components/wine-review/wine-review-list-header.tsx @@ -2,7 +2,7 @@ interface ReviewListHeaderProps { totalCount: number; } const ReviewListHeader = ({ totalCount }: ReviewListHeaderProps) => ( -
+

리뷰 목록

{totalCount.toLocaleString()}개 diff --git a/src/app/wines/[id]/_components/wine-review/wine-review-section.tsx b/src/app/wines/[id]/_components/wine-review/wine-review-section.tsx index 09171a0d..eed9df4e 100644 --- a/src/app/wines/[id]/_components/wine-review/wine-review-section.tsx +++ b/src/app/wines/[id]/_components/wine-review/wine-review-section.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import RatingDistribution from "@/components/rating/rating-distribution"; import ReviewListHeader from "./wine-review-list-header"; @@ -35,16 +35,31 @@ const ReviewSection = ({ wineId, currentUserId, }: ReviewSectionProps) => { - const [visibleCount, setVisibleCount] = useState(REVIEW_PIECES); + const [visibleCount, setVisibleCount] = useState(() => + Math.min(REVIEW_PIECES, reviews.length) + ); + const visibleReviews = reviews.slice(0, visibleCount); - const hasMore = visibleCount < reviews.length; + + const hasMore = + reviews.length > REVIEW_PIECES && visibleCount < reviews.length; + + const prevLengthRef = useRef(reviews.length); useEffect(() => { - setVisibleCount((prev) => { - const nextLen = reviews.length; - if (nextLen < prev) return nextLen; - return prev; - }); + const prevLength = prevLengthRef.current; + const scrollY = window.scrollY; + + if (reviews.length > prevLength) { + setVisibleCount(Math.min(REVIEW_PIECES, reviews.length)); + } else if (reviews.length < prevLength) { + setVisibleCount((prev) => Math.min(prev, reviews.length)); + requestAnimationFrame(() => { + window.scrollTo(0, scrollY); + }); + } + + prevLengthRef.current = reviews.length; }, [reviews.length]); const handleLoadMore = () => { diff --git a/src/app/wines/[id]/_components/wine-taste/wine-taste-section.tsx b/src/app/wines/[id]/_components/wine-taste/wine-taste-section.tsx index 0552acf0..491a72ea 100644 --- a/src/app/wines/[id]/_components/wine-taste/wine-taste-section.tsx +++ b/src/app/wines/[id]/_components/wine-taste/wine-taste-section.tsx @@ -54,7 +54,7 @@ const WineTasteSection = ({ reviews, reviewCount }: WineTasteSectionProps) => { {/* 제목 + 갯수 */}
{ {/* 와인 맛 분포 컴포넌트 */}
diff --git a/src/app/wines/[id]/_hooks/use-review-mutation.ts b/src/app/wines/[id]/_hooks/use-review-mutation.ts index 46fe243f..e956bfdc 100644 --- a/src/app/wines/[id]/_hooks/use-review-mutation.ts +++ b/src/app/wines/[id]/_hooks/use-review-mutation.ts @@ -3,11 +3,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import postReview from "@/api/reviews/post-review"; import patchReview from "@/api/reviews/patch-review"; -import type { ReviewBase } from "@/types/wine"; +import type { ReviewBase, Review } from "@/types/wine"; interface ReviewMutationOptions { mode: "create" | "edit"; - reviewId?: number; // edit일 때만 필요 + reviewId?: number; } const useReviewMutation = ({ mode, reviewId }: ReviewMutationOptions) => { @@ -21,23 +21,35 @@ const useReviewMutation = ({ mode, reviewId }: ReviewMutationOptions) => { } return postReview(data); }, - onSuccess: (_, variables) => { - queryClient.invalidateQueries({ - queryKey: ["reviews", variables.wineId], - }); - queryClient.invalidateQueries({ - queryKey: ["user-review"], + onSuccess: (response, variables) => { + const { wineId } = variables; + + queryClient.setQueryData(["wine", wineId], (old: any) => { + if (!old) return old; + + if (mode === "create") { + return { + ...old, + reviews: [response, ...old.reviews], + reviewCount: (old.reviewCount || 0) + 1, + }; + } else if (mode === "edit") { + return { + ...old, + reviews: old.reviews.map((r: Review) => + r.id === reviewId ? response : r + ), + }; + } + return old; }); - queryClient.invalidateQueries({ queryKey: ["wine", variables.wineId] }); + if (mode === "edit" && reviewId) { - queryClient.invalidateQueries({ queryKey: ["review", reviewId] }); + queryClient.setQueryData(["review", reviewId], response); } - }, - onError: (error: unknown) => { - console.error( - mode === "edit" ? "리뷰 수정 실패:" : "리뷰 등록 실패:", - error - ); + + queryClient.invalidateQueries({ queryKey: ["wine", wineId] }); + queryClient.invalidateQueries({ queryKey: ["user-review"] }); }, }); }; diff --git a/src/components/flavor/flavor.tsx b/src/components/flavor/flavor.tsx index 71d14145..3981f708 100644 --- a/src/components/flavor/flavor.tsx +++ b/src/components/flavor/flavor.tsx @@ -1,19 +1,22 @@ import { AromaKey } from "@/types/aroma-type"; import Image from "next/image"; import { aromaMap } from "./aroma-map"; +import { cn } from "@/lib/utils"; + interface FlavorItemProps { aroma: AromaKey; + className?: string; } -const FlavorItem = ({ aroma }: FlavorItemProps) => { +const FlavorItem = ({ aroma, className }: FlavorItemProps) => { const { img, label } = aromaMap[aroma]; return ( -
+
{label} {label} @@ -24,22 +27,29 @@ const FlavorItem = ({ aroma }: FlavorItemProps) => { interface FlavorProps { count: number; items: AromaKey[]; + showHeader?: boolean; } -const Flavor = ({ count, items }: FlavorProps) => { +const Flavor = ({ count, items, showHeader = true }: FlavorProps) => { return ( -
-
-

- 어떤 향이 있나요? -

- - ({count}명 참여) - -
+
+ {showHeader && ( +
+

+ 어떤 향이 있나요? +

+ + ({count}명 참여) + +
+ )}
-
+
{items.map((item, index) => ( - + ))}
diff --git a/src/components/modal/page-modal.tsx b/src/components/modal/page-modal.tsx index 70b8c3df..73b5b562 100644 --- a/src/components/modal/page-modal.tsx +++ b/src/components/modal/page-modal.tsx @@ -37,6 +37,9 @@ const PageModal = ({ return () => { allowScroll(currentScrollY); + requestAnimationFrame(() => { + window.scrollTo(0, currentScrollY); + }); }; }, []); diff --git a/src/hooks/api/reviews/use-review-delete.ts b/src/hooks/api/reviews/use-review-delete.ts index 03ff53b3..c19c2c34 100644 --- a/src/hooks/api/reviews/use-review-delete.ts +++ b/src/hooks/api/reviews/use-review-delete.ts @@ -14,12 +14,17 @@ const useReviewDelete = ({ wineId, reviewId }: UseReviewDeleteOptions) => { return useMutation({ mutationFn: () => deleteReview({ id: reviewId }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["reviews", wineId] }); - queryClient.invalidateQueries({ queryKey: ["wine", wineId] }); + queryClient.setQueryData(["wine", wineId], (old: any) => { + if (!old) return old; + return { + ...old, + reviews: old.reviews.filter((r: any) => r.id !== reviewId), + reviewCount: Math.max((old.reviewCount || 1) - 1, 0), + }; + }); + queryClient.invalidateQueries({ queryKey: ["review", reviewId] }); - }, - onError: (error: unknown) => { - console.error("리뷰 삭제 실패:", error); + queryClient.invalidateQueries({ queryKey: ["user-review"] }); }, }); }; diff --git a/src/hooks/api/reviews/use-review-like.ts b/src/hooks/api/reviews/use-review-like.ts index 05ad4df6..51484844 100644 --- a/src/hooks/api/reviews/use-review-like.ts +++ b/src/hooks/api/reviews/use-review-like.ts @@ -1,18 +1,31 @@ -"use client"; - import { useMutation, useQueryClient } from "@tanstack/react-query"; import postLike from "@/api/reviews/post-like"; import deleteLike from "@/api/reviews/delete-like"; +import type { Review } from "@/types/wine"; -const useReviewLike = (reviewId: number, wineId: number, isLiked: boolean) => { +const useReviewLike = (reviewId: number, wineId: number) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: () => (isLiked ? deleteLike(reviewId) : postLike(reviewId)), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["reviews", wineId] }); - queryClient.invalidateQueries({ queryKey: ["wine", wineId] }); - queryClient.invalidateQueries({ queryKey: ["review", reviewId] }); + mutationFn: (nextLike: boolean) => + nextLike ? postLike(reviewId) : deleteLike(reviewId), + onMutate: async (nextLike) => { + await queryClient.cancelQueries({ queryKey: ["wine", wineId] }); + const previous = queryClient.getQueryData(["wine", wineId]); + + queryClient.setQueryData(["wine", wineId], (old: any) => ({ + ...old, + reviews: old?.reviews.map((item: Review) => + item.id === reviewId ? { ...item, isLiked: nextLike } : item + ), + })); + + return { previous }; + }, + onError: (_err, _nextLike, context) => { + if (context?.previous) { + queryClient.setQueryData(["wine", wineId], context.previous); + } }, }); };