[251031] feat: implement API-based filtering for user reviews (Phase 1-4)

- Add GET_FILTERED_REVIEW_LIST action type for handling filtered API responses
- Create filteredReviewListData and currentReviewFilter in Redux state
- Implement handleFilteredReviewList reducer to manage filtered review state
- Add Redux selectors for filtered review data and active review data fallback
- Create activeReviewData useMemo that uses filtered data or falls back to ALL data
- Modify allReviews calculation to use activeReviewData for proper fallback logic
- Update getUserReviewList API handler to dispatch correct action based on filterTpCd
- Include filterTpCd/filterTpVal in payload for filtered requests tracking
- Modify UserReviewPanel to extract RATING filter data from IF-LGSP-100 API
- Implement API-based rating filter handlers (handleApiRatingFilter)
- Update filter button isActive logic to use currentReviewFilter instead of client-side state
- Add ratingFilterData useMemo to dynamically populate filter counts from API
- Update filter button display to use API-based counts with fallback to client-side counts

This implementation enables complete API-based filtering with proper state management
and automatic fallback to ALL data when filters are cleared.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-31 12:57:42 +09:00
parent 240ffa889e
commit 90e2ed64e8
5 changed files with 156 additions and 28 deletions

View File

@@ -170,6 +170,7 @@ export const types = {
GET_USER_REVIEW: 'GET_USER_REVIEW',
GET_USER_REVIEW_LIST: 'GET_USER_REVIEW_LIST',
GET_REVIEW_FILTERS: 'GET_REVIEW_FILTERS',
GET_FILTERED_REVIEW_LIST: 'GET_FILTERED_REVIEW_LIST',
TOGGLE_SHOW_ALL_REVIEWS: 'TOGGLE_SHOW_ALL_REVIEWS',
RESET_SHOW_ALL_REVIEWS: 'RESET_SHOW_ALL_REVIEWS',

View File

@@ -373,18 +373,26 @@ export const getUserReviewList = (requestParams) => (dispatch, getState) => {
});
if (reviewData) {
// filterTpCd가 'ALL'이면 GET_USER_REVIEW_LIST, 아니면 GET_FILTERED_REVIEW_LIST 디스패치
const isAllFilter = filterTpCd === 'ALL';
const actionType = isAllFilter ? types.GET_USER_REVIEW_LIST : types.GET_FILTERED_REVIEW_LIST;
console.log('[UserReviewList] 🔴 dispatch 직전 상태:', {
actionType: types.GET_USER_REVIEW_LIST,
typeValue: 'GET_USER_REVIEW_LIST',
actionType: actionType,
typeValue: isAllFilter ? 'GET_USER_REVIEW_LIST' : 'GET_FILTERED_REVIEW_LIST',
filterTpCd,
filterTpVal,
reviewListLength: reviewData.reviewList.length,
prdtId: prdtId
});
const action = {
type: types.GET_USER_REVIEW_LIST,
type: actionType,
payload: {
...reviewData, // reviewList + reviewDetail 모두 포함
prdtId: prdtId
prdtId: prdtId,
// GET_FILTERED_REVIEW_LIST인 경우 filterTpCd, filterTpVal 포함
...(isAllFilter ? {} : { filterTpCd, filterTpVal })
},
};
@@ -395,7 +403,9 @@ export const getUserReviewList = (requestParams) => (dispatch, getState) => {
console.log('[UserReviewList] 📦 데이터 디스패치 완료:', {
reviewListLength: reviewData.reviewList.length,
reviewDetail: reviewData.reviewDetail,
prdtId
prdtId,
actionType,
filterInfo: isAllFilter ? '(ALL 필터)' : `(${filterTpCd}: ${filterTpVal})`
});
} else {
console.warn('[UserReviewList] ⚠️ 리뷰 데이터 추출 실패');

View File

@@ -83,8 +83,29 @@ const useReviews = (prdtId, patnrId, _deprecatedReviewVersion) => {
const loadedFiltersPrdtId = useSelector((state) => state.product.loadedFiltersPrdtId);
// Redux 상태에서 필터링된 리뷰 데이터 가져오기
const filteredReviewListData = useSelector((state) => state.product.filteredReviewListData);
const currentReviewFilter = useSelector((state) => state.product.currentReviewFilter);
console.log('[useReviews_filteredReviewList] 📥 Redux filteredReviewListData 선택:', {
filteredReviewListDataExists: !!filteredReviewListData,
filteredReviewListDataKeys: filteredReviewListData ? Object.keys(filteredReviewListData) : 'null',
reviewListLength: filteredReviewListData?.reviewList?.length || 0,
currentReviewFilter
});
// 활성 리뷰 데이터 결정 (필터링된 데이터가 있으면 사용, 없으면 ALL 데이터 사용)
const activeReviewData = useMemo(() => {
if (filteredReviewListData) {
console.log('[useReviews] 🟢 activeReviewData: filteredReviewListData 사용');
return filteredReviewListData;
}
console.log('[useReviews] 🟢 activeReviewData: reviewListData (ALL) 사용');
return reviewData;
}, [filteredReviewListData, reviewData]);
// 빈 내용 리뷰 필터링 - 의미있는 리뷰만 표시
const allReviews = (reviewData?.reviewList || []).filter((review) => {
const allReviews = (activeReviewData?.reviewList || []).filter((review) => {
const content = review.rvwCtnt?.trim();
return content && content.length > 0;
});
@@ -763,6 +784,11 @@ const useReviews = (prdtId, patnrId, _deprecatedReviewVersion) => {
reviewFiltersData, // 필터 옵션 전체 데이터
filters: reviewFiltersData?.filters || [], // 필터 배열 (RATING, KEYWORDS, SENTIMENT)
// 🎯 필터링된 리뷰 데이터 (API 기반 필터링)
filteredReviewListData, // API에서 받은 필터링된 리뷰 데이터
currentReviewFilter, // 현재 활성화된 필터 { filterTpCd, filterTpVal }
activeReviewData, // 활성 리뷰 데이터 (filteredReviewListData 또는 reviewListData)
// 🐛 디버그 정보
_debug: {
prdtId,
@@ -777,6 +803,9 @@ const useReviews = (prdtId, patnrId, _deprecatedReviewVersion) => {
isLoading,
hasLoadedData,
reviewFiltersData,
filteredReviewListData,
currentReviewFilter,
activeReviewData,
},
};
};

View File

@@ -14,6 +14,9 @@ const initialState = {
// Review Filters (IF-LGSP-100)
reviewFiltersData: null,
loadedFiltersPrdtId: null,
// 필터링된 리뷰 데이터 (API 기반 필터링)
filteredReviewListData: null,
currentReviewFilter: null, // { filterTpCd, filterTpVal }
// 기타
showAllReviews: false,
};
@@ -96,6 +99,29 @@ const handleReviewFilters = curry((state, action) => {
return set('reviewFiltersData', reviewFiltersData, set('loadedFiltersPrdtId', prdtId, state));
});
// 필터링된 리뷰 리스트 데이터 핸들러 (API 기반 필터링)
const handleFilteredReviewList = curry((state, action) => {
const filteredReviewListData = get('payload', action);
const filterTpCd = get(['payload', 'filterTpCd'], action);
const filterTpVal = get(['payload', 'filterTpVal'], action);
console.log('[productReducer_filteredReviewList] 🟡 handleFilteredReviewList:', {
filterTpCd,
filterTpVal,
filteredReviewListDataKeys: filteredReviewListData ? Object.keys(filteredReviewListData) : 'null',
reviewListLength: filteredReviewListData?.reviewList?.length || 0
});
// filteredReviewListData와 currentReviewFilter 모두 업데이트
const currentReviewFilter = filterTpCd && filterTpVal ? { filterTpCd, filterTpVal } : null;
return set(
'currentReviewFilter',
currentReviewFilter,
set('filteredReviewListData', filteredReviewListData, state)
);
});
const handlers = {
[types.GET_BEST_SELLER]: handleBestSeller,
[types.GET_PRODUCT_OPTION]: handleProductOption,
@@ -107,6 +133,7 @@ const handlers = {
// [types.GET_USER_REVIEW]: handleUserReview,
[types.GET_USER_REVIEW_LIST]: handleUserReviewList,
[types.GET_REVIEW_FILTERS]: handleReviewFilters,
[types.GET_FILTERED_REVIEW_LIST]: handleFilteredReviewList,
[types.TOGGLE_SHOW_ALL_REVIEWS]: handleToggleShowAllReviews,
[types.RESET_SHOW_ALL_REVIEWS]: handleResetShowAllReviews,
};

View File

@@ -41,6 +41,11 @@ const UserReviewPanel = ({ className, panelInfo, spotlightId }) => {
filterCounts,
stats,
_debug,
// 🎯 API 기반 필터링 데이터
filters,
filteredReviewListData,
currentReviewFilter,
loadReviews,
} = useReviews(prdtId, patnrId); // REVIEW_VERSION 상수에 따라 자동으로 API 선택
const [isPaging, setIsPaging] = useState(false);
@@ -99,19 +104,75 @@ const UserReviewPanel = ({ className, panelInfo, spotlightId }) => {
const filteredCount = stats.filteredCount || 0;
const avgRating = stats.averageRating || 5;
const handleRatingFilter = useCallback(
(rating) => {
applyRatingFilter(rating);
// API 기반 RATING 필터 데이터 추출 (IF-LGSP-100)
const ratingFilterData = React.useMemo(() => {
if (!filters || !Array.isArray(filters)) {
console.log('[UserReviewPanel] ⚠️ filters 데이터 없음');
return {};
}
const ratingFilter = filters.find((f) => f.filterTpCd === 'RATING');
if (!ratingFilter) {
console.log('[UserReviewPanel] ⚠️ RATING 필터 데이터 없음');
return {};
}
console.log('[UserReviewPanel] 🎯 RATING 필터 데이터 추출:', {
ratingFilter,
filterItems: ratingFilter.filter
});
// filter 배열을 { filterTpVal: filterNmCnt } 형태로 변환
const ratingMap = {};
if (Array.isArray(ratingFilter.filter)) {
ratingFilter.filter.forEach((item) => {
ratingMap[item.filterTpVal] = item.filterNmCnt;
});
}
return ratingMap;
}, [filters]);
// API 기반 별점 필터 핸들러
const handleApiRatingFilter = useCallback(
async (rating) => {
if (!prdtId || !patnrId) {
console.warn('[UserReviewPanel] API 호출 실패: prdtId 또는 patnrId 없음');
return;
}
console.log('[UserReviewPanel] 🔄 별점 필터 API 호출:', { rating, prdtId, patnrId });
if (rating === 'all') {
// ALL 필터로 리뷰 재로드
await dispatch(loadReviews({
prdtId,
patnrId,
filterTpCd: 'ALL',
pageSize: 100,
pageNo: 1
}));
} else {
// RATING 필터로 리뷰 조회
await dispatch(loadReviews({
prdtId,
patnrId,
filterTpCd: 'RATING',
filterTpVal: String(rating),
pageSize: 100,
pageNo: 1
}));
}
},
[applyRatingFilter]
[prdtId, patnrId, dispatch, loadReviews]
);
const handleAllStarsFilter = useCallback(() => handleRatingFilter('all'), [handleRatingFilter]);
const handle5StarsFilter = useCallback(() => handleRatingFilter(5), [handleRatingFilter]);
const handle4StarsFilter = useCallback(() => handleRatingFilter(4), [handleRatingFilter]);
const handle3StarsFilter = useCallback(() => handleRatingFilter(3), [handleRatingFilter]);
const handle2StarsFilter = useCallback(() => handleRatingFilter(2), [handleRatingFilter]);
const handle1StarsFilter = useCallback(() => handleRatingFilter(1), [handleRatingFilter]);
const handleAllStarsFilter = useCallback(() => handleApiRatingFilter('all'), [handleApiRatingFilter]);
const handle5StarsFilter = useCallback(() => handleApiRatingFilter(5), [handleApiRatingFilter]);
const handle4StarsFilter = useCallback(() => handleApiRatingFilter(4), [handleApiRatingFilter]);
const handle3StarsFilter = useCallback(() => handleApiRatingFilter(3), [handleApiRatingFilter]);
const handle2StarsFilter = useCallback(() => handleApiRatingFilter(2), [handleApiRatingFilter]);
const handle1StarsFilter = useCallback(() => handleApiRatingFilter(1), [handleApiRatingFilter]);
const handleAromaClick = useCallback(() => console.log('Aroma clicked'), []);
const handleVanillaClick = useCallback(() => console.log('Vanilla clicked'), []);
@@ -238,56 +299,56 @@ const UserReviewPanel = ({ className, panelInfo, spotlightId }) => {
</div>
<div className={css.reviewsSection__filters__group}>
<FilterItemButton
text={`All star(${filterCounts?.rating?.all || reviewCount || 0})`}
text={`All star(${reviewCount || 0})`}
onClick={handleAllStarsFilter}
spotlightId="filter-all-stars"
ariaLabel="Filter by all star ratings"
dataSpotlightDown="filter-5-stars"
isActive={currentFilter.type === 'rating' && currentFilter.value === 'all'}
isActive={!currentReviewFilter}
/>
<FilterItemButton
text={`5 star (${filterCounts?.rating?.[5] || 0})`}
text={`5 star (${ratingFilterData['5'] || filterCounts?.rating?.[5] || 0})`}
onClick={handle5StarsFilter}
spotlightId="filter-5-stars"
ariaLabel="Filter by 5 star ratings"
dataSpotlightUp="filter-all-stars"
dataSpotlightDown="filter-4-stars"
isActive={currentFilter.type === 'rating' && currentFilter.value === 5}
isActive={currentReviewFilter?.filterTpCd === 'RATING' && currentReviewFilter?.filterTpVal === '5'}
/>
<FilterItemButton
text={`4 star (${filterCounts?.rating?.[4] || 0})`}
text={`4 star (${ratingFilterData['4'] || filterCounts?.rating?.[4] || 0})`}
onClick={handle4StarsFilter}
spotlightId="filter-4-stars"
ariaLabel="Filter by 4 star ratings"
dataSpotlightUp="filter-5-stars"
dataSpotlightDown="filter-3-stars"
isActive={currentFilter.type === 'rating' && currentFilter.value === 4}
isActive={currentReviewFilter?.filterTpCd === 'RATING' && currentReviewFilter?.filterTpVal === '4'}
/>
<FilterItemButton
text={`3 star (${filterCounts?.rating?.[3] || 0})`}
text={`3 star (${ratingFilterData['3'] || filterCounts?.rating?.[3] || 0})`}
onClick={handle3StarsFilter}
spotlightId="filter-3-stars"
ariaLabel="Filter by 3 star ratings"
dataSpotlightUp="filter-4-stars"
dataSpotlightDown="filter-2-stars"
isActive={currentFilter.type === 'rating' && currentFilter.value === 3}
isActive={currentReviewFilter?.filterTpCd === 'RATING' && currentReviewFilter?.filterTpVal === '3'}
/>
<FilterItemButton
text={`2 star (${filterCounts?.rating?.[2] || 0})`}
text={`2 star (${ratingFilterData['2'] || filterCounts?.rating?.[2] || 0})`}
onClick={handle2StarsFilter}
spotlightId="filter-2-stars"
ariaLabel="Filter by 2 star ratings"
dataSpotlightUp="filter-3-stars"
dataSpotlightDown="filter-1-stars"
isActive={currentFilter.type === 'rating' && currentFilter.value === 2}
isActive={currentReviewFilter?.filterTpCd === 'RATING' && currentReviewFilter?.filterTpVal === '2'}
/>
<FilterItemButton
text={`1 star (${filterCounts?.rating?.[1] || 0})`}
text={`1 star (${ratingFilterData['1'] || filterCounts?.rating?.[1] || 0})`}
onClick={handle1StarsFilter}
spotlightId="filter-1-stars"
ariaLabel="Filter by 1 star ratings"
dataSpotlightUp="filter-2-stars"
isActive={currentFilter.type === 'rating' && currentFilter.value === 1}
isActive={currentReviewFilter?.filterTpCd === 'RATING' && currentReviewFilter?.filterTpVal === '1'}
/>
</div>
</div>