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
1 change: 1 addition & 0 deletions src/apis/devices/searchDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,6 @@ export const useSearchDevices = (params: Omit<SearchDevicesParams, 'cursor'>) =>
lastPage?.hasNext ? lastPage.nextCursor : undefined,
enabled: true,
staleTime: 1000 * 60 * 5,
placeholderData: (prev) => prev,
});
};
112 changes: 112 additions & 0 deletions src/components/DeviceSearch/CombinationDetailModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { ComboListItem, ComboDevice } from '@/types/combo/combo';
import CombinationDeviceCard from '@/components/Combination/CombinationDeviceCard';
import PrimaryButton from '@/components/Button/PrimaryButton';
import BackIcon from '@/assets/icons/back.svg?react';
import XIcon from '@/assets/icons/X.svg?react';

interface CombinationDetailModalProps {
combination: ComboListItem;
devices: ComboDevice[];
comboIndex: number;
showAllDevices: boolean;
onExpandChange: (expanded: boolean) => void;
isAlreadyInCombination: boolean | 0 | null;
isAddingDevice: boolean;
onAddDevice: () => void;
onBack: () => void;
onClose: () => void;
}

const CombinationDetailModal = ({
combination,
devices,
comboIndex,
showAllDevices,
onExpandChange,
isAlreadyInCombination,
isAddingDevice,
onAddDevice,
onBack,
onClose,
}: CombinationDetailModalProps) => {
return (
<div
className="flex flex-col items-start gap-20 pointer-events-auto"
style={{ paddingTop: '50px' }}
>
{/* Header: Back + X 버튼 */}
<div className="flex items-center justify-between w-full">
<button
onClick={onBack}
className="w-48 h-48 flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
aria-label="뒤로가기"
>
<BackIcon className="w-48 h-48" />
</button>
<button
onClick={onClose}
className="w-48 h-48 flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
aria-label="닫기"
>
<XIcon className="w-48 h-48 text-white" />
</button>
</div>

{/* Card */}
<div
className="bg-white rounded-card shadow-[0_0_10px_rgba(0,0,0,0.25)] mb-50 flex flex-col overflow-y-auto scrollbar-minimal"
style={{
width: '907px',
height:'670px',
}}
onClick={(e) => e.stopPropagation()}
>
{/* 조합 정보 + 기기 그리드 */}
<CombinationDeviceCard
combination={combination}
devices={devices}
columns={3}
defaultRows={3}
expanded={showAllDevices}
onExpand={(value) => onExpandChange(value)}
showExpandButton={false}
showGradient={true}
className="px-56 pt-40 pb-0 flex-shrink-0"
index={comboIndex}
/>

{/* 토글 버튼 - CombinationDeviceCard 외부에 배치 */}
{devices.length > 9 && !showAllDevices && (
<button
onClick={() => onExpandChange(true)}
className="mt-16 px-56 pl-68 font-body-2-r text-gray-500 underline cursor-pointer hover:opacity-80 w-fit"
>
기기 전체보기
</button>
)}
{devices.length > 9 && showAllDevices && (
<button
onClick={() => onExpandChange(false)}
className="mt-16 px-56 pl-68 font-body-2-r text-gray-500 underline cursor-pointer hover:opacity-80 w-fit"
>
간략히 보기
</button>
)}

{/* 버튼 컨테이너 - 토글 버튼으로부터 간격 유지하며 하단 고정 */}
<div className="px-40 pb-40 pt-30 mt-auto">
<div className="flex justify-end">
<PrimaryButton
text={isAlreadyInCombination ? '이미 담은 상품입니다.' : `${combination.comboName}에 담기`}
onClick={onAddDevice}
disabled={!!isAlreadyInCombination || isAddingDevice}
className={`w-280 ${isAlreadyInCombination ? '' : 'bg-blue-600 hover:bg-blue-500'}`}
/>
</div>
</div>
</div>
</div>
);
};

export default CombinationDetailModal;
88 changes: 88 additions & 0 deletions src/components/DeviceSearch/CombinationSelectModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { ComboListItem } from '@/types/combo/combo';
import BackIcon from '@/assets/icons/back.svg?react';
import XIcon from '@/assets/icons/X.svg?react';
import StarIcon from '@/assets/icons/star.svg?react';
import MoreIcon from '@/assets/icons/more.svg?react';

interface CombinationSelectModalProps {
combos: ComboListItem[];
onSelectCombination: (comboId: number) => void;
onBack: () => void;
onClose: () => void;
}

const CombinationSelectModal = ({
combos,
onSelectCombination,
onBack,
onClose,
}: CombinationSelectModalProps) => {
return (
<div className="flex flex-col items-end gap-20 pointer-events-auto">
<div className="flex items-center justify-between w-full">
<button
onClick={onBack}
className="w-48 h-48 flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
aria-label="뒤로가기"
>
<BackIcon className="w-48 h-48" />
</button>
<button
onClick={onClose}
className="w-48 h-48 flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
aria-label="닫기"
>
<XIcon className="w-48 h-48 text-white" />
</button>
</div>

{/* Card */}
<div
className="bg-white rounded-card shadow-[0_0_10px_rgba(0,0,0,0.25)]"
style={{
width: '907px',
height: '670px',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Combination List */}
<div className="flex flex-col ml-20 overflow-y-auto h-full scrollbar-minimal">
{combos.map((comboItem, index) => (
<button
key={comboItem.comboId}
onClick={() => onSelectCombination(comboItem.comboId)}
className="flex items-center justify-between pl-20 pr-36 py-24 hover:bg-gray-50 transition-colors border-b border-gray-200 cursor-pointer last:border-none"
>
{/* 좌측: 조합 정보 */}
<div className="flex flex-col gap-24 items-start">
{/* 조합 번호 + 조합명 */}
<div className="flex flex-col gap-8 items-start">
<p className="font-body-3-r text-gray-400">조합 {index + 1}</p>
{/* 조합명 + 대표조합 star */}
<div className="flex items-center gap-8">
<p className="font-body-1-sm text-black">{comboItem.comboName}</p>
{comboItem.isPinned && <StarIcon className="w-22 h-22 -mt-3" />}
</div>
</div>
{/* 기기 수 + 총 가격 */}
<div className="flex gap-12">
<span className="bg-blue-200 text-blue-700 font-body-2-sm px-12 py-8 rounded-full">
기기 {comboItem.deviceCount}개
</span>
<span className="bg-gray-200 text-gray-700 font-body-2-sm px-12 py-8 rounded-full">
₩{comboItem.totalPrice.toLocaleString()}
</span>
</div>
</div>

{/* 우측: More 아이콘 */}
<MoreIcon className="w-20 h-36 text-gray-400" />
</button>
))}
</div>
</div>
</div>
);
};

export default CombinationSelectModal;
137 changes: 137 additions & 0 deletions src/components/DeviceSearch/DeviceDetailModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { Product } from '@/constants/mockData';
import type { SearchDevice } from '@/types/devices';
import PrimaryButton from '@/components/Button/PrimaryButton';
import XIcon from '@/assets/icons/X.svg?react';

interface DeviceDetailModalProps {
product: Product;
device: SearchDevice;
addToCombinationConfig: { text: string; handler: () => void };
isProfileLoading: boolean;
onClose: () => void;
}

const DeviceDetailModal = ({
product,
device,
addToCombinationConfig,
isProfileLoading,
onClose,
}: DeviceDetailModalProps) => {
return (
<div className="flex flex-col items-end gap-20 pointer-events-auto">
{/* Close Button - 카드 바깥 */}
<button
onClick={onClose}
className="w-48 h-48 flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
aria-label="닫기"
>
<XIcon className="w-48 h-48 text-white" />
</button>

{/* Card */}
<div
className="bg-white rounded-card px-56 py-40 overflow-y-auto scrollbar-minimal"
style={{
width: '907px',
height: '670px',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Content */}
<div className="flex flex-col gap-20">
{/* Row 1: Name & Price */}
<div className="w-400">
{/* Name & Price */}
<div className="flex flex-col gap-12">
<p className="font-heading-1 text-blue-600">{product.name}</p>
<div className="flex items-center gap-8 font-heading-2 text-black">
<p>₩</p>
<p>{(product.price ?? 0).toLocaleString()}</p>
</div>
</div>
</div>

{/* Row 2: Image + Specs */}
<div className="flex items-start gap-56">
{/* Image */}
<div className="w-400 h-400 bg-gray-200 relative">
{product.image ? (
<img
src={product.image}
alt={product.name}
className="absolute inset-0 w-full h-full object-contain"
/>
) : null}
</div>

{/* Right Section - Specs */}
<div className="w-303 flex flex-col gap-40">
{/* Product Info Table */}
<div className="flex flex-col gap-20 pl-16">
<div className="flex items-center gap-24">
<p className="font-body-2-r text-gray-400 w-80 flex-shrink-0">모델명</p>
<p className="font-body-2-r text-black line-clamp-1">{product.name}</p>
</div>
<div className="flex items-center gap-24">
<p className="font-body-2-r text-gray-400 w-80">카테고리</p>
<p className="font-body-2-r text-black">{product.category}</p>
</div>
{device?.brandName && (
<div className="flex items-center gap-24">
<p className="font-body-2-r text-gray-400 w-80">브랜드</p>
<p className="font-body-2-r text-black">{device.brandName}</p>
</div>
)}
<div className="flex items-center gap-24">
<p className="font-body-2-r text-gray-400 w-80">가격</p>
<div className="flex items-center gap-4 font-body-2-r text-black">
<p>{(product.price ?? 0).toLocaleString()}</p>
<p>원</p>
</div>
</div>
{device?.specifications?.screenInch ? (
<div className="flex items-center gap-24">
<p className="font-body-2-r text-gray-400 w-80">인치</p>
<p className="font-body-2-r text-black">
{String(device.specifications.screenInch)}
</p>
</div>
) : null}
{device?.specifications?.chargingPort ? (
<div className="flex items-center gap-24">
<p className="font-body-2-r text-gray-400 w-80">충전방식</p>
<p className="font-body-2-r text-black">
{String(device.specifications.chargingPort).replace('_', '-')}
</p>
</div>
) : null}
{device?.releaseDate && (
<div className="flex items-center gap-24">
<p className="font-body-2-r text-gray-400 w-80">출시일</p>
<p className="font-body-2-r text-black">
{new Date(device.releaseDate).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long' })}
</p>
</div>
)}
</div>

</div>
</div>

{/* Row 3: Button */}
<div className="w-400">
<PrimaryButton
text={addToCombinationConfig.text}
onClick={addToCombinationConfig.handler}
disabled={isProfileLoading}
className="w-full bg-blue-500 hover:bg-blue-400 transition-colors"
/>
</div>
</div>
</div>
</div>
);
};

export default DeviceDetailModal;
21 changes: 21 additions & 0 deletions src/components/DeviceSearch/SaveCompleteModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import SaveIcon from '@/assets/icons/save.svg?react';

interface SaveCompleteModalProps {
isFadingOut: boolean;
}

const SaveCompleteModal = ({ isFadingOut }: SaveCompleteModalProps) => {
return (
<>
<div className={`fixed inset-0 bg-black/50 z-60 transition-opacity duration-200 ${isFadingOut ? 'opacity-0' : 'opacity-100'}`} />
<div className={`fixed inset-0 flex items-center justify-center z-80 transition-opacity duration-200 ${isFadingOut ? 'opacity-0' : 'opacity-100'}`}>
<div className="w-300 h-300 bg-white rounded-card shadow-[0_0_10px_rgba(0,0,0,0.25)] relative animate-fade-in">
<SaveIcon className="w-100 h-100 text-blue-600 absolute left-1/2 -translate-x-1/2 top-64" />
<p className="font-heading-3 text-blue-600 absolute left-1/2 -translate-x-1/2 top-206">저장 완료!</p>
</div>
</div>
</>
);
};

export default SaveCompleteModal;
26 changes: 26 additions & 0 deletions src/constants/deviceMapping.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// 카테고리 ID를 API deviceType으로 변환
export const getCategoryDeviceType = (categoryId: number | null): string | undefined => {
if (!categoryId) return undefined;
const mapping: Record<number, string> = {
1: 'SMARTPHONE',
2: 'LAPTOP',
3: 'TABLET',
4: 'SMARTWATCH',
5: 'AUDIO',
6: 'KEYBOARD',
7: 'MOUSE',
8: 'CHARGER',
};
return mapping[categoryId];
};

// sortOption을 API sortType으로 변환
export const getSortType = (sortOption: string) => {
const mapping: Record<string, 'LATEST' | 'NAME_ASC' | 'PRICE_ASC' | 'PRICE_DESC'> = {
'latest': 'LATEST',
'alphabetical': 'NAME_ASC',
'price-low': 'PRICE_ASC',
'price-high': 'PRICE_DESC',
};
return mapping[sortOption] ?? 'LATEST';
};
Loading