Skip to content

Commit 6cae460

Browse files
authored
Merge pull request #144 from codeit-13-3team/fix/ProductDetailFix
fix: product 상세 페이지 수정
2 parents dd5bd47 + 74c1064 commit 6cae460

14 files changed

Lines changed: 375 additions & 166 deletions

src/api/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,4 @@ export const kakaoSignup = async (token: string, nickname: string) => {
4242
token,
4343
});
4444
return response.data;
45-
};
45+
};

src/api/axiosInstance.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import axios from 'axios';
22

3-
// export const teamId = '13-3';
4-
53
const axiosInstance = axios.create({
64
baseURL: 'https://mogazoa-api.vercel.app/13-3',
75
});

src/components/StarRating.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,32 @@ import FullStar from '../../public/icon/common/star.png';
55

66
type StarRatingProps = {
77
value: number;
8-
onChange: (value: number) => void;
8+
onChange?: (value: number) => void;
9+
starClassName?: string;
910
};
1011

11-
function StarRating({ value, onChange }: StarRatingProps) {
12+
function StarRating({ value, onChange, starClassName }: StarRatingProps) {
1213
const handleClick = (e: React.MouseEvent<HTMLSpanElement>, index: number) => {
1314
const { left, width } = e.currentTarget.getBoundingClientRect();
1415
const x = e.clientX - left;
1516
const isHalf = x < width / 2;
1617
const newValue = isHalf ? index + 0.5 : index + 1;
17-
onChange(newValue);
18+
onChange?.(newValue);
1819
};
1920

2021
const renderStar = (index: number) => {
22+
const commonClasses = starClassName ?? 'md:w-8 md:h-8';
23+
2124
const diff = value - index;
2225
if (diff >= 1) {
2326
return (
24-
<Image src={FullStar} alt="별점 아이콘" width={28} height={28} className="md:w-8 md:h-8" />
27+
<Image src={FullStar} alt="별점 아이콘" width={28} height={28} className={commonClasses} />
2528
);
2629
}
2730
if (diff >= 0.5) {
28-
return <HalfStar size={28} className="text-yellow md:w-8 md:h-8" />;
31+
return <HalfStar size={28} className={`${commonClasses} text-yellow`} />;
2932
}
30-
return <EmptyStar size={28} className="text-gray-200 md:w-8 md:h-8" />;
33+
return <EmptyStar size={28} className={`${commonClasses} text-gray-200`} />;
3134
};
3235

3336
return (
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
const CATEGORY_COLOR_MAP: Record<string, string> = {
2+
음악: 'bg-[#C5D17C1A] text-[#C5D17C]',
3+
'영화/드라마': 'bg-[#F755321A] text-[#F75532]',
4+
'강의/책': 'bg-[#A953FF1A] text-[#A953FF]',
5+
호텔: 'bg-[#49AF1A1A] text-[#49AF1A]',
6+
'가구/인테리어': 'bg-[#D676C11A] text-[#D676C1]',
7+
식당: 'bg-[#FF7E461A] text-[#FF7E46]',
8+
전자기기: 'bg-[#23B5811A] text-[#23B581]',
9+
화장품: 'bg-[#FD529A1A] text-[#FD529A]',
10+
'의류/악세서리': 'bg-[#757AFF1A] text-[#757AFF]',
11+
: 'bg-[#3098E31A] text-[#3098E3]',
12+
};
13+
14+
interface CategoryTagProps {
15+
name?: string;
16+
}
17+
18+
const CategoryTag = ({ name }: CategoryTagProps) => {
19+
if (!name) return null;
20+
21+
const colorClasses = CATEGORY_COLOR_MAP[name] ?? 'bg-gray-700 text-gray-200';
22+
23+
return (
24+
<span className={`inline-block text-[12px] px-2 py-1 rounded ${colorClasses}`}>{name}</span>
25+
);
26+
};
27+
28+
export default CategoryTag;

src/components/products/ProductDetail.tsx

Lines changed: 88 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,36 @@
1-
'use client';
2-
31
import Image from 'next/image';
42
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
53
import { ProductResponse } from '@/types/product';
64
import { getProductById } from '@/api/products';
75
import ProductDetailButtonGroup from '@/components/products/ProductDetailButtonGroup';
6+
import CategoryTag from './CategoryTag';
7+
import Share from '../../../public/icon/common/share.png';
8+
import unFilledHeart from '../../../public/icon/common/unsave.png';
9+
import FilledHeart from '../../../public/icon/common/save.png';
10+
import useAuthStore from '@/stores/authStores';
11+
import { useRouter } from 'next/router';
12+
import axiosInstance from '@/api/axiosInstance';
813

914
interface ProductDetailProps {
1015
id: number;
1116
}
1217

1318
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`);
19+
try {
20+
if (isFavorite) {
21+
await axiosInstance.delete(`/products/${productId}/favorite`);
22+
} else {
23+
await axiosInstance.post(`/products/${productId}/favorite`);
24+
}
25+
} catch (err: any) {
26+
throw new Error(err.message || 'Failed to toggle favorite');
2027
}
2128
};
2229

2330
export default function ProductDetail({ id }: ProductDetailProps) {
2431
const queryClient = useQueryClient();
32+
const router = useRouter();
33+
const isLoggedIn = useAuthStore((state) => state.isLoggedIn);
2534

2635
const { data: product, isLoading } = useQuery<ProductResponse>({
2736
queryKey: ['product', id],
@@ -34,14 +43,37 @@ export default function ProductDetail({ id }: ProductDetailProps) {
3443
onSuccess: () => {
3544
queryClient.invalidateQueries({ queryKey: ['product', id] });
3645
},
46+
onError: (err) => {
47+
if (err.message === 'Unauthorized') {
48+
alert('로그인이 필요한 기능입니다.');
49+
router.push('/login');
50+
} else {
51+
alert(err.message);
52+
}
53+
},
3754
});
3855

3956
const handleFavoriteClick = () => {
57+
if (!isLoggedIn) {
58+
alert('로그인이 필요한 기능입니다.');
59+
return router.push('/login');
60+
}
4061
if (product) {
4162
favoriteMutation.mutate({ productId: product.id, isFavorite: product.isFavorite });
4263
}
4364
};
4465

66+
const handleShareClick = async () => {
67+
try {
68+
const url = window.location.href;
69+
await navigator.clipboard.writeText(url);
70+
alert('클립보드에 URL이 복사되었습니다.');
71+
} catch (err) {
72+
console.error('클립보드 복사 실패:', err);
73+
alert('URL 복사에 실패했습니다.');
74+
}
75+
};
76+
4577
if (isLoading) {
4678
return (
4779
<section className="flex items-center justify-center min-h-[200px]">
@@ -59,40 +91,62 @@ export default function ProductDetail({ id }: ProductDetailProps) {
5991
}
6092

6193
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">
94+
<section className="w-full mx-auto mb-[60px] lg:mb-20">
95+
<div className="w-full flex flex-col md:flex-row items-center gap-4">
96+
<div className="lg:w-[355px] min-w-[240px] w-full flex items-center justify-center">
6597
<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 || '상품 이미지'}
98+
src={product.image}
99+
alt={product.name}
71100
width={160}
72101
height={160}
73-
className="object-contain"
102+
priority
103+
className="object-contain h-auto"
74104
/>
75105
</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}
106+
<div className="flex flex-col justify-between min-w-0 w-full">
107+
<>
108+
<div className="flex justify-between mb-[10px]">
109+
<CategoryTag name={product.category?.name} />
110+
<div
111+
className="p-[5px] bg-black-400 rounded-[6px] block md:hidden"
112+
onClick={handleShareClick}
113+
>
114+
<Image src={Share} alt="공유하기 아이콘" width={14} height={14} />
115+
</div>
116+
</div>
117+
<div className="text-lg sm:text-xl lg:text-2xl font-bold mb-5 md:mb-[50px] flex items-center justify-between">
118+
<div className="flex items-center gap-[15px]">
119+
<span>{product.name || '상품명'}</span>
120+
{product.isFavorite ? (
121+
<Image
122+
src={FilledHeart}
123+
alt="좋아요 아이콘"
124+
width={24}
125+
height={24}
126+
onClick={handleFavoriteClick}
127+
/>
128+
) : (
129+
<Image
130+
src={unFilledHeart}
131+
alt="비어있는 좋아요 아이콘"
132+
width={24}
133+
height={24}
134+
onClick={handleFavoriteClick}
135+
/>
136+
)}
137+
</div>
138+
<div
139+
className="p-[5px] bg-black-400 rounded-[6px] hidden md:block"
140+
onClick={handleShareClick}
87141
>
88-
{product.isFavorite ? '❤️' : '🤍'}
89-
</button>
142+
<Image src={Share} alt="공유하기 아이콘" width={14} height={14} />
143+
</div>
90144
</div>
91-
<div className="text-sm lg:text-base text-gray-300">
92-
{product.description || '상품 설명이 들어갑니다.'}
145+
<div className="text-sm lg:text-base mb-[67px] md:mb-[60px] text-gray-50">
146+
{product.description}
93147
</div>
94-
</div>
95-
<ProductDetailButtonGroup />
148+
</>
149+
<ProductDetailButtonGroup product={product} />
96150
</div>
97151
</div>
98152
</section>
Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,38 @@
1+
import { ProductResponse } from '@/types/product';
12
import Button from '../button/Button';
23

3-
export interface ProductDetailButtonGroupProps {}
4+
export interface ProductDetailButtonGroupProps {
5+
product: ProductResponse;
6+
}
7+
8+
export default function ProductDetailButtonGroup({ product }: ProductDetailButtonGroupProps) {
9+
const user = JSON.parse(localStorage.getItem('user')!);
410

5-
export default function ProductDetailButtonGroup({}: ProductDetailButtonGroupProps) {
611
return (
7-
<section className="flex flex-col sm:flex-row gap-3 sm:gap-4 w-full">
12+
<section className="flex flex-col md:flex-row gap-4 w-full">
813
<Button
914
size="l"
1015
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"
16+
className="w-full flex-[2] py-[15.5px] md:py-[18px] lg:py-[22px] rounded-[8px] bg-gradient-to-r from-main-blue to-main-indigo text-gray-50"
1217
>
1318
리뷰 작성하기
1419
</Button>
1520
<Button
1621
size="l"
1722
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"
23+
className="w-full flex-[1] py-[15.5px] md:py-[18px] lg:py-[22px] rounded-[8px] border border-main-blue text-main-blue bg-black-500"
1924
>
2025
비교하기
2126
</Button>
27+
{product.writerId === user.id && (
28+
<Button
29+
size="l"
30+
variant="tertiary"
31+
className="w-full flex-[1] py-[15.5px] md:py-[18px] lg:py-[22px] rounded-[8px] border border-gray-100 text-gray-100 bg-black-500"
32+
>
33+
편집하기
34+
</Button>
35+
)}
2236
</section>
2337
);
2438
}

src/components/products/ProductDetailLayout.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
'use client';
2-
31
import React, { useState } from 'react';
42
import ProductDetail from '@/components/products/ProductDetail';
53
import ProductStatistics from '@/components/products/ProductStatistics';
@@ -23,15 +21,15 @@ export default function ProductDetailLayout({ id }: ProductDetailLayoutProps) {
2321
const [sortOrder, setSortOrder] = useState<SortOrder>('recent');
2422

2523
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">
24+
<div className="min-h-screen text-gray-50">
25+
<div className="w-full max-w-[1920px] mx-auto px-4 lg:px-8 py-4 lg:py-8 flex flex-col items-center">
2826
<div className="w-full max-w-[940px]">
2927
<ProductDetail id={id} />
3028
<ProductStatistics id={id} />
3129

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>
30+
<div className="w-full mx-auto mb-4 lg:mb-8">
31+
<div className="flex justify-between items-center gap-3 mb-[30px]">
32+
<h2 className="text-[16px] font-semibold text-gray-50">상품 리뷰</h2>
3533
<ReviewSortDropdown
3634
options={SORT_OPTIONS}
3735
value={sortOrder}

0 commit comments

Comments
 (0)