[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:
2025-11-03 15:17:14 +09:00
parent e58ee38c3f
commit 7d40af88d2
2 changed files with 163 additions and 10 deletions

View File

@@ -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 실패 시뮬레이션 스위치

View File

@@ -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,