diff --git a/src/components/DeviceSearch/DeviceDetailModal.tsx b/src/components/DeviceSearch/DeviceDetailModal.tsx index 2f1275c..f7fc1d2 100644 --- a/src/components/DeviceSearch/DeviceDetailModal.tsx +++ b/src/components/DeviceSearch/DeviceDetailModal.tsx @@ -52,7 +52,7 @@ const DeviceDetailModal = ({
{/* Name & Price */}
-

{product.name}

+

{product.name}

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

diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx index 33f071c..333dfa5 100644 --- a/src/components/ProductCard/ProductCard.tsx +++ b/src/components/ProductCard/ProductCard.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import type { Product } from '@/types/product'; interface ProductCardProps { @@ -5,7 +6,7 @@ interface ProductCardProps { onClick?: () => void; } -const ProductCard: React.FC = ({ product, onClick }) => { +const ProductCard: React.FC = memo(({ product, onClick }) => { return (
= ({ product, onClick }) => {
); -}; +}); + +ProductCard.displayName = 'ProductCard'; export default ProductCard; diff --git a/src/hooks/useScrollState.ts b/src/hooks/useScrollState.ts index fa4ce50..eebde81 100644 --- a/src/hooks/useScrollState.ts +++ b/src/hooks/useScrollState.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, type RefObject } from 'react'; +import { useState, useEffect, useCallback, type RefObject } from 'react'; import { SCROLL_CONSTANTS } from '@/constants/devices'; export const useScrollState = (productGridRef: RefObject) => { @@ -10,35 +10,35 @@ export const useScrollState = (productGridRef: RefObject) 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); - } - }; + const handleScroll = useCallback(() => { + 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); + } + }, [productGridRef]); + useEffect(() => { window.addEventListener('scroll', handleScroll); handleScroll(); return () => window.removeEventListener('scroll', handleScroll); - }, []); + }, [handleScroll]); - const handleScrollToTop = () => { + const handleScrollToTop = useCallback(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); - }; + }, []); return { isAtBottom, diff --git a/src/pages/devices/DeviceSearchPage.tsx b/src/pages/devices/DeviceSearchPage.tsx index f8f05c4..5a2c990 100644 --- a/src/pages/devices/DeviceSearchPage.tsx +++ b/src/pages/devices/DeviceSearchPage.tsx @@ -1,4 +1,4 @@ -import { useRef, useLayoutEffect } from 'react'; +import { useRef, useLayoutEffect, useCallback, useMemo, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import GNB from '@/components/Home/GNB'; import ProductCard from '@/components/ProductCard/ProductCard'; @@ -33,6 +33,28 @@ const DeviceSearchPage = () => { const productGridRef = useRef(null); const scroll = useScrollState(productGridRef); + /* 카테고리 클릭 핸들러 */ + const handleCategoryClick = useCallback((categoryId: number) => { + search.setSelectedCategory( + search.selectedCategory === categoryId ? null : categoryId + ); + }, [search.selectedCategory, search.setSelectedCategory]); + + /* 제품 클릭 핸들러 */ + const handleProductClick = useCallback((deviceId: number) => { + searchParams.set('productId', deviceId.toString()); + setSearchParams(searchParams); + }, [searchParams, setSearchParams]); + + /* 변환된 제품 리스트 (useMemo로 캐싱) */ + const products = useMemo( + () => search.allDevices.map(device => ({ + product: mapSearchDeviceToProduct(device), + deviceId: device.deviceId, + })), + [search.allDevices] + ); + /* 선택된 제품 찾기 */ const selectedDevice = selectedProductId ? search.allDevices.find(d => d.deviceId === Number(selectedProductId)) @@ -54,29 +76,36 @@ const DeviceSearchPage = () => { }, }); - /* 모달 열림 상태 확인 및 스크롤 잠금 (회색 배경이 보일 때와 동일한 조건) */ - const isModalOpen = (!!selectedProduct && !combo.showSaveCompleteModal) || combo.showSaveCompleteModal; + /* 모달 열림 상태 확인 및 스크롤 잠금 + * 기기 상세 모달 또는 저장 완료 모달이 열려있을 때 스크롤 잠금 */ + const isModalOpen = !!selectedProduct || combo.showSaveCompleteModal; - /* selectedProductId가 null이 될 때 명시적으로 스크롤 unlock */ + /* 모달 열림 상태에 따른 스크롤 lock/unlock */ useLayoutEffect(() => { - if (selectedProductId === null) { + if (isModalOpen) { + document.documentElement.style.overflow = 'hidden'; + document.body.style.overflow = 'hidden'; + } else { document.documentElement.style.overflow = ''; document.body.style.overflow = ''; } - }, [selectedProductId]); + }, [isModalOpen]); - /* 모달 열림 상태에 따른 스크롤 lock */ - useLayoutEffect(() => { - if (isModalOpen) { - document.documentElement.style.overflow = 'hidden'; - document.body.style.overflow = 'hidden'; + /* ESC 키로 모달 닫기 */ + useEffect(() => { + const handleEscapeKey = (event: KeyboardEvent) => { + if (event.key === 'Escape' && isModalOpen) { + combo.handleCloseModal(); + } + }; + if (isModalOpen) { + document.addEventListener('keydown', handleEscapeKey); return () => { - document.documentElement.style.overflow = ''; - document.body.style.overflow = ''; + document.removeEventListener('keydown', handleEscapeKey); }; } - }, [isModalOpen]); + }, [isModalOpen, combo]); return (
@@ -89,6 +118,7 @@ const DeviceSearchPage = () => { search.setSearchQuery(e.target.value)} className="flex-1 bg-transparent font-body-1-r text-gray-500 outline-none placeholder:text-gray-500" @@ -105,7 +135,7 @@ const DeviceSearchPage = () => { return ( @@ -190,14 +223,11 @@ const DeviceSearchPage = () => {
) : (
- {search.allDevices.map((device) => ( + {products.map(({ product, deviceId }) => ( { - searchParams.set('productId', device.deviceId.toString()); - setSearchParams(searchParams); - }} + key={deviceId} + product={product} + onClick={() => handleProductClick(deviceId)} /> ))}
@@ -234,10 +264,22 @@ const DeviceSearchPage = () => {