[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:
2025-09-04 12:56:26 +09:00
parent 1a42261b57
commit 4a84235ff2
56 changed files with 6302 additions and 552 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -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",
@@ -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",
@@ -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",
};

View File

@@ -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({
export const getProductGroup = createGetThunk({
url: URLS.GET_PRODUCT_GROUP,
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
);
};
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 });
};

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}
/>
)}
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
{
"main": "ProductDescription.jsx",
"styles": [
"ProductDescription.module.less"
]
}

View File

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

View File

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

View File

@@ -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>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
{
"main": "UserReviews.jsx",
"styles": [
"UserReviews.module.less"
]
}

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
{
"main": "YouMayAlsoLike.jsx",
"styles": [
"YouMayAlsoLike.module.less"
]
}

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
{
"main": "QRCode.jsx",
"styles": [
"QRCode.module.less"
]
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
{
"main": "BuyNowPriceDisplay.jsx",
"styles": [
"BuyNowPriceDisplay.module.less"
]
}

View File

@@ -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>
)}
</>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
{
"main": "ShopByMobilePriceDisplay.jsx",
"styles": [
"ShopByMobilePriceDisplay.module.less"
]
}

View File

@@ -0,0 +1,6 @@
{
"main": "ProductPriceDisplay.jsx",
"styles": [
"ProductPriceDisplay.module.less"
]
}

View File

@@ -0,0 +1,6 @@
{
"main": "ProductOverview.jsx",
"styles": [
"ProductOverview.module.less"
]
}

View File

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

View File

@@ -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);
// }
}
}
}

View File

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

View File

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

View File

@@ -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}
/>
)}
</>
);
}

View File

@@ -0,0 +1,2 @@
@import "../../../style/CommonStyle.module.less";"
@import "../../../style/utils.module.less";

View File

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

View File

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

View File

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

View File

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