[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:
2025-10-22 10:55:18 +09:00
parent 553aab4ce5
commit 94f06f9820
6 changed files with 559 additions and 40 deletions

View File

@@ -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",

View File

@@ -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 = () => {
// ✨ 검색 키 초기화 - 진행 중인 요청의 응답을 무시하도록

View File

@@ -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: {

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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;
}
}
}