[251123] fix: DetailPanel ThemeContent-1

🕐 커밋 시간: 2025. 11. 23. 08:13:50

📊 변경 통계:
  • 총 파일: 5개
  • 추가: +500줄
  • 삭제: -171줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/components/TToast/TToastEnhanced.jsx
  ~ com.twin.app.shoptime/src/components/TToast/TToastEnhanced.module.less
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
  ~ com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeContents.jsx
  ~ com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeContents.module.less

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/components/TToast/TToastEnhanced.module.less (unknown):
     Added: gradient()
  📄 com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx (javascript):
    🔄 Modified: extractProductMeta()
  📄 com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeContents.jsx (javascript):
     Added: handleItemClick()
     Deleted: handleItemClick(), productNameDangerouslySetInnerHTML()
  📄 com.twin.app.shoptime/src/views/DetailPanel/ThemeProduct/ThemeContents.module.less (unknown):
     Added: gradient(), global()

🔧 주요 변경 내용:
  • UI 컴포넌트 아키텍처 개선
This commit is contained in:
2025-11-23 08:13:51 +09:00
parent 9153c70af0
commit 8d45d89d09
5 changed files with 494 additions and 183 deletions

View File

@@ -1,19 +1,14 @@
import React, {
useEffect,
useRef,
useState,
} from 'react';
import React, { useEffect, useRef, useState } from 'react';
import classNames from 'classnames';
import {
useDispatch,
useSelector,
} from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import Spotlight from '@enact/spotlight';
import Spottable from '@enact/spotlight/Spottable';
import { changeAppStatus } from '../../actions/commonActions';
import BuyOption from '../../views/DetailPanel/components/BuyOption';
import ThemeContents from '../../views/DetailPanel/ThemeProduct/ThemeContents';
import css from './TToastEnhanced.module.less';
const SpottableToast = Spottable('div');
@@ -37,6 +32,17 @@ export default function TToastEnhanced({
productInfo, // 🚀 BuyOption에 전달할 상품 정보
selectedPatnrId, // 🚀 BuyOption에 전달할 파트너 ID
selectedPrdtId, // 🚀 BuyOption에 전달할 상품 ID
// 🚀 ThemeContents 관련 props
themeItems,
setSelectedIndex,
videoVerticalVisible,
currentVideoShowId,
tabIndex,
handleItemFocus,
tabTitle,
panelInfo,
direction,
version,
...rest
}) {
const dispatch = useDispatch();
@@ -60,6 +66,12 @@ export default function TToastEnhanced({
// 약간의 지연을 두고 애니메이션 시작
const showTimer = setTimeout(() => {
setIsVisible(true);
// themeContents 타입일 때 포커스 설정
if (type === 'themeContents') {
setTimeout(() => {
Spotlight.focus('theme-contents-close-button');
}, 100);
}
}, 50);
startTimer();
@@ -68,36 +80,40 @@ export default function TToastEnhanced({
clearTimeout(showTimer);
clearTimer();
};
}, []);
}, [type]);
// BuyOption 컨테이너 ref
// BuyOption, ThemeContents 컨테이너 ref
const buyOptionRef = useRef(null);
const themeContentsRef = useRef(null);
// BuyOption 타입일 때 전역 포커스 감지
// BuyOption, ThemeContents 타입일 때 전역 포커스 감지
useEffect(() => {
if (type === 'buyOption') {
// BuyOption이 포커스를 받았는지 추적하는 플래그
let hasBuyOptionReceivedFocus = false;
if (type === 'buyOption' || type === 'themeContents') {
// 포커스를 받았는지 추적하는 플래그
let hasComponentReceivedFocus = false;
const componentRef = type === 'buyOption' ? buyOptionRef : themeContentsRef;
const handleFocusChange = (e) => {
// 1. BuyOption 내부로 포커스가 들어온 경우 - 플래그를 true로 설정
if(!cursorVisible){
if (buyOptionRef.current && buyOptionRef.current.contains(e.target)) {
if (!hasBuyOptionReceivedFocus) {
hasBuyOptionReceivedFocus = true;
console.log('[TToastEnhanced] BuyOption received focus - now tracking focus leaving');
// 1. 컴포넌트 내부로 포커스가 들어온 경우 - 플래그를 true로 설정
if (!cursorVisible) {
if (componentRef.current && componentRef.current.contains(e.target)) {
if (!hasComponentReceivedFocus) {
hasComponentReceivedFocus = true;
console.log(`[TToastEnhanced] ${type} received focus - now tracking focus leaving`);
}
return; // 내부에 포커스가 있으면 아무것도 하지 않음
}
// 2. BuyOption이 포커스를 받은 적이 있고, 현재 외부로 포커스가 이동한 경우 - Toast 닫기
// 2. 컴포넌트가 포커스를 받은 적이 있고, 현재 외부로 포커스가 이동한 경우 - Toast 닫기
// themeContents는 spotlightRestrict: 'self-only'이므로 keyboard로는 포커스가 나가지 않음
// 따라서 이는 mouse click 등으로 다른 요소를 클릭한 경우만 해당
if (
hasBuyOptionReceivedFocus &&
buyOptionRef.current &&
!buyOptionRef.current.contains(e.target)
hasComponentReceivedFocus &&
componentRef.current &&
!componentRef.current.contains(e.target)
) {
console.log(
'[TToastEnhanced] Focus left BuyOption after receiving focus - closing toast'
`[TToastEnhanced] Focus left ${type} after receiving focus - closing toast`
);
handleClose();
}
@@ -195,6 +211,22 @@ export default function TToastEnhanced({
selectedPrdtId={selectedPrdtId}
/>
</div>
) : type === 'themeContents' ? (
<div ref={themeContentsRef}>
<ThemeContents
themeItems={themeItems}
setSelectedIndex={setSelectedIndex}
videoVerticalVisible={videoVerticalVisible}
currentVideoShowId={currentVideoShowId}
tabIndex={tabIndex}
handleItemFocus={handleItemFocus}
tabTitle={tabTitle}
panelInfo={panelInfo}
direction={direction}
version={version}
onThemeItemClose={handleClose}
/>
</div>
) : (
<div className={css.content}>
<div className={css.message}>{text}</div>

View File

@@ -115,6 +115,14 @@
height: 400px;
}
.themeContents {
height: auto;
max-height: 80vh;
background: 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%, rgba(0, 0, 0, 0.40) 45%, rgba(0, 0, 0, 0.40) 100%), rgba(30, 30, 30, 0.80);
border-radius: 12px;
overflow: hidden;
}
// 컨텐츠 스타일 - 간단한 메시지만
.content {
display: flex;

View File

@@ -923,16 +923,33 @@ export default function ProductAllSection({
[]
);
const handleThemeItemButtonClick = useCallback(
pipe(
() => setOpenThemeItemOverlay(true),
tap(() => {
const timerId = setTimeout(() => Spotlight.focus('theme-close-button'), 0);
timersRef.current.push(timerId);
const handleThemeItemButtonClick = useCallback(() => {
dispatch(
showToast({
id: 'theme-contents-toast',
message: '',
type: 'themeContents',
duration: 0, // 수동으로 닫을 때까지 유지
position: 'bottom-center',
// ThemeContents 관련 props
themeItems: themeProducts,
setSelectedIndex,
videoVerticalVisible: false,
currentVideoShowId: null,
tabIndex: 0,
handleItemFocus: () => {},
tabTitle: ['THEME'],
panelInfo,
direction: 'horizontal',
version: 2,
onToastClose: () => {
setTimeout(() => {
Spotlight.focus('theme-open-button');
}, 100);
},
})
),
[setOpenThemeItemOverlay]
);
}, [dispatch, themeProducts, setSelectedIndex, panelInfo]);
const handleProductDetailsClick = useCallback(() => {
dispatch(minimizeModalMedia());
@@ -1463,6 +1480,10 @@ export default function ProductAllSection({
className={css.themeButton}
onClick={handleThemeItemButtonClick}
spotlightId="theme-open-button"
onMouseDown={(e) => {
e.preventDefault();
handleThemeItemButtonClick();
}}
>
<div>{$L('THEME ITEM')}</div>
<img src={arrowDownIcon} className={css.themeButtonIcon} />

View File

@@ -1,20 +1,55 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import Spotlight from '@enact/spotlight';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import arrowDownIcon from '../../../../assets/images/icons/ic-arrow-down.svg';
import { updatePanel } from '../../../actions/panelActions';
import TButton from '../../../components/TButton/TButton';
import TVirtualGridList from '../../../components/TVirtualGridList/TVirtualGridList';
import { LOG_CONTEXT_NAME, LOG_MENU, LOG_MESSAGE_ID, panel_names } from '../../../utils/Config';
import { LOG_CONTEXT_NAME, LOG_MESSAGE_ID, panel_names } from '../../../utils/Config';
import { $L } from '../../../utils/helperMethods';
import PlayerItemCard, { TYPES } from '../../PlayerPanel/PlayerItemCard/PlayerItemCard';
import ListEmptyContents from '../../PlayerPanel/PlayerTabContents/TabContents/ListEmptyContents/ListEmptyContents';
import css from './ThemeContents.module.less';
import { sendLogTotalRecommend } from '../../../actions/logActions';
const Container = SpotlightContainerDecorator(
{
enterTo: 'default-element',
preserveId: true,
spotlightDirection: 'vertical', // THEME ITEM 버튼 -> 리스트(아래) 이동
spotlightRestrict: 'self-only',
leaveFor: {}, // arrow key로 빠져나가지 않도록 설정
},
'div'
);
const ItemsContainer = SpotlightContainerDecorator(
{
enterTo: 'default-element',
preserveId: true,
spotlightDirection: 'horizontal', // 리스트 내 좌/우 이동
spotlightRestrict: 'self-only',
},
'div'
);
const ButtonContainer = SpotlightContainerDecorator(
{
enterTo: 'default-element',
preserveId: true,
spotlightDirection: 'horizontal',
// 버튼에서 아래 리스트로 포커스 이동해야 하므로 self-only는 사용하지 않는다
},
'div'
);
const SpottableItemCard = Spottable({
allowDisabledFocus: true,
noAutoFocus: true,
})('div');
export default function ThemeContents({
themeItems,
setSelectedIndex,
@@ -31,6 +66,74 @@ export default function ThemeContents({
const dispatch = useDispatch();
const isClickBlocked = useRef(false);
const blockTimeoutRef = useRef(null);
const [focusedIndex, setFocusedIndex] = useState(-1);
// Mock 데이터
const mockItems = [
{
prdtId: 'mock-1',
prdtNm: 'Origami Removable Connecting Rack 2-pack',
prdtImgPath: 'https://placehold.co/120x120',
salePrice: '$32.98',
originalPrice: '$150.00',
patnrLogoPath: null,
patncNm: 'Partner 1',
showId: 'show-1',
catNm: 'Category',
energyLabels: [],
},
{
prdtId: 'mock-2',
prdtNm: 'Origami Removable Connecting Rack 2-pack',
prdtImgPath: 'https://placehold.co/120x120',
salePrice: '$32.98',
originalPrice: '$150.00',
patnrLogoPath: null,
patncNm: 'Partner 2',
showId: 'show-2',
catNm: 'Category',
energyLabels: [],
},
{
prdtId: 'mock-3',
prdtNm: 'Origami Removable Connecting Rack 2-pack',
prdtImgPath: 'https://placehold.co/120x120',
salePrice: '$32.98',
originalPrice: '$150.00',
patnrLogoPath: null,
patncNm: 'Partner 3',
showId: 'show-3',
catNm: 'Category',
energyLabels: [],
},
{
prdtId: 'mock-4',
prdtNm: 'Origami Removable Connecting Rack 2-pack',
prdtImgPath: 'https://placehold.co/120x120',
salePrice: '$32.98',
originalPrice: '$150.00',
patnrLogoPath: null,
patncNm: 'Partner 4',
showId: 'show-4',
catNm: 'Category',
energyLabels: [],
},
{
prdtId: 'mock-5',
prdtNm: 'Origami Removable Connecting Rack 2-pack',
prdtImgPath: 'https://placehold.co/120x120',
salePrice: '$32.98',
originalPrice: '$150.00',
patnrLogoPath: null,
patncNm: 'Partner 5',
showId: 'show-5',
catNm: 'Category',
energyLabels: [],
},
];
// themeItems가 비어있으면 mock 데이터 사용
const displayItems = themeItems && themeItems.length > 0 ? themeItems : mockItems;
const handleThemeItemButtonClick = useCallback(() => {
if (onThemeItemClose) {
@@ -38,17 +141,61 @@ export default function ThemeContents({
}
}, [onThemeItemClose]);
const handleFocus = useCallback(
() => () => {
if (handleItemFocus) {
handleItemFocus(LOG_MENU.THEME_ITEMS);
// cleanup useEffect
useEffect(() => {
return () => {
if (blockTimeoutRef.current) {
clearTimeout(blockTimeoutRef.current);
}
},
[handleItemFocus]
);
};
}, []);
const renderItem = useCallback(
({ index, ...rest }) => {
// 토스트가 열리면 닫기(=THEME ITEM) 버튼부터 포커스되도록 보정
useEffect(() => {
Spotlight.focus('theme-contents-close-button');
}, []);
// 키 이동 보정: 위/아래 이동 시 THEME ITEM 버튼 <-> 첫 번째 아이템으로 확실히 연결
useEffect(() => {
if (!displayItems || displayItems.length === 0) return;
const firstItemId = 'theme-toast-item-0';
Spotlight.set('theme-contents-close-button', {
next: { down: firstItemId },
});
displayItems.slice(0, 5).forEach((_, index) => {
const itemId = `theme-toast-item-${index}`;
Spotlight.set(itemId, {
next: { up: 'theme-contents-close-button' },
});
});
}, [displayItems]);
return (
<Container
className={css.toastWrapper}
spotlightId="theme-contents-container"
spotlightDefaultElement="theme-contents-close-button"
>
<ButtonContainer className={css.topButtonWrapper} spotlightId="theme-button-container">
<TButton
className={css.themeButton}
onClick={handleThemeItemButtonClick}
spotlightId="theme-contents-close-button"
data-spotlight-default
>
<div>{$L('THEME ITEM')}</div>
<img src={arrowDownIcon} className={css.themeButtonIcon} />
</TButton>
</ButtonContainer>
<ItemsContainer
className={css.itemsWrapper}
spotlightId="theme-items-container"
spotlightDefaultElement="theme-toast-item-0"
>
{displayItems && displayItems.length > 0 ? (
displayItems.slice(0, 5).map((item, index) => {
const {
prdtId,
prdtNm,
@@ -60,7 +207,9 @@ export default function ThemeContents({
showId,
catNm,
energyLabels,
} = themeItems[index];
} = item;
const spotlightItemId = `theme-toast-item-${index}`;
const handleItemClick = () => {
const params = {
@@ -75,14 +224,12 @@ export default function ThemeContents({
};
dispatch(sendLogTotalRecommend(params));
// 중복클릭방지
if (isClickBlocked.current) {
return;
}
isClickBlocked.current = true;
// 이전 타이머가 있으면 정리
if (blockTimeoutRef.current) {
clearTimeout(blockTimeoutRef.current);
}
@@ -108,91 +255,50 @@ export default function ThemeContents({
);
};
const productNameDangerouslySetInnerHTML = () => {
return prdtNm ? { __html: prdtNm } : { __html: '' };
};
return (
<PlayerItemCard
{...rest}
<SpottableItemCard
key={prdtId}
imageAlt={prdtId}
logo={patnrLogoPath}
imageSource={prdtImgPath}
videoVerticalVisible={videoVerticalVisible}
productName={productNameDangerouslySetInnerHTML}
patnerName={patncNm}
salePrice={salePrice}
originalPrice={originalPrice}
energyLabels={energyLabels}
className={`${css.itemCard} ${focusedIndex === index ? css.focused : ''}`}
onClick={handleItemClick}
onFocus={handleFocus()}
onSpotlightUp={
version === 2 && index === 0
? (e) => {
// v2에서 첫 번째 아이템일 때 위로 가면 THEME ITEM 버튼으로
e.stopPropagation();
e.preventDefault();
Spotlight.focus('below-tab-theme-button');
spotlightId={spotlightItemId}
data-spotlight-default={index === 0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleItemClick();
}
: undefined
}
type={TYPES.themeHorizontal}
spotlightId={`tabTheme-item-${index}`}
version={version}
/>
);
},
[
themeItems,
currentVideoShowId,
isClickBlocked,
dispatch,
handleFocus,
version,
tabIndex,
tabTitle,
panelInfo,
setSelectedIndex,
]
);
// cleanup useEffect
useEffect(() => {
return () => {
if (blockTimeoutRef.current) {
clearTimeout(blockTimeoutRef.current);
}
};
}, []);
return (
<>
<div className={css.container}>
{themeItems && themeItems.length > 0 ? (
<TVirtualGridList
dataSize={themeItems.length}
direction={direction}
renderItem={renderItem}
itemWidth={470}
itemHeight={180}
spacing={18}
noScrollByWheel={false}
/>
) : (
<ListEmptyContents tabIndex={tabIndex} />
}}
onFocus={() => {
setFocusedIndex(index);
handleItemFocus?.(index);
}}
onBlur={() => setFocusedIndex(-1)}
onMouseEnter={() => Spotlight.focus(spotlightItemId)}
>
<img src={prdtImgPath} alt={prdtId} className={css.itemImage} />
<div className={css.itemInfo}>
<div className={css.itemName}>{prdtNm}</div>
<div className={css.itemPrice}>
<span className={css.salePrice}>{salePrice}</span>
<span className={css.originalPrice}>{originalPrice}</span>
</div>
{energyLabels && energyLabels.length > 0 && (
<div className={css.energyLabels}>
{energyLabels.map((label, labelIndex) => (
<div key={labelIndex} className={css.energyLabel}>
{/* 에너지 라벨 렌더링 */}
{label}
</div>
))}
</div>
)}
</div>
<div className={css.bottomButtonWrapper}>
<TButton
className={css.themeButton}
onClick={handleThemeItemButtonClick}
spotlightId="below-tab-theme-button"
>
<div>{$L('THEME ITEM')}</div>
<img src={arrowDownIcon} className={css.themeButtonIcon} />
</TButton>
</div>
</>
</SpottableItemCard>
);
})
) : (
<div>No items</div>
)}
</ItemsContainer>
</Container>
);
}

View File

@@ -1,5 +1,149 @@
@import "../../../../style/CommonStyle.module.less";
@import "../../../../style/utils.module.less";
@import "../../../style/CommonStyle.module.less";
@import "../../../style/utils.module.less";
// Toast wrapper 스타일
.toastWrapper {
width: 100%;
height: 390px;
display: inline-flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 30px;
padding: 60px;
background: 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%, rgba(0, 0, 0, 0.4) 45%, rgba(0, 0, 0, 0.4) 100%),
rgba(30, 30, 30, 0.8);
overflow: visible; // 포커스 테두리가 잘리지 않도록
}
.topButtonWrapper {
width: 635px;
height: 60px;
display: inline-flex;
justify-content: flex-start;
align-items: center;
}
.itemsWrapper {
display: inline-flex;
flex-direction: row;
gap: 18px;
justify-content: flex-start;
align-items: center;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: visible; // 포커스 테두리 보존
flex: none;
padding: 4px; // 포커스 테두리(4px box-shadow) 표시 공간 확보
}
.itemCard {
width: 470px;
height: 180px;
padding: 30px;
background: linear-gradient(0deg, 0%, 100%), #2C2C2C;
border-radius: 12px;
justify-content: flex-start;
align-items: flex-start;
gap: 15px;
display: inline-flex;
flex-shrink: 0;
cursor: pointer;
outline: none;
transition: all 0.2s ease;
position: relative;
&:focus {
outline: none;
box-shadow: 0 0 0 4px @PRIMARY_COLOR_RED;
}
&:global(.spotlight) {
box-shadow: 0 0 0 4px @PRIMARY_COLOR_RED;
}
}
.focused {
box-shadow: 0 0 0 4px @PRIMARY_COLOR_RED;
}
.itemImage {
width: 120px;
height: 120px;
padding-left: 0.41px;
padding-right: 0.41px;
padding-top: 0.51px;
padding-bottom: 0.51px;
flex-shrink: 0;
}
.itemInfo {
flex: 1 1 0;
align-self: stretch;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 10px;
display: inline-flex;
}
.itemName {
align-self: stretch;
color: #EAEAEA;
font-size: 20px;
font-family: @baseFont;
font-weight: 400;
line-height: 25px;
word-wrap: break-word;
}
.itemPrice {
padding-top: 1px;
padding-bottom: 1px;
justify-content: flex-start;
align-items: center;
gap: 5px;
display: inline-flex;
}
.salePrice {
color: #C70850;
font-size: 20px;
font-family: @baseFont;
font-weight: 700;
line-height: 16.67;
word-wrap: break-word;
}
.originalPrice {
color: #C2C2C2;
font-size: 16px;
font-family: @baseFont;
font-weight: 400;
text-decoration: line-through;
line-height: 16.67;
word-wrap: break-word;
}
.energyLabels {
justify-content: flex-end;
align-items: flex-end;
gap: 5px;
display: inline-flex;
}
.energyLabel {
width: 54px;
height: 30px;
position: relative;
overflow: hidden;
}
.container {
width: 100%;
@@ -33,10 +177,10 @@
}
.themeButton {
width: 100% !important;
width: 635px !important;
height: 60px !important;
padding: 20px 30px !important;
background: rgba(255, 255, 255, 0.05) !important;
background: #c72054 !important;
overflow: visible !important;
border-radius: 6px !important;
border: none !important;