[251031] feat: UserReview Filter IF-LGSP-100 적용
🕐 커밋 시간: 2025. 10. 31. 12:38:53 📊 변경 통계: • 총 파일: 7개 • 추가: +249줄 • 삭제: -27줄 📝 수정된 파일: ~ com.twin.app.shoptime/src/actions/actionTypes.js ~ com.twin.app.shoptime/src/actions/productActions.js ~ com.twin.app.shoptime/src/api/apiConfig.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/ProductAllSection/ProductAllSection.jsx ~ com.twin.app.shoptime/src/views/SearchPanel/SearchInputOverlay.jsx 🔧 주요 변경 내용: • 타입 시스템 안정성 강화 • 핵심 비즈니스 로직 개선 • API 서비스 레이어 개선 • 대규모 기능 개발 • 모듈 구조 개선
This commit is contained in:
@@ -169,6 +169,7 @@ export const types = {
|
||||
CLEAR_PRODUCT_OPTIONS: 'CLEAR_PRODUCT_OPTIONS',
|
||||
GET_USER_REVIEW: 'GET_USER_REVIEW',
|
||||
GET_USER_REVIEW_LIST: 'GET_USER_REVIEW_LIST',
|
||||
GET_REVIEW_FILTERS: 'GET_REVIEW_FILTERS',
|
||||
TOGGLE_SHOW_ALL_REVIEWS: 'TOGGLE_SHOW_ALL_REVIEWS',
|
||||
RESET_SHOW_ALL_REVIEWS: 'RESET_SHOW_ALL_REVIEWS',
|
||||
|
||||
|
||||
@@ -311,24 +311,34 @@ export const getVideoIndicatorFocus = (focused) => (dispatch) => {
|
||||
|
||||
// User Review List 추가 조회 IF-LGSP-101
|
||||
export const getUserReviewList = (requestParams) => (dispatch, getState) => {
|
||||
const {
|
||||
prdtId,
|
||||
patnrId,
|
||||
filterTpCd = 'ALL',
|
||||
filterTpVal,
|
||||
pageSize = 5,
|
||||
pageNo = 1
|
||||
const {
|
||||
prdtId,
|
||||
patnrId,
|
||||
filterTpCd = 'ALL',
|
||||
filterTpVal,
|
||||
pageSize = 5,
|
||||
pageNo = 1
|
||||
} = requestParams;
|
||||
|
||||
const params = {
|
||||
prdtId,
|
||||
patnrId,
|
||||
const params = {
|
||||
prdtId,
|
||||
patnrId,
|
||||
filterTpCd,
|
||||
pageSize,
|
||||
pageNo
|
||||
pageNo,
|
||||
// 우선순위 1: cntryCd 기본값 'US' 설정 (TV 환경에서는 자동으로 header로 전달됨)
|
||||
cntryCd: 'US'
|
||||
};
|
||||
|
||||
if (filterTpCd !== 'ALL' && filterTpVal) {
|
||||
// 우선순위 2: filterTpVal 조건부 필수 검증 강화
|
||||
if (filterTpCd !== 'ALL') {
|
||||
if (!filterTpVal) {
|
||||
console.warn('[UserReviewList] ⚠️ filterTpCd가 ALL이 아니면 filterTpVal은 필수입니다', {
|
||||
filterTpCd,
|
||||
filterTpVal,
|
||||
message: 'filterTpVal이 제공되지 않았습니다'
|
||||
});
|
||||
}
|
||||
params.filterTpVal = filterTpVal;
|
||||
}
|
||||
|
||||
@@ -406,4 +416,146 @@ export const getUserReviewList = (requestParams) => (dispatch, getState) => {
|
||||
};
|
||||
|
||||
TAxios(dispatch, getState, 'get', URLS.GET_USER_REVIEW_LIST, params, body, onSuccess, onFail);
|
||||
};
|
||||
|
||||
// Review Filters 추출 함수 (IF-LGSP-100)
|
||||
const extractReviewFiltersApiData = (apiResponse) => {
|
||||
try {
|
||||
console.log('[ReviewFilters] 📥 extractReviewFiltersApiData 호출 - 원본 응답:', apiResponse);
|
||||
|
||||
let data = null;
|
||||
|
||||
// 응답 구조 분석
|
||||
if (apiResponse && apiResponse.data) {
|
||||
// data 경로에서 추출
|
||||
const apiData = apiResponse.data;
|
||||
const reviewFilterInfos = apiData.reviewFilterInfos || {};
|
||||
|
||||
data = reviewFilterInfos;
|
||||
|
||||
console.log('[ReviewFilters] 📊 apiResponse.data 경로에서 추출:', {
|
||||
patnrId: data.patnrId,
|
||||
prdtId: data.prdtId,
|
||||
filtersLength: data.filters ? data.filters.length : 0,
|
||||
filters: data.filters
|
||||
});
|
||||
} else if (apiResponse) {
|
||||
// 직접 경로에서 추출
|
||||
data = apiResponse.reviewFilterInfos || apiResponse;
|
||||
|
||||
console.log('[ReviewFilters] 📊 직접 경로에서 추출:', {
|
||||
patnrId: data.patnrId,
|
||||
prdtId: data.prdtId,
|
||||
filtersLength: data.filters ? data.filters.length : 0,
|
||||
filters: data.filters
|
||||
});
|
||||
}
|
||||
|
||||
if (!data || !data.filters) {
|
||||
console.warn('[ReviewFilters] ⚠️ filters가 없음:', apiResponse);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[ReviewFilters] ✅ 추출 완료:', {
|
||||
patnrId: data.patnrId,
|
||||
prdtId: data.prdtId,
|
||||
filtersLength: data.filters.length
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('[ReviewFilters] ❌ extractReviewFiltersApiData 에러:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Review Filters 조회 IF-LGSP-100
|
||||
export const getReviewFilters = (requestParams) => (dispatch, getState) => {
|
||||
const {
|
||||
prdtId,
|
||||
patnrId
|
||||
} = requestParams;
|
||||
|
||||
const params = {
|
||||
prdtId,
|
||||
patnrId,
|
||||
// 우선순위 1: cntryCd 기본값 'US' 설정 (TV 환경에서는 자동으로 header로 전달됨)
|
||||
cntryCd: 'US'
|
||||
};
|
||||
|
||||
const body = {};
|
||||
|
||||
console.log('[ReviewFilters] 🚀 API 요청 시작:', {
|
||||
requestParams,
|
||||
params,
|
||||
body,
|
||||
url: URLS.GET_REVIEW_FILTERS,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
const onSuccess = (response) => {
|
||||
console.log('[ReviewFilters] ✅ API 성공 응답:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
retCode: response.data && response.data.retCode,
|
||||
retMsg: response.data && response.data.retMsg,
|
||||
hasData: !!(response.data && response.data.data),
|
||||
fullResponse: response.data,
|
||||
});
|
||||
|
||||
const filtersData = extractReviewFiltersApiData(response.data);
|
||||
|
||||
console.log('[ReviewFilters] 📊 추출된 필터 데이터:', {
|
||||
hasData: !!filtersData,
|
||||
patnrId: filtersData && filtersData.patnrId,
|
||||
prdtId: filtersData && filtersData.prdtId,
|
||||
filtersLength: filtersData && filtersData.filters ? filtersData.filters.length : 0,
|
||||
filters: filtersData && filtersData.filters
|
||||
});
|
||||
|
||||
if (filtersData) {
|
||||
console.log('[ReviewFilters] 🔴 dispatch 직전 상태:', {
|
||||
actionType: types.GET_REVIEW_FILTERS,
|
||||
typeValue: 'GET_REVIEW_FILTERS',
|
||||
patnrId: patnrId,
|
||||
prdtId: prdtId
|
||||
});
|
||||
|
||||
const action = {
|
||||
type: types.GET_REVIEW_FILTERS,
|
||||
payload: {
|
||||
...filtersData,
|
||||
prdtId: prdtId,
|
||||
patnrId: patnrId
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[ReviewFilters] 🟡 dispatch할 액션:', JSON.stringify(action, null, 2));
|
||||
|
||||
dispatch(action);
|
||||
|
||||
console.log('[ReviewFilters] 📦 데이터 디스패치 완료:', {
|
||||
patnrId,
|
||||
prdtId,
|
||||
filtersLength: filtersData.filters ? filtersData.filters.length : 0
|
||||
});
|
||||
} else {
|
||||
console.warn('[ReviewFilters] ⚠️ 필터 데이터 추출 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const onFail = (error) => {
|
||||
console.error('[ReviewFilters] ❌ API 실패:', {
|
||||
message: error.message,
|
||||
status: error.response && error.response.status,
|
||||
statusText: error.response && error.response.statusText,
|
||||
responseData: error.response && error.response.data,
|
||||
requestParams: requestParams,
|
||||
params: params,
|
||||
url: URLS.GET_REVIEW_FILTERS,
|
||||
fullError: error,
|
||||
});
|
||||
};
|
||||
|
||||
TAxios(dispatch, getState, 'get', URLS.GET_REVIEW_FILTERS, params, body, onSuccess, onFail);
|
||||
};
|
||||
@@ -59,6 +59,8 @@ export const URLS = {
|
||||
GET_PRODUCT_GROUP: "/lgsp/v1/product/group.lge",
|
||||
GET_PRODUCT_OPTION: "/lgsp/v1/product/option.lge",
|
||||
GET_USER_REVEIW: "/lgsp/v1/product/reviews.lge",
|
||||
// IF-LGSP-100 신규 - Review Filters API 추가 251031
|
||||
GET_REVIEW_FILTERS: "/lgsp/v1/product/review/filter.lge",
|
||||
// IF-LGSP-101 신규 - Reviews API 추가 251029
|
||||
GET_USER_REVIEW_LIST: "/lgsp/v1/product/review/list.lge",
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { getUserReviews, getUserReviewList } from '../../actions/productActions';
|
||||
import { getUserReviews, getUserReviewList, getReviewFilters } from '../../actions/productActions';
|
||||
import fp from '../../utils/fp';
|
||||
|
||||
const DISPLAY_SIZE = 3; // 화면에 표시할 리뷰 개수
|
||||
@@ -67,6 +67,22 @@ const useReviews = (prdtId, patnrId, _deprecatedReviewVersion) => {
|
||||
return id;
|
||||
});
|
||||
|
||||
// Redux 상태에서 필터 데이터 가져오기 (IF-LGSP-100)
|
||||
const reviewFiltersData = useSelector((state) => {
|
||||
const data = state.product.reviewFiltersData;
|
||||
|
||||
console.log('[useReviews_reviewFilters] 📥 Redux reviewFiltersData 선택:', {
|
||||
dataExists: !!data,
|
||||
dataKeys: data ? Object.keys(data) : 'null',
|
||||
filtersLength: data?.filters?.length || 0,
|
||||
filters: data?.filters
|
||||
});
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
const loadedFiltersPrdtId = useSelector((state) => state.product.loadedFiltersPrdtId);
|
||||
|
||||
// 빈 내용 리뷰 필터링 - 의미있는 리뷰만 표시
|
||||
const allReviews = (reviewData?.reviewList || []).filter((review) => {
|
||||
const content = review.rvwCtnt?.trim();
|
||||
@@ -152,6 +168,13 @@ const useReviews = (prdtId, patnrId, _deprecatedReviewVersion) => {
|
||||
pageSize: 100,
|
||||
pageNo: 1
|
||||
}));
|
||||
|
||||
// IF-LGSP-100 필터 데이터 조회
|
||||
console.log('[useReviews] 🔄 getReviewFilters 호출 중... (IF-LGSP-100)');
|
||||
await dispatch(getReviewFilters({
|
||||
prdtId,
|
||||
patnrId
|
||||
}));
|
||||
}
|
||||
setHasLoadedData(true);
|
||||
} catch (error) {
|
||||
@@ -163,20 +186,24 @@ const useReviews = (prdtId, patnrId, _deprecatedReviewVersion) => {
|
||||
|
||||
// prdtId가 변경되면 자동으로 리뷰 데이터 로드 (싱글톤 패턴)
|
||||
// 중요: reviewVersion이 다르면 다른 Redux 상태를 사용하므로 reviewVersion도 체크해야 함
|
||||
// 우선순위 3: 필터 캐싱 로직 완성
|
||||
useEffect(() => {
|
||||
if (prdtId && patnrId) {
|
||||
const needsLoad = prdtId !== loadedPrdtId;
|
||||
const needsReviewLoad = prdtId !== loadedPrdtId;
|
||||
const needsFiltersLoad = prdtId !== loadedFiltersPrdtId;
|
||||
|
||||
// console.log('[useReviews] 🔄 로드 필요 여부 체크:', {
|
||||
// prdtId,
|
||||
// patnrId,
|
||||
// loadedPrdtId,
|
||||
// reviewVersion,
|
||||
// needsLoad,
|
||||
// reason: needsLoad ? 'prdtId가 변경됨' : '캐시된 데이터 사용'
|
||||
// });
|
||||
console.log('[useReviews] 🔄 로드 필요 여부 체크 (캐싱 로직 포함):', {
|
||||
prdtId,
|
||||
patnrId,
|
||||
loadedPrdtId,
|
||||
loadedFiltersPrdtId,
|
||||
reviewVersion,
|
||||
needsReviewLoad,
|
||||
needsFiltersLoad,
|
||||
reason: (needsReviewLoad || needsFiltersLoad) ? 'prdtId가 변경됨' : '캐시된 데이터 사용'
|
||||
});
|
||||
|
||||
if (needsLoad) {
|
||||
if (needsReviewLoad || needsFiltersLoad) {
|
||||
// prdtId 변경 시 로딩 상태 즉시 설정으로 UI 깜빡임 방지
|
||||
setIsLoading(true);
|
||||
setHasLoadedData(false);
|
||||
@@ -186,7 +213,7 @@ const useReviews = (prdtId, patnrId, _deprecatedReviewVersion) => {
|
||||
setHasLoadedData(true);
|
||||
}
|
||||
}
|
||||
}, [prdtId, patnrId, loadedPrdtId, loadReviews]);
|
||||
}, [prdtId, patnrId, loadedPrdtId, loadedFiltersPrdtId, loadReviews]);
|
||||
|
||||
// 리뷰 데이터가 로드되면 로딩 상태 업데이트
|
||||
useEffect(() => {
|
||||
@@ -732,6 +759,10 @@ const useReviews = (prdtId, patnrId, _deprecatedReviewVersion) => {
|
||||
hasLoadedData, // 데이터 로드 완료 여부
|
||||
stats, // 통계 정보
|
||||
|
||||
// 🔤 필터 옵션 데이터 (IF-LGSP-100)
|
||||
reviewFiltersData, // 필터 옵션 전체 데이터
|
||||
filters: reviewFiltersData?.filters || [], // 필터 배열 (RATING, KEYWORDS, SENTIMENT)
|
||||
|
||||
// 🐛 디버그 정보
|
||||
_debug: {
|
||||
prdtId,
|
||||
@@ -745,6 +776,7 @@ const useReviews = (prdtId, patnrId, _deprecatedReviewVersion) => {
|
||||
hasMore,
|
||||
isLoading,
|
||||
hasLoadedData,
|
||||
reviewFiltersData,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,6 +11,9 @@ const initialState = {
|
||||
// ReviewVersion 2 (신 API)
|
||||
reviewListData: null,
|
||||
loadedListPrdtId: null,
|
||||
// Review Filters (IF-LGSP-100)
|
||||
reviewFiltersData: null,
|
||||
loadedFiltersPrdtId: null,
|
||||
// 기타
|
||||
showAllReviews: false,
|
||||
};
|
||||
@@ -78,6 +81,21 @@ const handleResetShowAllReviews = curry((state, action) => {
|
||||
return set('showAllReviews', false, state);
|
||||
});
|
||||
|
||||
// 리뷰 필터 데이터 핸들러 (v2 - 신 API)
|
||||
const handleReviewFilters = curry((state, action) => {
|
||||
const reviewFiltersData = get('payload', action);
|
||||
const prdtId = get(['payload', 'prdtId'], action);
|
||||
|
||||
console.log('[productReducer_reviewFilters] 🟡 handleReviewFilters:', {
|
||||
prdtId,
|
||||
reviewFiltersDataKeys: reviewFiltersData ? Object.keys(reviewFiltersData) : 'null',
|
||||
filtersLength: reviewFiltersData?.filters?.length || 0,
|
||||
filters: reviewFiltersData?.filters
|
||||
});
|
||||
|
||||
return set('reviewFiltersData', reviewFiltersData, set('loadedFiltersPrdtId', prdtId, state));
|
||||
});
|
||||
|
||||
const handlers = {
|
||||
[types.GET_BEST_SELLER]: handleBestSeller,
|
||||
[types.GET_PRODUCT_OPTION]: handleProductOption,
|
||||
@@ -88,6 +106,7 @@ const handlers = {
|
||||
[types.GET_PRODUCT_OPTION_ID]: handleProductOptionId,
|
||||
// [types.GET_USER_REVIEW]: handleUserReview,
|
||||
[types.GET_USER_REVIEW_LIST]: handleUserReviewList,
|
||||
[types.GET_REVIEW_FILTERS]: handleReviewFilters,
|
||||
[types.TOGGLE_SHOW_ALL_REVIEWS]: handleToggleShowAllReviews,
|
||||
[types.RESET_SHOW_ALL_REVIEWS]: handleResetShowAllReviews,
|
||||
};
|
||||
|
||||
@@ -276,6 +276,22 @@ export default function ProductAllSection({
|
||||
selectedReduxState: _debug?.reviewVersion === 1 ? 'reviewData' : 'reviewListData'
|
||||
});
|
||||
|
||||
// 별점 높은 순으로 정렬된 상위 5개 리뷰 (방법 2: useMemo 최적화)
|
||||
const topRatedPreviewReviews = useMemo(() => {
|
||||
if (!previewReviews || previewReviews.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return previewReviews
|
||||
.slice() // 원본 배열 보존
|
||||
.sort((a, b) => {
|
||||
const ratingA = a.rvwRtng || 0;
|
||||
const ratingB = b.rvwRtng || 0;
|
||||
return ratingB - ratingA; // 내림차순 (높은 별점부터)
|
||||
})
|
||||
.slice(0, 5);
|
||||
}, [previewReviews]);
|
||||
|
||||
// YouMayAlsoLike 데이터 확인
|
||||
const hasYouMayAlsoLike = youmaylikeData && youmaylikeData.length > 0;
|
||||
|
||||
@@ -950,7 +966,7 @@ export default function ProductAllSection({
|
||||
productInfo={productData}
|
||||
panelInfo={panelInfo}
|
||||
reviewsData={{
|
||||
previewReviews: previewReviews.slice(0, 5), // 처음 5개만
|
||||
previewReviews: topRatedPreviewReviews, // 별점 높은 순 상위 5개
|
||||
stats: stats,
|
||||
isLoading: reviewsLoading,
|
||||
}}
|
||||
|
||||
@@ -142,7 +142,7 @@ const SearchInputOverlay = ({
|
||||
// ✨ [Phase 3] 동적 placeholder 텍스트
|
||||
const placeholderText = useMemo(() => {
|
||||
if (inputFocused) {
|
||||
return 'Start typing to search...';
|
||||
return 'Search products or brands';
|
||||
}
|
||||
return 'Ready to input..';
|
||||
}, [inputFocused]);
|
||||
|
||||
Reference in New Issue
Block a user