diff --git a/src/app/(with-header)/wines/[id]/_components/NoReview.tsx b/src/app/(with-header)/wines/[id]/_components/NoReview.tsx index 4dadd36..441510b 100644 --- a/src/app/(with-header)/wines/[id]/_components/NoReview.tsx +++ b/src/app/(with-header)/wines/[id]/_components/NoReview.tsx @@ -1,7 +1,7 @@ import Image from 'next/image'; -import emptyReview from '@/assets/icons/empty_review.svg'; -import PostReviewModal from '@/components/modal/PostReviewModal'; import { AddReviewData } from '@/types/review-data'; +import PostReviewModal from '@/components/modal/PostReviewModal'; +import emptyReview from '@/assets/icons/empty_review.svg'; export default function NoReview({ addReview }: { addReview: (newReview: AddReviewData) => void }) { return ( diff --git a/src/app/(with-header)/wines/[id]/_components/ReviewAroma.tsx b/src/app/(with-header)/wines/[id]/_components/ReviewAroma.tsx index c2fa6fc..ce32da7 100644 --- a/src/app/(with-header)/wines/[id]/_components/ReviewAroma.tsx +++ b/src/app/(with-header)/wines/[id]/_components/ReviewAroma.tsx @@ -1,10 +1,10 @@ import Image from 'next/image'; import { aromaTraslations } from '@/constants/aromaTranslation'; -interface ReviewAromaProps { +type ReviewAromaProps = { selectedAroma: string[]; count: number; -} +}; export default function ReviewAroma({ selectedAroma, count }: ReviewAromaProps) { return ( diff --git a/src/app/(with-header)/wines/[id]/_components/ReviewContainer.tsx b/src/app/(with-header)/wines/[id]/_components/ReviewContainer.tsx index df6591e..f98df5c 100644 --- a/src/app/(with-header)/wines/[id]/_components/ReviewContainer.tsx +++ b/src/app/(with-header)/wines/[id]/_components/ReviewContainer.tsx @@ -32,15 +32,25 @@ function ReviewList({ } export default function ReviewContainer({ data }: { data: ReviewData }) { - const { reviews, avgRating } = data; - const [localReviews, setLocalReviews] = useState(reviews); + const [localReviews, setLocalReviews] = useState(data.reviews); + const [averageRating, setAverageRating] = useState(data.avgRating); + + const recalculateAverageRating = (reviews: ReviewData['reviews']) => (reviews.length ? reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length : 0); + + const updateReviews = (updater: (prev: ReviewData['reviews']) => ReviewData['reviews']) => { + setLocalReviews((prev) => { + const updatedReviews = updater(prev); + setAverageRating(recalculateAverageRating(updatedReviews)); + return updatedReviews; + }); + }; const deleteMyReview = (id: number) => { - setLocalReviews((prevReviews) => prevReviews.filter((review) => review.id !== id)); + updateReviews((prev) => prev.filter((review) => review.id !== id)); }; const editMyReview = (id: number, editReviewData: EditReviewData, updatedAt: string) => { - setLocalReviews((prevReviews) => prevReviews.map((review) => (review.id === id ? { ...review, ...editReviewData, updatedAt } : review))); + updateReviews((prev) => prev.map((review) => (review.id === id ? { ...review, ...editReviewData, updatedAt } : review))); }; const addReview = (newReview: AddReviewData) => { @@ -53,27 +63,28 @@ export default function ReviewContainer({ data }: { data: ReviewData }) { isLiked: false, }; - setLocalReviews((prevReviews) => [formattedReview, ...prevReviews]); + updateReviews((prev) => [formattedReview, ...prev]); }; - const averages = calculateTasteAverage(reviews); - const topThreeAromas = getTopThreeAromas(reviews); - const ratingPercentages = calculateRatingCount(reviews); + const averages = calculateTasteAverage(localReviews); + const topThreeAromas = getTopThreeAromas(localReviews); + const ratingPercentages = calculateRatingCount(localReviews); + return (
- {reviews.length > 0 ? ( + {localReviews.length > 0 ? (
- +
@@ -82,7 +93,7 @@ export default function ReviewContainer({ data }: { data: ReviewData }) {
- +
diff --git a/src/app/(with-header)/wines/[id]/_components/ReviewDropdown.tsx b/src/app/(with-header)/wines/[id]/_components/ReviewDropdown.tsx index caf9c58..4c1c8b5 100644 --- a/src/app/(with-header)/wines/[id]/_components/ReviewDropdown.tsx +++ b/src/app/(with-header)/wines/[id]/_components/ReviewDropdown.tsx @@ -1,14 +1,15 @@ 'use client'; import Image from 'next/image'; -import Dropdown from '@/components/Dropdown'; -import menu from '@/assets/icons/menu.svg'; -import { fetchWithAuth } from '@/lib/auth'; -import Modal from '@/components/modal/Modal'; -import DeleteWineForm from '@/components/modal/DeleteWineModal'; -import PatchReviewForm from '@/components/modal/PatchReviewForm'; import { useState } from 'react'; +import { fetchWithAuth } from '@/lib/auth'; import { MyReview } from '@/types/review-data'; import { EditReviewData } from '@/types/review-data'; +import { toast } from 'react-toastify'; +import Dropdown from '@/components/Dropdown'; +import Modal from '@/components/modal/Modal'; +import PatchReviewForm from '@/components/modal/PatchReviewForm'; +import DeleteWineForm from '@/components/modal/DeleteWineModal'; +import menu from '@/assets/icons/menu.svg'; export default function ReviewDropdown({ id, @@ -54,7 +55,7 @@ export default function ReviewDropdown({ }); if (!response?.ok || response === null) { - throw new Error('리뷰 삭제에 실패했습니다'); + throw new Error('리뷰 삭제에 실패했습니다.'); } const body = await response.json(); @@ -65,7 +66,10 @@ export default function ReviewDropdown({ closeDeleteModal(); } } catch (error) { - console.error('리뷰 삭제 에러:', error); + closeDeleteModal(); + console.log(status); + toast.error('리뷰 삭제에 실패했습니다.'); + console.error('리뷰 삭제 에러', error); } }; diff --git a/src/app/(with-header)/wines/[id]/_components/ReviewItem.tsx b/src/app/(with-header)/wines/[id]/_components/ReviewItem.tsx index 825d145..7e138cc 100644 --- a/src/app/(with-header)/wines/[id]/_components/ReviewItem.tsx +++ b/src/app/(with-header)/wines/[id]/_components/ReviewItem.tsx @@ -1,19 +1,19 @@ import Image from 'next/image'; import { useEffect, useState } from 'react'; +import { toast } from 'react-toastify'; +import { fetchWithAuth } from '@/lib/auth'; +import { MyReview, EditReviewData, ReviewData } from '@/types/review-data'; +import { aromaTraslations } from '@/constants/aromaTranslation'; +import elapsedTime from '@/utils/formatDate'; +import ProfileImg from '@/components/ProfileImg'; import profileDefault from '@/assets/icons/profile_default.svg'; import likeIcon from '@/assets/icons/like.svg'; import likeFilledIcon from '@/assets/icons/like_filled.svg'; import starIcon from '@/assets/icons/star_hover.svg'; import lessIcon from '@/assets/icons/less.svg'; import moreIcon from '@/assets/icons/more.svg'; -import { fetchWithAuth } from '@/lib/auth'; -import { MyReview, ReviewData } from '@/types/review-data'; -import { aromaTraslations } from '@/constants/aromaTranslation'; -import elapsedTime from '@/utils/formatDate'; -import ProfileImg from '@/components/ProfileImg'; import ReviewTasteItem from './ReviewTasteItem'; import ReviewDropdown from './ReviewDropdown'; -import { EditReviewData } from '@/types/review-data'; type ReviewItemProps = { review: ReviewData['reviews'][0]; @@ -40,7 +40,7 @@ export default function ReviewItem({ review, wineName, reviewInitialData, editMy setIsMyReview(userData.id === review.user.id); } } catch (error) { - console.error('Error fetching data:', error); + console.error('사용자 정보 불러오기 에러', error); setIsMyReview(false); } }; @@ -56,11 +56,23 @@ export default function ReviewItem({ review, wineName, reviewInitialData, editMy method: liked ? 'DELETE' : 'POST', }); + if (response?.status === 401) { + toast.error('로그인 세션이 만료되었습니다. 다시 로그인해 주세요.'); + return; + } + + if (!response?.ok) { + toast.error('좋아요를 누를 수 없습니다.'); + return; + } + if (response?.ok) { - setLiked((prevLiked) => !prevLiked); + const newLikedState = !liked; + setLiked(newLikedState); + toast.success(newLikedState ? '좋아요를 눌렀습니다.' : '좋아요를 취소했습니다.'); } } catch (error) { - console.error(error); + console.error('좋아요 에러', error); } finally { setLoading(false); } @@ -78,6 +90,7 @@ export default function ReviewItem({ review, wineName, reviewInitialData, editMy const handleDelete = () => { deleteMyReview(reviewData.id); + toast.success('리뷰 삭제에 성공했습니다.'); }; return ( diff --git a/src/app/(with-header)/wines/[id]/_components/ReviewRating.tsx b/src/app/(with-header)/wines/[id]/_components/ReviewRating.tsx index 4295bbe..3ea2b15 100644 --- a/src/app/(with-header)/wines/[id]/_components/ReviewRating.tsx +++ b/src/app/(with-header)/wines/[id]/_components/ReviewRating.tsx @@ -1,7 +1,7 @@ 'use client'; +import { AddReviewData } from '@/types/review-data'; import StaticRating from '@/components/StaticRating'; import PostReviewModal from '@/components/modal/PostReviewModal'; -import { AddReviewData } from '@/types/review-data'; type ReviewRatingProps = { count: number; diff --git a/src/app/(with-header)/wines/[id]/_components/ReviewTasteItem.tsx b/src/app/(with-header)/wines/[id]/_components/ReviewTasteItem.tsx index 5ab7de5..536291e 100644 --- a/src/app/(with-header)/wines/[id]/_components/ReviewTasteItem.tsx +++ b/src/app/(with-header)/wines/[id]/_components/ReviewTasteItem.tsx @@ -11,7 +11,7 @@ type ReviewTasteItemProps = { export default function ReviewListTasteItem({ lightBold, smoothTannic, drySweet, softAcidic, isDraggable }: ReviewTasteItemProps) { return ( -
+
{}} isDraggable={isDraggable} name='바디감' size='large' /> {}} isDraggable={false} name='타닌' size='large' /> {}} isDraggable={false} name='당도' size='large' /> diff --git a/src/app/(with-header)/wines/[id]/_components/skeleton/ReviewContainerSkeleton.tsx b/src/app/(with-header)/wines/[id]/_components/skeleton/ReviewContainerSkeleton.tsx new file mode 100644 index 0000000..e0f9e34 --- /dev/null +++ b/src/app/(with-header)/wines/[id]/_components/skeleton/ReviewContainerSkeleton.tsx @@ -0,0 +1,18 @@ +export default function ReviewContainerSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/app/(with-header)/wines/[id]/_components/skeleton/WineContainerSkeleton.tsx b/src/app/(with-header)/wines/[id]/_components/skeleton/WineContainerSkeleton.tsx index e2b4149..610bc58 100644 --- a/src/app/(with-header)/wines/[id]/_components/skeleton/WineContainerSkeleton.tsx +++ b/src/app/(with-header)/wines/[id]/_components/skeleton/WineContainerSkeleton.tsx @@ -1,10 +1,10 @@ export default function WineContainerSkeleton() { return (
-
+
-
+
diff --git a/src/app/(with-header)/wines/[id]/_components/skeleton/WineDetailSkeleton.tsx b/src/app/(with-header)/wines/[id]/_components/skeleton/WineDetailSkeleton.tsx new file mode 100644 index 0000000..32cc1da --- /dev/null +++ b/src/app/(with-header)/wines/[id]/_components/skeleton/WineDetailSkeleton.tsx @@ -0,0 +1,11 @@ +import WineContainerSkeleton from './WineContainerSkeleton'; +import ReviewContainerSkeleton from './ReviewContainerSkeleton'; + +export default function WineDetailSkeleton() { + return ( + <> + + + + ); +} diff --git a/src/app/(with-header)/wines/[id]/page.tsx b/src/app/(with-header)/wines/[id]/page.tsx index ffef1c4..403a958 100644 --- a/src/app/(with-header)/wines/[id]/page.tsx +++ b/src/app/(with-header)/wines/[id]/page.tsx @@ -1,15 +1,13 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { useParams } from 'next/navigation'; +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; import { fetchWineDetail } from '@/lib/fetchWineDetail'; import { ReviewData } from '@/types/review-data'; -import ReviewContainer from './_components/ReviewContainer'; import WineContainer from './_components/WineContainer'; -import WineContainerSkeleton from './_components/skeleton/WineContainerSkeleton'; +import ReviewContainer from './_components/ReviewContainer'; +import WineDetailSkeleton from './_components/skeleton/WineDetailSkeleton'; import NotFound from '@/app/not-found'; import Refresh from '@/components/Refresh'; -import { useRouter } from 'next/navigation'; -import { useCallback } from 'react'; export default function Page() { const { id } = useParams(); @@ -34,7 +32,7 @@ export default function Page() { if (err.message === 'NOT_FOUND') { setNotFound(true); } else if (err.message === 'UNAUTHORIZED') { - alert('로그인 후, 이용해 주세요.'); + alert('로그인 후 이용 가능합니다.'); router.push('/signin'); } else { setError(true); @@ -58,7 +56,7 @@ export default function Page() { } }, [wineId, fetchWineData]); - if (loading) return ; + if (loading) return ; if (notFound || isNaN(wineId)) return ; if (error) { return ( diff --git a/src/components/ControlBar.tsx b/src/components/ControlBar.tsx index af053f3..121534e 100644 --- a/src/components/ControlBar.tsx +++ b/src/components/ControlBar.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; type ControlBarProps = { + reset?: boolean; label: string; minLabel: string; maxLabel: string; @@ -12,7 +13,7 @@ type ControlBarProps = { onChange: (value: number) => void; }; -export default function ControlBar({ label, minLabel, maxLabel, value, isDraggable, size = 'large', onChange, name }: ControlBarProps) { +export default function ControlBar({ reset = false, label, minLabel, maxLabel, value, isDraggable, size = 'large', onChange, name }: ControlBarProps) { const controlBarStyle = size === 'large' ? 'max-w-[720px] h-[28px] tablet:max-w-[880px] tablet:h-[26px] mobile:max-w-[600px] mobile:h-[30px]' @@ -30,6 +31,12 @@ export default function ControlBar({ label, minLabel, maxLabel, value, isDraggab setDragValue(value); }, [value]); + useEffect(() => { + if (value === 0) { + setDragValue(0); + } + }, [reset, value]); + const handleMouseDown = (e: React.MouseEvent) => { if (!isDraggable || !controlBarRef.current) return; @@ -47,7 +54,7 @@ export default function ControlBar({ label, minLabel, maxLabel, value, isDraggab const controlBarWidth = controlBarRef.current!.offsetWidth; let newValue = initialValue.current + (deltaX / controlBarWidth) * 10; - newValue = Math.min(Math.max(Math.round(newValue), 1), 10); + newValue = Math.min(Math.max(Math.round(newValue), 0), 10); setDragValue(newValue); onChange(newValue); }; @@ -74,9 +81,9 @@ export default function ControlBar({ label, minLabel, maxLabel, value, isDraggab {label} {minLabel} -
+
diff --git a/src/components/InteractiveRating.tsx b/src/components/InteractiveRating.tsx index e0ab048..72f8e04 100644 --- a/src/components/InteractiveRating.tsx +++ b/src/components/InteractiveRating.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Rating } from '@mui/material'; import StarIcon from '@mui/icons-material/Star'; @@ -7,11 +7,18 @@ interface InteractiveRatingProps { onChange: (newValue: number) => void; size?: 'small' | 'medium' | 'large'; className?: string; + resetTrigger?: boolean; } -export default function InteractiveRating({ initialValue = 0, onChange, size = 'large', className = '' }: InteractiveRatingProps) { +export default function InteractiveRating({ initialValue = 0, onChange, size = 'large', className = '', resetTrigger = false }: InteractiveRatingProps) { const [value, setValue] = useState(initialValue); + useEffect(() => { + if (!initialValue) { + setValue(0); + } + }, [resetTrigger, initialValue]); + return ( (false); const [wineData, setWineData] = useState(INICIALVALUES); const [selectedAroma, setSelectedAroma] = useState([]); + const [resetTrigger, setResetTrigger] = useState(false); const { id } = useParams<{ id: string }>(); - const router = useRouter(); const { register, handleSubmit, setValue, watch, reset } = useForm({ defaultValues: { @@ -96,6 +96,7 @@ export default function PostReviewModal({ addReview }: { addReview: (newReview: const closeModal = () => { setSelectedAroma([]); + setResetTrigger((prev) => !prev); reset({ rating: 0, lightBold: 0, @@ -137,8 +138,7 @@ export default function PostReviewModal({ addReview }: { addReview: (newReview: }); if (!response?.ok || response === null) { - alert('리뷰 등록에 실패했습니다.'); - throw new Error('리뷰 등록에 실패했습니다'); + throw new Error('리뷰 등록에 실패했습니다.'); } const body = await response.json(); @@ -161,6 +161,7 @@ export default function PostReviewModal({ addReview }: { addReview: (newReview: wineName: wineData.name, }; addReview(newReview); + setResetTrigger((prev) => !prev); reset({ rating: 0, lightBold: 0, @@ -172,18 +173,13 @@ export default function PostReviewModal({ addReview }: { addReview: (newReview: wineId: wineData.id, }); setSelectedAroma([]); - setValue('rating', -1); - setValue('lightBold', -1); - setValue('smoothTannic', -1); - setValue('drySweet', -1); - setValue('softAcidic', -1); - setValue('aroma', []); setIsOpen(false); + toast.success('리뷰 등록에 성공했습니다.'); } } catch (error) { - alert('로그인이 만료되었습니다. 로그인 후, 다시 시도해 주세요.'); + setIsOpen(false); + toast.error('리뷰 등록에 실패했습니다.'); console.error('리뷰 등록 에러:', error); - router.push('/signin'); } }; @@ -235,7 +231,7 @@ export default function PostReviewModal({ addReview }: { addReview: (newReview: 와인 이미지

{wineData.name}

- setValue('rating', rate)} /> + setValue('rating', rate)} />