[251008] feat v2 탭 컨테이너 추가 및 UI 조정

v2 탭 컨테이너를 도입하고, 아이템 크기와 간격을 조정해 UI를 개선했습니다.

🕐 커밋 시간: 2025. 10. 08. 21:38:17

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

📁 추가된 파일:
  + com.twin.app.shoptime/src/components/TItemCard/TItemCard.v2.module.less
  + com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/ShopNowContents.v2.module.less
  + com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/YouMayLikeContents.v2.module.less
  + com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/v2/TabContainer.v2.jsx
  + com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/v2/TabContainer.v2.module.less

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/components/TItemCard/TItemCard.jsx
  ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerPanel.jsx
  ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/LiveChannelContents.jsx
  ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/LiveChannelContents.v2.module.less
  ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/ShopNowContents.jsx
  ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/YouMayLikeContents.jsx

🔧 주요 변경 내용:
  • UI 컴포넌트 아키텍처 개선
  • 소규모 기능 개선
  • 모듈 구조 개선
This commit is contained in:
2025-10-08 21:38:22 +09:00
parent a8289c905e
commit 7f66fd9735
11 changed files with 581 additions and 22 deletions

View File

@@ -13,7 +13,8 @@ import IcLiveShow from "../../../assets/images/tag/tag-liveshow.png";
import usePriceInfo from "../../hooks/usePriceInfo";
import { $L, removeSpecificTags } from "../../utils/helperMethods";
import CustomImage from "../CustomImage/CustomImage";
import css from "./TItemCard.module.less";
import css1 from "./TItemCard.module.less";
import css2 from "./TItemCard.v2.module.less";
import { sendLogTotalRecommend } from "../../actions/logActions";
const SpottableComponent = Spottable("div");
@@ -81,8 +82,10 @@ export default memo(function TItemCard({
nowCategory,
nowProductTitle,
contentId,
version = 1,
...rest
}) {
const css = version === 2 ? css2 : css1;
const dispatch = useDispatch();
const [defaultImage, setDefaultImage] = useState(null);
const countryCode = useSelector((state) => state.common.httpHeader.cntry_cd);

View File

@@ -0,0 +1,140 @@
@import "../../style/CommonStyle.module.less";
@import "../../style/utils.module.less";
// horizontal type for v2 (ShopNowItem.module.less 스타일 적용, TItemCard.jsx 클래스명 구조 유지)
.horizontal {
width: 310px;
padding: 18px;
background: rgba(51, 51, 51, 0.95);
border-radius: 12px;
border: 1px solid transparent;
.flex(@display: flex, @justifyCenter: flex-start, @alignCenter: flex-start, @direction: column);
gap: 15px;
flex-shrink: 0;
transition: all 0.3s ease;
.imageWrap {
align-self: stretch;
height: 274px;
padding: 5px 4px;
position: relative;
> img {
width: 100%;
height: 100%;
object-fit: contain;
}
// discount rate
> span {
.position(@position: absolute, @right: 12px, @bottom: 12px);
.size(@w: 60px, @h: 60px);
border-radius: 60px;
background-color: @PRIMARY_COLOR_RED;
font-size: 26px;
font-weight: 600;
text-align: center;
line-height: 60px;
color: @COLOR_WHITE;
}
// sold out
.soldout {
.position(@position: absolute, @top: 0, @right: 0);
.flex();
.size(@w: 100%, @h: 100%);
background-color: rgba(26, 26, 26, 0.6);
font-weight: bold;
font-size: 24px;
color: @COLOR_WHITE;
}
}
.descWrap {
align-self: stretch;
.flex(@display: flex, @justifyCenter: center, @alignCenter: flex-start, @direction: column);
gap: 15px;
.logo {
width: 60px;
height: 60px;
> img {
width: 60px;
min-height: 60px;
}
}
.title {
align-self: stretch;
.productNameTitle {
align-self: stretch;
color: #EAEAEA;
font-size: 24px;
font-family: @baseFont;
font-weight: 700;
line-height: 31px;
word-wrap: break-word;
.elip(@clamp: 2);
}
}
.priceInfo {
align-self: stretch;
.flex(@display: flex, @justifyCenter: flex-start, @alignCenter: center);
gap: 11px;
font-weight: 700;
font-size: 30px;
color: @PRIMARY_COLOR_RED;
font-family: @baseFont;
line-height: 35px;
word-wrap: break-word;
> span {
color: #808080;
font-size: 24px;
font-family: @baseFont;
font-weight: 400;
text-decoration: line-through;
line-height: 18px;
word-wrap: break-word;
}
> strong {
color: @PRIMARY_COLOR_RED;
font-size: 30px;
font-family: @baseFont;
font-weight: 700;
line-height: 35px;
}
}
.offerInfo {
align-self: stretch;
color: @PRIMARY_COLOR_RED;
font-size: 30px;
font-family: @baseFont;
font-weight: 700;
line-height: 35px;
word-wrap: break-word;
}
}
&:focus {
border-color: @PRIMARY_COLOR_RED;
outline: 4px @PRIMARY_COLOR_RED solid;
outline-offset: -4px;
&::after {
.focused(@boxShadow: 22px, @borderRadius: 12px);
}
}
}
// vertical type (v2에서는 사용하지 않음)
.vertical {
}
// videoShow type (v2에서는 사용하지 않음)
.videoShow {
}

View File

@@ -55,9 +55,10 @@ import PlayerOverlayQRCode from './PlayerOverlay/PlayerOverlayQRCode';
import css from './PlayerPanel.module.less';
import PlayerTabButton from './PlayerTabContents/TabButton/PlayerTabButton';
import TabContainer from './PlayerTabContents/TabContainer';
import LiveShowContainer from './PlayerTabContents/v2/LiveShowContainer';
import ShopNowContainer from './PlayerTabContents/v2/ShopNowContainer';
import ShopNowButton from './PlayerTabContents/v2/ShopNowButton';
import TabContainerV2 from './PlayerTabContents/v2/TabContainer.v2';
// import LiveShowContainer from './PlayerTabContents/v2/LiveShowContainer';
// import ShopNowContainer from './PlayerTabContents/v2/ShopNowContainer';
// import ShopNowButton from './PlayerTabContents/v2/ShopNowButton';
const Container = SpotlightContainerDecorator(
{ enterTo: 'default-element', preserveld: true },
@@ -203,6 +204,7 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
});
const [isVODPaused, setIsVODPaused] = USE_STATE('isVODPaused', false);
const [belowTabMode, setBelowTabMode] = USE_STATE('belowTabMode', 'liveShow');
const [tabIndexV2, setTabIndexV2] = USE_STATE('tabIndexV2', 1); // 0: ShopNow, 1: LiveChannel, 2: ShopNowButton
const panels = USE_SELECTOR('panels', (state) => state.panels.panels);
const chatData = USE_SELECTOR('chatData', (state) => state.play.chatData);
@@ -2076,7 +2078,7 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
/>
)}
{shouldShowBelowTab && (
{/* {shouldShowBelowTab && (
<>
{belowTabMode === 'liveShow' && (
<LiveShowContainer
@@ -2111,6 +2113,25 @@ const PlayerPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props
/>
)}
</>
)} */}
{shouldShowBelowTab && (
<TabContainerV2
panelInfo={panelInfo}
playListInfo={playListInfo}
shopNowInfo={shopNowInfo}
selectedIndex={selectedIndex}
setSelectedIndex={setSelectedIndex}
liveChannelInfos={liveChannelInfos || liveShowInfos}
videoVerticalVisible={videoVerticalVisible}
handleItemFocus={handleItemFocus}
prevChannelIndex={prevChannelIndex}
currentTime={currentTime}
spotlightId="tab-container-v2-spotlight-id"
tabIndex={tabIndexV2}
onShopNowButtonClick={() => setTabIndexV2(0)}
onLiveChannelButtonClick={() => setTabIndexV2(2)}
/>
)}
</Container>

View File

@@ -142,9 +142,9 @@ export default function LiveChannelContents({
dataSize={liveInfos.length}
direction={direction}
renderItem={renderItem}
itemWidth={version === 2 ? 280 : videoVerticalVisible ? 540 : 600}
itemHeight={version === 2 ? 78 : 236}
spacing={12}
itemWidth={version === 2 ? 470 : videoVerticalVisible ? 540 : 600}
itemHeight={version === 2 ? 155 : 236}
spacing={version === 2 ? 30 : 12}
noScrollByWheel={false}
/>
) : (

View File

@@ -3,7 +3,7 @@
.container {
width: 100%;
height: 175px;
height: 155px;
> div:nth-child(1) {
.size(@w: 100%, @h: 100%);

View File

@@ -22,7 +22,8 @@ import useScrollTo from "../../../../hooks/useScrollTo";
import { LOG_CONTEXT_NAME, LOG_MENU, LOG_MESSAGE_ID, panel_names } from "../../../../utils/Config";
import { scaleH } from "../../../../utils/helperMethods";
import ListEmptyContents from "../TabContents/ListEmptyContents/ListEmptyContents";
import css from "./ShopNowContents.module.less";
import css1 from "./ShopNowContents.module.less";
import cssV2 from "./ShopNowContents.v2.module.less";
import { sendLogTotalRecommend } from "../../../../actions/logActions";
@@ -49,8 +50,11 @@ export default function ShopNowContents({
tabIndex,
handleItemFocus,
panelInfo,
tabTitle
tabTitle,
version = 1,
direction = "vertical"
}) {
const css = version === 2 ? cssV2 : css1;
const { getScrollTo, scrollTop } = useScrollTo();
const dispatch = useDispatch();
const paenls = useSelector((state) => state.panels.panels[1]?.panelInfo);
@@ -175,10 +179,11 @@ export default function ShopNowContents({
onClick={handleItemClick}
onFocus={handleFocus()}
type={TYPES.horizontal}
version={version}
/>
);
},
[shopNowInfo, videoVerticalVisible, panelInfo?.shptmBanrTpNm, priceInfoMap, tabTitle, tabIndex, playListInfo, dispatch]
[shopNowInfo, videoVerticalVisible, panelInfo?.shptmBanrTpNm, priceInfoMap, tabTitle, tabIndex, playListInfo, dispatch, version]
);
return (
@@ -189,11 +194,11 @@ export default function ShopNowContents({
style={gridStyle}
cbScrollTo={getScrollTo}
dataSize={shopNowInfo.length}
direction="vertical"
direction={direction}
renderItem={renderItem}
itemWidth={videoVerticalVisible ? 540 : 600}
itemHeight={236}
spacing={12}
itemWidth={version === 2 ? 310 : (videoVerticalVisible ? 540 : 600)}
itemHeight={version === 2 ? 420 : 236}
spacing={version === 2 ? 30 : 12}
className={
videoVerticalVisible ? css.verticalItemList : css.itemList
}

View File

@@ -0,0 +1,52 @@
@import "../../../../style/CommonStyle.module.less";
@import "../../../../style/utils.module.less";
.container {
width: 100%;
height: 420px;
overflow: hidden;
> div:nth-child(1) {
.size(@w: 100%, @h: 100%);
}
}
.itemList {
width: 100%;
overflow: hidden;
> div {
> div {
> div {
> div {
> div {
.size(@w: 310px, @h: 420px);
> div:nth-child(1) {
flex: 1 0 auto;
}
}
}
}
}
}
}
.verticalItemList {
width: 100%;
overflow: hidden;
> div {
> div {
> div {
> div {
> div {
.size(@w: 310px, @h: 420px);
> div:nth-child(1) {
flex: 1 0 auto;
}
}
}
}
}
}
}

View File

@@ -7,13 +7,17 @@ import TItemCard, { TYPES } from "../../../../components/TItemCard/TItemCard";
import TVirtualGridList from "../../../../components/TVirtualGridList/TVirtualGridList";
import { LOG_MENU, panel_names } from "../../../../utils/Config";
import { $L, scaleH } from "../../../../utils/helperMethods";
import css from "./YouMayLikeContents.module.less";
import css1 from "./YouMayLikeContents.module.less";
import cssV2 from "./YouMayLikeContents.v2.module.less";
export default function YouMayLikeContents({
shopNowInfo,
handleItemFocus,
playListInfo,
version = 1,
direction = "vertical"
}) {
const css = version === 2 ? cssV2 : css1;
const dispatch = useDispatch();
const [height, setHeight] = useState(scaleH(236));
const youmaylikeInfos = useSelector((state) => state.main.youmaylikeInfos);
@@ -72,10 +76,11 @@ export default function YouMayLikeContents({
onClick={handleItemClick}
onFocus={handleFocus()}
type={TYPES.horizontal}
version={version}
/>
);
},
[youmaylikeInfos]
[youmaylikeInfos, version, handleFocus, dispatch, playListInfo]
);
return (
@@ -88,11 +93,11 @@ export default function YouMayLikeContents({
<TVirtualGridList
style={gridStyle}
dataSize={youmaylikeInfos.length}
direction="vertical"
direction={direction}
renderItem={renderItem}
itemWidth={600}
itemHeight={236}
spacing={12}
itemWidth={version === 2 ? 310 : 600}
itemHeight={version === 2 ? 420 : 236}
spacing={version === 2 ? 30 : 12}
className={css.itemList}
noScrollByWheel={false}
/>

View File

@@ -0,0 +1,37 @@
@import "../../../../style/CommonStyle.module.less";
@import "../../../../style/utils.module.less";
.title {
color: #EAEAEA;
font-size: 30px;
font-weight: 700;
margin-top: 40px;
margin-bottom: 20px;
}
.line {
.size(@w: 100%, @h: 1px);
opacity: 0.3;
background-color: #d9d9d9;
margin-bottom: 24px;
}
.itemList {
width: 100%;
overflow: hidden;
> div {
> div {
> div {
> div {
> div {
.size(@w: 310px, @h: 420px);
> div:nth-child(1) {
flex: 1 0 auto;
}
}
}
}
}
}
}

View File

@@ -0,0 +1,156 @@
import React, { useCallback, useEffect } from "react";
import classNames from "classnames";
import Spotlight from "@enact/spotlight";
import Spottable from "@enact/spotlight/Spottable";
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
import { LOG_MENU } from "../../../../utils/Config";
import css from "./TabContainer.v2.module.less";
import LiveChannelContents from "../TabContents/LiveChannelContents";
import ShopNowContents from "../TabContents/ShopNowContents";
import YouMayLikeContents from "../TabContents/YouMayLikeContents";
import ShopNowButton from "./ShopNowButton";
import icon_arrow_dwon from "../../../../../assets/images/player/icon_tabcontainer_arrow_down.png";
import icon_shop_now from "../../../../../assets/images/player/icon_tabcontainer_shopnow.png";
const Container = SpotlightContainerDecorator(
{ enterTo: "last-focused" },
"div"
);
const SpottableDiv = Spottable("div");
export default function TabContainerV2({
panelInfo,
playListInfo,
shopNowInfo,
selectedIndex,
setSelectedIndex,
liveChannelInfos,
videoVerticalVisible,
handleItemFocus,
prevChannelIndex,
currentTime,
spotlightId,
tabIndex = 1, // tabIndex prop으로 제어 (0: ShopNow, 1: LiveChannel, 2: ShopNowButton)
onShopNowButtonClick,
onLiveChannelButtonClick,
}) {
useEffect(() => {
let nowMenu;
if (tabIndex === 0) {
nowMenu = LOG_MENU.FULL_SHOP_NOW;
}
if (tabIndex === 1) {
const isLive = panelInfo?.shptmBanrTpNm === "LIVE";
nowMenu = isLive
? LOG_MENU.FULL_LIVE_CHANNELS
: LOG_MENU.FULL_FEATURED_SHOWS;
}
if (nowMenu) {
handleItemFocus(nowMenu);
}
}, [handleItemFocus, panelInfo?.shptmBanrTpNm, tabIndex]);
const _handleItemFocus = useCallback(
(nowMenu) => {
if (handleItemFocus) {
handleItemFocus(nowMenu);
}
},
[handleItemFocus]
);
const onSpotlightIndicatorUpButton = useCallback(
(e) => {
if (videoVerticalVisible) {
e.stopPropagation();
e.preventDefault();
Spotlight.focus("spotlightId-video-contaienr");
}
},
[videoVerticalVisible]
);
return (
<Container
className={classNames(
css.tabContainer,
videoVerticalVisible && css.vertical,
css[`tabIndex${tabIndex}`]
)}
spotlightId={spotlightId}
>
{tabIndex === 0 && (
<>
<div className={css.shopNowHeader}>
<div className={css.shopNowIconWrapper}>
<img src={icon_shop_now} alt="shop now icon" className={css.shopNowIcon} />
</div>
<div className={css.shopNowHeaderText}>SHOP NOW</div>
</div>
<ShopNowContents
shopNowInfo={shopNowInfo}
playListInfo={playListInfo && playListInfo[selectedIndex]}
videoVerticalVisible={videoVerticalVisible}
panelInfo={panelInfo}
tabIndex={tabIndex}
handleItemFocus={_handleItemFocus}
version={2}
direction="horizontal"
/>
</>
)}
{tabIndex === 1 && (
<>
<SpottableDiv
className={css.liveChannelButton}
onClick={onLiveChannelButtonClick}
spotlightId="below-tab-live-channel-button"
>
<span className={css.buttonText}>LIVE CHANNEL</span>
<div className={css.arrowIcon}>
<img src={icon_arrow_dwon} alt="arrow down" />
</div>
</SpottableDiv>
{panelInfo?.shptmBanrTpNm === "LIVE" && playListInfo && (
<LiveChannelContents
selectedIndex={selectedIndex}
setSelectedIndex={setSelectedIndex}
videoVerticalVisible={videoVerticalVisible}
currentVideoShowId={playListInfo[selectedIndex]?.showId}
liveInfos={playListInfo}
tabIndex={tabIndex}
handleItemFocus={_handleItemFocus}
panelInfo={panelInfo}
currentTime={currentTime}
version={2}
direction="horizontal"
/>
)}
</>
)}
{tabIndex === 2 && (
<ShopNowButton onClick={onShopNowButtonClick} />
)}
{shopNowInfo && shopNowInfo.length < 3 && tabIndex === 0 && (
<YouMayLikeContents
shopNowInfo={shopNowInfo}
handleItemFocus={_handleItemFocus}
playListInfo={playListInfo && playListInfo[selectedIndex]}
version={2}
direction="horizontal"
/>
)}
</Container>
);
}

View File

@@ -0,0 +1,140 @@
@import "../../../../style/CommonStyle.module.less";
@import "../../../../style/utils.module.less";
.tabContainer {
.position(@position: fixed, @bottom: 0, @left: 0);
.flex(@display: flex, @justifyCenter: flex-start, @alignCenter: flex-start, @direction: column);
overflow: hidden;
z-index: 5;
// tabIndex = 0: ShopNow 스타일 (ShopNowContainer 참고)
&.tabIndex0 {
.size(@w: 100%, @h: 675px);
padding: 60px;
background: linear-gradient(270deg, rgba(0, 0, 0, 0.80) 0%, rgba(0, 0, 0, 0.62) 30%, rgba(0, 0, 0, 0) 65%),
linear-gradient(0deg, rgba(0, 0, 0, 0.53) 0%, rgba(20.56, 4.68, 32.71, 0.53) 60%, rgba(199, 32, 84, 0) 98%),
linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, black 45%, black 100%),
rgba(0, 0, 0, 0.56);
border-top: 1px solid rgba(234, 234, 234, 0.8);
gap: 40px;
}
// tabIndex = 1: LiveShow 스타일 (LiveShowContainer 참고)
&.tabIndex1 {
.size(@w: 100%, @h: 365px);
padding: 60px;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.95) 0%,
rgba(0, 0, 0, 0.85) 40%,
rgba(0, 0, 0, 0.6) 70%,
rgba(0, 0, 0, 0) 100%
);
border-top: 1px solid rgba(234, 234, 234, 0.3);
gap: 20px;
&::before {
content: "";
.position(@position: absolute, @top: 0, @left: 0);
.size(@w: 100%, @h: 100%);
background: linear-gradient(
90deg,
rgba(30, 30, 30, 0.4) 0%,
rgba(50, 50, 50, 0.25) 30%,
transparent 50%,
rgba(50, 50, 50, 0.25) 70%,
rgba(30, 30, 30, 0.4) 100%
);
pointer-events: none;
z-index: -1;
}
}
// tabIndex = 2: ShopNowButton만 (특별한 컨테이너 스타일 없음)
&.tabIndex2 {
background: transparent;
border: none;
}
&.vertical {
// vertical 모드일 때 추가 스타일 (필요시)
}
}
// SHOP NOW 헤더 스타일 (ShopNowContainer 참고)
.shopNowHeader {
width: 300px;
height: 70px;
padding: 20px 0;
overflow: hidden;
border-radius: 100px;
.flex(@display: flex, @justifyCenter: flex-start, @alignCenter: center);
gap: 15px;
}
.shopNowIconWrapper {
.size(@w: 40px, @h: 40px);
.position(@position: relative);
background: white;
border-radius: 100px;
.flex(@display: flex, @justifyCenter: center, @alignCenter: center);
}
.shopNowIcon {
.size(@w: 20.67px, @h: 20.67px);
object-fit: contain;
}
.shopNowHeaderText {
color: #EAEAEA;
font-size: 24px;
font-family: @baseFont;
font-weight: 700;
line-height: 31px;
word-wrap: break-word;
}
// LIVE CHANNEL 버튼 스타일 (LiveShowContainer 참고)
.liveChannelButton {
.size(@w: 300px, @h: 70px);
padding: 20px 30px;
border-radius: 100px;
border: 1px solid rgba(234, 234, 234, 0.5);
overflow: hidden;
.flex(@display: flex, @justifyCenter: space-between, @alignCenter: center);
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(5px);
cursor: pointer;
transition: all 0.3s ease;
.buttonText {
color: @COLOR_WHITE;
font-size: 24px;
font-family: @baseFont;
font-weight: 600;
line-height: 35px;
}
.arrowIcon {
.size(@w: 26.25px, @h: 15.63px);
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
&:hover {
background: rgba(0, 0, 0, 0.5);
border-color: rgba(234, 234, 234, 0.7);
}
&:focus {
background: rgba(199, 8, 80, 0.2);
border-color: @PRIMARY_COLOR_RED;
&::after {
.focused(@boxShadow: 22px, @borderRadius: 100px);
}
}
}