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
2 changes: 1 addition & 1 deletion 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 './ReviewContainer';
import { AddReviewData } from '@/types/review-data';

export default function NoReview({ addReview }: { addReview: (newReview: AddReviewData) => void }) {
return (
Expand Down
109 changes: 12 additions & 97 deletions src/app/(with-header)/wines/[id]/_components/ReviewContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,15 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { fetchWineDetail } from '@/lib/fetchWineDetail';
import { ReviewData } from '@/types/review-data';
import { useState } from 'react';
import { calculateTasteAverage, getTopThreeAromas, calculateRatingCount } from '@/utils/ReviewUtils';
import { ReviewData } from '@/types/review-data';
import { EditReviewData } from '@/types/review-data';
import { AddReviewData } from '@/types/review-data';
import ReviewTasteAverage from './ReviewTasteAverage';
import ReviewAroma from './ReviewAroma';
import ReviewItem from './ReviewItem';
import ReviewRating from './ReviewRating';
import NoReview from './NoReview';

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,
Expand All @@ -60,69 +31,16 @@ function ReviewList({
);
}

export default function ReviewContainer() {
const { id } = useParams();
const wineId = typeof id === 'string' ? Number(id) : NaN;
const [reviews, setReviews] = useState<ReviewData['reviews']>([]);
const [wineName, setWineName] = useState<string>('');
const [avgRating, setAvgRating] = useState<number>(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [myReviewData, setMyReviewData] = useState<ReviewData['reviews']>([]);

useEffect(() => {
const fetchReviews = async () => {
if (isNaN(wineId)) {
setError('유효한 와인 ID가 아닙니다.');
setLoading(false);
return;
}

setLoading(true);
try {
const data = await fetchWineDetail(wineId);
setWineName(data.name);
setReviews(data.reviews);
setAvgRating(data.avgRating);
setMyReviewData(data.reviews);
} catch (err) {
if (err instanceof Error) {
setError(err.message);
}
} finally {
setLoading(false);
}
};

if (wineId) {
fetchReviews();
}
}, [wineId]);
export default function ReviewContainer({ data }: { data: ReviewData }) {
const { reviews, avgRating } = data;
const [localReviews, setLocalReviews] = useState(reviews);

const deleteMyReview = (id: number) => {
const updatedReviewList = myReviewData.filter((value) => value.id !== id);
setMyReviewData(updatedReviewList);

const updatedReviews = reviews.filter((value) => value.id !== id);
setReviews(updatedReviews);
setLocalReviews((prevReviews) => prevReviews.filter((review) => review.id !== id));
};

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);
setLocalReviews((prevReviews) => prevReviews.map((review) => (review.id === id ? { ...review, ...editReviewData, updatedAt } : review)));
};

const addReview = (newReview: AddReviewData) => {
Expand All @@ -131,16 +49,13 @@ export default function ReviewContainer() {
id: newReview.reviewId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
wine: { id: wineId, name: wineName, image: '', avgRating: 0 },
wine: { id: data.id, name: data.name, image: '', avgRating: 0 },
isLiked: false,
};

setReviews((prevReviews) => [formattedReview, ...prevReviews]);
setLocalReviews((prevReviews) => [formattedReview, ...prevReviews]);
};

if (loading) return <div>Loading...</div>;
if (error) return <div>{error}</div>;

const averages = calculateTasteAverage(reviews);
const topThreeAromas = getTopThreeAromas(reviews);
const ratingPercentages = calculateRatingCount(reviews);
Expand All @@ -163,7 +78,7 @@ export default function ReviewContainer() {

<div className='mt-[60px] flex justify-between gap-[60px] tablet:flex-col-reverse tablet:px-6'>
<div>
<ReviewList reviews={reviews} wineName={wineName} deleteMyReview={deleteMyReview} editMyReview={editMyReview} />
<ReviewList reviews={localReviews} wineName={data.name} deleteMyReview={deleteMyReview} editMyReview={editMyReview} />
</div>
<div className='relative'>
<div className='sticky top-28'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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';
import { EditReviewData } from '@/types/review-data';

export default function ReviewDropdown({
id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import elapsedTime from '@/utils/formatDate';
import ProfileImg from '@/components/ProfileImg';
import ReviewTasteItem from './ReviewTasteItem';
import ReviewDropdown from './ReviewDropdown';
import { EditReviewData } from './ReviewContainer';
import { EditReviewData } from '@/types/review-data';

type ReviewItemProps = {
review: ReviewData['reviews'][0];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';
import StaticRating from '@/components/StaticRating';
import PostReviewModal from '@/components/modal/PostReviewModal';
import { AddReviewData } from './ReviewContainer';
import { AddReviewData } from '@/types/review-data';

type ReviewRatingProps = {
count: number;
Expand Down
39 changes: 1 addition & 38 deletions src/app/(with-header)/wines/[id]/_components/WineContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,8 @@
'use client';
import { useState, useEffect } from 'react';
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<ReviewData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const fetchWineData = async () => {
if (id) {
const wineId = typeof id === 'string' ? Number(id) : NaN;
if (isNaN(wineId)) {
setError('유효한 와인 ID가 아닙니다.');
setLoading(false);
return;
}

try {
const wineData = await fetchWineDetail(wineId);
setWine(wineData);
} catch (error: unknown) {
if (error instanceof Error) setError(error.message);
} finally {
setLoading(false);
}
} else {
setLoading(false);
}
};

fetchWineData();
}, [id]);

if (loading) return <div>Loading...</div>;
if (error) return <div>{error}</div>;

export default function WineContainer({ wine }: { wine: ReviewData }) {
return (
<div className='mt-[62px] w-full mobile:mt-[29px]'>
<div className='mx-auto w-full max-w-[1140px] tablet:w-[calc(100%-45px)] tablet:max-w-[1000px] mobile:max-w-[700px]'>{wine ? <WineCard {...wine} size='large' /> : ''}</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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='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-[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>
</div>
</div>
);
}
78 changes: 76 additions & 2 deletions src/app/(with-header)/wines/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,85 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } 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 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();
const wineId = typeof id === 'string' ? Number(id) : NaN;
const [data, setData] = useState<ReviewData | null>(null);
const [notFound, setNotFound] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const router = useRouter();

const fetchWineData = useCallback(
async (id: number) => {
setLoading(true);
setError(false);
setNotFound(false);

try {
const data = await fetchWineDetail(id);
setData(data);
} catch (err) {
if (err instanceof Error) {
if (err.message === 'NOT_FOUND') {
setNotFound(true);
} else if (err.message === 'UNAUTHORIZED') {
alert('로그인 후, 이용해 주세요.');
router.push('/signin');
} else {
setError(true);
}
} else {
setError(true);
}
} finally {
setLoading(false);
}
},
[router],
);

useEffect(() => {
if (isNaN(wineId)) {
setNotFound(true);
setLoading(false);
} else {
fetchWineData(wineId);
}
}, [wineId, fetchWineData]);

if (loading) return <WineContainerSkeleton />;
if (notFound || isNaN(wineId)) return <NotFound />;
if (error) {
return (
<div className='mt-[62px] w-full mobile:mt-[29px]'>
<div className='mx-auto flex h-[260px] w-full max-w-[1140px] tablet:w-[calc(100%-45px)] tablet:max-w-[1000px] mobile:max-w-[700px]'>
<Refresh
handleLoad={() => fetchWineDetail(wineId)}
boxStyle='mx-auto h-[228px] rounded-[16px] tablet:w-full mobile:w-full gap-[10px]'
buttonStyle='px-[20px] py-[8px]'
iconSize='w-[100px] h-[100px] mobile:w-[60px] mobile:h-[60px]'
iconTextGap='gap-[10px]'
/>
</div>
</div>
);
}

return (
<>
<WineContainer />
<ReviewContainer />
{data && <WineContainer wine={data} />}
{data && <ReviewContainer data={data} />}
</>
);
}
2 changes: 1 addition & 1 deletion src/components/modal/PostReviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import close from '@/assets/icons/close.svg';
import wineIcon from '@/assets/icons/wine.svg';
import InteractiveRating from '../InteractiveRating';
import ControlBar from '../ControlBar';
import { AddReviewData } from '@/app/(with-header)/wines/[id]/_components/ReviewContainer';
import { AddReviewData } from '@/types/review-data';

interface FormValues {
rating: number;
Expand Down
14 changes: 8 additions & 6 deletions src/lib/fetchWineDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,21 @@ export const fetchWineDetail = async (id: number): Promise<ReviewData> => {
});

if (!response) {
throw new Error('로그인 후, 이용해 주세요.');
throw new Error('UNAUTHORIZED');
}

if (!response.ok) {
throw new Error('와인 상세 정보를 불러오는 데 실패했습니다.');
if (response.status === 404) {
throw new Error('NOT_FOUND');
}
throw new Error('FETCH_ERROR');
}

const data: ReviewData = await response.json();
return data;
return await response.json();
} catch (error: unknown) {
if (error instanceof Error) {
throw new Error(`와인 데이터 로드 실패: ${error.message}`);
throw new Error(error.message);
}
throw new Error('알 수 없는 오류가 발생했습니다.');
throw new Error('UNKNOWON_ERROR');
}
};
Loading