From b44f3e711aa5a77da2f13eb26bd710eabd172a13 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 00:32:07 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20DeviceSearchPage.tsx=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=ED=95=A8=EC=88=98,=20=EB=A1=9C=EC=A7=81,?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=20UI=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DeviceSearch/CombinationDetailModal.tsx | 112 +++ .../DeviceSearch/CombinationSelectModal.tsx | 88 +++ .../DeviceSearch/DeviceDetailModal.tsx | 137 ++++ .../DeviceSearch/SaveCompleteModal.tsx | 21 + src/constants/deviceMapping.ts | 26 + src/hooks/useAddToCombination.ts | 203 +++++ src/hooks/useDeviceSearch.ts | 89 +++ src/hooks/useScrollState.ts | 48 ++ src/pages/devices/DeviceSearchPage.tsx | 700 ++---------------- src/utils/mapSearchDevice.ts | 22 + 10 files changed, 822 insertions(+), 624 deletions(-) create mode 100644 src/components/DeviceSearch/CombinationDetailModal.tsx create mode 100644 src/components/DeviceSearch/CombinationSelectModal.tsx create mode 100644 src/components/DeviceSearch/DeviceDetailModal.tsx create mode 100644 src/components/DeviceSearch/SaveCompleteModal.tsx create mode 100644 src/constants/deviceMapping.ts create mode 100644 src/hooks/useAddToCombination.ts create mode 100644 src/hooks/useDeviceSearch.ts create mode 100644 src/hooks/useScrollState.ts create mode 100644 src/utils/mapSearchDevice.ts diff --git a/src/components/DeviceSearch/CombinationDetailModal.tsx b/src/components/DeviceSearch/CombinationDetailModal.tsx new file mode 100644 index 0000000..4f86496 --- /dev/null +++ b/src/components/DeviceSearch/CombinationDetailModal.tsx @@ -0,0 +1,112 @@ +import type { ComboListItem, ComboDevice } from '@/types/combo/combo'; +import CombinationDeviceCard from '@/components/Combination/CombinationDeviceCard'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import BackIcon from '@/assets/icons/back.svg?react'; +import XIcon from '@/assets/icons/X.svg?react'; + +interface CombinationDetailModalProps { + combination: ComboListItem; + devices: ComboDevice[]; + comboIndex: number; + showAllDevices: boolean; + onExpandChange: (expanded: boolean) => void; + isAlreadyInCombination: boolean | 0 | null; + isAddingDevice: boolean; + onAddDevice: () => void; + onBack: () => void; + onClose: () => void; +} + +const CombinationDetailModal = ({ + combination, + devices, + comboIndex, + showAllDevices, + onExpandChange, + isAlreadyInCombination, + isAddingDevice, + onAddDevice, + onBack, + onClose, +}: CombinationDetailModalProps) => { + return ( +
+ {/* Header: Back + X 버튼 */} +
+ + +
+ + {/* Card */} +
e.stopPropagation()} + > + {/* 조합 정보 + 기기 그리드 */} + onExpandChange(value)} + showExpandButton={false} + showGradient={true} + className="px-56 pt-40 pb-0 flex-shrink-0" + index={comboIndex} + /> + + {/* 토글 버튼 - CombinationDeviceCard 외부에 배치 */} + {devices.length > 9 && !showAllDevices && ( + + )} + {devices.length > 9 && showAllDevices && ( + + )} + + {/* 버튼 컨테이너 - 토글 버튼으로부터 간격 유지하며 하단 고정 */} +
+
+ +
+
+
+
+ ); +}; + +export default CombinationDetailModal; diff --git a/src/components/DeviceSearch/CombinationSelectModal.tsx b/src/components/DeviceSearch/CombinationSelectModal.tsx new file mode 100644 index 0000000..a75cdc3 --- /dev/null +++ b/src/components/DeviceSearch/CombinationSelectModal.tsx @@ -0,0 +1,88 @@ +import type { ComboListItem } from '@/types/combo/combo'; +import BackIcon from '@/assets/icons/back.svg?react'; +import XIcon from '@/assets/icons/X.svg?react'; +import StarIcon from '@/assets/icons/star.svg?react'; +import MoreIcon from '@/assets/icons/more.svg?react'; + +interface CombinationSelectModalProps { + combos: ComboListItem[]; + onSelectCombination: (comboId: number) => void; + onBack: () => void; + onClose: () => void; +} + +const CombinationSelectModal = ({ + combos, + onSelectCombination, + onBack, + onClose, +}: CombinationSelectModalProps) => { + return ( +
+
+ + +
+ + {/* Card */} +
e.stopPropagation()} + > + {/* Combination List */} +
+ {combos.map((comboItem, index) => ( + + ))} +
+
+
+ ); +}; + +export default CombinationSelectModal; diff --git a/src/components/DeviceSearch/DeviceDetailModal.tsx b/src/components/DeviceSearch/DeviceDetailModal.tsx new file mode 100644 index 0000000..86bf46d --- /dev/null +++ b/src/components/DeviceSearch/DeviceDetailModal.tsx @@ -0,0 +1,137 @@ +import type { Product } from '@/constants/mockData'; +import type { SearchDevice } from '@/types/devices'; +import PrimaryButton from '@/components/Button/PrimaryButton'; +import XIcon from '@/assets/icons/X.svg?react'; + +interface DeviceDetailModalProps { + product: Product; + device: SearchDevice; + addToCombinationConfig: { text: string; handler: () => void }; + isProfileLoading: boolean; + onClose: () => void; +} + +const DeviceDetailModal = ({ + product, + device, + addToCombinationConfig, + isProfileLoading, + onClose, +}: DeviceDetailModalProps) => { + return ( +
+ {/* Close Button - 카드 바깥 */} + + + {/* Card */} +
e.stopPropagation()} + > + {/* Content */} +
+ {/* Row 1: Name & Price */} +
+ {/* Name & Price */} +
+

{product.name}

+
+

+

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

+
+
+
+ + {/* Row 2: Image + Specs */} +
+ {/* Image */} +
+ {product.image ? ( + {product.name} + ) : null} +
+ + {/* Right Section - Specs */} +
+ {/* Product Info Table */} +
+
+

모델명

+

{product.name}

+
+
+

카테고리

+

{product.category}

+
+ {device?.brandName && ( +
+

브랜드

+

{device.brandName}

+
+ )} +
+

가격

+
+

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

+

+
+
+ {device?.specifications?.screenInch ? ( +
+

인치

+

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

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

충전방식

+

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

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

출시일

+

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

+
+ )} +
+ +
+
+ + {/* Row 3: Button */} +
+ +
+
+
+
+ ); +}; + +export default DeviceDetailModal; diff --git a/src/components/DeviceSearch/SaveCompleteModal.tsx b/src/components/DeviceSearch/SaveCompleteModal.tsx new file mode 100644 index 0000000..9a9a469 --- /dev/null +++ b/src/components/DeviceSearch/SaveCompleteModal.tsx @@ -0,0 +1,21 @@ +import SaveIcon from '@/assets/icons/save.svg?react'; + +interface SaveCompleteModalProps { + isFadingOut: boolean; +} + +const SaveCompleteModal = ({ isFadingOut }: SaveCompleteModalProps) => { + return ( + <> +
+
+
+ +

저장 완료!

+
+
+ + ); +}; + +export default SaveCompleteModal; diff --git a/src/constants/deviceMapping.ts b/src/constants/deviceMapping.ts new file mode 100644 index 0000000..b3b03b3 --- /dev/null +++ b/src/constants/deviceMapping.ts @@ -0,0 +1,26 @@ +// 카테고리 ID를 API deviceType으로 변환 +export const getCategoryDeviceType = (categoryId: number | null): string | undefined => { + if (!categoryId) return undefined; + const mapping: Record = { + 1: 'SMARTPHONE', + 2: 'LAPTOP', + 3: 'TABLET', + 4: 'SMARTWATCH', + 5: 'AUDIO', + 6: 'KEYBOARD', + 7: 'MOUSE', + 8: 'CHARGER', + }; + return mapping[categoryId]; +}; + +// sortOption을 API sortType으로 변환 +export const getSortType = (sortOption: string) => { + const mapping: Record = { + 'latest': 'LATEST', + 'alphabetical': 'NAME_ASC', + 'price-low': 'PRICE_ASC', + 'price-high': 'PRICE_DESC', + }; + return mapping[sortOption] ?? 'LATEST'; +}; diff --git a/src/hooks/useAddToCombination.ts b/src/hooks/useAddToCombination.ts new file mode 100644 index 0000000..07ef541 --- /dev/null +++ b/src/hooks/useAddToCombination.ts @@ -0,0 +1,203 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { type ModalView } from '@/types/devices'; +import { ROUTES } from '@/constants/routes'; +import { useGetCombos } from '@/apis/combo/getCombos'; +import { useGetCombo } from '@/apis/combo/getComboId'; +import { usePostComboDevice } from '@/apis/combo/postComboDevices'; +import { useGetUserProfile } from '@/apis/mypage/getUserProfile'; +import { hasAccessToken, hasCompletedOnboarding } from '@/utils/authStorage'; + +interface UseAddToCombinationParams { + selectedProductId: string | null; + onCloseModal: () => void; +} + +export const useAddToCombination = ({ + selectedProductId, + onCloseModal, +}: UseAddToCombinationParams) => { + const navigate = useNavigate(); + + // 로그인 상태 확인 + const isLoggedIn = hasAccessToken(); + + // 사용자 프로필 조회 (로그인 시에만 자동 실행) + const { data: userProfile, isLoading: isProfileLoading } = useGetUserProfile(); + + // 온보딩 완료 여부 확인 (로딩 중에는 false로 기본 처리) + const hasOnboarding = isProfileLoading ? false : hasCompletedOnboarding(userProfile); + + const [modalView, setModalView] = useState('device'); + + // API hooks + const { data: combos = [] } = useGetCombos(); + const { mutate: addDeviceToCombo, isPending: isAddingDevice } = usePostComboDevice(); + + const [selectedCombinationId, setSelectedCombinationId] = useState(null); + const [showAllDevices, setShowAllDevices] = useState(false); + const [showSaveCompleteModal, setShowSaveCompleteModal] = useState(false); + const [isFadingOut, setIsFadingOut] = useState(false); + + // 선택된 조합의 상세 정보 조회 + const { data: comboDetail } = useGetCombo(selectedCombinationId); + + /* 모달 닫기 */ + const handleCloseModal = () => { + onCloseModal(); + setModalView('device'); + setSelectedCombinationId(null); + setShowAllDevices(false); + }; + + // 에러 핸들러 (공통) + const handleComboError = (error: unknown) => { + const axiosError = error as { response?: { status?: number } }; + if (axiosError?.response?.status === 400) { + alert('이미 조합에 추가된 기기입니다.'); + } else { + console.error('기기 추가 실패:', error); + } + }; + + /* 내 조합에 담기 */ + const handleAddToCombination = () => { + // 조합이 1개면 바로 저장 + if (combos.length === 1 && selectedProductId) { + addDeviceToCombo( + { comboId: combos[0].comboId, deviceId: Number(selectedProductId) }, + { + onSuccess: () => { + setModalView('device'); + setShowSaveCompleteModal(true); + }, + onError: handleComboError, + } + ); + return; + } + + // 조합이 2개 이상이면 선택 모달 표시 + setModalView('combination'); + }; + + /* 버튼 텍스트 및 핸들러 결정 */ + const getAddToCombinationConfig = () => { + // Case 1: 로그아웃 상태 + if (!isLoggedIn) { + return { + text: '로그인하고 내 조합에 담기', + handler: () => { + navigate(ROUTES.auth.login); + }, + }; + } + + // Case 2: 로그인했지만 온보딩 미완료 + if (!hasOnboarding) { + return { + text: '맞춤 설정하고 담기', + handler: () => { + navigate(ROUTES.onboarding.lifestyle); + }, + }; + } + + // Case 3: 로그인 + 온보딩 완료 + return { + text: '내 조합에 담기', + handler: handleAddToCombination, + }; + }; + + const addToCombinationConfig = getAddToCombinationConfig(); + + /* 조합 선택 - 기기 리스트 보기 */ + const handleSelectCombination = (combinationId: number) => { + setSelectedCombinationId(combinationId); + setShowAllDevices(false); + setModalView('combinationDetail'); + }; + + /* 조합에 기기 담기 */ + const handleAddDeviceToCombination = () => { + if (selectedCombinationId && selectedProductId) { + addDeviceToCombo( + { comboId: selectedCombinationId, deviceId: Number(selectedProductId) }, + { + onSuccess: () => { + setModalView('device'); + setShowSaveCompleteModal(true); + }, + onError: handleComboError, + } + ); + } + }; + + /* 저장 완료 모달 자동 닫기 */ + useEffect(() => { + if (showSaveCompleteModal) { + // 1. 0.8초 유지 + const holdTimer = setTimeout(() => { + setIsFadingOut(true); + + // 2. 0.2초 동안 dissolve (fade-out) 후 종료 + const closeTimer = setTimeout(() => { + setShowSaveCompleteModal(false); + setIsFadingOut(false); + handleCloseModal(); + }, 200); + + return () => clearTimeout(closeTimer); + }, 800); + + return () => clearTimeout(holdTimer); + } + }, [showSaveCompleteModal]); + + /* 선택된 조합 정보 */ + const selectedCombination = selectedCombinationId + ? combos.find(c => c.comboId === selectedCombinationId) + : null; + + /* 선택된 조합의 기기 리스트 (API에서 조회) */ + const combinationDevices = comboDetail?.devices || []; + + /* 선택된 조합에 이미 담긴 기기인지 확인 */ + const isAlreadyInSelectedCombination = selectedCombinationId && selectedProductId + ? combinationDevices.some(device => device.deviceId === Number(selectedProductId)) + : false; + + /* 모달 열렸을 때 y 스크롤 방지 */ + useEffect(() => { + if (selectedProductId) { + document.documentElement.style.overflowY = 'hidden'; + } else { + document.documentElement.style.overflowY = 'auto'; + } + return () => { + document.documentElement.style.overflowY = 'auto'; + }; + }, [selectedProductId]); + + return { + modalView, + setModalView, + showSaveCompleteModal, + isFadingOut, + combos, + selectedCombination, + combinationDevices, + selectedCombinationId, + showAllDevices, + setShowAllDevices, + isAlreadyInSelectedCombination, + isAddingDevice, + addToCombinationConfig, + isProfileLoading, + handleCloseModal, + handleSelectCombination, + handleAddDeviceToCombination, + }; +}; diff --git a/src/hooks/useDeviceSearch.ts b/src/hooks/useDeviceSearch.ts new file mode 100644 index 0000000..4f6233c --- /dev/null +++ b/src/hooks/useDeviceSearch.ts @@ -0,0 +1,89 @@ +import { useState, useEffect, useMemo } from 'react'; +import type { FilterOption } from '@/constants/devices'; +import { getCategoryDeviceType, getSortType } from '@/constants/deviceMapping'; +import { useSearchDevices } from '@/apis/devices/searchDevices'; +import { useGetBrands } from '@/apis/devices/getBrands'; +import { useIntersectionObserver } from '@/hooks/useIntersectionObserver'; + +export const useDeviceSearch = () => { + const [searchQuery, setSearchQuery] = useState(''); + const [selectedCategory, setSelectedCategory] = useState(null); + const [sortOption, setSortOption] = useState('latest'); + const [selectedPrice, setSelectedPrice] = useState([]); + const [selectedBrand, setSelectedBrand] = useState(null); + + // 브랜드 API 조회 - 선택된 카테고리에 따라 deviceType 전달 + const { data: brandsData } = useGetBrands(getCategoryDeviceType(selectedCategory)); + + // API 데이터를 FilterOption 형식으로 변환 + const brandOptions: FilterOption[] = useMemo(() => { + if (!brandsData?.result) return []; + return brandsData.result.map(brand => ({ + value: brand.brandId.toString(), + label: brand.brandName, + })); + }, [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(() => { + setSelectedBrand(null); + }, [selectedCategory]); + + return { + searchQuery, + setSearchQuery, + selectedCategory, + setSelectedCategory, + sortOption, + setSortOption, + selectedPrice, + setSelectedPrice, + selectedBrand, + setSelectedBrand, + brandOptions, + allDevices, + isSearchLoading, + isSearchError, + isFetchingNextPage, + hasNextPage, + targetRef, + }; +}; diff --git a/src/hooks/useScrollState.ts b/src/hooks/useScrollState.ts new file mode 100644 index 0000000..fa4ce50 --- /dev/null +++ b/src/hooks/useScrollState.ts @@ -0,0 +1,48 @@ +import { useState, useEffect, type RefObject } from 'react'; +import { SCROLL_CONSTANTS } from '@/constants/devices'; + +export const useScrollState = (productGridRef: RefObject) => { + const [isAtBottom, setIsAtBottom] = useState(false); + const [showTopButton, setShowTopButton] = useState(false); + + // 페이지 마운트 시 상단으로 스크롤 + useEffect(() => { + window.scrollTo(0, 0); + }, []); + + useEffect(() => { + const handleScroll = () => { + const scrollTop = window.scrollY; + const windowHeight = window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + + /* 맨 마지막 스크롤 도달 여부 체크 */ + const reachedBottom = + scrollTop + windowHeight >= documentHeight - SCROLL_CONSTANTS.BOTTOM_BUFFER; + setIsAtBottom(reachedBottom); + + /* 3행이 완전히 보일 때 Top 버튼 표시 */ + if (productGridRef.current) { + const gridTop = productGridRef.current.offsetTop; + const thirdRowVisible = + scrollTop + windowHeight >= gridTop + SCROLL_CONSTANTS.TOP_BUTTON_THRESHOLD; + setShowTopButton(thirdRowVisible); + } + }; + + window.addEventListener('scroll', handleScroll); + handleScroll(); + + return () => window.removeEventListener('scroll', handleScroll); + }, []); + + const handleScrollToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return { + isAtBottom, + showTopButton, + handleScrollToTop, + }; +}; diff --git a/src/pages/devices/DeviceSearchPage.tsx b/src/pages/devices/DeviceSearchPage.tsx index ed978b6..1f4a975 100644 --- a/src/pages/devices/DeviceSearchPage.tsx +++ b/src/pages/devices/DeviceSearchPage.tsx @@ -1,364 +1,55 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; -import { useSearchParams, useNavigate } from 'react-router-dom'; +import { useRef } from 'react'; +import { useSearchParams } from 'react-router-dom'; 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 FilterDropdown from '@/components/Filter/FilterDropdown'; import SortDropdown from '@/components/Filter/SortDropdown'; import LoadingSpinner from '@/components/LoadingSpinner'; +import DeviceDetailModal from '@/components/DeviceSearch/DeviceDetailModal'; +import CombinationSelectModal from '@/components/DeviceSearch/CombinationSelectModal'; +import CombinationDetailModal from '@/components/DeviceSearch/CombinationDetailModal'; +import SaveCompleteModal from '@/components/DeviceSearch/SaveCompleteModal'; import SearchIcon from '@/assets/icons/search.svg?react'; import FilterIcon from '@/assets/icons/filter.svg?react'; import TopIcon from '@/assets/icons/top.svg?react'; -import XIcon from '@/assets/icons/X.svg?react'; -import BackIcon from '@/assets/icons/back.svg?react'; -import StarIcon from '@/assets/icons/star.svg?react'; -import MoreIcon from '@/assets/icons/more.svg?react'; -import SaveIcon from '@/assets/icons/save.svg?react'; import { DEVICE_CATEGORIES, SORT_OPTIONS, PRICE_OPTIONS, - SCROLL_CONSTANTS, } from '@/constants/devices'; -import { ROUTES } from '@/constants/routes'; -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'; -import { useGetUserProfile } from '@/apis/mypage/getUserProfile'; -import { useGetBrands } from '@/apis/devices/getBrands'; -import { hasAccessToken, hasCompletedOnboarding } from '@/utils/authStorage'; - -// 카테고리 ID를 API deviceType으로 변환 -const getCategoryDeviceType = (categoryId: number | null): string | undefined => { - if (!categoryId) return undefined; - const mapping: Record = { - 1: 'SMARTPHONE', - 2: 'LAPTOP', - 3: 'TABLET', - 4: 'SMARTWATCH', - 5: 'AUDIO', - 6: 'KEYBOARD', - 7: 'MOUSE', - 8: 'CHARGER', - }; - 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[], - }; -}; +import { mapSearchDeviceToProduct } from '@/utils/mapSearchDevice'; +import { useDeviceSearch } from '@/hooks/useDeviceSearch'; +import { useScrollState } from '@/hooks/useScrollState'; +import { useAddToCombination } from '@/hooks/useAddToCombination'; const DeviceSearchPage = () => { const [searchParams, setSearchParams] = useSearchParams(); const selectedProductId = searchParams.get('productId'); - const navigate = useNavigate(); - - // 로그인 상태 확인 - const isLoggedIn = hasAccessToken(); - - // 사용자 프로필 조회 (로그인 시에만 자동 실행) - const { data: userProfile, isLoading: isProfileLoading } = useGetUserProfile(); - // 온보딩 완료 여부 확인 (로딩 중에는 false로 기본 처리) - const hasOnboarding = isProfileLoading ? false : hasCompletedOnboarding(userProfile); - - 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 [isAtBottom, setIsAtBottom] = useState(false); - const [showTopButton, setShowTopButton] = useState(false); - const [selectedCombinationId, setSelectedCombinationId] = useState(null); - const [showAllDevices, setShowAllDevices] = useState(false); - const [showSaveCompleteModal, setShowSaveCompleteModal] = useState(false); - const [isFadingOut, setIsFadingOut] = useState(false); - - // 선택된 조합의 상세 정보 조회 - const { data: comboDetail } = useGetCombo(selectedCombinationId); + // 검색/필터 상태 + const search = useDeviceSearch(); const productGridRef = useRef(null); + const scroll = useScrollState(productGridRef); - // 브랜드 API 조회 - 선택된 카테고리에 따라 deviceType 전달 - const { data: brandsData } = useGetBrands(getCategoryDeviceType(selectedCategory)); - - // API 데이터를 FilterOption 형식으로 변환 - const brandOptions = useMemo(() => { - if (!brandsData?.result) return []; - return brandsData.result.map(brand => ({ - value: brand.brandId.toString(), - label: brand.brandName, - })); - }, [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); - }, []); - - // 카테고리 변경 시 선택된 브랜드 초기화 - useEffect(() => { - setSelectedBrand(null); - }, [selectedCategory]); + // 조합 담기 + 모달 상태 + const combo = useAddToCombination({ + selectedProductId, + onCloseModal: () => { + searchParams.delete('productId'); + setSearchParams(searchParams); + }, + }); /* 선택된 제품 찾기 */ const selectedDevice = selectedProductId - ? allDevices.find(d => d.deviceId === Number(selectedProductId)) + ? search.allDevices.find(d => d.deviceId === Number(selectedProductId)) : null; const selectedProduct = selectedDevice ? mapSearchDeviceToProduct(selectedDevice) : null; - /* 모달 닫기 */ - const handleCloseModal = () => { - searchParams.delete('productId'); - setSearchParams(searchParams); - setModalView('device'); - setSelectedCombinationId(null); - setShowAllDevices(false); - }; - - /* 내 조합에 담기 */ - const handleAddToCombination = () => { - // 조합이 1개면 바로 저장 - if (combos.length === 1 && selectedProductId) { - addDeviceToCombo( - { comboId: combos[0].comboId, deviceId: Number(selectedProductId) }, - { - onSuccess: () => { - setModalView('device'); - setShowSaveCompleteModal(true); - }, - onError: (error: unknown) => { - const axiosError = error as { response?: { status?: number } }; - if (axiosError?.response?.status === 400) { - alert('이미 조합에 추가된 기기입니다.'); - } else { - console.error('기기 추가 실패:', error); - } - }, - } - ); - return; - } - - // 조합이 2개 이상이면 선택 모달 표시 - setModalView('combination'); - }; - - /* 버튼 텍스트 및 핸들러 결정 */ - const getAddToCombinationConfig = () => { - // Case 1: 로그아웃 상태 - if (!isLoggedIn) { - return { - text: '로그인하고 내 조합에 담기', - handler: () => { - navigate(ROUTES.auth.login); - }, - }; - } - - // Case 2: 로그인했지만 온보딩 미완료 - if (!hasOnboarding) { - return { - text: '맞춤 설정하고 담기', - handler: () => { - navigate(ROUTES.onboarding.lifestyle); - }, - }; - } - - // Case 3: 로그인 + 온보딩 완료 - return { - text: '내 조합에 담기', - handler: handleAddToCombination, - }; - }; - - const addToCombinationConfig = getAddToCombinationConfig(); - - /* 조합 선택 - 기기 리스트 보기 */ - const handleSelectCombination = (combinationId: number) => { - setSelectedCombinationId(combinationId); - setShowAllDevices(false); - setModalView('combinationDetail'); - }; - - /* 조합에 기기 담기 */ - const handleAddDeviceToCombination = () => { - if (selectedCombinationId && selectedProductId) { - addDeviceToCombo( - { comboId: selectedCombinationId, deviceId: Number(selectedProductId) }, - { - onSuccess: () => { - setModalView('device'); - setShowSaveCompleteModal(true); - }, - onError: (error: unknown) => { - const axiosError = error as { response?: { status?: number } }; - if (axiosError?.response?.status === 400) { - alert('이미 조합에 추가된 기기입니다.'); - } else { - console.error('기기 추가 실패:', error); - } - }, - } - ); - } - }; - - /* 저장 완료 모달 자동 닫기 */ - useEffect(() => { - if (showSaveCompleteModal) { - // 1. 0.8초 유지 - const holdTimer = setTimeout(() => { - setIsFadingOut(true); - - // 2. 0.2초 동안 dissolve (fade-out) 후 종료 - const closeTimer = setTimeout(() => { - setShowSaveCompleteModal(false); - setIsFadingOut(false); - handleCloseModal(); - }, 200); - - return () => clearTimeout(closeTimer); - }, 800); - - return () => clearTimeout(holdTimer); - } - }, [showSaveCompleteModal]); - - /* 선택된 조합 정보 */ - const selectedCombination = selectedCombinationId - ? combos.find(c => c.comboId === selectedCombinationId) - : null; - - /* 선택된 조합의 기기 리스트 (API에서 조회) */ - const combinationDevices = comboDetail?.devices || []; - - /* 선택된 조합에 이미 담긴 기기인지 확인 */ - const isAlreadyInSelectedCombination = selectedCombinationId && selectedProductId - ? combinationDevices.some(device => device.deviceId === Number(selectedProductId)) - : false; - - useEffect(() => { - const handleScroll = () => { - const scrollTop = window.scrollY; - const windowHeight = window.innerHeight; - const documentHeight = document.documentElement.scrollHeight; - - /* 맨 마지막 스크롤 도달 여부 체크 */ - const reachedBottom = - scrollTop + windowHeight >= documentHeight - SCROLL_CONSTANTS.BOTTOM_BUFFER; - setIsAtBottom(reachedBottom); - - /* 3행이 완전히 보일 때 Top 버튼 표시 */ - if (productGridRef.current) { - const gridTop = productGridRef.current.offsetTop; - const thirdRowVisible = - scrollTop + windowHeight >= gridTop + SCROLL_CONSTANTS.TOP_BUTTON_THRESHOLD; - setShowTopButton(thirdRowVisible); - } - }; - - window.addEventListener('scroll', handleScroll); - handleScroll(); - - return () => window.removeEventListener('scroll', handleScroll); - }, []); - - /* 모달 열렸을 때 y 스크롤 방지 */ - useEffect(() => { - if (selectedProduct) { - document.documentElement.style.overflowY = 'hidden'; - } else { - document.documentElement.style.overflowY = 'auto'; - } - return () => { - document.documentElement.style.overflowY = 'auto'; - }; - }, [selectedProduct]); - - const handleScrollToTop = () => { - window.scrollTo({ top: 0, behavior: 'smooth' }); - }; - return ( -
+
{/* Main Content */} @@ -370,8 +61,8 @@ const DeviceSearchPage = () => { setSearchQuery(e.target.value)} + value={search.searchQuery} + onChange={(e) => search.setSearchQuery(e.target.value)} className="flex-1 bg-transparent font-body-1-r text-gray-500 outline-none placeholder:text-gray-500" />
@@ -382,11 +73,11 @@ const DeviceSearchPage = () => {
{DEVICE_CATEGORIES.map((category) => { const { Icon } = category; - const isSelected = selectedCategory === category.id; + const isSelected = search.selectedCategory === category.id; return ( {/* Price Filter */} @@ -423,8 +114,8 @@ const DeviceSearchPage = () => { setSelectedPrice(Array.isArray(value) ? value : [])} + selectedValue={search.selectedPrice} + onSelect={(value) => search.setSelectedPrice(Array.isArray(value) ? value : [])} multiple />
@@ -433,9 +124,9 @@ const DeviceSearchPage = () => {
setSelectedBrand(value as string | null)} + options={search.brandOptions} + selectedValue={search.selectedBrand} + onSelect={(value) => search.setSelectedBrand(value as string | null)} />
@@ -443,15 +134,15 @@ const DeviceSearchPage = () => {
{/* Left side - Result count */}
-

{allDevices.length}

+

{search.allDevices.length}

개 결과

{/* Right side - Sort dropdown */}
@@ -459,19 +150,19 @@ const DeviceSearchPage = () => { {/* Product Grid */}
{/* 초기 로딩: 데이터가 없고 로딩 중일 때만 로딩 스피너 표시 */} - {isSearchLoading && allDevices.length === 0 ? ( + {search.isSearchLoading && search.allDevices.length === 0 ? ( - ) : isSearchError && allDevices.length === 0 ? ( + ) : search.isSearchError && search.allDevices.length === 0 ? (

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

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

검색 결과가 없습니다.

) : (
- {allDevices.map((device) => ( + {search.allDevices.map((device) => ( { )} {/* 무한 스크롤 트리거 */} -
+
{/* 로딩 인디케이터 */} - {isFetchingNextPage && ( + {search.isFetchingNextPage && (

더 불러오는 중...

@@ -496,9 +187,9 @@ const DeviceSearchPage = () => {
{/* Top Button - 3행이 보일 때만 표시 */} - {showTopButton && ( + {scroll.showTopButton && (
*/} {/* Device Detail Modal */} - {selectedProduct && !showSaveCompleteModal && ( + {selectedProduct && !combo.showSaveCompleteModal && ( <> {/* Background Overlay - HomeIndicator보다 높게 설정 */}
{/* Modal */}
- {/* Device Info Modal */} - {modalView === 'device' && ( -
- {/* Close Button - 카드 바깥 */} - - - {/* Card */} -
e.stopPropagation()} - > - {/* Content */} -
- {/* Row 1: Name & Price */} -
- {/* Name & Price */} -
-

{selectedProduct.name}

-
-

-

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

-
-
-
- - {/* Row 2: Image + Specs */} -
- {/* Image */} -
- {selectedProduct.image ? ( - {selectedProduct.name} - ) : null} -
- - {/* Right Section - Specs */} -
- {/* Product Info Table */} -
-
-

모델명

-

{selectedProduct.name}

-
-
-

카테고리

-

{selectedProduct.category}

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

브랜드

-

{selectedDevice.brandName}

-
- )} -
-

가격

-
-

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

-

-
-
- {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' })} -

-
- )} -
- -
-
- - {/* Row 3: Button */} -
- -
-
-
-
+ {combo.modalView === 'device' && ( + )} - {/* Combination Selection Modal */} - {modalView === 'combination' && ( -
-
- - -
- - {/* Card */} -
e.stopPropagation()} - > - {/* Combination List */} -
- {combos.map((combo, index) => ( - - ))} -
-
-
+ {combo.modalView === 'combination' && ( + combo.setModalView('device')} + onClose={combo.handleCloseModal} + /> )} - {/* Combination Detail Modal - 기기 리스트 */} - {modalView === 'combinationDetail' && selectedCombination && ( -
- {/* Header: Back + X 버튼 */} -
- - -
- - {/* Card */} -
e.stopPropagation()} - > - {/* 조합 정보 + 기기 그리드 */} - setShowAllDevices(value)} - showExpandButton={false} - showGradient={true} - className="px-56 pt-40 pb-0 flex-shrink-0" - index={combos.findIndex(c => c.comboId === selectedCombinationId)} - /> - - {/* 토글 버튼 - CombinationDeviceCard 외부에 배치 */} - {combinationDevices.length > 9 && !showAllDevices && ( - - )} - {combinationDevices.length > 9 && showAllDevices && ( - - )} - - {/* 버튼 컨테이너 - 토글 버튼으로부터 간격 유지하며 하단 고정 */} -
-
- -
-
-
-
+ {combo.modalView === 'combinationDetail' && combo.selectedCombination && ( + c.comboId === combo.selectedCombinationId)} + showAllDevices={combo.showAllDevices} + onExpandChange={combo.setShowAllDevices} + isAlreadyInCombination={combo.isAlreadyInSelectedCombination} + isAddingDevice={combo.isAddingDevice} + onAddDevice={combo.handleAddDeviceToCombination} + onBack={() => combo.setModalView('combination')} + onClose={combo.handleCloseModal} + /> )} -
)} {/* 저장 완료 모달 - 독립적으로 표시 */} - {showSaveCompleteModal && ( - <> -
-
-
- -

저장 완료!

-
-
- + {combo.showSaveCompleteModal && ( + )}
); diff --git a/src/utils/mapSearchDevice.ts b/src/utils/mapSearchDevice.ts new file mode 100644 index 0000000..d264be6 --- /dev/null +++ b/src/utils/mapSearchDevice.ts @@ -0,0 +1,22 @@ +import type { SearchDevice } from '@/types/devices'; +import type { Product } from '@/constants/mockData'; + +// SearchDevice를 Product 형식으로 변환 +export const mapSearchDeviceToProduct = (device: SearchDevice): Product => { + 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[], + }; +}; From 2ad8d9c9d1e4a54d0385242056d65f0bf2588f09 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 00:37:36 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EC=B2=AB=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EA=B0=80=EC=A0=B8=EC=98=AC=20=EB=95=8C=20=EA=B9=9C?= =?UTF-8?q?=EB=B9=A1=EC=9D=B4=EB=8A=94=20=ED=98=84=EC=83=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/devices/searchDevices.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/apis/devices/searchDevices.ts b/src/apis/devices/searchDevices.ts index b873558..b1e315c 100644 --- a/src/apis/devices/searchDevices.ts +++ b/src/apis/devices/searchDevices.ts @@ -30,5 +30,6 @@ export const useSearchDevices = (params: Omit) => lastPage?.hasNext ? lastPage.nextCursor : undefined, enabled: true, staleTime: 1000 * 60 * 5, + placeholderData: (prev) => prev, }); }; From 20d97cbd97fc52465379549e377d467e4d2c2083 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 00:39:36 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=9E=AC=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EC=8B=9C=20=ED=95=B4=EC=A0=9C=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/devices/DeviceSearchPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/devices/DeviceSearchPage.tsx b/src/pages/devices/DeviceSearchPage.tsx index 1f4a975..c2ff636 100644 --- a/src/pages/devices/DeviceSearchPage.tsx +++ b/src/pages/devices/DeviceSearchPage.tsx @@ -77,7 +77,7 @@ const DeviceSearchPage = () => { return (