Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/components/DeviceSearch/CombinationDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface CombinationDetailModalProps {
showAllDevices: boolean;
onExpandChange: (expanded: boolean) => void;
isAlreadyInCombination: boolean | 0 | null;
duplicateReason: 'model' | 'category' | null;
isAddingDevice: boolean;
onAddDevice: () => void;
onBack: () => void;
Expand All @@ -24,11 +25,28 @@ const CombinationDetailModal = ({
showAllDevices,
onExpandChange,
isAlreadyInCombination,
duplicateReason,
isAddingDevice,
onAddDevice,
onBack,
onClose,
}: CombinationDetailModalProps) => {
// 중복 이유에 따라 다른 메시지 표시
const getButtonText = () => {
if (!isAlreadyInCombination) {
return `${combination.comboName}에 담기`;
}

if (duplicateReason === 'model') {
return '이미 담은 기기입니다.';
}

if (duplicateReason === 'category') {
return '이미 담은 타입입니다.';
}

return '이미 담은 타입입니다.';
};
return (
<div
className="flex flex-col items-start gap-20 pointer-events-auto"
Expand Down Expand Up @@ -97,7 +115,7 @@ const CombinationDetailModal = ({
<div className="px-40 pb-40 pt-30 mt-auto">
<div className="flex justify-end">
<PrimaryButton
text={isAlreadyInCombination ? '이미 담은 상품입니다.' : `${combination.comboName}에 담기`}
text={getButtonText()}
onClick={onAddDevice}
disabled={!!isAlreadyInCombination || isAddingDevice}
className={`w-280 ${isAlreadyInCombination ? '' : 'bg-blue-600 hover:bg-blue-500'}`}
Expand Down
72 changes: 63 additions & 9 deletions src/hooks/useAddToCombination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@ import { useGetCombo } from '@/apis/combo/getComboId';
import { usePostComboDevice } from '@/apis/combo/postComboDevices';
import { usePostRecentlyViewed } from '@/apis/recentlyViewed/postRecentlyViewed';
import { useAuth } from '@/hooks/useAuth';
import { getBaseModelName } from '@/utils/devices/getBaseModelName';

interface UseAddToCombinationParams {
selectedProductId: string | null;
selectedDeviceType?: string | null;
selectedDeviceName?: string | null;
onCloseModal: () => void;
}

export const useAddToCombination = ({
selectedProductId,
selectedDeviceType,
selectedDeviceName,
onCloseModal,
}: UseAddToCombinationParams) => {
const navigate = useNavigate();
Expand Down Expand Up @@ -56,12 +61,8 @@ export const useAddToCombination = ({
};

// 에러 핸들러 (공통)
const handleComboError = (error: any) => {
console.error('기기 추가 실패 상세 정보:', error.response?.data || error.message);
if (error.response?.data) {
console.log('Error Code:', error.response.data.errorCode || error.response.data.code);
console.log('Error Message:', error.response.data.message);
}
const handleComboError = (_error: any) => {
// 에러 처리 로직 필요시 추가
};

/* 내 조합에 담기 */
Expand Down Expand Up @@ -169,9 +170,61 @@ export const useAddToCombination = ({
const combinationDevices = comboDetail?.devices || [];

/* 선택된 조합에 이미 담긴 기기인지 확인 */
const isAlreadyInSelectedCombination = selectedCombinationId && selectedProductId
? combinationDevices.some(device => device.deviceId === Number(selectedProductId))
: false;
const duplicateCheck = (() => {
if (!selectedCombinationId || !selectedDeviceType || !selectedDeviceName) {
return { isBlocked: false, reason: null };
}

// deviceType 매핑 (영어 ↔ 한글)
const deviceTypeMap: Record<string, string[]> = {
'SMARTPHONE': ['SMARTPHONE', 'PHONE', '스마트폰', '폰'],
'LAPTOP': ['LAPTOP', '노트북'],
'TABLET': ['TABLET', '태블릿'],
'CHARGER': ['CHARGER', '충전기'],
'EARBUDS': ['EARBUDS', '이어버드'],
'WATCH': ['WATCH', '워치', '시계'],
};

// 같은 카테고리인지 확인하는 함수
const isSameDeviceType = (type1: string, type2: string): boolean => {
// 정확히 일치
if (type1 === type2) return true;

// 매핑 테이블에서 확인
for (const types of Object.values(deviceTypeMap)) {
if (types.includes(type1) && types.includes(type2)) {
return true;
}
}

return false;
};

const selectedBaseName = getBaseModelName(selectedDeviceName);

// 우선순위: 같은 모델 > 같은 카테고리
for (const device of combinationDevices) {
const deviceBaseName = getBaseModelName(device.name);
const isSameModel = deviceBaseName === selectedBaseName;

if (isSameModel) {
return { isBlocked: true, reason: 'model' as const };
}
}

for (const device of combinationDevices) {
const isSameCategory = isSameDeviceType(device.deviceType, selectedDeviceType);

if (isSameCategory) {
return { isBlocked: true, reason: 'category' as const };
}
}

return { isBlocked: false, reason: null };
})();

const isAlreadyInSelectedCombination = duplicateCheck.isBlocked;
const duplicateReason = duplicateCheck.reason;

return {
modalView,
Expand All @@ -185,6 +238,7 @@ export const useAddToCombination = ({
showAllDevices,
setShowAllDevices,
isAlreadyInSelectedCombination,
duplicateReason,
isAddingDevice,
addToCombinationConfig,
isProfileLoading: isAuthLoading,
Expand Down
18 changes: 12 additions & 6 deletions src/pages/devices/DeviceSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,33 +34,38 @@ const DeviceSearchPage = () => {
const productGridRef = useRef<HTMLDivElement>(null);
const scroll = useScrollState(productGridRef);

/* 선택된 제품 찾기 */
const selectedDevice = selectedProductId
? search.allDevices.find(d => d.deviceId === Number(selectedProductId))
: null;
const selectedProduct = selectedDevice ? mapSearchDeviceToProduct(selectedDevice) : null;

// 조합 담기 + 모달 상태
const combo = useAddToCombination({
selectedProductId,
selectedDeviceType: selectedDevice?.deviceType ?? null,
selectedDeviceName: selectedDevice?.name ?? null,
onCloseModal: () => {
searchParams.delete('productId');
setSearchParams(searchParams);
},
});

/* 선택된 제품 찾기 */
const selectedDevice = selectedProductId
? search.allDevices.find(d => d.deviceId === Number(selectedProductId))
: null;
const selectedProduct = selectedDevice ? mapSearchDeviceToProduct(selectedDevice) : null;

/* 모달 열림 상태 확인 및 스크롤 잠금 (회색 배경이 보일 때와 동일한 조건) */
const isModalOpen = (!!selectedProduct && !combo.showSaveCompleteModal) || combo.showSaveCompleteModal;

useEffect(() => {
if (isModalOpen) {
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
} else {
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
}

return () => {
document.documentElement.style.overflow = '';
document.body.style.overflow = '';
};
}, [isModalOpen]);

Expand Down Expand Up @@ -251,6 +256,7 @@ const DeviceSearchPage = () => {
showAllDevices={combo.showAllDevices}
onExpandChange={combo.setShowAllDevices}
isAlreadyInCombination={combo.isAlreadyInSelectedCombination}
duplicateReason={combo.duplicateReason}
isAddingDevice={combo.isAddingDevice}
onAddDevice={combo.handleAddDeviceToCombination}
onBack={() => combo.setModalView('combination')}
Expand Down
76 changes: 76 additions & 0 deletions src/utils/devices/getBaseModelName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* 기기명에서 용량과 색상 정보를 제거하여 베이스 모델명을 반환합니다.
* 같은 모델의 다른 용량/색상 기기를 동일한 모델로 인식하기 위해 사용됩니다.
*
* @param name - 전체 기기명 (예: "iPhone 15 Pro 블랙 512GB")
* @returns 베이스 모델명 (예: "iPhone 15 Pro")
*
* @example
* getBaseModelName("iPhone 15 Pro 블랙 512GB") // "iPhone 15 Pro"
* getBaseModelName("Samsung Galaxy S24 Ultra 화이트 256GB") // "Samsung Galaxy S24 Ultra"
* getBaseModelName("MacBook Pro 14 1TB") // "MacBook Pro 14"
*/
export const getBaseModelName = (name: string): string => {
if (!name) return '';

let baseName = name;

// 1. 용량 정보 제거 (512GB, 1TB, 256MB 등)
baseName = baseName.replace(/\s*\d+\s*(GB|TB|MB)\s*/gi, ' ');

// 2. 색상 정보 제거
const colorPatterns = [
// 한글 색상
/\s*블랙\s*/gi,
/\s*화이트\s*/gi,
/\s*실버\s*/gi,
/\s*골드\s*/gi,
/\s*그레이\s*/gi,
/\s*그린\s*/gi,
/\s*블루\s*/gi,
/\s*레드\s*/gi,
/\s*핑크\s*/gi,
/\s*퍼플\s*/gi,
/\s*옐로우\s*/gi,
/\s*오렌지\s*/gi,
/\s*브라운\s*/gi,
/\s*네이비\s*/gi,
/\s*스페이스\s*그레이\s*/gi,
/\s*미드나이트\s*/gi,
/\s*스타라이트\s*/gi,

// 영어 색상
/\s*Black\s*/gi,
/\s*White\s*/gi,
/\s*Silver\s*/gi,
/\s*Gold\s*/gi,
/\s*Gray\s*/gi,
/\s*Grey\s*/gi,
/\s*Green\s*/gi,
/\s*Blue\s*/gi,
/\s*Red\s*/gi,
/\s*Pink\s*/gi,
/\s*Purple\s*/gi,
/\s*Yellow\s*/gi,
/\s*Orange\s*/gi,
/\s*Brown\s*/gi,
/\s*Navy\s*/gi,
/\s*Space\s*Gray\s*/gi,
/\s*Midnight\s*/gi,
/\s*Starlight\s*/gi,
/\s*Rose\s*Gold\s*/gi,
/\s*로즈\s*골드\s*/gi,

// 괄호로 감싸진 색상 (예: "(블랙)", "(Black)")
/\s*\([^)]*\)\s*/g,
];

colorPatterns.forEach(pattern => {
baseName = baseName.replace(pattern, ' ');
});

// 3. 양 끝 공백 제거 및 연속된 공백을 하나로
baseName = baseName.trim().replace(/\s+/g, ' ');

return baseName;
};