diff --git a/src/app/(with-header)/wines/[id]/_components/NoReview.tsx b/src/app/(with-header)/wines/[id]/_components/NoReview.tsx index ce31971..156f199 100644 --- a/src/app/(with-header)/wines/[id]/_components/NoReview.tsx +++ b/src/app/(with-header)/wines/[id]/_components/NoReview.tsx @@ -1,15 +1,16 @@ import Image from 'next/image'; import emptyReview from '@/assets/icons/empty_review.svg'; import PostReviewModal from '@/components/modal/PostReviewModal'; +import { AddReviewData } from './ReviewContainer'; -export default function NoReview() { +export default function NoReview({ addReview }: { addReview: (newReview: AddReviewData) => void }) { return (
리뷰 0개
작성된 리뷰가 없어요
- +
diff --git a/src/app/(with-header)/wines/[id]/_components/ReviewContainer.tsx b/src/app/(with-header)/wines/[id]/_components/ReviewContainer.tsx index 7e42c3a..cca64f8 100644 --- a/src/app/(with-header)/wines/[id]/_components/ReviewContainer.tsx +++ b/src/app/(with-header)/wines/[id]/_components/ReviewContainer.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; import { useParams } from 'next/navigation'; -import { fetchWithAuth } from '@/lib/auth'; +import { fetchWineDetail } from '@/lib/fetchWineDetail'; import { ReviewData } from '@/types/review-data'; import { calculateTasteAverage, getTopThreeAromas, calculateRatingCount } from '@/utils/ReviewUtils'; import ReviewTasteAverage from './ReviewTasteAverage'; @@ -10,12 +10,51 @@ import ReviewItem from './ReviewItem'; import ReviewRating from './ReviewRating'; import NoReview from './NoReview'; -function ReviewList({ reviews }: { reviews: ReviewData['reviews'] }) { +export interface AddReviewData { + reviewId: number; + rating: number; + lightBold: number; + smoothTannic: number; + drySweet: number; + softAcidic: number; + aroma: string[]; + content: string; + user: { + id: number; + nickname: string; + image: string; + }; + wineId: number; + wineName: string; +} + +export interface EditReviewData { + rating: number; + lightBold: number; + smoothTannic: number; + drySweet: number; + softAcidic: number; + aroma: string[]; + content: string; + wineId: number; +} + +function ReviewList({ + reviews, + wineName, + deleteMyReview, + editMyReview, +}: { + reviews: ReviewData['reviews']; + wineName: string; + deleteMyReview: (id: number) => void; + editMyReview: (id: number, editReviewData: EditReviewData, updatedAt: string) => void; +}) { return (
리뷰 목록
{reviews.map((review) => ( - + ))}
); @@ -23,40 +62,81 @@ function ReviewList({ reviews }: { reviews: ReviewData['reviews'] }) { export default function ReviewContainer() { const { id } = useParams(); + const wineId = typeof id === 'string' ? Number(id) : NaN; const [reviews, setReviews] = useState([]); + const [wineName, setWineName] = useState(''); const [avgRating, setAvgRating] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [myReviewData, setMyReviewData] = useState([]); useEffect(() => { const fetchReviews = async () => { - try { - const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/wines/${id}`, { - method: 'GET', - }); - - if (!response) { - setError(' '); - setLoading(false); - return; - } + if (isNaN(wineId)) { + setError('유효한 와인 ID가 아닙니다.'); + setLoading(false); + return; + } - const data: ReviewData = await response.json(); + setLoading(true); + try { + const data = await fetchWineDetail(wineId); + setWineName(data.name); setReviews(data.reviews); setAvgRating(data.avgRating); - } catch (error: unknown) { - if (error instanceof Error) setError(`Error fetching reviews: ${error.message}`); + setMyReviewData(data.reviews); + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } } finally { setLoading(false); } }; - if (id) { + if (wineId) { fetchReviews(); - } else { - setLoading(false); } - }, [id]); + }, [wineId]); + + const deleteMyReview = (id: number) => { + const updatedReviewList = myReviewData.filter((value) => value.id !== id); + setMyReviewData(updatedReviewList); + + const updatedReviews = reviews.filter((value) => value.id !== id); + setReviews(updatedReviews); + }; + + const editMyReview = (id: number, editReviewData: EditReviewData, updatedAt: string) => { + const updatedReviewList = myReviewData.map((value) => { + if (value.id === id) { + return { ...value, ...editReviewData, updatedAt: updatedAt }; + } + return value; + }); + setMyReviewData(updatedReviewList); + + const updatedReviews = reviews.map((value) => { + if (value.id === id) { + return { ...value, ...editReviewData, updatedAt: updatedAt }; + } + return value; + }); + setReviews(updatedReviews); + }; + + const addReview = (newReview: AddReviewData) => { + const formattedReview = { + ...newReview, + id: newReview.reviewId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + wine: { id: wineId, name: wineName, image: '', avgRating: 0 }, + isLiked: false, + }; + + setReviews((prevReviews) => [formattedReview, ...prevReviews]); + }; if (loading) return
Loading...
; if (error) return
{error}
; @@ -64,7 +144,6 @@ export default function ReviewContainer() { const averages = calculateTasteAverage(reviews); const topThreeAromas = getTopThreeAromas(reviews); const ratingPercentages = calculateRatingCount(reviews); - return (
{reviews.length > 0 ? ( @@ -84,11 +163,11 @@ export default function ReviewContainer() {
- +
- +
@@ -97,7 +176,7 @@ export default function ReviewContainer() { ) : (
리뷰 목록
- +
)}
diff --git a/src/app/(with-header)/wines/[id]/_components/ReviewDropdown.tsx b/src/app/(with-header)/wines/[id]/_components/ReviewDropdown.tsx index 6b4d529..bdae9a6 100644 --- a/src/app/(with-header)/wines/[id]/_components/ReviewDropdown.tsx +++ b/src/app/(with-header)/wines/[id]/_components/ReviewDropdown.tsx @@ -1,34 +1,74 @@ 'use client'; -import { useRouter } from 'next/navigation'; 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 { MyReview } from '@/types/review-data'; +import { EditReviewData } from './ReviewContainer'; -export default function ReviewDropdown({ id }: { id: number }) { - const router = useRouter(); +export default function ReviewDropdown({ + id, + wineName, + reviewInitialData, + editMyReview, + deleteMyReview, +}: { + id: number; + wineName: string; + reviewInitialData: MyReview; + editMyReview: (id: number, editReviewData: EditReviewData, updatedAt: string) => void; + deleteMyReview: (id: number) => void; +}) { + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const handleDelete = async () => { + const openEditModal = () => { + setIsEditModalOpen(true); + }; + + const closeEditModal = () => { + setIsEditModalOpen(false); + }; + + const openDeleteModal = () => { + setIsDeleteModalOpen(true); + }; + + const closeDeleteModal = () => { + setIsDeleteModalOpen(false); + }; + + const options = [ + { value: openEditModal, label: '수정하기' }, + { value: openDeleteModal, label: '삭제하기' }, + ]; + + const handleDeleteWine = async () => { try { const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/reviews/${id}`, { method: 'DELETE', }); - if (response && response.ok) { - window.location.reload(); - } else { - alert('삭제에 실패했습니다.'); + + if (!response?.ok || response === null) { + throw new Error('리뷰 삭제에 실패했습니다'); + } + + const body = await response.json(); + if (body) { + if (deleteMyReview) { + deleteMyReview(id); + } + closeDeleteModal(); } } catch (error) { - console.error(error); - alert('삭제 중 오류가 발생했습니다.'); + console.error('리뷰 삭제 에러:', error); } }; - const options = [ - { value: () => router.push(`/reviews/${id}`), label: '수정하기' }, - { value: () => handleDelete(), label: '삭제하기' }, - ]; - return (
메뉴 아이콘 + + + + + +
); } diff --git a/src/app/(with-header)/wines/[id]/_components/ReviewItem.tsx b/src/app/(with-header)/wines/[id]/_components/ReviewItem.tsx index 7a7bb6f..64ab800 100644 --- a/src/app/(with-header)/wines/[id]/_components/ReviewItem.tsx +++ b/src/app/(with-header)/wines/[id]/_components/ReviewItem.tsx @@ -7,20 +7,28 @@ 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 { ReviewData } from '@/types/review-data'; +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 './ReviewContainer'; -type ReviewItemProps = { review: ReviewData['reviews'][0] }; +type ReviewItemProps = { + review: ReviewData['reviews'][0]; + wineName: string; + reviewInitialData: MyReview; + editMyReview: (id: number, editReviewData: EditReviewData, updatedAt: string) => void; + deleteMyReview: (id: number) => void; +}; -export default function ReviewItem({ review }: ReviewItemProps) { +export default function ReviewItem({ review, wineName, reviewInitialData, editMyReview, deleteMyReview }: ReviewItemProps) { const [isOpen, setIsOpen] = useState(true); const [liked, setLiked] = useState(review.isLiked || false); const [loading, setLoading] = useState(false); const [isMyReview, setIsMyReview] = useState(false); + const [reviewData, setReviewData] = useState(review); useEffect(() => { const fetchUserData = async () => { @@ -39,18 +47,13 @@ export default function ReviewItem({ review }: ReviewItemProps) { fetchUserData(); }, [review.user.id]); - useEffect(() => { - setLiked(review.isLiked); - }, [review.isLiked]); - const handleLikeToggle = async () => { if (loading) return; setLoading(true); try { - const method = liked ? 'DELETE' : 'POST'; const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/reviews/${review.id}/like`, { - method, + method: liked ? 'DELETE' : 'POST', }); if (response?.ok) { @@ -63,6 +66,20 @@ export default function ReviewItem({ review }: ReviewItemProps) { } }; + const handleEdit = (id: number, editReviewData: EditReviewData, updatedAt: string) => { + editMyReview(review.id, editReviewData, updatedAt); + + setReviewData((prev) => ({ + ...prev, + ...editReviewData, + updatedAt, + })); + }; + + const handleDelete = () => { + deleteMyReview(reviewData.id); + }; + return (
@@ -70,7 +87,7 @@ export default function ReviewItem({ review }: ReviewItemProps) {
{review.user.nickname}
-
{elapsedTime(review.createdAt)}
+
{elapsedTime(review.updatedAt || review.createdAt)}
@@ -81,7 +98,9 @@ export default function ReviewItem({ review }: ReviewItemProps) { )}
-
{isMyReview && }
+
+ {isMyReview && } +
diff --git a/src/app/(with-header)/wines/[id]/_components/ReviewRating.tsx b/src/app/(with-header)/wines/[id]/_components/ReviewRating.tsx index 6561e2a..1772f10 100644 --- a/src/app/(with-header)/wines/[id]/_components/ReviewRating.tsx +++ b/src/app/(with-header)/wines/[id]/_components/ReviewRating.tsx @@ -1,14 +1,16 @@ 'use client'; import StaticRating from '@/components/StaticRating'; import PostReviewModal from '@/components/modal/PostReviewModal'; +import { AddReviewData } from './ReviewContainer'; type ReviewRatingProps = { count: number; ratingPercentages: number[]; avgRating: number; + addReview: (newReview: AddReviewData) => void; }; -export default function ReviewRating({ count, avgRating, ratingPercentages }: ReviewRatingProps) { +export default function ReviewRating({ count, avgRating, ratingPercentages, addReview }: ReviewRatingProps) { return (
@@ -26,7 +28,7 @@ export default function ReviewRating({ count, avgRating, ratingPercentages }: Re
- +
@@ -50,7 +52,7 @@ export default function ReviewRating({ count, avgRating, ratingPercentages }: Re
- +
diff --git a/src/app/(with-header)/wines/[id]/_components/ReviewTasteItem.tsx b/src/app/(with-header)/wines/[id]/_components/ReviewTasteItem.tsx index 536291e..5ab7de5 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/WineContainer.tsx b/src/app/(with-header)/wines/[id]/_components/WineContainer.tsx index db7805e..6910694 100644 --- a/src/app/(with-header)/wines/[id]/_components/WineContainer.tsx +++ b/src/app/(with-header)/wines/[id]/_components/WineContainer.tsx @@ -1,52 +1,41 @@ 'use client'; import { useState, useEffect } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import { fetchWithAuth } from '@/lib/auth'; -import WineCard, { WineCardProps } from '@/components/WineCard'; +import { useParams } from 'next/navigation'; +import { fetchWineDetail } from '@/lib/fetchWineDetail'; +import { ReviewData } from '@/types/review-data'; +import WineCard from '@/components/WineCard'; export default function WineContainer() { const { id } = useParams(); - const [wine, setWine] = useState(null); + const [wine, setWine] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const router = useRouter(); useEffect(() => { const fetchWineData = async () => { - try { - const response = await fetchWithAuth(`${process.env.NEXT_PUBLIC_BASE_URL}/wines/${id}`, { - method: 'GET', - }); - - if (!response) { - alert('로그인 후, 이용해 주세요'); - router.push('/signin'); + if (id) { + const wineId = typeof id === 'string' ? Number(id) : NaN; + if (isNaN(wineId)) { + setError('유효한 와인 ID가 아닙니다.'); setLoading(false); return; } - if (!response.ok) { - if (response.status === 404) { - alert('존재하지 않는 와인입니다. 다시 확인해 주세요.'); - router.push('/wines'); - } + try { + const wineData = await fetchWineDetail(wineId); + setWine(wineData); + } catch (error: unknown) { + if (error instanceof Error) setError(error.message); + } finally { + setLoading(false); } - - const data: WineCardProps = await response.json(); - setWine(data); - } catch (error: unknown) { - if (error instanceof Error) setError(`와인 데이터 로드 실패: ${error.message}`); - } finally { + } else { setLoading(false); } }; - if (id) { - fetchWineData(); - } else { - setLoading(false); - } - }, [id, router]); + fetchWineData(); + }, [id]); if (loading) return
Loading...
; if (error) return
{error}
; diff --git a/src/components/InteractiveRating.tsx b/src/components/InteractiveRating.tsx index 92aa3a5..e0ab048 100644 --- a/src/components/InteractiveRating.tsx +++ b/src/components/InteractiveRating.tsx @@ -20,6 +20,8 @@ export default function InteractiveRating({ initialValue = 0, onChange, size = ' setValue(newValue); if (newValue !== null) { onChange(newValue); + } else if (newValue === null) { + onChange(0); } }} size={size} diff --git a/src/components/modal/Modal.tsx b/src/components/modal/Modal.tsx index 0bd8b0f..e246171 100644 --- a/src/components/modal/Modal.tsx +++ b/src/components/modal/Modal.tsx @@ -59,7 +59,7 @@ export default function Modal({ children, isOpen, setIsOpen, className }: ModalP } return createPortal( - + {children} , document.getElementById('modal-root') as HTMLElement, diff --git a/src/components/modal/PatchReviewForm.tsx b/src/components/modal/PatchReviewForm.tsx index 5536512..20972d2 100644 --- a/src/components/modal/PatchReviewForm.tsx +++ b/src/components/modal/PatchReviewForm.tsx @@ -9,6 +9,18 @@ import close from '@/assets/icons/close.svg'; import wineIcon from '@/assets/icons/wine.svg'; import InteractiveRating from '../InteractiveRating'; import ControlBar from '../ControlBar'; +import { MyReview } from '@/types/review-data'; + +export interface EditReviewData { + rating: number; + lightBold: number; + smoothTannic: number; + drySweet: number; + softAcidic: number; + aroma: string[]; + content: string; + wineId: number; +} interface FormValues { rating: number; @@ -52,23 +64,25 @@ interface postReviewPorp { name: string; id: number; onClose: () => void; + reviewInitialData?: MyReview; + editMyReview?: (id: number, editReviewData: EditReviewData, updatedAt: string) => void; } -export default function PatchReviewForm({ name, id, onClose }: postReviewPorp) { - const [selectedAroma, setSelectedAroma] = useState([]); +export default function PatchReviewForm({ name, id, onClose, reviewInitialData, editMyReview }: postReviewPorp) { + const [selectedAroma, setSelectedAroma] = useState(reviewInitialData?.aroma || []); const { register, handleSubmit, setValue, watch } = useForm({ defaultValues: { - lightBold: 0, - smoothTannic: 0, - drySweet: 0, - softAcidic: 0, + lightBold: reviewInitialData?.lightBold || 0, + smoothTannic: reviewInitialData?.smoothTannic || 0, + drySweet: reviewInitialData?.drySweet || 0, + softAcidic: reviewInitialData?.softAcidic || 0, }, }); - const aromaValue = watch('aroma', []); - const ratingValue = watch('rating', 0); - const textValue = watch('content', ''); + const aromaValue = watch('aroma', reviewInitialData?.aroma || []); + const ratingValue = watch('rating', reviewInitialData?.rating || 0); + const textValue = watch('content', reviewInitialData?.content || ''); const handleAromaClick = (aroma: string) => { setSelectedAroma((prevSelectedAroma) => (prevSelectedAroma.includes(aroma) ? prevSelectedAroma.filter((a) => a !== aroma) : [...prevSelectedAroma, aroma])); @@ -101,6 +115,10 @@ export default function PatchReviewForm({ name, id, onClose }: postReviewPorp) { const body = await response.json(); if (body) { + if (editMyReview) { + const now: string = new Date().toISOString(); + editMyReview(id, data, now); + } onClose(); } } catch (error) { @@ -128,11 +146,12 @@ export default function PatchReviewForm({ name, id, onClose }: postReviewPorp) { 와인 이미지

{name}

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