[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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
86
com.twin.app.shoptime/src/actions/toastActions.js
Normal file
86
com.twin.app.shoptime/src/actions/toastActions.js
Normal 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',
|
||||
})
|
||||
);
|
||||
124
com.twin.app.shoptime/src/components/TToast/TToastEnhanced.jsx
Normal file
124
com.twin.app.shoptime/src/components/TToast/TToastEnhanced.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
com.twin.app.shoptime/src/reducers/toastReducer.js
Normal file
40
com.twin.app.shoptime/src/reducers/toastReducer.js
Normal 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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user