diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx index 68054800..cd037b92 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx +++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx @@ -34,7 +34,6 @@ import TPopUp from '../../../components/TPopUp/TPopUp.jsx'; import TVirtualGridList from '../../../components/TVirtualGridList/TVirtualGridList.jsx'; import useReviews from '../../../hooks/useReviews/useReviews'; import useScrollTo from '../../../hooks/useScrollTo'; -import useDetailFocus from '../../../hooks/useDetailFocus'; import { BUYNOW_CONFIG } from '../../../utils/BuyNowConfig'; import { panel_names } from '../../../utils/Config'; import * as Config from '../../../utils/Config.js'; @@ -134,6 +133,16 @@ const BuyNowContainer = SpotlightContainerDecorator( 'div' ); +const CouponContainer = SpotlightContainerDecorator( + { + spotlightDirection: 'vertical', + enterTo: 'last-focused', + restrict: 'self-only', + defaultElement: 'detail-coupon-button-0', + }, + 'div' +); + const ButtonStackContainer = SpotlightContainerDecorator( { spotlightDirection: 'vertical', @@ -189,9 +198,6 @@ export default function ProductAllSection({ }) { const dispatch = useDispatch(); - // 포커스 이동 보정 Hook (0.25초 타이머) - const { enqueueFocus } = useDetailFocus(500); - // Redux 상태 const webOSVersion = useSelector((state) => state.common.appStatus.webOSVersion); 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( () => SpotlightIds?.DETAIL_SHOPBYMOBILE || 'detail_shop_by_mobile', [] ); - const stackDefaultElement = useMemo( - () => shopByMobileId || firstStackElementId, - [shopByMobileId, firstStackElementId] - ); - - // 버튼 스택(위→아래) 구성: 실제 렌더링 순서에 맞춰 행(row) 단위로 설정 - const { focusDownMap, focusUpMap, focusOrder, focusRows } = useMemo(() => { - const rows = []; - + const stackOrder = useMemo(() => { + const ids = []; if (promotions && promotions.length > 0) { - // 쿠폰이 여러 개일 수 있으므로 각각 고유 ID로 추가 - const couponButtonIds = promotions.map((_, idx) => `detail-coupon-button-${idx}`); - rows.push(couponButtonIds); + promotions.forEach((_, idx) => ids.push(`detail-coupon-button-${idx}`)); } - if (isBillingProductVisible) { - rows.push(['detail-buy-now-button', 'detail-add-to-cart-button']); + ids.push('detail-buy-now-button', 'detail-add-to-cart-button'); } - - const shopRow = [shopByMobileId]; + ids.push(shopByMobileId); 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) { - rows.push(['user-reviews-button']); - } - - // 아래 방향: 각 행의 모든 요소가 다음 행의 첫 번째 요소를 바라보도록 매핑 - 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 firstCouponId = useMemo( + () => (promotions && promotions.length > 0 ? 'detail-coupon-button-0' : null), + [promotions] ); const onFavoriteFlagChanged = useCallback( @@ -1171,7 +933,7 @@ export default function ProductAllSection({ }, [scrollToSection, dispatch]); // 헤더 Back 아이콘에서 아래로 내려올 때 첫 번째 버튼을 바라보도록 설정 useEffect(() => { - const firstId = focusRows[0]?.[0]; + const firstId = stackOrder[0]; if (firstId && Spotlight && Spotlight.set) { Spotlight.set('spotlightId_backBtn', { next: { @@ -1179,8 +941,7 @@ export default function ProductAllSection({ }, }); } - }, [focusRows]); - const firstStackElementId = focusRows[0]?.[0]; + }, [stackOrder]); const scrollPositionRef = useRef(0); const prevScrollPositionRef = useRef(0); // 이전 스크롤 위치 추적 const prevScrollTopRef = useRef(0); // HomePanel 스타일 스크롤 위치 추적 @@ -1510,35 +1271,45 @@ export default function ProductAllSection({ className={css.buttonStackContainer} spotlightId="detail-button-stack" defaultElement={stackDefaultElement} + leaveFor={{ + up: 'spotlightId_backBtn', + }} > - {userNumber && - promotions.map((promotion, idx) => { - const couponButtonId = `detail-coupon-button-${idx}`; - return ( -
-
-
SPECIAL PROMOTION
-
- Coupon only applicable to this product! + {userNumber && promotions.length > 0 && ( + + {promotions.map((promotion, idx) => { + const couponButtonId = `detail-coupon-button-${idx}`; + return ( +
+
+
SPECIAL PROMOTION
+
+ Coupon only applicable to this product! +
+ { + handleCouponClick(idx, promotion); + }} + size="detail_very_small" + > +
COUPON
+ +
- { - handleCouponClick(idx, promotion); - }} - onSpotlightUp={handleSpotlightUpFromCouponButtons} - onSpotlightDown={(e) => handleSpotlightDownFromCoupon(e, couponButtonId)} - data-spotlight-next-down={focusDownMap[couponButtonId]} - size="detail_very_small" - > -
COUPON
- -
-
- ); - })} + ); + })} + + )} {isBillingProductVisible && (
{$L('BUY NOW')}
@@ -1561,9 +1329,6 @@ export default function ProductAllSection({ className={css.addToCartButton} // onClick={handleAddToCartClick} onClick={handleBuyNowClick} - onSpotlightUp={handleSpotlightUpFromBuyButtons} - onSpotlightDown={handleSpotlightDownFromAddToCart} - data-spotlight-next-down={focusDownMap['detail-add-to-cart-button']} type="detail_small" >
{$L('ADD TO CART')}
@@ -1584,10 +1349,6 @@ export default function ProductAllSection({ spotlightId={SpotlightIds.DETAIL_SHOPBYMOBILE} className={css.shopByMobileButton} onClick={handleShopByMobileOpen} - onSpotlightUp={handleSpotlightUpToBackButton} - onSpotlightDown={handleSpotlightDownFromShopByMobile} - data-spotlight-next-down={focusDownMap[shopByMobileId]} - data-spotlight-next-up={shopByMobileUpTarget} >
{$L('SHOP BY MOBILE')}
@@ -1600,7 +1361,6 @@ export default function ProductAllSection({ favoriteFlag={favoriteFlag} onFavoriteFlagChanged={onFavoriteFlagChanged} kind={'item_detail'} - nextDownId={focusDownMap['favoriteBtn']} />
)} diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.module.less index 5accc675..110debd2 100644 --- a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.module.less +++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.module.less @@ -618,6 +618,19 @@ } } +.couponStackContainer { + width: 100%; + display: flex; + flex-direction: column; + + > * { + margin-bottom: 5px; + &:last-child { + margin-bottom: 0; + } + } +} + .favoriteBtnWrapper { width: 60px; height: 60px;