[251103] fix: shopperHouse API오류처리 및 fallback Data표시 개선

🕐 커밋 시간: 2025. 11. 03. 12:25:18

📊 변경 통계:
  • 총 파일: 9개
  • 추가: +315줄
  • 삭제: -26줄

📁 추가된 파일:
  + code/

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/actions/actionTypes.js
  ~ com.twin.app.shoptime/src/actions/searchActions.js
  ~ com.twin.app.shoptime/src/reducers/searchReducer.js
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.v2.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchResults.new.v2.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchResults.new.v2.module.less
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceResponse.jsx

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx (javascript):
    🔄 Modified: clearAllTimers()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceResponse.jsx (javascript):
     Added: VoiceResponse()
    🔄 Modified: SpotlightContainerDecorator()

🔧 주요 변경 내용:
  • 타입 시스템 안정성 강화
  • 핵심 비즈니스 로직 개선
This commit is contained in:
2025-11-03 12:25:18 +09:00
parent 1bbe60a801
commit e661a18458
9 changed files with 546 additions and 293 deletions

1
code Submodule

Submodule code added at c2ac8e3c01

View File

@@ -187,6 +187,8 @@ export const types = {
SET_SEARCH_INIT_PERFORMED: 'SET_SEARCH_INIT_PERFORMED',
UPDATE_SEARCH_TIMESTAMP: 'UPDATE_SEARCH_TIMESTAMP',
SET_SHOPPERHOUSE_ERROR: 'SET_SHOPPERHOUSE_ERROR',
SHOW_SHOPPERHOUSE_ERROR: 'SHOW_SHOPPERHOUSE_ERROR',
HIDE_SHOPPERHOUSE_ERROR: 'HIDE_SHOPPERHOUSE_ERROR',
// 🎯 [Phase 1] SearchPanel 모드 제어 명령
SWITCH_TO_SEARCH_INPUT_OVERLAY: 'SWITCH_TO_SEARCH_INPUT_OVERLAY',

View File

@@ -99,8 +99,13 @@ let getShopperHouseSearchKey = null;
export const getShopperHouseSearch =
(query, searchId = null) =>
(dispatch, getState) => {
// ✅ 빈 query 체크 - API 호출 방지
if (!query || query.trim() === '') {
console.log('[ShopperHouse] ⚠️ 빈 쿼리 - API 호출 건너뜀');
return;
}
// 이전 데이터 초기화 -> shopperHouseData만 초기화
// 이전 데이터 초기화 -> shopperHouseData만 초기화
// dispatch({ type: types.CLEAR_SHOPPERHOUSE_DATA });
// 새로운 검색 시작 - 고유 키 생성
@@ -190,7 +195,10 @@ export const getShopperHouseSearch =
const elapsedTime = ((new Date().getTime() - currentSearchKey) / 1000).toFixed(2);
console.log('*[ShopperHouseAPI] ✅ onSuccess - API 응답 성공');
console.log('*[ShopperHouseAPI] ├─ searchId:', receivedSearchId === null ? '(NULL)' : receivedSearchId);
console.log(
'*[ShopperHouseAPI] ├─ searchId:',
receivedSearchId === null ? '(NULL)' : receivedSearchId
);
console.log('*[ShopperHouseAPI] ├─ 상품 개수:', productCount);
console.log('*[ShopperHouseAPI] ├─ relativeQueries:', relativeQueries || '(없음)');
console.log('*[ShopperHouseAPI] ├─ 소요 시간:', elapsedTime + '초');
@@ -253,6 +261,25 @@ export const getShopperHouseSearch =
console.log('*[ShopperHouseAPI] ├─ errorMessage:', errorMessage);
console.log('*[ShopperHouseAPI] └─ retMsg:', error?.data?.retMsg || '(없음)');
// ✅ API 실패 시 모든 데이터 정리
console.log('*[ShopperHouseAPI] 🧹 API 실패 - shopperHouse 데이터 정리');
dispatch(clearShopperHouseData());
// ✅ 사용자에게 실패 알림 표시
dispatch(
showShopperHouseError({
message: '검색에 실패했습니다. 다시 시도해주세요.',
type: 'API_FAILURE',
originalError: {
status: status,
retCode: retCode,
retMsg: error?.data?.retMsg,
errorMessage: errorMessage,
},
})
);
// 기존 에러 정보도 저장 (디버깅용)
dispatch(
setShopperHouseError({
message: errorMessage || 'Unknown API error',
@@ -281,6 +308,32 @@ export const getShopperHouseSearch =
console.log('*[ShopperHouseAPI] ├─ searchId:', searchId === null ? '(NULL)' : searchId);
console.log('*[ShopperHouseAPI] └─ timestamp:', new Date().toISOString());
// 🔧 [테스트용] API 실패 시뮬레이션 스위치
const SIMULATE_API_FAILURE = false; // ⭐ 이 값을 true로 변경하면 실패 시뮬레이션
if (SIMULATE_API_FAILURE) {
console.log('🧪 [TEST] API 실패 시뮬레이션 활성화 - 2초 후 실패 응답');
// 2초 후 실패 시뮬레이션
setTimeout(() => {
const simulatedError = {
status: 500,
retCode: 500,
retMsg: '시뮬레이션된 API 실패',
message: 'Simulated API failure for testing',
data: {
retCode: 500,
retMsg: '시뮬레이션된 서버 오류',
},
};
console.log('🧪 [TEST] 시뮬레이션된 실패 응답 전송');
onFail(simulatedError);
}, 2000); // 2초 딜레이
return; // 실제 API 호출하지 않음
}
TAxios(dispatch, getState, 'post', URLS.GET_SHOPPERHOUSE_SEARCH, {}, params, onSuccess, onFail);
};
@@ -295,6 +348,32 @@ export const setShopperHouseError = (error) => {
};
};
// ShopperHouse 에러 표시 액션 (사용자에게 팝업으로 알림)
export const showShopperHouseError = (error) => {
console.log('[ShopperHouse] 🔴 [DEBUG] showShopperHouseError - 에러 팝업 표시');
console.log('[ShopperHouse] └─ error:', error);
return {
type: types.SHOW_SHOPPERHOUSE_ERROR,
payload: {
message: error.message,
type: error.type || 'API_FAILURE',
timestamp: Date.now(),
visible: true,
originalError: error.originalError || null,
},
};
};
// ShopperHouse 에러 숨김 액션 (팝업 닫기)
export const hideShopperHouseError = () => {
console.log('[ShopperHouse] ✅ [DEBUG] hideShopperHouseError - 에러 팝업 숨김');
return {
type: types.HIDE_SHOPPERHOUSE_ERROR,
};
};
// ShopperHouse 검색 데이터 초기화
export const clearShopperHouseData = () => {
// ✨ 검색 키 초기화 - 진행 중인 요청의 응답을 무시하도록

View File

@@ -16,7 +16,15 @@ const initialState = {
popularBrands: [],
hotPicksForYou: [],
},
shopperHouseError: null, // ShopperHouse API 오류 정보 저장
shopperHouseError: null, // ShopperHouse API 오류 정보 저장 (디버깅용)
shopperHouseErrorPopup: {
// ShopperHouse 에러 팝업 상태 (사용자 알림용)
message: null,
type: null, // 'API_FAILURE', 'NO_RESULTS', etc.
visible: false,
timestamp: null,
originalError: null,
},
// 🎯 [Phase 1] SearchPanel 모드 제어 명령
panelCommand: {
@@ -161,6 +169,32 @@ export const searchReducer = (state = initialState, action) => {
shopperHouseSearchId: null,
};
case types.SHOW_SHOPPERHOUSE_ERROR:
console.log('[ShopperHouse] 🔴 Redux shopperHouseErrorPopup 표시:', action.payload);
return {
...state,
shopperHouseErrorPopup: {
message: action.payload.message,
type: action.payload.type,
visible: action.payload.visible,
timestamp: action.payload.timestamp,
originalError: action.payload.originalError,
},
};
case types.HIDE_SHOPPERHOUSE_ERROR:
console.log('[ShopperHouse] ✅ Redux shopperHouseErrorPopup 숨김');
return {
...state,
shopperHouseErrorPopup: {
message: null,
type: null,
visible: false,
timestamp: null,
originalError: null,
},
};
case types.CLEAR_SHOPPERHOUSE_DATA:
console.log('[DEBUG] 🧹 Redux shopperHouseData 초기화 호출 - 호출 스택 추적:');
console.log(

View File

@@ -1,37 +1,19 @@
// src/views/SearchPanel/SearchPanel.new.jsx
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import {
useDispatch,
useSelector,
} from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import Spotlight from '@enact/spotlight';
import SpotlightContainerDecorator
from '@enact/spotlight/SpotlightContainerDecorator';
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 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 { popPanel, pushPanel, updatePanel } from '../../actions/panelActions';
import {
clearShopperHouseData,
getSearch,
@@ -52,32 +34,20 @@ import {
// } from '../../actions/toastActions';
import TBody from '../../components/TBody/TBody';
import TPanel from '../../components/TPanel/TPanel';
import TVerticalPagenator
from '../../components/TVerticalPagenator/TVerticalPagenator';
import TVirtualGridList
from '../../components/TVirtualGridList/TVirtualGridList';
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 { LOG_CONTEXT_NAME, LOG_MENU, LOG_MESSAGE_ID, panel_names } from '../../utils/Config';
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';
import TInputSimple, { ICONS, KINDS } from './TInput/TInputSimple';
import VoiceInputOverlay, { VOICE_MODES } from './VoiceInputOverlay/VoiceInputOverlay';
/**
* ✨ Mode-Based Architecture 도입
@@ -302,6 +272,17 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
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') {
console.log('[SearchPanel] 🧹 API 실패 감지 - fallbackShopperHouseData 초기화');
shopperHouseDataRef.current = null;
}
}, [shopperHouseErrorPopup?.visible, shopperHouseErrorPopup?.type]);
const detailReturnHandledRef = useRef(false);
// Spottable 컴포넌트 캐싱으로 메모리 누수 방지
@@ -315,7 +296,8 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
/**
* useSearchHistory Hook 적용
*/
const { normalSearches, addNormalSearch, refreshHistory, executeSearchFromHistory } = useSearchHistory();
const { normalSearches, addNormalSearch, refreshHistory, executeSearchFromHistory } =
useSearchHistory();
/**
* 🎯 [DetailPanel 복귀 감지] usePanelHistory Hook 적용
@@ -324,7 +306,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
currentPanel,
previousPanel,
currentIsOnTop, // 🎯 usePanelHistory의 isOnTop 정보 사용
isOnTopChange
isOnTopChange,
} = usePanelHistory();
// 🎯 DetailPanel에서 SearchPanel로 돌아왔는지 감지
@@ -862,7 +844,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
if (
!isReturningFromDetailPanel && // 🎯 usePanelHistory로 감지 못했을 때만
((currentIsOnTop && isOnTopChange?.becameOnTop) || // 🎯 usePanelHistory 기반 감지 (우선)
(isOnTop && !isOnTopRef.current)) && // 🎯 기존 방식 (fallback)
(isOnTop && !isOnTopRef.current)) && // 🎯 기존 방식 (fallback)
panelInfo?.currentSpot &&
(currentMode === SEARCH_PANEL_MODES.SEARCH_RESULT ||
currentMode === SEARCH_PANEL_MODES.VOICE_RESULT)
@@ -1028,10 +1010,13 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
return currentSpot;
} else {
if (DEBUG_MODE) {
console.log('[FOCUS] ⚠️ DETAIL_PANEL_RETURN: currentSpot이 유효하지 않음, fallback으로 이동:', {
currentSpot,
fallback: SPOTLIGHT_IDS.SEARCH_INPUT_BOX
});
console.log(
'[FOCUS] ⚠️ DETAIL_PANEL_RETURN: currentSpot이 유효하지 않음, fallback으로 이동:',
{
currentSpot,
fallback: SPOTLIGHT_IDS.SEARCH_INPUT_BOX,
}
);
}
return SPOTLIGHT_IDS.SEARCH_INPUT_BOX;
}
@@ -1068,7 +1053,16 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
default:
return null;
}
}, [analyzeCurrentScenario, panelInfo?.currentSpot, shopperHouseData, DEBUG_MODE, isReturningFromDetailPanel, currentPanel, currentIsOnTop, isOnTopChange]);
}, [
analyzeCurrentScenario,
panelInfo?.currentSpot,
shopperHouseData,
DEBUG_MODE,
isReturningFromDetailPanel,
currentPanel,
currentIsOnTop,
isOnTopChange,
]);
/**
* 🎯 [Phase 2] VoiceInputOverlay → SearchInputOverlay 전환 콜백
@@ -1124,7 +1118,6 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
}
}, [DEBUG_MODE, searchDatas, searchPerformed]);
/**
* Voice overlay close handler
*/
@@ -1511,13 +1504,13 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
searchQuery,
hasSearchResults: !!(
searchDatas?.theme?.length > 0 ||
searchDatas?.item?.length > 0 ||
searchDatas?.show?.length > 0
),
isSearchOverlayVisible,
isShopperHousePending,
currentMode,
});
searchDatas?.item?.length > 0 ||
searchDatas?.show?.length > 0
),
isSearchOverlayVisible,
isShopperHousePending,
currentMode,
});
}
// 우선순위 1: 음성 입력 오버레이가 열려있으면 VOICE_INPUT 모드
@@ -1734,7 +1727,10 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
const fallbackElement = document.querySelector(`[data-spotlight-id="${fallbackTarget}"]`);
if (fallbackElement) {
if (DEBUG_MODE) {
console.log('[FOCUS] 🔄 DETAIL_PANEL_RETURN fallback: 첫 번째 상품으로 포커스:', fallbackTarget);
console.log(
'[FOCUS] 🔄 DETAIL_PANEL_RETURN fallback: 첫 번째 상품으로 포커스:',
fallbackTarget
);
}
Spotlight.focus(fallbackTarget);
}
@@ -1860,9 +1856,12 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
});
if (focusTimer) {
if (DEBUG_MODE) {
console.log('[FOCUS] 🧹 SearchInputOverlay 포커스 관리 useEffect cleanup - 타이머 정리', {
timestamp: new Date().toISOString(),
});
console.log(
'[FOCUS] 🧹 SearchInputOverlay 포커스 관리 useEffect cleanup - 타이머 정리',
{
timestamp: new Date().toISOString(),
}
);
}
clearTimeout(focusTimer);
}
@@ -2033,7 +2032,11 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
// 🎯 [포커스 중첩 해결] SearchResultsContainer로 포커스 전달
// SearchResultsContainer가 Spotlight 컨테이너이므로, 포커스가 들어오면
// enterTo: 'last-focused' 설정에 의해 자동으로 HowAboutThese.small의 SEE MORE로 이동
data-spotlight-down={currentMode === SEARCH_PANEL_MODES.VOICE_RESULT ? 'search-results-container' : undefined}
data-spotlight-down={
currentMode === SEARCH_PANEL_MODES.VOICE_RESULT
? 'search-results-container'
: undefined
}
// 🎯 HowAboutThese 포커스 관리 - 포커스가 검색 입력 영역으로 감지
onSpotlightUp={handleSearchInputFocus}
onSpotlightLeft={handleSearchInputFocus}
@@ -2052,7 +2055,11 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
onKeyDown={handleMicKeyDown}
spotlightId={SPOTLIGHT_IDS.MICROPHONE_BUTTON}
// 🎯 [포커스 중첩 해결] SearchResultsContainer로 포커스 전달
data-spotlight-down={currentMode === SEARCH_PANEL_MODES.VOICE_RESULT ? 'search-results-container' : undefined}
data-spotlight-down={
currentMode === SEARCH_PANEL_MODES.VOICE_RESULT
? 'search-results-container'
: undefined
}
// 🎯 HowAboutThese 포커스 관리 - 포커스가 마이크 버튼으로 감지
onSpotlightUp={handleSearchInputFocus}
onSpotlightLeft={handleSearchInputFocus}

View File

@@ -1,27 +1,20 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import Spotlight from '@enact/spotlight';
import SpotlightContainerDecorator
from '@enact/spotlight/SpotlightContainerDecorator';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import downBtnImg from '../../../assets/images/btn/search_btn_down_arrow.png';
import upBtnImg from '../../../assets/images/btn/search_btn_up_arrow.png';
import { pushPanel } from '../../actions/panelActions';
import { hideShopperHouseError } from '../../actions/searchActions';
import CustomImage from '../../components/CustomImage/CustomImage';
import TButtonTab, { LIST_TYPE } from '../../components/TButtonTab/TButtonTab';
import TDropDown from '../../components/TDropDown/TDropDown';
import TVirtualGridList
from '../../components/TVirtualGridList/TVirtualGridList';
import TVirtualGridList from '../../components/TVirtualGridList/TVirtualGridList';
import { panel_names } from '../../utils/Config';
import { $L } from '../../utils/helperMethods';
import { SpotlightIds } from '../../utils/SpotlightIds';
@@ -76,9 +69,7 @@ const SafeImage = ({ src, alt, className, ...props }) => {
};
}, []);
return (
<img ref={imgRef} src={src} alt={alt} className={className} {...props} />
);
return <img ref={imgRef} src={src} alt={alt} className={className} {...props} />;
};
const SearchResultsNew = ({
@@ -97,14 +88,11 @@ const SearchResultsNew = ({
const dispatch = useDispatch();
// ShopperHouse 데이터를 ItemCard 형식으로 변환
const convertedShopperHouseItems = useMemo(() => {
// 🎯 Fallback 로직: shopperHouseInfo가 없으면 fallbackShopperHouseData
const targetData = shopperHouseInfo || fallbackShopperHouseData;
// 🎯 Fallback 로직: HowAboutThese 로딩 중에만 fallbackShopperHouseData
const targetData =
shopperHouseInfo || (isHowAboutTheseLoading ? fallbackShopperHouseData : null);
if (
!targetData ||
!targetData.results ||
targetData.results.length === 0
) {
if (!targetData || !targetData.results || targetData.results.length === 0) {
return null;
}
@@ -140,8 +128,7 @@ const SearchResultsNew = ({
const getButtonTabList = () => {
// ShopperHouse 데이터가 있으면 그것을 사용, 없으면 기존 검색 결과 사용
const itemLength =
convertedShopperHouseItems?.length || itemInfo?.length || 0;
const itemLength = convertedShopperHouseItems?.length || itemInfo?.length || 0;
const showLength = showInfo?.length || 0;
return [
@@ -165,15 +152,33 @@ const SearchResultsNew = ({
const [pendingFocusIndex, setPendingFocusIndex] = useState(null);
// HowAboutThese 모드 상태 관리
const [howAboutTheseMode, setHowAboutTheseMode] = useState(
HOW_ABOUT_THESE_MODES.SMALL
);
const [howAboutTheseMode, setHowAboutTheseMode] = useState(HOW_ABOUT_THESE_MODES.SMALL);
// HowAboutThese Response 로딩 상태
const [isHowAboutTheseLoading, setIsHowAboutTheseLoading] = useState(false);
const [hasPendingResponse, setHasPendingResponse] = useState(false);
const previousShopperHouseInfoRef = useRef(shopperHouseInfo);
// ShopperHouse 에러 팝업 상태 가져오기
const shopperHouseErrorPopup = useSelector((state) => state.search.shopperHouseErrorPopup);
// 자동 닫기 타이머 (3초 후 자동으로 팝업 닫기)
useEffect(() => {
if (shopperHouseErrorPopup?.visible) {
const timer = setTimeout(() => {
dispatch(hideShopperHouseError());
console.log('[ShopperHouse] ⏰ 3초 후 에러 팝업 자동 닫기');
}, 3000);
return () => clearTimeout(timer);
}
}, [shopperHouseErrorPopup?.visible, dispatch]);
// 에러 팝업 닫기 핸들러
const handleCloseErrorPopup = useCallback(() => {
dispatch(hideShopperHouseError());
}, [dispatch]);
// HowAboutThese 모드 전환 핸들러
const handleShowFullHowAboutThese = useCallback(() => {
setHowAboutTheseMode(HOW_ABOUT_THESE_MODES.FULL);
@@ -193,7 +198,9 @@ const SearchResultsNew = ({
// HowAboutThese Response 모드로 전환 핸들러
const handleShowHowAboutTheseResponse = useCallback(() => {
console.log('[HowAboutThese] 🚀 1. handleShowHowAboutTheseResponse 호출 - RESPONSE 모드로 전환, isLoading: true');
console.log(
'[HowAboutThese] 🚀 1. handleShowHowAboutTheseResponse 호출 - RESPONSE 모드로 전환, isLoading: true'
);
console.log('[HowAboutThese] 🚀 현재 shopperHouseInfo 상태:', !!shopperHouseInfo);
setHowAboutTheseMode(HOW_ABOUT_THESE_MODES.RESPONSE);
setIsHowAboutTheseLoading(true);
@@ -206,7 +213,7 @@ const SearchResultsNew = ({
if (
onSearchInputFocus &&
howAboutTheseMode === HOW_ABOUT_THESE_MODES.FULL &&
howAboutTheseMode !== HOW_ABOUT_THESE_MODES.RESPONSE // ✅ RESPONSE 모드는 우회
howAboutTheseMode !== HOW_ABOUT_THESE_MODES.RESPONSE // ✅ RESPONSE 모드는 우회
) {
console.log(
'[SearchResultsNew] 검색 입력 영역 포커스 감지 - HowAboutThese를 SMALL 모드로 전환'
@@ -221,8 +228,14 @@ const SearchResultsNew = ({
console.log('[HowAboutThese] howAboutTheseMode:', howAboutTheseMode);
console.log('[HowAboutThese] isHowAboutTheseLoading:', isHowAboutTheseLoading);
console.log('[HowAboutThese] shopperHouseInfo 존재 여부:', !!shopperHouseInfo);
console.log('[HowAboutThese] shopperHouseInfo.results 존재 여부:', !!(shopperHouseInfo?.results));
console.log('[HowAboutThese] shopperHouseInfo.results.length:', shopperHouseInfo?.results?.length || 0);
console.log(
'[HowAboutThese] shopperHouseInfo.results 존재 여부:',
!!shopperHouseInfo?.results
);
console.log(
'[HowAboutThese] shopperHouseInfo.results.length:',
shopperHouseInfo?.results?.length || 0
);
const prevShopperHouseInfo = previousShopperHouseInfoRef.current;
@@ -269,8 +282,7 @@ const SearchResultsNew = ({
// 현재 탭의 데이터 가져오기 - ShopperHouse 데이터 우선
const hasShopperHouseItems = convertedShopperHouseItems?.length > 0;
const currentData =
tab === 0 ? convertedShopperHouseItems || itemInfo : showInfo;
const currentData = tab === 0 ? convertedShopperHouseItems || itemInfo : showInfo;
// 표시할 데이터 (처음부터 visibleCount 개수만큼)
const displayedData = useMemo(() => {
@@ -404,9 +416,7 @@ const SearchResultsNew = ({
const spotlightId = `searchItemContents${pendingFocusIndex}`;
const focusTimer = setTimeout(() => {
const targetElement = document.querySelector(
`[data-spotlight-id="${spotlightId}"]`
);
const targetElement = document.querySelector(`[data-spotlight-id="${spotlightId}"]`);
if (effectiveChangePageRef && effectiveChangePageRef.current) {
effectiveChangePageRef.current(spotlightId, true, true);
@@ -429,8 +439,7 @@ const SearchResultsNew = ({
if (!themeInfo || !themeInfo[index]) {
return null;
}
const { bgImgPath, title, partnerLogo, partnerName, keyword, contentId } =
themeInfo[index];
const { bgImgPath, title, partnerLogo, partnerName, keyword, contentId } = themeInfo[index];
const tokens = contentId.split('_');
const onClick = () => {
dispatch(
@@ -453,19 +462,11 @@ const SearchResultsNew = ({
{...rest}
>
<div className={css.productImageWrapper}>
<SafeImage
src={bgImgPath}
alt={title}
className={css.productImage}
/>
<SafeImage src={bgImgPath} alt={title} className={css.productImage} />
</div>
<div className={css.productInfo}>
<div className={css.productBrandWrapper}>
<SafeImage
src={partnerLogo}
alt={partnerName}
className={css.brandLogo}
/>
<SafeImage src={partnerLogo} alt={partnerName} className={css.brandLogo} />
</div>
<div className={css.productDetails}>
{keyword && (
@@ -506,9 +507,6 @@ const SearchResultsNew = ({
{/* HowAboutThese Small 버전 - relativeQueries가 존재할 때 항상 표시 (레이아웃 유지용) */}
{relativeQueries && relativeQueries.length > 0 && (
<div>
{/* ✅ [251026] CHANGED: SpottableDiv wrapper 제거 - Spotlight 포커스 네비게이션 개선 */}
{/* 이전: spotlightId="how-about-these-small-wrapper"로 인해 포커스가 wrapper로 먼저 이동하는 문제 발생 */}
{/* 현재: 일반 div로 변경 - 포커스는 내부의 SEE MORE 버튼으로 직접 이동 */}
<HowAboutTheseSmall
relativeQueries={relativeQueries}
searchId={shopperHouseSearchId}
@@ -534,26 +532,24 @@ const SearchResultsNew = ({
/>
</div>
)}
{/* HowAboutThese Response 버전 - 로딩 메시지 표시 */}
{howAboutTheseMode === HOW_ABOUT_THESE_MODES.RESPONSE && (
<>
{console.log('[HowAboutThese] 📱 4. HowAboutTheseResponse 렌더링 시작!') || null}
<div className={css.howAboutTheseOverlay}>
<HowAboutTheseResponse
searchId={shopperHouseSearchId}
isLoading={isHowAboutTheseLoading}
onClose={handleCloseHowAboutTheseResponse}
/>
</div>
</>
<div className={css.howAboutTheseOverlay}>
<HowAboutTheseResponse
searchId={shopperHouseSearchId}
isLoading={isHowAboutTheseLoading}
onClose={handleCloseHowAboutTheseResponse}
/>
</div>
)}
{/* Hot Picks 섹션 */}
{themeInfo && themeInfo?.length > 0 && (
<div className={css.hotpicksSection} data-wheel-point="true">
<div className={css.sectionHeader}>
<div className={css.sectionIndicator} />
<div className={css.sectionTitle}>
Hot Picks ({themeInfo?.length})
</div>
<div className={css.sectionTitle}>Hot Picks ({themeInfo?.length})</div>
</div>
<div className={css.productList}>
<TVirtualGridList
@@ -567,6 +563,8 @@ const SearchResultsNew = ({
</div>
</div>
)}
{/* 메인 컨텐츠 영역 */}
<div className={css.itemBox}>
<div className={css.tabContainer}>
<TButtonTab
@@ -575,12 +573,11 @@ const SearchResultsNew = ({
selectedIndex={tab}
listType={LIST_TYPE.medium}
spotlightId={SpotlightIds.SEARCH_TAB_CONTAINER}
// [251026] ADD: Spotlight 포커스 네비게이션 - data-spotlight-up 속성 사용
data-spotlight-up="howAboutThese-seeMore"
// [251026] DEPRECATED: onKeyDown={handleTabKeyDown} - Spotlight 속성으로 대체됨
/>
{/* 2025/10/17 김영진 부장과 이야기 하여 일반에서는 아직 필터 검토하지않아 빼달라고 하여 우선 일반검색에서는 미노출 처리 추후 처리 필요함 */}
{tab === 2 && !itemInfo?.length && ( //일반에서는 노출해야함.tab === 0
{/* 필터링 dropdown */}
{tab === 2 && !itemInfo?.length && (
<TDropDown
className={classNames(
css.dropdown,
@@ -596,23 +593,37 @@ const SearchResultsNew = ({
</TDropDown>
)}
</div>
{/* 아이템/쇼 컨텐츠 */}
{tab === 0 && <ItemCard itemInfo={displayedData} />}
{tab === 1 && <ShowCard showInfo={displayedData} />}
</div>
{/* 버튼 컨테이너 */}
<div className={css.buttonContainer}>
{hasMore && (
<SpottableDiv onClick={downBtnClick} className={css.downBtn}>
<CustomImage
className={css.btnImg}
src={downBtnImg}
alt="Down arrow"
/>
<CustomImage className={css.btnImg} src={downBtnImg} alt="Down arrow" />
</SpottableDiv>
)}
<SpottableDiv onClick={upBtnClick} className={css.upBtn}>
<CustomImage className={css.btnImg} src={upBtnImg} alt="Up arrow" />
</SpottableDiv>
</div>
{/* ShopperHouse 에러 팝업 */}
{shopperHouseErrorPopup?.visible && (
<div className={css.errorOverlay}>
<div className={css.errorModal}>
<div className={css.errorMessage}>{shopperHouseErrorPopup.message}</div>
<div className={css.errorActions}>
<button className={css.errorCloseButton} onClick={handleCloseErrorPopup}>
확인
</button>
</div>
</div>
</div>
)}
</SearchResultsContainer>
);
};

View File

@@ -1,6 +1,64 @@
@import '../../style/CommonStyle.module.less';
@import '../../style/utils.module.less';
// ShopperHouse 에러 팝업 스타일
.errorOverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.errorModal {
background: white;
border-radius: 12px;
padding: 32px 40px;
min-width: 400px;
max-width: 600px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
text-align: center;
}
.errorMessage {
font-size: 18px;
font-weight: 500;
color: #333;
margin-bottom: 24px;
line-height: 1.4;
}
.errorActions {
display: flex;
justify-content: center;
}
.errorCloseButton {
background: #FF6B6B;
color: white;
border: none;
border-radius: 8px;
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background: #FF5252;
}
&:active {
background: #FF4444;
}
}
.searchBox {
width: 100%;
height: 100%;
@@ -326,6 +384,7 @@
right: 0;
top: 50%;
transform: translateY(-50%);
&.categoryDropdown {
> div {
> div {

View File

@@ -1,25 +1,19 @@
// src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.v2.jsx
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import {
useDispatch,
useSelector,
} from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import Spotlight from '@enact/spotlight';
import SpotlightContainerDecorator
from '@enact/spotlight/SpotlightContainerDecorator';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import micIcon from '../../../../assets/images/searchpanel/image-mic.png';
import { getShopperHouseSearch, clearShopperHouseData, transitionToSearchInputOverlay } from '../../../actions/searchActions';
import {
getShopperHouseSearch,
clearShopperHouseData,
transitionToSearchInputOverlay,
} from '../../../actions/searchActions';
import { updatePanel } from '../../../actions/panelActions';
// import {
// clearShopperHouseData,
@@ -34,18 +28,10 @@ import {
import TFullPopup from '../../../components/TFullPopup/TFullPopup';
import { useSearchHistory } from '../../../hooks/useSearchHistory';
import { useWebSpeech } from '../../../hooks/useWebSpeech';
import {
CURRENT_WEBSPEECH_VERSION,
} from '../../../services/voice/VoiceRecognitionManager';
import { CURRENT_WEBSPEECH_VERSION } from '../../../services/voice/VoiceRecognitionManager';
import { panel_names } from '../../../utils/Config';
import {
readLocalStorage,
writeLocalStorage,
} from '../../../utils/helperMethods';
import TInputSimple, {
ICONS,
KINDS,
} from '../TInput/TInputSimple';
import { readLocalStorage, writeLocalStorage } from '../../../utils/helperMethods';
import TInputSimple, { ICONS, KINDS } from '../TInput/TInputSimple';
import ApiStatusDisplay from './ApiStatusDisplay';
import VoiceApiError from './modes/VoiceApiError';
import VoiceListening from './modes/VoiceListening';
@@ -220,7 +206,7 @@ const VoiceInputOverlay = ({
// 디버그 대시보드 표시 여부
const [showDashboard, setShowDashboard] = useState(false);
// API 상태 표시용 state
const [apiStatus, setApiStatus] = useState('idle'); // 'idle', 'loading', 'success', 'error'
const [apiStatus, setApiStatus] = useState('idle'); // 'idle', 'loading', 'success', 'error'
const [apiStatusMessage, setApiStatusMessage] = useState('API Status...');
// 🚦 Silence Detection 상태 (신호등 인디케이터용)
const [silenceSeconds, setSilenceSeconds] = useState(0); // 0, 1, 2, 3
@@ -389,9 +375,44 @@ const VoiceInputOverlay = ({
(state) => state.search.shopperHouseRelativeQueries
); // ✨ 관련 검색어
const shopperHouseError = useSelector((state) => state.search.shopperHouseError); // API 에러 정보
const shopperHouseErrorPopup = useSelector((state) => state.search.shopperHouseErrorPopup); // 에러 팝업 상태
const shopperHouseDataRef = useRef(null);
const isInitializingRef = useRef(false); // overlay 초기화 중 플래그
// 🎯 API 실패 감지 - APIERROR 모드로 전환
useEffect(() => {
if (shopperHouseErrorPopup?.visible && shopperHouseErrorPopup?.type === 'API_FAILURE') {
console.log('[VoiceInputOverlay] 🚨 API 실패 감지 - APIERROR 모드로 전환');
setCurrentMode(VOICE_MODES.APIERROR);
// 3초 후 자동으로 PROMPT 모드로 복귀
const timer = setTimeout(() => {
console.log('[VoiceInputOverlay] ⏰ 3초 후 PROMPT 모드로 복귀');
setCurrentMode(VOICE_MODES.PROMPT);
}, 3000);
return () => clearTimeout(timer);
}
}, [shopperHouseErrorPopup?.visible, shopperHouseErrorPopup?.type]);
// 🎯 VoiceResponse 에러 핸들러 - 빈 query 처리
const handleVoiceResponseError = useCallback((error) => {
console.log('[VoiceInputOverlay] ⚠️ VoiceResponse 에러 수신:', error);
if (error.type === 'EMPTY_QUERY') {
console.log('[VoiceInputOverlay] 🚨 빈 query 에러 - NOTRECOGNIZED 모드로 전환');
setCurrentMode(VOICE_MODES.NOTRECOGNIZED);
// 3초 후 자동으로 PROMPT 모드로 복귀
const timer = setTimeout(() => {
console.log('[VoiceInputOverlay] ⏰ 3초 후 PROMPT 모드로 복귀 (빈 query 에러)');
setCurrentMode(VOICE_MODES.PROMPT);
}, 3000);
return () => clearTimeout(timer);
}
}, []);
// ✅ searchId ref 추가 - processFinalVoiceInput에서 최신 값을 읽기 위함
const shopperHouseSearchIdRef = useRef(shopperHouseSearchId);
@@ -686,7 +707,10 @@ const VoiceInputOverlay = ({
setApiStatusMessage('Success');
if (DEBUG_MODE) {
console.log('[VoiceInputOverlay] 📍 API Status updated:', { status: 'success', message: 'Success' });
console.log('[VoiceInputOverlay] 📍 API Status updated:', {
status: 'success',
message: 'Success',
});
}
// 직접 닫기 (VoiceResponse 컴포넌트에서 결과를 표시한 후 닫음)
@@ -852,11 +876,19 @@ const VoiceInputOverlay = ({
})
);
} else {
// 입력이 없거나 너무 짧으면 PROMPT 모드로 복귀
console.log('[VoiceInput] ⚠️ 입력 없음 또는 너무 짧음 - PROMPT 모드로 복귀');
// 입력이 없거나 너무 짧으면 NOTRECOGNIZED 모드로 전환
console.log('[VoiceInput] ⚠️ 입력 없음 또는 너무 짧음 - NOTRECOGNIZED 모드로 전환');
console.log('[VoiceInput] └─ finalText 길이:', finalText.length);
setCurrentMode(VOICE_MODES.PROMPT);
setCurrentMode(VOICE_MODES.NOTRECOGNIZED);
setVoiceInputMode(null);
// 3초 후 자동으로 PROMPT 모드로 복귀
const timer = setTimeout(() => {
console.log('[VoiceInputOverlay] ⏰ 3초 후 PROMPT 모드로 복귀 (NOTRECOGNIZED)');
setCurrentMode(VOICE_MODES.PROMPT);
}, 3000);
return () => clearTimeout(timer);
}
// Ref 초기화
@@ -960,7 +992,15 @@ const VoiceInputOverlay = ({
clearTimerRef(silenceDetectionTimerRef);
silenceDetectionTimerRef.current = null;
};
}, [interimText, currentMode, onSearchChange, processFinalVoiceInput, addWebSpeechEventLog, IS_SILENCE_CHECK_ENABLED, hasReached5Chars]);
}, [
interimText,
currentMode,
onSearchChange,
processFinalVoiceInput,
addWebSpeechEventLog,
IS_SILENCE_CHECK_ENABLED,
hasReached5Chars,
]);
// 🎉 Wake Word Detection: PROMPT 모드에서 백그라운드 리스닝 시작
useEffect(() => {
@@ -1375,16 +1415,21 @@ const VoiceInputOverlay = ({
// Input 창에서 엔터키 핸들러
// 🎯 [Phase 2] Enter 키 감지 시 SearchInputOverlay로 부드럽게 전환
const handleInputKeyDown = useCallback((e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
console.log('[VoiceInputOverlay] 🔄 TInputSimple에서 Enter 키 감지 → SearchInputOverlay 전환 시작');
// 🎯 SearchPanel의 콜백 호출
if (onTransitionToSearchInput) {
onTransitionToSearchInput();
const handleInputKeyDown = useCallback(
(e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
console.log(
'[VoiceInputOverlay] 🔄 TInputSimple에서 Enter 키 감지 → SearchInputOverlay 전환 시작'
);
// 🎯 SearchPanel의 콜백 호출
if (onTransitionToSearchInput) {
onTransitionToSearchInput();
}
}
}
}, [onTransitionToSearchInput]);
},
[onTransitionToSearchInput]
);
// 🎯 [Phase 2] Input 창에서 마우스 클릭 감지 시 SearchInputOverlay로 전환
// ⚠️ [251031] 마우스 클릭 시 프리징 발생 - 추후 원인 분석 후 활성화 필요
@@ -1560,6 +1605,7 @@ const VoiceInputOverlay = ({
isLoading
query={sttResponseText}
searchId={shopperHouseSearchId || ''}
onError={handleVoiceResponseError}
/>
);
case VOICE_MODES.NOINIT:
@@ -1684,112 +1730,112 @@ const VoiceInputOverlay = ({
console.log('🎤 [VoiceInput] MIC BUTTON CLICKED - WebSpeech Initialization Flow (V1)');
console.log('🎤 ════════════════════════════════════════════════════════════════\n');
// 📋 단계별 처리:
// 1. WebSpeech 완전 cleanup (이전 상태 제거)
// 2. Redux STT 데이터 초기화 (searchId는 유지)
// 3. WebSpeech 재초기화
// 4. 즉시 시작
// ============================================================
// 1⃣ WebSpeech 완전 cleanup (이전 상태 제거)
// ============================================================
console.log('[VoiceInput] 📍 [STEP 1] WebSpeech Cleanup');
console.log('[VoiceInput] ├─ Dispatching: cleanupWebSpeech()');
dispatch(cleanupWebSpeech());
console.log('[VoiceInput] └─ ✅ Cleanup dispatched\n');
// ============================================================
// 2⃣ Redux STT 데이터 초기화 (searchId는 Redux에서 유지됨)
// ============================================================
console.log('[VoiceInput] 📍 [STEP 2] STT Data Clear');
console.log('[VoiceInput] ├─ Dispatching: clearSTTText()');
dispatch(clearSTTText());
console.log('[VoiceInput] ├─ Clearing interim text buffer');
// ✅ TInput 초기화
if (onSearchChange) {
onSearchChange({ value: '' });
console.log('[VoiceInput] ├─ TInput cleared');
}
// ✅ Interim text ref 초기화
interimTextRef.current = '';
console.log('[VoiceInput] ├─ Interim text ref cleared');
// 기존 타이머 정리
clearTimerRef(listeningTimerRef);
clearTimerRef(silenceDetectionTimerRef);
console.log('[VoiceInput] ├─ Previous timers cleared');
// UI 모드 업데이트
setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH);
setCurrentMode(VOICE_MODES.LISTENING);
console.log('[VoiceInput] ├─ UI mode set to WEBSPEECH / LISTENING');
// ✅ LISTENING 모드 진입 시 로그 초기화 (새로운 음성 입력 시작)
setWebSpeechEventLogs([]);
writeLocalStorage(VOICE_EVENT_LOGS_KEY, []);
console.log('[VoiceInput] └─ ✅ Event logs cleared\n');
if (DEBUG_MODE) {
console.log('🧹 [DEBUG] Cleared webSpeechEventLogs on mic click');
}
// ============================================================
// 3⃣ WebSpeech 재초기화 (약간의 지연 후)
// ============================================================
console.log('[VoiceInput] ⏳ [STEP 3] Waiting 100ms for Redux state sync...');
const reinitializeWebSpeech = setTimeout(() => {
console.log('[VoiceInput] 📍 [STEP 3] WebSpeech Initialization');
console.log('[VoiceInput] ├─ Dispatching: initializeWebSpeech()');
console.log('[VoiceInput] │ └─ lang: en-US, continuous: true, interimResults: true');
// Redux 상태 업데이트를 기다리기 위해 약간의 지연
dispatch(
initializeWebSpeech({
lang: 'en-US',
continuous: true,
interimResults: true,
})
);
console.log('[VoiceInput] └─ ✅ Initialize dispatched\n');
// 📋 단계별 처리:
// 1. WebSpeech 완전 cleanup (이전 상태 제거)
// 2. Redux STT 데이터 초기화 (searchId는 유지)
// 3. WebSpeech 재초기화
// 4. 즉시 시작
// ============================================================
// 4️⃣ WebSpeech 즉시 시작
// 1️⃣ WebSpeech 완전 cleanup (이전 상태 제거)
// ============================================================
console.log('[VoiceInput] 📍 [STEP 4] WebSpeech Start');
console.log('[VoiceInput] ├─ Dispatching: startWebSpeech()');
dispatch(startWebSpeech());
console.log('[VoiceInput] └─ ✅ Start dispatched\n');
console.log('[VoiceInput] 📍 [STEP 1] WebSpeech Cleanup');
console.log('[VoiceInput] ├─ Dispatching: cleanupWebSpeech()');
dispatch(cleanupWebSpeech());
console.log('[VoiceInput] └─ ✅ Cleanup dispatched\n');
// ============================================================
// 5️⃣ 15초 타이머 설정 및 준비 완료
// 2️⃣ Redux STT 데이터 초기화 (searchId는 Redux에서 유지됨)
// ============================================================
console.log('[VoiceInput] 📍 [STEP 5] Setup MaxListeningTime Timer');
console.log('[VoiceInput] ├─ Setting 15-second listening timeout');
listeningTimerRef.current = setTimeout(() => {
console.log('[VoiceInput] ⏲️ [TIMEOUT] 15-second max listening time reached');
addWebSpeechEventLog('TIMEOUT_15S', '15 second timeout reached - finishing input');
processFinalVoiceInput('15초 타임아웃');
}, 15000); // 15초
console.log('[VoiceInput] 📍 [STEP 2] STT Data Clear');
console.log('[VoiceInput] ├─ Dispatching: clearSTTText()');
dispatch(clearSTTText());
console.log('[VoiceInput] ├─ Clearing interim text buffer');
console.log('[VoiceInput] ├─ ⏲️ Timer started (15000ms)');
console.log('[VoiceInput] └─ ✅ Timer configured\n');
// ✅ TInput 초기화
if (onSearchChange) {
onSearchChange({ value: '' });
console.log('[VoiceInput] ├─ TInput cleared');
}
// ✅ Interim text ref 초기화
interimTextRef.current = '';
console.log('[VoiceInput] ├─ Interim text ref cleared');
// 기존 타이머 정리
clearTimerRef(listeningTimerRef);
clearTimerRef(silenceDetectionTimerRef);
console.log('[VoiceInput] ├─ Previous timers cleared');
// UI 모드 업데이트
setVoiceInputMode(VOICE_INPUT_MODE.WEBSPEECH);
setCurrentMode(VOICE_MODES.LISTENING);
console.log('[VoiceInput] ├─ UI mode set to WEBSPEECH / LISTENING');
// ✅ LISTENING 모드 진입 시 로그 초기화 (새로운 음성 입력 시작)
setWebSpeechEventLogs([]);
writeLocalStorage(VOICE_EVENT_LOGS_KEY, []);
console.log('[VoiceInput] └─ ✅ Event logs cleared\n');
if (DEBUG_MODE) {
console.log('🧹 [DEBUG] Cleared webSpeechEventLogs on mic click');
}
// ============================================================
// ✨ INITIALIZATION COMPLETE
// 3⃣ WebSpeech 재초기화 (약간의 지연 후)
// ============================================================
console.log('🎤 ════════════════════════════════════════════════════════════════');
console.log('✨ [VoiceInput] 음성 인식 준비 완료! (Voice Recognition Ready!)');
console.log('[VoiceInput] Waiting for voice input... (max 15 seconds)');
console.log('🎤 ════════════════════════════════════════════════════════════════\n');
}, 100);
console.log('[VoiceInput] ⏳ [STEP 3] Waiting 100ms for Redux state sync...');
const reinitializeWebSpeech = setTimeout(() => {
console.log('[VoiceInput] 📍 [STEP 3] WebSpeech Initialization');
console.log('[VoiceInput] ├─ Dispatching: initializeWebSpeech()');
console.log('[VoiceInput] │ └─ lang: en-US, continuous: true, interimResults: true');
// Cleanup: 언마운트 시 타이머 정리
return () => {
clearTimeout(reinitializeWebSpeech);
};
// Redux 상태 업데이트를 기다리기 위해 약간의 지연
dispatch(
initializeWebSpeech({
lang: 'en-US',
continuous: true,
interimResults: true,
})
);
console.log('[VoiceInput] └─ ✅ Initialize dispatched\n');
// ============================================================
// 4⃣ WebSpeech 즉시 시작
// ============================================================
console.log('[VoiceInput] 📍 [STEP 4] WebSpeech Start');
console.log('[VoiceInput] ├─ Dispatching: startWebSpeech()');
dispatch(startWebSpeech());
console.log('[VoiceInput] └─ ✅ Start dispatched\n');
// ============================================================
// 5⃣ 15초 타이머 설정 및 준비 완료
// ============================================================
console.log('[VoiceInput] 📍 [STEP 5] Setup MaxListeningTime Timer');
console.log('[VoiceInput] ├─ Setting 15-second listening timeout');
listeningTimerRef.current = setTimeout(() => {
console.log('[VoiceInput] ⏲️ [TIMEOUT] 15-second max listening time reached');
addWebSpeechEventLog('TIMEOUT_15S', '15 second timeout reached - finishing input');
processFinalVoiceInput('15초 타임아웃');
}, 15000); // 15초
console.log('[VoiceInput] ├─ ⏲️ Timer started (15000ms)');
console.log('[VoiceInput] └─ ✅ Timer configured\n');
// ============================================================
// ✨ INITIALIZATION COMPLETE
// ============================================================
console.log('🎤 ════════════════════════════════════════════════════════════════');
console.log('✨ [VoiceInput] 음성 인식 준비 완료! (Voice Recognition Ready!)');
console.log('✨ [VoiceInput] Waiting for voice input... (max 15 seconds)');
console.log('🎤 ════════════════════════════════════════════════════════════════\n');
}, 100);
// Cleanup: 언마운트 시 타이머 정리
return () => {
clearTimeout(reinitializeWebSpeech);
};
} else {
// listening 모드 또는 기타 모드에서 클릭 시 -> overlay 닫기
if (DEBUG_MODE) {
@@ -2045,7 +2091,13 @@ const VoiceInputOverlay = ({
>
<div className={css.voiceOverlayContainer}>
{/* 배경 dim 레이어 - 클릭하면 닫힘 */}
<div className={classNames(css.dimBackground,currentMode === VOICE_MODES.RESPONSE && css.responseDimBackground)} onClick={handleClose} />
<div
className={classNames(
css.dimBackground,
currentMode === VOICE_MODES.RESPONSE && css.responseDimBackground
)}
onClick={handleClose}
/>
{/* 디버깅용: Voice 상태 표시 */}
{DEBUG_MODE && debugUI}
@@ -2171,11 +2223,7 @@ const VoiceInputOverlay = ({
{/* API 상태 표시 - 오른쪽 아래에 조용하게 표시 (prompt 모드에서는 숨김) */}
{mode !== VOICE_MODES.PROMPT && (
<ApiStatusDisplay
status={apiStatus}
message={apiStatusMessage}
autoHideDuration={3000}
/>
<ApiStatusDisplay status={apiStatus} message={apiStatusMessage} autoHideDuration={3000} />
)}
</TFullPopup>
);

View File

@@ -1,14 +1,10 @@
// src/views/SearchPanel/VoiceInputOverlay/modes/VoiceResponse.jsx
import React, {
useEffect,
useRef,
useState,
} from 'react';
import React, { useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import SpotlightContainerDecorator
from '@enact/spotlight/SpotlightContainerDecorator';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import voiceGif from '../../../../../assets/images/voiceGif.gif';
import css from './VoiceResponse.module.less';
@@ -24,12 +20,29 @@ const ResponseContainer = SpotlightContainerDecorator(
// 디버그 정보 표시 여부 (개발/프로덕션 모두에서 작동)
const IS_DEBUG = true;
const VoiceResponse = ({ isLoading = true, query = '', searchId = '' }) => {
const VoiceResponse = ({ isLoading = true, query = '', searchId = '', onError = null }) => {
const [typedText, setTypedText] = useState('');
const [currentMessageIndex, setCurrentMessageIndex] = useState(0);
const typingTimerRef = useRef(null);
const stageTimerRef = useRef(null);
// 🎯 빈 query 체크 - 빈 값이면 에러 상태로 처리
useEffect(() => {
if (!query || query.trim() === '') {
console.log('[VoiceResponse] ⚠️ 빈 query 감지 - 에러 상태로 전환');
// 부모 컴포넌트에 에러 상태 알림 (onError prop이 있다면)
if (onError) {
onError({
type: 'EMPTY_QUERY',
message: 'Query is empty',
});
}
return;
}
}, [query]);
const MESSAGES = [
"I'm analyzing your request...",
'Preparing your answer...',
@@ -107,15 +120,12 @@ const VoiceResponse = ({ isLoading = true, query = '', searchId = '' }) => {
}, [isLoading, currentMessageIndex]);
return (
<ResponseContainer
className={css.container}
spotlightId="voice-response-container"
>
<ResponseContainer className={css.container} spotlightId="voice-response-container">
<div className={css.responseContainer}>
{/* 로딩 메시지 (타이핑 애니메이션) - 화면 정가운데 */}
{isLoading && (
<div className={css.loadingMessage}>
<img src={voiceGif} className={css.voiceGif}/>
<img src={voiceGif} className={css.voiceGif} />
<div className={css.flexBox}>
<div className={css.typingText}>{typedText}</div>
<span className={css.cursor}>|</span>
@@ -140,6 +150,7 @@ VoiceResponse.propTypes = {
isLoading: PropTypes.bool,
query: PropTypes.string,
searchId: PropTypes.string,
onError: PropTypes.func,
};
VoiceResponse.defaultProps = {
@@ -147,6 +158,7 @@ VoiceResponse.defaultProps = {
isLoading: true,
query: '',
searchId: '',
onError: null,
};
export default VoiceResponse;