diff --git a/src/apis/devices/searchDevices.ts b/src/apis/devices/searchDevices.ts new file mode 100644 index 00000000..b873558f --- /dev/null +++ b/src/apis/devices/searchDevices.ts @@ -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 => { + const { data } = await axiosInstance.get( + '/api/devices/search', + { params } + ); + return data.result ?? { devices: [], nextCursor: null, hasNext: false }; +}; + +export const useSearchDevices = (params: Omit) => { + return useInfiniteQuery({ + 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, + }); +}; diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx index 4e79f2b1..0d3d5e8b 100644 --- a/src/components/ProductCard/ProductCard.tsx +++ b/src/components/ProductCard/ProductCard.tsx @@ -12,19 +12,29 @@ const ProductCard: React.FC = ({ product, onClick }) => { onClick={onClick} > {/* Image - 정사각형 */} -
+
+ {product.image ? ( + {product.name} + ) : null} +
{/* Content */}
{/* Name & Category */}
-

{product.name}

+

+ {product.name.length > 19 ? `${product.name.slice(0, 19)}...` : product.name} +

{product.category}

{/* Price */}

- {product.price.toLocaleString()} + {(product.price ?? 0).toLocaleString()}

{/* Color Chips */} diff --git a/src/constants/queryKey.ts b/src/constants/queryKey.ts index 46b3bec6..97243d3d 100644 --- a/src/constants/queryKey.ts +++ b/src/constants/queryKey.ts @@ -10,4 +10,5 @@ export const queryKey = { LIFESTYLE_DEVICE: 'lifestyle_device', BRANDS: 'brands', RECENTLY_VIEWED: 'recently_viewed', + DEVICE_SEARCH: 'device_search', } as const; diff --git a/src/hooks/useIntersectionObserver.ts b/src/hooks/useIntersectionObserver.ts new file mode 100644 index 00000000..b2c78c02 --- /dev/null +++ b/src/hooks/useIntersectionObserver.ts @@ -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(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 }; +}; diff --git a/src/pages/devices/DeviceSearchPage.tsx b/src/pages/devices/DeviceSearchPage.tsx index 512420e2..ed978b68 100644 --- a/src/pages/devices/DeviceSearchPage.tsx +++ b/src/pages/devices/DeviceSearchPage.tsx @@ -4,9 +4,9 @@ import GNB from '@/components/Home/GNB'; import ProductCard from '@/components/ProductCard/ProductCard'; import PrimaryButton from '@/components/Button/PrimaryButton'; import CombinationDeviceCard from '@/components/Combination/CombinationDeviceCard'; -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'; @@ -22,9 +22,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'; @@ -48,6 +49,37 @@ const getCategoryDeviceType = (categoryId: number | null): string | undefined => return mapping[categoryId]; }; +// sortOption을 API sortType으로 변환 +const getSortType = (sortOption: string) => { + const mapping: Record = { + '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'); @@ -97,6 +129,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); @@ -108,9 +178,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 = () => { @@ -222,10 +293,10 @@ const DeviceSearchPage = () => { setShowSaveCompleteModal(false); setIsFadingOut(false); handleCloseModal(); - }, 200); // 0.2초 + }, 200); return () => clearTimeout(closeTimer); - }, 800); // 0.8초 + }, 800); return () => clearTimeout(holdTimer); } @@ -287,7 +358,7 @@ const DeviceSearchPage = () => { }; return ( -
+
{/* Main Content */} @@ -372,7 +443,7 @@ const DeviceSearchPage = () => {
{/* Left side - Result count */}
-

40

+

{allDevices.length}

개 결과

@@ -387,18 +458,41 @@ const DeviceSearchPage = () => { {/* Product Grid */}
-
- {MOCK_PRODUCTS.map((product) => ( - { - searchParams.set('productId', product.id.toString()); - setSearchParams(searchParams); - }} - /> - ))} -
+ {/* 초기 로딩: 데이터가 없고 로딩 중일 때만 로딩 스피너 표시 */} + {isSearchLoading && allDevices.length === 0 ? ( + + ) : isSearchError && allDevices.length === 0 ? ( +
+

검색 결과를 불러오는데 실패했습니다.

+
+ ) : allDevices.length === 0 ? ( +
+

검색 결과가 없습니다.

+
+ ) : ( +
+ {allDevices.map((device) => ( + { + searchParams.set('productId', device.deviceId.toString()); + setSearchParams(searchParams); + }} + /> + ))} +
+ )} + + {/* 무한 스크롤 트리거 */} +
+ + {/* 로딩 인디케이터 */} + {isFetchingNextPage && ( +
+

더 불러오는 중...

+
+ )}
{/* Top Button - 3행이 보일 때만 표시 */} @@ -457,7 +551,7 @@ const DeviceSearchPage = () => {

{selectedProduct.name}

-

{selectedProduct.price.toLocaleString()}

+

{(selectedProduct.price ?? 0).toLocaleString()}

@@ -466,6 +560,13 @@ const DeviceSearchPage = () => {
{/* Image */}
+ {selectedProduct.image ? ( + {selectedProduct.name} + ) : null}
{/* Right Section - Specs */} @@ -480,36 +581,45 @@ const DeviceSearchPage = () => {

카테고리

{selectedProduct.category}

-
-

브랜드

-

Apple

-
-
-

색상

-

내추럴 티타늄

-
+ {selectedDevice?.brandName && ( +
+

브랜드

+

{selectedDevice.brandName}

+
+ )}

가격

-

{selectedProduct.price.toLocaleString()}

+

{(selectedProduct.price ?? 0).toLocaleString()}

-
-

충전방식

-

USB-C

-
-
-

출시일

-

2023년 9월

-
+ {selectedDevice?.specifications?.screenInch ? ( +
+

인치

+

+ {String(selectedDevice.specifications.screenInch)} +

+
+ ) : null} + {selectedDevice?.specifications?.chargingPort ? ( +
+

충전방식

+

+ {String(selectedDevice.specifications.chargingPort).replace('_', '-')} +

+
+ ) : null} + {selectedDevice?.releaseDate && ( +
+

출시일

+

+ {new Date(selectedDevice.releaseDate).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long' })} +

+
+ )}
- {/* Hashtags */} -
- - -
diff --git a/src/types/devices.ts b/src/types/devices.ts index 2fb92528..d6564bee 100644 --- a/src/types/devices.ts +++ b/src/types/devices.ts @@ -1,4 +1,5 @@ import { type CombinationName, type CombinationStatus } from '@/constants/combination'; +import type { CommonResponse } from '@/types/common'; export type AuthStatus = 'logout' | 'login'; export type ModalView = 'device' | 'combination' | 'combinationDetail'; @@ -27,3 +28,38 @@ export type UserCombination = { createdAt?: string; tags: CombinationTagType[]; }; + +// 기기 검색 API 파라미터 +export interface SearchDevicesParams { + keyword?: string; + cursor?: string; + size?: number; + sortType?: 'LATEST' | 'NAME_ASC' | 'PRICE_ASC' | 'PRICE_DESC'; + deviceTypes?: string[]; + minPrice?: number; + maxPrice?: number; + brandIds?: number[]; +} + +// 검색 결과 기기 +export interface SearchDevice { + deviceId: number; + deviceType: string; + brandName: string; + name: string; + price: number; + priceCurrency: string; + imageUrl: string; + releaseDate: string; + specifications: Record; +} + +// 검색 결과 +export interface DeviceSearchResult { + devices: SearchDevice[]; + nextCursor: string | null; + hasNext: boolean; +} + +// API 응답 +export type GetDevicesSearchResponse = CommonResponse;