[251103] feat(actions): Implement dynamic ShopperHouse sorting tabs
🕐 커밋 시간: 2025. 11. 03. 15:17:14 Add dynamic sorting tab functionality that displays API-provided sortingType as the first tab option, with 5 additional client-side sorting methods. Key changes: - formatSortingTypeLabel: Converts API sortingType strings to readable labels (e.g. LG_RECOMMENDED -> Lg Recommended) - filterOptions: Dynamically creates sort options array with API sortingType as first item - filterMethods: Extracts UI labels from filterOptions - sortItems: Added 'api' case to preserve original sort order - convertedShopperHouseItemsSorted: Uses filterOptions to determine sort type - useEffect: Resets sorting tab to default when new search performed Original shopperHouseData is preserved; only client-side sorting applied with no additional API calls. Supports any sortingType value received from API. 📊 변경 통계: • 총 파일: 2개 • 추가: +163줄 • 삭제: -10줄 📝 수정된 파일: ~ com.twin.app.shoptime/src/actions/searchActions.js ~ com.twin.app.shoptime/src/views/SearchPanel/SearchResults.new.v2.jsx 🔧 주요 변경 내용: • 핵심 비즈니스 로직 개선 • 중간 규모 기능 개선
This commit is contained in:
@@ -97,7 +97,7 @@ export const updateSearchTimestamp = () => ({
|
||||
let getShopperHouseSearchKey = null;
|
||||
|
||||
export const getShopperHouseSearch =
|
||||
(query, searchId = null) =>
|
||||
(query, searchId = null, sortingType = null) =>
|
||||
(dispatch, getState) => {
|
||||
// ✅ 빈 query 체크 - API 호출 방지
|
||||
if (!query || query.trim() === '') {
|
||||
@@ -303,9 +303,13 @@ export const getShopperHouseSearch =
|
||||
if (searchId) {
|
||||
params.searchId = searchId;
|
||||
}
|
||||
if (sortingType) {
|
||||
params.sortingType = sortingType;
|
||||
}
|
||||
console.log('*[ShopperHouseAPI] getShopperHouseSearch params: ', JSON.stringify(params));
|
||||
console.log('*[ShopperHouseAPI] ├─ query:', query);
|
||||
console.log('*[ShopperHouseAPI] ├─ searchId:', searchId === null ? '(NULL)' : searchId);
|
||||
console.log('*[ShopperHouseAPI] ├─ sortingType:', sortingType === null ? '(NULL)' : sortingType);
|
||||
console.log('*[ShopperHouseAPI] └─ timestamp:', new Date().toISOString());
|
||||
|
||||
// 🔧 [테스트용] API 실패 시뮬레이션 스위치
|
||||
|
||||
@@ -27,6 +27,27 @@ import ShowCard from './SearchResultsNew/ShowCard';
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
// 정렬 타입 매핑
|
||||
const SORTING_TYPE_MAP = {
|
||||
0: 'NEW', // New
|
||||
1: 'POPULAR', // Popular
|
||||
2: 'LOW_PRICE', // Low Price
|
||||
3: 'HIGH_PRICE', // High Price
|
||||
4: 'RATING' // Rating
|
||||
};
|
||||
|
||||
// sortingType 문자열을 읽기 쉬운 라벨로 변환
|
||||
// 예: "LG_RECOMMENDED" → "Lg Recommended"
|
||||
// 예: "BEST_SELLER" → "Best Seller"
|
||||
const formatSortingTypeLabel = (sortingType) => {
|
||||
if (!sortingType) return 'Recommended';
|
||||
|
||||
return sortingType
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
// HowAboutThese 모드 상수
|
||||
export const HOW_ABOUT_THESE_MODES = {
|
||||
SMALL: 'small', // 작은 버전 (기본)
|
||||
@@ -34,6 +55,92 @@ export const HOW_ABOUT_THESE_MODES = {
|
||||
RESPONSE: 'response', // 응답 버전 (팝업)
|
||||
};
|
||||
|
||||
// 프론트엔드 정렬 함수들
|
||||
const sortItems = (items, sortType) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
|
||||
console.log('[SearchResultsNew] 🔧 정렬 시작 - sortType:', sortType, 'items.length:', items.length);
|
||||
|
||||
// 아이템 데이터 샘플 출력 (디버깅용)
|
||||
console.log('[SearchResultsNew] 📋 정렬 전 데이터 샘플:', items.slice(0, 3).map(item => ({
|
||||
title: item.title?.substring(0, 30) + '...',
|
||||
dcPrice: item.dcPrice,
|
||||
rankInfo: item.rankInfo,
|
||||
reviewGrade: item.reviewGrade
|
||||
})));
|
||||
|
||||
const sortedItems = [...items];
|
||||
|
||||
switch (sortType) {
|
||||
case 'api': // API에서 제공한 순서 (기본 정렬)
|
||||
console.log('[SearchResultsNew] ✅ API 정렬 (기본 순서 유지)');
|
||||
return sortedItems;
|
||||
|
||||
case 'NEW': // 신상품 (기본 정렬)
|
||||
console.log('[SearchResultsNew] ✅ NEW 정렬 (기본 순서 유지)');
|
||||
return sortedItems;
|
||||
|
||||
case 'POPULAR': { // 인기순 (랭킹 정보 기준)
|
||||
console.log('[SearchResultsNew] ✅ POPULAR 정렬 (랭킹순)');
|
||||
const result = sortedItems.sort((a, b) => (b.rankInfo || 0) - (a.rankInfo || 0));
|
||||
console.log('[SearchResultsNew] 정렬 후 데이터 샘플:', result.slice(0, 3).map(item => ({
|
||||
title: item.title?.substring(0, 30) + '...',
|
||||
rankInfo: item.rankInfo
|
||||
})));
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'LOW_PRICE': { // 낮은 가격순
|
||||
console.log('[SearchResultsNew] ✅ LOW_PRICE 정렬 (낮은 가격순)');
|
||||
const result = sortedItems.sort((a, b) => {
|
||||
const priceA = parseInt(a.dcPrice?.replace(/[^0-9]/g, '') || '0');
|
||||
const priceB = parseInt(b.dcPrice?.replace(/[^0-9]/g, '') || '0');
|
||||
console.log(` 가격 비교: ${a.title?.substring(0, 20)}... (${priceA}) vs ${b.title?.substring(0, 20)}... (${priceB})`);
|
||||
return priceA - priceB;
|
||||
});
|
||||
console.log('[SearchResultsNew] 정렬 후 가격순 샘플:', result.slice(0, 3).map(item => ({
|
||||
title: item.title?.substring(0, 30) + '...',
|
||||
dcPrice: item.dcPrice,
|
||||
parsedPrice: parseInt(item.dcPrice?.replace(/[^0-9]/g, '') || '0')
|
||||
})));
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'HIGH_PRICE': { // 높은 가격순
|
||||
console.log('[SearchResultsNew] ✅ HIGH_PRICE 정렬 (높은 가격순)');
|
||||
const result = sortedItems.sort((a, b) => {
|
||||
const priceA = parseInt(a.dcPrice?.replace(/[^0-9]/g, '') || '0');
|
||||
const priceB = parseInt(b.dcPrice?.replace(/[^0-9]/g, '') || '0');
|
||||
return priceB - priceA;
|
||||
});
|
||||
console.log('[SearchResultsNew] 정렬 후 높은 가격순 샘플:', result.slice(0, 3).map(item => ({
|
||||
title: item.title?.substring(0, 30) + '...',
|
||||
dcPrice: item.dcPrice
|
||||
})));
|
||||
return result;
|
||||
}
|
||||
|
||||
case 'RATING': { // 평점순
|
||||
console.log('[SearchResultsNew] ✅ RATING 정렬 (평점순)');
|
||||
const result = sortedItems.sort((a, b) => {
|
||||
const ratingA = parseFloat(a.reviewGrade || '0');
|
||||
const ratingB = parseFloat(b.reviewGrade || '0');
|
||||
console.log(` 평점 비교: ${a.title?.substring(0, 20)}... (${ratingA}) vs ${b.title?.substring(0, 20)}... (${ratingB})`);
|
||||
return ratingB - ratingA;
|
||||
});
|
||||
console.log('[SearchResultsNew] 정렬 후 평점순 샘플:', result.slice(0, 3).map(item => ({
|
||||
title: item.title?.substring(0, 30) + '...',
|
||||
reviewGrade: item.reviewGrade
|
||||
})));
|
||||
return result;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log('[SearchResultsNew] ⚠️ 알 수 없는 정렬 타입, 기본 순서 유지');
|
||||
return sortedItems;
|
||||
}
|
||||
};
|
||||
|
||||
// 메모리 누수 방지를 위한 안전한 이미지 컴포넌트
|
||||
const SafeImage = ({ src, alt, className, ...props }) => {
|
||||
const imgRef = useRef(null);
|
||||
@@ -128,7 +235,7 @@ const SearchResultsNew = ({
|
||||
|
||||
const getButtonTabList = () => {
|
||||
// ShopperHouse 데이터가 있으면 그것을 사용, 없으면 기존 검색 결과 사용
|
||||
const itemLength = convertedShopperHouseItems?.length || itemInfo?.length || 0;
|
||||
const itemLength = convertedShopperHouseItemsSorted?.length || itemInfo?.length || 0;
|
||||
const showLength = showInfo?.length || 0;
|
||||
|
||||
return [
|
||||
@@ -140,13 +247,53 @@ const SearchResultsNew = ({
|
||||
//탭
|
||||
const [tab, setTab] = useState(panelInfo?.tab ? panelInfo?.tab : 0);
|
||||
//드롭다운
|
||||
const [dropDownTab, setDropDownTab] = useState(0);
|
||||
const [dropDownTab, setDropDownTab] = useState(0); // 기본값: 첫번째 옵션(API sortingType)
|
||||
//표시할 아이템 개수
|
||||
const [visibleCount, setVisibleCount] = useState(ITEMS_PER_PAGE);
|
||||
|
||||
const [styleChange, setStyleChange] = useState(false);
|
||||
// filterMethods 빈 배열 캐싱으로 메모리 누수 방지
|
||||
const filterMethods = useMemo(() => [], []);
|
||||
|
||||
// 동적 정렬 옵션 배열 (인덱스 기반)
|
||||
const filterOptions = useMemo(() => {
|
||||
const apiSortingType = convertedShopperHouseItems?.[0]?.sortingType;
|
||||
const apiLabel = formatSortingTypeLabel(apiSortingType);
|
||||
|
||||
return [
|
||||
{ label: apiLabel, value: 'api' }, // ⭐ 첫번째: API sortingType (동적)
|
||||
{ label: 'New', value: 'NEW' }, // 두번째: 신상품
|
||||
{ label: 'Popular', value: 'POPULAR' }, // 세번째: 인기순
|
||||
{ label: 'Low Price', value: 'LOW_PRICE' }, // 네번째: 낮은 가격순
|
||||
{ label: 'High Price', value: 'HIGH_PRICE' }, // 다섯번째: 높은 가격순
|
||||
{ label: 'Rating', value: 'RATING' } // 여섯번째: 평점순
|
||||
];
|
||||
}, [convertedShopperHouseItems]);
|
||||
|
||||
// UI에 표시되는 라벨만 추출
|
||||
const filterMethods = useMemo(() =>
|
||||
filterOptions.map(opt => $L(opt.label)),
|
||||
[filterOptions]
|
||||
);
|
||||
|
||||
// 정렬된 ShopperHouse 데이터 계산
|
||||
const convertedShopperHouseItemsSorted = useMemo(() => {
|
||||
if (!convertedShopperHouseItems) return null;
|
||||
|
||||
// dropDownTab 인덱스로 filterOptions에서 정렬 타입 조회
|
||||
const currentSortingType = filterOptions[dropDownTab]?.value || 'api';
|
||||
|
||||
console.log('[SearchResultsNew] 🔄 정렬된 데이터 계산 - dropDownTab:', dropDownTab, 'sortingType:', currentSortingType);
|
||||
const sorted = sortItems(convertedShopperHouseItems, currentSortingType);
|
||||
console.log('[SearchResultsNew] ✅ 정렬된 데이터 생성 완료 - 길이:', sorted?.length);
|
||||
return sorted;
|
||||
}, [convertedShopperHouseItems, dropDownTab, filterOptions]);
|
||||
|
||||
// 새로운 검색(shopperHouseInfo 변경) 시 정렬 탭을 첫번째로 리셋
|
||||
useEffect(() => {
|
||||
console.log('[SearchResultsNew] 🔄 새 검색 감지 - 정렬 탭 리셋');
|
||||
setDropDownTab(0); // 첫번째 옵션(API sortingType)으로 리셋
|
||||
setVisibleCount(ITEMS_PER_PAGE); // 표시 개수도 리셋
|
||||
}, [shopperHouseInfo?.results?.[0]?.searchId]);
|
||||
|
||||
const localChangePageRef = useRef(null);
|
||||
const effectiveChangePageRef = cbChangePageRef ?? localChangePageRef;
|
||||
const [pendingFocusIndex, setPendingFocusIndex] = useState(null);
|
||||
@@ -278,11 +425,11 @@ const SearchResultsNew = ({
|
||||
}, [shopperHouseInfo, howAboutTheseMode, isHowAboutTheseLoading, hasPendingResponse]);
|
||||
|
||||
// buttonTabList 최적화 - 의존성이 변경될 때만 재계산
|
||||
const buttonTabList = useMemo(() => getButtonTabList(), [getButtonTabList]);
|
||||
const buttonTabList = useMemo(() => getButtonTabList(), [convertedShopperHouseItemsSorted, itemInfo, showInfo]);
|
||||
|
||||
// 현재 탭의 데이터 가져오기 - ShopperHouse 데이터 우선
|
||||
// 현재 탭의 데이터 가져오기 - ShopperHouse 데이터 우선 (정렬된 데이터 사용)
|
||||
const hasShopperHouseItems = convertedShopperHouseItems?.length > 0;
|
||||
const currentData = tab === 0 ? convertedShopperHouseItems || itemInfo : showInfo;
|
||||
const currentData = tab === 0 ? convertedShopperHouseItemsSorted || itemInfo : showInfo;
|
||||
|
||||
// 표시할 데이터 (처음부터 visibleCount 개수만큼)
|
||||
const displayedData = useMemo(() => {
|
||||
@@ -321,9 +468,11 @@ const SearchResultsNew = ({
|
||||
const handleSelectFilter = useCallback(
|
||||
({ selected }) => {
|
||||
if (selected === dropDownTab) {
|
||||
console.log('[SearchResultsNew] ⏭️ 동일한 정렬 옵션 선택 무시 - selected:', selected);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SearchResultsNew] 🎯 정렬 옵션 변경 -', dropDownTab, '→', selected, '(', SORTING_TYPE_MAP[selected], ')');
|
||||
setDropDownTab(selected);
|
||||
setVisibleCount(ITEMS_PER_PAGE); // 필터 변경시 표시 개수 리셋
|
||||
},
|
||||
@@ -576,8 +725,8 @@ const SearchResultsNew = ({
|
||||
data-spotlight-up="howAboutThese-seeMore"
|
||||
/>
|
||||
|
||||
{/* 필터링 dropdown */}
|
||||
{tab === 2 && !itemInfo?.length && (
|
||||
{/* 필터링 dropdown - ShopperHouse 데이터가 있을 때 정렬 옵션 표시 */}
|
||||
{tab === 0 && hasShopperHouseItems && (
|
||||
<TDropDown
|
||||
className={classNames(
|
||||
css.dropdown,
|
||||
|
||||
Reference in New Issue
Block a user