[상품 상세] 개발 진행중 - UserReviews 기능 개선 및 UI 개선
- UserReviews 컴포넌트 리팩토링 및 페이지네이션 구현 - 새로운 hooks/useReviews 훅 추가 - DetailPanel UI/UX 개선 및 스타일 업데이트 - 이미지 스켈레톤 로딩 컴포넌트 추가 - THeaderDetail 컴포넌트 신규 추가 - 유틸리티 함수 확장 (fpHelpers.js)
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 552 B |
|
After Width: | Height: | Size: 598 B |
|
After Width: | Height: | Size: 750 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="17" height="29" viewBox="0 0 17 29" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14.7383 2.2207L3.40828 13.5267C2.83828 14.0947 2.83828 15.0137 3.40828 15.5817L14.7383 26.8877" stroke="#C70850" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 267 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="17" height="29" viewBox="0 0 17 29" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14.7383 2.2207L3.40828 13.5267C2.83828 14.0947 2.83828 15.0137 3.40828 15.5817L14.7383 26.8877" stroke="#222222" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 267 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="17" height="29" viewBox="0 0 17 29" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2.98047 2.2207L14.3105 13.5267C14.8805 14.0947 14.8805 15.0137 14.3105 15.5817L2.98047 26.8877" stroke="#C70850" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 267 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="17" height="29" viewBox="0 0 17 29" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2.98047 2.2207L14.3105 13.5267C14.8805 14.0947 14.8805 15.0137 14.3105 15.5817L2.98047 26.8877" stroke="#222222" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 267 B |
@@ -95,6 +95,36 @@ const disableConsole = () => {
|
|||||||
console.info = function () {};
|
console.info = function () {};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// console.log 자동 Object 직렬화 오버라이드
|
||||||
|
const originalConsoleLog = console.log;
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
const originalConsoleWarn = console.warn;
|
||||||
|
|
||||||
|
const processArgs = (args) => {
|
||||||
|
return args.map(arg => {
|
||||||
|
if (typeof arg === 'object' && arg !== null && !Array.isArray(arg)) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(arg, null, 2);
|
||||||
|
} catch (e) {
|
||||||
|
return `[Object: ${e.message}]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return arg;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log = function(...args) {
|
||||||
|
originalConsoleLog.apply(console, processArgs(args));
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = function(...args) {
|
||||||
|
originalConsoleError.apply(console, processArgs(args));
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn = function(...args) {
|
||||||
|
originalConsoleWarn.apply(console, processArgs(args));
|
||||||
|
};
|
||||||
|
|
||||||
const originFocus = Spotlight.focus;
|
const originFocus = Spotlight.focus;
|
||||||
Spotlight.focus = function (elem, containerOption) {
|
Spotlight.focus = function (elem, containerOption) {
|
||||||
const ret = originFocus.apply(this, [elem, containerOption]); // this 바인딩을 유지하여 originFocus 호출
|
const ret = originFocus.apply(this, [elem, containerOption]); // this 바인딩을 유지하여 originFocus 호출
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export const types = {
|
|||||||
CLEAR_PRODUCT_OPTIONS: "CLEAR_PRODUCT_OPTIONS",
|
CLEAR_PRODUCT_OPTIONS: "CLEAR_PRODUCT_OPTIONS",
|
||||||
GET_USER_REVIEW: "GET_USER_REVIEW",
|
GET_USER_REVIEW: "GET_USER_REVIEW",
|
||||||
TOGGLE_SHOW_ALL_REVIEWS: "TOGGLE_SHOW_ALL_REVIEWS",
|
TOGGLE_SHOW_ALL_REVIEWS: "TOGGLE_SHOW_ALL_REVIEWS",
|
||||||
|
RESET_SHOW_ALL_REVIEWS: "RESET_SHOW_ALL_REVIEWS",
|
||||||
|
|
||||||
// search actions
|
// search actions
|
||||||
GET_SEARCH: "GET_SEARCH",
|
GET_SEARCH: "GET_SEARCH",
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ const extractReviewApiData = (apiResponse) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 추출된 데이터 검증
|
// 추출된 데이터 검증
|
||||||
console.log("[UserReviews] 📊 추출된 데이터 검증:", {
|
/* console.log("[UserReviews] 📊 추출된 데이터 검증:", {
|
||||||
hasReviewList: !!apiData.reviewList,
|
hasReviewList: !!apiData.reviewList,
|
||||||
hasReviewDetail: !!apiData.reviewDetail,
|
hasReviewDetail: !!apiData.reviewDetail,
|
||||||
reviewListLength: apiData.reviewList ? apiData.reviewList.length : 0,
|
reviewListLength: apiData.reviewList ? apiData.reviewList.length : 0,
|
||||||
@@ -155,7 +155,7 @@ const extractReviewApiData = (apiResponse) => {
|
|||||||
totRvwCnt: apiData.reviewDetail && apiData.reviewDetail.totRvwCnt,
|
totRvwCnt: apiData.reviewDetail && apiData.reviewDetail.totRvwCnt,
|
||||||
totRvwAvg: apiData.reviewDetail && apiData.reviewDetail.totRvwAvg,
|
totRvwAvg: apiData.reviewDetail && apiData.reviewDetail.totRvwAvg,
|
||||||
extractedData: apiData
|
extractedData: apiData
|
||||||
});
|
}); */
|
||||||
|
|
||||||
return apiData;
|
return apiData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -164,51 +164,80 @@ const extractReviewApiData = (apiResponse) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock 데이터 생성 함수 (재사용성 위해 분리)
|
// Mock 데이터 생성 함수 (재사용성 위해 분리) - 100개 리뷰와 많은 이미지 포함
|
||||||
const createMockReviewData = () => ({
|
const createMockReviewData = () => {
|
||||||
reviewList: [
|
const reviewTexts = [
|
||||||
{
|
"The shoes are really stylish and comfortable for daily wear. I love the design and how lightweight they feel.",
|
||||||
rvwId: "mock-review-1",
|
"Great value for the price! The quality is better than I expected. Shipping was fast and the product arrived in perfect condition.",
|
||||||
rvwRtng: 5,
|
"Amazing product! Really happy with this purchase. The color is exactly as shown in the pictures.",
|
||||||
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.",
|
"Good quality overall but took a while to arrive. Packaging was excellent though.",
|
||||||
rvwRgstDtt: "2024-01-15",
|
"Perfect fit and very comfortable. Would definitely buy again from this seller.",
|
||||||
reviewImageList: [
|
"Beautiful design and great materials. My family loves it!",
|
||||||
{
|
"Exceeded my expectations. Really impressed with the craftsmanship.",
|
||||||
imgId: "mock-img-1",
|
"Good product but could be better. Price is reasonable for what you get.",
|
||||||
|
"Love the style and functionality. Works exactly as described.",
|
||||||
|
"Fantastic quality! Highly recommend this to anyone looking for this type of product."
|
||||||
|
];
|
||||||
|
|
||||||
|
const reviewList = [];
|
||||||
|
for (let i = 1; i <= 100; i++) {
|
||||||
|
const hasImages = Math.random() > 0.6; // 40% 확률로 이미지 있음
|
||||||
|
const numImages = hasImages ? Math.floor(Math.random() * 3) + 1 : 0; // 1~3개 이미지
|
||||||
|
const rating = Math.floor(Math.random() * 5) + 1; // 1~5 별점
|
||||||
|
|
||||||
|
const reviewImageList = [];
|
||||||
|
if (hasImages) {
|
||||||
|
for (let j = 0; j < numImages; j++) {
|
||||||
|
reviewImageList.push({
|
||||||
|
imgId: `mock-img-${i}-${j + 1}`,
|
||||||
imgUrl: reviewSampleImage,
|
imgUrl: reviewSampleImage,
|
||||||
imgSeq: 1
|
imgSeq: j + 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: {
|
reviewList.push({
|
||||||
totRvwCnt: 2,
|
rvwId: `mock-review-${i}`,
|
||||||
totRvwAvg: 4.5
|
rvwRtng: rating,
|
||||||
|
rvwScr: rating,
|
||||||
|
rvwCtnt: reviewTexts[i % reviewTexts.length] + ` (Review #${i})`,
|
||||||
|
rvwRgstDtt: `2024-01-${String((i % 30) + 1).padStart(2, '0')}`,
|
||||||
|
wrtrNknm: `User${i}`,
|
||||||
|
rvwWrtrId: `user${i}`,
|
||||||
|
reviewImageList
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
return {
|
||||||
|
reviewList,
|
||||||
|
reviewDetail: {
|
||||||
|
totRvwCnt: 100,
|
||||||
|
totRvwAvg: 4.2,
|
||||||
|
avgRvwScr: 4.2
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// showAllReviews 상태 토글
|
// showAllReviews 상태 토글
|
||||||
export const toggleShowAllReviews = () => ({
|
export const toggleShowAllReviews = () => ({
|
||||||
type: types.TOGGLE_SHOW_ALL_REVIEWS
|
type: types.TOGGLE_SHOW_ALL_REVIEWS
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// showAllReviews 상태 초기화 (ProductAllSection 마운트 시 사용)
|
||||||
|
export const resetShowAllReviews = () => ({
|
||||||
|
type: types.RESET_SHOW_ALL_REVIEWS
|
||||||
|
});
|
||||||
|
|
||||||
// 상품별 유저 리뷰 리스트 조회 : IF-LGSP-0002
|
// 상품별 유저 리뷰 리스트 조회 : IF-LGSP-0002
|
||||||
export const getUserReviews = (requestParams) => (dispatch, getState) => {
|
export const getUserReviews = (requestParams) => (dispatch, getState) => {
|
||||||
const { prdtId } = requestParams;
|
const { prdtId } = requestParams;
|
||||||
|
|
||||||
console.log("[UserReviews] 🚀 getUserReviews 액션 시작:", {
|
/* console.log("[UserReviews] 🚀 getUserReviews 액션 시작:", {
|
||||||
requestParams,
|
requestParams,
|
||||||
originalPrdtId: prdtId,
|
originalPrdtId: prdtId,
|
||||||
willUseRandomPrdtId: true, // 임시 테스트 플래그
|
willUseRandomPrdtId: true, // 임시 테스트 플래그
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
}); */
|
||||||
|
|
||||||
// ==================== [임시 테스트] 시작 ====================
|
// ==================== [임시 테스트] 시작 ====================
|
||||||
// 테스트용 prdtId 목록 - 제거 시 이 블록 전체 삭제
|
// 테스트용 prdtId 목록 - 제거 시 이 블록 전체 삭제
|
||||||
@@ -232,16 +261,16 @@ export const getUserReviews = (requestParams) => (dispatch, getState) => {
|
|||||||
const params = { prdtId: randomPrdtId }; // 임시: randomPrdtId 사용, 원본: prdtId 사용
|
const params = { prdtId: randomPrdtId }; // 임시: randomPrdtId 사용, 원본: prdtId 사용
|
||||||
const body = {}; // GET이므로 빈 객체
|
const body = {}; // GET이므로 빈 객체
|
||||||
|
|
||||||
console.log("[UserReviews] 📡 TAxios 호출 준비:", {
|
/* console.log("[UserReviews] 📡 TAxios 호출 준비:", {
|
||||||
method: "get",
|
method: "get",
|
||||||
url: URLS.GET_USER_REVEIW,
|
url: URLS.GET_USER_REVEIW,
|
||||||
params,
|
params,
|
||||||
body,
|
body,
|
||||||
selectedRandomPrdtId: randomPrdtId, // 임시: 선택된 랜덤 상품 ID
|
selectedRandomPrdtId: randomPrdtId, // 임시: 선택된 랜덤 상품 ID
|
||||||
});
|
}); */
|
||||||
|
|
||||||
const onSuccess = (response) => {
|
const onSuccess = (response) => {
|
||||||
console.log("[UserReviews] ✅ API 성공 응답:", {
|
/* console.log("[UserReviews] ✅ API 성공 응답:", {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
headers: response.headers,
|
headers: response.headers,
|
||||||
@@ -249,7 +278,28 @@ export const getUserReviews = (requestParams) => (dispatch, getState) => {
|
|||||||
retMsg: response.data && response.data.retMsg,
|
retMsg: response.data && response.data.retMsg,
|
||||||
hasData: !!(response.data && response.data.data),
|
hasData: !!(response.data && response.data.data),
|
||||||
fullResponse: response.data
|
fullResponse: response.data
|
||||||
});
|
}); */
|
||||||
|
|
||||||
|
// ==================== [임시 테스트] 빈 리뷰 응답 시뮬레이션 ====================
|
||||||
|
// 30% 확률로 리뷰 없는 상품 응답 반환 (테스트용)
|
||||||
|
if (Math.random() < 0.3) {
|
||||||
|
console.log("[UserReviews] 🚫 임시 테스트: 빈 리뷰 응답 반환");
|
||||||
|
const emptyReviewData = {
|
||||||
|
reviewList: [],
|
||||||
|
reviewDetail: {
|
||||||
|
totRvwCnt: 0,
|
||||||
|
totRvwAvg: 0,
|
||||||
|
avgRvwScr: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
dispatch({
|
||||||
|
type: types.GET_USER_REVIEW,
|
||||||
|
payload: { ...emptyReviewData, prdtId: prdtId }
|
||||||
|
});
|
||||||
|
console.log("[UserReviews] 📦 빈 리뷰 데이터 디스패치 완료:", emptyReviewData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ==================== [임시 테스트] 끝 ====================
|
||||||
|
|
||||||
if (response.data && response.data.data) {
|
if (response.data && response.data.data) {
|
||||||
console.log("[UserReviews] 📊 API 데이터 상세:", {
|
console.log("[UserReviews] 📊 API 데이터 상세:", {
|
||||||
@@ -268,7 +318,7 @@ export const getUserReviews = (requestParams) => (dispatch, getState) => {
|
|||||||
console.log("[UserReviews] ✅ 실제 API 데이터 사용");
|
console.log("[UserReviews] ✅ 실제 API 데이터 사용");
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.GET_USER_REVIEW,
|
type: types.GET_USER_REVIEW,
|
||||||
payload: apiData
|
payload: { ...apiData, prdtId: prdtId }
|
||||||
});
|
});
|
||||||
console.log("[UserReviews] 📦 실제 API 데이터 디스패치 완료:", apiData);
|
console.log("[UserReviews] 📦 실제 API 데이터 디스패치 완료:", apiData);
|
||||||
} else {
|
} else {
|
||||||
@@ -276,7 +326,7 @@ export const getUserReviews = (requestParams) => (dispatch, getState) => {
|
|||||||
const mockData = createMockReviewData();
|
const mockData = createMockReviewData();
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.GET_USER_REVIEW,
|
type: types.GET_USER_REVIEW,
|
||||||
payload: mockData
|
payload: { ...mockData, prdtId: prdtId }
|
||||||
});
|
});
|
||||||
console.log("[UserReviews] 📦 Mock 데이터 디스패치 완료:", mockData);
|
console.log("[UserReviews] 📦 Mock 데이터 디스패치 완료:", mockData);
|
||||||
}
|
}
|
||||||
@@ -297,7 +347,7 @@ export const getUserReviews = (requestParams) => (dispatch, getState) => {
|
|||||||
const mockData = createMockReviewData();
|
const mockData = createMockReviewData();
|
||||||
dispatch({
|
dispatch({
|
||||||
type: types.GET_USER_REVIEW,
|
type: types.GET_USER_REVIEW,
|
||||||
payload: mockData
|
payload: { ...mockData, prdtId: prdtId }
|
||||||
});
|
});
|
||||||
console.log("[UserReviews] 📦 Mock 데이터 디스패치 완료 (API 실패):", mockData);
|
console.log("[UserReviews] 📦 Mock 데이터 디스패치 완료 (API 실패):", mockData);
|
||||||
};
|
};
|
||||||
|
|||||||
419
com.twin.app.shoptime/src/hooks/useReviews/useReviews.js
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
|
import { getUserReviews } from '../../actions/productActions';
|
||||||
|
import fp from '../../utils/fp';
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 3;
|
||||||
|
|
||||||
|
const useReviews = (prdtId) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
// Redux 상태에서 리뷰 데이터 가져오기 - CustomerImages와 동일한 방식
|
||||||
|
const reviewData = useSelector(state => state.product.reviewData);
|
||||||
|
const loadedPrdtId = useSelector(state => state.product.loadedPrdtId);
|
||||||
|
|
||||||
|
// 빈 내용 리뷰 필터링 - 의미있는 리뷰만 표시
|
||||||
|
const allReviews = (reviewData?.reviewList || []).filter(review => {
|
||||||
|
const content = review.rvwCtnt?.trim();
|
||||||
|
return content && content.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const reviewDetail = reviewData?.reviewDetail || {};
|
||||||
|
|
||||||
|
// [useReviews] Redux 상태 확인 로그
|
||||||
|
console.log('[useReviews] Redux 상태 확인:', {
|
||||||
|
prdtId,
|
||||||
|
hasReviewData: !!reviewData,
|
||||||
|
reviewDataKeys: reviewData ? Object.keys(reviewData) : [],
|
||||||
|
reviewListLength: (reviewData && reviewData.reviewList) ? reviewData.reviewList.length : 0,
|
||||||
|
reviewDetail: reviewData ? reviewData.reviewDetail : null,
|
||||||
|
fullReviewData: reviewData
|
||||||
|
});
|
||||||
|
|
||||||
|
// 로컬 상태 관리
|
||||||
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
|
const [currentFilter, setCurrentFilter] = useState({
|
||||||
|
type: 'rating', // 'rating', 'keyword', 'sentiment'
|
||||||
|
value: 'all' // 'all', 1-5, 'positive', 'negative', 'aroma' 등
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [hasLoadedData, setHasLoadedData] = useState(false);
|
||||||
|
|
||||||
|
// 리뷰 데이터 로드 함수 - useReviews가 모든 API 호출을 담당
|
||||||
|
const loadReviews = useCallback(async () => {
|
||||||
|
if (!prdtId) {
|
||||||
|
console.warn('[useReviews] loadReviews 호출되었지만 prdtId가 없음');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useReviews] loadReviews 시작:', { prdtId });
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Redux action을 통해 API 호출
|
||||||
|
await dispatch(getUserReviews({ prdtId }));
|
||||||
|
setHasLoadedData(true);
|
||||||
|
console.log('[useReviews] loadReviews 완료');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useReviews] loadReviews 실패:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [prdtId, dispatch]);
|
||||||
|
|
||||||
|
// prdtId가 변경되면 자동으로 리뷰 데이터 로드 (싱글톤 패턴)
|
||||||
|
useEffect(() => {
|
||||||
|
if (prdtId && prdtId !== loadedPrdtId) {
|
||||||
|
console.log('[useReviews] prdtId changed, loading new data:', {
|
||||||
|
from: loadedPrdtId,
|
||||||
|
to: prdtId
|
||||||
|
});
|
||||||
|
loadReviews();
|
||||||
|
} else if (prdtId === loadedPrdtId) {
|
||||||
|
console.log('[useReviews] Using cached data for same prdtId:', prdtId);
|
||||||
|
setHasLoadedData(true); // 캐시된 데이터 사용 시 로드 완료 상태로 설정
|
||||||
|
}
|
||||||
|
}, [prdtId, loadedPrdtId, loadReviews]);
|
||||||
|
|
||||||
|
// 리뷰 데이터가 로드되면 로딩 상태 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
if (allReviews.length > 0 && isLoading) {
|
||||||
|
setIsLoading(false);
|
||||||
|
setHasLoadedData(true);
|
||||||
|
}
|
||||||
|
}, [allReviews.length, isLoading]);
|
||||||
|
|
||||||
|
// 키워드 매칭 함수
|
||||||
|
const matchesKeyword = useCallback((review, keyword) => {
|
||||||
|
if (!keyword) return true;
|
||||||
|
const content = review.rvwCtnt ? review.rvwCtnt.toLowerCase() : '';
|
||||||
|
return content.includes(keyword.toLowerCase());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 감정 매칭 함수
|
||||||
|
const matchesSentiment = useCallback((review, sentiment) => {
|
||||||
|
if (!sentiment) return true;
|
||||||
|
|
||||||
|
const positiveWords = new Set([
|
||||||
|
'good', 'great', 'excellent', 'amazing', 'love', 'perfect', 'best',
|
||||||
|
'wonderful', 'fantastic', 'awesome', 'outstanding', 'superb', 'brilliant',
|
||||||
|
'positive', 'happy', 'satisfied', 'pleased', 'delightful', 'enjoyed',
|
||||||
|
'recommend', 'nice', 'helpful', 'valuable', 'impressive', 'remarkable',
|
||||||
|
'favorite', 'friendly', 'comfortable', 'smooth', 'reliable', 'incredible',
|
||||||
|
'lovely', 'beautiful', 'fun', 'worthwhile', 'useful'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const negativeWords = new Set([
|
||||||
|
'bad', 'terrible', 'awful', 'hate', 'worst', 'horrible', 'poor',
|
||||||
|
'disappointing', 'useless', 'waste', 'cheap', 'broken', 'defective',
|
||||||
|
'unhappy', 'unsatisfied', 'frustrating', 'annoying', 'regret', 'slow',
|
||||||
|
'ugly', 'overpriced', 'boring', 'uncomfortable', 'noisy', 'buggy',
|
||||||
|
'dirty', 'smelly', 'difficult', 'complicated', 'fake', 'flimsy'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const negationWords = new Set(['not', 'no', 'never', "isn't", "wasn't", "don't", "doesn't", "didn't"]);
|
||||||
|
|
||||||
|
const content = (review.rvwCtnt || '').toLowerCase();
|
||||||
|
if (!content) return false;
|
||||||
|
|
||||||
|
const tokens = content.replace(/[^a-z0-9\s'-]/g, ' ').split(/\s+/).filter(Boolean);
|
||||||
|
|
||||||
|
const hasWordWithoutNegation = (wordSet) => {
|
||||||
|
return tokens.some((token, index) => {
|
||||||
|
if (!wordSet.has(token)) return false;
|
||||||
|
|
||||||
|
const prevTokens = tokens.slice(Math.max(0, index - 3), index);
|
||||||
|
return !prevTokens.some(prevToken => negationWords.has(prevToken));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sentiment === 'positive') {
|
||||||
|
return hasWordWithoutNegation(positiveWords);
|
||||||
|
}
|
||||||
|
if (sentiment === 'negative') {
|
||||||
|
return hasWordWithoutNegation(negativeWords);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 필터 카운트 계산 (전체 리뷰 데이터 기반)
|
||||||
|
const filterCounts = useMemo(() => {
|
||||||
|
if (allReviews.length === 0) {
|
||||||
|
return {
|
||||||
|
rating: { all: 0 },
|
||||||
|
keyword: {},
|
||||||
|
sentiment: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
rating: { all: allReviews.length },
|
||||||
|
keyword: {},
|
||||||
|
sentiment: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 별점별 카운트 - 소수점 별점을 정수로 반올림하여 카운팅
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
counts.rating[i] = allReviews.filter(review => {
|
||||||
|
const rating = review.rvwScr || review.rvwRtng || review.rating || 0;
|
||||||
|
return Math.round(rating) === i;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 키워드별 카운트
|
||||||
|
const keywords = ['aroma', 'vanilla', 'cinnamon', 'quality'];
|
||||||
|
keywords.forEach(keyword => {
|
||||||
|
counts.keyword[keyword] = allReviews.filter(review =>
|
||||||
|
matchesKeyword(review, keyword)
|
||||||
|
).length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 감정별 카운트
|
||||||
|
counts.sentiment.positive = allReviews.filter(review =>
|
||||||
|
matchesSentiment(review, 'positive')
|
||||||
|
).length;
|
||||||
|
counts.sentiment.negative = allReviews.filter(review =>
|
||||||
|
matchesSentiment(review, 'negative')
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// 디버깅: filterCounts 계산 결과 확인
|
||||||
|
console.log('[useReviews] filterCounts 계산 완료:', {
|
||||||
|
totalReviews: allReviews.length,
|
||||||
|
ratingCounts: counts.rating,
|
||||||
|
keywordCounts: counts.keyword,
|
||||||
|
sentimentCounts: counts.sentiment,
|
||||||
|
sampleReviews: allReviews.slice(0, 3).map(review => ({
|
||||||
|
rvwId: review.rvwId,
|
||||||
|
rvwScr: review.rvwScr,
|
||||||
|
rvwRtng: review.rvwRtng,
|
||||||
|
rating: review.rating,
|
||||||
|
roundedRating: Math.round(review.rvwScr || review.rvwRtng || review.rating || 0)
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}, [allReviews, matchesKeyword, matchesSentiment]);
|
||||||
|
|
||||||
|
// 필터링된 리뷰 계산 (Single Filter 구조)
|
||||||
|
const filteredReviews = useMemo(() => {
|
||||||
|
if (allReviews.length === 0) return [];
|
||||||
|
|
||||||
|
// 이전 결과 명시적 해제를 위한 새로운 배열 생성
|
||||||
|
let result = null;
|
||||||
|
|
||||||
|
switch (currentFilter.type) {
|
||||||
|
case 'rating':
|
||||||
|
if (currentFilter.value === 'all' || currentFilter.value === null) {
|
||||||
|
result = [...allReviews]; // 전체 표시
|
||||||
|
} else {
|
||||||
|
result = allReviews.filter(review => {
|
||||||
|
const rating = review.rvwScr || review.rvwRtng || review.rating || 0;
|
||||||
|
return Math.round(rating) === currentFilter.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'sentiment':
|
||||||
|
result = allReviews.filter(review =>
|
||||||
|
matchesSentiment(review, currentFilter.value)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'keyword':
|
||||||
|
result = allReviews.filter(review =>
|
||||||
|
matchesKeyword(review, currentFilter.value)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
result = [...allReviews];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 불변성 보장 및 메모리 최적화
|
||||||
|
return Object.freeze(result);
|
||||||
|
}, [allReviews, currentFilter.type, currentFilter.value, matchesKeyword, matchesSentiment]);
|
||||||
|
|
||||||
|
// 현재 화면에 표시할 리뷰들 (항상 3개로 고정)
|
||||||
|
const displayReviews = useMemo(() => {
|
||||||
|
return filteredReviews.slice(0, 3);
|
||||||
|
}, [filteredReviews]);
|
||||||
|
|
||||||
|
// 더 로드할 리뷰가 있는지 확인
|
||||||
|
const hasMore = displayReviews.length < filteredReviews.length;
|
||||||
|
|
||||||
|
// 다음 청크 로드 (클라이언트 사이드에서 페이지만 증가)
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (hasMore) {
|
||||||
|
console.log('[useReviews] loadMore: 다음 청크 로드', {
|
||||||
|
currentPage,
|
||||||
|
nextPage: currentPage + 1,
|
||||||
|
currentDisplayCount: displayReviews.length,
|
||||||
|
totalFilteredCount: filteredReviews.length
|
||||||
|
});
|
||||||
|
setCurrentPage(prev => prev + 1);
|
||||||
|
}
|
||||||
|
}, [hasMore, currentPage, displayReviews.length, filteredReviews.length]);
|
||||||
|
|
||||||
|
// Single Filter 적용 함수
|
||||||
|
const applyFilter = useCallback((type, value) => {
|
||||||
|
console.log('[useReviews] 필터 적용:', { type, value });
|
||||||
|
|
||||||
|
// 이전 필터 결과 해제
|
||||||
|
setCurrentFilter({ type, value });
|
||||||
|
setCurrentPage(0); // 필터 변경 시 첫 페이지로
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 편의 함수들 (기존 인터페이스 호환성)
|
||||||
|
const applyRatingFilter = useCallback((rating) => {
|
||||||
|
console.log('[useReviews] applyRatingFilter called with:', rating);
|
||||||
|
applyFilter('rating', rating);
|
||||||
|
}, [applyFilter]);
|
||||||
|
|
||||||
|
const applySentimentFilter = useCallback((sentiment) => {
|
||||||
|
applyFilter('sentiment', sentiment);
|
||||||
|
}, [applyFilter]);
|
||||||
|
|
||||||
|
const applyKeywordFilter = useCallback((keyword) => {
|
||||||
|
applyFilter('keyword', keyword);
|
||||||
|
}, [applyFilter]);
|
||||||
|
|
||||||
|
// 필터 초기화 함수
|
||||||
|
const clearAllFilters = useCallback(() => {
|
||||||
|
console.log('[useReviews] 모든 필터 초기화');
|
||||||
|
applyFilter('rating', 'all');
|
||||||
|
}, [applyFilter]);
|
||||||
|
|
||||||
|
// 이미지가 있는 리뷰들만 추려내는 함수
|
||||||
|
const getReviewsWithImages = useMemo(() => {
|
||||||
|
const reviewsWithImages = allReviews.filter(
|
||||||
|
(review) => review.reviewImageList &&
|
||||||
|
Array.isArray(review.reviewImageList) &&
|
||||||
|
review.reviewImageList.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('[useReviews] 이미지가 있는 리뷰 필터링:', {
|
||||||
|
totalReviews: allReviews.length,
|
||||||
|
reviewsWithImages: reviewsWithImages.length,
|
||||||
|
imageReviews: reviewsWithImages.slice(0, 3) // 처음 3개만 로그
|
||||||
|
});
|
||||||
|
|
||||||
|
return reviewsWithImages;
|
||||||
|
}, [allReviews]);
|
||||||
|
|
||||||
|
// 이미지 데이터 추출 함수 - CustomerImages에서 사용할 수 있도록
|
||||||
|
const extractImagesFromReviews = useMemo(() => {
|
||||||
|
const images = [];
|
||||||
|
|
||||||
|
getReviewsWithImages.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,
|
||||||
|
reviewData: review // 전체 리뷰 데이터도 포함
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[useReviews] 이미지 데이터 추출 완료:', {
|
||||||
|
totalImages: images.length,
|
||||||
|
sampleImages: images.slice(0, 3)
|
||||||
|
});
|
||||||
|
|
||||||
|
return images;
|
||||||
|
}, [getReviewsWithImages]);
|
||||||
|
|
||||||
|
// DetailPanel용 - 처음 5개만 가져오기 (필터링 안된 원본에서)
|
||||||
|
const previewReviews = useMemo(() => {
|
||||||
|
console.log('[useReviews] previewReviews 계산:', {
|
||||||
|
allReviewsLength: allReviews.length,
|
||||||
|
previewCount: Math.min(allReviews.length, 5),
|
||||||
|
prdtId,
|
||||||
|
hasLoadedData,
|
||||||
|
isLoading,
|
||||||
|
reviewData,
|
||||||
|
allReviews: allReviews.slice(0, 3)
|
||||||
|
});
|
||||||
|
return allReviews.slice(0, 5);
|
||||||
|
}, [allReviews, prdtId, hasLoadedData, isLoading, reviewData]);
|
||||||
|
|
||||||
|
// 통계 정보
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
return {
|
||||||
|
totalReviews: allReviews.length, // 전체 리뷰 개수
|
||||||
|
filteredCount: filteredReviews.length, // 필터링된 리뷰 개수
|
||||||
|
displayedCount: displayReviews.length, // 현재 표시 중인 리뷰 개수
|
||||||
|
averageRating: (reviewDetail && reviewDetail.totRvwAvg) ? reviewDetail.totRvwAvg :
|
||||||
|
(reviewDetail && reviewDetail.avgRvwScr) ? reviewDetail.avgRvwScr : 0,
|
||||||
|
totalRatingCount: (reviewDetail && reviewDetail.totRvwCnt) ? reviewDetail.totRvwCnt : allReviews.length
|
||||||
|
};
|
||||||
|
}, [allReviews.length, filteredReviews.length, displayReviews.length, reviewDetail]);
|
||||||
|
|
||||||
|
// 데이터 새로고침 - 강제로 다시 로드
|
||||||
|
const refreshData = useCallback(() => {
|
||||||
|
console.log('[useReviews] 데이터 새로고침 시작');
|
||||||
|
setHasLoadedData(false); // 강제로 다시 로드하도록
|
||||||
|
setCurrentPage(0);
|
||||||
|
setCurrentFilter({ type: 'rating', value: 'all' }); // 기본 필터로 초기화
|
||||||
|
loadReviews();
|
||||||
|
}, [loadReviews]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 🔥 핵심 API 함수 - useReviews가 모든 API 호출 담당
|
||||||
|
loadReviews, // 리뷰 데이터 로드 (prdtId 기반)
|
||||||
|
refreshData, // 데이터 강제 새로고침
|
||||||
|
|
||||||
|
// 📊 리뷰 데이터
|
||||||
|
displayReviews, // 현재 화면에 표시할 리뷰들 (청킹된)
|
||||||
|
previewReviews, // DetailPanel용 미리보기 리뷰 (첫 5개)
|
||||||
|
allReviews, // 전체 원본 리뷰 (필터링 안된, 빈 내용 제외)
|
||||||
|
filteredReviews, // 필터링된 전체 리뷰
|
||||||
|
hasReviews: allReviews.length > 0, // 리뷰 존재 여부 (DetailPanel 조건부 렌더링용)
|
||||||
|
|
||||||
|
// 🖼️ 이미지 관련 데이터 - CustomerImages 전용
|
||||||
|
getReviewsWithImages, // 이미지가 있는 리뷰들만 필터링
|
||||||
|
extractImagesFromReviews, // 이미지 데이터만 추출 (reviewData 포함)
|
||||||
|
|
||||||
|
// 📄 클라이언트 사이드 페이지네이션
|
||||||
|
hasMore, // 더 로드할 리뷰가 있는지
|
||||||
|
loadMore, // 다음 청크 표시 (클라이언트에서 슬라이싱)
|
||||||
|
currentPage, // 현재 페이지 (0부터 시작)
|
||||||
|
|
||||||
|
// 🔍 필터링 시스템
|
||||||
|
currentFilter, // 현재 활성화된 필터 { type, value }
|
||||||
|
filterCounts, // 각 필터별 리뷰 개수 (실시간 계산)
|
||||||
|
applyFilter, // 통합 필터 적용 함수
|
||||||
|
applyRatingFilter, // 별점 필터 적용
|
||||||
|
applyKeywordFilter, // 키워드 필터 적용
|
||||||
|
applySentimentFilter, // 감정 필터 적용
|
||||||
|
clearAllFilters, // 모든 필터 초기화
|
||||||
|
|
||||||
|
// ⚡ 상태 관리
|
||||||
|
isLoading, // API 로딩 상태
|
||||||
|
hasLoadedData, // 데이터 로드 완료 여부
|
||||||
|
stats, // 통계 정보
|
||||||
|
|
||||||
|
// 🐛 디버그 정보
|
||||||
|
_debug: {
|
||||||
|
prdtId,
|
||||||
|
allReviews: allReviews.slice(0, 3), // 처음 3개만
|
||||||
|
currentFilter,
|
||||||
|
filteredCount: filteredReviews.length,
|
||||||
|
displayedCount: displayReviews.length,
|
||||||
|
currentPage,
|
||||||
|
hasMore,
|
||||||
|
isLoading,
|
||||||
|
hasLoadedData
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useReviews;
|
||||||
@@ -7,6 +7,7 @@ const initialState = {
|
|||||||
prdtOptInfo: {},
|
prdtOptInfo: {},
|
||||||
reviewData: null, // 리뷰 데이터 추가
|
reviewData: null, // 리뷰 데이터 추가
|
||||||
showAllReviews: false, // 전체 리뷰 보기 상태
|
showAllReviews: false, // 전체 리뷰 보기 상태
|
||||||
|
loadedPrdtId: null, // 현재 로드된 상품 ID
|
||||||
};
|
};
|
||||||
|
|
||||||
// FP: handlers map (curried), pure and immutable updates only
|
// FP: handlers map (curried), pure and immutable updates only
|
||||||
@@ -43,21 +44,32 @@ const handleProductOptionId = curry((state, action) =>
|
|||||||
// 유저 리뷰 데이터 핸들러 추가
|
// 유저 리뷰 데이터 핸들러 추가
|
||||||
const handleUserReview = curry((state, action) => {
|
const handleUserReview = curry((state, action) => {
|
||||||
const reviewData = get("payload", action);
|
const reviewData = get("payload", action);
|
||||||
|
const prdtId = get(["payload", "prdtId"], action);
|
||||||
|
|
||||||
console.log("[UserReviews] Reducer - Storing review data:", {
|
console.log("[UserReviews] Reducer - Storing review data:", {
|
||||||
|
prdtId: prdtId,
|
||||||
hasData: !!reviewData,
|
hasData: !!reviewData,
|
||||||
reviewListLength: reviewData && reviewData.reviewList ? reviewData.reviewList.length : 0,
|
reviewListLength: reviewData && reviewData.reviewList ? reviewData.reviewList.length : 0,
|
||||||
totalCount: reviewData && reviewData.reviewDetail ? reviewData.reviewDetail.totRvwCnt : 0
|
totalCount: reviewData && reviewData.reviewDetail ? reviewData.reviewDetail.totRvwCnt : 0
|
||||||
});
|
});
|
||||||
return set("reviewData", reviewData, state);
|
|
||||||
|
return set("reviewData", reviewData,
|
||||||
|
set("loadedPrdtId", prdtId, state));
|
||||||
});
|
});
|
||||||
|
|
||||||
// showAllReviews 토글 핸들러
|
// showAllReviews 토글 핸들러
|
||||||
const handleToggleShowAllReviews = curry((state, action) => {
|
const handleToggleShowAllReviews = curry((state, action) => {
|
||||||
const currentValue = get("showAllReviews", state);
|
const currentValue = get("showAllReviews", state);
|
||||||
console.log("[UserReviews] Toggle showAllReviews:", !currentValue);
|
// console.log("[UserReviews] Toggle showAllReviews:", !currentValue);
|
||||||
return set("showAllReviews", !currentValue, state);
|
return set("showAllReviews", !currentValue, state);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// showAllReviews 초기화 핸들러
|
||||||
|
const handleResetShowAllReviews = curry((state, action) => {
|
||||||
|
// console.log("[UserReviews] Reset showAllReviews to false");
|
||||||
|
return set("showAllReviews", false, state);
|
||||||
|
});
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
[types.GET_BEST_SELLER]: handleBestSeller,
|
[types.GET_BEST_SELLER]: handleBestSeller,
|
||||||
[types.GET_PRODUCT_OPTION]: handleProductOption,
|
[types.GET_PRODUCT_OPTION]: handleProductOption,
|
||||||
@@ -68,6 +80,7 @@ const handlers = {
|
|||||||
[types.GET_PRODUCT_OPTION_ID]: handleProductOptionId,
|
[types.GET_PRODUCT_OPTION_ID]: handleProductOptionId,
|
||||||
[types.GET_USER_REVIEW]: handleUserReview, // GET_USER_REVIEW 핸들러 추가
|
[types.GET_USER_REVIEW]: handleUserReview, // GET_USER_REVIEW 핸들러 추가
|
||||||
[types.TOGGLE_SHOW_ALL_REVIEWS]: handleToggleShowAllReviews, // showAllReviews 토글 핸들러
|
[types.TOGGLE_SHOW_ALL_REVIEWS]: handleToggleShowAllReviews, // showAllReviews 토글 핸들러
|
||||||
|
[types.RESET_SHOW_ALL_REVIEWS]: handleResetShowAllReviews, // showAllReviews 초기화 핸들러
|
||||||
};
|
};
|
||||||
|
|
||||||
export const productReducer = (state = initialState, action = {}) => {
|
export const productReducer = (state = initialState, action = {}) => {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export const panel_names = {
|
|||||||
// debug
|
// debug
|
||||||
DEBUG_PANEL: "debugpanel",
|
DEBUG_PANEL: "debugpanel",
|
||||||
VIDEO_TEST_PANEL: "videotestpanel",
|
VIDEO_TEST_PANEL: "videotestpanel",
|
||||||
|
|
||||||
|
// user review
|
||||||
|
USER_REVIEW_PANEL: "userreviewpanel",
|
||||||
};
|
};
|
||||||
|
|
||||||
//button
|
//button
|
||||||
|
|||||||
129
com.twin.app.shoptime/src/utils/fpHelpers.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* fp.safeGet 사용을 위한 헬퍼 함수들
|
||||||
|
* Chromium 68 호환성 및 사용 편의성을 위한 래퍼
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fp from './fp';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redux state에서 안전하게 값을 가져오는 헬퍼
|
||||||
|
* fp.safeGet의 커링 특성을 고려한 편의 함수
|
||||||
|
*
|
||||||
|
* @param {string} path - 점 표기법 경로 (예: 'product.reviewData')
|
||||||
|
* @param {Object} state - Redux state 객체
|
||||||
|
* @param {*} defaultValue - 기본값 (선택사항, 기본: {})
|
||||||
|
* @returns {*} 경로의 값 또는 기본값
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Redux state에서 사용
|
||||||
|
* const reviewData = getFromState('product.reviewData', state);
|
||||||
|
* const reviewList = getFromState('product.reviewData.reviewList', state, []);
|
||||||
|
*/
|
||||||
|
export const getFromState = (path, state, defaultValue = {}) => {
|
||||||
|
// fp.safeGet은 (path, defaultValue, obj) 순서의 커링 함수
|
||||||
|
return fp.safeGet(path, defaultValue, state);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중첩된 객체에서 안전하게 값을 가져오는 헬퍼
|
||||||
|
*
|
||||||
|
* @param {Object} obj - 대상 객체
|
||||||
|
* @param {string} path - 점 표기법 경로
|
||||||
|
* @param {*} defaultValue - 기본값 (선택사항)
|
||||||
|
* @returns {*} 경로의 값 또는 기본값
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const user = { profile: { name: 'John' } };
|
||||||
|
* const name = safeAccess(user, 'profile.name'); // 'John'
|
||||||
|
* const age = safeAccess(user, 'profile.age', 0); // 0
|
||||||
|
*/
|
||||||
|
export const safeAccess = (obj, path, defaultValue = null) => {
|
||||||
|
return fp.safeGet(path, defaultValue, obj);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redux selector에서 사용하기 위한 커링된 헬퍼
|
||||||
|
*
|
||||||
|
* @param {string} path - 점 표기법 경로
|
||||||
|
* @param {*} defaultValue - 기본값
|
||||||
|
* @returns {Function} state를 받는 함수
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // selector 정의
|
||||||
|
* const selectReviewData = createStateSelector('product.reviewData', {});
|
||||||
|
*
|
||||||
|
* // 컴포넌트에서 사용
|
||||||
|
* const reviewData = useSelector(selectReviewData);
|
||||||
|
*/
|
||||||
|
export const createStateSelector = (path, defaultValue = null) => {
|
||||||
|
return (state) => fp.safeGet(path, defaultValue, state);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 경로에서 값을 가져오는 헬퍼
|
||||||
|
*
|
||||||
|
* @param {Object} state - Redux state 객체
|
||||||
|
* @param {Object} paths - 경로와 기본값을 담은 객체
|
||||||
|
* @returns {Object} 추출된 값들을 담은 객체
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const data = getMultipleFromState(state, {
|
||||||
|
* reviewData: { path: 'product.reviewData', default: {} },
|
||||||
|
* userInfo: { path: 'user.info', default: null },
|
||||||
|
* settings: { path: 'app.settings', default: {} }
|
||||||
|
* });
|
||||||
|
* // { reviewData: {...}, userInfo: {...}, settings: {...} }
|
||||||
|
*/
|
||||||
|
export const getMultipleFromState = (state, paths) => {
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
Object.keys(paths).forEach(key => {
|
||||||
|
const { path, default: defaultValue = null } = paths[key];
|
||||||
|
result[key] = fp.safeGet(path, defaultValue, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배열 인덱스를 포함한 경로 처리
|
||||||
|
*
|
||||||
|
* @param {Object} obj - 대상 객체
|
||||||
|
* @param {string} path - 배열 인덱스를 포함한 경로
|
||||||
|
* @param {*} defaultValue - 기본값
|
||||||
|
* @returns {*} 경로의 값 또는 기본값
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const data = { items: [{ name: 'First' }, { name: 'Second' }] };
|
||||||
|
* const firstName = getWithArrayIndex(data, 'items.0.name'); // 'First'
|
||||||
|
* const thirdName = getWithArrayIndex(data, 'items.2.name', 'Unknown'); // 'Unknown'
|
||||||
|
*/
|
||||||
|
export const getWithArrayIndex = (obj, path, defaultValue = null) => {
|
||||||
|
// 배열 인덱스를 [] 표기법으로 변환
|
||||||
|
const convertedPath = path.replace(/\.(\d+)/g, '[$1]');
|
||||||
|
return fp.safeGet(convertedPath, defaultValue, obj);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디버깅을 위한 safeGet with 로깅
|
||||||
|
*
|
||||||
|
* @param {string} label - 디버그 라벨
|
||||||
|
* @param {string} path - 점 표기법 경로
|
||||||
|
* @param {Object} obj - 대상 객체
|
||||||
|
* @param {*} defaultValue - 기본값
|
||||||
|
* @returns {*} 경로의 값 또는 기본값
|
||||||
|
*/
|
||||||
|
export const debugSafeGet = (label, path, obj, defaultValue = null) => {
|
||||||
|
const result = fp.safeGet(path, defaultValue, obj);
|
||||||
|
console.log(`[${label}] safeGet('${path}'):`, result);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getFromState,
|
||||||
|
safeAccess,
|
||||||
|
createStateSelector,
|
||||||
|
getMultipleFromState,
|
||||||
|
getWithArrayIndex,
|
||||||
|
debugSafeGet
|
||||||
|
};
|
||||||
@@ -167,7 +167,9 @@ const forEachAsync = fp.curry(async (cb, collection) => {
|
|||||||
const loopResults = [];
|
const loopResults = [];
|
||||||
const iterator = fp.entries(collection);
|
const iterator = fp.entries(collection);
|
||||||
|
|
||||||
for (const e of iterator) {
|
// Chromium 68 호환성: for...of 대신 일반 for 루프 사용
|
||||||
|
for (let i = 0; i < iterator.length; i++) {
|
||||||
|
const e = iterator[i];
|
||||||
loopResults.push(await cb(e[1], e[0]));
|
loopResults.push(await cb(e[1], e[0]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,8 +480,10 @@ const tryCatch = fp.curry((tryFn, catchFn, value) => {
|
|||||||
*/
|
*/
|
||||||
const safeGet = fp.curry((path, defaultValue, obj) => {
|
const safeGet = fp.curry((path, defaultValue, obj) => {
|
||||||
try {
|
try {
|
||||||
return fp.get(path, obj) ?? defaultValue;
|
const result = fp.get(path, obj);
|
||||||
} catch {
|
// Chromium 68 호환성: ?? 연산자 대신 일반 조건문 사용
|
||||||
|
return result !== null && result !== undefined ? result : defaultValue;
|
||||||
|
} catch (error) {
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -491,7 +495,7 @@ const safeGet = fp.curry((path, defaultValue, obj) => {
|
|||||||
* @param {Array} array 대상 배열
|
* @param {Array} array 대상 배열
|
||||||
*/
|
*/
|
||||||
const mapWhen = fp.curry((predicate, fn, array) =>
|
const mapWhen = fp.curry((predicate, fn, array) =>
|
||||||
array.map(item => predicate(item) ? fn(item) : item)
|
array.map(function(item) { return predicate(item) ? fn(item) : item; })
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -524,11 +528,10 @@ const renameKeys = fp.curry((keyMap, obj) =>
|
|||||||
* @param {*} item 삽입할 요소
|
* @param {*} item 삽입할 요소
|
||||||
* @param {Array} array 대상 배열
|
* @param {Array} array 대상 배열
|
||||||
*/
|
*/
|
||||||
const insertAt = fp.curry((index, item, array) => [
|
const insertAt = fp.curry((index, item, array) => {
|
||||||
...array.slice(0, index),
|
// Chromium 68 호환성: spread 연산자 대신 concat 사용
|
||||||
item,
|
return array.slice(0, index).concat([item]).concat(array.slice(index));
|
||||||
...array.slice(index)
|
});
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 값을 첫 번째 인자로 받는 applicative
|
* 값을 첫 번째 인자로 받는 applicative
|
||||||
@@ -542,7 +545,7 @@ const applyTo = fp.curry((value, fn) => fn(value));
|
|||||||
* @param {Array} fns 함수 배열
|
* @param {Array} fns 함수 배열
|
||||||
* @param {*} value 대상 값
|
* @param {*} value 대상 값
|
||||||
*/
|
*/
|
||||||
const juxt = fp.curry((fns, value) => fns.map(fn => fn(value)));
|
const juxt = fp.curry((fns, value) => fns.map(function(fn) { return fn(value); }));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 여러 함수의 결과를 converge 함수로 조합
|
* 여러 함수의 결과를 converge 함수로 조합
|
||||||
@@ -550,9 +553,11 @@ const juxt = fp.curry((fns, value) => fns.map(fn => fn(value)));
|
|||||||
* @param {Array} fns 함수 배열
|
* @param {Array} fns 함수 배열
|
||||||
* @param {*} value 대상 값
|
* @param {*} value 대상 값
|
||||||
*/
|
*/
|
||||||
const converge = fp.curry((convergeFn, fns, value) =>
|
const converge = fp.curry((convergeFn, fns, value) => {
|
||||||
convergeFn(...fns.map(fn => fn(value)))
|
// Chromium 68 호환성: spread 연산자 대신 apply 사용
|
||||||
);
|
const results = fns.map(function(fn) { return fn(value); });
|
||||||
|
return convergeFn.apply(null, results);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 문자열을 trim하고 빈 문자열이면 undefined 반환
|
* 문자열을 trim하고 빈 문자열이면 undefined 반환
|
||||||
@@ -567,8 +572,10 @@ const trimToUndefined = (str) => {
|
|||||||
* 첫 글자 대문자, 나머지 소문자
|
* 첫 글자 대문자, 나머지 소문자
|
||||||
* @param {string} str 대상 문자열
|
* @param {string} str 대상 문자열
|
||||||
*/
|
*/
|
||||||
const capitalize = (str) =>
|
const capitalize = function(str) {
|
||||||
str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
if (!str || typeof str !== 'string') return str;
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 값을 min과 max 사이로 제한
|
* 값을 min과 max 사이로 제한
|
||||||
@@ -615,7 +622,7 @@ const elvis = fp.curry((fn, value) =>
|
|||||||
*/
|
*/
|
||||||
const partition = fp.curry((predicate, array) => [
|
const partition = fp.curry((predicate, array) => [
|
||||||
array.filter(predicate),
|
array.filter(predicate),
|
||||||
array.filter(item => !predicate(item))
|
array.filter(function(item) { return !predicate(item); })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -623,9 +630,14 @@ const partition = fp.curry((predicate, array) => [
|
|||||||
* @param {Function} fn 실행할 함수
|
* @param {Function} fn 실행할 함수
|
||||||
* @param {number} n 실행 횟수
|
* @param {number} n 실행 횟수
|
||||||
*/
|
*/
|
||||||
const times = fp.curry((fn, n) =>
|
const times = fp.curry((fn, n) => {
|
||||||
Array.from({ length: n }, (_, i) => fn(i))
|
// Chromium 68 호환성: Array.from 대신 일반 루프 사용
|
||||||
);
|
const result = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
result.push(fn(i));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 지연 평가 함수 (memoization과 유사)
|
* 지연 평가 함수 (memoization과 유사)
|
||||||
@@ -634,9 +646,10 @@ const times = fp.curry((fn, n) =>
|
|||||||
const lazy = (fn) => {
|
const lazy = (fn) => {
|
||||||
let cached = false;
|
let cached = false;
|
||||||
let result;
|
let result;
|
||||||
return (...args) => {
|
// Chromium 68 호환성: spread 연산자 대신 arguments 사용
|
||||||
|
return function() {
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
result = fn(...args);
|
result = fn.apply(this, arguments);
|
||||||
cached = true;
|
cached = true;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import css from "./DetailPanel.module.less";
|
|||||||
import ProductAllSection from "./ProductAllSection/ProductAllSection";
|
import ProductAllSection from "./ProductAllSection/ProductAllSection";
|
||||||
import { getThemeCurationDetailInfo } from "../../actions/homeActions";
|
import { getThemeCurationDetailInfo } from "../../actions/homeActions";
|
||||||
import indicatorDefaultImage from "../../../assets/images/img-thumb-empty-144@3x.png";
|
import indicatorDefaultImage from "../../../assets/images/img-thumb-empty-144@3x.png";
|
||||||
|
import detailPanelBg from "../../../assets/images/detailpanel/detailpanel-bg-1.png";
|
||||||
import ThemeItemListOverlay from "./ThemeItemListOverlay/ThemeItemListOverlay";
|
import ThemeItemListOverlay from "./ThemeItemListOverlay/ThemeItemListOverlay";
|
||||||
import Spinner from "@enact/sandstone/Spinner";
|
import Spinner from "@enact/sandstone/Spinner";
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
|
|||||||
|
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
// FP 파생 값 메모이제이션 (optional chaining 대체 및 deps 안정화)
|
|
||||||
const panelType = useMemo(() => fp.pipe(() => panelInfo, fp.get('type'))(), [panelInfo]);
|
const panelType = useMemo(() => fp.pipe(() => panelInfo, fp.get('type'))(), [panelInfo]);
|
||||||
const panelCurationId = useMemo(() => fp.pipe(() => panelInfo, fp.get('curationId'))(), [panelInfo]);
|
const panelCurationId = useMemo(() => fp.pipe(() => panelInfo, fp.get('curationId'))(), [panelInfo]);
|
||||||
const panelPatnrId = useMemo(() => fp.pipe(() => panelInfo, fp.get('patnrId'))(), [panelInfo]);
|
const panelPatnrId = useMemo(() => fp.pipe(() => panelInfo, fp.get('patnrId'))(), [panelInfo]);
|
||||||
@@ -83,7 +84,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
|
|||||||
const productPmtSuptYn = useMemo(() => fp.pipe(() => productData, fp.get('pmtSuptYn'))(), [productData]);
|
const productPmtSuptYn = useMemo(() => fp.pipe(() => productData, fp.get('pmtSuptYn'))(), [productData]);
|
||||||
const productGrPrdtProcYn = useMemo(() => fp.pipe(() => productData, fp.get('grPrdtProcYn'))(), [productData]);
|
const productGrPrdtProcYn = useMemo(() => fp.pipe(() => productData, fp.get('grPrdtProcYn'))(), [productData]);
|
||||||
|
|
||||||
// FP 방식으로 데이터 소스 결정 (메모이제이션 최적화)
|
|
||||||
const productDataSource = useMemo(() =>
|
const productDataSource = useMemo(() =>
|
||||||
fp.pipe(
|
fp.pipe(
|
||||||
() => panelType,
|
() => panelType,
|
||||||
@@ -194,6 +195,22 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
|
|||||||
[scrollToSection],
|
[scrollToSection],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ===== 고정 배경 이미지 설정 (detailPanelBg만 사용) =====
|
||||||
|
// 모든 DetailPanel에서 동일한 배경 이미지(detailpanel-bg-1.png) 사용
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[PartnerId] Partner background info:', {
|
||||||
|
panelPatnrId: panelPatnrId,
|
||||||
|
productDataPatnrId: productData?.patnrId,
|
||||||
|
productDataPatncNm: productData?.patncNm,
|
||||||
|
productDataThumbnailUrl960: productData?.thumbnailUrl960,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
detailPanelBg: detailPanelBg
|
||||||
|
});
|
||||||
|
|
||||||
|
// 고정 배경 이미지만 설정 (파트너사별 변경 없이)
|
||||||
|
document.documentElement.style.setProperty('--bg-url', `url(${detailPanelBg})`);
|
||||||
|
}, [panelPatnrId, productData, imageUrl]);
|
||||||
|
|
||||||
// FP 방식으로 pending scroll 처리 (메모리 누수 방지)
|
// FP 방식으로 pending scroll 처리 (메모리 누수 방지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const shouldExecutePendingScroll = fp.pipe(
|
const shouldExecutePendingScroll = fp.pipe(
|
||||||
@@ -547,7 +564,13 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
|
|||||||
: ""
|
: ""
|
||||||
)(), [panelPrdtId, productData]);
|
)(), [panelPrdtId, productData]);
|
||||||
|
|
||||||
|
// ===== 파트너사별 배경 이미지 설정 로직 (현재 비활성화) =====
|
||||||
|
// thumbnailUrl960을 사용하여 파트너사별로 다른 배경 이미지를 설정하는 기능
|
||||||
|
// Pink Pong 등 특정 파트너사에서만 thumbnailUrl960 데이터가 있어서 배경이 변경됨
|
||||||
|
// 현재는 고정 배경(detailPanelBg)만 사용하기 위해 주석 처리
|
||||||
|
|
||||||
// FP 방식으로 배경 이미지 설정 (메모리 누수 방지)
|
// FP 방식으로 배경 이미지 설정 (메모리 누수 방지)
|
||||||
|
/*
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const shouldSetBackground = fp.pipe(
|
const shouldSetBackground = fp.pipe(
|
||||||
() => ({ imageUrl, containerRef }),
|
() => ({ imageUrl, containerRef }),
|
||||||
@@ -559,6 +582,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
|
|||||||
containerRef.current.style.setProperty("--bg-url", `url('${imageUrl}')`);
|
containerRef.current.style.setProperty("--bg-url", `url('${imageUrl}')`);
|
||||||
}
|
}
|
||||||
}, [imageUrl]);
|
}, [imageUrl]);
|
||||||
|
*/
|
||||||
|
|
||||||
console.log("productDataSource :", productDataSource);
|
console.log("productDataSource :", productDataSource);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
rgba(0, 0, 0, 0.7) 100%
|
rgba(0, 0, 0, 0.7) 100%
|
||||||
),
|
),
|
||||||
linear-gradient(0deg, rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.4)),
|
linear-gradient(0deg, rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.4)),
|
||||||
url("../../../assets/images/default_bg_image.png");
|
var(--bg-url);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
|||||||
@@ -1,75 +1,54 @@
|
|||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { PropTypes } from 'prop-types';
|
|
||||||
import {
|
|
||||||
useDispatch,
|
|
||||||
useSelector,
|
|
||||||
} from 'react-redux';
|
|
||||||
|
|
||||||
import Spotlight from '@enact/spotlight';
|
|
||||||
/* eslint-disable react/jsx-no-bind */
|
/* eslint-disable react/jsx-no-bind */
|
||||||
// src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
|
// src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
|
||||||
import SpotlightContainerDecorator
|
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
||||||
from '@enact/spotlight/SpotlightContainerDecorator';
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
import Spottable from '@enact/spotlight/Spottable';
|
import React, { useCallback, useRef, useState, useMemo, useEffect } from "react";
|
||||||
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
|
import Spotlight from "@enact/spotlight";
|
||||||
|
import { PropTypes } from "prop-types";
|
||||||
|
|
||||||
import { toggleShowAllReviews } from '../../../actions/productActions';
|
|
||||||
// ProductInfoSection imports
|
// ProductInfoSection imports
|
||||||
import TButton from '../../../components/TButton/TButton';
|
import TButton from "../../../components/TButton/TButton";
|
||||||
import useScrollTo from '../../../hooks/useScrollTo';
|
import { $L } from "../../../utils/helperMethods";
|
||||||
import {
|
import {
|
||||||
andThen,
|
curry, pipe, when, isVal, isNotNil, defaultTo, defaultWith, get, identity, isEmpty, isNil, andThen, tap
|
||||||
curry,
|
} from "../../../utils/fp";
|
||||||
defaultTo,
|
import { resetShowAllReviews } from "../../../actions/productActions";
|
||||||
defaultWith,
|
import useReviews from "../../../hooks/useReviews/useReviews";
|
||||||
get,
|
import { pushPanel } from "../../../actions/panelActions";
|
||||||
identity,
|
import { panel_names } from "../../../utils/Config";
|
||||||
isEmpty,
|
import ViewAllReviewsButton from "../ProductContentSection/UserReviews/ViewAllReviewsButton";
|
||||||
isNil,
|
import FavoriteBtn from "../components/FavoriteBtn";
|
||||||
isNotNil,
|
import StarRating from "../components/StarRating";
|
||||||
isVal,
|
import ProductTag from "../components/ProductTag";
|
||||||
pipe,
|
import DetailMobileSendPopUp from "../components/DetailMobileSendPopUp";
|
||||||
tap,
|
import { SpotlightIds } from "../../../utils/SpotlightIds";
|
||||||
when,
|
import QRCode from "../ProductInfoSection/QRCode/QRCode";
|
||||||
} from '../../../utils/fp';
|
import ProductOverview from "../ProductOverview/ProductOverview";
|
||||||
import { $L } from '../../../utils/helperMethods';
|
|
||||||
import { SpotlightIds } from '../../../utils/SpotlightIds';
|
|
||||||
import CustomScrollbar from '../components/CustomScrollbar/CustomScrollbar';
|
|
||||||
import DetailMobileSendPopUp from '../components/DetailMobileSendPopUp';
|
|
||||||
import FavoriteBtn from '../components/FavoriteBtn';
|
|
||||||
import ProductTag from '../components/ProductTag';
|
|
||||||
import StarRating from '../components/StarRating';
|
|
||||||
// ProductContentSection imports
|
// ProductContentSection imports
|
||||||
import TScrollerDetail from '../components/TScroller/TScrollerDetail';
|
import TScrollerDetail from "../components/TScroller/TScrollerDetail";
|
||||||
import ProductDescription
|
import CustomScrollbar from "../components/CustomScrollbar/CustomScrollbar";
|
||||||
from '../ProductContentSection/ProductDescription/ProductDescription';
|
import useScrollTo from "../../../hooks/useScrollTo";
|
||||||
import ProductDetail
|
import ProductDetail from "../ProductContentSection/ProductDetail/ProductDetail.new";
|
||||||
from '../ProductContentSection/ProductDetail/ProductDetail.new';
|
import UserReviews from "../ProductContentSection/UserReviews/UserReviews";
|
||||||
import UserReviews from '../ProductContentSection/UserReviews/UserReviews';
|
import YouMayAlsoLike from "../ProductContentSection/YouMayAlsoLike/YouMayAlsoLike";
|
||||||
import YouMayAlsoLike
|
import ProductDescription from "../ProductContentSection/ProductDescription/ProductDescription";
|
||||||
from '../ProductContentSection/YouMayAlsoLike/YouMayAlsoLike';
|
import ShowUserReviews from "../../UserReview/ShowUserReviews";
|
||||||
import QRCode from '../ProductInfoSection/QRCode/QRCode';
|
|
||||||
import ProductOverview from '../ProductOverview/ProductOverview';
|
|
||||||
// CSS imports
|
// CSS imports
|
||||||
// import infoCSS from "../ProductInfoSection/ProductInfoSection.module.less";
|
// import infoCSS from "../ProductInfoSection/ProductInfoSection.module.less";
|
||||||
// import contentCSS from "../ProductContentSection/ProductContentSection.module.less";
|
// import contentCSS from "../ProductContentSection/ProductContentSection.module.less";
|
||||||
import css from './ProductAllSection.module.less';
|
import css from "./ProductAllSection.module.less";
|
||||||
|
|
||||||
const Container = SpotlightContainerDecorator(
|
const Container = SpotlightContainerDecorator(
|
||||||
{
|
{
|
||||||
enterTo: "last-focused",
|
enterTo: "last-focused",
|
||||||
preserveld: true,
|
preserveld: true,
|
||||||
leaveFor: { right: "content-scroller-container" },
|
leaveFor: { right: "content-scroller-container" },
|
||||||
spotlightDirection: "vertical",
|
spotlightDirection: "vertical"
|
||||||
},
|
},
|
||||||
"div"
|
"div",
|
||||||
);
|
);
|
||||||
|
|
||||||
const ContentContainer = SpotlightContainerDecorator(
|
const ContentContainer = SpotlightContainerDecorator(
|
||||||
@@ -77,12 +56,12 @@ const ContentContainer = SpotlightContainerDecorator(
|
|||||||
enterTo: "default-element",
|
enterTo: "default-element",
|
||||||
preserveld: true,
|
preserveld: true,
|
||||||
leaveFor: {
|
leaveFor: {
|
||||||
left: "spotlight-product-info-section-container",
|
left: "spotlight-product-info-section-container"
|
||||||
},
|
},
|
||||||
restrict: "none",
|
restrict: "none",
|
||||||
spotlightDirection: "vertical",
|
spotlightDirection: "vertical"
|
||||||
},
|
},
|
||||||
"div"
|
"div",
|
||||||
);
|
);
|
||||||
|
|
||||||
const HorizontalContainer = SpotlightContainerDecorator(
|
const HorizontalContainer = SpotlightContainerDecorator(
|
||||||
@@ -90,19 +69,17 @@ const HorizontalContainer = SpotlightContainerDecorator(
|
|||||||
enterTo: "last-focused",
|
enterTo: "last-focused",
|
||||||
preserveld: true,
|
preserveld: true,
|
||||||
defaultElement: "spotlight-product-info-section-container",
|
defaultElement: "spotlight-product-info-section-container",
|
||||||
spotlightDirection: "horizontal",
|
spotlightDirection: "horizontal"
|
||||||
},
|
},
|
||||||
"div"
|
"div",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// FP: Pure function to determine product data based on type
|
// FP: Pure function to determine product data based on type
|
||||||
const getProductData = curry((productType, themeProductInfo, productInfo) =>
|
const getProductData = curry((productType, themeProductInfo, productInfo) =>
|
||||||
pipe(
|
pipe(
|
||||||
when(
|
when(
|
||||||
() =>
|
() => isVal(productType) && productType === "theme" && isVal(themeProductInfo),
|
||||||
isVal(productType) &&
|
|
||||||
productType === "theme" &&
|
|
||||||
isVal(themeProductInfo),
|
|
||||||
() => themeProductInfo
|
() => themeProductInfo
|
||||||
),
|
),
|
||||||
defaultTo(productInfo),
|
defaultTo(productInfo),
|
||||||
@@ -114,14 +91,19 @@ const getProductData = curry((productType, themeProductInfo, productInfo) =>
|
|||||||
const deriveFavoriteFlag = curry((favoriteOverride, productData) =>
|
const deriveFavoriteFlag = curry((favoriteOverride, productData) =>
|
||||||
pipe(
|
pipe(
|
||||||
when(isNotNil, identity),
|
when(isNotNil, identity),
|
||||||
defaultWith(() => pipe(get("favorYn"), defaultTo("N"))(productData))
|
defaultWith(() =>
|
||||||
|
pipe(
|
||||||
|
get("favorYn"),
|
||||||
|
defaultTo("N")
|
||||||
|
)(productData)
|
||||||
|
)
|
||||||
)(favoriteOverride)
|
)(favoriteOverride)
|
||||||
);
|
);
|
||||||
|
|
||||||
// FP: Pure function to extract review grade and order phone
|
// FP: Pure function to extract review grade and order phone
|
||||||
const extractProductMeta = (productInfo) => ({
|
const extractProductMeta = (productInfo) => ({
|
||||||
revwGrd: get("revwGrd", productInfo),
|
revwGrd: get("revwGrd", productInfo),
|
||||||
orderPhnNo: get("orderPhnNo", productInfo),
|
orderPhnNo: get("orderPhnNo", productInfo)
|
||||||
});
|
});
|
||||||
|
|
||||||
// 레이아웃 확인용 샘플 컴포넌트 - Spottable로 변경
|
// 레이아웃 확인용 샘플 컴포넌트 - Spottable로 변경
|
||||||
@@ -133,7 +115,7 @@ const LayoutSample = ({ onClick }) => (
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
spotlightId="layout-sample-button"
|
spotlightId="layout-sample-button"
|
||||||
>
|
>
|
||||||
Layout Sample - Click to Show All Reviews (1124px x 300px)
|
Click to Show All Reviews (W-1124px)
|
||||||
</SpottableComponent>
|
</SpottableComponent>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -151,68 +133,100 @@ export default function ProductAllSection({
|
|||||||
}) {
|
}) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const productData = useMemo(
|
const productData = useMemo(() =>
|
||||||
() => getProductData(productType, themeProductInfo, productInfo),
|
getProductData(productType, themeProductInfo, productInfo),
|
||||||
[productType, themeProductInfo, productInfo]
|
[productType, themeProductInfo, productInfo]
|
||||||
);
|
);
|
||||||
|
|
||||||
// [임시 테스트] LayoutSample 클릭 핸들러
|
// useReviews Hook 사용 - 모든 리뷰 관련 로직을 담당
|
||||||
|
const {
|
||||||
|
previewReviews,
|
||||||
|
stats,
|
||||||
|
isLoading: reviewsLoading,
|
||||||
|
hasReviews // 리뷰 존재 여부 플래그 추가
|
||||||
|
} = useReviews(productData.prdtId);
|
||||||
|
|
||||||
|
// YouMayAlsoLike 데이터 확인
|
||||||
|
const youmaylikeProductData = useSelector((state) => state.main.youmaylikeData);
|
||||||
|
const hasYouMayAlsoLike = youmaylikeProductData && youmaylikeProductData.length > 0;
|
||||||
|
|
||||||
|
// ProductAllSection 마운트 시 showAllReviews 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[ProductAllSection] Component mounted - resetting showAllReviews to false");
|
||||||
|
dispatch(resetShowAllReviews());
|
||||||
|
}, []); // 빈 dependency array = 마운트 시에만 실행
|
||||||
|
|
||||||
|
// [임시 테스트] LayoutSample 클릭 핸들러 - ShowUserReviews와 동일하게 UserReviewPanel 열기
|
||||||
const handleLayoutSampleClick = useCallback(() => {
|
const handleLayoutSampleClick = useCallback(() => {
|
||||||
console.log(
|
console.log(`[ProductId] LayoutSample clicked - opening UserReviewPanel`, {
|
||||||
"[Test] LayoutSample clicked - dispatching toggleShowAllReviews"
|
productDataPrdtId: productData && productData.prdtId,
|
||||||
|
hasProductData: !!productData,
|
||||||
|
reviewTotalCount: stats.totalReviews,
|
||||||
|
averageRating: stats.averageRating,
|
||||||
|
productData: productData
|
||||||
|
});
|
||||||
|
dispatch(
|
||||||
|
pushPanel({
|
||||||
|
name: panel_names.USER_REVIEW_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
prdtId: productData.prdtId,
|
||||||
|
productImage: (productData.imgUrls600 && productData.imgUrls600[0]) || (productData.imgUrls && productData.imgUrls[0]) || productData.thumbnailUrl || 'https://placehold.co/150x150',
|
||||||
|
brandLogo: productData.patncLogoPath || 'https://placehold.co/50x50',
|
||||||
|
productName: productData.prdtNm || '상품명 정보가 없습니다',
|
||||||
|
avgRating: stats.averageRating || 5,
|
||||||
|
reviewCount: stats.totalReviews || 0
|
||||||
|
},
|
||||||
|
})
|
||||||
);
|
);
|
||||||
dispatch(toggleShowAllReviews());
|
}, [dispatch, productData, stats]);
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
// 디버깅: 실제 이미지 데이터 확인
|
// 디버깅: 실제 이미지 데이터 확인
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("[ProductAllSection] Image data check:", {
|
console.log("[ProductId] ProductAllSection productData check:", {
|
||||||
hasProductData: !!productData,
|
hasProductData: !!productData,
|
||||||
imgUrls600: productData?.imgUrls600,
|
productDataPrdtId: productData && productData.prdtId,
|
||||||
imgUrls600Length: productData?.imgUrls600?.length,
|
imgUrls600: productData && productData.imgUrls600,
|
||||||
imgUrls600Type: Array.isArray(productData?.imgUrls600)
|
imgUrls600Length: productData && productData.imgUrls600 && productData.imgUrls600.length,
|
||||||
? "array"
|
imgUrls600Type: Array.isArray(productData && productData.imgUrls600) ? 'array' : typeof (productData && productData.imgUrls600),
|
||||||
: typeof productData?.imgUrls600,
|
productData: productData
|
||||||
});
|
});
|
||||||
}, [productData]);
|
}, [productData]);
|
||||||
|
|
||||||
const { revwGrd, orderPhnNo } = useMemo(
|
const { revwGrd, orderPhnNo } = useMemo(() =>
|
||||||
() => extractProductMeta(productInfo),
|
extractProductMeta(productInfo),
|
||||||
[productInfo]
|
[productInfo]
|
||||||
);
|
);
|
||||||
|
|
||||||
// FP: derive favorite flag from props with local override, avoid non-I/O useEffect
|
// FP: derive favorite flag from props with local override, avoid non-I/O useEffect
|
||||||
const [favoriteOverride, setFavoriteOverride] = useState(null);
|
const [favoriteOverride, setFavoriteOverride] = useState(null);
|
||||||
const favoriteFlag = useMemo(
|
const favoriteFlag = useMemo(() =>
|
||||||
() => deriveFavoriteFlag(favoriteOverride, productData),
|
deriveFavoriteFlag(favoriteOverride, productData),
|
||||||
[favoriteOverride, productData]
|
[favoriteOverride, productData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [mobileSendPopupOpen, setMobileSendPopupOpen] = useState(false);
|
const [mobileSendPopupOpen, setMobileSendPopupOpen] = useState(false);
|
||||||
|
|
||||||
// 🔧 [임시] 고객 데모용: UserReviews 버튼은 숨기고 UserReviews 섹션만 표시
|
|
||||||
const showUserReviewsButton = false; // 임시 변경 - 버튼 숨김
|
|
||||||
const showUserReviewsSection = true; // 임시 변경 - 섹션은 항상 표시
|
|
||||||
|
|
||||||
const reviewTotalCount = useSelector(
|
// useReviews에서 모든 리뷰 데이터 관리
|
||||||
pipe(
|
const reviewTotalCount = stats.totalReviews;
|
||||||
get(["product", "reviewData", "reviewDetail", "totRvwCnt"]),
|
const reviewData = { reviewList: previewReviews, reviewDetail: { totRvwCnt: stats.totalReviews, avgRvwScr: stats.averageRating } };
|
||||||
defaultTo(0)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// User Reviews 스크롤 핸들러 추가
|
// User Reviews 스크롤 핸들러 추가
|
||||||
const handleUserReviewsClick = useCallback(
|
const handleUserReviewsClick = useCallback(
|
||||||
() => scrollToSection("scroll-marker-user-reviews"),
|
() => scrollToSection("scroll-marker-user-reviews"),
|
||||||
[scrollToSection]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const scrollContainerRef = useRef(null);
|
const scrollContainerRef = useRef(null);
|
||||||
const { getScrollTo, scrollTop } = useScrollTo();
|
const { getScrollTo, scrollTop } = useScrollTo();
|
||||||
|
|
||||||
|
|
||||||
// FP: Pure function for mobile popup state change
|
// FP: Pure function for mobile popup state change
|
||||||
const handleShopByMobileOpen = useCallback(
|
const handleShopByMobileOpen = useCallback(
|
||||||
pipe(() => true, setMobileSendPopupOpen),
|
pipe(
|
||||||
|
() => true,
|
||||||
|
setMobileSendPopupOpen
|
||||||
|
),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -262,6 +276,7 @@ export default function ProductAllSection({
|
|||||||
[scrollToSection]
|
[scrollToSection]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HorizontalContainer className={css.detailArea}>
|
<HorizontalContainer className={css.detailArea}>
|
||||||
{/* Left Margin Section - 60px */}
|
{/* Left Margin Section - 60px */}
|
||||||
@@ -294,22 +309,14 @@ export default function ProductAllSection({
|
|||||||
productType={productType}
|
productType={productType}
|
||||||
>
|
>
|
||||||
<div className={css.qrWrapper}>
|
<div className={css.qrWrapper}>
|
||||||
{/* <QRCode productInfo={productData} productType={productType} /> */}
|
<QRCode productInfo={productData} productType={productType} />
|
||||||
<QRCode
|
|
||||||
productInfo={productData}
|
|
||||||
productType={productType}
|
|
||||||
kind="detail"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</ProductOverview>
|
</ProductOverview>
|
||||||
|
|
||||||
<Container className={css.buttonContainer}>
|
<Container className={css.buttonContainer}>
|
||||||
<TButton
|
<TButton
|
||||||
spotlightId={SpotlightIds.DETAIL_SHOPBYMOBILE}
|
spotlightId={SpotlightIds.DETAIL_SHOPBYMOBILE}
|
||||||
className={classNames(
|
className={css.shopByMobileButton}
|
||||||
css.shopByMobileButton,
|
|
||||||
css.shopByMobileOne
|
|
||||||
)}
|
|
||||||
onClick={handleShopByMobileOpen}
|
onClick={handleShopByMobileOpen}
|
||||||
onSpotlightUp={handleSpotlightUpToBackButton}
|
onSpotlightUp={handleSpotlightUpToBackButton}
|
||||||
>
|
>
|
||||||
@@ -325,7 +332,6 @@ export default function ProductAllSection({
|
|||||||
selectedPrdtId={panelInfo && panelInfo.prdtId}
|
selectedPrdtId={panelInfo && panelInfo.prdtId}
|
||||||
favoriteFlag={favoriteFlag}
|
favoriteFlag={favoriteFlag}
|
||||||
onFavoriteFlagChanged={onFavoriteFlagChanged}
|
onFavoriteFlagChanged={onFavoriteFlagChanged}
|
||||||
kind={"item_detail"}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -356,30 +362,27 @@ export default function ProductAllSection({
|
|||||||
>
|
>
|
||||||
{$L("PRODUCT DETAILS")}
|
{$L("PRODUCT DETAILS")}
|
||||||
</TButton>
|
</TButton>
|
||||||
{/* 🔧 [임시] 고객 데모용 조건 변경: showUserReviewsButton (원래: reviewTotalCount > 0) */}
|
{hasReviews && (
|
||||||
{/* {showUserReviewsButton && ( */}
|
|
||||||
{reviewTotalCount > 0 && (
|
|
||||||
<TButton
|
<TButton
|
||||||
className={css.userReviewsButton}
|
className={css.userReviewsButton}
|
||||||
onClick={handleUserReviewsClick}
|
onClick={handleUserReviewsClick}
|
||||||
spotlightId="user-reviews-button"
|
spotlightId="user-reviews-button"
|
||||||
>
|
>
|
||||||
{$L(
|
{$L("USER REVIEWS")}
|
||||||
`USER REVIEWS (${reviewTotalCount > 100 ? "100" : reviewTotalCount || "0"})`
|
</TButton>
|
||||||
)}
|
)}
|
||||||
|
{hasYouMayAlsoLike && (
|
||||||
|
<TButton
|
||||||
|
className={css.youMayLikeButton}
|
||||||
|
onClick={handleYouMayAlsoLikeClick}
|
||||||
|
>
|
||||||
|
{$L("YOU MAY ALSO LIKE")}
|
||||||
</TButton>
|
</TButton>
|
||||||
)}
|
)}
|
||||||
<TButton
|
|
||||||
className={css.youMayLikeButton}
|
|
||||||
onClick={handleYouMayAlsoLikeClick}
|
|
||||||
>
|
|
||||||
{$L("YOU MAY ALSO LIKE")}
|
|
||||||
</TButton>
|
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{panelInfo &&
|
{panelInfo &&
|
||||||
panelInfo &&
|
panelInfo && panelInfo.type === "theme" &&
|
||||||
panelInfo.type === "theme" &&
|
|
||||||
!openThemeItemOverlay && (
|
!openThemeItemOverlay && (
|
||||||
<TButton
|
<TButton
|
||||||
className={css.themeButton}
|
className={css.themeButton}
|
||||||
@@ -425,8 +428,7 @@ export default function ProductAllSection({
|
|||||||
></div>
|
></div>
|
||||||
<LayoutSample onClick={handleLayoutSampleClick} />
|
<LayoutSample onClick={handleLayoutSampleClick} />
|
||||||
<div id="product-details-section">
|
<div id="product-details-section">
|
||||||
{productData?.imgUrls600 &&
|
{productData && productData.imgUrls600 && productData.imgUrls600.length > 0 ? (
|
||||||
productData.imgUrls600.length > 0 ? (
|
|
||||||
productData.imgUrls600.map((image, index) => (
|
productData.imgUrls600.map((image, index) => (
|
||||||
<ProductDetail
|
<ProductDetail
|
||||||
key={`product-detail-${index}`}
|
key={`product-detail-${index}`}
|
||||||
@@ -434,7 +436,7 @@ export default function ProductAllSection({
|
|||||||
...productData,
|
...productData,
|
||||||
singleImage: image,
|
singleImage: image,
|
||||||
imageIndex: index,
|
imageIndex: index,
|
||||||
totalImages: productData.imgUrls600.length,
|
totalImages: productData.imgUrls600.length
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -445,32 +447,42 @@ export default function ProductAllSection({
|
|||||||
<div id="product-description-section">
|
<div id="product-description-section">
|
||||||
<ProductDescription productInfo={productData} />
|
<ProductDescription productInfo={productData} />
|
||||||
</div>
|
</div>
|
||||||
<div
|
{/* 리뷰가 있을 때만 UserReviews 섹션 표시 */}
|
||||||
id="scroll-marker-user-reviews"
|
{hasReviews && (
|
||||||
className={css.scrollMarker}
|
<>
|
||||||
></div>
|
<div id="scroll-marker-user-reviews" className={css.scrollMarker}></div>
|
||||||
{/* Description 바로 아래에 UserReviews 항상 표시 (조건 제거) */}
|
<div id="user-reviews-section">
|
||||||
<div id="user-reviews-section">
|
<UserReviews
|
||||||
<UserReviews
|
productInfo={productData}
|
||||||
productInfo={productData}
|
panelInfo={panelInfo}
|
||||||
panelInfo={panelInfo}
|
reviewsData={{
|
||||||
/>
|
previewReviews: previewReviews.slice(0, 5), // 처음 5개만
|
||||||
</div>
|
stats: stats,
|
||||||
</div>
|
isLoading: reviewsLoading
|
||||||
<div
|
}}
|
||||||
id="scroll-marker-you-may-also-like"
|
/>
|
||||||
className={css.scrollMarker}
|
</div>
|
||||||
></div>
|
{/* <ViewAllReviewsButton /> */}
|
||||||
<div id="you-may-also-like-section">
|
<ShowUserReviews />
|
||||||
<YouMayAlsoLike
|
</>
|
||||||
productInfo={productData}
|
)}
|
||||||
panelInfo={panelInfo}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
{hasYouMayAlsoLike && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
id="scroll-marker-you-may-also-like"
|
||||||
|
className={css.scrollMarker}
|
||||||
|
></div>
|
||||||
|
<div id="you-may-also-like-section">
|
||||||
|
<YouMayAlsoLike productInfo={productData} panelInfo={panelInfo} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</TScrollerDetail>
|
</TScrollerDetail>
|
||||||
</div>
|
</div>
|
||||||
</ContentContainer>
|
</ContentContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</HorizontalContainer>
|
</HorizontalContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,16 +35,13 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 60px;
|
left: 60px;
|
||||||
top: 0;
|
top: 0;
|
||||||
width: 650px;
|
width: 645px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
> div {
|
|
||||||
height: 339px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Content Section - 1180px (1114px 콘텐츠 + 66px 스크롤바)
|
// 3. Content Section - 1180px (1114px 콘텐츠 + 66px 스크롤바)
|
||||||
@@ -91,9 +88,7 @@
|
|||||||
// gap 대신 margin 사용 (Chromium 68 호환성)
|
// gap 대신 margin 사용 (Chromium 68 호환성)
|
||||||
> * {
|
> * {
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
&:last-child {
|
&:last-child { margin-bottom: 0; }
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +115,7 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
position: relative; // 자식 absolute 요소의 기준점
|
position: relative; // 자식 absolute 요소의 기준점
|
||||||
|
|
||||||
|
|
||||||
// 스크롤러 오버라이드 (1210px = 30px + content + 스크롤바)
|
// 스크롤러 오버라이드 (1210px = 30px + content + 스크롤바)
|
||||||
.scrollerOverride {
|
.scrollerOverride {
|
||||||
width: 1210px; // 절대 크기 지정
|
width: 1210px; // 절대 크기 지정
|
||||||
@@ -143,16 +139,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
&::-webkit-scrollbar-thumb {
|
||||||
background: #9c9c9c; // 스크롤바 색상
|
background: #9C9C9C; // 스크롤바 색상
|
||||||
border-radius: 3px; // 스크롤바 둥근 모서리
|
border-radius: 3px; // 스크롤바 둥근 모서리
|
||||||
}
|
}
|
||||||
|
|
||||||
// 스크롤바 thumb에 hover 효과 적용
|
// 스크롤바 thumb에 hover 효과 적용
|
||||||
&:hover::-webkit-scrollbar-thumb {
|
&:hover::-webkit-scrollbar-thumb {
|
||||||
background: #c72054;
|
background: #C72054;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 내부 콘텐츠는 별도 너비 계산 없이 100%를 사용
|
// 내부 콘텐츠는 별도 너비 계산 없이 100%를 사용
|
||||||
> div {
|
> div {
|
||||||
width: 100%; // 부모의 패딩을 제외한 나머지 공간(1114px)을 모두 사용
|
width: 100%; // 부모의 패딩을 제외한 나머지 공간(1114px)을 모두 사용
|
||||||
@@ -232,6 +229,7 @@
|
|||||||
|
|
||||||
// (중복 제거됨) 최상위 스크롤러/섹션 정의는 .scrollerWrapper 중첩 내부로 이동
|
// (중복 제거됨) 최상위 스크롤러/섹션 정의는 .scrollerWrapper 중첩 내부로 이동
|
||||||
|
|
||||||
|
|
||||||
// ProductDetailCard 스타일 참고 - 크기/간격만 적용
|
// ProductDetailCard 스타일 참고 - 크기/간격만 적용
|
||||||
|
|
||||||
// 헤더 컨텐츠 영역 (태그, 별점)
|
// 헤더 컨텐츠 영역 (태그, 별점)
|
||||||
@@ -239,6 +237,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 모바일 쇼핑 섹션 (mobileSection 참고)
|
// 모바일 쇼핑 섹션 (mobileSection 참고)
|
||||||
@@ -251,9 +250,7 @@
|
|||||||
|
|
||||||
> * {
|
> * {
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
&:last-child {
|
&:last-child { margin-right: 0; }
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,17 +258,14 @@
|
|||||||
flex: 1 1 0 !important;
|
flex: 1 1 0 !important;
|
||||||
width: auto !important; // flex로 크기 조정
|
width: auto !important; // flex로 크기 조정
|
||||||
height: 60px !important;
|
height: 60px !important;
|
||||||
background: rgba(68, 68, 68, 0.5) !important;
|
background: rgba(68, 68, 68, 0.50) !important;
|
||||||
border-radius: 6px !important;
|
border-radius: 6px !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
margin: 0;
|
margin: 0 !important;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
justify-content: center !important;
|
justify-content: center !important;
|
||||||
&.shopByMobileOne {
|
|
||||||
margin: 0 10px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shopByMobileText {
|
.shopByMobileText {
|
||||||
color: white !important;
|
color: white !important;
|
||||||
@@ -314,7 +308,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
.callToOrderText {
|
.callToOrderText {
|
||||||
color: #eaeaea;
|
color: #EAEAEA;
|
||||||
font-size: 25px;
|
font-size: 25px;
|
||||||
font-family: @baseFont; // LG Smart 폰트 사용
|
font-family: @baseFont; // LG Smart 폰트 사용
|
||||||
font-weight: 400; // Bold에서 Regular로 변경
|
font-weight: 400; // Bold에서 Regular로 변경
|
||||||
@@ -328,9 +322,7 @@
|
|||||||
|
|
||||||
> * {
|
> * {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
&:last-child {
|
&:last-child { margin-right: 0; }
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.phoneIconContainer {
|
.phoneIconContainer {
|
||||||
@@ -345,6 +337,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
background: #EAEAEA;
|
||||||
// 전화 아이콘 이미지 또는 CSS로 구현
|
// 전화 아이콘 이미지 또는 CSS로 구현
|
||||||
background-image: url("../../../../assets/images/icons/ic-gr-call-1.png");
|
background-image: url("../../../../assets/images/icons/ic-gr-call-1.png");
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
@@ -354,9 +347,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.phoneNumber {
|
.phoneNumber {
|
||||||
color: #eaeaea;
|
color: #EAEAEA;
|
||||||
font-size: 25px;
|
font-size: 25px;
|
||||||
font-family: "LG Smart UI";
|
font-family: 'LG Smart UI';
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 35px;
|
line-height: 35px;
|
||||||
}
|
}
|
||||||
@@ -372,9 +365,7 @@
|
|||||||
|
|
||||||
> * {
|
> * {
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
&:last-child {
|
&:last-child { margin-bottom: 0; }
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,14 +381,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
color: #eaeaea;
|
color: #EAEAEA;
|
||||||
font-size: 25px;
|
font-size: 25px;
|
||||||
font-family: @baseFont; // LG Smart 폰트 사용
|
font-family: @baseFont; // LG Smart 폰트 사용
|
||||||
font-weight: 400; // Bold에서 Regular로 변경
|
font-weight: 400; // Bold에서 Regular로 변경
|
||||||
line-height: 35px;
|
line-height: 35px;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
background: #c72054; // 포커스시만 빨간색
|
background: #C72054; // 포커스시만 빨간색
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +400,7 @@
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
background: #c72054;
|
background: #C72054;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,32 +414,62 @@
|
|||||||
|
|
||||||
> * {
|
> * {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
&:last-child {
|
&:last-child { margin-bottom: 0; }
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductOverview 컨테이너 스타일 수정 (자식 요소에 맞게 크기 조정)
|
// ProductOverview 컨테이너 스타일 수정 (자식 요소에 맞게 크기 조정)
|
||||||
[class*="ProductOverview"] {
|
[class*="ProductOverview"] {
|
||||||
padding: 0 0 5px;
|
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 (productInfoWrapper)
|
||||||
> div {
|
> div {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
|
||||||
> div:first-child {
|
> * {
|
||||||
width: 380px;
|
margin-right: 15px; // ProductDetailCard와 동일한 간격
|
||||||
text-align: left;
|
&: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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LayoutSample 포커스 테스트용 스타일
|
// LayoutSample 포커스 테스트용 스타일
|
||||||
.layoutSample {
|
.layoutSample {
|
||||||
width: 1124px;
|
width: 1124px;
|
||||||
height: 300px;
|
height: 35px;
|
||||||
background-color: yellow;
|
background-color: yellow;
|
||||||
|
// border: 2px solid white;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import css from "./ProductDetail.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);
|
||||||
|
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||||||
|
const [unitHasFocus, setUnitHasFocus] = useState(false);
|
||||||
|
const [contentsFocus, setContentsFocus] = useState(false);
|
||||||
|
const [prevFocus, setPrevFocus] = useState(false);
|
||||||
|
const [nextFocus, setNextFocus] = useState(false);
|
||||||
|
|
||||||
|
// listImages를 먼저 정의 (다른 함수들이 이를 사용하므로)
|
||||||
|
const listImages = useMemo(() => {
|
||||||
|
const images = [...(productInfo?.imgUrls600 || [])];
|
||||||
|
|
||||||
|
const hasMedia = Boolean(productInfo?.prdtMediaUrl);
|
||||||
|
const hasThumbnail = Boolean(productInfo?.thumbnailUrl960);
|
||||||
|
|
||||||
|
if (hasMedia) {
|
||||||
|
images.unshift(
|
||||||
|
hasThumbnail ? productInfo.thumbnailUrl960 : indicatorDefaultImage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
return [indicatorDefaultImage];
|
||||||
|
}
|
||||||
|
|
||||||
|
return images;
|
||||||
|
}, [
|
||||||
|
productInfo?.imgUrls600,
|
||||||
|
productInfo?.prdtMediaUrl,
|
||||||
|
productInfo?.thumbnailUrl960,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// RollingUnit 패턴: 이전 이미지로 이동
|
||||||
|
const handlePrev = useCallback(() => {
|
||||||
|
if (!listImages || listImages.length <= 1) return;
|
||||||
|
|
||||||
|
const maxIndex = listImages.length - 1;
|
||||||
|
if (currentImageIndex === 0) {
|
||||||
|
setCurrentImageIndex(maxIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentImageIndex(currentImageIndex - 1);
|
||||||
|
// console.log(`[ProductDetail] Moving to previous image: ${currentImageIndex - 1}`);
|
||||||
|
}, [currentImageIndex, listImages]);
|
||||||
|
|
||||||
|
// RollingUnit 패턴: 다음 이미지로 이동
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
if (!listImages || listImages.length <= 1) return;
|
||||||
|
|
||||||
|
const maxIndex = listImages.length - 1;
|
||||||
|
if (maxIndex === currentImageIndex) {
|
||||||
|
setCurrentImageIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentImageIndex(currentImageIndex + 1);
|
||||||
|
// console.log(`[ProductDetail] Moving to next image: ${currentImageIndex + 1}`);
|
||||||
|
}, [currentImageIndex, listImages]);
|
||||||
|
|
||||||
|
// 메인 이미지 영역 포커스 핸들러
|
||||||
|
const onFocus = useCallback(() => {
|
||||||
|
// console.log("[ProductDetail] Main image area focused");
|
||||||
|
setUnitHasFocus(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onBlur = useCallback(() => {
|
||||||
|
// console.log("[ProductDetail] Main image area blurred");
|
||||||
|
setUnitHasFocus(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 화살표 버튼 포커스 핸들러
|
||||||
|
const indicatorFocus = useCallback(() => {
|
||||||
|
// console.log("[ProductDetail] Arrow button focused");
|
||||||
|
setNextFocus(false);
|
||||||
|
setContentsFocus(false);
|
||||||
|
setPrevFocus(false);
|
||||||
|
setUnitHasFocus(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const indicatorBlur = useCallback(() => {
|
||||||
|
// console.log("[ProductDetail] Arrow button blurred");
|
||||||
|
setUnitHasFocus(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setNextFocus(false);
|
||||||
|
setContentsFocus(false);
|
||||||
|
}, 300);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 화살표 버튼에서 아래 키 누를 시 처리
|
||||||
|
const prevKeyDown = useCallback((event) => {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
setNextFocus(true);
|
||||||
|
setContentsFocus(true);
|
||||||
|
} else if (event.key === "ArrowLeft") {
|
||||||
|
// 왼쪽 화살표 버튼에서 왼쪽 키 누르면 PRODUCT DETAILS로 이동
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
// console.log("[ProductDetail] Left arrow from prev button, focusing product-details-button");
|
||||||
|
Spotlight.focus("product-details-button");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const nextKeyDown = useCallback((event) => {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
setPrevFocus(true);
|
||||||
|
setContentsFocus(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 현재 선택된 단일 이미지 렌더링
|
||||||
|
const renderCurrentImage = useCallback(() => {
|
||||||
|
if (!listImages || listImages.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={css.thumbnailWrapper}>
|
||||||
|
<CustomImage
|
||||||
|
src={indicatorDefaultImage}
|
||||||
|
alt="No image available"
|
||||||
|
fallbackSrc={indicatorDefaultImage}
|
||||||
|
className={css.productImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeIndex = Math.max(0, Math.min(currentImageIndex, listImages.length - 1));
|
||||||
|
const currentImage = listImages[safeIndex] || listImages[0] || indicatorDefaultImage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.thumbnailWrapper}>
|
||||||
|
<CustomImage
|
||||||
|
src={currentImage}
|
||||||
|
alt={`Product image ${safeIndex + 1} of ${listImages.length}`}
|
||||||
|
fallbackSrc={indicatorDefaultImage}
|
||||||
|
className={css.productImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [listImages, currentImageIndex]);
|
||||||
|
|
||||||
|
// listImages 변경시 currentImageIndex 리셋
|
||||||
|
useEffect(() => {
|
||||||
|
if (listImages && listImages.length > 0) {
|
||||||
|
// 현재 인덱스가 범위를 벗어나면 0으로 리셋
|
||||||
|
if (currentImageIndex >= listImages.length) {
|
||||||
|
setCurrentImageIndex(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [listImages, currentImageIndex]);
|
||||||
|
|
||||||
|
// ProductDetail: Container 직접 사용 패턴 (TVerticalPagenator 충돌 문제로 제거)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
ref={containerRef}
|
||||||
|
className={css.rollingWrap}
|
||||||
|
spotlightId="product-detail-container"
|
||||||
|
>
|
||||||
|
{/* 왼쪽 화살표 버튼 (이미지가 2개 이상일 때만 표시) */}
|
||||||
|
{listImages && listImages.length > 1 && (
|
||||||
|
<SpottableComponent
|
||||||
|
className={`${css.arrow} ${css.leftBtn}`}
|
||||||
|
onClick={handlePrev}
|
||||||
|
onFocus={indicatorFocus}
|
||||||
|
onBlur={indicatorBlur}
|
||||||
|
spotlightId="product-images-prev"
|
||||||
|
spotlightDisabled={prevFocus}
|
||||||
|
onKeyDown={prevKeyDown}
|
||||||
|
aria-label="Move to previous image"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 메인 이미지 영역 */}
|
||||||
|
<SpottableComponent
|
||||||
|
className={css.itemBox}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
spotlightId="product-img-main"
|
||||||
|
spotlightDisabled={contentsFocus}
|
||||||
|
aria-label={`Product image ${currentImageIndex + 1} of ${listImages?.length || 0}`}
|
||||||
|
>
|
||||||
|
{renderCurrentImage()}
|
||||||
|
</SpottableComponent>
|
||||||
|
|
||||||
|
{/* 오른쪽 화살표 버튼 (이미지가 2개 이상일 때만 표시) */}
|
||||||
|
{listImages && listImages.length > 1 && (
|
||||||
|
<SpottableComponent
|
||||||
|
className={`${css.arrow} ${css.rightBtn}`}
|
||||||
|
onClick={handleNext}
|
||||||
|
onFocus={indicatorFocus}
|
||||||
|
onBlur={indicatorBlur}
|
||||||
|
spotlightId="product-images-next"
|
||||||
|
spotlightDisabled={nextFocus}
|
||||||
|
onKeyDown={nextKeyDown}
|
||||||
|
aria-label="Move to next image"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,12 +46,12 @@ export default function ProductDetail({ productInfo }) {
|
|||||||
// 메인 이미지 영역 포커스 핸들러
|
// 메인 이미지 영역 포커스 핸들러
|
||||||
const onFocus = useCallback(() => {
|
const onFocus = useCallback(() => {
|
||||||
const imageIndex = productInfo?.imageIndex ?? 0;
|
const imageIndex = productInfo?.imageIndex ?? 0;
|
||||||
console.log(`[ProductDetail] Image ${imageIndex + 1} focused`);
|
// console.log(`[ProductDetail] Image ${imageIndex + 1} focused`);
|
||||||
}, [productInfo?.imageIndex]);
|
}, [productInfo?.imageIndex]);
|
||||||
|
|
||||||
const onBlur = useCallback(() => {
|
const onBlur = useCallback(() => {
|
||||||
const imageIndex = productInfo?.imageIndex ?? 0;
|
const imageIndex = productInfo?.imageIndex ?? 0;
|
||||||
console.log(`[ProductDetail] Image ${imageIndex + 1} blurred`);
|
// console.log(`[ProductDetail] Image ${imageIndex + 1} blurred`);
|
||||||
}, [productInfo?.imageIndex]);
|
}, [productInfo?.imageIndex]);
|
||||||
|
|
||||||
// 단일 이미지 렌더링 (항상 하나의 이미지만)
|
// 단일 이미지 렌더링 (항상 하나의 이미지만)
|
||||||
|
|||||||
@@ -37,6 +37,10 @@
|
|||||||
bottom: -6px;
|
bottom: -6px;
|
||||||
left: -6px;
|
left: -6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thumbnailWrapper .productImage {
|
||||||
|
transform: scale(1.015); // 가로세로 10px 정도 확대 효과
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import css from "./CustomerImages.module.less";
|
import css from "./CustomerImages.module.less";
|
||||||
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
||||||
import { useSelector } from "react-redux";
|
|
||||||
import Spottable from "@enact/spotlight/Spottable";
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
import THeader from "../../../../../components/THeader/THeader";
|
import THeader from "../../../../../components/THeader/THeader";
|
||||||
import { $L } from "../../../../../utils/helperMethods";
|
import { $L } from "../../../../../utils/helperMethods";
|
||||||
@@ -21,131 +20,41 @@ const Container = SpotlightContainerDecorator(
|
|||||||
|
|
||||||
const SpottableComponent = Spottable("div");
|
const SpottableComponent = Spottable("div");
|
||||||
|
|
||||||
export default function CustomerImages({ onImageClick }) {
|
export default function CustomerImages({ onImageClick, onViewMoreClick, imageData }) {
|
||||||
// Redux에서 reviewData 전체를 가져옴
|
// Props로 전달받은 imageData 사용 (useReviews Hook에서 추출된 이미지 데이터)
|
||||||
const reviewData = useSelector((state) => state.product.reviewData);
|
|
||||||
const reviewListData = reviewData?.reviewList;
|
|
||||||
|
|
||||||
const [imageList, setImageList] = useState([]);
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(null);
|
const [selectedIndex, setSelectedIndex] = useState(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const IMAGES_PER_PAGE = 5;
|
const IMAGES_PER_PAGE = 5;
|
||||||
|
|
||||||
// [UserReviews] CustomerImages 데이터 수신 확인 - 개선된 로깅
|
// [CustomerImages] useReviews 이미지 데이터 수신 확인
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
console.log("[UserReviews] CustomerImages - Full review data received:", {
|
// console.log("[CustomerImages] useReviews 이미지 데이터 확인:", {
|
||||||
reviewData,
|
// imageData,
|
||||||
reviewListData,
|
// hasImageData: !!imageData,
|
||||||
hasData: reviewData && reviewListData && reviewListData.length > 0,
|
// imageCount: imageData?.length || 0,
|
||||||
reviewCount: reviewListData?.length || 0
|
// dataSource: 'useReviews.extractImagesFromReviews'
|
||||||
});
|
// });
|
||||||
}, [reviewData, reviewListData]);
|
// }, [imageData]);
|
||||||
|
|
||||||
// 이미지 데이터 처리 로직 개선
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [imageList]);
|
}, [imageData]);
|
||||||
|
|
||||||
const handleReviewImageClick = (index) => {
|
const handleReviewImageClick = (index) => {
|
||||||
console.log("[UserReviews] CustomerImages - Image clicked at index:", index, {
|
|
||||||
imageData: imageList[index]
|
|
||||||
});
|
|
||||||
setSelectedIndex(index);
|
setSelectedIndex(index);
|
||||||
|
|
||||||
// 부모 컴포넌트에 팝업 열기 이벤트 전달
|
// 이미지 클릭 시 All-Images 모드로 팝업 열기
|
||||||
if (onImageClick) {
|
if (onImageClick) {
|
||||||
onImageClick(index);
|
onImageClick(index);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewMoreClick = () => {
|
const handleViewMoreClick = () => {
|
||||||
console.log("[UserReviews] CustomerImages - View more clicked", {
|
// +View More 버튼 클릭 시 Customer Images 모드로 팝업 열기
|
||||||
currentPage,
|
if (onViewMoreClick) {
|
||||||
totalImages: imageList.length,
|
onViewMoreClick();
|
||||||
totalPages: Math.ceil(imageList.length / IMAGES_PER_PAGE)
|
}
|
||||||
});
|
|
||||||
setCurrentPage(prev => prev + 1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 키 이벤트 처리 (왼쪽 화살표, Enter 키)
|
// 키 이벤트 처리 (왼쪽 화살표, Enter 키)
|
||||||
@@ -168,18 +77,18 @@ export default function CustomerImages({ onImageClick }) {
|
|||||||
<>
|
<>
|
||||||
<Container className={css.container}>
|
<Container className={css.container}>
|
||||||
<THeader className={css.tHeader} title={$L("Customer Images")} />
|
<THeader className={css.tHeader} title={$L("Customer Images")} />
|
||||||
{imageList && imageList.length > 0 ? (
|
{imageData && imageData.length > 0 ? (
|
||||||
<div className={css.wrapper}>
|
<div className={css.wrapper}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const startIndex = (currentPage - 1) * IMAGES_PER_PAGE;
|
const startIndex = (currentPage - 1) * IMAGES_PER_PAGE;
|
||||||
const endIndex = startIndex + IMAGES_PER_PAGE;
|
const endIndex = startIndex + IMAGES_PER_PAGE;
|
||||||
const displayImages = imageList.slice(startIndex, endIndex);
|
const displayImages = imageData.slice(startIndex, endIndex);
|
||||||
const hasMoreImages = imageList.length > endIndex;
|
const hasMoreImages = imageData.length > endIndex;
|
||||||
|
|
||||||
console.log("[CustomerImages] Pagination debug:", {
|
console.log("[CustomerImages] Pagination debug:", {
|
||||||
currentPage,
|
currentPage,
|
||||||
IMAGES_PER_PAGE,
|
IMAGES_PER_PAGE,
|
||||||
totalImages: imageList.length,
|
totalImages: imageData.length,
|
||||||
startIndex,
|
startIndex,
|
||||||
endIndex,
|
endIndex,
|
||||||
displayImagesCount: displayImages.length,
|
displayImagesCount: displayImages.length,
|
||||||
@@ -208,14 +117,14 @@ export default function CustomerImages({ onImageClick }) {
|
|||||||
src={imgUrl}
|
src={imgUrl}
|
||||||
alt={`Review image ${actualIndex + 1}`}
|
alt={`Review image ${actualIndex + 1}`}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
console.log(`[UserReviews] CustomerImages - Image loaded successfully:`, {
|
/* console.log(`[CustomerImages] Image loaded successfully:`, {
|
||||||
index: actualIndex,
|
index: actualIndex,
|
||||||
imgUrl,
|
imgUrl,
|
||||||
imgId
|
imgId
|
||||||
});
|
}); */
|
||||||
}}
|
}}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
console.error(`[UserReviews] CustomerImages - Image load failed:`, {
|
console.error(`[CustomerImages] Image load failed:`, {
|
||||||
index: actualIndex,
|
index: actualIndex,
|
||||||
imgUrl,
|
imgUrl,
|
||||||
imgId,
|
imgId,
|
||||||
@@ -258,7 +167,7 @@ export default function CustomerImages({ onImageClick }) {
|
|||||||
padding: '20px',
|
padding: '20px',
|
||||||
fontSize: '16px'
|
fontSize: '16px'
|
||||||
}}>
|
}}>
|
||||||
{reviewData ? 'No customer images available' : 'Loading customer images...'}
|
{imageData ? 'No customer images available' : 'Loading customer images...'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
|
import StarRating from "../../../components/StarRating";
|
||||||
|
import { $L } from "../../../../../utils/helperMethods";
|
||||||
|
import css from "./UserReviewDetail.module.less";
|
||||||
|
|
||||||
|
const SpottableButton = Spottable("div");
|
||||||
|
|
||||||
|
export default function UserReviewDetail({
|
||||||
|
currentReview,
|
||||||
|
currentIndex = 0,
|
||||||
|
totalReviews = 1,
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
className,
|
||||||
|
}) {
|
||||||
|
const handlePrevious = useCallback(() => {
|
||||||
|
if (onPrevious && currentIndex > 0) {
|
||||||
|
onPrevious();
|
||||||
|
}
|
||||||
|
}, [onPrevious, currentIndex]);
|
||||||
|
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
if (onNext && currentIndex < totalReviews - 1) {
|
||||||
|
onNext();
|
||||||
|
}
|
||||||
|
}, [onNext, currentIndex, totalReviews]);
|
||||||
|
|
||||||
|
// 리뷰 데이터가 없을 때 처리
|
||||||
|
if (!currentReview) {
|
||||||
|
return (
|
||||||
|
<div className={css.container}>
|
||||||
|
<div className={css.noReview}>No review data available</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewImage = currentReview.reviewImageList && currentReview.reviewImageList[0];
|
||||||
|
const hasMultipleReviews = totalReviews > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames(css.container, className)}>
|
||||||
|
{/* Left Arrow - 이전 리뷰가 있을 때만 표시 */}
|
||||||
|
{hasMultipleReviews && currentIndex > 0 && (
|
||||||
|
<SpottableButton
|
||||||
|
className={css.leftArrow}
|
||||||
|
onClick={handlePrevious}
|
||||||
|
spotlightId="review-detail-prev"
|
||||||
|
aria-label="Previous review"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className={css.mainContent}>
|
||||||
|
<div className={css.reviewContainer}>
|
||||||
|
{/* Review Image */}
|
||||||
|
{reviewImage && (
|
||||||
|
<div className={css.imageSection}>
|
||||||
|
<img
|
||||||
|
src={reviewImage.imgUrl}
|
||||||
|
alt="Review image"
|
||||||
|
className={css.reviewImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review Info */}
|
||||||
|
<div className={css.infoSection}>
|
||||||
|
{/* Rating and Meta */}
|
||||||
|
<div className={css.metaSection}>
|
||||||
|
{/* Star Rating */}
|
||||||
|
{currentReview.rvwRtng && (
|
||||||
|
<div className={css.ratingContainer}>
|
||||||
|
<StarRating
|
||||||
|
rating={currentReview.rvwRtng}
|
||||||
|
className={css.starRating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
{(currentReview.wrtrNknm || currentReview.rvwWrtrId) && (
|
||||||
|
<div className={css.email}>
|
||||||
|
{currentReview.wrtrNknm || currentReview.rvwWrtrId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
{currentReview.rvwRgstDtt && (
|
||||||
|
<div className={css.date}>
|
||||||
|
{currentReview.rvwRgstDtt}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Review Text */}
|
||||||
|
{currentReview.rvwCtnt && (
|
||||||
|
<div className={css.reviewText}>
|
||||||
|
"{currentReview.rvwCtnt}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Scrollbar (피그마 스타일) */}
|
||||||
|
<div className={css.customScrollbar}>
|
||||||
|
<div className={css.scrollTrack} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Arrow - 다음 리뷰가 있을 때만 표시 */}
|
||||||
|
{hasMultipleReviews && currentIndex < totalReviews - 1 && (
|
||||||
|
<SpottableButton
|
||||||
|
className={css.rightArrow}
|
||||||
|
onClick={handleNext}
|
||||||
|
spotlightId="review-detail-next"
|
||||||
|
aria-label="Next review"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
@import "../../../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding-left: 30px;
|
||||||
|
padding-right: 30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
// 좌측 화살표 (이전 리뷰) - detailpanel PNG 사용
|
||||||
|
.leftArrow {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
padding: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
// 화살표 아이콘 (< 모양, 17px x 29px)
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 17px;
|
||||||
|
height: 29px;
|
||||||
|
background-image: url("../../../../../../assets/images/detailpanel/left-arrow.svg");
|
||||||
|
background-position: center;
|
||||||
|
background-size: 17px 29px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
background-image: url("../../../../../../assets/images/detailpanel/left-arrow-red.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
.focused(@boxShadow: 0px, @borderRadius: 4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
&::before {
|
||||||
|
background-image: url("../../../../../../assets/images/detailpanel/left-arrow-red.svg");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 콘텐츠 영역 (피그마 레이아웃 적용)
|
||||||
|
.mainContent {
|
||||||
|
flex: 1;
|
||||||
|
height: 557px;
|
||||||
|
padding: 30px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.reviewContainer {
|
||||||
|
flex: 1;
|
||||||
|
align-self: stretch;
|
||||||
|
padding: 30px;
|
||||||
|
background: #F8F8F8;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-right: 30px; // gap: 30 → margin-right: 30px
|
||||||
|
|
||||||
|
// 이미지 섹션 (피그마: flex: 1, align-self: stretch, padding: 5px 4px)
|
||||||
|
.imageSection {
|
||||||
|
flex: 1;
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.reviewImage {
|
||||||
|
width: 357px;
|
||||||
|
height: 467px;
|
||||||
|
padding: 5px 4px;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정보 섹션
|
||||||
|
.infoSection {
|
||||||
|
flex: 1;
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-left: 30px; // gap: 30 → margin-left: 30px
|
||||||
|
|
||||||
|
// 메타 섹션 (별점, 이메일, 날짜)
|
||||||
|
.metaSection {
|
||||||
|
align-self: stretch;
|
||||||
|
padding-bottom: 30px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 15px; // gap: 15 → margin-bottom: 15px
|
||||||
|
|
||||||
|
.ratingContainer {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
display: inline-flex; // 피그마: inline-flex
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-right: 4px; // gap: 4 → margin-right: 4px
|
||||||
|
|
||||||
|
.starRating {
|
||||||
|
// 별점 아이콘들 사이 간격은 StarRating 컴포넌트 내부에서 처리
|
||||||
|
// :global 사용 금지로 인해 내부 처리에 맡김
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.email {
|
||||||
|
width: 400px;
|
||||||
|
min-height: 31px;
|
||||||
|
position: relative;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 24px;
|
||||||
|
font-family: @baseFont;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 31px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
color: #333333;
|
||||||
|
font-size: 24px;
|
||||||
|
font-family: @baseFont;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 31px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리뷰 텍스트
|
||||||
|
.reviewText {
|
||||||
|
align-self: stretch;
|
||||||
|
color: #333333;
|
||||||
|
font-size: 24px;
|
||||||
|
font-family: @baseFont;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 31px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 커스텀 스크롤바
|
||||||
|
.customScrollbar {
|
||||||
|
width: 6px;
|
||||||
|
align-self: stretch;
|
||||||
|
position: relative;
|
||||||
|
background: #E7E7E7;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.scrollTrack {
|
||||||
|
width: 6px;
|
||||||
|
height: 100px;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
position: absolute;
|
||||||
|
background: #7A808D;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 우측 화살표 (다음 리뷰) - detailpanel PNG 사용
|
||||||
|
.rightArrow {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
padding: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
// 화살표 아이콘 (> 모양, 17px x 29px)
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 17px;
|
||||||
|
height: 29px;
|
||||||
|
background-image: url("../../../../../../assets/images/detailpanel/right-arrow.svg");
|
||||||
|
background-position: center;
|
||||||
|
background-size: 17px 29px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
background-image: url("../../../../../../assets/images/detailpanel/right-arrow-red.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
.focused(@boxShadow: 0px, @borderRadius: 4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
&::before {
|
||||||
|
background-image: url("../../../../../../assets/images/detailpanel/right-arrow-red.svg");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리뷰 데이터가 없을 때
|
||||||
|
.noReview {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: #666;
|
||||||
|
font-size: 24px;
|
||||||
|
font-family: @baseFont;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import css from "./UserReviews.module.less";
|
import css from "./UserReviews.module.less";
|
||||||
import TScroller from "../../../../components/TScroller/TScroller";
|
import UserReviewsScroller from "../../components/UserReviewsScroller/UserReviewsScroller";
|
||||||
import useScrollTo from "../../../../hooks/useScrollTo";
|
import useScrollTo from "../../../../hooks/useScrollTo";
|
||||||
import THeader from "../../../../components/THeader/THeader";
|
import THeaderDetail from "../../components/THeaderDetail";
|
||||||
import { $L } from "../../../../utils/helperMethods";
|
import { $L } from "../../../../utils/helperMethods";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import Spottable from "@enact/spotlight/Spottable";
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
import { getUserReviews, toggleShowAllReviews } from "../../../../actions/productActions";
|
import { toggleShowAllReviews } from "../../../../actions/productActions";
|
||||||
|
import useReviews from "../../../../hooks/useReviews/useReviews";
|
||||||
import StarRating from "../../components/StarRating";
|
import StarRating from "../../components/StarRating";
|
||||||
import CustomerImages from "./CustomerImages/CustomerImages";
|
import CustomerImages from "./CustomerImages/CustomerImages";
|
||||||
import UserReviewsPopup from "./UserReviewsPopup/UserReviewsPopup";
|
import UserReviewsPopup from "./UserReviewsPopup/UserReviewsPopup";
|
||||||
@@ -20,7 +21,8 @@ const Container = SpotlightContainerDecorator(
|
|||||||
enterTo: "default-element",
|
enterTo: "default-element",
|
||||||
preserveld: true,
|
preserveld: true,
|
||||||
leaveFor: {
|
leaveFor: {
|
||||||
left: "spotlight-product-info-section-container"
|
left: "spotlight-product-info-section-container",
|
||||||
|
up: "view-all-reviews-button"
|
||||||
},
|
},
|
||||||
restrict: "none",
|
restrict: "none",
|
||||||
spotlightDirection: "vertical"
|
spotlightDirection: "vertical"
|
||||||
@@ -28,14 +30,25 @@ const Container = SpotlightContainerDecorator(
|
|||||||
"div"
|
"div"
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function UserReviews({ productInfo, panelInfo }) {
|
export default function UserReviews({ productInfo, panelInfo, reviewsData }) {
|
||||||
const { getScrollTo, scrollTop } = useScrollTo();
|
const { getScrollTo, scrollTop } = useScrollTo();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const tScrollerRef = useRef(null);
|
const tScrollerRef = useRef(null);
|
||||||
|
|
||||||
// 팝업 상태 관리
|
// ProductAllSection에서 전달받은 데이터 사용 (우선순위)
|
||||||
|
// 없으면 자체 useReviews Hook 사용 (UserReviewPanel에서 직접 접근할 때)
|
||||||
|
const fallbackReviews = useReviews(productInfo && productInfo.prdtId);
|
||||||
|
|
||||||
|
const actualReviewsData = reviewsData || {
|
||||||
|
previewReviews: fallbackReviews.previewReviews,
|
||||||
|
stats: fallbackReviews.stats,
|
||||||
|
isLoading: fallbackReviews.isLoading
|
||||||
|
};
|
||||||
|
|
||||||
|
// 팝업 상태 관리 - 모드와 선택된 이미지 인덱스 추가
|
||||||
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||||||
|
const [popupMode, setPopupMode] = useState("customer-images"); // "customer-images", "all-images"
|
||||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||||
// Redux에서 showAllReviews 상태 가져오기
|
// Redux에서 showAllReviews 상태 가져오기
|
||||||
const showAllReviews = useSelector((state) => state.product.showAllReviews);
|
const showAllReviews = useSelector((state) => state.product.showAllReviews);
|
||||||
@@ -44,15 +57,17 @@ export default function UserReviews({ productInfo, panelInfo }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("[UserReviews] showAllReviews state changed:", {
|
console.log("[UserReviews] showAllReviews state changed:", {
|
||||||
showAllReviews,
|
showAllReviews,
|
||||||
reviewListLength: reviewListData?.length || 0,
|
reviewListLength: (actualReviewsData.previewReviews && actualReviewsData.previewReviews.length) || 0,
|
||||||
willShowCount: showAllReviews ? (reviewListData?.length || 0) : 5
|
willShowCount: showAllReviews ? ((actualReviewsData.previewReviews && actualReviewsData.previewReviews.length) || 0) : 5,
|
||||||
|
hasReviewsData: !!reviewsData,
|
||||||
|
isFromProductAllSection: !!reviewsData
|
||||||
});
|
});
|
||||||
}, [showAllReviews, reviewListData]);
|
}, [showAllReviews, actualReviewsData.previewReviews, reviewsData]);
|
||||||
|
|
||||||
// showAllReviews 상태 변경 시 TScroller 스크롤 영역 강제 재계산
|
// showAllReviews 상태 변경 시 TScroller 스크롤 영역 강제 재계산
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showAllReviews && tScrollerRef.current) {
|
if (showAllReviews && tScrollerRef.current) {
|
||||||
console.log("[UserReviews] Forcing TScroller to update scroll area for all reviews");
|
// console.log("[UserReviews] Forcing TScroller to update scroll area for all reviews");
|
||||||
|
|
||||||
// 다음 렌더링 사이클 후 스크롤 영역 재계산
|
// 다음 렌더링 사이클 후 스크롤 영역 재계산
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -67,67 +82,34 @@ export default function UserReviews({ productInfo, panelInfo }) {
|
|||||||
tScrollerRef.current.scrollTo({ position: { y: 0 }, animate: false });
|
tScrollerRef.current.scrollTo({ position: { y: 0 }, animate: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[UserReviews] TScroller scroll area updated");
|
// console.log("[UserReviews] TScroller scroll area updated");
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
}, [showAllReviews]);
|
}, [showAllReviews]);
|
||||||
const reviewListData = useSelector(
|
|
||||||
(state) => state.product.reviewData && state.product.reviewData.reviewList
|
// actualReviewsData에서 데이터 가져오기 (ProductAllSection에서 전달받거나 자체 useReviews)
|
||||||
);
|
const reviewListData = actualReviewsData.previewReviews; // 리뷰 리스트 (5개)
|
||||||
const reviewTotalCount = useSelector(
|
const reviewTotalCount = actualReviewsData.stats.totalReviews;
|
||||||
(state) => {
|
const reviewDetailData = {
|
||||||
const reviewData = state.product.reviewData;
|
totRvwCnt: actualReviewsData.stats.totalReviews,
|
||||||
return reviewData && reviewData.reviewDetail && reviewData.reviewDetail.totRvwCnt ? reviewData.reviewDetail.totRvwCnt : 0;
|
avgRvwScr: actualReviewsData.stats.averageRating,
|
||||||
}
|
totRvwAvg: actualReviewsData.stats.averageRating
|
||||||
);
|
};
|
||||||
const reviewDetailData = useSelector(
|
|
||||||
(state) => state.product.reviewData && state.product.reviewData.reviewDetail
|
|
||||||
);
|
|
||||||
|
|
||||||
// [UserReviews] 데이터 수신 확인 로그
|
// [UserReviews] 데이터 수신 확인 로그
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("[UserReviews] Review data received:", {
|
console.log("[UserReviews] 실제 데이터 확인:", {
|
||||||
reviewListData,
|
reviewListLength: (reviewListData && reviewListData.length) || 0,
|
||||||
reviewListLength: reviewListData?.length || 0,
|
totalReviews: actualReviewsData.stats.totalReviews,
|
||||||
reviewTotalCount,
|
averageRating: actualReviewsData.stats.averageRating,
|
||||||
reviewDetailData,
|
isLoading: actualReviewsData.isLoading,
|
||||||
hasData: reviewListData && reviewListData.length > 0,
|
hasData: reviewListData && reviewListData.length > 0,
|
||||||
actualDataLength: Array.isArray(reviewListData) ? reviewListData.length : 'not array'
|
dataSource: reviewsData ? 'ProductAllSection props' : 'useReviews fallback'
|
||||||
});
|
});
|
||||||
}, [reviewListData, reviewTotalCount, reviewDetailData]);
|
}, [reviewListData, actualReviewsData, reviewsData]);
|
||||||
|
|
||||||
// UserReviews: Container 직접 사용 패턴 (TScroller 중복 제거)
|
// ✅ useReviews Hook이 모든 API 호출을 담당하므로 별도 API 호출 불필요
|
||||||
|
|
||||||
// 실제 상품 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 formatToYYMMDD = (dateStr) => {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
@@ -135,65 +117,66 @@ export default function UserReviews({ productInfo, panelInfo }) {
|
|||||||
return iso.replace(/-/g, ".");
|
return iso.replace(/-/g, ".");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReviewClick = useCallback(() => {
|
// 리뷰 클릭으로 User Reviews 모드 팝업 열기
|
||||||
console.log("[UserReviews] Review item clicked");
|
const handleReviewClick = useCallback((reviewIndex) => {
|
||||||
}, []);
|
// 클릭한 리뷰 정보 (previewReviews에서)
|
||||||
|
const clickedReview = reviewListData[reviewIndex];
|
||||||
|
|
||||||
|
// 전체 리뷰에서 클릭한 리뷰의 실제 인덱스 찾기
|
||||||
|
const realIndex = fallbackReviews.allReviews.findIndex(
|
||||||
|
review => review.rvwId === clickedReview.rvwId
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("[UserReviews] Review clicked, opening popup in User Reviews mode:", {
|
||||||
|
previewIndex: reviewIndex,
|
||||||
|
realIndex,
|
||||||
|
clickedReviewId: clickedReview.rvwId,
|
||||||
|
totalReviews: fallbackReviews.allReviews.length
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedImageIndex(realIndex >= 0 ? realIndex : reviewIndex);
|
||||||
|
setPopupMode("user-reviews");
|
||||||
|
setIsPopupOpen(true);
|
||||||
|
}, [reviewListData, fallbackReviews.allReviews]);
|
||||||
|
|
||||||
// 팝업 관련 핸들러들
|
// 팝업 관련 핸들러들
|
||||||
const handleOpenPopup = useCallback((imageIndex = 0) => {
|
// +View More 버튼으로 Customer Images 모드 팝업 열기
|
||||||
console.log("[UserReviews] Opening popup with image index:", imageIndex);
|
const handleOpenPopup = useCallback(() => {
|
||||||
|
console.log("[UserReviews] Opening popup in Customer Images mode");
|
||||||
|
setPopupMode("customer-images");
|
||||||
|
setIsPopupOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 이미지 클릭으로 All-Images 모드 팝업 열기
|
||||||
|
const handleOpenAllImagesPopup = useCallback((imageIndex = 0) => {
|
||||||
|
console.log("[UserReviews] Opening popup in All-Images mode, image index:", imageIndex);
|
||||||
setSelectedImageIndex(imageIndex);
|
setSelectedImageIndex(imageIndex);
|
||||||
|
setPopupMode("all-images");
|
||||||
setIsPopupOpen(true);
|
setIsPopupOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleClosePopup = useCallback(() => {
|
const handleClosePopup = useCallback(() => {
|
||||||
console.log("[UserReviews] Closing popup");
|
console.log("[UserReviews] Closing popup and resetting mode");
|
||||||
setIsPopupOpen(false);
|
setIsPopupOpen(false);
|
||||||
|
setPopupMode("customer-images"); // 모드를 초기값으로 리셋
|
||||||
|
setSelectedImageIndex(0); // 이미지 인덱스도 초기값으로 리셋
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleImageClick = useCallback((index, image) => {
|
// 팝업 모드 변경 핸들러
|
||||||
console.log("[UserReviews] Popup image clicked:", { index, image });
|
const handleModeChange = useCallback((newMode, imageIndex = 0) => {
|
||||||
setSelectedImageIndex(index);
|
console.log("[UserReviews] Mode change requested:", { newMode, imageIndex });
|
||||||
}, []);
|
setPopupMode(newMode);
|
||||||
|
if (newMode === "all-images" || newMode === "user-reviews") {
|
||||||
|
setSelectedImageIndex(imageIndex);
|
||||||
const handleViewAllReviewsClick = useCallback(() => {
|
|
||||||
console.log("[UserReviews] View All Reviews clicked - dispatching toggleShowAllReviews");
|
|
||||||
dispatch(toggleShowAllReviews());
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
|
|
||||||
// 이미지 데이터 가공 (CustomerImages와 동일한 로직)
|
|
||||||
const customerImages = useMemo(() => {
|
|
||||||
if (!reviewListData || !Array.isArray(reviewListData)) {
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const imageReviews = reviewListData.filter(
|
// handleImageClick 제거 - 더 이상 필요없음
|
||||||
(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]);
|
|
||||||
|
// customerImages 로직 제거 - useReviews의 extractImagesFromReviews 사용
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
@@ -201,14 +184,15 @@ export default function UserReviews({ productInfo, panelInfo }) {
|
|||||||
className={css.userReviewsContainer}
|
className={css.userReviewsContainer}
|
||||||
spotlightId="user-reviews-container"
|
spotlightId="user-reviews-container"
|
||||||
>
|
>
|
||||||
<TScroller
|
<UserReviewsScroller
|
||||||
ref={tScrollerRef}
|
ref={tScrollerRef}
|
||||||
className={css.tScroller}
|
className={css.tScroller}
|
||||||
verticalScrollbar="auto"
|
verticalScrollbar="auto"
|
||||||
cbScrollTo={getScrollTo}
|
cbScrollTo={getScrollTo}
|
||||||
key={showAllReviews ? `all-${reviewListData?.length || 0}` : 'limited-5'}
|
forceUpdate={showAllReviews}
|
||||||
|
key={showAllReviews ? `all-${(reviewListData && reviewListData.length) || 0}` : 'limited-5'}
|
||||||
>
|
>
|
||||||
<THeader
|
<THeaderDetail
|
||||||
title={$L(
|
title={$L(
|
||||||
`USER REVIEWS (${reviewTotalCount})`
|
`USER REVIEWS (${reviewTotalCount})`
|
||||||
)}
|
)}
|
||||||
@@ -220,8 +204,13 @@ export default function UserReviews({ productInfo, panelInfo }) {
|
|||||||
className={css.averageOverallRating}
|
className={css.averageOverallRating}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</THeader>
|
</THeaderDetail>
|
||||||
<CustomerImages panelInfo={panelInfo} onImageClick={handleOpenPopup} />
|
<CustomerImages
|
||||||
|
panelInfo={panelInfo}
|
||||||
|
onImageClick={handleOpenPopup}
|
||||||
|
onViewMoreClick={handleOpenPopup}
|
||||||
|
imageData={fallbackReviews.extractImagesFromReviews}
|
||||||
|
/>
|
||||||
<div className={css.reviewItem}>
|
<div className={css.reviewItem}>
|
||||||
<div className={css.showReviewsText}>
|
<div className={css.showReviewsText}>
|
||||||
{$L(
|
{$L(
|
||||||
@@ -235,20 +224,41 @@ export default function UserReviews({ productInfo, panelInfo }) {
|
|||||||
showAllReviews,
|
showAllReviews,
|
||||||
totalReviews: reviewListData.length,
|
totalReviews: reviewListData.length,
|
||||||
reviewsToShowCount: reviewsToShow.length,
|
reviewsToShowCount: reviewsToShow.length,
|
||||||
isShowingAll: showAllReviews
|
isShowingAll: showAllReviews,
|
||||||
|
reviewListData: reviewListData
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 실제 렌더링될 각 리뷰 로그
|
||||||
|
reviewsToShow.forEach((review, index) => {
|
||||||
|
console.log(`[UserReviews] Review ${index + 1}/${reviewsToShow.length}:`, {
|
||||||
|
rvwId: review.rvwId,
|
||||||
|
rvwCtnt: (review.rvwCtnt && review.rvwCtnt.substring(0, 50)) + "...",
|
||||||
|
rvwRtng: review.rvwRtng,
|
||||||
|
hasImages: (review.reviewImageList && review.reviewImageList.length) || 0,
|
||||||
|
rvwRgstDtt: review.rvwRgstDtt,
|
||||||
|
fullReview: review
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return reviewsToShow;
|
return reviewsToShow;
|
||||||
})().map((review, index) => {
|
})().map((review, index, array) => {
|
||||||
const { reviewImageList, rvwRtng, rvwRgstDtt, rvwCtnt, rvwId, wrtrNknm, rvwWrtrId } =
|
const { reviewImageList, rvwRtng, rvwRgstDtt, rvwCtnt, rvwId, wrtrNknm, rvwWrtrId } =
|
||||||
review;
|
review;
|
||||||
console.log(`[UserReviews] Rendering review ${index}:`, { rvwId, hasImages: reviewImageList && reviewImageList.length > 0 });
|
const isLastReview = index === array.length - 1;
|
||||||
|
/* console.log(`[UserReviews] Rendering review ${index}:`, {
|
||||||
|
rvwId,
|
||||||
|
hasImages: reviewImageList && reviewImageList.length > 0,
|
||||||
|
isLastReview,
|
||||||
|
spotlightId: isLastReview ? 'user-review-at-last' : `user-review-${index}`,
|
||||||
|
totalReviews: array.length
|
||||||
|
}); */
|
||||||
return (
|
return (
|
||||||
<SpottableComponent
|
<SpottableComponent
|
||||||
key={`user-reviews-:${rvwId}`}
|
key={`user-reviews-:${rvwId}`}
|
||||||
aria-label={`user-reviews-:${rvwId}`}
|
aria-label={`user-reviews-:${rvwId}`}
|
||||||
className={css.reviewContentContainer}
|
className={css.reviewContentContainer}
|
||||||
onClick={handleReviewClick}
|
onClick={() => handleReviewClick(index)}
|
||||||
spotlightId={`user-review-${index}`}
|
spotlightId={isLastReview ? 'user-review-at-last' : `user-review-${index}`}
|
||||||
>
|
>
|
||||||
{reviewImageList && reviewImageList.length > 0 && (
|
{reviewImageList && reviewImageList.length > 0 && (
|
||||||
<img
|
<img
|
||||||
@@ -284,29 +294,18 @@ export default function UserReviews({ productInfo, panelInfo }) {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{/* View All Reviews 버튼 - 일시적으로 코멘트 처리 */}
|
</UserReviewsScroller>
|
||||||
{/* {!showAllReviews && reviewListData && reviewListData.length > 5 && (
|
|
||||||
<div className={css.viewAllReviewsSection}>
|
|
||||||
<SpottableComponent
|
|
||||||
className={css.viewAllReviewsButton}
|
|
||||||
onClick={handleViewAllReviewsClick}
|
|
||||||
spotlightId="view-all-reviews-button"
|
|
||||||
>
|
|
||||||
<div className={css.viewAllReviewsContent}>
|
|
||||||
<div className={css.viewAllReviewsText}>View All Reviews +</div>
|
|
||||||
</div>
|
|
||||||
</SpottableComponent>
|
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
</TScroller>
|
|
||||||
|
|
||||||
{/* UserReviewsPopup 추가 */}
|
{/* UserReviewsPopup 추가 - 모드별 데이터 전달 */}
|
||||||
<UserReviewsPopup
|
<UserReviewsPopup
|
||||||
open={isPopupOpen}
|
open={isPopupOpen}
|
||||||
onClose={handleClosePopup}
|
onClose={handleClosePopup}
|
||||||
images={customerImages}
|
mode={popupMode}
|
||||||
|
images={fallbackReviews.extractImagesFromReviews}
|
||||||
selectedImageIndex={selectedImageIndex}
|
selectedImageIndex={selectedImageIndex}
|
||||||
onImageClick={handleImageClick}
|
reviewsWithImages={fallbackReviews.getReviewsWithImages}
|
||||||
|
allReviews={fallbackReviews.allReviews} // user-reviews 모드용 전체 리뷰 데이터
|
||||||
|
onModeChange={handleModeChange}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,12 +2,19 @@
|
|||||||
@import "../../../../style/utils.module.less";
|
@import "../../../../style/utils.module.less";
|
||||||
|
|
||||||
.tScroller {
|
.tScroller {
|
||||||
.size(@w: 1124px, @h: auto); // auto height to accommodate dynamic content
|
width: 1124px;
|
||||||
max-width: 1124px;
|
max-width: 1124px;
|
||||||
|
height: auto !important; // 동적 높이 강제 적용
|
||||||
min-height: 500px; // 최소 높이 보장
|
min-height: 500px; // 최소 높이 보장
|
||||||
max-height: none; // 최대 높이 제한 없음
|
max-height: none !important; // 최대 높이 제한 완전 제거
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
// 스크롤 컨테이너 내부도 동적 높이 허용
|
||||||
|
> * {
|
||||||
|
height: auto !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.userReviewsContainer {
|
.userReviewsContainer {
|
||||||
@@ -19,6 +26,8 @@
|
|||||||
.size(@w: 1020px, @h: 36px); // CustomerImages와 일치하도록 크기 조정
|
.size(@w: 1020px, @h: 36px); // CustomerImages와 일치하도록 크기 조정
|
||||||
max-width: 1020px;
|
max-width: 1020px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
.size(@w:100%,@h:100%);
|
.size(@w:100%,@h:100%);
|
||||||
@@ -27,6 +36,9 @@
|
|||||||
|
|
||||||
.averageOverallRating {
|
.averageOverallRating {
|
||||||
.size(@w: 176px,@h:30px);
|
.size(@w: 176px,@h:30px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
@@ -34,6 +46,8 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
color: rgba(234, 234, 234, 1);
|
color: rgba(234, 234, 234, 1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +113,15 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.viewAllReviewsText {
|
||||||
|
color: rgba(234, 234, 234, 1);
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 24px);
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.reviewText {
|
.reviewText {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import css from "./ImageSkeleton.module.less";
|
||||||
|
|
||||||
|
export default function ImageSkeleton({ className }) {
|
||||||
|
return (
|
||||||
|
<div className={`${css.imageSkeleton} ${className || ''}`}>
|
||||||
|
<div className={css.shimmer}></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
@import "../../../../../../style/CommonStyle.module.less";
|
||||||
|
|
||||||
|
.imageSkeleton {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, transparent 37%, #f0f0f0 63%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
border-radius: 12px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.shimmer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0) 0%,
|
||||||
|
rgba(255, 255, 255, 0.4) 50%,
|
||||||
|
rgba(255, 255, 255, 0) 100%
|
||||||
|
);
|
||||||
|
animation: shimmerMove 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -400% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 400% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmerMove {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { useCallback } from "react";
|
import React, { useCallback, useState, useEffect } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import Spottable from "@enact/spotlight/Spottable";
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
||||||
import TNewPopUp from "../../../../../components/TPopUp/TNewPopUp";
|
import TNewPopUp from "../../../../../components/TPopUp/TNewPopUp";
|
||||||
import TButton from "../../../../../components/TButton/TButton";
|
import TButton from "../../../../../components/TButton/TButton";
|
||||||
import { $L } from "../../../../../utils/helperMethods";
|
import { $L } from "../../../../../utils/helperMethods";
|
||||||
|
import UserReviewDetail from "../UserReviewDetail/UserReviewDetail";
|
||||||
|
import ImageSkeleton from "./ImageSkeleton/ImageSkeleton";
|
||||||
import css from "./UserReviewsPopup.module.less";
|
import css from "./UserReviewsPopup.module.less";
|
||||||
|
|
||||||
const SpottableImage = Spottable("div");
|
const SpottableImage = Spottable("div");
|
||||||
@@ -13,7 +15,7 @@ const ContentContainer = SpotlightContainerDecorator(
|
|||||||
{
|
{
|
||||||
enterTo: "default-element",
|
enterTo: "default-element",
|
||||||
preserveId: true,
|
preserveId: true,
|
||||||
defaultElement: "user-review-image-0"
|
// 모드별 기본 요소 처리는 동적으로 설정
|
||||||
},
|
},
|
||||||
"div"
|
"div"
|
||||||
);
|
);
|
||||||
@@ -31,8 +33,11 @@ export default function UserReviewsPopup({
|
|||||||
open = false,
|
open = false,
|
||||||
onClose,
|
onClose,
|
||||||
images = [],
|
images = [],
|
||||||
|
mode = "customer-images", // "customer-images", "all-images", "user-reviews"
|
||||||
selectedImageIndex = 0,
|
selectedImageIndex = 0,
|
||||||
onImageClick,
|
reviewsWithImages = [], // 이미지가 있는 리뷰들
|
||||||
|
allReviews = [], // 모든 리뷰들 (user-reviews 모드용)
|
||||||
|
onModeChange, // 모드 변경 콜백 함수
|
||||||
className,
|
className,
|
||||||
}) {
|
}) {
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
@@ -41,14 +46,114 @@ export default function UserReviewsPopup({
|
|||||||
}
|
}
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const handleImageClick = useCallback((index, image) => {
|
// Customer Images 모드에서 이미지 클릭 시 All Images 모드로 변경
|
||||||
if (onImageClick) {
|
const handleCustomerImageClick = useCallback((index) => {
|
||||||
onImageClick(index, image);
|
console.log("[UserReviewsPopup] Customer image clicked, switching to All Images mode, index:", index);
|
||||||
|
if (onModeChange) {
|
||||||
|
onModeChange("all-images", index);
|
||||||
}
|
}
|
||||||
}, [onImageClick]);
|
}, [onModeChange]);
|
||||||
|
|
||||||
// 최대 8개 이미지만 표시
|
// 모드별 헤더 정보
|
||||||
const displayImages = images.slice(0, 8);
|
const getHeaderInfo = useCallback((mode) => {
|
||||||
|
switch (mode) {
|
||||||
|
case "all-images":
|
||||||
|
return {
|
||||||
|
title: $L("All images"),
|
||||||
|
hasIcon: true,
|
||||||
|
iconType: "all-images"
|
||||||
|
};
|
||||||
|
case "user-reviews":
|
||||||
|
return {
|
||||||
|
title: $L("User Reviews"),
|
||||||
|
hasIcon: true,
|
||||||
|
iconType: "user-reviews"
|
||||||
|
};
|
||||||
|
case "customer-images":
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: $L("Customer Images"),
|
||||||
|
hasIcon: false,
|
||||||
|
iconType: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const headerInfo = getHeaderInfo(mode);
|
||||||
|
|
||||||
|
// All-Images 및 User-Reviews 모드를 위한 상태
|
||||||
|
const [currentReviewIndex, setCurrentReviewIndex] = useState(0);
|
||||||
|
|
||||||
|
// 모드별 리뷰 인덱스 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "all-images" && images && images[selectedImageIndex]) {
|
||||||
|
const selectedImage = images[selectedImageIndex];
|
||||||
|
const reviewIndex = reviewsWithImages.findIndex(review =>
|
||||||
|
review.rvwId === selectedImage.reviewId
|
||||||
|
);
|
||||||
|
if (reviewIndex !== -1) {
|
||||||
|
setCurrentReviewIndex(reviewIndex);
|
||||||
|
}
|
||||||
|
} else if (mode === "user-reviews") {
|
||||||
|
// User-Reviews 모드: selectedImageIndex를 그대로 사용
|
||||||
|
setCurrentReviewIndex(selectedImageIndex);
|
||||||
|
}
|
||||||
|
}, [mode, selectedImageIndex, images, reviewsWithImages, allReviews]);
|
||||||
|
|
||||||
|
// 리뷰 네비게이션 핸들러 (All-Images 및 User-Reviews 모드)
|
||||||
|
const handlePreviousReview = useCallback(() => {
|
||||||
|
if (currentReviewIndex > 0) {
|
||||||
|
setCurrentReviewIndex(currentReviewIndex - 1);
|
||||||
|
}
|
||||||
|
}, [currentReviewIndex]);
|
||||||
|
|
||||||
|
const handleNextReview = useCallback(() => {
|
||||||
|
const maxIndex = mode === "user-reviews"
|
||||||
|
? allReviews.length - 1
|
||||||
|
: reviewsWithImages.length - 1;
|
||||||
|
|
||||||
|
if (currentReviewIndex < maxIndex) {
|
||||||
|
setCurrentReviewIndex(currentReviewIndex + 1);
|
||||||
|
}
|
||||||
|
}, [currentReviewIndex, mode, allReviews.length, reviewsWithImages.length]);
|
||||||
|
|
||||||
|
// All Images 아이콘 컴포넌트 (PNG 파일 사용)
|
||||||
|
const AllImagesIcon = () => (
|
||||||
|
<div className={css.headerIcon} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// User Reviews 아이콘 컴포넌트 (추후 구현)
|
||||||
|
const UserReviewsIcon = () => (
|
||||||
|
<div className={css.headerIcon}>
|
||||||
|
{/* User Reviews 아이콘 내용 */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 이미지 로딩 상태 관리
|
||||||
|
const [imageLoadStates, setImageLoadStates] = useState({});
|
||||||
|
|
||||||
|
const handleImageLoad = useCallback((index) => {
|
||||||
|
setImageLoadStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[index]: true
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleImageError = useCallback((index) => {
|
||||||
|
console.error(`[UserReviewsPopup] Image load failed for index: ${index}`);
|
||||||
|
setImageLoadStates(prev => ({
|
||||||
|
...prev,
|
||||||
|
[index]: false
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 모드가 변경되면 이미지 로딩 상태 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
setImageLoadStates({});
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
// 모든 이미지 표시
|
||||||
|
const displayImages = images;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TNewPopUp
|
<TNewPopUp
|
||||||
@@ -58,34 +163,93 @@ export default function UserReviewsPopup({
|
|||||||
className={classNames(css.userReviewsPopup, className)}
|
className={classNames(css.userReviewsPopup, className)}
|
||||||
>
|
>
|
||||||
<div className={css.popupContainer}>
|
<div className={css.popupContainer}>
|
||||||
{/* Header */}
|
{/* Header - 모드별 아이콘과 제목 */}
|
||||||
<div className={css.header}>
|
<div className={css.header}>
|
||||||
|
{headerInfo.hasIcon && (
|
||||||
|
<>
|
||||||
|
{headerInfo.iconType === "all-images" && <AllImagesIcon />}
|
||||||
|
{headerInfo.iconType === "user-reviews" && <UserReviewsIcon />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className={css.headerTitle}>
|
<div className={css.headerTitle}>
|
||||||
{$L("Customer Images")}
|
{headerInfo.title}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content - 모드별 내용 */}
|
||||||
<ContentContainer className={css.content}>
|
<ContentContainer
|
||||||
<div className={css.imageGrid}>
|
className={css.content}
|
||||||
{displayImages.map((image, index) => (
|
defaultElement={
|
||||||
<SpottableImage
|
mode === "all-images" ? "review-detail-prev" :
|
||||||
key={`user-review-image-${index}`}
|
mode === "user-reviews" ? "user-review-detail-prev" :
|
||||||
spotlightId={`user-review-image-${index}`}
|
"user-review-image-0"
|
||||||
className={classNames(
|
}
|
||||||
css.imageItem,
|
>
|
||||||
selectedImageIndex === index && css.selectedImage
|
{mode === "customer-images" && (
|
||||||
)}
|
<div className={css.imageGrid}>
|
||||||
onClick={() => handleImageClick(index, image)}
|
{displayImages.map((image, index) => {
|
||||||
>
|
const isLoaded = imageLoadStates[index] === true;
|
||||||
<img
|
const isFailed = imageLoadStates[index] === false;
|
||||||
src={image.imgUrl || image}
|
|
||||||
alt={`Customer review ${index + 1}`}
|
return (
|
||||||
className={css.image}
|
<SpottableImage
|
||||||
/>
|
key={`user-review-image-${index}`}
|
||||||
</SpottableImage>
|
spotlightId={`user-review-image-${index}`}
|
||||||
))}
|
className={css.imageItem}
|
||||||
</div>
|
onClick={() => handleCustomerImageClick(index)}
|
||||||
|
>
|
||||||
|
{/* Skeleton UI - 이미지 로딩 중일 때 표시 */}
|
||||||
|
{!isLoaded && !isFailed && (
|
||||||
|
<ImageSkeleton className={css.image} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 실제 이미지 */}
|
||||||
|
<img
|
||||||
|
src={image.imgUrl || image}
|
||||||
|
alt={`Customer review ${index + 1}`}
|
||||||
|
className={css.image}
|
||||||
|
style={{
|
||||||
|
opacity: isLoaded ? 1 : 0,
|
||||||
|
transition: 'opacity 0.3s ease-in-out'
|
||||||
|
}}
|
||||||
|
onLoad={() => handleImageLoad(index)}
|
||||||
|
onError={() => handleImageError(index)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 이미지 로드 실패 시 표시할 플레이스홀더 */}
|
||||||
|
{isFailed && (
|
||||||
|
<div className={css.imagePlaceholder}>
|
||||||
|
<div className={css.placeholderText}>이미지 없음</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SpottableImage>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "all-images" && (
|
||||||
|
<UserReviewDetail
|
||||||
|
currentReview={reviewsWithImages[currentReviewIndex]}
|
||||||
|
currentIndex={currentReviewIndex}
|
||||||
|
totalReviews={reviewsWithImages.length}
|
||||||
|
onPrevious={handlePreviousReview}
|
||||||
|
onNext={handleNextReview}
|
||||||
|
className={css.reviewDetailContainer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "user-reviews" && (
|
||||||
|
<UserReviewDetail
|
||||||
|
currentReview={allReviews[currentReviewIndex]}
|
||||||
|
currentIndex={currentReviewIndex}
|
||||||
|
totalReviews={allReviews.length}
|
||||||
|
onPrevious={handlePreviousReview}
|
||||||
|
onNext={handleNextReview}
|
||||||
|
className={css.reviewDetailContainer}
|
||||||
|
spotlightId="user-review-detail"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</ContentContainer>
|
</ContentContainer>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
// Header 영역
|
// Header 영역 - 모드별 아이콘과 제목 지원 (기존 레이아웃 유지)
|
||||||
.header {
|
.header {
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
@@ -22,6 +22,17 @@
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
// All Images 아이콘 (PNG 파일 사용)
|
||||||
|
.headerIcon {
|
||||||
|
width: 52px;
|
||||||
|
height: 41px;
|
||||||
|
background-image: url("../../../../../../assets/images/detailpanel/all-images-icon.png");
|
||||||
|
background-position: center;
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
margin-right: 15px; // gap: 15 → margin-right: 15px
|
||||||
|
}
|
||||||
|
|
||||||
.headerTitle {
|
.headerTitle {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: black;
|
color: black;
|
||||||
@@ -45,27 +56,30 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
.imageGrid {
|
.imageGrid {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center; // 중앙 정렬로 변경
|
justify-content: center; // 중앙 정렬로 균등 배치
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-content: center;
|
align-content: flex-start;
|
||||||
overflow: hidden; // 스크롤 완전 제거
|
overflow-y: scroll; // 스크롤바 항상 표시
|
||||||
max-height: 100%;
|
overflow-x: hidden;
|
||||||
|
padding: 30px 40px; // 좌우 패딩 증가
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
// gap 대신 margin 사용 (TV 호환성)
|
// gap 대신 margin 사용 (TV 호환성) - 화면을 적절히 채우도록 조정
|
||||||
.imageItem {
|
.imageItem {
|
||||||
width: 226px;
|
width: 210px; // 크기 약간 증가
|
||||||
height: 218px;
|
height: 190px; // 비율 맞춤
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-right: 20px;
|
margin-right: 35px; // 마진 증가로 균등 분배
|
||||||
margin-bottom: 20px;
|
margin-bottom: 30px; // 세로 마진도 증가
|
||||||
|
|
||||||
// 3개씩 배치하므로 3번째마다 margin-right 제거
|
// 4개씩 배치하므로 4번째마다 margin-right 제거
|
||||||
&:nth-child(3n) {
|
&:nth-child(4n) {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,15 +87,8 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
.focused(@boxShadow: 0px, @borderRadius: 12px);
|
||||||
position: absolute;
|
// 프로젝트 표준 포커스 스타일 사용 (4px solid @PRIMARY_COLOR_RED)
|
||||||
top: -2px; // 포커스 위치 미세 조정
|
|
||||||
left: -2px; // 포커스 위치 미세 조정
|
|
||||||
width: calc(100% + 4px); // 크기 미세 조정
|
|
||||||
height: calc(100% + 4px); // 크기 미세 조정
|
|
||||||
border: 4px solid #C70850;
|
|
||||||
border-radius: 12px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,32 +101,40 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 선택된 이미지 스타일
|
// 이미지 로드 실패 시 플레이스홀더
|
||||||
&.selectedImage {
|
.imagePlaceholder {
|
||||||
&::after {
|
position: absolute;
|
||||||
content: '';
|
top: 0;
|
||||||
position: absolute;
|
left: 0;
|
||||||
top: 0;
|
right: 0;
|
||||||
left: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
background: #f5f5f5;
|
||||||
height: 100%;
|
border-radius: 12px;
|
||||||
border: 4px solid #C70850;
|
display: flex;
|
||||||
border-radius: 12px;
|
justify-content: center;
|
||||||
pointer-events: none;
|
align-items: center;
|
||||||
|
|
||||||
|
.placeholderText {
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: @baseFont;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selectedImage 스타일 제거 - 오직 포커스만 사용
|
||||||
}
|
}
|
||||||
|
|
||||||
// View More 아이템
|
// View More 아이템
|
||||||
.viewMoreItem {
|
.viewMoreItem {
|
||||||
width: 226px;
|
width: 210px; // 크기 증가
|
||||||
height: 218px;
|
height: 190px; // 비율 맞춤
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-right: 20px;
|
margin-right: 35px; // 마진 증가
|
||||||
margin-bottom: 20px;
|
margin-bottom: 30px; // 세로 마진 증가
|
||||||
|
|
||||||
&:nth-child(3n) {
|
&:nth-child(4n) {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,10 +171,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 커스텀 스크롤바 (숨김)
|
// User Reviews 모드용 reviewGrid 스타일
|
||||||
.scrollbar {
|
.reviewGrid {
|
||||||
display: none; // 스크롤바 완전 숨김
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 30px 40px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
// User Reviews 콘텐츠 스타일 - 추후 구현
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserReviewDetail 컴포넌트를 위한 컨테이너
|
||||||
|
.reviewDetailContainer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크롤바 표시 (기본 브라우저 스타일 사용)
|
||||||
|
// 별도 커스텀 스크롤바 스타일 없음
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer 영역
|
// Footer 영역
|
||||||
@@ -189,11 +222,14 @@
|
|||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
background: @PRIMARY_COLOR_RED !important;
|
background: @PRIMARY_COLOR_RED !important;
|
||||||
outline: 2px solid @PRIMARY_COLOR_RED !important;
|
color: white !important;
|
||||||
|
outline: none !important;
|
||||||
|
border: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: lighten(#7A808D, 10%) !important;
|
background: @PRIMARY_COLOR_RED !important;
|
||||||
|
color: white !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React, { useCallback, useEffect } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
|
import Spotlight from "@enact/spotlight";
|
||||||
|
import { toggleShowAllReviews } from "../../../../actions/productActions";
|
||||||
|
import css from "./ViewAllReviewsButton.module.less";
|
||||||
|
|
||||||
|
const SpottableComponent = Spottable("div");
|
||||||
|
|
||||||
|
const ViewAllReviewsButton = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
// Redux에서 리뷰 데이터와 showAllReviews 상태 가져오기
|
||||||
|
const showAllReviews = useSelector((state) => state.product.showAllReviews);
|
||||||
|
const reviewListData = useSelector(
|
||||||
|
(state) => state.product.reviewData && state.product.reviewData.reviewList
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleViewAllReviewsClick = useCallback(() => {
|
||||||
|
console.log("[ViewAllReviewsButton] View All Reviews clicked - dispatching toggleShowAllReviews");
|
||||||
|
dispatch(toggleShowAllReviews());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
// Container 경계 문제로 인한 프로그래매틱 포커스 해결책
|
||||||
|
const handleKeyDown = useCallback((event) => {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
console.log("[ViewAllReviewsButton] ArrowUp key pressed - attempting to focus last review");
|
||||||
|
|
||||||
|
// 스크롤 위치 보존
|
||||||
|
const container = document.querySelector('[data-spotlight-id="user-reviews-container"]');
|
||||||
|
const savedScrollTop = container?.scrollTop || 0;
|
||||||
|
|
||||||
|
// 잠시 후에 포커스 이동 시도 (DOM 업데이트를 기다리기 위해)
|
||||||
|
setTimeout(() => {
|
||||||
|
const lastReviewElement = document.querySelector('[data-spotlight-id="user-review-at-last"]');
|
||||||
|
if (lastReviewElement) {
|
||||||
|
console.log("[ViewAllReviewsButton] Found last review element, focusing...");
|
||||||
|
Spotlight.focus(lastReviewElement);
|
||||||
|
// 스크롤 위치 복원
|
||||||
|
if (container) container.scrollTop = savedScrollTop;
|
||||||
|
} else {
|
||||||
|
console.log("[ViewAllReviewsButton] Last review element not found, trying alternative...");
|
||||||
|
// 대안: 마지막 리뷰를 spotlight ID로 찾기
|
||||||
|
const reviewElements = document.querySelectorAll('[data-spotlight-id^="user-review-"]');
|
||||||
|
if (reviewElements.length > 0) {
|
||||||
|
const lastElement = reviewElements[reviewElements.length - 1];
|
||||||
|
console.log("[ViewAllReviewsButton] Focusing last review element (alternative method)");
|
||||||
|
Spotlight.focus(lastElement);
|
||||||
|
// 스크롤 위치 복원
|
||||||
|
if (container) container.scrollTop = savedScrollTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 디버깅: 컴포넌트 마운트 시 로그
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[ViewAllReviewsButton] Component mounted/updated", {
|
||||||
|
showAllReviews,
|
||||||
|
hasReviewData: !!reviewListData,
|
||||||
|
reviewCount: reviewListData?.length || 0,
|
||||||
|
spotlightUpTarget: "user-review-at-last"
|
||||||
|
});
|
||||||
|
}, [showAllReviews, reviewListData]);
|
||||||
|
|
||||||
|
// showAllReviews가 true이거나 리뷰가 5개 이하면 버튼 숨김
|
||||||
|
if (showAllReviews || !reviewListData || reviewListData.length <= 5) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.viewAllReviews__wrapper}>
|
||||||
|
<SpottableComponent
|
||||||
|
className={css.viewAllReviews__button}
|
||||||
|
onClick={handleViewAllReviewsClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
spotlightId="view-all-reviews-button"
|
||||||
|
data-spotlight-up="user-review-at-last"
|
||||||
|
>
|
||||||
|
View All Reviews +
|
||||||
|
</SpottableComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ViewAllReviewsButton;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
@import "../../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../../style/utils.module.less";
|
||||||
|
|
||||||
|
// ViewAllReviewsButton 컴포넌트 전체 컨테이너
|
||||||
|
.viewAllReviews {
|
||||||
|
// 외부 래퍼 컨테이너 (중앙 정렬과 간격을 담당)
|
||||||
|
&__wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 버튼 컨테이너 (실제 버튼 스타일)
|
||||||
|
&__button {
|
||||||
|
width: 260px;
|
||||||
|
height: 65px; // JSX의 인라인 스타일에서 가져옴
|
||||||
|
background-color: transparent;
|
||||||
|
border: 2px solid white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: @COLOR_WHITE;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background-color: @PRIMARY_COLOR_RED;
|
||||||
|
border: 2px solid @PRIMARY_COLOR_RED; // 포커스 시 테두리도 빨간색으로 변경
|
||||||
|
color: @COLOR_WHITE; // 포커스 시 텍스트 색상도 흰색으로 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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 css from "./THeaderDetail.module.less";
|
||||||
|
|
||||||
|
const Container = SpotlightContainerDecorator(
|
||||||
|
{ enterTo: "last-focused" },
|
||||||
|
"div"
|
||||||
|
);
|
||||||
|
const SpottableComponent = Spottable("button");
|
||||||
|
|
||||||
|
export default function THeaderDetail({
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
onBackButton,
|
||||||
|
onSpotlightUp,
|
||||||
|
onSpotlightLeft,
|
||||||
|
marqueeDisabled = true,
|
||||||
|
onClick,
|
||||||
|
ariaLabel,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
const convertedTitle = useMemo(() => {
|
||||||
|
if (title) {
|
||||||
|
const cleanedTitle = title.replace(/(\r\n|\n)/g, "");
|
||||||
|
return $L(marqueeDisabled ? title : cleanedTitle);
|
||||||
|
}
|
||||||
|
}, [marqueeDisabled, title]);
|
||||||
|
|
||||||
|
const _onClick = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (onClick) {
|
||||||
|
onClick(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _onSpotlightUp = (e) => {
|
||||||
|
if (onSpotlightUp) {
|
||||||
|
onSpotlightUp(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const _onSpotlightLeft = (e) => {
|
||||||
|
if (onSpotlightLeft) {
|
||||||
|
onSpotlightLeft(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className={classNames(css.tHeader, className)} {...rest}>
|
||||||
|
{onBackButton && (
|
||||||
|
<SpottableComponent
|
||||||
|
className={css.button}
|
||||||
|
onClick={_onClick}
|
||||||
|
spotlightId={"spotlightId_backBtn"}
|
||||||
|
onSpotlightUp={_onSpotlightUp}
|
||||||
|
onSpotlightLeft={_onSpotlightLeft}
|
||||||
|
aria-label="Back"
|
||||||
|
role="button"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Marquee
|
||||||
|
marqueeOn="render"
|
||||||
|
className={css.title}
|
||||||
|
marqueeDisabled={marqueeDisabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{convertedTitle && (
|
||||||
|
<span dangerouslySetInnerHTML={{ __html: convertedTitle }} />
|
||||||
|
)}
|
||||||
|
</Marquee>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
@import "../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.tHeader {
|
||||||
|
width: 100%;
|
||||||
|
height: 90px;
|
||||||
|
color: #222;
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: transparent;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
width: 1788px;
|
||||||
|
font-size: 42px;
|
||||||
|
padding-left: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
|
||||||
|
&: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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useMemo,
|
||||||
|
} 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 "./UserReviewsScroller.module.less";
|
||||||
|
|
||||||
|
export default function UserReviewsScroller({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
verticalScrollbar = "hidden",
|
||||||
|
focusableScrollbar = false,
|
||||||
|
direction = "vertical",
|
||||||
|
horizontalScrollbar = "hidden",
|
||||||
|
scrollMode,
|
||||||
|
onScrollStart,
|
||||||
|
onScrollStop,
|
||||||
|
onScroll,
|
||||||
|
noScrollByWheel = false,
|
||||||
|
cbScrollTo,
|
||||||
|
autoScroll = direction === "horizontal",
|
||||||
|
setScrollVerticalPos,
|
||||||
|
setCheckScrollPosition,
|
||||||
|
forceUpdate = false, // 새로운 prop: 스크롤 영역 강제 업데이트용
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
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 scrollerRef = useRef(null); // Scroller 참조 추가
|
||||||
|
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
|
||||||
|
return () => setIsMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// forceUpdate prop 변경 시 스크롤 영역 재계산 (Enact 방식)
|
||||||
|
useEffect(() => {
|
||||||
|
if (forceUpdate && scrollerRef.current) {
|
||||||
|
console.log("[UserReviewsScroller] Force updating scroll area using Enact methods");
|
||||||
|
|
||||||
|
// Enact Job을 사용한 안전한 비동기 처리
|
||||||
|
const updateJob = new Job(() => {
|
||||||
|
if (scrollerRef.current) {
|
||||||
|
// Enact Scroller의 scrollTo 메서드로 스크롤 영역 강제 재계산
|
||||||
|
if (scrollerRef.current.scrollTo) {
|
||||||
|
// 현재 위치에서 0px 이동으로 스크롤 영역 재계산 트리거
|
||||||
|
scrollerRef.current.scrollTo({ position: { y: 0 }, animate: false });
|
||||||
|
console.log("[UserReviewsScroller] Scroll area recalculated via scrollTo");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 추가: Enact의 내장 업데이트 메서드 시도
|
||||||
|
if (scrollerRef.current.update) {
|
||||||
|
scrollerRef.current.update();
|
||||||
|
console.log("[UserReviewsScroller] Scroller updated via update method");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
updateJob.start();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (updateJob) {
|
||||||
|
updateJob.stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [forceUpdate]);
|
||||||
|
|
||||||
|
const _onScrollStart = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (onScrollStart) {
|
||||||
|
onScrollStart(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
isScrolling.current = true;
|
||||||
|
},
|
||||||
|
[onScrollStart]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _onScrollStop = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (onScrollStop) {
|
||||||
|
onScrollStop(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
isScrolling.current = false;
|
||||||
|
|
||||||
|
if (e.reachedEdgeInfo) {
|
||||||
|
if (e.reachedEdgeInfo.top) {
|
||||||
|
scrollPosition.current = "top";
|
||||||
|
} else if (e.reachedEdgeInfo.bottom) {
|
||||||
|
scrollPosition.current = "bottom";
|
||||||
|
} else if (e.reachedEdgeInfo.left) {
|
||||||
|
scrollPosition.current = "left";
|
||||||
|
} else if (e.reachedEdgeInfo.right) {
|
||||||
|
scrollPosition.current = "right";
|
||||||
|
} else {
|
||||||
|
scrollPosition.current = "middle";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scrollPosition.current = "middle";
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollHorizontalPos.current = e.scrollLeft;
|
||||||
|
scrollVerticalPos.current = e.scrollTop;
|
||||||
|
|
||||||
|
if (setScrollVerticalPos) {
|
||||||
|
setScrollVerticalPos(scrollVerticalPos.current);
|
||||||
|
}
|
||||||
|
if (setCheckScrollPosition) {
|
||||||
|
setCheckScrollPosition(scrollPosition.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onScrollStop]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _onScroll = useCallback(
|
||||||
|
(ev) => {
|
||||||
|
if (onScroll) {
|
||||||
|
onScroll(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onScroll]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _cbScrollTo = useCallback(
|
||||||
|
(ref) => {
|
||||||
|
if (cbScrollTo) {
|
||||||
|
cbScrollTo(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToRef.current = ref;
|
||||||
|
},
|
||||||
|
[cbScrollTo]
|
||||||
|
);
|
||||||
|
|
||||||
|
const relevantPositions = useMemo(() => {
|
||||||
|
switch (direction) {
|
||||||
|
case "horizontal":
|
||||||
|
return ["left", "right"];
|
||||||
|
case "vertical":
|
||||||
|
return ["top", "bottom"];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [direction]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
className ? className : null,
|
||||||
|
css.scrollerContainer
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Scroller
|
||||||
|
{...rest}
|
||||||
|
ref={scrollerRef}
|
||||||
|
cbScrollTo={_cbScrollTo}
|
||||||
|
onScrollStart={_onScrollStart}
|
||||||
|
onScrollStop={_onScrollStop}
|
||||||
|
onScroll={_onScroll}
|
||||||
|
scrollMode={scrollMode || "translate"}
|
||||||
|
focusableScrollbar={focusableScrollbar}
|
||||||
|
className={classNames(
|
||||||
|
isMounted && css.tScroller,
|
||||||
|
noScrollByWheel && css.preventScroll
|
||||||
|
)}
|
||||||
|
direction={direction}
|
||||||
|
horizontalScrollbar={horizontalScrollbar}
|
||||||
|
verticalScrollbar={verticalScrollbar}
|
||||||
|
overscrollEffectOn={{
|
||||||
|
arrowKey: false,
|
||||||
|
drag: false,
|
||||||
|
pageKey: false,
|
||||||
|
track: false,
|
||||||
|
wheel: false,
|
||||||
|
}}
|
||||||
|
noScrollByWheel={noScrollByWheel}
|
||||||
|
noScrollByDrag
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Scroller>
|
||||||
|
{cursorVisible &&
|
||||||
|
autoScroll &&
|
||||||
|
relevantPositions.map((pos) => (
|
||||||
|
<AutoScrollArea
|
||||||
|
key={pos}
|
||||||
|
position={POSITION[pos]}
|
||||||
|
autoScroll={autoScroll}
|
||||||
|
scrollHorizontalPos={scrollHorizontalPos}
|
||||||
|
scrollVerticalPos={scrollVerticalPos}
|
||||||
|
scrollToRef={scrollToRef}
|
||||||
|
scrollPosition={scrollPosition}
|
||||||
|
direction={direction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
@import "../../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../../style/utils.module.less";
|
||||||
|
|
||||||
|
@scrollBar: 0.625rem;
|
||||||
|
@scrollCircle: 1.6667rem;
|
||||||
|
@barColor: transparent;
|
||||||
|
@focusedBarColor: #transparent;
|
||||||
|
@verTrackColor: #f1efec;
|
||||||
|
|
||||||
|
.scrollerContainer {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
height: auto; // 동적 높이 허용
|
||||||
|
min-height: 500px; // 최소 높이 보장
|
||||||
|
max-height: none; // 최대 높이 제한 없음
|
||||||
|
|
||||||
|
.tScroller {
|
||||||
|
width: 100%;
|
||||||
|
height: auto; // 동적 높이 허용
|
||||||
|
min-height: inherit; // 부모의 최소 높이 상속
|
||||||
|
max-height: none; // 높이 제한 없음
|
||||||
|
|
||||||
|
> div:nth-child(2) {
|
||||||
|
padding: 0;
|
||||||
|
> div:nth-child(1) {
|
||||||
|
background-color: #e7e7e7;
|
||||||
|
width: 8px;
|
||||||
|
border-radius: 0;
|
||||||
|
> div {
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: #7a808d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.preventScroll {
|
||||||
|
>div{
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크롤 컨테이너 최적화
|
||||||
|
> div {
|
||||||
|
height: auto !important; // 동적 높이 강제 적용
|
||||||
|
min-height: 500px; // 최소 높이 보장
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,7 @@ import ThemeCurationPanel from "../ThemeCurationPanel/ThemeCurationPanel";
|
|||||||
import TrendingNowPanel from "../TrendingNowPanel/TrendingNowPanel";
|
import TrendingNowPanel from "../TrendingNowPanel/TrendingNowPanel";
|
||||||
import VideoTestPanel from "../VideoTestPanel/VideoTestPanel";
|
import VideoTestPanel from "../VideoTestPanel/VideoTestPanel";
|
||||||
import WelcomeEventPanel from "../WelcomeEventPanel/WelcomeEventPanel";
|
import WelcomeEventPanel from "../WelcomeEventPanel/WelcomeEventPanel";
|
||||||
|
import UserReviewPanel from "../UserReview/UserReviewPanel";
|
||||||
import OptionalTermsConfirm from "../../components/Optional/OptionalTermsConfirm";
|
import OptionalTermsConfirm from "../../components/Optional/OptionalTermsConfirm";
|
||||||
import OptionalTermsConfirmBottom from "../../components/Optional/OptionalTermsConfirmBottom";
|
import OptionalTermsConfirmBottom from "../../components/Optional/OptionalTermsConfirmBottom";
|
||||||
import css from "./MainView.module.less";
|
import css from "./MainView.module.less";
|
||||||
@@ -122,6 +123,7 @@ const panelMap = {
|
|||||||
[Config.panel_names.THEME_CURATION_PANEL]: ThemeCurationPanel,
|
[Config.panel_names.THEME_CURATION_PANEL]: ThemeCurationPanel,
|
||||||
[Config.panel_names.IMAGE_PANEL]: ImagePanel,
|
[Config.panel_names.IMAGE_PANEL]: ImagePanel,
|
||||||
[Config.panel_names.CONFIRM_PANEL]: ConfirmPanel,
|
[Config.panel_names.CONFIRM_PANEL]: ConfirmPanel,
|
||||||
|
[Config.panel_names.USER_REVIEW_PANEL]: UserReviewPanel,
|
||||||
// [Config.panel_names.OPTIONAL_TERMS_PANEL]: TermsOfOptional,
|
// [Config.panel_names.OPTIONAL_TERMS_PANEL]: TermsOfOptional,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
107
com.twin.app.shoptime/src/views/UserReview/ShowUserReviews.jsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React, { useCallback, useEffect } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
|
import Spotlight from "@enact/spotlight";
|
||||||
|
import { pushPanel } from "../../actions/panelActions";
|
||||||
|
import { panel_names } from "../../utils/Config";
|
||||||
|
import css from "./ShowUserReviews.module.less";
|
||||||
|
|
||||||
|
const SpottableComponent = Spottable("div");
|
||||||
|
|
||||||
|
const ShowUserReviews = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
// Redux에서 리뷰 데이터와 제품 데이터 가져오기
|
||||||
|
const reviewListData = useSelector(
|
||||||
|
(state) => state.product.reviewData && state.product.reviewData.reviewList
|
||||||
|
);
|
||||||
|
const reviewData = useSelector((state) => state.product.reviewData || {});
|
||||||
|
const productData = useSelector((state) => state.main.productData || {});
|
||||||
|
|
||||||
|
const handleShowUserReviewsClick = useCallback(() => {
|
||||||
|
console.log(`[ProductId] Show User Reviews clicked - opening UserReviewPanel`, {
|
||||||
|
productDataPrdtId: productData.prdtId,
|
||||||
|
hasProductData: !!productData,
|
||||||
|
reviewData: reviewData,
|
||||||
|
reviewDetailTotRvwCnt: reviewData.reviewDetail?.totRvwCnt,
|
||||||
|
reviewListLength: reviewListData?.length,
|
||||||
|
productData: productData
|
||||||
|
});
|
||||||
|
dispatch(
|
||||||
|
pushPanel({
|
||||||
|
name: panel_names.USER_REVIEW_PANEL,
|
||||||
|
panelInfo: {
|
||||||
|
prdtId: productData.prdtId,
|
||||||
|
productImage: productData.imgUrls600?.[0] || productData.imgUrls?.[0] || productData.thumbnailUrl || 'https://placehold.co/150x150',
|
||||||
|
brandLogo: productData.patncLogoPath || 'https://placehold.co/50x50',
|
||||||
|
productName: productData.prdtNm || '상품명 정보가 없습니다',
|
||||||
|
avgRating: reviewData.reviewDetail?.avgRvwScr || 5,
|
||||||
|
reviewCount: reviewData.reviewDetail?.totRvwCnt || reviewListData?.length || 0
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [dispatch, productData.prdtId]);
|
||||||
|
|
||||||
|
// Container 경계 문제로 인한 프로그래매틱 포커스 해결책
|
||||||
|
const handleKeyDown = useCallback((event) => {
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
console.log("[ShowUserReviews] ArrowUp key pressed - attempting to focus last review");
|
||||||
|
|
||||||
|
// 스크롤 위치 보존
|
||||||
|
const container = document.querySelector('[data-spotlight-id="user-reviews-container"]');
|
||||||
|
const savedScrollTop = container?.scrollTop || 0;
|
||||||
|
|
||||||
|
// 잠시 후에 포커스 이동 시도 (DOM 업데이트를 기다리기 위해)
|
||||||
|
setTimeout(() => {
|
||||||
|
const lastReviewElement = document.querySelector('[data-spotlight-id="user-review-at-last"]');
|
||||||
|
if (lastReviewElement) {
|
||||||
|
console.log("[ShowUserReviews] Found last review element, focusing...");
|
||||||
|
Spotlight.focus(lastReviewElement);
|
||||||
|
// 스크롤 위치 복원
|
||||||
|
if (container) container.scrollTop = savedScrollTop;
|
||||||
|
} else {
|
||||||
|
console.log("[ShowUserReviews] Last review element not found, trying alternative...");
|
||||||
|
// 대안: 마지막 리뷰를 spotlight ID로 찾기
|
||||||
|
const reviewElements = document.querySelectorAll('[data-spotlight-id^="user-review-"]');
|
||||||
|
if (reviewElements.length > 0) {
|
||||||
|
const lastElement = reviewElements[reviewElements.length - 1];
|
||||||
|
console.log("[ShowUserReviews] Focusing last review element (alternative method)");
|
||||||
|
Spotlight.focus(lastElement);
|
||||||
|
// 스크롤 위치 복원
|
||||||
|
if (container) container.scrollTop = savedScrollTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 디버깅: 컴포넌트 마운트 시 로그
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[ShowUserReviews] Component mounted/updated", {
|
||||||
|
hasReviewData: !!reviewListData,
|
||||||
|
reviewCount: reviewListData?.length || 0,
|
||||||
|
spotlightUpTarget: "user-review-at-last"
|
||||||
|
});
|
||||||
|
}, [reviewListData]);
|
||||||
|
|
||||||
|
// 리뷰가 5개 이하면 버튼 숨김
|
||||||
|
if (!reviewListData || reviewListData.length <= 5) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.showUserReviews__wrapper}>
|
||||||
|
<SpottableComponent
|
||||||
|
className={css.showUserReviews__button}
|
||||||
|
onClick={handleShowUserReviewsClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
spotlightId="show-user-reviews-button"
|
||||||
|
data-spotlight-up="user-review-at-last"
|
||||||
|
>
|
||||||
|
Show User Reviews
|
||||||
|
</SpottableComponent>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShowUserReviews;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
@import "../../style/CommonStyle.module.less";
|
||||||
|
@import "../../style/utils.module.less";
|
||||||
|
|
||||||
|
// ShowUserReviews 컴포넌트 전체 컨테이너
|
||||||
|
.showUserReviews {
|
||||||
|
// 외부 래퍼 컨테이너 (중앙 정렬과 간격을 담당)
|
||||||
|
&__wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 버튼 컨테이너 (실제 버튼 스타일)
|
||||||
|
&__button {
|
||||||
|
width: 260px;
|
||||||
|
height: 65px; // JSX의 인라인 스타일에서 가져옴
|
||||||
|
background-color: transparent;
|
||||||
|
border: 2px solid white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: @COLOR_WHITE;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background-color: @PRIMARY_COLOR_RED;
|
||||||
|
border: 2px solid @PRIMARY_COLOR_RED; // 포커스 시 테두리도 빨간색으로 변경
|
||||||
|
color: @COLOR_WHITE; // 포커스 시 텍스트 색상도 흰색으로 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
||||||
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
|
|
||||||
|
import { $L } from "../../utils/helperMethods";
|
||||||
|
import css from "./UserReviewHeader.module.less";
|
||||||
|
|
||||||
|
const Container = SpotlightContainerDecorator(
|
||||||
|
{ enterTo: "last-focused" },
|
||||||
|
"div"
|
||||||
|
);
|
||||||
|
const SpottableComponent = Spottable("button");
|
||||||
|
|
||||||
|
export default function UserReviewHeader({
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
onBackButton,
|
||||||
|
onSpotlightUp,
|
||||||
|
onSpotlightLeft,
|
||||||
|
marqueeDisabled = true,
|
||||||
|
onClick,
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
|
||||||
|
const _onClick = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (onClick) {
|
||||||
|
onClick(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _onSpotlightUp = useCallback((e) => {
|
||||||
|
if (onSpotlightUp) {
|
||||||
|
onSpotlightUp(e);
|
||||||
|
}
|
||||||
|
}, [onSpotlightUp]);
|
||||||
|
|
||||||
|
const _onSpotlightLeft = useCallback((e) => {
|
||||||
|
if (onSpotlightLeft) {
|
||||||
|
onSpotlightLeft(e);
|
||||||
|
}
|
||||||
|
}, [onSpotlightLeft]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className={classNames(css.tHeaderCustom, className)} {...rest}>
|
||||||
|
{onBackButton && (
|
||||||
|
<SpottableComponent
|
||||||
|
className={css.button}
|
||||||
|
onClick={_onClick}
|
||||||
|
spotlightId={"spotlightId_backBtn"}
|
||||||
|
onSpotlightUp={_onSpotlightUp}
|
||||||
|
onSpotlightLeft={_onSpotlightLeft}
|
||||||
|
aria-label="Back"
|
||||||
|
role="button"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
@import "../../style/CommonStyle.module.less";
|
||||||
|
@import "../../style/utils.module.less";
|
||||||
|
|
||||||
|
.tHeaderCustom {
|
||||||
|
align-self: stretch;
|
||||||
|
margin: 30px 60px; // 상하 30px, 좌우 60px 마진
|
||||||
|
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;
|
||||||
|
line-height: 50px; // 60px 컨테이너에 맞게 조정
|
||||||
|
height: 50px; // 컨테이너 높이에 맞춤
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
.size(@w: 50px, @h: 50px);
|
||||||
|
background-size: 50px 50px;
|
||||||
|
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: 50px, @h: 50px);
|
||||||
|
background-size: contain;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 10px; // 파트너사 로고 후 10px gap
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
import { useDispatch } from "react-redux";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import { popPanel } from "../../actions/panelActions";
|
||||||
|
import THeaderCustom from "../DetailPanel/components/THeaderCustom";
|
||||||
|
import TPanel from "../../components/TPanel/TPanel";
|
||||||
|
import TBody from "../../components/TBody/TBody";
|
||||||
|
import css from "./UserReviewPanel.module.less";
|
||||||
|
|
||||||
|
const UserReviewPanel = ({ className, isOnTop, panelInfo, spotlightId }) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const handleBackButton = useCallback(() => {
|
||||||
|
console.log("[UserReviewPanel] Back button clicked - returning to DetailPanel");
|
||||||
|
dispatch(popPanel());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback((e) => {
|
||||||
|
dispatch(popPanel());
|
||||||
|
e.stopPropagation();
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TPanel
|
||||||
|
isTabActivated={false}
|
||||||
|
className={classNames(css.userReviewPanel, className)}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
spotlightId={spotlightId}
|
||||||
|
>
|
||||||
|
<THeaderCustom
|
||||||
|
title="User Reviews"
|
||||||
|
onBackButton={handleBackButton}
|
||||||
|
onClick={handleBackButton}
|
||||||
|
className={css.header}
|
||||||
|
/>
|
||||||
|
<TBody className={css.tbody} scrollable={false}>
|
||||||
|
<div className={css.container}>
|
||||||
|
<h1>User Review Panel</h1>
|
||||||
|
<p>사용자 리뷰 패널입니다.</p>
|
||||||
|
</div>
|
||||||
|
</TBody>
|
||||||
|
</TPanel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserReviewPanel;
|
||||||
377
com.twin.app.shoptime/src/views/UserReview/UserReviewPanel.jsx
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import React, { useCallback, useEffect } from "react";
|
||||||
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
import { popPanel } from "../../actions/panelActions";
|
||||||
|
import useReviews from "../../hooks/useReviews/useReviews";
|
||||||
|
import UserReviewHeader from "./UserReviewHeader";
|
||||||
|
import TPanel from "../../components/TPanel/TPanel";
|
||||||
|
import TBody from "../../components/TBody/TBody";
|
||||||
|
import StarRating from "../DetailPanel/components/StarRating";
|
||||||
|
import FilterItemButton from "./components/FilterItemButton";
|
||||||
|
import UserReviewsList from "./components/UserReviewsList";
|
||||||
|
import fp from "../../utils/fp";
|
||||||
|
import css from "./UserReviewPanel.module.less";
|
||||||
|
|
||||||
|
const UserReviewPanel = ({ className, panelInfo, spotlightId }) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
// panelInfo에서 prdtId 추출
|
||||||
|
const prdtId = fp.pipe(
|
||||||
|
() => panelInfo,
|
||||||
|
fp.get('prdtId'),
|
||||||
|
fp.defaultTo(null)
|
||||||
|
)();
|
||||||
|
|
||||||
|
// useReviews hook 사용 - Single Filter 시스템
|
||||||
|
const {
|
||||||
|
previewReviews,
|
||||||
|
displayReviews,
|
||||||
|
filteredReviews,
|
||||||
|
applyRatingFilter,
|
||||||
|
applySentimentFilter,
|
||||||
|
clearAllFilters,
|
||||||
|
currentFilter,
|
||||||
|
filterCounts,
|
||||||
|
stats
|
||||||
|
} = useReviews(prdtId);
|
||||||
|
|
||||||
|
// Redux에서 제품 데이터 가져오기 (기존 유지)
|
||||||
|
const productData = useSelector((state) => state.main.productData || {});
|
||||||
|
// 디버깅: 받은 데이터 확인
|
||||||
|
console.log(`[ProductId] UserReviewPanel rendered`, {
|
||||||
|
panelInfo: panelInfo,
|
||||||
|
prdtId: prdtId,
|
||||||
|
productData: productData,
|
||||||
|
productDataPrdtId: productData.prdtId,
|
||||||
|
hasProductData: !!productData,
|
||||||
|
previewReviews: previewReviews ? previewReviews.length : 0,
|
||||||
|
displayReviews: displayReviews ? displayReviews.length : 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// UserReviewPanel은 새로운 API 호출 없이 기존 데이터만 사용
|
||||||
|
console.log("[ProductId] UserReviewPanel 기존 데이터 사용 (API 호출 안함):", {
|
||||||
|
prdtId: prdtId,
|
||||||
|
hasPreviewReviews: !!previewReviews,
|
||||||
|
hasDisplayReviews: !!displayReviews,
|
||||||
|
previewCount: previewReviews ? previewReviews.length : 0,
|
||||||
|
displayCount: displayReviews ? displayReviews.length : 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// 데이터 파싱 - panelInfo에서 직접 가져오기 (Redux productData가 빈 객체가 되는 문제 해결)
|
||||||
|
const productImage = fp.pipe(
|
||||||
|
() => panelInfo,
|
||||||
|
fp.get('productImage'),
|
||||||
|
fp.defaultTo('https://placehold.co/150x150')
|
||||||
|
)();
|
||||||
|
|
||||||
|
const brandLogo = fp.pipe(
|
||||||
|
() => panelInfo,
|
||||||
|
fp.get('brandLogo'),
|
||||||
|
fp.defaultTo('https://placehold.co/50x50')
|
||||||
|
)();
|
||||||
|
|
||||||
|
const productId = fp.pipe(
|
||||||
|
() => panelInfo,
|
||||||
|
fp.get('prdtId'),
|
||||||
|
fp.defaultTo(null)
|
||||||
|
)();
|
||||||
|
|
||||||
|
const productName = fp.pipe(
|
||||||
|
() => panelInfo,
|
||||||
|
fp.get('productName'),
|
||||||
|
fp.defaultTo('상품명 정보가 없습니다')
|
||||||
|
)();
|
||||||
|
|
||||||
|
// 필터링된 리뷰에서 처음 4개만 사용 (UserReviewPanel용)
|
||||||
|
const userReviewPanelReviews = fp.pipe(
|
||||||
|
() => filteredReviews || [],
|
||||||
|
(reviews) => reviews.slice(0, 4)
|
||||||
|
)();
|
||||||
|
|
||||||
|
// 통계 정보 - stats에서 가져오기
|
||||||
|
const reviewCount = stats.totalReviews || 0; // 전체 리뷰 개수
|
||||||
|
const filteredCount = stats.filteredCount || 0; // 필터링된 리뷰 개수
|
||||||
|
const avgRating = stats.averageRating || 5;
|
||||||
|
|
||||||
|
// 별점 필터링 핸들러들
|
||||||
|
const handleRatingFilter = useCallback((rating) => {
|
||||||
|
console.log('[ProductId] Rating filter applied:', rating);
|
||||||
|
console.log('[ProductId] applyRatingFilter function:', !!applyRatingFilter);
|
||||||
|
applyRatingFilter(rating); // 'all' 값을 그대로 전달
|
||||||
|
}, [applyRatingFilter]);
|
||||||
|
|
||||||
|
const handleAllStarsFilter = useCallback(() => handleRatingFilter('all'), [handleRatingFilter]);
|
||||||
|
const handle5StarsFilter = useCallback(() => handleRatingFilter(5), [handleRatingFilter]);
|
||||||
|
const handle4StarsFilter = useCallback(() => handleRatingFilter(4), [handleRatingFilter]);
|
||||||
|
const handle3StarsFilter = useCallback(() => handleRatingFilter(3), [handleRatingFilter]);
|
||||||
|
const handle2StarsFilter = useCallback(() => handleRatingFilter(2), [handleRatingFilter]);
|
||||||
|
const handle1StarsFilter = useCallback(() => handleRatingFilter(1), [handleRatingFilter]);
|
||||||
|
|
||||||
|
const handleAromaClick = useCallback(() => console.log('Aroma clicked'), []);
|
||||||
|
const handleVanillaClick = useCallback(() => console.log('Vanilla clicked'), []);
|
||||||
|
const handleCinnamonClick = useCallback(() => console.log('Cinnamon clicked'), []);
|
||||||
|
const handleQualityClick = useCallback(() => console.log('Quality clicked'), []);
|
||||||
|
|
||||||
|
// 감정 필터링 핸들러들 - 별점 필터와 동일한 방식
|
||||||
|
const handleSentimentFilter = useCallback((sentiment) => {
|
||||||
|
console.log('[ProductId] Sentiment filter applied:', sentiment);
|
||||||
|
applySentimentFilter(sentiment === 'all' ? null : sentiment);
|
||||||
|
}, [applySentimentFilter]);
|
||||||
|
|
||||||
|
const handlePositiveClick = useCallback(() => handleSentimentFilter('positive'), [handleSentimentFilter]);
|
||||||
|
const handleNegativeClick = useCallback(() => handleSentimentFilter('negative'), [handleSentimentFilter]);
|
||||||
|
|
||||||
|
// UserReviewPanel 마운트 시 기본 All stars 필터 적용
|
||||||
|
useEffect(() => {
|
||||||
|
if (prdtId && currentFilter.type === 'rating' && currentFilter.value === 'all') {
|
||||||
|
console.log('[ProductId] UserReviewPanel 기본 All stars 필터 이미 적용됨');
|
||||||
|
}
|
||||||
|
}, [prdtId, currentFilter]);
|
||||||
|
|
||||||
|
// 메모리 해제를 위한 cleanup 함수
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
console.log('[ProductId] UserReviewPanel unmounting - clearing filters');
|
||||||
|
clearAllFilters(); // 필터 상태 초기화로 메모리 해제
|
||||||
|
};
|
||||||
|
}, [clearAllFilters]);
|
||||||
|
|
||||||
|
// [UserReviewPanel] 리뷰 데이터 수신 확인 로그
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
console.log("[ProductId] UserReviewPanel useReviews 데이터 확인:", {
|
||||||
|
reviewCount, // 전체 리뷰 개수
|
||||||
|
filteredCount, // 필터링된 리뷰 개수
|
||||||
|
avgRating,
|
||||||
|
hasDisplayReviews: !!displayReviews,
|
||||||
|
displayReviewsLength: displayReviews ? displayReviews.length : 0,
|
||||||
|
userReviewPanelReviewsLength: userReviewPanelReviews.length, // UserReviewPanel에서 표시할 4개
|
||||||
|
currentFilter: currentFilter,
|
||||||
|
filterCounts: filterCounts,
|
||||||
|
isDataFromCache: true // API 호출 없이 캐시된 데이터 사용
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ProductId] UserReviewPanel 로그 오류:", error);
|
||||||
|
}
|
||||||
|
}, [reviewCount, filteredCount, avgRating, displayReviews, userReviewPanelReviews, currentFilter, filterCounts]);
|
||||||
|
|
||||||
|
const handleBackButton = useCallback(() => {
|
||||||
|
console.log(`[ProductId] Back button clicked - returning to DetailPanel`);
|
||||||
|
dispatch(popPanel());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback((e) => {
|
||||||
|
dispatch(popPanel());
|
||||||
|
e.stopPropagation();
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TPanel
|
||||||
|
isTabActivated={false}
|
||||||
|
className={classNames(css.userReviewPanel, className)}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
spotlightId={spotlightId}
|
||||||
|
>
|
||||||
|
<UserReviewHeader
|
||||||
|
title="User Reviews"
|
||||||
|
onBackButton={handleBackButton}
|
||||||
|
onClick={handleBackButton}
|
||||||
|
className={css.header}
|
||||||
|
/>
|
||||||
|
<TBody className={css.tbody} scrollable={false}>
|
||||||
|
{/* Info Section - 제품 정보 */}
|
||||||
|
<div className={css.infoSection}>
|
||||||
|
<img
|
||||||
|
className={css.infoSection__productImage}
|
||||||
|
src={productImage}
|
||||||
|
alt="Product"
|
||||||
|
/>
|
||||||
|
<div className={css.infoSection__content}>
|
||||||
|
<div className={css.infoSection__content__topRow}>
|
||||||
|
<img
|
||||||
|
className={css.infoSection__content__topRow__brandLogo}
|
||||||
|
src={brandLogo}
|
||||||
|
alt="Brand"
|
||||||
|
/>
|
||||||
|
{productId && (
|
||||||
|
<div className={css.infoSection__content__topRow__productId}>
|
||||||
|
ID: {productId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={css.infoSection__content__titleRow}>
|
||||||
|
<div className={css.infoSection__content__titleRow__title}>
|
||||||
|
{productName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={css.infoSection__content__bottomRow}>
|
||||||
|
<StarRating
|
||||||
|
rating={avgRating}
|
||||||
|
className={css.infoSection__content__bottomRow__starRating}
|
||||||
|
/>
|
||||||
|
<div className={css.infoSection__content__bottomRow__reviewCount}>
|
||||||
|
({reviewCount} Reviews)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reviews Section - 필터 + 리뷰 리스트 */}
|
||||||
|
<div className={css.reviewsSection}>
|
||||||
|
{/* 왼쪽 필터 영역 */}
|
||||||
|
<div className={css.reviewsSection__filters}>
|
||||||
|
<div className={css.reviewsSection__filters__title}>
|
||||||
|
<div className={css.reviewsSection__filters__title__text}>Filter Reviews</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모든 필터들을 묶는 컨테이너 */}
|
||||||
|
<div className={css.reviewsSection__filters__container}>
|
||||||
|
{/* Rating 필터 섹션 */}
|
||||||
|
<div className={css.reviewsSection__filters__section}>
|
||||||
|
<div className={css.reviewsSection__filters__sectionTitle}>
|
||||||
|
<div className={css.reviewsSection__filters__sectionTitle__text}>Rating</div>
|
||||||
|
</div>
|
||||||
|
<div className={css.reviewsSection__filters__group}>
|
||||||
|
<FilterItemButton
|
||||||
|
text={`All stars(${filterCounts?.rating?.all || reviewCount || 0})`}
|
||||||
|
onClick={handleAllStarsFilter}
|
||||||
|
spotlightId="filter-all-stars"
|
||||||
|
ariaLabel="Filter by all star ratings"
|
||||||
|
dataSpotlightDown="filter-5-stars"
|
||||||
|
isActive={currentFilter.type === 'rating' && currentFilter.value === 'all'}
|
||||||
|
/>
|
||||||
|
<FilterItemButton
|
||||||
|
text={`5 stars (${filterCounts?.rating?.[5] || 0})`}
|
||||||
|
onClick={handle5StarsFilter}
|
||||||
|
spotlightId="filter-5-stars"
|
||||||
|
ariaLabel="Filter by 5 star ratings"
|
||||||
|
dataSpotlightUp="filter-all-stars"
|
||||||
|
dataSpotlightDown="filter-4-stars"
|
||||||
|
isActive={currentFilter.type === 'rating' && currentFilter.value === 5}
|
||||||
|
/>
|
||||||
|
<FilterItemButton
|
||||||
|
text={`4 stars (${filterCounts?.rating?.[4] || 0})`}
|
||||||
|
onClick={handle4StarsFilter}
|
||||||
|
spotlightId="filter-4-stars"
|
||||||
|
ariaLabel="Filter by 4 star ratings"
|
||||||
|
dataSpotlightUp="filter-5-stars"
|
||||||
|
dataSpotlightDown="filter-3-stars"
|
||||||
|
isActive={currentFilter.type === 'rating' && currentFilter.value === 4}
|
||||||
|
/>
|
||||||
|
<FilterItemButton
|
||||||
|
text={`3 stars (${filterCounts?.rating?.[3] || 0})`}
|
||||||
|
onClick={handle3StarsFilter}
|
||||||
|
spotlightId="filter-3-stars"
|
||||||
|
ariaLabel="Filter by 3 star ratings"
|
||||||
|
dataSpotlightUp="filter-4-stars"
|
||||||
|
dataSpotlightDown="filter-2-stars"
|
||||||
|
isActive={currentFilter.type === 'rating' && currentFilter.value === 3}
|
||||||
|
/>
|
||||||
|
<FilterItemButton
|
||||||
|
text={`2 stars (${filterCounts?.rating?.[2] || 0})`}
|
||||||
|
onClick={handle2StarsFilter}
|
||||||
|
spotlightId="filter-2-stars"
|
||||||
|
ariaLabel="Filter by 2 star ratings"
|
||||||
|
dataSpotlightUp="filter-3-stars"
|
||||||
|
dataSpotlightDown="filter-1-stars"
|
||||||
|
isActive={currentFilter.type === 'rating' && currentFilter.value === 2}
|
||||||
|
/>
|
||||||
|
<FilterItemButton
|
||||||
|
text={`1 stars (${filterCounts?.rating?.[1] || 0})`}
|
||||||
|
onClick={handle1StarsFilter}
|
||||||
|
spotlightId="filter-1-stars"
|
||||||
|
ariaLabel="Filter by 1 star ratings"
|
||||||
|
dataSpotlightUp="filter-2-stars"
|
||||||
|
isActive={currentFilter.type === 'rating' && currentFilter.value === 1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keywords 필터 섹션 */}
|
||||||
|
<div className={css.reviewsSection__filters__section}>
|
||||||
|
<div className={css.reviewsSection__filters__sectionTitle}>
|
||||||
|
<div className={css.reviewsSection__filters__sectionTitle__text}>Keywords</div>
|
||||||
|
</div>
|
||||||
|
<div className={css.reviewsSection__filters__group}>
|
||||||
|
<FilterItemButton
|
||||||
|
text="Aroma (99)"
|
||||||
|
onClick={handleAromaClick}
|
||||||
|
spotlightId="filter-aroma"
|
||||||
|
ariaLabel="Filter by aroma keyword"
|
||||||
|
dataSpotlightUp="filter-1-stars"
|
||||||
|
dataSpotlightDown="filter-vanilla"
|
||||||
|
/>
|
||||||
|
<FilterItemButton
|
||||||
|
text="Vanilla (92)"
|
||||||
|
onClick={handleVanillaClick}
|
||||||
|
spotlightId="filter-vanilla"
|
||||||
|
ariaLabel="Filter by vanilla keyword"
|
||||||
|
dataSpotlightUp="filter-aroma"
|
||||||
|
dataSpotlightDown="filter-cinnamon"
|
||||||
|
/>
|
||||||
|
<FilterItemButton
|
||||||
|
text="Cinnamon (85)"
|
||||||
|
onClick={handleCinnamonClick}
|
||||||
|
spotlightId="filter-cinnamon"
|
||||||
|
ariaLabel="Filter by cinnamon keyword"
|
||||||
|
dataSpotlightUp="filter-vanilla"
|
||||||
|
dataSpotlightDown="filter-quality"
|
||||||
|
/>
|
||||||
|
<FilterItemButton
|
||||||
|
text="Quality (83)"
|
||||||
|
onClick={handleQualityClick}
|
||||||
|
spotlightId="filter-quality"
|
||||||
|
ariaLabel="Filter by quality keyword"
|
||||||
|
dataSpotlightUp="filter-cinnamon"
|
||||||
|
dataSpotlightDown="filter-positive"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sentiment 필터 섹션 */}
|
||||||
|
<div className={css.reviewsSection__filters__section}>
|
||||||
|
<div className={css.reviewsSection__filters__sectionTitle}>
|
||||||
|
<div className={css.reviewsSection__filters__sectionTitle__text}>Sentiment</div>
|
||||||
|
</div>
|
||||||
|
<div className={css.reviewsSection__filters__group}>
|
||||||
|
<FilterItemButton
|
||||||
|
text={`Positive (${filterCounts?.sentiment?.positive || 0})`}
|
||||||
|
onClick={handlePositiveClick}
|
||||||
|
spotlightId="filter-positive"
|
||||||
|
ariaLabel="Filter by positive sentiment"
|
||||||
|
dataSpotlightUp="filter-quality"
|
||||||
|
dataSpotlightDown="filter-negative"
|
||||||
|
isActive={currentFilter.type === 'sentiment' && currentFilter.value === 'positive'}
|
||||||
|
/>
|
||||||
|
<FilterItemButton
|
||||||
|
text={`Negative (${filterCounts?.sentiment?.negative || 0})`}
|
||||||
|
onClick={handleNegativeClick}
|
||||||
|
spotlightId="filter-negative"
|
||||||
|
ariaLabel="Filter by negative sentiment"
|
||||||
|
dataSpotlightUp="filter-positive"
|
||||||
|
isActive={currentFilter.type === 'sentiment' && currentFilter.value === 'negative'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽 리뷰 리스트 영역 - useReviews hook에서 처음 4개 리뷰 전달 */}
|
||||||
|
<div className={css.reviewsSection__reviewsList}>
|
||||||
|
<UserReviewsList
|
||||||
|
prdtId={prdtId}
|
||||||
|
className={css.userReviewsList}
|
||||||
|
reviewsData={userReviewPanelReviews} // 필터링된 처음 4개 리뷰
|
||||||
|
totalReviewCount={reviewCount} // 전체 리뷰 개수
|
||||||
|
filteredReviewCount={filteredCount} // 필터링된 리뷰 개수
|
||||||
|
currentFilter={currentFilter} // 현재 적용된 필터
|
||||||
|
showAllReviews
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TBody>
|
||||||
|
</TPanel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserReviewPanel;
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
@import "../../style/CommonStyle.module.less";
|
||||||
|
@import "../../style/utils.module.less";
|
||||||
|
|
||||||
|
.userReviewPanel {
|
||||||
|
background-color: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background-color: #fff;
|
||||||
|
// border-bottom: 1px solid #e0e0e0; // 가로선 제거
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 60px;
|
||||||
|
align-items: center;
|
||||||
|
color: #333;
|
||||||
|
// padding: 30px 60px 0 60px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tbody {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: #fff;
|
||||||
|
// padding: 60px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header 섹션 (뒤로가기 버튼)
|
||||||
|
.headerSection {
|
||||||
|
width: 100%;
|
||||||
|
padding: 30px 60px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
&__buttonContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&__iconWrapper {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 20px;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 39px;
|
||||||
|
height: 39px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-right: 10px;
|
||||||
|
|
||||||
|
&__arrow1 {
|
||||||
|
width: 32px;
|
||||||
|
height: 26px;
|
||||||
|
left: 4px;
|
||||||
|
top: 10px;
|
||||||
|
position: absolute;
|
||||||
|
border: 2.5px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__arrow2 {
|
||||||
|
width: 8px;
|
||||||
|
height: 17px;
|
||||||
|
left: 3px;
|
||||||
|
top: 3px;
|
||||||
|
position: absolute;
|
||||||
|
border: 2.5px solid #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info 섹션 (제품 정보)
|
||||||
|
.infoSection {
|
||||||
|
width: calc(100% - 120px);
|
||||||
|
padding: 18px ;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #DADADA;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
// margin-bottom: 40px;
|
||||||
|
margin-left: 60px;
|
||||||
|
margin-right: 60px;
|
||||||
|
|
||||||
|
&__productImage {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border-radius: 14px;
|
||||||
|
object-fit: cover;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
flex: 1;
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
&__topRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&__brandLogo {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
object-fit: contain;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__productId {
|
||||||
|
color: #808080;
|
||||||
|
font-size: 24px;
|
||||||
|
font-family: 'LG Smart UI';
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__titleRow {
|
||||||
|
align-self: stretch;
|
||||||
|
padding-top: 5px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
flex: 1;
|
||||||
|
color: black;
|
||||||
|
font-size: 30px;
|
||||||
|
font-family: 'LG Smart UI';
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 32px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bottomRow {
|
||||||
|
flex: 1;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-end;
|
||||||
|
|
||||||
|
&__starRating {
|
||||||
|
margin-right: 15px;
|
||||||
|
width: 155px !important;
|
||||||
|
height: 31px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reviewCount {
|
||||||
|
color: #333333;
|
||||||
|
font-size: 24px;
|
||||||
|
font-family: 'LG Smart UI';
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 31px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reviews 섹션 (필터 + 리뷰 리스트)
|
||||||
|
.reviewsSection {
|
||||||
|
width: 100%;
|
||||||
|
padding: 30px 60px 60px 60px; // 위쪽만 30px, 나머지는 60px
|
||||||
|
background: white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
// 왼쪽 필터 영역
|
||||||
|
&__filters {
|
||||||
|
flex: 1;
|
||||||
|
align-self: stretch;
|
||||||
|
padding-bottom: 60px;
|
||||||
|
padding-right: 60px;
|
||||||
|
border-right: 1px solid #DADADA;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-right: 60px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
padding-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
text-align: center;
|
||||||
|
color: black;
|
||||||
|
font-size: 42px;
|
||||||
|
font-family: 'LG Smart UI';
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 42px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sectionTitle {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
text-align: center;
|
||||||
|
color: black;
|
||||||
|
font-size: 28px;
|
||||||
|
font-family: 'LG Smart UI';
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 42px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 필터들을 묶는 컨테이너 (세로 flex)
|
||||||
|
&__container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 필터 섹션 (Rating, Keywords, Sentiment)
|
||||||
|
&__section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__group {
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-content: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오른쪽 리뷰 리스트 영역 - 새로운 UserReviewsList 컴포넌트 사용
|
||||||
|
// 📝 레이아웃 조정: 왼쪽으로 30px 이동 + 넓이 40px 증가 (기존: width 885px, margin-left 0)
|
||||||
|
&__reviewsList {
|
||||||
|
width: 925px; // 기존 905px에서 20px 추가 증가 (총 40px 증가)
|
||||||
|
margin-left: -30px; // 왼쪽으로 30px 이동 (사용자가 수정한 값 유지)
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserReviewsList 컴포넌트용 스타일
|
||||||
|
.userReviewsList {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import TFilterButton from './TFilterButton';
|
||||||
|
import { TYPES } from './TFilterButton';
|
||||||
|
import css from './FilterItemButton.module.less';
|
||||||
|
|
||||||
|
const FilterItemButton = ({
|
||||||
|
text,
|
||||||
|
isActive = false,
|
||||||
|
showCloseIcon = false,
|
||||||
|
onClick,
|
||||||
|
spotlightId,
|
||||||
|
ariaLabel,
|
||||||
|
dataSpotlightUp,
|
||||||
|
dataSpotlightDown,
|
||||||
|
dataSpotlightLeft,
|
||||||
|
dataSpotlightRight
|
||||||
|
}) => {
|
||||||
|
const itemClassName = `${css.filterItemButton} ${isActive ? css['filterItemButton--active'] : ''}`;
|
||||||
|
const textClassName = `${css.filterItemButton__text} ${isActive ? css['filterItemButton__text--active'] : ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TFilterButton
|
||||||
|
className={itemClassName}
|
||||||
|
onClick={onClick}
|
||||||
|
spotlightId={spotlightId}
|
||||||
|
type={TYPES.normal}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
data-spotlight-up={dataSpotlightUp}
|
||||||
|
data-spotlight-down={dataSpotlightDown}
|
||||||
|
data-spotlight-left={dataSpotlightLeft}
|
||||||
|
data-spotlight-right={dataSpotlightRight}
|
||||||
|
>
|
||||||
|
<div className={textClassName}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
{showCloseIcon && isActive && (
|
||||||
|
<div className={css.filterItemButton__closeIcon}>
|
||||||
|
<div className={css.filterItemButton__closeIcon__icon} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TFilterButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilterItemButton;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
@import "../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.filterItemButton {
|
||||||
|
// TButton 기본 스타일 무력화
|
||||||
|
all: unset;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
// FilterItem 레이아웃 스타일 (요구사항에 맞는 스타일 적용)
|
||||||
|
display: flex;
|
||||||
|
padding: 20px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-radius: 100px;
|
||||||
|
border: 1px solid #DADADA;
|
||||||
|
background: #FFF;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
// 고정 넓이와 마진 설정으로 일정한 간격 유지 (한 라인에 4개 표시)
|
||||||
|
width: 90px;
|
||||||
|
margin-right: 40px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
// 각 라인의 마지막 아이템은 오른쪽 마진 제거
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 포커스 상태: 빨간색 배경
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background: #D32F2F !important; // 빨간색
|
||||||
|
border: 1px solid #D32F2F !important;
|
||||||
|
|
||||||
|
.filterItemButton__text {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택된 상태: 회색 배경 (포커스보다 우선도 높게)
|
||||||
|
&--active {
|
||||||
|
display: flex;
|
||||||
|
padding: 20px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
background: #7A808D !important; // 회색 (선택됨)
|
||||||
|
border-radius: 100px;
|
||||||
|
border: 1px solid #7A808D !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
text-align: center;
|
||||||
|
color: black;
|
||||||
|
font-size: 24px;
|
||||||
|
font-family: 'LG Smart UI';
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 24px;
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
font-family: 'LG Smart UI';
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__closeIcon {
|
||||||
|
align-self: stretch;
|
||||||
|
padding-top: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: white;
|
||||||
|
border: 1.2px solid white;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
import classNames from "classnames";
|
||||||
|
import compose from "ramda/src/compose";
|
||||||
|
|
||||||
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
|
import { Marquee, MarqueeController } from "@enact/ui/Marquee";
|
||||||
|
|
||||||
|
import IcProfile from "../../../../assets/images/icons/ic-creator-profile@3x.png";
|
||||||
|
import { $L } from "../../../utils/helperMethods";
|
||||||
|
import CustomImage from "../../../components/CustomImage/CustomImage";
|
||||||
|
import css from "./TFilterButton.module.less";
|
||||||
|
|
||||||
|
const SIZES = {
|
||||||
|
small: "small",
|
||||||
|
large: "large",
|
||||||
|
full: "full",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPES = {
|
||||||
|
normal: "normal",
|
||||||
|
// withIcon: "withIcon", // 추후 추가 예정
|
||||||
|
terms: "terms",
|
||||||
|
agree: "agree",
|
||||||
|
popup: "popup",
|
||||||
|
mypage: "mypage",
|
||||||
|
oneDepthCategory: "oneDepthCategory",
|
||||||
|
twoDepthCategory: "twoDepthCategory",
|
||||||
|
topButton: "topButton",
|
||||||
|
withAvatar: "withAvatar",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLOR = {
|
||||||
|
basic: "basic",
|
||||||
|
white: "white",
|
||||||
|
gray: "gray",
|
||||||
|
}; // white: default color
|
||||||
|
|
||||||
|
const SpottableComponent = Spottable("div");
|
||||||
|
|
||||||
|
function TFilterButtonBase({
|
||||||
|
avatarAlt,
|
||||||
|
avatarSource,
|
||||||
|
ariaLabel,
|
||||||
|
children,
|
||||||
|
spotlightId,
|
||||||
|
onSpotlightLeft,
|
||||||
|
spotlightDisabled = false,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
onBlur,
|
||||||
|
onFocus,
|
||||||
|
disabled,
|
||||||
|
selected,
|
||||||
|
type = "normal",
|
||||||
|
color = "basic",
|
||||||
|
size = "large",
|
||||||
|
withAvatar = false,
|
||||||
|
withMarquee = false,
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const _onClick = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (disabled) {
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onClick) {
|
||||||
|
onClick(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClick, disabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _onFocus = useCallback(() => {
|
||||||
|
setIsFocused(true);
|
||||||
|
|
||||||
|
if (onFocus) {
|
||||||
|
onFocus();
|
||||||
|
}
|
||||||
|
}, [onFocus]);
|
||||||
|
|
||||||
|
const _onBlur = useCallback(() => {
|
||||||
|
setIsFocused(false);
|
||||||
|
|
||||||
|
if (onBlur) {
|
||||||
|
onBlur();
|
||||||
|
}
|
||||||
|
}, [onBlur]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpottableComponent
|
||||||
|
{...rest}
|
||||||
|
className={classNames(
|
||||||
|
css.tButton,
|
||||||
|
css[type],
|
||||||
|
css[size],
|
||||||
|
css[color],
|
||||||
|
isFocused && css.focused,
|
||||||
|
selected && css.selected,
|
||||||
|
disabled ? css.disabled : null,
|
||||||
|
className ? className : null
|
||||||
|
)}
|
||||||
|
spotlightId={spotlightId}
|
||||||
|
onSpotlightLeft={onSpotlightLeft && onSpotlightLeft}
|
||||||
|
onFocus={_onFocus}
|
||||||
|
onBlur={_onBlur}
|
||||||
|
onClick={_onClick}
|
||||||
|
role="button"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
spotlightDisabled={spotlightDisabled}
|
||||||
|
>
|
||||||
|
{type === "topButton" && <div className={css.topText}>{$L("TOP")}</div>}
|
||||||
|
{type !== "topButton" &&
|
||||||
|
(withMarquee ? (
|
||||||
|
<Marquee className={css.marquee} marqueeOn="focus">
|
||||||
|
{children}
|
||||||
|
</Marquee>
|
||||||
|
) : (
|
||||||
|
<div className={css.text}>
|
||||||
|
{withAvatar && (
|
||||||
|
<CustomImage
|
||||||
|
src={avatarSource}
|
||||||
|
alt={avatarAlt}
|
||||||
|
fallbackSrc={IcProfile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SpottableComponent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ButtonDecorator = compose(MarqueeController({ marqueeOnFocus: true }));
|
||||||
|
const TFilterButton = ButtonDecorator(TFilterButtonBase);
|
||||||
|
|
||||||
|
export default TFilterButton;
|
||||||
|
|
||||||
|
export { COLOR, SIZES, TYPES };
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
@import "../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.tButton {
|
||||||
|
// 필터 버튼용 스타일 (자동 넓이)
|
||||||
|
width: auto;
|
||||||
|
min-width: auto;
|
||||||
|
max-width: none;
|
||||||
|
height: auto;
|
||||||
|
line-height: normal;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
background: #FFF;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 100px;
|
||||||
|
border: 1px solid #DADADA;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: inherit;
|
||||||
|
transition: all 0.1s ease;
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
.text {
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.marquee {
|
||||||
|
width: 100%;
|
||||||
|
> div > div {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
width: auto;
|
||||||
|
min-width: auto;
|
||||||
|
max-width: none;
|
||||||
|
height: auto;
|
||||||
|
line-height: normal;
|
||||||
|
font-size: inherit;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.large {
|
||||||
|
width: auto;
|
||||||
|
min-width: auto;
|
||||||
|
max-width: none;
|
||||||
|
letter-spacing: -0.75px;
|
||||||
|
}
|
||||||
|
&.extra {
|
||||||
|
width: auto;
|
||||||
|
min-width: auto;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
&.full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
&.basic {
|
||||||
|
&.disabled {
|
||||||
|
background-color: #7a808d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.gray {
|
||||||
|
background-color: @COLOR_GRAY03;
|
||||||
|
&.disabled {
|
||||||
|
color: #fff;
|
||||||
|
background-color: @COLOR_GRAY03;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.white {
|
||||||
|
color: @COLOR_GRAY03;
|
||||||
|
background-color: @COLOR_WHITE;
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0px 18px 28.2px 1.8px rgba(62, 59, 59, 0.4);
|
||||||
|
background-color: @PRIMARY_COLOR_RED;
|
||||||
|
color: @COLOR_WHITE;
|
||||||
|
}
|
||||||
|
&.disabled {
|
||||||
|
color: @COLOR_GRAY02;
|
||||||
|
background-color: @COLOR_WHITE;
|
||||||
|
border: 1px solid #dadada;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.popup {
|
||||||
|
min-width: 240px;
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.focused {
|
||||||
|
background-color: @PRIMARY_COLOR_RED;
|
||||||
|
.focusDropShadow();
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.terms {
|
||||||
|
background-color: @COLOR_WHITE;
|
||||||
|
.flex(@justifyCenter: space-between);
|
||||||
|
min-width: 530px;
|
||||||
|
position: relative;
|
||||||
|
height: 120px;
|
||||||
|
font-size: 34px;
|
||||||
|
color: @COLOR_GRAY08;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 0 20px;
|
||||||
|
padding: 0 50px;
|
||||||
|
box-shadow: 0 0 25px #0003;
|
||||||
|
max-width: none;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border: 1px solid @PRIMARY_COLOR_RED;
|
||||||
|
color: @PRIMARY_COLOR_RED;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: "";
|
||||||
|
background: url(../../../../assets/images/icons/ic-pop-conts-go-nor.png);
|
||||||
|
background-size: 37px 37px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center center;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
&:focus {
|
||||||
|
&:after {
|
||||||
|
background: url(../../../../assets/images/icons/ic-pop-conts-go-hov-new.png);
|
||||||
|
background-size: 37px 37px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center center;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.agree {
|
||||||
|
width: auto;
|
||||||
|
background-color: #999999;
|
||||||
|
color: @COLOR_WHITE;
|
||||||
|
min-width: 450px;
|
||||||
|
height: 100px;
|
||||||
|
margin: 0 20px;
|
||||||
|
font-size: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
.flex();
|
||||||
|
box-shadow: 0 5px 5px #003, 0 6px 7px #0000001a;
|
||||||
|
line-height: normal;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0px 18px 28.2px 1.8px rgba(0, 0, 0, 0.4);
|
||||||
|
font-size: 40px;
|
||||||
|
// line-height: normal;
|
||||||
|
background-color: @PRIMARY_COLOR_RED;
|
||||||
|
color: @COLOR_WHITE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.mypage {
|
||||||
|
width: auto;
|
||||||
|
background-color: #999999;
|
||||||
|
color: @COLOR_WHITE;
|
||||||
|
margin: 0 20px;
|
||||||
|
font-size: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
.flex();
|
||||||
|
box-shadow: 0 5px 5px #003, 0 6px 7px #0000001a;
|
||||||
|
line-height: normal;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: 0px 18px 28.2px 1.8px rgba(0, 0, 0, 0.4);
|
||||||
|
background-color: @PRIMARY_COLOR_RED;
|
||||||
|
color: @COLOR_WHITE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.oneDepthCategory {
|
||||||
|
position: relative;
|
||||||
|
max-width: none;
|
||||||
|
min-width: 180px;
|
||||||
|
height: 84px;
|
||||||
|
padding: 24px 30px;
|
||||||
|
background-color: @COLOR_WHITE;
|
||||||
|
border-radius: 42px;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: normal;
|
||||||
|
color: @COLOR_GRAY08;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
color: @PRIMARY_COLOR_RED;
|
||||||
|
&::after {
|
||||||
|
.focused(@boxShadow: 0, @borderRadius: 42px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: @COLOR_WHITE;
|
||||||
|
color: @PRIMARY_COLOR_RED;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
&::after {
|
||||||
|
.focused(@boxShadow: 0, @borderRadius: 42px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.twoDepthCategory {
|
||||||
|
position: relative;
|
||||||
|
.flex();
|
||||||
|
width: 300px;
|
||||||
|
height: 72px;
|
||||||
|
padding: 18px 30px;
|
||||||
|
background-color: @COLOR_WHITE;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 30px;
|
||||||
|
color: @COLOR_GRAY08;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
font-weight: bold;
|
||||||
|
color: @PRIMARY_COLOR_RED;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
.focused(@boxShadow: 0, @borderRadius: 12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: @COLOR_NAVY;
|
||||||
|
color: @COLOR_WHITE;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
&::after {
|
||||||
|
all: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.topButton {
|
||||||
|
.size(@w: 120px, @h: 120px);
|
||||||
|
border-radius: 100%;
|
||||||
|
background-image: url("../../../../assets/images/rectangle-639@3x.png");
|
||||||
|
background-position: center 31px;
|
||||||
|
background-size: 32px 17px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: block;
|
||||||
|
margin: 60px auto;
|
||||||
|
> div.topText {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 30px;
|
||||||
|
padding-top: 60px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.withAvatar {
|
||||||
|
position: relative;
|
||||||
|
min-width: 180px;
|
||||||
|
height: 84px;
|
||||||
|
padding: 24px 30px 24px 90px;
|
||||||
|
background-color: @COLOR_WHITE;
|
||||||
|
border-radius: 42px;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: normal;
|
||||||
|
color: @COLOR_GRAY08;
|
||||||
|
|
||||||
|
img {
|
||||||
|
.position(@position: absolute, @top: 6px, @left: 6px);
|
||||||
|
.size(@w: 72px, @h:72px);
|
||||||
|
border-radius: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
color: @PRIMARY_COLOR_RED;
|
||||||
|
&::after {
|
||||||
|
.focused(@boxShadow: 0, @borderRadius: 42px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: @SELECTED_COLOR_RED;
|
||||||
|
color: @COLOR_WHITE;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
&::after {
|
||||||
|
all: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Light theme 개별 리뷰 아이템 컴포넌트
|
||||||
|
import React, { useCallback } from "react";
|
||||||
|
import Spottable from "@enact/spotlight/Spottable";
|
||||||
|
import StarRating from "../../DetailPanel/components/StarRating";
|
||||||
|
import css from "./UserReviewItem.module.less";
|
||||||
|
|
||||||
|
const SpottableComponent = Spottable("div");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserReviewPanel에서 사용하는 개별 리뷰 아이템 컴포넌트
|
||||||
|
* ProductAllSection의 UserReviews에서 복사하여 Light theme으로 수정
|
||||||
|
*/
|
||||||
|
const UserReviewItem = ({
|
||||||
|
review,
|
||||||
|
index,
|
||||||
|
isLastReview = false,
|
||||||
|
onClick
|
||||||
|
}) => {
|
||||||
|
const handleReviewClick = useCallback(() => {
|
||||||
|
if (onClick) {
|
||||||
|
onClick(review, index);
|
||||||
|
}
|
||||||
|
}, [onClick, review, index]);
|
||||||
|
|
||||||
|
// 날짜 포맷팅 함수
|
||||||
|
const formatToYYMMDD = (dateStr) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const iso = date.toISOString().slice(2, 10);
|
||||||
|
return iso.replace(/-/g, ".");
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
reviewImageList,
|
||||||
|
rvwRtng,
|
||||||
|
rvwRgstDtt,
|
||||||
|
rvwCtnt,
|
||||||
|
rvwId,
|
||||||
|
wrtrNknm,
|
||||||
|
rvwWrtrId
|
||||||
|
} = review;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpottableComponent
|
||||||
|
key={`user-review-item-${rvwId}`}
|
||||||
|
aria-label={`user-review-item-${rvwId}`}
|
||||||
|
className={css.reviewContentContainer}
|
||||||
|
onClick={handleReviewClick}
|
||||||
|
spotlightId={isLastReview ? 'user-review-at-last' : `user-review-${index}`}
|
||||||
|
>
|
||||||
|
{/* 리뷰 이미지 */}
|
||||||
|
{reviewImageList && reviewImageList.length > 0 && (
|
||||||
|
<img
|
||||||
|
className={css.reviewThumbnail}
|
||||||
|
src={reviewImageList[0].imgUrl}
|
||||||
|
alt={`Review for ${rvwId}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 리뷰 내용 */}
|
||||||
|
<div className={css.reviewContent}>
|
||||||
|
{/* 메타 정보 (별점, 작성자, 날짜) */}
|
||||||
|
<div className={css.reviewMeta}>
|
||||||
|
{rvwRtng && (
|
||||||
|
<StarRating
|
||||||
|
rating={rvwRtng}
|
||||||
|
aria-label={`star rating ${rvwRtng} out of 5`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(wrtrNknm || rvwWrtrId) && (
|
||||||
|
<span className={css.reviewAuthor}>
|
||||||
|
{wrtrNknm || rvwWrtrId}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{rvwRgstDtt && (
|
||||||
|
<span className={css.reviewDate}>
|
||||||
|
{formatToYYMMDD(rvwRgstDtt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 리뷰 텍스트 */}
|
||||||
|
{rvwCtnt && (
|
||||||
|
<div className={css.reviewText}>{rvwCtnt}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SpottableComponent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserReviewItem;
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Light theme 개별 리뷰 아이템 스타일
|
||||||
|
@import "../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.reviewContentContainer {
|
||||||
|
// 📝 15px 오버플로우 방지: 100%에서 15px 줄임
|
||||||
|
width: calc(100% - 15px);
|
||||||
|
height: 168px;
|
||||||
|
// Light theme으로 변경: 어두운 회색에서 밝은 색상으로
|
||||||
|
background-color: rgba(255, 255, 255, 0.95);
|
||||||
|
border: 1px solid rgba(230, 230, 230, 1);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin: 0 0 10px 0; // 📝 좌우 마진 제거, 아래쪽만 유지
|
||||||
|
.flex(@justifyCenter:flex-start);
|
||||||
|
padding: 20px 15px; // 📝 패딩 복원 (상하 20px, 좌우 15px)
|
||||||
|
position: relative;
|
||||||
|
// 📝 부모 영역 오버플로우 방지를 위한 box-sizing 설정
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
&::after {
|
||||||
|
.focused(@boxShadow:22px, @borderRadius:12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewThumbnail {
|
||||||
|
// 📝 이미지 크기 축소로 텍스트 영역 확보 (108px -> 90px)
|
||||||
|
.size(@w: 90px,@h:90px);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-right: 12px; // 📝 오른쪽 마진만 유지 (패딩이 있으므로 좌측 마진 제거)
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0; // 이미지는 축소되지 않도록 고정
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewContent {
|
||||||
|
// 📝 이미지 크기 변경에 맞춰 높이 조정 (108px -> 90px)
|
||||||
|
.size(@w: 100%,@h:90px);
|
||||||
|
// 📝 이미지와 마진을 고려한 실제 사용 가능한 넓이로 제한
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0; // flex item이 축소될 수 있도록 설정
|
||||||
|
// 📝 오른쪽 마진 제거 (패딩이 있으므로 중복 여백 방지)
|
||||||
|
|
||||||
|
.reviewMeta {
|
||||||
|
.size(@w:100%, @h:31px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-right: 20px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StarRating 컴포넌트 스타일 - Light theme에 맞게 조정
|
||||||
|
:global(.star-rating) {
|
||||||
|
width: 160px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewAuthor {
|
||||||
|
// Light theme: 어두운 회색으로 변경
|
||||||
|
color: rgba(100, 100, 100, 1);
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 22px);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewDate {
|
||||||
|
// Light theme: 어두운 색상으로 변경
|
||||||
|
color: rgba(60, 60, 60, 1);
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 24px);
|
||||||
|
font-weight: 400;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewText {
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 24px);
|
||||||
|
font-weight: 400;
|
||||||
|
.elip(@clamp:2);
|
||||||
|
// Light theme: 어두운 텍스트 색상으로 변경
|
||||||
|
color: rgba(40, 40, 40, 1);
|
||||||
|
margin-top: 15px;
|
||||||
|
// 📝 텍스트가 부모 영역을 벗어나지 않도록 설정
|
||||||
|
word-break: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Light theme 리뷰 리스트 컴포넌트
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
|
||||||
|
import UserReviewItem from "./UserReviewItem";
|
||||||
|
import UserReviewsScroller from "./UserReviewsScroller";
|
||||||
|
import { $L } from "../../../utils/helperMethods";
|
||||||
|
import css from "./UserReviewsList.module.less";
|
||||||
|
|
||||||
|
const Container = SpotlightContainerDecorator(
|
||||||
|
{
|
||||||
|
enterTo: "default-element",
|
||||||
|
preserveId: true,
|
||||||
|
leaveFor: {
|
||||||
|
left: "filter-all-stars"
|
||||||
|
},
|
||||||
|
restrict: "none",
|
||||||
|
spotlightDirection: "vertical"
|
||||||
|
},
|
||||||
|
"div"
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserReviewPanel에서 사용하는 리뷰 리스트 컨테이너
|
||||||
|
* Redux 대신 props로 리뷰 데이터를 받음
|
||||||
|
*/
|
||||||
|
const UserReviewsList = ({
|
||||||
|
prdtId,
|
||||||
|
className,
|
||||||
|
reviewsData = [], // UserReviewPanel에서 전달받은 필터링된 처음 4개 리뷰
|
||||||
|
totalReviewCount = 0, // 전체 리뷰 개수
|
||||||
|
filteredReviewCount = 0, // 필터링된 리뷰 개수
|
||||||
|
currentFilter = { type: 'rating', value: 'all' }, // Single Filter 구조
|
||||||
|
showAllReviews = true
|
||||||
|
}) => {
|
||||||
|
const [selectedReviewIndex, setSelectedReviewIndex] = useState(null);
|
||||||
|
|
||||||
|
// 리뷰 클릭 핸들러
|
||||||
|
const handleReviewClick = useCallback((review, index) => {
|
||||||
|
console.log("[UserReviewsList] Review item clicked:", {
|
||||||
|
rvwId: review.rvwId,
|
||||||
|
index: index,
|
||||||
|
review: review
|
||||||
|
});
|
||||||
|
setSelectedReviewIndex(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 디버깅: props로 받은 리뷰 데이터 확인
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[UserReviewsList] Props data received:", {
|
||||||
|
reviewsData,
|
||||||
|
reviewsDataLength: reviewsData?.length || 0,
|
||||||
|
totalReviewCount,
|
||||||
|
filteredReviewCount,
|
||||||
|
currentFilter,
|
||||||
|
hasData: reviewsData && reviewsData.length > 0,
|
||||||
|
prdtId: prdtId,
|
||||||
|
dataSource: "props", // Redux가 아닌 props에서 받음
|
||||||
|
isFiltered: currentFilter.value !== 'all'
|
||||||
|
});
|
||||||
|
}, [reviewsData, totalReviewCount, filteredReviewCount, currentFilter, prdtId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container className={css.reviewsListContainer}>
|
||||||
|
{/* 헤더 - 필터 상태에 따라 다른 숫자 표시 */}
|
||||||
|
<div className={css.reviewsListHeader}>
|
||||||
|
<div className={css.reviewsListHeaderText}>
|
||||||
|
<span className={css.reviewsListHeaderCount}>
|
||||||
|
{currentFilter.value !== 'all' ? filteredReviewCount : totalReviewCount}
|
||||||
|
</span> Customer Reviews
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 리뷰 스크롤러 */}
|
||||||
|
<UserReviewsScroller className={css.reviewsScroller}>
|
||||||
|
<div className={css.showReviewsText}>
|
||||||
|
{$L(
|
||||||
|
currentFilter.value !== 'all' ?
|
||||||
|
`Showing ${reviewsData ? reviewsData.length : 0} out of ${filteredReviewCount} filtered reviews` :
|
||||||
|
`Showing ${reviewsData ? reviewsData.length : 0} out of ${totalReviewCount} reviews`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 리뷰 아이템들 - props로 받은 데이터 사용 (이미 4개로 제한됨) */}
|
||||||
|
<div className={css.reviewItems}>
|
||||||
|
{reviewsData && reviewsData.length > 0 ? (
|
||||||
|
reviewsData.map((review, index, array) => {
|
||||||
|
const isLastReview = index === array.length - 1;
|
||||||
|
return (
|
||||||
|
<UserReviewItem
|
||||||
|
key={`user-review-${review.rvwId}`}
|
||||||
|
review={review}
|
||||||
|
index={index}
|
||||||
|
isLastReview={isLastReview}
|
||||||
|
onClick={handleReviewClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className={css.noReviews}>
|
||||||
|
{totalReviewCount === 0 ? 'No reviews available' : 'Loading reviews...'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</UserReviewsScroller>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserReviewsList;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Light theme 리뷰 리스트 스타일
|
||||||
|
@import "../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../style/utils.module.less";
|
||||||
|
|
||||||
|
.reviewsListContainer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
// Light theme: 밝은 배경
|
||||||
|
background-color: rgba(250, 250, 250, 1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewsListHeader {
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid rgba(230, 230, 230, 1);
|
||||||
|
padding-bottom: 15px;
|
||||||
|
|
||||||
|
.reviewsListHeaderText {
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 28px);
|
||||||
|
font-weight: 600;
|
||||||
|
// Light theme: 어두운 텍스트
|
||||||
|
color: rgba(40, 40, 40, 1);
|
||||||
|
|
||||||
|
.reviewsListHeaderCount {
|
||||||
|
// Light theme: 포인트 색상
|
||||||
|
color: rgba(199, 8, 80, 1);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewsScroller {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.showReviewsText {
|
||||||
|
.size(@w:100%, @h:36px);
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 20px);
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
// Light theme: 회색 텍스트
|
||||||
|
color: rgba(120, 120, 120, 1);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reviewItems {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noReviews {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
.font(@fontFamily: @baseFont, @fontSize: 24px);
|
||||||
|
font-weight: 400;
|
||||||
|
// Light theme: 회색 텍스트
|
||||||
|
color: rgba(150, 150, 150, 1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// Light theme용 UserReviewsScroller 컴포넌트
|
||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
|
||||||
|
import { Job } from "@enact/core/util";
|
||||||
|
import Scroller from "@enact/sandstone/Scroller";
|
||||||
|
|
||||||
|
import css from "./UserReviewsScroller.module.less";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserReviewPanel에서 사용하는 스크롤러 컴포넌트
|
||||||
|
* DetailPanel의 UserReviewsScroller에서 복사하여 Light theme으로 수정
|
||||||
|
*/
|
||||||
|
export default function UserReviewsScroller({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
verticalScrollbar = "visible",
|
||||||
|
focusableScrollbar = true,
|
||||||
|
direction = "vertical",
|
||||||
|
horizontalScrollbar = "hidden",
|
||||||
|
scrollMode,
|
||||||
|
onScrollStart,
|
||||||
|
onScrollStop,
|
||||||
|
onScroll,
|
||||||
|
noScrollByWheel = false,
|
||||||
|
cbScrollTo,
|
||||||
|
forceUpdate = false,
|
||||||
|
...rest
|
||||||
|
}) {
|
||||||
|
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 scrollerRef = useRef(null);
|
||||||
|
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
return () => setIsMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// forceUpdate prop 변경 시 스크롤 영역 재계산
|
||||||
|
useEffect(() => {
|
||||||
|
if (forceUpdate && scrollerRef.current) {
|
||||||
|
console.log("[UserReviewsScroller] Force updating scroll area");
|
||||||
|
|
||||||
|
const updateJob = new Job(() => {
|
||||||
|
if (scrollerRef.current) {
|
||||||
|
if (scrollerRef.current.scrollTo) {
|
||||||
|
scrollerRef.current.scrollTo({ position: { y: 0 }, animate: false });
|
||||||
|
console.log("[UserReviewsScroller] Scroll area recalculated");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollerRef.current.update) {
|
||||||
|
scrollerRef.current.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
updateJob.start();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (updateJob) {
|
||||||
|
updateJob.stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [forceUpdate]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
[onScrollStop]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _onScroll = useCallback(
|
||||||
|
(ev) => {
|
||||||
|
if (onScroll) {
|
||||||
|
onScroll(ev);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onScroll]
|
||||||
|
);
|
||||||
|
|
||||||
|
const _cbScrollTo = useCallback(
|
||||||
|
(ref) => {
|
||||||
|
if (cbScrollTo) {
|
||||||
|
cbScrollTo(ref);
|
||||||
|
}
|
||||||
|
scrollToRef.current = ref;
|
||||||
|
},
|
||||||
|
[cbScrollTo]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
className ? className : null,
|
||||||
|
css.scrollerContainer
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Scroller
|
||||||
|
{...rest}
|
||||||
|
ref={scrollerRef}
|
||||||
|
cbScrollTo={_cbScrollTo}
|
||||||
|
onScrollStart={_onScrollStart}
|
||||||
|
onScrollStop={_onScrollStop}
|
||||||
|
onScroll={_onScroll}
|
||||||
|
scrollMode={scrollMode || "translate"}
|
||||||
|
focusableScrollbar={focusableScrollbar}
|
||||||
|
className={classNames(
|
||||||
|
isMounted && css.tScroller,
|
||||||
|
noScrollByWheel && css.preventScroll
|
||||||
|
)}
|
||||||
|
direction={direction}
|
||||||
|
horizontalScrollbar={horizontalScrollbar}
|
||||||
|
verticalScrollbar={verticalScrollbar}
|
||||||
|
overscrollEffectOn={{
|
||||||
|
arrowKey: false,
|
||||||
|
drag: false,
|
||||||
|
pageKey: false,
|
||||||
|
track: false,
|
||||||
|
wheel: false,
|
||||||
|
}}
|
||||||
|
noScrollByWheel={noScrollByWheel}
|
||||||
|
noScrollByDrag
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Scroller>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Light theme용 UserReviewsScroller 스타일
|
||||||
|
@import "../../../style/CommonStyle.module.less";
|
||||||
|
@import "../../../style/utils.module.less";
|
||||||
|
|
||||||
|
@scrollBar: 0.625rem;
|
||||||
|
@scrollCircle: 1.6667rem;
|
||||||
|
// Light theme 스크롤바 색상
|
||||||
|
@barColor: rgba(220, 220, 220, 1);
|
||||||
|
@focusedBarColor: rgba(200, 200, 200, 1);
|
||||||
|
@verTrackColor: rgba(240, 240, 240, 1);
|
||||||
|
|
||||||
|
.scrollerContainer {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
height: auto;
|
||||||
|
min-height: 400px;
|
||||||
|
max-height: none;
|
||||||
|
// Light theme: 밝은 배경
|
||||||
|
background-color: rgba(255, 255, 255, 1);
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.tScroller {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
min-height: inherit;
|
||||||
|
max-height: none;
|
||||||
|
|
||||||
|
// Light theme 스크롤바 스타일
|
||||||
|
> div:nth-child(2) {
|
||||||
|
padding: 0;
|
||||||
|
> div:nth-child(1) {
|
||||||
|
background-color: @verTrackColor;
|
||||||
|
width: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
> div {
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: @barColor;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: @focusedBarColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.preventScroll {
|
||||||
|
>div{
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크롤 컨테이너 최적화
|
||||||
|
> div {
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 400px;
|
||||||
|
padding-bottom: 0;
|
||||||
|
// Light theme: 밝은 배경
|
||||||
|
background-color: rgba(255, 255, 255, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// UserReview 관련 컴포넌트들의 barrel export
|
||||||
|
// Light theme으로 수정된 개별 리뷰 표시용 컴포넌트들
|
||||||
|
|
||||||
|
export { default as UserReviewItem } from './UserReviewItem';
|
||||||
|
export { default as UserReviewsList } from './UserReviewsList';
|
||||||
|
export { default as UserReviewsScroller } from './UserReviewsScroller';
|
||||||
|
export { default as FilterItemButton } from './FilterItemButton';
|
||||||
|
export { default as TFilterButton } from './TFilterButton';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const UserReviewPanelHeader = () => {
|
||||||
|
return (
|
||||||
|
<div style={{width: '100%', height: '100%', paddingLeft: 60, paddingRight: 60, paddingTop: 30, paddingBottom: 30, flexDirection: 'column', justifyContent: 'center', alignItems: 'flex-start', display: 'inline-flex'}}>
|
||||||
|
<div style={{justifyContent: 'flex-start', alignItems: 'center', gap: 20, display: 'inline-flex'}}>
|
||||||
|
<div style={{height: 60, justifyContent: 'flex-start', alignItems: 'center', gap: 10, display: 'flex'}}>
|
||||||
|
<div style={{width: 39, height: 39, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 32.10, height: 26, left: 4, top: 10.10, position: 'absolute', outline: '5px #333333 solid', outlineOffset: '-2.50px'}} />
|
||||||
|
<div style={{width: 8.07, height: 16.97, left: 2.72, top: 3.01, position: 'absolute', outline: '5px #333333 solid', outlineOffset: '-2.50px'}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserReviewPanelHeader;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const UserReviewPanelInfo = () => {
|
||||||
|
return (
|
||||||
|
<div style={{width: '100%', height: '100%', padding: 18, background: 'white', borderRadius: 12, outline: '1px #DADADA solid', outlineOffset: '-1px', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 15, display: 'inline-flex'}}>
|
||||||
|
<img style={{width: 150, height: 150, borderRadius: 13.85}} src="https://placehold.co/150x150" alt="Product" />
|
||||||
|
<div style={{flex: '1 1 0', alignSelf: 'stretch', flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', display: 'inline-flex'}}>
|
||||||
|
<div style={{justifyContent: 'flex-start', alignItems: 'center', gap: 15, display: 'inline-flex'}}>
|
||||||
|
<img style={{width: 50, height: 50}} src="https://placehold.co/50x50" alt="Brand" />
|
||||||
|
<div style={{color: '#808080', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '600', lineHeight: 18, wordWrap: 'break-word'}}>ID: M90221</div>
|
||||||
|
</div>
|
||||||
|
<div style={{alignSelf: 'stretch', paddingTop: 5, justifyContent: 'flex-start', alignItems: 'flex-start', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{flex: '1 1 0', color: 'black', fontSize: 30, fontFamily: 'LG Smart UI', fontWeight: '700', lineHeight: 32, wordWrap: 'break-word'}}>
|
||||||
|
Farmer Jon`s 25ct Mini Bags of Microwave Popcorn in Flavor Choice
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{flex: '1 1 0', paddingBottom: 5, justifyContent: 'center', alignItems: 'flex-end', gap: 15, display: 'inline-flex'}}>
|
||||||
|
<div style={{justifyContent: 'flex-start', alignItems: 'flex-start', gap: 4, display: 'flex'}}>
|
||||||
|
<div style={{width: 31, height: 29, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.42, top: 0, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 31, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.42, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 31, height: 29, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.42, top: 0, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 31, height: 29, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.42, top: 0, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 31, height: 29, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.42, top: 0, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{color: '#333333', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 31, wordWrap: 'break-word'}}>(99 Reviews)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserReviewPanelInfo;
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const UserReviewPanelReviews = () => {
|
||||||
|
return (
|
||||||
|
<div style={{width: '100%', height: '100%', padding: 60, background: 'white', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 60, display: 'inline-flex'}}>
|
||||||
|
<div style={{flex: '1 1 0', alignSelf: 'stretch', paddingBottom: 60, paddingRight: 60, borderRight: '1px #DADADA solid', flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 20, display: 'inline-flex'}}>
|
||||||
|
<div style={{paddingBottom: 15, justifyContent: 'flex-start', alignItems: 'center', gap: 12, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'black', fontSize: 42, fontFamily: 'LG Smart UI', fontWeight: '700', lineHeight: 42, wordWrap: 'break-word'}}>Filter Reviews</div>
|
||||||
|
</div>
|
||||||
|
<div style={{justifyContent: 'flex-start', alignItems: 'center', gap: 12, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'black', fontSize: 28, fontFamily: 'LG Smart UI', fontWeight: '600', lineHeight: 42, wordWrap: 'break-word'}}>Rating</div>
|
||||||
|
</div>
|
||||||
|
<div style={{alignSelf: 'stretch', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 15, display: 'inline-flex', flexWrap: 'wrap', alignContent: 'flex-start'}}>
|
||||||
|
<div style={{padding: 20, background: 'white', borderRadius: 100, outline: '1px #DADADA solid', outlineOffset: '-1px', flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'black', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 24, wordWrap: 'break-word'}}>All stars(99)</div>
|
||||||
|
</div>
|
||||||
|
<div style={{padding: 20, background: 'white', borderRadius: 100, outline: '1px #DADADA solid', outlineOffset: '-1px', flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'black', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 24, wordWrap: 'break-word'}}>5 stars (55)</div>
|
||||||
|
</div>
|
||||||
|
<div style={{padding: 20, background: 'white', borderRadius: 100, outline: '1px #DADADA solid', outlineOffset: '-1px', flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'black', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 24, wordWrap: 'break-word'}}>4 stars (25)</div>
|
||||||
|
</div>
|
||||||
|
<div style={{padding: 20, background: '#7A808D', borderRadius: 100, justifyContent: 'flex-start', alignItems: 'center', gap: 15, display: 'flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'white', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 24, wordWrap: 'break-word'}}>3 stars (25)</div>
|
||||||
|
<div style={{alignSelf: 'stretch', paddingTop: 2, overflow: 'hidden', justifyContent: 'center', alignItems: 'center', gap: 10, display: 'flex'}}>
|
||||||
|
<div style={{width: 18, height: 18, background: 'white', border: '1.20px white solid'}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{padding: 20, background: 'white', borderRadius: 100, outline: '1px #DADADA solid', outlineOffset: '-1px', flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'black', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 24, wordWrap: 'break-word'}}>2 stars (10)</div>
|
||||||
|
</div>
|
||||||
|
<div style={{padding: 20, background: 'white', borderRadius: 100, outline: '1px #DADADA solid', outlineOffset: '-1px', flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'black', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 24, wordWrap: 'break-word'}}>1 stars (1)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{paddingTop: 30, justifyContent: 'flex-start', alignItems: 'center', gap: 12, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'black', fontSize: 28, fontFamily: 'LG Smart UI', fontWeight: '600', lineHeight: 42, wordWrap: 'break-word'}}>Keywords</div>
|
||||||
|
</div>
|
||||||
|
<div style={{alignSelf: 'stretch', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 15, display: 'inline-flex', flexWrap: 'wrap', alignContent: 'flex-start'}}>
|
||||||
|
<div style={{padding: 20, background: 'white', borderRadius: 100, outline: '1px #DADADA solid', outlineOffset: '-1px', flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'black', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 24, wordWrap: 'break-word'}}>Aroma (99)</div>
|
||||||
|
</div>
|
||||||
|
<div style={{padding: 20, background: 'white', borderRadius: 100, outline: '1px #DADADA solid', outlineOffset: '-1px', flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'black', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 24, wordWrap: 'break-word'}}>Vanilla (92)</div>
|
||||||
|
</div>
|
||||||
|
<div style={{padding: 20, background: 'white', borderRadius: 100, outline: '1px #DADADA solid', outlineOffset: '-1px', flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'black', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 24, wordWrap: 'break-word'}}>Cinnamon (85)</div>
|
||||||
|
</div>
|
||||||
|
<div style={{padding: 20, background: 'white', borderRadius: 100, outline: '1px #DADADA solid', outlineOffset: '-1px', flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'black', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 24, wordWrap: 'break-word'}}>Quality (83)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{paddingTop: 30, justifyContent: 'flex-start', alignItems: 'center', gap: 12, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'black', fontSize: 28, fontFamily: 'LG Smart UI', fontWeight: '600', lineHeight: 42, wordWrap: 'break-word'}}>Sentiment</div>
|
||||||
|
</div>
|
||||||
|
<div style={{alignSelf: 'stretch', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 15, display: 'inline-flex', flexWrap: 'wrap', alignContent: 'flex-start'}}>
|
||||||
|
<div style={{padding: 20, background: 'white', borderRadius: 100, outline: '1px #DADADA solid', outlineOffset: '-1px', flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'black', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 24, wordWrap: 'break-word'}}> Positive(66)</div>
|
||||||
|
</div>
|
||||||
|
<div style={{padding: 20, background: 'white', borderRadius: 100, outline: '1px #DADADA solid', outlineOffset: '-1px', flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: '#131314', fontSize: 21, fontFamily: 'Arial', fontWeight: '400', lineHeight: 31.50, wordWrap: 'break-word'}}>Negative (12)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{width: 885, flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{justifyContent: 'flex-start', alignItems: 'center', gap: 12, display: 'inline-flex'}}>
|
||||||
|
<div style={{textAlign: 'center', color: 'black', fontSize: 28, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 42, wordWrap: 'break-word'}}>
|
||||||
|
<span style={{fontWeight: '700'}}>83</span>
|
||||||
|
<span style={{fontWeight: '600'}}> </span>
|
||||||
|
Customer Reviews
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{alignSelf: 'stretch', height: 168, padding: 30, background: '#F8F8F8', borderRadius: 12, outline: '1px #DADADA solid', outlineOffset: '-1px', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 15, display: 'inline-flex'}}>
|
||||||
|
<img style={{width: 108, alignSelf: 'stretch', paddingLeft: 4, paddingRight: 4, paddingTop: 5, paddingBottom: 5, borderRadius: 12}} src="https://placehold.co/108x108" alt="Review" />
|
||||||
|
<div style={{flex: '1 1 0', flexDirection: 'column', justifyContent: 'center', alignItems: 'flex-start', gap: 15, display: 'inline-flex'}}>
|
||||||
|
<div style={{alignSelf: 'stretch', justifyContent: 'space-between', alignItems: 'flex-start', display: 'inline-flex'}}>
|
||||||
|
<div style={{justifyContent: 'flex-start', alignItems: 'flex-start', gap: 20, display: 'flex'}}>
|
||||||
|
<div style={{justifyContent: 'flex-start', alignItems: 'flex-start', gap: 4, display: 'flex'}}>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{flexDirection: 'column', justifyContent: 'center', alignItems: 'center', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{alignSelf: 'stretch', color: '#333333', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 31, wordWrap: 'break-word'}}>alex*****@mail.com</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{color: '#333333', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '600', lineHeight: 31, wordWrap: 'break-word'}}>25.05.28</div>
|
||||||
|
</div>
|
||||||
|
<div style={{width: 704, color: '#333333', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 31, wordWrap: 'break-word'}}>
|
||||||
|
"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."
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{alignSelf: 'stretch', height: 168, padding: 30, background: '#F8F8F8', borderRadius: 12, outline: '1px #DADADA solid', outlineOffset: '-1px', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 15, display: 'inline-flex'}}>
|
||||||
|
<div style={{flex: '1 1 0', flexDirection: 'column', justifyContent: 'center', alignItems: 'flex-start', gap: 15, display: 'inline-flex'}}>
|
||||||
|
<div style={{alignSelf: 'stretch', justifyContent: 'space-between', alignItems: 'flex-start', display: 'inline-flex'}}>
|
||||||
|
<div style={{justifyContent: 'flex-start', alignItems: 'flex-start', gap: 20, display: 'flex'}}>
|
||||||
|
<div style={{justifyContent: 'flex-start', alignItems: 'flex-start', gap: 4, display: 'flex'}}>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{flexDirection: 'column', justifyContent: 'center', alignItems: 'center', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{alignSelf: 'stretch', color: '#333333', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 31, wordWrap: 'break-word'}}>alex*****@mail.com</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{color: '#333333', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '600', lineHeight: 31, wordWrap: 'break-word'}}>25.05.28</div>
|
||||||
|
</div>
|
||||||
|
<div style={{width: 704, color: '#333333', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 31, wordWrap: 'break-word'}}>
|
||||||
|
"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."
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{alignSelf: 'stretch', height: 168, padding: 30, background: '#F8F8F8', borderRadius: 12, outline: '1px #DADADA solid', outlineOffset: '-1px', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 15, display: 'inline-flex'}}>
|
||||||
|
<img style={{width: 108, alignSelf: 'stretch', paddingLeft: 4, paddingRight: 4, paddingTop: 5, paddingBottom: 5, borderRadius: 12}} src="https://placehold.co/108x108" alt="Review" />
|
||||||
|
<div style={{flex: '1 1 0', flexDirection: 'column', justifyContent: 'center', alignItems: 'flex-start', gap: 15, display: 'inline-flex'}}>
|
||||||
|
<div style={{alignSelf: 'stretch', justifyContent: 'space-between', alignItems: 'flex-start', display: 'inline-flex'}}>
|
||||||
|
<div style={{justifyContent: 'flex-start', alignItems: 'flex-start', gap: 20, display: 'flex'}}>
|
||||||
|
<div style={{justifyContent: 'flex-start', alignItems: 'flex-start', gap: 4, display: 'flex'}}>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{flexDirection: 'column', justifyContent: 'center', alignItems: 'center', gap: 10, display: 'inline-flex'}}>
|
||||||
|
<div style={{alignSelf: 'stretch', color: '#333333', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 31, wordWrap: 'break-word'}}>alex*****@mail.com</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{color: '#333333', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '600', lineHeight: 31, wordWrap: 'break-word'}}>25.05.28</div>
|
||||||
|
</div>
|
||||||
|
<div style={{width: 704, color: '#333333', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 31, wordWrap: 'break-word'}}>
|
||||||
|
"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."
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{alignSelf: 'stretch', height: 168, padding: 30, background: '#F8F8F8', borderRadius: 12, outline: '1px #DADADA solid', outlineOffset: '-1px', justifyContent: 'flex-start', alignItems: 'flex-start', gap: 15, display: 'inline-flex'}}>
|
||||||
|
<div style={{flex: '1 1 0', flexDirection: 'column', justifyContent: 'center', alignItems: 'flex-start', gap: 15, display: 'inline-flex'}}>
|
||||||
|
<div style={{alignSelf: 'stretch', justifyContent: 'space-between', alignItems: 'flex-start', display: 'inline-flex'}}>
|
||||||
|
<div style={{justifyContent: 'flex-start', alignItems: 'flex-start', gap: 4, display: 'flex'}}>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
<div style={{width: 32, height: 30, position: 'relative', overflow: 'hidden'}}>
|
||||||
|
<div style={{width: 30.03, height: 29, left: 0.99, top: 0.50, position: 'absolute', background: '#CE1C5E'}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{color: '#333333', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '600', lineHeight: 31, wordWrap: 'break-word'}}>25.05.28</div>
|
||||||
|
</div>
|
||||||
|
<div style={{width: 704, color: '#333333', fontSize: 24, fontFamily: 'LG Smart UI', fontWeight: '400', lineHeight: 31, wordWrap: 'break-word'}}>
|
||||||
|
"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."
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserReviewPanelReviews;
|
||||||