diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx index f8909f52..b12adcd7 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx +++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx @@ -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); diff --git a/com.twin.app.shoptime/src/views/MainView/MainView.jsx b/com.twin.app.shoptime/src/views/MainView/MainView.jsx index 45f52abe..dbf2923e 100644 --- a/com.twin.app.shoptime/src/views/MainView/MainView.jsx +++ b/com.twin.app.shoptime/src/views/MainView/MainView.jsx @@ -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]); diff --git a/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.jsx b/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.jsx index b5fd03b5..2aa2d005 100644 --- a/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.jsx +++ b/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.jsx @@ -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 ( - - + + {isOnTop && ( diff --git a/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new copy.jsx b/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new copy.jsx new file mode 100644 index 00000000..6b7eb0d5 --- /dev/null +++ b/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new copy.jsx @@ -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 ( + +
+ {title} +
+
+ {showBrandLogo && ( +
+ {brandName} +
+ )} +
+ {showBrandName && ( +
{brandName}
+ )} + {showProductTitle && ( +
{title}
+ )} +
+
+
+ ); + }, + [] + ); + + //test + useEffect(() => { + console.log("###searchDatas", searchDatas); + console.log("###panelInfo", panelInfo); + }, [searchDatas, panelInfo]); + + return ( + + + + {isOnTop && ( + + {/* 검색 내용있을때 검색 부분 */} + {/* 검색 입력 영역 - overlay 열릴 때 숨김 */} + {!isVoiceOverlayVisible && ( + +
+ { + 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} + /> + { + if (e.key === 'Enter') { + onClickMic(); + } + }} + spotlightId={SPOTLIGHT_IDS.MICROPHONE_BUTTON} + > +
+ Microphone +
+
+ + {/* 테스트용 Toast 버튼 (개발용) */} + {/* +
🧪
+
*/} +
+
+ )} + + {/* 검색내용이 존재하고, 인풋창에 포커스가 가서 노출 시작 */} + {inputFocus === true && + (searchDatas?.item?.length > 0 || + searchDatas?.show?.length > 0) && ( + <> +
+
+ {recentResultSearches.map((keyword, index) => ( + handleKeywordClick(keyword)} + spotlightId={`recent-Resultkeyword-${index}`} + > + {keyword} + + ))} +
+ + )} + {/* 검색내용이 존재하고, 인풋창에 포커스가 가서 노출 끝! */} + + {/* 결과갑 부분 작업중 시작! */} + + {/* 결과갑 부분 작업중 끝! */} + {/* 검색 결과 표시 영역 */} + {searchPerformed && searchQuery !== null ? ( + + ) : ( + + {/* 노출 조건 변경 필요. 포커스 블러만으로는 안됌.(가상 키보드 노출시가 맞을듯) */} + {/* {inputFocus === false ? ( */} + {/* {inputFocus === false && ( */} + <> + {/* 최근 검색어 섹션 */} + +
+
+
+ Your Recent Searches +
+
+
+ {recentSearches.map((keyword, index) => ( + handleKeywordClick(keyword)} + spotlightId={`recent-keyword-${index}`} + > + {keyword} + + ))} +
+
+ + {/* 인기 검색어 섹션 */} + +
+
+
Top Searches
+
+
+ {topSearches.map((keyword, index) => ( + handleKeywordClick(keyword)} + spotlightId={`top-keyword-${index}`} + > + {keyword} + + ))} +
+
+ + {/* 인기 브랜드 섹션 */} + +
+
+
Popular Brands
+
+
+ {popularBrands.map((brand, index) => ( + handleKeywordClick(brand)} + spotlightId={`brand-${index}`} + > + {brand} + + ))} +
+
+ + {/* Hot Picks for You 섹션 */} + +
+
+
+ Hot Picks for You +
+
+
+ {hotPicks && hotPicks.length > 0 && ( + + )} +
+
+ + {/* )} */} + {/* ) : ( */} + {/*
+
+ {recentSearches.map((keyword, index) => ( + handleKeywordClick(keyword)} + spotlightId={`recent-keyword-${index}`} + > + {keyword} + + ))} +
+
*/} + {/* )} */} +
+ )} +
+ )} +
+ + + {/* Virtual Keyboard */} + {/* setShowVirtualKeyboard(false)} + /> */} + + {/* Voice Input Overlay */} + setIsVoiceOverlayVisible(false)} + mode={voiceMode} + suggestions={voiceSuggestions} + searchQuery={searchQuery} + onSearchChange={handleSearchChange} + onSearchSubmit={handleSearchSubmit} + /> +
+ ); +} diff --git a/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.jsx b/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.jsx index 33538d02..58330309 100644 --- a/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.jsx +++ b/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.jsx @@ -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({
{showBrandLogo && (
- {brandName} + {brandName}
)}
- {showBrandName && ( -
{brandName}
- )} - {showProductTitle && ( -
{title}
- )} + {showBrandName &&
{brandName}
} + {showProductTitle &&
{title}
}
@@ -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 ( - - + + {isOnTop && ( {/* 검색 내용있을때 검색 부분 */} - {/* 검색 입력 영역 */} + {/* 검색 입력 영역 - overlay 열릴 때 숨김 (visibility로 처리) */} { + if (e.key === 'Enter') { + onClickMic(); + } + }} spotlightId={SPOTLIGHT_IDS.MICROPHONE_BUTTON} >
- Microphone + Microphone
@@ -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) && ( <>
@@ -699,9 +637,7 @@ export default function SearchPanel({ >
-
- Your Recent Searches -
+
Your Recent Searches
{recentSearches.map((keyword, index) => ( @@ -773,9 +709,7 @@ export default function SearchPanel({ >
-
- Hot Picks for You -
+
Hot Picks for You
{hotPicks && hotPicks.length > 0 && ( @@ -821,6 +755,17 @@ export default function SearchPanel({ isVisible={showVirtualKeyboard} onClose={() => setShowVirtualKeyboard(false)} /> */} + + {/* Voice Input Overlay */} + setIsVoiceOverlayVisible(false)} + mode={voiceMode} + suggestions={voiceSuggestions} + searchQuery={searchQuery} + onSearchChange={handleSearchChange} + onSearchSubmit={handleSearchSubmit} + /> ); } diff --git a/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.module copy.less b/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.module copy.less new file mode 100644 index 00000000..f97e8767 --- /dev/null +++ b/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.module copy.less @@ -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; + } + } +} diff --git a/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.module.less b/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.module.less index 761cfb32..0fc76145 100644 --- a/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.module.less +++ b/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.module.less @@ -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 버튼 스타일 diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/OverlayFirst.figma.jsx b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/OverlayFirst.figma.jsx new file mode 100644 index 00000000..e6c8a484 --- /dev/null +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/OverlayFirst.figma.jsx @@ -0,0 +1,187 @@ +
+
+ Try saying +
+
+
+
+ “ 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. ” +
+
+
+
; diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx new file mode 100644 index 00000000..683ecf78 --- /dev/null +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.jsx @@ -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 ; + case VOICE_MODES.MODE_2: + // 추후 MODE_2 컴포넌트 추가 + return
Mode 2 (Coming soon)
; + case VOICE_MODES.MODE_3: + // 추후 MODE_3 컴포넌트 추가 + return
Mode 3 (Coming soon)
; + case VOICE_MODES.MODE_4: + // 추후 MODE_4 컴포넌트 추가 + return
Mode 4 (Coming soon)
; + default: + return ; + } + }; + + // 입력창 포커스 핸들러 + const handleInputFocus = useCallback(() => { + setInputFocus(true); + }, []); + + const handleInputBlur = useCallback(() => { + setInputFocus(false); + }, []); + + // 마이크 버튼 클릭 (overlay 닫기) + const handleMicClick = useCallback(() => { + onClose(); + }, [onClose]); + + if (!isVisible) return null; + + return ( +
+ {/* 배경 dim 레이어 - 클릭하면 닫힘 */} +
+ + {/* 모드별 컨텐츠 영역 - Spotlight Container (self-only) */} + + {/* 입력창과 마이크 버튼 - SearchPanel.inputContainer와 동일한 구조 */} +
+
+ onSearchSubmit && onSearchSubmit(searchQuery)} + spotlightId={INPUT_SPOTLIGHT_ID} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + /> + { + if (e.key === 'Enter') { + handleMicClick(); + } + }} + spotlightId={MIC_SPOTLIGHT_ID} + > +
+ Microphone +
+
+
+
+ + {/* 모드별 컨텐츠 */} +
{renderModeContent()}
+
+
+ ); +}; + +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; diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less new file mode 100644 index 00000000..34802395 --- /dev/null +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/VoiceInputOverlay.module.less @@ -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; // 컨텐츠 영역은 클릭 가능 +} diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx new file mode 100644 index 00000000..388095a7 --- /dev/null +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.jsx @@ -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 ( +
+
{title}
+
+ {suggestions.map((suggestion, index) => ( + handleBubbleClick(suggestion, index)} + spotlightId={`voice-bubble-${index}`} + > +
{suggestion}
+
+ ))} +
+
+ ); +}; + +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; diff --git a/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.module.less b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.module.less new file mode 100644 index 00000000..efeac45f --- /dev/null +++ b/com.twin.app.shoptime/src/views/SearchPanel/VoiceInputOverlay/modes/VoicePromptScreen.module.less @@ -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; +}