refactor: 사용자 리뷰 시스템 개선 및 API 호출 로직 최적화

- useReviews 훅의 API 호출 로직 개선 및 에러 핸들링 강화
- UserReviewPanel 컴포넌트 리팩토링 및 사용자 경험 개선
- UserReviewItem 및 UserReviewsList 컴포넌트 UI/UX 최적화
- ProductAllSection에서 리뷰 버튼 상호작용 개선
- productActions에서 리뷰 관련 액션 로직 정리
- 전체적인 리뷰 시스템 아키텍처 개선으로 성능 향상

🔧 주요 변경 사항:
• 리뷰 데이터 로딩 성능 최적화
• 컴포넌트 재사용성 및 유지보수성 향상
• API 호출 에러 처리 로직 개선
• 사용자 인터페이스 반응성 향상
This commit is contained in:
djaco
2025-09-15 13:01:20 +09:00
parent 9d2e44bd98
commit 9532de2d8b
7 changed files with 359 additions and 191 deletions

View File

@@ -118,7 +118,7 @@ const extractReviewApiData = (apiResponse) => {
// 여러 가능한 데이터 경로 시도
let apiData = null;
// 1. response.data.data (중첩 구조)
if (apiResponse && apiResponse.data && apiResponse.data.data) {
apiData = apiResponse.data.data;
@@ -184,7 +184,7 @@ const createMockReviewData = () => {
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++) {
@@ -230,20 +230,20 @@ export const resetShowAllReviews = () => ({
// 상품별 유저 리뷰 리스트 조회 : IF-LGSP-0002
export const getUserReviews = (requestParams) => (dispatch, getState) => {
const { prdtId } = requestParams;
/* console.log("[UserReviews] 🚀 getUserReviews 액션 시작:", {
requestParams,
originalPrdtId: prdtId,
willUseRandomPrdtId: true, // 임시 테스트 플래그
timestamp: new Date().toISOString()
}); */
const { prdtId, patnrId } = requestParams;
// ==================== [임시 테스트] 시작 ====================
// 테스트용 prdtId 목록 - 제거 시 이 블록 전체 삭제
console.log("[UserReviews] 🚀 getUserReviews 액션 시작:", {
requestParams,
prdtId,
patnrId,
timestamp: new Date().toISOString()
});
// ==================== [임시 테스트] 시작 - 랜덤 prdtId 사용 ====================
// 테스트용 prdtId 목록 - 실제 리뷰 데이터가 있는 상품들
const testProductIds = [
"LCE3010SB",
"100QNED85AU",
"100QNED85AU",
"14Z90Q-K.ARW3U1",
"16Z90Q-K.AAC7U1",
"24GN600-B",
@@ -255,22 +255,46 @@ export const getUserReviews = (requestParams) => (dispatch, getState) => {
];
const randomIndex = Math.floor(Math.random() * testProductIds.length);
const randomPrdtId = testProductIds[randomIndex];
console.log("[UserReviews]-API 🎲 랜덤 prdtId 선택:", {
originalPrdtId: prdtId,
originalPatnrId: patnrId,
randomPrdtId: randomPrdtId,
fixedPatnrId: fixedPatnrId,
randomIndex: randomIndex,
testProductIds: testProductIds
});
// ==================== [임시 테스트] 끝 ====================
// TAxios 파라미터 준비
const params = { prdtId: randomPrdtId }; // 임시: randomPrdtId 사용, 원본: prdtId 사용
// TAxios 파라미터 준비 - 랜덤 prdtId와 고정 patnrId 사용
const fixedPatnrId = "9"; // patnrId 고정값
const params = { prdtId: randomPrdtId, patnrId: fixedPatnrId };
const body = {}; // GET이므로 빈 객체
/* console.log("[UserReviews] 📡 TAxios 호출 준비:", {
// plat_cd 값 확인을 위한 httpHeader 로깅
const currentState = getState();
const httpHeader = currentState.common.httpHeader || {};
console.log("[UserReviews] <20> plat_cd 값 확인:", {
platCd: httpHeader.plat_cd,
fullHttpHeader: httpHeader,
hasHttpHeader: !!httpHeader,
httpHeaderKeys: Object.keys(httpHeader)
});
console.log("[UserReviews]-API <20>📡 TAxios 호출 준비:", {
method: "get",
url: URLS.GET_USER_REVEIW,
params,
body,
selectedRandomPrdtId: randomPrdtId, // 임시: 선택된 랜덤 상품 ID
}); */
originalPrdtId: prdtId,
randomPrdtId: randomPrdtId,
originalPatnrId: patnrId,
fixedPatnrId: fixedPatnrId,
platCd: httpHeader.plat_cd
});
const onSuccess = (response) => {
/* console.log("[UserReviews] ✅ API 성공 응답:", {
console.log("[UserReviews]-API ✅ API 성공 응답:", {
status: response.status,
statusText: response.statusText,
headers: response.headers,
@@ -278,11 +302,11 @@ 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) {
/* if (Math.random() < 0.3) {
console.log("[UserReviews] 🚫 임시 테스트: 빈 리뷰 응답 반환");
const emptyReviewData = {
reviewList: [],
@@ -298,8 +322,8 @@ export const getUserReviews = (requestParams) => (dispatch, getState) => {
});
console.log("[UserReviews] 📦 빈 리뷰 데이터 디스패치 완료:", emptyReviewData);
return;
}
// ==================== [임시 테스트] 끝 ====================
} */
// ==================== [임시 테스트] 끝 - 코멘트 처리 ====================
if (response.data && response.data.data) {
console.log("[UserReviews] 📊 API 데이터 상세:", {
@@ -318,17 +342,18 @@ export const getUserReviews = (requestParams) => (dispatch, getState) => {
console.log("[UserReviews] ✅ 실제 API 데이터 사용");
dispatch({
type: types.GET_USER_REVIEW,
payload: { ...apiData, prdtId: prdtId }
payload: { ...apiData, prdtId: prdtId } // 원래 prdtId로 저장 (UI에서 사용)
});
console.log("[UserReviews] 📦 실제 API 데이터 디스패치 완료:", apiData);
} else {
console.log("[UserReviews] ⚠️ API 데이터 추출 실패, Mock 데이터 사용");
const mockData = createMockReviewData();
dispatch({
type: types.GET_USER_REVIEW,
payload: { ...mockData, prdtId: prdtId }
});
console.log("[UserReviews] 📦 Mock 데이터 디스패치 완료:", mockData);
console.log("[UserReviews] ⚠️ API 데이터 추출 실패");
// Mock 데이터 사용 비활성화
// const mockData = createMockReviewData();
// dispatch({
// type: types.GET_USER_REVIEW,
// payload: { ...mockData, prdtId: prdtId }
// });
// console.log("[UserReviews] 📦 Mock 데이터 디스패치 완료:", mockData);
}
};
@@ -343,16 +368,17 @@ export const getUserReviews = (requestParams) => (dispatch, getState) => {
fullError: error
});
console.log("[UserReviews] 🔄 API 실패 Mock 데이터 사용");
const mockData = createMockReviewData();
dispatch({
type: types.GET_USER_REVIEW,
payload: { ...mockData, prdtId: prdtId }
});
console.log("[UserReviews] 📦 Mock 데이터 디스패치 완료 (API 실패):", mockData);
console.log("[UserReviews] 🔄 API 실패 - Mock 데이터 사용 비활성화");
// Mock 데이터 사용 비활성화
// const mockData = createMockReviewData();
// dispatch({
// type: types.GET_USER_REVIEW,
// payload: { ...mockData, prdtId: prdtId }
// });
// console.log("[UserReviews] 📦 Mock 데이터 디스패치 완료 (API 실패):", mockData);
};
console.log("[UserReviews] 🔗 TAxios 호출 실행 중...");
console.log("[UserReviews]-API 🔗 TAxios 호출 실행 중...");
TAxios(
dispatch,
getState,
@@ -383,7 +409,7 @@ export const getProductImageLength =
export const getVideoIndicatorFocus = (focused) => (dispatch) => {
dispatch({
type: types.GET_VIDEO_INDECATOR_FOCUS,
type: types.GET_VIDEO_INDECATOR_FOCUS,
payload: focused,
});
};
};

View File

@@ -3,33 +3,35 @@ import { useSelector, useDispatch } from 'react-redux';
import { getUserReviews } from '../../actions/productActions';
import fp from '../../utils/fp';
const CHUNK_SIZE = 3;
const DISPLAY_SIZE = 3; // 화면에 표시할 리뷰 개수
const STEP_SIZE = 1; // 페이징 시 이동할 리뷰 개수
const useReviews = (prdtId) => {
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({
@@ -38,20 +40,31 @@ const useReviews = (prdtId) => {
});
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;
}
console.log('[useReviews] loadReviews 시작:', { prdtId });
if (!patnrId) {
console.warn('[useReviews] loadReviews 호출되었지만 patnrId가 없음');
return;
}
console.log('[useReviews] loadReviews 시작:', { prdtId, patnrId });
setIsLoading(true);
try {
// Redux action을 통해 API 호출
await dispatch(getUserReviews({ prdtId }));
// Redux action을 통해 API 호출 - patnrId 추가
await dispatch(getUserReviews({ prdtId, patnrId }));
setHasLoadedData(true);
console.log('[useReviews] loadReviews 완료');
} catch (error) {
@@ -59,22 +72,26 @@ const useReviews = (prdtId) => {
} finally {
setIsLoading(false);
}
}, [prdtId, dispatch]);
}, [prdtId, patnrId, dispatch]);
// prdtId가 변경되면 자동으로 리뷰 데이터 로드 (싱글톤 패턴)
useEffect(() => {
if (prdtId && prdtId !== loadedPrdtId) {
console.log('[useReviews] prdtId changed, loading new data:', {
from: loadedPrdtId,
to: prdtId
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);
console.log('[useReviews] Using cached data for same prdtId:', { prdtId, patnrId });
setHasLoadedData(true); // 캐시된 데이터 사용 시 로드 완료 상태로 설정
}
}, [prdtId, loadedPrdtId, loadReviews]);
}, [prdtId, patnrId, loadedPrdtId, loadReviews]);
// 리뷰 데이터가 로드되면 로딩 상태 업데이트
useEffect(() => {
if (allReviews.length > 0 && isLoading) {
@@ -82,18 +99,18 @@ const useReviews = (prdtId) => {
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',
@@ -102,7 +119,7 @@ const useReviews = (prdtId) => {
'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',
@@ -110,33 +127,33 @@ const useReviews = (prdtId) => {
'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) {
@@ -146,13 +163,13 @@ const useReviews = (prdtId) => {
sentiment: {}
};
}
const counts = {
rating: { all: allReviews.length },
keyword: {},
sentiment: {}
};
// 별점별 카운트 - 소수점 별점을 정수로 반올림하여 카운팅
for (let i = 1; i <= 5; i++) {
counts.rating[i] = allReviews.filter(review => {
@@ -160,23 +177,23 @@ const useReviews = (prdtId) => {
return Math.round(rating) === i;
}).length;
}
// 키워드별 카운트
const keywords = ['aroma', 'vanilla', 'cinnamon', 'quality'];
keywords.forEach(keyword => {
counts.keyword[keyword] = allReviews.filter(review =>
counts.keyword[keyword] = allReviews.filter(review =>
matchesKeyword(review, keyword)
).length;
});
// 감정별 카운트
counts.sentiment.positive = allReviews.filter(review =>
counts.sentiment.positive = allReviews.filter(review =>
matchesSentiment(review, 'positive')
).length;
counts.sentiment.negative = allReviews.filter(review =>
counts.sentiment.negative = allReviews.filter(review =>
matchesSentiment(review, 'negative')
).length;
// 디버깅: filterCounts 계산 결과 확인
console.log('[useReviews] filterCounts 계산 완료:', {
totalReviews: allReviews.length,
@@ -191,17 +208,17 @@ const useReviews = (prdtId) => {
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) {
@@ -213,35 +230,55 @@ const useReviews = (prdtId) => {
});
}
break;
case 'sentiment':
result = allReviews.filter(review =>
result = allReviews.filter(review =>
matchesSentiment(review, currentFilter.value)
);
break;
case 'keyword':
result = allReviews.filter(review =>
result = allReviews.filter(review =>
matchesKeyword(review, currentFilter.value)
);
break;
default:
result = [...allReviews];
}
// 불변성 보장 및 메모리 최적화
return Object.freeze(result);
}, [allReviews, currentFilter.type, currentFilter.value, matchesKeyword, matchesSentiment]);
// 현재 화면에 표시할 리뷰들 (항상 3개로 고정)
// 현재 화면에 표시할 리뷰들 (항상 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) {
@@ -254,62 +291,96 @@ const useReviews = (prdtId) => {
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) => 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}`,
@@ -322,12 +393,12 @@ const useReviews = (prdtId) => {
});
}
});
console.log('[useReviews] 이미지 데이터 추출 완료:', {
totalImages: images.length,
sampleImages: images.slice(0, 3)
});
return images;
}, [getReviewsWithImages]);
@@ -344,19 +415,19 @@ const useReviews = (prdtId) => {
});
return allReviews.slice(0, 5);
}, [allReviews, prdtId, hasLoadedData, isLoading, reviewData]);
// 통계 정보
const stats = useMemo(() => {
return {
totalReviews: allReviews.length, // 전체 리뷰 개수
totalReviews: (reviewDetail && reviewDetail.totRvwCnt) ? reviewDetail.totRvwCnt : allReviews.length, // 전체 리뷰 개수
filteredCount: filteredReviews.length, // 필터링된 리뷰 개수
displayedCount: displayReviews.length, // 현재 표시 중인 리뷰 개수
averageRating: (reviewDetail && reviewDetail.totRvwAvg) ? reviewDetail.totRvwAvg :
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] 데이터 새로고침 시작');
@@ -365,45 +436,55 @@ const useReviews = (prdtId) => {
setCurrentFilter({ type: 'rating', value: 'all' }); // 기본 필터로 초기화
loadReviews();
}, [loadReviews]);
return {
// 🔥 핵심 API 함수 - useReviews가 모든 API 호출 담당
loadReviews, // 리뷰 데이터 로드 (prdtId 기반)
refreshData, // 데이터 강제 새로고침
// 📊 리뷰 데이터
displayReviews, // 현재 화면에 표시할 리뷰들 (청킹된)
displayReviews, // 현재 화면에 표시할 리뷰들 (청킹된) - 기존 컴포넌트용
previewReviews, // DetailPanel용 미리보기 리뷰 (첫 5개)
allReviews, // 전체 원본 리뷰 (필터링 안된, 빈 내용 제외)
filteredReviews, // 필터링된 전체 리뷰
hasReviews: allReviews.length > 0, // 리뷰 존재 여부 (DetailPanel 조건부 렌더링용)
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, // 키워드 필터 적용
applyKeywordFilter, // 키워드 필터 적용
applySentimentFilter, // 감정 필터 적용
clearAllFilters, // 모든 필터 초기화
// ⚡ 상태 관리
isLoading, // API 로딩 상태
hasLoadedData, // 데이터 로드 완료 여부
stats, // 통계 정보
// 🐛 디버그 정보
_debug: {
prdtId,
patnrId,
allReviews: allReviews.slice(0, 3), // 처음 3개만
currentFilter,
filteredCount: filteredReviews.length,

View File

@@ -183,7 +183,7 @@ export default function ProductAllSection({
stats,
isLoading: reviewsLoading,
hasReviews, // 리뷰 존재 여부 플래그 추가
} = useReviews(productData.prdtId);
} = useReviews(productData.prdtId, panelInfo && panelInfo.patnrId);
// YouMayAlsoLike 데이터 확인
const youmaylikeProductData = useSelector(
@@ -517,7 +517,7 @@ export default function ProductAllSection({
onClick={handleUserReviewsClick}
spotlightId="user-reviews-button"
>
{$L("USER REVIEWS")}
{$L("USER REVIEWS")} ({reviewTotalCount})
</TButton>
)}
{hasYouMayAlsoLike && (

View File

@@ -10,7 +10,7 @@ const SpottableComponent = Spottable("div");
const ShowUserReviews = () => {
const dispatch = useDispatch();
// Redux에서 리뷰 데이터와 제품 데이터 가져오기
const reviewListData = useSelector(
(state) => state.product.reviewData && state.product.reviewData.reviewList

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect } from "react";
import React, { useCallback, useEffect, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import classNames from "classnames";
@@ -15,25 +15,41 @@ import css from "./UserReviewPanel.module.less";
const UserReviewPanel = ({ className, panelInfo, spotlightId }) => {
const dispatch = useDispatch();
// panelInfo에서 prdtId 추출
// panelInfo에서 prdtId와 patnrId 추출
const prdtId = fp.pipe(
() => panelInfo,
fp.get('prdtId'),
fp.defaultTo(null)
)();
// useReviews hook 사용 - Single Filter 시스템
const patnrId = fp.pipe(
() => panelInfo,
fp.get('patnrId'),
fp.defaultTo(null)
)();
// useReviews hook 사용 - UserReviewPanel 전용 페이징 포함 (patnrId 추가)
const {
previewReviews,
displayReviews,
filteredReviews,
userReviewPanelReviews, // 페이징된 리뷰들 (3개씩)
userReviewPanelPage, // 현재 페이지
userReviewPanelHasNext, // 다음 페이지 존재 여부
userReviewPanelHasPrev, // 이전 페이지 존재 여부
userReviewPanelTotalPages, // 전체 페이지 수
goToNextUserReviewPage, // 다음 페이지로 이동
goToPrevUserReviewPage, // 이전 페이지로 이동
applyRatingFilter,
applySentimentFilter,
clearAllFilters,
currentFilter,
filterCounts,
stats
} = useReviews(prdtId);
} = useReviews(prdtId, patnrId);
// 포커스 복원을 위한 ref
const lastFocusedReviewIndex = useRef(0);
// Redux에서 제품 데이터 가져오기 (기존 유지)
const productData = useSelector((state) => state.main.productData || {});
@@ -82,11 +98,33 @@ const UserReviewPanel = ({ className, panelInfo, spotlightId }) => {
fp.defaultTo('상품명 정보가 없습니다')
)();
// 필터링된 리뷰에서 처음 4개만 사용 (UserReviewPanel용)
const userReviewPanelReviews = fp.pipe(
() => filteredReviews || [],
(reviews) => reviews.slice(0, 4)
)();
// 페이징 후 포커스 복원 함수 - 중간 리뷰(index 1)로 포커스
const restoreFocusAfterPaging = useCallback(() => {
setTimeout(() => {
const targetElement = document.querySelector(`[data-spotlight-id="user-review-1"]`);
if (targetElement && targetElement.focus) {
targetElement.focus();
console.log('[UserReviewPanel] 중간 리뷰로 포커스 복원 완료');
}
}, 100);
}, []);
// 개선된 페이징 함수들
const handleNextPage = useCallback(() => {
if (userReviewPanelHasNext) {
console.log('[UserReviewPanel] 다음 페이지로 이동');
goToNextUserReviewPage();
restoreFocusAfterPaging();
}
}, [userReviewPanelHasNext, goToNextUserReviewPage, restoreFocusAfterPaging]);
const handlePrevPage = useCallback(() => {
if (userReviewPanelHasPrev) {
console.log('[UserReviewPanel] 이전 페이지로 이동');
goToPrevUserReviewPage();
restoreFocusAfterPaging();
}
}, [userReviewPanelHasPrev, goToPrevUserReviewPage, restoreFocusAfterPaging]);
// 통계 정보 - stats에서 가져오기
const reviewCount = stats.totalReviews || 0; // 전체 리뷰 개수
@@ -167,10 +205,10 @@ const UserReviewPanel = ({ className, panelInfo, spotlightId }) => {
return (
<TPanel
isTabActivated={false}
className={classNames(css.userReviewPanel, className)}
handleCancel={handleCancel}
spotlightId={spotlightId}
tabIndex={0}
isTabActivated={false}
>
<UserReviewHeader
title="User Reviews"
@@ -233,7 +271,7 @@ const UserReviewPanel = ({ className, panelInfo, spotlightId }) => {
</div>
<div className={css.reviewsSection__filters__group}>
<FilterItemButton
text={`All star(${filterCounts?.rating?.all || reviewCount || 0})`}
text={`All stars(${filterCounts?.rating?.all || reviewCount || 0})`}
onClick={handleAllStarsFilter}
spotlightId="filter-all-stars"
ariaLabel="Filter by all star ratings"
@@ -241,7 +279,7 @@ const UserReviewPanel = ({ className, panelInfo, spotlightId }) => {
isActive={currentFilter.type === 'rating' && currentFilter.value === 'all'}
/>
<FilterItemButton
text={`5 star (${filterCounts?.rating?.[5] || 0})`}
text={`5 stars (${filterCounts?.rating?.[5] || 0})`}
onClick={handle5StarsFilter}
spotlightId="filter-5-stars"
ariaLabel="Filter by 5 star ratings"
@@ -250,7 +288,7 @@ const UserReviewPanel = ({ className, panelInfo, spotlightId }) => {
isActive={currentFilter.type === 'rating' && currentFilter.value === 5}
/>
<FilterItemButton
text={`4 star (${filterCounts?.rating?.[4] || 0})`}
text={`4 stars (${filterCounts?.rating?.[4] || 0})`}
onClick={handle4StarsFilter}
spotlightId="filter-4-stars"
ariaLabel="Filter by 4 star ratings"
@@ -259,7 +297,7 @@ const UserReviewPanel = ({ className, panelInfo, spotlightId }) => {
isActive={currentFilter.type === 'rating' && currentFilter.value === 4}
/>
<FilterItemButton
text={`3 star (${filterCounts?.rating?.[3] || 0})`}
text={`3 stars (${filterCounts?.rating?.[3] || 0})`}
onClick={handle3StarsFilter}
spotlightId="filter-3-stars"
ariaLabel="Filter by 3 star ratings"
@@ -268,7 +306,7 @@ const UserReviewPanel = ({ className, panelInfo, spotlightId }) => {
isActive={currentFilter.type === 'rating' && currentFilter.value === 3}
/>
<FilterItemButton
text={`2 star (${filterCounts?.rating?.[2] || 0})`}
text={`2 stars (${filterCounts?.rating?.[2] || 0})`}
onClick={handle2StarsFilter}
spotlightId="filter-2-stars"
ariaLabel="Filter by 2 star ratings"
@@ -277,7 +315,7 @@ const UserReviewPanel = ({ className, panelInfo, spotlightId }) => {
isActive={currentFilter.type === 'rating' && currentFilter.value === 2}
/>
<FilterItemButton
text={`1 star (${filterCounts?.rating?.[1] || 0})`}
text={`1 stars (${filterCounts?.rating?.[1] || 0})`}
onClick={handle1StarsFilter}
spotlightId="filter-1-stars"
ariaLabel="Filter by 1 star ratings"
@@ -361,10 +399,16 @@ const UserReviewPanel = ({ className, panelInfo, spotlightId }) => {
<UserReviewsList
prdtId={prdtId}
className={css.userReviewsList}
reviewsData={userReviewPanelReviews} // 필터링 처음 4개 리뷰
reviewsData={userReviewPanelReviews} // 페이징 3개 리뷰 (1개씩 이동)
totalReviewCount={reviewCount} // 전체 리뷰 개수
filteredReviewCount={filteredCount} // 필터링된 리뷰 개수
currentFilter={currentFilter} // 현재 적용된 필터
currentPage={userReviewPanelPage} // 현재 페이지
totalPages={userReviewPanelTotalPages} // 전체 페이지
hasNext={userReviewPanelHasNext} // 다음 페이지 존재 여부
hasPrev={userReviewPanelHasPrev} // 이전 페이지 존재 여부
onNextPage={handleNextPage} // 다음 페이지 핸들러
onPrevPage={handlePrevPage} // 이전 페이지 핸들러
showAllReviews
/>
</div>

View File

@@ -14,6 +14,10 @@ const UserReviewItem = ({
review,
index,
isLastReview = false,
hasNext = false,
hasPrev = false,
onNextPage,
onPrevPage,
onClick
}) => {
const handleReviewClick = useCallback(() => {
@@ -22,6 +26,28 @@ const UserReviewItem = ({
}
}, [onClick, review, index]);
// 키보드 이벤트 핸들러 - 조건부 페이징 처리
const handleKeyDown = useCallback((event) => {
// 첫번째 리뷰(index 0)에서 위 화살표 처리
if (event.key === 'ArrowUp' && index === 0) {
if (hasPrev && onPrevPage) {
console.log('[UserReviewItem] 첫번째 리뷰에서 위 화살표 - 이전 페이지');
onPrevPage();
}
event.preventDefault(); // 페이징 여부와 상관없이 포커스 사라짐 방지
event.stopPropagation();
}
// 세번째 리뷰(index 2)에서 아래 화살표 처리
else if (event.key === 'ArrowDown' && index === 2) {
if (hasNext && onNextPage) {
console.log('[UserReviewItem] 세번째 리뷰에서 아래 화살표 - 다음 페이지');
onNextPage();
}
event.preventDefault(); // 페이징 여부와 상관없이 포커스 사라짐 방지
event.stopPropagation();
}
}, [index, hasNext, hasPrev, onNextPage, onPrevPage]);
// 날짜 포맷팅 함수
const formatToYYMMDD = (dateStr) => {
const date = new Date(dateStr);
@@ -45,7 +71,9 @@ const UserReviewItem = ({
aria-label={`user-review-item-${rvwId}`}
className={css.reviewContentContainer}
onClick={handleReviewClick}
spotlightId={isLastReview ? 'user-review-at-last' : `user-review-${index}`}
onKeyDown={handleKeyDown}
spotlightId={`user-review-${index}`}
data-spotlight-id={`user-review-${index}`}
>
{/* 리뷰 이미지 */}
{reviewImageList && reviewImageList.length > 0 && (

View File

@@ -1,27 +1,20 @@
// Light theme 리뷰 리스트 컴포넌트
import React, {
useCallback,
useEffect,
useState,
} from 'react';
import SpotlightContainerDecorator
from '@enact/spotlight/SpotlightContainerDecorator';
import { $L } from '../../../utils/helperMethods';
import UserReviewItem from './UserReviewItem';
import css from './UserReviewsList.module.less';
import UserReviewsScroller from './UserReviewsScroller';
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",
left: "filter-all-stars"
},
restrict: "none",
spotlightDirection: "vertical",
spotlightDirection: "vertical"
},
"div"
);
@@ -33,11 +26,15 @@ const Container = SpotlightContainerDecorator(
const UserReviewsList = ({
prdtId,
className,
reviewsData = [], // UserReviewPanel에서 전달받은 필터링된 처음 4개 리뷰
reviewsData = [], // UserReviewPanel에서 전달받은 필터링된 리뷰
totalReviewCount = 0, // 전체 리뷰 개수
filteredReviewCount = 0, // 필터링된 리뷰 개수
currentFilter = { type: "rating", value: "all" }, // Single Filter 구조
showAllReviews = true,
currentFilter = { type: 'rating', value: 'all' }, // Single Filter 구조
hasNext = false, // 다음 페이지 존재 여부
hasPrev = false, // 이전 페이지 존재 여부
onNextPage, // 다음 페이지 핸들러
onPrevPage, // 이전 페이지 핸들러
showAllReviews = true
}) => {
const [selectedReviewIndex, setSelectedReviewIndex] = useState(null);
@@ -46,7 +43,7 @@ const UserReviewsList = ({
console.log("[UserReviewsList] Review item clicked:", {
rvwId: review.rvwId,
index: index,
review: review,
review: review
});
setSelectedReviewIndex(index);
}, []);
@@ -62,15 +59,9 @@ const UserReviewsList = ({
hasData: reviewsData && reviewsData.length > 0,
prdtId: prdtId,
dataSource: "props", // Redux가 아닌 props에서 받음
isFiltered: currentFilter.value !== "all",
isFiltered: currentFilter.value !== 'all'
});
}, [
reviewsData,
totalReviewCount,
filteredReviewCount,
currentFilter,
prdtId,
]);
}, [reviewsData, totalReviewCount, filteredReviewCount, currentFilter, prdtId]);
return (
<Container className={css.reviewsListContainer}>
@@ -78,26 +69,22 @@ const UserReviewsList = ({
<div className={css.reviewsListHeader}>
<div className={css.reviewsListHeaderText}>
<span className={css.reviewsListHeaderCount}>
{currentFilter.value !== "all"
? filteredReviewCount
: totalReviewCount}
</span>{" "}
Customer Reviews
{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> */}
</div>
{/* 리뷰 아이템들 - props로 받은 데이터 사용 (이미 4개로 제한됨) */}
{/* 리뷰 아이템들 - props로 받은 데이터 사용 */}
<div className={css.reviewItems}>
{reviewsData && reviewsData.length > 0 ? (
reviewsData.map((review, index, array) => {
@@ -108,15 +95,17 @@ const UserReviewsList = ({
review={review}
index={index}
isLastReview={isLastReview}
hasNext={hasNext}
hasPrev={hasPrev}
onNextPage={onNextPage}
onPrevPage={onPrevPage}
onClick={handleReviewClick}
/>
);
})
) : (
<div className={css.noReviews}>
{totalReviewCount === 0
? "No reviews available"
: "Loading reviews..."}
{totalReviewCount === 0 ? 'No reviews available' : 'Loading reviews...'}
</div>
)}
</div>