Skip to content

Commit 9e114f2

Browse files
authored
Merge pull request #97 from Leets-Official/96-feat/위시리스트-API-연결-및-코드-리팩토링
[Feat] 위시리스트 api 연결 및 코드 리팩토링
2 parents 3cb84dc + cfe253d commit 9e114f2

78 files changed

Lines changed: 1347 additions & 371 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/app/routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export default [
2323
route('repair', 'routes/(main)/repair/index.tsx'),
2424

2525
// seller
26-
route('seller/:userId', 'routes/(main)/seller/index.tsx'),
26+
route('seller/:postId', 'routes/(main)/seller/index.tsx'),
2727

2828
// 로그인 필요 페이지 (미로그인 시 /login 으로 리다이렉트)
2929
layout('layout/ProtectedLayout.tsx', [

src/pages/buy-detail/model/useManageActions.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,17 @@ export const useManageActions = (item: BuyItem | undefined) => {
1818
state: {
1919
postId: item.id,
2020
title: item.title,
21-
price: item.priceValue,
22-
imageUrl: item.image,
21+
price: String(item.priceValue),
22+
imageUrls: item.imageUrls,
2323
manufacturer: item.specs.manufacturer,
2424
modelName: item.specs.model,
2525
colorName: item.specs.color,
2626
storageSize: item.specs.storage,
2727
description: item.description.join('\n'),
28+
productCondition: item.used,
29+
scratchCondition: item.hasScratch,
30+
screenCondition: item.screenCracked,
31+
batteryCondition: item.batteryStatus,
2832
},
2933
});
3034
};
@@ -33,7 +37,7 @@ export const useManageActions = (item: BuyItem | undefined) => {
3337
deleteModal.close();
3438
try {
3539
await deleteMutation.mutateAsync();
36-
showToast('삭제되었습니다', 'success');
40+
showToast('삭제되었습니다', 'error');
3741
navigate(ROUTES.MAIN);
3842
} catch {
3943
showToast('삭제에 실패했습니다. 다시 시도해 주세요.', 'error');

src/pages/buy-detail/ui/BuyDetailPage.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { buildDetailInfo } from '@pages/buy-detail/model/buildDetailInfo';
22
import { useManageActions } from '@pages/buy-detail/model/useManageActions';
3-
import { useBuyItemQuery } from '@shared/apis/buy';
3+
import { useBuyItemQuery, useTogglePostWishlistMutation } from '@shared/apis/buy';
44
import { useCreateRoomMutation } from '@shared/apis/chat';
55
import { ROUTES } from '@shared/constants';
6-
import { LoadingFallback } from '@shared/ui/LoadingFallback';
76
import { NotFoundFallback } from '@shared/ui/NotFoundFallback';
87
import { useNavigate, useParams } from 'react-router';
8+
import { BuyDetailSkeleton } from './BuyDetailSkeleton';
99
import { ContactActions } from './ContactActions';
1010
import { ManageActions } from './ManageActions';
1111
import { ProductImageSection } from './ProductImageSection';
@@ -19,6 +19,7 @@ const BuyDetailPage = () => {
1919
const { data: item, isLoading } = useBuyItemQuery(id);
2020
const { deleteModal, isDeleting, handleEdit, handleDelete } = useManageActions(item);
2121
const createRoomMutation = useCreateRoomMutation();
22+
const toggleWishlistMutation = useTogglePostWishlistMutation();
2223

2324
const handleContact = () => {
2425
if (!item) {
@@ -32,7 +33,7 @@ const BuyDetailPage = () => {
3233
};
3334

3435
if (isLoading) {
35-
return <LoadingFallback message="상품 정보를 불러오는 중이에요." />;
36+
return <BuyDetailSkeleton />;
3637
}
3738

3839
if (!item) {
@@ -51,7 +52,12 @@ const BuyDetailPage = () => {
5152
return (
5253
<main className="md:px-xxxl mx-auto flex w-full max-w-[1200px] flex-col items-start gap-5 px-(--margin-l) lg:gap-[157px] lg:px-0">
5354
<div className="flex w-full flex-col items-start gap-5 lg:flex-row">
54-
<ProductImageSection image={item.image} title={item.title} seller={item.seller} />
55+
<ProductImageSection
56+
postId={item.id}
57+
images={item.imageUrls.length > 0 ? item.imageUrls : [item.image]}
58+
title={item.title}
59+
seller={item.seller}
60+
/>
5561

5662
<div className="flex w-full shrink-0 flex-col items-start gap-10 lg:w-[590px] lg:gap-[108px]">
5763
<ProductInfoSection
@@ -72,7 +78,11 @@ const BuyDetailPage = () => {
7278
onDeleteConfirm={handleDelete}
7379
/>
7480
) : (
75-
<ContactActions liked={item.liked} onContact={handleContact} />
81+
<ContactActions
82+
liked={item.liked}
83+
onContact={handleContact}
84+
onToggleFavorite={() => toggleWishlistMutation.mutate(item.id)}
85+
/>
7686
)}
7787
</div>
7888
</div>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
export const BuyDetailSkeleton = () => {
2+
return (
3+
<main className="md:px-xxxl mx-auto flex w-full max-w-[1200px] flex-col items-start gap-5 px-(--margin-l) lg:gap-[157px] lg:px-0">
4+
<div className="flex w-full flex-col items-start gap-5 lg:flex-row">
5+
{/* 이미지 섹션 */}
6+
<div className="flex w-full shrink-0 flex-col items-start gap-[30px] lg:w-[590px]">
7+
<div className="aspect-square w-full animate-pulse rounded-(--radius-s) bg-gray-200 lg:h-[568px] lg:w-[590px]" />
8+
<div className="flex items-center gap-[23px]">
9+
<div className="h-[52px] w-[52px] animate-pulse rounded-full bg-gray-200" />
10+
<div className="h-6 w-24 animate-pulse rounded bg-gray-200" />
11+
</div>
12+
</div>
13+
14+
{/* 정보 섹션 */}
15+
<div className="flex w-full shrink-0 flex-col items-start gap-10 lg:w-[590px] lg:gap-[108px]">
16+
<div className="flex flex-col items-start gap-[27px] self-stretch">
17+
<div className="flex flex-col items-start gap-[21px] self-stretch">
18+
<div className="flex flex-col items-start gap-[38px] self-stretch">
19+
<div className="h-8 w-3/4 animate-pulse rounded bg-gray-200" />
20+
<div className="h-10 w-32 animate-pulse rounded bg-gray-200" />
21+
</div>
22+
23+
<div className="h-px w-full bg-gray-100 lg:w-[584px]" />
24+
25+
<div className="gap-xxs flex flex-col items-start self-stretch">
26+
<div className="flex flex-col items-start gap-[6px] self-stretch">
27+
{Array.from({ length: 4 }).map((_, index) => (
28+
<div key={index} className="flex items-center gap-[11px]">
29+
<div className="h-4 w-12 animate-pulse rounded bg-gray-200" />
30+
<div className="h-5 w-24 animate-pulse rounded bg-gray-200" />
31+
</div>
32+
))}
33+
</div>
34+
<div className="h-4 w-40 animate-pulse rounded bg-gray-200" />
35+
</div>
36+
</div>
37+
38+
<div className="h-px w-full bg-gray-100 lg:w-[584px]" />
39+
40+
<div className="flex min-h-[80px] flex-col gap-2 self-stretch">
41+
<div className="h-4 w-full animate-pulse rounded bg-gray-200" />
42+
<div className="h-4 w-5/6 animate-pulse rounded bg-gray-200" />
43+
<div className="h-4 w-4/6 animate-pulse rounded bg-gray-200" />
44+
</div>
45+
</div>
46+
47+
{/* 액션 버튼 */}
48+
<div className="flex w-full gap-4">
49+
<div className="h-12 w-12 animate-pulse rounded-(--radius-l) bg-gray-200" />
50+
<div className="h-12 flex-1 animate-pulse rounded-(--radius-l) bg-gray-200" />
51+
</div>
52+
</div>
53+
</div>
54+
</main>
55+
);
56+
};
Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,54 @@
1+
import { ChevronLeftIcon, ChevronRightIcon } from '@shared/assets/icons';
12
import { ROUTES } from '@shared/constants';
23
import { Profile } from '@shared/ui/Profile';
4+
import { cn } from '@shared/utils/cn';
5+
import { useRef, useState } from 'react';
36
import { Link } from 'react-router';
47

58
type ProductImageSectionProps = {
6-
image: string;
9+
postId: string;
10+
images: string[];
711
title: string;
812
seller: {
9-
id?: string;
1013
nickname: string;
1114
profileImage?: string;
1215
};
1316
};
1417

15-
export const ProductImageSection = ({ image, title, seller }: ProductImageSectionProps) => {
18+
export const ProductImageSection = ({ postId, images, title, seller }: ProductImageSectionProps) => {
19+
const [currentIndex, setCurrentIndex] = useState(0);
20+
const touchStartX = useRef(0);
21+
const hasMultipleImages = images.length > 1;
22+
23+
const goToPrevious = () => {
24+
if (currentIndex > 0) {
25+
setCurrentIndex((prev) => prev - 1);
26+
}
27+
};
28+
29+
const goToNext = () => {
30+
if (currentIndex < images.length - 1) {
31+
setCurrentIndex((prev) => prev + 1);
32+
}
33+
};
34+
35+
const handleTouchStart = (e: React.TouchEvent) => {
36+
touchStartX.current = e.touches[0].clientX;
37+
};
38+
39+
const handleTouchEnd = (e: React.TouchEvent) => {
40+
const touchEndX = e.changedTouches[0].clientX;
41+
const diff = touchStartX.current - touchEndX;
42+
43+
if (Math.abs(diff) > 50) {
44+
if (diff > 0) {
45+
goToNext();
46+
} else {
47+
goToPrevious();
48+
}
49+
}
50+
};
51+
1652
const sellerContent = (
1753
<>
1854
<Profile size="chat" image={seller.profileImage} alt={`${seller.nickname} 프로필`} className="shrink-0" />
@@ -22,16 +58,72 @@ export const ProductImageSection = ({ image, title, seller }: ProductImageSectio
2258

2359
return (
2460
<div className="flex w-full shrink-0 flex-col items-start gap-[30px] lg:w-[590px]">
25-
<div className="aspect-square w-full overflow-hidden rounded-(--radius-s) bg-gray-50 lg:h-[568px] lg:w-[590px]">
26-
<img src={image} alt={title} className="h-full w-full object-cover" />
61+
<div
62+
className="group relative aspect-square w-full overflow-hidden rounded-(--radius-s) bg-gray-50 lg:h-[568px] lg:w-[590px]"
63+
onTouchStart={handleTouchStart}
64+
onTouchEnd={handleTouchEnd}
65+
>
66+
<div
67+
className="flex h-full transition-transform duration-300 ease-out"
68+
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
69+
>
70+
{images.map((image, index) => (
71+
<img
72+
key={index}
73+
src={image}
74+
alt={`${title} ${index + 1}`}
75+
className="h-full w-full shrink-0 object-cover"
76+
/>
77+
))}
78+
</div>
79+
80+
{hasMultipleImages && (
81+
<>
82+
{currentIndex > 0 && (
83+
<button
84+
type="button"
85+
onClick={goToPrevious}
86+
className="absolute top-1/2 left-4 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white/80 text-gray-700 opacity-0 shadow-md transition-opacity group-hover:opacity-100"
87+
aria-label="이전 이미지"
88+
>
89+
<ChevronLeftIcon className="h-6 w-6" />
90+
</button>
91+
)}
92+
{currentIndex < images.length - 1 && (
93+
<button
94+
type="button"
95+
onClick={goToNext}
96+
className="absolute top-1/2 right-4 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full bg-white/80 text-gray-700 opacity-0 shadow-md transition-opacity group-hover:opacity-100"
97+
aria-label="다음 이미지"
98+
>
99+
<ChevronRightIcon className="h-6 w-6" />
100+
</button>
101+
)}
102+
103+
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-2">
104+
{images.map((_, index) => (
105+
<button
106+
key={index}
107+
type="button"
108+
onClick={() => setCurrentIndex(index)}
109+
className={cn(
110+
'h-2 w-2 rounded-full transition-colors',
111+
index === currentIndex ? 'bg-green-500' : 'bg-white/60'
112+
)}
113+
aria-label={`${index + 1}번째 이미지로 이동`}
114+
/>
115+
))}
116+
</div>
117+
</>
118+
)}
27119
</div>
28-
{seller.id ? (
29-
<Link to={`${ROUTES.SELLER_PROFILE}/${seller.id}`} className="flex items-center gap-[23px]">
30-
{sellerContent}
31-
</Link>
32-
) : (
33-
<div className="flex items-center gap-[23px]">{sellerContent}</div>
34-
)}
120+
<Link
121+
to={`${ROUTES.SELLER_PROFILE}/${postId}`}
122+
state={{ nickname: seller.nickname, profileImage: seller.profileImage }}
123+
className="flex items-center gap-[23px]"
124+
>
125+
{sellerContent}
126+
</Link>
35127
</div>
36128
);
37129
};

src/pages/buy/ui/BuyFilter.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export const BuyFilter = ({
119119
{showAllModels ? '접기' : '더보기'}
120120
</button>
121121
)}
122-
<span className="typo-caption-2 mt-2 text-gray-400">시리즈 모델 포함</span>
122+
<span className="typo-caption-2 mt-2 text-gray-400">* 시리즈 모델 포함</span>
123123
</FilterSection>
124124

125125
<FilterSection title="가격">

src/pages/buy/ui/BuyPage.tsx

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@ import { useBuyAutocompleteQuery } from '@shared/apis/buy';
44
import { CloseIcon } from '@shared/assets/icons';
55
import { ROUTES } from '@shared/constants';
66
import { useDebounce } from '@shared/hooks';
7-
import { Card } from '@shared/ui/Card';
7+
import { Card, CardSkeleton } from '@shared/ui/Card';
88
import { EmptyState } from '@shared/ui/EmptyState';
9-
import { LoadingFallback } from '@shared/ui/LoadingFallback';
109
import { SearchBar } from '@shared/ui/SearchBar';
1110
import { cn } from '@shared/utils/cn';
1211
import { type KeyboardEvent, useMemo, useState } from 'react';
@@ -47,6 +46,7 @@ const BuyPage = () => {
4746

4847
const [isSearchFocused, setIsSearchFocused] = useState(false);
4948
const [activeIndex, setActiveIndex] = useState(-1);
49+
const [hasSelected, setHasSelected] = useState(false);
5050
const debouncedQuery = useDebounce(query.trim(), 300);
5151

5252
const { data: suggestions = [] } = useBuyAutocompleteQuery(debouncedQuery);
@@ -59,11 +59,12 @@ const BuyPage = () => {
5959
return suggestions.filter((item) => item.toLowerCase().includes(normalized.toLowerCase()));
6060
}, [debouncedQuery, suggestions]);
6161

62-
const showSuggestions = isSearchFocused && filteredSuggestions.length > 0;
62+
const showSuggestions = isSearchFocused && filteredSuggestions.length > 0 && !hasSelected;
6363

6464
const selectSuggestion = (value: string) => {
6565
setQuery(value);
6666
setActiveIndex(-1);
67+
setHasSelected(true);
6768
};
6869

6970
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
@@ -89,11 +90,7 @@ const BuyPage = () => {
8990
}
9091
};
9192

92-
const showEmpty = filteredItems.length === 0;
93-
94-
if (isLoading) {
95-
return <LoadingFallback message="상품을 불러오는 중이에요." />;
96-
}
93+
const showEmpty = !isLoading && filteredItems.length === 0;
9794

9895
return (
9996
<main className="md:px-xxxl mx-auto flex w-full max-w-[1200px] flex-col items-center gap-6 px-(--margin-l) md:gap-10 lg:gap-[68px] lg:px-0">
@@ -102,7 +99,10 @@ const BuyPage = () => {
10299
<SearchBar
103100
placeholder="어떤 제품을 찾으시나요?"
104101
value={query}
105-
onChange={setQuery}
102+
onChange={(value) => {
103+
setQuery(value);
104+
setHasSelected(false);
105+
}}
106106
onSearch={() => {}}
107107
onFocus={() => setIsSearchFocused(true)}
108108
onBlur={() => setIsSearchFocused(false)}
@@ -163,17 +163,19 @@ const BuyPage = () => {
163163
) : (
164164
<>
165165
<div className="lg:gap-xl grid w-full grid-cols-2 gap-4 sm:grid-cols-3 sm:gap-5 md:grid-cols-4 md:gap-6 lg:grid-cols-5">
166-
{filteredItems.map((item) => (
167-
<Link key={item.id} to={`${ROUTES.BUY}/${item.id}`} className="block focus-visible:outline-none">
168-
<Card
169-
image={item.image}
170-
title={item.title}
171-
price={item.priceLabel}
172-
date={item.dateLabel}
173-
className={cn(!item.available && 'opacity-50')}
174-
/>
175-
</Link>
176-
))}
166+
{isLoading
167+
? Array.from({ length: 10 }).map((_, index) => <CardSkeleton key={index} />)
168+
: filteredItems.map((item) => (
169+
<Link key={item.id} to={`${ROUTES.BUY}/${item.id}`} className="block focus-visible:outline-none">
170+
<Card
171+
image={item.image}
172+
title={item.title}
173+
price={item.priceLabel}
174+
date={item.dateLabel}
175+
className={cn(!item.available && 'opacity-50')}
176+
/>
177+
</Link>
178+
))}
177179
</div>
178180
<div ref={observerRef} className="h-10 w-full" />
179181
</>

0 commit comments

Comments
 (0)