Skip to content
4 changes: 2 additions & 2 deletions src/app/(with-header)/wines/[id]/_components/NoReview.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
4 changes: 2 additions & 2 deletions src/app/(with-header)/wines/[id]/_components/ReviewAroma.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
35 changes: 23 additions & 12 deletions src/app/(with-header)/wines/[id]/_components/ReviewContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 (
<div>
{reviews.length > 0 ? (
{localReviews.length > 0 ? (
<div className='mb-[100px] mt-[60px] w-full'>
<div className='mx-auto w-full max-w-[1140px] transition-all duration-300 ease-in-out tablet:max-w-[1000px] mobile:max-w-[700px]'>
<div className='grid grid-cols-2 gap-8 tablet:grid-cols-1 tablet:px-6 mobile:grid-cols-1 mobile:px-6'>
<ReviewTasteAverage
count={reviews.length}
count={localReviews.length}
lightBold={averages.lightBold}
smoothTannic={averages.smoothTannic}
drySweet={averages.drySweet}
softAcidic={averages.softAcidic}
isDraggable={false}
/>
<ReviewAroma selectedAroma={topThreeAromas} count={reviews.length} />
<ReviewAroma selectedAroma={topThreeAromas} count={localReviews.length} />
</div>

<div className='mt-[60px] flex justify-between gap-[60px] tablet:flex-col-reverse tablet:px-6'>
Expand All @@ -82,7 +93,7 @@ export default function ReviewContainer({ data }: { data: ReviewData }) {
</div>
<div className='relative'>
<div className='sticky top-28'>
<ReviewRating avgRating={avgRating} count={reviews.length} ratingPercentages={ratingPercentages} addReview={addReview} />
<ReviewRating avgRating={averageRating} count={localReviews.length} ratingPercentages={ratingPercentages} addReview={addReview} />
</div>
</div>
</div>
Expand Down
20 changes: 12 additions & 8 deletions src/app/(with-header)/wines/[id]/_components/ReviewDropdown.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -54,7 +55,7 @@ export default function ReviewDropdown({
});

if (!response?.ok || response === null) {
throw new Error('리뷰 삭제에 실패했습니다');
throw new Error('리뷰 삭제에 실패했습니다.');
}

const body = await response.json();
Expand All @@ -65,7 +66,10 @@ export default function ReviewDropdown({
closeDeleteModal();
}
} catch (error) {
console.error('리뷰 삭제 에러:', error);
closeDeleteModal();
console.log(status);
toast.error('리뷰 삭제에 실패했습니다.');
console.error('리뷰 삭제 에러', error);
}
};

Expand Down
31 changes: 22 additions & 9 deletions src/app/(with-header)/wines/[id]/_components/ReviewItem.tsx
Original file line number Diff line number Diff line change
@@ -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];
Expand All @@ -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);
}
};
Expand All @@ -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);
}
Expand All @@ -78,6 +90,7 @@ export default function ReviewItem({ review, wineName, reviewInitialData, editMy

const handleDelete = () => {
deleteMyReview(reviewData.id);
toast.success('리뷰 삭제에 성공했습니다.');
};

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ type ReviewTasteItemProps = {

export default function ReviewListTasteItem({ lightBold, smoothTannic, drySweet, softAcidic, isDraggable }: ReviewTasteItemProps) {
return (
<div className='cursor-default'>
<div>
<ControlBar label='바디감' minLabel={'가벼워요'} maxLabel={'진해요'} value={lightBold} onChange={() => {}} isDraggable={isDraggable} name='바디감' size='large' />
<ControlBar label='타닌' minLabel={'부드러워요'} maxLabel={'떫어요'} value={smoothTannic} onChange={() => {}} isDraggable={false} name='타닌' size='large' />
<ControlBar label='당도' minLabel={'드라이해요'} maxLabel={'달아요'} value={drySweet} onChange={() => {}} isDraggable={false} name='당도' size='large' />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export default function ReviewContainerSkeleton() {
return (
<div className='mb-[100px] mt-[60px] w-full animate-pulse'>
<div className='mx-auto w-full max-w-[1140px] transition-all duration-300 ease-in-out tablet:max-w-[1000px] mobile:max-w-[700px]'>
<div className='grid grid-cols-2 gap-8 tablet:grid-cols-1 tablet:px-6 mobile:grid-cols-1 mobile:px-6'>
<div>
<div className='mb-[32px] h-[32px] rounded-md bg-gray-200 pc:w-[141px] tablet:w-[150px]'></div>
<div className='h-[172px] rounded-md bg-gray-100 pc:w-[540px] tablet:max-w-full mobile:h-[144px]'></div>
</div>
<div>
<div className='mb-[32px] h-[32px] rounded-md bg-gray-200 pc:w-[141px] tablet:w-[150px]'></div>
<div className='h-[172px] rounded-md bg-gray-100 pc:w-[540px] tablet:max-w-full mobile:h-[144px]'></div>
</div>
</div>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
export default function WineContainerSkeleton() {
return (
<div className='mt-[62px] w-full mobile:mt-[29px]'>
<div className='mx-auto flex h-[260px] w-full max-w-[1140px] animate-pulse gap-[86px] rounded-[16px] border-gray-300 bg-gray-100 pb-[40px] pl-[100px] pr-[40px] pt-[52px] tablet:w-[calc(100%-45px)] tablet:max-w-[1000px] tablet:pl-[60px] mobile:h-[190px] mobile:max-w-[700px] mobile:gap-[60px] mobile:pb-[29.5px] mobile:pl-[40px] mobile:pr-[20px] mobile:pt-[33px]'>
<div className='mx-auto flex h-[260px] w-full max-w-[1140px] animate-pulse gap-[86px] rounded-[16px] border-gray-300 bg-gray-100 pb-[40px] pl-[100px] pr-[40px] pt-[52px] tablet:w-[calc(100%-45px)] tablet:max-w-[1000px] tablet:pl-[60px] mobile:h-[190px] mobile:max-w-[700px] mobile:gap-[35px] mobile:pb-[29.5px] mobile:pl-[40px] mobile:pr-[20px] mobile:pt-[33px]'>
<div className='h-[208px] w-[60px] rounded-md bg-gray-200 mobile:h-[155px] mobile:w-[50px]'></div>
<div>
<div className='mb-[15px] h-[36px] rounded-md bg-gray-200 pc:w-[500px] tablet:w-[450px] mobile:h-[35px] mobile:w-[260px]'></div>
<div className='mb-[15px] h-[36px] w-auto rounded-md bg-gray-200 pc:w-[500px] tablet:w-[450px] mobile:mb-[20px] mobile:h-[30px] mobile:w-[100%] mobile:min-w-[200px] mobile:max-w-[230px]'></div>
<div className='mb-[55px] h-[26px] w-[200px] rounded-md bg-gray-200 mobile:mb-[30px] mobile:h-[24px] mobile:w-[100px]'></div>
<div className='h-[38px] w-[112px] rounded-md bg-gray-200 mobile:h-[24px] mobile:w-[66px]'></div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import WineContainerSkeleton from './WineContainerSkeleton';
import ReviewContainerSkeleton from './ReviewContainerSkeleton';

export default function WineDetailSkeleton() {
return (
<>
<WineContainerSkeleton />
<ReviewContainerSkeleton />
</>
);
}
14 changes: 6 additions & 8 deletions src/app/(with-header)/wines/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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);
Expand All @@ -58,7 +56,7 @@ export default function Page() {
}
}, [wineId, fetchWineData]);

if (loading) return <WineContainerSkeleton />;
if (loading) return <WineDetailSkeleton />;
if (notFound || isNaN(wineId)) return <NotFound />;
if (error) {
return (
Expand Down
15 changes: 11 additions & 4 deletions src/components/ControlBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from 'react';

type ControlBarProps = {
reset?: boolean;
label: string;
minLabel: string;
maxLabel: string;
Expand All @@ -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]'
Expand All @@ -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;

Expand All @@ -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);
};
Expand All @@ -74,9 +81,9 @@ export default function ControlBar({ label, minLabel, maxLabel, value, isDraggab
{label}
</label>
<span className='mr-[16px] w-[70px] flex-none whitespace-nowrap text-lg font-medium text-gray-800 mobile:mr-0 mobile:text-md'>{minLabel}</span>
<div className='relative h-[6px] w-full cursor-pointer rounded-full border-[1px] border-solid border-gray-300 bg-gray-100' ref={controlBarRef} onMouseDown={handleMouseDown}>
<div className='relative h-[6px] w-full cursor-default rounded-full border-[1px] border-solid border-gray-300 bg-gray-100' ref={controlBarRef} onMouseDown={handleMouseDown}>
<div
className='absolute left-0 top-1/2 h-[16px] w-[16px] -translate-y-1/2 transform cursor-pointer rounded-full bg-purple-100 mobile:h-[12px] mobile:w-[12px]'
className={`absolute left-0 top-1/2 h-[16px] w-[16px] -translate-y-1/2 transform rounded-full bg-purple-100 mobile:h-[12px] mobile:w-[12px] ${isDraggable ? 'cursor-pointer' : 'cursor-default'}`}
style={{ left: `${(dragValue / 10) * 100}%` }}
onMouseDown={handleMouseDown}
/>
Expand Down
11 changes: 9 additions & 2 deletions src/components/InteractiveRating.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<number | null>(initialValue);

useEffect(() => {
if (!initialValue) {
setValue(0);
}
}, [resetTrigger, initialValue]);

return (
<Rating
value={value}
Expand Down
Loading