[251024] [251025] feat: SearchPanel.new.v2 HowAboutThese 검색 대기화면
🕐 커밋 시간: 2025. 10. 24. 20:45:45 📊 변경 통계: • 총 파일: 3개 • 추가: +67줄 • 삭제: -1줄 📝 수정된 파일: ~ 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/VoiceInputOverlay/VoiceInputOverlay.jsx 🔧 함수 변경 내용: 📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx (javascript): ✅ Added: clearAllTimers()
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
resetSearch,
|
||||
resetVoiceSearch,
|
||||
clearShopperHouseData,
|
||||
getShopperHouseSearch,
|
||||
} from '../../actions/searchActions';
|
||||
// import {
|
||||
// showErrorToast,
|
||||
@@ -179,6 +180,9 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
|
||||
|
||||
// ✨ [Phase 1] SearchPanel의 현재 모드 상태 (VoiceInputOverlay의 VOICE_MODES와 동일한 개념)
|
||||
const [currentMode, setCurrentMode] = useState(SEARCH_PANEL_MODES.INITIAL);
|
||||
const [voiceOverlayMode, setVoiceOverlayMode] = useState(VOICE_MODES.PROMPT);
|
||||
const [voiceOverlayResponseText, setVoiceOverlayResponseText] = useState('');
|
||||
const [isVoiceOverlayBubbleSearch, setIsVoiceOverlayBubbleSearch] = useState(false);
|
||||
|
||||
// 🐛 [DEBUG] shopperHouseData 상태 변경 추적 (DEBUG_MODE가 true일 경우에만)
|
||||
useEffect(() => {
|
||||
@@ -438,6 +442,9 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
|
||||
isVoiceOverlayVisible
|
||||
);
|
||||
}
|
||||
setVoiceOverlayMode(VOICE_MODES.PROMPT);
|
||||
setVoiceOverlayResponseText('');
|
||||
setIsVoiceOverlayBubbleSearch(false);
|
||||
setIsVoiceOverlayVisible(true);
|
||||
// setIsVoiceOverlayVisible((prev) => !prev);
|
||||
}, [isVoiceOverlayVisible]);
|
||||
@@ -646,6 +653,9 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
|
||||
// VoiceInputOverlay가 닫히는 순간 clearShopperHouseData()를 호출하면,
|
||||
// SearchPanel에서 shopperHouseData를 표시할 수 없으므로 여기서는 정리하지 않음
|
||||
|
||||
setVoiceOverlayMode(VOICE_MODES.PROMPT);
|
||||
setVoiceOverlayResponseText('');
|
||||
setIsVoiceOverlayBubbleSearch(false);
|
||||
setIsVoiceOverlayVisible(false);
|
||||
// ✅ VoiceOverlay가 닫힐 때 항상 TInput으로 포커스 이동
|
||||
setTimeout(() => {
|
||||
@@ -653,6 +663,27 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
|
||||
}, 150); // Overlay 닫히는 시간을 고려한 지연
|
||||
}, []);
|
||||
|
||||
const handleHowAboutTheseQueryClick = useCallback(
|
||||
(rawQuery) => {
|
||||
if (!rawQuery) return;
|
||||
|
||||
const trimmedQuery = typeof rawQuery === 'string' ? rawQuery.trim() : '';
|
||||
if (!trimmedQuery) return;
|
||||
|
||||
// 검색 입력 필드에도 동일한 쿼리를 표시
|
||||
setSearchQuery(trimmedQuery);
|
||||
|
||||
// Voice overlay를 RESPONSE 모드로 전환하여 대기 화면 표시
|
||||
setVoiceOverlayResponseText(trimmedQuery);
|
||||
setVoiceOverlayMode(VOICE_MODES.RESPONSE);
|
||||
setIsVoiceOverlayBubbleSearch(true);
|
||||
setIsVoiceOverlayVisible(true);
|
||||
|
||||
dispatch(getShopperHouseSearch(trimmedQuery, shopperHouseSearchId));
|
||||
},
|
||||
[dispatch, shopperHouseSearchId]
|
||||
);
|
||||
|
||||
// ✨ [Phase 3] handleInputIconClick 제거 (돋보기 아이콘 기능 비활성화)
|
||||
// /**
|
||||
// * ✨ [Phase 3] TInput icon click handler (showVirtualKeyboard 제거)
|
||||
@@ -770,6 +801,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
|
||||
shopperHouseRelativeQueries={shopperHouseRelativeQueries}
|
||||
keywordClick={handleKeywordClick}
|
||||
panelInfo={panelInfo}
|
||||
onRelativeQueryClick={handleHowAboutTheseQueryClick}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1331,12 +1363,14 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
|
||||
<VoiceInputOverlay
|
||||
isVisible={isVoiceOverlayVisible}
|
||||
onClose={handleVoiceOverlayClose}
|
||||
mode={VOICE_MODES.PROMPT}
|
||||
mode={voiceOverlayMode}
|
||||
suggestions={voiceSuggestions}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSearchSubmit={handleSearchSubmit}
|
||||
isVoiceResultMode={currentMode === SEARCH_PANEL_MODES.VOICE_RESULT}
|
||||
externalResponseText={voiceOverlayResponseText}
|
||||
isExternalBubbleSearch={isVoiceOverlayBubbleSearch}
|
||||
/>
|
||||
|
||||
{/* ✨ [Phase 1] Search Input Overlay - currentMode로 visibility 제어 */}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
@@ -16,8 +10,7 @@ import upBtnImg from '../../../assets/images/btn/search_btn_up_arrow.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 HowAboutThese from './HowAboutThese/HowAboutThese';
|
||||
@@ -69,9 +62,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 = ({
|
||||
@@ -82,14 +73,11 @@ const SearchResultsNew = ({
|
||||
shopperHouseInfo,
|
||||
shopperHouseSearchId = null,
|
||||
shopperHouseRelativeQueries = [],
|
||||
onRelativeQueryClick,
|
||||
}) => {
|
||||
// ShopperHouse 데이터를 ItemCard 형식으로 변환
|
||||
const convertedShopperHouseItems = useMemo(() => {
|
||||
if (
|
||||
!shopperHouseInfo ||
|
||||
!shopperHouseInfo.results ||
|
||||
shopperHouseInfo.results.length === 0
|
||||
) {
|
||||
if (!shopperHouseInfo || !shopperHouseInfo.results || shopperHouseInfo.results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -125,8 +113,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 [
|
||||
@@ -148,9 +135,7 @@ const SearchResultsNew = ({
|
||||
const cbChangePageRef = useRef(null);
|
||||
|
||||
// HowAboutThese 모드 상태 관리
|
||||
const [howAboutTheseMode, setHowAboutTheseMode] = useState(
|
||||
HOW_ABOUT_THESE_MODES.SMALL
|
||||
);
|
||||
const [howAboutTheseMode, setHowAboutTheseMode] = useState(HOW_ABOUT_THESE_MODES.SMALL);
|
||||
|
||||
// HowAboutThese 모드 전환 핸들러
|
||||
const handleShowFullHowAboutThese = useCallback(() => {
|
||||
@@ -166,8 +151,7 @@ const SearchResultsNew = ({
|
||||
const buttonTabList = useMemo(() => getButtonTabList(), [getButtonTabList]);
|
||||
|
||||
// 현재 탭의 데이터 가져오기 - ShopperHouse 데이터 우선
|
||||
const currentData =
|
||||
tab === 0 ? convertedShopperHouseItems || itemInfo : showInfo;
|
||||
const currentData = tab === 0 ? convertedShopperHouseItems || itemInfo : showInfo;
|
||||
|
||||
// 표시할 데이터 (처음부터 visibleCount 개수만큼)
|
||||
const displayedData = useMemo(() => {
|
||||
@@ -255,8 +239,7 @@ const SearchResultsNew = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const { bgImgPath, title, partnerLogo, partnerName, keyword } =
|
||||
themeInfo[index];
|
||||
const { bgImgPath, title, partnerLogo, partnerName, keyword } = themeInfo[index];
|
||||
return (
|
||||
<SpottableDiv
|
||||
key={`searchProduct-${index}`}
|
||||
@@ -265,19 +248,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 && (
|
||||
@@ -336,6 +311,7 @@ const SearchResultsNew = ({
|
||||
<HowAboutTheseSmall
|
||||
relativeQueries={relativeQueries}
|
||||
searchId={shopperHouseSearchId}
|
||||
onQueryClick={onRelativeQueryClick}
|
||||
onSeeMoreClick={handleShowFullHowAboutThese}
|
||||
/>
|
||||
</SpottableDiv>
|
||||
@@ -347,6 +323,12 @@ const SearchResultsNew = ({
|
||||
<HowAboutThese
|
||||
relativeQueries={relativeQueries}
|
||||
searchId={shopperHouseSearchId}
|
||||
onQueryClick={(query) => {
|
||||
if (onRelativeQueryClick) {
|
||||
onRelativeQueryClick(query);
|
||||
}
|
||||
handleCloseHowAboutThese();
|
||||
}}
|
||||
onClose={handleCloseHowAboutThese}
|
||||
/>
|
||||
</div>
|
||||
@@ -355,9 +337,7 @@ const SearchResultsNew = ({
|
||||
<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
|
||||
@@ -403,11 +383,7 @@ const SearchResultsNew = ({
|
||||
<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}>
|
||||
|
||||
@@ -121,7 +121,10 @@ const VoiceInputOverlay = ({
|
||||
mode = VOICE_MODES.PROMPT,
|
||||
searchQuery = '',
|
||||
onSearchChange,
|
||||
onSearchSubmit,
|
||||
isVoiceResultMode = false,
|
||||
externalResponseText = '',
|
||||
isExternalBubbleSearch = false,
|
||||
}) => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log('🔄 [DEBUG] VoiceInputOverlay render - isVisible:', isVisible, 'mode:', mode);
|
||||
@@ -141,6 +144,7 @@ const VoiceInputOverlay = ({
|
||||
const countdownIntervalRef = useRef(null);
|
||||
const sttDebounceTimerRef = useRef(null); // STT 결과 debounce 타이머
|
||||
const silenceDetectionTimerRef = useRef(null); // 3초 silence detection 타이머
|
||||
const externalResponseTextRef = useRef('');
|
||||
|
||||
// All timer refs array for batch cleanup
|
||||
const allTimerRefs = [
|
||||
@@ -1094,6 +1098,7 @@ const VoiceInputOverlay = ({
|
||||
setSttResponseText('');
|
||||
setErrorMessage('');
|
||||
setIsBubbleClickSearch(false); // bubble 클릭 상태 초기화
|
||||
externalResponseTextRef.current = '';
|
||||
|
||||
// 3. Redux 정리 (VoiceInputOverlay의 책임)
|
||||
// dispatch(clearShopperHouseData());
|
||||
@@ -1103,6 +1108,21 @@ const VoiceInputOverlay = ({
|
||||
onClose();
|
||||
}, [onClose, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !isExternalBubbleSearch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextText = typeof externalResponseText === 'string' ? externalResponseText : '';
|
||||
|
||||
if (externalResponseTextRef.current !== nextText) {
|
||||
setSttResponseText(nextText);
|
||||
externalResponseTextRef.current = nextText;
|
||||
}
|
||||
|
||||
setIsBubbleClickSearch(true);
|
||||
}, [externalResponseText, isExternalBubbleSearch, isVisible]);
|
||||
|
||||
// Suggestion 버튼 클릭 핸들러 - Input 창에 텍스트 설정 + API 자동 호출 + response 모드 전환
|
||||
const handleSuggestionClick = useCallback(
|
||||
(suggestion) => {
|
||||
@@ -1843,6 +1863,8 @@ VoiceInputOverlay.propTypes = {
|
||||
onSearchChange: PropTypes.func,
|
||||
onSearchSubmit: PropTypes.func,
|
||||
isVoiceResultMode: PropTypes.bool,
|
||||
externalResponseText: PropTypes.string,
|
||||
isExternalBubbleSearch: PropTypes.bool,
|
||||
};
|
||||
|
||||
VoiceInputOverlay.defaultProps = {
|
||||
@@ -1851,6 +1873,8 @@ VoiceInputOverlay.defaultProps = {
|
||||
onSearchChange: null,
|
||||
onSearchSubmit: null,
|
||||
isVoiceResultMode: false,
|
||||
externalResponseText: '',
|
||||
isExternalBubbleSearch: false,
|
||||
};
|
||||
|
||||
export default VoiceInputOverlay;
|
||||
|
||||
Reference in New Issue
Block a user