Skip to content

Commit b4f21db

Browse files
authored
Merge pull request #131 from codeit-13-3team/feature/product-detail-page
feat: 상품 상세 페이지 구현
2 parents 0d02ae2 + 6be30c7 commit b4f21db

9 files changed

Lines changed: 468 additions & 0 deletions

File tree

next.config.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/** @type {import('next').NextConfig} */
2+
const nextConfig = {
3+
images: {
4+
remotePatterns: [
5+
{
6+
protocol: 'https',
7+
hostname: 'store.storeimages.cdn-apple.com',
8+
port: '',
9+
pathname: '/**',
10+
},
11+
{
12+
protocol: 'https',
13+
hostname: 'cdn.gukjenews.com',
14+
port: '',
15+
pathname: '/**',
16+
},
17+
{
18+
protocol: 'https',
19+
hostname: 'cdn.pixabay.com',
20+
port: '',
21+
pathname: '/**',
22+
},
23+
{
24+
protocol: 'https',
25+
hostname: 'sprint-fe-project.s3.ap-northeast-2.amazonaws.com',
26+
port: '',
27+
pathname: '/**',
28+
},
29+
],
30+
},
31+
};
32+
33+
module.exports = nextConfig;
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
'use client';
2+
3+
import Image from 'next/image';
4+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
5+
import { ProductResponse } from '@/types/product';
6+
import { getProductById } from '@/api/products';
7+
import ProductDetailButtonGroup from '@/components/products/ProductDetailButtonGroup';
8+
9+
interface ProductDetailProps {
10+
id: number;
11+
}
12+
13+
const toggleFavorite = async (productId: number, isFavorite: boolean): Promise<void> => {
14+
const method = isFavorite ? 'DELETE' : 'POST';
15+
const res = await fetch(`https://mogazoa-api.vercel.app/13-3/products/${productId}/favorite`, {
16+
method,
17+
});
18+
if (!res.ok) {
19+
throw new Error(`Failed to ${isFavorite ? 'unlike' : 'like'} product`);
20+
}
21+
};
22+
23+
export default function ProductDetail({ id }: ProductDetailProps) {
24+
const queryClient = useQueryClient();
25+
26+
const { data: product, isLoading } = useQuery<ProductResponse>({
27+
queryKey: ['product', id],
28+
queryFn: () => getProductById(id),
29+
enabled: Boolean(id),
30+
});
31+
32+
const favoriteMutation = useMutation<void, Error, { productId: number; isFavorite: boolean }>({
33+
mutationFn: ({ productId, isFavorite }) => toggleFavorite(productId, isFavorite),
34+
onSuccess: () => {
35+
queryClient.invalidateQueries({ queryKey: ['product', id] });
36+
},
37+
});
38+
39+
const handleFavoriteClick = () => {
40+
if (product) {
41+
favoriteMutation.mutate({ productId: product.id, isFavorite: product.isFavorite });
42+
}
43+
};
44+
45+
if (isLoading) {
46+
return (
47+
<section className="flex items-center justify-center min-h-[200px]">
48+
<span>로딩 중...</span>
49+
</section>
50+
);
51+
}
52+
53+
if (!product) {
54+
return (
55+
<section className="flex items-center justify-center min-h-[200px] text-gray-400">
56+
상품 정보가 존재하지 않습니다.
57+
</section>
58+
);
59+
}
60+
61+
return (
62+
<section className="w-full mx-auto mb-4 sm:mb-6 lg:mb-8">
63+
<div className="w-full bg-black-400 rounded-xl flex flex-col lg:flex-row items-center gap-4 lg:gap-8 p-4 sm:p-6 lg:p-8">
64+
<div className="w-full sm:w-[180px] h-[180px] flex-shrink-0 flex items-center justify-center bg-black-500 rounded-lg border border-gray-200">
65+
<Image
66+
src={
67+
product.image ||
68+
'https://store.storeimages.cdn-apple.com/8756/as-images.apple.com/is/MWP22?wid=1144&hei=1144&fmt=jpeg&qlt=80&.v=1591634795000'
69+
}
70+
alt={product.name || '상품 이미지'}
71+
width={160}
72+
height={160}
73+
className="object-contain"
74+
/>
75+
</div>
76+
<div className="flex-1 flex flex-col justify-between w-full h-full gap-4">
77+
<div>
78+
<span className="inline-block bg-gray-200 text-white text-xs px-2 py-1 rounded mb-2">
79+
{product.category?.name || '브랜드명'}
80+
</span>
81+
<div className="text-lg sm:text-xl lg:text-2xl font-bold mb-2 flex items-center justify-between gap-4">
82+
<span>{product.name || '상품명'}</span>
83+
<button
84+
onClick={handleFavoriteClick}
85+
className="text-gray-400 hover:text-red-500"
86+
disabled={favoriteMutation.isPending}
87+
>
88+
{product.isFavorite ? '❤️' : '🤍'}
89+
</button>
90+
</div>
91+
<div className="text-sm lg:text-base text-gray-300">
92+
{product.description || '상품 설명이 들어갑니다.'}
93+
</div>
94+
</div>
95+
<ProductDetailButtonGroup />
96+
</div>
97+
</div>
98+
</section>
99+
);
100+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import Button from '../button/Button';
2+
3+
export interface ProductDetailButtonGroupProps {}
4+
5+
export default function ProductDetailButtonGroup({}: ProductDetailButtonGroupProps) {
6+
return (
7+
<section className="flex flex-col sm:flex-row gap-3 sm:gap-4 w-full">
8+
<Button
9+
size="l"
10+
variant="primary"
11+
className="w-full sm:w-[345px] h-[48px] sm:h-[65px] rounded-[8px] text-sm sm:text-base bg-gradient-to-r from-main-blue to-main-indigo text-white"
12+
>
13+
리뷰 작성하기
14+
</Button>
15+
<Button
16+
size="l"
17+
variant="tertiary"
18+
className="w-full sm:w-[180px] h-[48px] sm:h-[65px] rounded-[8px] border border-main-blue text-main-blue text-sm sm:text-base bg-black-400"
19+
>
20+
비교하기
21+
</Button>
22+
</section>
23+
);
24+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
'use client';
2+
3+
import React, { useState } from 'react';
4+
import ProductDetail from '@/components/products/ProductDetail';
5+
import ProductStatistics from '@/components/products/ProductStatistics';
6+
import ProductReviews from '@/components/products/ProductReviews';
7+
import ReviewSortDropdown from '@/components/products/ReviewSortDropdown';
8+
9+
interface ProductDetailLayoutProps {
10+
id: number;
11+
}
12+
13+
type SortOrder = 'recent' | 'ratingDesc' | 'ratingAsc' | 'likeCount';
14+
15+
const SORT_OPTIONS = [
16+
{ value: 'recent' as const, label: '최신순' },
17+
{ value: 'ratingDesc' as const, label: '별점 높은순' },
18+
{ value: 'ratingAsc' as const, label: '별점 낮은순' },
19+
{ value: 'likeCount' as const, label: '좋아요순' },
20+
] as const;
21+
22+
export default function ProductDetailLayout({ id }: ProductDetailLayoutProps) {
23+
const [sortOrder, setSortOrder] = useState<SortOrder>('recent');
24+
25+
return (
26+
<div className="min-h-screen bg-black-500 text-white">
27+
<div className="w-full max-w-[1920px] mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6 lg:py-8 flex flex-col items-center">
28+
<div className="w-full max-w-[940px]">
29+
<ProductDetail id={id} />
30+
<ProductStatistics id={id} />
31+
32+
<div className="w-full mx-auto mb-4 sm:mb-6 lg:mb-8">
33+
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-4 mb-4">
34+
<h2 className="text-lg sm:text-xl font-normal leading-none">상품 리뷰</h2>
35+
<ReviewSortDropdown
36+
options={SORT_OPTIONS}
37+
value={sortOrder}
38+
onChange={setSortOrder}
39+
/>
40+
</div>
41+
<ProductReviews id={id} order={sortOrder} />
42+
</div>
43+
</div>
44+
</div>
45+
</div>
46+
);
47+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
'use client';
2+
3+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
4+
import { ReviewListItem } from '@/types/product';
5+
6+
export interface ProductReviewsProps {
7+
id: number;
8+
order?: 'recent' | 'ratingDesc' | 'ratingAsc' | 'likeCount';
9+
}
10+
11+
const likeReview = async (reviewId: number): Promise<void> => {
12+
const res = await fetch(`https://mogazoa-api.vercel.app/13-3/reviews/${reviewId}/like`, {
13+
method: 'POST',
14+
});
15+
if (!res.ok) throw new Error('Failed to like review');
16+
};
17+
18+
const unlikeReview = async (reviewId: number): Promise<void> => {
19+
const res = await fetch(`https://mogazoa-api.vercel.app/13-3/reviews/${reviewId}/like`, {
20+
method: 'DELETE',
21+
});
22+
if (!res.ok) throw new Error('Failed to unlike review');
23+
};
24+
25+
export default function ProductReviews({ id, order = 'recent' }: ProductReviewsProps) {
26+
const queryClient = useQueryClient();
27+
28+
const { data: reviews, isLoading } = useQuery<ReviewListItem[]>({
29+
queryKey: ['reviews', id, order],
30+
queryFn: async () => {
31+
const res = await fetch(
32+
`https://mogazoa-api.vercel.app/13-3/products/${id}/reviews?order=${order}`,
33+
);
34+
const data = await res.json();
35+
return data.list;
36+
},
37+
enabled: Boolean(id),
38+
});
39+
40+
const likeReviewMutation = useMutation<void, Error, number>({
41+
mutationFn: likeReview,
42+
onSuccess: () => {
43+
queryClient.invalidateQueries({ queryKey: ['reviews', id, order] });
44+
},
45+
});
46+
47+
const unlikeReviewMutation = useMutation<void, Error, number>({
48+
mutationFn: unlikeReview,
49+
onSuccess: () => {
50+
queryClient.invalidateQueries({ queryKey: ['reviews', id, order] });
51+
},
52+
});
53+
54+
const handleLikeClick = (reviewId: number, isLiked: boolean) => {
55+
if (isLiked) {
56+
unlikeReviewMutation.mutate(reviewId);
57+
} else {
58+
likeReviewMutation.mutate(reviewId);
59+
}
60+
};
61+
62+
if (isLoading) {
63+
return (
64+
<section className="w-[940px] mx-auto min-h-[120px] flex items-center justify-center text-gray-400">
65+
로딩 중...
66+
</section>
67+
);
68+
}
69+
70+
if (!reviews || reviews.length === 0) {
71+
return (
72+
<section className="w-[940px] mx-auto">
73+
<div className="bg-black-400 rounded-xl p-8">
74+
<div className="text-gray-400 text-center py-8">리뷰가 없습니다</div>
75+
</div>
76+
</section>
77+
);
78+
}
79+
80+
return (
81+
<section className="w-[940px] mx-auto">
82+
<div className="bg-black-400 rounded-xl p-8">
83+
<div className="space-y-6">
84+
{reviews.map((review) => (
85+
<article key={review.id} className="border-b border-gray-200 pb-6 last:border-0">
86+
<div className="flex items-center gap-4 mb-4">
87+
<div className="w-10 h-10 rounded-full overflow-hidden">
88+
<img
89+
src={review.user.image || '/placeholder-user.png'}
90+
alt={review.user.nickname}
91+
className="w-full h-full object-cover"
92+
/>
93+
</div>
94+
<div>
95+
<div className="font-medium">{review.user.nickname}</div>
96+
<div className="text-sm text-gray-500">{review.createdAt}</div>
97+
</div>
98+
</div>
99+
<div className="flex items-center gap-2 mb-2">
100+
<span className="text-main-indigo">⭐️ {review.rating}</span>
101+
</div>
102+
<div className="text-gray-300 mb-4">{review.content}</div>
103+
{review.reviewImages.length > 0 && (
104+
<div className="flex gap-2">
105+
{review.reviewImages.map((image) => (
106+
<div key={image.id} className="w-20 h-20 rounded-lg overflow-hidden">
107+
<img
108+
src={image.source}
109+
alt="리뷰 이미지"
110+
className="w-full h-full object-cover"
111+
/>
112+
</div>
113+
))}
114+
</div>
115+
)}
116+
<button
117+
className={`flex items-center gap-1 text-sm ${review.isLiked ? 'text-red-500' : 'text-gray-400'} hover:text-red-500`}
118+
onClick={() => handleLikeClick(review.id, review.isLiked)}
119+
disabled={likeReviewMutation.isPending || unlikeReviewMutation.isPending}
120+
>
121+
<span>❤️</span> {review.likeCount}
122+
</button>
123+
</article>
124+
))}
125+
</div>
126+
</div>
127+
</section>
128+
);
129+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
'use client';
2+
3+
import { useQuery } from '@tanstack/react-query';
4+
import { getProductById } from '@/api/products';
5+
import { ProductResponse } from '@/types/product';
6+
import StatisticCard from '@/components/products/StatisticCard';
7+
8+
export interface ProductStatisticsProps {
9+
id: number;
10+
}
11+
12+
type StatConfig = {
13+
key: string;
14+
label: string;
15+
render: (product: ProductResponse | undefined) => string | number;
16+
};
17+
18+
const STATS: StatConfig[] = [
19+
{
20+
key: 'rating',
21+
label: '별점 평균',
22+
render: (product) => product?.rating?.toFixed(1) || '4.9',
23+
},
24+
{
25+
key: 'favoriteCount',
26+
label: '찜',
27+
render: (product) => product?.favoriteCount || '566',
28+
},
29+
{
30+
key: 'reviewCount',
31+
label: '리뷰',
32+
render: (product) => product?.reviewCount || '4,123',
33+
},
34+
];
35+
36+
export default function ProductStatistics({ id }: ProductStatisticsProps) {
37+
const { data: product, isLoading } = useQuery<ProductResponse>({
38+
queryKey: ['product', id],
39+
queryFn: () => getProductById(id),
40+
enabled: Boolean(id),
41+
});
42+
43+
if (isLoading)
44+
return (
45+
<section className="w-full mx-auto mb-4 sm:mb-6 lg:mb-8 min-h-[120px] flex items-center justify-center text-gray-400">
46+
로딩 중...
47+
</section>
48+
);
49+
50+
return (
51+
<section className="w-full mx-auto mb-4 sm:mb-6 lg:mb-8">
52+
<h2 className="text-lg sm:text-xl font-normal leading-none mb-4">상품 통계</h2>
53+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
54+
{STATS.map((stat) => (
55+
<StatisticCard key={stat.key} value={stat.render(product)} label={stat.label} />
56+
))}
57+
</div>
58+
</section>
59+
);
60+
}

0 commit comments

Comments
 (0)