Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 > 21 ? `${product.name.slice(0, 21)}...` : 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 };
};
140 changes: 118 additions & 22 deletions src/pages/devices/DeviceSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -48,6 +49,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 +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);
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -372,7 +443,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 +458,43 @@ 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 ? (
<div className="flex justify-center items-center py-100">
<p className="font-body-1-r text-gray-400">로딩 중...</p>
</div>
Copy link
Member

@waldls waldls Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기 <LoadingSpinner />로 연결하시면 될 거 같습니다.
아래 더 불러오는 중... 도 마찬가지입니다!

  • 더 불러오는 중 의 경우 화면 맨 화면에 등장해서 사용자가 화면을 내릴 때 로딩스피너가 가운데 딱 뜨면 좀 어색할 것 같아서 이 부분만 반영해주시면 될 거 같아요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기 <LoadingSpinner />로 연결하시면 될 거 같습니다. 아래 더 불러오는 중... 도 마찬가지입니다!

  • 더 불러오는 중 의 경우 화면 맨 화면에 등장해서 사용자가 화면을 내릴 때 로딩스피너가 가운데 딱 뜨면 좀 어색할 것 같아서 이 부분만 반영해주시면 될 거 같아요!

463만 해결했습니다

) : 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 +553,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,7 +578,7 @@ 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>
<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>
Expand All @@ -491,7 +587,7 @@ const DeviceSearchPage = () => {
<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>
Expand Down
36 changes: 36 additions & 0 deletions src/types/devices.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, unknown>;
}

// 검색 결과
export interface DeviceSearchResult {
devices: SearchDevice[];
nextCursor: string | null;
hasNext: boolean;
}

// API 응답
export type GetDevicesSearchResponse = CommonResponse<DeviceSearchResult>;