diff --git a/com.twin.app.shoptime/assets/images/icons/ic-gr-call-1.png b/com.twin.app.shoptime/assets/images/icons/ic-gr-call-1.png
new file mode 100644
index 00000000..e34cabe4
Binary files /dev/null and b/com.twin.app.shoptime/assets/images/icons/ic-gr-call-1.png differ
diff --git a/com.twin.app.shoptime/assets/images/image-review-sample-1.png b/com.twin.app.shoptime/assets/images/image-review-sample-1.png
new file mode 100644
index 00000000..61ad0840
Binary files /dev/null and b/com.twin.app.shoptime/assets/images/image-review-sample-1.png differ
diff --git a/com.twin.app.shoptime/src/actions/actionTypes.js b/com.twin.app.shoptime/src/actions/actionTypes.js
index a9b026e3..23e0d69d 100644
--- a/com.twin.app.shoptime/src/actions/actionTypes.js
+++ b/com.twin.app.shoptime/src/actions/actionTypes.js
@@ -20,7 +20,8 @@ export const types = {
CHANGE_APP_STATUS: "CHANGE_APP_STATUS",
SEND_BROADCAST: "SEND_BROADCAST",
CHANGE_LOCAL_SETTINGS: "CHANGE_LOCAL_SETTINGS",
- GNB_OPENED: "GNB_OPENED", SET_SHOW_POPUP: "SET_SHOW_POPUP",
+ GNB_OPENED: "GNB_OPENED",
+ SET_SHOW_POPUP: "SET_SHOW_POPUP",
SET_SHOW_SECONDARY_POPUP: "SET_SHOW_SECONDARY_POPUP",
SET_HIDE_POPUP: "SET_HIDE_POPUP",
SET_HIDE_SECONDARY_POPUP: "SET_HIDE_SECONDARY_POPUP",
@@ -65,7 +66,7 @@ export const types = {
// home actions
GET_HOME_TERMS: "GET_HOME_TERMS",
- SET_TERMS_ID_MAP: "SET_TERMS_ID_MAP",
+ SET_TERMS_ID_MAP: "SET_TERMS_ID_MAP",
SET_OPTIONAL_TERMS_AVAILABILITY: "SET_OPTIONAL_TERMS_AVAILABILITY",
GET_HOME_MENU: "GET_HOME_MENU",
GET_HOME_LAYOUT: "GET_HOME_LAYOUT",
@@ -154,6 +155,7 @@ export const types = {
GET_VIDEO_INDECATOR_FOCUS: "GET_VIDEO_INDECATOR_FOCUS",
GET_PRODUCT_OPTION_ID: "GET_PRODUCT_OPTION_ID",
CLEAR_PRODUCT_OPTIONS: "CLEAR_PRODUCT_OPTIONS",
+ GET_USER_REVIEW: "GET_USER_REVIEW",
// search actions
GET_SEARCH: "GET_SEARCH",
@@ -235,9 +237,9 @@ export const types = {
// new actions
CANCEL_FOCUS_ELEMENT: "CANCEL_FOCUS_ELEMENT",
- // 약관동의 여부 확인 상태
+ // 약관동의 여부 확인 상태
GET_TERMS_AGREE_YN_START: "GET_TERMS_AGREE_YN_START",
- GET_TERMS_AGREE_YN_SUCCESS: "GET_TERMS_AGREE_YN_SUCCESS",
+ GET_TERMS_AGREE_YN_SUCCESS: "GET_TERMS_AGREE_YN_SUCCESS",
GET_TERMS_AGREE_YN_FAILURE: "GET_TERMS_AGREE_YN_FAILURE",
// device
@@ -245,4 +247,21 @@ export const types = {
// 🔽 [추가] 영구재생 비디오 정보 저장
SET_PERSISTENT_VIDEO_INFO: "SET_PERSISTENT_VIDEO_INFO",
+
+ // 🔽 [추가] 배너 비디오 제어 액션 타입
+ /**
+ * HomeBanner의 배너 간 비디오 재생 제어를 위한 액션 타입들.
+ * 첫 번째 배너 상시 재생과 두 번째 배너 포커스 재생을 관리합니다.
+ */
+ SET_BANNER_STATE: "SET_BANNER_STATE",
+ SET_BANNER_FOCUS: "SET_BANNER_FOCUS",
+ SET_BANNER_AVAILABILITY: "SET_BANNER_AVAILABILITY",
+ SET_BANNER_TRANSITION: "SET_BANNER_TRANSITION",
+ PAUSE_PLAYER_CONTROL: "PAUSE_PLAYER_CONTROL",
+ RESUME_PLAYER_CONTROL: "RESUME_PLAYER_CONTROL",
+ // 🔽 [추가] HomeBanner 동영상 포커스 정책 관리
+ SET_CURRENT_FOCUS_BANNER: "SET_CURRENT_FOCUS_BANNER",
+ UPDATE_VIDEO_POLICY: "UPDATE_VIDEO_POLICY",
+ SET_MODAL_BORDER: "SET_MODAL_BORDER",
+ SET_BANNER_VISIBILITY: "SET_BANNER_VISIBILITY",
};
diff --git a/com.twin.app.shoptime/src/actions/productActions.js b/com.twin.app.shoptime/src/actions/productActions.js
index cb13a8d2..67ab1991 100644
--- a/com.twin.app.shoptime/src/actions/productActions.js
+++ b/com.twin.app.shoptime/src/actions/productActions.js
@@ -2,30 +2,78 @@ import { URLS } from "../api/apiConfig";
import { TAxios } from "../api/TAxios";
import { types } from "./actionTypes";
import { changeAppStatus } from "./commonActions";
+import { reduce, set, get } from "../utils/fp";
+
+// CustomerImages용 리뷰 이미지 import
+import reviewSampleImage from "../../assets/images/image-review-sample-1.png";
// Best Seller 상품 목록 조회 IF-LGSP-303
+// FP helpers
+const pickParams = (keys) => (src) =>
+ reduce(
+ (acc, key) =>
+ src && src[key] !== null && src[key] !== undefined
+ ? set(key, src[key], acc)
+ : acc,
+ {},
+ keys
+ );
+
+// Generic request thunk factory
+const createRequestThunk = ({
+ method,
+ url,
+ type,
+ params = () => ({}),
+ data = () => ({}),
+ tag,
+ onSuccessExtra = () => (dispatch, getState, response) => {},
+ onFailExtra = () => (dispatch, getState, error) => {},
+ selectPayload = (response) => get("data.data", response),
+}) =>
+ (props) => (dispatch, getState) => {
+ const query = params(props);
+ const body = data(props);
+
+ const onSuccess = (response) => {
+ console.log(`${tag} onSuccess`, response.data);
+ dispatch({ type, payload: selectPayload(response) });
+ onSuccessExtra(props, dispatch, getState, response);
+ };
+
+ const onFail = (error) => {
+ console.error(`${tag} onFail`, error);
+ onFailExtra(props, dispatch, getState, error);
+ };
+
+ TAxios(
+ dispatch,
+ getState,
+ method,
+ url,
+ query,
+ body,
+ onSuccess,
+ onFail
+ );
+ };
+
+// Specialization for GET
+const createGetThunk = ({ url, type, params = () => ({}), tag }) =>
+ createRequestThunk({ method: "get", url, type, params, tag });
+
export const getBestSeller = (callback) => (dispatch, getState) => {
const onSuccess = (response) => {
console.log("getBestSeller onSuccess", response.data);
-
- dispatch({
- type: types.GET_BEST_SELLER,
- payload: response.data.data,
- });
+ dispatch({ type: types.GET_BEST_SELLER, payload: get("data.data", response) });
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
-
- if (callback) {
- callback();
- }
+ callback && callback();
};
const onFail = (error) => {
console.error("getBestSeller onFail", error);
dispatch(changeAppStatus({ showLoadingPanel: { show: false } }));
-
- if (callback) {
- callback();
- }
+ callback && callback();
};
TAxios(
@@ -41,62 +89,227 @@ export const getBestSeller = (callback) => (dispatch, getState) => {
};
// Detail 옵션상품 정보 조회 IF-LGSP-319
-export const getProductGroup = (props) => (dispatch, getState) => {
- const { patnrId, prdtId } = props;
-
- const onSuccess = (response) => {
- console.log("getProductGroup onSuccess", response.data);
-
- dispatch({
- type: types.GET_PRODUCT_GROUP,
- payload: response.data.data,
- });
- };
-
- const onFail = (error) => {
- console.error("getProductGroup onFail", error);
- };
-
- TAxios(
- dispatch,
- getState,
- "get",
- URLS.GET_PRODUCT_GROUP,
- { patnrId, prdtId },
- {},
- onSuccess,
- onFail
- );
-};
+export const getProductGroup = createGetThunk({
+ url: URLS.GET_PRODUCT_GROUP,
+ type: types.GET_PRODUCT_GROUP,
+ params: pickParams(["patnrId", "prdtId"]),
+ tag: "getProductGroup",
+});
// Detail 옵션상품 정보 조회 IF-LGSP-320
-export const getProductOption = (props) => (dispatch, getState) => {
- const { patnrId, prdtId } = props;
+export const getProductOption = createGetThunk({
+ url: URLS.GET_PRODUCT_OPTION,
+ type: types.GET_PRODUCT_OPTION,
+ params: pickParams(["patnrId", "prdtId"]),
+ tag: "getProductOption",
+});
+
+// FP: 실제 API 응답에서 data 부분만 추출 (간소화)
+const extractReviewApiData = (apiResponse) => {
+ try {
+ console.log("[UserReviews] 🔍 extractReviewApiData - 전체 API 응답 분석:", {
+ fullApiResponse: apiResponse,
+ hasData: !!(apiResponse && apiResponse.data),
+ retCode: apiResponse && apiResponse.retCode,
+ retMsg: apiResponse && apiResponse.retMsg,
+ responseKeys: apiResponse ? Object.keys(apiResponse) : [],
+ dataKeys: apiResponse && apiResponse.data ? Object.keys(apiResponse.data) : []
+ });
+
+ // 여러 가능한 데이터 경로 시도
+ let apiData = null;
+
+ // 1. response.data.data (중첩 구조)
+ if (apiResponse && apiResponse.data && apiResponse.data.data) {
+ apiData = apiResponse.data.data;
+ console.log("[UserReviews] ✅ 데이터 경로 1: response.data.data 사용");
+ }
+ // 2. response.data (단일 구조)
+ else if (apiResponse && apiResponse.data && (apiResponse.data.reviewList || apiResponse.data.reviewDetail)) {
+ apiData = apiResponse.data;
+ console.log("[UserReviews] ✅ 데이터 경로 2: response.data 사용");
+ }
+ // 3. response 직접 (최상위)
+ else if (apiResponse && (apiResponse.reviewList || apiResponse.reviewDetail)) {
+ apiData = apiResponse;
+ console.log("[UserReviews] ✅ 데이터 경로 3: response 직접 사용");
+ }
+
+ if (!apiData) {
+ console.warn("[UserReviews] ❌ 모든 데이터 경로에서 추출 실패:", {
+ hasResponseData: !!(apiResponse && apiResponse.data),
+ hasReviewList: !!(apiResponse && apiResponse.data && apiResponse.data.reviewList),
+ hasReviewDetail: !!(apiResponse && apiResponse.data && apiResponse.data.reviewDetail),
+ hasNestedData: !!(apiResponse && apiResponse.data && apiResponse.data.data),
+ fullResponse: apiResponse
+ });
+ return null;
+ }
+
+ // 추출된 데이터 검증
+ console.log("[UserReviews] 📊 추출된 데이터 검증:", {
+ hasReviewList: !!apiData.reviewList,
+ hasReviewDetail: !!apiData.reviewDetail,
+ reviewListLength: apiData.reviewList ? apiData.reviewList.length : 0,
+ reviewDetailKeys: apiData.reviewDetail ? Object.keys(apiData.reviewDetail) : [],
+ totRvwCnt: apiData.reviewDetail && apiData.reviewDetail.totRvwCnt,
+ totRvwAvg: apiData.reviewDetail && apiData.reviewDetail.totRvwAvg,
+ extractedData: apiData
+ });
+
+ return apiData;
+ } catch (error) {
+ console.error("[UserReviews] ❌ extractReviewApiData 에러:", error);
+ return null;
+ }
+};
+
+// Mock 데이터 생성 함수 (재사용성 위해 분리)
+const createMockReviewData = () => ({
+ reviewList: [
+ {
+ rvwId: "mock-review-1",
+ rvwRtng: 5,
+ rvwCtnt: "The shoes are really stylish and comfortable for daily wear. I love the design and how lightweight they feel. However, the size runs a bit small, so I'd recommend ordering half a size up.",
+ rvwRgstDtt: "2024-01-15",
+ reviewImageList: [
+ {
+ imgId: "mock-img-1",
+ imgUrl: reviewSampleImage,
+ imgSeq: 1
+ }
+ ]
+ },
+ {
+ rvwId: "mock-review-2",
+ rvwRtng: 4,
+ rvwCtnt: "Great value for the price! The quality is better than I expected. Shipping was fast and the product arrived in perfect condition. Would definitely recommend to others.",
+ rvwRgstDtt: "2024-01-10",
+ reviewImageList: []
+ }
+ ],
+ reviewDetail: {
+ totRvwCnt: 2,
+ totRvwAvg: 4.5
+ }
+});
+
+// 상품별 유저 리뷰 리스트 조회 : IF-LGSP-0002
+export const getUserReviews = (requestParams) => (dispatch, getState) => {
+ const { prdtId } = requestParams;
+
+ console.log("[UserReviews] 🚀 getUserReviews 액션 시작:", {
+ requestParams,
+ originalPrdtId: prdtId,
+ willUseRandomPrdtId: true, // 임시 테스트 플래그
+ timestamp: new Date().toISOString()
+ });
+
+ // ==================== [임시 테스트] 시작 ====================
+ // 테스트용 prdtId 목록 - 제거 시 이 블록 전체 삭제
+ const testProductIds = [
+ "LCE3010SB",
+ "100QNED85AU",
+ "14Z90Q-K.ARW3U1",
+ "16Z90Q-K.AAC7U1",
+ "24GN600-B",
+ "50UT8000AUA",
+ "A949KTMS",
+ "AGF76631064",
+ "C5323B0",
+ "DLE3600V"
+ ];
+ const randomIndex = Math.floor(Math.random() * testProductIds.length);
+ const randomPrdtId = testProductIds[randomIndex];
+ // ==================== [임시 테스트] 끝 ====================
+
+ // TAxios 파라미터 준비
+ const params = { prdtId: randomPrdtId }; // 임시: randomPrdtId 사용, 원본: prdtId 사용
+ const body = {}; // GET이므로 빈 객체
+
+ console.log("[UserReviews] 📡 TAxios 호출 준비:", {
+ method: "get",
+ url: URLS.GET_USER_REVEIW,
+ params,
+ body,
+ selectedRandomPrdtId: randomPrdtId, // 임시: 선택된 랜덤 상품 ID
+ });
const onSuccess = (response) => {
- console.log("getProductOption onSuccess", response.data);
-
- dispatch({
- type: types.GET_PRODUCT_OPTION,
- payload: response.data.data,
+ console.log("[UserReviews] ✅ API 성공 응답:", {
+ status: response.status,
+ statusText: response.statusText,
+ headers: response.headers,
+ retCode: response.data && response.data.retCode,
+ retMsg: response.data && response.data.retMsg,
+ hasData: !!(response.data && response.data.data),
+ fullResponse: response.data
});
+
+ if (response.data && response.data.data) {
+ console.log("[UserReviews] 📊 API 데이터 상세:", {
+ reviewListLength: response.data.data.reviewList ? response.data.data.reviewList.length : 0,
+ reviewDetail: response.data.data.reviewDetail,
+ reviewList_sample: response.data.data.reviewList && response.data.data.reviewList[0] || "empty",
+ totRvwCnt: response.data.data.reviewDetail && response.data.data.reviewDetail.totRvwCnt,
+ totRvwAvg: response.data.data.reviewDetail && response.data.data.reviewDetail.totRvwAvg
+ });
+ }
+
+ // 실제 API 응답에서 data 부분 추출
+ const apiData = extractReviewApiData(response.data);
+
+ if (apiData) {
+ console.log("[UserReviews] ✅ 실제 API 데이터 사용");
+ dispatch({
+ type: types.GET_USER_REVIEW,
+ payload: apiData
+ });
+ console.log("[UserReviews] 📦 실제 API 데이터 디스패치 완료:", apiData);
+ } else {
+ console.log("[UserReviews] ⚠️ API 데이터 추출 실패, Mock 데이터 사용");
+ const mockData = createMockReviewData();
+ dispatch({
+ type: types.GET_USER_REVIEW,
+ payload: mockData
+ });
+ console.log("[UserReviews] 📦 Mock 데이터 디스패치 완료:", mockData);
+ }
};
const onFail = (error) => {
- console.error("getProductOption onFail", error);
+ console.error("[UserReviews] ❌ API 실패:", {
+ message: error.message,
+ status: error.response && error.response.status,
+ statusText: error.response && error.response.statusText,
+ responseData: error.response && error.response.data,
+ requestParams: requestParams,
+ url: URLS.GET_USER_REVEIW,
+ fullError: error
+ });
+
+ console.log("[UserReviews] 🔄 API 실패로 Mock 데이터 사용");
+ const mockData = createMockReviewData();
+ dispatch({
+ type: types.GET_USER_REVIEW,
+ payload: mockData
+ });
+ console.log("[UserReviews] 📦 Mock 데이터 디스패치 완료 (API 실패):", mockData);
};
+ console.log("[UserReviews] 🔗 TAxios 호출 실행 중...");
TAxios(
dispatch,
getState,
"get",
- URLS.GET_PRODUCT_OPTION,
- { patnrId, prdtId },
- {},
+ URLS.GET_USER_REVEIW,
+ params,
+ body,
onSuccess,
onFail
);
};
+
export const getProductOptionId = (id) => (dispatch) => {
dispatch({ type: types.GET_PRODUCT_OPTION_ID, payload: id });
};
diff --git a/com.twin.app.shoptime/src/api/apiConfig.js b/com.twin.app.shoptime/src/api/apiConfig.js
index 1eaf60a5..6b6313e6 100644
--- a/com.twin.app.shoptime/src/api/apiConfig.js
+++ b/com.twin.app.shoptime/src/api/apiConfig.js
@@ -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",
diff --git a/com.twin.app.shoptime/src/reducers/productReducer.js b/com.twin.app.shoptime/src/reducers/productReducer.js
index 09f30f66..a32edfe7 100644
--- a/com.twin.app.shoptime/src/reducers/productReducer.js
+++ b/com.twin.app.shoptime/src/reducers/productReducer.js
@@ -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;
};
diff --git a/com.twin.app.shoptime/src/utils/fp.js b/com.twin.app.shoptime/src/utils/fp.js
index 569e44b3..fe45aff2 100644
--- a/com.twin.app.shoptime/src/utils/fp.js
+++ b/com.twin.app.shoptime/src/utils/fp.js
@@ -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;
\ No newline at end of file
diff --git a/com.twin.app.shoptime/src/utils/lodash.js b/com.twin.app.shoptime/src/utils/lodash.js
index ac1389e5..d1146ffd 100644
--- a/com.twin.app.shoptime/src/utils/lodash.js
+++ b/com.twin.app.shoptime/src/utils/lodash.js
@@ -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;
diff --git a/com.twin.app.shoptime/src/utils/lodashFpEx.js b/com.twin.app.shoptime/src/utils/lodashFpEx.js
index cb136ef3..e714731a 100644
--- a/com.twin.app.shoptime/src/utils/lodashFpEx.js
+++ b/com.twin.app.shoptime/src/utils/lodashFpEx.js
@@ -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,
+};
\ No newline at end of file
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.backup.jsx b/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.backup.jsx
new file mode 100644
index 00000000..bb453935
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.backup.jsx
@@ -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 (
+ <>
+
+
+
+ {!isLoading && (
+ <>
+ {/* 결제가능상품 영역 */}
+ {isBillingProductVisible && (
+
+ )}
+ {/* 구매불가상품 영역 */}
+ {isUnavailableProductVisible && (
+
+ )}
+ {/* 그룹상품 영역 */}
+ {isGroupProductVisible && (
+
+ )}
+ {/* 테마그룹상품 영역*/}
+ {isTravelProductVisible && (
+
+ )}
+ >
+ )}
+
+
+ {lgCatCd && youmaylikeData && youmaylikeData.length > 0 && isOnTop && (
+
+ )}
+ {activePopup === Config.ACTIVE_POPUP.smsPopup && (
+
+ )}
+ >
+ );
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.backup.module.less b/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.backup.module.less
new file mode 100644
index 00000000..ca94a989
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.backup.module.less
@@ -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;
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.jsx b/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.jsx
index bb453935..7a427615 100644
--- a/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.jsx
+++ b/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.jsx
@@ -1,6 +1,8 @@
+// src/views/DetailPanel/DetailPanel.new.jsx
import React, {
useCallback,
useEffect,
+ useLayoutEffect,
useMemo,
useRef,
useState,
@@ -11,18 +13,8 @@ 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,
@@ -31,406 +23,566 @@ 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 THeaderCustom from "./components/THeaderCustom";
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 fp from "../../utils/fp";
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";
+import ProductAllSection from "./ProductAllSection/ProductAllSection";
+import { getThemeCurationDetailInfo } from "../../actions/homeActions";
+import indicatorDefaultImage from "../../../assets/images/img-thumb-empty-144@3x.png";
+import ThemeItemListOverlay from "./ThemeItemListOverlay/ThemeItemListOverlay";
+import Spinner from "@enact/sandstone/Spinner";
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 isLoading = useSelector((state) =>
+ fp.pipe(() => state, fp.get('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 themeData = useSelector((state) =>
+ fp.pipe(
+ () => state,
+ fp.get('home.productData.themeInfo'),
+ (list) => (list && list[0])
+ )()
);
const webOSVersion = useSelector(
- (state) => state.common.appStatus.webOSVersion
+ (state) => state.common.appStatus.webOSVersion,
+ );
+ const panels = useSelector((state) => state.panels.panels);
+
+ // FP 방식으로 상태 관리
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const localRecentItems = useSelector((state) =>
+ fp.pipe(() => state, fp.get('localSettings.recentItems'))()
+ );
+ const { httpHeader } = useSelector((state) => state.common);
+ const { popupVisible, activePopup } = useSelector(
+ (state) => state.common.popup,
+ );
+ const [lgCatCd, setLgCatCd] = useState("");
+ const [themeProductInfo, setThemeProductInfo] = useState(null);
+
+ const containerRef = useRef(null);
+
+ // FP 파생 값 메모이제이션 (optional chaining 대체 및 deps 안정화)
+ const panelType = useMemo(() => fp.pipe(() => panelInfo, fp.get('type'))(), [panelInfo]);
+ const panelCurationId = useMemo(() => fp.pipe(() => panelInfo, fp.get('curationId'))(), [panelInfo]);
+ const panelPatnrId = useMemo(() => fp.pipe(() => panelInfo, fp.get('patnrId'))(), [panelInfo]);
+ const panelPrdtId = useMemo(() => fp.pipe(() => panelInfo, fp.get('prdtId'))(), [panelInfo]);
+ const panelLiveReqFlag = useMemo(() => fp.pipe(() => panelInfo, fp.get('liveReqFlag'))(), [panelInfo]);
+ const panelBgImgNo = useMemo(() => fp.pipe(() => panelInfo, fp.get('bgImgNo'))(), [panelInfo]);
+ const productPmtSuptYn = useMemo(() => fp.pipe(() => productData, fp.get('pmtSuptYn'))(), [productData]);
+ const productGrPrdtProcYn = useMemo(() => fp.pipe(() => productData, fp.get('grPrdtProcYn'))(), [productData]);
+
+ // FP 방식으로 데이터 소스 결정 (메모이제이션 최적화)
+ const productDataSource = useMemo(() =>
+ fp.pipe(
+ () => panelType,
+ type => type === "theme" ? themeData : productData
+ )(),
+ [panelType, themeData, productData]
);
- const panels = useSelector((state) => state.panels.panels);
- const [lgCatCd, setLgCatCd] = useState("");
- const [isYouMayLikeOpened, setIsYouMayLikeOpened] = useState(false);
- const [selectedIndex, setSelectedIndex] = useState(0);
+ const [productType, setProductType] = useState(null);
+ const [openThemeItemOverlay, setOpenThemeItemOverlay] = useState(false);
- const shopByMobileLogRef = useRef(null);
+ // FP 방식으로 스크롤 상태 관리
+ const [scrollToSection, setScrollToSection] = useState(null);
+ const [pendingScrollSection, setPendingScrollSection] = useState(null);
- useEffect(() => {
- dispatch(getProductOptionId(undefined));
- if (panelInfo?.type === "hotel") {
- dispatch(
- getThemeHotelDetailInfo({
- patnrId: panelInfo?.patnrId,
- curationId: panelInfo?.curationId,
- })
- );
- }
+ // FP 방식으로 상태 업데이트 함수들
+ const updateSelectedIndex = useCallback((newIndex) => {
+ setSelectedIndex(fp.pipe(
+ () => newIndex,
+ index => Math.max(0, Math.min(index, 999)) // 범위 제한
+ )());
+ }, []);
- if (panelInfo?.type === "theme") {
- dispatch(
- getThemeCurationDetailInfo({
- patnrId: panelInfo?.patnrId,
- curationId: panelInfo?.curationId,
- bgImgNo: panelInfo?.bgImgNo,
- })
- );
- }
+ const updateThemeItemOverlay = useCallback((isOpen) => {
+ setOpenThemeItemOverlay(fp.pipe(
+ () => isOpen,
+ Boolean
+ )());
+ }, []);
- 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) => {
+ // FP 방식으로 이벤트 핸들러 정의
+ const onSpotlightUpTButton = useCallback((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,
- },
- })
- );
- }
+ // FP 방식으로 액션 디스패치 체이닝
+ fp.pipe(
+ () => {
+ dispatch(finishVideoPreview());
+ dispatch(popPanel(panel_names.DETAIL_PANEL));
+ },
+ () => {
+ // 패널 업데이트 조건 체크
+ const shouldUpdatePanel = fp.pipe(
+ () => panels,
+ fp.get('length'),
+ length => length === 4
+ )() && fp.pipe(
+ () => panels,
+ fp.get('1.name'),
+ name => name === panel_names.PLAYER_PANEL
+ )();
+
+ if (shouldUpdatePanel) {
+ dispatch(
+ updatePanel({
+ name: panel_names.PLAYER_PANEL,
+ panelInfo: {
+ thumbnail: fp.pipe(
+ () => panelInfo,
+ fp.get('thumbnailUrl')
+ )(),
+ },
+ }),
+ );
+ }
+ }
+ )();
if (isCancelClick) {
ev.stopPropagation();
}
},
- [dispatch, panelInfo, panels]
+ [dispatch, panelInfo, panels],
);
- const handleSMSonClose = useCallback(() => {
- dispatch(setHidePopup());
- setTimeout(() => {
- Spotlight.focus("spotlightId_backBtn");
- Spotlight.focus("shopbymobile_Btn");
- }, 0);
- }, [dispatch]);
+ // FP 방식으로 스크롤 함수 핸들러
+ const handleScrollToSection = useCallback(
+ (sectionId) => {
+ console.log("DetailPanel: handleScrollToSection called with:", sectionId);
+ console.log("DetailPanel: scrollToSection function:", scrollToSection);
- const saveToLocalSettings = useCallback(() => {
- let recentItems = [];
- const prdtId =
- themeProductInfos &&
- themeProductInfos.length > 0 &&
- panelInfo?.type === "theme"
- ? themeProductInfos[selectedIndex].prdtId
- : panelInfo?.prdtId;
+ // FP 방식으로 스크롤 처리 로직
+ const scrollAction = fp.pipe(
+ () => ({ scrollToSection, sectionId }),
+ ({ scrollToSection, sectionId }) => {
+ if (fp.isNotNil(scrollToSection)) {
+ return { action: 'execute', scrollFunction: scrollToSection, sectionId };
+ } else {
+ return { action: 'store', sectionId };
+ }
+ }
+ )();
- if (localRecentItems) {
- recentItems = [...localRecentItems];
+ // 액션에 따른 처리
+ if (scrollAction.action === 'execute') {
+ scrollAction.scrollFunction(scrollAction.sectionId);
+ } else {
+ console.log(
+ "DetailPanel: scrollToSection function is null, storing pending scroll",
+ );
+ setPendingScrollSection(scrollAction.sectionId);
+ }
+ },
+ [scrollToSection],
+ );
+
+ // FP 방식으로 pending scroll 처리 (메모리 누수 방지)
+ useEffect(() => {
+ const shouldExecutePendingScroll = fp.pipe(
+ () => ({ scrollToSection, pendingScrollSection }),
+ ({ scrollToSection, pendingScrollSection }) =>
+ fp.isNotNil(scrollToSection) && fp.isNotNil(pendingScrollSection)
+ )();
+
+ if (shouldExecutePendingScroll) {
+ console.log(
+ "DetailPanel: executing pending scroll to:",
+ pendingScrollSection,
+ );
+
+ // 메모리 누수 방지를 위한 cleanup 함수
+ const timeoutId = setTimeout(() => {
+ if (scrollToSection) {
+ scrollToSection(pendingScrollSection);
+ }
+ setPendingScrollSection(null);
+ }, 100);
+
+ // cleanup 함수 반환으로 메모리 누수 방지
+ return () => {
+ clearTimeout(timeoutId);
+ };
}
+ }, [scrollToSection, pendingScrollSection]);
- const currentDate = new Date();
+ // FP 방식으로 초기 데이터 로딩 처리 (메모리 누수 방지)
+ useEffect(() => {
+ // FP 방식으로 액션 디스패치 체이닝
+ const loadInitialData = fp.pipe(
+ () => {
+ // 기본 액션 디스패치
+ dispatch(getProductOptionId(undefined));
+ dispatch(getDeviceAdditionInfo());
+ },
+ () => {
+ // 테마 데이터 로딩
+ const isThemeType = panelType === "theme";
+
+ if (isThemeType) {
+ dispatch(
+ getThemeCurationDetailInfo({
+ patnrId: panelPatnrId,
+ curationId: panelCurationId,
+ bgImgNo: panelBgImgNo,
+ }),
+ );
+ }
+ },
+ () => {
+ // 일반 상품 데이터 로딩
+ const hasProductId = fp.isNotNil(panelPrdtId);
+ const hasNoCuration = fp.isNil(panelCurationId);
+
+ if (hasProductId && hasNoCuration) {
+ dispatch(
+ getMainCategoryDetail({
+ patnrId: panelPatnrId,
+ prdtId: panelPrdtId,
+ liveReqFlag: panelLiveReqFlag || "N",
+ }),
+ );
+ }
+ }
+ )();
- 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 }));
- }
+ // cleanup 함수로 메모리 누수 방지
+ return () => {
+ // 필요한 경우 cleanup 로직 추가
+ };
}, [
- panelInfo?.prdtId,
- httpHeader,
- localRecentItems,
dispatch,
- selectedIndex,
- themeProductInfos,
+ panelLiveReqFlag,
+ panelCurationId,
+ panelPrdtId,
+ panelType,
+ panelPatnrId,
+ panelBgImgNo,
]);
+ // FP 방식으로 추천 상품 데이터 로딩 (메모리 누수 방지)
+ useEffect(() => {
+ const shouldLoadRecommendations = fp.pipe(
+ () => lgCatCd,
+ fp.isNotEmpty
+ )();
+
+ if (shouldLoadRecommendations) {
+ dispatch(
+ getMainYouMayLike({
+ lgCatCd: lgCatCd,
+ exclCurationId: panelCurationId,
+ exclPatnrId: panelPatnrId,
+ exclPrdtId: panelPrdtId,
+ }),
+ );
+ }
+ }, [panelCurationId, panelPatnrId, panelPrdtId, lgCatCd]);
+
+ // FP 방식으로 카테고리 규칙 헬퍼 함수들 (curry 적용)
+ const categoryHelpers = useMemo(() => ({
+ createCategoryRule: fp.curry((conditionFn, extractFn, data) =>
+ conditionFn(data) ? extractFn(data) : null
+ ),
+ hasProductWithoutCuration: fp.curry((panelCurationId, productData) =>
+ fp.isNotNil(productData) && fp.isNil(panelCurationId)
+ ),
+ hasThemeWithPaymentCondition: fp.curry((panelCurationId, themeProductInfo) => {
+ const hasThemeProduct = fp.isNotNil(themeProductInfo);
+ const equalToN = fp.curry((expected, actual) => actual === expected)("N");
+ const isNoPayment = equalToN(fp.pipe(() => themeProductInfo, fp.get('pmtSuptYn'))());
+ const hasCuration = fp.isNotNil(panelCurationId);
+ return hasThemeProduct && isNoPayment && hasCuration;
+ })
+ }), []);
+
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]);
+ // FP 방식으로 카테고리 코드 결정 - curry 적용으로 더 함수형 개선
+ const categoryRules = [
+ // 일반 상품 규칙 (curry 활용)
+ () => categoryHelpers.createCategoryRule(
+ categoryHelpers.hasProductWithoutCuration(panelCurationId),
+ (data) => fp.pipe(() => data, fp.get('catCd'))(),
+ productData
+ ),
+
+ // 테마 상품 규칙 (curry 활용)
+ () => categoryHelpers.createCategoryRule(
+ categoryHelpers.hasThemeWithPaymentCondition(panelCurationId),
+ (data) => fp.pipe(() => data, fp.get('catCd'))(),
+ themeProductInfo
+ )
+ ];
- 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"
+ // 첫 번째로 매칭되는 규칙의 결과 사용 (curry의 reduce 활용)
+ const categoryCode = fp.reduce(
+ (result, value) => result || value || "",
+ "",
+ categoryRules.map(rule => rule())
);
- }, [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}
- >
- );
- };
+
+ setLgCatCd(categoryCode);
+ }, [productData, selectedIndex, panelCurationId, themeProductInfo, categoryHelpers]);
+ // FP 방식으로 카테고리 코드 업데이트 (메모리 누수 방지)
useEffect(() => {
- getlgCatCd();
- }, [themeProductInfos, productData, panelInfo, selectedIndex]);
+ const shouldUpdateCategory = fp.pipe(
+ () => ({ themeProductInfo, productData, panelInfo, selectedIndex }),
+ ({ themeProductInfo, productData, panelInfo, selectedIndex }) =>
+ fp.isNotNil(themeProductInfo) || fp.isNotNil(productData) || fp.isNotNil(panelInfo)
+ )();
- useEffect(() => {
- if (panelInfo && panelInfo?.patnrId && panelInfo?.prdtId) {
- saveToLocalSettings();
+ if (shouldUpdateCategory) {
+ getlgCatCd();
}
- }, [panelInfo, selectedIndex]);
+ }, [themeProductInfo, productData, 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에서 호출하세요.
+ // 예) saveRecentItem(panelInfo, selectedIndex)
+ // FP 방식으로 cleanup 처리 (메모리 누수 방지)
useEffect(() => {
return () => {
- dispatch(clearProductDetail());
- dispatch(clearThemeDetail());
- dispatch(clearCouponInfo());
- setContainerLastFocusedElement(null, ["indicator-GridListContainer"]);
+ // FP 방식으로 cleanup 액션 실행
+ fp.pipe(
+ () => {
+ dispatch(clearProductDetail());
+ },
+ () => {
+ setContainerLastFocusedElement(null, ["indicator-GridListContainer"]);
+ }
+ )();
};
}, [dispatch]);
+ // 최근 본 상품 트리거 예시:
+ // useEffect(() => {
+ // if (panelInfo && panelInfo.patnrId && panelInfo.prdtId) {
+ // // saveRecentItem(panelInfo, selectedIndex)
+ // }
+ // }, [panelInfo, selectedIndex])
+
+ // 테마/호텔 기반 인덱스 초기화가 필요하면:
+ // - findIndex 유틸을 만들어 매칭 인덱스를 계산 후 setSelectedIndex에 반영하세요.
+ // FP 방식으로 버전 비교 헬퍼 함수 (curry 적용)
+ const versionComparators = useMemo(() => ({
+ isVersionGTE: fp.curry((target, version) => version >= target),
+ isVersionLT: fp.curry((target, version) => version < target)
+ }), []);
+
+ // FP 방식으로 조건 체크 헬퍼 함수들 (curry 적용)
+ const conditionCheckers = useMemo(() => ({
+ hasDataAndCondition: fp.curry((conditionFn, data) => fp.isNotNil(data) && conditionFn(data)),
+ equalTo: fp.curry((expected, actual) => actual === expected),
+ checkAllConditions: fp.curry((conditions, data) =>
+ fp.reduce((acc, condition) => acc && condition, true, conditions.map(fn => fn(data)))
+ )
+ }), []);
+
+ const getProductType = useCallback(() => {
+ // FP 방식으로 데이터 검증 및 타입 결정 - curry 적용으로 더 함수형 개선
+ const createTypeChecker = fp.curry((type, conditions, sideEffect) =>
+ fp.pipe(
+ () => conditions(),
+ isValid => isValid ? (() => {
+ sideEffect && sideEffect();
+ return { matched: true, type };
+ })() : { matched: false }
+ )()
+ );
+
+ const productTypeRules = [
+ // 테마 타입 체크
+ () => createTypeChecker(
+ "theme",
+ () => fp.pipe(
+ () => ({ panelCurationId, themeData }),
+ ({ panelCurationId, themeData }) =>
+ fp.isNotNil(panelCurationId) && fp.isNotNil(themeData)
+ )(),
+ () => {
+ const themeProduct = fp.pipe(
+ () => themeData,
+ fp.get('productInfos'),
+ fp.get(selectedIndex.toString())
+ )();
+ setProductType("theme");
+ setThemeProductInfo(themeProduct);
+ }
+ ),
+
+ // Buy Now 타입 체크 (curry 활용)
+ () => createTypeChecker(
+ "buyNow",
+ () => fp.pipe(
+ () => ({ productData, panelPrdtId, productPmtSuptYn, productGrPrdtProcYn, webOSVersion }),
+ ({ productData, panelPrdtId, productPmtSuptYn, productGrPrdtProcYn, webOSVersion }) => {
+ const conditions = [
+ () => fp.isNotNil(productData),
+ () => conditionCheckers.equalTo("Y")(productPmtSuptYn),
+ () => conditionCheckers.equalTo("N")(productGrPrdtProcYn),
+ () => fp.isNotNil(panelPrdtId),
+ () => versionComparators.isVersionGTE("6.0")(webOSVersion)
+ ];
+ return conditionCheckers.checkAllConditions(conditions)({});
+ }
+ )(),
+ () => setProductType("buyNow")
+ ),
+
+ // Shop By Mobile 타입 체크 (curry 활용)
+ () => createTypeChecker(
+ "shopByMobile",
+ () => fp.pipe(
+ () => ({ productData, panelPrdtId, productPmtSuptYn, productGrPrdtProcYn, webOSVersion }),
+ ({ productData, panelPrdtId, productPmtSuptYn, productGrPrdtProcYn, webOSVersion }) => {
+ if (!productData) return false;
+
+ const isDirectMobile = conditionCheckers.equalTo("N")(productPmtSuptYn);
+ const conditionalMobileConditions = [
+ () => conditionCheckers.equalTo("Y")(productPmtSuptYn),
+ () => conditionCheckers.equalTo("N")(productGrPrdtProcYn),
+ () => versionComparators.isVersionLT("6.0")(webOSVersion),
+ () => fp.isNotNil(panelPrdtId)
+ ];
+ const isConditionalMobile = conditionCheckers.checkAllConditions(conditionalMobileConditions)({});
+
+ return isDirectMobile || isConditionalMobile;
+ }
+ )(),
+ () => setProductType("shopByMobile")
+ )
+ ];
+
+ // FP 방식으로 순차적 타입 체크
+ const matchedRule = fp.reduce(
+ (result, rule) => result.matched ? result : rule(),
+ { matched: false },
+ productTypeRules
+ );
+
+ // 매칭되지 않은 경우 디버깅 정보 출력
+ if (!matchedRule.matched) {
+ const debugInfo = fp.pipe(
+ () => ({ productData, panelPrdtId, productPmtSuptYn, productGrPrdtProcYn, webOSVersion }),
+ ({ productData, panelPrdtId, productPmtSuptYn, productGrPrdtProcYn, webOSVersion }) => ({
+ pmtSuptYn: productPmtSuptYn,
+ grPrdtProcYn: productGrPrdtProcYn,
+ prdtId: panelPrdtId,
+ webOSVersion,
+ })
+ )();
+
+ console.warn("Unknown product type:", productData);
+ console.warn("Product data properties:", debugInfo);
+ }
+ }, [
+ panelCurationId,
+ themeData,
+ productPmtSuptYn,
+ productGrPrdtProcYn,
+ panelPrdtId,
+ webOSVersion,
+ selectedIndex,
+ versionComparators,
+ conditionCheckers,
+ ]);
+
+ useEffect(() => {
+ // productData가 로드된 후에만 getProductType 실행
+ if (productData || (panelType === "theme" && themeData)) {
+ getProductType();
+ }
+ }, [getProductType, productData, themeData, panelType]);
+
+ const imageUrl = useMemo(() => fp.pipe(() => productData, fp.get('thumbnailUrl960'))(), [productData]);
+
+ // FP 방식으로 타이틀과 aria-label 메모이제이션 (성능 최적화)
+ const headerTitle = useMemo(() => fp.pipe(
+ () => ({ panelPrdtId, productData, panelType, themeData }),
+ ({ panelPrdtId, productData, panelType, themeData }) => {
+ const productTitle = fp.pipe(
+ () => ({ panelPrdtId, productData }),
+ ({ panelPrdtId, productData }) =>
+ fp.isNotNil(panelPrdtId) && fp.pipe(() => productData, fp.get('prdtNm'), fp.isNotNil)()
+ ? fp.pipe(() => productData, fp.get('prdtNm'))()
+ : null
+ )();
+
+ const themeTitle = fp.pipe(
+ () => ({ panelType, themeData }),
+ ({ panelType, themeData }) =>
+ panelType === "theme" && fp.pipe(() => themeData, fp.get('curationNm'), fp.isNotNil)()
+ ? fp.pipe(() => themeData, fp.get('curationNm'))()
+ : null
+ )();
+
+ return productTitle || themeTitle || "";
+ }
+ )(), [panelPrdtId, productData, panelType, themeData]);
+
+ const ariaLabel = useMemo(() => fp.pipe(
+ () => ({ panelPrdtId, productData }),
+ ({ panelPrdtId, productData }) =>
+ fp.isNotNil(panelPrdtId) && fp.pipe(() => productData, fp.get('prdtNm'), fp.isNotNil)()
+ ? fp.pipe(() => productData, fp.get('prdtNm'))()
+ : ""
+ )(), [panelPrdtId, productData]);
+
+ // FP 방식으로 배경 이미지 설정 (메모리 누수 방지)
+ useLayoutEffect(() => {
+ const shouldSetBackground = fp.pipe(
+ () => ({ imageUrl, containerRef }),
+ ({ imageUrl, containerRef }) =>
+ fp.isNotNil(imageUrl) && fp.isNotNil(containerRef.current)
+ )();
+
+ if (shouldSetBackground) {
+ containerRef.current.style.setProperty("--bg-url", `url('${imageUrl}')`);
+ }
+ }, [imageUrl]);
+
+ console.log("productDataSource :", productDataSource);
+
+ // 언마운트 시 인덱스 초기화가 필요하면:
+ // useEffect(() => () => setSelectedIndex(0), [])
+
return (
- <>
+
-
- {!isLoading && (
- <>
- {/* 결제가능상품 영역 */}
- {isBillingProductVisible && (
- {
+ // FP 방식으로 렌더링 조건 결정 (메모이제이션으로 최적화)
+ const renderStates = fp.pipe(
+ () => ({ isLoading, panelInfo, productDataSource, productType }),
+ ({ isLoading, panelInfo, productDataSource, productType }) => {
+ const hasRequiredData = fp.pipe(
+ () => [panelInfo, productDataSource, productType],
+ data => fp.reduce((acc, item) => acc && fp.isNotNil(item), true, data)
+ )();
+
+ return {
+ canRender: !isLoading && hasRequiredData,
+ showLoading: !isLoading && !hasRequiredData,
+ showNothing: isLoading
+ };
+ }
+ )();
+
+ if (renderStates.canRender) {
+ return (
+
- )}
- {/* 구매불가상품 영역 */}
- {isUnavailableProductVisible && (
-
- )}
- {/* 그룹상품 영역 */}
- {isGroupProductVisible && (
-
- )}
- {/* 테마그룹상품 영역*/}
- {isTravelProductVisible && (
-
- )}
- >
- )}
+ );
+ }
+
+ if (renderStates.showLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+ }, [isLoading, panelInfo, productDataSource, productType, selectedIndex, panelPatnrId, panelPrdtId, updateSelectedIndex, openThemeItemOverlay, updateThemeItemOverlay, themeProductInfo])}
-
- {lgCatCd && youmaylikeData && youmaylikeData.length > 0 && isOnTop && (
-
- )}
- {activePopup === Config.ACTIVE_POPUP.smsPopup && (
-
- )}
- >
+
+
);
}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.module.less b/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.module.less
index ca94a989..d50c6f2f 100644
--- a/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.module.less
+++ b/com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.module.less
@@ -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;
+ }
}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
new file mode 100644
index 00000000..11491d2d
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
@@ -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 = () => (
+
+ Layout Sample (1124px x 300px)
+
+);
+
+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 (
+
+ {/* Left Margin Section - 60px */}
+
+
+ {/* Info Section - 645px */}
+
+
+ {productType && productData && (
+
+
+
+ {revwGrd && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {$L("SHOP BY MOBILE")}
+
+
+ {panelInfo && (
+
+
+
+ )}
+
+
+ {orderPhnNo && (
+
+
+ {$L("Call to Order")}
+
+
+
+ )}
+
+
+
+ {$L("PRODUCT DETAILS")}
+
+ {/* 🔧 [임시] 고객 데모용 조건 변경: showUserReviewsButton (원래: reviewTotalCount > 0) */}
+ {showUserReviewsButton && (
+
+ {$L(
+ `USER REVIEWS (${reviewTotalCount > 100 ? "100" : reviewTotalCount || "0"})`,
+ )}
+
+ )}
+
+ {$L("YOU MAY ALSO LIKE")}
+
+
+
+ {panelInfo &&
+ panelInfo && panelInfo.type === "theme" &&
+ !openThemeItemOverlay && (
+
+ {$L("THEME ITEM")}
+
+ )}
+
+
+
+ )}
+
+
+
+ {/* Content Section - 1114px */}
+
+
+
+
+
+
+
+
+ {productData?.imgUrls600 && productData.imgUrls600.length > 0 ? (
+ productData.imgUrls600.map((image, index) => (
+
+ ))
+ ) : (
+
+ )}
+
+
+
+ {/* Description 바로 아래에 UserReviews 항상 표시 (조건 제거) */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+ProductAllSection.propTypes = {
+ productType: PropTypes.oneOf(["buyNow", "shopByMobile", "theme"]).isRequired,
+};
\ No newline at end of file
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.module.less
new file mode 100644
index 00000000..58a69c8e
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.module.less
@@ -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; }
+ }
+ }
+ }
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDescription/ProductDescription.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDescription/ProductDescription.jsx
new file mode 100644
index 00000000..8768b092
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDescription/ProductDescription.jsx
@@ -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 (
+
+ {/* console.log("[ProductDescription] Title clicked")}
+ onFocus={() => console.log("[ProductDescription] Title focused")}
+ onBlur={() => console.log("[ProductDescription] Title blurred")}
+ > */}
+
+
+ {/* */}
+
+ console.log("[ProductDescription] Content clicked")}
+ onFocus={() => console.log("[ProductDescription] Content focused")}
+ onBlur={() => console.log("[ProductDescription] Content blurred")}
+ onKeyDown={handleKeyDown}
+ >
+
+
+
+ );
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDescription/ProductDescription.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDescription/ProductDescription.module.less
new file mode 100644
index 00000000..d465ee52
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDescription/ProductDescription.module.less
@@ -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;
+ }
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDescription/package.json b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDescription/package.json
new file mode 100644
index 00000000..cf808315
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDescription/package.json
@@ -0,0 +1,6 @@
+{
+ "main": "ProductDescription.jsx",
+ "styles": [
+ "ProductDescription.module.less"
+ ]
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDetail/ProductDetail.new.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDetail/ProductDetail.new.jsx
new file mode 100644
index 00000000..9a698ef8
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDetail/ProductDetail.new.jsx
@@ -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 (
+
+
+
+ );
+ }, [listImages, productInfo?.imageIndex, productInfo?.totalImages]);
+
+ const imageIndex = productInfo?.imageIndex ?? 0;
+ const totalImages = productInfo?.totalImages ?? listImages.length;
+
+ return (
+
+ {/* 메인 이미지 영역 - 단일 이미지 표시 */}
+
+ {renderSingleImage()}
+
+
+ );
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDetail/ProductDetail.new.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDetail/ProductDetail.new.module.less
new file mode 100644
index 00000000..a70d66cf
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductDetail/ProductDetail.new.module.less
@@ -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;
+ }
+ }
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/CustomerImages/CustomerImages.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/CustomerImages/CustomerImages.jsx
new file mode 100644
index 00000000..ae624a44
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/CustomerImages/CustomerImages.jsx
@@ -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 (
+ <>
+
+
+ {imageList && imageList.length > 0 ? (
+
+ {(() => {
+ 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 (
+
handleReviewImageClick(actualIndex)}
+ onKeyDown={(ev) => handleKeyDown(ev, actualIndex)}
+ spotlightId={`customer-image-${actualIndex}`}
+ >
+
{
+ 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';
+ }}
+ />
+
+ );
+ })}
+
+ {hasMoreImages && (
+
{
+ if (ev.keyCode === 13) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ handleViewMoreClick();
+ }
+ }}
+ spotlightId="customer-images-view-more"
+ >
+
+
+ )}
+ >
+ );
+ })()}
+
+ ) : (
+
+
+ {reviewData ? 'No customer images available' : 'Loading customer images...'}
+
+
+ )}
+
+ >
+ );
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/CustomerImages/CustomerImages.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/CustomerImages/CustomerImages.module.less
new file mode 100644
index 00000000..da6f1108
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/CustomerImages/CustomerImages.module.less
@@ -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;
+ }
+ }
+ }
+ }
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviews.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviews.jsx
new file mode 100644
index 00000000..e3eaba9b
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviews.jsx
@@ -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 (
+
+
+
+ {reviewDetailData && reviewDetailData.totRvwAvg && (
+
+ )}
+
+
+
+
+ {$L(
+ `Showing ${reviewListData ? reviewListData.length : 0} out of ${reviewTotalCount} reviews`
+ )}
+
+ {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 (
+
+ {reviewImageList && reviewImageList.length > 0 && (
+
+ )}
+
+
+ {rvwRtng && (
+
+ )}
+ {(wrtrNknm || rvwWrtrId) && (
+
+ {wrtrNknm || rvwWrtrId}
+
+ )}
+ {rvwRgstDtt && (
+
+ {formatToYYMMDD(rvwRgstDtt)}
+
+ )}
+
+ {rvwCtnt && (
+
{rvwCtnt}
+ )}
+
+
+ );
+ })}
+
+
+
+ {/* UserReviewsPopup 추가 */}
+
+
+ );
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviews.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviews.module.less
new file mode 100644
index 00000000..9b559131
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviews.module.less
@@ -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;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviewsPopup/UserReviewsPopup.figma.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviewsPopup/UserReviewsPopup.figma.jsx
new file mode 100644
index 00000000..9d608bb9
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviewsPopup/UserReviewsPopup.figma.jsx
@@ -0,0 +1,33 @@
+
+
+
+
+
+

+
+

+

+

+

+

+

+

+

+

+

+
+
+
+
+
+
\ No newline at end of file
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviewsPopup/UserReviewsPopup.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviewsPopup/UserReviewsPopup.jsx
new file mode 100644
index 00000000..c13b80e5
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviewsPopup/UserReviewsPopup.jsx
@@ -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 (
+
+
+ {/* Header */}
+
+
+ {$L("Customer Images")}
+
+
+
+ {/* Content */}
+
+
+ {displayImages.map((image, index) => (
+
handleImageClick(index, image)}
+ >
+
+
+ ))}
+
+
+
+ {/* Footer */}
+
+
+ {$L("CLOSE")}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviewsPopup/UserReviewsPopup.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviewsPopup/UserReviewsPopup.module.less
new file mode 100644
index 00000000..15205038
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviewsPopup/UserReviewsPopup.module.less
@@ -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;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/package.json b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/package.json
new file mode 100644
index 00000000..3a2758ac
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/package.json
@@ -0,0 +1,6 @@
+{
+ "main": "UserReviews.jsx",
+ "styles": [
+ "UserReviews.module.less"
+ ]
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/YouMayAlsoLike/YouMayAlsoLike.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/YouMayAlsoLike/YouMayAlsoLike.jsx
new file mode 100644
index 00000000..765429c9
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/YouMayAlsoLike/YouMayAlsoLike.jsx
@@ -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 (
+
+ {youmaylikeProductData && youmaylikeProductData.length > 0 && (
+
+
+
+
+ {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 (
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/YouMayAlsoLike/YouMayAlsoLike.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/YouMayAlsoLike/YouMayAlsoLike.module.less
new file mode 100644
index 00000000..25ebe9ef
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/YouMayAlsoLike/YouMayAlsoLike.module.less
@@ -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;
+ }
+ }
+ }
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/YouMayAlsoLike/package.json b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/YouMayAlsoLike/package.json
new file mode 100644
index 00000000..008e47d9
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/YouMayAlsoLike/package.json
@@ -0,0 +1,6 @@
+{
+ "main": "YouMayAlsoLike.jsx",
+ "styles": [
+ "YouMayAlsoLike.module.less"
+ ]
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductInfoSection/QRCode/QRCode.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductInfoSection/QRCode/QRCode.jsx
new file mode 100644
index 00000000..e6d38cd9
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductInfoSection/QRCode/QRCode.jsx
@@ -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 (
+
+ {qrCodeUrl &&
}
+
+ {/* todo : 시나리오,UI 릴리즈 후 */}
+ {/*
+
+ {promotionCode ? promotionTooltip : tooltipDes}
+
+
*/}
+
+ );
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductInfoSection/QRCode/QRCode.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ProductInfoSection/QRCode/QRCode.module.less
new file mode 100644
index 00000000..48ecdbd7
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductInfoSection/QRCode/QRCode.module.less
@@ -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;
+ }
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductInfoSection/QRCode/package.json b/com.twin.app.shoptime/src/views/DetailPanel/ProductInfoSection/QRCode/package.json
new file mode 100644
index 00000000..f0891a0b
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductInfoSection/QRCode/package.json
@@ -0,0 +1,6 @@
+{
+ "main": "QRCode.jsx",
+ "styles": [
+ "QRCode.module.less"
+ ]
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductOverview.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductOverview.jsx
new file mode 100644
index 00000000..b1e66f6e
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductOverview.jsx
@@ -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 (
+
+ {/* price Info */}
+ {productInfo && productType && (
+
+ {/* price */}
+
+ {/* QR */}
+ {children}
+
+ )}
+
+ );
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductOverview.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductOverview.module.less
new file mode 100644
index 00000000..01c97513
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductOverview.module.less
@@ -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;
+ }
+ }
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/BuyNowPriceDisplay/BuyNowPriceDisplay.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/BuyNowPriceDisplay/BuyNowPriceDisplay.jsx
new file mode 100644
index 00000000..0b238174
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/BuyNowPriceDisplay/BuyNowPriceDisplay.jsx
@@ -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 (
+
+
+ {patncNm
+ ? patncNm + " " + $L("Price")
+ : patnrName + " " + $L("Price")}
+
+
+ {discountRate && Number(discountRate.replace("%", "")) > 4 && (
+
{discountRate}
+ )}
+
+ {isDiscountedPriceEmpty ? offerInfo : discountedPrice}
+
+ {isDiscounted && (
+
+ {originalPrice && isOriginalPriceEmpty
+ ? offerInfo
+ : originalPrice}
+
+ )}
+
+ {/* 할부 */}
+
+ );
+ }, []);
+
+ return {renderItem()}
;
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/BuyNowPriceDisplay/BuyNowPriceDisplay.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/BuyNowPriceDisplay/BuyNowPriceDisplay.module.less
new file mode 100644
index 00000000..e69de29b
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/BuyNowPriceDisplay/package.json b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/BuyNowPriceDisplay/package.json
new file mode 100644
index 00000000..c6f3eb23
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/BuyNowPriceDisplay/package.json
@@ -0,0 +1,6 @@
+{
+ "main": "BuyNowPriceDisplay.jsx",
+ "styles": [
+ "BuyNowPriceDisplay.module.less"
+ ]
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ProductPriceDisplay.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ProductPriceDisplay.jsx
new file mode 100644
index 00000000..0d14b765
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ProductPriceDisplay.jsx
@@ -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 && (
+
+ {/* shop by mobile (구매불가) 상품 price render */}
+ {(productType === "shopByMobile" || isThemeShopByMobile) && (
+
+ )}
+
+ {/* buy now (결제 가능) 상품 price render */}
+ {(productType === "buyNow" || isThemeBuyNow) && (
+
+ )}
+
+ )}
+ >
+ );
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ProductPriceDisplay.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ProductPriceDisplay.module.less
new file mode 100644
index 00000000..44afa451
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ProductPriceDisplay.module.less
@@ -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;
+ }
+ }
+ }
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ShopByMobilePriceDisplay/ShopByMobilePriceDisplay.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ShopByMobilePriceDisplay/ShopByMobilePriceDisplay.jsx
new file mode 100644
index 00000000..29c5aa1a
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ShopByMobilePriceDisplay/ShopByMobilePriceDisplay.jsx
@@ -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 (
+
+
+
+ {patncNm
+ ? patncNm + " " + $L("Price") + " "
+ : patnrName + " " + $L("Price") + " "}
+
+
+ {TYPE_CASE.case5 || TYPE_CASE.case8
+ ? isOriginalPriceEmpty
+ ? offerInfo
+ : originalPrice
+ : discountedPrice}
+
+
+
+
{$L("Shop Time Price")}
+
+ {/* TODO : rewd data*/}
+ {/* 분할금액 조건문처리 케이스별로 */}
+ {discountedPrice}
+ {/* 리워드 금액 */}
+ {(TYPE_CASE.case7 ||
+ TYPE_CASE.case5 ||
+ TYPE_CASE.case6 ||
+ TYPE_CASE.case8) && (
+
+ {$L("Save") + discountAmount + discountRate}
+
+ )}
+
+
+
+ );
+ } else {
+ if (TYPE_CASE.case1 || TYPE_CASE.case4) {
+ return (
+
+
+ {patncNm
+ ? patncNm + " " + $L("Price")
+ : patnrName + " " + $L("Price")}
+
+
+
+ {isOriginalPriceEmpty ? offerInfo : originalPrice}
+
+
+
+ );
+ } else if (TYPE_CASE.case2) {
+ return (
+
+
+ {discountRate && Number(discountRate.replace("%", "")) > 4 && (
+
{discountRate}
+ )}
+
+ {patncNm
+ ? patncNm + " " + $L("Price")
+ : patnrName + $L("Price")}
+
+
+
+
+ {isDiscountedPriceEmpty ? offerInfo : discountedPrice}
+
+ {isDiscounted && (
+
+ {originalPrice && isOriginalPriceEmpty
+ ? offerInfo
+ : originalPrice}
+
+ )}
+
+
+ );
+ } else if (TYPE_CASE.case3) {
+ return (
+
+
+ {patncNm
+ ? patncNm + " " + $L("Price")
+ : patnrName + " " + $L("Price")}
+
+
+ {discountRate && Number(discountRate.replace("%", "")) > 4 && (
+
{discountRate}
+ )}
+
+ {isDiscountedPriceEmpty ? offerInfo : discountedPrice}
+
+ {isDiscounted && (
+
+ {originalPrice && isOriginalPriceEmpty
+ ? offerInfo
+ : originalPrice}
+
+ )}
+
+ {/* 할부 */}
+
+ );
+ }
+ }
+ } else if (promotionCode) {
+ return (
+
+
+ {discountRate && Number(discountRate.replace("%", "")) > 4 && (
+
{discountRate}
+ )}
+
{$L("Shop Time Price")}
+
+
+ {discountedPrice}
+ {discountedPrice !== originalPrice && (
+
+ {originalPrice && isOriginalPriceEmpty
+ ? offerInfo
+ : originalPrice}
+
+ )}
+
+
+ );
+ }
+ if (TYPE_CASE.case9) {
+ return (
+
+
+ {patncNm ? patncNm + " " + $L("Price") : patnrName + $L("Price")}
+
+ {offerInfo}
+
+ );
+ }
+ }, [
+ patnrName,
+ priceInfo,
+ isOriginalPriceEmpty,
+ isDiscountedPriceEmpty,
+ TYPE_CASE,
+ offerInfo,
+ isDiscounted,
+ ]);
+
+ return {renderPriceItem()}
;
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ShopByMobilePriceDisplay/ShopByMobilePriceDisplay.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ShopByMobilePriceDisplay/ShopByMobilePriceDisplay.module.less
new file mode 100644
index 00000000..b003c935
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ShopByMobilePriceDisplay/ShopByMobilePriceDisplay.module.less
@@ -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;
+ }
+ }
+ }
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ShopByMobilePriceDisplay/package.json b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ShopByMobilePriceDisplay/package.json
new file mode 100644
index 00000000..32c41246
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/ShopByMobilePriceDisplay/package.json
@@ -0,0 +1,6 @@
+{
+ "main": "ShopByMobilePriceDisplay.jsx",
+ "styles": [
+ "ShopByMobilePriceDisplay.module.less"
+ ]
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/package.json b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/package.json
new file mode 100644
index 00000000..1d8ade51
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductPriceDisplay/package.json
@@ -0,0 +1,6 @@
+{
+ "main": "ProductPriceDisplay.jsx",
+ "styles": [
+ "ProductPriceDisplay.module.less"
+ ]
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/package.json b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/package.json
new file mode 100644
index 00000000..31539a61
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/package.json
@@ -0,0 +1,6 @@
+{
+ "main": "ProductOverview.jsx",
+ "styles": [
+ "ProductOverview.module.less"
+ ]
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ThemeItemListOverlay/ThemeItemListOverlay.jsx b/com.twin.app.shoptime/src/views/DetailPanel/ThemeItemListOverlay/ThemeItemListOverlay.jsx
new file mode 100644
index 00000000..882d2a46
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ThemeItemListOverlay/ThemeItemListOverlay.jsx
@@ -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 (
+
+ );
+ },
+ [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 (
+
+ {productInfo && productInfo?.productInfos?.length > 0 && isOpen && (
+
+
+ {$L("THEME ITEM")}
+
+
+
+ )}
+
+ );
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/ThemeItemListOverlay/ThemeItemListOverlay.module.less b/com.twin.app.shoptime/src/views/DetailPanel/ThemeItemListOverlay/ThemeItemListOverlay.module.less
new file mode 100644
index 00000000..c69e78d1
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/ThemeItemListOverlay/ThemeItemListOverlay.module.less
@@ -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);
+ // }
+ }
+ }
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/components/CustomScrollbar/CustomScrollbar.jsx b/com.twin.app.shoptime/src/views/DetailPanel/components/CustomScrollbar/CustomScrollbar.jsx
new file mode 100644
index 00000000..beec03ee
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/components/CustomScrollbar/CustomScrollbar.jsx
@@ -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 (
+
+ );
+};
+
+export default CustomScrollbar;
\ No newline at end of file
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/components/CustomScrollbar/CustomScrollbar.module.less b/com.twin.app.shoptime/src/views/DetailPanel/components/CustomScrollbar/CustomScrollbar.module.less
new file mode 100644
index 00000000..77d64a46
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/components/CustomScrollbar/CustomScrollbar.module.less
@@ -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;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/components/DetailMobileSendPopUp.jsx b/com.twin.app.shoptime/src/views/DetailPanel/components/DetailMobileSendPopUp.jsx
new file mode 100644
index 00000000..da6fc63c
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/components/DetailMobileSendPopUp.jsx
@@ -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 && (
+
+ )}
+ >
+ );
+}
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/components/DetailMobileSendPopUp.module.less b/com.twin.app.shoptime/src/views/DetailPanel/components/DetailMobileSendPopUp.module.less
new file mode 100644
index 00000000..7b34bb91
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/components/DetailMobileSendPopUp.module.less
@@ -0,0 +1,2 @@
+@import "../../../style/CommonStyle.module.less";"
+@import "../../../style/utils.module.less";
\ No newline at end of file
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/components/THeaderCustom.jsx b/com.twin.app.shoptime/src/views/DetailPanel/components/THeaderCustom.jsx
new file mode 100644
index 00000000..0054fb20
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/components/THeaderCustom.jsx
@@ -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 (
+
+ {onBackButton && (
+
+ )}
+
+
+ {children}
+
+ );
+}
\ No newline at end of file
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/components/THeaderCustom.module.less b/com.twin.app.shoptime/src/views/DetailPanel/components/THeaderCustom.module.less
new file mode 100644
index 00000000..dcb534a4
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/components/THeaderCustom.module.less
@@ -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
+}
\ No newline at end of file
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/components/TScroller/TScrollerDetail.jsx b/com.twin.app.shoptime/src/views/DetailPanel/components/TScroller/TScrollerDetail.jsx
new file mode 100644
index 00000000..dc4c9a18
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/components/TScroller/TScrollerDetail.jsx
@@ -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 (
+
+
+ {children}
+
+ {cursorVisible &&
+ autoScroll &&
+ relevantPositions.map((pos) => (
+
+ ))}
+
+ );
+});
+
+// TScrollerDetail에 메서드 노출
+TScrollerDetail.scrollToElement = (element) => {
+ // 이 메서드는 ref를 통해 접근할 수 있도록 구현
+};
+
+// displayName을 명확하게 설정
+TScrollerDetail.displayName = 'TScrollerDetail';
+
+// forwardRef를 사용하는 컴포넌트임을 명시
+export default TScrollerDetail;
\ No newline at end of file
diff --git a/com.twin.app.shoptime/src/views/DetailPanel/components/TScroller/TScrollerDetail.module.less b/com.twin.app.shoptime/src/views/DetailPanel/components/TScroller/TScrollerDetail.module.less
new file mode 100644
index 00000000..ec226c6c
--- /dev/null
+++ b/com.twin.app.shoptime/src/views/DetailPanel/components/TScroller/TScrollerDetail.module.less
@@ -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
+ }
+ }
+ }
+}
\ No newline at end of file