[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",
|
||||
SEND_BROADCAST: "SEND_BROADCAST",
|
||||
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_HIDE_POPUP: "SET_HIDE_POPUP",
|
||||
SET_HIDE_SECONDARY_POPUP: "SET_HIDE_SECONDARY_POPUP",
|
||||
@@ -65,7 +66,7 @@ export const types = {
|
||||
|
||||
// home actions
|
||||
GET_HOME_TERMS: "GET_HOME_TERMS",
|
||||
SET_TERMS_ID_MAP: "SET_TERMS_ID_MAP",
|
||||
SET_TERMS_ID_MAP: "SET_TERMS_ID_MAP",
|
||||
SET_OPTIONAL_TERMS_AVAILABILITY: "SET_OPTIONAL_TERMS_AVAILABILITY",
|
||||
GET_HOME_MENU: "GET_HOME_MENU",
|
||||
GET_HOME_LAYOUT: "GET_HOME_LAYOUT",
|
||||
@@ -154,6 +155,7 @@ export const types = {
|
||||
GET_VIDEO_INDECATOR_FOCUS: "GET_VIDEO_INDECATOR_FOCUS",
|
||||
GET_PRODUCT_OPTION_ID: "GET_PRODUCT_OPTION_ID",
|
||||
CLEAR_PRODUCT_OPTIONS: "CLEAR_PRODUCT_OPTIONS",
|
||||
GET_USER_REVIEW: "GET_USER_REVIEW",
|
||||
|
||||
// search actions
|
||||
GET_SEARCH: "GET_SEARCH",
|
||||
@@ -235,9 +237,9 @@ export const types = {
|
||||
// new actions
|
||||
CANCEL_FOCUS_ELEMENT: "CANCEL_FOCUS_ELEMENT",
|
||||
|
||||
// 약관동의 여부 확인 상태
|
||||
// 약관동의 여부 확인 상태
|
||||
GET_TERMS_AGREE_YN_START: "GET_TERMS_AGREE_YN_START",
|
||||
GET_TERMS_AGREE_YN_SUCCESS: "GET_TERMS_AGREE_YN_SUCCESS",
|
||||
GET_TERMS_AGREE_YN_SUCCESS: "GET_TERMS_AGREE_YN_SUCCESS",
|
||||
GET_TERMS_AGREE_YN_FAILURE: "GET_TERMS_AGREE_YN_FAILURE",
|
||||
|
||||
// device
|
||||
@@ -245,4 +247,21 @@ export const types = {
|
||||
|
||||
// 🔽 [추가] 영구재생 비디오 정보 저장
|
||||
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 { types } from "./actionTypes";
|
||||
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
|
||||
// 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) => {
|
||||
const onSuccess = (response) => {
|
||||
console.log("getBestSeller onSuccess", response.data);
|
||||
|
||||
dispatch({
|
||||
type: types.GET_BEST_SELLER,
|
||||
payload: response.data.data,
|
||||
});
|
||||
dispatch({ type: types.GET_BEST_SELLER, payload: get("data.data", response) });
|
||||
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
callback && callback();
|
||||
};
|
||||
|
||||
const onFail = (error) => {
|
||||
console.error("getBestSeller onFail", error);
|
||||
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
callback && callback();
|
||||
};
|
||||
|
||||
TAxios(
|
||||
@@ -41,62 +89,227 @@ export const getBestSeller = (callback) => (dispatch, getState) => {
|
||||
};
|
||||
|
||||
// Detail 옵션상품 정보 조회 IF-LGSP-319
|
||||
export const getProductGroup = (props) => (dispatch, getState) => {
|
||||
const { patnrId, prdtId } = props;
|
||||
|
||||
const onSuccess = (response) => {
|
||||
console.log("getProductGroup onSuccess", response.data);
|
||||
|
||||
dispatch({
|
||||
type: types.GET_PRODUCT_GROUP,
|
||||
payload: response.data.data,
|
||||
});
|
||||
};
|
||||
|
||||
const onFail = (error) => {
|
||||
console.error("getProductGroup onFail", error);
|
||||
};
|
||||
|
||||
TAxios(
|
||||
dispatch,
|
||||
getState,
|
||||
"get",
|
||||
URLS.GET_PRODUCT_GROUP,
|
||||
{ patnrId, prdtId },
|
||||
{},
|
||||
onSuccess,
|
||||
onFail
|
||||
);
|
||||
};
|
||||
export const getProductGroup = createGetThunk({
|
||||
url: URLS.GET_PRODUCT_GROUP,
|
||||
type: types.GET_PRODUCT_GROUP,
|
||||
params: pickParams(["patnrId", "prdtId"]),
|
||||
tag: "getProductGroup",
|
||||
});
|
||||
|
||||
// Detail 옵션상품 정보 조회 IF-LGSP-320
|
||||
export const getProductOption = (props) => (dispatch, getState) => {
|
||||
const { patnrId, prdtId } = props;
|
||||
export const getProductOption = createGetThunk({
|
||||
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) => {
|
||||
console.log("getProductOption onSuccess", response.data);
|
||||
|
||||
dispatch({
|
||||
type: types.GET_PRODUCT_OPTION,
|
||||
payload: response.data.data,
|
||||
console.log("[UserReviews] ✅ API 성공 응답:", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
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) => {
|
||||
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(
|
||||
dispatch,
|
||||
getState,
|
||||
"get",
|
||||
URLS.GET_PRODUCT_OPTION,
|
||||
{ patnrId, prdtId },
|
||||
{},
|
||||
URLS.GET_USER_REVEIW,
|
||||
params,
|
||||
body,
|
||||
onSuccess,
|
||||
onFail
|
||||
);
|
||||
};
|
||||
|
||||
export const getProductOptionId = (id) => (dispatch) => {
|
||||
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_GROUP: "/lgsp/v1/product/group.lge",
|
||||
GET_PRODUCT_OPTION: "/lgsp/v1/product/option.lge",
|
||||
GET_USER_REVEIW: "/lgsp/v1/product/reviews.lge",
|
||||
|
||||
//my-page controller
|
||||
GET_MY_RECOMMANDED_KEYWORD: "/lgsp/v1/mypage/reckeyword.lge",
|
||||
|
||||
@@ -1,49 +1,68 @@
|
||||
import { types } from "../actions/actionTypes";
|
||||
import { curry, get, set } from "../utils/fp";
|
||||
|
||||
const initialState = {
|
||||
bestSellerData: {},
|
||||
productImageLength: 0,
|
||||
prdtOptInfo: {},
|
||||
reviewData: null, // 리뷰 데이터 추가
|
||||
};
|
||||
|
||||
export const productReducer = (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case types.GET_BEST_SELLER:
|
||||
return {
|
||||
...state,
|
||||
bestSellerData: action.payload,
|
||||
};
|
||||
case types.GET_PRODUCT_OPTION:
|
||||
return {
|
||||
...state,
|
||||
prdtOptInfo: action.payload.prdtOptInfo,
|
||||
};
|
||||
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;
|
||||
}
|
||||
// FP: handlers map (curried), pure and immutable updates only
|
||||
const handleBestSeller = curry((state, action) =>
|
||||
set("bestSellerData", get("payload", action), state)
|
||||
);
|
||||
|
||||
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 핸들러 추가
|
||||
};
|
||||
|
||||
export const productReducer = (state = initialState, action = {}) => {
|
||||
const type = get("type", action);
|
||||
const handler = handlers[type];
|
||||
return handler ? handler(state, action) : state;
|
||||
};
|
||||
|
||||
@@ -1,12 +1,68 @@
|
||||
// src/utils/fp.js
|
||||
// FP bootstrap: use locally-extended lodash instance
|
||||
// './lodash' already mixes in our custom extensions from './lodashFpEx'
|
||||
import fp from './lodash';
|
||||
|
||||
export const {
|
||||
pipe, flow, curry, compose,
|
||||
map, filter, reduce, get, set,
|
||||
isEmpty, isNotEmpty, isNil, isNotNil,
|
||||
mapAsync, reduceAsync, filterAsync,
|
||||
} = fp;
|
||||
// 🔽 [FIX] fp가 undefined일 경우를 대비한 기본값 제공
|
||||
const safeFp = 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 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';
|
||||
|
||||
/**
|
||||
@@ -129,6 +130,7 @@ const filterAsync = fp.curry(async (asyncFilter, arr) => {
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* (collection) fp.find의 비동기 함수
|
||||
*/
|
||||
@@ -257,6 +259,7 @@ const toPascalcase = transformObjectKey(pascalCase);
|
||||
* @param {string} str date형식 문자열
|
||||
*/
|
||||
const isDatetimeString = (str) => isNaN(str) && !isNaN(Date.parse(str));
|
||||
|
||||
/**
|
||||
* applicative functor pattern 구현체
|
||||
* (주로 fp.pipe함수에서 함수의 인자 순서를 변경하기 위해 사용)
|
||||
@@ -400,6 +403,255 @@ const getOr = (({ curry, getOr }) => {
|
||||
return _getOr;
|
||||
})(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 {
|
||||
mapAsync,
|
||||
filterAsync,
|
||||
@@ -465,4 +717,30 @@ export default {
|
||||
isTruthy,
|
||||
|
||||
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";
|
||||
|
||||
.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 {
|
||||
@@ -14,12 +24,17 @@
|
||||
text-transform: none !important;
|
||||
letter-spacing: 0 !important;
|
||||
}
|
||||
|
||||
> button {
|
||||
background-image: url("../../../assets/images/btn/btn-60-wh-back-nor@3x.png");
|
||||
}
|
||||
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 90px;
|
||||
background-color: #f2f2f2;
|
||||
height: 60px;
|
||||
background-color: transparent;
|
||||
align-items: center;
|
||||
color: #333333;
|
||||
color: rgba(238, 238, 238, 1);
|
||||
padding: 0 0 0 60px;
|
||||
position: relative;
|
||||
|
||||
@@ -37,5 +52,10 @@
|
||||
display: flex;
|
||||
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