[251122] fix: DetailPaneel->ProductAllSection Focus\

🕐 커밋 시간: 2025. 11. 22. 06:07:46

📊 변경 통계:
  • 총 파일: 2개
  • 추가: +40줄
  • 삭제: -2줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
  ~ com.twin.app.shoptime/src/views/DetailPanel/components/FavoriteBtn.jsx

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx (javascript):
    🔄 Modified: SpotlightContainerDecorator(), extractProductMeta()
  📄 com.twin.app.shoptime/src/views/DetailPanel/components/FavoriteBtn.jsx (javascript):
    🔄 Modified: Spottable()

🔧 주요 변경 내용:
  • UI 컴포넌트 아키텍처 개선
This commit is contained in:
2025-11-22 06:07:46 +09:00
parent 5278151102
commit 8b64875bfe
2 changed files with 155 additions and 165 deletions

View File

@@ -1,60 +1,37 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
// import { throttle } from 'lodash';
import { PropTypes } from 'prop-types';
import {
useDispatch,
useSelector,
} from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import Spotlight from '@enact/spotlight';
/* eslint-disable react/jsx-no-bind */
// src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
import SpotlightContainerDecorator
from '@enact/spotlight/SpotlightContainerDecorator';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import couponImg from '../../../../assets/images/icons/coupon.png';
// import Spottable from '@enact/spotlight/Spottable';
//image
import arrowDown
from '../../../../assets/images/icons/ic_arrow_down_3x_new.png';
import indicatorDefaultImage
from '../../../../assets/images/img-thumb-empty-144@3x.png';
import {
setHidePopup,
setShowPopup,
} from '../../../actions/commonActions.js';
import arrowDown from '../../../../assets/images/icons/ic_arrow_down_3x_new.png';
import indicatorDefaultImage from '../../../../assets/images/img-thumb-empty-144@3x.png';
import { setHidePopup, setShowPopup } from '../../../actions/commonActions.js';
import {
getProductCouponDownload,
getProductCouponSearch,
getProductCouponTotDownload,
} from '../../../actions/couponActions.js';
// import { pushPanel } from '../../../actions/panelActions';
import {
minimizeModalMedia,
restoreModalMedia,
} from '../../../actions/mediaActions';
import { minimizeModalMedia, restoreModalMedia } from '../../../actions/mediaActions';
import { pauseFullscreenVideo } from '../../../actions/playActions';
import { resetShowAllReviews } from '../../../actions/productActions';
import {
clearAllToasts,
removeToast,
showToast,
} from '../../../actions/toastActions';
import { clearAllToasts, removeToast, showToast } from '../../../actions/toastActions';
import CustomImage from '../../../components/CustomImage/CustomImage.jsx';
// ProductInfoSection imports
import TButton from '../../../components/TButton/TButton';
import TPopUp from '../../../components/TPopUp/TPopUp.jsx';
import TVirtualGridList
from '../../../components/TVirtualGridList/TVirtualGridList.jsx';
import TVirtualGridList from '../../../components/TVirtualGridList/TVirtualGridList.jsx';
import useReviews from '../../../hooks/useReviews/useReviews';
import useScrollTo from '../../../hooks/useScrollTo';
import { BUYNOW_CONFIG } from '../../../utils/BuyNowConfig';
@@ -86,19 +63,13 @@ import StarRating from '../components/StarRating';
// ProductContentSection imports
import TScrollerDetail from '../components/TScroller/TScrollerDetail';
import DetailPanelSkeleton from '../DetailPanelSkeleton/DetailPanelSkeleton';
import ProductDescription
from '../ProductContentSection/ProductDescription/ProductDescription';
import ProductDetail
from '../ProductContentSection/ProductDetail/ProductDetail.new';
import {
ProductVideoV2,
} from '../ProductContentSection/ProductVideo/ProductVideo.v2.jsx';
import ProductVideo
from '../ProductContentSection/ProductVideo/ProductVideo.v3';
import ProductDescription from '../ProductContentSection/ProductDescription/ProductDescription';
import ProductDetail from '../ProductContentSection/ProductDetail/ProductDetail.new';
import { ProductVideoV2 } from '../ProductContentSection/ProductVideo/ProductVideo.v2.jsx';
import ProductVideo from '../ProductContentSection/ProductVideo/ProductVideo.v3';
import UserReviews from '../ProductContentSection/UserReviews/UserReviews';
// import ViewAllReviewsButton from '../ProductContentSection/UserReviews/ViewAllReviewsButton';
import YouMayAlsoLike
from '../ProductContentSection/YouMayAlsoLike/YouMayAlsoLike';
import YouMayAlsoLike from '../ProductContentSection/YouMayAlsoLike/YouMayAlsoLike';
import QRCode from '../ProductInfoSection/QRCode/QRCode';
import ProductOverview from '../ProductOverview/ProductOverview';
// CSS imports
@@ -139,7 +110,7 @@ const HorizontalContainer = SpotlightContainerDecorator(
'div'
);
const SpottableComponent = Spottable("div");
const SpottableComponent = Spottable('div');
const getProductData = curry((productType, themeProductInfo, productInfo) =>
pipe(
@@ -192,15 +163,9 @@ export default function ProductAllSection({
// YouMayLike 데이터는 API 응답 시간이 걸리므로 직접 구독
const youmaylikeData = useSelector((state) => state.main.youmaylikeData);
//coupon
const { partnerCoupon } = useSelector(
(state) => state.coupon.productCouponSearchData
);
const { userNumber } = useSelector(
(state) => state.common.appStatus.loginUserData
);
const { popupVisible, activePopup } = useSelector(
(state) => state.common.popup
);
const { partnerCoupon } = useSelector((state) => state.coupon.productCouponSearchData);
const { userNumber } = useSelector((state) => state.common.appStatus.loginUserData);
const { popupVisible, activePopup } = useSelector((state) => state.common.popup);
// ProductVideo 버전 관리 (1: 기존 modal 방식, 2: 내장 방식 , 3: 비디오 생략)
const [productVideoVersion, setProductVideoVersion] = useState(1);
// 비디오 재생 여부 flag (재생 전에는 minimize/restore 로직 비활성화)
@@ -235,10 +200,10 @@ export default function ProductAllSection({
const [selectedCoupon, setSelectedCoupon] = useState();
const [downloadCouponArr, setDownloadCouponArr] = useState([]);
const [couponTypes, setCouponTypes] = useState(null);
const [couponCodes, setCouponCodes] = useState("");
const [couponCodes, setCouponCodes] = useState('');
const [focused, setFocused] = useState(false);
useEffect(()=>{
useEffect(() => {
dispatch(
getProductCouponSearch({
patnrId: selectedPatnrId,
@@ -246,18 +211,18 @@ export default function ProductAllSection({
mbrNo: userNumber,
})
);
},[dispatch])
}, [dispatch]);
const getCouponCode = () => {
const snoArray = [];
for (let i = 0; i < selectedCoupon.length; i++) {
if (selectedCoupon[i].downloadYn === "Y") {
if (selectedCoupon[i].downloadYn === 'Y') {
snoArray.push(selectedCoupon[i].cpnSno);
}
}
setCouponCodes(snoArray.join(", "));
setCouponCodes(snoArray.join(', '));
};
useEffect(() => {
@@ -269,7 +234,7 @@ export default function ProductAllSection({
useEffect(() => {
const newPromotions = [];
if (partnerCoupon && partnerCoupon.length > 0) {
newPromotions.push($L("SPECIAL PROMOTION"));
newPromotions.push($L('SPECIAL PROMOTION'));
}
setPromotions(newPromotions);
}, [partnerCoupon]);
@@ -280,7 +245,7 @@ export default function ProductAllSection({
dispatch(setShowPopup(Config.ACTIVE_POPUP.loginPopup));
return;
}
if (promotion === "SPECIAL PROMOTION") {
if (promotion === 'SPECIAL PROMOTION') {
setSelectedCoupon(partnerCoupon);
}
setCouponTypes(idx);
@@ -291,9 +256,7 @@ export default function ProductAllSection({
const handleCouponTotDownload = useCallback(() => {
if (selectedCoupon && userNumber) {
const couponCodesArray = couponCodes
.split(",")
.map((code) => parseInt(code.trim()));
const couponCodesArray = couponCodes.split(',').map((code) => parseInt(code.trim()));
setDownloadCouponArr((prevArr) => [...prevArr, ...couponCodesArray]);
dispatch(
@@ -302,7 +265,7 @@ export default function ProductAllSection({
cpnSnoAll: couponCodes,
})
);
dispatch(showToast({message : "Your coupon download is complete."}));
dispatch(showToast({ message: 'Your coupon download is complete.' }));
}
}, [dispatch, selectedCoupon, userNumber, couponCodes]);
@@ -311,7 +274,7 @@ export default function ProductAllSection({
dispatch(setHidePopup());
let currentSpot;
if (typeof spotlightId === "string") {
if (typeof spotlightId === 'string') {
currentSpot = spotlightId;
} else {
currentSpot = SpotlightIds.DETAIL_BUYNOW;
@@ -324,7 +287,7 @@ export default function ProductAllSection({
}
},
[dispatch]
)
);
const renderItem = useCallback(
({ index, ...rest }) => {
@@ -343,8 +306,8 @@ export default function ProductAllSection({
duplDwldYn,
} = selectedCoupon[index];
const couponAplyStartDate = cpnAplyStrtDtt.split(" ")[0];
const couponAplyEndDate = cpnAplyEndDtt.split(" ")[0];
const couponAplyStartDate = cpnAplyStrtDtt.split(' ')[0];
const couponAplyEndDate = cpnAplyEndDtt.split(' ')[0];
const onFocus = (index) => {
setSelectedCouponIndex(index);
@@ -359,16 +322,14 @@ export default function ProductAllSection({
if (
downloadCouponArr.length > 0 &&
downloadCouponArr.includes(cpnSno) &&
(downloadYn === "N" || (duplDwldYn === "Y" && downloadYn === "Y"))
(downloadYn === 'N' || (duplDwldYn === 'Y' && downloadYn === 'Y'))
) {
return;
}
setDownloadCouponArr((prevArr) => [...prevArr, cpnSno]);
dispatch(
getProductCouponDownload({ mbrNo: userNumber, cpnSno: cpnSno })
);
dispatch(showToast({message : "Your coupon download is complete."}));
};
dispatch(getProductCouponDownload({ mbrNo: userNumber, cpnSno: cpnSno }));
dispatch(showToast({ message: 'Your coupon download is complete.' }));
};
return (
<SpottableComponent
@@ -377,23 +338,17 @@ export default function ProductAllSection({
{...rest}
onFocus={() => onFocus(index)}
onBlur={onBlur}
onClick={downloadYn === "N" ? null : handleDownloadClick}
onClick={downloadYn === 'N' ? null : handleDownloadClick}
>
<div
className={css.couponItem}
aria-label={
"Purchase over " +
cpnAplyMinPurcAmt +
"up to " +
cpnAplyMaxDcAmt +
" off"
}
aria-label={'Purchase over ' + cpnAplyMinPurcAmt + 'up to ' + cpnAplyMaxDcAmt + ' off'}
>
<div className={css.couponTopContents}>
{shptmDcTpCd === "CPN00401" && (
{shptmDcTpCd === 'CPN00401' && (
<span className={css.couponLate}>{`${currSign}${dcAmt}`}</span>
)}
{shptmDcTpCd === "CPN00402" && (
{shptmDcTpCd === 'CPN00402' && (
<span className={css.couponLate}>{`${cpnDctrt}%`}</span>
)}
<span className={css.title} aria-label={cpnTtl}>
@@ -403,25 +358,21 @@ export default function ProductAllSection({
<div className={css.couponMiddleContents}>
<span>
{$L(
"Purchase over ${cpnAplyMinPurcAmt} (up to ${cpnAplyMaxDcAmt} off)"
)
.replace("{cpnAplyMinPurcAmt}", cpnAplyMinPurcAmt)
.replace("{cpnAplyMaxDcAmt}", cpnAplyMaxDcAmt)}
{$L('Purchase over ${cpnAplyMinPurcAmt} (up to ${cpnAplyMaxDcAmt} off)')
.replace('{cpnAplyMinPurcAmt}', cpnAplyMinPurcAmt)
.replace('{cpnAplyMaxDcAmt}', cpnAplyMaxDcAmt)}
</span>
<span className={classNames(css.content, css.date)}>
{couponAplyStartDate}~{couponAplyEndDate}
</span>
</div>
{downloadYn !== "N" && (
{downloadYn !== 'N' && (
<div
className={classNames(
css.couponBottomButton,
downloadCouponArr.length > 0 &&
downloadCouponArr.includes(cpnSno) &&
css.disable,
duplDwldYn === "N" && downloadYn === "Y" && css.disable,
downloadCouponArr.length > 0 && downloadCouponArr.includes(cpnSno) && css.disable,
duplDwldYn === 'N' && downloadYn === 'Y' && css.disable,
!downloadCouponArr.includes(cpnSno) &&
index === selectedCouponIndex &&
focused &&
@@ -429,13 +380,9 @@ export default function ProductAllSection({
)}
aria-label="Download Button"
>
{downloadCouponArr.length > 0 &&
downloadCouponArr.includes(cpnSno)
?
$L("DOWNLOAD COMPLETED")
:
$L("DOWNLOAD")
}
{downloadCouponArr.length > 0 && downloadCouponArr.includes(cpnSno)
? $L('DOWNLOAD COMPLETED')
: $L('DOWNLOAD')}
</div>
)}
</div>
@@ -666,13 +613,13 @@ export default function ProductAllSection({
//닫히도록
const handleCloseToast = useCallback(() => {
// 팝업이 열려있으면 닫지 않음
if (popupVisible) {
return; // 팝업이 활성이면 무시
}
dispatch(clearAllToasts());
setOpenToast(false);
}, [dispatch, popupVisible]);
// 팝업이 열려있으면 닫지 않음
if (popupVisible) {
return; // 팝업이 활성이면 무시
}
dispatch(clearAllToasts());
setOpenToast(false);
}, [dispatch, popupVisible]);
// 스크롤 컨테이너의 클릭 이벤트 추적용 로깅
const handleScrollContainerClick = useCallback((e) => {
@@ -901,18 +848,20 @@ export default function ProductAllSection({
);
// BUY NOW, ADD TO CART 버튼에서 arrow up 시: 항상 헤더 뒤로가기 버튼으로
const handleSpotlightUpFromBuyButtons = useCallback((e) => {
e.stopPropagation();
if(promotions && promotions.length > 0){
Spotlight.focus('detail-coupon-button');
} else {
// 쿠폰 버튼이 없을 때 뒤로가기 버튼으로 포커스 이동 (특정 Chrome 버전에서의 포커스 문제 해결)
setTimeout(() => {
Spotlight.focus('spotlightId_backBtn');
}, 0);
}
}, [promotions]);
const handleSpotlightUpFromBuyButtons = useCallback(
(e) => {
e.stopPropagation();
if (promotions && promotions.length > 0) {
Spotlight.focus('detail-coupon-button');
} else {
// 쿠폰 버튼이 없을 때 뒤로가기 버튼으로 포커스 이동 (특정 Chrome 버전에서의 포커스 문제 해결)
setTimeout(() => {
Spotlight.focus('spotlightId_backBtn');
}, 0);
}
},
[promotions]
);
const handleSpotlightUpFromCouponButtons = useCallback((e) => {
e.stopPropagation();
@@ -923,6 +872,28 @@ export default function ProductAllSection({
e.stopPropagation();
}, []);
// SHOP BY MOBILE에서 아래로 이동 시 제품 정보 영역으로 포커스 이동
const handleSpotlightDownFromShopByMobile = useCallback((e) => {
e.stopPropagation();
e.preventDefault();
if (!Spotlight.focus('product-details-button')) {
Spotlight.focus('user-reviews-button');
}
// 포커스가 비거나 다른 곳으로 튀는 경우를 방어
setTimeout(() => {
const current = Spotlight.getCurrent();
const currentId = current?.dataset?.spotlightId;
if (
!current ||
(currentId !== 'product-details-button' && currentId !== 'user-reviews-button')
) {
Spotlight.focus('product-details-button') || Spotlight.focus('user-reviews-button');
}
}, 0);
}, []);
const onFavoriteFlagChanged = useCallback(
(newFavoriteFlag) => setFavoriteOverride(newFavoriteFlag),
[]
@@ -1216,8 +1187,6 @@ export default function ProductAllSection({
};
}, []);
// 초기 로딩 중에는 Skeleton 표시
if (isInitialLoading) {
return (
@@ -1226,9 +1195,6 @@ export default function ProductAllSection({
</div>
);
}
return (
<HorizontalContainer className={css.detailArea} onClick={handleCloseToast}>
@@ -1276,31 +1242,32 @@ export default function ProductAllSection({
</div>
</ProductOverview>
{userNumber && promotions.map((promotion, idx) => {
return(
<div className={css.couponContainer} key={idx}>
<div className={css.couponTitleText}>
<div className={css.firstTitle}>SPECIAL PROMOTION</div>
<div className={css.secondTitle}>Coupon only applicable to this product!</div>
</div>
<TButton
spotlightId="detail-coupon-button"
className={css.couponButton}
onClick={() => {
handleCouponClick(idx, promotion);
}}
onSpotlightUp={handleSpotlightUpFromCouponButtons}
onSpotlightDown={handleSpotlightDown}
size="detail_very_small"
>
<div className={css.couponText}>
COUPON
{userNumber &&
promotions.map((promotion, idx) => {
return (
<div className={css.couponContainer} key={idx}>
<div className={css.couponTitleText}>
<div className={css.firstTitle}>SPECIAL PROMOTION</div>
<div className={css.secondTitle}>
Coupon only applicable to this product!
</div>
</div>
<img className={css.buttonImg} src={couponImg} />
</TButton>
</div>
)
})}
<TButton
spotlightId="detail-coupon-button"
className={css.couponButton}
onClick={() => {
handleCouponClick(idx, promotion);
}}
onSpotlightUp={handleSpotlightUpFromCouponButtons}
onSpotlightDown={handleSpotlightDown}
size="detail_very_small"
>
<div className={css.couponText}>COUPON</div>
<img className={css.buttonImg} src={couponImg} />
</TButton>
</div>
);
})}
{isBillingProductVisible && (
<div className={css.buyNowCartContainer}>
<TButton
@@ -1339,7 +1306,8 @@ export default function ProductAllSection({
className={css.shopByMobileButton}
onClick={handleShopByMobileOpen}
onSpotlightUp={handleSpotlightUpToBackButton}
onSpotlightDown={handleSpotlightDown}
onSpotlightDown={handleSpotlightDownFromShopByMobile}
data-spotlight-next-down="product-details-button"
>
<div className={css.shopByMobileText}>{$L('SHOP BY MOBILE')}</div>
</TButton>
@@ -1608,8 +1576,8 @@ export default function ProductAllSection({
onClick={handleCouponTotDownload}
onClose={onClose}
hasButton
button1Text={$L("DOWNLOAD ALL")}
button2Text={$L("CLOSE")}
button1Text={$L('DOWNLOAD ALL')}
button2Text={$L('CLOSE')}
>
<Container className={css.itemWrap}>
{selectedCoupon && selectedCoupon.length > 0 && (
@@ -1625,9 +1593,7 @@ export default function ProductAllSection({
/>
)}
</Container>
<div
className={css.couponRemain}
>{`1/${selectedCoupon?.length}`}</div>
<div className={css.couponRemain}>{`1/${selectedCoupon?.length}`}</div>
</TPopUp>
)}
</HorizontalContainer>

View File

@@ -66,16 +66,39 @@ export default function FavoriteBtn({
onFavoriteFlagChanged(favoriteFlag === 'Y' ? 'N' : 'Y');
}, [dispatch, favoriteFlag, onFavoriteFlagChanged]);
const handleFavoriteKeyDown = useCallback((e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleFavoriteClick();
}
}, [handleFavoriteClick]);
const handleFavoriteKeyDown = useCallback(
(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleFavoriteClick();
}
},
[handleFavoriteClick]
);
const handleSpotlightDown = useCallback((e) => {
e.stopPropagation();
}, []);
const handleSpotlightDown = useCallback(
(e) => {
e.stopPropagation();
if (kind === 'item_detail') {
e.preventDefault();
if (!Spotlight.focus('product-details-button')) {
Spotlight.focus('user-reviews-button');
}
// 포커스가 비거나 다른 곳으로 튀는 경우를 방어
setTimeout(() => {
const current = Spotlight.getCurrent();
const currentId = current?.dataset?.spotlightId;
if (
!current ||
(currentId !== 'product-details-button' && currentId !== 'user-reviews-button')
) {
Spotlight.focus('product-details-button') || Spotlight.focus('user-reviews-button');
}
}, 0);
}
},
[kind]
);
return (
<SpottableDiv
@@ -87,6 +110,7 @@ export default function FavoriteBtn({
onClick={handleFavoriteClick}
onKeyDown={handleFavoriteKeyDown}
onSpotlightDown={handleSpotlightDown}
data-spotlight-next-down={kind === 'item_detail' ? 'product-details-button' : undefined}
>
<div
className={classNames(