-
{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 (