[251024] fix: SearchPanel.new.v2 Optimization-1

🕐 커밋 시간: 2025. 10. 24. 09:51:08

📊 변경 통계:
  • 총 파일: 4개
  • 추가: +357줄
  • 삭제: -145줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/.gitignore
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.v2.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchResults.new.v2.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/TInput/TInputSimple.jsx

🔧 주요 변경 내용:
  • 대규모 기능 개발
This commit is contained in:
2025-10-24 09:51:10 +09:00
parent c3985f6f98
commit 25b038e304
4 changed files with 359 additions and 147 deletions

View File

@@ -16,3 +16,4 @@ npm-debug.log
srcBackup srcBackup
# com.lgshop.app_*.ipk # com.lgshop.app_*.ipk
.docs .docs
nul

View File

@@ -40,7 +40,7 @@ import NoSearchResults from './NoSearchResults/NoSearchResults';
// import NoSearchResults from './NoSearchResults/NoSearchResults'; // import NoSearchResults from './NoSearchResults/NoSearchResults';
import SearchInputOverlay from './SearchInpuOverlay'; import SearchInputOverlay from './SearchInpuOverlay';
import css from './SearchPanel.new.module.less'; import css from './SearchPanel.new.module.less';
import SearchResultsNew from './SearchResults.new'; import SearchResultsNew from './SearchResults.new.v2';
import TInputSimple, { ICONS, KINDS } from './TInput/TInputSimple'; import TInputSimple, { ICONS, KINDS } from './TInput/TInputSimple';
import VoiceInputOverlay, { VOICE_MODES } from './VoiceInputOverlay/VoiceInputOverlay'; import VoiceInputOverlay, { VOICE_MODES } from './VoiceInputOverlay/VoiceInputOverlay';
@@ -72,10 +72,51 @@ const InputContainer = SpotlightContainerDecorator({ enterTo: 'last-focused' },
// 콘텐츠 섹션 컨테이너 // 콘텐츠 섹션 컨테이너
const SectionContainer = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div'); const SectionContainer = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
// Spottable 컴포넌트들 // 메모리 누수 방지를 위한 안전한 이미지 컴포넌트 (컴포넌트 외부로 이동)
const SpottableMicButton = Spottable('div'); const SafeImageComponent = ({ src, alt, className, ...props }) => {
const SpottableKeyword = Spottable('div'); const imgRef = useRef(null);
const SpottableProduct = Spottable('div');
useEffect(() => {
const img = imgRef.current;
if (!img) return;
// 이미지 로드 완료 핸들러
const handleLoad = () => {
// 로드 성공 시 특별한 처리 불필요
};
// 이미지 로드 에러 핸들러
const handleError = () => {
// 에러 시 src를 제거하여 깨진 이미지 방지
if (img.src) {
img.src = '';
}
};
img.addEventListener('load', handleLoad);
img.addEventListener('error', handleError);
// 컴포넌트 unmount 시 이벤트 리스너 정리
return () => {
img.removeEventListener('load', handleLoad);
img.removeEventListener('error', handleError);
// 이미지 로딩 취소
if (img.src) {
img.src = '';
}
};
}, []);
return (
<img
ref={imgRef}
src={src}
alt={alt}
className={className}
{...props}
/>
);
};
const ITEMS_PER_PAGE = 9; const ITEMS_PER_PAGE = 9;
@@ -94,6 +135,9 @@ const SPOTLIGHT_IDS = {
export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) { export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
const dispatch = useDispatch(); const dispatch = useDispatch();
// DEBUG 모드 상태 - 개발 환경에서만 활성화
const DEBUG_MODE = process.env.NODE_ENV === 'development';
/** /**
* stores * stores
*/ */
@@ -137,37 +181,43 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
// ✨ [Phase 1] SearchPanel의 현재 모드 상태 (VoiceInputOverlay의 VOICE_MODES와 동일한 개념) // ✨ [Phase 1] SearchPanel의 현재 모드 상태 (VoiceInputOverlay의 VOICE_MODES와 동일한 개념)
const [currentMode, setCurrentMode] = useState(SEARCH_PANEL_MODES.INITIAL); const [currentMode, setCurrentMode] = useState(SEARCH_PANEL_MODES.INITIAL);
// 🐛 [DEBUG] shopperHouseData 상태 변경 추적 // 🐛 [DEBUG] shopperHouseData 상태 변경 추적 (DEBUG_MODE가 true일 경우에만)
useEffect(() => { useEffect(() => {
console.log('[DEBUG] 📊 SearchPanel shopperHouseData 상태 변경:', { if (DEBUG_MODE) {
hasData: !!shopperHouseData, console.log('[DEBUG] 📊 SearchPanel shopperHouseData 상태 변경:', {
dataLength: shopperHouseData?.results?.[0]?.docs?.length || 0,
searchId: shopperHouseData?.results?.[0]?.searchId || '(없음)',
timestamp: new Date().toISOString(),
});
}, [shopperHouseData]);
// 🐛 [DEBUG] SearchPanel 마운트/언마운트 추적
useEffect(() => {
console.log('[DEBUG] 🚀 SearchPanel 마운트됨 - 초기 shopperHouseData 상태:', {
hasData: !!shopperHouseData,
dataLength: shopperHouseData?.results?.[0]?.docs?.length || 0,
currentMode,
timestamp: new Date().toISOString(),
});
return () => {
console.log('[DEBUG] 🔚 SearchPanel 언마운트됨 - shopperHouseData 상태:', {
hasData: !!shopperHouseData, hasData: !!shopperHouseData,
dataLength: shopperHouseData?.results?.[0]?.docs?.length || 0, dataLength: shopperHouseData?.results?.[0]?.docs?.length || 0,
searchId: shopperHouseData?.results?.[0]?.searchId || '(없음)',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
}; }
}, []); }, [shopperHouseData?.results?.[0]?.docs?.length, shopperHouseData?.results?.[0]?.searchId, DEBUG_MODE]);
// 🐛 [DEBUG] isOnTop 상태 변경 추적 (DetailPanel <-> SearchPanel 전환) // 🐛 [DEBUG] SearchPanel 마운트/언마운트 추적 (DEBUG_MODE가 true일 경우에만)
useEffect(() => { useEffect(() => {
if (isOnTopRef.current !== isOnTop) { if (DEBUG_MODE) {
console.log('[DEBUG] 🚀 SearchPanel 마운트됨 - 초기 shopperHouseData 상태:', {
hasData: !!shopperHouseData,
dataLength: shopperHouseData?.results?.[0]?.docs?.length || 0,
currentMode,
timestamp: new Date().toISOString(),
});
}
return () => {
if (DEBUG_MODE) {
console.log('[DEBUG] 🔚 SearchPanel 언마운트됨 - shopperHouseData 상태:', {
hasData: !!shopperHouseData,
dataLength: shopperHouseData?.results?.[0]?.docs?.length || 0,
timestamp: new Date().toISOString(),
});
}
};
}, [DEBUG_MODE]);
// 🐛 [DEBUG] isOnTop 상태 변경 추적 (DetailPanel <-> SearchPanel 전환, DEBUG_MODE가 true일 경우에만)
useEffect(() => {
if (isOnTopRef.current !== isOnTop && DEBUG_MODE) {
console.log('[DEBUG] 🔄 SearchPanel isOnTop 상태 변경:', { console.log('[DEBUG] 🔄 SearchPanel isOnTop 상태 변경:', {
from: isOnTopRef.current, from: isOnTopRef.current,
to: isOnTop, to: isOnTop,
@@ -179,7 +229,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} }
}, [isOnTop, shopperHouseData]); }, [isOnTop, shopperHouseData?.results?.[0]?.docs?.length, DEBUG_MODE]);
/** /**
* refs * refs
@@ -190,6 +240,16 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
const cbChangePageRef = useRef(null); const cbChangePageRef = useRef(null);
const initialFocusTimerRef = useRef(null); const initialFocusTimerRef = useRef(null);
const spotlightResumeTimerRef = useRef(null); const spotlightResumeTimerRef = useRef(null);
// DOM 쿼리 최적화를 위한 ref 추가
const inputElementRef = useRef(null);
// Spottable 컴포넌트 캐싱으로 메모리 누수 방지
const SpottableMicButton = useMemo(() => Spottable('div'), []);
const SpottableKeyword = useMemo(() => Spottable('div'), []);
const SpottableProduct = useMemo(() => Spottable('div'), []);
// SafeImage 컴포넌트 캐싱
const SafeImage = useCallback(SafeImageComponent, []);
/** /**
* memoized variables * memoized variables
@@ -232,10 +292,11 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
// ✨ [Phase 4] Enter/OK 키 처리 - SearchInputOverlay 표시 // ✨ [Phase 4] Enter/OK 키 처리 - SearchInputOverlay 표시
if (e.key === 'Enter' || e.keyCode === 13) { if (e.key === 'Enter' || e.keyCode === 13) {
console.log('[DEBUG] [SearchPanel] handleKeydown: Enter/OK 키 누름');
e.preventDefault(); e.preventDefault();
setIsSearchOverlayVisible(true); setIsSearchOverlayVisible(true);
// TInputSimple의 포커스 해제 // ✅ 수정: Enter 키 누를 때에도 inputFocus 유지 (VOICE_RESULT 모드에서 키보드 이벤트 수신 가능)
setInputFocus(false); // setInputFocus(false);
return; return;
} }
@@ -260,10 +321,15 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
// 오른쪽 화살표 키 처리 - 포커스 이동 허용 // 오른쪽 화살표 키 처리 - 포커스 이동 허용
if (e.key === 'ArrowRight' || e.key === 'Right') { if (e.key === 'ArrowRight' || e.key === 'Right') {
// 커서가 텍스트 끝에 있을 때만 포커스 이동 허용 // 커서가 텍스트 끝에 있을 때만 포커스 이동 허용
const input = document.querySelector(`[data-spotlight-id="input-field-box"] > input`); // DOM 쿼리 최적화: 캐싱된 input element 사용
if (input && position === input.value.length) { const input = inputElementRef.current ||
// 커서가 텍스트 끝에 있으면 포커스 이동 허용 document.querySelector(`[data-spotlight-id="input-field-box"] > input`);
return; if (input) {
inputElementRef.current = input; // 캐싱
if (position === input.value.length) {
// 커서가 텍스트 끝에 있으면 포커스 이동 허용
return;
}
} }
} }
@@ -329,11 +395,15 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
/** /**
* 0hun: keyUp 이벤트 핸들러, keyUp 시 `position`의 상태값 변경하는 함수 * 0hun: keyUp 이벤트 핸들러, keyUp 시 `position`의 상태값 변경하는 함수
* DOM 쿼리 최적화: ref를 사용하여 반복적인 DOM 조회 방지
*/ */
const cursorPosition = useCallback(() => { const cursorPosition = useCallback(() => {
const input = document.querySelector(`[data-spotlight-id="input-field-box"] > input`); // ref를 사용하여 캐싱된 input element 사용
const input = inputElementRef.current ||
document.querySelector(`[data-spotlight-id="input-field-box"] > input`);
if (input) { if (input) {
inputElementRef.current = input; // 캐싱
setPosition(input.selectionStart); setPosition(input.selectionStart);
} }
}, []); }, []);
@@ -346,10 +416,12 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
return; return;
} }
console.log( if (DEBUG_MODE) {
'🖱️ [DEBUG][SearchPanel] onClickMic called, current isVoiceOverlayVisible:', console.log(
isVoiceOverlayVisible '🖱️ [DEBUG][SearchPanel] onClickMic called, current isVoiceOverlayVisible:',
); isVoiceOverlayVisible
);
}
setIsVoiceOverlayVisible(true); setIsVoiceOverlayVisible(true);
// setIsVoiceOverlayVisible((prev) => !prev); // setIsVoiceOverlayVisible((prev) => !prev);
}, [isVoiceOverlayVisible]); }, [isVoiceOverlayVisible]);
@@ -358,59 +430,77 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
* 0hun: panel 뒤로가기 이벤트 * 0hun: panel 뒤로가기 이벤트
*/ */
const onCancel = useCallback(() => { const onCancel = useCallback(() => {
console.log('[DEBUG]-onCancel called', { if (DEBUG_MODE) {
isOnTop: isOnTopRef.current, console.log('[DEBUG]-onCancel called', {
isVoiceOverlayVisible, isOnTop: isOnTopRef.current,
isSearchOverlayVisible, isVoiceOverlayVisible,
currentMode, isSearchOverlayVisible,
searchQuery, currentMode,
hasShopperHouseData: !!shopperHouseData, searchQuery,
}); hasShopperHouseData: !!shopperHouseData,
});
}
if (!isOnTopRef.current) { if (!isOnTopRef.current) {
console.log('[DEBUG]-onCancel: isOnTopRef is false, returning'); if (DEBUG_MODE) {
console.log('[DEBUG]-onCancel: isOnTopRef is false, returning');
}
return; return;
} }
// VoiceInputOverlay가 열려있으면 먼저 닫기 // VoiceInputOverlay가 열려있으면 먼저 닫기
if (isVoiceOverlayVisible) { if (isVoiceOverlayVisible) {
console.log('[DEBUG]-onCancel: closing VoiceInputOverlay'); if (DEBUG_MODE) {
console.log('[DEBUG]-onCancel: closing VoiceInputOverlay');
}
setIsVoiceOverlayVisible(false); setIsVoiceOverlayVisible(false);
return; return;
} }
// SearchInputOverlay가 열려있으면 먼저 닫기 // SearchInputOverlay가 열려있으면 먼저 닫기
if (isSearchOverlayVisible) { if (isSearchOverlayVisible) {
console.log('[DEBUG]-onCancel: closing SearchInputOverlay'); if (DEBUG_MODE) {
console.log('[DEBUG]-onCancel: closing SearchInputOverlay');
}
setIsSearchOverlayVisible(false); setIsSearchOverlayVisible(false);
return; return;
} }
// ✨ [Phase 5] VOICE_RESULT 모드에서 ESC/뒤로가기 누르면 INITIAL 모드로 돌아가기 // ✨ [Phase 5] VOICE_RESULT 모드에서 ESC/뒤로가기 누르면 INITIAL 모드로 돌아가기
console.log('[DEBUG]-VOICE_RESULT check:', { if (DEBUG_MODE) {
currentMode, console.log('[DEBUG]-VOICE_RESULT check:', {
isVoiceResultMode: currentMode === SEARCH_PANEL_MODES.VOICE_RESULT, currentMode,
VOICE_RESULT_value: SEARCH_PANEL_MODES.VOICE_RESULT, isVoiceResultMode: currentMode === SEARCH_PANEL_MODES.VOICE_RESULT,
}); VOICE_RESULT_value: SEARCH_PANEL_MODES.VOICE_RESULT,
});
}
if (currentMode === SEARCH_PANEL_MODES.VOICE_RESULT) { if (currentMode === SEARCH_PANEL_MODES.VOICE_RESULT) {
console.log( if (DEBUG_MODE) {
'[DEBUG]-VOICE_RESULT: Clearing ShopperHouse data (searchId will be preserved for 2nd search)' console.log(
); '[DEBUG]-VOICE_RESULT: Clearing ShopperHouse data (searchId will be preserved for 2nd search)'
console.log('[VoiceInput]-SearchPanel-onCancel-VOICE_RESULT'); );
console.log('[VoiceInput] 🧹 VOICE_RESULT 모드에서 ESC 누름 - clearShopperHouseData 호출'); console.log('[VoiceInput]-SearchPanel-onCancel-VOICE_RESULT');
console.log('[VoiceInput] 🧹 VOICE_RESULT 모드에서 ESC 누름 - clearShopperHouseData 호출');
}
dispatch(clearShopperHouseData()); // ✨ shopperHouseData만 초기화, searchId & relativeQuerys 유지 dispatch(clearShopperHouseData()); // ✨ shopperHouseData만 초기화, searchId & relativeQuerys 유지
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX); Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
return; return;
} }
console.log('[DEBUG]-onCancel: normal cancel logic', { searchQuery }); if (DEBUG_MODE) {
console.log('[DEBUG]-onCancel: normal cancel logic', { searchQuery });
}
if (searchQuery === null || searchQuery === '') { if (searchQuery === null || searchQuery === '') {
console.log('[DEBUG]-onCancel: popping panel'); if (DEBUG_MODE) {
console.log('[DEBUG]-onCancel: popping panel');
}
dispatch(popPanel(panel_names.SEARCH_PANEL)); dispatch(popPanel(panel_names.SEARCH_PANEL));
} else { } else {
console.log('[DEBUG]-onCancel: resetting search query'); if (DEBUG_MODE) {
console.log('[DEBUG]-onCancel: resetting search query');
}
setSearchQuery(''); setSearchQuery('');
dispatch(resetSearch()); dispatch(resetSearch());
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX); Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
@@ -449,7 +539,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
}, 0); }, 0);
} }
}, },
[panelInfo?.currentSpot, firstSpot, panels] [firstSpot, panels.length, panelInfo?.currentSpot] // panels 대신 panels.length 사용
); );
/** /**
@@ -493,9 +583,11 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
* Voice overlay close handler * Voice overlay close handler
*/ */
const handleVoiceOverlayClose = useCallback(() => { const handleVoiceOverlayClose = useCallback(() => {
console.log( if (DEBUG_MODE) {
'🚪 [DEBUG][SearchPanel] handleVoiceOverlayClose called, setting isVoiceOverlayVisible to FALSE' console.log(
); '🚪 [DEBUG][SearchPanel] handleVoiceOverlayClose called, setting isVoiceOverlayVisible to FALSE'
);
}
// ✨ Redux 정리는 VoiceInputOverlay.handleClose()에서 처리함 // ✨ Redux 정리는 VoiceInputOverlay.handleClose()에서 처리함
// VoiceInputOverlay가 닫히는 순간 clearShopperHouseData()를 호출하면, // VoiceInputOverlay가 닫히는 순간 clearShopperHouseData()를 호출하면,
@@ -576,12 +668,12 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
{...rest} {...rest}
> >
<div className={css.productImageWrapper}> <div className={css.productImageWrapper}>
<img src={bgImgPath} alt={curationNm} className={css.productImage} /> <SafeImage src={bgImgPath} alt={curationNm} className={css.productImage} />
</div> </div>
<div className={css.productInfo}> <div className={css.productInfo}>
{showBrandLogo && ( {showBrandLogo && (
<div className={css.productBrandWrapper}> <div className={css.productBrandWrapper}>
<img src={patncLogoPath} alt={patncNm} className={css.brandLogo} /> <SafeImage src={patncLogoPath} alt={patncNm} className={css.brandLogo} />
</div> </div>
)} )}
<div className={css.productDetails}> <div className={css.productDetails}>
@@ -592,7 +684,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
</SpottableProduct> </SpottableProduct>
); );
}, },
[hotPicksForYou, dispatch] [hotPicksForYou, dispatch, SafeImage]
); );
/** /**
@@ -639,6 +731,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
case SEARCH_PANEL_MODES.INITIAL: case SEARCH_PANEL_MODES.INITIAL:
case SEARCH_PANEL_MODES.INPUT_FOCUSED: case SEARCH_PANEL_MODES.INPUT_FOCUSED:
case SEARCH_PANEL_MODES.VOICE_INPUT: case SEARCH_PANEL_MODES.VOICE_INPUT:
case SEARCH_PANEL_MODES.SEARCH_INPUT:
default: default:
return ( return (
<ContainerBasic className={css.contentContainer}> <ContainerBasic className={css.contentContainer}>
@@ -758,16 +851,18 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
} }
}, [ }, [
currentMode, currentMode,
searchDatas, searchDatas?.theme?.length,
shopperHouseData, searchDatas?.item?.length,
recentSearches, searchDatas?.show?.length,
topSearchs, shopperHouseData?.results?.[0]?.docs?.length,
popularBrands, recentSearches?.length,
hotPicksForYou, topSearchs?.length,
popularBrands?.length,
hotPicksForYou?.length,
handleKeywordClick, handleKeywordClick,
createKeywordClickHandler, createKeywordClickHandler,
renderItem, renderItem,
panelInfo, panelInfo?.currentSpot,
]); ]);
/** /**
@@ -787,29 +882,35 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
useEffect(() => { useEffect(() => {
let nextMode = SEARCH_PANEL_MODES.INITIAL; let nextMode = SEARCH_PANEL_MODES.INITIAL;
console.log('[DEBUG]-MODE DECISION useEffect running', { if (DEBUG_MODE) {
isVoiceOverlayVisible, console.log('[DEBUG]-MODE DECISION useEffect running', {
hasShopperHouseData: !!shopperHouseData, isVoiceOverlayVisible,
shopperHouseData_detail: shopperHouseData ? 'EXISTS' : 'NULL', hasShopperHouseData: !!shopperHouseData,
searchPerformed, shopperHouseData_detail: shopperHouseData ? 'EXISTS' : 'NULL',
searchQuery, searchPerformed,
hasSearchResults: !!( searchQuery,
searchDatas?.theme?.length > 0 || hasSearchResults: !!(
searchDatas?.item?.length > 0 || searchDatas?.theme?.length > 0 ||
searchDatas?.show?.length > 0 searchDatas?.item?.length > 0 ||
), searchDatas?.show?.length > 0
isSearchOverlayVisible, ),
currentMode, isSearchOverlayVisible,
}); currentMode,
});
}
// 우선순위 1: 음성 입력 오버레이가 열려있으면 VOICE_INPUT 모드 // 우선순위 1: 음성 입력 오버레이가 열려있으면 VOICE_INPUT 모드
if (isVoiceOverlayVisible) { if (isVoiceOverlayVisible) {
console.log('[DEBUG]-MODE: isVoiceOverlayVisible is TRUE → VOICE_INPUT'); if (DEBUG_MODE) {
console.log('[DEBUG]-MODE: isVoiceOverlayVisible is TRUE → VOICE_INPUT');
}
nextMode = SEARCH_PANEL_MODES.VOICE_INPUT; nextMode = SEARCH_PANEL_MODES.VOICE_INPUT;
} }
// 우선순위 2: 음성 검색 결과가 있으면 VOICE_RESULT 모드 // 우선순위 2: 음성 검색 결과가 있으면 VOICE_RESULT 모드
else if (shopperHouseData) { else if (shopperHouseData) {
console.log('[DEBUG]-MODE: shopperHouseData EXISTS → VOICE_RESULT'); if (DEBUG_MODE) {
console.log('[DEBUG]-MODE: shopperHouseData EXISTS → VOICE_RESULT');
}
nextMode = SEARCH_PANEL_MODES.VOICE_RESULT; nextMode = SEARCH_PANEL_MODES.VOICE_RESULT;
} }
// 우선순위 3: 검색 결과가 있으면 SEARCH_RESULT 모드 // 우선순위 3: 검색 결과가 있으면 SEARCH_RESULT 모드
@@ -819,38 +920,48 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
searchDatas?.item?.length > 0 || searchDatas?.item?.length > 0 ||
searchDatas?.show?.length > 0 searchDatas?.show?.length > 0
) { ) {
console.log('[DEBUG]-MODE: searchResults EXISTS → SEARCH_RESULT'); if (DEBUG_MODE) {
console.log('[DEBUG]-MODE: searchResults EXISTS → SEARCH_RESULT');
}
nextMode = SEARCH_PANEL_MODES.SEARCH_RESULT; nextMode = SEARCH_PANEL_MODES.SEARCH_RESULT;
} }
// 우선순위 4: 검색 입력 오버레이가 열려있으면 SEARCH_INPUT 모드 // 우선순위 4: 검색 입력 오버레이가 열려있으면 SEARCH_INPUT 모드
else if (isSearchOverlayVisible) { else if (isSearchOverlayVisible) {
console.log('[DEBUG]-MODE: isSearchOverlayVisible is TRUE → SEARCH_INPUT'); if (DEBUG_MODE) {
console.log('[DEBUG]-MODE: isSearchOverlayVisible is TRUE → SEARCH_INPUT');
}
nextMode = SEARCH_PANEL_MODES.SEARCH_INPUT; nextMode = SEARCH_PANEL_MODES.SEARCH_INPUT;
} }
// 우선순위 5: 초기 상태 (기본값) // 우선순위 5: 초기 상태 (기본값)
else { else {
console.log('[DEBUG]-MODE: No condition met → INITIAL'); if (DEBUG_MODE) {
console.log('[DEBUG]-MODE: No condition met → INITIAL');
}
nextMode = SEARCH_PANEL_MODES.INITIAL; nextMode = SEARCH_PANEL_MODES.INITIAL;
} }
// 모드가 변경되었을 때만 업데이트 // 모드가 변경되었을 때만 업데이트
if (nextMode !== currentMode) { if (nextMode !== currentMode) {
console.log(`[DEBUG]-VOICE_RESULT 🔀 Mode changed: ${currentMode}${nextMode}`, { if (DEBUG_MODE) {
isVoiceOverlayVisible, console.log(`[DEBUG]-VOICE_RESULT 🔀 Mode changed: ${currentMode}${nextMode}`, {
shopperHouseData: !!shopperHouseData, isVoiceOverlayVisible,
searchPerformed, shopperHouseData: !!shopperHouseData,
searchQuery, searchPerformed,
hasSearchResults: !!( searchQuery,
searchDatas?.theme?.length > 0 || hasSearchResults: !!(
searchDatas?.item?.length > 0 || searchDatas?.theme?.length > 0 ||
searchDatas?.show?.length > 0 searchDatas?.item?.length > 0 ||
), searchDatas?.show?.length > 0
isSearchOverlayVisible, ),
inputFocus, isSearchOverlayVisible,
}); inputFocus,
});
}
setCurrentMode(nextMode); setCurrentMode(nextMode);
} else { } else {
console.log('[DEBUG]-MODE: Mode unchanged -', currentMode); if (DEBUG_MODE) {
console.log('[DEBUG]-MODE: Mode unchanged -', currentMode);
}
} }
}, [ }, [
isVoiceOverlayVisible, isVoiceOverlayVisible,
@@ -870,9 +981,13 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
* TODO: 현재 검색어가 빈 스트링일 경우, `resetSearch`가 발동해서 searchData가 reset되는 현상이 있음, 조건이 변경되어야 한다. * TODO: 현재 검색어가 빈 스트링일 경우, `resetSearch`가 발동해서 searchData가 reset되는 현상이 있음, 조건이 변경되어야 한다.
*/ */
useEffect(() => { useEffect(() => {
console.log('[DEBUG]-searchQuery useEffect:', { searchQuery }); if (DEBUG_MODE) {
console.log('[DEBUG]-searchQuery useEffect:', { searchQuery });
}
if (!searchQuery) { if (!searchQuery) {
console.log('[DEBUG]-VOICE_RESULT: searchQuery is empty, calling resetSearch'); if (DEBUG_MODE) {
console.log('[DEBUG]-VOICE_RESULT: searchQuery is empty, calling resetSearch');
}
dispatch(resetSearch()); dispatch(resetSearch());
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -913,11 +1028,13 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
initialFocusTimerRef.current = setTimeout(() => { initialFocusTimerRef.current = setTimeout(() => {
// 🎯 DetailPanel에서 돌아왔을 때 이전 상품에 포커스 복원 // 🎯 DetailPanel에서 돌아왔을 때 이전 상품에 포커스 복원
if (panelInfo?.currentSpot && currentMode === SEARCH_PANEL_MODES.VOICE_RESULT) { if (panelInfo?.currentSpot && currentMode === SEARCH_PANEL_MODES.VOICE_RESULT) {
console.log('[DEBUG] 🎯 DetailPanel에서 복귀 - 이전 상품으로 포커스 복원:', { if (DEBUG_MODE) {
currentSpot: panelInfo.currentSpot, console.log('[DEBUG] 🎯 DetailPanel에서 복귀 - 이전 상품으로 포커스 복원:', {
currentMode, currentSpot: panelInfo.currentSpot,
timestamp: new Date().toISOString(), currentMode,
}); timestamp: new Date().toISOString(),
});
}
Spotlight.focus(panelInfo.currentSpot); Spotlight.focus(panelInfo.currentSpot);
} else { } else {
// 일반적인 경우: TInput으로 포커스 // 일반적인 경우: TInput으로 포커스
@@ -933,7 +1050,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
initialFocusTimerRef.current = null; initialFocusTimerRef.current = null;
} }
}; };
}, [isOnTop, isOnTopRef, panelInfo?.currentSpot, currentMode]); }, [isOnTop, panelInfo?.currentSpot, currentMode]); // isOnTopRef 제거
/** /**
* focus 용도 * focus 용도
@@ -943,14 +1060,16 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
if (shopperHouseData && isOnTop) { if (shopperHouseData && isOnTop) {
// 🎯 VOICE_RESULT 모드에서는 포커스 복원을 위해 TInput으로 이동하지 않음 // 🎯 VOICE_RESULT 모드에서는 포커스 복원을 위해 TInput으로 이동하지 않음
if (currentMode === SEARCH_PANEL_MODES.VOICE_RESULT) { if (currentMode === SEARCH_PANEL_MODES.VOICE_RESULT) {
console.log( if (DEBUG_MODE) {
'[DEBUG] 🎯 VOICE_RESULT 모드 - ShopperHouse 데이터 수신 시 TInput 포커스 스킵:', console.log(
{ '[DEBUG] 🎯 VOICE_RESULT 모드 - ShopperHouse 데이터 수신 시 TInput 포커스 스킵:',
currentMode, {
hasShopperHouseData: !!shopperHouseData, currentMode,
reason: '상품 포커스 복원 우선', hasShopperHouseData: !!shopperHouseData,
} reason: '상품 포커스 복원 우선',
); }
);
}
return; return;
} }
@@ -1096,7 +1215,8 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
kind={KINDS.withIcon} kind={KINDS.withIcon}
icon={ICONS.search} icon={ICONS.search}
text={searchQuery} // [Phase 8] Overlay에서 입력받은 텍스트만 표시 text={searchQuery} // [Phase 8] Overlay에서 입력받은 텍스트만 표시
alwaysShowText={currentMode === SEARCH_PANEL_MODES.VOICE_RESULT} // 🎯 VOICE_RESULT 모드에서 항상 텍스트 표시 alwaysShowText={currentMode === SEARCH_PANEL_MODES.VOICE_RESULT || currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT} // 🎯 VOICE_RESULT & SEARCH_RESULT 모드에서 항상 텍스트 표시
inputFocus={currentMode === SEARCH_PANEL_MODES.VOICE_RESULT || currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT} // VOICE_RESULT & SEARCH_RESULT 모드에서 TInputSimple 내부 포커스 활성화
onKeyDown={handleKeydown} onKeyDown={handleKeydown}
spotlightId={SPOTLIGHT_IDS.SEARCH_INPUT_BOX} spotlightId={SPOTLIGHT_IDS.SEARCH_INPUT_BOX}
forcedSpotlight="recent-keyword-0" forcedSpotlight="recent-keyword-0"
@@ -1115,7 +1235,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
spotlightId={SPOTLIGHT_IDS.MICROPHONE_BUTTON} spotlightId={SPOTLIGHT_IDS.MICROPHONE_BUTTON}
> >
<div className={css.microphoneCircle}> <div className={css.microphoneCircle}>
<img src={micIcon} alt="Microphone" className={css.microphoneIcon} /> <SafeImage src={micIcon} alt="Microphone" className={css.microphoneIcon} />
</div> </div>
</SpottableMicButton> </SpottableMicButton>
</div> </div>
@@ -1155,7 +1275,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
{/* ✨ [Phase 1] Voice Input Overlay - currentMode로 visibility 제어 */} {/* ✨ [Phase 1] Voice Input Overlay - currentMode로 visibility 제어 */}
<VoiceInputOverlay <VoiceInputOverlay
isVisible={currentMode === SEARCH_PANEL_MODES.VOICE_INPUT} isVisible={isVoiceOverlayVisible}
onClose={handleVoiceOverlayClose} onClose={handleVoiceOverlayClose}
mode={VOICE_MODES.PROMPT} mode={VOICE_MODES.PROMPT}
suggestions={voiceSuggestions} suggestions={voiceSuggestions}
@@ -1166,7 +1286,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
{/* ✨ [Phase 1] Search Input Overlay - currentMode로 visibility 제어 */} {/* ✨ [Phase 1] Search Input Overlay - currentMode로 visibility 제어 */}
<SearchInputOverlay <SearchInputOverlay
isVisible={currentMode === SEARCH_PANEL_MODES.SEARCH_INPUT} isVisible={isSearchOverlayVisible}
onClose={handleSearchOverlayClose} onClose={handleSearchOverlayClose}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}

View File

@@ -1,7 +1,8 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import Spotlight from '@enact/spotlight';
import Spottable from '@enact/spotlight/Spottable'; import Spottable from '@enact/spotlight/Spottable';
import downBtnImg from '../../../assets/images/btn/search_btn_down_arrow.png'; import downBtnImg from '../../../assets/images/btn/search_btn_down_arrow.png';
@@ -26,7 +27,53 @@ export const HOW_ABOUT_THESE_MODES = {
FULL: 'full', // 전체 버전 (팝업) FULL: 'full', // 전체 버전 (팝업)
}; };
const SearchResultsNew = ({ itemInfo, showInfo, themeInfo, shopperHouseInfo, keywordClick }) => { // 메모리 누수 방지를 위한 안전한 이미지 컴포넌트
const SafeImage = ({ src, alt, className, ...props }) => {
const imgRef = useRef(null);
useEffect(() => {
const img = imgRef.current;
if (!img) return;
// 이미지 로드 완료 핸들러
const handleLoad = () => {
// 로드 성공 시 특별한 처리 불필요
};
// 이미지 로드 에러 핸들러
const handleError = () => {
// 에러 시 src를 제거하여 깨진 이미지 방지
if (img.src) {
img.src = '';
}
};
img.addEventListener('load', handleLoad);
img.addEventListener('error', handleError);
// 컴포넌트 unmount 시 이벤트 리스너 정리
return () => {
img.removeEventListener('load', handleLoad);
img.removeEventListener('error', handleError);
// 이미지 로딩 취소
if (img.src) {
img.src = '';
}
};
}, []);
return (
<img
ref={imgRef}
src={src}
alt={alt}
className={className}
{...props}
/>
);
};
const SearchResultsNew = ({ panelInfo, itemInfo, showInfo, themeInfo, shopperHouseInfo, keywordClick }) => {
// ShopperHouse 데이터를 ItemCard 형식으로 변환 // ShopperHouse 데이터를 ItemCard 형식으로 변환
const convertedShopperHouseItems = useMemo(() => { const convertedShopperHouseItems = useMemo(() => {
if (!shopperHouseInfo || !shopperHouseInfo.results || shopperHouseInfo.results.length === 0) { if (!shopperHouseInfo || !shopperHouseInfo.results || shopperHouseInfo.results.length === 0) {
@@ -73,8 +120,6 @@ const SearchResultsNew = ({ itemInfo, showInfo, themeInfo, shopperHouseInfo, key
].filter(Boolean); ].filter(Boolean);
}; };
let buttonTabList = null;
//탭 //탭
const [tab, setTab] = useState(0); const [tab, setTab] = useState(0);
//드롭다운 //드롭다운
@@ -83,7 +128,8 @@ const SearchResultsNew = ({ itemInfo, showInfo, themeInfo, shopperHouseInfo, key
const [visibleCount, setVisibleCount] = useState(ITEMS_PER_PAGE); const [visibleCount, setVisibleCount] = useState(ITEMS_PER_PAGE);
const [styleChange, setStyleChange] = useState(false); const [styleChange, setStyleChange] = useState(false);
const filterMethods = []; // filterMethods 빈 배열 캐싱으로 메모리 누수 방지
const filterMethods = useMemo(() => [], []);
const cbChangePageRef = useRef(null); const cbChangePageRef = useRef(null);
// HowAboutThese 모드 상태 관리 // HowAboutThese 모드 상태 관리
@@ -121,9 +167,12 @@ const SearchResultsNew = ({ itemInfo, showInfo, themeInfo, shopperHouseInfo, key
[keywordClick] [keywordClick]
); );
if (!buttonTabList) { // buttonTabList 최적화 - 의존성이 변경될 때만 재계산
buttonTabList = getButtonTabList(); const buttonTabList = useMemo(() => getButtonTabList(), [
} convertedShopperHouseItems?.length,
itemInfo?.length,
showInfo?.length
]);
// 현재 탭의 데이터 가져오기 - ShopperHouse 데이터 우선 // 현재 탭의 데이터 가져오기 - ShopperHouse 데이터 우선
const currentData = tab === 0 ? convertedShopperHouseItems || itemInfo : showInfo; const currentData = tab === 0 ? convertedShopperHouseItems || itemInfo : showInfo;
@@ -174,8 +223,9 @@ const SearchResultsNew = ({ itemInfo, showInfo, themeInfo, shopperHouseInfo, key
[dropDownTab] [dropDownTab]
); );
const SpottableLi = Spottable('li'); // Spottable 컴포넌트 캐싱하여 메모리 누수 방지
const SpottableDiv = Spottable('div'); const SpottableLi = useMemo(() => Spottable('li'), []);
const SpottableDiv = useMemo(() => Spottable('div'), []);
// 맨 처음으로 이동 (위 버튼) // 맨 처음으로 이동 (위 버튼)
const upBtnClick = () => { const upBtnClick = () => {
@@ -191,9 +241,14 @@ const SearchResultsNew = ({ itemInfo, showInfo, themeInfo, shopperHouseInfo, key
} }
}, [hasMore]); }, [hasMore]);
// ProductCard 컴포넌트 // ProductCard 컴포넌트 - 의존성 최적화 및 안전한 이미지 사용
const renderItem = useCallback( const renderItem = useCallback(
({ index, ...rest }) => { ({ index, ...rest }) => {
// themeInfo가 undefined일 경우를 대비한 방어 코드
if (!themeInfo || !themeInfo[index]) {
return null;
}
const { bgImgPath, title, partnerLogo, partnerName, keyword } = themeInfo[index]; const { bgImgPath, title, partnerLogo, partnerName, keyword } = themeInfo[index];
return ( return (
<SpottableDiv <SpottableDiv
@@ -203,11 +258,11 @@ const SearchResultsNew = ({ itemInfo, showInfo, themeInfo, shopperHouseInfo, key
{...rest} {...rest}
> >
<div className={css.productImageWrapper}> <div className={css.productImageWrapper}>
<img src={bgImgPath} alt={title} className={css.productImage} /> <SafeImage src={bgImgPath} alt={title} className={css.productImage} />
</div> </div>
<div className={css.productInfo}> <div className={css.productInfo}>
<div className={css.productBrandWrapper}> <div className={css.productBrandWrapper}>
<img src={partnerLogo} alt={partnerName} className={css.brandLogo} /> <SafeImage src={partnerLogo} alt={partnerName} className={css.brandLogo} />
</div> </div>
<div className={css.productDetails}> <div className={css.productDetails}>
{keyword && ( {keyword && (
@@ -223,7 +278,7 @@ const SearchResultsNew = ({ itemInfo, showInfo, themeInfo, shopperHouseInfo, key
</SpottableDiv> </SpottableDiv>
); );
}, },
[themeInfo] [themeInfo?.length, SpottableDiv, SafeImage] // themeInfo 전체 대신 length와 캐싱된 컴포넌트 사용
); );
// relativeQueries 가져오기 (Redux에서 제공) // relativeQueries 가져오기 (Redux에서 제공)
@@ -237,6 +292,29 @@ const SearchResultsNew = ({ itemInfo, showInfo, themeInfo, shopperHouseInfo, key
return ['Puppy food', 'Dog toy', 'Fitness']; return ['Puppy food', 'Dog toy', 'Fitness'];
}, [shopperHouseInfo]); }, [shopperHouseInfo]);
useEffect(() => {
const targetId = panelInfo?.currentSpot
? panelInfo?.currentSpot
: themeInfo?.length > 0
? "searchProduct-0"
: itemInfo?.length > 0
? "searchItemContents0"
: showInfo?.length > 0
? "categoryShowContents0"
: null;
if (!targetId) return;
const spotTimeout = setTimeout(() => Spotlight.focus(targetId), 100);
return () => clearTimeout(spotTimeout);
}, [
panelInfo?.currentSpot,
themeInfo?.length,
itemInfo?.length,
showInfo?.length,
convertedShopperHouseItems?.length // shopperHouseInfo 대신 구체적인 의존성 사용
]);
return ( return (
<div className={css.searchBox}> <div className={css.searchBox}>
{/* HowAboutThese Small 버전 - 기본 인라인 표시 */} {/* HowAboutThese Small 버전 - 기본 인라인 표시 */}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState, useEffect } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
@@ -42,6 +42,7 @@ export default function TInputSimple({
placeholder, placeholder,
text, // ✨ [Phase 8] Overlay에서 입력받은 텍스트 text, // ✨ [Phase 8] Overlay에서 입력받은 텍스트
alwaysShowText = false, // 🎯 VOICE_RESULT 모드에서 항상 텍스트 표시 alwaysShowText = false, // 🎯 VOICE_RESULT 모드에서 항상 텍스트 표시
inputFocus = false, // ✨ 외부 inputFocus 상태 전달받기 위한 prop
...rest ...rest
}) { }) {
const { handleScrollReset, handleStopScrolling } = useScrollReset(scrollTop); const { handleScrollReset, handleStopScrolling } = useScrollReset(scrollTop);
@@ -52,6 +53,18 @@ export default function TInputSimple({
// 🎯 텍스트 표시 조건: 포커스가 있거나 alwaysShowText가 true면 텍스트 표시 // 🎯 텍스트 표시 조건: 포커스가 있거나 alwaysShowText가 true면 텍스트 표시
const shouldShowText = isFocused || alwaysShowText; const shouldShowText = isFocused || alwaysShowText;
// ✨ 외부 inputFocus 상태와 내부 isFocused 상태 동기화를 위한 effect
useEffect(() => {
// 외부에서 전달받은 inputFocus가 true일 때 내부 isFocused도 true로 설정
if (inputFocus && !isFocused) {
setIsFocused(true);
}
// 외부에서 전달받은 inputFocus가 false일 때 내부 isFocused도 false로 설정
if (!inputFocus && isFocused) {
setIsFocused(false);
}
}, [inputFocus]);
// Focus 핸들러 // Focus 핸들러
const _onFocus = useCallback(() => { const _onFocus = useCallback(() => {
setIsFocused(true); setIsFocused(true);