Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
34 changes: 34 additions & 0 deletions src/apis/devices/searchDevices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { axiosInstance } from '@/apis/axios/axios';
import type {
SearchDevicesParams,
GetDevicesSearchResponse,
DeviceSearchResult,
} from '@/types/devices';
import { useInfiniteQuery } from '@tanstack/react-query';
import { queryKey } from '@/constants/queryKey';

export const searchDevices = async (
params: SearchDevicesParams
): Promise<DeviceSearchResult> => {
const { data } = await axiosInstance.get<GetDevicesSearchResponse>(
'/api/devices/search',
{ params }
);
return data.result ?? { devices: [], nextCursor: null, hasNext: false };
};

export const useSearchDevices = (params: Omit<SearchDevicesParams, 'cursor'>) => {
return useInfiniteQuery<DeviceSearchResult, Error>({
queryKey: [queryKey.DEVICE_SEARCH, params],
queryFn: ({ pageParam }) =>
searchDevices({
...params,
cursor: pageParam as string | undefined,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage?.hasNext ? lastPage.nextCursor : undefined,
enabled: true,
staleTime: 1000 * 60 * 5,
});
};
16 changes: 13 additions & 3 deletions src/components/ProductCard/ProductCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,29 @@ const ProductCard: React.FC<ProductCardProps> = ({ product, onClick }) => {
onClick={onClick}
>
{/* Image - 정사각형 */}
<div className="w-full aspect-square bg-gray-200 mb-20" />
<div className="w-full aspect-square bg-gray-200 mb-20 overflow-hidden relative">
{product.image ? (
<img
src={product.image}
alt={product.name}
className="absolute inset-0 w-full h-full object-cover"
/>
) : null}
</div>

{/* Content */}
<div className="flex flex-col gap-16">
{/* Name & Category */}
<div className="flex flex-col gap-4">
<p className="font-heading-4 text-black group-hover:text-blue-600 transition-colors">{product.name}</p>
<p className="font-heading-4 text-black group-hover:text-blue-600 transition-colors">
{product.name.length > 19 ? `${product.name.slice(0, 19)}...` : product.name}
</p>
<p className="font-body-2-sm text-gray-300">{product.category}</p>
</div>

{/* Price */}
<p className="font-body-1-sm text-gray-500">
{product.price.toLocaleString()}
{(product.price ?? 0).toLocaleString()}
</p>

{/* Color Chips */}
Expand Down
1 change: 1 addition & 0 deletions src/constants/queryKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export const queryKey = {
LIFESTYLE_DEVICE: 'lifestyle_device',
BRANDS: 'brands',
RECENTLY_VIEWED: 'recently_viewed',
DEVICE_SEARCH: 'device_search',
} as const;
38 changes: 38 additions & 0 deletions src/hooks/useIntersectionObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useEffect, useRef, useState } from 'react';

interface UseIntersectionObserverOptions {
root?: Element | null;
rootMargin?: string;
threshold?: number | number[];
}

export const useIntersectionObserver = (
options: UseIntersectionObserverOptions = {}
) => {
const [isIntersecting, setIsIntersecting] = useState(false);
const targetRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const target = targetRef.current;
if (!target) return;

const observer = new IntersectionObserver(
([entry]) => {
setIsIntersecting(entry.isIntersecting);
},
{
root: options.root ?? null,
rootMargin: options.rootMargin ?? '0px',
threshold: options.threshold ?? 0,
}
);

observer.observe(target);

return () => {
observer.disconnect();
};
}, [options.root, options.rootMargin, options.threshold]);

return { targetRef, isIntersecting };
};
157 changes: 128 additions & 29 deletions src/pages/devices/DeviceSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import CombinationDeviceCard from '@/components/Combination/CombinationDeviceCar
import ProductLife from '@/components/ProductCard/ProductLife';
import FilterDropdown from '@/components/Filter/FilterDropdown';
import SortDropdown from '@/components/Filter/SortDropdown';
import LoadingSpinner from '@/components/LoadingSpinner';
import SearchIcon from '@/assets/icons/search.svg?react';
import FilterIcon from '@/assets/icons/filter.svg?react';
import TopIcon from '@/assets/icons/top.svg?react';
Expand All @@ -22,9 +23,10 @@ import {
PRICE_OPTIONS,
SCROLL_CONSTANTS,
} from '@/constants/devices';
import { MOCK_PRODUCTS } from '@/constants/mockData';
import { ROUTES } from '@/constants/routes';
import { type ModalView } from '@/types/devices';
import { type ModalView, type SearchDevice } from '@/types/devices';
import { useSearchDevices } from '@/apis/devices/searchDevices';
import { useIntersectionObserver } from '@/hooks/useIntersectionObserver';
import { useGetCombos } from '@/apis/combo/getCombos';
import { useGetCombo } from '@/apis/combo/getComboId';
import { usePostComboDevice } from '@/apis/combo/postComboDevices';
Expand All @@ -48,6 +50,37 @@ const getCategoryDeviceType = (categoryId: number | null): string | undefined =>
return mapping[categoryId];
};

// sortOption을 API sortType으로 변환
const getSortType = (sortOption: string) => {
const mapping: Record<string, 'LATEST' | 'NAME_ASC' | 'PRICE_ASC' | 'PRICE_DESC'> = {
'latest': 'LATEST',
'alphabetical': 'NAME_ASC',
'price-low': 'PRICE_ASC',
'price-high': 'PRICE_DESC',
};
return mapping[sortOption] ?? 'LATEST';
};

// SearchDevice를 Product 형식으로 변환
const mapSearchDeviceToProduct = (device: SearchDevice) => {
const brandName = device.brandName ?? '';
const deviceName = device.name ?? '';

// device.name이 이미 brandName으로 시작하면 중복 방지
const fullName = deviceName.startsWith(brandName)
? deviceName
: `${brandName} ${deviceName}`.trim();

return {
id: device.deviceId,
name: fullName,
category: device.deviceType ?? '',
price: device.price ?? 0,
image: device.imageUrl ?? null,
colors: [] as string[],
};
};

const DeviceSearchPage = () => {
const [searchParams, setSearchParams] = useSearchParams();
const selectedProductId = searchParams.get('productId');
Expand Down Expand Up @@ -97,6 +130,44 @@ const DeviceSearchPage = () => {
}));
}, [brandsData]);

// 기기 검색 API 파라미터 구성
const apiSearchParams = useMemo(() => {
const deviceType = getCategoryDeviceType(selectedCategory);
return {
keyword: searchQuery || undefined,
size: 24,
sortType: getSortType(sortOption),
deviceTypes: deviceType ? [deviceType] : undefined,
brandIds: selectedBrand ? [Number(selectedBrand)] : undefined,
};
}, [searchQuery, selectedCategory, sortOption, selectedBrand]);

// 기기 검색 API 호출
const {
data: searchData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading: isSearchLoading,
isError: isSearchError,
} = useSearchDevices(apiSearchParams);

// 전체 기기 목록 (모든 페이지 결합)
const allDevices = useMemo(() =>
searchData?.pages.flatMap(page => page.devices) ?? [],
[searchData]
);

// 무한 스크롤 트리거
const { targetRef, isIntersecting } = useIntersectionObserver({ rootMargin: '100px' });

// 스크롤 감지 시 다음 페이지 로드
useEffect(() => {
if (isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [isIntersecting, hasNextPage, isFetchingNextPage, fetchNextPage]);

// 페이지 마운트 시 상단으로 스크롤
useEffect(() => {
window.scrollTo(0, 0);
Expand All @@ -108,9 +179,10 @@ const DeviceSearchPage = () => {
}, [selectedCategory]);

/* 선택된 제품 찾기 */
const selectedProduct = selectedProductId
? MOCK_PRODUCTS.find(p => p.id === Number(selectedProductId))
const selectedDevice = selectedProductId
? allDevices.find(d => d.deviceId === Number(selectedProductId))
: null;
const selectedProduct = selectedDevice ? mapSearchDeviceToProduct(selectedDevice) : null;

/* 모달 닫기 */
const handleCloseModal = () => {
Expand Down Expand Up @@ -222,10 +294,10 @@ const DeviceSearchPage = () => {
setShowSaveCompleteModal(false);
setIsFadingOut(false);
handleCloseModal();
}, 200); // 0.2초
}, 200);

return () => clearTimeout(closeTimer);
}, 800); // 0.8초
}, 800);

return () => clearTimeout(holdTimer);
}
Expand Down Expand Up @@ -287,7 +359,7 @@ const DeviceSearchPage = () => {
};

return (
<div className={`min-h-screen bg-white relative ${isAtBottom ? 'bg-effect-fade-bottom' : ''}`}>
<div className={`min-h-screen bg-white relative max-w-[100vw] overflow-x-hidden ${isAtBottom ? 'bg-effect-fade-bottom' : ''}`}>
<GNB />

{/* Main Content */}
Expand Down Expand Up @@ -372,7 +444,7 @@ const DeviceSearchPage = () => {
<div className="flex items-center justify-between pt-80">
{/* Left side - Result count */}
<div className="flex items-center gap-2">
<p className="font-body-1-sm text-black">40</p>
<p className="font-body-1-sm text-black">{allDevices.length}</p>
<p className="font-body-1-r text-black">개 결과</p>
</div>

Expand All @@ -387,18 +459,41 @@ const DeviceSearchPage = () => {

{/* Product Grid */}
<div ref={productGridRef} className="mx-auto px-120 2xl:px-160">
<div className="grid grid-cols-3 2xl:grid-cols-4 gap-x-28 gap-y-164">
{MOCK_PRODUCTS.map((product) => (
<ProductCard
key={product.id}
product={product}
onClick={() => {
searchParams.set('productId', product.id.toString());
setSearchParams(searchParams);
}}
/>
))}
</div>
{/* 초기 로딩: 데이터가 없고 로딩 중일 때만 로딩 스피너 표시 */}
{isSearchLoading && allDevices.length === 0 ? (
<LoadingSpinner />
) : isSearchError && allDevices.length === 0 ? (
<div className="flex justify-center items-center py-100">
<p className="font-body-1-r text-red-500">검색 결과를 불러오는데 실패했습니다.</p>
</div>
) : allDevices.length === 0 ? (
<div className="flex justify-center items-center py-100">
<p className="font-body-1-r text-gray-400">검색 결과가 없습니다.</p>
</div>
) : (
<div className="grid grid-cols-3 2xl:grid-cols-4 gap-x-28 gap-y-164">
{allDevices.map((device) => (
<ProductCard
key={device.deviceId}
product={mapSearchDeviceToProduct(device)}
onClick={() => {
searchParams.set('productId', device.deviceId.toString());
setSearchParams(searchParams);
}}
/>
))}
</div>
)}

{/* 무한 스크롤 트리거 */}
<div ref={targetRef} className="h-20" />

{/* 로딩 인디케이터 */}
{isFetchingNextPage && (
<div className="flex justify-center py-40">
<p className="font-body-1-r text-gray-400">더 불러오는 중...</p>
</div>
)}
</div>

{/* Top Button - 3행이 보일 때만 표시 */}
Expand Down Expand Up @@ -457,7 +552,7 @@ const DeviceSearchPage = () => {
<p className="font-heading-1 text-blue-600">{selectedProduct.name}</p>
<div className="flex items-center gap-8 font-heading-2 text-black">
<p>₩</p>
<p>{selectedProduct.price.toLocaleString()}</p>
<p>{(selectedProduct.price ?? 0).toLocaleString()}</p>
</div>
</div>
</div>
Expand All @@ -482,26 +577,30 @@ const DeviceSearchPage = () => {
</div>
<div className="flex items-center gap-24">
<p className="font-body-2-r text-gray-400 w-80">브랜드</p>
<p className="font-body-2-r text-black">Apple</p>
</div>
<div className="flex items-center gap-24">
<p className="font-body-2-r text-gray-400 w-80">색상</p>
<p className="font-body-2-r text-black">내추럴 티타늄</p>
<p className="font-body-2-r text-black">{selectedDevice?.brandName ?? '-'}</p>
</div>
<div className="flex items-center gap-24">
<p className="font-body-2-r text-gray-400 w-80">가격</p>
<div className="flex items-center gap-4 font-body-2-r text-black">
<p>{selectedProduct.price.toLocaleString()}</p>
<p>{(selectedProduct.price ?? 0).toLocaleString()}</p>
<p>원</p>
</div>
</div>
<div className="flex items-center gap-24">
<p className="font-body-2-r text-gray-400 w-80">충전방식</p>
<p className="font-body-2-r text-black">USB-C</p>
<p className="font-body-2-r text-black">
{selectedDevice?.specifications?.chargingPort
? String(selectedDevice.specifications.chargingPort).replace('_', '-')
: '-'}
</p>
</div>
<div className="flex items-center gap-24">
<p className="font-body-2-r text-gray-400 w-80">출시일</p>
<p className="font-body-2-r text-black">2023년 9월</p>
<p className="font-body-2-r text-black">
{selectedDevice?.releaseDate
? new Date(selectedDevice.releaseDate).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long' })
: '-'}
</p>
</div>
</div>

Expand Down
Loading