[250904] feat: DetailPanel UserReviews 페이지네이션 구현
🕐 커밋 시간: 2025. 09. 04. 12:56:09 💬 사용자 메시지: UserReviews와 CustomerImages에 페이지네이션 기능 추가 - CustomerImages: 5개씩 표시하는 View More 버튼 기능 - UserReviews: 모든 리뷰 데이터 표시로 변경 - Chromium 68 호환성 개선 (Optional Chaining 제거) - API 엔드포인트 및 Redux 액션/리듀서 추가 - 1124px 레이아웃 통일 및 View More 버튼 스타일링 📊 변경 통계: • 총 파일: 57개 • 추가: +1252줄 • 삭제: -540줄 📁 추가된 파일: + com.twin.app.shoptime/assets/images/icons/ic-gr-call-1.png + com.twin.app.shoptime/assets/images/image-review-sample-1.png + com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.backup.jsx + com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.backup.module.less + com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx + com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.module.less + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDescription/ProductDescription.jsx + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDescription/ProductDescription.module.less + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDescription/package.json + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDetail/ProductDetail.new.jsx + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDetail/ProductDetail.new.module.less + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/CustomerImages/CustomerImages.jsx + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/CustomerImages/CustomerImages.module.less + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviews.jsx + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviews.module.less + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviewsPopup/UserReviewsPopup.figma.jsx + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviewsPopup/UserReviewsPopup.jsx + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviewsPopup/UserReviewsPopup.module.less + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/package.json + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/YouMayAlsoLike/YouMayAlsoLike.jsx + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/YouMayAlsoLike/YouMayAlsoLike.module.less + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/YouMayAlsoLike/package.json + com.twin.app.shoptime/src/views/DetailPanel/ProductInfoSection/QRCode/QRCode.jsx + com.twin.app.shoptime/src/views/DetailPanel/ProductInfoSection/QRCode/QRCode.module.less + com.twin.app.shoptime/src/views/DetailPanel/ProductInfoSection/QRCode/package.json + com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductOverview.jsx + com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductOverview.module.less + com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/BuyNowPriceDisplay/BuyNowPriceDisplay.jsx + com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/BuyNowPriceDisplay/BuyNowPriceDisplay.module.less + com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/BuyNowPriceDisplay/package.json + com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ProductPriceDisplay.jsx + com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ProductPriceDisplay.module.less + com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ShopByMobilePriceDisplay/ShopByMobilePriceDisplay.jsx + com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ShopByMobilePriceDisplay/ShopByMobilePriceDisplay.module.less + com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ShopByMobilePriceDisplay/package.json + com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/package.json + com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/package.json + com.twin.app.shoptime/src/views/DetailPanel/ThemeItemListOverlay/ThemeItemListOverlay.jsx + com.twin.app.shoptime/src/views/DetailPanel/ThemeItemListOverlay/ThemeItemListOverlay.module.less + com.twin.app.shoptime/src/views/DetailPanel/components/CustomScrollbar/CustomScrollbar.jsx + com.twin.app.shoptime/src/views/DetailPanel/components/CustomScrollbar/CustomScrollbar.module.less + com.twin.app.shoptime/src/views/DetailPanel/components/DetailMobileSendPopUp.jsx + com.twin.app.shoptime/src/views/DetailPanel/components/DetailMobileSendPopUp.module.less + com.twin.app.shoptime/src/views/DetailPanel/components/THeaderCustom.jsx + com.twin.app.shoptime/src/views/DetailPanel/components/THeaderCustom.module.less + com.twin.app.shoptime/src/views/DetailPanel/components/TScroller/TScrollerDetail.jsx + com.twin.app.shoptime/src/views/DetailPanel/components/TScroller/TScrollerDetail.module.less + package-lock.json 📝 수정된 파일: ~ com.twin.app.shoptime/src/actions/actionTypes.js ~ com.twin.app.shoptime/src/actions/productActions.js ~ com.twin.app.shoptime/src/api/apiConfig.js ~ com.twin.app.shoptime/src/reducers/productReducer.js ~ com.twin.app.shoptime/src/utils/fp.js ~ com.twin.app.shoptime/src/utils/lodash.js ~ com.twin.app.shoptime/src/utils/lodashFpEx.js ~ com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.jsx ~ com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.module.less 🔧 주요 변경 내용: • 타입 시스템 안정성 강화 • 핵심 비즈니스 로직 개선 • API 서비스 레이어 개선 • 공통 유틸리티 함수 최적화 • 프로젝트 의존성 관리 개선 • UI 컴포넌트 아키텍처 개선 • 대규모 기능 개발 • 모듈 구조 개선 BREAKING CHANGE: API 또는 설정 변경으로 인한 호환성 영향 가능
This commit is contained in:
BIN
com.twin.app.shoptime/assets/images/icons/ic-gr-call-1.png
Normal file
BIN
com.twin.app.shoptime/assets/images/icons/ic-gr-call-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
com.twin.app.shoptime/assets/images/image-review-sample-1.png
Normal file
BIN
com.twin.app.shoptime/assets/images/image-review-sample-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
@@ -20,7 +20,8 @@ export const types = {
|
|||||||
CHANGE_APP_STATUS: "CHANGE_APP_STATUS",
|
CHANGE_APP_STATUS: "CHANGE_APP_STATUS",
|
||||||
SEND_BROADCAST: "SEND_BROADCAST",
|
SEND_BROADCAST: "SEND_BROADCAST",
|
||||||
CHANGE_LOCAL_SETTINGS: "CHANGE_LOCAL_SETTINGS",
|
CHANGE_LOCAL_SETTINGS: "CHANGE_LOCAL_SETTINGS",
|
||||||
GNB_OPENED: "GNB_OPENED", SET_SHOW_POPUP: "SET_SHOW_POPUP",
|
GNB_OPENED: "GNB_OPENED",
|
||||||
|
SET_SHOW_POPUP: "SET_SHOW_POPUP",
|
||||||
SET_SHOW_SECONDARY_POPUP: "SET_SHOW_SECONDARY_POPUP",
|
SET_SHOW_SECONDARY_POPUP: "SET_SHOW_SECONDARY_POPUP",
|
||||||
SET_HIDE_POPUP: "SET_HIDE_POPUP",
|
SET_HIDE_POPUP: "SET_HIDE_POPUP",
|
||||||
SET_HIDE_SECONDARY_POPUP: "SET_HIDE_SECONDARY_POPUP",
|
SET_HIDE_SECONDARY_POPUP: "SET_HIDE_SECONDARY_POPUP",
|
||||||
@@ -154,6 +155,7 @@ export const types = {
|
|||||||
GET_VIDEO_INDECATOR_FOCUS: "GET_VIDEO_INDECATOR_FOCUS",
|
GET_VIDEO_INDECATOR_FOCUS: "GET_VIDEO_INDECATOR_FOCUS",
|
||||||
GET_PRODUCT_OPTION_ID: "GET_PRODUCT_OPTION_ID",
|
GET_PRODUCT_OPTION_ID: "GET_PRODUCT_OPTION_ID",
|
||||||
CLEAR_PRODUCT_OPTIONS: "CLEAR_PRODUCT_OPTIONS",
|
CLEAR_PRODUCT_OPTIONS: "CLEAR_PRODUCT_OPTIONS",
|
||||||
|
GET_USER_REVIEW: "GET_USER_REVIEW",
|
||||||
|
|
||||||
// search actions
|
// search actions
|
||||||
GET_SEARCH: "GET_SEARCH",
|
GET_SEARCH: "GET_SEARCH",
|
||||||
@@ -245,4 +247,21 @@ export const types = {
|
|||||||
|
|
||||||
// 🔽 [추가] 영구재생 비디오 정보 저장
|
// 🔽 [추가] 영구재생 비디오 정보 저장
|
||||||
SET_PERSISTENT_VIDEO_INFO: "SET_PERSISTENT_VIDEO_INFO",
|
SET_PERSISTENT_VIDEO_INFO: "SET_PERSISTENT_VIDEO_INFO",
|
||||||
|
|
||||||
|
// 🔽 [추가] 배너 비디오 제어 액션 타입
|
||||||
|
/**
|
||||||
|
* HomeBanner의 배너 간 비디오 재생 제어를 위한 액션 타입들.
|
||||||
|
* 첫 번째 배너 상시 재생과 두 번째 배너 포커스 재생을 관리합니다.
|
||||||
|
*/
|
||||||
|
SET_BANNER_STATE: "SET_BANNER_STATE",
|
||||||
|
SET_BANNER_FOCUS: "SET_BANNER_FOCUS",
|
||||||
|
SET_BANNER_AVAILABILITY: "SET_BANNER_AVAILABILITY",
|
||||||
|
SET_BANNER_TRANSITION: "SET_BANNER_TRANSITION",
|
||||||
|
PAUSE_PLAYER_CONTROL: "PAUSE_PLAYER_CONTROL",
|
||||||
|
RESUME_PLAYER_CONTROL: "RESUME_PLAYER_CONTROL",
|
||||||
|
// 🔽 [추가] HomeBanner 동영상 포커스 정책 관리
|
||||||
|
SET_CURRENT_FOCUS_BANNER: "SET_CURRENT_FOCUS_BANNER",
|
||||||
|
UPDATE_VIDEO_POLICY: "UPDATE_VIDEO_POLICY",
|
||||||
|
SET_MODAL_BORDER: "SET_MODAL_BORDER",
|
||||||
|
SET_BANNER_VISIBILITY: "SET_BANNER_VISIBILITY",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,30 +2,78 @@ import { URLS } from "../api/apiConfig";
|
|||||||
import { TAxios } from "../api/TAxios";
|
import { TAxios } from "../api/TAxios";
|
||||||
import { types } from "./actionTypes";
|
import { types } from "./actionTypes";
|
||||||
import { changeAppStatus } from "./commonActions";
|
import { changeAppStatus } from "./commonActions";
|
||||||
|
import { reduce, set, get } from "../utils/fp";
|
||||||
|
|
||||||
|
// CustomerImages용 리뷰 이미지 import
|
||||||
|
import reviewSampleImage from "../../assets/images/image-review-sample-1.png";
|
||||||
|
|
||||||
// Best Seller 상품 목록 조회 IF-LGSP-303
|
// Best Seller 상품 목록 조회 IF-LGSP-303
|
||||||
|
// FP helpers
|
||||||
|
const pickParams = (keys) => (src) =>
|
||||||
|
reduce(
|
||||||
|
(acc, key) =>
|
||||||
|
src && src[key] !== null && src[key] !== undefined
|
||||||
|
? set(key, src[key], acc)
|
||||||
|
: acc,
|
||||||
|
{},
|
||||||
|
keys
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generic request thunk factory
|
||||||
|
const createRequestThunk = ({
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
type,
|
||||||
|
params = () => ({}),
|
||||||
|
data = () => ({}),
|
||||||
|
tag,
|
||||||
|
onSuccessExtra = () => (dispatch, getState, response) => {},
|
||||||
|
onFailExtra = () => (dispatch, getState, error) => {},
|
||||||
|
selectPayload = (response) => get("data.data", response),
|
||||||
|
}) =>
|
||||||
|
(props) => (dispatch, getState) => {
|
||||||
|
const query = params(props);
|
||||||
|
const body = data(props);
|
||||||
|
|
||||||
|
const onSuccess = (response) => {
|
||||||
|
console.log(`${tag} onSuccess`, response.data);
|
||||||
|
dispatch({ type, payload: selectPayload(response) });
|
||||||
|
onSuccessExtra(props, dispatch, getState, response);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFail = (error) => {
|
||||||
|
console.error(`${tag} onFail`, error);
|
||||||
|
onFailExtra(props, dispatch, getState, error);
|
||||||
|
};
|
||||||
|
|
||||||
|
TAxios(
|
||||||
|
dispatch,
|
||||||
|
getState,
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
query,
|
||||||
|
body,
|
||||||
|
onSuccess,
|
||||||
|
onFail
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Specialization for GET
|
||||||
|
const createGetThunk = ({ url, type, params = () => ({}), tag }) =>
|
||||||
|
createRequestThunk({ method: "get", url, type, params, tag });
|
||||||
|
|
||||||
export const getBestSeller = (callback) => (dispatch, getState) => {
|
export const getBestSeller = (callback) => (dispatch, getState) => {
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("getBestSeller onSuccess", response.data);
|
console.log("getBestSeller onSuccess", response.data);
|
||||||
|
dispatch({ type: types.GET_BEST_SELLER, payload: get("data.data", response) });
|
||||||
dispatch({
|
|
||||||
type: types.GET_BEST_SELLER,
|
|
||||||
payload: response.data.data,
|
|
||||||
});
|
|
||||||
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
||||||
|
callback && callback();
|
||||||
if (callback) {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error("getBestSeller onFail", error);
|
console.error("getBestSeller onFail", error);
|
||||||
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
||||||
|
callback && callback();
|
||||||
if (callback) {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
TAxios(
|
TAxios(
|
||||||
@@ -41,62 +89,227 @@ export const getBestSeller = (callback) => (dispatch, getState) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Detail 옵션상품 정보 조회 IF-LGSP-319
|
// Detail 옵션상품 정보 조회 IF-LGSP-319
|
||||||
export const getProductGroup = (props) => (dispatch, getState) => {
|
export const getProductGroup = createGetThunk({
|
||||||
const { patnrId, prdtId } = props;
|
url: URLS.GET_PRODUCT_GROUP,
|
||||||
|
|
||||||
const onSuccess = (response) => {
|
|
||||||
console.log("getProductGroup onSuccess", response.data);
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: types.GET_PRODUCT_GROUP,
|
type: types.GET_PRODUCT_GROUP,
|
||||||
payload: response.data.data,
|
params: pickParams(["patnrId", "prdtId"]),
|
||||||
|
tag: "getProductGroup",
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const onFail = (error) => {
|
|
||||||
console.error("getProductGroup onFail", error);
|
|
||||||
};
|
|
||||||
|
|
||||||
TAxios(
|
|
||||||
dispatch,
|
|
||||||
getState,
|
|
||||||
"get",
|
|
||||||
URLS.GET_PRODUCT_GROUP,
|
|
||||||
{ patnrId, prdtId },
|
|
||||||
{},
|
|
||||||
onSuccess,
|
|
||||||
onFail
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Detail 옵션상품 정보 조회 IF-LGSP-320
|
// Detail 옵션상품 정보 조회 IF-LGSP-320
|
||||||
export const getProductOption = (props) => (dispatch, getState) => {
|
export const getProductOption = createGetThunk({
|
||||||
const { patnrId, prdtId } = props;
|
url: URLS.GET_PRODUCT_OPTION,
|
||||||
|
type: types.GET_PRODUCT_OPTION,
|
||||||
|
params: pickParams(["patnrId", "prdtId"]),
|
||||||
|
tag: "getProductOption",
|
||||||
|
});
|
||||||
|
|
||||||
|
// FP: 실제 API 응답에서 data 부분만 추출 (간소화)
|
||||||
|
const extractReviewApiData = (apiResponse) => {
|
||||||
|
try {
|
||||||
|
console.log("[UserReviews] 🔍 extractReviewApiData - 전체 API 응답 분석:", {
|
||||||
|
fullApiResponse: apiResponse,
|
||||||
|
hasData: !!(apiResponse && apiResponse.data),
|
||||||
|
retCode: apiResponse && apiResponse.retCode,
|
||||||
|
retMsg: apiResponse && apiResponse.retMsg,
|
||||||
|
responseKeys: apiResponse ? Object.keys(apiResponse) : [],
|
||||||
|
dataKeys: apiResponse && apiResponse.data ? Object.keys(apiResponse.data) : []
|
||||||
|
});
|
||||||
|
|
||||||
|
// 여러 가능한 데이터 경로 시도
|
||||||
|
let apiData = null;
|
||||||
|
|
||||||
|
// 1. response.data.data (중첩 구조)
|
||||||
|
if (apiResponse && apiResponse.data && apiResponse.data.data) {
|
||||||
|
apiData = apiResponse.data.data;
|
||||||
|
console.log("[UserReviews] ✅ 데이터 경로 1: response.data.data 사용");
|
||||||
|
}
|
||||||
|
// 2. response.data (단일 구조)
|
||||||
|
else if (apiResponse && apiResponse.data && (apiResponse.data.reviewList || apiResponse.data.reviewDetail)) {
|
||||||
|
apiData = apiResponse.data;
|
||||||
|
console.log("[UserReviews] ✅ 데이터 경로 2: response.data 사용");
|
||||||
|
}
|
||||||
|
// 3. response 직접 (최상위)
|
||||||
|
else if (apiResponse && (apiResponse.reviewList || apiResponse.reviewDetail)) {
|
||||||
|
apiData = apiResponse;
|
||||||
|
console.log("[UserReviews] ✅ 데이터 경로 3: response 직접 사용");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiData) {
|
||||||
|
console.warn("[UserReviews] ❌ 모든 데이터 경로에서 추출 실패:", {
|
||||||
|
hasResponseData: !!(apiResponse && apiResponse.data),
|
||||||
|
hasReviewList: !!(apiResponse && apiResponse.data && apiResponse.data.reviewList),
|
||||||
|
hasReviewDetail: !!(apiResponse && apiResponse.data && apiResponse.data.reviewDetail),
|
||||||
|
hasNestedData: !!(apiResponse && apiResponse.data && apiResponse.data.data),
|
||||||
|
fullResponse: apiResponse
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 추출된 데이터 검증
|
||||||
|
console.log("[UserReviews] 📊 추출된 데이터 검증:", {
|
||||||
|
hasReviewList: !!apiData.reviewList,
|
||||||
|
hasReviewDetail: !!apiData.reviewDetail,
|
||||||
|
reviewListLength: apiData.reviewList ? apiData.reviewList.length : 0,
|
||||||
|
reviewDetailKeys: apiData.reviewDetail ? Object.keys(apiData.reviewDetail) : [],
|
||||||
|
totRvwCnt: apiData.reviewDetail && apiData.reviewDetail.totRvwCnt,
|
||||||
|
totRvwAvg: apiData.reviewDetail && apiData.reviewDetail.totRvwAvg,
|
||||||
|
extractedData: apiData
|
||||||
|
});
|
||||||
|
|
||||||
|
return apiData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[UserReviews] ❌ extractReviewApiData 에러:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock 데이터 생성 함수 (재사용성 위해 분리)
|
||||||
|
const createMockReviewData = () => ({
|
||||||
|
reviewList: [
|
||||||
|
{
|
||||||
|
rvwId: "mock-review-1",
|
||||||
|
rvwRtng: 5,
|
||||||
|
rvwCtnt: "The shoes are really stylish and comfortable for daily wear. I love the design and how lightweight they feel. However, the size runs a bit small, so I'd recommend ordering half a size up.",
|
||||||
|
rvwRgstDtt: "2024-01-15",
|
||||||
|
reviewImageList: [
|
||||||
|
{
|
||||||
|
imgId: "mock-img-1",
|
||||||
|
imgUrl: reviewSampleImage,
|
||||||
|
imgSeq: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rvwId: "mock-review-2",
|
||||||
|
rvwRtng: 4,
|
||||||
|
rvwCtnt: "Great value for the price! The quality is better than I expected. Shipping was fast and the product arrived in perfect condition. Would definitely recommend to others.",
|
||||||
|
rvwRgstDtt: "2024-01-10",
|
||||||
|
reviewImageList: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
reviewDetail: {
|
||||||
|
totRvwCnt: 2,
|
||||||
|
totRvwAvg: 4.5
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 상품별 유저 리뷰 리스트 조회 : IF-LGSP-0002
|
||||||
|
export const getUserReviews = (requestParams) => (dispatch, getState) => {
|
||||||
|
const { prdtId } = requestParams;
|
||||||
|
|
||||||
|
console.log("[UserReviews] 🚀 getUserReviews 액션 시작:", {
|
||||||
|
requestParams,
|
||||||
|
originalPrdtId: prdtId,
|
||||||
|
willUseRandomPrdtId: true, // 임시 테스트 플래그
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== [임시 테스트] 시작 ====================
|
||||||
|
// 테스트용 prdtId 목록 - 제거 시 이 블록 전체 삭제
|
||||||
|
const testProductIds = [
|
||||||
|
"LCE3010SB",
|
||||||
|
"100QNED85AU",
|
||||||
|
"14Z90Q-K.ARW3U1",
|
||||||
|
"16Z90Q-K.AAC7U1",
|
||||||
|
"24GN600-B",
|
||||||
|
"50UT8000AUA",
|
||||||
|
"A949KTMS",
|
||||||
|
"AGF76631064",
|
||||||
|
"C5323B0",
|
||||||
|
"DLE3600V"
|
||||||
|
];
|
||||||
|
const randomIndex = Math.floor(Math.random() * testProductIds.length);
|
||||||
|
const randomPrdtId = testProductIds[randomIndex];
|
||||||
|
// ==================== [임시 테스트] 끝 ====================
|
||||||
|
|
||||||
|
// TAxios 파라미터 준비
|
||||||
|
const params = { prdtId: randomPrdtId }; // 임시: randomPrdtId 사용, 원본: prdtId 사용
|
||||||
|
const body = {}; // GET이므로 빈 객체
|
||||||
|
|
||||||
|
console.log("[UserReviews] 📡 TAxios 호출 준비:", {
|
||||||
|
method: "get",
|
||||||
|
url: URLS.GET_USER_REVEIW,
|
||||||
|
params,
|
||||||
|
body,
|
||||||
|
selectedRandomPrdtId: randomPrdtId, // 임시: 선택된 랜덤 상품 ID
|
||||||
|
});
|
||||||
|
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("getProductOption onSuccess", response.data);
|
console.log("[UserReviews] ✅ API 성공 응답:", {
|
||||||
|
status: response.status,
|
||||||
dispatch({
|
statusText: response.statusText,
|
||||||
type: types.GET_PRODUCT_OPTION,
|
headers: response.headers,
|
||||||
payload: response.data.data,
|
retCode: response.data && response.data.retCode,
|
||||||
|
retMsg: response.data && response.data.retMsg,
|
||||||
|
hasData: !!(response.data && response.data.data),
|
||||||
|
fullResponse: response.data
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.data) {
|
||||||
|
console.log("[UserReviews] 📊 API 데이터 상세:", {
|
||||||
|
reviewListLength: response.data.data.reviewList ? response.data.data.reviewList.length : 0,
|
||||||
|
reviewDetail: response.data.data.reviewDetail,
|
||||||
|
reviewList_sample: response.data.data.reviewList && response.data.data.reviewList[0] || "empty",
|
||||||
|
totRvwCnt: response.data.data.reviewDetail && response.data.data.reviewDetail.totRvwCnt,
|
||||||
|
totRvwAvg: response.data.data.reviewDetail && response.data.data.reviewDetail.totRvwAvg
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실제 API 응답에서 data 부분 추출
|
||||||
|
const apiData = extractReviewApiData(response.data);
|
||||||
|
|
||||||
|
if (apiData) {
|
||||||
|
console.log("[UserReviews] ✅ 실제 API 데이터 사용");
|
||||||
|
dispatch({
|
||||||
|
type: types.GET_USER_REVIEW,
|
||||||
|
payload: apiData
|
||||||
|
});
|
||||||
|
console.log("[UserReviews] 📦 실제 API 데이터 디스패치 완료:", apiData);
|
||||||
|
} else {
|
||||||
|
console.log("[UserReviews] ⚠️ API 데이터 추출 실패, Mock 데이터 사용");
|
||||||
|
const mockData = createMockReviewData();
|
||||||
|
dispatch({
|
||||||
|
type: types.GET_USER_REVIEW,
|
||||||
|
payload: mockData
|
||||||
|
});
|
||||||
|
console.log("[UserReviews] 📦 Mock 데이터 디스패치 완료:", mockData);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFail = (error) => {
|
const onFail = (error) => {
|
||||||
console.error("getProductOption onFail", error);
|
console.error("[UserReviews] ❌ API 실패:", {
|
||||||
|
message: error.message,
|
||||||
|
status: error.response && error.response.status,
|
||||||
|
statusText: error.response && error.response.statusText,
|
||||||
|
responseData: error.response && error.response.data,
|
||||||
|
requestParams: requestParams,
|
||||||
|
url: URLS.GET_USER_REVEIW,
|
||||||
|
fullError: error
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[UserReviews] 🔄 API 실패로 Mock 데이터 사용");
|
||||||
|
const mockData = createMockReviewData();
|
||||||
|
dispatch({
|
||||||
|
type: types.GET_USER_REVIEW,
|
||||||
|
payload: mockData
|
||||||
|
});
|
||||||
|
console.log("[UserReviews] 📦 Mock 데이터 디스패치 완료 (API 실패):", mockData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("[UserReviews] 🔗 TAxios 호출 실행 중...");
|
||||||
TAxios(
|
TAxios(
|
||||||
dispatch,
|
dispatch,
|
||||||
getState,
|
getState,
|
||||||
"get",
|
"get",
|
||||||
URLS.GET_PRODUCT_OPTION,
|
URLS.GET_USER_REVEIW,
|
||||||
{ patnrId, prdtId },
|
params,
|
||||||
{},
|
body,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onFail
|
onFail
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getProductOptionId = (id) => (dispatch) => {
|
export const getProductOptionId = (id) => (dispatch) => {
|
||||||
dispatch({ type: types.GET_PRODUCT_OPTION_ID, payload: id });
|
dispatch({ type: types.GET_PRODUCT_OPTION_ID, payload: id });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const URLS = {
|
|||||||
GET_PRODUCT_BESTSELLER: "/lgsp/v1/product/bestSeller.lge",
|
GET_PRODUCT_BESTSELLER: "/lgsp/v1/product/bestSeller.lge",
|
||||||
GET_PRODUCT_GROUP: "/lgsp/v1/product/group.lge",
|
GET_PRODUCT_GROUP: "/lgsp/v1/product/group.lge",
|
||||||
GET_PRODUCT_OPTION: "/lgsp/v1/product/option.lge",
|
GET_PRODUCT_OPTION: "/lgsp/v1/product/option.lge",
|
||||||
|
GET_USER_REVEIW: "/lgsp/v1/product/reviews.lge",
|
||||||
|
|
||||||
//my-page controller
|
//my-page controller
|
||||||
GET_MY_RECOMMANDED_KEYWORD: "/lgsp/v1/mypage/reckeyword.lge",
|
GET_MY_RECOMMANDED_KEYWORD: "/lgsp/v1/mypage/reckeyword.lge",
|
||||||
|
|||||||
@@ -1,49 +1,68 @@
|
|||||||
import { types } from "../actions/actionTypes";
|
import { types } from "../actions/actionTypes";
|
||||||
|
import { curry, get, set } from "../utils/fp";
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
bestSellerData: {},
|
bestSellerData: {},
|
||||||
productImageLength: 0,
|
productImageLength: 0,
|
||||||
prdtOptInfo: {},
|
prdtOptInfo: {},
|
||||||
|
reviewData: null, // 리뷰 데이터 추가
|
||||||
};
|
};
|
||||||
|
|
||||||
export const productReducer = (state = initialState, action) => {
|
// FP: handlers map (curried), pure and immutable updates only
|
||||||
switch (action.type) {
|
const handleBestSeller = curry((state, action) =>
|
||||||
case types.GET_BEST_SELLER:
|
set("bestSellerData", get("payload", action), state)
|
||||||
return {
|
);
|
||||||
...state,
|
|
||||||
bestSellerData: action.payload,
|
const handleProductOption = curry((state, action) =>
|
||||||
|
set("prdtOptInfo", get(["payload", "prdtOptInfo"], action), state)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleProductGroup = curry((state, action) =>
|
||||||
|
set("groupInfo", get(["payload", "groupInfo"], action), state)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleProductImageLength = curry((state, action) =>
|
||||||
|
set(
|
||||||
|
"productImageLength",
|
||||||
|
(get("payload", action) ? get("payload", action) : 0) + 1,
|
||||||
|
state
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleVideoIndicatorFocus = curry((state, action) =>
|
||||||
|
set("videoIndicatorFocus", get("payload", action), state)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClearProductDetail = curry((state) => set("prdtOptInfo", null, state));
|
||||||
|
|
||||||
|
const handleProductOptionId = curry((state, action) =>
|
||||||
|
set("prodOptCdCval", get("payload", action), state)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 유저 리뷰 데이터 핸들러 추가
|
||||||
|
const handleUserReview = curry((state, action) => {
|
||||||
|
const reviewData = get("payload", action);
|
||||||
|
console.log("[UserReviews] Reducer - Storing review data:", {
|
||||||
|
hasData: !!reviewData,
|
||||||
|
reviewListLength: reviewData && reviewData.reviewList ? reviewData.reviewList.length : 0,
|
||||||
|
totalCount: reviewData && reviewData.reviewDetail ? reviewData.reviewDetail.totRvwCnt : 0
|
||||||
|
});
|
||||||
|
return set("reviewData", reviewData, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
[types.GET_BEST_SELLER]: handleBestSeller,
|
||||||
|
[types.GET_PRODUCT_OPTION]: handleProductOption,
|
||||||
|
[types.GET_PRODUCT_GROUP]: handleProductGroup,
|
||||||
|
[types.GET_PRODUCT_IMAGE_LENGTH]: handleProductImageLength,
|
||||||
|
[types.GET_VIDEO_INDECATOR_FOCUS]: handleVideoIndicatorFocus,
|
||||||
|
[types.CLEAR_PRODUCT_DETAIL]: handleClearProductDetail,
|
||||||
|
[types.GET_PRODUCT_OPTION_ID]: handleProductOptionId,
|
||||||
|
[types.GET_USER_REVIEW]: handleUserReview, // GET_USER_REVIEW 핸들러 추가
|
||||||
};
|
};
|
||||||
case types.GET_PRODUCT_OPTION:
|
|
||||||
return {
|
export const productReducer = (state = initialState, action = {}) => {
|
||||||
...state,
|
const type = get("type", action);
|
||||||
prdtOptInfo: action.payload.prdtOptInfo,
|
const handler = handlers[type];
|
||||||
};
|
return handler ? handler(state, action) : state;
|
||||||
case types.GET_PRODUCT_GROUP:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
groupInfo: action.payload.groupInfo,
|
|
||||||
};
|
|
||||||
case types.GET_PRODUCT_IMAGE_LENGTH:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
productImageLength: action.payload + 1,
|
|
||||||
};
|
|
||||||
case types.GET_VIDEO_INDECATOR_FOCUS:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
videoIndicatorFocus: action.payload,
|
|
||||||
};
|
|
||||||
case types.CLEAR_PRODUCT_DETAIL:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
prdtOptInfo: null,
|
|
||||||
};
|
|
||||||
case types.GET_PRODUCT_OPTION_ID:
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
prodOptCdCval: action.payload,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,68 @@
|
|||||||
|
// src/utils/fp.js
|
||||||
// FP bootstrap: use locally-extended lodash instance
|
// FP bootstrap: use locally-extended lodash instance
|
||||||
// './lodash' already mixes in our custom extensions from './lodashFpEx'
|
// './lodash' already mixes in our custom extensions from './lodashFpEx'
|
||||||
import fp from './lodash';
|
import fp from './lodash';
|
||||||
|
|
||||||
export const {
|
// 🔽 [FIX] fp가 undefined일 경우를 대비한 기본값 제공
|
||||||
pipe, flow, curry, compose,
|
const safeFp = fp || {};
|
||||||
map, filter, reduce, get, set,
|
|
||||||
isEmpty, isNotEmpty, isNil, isNotNil,
|
|
||||||
mapAsync, reduceAsync, filterAsync,
|
|
||||||
} = fp;
|
|
||||||
|
|
||||||
export default fp;
|
export const {
|
||||||
|
// 기본 함수 조합
|
||||||
|
pipe, flow, curry, compose,
|
||||||
|
|
||||||
|
// 기본 컬렉션 함수들
|
||||||
|
map, filter, reduce, get, set,
|
||||||
|
|
||||||
|
// 기본 타입 체크 함수들
|
||||||
|
isEmpty, isNotEmpty, isNil, isNotNil,
|
||||||
|
|
||||||
|
// 비동기 함수들
|
||||||
|
mapAsync, reduceAsync, filterAsync, findAsync, forEachAsync,
|
||||||
|
|
||||||
|
// Promise 관련
|
||||||
|
promisify, then, andThen, otherwise, catch: catchFn, finally: finallyFn,
|
||||||
|
isPromise,
|
||||||
|
|
||||||
|
// 조건부 실행 함수들
|
||||||
|
when, unless, ifElse, ifT, ifF, ternary,
|
||||||
|
|
||||||
|
// 디버깅 및 사이드 이펙트
|
||||||
|
tap, trace,
|
||||||
|
|
||||||
|
// 안전한 실행
|
||||||
|
tryCatch, safeGet, getOr,
|
||||||
|
|
||||||
|
// 타입 체크 확장
|
||||||
|
isJson, notEquals, isNotEqual, isVal, isPrimitive, isRef, isReference,
|
||||||
|
not, notIncludes, toBool, isFalsy, isTruthy,
|
||||||
|
|
||||||
|
// 객체 변환
|
||||||
|
transformObjectKey, toCamelcase, toCamelKey, toSnakecase, toSnakeKey,
|
||||||
|
toPascalcase, pascalCase, renameKeys,
|
||||||
|
|
||||||
|
// 배열 유틸리티
|
||||||
|
mapWhen, filterWhen, removeByIndex, removeByIdx, removeLast,
|
||||||
|
append, prepend, insertAt, partition,
|
||||||
|
|
||||||
|
// 함수 조합 유틸리티
|
||||||
|
ap, applyTo, juxt, converge, instanceOf,
|
||||||
|
|
||||||
|
// 문자열 유틸리티
|
||||||
|
trimToUndefined, capitalize, isDatetimeString,
|
||||||
|
|
||||||
|
// 수학 유틸리티
|
||||||
|
clampTo, between,
|
||||||
|
|
||||||
|
// 기본값 처리
|
||||||
|
defaultTo, defaultWith, elvis,
|
||||||
|
|
||||||
|
// 키 관련
|
||||||
|
key, keyByVal,
|
||||||
|
mapWithKey, mapWithIdx, forEachWithKey, forEachWithIdx,
|
||||||
|
reduceWithKey, reduceWithIdx,
|
||||||
|
|
||||||
|
// 유틸리티
|
||||||
|
deepFreeze, times, lazy,
|
||||||
|
} = safeFp;
|
||||||
|
|
||||||
|
export default safeFp;
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
|
// src/utils/lodash.js
|
||||||
import fp from 'lodash/fp';
|
import fp from 'lodash/fp';
|
||||||
import fpEx from './lodashFpEx';
|
import fpEx from './lodashFpEx';
|
||||||
|
|
||||||
export default fp.mixin(fpEx);
|
// 🔽 [FIX] lodash/fp import 실패 시 기본값 제공
|
||||||
|
const safeFp = fp || {};
|
||||||
|
const safeFpEx = fpEx || {};
|
||||||
|
|
||||||
|
export default safeFp.mixin ? safeFp.mixin(safeFpEx) : safeFp;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// src/utils/lodashFpEx.js
|
||||||
import fp from 'lodash/fp';
|
import fp from 'lodash/fp';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,6 +130,7 @@ const filterAsync = fp.curry(async (asyncFilter, arr) => {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* (collection) fp.find의 비동기 함수
|
* (collection) fp.find의 비동기 함수
|
||||||
*/
|
*/
|
||||||
@@ -257,6 +259,7 @@ const toPascalcase = transformObjectKey(pascalCase);
|
|||||||
* @param {string} str date형식 문자열
|
* @param {string} str date형식 문자열
|
||||||
*/
|
*/
|
||||||
const isDatetimeString = (str) => isNaN(str) && !isNaN(Date.parse(str));
|
const isDatetimeString = (str) => isNaN(str) && !isNaN(Date.parse(str));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* applicative functor pattern 구현체
|
* applicative functor pattern 구현체
|
||||||
* (주로 fp.pipe함수에서 함수의 인자 순서를 변경하기 위해 사용)
|
* (주로 fp.pipe함수에서 함수의 인자 순서를 변경하기 위해 사용)
|
||||||
@@ -400,6 +403,255 @@ const getOr = (({ curry, getOr }) => {
|
|||||||
return _getOr;
|
return _getOr;
|
||||||
})(fp);
|
})(fp);
|
||||||
|
|
||||||
|
// ========== 새로 추가되는 함수들 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건이 참일 때만 함수를 실행, 거짓이면 원래 값 반환
|
||||||
|
* @param {Function} predicate 조건 함수
|
||||||
|
* @param {Function} fn 실행할 함수
|
||||||
|
* @param {*} value 대상 값
|
||||||
|
*/
|
||||||
|
const when = fp.curry((predicate, fn, value) =>
|
||||||
|
predicate(value) ? fn(value) : value
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건이 거짓일 때만 함수를 실행, 참이면 원래 값 반환
|
||||||
|
* @param {Function} predicate 조건 함수
|
||||||
|
* @param {Function} fn 실행할 함수
|
||||||
|
* @param {*} value 대상 값
|
||||||
|
*/
|
||||||
|
const unless = fp.curry((predicate, fn, value) =>
|
||||||
|
!predicate(value) ? fn(value) : value
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* if-else 조건부 실행
|
||||||
|
* @param {Function} predicate 조건 함수
|
||||||
|
* @param {Function} onTrue 참일 때 실행할 함수
|
||||||
|
* @param {Function} onFalse 거짓일 때 실행할 함수
|
||||||
|
* @param {*} value 대상 값
|
||||||
|
*/
|
||||||
|
const ifElse = fp.curry((predicate, onTrue, onFalse, value) =>
|
||||||
|
predicate(value) ? onTrue(value) : onFalse(value)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부수 효과를 위한 함수 (값을 변경하지 않고 함수 실행)
|
||||||
|
* @param {Function} fn 실행할 함수
|
||||||
|
* @param {*} value 대상 값
|
||||||
|
*/
|
||||||
|
const tap = fp.curry((fn, value) => {
|
||||||
|
fn(value);
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디버깅을 위한 trace 함수
|
||||||
|
* @param {string} label 라벨
|
||||||
|
* @param {*} value 대상 값
|
||||||
|
*/
|
||||||
|
const trace = fp.curry((label, value) => {
|
||||||
|
console.log(label, value);
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* try-catch를 함수형으로 처리
|
||||||
|
* @param {Function} tryFn 시도할 함수
|
||||||
|
* @param {Function} catchFn 에러 시 실행할 함수
|
||||||
|
* @param {*} value 대상 값
|
||||||
|
*/
|
||||||
|
const tryCatch = fp.curry((tryFn, catchFn, value) => {
|
||||||
|
try {
|
||||||
|
return tryFn(value);
|
||||||
|
} catch (error) {
|
||||||
|
return catchFn(error, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안전한 get 함수 (에러 시 기본값 반환)
|
||||||
|
* @param {string|Array} path 경로
|
||||||
|
* @param {*} defaultValue 기본값
|
||||||
|
* @param {Object} obj 대상 객체
|
||||||
|
*/
|
||||||
|
const safeGet = fp.curry((path, defaultValue, obj) => {
|
||||||
|
try {
|
||||||
|
return fp.get(path, obj) ?? defaultValue;
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건에 따른 map
|
||||||
|
* @param {Function} predicate 조건 함수
|
||||||
|
* @param {Function} fn 변환 함수
|
||||||
|
* @param {Array} array 대상 배열
|
||||||
|
*/
|
||||||
|
const mapWhen = fp.curry((predicate, fn, array) =>
|
||||||
|
array.map(item => predicate(item) ? fn(item) : item)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조건에 따른 filter
|
||||||
|
* @param {boolean} condition 조건
|
||||||
|
* @param {Function} predicate 필터 함수
|
||||||
|
* @param {Array} array 대상 배열
|
||||||
|
*/
|
||||||
|
const filterWhen = fp.curry((condition, predicate, array) =>
|
||||||
|
condition ? array.filter(predicate) : array
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 객체 키 이름 변경
|
||||||
|
* @param {Object} keyMap 키 매핑 객체
|
||||||
|
* @param {Object} obj 대상 객체
|
||||||
|
*/
|
||||||
|
const renameKeys = fp.curry((keyMap, obj) =>
|
||||||
|
fp.reduce((acc, [oldKey, newKey]) => {
|
||||||
|
if (fp.has(oldKey, obj)) {
|
||||||
|
acc[newKey] = obj[oldKey];
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {}, fp.toPairs(keyMap))
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배열에서 특정 인덱스에 요소 삽입
|
||||||
|
* @param {number} index 삽입할 인덱스
|
||||||
|
* @param {*} item 삽입할 요소
|
||||||
|
* @param {Array} array 대상 배열
|
||||||
|
*/
|
||||||
|
const insertAt = fp.curry((index, item, array) => [
|
||||||
|
...array.slice(0, index),
|
||||||
|
item,
|
||||||
|
...array.slice(index)
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 값을 첫 번째 인자로 받는 applicative
|
||||||
|
* @param {*} value 대상 값
|
||||||
|
* @param {Function} fn 실행할 함수
|
||||||
|
*/
|
||||||
|
const applyTo = fp.curry((value, fn) => fn(value));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 함수를 하나의 값에 적용하여 배열로 반환
|
||||||
|
* @param {Array} fns 함수 배열
|
||||||
|
* @param {*} value 대상 값
|
||||||
|
*/
|
||||||
|
const juxt = fp.curry((fns, value) => fns.map(fn => fn(value)));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 함수의 결과를 converge 함수로 조합
|
||||||
|
* @param {Function} convergeFn 조합 함수
|
||||||
|
* @param {Array} fns 함수 배열
|
||||||
|
* @param {*} value 대상 값
|
||||||
|
*/
|
||||||
|
const converge = fp.curry((convergeFn, fns, value) =>
|
||||||
|
convergeFn(...fns.map(fn => fn(value)))
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 문자열을 trim하고 빈 문자열이면 undefined 반환
|
||||||
|
* @param {string} str 대상 문자열
|
||||||
|
*/
|
||||||
|
const trimToUndefined = (str) => {
|
||||||
|
const trimmed = fp.trim(str);
|
||||||
|
return trimmed === '' ? undefined : trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 첫 글자 대문자, 나머지 소문자
|
||||||
|
* @param {string} str 대상 문자열
|
||||||
|
*/
|
||||||
|
const capitalize = (str) =>
|
||||||
|
str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 값을 min과 max 사이로 제한
|
||||||
|
* @param {number} min 최솟값
|
||||||
|
* @param {number} max 최댓값
|
||||||
|
* @param {number} value 대상 값
|
||||||
|
*/
|
||||||
|
const clampTo = fp.curry((min, max, value) =>
|
||||||
|
Math.min(Math.max(value, min), max)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 값이 min과 max 사이에 있는지 확인
|
||||||
|
* @param {number} min 최솟값
|
||||||
|
* @param {number} max 최댓값
|
||||||
|
* @param {number} value 대상 값
|
||||||
|
*/
|
||||||
|
const between = fp.curry((min, max, value) =>
|
||||||
|
value >= min && value <= max
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* null/undefined일 때 기본값 반환
|
||||||
|
* @param {*} defaultValue 기본값
|
||||||
|
* @param {*} value 대상 값
|
||||||
|
*/
|
||||||
|
const defaultTo = fp.curry((defaultValue, value) =>
|
||||||
|
value == null ? defaultValue : value
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elvis 연산자 구현 (null safe 함수 적용)
|
||||||
|
* @param {Function} fn 적용할 함수
|
||||||
|
* @param {*} value 대상 값
|
||||||
|
*/
|
||||||
|
const elvis = fp.curry((fn, value) =>
|
||||||
|
value == null ? undefined : fn(value)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배열을 조건에 따라 두 그룹으로 분할
|
||||||
|
* @param {Function} predicate 조건 함수
|
||||||
|
* @param {Array} array 대상 배열
|
||||||
|
*/
|
||||||
|
const partition = fp.curry((predicate, array) => [
|
||||||
|
array.filter(predicate),
|
||||||
|
array.filter(item => !predicate(item))
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* n번 함수를 실행하여 결과 배열 생성
|
||||||
|
* @param {Function} fn 실행할 함수
|
||||||
|
* @param {number} n 실행 횟수
|
||||||
|
*/
|
||||||
|
const times = fp.curry((fn, n) =>
|
||||||
|
Array.from({ length: n }, (_, i) => fn(i))
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 지연 평가 함수 (memoization과 유사)
|
||||||
|
* @param {Function} fn 지연 실행할 함수
|
||||||
|
*/
|
||||||
|
const lazy = (fn) => {
|
||||||
|
let cached = false;
|
||||||
|
let result;
|
||||||
|
return (...args) => {
|
||||||
|
if (!cached) {
|
||||||
|
result = fn(...args);
|
||||||
|
cached = true;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 함수가 아닌 값도 받을 수 있는 범용 기본값 함수
|
||||||
|
* @param {*} defaultValue 기본값 (함수 또는 값)
|
||||||
|
* @param {*} value 대상 값
|
||||||
|
*/
|
||||||
|
const defaultWith = fp.curry((defaultValue, value) =>
|
||||||
|
value == null ? (fp.isFunction(defaultValue) ? defaultValue() : defaultValue) : value
|
||||||
|
);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mapAsync,
|
mapAsync,
|
||||||
filterAsync,
|
filterAsync,
|
||||||
@@ -465,4 +717,30 @@ export default {
|
|||||||
isTruthy,
|
isTruthy,
|
||||||
|
|
||||||
getOr,
|
getOr,
|
||||||
|
|
||||||
|
// 새로 추가된 함수들
|
||||||
|
when,
|
||||||
|
unless,
|
||||||
|
ifElse,
|
||||||
|
tap,
|
||||||
|
trace,
|
||||||
|
tryCatch,
|
||||||
|
safeGet,
|
||||||
|
mapWhen,
|
||||||
|
filterWhen,
|
||||||
|
renameKeys,
|
||||||
|
insertAt,
|
||||||
|
applyTo,
|
||||||
|
juxt,
|
||||||
|
converge,
|
||||||
|
trimToUndefined,
|
||||||
|
capitalize,
|
||||||
|
clampTo,
|
||||||
|
between,
|
||||||
|
defaultTo,
|
||||||
|
defaultWith,
|
||||||
|
elvis,
|
||||||
|
partition,
|
||||||
|
times,
|
||||||
|
lazy,
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
|
||||||
|
import Spotlight from "@enact/spotlight";
|
||||||
|
import { setContainerLastFocusedElement } from "@enact/spotlight/src/container";
|
||||||
|
|
||||||
|
import {
|
||||||
|
changeAppStatus,
|
||||||
|
changeLocalSettings,
|
||||||
|
setHidePopup,
|
||||||
|
} from "../../actions/commonActions";
|
||||||
|
import { clearCouponInfo } from "../../actions/couponActions";
|
||||||
|
import { getDeviceAdditionInfo } from "../../actions/deviceActions";
|
||||||
|
import {
|
||||||
|
clearThemeDetail,
|
||||||
|
getThemeCurationDetailInfo,
|
||||||
|
getThemeHotelDetailInfo,
|
||||||
|
} from "../../actions/homeActions";
|
||||||
|
import {
|
||||||
|
getMainCategoryDetail,
|
||||||
|
getMainYouMayLike,
|
||||||
|
} from "../../actions/mainActions";
|
||||||
|
import { popPanel, updatePanel } from "../../actions/panelActions";
|
||||||
|
import { finishVideoPreview } from "../../actions/playActions";
|
||||||
|
import {
|
||||||
|
clearProductDetail,
|
||||||
|
getProductGroup,
|
||||||
|
getProductImageLength,
|
||||||
|
getProductOptionId,
|
||||||
|
} from "../../actions/productActions";
|
||||||
|
import MobileSendPopUp from "../../components/MobileSend/MobileSendPopUp";
|
||||||
|
import TBody from "../../components/TBody/TBody";
|
||||||
|
import THeader from "../../components/THeader/THeader";
|
||||||
|
import TPanel from "../../components/TPanel/TPanel";
|
||||||
|
import * as Config from "../../utils/Config";
|
||||||
|
import { panel_names } from "../../utils/Config";
|
||||||
|
import { $L, getQRCodeUrl } from "../../utils/helperMethods";
|
||||||
|
import css from "./DetailPanel.module.less";
|
||||||
|
import GroupProduct from "./GroupProduct/GroupProduct";
|
||||||
|
import SingleProduct from "./SingleProduct/SingleProduct";
|
||||||
|
import ThemeProduct from "./ThemeProduct/ThemeProduct";
|
||||||
|
import UnableProduct from "./UnableProduct/UnableProduct";
|
||||||
|
import YouMayLike from "./YouMayLike/YouMayLike";
|
||||||
|
|
||||||
|
export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const productData = useSelector((state) => state.main.productData);
|
||||||
|
const themeData = useSelector((state) => state.home.productData);
|
||||||
|
const hotelData = useSelector((state) => state.home.hotelData);
|
||||||
|
const isLoading = useSelector(
|
||||||
|
(state) => state.common.appStatus?.showLoadingPanel?.show
|
||||||
|
);
|
||||||
|
const themeProductInfos = useSelector(
|
||||||
|
(state) => state.home.themeCurationDetailInfoData
|
||||||
|
);
|
||||||
|
const hotelInfos = useSelector(
|
||||||
|
(state) => state.home.themeCurationHotelDetailData
|
||||||
|
);
|
||||||
|
const localRecentItems = useSelector(
|
||||||
|
(state) => state.localSettings?.recentItems
|
||||||
|
);
|
||||||
|
const youmaylikeData = useSelector((state) => state.main.youmaylikeData);
|
||||||
|
const { httpHeader } = useSelector((state) => state.common);
|
||||||
|
const deviceInfo = useSelector((state) => state.device.deviceInfo);
|
||||||
|
|
||||||
|
const serverHOST = useSelector((state) => state.common.appStatus.serverHOST);
|
||||||
|
const serverType = useSelector((state) => state.localSettings.serverType);
|
||||||
|
const { entryMenu, nowMenu } = useSelector((state) => state.common.menu);
|
||||||
|
const groupInfos = useSelector((state) => state.product.groupInfo);
|
||||||
|
const productInfo = useSelector((state) => state.main.productData);
|
||||||
|
const { popupVisible, activePopup } = useSelector(
|
||||||
|
(state) => state.common.popup
|
||||||
|
);
|
||||||
|
const webOSVersion = useSelector(
|
||||||
|
(state) => state.common.appStatus.webOSVersion
|
||||||
|
);
|
||||||
|
|
||||||
|
const panels = useSelector((state) => state.panels.panels);
|
||||||
|
const [lgCatCd, setLgCatCd] = useState("");
|
||||||
|
const [isYouMayLikeOpened, setIsYouMayLikeOpened] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
const shopByMobileLogRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(getProductOptionId(undefined));
|
||||||
|
if (panelInfo?.type === "hotel") {
|
||||||
|
dispatch(
|
||||||
|
getThemeHotelDetailInfo({
|
||||||
|
patnrId: panelInfo?.patnrId,
|
||||||
|
curationId: panelInfo?.curationId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panelInfo?.type === "theme") {
|
||||||
|
dispatch(
|
||||||
|
getThemeCurationDetailInfo({
|
||||||
|
patnrId: panelInfo?.patnrId,
|
||||||
|
curationId: panelInfo?.curationId,
|
||||||
|
bgImgNo: panelInfo?.bgImgNo,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panelInfo?.prdtId && !panelInfo?.curationId) {
|
||||||
|
dispatch(
|
||||||
|
getMainCategoryDetail({
|
||||||
|
patnrId: panelInfo?.patnrId,
|
||||||
|
prdtId: panelInfo?.prdtId,
|
||||||
|
liveReqFlag: panelInfo?.liveReqFlag ? panelInfo?.liveReqFlag : "N",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dispatch(getDeviceAdditionInfo());
|
||||||
|
}, [
|
||||||
|
dispatch,
|
||||||
|
panelInfo.liveReqFlag,
|
||||||
|
panelInfo.curationId,
|
||||||
|
panelInfo.prdtId,
|
||||||
|
panelInfo.type,
|
||||||
|
panelInfo.patnrId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lgCatCd) {
|
||||||
|
dispatch(
|
||||||
|
getMainYouMayLike({
|
||||||
|
lgCatCd: lgCatCd,
|
||||||
|
exclCurationId: panelInfo?.curationId,
|
||||||
|
exclPatnrId: panelInfo?.patnrId,
|
||||||
|
exclPrdtId: panelInfo?.prdtId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [panelInfo?.curationId, panelInfo?.patnrId, panelInfo?.prdtId, lgCatCd]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (productData?.pmtSuptYn === "Y" && productData?.grPrdtProcYn === "Y") {
|
||||||
|
dispatch(
|
||||||
|
getProductGroup({
|
||||||
|
patnrId: panelInfo?.patnrId,
|
||||||
|
prdtId: panelInfo?.prdtId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [productData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
themeProductInfos &&
|
||||||
|
themeProductInfos.length > 0 &&
|
||||||
|
panelInfo?.themePrdtId
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < themeProductInfos.length; i++) {
|
||||||
|
if (themeProductInfos[i].prdtId === panelInfo?.themePrdtId) {
|
||||||
|
setSelectedIndex(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hotelInfos && hotelInfos.length > 0 && panelInfo?.themeHotelId) {
|
||||||
|
for (let i = 0; i < hotelInfos.length; i++) {
|
||||||
|
if (hotelInfos[i].hotelId === panelInfo?.themeHotelId) {
|
||||||
|
setSelectedIndex(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
themeProductInfos,
|
||||||
|
hotelInfos,
|
||||||
|
panelInfo?.themePrdtId,
|
||||||
|
panelInfo?.themeHotelId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { detailUrl } = useMemo(() => {
|
||||||
|
return getQRCodeUrl({
|
||||||
|
serverHOST,
|
||||||
|
serverType,
|
||||||
|
index: deviceInfo?.dvcIndex,
|
||||||
|
patnrId: productInfo?.patnrId,
|
||||||
|
prdtId: productInfo?.prdtId,
|
||||||
|
entryMenu: entryMenu,
|
||||||
|
nowMenu: nowMenu,
|
||||||
|
liveFlag: "Y",
|
||||||
|
qrType: "billingDetail",
|
||||||
|
});
|
||||||
|
}, [serverHOST, serverType, deviceInfo, entryMenu, nowMenu, productInfo]);
|
||||||
|
|
||||||
|
const onSpotlightUpTButton = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Spotlight.focus("spotlightId_backBtn");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = useCallback(
|
||||||
|
(isCancelClick) => (ev) => {
|
||||||
|
dispatch(finishVideoPreview());
|
||||||
|
dispatch(popPanel(panel_names.DETAIL_PANEL));
|
||||||
|
|
||||||
|
if (panels.length === 4 && panels[1]?.name === panel_names.PLAYER_PANEL) {
|
||||||
|
dispatch(
|
||||||
|
updatePanel({
|
||||||
|
name: panel_names.PLAYER_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
thumbnail: panelInfo.thumbnailUrl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelClick) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch, panelInfo, panels]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSMSonClose = useCallback(() => {
|
||||||
|
dispatch(setHidePopup());
|
||||||
|
setTimeout(() => {
|
||||||
|
Spotlight.focus("spotlightId_backBtn");
|
||||||
|
Spotlight.focus("shopbymobile_Btn");
|
||||||
|
}, 0);
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const saveToLocalSettings = useCallback(() => {
|
||||||
|
let recentItems = [];
|
||||||
|
const prdtId =
|
||||||
|
themeProductInfos &&
|
||||||
|
themeProductInfos.length > 0 &&
|
||||||
|
panelInfo?.type === "theme"
|
||||||
|
? themeProductInfos[selectedIndex].prdtId
|
||||||
|
: panelInfo?.prdtId;
|
||||||
|
|
||||||
|
if (localRecentItems) {
|
||||||
|
recentItems = [...localRecentItems];
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDate = new Date();
|
||||||
|
|
||||||
|
const formattedDate = `${
|
||||||
|
currentDate.getMonth() + 1
|
||||||
|
}/${currentDate.getDate()}`;
|
||||||
|
|
||||||
|
const existingProductIndex = recentItems.findIndex((item) => {
|
||||||
|
return item.prdtId === prdtId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingProductIndex !== -1) {
|
||||||
|
recentItems.splice(existingProductIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
recentItems.push({
|
||||||
|
prdtId: prdtId,
|
||||||
|
patnrId: panelInfo?.patnrId,
|
||||||
|
date: formattedDate,
|
||||||
|
expireTime: currentDate.getTime() + 1000 * 60 * 60 * 24 * 14,
|
||||||
|
cntryCd: httpHeader["X-Device-Country"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recentItems.length >= 51) {
|
||||||
|
const data = [...recentItems];
|
||||||
|
dispatch(changeLocalSettings({ recentItems: data.slice(1) }));
|
||||||
|
} else {
|
||||||
|
dispatch(changeLocalSettings({ recentItems }));
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
panelInfo?.prdtId,
|
||||||
|
httpHeader,
|
||||||
|
localRecentItems,
|
||||||
|
dispatch,
|
||||||
|
selectedIndex,
|
||||||
|
themeProductInfos,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getlgCatCd = useCallback(() => {
|
||||||
|
if (productData && !panelInfo?.curationId) {
|
||||||
|
setLgCatCd(productData.catCd);
|
||||||
|
} else if (
|
||||||
|
themeProductInfos &&
|
||||||
|
themeProductInfos[selectedIndex]?.pmtSuptYn === "N" &&
|
||||||
|
panelInfo?.curationId
|
||||||
|
) {
|
||||||
|
setLgCatCd(themeProductInfos[selectedIndex]?.catCd);
|
||||||
|
} else {
|
||||||
|
setLgCatCd("");
|
||||||
|
}
|
||||||
|
}, [productData, themeProductInfos, selectedIndex, panelInfo?.curationId]);
|
||||||
|
|
||||||
|
const mobileSendPopUpProductImg = useMemo(() => {
|
||||||
|
if (panelInfo?.type === "theme" && themeProductInfos) {
|
||||||
|
return themeProductInfos[selectedIndex]?.imgUrls600[0];
|
||||||
|
} else if (panelInfo?.type === "hotel" && hotelInfos) {
|
||||||
|
return hotelInfos[selectedIndex]?.hotelImgUrl;
|
||||||
|
} else {
|
||||||
|
return productData?.imgUrls600[0];
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
themeProductInfos,
|
||||||
|
hotelInfos,
|
||||||
|
selectedIndex,
|
||||||
|
productData,
|
||||||
|
panelInfo?.type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mobileSendPopUpSubtitle = useMemo(() => {
|
||||||
|
if (panelInfo?.type === "theme" && themeProductInfos) {
|
||||||
|
return themeProductInfos[selectedIndex]?.prdtNm;
|
||||||
|
} else if (panelInfo?.type === "hotel" && hotelInfos) {
|
||||||
|
return hotelInfos[selectedIndex]?.hotelNm;
|
||||||
|
} else {
|
||||||
|
return productData?.prdtNm;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
themeProductInfos,
|
||||||
|
hotelInfos,
|
||||||
|
selectedIndex,
|
||||||
|
productData,
|
||||||
|
panelInfo?.type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isBillingProductVisible = useMemo(() => {
|
||||||
|
return (
|
||||||
|
productData?.pmtSuptYn === "Y" &&
|
||||||
|
productData?.grPrdtProcYn === "N" &&
|
||||||
|
panelInfo?.prdtId &&
|
||||||
|
webOSVersion >= "6.0"
|
||||||
|
);
|
||||||
|
}, [productData, webOSVersion, panelInfo?.prdtId]);
|
||||||
|
|
||||||
|
const isUnavailableProductVisible = useMemo(() => {
|
||||||
|
return (
|
||||||
|
productData?.pmtSuptYn === "N" ||
|
||||||
|
(productData?.pmtSuptYn === "Y" &&
|
||||||
|
productData?.grPrdtProcYn === "N" &&
|
||||||
|
webOSVersion < "6.0" &&
|
||||||
|
panelInfo?.prdtId)
|
||||||
|
);
|
||||||
|
}, [productData, webOSVersion, panelInfo?.prdtId]);
|
||||||
|
|
||||||
|
const isGroupProductVisible = useMemo(() => {
|
||||||
|
return (
|
||||||
|
productData?.pmtSuptYn === "Y" &&
|
||||||
|
productData?.grPrdtProcYn === "Y" &&
|
||||||
|
groupInfos &&
|
||||||
|
groupInfos.length > 0
|
||||||
|
);
|
||||||
|
}, [productData, groupInfos]);
|
||||||
|
|
||||||
|
const isTravelProductVisible = useMemo(() => {
|
||||||
|
return panelInfo?.curationId && (hotelInfos || themeData);
|
||||||
|
}, [panelInfo?.curationId, hotelInfos, themeData]);
|
||||||
|
|
||||||
|
const Price = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hotelInfos[selectedIndex]?.hotelDetailInfo.currencySign}
|
||||||
|
{hotelInfos[selectedIndex]?.hotelDetailInfo.price}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getlgCatCd();
|
||||||
|
}, [themeProductInfos, productData, panelInfo, selectedIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (panelInfo && panelInfo?.patnrId && panelInfo?.prdtId) {
|
||||||
|
saveToLocalSettings();
|
||||||
|
}
|
||||||
|
}, [panelInfo, selectedIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
((themeProductInfos && themeProductInfos.length > 0) ||
|
||||||
|
(hotelInfos && hotelInfos.length > 0)) &&
|
||||||
|
selectedIndex !== undefined
|
||||||
|
) {
|
||||||
|
if (panelInfo?.type === "theme") {
|
||||||
|
const imgUrls600 = themeProductInfos[selectedIndex]?.imgUrls600 || [];
|
||||||
|
dispatch(getProductImageLength({ imageLength: imgUrls600.length }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (panelInfo?.type === "hotel") {
|
||||||
|
const imgUrls600 = hotelInfos[selectedIndex]?.imgUrls600 || [];
|
||||||
|
dispatch(getProductImageLength({ imageLength: imgUrls600.length }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [dispatch, themeProductInfos, selectedIndex, hotelInfos]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
dispatch(clearProductDetail());
|
||||||
|
dispatch(clearThemeDetail());
|
||||||
|
dispatch(clearCouponInfo());
|
||||||
|
setContainerLastFocusedElement(null, ["indicator-GridListContainer"]);
|
||||||
|
};
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TPanel
|
||||||
|
isTabActivated={false}
|
||||||
|
className={css.detailPanelWrap}
|
||||||
|
handleCancel={onClick(true)}
|
||||||
|
spotlightId={spotlightId}
|
||||||
|
>
|
||||||
|
<THeader
|
||||||
|
className={css.header}
|
||||||
|
title={
|
||||||
|
(panelInfo?.prdtId && productData?.prdtNm) ||
|
||||||
|
(panelInfo?.type === "hotel" && hotelData?.hotelInfo.curationNm) ||
|
||||||
|
(panelInfo?.type === "theme" && themeData?.themeInfo[0]?.curationNm)
|
||||||
|
}
|
||||||
|
onBackButton
|
||||||
|
onClick={onClick(false)}
|
||||||
|
spotlightDisabled={isLoading}
|
||||||
|
onSpotlightUp={onSpotlightUpTButton}
|
||||||
|
onSpotlightLeft={onSpotlightUpTButton}
|
||||||
|
marqueeDisabled={false}
|
||||||
|
ariaLabel={
|
||||||
|
(panelInfo?.prdtId && productData?.prdtNm) ||
|
||||||
|
(panelInfo?.type === "hotel" && hotelData?.hotelInfo.curationNm) ||
|
||||||
|
(panelInfo?.type === "theme" && themeData?.themeInfo[0]?.curationNm)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TBody
|
||||||
|
className={css.tbody}
|
||||||
|
scrollable={false}
|
||||||
|
spotlightDisabled={isLoading}
|
||||||
|
isDefaultContainer
|
||||||
|
>
|
||||||
|
{!isLoading && (
|
||||||
|
<>
|
||||||
|
{/* 결제가능상품 영역 */}
|
||||||
|
{isBillingProductVisible && (
|
||||||
|
<SingleProduct
|
||||||
|
isOnTop={isOnTop}
|
||||||
|
panelInfo={panelInfo}
|
||||||
|
selectedPatnrId={panelInfo?.patnrId}
|
||||||
|
selectedPrdtId={panelInfo?.prdtId}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
setSelectedIndex={setSelectedIndex}
|
||||||
|
shopByMobileLogRef={shopByMobileLogRef}
|
||||||
|
isYouMayLikeOpened={isYouMayLikeOpened}
|
||||||
|
isBillingProductVisible={isBillingProductVisible}
|
||||||
|
detailQRCodeUrl={detailUrl}
|
||||||
|
logMenu={Config.LOG_MENU.DETAIL_PAGE_BILLING_PRODUCT_DETAIL}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* 구매불가상품 영역 */}
|
||||||
|
{isUnavailableProductVisible && (
|
||||||
|
<UnableProduct
|
||||||
|
isOnTop={isOnTop}
|
||||||
|
panelInfo={panelInfo}
|
||||||
|
selectedPatnrId={panelInfo?.patnrId}
|
||||||
|
selectedPrdtId={panelInfo?.prdtId}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
setSelectedIndex={setSelectedIndex}
|
||||||
|
shopByMobileLogRef={shopByMobileLogRef}
|
||||||
|
isYouMayLikeOpened={isYouMayLikeOpened}
|
||||||
|
logMenu={Config.LOG_MENU.DETAIL_PAGE_PRODUCT_DETAIL}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* 그룹상품 영역 */}
|
||||||
|
{isGroupProductVisible && (
|
||||||
|
<GroupProduct
|
||||||
|
isOnTop={isOnTop}
|
||||||
|
panelInfo={panelInfo}
|
||||||
|
selectedPatnrId={panelInfo?.patnrId}
|
||||||
|
selectedPrdtId={panelInfo?.prdtId}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
setSelectedIndex={setSelectedIndex}
|
||||||
|
shopByMobileLogRef={shopByMobileLogRef}
|
||||||
|
isYouMayLikeOpened={isYouMayLikeOpened}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* 테마그룹상품 영역*/}
|
||||||
|
{isTravelProductVisible && (
|
||||||
|
<ThemeProduct
|
||||||
|
isOnTop={isOnTop}
|
||||||
|
panelInfo={panelInfo}
|
||||||
|
selectedCurationId={panelInfo?.curationId}
|
||||||
|
selectedCurationNm={panelInfo?.curationNm}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
selectedPatnrId={panelInfo?.patnrId}
|
||||||
|
setSelectedIndex={setSelectedIndex}
|
||||||
|
themeType={panelInfo?.type}
|
||||||
|
isLoading={isLoading}
|
||||||
|
shopByMobileLogRef={shopByMobileLogRef}
|
||||||
|
isYouMayLikeOpened={isYouMayLikeOpened}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TBody>
|
||||||
|
</TPanel>
|
||||||
|
{lgCatCd && youmaylikeData && youmaylikeData.length > 0 && isOnTop && (
|
||||||
|
<YouMayLike
|
||||||
|
isUnable={
|
||||||
|
productData?.pmtSuptYn === "N" ||
|
||||||
|
panelInfo?.type === "hotel" ||
|
||||||
|
panelInfo?.type === "theme"
|
||||||
|
}
|
||||||
|
panelInfo={panelInfo}
|
||||||
|
setSelectedIndex={setSelectedIndex}
|
||||||
|
productInfo={productData || themeData}
|
||||||
|
setIsYouMayLikeOpened={setIsYouMayLikeOpened}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activePopup === Config.ACTIVE_POPUP.smsPopup && (
|
||||||
|
<MobileSendPopUp
|
||||||
|
open={popupVisible}
|
||||||
|
onClose={handleSMSonClose}
|
||||||
|
title={$L("Send a purchase link for this item via SMS")}
|
||||||
|
subTitle={mobileSendPopUpSubtitle}
|
||||||
|
patncNm={productData?.patncNm}
|
||||||
|
productImg={mobileSendPopUpProductImg}
|
||||||
|
patnrId={panelInfo?.patnrId}
|
||||||
|
prdtId={panelInfo?.prdtId}
|
||||||
|
smsTpCd={panelInfo?.type === "hotel" ? "APP00205" : "APP00201"}
|
||||||
|
curationId={panelInfo?.curationId}
|
||||||
|
curationNm={panelInfo?.curationNm}
|
||||||
|
hotelId={
|
||||||
|
panelInfo?.type === "hotel" && hotelInfos[selectedIndex]?.hotelId
|
||||||
|
}
|
||||||
|
hotelNm={
|
||||||
|
panelInfo?.type === "hotel" && hotelInfos[selectedIndex]?.hotelNm
|
||||||
|
}
|
||||||
|
hotelDtlUrl={
|
||||||
|
panelInfo?.type === "hotel" &&
|
||||||
|
hotelInfos[selectedIndex]?.hotelDetailInfo?.hotelDtlUrl
|
||||||
|
}
|
||||||
|
productPrice={panelInfo?.type === "hotel" && Price()}
|
||||||
|
shopByMobileLogRef={shopByMobileLogRef}
|
||||||
|
spotlightId="shopbymobile_Btn"
|
||||||
|
smsText={productInfo?.pmtSuptYn === "Y" ? detailUrl : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
@import "../../style/CommonStyle.module.less";
|
||||||
|
@import "../../style/utils.module.less";
|
||||||
|
|
||||||
|
.detailPanelWrap {
|
||||||
|
background: @COLOR_WHITE;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
> div {
|
||||||
|
font-weight: bold !important;
|
||||||
|
font-size: 30px !important;
|
||||||
|
.elip(@clamp: 1);
|
||||||
|
padding-left: 12px !important;
|
||||||
|
text-transform: none !important;
|
||||||
|
letter-spacing: 0 !important;
|
||||||
|
}
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 90px;
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
align-items: center;
|
||||||
|
color: #333333;
|
||||||
|
padding: 0 0 0 60px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
// > button {
|
||||||
|
// &:focus {
|
||||||
|
// &::after {
|
||||||
|
// .focused(@boxShadow: 22px, @borderRadius:0px);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbody {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
padding-left: 120px;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,17 @@
|
|||||||
@import "../../style/utils.module.less";
|
@import "../../style/utils.module.less";
|
||||||
|
|
||||||
.detailPanelWrap {
|
.detailPanelWrap {
|
||||||
background: @COLOR_WHITE;
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(
|
||||||
|
270deg,
|
||||||
|
rgba(0, 0, 0, 0) 43.07%,
|
||||||
|
rgba(0, 0, 0, 0.539) 73.73%,
|
||||||
|
rgba(0, 0, 0, 0.7) 100%
|
||||||
|
),
|
||||||
|
linear-gradient(0deg, rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.4));
|
||||||
|
// var(--bg-url);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
@@ -14,12 +24,17 @@
|
|||||||
text-transform: none !important;
|
text-transform: none !important;
|
||||||
letter-spacing: 0 !important;
|
letter-spacing: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> button {
|
||||||
|
background-image: url("../../../assets/images/btn/btn-60-wh-back-nor@3x.png");
|
||||||
|
}
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 90px;
|
height: 60px;
|
||||||
background-color: #f2f2f2;
|
background-color: transparent;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: #333333;
|
color: rgba(238, 238, 238, 1);
|
||||||
padding: 0 0 0 60px;
|
padding: 0 0 0 60px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
@@ -37,5 +52,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
padding-left: 120px;
|
// padding-left: 120px;
|
||||||
|
|
||||||
|
.detailArea {
|
||||||
|
.flex();
|
||||||
|
padding-left: -60px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
/* eslint-disable react/jsx-no-bind */
|
||||||
|
// src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
|
||||||
|
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
||||||
|
import React, { useCallback, useRef, useState, useMemo, useEffect } from "react";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import Spotlight from "@enact/spotlight";
|
||||||
|
import { PropTypes } from "prop-types";
|
||||||
|
|
||||||
|
// ProductInfoSection imports
|
||||||
|
import TButton from "../../../components/TButton/TButton";
|
||||||
|
import { $L } from "../../../utils/helperMethods";
|
||||||
|
import {
|
||||||
|
curry, pipe, when, isVal, isNotNil, defaultTo, defaultWith, get, identity, isEmpty, isNil, andThen, tap
|
||||||
|
} from "../../../utils/fp";
|
||||||
|
import FavoriteBtn from "../components/FavoriteBtn";
|
||||||
|
import StarRating from "../components/StarRating";
|
||||||
|
import ProductTag from "../components/ProductTag";
|
||||||
|
import DetailMobileSendPopUp from "../components/DetailMobileSendPopUp";
|
||||||
|
import { SpotlightIds } from "../../../utils/SpotlightIds";
|
||||||
|
import QRCode from "../ProductInfoSection/QRCode/QRCode";
|
||||||
|
import ProductOverview from "../ProductOverview/ProductOverview";
|
||||||
|
|
||||||
|
// ProductContentSection imports
|
||||||
|
import TScrollerDetail from "../components/TScroller/TScrollerDetail";
|
||||||
|
import CustomScrollbar from "../components/CustomScrollbar/CustomScrollbar";
|
||||||
|
import useScrollTo from "../../../hooks/useScrollTo";
|
||||||
|
import ProductDetail from "../ProductContentSection/ProductDetail/ProductDetail.new";
|
||||||
|
import UserReviews from "../ProductContentSection/UserReviews/UserReviews";
|
||||||
|
import YouMayAlsoLike from "../ProductContentSection/YouMayAlsoLike/YouMayAlsoLike";
|
||||||
|
import ProductDescription from "../ProductContentSection/ProductDescription/ProductDescription";
|
||||||
|
|
||||||
|
// CSS imports
|
||||||
|
// import infoCSS from "../ProductInfoSection/ProductInfoSection.module.less";
|
||||||
|
// import contentCSS from "../ProductContentSection/ProductContentSection.module.less";
|
||||||
|
import css from "./ProductAllSection.module.less";
|
||||||
|
|
||||||
|
const Container = SpotlightContainerDecorator(
|
||||||
|
{
|
||||||
|
enterTo: "last-focused",
|
||||||
|
preserveld: true,
|
||||||
|
leaveFor: { right: "content-scroller-container" },
|
||||||
|
spotlightDirection: "vertical"
|
||||||
|
},
|
||||||
|
"div",
|
||||||
|
);
|
||||||
|
|
||||||
|
const ContentContainer = SpotlightContainerDecorator(
|
||||||
|
{
|
||||||
|
enterTo: "default-element",
|
||||||
|
preserveld: true,
|
||||||
|
leaveFor: {
|
||||||
|
left: "spotlight-product-info-section-container"
|
||||||
|
},
|
||||||
|
restrict: "none",
|
||||||
|
spotlightDirection: "vertical"
|
||||||
|
},
|
||||||
|
"div",
|
||||||
|
);
|
||||||
|
|
||||||
|
const HorizontalContainer = SpotlightContainerDecorator(
|
||||||
|
{
|
||||||
|
enterTo: "last-focused",
|
||||||
|
preserveld: true,
|
||||||
|
defaultElement: "spotlight-product-info-section-container",
|
||||||
|
spotlightDirection: "horizontal"
|
||||||
|
},
|
||||||
|
"div",
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// FP: Pure function to determine product data based on type
|
||||||
|
const getProductData = curry((productType, themeProductInfo, productInfo) =>
|
||||||
|
pipe(
|
||||||
|
when(
|
||||||
|
() => isVal(productType) && productType === "theme" && isVal(themeProductInfo),
|
||||||
|
() => themeProductInfo
|
||||||
|
),
|
||||||
|
defaultTo(productInfo),
|
||||||
|
defaultTo({}) // 빈 객체라도 반환하여 컴포넌트가 렌더링되도록 함
|
||||||
|
)(productInfo)
|
||||||
|
);
|
||||||
|
|
||||||
|
// FP: Pure function to derive favorite flag
|
||||||
|
const deriveFavoriteFlag = curry((favoriteOverride, productData) =>
|
||||||
|
pipe(
|
||||||
|
when(isNotNil, identity),
|
||||||
|
defaultWith(() =>
|
||||||
|
pipe(
|
||||||
|
get("favorYn"),
|
||||||
|
defaultTo("N")
|
||||||
|
)(productData)
|
||||||
|
)
|
||||||
|
)(favoriteOverride)
|
||||||
|
);
|
||||||
|
|
||||||
|
// FP: Pure function to extract review grade and order phone
|
||||||
|
const extractProductMeta = (productInfo) => ({
|
||||||
|
revwGrd: get("revwGrd", productInfo),
|
||||||
|
orderPhnNo: get("orderPhnNo", productInfo)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 레이아웃 확인용 샘플 컴포넌트
|
||||||
|
const LayoutSample = () => (
|
||||||
|
<div style={{
|
||||||
|
width: '1124px', // 1114px + 10 px
|
||||||
|
height: '300px',
|
||||||
|
backgroundColor: 'yellow',
|
||||||
|
marginBottom: '20px', // 다른 컴포넌트와의 구분을 위한 하단 마진
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: 'black',
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}>
|
||||||
|
Layout Sample (1124px x 300px)
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function ProductAllSection({
|
||||||
|
productType,
|
||||||
|
productInfo,
|
||||||
|
panelInfo,
|
||||||
|
selectedIndex,
|
||||||
|
selectedPatnrId,
|
||||||
|
selectedPrdtId,
|
||||||
|
setSelectedIndex,
|
||||||
|
openThemeItemOverlay,
|
||||||
|
setOpenThemeItemOverlay,
|
||||||
|
themeProductInfo,
|
||||||
|
}) {
|
||||||
|
const productData = useMemo(() =>
|
||||||
|
getProductData(productType, themeProductInfo, productInfo),
|
||||||
|
[productType, themeProductInfo, productInfo]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 디버깅: 실제 이미지 데이터 확인
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[ProductAllSection] Image data check:", {
|
||||||
|
hasProductData: !!productData,
|
||||||
|
imgUrls600: productData?.imgUrls600,
|
||||||
|
imgUrls600Length: productData?.imgUrls600?.length,
|
||||||
|
imgUrls600Type: Array.isArray(productData?.imgUrls600) ? 'array' : typeof productData?.imgUrls600
|
||||||
|
});
|
||||||
|
}, [productData]);
|
||||||
|
|
||||||
|
const { revwGrd, orderPhnNo } = useMemo(() =>
|
||||||
|
extractProductMeta(productInfo),
|
||||||
|
[productInfo]
|
||||||
|
);
|
||||||
|
|
||||||
|
// FP: derive favorite flag from props with local override, avoid non-I/O useEffect
|
||||||
|
const [favoriteOverride, setFavoriteOverride] = useState(null);
|
||||||
|
const favoriteFlag = useMemo(() =>
|
||||||
|
deriveFavoriteFlag(favoriteOverride, productData),
|
||||||
|
[favoriteOverride, productData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [mobileSendPopupOpen, setMobileSendPopupOpen] = useState(false);
|
||||||
|
|
||||||
|
// 🔧 [임시] 고객 데모용: UserReviews 버튼은 숨기고 UserReviews 섹션만 표시
|
||||||
|
const showUserReviewsButton = false; // 임시 변경 - 버튼 숨김
|
||||||
|
const showUserReviewsSection = true; // 임시 변경 - 섹션은 항상 표시
|
||||||
|
|
||||||
|
const reviewTotalCount = useSelector(pipe(
|
||||||
|
get(["product", "reviewData", "reviewDetail", "totRvwCnt"]),
|
||||||
|
defaultTo(0)
|
||||||
|
));
|
||||||
|
|
||||||
|
// User Reviews 스크롤 핸들러 추가
|
||||||
|
const handleUserReviewsClick = useCallback(
|
||||||
|
() => scrollToSection("scroll-marker-user-reviews"),
|
||||||
|
[scrollToSection]
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollContainerRef = useRef(null);
|
||||||
|
const { getScrollTo, scrollTop } = useScrollTo();
|
||||||
|
|
||||||
|
|
||||||
|
// FP: Pure function for mobile popup state change
|
||||||
|
const handleShopByMobileOpen = useCallback(
|
||||||
|
pipe(
|
||||||
|
() => true,
|
||||||
|
setMobileSendPopupOpen
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// FP: Pure function for focus navigation to back button
|
||||||
|
const handleSpotlightUpToBackButton = useCallback((e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Spotlight.focus("spotlightId_backBtn");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// FP: Pure function for favorite flag change
|
||||||
|
const onFavoriteFlagChanged = useCallback(
|
||||||
|
(newFavoriteFlag) => setFavoriteOverride(newFavoriteFlag),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// FP: Pure function for theme item button click with side effects
|
||||||
|
const handleThemeItemButtonClick = useCallback(
|
||||||
|
pipe(
|
||||||
|
() => setOpenThemeItemOverlay(true),
|
||||||
|
tap(() => setTimeout(() => Spotlight.focus("theme-close-button"), 0))
|
||||||
|
),
|
||||||
|
[setOpenThemeItemOverlay]
|
||||||
|
);
|
||||||
|
|
||||||
|
// FP: Pure function for scroll to section with early returns handled functionally
|
||||||
|
const scrollToSection = curry((sectionId) =>
|
||||||
|
pipe(
|
||||||
|
when(isEmpty, () => null),
|
||||||
|
andThen(() => document.getElementById(sectionId)),
|
||||||
|
when(isNil, () => null),
|
||||||
|
andThen((targetElement) => {
|
||||||
|
const targetRect = targetElement.getBoundingClientRect();
|
||||||
|
const y = targetRect.top;
|
||||||
|
return scrollTop({ y, animate: true });
|
||||||
|
})
|
||||||
|
)(sectionId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// FP: Curried scroll handlers
|
||||||
|
const handleProductDetailsClick = useCallback(
|
||||||
|
() => scrollToSection("scroll-marker-product-details"),
|
||||||
|
[scrollToSection]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleYouMayAlsoLikeClick = useCallback(
|
||||||
|
() => scrollToSection("scroll-marker-you-may-also-like"),
|
||||||
|
[scrollToSection]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HorizontalContainer className={css.detailArea}>
|
||||||
|
{/* Left Margin Section - 60px */}
|
||||||
|
<div className={css.leftMarginSection}></div>
|
||||||
|
|
||||||
|
{/* Info Section - 645px */}
|
||||||
|
<div className={css.infoSection}>
|
||||||
|
<Container
|
||||||
|
className={css.leftInfoContainer}
|
||||||
|
spotlightId="spotlight-product-info-section-container"
|
||||||
|
>
|
||||||
|
{productType && productData && (
|
||||||
|
<div className={css.leftInfoWrapper}>
|
||||||
|
<div className={css.headerContent}>
|
||||||
|
<ProductTag productInfo={productData} />
|
||||||
|
{revwGrd && (
|
||||||
|
<StarRating
|
||||||
|
rating={revwGrd}
|
||||||
|
aria-label={"star rating " + revwGrd + " out of 5"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProductOverview
|
||||||
|
panelInfo={panelInfo}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
selectedPatnrId={selectedPatnrId}
|
||||||
|
selectedPrdtId={selectedPrdtId}
|
||||||
|
productInfo={productData}
|
||||||
|
productType={productType}
|
||||||
|
>
|
||||||
|
<div className={css.qrWrapper}>
|
||||||
|
<QRCode productInfo={productData} productType={productType} />
|
||||||
|
</div>
|
||||||
|
</ProductOverview>
|
||||||
|
|
||||||
|
<Container className={css.buttonContainer}>
|
||||||
|
<TButton
|
||||||
|
spotlightId={SpotlightIds.DETAIL_SHOPBYMOBILE}
|
||||||
|
className={css.shopByMobileButton}
|
||||||
|
onClick={handleShopByMobileOpen}
|
||||||
|
onSpotlightUp={handleSpotlightUpToBackButton}
|
||||||
|
>
|
||||||
|
<div className={css.shopByMobileText}>
|
||||||
|
{$L("SHOP BY MOBILE")}
|
||||||
|
</div>
|
||||||
|
</TButton>
|
||||||
|
{panelInfo && (
|
||||||
|
<div className={css.favoriteBtnWrapper}>
|
||||||
|
<FavoriteBtn
|
||||||
|
className={css.favoriteBtn}
|
||||||
|
selectedPatnrId={panelInfo && panelInfo.patnrId}
|
||||||
|
selectedPrdtId={panelInfo && panelInfo.prdtId}
|
||||||
|
favoriteFlag={favoriteFlag}
|
||||||
|
onFavoriteFlagChanged={onFavoriteFlagChanged}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{orderPhnNo && (
|
||||||
|
<div className={css.callToOrderSection}>
|
||||||
|
<div className={css.callToOrderText}>
|
||||||
|
{$L("Call to Order")}
|
||||||
|
</div>
|
||||||
|
<div className={css.phoneSection}>
|
||||||
|
<div className={css.phoneIconContainer}>
|
||||||
|
<div className={css.phoneIcon} />
|
||||||
|
</div>
|
||||||
|
<div className={css.phoneNumber}>{orderPhnNo}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Container
|
||||||
|
className={css.actionButtonsWrapper}
|
||||||
|
spotlightId="product-info-button-container"
|
||||||
|
>
|
||||||
|
<TButton
|
||||||
|
className={css.productDetailsButton}
|
||||||
|
onClick={handleProductDetailsClick}
|
||||||
|
spotlightId="product-details-button"
|
||||||
|
>
|
||||||
|
{$L("PRODUCT DETAILS")}
|
||||||
|
</TButton>
|
||||||
|
{/* 🔧 [임시] 고객 데모용 조건 변경: showUserReviewsButton (원래: reviewTotalCount > 0) */}
|
||||||
|
{showUserReviewsButton && (
|
||||||
|
<TButton
|
||||||
|
className={css.userReviewsButton}
|
||||||
|
onClick={handleUserReviewsClick}
|
||||||
|
spotlightId="user-reviews-button"
|
||||||
|
>
|
||||||
|
{$L(
|
||||||
|
`USER REVIEWS (${reviewTotalCount > 100 ? "100" : reviewTotalCount || "0"})`,
|
||||||
|
)}
|
||||||
|
</TButton>
|
||||||
|
)}
|
||||||
|
<TButton
|
||||||
|
className={css.youMayLikeButton}
|
||||||
|
onClick={handleYouMayAlsoLikeClick}
|
||||||
|
>
|
||||||
|
{$L("YOU MAY ALSO LIKE")}
|
||||||
|
</TButton>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{panelInfo &&
|
||||||
|
panelInfo && panelInfo.type === "theme" &&
|
||||||
|
!openThemeItemOverlay && (
|
||||||
|
<TButton
|
||||||
|
className={css.themeButton}
|
||||||
|
onClick={handleThemeItemButtonClick}
|
||||||
|
spotlightId="theme-open-button"
|
||||||
|
>
|
||||||
|
{$L("THEME ITEM")}
|
||||||
|
</TButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DetailMobileSendPopUp
|
||||||
|
ismobileSendPopupOpen={mobileSendPopupOpen}
|
||||||
|
setMobileSendPopupOpen={setMobileSendPopupOpen}
|
||||||
|
panelInfo={panelInfo}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Section - 1114px */}
|
||||||
|
<div className={css.contentSection}>
|
||||||
|
<ContentContainer
|
||||||
|
className={css.rightContentContainer}
|
||||||
|
spotlightId="content-scroller-container"
|
||||||
|
>
|
||||||
|
<div className={css.scrollerWrapper}>
|
||||||
|
<TScrollerDetail
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
verticalScrollbar="visible"
|
||||||
|
focusableScrollbar={true}
|
||||||
|
className={css.scrollerOverride}
|
||||||
|
cbScrollTo={getScrollTo}
|
||||||
|
spotlightId="main-content-scroller"
|
||||||
|
spotlightDisabled={false}
|
||||||
|
spotlightRestrict="none"
|
||||||
|
>
|
||||||
|
<div className={css.productDetail}>
|
||||||
|
<div
|
||||||
|
id="scroll-marker-product-details"
|
||||||
|
className={css.scrollMarker}
|
||||||
|
></div>
|
||||||
|
<LayoutSample />
|
||||||
|
<div id="product-details-section">
|
||||||
|
{productData?.imgUrls600 && productData.imgUrls600.length > 0 ? (
|
||||||
|
productData.imgUrls600.map((image, index) => (
|
||||||
|
<ProductDetail
|
||||||
|
key={`product-detail-${index}`}
|
||||||
|
productInfo={{
|
||||||
|
...productData,
|
||||||
|
singleImage: image,
|
||||||
|
imageIndex: index,
|
||||||
|
totalImages: productData.imgUrls600.length
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<ProductDetail productInfo={productData} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div id="product-description-section">
|
||||||
|
<ProductDescription productInfo={productData} />
|
||||||
|
</div>
|
||||||
|
<div id="scroll-marker-user-reviews" className={css.scrollMarker}></div>
|
||||||
|
{/* Description 바로 아래에 UserReviews 항상 표시 (조건 제거) */}
|
||||||
|
<div id="user-reviews-section">
|
||||||
|
<UserReviews productInfo={productData} panelInfo={panelInfo} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="scroll-marker-you-may-also-like"
|
||||||
|
className={css.scrollMarker}
|
||||||
|
></div>
|
||||||
|
<div id="you-may-also-like-section">
|
||||||
|
<YouMayAlsoLike productInfo={productData} panelInfo={panelInfo} />
|
||||||
|
</div>
|
||||||
|
</TScrollerDetail>
|
||||||
|
</div>
|
||||||
|
</ContentContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</HorizontalContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProductAllSection.propTypes = {
|
||||||
|
productType: PropTypes.oneOf(["buyNow", "shopByMobile", "theme"]).isRequired,
|
||||||
|
};
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
@import "../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../style/utils.module.less";
|
||||||
|
|
||||||
|
// 1920px 화면 기준 전체 구조 (절대 위치로 정확히 배치)
|
||||||
|
.detailArea {
|
||||||
|
width: 1920px; // 명시적으로 화면 크기 설정
|
||||||
|
height: 100%;
|
||||||
|
padding: 0; // 모든 패딩 제거
|
||||||
|
margin: 0; // 모든 마진 제거
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
position: relative; // 절대 위치 기준점
|
||||||
|
|
||||||
|
// Spotlight 좌우 이동을 위한 설정
|
||||||
|
&:focus-within {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Left Margin Section - 60px
|
||||||
|
.leftMarginSection {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 60px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Info Section - 645px
|
||||||
|
.infoSection {
|
||||||
|
position: absolute;
|
||||||
|
left: 60px;
|
||||||
|
top: 0;
|
||||||
|
width: 645px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Content Section - 1180px (1114px 콘텐츠 + 66px 스크롤바)
|
||||||
|
.contentSection {
|
||||||
|
position: absolute;
|
||||||
|
left: 705px; // 60px + 645px
|
||||||
|
top: 0;
|
||||||
|
width: 1200px; // 30px 마진 + 1114px 콘텐츠 + 66px 스크롤바
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Scroll Section - 66px (삭제 - contentSection에 포함)
|
||||||
|
.scrollSection {
|
||||||
|
display: none; // 사용하지 않음
|
||||||
|
}
|
||||||
|
|
||||||
|
// 왼쪽 영역 컨테이너 (infoSection 내부)
|
||||||
|
.leftInfoContainer {
|
||||||
|
width: 635px; // 실제 콘텐츠 영역
|
||||||
|
margin-right: 10px; // 우측 10px 간격
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 왼쪽 영역 내부 래퍼
|
||||||
|
.leftInfoWrapper {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
// gap 대신 margin 사용 (Chromium 68 호환성)
|
||||||
|
> * {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
&:last-child { margin-bottom: 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오른쪽 영역 컨테이너 (contentSection 내부)
|
||||||
|
.rightContentContainer {
|
||||||
|
width: 1210px; // 30px 마진 + 1114px 콘텐츠 + 66px 스크롤바
|
||||||
|
height: @globalHeight - 136px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
// 스크롤러 래퍼 (contentSection 내부)
|
||||||
|
.scrollerWrapper {
|
||||||
|
width: 1210px; // 30px 마진 + 1114px 콘텐츠 + 66px 스크롤바 (절대값)
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: visible; // hidden에서 visible로 변경
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
position: relative; // 자식 absolute 요소의 기준점
|
||||||
|
|
||||||
|
|
||||||
|
// 스크롤러 오버라이드 (1210px = 30px + content + 스크롤바)
|
||||||
|
.scrollerOverride {
|
||||||
|
width: 1210px; // 절대 크기 지정
|
||||||
|
height: 100%;
|
||||||
|
// 좌측 30px, 우측 66px(스크롤바) 패딩을 명시적으로 적용
|
||||||
|
padding: 0 10px 0 30px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
// 스크롤바 너비를 6px로 명확하게 설정
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent; // 트랙 배경은 투명하게
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #9C9C9C; // 스크롤바 색상
|
||||||
|
border-radius: 3px; // 스크롤바 둥근 모서리
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크롤바 thumb에 hover 효과 적용
|
||||||
|
&:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: #C72054;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 내부 콘텐츠는 별도 너비 계산 없이 100%를 사용
|
||||||
|
> div {
|
||||||
|
width: 100%; // 부모의 패딩을 제외한 나머지 공간(1114px)을 모두 사용
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크롤 콘텐츠 내부의 모든 섹션들 (패딩 제거 - scrollerOverride에서 처리함)
|
||||||
|
#product-details-section {
|
||||||
|
width: 1124px; // 부모 콘텐츠 영역 전체 사용
|
||||||
|
max-width: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: visible;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
// ProductDetail.new 컴포넌트들
|
||||||
|
> div[class*="rollingWrap"] {
|
||||||
|
width: 100% !important; // 부모 영역 전체 사용
|
||||||
|
max-width: none !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#product-description-section,
|
||||||
|
#user-reviews-section,
|
||||||
|
#you-may-also-like-section {
|
||||||
|
width: 100%; // 부모 콘텐츠 영역 전체 사용
|
||||||
|
max-width: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
max-width: 100% !important; // 부모 영역 전체 사용
|
||||||
|
width: 100% !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미지들이 컨테이너를 넘지 않도록
|
||||||
|
img {
|
||||||
|
max-width: 100% !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상품 상세 영역 (마진 포함 크기)
|
||||||
|
.productDetail {
|
||||||
|
width: 1124px; // 1114px + 10px
|
||||||
|
max-width: 1124px;
|
||||||
|
height: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch; // 자식 요소들이 전체 너비 사용하도록
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크롤 마커
|
||||||
|
.scrollMarker {
|
||||||
|
height: 1px;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
} // .scrollerWrapper
|
||||||
|
} // .rightContentContainer
|
||||||
|
|
||||||
|
// (중복 제거됨) .scrollerWrapper는 .rightContentContainer 하위로 중첩 이동
|
||||||
|
|
||||||
|
// (중복 제거됨) 최상위 스크롤러/섹션 정의는 .scrollerWrapper 중첩 내부로 이동
|
||||||
|
|
||||||
|
|
||||||
|
// ProductDetailCard 스타일 참고 - 크기/간격만 적용
|
||||||
|
|
||||||
|
// 헤더 컨텐츠 영역 (태그, 별점)
|
||||||
|
.headerContent {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모바일 쇼핑 섹션 (mobileSection 참고)
|
||||||
|
.buttonContainer {
|
||||||
|
align-self: stretch;
|
||||||
|
padding-top: 19px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-right: 6px;
|
||||||
|
&:last-child { margin-right: 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopByMobileButton {
|
||||||
|
flex: 1 1 0 !important;
|
||||||
|
width: auto !important; // flex로 크기 조정
|
||||||
|
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;
|
||||||
|
|
||||||
|
.shopByMobileText {
|
||||||
|
color: white !important;
|
||||||
|
font-size: 25px !important;
|
||||||
|
font-family: @baseFont !important; // LG Smart 폰트 사용
|
||||||
|
font-weight: 400 !important; // Bold에서 Regular로 변경
|
||||||
|
line-height: 35px !important;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 포커스 상태 추가
|
||||||
|
&:focus {
|
||||||
|
background: @PRIMARY_COLOR_RED !important; // 포커스시 빨간색 배경
|
||||||
|
outline: 2px solid @PRIMARY_COLOR_RED !important;
|
||||||
|
|
||||||
|
.shopByMobileText {
|
||||||
|
color: white !important; // 포커스시에도 텍스트는 흰색 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.favoriteBtnWrapper {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
// 배경색과 라운드는 FavoriteBtn 내부에서 처리하므로 제거
|
||||||
|
}
|
||||||
|
|
||||||
|
// 주문 전화 섹션 (callToOrderSection 참고)
|
||||||
|
.callToOrderSection {
|
||||||
|
align-self: stretch;
|
||||||
|
height: 40px;
|
||||||
|
padding: 17px 30px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.callToOrderText {
|
||||||
|
color: #EAEAEA;
|
||||||
|
font-size: 25px;
|
||||||
|
font-family: @baseFont; // LG Smart 폰트 사용
|
||||||
|
font-weight: 400; // Bold에서 Regular로 변경
|
||||||
|
line-height: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phoneSection {
|
||||||
|
padding: 0 1px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-right: 10px;
|
||||||
|
&:last-child { margin-right: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.phoneIconContainer {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.phoneIcon {
|
||||||
|
width: 24.94px;
|
||||||
|
height: 24.97px;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
background: #EAEAEA;
|
||||||
|
// 전화 아이콘 이미지 또는 CSS로 구현
|
||||||
|
background-image: url("../../../../assets/images/icons/ic-gr-call-1.png");
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.phoneNumber {
|
||||||
|
color: #EAEAEA;
|
||||||
|
font-size: 25px;
|
||||||
|
font-family: 'LG Smart UI';
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 35px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 액션 버튼들 (actionButtons 참고)
|
||||||
|
.actionButtonsWrapper {
|
||||||
|
align-self: stretch;
|
||||||
|
padding-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
&:last-child { margin-bottom: 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 버튼 기본 스타일 (PRODUCT DETAILS는 빨간색 아님!)
|
||||||
|
.productDetailsButton,
|
||||||
|
.userReviewsButton,
|
||||||
|
.youMayLikeButton {
|
||||||
|
align-self: stretch;
|
||||||
|
height: 60px;
|
||||||
|
background: rgba(255, 255, 255, 0.05); // 기본 회색 배경
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
color: #EAEAEA;
|
||||||
|
font-size: 25px;
|
||||||
|
font-family: @baseFont; // LG Smart 폰트 사용
|
||||||
|
font-weight: 400; // Bold에서 Regular로 변경
|
||||||
|
line-height: 35px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: #C72054; // 포커스시만 빨간색
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeButton {
|
||||||
|
align-self: stretch;
|
||||||
|
height: 60px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: #C72054;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QR 래퍼 (imageSection 참고 - 240px 고정)
|
||||||
|
.qrWrapper {
|
||||||
|
width: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
&:last-child { margin-bottom: 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductOverview 컨테이너 스타일 수정 (자식 요소에 맞게 크기 조정)
|
||||||
|
[class*="ProductOverview"] {
|
||||||
|
align-self: stretch;
|
||||||
|
padding: 0 0 5px; // ProductDetailCard mainContent와 동일한 패딩
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
// 내부 div (productInfoWrapper)
|
||||||
|
> div {
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-right: 15px; // ProductDetailCard와 동일한 간격
|
||||||
|
&:last-child { margin-right: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 가격 섹션 (flex로 남은 공간 차지)
|
||||||
|
> div:first-child {
|
||||||
|
flex: 1 1 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미지 섹션 (QR 포함, 고정 크기)
|
||||||
|
> div:last-child {
|
||||||
|
width: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
&:last-child { margin-bottom: 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import css from "./ProductDescription.module.less";
|
||||||
|
import { $L, removeSpecificTags } from "../../../../utils/helperMethods";
|
||||||
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
|
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
||||||
|
import Spotlight from "@enact/spotlight";
|
||||||
|
// TVerticalPagenator 제거됨 - TScrollerNew와 충돌 문제로 인해
|
||||||
|
|
||||||
|
const SpottableComponent = Spottable("div");
|
||||||
|
|
||||||
|
const Container = SpotlightContainerDecorator(
|
||||||
|
{
|
||||||
|
enterTo: "default-element",
|
||||||
|
leaveFor: {
|
||||||
|
left: "spotlight-product-info-section-container"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"div"
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function ProductDescription({ productInfo }) {
|
||||||
|
const productDescription = useCallback(() => {
|
||||||
|
const sanitizedString = removeSpecificTags(productInfo?.prdtDesc);
|
||||||
|
return { __html: sanitizedString };
|
||||||
|
}, [productInfo?.prdtDesc]);
|
||||||
|
|
||||||
|
// 왼쪽 화살표 키 이벤트 처리
|
||||||
|
const handleKeyDown = useCallback((ev) => {
|
||||||
|
if (ev.keyCode === 37) { // 왼쪽 화살표 키
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
console.log("[ProductDescription] Left arrow pressed, focusing product-details-button");
|
||||||
|
Spotlight.focus("product-details-button");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ProductDescription: Container 직접 사용 패턴
|
||||||
|
// prdtDesc가 없으면 렌더링하지 않음
|
||||||
|
if (!productInfo?.prdtDesc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
className={css.descriptionContainer}
|
||||||
|
spotlightId="product-description-container"
|
||||||
|
>
|
||||||
|
{/* <SpottableComponent
|
||||||
|
className={css.titleWrapper}
|
||||||
|
spotlightId="product-description-title"
|
||||||
|
onClick={() => console.log("[ProductDescription] Title clicked")}
|
||||||
|
onFocus={() => console.log("[ProductDescription] Title focused")}
|
||||||
|
onBlur={() => console.log("[ProductDescription] Title blurred")}
|
||||||
|
> */}
|
||||||
|
<div className={css.titleWrapper}>
|
||||||
|
<div className={css.title}>{$L("DESCRIPTION")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* </SpottableComponent> */}
|
||||||
|
|
||||||
|
<SpottableComponent
|
||||||
|
className={css.descriptionWrapper}
|
||||||
|
spotlightId="product-description-content"
|
||||||
|
onClick={() => console.log("[ProductDescription] Content clicked")}
|
||||||
|
onFocus={() => console.log("[ProductDescription] Content focused")}
|
||||||
|
onBlur={() => console.log("[ProductDescription] Content blurred")}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css.productDescription}
|
||||||
|
dangerouslySetInnerHTML={productDescription()}
|
||||||
|
/>
|
||||||
|
</SpottableComponent>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
@import "../../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.descriptionContainer {
|
||||||
|
position: relative;
|
||||||
|
top: -1px;
|
||||||
|
width: calc(100% - 5px);
|
||||||
|
margin-left: 5px;
|
||||||
|
max-width: none;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: rgba(234, 234, 234, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleWrapper {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
outline: 2px solid @PRIMARY_COLOR_RED;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 30px);
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 90px 0 20px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.descriptionWrapper {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 6px solid @PRIMARY_COLOR_RED;
|
||||||
|
outline-offset: 2px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.productDescription {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-height: calc(100% - 150px);
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 26px);
|
||||||
|
font-weight: 400;
|
||||||
|
padding: 30px;
|
||||||
|
background-color: rgba(51, 51, 51, 1);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"main": "ProductDescription.jsx",
|
||||||
|
"styles": [
|
||||||
|
"ProductDescription.module.less"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import css from "./ProductDetail.new.module.less";
|
||||||
|
import TVirtualGridList from "../../../../components/TVirtualGridList/TVirtualGridList";
|
||||||
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
|
import CustomImage from "../../../../components/CustomImage/CustomImage";
|
||||||
|
import indicatorDefaultImage from "../../../../../assets/images/img-thumb-empty-144@3x.png";
|
||||||
|
import useScrollTo from "../../../../hooks/useScrollTo";
|
||||||
|
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
||||||
|
// TVerticalPagenator 제거됨 - TScrollerNew와 충돌 문제로 인해
|
||||||
|
import { removeSpecificTags } from "../../../../utils/helperMethods";
|
||||||
|
import Spotlight from "@enact/spotlight";
|
||||||
|
|
||||||
|
const Container = SpotlightContainerDecorator(
|
||||||
|
{
|
||||||
|
enterTo: "last-focused",
|
||||||
|
preserveld: true,
|
||||||
|
leaveFor: {
|
||||||
|
left: "spotlight-product-info-section-container"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"div"
|
||||||
|
);
|
||||||
|
|
||||||
|
const SpottableComponent = Spottable("div");
|
||||||
|
|
||||||
|
export default function ProductDetail({ productInfo }) {
|
||||||
|
const { getScrollTo, scrollTop } = useScrollTo();
|
||||||
|
const imageRef = useRef(null);
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
|
// 단일 이미지 모드: singleImage가 있으면 그것만 사용, 없으면 기존 방식
|
||||||
|
const listImages = useMemo(() => {
|
||||||
|
// 단일 이미지 모드
|
||||||
|
if (productInfo?.singleImage) {
|
||||||
|
return [productInfo.singleImage];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 방식 (폴백)
|
||||||
|
const images = [...(productInfo?.imgUrls600 || [])];
|
||||||
|
if (images.length === 0) {
|
||||||
|
return [indicatorDefaultImage];
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
}, [productInfo?.singleImage, productInfo?.imgUrls600]);
|
||||||
|
|
||||||
|
// 메인 이미지 영역 포커스 핸들러
|
||||||
|
const onFocus = useCallback(() => {
|
||||||
|
const imageIndex = productInfo?.imageIndex ?? 0;
|
||||||
|
console.log(`[ProductDetail] Image ${imageIndex + 1} focused`);
|
||||||
|
}, [productInfo?.imageIndex]);
|
||||||
|
|
||||||
|
const onBlur = useCallback(() => {
|
||||||
|
const imageIndex = productInfo?.imageIndex ?? 0;
|
||||||
|
console.log(`[ProductDetail] Image ${imageIndex + 1} blurred`);
|
||||||
|
}, [productInfo?.imageIndex]);
|
||||||
|
|
||||||
|
// 단일 이미지 렌더링 (항상 하나의 이미지만)
|
||||||
|
const renderSingleImage = useCallback(() => {
|
||||||
|
const image = listImages[0] || indicatorDefaultImage;
|
||||||
|
const imageIndex = productInfo?.imageIndex ?? 0;
|
||||||
|
const totalImages = productInfo?.totalImages ?? listImages.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.thumbnailWrapper}>
|
||||||
|
<CustomImage
|
||||||
|
src={image}
|
||||||
|
alt={`Product image ${imageIndex + 1} of ${totalImages}`}
|
||||||
|
fallbackSrc={indicatorDefaultImage}
|
||||||
|
className={css.productImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [listImages, productInfo?.imageIndex, productInfo?.totalImages]);
|
||||||
|
|
||||||
|
const imageIndex = productInfo?.imageIndex ?? 0;
|
||||||
|
const totalImages = productInfo?.totalImages ?? listImages.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
ref={containerRef}
|
||||||
|
className={css.rollingWrap}
|
||||||
|
spotlightId={`product-detail-container-${imageIndex}`}
|
||||||
|
>
|
||||||
|
{/* 메인 이미지 영역 - 단일 이미지 표시 */}
|
||||||
|
<SpottableComponent
|
||||||
|
className={css.itemBox}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
spotlightId={`product-img-${imageIndex}`}
|
||||||
|
aria-label={`Product image ${imageIndex + 1} of ${totalImages}`}
|
||||||
|
>
|
||||||
|
{renderSingleImage()}
|
||||||
|
</SpottableComponent>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
@import "../../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../../style/utils.module.less";
|
||||||
|
|
||||||
|
// 단일 이미지용 ProductDetail 컨테이너 - 명확한 1054px 크기
|
||||||
|
.rollingWrap {
|
||||||
|
position: relative;
|
||||||
|
width: 1124px; // 고정 크기 (Description, UserReviews와 동일)
|
||||||
|
max-width: 1124px;
|
||||||
|
height: 680px; // 고정 높이로 각 이미지가 한 화면 차지
|
||||||
|
background-color: rgba(255, 255, 255, 1);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 30px; // 다음 ProductDetail과의 간격
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 6px; // 포커스 테두리(6px)를 위한 정확한 공간 확보
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0; // 마지막 이미지는 하단 마진 제거
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 이미지 영역 - 단일 이미지 중앙 배치
|
||||||
|
.itemBox {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
&::after {
|
||||||
|
.focused(@boxShadow: 22px, @borderRadius: 12px);
|
||||||
|
border: 6px solid @PRIMARY_COLOR_RED;
|
||||||
|
// 부모의 padding(6px)에 정확히 맞추기 위해 오프셋을 -6px로 설정
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
bottom: -6px;
|
||||||
|
left: -6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 화살표 버튼 스타일 (RollingUnit 패턴, 새로운 크기에 맞춰 조정)
|
||||||
|
.arrow {
|
||||||
|
z-index: 9999;
|
||||||
|
.size(@w: 42px, @h: 42px);
|
||||||
|
background-size: 42px 42px;
|
||||||
|
background-position: center center;
|
||||||
|
|
||||||
|
&.leftBtn {
|
||||||
|
.position(@position: absolute, @top: 349px, @left: 43px); // 높이 중앙: (740/2 - 21) = 349px, 좌측: 30px + 13px = 43px
|
||||||
|
background-image: url("../../../../../assets/images/btn/btn_prev_thumb_nor.png");
|
||||||
|
&:focus {
|
||||||
|
background-image: url("../../../../../assets/images/btn/btn_prev_thumb_foc.png");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.rightBtn {
|
||||||
|
.position(@position: absolute, @top: 349px, @right: 43px); // 높이 중앙: 349px, 우측: 30px + 13px = 43px
|
||||||
|
background-image: url("../../../../../assets/images/btn/btn_next_thumb_nor.png");
|
||||||
|
&:focus {
|
||||||
|
background-image: url("../../../../../assets/images/btn/btn_next_thumb_foc.png");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnailWrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%; // 부모 itemBox의 680px 전체 사용
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.productImage {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
object-fit: contain; // 비율 유지하며 컨테이너에 맞춤
|
||||||
|
background-color: @COLOR_WHITE;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import css from "./CustomerImages.module.less";
|
||||||
|
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
|
import THeader from "../../../../../components/THeader/THeader";
|
||||||
|
import { $L } from "../../../../../utils/helperMethods";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import Spotlight from "@enact/spotlight";
|
||||||
|
|
||||||
|
const Container = SpotlightContainerDecorator(
|
||||||
|
{
|
||||||
|
enterTo: "default-element",
|
||||||
|
preserveld: true,
|
||||||
|
leaveFor: {
|
||||||
|
left: "spotlight-product-info-section-container"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"div"
|
||||||
|
);
|
||||||
|
|
||||||
|
const SpottableComponent = Spottable("div");
|
||||||
|
|
||||||
|
export default function CustomerImages({ onImageClick }) {
|
||||||
|
// Redux에서 reviewData 전체를 가져옴
|
||||||
|
const reviewData = useSelector((state) => state.product.reviewData);
|
||||||
|
const reviewListData = reviewData?.reviewList;
|
||||||
|
|
||||||
|
const [imageList, setImageList] = useState([]);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const IMAGES_PER_PAGE = 5;
|
||||||
|
|
||||||
|
// [UserReviews] CustomerImages 데이터 수신 확인 - 개선된 로깅
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[UserReviews] CustomerImages - Full review data received:", {
|
||||||
|
reviewData,
|
||||||
|
reviewListData,
|
||||||
|
hasData: reviewData && reviewListData && reviewListData.length > 0,
|
||||||
|
reviewCount: reviewListData?.length || 0
|
||||||
|
});
|
||||||
|
}, [reviewData, reviewListData]);
|
||||||
|
|
||||||
|
// 이미지 데이터 처리 로직 개선
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[UserReviews] CustomerImages - Processing review data:", {
|
||||||
|
reviewListData,
|
||||||
|
reviewListType: Array.isArray(reviewListData) ? 'array' : typeof reviewListData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!reviewListData || !Array.isArray(reviewListData)) {
|
||||||
|
console.log("[UserReviews] CustomerImages - No valid review data available");
|
||||||
|
setImageList([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 리뷰의 구조를 자세히 로깅
|
||||||
|
reviewListData.forEach((review, index) => {
|
||||||
|
console.log(`[UserReviews] CustomerImages - Review ${index} structure:`, {
|
||||||
|
rvwId: review.rvwId,
|
||||||
|
hasReviewImageList: !!review.reviewImageList,
|
||||||
|
reviewImageListType: Array.isArray(review.reviewImageList) ? 'array' : typeof review.reviewImageList,
|
||||||
|
reviewImageListLength: review.reviewImageList?.length || 0,
|
||||||
|
reviewImageList: review.reviewImageList
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 이미지가 있는 리뷰만 필터링
|
||||||
|
const imageReviews = reviewListData.filter(
|
||||||
|
(review) => review.reviewImageList &&
|
||||||
|
Array.isArray(review.reviewImageList) &&
|
||||||
|
review.reviewImageList.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("[UserReviews] CustomerImages - Reviews with images:", {
|
||||||
|
totalReviews: reviewListData.length,
|
||||||
|
imageReviewsCount: imageReviews.length,
|
||||||
|
imageReviews: imageReviews
|
||||||
|
});
|
||||||
|
|
||||||
|
const images = [];
|
||||||
|
|
||||||
|
// 각 리뷰의 이미지들을 수집
|
||||||
|
imageReviews.forEach((review, reviewIndex) => {
|
||||||
|
console.log(`[UserReviews] CustomerImages - Processing review ${reviewIndex}:`, {
|
||||||
|
rvwId: review.rvwId,
|
||||||
|
imageCount: review.reviewImageList?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (review.reviewImageList && Array.isArray(review.reviewImageList)) {
|
||||||
|
review.reviewImageList.forEach((imgItem, imgIndex) => {
|
||||||
|
const { imgId, imgUrl, imgSeq } = imgItem;
|
||||||
|
console.log(`[UserReviews] CustomerImages - Adding image ${imgIndex}:`, {
|
||||||
|
imgId,
|
||||||
|
imgSeq,
|
||||||
|
imgUrl,
|
||||||
|
isValidUrl: !!imgUrl && imgUrl !== '',
|
||||||
|
urlType: typeof imgUrl,
|
||||||
|
urlLength: imgUrl?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// 유효한 이미지 URL만 추가
|
||||||
|
if (imgUrl && imgUrl.trim() !== '') {
|
||||||
|
images.push({
|
||||||
|
imgId: imgId || `img-${reviewIndex}-${imgIndex}`,
|
||||||
|
imgUrl,
|
||||||
|
imgSeq: imgSeq || imgIndex + 1,
|
||||||
|
reviewId: review.rvwId
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn(`[UserReviews] CustomerImages - Skipping invalid image URL:`, imgUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[UserReviews] CustomerImages - Final image list:", {
|
||||||
|
totalImages: images.length,
|
||||||
|
images: images
|
||||||
|
});
|
||||||
|
|
||||||
|
setImageList(images);
|
||||||
|
}, [reviewListData]);
|
||||||
|
|
||||||
|
// 이미지 목록이 변경되면 페이지를 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [imageList]);
|
||||||
|
|
||||||
|
const handleReviewImageClick = (index) => {
|
||||||
|
console.log("[UserReviews] CustomerImages - Image clicked at index:", index, {
|
||||||
|
imageData: imageList[index]
|
||||||
|
});
|
||||||
|
setSelectedIndex(index);
|
||||||
|
|
||||||
|
// 부모 컴포넌트에 팝업 열기 이벤트 전달
|
||||||
|
if (onImageClick) {
|
||||||
|
onImageClick(index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewMoreClick = () => {
|
||||||
|
console.log("[UserReviews] CustomerImages - View more clicked", {
|
||||||
|
currentPage,
|
||||||
|
totalImages: imageList.length,
|
||||||
|
totalPages: Math.ceil(imageList.length / IMAGES_PER_PAGE)
|
||||||
|
});
|
||||||
|
setCurrentPage(prev => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 키 이벤트 처리 (왼쪽 화살표, Enter 키)
|
||||||
|
const handleKeyDown = useCallback((ev, index) => {
|
||||||
|
if (ev.keyCode === 37) { // 왼쪽 화살표 키
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
console.log("[CustomerImages] Left arrow pressed, focusing product-details-button");
|
||||||
|
Spotlight.focus("product-details-button");
|
||||||
|
} else if (ev.keyCode === 13) { // Enter 키
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
console.log("[CustomerImages] Enter pressed on image:", index);
|
||||||
|
handleReviewImageClick(index);
|
||||||
|
}
|
||||||
|
}, [handleReviewImageClick]);
|
||||||
|
|
||||||
|
// 이미지가 없을 때도 컴포넌트를 렌더링하되 내용은 표시하지 않음
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Container className={css.container}>
|
||||||
|
<THeader className={css.tHeader} title={$L("Customer Images")} />
|
||||||
|
{imageList && imageList.length > 0 ? (
|
||||||
|
<div className={css.wrapper}>
|
||||||
|
{(() => {
|
||||||
|
const startIndex = (currentPage - 1) * IMAGES_PER_PAGE;
|
||||||
|
const endIndex = startIndex + IMAGES_PER_PAGE;
|
||||||
|
const displayImages = imageList.slice(startIndex, endIndex);
|
||||||
|
const hasMoreImages = imageList.length > endIndex;
|
||||||
|
|
||||||
|
console.log("[CustomerImages] Pagination debug:", {
|
||||||
|
currentPage,
|
||||||
|
IMAGES_PER_PAGE,
|
||||||
|
totalImages: imageList.length,
|
||||||
|
startIndex,
|
||||||
|
endIndex,
|
||||||
|
displayImagesCount: displayImages.length,
|
||||||
|
hasMoreImages
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{displayImages.map((reviewImage, displayIndex) => {
|
||||||
|
const actualIndex = startIndex + displayIndex;
|
||||||
|
const { imgId, imgUrl, reviewId } = reviewImage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpottableComponent
|
||||||
|
className={classNames(
|
||||||
|
css.reviewCard,
|
||||||
|
selectedIndex === actualIndex ? css.selectedReviewImage : null
|
||||||
|
)}
|
||||||
|
key={`review-image-${imgId}-${reviewId}`}
|
||||||
|
onClick={() => handleReviewImageClick(actualIndex)}
|
||||||
|
onKeyDown={(ev) => handleKeyDown(ev, actualIndex)}
|
||||||
|
spotlightId={`customer-image-${actualIndex}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className={css.reviewImg}
|
||||||
|
src={imgUrl}
|
||||||
|
alt={`Review image ${actualIndex + 1}`}
|
||||||
|
onLoad={() => {
|
||||||
|
console.log(`[UserReviews] CustomerImages - Image loaded successfully:`, {
|
||||||
|
index: actualIndex,
|
||||||
|
imgUrl,
|
||||||
|
imgId
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
console.error(`[UserReviews] CustomerImages - Image load failed:`, {
|
||||||
|
index: actualIndex,
|
||||||
|
imgUrl,
|
||||||
|
imgId,
|
||||||
|
error: e.target.error
|
||||||
|
});
|
||||||
|
e.target.style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SpottableComponent>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{hasMoreImages && (
|
||||||
|
<SpottableComponent
|
||||||
|
className={css.viewMoreButton}
|
||||||
|
onClick={handleViewMoreClick}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.keyCode === 13) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
handleViewMoreClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
spotlightId="customer-images-view-more"
|
||||||
|
>
|
||||||
|
<div className={css.viewMoreContent}>
|
||||||
|
<div className={css.viewMoreText}>+View More</div>
|
||||||
|
</div>
|
||||||
|
</SpottableComponent>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={css.wrapper}>
|
||||||
|
<div style={{
|
||||||
|
color: 'rgba(255, 255, 255, 0.7)',
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '20px',
|
||||||
|
fontSize: '16px'
|
||||||
|
}}>
|
||||||
|
{reviewData ? 'No customer images available' : 'Loading customer images...'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
@import "../../../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 1124px;
|
||||||
|
height: 236px;
|
||||||
|
max-width: 1124px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.tHeader {
|
||||||
|
.size(@w: 100%,@h:36px);
|
||||||
|
background: transparent;
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
padding: 0;
|
||||||
|
> span {
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 24px);
|
||||||
|
font-weight: 400;
|
||||||
|
color: rgba(255, 255, 255, 1);
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 190px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
background-color: rgba(51, 51, 51, 0.95);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
.reviewCard {
|
||||||
|
width: calc((100% - 75px) / 6); // 6개 슬롯(5이미지+1버튼), margin-right 15px * 5 = 75px
|
||||||
|
height: 150px;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 15px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
&::after {
|
||||||
|
.focused(@boxShadow:22px, @borderRadius:12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewImg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lastReviewImage {
|
||||||
|
margin-right: 0; // 마지막 이미지의 오른쪽 마진 제거
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewMoreButton {
|
||||||
|
width: calc((100% - 75px) / 6); // reviewCard와 동일한 크기
|
||||||
|
height: 150px;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
&::after {
|
||||||
|
.focused(@boxShadow:22px, @borderRadius:12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewMoreContent {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
background: linear-gradient(0deg, rgba(0, 0, 0, 0.80) 0%, rgba(0, 0, 0, 0.80) 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.viewMoreText {
|
||||||
|
color: white;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 31px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: @baseFont;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import css from "./UserReviews.module.less";
|
||||||
|
import TScroller from "../../../../components/TScroller/TScroller";
|
||||||
|
import useScrollTo from "../../../../hooks/useScrollTo";
|
||||||
|
import THeader from "../../../../components/THeader/THeader";
|
||||||
|
import { $L } from "../../../../utils/helperMethods";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
|
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { getUserReviews } from "../../../../actions/productActions";
|
||||||
|
import StarRating from "../../components/StarRating";
|
||||||
|
import CustomerImages from "./CustomerImages/CustomerImages";
|
||||||
|
import UserReviewsPopup from "./UserReviewsPopup/UserReviewsPopup";
|
||||||
|
|
||||||
|
const SpottableComponent = Spottable("div");
|
||||||
|
|
||||||
|
const Container = SpotlightContainerDecorator(
|
||||||
|
{
|
||||||
|
enterTo: "last-focused",
|
||||||
|
preserveld: true,
|
||||||
|
leaveFor: {
|
||||||
|
left: "spotlight-product-info-section-container"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"div"
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function UserReviews({ productInfo, panelInfo }) {
|
||||||
|
const { getScrollTo, scrollTop } = useScrollTo();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
|
// 팝업 상태 관리
|
||||||
|
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||||||
|
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||||
|
const reviewListData = useSelector(
|
||||||
|
(state) => state.product.reviewData && state.product.reviewData.reviewList
|
||||||
|
);
|
||||||
|
const reviewTotalCount = useSelector(
|
||||||
|
(state) => {
|
||||||
|
const reviewData = state.product.reviewData;
|
||||||
|
return reviewData && reviewData.reviewDetail && reviewData.reviewDetail.totRvwCnt ? reviewData.reviewDetail.totRvwCnt : 0;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const reviewDetailData = useSelector(
|
||||||
|
(state) => state.product.reviewData && state.product.reviewData.reviewDetail
|
||||||
|
);
|
||||||
|
|
||||||
|
// [UserReviews] 데이터 수신 확인 로그
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[UserReviews] Review data received:", {
|
||||||
|
reviewListData,
|
||||||
|
reviewTotalCount,
|
||||||
|
reviewDetailData,
|
||||||
|
hasData: reviewListData && reviewListData.length > 0
|
||||||
|
});
|
||||||
|
}, [reviewListData, reviewTotalCount, reviewDetailData]);
|
||||||
|
|
||||||
|
// UserReviews: Container 직접 사용 패턴 (TScroller 중복 제거)
|
||||||
|
|
||||||
|
// 실제 상품 ID를 사용해서 리뷰 데이터 요청 (panelInfo에서 prdtId 우선 사용)
|
||||||
|
useEffect(() => {
|
||||||
|
const productId = (panelInfo && panelInfo.prdtId) || (productInfo && productInfo.prdtId);
|
||||||
|
|
||||||
|
console.log("[UserReviews] useEffect triggered - Product ID check:", {
|
||||||
|
panelInfo_prdtId: panelInfo && panelInfo.prdtId,
|
||||||
|
productInfo_prdtId: productInfo && productInfo.prdtId,
|
||||||
|
finalProductId: productId,
|
||||||
|
panelInfo: panelInfo,
|
||||||
|
productInfo: productInfo
|
||||||
|
});
|
||||||
|
|
||||||
|
if (productId) {
|
||||||
|
console.log("[UserReviews] ✅ API 호출 시작:", {
|
||||||
|
prdtId: productId,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
dispatch(getUserReviews({ prdtId: productId }));
|
||||||
|
} else {
|
||||||
|
console.error("[UserReviews] ❌ API 호출 실패 - prdtId 없음:", {
|
||||||
|
panelInfo_exists: !!panelInfo,
|
||||||
|
productInfo_exists: !!productInfo,
|
||||||
|
panelInfo_prdtId: panelInfo && panelInfo.prdtId,
|
||||||
|
productInfo_prdtId: productInfo && productInfo.prdtId,
|
||||||
|
panelInfo_keys: panelInfo ? Object.keys(panelInfo) : [],
|
||||||
|
productInfo_keys: productInfo ? Object.keys(productInfo) : []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [dispatch, panelInfo && panelInfo.prdtId, productInfo && productInfo.prdtId]);
|
||||||
|
|
||||||
|
const formatToYYMMDD = (dateStr) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const iso = date.toISOString().slice(2, 10);
|
||||||
|
return iso.replace(/-/g, ".");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReviewClick = useCallback(() => {
|
||||||
|
console.log("[UserReviews] Review item clicked");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 팝업 관련 핸들러들
|
||||||
|
const handleOpenPopup = useCallback((imageIndex = 0) => {
|
||||||
|
console.log("[UserReviews] Opening popup with image index:", imageIndex);
|
||||||
|
setSelectedImageIndex(imageIndex);
|
||||||
|
setIsPopupOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClosePopup = useCallback(() => {
|
||||||
|
console.log("[UserReviews] Closing popup");
|
||||||
|
setIsPopupOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleImageClick = useCallback((index, image) => {
|
||||||
|
console.log("[UserReviews] Popup image clicked:", { index, image });
|
||||||
|
setSelectedImageIndex(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 이미지 데이터 가공 (CustomerImages와 동일한 로직)
|
||||||
|
const customerImages = useMemo(() => {
|
||||||
|
if (!reviewListData || !Array.isArray(reviewListData)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageReviews = reviewListData.filter(
|
||||||
|
(review) => review.reviewImageList &&
|
||||||
|
Array.isArray(review.reviewImageList) &&
|
||||||
|
review.reviewImageList.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const images = [];
|
||||||
|
imageReviews.forEach((review, reviewIndex) => {
|
||||||
|
if (review.reviewImageList && Array.isArray(review.reviewImageList)) {
|
||||||
|
review.reviewImageList.forEach((imgItem, imgIndex) => {
|
||||||
|
const { imgId, imgUrl, imgSeq } = imgItem;
|
||||||
|
if (imgUrl && imgUrl.trim() !== '') {
|
||||||
|
images.push({
|
||||||
|
imgId: imgId || `img-${reviewIndex}-${imgIndex}`,
|
||||||
|
imgUrl,
|
||||||
|
imgSeq: imgSeq || imgIndex + 1,
|
||||||
|
reviewId: review.rvwId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return images;
|
||||||
|
}, [reviewListData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
ref={containerRef}
|
||||||
|
className={css.userReviewsContainer}
|
||||||
|
spotlightId="user-reviews-container"
|
||||||
|
>
|
||||||
|
<TScroller
|
||||||
|
className={css.tScroller}
|
||||||
|
verticalScrollbar="auto"
|
||||||
|
cbScrollTo={getScrollTo}
|
||||||
|
>
|
||||||
|
<THeader
|
||||||
|
title={$L(
|
||||||
|
`USER REVIEWS (${reviewTotalCount})`
|
||||||
|
)}
|
||||||
|
className={css.tHeader}
|
||||||
|
>
|
||||||
|
{reviewDetailData && reviewDetailData.totRvwAvg && (
|
||||||
|
<StarRating
|
||||||
|
rating={reviewDetailData.totRvwAvg}
|
||||||
|
className={css.averageOverallRating}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</THeader>
|
||||||
|
<CustomerImages panelInfo={panelInfo} onImageClick={handleOpenPopup} />
|
||||||
|
<div className={css.reviewItem}>
|
||||||
|
<div className={css.showReviewsText}>
|
||||||
|
{$L(
|
||||||
|
`Showing ${reviewListData ? reviewListData.length : 0} out of ${reviewTotalCount} reviews`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{reviewListData &&
|
||||||
|
reviewListData.map((review, index) => {
|
||||||
|
const { reviewImageList, rvwRtng, rvwRgstDtt, rvwCtnt, rvwId, wrtrNknm, rvwWrtrId } =
|
||||||
|
review;
|
||||||
|
console.log(`[UserReviews] Rendering review ${index}:`, { rvwId, hasImages: reviewImageList && reviewImageList.length > 0 });
|
||||||
|
return (
|
||||||
|
<SpottableComponent
|
||||||
|
key={`user-reviews-:${rvwId}`}
|
||||||
|
aria-label={`user-reviews-:${rvwId}`}
|
||||||
|
className={css.reviewContentContainer}
|
||||||
|
onClick={handleReviewClick}
|
||||||
|
spotlightId={`user-review-${index}`}
|
||||||
|
>
|
||||||
|
{reviewImageList && reviewImageList.length > 0 && (
|
||||||
|
<img
|
||||||
|
className={css.reviewThumbnail}
|
||||||
|
src={reviewImageList[0].imgUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={css.reviewContent}>
|
||||||
|
<div className={css.reviewMeta}>
|
||||||
|
{rvwRtng && (
|
||||||
|
<StarRating
|
||||||
|
rating={rvwRtng}
|
||||||
|
aria-label={"star rating " + rvwRtng + " out of 5"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(wrtrNknm || rvwWrtrId) && (
|
||||||
|
<span className={css.reviewAuthor}>
|
||||||
|
{wrtrNknm || rvwWrtrId}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{rvwRgstDtt && (
|
||||||
|
<span className={css.reviewDate}>
|
||||||
|
{formatToYYMMDD(rvwRgstDtt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{rvwCtnt && (
|
||||||
|
<div className={css.reviewText}>{rvwCtnt}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SpottableComponent>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TScroller>
|
||||||
|
|
||||||
|
{/* UserReviewsPopup 추가 */}
|
||||||
|
<UserReviewsPopup
|
||||||
|
open={isPopupOpen}
|
||||||
|
onClose={handleClosePopup}
|
||||||
|
images={customerImages}
|
||||||
|
selectedImageIndex={selectedImageIndex}
|
||||||
|
onImageClick={handleImageClick}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
@import "../../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.tScroller {
|
||||||
|
.size(@w: 1124px, @h: 100%); // 마진 포함 전체 크기 (1054px + 60px)
|
||||||
|
max-width: 1124px;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userReviewsContainer {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tHeader {
|
||||||
|
background: transparent;
|
||||||
|
.size(@w: 1020px, @h: 36px); // CustomerImages와 일치하도록 크기 조정
|
||||||
|
max-width: 1020px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
.size(@w:100%,@h:100%);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.averageOverallRating {
|
||||||
|
.size(@w: 176px,@h:30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 30px);
|
||||||
|
font-weight: 700;
|
||||||
|
height: 36px;
|
||||||
|
color: rgba(234, 234, 234, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewItem {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.showReviewsText {
|
||||||
|
.size(@w:100%, @h:36px);
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 24px);
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 5px 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewContentContainer {
|
||||||
|
.size(@w:100%, @h:168px);
|
||||||
|
background-color: rgba(51, 51, 51, 0.95);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
.flex(@justifyCenter:flex-start);
|
||||||
|
padding: 30px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
&::after {
|
||||||
|
.focused(@boxShadow:22px, @borderRadius:12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewThumbnail {
|
||||||
|
.size(@w: 108px,@h:108px);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewContent {
|
||||||
|
.size(@w: 100%,@h:108px);
|
||||||
|
|
||||||
|
.reviewMeta {
|
||||||
|
.size(@w:100%, @h:31px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-right: 20px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewAuthor {
|
||||||
|
color: rgba(176, 176, 176, 1);
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 22px);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewDate {
|
||||||
|
color: rgba(234, 234, 234, 1);
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 24px);
|
||||||
|
font-weight: 400;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewText {
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 24px);
|
||||||
|
font-weight: 400;
|
||||||
|
.elip(@clamp:2);
|
||||||
|
color: rgba(234, 234, 234, 1);
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<div style={{width: 1060, height: 797, background: 'white', overflow: 'hidden', borderRadius: 12, flexDirection: 'column', justifyContent: 'center', alignItems: 'center', display: 'inline-flex'}}>
|
||||||
|
<div style={{alignSelf: 'stretch', padding: 30, background: '#E7EBEF', justifyContent: 'flex-start', alignItems: 'center', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'black', fontSize: 42, fontFamily: 'LG Smart UI', fontWeight: '700', lineHeight: 42, wordWrap: 'break-word'}}>Customer Images </div>
|
||||||
|
</div>
|
||||||
|
<div style={{alignSelf: 'stretch', height: 557, padding: 30, overflow: 'hidden', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 30, display: 'inline-flex'}}>
|
||||||
|
<div style={{flex: '1 1 0', justifyContent: 'flex-start', alignItems: 'center', gap: 20, display: 'flex', flexWrap: 'wrap', alignContent: 'center'}}>
|
||||||
|
<div style={{width: 226, height: 218, position: 'relative'}}>
|
||||||
|
<img style={{width: 239.80, height: 239.80, left: -10.90, top: -10.90, position: 'absolute', borderRadius: 13.20, border: '4px #C70850 solid'}} src="https://placehold.co/240x240" />
|
||||||
|
</div>
|
||||||
|
<img style={{width: 226, height: 218, paddingLeft: 4, paddingRight: 4, paddingTop: 5, paddingBottom: 5, borderRadius: 12}} src="https://placehold.co/226x218" />
|
||||||
|
<img style={{width: 226, height: 218, paddingLeft: 4, paddingRight: 4, paddingTop: 5, paddingBottom: 5, borderRadius: 12}} src="https://placehold.co/226x218" />
|
||||||
|
<img style={{width: 226, height: 218, paddingLeft: 4, paddingRight: 4, paddingTop: 5, paddingBottom: 5, borderRadius: 12}} src="https://placehold.co/226x218" />
|
||||||
|
<img style={{width: 226, height: 218, paddingLeft: 4, paddingRight: 4, paddingTop: 5, paddingBottom: 5, borderRadius: 12}} src="https://placehold.co/226x218" />
|
||||||
|
<img style={{width: 226, height: 218, paddingLeft: 4, paddingRight: 4, paddingTop: 5, paddingBottom: 5, borderRadius: 12}} src="https://placehold.co/226x218" />
|
||||||
|
<img style={{width: 226, height: 218, paddingLeft: 4, paddingRight: 4, paddingTop: 5, paddingBottom: 5, borderRadius: 12}} src="https://placehold.co/226x218" />
|
||||||
|
<img style={{width: 226, height: 218, paddingLeft: 4, paddingRight: 4, paddingTop: 5, paddingBottom: 5, borderRadius: 12}} src="https://placehold.co/226x218" />
|
||||||
|
<img style={{width: 226, height: 218, paddingLeft: 4, paddingRight: 4, paddingTop: 5, paddingBottom: 5, borderRadius: 12}} src="https://placehold.co/226x218" />
|
||||||
|
<img style={{width: 226, height: 218, paddingLeft: 4, paddingRight: 4, paddingTop: 5, paddingBottom: 5, borderRadius: 12}} src="https://placehold.co/226x218" />
|
||||||
|
<img style={{width: 226, height: 218, paddingLeft: 4, paddingRight: 4, paddingTop: 5, paddingBottom: 5, borderRadius: 12}} src="https://placehold.co/226x218" />
|
||||||
|
<div style={{width: 226, height: 218, paddingLeft: 4, paddingRight: 4, paddingTop: 5, paddingBottom: 5, background: 'linear-gradient(0deg, rgba(0, 0, 0, 0.80) 0%, rgba(0, 0, 0, 0.80) 100%)', borderRadius: 12, backgroundImage: 'url(https://placehold.co/226x218)', justifyContent: 'center', alignItems: 'center', gap: 10, display: 'flex'}}>
|
||||||
|
<div style={{color: 'white', fontSize: 16, fontFamily: 'LG Smart UI', fontWeight: '700', lineHeight: 31, wordWrap: 'break-word'}}>+View More</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{width: 6, alignSelf: 'stretch', position: 'relative', background: '#E7E7E7', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 6, height: 100, left: 0, top: 0, position: 'absolute', background: '#7A808D'}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{alignSelf: 'stretch', paddingLeft: 60, paddingRight: 60, paddingTop: 30, paddingBottom: 30, justifyContent: 'center', alignItems: 'center', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{width: 300, height: 78, background: '#7A808D', borderRadius: 12, justifyContent: 'center', alignItems: 'center', gap: 10, display: 'flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'white', fontSize: 30, fontFamily: 'LG Smart UI', fontWeight: '700', lineHeight: 30, wordWrap: 'break-word'}}>CLOSE</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
|
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
||||||
|
import TNewPopUp from "../../../../../components/TPopUp/TNewPopUp";
|
||||||
|
import TButton from "../../../../../components/TButton/TButton";
|
||||||
|
import { $L } from "../../../../../utils/helperMethods";
|
||||||
|
import css from "./UserReviewsPopup.module.less";
|
||||||
|
|
||||||
|
const SpottableImage = Spottable("div");
|
||||||
|
|
||||||
|
const ContentContainer = SpotlightContainerDecorator(
|
||||||
|
{
|
||||||
|
enterTo: "default-element",
|
||||||
|
preserveId: true,
|
||||||
|
defaultElement: "user-review-image-0"
|
||||||
|
},
|
||||||
|
"div"
|
||||||
|
);
|
||||||
|
|
||||||
|
const FooterContainer = SpotlightContainerDecorator(
|
||||||
|
{
|
||||||
|
enterTo: "default-element",
|
||||||
|
preserveId: true,
|
||||||
|
defaultElement: "close-button"
|
||||||
|
},
|
||||||
|
"div"
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function UserReviewsPopup({
|
||||||
|
open = false,
|
||||||
|
onClose,
|
||||||
|
images = [],
|
||||||
|
selectedImageIndex = 0,
|
||||||
|
onImageClick,
|
||||||
|
className,
|
||||||
|
}) {
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (onClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleImageClick = useCallback((index, image) => {
|
||||||
|
if (onImageClick) {
|
||||||
|
onImageClick(index, image);
|
||||||
|
}
|
||||||
|
}, [onImageClick]);
|
||||||
|
|
||||||
|
// 최대 8개 이미지만 표시
|
||||||
|
const displayImages = images.slice(0, 8);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TNewPopUp
|
||||||
|
open={open}
|
||||||
|
kind="normal"
|
||||||
|
onClose={handleClose}
|
||||||
|
className={classNames(css.userReviewsPopup, className)}
|
||||||
|
>
|
||||||
|
<div className={css.popupContainer}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={css.header}>
|
||||||
|
<div className={css.headerTitle}>
|
||||||
|
{$L("Customer Images")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ContentContainer className={css.content}>
|
||||||
|
<div className={css.imageGrid}>
|
||||||
|
{displayImages.map((image, index) => (
|
||||||
|
<SpottableImage
|
||||||
|
key={`user-review-image-${index}`}
|
||||||
|
spotlightId={`user-review-image-${index}`}
|
||||||
|
className={classNames(
|
||||||
|
css.imageItem,
|
||||||
|
selectedImageIndex === index && css.selectedImage
|
||||||
|
)}
|
||||||
|
onClick={() => handleImageClick(index, image)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={image.imgUrl || image}
|
||||||
|
alt={`Customer review ${index + 1}`}
|
||||||
|
className={css.image}
|
||||||
|
/>
|
||||||
|
</SpottableImage>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ContentContainer>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<FooterContainer className={css.footer}>
|
||||||
|
<TButton
|
||||||
|
className={css.closeButton}
|
||||||
|
onClick={handleClose}
|
||||||
|
spotlightId="close-button"
|
||||||
|
>
|
||||||
|
{$L("CLOSE")}
|
||||||
|
</TButton>
|
||||||
|
</FooterContainer>
|
||||||
|
</div>
|
||||||
|
</TNewPopUp>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
@import "../../../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.userReviewsPopup {
|
||||||
|
.popupContainer {
|
||||||
|
width: 1060px;
|
||||||
|
height: 797px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
// Header 영역
|
||||||
|
.header {
|
||||||
|
align-self: stretch;
|
||||||
|
padding: 30px;
|
||||||
|
background: #E7EBEF;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.headerTitle {
|
||||||
|
text-align: center;
|
||||||
|
color: black;
|
||||||
|
font-size: 42px;
|
||||||
|
font-family: @baseFont;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 42px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content 영역
|
||||||
|
.content {
|
||||||
|
align-self: stretch;
|
||||||
|
height: 557px;
|
||||||
|
padding: 0; // 패딩 제거
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center; // 중앙 정렬
|
||||||
|
align-items: center; // 중앙 정렬
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.imageGrid {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center; // 중앙 정렬로 변경
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-content: center;
|
||||||
|
overflow: hidden; // 스크롤 완전 제거
|
||||||
|
max-height: 100%;
|
||||||
|
|
||||||
|
// gap 대신 margin 사용 (TV 호환성)
|
||||||
|
.imageItem {
|
||||||
|
width: 226px;
|
||||||
|
height: 218px;
|
||||||
|
border-radius: 12px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
// 3개씩 배치하므로 3번째마다 margin-right 제거
|
||||||
|
&:nth-child(3n) {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -2px; // 포커스 위치 미세 조정
|
||||||
|
left: -2px; // 포커스 위치 미세 조정
|
||||||
|
width: calc(100% + 4px); // 크기 미세 조정
|
||||||
|
height: calc(100% + 4px); // 크기 미세 조정
|
||||||
|
border: 4px solid #C70850;
|
||||||
|
border-radius: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택된 이미지 스타일
|
||||||
|
&.selectedImage {
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 4px solid #C70850;
|
||||||
|
border-radius: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View More 아이템
|
||||||
|
.viewMoreItem {
|
||||||
|
width: 226px;
|
||||||
|
height: 218px;
|
||||||
|
border-radius: 12px;
|
||||||
|
position: relative;
|
||||||
|
margin-right: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&:nth-child(3n) {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
padding: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewMoreOverlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(0deg, rgba(0, 0, 0, 0.80) 0%, rgba(0, 0, 0, 0.80) 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.viewMoreText {
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: @baseFont;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 31px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 커스텀 스크롤바 (숨김)
|
||||||
|
.scrollbar {
|
||||||
|
display: none; // 스크롤바 완전 숨김
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer 영역
|
||||||
|
.footer {
|
||||||
|
align-self: stretch;
|
||||||
|
padding: 30px 60px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
width: 300px !important;
|
||||||
|
height: 78px !important;
|
||||||
|
background: #7A808D !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
border: none !important;
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
align-items: center !important;
|
||||||
|
|
||||||
|
color: white !important;
|
||||||
|
font-size: 30px !important;
|
||||||
|
font-family: @baseFont !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
line-height: 30px !important;
|
||||||
|
text-align: center !important;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: @PRIMARY_COLOR_RED !important;
|
||||||
|
outline: 2px solid @PRIMARY_COLOR_RED !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: lighten(#7A808D, 10%) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"main": "UserReviews.jsx",
|
||||||
|
"styles": [
|
||||||
|
"UserReviews.module.less"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import css from "./YouMayAlsoLike.module.less";
|
||||||
|
import { $L } from "../../../../utils/helperMethods";
|
||||||
|
import TVerticalPagenator from "../../../../components/TVerticalPagenator/TVerticalPagenator";
|
||||||
|
import TVirtualGridList from "../../../../components/TVirtualGridList/TVirtualGridList";
|
||||||
|
import useScrollTo from "../../../../hooks/useScrollTo";
|
||||||
|
import TItemCard from "../../../../components/TItemCard/TItemCard";
|
||||||
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
|
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import {
|
||||||
|
LOG_CONTEXT_NAME,
|
||||||
|
LOG_MESSAGE_ID,
|
||||||
|
panel_names,
|
||||||
|
} from "../../../../utils/Config";
|
||||||
|
import THeader from "../../../../components/THeader/THeader";
|
||||||
|
import { finishVideoPreview } from "../../../../actions/playActions";
|
||||||
|
import { popPanel, pushPanel } from "../../../../actions/panelActions";
|
||||||
|
import { clearThemeDetail } from "../../../../actions/homeActions";
|
||||||
|
import { Job } from "@enact/core/util";
|
||||||
|
|
||||||
|
const SpottableComponent = Spottable("div");
|
||||||
|
|
||||||
|
const Container = SpotlightContainerDecorator(
|
||||||
|
{
|
||||||
|
enterTo: "last-focused",
|
||||||
|
leaveFor: {
|
||||||
|
left: "spotlight-product-info-section-container"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"div"
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function YouMayAlsoLike({ productInfo, panelInfo }) {
|
||||||
|
const { getScrollTo, scrollLeft } = useScrollTo();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const focusedContainerIdRef = useRef(null);
|
||||||
|
|
||||||
|
const youmaylikeProductData = useSelector(
|
||||||
|
(state) => state.main.youmaylikeData
|
||||||
|
);
|
||||||
|
const panels = useSelector((state) => state.panels.panels);
|
||||||
|
const themeProductInfos = useSelector(
|
||||||
|
(state) => state.home.themeCurationDetailInfoData
|
||||||
|
);
|
||||||
|
|
||||||
|
// const [focused, setFocused] = useState(false);
|
||||||
|
|
||||||
|
const cursorOpen = useRef(new Job((func) => func(), 1000));
|
||||||
|
|
||||||
|
const launchedFromPlayer = useMemo(() => {
|
||||||
|
const detailPanelIndex = panels.findIndex(
|
||||||
|
({ name }) => name === "detailpanel"
|
||||||
|
);
|
||||||
|
const playerPanelIndex = panels.findIndex(
|
||||||
|
({ name }) => name === "playerpanel"
|
||||||
|
);
|
||||||
|
|
||||||
|
return detailPanelIndex - 1 === playerPanelIndex;
|
||||||
|
}, [panels]);
|
||||||
|
|
||||||
|
const onFocusedContainerId = useCallback((containerId) => {
|
||||||
|
focusedContainerIdRef.current = containerId;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{youmaylikeProductData && youmaylikeProductData.length > 0 && (
|
||||||
|
<TVerticalPagenator
|
||||||
|
spotlightId={"detail_youMayAlsoLike_area"}
|
||||||
|
data-wheel-point={true}
|
||||||
|
className={css.tVerticalPagenator}
|
||||||
|
defaultContainerId={panelInfo?.focusedContainerId}
|
||||||
|
onFocusedContainerId={onFocusedContainerId}
|
||||||
|
topMargin={36}
|
||||||
|
>
|
||||||
|
<Container className={css.container}>
|
||||||
|
<THeader title={$L("YOU MAY ALSO LIKE")} className={css.tHeader} />
|
||||||
|
<div className={css.renderCardContainer}>
|
||||||
|
{youmaylikeProductData?.map((product, index) => {
|
||||||
|
const {
|
||||||
|
imgUrl,
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
prdtNm,
|
||||||
|
priceInfo,
|
||||||
|
offerInfo,
|
||||||
|
patncNm,
|
||||||
|
brndNm,
|
||||||
|
lgCatCd,
|
||||||
|
} = product;
|
||||||
|
|
||||||
|
const handleItemClick = () => {
|
||||||
|
dispatch(finishVideoPreview());
|
||||||
|
dispatch(popPanel(panel_names.DETAIL_PANEL));
|
||||||
|
|
||||||
|
if (themeProductInfos && themeProductInfos.length > 0) {
|
||||||
|
dispatch(clearThemeDetail());
|
||||||
|
}
|
||||||
|
dispatch(
|
||||||
|
pushPanel({
|
||||||
|
name: panel_names.DETAIL_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
launchedFromPlayer: launchedFromPlayer,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
cursorOpen.current.stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TItemCard
|
||||||
|
key={prdtId}
|
||||||
|
contextName={LOG_CONTEXT_NAME.YOUMAYLIKE}
|
||||||
|
messageId={LOG_MESSAGE_ID.CONTENTCLICK}
|
||||||
|
productId={prdtId}
|
||||||
|
productTitle={prdtNm}
|
||||||
|
nowProductId={productInfo?.prdtId}
|
||||||
|
nowProductTitle={productInfo?.prdtNm}
|
||||||
|
nowCategory={productInfo?.catNm}
|
||||||
|
catNm={lgCatCd}
|
||||||
|
patnerName={patncNm}
|
||||||
|
brandName={brndNm}
|
||||||
|
imageAlt={prdtId}
|
||||||
|
imageSource={imgUrl}
|
||||||
|
priceInfo={priceInfo}
|
||||||
|
offerInfo={offerInfo}
|
||||||
|
productName={prdtNm}
|
||||||
|
onClick={handleItemClick}
|
||||||
|
label={
|
||||||
|
index * 1 + 1 + " of " + youmaylikeProductData.length
|
||||||
|
}
|
||||||
|
lastLabel=" go to detail, button"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</TVerticalPagenator>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
@import "../../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../../style/utils.module.less";
|
||||||
|
|
||||||
|
// .container {
|
||||||
|
// .size(@w: 874px,@h:500px);
|
||||||
|
|
||||||
|
// .itemWrapper {
|
||||||
|
// .size(@w: 874px,@h:500px);
|
||||||
|
// .item {
|
||||||
|
// .size(@w: 300px,@h:300px);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
.tVerticalPagenator {
|
||||||
|
.size(@w: 1114px, @h: auto); // 마진 포함 전체 크기 (1054px + 60px)
|
||||||
|
max-width: 1114px;
|
||||||
|
padding-left: 30px; // 좌측 30px 마진
|
||||||
|
padding-right: 30px; // 우측 30px 마진
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
// .sectionTitle {
|
||||||
|
// .font(@fontFamily: @baseFont, @fontSize: 30px);
|
||||||
|
// min-height: 56px;
|
||||||
|
// font-weight: 700;
|
||||||
|
// color: rgba(234, 234, 234, 1);
|
||||||
|
// // margin: 30px 0 20px 0;
|
||||||
|
// }
|
||||||
|
.tHeader {
|
||||||
|
background: transparent;
|
||||||
|
.size(@w: 1054px, @h: 36px); // 마진 제외 콘텐츠 크기
|
||||||
|
max-width: 1054px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
.size(@w:100%,@h:100%);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.averageOverallRating {
|
||||||
|
.size(@w: 176px,@h:30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
height: 36px;
|
||||||
|
color: rgba(234, 234, 234, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
.flex(@direction:column,@alignCenter:flex-start);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 34px;
|
||||||
|
// > div {
|
||||||
|
// margin: 0 15px 15px 0;
|
||||||
|
// }
|
||||||
|
|
||||||
|
.renderCardContainer {
|
||||||
|
width: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
// margin-top: 34px;
|
||||||
|
> div {
|
||||||
|
/* item card */
|
||||||
|
margin: 0 15px 15px 0;
|
||||||
|
.size(@w:300px,@h:435px);
|
||||||
|
background-color: rgba(51, 51, 51, 0.95);
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
> div:nth-child(1) {
|
||||||
|
/* img wrapper*/
|
||||||
|
.size(@w:264px,@h:264px);
|
||||||
|
|
||||||
|
> img {
|
||||||
|
.size(@w:100%,@h:100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> div:nth-child(2) {
|
||||||
|
/* desc wrapper */
|
||||||
|
> div > h3 {
|
||||||
|
/* title */
|
||||||
|
color: rgba(234, 234, 234, 1);
|
||||||
|
margin-top: 15px;
|
||||||
|
.size(@w:100%,@h:62px);
|
||||||
|
line-height: 31px;
|
||||||
|
}
|
||||||
|
> p {
|
||||||
|
/* priceInfo */
|
||||||
|
height: 43px;
|
||||||
|
line-height: 35px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// width: 100%;
|
||||||
|
// padding-left: 60px;
|
||||||
|
// overflow: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"main": "YouMayAlsoLike.jsx",
|
||||||
|
"styles": [
|
||||||
|
"YouMayAlsoLike.module.less"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import css from "./QRCode.module.less";
|
||||||
|
import { getQRCodeUrl } from "../../../../utils/helperMethods";
|
||||||
|
import TQRCode from "../../../../components/TQRCode/TQRCode";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
|
export default function QRCode({
|
||||||
|
productType,
|
||||||
|
selectedIndex,
|
||||||
|
productInfo,
|
||||||
|
tooltipDes,
|
||||||
|
promotionTooltip,
|
||||||
|
promotionCode,
|
||||||
|
popupVisible,
|
||||||
|
onClose,
|
||||||
|
// serverHOST,
|
||||||
|
// serverType,
|
||||||
|
prdtData,
|
||||||
|
// entryMenu,
|
||||||
|
// nowMenu,
|
||||||
|
selectedPrdtId,
|
||||||
|
selectedPatnrId,
|
||||||
|
deviceInfo,
|
||||||
|
}) {
|
||||||
|
const isBuyNow = productType === "buyNow";
|
||||||
|
const isTheme = productType === "theme";
|
||||||
|
const isShopByMobile = productType === "shopByMobile";
|
||||||
|
|
||||||
|
const serverHOST = useSelector((state) => state.common.appStatus.serverHOST);
|
||||||
|
const serverType = useSelector((state) => state.localSettings.serverType);
|
||||||
|
const { entryMenu, nowMenu } = useSelector((state) => state.common.menu);
|
||||||
|
|
||||||
|
const { detailUrl } = useMemo(() => {
|
||||||
|
return getQRCodeUrl({
|
||||||
|
serverHOST,
|
||||||
|
serverType,
|
||||||
|
index: deviceInfo?.dvcIndex,
|
||||||
|
patnrId: productInfo?.patnrId,
|
||||||
|
prdtId: productInfo?.prdtId,
|
||||||
|
entryMenu: entryMenu,
|
||||||
|
nowMenu: nowMenu,
|
||||||
|
liveFlag: "Y",
|
||||||
|
qrType: "billingDetail",
|
||||||
|
});
|
||||||
|
}, [serverHOST, serverType, deviceInfo, entryMenu, nowMenu, productInfo]);
|
||||||
|
|
||||||
|
const qrCodeUrl = useMemo(() => {
|
||||||
|
if (isShopByMobile) {
|
||||||
|
return productInfo?.qrImgUrl || productInfo?.qrcodeUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return detailUrl;
|
||||||
|
}, [productInfo, isShopByMobile, detailUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.qrcode}>
|
||||||
|
{qrCodeUrl && <TQRCode text={qrCodeUrl} width="190" height="190" />}
|
||||||
|
|
||||||
|
{/* todo : 시나리오,UI 릴리즈 후 */}
|
||||||
|
{/* <div className={css.tooltip}>
|
||||||
|
<div className={css.tooltipBody}>
|
||||||
|
{promotionCode ? promotionTooltip : tooltipDes}
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
@import "../../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.qrcode {
|
||||||
|
> div:first-child {
|
||||||
|
width: 190px;
|
||||||
|
height: 190px;
|
||||||
|
background: @COLOR_WHITE;
|
||||||
|
border: solid 1px #dadada;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"main": "QRCode.jsx",
|
||||||
|
"styles": [
|
||||||
|
"QRCode.module.less"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import css from "./ProductOverview.module.less";
|
||||||
|
import TButton from "../../../components/TButton/TButton";
|
||||||
|
import FavoriteBtn from "../components/FavoriteBtn";
|
||||||
|
import { $L } from "../../../utils/helperMethods";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
|
import { SpotlightIds } from "../../../utils/SpotlightIds";
|
||||||
|
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
||||||
|
import Spotlight from "@enact/spotlight";
|
||||||
|
import TQRCode from "../../../components/TQRCode/TQRCode";
|
||||||
|
|
||||||
|
import DetailMobileSendPopUp from "../components/DetailMobileSendPopUp";
|
||||||
|
import useWhyDidYouUpdate from "../../../hooks/useWhyDidYouUpdate";
|
||||||
|
import ProductPriceDisplay from "./ProductPriceDisplay/ProductPriceDisplay";
|
||||||
|
|
||||||
|
const SpottableComponent = Spottable("div");
|
||||||
|
|
||||||
|
const Container = SpotlightContainerDecorator(
|
||||||
|
{ enterTo: "last-focused" },
|
||||||
|
"div"
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function ProductOverview({
|
||||||
|
children,
|
||||||
|
panelInfo,
|
||||||
|
selectedIndex,
|
||||||
|
productInfo,
|
||||||
|
productType,
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
Spotlight.focus(SpotlightIds.DETAIL_SHOPBYMOBILE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.container}>
|
||||||
|
{/* price Info */}
|
||||||
|
{productInfo && productType && (
|
||||||
|
<div className={css.productInfoWrapper}>
|
||||||
|
{/* price */}
|
||||||
|
<ProductPriceDisplay
|
||||||
|
productInfo={productInfo}
|
||||||
|
productType={productType}
|
||||||
|
/>
|
||||||
|
{/* QR */}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
@import "../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.container {
|
||||||
|
.size(@w:100%,@h:100%);
|
||||||
|
|
||||||
|
.productInfoWrapper {
|
||||||
|
.flex(@justifyCenter:flex-start,@alignCenter:flex-start);
|
||||||
|
margin: 54px 0 10px 0;
|
||||||
|
// 고정 높이로 인해 QR 영역과 하단 버튼 영역 사이에 과도한 여백이 생김
|
||||||
|
// 콘텐츠 높이에 맞춰 자동으로 계산되도록 변경하여 불필요한 간격 제거
|
||||||
|
.size(@w:100%,@h:auto);
|
||||||
|
|
||||||
|
.priceWrapper {
|
||||||
|
.flex(@justifyCenter:space-between,@direction:column,@alignCenter:flex-start);
|
||||||
|
.size(@w:344px,@h:216px);
|
||||||
|
|
||||||
|
.priceName {
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 55px);
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(234, 234, 234, 1);
|
||||||
|
line-height: 55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
.size(@w:100%,@h:42px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrWrapper {
|
||||||
|
> div:first-child {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonWrapper {
|
||||||
|
.size(@w:470px,@h:60px);
|
||||||
|
.flex(@justifyCenter:space-between,@alignCenter:flex-end);
|
||||||
|
|
||||||
|
.sbmButton {
|
||||||
|
width: 404px;
|
||||||
|
height: 60px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(234, 234, 234, 1);
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 25px);
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
> div {
|
||||||
|
.size(@w:100%,@h:100%);
|
||||||
|
line-height: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0px 18px 28.2px 1.8px rgba(62, 59, 59, 0.4);
|
||||||
|
background-color: @PRIMARY_COLOR_RED;
|
||||||
|
color: @COLOR_WHITE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.orderNum {
|
||||||
|
width: 100%;
|
||||||
|
padding: 21.5px 30px;
|
||||||
|
.flex(@justifyCenter:space-between);
|
||||||
|
height: 40px;
|
||||||
|
margin: 10px 0;
|
||||||
|
span {
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 20px);
|
||||||
|
line-height: 40ppx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(234, 234, 234, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
// width: aupx;
|
||||||
|
.flex();
|
||||||
|
|
||||||
|
.callIcon {
|
||||||
|
.size(@w: 25px, @h: 25px);
|
||||||
|
background-image: url("../../../../assets/images/icons/ic-gr-call-1.png");
|
||||||
|
background-size: 25px;
|
||||||
|
background-position: center;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomBtnWrapper {
|
||||||
|
// .size(@w:100%,@h:246px);
|
||||||
|
margin-top: 10px;
|
||||||
|
.size(@w:100%,@h:220px);
|
||||||
|
.flex(@direction:column);
|
||||||
|
.button {
|
||||||
|
.size(@w:100%,@h:60px);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(234, 234, 234, 1);
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 25px);
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
> div {
|
||||||
|
.size(@w:100%,@h:100%);
|
||||||
|
line-height: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0px 18px 28.2px 1.8px rgba(62, 59, 59, 0.4);
|
||||||
|
background-color: @PRIMARY_COLOR_RED;
|
||||||
|
color: @COLOR_WHITE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
import usePriceInfo from "../../../../../hooks/usePriceInfo";
|
||||||
|
import { $L } from "../../../../../utils/helperMethods";
|
||||||
|
import css from "./BuyNowPriceDisplay.module.less";
|
||||||
|
|
||||||
|
export default function BuyNowPriceDisplay({
|
||||||
|
priceData,
|
||||||
|
priceInfo,
|
||||||
|
isOriginalPriceEmpty,
|
||||||
|
isDiscountedPriceEmpty,
|
||||||
|
isDiscounted,
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
price5,
|
||||||
|
rewd,
|
||||||
|
offerInfo,
|
||||||
|
patncNm,
|
||||||
|
patnrName,
|
||||||
|
installmentMonths,
|
||||||
|
orderPhnNo,
|
||||||
|
} = priceData;
|
||||||
|
|
||||||
|
const {
|
||||||
|
discountRate,
|
||||||
|
// rewardFlag,
|
||||||
|
discountedPrice,
|
||||||
|
discountAmount,
|
||||||
|
originalPrice,
|
||||||
|
promotionCode,
|
||||||
|
} = usePriceInfo(priceInfo) || {};
|
||||||
|
|
||||||
|
const renderItem = useCallback(() => {
|
||||||
|
return (
|
||||||
|
<div className={css.wrapper}>
|
||||||
|
<span className={css.name}>
|
||||||
|
{patncNm
|
||||||
|
? patncNm + " " + $L("Price")
|
||||||
|
: patnrName + " " + $L("Price")}
|
||||||
|
</span>
|
||||||
|
<div className={css.btmLayer}>
|
||||||
|
{discountRate && Number(discountRate.replace("%", "")) > 4 && (
|
||||||
|
<div className={css.rateTag}>{discountRate}</div>
|
||||||
|
)}
|
||||||
|
<span className={css.price}>
|
||||||
|
{isDiscountedPriceEmpty ? offerInfo : discountedPrice}
|
||||||
|
</span>
|
||||||
|
{isDiscounted && (
|
||||||
|
<span className={css.discountedPrc}>
|
||||||
|
{originalPrice && isOriginalPriceEmpty
|
||||||
|
? offerInfo
|
||||||
|
: originalPrice}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 할부 */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div>{renderItem()}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"main": "BuyNowPriceDisplay.jsx",
|
||||||
|
"styles": [
|
||||||
|
"BuyNowPriceDisplay.module.less"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import usePriceInfo from "../../../../hooks/usePriceInfo";
|
||||||
|
import css from "./ProductPriceDisplay.module.less";
|
||||||
|
import { $L } from "../../../../utils/helperMethods";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import ShopByMobilePriceDisplay from "./ShopByMobilePriceDisplay/ShopByMobilePriceDisplay";
|
||||||
|
import BuyNowPriceDisplay from "./BuyNowPriceDisplay/BuyNowPriceDisplay";
|
||||||
|
|
||||||
|
export default function ProductPriceDisplay({ productType, productInfo }) {
|
||||||
|
const webOSVersion = useSelector(
|
||||||
|
(state) => state.common.appStatus.webOSVersion
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
price5,
|
||||||
|
priceInfo,
|
||||||
|
rewd,
|
||||||
|
offerInfo,
|
||||||
|
patncNm,
|
||||||
|
patnrName,
|
||||||
|
installmentMonths,
|
||||||
|
orderPhnNo,
|
||||||
|
} = productInfo;
|
||||||
|
|
||||||
|
const {
|
||||||
|
discountRate,
|
||||||
|
// rewardFlag,
|
||||||
|
discountedPrice,
|
||||||
|
discountAmount,
|
||||||
|
originalPrice,
|
||||||
|
promotionCode,
|
||||||
|
} = usePriceInfo(priceInfo) || {};
|
||||||
|
|
||||||
|
const isOriginalPriceEmpty = useMemo(() => {
|
||||||
|
return parseFloat(originalPrice.replace(/[^0-9.-]+/g, "")) === 0;
|
||||||
|
}, [originalPrice]);
|
||||||
|
|
||||||
|
const isDiscountedPriceEmpty = useMemo(() => {
|
||||||
|
return parseFloat(discountedPrice.replace(/[^0-9.-]+/g, "")) === 0;
|
||||||
|
}, [discountedPrice]);
|
||||||
|
|
||||||
|
const isDiscounted = useMemo(() => {
|
||||||
|
return discountedPrice !== originalPrice;
|
||||||
|
}, [discountedPrice, originalPrice]);
|
||||||
|
|
||||||
|
const isThemeBuyNow = useMemo(() => {
|
||||||
|
return (
|
||||||
|
productType === "theme" &&
|
||||||
|
productInfo?.pmtSuptYn === "Y" &&
|
||||||
|
webOSVersion >= "6.0"
|
||||||
|
);
|
||||||
|
}, [productType, productInfo?.pmtSuptYn, webOSVersion]);
|
||||||
|
|
||||||
|
const isThemeShopByMobile = useMemo(() => {
|
||||||
|
return (
|
||||||
|
productType === "theme" &&
|
||||||
|
(productInfo?.pmtSuptYn === "N" || webOSVersion < "6.0")
|
||||||
|
);
|
||||||
|
}, [productType, productInfo?.pmtSuptYn, webOSVersion]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{productType && productInfo && (
|
||||||
|
<div>
|
||||||
|
{/* shop by mobile (구매불가) 상품 price render */}
|
||||||
|
{(productType === "shopByMobile" || isThemeShopByMobile) && (
|
||||||
|
<ShopByMobilePriceDisplay
|
||||||
|
priceData={productInfo}
|
||||||
|
priceInfo={priceInfo}
|
||||||
|
isOriginalPriceEmpty={isOriginalPriceEmpty}
|
||||||
|
isDiscountedPriceEmpty={isDiscountedPriceEmpty}
|
||||||
|
isDiscounted={isDiscounted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* buy now (결제 가능) 상품 price render */}
|
||||||
|
{(productType === "buyNow" || isThemeBuyNow) && (
|
||||||
|
<BuyNowPriceDisplay
|
||||||
|
priceData={productInfo}
|
||||||
|
priceInfo={priceInfo}
|
||||||
|
isOriginalPriceEmpty={isOriginalPriceEmpty}
|
||||||
|
isDiscountedPriceEmpty={isDiscountedPriceEmpty}
|
||||||
|
isDiscounted={isDiscounted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
@import "../../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.partnerName {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 36px;
|
||||||
|
color: @COLOR_BLACK;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.flex(@alignCenter:flex-start,@direction:column);
|
||||||
|
.topLayer {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
height: 42px;
|
||||||
|
.flex();
|
||||||
|
}
|
||||||
|
.rateTag {
|
||||||
|
background: linear-gradient(309.43deg, #ef775b 23.84%, #c70850 100%);
|
||||||
|
width: 70px;
|
||||||
|
height: 42px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: @COLOR_WHITE;
|
||||||
|
margin-right: 10px;
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
.flex();
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 36px;
|
||||||
|
color: @COLOR_GRAY07;
|
||||||
|
}
|
||||||
|
.btmLayer {
|
||||||
|
.flex();
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.price {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 60px;
|
||||||
|
color: @PRIMARY_COLOR_RED;
|
||||||
|
margin-right: 9px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.offerInfo {
|
||||||
|
.elip(4);
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
color: #808080;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
.discountedPrc {
|
||||||
|
font-size: 24px;
|
||||||
|
color: @COLOR_GRAY03;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
//rewd Layer
|
||||||
|
.rewdTopLayer {
|
||||||
|
width: 500px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid @COLOR_GRAY02;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 36px;
|
||||||
|
color: @COLOR_GRAY03;
|
||||||
|
}
|
||||||
|
.partnerPrc {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rewdBtmLayer {
|
||||||
|
padding-top: 24px;
|
||||||
|
.flex(@direction:column,@alignCenter:flex-start);
|
||||||
|
.rewdNm {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 36px;
|
||||||
|
color: @COLOR_BLACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btmPrc {
|
||||||
|
margin-top: 17px;
|
||||||
|
.flex();
|
||||||
|
.rewdPrc {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #808080;
|
||||||
|
margin-right: 10px;
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
.rewdRate {
|
||||||
|
font-size: 60px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: @PRIMARY_COLOR_RED;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
import usePriceInfo from "../../../../../hooks/usePriceInfo";
|
||||||
|
import { $L } from "../../../../../utils/helperMethods";
|
||||||
|
import css from "./ShopByMobilePriceDisplay.module.less";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
export default function ShopByMobilePriceDisplay({
|
||||||
|
priceData,
|
||||||
|
priceInfo,
|
||||||
|
isOriginalPriceEmpty,
|
||||||
|
isDiscountedPriceEmpty,
|
||||||
|
isDiscounted,
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
price5,
|
||||||
|
rewd,
|
||||||
|
offerInfo,
|
||||||
|
patncNm,
|
||||||
|
patnrName,
|
||||||
|
installmentMonths,
|
||||||
|
orderPhnNo,
|
||||||
|
} = priceData;
|
||||||
|
|
||||||
|
const {
|
||||||
|
discountRate,
|
||||||
|
rewardFlag,
|
||||||
|
discountedPrice,
|
||||||
|
discountAmount,
|
||||||
|
originalPrice,
|
||||||
|
promotionCode,
|
||||||
|
} = usePriceInfo(priceInfo) || {};
|
||||||
|
|
||||||
|
const TYPE_CASE = useMemo(
|
||||||
|
() => ({
|
||||||
|
case1: !isOriginalPriceEmpty && isDiscountedPriceEmpty && !price5,
|
||||||
|
case2: !isOriginalPriceEmpty && !isDiscountedPriceEmpty && !price5,
|
||||||
|
case3: !isOriginalPriceEmpty && !isDiscountedPriceEmpty && price5,
|
||||||
|
case4: !isOriginalPriceEmpty && isDiscountedPriceEmpty && price5,
|
||||||
|
case5: !isOriginalPriceEmpty && isDiscountedPriceEmpty && !price5,
|
||||||
|
case6: !isOriginalPriceEmpty && !isDiscountedPriceEmpty && !price5,
|
||||||
|
case7: !isOriginalPriceEmpty && !isDiscountedPriceEmpty && price5,
|
||||||
|
case8: !isOriginalPriceEmpty && isDiscountedPriceEmpty && price5,
|
||||||
|
case9: !!(isOriginalPriceEmpty && isDiscountedPriceEmpty && offerInfo),
|
||||||
|
}),
|
||||||
|
[isOriginalPriceEmpty, isDiscountedPriceEmpty, price5, offerInfo]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPriceItem = useCallback(() => {
|
||||||
|
if (priceData && !promotionCode) {
|
||||||
|
if (rewd) {
|
||||||
|
return (
|
||||||
|
<div className={css.wrapper}>
|
||||||
|
<div className={css.rewdTopLayer}>
|
||||||
|
<span>
|
||||||
|
{patncNm
|
||||||
|
? patncNm + " " + $L("Price") + " "
|
||||||
|
: patnrName + " " + $L("Price") + " "}
|
||||||
|
</span>
|
||||||
|
<span className={css.partnerPrc}>
|
||||||
|
{TYPE_CASE.case5 || TYPE_CASE.case8
|
||||||
|
? isOriginalPriceEmpty
|
||||||
|
? offerInfo
|
||||||
|
: originalPrice
|
||||||
|
: discountedPrice}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={css.rewdBtmLayer}>
|
||||||
|
<span className={css.rewdNm}>{$L("Shop Time Price")}</span>
|
||||||
|
<div className={css.btmPrc}>
|
||||||
|
{/* TODO : rewd data*/}
|
||||||
|
{/* 분할금액 조건문처리 케이스별로 */}
|
||||||
|
<span className={css.rewdRate}>{discountedPrice} </span>
|
||||||
|
{/* 리워드 금액 */}
|
||||||
|
{(TYPE_CASE.case7 ||
|
||||||
|
TYPE_CASE.case5 ||
|
||||||
|
TYPE_CASE.case6 ||
|
||||||
|
TYPE_CASE.case8) && (
|
||||||
|
<span className={css.rewdPrc}>
|
||||||
|
{$L("Save") + discountAmount + discountRate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (TYPE_CASE.case1 || TYPE_CASE.case4) {
|
||||||
|
return (
|
||||||
|
<div className={css.wrapper}>
|
||||||
|
<span className={css.name}>
|
||||||
|
{patncNm
|
||||||
|
? patncNm + " " + $L("Price")
|
||||||
|
: patnrName + " " + $L("Price")}
|
||||||
|
</span>
|
||||||
|
<div className={css.btmLayer}>
|
||||||
|
<span className={classNames(css.price, css.case01)}>
|
||||||
|
{isOriginalPriceEmpty ? offerInfo : originalPrice}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (TYPE_CASE.case2) {
|
||||||
|
return (
|
||||||
|
<div className={css.wrapper}>
|
||||||
|
<div className={css.topLayer}>
|
||||||
|
{discountRate && Number(discountRate.replace("%", "")) > 4 && (
|
||||||
|
<div className={css.rateTag}>{discountRate}</div>
|
||||||
|
)}
|
||||||
|
<span className={css.name}>
|
||||||
|
{patncNm
|
||||||
|
? patncNm + " " + $L("Price")
|
||||||
|
: patnrName + $L("Price")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={css.btmLayer}>
|
||||||
|
<span className={css.price}>
|
||||||
|
{isDiscountedPriceEmpty ? offerInfo : discountedPrice}
|
||||||
|
</span>
|
||||||
|
{isDiscounted && (
|
||||||
|
<span className={css.discountedPrc}>
|
||||||
|
{originalPrice && isOriginalPriceEmpty
|
||||||
|
? offerInfo
|
||||||
|
: originalPrice}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (TYPE_CASE.case3) {
|
||||||
|
return (
|
||||||
|
<div className={css.wrapper}>
|
||||||
|
<span className={css.name}>
|
||||||
|
{patncNm
|
||||||
|
? patncNm + " " + $L("Price")
|
||||||
|
: patnrName + " " + $L("Price")}
|
||||||
|
</span>
|
||||||
|
<div className={css.btmLayer}>
|
||||||
|
{discountRate && Number(discountRate.replace("%", "")) > 4 && (
|
||||||
|
<div className={css.rateTag}>{discountRate}</div>
|
||||||
|
)}
|
||||||
|
<span className={css.price}>
|
||||||
|
{isDiscountedPriceEmpty ? offerInfo : discountedPrice}
|
||||||
|
</span>
|
||||||
|
{isDiscounted && (
|
||||||
|
<span className={css.discountedPrc}>
|
||||||
|
{originalPrice && isOriginalPriceEmpty
|
||||||
|
? offerInfo
|
||||||
|
: originalPrice}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 할부 */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (promotionCode) {
|
||||||
|
return (
|
||||||
|
<div className={css.wrapper}>
|
||||||
|
<div className={css.topLayer}>
|
||||||
|
{discountRate && Number(discountRate.replace("%", "")) > 4 && (
|
||||||
|
<div className={css.rateTag}>{discountRate}</div>
|
||||||
|
)}
|
||||||
|
<span className={css.name}>{$L("Shop Time Price")}</span>
|
||||||
|
</div>
|
||||||
|
<div className={css.btmLayer}>
|
||||||
|
<span className={css.price}>{discountedPrice}</span>
|
||||||
|
{discountedPrice !== originalPrice && (
|
||||||
|
<span className={css.discountedPrc}>
|
||||||
|
{originalPrice && isOriginalPriceEmpty
|
||||||
|
? offerInfo
|
||||||
|
: originalPrice}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (TYPE_CASE.case9) {
|
||||||
|
return (
|
||||||
|
<div className={css.wrapper}>
|
||||||
|
<span className={css.partnerName}>
|
||||||
|
{patncNm ? patncNm + " " + $L("Price") : patnrName + $L("Price")}
|
||||||
|
</span>
|
||||||
|
<span className={css.offerInfo}>{offerInfo}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
patnrName,
|
||||||
|
priceInfo,
|
||||||
|
isOriginalPriceEmpty,
|
||||||
|
isDiscountedPriceEmpty,
|
||||||
|
TYPE_CASE,
|
||||||
|
offerInfo,
|
||||||
|
isDiscounted,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return <div>{renderPriceItem()}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
@import "../../../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.partnerName {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 36px;
|
||||||
|
color: @COLOR_BLACK;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.flex(@alignCenter:flex-start,@direction:column);
|
||||||
|
.topLayer {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
height: 42px;
|
||||||
|
.flex();
|
||||||
|
}
|
||||||
|
.rateTag {
|
||||||
|
background: linear-gradient(309.43deg, #ef775b 23.84%, #c70850 100%);
|
||||||
|
width: 70px;
|
||||||
|
height: 42px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: @COLOR_WHITE;
|
||||||
|
margin-right: 10px;
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 24px);
|
||||||
|
font-weight: 700;
|
||||||
|
.flex();
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 36px;
|
||||||
|
color: @COLOR_GRAY07;
|
||||||
|
}
|
||||||
|
.btmLayer {
|
||||||
|
.flex();
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.price {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 60px;
|
||||||
|
color: @PRIMARY_COLOR_RED;
|
||||||
|
margin-right: 9px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.offerInfo {
|
||||||
|
.elip(4);
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
color: #808080;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
.discountedPrc {
|
||||||
|
font-size: 24px;
|
||||||
|
color: @COLOR_GRAY03;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
//rewd Layer
|
||||||
|
.rewdTopLayer {
|
||||||
|
width: 500px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid @COLOR_GRAY02;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 36px;
|
||||||
|
color: @COLOR_GRAY03;
|
||||||
|
}
|
||||||
|
.partnerPrc {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rewdBtmLayer {
|
||||||
|
padding-top: 24px;
|
||||||
|
.flex(@direction:column,@alignCenter:flex-start);
|
||||||
|
.rewdNm {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 36px;
|
||||||
|
color: @COLOR_BLACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btmPrc {
|
||||||
|
margin-top: 17px;
|
||||||
|
.flex();
|
||||||
|
.rewdPrc {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #808080;
|
||||||
|
margin-right: 10px;
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
.rewdRate {
|
||||||
|
font-size: 60px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: @PRIMARY_COLOR_RED;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"main": "ShopByMobilePriceDisplay.jsx",
|
||||||
|
"styles": [
|
||||||
|
"ShopByMobilePriceDisplay.module.less"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"main": "ProductPriceDisplay.jsx",
|
||||||
|
"styles": [
|
||||||
|
"ProductPriceDisplay.module.less"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"main": "ProductOverview.jsx",
|
||||||
|
"styles": [
|
||||||
|
"ProductOverview.module.less"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import classNames from "classnames/bind";
|
||||||
|
|
||||||
|
import css from "./ThemeItemListOverlay.module.less";
|
||||||
|
import TVirtualGridList from "../../../components/TVirtualGridList/TVirtualGridList";
|
||||||
|
import useScrollTo from "../../../hooks/useScrollTo";
|
||||||
|
import { finishVideoPreview } from "../../../actions/playActions";
|
||||||
|
import { panel_names } from "../../../utils/Config";
|
||||||
|
import { setContainerLastFocusedElement } from "@enact/spotlight/src/container";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { popPanel, pushPanel } from "../../../actions/panelActions";
|
||||||
|
import { clearThemeDetail } from "../../../actions/homeActions";
|
||||||
|
import TItemCard from "../../../components/TItemCard/TItemCard";
|
||||||
|
import * as Config from "../../../utils/Config";
|
||||||
|
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
||||||
|
import { $L } from "../../../utils/helperMethods";
|
||||||
|
import TButton from "../../../components/TButton/TButton";
|
||||||
|
import Spotlight from "@enact/spotlight";
|
||||||
|
|
||||||
|
const Container = SpotlightContainerDecorator(
|
||||||
|
{ enterTo: "default-element" },
|
||||||
|
"div"
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function ThemeItemListOverlay({
|
||||||
|
productInfo,
|
||||||
|
isOpen,
|
||||||
|
panelInfo,
|
||||||
|
productType,
|
||||||
|
setSelectedIndex,
|
||||||
|
openThemeItemOverlay,
|
||||||
|
setOpenThemeItemOverlay,
|
||||||
|
}) {
|
||||||
|
const { getScrollTo, scrollLeft } = useScrollTo();
|
||||||
|
const panels = useSelector((state) => state.panels.panels);
|
||||||
|
const themeProductInfos = useSelector(
|
||||||
|
(state) => state.home.themeCurationDetailInfoData
|
||||||
|
);
|
||||||
|
const overlayRef = useRef(null);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e) {
|
||||||
|
/* 오버레이 이외 영역 클릭시 창 닫음 처리 */
|
||||||
|
if (
|
||||||
|
openThemeItemOverlay &&
|
||||||
|
overlayRef.current &&
|
||||||
|
!overlayRef.current.contains(e.target)
|
||||||
|
) {
|
||||||
|
setOpenThemeItemOverlay(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (openThemeItemOverlay) {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [openThemeItemOverlay, setOpenThemeItemOverlay]);
|
||||||
|
|
||||||
|
const launchedFromPlayer = useMemo(() => {
|
||||||
|
const detailPanelIndex = panels.findIndex(
|
||||||
|
({ name }) => name === "detailpanel"
|
||||||
|
);
|
||||||
|
const playerPanelIndex = panels.findIndex(
|
||||||
|
({ name }) => name === "playerpanel"
|
||||||
|
);
|
||||||
|
|
||||||
|
return detailPanelIndex - 1 === playerPanelIndex;
|
||||||
|
}, [panels]);
|
||||||
|
|
||||||
|
const onKeyDown = useCallback((event) => {
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
setOpenThemeItemOverlay(false);
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
Spotlight.focus("theme-open-button");
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
({ index, ...rest }) => {
|
||||||
|
const {
|
||||||
|
imgUrls,
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
prdtNm,
|
||||||
|
priceInfo,
|
||||||
|
offerInfo,
|
||||||
|
patncNm,
|
||||||
|
brndNm,
|
||||||
|
curationNm,
|
||||||
|
// lgCatCd,
|
||||||
|
// lgCatNm,
|
||||||
|
} = productInfo.productInfos[index];
|
||||||
|
|
||||||
|
const handleItemClick = () => {
|
||||||
|
setSelectedIndex(index);
|
||||||
|
dispatch(finishVideoPreview());
|
||||||
|
dispatch(popPanel(panel_names.DETAIL_PANEL));
|
||||||
|
|
||||||
|
// setContainerLastFocusedElement(null, ["indicator-GridListContainer"]);
|
||||||
|
if (themeProductInfos && themeProductInfos.length > 0) {
|
||||||
|
dispatch(clearThemeDetail());
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
pushPanel({
|
||||||
|
name: panel_names.DETAIL_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
patnrId,
|
||||||
|
prdtId,
|
||||||
|
curationId: productInfo?.curationId,
|
||||||
|
curationNm: productInfo ? launchedFromPlayer : launchedFromPlayer,
|
||||||
|
type: "theme",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setOpenThemeItemOverlay(false);
|
||||||
|
// cursorOpen.current.stop();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<TItemCard
|
||||||
|
{...rest}
|
||||||
|
key={prdtId}
|
||||||
|
// contextName={Config.LOG_CONTEXT_NAME.YOUMAYLIKE}
|
||||||
|
// messageId={Config.LOG_MESSAGE_ID.CONTENTCLICK}
|
||||||
|
productId={prdtId}
|
||||||
|
productTitle={prdtNm}
|
||||||
|
// nowProductId={productInfo?.prdtId}
|
||||||
|
// nowProductTitle={productInfo?.prdtNm}
|
||||||
|
// nowCategory={productInfo?.catNm}
|
||||||
|
// catNm={lgCatNm}
|
||||||
|
patnerName={patncNm}
|
||||||
|
brandName={brndNm}
|
||||||
|
imageAlt={prdtId}
|
||||||
|
imageSource={imgUrls}
|
||||||
|
priceInfo={priceInfo}
|
||||||
|
offerInfo={offerInfo}
|
||||||
|
productName={prdtNm}
|
||||||
|
onClick={handleItemClick}
|
||||||
|
label={index * 1 + 1 + " of " + themeProductInfos.length}
|
||||||
|
lastLabel=" go to detail, button"
|
||||||
|
className={css.themeItemCard}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[productInfo?.productInfos, launchedFromPlayer, setOpenThemeItemOverlay]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (openThemeItemOverlay) {
|
||||||
|
Spotlight.focus("theme-close-button");
|
||||||
|
}
|
||||||
|
}, [openThemeItemOverlay]);
|
||||||
|
|
||||||
|
const handleButtonClick = useCallback(() => {
|
||||||
|
setOpenThemeItemOverlay(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
Spotlight.focus("theme-open-button");
|
||||||
|
}, 0);
|
||||||
|
}, [setOpenThemeItemOverlay]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={overlayRef}>
|
||||||
|
{productInfo && productInfo?.productInfos?.length > 0 && isOpen && (
|
||||||
|
<Container className={css.container}>
|
||||||
|
<TButton
|
||||||
|
className={css.button}
|
||||||
|
onClick={handleButtonClick}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
spotlightId="theme-close-button"
|
||||||
|
>
|
||||||
|
{$L("THEME ITEM")}
|
||||||
|
</TButton>
|
||||||
|
<TVirtualGridList
|
||||||
|
cbScrollTo={getScrollTo}
|
||||||
|
dataSize={productInfo?.productInfos.length}
|
||||||
|
direction="horizontal"
|
||||||
|
autoScroll
|
||||||
|
renderItem={renderItem}
|
||||||
|
itemWidth={470}
|
||||||
|
itemHeight={170}
|
||||||
|
spacing={15}
|
||||||
|
className={css.themeItemList}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
@import "../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.container {
|
||||||
|
.size(@w: 1920px,@h: 390px);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
height: 390px;
|
||||||
|
background: rgba(30, 30, 30, 0.8);
|
||||||
|
z-index: 23;
|
||||||
|
.flex(@justifyCenter:space-between,@direction:column,@alignCenter:flex-start);
|
||||||
|
padding: 60px 0;
|
||||||
|
|
||||||
|
.button {
|
||||||
|
margin-left: 70px;
|
||||||
|
.size(@w: 470px,@h:70px);
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: #c72054;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.themeItemList {
|
||||||
|
.size(@w: 1920px,@h: 170px);
|
||||||
|
padding-left: 70px;
|
||||||
|
|
||||||
|
.themeItemCard {
|
||||||
|
.flex(@justifyCenter:space-between);
|
||||||
|
background: rgba(44, 44, 44, 1);
|
||||||
|
border: none;
|
||||||
|
padding: 30px;
|
||||||
|
|
||||||
|
.size(@w: 470px,@h: 170px);
|
||||||
|
|
||||||
|
/* img */
|
||||||
|
> div {
|
||||||
|
.size(@w: 110px,@h: 110px);
|
||||||
|
> img {
|
||||||
|
.size(@w: 110px,@h: 110px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* description */
|
||||||
|
> div:nth-child(2) {
|
||||||
|
.size(@w: 285px,@h: 110px);
|
||||||
|
|
||||||
|
> div > h3 {
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 20px);
|
||||||
|
height: 50px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 25px;
|
||||||
|
color: rgba(234, 234, 234, 1);
|
||||||
|
.elip(@clamp:2);
|
||||||
|
}
|
||||||
|
|
||||||
|
> p {
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// > img {
|
||||||
|
// .size(@w: 110px,@h: 110px);
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import css from "./CustomScrollbar.module.less";
|
||||||
|
|
||||||
|
const CustomScrollbar = ({ scrollerRef, trackHeight = 100 }) => {
|
||||||
|
const [thumbHeight, setThumbHeight] = useState(60);
|
||||||
|
const [thumbTop, setThumbTop] = useState(0);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const thumbRef = useRef(null);
|
||||||
|
const trackRef = useRef(null);
|
||||||
|
const dragStartY = useRef(0);
|
||||||
|
const dragStartScrollTop = useRef(0);
|
||||||
|
|
||||||
|
// 스크롤바 상태 업데이트
|
||||||
|
const updateScrollbar = useCallback(() => {
|
||||||
|
if (!scrollerRef?.current) return;
|
||||||
|
|
||||||
|
const scroller = scrollerRef.current;
|
||||||
|
const scrollHeight = scroller.scrollHeight;
|
||||||
|
const clientHeight = scroller.clientHeight;
|
||||||
|
const scrollTop = scroller.scrollTop;
|
||||||
|
|
||||||
|
// 스크롤이 필요한지 확인
|
||||||
|
if (scrollHeight <= clientHeight) {
|
||||||
|
setIsVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsVisible(true);
|
||||||
|
|
||||||
|
// 썸 높이 계산 (콘텐츠 비율에 따라, 최소 20px)
|
||||||
|
const newThumbHeight = Math.max(20, (clientHeight / scrollHeight) * trackHeight);
|
||||||
|
setThumbHeight(newThumbHeight);
|
||||||
|
|
||||||
|
// 썸 위치 계산
|
||||||
|
const maxScrollTop = scrollHeight - clientHeight;
|
||||||
|
const maxThumbTop = trackHeight - newThumbHeight;
|
||||||
|
const newThumbTop = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0;
|
||||||
|
setThumbTop(newThumbTop);
|
||||||
|
}, [scrollerRef, trackHeight]);
|
||||||
|
|
||||||
|
// 스크롤 이벤트 리스너
|
||||||
|
useEffect(() => {
|
||||||
|
const scroller = scrollerRef?.current;
|
||||||
|
if (!scroller) return;
|
||||||
|
|
||||||
|
const handleScroll = () => updateScrollbar();
|
||||||
|
scroller.addEventListener('scroll', handleScroll);
|
||||||
|
|
||||||
|
// 초기 상태 설정
|
||||||
|
const timer = setTimeout(updateScrollbar, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scroller.removeEventListener('scroll', handleScroll);
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [scrollerRef, updateScrollbar]);
|
||||||
|
|
||||||
|
// 드래그 시작
|
||||||
|
const handleMouseDown = useCallback((e) => {
|
||||||
|
if (!scrollerRef?.current) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
dragStartY.current = e.clientY;
|
||||||
|
dragStartScrollTop.current = scrollerRef.current.scrollTop;
|
||||||
|
}, [scrollerRef]);
|
||||||
|
|
||||||
|
// 드래그 중
|
||||||
|
const handleMouseMove = useCallback((e) => {
|
||||||
|
if (!isDragging || !scrollerRef?.current) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
const scroller = scrollerRef.current;
|
||||||
|
const deltaY = e.clientY - dragStartY.current;
|
||||||
|
const scrollRatio = scroller.scrollHeight / trackHeight;
|
||||||
|
const newScrollTop = dragStartScrollTop.current + (deltaY * scrollRatio);
|
||||||
|
|
||||||
|
scroller.scrollTop = Math.max(0, Math.min(newScrollTop, scroller.scrollHeight - scroller.clientHeight));
|
||||||
|
}, [isDragging, scrollerRef, trackHeight]);
|
||||||
|
|
||||||
|
// 드래그 끝
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 전역 마우스 이벤트 리스너
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDragging) {
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||||
|
|
||||||
|
// 트랙 클릭
|
||||||
|
const handleTrackClick = useCallback((e) => {
|
||||||
|
if (!scrollerRef?.current || !trackRef.current) return;
|
||||||
|
|
||||||
|
const track = trackRef.current;
|
||||||
|
const rect = track.getBoundingClientRect();
|
||||||
|
const clickY = e.clientY - rect.top;
|
||||||
|
const scroller = scrollerRef.current;
|
||||||
|
|
||||||
|
const scrollRatio = scroller.scrollHeight / trackHeight;
|
||||||
|
const newScrollTop = (clickY - thumbHeight / 2) * scrollRatio;
|
||||||
|
|
||||||
|
scroller.scrollTop = Math.max(0, Math.min(newScrollTop, scroller.scrollHeight - scroller.clientHeight));
|
||||||
|
}, [scrollerRef, trackHeight, thumbHeight]);
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.customScrollbarArea}>
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className={css.scrollbarTrack}
|
||||||
|
onClick={handleTrackClick}
|
||||||
|
style={{ height: `${trackHeight}px` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={thumbRef}
|
||||||
|
className={`${css.customScrollbar} ${isDragging ? css.dragging : ''}`}
|
||||||
|
style={{
|
||||||
|
height: `${thumbHeight}px`,
|
||||||
|
top: `${thumbTop}px`
|
||||||
|
}}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomScrollbar;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
@import "../../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.customScrollbarArea {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 66px;
|
||||||
|
height: calc(100% - 60px); // 하단 60px 마진
|
||||||
|
background: transparent; // 디버깅 완료 후 transparent로 변경
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 0 30px 60px 30px; // 좌우 30px, 하단 60px 마진
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
// 스크롤바 트랙 (100px 고정 영역)
|
||||||
|
.scrollbarTrack {
|
||||||
|
width: 6px;
|
||||||
|
height: 100px; // 고정 높이
|
||||||
|
background: transparent; // 디버깅 완료 후 transparent로 변경
|
||||||
|
position: relative;
|
||||||
|
margin: 0 auto; // 중앙 정렬
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
// 실제 스크롤바 썸
|
||||||
|
.customScrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 60px; // 기본 길이 (JavaScript로 동적 조정)
|
||||||
|
background: #9C9C9C;
|
||||||
|
border-radius: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0; // JavaScript로 동적 조정
|
||||||
|
left: 0;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
cursor: grab;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: @PRIMARY_COLOR_RED;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
background: @PRIMARY_COLOR_RED;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크롤 상태 표시
|
||||||
|
&.active {
|
||||||
|
background: @PRIMARY_COLOR_RED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import MobileSendPopUp from "../../../components/MobileSend/MobileSendPopUp";
|
||||||
|
import * as Config from "../../../utils/Config";
|
||||||
|
import { setHidePopup, setShowPopup } from "../../../actions/commonActions";
|
||||||
|
import Spotlight from "@enact/spotlight";
|
||||||
|
import {
|
||||||
|
sendLogShopByMobile,
|
||||||
|
sendLogTotalRecommend,
|
||||||
|
} from "../../../actions/logActions";
|
||||||
|
import { $L, formatLocalDateTime } from "../../../utils/helperMethods";
|
||||||
|
|
||||||
|
export default function DetailMobileSendPopUp({
|
||||||
|
panelInfo,
|
||||||
|
ismobileSendPopupOpen,
|
||||||
|
selectedIndex,
|
||||||
|
setMobileSendPopupOpen,
|
||||||
|
}) {
|
||||||
|
const productData = useSelector((state) => state.main.productData);
|
||||||
|
const themeProductInfos = useSelector(
|
||||||
|
(state) => state.home.themeCurationDetailInfoData
|
||||||
|
);
|
||||||
|
const { popupVisible, activePopup } = useSelector(
|
||||||
|
(state) => state.common.popup
|
||||||
|
);
|
||||||
|
const shopByMobileLogRef = useRef(null);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const { entryMenu, nowMenu } = useSelector((state) => state.common.menu);
|
||||||
|
|
||||||
|
const mobileSendPopUpProductImg = useMemo(() => {
|
||||||
|
if (panelInfo?.type === "theme" && themeProductInfos) {
|
||||||
|
return themeProductInfos[selectedIndex]?.imgUrls600[0];
|
||||||
|
}
|
||||||
|
// else if (panelInfo?.type === "hotel" && hotelInfos) {
|
||||||
|
// return hotelInfos[selectedIndex]?.hotelImgUrl;
|
||||||
|
// }
|
||||||
|
else {
|
||||||
|
return productData?.imgUrls600[0];
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
themeProductInfos,
|
||||||
|
// hotelInfos,
|
||||||
|
selectedIndex,
|
||||||
|
productData,
|
||||||
|
panelInfo?.type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mobileSendPopUpSubtitle = useMemo(() => {
|
||||||
|
// if (panelInfo?.type === "theme" && themeProductInfos) {
|
||||||
|
// return themeProductInfos[selectedIndex]?.prdtNm;
|
||||||
|
// }
|
||||||
|
// else if (panelInfo?.type === "hotel" && hotelInfos) {
|
||||||
|
// return hotelInfos[selectedIndex]?.hotelNm;
|
||||||
|
// }
|
||||||
|
// else {
|
||||||
|
return productData?.prdtNm;
|
||||||
|
// }
|
||||||
|
}, [
|
||||||
|
// themeProductInfos,
|
||||||
|
// hotelInfos,
|
||||||
|
selectedIndex,
|
||||||
|
productData,
|
||||||
|
panelInfo?.type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleSMSonClose = useCallback(() => {
|
||||||
|
dispatch(setHidePopup());
|
||||||
|
setMobileSendPopupOpen(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
Spotlight.focus("spotlightId_backBtn");
|
||||||
|
Spotlight.focus("shopbymobile_Btn");
|
||||||
|
}, 0);
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// const Price = () => {
|
||||||
|
// return (
|
||||||
|
// <>
|
||||||
|
// {hotelInfos[selectedIndex]?.hotelDetailInfo.currencySign}
|
||||||
|
// {hotelInfos[selectedIndex]?.hotelDetailInfo.price}
|
||||||
|
// </>
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
|
||||||
|
const handleMobileSendPopupOpen = useCallback(() => {
|
||||||
|
dispatch(setShowPopup(Config.ACTIVE_POPUP.smsPopup));
|
||||||
|
|
||||||
|
if (productData && Object.keys(productData).length > 0) {
|
||||||
|
const { priceInfo, patncNm, prdtId, prdtNm, brndNm, catNm } = productData;
|
||||||
|
const regularPrice = priceInfo.split("|")[0];
|
||||||
|
const discountPrice = priceInfo.split("|")[1];
|
||||||
|
const discountRate = priceInfo.split("|")[4];
|
||||||
|
const logParams = {
|
||||||
|
status: "open",
|
||||||
|
nowMenu: nowMenu,
|
||||||
|
partner: patncNm,
|
||||||
|
productId: prdtId,
|
||||||
|
productTitle: prdtNm,
|
||||||
|
price: discountRate ? discountPrice : regularPrice,
|
||||||
|
brand: brndNm,
|
||||||
|
discount: discountRate,
|
||||||
|
category: catNm,
|
||||||
|
contextName: Config.LOG_CONTEXT_NAME.SHOPBYMOBILE,
|
||||||
|
messageId: Config.LOG_MESSAGE_ID.SMB,
|
||||||
|
};
|
||||||
|
dispatch(sendLogTotalRecommend(logParams));
|
||||||
|
dispatch(
|
||||||
|
sendLogTotalRecommend({
|
||||||
|
menu: Config.LOG_MENU.DETAIL_PAGE_BILLING_PRODUCT_DETAIL,
|
||||||
|
buttonTitle: "Shop By Mobile",
|
||||||
|
contextName: Config.LOG_CONTEXT_NAME.DETAILPAGE,
|
||||||
|
messageId: Config.LOG_MESSAGE_ID.BUTTONCLICK,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (productData && Object.keys(productData).length > 0) {
|
||||||
|
const params = {
|
||||||
|
befPrice: productData?.priceInfo?.split("|")[0],
|
||||||
|
curationId: productData?.curationId ?? "",
|
||||||
|
curationNm: productData?.curationNm ?? "",
|
||||||
|
evntId: "",
|
||||||
|
evntNm: "",
|
||||||
|
lastPrice: productData?.priceInfo?.split("|")[1],
|
||||||
|
lgCatCd: productData?.catCd ?? "",
|
||||||
|
lgCatNm: productData?.catNm ?? "",
|
||||||
|
liveFlag: panelInfo?.liveFlag ?? "N",
|
||||||
|
locDt: formatLocalDateTime(new Date()),
|
||||||
|
logTpNo: Config.LOG_TP_NO.SHOP_BY_MOBILE.SHOP_BY_MOBILE,
|
||||||
|
mbphNoFlag: "N",
|
||||||
|
patncNm: productData?.patncNm,
|
||||||
|
patnrId: productData?.patnrId,
|
||||||
|
prdtId: productData?.prdtId,
|
||||||
|
prdtNm: productData?.prdtNm,
|
||||||
|
revwGrd: productData?.revwGrd ?? "",
|
||||||
|
rewdAplyFlag: productData?.priceInfo?.split("|")[2],
|
||||||
|
shopByMobileFlag: "Y",
|
||||||
|
shopTpNm: "product",
|
||||||
|
showId: productData?.showId ?? "",
|
||||||
|
showNm: productData?.showNm ?? "",
|
||||||
|
trmsAgrFlag: "N",
|
||||||
|
tsvFlag: productData?.todaySpclFlag ?? "",
|
||||||
|
};
|
||||||
|
dispatch(sendLogShopByMobile(params));
|
||||||
|
shopByMobileLogRef.current = params;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
panelInfo?.liveFlag,
|
||||||
|
productData,
|
||||||
|
shopByMobileLogRef,
|
||||||
|
dispatch,
|
||||||
|
ismobileSendPopupOpen,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("ismobileSendPopupOpen @@@@ :", ismobileSendPopupOpen);
|
||||||
|
if (ismobileSendPopupOpen) {
|
||||||
|
handleMobileSendPopupOpen();
|
||||||
|
}
|
||||||
|
}, [ismobileSendPopupOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{activePopup === Config.ACTIVE_POPUP.smsPopup && (
|
||||||
|
<MobileSendPopUp
|
||||||
|
open={popupVisible}
|
||||||
|
onClose={handleSMSonClose}
|
||||||
|
title={$L("Send a purchase link for this item via SMS")}
|
||||||
|
subTitle={mobileSendPopUpSubtitle}
|
||||||
|
patncNm={productData?.patncNm}
|
||||||
|
productImg={mobileSendPopUpProductImg}
|
||||||
|
patnrId={panelInfo?.patnrId}
|
||||||
|
prdtId={panelInfo?.prdtId}
|
||||||
|
smsTpCd={panelInfo?.type === "hotel" ? "APP00205" : "APP00201"}
|
||||||
|
curationId={panelInfo?.curationId}
|
||||||
|
curationNm={panelInfo?.curationNm}
|
||||||
|
// hotelId={
|
||||||
|
// panelInfo?.type === "hotel" && hotelInfos[selectedIndex]?.hotelId
|
||||||
|
// }
|
||||||
|
// hotelNm={
|
||||||
|
// panelInfo?.type === "hotel" && hotelInfos[selectedIndex]?.hotelNm
|
||||||
|
// }
|
||||||
|
// hotelDtlUrl={
|
||||||
|
// panelInfo?.type === "hotel" &&
|
||||||
|
// hotelInfos[selectedIndex]?.hotelDetailInfo?.hotelDtlUrl
|
||||||
|
// }
|
||||||
|
// productPrice={panelInfo?.type === "hotel" && Price()}
|
||||||
|
shopByMobileLogRef={shopByMobileLogRef}
|
||||||
|
spotlightId="shopbymobile_Btn"
|
||||||
|
// smsText={productInfo?.pmtSuptYn === "Y" ? detailUrl : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@import "../../../style/CommonStyle.module.less";"
|
||||||
|
@import "../../../style/utils.module.less";
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import { Marquee } from "@enact/sandstone/Marquee";
|
||||||
|
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
||||||
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
|
|
||||||
|
import { $L } from "../../../utils/helperMethods";
|
||||||
|
import defaultLogoImg from "../../../../assets/images/ic-tab-partners-default@3x.png";
|
||||||
|
import qvcLogoImg from "../../../../assets/images/icons/ic-partners-qvc@3x.png";
|
||||||
|
import css from "./THeaderCustom.module.less";
|
||||||
|
|
||||||
|
const Container = SpotlightContainerDecorator(
|
||||||
|
{ enterTo: "last-focused" },
|
||||||
|
"div"
|
||||||
|
);
|
||||||
|
const SpottableComponent = Spottable("button");
|
||||||
|
|
||||||
|
export default function THeaderCustom({
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
onBackButton,
|
||||||
|
onSpotlightUp,
|
||||||
|
onSpotlightLeft,
|
||||||
|
marqueeDisabled = true,
|
||||||
|
onClick,
|
||||||
|
ariaLabel,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
const convertedTitle = useMemo(() => {
|
||||||
|
if (title && typeof title === 'string') {
|
||||||
|
const cleanedTitle = title.replace(/(\r\n|\n)/g, "");
|
||||||
|
return $L(marqueeDisabled ? title : cleanedTitle);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, [marqueeDisabled, title]);
|
||||||
|
|
||||||
|
const _onClick = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (onClick) {
|
||||||
|
onClick(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _onSpotlightUp = (e) => {
|
||||||
|
if (onSpotlightUp) {
|
||||||
|
onSpotlightUp(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _onSpotlightLeft = (e) => {
|
||||||
|
if (onSpotlightLeft) {
|
||||||
|
onSpotlightLeft(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className={classNames(css.tHeaderCustom, className)} {...rest}>
|
||||||
|
{onBackButton && (
|
||||||
|
<SpottableComponent
|
||||||
|
className={css.button}
|
||||||
|
onClick={_onClick}
|
||||||
|
spotlightId={"spotlightId_backBtn"}
|
||||||
|
onSpotlightUp={_onSpotlightUp}
|
||||||
|
onSpotlightLeft={_onSpotlightLeft}
|
||||||
|
aria-label="Back"
|
||||||
|
role="button"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={css.centerImage}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("${qvcLogoImg}")`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Marquee
|
||||||
|
marqueeOn="render"
|
||||||
|
className={css.title}
|
||||||
|
marqueeDisabled={marqueeDisabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{convertedTitle && (
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: convertedTitle }} />
|
||||||
|
)}
|
||||||
|
</Marquee>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
@import "../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.tHeaderCustom {
|
||||||
|
align-self: stretch;
|
||||||
|
margin: 30px 0; // 상하 30px, 좌우 0px 마진 (DetailPanel에서 이미 60px padding 적용)
|
||||||
|
height: 60px; // 마진을 제외한 높이 60px
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
background-color: transparent; // DetailPanel에서는 배경 투명
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 25px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #EAEAEA;
|
||||||
|
padding-left: 0;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-right: 20px; // Header Title 후 간격 (children과의 gap)
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
.size(@w: 60px, @h: 60px);
|
||||||
|
background-size: 60px 60px;
|
||||||
|
background-position: center;
|
||||||
|
background-image: url("../../../../assets/images/btn/btn-60-bk-back-nor@3x.png");
|
||||||
|
border: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 20px; // 되돌아가기 아이콘 후 20px gap
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-radius: 10px;
|
||||||
|
background-image: url("../../../../assets/images/btn/btn-60-wh-back-foc@3x.png");
|
||||||
|
box-shadow: 0px 6px 30px 0 rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.centerImage {
|
||||||
|
.size(@w: 60px, @h: 60px);
|
||||||
|
background-size: contain;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 10px; // 파트너사 로고 후 10px gap
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useMemo,
|
||||||
|
forwardRef,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
|
import { off, on } from "@enact/core/dispatcher";
|
||||||
|
import { Job } from "@enact/core/util";
|
||||||
|
import Scroller from "@enact/sandstone/Scroller";
|
||||||
|
|
||||||
|
import AutoScrollArea, { POSITION } from "../../../../components/AutoScrollArea/AutoScrollArea";
|
||||||
|
import css from "./TScrollerDetail.module.less";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DetailPanel 전용 TScroller - 커스텀 스크롤바 구현
|
||||||
|
* onScroll* event can't use Callback dependency
|
||||||
|
*/
|
||||||
|
const TScrollerDetail = forwardRef(({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
verticalScrollbar = "hidden",
|
||||||
|
focusableScrollbar = false,
|
||||||
|
direction = "vertical",
|
||||||
|
horizontalScrollbar = "hidden",
|
||||||
|
scrollMode,
|
||||||
|
onScrollStart,
|
||||||
|
onScrollStop,
|
||||||
|
onScroll,
|
||||||
|
noScrollByWheel = false,
|
||||||
|
cbScrollTo,
|
||||||
|
autoScroll = direction === "horizontal",
|
||||||
|
setScrollVerticalPos,
|
||||||
|
setCheckScrollPosition,
|
||||||
|
...rest
|
||||||
|
}, ref) => {
|
||||||
|
const { cursorVisible } = useSelector((state) => state.common.appStatus);
|
||||||
|
|
||||||
|
const isScrolling = useRef(false);
|
||||||
|
const scrollPosition = useRef("top");
|
||||||
|
|
||||||
|
const scrollToRef = useRef(null);
|
||||||
|
const scrollHorizontalPos = useRef(0);
|
||||||
|
const scrollVerticalPos = useRef(0);
|
||||||
|
const actualScrollerElement = useRef(null); // 실제 스크롤 DOM 요소
|
||||||
|
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
// ref를 내부 Scroller 요소에 연결
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref && isMounted) {
|
||||||
|
// DOM에서 Scroller 요소 찾기
|
||||||
|
let scrollerElement = document.querySelector(`.${css.tScroller}`);
|
||||||
|
|
||||||
|
if (!scrollerElement) {
|
||||||
|
// 다른 방법으로 찾기
|
||||||
|
scrollerElement = document.querySelector('[data-spotlight-container="true"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scrollerElement) {
|
||||||
|
// 스크롤 가능한 요소 찾기
|
||||||
|
scrollerElement = document.querySelector('[style*="overflow"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollerElement) {
|
||||||
|
// ref가 함수인 경우와 객체인 경우를 모두 처리
|
||||||
|
if (typeof ref === 'function') {
|
||||||
|
ref(scrollerElement);
|
||||||
|
} else if (ref && ref.current !== undefined) {
|
||||||
|
ref.current = scrollerElement;
|
||||||
|
}
|
||||||
|
actualScrollerElement.current = scrollerElement; // 실제 스크롤 요소 저장
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [ref, isMounted]);
|
||||||
|
|
||||||
|
// 스크롤 제어 메서드 추가
|
||||||
|
const scrollToElement = useCallback((element) => {
|
||||||
|
if (actualScrollerElement.current && element) {
|
||||||
|
const scrollerRect = actualScrollerElement.current.getBoundingClientRect();
|
||||||
|
const elementRect = element.getBoundingClientRect();
|
||||||
|
const relativeTop = elementRect.top - scrollerRect.top;
|
||||||
|
const scrollTop = actualScrollerElement.current.scrollTop + relativeTop - 20;
|
||||||
|
|
||||||
|
actualScrollerElement.current.scrollTo({
|
||||||
|
top: scrollTop,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
|
||||||
|
return () => setIsMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const _onScrollStart = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (onScrollStart) {
|
||||||
|
onScrollStart(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
isScrolling.current = true;
|
||||||
|
},
|
||||||
|
[onScrollStart]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _onScrollStop = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (onScrollStop) {
|
||||||
|
onScrollStop(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
isScrolling.current = false;
|
||||||
|
|
||||||
|
if (e.reachedEdgeInfo) {
|
||||||
|
if (e.reachedEdgeInfo.top) {
|
||||||
|
scrollPosition.current = "top";
|
||||||
|
} else if (e.reachedEdgeInfo.bottom) {
|
||||||
|
scrollPosition.current = "bottom";
|
||||||
|
} else if (e.reachedEdgeInfo.left) {
|
||||||
|
scrollPosition.current = "left";
|
||||||
|
} else if (e.reachedEdgeInfo.right) {
|
||||||
|
scrollPosition.current = "right";
|
||||||
|
} else {
|
||||||
|
scrollPosition.current = "middle";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scrollPosition.current = "middle";
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollHorizontalPos.current = e.scrollLeft;
|
||||||
|
scrollVerticalPos.current = e.scrollTop;
|
||||||
|
|
||||||
|
if (setScrollVerticalPos) {
|
||||||
|
setScrollVerticalPos(scrollVerticalPos.current);
|
||||||
|
}
|
||||||
|
if (setCheckScrollPosition) {
|
||||||
|
setCheckScrollPosition(scrollPosition.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onScrollStop]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _onScroll = useCallback(
|
||||||
|
(ev) => {
|
||||||
|
if (onScroll) {
|
||||||
|
onScroll(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onScroll]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _cbScrollTo = useCallback(
|
||||||
|
(ref) => {
|
||||||
|
if (cbScrollTo) {
|
||||||
|
cbScrollTo(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToRef.current = ref;
|
||||||
|
},
|
||||||
|
[cbScrollTo]
|
||||||
|
);
|
||||||
|
|
||||||
|
const relevantPositions = useMemo(() => {
|
||||||
|
switch (direction) {
|
||||||
|
case "horizontal":
|
||||||
|
return ["left", "right"];
|
||||||
|
case "vertical":
|
||||||
|
return ["top", "bottom"];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [direction]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
className ? className : null,
|
||||||
|
css.scrollerContainer
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Scroller
|
||||||
|
cbScrollTo={_cbScrollTo}
|
||||||
|
onScrollStart={_onScrollStart}
|
||||||
|
onScrollStop={_onScrollStop}
|
||||||
|
onScroll={_onScroll}
|
||||||
|
scrollMode={scrollMode || "translate"}
|
||||||
|
focusableScrollbar={focusableScrollbar}
|
||||||
|
className={classNames(
|
||||||
|
isMounted && css.tScroller,
|
||||||
|
noScrollByWheel && css.preventScroll
|
||||||
|
)}
|
||||||
|
direction={direction}
|
||||||
|
horizontalScrollbar={horizontalScrollbar}
|
||||||
|
verticalScrollbar={verticalScrollbar}
|
||||||
|
overscrollEffectOn={{
|
||||||
|
arrowKey: false,
|
||||||
|
drag: false,
|
||||||
|
pageKey: false,
|
||||||
|
track: false,
|
||||||
|
wheel: false,
|
||||||
|
}}
|
||||||
|
noScrollByWheel={noScrollByWheel}
|
||||||
|
noScrollByDrag
|
||||||
|
// rest props에서 ref만 제외하고 전달
|
||||||
|
{...(rest.ref ? { ...rest, ref: undefined } : rest)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Scroller>
|
||||||
|
{cursorVisible &&
|
||||||
|
autoScroll &&
|
||||||
|
relevantPositions.map((pos) => (
|
||||||
|
<AutoScrollArea
|
||||||
|
key={pos}
|
||||||
|
position={POSITION[pos]}
|
||||||
|
autoScroll={autoScroll}
|
||||||
|
scrollHorizontalPos={scrollHorizontalPos}
|
||||||
|
scrollVerticalPos={scrollVerticalPos}
|
||||||
|
scrollToRef={scrollToRef}
|
||||||
|
scrollPosition={scrollPosition}
|
||||||
|
direction={direction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TScrollerDetail에 메서드 노출
|
||||||
|
TScrollerDetail.scrollToElement = (element) => {
|
||||||
|
// 이 메서드는 ref를 통해 접근할 수 있도록 구현
|
||||||
|
};
|
||||||
|
|
||||||
|
// displayName을 명확하게 설정
|
||||||
|
TScrollerDetail.displayName = 'TScrollerDetail';
|
||||||
|
|
||||||
|
// forwardRef를 사용하는 컴포넌트임을 명시
|
||||||
|
export default TScrollerDetail;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
@import "../../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../../style/utils.module.less";
|
||||||
|
|
||||||
|
// DetailPanel 전용 TScroller 스타일
|
||||||
|
.scrollerContainer {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.tScroller {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
// Sandstone Scroller 내부 콘텐츠 컨테이너 스타일
|
||||||
|
> div:nth-child(1) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 두 번째 자식 요소 (스크롤바 영역) 완전히 숨김
|
||||||
|
// > div:nth-child(2) {
|
||||||
|
// display: none !important;
|
||||||
|
// }
|
||||||
|
|
||||||
|
&.preventScroll {
|
||||||
|
> div {
|
||||||
|
overflow: hidden !important; // prevent wheel event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user