Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const WineDetailContent = ({ wineId }: WineDetailContentProps) => {

{/* 구분선 */}
<div className="container">
<hr className="border-t border-gray-300" />
<hr className="mt-[-5px] border-t border-gray-300 tablet:mt-0 pc:mt-0" />
</div>

{/* 리뷰 섹션 */}
Expand Down
30 changes: 19 additions & 11 deletions src/app/wines/[id]/_components/wine-flavor/wine-flavor-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AromaKey, number>();

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 (
<div
className={cn(
Expand All @@ -33,30 +39,32 @@ const FlavorSection = ({ reviews, reviewCount }: FlavorSectionProps) => {
"pc:flex pc:flex-col pc:gap-6"
)}
>
{/* 제목 + 참여 인원 */}
<div
className={cn(
"mb-4 flex flex-col items-start gap-1",
"mb-4 flex select-none flex-col items-start gap-1",
"tablet:mb-0 tablet:ml-9 tablet:flex-col tablet:gap-3",
"pc:flex-row pc:items-center pc:justify-between"
)}
>
<h2 className="text-heading-lg text-gray-900 pc:ml-10">
어떤 향이 있나요?
</h2>
<span className="text-body-sm text-gray-400">
<span className="mr-8 text-body-sm text-gray-400">
({reviewCount}명 참여)
</span>
</div>
{/* Flavor 컴포넌트 */}
<div className="flex w-full justify-center tablet:justify-start">
<div className="w-full max-w-[300px] tablet:max-w-none pc:ml-16 pc:max-w-none">
<div className="scrollbar-hide overflow-hidden [&>div>div:first-child]:hidden">
<Flavor count={reviewCount} items={displayAromas} />
</div>

<div className="flex w-full select-none justify-center tablet:justify-start">
<div className="w-full max-w-[340px] tablet:max-w-[440px] pc:ml-10 pc:max-w-none">
<Flavor
count={reviewCount}
items={displayAromas}
showHeader={false}
/>
</div>
</div>
</div>
);
};

export default FlavorSection;
2 changes: 1 addition & 1 deletion src/app/wines/[id]/_components/wine-header/wine-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const reviewLabel = (count: number) =>

const WineHeader = ({ wine }: WineHeaderProps) => {
return (
<div className="container relative py-8 tablet:py-10 pc:py-12">
<div className="container relative select-none py-8 tablet:py-10 pc:py-12">
<div className="absolute inset-0 -z-10">
<Image
src="/images/wines/bg-recommended.png"
Expand Down
40 changes: 25 additions & 15 deletions src/app/wines/[id]/_components/wine-review/wine-review-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,19 @@ const WineReviewItem = ({
const router = useRouter();
const { reviewDeleteSuccess, reviewDeleteError } = useToast();

const isLiked = useMemo(() => {
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,
Expand All @@ -97,8 +102,7 @@ const WineReviewItem = ({

const { mutate: toggleLike, isPending: likePending } = useReviewLike(
review.id,
wineId,
isLiked
wineId
);

const { mutate: deleteReview, isPending: deletePending } = useReviewDelete({
Expand Down Expand Up @@ -131,12 +135,9 @@ const WineReviewItem = ({
};

const handleDeleteConfirm = () => {
const scrollYBeforeDelete = window.scrollY;

deleteReview(undefined, {
onSuccess: () => {
setIsDeleteModalOpen(false);
requestAnimationFrame(() => window.scrollTo(0, scrollYBeforeDelete));
reviewDeleteSuccess();
},
onError: () => {
Expand All @@ -145,11 +146,22 @@ const WineReviewItem = ({
});
};

const handleLikeClick = () => {
const next = !liked;
setLiked(next);

toggleLike(next, {
onError: () => {
setLiked((prev) => !prev);
},
});
};

return (
<>
<article
className={cn(
"flex w-full flex-col items-center gap-6 py-8",
"flex w-full select-none flex-col items-center gap-6 py-8",
"tablet:items-center tablet:gap-8 tablet:py-10",
"pc:gap-6 pc:py-8",
!isFirst && "border-t border-gray-300"
Expand All @@ -176,12 +188,10 @@ const WineReviewItem = ({
<div className="flex items-center gap-2">
{!isMyReview && (
<LikeButton
isLike={isLiked}
isLike={liked}
disabled={likePending}
onClick={() => {
toggleLike();
}}
aria-label={isLiked ? "좋아요 취소" : "좋아요"}
onClick={handleLikeClick}
aria-label={liked ? "좋아요 취소" : "좋아요"}
/>
)}
{isMyReview && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ interface ReviewListHeaderProps {
totalCount: number;
}
const ReviewListHeader = ({ totalCount }: ReviewListHeaderProps) => (
<div className="mb-4 flex items-center gap-3 tablet:mb-6 pc:mb-6">
<div className="mb-4 flex select-none items-center gap-3 tablet:mb-6 pc:mb-6">
<h2 className="text-heading-lg text-gray-900">리뷰 목록</h2>
<span className="text-body-md text-gray-500">
{totalCount.toLocaleString()}개
Expand Down
31 changes: 23 additions & 8 deletions src/app/wines/[id]/_components/wine-review/wine-review-section.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const WineTasteSection = ({ reviews, reviewCount }: WineTasteSectionProps) => {
{/* 제목 + 갯수 */}
<div
className={cn(
"mb-4 flex flex-col items-start gap-1",
"mb-4 flex select-none flex-col items-start gap-1",
"tablet:mb-0 tablet:ml-9 tablet:flex-col tablet:gap-3",
"pc:flex-row pc:items-center pc:justify-between pc:pr-6"
)}
Expand All @@ -68,7 +68,7 @@ const WineTasteSection = ({ reviews, reviewCount }: WineTasteSectionProps) => {
{/* 와인 맛 분포 컴포넌트 */}
<section
aria-label={`와인 맛 분포: ${tasteA11yLabel}`}
className="flex w-full justify-center tablet:justify-start"
className="flex w-full select-none justify-center tablet:justify-start"
>
<div className="w-full max-w-[470px] tablet:max-w-[480px] pc:max-w-[560px]">
<WineTaste type="detail" tastes={tastes} />
Expand Down
44 changes: 28 additions & 16 deletions src/app/wines/[id]/_hooks/use-review-mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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"] });
},
});
};
Expand Down
44 changes: 27 additions & 17 deletions src/components/flavor/flavor.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex shrink-0 flex-col items-center gap-[14px] tablet:w-[100px] pc:w-[100px]">
<div className={cn("flex shrink-0 flex-col items-center gap-3", className)}>
<Image
src={img}
alt={label}
width={90}
height={90}
className="rounded-4 h-[90px] w-full tablet:h-[100px] pc:h-[100px]"
width={100}
height={100}
className="rounded-4"
/>
<span className="select-none text-body-md tracking-[-0.02em]">
{label}
Expand All @@ -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 (
<div className="flex min-h-[197px] w-full flex-col items-start justify-between gap-[17px]">
<div>
<h2 className="text-heading-lg tracking-[-0.48px] text-gray-900">
어떤 향이 있나요?
</h2>
<span className="text-body-sm tracking-[-0.28px] text-gray-400">
(<span>{count}</span>명 참여)
</span>
</div>
<div className="flex min-h-[197px] w-full flex-col items-start justify-between gap-4">
{showHeader && (
<div>
<h2 className="text-heading-lg tracking-[-0.48px] text-gray-900">
어떤 향이 있나요?
</h2>
<span className="text-body-sm tracking-[-0.28px] text-gray-400">
(<span>{count}</span>명 참여)
</span>
</div>
)}
<div className="scrollbar-hide overflow-hidden">
<div className="flex flex-nowrap gap-4">
<div className="flex flex-nowrap gap-4 tablet:gap-3 pc:gap-3">
{items.map((item, index) => (
<FlavorItem aroma={item} key={index} />
<FlavorItem
aroma={item}
key={index}
className={cn(index === 3 && "hidden tablet:hidden pc:flex")}
/>
))}
</div>
</div>
Expand Down
Loading