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")} +
+
+
+
+
+
{orderPhnNo}
+
+
+ )} + + + + {$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")} + > */} +
+
{$L("DESCRIPTION")}
+
+ + {/*
*/} + + 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}`} + > + {`Review { + 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" + > +
+
+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 @@ +
+
+
Customer Images 
+
+
+
+
+ +
+ + + + + + + + + + +
+
+View More
+
+
+
+
+
+
+
+
+
CLOSE
+
+
+
\ 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)} + > + {`Customer + + ))} +
+
+ + {/* 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 && ( + + )} +
+ + {convertedTitle && ( + + )} + + {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