[251011] fix: ProudctAllSection scroller 리렌더링 최적화-3

🕐 커밋 시간: 2025. 10. 11. 22:04:23

📊 변경 통계:
  • 총 파일: 5개
  • 추가: +123줄
  • 삭제: -102줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/actions/playActions.js
  ~ com.twin.app.shoptime/src/hooks/useReviews/useReviews.js
  ~ com.twin.app.shoptime/src/views/DetailPanel/DetailPanel.jsx
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.jsx
  ~ com.twin.app.shoptime/src/views/UserReview/components/UserReviewItem.jsx

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.jsx (javascript):
    🔄 Modified: Spottable()

🔧 주요 변경 내용:
  • 핵심 비즈니스 로직 개선
  • UI 컴포넌트 아키텍처 개선
This commit is contained in:
2025-10-11 22:04:25 +09:00
parent 122020a8f7
commit d3117351c0
5 changed files with 393 additions and 372 deletions

View File

@@ -31,7 +31,8 @@ export const startVideoPlayer =
const topPanel = panels[panels.length - 1]; const topPanel = panels[panels.length - 1];
let panelWorkingAction = pushPanel; let panelWorkingAction = pushPanel;
const panelName = useNewPlayer ? panel_names.PLAYER_PANEL_NEW : panel_names.PLAYER_PANEL; // const panelName = useNewPlayer ? panel_names.PLAYER_PANEL_NEW : panel_names.PLAYER_PANEL;
const panelName = panel_names.PLAYER_PANEL;
if (topPanel && topPanel.name === panelName) { if (topPanel && topPanel.name === panelName) {
panelWorkingAction = updatePanel; panelWorkingAction = updatePanel;

View File

@@ -4,17 +4,17 @@ import { getUserReviews } from '../../actions/productActions';
import fp from '../../utils/fp'; import fp from '../../utils/fp';
const DISPLAY_SIZE = 3; // 화면에 표시할 리뷰 개수 const DISPLAY_SIZE = 3; // 화면에 표시할 리뷰 개수
const STEP_SIZE = 1; // 페이징 시 이동할 리뷰 개수 const STEP_SIZE = 1; // 페이징 시 이동할 리뷰 개수
const useReviews = (prdtId, patnrId) => { const useReviews = (prdtId, patnrId) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
// Redux 상태에서 리뷰 데이터 가져오기 - CustomerImages와 동일한 방식 // Redux 상태에서 리뷰 데이터 가져오기 - CustomerImages와 동일한 방식
const reviewData = useSelector(state => state.product.reviewData); const reviewData = useSelector((state) => state.product.reviewData);
const loadedPrdtId = useSelector(state => state.product.loadedPrdtId); const loadedPrdtId = useSelector((state) => state.product.loadedPrdtId);
// 빈 내용 리뷰 필터링 - 의미있는 리뷰만 표시 // 빈 내용 리뷰 필터링 - 의미있는 리뷰만 표시
const allReviews = (reviewData?.reviewList || []).filter(review => { const allReviews = (reviewData?.reviewList || []).filter((review) => {
const content = review.rvwCtnt?.trim(); const content = review.rvwCtnt?.trim();
return content && content.length > 0; return content && content.length > 0;
}); });
@@ -22,21 +22,21 @@ const useReviews = (prdtId, patnrId) => {
const reviewDetail = reviewData?.reviewDetail || {}; const reviewDetail = reviewData?.reviewDetail || {};
// [useReviews] Redux 상태 확인 로그 // [useReviews] Redux 상태 확인 로그
console.log('[useReviews] Redux 상태 확인:', { // console.log('[useReviews] Redux 상태 확인:', {
prdtId, // prdtId,
patnrId, // patnrId,
hasReviewData: !!reviewData, // hasReviewData: !!reviewData,
reviewDataKeys: reviewData ? Object.keys(reviewData) : [], // reviewDataKeys: reviewData ? Object.keys(reviewData) : [],
reviewListLength: (reviewData && reviewData.reviewList) ? reviewData.reviewList.length : 0, // reviewListLength: (reviewData && reviewData.reviewList) ? reviewData.reviewList.length : 0,
reviewDetail: reviewData ? reviewData.reviewDetail : null, // reviewDetail: reviewData ? reviewData.reviewDetail : null,
fullReviewData: reviewData // fullReviewData: reviewData
}); // });
// 로컬 상태 관리 // 로컬 상태 관리
const [currentPage, setCurrentPage] = useState(0); const [currentPage, setCurrentPage] = useState(0);
const [currentFilter, setCurrentFilter] = useState({ const [currentFilter, setCurrentFilter] = useState({
type: 'rating', // 'rating', 'keyword', 'sentiment' type: 'rating', // 'rating', 'keyword', 'sentiment'
value: 'all' // 'all', 1-5, 'positive', 'negative', 'aroma' 등 value: 'all', // 'all', 1-5, 'positive', 'negative', 'aroma' 등
}); });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [hasLoadedData, setHasLoadedData] = useState(false); const [hasLoadedData, setHasLoadedData] = useState(false);
@@ -59,14 +59,14 @@ const useReviews = (prdtId, patnrId) => {
return; return;
} }
console.log('[useReviews] loadReviews 시작:', { prdtId, patnrId }); // console.log('[useReviews] loadReviews 시작:', { prdtId, patnrId });
setIsLoading(true); setIsLoading(true);
try { try {
// Redux action을 통해 API 호출 - patnrId 추가 // Redux action을 통해 API 호출 - patnrId 추가
await dispatch(getUserReviews({ prdtId, patnrId })); await dispatch(getUserReviews({ prdtId, patnrId }));
setHasLoadedData(true); setHasLoadedData(true);
console.log('[useReviews] loadReviews 완료'); // console.log('[useReviews] loadReviews 완료');
} catch (error) { } catch (error) {
console.error('[useReviews] loadReviews 실패:', error); console.error('[useReviews] loadReviews 실패:', error);
} finally { } finally {
@@ -77,17 +77,17 @@ const useReviews = (prdtId, patnrId) => {
// prdtId가 변경되면 자동으로 리뷰 데이터 로드 (싱글톤 패턴) // prdtId가 변경되면 자동으로 리뷰 데이터 로드 (싱글톤 패턴)
useEffect(() => { useEffect(() => {
if (prdtId && patnrId && prdtId !== loadedPrdtId) { if (prdtId && patnrId && prdtId !== loadedPrdtId) {
console.log('[useReviews] prdtId changed, loading new data:', { // console.log('[useReviews] prdtId changed, loading new data:', {
from: loadedPrdtId, // from: loadedPrdtId,
to: prdtId, // to: prdtId,
patnrId // patnrId
}); // });
// prdtId 변경 시 로딩 상태 즉시 설정으로 UI 깜빡임 방지 // prdtId 변경 시 로딩 상태 즉시 설정으로 UI 깜빡임 방지
setIsLoading(true); setIsLoading(true);
setHasLoadedData(false); setHasLoadedData(false);
loadReviews(); loadReviews();
} else if (prdtId === loadedPrdtId) { } else if (prdtId === loadedPrdtId) {
console.log('[useReviews] Using cached data for same prdtId:', { prdtId, patnrId }); // console.log('[useReviews] Using cached data for same prdtId:', { prdtId, patnrId });
setHasLoadedData(true); // 캐시된 데이터 사용 시 로드 완료 상태로 설정 setHasLoadedData(true); // 캐시된 데이터 사용 시 로드 완료 상태로 설정
} }
}, [prdtId, patnrId, loadedPrdtId, loadReviews]); }, [prdtId, patnrId, loadedPrdtId, loadReviews]);
@@ -112,35 +112,103 @@ const useReviews = (prdtId, patnrId) => {
if (!sentiment) return true; if (!sentiment) return true;
const positiveWords = new Set([ const positiveWords = new Set([
'good', 'great', 'excellent', 'amazing', 'love', 'perfect', 'best', 'good',
'wonderful', 'fantastic', 'awesome', 'outstanding', 'superb', 'brilliant', 'great',
'positive', 'happy', 'satisfied', 'pleased', 'delightful', 'enjoyed', 'excellent',
'recommend', 'nice', 'helpful', 'valuable', 'impressive', 'remarkable', 'amazing',
'favorite', 'friendly', 'comfortable', 'smooth', 'reliable', 'incredible', 'love',
'lovely', 'beautiful', 'fun', 'worthwhile', 'useful' '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([ const negativeWords = new Set([
'bad', 'terrible', 'awful', 'hate', 'worst', 'horrible', 'poor', 'bad',
'disappointing', 'useless', 'waste', 'cheap', 'broken', 'defective', 'terrible',
'unhappy', 'unsatisfied', 'frustrating', 'annoying', 'regret', 'slow', 'awful',
'ugly', 'overpriced', 'boring', 'uncomfortable', 'noisy', 'buggy', 'hate',
'dirty', 'smelly', 'difficult', 'complicated', 'fake', 'flimsy' '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 negationWords = new Set([
'not',
'no',
'never',
"isn't",
"wasn't",
"don't",
"doesn't",
"didn't",
]);
const content = (review.rvwCtnt || '').toLowerCase(); const content = (review.rvwCtnt || '').toLowerCase();
if (!content) return false; if (!content) return false;
const tokens = content.replace(/[^a-z0-9\s'-]/g, ' ').split(/\s+/).filter(Boolean); const tokens = content
.replace(/[^a-z0-9\s'-]/g, ' ')
.split(/\s+/)
.filter(Boolean);
const hasWordWithoutNegation = (wordSet) => { const hasWordWithoutNegation = (wordSet) => {
return tokens.some((token, index) => { return tokens.some((token, index) => {
if (!wordSet.has(token)) return false; if (!wordSet.has(token)) return false;
const prevTokens = tokens.slice(Math.max(0, index - 3), index); const prevTokens = tokens.slice(Math.max(0, index - 3), index);
return !prevTokens.some(prevToken => negationWords.has(prevToken)); return !prevTokens.some((prevToken) => negationWords.has(prevToken));
}); });
}; };
@@ -160,19 +228,19 @@ const useReviews = (prdtId, patnrId) => {
return { return {
rating: { all: 0 }, rating: { all: 0 },
keyword: {}, keyword: {},
sentiment: {} sentiment: {},
}; };
} }
const counts = { const counts = {
rating: { all: allReviews.length }, rating: { all: allReviews.length },
keyword: {}, keyword: {},
sentiment: {} sentiment: {},
}; };
// 별점별 카운트 - 소수점 별점을 정수로 반올림하여 카운팅 // 별점별 카운트 - 소수점 별점을 정수로 반올림하여 카운팅
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
counts.rating[i] = allReviews.filter(review => { counts.rating[i] = allReviews.filter((review) => {
const rating = review.rvwScr || review.rvwRtng || review.rating || 0; const rating = review.rvwScr || review.rvwRtng || review.rating || 0;
return Math.round(rating) === i; return Math.round(rating) === i;
}).length; }).length;
@@ -180,34 +248,34 @@ const useReviews = (prdtId, patnrId) => {
// 키워드별 카운트 // 키워드별 카운트
const keywords = ['aroma', 'vanilla', 'cinnamon', 'quality']; const keywords = ['aroma', 'vanilla', 'cinnamon', 'quality'];
keywords.forEach(keyword => { keywords.forEach((keyword) => {
counts.keyword[keyword] = allReviews.filter(review => counts.keyword[keyword] = allReviews.filter((review) =>
matchesKeyword(review, keyword) matchesKeyword(review, keyword)
).length; ).length;
}); });
// 감정별 카운트 // 감정별 카운트
counts.sentiment.positive = allReviews.filter(review => counts.sentiment.positive = allReviews.filter((review) =>
matchesSentiment(review, 'positive') matchesSentiment(review, 'positive')
).length; ).length;
counts.sentiment.negative = allReviews.filter(review => counts.sentiment.negative = allReviews.filter((review) =>
matchesSentiment(review, 'negative') matchesSentiment(review, 'negative')
).length; ).length;
// 디버깅: filterCounts 계산 결과 확인 // 디버깅: filterCounts 계산 결과 확인
console.log('[useReviews] filterCounts 계산 완료:', { // console.log('[useReviews] filterCounts 계산 완료:', {
totalReviews: allReviews.length, // totalReviews: allReviews.length,
ratingCounts: counts.rating, // ratingCounts: counts.rating,
keywordCounts: counts.keyword, // keywordCounts: counts.keyword,
sentimentCounts: counts.sentiment, // sentimentCounts: counts.sentiment,
sampleReviews: allReviews.slice(0, 3).map(review => ({ // sampleReviews: allReviews.slice(0, 3).map(review => ({
rvwId: review.rvwId, // rvwId: review.rvwId,
rvwScr: review.rvwScr, // rvwScr: review.rvwScr,
rvwRtng: review.rvwRtng, // rvwRtng: review.rvwRtng,
rating: review.rating, // rating: review.rating,
roundedRating: Math.round(review.rvwScr || review.rvwRtng || review.rating || 0) // roundedRating: Math.round(review.rvwScr || review.rvwRtng || review.rating || 0)
})) // }))
}); // });
return counts; return counts;
}, [allReviews, matchesKeyword, matchesSentiment]); }, [allReviews, matchesKeyword, matchesSentiment]);
@@ -224,7 +292,7 @@ const useReviews = (prdtId, patnrId) => {
if (currentFilter.value === 'all' || currentFilter.value === null) { if (currentFilter.value === 'all' || currentFilter.value === null) {
result = [...allReviews]; // 전체 표시 result = [...allReviews]; // 전체 표시
} else { } else {
result = allReviews.filter(review => { result = allReviews.filter((review) => {
const rating = review.rvwScr || review.rvwRtng || review.rating || 0; const rating = review.rvwScr || review.rvwRtng || review.rating || 0;
return Math.round(rating) === currentFilter.value; return Math.round(rating) === currentFilter.value;
}); });
@@ -232,15 +300,11 @@ const useReviews = (prdtId, patnrId) => {
break; break;
case 'sentiment': case 'sentiment':
result = allReviews.filter(review => result = allReviews.filter((review) => matchesSentiment(review, currentFilter.value));
matchesSentiment(review, currentFilter.value)
);
break; break;
case 'keyword': case 'keyword':
result = allReviews.filter(review => result = allReviews.filter((review) => matchesKeyword(review, currentFilter.value));
matchesKeyword(review, currentFilter.value)
);
break; break;
default: default:
@@ -282,44 +346,50 @@ const useReviews = (prdtId, patnrId) => {
// 다음 청크 로드 (클라이언트 사이드에서 페이지만 증가) // 다음 청크 로드 (클라이언트 사이드에서 페이지만 증가)
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (hasMore) { if (hasMore) {
console.log('[useReviews] loadMore: 다음 청크 로드', { // console.log('[useReviews] loadMore: 다음 청크 로드', {
currentPage, // currentPage,
nextPage: currentPage + 1, // nextPage: currentPage + 1,
currentDisplayCount: displayReviews.length, // currentDisplayCount: displayReviews.length,
totalFilteredCount: filteredReviews.length // totalFilteredCount: filteredReviews.length
}); // });
setCurrentPage(prev => prev + 1); setCurrentPage((prev) => prev + 1);
} }
}, [hasMore, currentPage, displayReviews.length, filteredReviews.length]); }, [hasMore, currentPage, displayReviews.length, filteredReviews.length]);
// UserReviewPanel 전용 페이징 함수들 // UserReviewPanel 전용 페이징 함수들
const goToNextUserReviewPage = useCallback(() => { const goToNextUserReviewPage = useCallback(() => {
if (userReviewPanelHasNext) { if (userReviewPanelHasNext) {
console.log('[useReviews] UserReviewPanel 다음 페이지:', { // console.log('[useReviews] UserReviewPanel 다음 페이지:', {
currentPage: userReviewPanelPage, // currentPage: userReviewPanelPage,
nextPage: userReviewPanelPage + 1, // nextPage: userReviewPanelPage + 1,
totalPages: userReviewPanelTotalPages, // totalPages: userReviewPanelTotalPages,
filteredCount: filteredReviews.length // filteredCount: filteredReviews.length
}); // });
setUserReviewPanelPage(fp.pipe( setUserReviewPanelPage(fp.pipe((prev) => prev + 1));
(prev) => prev + 1
));
} }
}, [userReviewPanelHasNext, userReviewPanelPage, userReviewPanelTotalPages, filteredReviews.length]); }, [
userReviewPanelHasNext,
userReviewPanelPage,
userReviewPanelTotalPages,
filteredReviews.length,
]);
const goToPrevUserReviewPage = useCallback(() => { const goToPrevUserReviewPage = useCallback(() => {
if (userReviewPanelHasPrev) { if (userReviewPanelHasPrev) {
console.log('[useReviews] UserReviewPanel 이전 페이지:', { // console.log('[useReviews] UserReviewPanel 이전 페이지:', {
currentPage: userReviewPanelPage, // currentPage: userReviewPanelPage,
prevPage: userReviewPanelPage - 1, // prevPage: userReviewPanelPage - 1,
totalPages: userReviewPanelTotalPages, // totalPages: userReviewPanelTotalPages,
filteredCount: filteredReviews.length // filteredCount: filteredReviews.length
}); // });
setUserReviewPanelPage(fp.pipe( setUserReviewPanelPage(fp.pipe((prev) => prev - 1));
(prev) => prev - 1
));
} }
}, [userReviewPanelHasPrev, userReviewPanelPage, userReviewPanelTotalPages, filteredReviews.length]); }, [
userReviewPanelHasPrev,
userReviewPanelPage,
userReviewPanelTotalPages,
filteredReviews.length,
]);
// 필터 변경 시 UserReviewPanel 페이지도 초기화 // 필터 변경 시 UserReviewPanel 페이지도 초기화
useEffect(() => { useEffect(() => {
@@ -328,7 +398,7 @@ const useReviews = (prdtId, patnrId) => {
// Single Filter 적용 함수 // Single Filter 적용 함수
const applyFilter = useCallback((type, value) => { const applyFilter = useCallback((type, value) => {
console.log('[useReviews] 필터 적용:', { type, value }); // console.log('[useReviews] 필터 적용:', { type, value });
// 이전 필터 결과 해제 // 이전 필터 결과 해제
setCurrentFilter({ type, value }); setCurrentFilter({ type, value });
@@ -336,38 +406,48 @@ const useReviews = (prdtId, patnrId) => {
}, []); }, []);
// 편의 함수들 (기존 인터페이스 호환성) // 편의 함수들 (기존 인터페이스 호환성)
const applyRatingFilter = useCallback((rating) => { const applyRatingFilter = useCallback(
console.log('[useReviews] applyRatingFilter called with:', rating); (rating) => {
applyFilter('rating', rating); // console.log('[useReviews] applyRatingFilter called with:', rating);
}, [applyFilter]); applyFilter('rating', rating);
},
[applyFilter]
);
const applySentimentFilter = useCallback((sentiment) => { const applySentimentFilter = useCallback(
applyFilter('sentiment', sentiment); (sentiment) => {
}, [applyFilter]); applyFilter('sentiment', sentiment);
},
[applyFilter]
);
const applyKeywordFilter = useCallback((keyword) => { const applyKeywordFilter = useCallback(
applyFilter('keyword', keyword); (keyword) => {
}, [applyFilter]); applyFilter('keyword', keyword);
},
[applyFilter]
);
// 필터 초기화 함수 // 필터 초기화 함수
const clearAllFilters = useCallback(() => { const clearAllFilters = useCallback(() => {
console.log('[useReviews] 모든 필터 초기화'); // console.log('[useReviews] 모든 필터 초기화');
applyFilter('rating', 'all'); applyFilter('rating', 'all');
}, [applyFilter]); }, [applyFilter]);
// 이미지가 있는 리뷰들만 추려내는 함수 // 이미지가 있는 리뷰들만 추려내는 함수
const getReviewsWithImages = useMemo(() => { const getReviewsWithImages = useMemo(() => {
const reviewsWithImages = allReviews.filter( const reviewsWithImages = allReviews.filter(
(review) => review.reviewImageList && (review) =>
Array.isArray(review.reviewImageList) && review.reviewImageList &&
review.reviewImageList.length > 0 Array.isArray(review.reviewImageList) &&
review.reviewImageList.length > 0
); );
console.log('[useReviews] 이미지가 있는 리뷰 필터링:', { // console.log('[useReviews] 이미지가 있는 리뷰 필터링:', {
totalReviews: allReviews.length, // totalReviews: allReviews.length,
reviewsWithImages: reviewsWithImages.length, // reviewsWithImages: reviewsWithImages.length,
imageReviews: reviewsWithImages.slice(0, 3) // 처음 3개만 로그 // imageReviews: reviewsWithImages.slice(0, 3) // 처음 3개만 로그
}); // });
return reviewsWithImages; return reviewsWithImages;
}, [allReviews]); }, [allReviews]);
@@ -387,50 +467,56 @@ const useReviews = (prdtId, patnrId) => {
imgUrl, imgUrl,
imgSeq: imgSeq || imgIndex + 1, imgSeq: imgSeq || imgIndex + 1,
reviewId: review.rvwId, reviewId: review.rvwId,
reviewData: review // 전체 리뷰 데이터도 포함 reviewData: review, // 전체 리뷰 데이터도 포함
}); });
} }
}); });
} }
}); });
console.log('[useReviews] 이미지 데이터 추출 완료:', { // console.log('[useReviews] 이미지 데이터 추출 완료:', {
totalImages: images.length, // totalImages: images.length,
sampleImages: images.slice(0, 3) // sampleImages: images.slice(0, 3)
}); // });
return images; return images;
}, [getReviewsWithImages]); }, [getReviewsWithImages]);
// DetailPanel용 - 처음 5개만 가져오기 (필터링 안된 원본에서) // DetailPanel용 - 처음 5개만 가져오기 (필터링 안된 원본에서)
const previewReviews = useMemo(() => { const previewReviews = useMemo(() => {
console.log('[useReviews] previewReviews 계산:', { // console.log('[useReviews] previewReviews 계산:', {
allReviewsLength: allReviews.length, // allReviewsLength: allReviews.length,
previewCount: Math.min(allReviews.length, 5), // previewCount: Math.min(allReviews.length, 5),
prdtId, // prdtId,
hasLoadedData, // hasLoadedData,
isLoading, // isLoading,
reviewData, // reviewData,
allReviews: allReviews.slice(0, 3) // allReviews: allReviews.slice(0, 3)
}); // });
return allReviews.slice(0, 5); return allReviews.slice(0, 5);
}, [allReviews, prdtId, hasLoadedData, isLoading, reviewData]); }, [allReviews, prdtId, hasLoadedData, isLoading, reviewData]);
// 통계 정보 // 통계 정보
const stats = useMemo(() => { const stats = useMemo(() => {
return { return {
totalReviews: (reviewDetail && reviewDetail.totRvwCnt) ? reviewDetail.totRvwCnt : allReviews.length, // 전체 리뷰 개수 totalReviews:
filteredCount: filteredReviews.length, // 필터링된 리뷰 개수 reviewDetail && reviewDetail.totRvwCnt ? reviewDetail.totRvwCnt : allReviews.length, // 전체 리뷰 개수
displayedCount: displayReviews.length, // 현재 표시 중인 리뷰 개수 filteredCount: filteredReviews.length, // 필터링된 리뷰 개수
averageRating: (reviewDetail && reviewDetail.totRvwAvg) ? reviewDetail.totRvwAvg : displayedCount: displayReviews.length, // 현재 표시 중인 리뷰 개수
(reviewDetail && reviewDetail.avgRvwScr) ? reviewDetail.avgRvwScr : 0, averageRating:
totalRatingCount: (reviewDetail && reviewDetail.totRvwCnt) ? reviewDetail.totRvwCnt : allReviews.length 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]); }, [allReviews.length, filteredReviews.length, displayReviews.length, reviewDetail]);
// 데이터 새로고침 - 강제로 다시 로드 // 데이터 새로고침 - 강제로 다시 로드
const refreshData = useCallback(() => { const refreshData = useCallback(() => {
console.log('[useReviews] 데이터 새로고침 시작'); // console.log('[useReviews] 데이터 새로고침 시작');
setHasLoadedData(false); // 강제로 다시 로드하도록 setHasLoadedData(false); // 강제로 다시 로드하도록
setCurrentPage(0); setCurrentPage(0);
setCurrentFilter({ type: 'rating', value: 'all' }); // 기본 필터로 초기화 setCurrentFilter({ type: 'rating', value: 'all' }); // 기본 필터로 초기화
@@ -439,47 +525,47 @@ const useReviews = (prdtId, patnrId) => {
return { return {
// 🔥 핵심 API 함수 - useReviews가 모든 API 호출 담당 // 🔥 핵심 API 함수 - useReviews가 모든 API 호출 담당
loadReviews, // 리뷰 데이터 로드 (prdtId 기반) loadReviews, // 리뷰 데이터 로드 (prdtId 기반)
refreshData, // 데이터 강제 새로고침 refreshData, // 데이터 강제 새로고침
// 📊 리뷰 데이터 // 📊 리뷰 데이터
displayReviews, // 현재 화면에 표시할 리뷰들 (청킹된) - 기존 컴포넌트용 displayReviews, // 현재 화면에 표시할 리뷰들 (청킹된) - 기존 컴포넌트용
previewReviews, // DetailPanel용 미리보기 리뷰 (첫 5개) previewReviews, // DetailPanel용 미리보기 리뷰 (첫 5개)
allReviews, // 전체 원본 리뷰 (필터링 안된, 빈 내용 제외) allReviews, // 전체 원본 리뷰 (필터링 안된, 빈 내용 제외)
filteredReviews, // 필터링된 전체 리뷰 filteredReviews, // 필터링된 전체 리뷰
hasReviews: allReviews.length > 0 && hasLoadedData && !isLoading && isCurrentProductLoaded, // 리뷰 존재 여부 (현재 제품이 로드된 경우에만 true) hasReviews: allReviews.length > 0 && hasLoadedData && !isLoading && isCurrentProductLoaded, // 리뷰 존재 여부 (현재 제품이 로드된 경우에만 true)
// 🔄 UserReviewPanel 전용 페이징 데이터 // 🔄 UserReviewPanel 전용 페이징 데이터
userReviewPanelReviews, // UserReviewPanel용 페이징된 리뷰들 (3개 표시, 1개씩 이동) userReviewPanelReviews, // UserReviewPanel용 페이징된 리뷰들 (3개 표시, 1개씩 이동)
userReviewPanelPage, // 현재 페이지 번호 (0부터 시작) userReviewPanelPage, // 현재 페이지 번호 (0부터 시작)
userReviewPanelHasNext, // 다음 페이지 존재 여부 userReviewPanelHasNext, // 다음 페이지 존재 여부
userReviewPanelHasPrev, // 이전 페이지 존재 여부 userReviewPanelHasPrev, // 이전 페이지 존재 여부
userReviewPanelTotalPages, // 전체 페이지 수 userReviewPanelTotalPages, // 전체 페이지 수
goToNextUserReviewPage, // 다음 페이지로 이동 goToNextUserReviewPage, // 다음 페이지로 이동
goToPrevUserReviewPage, // 이전 페이지로 이동 goToPrevUserReviewPage, // 이전 페이지로 이동
// 🖼️ 이미지 관련 데이터 - CustomerImages 전용 // 🖼️ 이미지 관련 데이터 - CustomerImages 전용
getReviewsWithImages, // 이미지가 있는 리뷰들만 필터링 getReviewsWithImages, // 이미지가 있는 리뷰들만 필터링
extractImagesFromReviews, // 이미지 데이터만 추출 (reviewData 포함) extractImagesFromReviews, // 이미지 데이터만 추출 (reviewData 포함)
// 📄 클라이언트 사이드 페이지네이션 // 📄 클라이언트 사이드 페이지네이션
hasMore, // 더 로드할 리뷰가 있는지 hasMore, // 더 로드할 리뷰가 있는지
loadMore, // 다음 청크 표시 (클라이언트에서 슬라이싱) loadMore, // 다음 청크 표시 (클라이언트에서 슬라이싱)
currentPage, // 현재 페이지 (0부터 시작) currentPage, // 현재 페이지 (0부터 시작)
// 🔍 필터링 시스템 // 🔍 필터링 시스템
currentFilter, // 현재 활성화된 필터 { type, value } currentFilter, // 현재 활성화된 필터 { type, value }
filterCounts, // 각 필터별 리뷰 개수 (실시간 계산) filterCounts, // 각 필터별 리뷰 개수 (실시간 계산)
applyFilter, // 통합 필터 적용 함수 applyFilter, // 통합 필터 적용 함수
applyRatingFilter, // 별점 필터 적용 applyRatingFilter, // 별점 필터 적용
applyKeywordFilter, // 키워드 필터 적용 applyKeywordFilter, // 키워드 필터 적용
applySentimentFilter, // 감정 필터 적용 applySentimentFilter, // 감정 필터 적용
clearAllFilters, // 모든 필터 초기화 clearAllFilters, // 모든 필터 초기화
// ⚡ 상태 관리 // ⚡ 상태 관리
isLoading, // API 로딩 상태 isLoading, // API 로딩 상태
hasLoadedData, // 데이터 로드 완료 여부 hasLoadedData, // 데이터 로드 완료 여부
stats, // 통계 정보 stats, // 통계 정보
// 🐛 디버그 정보 // 🐛 디버그 정보
_debug: { _debug: {
@@ -492,9 +578,9 @@ const useReviews = (prdtId, patnrId) => {
currentPage, currentPage,
hasMore, hasMore,
isLoading, isLoading,
hasLoadedData hasLoadedData,
} },
}; };
}; };
export default useReviews; export default useReviews;

View File

@@ -1,47 +1,24 @@
// src/views/DetailPanel/DetailPanel.new.jsx // src/views/DetailPanel/DetailPanel.new.jsx
import React, { import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { import { useDispatch, useSelector } from 'react-redux';
useDispatch,
useSelector,
} from 'react-redux';
import Spinner from '@enact/sandstone/Spinner'; import Spinner from '@enact/sandstone/Spinner';
import Spotlight from '@enact/spotlight'; import Spotlight from '@enact/spotlight';
import { setContainerLastFocusedElement } from '@enact/spotlight/src/container'; import { setContainerLastFocusedElement } from '@enact/spotlight/src/container';
import indicatorDefaultImage import indicatorDefaultImage from '../../../assets/images/img-thumb-empty-144@3x.png';
from '../../../assets/images/img-thumb-empty-144@3x.png';
import { getDeviceAdditionInfo } from '../../actions/deviceActions'; import { getDeviceAdditionInfo } from '../../actions/deviceActions';
import { getThemeCurationDetailInfo } from '../../actions/homeActions'; import { getThemeCurationDetailInfo } from '../../actions/homeActions';
import { import { getMainCategoryDetail, getMainYouMayLike } from '../../actions/mainActions';
getMainCategoryDetail, import { popPanel, updatePanel } from '../../actions/panelActions';
getMainYouMayLike,
} from '../../actions/mainActions';
import {
popPanel,
updatePanel,
} from '../../actions/panelActions';
import { finishVideoPreview } from '../../actions/playActions'; import { finishVideoPreview } from '../../actions/playActions';
import { import { clearProductDetail, getProductOptionId } from '../../actions/productActions';
clearProductDetail,
getProductOptionId,
} from '../../actions/productActions';
import TBody from '../../components/TBody/TBody'; import TBody from '../../components/TBody/TBody';
import TPanel from '../../components/TPanel/TPanel'; import TPanel from '../../components/TPanel/TPanel';
import { panel_names } from '../../utils/Config'; import { panel_names } from '../../utils/Config';
import fp from '../../utils/fp'; import fp from '../../utils/fp';
import { import { $L, getQRCodeUrl } from '../../utils/helperMethods';
$L,
getQRCodeUrl,
} from '../../utils/helperMethods';
import { SpotlightIds } from '../../utils/SpotlightIds'; import { SpotlightIds } from '../../utils/SpotlightIds';
import DetailPanelBackground from './components/DetailPanelBackground'; import DetailPanelBackground from './components/DetailPanelBackground';
import THeaderCustom from './components/THeaderCustom'; import THeaderCustom from './components/THeaderCustom';
@@ -55,68 +32,49 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
const productData = useSelector((state) => state.main.productData); const productData = useSelector((state) => state.main.productData);
const youmaylikeData = useSelector((state) => state.main.youmaylikeData); const youmaylikeData = useSelector((state) => state.main.youmaylikeData);
const themeProductInfos = useSelector( const themeProductInfos = useSelector((state) => state.home.themeCurationDetailInfoData);
(state) => state.home.themeCurationDetailInfoData
);
const isLoading = useSelector((state) => const isLoading = useSelector((state) =>
fp.pipe(() => state, fp.get("common.appStatus.showLoadingPanel.show"))() fp.pipe(() => state, fp.get('common.appStatus.showLoadingPanel.show'))()
); );
const themeData = useSelector((state) => const themeData = useSelector((state) =>
fp.pipe( fp.pipe(
() => state, () => state,
fp.get("home.productData.themeInfo"), fp.get('home.productData.themeInfo'),
(list) => list && list[0] (list) => list && list[0]
)() )()
); );
const webOSVersion = useSelector( const webOSVersion = useSelector((state) => state.common.appStatus.webOSVersion);
(state) => state.common.appStatus.webOSVersion
);
const panels = useSelector((state) => state.panels.panels); const panels = useSelector((state) => state.panels.panels);
// FP 방식으로 상태 관리
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const localRecentItems = useSelector((state) => const localRecentItems = useSelector((state) =>
fp.pipe(() => state, fp.get("localSettings.recentItems"))() fp.pipe(() => state, fp.get('localSettings.recentItems'))()
); );
const { httpHeader } = useSelector((state) => state.common); const { httpHeader } = useSelector((state) => state.common);
const { popupVisible, activePopup } = useSelector( const { popupVisible, activePopup } = useSelector((state) => state.common.popup);
(state) => state.common.popup const [lgCatCd, setLgCatCd] = useState('');
);
const [lgCatCd, setLgCatCd] = useState("");
const [themeProductInfo, setThemeProductInfo] = useState(null); const [themeProductInfo, setThemeProductInfo] = useState(null);
const containerRef = useRef(null); const containerRef = useRef(null);
const panelType = useMemo( const panelType = useMemo(() => fp.pipe(() => panelInfo, fp.get('type'))(), [panelInfo]);
() => fp.pipe(() => panelInfo, fp.get("type"))(),
[panelInfo]
);
const panelCurationId = useMemo( const panelCurationId = useMemo(
() => fp.pipe(() => panelInfo, fp.get("curationId"))(), () => fp.pipe(() => panelInfo, fp.get('curationId'))(),
[panelInfo]
);
const panelPatnrId = useMemo(
() => fp.pipe(() => panelInfo, fp.get("patnrId"))(),
[panelInfo]
);
const panelPrdtId = useMemo(
() => fp.pipe(() => panelInfo, fp.get("prdtId"))(),
[panelInfo] [panelInfo]
); );
const panelPatnrId = useMemo(() => fp.pipe(() => panelInfo, fp.get('patnrId'))(), [panelInfo]);
const panelPrdtId = useMemo(() => fp.pipe(() => panelInfo, fp.get('prdtId'))(), [panelInfo]);
const panelLiveReqFlag = useMemo( const panelLiveReqFlag = useMemo(
() => fp.pipe(() => panelInfo, fp.get("liveReqFlag"))(), () => fp.pipe(() => panelInfo, fp.get('liveReqFlag'))(),
[panelInfo]
);
const panelBgImgNo = useMemo(
() => fp.pipe(() => panelInfo, fp.get("bgImgNo"))(),
[panelInfo] [panelInfo]
); );
const panelBgImgNo = useMemo(() => fp.pipe(() => panelInfo, fp.get('bgImgNo'))(), [panelInfo]);
const productPmtSuptYn = useMemo( const productPmtSuptYn = useMemo(
() => fp.pipe(() => productData, fp.get("pmtSuptYn"))(), () => fp.pipe(() => productData, fp.get('pmtSuptYn'))(),
[productData] [productData]
); );
const productGrPrdtProcYn = useMemo( const productGrPrdtProcYn = useMemo(
() => fp.pipe(() => productData, fp.get("grPrdtProcYn"))(), () => fp.pipe(() => productData, fp.get('grPrdtProcYn'))(),
[productData] [productData]
); );
@@ -124,7 +82,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
() => () =>
fp.pipe( fp.pipe(
() => panelType, () => panelType,
(type) => (type === "theme" ? themeData : productData) (type) => (type === 'theme' ? themeData : productData)
)(), )(),
[panelType, themeData, productData] [panelType, themeData, productData]
); );
@@ -132,11 +90,9 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
const [productType, setProductType] = useState(null); const [productType, setProductType] = useState(null);
const [openThemeItemOverlay, setOpenThemeItemOverlay] = useState(false); const [openThemeItemOverlay, setOpenThemeItemOverlay] = useState(false);
// FP 방식으로 스크롤 상태 관리
const [scrollToSection, setScrollToSection] = useState(null); const [scrollToSection, setScrollToSection] = useState(null);
const [pendingScrollSection, setPendingScrollSection] = useState(null); const [pendingScrollSection, setPendingScrollSection] = useState(null);
// FP 방식으로 상태 업데이트 함수들
const updateSelectedIndex = useCallback((newIndex) => { const updateSelectedIndex = useCallback((newIndex) => {
setSelectedIndex( setSelectedIndex(
fp.pipe( fp.pipe(
@@ -150,15 +106,13 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
setOpenThemeItemOverlay(fp.pipe(() => isOpen, Boolean)()); setOpenThemeItemOverlay(fp.pipe(() => isOpen, Boolean)());
}, []); }, []);
// FP 방식으로 이벤트 핸들러 정의
const onSpotlightUpTButton = useCallback((e) => { const onSpotlightUpTButton = useCallback((e) => {
e.stopPropagation(); e.stopPropagation();
Spotlight.focus("spotlightId_backBtn"); Spotlight.focus('spotlightId_backBtn');
}, []); }, []);
const onClick = useCallback( const onClick = useCallback(
(isCancelClick) => (ev) => { (isCancelClick) => (ev) => {
// FP 방식으로 액션 디스패치 체이닝
fp.pipe( fp.pipe(
() => { () => {
dispatch(finishVideoPreview()); dispatch(finishVideoPreview());
@@ -169,12 +123,12 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
const shouldUpdatePanel = const shouldUpdatePanel =
fp.pipe( fp.pipe(
() => panels, () => panels,
fp.get("length"), fp.get('length'),
(length) => length === 4 (length) => length === 4
)() && )() &&
fp.pipe( fp.pipe(
() => panels, () => panels,
fp.get("1.name"), fp.get('1.name'),
(name) => name === panel_names.PLAYER_PANEL (name) => name === panel_names.PLAYER_PANEL
)(); )();
@@ -183,7 +137,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
updatePanel({ updatePanel({
name: panel_names.PLAYER_PANEL, name: panel_names.PLAYER_PANEL,
panelInfo: { panelInfo: {
thumbnail: fp.pipe(() => panelInfo, fp.get("thumbnailUrl"))(), thumbnail: fp.pipe(() => panelInfo, fp.get('thumbnailUrl'))(),
}, },
}) })
); );
@@ -198,35 +152,31 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
[dispatch, panelInfo, panels] [dispatch, panelInfo, panels]
); );
// FP 방식으로 스크롤 함수 핸들러
const handleScrollToSection = useCallback( const handleScrollToSection = useCallback(
(sectionId) => { (sectionId) => {
console.log("DetailPanel: handleScrollToSection called with:", sectionId); console.log('DetailPanel: handleScrollToSection called with:', sectionId);
console.log("DetailPanel: scrollToSection function:", scrollToSection); console.log('DetailPanel: scrollToSection function:', scrollToSection);
// FP 방식으로 스크롤 처리 로직
const scrollAction = fp.pipe( const scrollAction = fp.pipe(
() => ({ scrollToSection, sectionId }), () => ({ scrollToSection, sectionId }),
({ scrollToSection, sectionId }) => { ({ scrollToSection, sectionId }) => {
if (fp.isNotNil(scrollToSection)) { if (fp.isNotNil(scrollToSection)) {
return { return {
action: "execute", action: 'execute',
scrollFunction: scrollToSection, scrollFunction: scrollToSection,
sectionId, sectionId,
}; };
} else { } else {
return { action: "store", sectionId }; return { action: 'store', sectionId };
} }
} }
)(); )();
// 액션에 따른 처리 // 액션에 따른 처리
if (scrollAction.action === "execute") { if (scrollAction.action === 'execute') {
scrollAction.scrollFunction(scrollAction.sectionId); scrollAction.scrollFunction(scrollAction.sectionId);
} else { } else {
console.log( console.log('DetailPanel: scrollToSection function is null, storing pending scroll');
"DetailPanel: scrollToSection function is null, storing pending scroll"
);
setPendingScrollSection(scrollAction.sectionId); setPendingScrollSection(scrollAction.sectionId);
} }
}, },
@@ -236,7 +186,6 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
// ===== 배경 이미지 설정 (컴포넌트로 구현되어 useEffect 불필요) ===== // ===== 배경 이미지 설정 (컴포넌트로 구현되어 useEffect 불필요) =====
// DetailPanelBackground 컴포넌트로 배경 렌더링 // DetailPanelBackground 컴포넌트로 배경 렌더링
// FP 방식으로 pending scroll 처리 (메모리 누수 방지)
useEffect(() => { useEffect(() => {
const shouldExecutePendingScroll = fp.pipe( const shouldExecutePendingScroll = fp.pipe(
() => ({ scrollToSection, pendingScrollSection }), () => ({ scrollToSection, pendingScrollSection }),
@@ -245,10 +194,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
)(); )();
if (shouldExecutePendingScroll) { if (shouldExecutePendingScroll) {
console.log( console.log('DetailPanel: executing pending scroll to:', pendingScrollSection);
"DetailPanel: executing pending scroll to:",
pendingScrollSection
);
// 메모리 누수 방지를 위한 cleanup 함수 // 메모리 누수 방지를 위한 cleanup 함수
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
@@ -265,9 +211,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
} }
}, [scrollToSection, pendingScrollSection]); }, [scrollToSection, pendingScrollSection]);
// FP 방식으로 초기 데이터 로딩 처리 (메모리 누수 방지)
useEffect(() => { useEffect(() => {
// FP 방식으로 액션 디스패치 체이닝
const loadInitialData = fp.pipe( const loadInitialData = fp.pipe(
() => { () => {
// 기본 액션 디스패치 // 기본 액션 디스패치
@@ -276,7 +220,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
}, },
() => { () => {
// 테마 데이터 로딩 // 테마 데이터 로딩
const isThemeType = panelType === "theme"; const isThemeType = panelType === 'theme';
if (isThemeType) { if (isThemeType) {
dispatch( dispatch(
@@ -298,7 +242,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
getMainCategoryDetail({ getMainCategoryDetail({
patnrId: panelPatnrId, patnrId: panelPatnrId,
prdtId: panelPrdtId, prdtId: panelPrdtId,
liveReqFlag: panelLiveReqFlag || "N", liveReqFlag: panelLiveReqFlag || 'N',
}) })
); );
} }
@@ -319,7 +263,6 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
panelBgImgNo, panelBgImgNo,
]); ]);
// FP 방식으로 추천 상품 데이터 로딩 (메모리 누수 방지)
useEffect(() => { useEffect(() => {
const shouldLoadRecommendations = fp.pipe(() => lgCatCd, fp.isNotEmpty)(); const shouldLoadRecommendations = fp.pipe(() => lgCatCd, fp.isNotEmpty)();
@@ -345,7 +288,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
setLgCatCd(productData.catCd); setLgCatCd(productData.catCd);
} else if ( } else if (
themeProductInfos && themeProductInfos &&
themeProductInfos[selectedIndex]?.pmtSuptYn === "N" && themeProductInfos[selectedIndex]?.pmtSuptYn === 'N' &&
panelInfo?.curationId panelInfo?.curationId
) { ) {
const themeCatCd = themeProductInfos[selectedIndex]?.catCd; const themeCatCd = themeProductInfos[selectedIndex]?.catCd;
@@ -359,7 +302,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
// selectedIndex, // selectedIndex,
// themeProductPmtSuptYn: themeProductInfos?.[selectedIndex]?.pmtSuptYn // themeProductPmtSuptYn: themeProductInfos?.[selectedIndex]?.pmtSuptYn
// }); // });
setLgCatCd(""); setLgCatCd('');
} }
}, [productData, themeProductInfos, selectedIndex, panelInfo?.curationId]); }, [productData, themeProductInfos, selectedIndex, panelInfo?.curationId]);
@@ -389,16 +332,14 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
// - 순수 유틸로 빌드/업서트 함수 작성 후, 적절한 useEffect에서 호출하세요. // - 순수 유틸로 빌드/업서트 함수 작성 후, 적절한 useEffect에서 호출하세요.
// 예) saveRecentItem(panelInfo, selectedIndex) // 예) saveRecentItem(panelInfo, selectedIndex)
// FP 방식으로 cleanup 처리 (메모리 누수 방지)
useEffect(() => { useEffect(() => {
return () => { return () => {
// FP 방식으로 cleanup 액션 실행
fp.pipe( fp.pipe(
() => { () => {
dispatch(clearProductDetail()); dispatch(clearProductDetail());
}, },
() => { () => {
setContainerLastFocusedElement(null, ["indicator-GridListContainer"]); setContainerLastFocusedElement(null, ['indicator-GridListContainer']);
} }
)(); )();
}; };
@@ -411,9 +352,6 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
// } // }
// }, [panelInfo, selectedIndex]) // }, [panelInfo, selectedIndex])
// 테마/호텔 기반 인덱스 초기화가 필요하면:
// - findIndex 유틸을 만들어 매칭 인덱스를 계산 후 setSelectedIndex에 반영하세요.
// FP 방식으로 버전 비교 헬퍼 함수 (curry 적용)
const versionComparators = useMemo( const versionComparators = useMemo(
() => ({ () => ({
isVersionGTE: fp.curry((target, version) => version >= target), isVersionGTE: fp.curry((target, version) => version >= target),
@@ -422,12 +360,9 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
[] []
); );
// FP 방식으로 조건 체크 헬퍼 함수들 (curry 적용)
const conditionCheckers = useMemo( const conditionCheckers = useMemo(
() => ({ () => ({
hasDataAndCondition: fp.curry( hasDataAndCondition: fp.curry((conditionFn, data) => fp.isNotNil(data) && conditionFn(data)),
(conditionFn, data) => fp.isNotNil(data) && conditionFn(data)
),
equalTo: fp.curry((expected, actual) => actual === expected), equalTo: fp.curry((expected, actual) => actual === expected),
checkAllConditions: fp.curry((conditions, data) => checkAllConditions: fp.curry((conditions, data) =>
fp.reduce( fp.reduce(
@@ -441,7 +376,6 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
); );
const getProductType = useCallback(() => { const getProductType = useCallback(() => {
// FP 방식으로 데이터 검증 및 타입 결정 - curry 적용으로 더 함수형 개선
const createTypeChecker = fp.curry((type, conditions, sideEffect) => const createTypeChecker = fp.curry((type, conditions, sideEffect) =>
fp.pipe( fp.pipe(
() => conditions(), () => conditions(),
@@ -459,7 +393,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
// 테마 타입 체크 // 테마 타입 체크
() => () =>
createTypeChecker( createTypeChecker(
"theme", 'theme',
() => () =>
fp.pipe( fp.pipe(
() => ({ panelCurationId, themeData }), () => ({ panelCurationId, themeData }),
@@ -469,10 +403,10 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
() => { () => {
const themeProduct = fp.pipe( const themeProduct = fp.pipe(
() => themeData, () => themeData,
fp.get("productInfos"), fp.get('productInfos'),
fp.get(selectedIndex.toString()) fp.get(selectedIndex.toString())
)(); )();
setProductType("theme"); setProductType('theme');
setThemeProductInfo(themeProduct); setThemeProductInfo(themeProduct);
} }
), ),
@@ -480,7 +414,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
// Buy Now 타입 체크 (curry 활용) // Buy Now 타입 체크 (curry 활용)
() => () =>
createTypeChecker( createTypeChecker(
"buyNow", 'buyNow',
() => () =>
fp.pipe( fp.pipe(
() => ({ () => ({
@@ -499,21 +433,21 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
}) => { }) => {
const conditions = [ const conditions = [
() => fp.isNotNil(productData), () => fp.isNotNil(productData),
() => conditionCheckers.equalTo("Y")(productPmtSuptYn), () => conditionCheckers.equalTo('Y')(productPmtSuptYn),
() => conditionCheckers.equalTo("N")(productGrPrdtProcYn), () => conditionCheckers.equalTo('N')(productGrPrdtProcYn),
() => fp.isNotNil(panelPrdtId), () => fp.isNotNil(panelPrdtId),
() => versionComparators.isVersionGTE("6.0")(webOSVersion), () => versionComparators.isVersionGTE('6.0')(webOSVersion),
]; ];
return conditionCheckers.checkAllConditions(conditions)({}); return conditionCheckers.checkAllConditions(conditions)({});
} }
)(), )(),
() => setProductType("buyNow") () => setProductType('buyNow')
), ),
// Shop By Mobile 타입 체크 (curry 활용) // Shop By Mobile 타입 체크 (curry 활용)
() => () =>
createTypeChecker( createTypeChecker(
"shopByMobile", 'shopByMobile',
() => () =>
fp.pipe( fp.pipe(
() => ({ () => ({
@@ -532,27 +466,24 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
}) => { }) => {
if (!productData) return false; if (!productData) return false;
const isDirectMobile = const isDirectMobile = conditionCheckers.equalTo('N')(productPmtSuptYn);
conditionCheckers.equalTo("N")(productPmtSuptYn);
const conditionalMobileConditions = [ const conditionalMobileConditions = [
() => conditionCheckers.equalTo("Y")(productPmtSuptYn), () => conditionCheckers.equalTo('Y')(productPmtSuptYn),
() => conditionCheckers.equalTo("N")(productGrPrdtProcYn), () => conditionCheckers.equalTo('N')(productGrPrdtProcYn),
() => versionComparators.isVersionLT("6.0")(webOSVersion), () => versionComparators.isVersionLT('6.0')(webOSVersion),
() => fp.isNotNil(panelPrdtId), () => fp.isNotNil(panelPrdtId),
]; ];
const isConditionalMobile = const isConditionalMobile = conditionCheckers.checkAllConditions(
conditionCheckers.checkAllConditions( conditionalMobileConditions
conditionalMobileConditions )({});
)({});
return isDirectMobile || isConditionalMobile; return isDirectMobile || isConditionalMobile;
} }
)(), )(),
() => setProductType("shopByMobile") () => setProductType('shopByMobile')
), ),
]; ];
// FP 방식으로 순차적 타입 체크
const matchedRule = fp.reduce( const matchedRule = fp.reduce(
(result, rule) => (result.matched ? result : rule()), (result, rule) => (result.matched ? result : rule()),
{ matched: false }, { matched: false },
@@ -569,13 +500,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
productGrPrdtProcYn, productGrPrdtProcYn,
webOSVersion, webOSVersion,
}), }),
({ ({ productData, panelPrdtId, productPmtSuptYn, productGrPrdtProcYn, webOSVersion }) => ({
productData,
panelPrdtId,
productPmtSuptYn,
productGrPrdtProcYn,
webOSVersion,
}) => ({
pmtSuptYn: productPmtSuptYn, pmtSuptYn: productPmtSuptYn,
grPrdtProcYn: productGrPrdtProcYn, grPrdtProcYn: productGrPrdtProcYn,
prdtId: panelPrdtId, prdtId: panelPrdtId,
@@ -583,8 +508,8 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
}) })
)(); )();
console.warn("Unknown product type:", productData); console.warn('Unknown product type:', productData);
console.warn("Product data properties:", debugInfo); console.warn('Product data properties:', debugInfo);
} }
}, [ }, [
panelCurationId, panelCurationId,
@@ -600,7 +525,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
useEffect(() => { useEffect(() => {
// productData가 로드된 후에만 getProductType 실행 // productData가 로드된 후에만 getProductType 실행
if (productData || (panelType === "theme" && themeData)) { if (productData || (panelType === 'theme' && themeData)) {
getProductType(); getProductType();
} }
}, [getProductType, productData, themeData, panelType]); }, [getProductType, productData, themeData, panelType]);
@@ -614,11 +539,11 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
}, [themeData, selectedIndex]); }, [themeData, selectedIndex]);
const imageUrl = useMemo( const imageUrl = useMemo(
() => fp.pipe(() => productData, fp.get("thumbnailUrl960"))(), () => fp.pipe(() => productData, fp.get('thumbnailUrl960'))(),
[productData] [productData]
); );
// FP 방식으로 타이틀과 aria-label 메모이제이션 (성능 최적화) // 타이틀과 aria-label 메모이제이션 (성능 최적화)
const headerTitle = useMemo( const headerTitle = useMemo(
() => () =>
fp.pipe( fp.pipe(
@@ -628,21 +553,20 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
() => ({ panelPrdtId, productData }), () => ({ panelPrdtId, productData }),
({ panelPrdtId, productData }) => ({ panelPrdtId, productData }) =>
fp.isNotNil(panelPrdtId) && fp.isNotNil(panelPrdtId) &&
fp.pipe(() => productData, fp.get("prdtNm"), fp.isNotNil)() fp.pipe(() => productData, fp.get('prdtNm'), fp.isNotNil)()
? fp.pipe(() => productData, fp.get("prdtNm"))() ? fp.pipe(() => productData, fp.get('prdtNm'))()
: null : null
)(); )();
const themeTitle = fp.pipe( const themeTitle = fp.pipe(
() => ({ panelType, themeData }), () => ({ panelType, themeData }),
({ panelType, themeData }) => ({ panelType, themeData }) =>
panelType === "theme" && panelType === 'theme' && fp.pipe(() => themeData, fp.get('curationNm'), fp.isNotNil)()
fp.pipe(() => themeData, fp.get("curationNm"), fp.isNotNil)() ? fp.pipe(() => themeData, fp.get('curationNm'))()
? fp.pipe(() => themeData, fp.get("curationNm"))()
: null : null
)(); )();
return productTitle || themeTitle || ""; return productTitle || themeTitle || '';
} }
)(), )(),
[panelPrdtId, productData, panelType, themeData] [panelPrdtId, productData, panelType, themeData]
@@ -653,10 +577,9 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
fp.pipe( fp.pipe(
() => ({ panelPrdtId, productData }), () => ({ panelPrdtId, productData }),
({ panelPrdtId, productData }) => ({ panelPrdtId, productData }) =>
fp.isNotNil(panelPrdtId) && fp.isNotNil(panelPrdtId) && fp.pipe(() => productData, fp.get('prdtNm'), fp.isNotNil)()
fp.pipe(() => productData, fp.get("prdtNm"), fp.isNotNil)() ? fp.pipe(() => productData, fp.get('prdtNm'))()
? fp.pipe(() => productData, fp.get("prdtNm"))() : ''
: ""
)(), )(),
[panelPrdtId, productData] [panelPrdtId, productData]
); );
@@ -666,7 +589,6 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
// Pink Pong 등 특정 파트너사에서만 thumbnailUrl960 데이터가 있어서 배경이 변경됨 // Pink Pong 등 특정 파트너사에서만 thumbnailUrl960 데이터가 있어서 배경이 변경됨
// 현재는 고정 배경(detailPanelBg)만 사용하기 위해 주석 처리 // 현재는 고정 배경(detailPanelBg)만 사용하기 위해 주석 처리
// FP 방식으로 배경 이미지 설정 (메모리 누수 방지)
/* /*
useLayoutEffect(() => { useLayoutEffect(() => {
const shouldSetBackground = fp.pipe( const shouldSetBackground = fp.pipe(
@@ -681,7 +603,7 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
}, [imageUrl]); }, [imageUrl]);
*/ */
console.log("productDataSource :", productDataSource); console.log('productDataSource :', productDataSource);
// 언마운트 시 인덱스 초기화가 필요하면: // 언마운트 시 인덱스 초기화가 필요하면:
// useEffect(() => () => setSelectedIndex(0), []) // useEffect(() => () => setSelectedIndex(0), [])
@@ -725,18 +647,12 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
isDefaultContainer isDefaultContainer
> >
{useMemo(() => { {useMemo(() => {
// FP 방식으로 렌더링 조건 결정 (메모이제이션으로 최적화)
const renderStates = fp.pipe( const renderStates = fp.pipe(
() => ({ isLoading, panelInfo, productDataSource, productType }), () => ({ isLoading, panelInfo, productDataSource, productType }),
({ isLoading, panelInfo, productDataSource, productType }) => { ({ isLoading, panelInfo, productDataSource, productType }) => {
const hasRequiredData = fp.pipe( const hasRequiredData = fp.pipe(
() => [panelInfo, productDataSource, productType], () => [panelInfo, productDataSource, productType],
(data) => (data) => fp.reduce((acc, item) => acc && fp.isNotNil(item), true, data)
fp.reduce(
(acc, item) => acc && fp.isNotNil(item),
true,
data
)
)(); )();
return { return {

View File

@@ -2,6 +2,7 @@ import React, { useCallback, useMemo, useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import Spottable from '@enact/spotlight/Spottable'; import Spottable from '@enact/spotlight/Spottable';
import { startVideoPlayer, finishVideoPreview } from '../../../../actions/playActions'; import { startVideoPlayer, finishVideoPreview } from '../../../../actions/playActions';
import { startMediaPlayer, finishMediaPreview } from '../../../../actions/mediaActions';
import CustomImage from '../../../../components/CustomImage/CustomImage'; import CustomImage from '../../../../components/CustomImage/CustomImage';
import { panel_names } from '../../../../utils/Config'; import { panel_names } from '../../../../utils/Config';
import playImg from '../../../../../assets/images/btn/btn-play-thumb-nor.png'; import playImg from '../../../../../assets/images/btn/btn-play-thumb-nor.png';
@@ -78,8 +79,33 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl }) {
// modal=true로 재생 중이면 modal=false(전체화면)로 변경 // modal=true로 재생 중이면 modal=false(전체화면)로 변경
const newModalState = isCurrentlyPlayingModal ? false : modalState; const newModalState = isCurrentlyPlayingModal ? false : modalState;
// dispatch(
// startVideoPlayer({
// qrCurrentItem: productInfo,
// showUrl: productInfo?.prdtMediaUrl,
// showNm: productInfo?.prdtNm,
// patnrNm: productInfo?.patncNm,
// patncLogoPath: productInfo?.patncLogoPath,
// orderPhnNo: productInfo?.orderPhnNo,
// disclaimer: productInfo?.disclaimer,
// subtitle: productInfo?.prdtMediaSubtitlUrl,
// lgCatCd: productInfo?.catCd,
// patnrId: productInfo?.patnrId,
// lgCatNm: productInfo?.catNm,
// prdtId: productInfo?.prdtId,
// patncNm: productInfo?.patncNm,
// prdtNm: productInfo?.prdtNm,
// thumbnailUrl: productInfo?.thumbnailUrl960,
// shptmBanrTpNm: 'MEDIA',
// modal: newModalState,
// modalContainerId: 'product-video-player',
// modalClassName: modalClassNameChange(),
// spotlightDisable: true,
// })
// );
dispatch( dispatch(
startVideoPlayer({ startMediaPlayer({
qrCurrentItem: productInfo, qrCurrentItem: productInfo,
showUrl: productInfo?.prdtMediaUrl, showUrl: productInfo?.prdtMediaUrl,
showNm: productInfo?.prdtNm, showNm: productInfo?.prdtNm,
@@ -112,7 +138,15 @@ export default function ProductVideo({ productInfo, videoUrl, thumbnailUrl }) {
if (isLaunchedFromPlayer) { if (isLaunchedFromPlayer) {
setIsLaunchedFromPlayer(false); setIsLaunchedFromPlayer(false);
} }
}, [dispatch, productInfo, canPlayVideo, isLaunchedFromPlayer, modalClassNameChange, panels, modalState]); }, [
dispatch,
productInfo,
canPlayVideo,
isLaunchedFromPlayer,
modalClassNameChange,
panels,
modalState,
]);
if (!canPlayVideo) return null; if (!canPlayVideo) return null;

View File

@@ -1,11 +1,11 @@
// Light theme 개별 리뷰 아이템 컴포넌트 // Light theme 개별 리뷰 아이템 컴포넌트
import React, { useCallback } from "react"; import React, { useCallback } from 'react';
import Spottable from "@enact/spotlight/Spottable"; import Spottable from '@enact/spotlight/Spottable';
import classNames from "classnames"; import classNames from 'classnames';
import StarRating from "../../DetailPanel/components/StarRating"; import StarRating from '../../DetailPanel/components/StarRating';
import css from "./UserReviewItem.module.less"; import css from './UserReviewItem.module.less';
const SpottableComponent = Spottable("div"); const SpottableComponent = Spottable('div');
/** /**
* UserReviewPanel에서 사용하는 개별 리뷰 아이템 컴포넌트 * UserReviewPanel에서 사용하는 개별 리뷰 아이템 컴포넌트
@@ -20,7 +20,7 @@ const UserReviewItem = ({
onNextPage, onNextPage,
onPrevPage, onPrevPage,
onClick, onClick,
isPaging = false isPaging = false,
}) => { }) => {
const handleReviewClick = useCallback(() => { const handleReviewClick = useCallback(() => {
if (onClick) { if (onClick) {
@@ -29,50 +29,45 @@ const UserReviewItem = ({
}, [onClick, review, index]); }, [onClick, review, index]);
// 키보드 이벤트 핸들러 - 조건부 페이징 처리 // 키보드 이벤트 핸들러 - 조건부 페이징 처리
const handleKeyDown = useCallback((event) => { const handleKeyDown = useCallback(
// 첫번째 리뷰(index 0)에서 위 화살표 처리 (event) => {
if (event.key === 'ArrowUp' && index === 0) { // 첫번째 리뷰(index 0)에서 위 화살표 처리
if (hasPrev && onPrevPage) { if (event.key === 'ArrowUp' && index === 0) {
console.log('[UserReviewItem] 첫번째 리뷰에서 위 화살표 - 이전 페이지'); if (hasPrev && onPrevPage) {
onPrevPage(); // console.log('[UserReviewItem] 첫번째 리뷰에서 위 화살표 - 이전 페이지');
onPrevPage();
}
event.preventDefault(); // 페이징 여부와 상관없이 포커스 사라짐 방지
event.stopPropagation();
} }
event.preventDefault(); // 페이징 여부와 상관없이 포커스 사라짐 방지 // 세번째 리뷰(index 2)에서 아래 화살표 처리
event.stopPropagation(); else if (event.key === 'ArrowDown' && index === 2) {
} if (hasNext && onNextPage) {
// 세번째 리뷰(index 2)에서 아래 화살표 처리 // console.log('[UserReviewItem] 세번째 리뷰에서 아래 화살표 - 다음 페이지');
else if (event.key === 'ArrowDown' && index === 2) { onNextPage();
if (hasNext && onNextPage) { }
console.log('[UserReviewItem] 세번째 리뷰에서 아래 화살표 - 다음 페이지'); event.preventDefault(); // 페이징 여부와 상관없이 포커스 사라짐 방지
onNextPage(); event.stopPropagation();
} }
event.preventDefault(); // 페이징 여부와 상관없이 포커스 사라짐 방지 },
event.stopPropagation(); [index, hasNext, hasPrev, onNextPage, onPrevPage]
} );
}, [index, hasNext, hasPrev, onNextPage, onPrevPage]);
// 날짜 포맷팅 함수 // 날짜 포맷팅 함수
const formatToYYMMDD = (dateStr) => { const formatToYYMMDD = (dateStr) => {
const date = new Date(dateStr); const date = new Date(dateStr);
const iso = date.toISOString().slice(2, 10); const iso = date.toISOString().slice(2, 10);
return iso.replace(/-/g, "."); return iso.replace(/-/g, '.');
}; };
const { const { reviewImageList, rvwRtng, rvwRgstDtt, rvwCtnt, rvwId, wrtrNknm, rvwWrtrId } = review;
reviewImageList,
rvwRtng,
rvwRgstDtt,
rvwCtnt,
rvwId,
wrtrNknm,
rvwWrtrId
} = review;
return ( return (
<SpottableComponent <SpottableComponent
key={`user-review-item-${rvwId}`} key={`user-review-item-${rvwId}`}
aria-label={`user-review-item-${rvwId}`} aria-label={`user-review-item-${rvwId}`}
className={classNames(css.reviewContentContainer, { className={classNames(css.reviewContentContainer, {
[css.paging]: isPaging [css.paging]: isPaging,
})} })}
onClick={handleReviewClick} onClick={handleReviewClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -93,27 +88,16 @@ const UserReviewItem = ({
{/* 메타 정보 (별점, 작성자, 날짜) */} {/* 메타 정보 (별점, 작성자, 날짜) */}
<div className={css.reviewMeta}> <div className={css.reviewMeta}>
{rvwRtng && ( {rvwRtng && (
<StarRating <StarRating rating={rvwRtng} aria-label={`star rating ${rvwRtng} out of 5`} />
rating={rvwRtng}
aria-label={`star rating ${rvwRtng} out of 5`}
/>
)} )}
{(wrtrNknm || rvwWrtrId) && ( {(wrtrNknm || rvwWrtrId) && (
<span className={css.reviewAuthor}> <span className={css.reviewAuthor}>{wrtrNknm || rvwWrtrId}</span>
{wrtrNknm || rvwWrtrId}
</span>
)}
{rvwRgstDtt && (
<span className={css.reviewDate}>
{formatToYYMMDD(rvwRgstDtt)}
</span>
)} )}
{rvwRgstDtt && <span className={css.reviewDate}>{formatToYYMMDD(rvwRgstDtt)}</span>}
</div> </div>
{/* 리뷰 텍스트 */} {/* 리뷰 텍스트 */}
{rvwCtnt && ( {rvwCtnt && <div className={css.reviewText}>{rvwCtnt}</div>}
<div className={css.reviewText}>{rvwCtnt}</div>
)}
</div> </div>
</SpottableComponent> </SpottableComponent>
); );