diff --git a/src/apis/brand/getBrands.ts b/src/apis/brand/getBrands.ts new file mode 100644 index 00000000..307ed4a3 --- /dev/null +++ b/src/apis/brand/getBrands.ts @@ -0,0 +1,22 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { GetBrandsResponse, GetBrandsResult, DeviceType } from '@/types/brand/brand'; +import { useQuery } from '@tanstack/react-query'; +import { queryKey } from '@/constants/queryKey'; + +// 브랜드 목록 조회 +export const getBrands = async (deviceType: DeviceType): Promise => { + const { data } = await axiosInstance.get( + `/api/brands?deviceType=${deviceType}` + ); + return data.result ?? []; +}; + +export const useGetBrands = (deviceType: DeviceType | null) => { + return useQuery({ + queryKey: [queryKey.BRANDS, deviceType], + queryFn: () => getBrands(deviceType!), + enabled: deviceType !== null, + staleTime: 5 * 60 * 1000, // 5분 + gcTime: 10 * 60 * 1000, // 10분 + }); +}; diff --git a/src/apis/device/searchDevices.ts b/src/apis/device/searchDevices.ts new file mode 100644 index 00000000..aafdceb4 --- /dev/null +++ b/src/apis/device/searchDevices.ts @@ -0,0 +1,36 @@ +import { axiosInstance } from '@/apis/axios/axios'; +import type { SearchDevicesParams, SearchDevicesResponse, SearchDevicesResult } from '@/types/device/device'; +import { useQuery, 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, + paramsSerializer: { + indexes: null, // array를 deviceTypes=A&deviceTypes=B 형식으로 직렬화 + }, + }); + return data.result ?? { devices: [], nextCursor: null, hasNext: false }; +}; + +export const useSearchDevices = (params: SearchDevicesParams) => { + return useQuery({ + queryKey: [queryKey.DEVICES, 'search', params], + queryFn: () => searchDevices(params), + staleTime: 1 * 60 * 1000, // 1분 + gcTime: 5 * 60 * 1000, // 5분 + }); +}; + +// 무한 스크롤을 위한 hook +export const useInfiniteSearchDevices = (params: Omit) => { + return useInfiniteQuery({ + queryKey: [queryKey.DEVICES, 'search', params], + queryFn: ({ pageParam }) => searchDevices({ ...params, cursor: pageParam as string | undefined }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => (lastPage.hasNext ? lastPage.nextCursor : undefined), + staleTime: 1 * 60 * 1000, // 1분 + gcTime: 5 * 60 * 1000, // 5분 + }); +}; diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx index 4e79f2b1..54232090 100644 --- a/src/components/ProductCard/ProductCard.tsx +++ b/src/components/ProductCard/ProductCard.tsx @@ -8,7 +8,7 @@ interface ProductCardProps { const ProductCard: React.FC = ({ product, onClick }) => { return (
{/* Image - 정사각형 */} @@ -24,7 +24,7 @@ const ProductCard: React.FC = ({ product, onClick }) => { {/* Price */}

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

{/* Color Chips */} diff --git a/src/constants/devices.ts b/src/constants/devices.ts index 38e3b9cc..36d8633d 100644 --- a/src/constants/devices.ts +++ b/src/constants/devices.ts @@ -7,6 +7,7 @@ import KeyboardIcon from '@/assets/icons/keyboard.svg?react'; import MouseIcon from '@/assets/icons/mouse.svg?react'; import ChargeIcon from '@/assets/icons/charge.svg?react'; import type { ComponentType, SVGProps } from 'react'; +import type { DeviceType } from '@/types/brand/brand'; export interface DeviceCategory { id: number; @@ -30,6 +31,17 @@ export const DEVICE_CATEGORIES: DeviceCategory[] = [ { id: 8, name: '충전기', Icon: ChargeIcon }, ]; +export const CATEGORY_TO_DEVICE_TYPE: Record = { + 1: 'SMARTPHONE', + 2: 'LAPTOP', + 3: 'TABLET', + 4: 'SMARTWATCH', + 5: 'AUDIO', + 6: 'KEYBOARD', + 7: 'MOUSE', + 8: 'CHARGER', +}; + export const SORT_OPTIONS: FilterOption[] = [ { value: 'latest', label: '최신순' }, { value: 'alphabetical', label: '가나다순' }, diff --git a/src/constants/queryKey.ts b/src/constants/queryKey.ts index ac821956..fe22725d 100644 --- a/src/constants/queryKey.ts +++ b/src/constants/queryKey.ts @@ -7,4 +7,6 @@ export const queryKey = { TAGS: 'tags', COMBOS: 'combos', COMBO_DETAIL: 'combo', + BRANDS: 'brands', + DEVICES: 'devices', } as const; diff --git a/src/index.css b/src/index.css index 091289c0..e21f7a7d 100644 --- a/src/index.css +++ b/src/index.css @@ -77,11 +77,13 @@ --spacing-164: 164px; --spacing-184: 184px; --spacing-196: 196px; + --spacing-200: 200px; --spacing-204: 204px; --spacing-228: 228px; --spacing-268: 268px; --spacing-280: 280px; --spacing-300: 300px; + --spacing-600: 600px; /* Typography */ --font-service: 'KIMM_Bold', system-ui, sans-serif; diff --git a/src/pages/devices/DeviceSearchPage.tsx b/src/pages/devices/DeviceSearchPage.tsx index bc12fc11..1c47eb25 100644 --- a/src/pages/devices/DeviceSearchPage.tsx +++ b/src/pages/devices/DeviceSearchPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; import GNB from '@/components/Home/GNB'; import ProductCard from '@/components/ProductCard/ProductCard'; @@ -20,14 +20,17 @@ import { DEVICE_CATEGORIES, SORT_OPTIONS, PRICE_OPTIONS, - BRAND_OPTIONS, SCROLL_CONSTANTS, + CATEGORY_TO_DEVICE_TYPE, } from '@/constants/devices'; -import { MOCK_PRODUCTS } from '@/constants/mockData'; import { type AuthStatus, type ModalView } from '@/types/devices'; import { useGetCombos } from '@/apis/combo/getCombos'; import { useGetCombo } from '@/apis/combo/getComboId'; import { usePostComboDevice } from '@/apis/combo/postComboDevices'; +import { useGetBrands } from '@/apis/brand/getBrands'; +import { useInfiniteSearchDevices } from '@/apis/device/searchDevices'; +import { convertSortOption, convertPriceOptions, convertDeviceToProduct } from '@/utils/deviceFilter'; +import type { SearchDevicesParams } from '@/types/device/device'; const DeviceSearchPage = () => { const [searchParams, setSearchParams] = useSearchParams(); @@ -37,15 +40,11 @@ const DeviceSearchPage = () => { const [authStatus] = useState('login'); // 테스트로 login으로 변경. 추후 logout으로 변경. const [modalView, setModalView] = useState('device'); - // API hooks - const { data: combos = [] } = useGetCombos(); - const { mutate: addDeviceToCombo, isPending: isAddingDevice } = usePostComboDevice(); - const [searchQuery, setSearchQuery] = useState(''); const [selectedCategory, setSelectedCategory] = useState(null); const [sortOption, setSortOption] = useState('latest'); const [selectedPrice, setSelectedPrice] = useState([]); - const [selectedBrand, setSelectedBrand] = useState(null); + const [selectedBrand, setSelectedBrand] = useState(null); const [isAtBottom, setIsAtBottom] = useState(false); const [showTopButton, setShowTopButton] = useState(false); const [selectedCombinationId, setSelectedCombinationId] = useState(null); @@ -53,6 +52,13 @@ const DeviceSearchPage = () => { const [showSaveCompleteModal, setShowSaveCompleteModal] = useState(false); const [isFadingOut, setIsFadingOut] = useState(false); + // API hooks + const { data: combos = [] } = useGetCombos(); + const { mutate: addDeviceToCombo, isPending: isAddingDevice } = usePostComboDevice(); + + const deviceType = selectedCategory ? CATEGORY_TO_DEVICE_TYPE[selectedCategory] : null; + const { data: brands = [] } = useGetBrands(deviceType); + // 선택된 조합의 상세 정보 조회 const { data: comboDetail } = useGetCombo(selectedCombinationId); @@ -63,9 +69,41 @@ const DeviceSearchPage = () => { window.scrollTo(0, 0); }, []); + /* API 파라미터 생성 */ + const apiParams = useMemo>(() => ({ + keyword: searchQuery || undefined, + size: 24, + sortType: convertSortOption(sortOption), + deviceTypes: selectedCategory ? [CATEGORY_TO_DEVICE_TYPE[selectedCategory]] : undefined, + ...convertPriceOptions(selectedPrice), + brandIds: selectedBrand ? [selectedBrand] : undefined, + }), [searchQuery, sortOption, selectedCategory, selectedPrice, selectedBrand]); + + /* API 호출 */ + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useInfiniteSearchDevices(apiParams); + + /* 모든 페이지의 devices를 평탄화 */ + const allDevices = useMemo( + () => data?.pages.flatMap(page => page.devices) ?? [], + [data] + ); + + /* Product 타입으로 변환 */ + const products = useMemo( + () => allDevices.map(convertDeviceToProduct), + [allDevices] + ); + /* 선택된 제품 찾기 */ const selectedProduct = selectedProductId - ? MOCK_PRODUCTS.find(p => p.id === Number(selectedProductId)) + ? products.find(p => p.id === Number(selectedProductId)) : null; /* 모달 닫기 */ @@ -192,13 +230,23 @@ const DeviceSearchPage = () => { scrollTop + windowHeight >= gridTop + SCROLL_CONSTANTS.TOP_BUTTON_THRESHOLD; setShowTopButton(thirdRowVisible); } + + /* 무한 스크롤: 하단 도달 시 다음 페이지 로드 */ + if (reachedBottom && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } }; window.addEventListener('scroll', handleScroll); - handleScroll(); + handleScroll(); return () => window.removeEventListener('scroll', handleScroll); - }, []); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + /* 카테고리 변경 시 브랜드 필터 초기화 */ + useEffect(() => { + setSelectedBrand(null); + }, [selectedCategory]); /* 모달 열렸을 때 y 스크롤 방지 */ useEffect(() => { @@ -216,6 +264,12 @@ const DeviceSearchPage = () => { window.scrollTo({ top: 0, behavior: 'smooth' }); }; + /* 브랜드 옵션 변환 */ + const brandOptions = brands.map(brand => ({ + value: brand.brandId.toString(), + label: brand.brandName, + })); + return (
@@ -223,7 +277,13 @@ const DeviceSearchPage = () => { {/* Main Content */} {/*
*/} {/* Search Bar */} -
+
{
{/* Device Categories */} -
-
+
+
{DEVICE_CATEGORIES.map((category) => { const { Icon } = category; const isSelected = selectedCategory === category.id; @@ -247,15 +318,15 @@ const DeviceSearchPage = () => { key={category.id} onClick={() => setSelectedCategory(category.id)} className={`flex flex-col items-center gap-12 cursor-pointer transition-colors ${ - category.id === 8 ? 'w-80' : 'w-110' + category.id === 8 ? 'w-80' : 'w-108' } ${ isSelected ? 'text-blue-600' : 'text-black hover:text-blue-500 active:text-blue-600' }`} > -
- +
+

{category.name}

@@ -268,10 +339,16 @@ const DeviceSearchPage = () => {
{/* Filter Section */} -
+
{/* Filters */} -
+
{/* Filter Icon */}
-
+
{/* Left side - Result count */}
-

40

+

{allDevices.length}

개 결과

@@ -316,19 +393,53 @@ const DeviceSearchPage = () => {
{/* Product Grid */} -
-
- {MOCK_PRODUCTS.map((product) => ( - { - searchParams.set('productId', product.id.toString()); - setSearchParams(searchParams); - }} - /> - ))} -
+
+ {isLoading && ( +
+

기기를 불러오는 중...

+
+ )} + + {isError && ( +
+

기기를 불러오는데 실패했습니다.

+
+ )} + + {!isLoading && !isError && products.length === 0 && ( +
+

검색 결과가 없습니다.

+
+ )} + + {!isLoading && !isError && products.length > 0 && ( +
+ {products.map((product) => ( + { + searchParams.set('productId', product.id.toString()); + setSearchParams(searchParams); + }} + /> + ))} +
+ )} + + {/* 추가 로딩 인디케이터 (무한 스크롤 중) */} + {isFetchingNextPage && ( +
+

더 불러오는 중...

+
+ )}
{/* Top Button - 3행이 보일 때만 표시 */} @@ -484,7 +595,7 @@ const DeviceSearchPage = () => { onClick={(e) => e.stopPropagation()} > {/* Combination List */} -
+
{combos.map((combo, index) => (