Merge branch 'detail_v3' of http://gitlab.t-win.kr/ifheone/shoptime into detail_v3

This commit is contained in:
2025-10-16 09:06:01 +09:00
7 changed files with 401 additions and 74 deletions

View File

@@ -7,9 +7,11 @@ import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDeco
import Spottable from '@enact/spotlight/Spottable';
import TInput, { ICONS, KINDS } from '../../../components/TInput/TInput';
import TFullPopup from '../../../components/TFullPopup/TFullPopup';
import micIcon from '../../../../assets/images/searchpanel/image-mic.png';
import css from './VoiceInputOverlay.module.less';
import VoicePromptScreen from './modes/VoicePromptScreen';
import VoiceListening from './modes/VoiceListening';
const OverlayContainer = SpotlightContainerDecorator(
{
@@ -44,26 +46,8 @@ const VoiceInputOverlay = ({
}) => {
const lastFocusedElement = useRef(null);
const [inputFocus, setInputFocus] = useState(false);
// ESC 키 핸들러
const handleKeyDown = useCallback(
(e) => {
if (e.key === 'Escape') {
onClose();
}
},
[onClose]
);
// 키보드 이벤트 리스너 등록
useEffect(() => {
if (isVisible) {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}
}, [isVisible, handleKeyDown]);
// 내부 모드 상태 관리 (prompt -> listening -> close)
const [currentMode, setCurrentMode] = useState(mode);
// Overlay가 열릴 때 포커스를 overlay 내부로 이동
useEffect(() => {
@@ -71,6 +55,9 @@ const VoiceInputOverlay = ({
// 현재 포커스된 요소 저장
lastFocusedElement.current = Spotlight.getCurrent();
// 모드 초기화 (항상 prompt 모드로 시작)
setCurrentMode(mode);
// Overlay 내부로 포커스 이동
setTimeout(() => {
Spotlight.focus(OVERLAY_SPOTLIGHT_ID);
@@ -83,16 +70,15 @@ const VoiceInputOverlay = ({
}, 100);
}
}
}, [isVisible]);
}, [isVisible, mode]);
// 모드에 따른 컨텐츠 렌더링
const renderModeContent = () => {
switch (mode) {
switch (currentMode) {
case VOICE_MODES.PROMPT:
return <VoicePromptScreen suggestions={suggestions} />;
case VOICE_MODES.MODE_2:
// 추후 MODE_2 컴포넌트 추가
return <div>Mode 2 (Coming soon)</div>;
case VOICE_MODES.LISTENING:
return <VoiceListening />;
case VOICE_MODES.MODE_3:
// 추후 MODE_3 컴포넌트 추가
return <div>Mode 3 (Coming soon)</div>;
@@ -113,59 +99,113 @@ const VoiceInputOverlay = ({
setInputFocus(false);
}, []);
// 마이크 버튼 클릭 (overlay 닫기)
const handleMicClick = useCallback(() => {
// 마이크 버튼 클릭 (모드 전환: prompt -> listening -> close)
const handleMicClick = useCallback((e) => {
console.log('[VoiceInputOverlay] handleMicClick called, currentMode:', currentMode);
// 이벤트 전파 방지 - dim 레이어의 onClick 실행 방지
if (e && e.stopPropagation) {
e.stopPropagation();
}
if (e && e.nativeEvent && e.nativeEvent.stopImmediatePropagation) {
e.nativeEvent.stopImmediatePropagation();
}
if (currentMode === VOICE_MODES.PROMPT) {
// prompt 모드에서 클릭 시 -> listening 모드로 전환
console.log('[VoiceInputOverlay] Switching to LISTENING mode');
setCurrentMode(VOICE_MODES.LISTENING);
} else if (currentMode === VOICE_MODES.LISTENING) {
// listening 모드에서 클릭 시 -> 종료
console.log('[VoiceInputOverlay] Closing from LISTENING mode');
onClose();
} else {
// 기타 모드에서는 바로 종료
console.log('[VoiceInputOverlay] Closing from other mode');
onClose();
}
}, [currentMode, onClose]);
// dim 레이어 클릭 핸들러 (마이크 버튼과 분리)
const handleDimClick = useCallback((e) => {
console.log('[VoiceInputOverlay] dimBackground clicked');
onClose();
}, [onClose]);
if (!isVisible) return null;
return (
<div className={css.voiceOverlayContainer}>
{/* 배경 dim 레이어 - 클릭하면 닫힘 */}
<div className={css.dimBackground} onClick={onClose} />
<TFullPopup
open={isVisible}
onClose={onClose}
noAutoDismiss={true}
spotlightRestrict="self-only"
spotlightId={OVERLAY_SPOTLIGHT_ID}
noAnimation={false}
scrimType="none"
className={css.tFullPopupWrapper}
>
<div className={css.voiceOverlayContainer}>
{/* 배경 dim 레이어 - 클릭하면 닫힘 */}
<div className={css.dimBackground} onClick={handleDimClick} />
{/* 모드별 컨텐츠 영역 - Spotlight Container (self-only) */}
<OverlayContainer
className={css.contentArea}
spotlightId={OVERLAY_SPOTLIGHT_ID}
spotlightDisabled={!isVisible}
>
{/* 입력창과 마이크 버튼 - SearchPanel.inputContainer와 동일한 구조 */}
<div className={css.inputWrapper}>
<div className={css.searchInputWrapper}>
<TInput
className={css.inputBox}
kind={KINDS.withIcon}
icon={ICONS.search}
value={searchQuery}
onChange={onSearchChange}
onIconClick={() => onSearchSubmit && onSearchSubmit(searchQuery)}
spotlightId={INPUT_SPOTLIGHT_ID}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
<SpottableMicButton
className={classNames(css.microphoneButton, css.active)}
onClick={handleMicClick}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleMicClick();
}
}}
spotlightId={MIC_SPOTLIGHT_ID}
>
<div className={css.microphoneCircle}>
<img src={micIcon} alt="Microphone" className={css.microphoneIcon} />
</div>
</SpottableMicButton>
{/* 모드별 컨텐츠 영역 - Spotlight Container (self-only) */}
<OverlayContainer
className={css.contentArea}
spotlightId={OVERLAY_SPOTLIGHT_ID}
spotlightDisabled={!isVisible}
>
{/* 입력창과 마이크 버튼 - SearchPanel.inputContainer와 동일한 구조 */}
<div className={css.inputWrapper} onClick={(e) => e.stopPropagation()}>
<div className={css.searchInputWrapper}>
<TInput
className={css.inputBox}
kind={KINDS.withIcon}
icon={ICONS.search}
value={searchQuery}
onChange={onSearchChange}
onIconClick={() => onSearchSubmit && onSearchSubmit(searchQuery)}
spotlightId={INPUT_SPOTLIGHT_ID}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
<SpottableMicButton
className={classNames(
css.microphoneButton,
css.active,
currentMode === VOICE_MODES.LISTENING && css.listening
)}
onClick={handleMicClick}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleMicClick(e);
}
}}
spotlightId={MIC_SPOTLIGHT_ID}
>
<div className={css.microphoneCircle}>
<img src={micIcon} alt="Microphone" className={css.microphoneIcon} />
</div>
{currentMode === VOICE_MODES.LISTENING && (
<svg className={css.rippleSvg} width="100" height="100">
<circle
className={css.rippleCircle}
cx="50"
cy="50"
r="47"
fill="none"
stroke="#C70850"
strokeWidth="6"
/>
</svg>
)}
</SpottableMicButton>
</div>
</div>
</div>
{/* 모드별 컨텐츠 */}
<div className={css.modeContent}>{renderModeContent()}</div>
</OverlayContainer>
</div>
{/* 모드별 컨텐츠 */}
{renderModeContent()}
</OverlayContainer>
</div>
</TFullPopup>
);
};

View File

@@ -1,6 +1,11 @@
// src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less
@import "../../../style/CommonStyle.module.less";
// TFullPopup wrapper - TFullPopup의 기본 스타일을 override하지 않음
.tFullPopupWrapper {
// TFullPopup의 기본 동작 유지
}
.voiceOverlayContainer {
position: fixed;
top: 0;
@@ -173,6 +178,45 @@
}
}
}
// listening 상태 (배경 투명, 테두리 ripple 애니메이션)
&.listening {
.microphoneCircle {
background-color: transparent;
border-color: transparent; // 테두리 투명
box-shadow: none;
.microphoneIcon {
filter: brightness(0) invert(1); // 아이콘은 흰색 유지
}
}
}
}
// Ripple 애니메이션 (원형 테두리가 점에서 시작해서 그려짐)
.rippleSvg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.rippleCircle {
stroke-dasharray: 295.3; // 2 * PI * 47 (원의 둘레)
stroke-dashoffset: 295.3; // 초기값: 완전히 숨김
transform-origin: center;
transform: rotate(-90deg); // 12시 방향에서 시작
animation: drawCircle 2s ease-in-out infinite;
}
@keyframes drawCircle {
0% {
stroke-dashoffset: 295.3; // 점에서 시작
}
100% {
stroke-dashoffset: 0; // 원 완성 (계속 시계방향으로)
}
}
// 모드별 컨텐츠 영역 - 화면 중앙에 배치

View File

@@ -0,0 +1,10 @@
<div style={{width: '100%', height: '100%', position: 'relative'}}>
<div style={{width: 510, height: 25, left: 0, top: 0, position: 'absolute', background: '#424242', borderRadius: 100}} />
<div style={{width: 510, height: 25, left: 0, top: 0, position: 'absolute', opacity: 0.10, background: '#C70850', borderRadius: 100}} />
<div style={{width: 480, height: 25, left: 15, top: 0, position: 'absolute', opacity: 0.20, background: '#C70850', borderRadius: 100}} />
<div style={{width: 390, height: 25, left: 60, top: 0, position: 'absolute', opacity: 0.30, background: '#C70850', borderRadius: 100}} />
<div style={{width: 350, height: 25, left: 80, top: 0, position: 'absolute', opacity: 0.40, background: '#C70850', borderRadius: 100}} />
<div style={{width: 320, height: 25, left: 95, top: 0, position: 'absolute', opacity: 0.50, background: '#C70850', borderRadius: 100}} />
<div style={{width: 260, height: 25, left: 125, top: 0, position: 'absolute', background: '#C70850', borderRadius: 100}} />
</div>

View File

@@ -0,0 +1,37 @@
// src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.jsx
import React from 'react';
import css from './VoiceListening.module.less';
/**
* VoiceListening - 음성 입력 중 애니메이션 표시 컴포넌트
* 화면 중앙에 표시되는 음성 입력 시각화 막대
* 포커스를 받지 않으며 순수하게 시각적 피드백만 제공
*/
const VoiceListening = () => {
return (
<div className={css.container}>
<div className={css.listeningText}>
Listening
<span className={css.dotsContainer}>
<span className={`${css.dot} ${css.dot1}`}>.</span>
<span className={`${css.dot} ${css.dot2}`}>.</span>
<span className={`${css.dot} ${css.dot3}`}>.</span>
</span>
</div>
<div className={css.visualizer}>
<div className={`${css.bar} ${css.bar1}`} />
<div className={`${css.bar} ${css.bar2}`} />
<div className={`${css.bar} ${css.bar3}`} />
<div className={`${css.bar} ${css.bar4}`} />
<div className={`${css.bar} ${css.bar5}`} />
<div className={`${css.bar} ${css.bar6}`} />
<div className={`${css.bar} ${css.bar7}`} />
<div className={`${css.bar} ${css.bar8}`} />
<div className={`${css.bar} ${css.bar9}`} />
<div className={`${css.bar} ${css.bar10}`} />
</div>
</div>
);
};
export default VoiceListening;

View File

@@ -0,0 +1,176 @@
// src/views/SearchPanel/VoiceInputOverlay/modes/VoiceListening.module.less
@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;
}
.listeningText {
font-size: 25px;
font-family: "LG Smart UI";
font-weight: 700;
color: white;
margin-bottom: 35px;
text-align: center;
.dotsContainer {
display: inline-block;
width: 24px; // 점 3개 공간 확보 (고정 너비로 글자 안 움직임)
text-align: left;
}
.dot {
opacity: 0;
animation: dotFade 1.5s infinite;
}
.dot1 {
animation-delay: 0s;
}
.dot2 {
animation-delay: 0.5s;
}
.dot3 {
animation-delay: 1s;
}
}
.visualizer {
width: 510px;
height: 25px;
position: relative;
}
// 기본 막대 스타일
.bar {
height: 25px;
position: absolute;
top: 0;
border-radius: 100px;
transition: all 0.3s ease-in-out;
}
// 배경 막대 (투명, 고정)
.bar1 {
width: 510px;
left: 0;
background: transparent;
animation: none; // 고정
}
// 그라데이션 효과를 위한 막대들 (빨간색 계열) - 작은 것부터 큰 것으로 순차적으로 나타남
.bar2 {
width: 510px;
left: 0;
background: @PRIMARY_COLOR_RED;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 1.4s; // 가장 큰 막대 - 마지막
opacity: 0; // 애니메이션으로 제어
}
.bar3 {
width: 480px;
left: 15px;
background: @PRIMARY_COLOR_RED;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 1.2s;
opacity: 0;
}
.bar4 {
width: 390px;
left: 60px;
background: @PRIMARY_COLOR_RED;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 1.0s;
opacity: 0;
}
.bar5 {
width: 350px;
left: 80px;
background: @PRIMARY_COLOR_RED;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0.8s;
opacity: 0;
}
.bar6 {
width: 320px;
left: 95px;
background: @PRIMARY_COLOR_RED;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0.6s;
opacity: 0;
}
.bar7 {
width: 260px;
left: 125px;
background: @PRIMARY_COLOR_RED;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0.4s;
opacity: 0;
}
.bar8 {
width: 200px;
left: 155px;
background: @PRIMARY_COLOR_RED;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0.2s;
opacity: 0;
}
.bar9 {
width: 150px;
left: 180px;
background: @PRIMARY_COLOR_RED;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0.1s;
opacity: 0;
}
.bar10 {
width: 100px;
left: 205px;
background: @PRIMARY_COLOR_RED;
animation: waveAppear 1.6s ease-in-out infinite;
animation-delay: 0s; // 가장 작은 막대 - 처음 시작
opacity: 0;
}
// 애니메이션 정의 - 막대들이 차례대로 나타났다가 완전히 사라짐
@keyframes waveAppear {
0% {
opacity: 0;
transform: scaleY(0);
}
50% {
opacity: 1;
transform: scaleY(1);
}
100% {
opacity: 0;
transform: scaleY(0);
}
}
// 점 애니메이션 - 각 점이 차례로 나타남 (. → .. → ...)
@keyframes dotFade {
0% {
opacity: 1;
}
33%, 100% {
opacity: 0;
}
}

View File

@@ -3,11 +3,20 @@ import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import Spottable from '@enact/spotlight/Spottable';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import { getShopperHouseSearch } from '../../../../actions/searchActions';
import css from './VoicePromptScreen.module.less';
const SpottableBubble = Spottable('div');
const PromptContainer = SpotlightContainerDecorator(
{
enterTo: 'default-element',
restrict: 'self-only', // 포커스를 bubble 영역 내부로만 제한
},
'div'
);
const VoicePromptScreen = ({ title = 'Try saying', suggestions = [] }) => {
const dispatch = useDispatch();
const shopperHouseData = useSelector((state) => state.search.shopperHouseData);
@@ -32,7 +41,11 @@ const VoicePromptScreen = ({ title = 'Try saying', suggestions = [] }) => {
};
return (
<div className={css.container}>
<PromptContainer
className={css.container}
spotlightId="voice-prompt-container"
style={{ pointerEvents: 'all' }}
>
<div className={css.title}>{title}</div>
<div className={css.suggestionsContainer}>
{suggestions.map((suggestion, index) => (
@@ -46,7 +59,7 @@ const VoicePromptScreen = ({ title = 'Try saying', suggestions = [] }) => {
</SpottableBubble>
))}
</div>
</div>
</PromptContainer>
);
};

View File

@@ -7,6 +7,9 @@
position: relative;
border-radius: 12px;
pointer-events: all; // 실제 컨텐츠는 클릭 가능
// modeContent의 스타일을 여기로 이동
margin-top: 100px;
padding-right: 120px;
}
.title {
@@ -30,11 +33,11 @@
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 15px;
display: inline-flex;
}
.bubbleMessage {
margin-bottom: 15px;
padding: 20px 30px;
background: rgba(68, 68, 68, 0.5);
box-shadow: 0px 10px 30px rgba(0, 0, 0, 0.35);
@@ -74,3 +77,7 @@
word-wrap: break-word;
letter-spacing: -1px;
}
.bubbleMessage:last-child {
margin-bottom: 0;
}