2352 lines
81 KiB
JavaScript
2352 lines
81 KiB
JavaScript
// 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>
|
||
);
|
||
}
|