[250925] feat: components - App.js, store.js, ProductAllSection.jsx 등 16개 파...

🕐 커밋 시간: 2025. 09. 25. 13:03:22

📊 변경 통계:
  • 총 파일: 16개
  • 추가: +116줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/actions/toastActions.js
  + com.twin.app.shoptime/src/components/TToast/TToastEnhanced.jsx
  + com.twin.app.shoptime/src/components/TToast/TToastEnhanced.module.less
  + com.twin.app.shoptime/src/components/TToast/ToastContainer.jsx
  + com.twin.app.shoptime/src/components/TToast/ToastContainer.module.less
  + com.twin.app.shoptime/src/reducers/toastReducer.js
  + com.twin.app.shoptime/src/views/DetailPanel/components/BuyOption.jsx
  + com.twin.app.shoptime/src/views/DetailPanel/components/BuyOption.module.less
  + com.twin.app.shoptime/src/views/DetailPanel/components/BuyOptionPriceBlock.jsx
  + com.twin.app.shoptime/src/views/DetailPanel/components/BuyOptionPriceBlock.module.less
  + com.twin.app.shoptime/src/views/DetailPanel/components/CustomDropDown.jsx
  + com.twin.app.shoptime/src/views/DetailPanel/components/CustomDropDown.module.less

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

🔧 주요 변경 내용:
  • 핵심 비즈니스 로직 개선
  • UI 컴포넌트 아키텍처 개선
  • 중간 규모 기능 개선
  • 모듈 구조 개선
This commit is contained in:
djaco
2025-09-25 13:03:29 +09:00
parent 41c4d45061
commit 4296d89bc6
16 changed files with 1664 additions and 0 deletions

View File

@@ -46,6 +46,7 @@ import {
} from "../actions/myPageActions";
import { pushPanel } from "../actions/panelActions";
import NotSupportedVersion from "../components/NotSupportedVersion/NotSupportedVersion";
import ToastContainer from "../components/TToast/ToastContainer";
import usePrevious from "../hooks/usePrevious";
import { lunaTest } from "../lunaSend/lunaTest";
import { store } from "../store/store";
@@ -522,6 +523,7 @@ function AppBase(props) {
}
/>
)}
<ToastContainer />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,86 @@
// Toast Actions
import { curry } from '../utils/fp';
export const TOAST_ACTIONS = {
ADD_TOAST: 'ADD_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
CLEAR_ALL_TOASTS: 'CLEAR_ALL_TOASTS'
};
// 기본 Toast 추가 액션
const createId = () => Date.now() + Math.random();
export const showToast = curry(({
message,
type = 'success',
duration = 4000,
position = 'bottom-center',
id,
}) => ({
type: TOAST_ACTIONS.ADD_TOAST,
payload: {
id: id || createId(), // 고유 ID 생성 (옵션으로 주입 가능)
text: message,
type,
duration,
position,
timestamp: Date.now(),
}
}));
// Toast 제거 액션
export const removeToast = curry((id) => ({
type: TOAST_ACTIONS.REMOVE_TOAST,
payload: id
}));
// 모든 Toast 제거 액션
export const clearAllToasts = () => ({
type: TOAST_ACTIONS.CLEAR_ALL_TOASTS
});
// 편의 함수들 - 타입별 Toast 생성
export const showSuccessToast = curry((message, options) =>
showToast({ message, type: 'success', ...(options || {}) })
);
export const showErrorToast = curry((message, options) =>
showToast({ message, type: 'error', ...(options || {}) })
);
export const showWarningToast = curry((message, options) =>
showToast({ message, type: 'warning', ...(options || {}) })
);
export const showInfoToast = curry((message, options) =>
showToast({ message, type: 'info', ...(options || {}) })
);
// 특별한 용도의 Toast들
export const showSearchSuccessToast = curry((query, resultCount) =>
showSuccessToast(`"${query}"에 대한 ${resultCount}개의 결과를 찾았습니다.`, {
duration: 3000,
position: 'bottom-center',
})
);
export const showSearchErrorToast = curry((query) =>
showErrorToast(`"${query}"에 대한 검색 결과를 찾을 수 없습니다.`, {
duration: 4000,
position: 'bottom-center',
})
);
export const showNetworkErrorToast = curry(() =>
showErrorToast('네트워크 연결을 확인해주세요.', {
duration: 5000,
position: 'bottom-center',
})
);
export const showLoadingToast = curry((message = '로딩 중...') =>
showInfoToast(message, {
duration: 0, // 수동으로 닫을 때까지 유지
position: 'bottom-center',
})
);

View File

@@ -0,0 +1,124 @@
import React, { useEffect, useState, useRef } from "react";
import classNames from "classnames";
import { useDispatch } from "react-redux";
import Spottable from "@enact/spotlight/Spottable";
import { changeAppStatus } from "../../actions/commonActions";
import BuyOption from "../../views/DetailPanel/components/BuyOption";
import css from "./TToastEnhanced.module.less";
const SpottableToast = Spottable("div");
// 타입별 아이콘 정의
const toastIcons = {
success: "✓",
error: "✕",
warning: "⚠",
info: ""
};
export default function TToastEnhanced({
text,
type = "success",
duration = 4000,
position = "bottom-center",
id,
onClose,
...rest
}) {
const dispatch = useDispatch();
const [isVisible, setIsVisible] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const timerRef = useRef(null);
const progressRef = useRef(null);
// 애니메이션 시작
useEffect(() => {
// 약간의 지연을 두고 애니메이션 시작
const showTimer = setTimeout(() => {
setIsVisible(true);
}, 50);
startTimer();
return () => {
clearTimeout(showTimer);
clearTimer();
};
}, []);
const startTimer = () => {
if (duration > 0) {
timerRef.current = setTimeout(handleClose, duration);
}
};
const clearTimer = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
const handleClose = () => {
setIsExiting(true);
clearTimer();
// 애니메이션 완료 후 제거
setTimeout(() => {
if (onClose) {
onClose(id);
} else {
// 기존 방식 호환성
dispatch(changeAppStatus({ toast: false }));
}
}, 300); // 애니메이션 시간
};
const handleMouseEnter = () => {
clearTimer();
if (progressRef.current) {
progressRef.current.style.animationPlayState = "paused";
}
};
const handleMouseLeave = () => {
if (!isExiting) {
const remainingTime = duration > 0 ?
(progressRef.current?.offsetWidth / progressRef.current?.parentElement?.offsetWidth) * duration :
0;
if (remainingTime > 100) { // 최소 100ms 남아있을 때만 재시작
clearTimer();
timerRef.current = setTimeout(handleClose, remainingTime);
}
if (progressRef.current) {
progressRef.current.style.animationPlayState = "running";
}
}
};
return (
<SpottableToast
className={classNames(
css.toast,
css[type],
css[position],
isVisible && css.visible,
isExiting && css.exiting
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
spotlightId={`toast-${id || Date.now()}`}
{...rest}
>
{type === 'buyOption' ? (
<BuyOption />
) : (
<div className={css.content}>
<div className={css.message}>{text}</div>
</div>
)}
</SpottableToast>
);
}

View File

@@ -0,0 +1,182 @@
// Enhanced Toast 컴포넌트 스타일
@PRIMARY_COLOR_RED: #E50914;
@SUCCESS_COLOR: #4CAF50;
@ERROR_COLOR: #F44336;
@WARNING_COLOR: #FF9800;
@INFO_COLOR: #2196F3;
.toast {
position: fixed;
width: 1920px;
background: white;
z-index: 9999;
opacity: 0;
transform: translateY(100px);
transition: all 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
overflow: hidden;
&.visible {
opacity: 1;
transform: translateY(0);
}
&.exiting {
opacity: 0;
transform: translateY(100px);
transition: all 0.3s ease-in;
}
}
// 위치별 스타일 - 전체 너비 Toast
.bottom-center {
bottom: 0;
left: 50%;
transform: translateX(-50%) translateY(100px);
&.visible {
transform: translateX(-50%) translateY(0);
}
&.exiting {
transform: translateX(-50%) translateY(100px);
}
}
.bottom-right {
bottom: 0;
right: 0;
}
.bottom-left {
bottom: 0;
left: 0;
}
.top-center {
top: 0;
left: 50%;
transform: translateX(-50%) translateY(-100px);
&.visible {
transform: translateX(-50%) translateY(0);
}
&.exiting {
transform: translateX(-50%) translateY(-100px);
}
}
.top-right {
top: 0;
right: 0;
transform: translateY(-100px);
&.visible {
transform: translateY(0);
}
&.exiting {
transform: translateY(-100px);
}
}
.top-left {
top: 0;
left: 0;
transform: translateY(-100px);
&.visible {
transform: translateY(0);
}
&.exiting {
transform: translateY(-100px);
}
}
// 타입별 스타일 - 간단한 스타일
.success {
// 간단한 흰색 배경만
}
.error {
// 간단한 흰색 배경만
}
.warning {
// 간단한 흰색 배경만
}
.info {
// 간단한 흰색 배경만
}
.buyOption {
height: 360px;
}
// 컨텐츠 스타일 - 간단한 메시지만
.content {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 60px;
position: relative;
height: 120px;
}
.icon {
display: none; // 아이콘 숨김
}
.message {
font-size: 48px;
line-height: 1.2;
color: #333;
font-weight: 500;
text-align: center;
font-family: 'LG Smart UI';
}
.closeButton {
display: none; // 닫기 버튼 숨김
}
// 프로그레스 바 - 숨김
.progressBar {
display: none; // 프로그레스 바 숨김
}
.progress {
display: none; // 프로그레스 바 숨김
}
@keyframes progress {
from {
width: 100%;
}
to {
width: 0%;
}
}
// 호버 효과 - 제거
.toast:hover {
// 호버 효과 제거
}
// WebOS/TV 환경을 위한 큰 화면 대응 - 간단한 스타일
@media (min-width: 1920px) {
.toast {
width: 1920px; // 고정 너비
}
.content {
padding: 40px 60px;
height: 120px;
}
.message {
font-size: 48px;
}
}

View File

@@ -0,0 +1,40 @@
import React from "react";
import { useSelector, useDispatch } from "react-redux";
import TToastEnhanced from "./TToastEnhanced";
import { removeToast } from "../../actions/toastActions";
import css from "./ToastContainer.module.less";
export default function ToastContainer({ position = "bottom-center", maxToasts = 5 }) {
const toasts = useSelector(state => state.toast?.toasts || []);
const dispatch = useDispatch();
const handleCloseToast = (id) => {
dispatch(removeToast(id));
};
// 최대 개수 제한
const visibleToasts = toasts.slice(-maxToasts);
if (visibleToasts.length === 0) {
return null;
}
return (
<div className={`${css.container} ${css[position]}`}>
{visibleToasts.map((toast, index) => (
<TToastEnhanced
key={toast.id}
{...toast}
onClose={handleCloseToast}
style={{
// 스택 효과를 위한 z-index와 margin 조정
zIndex: 9999 + index,
marginBottom: index > 0 ? '12px' : '0',
// 애니메이션 지연으로 순차적 등장 효과
animationDelay: `${index * 100}ms`
}}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,93 @@
// ToastContainer 스타일
.container {
position: fixed;
z-index: 9999;
pointer-events: none; // 컨테이너 자체는 클릭 불가, 자식 요소만 클릭 가능
// 자식 toast들은 클릭 가능하도록 설정
> * {
pointer-events: auto;
}
}
// 위치별 스타일
.bottom-center {
bottom: 0;
left: 50%;
transform: translateX(-50%);
// 아래에서 위로 스택
display: flex;
flex-direction: column-reverse;
align-items: center;
}
.bottom-right {
bottom: 0;
right: 0;
display: flex;
flex-direction: column-reverse;
align-items: flex-end;
}
.bottom-left {
bottom: 0;
left: 0;
display: flex;
flex-direction: column-reverse;
align-items: flex-start;
}
.top-center {
top: 0;
left: 50%;
transform: translateX(-50%);
// 위에서 아래로 스택
display: flex;
flex-direction: column;
align-items: center;
}
.top-right {
top: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.top-left {
top: 0;
left: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
}
// 스택 효과를 위한 추가 스타일 (필요시)
.container > * {
margin-bottom: 0;
&:not(:last-child) {
margin-bottom: 12px;
}
}
// 반응형 대응
@media (max-width: 768px) {
.container {
left: 10px !important;
right: 10px !important;
transform: none !important;
> * {
max-width: calc(100vw - 20px);
min-width: calc(100vw - 20px);
}
}
}

View File

@@ -0,0 +1,40 @@
import { TOAST_ACTIONS } from '../actions/toastActions';
import { curry, get, set, filter } from '../utils/fp';
const initialState = {
toasts: []
};
// FP handlers (curried) with immutable updates only
const handleAddToast = curry((state, action) =>
set(
'toasts',
(get('toasts', state) || []).concat(get('payload', action)),
state
)
);
const handleRemoveToast = curry((state, action) =>
set(
'toasts',
filter(
(toast) => get('id', toast) !== get('payload', action),
get('toasts', state) || []
),
state
)
);
const handleClearAllToasts = curry((state) => set('toasts', [], state));
const handlers = {
[TOAST_ACTIONS.ADD_TOAST]: handleAddToast,
[TOAST_ACTIONS.REMOVE_TOAST]: handleRemoveToast,
[TOAST_ACTIONS.CLEAR_ALL_TOASTS]: handleClearAllToasts,
};
export function toastReducer(state = initialState, action = {}) {
const type = get('type', action);
const handler = handlers[type];
return handler ? handler(state, action) : state;
}

View File

@@ -30,6 +30,7 @@ import { playReducer } from '../reducers/playReducer';
import { productReducer } from '../reducers/productReducer';
import { searchReducer } from '../reducers/searchReducer';
import { shippingReducer } from '../reducers/shippingReducer';
import { toastReducer } from '../reducers/toastReducer';
const rootReducer = combineReducers({
panels: panelsReducer,
@@ -56,6 +57,7 @@ const rootReducer = combineReducers({
pinCode: pinCodeReducer,
emp: empReducer,
foryou: foryouReducer,
toast: toastReducer,
});
export const store = createStore(rootReducer, applyMiddleware(thunk));

View File

@@ -26,6 +26,7 @@ import arrowDown
from '../../../../assets/images/icons/ic_arrow_down_3x_new.png';
import { pushPanel } from '../../../actions/panelActions';
import { resetShowAllReviews } from '../../../actions/productActions';
import { showToast } from '../../../actions/toastActions';
// ProductInfoSection imports
import TButton from '../../../components/TButton/TButton';
import useReviews from '../../../hooks/useReviews/useReviews';
@@ -228,6 +229,25 @@ export default function ProductAllSection({
);
}, [dispatch, productData, stats]);
// BUY NOW 버튼 클릭 핸들러 - Toast로 BuyOption 표시
const handleBuyNowClick = useCallback(() => {
console.log('[BuyNow] Buy Now button clicked');
dispatch(
showToast({
message: '',
type: 'buyOption',
duration: 0,
position: 'bottom-center',
})
);
}, [dispatch]);
// ADD TO CART 버튼 클릭 핸들러
const handleAddToCartClick = useCallback(() => {
console.log('[AddToCart] Add To Cart button clicked');
// TODO: 장바구니 추가 로직 구현
}, []);
// 디버깅: 실제 이미지 데이터 확인
useEffect(() => {
console.log("[ProductId] ProductAllSection productData check:", {
@@ -453,6 +473,26 @@ export default function ProductAllSection({
</div>
</ProductOverview>
{/* BUY NOW + ADD TO CART 버튼들 (결제 가능 상품일 때만 렌더링) */}
<HorizontalContainer className={css.buyNowCartContainer}>
<TButton
spotlightId="detail-buy-now-button"
className={css.buyNowButton}
onClick={handleBuyNowClick}
onSpotlightUp={handleSpotlightUpToBackButton}
>
<div className={css.buyNowText}>{$L('BUY NOW')}</div>
</TButton>
<TButton
spotlightId="detail-add-to-cart-button"
className={css.addToCartButton}
onClick={handleAddToCartClick}
onSpotlightUp={handleSpotlightUpToBackButton}
>
<div className={css.addToCartText}>{$L('ADD TO CART')}</div>
</TButton>
</HorizontalContainer>
<Container className={css.buttonContainer}>
<TButton
spotlightId={SpotlightIds.DETAIL_SHOPBYMOBILE}

View File

@@ -956,3 +956,75 @@
height: 15px;
}
}
/* BUY NOW + ADD TO CART 버튼 스타일 */
.buyNowCartContainer {
align-self: stretch;
padding-top: 19px;
display: flex;
justify-content: flex-start;
align-items: center;
gap: 10px;
}
.buyNowButton {
flex: 1 1 0 !important;
width: auto !important;
height: 60px !important;
background: rgba(68, 68, 68, 0.50) !important;
border-radius: 6px !important;
border: none !important;
padding: 0 !important;
margin: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
.buyNowText {
color: white !important;
font-size: 25px !important;
font-weight: 400 !important;
line-height: 35px !important;
text-align: center !important;
}
&:focus {
background: @PRIMARY_COLOR_RED !important;
outline: 2px solid @PRIMARY_COLOR_RED !important;
.buyNowText {
color: white !important;
}
}
}
.addToCartButton {
flex: 1 1 0 !important;
width: auto !important;
height: 60px !important;
background: rgba(68, 68, 68, 0.50) !important;
border-radius: 6px !important;
border: none !important;
padding: 0 !important;
margin: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
.addToCartText {
color: white !important;
font-size: 25px !important;
font-weight: 400 !important;
line-height: 35px !important;
text-align: center !important;
}
&:focus {
background: @PRIMARY_COLOR_RED !important;
outline: 2px solid @PRIMARY_COLOR_RED !important;
.addToCartText {
color: white !important;
}
}
}

View File

@@ -0,0 +1,587 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Spotlight from '@enact/spotlight';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import TButton from '../../../components/TButton/TButton';
import CustomDropDown from './CustomDropDown';
import { clearAllToasts } from '../../../actions/toastActions';
import { getMyInfoCheckoutInfo } from '../../../actions/checkoutActions';
import { changeAppStatus, setShowPopup, setHidePopup } from '../../../actions/commonActions';
import { sendLogPaymentEntry, sendLogTotalRecommend } from '../../../actions/logActions';
import { pushPanel } from '../../../actions/panelActions';
import { getProductOption, getProductOptionId } from '../../../actions/productActions';
import { getProductCouponSearch } from '../../../actions/couponActions';
import * as Config from '../../../utils/Config';
import { showError } from '../../../actions/commonActions';
import { $L } from '../../../utils/helperMethods';
import BuyOptionPriceBlock from './BuyOptionPriceBlock';
import FavoriteBtn from '../components/FavoriteBtn';
import TPopUp from '../../../components/TPopUp/TPopUp';
import styles from './BuyOption.module.less';
const Container = SpotlightContainerDecorator({ restrict: 'self-only' }, 'div');
const BuyOption = ({
patncNm,
productInfo: propsProductInfo,
isSpotlight,
selectedPatnrId: propsSelectedPatnrId,
selectedPrdtId: propsSelectedPrdtId,
selectedIndex,
logMenu,
type,
}) => {
const dispatch = useDispatch();
// Redux 상태 (props가 있으면 props 우선, 없으면 Redux에서)
const { userId, userNumber } = useSelector((state) => state.common.appStatus.loginUserData);
const reduxProductInfo = useSelector((state) => state.product.productInfo);
const productInfo = propsProductInfo || reduxProductInfo;
const productOptionInfos = useSelector((state) => state.product.prdtOptInfo);
const productData = useSelector((state) => state.main.productData);
const { partnerCoupon } = useSelector((state) => state.coupon.productCouponSearchData || {});
const { popupVisible, activePopup } = useSelector((state) => state.common.popup);
const nowMenu = useSelector((state) => state.common.menu.nowMenu);
const webOSVersion = useSelector((state) => state.common.appStatus.webOSVersion);
// 옵션 선택 상태 관리 (SingleOption과 동일한 구조)
const [selectedBtnOptIdx, setSelectedBtnOptIdx] = useState(0);
const [selectedOptionItemIndex, setSelectedOptionItemIndex] = useState(0);
const [selectedOptions, setSelectedOptions] = useState();
const [isOptionValue, setIsOptionValue] = useState(false);
const [isOptionSelect, setIsOptionSelect] = useState(false);
const [quantity, setQuantity] = useState(1);
const [favoriteFlag, setFavoriteFlag] = useState(productInfo?.favorYn);
const [hasProductOptionArray, setHasProductOptionArray] = useState(true);
const [selectedOptionInfo, setSelectedOptionInfo] = useState();
// 상품 정보 (props 우선, 없으면 productInfo에서)
const selectedPatnrId = propsSelectedPatnrId || productInfo?.patnrId;
const selectedPrdtId = propsSelectedPrdtId || productInfo?.prdtId;
// logInfo 생성 (SingleOption과 동일한 로직, productData 우선 사용)
const logInfo = useMemo(() => {
if (productData) {
// productData가 있으면 SingleOption과 동일하게 처리
let couponInfo;
if (partnerCoupon && partnerCoupon.length > 0) {
couponInfo = partnerCoupon[0];
}
const { catCd, catNm, patncNm, patnrId, prdtId, prdtNm, priceInfo } = productData;
const { cpnSno, cpnTtl } = couponInfo || {};
const prodOptSno =
(productOptionInfos &&
productOptionInfos.length > 0 &&
productOptionInfos[0]?.prodOptSno) ||
'';
const prodOptTpCdCval =
(productOptionInfos &&
productOptionInfos.length > 0 &&
productOptionInfos[0]?.prodOptTpCdCval) ||
'';
return {
cpnSno: String(cpnSno) || '',
cpnTtl: cpnTtl || '',
dcAftrPrc: priceInfo.split('|')[1],
dcBefPrc: priceInfo.split('|')[0],
lgCatCd: catCd || '',
lgCatNm: catNm || '',
patncNm,
patnrId,
prodId: prdtId,
prodNm: prdtNm,
prodOptSno: prodOptSno,
prodOptTpCdCval: prodOptTpCdCval,
qty: String(quantity),
};
} else if (productInfo) {
// productData가 없으면 productInfo 사용
let couponInfo;
if (partnerCoupon && partnerCoupon.length > 0) {
couponInfo = partnerCoupon[0];
}
const { catCd, catNm, patncNm, patnrId, prdtId, prdtNm, priceInfo } = productInfo;
const { cpnSno, cpnTtl } = couponInfo || {};
const prodOptSno =
(productOptionInfos &&
productOptionInfos.length > 0 &&
productOptionInfos[0]?.prodOptSno) ||
'';
const prodOptTpCdCval =
(productOptionInfos &&
productOptionInfos.length > 0 &&
productOptionInfos[0]?.prodOptTpCdCval) ||
'';
return {
cpnSno: String(cpnSno) || '',
cpnTtl: cpnTtl || '',
dcAftrPrc: priceInfo.split('|')[1],
dcBefPrc: priceInfo.split('|')[0],
lgCatCd: catCd || '',
lgCatNm: catNm || '',
patncNm,
patnrId,
prodId: prdtId,
prodNm: prdtNm,
prodOptSno: prodOptSno,
prodOptTpCdCval: prodOptTpCdCval,
qty: String(quantity),
};
}
return {};
}, [partnerCoupon, productData, productInfo, productOptionInfos, quantity]);
// 옵션 리셋 로직 (SingleOption과 동일)
useEffect(() => {
if (type !== 'theme') {
return;
}
setSelectedOptions();
setIsOptionValue(false);
setSelectedOptionItemIndex(0);
setIsOptionSelect(false);
setQuantity(1);
setSelectedBtnOptIdx(0);
}, [selectedIndex, productOptionInfos, type]);
// 옵션 자동 선택 로직 (SingleOption과 동일)
useEffect(() => {
if (
productOptionInfos &&
selectedBtnOptIdx >= 0 &&
productOptionInfos[selectedBtnOptIdx]?.prdtOptDtl.length === 1 &&
!isOptionValue
) {
setSelectedOptions(productOptionInfos[selectedBtnOptIdx]?.prdtOptDtl[0]);
setIsOptionValue(true);
}
}, [productOptionInfos, selectedBtnOptIdx, isOptionValue]);
// 필수 데이터 로드 (SingleOption과 동일)
useEffect(() => {
dispatch(
getProductOption({
patnrId: selectedPatnrId,
prdtId: selectedPrdtId,
})
);
dispatch(
getProductCouponSearch({
patnrId: selectedPatnrId,
prdtId: selectedPrdtId,
mbrNo: userNumber,
})
);
}, [dispatch, selectedPatnrId, selectedPrdtId, userNumber]);
// 포커스 관리 로직 (SingleOption과 유사)
useEffect(() => {
if (!isSpotlight) {
// isSpotlight이 false면 일반적인 BuyOption 포커스
console.log('[BuyOption] Component mounted - focusing BUY NOW button');
setTimeout(() => {
Spotlight.focus('buy-option-buy-now-button');
}, 100);
return;
}
// isSpotlight이 true이고 SingleOption 동작이 필요한 경우
if (productInfo?.optProdYn === 'N') {
Spotlight.focus('buy-option-buy-now-button');
} else if (productOptionInfos && productOptionInfos.length > 0) {
Spotlight.focus('buy-option-first-dropdown');
if (productOptionInfos.length === 1) {
Spotlight.focus('buy-option-second-dropdown');
}
}
}, [productOptionInfos, productInfo, isSpotlight]);
// checkOutValidate 콜백 함수 (SingleOption과 동일한 로직)
function checkOutValidate(response) {
if (response) {
if (response.retCode === 0) {
if (
response.data.cardInfo === null ||
response.data.billingAddressList.length === 0 ||
response.data.shippingAddressList.length === 0
) {
dispatch(setShowPopup(Config.ACTIVE_POPUP.qrPopup));
dispatch(changeAppStatus({ isLoading: false }));
return;
} else {
const { mbrId, prdtId, prodSno } = response.data.productList[0];
const cartTpSno = `${mbrId}_${prdtId}_${prodSno}`;
dispatch(
pushPanel({
name: Config.panel_names.CHECKOUT_PANEL,
panelInfo: { logInfo: { ...logInfo, cartTpSno } },
})
);
dispatch(sendLogPaymentEntry({ ...logInfo, cartTpSno }));
}
} else if (response.retCode === 1001) {
dispatch(setShowPopup(Config.ACTIVE_POPUP.qrPopup));
dispatch(changeAppStatus({ isLoading: false }));
} else {
dispatch(
showError(
response.retCode,
response.retMsg,
false,
response.retDetailCode,
response.returnBindStrings
)
);
dispatch(changeAppStatus({ isLoading: false }));
return;
}
}
Spotlight.focus('buy-option-buy-now-button');
}
const handleBuyNowClick = useCallback(() => {
console.log('[BuyOption] BUY NOW clicked');
const optionName = renderOptionName();
const optionValueName = renderOptionValue();
if (productInfo && productInfo?.soldoutFlag === 'Y') {
return;
}
if (!userNumber || userNumber === '') {
return dispatch(setShowPopup(Config.ACTIVE_POPUP.loginPopup));
}
// 옵션 선택 검증 (SingleOption과 동일)
if (
productOptionInfos &&
productOptionInfos.length > 0 &&
(optionName === 'SELECT' || optionValueName === 'SELECT')
) {
return dispatch(setShowPopup(Config.ACTIVE_POPUP.loginPopup));
}
if (userNumber && selectedPatnrId && selectedPrdtId && quantity) {
const { prodOptCval, priceInfo } = selectedOptions || {};
const { patncNm, brndNm, catNm, prdtNm, prdtId } = productInfo;
const regularPrice = priceInfo?.split('|')[0];
const discountPrice = priceInfo?.split('|')[1];
const discountRate = priceInfo?.split('|')[4];
dispatch(
sendLogTotalRecommend({
nowMenu: nowMenu,
productId: prdtId,
productTitle: prdtNm,
partner: patncNm,
price: discountRate ? discountPrice : regularPrice,
discount: discountRate,
brand: brndNm,
productOption: prodOptCval || '',
category: catNm,
contextName: Config.LOG_CONTEXT_NAME.DETAILPAGE,
messageId: Config.LOG_MESSAGE_ID.BUY_NOW,
})
);
dispatch(
getMyInfoCheckoutInfo(
{
mbrNo: userNumber,
dirPurcSelYn: 'Y',
cartList: [
{
patnrId: selectedPatnrId,
prdtId: selectedPrdtId,
prodOptCdCval: selectedOptions?.prodOptCdCval || null,
prodQty: quantity,
prodOptTpCdCval: productOptionInfos[0]?.prodOptTpCdCval,
},
],
},
checkOutValidate
)
);
}
}, [
dispatch,
userNumber,
selectedPatnrId,
selectedPrdtId,
productInfo,
productOptionInfos,
quantity,
logInfo,
]);
// [임시] ADD TO CART 버튼 클릭 시 toast 닫기
const handleAddToCartClick = () => {
console.log('[BuyOption] ADD TO CART clicked - closing toast (임시)');
dispatch(clearAllToasts());
};
// 첫번째 옵션 선택 핸들러 (SingleOption과 동일)
const handleFirstOptionSelect = (selected) => {
const optionValIdx = selected.selected;
console.log('[BuyOption] First option selected:', optionValIdx);
setSelectedBtnOptIdx(optionValIdx);
setSelectedOptionItemIndex(0);
setSelectedOptions(productOptionInfos[optionValIdx]?.prdtOptDtl[0]);
setIsOptionValue(false);
setIsOptionSelect(true);
};
// 두번째 옵션 선택 핸들러 (SingleOption과 동일)
const handleSecondOptionSelect = (selected) => {
const index = selected.selected;
console.log('[BuyOption] Second option selected:', index);
setSelectedOptionItemIndex(index);
setSelectedOptions(productOptionInfos[selectedBtnOptIdx]?.prdtOptDtl[index]);
dispatch(
getProductOptionId(productOptionInfos[selectedBtnOptIdx]?.prdtOptDtl[index]?.prodOptCdCval)
);
setIsOptionValue(true);
};
// 수량 선택 핸들러
const handleQuantitySelect = (selected) => {
const qty = selected.selected + 1;
console.log('[BuyOption] Quantity selected:', qty);
setQuantity(qty);
};
// 옵션명 렌더링 함수 (SingleOption과 동일)
const renderOptionName = useCallback(() => {
if (selectedOptions) {
return productOptionInfos[selectedBtnOptIdx]?.optNm || null;
}
return $L('SELECT');
}, [productOptionInfos, selectedOptions, selectedBtnOptIdx]);
// 옵션값 렌더링 함수 (SingleOption과 동일)
const renderOptionValue = useCallback(() => {
if (
productOptionInfos &&
productOptionInfos[selectedBtnOptIdx] &&
productOptionInfos[selectedBtnOptIdx]?.prdtOptDtl &&
productOptionInfos[selectedBtnOptIdx]?.prdtOptDtl.length > 0 &&
isOptionValue
) {
return (
productOptionInfos[selectedBtnOptIdx]?.prdtOptDtl[selectedOptionItemIndex]?.prodOptCval ||
null
);
}
return $L('SELECT');
}, [productOptionInfos, selectedBtnOptIdx, isOptionValue, selectedOptionItemIndex]);
// Favorite 플래그 업데이트 (SingleOption과 동일)
useEffect(() => {
setFavoriteFlag(productInfo?.favorYn ? productInfo?.favorYn : 'N');
}, [productInfo]);
// Favorite 플래그 변경 콜백 (SingleOption과 동일)
const onFavoriteFlagChanged = useCallback((ev) => {
setFavoriteFlag(ev);
}, []);
// hasOnClose 로직 (SingleOption과 동일)
const hasOnClose = useMemo(() => {
if (productOptionInfos && productOptionInfos.length > 0) {
return true;
}
return false;
}, [productOptionInfos, isOptionValue, isOptionSelect, selectedOptions]);
// 로그인 팝업 텍스트 로직 (SingleOption과 동일)
const loginPopupText = useMemo(() => {
if (!userNumber) {
return $L('Would you like to sign in?');
}
if (hasOnClose) {
return $L('Please select Option');
}
return $L('Would you like to sign in?');
}, [hasOnClose, userNumber]);
// 팝업 닫기 핸들러 (SingleOption과 동일)
const onClose = useCallback(
(spotlightId) => {
dispatch(setHidePopup());
let currentSpot;
if (typeof spotlightId === 'string') {
currentSpot = spotlightId;
} else {
currentSpot = 'buy-option-buy-now-button';
}
if (currentSpot) {
setTimeout(() => {
Spotlight.focus(currentSpot);
});
}
},
[dispatch]
);
// 로그인 팝업 열기 핸들러 (SingleOption과 동일)
const handleLoginPopUpOpen = useCallback(() => {
if (!userNumber) {
if (webOSVersion >= '6.0') {
setTimeout(() => {
Spotlight.focus('buy-option-buy-now-button');
});
dispatch(setHidePopup());
// dispatch(launchMembershipApp()); // 필요시 추가
} else {
dispatch(setShowPopup(Config.ACTIVE_POPUP.qrPopup));
}
return;
}
if (hasOnClose) {
dispatch(setHidePopup());
let spotlightId = 'buy-option-first-dropdown';
//옵션이 하나만 있는경우 isOptionValue === false
if (!isOptionValue) {
spotlightId = 'buy-option-second-dropdown';
}
setTimeout(() => {
Spotlight.focus(spotlightId);
}, 100);
return;
}
}, [dispatch, hasOnClose, isOptionValue, webOSVersion, userNumber]);
return (
<Container className={styles.buy_option}>
<div className={styles.buy_option__left_section}>
{/* 동적 옵션 렌더링 */}
{productOptionInfos && productOptionInfos.length > 0 && productInfo?.optProdYn === 'Y' && (
<>
{/* 첫번째 옵션 (여러 옵션이 있을 때만) */}
{productOptionInfos.length > 1 && (
<div className={styles.buy_option__option_row}>
<div className={styles.buy_option__option_label}>
<div className={styles.buy_option__label_text}>OPTION 1</div>
</div>
<div className={styles.buy_option__option_control}>
<CustomDropDown
options={productOptionInfos.map((option) => option.optNm)}
selectedIndex={selectedBtnOptIdx}
onSelect={handleFirstOptionSelect}
spotlightId="buy-option-first-dropdown"
/>
</div>
</div>
)}
{/* 두번째 옵션 (옵션 상세값들) */}
{productOptionInfos[selectedBtnOptIdx]?.prdtOptDtl &&
productOptionInfos[selectedBtnOptIdx]?.prdtOptDtl.length > 1 && (
<div className={styles.buy_option__option_row}>
<div className={styles.buy_option__option_label}>
<div className={styles.buy_option__label_text}>
{productOptionInfos.length === 1 ? 'OPTION' : 'OPTION 2'}
</div>
</div>
<div className={styles.buy_option__option_control}>
<CustomDropDown
options={
productOptionInfos[selectedBtnOptIdx]?.prdtOptDtl.map(
(detail) => detail.prodOptCval
) || []
}
selectedIndex={selectedOptionItemIndex}
onSelect={handleSecondOptionSelect}
spotlightId="buy-option-second-dropdown"
/>
</div>
</div>
)}
</>
)}
{/* 수량 선택 */}
<div className={styles.buy_option__option_row}>
<div className={styles.buy_option__option_label}>
<div className={styles.buy_option__label_text}>QUANTITY</div>
</div>
<div className={styles.buy_option__option_control}>
<CustomDropDown
options={['1', '2', '3', '4', '5']}
selectedIndex={quantity - 1}
onSelect={handleQuantitySelect}
spotlightId="buy-option-quantity-dropdown"
/>
</div>
</div>
</div>
<div className={styles.buy_option__right_section}>
<BuyOptionPriceBlock
className={styles.buy_option__price_block}
productInfo={productInfo || productData}
selectedOptions={selectedOptions}
patncNm={patncNm || productData?.patncNm || productInfo?.patncNm}
/>
<div className={styles.buy_option__button_section}>
<TButton
className={styles.buy_option__buy_button}
spotlightId="buy-option-buy-now-button"
onClick={handleBuyNowClick}
>
<span className={styles.buy_option__button_text}>BUY NOW</span>
</TButton>
<TButton
className={styles.buy_option__cart_button}
spotlightId="buy-option-add-to-cart-button"
onClick={handleAddToCartClick}
>
<span className={styles.buy_option__button_text}>ADD TO CART</span>
</TButton>
<FavoriteBtn
selectedPatnrId={selectedPatnrId}
selectedPrdtId={selectedPrdtId}
favoriteFlag={favoriteFlag}
onFavoriteFlagChanged={onFavoriteFlagChanged}
logMenu={logMenu || 'DetailPage'}
className={styles.buy_option__favorite_button}
/>
</div>
</div>
{/* LOGIN POPUP */}
{activePopup === Config.ACTIVE_POPUP.loginPopup && (
<TPopUp
kind="textPopup"
hasText
open={popupVisible}
text={loginPopupText}
hasButton
hasOnClose={hasOnClose}
button1Text={$L('OK')}
button2Text={$L('CANCEL')}
onClick={handleLoginPopUpOpen}
onClose={onClose}
/>
)}
</Container>
);
};
export default BuyOption;

View File

@@ -0,0 +1,134 @@
// BEM Style: buy-option (Block)
.buy_option {
width: 100%;
height: 360px;
padding: 60px 70px;
background: 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.95);
overflow: hidden;
display: flex;
justify-content: center;
align-items: flex-start;
// buy-option__left-section (Element)
&__left_section {
flex: 1 1 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
margin-right: 120px;
}
// buy-option__option-row (Element)
&__option_row {
align-self: stretch;
height: 60px;
display: flex;
justify-content: flex-start;
align-items: center;
&:not(:last-child) {
margin-bottom: 30px;
}
}
// buy-option__option-label (Element)
&__option_label {
width: 140px;
display: flex;
justify-content: flex-start;
align-items: center;
margin-right: 15px;
}
// buy-option__label-text (Element)
&__label_text {
color: #EAEAEA;
font-size: 25px;
font-family: 'LG Smart UI';
font-weight: 700;
line-height: 35px;
word-wrap: break-word;
}
// buy-option__option-control (Element)
&__option_control {
flex: 1 1 0;
height: 60px;
display: flex;
justify-content: flex-start;
align-items: center;
}
// buy-option__right-section (Element)
&__right_section {
flex: 1 1 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-end;
}
&__price_block {
width: 300px;
margin-bottom: 30px;
}
// buy-option__button-section (Element)
&__button_section {
width: 100%;
display: flex;
justify-content: flex-end;
align-items: center;
}
// buy-option__buy-button (Element)
&__buy_button {
width: 180px;
height: 60px;
padding: 20px 30px;
background: #C72054;
overflow: hidden;
border-radius: 6px;
display: flex;
justify-content: center;
align-items: center;
margin-right: 15px;
&:focus {
background: #D73B69;
}
}
// buy-option__cart-button (Element)
&__cart_button {
width: 180px;
height: 60px;
padding: 20px 30px;
background: #2F2D2D;
overflow: hidden;
border-radius: 6px;
display: flex;
justify-content: center;
align-items: center;
margin-right: 15px;
&:focus {
background: #4a4a4a;
}
}
// buy-option__button-text (Element)
&__button_text {
color: white;
font-size: 25px;
font-family: 'LG Smart UI';
font-weight: 600;
line-height: 35px;
text-align: center;
}
&__favorite_button {
display: flex;
}
}

View File

@@ -0,0 +1,52 @@
import React, { useMemo } from 'react';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import usePriceInfo from '../../../hooks/usePriceInfo';
import { $L } from '../../../utils/helperMethods';
import styles from './BuyOptionPriceBlock.module.less';
const BuyOptionPriceBlock = ({ patncNm, productInfo, selectedOptions, className }) => {
const productData = useSelector((state) => state.main.productData);
const sourceProduct = productInfo || productData;
const { shippingCharge } = sourceProduct || {};
const priceSource = selectedOptions || sourceProduct;
const { originalPrice, discountedPrice, discountRate } =
usePriceInfo(priceSource?.priceInfo) || {};
const priceLabel = useMemo(() => {
if (patncNm) {
return `${patncNm} ${$L('Price')}`;
}
return $L('Price');
}, [patncNm]);
const showDiscountBadge = discountedPrice && originalPrice && discountedPrice !== originalPrice;
if (!discountedPrice && !shippingCharge) {
return null;
}
return (
<div className={classNames(styles.price_block, className)}>
{discountedPrice && (
<div className={styles.price_row}>
<span className={styles.price_label}>{priceLabel}</span>
<div className={styles.price_value_group}>
<span className={styles.price_value}>{discountedPrice}</span>
</div>
</div>
)}
{shippingCharge && (
<div className={styles.shipping_row}>
<span className={styles.shipping_label}>{$L('Shipping and Handling')}</span>
<span className={styles.shipping_value}>{shippingCharge}</span>
</div>
)}
</div>
);
};
export default BuyOptionPriceBlock;

View File

@@ -0,0 +1,49 @@
/* BuyOptionPriceBlock Module - 타겟 UI 스타일 유지 */
.price_block {
display: flex;
flex-direction: column;
gap: 8px;
}
.price_row {
display: flex;
justify-content: space-between;
align-items: center;
}
.price_label {
font-size: 14px;
color: #666;
font-weight: 500;
}
.price_value_group {
display: flex;
align-items: center;
gap: 8px;
}
.price_value {
font-size: 16px;
font-weight: 600;
color: #333;
}
.shipping_row {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 8px;
border-top: 1px solid #eee;
}
.shipping_label {
font-size: 12px;
color: #666;
}
.shipping_value {
font-size: 12px;
font-weight: 500;
color: #333;
}

View File

@@ -0,0 +1,91 @@
import React, { useState, useCallback, useEffect } from 'react';
import Spottable from '@enact/spotlight/Spottable';
import Spotlight from '@enact/spotlight';
import styles from './CustomDropDown.module.less';
import iconDownArrow from '../../../../assets/images/btn/btn-dropdown-nor@3x.png';
const SpottableDiv = Spottable('div');
const CustomDropDown = ({
className,
options = [],
selectedIndex = 0,
onSelect,
spotlightId,
placeholder = 'Select option',
}) => {
const [isOpen, setIsOpen] = useState(false);
const handleToggle = useCallback(() => {
const newIsOpen = !isOpen;
setIsOpen(newIsOpen);
// 드롭다운이 닫힐 때 버튼으로 포커스 이동
if (!newIsOpen) {
setTimeout(() => {
if (spotlightId) {
Spotlight.focus(spotlightId);
}
}, 50);
}
}, [isOpen, spotlightId]);
const handleOptionSelect = useCallback(
(index) => {
setIsOpen(false);
if (onSelect) {
onSelect({ selected: index });
}
// 드롭다운이 닫힐 때 버튼으로 포커스 이동
setTimeout(() => {
if (spotlightId) {
Spotlight.focus(spotlightId);
}
}, 50);
},
[onSelect, spotlightId]
);
const selectedOption = options[selectedIndex] || placeholder;
return (
<div className={`${styles.custom_dropdown} ${className || ''}`}>
<SpottableDiv
className={`${styles.custom_dropdown__button} ${
isOpen ? styles.custom_dropdown__button_expanded : ''
}`}
onClick={handleToggle}
spotlightId={spotlightId}
>
<div className={styles.custom_dropdown__text}>{selectedOption}</div>
<div className={styles.custom_dropdown__icon}>
<img src={iconDownArrow} alt="dropdown arrow" />
</div>
</SpottableDiv>
{isOpen && (
<div className={styles.custom_dropdown__list}>
{options
.slice()
.reverse()
.map((option, reverseIndex) => {
const originalIndex = options.length - 1 - reverseIndex;
return (
<SpottableDiv
key={originalIndex}
className={`${styles.custom_dropdown__option} ${
originalIndex === selectedIndex ? styles.custom_dropdown__option_selected : ''
}`}
onClick={() => handleOptionSelect(originalIndex)}
>
{option}
</SpottableDiv>
);
})}
</div>
)}
</div>
);
};
export default CustomDropDown;

View File

@@ -0,0 +1,70 @@
/* CustomDropDown Module - 타겟 UI 스타일 유지 */
.custom_dropdown {
position: relative;
display: inline-block;
}
.custom_dropdown__button {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border: 1px solid #ddd;
background-color: #fff;
cursor: pointer;
border-radius: 4px;
min-width: 200px;
}
.custom_dropdown__button_expanded {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-bottom-color: transparent;
}
.custom_dropdown__text {
flex: 1;
text-align: left;
color: #333;
font-size: 14px;
}
.custom_dropdown__icon {
margin-left: 8px;
img {
width: 12px;
height: 12px;
}
}
.custom_dropdown__list {
position: absolute;
top: 100%;
left: 0;
right: 0;
background-color: #fff;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 4px 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
max-height: 200px;
overflow-y: auto;
}
.custom_dropdown__option {
padding: 12px 16px;
cursor: pointer;
font-size: 14px;
color: #333;
&:hover {
background-color: #f5f5f5;
}
}
.custom_dropdown__option_selected {
background-color: #e6f3ff;
color: #0066cc;
}