[251015] feat: VoiceInputOverlay

🕐 커밋 시간: 2025. 10. 15. 20:10:25

📊 변경 통계:
  • 총 파일: 13개
  • 추가: +90줄
  • 삭제: -20줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new copy.jsx
  + com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.module copy.less
  + com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/OverlayFirst.figma.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/VoiceInputOverlay/modes/VoicePromptScreen.jsx
  + com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.module.less

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/App/App.js
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
  ~ com.twin.app.shoptime/src/views/MainView/MainView.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.jsx
  ~ com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.module.less

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx (javascript):
    🔄 Modified: extractProductMeta()
  📄 com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new copy.jsx (javascript):
     Added: _onFocus(), _onBlur()
  📄 com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.module copy.less (unknown):
     Added: translateY(), child(), media()
  📄 com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.module.less (unknown):
     Added: translateY()

🔧 주요 변경 내용:
  • 핵심 비즈니스 로직 개선
This commit is contained in:
2025-10-15 20:10:31 +09:00
parent 6c7791f912
commit 672d03ef3f
12 changed files with 2489 additions and 263 deletions

View File

@@ -143,7 +143,7 @@ export default function ProductAllSection({
// YouMayLike 데이터는 API 응답 시간이 걸리므로 직접 구독
const youmaylikeData = useSelector((state) => state.main.youmaylikeData);
// ProductVideo 버전 관리 (1: 기존 modal 방식, 2: 내장 방식)
// ProductVideo 버전 관리 (1: 기존 modal 방식, 2: 내장 방식 , 3: 비디오 생략)
const [productVideoVersion, setProductVideoVersion] = useState(3);
// const [currentHeight, setCurrentHeight] = useState(0);

View File

@@ -69,7 +69,7 @@ import MyPagePanel from '../MyPagePanel/MyPagePanel';
import OnSalePanel from '../OnSalePanel/OnSalePanel';
import PlayerPanel from '../PlayerPanel/PlayerPanel';
import PlayerPanelNew from '../PlayerPanel/PlayerPanel.new';
import SearchPanel from '../SearchPanel/SearchPanel';
import SearchPanel from '../SearchPanel/SearchPanel.new';
import VoicePanel from '../VoicePanel/VoicePanel';
import ThemeCurationPanel from '../ThemeCurationPanel/ThemeCurationPanel';
import TrendingNowPanel from '../TrendingNowPanel/TrendingNowPanel';
@@ -162,19 +162,19 @@ export default function MainView({ className, initService }) {
const topPanel = panels[panels.length - 1];
useEffect(() => {
console.log('🔍 MainView 팝업 상태 변경:', {
popupVisible,
activePopup,
});
}, [popupVisible, activePopup]);
// useEffect(() => {
// console.log('🔍 MainView 팝업 상태 변경:', {
// popupVisible,
// activePopup,
// });
// }, [popupVisible, activePopup]);
const isHomeOnTop = useMemo(() => {
return !mainIndex && (panels.length <= 0 || (panels.length === 1 && panels[0].panelInfo.modal));
}, [mainIndex, panels]);
const onPreImageLoadComplete = useCallback(() => {
console.log('MainView onPreImageLoadComplete');
// console.log('MainView onPreImageLoadComplete');
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
setImagePreloaded(true);
}, [dispatch]);

View File

@@ -1,43 +1,29 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDispatch, useSelector } from "react-redux";
import { useDispatch, useSelector } from 'react-redux';
import { Job } from "@enact/core/util";
import Spotlight from "@enact/spotlight";
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
import { setContainerLastFocusedElement } from "@enact/spotlight/src/container";
import { Job } from '@enact/core/util';
import Spotlight from '@enact/spotlight';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import { setContainerLastFocusedElement } from '@enact/spotlight/src/container';
import { sendLogGNB, sendLogTotalRecommend } from "../../actions/logActions";
import { getMyRecommandedKeyword } from "../../actions/myPageActions";
import { popPanel, updatePanel } from "../../actions/panelActions";
import { getSearch, resetSearch } from "../../actions/searchActions";
import TBody from "../../components/TBody/TBody";
import TInput, { ICONS, KINDS } from "../../components/TInput/TInput";
import TPanel from "../../components/TPanel/TPanel";
import TVerticalPagenator from "../../components/TVerticalPagenator/TVerticalPagenator";
import usePrevious from "../../hooks/usePrevious";
import {
LOG_CONTEXT_NAME,
LOG_MENU,
LOG_MESSAGE_ID,
panel_names,
} from "../../utils/Config";
import { SpotlightIds } from "../../utils/SpotlightIds";
import NoSearchResults from "./NoSearchResults/NoSearchResults";
import RecommendedKeywords from "./RecommendedKeywords/RecommendedKeywords";
import css from "./SearchPanel.module.less";
import SearchResults from "./SearchResults/SearchResults";
import { sendLogGNB, sendLogTotalRecommend } from '../../actions/logActions';
import { getMyRecommandedKeyword } from '../../actions/myPageActions';
import { popPanel, updatePanel } from '../../actions/panelActions';
import { getSearch, resetSearch } from '../../actions/searchActions';
import TBody from '../../components/TBody/TBody';
import TInput, { ICONS, KINDS } from '../../components/TInput/TInput';
import TPanel from '../../components/TPanel/TPanel';
import TVerticalPagenator from '../../components/TVerticalPagenator/TVerticalPagenator';
import usePrevious from '../../hooks/usePrevious';
import { LOG_CONTEXT_NAME, LOG_MENU, LOG_MESSAGE_ID, panel_names } from '../../utils/Config';
import { SpotlightIds } from '../../utils/SpotlightIds';
import NoSearchResults from './NoSearchResults/NoSearchResults';
import RecommendedKeywords from './RecommendedKeywords/RecommendedKeywords';
import css from './SearchPanel.module.less';
import SearchResults from './SearchResults/SearchResults';
const ContainerBasic = SpotlightContainerDecorator(
{ enterTo: "last-focused" },
"div"
);
const ContainerBasic = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
const ITEMS_PER_PAGE = 9;
export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
@@ -54,9 +40,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
const [currentPage, setCurrentPage] = useState(1);
const [paginatedKeywords, setPaginatedKeywords] = useState([]);
const [pageChanged, setPageChanged] = useState(false);
const [searchQuery, setSearchQuery] = useState(
panelInfo.searchVal ? panelInfo.searchVal : null
);
const [searchQuery, setSearchQuery] = useState(panelInfo.searchVal ? panelInfo.searchVal : null);
const [position, setPosition] = useState(null);
let searchQueryRef = usePrevious(searchQuery);
@@ -64,16 +48,12 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
const isRecommendedSearchRef = useRef(false);
const firstButtonSpotlightId = "first-keyword-button";
const firstButtonSpotlightId = 'first-keyword-button';
const focusJob = useRef(new Job((func) => func(), 100));
const cbChangePageRef = useRef(null);
const [focusedContainerId, setFocusedContainerId] = useState(
panelInfo?.focusedContainerId
);
const [focusedContainerId, setFocusedContainerId] = useState(panelInfo?.focusedContainerId);
const focusedContainerIdRef = usePrevious(focusedContainerId);
const bestSellerDatas = useSelector(
(state) => state.product.bestSellerData.bestSeller
);
const bestSellerDatas = useSelector((state) => state.product.bestSellerData.bestSeller);
useEffect(() => {
if (loadingComplete && !recommandedKeywords) {
@@ -136,7 +116,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
dispatch(
sendLogTotalRecommend({
query: searchQuery,
searchType: searchPerformed ? "query" : "keyword",
searchType: searchPerformed ? 'query' : 'keyword',
result: result,
contextName: LOG_CONTEXT_NAME.SEARCH,
messageId: LOG_MESSAGE_ID.SEARCH_ITEM,
@@ -151,9 +131,9 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
if (query.trim()) {
dispatch(
getSearch({
service: "com.lgshop.app",
service: 'com.lgshop.app',
query: query,
domain: "theme,show,item",
domain: 'theme,show,item',
})
);
} else {
@@ -181,8 +161,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
}, [currentPage]);
const hasPrevPage = currentPage > 1;
const hasNextPage =
currentPage * ITEMS_PER_PAGE < recommandedKeywords?.length;
const hasNextPage = currentPage * ITEMS_PER_PAGE < recommandedKeywords?.length;
useEffect(() => {
if (panelInfo && isOnTop) {
@@ -211,21 +190,19 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
return;
}
if (e.key === "Enter") {
if (e.key === 'Enter') {
handleSearchSubmit(searchQuery);
}
if (position === 0) {
if (e.key === "Left" || e.key === "ArrowLeft") {
if (e.key === 'Left' || e.key === 'ArrowLeft') {
e.preventDefault();
}
}
};
const cursorPosition = () => {
const input = document.querySelector(
`[data-spotlight-id="input-field-box"] > input`
);
const input = document.querySelector(`[data-spotlight-id="input-field-box"] > input`);
if (input) {
setPosition(input.selectionStart);
}
@@ -235,13 +212,13 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
if (!isOnTopRef.current) {
return;
}
if (searchQuery === null || searchQuery === "") {
if (searchQuery === null || searchQuery === '') {
dispatch(popPanel(panel_names.SEARCH_PANEL));
} else {
setSearchQuery("");
setSearchQuery('');
setCurrentPage(1);
dispatch(resetSearch());
Spotlight.focus("search-input-box");
Spotlight.focus('search-input-box');
}
}, [searchQuery, dispatch]);
@@ -253,7 +230,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
Spotlight.resume();
setFirstSpot(true);
if (panelInfo.currentSpot) {
if (panels[panels.length - 1]?.name === "searchpanel") {
if (panels[panels.length - 1]?.name === 'searchpanel') {
Spotlight.focus(panelInfo.currentSpot);
}
}
@@ -272,21 +249,13 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
}, [panelInfo, firstSpot]);
return (
<TPanel
className={css.container}
handleCancel={onCancel}
spotlightId={spotlightId}
>
<TBody
className={css.tBody}
scrollable={false}
spotlightDisabled={!isOnTop}
>
<TPanel className={css.container} handleCancel={onCancel} spotlightId={spotlightId}>
<TBody className={css.tBody} scrollable={false} spotlightDisabled={!isOnTop}>
<ContainerBasic>
{isOnTop && (
<TVerticalPagenator
className={css.tVerticalPagenator}
spotlightId={"search_verticalPagenator"}
spotlightId={'search_verticalPagenator'}
defaultContainerId={panelInfo?.focusedContainerId}
disabled={!isOnTop}
// onScrollStop={onScrollStop}
@@ -297,7 +266,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
<ContainerBasic
className={css.inputContainer}
data-wheel-point={true}
spotlightId={"search-input-layer"}
spotlightId={'search-input-layer'}
>
<TInput
className={css.inputBox}
@@ -309,7 +278,7 @@ export default function SearchPanel({ panelInfo, isOnTop, spotlightId }) {
onKeyDown={handleKeydown}
onKeyUp={cursorPosition}
forcedSpotlight="first-keyword-button"
spotlightId={"search-input-box"}
spotlightId={'search-input-box'}
/>
</ContainerBasic>

View File

@@ -0,0 +1,868 @@
// src/views/SearchPanel/SearchPanel.new.jsx
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
import {
useDispatch,
useSelector,
} from 'react-redux';
import { Job } from '@enact/core/util';
import Spotlight from '@enact/spotlight';
import SpotlightContainerDecorator
from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import { setContainerLastFocusedElement } from '@enact/spotlight/src/container';
import micIcon from '../../../assets/images/searchpanel/image-mic.png';
import hotPicksImage from '../../../assets/images/searchpanel/img-hotpicks.png';
import hotPicksBrandImage
from '../../../assets/images/searchpanel/img-search-hotpicks.png';
import {
sendLogGNB,
sendLogTotalRecommend,
} from '../../actions/logActions';
import { getMyRecommandedKeyword } from '../../actions/myPageActions';
import {
popPanel,
updatePanel,
} from '../../actions/panelActions';
import {
getSearch,
resetSearch,
searchMain,
} from '../../actions/searchActions';
import {
showErrorToast,
showInfoToast,
showSearchErrorToast,
showSearchSuccessToast,
showSuccessToast,
showWarningToast,
} from '../../actions/toastActions';
import TBody from '../../components/TBody/TBody';
import TInput, {
ICONS,
KINDS,
} from '../../components/TInput/TInput';
import TPanel from '../../components/TPanel/TPanel';
import TScroller from '../../components/TScroller/TScroller';
import TVerticalPagenator
from '../../components/TVerticalPagenator/TVerticalPagenator';
import TVirtualGridList
from '../../components/TVirtualGridList/TVirtualGridList';
// import VirtualKeyboardContainer from "../../components/TToast/VirtualKeyboardContainer";
import usePrevious from '../../hooks/usePrevious';
import {
LOG_CONTEXT_NAME,
LOG_MENU,
LOG_MESSAGE_ID,
panel_names,
} from '../../utils/Config';
import { SpotlightIds } from '../../utils/SpotlightIds';
import NoSearchResults from './NoSearchResults/NoSearchResults';
import RecommendedKeywords from './RecommendedKeywords/RecommendedKeywords';
import css from './SearchPanel.new.module.less';
import SearchResultsNew from './SearchResults.new';
import VoiceInputOverlay, { VOICE_MODES } from './VoiceInputOverlay/VoiceInputOverlay';
const ContainerBasic = SpotlightContainerDecorator(
{ enterTo: "last-focused" },
"div"
);
// 검색 입력 영역 컨테이너
const InputContainer = SpotlightContainerDecorator(
{ enterTo: "last-focused" },
"div"
);
// 콘텐츠 섹션 컨테이너
const SectionContainer = SpotlightContainerDecorator(
{ enterTo: "last-focused" },
"div"
);
// Spottable 컴포넌트들
const SpottableMicButton = Spottable("div");
const SpottableKeyword = Spottable("div");
const SpottableProduct = Spottable("div");
const SpottableLi = Spottable("li");
const ITEMS_PER_PAGE = 9;
// Spotlight ID 상수
const SPOTLIGHT_IDS = {
SEARCH_INPUT_LAYER: "search-input-layer",
SEARCH_INPUT_BOX: "search-input-box",
MICROPHONE_BUTTON: "microphone-button",
RECENT_SEARCHES_SECTION: "recent-searches-section",
TOP_SEARCHES_SECTION: "top-searches-section",
POPULAR_BRANDS_SECTION: "popular-brands-section",
HOT_PICKS_SECTION: "hot-picks-section",
SEARCH_VERTICAL_PAGENATOR: "search_verticalPagenator",
};
export default function SearchPanel({
panelInfo,
isOnTop,
spotlightId,
scrollOptions = [],
}) {
const dispatch = useDispatch();
const loadingComplete = useSelector((state) => state.common?.loadingComplete);
const recommandedKeywords = useSelector(
(state) => state.myPage.recommandedKeywordData.data?.keywords
);
const { searchDatas: searchDatas } = useSelector((state) => state.search);
const searchPerformed = useSelector((state) => state.search.searchPerformed);
const panels = useSelector((state) => state.panels.panels);
const [firstSpot, setFirstSpot] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [paginatedKeywords, setPaginatedKeywords] = useState([]);
const [pageChanged, setPageChanged] = useState(false);
const [searchQuery, setSearchQuery] = useState(
panelInfo.searchVal ? panelInfo.searchVal : null
);
const [position, setPosition] = useState(null);
const [showVirtualKeyboard, setShowVirtualKeyboard] = useState(false);
const [isVoiceOverlayVisible, setIsVoiceOverlayVisible] = useState(false);
const [voiceMode, setVoiceMode] = useState(VOICE_MODES.PROMPT);
//인풋창 포커스 구분을 위함
const [inputFocus, setInputFocus] = useState(false);
const _onFocus = () => {
setInputFocus(true);
};
const _onBlur = () => {
setInputFocus(false);
};
let searchQueryRef = usePrevious(searchQuery);
let isOnTopRef = usePrevious(isOnTop);
const firstButtonSpotlightId = "first-keyword-button";
const cbChangePageRef = useRef(null);
const [focusedContainerId, setFocusedContainerId] = useState(
panelInfo?.focusedContainerId
);
const focusedContainerIdRef = usePrevious(focusedContainerId);
// 가짜 데이터 - 실제로는 Redux store나 API에서 가져와야 함
const recentSearches = useMemo(
() => ["Puppy food", "Dog toy", "Fitness"],
[]
);
const recentResultSearches = useMemo(
() => [
"Puppy food",
"Dog toy",
"Mather's Day",
"Gift",
"Easter Day",
"Royal Canin puppy food",
"Shark",
],
[]
);
const topSearches = useMemo(
() => [
"Mather's Day",
"Gift",
"Easter Day",
"Royal Canin puppy food",
"Fitness",
"Parrot",
],
[]
);
const popularBrands = useMemo(
() => ["Shark", "Ninja", "Skechers", "LocknLock", "8Greens", "LGE"],
[]
);
const hotPicks = useMemo(
() => [
{
id: 1,
image: hotPicksImage,
brandLogo: hotPicksBrandImage,
brandName: "Product Name",
title: "New Shark Vacuum! Your pet Hair Solution!",
isForYou: false,
},
{
id: 2,
image: hotPicksImage,
brandLogo: hotPicksBrandImage,
brandName: "Product Name",
title: "New Shark Vacuum! Your pet Hair Solution!",
isForYou: false,
},
{
id: 3,
image: hotPicksImage,
brandLogo: hotPicksBrandImage,
brandName: "Product Name",
title: "New Shark Vacuum! Your pet Hair Solution!",
isForYou: false,
},
{
id: 4,
image: hotPicksImage,
brandLogo: hotPicksBrandImage,
brandName: "Product Name",
title: "New Shark Vacuum! Your pet Hair Solution!",
isForYou: true,
},
],
[]
);
// Voice overlay suggestions (동적으로 변경 가능)
const voiceSuggestions = useMemo(
() => [
'" Can you recommend a good budget cordless vacuum? "',
'" Show me trending skincare. "',
'" Find the newest Nike sneakers. "',
'" Show me snail cream that helps with sensitive skin. "',
'" Recommend a tasty melatonin gummy. "',
],
[]
);
useEffect(() => {
if (loadingComplete && !recommandedKeywords) {
dispatch(getMyRecommandedKeyword());
}
}, [loadingComplete]);
useEffect(() => {
if (isOnTop) {
let menu;
if (!searchPerformed) menu = LOG_MENU.SEARCH_SEARCH;
else {
if (searchQueryRef.current)
menu =
Object.keys(searchDatas).length > 0
? LOG_MENU.SEARCH_RESULT
: LOG_MENU.SEARCH_BEST_SELLER;
}
dispatch(sendLogGNB(menu));
}
}, [isOnTop, searchDatas, searchPerformed]);
useEffect(() => {
if (!searchQuery) {
dispatch(resetSearch());
}
}, [dispatch]);
useEffect(() => {
if (recommandedKeywords) {
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
setPaginatedKeywords(recommandedKeywords.slice(startIndex, endIndex));
}
}, [recommandedKeywords, currentPage]);
useEffect(() => {
if (pageChanged && paginatedKeywords.length > 0) {
Spotlight.focus(firstButtonSpotlightId);
setPageChanged(false);
}
}, [pageChanged, paginatedKeywords]);
const handleSearchChange = useCallback((e) => {
const query = e.value;
if (query.length <= 255) {
setSearchQuery(query);
}
}, []);
useEffect(() => {
const result = Object.values(searchDatas).reduce((acc, curr) => {
return acc + curr.length;
}, 0);
if (searchQuery) {
dispatch(
sendLogTotalRecommend({
query: searchQuery,
searchType: searchPerformed ? "query" : "keyword",
result: result,
contextName: LOG_CONTEXT_NAME.SEARCH,
messageId: LOG_MESSAGE_ID.SEARCH_ITEM,
})
);
// 검색 완료 후 결과에 따른 Toast 표시
if (searchPerformed && searchQuery.trim()) {
if (result > 0) {
dispatch(showSearchSuccessToast(searchQuery, result));
} else {
dispatch(showSearchErrorToast(searchQuery));
}
}
}
}, [searchDatas, searchPerformed, searchQuery, dispatch]);
const handleSearchSubmit = useCallback(
(query) => {
if (!searchPerformed && !query) return;
if (query.trim()) {
dispatch(
getSearch({
service: "com.lgshop.app",
query: query,
domain: "theme,show,item",
})
);
// 검색 시작 알림 (선택사항)
dispatch(showSuccessToast(`"${query}" 검색 중...`, { duration: 2000 }));
} else {
dispatch(resetSearch());
}
setSearchQuery(query);
// 검색 시 가상 키보드 숨김
setShowVirtualKeyboard(false);
},
[dispatch, searchPerformed, searchDatas, searchQuery]
);
const handleNext = useCallback(() => {
if (!isOnTopRef.current) {
return;
}
setCurrentPage((prev) => prev + 1);
setPageChanged(true);
}, [currentPage]);
const handlePrev = useCallback(() => {
if (!isOnTopRef.current) {
return;
}
setCurrentPage((prev) => (prev > 1 ? prev - 1 : prev));
setPageChanged(true);
}, [currentPage]);
const hasPrevPage = currentPage > 1;
const hasNextPage =
currentPage * ITEMS_PER_PAGE < recommandedKeywords?.length;
useEffect(() => {
if (panelInfo && isOnTop) {
if (panelInfo.currentSpot && firstSpot) {
Spotlight.focus(panel_names.SEARCH_PANEL);
}
}
}, [panelInfo, isOnTop]);
useEffect(() => {
return () => {
dispatch(
updatePanel({
name: panel_names.SEARCH_PANEL,
panelInfo: {
searchVal: searchQueryRef.current,
focusedContainerId: focusedContainerIdRef.current,
},
})
);
};
}, []);
const handleKeydown = useCallback(
(e) => {
if (!isOnTopRef.current) {
return;
}
// Enter 키 처리
if (e.key === "Enter") {
e.preventDefault();
if (showVirtualKeyboard) {
// 가상 키보드가 열려있으면 검색 실행하고 키보드 닫기
handleSearchSubmit(searchQuery);
} else {
// 가상 키보드가 닫혀있으면 키보드 열기
setShowVirtualKeyboard(true);
}
return;
}
// 방향키 처리 - Spotlight 네비게이션 허용
const arrowKeys = [
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Left",
"Right",
"Up",
"Down",
];
if (arrowKeys.includes(e.key)) {
// 입력 필드가 비어있고 왼쪽 화살표인 경우에만 방지
if (
position === 0 &&
(e.key === "Left" || e.key === "ArrowLeft") &&
!searchQuery
) {
e.preventDefault();
return;
}
// 오른쪽 화살표 키 처리 - 포커스 이동 허용
if (e.key === "ArrowRight" || e.key === "Right") {
// 커서가 텍스트 끝에 있을 때만 포커스 이동 허용
const input = document.querySelector(
`[data-spotlight-id="input-field-box"] > input`
);
if (input && position === input.value.length) {
// 커서가 텍스트 끝에 있으면 포커스 이동 허용
return;
}
}
// 나머지 방향키는 Spotlight가 처리하도록 허용
return;
}
},
[searchQuery, position, handleSearchSubmit, showVirtualKeyboard]
);
const cursorPosition = useCallback(() => {
const input = document.querySelector(
`[data-spotlight-id="input-field-box"] > input`
);
if (input) {
setPosition(input.selectionStart);
}
}, []);
const onClickMic = useCallback(() => {
if (!isOnTopRef.current) {
return;
}
// 마이크 버튼 클릭 시 voice overlay 토글
setIsVoiceOverlayVisible((prev) => !prev);
}, []);
const onCancel = useCallback(() => {
if (!isOnTopRef.current) {
return;
}
if (searchQuery === null || searchQuery === "") {
dispatch(popPanel(panel_names.SEARCH_PANEL));
} else {
setSearchQuery("");
setCurrentPage(1);
dispatch(resetSearch());
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
}
}, [searchQuery, dispatch]);
const onFocusedContainerId = useCallback(
(containerId) => {
setFocusedContainerId(containerId);
if (!firstSpot) {
setTimeout(() => {
Spotlight.resume();
setFirstSpot(true);
if (panelInfo.currentSpot) {
if (panels[panels.length - 1]?.name === "searchpanel") {
Spotlight.focus(panelInfo.currentSpot);
}
}
}, 0);
}
},
[panelInfo, firstSpot]
);
const panelInfoFall = useMemo(() => {
const newPanelInfo = { ...panelInfo };
if (firstSpot) {
newPanelInfo.currentSpot = null;
}
return newPanelInfo;
}, [panelInfo, firstSpot]);
// 키워드 클릭 핸들러
const handleKeywordClick = useCallback(
(keyword) => {
setSearchQuery(keyword);
handleSearchSubmit(keyword);
// 키워드 선택 알림
dispatch(
showSuccessToast(`"${keyword}" 키워드로 검색합니다.`, {
duration: 2000,
})
);
},
[handleSearchSubmit, dispatch]
);
// 상품 클릭 핸들러
const handleProductClick = useCallback((product) => {
// 상품 상세 페이지로 이동하는 로직 구현
console.log("Product clicked:", product);
}, []);
// 테스트용 Toast 핸들러들
const handleTestToasts = useCallback(() => {
// 간단한 Toast 테스트
dispatch(showSuccessToast("테스트 메시지입니다", { duration: 3000 }));
}, [dispatch]);
// ProductCard 컴포넌트
const renderItem = useCallback(
(
// {
// product,
// index,
// onClick,
// showBrandLogo = true,
// showBrandName = true,
// showProductTitle = true,
// ...rest
// }
{ index, ...rest }
) => {
const {
showBrandLogo = true,
showBrandName = true,
showProductTitle = true,
image,
title,
brandLogo,
brandName,
} = hotPicks[index];
return (
<SpottableProduct
key={`product-${index}`}
className={css.productCard}
spotlightId={`product-${index}`}
{...rest}
>
<div className={css.productImageWrapper}>
<img src={image} alt={title} className={css.productImage} />
</div>
<div className={css.productInfo}>
{showBrandLogo && (
<div className={css.productBrandWrapper}>
<img
src={brandLogo}
alt={brandName}
className={css.brandLogo}
/>
</div>
)}
<div className={css.productDetails}>
{showBrandName && (
<div className={css.brandName}>{brandName}</div>
)}
{showProductTitle && (
<div className={css.productTitle}>{title}</div>
)}
</div>
</div>
</SpottableProduct>
);
},
[]
);
//test
useEffect(() => {
console.log("###searchDatas", searchDatas);
console.log("###panelInfo", panelInfo);
}, [searchDatas, panelInfo]);
return (
<TPanel
className={css.container}
handleCancel={onCancel}
spotlightId={spotlightId}
>
<TBody
className={css.tBody}
scrollable={true}
spotlightDisabled={!isOnTop}
>
<ContainerBasic>
{isOnTop && (
<TVerticalPagenator
className={css.tVerticalPagenator}
spotlightId={SPOTLIGHT_IDS.SEARCH_VERTICAL_PAGENATOR}
defaultContainerId={panelInfo?.focusedContainerId}
disabled={!isOnTop}
onFocusedContainerId={onFocusedContainerId}
cbChangePageRef={cbChangePageRef}
topMargin={36}
scrollable={true}
>
{/* 검색 내용있을때 검색 부분 */}
{/* 검색 입력 영역 - overlay 열릴 때 숨김 */}
{!isVoiceOverlayVisible && (
<InputContainer
className={classNames(
css.inputContainer,
inputFocus === true && css.inputFocus,
searchDatas &&
css.searchValue /* 이건 결과값 있을때만. 조건 추가필요 */
)}
data-wheel-point={true}
spotlightId={SPOTLIGHT_IDS.SEARCH_INPUT_LAYER}
>
<div className={css.searchInputWrapper}>
<TInput
className={css.inputBox}
kind={KINDS.withIcon}
icon={ICONS.search}
value={searchQuery}
onChange={handleSearchChange}
onIconClick={() => {
if (showVirtualKeyboard) {
handleSearchSubmit(searchQuery);
} else {
setShowVirtualKeyboard(true);
}
}}
onKeyDown={handleKeydown}
onKeyUp={cursorPosition}
spotlightId={SPOTLIGHT_IDS.SEARCH_INPUT_BOX}
forcedSpotlight="recent-keyword-0"
tabIndex={0}
spotlightBoxDisabled={true}
onFocus={_onFocus}
onBlur={_onBlur}
/>
<SpottableMicButton
className={css.microphoneButton}
onClick={onClickMic}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onClickMic();
}
}}
spotlightId={SPOTLIGHT_IDS.MICROPHONE_BUTTON}
>
<div className={css.microphoneCircle}>
<img
src={micIcon}
alt="Microphone"
className={css.microphoneIcon}
/>
</div>
</SpottableMicButton>
{/* 테스트용 Toast 버튼 (개발용) */}
{/* <SpottableMicButton
className={css.testToastButton}
onClick={handleTestToasts}
spotlightId="test-toast-button"
>
<div className={css.testButtonCircle}>🧪</div>
</SpottableMicButton> */}
</div>
</InputContainer>
)}
{/* 검색내용이 존재하고, 인풋창에 포커스가 가서 노출 시작 */}
{inputFocus === true &&
(searchDatas?.item?.length > 0 ||
searchDatas?.show?.length > 0) && (
<>
<div className={css.overLay}></div>
<div className={css.overLayRecent}>
{recentResultSearches.map((keyword, index) => (
<SpottableKeyword
key={`recentResult-${index}`}
className={css.keywordButton}
onClick={() => handleKeywordClick(keyword)}
spotlightId={`recent-Resultkeyword-${index}`}
>
{keyword}
</SpottableKeyword>
))}
</div>
</>
)}
{/* 검색내용이 존재하고, 인풋창에 포커스가 가서 노출 끝! */}
{/* 결과갑 부분 작업중 시작! */}
{/* 결과갑 부분 작업중 끝! */}
{/* 검색 결과 표시 영역 */}
{searchPerformed && searchQuery !== null ? (
<SearchResultsNew
themeInfo={searchDatas.theme}
itemInfo={searchDatas.item}
showInfo={searchDatas.show}
/>
) : (
<ContainerBasic
className={css.contentContainer}
>
{/* 노출 조건 변경 필요. 포커스 블러만으로는 안됌.(가상 키보드 노출시가 맞을듯) */}
{/* {inputFocus === false ? ( */}
{/* {inputFocus === false && ( */}
<>
{/* 최근 검색어 섹션 */}
<SectionContainer
className={css.section}
data-wheel-point={true}
spotlightId={SPOTLIGHT_IDS.RECENT_SEARCHES_SECTION}
>
<div className={css.sectionHeader}>
<div className={css.sectionIndicator}></div>
<div className={css.sectionTitle}>
Your Recent Searches
</div>
</div>
<div className={css.keywordList}>
{recentSearches.map((keyword, index) => (
<SpottableKeyword
key={`recent-${index}`}
className={css.keywordButton}
onClick={() => handleKeywordClick(keyword)}
spotlightId={`recent-keyword-${index}`}
>
{keyword}
</SpottableKeyword>
))}
</div>
</SectionContainer>
{/* 인기 검색어 섹션 */}
<SectionContainer
className={css.section}
data-wheel-point={true}
spotlightId={SPOTLIGHT_IDS.TOP_SEARCHES_SECTION}
>
<div className={css.sectionHeader}>
<div className={css.sectionIndicator}></div>
<div className={css.sectionTitle}>Top Searches</div>
</div>
<div className={css.keywordList}>
{topSearches.map((keyword, index) => (
<SpottableKeyword
key={`top-${index}`}
className={css.keywordButton}
onClick={() => handleKeywordClick(keyword)}
spotlightId={`top-keyword-${index}`}
>
{keyword}
</SpottableKeyword>
))}
</div>
</SectionContainer>
{/* 인기 브랜드 섹션 */}
<SectionContainer
className={css.section}
data-wheel-point={true}
spotlightId={SPOTLIGHT_IDS.POPULAR_BRANDS_SECTION}
>
<div className={css.sectionHeader}>
<div className={css.sectionIndicator}></div>
<div className={css.sectionTitle}>Popular Brands</div>
</div>
<div className={css.keywordList}>
{popularBrands.map((brand, index) => (
<SpottableKeyword
key={`brand-${index}`}
className={css.keywordButton}
onClick={() => handleKeywordClick(brand)}
spotlightId={`brand-${index}`}
>
{brand}
</SpottableKeyword>
))}
</div>
</SectionContainer>
{/* Hot Picks for You 섹션 */}
<SectionContainer
className={css.hotpicksSection}
data-wheel-point={true}
spotlightId={SPOTLIGHT_IDS.HOT_PICKS_SECTION}
>
<div className={css.sectionHeader}>
<div className={css.sectionIndicator}></div>
<div className={css.sectionTitle}>
Hot Picks for You
</div>
</div>
<div className={css.productList}>
{hotPicks && hotPicks.length > 0 && (
<TVirtualGridList
dataSize={hotPicks.length}
direction="horizontal"
renderItem={renderItem}
// itemWidth={546}
itemWidth={416}
itemHeight={436}
spacing={20}
/>
)}
</div>
</SectionContainer>
</>
{/* )} */}
{/* ) : ( */}
{/* <div className={css.inputFocusBox}>
<div className={css.keywordList}>
{recentSearches.map((keyword, index) => (
<SpottableKeyword
key={`recent-${index}`}
className={css.keywordButton}
onClick={() => handleKeywordClick(keyword)}
spotlightId={`recent-keyword-${index}`}
>
{keyword}
</SpottableKeyword>
))}
</div>
</div> */}
{/* )} */}
</ContainerBasic>
)}
</TVerticalPagenator>
)}
</ContainerBasic>
</TBody>
{/* Virtual Keyboard */}
{/* <VirtualKeyboardContainer
isVisible={showVirtualKeyboard}
onClose={() => setShowVirtualKeyboard(false)}
/> */}
{/* Voice Input Overlay */}
<VoiceInputOverlay
isVisible={isVoiceOverlayVisible}
onClose={() => setIsVoiceOverlayVisible(false)}
mode={voiceMode}
suggestions={voiceSuggestions}
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
onSearchSubmit={handleSearchSubmit}
/>
</TPanel>
);
}

View File

@@ -1,43 +1,22 @@
// src/views/SearchPanel/SearchPanel.new.jsx
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import {
useDispatch,
useSelector,
} from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { Job } from '@enact/core/util';
import Spotlight from '@enact/spotlight';
import SpotlightContainerDecorator
from '@enact/spotlight/SpotlightContainerDecorator';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import { setContainerLastFocusedElement } from '@enact/spotlight/src/container';
import micIcon from '../../../assets/images/searchpanel/image-mic.png';
import hotPicksImage from '../../../assets/images/searchpanel/img-hotpicks.png';
import hotPicksBrandImage
from '../../../assets/images/searchpanel/img-search-hotpicks.png';
import {
sendLogGNB,
sendLogTotalRecommend,
} from '../../actions/logActions';
import hotPicksBrandImage from '../../../assets/images/searchpanel/img-search-hotpicks.png';
import { sendLogGNB, sendLogTotalRecommend } from '../../actions/logActions';
import { getMyRecommandedKeyword } from '../../actions/myPageActions';
import {
popPanel,
updatePanel,
} from '../../actions/panelActions';
import {
getSearch,
resetSearch,
searchMain,
} from '../../actions/searchActions';
import { popPanel, updatePanel } from '../../actions/panelActions';
import { getSearch, resetSearch, searchMain } from '../../actions/searchActions';
import {
showErrorToast,
showInfoToast,
@@ -47,73 +26,50 @@ import {
showWarningToast,
} from '../../actions/toastActions';
import TBody from '../../components/TBody/TBody';
import TInput, {
ICONS,
KINDS,
} from '../../components/TInput/TInput';
import TInput, { ICONS, KINDS } from '../../components/TInput/TInput';
import TPanel from '../../components/TPanel/TPanel';
import TScroller from '../../components/TScroller/TScroller';
import TVerticalPagenator
from '../../components/TVerticalPagenator/TVerticalPagenator';
import TVirtualGridList
from '../../components/TVirtualGridList/TVirtualGridList';
import TVerticalPagenator from '../../components/TVerticalPagenator/TVerticalPagenator';
import TVirtualGridList from '../../components/TVirtualGridList/TVirtualGridList';
// import VirtualKeyboardContainer from "../../components/TToast/VirtualKeyboardContainer";
import usePrevious from '../../hooks/usePrevious';
import {
LOG_CONTEXT_NAME,
LOG_MENU,
LOG_MESSAGE_ID,
panel_names,
} from '../../utils/Config';
import { LOG_CONTEXT_NAME, LOG_MENU, LOG_MESSAGE_ID, panel_names } from '../../utils/Config';
import { SpotlightIds } from '../../utils/SpotlightIds';
import NoSearchResults from './NoSearchResults/NoSearchResults';
import RecommendedKeywords from './RecommendedKeywords/RecommendedKeywords';
import css from './SearchPanel.new.module.less';
import SearchResultsNew from './SearchResults.new';
import VoiceInputOverlay, { VOICE_MODES } from './VoiceInputOverlay/VoiceInputOverlay';
const ContainerBasic = SpotlightContainerDecorator(
{ enterTo: "last-focused" },
"div"
);
const ContainerBasic = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
// 검색 입력 영역 컨테이너
const InputContainer = SpotlightContainerDecorator(
{ enterTo: "last-focused" },
"div"
);
const InputContainer = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
// 콘텐츠 섹션 컨테이너
const SectionContainer = SpotlightContainerDecorator(
{ enterTo: "last-focused" },
"div"
);
const SectionContainer = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
// Spottable 컴포넌트들
const SpottableMicButton = Spottable("div");
const SpottableKeyword = Spottable("div");
const SpottableProduct = Spottable("div");
const SpottableLi = Spottable("li");
const SpottableMicButton = Spottable('div');
const SpottableKeyword = Spottable('div');
const SpottableProduct = Spottable('div');
const SpottableLi = Spottable('li');
const ITEMS_PER_PAGE = 9;
// Spotlight ID 상수
const SPOTLIGHT_IDS = {
SEARCH_INPUT_LAYER: "search-input-layer",
SEARCH_INPUT_BOX: "search-input-box",
MICROPHONE_BUTTON: "microphone-button",
RECENT_SEARCHES_SECTION: "recent-searches-section",
TOP_SEARCHES_SECTION: "top-searches-section",
POPULAR_BRANDS_SECTION: "popular-brands-section",
HOT_PICKS_SECTION: "hot-picks-section",
SEARCH_VERTICAL_PAGENATOR: "search_verticalPagenator",
SEARCH_INPUT_LAYER: 'search-input-layer',
SEARCH_INPUT_BOX: 'search-input-box',
MICROPHONE_BUTTON: 'microphone-button',
RECENT_SEARCHES_SECTION: 'recent-searches-section',
TOP_SEARCHES_SECTION: 'top-searches-section',
POPULAR_BRANDS_SECTION: 'popular-brands-section',
HOT_PICKS_SECTION: 'hot-picks-section',
SEARCH_VERTICAL_PAGENATOR: 'search_verticalPagenator',
};
export default function SearchPanel({
panelInfo,
isOnTop,
spotlightId,
scrollOptions = [],
}) {
export default function SearchPanel({ panelInfo, isOnTop, spotlightId, scrollOptions = [] }) {
const dispatch = useDispatch();
const loadingComplete = useSelector((state) => state.common?.loadingComplete);
const recommandedKeywords = useSelector(
@@ -127,11 +83,11 @@ export default function SearchPanel({
const [currentPage, setCurrentPage] = useState(1);
const [paginatedKeywords, setPaginatedKeywords] = useState([]);
const [pageChanged, setPageChanged] = useState(false);
const [searchQuery, setSearchQuery] = useState(
panelInfo.searchVal ? panelInfo.searchVal : null
);
const [searchQuery, setSearchQuery] = useState(panelInfo.searchVal ? panelInfo.searchVal : null);
const [position, setPosition] = useState(null);
const [showVirtualKeyboard, setShowVirtualKeyboard] = useState(false);
const [isVoiceOverlayVisible, setIsVoiceOverlayVisible] = useState(false);
const [voiceMode, setVoiceMode] = useState(VOICE_MODES.PROMPT);
//인풋창 포커스 구분을 위함
const [inputFocus, setInputFocus] = useState(false);
@@ -145,45 +101,33 @@ export default function SearchPanel({
let searchQueryRef = usePrevious(searchQuery);
let isOnTopRef = usePrevious(isOnTop);
const firstButtonSpotlightId = "first-keyword-button";
const firstButtonSpotlightId = 'first-keyword-button';
const cbChangePageRef = useRef(null);
const [focusedContainerId, setFocusedContainerId] = useState(
panelInfo?.focusedContainerId
);
const [focusedContainerId, setFocusedContainerId] = useState(panelInfo?.focusedContainerId);
const focusedContainerIdRef = usePrevious(focusedContainerId);
// 가짜 데이터 - 실제로는 Redux store나 API에서 가져와야 함
const recentSearches = useMemo(
() => ["Puppy food", "Dog toy", "Fitness"],
[]
);
const recentSearches = useMemo(() => ['Puppy food', 'Dog toy', 'Fitness'], []);
const recentResultSearches = useMemo(
() => [
"Puppy food",
"Dog toy",
'Puppy food',
'Dog toy',
"Mather's Day",
"Gift",
"Easter Day",
"Royal Canin puppy food",
"Shark",
'Gift',
'Easter Day',
'Royal Canin puppy food',
'Shark',
],
[]
);
const topSearches = useMemo(
() => [
"Mather's Day",
"Gift",
"Easter Day",
"Royal Canin puppy food",
"Fitness",
"Parrot",
],
() => ["Mather's Day", 'Gift', 'Easter Day', 'Royal Canin puppy food', 'Fitness', 'Parrot'],
[]
);
const popularBrands = useMemo(
() => ["Shark", "Ninja", "Skechers", "LocknLock", "8Greens", "LGE"],
() => ['Shark', 'Ninja', 'Skechers', 'LocknLock', '8Greens', 'LGE'],
[]
);
const hotPicks = useMemo(
@@ -192,38 +136,50 @@ export default function SearchPanel({
id: 1,
image: hotPicksImage,
brandLogo: hotPicksBrandImage,
brandName: "Product Name",
title: "New Shark Vacuum! Your pet Hair Solution!",
brandName: 'Product Name',
title: 'New Shark Vacuum! Your pet Hair Solution!',
isForYou: false,
},
{
id: 2,
image: hotPicksImage,
brandLogo: hotPicksBrandImage,
brandName: "Product Name",
title: "New Shark Vacuum! Your pet Hair Solution!",
brandName: 'Product Name',
title: 'New Shark Vacuum! Your pet Hair Solution!',
isForYou: false,
},
{
id: 3,
image: hotPicksImage,
brandLogo: hotPicksBrandImage,
brandName: "Product Name",
title: "New Shark Vacuum! Your pet Hair Solution!",
brandName: 'Product Name',
title: 'New Shark Vacuum! Your pet Hair Solution!',
isForYou: false,
},
{
id: 4,
image: hotPicksImage,
brandLogo: hotPicksBrandImage,
brandName: "Product Name",
title: "New Shark Vacuum! Your pet Hair Solution!",
brandName: 'Product Name',
title: 'New Shark Vacuum! Your pet Hair Solution!',
isForYou: true,
},
],
[]
);
// Voice overlay suggestions (동적으로 변경 가능)
const voiceSuggestions = useMemo(
() => [
'" Can you recommend a good budget cordless vacuum? "',
'" Show me trending skincare. "',
'" Find the newest Nike sneakers. "',
'" Show me snail cream that helps with sensitive skin. "',
'" Recommend a tasty melatonin gummy. "',
],
[]
);
useEffect(() => {
if (loadingComplete && !recommandedKeywords) {
dispatch(getMyRecommandedKeyword());
@@ -285,7 +241,7 @@ export default function SearchPanel({
dispatch(
sendLogTotalRecommend({
query: searchQuery,
searchType: searchPerformed ? "query" : "keyword",
searchType: searchPerformed ? 'query' : 'keyword',
result: result,
contextName: LOG_CONTEXT_NAME.SEARCH,
messageId: LOG_MESSAGE_ID.SEARCH_ITEM,
@@ -309,9 +265,9 @@ export default function SearchPanel({
if (query.trim()) {
dispatch(
getSearch({
service: "com.lgshop.app",
service: 'com.lgshop.app',
query: query,
domain: "theme,show,item",
domain: 'theme,show,item',
})
);
@@ -344,8 +300,7 @@ export default function SearchPanel({
}, [currentPage]);
const hasPrevPage = currentPage > 1;
const hasNextPage =
currentPage * ITEMS_PER_PAGE < recommandedKeywords?.length;
const hasNextPage = currentPage * ITEMS_PER_PAGE < recommandedKeywords?.length;
useEffect(() => {
if (panelInfo && isOnTop) {
@@ -376,7 +331,7 @@ export default function SearchPanel({
}
// Enter 키 처리
if (e.key === "Enter") {
if (e.key === 'Enter') {
e.preventDefault();
if (showVirtualKeyboard) {
// 가상 키보드가 열려있으면 검색 실행하고 키보드 닫기
@@ -390,32 +345,26 @@ export default function SearchPanel({
// 방향키 처리 - Spotlight 네비게이션 허용
const arrowKeys = [
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Left",
"Right",
"Up",
"Down",
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'ArrowDown',
'Left',
'Right',
'Up',
'Down',
];
if (arrowKeys.includes(e.key)) {
// 입력 필드가 비어있고 왼쪽 화살표인 경우에만 방지
if (
position === 0 &&
(e.key === "Left" || e.key === "ArrowLeft") &&
!searchQuery
) {
if (position === 0 && (e.key === 'Left' || e.key === 'ArrowLeft') && !searchQuery) {
e.preventDefault();
return;
}
// 오른쪽 화살표 키 처리 - 포커스 이동 허용
if (e.key === "ArrowRight" || e.key === "Right") {
if (e.key === 'ArrowRight' || e.key === 'Right') {
// 커서가 텍스트 끝에 있을 때만 포커스 이동 허용
const input = document.querySelector(
`[data-spotlight-id="input-field-box"] > input`
);
const input = document.querySelector(`[data-spotlight-id="input-field-box"] > input`);
if (input && position === input.value.length) {
// 커서가 텍스트 끝에 있으면 포커스 이동 허용
return;
@@ -430,22 +379,28 @@ export default function SearchPanel({
);
const cursorPosition = useCallback(() => {
const input = document.querySelector(
`[data-spotlight-id="input-field-box"] > input`
);
const input = document.querySelector(`[data-spotlight-id="input-field-box"] > input`);
if (input) {
setPosition(input.selectionStart);
}
}, []);
const onClickMic = useCallback(() => {
if (!isOnTopRef.current) {
return;
}
// 마이크 버튼 클릭 시 voice overlay 토글
setIsVoiceOverlayVisible((prev) => !prev);
}, []);
const onCancel = useCallback(() => {
if (!isOnTopRef.current) {
return;
}
if (searchQuery === null || searchQuery === "") {
if (searchQuery === null || searchQuery === '') {
dispatch(popPanel(panel_names.SEARCH_PANEL));
} else {
setSearchQuery("");
setSearchQuery('');
setCurrentPage(1);
dispatch(resetSearch());
Spotlight.focus(SPOTLIGHT_IDS.SEARCH_INPUT_BOX);
@@ -460,7 +415,7 @@ export default function SearchPanel({
Spotlight.resume();
setFirstSpot(true);
if (panelInfo.currentSpot) {
if (panels[panels.length - 1]?.name === "searchpanel") {
if (panels[panels.length - 1]?.name === 'searchpanel') {
Spotlight.focus(panelInfo.currentSpot);
}
}
@@ -496,13 +451,13 @@ export default function SearchPanel({
// 상품 클릭 핸들러
const handleProductClick = useCallback((product) => {
// 상품 상세 페이지로 이동하는 로직 구현
console.log("Product clicked:", product);
console.log('Product clicked:', product);
}, []);
// 테스트용 Toast 핸들러들
const handleTestToasts = useCallback(() => {
// 간단한 Toast 테스트
dispatch(showSuccessToast("테스트 메시지입니다", { duration: 3000 }));
dispatch(showSuccessToast('테스트 메시지입니다', { duration: 3000 }));
}, [dispatch]);
// ProductCard 컴포넌트
@@ -541,20 +496,12 @@ export default function SearchPanel({
<div className={css.productInfo}>
{showBrandLogo && (
<div className={css.productBrandWrapper}>
<img
src={brandLogo}
alt={brandName}
className={css.brandLogo}
/>
<img src={brandLogo} alt={brandName} className={css.brandLogo} />
</div>
)}
<div className={css.productDetails}>
{showBrandName && (
<div className={css.brandName}>{brandName}</div>
)}
{showProductTitle && (
<div className={css.productTitle}>{title}</div>
)}
{showBrandName && <div className={css.brandName}>{brandName}</div>}
{showProductTitle && <div className={css.productTitle}>{title}</div>}
</div>
</div>
</SpottableProduct>
@@ -565,21 +512,13 @@ export default function SearchPanel({
//test
useEffect(() => {
console.log("###searchDatas", searchDatas);
console.log("###panelInfo", panelInfo);
console.log('###searchDatas', searchDatas);
console.log('###panelInfo', panelInfo);
}, [searchDatas, panelInfo]);
return (
<TPanel
className={css.container}
handleCancel={onCancel}
spotlightId={spotlightId}
>
<TBody
className={css.tBody}
scrollable={true}
spotlightDisabled={!isOnTop}
>
<TPanel className={css.container} handleCancel={onCancel} spotlightId={spotlightId}>
<TBody className={css.tBody} scrollable={true} spotlightDisabled={!isOnTop}>
<ContainerBasic>
{isOnTop && (
<TVerticalPagenator
@@ -593,13 +532,13 @@ export default function SearchPanel({
scrollable={true}
>
{/* 검색 내용있을때 검색 부분 */}
{/* 검색 입력 영역 */}
{/* 검색 입력 영역 - overlay 열릴 때 숨김 (visibility로 처리) */}
<InputContainer
className={classNames(
css.inputContainer,
inputFocus === true && css.inputFocus,
searchDatas &&
css.searchValue /* 이건 결과값 있을때만. 조건 추가필요 */
searchDatas && css.searchValue /* 이건 결과값 있을때만. 조건 추가필요 */,
isVoiceOverlayVisible && css.hidden
)}
data-wheel-point={true}
spotlightId={SPOTLIGHT_IDS.SEARCH_INPUT_LAYER}
@@ -623,22 +562,22 @@ export default function SearchPanel({
spotlightId={SPOTLIGHT_IDS.SEARCH_INPUT_BOX}
forcedSpotlight="recent-keyword-0"
tabIndex={0}
spotlightDisabled={false}
spotlightBoxDisabled={true}
onFocus={_onFocus}
onBlur={_onBlur}
/>
<SpottableMicButton
className={css.microphoneButton}
onClick={onCancel}
onClick={onClickMic}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onClickMic();
}
}}
spotlightId={SPOTLIGHT_IDS.MICROPHONE_BUTTON}
>
<div className={css.microphoneCircle}>
<img
src={micIcon}
alt="Microphone"
className={css.microphoneIcon}
/>
<img src={micIcon} alt="Microphone" className={css.microphoneIcon} />
</div>
</SpottableMicButton>
@@ -655,8 +594,7 @@ export default function SearchPanel({
{/* 검색내용이 존재하고, 인풋창에 포커스가 가서 노출 시작 */}
{inputFocus === true &&
(searchDatas?.item?.length > 0 ||
searchDatas?.show?.length > 0) && (
(searchDatas?.item?.length > 0 || searchDatas?.show?.length > 0) && (
<>
<div className={css.overLay}></div>
<div className={css.overLayRecent}>
@@ -699,9 +637,7 @@ export default function SearchPanel({
>
<div className={css.sectionHeader}>
<div className={css.sectionIndicator}></div>
<div className={css.sectionTitle}>
Your Recent Searches
</div>
<div className={css.sectionTitle}>Your Recent Searches</div>
</div>
<div className={css.keywordList}>
{recentSearches.map((keyword, index) => (
@@ -773,9 +709,7 @@ export default function SearchPanel({
>
<div className={css.sectionHeader}>
<div className={css.sectionIndicator}></div>
<div className={css.sectionTitle}>
Hot Picks for You
</div>
<div className={css.sectionTitle}>Hot Picks for You</div>
</div>
<div className={css.productList}>
{hotPicks && hotPicks.length > 0 && (
@@ -821,6 +755,17 @@ export default function SearchPanel({
isVisible={showVirtualKeyboard}
onClose={() => setShowVirtualKeyboard(false)}
/> */}
{/* Voice Input Overlay */}
<VoiceInputOverlay
isVisible={isVoiceOverlayVisible}
onClose={() => setIsVoiceOverlayVisible(false)}
mode={voiceMode}
suggestions={voiceSuggestions}
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
onSearchSubmit={handleSearchSubmit}
/>
</TPanel>
);
}

View File

@@ -0,0 +1,725 @@
// src/views/SearchPanel/SearchPanel.module.less
@import "../../style/CommonStyle.module.less";
@import "../../style/utils.module.less";
.container {
background-color: @BG_COLOR_01;
.tBody {
height: 100%;
> div {
width: 100%;
height: 100%;
}
.focusedContainerId {
height: 100%;
}
}
// 검색 입력 영역 스타일
.inputContainer {
padding-top: 180px;
padding-bottom: 94px;
padding-left: 60px;
padding-right: 60px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 2;
position: relative;
&.inputFocus {
padding-bottom: 10px;
}
&.searchValue {
padding-bottom: 55px;
padding-top: 55px;
}
> * {
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
.searchInputWrapper {
height: 100px;
display: flex;
justify-content: flex-start;
align-items: center;
min-height: 100px;
max-height: 100px;
> * {
margin-right: 15px;
&:last-child {
margin-right: 0;
}
}
.inputBox {
width: 880px;
height: 100px !important;
padding-left: 50px;
padding-right: 40px;
background: white;
border-radius: 1000px;
border: 5px solid #ccc;
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 1001;
position: relative;
> div:first-child {
margin: 0 !important;
width: calc(100% - 121px) !important;
height: 90px !important;
padding: 20px 40px 20px 0px !important;
border: none !important;
background-color: #fff !important;
input {
text-align: left;
color: black;
font-size: 42px;
font-family: "LG Smart UI";
font-weight: 700;
line-height: 42px;
outline: none;
border: none;
background: transparent;
}
// 내부 요소들의 포커스 제거
* {
outline: none !important;
border: none !important;
}
// TInput 내부 컨테이너의 포커스 스타일 완전 제거
&[data-spotlight-container="true"] {
outline: none !important;
border: none !important;
box-shadow: none !important;
&:focus,
&:focus-within {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
}
}
&:focus-within,
&:focus {
border: 5px solid @PRIMARY_COLOR_RED;
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
}
// TInput 컴포넌트 자체의 내부 포커스 스타일 제거
> div {
&:focus,
&:focus-within {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
}
// 모든 내부 요소의 포커스 스타일 완전 제거
* {
&:focus,
&:focus-within {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
}
// TInput 내부 Container의 포커스 제거
> div[data-spotlight-container="true"] {
outline: none !important;
border: none !important;
box-shadow: none !important;
&:focus,
&:focus-within {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
}
// InputField의 포커스 제거
input {
outline: none !important;
border: none !important;
box-shadow: none !important;
&:focus,
&:focus-within {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
}
// 검색 아이콘 스타일
.searchIcon {
width: 41px;
height: 41px;
position: relative;
&::before {
content: "";
width: 36.27px;
height: 36.27px;
position: absolute;
left: 1.95px;
top: 1.95px;
border: 3.9px solid black;
border-radius: 50%;
}
}
}
// 마이크 버튼 스타일
.microphoneButton {
width: 100px;
height: 100px;
position: relative;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
z-index: 1001;
.microphoneCircle {
width: 100%;
height: 100%;
position: relative;
background: white;
overflow: hidden;
border-radius: 1000px;
border: 5px solid #ccc;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
.microphoneIcon {
height: 50px;
box-sizing: border-box;
transition: filter 0.3s ease;
}
}
&:hover {
.microphoneCircle {
border-color: @PRIMARY_COLOR_RED;
}
}
&:focus {
.microphoneCircle {
border-color: @PRIMARY_COLOR_RED;
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
}
}
// active 상태 (음성 입력 모드)
&.active {
.microphoneCircle {
background-color: @PRIMARY_COLOR_RED;
border-color: @PRIMARY_COLOR_RED;
box-shadow: 0 0 22px 0 rgba(229, 9, 20, 0.5);
.microphoneIcon {
filter: brightness(0) invert(1); // 아이콘을 흰색으로 변경
}
}
}
}
// 테스트용 Toast 버튼 스타일
.testToastButton {
width: 100px;
height: 100px;
position: relative;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
margin-left: 15px;
.testButtonCircle {
width: 100%;
height: 100%;
position: relative;
background: #ff6b6b;
overflow: hidden;
border-radius: 1000px;
border: 5px solid #ff4757;
display: flex;
justify-content: center;
align-items: center;
font-size: 24px;
color: white;
}
&:hover {
.testButtonCircle {
border-color: @PRIMARY_COLOR_RED;
background: #ff5252;
}
}
&:focus {
.testButtonCircle {
border-color: @PRIMARY_COLOR_RED;
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
}
}
}
}
}
// 컨텐츠 컨테이너
.contentContainer {
width: 100%;
height: 100%;
overflow: hidden;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
display: inline-flex;
.inputFocusBox {
width: 935px;
height: 355px;
margin: 0 auto;
.keywordList {
align-self: stretch;
padding-top: 10px;
justify-content: flex-start;
align-items: flex-start;
display: inline-flex;
flex-wrap: wrap;
flex-direction: column;
> * {
margin-bottom: 5px;
}
.keywordButton {
padding: 20px;
background: white;
border-radius: 100px;
border: 5px solid #dadada;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
display: inline-flex;
cursor: pointer;
transition: all 0.2s ease;
height: 64px;
> * {
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
color: black;
font-size: 24px;
font-family: "LG Smart UI";
font-weight: 700;
line-height: 24px;
text-align: center;
word-wrap: break-word;
&:hover {
border-color: @PRIMARY_COLOR_RED;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
&:focus {
border: 5px solid @PRIMARY_COLOR_RED;
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
outline: none;
}
}
}
}
}
// 섹션 공통 스타일
.section {
align-self: stretch;
padding-top: 63px;
padding-left: 60px;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
display: flex;
> * {
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
.sectionHeader {
width: 1800px;
height: 42px;
justify-content: flex-start;
align-items: center;
display: inline-flex;
> * {
margin-right: 12px;
&:last-child {
margin-right: 0;
}
}
.sectionIndicator {
width: 6px;
height: 36px;
background: #c70850;
}
.sectionTitle {
text-align: center;
color: black;
font-size: 42px;
font-family: "LG Smart UI";
font-weight: 700;
line-height: 42px;
word-wrap: break-word;
}
}
// 키워드 리스트 스타일 (최근 검색어, 인기 검색어, 브랜드)
.keywordList {
align-self: stretch;
padding-top: 30px;
justify-content: flex-start;
align-items: flex-start;
display: inline-flex;
flex-wrap: wrap;
> * {
margin-right: 19px;
margin-bottom: 19px;
}
.keywordButton {
padding: 20px;
background: white;
border-radius: 100px;
border: 5px solid #dadada;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
display: inline-flex;
cursor: pointer;
transition: all 0.2s ease;
> * {
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
color: black;
font-size: 24px;
font-family: "LG Smart UI";
font-weight: 700;
line-height: 24px;
text-align: center;
word-wrap: break-word;
&:hover {
border-color: @PRIMARY_COLOR_RED;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
&:focus {
border: 5px solid @PRIMARY_COLOR_RED;
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
outline: none;
}
}
}
}
.hotpicksSection {
padding-top: 63px;
padding-left: 60px;
width: 1800px;
height: 580px;
.sectionHeader {
width: 1800px;
height: 42px;
justify-content: flex-start;
align-items: center;
display: inline-flex;
> * {
margin-right: 12px;
&:last-child {
margin-right: 0;
}
}
.sectionIndicator {
width: 6px;
height: 36px;
background: #c70850;
}
.sectionTitle {
text-align: center;
color: black;
font-size: 42px;
font-family: "LG Smart UI";
font-weight: 700;
line-height: 42px;
word-wrap: break-word;
}
}
// 상품 리스트 스타일 (Hot Picks for You)
.productList {
padding-top: 30px;
.size(@w: 100%, @h: inherit);
> div:nth-child(1) {
.size(@w: 100%, @h: inherit);
}
.productCard {
width: 546px;
padding: 18px;
background: white;
border-radius: 12px;
border: 5px solid #dadada;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
display: inline-flex;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: @PRIMARY_COLOR_RED;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&:focus {
border: 5px solid @PRIMARY_COLOR_RED;
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
outline: none;
}
.productImageWrapper {
align-self: stretch;
height: 287px;
position: relative;
.productImage {
width: 510px;
height: 287px;
position: absolute;
left: 0;
top: 0;
object-fit: cover;
border-radius: 8px;
}
}
.productInfo {
align-self: stretch;
padding-top: 15px;
padding-bottom: 15px;
justify-content: flex-start;
align-items: flex-start;
display: inline-flex;
> * {
margin-right: 10px;
&:last-child {
margin-right: 0;
}
}
.productBrandWrapper {
justify-content: flex-start;
align-items: center;
display: flex;
> * {
margin-right: 10px;
&:last-child {
margin-right: 0;
}
}
.brandLogo {
width: 60px;
height: 60px;
border-radius: 8px;
object-fit: cover;
}
}
.productDetails {
flex: 1;
flex-direction: column;
justify-content: center;
align-items: flex-start;
display: inline-flex;
> * {
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
.brandName {
text-align: center;
color: #808080;
font-size: 18px;
font-family: "LG Smart UI";
font-weight: 700;
line-height: 18px;
word-wrap: break-word;
}
.productTitle {
align-self: stretch;
color: black;
font-size: 24px;
font-family: "LG Smart UI";
font-weight: 400;
line-height: 24px;
word-wrap: break-word;
}
}
}
}
}
}
// 스크롤 스타일
.tVerticalPagenator {
scrollbar-width: none;
-ms-overflow-style: none;
overflow-y: auto;
height: 100%;
position: relative;
&::-webkit-scrollbar {
display: none;
}
// 스크롤 동작 개선
scroll-behavior: smooth;
}
// 반응형 디자인 (필요시)
@media (max-width: 1920px) {
.section {
.sectionHeader {
width: 100%;
}
}
}
// Spotlight 포커스 스타일
[data-spotlight-id] {
&:focus {
// outline: 2px solid @PRIMARY_COLOR_RED;
}
}
}
.overLay {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 1;
}
.overLayRecent {
position: absolute;
left: 403px;
top: 172px;
width: 995px;
height: 488px;
z-index: 2;
display: flex;
flex-direction: column;
* {
margin-bottom: 5px;
}
.keywordButton {
height: 64px;
background: white;
border-radius: 100px;
border: 5px solid #dadada;
padding: 0 20px;
flex-direction: column;
justify-content: center;
align-items: flex-start;
display: inline-flex;
cursor: pointer;
transition: all 0.2s ease;
width: fit-content;
> * {
margin-bottom: 5px;
&:last-child {
margin-bottom: 0;
}
}
color: black;
font-size: 24px;
font-family: "LG Smart UI";
font-weight: 700;
line-height: 24px;
text-align: center;
word-wrap: break-word;
&:hover {
border-color: @PRIMARY_COLOR_RED;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&:focus {
border: 5px solid @PRIMARY_COLOR_RED;
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
outline: none;
}
}
}

View File

@@ -16,8 +16,10 @@
}
}
// 검색 입력 영역 스타일
// 검색 입력 영역 스타일 - DOM에서 항상 공간 차지
.inputContainer {
position: relative;
width: 100%;
padding-top: 180px;
padding-bottom: 94px;
padding-left: 60px;
@@ -26,8 +28,9 @@
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 2;
position: relative;
z-index: 1001;
pointer-events: all;
&.inputFocus {
padding-bottom: 10px;
}
@@ -35,6 +38,14 @@
padding-bottom: 55px;
padding-top: 55px;
}
// 숨김 상태 - visibility: hidden으로 공간은 차지하되 내용은 안 보임
&.hidden {
visibility: hidden;
opacity: 0;
pointer-events: none;
}
> * {
margin-bottom: 10px;
@@ -71,6 +82,8 @@
display: flex;
justify-content: space-between;
align-items: center;
z-index: 1001;
position: relative;
> div:first-child {
margin: 0 !important;
@@ -196,6 +209,7 @@
justify-content: center;
align-items: center;
padding: 0;
z-index: 1001;
.microphoneCircle {
width: 100%;
@@ -208,10 +222,12 @@
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
.microphoneIcon {
height: 50px;
box-sizing: border-box;
transition: filter 0.3s ease;
}
}
@@ -227,6 +243,19 @@
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
}
}
// active 상태 (음성 입력 모드)
&.active {
.microphoneCircle {
background-color: @PRIMARY_COLOR_RED;
border-color: @PRIMARY_COLOR_RED;
box-shadow: 0 0 22px 0 rgba(229, 9, 20, 0.5);
.microphoneIcon {
filter: brightness(0) invert(1); // 아이콘을 흰색으로 변경
}
}
}
}
// 테스트용 Toast 버튼 스타일

View File

@@ -0,0 +1,187 @@
<div style={{ width: '642px', height: '437px', position: 'relative', borderRadius: 12 }}>
<div
style={{
width: 642,
left: 0,
top: 0,
position: 'absolute',
textAlign: 'center',
color: 'white',
fontSize: 42,
fontFamily: 'LG Smart UI',
fontWeight: '700',
lineHeight: 42,
wordWrap: 'break-word',
}}
>
Try saying
</div>
<div
style={{
left: 0,
top: 57,
position: 'absolute',
flexDirection: 'column',
justifyContent: 'flex-start',
alignItems: 'center',
gap: 15,
display: 'inline-flex',
}}
>
<div
style={{
paddingLeft: 30,
paddingRight: 30,
paddingTop: 20,
paddingBottom: 20,
background: 'rgba(68, 68, 68, 0.50)',
boxShadow: '0px 10px 30px rgba(0, 0, 0, 0.35)',
borderRadius: 1000,
outline: '2px rgba(251, 251, 251, 0.20) solid',
outlineOffset: '-2px',
flexDirection: 'column',
justifyContent: 'flex-start',
alignItems: 'flex-start',
display: 'flex',
}}
>
<div
style={{
textAlign: 'center',
color: '#EAEAEA',
fontSize: 24,
fontFamily: 'LG Smart UI',
fontWeight: '700',
lineHeight: 24,
wordWrap: 'break-word',
}}
>
Can you recommend a good budget cordless vacuum?
</div>
</div>
<div
style={{
paddingLeft: 30,
paddingRight: 30,
paddingTop: 20,
paddingBottom: 20,
background: 'rgba(68, 68, 68, 0.50)',
boxShadow: '0px 10px 30px rgba(0, 0, 0, 0.35)',
borderRadius: 1000,
outline: '2px rgba(251, 251, 251, 0.20) solid',
outlineOffset: '-2px',
flexDirection: 'column',
justifyContent: 'flex-start',
alignItems: 'flex-start',
display: 'flex',
}}
>
<div
style={{
textAlign: 'center',
color: '#EAEAEA',
fontSize: 24,
fontFamily: 'LG Smart UI',
fontWeight: '700',
lineHeight: 24,
wordWrap: 'break-word',
}}
>
Show me trending skincare.
</div>
</div>
<div
style={{
paddingLeft: 30,
paddingRight: 30,
paddingTop: 20,
paddingBottom: 20,
background: 'rgba(68, 68, 68, 0.50)',
boxShadow: '0px 10px 30px rgba(0, 0, 0, 0.35)',
borderRadius: 1000,
outline: '2px rgba(251, 251, 251, 0.20) solid',
outlineOffset: '-2px',
flexDirection: 'column',
justifyContent: 'flex-start',
alignItems: 'flex-start',
display: 'flex',
}}
>
<div
style={{
textAlign: 'center',
color: '#EAEAEA',
fontSize: 24,
fontFamily: 'LG Smart UI',
fontWeight: '700',
lineHeight: 24,
wordWrap: 'break-word',
}}
>
Find the newest Nike sneakers.
</div>
</div>
<div
style={{
paddingLeft: 30,
paddingRight: 30,
paddingTop: 20,
paddingBottom: 20,
background: 'rgba(68, 68, 68, 0.50)',
boxShadow: '0px 10px 30px rgba(0, 0, 0, 0.35)',
borderRadius: 1000,
outline: '2px rgba(251, 251, 251, 0.20) solid',
outlineOffset: '-2px',
flexDirection: 'column',
justifyContent: 'flex-start',
alignItems: 'flex-start',
display: 'flex',
}}
>
<div
style={{
textAlign: 'center',
color: '#EAEAEA',
fontSize: 24,
fontFamily: 'LG Smart UI',
fontWeight: '700',
lineHeight: 24,
wordWrap: 'break-word',
}}
>
Show me snail cream that helps with sensitive skin.
</div>
</div>
<div
style={{
paddingLeft: 30,
paddingRight: 30,
paddingTop: 20,
paddingBottom: 20,
background: 'rgba(68, 68, 68, 0.50)',
boxShadow: '0px 10px 30px rgba(0, 0, 0, 0.35)',
borderRadius: 1000,
outline: '2px rgba(251, 251, 251, 0.20) solid',
outlineOffset: '-2px',
flexDirection: 'column',
justifyContent: 'flex-start',
alignItems: 'flex-start',
display: 'flex',
}}
>
<div
style={{
textAlign: 'center',
color: '#EAEAEA',
fontSize: 24,
fontFamily: 'LG Smart UI',
fontWeight: '700',
lineHeight: 24,
wordWrap: 'break-word',
}}
>
Recommend a tasty melatonin gummy.
</div>
</div>
</div>
</div>;

View File

@@ -0,0 +1,190 @@
// src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx
import React, { useCallback, useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Spotlight from '@enact/spotlight';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import TInput, { ICONS, KINDS } from '../../../components/TInput/TInput';
import micIcon from '../../../../assets/images/searchpanel/image-mic.png';
import css from './VoiceInputOverlay.module.less';
import VoicePromptScreen from './modes/VoicePromptScreen';
const OverlayContainer = SpotlightContainerDecorator(
{
enterTo: 'default-element',
restrict: 'self-only', // 포커스를 overlay 내부로만 제한
},
'div'
);
const SpottableMicButton = Spottable('div');
// Voice overlay 모드 상수
export const VOICE_MODES = {
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 VoiceInputOverlay = ({
isVisible,
onClose,
mode = VOICE_MODES.PROMPT,
suggestions = [],
searchQuery = '',
onSearchChange,
onSearchSubmit,
}) => {
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]);
// Overlay가 열릴 때 포커스를 overlay 내부로 이동
useEffect(() => {
if (isVisible) {
// 현재 포커스된 요소 저장
lastFocusedElement.current = Spotlight.getCurrent();
// Overlay 내부로 포커스 이동
setTimeout(() => {
Spotlight.focus(OVERLAY_SPOTLIGHT_ID);
}, 100);
} else {
// Overlay가 닫힐 때 원래 포커스 복원
if (lastFocusedElement.current) {
setTimeout(() => {
Spotlight.focus(lastFocusedElement.current);
}, 100);
}
}
}, [isVisible]);
// 모드에 따른 컨텐츠 렌더링
const renderModeContent = () => {
switch (mode) {
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.MODE_3:
// 추후 MODE_3 컴포넌트 추가
return <div>Mode 3 (Coming soon)</div>;
case VOICE_MODES.MODE_4:
// 추후 MODE_4 컴포넌트 추가
return <div>Mode 4 (Coming soon)</div>;
default:
return <VoicePromptScreen suggestions={suggestions} />;
}
};
// 입력창 포커스 핸들러
const handleInputFocus = useCallback(() => {
setInputFocus(true);
}, []);
const handleInputBlur = useCallback(() => {
setInputFocus(false);
}, []);
// 마이크 버튼 클릭 (overlay 닫기)
const handleMicClick = useCallback(() => {
onClose();
}, [onClose]);
if (!isVisible) return null;
return (
<div className={css.voiceOverlayContainer}>
{/* 배경 dim 레이어 - 클릭하면 닫힘 */}
<div className={css.dimBackground} onClick={onClose} />
{/* 모드별 컨텐츠 영역 - 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>
</div>
</div>
{/* 모드별 컨텐츠 */}
<div className={css.modeContent}>{renderModeContent()}</div>
</OverlayContainer>
</div>
);
};
VoiceInputOverlay.propTypes = {
isVisible: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
mode: PropTypes.oneOf(Object.values(VOICE_MODES)),
suggestions: PropTypes.arrayOf(PropTypes.string),
searchQuery: PropTypes.string,
onSearchChange: PropTypes.func,
onSearchSubmit: PropTypes.func,
};
VoiceInputOverlay.defaultProps = {
mode: VOICE_MODES.PROMPT,
suggestions: [],
searchQuery: '',
onSearchChange: null,
onSearchSubmit: null,
};
export default VoiceInputOverlay;

View File

@@ -0,0 +1,186 @@
// src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less
@import "../../../style/CommonStyle.module.less";
.voiceOverlayContainer {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999;
pointer-events: all;
}
.dimBackground {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
cursor: pointer;
}
.contentArea {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1002;
pointer-events: none; // 빈 공간 클릭 시 dimBackground로 이벤트 전달
display: flex;
flex-direction: column;
align-items: center;
}
// 입력창과 마이크 버튼 영역 - SearchPanel.inputContainer와 동일 (210px 높이)
.inputWrapper {
width: 100%;
padding-top: 55px;
padding-bottom: 55px;
padding-left: 60px;
padding-right: 60px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 1003;
position: relative;
pointer-events: all; // 입력 영역은 클릭 가능
.searchInputWrapper {
height: 100px;
display: flex;
justify-content: flex-start;
align-items: center;
min-height: 100px;
max-height: 100px;
> * {
margin-right: 15px;
&:last-child {
margin-right: 0;
}
}
}
}
.inputBox {
width: 880px;
height: 100px !important;
padding-left: 50px;
padding-right: 40px;
background: white;
border-radius: 1000px;
border: 5px solid #ccc;
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 1003;
position: relative;
> div:first-child {
margin: 0 !important;
width: calc(100% - 121px) !important;
height: 90px !important;
padding: 20px 40px 20px 0px !important;
border: none !important;
background-color: #fff !important;
input {
text-align: left;
color: black;
font-size: 42px;
font-family: "LG Smart UI";
font-weight: 700;
line-height: 42px;
outline: none;
border: none;
background: transparent;
}
* {
outline: none !important;
border: none !important;
}
}
&:focus-within,
&:focus {
border: 5px solid @PRIMARY_COLOR_RED;
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
}
}
.microphoneButton {
width: 100px;
height: 100px;
position: relative;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
z-index: 1003;
.microphoneCircle {
width: 100%;
height: 100%;
position: relative;
background: white;
overflow: hidden;
border-radius: 1000px;
border: 5px solid #ccc;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s ease;
.microphoneIcon {
height: 50px;
box-sizing: border-box;
transition: filter 0.3s ease;
}
}
&:hover {
.microphoneCircle {
border-color: @PRIMARY_COLOR_RED;
}
}
&:focus {
.microphoneCircle {
border-color: @PRIMARY_COLOR_RED;
box-shadow: 0 0 22px 0 rgba(0, 0, 0, 0.5);
}
}
// active 상태 (음성 입력 모드 - 항상 빨간색)
&.active {
.microphoneCircle {
background-color: @PRIMARY_COLOR_RED;
border-color: @PRIMARY_COLOR_RED;
box-shadow: 0 0 22px 0 rgba(229, 9, 20, 0.5);
.microphoneIcon {
filter: brightness(0) invert(1); // 아이콘을 흰색으로 변경
}
}
}
}
// 모드별 컨텐츠 영역 - 화면 중앙에 배치
.modeContent {
width: 100%;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
z-index: 1002;
pointer-events: all; // 컨텐츠 영역은 클릭 가능
}

View File

@@ -0,0 +1,50 @@
// src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx
import React from 'react';
import PropTypes from 'prop-types';
import Spottable from '@enact/spotlight/Spottable';
import css from './VoicePromptScreen.module.less';
const SpottableBubble = Spottable('div');
const VoicePromptScreen = ({ title = 'Try saying', suggestions = [] }) => {
const handleBubbleClick = (suggestion, index) => {
console.log(`Bubble clicked: ${suggestion}`, index);
// 나중에 음성 검색 실행 등의 로직 추가
};
return (
<div className={css.container}>
<div className={css.title}>{title}</div>
<div className={css.suggestionsContainer}>
{suggestions.map((suggestion, index) => (
<SpottableBubble
key={index}
className={css.bubbleMessage}
onClick={() => handleBubbleClick(suggestion, index)}
spotlightId={`voice-bubble-${index}`}
>
<div className={css.bubbleText}>{suggestion}</div>
</SpottableBubble>
))}
</div>
</div>
);
};
VoicePromptScreen.propTypes = {
title: PropTypes.string,
suggestions: PropTypes.arrayOf(PropTypes.string),
};
VoicePromptScreen.defaultProps = {
title: 'Try saying',
suggestions: [
'" Can you recommend a good budget cordless vacuum? "',
'" Show me trending skincare. "',
'" Find the newest Nike sneakers. "',
'" Show me snail cream that helps with sensitive skin. "',
'" Recommend a tasty melatonin gummy. "',
],
};
export default VoicePromptScreen;

View File

@@ -0,0 +1,77 @@
// src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.module.less
@import "../../../../style/CommonStyle.module.less";
.container {
width: 642px;
height: 437px;
position: relative;
border-radius: 12px;
}
.title {
width: 642px;
left: 0;
top: 0;
position: absolute;
text-align: center;
color: white;
font-size: 42px;
font-family: "LG Smart UI";
font-weight: 700;
line-height: 42px;
word-wrap: break-word;
}
.suggestionsContainer {
left: 0;
top: 57px;
position: absolute;
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 15px;
display: inline-flex;
}
.bubbleMessage {
padding-left: 30px;
padding-right: 30px;
padding-top: 20px;
padding-bottom: 20px;
background: rgba(68, 68, 68, 0.5);
box-shadow: 0px 10px 30px rgba(0, 0, 0, 0.35);
border-radius: 1000px;
outline: 2px rgba(251, 251, 251, 0.2) solid;
outline-offset: -2px;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
display: flex;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
background: rgba(88, 88, 88, 0.6);
outline: 2px rgba(251, 251, 251, 0.3) solid;
transform: translateY(-2px);
box-shadow: 0px 12px 35px rgba(0, 0, 0, 0.45);
}
&:focus {
background: rgba(100, 100, 100, 0.7);
outline: 3px rgba(251, 251, 251, 0.5) solid;
outline-offset: -3px;
box-shadow: 0px 15px 40px rgba(0, 0, 0, 0.55);
transform: translateY(-3px);
}
}
.bubbleText {
text-align: center;
color: #eaeaea;
font-size: 24px;
font-family: "LG Smart UI";
font-weight: 700;
line-height: 24px;
word-wrap: break-word;
}