[251012] fear: VoicePanel and GNB update

🕐 커밋 시간: 2025. 10. 12. 16:42:36

📊 변경 통계:
  • 총 파일: 10개
  • 추가: +56줄
  • 삭제: -12줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/components/TabLayout/iconComponents/VoiceIcon.jsx
  + com.twin.app.shoptime/src/views/VoicePanel/VoiceHeader.jsx
  + com.twin.app.shoptime/src/views/VoicePanel/VoiceHeader.module.less
  + com.twin.app.shoptime/src/views/VoicePanel/VoicePanel.jsx
  + com.twin.app.shoptime/src/views/VoicePanel/VoicePanel.module.less

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/components/TabLayout/TabItemSub.jsx
  ~ com.twin.app.shoptime/src/components/TabLayout/TabItemSub.module.less
  ~ com.twin.app.shoptime/src/components/TabLayout/TabLayout.jsx
  ~ com.twin.app.shoptime/src/utils/Config.js
  ~ com.twin.app.shoptime/src/views/MainView/MainView.jsx

🔧 주요 변경 내용:
  • UI 컴포넌트 아키텍처 개선
  • 공통 유틸리티 함수 최적화
  • 소규모 기능 개선
  • 모듈 구조 개선
This commit is contained in:
2025-10-12 16:42:39 +09:00
parent 7f1f3100d8
commit 05a1629fc9
10 changed files with 315 additions and 33 deletions

View File

@@ -1,18 +1,18 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from 'react';
import classNames from "classnames";
import compose from "ramda/src/compose";
import classNames from 'classnames';
import compose from 'ramda/src/compose';
import { Job } from "@enact/core/util";
import { Marquee, MarqueeController } from "@enact/sandstone/Marquee";
import Spottable from "@enact/spotlight/Spottable";
import { Job } from '@enact/core/util';
import { Marquee, MarqueeController } from '@enact/sandstone/Marquee';
import Spottable from '@enact/spotlight/Spottable';
import css from "./TabItemSub.module.less";
import { sendLogTotalRecommend } from "../../actions/logActions";
import { LOG_CONTEXT_NAME, LOG_MESSAGE_ID } from "../../utils/Config";
import { useDispatch } from "react-redux";
import css from './TabItemSub.module.less';
import { sendLogTotalRecommend } from '../../actions/logActions';
import { LOG_CONTEXT_NAME, LOG_MESSAGE_ID } from '../../utils/Config';
import { useDispatch } from 'react-redux';
const SpottableComponent = Spottable("div");
const SpottableComponent = Spottable('div');
const TabItemBase = ({
selected = false,
@@ -34,6 +34,7 @@ const TabItemBase = ({
setSelectedSubItemId,
setSelectedSubIndex,
label,
icons,
...rest
}) => {
const [focused, setFocused] = useState(false);
@@ -43,7 +44,7 @@ const TabItemBase = ({
const _onClick = useCallback(
(ev) => {
const subtitle = title.split("-")[0];
const subtitle = title.split('-')[0];
const buttonTitle = patncNm ? patncNm : subtitle;
clearPressedJob.current.start(() => {
if (itemId) {
@@ -63,7 +64,7 @@ const TabItemBase = ({
contextName: LOG_CONTEXT_NAME.GNB,
messageId: LOG_MESSAGE_ID.GNB,
buttonTitle: buttonTitle,
buttonId: `GNB_CLICK_${buttonTitle.toUpperCase().replace(" ", "_")}`,
buttonId: `GNB_CLICK_${buttonTitle.toUpperCase().replace(' ', '_')}`,
})
);
});
@@ -88,7 +89,7 @@ const TabItemBase = ({
const onKeyDown = useCallback(
(event) => {
if (event.key === "ArrowRight") {
if (event.key === 'ArrowRight') {
_onClick();
}
},
@@ -105,9 +106,7 @@ const TabItemBase = ({
focused && selected && css.selectedFocus
)}
>
<div
className={classNames(css.iconContainer, focused && css.focused)}
>
<div className={classNames(css.iconContainer, focused && css.focused)}>
<img src={path} alt="" />
<h3>{patncNm}</h3>
</div>
@@ -117,21 +116,29 @@ const TabItemBase = ({
}, [path, focused]);
const TextComponent = useCallback(() => {
const subtitle = title.split("-")[0];
const subtitle = title.split('-')[0];
const IconComponent = icons;
return (
<>
{subtitle && (
<Marquee
marqueeDisabled={!focused}
marqueeOn={"focus"}
className={classNames(css.text, isSubItem && css.subItem)}
>
{subtitle}
</Marquee>
<div className={css.textWithIcon}>
{IconComponent && (
<span className={css.iconWrapper}>
<IconComponent iconType={focused ? 'focused' : selected ? 'selected' : 'normal'} />
</span>
)}
<Marquee
marqueeDisabled={!focused}
marqueeOn={'focus'}
className={classNames(css.text, isSubItem && css.subItem)}
>
{subtitle}
</Marquee>
</div>
)}
</>
);
}, [title, focused, expanded]);
}, [title, focused, expanded, icons, selected]);
delete rest.hasChildren;
delete rest.getChildren;
@@ -154,9 +161,9 @@ const TabItemBase = ({
aria-label={
patncNm
? selected && path
? "Selected Channel " + patncNm + " button " + label
: "Channel " + patncNm + " button " + label
: title.split("-")[0] + " Button " + label
? 'Selected Channel ' + patncNm + ' button ' + label
: 'Channel ' + patncNm + ' button ' + label
: title.split('-')[0] + ' Button ' + label
}
>
<div className={classNames(isSubItem && css.subWrap)}>

View File

@@ -92,6 +92,19 @@
}
}
.textWithIcon {
display: flex;
align-items: center;
gap: 12px;
}
.iconWrapper {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.text {
height: 42px;
line-height: 1.2;

View File

@@ -31,6 +31,7 @@ import MyPageIcon from './iconComponents/MyPageIcon';
import OnSaleIcon from './iconComponents/OnSaleIcon';
import SearchIcon from './iconComponents/SearchIcon';
import TrendingNowIcon from './iconComponents/TrendingNowIcon';
import VoiceIcon from './iconComponents/VoiceIcon';
import CartIcon from './iconComponents/CartIcon';
import TabItem from './TabItem';
import TabItemSub from './TabItemSub';
@@ -99,6 +100,7 @@ const PANELS_HAS_TAB = [
panel_names.MY_PAGE_PANEL,
panel_names.ON_SALE_PANEL,
panel_names.SEARCH_PANEL,
// VOICE_PANEL은 서브메뉴로만 접근되므로 제외
panel_names.TRENDING_NOW_PANEL,
];
@@ -230,9 +232,18 @@ export default function TabLayout({ topPanelName, onTabActivated, panelInfo }) {
result = [
{
icons: SearchIcon,
id: 'search',
title: 'Search',
spotlightId: 'spotlight_search',
target: [{ name: panel_names.SEARCH_PANEL }],
},
{
icons: VoiceIcon,
id: 'voice',
title: 'Voice',
spotlightId: 'spotlight_voice',
target: [{ name: panel_names.VOICE_PANEL }],
},
];
break;
@@ -297,19 +308,27 @@ export default function TabLayout({ topPanelName, onTabActivated, panelInfo }) {
const menuInfo = getMenuData(currentKey || 'GNB');
for (let j = 0; j < menuInfo.length; j++) {
if (![10600, 10500, 10300].includes(currentKey)) {
if (![10600, 10500, 10300, 10700].includes(currentKey)) {
menuItems[i].target = menuInfo[j].target;
}
menuItems[i].spotlightId = menuInfo[j].spotlightId;
menuItems[i].icons = menuInfo[j].icons;
if ([10600, 10500, 10300].includes(currentKey)) {
// 10700(Search)의 경우 메인 아이콘은 SearchIcon으로 고정
if (currentKey === 10700) {
menuItems[i].icons = SearchIcon;
menuItems[i].spotlightId = 'spotlight_search';
} else {
menuItems[i].spotlightId = menuInfo[j].spotlightId;
menuItems[i].icons = menuInfo[j].icons;
}
if ([10600, 10500, 10300, 10700].includes(currentKey)) {
menuItems[i].children = menuInfo.map((item) => ({
id: item.id,
title: item.title,
path: item.path,
patncNm: item.patncNm,
target: item.target,
icons: item.icons,
spotlightId: `secondDepth-${item.id}`,
}));
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { scaleW } from '../../../utils/helperMethods';
import useConvertThemeColor from './useConvertThemeColor';
const VoiceIcon = ({ iconType = 'normal' }) => {
const themeColor = useConvertThemeColor({ iconType });
return (
<svg
width={scaleW(48)}
height={scaleW(48)}
viewBox="0 0 45 45"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22.5 7.5c-2.5 0-4.5 2-4.5 4.5v10.5c0 2.5 2 4.5 4.5 4.5s4.5-2 4.5-4.5V12c0-2.5-2-4.5-4.5-4.5z"
fill={themeColor}
stroke={themeColor}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M31.5 22.5c0 5-4 9-9 9s-9-4-9-9M22.5 31.5v6M16.5 37.5h12"
stroke={themeColor}
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
export default VoiceIcon;

View File

@@ -16,6 +16,7 @@ export const panel_names = {
MY_PAGE_PANEL: 'mypagepanel',
CATEGORY_PANEL: 'categorypanel',
SEARCH_PANEL: 'searchpanel',
VOICE_PANEL: 'voicepanel',
ON_SALE_PANEL: 'onsalepanel',
TRENDING_NOW_PANEL: 'trendingnowpanel',
HOT_PICKS_PANEL: 'hotpickpanel',

View File

@@ -70,6 +70,7 @@ import OnSalePanel from '../OnSalePanel/OnSalePanel';
import PlayerPanel from '../PlayerPanel/PlayerPanel';
import PlayerPanelNew from '../PlayerPanel/PlayerPanel.new';
import SearchPanel from '../SearchPanel/SearchPanel';
import VoicePanel from '../VoicePanel/VoicePanel';
import ThemeCurationPanel from '../ThemeCurationPanel/ThemeCurationPanel';
import TrendingNowPanel from '../TrendingNowPanel/TrendingNowPanel';
import UserReviewPanel from '../UserReview/UserReviewPanel';
@@ -90,6 +91,7 @@ const panelMap = {
[Config.panel_names.MY_PAGE_PANEL]: MyPagePanel,
[Config.panel_names.CATEGORY_PANEL]: CategoryPanel,
[Config.panel_names.SEARCH_PANEL]: SearchPanel,
[Config.panel_names.VOICE_PANEL]: VoicePanel,
[Config.panel_names.ON_SALE_PANEL]: OnSalePanel,
[Config.panel_names.TRENDING_NOW_PANEL]: TrendingNowPanel,
[Config.panel_names.HOT_PICKS_PANEL]: HotPicksPanel,

View File

@@ -0,0 +1,65 @@
import React, { useCallback } from 'react';
import classNames from 'classnames';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import css from './VoiceHeader.module.less';
const Container = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
const SpottableComponent = Spottable('button');
export default function VoiceHeader({
title,
className,
onBackButton,
onSpotlightUp,
onSpotlightLeft,
onClick,
...rest
}) {
const _onClick = useCallback(
(e) => {
if (onClick) {
onClick(e);
}
},
[onClick]
);
const _onSpotlightUp = useCallback(
(e) => {
if (onSpotlightUp) {
onSpotlightUp(e);
}
},
[onSpotlightUp]
);
const _onSpotlightLeft = useCallback(
(e) => {
if (onSpotlightLeft) {
onSpotlightLeft(e);
}
},
[onSpotlightLeft]
);
return (
<Container className={classNames(css.voiceHeader, className)} {...rest}>
{onBackButton && (
<SpottableComponent
className={css.backButton}
onClick={_onClick}
spotlightId={'spotlightId_voiceBackBtn'}
onSpotlightUp={_onSpotlightUp}
onSpotlightLeft={_onSpotlightLeft}
aria-label="Back"
role="button"
/>
)}
{title && <div className={css.title}>{title}</div>}
</Container>
);
}

View File

@@ -0,0 +1,40 @@
@import "../../style/CommonStyle.module.less";
@import "../../style/utils.module.less";
.voiceHeader {
align-self: stretch;
margin: 30px 60px;
height: 60px;
display: flex;
justify-content: flex-start;
align-items: center;
background-color: transparent;
.title {
font-size: 36px;
font-weight: 700;
color: #FFFFFF;
padding-left: 0;
letter-spacing: 1px;
white-space: nowrap;
overflow: hidden;
line-height: 60px;
height: 60px;
}
}
.backButton {
.size(@w: 50px, @h: 50px);
background-size: 50px 50px;
background-position: center;
background-image: url("../../../assets/images/btn/btn-60-bk-back-nor@3x.png");
border: none;
flex-shrink: 0;
margin-right: 20px;
&:focus {
border-radius: 10px;
background-image: url("../../../assets/images/btn/btn-60-wh-back-foc@3x.png");
box-shadow: 0px 6px 30px 0 rgba(0, 0, 0, 0.4);
}
}

View File

@@ -0,0 +1,59 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import TBody from '../../components/TBody/TBody';
import TPanel from '../../components/TPanel/TPanel';
import { sendLogGNB } from '../../actions/logActions';
import { popPanel } from '../../actions/panelActions';
import { LOG_MENU } from '../../utils/Config';
import VoiceHeader from './VoiceHeader';
import css from './VoicePanel.module.less';
const ContainerBasic = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
export default function VoicePanel({ panelInfo, isOnTop, spotlightId }) {
const dispatch = useDispatch();
const loadingComplete = useSelector((state) => state.common?.loadingComplete);
useEffect(() => {
if (isOnTop) {
dispatch(sendLogGNB(LOG_MENU.SEARCH_SEARCH));
}
}, [isOnTop, dispatch]);
const handleBackButton = useCallback(() => {
console.log(`[VoicePanel] Back button clicked - returning to previous panel`);
dispatch(popPanel());
}, [dispatch]);
return (
<TPanel
panelInfo={panelInfo}
className={css.voicePanel}
isOnTop={isOnTop}
spotlightId={spotlightId}
>
<VoiceHeader
title="Voice Search"
onBackButton={handleBackButton}
onClick={handleBackButton}
className={css.header}
/>
<TBody spotlightId={spotlightId} className={css.tbody}>
{loadingComplete && (
<ContainerBasic className={css.voiceContainer}>
<div className={css.voiceContent}>
<h1 className={css.title}>Voice Panel</h1>
<p className={css.description}>
Voice search functionality will be implemented here.
</p>
</div>
</ContainerBasic>
)}
</TBody>
</TPanel>
);
}

View File

@@ -0,0 +1,41 @@
.voicePanel {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.header {
flex-shrink: 0;
}
.tbody {
flex: 1;
overflow: hidden;
}
.voiceContainer {
display: flex;
justify-content: center;
align-items: center;
min-height: 600px;
padding: 60px;
}
.voiceContent {
text-align: center;
max-width: 800px;
}
.title {
font-size: 48px;
font-weight: bold;
color: #ffffff;
margin-bottom: 30px;
}
.description {
font-size: 24px;
color: #cccccc;
line-height: 1.6;
}