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
24 changes: 24 additions & 0 deletions src/apis/recentlyViewed/postRecentlyViewed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { axiosInstance } from '@/apis/axios/axios';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKey } from '@/constants/queryKey';
import type { CommonResponse } from '@/types/common';

// 최근 본 기기 기록 API
export const postRecentlyViewed = async (deviceId: number): Promise<CommonResponse> => {
const { data } = await axiosInstance.post<CommonResponse>(
`/api/recently-viewed/${deviceId}`
);
return data;
};

// 최근 본 기기 기록 Mutation
export const usePostRecentlyViewed = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (deviceId: number) => postRecentlyViewed(deviceId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [queryKey.RECENTLY_VIEWED] });
},
});
};
43 changes: 32 additions & 11 deletions src/components/Combination/CombinationDeviceCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { useState } from 'react';
import StarIcon from '@/assets/icons/star.svg?react';
import type { ComboListItem, ComboDevice } from '@/types/combo/combo';
import type { CombinationStatus } from '@/constants/combination';
import CombinationTag from '@/components/Combination/CombinationTag';
import { useComboEvaluation } from '@/apis/combo/getComboEvaluation';

type CombinationDeviceCardProps = {
combination: ComboListItem;
Expand Down Expand Up @@ -29,6 +32,9 @@ const CombinationDeviceCard = ({
}: CombinationDeviceCardProps) => {
const [internalExpanded, setInternalExpanded] = useState(false);

// 평가 데이터 조회 (마이페이지와 동일한 데이터 소스)
const { data: evaluation } = useComboEvaluation(combination.comboId);

const isControlled = expanded !== undefined;
const showAllDevices = isControlled ? expanded : internalExpanded;

Expand Down Expand Up @@ -60,7 +66,7 @@ const CombinationDeviceCard = ({
return (
<div className={className}>
{/* 조합 정보 */}
<div className="flex flex-col gap-24 pl-20 py-24 flex-shrink-0">
<div className="flex flex-col gap-16 pl-20 pt-24 flex-shrink-0">
{/* 조합명 */}
<div className="flex flex-col gap-8">
{/* 조합 번호 */}
Expand All @@ -73,26 +79,40 @@ const CombinationDeviceCard = ({
{combination.isPinned && <StarIcon className="w-22 h-22 -mt-3" />}
</div>
</div>
{/* Tags */}
<div className="flex gap-12 -ml-4">
<span className="bg-blue-200 text-blue-700 font-body-2-sm px-12 py-8 rounded-full">
기기 {combination.deviceCount}개
</span>
<span className="bg-gray-200 text-gray-700 font-body-2-sm px-12 py-8 rounded-full">
₩{combination.totalPrice.toLocaleString()}
</span>
{/* 평가 태그 */}
<div className="flex gap-8 -ml-4">
<CombinationTag
name="연동성"
status={(evaluation?.connectivityGrade || '-') as CombinationStatus}
/>
<CombinationTag
name="편의성"
status={(evaluation?.convenienceGrade || '-') as CombinationStatus}
/>
<CombinationTag
name="라이프스타일"
status={(evaluation?.lifestyleGrade || '-') as CombinationStatus}
/>
</div>
</div>

{/* 기기 그리드 */}
{/* 기기 그리드 - 태그와의 마진 24px (mt-24) */}
<div className="pl-8 mt-24 relative">
<div className={`grid ${gridColsClass} gap-x-28 gap-y-12`}>
{displayedDevices.map((device) => (
<div
key={device.deviceId}
className={`bg-white rounded-card shadow-[0_0_4px_rgba(0,0,0,0.1)] p-12 ${deviceCardWidth} flex items-center gap-12`}
>
<div className={`${deviceImageSize} bg-gray-200 flex-shrink-0`} />
<div className={`${deviceImageSize} bg-gray-200 flex-shrink-0 overflow-hidden relative`}>
{device.imageUrl && (
<img
src={device.imageUrl}
alt={device.name}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
</div>
<div className="flex flex-col gap-4 min-w-0">
<p className="font-body-3-sm text-black truncate">{device.name}</p>
<p className="font-body-4-r text-gray-300">{device.brandName}</p>
Expand Down Expand Up @@ -135,3 +155,4 @@ const CombinationDeviceCard = ({
};

export default CombinationDeviceCard;

6 changes: 5 additions & 1 deletion src/components/Combination/CombinationTag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ type CombinationTagProps = {
};

const CombinationTag = ({ name, status, className = '' }: CombinationTagProps) => {
// status가 유효하지 않을 경우를 대비한 안전한 스타일 추출
const statusStyle = COMBINATION_STATUS_STYLE_MAP[status] || COMBINATION_STATUS_STYLE_MAP['-'];
const displayStatus = status || '-';

return (
<span
className={`
Expand All @@ -24,7 +28,7 @@ const CombinationTag = ({ name, status, className = '' }: CombinationTagProps) =
`}
>
<span>{name}:</span>
<span className={COMBINATION_STATUS_STYLE_MAP[status]}>{status}</span>
<span className={statusStyle}>{displayStatus}</span>
</span>
);
};
Expand Down
94 changes: 65 additions & 29 deletions src/components/DeviceSearch/CombinationSelectModal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { ComboListItem } from '@/types/combo/combo';
import type { CombinationStatus } from '@/constants/combination';
import CombinationTag from '@/components/Combination/CombinationTag';
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';
import { useComboEvaluation } from '@/apis/combo/getComboEvaluation';

interface CombinationSelectModalProps {
combos: ComboListItem[];
Expand All @@ -11,6 +14,61 @@ interface CombinationSelectModalProps {
onClose: () => void;
}

/**
* 개별 조합 아이템 컴포넌트
* 마이페이지 상세 정보와 동일한 평가 데이터를 가져와서 표시합니다.
*/
const CombinationSelectItem = ({
comboItem,
index,
onSelect,
}: {
comboItem: ComboListItem;
index: number;
onSelect: (id: number) => void;
}) => {
// 각 조합의 평가 정보를 상세 API에서 가져옴 (마이페이지와 동일한 데이터 소스)
const { data: evaluation } = useComboEvaluation(comboItem.comboId);

return (
<button
onClick={() => onSelect(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-16 items-start">
{/* 조합 번호 + 조합명 */}
<div className="flex flex-col gap-8 items-start">
<p className="font-body-3-r text-gray-400">조합 {index + 1}</p>
<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>

{/* 평가 태그: evaluation 데이터가 있으면 등급을, 없으면 '-' 표시 */}
<div className="flex gap-8">
<CombinationTag
name="연동성"
status={(evaluation?.connectivityGrade || '-') as CombinationStatus}
/>
<CombinationTag
name="편의성"
status={(evaluation?.convenienceGrade || '-') as CombinationStatus}
/>
<CombinationTag
name="라이프스타일"
status={(evaluation?.lifestyleGrade || '-') as CombinationStatus}
/>
</div>
</div>

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

const CombinationSelectModal = ({
combos,
onSelectCombination,
Expand Down Expand Up @@ -48,36 +106,12 @@ const CombinationSelectModal = ({
{/* Combination List */}
<div className="flex flex-col ml-20 overflow-y-auto h-full scrollbar-minimal">
{combos.map((comboItem, index) => (
<button
<CombinationSelectItem
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>
comboItem={comboItem}
index={index}
onSelect={onSelectCombination}
/>
))}
</div>
</div>
Expand All @@ -86,3 +120,5 @@ const CombinationSelectModal = ({
};

export default CombinationSelectModal;


16 changes: 14 additions & 2 deletions src/components/DeviceSearch/DeviceDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { Product } from '@/types/product';
import type { SearchDevice } from '@/types/devices';
import PrimaryButton from '@/components/Button/PrimaryButton';
import XIcon from '@/assets/icons/X.svg?react';
import RoundedLifestyleTag from '@/components/Lifestyle/RoundedLifestyleTag';
import { getDeviceLifestyleTags } from '@/utils/tag/deviceLifestyleTags';

interface DeviceDetailModalProps {
product: Product;
Expand All @@ -18,6 +20,8 @@ const DeviceDetailModal = ({
isProfileLoading,
onClose,
}: DeviceDetailModalProps) => {
const rawTags: string[] = getDeviceLifestyleTags(device);

return (
<div className="flex flex-col items-end gap-20 pointer-events-auto">
{/* Close Button - 카드 바깥 */}
Expand Down Expand Up @@ -66,9 +70,9 @@ const DeviceDetailModal = ({
</div>

{/* Right Section - Specs */}
<div className="w-303 flex flex-col gap-40">
<div className="w-303 flex flex-col pl-16">
{/* Product Info Table */}
<div className="flex flex-col gap-20 pl-16">
<div className="flex flex-col gap-20 mb-40">
<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>
Expand Down Expand Up @@ -116,6 +120,14 @@ const DeviceDetailModal = ({
)}
</div>

{/* Lifestyle Tags */}
{rawTags.length > 0 && (
<div className="flex flex-wrap gap-12">
{rawTags.map((tag: string) => (
<RoundedLifestyleTag key={tag} label={`# ${tag}`} />
))}
</div>
)}
</div>
</div>

Expand Down
10 changes: 9 additions & 1 deletion src/components/MyPage/CombinationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,15 @@ const CombinationCard = ({
key={device.deviceId}
className="bg-white rounded-card shadow-[0_0_4px_rgba(0,0,0,0.1)] p-12 w-244 flex items-center gap-12"
>
<div className="w-64 h-64 bg-gray-200 flex-shrink-0" />
<div className="w-64 h-64 bg-gray-200 flex-shrink-0 overflow-hidden relative">
{device.imageUrl && (
<img
src={device.imageUrl}
alt={device.name}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
</div>
<div className="flex flex-col gap-4 flex-1">
<p className="font-body-3-sm text-black truncate w-120">{device.name}</p>
<p className="font-body-4-r text-gray-300">{device.brandName}</p>
Expand Down
10 changes: 9 additions & 1 deletion src/components/MyPage/CombinationDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface Device {
name: string;
brandName?: string;
deviceType?: string;
imageUrl?: string;
}

interface EvaluationCard {
Expand Down Expand Up @@ -151,7 +152,14 @@ const CombinationDetailView = ({
onClick={() => window.open(`/devices?productId=${device.deviceId}`, '_blank')}
className={`bg-white rounded-card shadow-[0_0_4px_rgba(0,0,0,0.1)] p-12 w-244 flex items-center gap-12 border cursor-pointer hover:shadow-[0_0_7px_#57a0ff] transition-shadow ${selectedDevices.includes(device.deviceId) ? 'border-blue-600' : 'border-transparent'}`}
>
<div className="w-64 h-64 bg-gray-200 flex-shrink-0 relative group/image">
<div className="w-64 h-64 bg-gray-200 flex-shrink-0 relative group/image overflow-hidden">
{device.imageUrl && (
<img
src={device.imageUrl}
alt={device.name}
className="absolute inset-0 w-full h-full object-cover"
/>
)}
{/* 호버 오버레이 */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover/image:opacity-100 transition-opacity flex items-center justify-center">
<span className="bg-white px-8 py-4 rounded-tag font-caption-r text-black">
Expand Down
28 changes: 10 additions & 18 deletions src/hooks/useAddToCombination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useGetCombos } from '@/apis/combo/getCombos';
import { useGetCombo } from '@/apis/combo/getComboId';
import { usePostComboDevice } from '@/apis/combo/postComboDevices';
import { useGetUserProfile } from '@/apis/mypage/getUserProfile';
import { usePostRecentlyViewed } from '@/apis/recentlyViewed/postRecentlyViewed';
import { hasAccessToken, hasCompletedOnboarding } from '@/utils/authStorage';

interface UseAddToCombinationParams {
Expand Down Expand Up @@ -33,12 +34,20 @@ export const useAddToCombination = ({
// API hooks
const { data: combos = [] } = useGetCombos();
const { mutate: addDeviceToCombo, isPending: isAddingDevice } = usePostComboDevice();
const { mutate: recordRecentlyViewed } = usePostRecentlyViewed();

const [selectedCombinationId, setSelectedCombinationId] = useState<number | null>(null);
const [showAllDevices, setShowAllDevices] = useState(false);
const [showSaveCompleteModal, setShowSaveCompleteModal] = useState(false);
const [isFadingOut, setIsFadingOut] = useState(false);

// 모달 열릴 때 최근 본 기기 기록 (로그인 상태에서만)
useEffect(() => {
if (isLoggedIn && selectedProductId) {
recordRecentlyViewed(Number(selectedProductId));
}
}, [selectedProductId]);

// 선택된 조합의 상세 정보 조회
const { data: comboDetail } = useGetCombo(selectedCombinationId);

Expand All @@ -52,12 +61,7 @@ export const useAddToCombination = ({

// 에러 핸들러 (공통)
const handleComboError = (error: unknown) => {
const axiosError = error as { response?: { status?: number } };
if (axiosError?.response?.status === 400) {
alert('이미 조합에 추가된 기기입니다.');
} else {
console.error('기기 추가 실패:', error);
}
console.error('기기 추가 실패:', error);
};

/* 내 조합에 담기 */
Expand Down Expand Up @@ -169,18 +173,6 @@ export const useAddToCombination = ({
? combinationDevices.some(device => device.deviceId === Number(selectedProductId))
: false;

/* 모달 열렸을 때 y 스크롤 방지 */
useEffect(() => {
if (selectedProductId) {
document.documentElement.style.overflowY = 'hidden';
} else {
document.documentElement.style.overflowY = 'auto';
}
return () => {
document.documentElement.style.overflowY = 'auto';
};
}, [selectedProductId]);

return {
modalView,
setModalView,
Expand Down
Loading