Files
shoptime/com.twin.app.shoptime/src/hooks/useReviews/useReviews.js
optrader 95bb25a135 [251019] fix: PlayerPanel Optimization-1
🕐 커밋 시간: 2025. 10. 19. 21:45:39

📊 변경 통계:
  • 총 파일: 11개
  • 추가: +119줄
  • 삭제: -101줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/actions/productActions.js
  ~ com.twin.app.shoptime/src/components/VideoPlayer/MediaPlayer.jsx
  ~ com.twin.app.shoptime/src/components/VideoPlayer/VideoPlayer.js
  ~ com.twin.app.shoptime/src/hooks/useReviews/useReviews.js
  ~ com.twin.app.shoptime/src/reducers/productReducer.js
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviews.jsx
  ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerPanel.jsx
  ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabButton/PlayerTabButton.jsx
  ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/FeaturedShowContents.jsx
  ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabContents/LiveChannelContents.jsx
  ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/v2/TabContainer.v2.jsx

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/actions/productActions.js (javascript):
    🔄 Modified: resetShowAllReviews()
  📄 com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/UserReviews/UserReviews.jsx (javascript):
    🔄 Modified: SpotlightContainerDecorator()
  📄 com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/TabButton/PlayerTabButton.jsx (javascript):
     Deleted: handleTabOnClick()
  📄 com.twin.app.shoptime/src/views/PlayerPanel/PlayerTabContents/v2/TabContainer.v2.jsx (javascript):
    🔄 Modified: Spottable()

🔧 주요 변경 내용:
  • 핵심 비즈니스 로직 개선
  • UI 컴포넌트 아키텍처 개선
2025-10-19 21:45:44 +09:00

634 lines
21 KiB
JavaScript

import { useState, useMemo, useEffect, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { getUserReviews } from '../../actions/productActions';
import fp from '../../utils/fp';
const DISPLAY_SIZE = 3; // 화면에 표시할 리뷰 개수
const STEP_SIZE = 1; // 페이징 시 이동할 리뷰 개수
const useReviews = (prdtId, patnrId) => {
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,
// patnrId,
// 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);
// 현재 제품이 이미 로드된 적이 있는지 확인 (Redux 기반)
const isCurrentProductLoaded = prdtId === loadedPrdtId;
// UserReviewPanel 전용 페이징 상태 (다른 컴포넌트에 영향 없음)
const [userReviewPanelPage, setUserReviewPanelPage] = useState(0);
// 리뷰 데이터 로드 함수 - useReviews가 모든 API 호출을 담당
const loadReviews = useCallback(async () => {
if (!prdtId) {
console.warn('[useReviews] loadReviews 호출되었지만 prdtId가 없음');
return;
}
if (!patnrId) {
console.warn('[useReviews] loadReviews 호출되었지만 patnrId가 없음');
return;
}
// console.log('[useReviews] loadReviews 시작:', { prdtId, patnrId });
setIsLoading(true);
try {
// Redux action을 통해 API 호출 - patnrId 추가
await dispatch(getUserReviews({ prdtId, patnrId }));
setHasLoadedData(true);
// console.log('[useReviews] loadReviews 완료');
} catch (error) {
console.error('[useReviews] loadReviews 실패:', error);
} finally {
setIsLoading(false);
}
}, [prdtId, patnrId, dispatch]);
// prdtId가 변경되면 자동으로 리뷰 데이터 로드 (싱글톤 패턴)
useEffect(() => {
if (prdtId && patnrId && prdtId !== loadedPrdtId) {
// console.log('[useReviews] prdtId changed, loading new data:', {
// from: loadedPrdtId,
// to: prdtId,
// patnrId
// });
// prdtId 변경 시 로딩 상태 즉시 설정으로 UI 깜빡임 방지
setIsLoading(true);
setHasLoadedData(false);
loadReviews();
} else if (prdtId === loadedPrdtId) {
// console.log('[useReviews] Using cached data for same prdtId:', { prdtId, patnrId });
setHasLoadedData(true); // 캐시된 데이터 사용 시 로드 완료 상태로 설정
}
}, [prdtId, patnrId, loadedPrdtId, loadReviews]);
// 리뷰 데이터가 로드되면 로딩 상태 업데이트
useEffect(() => {
if (allReviews.length > 0 && isLoading) {
setIsLoading(false);
setHasLoadedData(true);
}
}, [allReviews.length, isLoading]);
// keyword matching function
const matchesKeyword = useCallback((review, keyword) => {
if (!keyword) return true;
const content = review.rvwCtnt ? review.rvwCtnt.toLowerCase() : '';
return content.includes(keyword.toLowerCase());
}, []);
// Senntiment matching function
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 noStarReviews = allReviews.filter((review) => {
const rating = review.rvwScr || review.rvwRtng || review.rating;
return !rating || rating === 0;
});
// 별점이 없는 리뷰들 로그 출력
// if (noStarReviews.length > 0) {
// console.log('[UserReviews]-NoStar 별점 없는 리뷰들:', {
// totalReviews: allReviews.length,
// noStarCount: noStarReviews.length,
// noStarReviews: noStarReviews.map((review) => ({
// rvwId: review.rvwId,
// rvwScr: review.rvwScr,
// rvwRtng: review.rvwRtng,
// rating: review.rating,
// rvwCtnt: review.rvwCtnt?.substring(0, 50) + '...',
// wrtrNknm: review.wrtrNknm,
// })),
// });
// }
// 별점이 있는 리뷰만 카운트 (1~5점)
let ratingSum = 0;
for (let i = 1; i <= 5; i++) {
const count = allReviews.filter((review) => {
const rating = review.rvwScr || review.rvwRtng || review.rating || 0;
return Math.round(rating) === i;
}).length;
ratingSum += count;
}
const counts = {
rating: { all: ratingSum }, // 별점이 있는 리뷰만 카운트
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) {
// 'all' 필터: 별점이 있는 리뷰만 표시 (별점 없는 리뷰 제외)
result = allReviews.filter((review) => {
const rating = review.rvwScr || review.rvwRtng || review.rating;
return rating && rating > 0;
});
} 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]);
// UserReviewPanel 전용 페이징된 리뷰들 (3개 표시, 1개씩 이동)
const userReviewPanelReviews = useMemo(() => {
const startIndex = userReviewPanelPage * STEP_SIZE;
const endIndex = startIndex + DISPLAY_SIZE;
return filteredReviews.slice(startIndex, endIndex);
}, [filteredReviews, userReviewPanelPage]);
// 더 로드할 리뷰가 있는지 확인 (기존 로직)
const hasMore = displayReviews.length < filteredReviews.length;
// UserReviewPanel 전용 페이징 상태들
const userReviewPanelHasNext = fp.pipe(
() => (userReviewPanelPage + 1) * STEP_SIZE + DISPLAY_SIZE - 1,
(lastIndex) => lastIndex < filteredReviews.length
)();
const userReviewPanelHasPrev = userReviewPanelPage > 0;
const userReviewPanelTotalPages = fp.pipe(
() => Math.max(0, filteredReviews.length - DISPLAY_SIZE + 1),
(maxStartIndex) => Math.ceil(maxStartIndex / STEP_SIZE)
)();
// 다음 청크 로드 (클라이언트 사이드에서 페이지만 증가)
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]);
// UserReviewPanel 전용 페이징 함수들
const goToNextUserReviewPage = useCallback(() => {
if (userReviewPanelHasNext) {
// console.log('[useReviews] UserReviewPanel 다음 페이지:', {
// currentPage: userReviewPanelPage,
// nextPage: userReviewPanelPage + 1,
// totalPages: userReviewPanelTotalPages,
// filteredCount: filteredReviews.length
// });
setUserReviewPanelPage(fp.pipe((prev) => prev + 1));
}
}, [
userReviewPanelHasNext,
userReviewPanelPage,
userReviewPanelTotalPages,
filteredReviews.length,
]);
const goToPrevUserReviewPage = useCallback(() => {
if (userReviewPanelHasPrev) {
// console.log('[useReviews] UserReviewPanel 이전 페이지:', {
// currentPage: userReviewPanelPage,
// prevPage: userReviewPanelPage - 1,
// totalPages: userReviewPanelTotalPages,
// filteredCount: filteredReviews.length
// });
setUserReviewPanelPage(fp.pipe((prev) => prev - 1));
}
}, [
userReviewPanelHasPrev,
userReviewPanelPage,
userReviewPanelTotalPages,
filteredReviews.length,
]);
// 필터 변경 시 UserReviewPanel 페이지도 초기화
useEffect(() => {
setUserReviewPanelPage(0);
}, [currentFilter.type, currentFilter.value]);
// 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(() => {
// API와 실제 리뷰 개수 불일치 확인
const apiTotalCount = reviewDetail && reviewDetail.totRvwCnt ? reviewDetail.totRvwCnt : 0;
const actualReviewCount = allReviews.length;
// if (apiTotalCount > actualReviewCount && actualReviewCount > 0) {
// console.log('[UserReviews]-API Mismatch API 개수와 실제 개수 불일치:', {
// apiTotalCount,
// actualReviewCount,
// difference: apiTotalCount - actualReviewCount,
// reason: 'API가 최대 100개만 반환하는 페이징 제한',
// });
// }
return {
totalReviews: actualReviewCount, // 실제로 받은 리뷰 개수 사용 (API 제한 반영)
filteredCount: filteredReviews.length, // 필터링된 리뷰 개수
displayedCount: displayReviews.length, // 현재 표시 중인 리뷰 개수
averageRating:
reviewDetail && reviewDetail.totRvwAvg
? reviewDetail.totRvwAvg
: reviewDetail && reviewDetail.avgRvwScr
? reviewDetail.avgRvwScr
: 0,
totalRatingCount: actualReviewCount, // 실제로 받은 리뷰 개수 사용
};
}, [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 && hasLoadedData && !isLoading && isCurrentProductLoaded, // 리뷰 존재 여부 (현재 제품이 로드된 경우에만 true)
// 🔄 UserReviewPanel 전용 페이징 데이터
userReviewPanelReviews, // UserReviewPanel용 페이징된 리뷰들 (3개 표시, 1개씩 이동)
userReviewPanelPage, // 현재 페이지 번호 (0부터 시작)
userReviewPanelHasNext, // 다음 페이지 존재 여부
userReviewPanelHasPrev, // 이전 페이지 존재 여부
userReviewPanelTotalPages, // 전체 페이지 수
goToNextUserReviewPage, // 다음 페이지로 이동
goToPrevUserReviewPage, // 이전 페이지로 이동
// 🖼️ 이미지 관련 데이터 - CustomerImages 전용
getReviewsWithImages, // 이미지가 있는 리뷰들만 필터링
extractImagesFromReviews, // 이미지 데이터만 추출 (reviewData 포함)
// 📄 클라이언트 사이드 페이지네이션
hasMore, // 더 로드할 리뷰가 있는지
loadMore, // 다음 청크 표시 (클라이언트에서 슬라이싱)
currentPage, // 현재 페이지 (0부터 시작)
// 🔍 필터링 시스템
currentFilter, // 현재 활성화된 필터 { type, value }
filterCounts, // 각 필터별 리뷰 개수 (실시간 계산)
applyFilter, // 통합 필터 적용 함수
applyRatingFilter, // 별점 필터 적용
applyKeywordFilter, // 키워드 필터 적용
applySentimentFilter, // 감정 필터 적용
clearAllFilters, // 모든 필터 초기화
// ⚡ 상태 관리
isLoading, // API 로딩 상태
hasLoadedData, // 데이터 로드 완료 여부
stats, // 통계 정보
// 🐛 디버그 정보
_debug: {
prdtId,
patnrId,
allReviews: allReviews.slice(0, 3), // 처음 3개만
currentFilter,
filteredCount: filteredReviews.length,
displayedCount: displayReviews.length,
currentPage,
hasMore,
isLoading,
hasLoadedData,
},
};
};
export default useReviews;