[251122] fix: DetailPaneel->ProductAllSection Focus-7

🕐 커밋 시간: 2025. 11. 22. 17:57:58

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

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

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

Performance: 코드 최적화로 성능 개선 기대
This commit is contained in:
2025-11-22 17:57:58 +09:00
parent 11bfdc0825
commit 9878c39512
2 changed files with 77 additions and 304 deletions

View File

@@ -34,7 +34,6 @@ 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 useReviews from '../../../hooks/useReviews/useReviews';
import useScrollTo from '../../../hooks/useScrollTo'; import useScrollTo from '../../../hooks/useScrollTo';
import useDetailFocus from '../../../hooks/useDetailFocus';
import { BUYNOW_CONFIG } from '../../../utils/BuyNowConfig'; import { BUYNOW_CONFIG } from '../../../utils/BuyNowConfig';
import { panel_names } from '../../../utils/Config'; import { panel_names } from '../../../utils/Config';
import * as Config from '../../../utils/Config.js'; import * as Config from '../../../utils/Config.js';
@@ -134,6 +133,16 @@ const BuyNowContainer = SpotlightContainerDecorator(
'div' 'div'
); );
const CouponContainer = SpotlightContainerDecorator(
{
spotlightDirection: 'vertical',
enterTo: 'last-focused',
restrict: 'self-only',
defaultElement: 'detail-coupon-button-0',
},
'div'
);
const ButtonStackContainer = SpotlightContainerDecorator( const ButtonStackContainer = SpotlightContainerDecorator(
{ {
spotlightDirection: 'vertical', spotlightDirection: 'vertical',
@@ -189,9 +198,6 @@ export default function ProductAllSection({
}) { }) {
const dispatch = useDispatch(); const dispatch = useDispatch();
// 포커스 이동 보정 Hook (0.25초 타이머)
const { enqueueFocus } = useDetailFocus(500);
// Redux 상태 // Redux 상태
const webOSVersion = useSelector((state) => state.common.appStatus.webOSVersion); const webOSVersion = useSelector((state) => state.common.appStatus.webOSVersion);
const groupInfos = useSelector((state) => state.product.groupInfo); const groupInfos = useSelector((state) => state.product.groupInfo);
@@ -869,279 +875,35 @@ export default function ProductAllSection({
[] []
); );
// SHOP BY MOBILE 버튼에서 arrow up 시
// focusUpMap을 사용해서 위쪽 버튼으로 이동
// focusUpMap이 없으면 (첫 행) BackBtn으로 이동
const handleSpotlightUpToBackButton = useCallback(
(e) => {
e.stopPropagation();
console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP 감지`);
// focusUpMap에서 ShopByMobile의 위쪽 버튼을 찾음
const targetId = focusUpMap[shopByMobileId];
if (targetId) {
console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP 타깃: ${targetId}`);
if (targetId === 'detail-buy-now-button') {
console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP: BuyNow 버튼 있음`);
} else if (targetId === 'detail-add-to-cart-button') {
console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP: AddToCart 버튼 있음`);
} else if (targetId === 'detail-coupon-button') {
console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP: Coupon 버튼 있음`);
}
// 즉시 포커스 시도
if (!Spotlight.focus(targetId)) {
enqueueFocus(targetId);
}
} else {
// focusUpMap에 없으면 (첫 행이거나 위쪽이 없음) BackBtn으로
console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP: 위쪽 버튼 없음, BackBtn으로 이동`);
if (!Spotlight.focus('spotlightId_backBtn')) {
enqueueFocus('spotlightId_backBtn');
}
}
},
[focusUpMap, shopByMobileId, enqueueFocus]
);
// BUY NOW, ADD TO CART 버튼에서 arrow up 시
// Coupon 버튼이 있으면 Coupon으로, 없으면 Back으로 이동
// 항상 Hook을 통해 포커스를 이동하도록 변경
const handleSpotlightUpFromBuyButtons = useCallback(
(e) => {
e.stopPropagation();
let targetId;
if (promotions && promotions.length > 0) {
// 쿠폰이 여러 개면 첫 번째 쿠폰 버튼으로 포커스
targetId = `detail-coupon-button-0`;
console.log(`💳 [FocusDetail] BuyNow/AddToCart - Arrow UP 감지 (Coupon 있음)`);
console.log(`💳 [FocusDetail] BuyNow/AddToCart - Arrow UP 타깃 결정: ${targetId}`);
} else {
targetId = 'spotlightId_backBtn';
console.log(`💳 [FocusDetail] BuyNow/AddToCart - Arrow UP 감지 (Coupon 없음)`);
console.log(`💳 [FocusDetail] BuyNow/AddToCart - Arrow UP 타깃 결정: Back Btn`);
}
// 즉시 포커스 시도, 실패 시 보정 큐
if (!Spotlight.focus(targetId)) {
enqueueFocus(targetId);
}
},
[promotions, enqueueFocus]
);
// Coupon 버튼에서 arrow up 시: Container의 leaveFor 설정으로 처리
// stopPropagation을 하지 않아서 Container가 up 방향으로 포커스를 이동하도록 함
const handleSpotlightUpFromCouponButtons = useCallback(
(e) => {
e.preventDefault();
console.log(`🎫 [FocusDetail] Coupon - Arrow UP 감지`);
console.log(`🎫 [FocusDetail] Coupon - Arrow UP 처리: Container의 leaveFor(up) 설정 사용`);
if (!Spotlight.focus('spotlightId_backBtn')) {
enqueueFocus('spotlightId_backBtn');
}
},
[enqueueFocus]
);
// SHOP BY MOBILE ↑ 타깃 계산
const shopByMobileUpTarget = useMemo(() => {
if (isBillingProductVisible) return 'detail-buy-now-button';
if (userNumber && promotions && promotions.length > 0) return 'detail-coupon-button';
return 'spotlightId_backBtn';
}, [isBillingProductVisible, promotions, userNumber]);
const shopByMobileId = useMemo( const shopByMobileId = useMemo(
() => SpotlightIds?.DETAIL_SHOPBYMOBILE || 'detail_shop_by_mobile', () => SpotlightIds?.DETAIL_SHOPBYMOBILE || 'detail_shop_by_mobile',
[] []
); );
const stackDefaultElement = useMemo( const stackOrder = useMemo(() => {
() => shopByMobileId || firstStackElementId, const ids = [];
[shopByMobileId, firstStackElementId]
);
// 버튼 스택(위→아래) 구성: 실제 렌더링 순서에 맞춰 행(row) 단위로 설정
const { focusDownMap, focusUpMap, focusOrder, focusRows } = useMemo(() => {
const rows = [];
if (promotions && promotions.length > 0) { if (promotions && promotions.length > 0) {
// 쿠폰이 여러 개일 수 있으므로 각각 고유 ID로 추가 promotions.forEach((_, idx) => ids.push(`detail-coupon-button-${idx}`));
const couponButtonIds = promotions.map((_, idx) => `detail-coupon-button-${idx}`);
rows.push(couponButtonIds);
} }
if (isBillingProductVisible) { if (isBillingProductVisible) {
rows.push(['detail-buy-now-button', 'detail-add-to-cart-button']); ids.push('detail-buy-now-button', 'detail-add-to-cart-button');
} }
ids.push(shopByMobileId);
const shopRow = [shopByMobileId];
if (panelInfo) { if (panelInfo) {
shopRow.push('favoriteBtn'); ids.push('favoriteBtn');
} }
rows.push(shopRow); return ids;
}, [promotions, isBillingProductVisible, shopByMobileId, panelInfo]);
rows.push(['product-details-button']); const stackDefaultElement = useMemo(() => {
if (promotions && promotions.length > 0) return 'detail-coupon-button-0';
if (isBillingProductVisible) return 'detail-buy-now-button';
return shopByMobileId || stackOrder[0];
}, [promotions, isBillingProductVisible, shopByMobileId, stackOrder]);
if (isReviewDataComplete) { const firstCouponId = useMemo(
rows.push(['user-reviews-button']); () => (promotions && promotions.length > 0 ? 'detail-coupon-button-0' : null),
} [promotions]
// 아래 방향: 각 행의 모든 요소가 다음 행의 첫 번째 요소를 바라보도록 매핑
const downMap = rows.reduce((acc, row, idx) => {
const nextRowFirst = rows[idx + 1]?.[0];
row.forEach((id) => {
acc[id] = nextRowFirst;
});
return acc;
}, {});
// 위 방향: 각 행의 모든 요소가 이전 행의 마지막 요소를 바라보도록 매핑
const upMap = rows.reduce((acc, row, idx) => {
if (idx > 0) {
// 이전 행이 있으면 그 행의 마지막 요소로 매핑
const prevRowLast = rows[idx - 1][rows[idx - 1].length - 1];
row.forEach((id) => {
acc[id] = prevRowLast;
});
}
return acc;
}, {});
// order는 행을 평탄화한 순서
const order = rows.flat();
return { focusDownMap: downMap, focusUpMap: upMap, focusOrder: order, focusRows: rows };
}, [isBillingProductVisible, panelInfo, isReviewDataComplete, promotions, shopByMobileId]);
// 공통 ↓ 이동 핸들러
const buildSpotlightDownHandler = useCallback(
(currentId) => (e) => {
e.stopPropagation();
e.preventDefault();
const nextId = focusDownMap[currentId];
const moved = nextId ? Spotlight.focus(nextId) : false;
// 이동 실패 시, 스택 내 다른 항목으로라도 보정
if (!moved) {
const fallback = focusOrder.find((id) => id !== currentId);
if (fallback) {
// Hook을 통한 포커스 이동 보정 (0.5초 타이머로 재시도)
enqueueFocus(fallback);
}
}
},
[focusDownMap, focusOrder, enqueueFocus]
);
const handleSpotlightDown = useCallback((e) => {
e.stopPropagation();
}, []);
// COUPON에서 아래로 이동 시
// 항상 Hook을 통해 포커스를 이동하도록 변경
const handleSpotlightDownFromCoupon = useCallback(
(e, currentCouponId) => {
e.stopPropagation();
e.preventDefault();
console.log(`🎫 [FocusDetail] Coupon - Arrow DOWN 감지 (ID: ${currentCouponId})`);
const nextId = focusDownMap[currentCouponId];
console.log(`🎫 [FocusDetail] Coupon - Arrow DOWN 다음 타깃: ${nextId}`);
if (nextId) {
// 항상 Hook을 통해 포커스를 이동
enqueueFocus(nextId);
} else {
// 다음 행이 없으면 focusOrder에서 현재 쿠폰 버튼이 아닌 다른 버튼을 찾음
const fallback = focusOrder.find((id) => !id.startsWith('detail-coupon-button'));
console.log(`🎫 [FocusDetail] Coupon - Arrow DOWN focusDownMap에 없음, 폴백: ${fallback}`);
if (fallback) {
enqueueFocus(fallback);
}
}
},
[focusDownMap, focusOrder, enqueueFocus]
);
// SHOP BY MOBILE에서 아래로 이동 시
// 항상 Hook을 통해 포커스를 이동하도록 변경
const handleSpotlightDownFromShopByMobile = useCallback(
(e) => {
e.stopPropagation();
e.preventDefault();
console.log(`🛵 [FocusDetail] ShopByMobile - Arrow DOWN 감지`);
const nextId = focusDownMap[shopByMobileId];
console.log(`🛵 [FocusDetail] ShopByMobile - Arrow DOWN 다음 타깃: ${nextId}`);
if (nextId) {
// 항상 Hook을 통해 포커스를 이동
enqueueFocus(nextId);
} else {
const fallback = focusOrder.find((id) => id !== shopByMobileId);
console.log(
`🛵 [FocusDetail] ShopByMobile - Arrow DOWN focusDownMap에 없음, 폴백: ${fallback}`
);
if (fallback) {
enqueueFocus(fallback);
}
}
},
[shopByMobileId, focusDownMap, focusOrder, enqueueFocus]
);
// BUY NOW에서 아래로 이동 시
// 항상 Hook을 통해 포커스를 이동하도록 변경
const handleSpotlightDownFromBuyNow = useCallback(
(e) => {
e.stopPropagation();
e.preventDefault();
console.log(`💳 [FocusDetail] BuyNow - Arrow DOWN 감지`);
const nextId = focusDownMap['detail-buy-now-button'];
console.log(`💳 [FocusDetail] BuyNow - Arrow DOWN 다음 타깃: ${nextId}`);
if (nextId) {
// 항상 Hook을 통해 포커스를 이동
enqueueFocus(nextId);
} else {
const fallback = focusOrder.find((id) => id !== 'detail-buy-now-button');
console.log(`💳 [FocusDetail] BuyNow - Arrow DOWN focusDownMap에 없음, 폴백: ${fallback}`);
if (fallback) {
enqueueFocus(fallback);
}
}
},
[focusDownMap, focusOrder, enqueueFocus]
);
// ADD TO CART에서 아래로 이동 시
// 항상 Hook을 통해 포커스를 이동하도록 변경
const handleSpotlightDownFromAddToCart = useCallback(
(e) => {
e.stopPropagation();
e.preventDefault();
console.log(`💳 [FocusDetail] AddToCart - Arrow DOWN 감지`);
const nextId = focusDownMap['detail-add-to-cart-button'];
console.log(`💳 [FocusDetail] AddToCart - Arrow DOWN 다음 타깃: ${nextId}`);
if (nextId) {
// 항상 Hook을 통해 포커스를 이동
enqueueFocus(nextId);
} else {
const fallback = focusOrder.find((id) => id !== 'detail-add-to-cart-button');
console.log(
`💳 [FocusDetail] AddToCart - Arrow DOWN focusDownMap에 없음, 폴백: ${fallback}`
);
if (fallback) {
enqueueFocus(fallback);
}
}
},
[focusDownMap, focusOrder, enqueueFocus]
); );
const onFavoriteFlagChanged = useCallback( const onFavoriteFlagChanged = useCallback(
@@ -1171,7 +933,7 @@ export default function ProductAllSection({
}, [scrollToSection, dispatch]); }, [scrollToSection, dispatch]);
// 헤더 Back 아이콘에서 아래로 내려올 때 첫 번째 버튼을 바라보도록 설정 // 헤더 Back 아이콘에서 아래로 내려올 때 첫 번째 버튼을 바라보도록 설정
useEffect(() => { useEffect(() => {
const firstId = focusRows[0]?.[0]; const firstId = stackOrder[0];
if (firstId && Spotlight && Spotlight.set) { if (firstId && Spotlight && Spotlight.set) {
Spotlight.set('spotlightId_backBtn', { Spotlight.set('spotlightId_backBtn', {
next: { next: {
@@ -1179,8 +941,7 @@ export default function ProductAllSection({
}, },
}); });
} }
}, [focusRows]); }, [stackOrder]);
const firstStackElementId = focusRows[0]?.[0];
const scrollPositionRef = useRef(0); const scrollPositionRef = useRef(0);
const prevScrollPositionRef = useRef(0); // 이전 스크롤 위치 추적 const prevScrollPositionRef = useRef(0); // 이전 스크롤 위치 추적
const prevScrollTopRef = useRef(0); // HomePanel 스타일 스크롤 위치 추적 const prevScrollTopRef = useRef(0); // HomePanel 스타일 스크롤 위치 추적
@@ -1510,35 +1271,45 @@ export default function ProductAllSection({
className={css.buttonStackContainer} className={css.buttonStackContainer}
spotlightId="detail-button-stack" spotlightId="detail-button-stack"
defaultElement={stackDefaultElement} defaultElement={stackDefaultElement}
leaveFor={{
up: 'spotlightId_backBtn',
}}
> >
{userNumber && {userNumber && promotions.length > 0 && (
promotions.map((promotion, idx) => { <CouponContainer
const couponButtonId = `detail-coupon-button-${idx}`; className={css.couponStackContainer}
return ( spotlightId="detail-coupon-container"
<div className={css.couponContainer} key={idx}> defaultElement={firstCouponId}
<div className={css.couponTitleText}> leaveFor={{
<div className={css.firstTitle}>SPECIAL PROMOTION</div> up: 'spotlightId_backBtn',
<div className={css.secondTitle}> }}
Coupon only applicable to this product! >
{promotions.map((promotion, idx) => {
const couponButtonId = `detail-coupon-button-${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> </div>
<TButton
spotlightId={couponButtonId}
className={css.couponButton}
onClick={() => {
handleCouponClick(idx, promotion);
}}
size="detail_very_small"
>
<div className={css.couponText}>COUPON</div>
<img className={css.buttonImg} src={couponImg} />
</TButton>
</div> </div>
<TButton );
spotlightId={couponButtonId} })}
className={css.couponButton} </CouponContainer>
onClick={() => { )}
handleCouponClick(idx, promotion);
}}
onSpotlightUp={handleSpotlightUpFromCouponButtons}
onSpotlightDown={(e) => handleSpotlightDownFromCoupon(e, couponButtonId)}
data-spotlight-next-down={focusDownMap[couponButtonId]}
size="detail_very_small"
>
<div className={css.couponText}>COUPON</div>
<img className={css.buttonImg} src={couponImg} />
</TButton>
</div>
);
})}
{isBillingProductVisible && ( {isBillingProductVisible && (
<BuyNowContainer <BuyNowContainer
className={css.buyNowCartContainer} className={css.buyNowCartContainer}
@@ -1549,9 +1320,6 @@ export default function ProductAllSection({
spotlightId="detail-buy-now-button" spotlightId="detail-buy-now-button"
className={css.buyNowButton} className={css.buyNowButton}
onClick={handleBuyNowClick} onClick={handleBuyNowClick}
onSpotlightUp={handleSpotlightUpFromBuyButtons}
onSpotlightDown={handleSpotlightDownFromBuyNow}
data-spotlight-next-down={focusDownMap['detail-buy-now-button']}
type="detail_small" type="detail_small"
> >
<div className={css.buyNowText}>{$L('BUY NOW')}</div> <div className={css.buyNowText}>{$L('BUY NOW')}</div>
@@ -1561,9 +1329,6 @@ export default function ProductAllSection({
className={css.addToCartButton} className={css.addToCartButton}
// onClick={handleAddToCartClick} // onClick={handleAddToCartClick}
onClick={handleBuyNowClick} onClick={handleBuyNowClick}
onSpotlightUp={handleSpotlightUpFromBuyButtons}
onSpotlightDown={handleSpotlightDownFromAddToCart}
data-spotlight-next-down={focusDownMap['detail-add-to-cart-button']}
type="detail_small" type="detail_small"
> >
<div className={css.addToCartText}>{$L('ADD TO CART')}</div> <div className={css.addToCartText}>{$L('ADD TO CART')}</div>
@@ -1584,10 +1349,6 @@ export default function ProductAllSection({
spotlightId={SpotlightIds.DETAIL_SHOPBYMOBILE} spotlightId={SpotlightIds.DETAIL_SHOPBYMOBILE}
className={css.shopByMobileButton} className={css.shopByMobileButton}
onClick={handleShopByMobileOpen} onClick={handleShopByMobileOpen}
onSpotlightUp={handleSpotlightUpToBackButton}
onSpotlightDown={handleSpotlightDownFromShopByMobile}
data-spotlight-next-down={focusDownMap[shopByMobileId]}
data-spotlight-next-up={shopByMobileUpTarget}
> >
<div className={css.shopByMobileText}>{$L('SHOP BY MOBILE')}</div> <div className={css.shopByMobileText}>{$L('SHOP BY MOBILE')}</div>
</TButton> </TButton>
@@ -1600,7 +1361,6 @@ export default function ProductAllSection({
favoriteFlag={favoriteFlag} favoriteFlag={favoriteFlag}
onFavoriteFlagChanged={onFavoriteFlagChanged} onFavoriteFlagChanged={onFavoriteFlagChanged}
kind={'item_detail'} kind={'item_detail'}
nextDownId={focusDownMap['favoriteBtn']}
/> />
</div> </div>
)} )}

View File

@@ -618,6 +618,19 @@
} }
} }
.couponStackContainer {
width: 100%;
display: flex;
flex-direction: column;
> * {
margin-bottom: 5px;
&:last-child {
margin-bottom: 0;
}
}
}
.favoriteBtnWrapper { .favoriteBtnWrapper {
width: 60px; width: 60px;
height: 60px; height: 60px;