Files
shoptime/com.twin.app.shoptime/src/views/SearchPanel/SearchPanel.new.jsx
junghoon86.park c415887e26 [search] 디자인 작업
- 중간부분에 처리할수없는 부분이있어 주석처리해두었습니다.
 - 주석명 (검색내용이 존재하고, 인풋창에 포커스가 가서 노출 시작)
 - 나머지 디자인 맞춰서 작업해두었습니다.
- 데이터 다른데서 가져와서 넣어두었습니다.
2025-10-02 17:30:50 +09:00

817 lines
26 KiB
JavaScript

// 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,
} 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';
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 [inputFocus, setInputFocus] = useState(false);
const _onFocus = () => {
setInputFocus(true);
};
const _onBlur = () => {
setInputFocus(false);
};
useEffect(() => {
console.log("###inputFocus", inputFocus);
}, [inputFocus]);
let searchQueryRef = usePrevious(searchQuery);
let isOnTopRef = usePrevious(isOnTop);
const isRecommendedSearchRef = useRef(false);
const firstButtonSpotlightId = "first-keyword-button";
const focusJob = useRef(new Job((func) => func(), 100));
const cbChangePageRef = useRef(null);
const [focusedContainerId, setFocusedContainerId] = useState(
panelInfo?.focusedContainerId
);
const focusedContainerIdRef = usePrevious(focusedContainerId);
const bestSellerDatas = useSelector(
(state) => state.product.bestSellerData.bestSeller
);
// 가짜 데이터 - 실제로는 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,
},
],
[]
);
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 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>
);
},
[]
);
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}
>
{/* 검색 내용있을때 검색 부분 */}
{/* 검색 입력 영역 */}
<InputContainer
className={classNames(
css.inputContainer,
inputFocus === true && css.inputFocus,
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}
spotlightDisabled={false}
spotlightBoxDisabled={true}
onFocus={_onFocus}
onBlur={_onBlur}
/>
<SpottableMicButton
className={css.microphoneButton}
onClick={onCancel}
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>
{/* 검색내용이 존재하고, 인풋창에 포커스가 가서 노출 시작 */}
{/* 피그마 기준 첫번째 줄 마지막 이미지 */}
{/* <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 />
) : (
<ContainerBasic className={css.contentContainer}>
{/* 노출 조건 변경 필요. 포커스 블러만으로는 안됌.(가상 키보드 노출시가 맞을듯) */}
{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)}
/> */}
</TPanel>
);
}