[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:
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 버튼 스타일
|
||||
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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; // 컨텐츠 영역은 클릭 가능
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user