Files
shoptime/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.v2.jsx
junghoon86.park 802484debd [검색] 음성검색관련 마이크 노출 주석처리
- 음성 검색우선 제외로 인하여 마이크 버튼주석처리.
2025-12-15 16:23:54 +09:00

2352 lines
81 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/views/SearchPanel/SearchPanel.new.jsx
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import Spotlight from '@enact/spotlight';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import micIcon from '../../../assets/images/searchpanel/image-mic.png';
import hotPicksImage from '../../../assets/images/searchpanel/img-hotpicks.png';
import hotPicksBrandImage from '../../../assets/images/searchpanel/img-search-hotpicks.png';
import { sendLogGNB, sendLogTotalRecommend } from '../../actions/logActions';
import { getMyRecommandedKeyword } from '../../actions/myPageActions';
import { popPanel, pushPanel, updatePanel } from '../../actions/panelActions';
import {
clearPanelCommand,
clearShopperHouseData,
getSearch,
getSearchMain,
getShopperHouseSearch,
resetSearch,
resetVoiceSearch,
transitionToSearchInputOverlay,
} from '../../actions/searchActions';
// import {
// showErrorToast,
// showInfoToast,
// showSearchErrorToast,
// showSearchSuccessToast,
// showSuccessToast,
// showWarningToast,
// } from '../../actions/toastActions';
import TBody from '../../components/TBody/TBody';
import TItemCardNew, {
removeDotAndColon,
} from '../../components/TItemCard/TItemCard.new';
import TPanel from '../../components/TPanel/TPanel';
import TVerticalPagenator from '../../components/TVerticalPagenator/TVerticalPagenator';
import TVirtualGridList from '../../components/TVirtualGridList/TVirtualGridList';
import usePanelHistory from '../../hooks/usePanelHistory/usePanelHistory';
// import VirtualKeyboardContainer from "../../components/TToast/VirtualKeyboardContainer";
import usePrevious from '../../hooks/usePrevious';
import { useSearchHistory } from '../../hooks/useSearchHistory';
import {
LOG_CONTEXT_NAME,
LOG_MENU,
LOG_MESSAGE_ID,
panel_names,
} from '../../utils/Config';
import { createDebugHelpers } from '../../utils/debug';
import NoSearchResults from './NoSearchResults/NoSearchResults';
// import NoSearchResults from './NoSearchResults/NoSearchResults';
import SearchInputOverlay from './SearchInputOverlay';
import css from './SearchPanel.new.module.less';
import SearchResultsNew from './SearchResults.new.v2';
import TInputSimple, { ICONS, KINDS } from './TInput/TInputSimple';
import VoiceInputOverlay, {
VOICE_MODES,
} from './VoiceInputOverlay/VoiceInputOverlay';
// 디버그 헬퍼 설정
const DEBUG_MODE = false;
const { dlog, dwarn, derror } = createDebugHelpers(DEBUG_MODE);
/**
* ✨ Mode-Based Architecture 도입
*
* VoiceInputOverlay와 동일한 방식으로 SearchPanel도 Mode 기반 아키텍처를 도입했습니다.
* 이를 통해:
* 1. UI 상태를 명시적으로 관리 (단일 currentMode 변수)
* 2. 렌더링 로직을 switch 기반으로 통합
* 3. 모드 전환을 자동으로 처리
* 4. 상태 조합 오류를 방지
*
* @see VoiceInputOverlay/VoiceInputOverlay.jsx의 VOICE_MODES와 동일한 패턴
*/
export const SEARCH_PANEL_MODES = {
INITIAL: 'initial', // 초기 상태 - 빈 검색어, 모든 섹션 표시
SEARCH_INPUT: 'search_input', // 검색 입력 중 - SearchInputOverlay 표시
VOICE_INPUT: 'voice_input', // 음성 입력 - VoiceInputOverlay 표시
SEARCH_RESULT: 'search_result', // 검색 결과 표시 (정규 검색)
VOICE_RESULT: 'voice_result', // 음성 검색 결과 표시
};
const ContainerBasic = SpotlightContainerDecorator(
{ enterTo: 'last-focused' },
'div'
);
// 검색 입력 영역 컨테이너
const InputContainer = SpotlightContainerDecorator(
{ enterTo: 'last-focused' },
'div'
);
// 콘텐츠 섹션 컨테이너
const SectionContainer = SpotlightContainerDecorator(
{ enterTo: 'last-focused' },
'div'
);
// 메모리 누수 방지를 위한 안전한 이미지 컴포넌트 (컴포넌트 외부로 이동)
const SafeImageComponent = ({ 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 ITEMS_PER_PAGE = 9;
// Spotlight ID 상수
const SPOTLIGHT_IDS = {
SEARCH_INPUT_LAYER: 'search-input-layer',
SEARCH_INPUT_BOX: 'search-input-box',
MICROPHONE_BUTTON: 'microphone-button',
RECENT_SEARCHES_SECTION: 'recent-searches-section',
TOP_SEARCHES_SECTION: 'top-searches-section',
POPULAR_BRANDS_SECTION: 'popular-brands-section',
HOT_PICKS_SECTION: 'hot-picks-section',
TODAY_DEALS_SECTION: 'today-deals-section',
SEARCH_VERTICAL_PAGENATOR: 'search_verticalPagenator',
};
export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
const dispatch = useDispatch();
// DEBUG 모드 상태 - 개발 환경에서만 활성화
const DEBUG_MODE = process.env.NODE_ENV === 'development';
/**
* stores
*/
// 0hun: 검색 데이터 응답 관리 전역 상태
const { searchDatas: searchDatas } = useSelector((state) => state.search);
// 0hun: 검색을 실행하여 검색 결과에 대한 UI인지, 초기 검색 UI인지 관리하는 Boolean 전역 상태
const searchPerformed = useSelector((state) => state.search.searchPerformed);
// 0hun: 패널 전역 상태
const panels = useSelector((state) => state.panels.panels);
// 0hun: 음성 검색 결과에 대한 전역 상태
const shopperHouseData = useSelector(
(state) => state.search.shopperHouseData
);
const shopperHouseError = useSelector(
(state) => state.search.shopperHouseError
);
// 0hun: 음성 검색 searchId (Redux에서 별도 관리)
const shopperHouseSearchId = useSelector(
(state) => state.search.shopperHouseSearchId
);
// 0hun: 음성 검색 relativeQueries (Redux에서 별도 관리)
const shopperHouseRelativeQueries = useSelector(
(state) => state.search.shopperHouseRelativeQueries
);
// 🔄 이전 shopperHouseData (sortingType 변경 시 사용)
const preShopperHouseData = useSelector(
(state) => state.search.preShopperHouseData
);
// 0hun: 검색 메인, Hot Picks for you 영역에 대한 전역 상태 값
const hotPicksForYou = useSelector(
(state) => state.search.searchMainData.hotPicksForYou
);
// 0hun: 검색 메인, Popular Brands 영역에 대한 전역 상태 값
const popularBrands = useSelector(
(state) => state.search.searchMainData.popularBrands
);
// 0hun: 검색 메인, Top Searchs 영역에 대한 전역 상태 값
const topSearchs = useSelector(
(state) => state.search.searchMainData.topSearchs
);
// jhun: 검색 메인, Today Deals 영역에 대한 전역 상태 값
const tsvInfo = useSelector((state) => state.search.searchMainData.tsvInfo);
/**
* states
*/
// 0hun: 초기 포커스 유무를 나타내는 Boolean 상태
const [firstSpot, setFirstSpot] = useState(false);
// 0hun: 검색어 상태
const [searchQuery, setSearchQuery] = useState(
panelInfo.searchVal ? panelInfo.searchVal : null
);
// 0hun: 검색 컨테이너 포커스 position 상태 값
const [position, setPosition] = useState(null);
// 0hun: 가상 키보드 Display 유무 Boolean 값 (주석: 현재 VirtualKeyboardContainer가 비활성화됨)
// const [showVirtualKeyboard, setShowVirtualKeyboard] = useState(false);
// 0hun: 보이스 오버레이 Display Boolean 값
const [isVoiceOverlayVisible, setIsVoiceOverlayVisible] = useState(false);
// 0hun: 서치 오버레이 Display Boolean 값
const [isSearchOverlayVisible, setIsSearchOverlayVisible] = useState(false);
// 0hun: input에 focus가 되어 커서 유무를 관리하는 Boolean 상태 값
const [inputFocus, setInputFocus] = useState(false);
// ✨ [Phase 3] TInput의 입력 모드 상태 제거 (더 이상 InputField가 없으므로 불필요)
// const [isInputModeActive, setIsInputModeActive] = useState(false);
// 0hun: 현재 포커스된 container의 spotlightId를 관리하는 상태 값
const [focusedContainerId, setFocusedContainerId] = useState(
panelInfo?.focusedContainerId
);
// ✨ [Phase 1] SearchPanel의 현재 모드 상태 (VoiceInputOverlay의 VOICE_MODES와 동일한 개념)
const [currentMode, setCurrentMode] = useState(SEARCH_PANEL_MODES.INITIAL);
const [isShopperHousePending, setIsShopperHousePending] = useState(false);
const [voiceOverlayMode, setVoiceOverlayMode] = useState(VOICE_MODES.PROMPT);
const [voiceOverlayResponseText, setVoiceOverlayResponseText] = useState('');
const [isVoiceOverlayBubbleSearch, setIsVoiceOverlayBubbleSearch] =
useState(false);
const [shouldFocusVoiceResult, setShouldFocusVoiceResult] = useState(false);
// 🎯 HowAboutThese 포커스 관리 - 검색 입력 영역 포커스 감지용 상태
const [searchInputFocused, setSearchInputFocused] = useState(false);
// 마이크 버튼 포커스 가능 여부 상태
const [isMicFocusable, setIsMicFocusable] = useState(true);
// 🎯 SearchInputOverlay 닫힘 후 포커스 관리 - 명시적 플래그
const [shouldFocusSearchInput, setShouldFocusSearchInput] = useState(false);
// 🐛 [DEBUG] shopperHouseData 상태 변경 추적 (DEBUG_MODE가 true일 경우에만)
useEffect(() => {
if (DEBUG_MODE) {
dlog('[DEBUG] 📊 SearchPanel shopperHouseData 상태 변경:', {
hasData: !!shopperHouseData,
dataLength: shopperHouseData?.results?.[0]?.docs?.length || 0,
searchId: shopperHouseData?.results?.[0]?.searchId || '(없음)',
timestamp: new Date().toISOString(),
});
}
}, [
shopperHouseData?.results?.[0]?.docs?.length,
shopperHouseData?.results?.[0]?.searchId,
DEBUG_MODE,
]);
// 🐛 [DEBUG] SearchPanel 마운트/언마운트 추적 (DEBUG_MODE가 true일 경우에만)
useEffect(() => {
if (DEBUG_MODE) {
dlog('[DEBUG] 🚀 SearchPanel 마운트됨 - 초기 shopperHouseData 상태:', {
hasData: !!shopperHouseData,
dataLength: shopperHouseData?.results?.[0]?.docs?.length || 0,
currentMode,
timestamp: new Date().toISOString(),
});
}
return () => {
if (DEBUG_MODE) {
dlog('[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) {
dlog('[DEBUG] 🔄 SearchPanel isOnTop 상태 변경:', {
from: isOnTopRef.current,
to: isOnTop,
shopperHouseData_preserved: !!shopperHouseData,
dataLength: shopperHouseData?.results?.[0]?.docs?.length || 0,
scenario: isOnTop
? 'DetailPanel에서 SearchPanel로 복귀'
: 'SearchPanel에서 DetailPanel로 이동',
timestamp: new Date().toISOString(),
});
}
}, [isOnTop, shopperHouseData?.results?.[0]?.docs?.length, DEBUG_MODE]);
/**
* refs
*/
const focusedContainerIdRef = usePrevious(focusedContainerId);
const isOnTopRef = usePrevious(isOnTop);
const searchQueryRef = usePrevious(searchQuery);
const cbChangePageRef = useRef(null);
const initialFocusTimerRef = useRef(null);
const spotlightResumeTimerRef = useRef(null);
// DOM 쿼리 최적화를 위한 ref 추가
const inputElementRef = useRef(null);
// 🎯 [포커스 로직 통합] 이전 상태 추적용 ref
const shopperHouseDataRef = useRef(null);
const searchDatasRef = useRef(null); // 일반 검색 결과 추적
const isVoiceOverlayVisibleRef = useRef(false);
const isSearchOverlayVisibleRef = useRef(false);
const currentModeRef = useRef(SEARCH_PANEL_MODES.INITIAL);
const unifiedFocusTimerRef = useRef(null);
// ShopperHouse 에러 팝업 상태 가져오기
const shopperHouseErrorPopup = useSelector(
(state) => state.search.shopperHouseErrorPopup
);
// API 실패 시 fallback reference 초기화
useEffect(() => {
if (
shopperHouseErrorPopup?.visible &&
shopperHouseErrorPopup?.type === 'API_FAILURE'
) {
dlog('[SearchPanel] 🧹 API 실패 감지 - fallbackShopperHouseData 초기화');
shopperHouseDataRef.current = null;
}
}, [shopperHouseErrorPopup?.visible, shopperHouseErrorPopup?.type]);
const detailReturnHandledRef = useRef(false);
// Spottable 컴포넌트 캐싱으로 메모리 누수 방지
const SpottableMicButton = useMemo(() => Spottable('div'), []);
const SpottableKeyword = useMemo(() => Spottable('div'), []);
const SpottableProduct = useMemo(() => Spottable('div'), []);
// SafeImage 컴포넌트 캐싱
const SafeImage = useCallback(SafeImageComponent, []);
/**
* useSearchHistory Hook 적용
*/
const {
normalSearches,
addNormalSearch,
refreshHistory,
executeSearchFromHistory,
} = useSearchHistory();
/**
* 🎯 [DetailPanel 복귀 감지] usePanelHistory Hook 적용
*/
const {
currentPanel,
previousPanel,
currentIsOnTop, // 🎯 usePanelHistory의 isOnTop 정보 사용
isOnTopChange,
} = usePanelHistory();
// 🎯 DetailPanel에서 SearchPanel로 돌아왔는지 감지
const isReturningFromDetailPanel = useMemo(() => {
const isReturning =
currentPanel?.panelName === 'searchpanel' &&
previousPanel?.panelName === 'detailpanel' &&
currentPanel?.action === 'POP' &&
currentPanel?.panelInfo?.currentSpot;
if (DEBUG_MODE && isReturning) {
dlog('[FOCUS] 🎯 DetailPanel 복귀 감지:', {
current: currentPanel?.panelName,
previous: previousPanel?.panelName,
action: currentPanel?.action,
currentSpot: currentPanel?.panelInfo?.currentSpot,
searchVal: currentPanel?.panelInfo?.searchVal,
currentIsOnTop, // 🎯 usePanelHistory의 isOnTop 정보
isOnTopChange, // 🎯 isOnTop 변화 정보
timestamp: new Date().toISOString(),
});
}
return isReturning;
}, [currentPanel, previousPanel, currentIsOnTop, isOnTopChange, DEBUG_MODE]);
// Voice overlay suggestions (동적으로 변경 가능)
const voiceSuggestions = useMemo(
() => [
'" Can you recommend a good budget cordless vacuum? "',
'" Show me trending skincare. "',
'" Find the newest Nike sneakers. "',
'" Show me snail cream that helps with sensitive skin. "',
'" Recommend a tasty melatonin gummy. "',
],
[]
);
/**
* 0hun: input에 focus 발생 시, `inputFocus` 상태 `true` 설정
*/
const _onFocus = useCallback(() => {
setInputFocus(true);
// 🎯 HowAboutThese 포커스 관리 - 검색 입력 영역 포커스 상태 설정
setSearchInputFocused(true);
// 마이크 버튼 포커스 가능하도록 설정
setIsMicFocusable(true);
}, []);
/**
* 0hun: input에 blur 발생 시, `inputFocus` 상태 `false` 설정
*/
const _onBlur = useCallback(() => {
setInputFocus(false);
// 🎯 HowAboutThese 포커스 관리 - 검색 입력 영역 포커스 해제
setSearchInputFocused(false);
}, []);
// 🎯 HowAboutThese 포커스 관리 - TInputSimple/Mic Icon 포커스 감지 핸들러
const handleSearchInputFocus = useCallback(() => {
setSearchInputFocused(true);
}, []);
const handleSearchInputBlur = useCallback(() => {
setSearchInputFocused(false);
}, []);
// 마이크 버튼 포커스 핸들러
const micFocusTimerRef = useRef(null);
const onFocusMic = useCallback(() => {
// 이전 타이머 정리
if (micFocusTimerRef.current) {
clearTimeout(micFocusTimerRef.current);
micFocusTimerRef.current = null;
}
// isMicFocusable이 true인 경우에만 음성 오버레이 열기
if (isMicFocusable) {
openVoiceOverlay();
// 500ms 후에 isMicFocusable을 false로 설정
micFocusTimerRef.current = setTimeout(() => {
setIsMicFocusable(false);
micFocusTimerRef.current = null;
}, 500);
} else {
// isMicFocusable이 false인 경우 250ms 후에 TInputSimple으로 포커스 이동
micFocusTimerRef.current = setTimeout(() => {
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
micFocusTimerRef.current = null;
}, 250);
}
}, [isMicFocusable, openVoiceOverlay]);
// 0hun: ✨ [Phase 3] showVirtualKeyboard 제거 (주석 처리된 VirtualKeyboardContainer와 함께 비활성화)
const handleKeydown = useCallback(
(e) => {
if (!isOnTopRef.current) {
return;
}
// ✨ [Phase 4] Enter/OK 키 처리 - SearchInputOverlay 표시
if (e.key === 'Enter' || e.keyCode === 13) {
dlog(
'[DEBUG] [SearchPanel] TInputSimple에서 Enter/OK 키 감지 → SearchInputOverlay 오픈'
);
e.preventDefault();
// ✨ [Phase 6] SearchInputOverlay 오픈 후 자동으로 입력 준비 완료
// SearchInputOverlay가 열리면 자동으로:
// 1. DOM 렌더링
// 2. useEffect에서 input.focus() 호출 (50ms 후)
// 3. 사용자가 바로 입력 가능한 상태
setIsSearchOverlayVisible(true);
// ✅ 수정: Enter 키 누를 때에도 inputFocus 유지 (VOICE_RESULT 모드에서 키보드 이벤트 수신 가능)
// setInputFocus(false);
return;
}
// 방향키 처리 - Spotlight 네비게이션 허용
const arrowKeys = [
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'Left',
'Right',
'Up',
'Down',
];
if (arrowKeys.includes(e.key)) {
// 입력 필드가 비어있고 왼쪽 화살표인 경우에만 방지
if (
position === 0 &&
(e.key === 'Left' || e.key === 'ArrowLeft') &&
!searchQuery
) {
e.preventDefault();
return;
}
// 오른쪽 화살표 키 처리 - 포커스 이동 허용
if (e.key === 'ArrowRight' || e.key === 'Right') {
// 커서가 텍스트 끝에 있을 때만 포커스 이동 허용
// DOM 쿼리 최적화: 캐싱된 input element 사용
const input =
inputElementRef.current ||
document.querySelector(
`[data-spotlight-id="input-field-box"] > input`
);
if (input) {
inputElementRef.current = input; // 캐싱
if (position === input.value.length) {
// 커서가 텍스트 끝에 있으면 포커스 이동 허용
return;
}
}
}
// ❌ [251026] DEPRECATED: 아래쪽 화살표 키 처리 - Spotlight 속성으로 대체됨
// Spotlight의 data-spotlight-down 속성으로 처리하도록 변경
// 기존 코드 보존 (향후 필요시 참고용)
/*
if (e.key === 'ArrowDown' || e.key === 'Down') {
e.preventDefault();
setTimeout(() => {
Spotlight.focus('howAboutThese-seeMore');
}, 0);
return;
}
*/
// 나머지 방향키는 Spotlight가 처리하도록 허용
return;
}
},
[searchQuery, position, isOnTopRef] // ✨ [Phase 3] showVirtualKeyboard 제거
);
// ✨ [Phase 3] handleInputModeChange 제거 (더 이상 InputField가 없으므로 불필요)
// /**
// * 0hun: input에 포커스 발생하여 가상 키보드 활성 시, `isInputModeActive` 상태 Boolean 값 설정
// */
// const handleInputModeChange = useCallback((isActive) => {
// dlog(
// "[SearchPanel] TInput 입력 모드:",
// isActive ? "활성화 (키보드 표시)" : "비활성화 (키보드 숨김)"
// );
// setIsInputModeActive(isActive);
// }, []);
/**
* 0hun: input value를 `query`에 담는 change 함수
*/
const handleSearchChange = useCallback((e) => {
const query = e.value;
if (query.length <= 255) {
setSearchQuery(query);
}
}, []);
/**
* ✨ [Phase 3] 검색 submit 시, `getSearch` API 요청하는 함수 (showVirtualKeyboard 제거)
*/
const handleSearchSubmit = useCallback(
(query) => {
if (!searchPerformed && !query) {
return;
}
if (query.trim()) {
// 일반 검색 기록 저장
addNormalSearch(query.trim());
dispatch(
getSearch({
domain: 'theme,show,item',
query: query,
service: 'com.lgshop.app',
})
);
// 🎯 [포커스 로직 통합] 검색어만 업데이트
// 포커스는 searchDatas 변경에 의해 자동으로 처리됨
dispatch(
updatePanel({
name: panel_names.SEARCH_PANEL,
panelInfo: {
searchVal: query,
tab: 0,
},
})
);
// 검색 시작 알림 (선택사항)
// dispatch(showSuccessToast(`"${query}" 검색 중...`, { duration: 2000 }));
} else {
dispatch(resetSearch());
}
setSearchQuery(query);
},
[searchPerformed, dispatch, addNormalSearch] // ✨ [Phase 3] dispatch 추가, showVirtualKeyboard 제거, addNormalSearch 추가
);
/**
* 0hun: keyUp 이벤트 핸들러, keyUp 시 `position`의 상태값 변경하는 함수
* DOM 쿼리 최적화: ref를 사용하여 반복적인 DOM 조회 방지
*/
const cursorPosition = useCallback(() => {
// ref를 사용하여 캐싱된 input element 사용
const input =
inputElementRef.current ||
document.querySelector(`[data-spotlight-id="input-field-box"] > input`);
if (input) {
inputElementRef.current = input; // 캐싱
setPosition(input.selectionStart);
}
}, []);
/**
* 0hun: 음성 입력 오버레이를 여는 공통 함수
*/
const openVoiceOverlay = useCallback(() => {
if (!isOnTopRef.current) {
return;
}
if (DEBUG_MODE) {
dlog(
'🎤 [DEBUG][SearchPanel] openVoiceOverlay called, current isVoiceOverlayVisible:',
isVoiceOverlayVisible
);
}
setVoiceOverlayMode(VOICE_MODES.PROMPT);
setVoiceOverlayResponseText('');
setIsVoiceOverlayBubbleSearch(false);
setIsVoiceOverlayVisible(true);
}, [isVoiceOverlayVisible]);
/**
* 0hun: Mic 아이콘 클릭 시 발생하는 이벤트
*/
const onClickMic = useCallback(() => {
openVoiceOverlay();
}, [openVoiceOverlay]);
/**
* 0hun: panel 뒤로가기 이벤트
*/
const onCancel = useCallback(() => {
if (DEBUG_MODE) {
dlog('[DEBUG]-onCancel called', {
isOnTop: isOnTopRef.current,
isVoiceOverlayVisible,
isSearchOverlayVisible,
currentMode,
searchQuery,
hasShopperHouseData: !!shopperHouseData,
isShopperHousePending,
});
}
if (!isOnTopRef.current) {
if (DEBUG_MODE) {
dlog('[DEBUG]-onCancel: isOnTopRef is false, returning');
}
return;
}
// VoiceInputOverlay가 열려있으면 먼저 닫기
if (isVoiceOverlayVisible) {
if (DEBUG_MODE) {
dlog('[DEBUG]-onCancel: closing VoiceInputOverlay');
}
setIsVoiceOverlayVisible(false);
return;
}
// SearchInputOverlay가 열려있으면 먼저 닫기
if (isSearchOverlayVisible) {
if (DEBUG_MODE) {
dlog('[DEBUG]-onCancel: closing SearchInputOverlay');
}
handleSearchOverlayClose();
return;
}
// ✨ [Phase 5] VOICE_RESULT 모드에서 ESC/뒤로가기 누르면 INITIAL 모드로 돌아가기
if (DEBUG_MODE) {
dlog('[DEBUG]-VOICE_RESULT check:', {
currentMode,
isVoiceResultMode: currentMode === SEARCH_PANEL_MODES.VOICE_RESULT,
VOICE_RESULT_value: SEARCH_PANEL_MODES.VOICE_RESULT,
});
}
if (currentMode === SEARCH_PANEL_MODES.VOICE_RESULT) {
if (DEBUG_MODE) {
dlog(
'[DEBUG]-VOICE_RESULT: Clearing ShopperHouse data (searchId will be preserved for 2nd search)'
);
dlog('[VoiceInput]-SearchPanel-onCancel-VOICE_RESULT');
dlog(
'[VoiceInput] 🧹 VOICE_RESULT 모드에서 ESC 누름 - clearShopperHouseData 호출'
);
}
// 🎯 [포커스 로직 통합] 포커스는 상태 변경에 의해 자동으로 처리됨
setIsShopperHousePending(false);
dispatch(clearShopperHouseData()); // ✨ shopperHouseData만 초기화, searchId & relativeQuerys 유지
return;
}
if (DEBUG_MODE) {
dlog('[DEBUG]-onCancel: normal cancel logic', { searchQuery });
}
if (searchQuery === null || searchQuery === '') {
if (DEBUG_MODE) {
dlog('[DEBUG]-onCancel: popping panel');
}
dispatch(popPanel(panel_names.SEARCH_PANEL));
} else {
if (DEBUG_MODE) {
dlog('[DEBUG]-onCancel: resetting search query');
}
setSearchQuery('');
// 🎯 [포커스 로직 통합] 포커스는 상태 변경(searchQuery)에 의해 자동으로 처리됨
dispatch(resetSearch());
}
}, [
isVoiceOverlayVisible,
isSearchOverlayVisible,
searchQuery,
currentMode,
dispatch,
shopperHouseData,
]);
/**
* 0hun: paginator 내부 아이템들의 onFocus 이벤트, containerId인자로 받는다
*/
const onFocusedContainerId = useCallback(
(containerId) => {
setFocusedContainerId(containerId);
if (!firstSpot) {
// Clear existing timer before setting new one
if (spotlightResumeTimerRef.current) {
clearTimeout(spotlightResumeTimerRef.current);
}
spotlightResumeTimerRef.current = setTimeout(() => {
Spotlight.resume();
setFirstSpot(true);
if (panelInfo.currentSpot) {
if (panels[panels.length - 1]?.name === 'searchpanel') {
Spotlight.focus(panelInfo.currentSpot);
}
}
spotlightResumeTimerRef.current = null;
}, 0);
}
},
[firstSpot, panels.length, panelInfo?.currentSpot] // panels 대신 panels.length 사용
);
/**
* 키워드 클릭 핸들러
*/
const handleKeywordClick = useCallback(
(keyword) => {
setSearchQuery(keyword);
handleSearchSubmit(keyword);
setIsSearchOverlayVisible(false);
setInputFocus(false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[handleSearchSubmit]
);
/**
* 검색 기록에서 키워드 클릭 핸들러
*/
const handleHistoryKeywordClick = useCallback(
(historyItem) => {
if (!historyItem || !historyItem.query) {
return;
}
// 검색어 설정
setSearchQuery(historyItem.query);
// 검색 타입에 따라 다른 API 호출
executeSearchFromHistory(historyItem);
// Overlay 닫기 및 포커스 해제
setIsSearchOverlayVisible(false);
setInputFocus(false);
},
[executeSearchFromHistory]
);
/**
* 키워드 클릭 핸들러 생성 함수
*/
const createKeywordClickHandler = useCallback(
(keyword) => {
return () => handleKeywordClick(keyword);
},
[handleKeywordClick]
);
/**
* 검색 기록 키워드 클릭 핸들러 생성 함수
*/
const createHistoryKeywordClickHandler = useCallback(
(historyItem) => {
return () => handleHistoryKeywordClick(historyItem);
},
[handleHistoryKeywordClick]
);
/**
* 🎯 [포커스 로직 통합]
* 현재 상태와 이전 상태를 비교하여 어떤 시나리오인지 분석
* 반환값:
* - 'DETAIL_PANEL_RETURN': DetailPanel에서 복귀 (이전 상품으로 포커스)
* - 'INITIAL_OPEN': SearchPanel 처음 열림
* - 'NEW_SEARCH_LOADED': 새로운 검색 결과 로드됨
* - 'OVERLAY_CLOSED': Overlay가 닫혔음
* - 'NO_CHANGE': 변화 없음
*/
const analyzeCurrentScenario = useCallback(() => {
// DEBUG: 모든 기본 상태값 출력
if (DEBUG_MODE) {
dlog('[DEBUG] analyzeCurrentScenario 호출됨:', {
// 🎯 기존 isOnTop과 usePanelHistory의 isOnTop 비교
propIsOnTop: isOnTop,
historyIsOnTop: currentIsOnTop,
isOnTopRefCurrent: isOnTopRef.current,
isOnTopChange, // 🎯 isOnTop 변화 정보
panelInfo: panelInfo,
currentMode,
currentModeRefCurrent: currentModeRef.current,
isVoiceOverlayVisible,
isVoiceOverlayVisibleRefCurrent: isVoiceOverlayVisibleRef.current,
isSearchOverlayVisible,
isSearchOverlayVisibleRefCurrent: isSearchOverlayVisibleRef.current,
shopperHouseData: !!shopperHouseData,
isReturningFromDetailPanel,
currentPanel: currentPanel?.panelName,
previousPanel: previousPanel?.panelName,
currentAction: currentPanel?.action,
});
}
// 🎯 [DetailPanel 복귀 감지 개선] usePanelHistory 데이터 사용 (우선순위 최상)
if (isReturningFromDetailPanel) {
const currentSpot = currentPanel?.panelInfo?.currentSpot;
if (DEBUG_MODE) {
dlog(
'[Focus] usePanelHistory로 DetailPanel 복귀 감지 - 이전 상품으로 포커스 이동'
);
dlog('[FOCUS] 🎯 Scenario: DETAIL_PANEL_RETURN (usePanelHistory)', {
currentSpot,
mode: currentMode,
fromSearchResult: currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT,
fromVoiceResult: currentMode === SEARCH_PANEL_MODES.VOICE_RESULT,
currentPanel: currentPanel?.panelName,
previousPanel: previousPanel?.panelName,
action: currentPanel?.action,
});
}
return 'DETAIL_PANEL_RETURN';
}
// 🎯 [개선된 fallback] usePanelHistory의 isOnTop 정보 활용
// DetailPanel에서 방금 복귀한 상황 (usePanelHistory가 없을 경우를 대비)
if (DEBUG_MODE) {
dlog('[DEBUG] 개선된 DETAIL_PANEL_RETURN 조건 확인 (fallback):', {
// 🎯 여러 isOnTop 소스 비교
propIsOnTop: isOnTop,
historyIsOnTop: currentIsOnTop,
historyIsOnTopChange: isOnTopChange,
isOnTopRefCurrent: isOnTopRef.current,
propIsOnTopChanged: isOnTop && !isOnTopRef.current,
historyIsOnTopChanged: isOnTopChange?.becameOnTop,
currentSpot: panelInfo?.currentSpot,
hasCurrentSpot: !!panelInfo?.currentSpot,
currentMode,
isSearchResultMode: currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT,
isVoiceResultMode: currentMode === SEARCH_PANEL_MODES.VOICE_RESULT,
});
}
// 🎯 usePanelHistory의 isOnTop 정보를 우선적으로 사용하는 fallback 로직
if (
!isReturningFromDetailPanel && // 🎯 usePanelHistory로 감지 못했을 때만
((currentIsOnTop && isOnTopChange?.becameOnTop) || // 🎯 usePanelHistory 기반 감지 (우선)
(isOnTop && !isOnTopRef.current)) && // 🎯 기존 방식 (fallback)
panelInfo?.currentSpot &&
(currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT ||
currentMode === SEARCH_PANEL_MODES.VOICE_RESULT)
) {
const usedHistoryOnTop = currentIsOnTop && isOnTopChange?.becameOnTop;
if (DEBUG_MODE) {
dlog(
'[Focus] 개선된 방식으로 DetailPanel 복귀 감지 - 이전 상품으로 포커스 이동'
);
dlog('[FOCUS] 🎯 Scenario: DETAIL_PANEL_RETURN (improved fallback)', {
currentSpot: panelInfo.currentSpot,
mode: currentMode,
fromSearchResult: currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT,
fromVoiceResult: currentMode === SEARCH_PANEL_MODES.VOICE_RESULT,
usedHistoryOnTop, // 🎯 어떤 방식으로 감지했는지
currentIsOnTop,
isOnTopChange,
});
}
return 'DETAIL_PANEL_RETURN';
}
// SearchPanel이 처음 열린 상황
// - isOnTop이 false → true로 변경되었고
// - 위의 DETAIL_PANEL_RETURN이 아닌 경우 (= currentSpot이 없거나 모드가 검색 결과 아님)
if (isOnTop && !isOnTopRef.current) {
if (DEBUG_MODE) {
dlog('[FOCUS] 🎯 Scenario: INITIAL_OPEN', {
currentSpot: panelInfo?.currentSpot,
mode: currentMode,
});
}
return 'INITIAL_OPEN';
}
// 일반 검색 결과 모드로 진입 (모드 변경으로 감지 - 가장 안정적)
// - currentMode가 SEARCH_RESULT로 변경되고
// - 이전에는 SEARCH_RESULT가 아니었으면
// - 🎯 중요: isOnTop이 변화하지 않았을 때만 (이미 SearchPanel이 열려있고 새로 검색한 경우)
// DetailPanel 복귀(isOnTop 변화)는 위의 DETAIL_PANEL_RETURN에서 먼저 처리됨
if (
isOnTop === isOnTopRef.current &&
currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT &&
currentModeRef.current !== SEARCH_PANEL_MODES.SEARCH_RESULT
) {
if (DEBUG_MODE) {
dlog('[FOCUS] 🎯 Scenario: SEARCH_RESULT_LOADED (Mode Changed)', {
themeCount: searchDatas?.theme?.length || 0,
itemCount: searchDatas?.item?.length || 0,
showCount: searchDatas?.show?.length || 0,
prevMode: currentModeRef.current,
nextMode: currentMode,
isOnTopChanged: isOnTop !== isOnTopRef.current,
});
}
return 'SEARCH_RESULT_LOADED';
}
// 새로운 음성 검색 결과 모드로 진입 (모드 변경 또는 데이터 도착으로 감지)
// - currentMode가 VOICE_RESULT 이고, 새로운 ShopperHouse 데이터가 도착했으면
// - 🎯 중요: isOnTop이 변화하지 않았을 때만 (이미 SearchPanel이 열려있고 새로 검색한 경우)
// DetailPanel 복귀(isOnTop 변화)는 위의 DETAIL_PANEL_RETURN에서 먼저 처리됨
if (
isOnTop === isOnTopRef.current &&
currentMode === SEARCH_PANEL_MODES.VOICE_RESULT &&
shopperHouseData &&
// 🎯 [개선] 모드 변경 OR 새로운 데이터 도착 감지
(currentModeRef.current !== SEARCH_PANEL_MODES.VOICE_RESULT ||
shopperHouseDataRef.current !== shopperHouseData)
) {
if (DEBUG_MODE) {
dlog('[FOCUS] 🎯 Scenario: NEW_SEARCH_LOADED (Voice Result Mode)', {
itemCount: shopperHouseData?.results?.[0]?.docs?.length || 0,
prevMode: currentModeRef.current,
nextMode: currentMode,
isOnTopChanged: isOnTop !== isOnTopRef.current,
modeChanged:
currentModeRef.current !== SEARCH_PANEL_MODES.VOICE_RESULT,
dataChanged: shopperHouseDataRef.current !== shopperHouseData,
});
}
return 'NEW_SEARCH_LOADED';
}
// Voice Overlay가 닫힌 상황
if (!isVoiceOverlayVisible && isVoiceOverlayVisibleRef.current) {
if (DEBUG_MODE) {
dlog('[FOCUS] 🎯 Scenario: VOICE_OVERLAY_CLOSED', {
hasShopperHouseData: !!shopperHouseData,
});
}
// 🎯 [중요] 새로운 음성 검색 결과가 도착했으면 NEW_SEARCH_LOADED 우선 처리
// 이렇게 하면 VOICE_OVERLAY_CLOSED 시나리오에서 TInput으로 가는 것을 방지
if (shopperHouseData && currentMode === SEARCH_PANEL_MODES.VOICE_RESULT) {
if (DEBUG_MODE) {
dlog(
'[FOCUS] 🔄 VOICE_OVERLAY_CLOSED + new data → NEW_SEARCH_LOADED 우선 처리'
);
}
return 'NEW_SEARCH_LOADED';
}
return 'VOICE_OVERLAY_CLOSED';
}
// Search Overlay가 닫힌 상황
// - isSearchOverlayVisible이 true → false로 변경되고
// - 검색이 수행되지 않았거나 SearchPanel이 SEARCH_RESULT 모드가 아닌 경우
if (!isSearchOverlayVisible && isSearchOverlayVisibleRef.current) {
if (DEBUG_MODE) {
dlog('[FOCUS] 🎯 Scenario: SEARCH_OVERLAY_CLOSED', {
isSearchOverlayVisible,
prevIsSearchOverlayVisible: isSearchOverlayVisibleRef.current,
currentMode,
searchPerformed,
hasSearchResults: !!(
searchDatas?.theme?.length > 0 ||
searchDatas?.item?.length > 0 ||
searchDatas?.show?.length > 0
),
});
}
return 'SEARCH_OVERLAY_CLOSED';
}
// 변화 없음
return 'NO_CHANGE';
}, [
isOnTop,
currentIsOnTop, // 🎯 usePanelHistory의 isOnTop 정보
isOnTopChange, // 🎯 isOnTop 변화 정보
panelInfo?.currentSpot,
currentMode, // 모드 변경으로 검색 결과 로드 감지
shopperHouseData,
isVoiceOverlayVisible,
isSearchOverlayVisible,
isReturningFromDetailPanel, // 🎯 usePanelHistory 기반 DetailPanel 복귀 감지
currentPanel,
previousPanel,
DEBUG_MODE,
]);
/**
* 🎯 [포커스 로직 통합]
* 현재 시나리오에 따라 다음 포커스 타겟을 결정
* Spotlight.focus()를 직접 호출하지 않고, "어디로 포커스할 것인가"만 결정
*/
const determineFocusTarget = useCallback(() => {
const scenario = analyzeCurrentScenario();
if (scenario === 'NO_CHANGE') {
return null;
}
switch (scenario) {
case 'DETAIL_PANEL_RETURN': {
// DetailPanel에서 복귀 → 이전 포커스된 상품으로 복원 (우선순위 최상)
// 🎯 [중요] usePanelHistory의 currentSpot 우선 사용, fallback으로 panelInfo.currentSpot 사용
let currentSpot = null;
// 1. usePanelHistory의 currentSpot 우선 사용
if (
isReturningFromDetailPanel &&
currentPanel?.panelInfo?.currentSpot
) {
currentSpot = currentPanel.panelInfo.currentSpot;
if (DEBUG_MODE) {
dlog('[FOCUS] 🎯 usePanelHistory currentSpot 사용:', currentSpot);
}
}
// 2. fallback: 기존 panelInfo.currentSpot 사용
else if (panelInfo?.currentSpot) {
currentSpot = panelInfo.currentSpot;
if (DEBUG_MODE) {
dlog(
'[FOCUS] 🔄 fallback으로 panelInfo.currentSpot 사용:',
currentSpot
);
}
}
if (currentSpot && currentSpot.startsWith('searchItemContents')) {
if (DEBUG_MODE) {
dlog(
'[FOCUS] 🎯 DETAIL_PANEL_RETURN: 이전 상품으로 포커스 복원:',
currentSpot
);
}
return currentSpot;
} else {
if (DEBUG_MODE) {
dlog(
'[FOCUS] ⚠️ DETAIL_PANEL_RETURN: currentSpot이 유효하지 않음, fallback으로 이동:',
{
currentSpot,
fallback: SPOTLIGHT_IDS.SEARCH_INPUT_BOX,
}
);
}
return SPOTLIGHT_IDS.SEARCH_INPUT_BOX;
}
}
case 'INITIAL_OPEN':
// SearchPanel 처음 열림 → TInput으로 포커스
return SPOTLIGHT_IDS.SEARCH_INPUT_BOX;
case 'NEW_SEARCH_LOADED':
// 음성 검색 결과 로드됨 → 첫 번째 상품으로 포커스
return 'searchItemContents0';
case 'SEARCH_RESULT_LOADED':
// 일반 검색 결과 로드됨 → 첫 번째 상품으로 포커스
return 'searchItemContents0';
case 'VOICE_OVERLAY_CLOSED':
// Voice Overlay 닫힘 → ShopperHouse 데이터 있으면 상품, 없으면 TInput
if (shopperHouseData) {
return 'searchItemContents0';
}
return SPOTLIGHT_IDS.SEARCH_INPUT_BOX;
case 'SEARCH_OVERLAY_CLOSED':
// 🎯 SearchInputOverlay 닫힘 (ESC/Back) → 항상 TInputSimple으로 포커스
// SearchInputOverlay에서 검색을 실행하면 isSearchOverlayVisible이 false로 설정되고
// 동시에 검색 결과에 따라 모드가 변경되므로, 이 케이스는 검색어 선택 후 닫을 때만 발생
if (DEBUG_MODE) {
dlog(
'[FOCUS] 🎯 Scenario: SEARCH_OVERLAY_CLOSED - TInputSimple으로 포커스'
);
}
return SPOTLIGHT_IDS.SEARCH_INPUT_BOX;
default:
return null;
}
}, [
analyzeCurrentScenario,
panelInfo?.currentSpot,
shopperHouseData,
DEBUG_MODE,
isReturningFromDetailPanel,
currentPanel,
currentIsOnTop,
isOnTopChange,
]);
/**
* 🎯 [Phase 2] VoiceInputOverlay → SearchInputOverlay 전환 콜백
* VoiceInputOverlay의 TInputSimple에서 Enter/마우스 클릭 시 호출
*/
const handleTransitionToSearchInput = useCallback(() => {
if (DEBUG_MODE) {
dlog('[SearchPanel] 🔄 handleTransitionToSearchInput 호출');
}
// Redux Thunk 액션으로 모든 전환 로직 처리
dispatch(
transitionToSearchInputOverlay({
setIsVoiceOverlayVisible,
setIsSearchOverlayVisible,
Spotlight,
})
);
}, [DEBUG_MODE, dispatch]);
/**
* Search overlay close handler
*/
const handleSearchOverlayClose = useCallback(() => {
dlog('[DEBUG] 🚪 handleSearchOverlayClose 호출됨 - 직접 확인!', {
timestamp: new Date().toISOString(),
});
if (DEBUG_MODE) {
dlog('[DEBUG] 🚪 SearchInputOverlay closing');
}
const hasSearchResults =
(searchDatas?.theme?.length || 0) > 0 ||
(searchDatas?.item?.length || 0) > 0 ||
(searchDatas?.show?.length || 0) > 0;
// 🎯 SearchInputOverlay 닫힘 후 TInputSimple으로 포커스 이동을 위한 플래그 설정
dlog('[DEBUG] setShouldFocusSearchInput(true) 설정 직전', {
timestamp: new Date().toISOString(),
});
setShouldFocusSearchInput(true);
dlog('[DEBUG] setShouldFocusSearchInput(true) 설정됨!', {
timestamp: new Date().toISOString(),
});
// 🎯 [포커스 로직 통합] 포커스는 상태 변경(isSearchOverlayVisible)에 의해 자동으로 처리됨
setIsSearchOverlayVisible(false);
// SEARCH_RESULT 모드에서도 검색어를 유지하기 위해, 검색이 수행되지 않은 경우에만 초기화
if (!searchPerformed && !hasSearchResults) {
setSearchQuery('');
}
}, [DEBUG_MODE, searchDatas, searchPerformed]);
/**
* Voice overlay close handler
*/
const handleVoiceOverlayClose = useCallback(() => {
if (DEBUG_MODE) {
dlog(
'🚪 [DEBUG][SearchPanel] handleVoiceOverlayClose called, setting isVoiceOverlayVisible to FALSE'
);
}
// ✨ Redux 정리는 VoiceInputOverlay.handleClose()에서 처리함
// VoiceInputOverlay가 닫히는 순간 clearShopperHouseData()를 호출하면,
// SearchPanel에서 shopperHouseData를 표시할 수 없으므로 여기서는 정리하지 않음
setVoiceOverlayMode(VOICE_MODES.PROMPT);
setVoiceOverlayResponseText('');
setIsVoiceOverlayBubbleSearch(false);
// 🎯 [포커스 로직 통합] 포커스는 상태 변경(isVoiceOverlayVisible)에 의해 자동으로 처리됨
setIsVoiceOverlayVisible(false);
setShouldFocusVoiceResult(false);
}, []);
const handleHowAboutTheseQueryClick = useCallback(
(rawQuery) => {
if (!rawQuery) return;
const trimmedQuery = typeof rawQuery === 'string' ? rawQuery.trim() : '';
if (!trimmedQuery) return;
// 검색 입력 필드에도 동일한 쿼리를 표시
setSearchQuery(trimmedQuery);
// Voice overlay를 RESPONSE 모드로 전환하여 대기 화면 표시
// setVoiceOverlayResponseText(trimmedQuery);
// setVoiceOverlayMode(VOICE_MODES.RESPONSE);
// setIsVoiceOverlayBubbleSearch(true);
// setIsVoiceOverlayVisible(true);
// setShouldFocusVoiceResult(false);
// API 호출 전에 이전 데이터 초기화
setIsShopperHousePending(true);
// 🎯 REF 활용: clearShopperHouseData() 전에 현재 데이터를 ref에 임시 저장
shopperHouseDataRef.current = shopperHouseData;
dispatch(clearShopperHouseData());
// Redux state 업데이트를 위해 약간의 지연 후 API 호출
setTimeout(() => {
dlog('[HowAboutThese] 🔄 Redux 업데이트 후 API 호출');
dispatch(getShopperHouseSearch(trimmedQuery, shopperHouseSearchId));
}, 50); // 50ms 지연
// 🎯 [포커스 로직 통합] 검색어만 업데이트
// 포커스는 shopperHouseData 변경에 의해 자동으로 처리됨
dispatch(
updatePanel({
name: panel_names.SEARCH_PANEL,
panelInfo: {
searchVal: trimmedQuery,
tab: 0,
},
})
);
},
[dispatch, shopperHouseSearchId]
);
// ShopperHouse API 응답 도착 시 로딩 상태 해제
useEffect(() => {
if (shopperHouseData) {
setIsShopperHousePending(false);
}
}, [shopperHouseData]);
// ShopperHouse API 오류 발생 시 로딩 상태 해제
useEffect(() => {
if (shopperHouseError) {
setIsShopperHousePending(false);
}
}, [shopperHouseError]);
// ✨ [Phase 3] handleInputIconClick 제거 (돋보기 아이콘 기능 비활성화)
// /**
// * ✨ [Phase 3] TInput icon click handler (showVirtualKeyboard 제거)
// */
// const handleInputIconClick = useCallback(() => {
// // 돋보기 아이콘 클릭 시 검색 제출
// handleSearchSubmit(searchQuery);
// }, [handleSearchSubmit, searchQuery]);
/**
* Microphone button keydown handler
*/
const handleMicKeyDown = useCallback(
(e) => {
if (e.key === 'Enter') {
onClickMic();
}
// ❌ [251026] DEPRECATED: 아래쪽 화살표 키 처리 - Spotlight 속성으로 대체됨
// Spotlight의 data-spotlight-down 속성으로 처리하도록 변경
// 기존 코드 보존 (향후 필요시 참고용)
/*
if (e.key === 'ArrowDown' || e.key === 'Down') {
e.preventDefault();
setTimeout(() => {
Spotlight.focus('howAboutThese-seeMore');
}, 0);
return;
}
*/
},
[onClickMic]
);
/**
* ✨ [Phase 3] ProductCard 렌더링 함수 (renderModeContent 이전에 정의)
* renderModeContent에서 사용되므로 dependency 순서를 맞추기 위해 먼저 정의
*/
const renderItem = useCallback(
({ index, ...rest }) => {
const {
showBrandLogo = true,
showBrandName = true,
showProductTitle = true,
bgImgPath,
curationId,
curationNm,
patncLogoPath,
patncNm,
patnrId,
} = hotPicksForYou[index];
const onClick = () => {
dispatch(
pushPanel({
name: panel_names.HOT_PICKS_PANEL,
panelInfo: {
curationId: curationId,
patnrId: patnrId,
},
})
);
dispatch(popPanel(panel_names.SEARCH_PANEL));
};
return (
<SpottableProduct
key={`product-${index}`}
className={css.productCard}
spotlightId={`product-${index}`}
onClick={onClick}
{...rest}
>
<div className={css.productImageWrapper}>
<SafeImage
src={bgImgPath}
alt={curationNm}
className={css.productImage}
/>
</div>
<div className={css.productInfo}>
{showBrandLogo && (
<div className={css.productBrandWrapper}>
<SafeImage
src={patncLogoPath}
alt={patncNm}
className={css.brandLogo}
/>
</div>
)}
<div className={css.productDetails}>
{showBrandName && <div className={css.brandName}>{patncNm}</div>}
{showProductTitle && (
<div className={css.productTitle}>{curationNm}</div>
)}
</div>
</div>
</SpottableProduct>
);
},
[hotPicksForYou, dispatch, SafeImage]
);
const handleClick = useCallback(
(patnrId, prdtId) => {
dispatch(
pushPanel({
name: panel_names.DETAIL_PANEL,
panelInfo: { patnrId, prdtId },
})
);
},
[dispatch]
);
const renderTsvItem = useCallback(
({ index, ...rest }) => {
const { offerInfo, prdtId, imgUrl, patnrId, prdtNm, priceInfo } =
tsvInfo[index];
return (
<TItemCardNew
imageAlt={prdtNm}
imageSource={imgUrl}
onClick={() => {
handleClick(patnrId, prdtId);
}}
offerInfo={offerInfo}
priceInfo={priceInfo}
productId={prdtId}
productName={prdtNm}
spotlightId={
'searchMain-tsvInfo-spotlightId-' + removeDotAndColon(prdtId)
}
{...rest}
/>
);
},
[tsvInfo, handleClick]
);
/**
* ✨ [Phase 2] 모드별 콘텐츠 렌더링 (VoiceInputOverlay의 renderModeContent와 동일한 패턴)
* SearchPanel의 현재 모드에 따라 표시할 콘텐츠를 결정합니다.
*/
const renderModeContent = useMemo(() => {
switch (currentMode) {
// SEARCH_RESULT: 정규 검색 결과 표시
case SEARCH_PANEL_MODES.SEARCH_RESULT:
return (
<>
{searchDatas?.theme?.length > 0 ||
searchDatas?.item?.length > 0 ||
searchDatas?.show?.length > 0 ? (
<SearchResultsNew
themeInfo={searchDatas.theme}
itemInfo={searchDatas.item}
showInfo={searchDatas.show}
shopperHouseInfo={undefined}
keywordClick={handleKeywordClick}
panelInfo={panelInfo}
cbChangePageRef={cbChangePageRef}
onSearchInputFocus={searchInputFocused}
/>
) : (
<NoSearchResults />
)}
</>
);
// VOICE_RESULT: 음성 검색 결과 표시 (ShopperHouse 데이터 포함)
case SEARCH_PANEL_MODES.VOICE_RESULT:
return (
<SearchResultsNew
themeInfo={searchDatas.theme}
itemInfo={searchDatas.item}
showInfo={searchDatas.show}
shopperHouseInfo={shopperHouseData}
preShopperHouseInfo={preShopperHouseData}
fallbackShopperHouseData={shopperHouseDataRef.current}
shopperHouseSearchId={shopperHouseSearchId}
shopperHouseRelativeQueries={shopperHouseRelativeQueries}
keywordClick={handleKeywordClick}
panelInfo={panelInfo}
onRelativeQueryClick={handleHowAboutTheseQueryClick}
cbChangePageRef={cbChangePageRef}
onSearchInputFocus={searchInputFocused}
/>
);
// INITIAL, INPUT_FOCUSED: 초기 상태 또는 입력 중 - 모든 섹션 표시
case SEARCH_PANEL_MODES.INITIAL:
case SEARCH_PANEL_MODES.INPUT_FOCUSED:
case SEARCH_PANEL_MODES.VOICE_INPUT:
case SEARCH_PANEL_MODES.SEARCH_INPUT:
default:
return (
<ContainerBasic className={css.contentContainer}>
{/* 최근 검색어 섹션 (일반검색만 표시) */}
{normalSearches && normalSearches.length > 0 && (
<>
<SectionContainer
className={css.section}
data-wheel-point="true"
spotlightId={SPOTLIGHT_IDS.RECENT_SEARCHES_SECTION}
>
<div className={css.sectionHeader}>
<div className={css.sectionIndicator} />
<div className={css.sectionTitle}>Your Recent Searches</div>
</div>
<div className={css.keywordList}>
{normalSearches.map((historyItem, index) => (
<SpottableKeyword
key={`recent-${historyItem.timestamp}`}
className={css.keywordButton}
onClick={createHistoryKeywordClickHandler(historyItem)}
spotlightId={`recent-keyword-${index}`}
>
{historyItem.query}
</SpottableKeyword>
))}
</div>
</SectionContainer>
</>
)}
{/* 인기 검색어 섹션 */}
{topSearchs && topSearchs.length > 0 && (
<>
<SectionContainer
className={css.section}
data-wheel-point="true"
spotlightId={SPOTLIGHT_IDS.TOP_SEARCHES_SECTION}
>
<div className={css.sectionHeader}>
<div className={css.sectionIndicator} />
<div className={css.sectionTitle}>Top Searches</div>
</div>
<div className={css.keywordList}>
{topSearchs.map((item, index) => (
<SpottableKeyword
key={`top-${index}`}
className={css.keywordButton}
onClick={createKeywordClickHandler(item.query)}
spotlightId={`top-keyword-${index}`}
>
{item.query}
</SpottableKeyword>
))}
</div>
</SectionContainer>
</>
)}
{/* 인기 브랜드 섹션 */}
{popularBrands && popularBrands.length > 0 && (
<>
<SectionContainer
className={css.section}
data-wheel-point="true"
spotlightId={SPOTLIGHT_IDS.POPULAR_BRANDS_SECTION}
>
<div className={css.sectionHeader}>
<div className={css.sectionIndicator} />
<div className={css.sectionTitle}>Popular Brands</div>
</div>
<div className={css.keywordList}>
{popularBrands.map((item, index) => (
<SpottableKeyword
key={`brand-${index}`}
className={css.keywordButton}
onClick={createKeywordClickHandler(item.query)}
spotlightId={`brand-${index}`}
>
{item.query}
</SpottableKeyword>
))}
</div>
</SectionContainer>
</>
)}
{/* Hot Picks for You 섹션 */}
{hotPicksForYou && hotPicksForYou.length > 0 && (
<>
<SectionContainer
className={css.hotpicksSection}
data-wheel-point="true"
spotlightId={SPOTLIGHT_IDS.HOT_PICKS_SECTION}
>
<div className={css.sectionHeader}>
<div className={css.sectionIndicator} />
<div className={css.sectionTitle}>Hot Picks for You</div>
</div>
<div className={css.productList}>
{hotPicksForYou && hotPicksForYou.length > 0 && (
<TVirtualGridList
dataSize={hotPicksForYou.length}
direction="horizontal"
renderItem={renderItem}
itemWidth={416}
itemHeight={436}
spacing={20}
/>
)}
</div>
</SectionContainer>
</>
)}
{tsvInfo && tsvInfo.length > 0 && (
<>
<SectionContainer
className={css.todayDealSection}
data-wheel-point="true"
spotlightId={SPOTLIGHT_IDS.TODAY_DEALS_SECTION}
>
<div className={css.sectionHeader}>
<div className={css.sectionIndicator} />
<div className={css.sectionTitle}>Today's Deals</div>
</div>
<div className={css.itemList}>
<TVirtualGridList
dataSize={tsvInfo.length}
direction="horizontal"
renderItem={renderTsvItem}
itemWidth={324}
itemHeight={438}
spacing={18}
/>
</div>
</SectionContainer>
</>
)}
</ContainerBasic>
);
}
}, [
currentMode,
searchDatas?.theme?.length,
searchDatas?.item?.length,
searchDatas?.show?.length,
shopperHouseData?.results?.[0]?.docs?.length,
normalSearches?.length,
topSearchs?.length,
popularBrands?.length,
hotPicksForYou?.length,
handleKeywordClick,
createKeywordClickHandler,
createHistoryKeywordClickHandler,
renderItem,
panelInfo?.currentSpot,
// ✅ [251026] ADD: searchInputFocused를 의존성에 추가 - HowAboutThese 포커스 관리
searchInputFocused,
]);
/**
* 컴포넌트 최초 마운트 시 Voice Search 상태 초기화
*/
useEffect(() => {
// dispatch(resetVoiceSearch());
dispatch(getSearchMain());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
* ✨ [Phase 1] 모드 자동 결정 로직
* 여러 상태들을 감시하여 적절한 SearchPanel 모드를 자동으로 결정합니다.
* VoiceInputOverlay의 mode 기반 아키텍처와 동일한 방식으로 작동합니다.
*/
useEffect(() => {
let nextMode = SEARCH_PANEL_MODES.INITIAL;
if (DEBUG_MODE) {
dlog('[DEBUG]-MODE DECISION useEffect running', {
isVoiceOverlayVisible,
hasShopperHouseData: !!shopperHouseData,
shopperHouseData_detail: shopperHouseData ? 'EXISTS' : 'NULL',
searchPerformed,
searchQuery,
hasSearchResults: !!(
searchDatas?.theme?.length > 0 ||
searchDatas?.item?.length > 0 ||
searchDatas?.show?.length > 0
),
isSearchOverlayVisible,
isShopperHousePending,
currentMode,
});
}
// 우선순위 1: 음성 입력 오버레이가 열려있으면 VOICE_INPUT 모드
if (isVoiceOverlayVisible) {
if (DEBUG_MODE) {
dlog('[DEBUG]-MODE: isVoiceOverlayVisible is TRUE VOICE_INPUT');
}
nextMode = SEARCH_PANEL_MODES.VOICE_INPUT;
}
// 우선순위 2: 음성 검색 결과가 있으면 VOICE_RESULT 모드
else if (shopperHouseData || isShopperHousePending) {
if (DEBUG_MODE) {
dlog(
'[DEBUG]-MODE: shopperHouseData EXISTS or pending VOICE_RESULT',
{
hasData: !!shopperHouseData,
isPending: isShopperHousePending,
}
);
}
nextMode = SEARCH_PANEL_MODES.VOICE_RESULT;
}
// 우선순위 3: 검색 결과가 있으면 SEARCH_RESULT 모드
else if (
(searchPerformed && searchQuery !== null) ||
searchDatas?.theme?.length > 0 ||
searchDatas?.item?.length > 0 ||
searchDatas?.show?.length > 0
) {
if (DEBUG_MODE) {
dlog('[DEBUG]-MODE: searchResults EXISTS SEARCH_RESULT');
}
nextMode = SEARCH_PANEL_MODES.SEARCH_RESULT;
}
// 우선순위 4: 검색 입력 오버레이가 열려있으면 SEARCH_INPUT 모드
else if (isSearchOverlayVisible) {
if (DEBUG_MODE) {
dlog('[DEBUG]-MODE: isSearchOverlayVisible is TRUE SEARCH_INPUT');
}
nextMode = SEARCH_PANEL_MODES.SEARCH_INPUT;
}
// 우선순위 5: 초기 상태 (기본값)
else {
if (DEBUG_MODE) {
dlog('[DEBUG]-MODE: No condition met INITIAL');
}
nextMode = SEARCH_PANEL_MODES.INITIAL;
}
// 모드가 변경되었을 때만 업데이트
if (nextMode !== currentMode) {
if (DEBUG_MODE) {
dlog(
`[DEBUG]-VOICE_RESULT 🔀 Mode changed: ${currentMode} → ${nextMode}`,
{
isVoiceOverlayVisible,
shopperHouseData: !!shopperHouseData,
isShopperHousePending,
searchPerformed,
searchQuery,
hasSearchResults: !!(
searchDatas?.theme?.length > 0 ||
searchDatas?.item?.length > 0 ||
searchDatas?.show?.length > 0
),
isSearchOverlayVisible,
inputFocus,
}
);
}
setCurrentMode(nextMode);
} else {
if (DEBUG_MODE) {
dlog('[DEBUG]-MODE: Mode unchanged -', currentMode);
}
}
}, [
isVoiceOverlayVisible,
shopperHouseData,
isShopperHousePending,
searchPerformed,
searchQuery,
searchDatas,
isSearchOverlayVisible,
inputFocus,
currentMode,
]);
// ✨ [Phase 3] 제거됨: isVoiceOverlayVisible 상태 변화 추적 (currentMode로 대체됨)
/**
* `searchQuery` 가 falsy 값일 경우, search관련 store를 초기화해주는 이펙트
* TODO: 현재 검색어가 빈 스트링일 경우, `resetSearch`가 발동해서 searchData가 reset되는 현상이 있음, 조건이 변경되어야 한다.
*/
useEffect(() => {
if (DEBUG_MODE) {
dlog('[DEBUG]-searchQuery useEffect:', { searchQuery });
}
if (!searchQuery) {
if (DEBUG_MODE) {
dlog('[DEBUG]-VOICE_RESULT: searchQuery is empty, calling resetSearch');
}
dispatch(resetSearch());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery]);
// ✨ [Phase 4] SearchInputOverlay 자동 표시 useEffect 제거
// SearchInputOverlay는 이제 Enter/OK 키로만 표시됨
// /**
// * `SearchOverlayVisible` 상태를 설정하여, `SearchInputOverlay` 컴포는트의 display 설정하는 이펙트
// */
// useEffect(() => {
// if (searchQuery !== null && inputFocus === true) {
// setIsSearchOverlayVisible(true);
// } else {
// // setIsSearchOverlayVisible(false);
// }
// }, [inputFocus, searchQuery]);
/**
* focus 용도
* 추후 확인 (현재 삭제하지 말 것)
*/
// useEffect(() => {
// if (panelInfo && isOnTop) {
// if (panelInfo.currentSpot && firstSpot) {
// Spotlight.focus(panel_names.SEARCH_PANEL);
// }
// }
// }, [panelInfo, isOnTop, firstSpot]);
/**
* 🎯 [포커스 로직 통합]
* 모든 포커스 결정을 단일 useEffect에서 처리
* 상태 변경 감지 → 시나리오 분석 → 포커스 타겟 결정 → 포커스 적용
*
* 이전 Ref 값 업데이트는 useEffect 내에서 수행하여
* 다음 렌더링 사이클에 올바른 비교가 이루어지도록 함
*/
useEffect(() => {
if (DEBUG_MODE) {
dlog('[DEBUG][Focus] Focus useEffect 호출됨 - 상태값 확인:', {
isOnTop,
panelInfo: panelInfo,
currentMode,
shopperHouseData: !!shopperHouseData,
});
}
const scenario = analyzeCurrentScenario();
const isDetailReturnScenario = scenario === 'DETAIL_PANEL_RETURN';
// DETAIL_PANEL_RETURN 시나리오가 아니면 다음 복귀를 위해 플래그 초기화
if (!isDetailReturnScenario && detailReturnHandledRef.current) {
detailReturnHandledRef.current = false;
}
// DETAIL_PANEL_RETURN이 반복 감지되었지만 이미 처리한 경우에는 조기 종료
if (isDetailReturnScenario && detailReturnHandledRef.current) {
shopperHouseDataRef.current = shopperHouseData;
isVoiceOverlayVisibleRef.current = isVoiceOverlayVisible;
isSearchOverlayVisibleRef.current = isSearchOverlayVisible;
currentModeRef.current = currentMode;
return;
}
// 포커스 타겟 결정
const targetId = determineFocusTarget();
// DETAIL_PANEL_RETURN 처리 시작 시점에 일회성 플래그 설정
if (isDetailReturnScenario && !detailReturnHandledRef.current) {
detailReturnHandledRef.current = true;
}
// 변화 없으면 포커스 이동하지 않음
if (!targetId) {
// 이전 상태를 현재 상태로 업데이트 (다음 비교를 위해)
shopperHouseDataRef.current = shopperHouseData;
isVoiceOverlayVisibleRef.current = isVoiceOverlayVisible;
isSearchOverlayVisibleRef.current = isSearchOverlayVisible;
currentModeRef.current = currentMode;
return;
}
// 타이머 정리 (이전 타이머가 있으면)
if (unifiedFocusTimerRef.current) {
clearTimeout(unifiedFocusTimerRef.current);
}
// 🎯 [포커스 충돌 해결] 우선순위가 높은 시나리오에서는 빠른 포커스 전환 (50ms)
// DETAIL_PANEL_RETURN: DetailPanel에서 복귀 시 빠른 포커스 복원
// NEW_SEARCH_LOADED: 음성 검색 결과 로드 시 VoiceInputOverlay와 충돌 방지
// 다른 시나리오에서는 기존과 같은 지연 시간 (100ms)
const focusDelay =
scenario === 'DETAIL_PANEL_RETURN' || scenario === 'NEW_SEARCH_LOADED'
? 50
: 100;
unifiedFocusTimerRef.current = setTimeout(() => {
const targetElement = document.querySelector(
`[data-spotlight-id="${targetId}"]`
);
if (targetElement || targetId === SPOTLIGHT_IDS.SEARCH_INPUT_BOX) {
Spotlight.focus(targetId);
if (DEBUG_MODE) {
dlog('[FOCUS] 포커스 이동 완료:', {
targetId,
scenario,
hasElement: !!targetElement,
focusDelay,
timestamp: new Date().toISOString(),
});
}
} else {
if (DEBUG_MODE) {
dlog('[FOCUS] 포커스 대상 요소를 찾지 못했습니다:', {
targetId,
scenario,
timestamp: new Date().toISOString(),
});
}
// 🎯 DETAIL_PANEL_RETURN에서 요소를 찾지 못하면 fallback으로 첫 번째 상품 시도
if (
scenario === 'DETAIL_PANEL_RETURN' &&
targetId.startsWith('searchItemContents')
) {
const fallbackTarget = 'searchItemContents0';
const fallbackElement = document.querySelector(
`[data-spotlight-id="${fallbackTarget}"]`
);
if (fallbackElement) {
if (DEBUG_MODE) {
dlog(
'[FOCUS] 🔄 DETAIL_PANEL_RETURN fallback: 번째 상품으로 포커스:',
fallbackTarget
);
}
Spotlight.focus(fallbackTarget);
}
}
}
unifiedFocusTimerRef.current = null;
}, focusDelay);
// 🎯 [NEW_SEARCH_LOADED] 1초 후 다시 첫 번째 아이템으로 포커스 이동
// TInputSimple과 Mic Icon의 포커스 충돌 해결을 위해
if (
scenario === 'NEW_SEARCH_LOADED' &&
targetId === 'searchItemContents0'
) {
setTimeout(() => {
if (DEBUG_MODE) {
dlog(
'[FOCUS] 🔄 NEW_SEARCH_LOADED: 1 번째 상품으로 다시 포커스 이동'
);
}
Spotlight.focus('searchItemContents0');
}, 500); // 0.5초 후
}
// Cleanup: 컴포넌트 언마운트 또는 targetId 변경 시 타이머 정리
return () => {
if (unifiedFocusTimerRef.current) {
clearTimeout(unifiedFocusTimerRef.current);
unifiedFocusTimerRef.current = null;
}
};
}, [
isOnTop,
currentIsOnTop, // 🎯 usePanelHistory의 isOnTop 정보
isOnTopChange, // 🎯 isOnTop 변화 정보
panelInfo,
currentMode,
shopperHouseData,
isVoiceOverlayVisible,
isSearchOverlayVisible,
determineFocusTarget,
analyzeCurrentScenario,
DEBUG_MODE,
]);
/**
* 🎯 [포커스 로직 통합] Ref 값 업데이트
* 매 렌더링마다 이전 상태를 현재 상태로 업데이트
* 이를 통해 다음 useEffect에서 변화를 감지할 수 있음
*/
useEffect(() => {
shopperHouseDataRef.current = shopperHouseData;
searchDatasRef.current = searchDatas;
isVoiceOverlayVisibleRef.current = isVoiceOverlayVisible;
isSearchOverlayVisibleRef.current = isSearchOverlayVisible;
currentModeRef.current = currentMode;
}, [
shopperHouseData,
searchDatas,
isVoiceOverlayVisible,
isSearchOverlayVisible,
currentMode,
]);
/**
* 🎯 SearchInputOverlay 닫힘 후 포커스 관리
* shouldFocusSearchInput 플래그가 true로 설정되면 500ms 후 TInputSimple으로 포커스 이동
* - 명시적 플래그를 통해 SearchInputOverlay 닫힘 시나리오 처리
* - 다른 포커스 로직과 독립적으로 동작
* - 자동으로 플래그 초기화
*/
useEffect(() => {
dlog('[DEBUG] shouldFocusSearchInput useEffect 실행됨!', {
shouldFocusSearchInput,
timestamp: new Date().toISOString(),
});
if (shouldFocusSearchInput) {
let focusTimer = null;
dlog('[DEBUG] shouldFocusSearchInput === true, 타이머 설정 ...', {
timestamp: new Date().toISOString(),
});
if (DEBUG_MODE) {
dlog(
'[FOCUS] 🎯 SearchInputOverlay 닫힘 포커스 관리 useEffect 실행',
{
shouldFocusSearchInput,
timestamp: new Date().toISOString(),
}
);
}
// 500ms 후 TInputSimple에 포커스 이동
focusTimer = setTimeout(() => {
dlog('[DEBUG] 500ms 타이머 콜백 실행!', {
targetId: SPOTLIGHT_IDS.SEARCH_INPUT_BOX,
timestamp: new Date().toISOString(),
});
if (DEBUG_MODE) {
dlog(
'[FOCUS] 500ms 타이머 콜백 실행 - TInputSimple으로 포커스 이동',
{
targetId: SPOTLIGHT_IDS.SEARCH_INPUT_BOX,
timestamp: new Date().toISOString(),
}
);
}
dlog('[DEBUG] Spotlight.focus() 호출 직전', {
targetId: SPOTLIGHT_IDS.SEARCH_INPUT_BOX,
});
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
dlog('[DEBUG] Spotlight.focus() 호출 완료!', {
targetId: SPOTLIGHT_IDS.SEARCH_INPUT_BOX,
timestamp: new Date().toISOString(),
});
if (DEBUG_MODE) {
dlog('[FOCUS] Spotlight.focus() 호출 완료', {
targetId: SPOTLIGHT_IDS.SEARCH_INPUT_BOX,
timestamp: new Date().toISOString(),
});
}
dlog('[DEBUG] setShouldFocusSearchInput(false) 호출 직전', {
timestamp: new Date().toISOString(),
});
// 포커스 이동 완료 후 플래그 초기화
setShouldFocusSearchInput(false);
dlog('[DEBUG] setShouldFocusSearchInput(false) 호출 완료!', {
timestamp: new Date().toISOString(),
});
}, 500);
dlog('[DEBUG] 타이머 설정 완료 - 500ms 포커스 이동 예약됨', {
timestamp: new Date().toISOString(),
});
// Cleanup: 타이머 정리
return () => {
dlog('[DEBUG] shouldFocusSearchInput useEffect cleanup 실행', {
timestamp: new Date().toISOString(),
});
if (focusTimer) {
if (DEBUG_MODE) {
dlog(
'[FOCUS] 🧹 SearchInputOverlay 포커스 관리 useEffect cleanup - 타이머 정리',
{
timestamp: new Date().toISOString(),
}
);
}
clearTimeout(focusTimer);
}
};
}
}, [shouldFocusSearchInput, DEBUG_MODE]);
/**
* LOG 용도,
* 현재 menu 위치를 설정하여, 로그를 보내는 용도
*/
useEffect(() => {
if (isOnTop) {
let menu;
if (!searchPerformed) {
menu = LOG_MENU.SEARCH_SEARCH;
} else {
if (searchQueryRef.current) {
menu =
Object.keys(searchDatas).length > 0
? LOG_MENU.SEARCH_RESULT
: LOG_MENU.SEARCH_BEST_SELLER;
}
}
dispatch(sendLogGNB(menu));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOnTop, searchDatas, searchPerformed, searchQueryRef]);
/**
* LOG 용도,
* 검색 시 로그를 보내는 용도의 이펙트
*/
useEffect(() => {
const result = Object.values(searchDatas).reduce((acc, curr) => {
return acc + curr.length;
}, 0);
if (searchQuery) {
dispatch(
sendLogTotalRecommend({
query: searchQuery,
searchType: searchPerformed ? 'query' : 'keyword',
result: result,
contextName: LOG_CONTEXT_NAME.SEARCH,
messageId: LOG_MESSAGE_ID.SEARCH_ITEM,
})
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchDatas, searchPerformed, searchQuery]);
/**
* clean up 용도
* Cleanup all timers on component unmount
*/
useEffect(() => {
return () => {
if (spotlightResumeTimerRef.current) {
clearTimeout(spotlightResumeTimerRef.current);
spotlightResumeTimerRef.current = null;
}
if (micFocusTimerRef.current) {
clearTimeout(micFocusTimerRef.current);
micFocusTimerRef.current = null;
}
};
}, [isOnTopRef]);
/**
* clean up 용도
* unmounted 시, 패널 정보를 업데이트하는 이펙트
*/
useEffect(() => {
return () => {
const currentSearchVal = searchQueryRef.current;
const currentFocusedId = focusedContainerIdRef.current;
dispatch(
updatePanel({
name: panel_names.SEARCH_PANEL,
panelInfo: {
searchVal: currentSearchVal,
focusedContainerId: currentFocusedId,
},
})
);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
//모드 변경시 최근 검색어 새로고침.
useEffect(() => {
if (currentMode === SEARCH_PANEL_MODES.INITIAL && isOnTop) {
refreshHistory();
}
}, [currentMode, isOnTop, refreshHistory]);
return (
<TPanel
className={css.container}
handleCancel={onCancel}
spotlightId={spotlightId}
>
{/* ✨ [Phase 2] spotlightDisabled를 currentMode로 제어 */}
<TBody
className={css.tBody}
scrollable
spotlightDisabled={
!isOnTop || currentMode === SEARCH_PANEL_MODES.SEARCH_INPUT
}
>
<ContainerBasic>
{isOnTop && (
<TVerticalPagenator
className={css.tVerticalPagenator}
spotlightId={SPOTLIGHT_IDS.SEARCH_VERTICAL_PAGENATOR}
defaultContainerId={panelInfo?.focusedContainerId}
disabled={!isOnTop}
onFocusedContainerId={onFocusedContainerId}
cbChangePageRef={cbChangePageRef}
topMargin={36}
scrollable
>
{/* 검색 내용있을때 검색 부분 */}
{/* ✨ [Phase 2] 검색 입력 영역 - currentMode로 숨김 처리 */}
<InputContainer
className={classNames(
css.inputContainer,
inputFocus === true && css.inputFocus,
searchDatas &&
css.searchValue /* 이건 결과값 있을때만. 조건 추가필요 */,
(currentMode === SEARCH_PANEL_MODES.VOICE_INPUT ||
currentMode === SEARCH_PANEL_MODES.INPUT_FOCUSED) &&
css.hidden
)}
data-wheel-point="true"
spotlightId={SPOTLIGHT_IDS.SEARCH_INPUT_LAYER}
>
<div className={css.searchInputWrapper}>
<div className={classNames(css.inputWrapper)}>
<TInputSimple
className={css.inputBox}
kind={KINDS.withIcon}
icon={ICONS.search}
text={searchQuery} // ✨ [Phase 8] Overlay에서 입력받은 텍스트만 표시
alwaysShowText={
currentMode === SEARCH_PANEL_MODES.VOICE_RESULT ||
currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT
} // 🎯 VOICE_RESULT & SEARCH_RESULT 모드에서 항상 텍스트 표시
inputFocus={
currentMode === SEARCH_PANEL_MODES.INITIAL ||
currentMode === SEARCH_PANEL_MODES.VOICE_RESULT ||
currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT
} // ✅ INITIAL, VOICE_RESULT & SEARCH_RESULT 모드에서 TInputSimple 내부 포커스 활성화
onKeyDown={handleKeydown}
onMouseDown={() => {
dlog(
'[DEBUG] [SearchPanel] TInputSimple에서 마우스 클릭 감지 SearchInputOverlay 오픈'
);
setIsSearchOverlayVisible(true);
}}
spotlightId={SPOTLIGHT_IDS.SEARCH_INPUT_BOX}
// 🎯 [포커스 중첩 해결] SearchResultsContainer로 포커스 전달
// SearchResultsContainer가 Spotlight 컨테이너이므로, 포커스가 들어오면
// enterTo: 'last-focused' 설정에 의해 자동으로 HowAboutThese.small의 SEE MORE로 이동
data-spotlight-down={
currentMode === SEARCH_PANEL_MODES.VOICE_RESULT
? 'search-results-container'
: undefined
}
// 🎯 HowAboutThese 포커스 관리 - 포커스가 검색 입력 영역으로 올 때 감지
onSpotlightUp={handleSearchInputFocus}
onSpotlightLeft={handleSearchInputFocus}
forcedSpotlight="recent-keyword-0"
tabIndex={0}
spotlightBoxDisabled
onFocus={_onFocus}
onBlur={_onBlur}
placeholder="Search products or brands"
/>
</div>
{/* <SpottableMicButton
className={css.microphoneButton}
onClick={onClickMic}
onFocus={onFocusMic}
onKeyDown={handleMicKeyDown}
spotlightId={SPOTLIGHT_IDS.MICROPHONE_BUTTON}
// 🎯 [포커스 중첩 해결] SearchResultsContainer로 포커스 전달
data-spotlight-down={
currentMode === SEARCH_PANEL_MODES.VOICE_RESULT
? 'search-results-container'
: undefined
}
// 🎯 HowAboutThese 포커스 관리 - 포커스가 마이크 버튼으로 올 때 감지
onSpotlightUp={handleSearchInputFocus}
onSpotlightLeft={handleSearchInputFocus}
>
<div className={css.microphoneCircle}>
<SafeImage src={micIcon} alt="Microphone" className={css.microphoneIcon} />
</div>
</SpottableMicButton> */}
</div>
</InputContainer>
{/* 검색내용이 존재하고, 인풋창에 포커스가 가서 노출 시작 searchInputOverlay 변경중*/}
{/* {inputFocus === true &&
(searchDatas?.item?.length > 0 ||
searchDatas?.show?.length > 0) && (
<>
<div className={css.overLay}></div>
<div className={css.overLayRecent}>
{recentResultSearches.map((keyword, index) => (
<SpottableKeyword
key={`recentResult-${index}`}
className={css.keywordButton}
onClick={() => handleKeywordClick(keyword)}
spotlightId={`recent-Resultkeyword-${index}`}
>
{keyword}
</SpottableKeyword>
))}
</div>
</>
)} */}
{/* 검색내용이 존재하고, 인풋창에 포커스가 가서 노출 끝! */}
{/* ✨ [Phase 2] 모드별 콘텐츠 - renderModeContent useMemo에서 관리 */}
{renderModeContent}
</TVerticalPagenator>
)}
</ContainerBasic>
</TBody>
{/* ✨ [Phase 3] Virtual Keyboard - 현재 비활성화됨 (VirtualKeyboardContainer 미사용) */}
{/* ✨ [Phase 2] Voice Input Overlay - 전환 콜백 추가 */}
<VoiceInputOverlay
isVisible={isVoiceOverlayVisible}
onClose={handleVoiceOverlayClose}
mode={voiceOverlayMode}
suggestions={voiceSuggestions}
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
onSearchSubmit={handleSearchSubmit}
onTransitionToSearchInput={handleTransitionToSearchInput}
isVoiceResultMode={currentMode === SEARCH_PANEL_MODES.VOICE_RESULT}
externalResponseText={voiceOverlayResponseText}
isExternalBubbleSearch={isVoiceOverlayBubbleSearch}
shopperHouseData={shopperHouseData} // 🎯 [포커스 충돌 해결] 음성 검색 결과 데이터 전달
/>
{/* ✨ [Phase 2] Search Input Overlay - isVisible 감지로 전환 자동 감지 */}
<SearchInputOverlay
isVisible={isSearchOverlayVisible}
onClose={handleSearchOverlayClose}
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
onSearchSubmit={handleSearchSubmit}
handleClick={handleKeywordClick}
/>
</TPanel>
);
}