[251022] fix: ShopperHouse API Error처리
🕐 커밋 시간: 2025. 10. 22. 10:55:17 📊 변경 통계: • 총 파일: 4개 • 추가: +96줄 • 삭제: -14줄 📝 수정된 파일: ~ com.twin.app.shoptime/src/reducers/searchReducer.js ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceApiError.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceApiError.module.less 🔧 함수 변경 내용: 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx (javascript): 🔄 Modified: clearAllTimers() 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceApiError.jsx (javascript): ✅ Added: VoiceApiError(), getErrorMessage(), getErrorDetails() 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoiceApiError.module.less (unknown): ✅ Added: brightness(), translateY() 🔧 주요 변경 내용: • 핵심 비즈니스 로직 개선 • API 서비스 레이어 개선
This commit is contained in:
@@ -167,6 +167,7 @@ export const types = {
|
||||
RESET_SHOW_ALL_REVIEWS: "RESET_SHOW_ALL_REVIEWS",
|
||||
|
||||
// search actions
|
||||
|
||||
GET_SEARCH: "GET_SEARCH",
|
||||
GET_SEARCH_MAIN: "GET_SEARCH_MAIN",
|
||||
CLEAR_SEARCH_MAIN_DATA: "CLEAR_SEARCH_MAIN_DATA",
|
||||
@@ -177,6 +178,8 @@ export const types = {
|
||||
GET_SEARCH_PROCESSED: "GET_SEARCH_PROCESSED",
|
||||
SET_SEARCH_INIT_PERFORMED: "SET_SEARCH_INIT_PERFORMED",
|
||||
UPDATE_SEARCH_TIMESTAMP: "UPDATE_SEARCH_TIMESTAMP",
|
||||
SET_SHOPPERHOUSE_ERROR: 'SET_SHOPPERHOUSE_ERROR',
|
||||
|
||||
|
||||
// event actions
|
||||
GET_WELCOME_EVENT_INFO: "GET_WELCOME_EVENT_INFO",
|
||||
|
||||
@@ -145,16 +145,42 @@ export const getShopperHouseSearch =
|
||||
"retMsg:",
|
||||
response.data?.retMsg
|
||||
);
|
||||
console.log("[VoiceInput] 📥 API 응답 실패");
|
||||
console.log("[VoiceInput] ├─ retCode:", retCode);
|
||||
console.log("[VoiceInput] └─ retMsg:", response.data?.retMsg);
|
||||
console.log('[VoiceInput] 📥 API 응답 실패');
|
||||
console.log('[VoiceInput] ├─ retCode:', retCode);
|
||||
console.log('[VoiceInput] └─ retMsg:', response.data?.retMsg);
|
||||
|
||||
// ✨ API 실패 응답을 Redux 에러 상태에 저장
|
||||
dispatch(setShopperHouseError({
|
||||
message: `API 실패: ${response.data?.retMsg || '알 수 없는 오류'}`,
|
||||
status: 'API_ERROR',
|
||||
retCode: retCode,
|
||||
retMsg: response.data?.retMsg,
|
||||
timestamp: Date.now(),
|
||||
endpoint: URLS.GET_SHOPPERHOUSE_SEARCH,
|
||||
query: query,
|
||||
searchId: searchId,
|
||||
response: response.data
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ result 데이터 존재 확인
|
||||
if (!response.data?.data?.result) {
|
||||
console.error("[ShopperHouse] ❌ API 응답에 result 데이터 없음");
|
||||
console.log("[VoiceInput] 📥 API 응답 실패 (result 데이터 없음)");
|
||||
console.error('[ShopperHouse] ❌ API 응답에 result 데이터 없음');
|
||||
console.log('[VoiceInput] 📥 API 응답 실패 (result 데이터 없음)');
|
||||
|
||||
// ✨ result 데이터 없음 에러를 Redux 에러 상태에 저장
|
||||
dispatch(setShopperHouseError({
|
||||
message: 'API 응답에 result 데이터가 없습니다',
|
||||
status: 'NO_RESULT_DATA',
|
||||
timestamp: Date.now(),
|
||||
endpoint: URLS.GET_SHOPPERHOUSE_SEARCH,
|
||||
query: query,
|
||||
searchId: searchId,
|
||||
response: response.data
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -190,16 +216,58 @@ export const getShopperHouseSearch =
|
||||
JSON.stringify(error)
|
||||
);
|
||||
|
||||
const retCode = error?.data?.retCode;
|
||||
if (retCode === 401) {
|
||||
console.log("[VoiceInput] ⚠️ Access Token 만료 - 자동 갱신 중...");
|
||||
console.log("[VoiceInput] └─ TAxios가 자동으로 재요청합니다");
|
||||
|
||||
// ✨ 현재 요청이 최신 요청인지 확인
|
||||
if (currentSearchKey === getShopperHouseSearchKey) {
|
||||
console.log('[ShopperHouse] ❌ [DEBUG] 유효한 에러 응답 - Redux 에러 상태 업데이트');
|
||||
|
||||
const retCode = error?.data?.retCode;
|
||||
const status = error?.status;
|
||||
const errorMessage = error?.message;
|
||||
|
||||
// ✅ TAxios 재인증 오류 필터링 (기존 방식 그대로 활용)
|
||||
if (retCode === 401) {
|
||||
console.log('[VoiceInput] ⚠️ Access Token 만료 - TAxios가 자동으로 재요청합니다');
|
||||
console.log('[VoiceInput] └─ TAxios가 자동으로 재인증하고 재시도합니다');
|
||||
// 401 에러는 Redux에 저장하지 않음 (TAxios 자동 재시도 대기)
|
||||
return;
|
||||
}
|
||||
|
||||
if (retCode === 402 || retCode === 501) {
|
||||
console.log('[VoiceInput] ⚠️ RefreshToken 만료 - TAxios가 자동으로 재발급합니다');
|
||||
console.log('[VoiceInput] └─ TAxios가 자동으로 토큰 재발급하고 재시도합니다');
|
||||
// 402/501 에러는 Redux에 저장하지 않음 (TAxios 자동 재시도 대기)
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ 네트워크 연결 관련 오류 중 일시적인 오류 필터링
|
||||
if (status === 0 || errorMessage?.includes('Network Error') || errorMessage?.includes('timeout')) {
|
||||
console.log('[VoiceInput] ⚠️ 일시적인 네트워크 오류 - 재시도 필요');
|
||||
console.log('[VoiceInput] └─ TAxios 큐에 의해 자동 재시도될 수 있습니다');
|
||||
// 일시적인 네트워크 오류는 Redux에 저장하지 않음
|
||||
return;
|
||||
}
|
||||
|
||||
// ✨ 그 외의 실제 API 오류들만 Redux에 저장
|
||||
console.log('[VoiceInput] 📥 실제 API 오류 발생 - Redux에 저장');
|
||||
console.log('[VoiceInput] ├─ retCode:', retCode);
|
||||
console.log('[VoiceInput] ├─ status:', status);
|
||||
console.log('[VoiceInput] └─ error:', errorMessage || JSON.stringify(error));
|
||||
|
||||
dispatch(setShopperHouseError({
|
||||
message: errorMessage || 'Unknown API error',
|
||||
status: status,
|
||||
statusText: error?.statusText,
|
||||
retCode: retCode,
|
||||
retMsg: error?.data?.retMsg,
|
||||
timestamp: Date.now(),
|
||||
endpoint: URLS.GET_SHOPPERHOUSE_SEARCH,
|
||||
query: query,
|
||||
searchId: searchId,
|
||||
originalError: error
|
||||
}));
|
||||
} else {
|
||||
console.log("[VoiceInput] 📥 API 요청 실패");
|
||||
console.log(
|
||||
"[VoiceInput] └─ error:",
|
||||
error?.message || JSON.stringify(error)
|
||||
);
|
||||
console.log('[ShopperHouse] ❌ [DEBUG] 오래된 에러 응답 무시 - Redux 업데이트 안함');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -224,6 +292,17 @@ export const getShopperHouseSearch =
|
||||
);
|
||||
};
|
||||
|
||||
// ShopperHouse API 에러 처리 액션
|
||||
export const setShopperHouseError = (error) => {
|
||||
console.log('[ShopperHouse] ❌ [DEBUG] setShopperHouseError - 에러 정보 저장');
|
||||
console.log('[ShopperHouse] └─ error:', error);
|
||||
|
||||
return {
|
||||
type: types.SET_SHOPPERHOUSE_ERROR,
|
||||
payload: error,
|
||||
};
|
||||
};
|
||||
|
||||
// ShopperHouse 검색 데이터 초기화
|
||||
export const clearShopperHouseData = () => {
|
||||
// ✨ 검색 키 초기화 - 진행 중인 요청의 응답을 무시하도록
|
||||
|
||||
@@ -8,12 +8,14 @@ const initialState = {
|
||||
searchTimestamp: null,
|
||||
shopperHouseData: null,
|
||||
shopperHouseSearchId: null,
|
||||
|
||||
// 🔽 검색 메인 데이터 추가
|
||||
searchMainData: {
|
||||
topSearchs: [],
|
||||
popularBrands: [],
|
||||
hotPicksForYou: [],
|
||||
},
|
||||
shopperHouseError: null, // ShopperHouse API 오류 정보 저장
|
||||
};
|
||||
|
||||
export const searchReducer = (state = initialState, action) => {
|
||||
@@ -83,7 +85,7 @@ export const searchReducer = (state = initialState, action) => {
|
||||
const resultData = action.payload?.data?.result;
|
||||
|
||||
if (!resultData) {
|
||||
console.error("[searchReducer] Invalid shopperHouse data structure");
|
||||
console.error('[searchReducer] Invalid shopperHouse data structure');
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -93,8 +95,8 @@ export const searchReducer = (state = initialState, action) => {
|
||||
const searchId = results.length > 0 ? results[0].searchId : null;
|
||||
|
||||
// [VoiceInput] Redux에 searchId 저장 로그
|
||||
console.log("[VoiceInput] 💾 Redux에 searchId 저장");
|
||||
console.log("[VoiceInput] └─ searchId:", searchId || "(없음)");
|
||||
console.log('[VoiceInput] 💾 Redux에 searchId 저장');
|
||||
console.log('[VoiceInput] └─ searchId:', searchId || '(없음)');
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -108,22 +110,27 @@ export const searchReducer = (state = initialState, action) => {
|
||||
};
|
||||
}
|
||||
|
||||
case types.SET_SHOPPERHOUSE_ERROR:
|
||||
console.log('[VoiceInput] ❌ Redux shopperHouseError 저장:', action.payload);
|
||||
return {
|
||||
...state,
|
||||
shopperHouseError: action.payload,
|
||||
shopperHouseData: null, // 오류 발생 시 데이터 초기화
|
||||
shopperHouseSearchId: null,
|
||||
};
|
||||
|
||||
case types.CLEAR_SHOPPERHOUSE_DATA:
|
||||
console.log(
|
||||
"[VoiceInput] 🧹 Redux shopperHouseData 초기화 (searchId 리셋)"
|
||||
);
|
||||
console.log('[VoiceInput] 🧹 Redux shopperHouseData 초기화 (searchId 리셋)');
|
||||
return {
|
||||
...state,
|
||||
shopperHouseData: null,
|
||||
shopperHouseSearchId: null,
|
||||
shopperHouseError: null, // 데이터 초기화 시 오류도 초기화
|
||||
};
|
||||
|
||||
// 🔽 검색 메인 데이터 처리
|
||||
case types.GET_SEARCH_MAIN: {
|
||||
console.log(
|
||||
"🔍 [searchReducer] GET_SEARCH_MAIN 받은 payload:",
|
||||
action.payload
|
||||
);
|
||||
console.log('🔍 [searchReducer] GET_SEARCH_MAIN 받은 payload:', action.payload);
|
||||
|
||||
// 여러 가능한 구조 확인
|
||||
let resultData = null;
|
||||
@@ -131,24 +138,24 @@ export const searchReducer = (state = initialState, action) => {
|
||||
if (action.payload?.result) {
|
||||
// payload.result 구조
|
||||
resultData = action.payload.result;
|
||||
console.log("✅ [searchReducer] payload.result 구조 확인");
|
||||
console.log('✅ [searchReducer] payload.result 구조 확인');
|
||||
} else if (action.payload?.data?.result) {
|
||||
// payload.data.result 구조
|
||||
resultData = action.payload.data.result;
|
||||
console.log("✅ [searchReducer] payload.data.result 구조 확인");
|
||||
console.log('✅ [searchReducer] payload.data.result 구조 확인');
|
||||
} else if (action.payload?.data) {
|
||||
// payload.data에 직접 데이터가 있는 경우
|
||||
resultData = action.payload.data;
|
||||
console.log("✅ [searchReducer] payload.data 직접 구조 확인");
|
||||
console.log('✅ [searchReducer] payload.data 직접 구조 확인');
|
||||
}
|
||||
|
||||
if (!resultData) {
|
||||
console.error("[searchReducer] ❌ Invalid searchMain data structure");
|
||||
console.error("받은 payload:", JSON.stringify(action.payload, null, 2));
|
||||
console.error('[searchReducer] ❌ Invalid searchMain data structure');
|
||||
console.error('받은 payload:', JSON.stringify(action.payload, null, 2));
|
||||
return state;
|
||||
}
|
||||
|
||||
console.log("[searchReducer] ✅ GET_SEARCH_MAIN success");
|
||||
console.log('[searchReducer] ✅ GET_SEARCH_MAIN success');
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -161,7 +168,7 @@ export const searchReducer = (state = initialState, action) => {
|
||||
}
|
||||
|
||||
case types.CLEAR_SEARCH_MAIN_DATA:
|
||||
console.log("[searchReducer] 🧹 searchMainData 초기화");
|
||||
console.log('[searchReducer] 🧹 searchMainData 초기화');
|
||||
return {
|
||||
...state,
|
||||
searchMainData: {
|
||||
|
||||
@@ -21,6 +21,7 @@ import VoiceNotRecognized from './modes/VoiceNotRecognized';
|
||||
import VoiceNotRecognizedCircle from './modes/VoiceNotRecognizedCircle';
|
||||
import VoicePromptScreen from './modes/VoicePromptScreen';
|
||||
import VoiceResponse from './modes/VoiceResponse';
|
||||
import VoiceApiError from './modes/VoiceApiError';
|
||||
import WebSpeechEventDebug from './WebSpeechEventDebug';
|
||||
import css from './VoiceInputOverlay.module.less';
|
||||
|
||||
@@ -44,6 +45,7 @@ export const VOICE_MODES = {
|
||||
RESPONSE: 'response', // STT 텍스트 표시 화면
|
||||
NOINIT: 'noinit', // 음성 인식이 초기화되지 않았을 때 화면
|
||||
NOTRECOGNIZED: 'notrecognized', // 음성 인식이 되지 않았을 때 화면
|
||||
APIERROR: 'apierror', // ShopperHouse API 오류 화면
|
||||
MODE_3: 'mode3', // 추후 추가
|
||||
MODE_4: 'mode4', // 추후 추가
|
||||
};
|
||||
@@ -278,9 +280,10 @@ const VoiceInputOverlay = ({
|
||||
return types[event] || 'info';
|
||||
}, []);
|
||||
|
||||
// Redux에서 shopperHouse 검색 결과 가져오기 (simplified ref usage)
|
||||
// Redux에서 shopperHouse 검색 결과 및 에러 가져오기 (simplified ref usage)
|
||||
const shopperHouseData = useSelector((state) => state.search.shopperHouseData);
|
||||
const shopperHouseSearchId = useSelector((state) => state.search.shopperHouseSearchId); // 2차 발화용 searchId
|
||||
const shopperHouseError = useSelector((state) => state.search.shopperHouseError); // API 에러 정보
|
||||
const shopperHouseDataRef = useRef(null);
|
||||
const isInitializingRef = useRef(false); // overlay 초기화 중 플래그
|
||||
|
||||
@@ -438,6 +441,47 @@ const VoiceInputOverlay = ({
|
||||
}
|
||||
}, [isVisible, isListening, addWebSpeechEventLog]);
|
||||
|
||||
// ShopperHouse API 오류 감지 및 APIError 모드로 전환
|
||||
useEffect(() => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log('🔍 [DEBUG] shopperHouseError useEffect running:', {
|
||||
isVisible,
|
||||
hasError: !!shopperHouseError,
|
||||
error: shopperHouseError,
|
||||
currentMode,
|
||||
});
|
||||
}
|
||||
|
||||
// ✨ 초기화 중에는 오류 처리 안 함
|
||||
if (isInitializingRef.current) {
|
||||
if (DEBUG_MODE) {
|
||||
console.log('🔍 [DEBUG] Skipping error check - overlay is initializing');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 오류가 발생하고 overlay가 표시 중이며 현재 모드가 RESPONSE일 경우 APIError 모드로 전환
|
||||
if (isVisible && shopperHouseError && currentMode === VOICE_MODES.RESPONSE) {
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
'[VoiceInputOverlay] ❌ ShopperHouse API error detected, switching to APIERROR mode:',
|
||||
{
|
||||
error: shopperHouseError,
|
||||
currentMode,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// APIError 모드로 전환
|
||||
setCurrentMode(VOICE_MODES.APIERROR);
|
||||
setVoiceInputMode(null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup: 필요 시 정리 로직 추가
|
||||
};
|
||||
}, [shopperHouseError, isVisible, currentMode]);
|
||||
|
||||
// ShopperHouse API 응답 수신 시 overlay 닫기
|
||||
useEffect(() => {
|
||||
if (DEBUG_MODE) {
|
||||
@@ -776,6 +820,65 @@ const VoiceInputOverlay = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 🔄 API 에러 재시작 함수
|
||||
const handleApiErrorRestart = useCallback(() => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log('[VoiceInput] 🔄 Restarting after API error');
|
||||
}
|
||||
|
||||
// ShopperHouse 에러 상태 정리 (Redux)
|
||||
dispatch(clearShopperHouseData());
|
||||
|
||||
// 에러 메시지 정리
|
||||
setErrorMessage('');
|
||||
|
||||
// Input 필드 초기화
|
||||
if (onSearchChange) {
|
||||
onSearchChange({ value: '' });
|
||||
}
|
||||
|
||||
// PROMPT 모드로 복귀
|
||||
setCurrentMode(VOICE_MODES.PROMPT);
|
||||
setVoiceInputMode(null);
|
||||
setSttResponseText('');
|
||||
|
||||
if (DEBUG_MODE) {
|
||||
console.log('[VoiceInput] ✅ API error restart complete - ready for new input');
|
||||
}
|
||||
}, [dispatch, onSearchChange]);
|
||||
|
||||
// API 재시도 함수
|
||||
const handleApiErrorRetry = useCallback(() => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log('[VoiceInput] 🔄 Retrying API call after error');
|
||||
}
|
||||
|
||||
// 이전 에러 상태 정리
|
||||
dispatch(clearShopperHouseData());
|
||||
|
||||
// sttResponseText가 있으면 다시 API 호출
|
||||
if (sttResponseText && sttResponseText.trim().length >= 3) {
|
||||
const query = sttResponseText.trim();
|
||||
const currentSearchId = shopperHouseSearchIdRef.current;
|
||||
|
||||
if (DEBUG_MODE) {
|
||||
console.log('[VoiceInput] 🔄 Retrying API call');
|
||||
console.log('[VoiceInput] ├─ query:', query);
|
||||
console.log('[VoiceInput] └─ searchId:', currentSearchId || '(없음 - 첫 번째 발화)');
|
||||
}
|
||||
|
||||
// 다시 API 호출
|
||||
dispatch(getShopperHouseSearch(query, currentSearchId));
|
||||
|
||||
// RESPONSE 모드로 복귀 (로딩 상태 표시)
|
||||
setCurrentMode(VOICE_MODES.RESPONSE);
|
||||
} else {
|
||||
// 텍스트가 없으면 PROMPT 모드로 복귀
|
||||
setCurrentMode(VOICE_MODES.PROMPT);
|
||||
setSttResponseText('');
|
||||
}
|
||||
}, [dispatch, sttResponseText]);
|
||||
|
||||
// 🔄 WebSpeech 에러 재시작 함수
|
||||
const restartWebSpeech = useCallback(() => {
|
||||
if (DEBUG_MODE) {
|
||||
@@ -822,7 +925,7 @@ const VoiceInputOverlay = ({
|
||||
}, 300);
|
||||
}, [dispatch, onSearchChange, addWebSpeechEventLog]);
|
||||
|
||||
// Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트 설정 + API 자동 호출
|
||||
// Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트 설정 + API 자동 호출 + response 모드 전환
|
||||
const handleSuggestionClick = useCallback(
|
||||
(suggestion) => {
|
||||
if (DEBUG_MODE) {
|
||||
@@ -840,12 +943,30 @@ const VoiceInputOverlay = ({
|
||||
// ✨ 검색 기록에 추가
|
||||
addToSearchHistory(query);
|
||||
|
||||
// ✨ ShopperHouse API 자동 호출
|
||||
// ✨ RESPONSE 모드로 전환을 위한 텍스트 설정
|
||||
setSttResponseText(query);
|
||||
|
||||
// ✨ RESPONSE 모드로 직접 전환
|
||||
setCurrentMode(VOICE_MODES.RESPONSE);
|
||||
setVoiceInputMode(null);
|
||||
|
||||
// ✨ ShopperHouse API 자동 호출 (searchId 포함)
|
||||
if (query && query.length >= 3) {
|
||||
// ✅ Ref에서 최신 searchId 읽기 (이전 검색이 있는 경우 2차 발화 처리)
|
||||
const currentSearchId = shopperHouseSearchIdRef.current;
|
||||
if (DEBUG_MODE) {
|
||||
console.log('🔍 [DEBUG] Calling ShopperHouse API from bubble click with query:', query);
|
||||
console.log('🔍 [DEBUG] Calling ShopperHouse API from bubble click');
|
||||
console.log('[VoiceInput] ├─ query:', query);
|
||||
console.log('[VoiceInput] └─ searchId:', currentSearchId || '(없음 - 첫 번째 발화)');
|
||||
}
|
||||
try {
|
||||
dispatch(getShopperHouseSearch(query, currentSearchId));
|
||||
} catch (error) {
|
||||
console.error('[VoiceInput] ❌ API 호출 실패:', error);
|
||||
// 에러 발생 시 PROMPT 모드로 복귀
|
||||
setCurrentMode(VOICE_MODES.PROMPT);
|
||||
setVoiceInputMode(null);
|
||||
}
|
||||
dispatch(getShopperHouseSearch(query));
|
||||
}
|
||||
},
|
||||
[onSearchChange, dispatch, addToSearchHistory]
|
||||
@@ -982,6 +1103,20 @@ const VoiceInputOverlay = ({
|
||||
onRestart={restartWebSpeech}
|
||||
/>
|
||||
);
|
||||
case VOICE_MODES.APIERROR:
|
||||
if (DEBUG_MODE) {
|
||||
console.log(
|
||||
'💥 [DEBUG][VoiceInputOverlay] MODE = APIERROR | Rendering VoiceApiError with error details',
|
||||
shopperHouseError
|
||||
);
|
||||
}
|
||||
return (
|
||||
<VoiceApiError
|
||||
error={shopperHouseError}
|
||||
onRetry={handleApiErrorRetry}
|
||||
onRestart={handleApiErrorRestart}
|
||||
/>
|
||||
);
|
||||
case VOICE_MODES.MODE_3:
|
||||
// 추후 MODE_3 컴포넌트 추가
|
||||
return <VoiceNotRecognized />;
|
||||
@@ -1010,6 +1145,9 @@ const VoiceInputOverlay = ({
|
||||
countdown,
|
||||
errorMessage,
|
||||
restartWebSpeech,
|
||||
shopperHouseError,
|
||||
handleApiErrorRetry,
|
||||
handleApiErrorRestart,
|
||||
]);
|
||||
|
||||
// 마이크 버튼 포커스 핸들러 (VUI)
|
||||
@@ -1447,10 +1585,8 @@ const VoiceInputOverlay = ({
|
||||
<div className={css.modeContent}>{renderModeContent}</div>
|
||||
</OverlayContainer>
|
||||
|
||||
{/* WebSpeech 이벤트 전용 디버그 - 우측 하단에 표시 */}
|
||||
{DEBUG_MODE && webSpeechEventLogs.length > 0 && (
|
||||
<WebSpeechEventDebug logs={webSpeechEventLogs} />
|
||||
)}
|
||||
{/* WebSpeech 이벤트 전용 디버그 - 모드와 상관없이 항상 우측 하단에 표시 */}
|
||||
<WebSpeechEventDebug logs={webSpeechEventLogs} />
|
||||
</div>
|
||||
</TFullPopup>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Spottable from '@enact/spotlight/Spottable';
|
||||
import defaultErrorImg from '../../../../../assets/images/icons/ic-warning@3x.png';
|
||||
import CustomImage from '../../../../components/CustomImage/CustomImage';
|
||||
import css from './VoiceApiError.module.less';
|
||||
|
||||
const SpottableButton = Spottable('div');
|
||||
|
||||
const VoiceApiError = ({ error = null, onRestart = null, onRetry = null }) => {
|
||||
// 에러 객체에서 메시지 추출
|
||||
const getErrorMessage = (error) => {
|
||||
if (!error) return 'An unknown error occurred.';
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
// API 에러 객체 구조에 따라 메시지 추출
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (error.data && error.data.message) {
|
||||
return error.data.message;
|
||||
}
|
||||
|
||||
if (error.response && error.response.data && error.response.data.message) {
|
||||
return error.response.data.message;
|
||||
}
|
||||
|
||||
if (error.status) {
|
||||
return `API Error: ${error.status} - ${error.statusText || 'Unknown error'}`;
|
||||
}
|
||||
|
||||
return JSON.stringify(error);
|
||||
};
|
||||
|
||||
// 에러 상세 정보 추출
|
||||
const getErrorDetails = (error) => {
|
||||
if (!error) return null;
|
||||
|
||||
const details = [];
|
||||
|
||||
if (error.status) {
|
||||
details.push(`Status: ${error.status}`);
|
||||
}
|
||||
|
||||
if (error.code) {
|
||||
details.push(`Code: ${error.code}`);
|
||||
}
|
||||
|
||||
if (error.timestamp) {
|
||||
details.push(`Time: ${new Date(error.timestamp).toLocaleString()}`);
|
||||
}
|
||||
|
||||
if (error.endpoint) {
|
||||
details.push(`Endpoint: ${error.endpoint}`);
|
||||
}
|
||||
|
||||
return details.length > 0 ? details : null;
|
||||
};
|
||||
|
||||
const errorMessage = getErrorMessage(error);
|
||||
const errorDetails = getErrorDetails(error);
|
||||
|
||||
return (
|
||||
<div className={css.container}>
|
||||
<div className={css.errorBox}>
|
||||
<CustomImage src={defaultErrorImg} className={css.errorIcon} />
|
||||
<div className={css.errorContent}>
|
||||
<h2 className={css.errorTitle}>API Error</h2>
|
||||
<p className={css.errorMessage}>{errorMessage}</p>
|
||||
|
||||
{errorDetails && (
|
||||
<div className={css.errorDetails}>
|
||||
{errorDetails.map((detail, index) => (
|
||||
<div key={index} className={css.detailItem}>
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 전체 에러 객체 (개발용) */}
|
||||
{process.env.NODE_ENV === 'development' && error && (
|
||||
<div className={css.debugInfo}>
|
||||
<h4>Debug Info:</h4>
|
||||
<pre className={css.errorObject}>{JSON.stringify(error, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={css.buttonContainer}>
|
||||
{onRetry && (
|
||||
<SpottableButton
|
||||
className={css.retryButton}
|
||||
onClick={onRetry}
|
||||
spotlightId="api-error-retry-button"
|
||||
>
|
||||
Retry
|
||||
</SpottableButton>
|
||||
)}
|
||||
|
||||
{onRestart && (
|
||||
<SpottableButton
|
||||
className={css.restartButton}
|
||||
onClick={onRestart}
|
||||
spotlightId="api-error-restart-button"
|
||||
>
|
||||
Start Over
|
||||
</SpottableButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
VoiceApiError.propTypes = {
|
||||
error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
||||
onRestart: PropTypes.func,
|
||||
onRetry: PropTypes.func,
|
||||
};
|
||||
|
||||
VoiceApiError.defaultProps = {
|
||||
error: null,
|
||||
onRestart: null,
|
||||
onRetry: null,
|
||||
};
|
||||
|
||||
export default VoiceApiError;
|
||||
@@ -0,0 +1,162 @@
|
||||
@import "../../../../style/CommonStyle.module.less";
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
pointer-events: none; // 포커스 받지 않음
|
||||
position: relative;
|
||||
margin-top: -210px;
|
||||
|
||||
.errorBox {
|
||||
width: 800px;
|
||||
min-height: 400px;
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
border: 2px solid #ff4444;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
box-shadow: 0 8px 32px rgba(255, 68, 68, 0.3);
|
||||
|
||||
.errorIcon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 20px;
|
||||
filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(346deg) brightness(104%) contrast(97%);
|
||||
}
|
||||
|
||||
.errorContent {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.errorTitle {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: #ff4444;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
font-size: 36px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 24px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.errorDetails {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
width: 100%;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
|
||||
.detailItem {
|
||||
font-size: 24px;
|
||||
color: #ccc;
|
||||
margin-bottom: 8px;
|
||||
font-family: monospace;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.debugInfo {
|
||||
background-color: rgba(255, 68, 68, 0.1);
|
||||
border: 1px solid #ff4444;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
text-align: left;
|
||||
|
||||
h4 {
|
||||
color: #ff4444;
|
||||
font-size: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.errorObject {
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
pointer-events: all; // 버튼은 포커스 가능
|
||||
|
||||
.retryButton,
|
||||
.restartButton {
|
||||
min-width: 160px;
|
||||
height: 60px;
|
||||
background-color: #C70850;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: #ff0066;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(199, 8, 80, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.restartButton {
|
||||
background-color: #666;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: #888;
|
||||
box-shadow: 0 4px 16px rgba(102, 102, 102, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TV 리모컨 포커스 스타일
|
||||
.buttonContainer {
|
||||
.retryButton,
|
||||
.restartButton {
|
||||
&:focus {
|
||||
outline: 3px solid #00ff00;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user