[251016] fix: VoiceInputOverlay Search API Call

🕐 커밋 시간: 2025. 10. 16. 13:18:03

📊 변경 통계:
  • 총 파일: 5개
  • 추가: +112줄
  • 삭제: -27줄

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

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/SearchPanel/SearchResults.new.jsx (javascript):
     Added: SearchResultsNew()
     Deleted: SearchResultsNew()

🔧 주요 변경 내용:
  • 핵심 비즈니스 로직 개선
This commit is contained in:
2025-10-16 13:18:05 +09:00
parent e93b379c51
commit 65318a820a
5 changed files with 153 additions and 127 deletions

View File

@@ -36,6 +36,9 @@ export const searchReducer = (state = initialState, action) => {
totalCount: updatedTotalCount,
searchPerformed: true,
initPerformed: !action.append,
// 일반 검색 시 ShopperHouse 데이터 초기화
shopperHouseData: null,
shopperHouseSearchId: null,
};
}
@@ -67,6 +70,11 @@ export const searchReducer = (state = initialState, action) => {
...state,
shopperHouseData: resultData,
shopperHouseSearchId: searchId,
// ShopperHouse 검색 시 일반 검색 데이터 초기화
searchDatas: {},
totalCount: {},
searchPerformed: false,
initPerformed: false,
};
}

View File

@@ -78,6 +78,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId, scrollOpt
const { searchDatas: searchDatas } = useSelector((state) => state.search);
const searchPerformed = useSelector((state) => state.search.searchPerformed);
const panels = useSelector((state) => state.panels.panels);
const shopperHouseData = useSelector((state) => state.search.shopperHouseData);
const [firstSpot, setFirstSpot] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
@@ -249,13 +250,13 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId, scrollOpt
);
// 검색 완료 후 결과에 따른 Toast 표시
if (searchPerformed && searchQuery.trim()) {
if (result > 0) {
dispatch(showSearchSuccessToast(searchQuery, result));
} else {
dispatch(showSearchErrorToast(searchQuery));
}
}
// if (searchPerformed && searchQuery.trim()) {
// if (result > 0) {
// dispatch(showSearchSuccessToast(searchQuery, result));
// } else {
// dispatch(showSearchErrorToast(searchQuery));
// }
// }
}
}, [searchDatas, searchPerformed, searchQuery, dispatch]);
@@ -617,11 +618,12 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId, scrollOpt
{/* 결과갑 부분 작업중 끝! */}
{/* 검색 결과 표시 영역 */}
{searchPerformed && searchQuery !== null ? (
{(searchPerformed && searchQuery !== null) || shopperHouseData ? (
<SearchResultsNew
themeInfo={searchDatas.theme}
itemInfo={searchDatas.item}
showInfo={searchDatas.show}
shopperHouseInfo={shopperHouseData}
/>
) : (
<ContainerBasic className={css.contentContainer}>

View File

@@ -1,27 +1,19 @@
import React, {
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import { useDispatch } from 'react-redux';
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 hotPicksImage from '../../../assets/images/searchpanel/img-hotpicks.png';
import hotPicksBrandImage
from '../../../assets/images/searchpanel/img-search-hotpicks.png';
import hotPicksBrandImage from '../../../assets/images/searchpanel/img-search-hotpicks.png';
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 { $L } from '../../utils/helperMethods';
import { SpotlightIds } from '../../utils/SpotlightIds';
import css from './SearchResults.new.module.less';
@@ -30,9 +22,28 @@ import ShowCard from './SearchResultsNew/ShowCard';
const ITEMS_PER_PAGE = 10;
const SearchResultsNew = ({ itemInfo, showInfo, themeInfo }) => {
const SearchResultsNew = ({ itemInfo, showInfo, themeInfo, shopperHouseInfo }) => {
// ShopperHouse 데이터를 ItemCard 형식으로 변환
const convertedShopperHouseItems = useMemo(() => {
if (!shopperHouseInfo || !shopperHouseInfo.results || shopperHouseInfo.results.length === 0) {
return null;
}
const docs = shopperHouseInfo.results[0].docs || [];
return docs.map((doc, index) => ({
thumbnail: doc.thumbNail || doc.imgPath || '', // 이미지 경로
title: doc.title || doc.prdtName || '', // 제목
dcPrice: doc.dcPrice || doc.price || '', // 할인가격
price: doc.orgPrice || doc.price || '', // 원가
soldout: doc.soldout || false, // 품절 여부
contentId: `shopperhouse_${doc.partnerName}_${doc.prdtId || index}_${doc.partnerId || ''}_${doc.partnerId || ''}_${doc.prdtId || index}`, // contentId 생성
reviewGrade: doc.reviewGrade || '', // 리뷰 점수 (추가 정보)
partnerName: doc.partnerName || '', // 파트너 이름
}));
}, [shopperHouseInfo]);
const getButtonTabList = () => {
const itemLength = itemInfo?.length || 0;
// ShopperHouse 데이터가 있으면 그것을 사용, 없으면 기존 검색 결과 사용
const itemLength = convertedShopperHouseItems?.length || itemInfo?.length || 0;
const showLength = showInfo?.length || 0;
return [
@@ -58,8 +69,8 @@ const SearchResultsNew = ({ itemInfo, showInfo, themeInfo }) => {
buttonTabList = getButtonTabList();
}
// 현재 탭의 데이터 가져오기
const currentData = tab === 0 ? itemInfo : showInfo;
// 현재 탭의 데이터 가져오기 - ShopperHouse 데이터 우선
const currentData = tab === 0 ? convertedShopperHouseItems || itemInfo : showInfo;
// 표시할 데이터 (처음부터 visibleCount 개수만큼)
const displayedData = useMemo(() => {
@@ -107,8 +118,8 @@ const SearchResultsNew = ({ itemInfo, showInfo, themeInfo }) => {
[dropDownTab]
);
const SpottableLi = Spottable("li");
const SpottableDiv = Spottable("div");
const SpottableLi = Spottable('li');
const SpottableDiv = Spottable('div');
// 맨 처음으로 이동 (위 버튼)
const upBtnClick = () => {
@@ -127,8 +138,7 @@ const SearchResultsNew = ({ itemInfo, showInfo, themeInfo }) => {
// ProductCard 컴포넌트
const renderItem = useCallback(
({ index, ...rest }) => {
const { bgImgPath, title, partnerLogo, partnerName, keyword } =
themeInfo[index];
const { bgImgPath, title, partnerLogo, partnerName, keyword } = themeInfo[index];
return (
<SpottableDiv
key={`searchProduct-${index}`}
@@ -141,11 +151,7 @@ const SearchResultsNew = ({ itemInfo, showInfo, themeInfo }) => {
</div>
<div className={css.productInfo}>
<div className={css.productBrandWrapper}>
<img
src={partnerLogo}
alt={partnerName}
className={css.brandLogo}
/>
<img src={partnerLogo} alt={partnerName} className={css.brandLogo} />
</div>
<div className={css.productDetails}>
{keyword && (
@@ -178,13 +184,11 @@ const SearchResultsNew = ({ itemInfo, showInfo, themeInfo }) => {
<div
className={css.hotpicksSection}
data-wheel-point={true}
spotlightId={"hot-picks-section"}
spotlightId={'hot-picks-section'}
>
<div className={css.sectionHeader}>
<div className={css.sectionIndicator}></div>
<div className={css.sectionTitle}>
Hot Picks ({themeInfo?.length})
</div>
<div className={css.sectionTitle}>Hot Picks ({themeInfo?.length})</div>
</div>
<div className={css.productList}>
<TVirtualGridList

View File

@@ -1,30 +1,18 @@
// src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
import React, {
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import React, { useCallback, useEffect, 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 } from '../../../actions/searchActions';
import TFullPopup from '../../../components/TFullPopup/TFullPopup';
import TInput, {
ICONS,
KINDS,
} from '../../../components/TInput/TInput';
import TInput, { ICONS, KINDS } from '../../../components/TInput/TInput';
import VoiceListening from './modes/VoiceListening';
import VoiceNotRecognized from './modes/VoiceNotRecognized';
import VoiceNotRecognizedCircle from './modes/VoiceNotRecognizedCircle';
@@ -33,53 +21,67 @@ import css from './VoiceInputOverlay.module.less';
const OverlayContainer = SpotlightContainerDecorator(
{
enterTo: "default-element",
restrict: "self-only", // 포커스를 overlay 내부로만 제한
enterTo: 'default-element',
restrict: 'self-only', // 포커스를 overlay 내부로만 제한
},
"div"
'div'
);
const SpottableMicButton = Spottable("div");
const SpottableMicButton = Spottable('div');
// Voice overlay 모드 상수
export const VOICE_MODES = {
PROMPT: "prompt", // Try saying 화면
LISTENING: "listening", // 듣는 중 화면
MODE_3: "mode3", // 추후 추가
MODE_4: "mode4", // 추후 추가
PROMPT: 'prompt', // Try saying 화면
LISTENING: 'listening', // 듣는 중 화면
MODE_3: 'mode3', // 추후 추가
MODE_4: 'mode4', // 추후 추가
};
const OVERLAY_SPOTLIGHT_ID = "voice-input-overlay-container";
const INPUT_SPOTLIGHT_ID = "voice-overlay-input-box";
const MIC_SPOTLIGHT_ID = "voice-overlay-mic-button";
const OVERLAY_SPOTLIGHT_ID = 'voice-input-overlay-container';
const INPUT_SPOTLIGHT_ID = 'voice-overlay-input-box';
const MIC_SPOTLIGHT_ID = 'voice-overlay-mic-button';
const VoiceInputOverlay = ({
isVisible,
onClose,
mode = VOICE_MODES.PROMPT,
suggestions = [],
searchQuery = "",
searchQuery = '',
onSearchChange,
onSearchSubmit,
}) => {
const dispatch = useDispatch();
const lastFocusedElement = useRef(null);
const [inputFocus, setInputFocus] = useState(false);
const [micFocused, setMicFocused] = useState(false);
// 내부 모드 상태 관리 (prompt -> listening -> close)
const [currentMode, setCurrentMode] = useState(mode);
// Redux에서 voice 상태 가져오기
const { isRegistered, lastSTTText, sttTimestamp } = useSelector(
(state) => state.voice
);
const { isRegistered, lastSTTText, sttTimestamp } = useSelector((state) => state.voice);
// Redux에서 shopperHouse 검색 결과 가져오기
const shopperHouseData = useSelector((state) => state.search.shopperHouseData);
const shopperHouseDataRef = useRef(shopperHouseData);
// ShopperHouse API 응답 수신 시 overlay 닫기
useEffect(() => {
// 이전 값과 비교하여 새로운 데이터가 들어왔을 때만 닫기
if (isVisible && shopperHouseData && shopperHouseData !== shopperHouseDataRef.current) {
console.log('[VoiceInputOverlay] ShopperHouse data received, closing overlay');
shopperHouseDataRef.current = shopperHouseData;
// 약간의 지연 후 닫기 (사용자가 결과를 인지할 수 있도록)
setTimeout(() => {
onClose();
}, 500);
}
}, [shopperHouseData, isVisible, onClose]);
// STT 텍스트 수신 시 처리
useEffect(() => {
if (lastSTTText && sttTimestamp && isVisible) {
console.log(
"[VoiceInputOverlay] STT text received in overlay:",
lastSTTText
);
console.log('[VoiceInputOverlay] STT text received in overlay:', lastSTTText);
// 입력창에 텍스트 표시 (부모 컴포넌트로 전달)
if (onSearchChange) {
@@ -122,9 +124,9 @@ const VoiceInputOverlay = ({
// Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트만 설정
const handleSuggestionClick = useCallback(
(suggestion) => {
console.log("[VoiceInputOverlay] Suggestion clicked:", suggestion);
console.log('[VoiceInputOverlay] Suggestion clicked:', suggestion);
// 따옴표 제거
const query = suggestion.replace(/^["']|["']$/g, "").trim();
const query = suggestion.replace(/^["']|["']$/g, '').trim();
// Input 창에 텍스트 설정
if (onSearchChange) {
onSearchChange({ value: query });
@@ -133,40 +135,45 @@ const VoiceInputOverlay = ({
[onSearchChange]
);
// Input 창에서 API 호출 핸들러 (엔터키 또는 돋보기 아이콘 클릭)
// Input 창에서 API 호출 핸들러 (돋보기 아이콘 클릭 시에만)
const handleSearchSubmit = useCallback(() => {
console.log("[VoiceInputOverlay] Search submit:", searchQuery);
console.log('[VoiceInputOverlay] Search submit:', searchQuery);
if (searchQuery && searchQuery.trim()) {
// ShopperHouse API 호출
dispatch(getShopperHouseSearch(searchQuery.trim()));
// Input 내용 비우기
if (onSearchChange) {
onSearchChange({ value: '' });
}
// API 호출 후 Input 박스로 포커스 이동
setTimeout(() => {
Spotlight.focus(INPUT_SPOTLIGHT_ID);
}, 100);
// VoiceInputOverlay는 SearchPanel과 다른 API를 사용하므로 onSearchSubmit 호출 안 함
// if (onSearchSubmit) {
// onSearchSubmit(searchQuery);
// }
}
}, [dispatch, searchQuery]);
}, [dispatch, searchQuery, onSearchChange]);
// Input 창에서 엔터키 핸들러
const handleInputKeyDown = useCallback(
(e) => {
if (e.key === "Enter" || e.keyCode === 13) {
e.preventDefault();
handleSearchSubmit();
}
},
[handleSearchSubmit]
);
// Input 창에서 엔터키 핸들러 (API 호출하지 않음)
const handleInputKeyDown = useCallback((e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
// Enter 키로는 API 호출하지 않음
// 돋보기 아이콘 클릭/Enter로만 API 호출
}
}, []);
// 모드에 따른 컨텐츠 렌더링
const renderModeContent = () => {
switch (currentMode) {
case VOICE_MODES.PROMPT:
return (
<VoicePromptScreen
suggestions={suggestions}
onSuggestionClick={handleSuggestionClick}
/>
<VoicePromptScreen suggestions={suggestions} onSuggestionClick={handleSuggestionClick} />
);
case VOICE_MODES.LISTENING:
return <VoiceListening />;
@@ -178,10 +185,7 @@ const VoiceInputOverlay = ({
return <VoiceNotRecognizedCircle />;
default:
return (
<VoicePromptScreen
suggestions={suggestions}
onSuggestionClick={handleSuggestionClick}
/>
<VoicePromptScreen suggestions={suggestions} onSuggestionClick={handleSuggestionClick} />
);
}
};
@@ -195,13 +199,19 @@ const VoiceInputOverlay = ({
setInputFocus(false);
}, []);
// 마이크 버튼 포커스 핸들러
const handleMicFocus = useCallback(() => {
setMicFocused(true);
}, []);
const handleMicBlur = useCallback(() => {
setMicFocused(false);
}, []);
// 마이크 버튼 클릭 (모드 전환: prompt -> listening -> close)
const handleMicClick = useCallback(
(e) => {
console.log(
"[VoiceInputOverlay] handleMicClick called, currentMode:",
currentMode
);
console.log('[VoiceInputOverlay] handleMicClick called, currentMode:', currentMode);
// 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지
if (e && e.stopPropagation) {
@@ -213,17 +223,17 @@ const VoiceInputOverlay = ({
if (currentMode === VOICE_MODES.PROMPT) {
// prompt 모드에서 클릭 시 -> listening 모드로 전환
console.log("[VoiceInputOverlay] Switching to LISTENING mode");
console.log('[VoiceInputOverlay] Switching to LISTENING mode');
setCurrentMode(VOICE_MODES.LISTENING);
// 이 시점에서 webOS Voice Framework가 자동으로 음성 인식 시작
// 이 시점에서 webOS Voice Framework가 자동으로 음성인식 시작
// (이미 registerVoiceFramework()로 등록되어 있으므로)
} else if (currentMode === VOICE_MODES.LISTENING) {
// listening 모드에서 클릭 시 -> 종료
console.log("[VoiceInputOverlay] Closing from LISTENING mode");
console.log('[VoiceInputOverlay] Closing from LISTENING mode');
onClose();
} else {
// 기타 모드에서는 바로 종료
console.log("[VoiceInputOverlay] Closing from other mode");
console.log('[VoiceInputOverlay] Closing from other mode');
onClose();
}
},
@@ -233,7 +243,7 @@ const VoiceInputOverlay = ({
// dim 레이어 클릭 핸들러 (마이크 버튼과 분리)
const handleDimClick = useCallback(
(e) => {
console.log("[VoiceInputOverlay] dimBackground clicked");
console.log('[VoiceInputOverlay] dimBackground clicked');
onClose();
},
[onClose]
@@ -255,17 +265,17 @@ const VoiceInputOverlay = ({
<div className={css.dimBackground} onClick={handleDimClick} />
{/* Voice 등록 상태 표시 (디버깅용) */}
{process.env.NODE_ENV === "development" && (
{process.env.NODE_ENV === 'development' && (
<div
style={{
position: "absolute",
position: 'absolute',
top: 10,
right: 10,
color: "#fff",
color: '#fff',
zIndex: 10000,
}}
>
Voice: {isRegistered ? "✓ Ready" : "✗ Not Ready"}
Voice: {isRegistered ? '✓ Ready' : '✗ Not Ready'}
</div>
)}
@@ -276,10 +286,7 @@ const VoiceInputOverlay = ({
spotlightDisabled={!isVisible}
>
{/* 입력창과 마이크 버튼 - SearchPanel.inputContainer와 동일한 구조 */}
<div
className={css.inputWrapper}
onClick={(e) => e.stopPropagation()}
>
<div className={css.inputWrapper} onClick={(e) => e.stopPropagation()}>
<div className={css.searchInputWrapper}>
<TInput
className={css.inputBox}
@@ -297,22 +304,23 @@ const VoiceInputOverlay = ({
className={classNames(
css.microphoneButton,
css.active,
currentMode === VOICE_MODES.LISTENING && css.listening
currentMode === VOICE_MODES.LISTENING && css.listening,
micFocused && css.focused
)}
onClick={handleMicClick}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (e.key === 'Enter' || e.keyCode === 13) {
e.preventDefault();
e.stopPropagation();
handleMicClick(e);
}
}}
onFocus={handleMicFocus}
onBlur={handleMicBlur}
spotlightId={MIC_SPOTLIGHT_ID}
>
<div className={css.microphoneCircle}>
<img
src={micIcon}
alt="Microphone"
className={css.microphoneIcon}
/>
<img src={micIcon} alt="Microphone" className={css.microphoneIcon} />
</div>
{currentMode === VOICE_MODES.LISTENING && (
<svg className={css.rippleSvg} width="100" height="100">
@@ -352,7 +360,7 @@ VoiceInputOverlay.propTypes = {
VoiceInputOverlay.defaultProps = {
mode: VOICE_MODES.PROMPT,
suggestions: [],
searchQuery: "",
searchQuery: '',
onSearchChange: null,
onSearchSubmit: null,
};

View File

@@ -155,16 +155,12 @@
&:hover {
.microphoneCircle {
border-color: @PRIMARY_COLOR_RED;
// border-color: @PRIMARY_COLOR_RED;
border-color: white;
}
}
&:focus {
.microphoneCircle {
border-color: @PRIMARY_COLOR_RED;
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
}
}
// active 상태 (음성 입력 모드 - 항상 빨간색)
&.active {
@@ -179,6 +175,14 @@
}
}
&.active.focused {
.microphoneCircle {
background-color: @PRIMARY_COLOR_RED;
border-color: white;
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
}
}
// listening 상태 (배경 투명, 테두리 ripple 애니메이션)
&.listening {
.microphoneCircle {