[상품 상세] 개발 진행중 - UserReviews 기능 개선 및 UI 개선

- UserReviews 컴포넌트 리팩토링 및 페이지네이션 구현
- 새로운 hooks/useReviews 훅 추가
- DetailPanel UI/UX 개선 및 스타일 업데이트
- 이미지 스켈레톤 로딩 컴포넌트 추가
- THeaderDetail 컴포넌트 신규 추가
- 유틸리티 함수 확장 (fpHelpers.js)
This commit is contained in:
djaco
2025-09-08 10:59:04 +09:00
parent d002a9b390
commit 76ea1c439c
61 changed files with 5072 additions and 629 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -95,6 +95,36 @@ const disableConsole = () => {
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;
Spotlight.focus = function (elem, containerOption) {
const ret = originFocus.apply(this, [elem, containerOption]); // this 바인딩을 유지하여 originFocus 호출

View File

@@ -157,6 +157,7 @@ export const types = {
CLEAR_PRODUCT_OPTIONS: "CLEAR_PRODUCT_OPTIONS",
GET_USER_REVIEW: "GET_USER_REVIEW",
TOGGLE_SHOW_ALL_REVIEWS: "TOGGLE_SHOW_ALL_REVIEWS",
RESET_SHOW_ALL_REVIEWS: "RESET_SHOW_ALL_REVIEWS",
// search actions
GET_SEARCH: "GET_SEARCH",

View File

@@ -147,7 +147,7 @@ const extractReviewApiData = (apiResponse) => {
}
// 추출된 데이터 검증
console.log("[UserReviews] 📊 추출된 데이터 검증:", {
/* console.log("[UserReviews] 📊 추출된 데이터 검증:", {
hasReviewList: !!apiData.reviewList,
hasReviewDetail: !!apiData.reviewDetail,
reviewListLength: apiData.reviewList ? apiData.reviewList.length : 0,
@@ -155,7 +155,7 @@ const extractReviewApiData = (apiResponse) => {
totRvwCnt: apiData.reviewDetail && apiData.reviewDetail.totRvwCnt,
totRvwAvg: apiData.reviewDetail && apiData.reviewDetail.totRvwAvg,
extractedData: apiData
});
}); */
return apiData;
} catch (error) {
@@ -164,51 +164,80 @@ const extractReviewApiData = (apiResponse) => {
}
};
// Mock 데이터 생성 함수 (재사용성 위해 분리)
const createMockReviewData = () => ({
reviewList: [
{
rvwId: "mock-review-1",
rvwRtng: 5,
rvwCtnt: "The shoes are really stylish and comfortable for daily wear. I love the design and how lightweight they feel. However, the size runs a bit small, so I'd recommend ordering half a size up.",
rvwRgstDtt: "2024-01-15",
reviewImageList: [
{
imgId: "mock-img-1",
// Mock 데이터 생성 함수 (재사용성 위해 분리) - 100개 리뷰와 많은 이미지 포함
const createMockReviewData = () => {
const reviewTexts = [
"The shoes are really stylish and comfortable for daily wear. I love the design and how lightweight they feel.",
"Great value for the price! The quality is better than I expected. Shipping was fast and the product arrived in perfect condition.",
"Amazing product! Really happy with this purchase. The color is exactly as shown in the pictures.",
"Good quality overall but took a while to arrive. Packaging was excellent though.",
"Perfect fit and very comfortable. Would definitely buy again from this seller.",
"Beautiful design and great materials. My family loves it!",
"Exceeded my expectations. Really impressed with the craftsmanship.",
"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,
imgSeq: 1
}
]
},
{
rvwId: "mock-review-2",
rvwRtng: 4,
rvwCtnt: "Great value for the price! The quality is better than I expected. Shipping was fast and the product arrived in perfect condition. Would definitely recommend to others.",
rvwRgstDtt: "2024-01-10",
reviewImageList: []
imgSeq: j + 1
});
}
}
],
reviewDetail: {
totRvwCnt: 2,
totRvwAvg: 4.5
reviewList.push({
rvwId: `mock-review-${i}`,
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 상태 토글
export const toggleShowAllReviews = () => ({
type: types.TOGGLE_SHOW_ALL_REVIEWS
});
// showAllReviews 상태 초기화 (ProductAllSection 마운트 시 사용)
export const resetShowAllReviews = () => ({
type: types.RESET_SHOW_ALL_REVIEWS
});
// 상품별 유저 리뷰 리스트 조회 : IF-LGSP-0002
export const getUserReviews = (requestParams) => (dispatch, getState) => {
const { prdtId } = requestParams;
console.log("[UserReviews] 🚀 getUserReviews 액션 시작:", {
/* console.log("[UserReviews] 🚀 getUserReviews 액션 시작:", {
requestParams,
originalPrdtId: prdtId,
willUseRandomPrdtId: true, // 임시 테스트 플래그
timestamp: new Date().toISOString()
});
}); */
// ==================== [임시 테스트] 시작 ====================
// 테스트용 prdtId 목록 - 제거 시 이 블록 전체 삭제
@@ -232,16 +261,16 @@ export const getUserReviews = (requestParams) => (dispatch, getState) => {
const params = { prdtId: randomPrdtId }; // 임시: randomPrdtId 사용, 원본: prdtId 사용
const body = {}; // GET이므로 빈 객체
console.log("[UserReviews] 📡 TAxios 호출 준비:", {
/* console.log("[UserReviews] 📡 TAxios 호출 준비:", {
method: "get",
url: URLS.GET_USER_REVEIW,
params,
body,
selectedRandomPrdtId: randomPrdtId, // 임시: 선택된 랜덤 상품 ID
});
}); */
const onSuccess = (response) => {
console.log("[UserReviews] ✅ API 성공 응답:", {
/* console.log("[UserReviews] ✅ API 성공 응답:", {
status: response.status,
statusText: response.statusText,
headers: response.headers,
@@ -249,7 +278,28 @@ export const getUserReviews = (requestParams) => (dispatch, getState) => {
retMsg: response.data && response.data.retMsg,
hasData: !!(response.data && response.data.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) {
console.log("[UserReviews] 📊 API 데이터 상세:", {
@@ -268,7 +318,7 @@ export const getUserReviews = (requestParams) => (dispatch, getState) => {
console.log("[UserReviews] ✅ 실제 API 데이터 사용");
dispatch({
type: types.GET_USER_REVIEW,
payload: apiData
payload: { ...apiData, prdtId: prdtId }
});
console.log("[UserReviews] 📦 실제 API 데이터 디스패치 완료:", apiData);
} else {
@@ -276,7 +326,7 @@ export const getUserReviews = (requestParams) => (dispatch, getState) => {
const mockData = createMockReviewData();
dispatch({
type: types.GET_USER_REVIEW,
payload: mockData
payload: { ...mockData, prdtId: prdtId }
});
console.log("[UserReviews] 📦 Mock 데이터 디스패치 완료:", mockData);
}
@@ -297,7 +347,7 @@ export const getUserReviews = (requestParams) => (dispatch, getState) => {
const mockData = createMockReviewData();
dispatch({
type: types.GET_USER_REVIEW,
payload: mockData
payload: { ...mockData, prdtId: prdtId }
});
console.log("[UserReviews] 📦 Mock 데이터 디스패치 완료 (API 실패):", mockData);
};

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

View File

@@ -7,6 +7,7 @@ const initialState = {
prdtOptInfo: {},
reviewData: null, // 리뷰 데이터 추가
showAllReviews: false, // 전체 리뷰 보기 상태
loadedPrdtId: null, // 현재 로드된 상품 ID
};
// FP: handlers map (curried), pure and immutable updates only
@@ -43,21 +44,32 @@ const handleProductOptionId = curry((state, action) =>
// 유저 리뷰 데이터 핸들러 추가
const handleUserReview = curry((state, action) => {
const reviewData = get("payload", action);
const prdtId = get(["payload", "prdtId"], action);
console.log("[UserReviews] Reducer - Storing review data:", {
prdtId: prdtId,
hasData: !!reviewData,
reviewListLength: reviewData && reviewData.reviewList ? reviewData.reviewList.length : 0,
totalCount: reviewData && reviewData.reviewDetail ? reviewData.reviewDetail.totRvwCnt : 0
});
return set("reviewData", reviewData, state);
return set("reviewData", reviewData,
set("loadedPrdtId", prdtId, state));
});
// showAllReviews 토글 핸들러
const handleToggleShowAllReviews = curry((state, action) => {
const currentValue = get("showAllReviews", state);
console.log("[UserReviews] Toggle showAllReviews:", !currentValue);
// console.log("[UserReviews] Toggle showAllReviews:", !currentValue);
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 = {
[types.GET_BEST_SELLER]: handleBestSeller,
[types.GET_PRODUCT_OPTION]: handleProductOption,
@@ -68,6 +80,7 @@ const handlers = {
[types.GET_PRODUCT_OPTION_ID]: handleProductOptionId,
[types.GET_USER_REVIEW]: handleUserReview, // GET_USER_REVIEW 핸들러 추가
[types.TOGGLE_SHOW_ALL_REVIEWS]: handleToggleShowAllReviews, // showAllReviews 토글 핸들러
[types.RESET_SHOW_ALL_REVIEWS]: handleResetShowAllReviews, // showAllReviews 초기화 핸들러
};
export const productReducer = (state = initialState, action = {}) => {

View File

@@ -37,6 +37,9 @@ export const panel_names = {
// debug
DEBUG_PANEL: "debugpanel",
VIDEO_TEST_PANEL: "videotestpanel",
// user review
USER_REVIEW_PANEL: "userreviewpanel",
};
//button

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

View File

@@ -167,7 +167,9 @@ const forEachAsync = fp.curry(async (cb, collection) => {
const loopResults = [];
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]));
}
@@ -478,8 +480,10 @@ const tryCatch = fp.curry((tryFn, catchFn, value) => {
*/
const safeGet = fp.curry((path, defaultValue, obj) => {
try {
return fp.get(path, obj) ?? defaultValue;
} catch {
const result = fp.get(path, obj);
// Chromium 68 호환성: ?? 연산자 대신 일반 조건문 사용
return result !== null && result !== undefined ? result : defaultValue;
} catch (error) {
return defaultValue;
}
});
@@ -491,7 +495,7 @@ const safeGet = fp.curry((path, defaultValue, obj) => {
* @param {Array} 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 {Array} array 대상 배열
*/
const insertAt = fp.curry((index, item, array) => [
...array.slice(0, index),
item,
...array.slice(index)
]);
const insertAt = fp.curry((index, item, array) => {
// Chromium 68 호환성: spread 연산자 대신 concat 사용
return array.slice(0, index).concat([item]).concat(array.slice(index));
});
/**
* 값을 첫 번째 인자로 받는 applicative
@@ -542,7 +545,7 @@ const applyTo = fp.curry((value, fn) => fn(value));
* @param {Array} fns 함수 배열
* @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 함수로 조합
@@ -550,9 +553,11 @@ const juxt = fp.curry((fns, value) => fns.map(fn => fn(value)));
* @param {Array} fns 함수 배열
* @param {*} value 대상 값
*/
const converge = fp.curry((convergeFn, fns, value) =>
convergeFn(...fns.map(fn => fn(value)))
);
const converge = fp.curry((convergeFn, fns, value) => {
// Chromium 68 호환성: spread 연산자 대신 apply 사용
const results = fns.map(function(fn) { return fn(value); });
return convergeFn.apply(null, results);
});
/**
* 문자열을 trim하고 빈 문자열이면 undefined 반환
@@ -567,8 +572,10 @@ const trimToUndefined = (str) => {
* 첫 글자 대문자, 나머지 소문자
* @param {string} str 대상 문자열
*/
const capitalize = (str) =>
str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
const capitalize = function(str) {
if (!str || typeof str !== 'string') return str;
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
};
/**
* 값을 min과 max 사이로 제한
@@ -615,7 +622,7 @@ const elvis = fp.curry((fn, value) =>
*/
const partition = fp.curry((predicate, array) => [
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 {number} n 실행 횟수
*/
const times = fp.curry((fn, n) =>
Array.from({ length: n }, (_, i) => fn(i))
);
const times = fp.curry((fn, n) => {
// Chromium 68 호환성: Array.from 대신 일반 루프 사용
const result = [];
for (let i = 0; i < n; i++) {
result.push(fn(i));
}
return result;
});
/**
* 지연 평가 함수 (memoization과 유사)
@@ -634,9 +646,10 @@ const times = fp.curry((fn, n) =>
const lazy = (fn) => {
let cached = false;
let result;
return (...args) => {
// Chromium 68 호환성: spread 연산자 대신 arguments 사용
return function() {
if (!cached) {
result = fn(...args);
result = fn.apply(this, arguments);
cached = true;
}
return result;

View File

@@ -37,6 +37,7 @@ import css from "./DetailPanel.module.less";
import ProductAllSection from "./ProductAllSection/ProductAllSection";
import { getThemeCurationDetailInfo } from "../../actions/homeActions";
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 Spinner from "@enact/sandstone/Spinner";
@@ -73,7 +74,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
const containerRef = useRef(null);
// FP 파생 값 메모이제이션 (optional chaining 대체 및 deps 안정화)
const panelType = useMemo(() => fp.pipe(() => panelInfo, fp.get('type'))(), [panelInfo]);
const panelCurationId = useMemo(() => fp.pipe(() => panelInfo, fp.get('curationId'))(), [panelInfo]);
const panelPatnrId = useMemo(() => fp.pipe(() => panelInfo, fp.get('patnrId'))(), [panelInfo]);
@@ -83,7 +84,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
const productPmtSuptYn = useMemo(() => fp.pipe(() => productData, fp.get('pmtSuptYn'))(), [productData]);
const productGrPrdtProcYn = useMemo(() => fp.pipe(() => productData, fp.get('grPrdtProcYn'))(), [productData]);
// FP 방식으로 데이터 소스 결정 (메모이제이션 최적화)
const productDataSource = useMemo(() =>
fp.pipe(
() => panelType,
@@ -194,6 +195,22 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
[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 처리 (메모리 누수 방지)
useEffect(() => {
const shouldExecutePendingScroll = fp.pipe(
@@ -547,7 +564,13 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
: ""
)(), [panelPrdtId, productData]);
// ===== 파트너사별 배경 이미지 설정 로직 (현재 비활성화) =====
// thumbnailUrl960을 사용하여 파트너사별로 다른 배경 이미지를 설정하는 기능
// Pink Pong 등 특정 파트너사에서만 thumbnailUrl960 데이터가 있어서 배경이 변경됨
// 현재는 고정 배경(detailPanelBg)만 사용하기 위해 주석 처리
// FP 방식으로 배경 이미지 설정 (메모리 누수 방지)
/*
useLayoutEffect(() => {
const shouldSetBackground = fp.pipe(
() => ({ imageUrl, containerRef }),
@@ -559,6 +582,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
containerRef.current.style.setProperty("--bg-url", `url('${imageUrl}')`);
}
}, [imageUrl]);
*/
console.log("productDataSource :", productDataSource);

View File

@@ -12,7 +12,7 @@
rgba(0, 0, 0, 0.7) 100%
),
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 {

View File

@@ -1,108 +1,85 @@
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 */
// src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx
import SpotlightContainerDecorator
from '@enact/spotlight/SpotlightContainerDecorator';
import Spottable from '@enact/spotlight/Spottable';
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
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
import TButton from '../../../components/TButton/TButton';
import useScrollTo from '../../../hooks/useScrollTo';
import TButton from "../../../components/TButton/TButton";
import { $L } from "../../../utils/helperMethods";
import {
andThen,
curry,
defaultTo,
defaultWith,
get,
identity,
isEmpty,
isNil,
isNotNil,
isVal,
pipe,
tap,
when,
} from '../../../utils/fp';
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';
curry, pipe, when, isVal, isNotNil, defaultTo, defaultWith, get, identity, isEmpty, isNil, andThen, tap
} from "../../../utils/fp";
import { resetShowAllReviews } from "../../../actions/productActions";
import useReviews from "../../../hooks/useReviews/useReviews";
import { pushPanel } from "../../../actions/panelActions";
import { panel_names } from "../../../utils/Config";
import ViewAllReviewsButton from "../ProductContentSection/UserReviews/ViewAllReviewsButton";
import FavoriteBtn from "../components/FavoriteBtn";
import StarRating from "../components/StarRating";
import ProductTag from "../components/ProductTag";
import DetailMobileSendPopUp from "../components/DetailMobileSendPopUp";
import { SpotlightIds } from "../../../utils/SpotlightIds";
import QRCode from "../ProductInfoSection/QRCode/QRCode";
import ProductOverview from "../ProductOverview/ProductOverview";
// ProductContentSection imports
import TScrollerDetail from '../components/TScroller/TScrollerDetail';
import ProductDescription
from '../ProductContentSection/ProductDescription/ProductDescription';
import ProductDetail
from '../ProductContentSection/ProductDetail/ProductDetail.new';
import UserReviews from '../ProductContentSection/UserReviews/UserReviews';
import YouMayAlsoLike
from '../ProductContentSection/YouMayAlsoLike/YouMayAlsoLike';
import QRCode from '../ProductInfoSection/QRCode/QRCode';
import ProductOverview from '../ProductOverview/ProductOverview';
import TScrollerDetail from "../components/TScroller/TScrollerDetail";
import CustomScrollbar from "../components/CustomScrollbar/CustomScrollbar";
import useScrollTo from "../../../hooks/useScrollTo";
import ProductDetail from "../ProductContentSection/ProductDetail/ProductDetail.new";
import UserReviews from "../ProductContentSection/UserReviews/UserReviews";
import YouMayAlsoLike from "../ProductContentSection/YouMayAlsoLike/YouMayAlsoLike";
import ProductDescription from "../ProductContentSection/ProductDescription/ProductDescription";
import ShowUserReviews from "../../UserReview/ShowUserReviews";
// CSS imports
// import infoCSS from "../ProductInfoSection/ProductInfoSection.module.less";
// import contentCSS from "../ProductContentSection/ProductContentSection.module.less";
import css from './ProductAllSection.module.less';
import css from "./ProductAllSection.module.less";
const Container = SpotlightContainerDecorator(
{
enterTo: "last-focused",
preserveld: true,
{
enterTo: "last-focused",
preserveld: true,
leaveFor: { right: "content-scroller-container" },
spotlightDirection: "vertical",
spotlightDirection: "vertical"
},
"div"
"div",
);
const ContentContainer = SpotlightContainerDecorator(
{
enterTo: "default-element",
preserveld: true,
leaveFor: {
left: "spotlight-product-info-section-container",
{
enterTo: "default-element",
preserveld: true,
leaveFor: {
left: "spotlight-product-info-section-container"
},
restrict: "none",
spotlightDirection: "vertical",
spotlightDirection: "vertical"
},
"div"
"div",
);
const HorizontalContainer = SpotlightContainerDecorator(
{
enterTo: "last-focused",
preserveld: true,
{
enterTo: "last-focused",
preserveld: true,
defaultElement: "spotlight-product-info-section-container",
spotlightDirection: "horizontal",
spotlightDirection: "horizontal"
},
"div"
"div",
);
// FP: Pure function to determine product data based on type
const getProductData = curry((productType, themeProductInfo, productInfo) =>
pipe(
when(
() =>
isVal(productType) &&
productType === "theme" &&
isVal(themeProductInfo),
() => isVal(productType) && productType === "theme" && isVal(themeProductInfo),
() => themeProductInfo
),
defaultTo(productInfo),
@@ -114,26 +91,31 @@ const getProductData = curry((productType, themeProductInfo, productInfo) =>
const deriveFavoriteFlag = curry((favoriteOverride, productData) =>
pipe(
when(isNotNil, identity),
defaultWith(() => pipe(get("favorYn"), defaultTo("N"))(productData))
defaultWith(() =>
pipe(
get("favorYn"),
defaultTo("N")
)(productData)
)
)(favoriteOverride)
);
// FP: Pure function to extract review grade and order phone
const extractProductMeta = (productInfo) => ({
revwGrd: get("revwGrd", productInfo),
orderPhnNo: get("orderPhnNo", productInfo),
orderPhnNo: get("orderPhnNo", productInfo)
});
// 레이아웃 확인용 샘플 컴포넌트 - Spottable로 변경
const SpottableComponent = Spottable("div");
const LayoutSample = ({ onClick }) => (
<SpottableComponent
<SpottableComponent
className={css.layoutSample}
onClick={onClick}
spotlightId="layout-sample-button"
>
Layout Sample - Click to Show All Reviews (1124px x 300px)
Click to Show All Reviews (W-1124px)
</SpottableComponent>
);
@@ -150,69 +132,101 @@ export default function ProductAllSection({
themeProductInfo,
}) {
const dispatch = useDispatch();
const productData = useMemo(
() => getProductData(productType, themeProductInfo, productInfo),
const productData = useMemo(() =>
getProductData(productType, themeProductInfo, productInfo),
[productType, themeProductInfo, productInfo]
);
// [임시 테스트] LayoutSample 클릭 핸들러
const handleLayoutSampleClick = useCallback(() => {
console.log(
"[Test] LayoutSample clicked - dispatching toggleShowAllReviews"
);
dispatch(toggleShowAllReviews());
}, [dispatch]);
// 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(() => {
console.log(`[ProductId] LayoutSample clicked - opening UserReviewPanel`, {
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, productData, stats]);
// 디버깅: 실제 이미지 데이터 확인
useEffect(() => {
console.log("[ProductAllSection] Image data check:", {
console.log("[ProductId] ProductAllSection productData check:", {
hasProductData: !!productData,
imgUrls600: productData?.imgUrls600,
imgUrls600Length: productData?.imgUrls600?.length,
imgUrls600Type: Array.isArray(productData?.imgUrls600)
? "array"
: typeof productData?.imgUrls600,
productDataPrdtId: productData && productData.prdtId,
imgUrls600: productData && productData.imgUrls600,
imgUrls600Length: productData && productData.imgUrls600 && productData.imgUrls600.length,
imgUrls600Type: Array.isArray(productData && productData.imgUrls600) ? 'array' : typeof (productData && productData.imgUrls600),
productData: productData
});
}, [productData]);
const { revwGrd, orderPhnNo } = useMemo(
() => extractProductMeta(productInfo),
const { revwGrd, orderPhnNo } = useMemo(() =>
extractProductMeta(productInfo),
[productInfo]
);
// FP: derive favorite flag from props with local override, avoid non-I/O useEffect
const [favoriteOverride, setFavoriteOverride] = useState(null);
const favoriteFlag = useMemo(
() => deriveFavoriteFlag(favoriteOverride, productData),
const favoriteFlag = useMemo(() =>
deriveFavoriteFlag(favoriteOverride, productData),
[favoriteOverride, productData]
);
const [mobileSendPopupOpen, setMobileSendPopupOpen] = useState(false);
// 🔧 [임시] 고객 데모용: UserReviews 버튼은 숨기고 UserReviews 섹션만 표시
const showUserReviewsButton = false; // 임시 변경 - 버튼 숨김
const showUserReviewsSection = true; // 임시 변경 - 섹션은 항상 표시
const reviewTotalCount = useSelector(
pipe(
get(["product", "reviewData", "reviewDetail", "totRvwCnt"]),
defaultTo(0)
)
);
// useReviews에서 모든 리뷰 데이터 관리
const reviewTotalCount = stats.totalReviews;
const reviewData = { reviewList: previewReviews, reviewDetail: { totRvwCnt: stats.totalReviews, avgRvwScr: stats.averageRating } };
// User Reviews 스크롤 핸들러 추가
const handleUserReviewsClick = useCallback(
() => scrollToSection("scroll-marker-user-reviews"),
[scrollToSection]
[]
);
const scrollContainerRef = useRef(null);
const { getScrollTo, scrollTop } = useScrollTo();
// FP: Pure function for mobile popup state change
const handleShopByMobileOpen = useCallback(
pipe(() => true, setMobileSendPopupOpen),
pipe(
() => true,
setMobileSendPopupOpen
),
[]
);
@@ -262,11 +276,12 @@ export default function ProductAllSection({
[scrollToSection]
);
return (
<HorizontalContainer className={css.detailArea}>
{/* Left Margin Section - 60px */}
<div className={css.leftMarginSection}></div>
{/* Info Section - 645px */}
<div className={css.infoSection}>
<Container
@@ -294,22 +309,14 @@ export default function ProductAllSection({
productType={productType}
>
<div className={css.qrWrapper}>
{/* <QRCode productInfo={productData} productType={productType} /> */}
<QRCode
productInfo={productData}
productType={productType}
kind="detail"
/>
<QRCode productInfo={productData} productType={productType} />
</div>
</ProductOverview>
<Container className={css.buttonContainer}>
<TButton
spotlightId={SpotlightIds.DETAIL_SHOPBYMOBILE}
className={classNames(
css.shopByMobileButton,
css.shopByMobileOne
)}
className={css.shopByMobileButton}
onClick={handleShopByMobileOpen}
onSpotlightUp={handleSpotlightUpToBackButton}
>
@@ -325,7 +332,6 @@ export default function ProductAllSection({
selectedPrdtId={panelInfo && panelInfo.prdtId}
favoriteFlag={favoriteFlag}
onFavoriteFlagChanged={onFavoriteFlagChanged}
kind={"item_detail"}
/>
</div>
)}
@@ -356,30 +362,27 @@ export default function ProductAllSection({
>
{$L("PRODUCT DETAILS")}
</TButton>
{/* 🔧 [임시] 고객 데모용 조건 변경: showUserReviewsButton (원래: reviewTotalCount > 0) */}
{/* {showUserReviewsButton && ( */}
{reviewTotalCount > 0 && (
<TButton
{hasReviews && (
<TButton
className={css.userReviewsButton}
onClick={handleUserReviewsClick}
spotlightId="user-reviews-button"
>
{$L(
`USER REVIEWS (${reviewTotalCount > 100 ? "100" : reviewTotalCount || "0"})`
)}
{$L("USER REVIEWS")}
</TButton>
)}
{hasYouMayAlsoLike && (
<TButton
className={css.youMayLikeButton}
onClick={handleYouMayAlsoLikeClick}
>
{$L("YOU MAY ALSO LIKE")}
</TButton>
)}
<TButton
className={css.youMayLikeButton}
onClick={handleYouMayAlsoLikeClick}
>
{$L("YOU MAY ALSO LIKE")}
</TButton>
</Container>
{panelInfo &&
panelInfo &&
panelInfo.type === "theme" &&
panelInfo && panelInfo.type === "theme" &&
!openThemeItemOverlay && (
<TButton
className={css.themeButton}
@@ -425,17 +428,16 @@ export default function ProductAllSection({
></div>
<LayoutSample onClick={handleLayoutSampleClick} />
<div id="product-details-section">
{productData?.imgUrls600 &&
productData.imgUrls600.length > 0 ? (
{productData && productData.imgUrls600 && productData.imgUrls600.length > 0 ? (
productData.imgUrls600.map((image, index) => (
<ProductDetail
<ProductDetail
key={`product-detail-${index}`}
productInfo={{
...productData,
singleImage: image,
imageIndex: index,
totalImages: productData.imgUrls600.length,
}}
totalImages: productData.imgUrls600.length
}}
/>
))
) : (
@@ -445,36 +447,46 @@ export default function ProductAllSection({
<div id="product-description-section">
<ProductDescription productInfo={productData} />
</div>
<div
id="scroll-marker-user-reviews"
className={css.scrollMarker}
></div>
{/* Description 바로 아래에 UserReviews 항상 표시 (조건 제거) */}
<div id="user-reviews-section">
<UserReviews
productInfo={productData}
panelInfo={panelInfo}
/>
</div>
</div>
<div
id="scroll-marker-you-may-also-like"
className={css.scrollMarker}
></div>
<div id="you-may-also-like-section">
<YouMayAlsoLike
productInfo={productData}
panelInfo={panelInfo}
/>
{/* 리뷰가 있을 때만 UserReviews 섹션 표시 */}
{hasReviews && (
<>
<div id="scroll-marker-user-reviews" className={css.scrollMarker}></div>
<div id="user-reviews-section">
<UserReviews
productInfo={productData}
panelInfo={panelInfo}
reviewsData={{
previewReviews: previewReviews.slice(0, 5), // 처음 5개만
stats: stats,
isLoading: reviewsLoading
}}
/>
</div>
{/* <ViewAllReviewsButton /> */}
<ShowUserReviews />
</>
)}
</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>
</div>
</ContentContainer>
</div>
</HorizontalContainer>
);
}
ProductAllSection.propTypes = {
productType: PropTypes.oneOf(["buyNow", "shopByMobile", "theme"]).isRequired,
};
};

View File

@@ -11,7 +11,7 @@
justify-content: flex-start;
align-items: flex-start;
position: relative; // 절대 위치 기준점
// Spotlight 좌우 이동을 위한 설정
&:focus-within {
outline: none;
@@ -35,22 +35,19 @@
position: absolute;
left: 60px;
top: 0;
width: 650px;
width: 645px;
height: 100%;
padding: 0;
margin: 0;
display: flex;
justify-content: flex-start;
align-items: flex-start;
> div {
height: 339px;
}
}
// 3. Content Section - 1180px (1114px 콘텐츠 + 66px 스크롤바)
.contentSection {
position: absolute;
left: 705px; // 60px + 645px
left: 705px; // 60px + 645px
top: 0;
width: 1200px; // 30px 마진 + 1114px 콘텐츠 + 66px 스크롤바
height: 100%;
@@ -87,13 +84,11 @@
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
// gap 대신 margin 사용 (Chromium 68 호환성)
> * {
margin-bottom: 5px;
&:last-child {
margin-bottom: 0;
}
&:last-child { margin-bottom: 0; }
}
}
@@ -119,6 +114,7 @@
justify-content: flex-start;
align-items: flex-start;
position: relative; // 자식 absolute 요소의 기준점
// 스크롤러 오버라이드 (1210px = 30px + content + 스크롤바)
.scrollerOverride {
@@ -143,16 +139,17 @@
}
&::-webkit-scrollbar-thumb {
background: #9c9c9c; // 스크롤바 색상
background: #9C9C9C; // 스크롤바 색상
border-radius: 3px; // 스크롤바 둥근 모서리
}
// 스크롤바 thumb에 hover 효과 적용
// 스크롤바 thumb에 hover 효과 적용
&:hover::-webkit-scrollbar-thumb {
background: #c72054;
background: #C72054;
}
}
// 내부 콘텐츠는 별도 너비 계산 없이 100%를 사용
> div {
width: 100%; // 부모의 패딩을 제외한 나머지 공간(1114px)을 모두 사용
@@ -177,7 +174,7 @@
display: flex;
flex-direction: column;
align-items: flex-start;
// ProductDetail.new 컴포넌트들
> div[class*="rollingWrap"] {
width: 100% !important; // 부모 영역 전체 사용
@@ -185,9 +182,9 @@
box-sizing: border-box;
}
}
#product-description-section,
#user-reviews-section,
#user-reviews-section,
#you-may-also-like-section {
width: 100%; // 부모 콘텐츠 영역 전체 사용
max-width: none;
@@ -195,13 +192,13 @@
margin: 0;
box-sizing: border-box;
overflow: visible;
> * {
max-width: 100% !important; // 부모 영역 전체 사용
width: 100% !important;
box-sizing: border-box;
}
// 이미지들이 컨테이너를 넘지 않도록
img {
max-width: 100% !important;
@@ -232,6 +229,7 @@
// (중복 제거됨) 최상위 스크롤러/섹션 정의는 .scrollerWrapper 중첩 내부로 이동
// ProductDetailCard 스타일 참고 - 크기/간격만 적용
// 헤더 컨텐츠 영역 (태그, 별점)
@@ -239,6 +237,7 @@
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
// 모바일 쇼핑 섹션 (mobileSection 참고)
@@ -248,12 +247,10 @@
display: flex;
justify-content: flex-start;
align-items: center;
> * {
margin-right: 6px;
&:last-child {
margin-right: 0;
}
&:last-child { margin-right: 0; }
}
}
@@ -261,18 +258,15 @@
flex: 1 1 0 !important;
width: auto !important; // flex로 크기 조정
height: 60px !important;
background: rgba(68, 68, 68, 0.5) !important;
background: rgba(68, 68, 68, 0.50) !important;
border-radius: 6px !important;
border: none !important;
padding: 0 !important;
margin: 0;
margin: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
&.shopByMobileOne {
margin: 0 10px 0 0;
}
.shopByMobileText {
color: white !important;
font-size: 25px !important;
@@ -281,12 +275,12 @@
line-height: 35px !important;
text-align: center !important;
}
// 포커스 상태 추가
&:focus {
background: @PRIMARY_COLOR_RED !important; // 포커스시 빨간색 배경
outline: 2px solid @PRIMARY_COLOR_RED !important;
.shopByMobileText {
color: white !important; // 포커스시에도 텍스트는 흰색 유지
}
@@ -312,39 +306,38 @@
display: flex;
justify-content: space-between;
align-items: center;
.callToOrderText {
color: #eaeaea;
color: #EAEAEA;
font-size: 25px;
font-family: @baseFont; // LG Smart 폰트 사용
font-weight: 400; // Bold에서 Regular로 변경
line-height: 35px;
}
.phoneSection {
padding: 0 1px;
display: flex;
align-items: center;
> * {
margin-right: 10px;
&:last-child {
margin-right: 0;
}
&:last-child { margin-right: 0; }
}
.phoneIconContainer {
width: 25px;
height: 25px;
position: relative;
overflow: hidden;
.phoneIcon {
width: 24.94px;
height: 24.97px;
position: absolute;
left: 0;
top: 0;
background: #EAEAEA;
// 전화 아이콘 이미지 또는 CSS로 구현
background-image: url("../../../../assets/images/icons/ic-gr-call-1.png");
background-size: contain;
@@ -352,11 +345,11 @@
background-position: center;
}
}
.phoneNumber {
color: #eaeaea;
color: #EAEAEA;
font-size: 25px;
font-family: "LG Smart UI";
font-family: 'LG Smart UI';
font-weight: 700;
line-height: 35px;
}
@@ -369,12 +362,10 @@
padding-top: 20px;
display: flex;
flex-direction: column;
> * {
margin-bottom: 5px;
&:last-child {
margin-bottom: 0;
}
&:last-child { margin-bottom: 0; }
}
}
@@ -389,15 +380,15 @@
display: flex;
align-items: center;
justify-content: center;
color: #eaeaea;
color: #EAEAEA;
font-size: 25px;
font-family: @baseFont; // LG Smart 폰트 사용
font-weight: 400; // Bold에서 Regular로 변경
line-height: 35px;
&:focus {
background: #c72054; // 포커스시만 빨간색
background: #C72054; // 포커스시만 빨간색
}
}
@@ -407,9 +398,9 @@
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
margin-top: 10px;
&:focus {
background: #c72054;
background: #C72054;
}
}
@@ -420,35 +411,65 @@
display: flex;
flex-direction: column;
align-items: flex-end;
> * {
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
&:last-child { margin-bottom: 0; }
}
}
// 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 {
align-self: stretch;
display: flex;
justify-content: flex-start;
align-items: flex-start;
}
> div:first-child {
width: 380px;
text-align: left;
> * {
margin-right: 15px; // ProductDetailCard와 동일한 간격
&:last-child { margin-right: 0; }
}
// 가격 섹션 (flex로 남은 공간 차지)
> div:first-child {
flex: 1 1 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
// 이미지 섹션 (QR 포함, 고정 크기)
> div:last-child {
width: 240px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
> * {
margin-bottom: 10px;
&:last-child { margin-bottom: 0; }
}
}
}
}
// LayoutSample 포커스 테스트용 스타일
.layoutSample {
width: 1124px;
height: 300px;
height: 35px;
background-color: yellow;
// border: 2px solid white;
margin-bottom: 20px;
display: flex;
justify-content: center;
@@ -459,7 +480,7 @@
cursor: pointer;
position: relative;
border-radius: 8px;
&:focus {
&::after {
.focused(@boxShadow:22px, @borderRadius:8px);

View File

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

View File

@@ -46,12 +46,12 @@ export default function ProductDetail({ productInfo }) {
// 메인 이미지 영역 포커스 핸들러
const onFocus = useCallback(() => {
const imageIndex = productInfo?.imageIndex ?? 0;
console.log(`[ProductDetail] Image ${imageIndex + 1} focused`);
// console.log(`[ProductDetail] Image ${imageIndex + 1} focused`);
}, [productInfo?.imageIndex]);
const onBlur = useCallback(() => {
const imageIndex = productInfo?.imageIndex ?? 0;
console.log(`[ProductDetail] Image ${imageIndex + 1} blurred`);
// console.log(`[ProductDetail] Image ${imageIndex + 1} blurred`);
}, [productInfo?.imageIndex]);
// 단일 이미지 렌더링 (항상 하나의 이미지만)

View File

@@ -37,6 +37,10 @@
bottom: -6px;
left: -6px;
}
.thumbnailWrapper .productImage {
transform: scale(1.015); // 가로세로 10px 정도 확대 효과
}
}
}

View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useState } from "react";
import css from "./CustomerImages.module.less";
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
import { useSelector } from "react-redux";
import Spottable from "@enact/spotlight/Spottable";
import THeader from "../../../../../components/THeader/THeader";
import { $L } from "../../../../../utils/helperMethods";
@@ -21,131 +20,41 @@ const Container = SpotlightContainerDecorator(
const SpottableComponent = Spottable("div");
export default function CustomerImages({ onImageClick }) {
// Redux에서 reviewData 전체를 가져옴
const reviewData = useSelector((state) => state.product.reviewData);
const reviewListData = reviewData?.reviewList;
const [imageList, setImageList] = useState([]);
export default function CustomerImages({ onImageClick, onViewMoreClick, imageData }) {
// Props로 전달받은 imageData 사용 (useReviews Hook에서 추출된 이미지 데이터)
const [selectedIndex, setSelectedIndex] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const IMAGES_PER_PAGE = 5;
// [UserReviews] CustomerImages 데이터 수신 확인 - 개선된 로깅
useEffect(() => {
console.log("[UserReviews] CustomerImages - Full review data received:", {
reviewData,
reviewListData,
hasData: reviewData && reviewListData && reviewListData.length > 0,
reviewCount: reviewListData?.length || 0
});
}, [reviewData, reviewListData]);
// 이미지 데이터 처리 로직 개선
useEffect(() => {
console.log("[UserReviews] CustomerImages - Processing review data:", {
reviewListData,
reviewListType: Array.isArray(reviewListData) ? 'array' : typeof reviewListData
});
if (!reviewListData || !Array.isArray(reviewListData)) {
console.log("[UserReviews] CustomerImages - No valid review data available");
setImageList([]);
return;
}
// 각 리뷰의 구조를 자세히 로깅
reviewListData.forEach((review, index) => {
console.log(`[UserReviews] CustomerImages - Review ${index} structure:`, {
rvwId: review.rvwId,
hasReviewImageList: !!review.reviewImageList,
reviewImageListType: Array.isArray(review.reviewImageList) ? 'array' : typeof review.reviewImageList,
reviewImageListLength: review.reviewImageList?.length || 0,
reviewImageList: review.reviewImageList
});
});
// 이미지가 있는 리뷰만 필터링
const imageReviews = reviewListData.filter(
(review) => review.reviewImageList &&
Array.isArray(review.reviewImageList) &&
review.reviewImageList.length > 0
);
console.log("[UserReviews] CustomerImages - Reviews with images:", {
totalReviews: reviewListData.length,
imageReviewsCount: imageReviews.length,
imageReviews: imageReviews
});
const images = [];
// 각 리뷰의 이미지들을 수집
imageReviews.forEach((review, reviewIndex) => {
console.log(`[UserReviews] CustomerImages - Processing review ${reviewIndex}:`, {
rvwId: review.rvwId,
imageCount: review.reviewImageList?.length || 0
});
if (review.reviewImageList && Array.isArray(review.reviewImageList)) {
review.reviewImageList.forEach((imgItem, imgIndex) => {
const { imgId, imgUrl, imgSeq } = imgItem;
console.log(`[UserReviews] CustomerImages - Adding image ${imgIndex}:`, {
imgId,
imgSeq,
imgUrl,
isValidUrl: !!imgUrl && imgUrl !== '',
urlType: typeof imgUrl,
urlLength: imgUrl?.length || 0
});
// 유효한 이미지 URL만 추가
if (imgUrl && imgUrl.trim() !== '') {
images.push({
imgId: imgId || `img-${reviewIndex}-${imgIndex}`,
imgUrl,
imgSeq: imgSeq || imgIndex + 1,
reviewId: review.rvwId
});
} else {
console.warn(`[UserReviews] CustomerImages - Skipping invalid image URL:`, imgUrl);
}
});
}
});
console.log("[UserReviews] CustomerImages - Final image list:", {
totalImages: images.length,
images: images
});
setImageList(images);
}, [reviewListData]);
// [CustomerImages] useReviews 이미지 데이터 수신 확인
// useEffect(() => {
// console.log("[CustomerImages] useReviews 이미지 데이터 확인:", {
// imageData,
// hasImageData: !!imageData,
// imageCount: imageData?.length || 0,
// dataSource: 'useReviews.extractImagesFromReviews'
// });
// }, [imageData]);
// 이미지 목록이 변경되면 페이지를 초기화
useEffect(() => {
setCurrentPage(1);
}, [imageList]);
}, [imageData]);
const handleReviewImageClick = (index) => {
console.log("[UserReviews] CustomerImages - Image clicked at index:", index, {
imageData: imageList[index]
});
setSelectedIndex(index);
// 부모 컴포넌트에 팝업 열기 이벤트 전달
// 이미지 클릭 시 All-Images 모드로 팝업 열기
if (onImageClick) {
onImageClick(index);
}
};
const handleViewMoreClick = () => {
console.log("[UserReviews] CustomerImages - View more clicked", {
currentPage,
totalImages: imageList.length,
totalPages: Math.ceil(imageList.length / IMAGES_PER_PAGE)
});
setCurrentPage(prev => prev + 1);
// +View More 버튼 클릭 시 Customer Images 모드로 팝업 열기
if (onViewMoreClick) {
onViewMoreClick();
}
};
// 키 이벤트 처리 (왼쪽 화살표, Enter 키)
@@ -168,18 +77,18 @@ export default function CustomerImages({ onImageClick }) {
<>
<Container className={css.container}>
<THeader className={css.tHeader} title={$L("Customer Images")} />
{imageList && imageList.length > 0 ? (
{imageData && imageData.length > 0 ? (
<div className={css.wrapper}>
{(() => {
const startIndex = (currentPage - 1) * IMAGES_PER_PAGE;
const endIndex = startIndex + IMAGES_PER_PAGE;
const displayImages = imageList.slice(startIndex, endIndex);
const hasMoreImages = imageList.length > endIndex;
const displayImages = imageData.slice(startIndex, endIndex);
const hasMoreImages = imageData.length > endIndex;
console.log("[CustomerImages] Pagination debug:", {
currentPage,
IMAGES_PER_PAGE,
totalImages: imageList.length,
totalImages: imageData.length,
startIndex,
endIndex,
displayImagesCount: displayImages.length,
@@ -208,14 +117,14 @@ export default function CustomerImages({ onImageClick }) {
src={imgUrl}
alt={`Review image ${actualIndex + 1}`}
onLoad={() => {
console.log(`[UserReviews] CustomerImages - Image loaded successfully:`, {
/* console.log(`[CustomerImages] Image loaded successfully:`, {
index: actualIndex,
imgUrl,
imgId
});
}); */
}}
onError={(e) => {
console.error(`[UserReviews] CustomerImages - Image load failed:`, {
console.error(`[CustomerImages] Image load failed:`, {
index: actualIndex,
imgUrl,
imgId,
@@ -258,7 +167,7 @@ export default function CustomerImages({ onImageClick }) {
padding: '20px',
fontSize: '16px'
}}>
{reviewData ? 'No customer images available' : 'Loading customer images...'}
{imageData ? 'No customer images available' : 'Loading customer images...'}
</div>
</div>
)}

View File

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

View File

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

View File

@@ -1,14 +1,15 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import css from "./UserReviews.module.less";
import TScroller from "../../../../components/TScroller/TScroller";
import UserReviewsScroller from "../../components/UserReviewsScroller/UserReviewsScroller";
import useScrollTo from "../../../../hooks/useScrollTo";
import THeader from "../../../../components/THeader/THeader";
import THeaderDetail from "../../components/THeaderDetail";
import { $L } from "../../../../utils/helperMethods";
import { useMemo } from "react";
import Spottable from "@enact/spotlight/Spottable";
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
import { useDispatch, useSelector } from "react-redux";
import { getUserReviews, toggleShowAllReviews } from "../../../../actions/productActions";
import { toggleShowAllReviews } from "../../../../actions/productActions";
import useReviews from "../../../../hooks/useReviews/useReviews";
import StarRating from "../../components/StarRating";
import CustomerImages from "./CustomerImages/CustomerImages";
import UserReviewsPopup from "./UserReviewsPopup/UserReviewsPopup";
@@ -16,11 +17,12 @@ import UserReviewsPopup from "./UserReviewsPopup/UserReviewsPopup";
const SpottableComponent = Spottable("div");
const Container = SpotlightContainerDecorator(
{
enterTo: "default-element",
{
enterTo: "default-element",
preserveld: true,
leaveFor: {
left: "spotlight-product-info-section-container"
leaveFor: {
left: "spotlight-product-info-section-container",
up: "view-all-reviews-button"
},
restrict: "none",
spotlightDirection: "vertical"
@@ -28,14 +30,25 @@ const Container = SpotlightContainerDecorator(
"div"
);
export default function UserReviews({ productInfo, panelInfo }) {
export default function UserReviews({ productInfo, panelInfo, reviewsData }) {
const { getScrollTo, scrollTop } = useScrollTo();
const dispatch = useDispatch();
const containerRef = 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 [popupMode, setPopupMode] = useState("customer-images"); // "customer-images", "all-images"
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
// Redux에서 showAllReviews 상태 가져오기
const showAllReviews = useSelector((state) => state.product.showAllReviews);
@@ -44,15 +57,17 @@ export default function UserReviews({ productInfo, panelInfo }) {
useEffect(() => {
console.log("[UserReviews] showAllReviews state changed:", {
showAllReviews,
reviewListLength: reviewListData?.length || 0,
willShowCount: showAllReviews ? (reviewListData?.length || 0) : 5
reviewListLength: (actualReviewsData.previewReviews && actualReviewsData.previewReviews.length) || 0,
willShowCount: showAllReviews ? ((actualReviewsData.previewReviews && actualReviewsData.previewReviews.length) || 0) : 5,
hasReviewsData: !!reviewsData,
isFromProductAllSection: !!reviewsData
});
}, [showAllReviews, reviewListData]);
}, [showAllReviews, actualReviewsData.previewReviews, reviewsData]);
// showAllReviews 상태 변경 시 TScroller 스크롤 영역 강제 재계산
useEffect(() => {
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(() => {
@@ -67,67 +82,34 @@ export default function UserReviews({ productInfo, panelInfo }) {
tScrollerRef.current.scrollTo({ position: { y: 0 }, animate: false });
}
console.log("[UserReviews] TScroller scroll area updated");
// console.log("[UserReviews] TScroller scroll area updated");
}
}, 100);
}
}, [showAllReviews]);
const reviewListData = useSelector(
(state) => state.product.reviewData && state.product.reviewData.reviewList
);
const reviewTotalCount = useSelector(
(state) => {
const reviewData = state.product.reviewData;
return reviewData && reviewData.reviewDetail && reviewData.reviewDetail.totRvwCnt ? reviewData.reviewDetail.totRvwCnt : 0;
}
);
const reviewDetailData = useSelector(
(state) => state.product.reviewData && state.product.reviewData.reviewDetail
);
// actualReviewsData에서 데이터 가져오기 (ProductAllSection에서 전달받거나 자체 useReviews)
const reviewListData = actualReviewsData.previewReviews; // 리뷰 리스트 (5개)
const reviewTotalCount = actualReviewsData.stats.totalReviews;
const reviewDetailData = {
totRvwCnt: actualReviewsData.stats.totalReviews,
avgRvwScr: actualReviewsData.stats.averageRating,
totRvwAvg: actualReviewsData.stats.averageRating
};
// [UserReviews] 데이터 수신 확인 로그
useEffect(() => {
console.log("[UserReviews] Review data received:", {
reviewListData,
reviewListLength: reviewListData?.length || 0,
reviewTotalCount,
reviewDetailData,
console.log("[UserReviews] 실제 데이터 확인:", {
reviewListLength: (reviewListData && reviewListData.length) || 0,
totalReviews: actualReviewsData.stats.totalReviews,
averageRating: actualReviewsData.stats.averageRating,
isLoading: actualReviewsData.isLoading,
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 중복 제거)
// 실제 상품 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]);
// ✅ useReviews Hook이 모든 API 호출을 담당하므로 별도 API 호출 불필요
const formatToYYMMDD = (dateStr) => {
const date = new Date(dateStr);
@@ -135,65 +117,66 @@ export default function UserReviews({ productInfo, panelInfo }) {
return iso.replace(/-/g, ".");
};
const handleReviewClick = useCallback(() => {
console.log("[UserReviews] Review item clicked");
}, []);
// 리뷰 클릭으로 User Reviews 모드 팝업 열기
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) => {
console.log("[UserReviews] Opening popup with image index:", imageIndex);
// +View More 버튼으로 Customer Images 모드 팝업 열기
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);
setPopupMode("all-images");
setIsPopupOpen(true);
}, []);
const handleClosePopup = useCallback(() => {
console.log("[UserReviews] Closing popup");
console.log("[UserReviews] Closing popup and resetting mode");
setIsPopupOpen(false);
setPopupMode("customer-images"); // 모드를 초기값으로 리셋
setSelectedImageIndex(0); // 이미지 인덱스도 초기값으로 리셋
}, []);
const handleImageClick = useCallback((index, image) => {
console.log("[UserReviews] Popup image clicked:", { index, image });
setSelectedImageIndex(index);
}, []);
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 handleModeChange = useCallback((newMode, imageIndex = 0) => {
console.log("[UserReviews] Mode change requested:", { newMode, imageIndex });
setPopupMode(newMode);
if (newMode === "all-images" || newMode === "user-reviews") {
setSelectedImageIndex(imageIndex);
}
}, []);
const imageReviews = reviewListData.filter(
(review) => review.reviewImageList &&
Array.isArray(review.reviewImageList) &&
review.reviewImageList.length > 0
);
// handleImageClick 제거 - 더 이상 필요없음
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 (
<Container
@@ -201,14 +184,15 @@ export default function UserReviews({ productInfo, panelInfo }) {
className={css.userReviewsContainer}
spotlightId="user-reviews-container"
>
<TScroller
<UserReviewsScroller
ref={tScrollerRef}
className={css.tScroller}
verticalScrollbar="auto"
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(
`USER REVIEWS (${reviewTotalCount})`
)}
@@ -220,8 +204,13 @@ export default function UserReviews({ productInfo, panelInfo }) {
className={css.averageOverallRating}
/>
)}
</THeader>
<CustomerImages panelInfo={panelInfo} onImageClick={handleOpenPopup} />
</THeaderDetail>
<CustomerImages
panelInfo={panelInfo}
onImageClick={handleOpenPopup}
onViewMoreClick={handleOpenPopup}
imageData={fallbackReviews.extractImagesFromReviews}
/>
<div className={css.reviewItem}>
<div className={css.showReviewsText}>
{$L(
@@ -235,20 +224,41 @@ export default function UserReviews({ productInfo, panelInfo }) {
showAllReviews,
totalReviews: reviewListData.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;
})().map((review, index) => {
})().map((review, index, array) => {
const { reviewImageList, rvwRtng, rvwRgstDtt, rvwCtnt, rvwId, wrtrNknm, rvwWrtrId } =
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 (
<SpottableComponent
key={`user-reviews-:${rvwId}`}
aria-label={`user-reviews-:${rvwId}`}
className={css.reviewContentContainer}
onClick={handleReviewClick}
spotlightId={`user-review-${index}`}
onClick={() => handleReviewClick(index)}
spotlightId={isLastReview ? 'user-review-at-last' : `user-review-${index}`}
>
{reviewImageList && reviewImageList.length > 0 && (
<img
@@ -284,30 +294,19 @@ export default function UserReviews({ productInfo, panelInfo }) {
})}
</div>
{/* View All Reviews 버튼 - 일시적으로 코멘트 처리 */}
{/* {!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>
</UserReviewsScroller>
{/* UserReviewsPopup 추가 */}
{/* UserReviewsPopup 추가 - 모드별 데이터 전달 */}
<UserReviewsPopup
open={isPopupOpen}
onClose={handleClosePopup}
images={customerImages}
mode={popupMode}
images={fallbackReviews.extractImagesFromReviews}
selectedImageIndex={selectedImageIndex}
onImageClick={handleImageClick}
reviewsWithImages={fallbackReviews.getReviewsWithImages}
allReviews={fallbackReviews.allReviews} // user-reviews 모드용 전체 리뷰 데이터
onModeChange={handleModeChange}
/>
</Container>
);
}
}

View File

@@ -2,12 +2,19 @@
@import "../../../../style/utils.module.less";
.tScroller {
.size(@w: 1124px, @h: auto); // auto height to accommodate dynamic content
width: 1124px;
max-width: 1124px;
height: auto !important; // 동적 높이 강제 적용
min-height: 500px; // 최소 높이 보장
max-height: none; // 최대 높이 제한 없음
max-height: none !important; // 최대 높이 제한 완전 제거
padding: 0;
box-sizing: border-box;
// 스크롤 컨테이너 내부도 동적 높이 허용
> * {
height: auto !important;
max-height: none !important;
}
}
.userReviewsContainer {
@@ -19,6 +26,8 @@
.size(@w: 1020px, @h: 36px); // CustomerImages와 일치하도록 크기 조정
max-width: 1020px;
margin-bottom: 20px;
display: flex;
align-items: center;
> div {
.size(@w:100%,@h:100%);
@@ -27,6 +36,9 @@
.averageOverallRating {
.size(@w: 176px,@h:30px);
display: flex;
align-items: center;
margin-left: auto;
}
span {
@@ -34,6 +46,8 @@
font-weight: 700;
height: 36px;
color: rgba(234, 234, 234, 1);
display: flex;
align-items: center;
}
}
@@ -99,6 +113,15 @@
font-weight: 400;
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 {

View File

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

View File

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

View File

@@ -1,10 +1,12 @@
import React, { useCallback } from "react";
import React, { useCallback, useState, useEffect } from "react";
import classNames from "classnames";
import Spottable from "@enact/spotlight/Spottable";
import SpotlightContainerDecorator from "@enact/spotlight/SpotlightContainerDecorator";
import TNewPopUp from "../../../../../components/TPopUp/TNewPopUp";
import TButton from "../../../../../components/TButton/TButton";
import { $L } from "../../../../../utils/helperMethods";
import UserReviewDetail from "../UserReviewDetail/UserReviewDetail";
import ImageSkeleton from "./ImageSkeleton/ImageSkeleton";
import css from "./UserReviewsPopup.module.less";
const SpottableImage = Spottable("div");
@@ -13,7 +15,7 @@ const ContentContainer = SpotlightContainerDecorator(
{
enterTo: "default-element",
preserveId: true,
defaultElement: "user-review-image-0"
// 모드별 기본 요소 처리는 동적으로 설정
},
"div"
);
@@ -31,8 +33,11 @@ export default function UserReviewsPopup({
open = false,
onClose,
images = [],
mode = "customer-images", // "customer-images", "all-images", "user-reviews"
selectedImageIndex = 0,
onImageClick,
reviewsWithImages = [], // 이미지가 있는 리뷰들
allReviews = [], // 모든 리뷰들 (user-reviews 모드용)
onModeChange, // 모드 변경 콜백 함수
className,
}) {
const handleClose = useCallback(() => {
@@ -41,14 +46,114 @@ export default function UserReviewsPopup({
}
}, [onClose]);
const handleImageClick = useCallback((index, image) => {
if (onImageClick) {
onImageClick(index, image);
// Customer Images 모드에서 이미지 클릭 시 All Images 모드로 변경
const handleCustomerImageClick = useCallback((index) => {
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 (
<TNewPopUp
@@ -58,34 +163,93 @@ export default function UserReviewsPopup({
className={classNames(css.userReviewsPopup, className)}
>
<div className={css.popupContainer}>
{/* Header */}
{/* Header - 모드별 아이콘과 제목 */}
<div className={css.header}>
{headerInfo.hasIcon && (
<>
{headerInfo.iconType === "all-images" && <AllImagesIcon />}
{headerInfo.iconType === "user-reviews" && <UserReviewsIcon />}
</>
)}
<div className={css.headerTitle}>
{$L("Customer Images")}
{headerInfo.title}
</div>
</div>
{/* Content */}
<ContentContainer className={css.content}>
<div className={css.imageGrid}>
{displayImages.map((image, index) => (
<SpottableImage
key={`user-review-image-${index}`}
spotlightId={`user-review-image-${index}`}
className={classNames(
css.imageItem,
selectedImageIndex === index && css.selectedImage
)}
onClick={() => handleImageClick(index, image)}
>
<img
src={image.imgUrl || image}
alt={`Customer review ${index + 1}`}
className={css.image}
/>
</SpottableImage>
))}
</div>
{/* Content - 모드별 내용 */}
<ContentContainer
className={css.content}
defaultElement={
mode === "all-images" ? "review-detail-prev" :
mode === "user-reviews" ? "user-review-detail-prev" :
"user-review-image-0"
}
>
{mode === "customer-images" && (
<div className={css.imageGrid}>
{displayImages.map((image, index) => {
const isLoaded = imageLoadStates[index] === true;
const isFailed = imageLoadStates[index] === false;
return (
<SpottableImage
key={`user-review-image-${index}`}
spotlightId={`user-review-image-${index}`}
className={css.imageItem}
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>
{/* Footer */}

View File

@@ -13,7 +13,7 @@
justify-content: center;
align-items: center;
// Header 영역
// Header 영역 - 모드별 아이콘과 제목 지원 (기존 레이아웃 유지)
.header {
align-self: stretch;
padding: 30px;
@@ -22,6 +22,17 @@
justify-content: flex-start;
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 {
text-align: center;
color: black;
@@ -45,27 +56,30 @@
position: relative;
.imageGrid {
flex: 1;
width: 100%;
height: 100%;
display: flex;
justify-content: center; // 중앙 정렬로 변경
align-items: center;
justify-content: center; // 중앙 정렬로 균등 배치
align-items: flex-start;
flex-wrap: wrap;
align-content: center;
overflow: hidden; // 스크롤 완전 제거
max-height: 100%;
align-content: flex-start;
overflow-y: scroll; // 스크롤바 항상 표시
overflow-x: hidden;
padding: 30px 40px; // 좌우 패딩 증가
box-sizing: border-box;
// gap 대신 margin 사용 (TV 호환성)
// gap 대신 margin 사용 (TV 호환성) - 화면을 적절히 채우도록 조정
.imageItem {
width: 226px;
height: 218px;
width: 210px; // 크기 약간 증가
height: 190px; // 비율 맞춤
border-radius: 12px;
position: relative;
cursor: pointer;
margin-right: 20px;
margin-bottom: 20px;
margin-right: 35px; // 마진 증가로 균등 분배
margin-bottom: 30px; // 세로 마진도 증가
// 3개씩 배치하므로 3번째마다 margin-right 제거
&:nth-child(3n) {
// 4개씩 배치하므로 4번째마다 margin-right 제거
&:nth-child(4n) {
margin-right: 0;
}
@@ -73,15 +87,8 @@
outline: none;
&::after {
content: '';
position: absolute;
top: -2px; // 포커스 위치 미세 조정
left: -2px; // 포커스 위치 미세 조정
width: calc(100% + 4px); // 크기 미세 조정
height: calc(100% + 4px); // 크기 미세 조정
border: 4px solid #C70850;
border-radius: 12px;
pointer-events: none;
.focused(@boxShadow: 0px, @borderRadius: 12px);
// 프로젝트 표준 포커스 스타일 사용 (4px solid @PRIMARY_COLOR_RED)
}
}
@@ -94,32 +101,40 @@
box-sizing: border-box;
}
// 선택된 이미지 스타일
&.selectedImage {
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 4px solid #C70850;
border-radius: 12px;
pointer-events: none;
// 이미지 로드 실패 시 플레이스홀더
.imagePlaceholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #f5f5f5;
border-radius: 12px;
display: flex;
justify-content: center;
align-items: center;
.placeholderText {
color: #999;
font-size: 14px;
font-family: @baseFont;
text-align: center;
}
}
// selectedImage 스타일 제거 - 오직 포커스만 사용
}
// View More 아이템
.viewMoreItem {
width: 226px;
height: 218px;
width: 210px; // 크기 증가
height: 190px; // 비율 맞춤
border-radius: 12px;
position: relative;
margin-right: 20px;
margin-bottom: 20px;
margin-right: 35px; // 마진 증가
margin-bottom: 30px; // 세로 마진 증가
&:nth-child(3n) {
&:nth-child(4n) {
margin-right: 0;
}
@@ -156,10 +171,28 @@
}
}
// 커스텀 스크롤바 (숨김)
.scrollbar {
display: none; // 스크롤바 완전 숨김
// User Reviews 모드용 reviewGrid 스타일
.reviewGrid {
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 영역
@@ -189,11 +222,14 @@
&:focus {
background: @PRIMARY_COLOR_RED !important;
outline: 2px solid @PRIMARY_COLOR_RED !important;
color: white !important;
outline: none !important;
border: none !important;
}
&:hover {
background: lighten(#7A808D, 10%) !important;
background: @PRIMARY_COLOR_RED !important;
color: white !important;
}
}
}

View File

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

View File

@@ -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; // 포커스 시 텍스트 색상도 흰색으로 유지
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -88,6 +88,7 @@ import ThemeCurationPanel from "../ThemeCurationPanel/ThemeCurationPanel";
import TrendingNowPanel from "../TrendingNowPanel/TrendingNowPanel";
import VideoTestPanel from "../VideoTestPanel/VideoTestPanel";
import WelcomeEventPanel from "../WelcomeEventPanel/WelcomeEventPanel";
import UserReviewPanel from "../UserReview/UserReviewPanel";
import OptionalTermsConfirm from "../../components/Optional/OptionalTermsConfirm";
import OptionalTermsConfirmBottom from "../../components/Optional/OptionalTermsConfirmBottom";
import css from "./MainView.module.less";
@@ -122,6 +123,7 @@ const panelMap = {
[Config.panel_names.THEME_CURATION_PANEL]: ThemeCurationPanel,
[Config.panel_names.IMAGE_PANEL]: ImagePanel,
[Config.panel_names.CONFIRM_PANEL]: ConfirmPanel,
[Config.panel_names.USER_REVIEW_PANEL]: UserReviewPanel,
// [Config.panel_names.OPTIONAL_TERMS_PANEL]: TermsOfOptional,
};

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

View File

@@ -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; // 포커스 시 텍스트 색상도 흰색으로 유지
}
}
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Submodule git-auto-commit deleted from d42d415623