Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d34562b
feat: 아이콘 추가 및 export 정리
KyeongJooni Feb 10, 2026
335564f
refactor: policy 상수를 constants로 이동
KyeongJooni Feb 10, 2026
f548682
feat: useScrollToError 훅 추가
KyeongJooni Feb 10, 2026
e797d6a
refactor: chunkArray를 es-toolkit으로 교체 및 가격 0원 검증 추가
KyeongJooni Feb 10, 2026
cd2a3e1
feat: Toast edit tone 추가 및 색상 스타일 개선
KyeongJooni Feb 10, 2026
bc77026
fix: TextField rawValue 타입 안전성 개선
KyeongJooni Feb 10, 2026
91df94b
style: Header 모바일 메뉴 애니메이션 추가
KyeongJooni Feb 10, 2026
055acc3
feat: CardSkeleton 추가 및 seller variant 배경색 제거
KyeongJooni Feb 10, 2026
9a10205
fix: FavoriteButton defaultActive prop 동기화
KyeongJooni Feb 10, 2026
98e7e68
feat: buy API 찜 토글 추가 및 batteryStatus 정규화 간소화
KyeongJooni Feb 10, 2026
2ad4d09
feat: sell API 예약/판매완료/판매중 상태 변경 추가
KyeongJooni Feb 10, 2026
51f9f06
refactor: seller API 응답 구조 변경 대응
KyeongJooni Feb 10, 2026
297f70a
feat: chat API roomByPost 쿼리 및 타입 확장
KyeongJooni Feb 10, 2026
3e1b5a1
feat: repair API 수리점 찜 토글/조회 추가
KyeongJooni Feb 10, 2026
9a90bc6
refactor: 구매 페이지 cursor-pointer 추가 및 코드 정리
KyeongJooni Feb 10, 2026
ef1d583
feat: 구매 상세 페이지 찜 기능 연동 및 스켈레톤 추가
KyeongJooni Feb 10, 2026
a4681e5
feat: 채팅 페이지 거래 상태 변경 기능 및 스켈레톤 추가
KyeongJooni Feb 10, 2026
17a5e45
feat: 수리점 페이지 찜 기능 연동 및 스켈레톤 추가
KyeongJooni Feb 10, 2026
d489ea6
refactor: 판매 페이지 에러 스크롤 및 수정 모드 개선
KyeongJooni Feb 10, 2026
4a4a6c4
refactor: 판매자 프로필 API 데이터 사용 및 스켈레톤 추가
KyeongJooni Feb 10, 2026
9990c1f
refactor: 회원가입 폼 에러 스크롤 추가
KyeongJooni Feb 10, 2026
4ef3dc2
refactor: seller 라우트 파라미터 userId에서 postId로 변경
KyeongJooni Feb 10, 2026
0e36aef
feat(sell): 다중 이미지 업로드 기능 구현
KyeongJooni Feb 10, 2026
9dfabcf
feat(buy-detail): 상품 이미지 캐러셀 기능 추가
KyeongJooni Feb 10, 2026
8a26ec3
fix(sell): 수정 모드 다중 이미지 처리 및 삭제 캐시 개선
KyeongJooni Feb 10, 2026
cfe253d
fix: 테스트 오류 수정
KyeongJooni Feb 10, 2026
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/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default [
route('repair', 'routes/(main)/repair/index.tsx'),

// seller
route('seller/:userId', 'routes/(main)/seller/index.tsx'),
route('seller/:postId', 'routes/(main)/seller/index.tsx'),

// 로그인 필요 페이지 (미로그인 시 /login 으로 리다이렉트)
layout('layout/ProtectedLayout.tsx', [
Expand Down
10 changes: 7 additions & 3 deletions src/pages/buy-detail/model/useManageActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@ export const useManageActions = (item: BuyItem | undefined) => {
state: {
postId: item.id,
title: item.title,
price: item.priceValue,
imageUrl: item.image,
price: String(item.priceValue),
imageUrls: item.imageUrls,
manufacturer: item.specs.manufacturer,
modelName: item.specs.model,
colorName: item.specs.color,
storageSize: item.specs.storage,
description: item.description.join('\n'),
productCondition: item.used,
scratchCondition: item.hasScratch,
screenCondition: item.screenCracked,
batteryCondition: item.batteryStatus,
},
});
};
Expand All @@ -33,7 +37,7 @@ export const useManageActions = (item: BuyItem | undefined) => {
deleteModal.close();
try {
await deleteMutation.mutateAsync();
showToast('삭제되었습니다', 'success');
showToast('삭제되었습니다', 'error');
navigate(ROUTES.MAIN);
} catch {
showToast('삭제에 실패했습니다. 다시 시도해 주세요.', 'error');
Expand Down
20 changes: 15 additions & 5 deletions src/pages/buy-detail/ui/BuyDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { buildDetailInfo } from '@pages/buy-detail/model/buildDetailInfo';
import { useManageActions } from '@pages/buy-detail/model/useManageActions';
import { useBuyItemQuery } from '@shared/apis/buy';
import { useBuyItemQuery, useTogglePostWishlistMutation } from '@shared/apis/buy';
import { useCreateRoomMutation } from '@shared/apis/chat';
import { ROUTES } from '@shared/constants';
import { LoadingFallback } from '@shared/ui/LoadingFallback';
import { NotFoundFallback } from '@shared/ui/NotFoundFallback';
import { useNavigate, useParams } from 'react-router';
import { BuyDetailSkeleton } from './BuyDetailSkeleton';
import { ContactActions } from './ContactActions';
import { ManageActions } from './ManageActions';
import { ProductImageSection } from './ProductImageSection';
Expand All @@ -19,6 +19,7 @@ const BuyDetailPage = () => {
const { data: item, isLoading } = useBuyItemQuery(id);
const { deleteModal, isDeleting, handleEdit, handleDelete } = useManageActions(item);
const createRoomMutation = useCreateRoomMutation();
const toggleWishlistMutation = useTogglePostWishlistMutation();

const handleContact = () => {
if (!item) {
Expand All @@ -32,7 +33,7 @@ const BuyDetailPage = () => {
};

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

if (!item) {
Expand All @@ -51,7 +52,12 @@ const BuyDetailPage = () => {
return (
<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">
<div className="flex w-full flex-col items-start gap-5 lg:flex-row">
<ProductImageSection image={item.image} title={item.title} seller={item.seller} />
<ProductImageSection
postId={item.id}
images={item.imageUrls.length > 0 ? item.imageUrls : [item.image]}
title={item.title}
seller={item.seller}
/>

<div className="flex w-full shrink-0 flex-col items-start gap-10 lg:w-[590px] lg:gap-[108px]">
<ProductInfoSection
Expand All @@ -72,7 +78,11 @@ const BuyDetailPage = () => {
onDeleteConfirm={handleDelete}
/>
) : (
<ContactActions liked={item.liked} onContact={handleContact} />
<ContactActions
liked={item.liked}
onContact={handleContact}
onToggleFavorite={() => toggleWishlistMutation.mutate(item.id)}
/>
)}
</div>
</div>
Expand Down
56 changes: 56 additions & 0 deletions src/pages/buy-detail/ui/BuyDetailSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
export const BuyDetailSkeleton = () => {
return (
<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">
<div className="flex w-full flex-col items-start gap-5 lg:flex-row">
{/* 이미지 섹션 */}
<div className="flex w-full shrink-0 flex-col items-start gap-[30px] lg:w-[590px]">
<div className="aspect-square w-full animate-pulse rounded-(--radius-s) bg-gray-200 lg:h-[568px] lg:w-[590px]" />
<div className="flex items-center gap-[23px]">
<div className="h-[52px] w-[52px] animate-pulse rounded-full bg-gray-200" />
<div className="h-6 w-24 animate-pulse rounded bg-gray-200" />
</div>
</div>

{/* 정보 섹션 */}
<div className="flex w-full shrink-0 flex-col items-start gap-10 lg:w-[590px] lg:gap-[108px]">
<div className="flex flex-col items-start gap-[27px] self-stretch">
<div className="flex flex-col items-start gap-[21px] self-stretch">
<div className="flex flex-col items-start gap-[38px] self-stretch">
<div className="h-8 w-3/4 animate-pulse rounded bg-gray-200" />
<div className="h-10 w-32 animate-pulse rounded bg-gray-200" />
</div>

<div className="h-px w-full bg-gray-100 lg:w-[584px]" />

<div className="gap-xxs flex flex-col items-start self-stretch">
<div className="flex flex-col items-start gap-[6px] self-stretch">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="flex items-center gap-[11px]">
<div className="h-4 w-12 animate-pulse rounded bg-gray-200" />
<div className="h-5 w-24 animate-pulse rounded bg-gray-200" />
</div>
))}
</div>
<div className="h-4 w-40 animate-pulse rounded bg-gray-200" />
</div>
</div>

<div className="h-px w-full bg-gray-100 lg:w-[584px]" />

<div className="flex min-h-[80px] flex-col gap-2 self-stretch">
<div className="h-4 w-full animate-pulse rounded bg-gray-200" />
<div className="h-4 w-5/6 animate-pulse rounded bg-gray-200" />
<div className="h-4 w-4/6 animate-pulse rounded bg-gray-200" />
</div>
</div>

{/* 액션 버튼 */}
<div className="flex w-full gap-4">
<div className="h-12 w-12 animate-pulse rounded-(--radius-l) bg-gray-200" />
<div className="h-12 flex-1 animate-pulse rounded-(--radius-l) bg-gray-200" />
</div>
</div>
</div>
</main>
);
};
116 changes: 104 additions & 12 deletions src/pages/buy-detail/ui/ProductImageSection.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,54 @@
import { ChevronLeftIcon, ChevronRightIcon } from '@shared/assets/icons';
import { ROUTES } from '@shared/constants';
import { Profile } from '@shared/ui/Profile';
import { cn } from '@shared/utils/cn';
import { useRef, useState } from 'react';
import { Link } from 'react-router';

type ProductImageSectionProps = {
image: string;
postId: string;
images: string[];
title: string;
seller: {
id?: string;
nickname: string;
profileImage?: string;
};
};

export const ProductImageSection = ({ image, title, seller }: ProductImageSectionProps) => {
export const ProductImageSection = ({ postId, images, title, seller }: ProductImageSectionProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
const touchStartX = useRef(0);
const hasMultipleImages = images.length > 1;

const goToPrevious = () => {
if (currentIndex > 0) {
setCurrentIndex((prev) => prev - 1);
}
};

const goToNext = () => {
if (currentIndex < images.length - 1) {
setCurrentIndex((prev) => prev + 1);
}
};

const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX;
};

const handleTouchEnd = (e: React.TouchEvent) => {
const touchEndX = e.changedTouches[0].clientX;
const diff = touchStartX.current - touchEndX;

if (Math.abs(diff) > 50) {
if (diff > 0) {
goToNext();
} else {
goToPrevious();
}
}
};

const sellerContent = (
<>
<Profile size="chat" image={seller.profileImage} alt={`${seller.nickname} 프로필`} className="shrink-0" />
Expand All @@ -22,16 +58,72 @@ export const ProductImageSection = ({ image, title, seller }: ProductImageSectio

return (
<div className="flex w-full shrink-0 flex-col items-start gap-[30px] lg:w-[590px]">
<div className="aspect-square w-full overflow-hidden rounded-(--radius-s) bg-gray-50 lg:h-[568px] lg:w-[590px]">
<img src={image} alt={title} className="h-full w-full object-cover" />
<div
className="group relative aspect-square w-full overflow-hidden rounded-(--radius-s) bg-gray-50 lg:h-[568px] lg:w-[590px]"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<div
className="flex h-full transition-transform duration-300 ease-out"
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
>
{images.map((image, index) => (
<img
key={index}
src={image}
alt={`${title} ${index + 1}`}
className="h-full w-full shrink-0 object-cover"
/>
))}
</div>

{hasMultipleImages && (
<>
{currentIndex > 0 && (
<button
type="button"
onClick={goToPrevious}
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"
aria-label="이전 이미지"
>
<ChevronLeftIcon className="h-6 w-6" />
</button>
)}
{currentIndex < images.length - 1 && (
<button
type="button"
onClick={goToNext}
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"
aria-label="다음 이미지"
>
<ChevronRightIcon className="h-6 w-6" />
</button>
)}

<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-2">
{images.map((_, index) => (
<button
key={index}
type="button"
onClick={() => setCurrentIndex(index)}
className={cn(
'h-2 w-2 rounded-full transition-colors',
index === currentIndex ? 'bg-green-500' : 'bg-white/60'
)}
aria-label={`${index + 1}번째 이미지로 이동`}
/>
))}
</div>
</>
)}
</div>
{seller.id ? (
<Link to={`${ROUTES.SELLER_PROFILE}/${seller.id}`} className="flex items-center gap-[23px]">
{sellerContent}
</Link>
) : (
<div className="flex items-center gap-[23px]">{sellerContent}</div>
)}
<Link
to={`${ROUTES.SELLER_PROFILE}/${postId}`}
state={{ nickname: seller.nickname, profileImage: seller.profileImage }}
className="flex items-center gap-[23px]"
>
{sellerContent}
</Link>
</div>
);
};
2 changes: 1 addition & 1 deletion src/pages/buy/ui/BuyFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export const BuyFilter = ({
{showAllModels ? '접기' : '더보기'}
</button>
)}
<span className="typo-caption-2 mt-2 text-gray-400">시리즈 모델 포함</span>
<span className="typo-caption-2 mt-2 text-gray-400">* 시리즈 모델 포함</span>
</FilterSection>

<FilterSection title="가격">
Expand Down
42 changes: 22 additions & 20 deletions src/pages/buy/ui/BuyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ import { useBuyAutocompleteQuery } from '@shared/apis/buy';
import { CloseIcon } from '@shared/assets/icons';
import { ROUTES } from '@shared/constants';
import { useDebounce } from '@shared/hooks';
import { Card } from '@shared/ui/Card';
import { Card, CardSkeleton } from '@shared/ui/Card';
import { EmptyState } from '@shared/ui/EmptyState';
import { LoadingFallback } from '@shared/ui/LoadingFallback';
import { SearchBar } from '@shared/ui/SearchBar';
import { cn } from '@shared/utils/cn';
import { type KeyboardEvent, useMemo, useState } from 'react';
Expand Down Expand Up @@ -47,6 +46,7 @@ const BuyPage = () => {

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

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

const showSuggestions = isSearchFocused && filteredSuggestions.length > 0;
const showSuggestions = isSearchFocused && filteredSuggestions.length > 0 && !hasSelected;

const selectSuggestion = (value: string) => {
setQuery(value);
setActiveIndex(-1);
setHasSelected(true);
};

const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
Expand All @@ -89,11 +90,7 @@ const BuyPage = () => {
}
};

const showEmpty = filteredItems.length === 0;

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

return (
<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">
Expand All @@ -102,7 +99,10 @@ const BuyPage = () => {
<SearchBar
placeholder="어떤 제품을 찾으시나요?"
value={query}
onChange={setQuery}
onChange={(value) => {
setQuery(value);
setHasSelected(false);
}}
onSearch={() => {}}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
Expand Down Expand Up @@ -163,17 +163,19 @@ const BuyPage = () => {
) : (
<>
<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">
{filteredItems.map((item) => (
<Link key={item.id} to={`${ROUTES.BUY}/${item.id}`} className="block focus-visible:outline-none">
<Card
image={item.image}
title={item.title}
price={item.priceLabel}
date={item.dateLabel}
className={cn(!item.available && 'opacity-50')}
/>
</Link>
))}
{isLoading
? Array.from({ length: 10 }).map((_, index) => <CardSkeleton key={index} />)
: filteredItems.map((item) => (
<Link key={item.id} to={`${ROUTES.BUY}/${item.id}`} className="block focus-visible:outline-none">
<Card
image={item.image}
title={item.title}
price={item.priceLabel}
date={item.dateLabel}
className={cn(!item.available && 'opacity-50')}
/>
</Link>
))}
</div>
<div ref={observerRef} className="h-10 w-full" />
</>
Expand Down
Loading
Loading