[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

@@ -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;