[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:
1
code
Submodule
1
code
Submodule
Submodule code added at c2ac8e3c01
@@ -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',
|
||||
|
||||
@@ -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 = () => {
|
||||
// ✨ 검색 키 초기화 - 진행 중인 요청의 응답을 무시하도록
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user