From 1d25408d3ed988eb4b815ff8b9ec5c01600b459b Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:20:44 +0900 Subject: [PATCH 01/28] =?UTF-8?q?refactor:=20=EC=83=81=EC=88=98=20&=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/combination.ts | 6 ++++++ src/utils/format.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/constants/combination.ts b/src/constants/combination.ts index ef05bee6..d0aaede8 100644 --- a/src/constants/combination.ts +++ b/src/constants/combination.ts @@ -44,3 +44,9 @@ export const COMBO_MOTION = { DOUBLE_DELAY: 160, EXTRAS_AT_LIFT_PROGRESS: 0.01, } as const; + +export const MYPAGE_SORT_OPTIONS = [ + { value: 'latest', label: '최근생성순' }, + { value: 'oldest', label: '오래된순' }, + { value: 'alphabetical', label: '가나다순' }, +] as const; diff --git a/src/utils/format.ts b/src/utils/format.ts index 328440dc..e9cb32e7 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -8,3 +8,16 @@ export function formatTime(seconds: number): string { const sec = seconds % 60; return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`; } + +/** + * ISO 날짜 문자열을 YYYY.MM.DD 형식으로 변환 + * @param isoDate - ISO 8601 형식 날짜 문자열 + * @returns "YYYY.MM.DD" 형식 문자열 + */ +export function formatDate(isoDate: string): string { + const date = new Date(isoDate); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}.${month}.${day}`; +} From 74628cdab4e2d146c219677211c07c6d36d79226 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:23:35 +0900 Subject: [PATCH 02/28] =?UTF-8?q?refactor:=20=EC=BB=A4=EC=8A=A4=ED=85=80?= =?UTF-8?q?=20=ED=9B=85=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useCombinationModals.ts | 119 ++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/hooks/useCombinationModals.ts diff --git a/src/hooks/useCombinationModals.ts b/src/hooks/useCombinationModals.ts new file mode 100644 index 00000000..56bdca89 --- /dev/null +++ b/src/hooks/useCombinationModals.ts @@ -0,0 +1,119 @@ +import { useState, useEffect } from 'react'; + +interface UseCombinationModalsReturn { + // 기기 삭제 모달 + showDeleteModal: boolean; + openDeleteModal: () => void; + closeDeleteModal: () => void; + + // 조합 삭제 모달 + showCombinationDeleteModal: boolean; + deleteTargetComboId: number | null; + openCombinationDeleteModal: (comboId: number) => void; + closeCombinationDeleteModal: () => void; + + // 조합명 저장 모달 + showSaveModal: boolean; + openSaveModal: () => void; + closeSaveModal: () => void; + + // 삭제 완료 팝업 + showDeleteSuccessModal: boolean; + isDeleteFadingOut: boolean; + openDeleteSuccessModal: () => void; + + // 저장 완료 팝업 + showSaveSuccessModal: boolean; + isSaveFadingOut: boolean; + openSaveSuccessModal: () => void; +} + +export const useCombinationModals = (): UseCombinationModalsReturn => { + // 기기 삭제 모달 + const [showDeleteModal, setShowDeleteModal] = useState(false); + + // 조합 삭제 모달 + const [showCombinationDeleteModal, setShowCombinationDeleteModal] = useState(false); + const [deleteTargetComboId, setDeleteTargetComboId] = useState(null); + + // 조합명 저장 모달 + const [showSaveModal, setShowSaveModal] = useState(false); + + // 삭제 완료 팝업 + const [showDeleteSuccessModal, setShowDeleteSuccessModal] = useState(false); + const [isDeleteFadingOut, setIsDeleteFadingOut] = useState(false); + + // 저장 완료 팝업 + const [showSaveSuccessModal, setShowSaveSuccessModal] = useState(false); + const [isSaveFadingOut, setIsSaveFadingOut] = useState(false); + + // 삭제 완료 팝업 자동 닫기 (0.8초 유지 후 0.2초 fade-out) + useEffect(() => { + if (showDeleteSuccessModal) { + const holdTimer = setTimeout(() => { + setIsDeleteFadingOut(true); + + const closeTimer = setTimeout(() => { + setShowDeleteSuccessModal(false); + setIsDeleteFadingOut(false); + }, 200); + + return () => clearTimeout(closeTimer); + }, 800); + + return () => clearTimeout(holdTimer); + } + }, [showDeleteSuccessModal]); + + // 저장 완료 팝업 자동 닫기 (0.8초 유지 후 0.2초 fade-out) + useEffect(() => { + if (showSaveSuccessModal) { + const holdTimer = setTimeout(() => { + setIsSaveFadingOut(true); + + const closeTimer = setTimeout(() => { + setShowSaveSuccessModal(false); + setIsSaveFadingOut(false); + }, 200); + + return () => clearTimeout(closeTimer); + }, 800); + + return () => clearTimeout(holdTimer); + } + }, [showSaveSuccessModal]); + + return { + // 기기 삭제 모달 + showDeleteModal, + openDeleteModal: () => setShowDeleteModal(true), + closeDeleteModal: () => setShowDeleteModal(false), + + // 조합 삭제 모달 + showCombinationDeleteModal, + deleteTargetComboId, + openCombinationDeleteModal: (comboId: number) => { + setDeleteTargetComboId(comboId); + setShowCombinationDeleteModal(true); + }, + closeCombinationDeleteModal: () => { + setShowCombinationDeleteModal(false); + setDeleteTargetComboId(null); + }, + + // 조합명 저장 모달 + showSaveModal, + openSaveModal: () => setShowSaveModal(true), + closeSaveModal: () => setShowSaveModal(false), + + // 삭제 완료 팝업 + showDeleteSuccessModal, + isDeleteFadingOut, + openDeleteSuccessModal: () => setShowDeleteSuccessModal(true), + + // 저장 완료 팝업 + showSaveSuccessModal, + isSaveFadingOut, + openSaveSuccessModal: () => setShowSaveSuccessModal(true), + }; +}; From 9975d9ae2d299f6fb5a6c1b7078ee4c8e7b82376 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:24:26 +0900 Subject: [PATCH 03/28] =?UTF-8?q?refactor:=20=EC=BB=A4=EC=8A=A4=ED=85=80?= =?UTF-8?q?=20=ED=9B=85=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useDeviceSelection.ts | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/hooks/useDeviceSelection.ts diff --git a/src/hooks/useDeviceSelection.ts b/src/hooks/useDeviceSelection.ts new file mode 100644 index 00000000..98e23346 --- /dev/null +++ b/src/hooks/useDeviceSelection.ts @@ -0,0 +1,40 @@ +import { useState } from 'react'; + +interface UseDeviceSelectionReturn { + selectedDevices: number[]; + handleSelectAll: (deviceIds: number[]) => void; + handleSelectDevice: (deviceId: number) => void; + clearSelection: () => void; +} + +export const useDeviceSelection = (): UseDeviceSelectionReturn => { + const [selectedDevices, setSelectedDevices] = useState([]); + + // 전체 선택/해제 핸들러 + const handleSelectAll = (deviceIds: number[]) => { + if (selectedDevices.length === deviceIds.length) { + setSelectedDevices([]); + } else { + setSelectedDevices(deviceIds); + } + }; + + // 개별 선택/해제 핸들러 + const handleSelectDevice = (deviceId: number) => { + setSelectedDevices((prev) => + prev.includes(deviceId) ? prev.filter((id) => id !== deviceId) : [...prev, deviceId] + ); + }; + + // 선택 초기화 + const clearSelection = () => { + setSelectedDevices([]); + }; + + return { + selectedDevices, + handleSelectAll, + handleSelectDevice, + clearSelection, + }; +}; From 289c6fc9717d3824e843f85e375bfcd6f2f226ba Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:25:04 +0900 Subject: [PATCH 04/28] =?UTF-8?q?refactor:=20=EC=A0=95=EB=A0=AC=EB=A1=9C?= =?UTF-8?q?=EC=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useCombinationSort.ts | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/hooks/useCombinationSort.ts diff --git a/src/hooks/useCombinationSort.ts b/src/hooks/useCombinationSort.ts new file mode 100644 index 00000000..a6002b95 --- /dev/null +++ b/src/hooks/useCombinationSort.ts @@ -0,0 +1,49 @@ +import { useState, useMemo } from 'react'; +import type { ComboListItem } from '@/types/combo/combo'; + +type SortOption = 'latest' | 'oldest' | 'alphabetical'; + +interface UseCombinationSortReturn { + sortOption: string; + setSortOption: (option: string) => void; + sortedCombos: ComboListItem[]; +} + +export const useCombinationSort = (combos: ComboListItem[]): UseCombinationSortReturn => { + const [sortOption, setSortOption] = useState('latest'); + + // 정렬된 조합 목록 + const sortedCombos = useMemo(() => { + const pinnedCombos = combos.filter(c => c.isPinned); + const unpinnedCombos = combos.filter(c => !c.isPinned); + + // 즐겨찾기는 pinnedAt 내림차순 (최근 즐겨찾기한 것이 위로) + pinnedCombos.sort((a, b) => { + const aTime = new Date(a.pinnedAt || 0).getTime(); + const bTime = new Date(b.pinnedAt || 0).getTime(); + return bTime - aTime; + }); + + // 일반 조합은 sortOption에 따라 정렬 + const sortUnpinned = (arr: ComboListItem[]) => { + switch (sortOption) { + case 'latest': + return arr.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + case 'oldest': + return arr.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + case 'alphabetical': + return arr.sort((a, b) => a.comboName.localeCompare(b.comboName, 'ko')); + default: + return arr; + } + }; + + return [...pinnedCombos, ...sortUnpinned(unpinnedCombos)]; + }, [combos, sortOption]); + + return { + sortOption, + setSortOption, + sortedCombos, + }; +}; From 00e1b3b7ea9caa7d039ee9acec0c4e418d83df73 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:25:53 +0900 Subject: [PATCH 05/28] =?UTF-8?q?refactor:=20=EC=A1=B0=ED=95=A9=EB=AA=85,?= =?UTF-8?q?=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useCombinationEdit.ts | 106 ++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/hooks/useCombinationEdit.ts diff --git a/src/hooks/useCombinationEdit.ts b/src/hooks/useCombinationEdit.ts new file mode 100644 index 00000000..d275784c --- /dev/null +++ b/src/hooks/useCombinationEdit.ts @@ -0,0 +1,106 @@ +import { useState, useCallback, useMemo } from 'react'; +import type { ComboListItem } from '@/types/combo/combo'; + +interface UseCombinationEditReturn { + editingComboId: number | null; + editingCombinationName: string; + comboNameError: string | null; + isComboNameValid: boolean; + startEditing: (comboId: number, comboName: string) => void; + stopEditing: () => void; + handleComboNameChange: (e: React.ChangeEvent) => void; + validateComboName: (name: string) => string | null; + setComboNameError: (error: string | null) => void; +} + +export const useCombinationEdit = (combos: ComboListItem[]): UseCombinationEditReturn => { + const [editingComboId, setEditingComboId] = useState(null); + const [editingCombinationName, setEditingCombinationName] = useState(''); + const [comboNameError, setComboNameError] = useState(null); + + // 조합명 유효성 검사 함수 + const validateComboName = useCallback( + (name: string): string | null => { + // 1. 빈 값 체크 + if (name.length === 0) { + return '조합명을 입력해주세요.'; + } + + // 2. 공백만 입력 체크 + if (name.trim().length === 0) { + return '조합명을 한 글자 이상 입력해주세요.'; + } + + // 3. 최대 길이 체크 (20자) + if (name.length > 20) { + return '조합명은 최대 20자까지 입력 가능합니다.'; + } + + // 4. 중복 체크 (현재 수정 중인 조합 제외, trim 후 대소문자 구분 없이 비교) + if (editingComboId !== null) { + const isDuplicate = combos.some( + (c) => + c.comboId !== editingComboId && + c.comboName.trim().toLowerCase() === name.trim().toLowerCase() + ); + if (isDuplicate) { + return '이미 존재하는 조합명입니다. 다른 이름을 시도해주세요.'; + } + } + + return null; // 유효함 + }, + [combos, editingComboId] + ); + + // 조합명이 유효한지 여부 + const isComboNameValid = useMemo(() => { + return validateComboName(editingCombinationName) === null; + }, [editingCombinationName, validateComboName]); + + // 조합명 입력 핸들러 (길이 제한 + 실시간 검사) + const handleComboNameChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + + // 최대 길이 20자로 제한 (입력 자체를 막음) + if (newValue.length > 20) { + return; + } + + setEditingCombinationName(newValue); + + // 실시간 검사 (입력 중에는 빈 값/공백만 에러는 표시하지 않음) + if (newValue.length > 0 && newValue.trim().length > 0) { + const error = validateComboName(newValue); + setComboNameError(error); + } else { + setComboNameError(null); + } + }; + + // 편집 시작 + const startEditing = (comboId: number, comboName: string) => { + setEditingComboId(comboId); + setEditingCombinationName(comboName); + setComboNameError(null); + }; + + // 편집 종료 + const stopEditing = () => { + setEditingComboId(null); + setEditingCombinationName(''); + setComboNameError(null); + }; + + return { + editingComboId, + editingCombinationName, + comboNameError, + isComboNameValid, + startEditing, + stopEditing, + handleComboNameChange, + validateComboName, + setComboNameError, + }; +}; From 06ee11d2985f71fff614dcef1b1a4813b3abc154 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:26:53 +0900 Subject: [PATCH 06/28] =?UTF-8?q?refactor:=20=EA=B8=B0=EA=B8=B0=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MyPage/DeviceDeleteModal.tsx | 53 +++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/components/MyPage/DeviceDeleteModal.tsx diff --git a/src/components/MyPage/DeviceDeleteModal.tsx b/src/components/MyPage/DeviceDeleteModal.tsx new file mode 100644 index 00000000..e362bf81 --- /dev/null +++ b/src/components/MyPage/DeviceDeleteModal.tsx @@ -0,0 +1,53 @@ +import RemoveIcon from '@/assets/icons/remove.svg?react'; + +interface DeviceDeleteModalProps { + isDeleting: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +const DeviceDeleteModal = ({ isDeleting, onConfirm, onCancel }: DeviceDeleteModalProps) => { + return ( + <> + {/* 배경 오버레이 */} +
+ + {/* 모달 */} +
+
e.stopPropagation()} + > + {/* 아이콘 */} + + + {/* 텍스트 */} +

선택한 기기들을 삭제하시겠습니까?

+ + {/* 버튼 그룹 */} +
+ + +
+
+
+ + ); +}; + +export default DeviceDeleteModal; From 881df001f5a6fabeb1c43b8ce913efe664dfa061 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:27:31 +0900 Subject: [PATCH 07/28] =?UTF-8?q?refactor:=20=EC=A1=B0=ED=95=A9=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyPage/CombinationDeleteModal.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/components/MyPage/CombinationDeleteModal.tsx diff --git a/src/components/MyPage/CombinationDeleteModal.tsx b/src/components/MyPage/CombinationDeleteModal.tsx new file mode 100644 index 00000000..a229938c --- /dev/null +++ b/src/components/MyPage/CombinationDeleteModal.tsx @@ -0,0 +1,61 @@ +import RemoveIcon from '@/assets/icons/remove.svg?react'; + +interface CombinationDeleteModalProps { + comboName: string; + isDeleting: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +const CombinationDeleteModal = ({ + comboName, + isDeleting, + onConfirm, + onCancel, +}: CombinationDeleteModalProps) => { + return ( + <> + {/* 배경 오버레이 */} +
+ + {/* 모달 */} +
+
e.stopPropagation()} + > + {/* 아이콘 */} + + + {/* 텍스트 */} +

+ '{comboName}'을 삭제하시겠습니까? +

+ + {/* 버튼 그룹 */} +
+ + +
+
+
+ + ); +}; + +export default CombinationDeleteModal; From d5bd2a3b272e0e39e495f68aa6ee5c008b89ec41 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:28:11 +0900 Subject: [PATCH 08/28] =?UTF-8?q?refactor:=20=EC=A1=B0=ED=95=A9=EB=AA=85?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=ED=99=95=EC=9D=B8=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MyPage/SaveNameModal.tsx | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/components/MyPage/SaveNameModal.tsx diff --git a/src/components/MyPage/SaveNameModal.tsx b/src/components/MyPage/SaveNameModal.tsx new file mode 100644 index 00000000..f02c1b0a --- /dev/null +++ b/src/components/MyPage/SaveNameModal.tsx @@ -0,0 +1,52 @@ +import SaveIcon from '@/assets/icons/save.svg?react'; + +interface SaveNameModalProps { + isSaving: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +const SaveNameModal = ({ isSaving, onConfirm, onCancel }: SaveNameModalProps) => { + return ( + <> + {/* 배경 오버레이 */} +
+ + {/* 모달 */} +
+
e.stopPropagation()} + > + {/* 아이콘 */} + + + {/* 텍스트 */} +

조합명을 저장하시겠습니까?

+ + {/* 버튼 그룹 */} +
+ + +
+
+
+ + ); +}; + +export default SaveNameModal; From c5c8e97ac5b3bb08b3ec3f4e2067d4d06fb2cd4d Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:29:04 +0900 Subject: [PATCH 09/28] =?UTF-8?q?refactor:=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=ED=8C=9D=EC=97=85=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MyPage/DeleteCompleteModal.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/components/MyPage/DeleteCompleteModal.tsx diff --git a/src/components/MyPage/DeleteCompleteModal.tsx b/src/components/MyPage/DeleteCompleteModal.tsx new file mode 100644 index 00000000..69cac8bd --- /dev/null +++ b/src/components/MyPage/DeleteCompleteModal.tsx @@ -0,0 +1,31 @@ +import RemoveIcon from '@/assets/icons/remove.svg?react'; + +interface DeleteCompleteModalProps { + isFadingOut: boolean; +} + +const DeleteCompleteModal = ({ isFadingOut }: DeleteCompleteModalProps) => { + return ( + <> + {/* 배경 오버레이 */} +
+ + {/* 팝업 */} +
+
+ {/* 아이콘 */} + + + {/* 텍스트 */} +

삭제 완료

+
+
+ + ); +}; + +export default DeleteCompleteModal; From bd35390c9e7e116b3ea5abac61631724621fc64c Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:30:09 +0900 Subject: [PATCH 10/28] =?UTF-8?q?refactor:=20mypage=20sidebar=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MyPage/MyPageSidebar.tsx | 88 +++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/components/MyPage/MyPageSidebar.tsx diff --git a/src/components/MyPage/MyPageSidebar.tsx b/src/components/MyPage/MyPageSidebar.tsx new file mode 100644 index 00000000..90e5e716 --- /dev/null +++ b/src/components/MyPage/MyPageSidebar.tsx @@ -0,0 +1,88 @@ +import { type RefObject } from 'react'; +import { useNavigate } from 'react-router-dom'; +import RoundedLifestyleTag from '@/components/Lifestyle/RoundedLifestyleTag'; +import RecentlyViewedFloating from '@/components/RecentlyViewed/RecentlyViewedFloating'; +import SettingIcon from '@/assets/icons/setting.svg?react'; +import SupportIcon from '@/assets/icons/support.svg?react'; +import Logo from '@/assets/logos/logo.svg?react'; +import { formatDate } from '@/utils/format'; +import type { UserProfile } from '@/types/mypage/user'; + +interface MyPageSidebarProps { + userProfile: UserProfile | null; + isAuthLoading: boolean; + sidebarContentRef: RefObject; +} + +const MyPageSidebar = ({ userProfile, isAuthLoading, sidebarContentRef }: MyPageSidebarProps) => { + const navigate = useNavigate(); + + return ( + + ); +}; + +export default MyPageSidebar; From 277e85590657feeec3de2bc3cf8a42077c8c31cd Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:31:08 +0900 Subject: [PATCH 11/28] =?UTF-8?q?refactor:=20=EB=93=9C=EB=9E=8D=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EB=A9=94=EB=89=B4=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MyPage/CombinationMenu.tsx | 68 +++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/components/MyPage/CombinationMenu.tsx diff --git a/src/components/MyPage/CombinationMenu.tsx b/src/components/MyPage/CombinationMenu.tsx new file mode 100644 index 00000000..c04e9a04 --- /dev/null +++ b/src/components/MyPage/CombinationMenu.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; + +interface CombinationMenuProps { + hasDevices: boolean; + onDelete: () => void; + onRename: () => void; + onDetail?: () => void; +} + +const CombinationMenu = ({ hasDevices, onDelete, onRename, onDetail }: CombinationMenuProps) => { + const [hoveredMenuItem, setHoveredMenuItem] = useState(null); + + return ( +
+ {/* 삭제하기 */} + + + {/* 조합명 수정하기 */} + + + {/* 자세히보기 - 기기가 있을 때만 표시 */} + {hasDevices && onDetail && ( + + )} +
+ ); +}; + +export default CombinationMenu; From 921cc169bffe303af18a7debbe221e1a7123d4ec Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:31:55 +0900 Subject: [PATCH 12/28] =?UTF-8?q?refactor:=20=EB=B9=88=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyPage/EmptyCombinationCard.tsx | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/components/MyPage/EmptyCombinationCard.tsx diff --git a/src/components/MyPage/EmptyCombinationCard.tsx b/src/components/MyPage/EmptyCombinationCard.tsx new file mode 100644 index 00000000..78aa6019 --- /dev/null +++ b/src/components/MyPage/EmptyCombinationCard.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import StarIcon from '@/assets/icons/star.svg?react'; +import StarXIcon from '@/assets/icons/starx.svg?react'; +import StarHoverIcon from '@/assets/icons/starhover.svg?react'; +import PlusIcon from '@/assets/icons/plus.svg?react'; +import { formatDate } from '@/utils/format'; + +interface EmptyCombinationCardProps { + comboId: number; + comboName: string; + createdAt: string; + isPinned: boolean; + index: number; + onTogglePin: (e: React.MouseEvent, comboId: number) => void; +} + +const EmptyCombinationCard = ({ + comboId, + comboName, + createdAt, + isPinned, + index, + onTogglePin, +}: EmptyCombinationCardProps) => { + const navigate = useNavigate(); + const [hoveredStarComboId, setHoveredStarComboId] = useState(null); + + return ( +
+ {/* 조합 정보 (생성일 포함) */} +
+ {/* 조합 번호 + 생성일 + 조합명 */} +
+
+

조합{index + 1}

+

생성일: {formatDate(createdAt)}

+
+
+

{comboName}

+ {isPinned ? ( + onTogglePin(e, comboId)} + className={`!w-22 !h-22 -mt-3 cursor-pointer transition-opacity ${ + hoveredStarComboId === comboId ? 'opacity-80' : '' + }`} + onMouseEnter={() => setHoveredStarComboId(comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + <> + {hoveredStarComboId === comboId ? ( + onTogglePin(e, comboId)} + className="!w-22 !h-22 -mt-3 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + onTogglePin(e, comboId)} + className="!w-22 !h-22 -mt-3 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + )} + + )} +
+
+
+ + {/* 빈 조합: 기기 추가 버튼 */} +
+ +
+
+ ); +}; + +export default EmptyCombinationCard; From c01fba09d48fe376ebbec894bce68f46742b1449 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:33:19 +0900 Subject: [PATCH 13/28] =?UTF-8?q?refactor:=20=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MyPage/CombinationCard.tsx | 171 ++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 src/components/MyPage/CombinationCard.tsx diff --git a/src/components/MyPage/CombinationCard.tsx b/src/components/MyPage/CombinationCard.tsx new file mode 100644 index 00000000..0a54af0e --- /dev/null +++ b/src/components/MyPage/CombinationCard.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react'; +import StarIcon from '@/assets/icons/star.svg?react'; +import StarXIcon from '@/assets/icons/starx.svg?react'; +import StarHoverIcon from '@/assets/icons/starhover.svg?react'; +import { formatDate } from '@/utils/format'; +import type { ComboListItem } from '@/types/combo/combo'; + +interface CombinationCardProps { + combination: ComboListItem; + index: number; + columns: 3 | 4; + isEditing: boolean; + editingName: string; + nameError: string | null; + onTogglePin: (e: React.MouseEvent, comboId: number) => void; + onNameChange: (e: React.ChangeEvent) => void; + onNameBlur: (name: string) => void; +} + +const CombinationCard = ({ + combination, + index, + columns, + isEditing, + editingName, + nameError, + onTogglePin, + onNameChange, + onNameBlur, +}: CombinationCardProps) => { + const [hoveredStarComboId, setHoveredStarComboId] = useState(null); + + // 그라데이션 로직 + const gradientThreshold = columns === 4 ? 9 : 7; + const shouldShowGradient = combination.devices.length >= gradientThreshold; + const maxDisplay = columns === 4 ? 8 : 6; + const displayedDevices = shouldShowGradient + ? combination.devices.slice(0, maxDisplay) + : combination.devices; + + return ( +
+ {/* 조합 정보 (생성일 포함) */} +
+ {isEditing ? ( + /* 수정 모드: 인풋박스 + 별 아이콘 + 에러 메시지 */ +
+
+ e.stopPropagation()} + onBlur={() => onNameBlur(editingName)} + maxLength={20} + className={`h-52 px-12 rounded-button font-body-1-sm text-gray-300 focus:outline-none ${ + nameError ? 'border-2 border-warning' : 'border border-blue-600' + }`} + autoFocus + /> + {combination.isPinned ? ( + onTogglePin(e, combination.comboId)} + className={`!w-22 !h-22 -mt-2 cursor-pointer transition-opacity ${ + hoveredStarComboId === combination.comboId ? 'opacity-80' : '' + }`} + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + <> + {hoveredStarComboId === combination.comboId ? ( + onTogglePin(e, combination.comboId)} + className="!w-22 !h-22 -mt-2 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + onTogglePin(e, combination.comboId)} + className="!w-22 !h-22 -mt-2 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + )} + + )} +
+ {nameError &&

{nameError}

} +
+ ) : ( + /* 일반 모드: 조합 번호 + 생성일 + 조합명 */ +
+
+

조합{index + 1}

+

+ 생성일: {formatDate(combination.createdAt)} +

+
+
+

{combination.comboName}

+ {combination.isPinned ? ( + onTogglePin(e, combination.comboId)} + className={`!w-22 !h-22 -mt-3 cursor-pointer transition-opacity ${ + hoveredStarComboId === combination.comboId ? 'opacity-80' : '' + }`} + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + <> + {hoveredStarComboId === combination.comboId ? ( + onTogglePin(e, combination.comboId)} + className="!w-22 !h-22 -mt-3 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + onTogglePin(e, combination.comboId)} + className="!w-22 !h-22 -mt-3 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + )} + + )} +
+
+ )} +
+ + {/* 기기 그리드 - 일반 모드에서도 기기 카드 표시 (그라데이션 포함) */} +
+
+ {displayedDevices.map((device) => ( +
+
+
+

{device.name}

+

{device.brandName}

+

{device.deviceType}

+
+
+ ))} +
+ + {/* 그라데이션 오버레이 */} + {shouldShowGradient && ( +
+ )} +
+
+ ); +}; + +export default CombinationCard; From 906a5dc3a85be72d94db9991fbdc73633db459ef Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:35:25 +0900 Subject: [PATCH 14/28] =?UTF-8?q?refactor:=20=EC=83=81=EC=84=B8=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyPage/CombinationDetailView.tsx | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 src/components/MyPage/CombinationDetailView.tsx diff --git a/src/components/MyPage/CombinationDetailView.tsx b/src/components/MyPage/CombinationDetailView.tsx new file mode 100644 index 00000000..8047fdd3 --- /dev/null +++ b/src/components/MyPage/CombinationDetailView.tsx @@ -0,0 +1,244 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import BackIcon from '@/assets/icons/back.svg?react'; +import StarIcon from '@/assets/icons/star.svg?react'; +import StarXIcon from '@/assets/icons/starx.svg?react'; +import StarHoverIcon from '@/assets/icons/starhover.svg?react'; +import CheckboxIcon from '@/assets/icons/checkbox.svg?react'; +import CheckboxOnIcon from '@/assets/icons/checkbox_on.svg?react'; +import TrashIcon from '@/assets/icons/trash.svg?react'; +import PlusIcon from '@/assets/icons/plus.svg?react'; +import CombinationEvaluationCard from '@/components/Combination/CombinationEvaluationCard'; +import { formatDate } from '@/utils/format'; +import type { ComboListItem } from '@/types/combo/combo'; +import type { CombinationName } from '@/constants/combination'; + +interface Device { + deviceId: number; + name: string; + brandName?: string; + deviceType?: string; +} + +interface EvaluationCard { + category: string; + grade: string; + text: string; + tags: string[]; +} + +interface CombinationDetailViewProps { + combination: ComboListItem; + index: number; + devices: Device[]; + deviceIds: number[]; + columns: 3 | 4; + selectedDevices: number[]; + totalPrice: number; + evaluationCards: EvaluationCard[] | null; + isEvaluationLoading: boolean; + onBack: () => void; + onTogglePin: (e: React.MouseEvent, comboId: number) => void; + onSelectAll: (deviceIds: number[]) => void; + onSelectDevice: (deviceId: number) => void; + onTrashClick: () => void; +} + +const CombinationDetailView = ({ + combination, + index, + devices, + deviceIds, + columns, + selectedDevices, + totalPrice, + evaluationCards, + isEvaluationLoading, + onBack, + onTogglePin, + onSelectAll, + onSelectDevice, + onTrashClick, +}: CombinationDetailViewProps) => { + const navigate = useNavigate(); + const [hoveredStarComboId, setHoveredStarComboId] = useState(null); + + return ( +
e.stopPropagation()}> + {/* 뒤로가기 버튼 */} +
+ +
+ + {/* 조합 정보 */} +
+
+
+

조합{index + 1}

+

+ 생성일: {formatDate(combination.createdAt)} +

+
+
+

{combination.comboName}

+ {combination.isPinned ? ( + onTogglePin(e, combination.comboId)} + className={`!w-22 !h-22 -mt-2 cursor-pointer transition-opacity ${ + hoveredStarComboId === combination.comboId ? 'opacity-80' : '' + }`} + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + <> + {hoveredStarComboId === combination.comboId ? ( + onTogglePin(e, combination.comboId)} + className="!w-22 !h-22 -mt-2 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + ) : ( + onTogglePin(e, combination.comboId)} + className="!w-22 !h-22 -mt-2 cursor-pointer" + onMouseEnter={() => setHoveredStarComboId(combination.comboId)} + onMouseLeave={() => setHoveredStarComboId(null)} + /> + )} + + )} +
+
+
+ + {/* 전체 선택 + 휴지통 */} +
+
+
+ +

전체 선택하기

+
+ +
+
+ + {/* 안내 텍스트 */} +

+ *마우스를 기기 위에 올려서 기기 상세정보를 확인하실 수도 있습니다. +

+ + {/* 기기 그리드 (체크박스 포함) */} +
+
+ {devices.map((device) => ( +
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'}`} + > +
+ {/* 호버 오버레이 */} +
+ + 보기 + +
+
+
+
+

{device.name}

+ {/* 체크박스 - 기기명과 같은 높이 */} + +
+

{device.brandName || '-'}

+

{device.deviceType || '-'}

+
+
+ ))} + {/* 기기 추가 버튼 */} + +
+
+ + {/* 총 가격 */} +
+
+

총 가격

+
+

+

{totalPrice.toLocaleString()}

+
+
+
+ + {/* 구분선 + 조합 평가 정보 (로딩 중에는 숨김) */} + {!isEvaluationLoading && ( + <> +
+ +
+
+

조합평가 전문보기

+
+ +
+ {evaluationCards ? ( + evaluationCards.map((card) => ( + + )) + ) : ( +
+

+ 조합 평가 정보가 아직 준비되지 않았습니다. +

+
+ )} +
+
+ + )} +
+ ); +}; + +export default CombinationDetailView; From b547819c83c1865ddaeee14323e81196a59bff5a Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:43:39 +0900 Subject: [PATCH 15/28] refactor: mypage.tsx --- src/pages/my/MyPage.tsx | 1201 ++++++--------------------------------- 1 file changed, 172 insertions(+), 1029 deletions(-) diff --git a/src/pages/my/MyPage.tsx b/src/pages/my/MyPage.tsx index b1e3a079..bcf723f8 100644 --- a/src/pages/my/MyPage.tsx +++ b/src/pages/my/MyPage.tsx @@ -1,28 +1,22 @@ -import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import GNB from '@/components/Home/GNB'; import PrimaryButton from '@/components/Button/PrimaryButton'; import SecondaryButton from '@/components/Button/SecondaryButton'; import SortDropdown from '@/components/Filter/SortDropdown'; -import RoundedLifestyleTag from '@/components/Lifestyle/RoundedLifestyleTag'; -import RecentlyViewedFloating from '@/components/RecentlyViewed/RecentlyViewedFloating'; -import CombinationEvaluationCard from '@/components/Combination/CombinationEvaluationCard'; -import SettingIcon from '@/assets/icons/setting.svg?react'; -import SupportIcon from '@/assets/icons/support.svg?react'; +import MyPageSidebar from '@/components/MyPage/MyPageSidebar'; +import CombinationMenu from '@/components/MyPage/CombinationMenu'; +import CombinationCard from '@/components/MyPage/CombinationCard'; +import EmptyCombinationCard from '@/components/MyPage/EmptyCombinationCard'; +import CombinationDetailView from '@/components/MyPage/CombinationDetailView'; +import DeviceDeleteModal from '@/components/MyPage/DeviceDeleteModal'; +import CombinationDeleteModal from '@/components/MyPage/CombinationDeleteModal'; +import SaveNameModal from '@/components/MyPage/SaveNameModal'; +import DeleteCompleteModal from '@/components/MyPage/DeleteCompleteModal'; +import SaveCompleteModal from '@/components/DeviceSearch/SaveCompleteModal'; import SettingMoreIcon from '@/assets/icons/settingmore.svg?react'; import AlarmIcon from '@/assets/icons/alarm.svg?react'; -import StarIcon from '@/assets/icons/star.svg?react'; -import StarXIcon from '@/assets/icons/starx.svg?react'; -import StarHoverIcon from '@/assets/icons/starhover.svg?react'; -import PlusIcon from '@/assets/icons/plus.svg?react'; -import BackIcon from '@/assets/icons/back.svg?react'; -import CheckboxIcon from '@/assets/icons/checkbox.svg?react'; -import CheckboxOnIcon from '@/assets/icons/checkbox_on.svg?react'; -import TrashIcon from '@/assets/icons/trash.svg?react'; -import RemoveIcon from '@/assets/icons/remove.svg?react'; -import SaveIcon from '@/assets/icons/save.svg?react'; import TopIcon from '@/assets/icons/top.svg?react'; -import Logo from '@/assets/logos/logo.svg?react'; import { useGetCombos } from '@/apis/combo/getCombos'; import { useGetCombo } from '@/apis/combo/getComboId'; import { usePutCombo } from '@/apis/combo/putCombos'; @@ -30,50 +24,26 @@ import { useDeleteCombo } from '@/apis/combo/deleteCombo'; import { usePostComboPin } from '@/apis/combo/postComboPin'; import { useDeleteComboDevice } from '@/apis/combo/deleteComboDevice'; import { useComboEvaluation } from '@/apis/combo/getComboEvaluation'; -import type { ComboListItem } from '@/types/combo/combo'; import { useAuth } from '@/hooks/useAuth'; +import { useCombinationModals } from '@/hooks/useCombinationModals'; +import { useDeviceSelection } from '@/hooks/useDeviceSelection'; +import { useCombinationSort } from '@/hooks/useCombinationSort'; +import { useCombinationEdit } from '@/hooks/useCombinationEdit'; import { mapEvaluationToUI } from '@/utils/mapEvaluationToUI'; +import { MYPAGE_SORT_OPTIONS } from '@/constants/combination'; import type { LifestyleKey } from '@/constants/evaluation/lifestyle'; -import type { CombinationName } from '@/constants/combination'; - -const MYPAGE_SORT_OPTIONS = [ - { value: 'latest', label: '최근생성순' }, - { value: 'oldest', label: '오래된순' }, - { value: 'alphabetical', label: '가나다순' }, -]; - -// 날짜 포맷 함수 (ISO -> YYYY.MM.DD) -const formatDate = (isoDate: string): string => { - const date = new Date(isoDate); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}.${month}.${day}`; -}; const MyPage = () => { const navigate = useNavigate(); - const [sortOption, setSortOption] = useState('latest'); + + // 상태 관리 const [isAtBottom, setIsAtBottom] = useState(false); const [columns, setColumns] = useState<3 | 4>(4); const [openMenuIndex, setOpenMenuIndex] = useState(null); - const [hoveredMenuItem, setHoveredMenuItem] = useState(null); const [detailViewComboId, setDetailViewComboId] = useState(null); - const [selectedDevices, setSelectedDevices] = useState([]); const [savedScrollPosition, setSavedScrollPosition] = useState(0); - const [showDeleteModal, setShowDeleteModal] = useState(false); - const [showCombinationDeleteModal, setShowCombinationDeleteModal] = useState(false); - const [deleteTargetComboId, setDeleteTargetComboId] = useState(null); - const [editingComboId, setEditingComboId] = useState(null); - const [editingCombinationName, setEditingCombinationName] = useState(''); - const [comboNameError, setComboNameError] = useState(null); - const [showSaveModal, setShowSaveModal] = useState(false); - const [showDeleteSuccessModal, setShowDeleteSuccessModal] = useState(false); - const [showSaveSuccessModal, setShowSaveSuccessModal] = useState(false); - const [isDeleteFadingOut, setIsDeleteFadingOut] = useState(false); - const [isSaveFadingOut, setIsSaveFadingOut] = useState(false); const [showTopButton, setShowTopButton] = useState(false); - const [hoveredStarComboId, setHoveredStarComboId] = useState(null); + const menuRef = useRef(null); const sidebarContentRef = useRef(null); const combinationListRef = useRef(null); @@ -86,50 +56,29 @@ const MyPage = () => { const { mutate: togglePin } = usePostComboPin(); const { mutate: deleteDevice, isPending: isDeletingDevice } = useDeleteComboDevice(); const { user: userProfile, isAuthLoading } = useAuth(); - const { data: evaluation, isLoading: isEvaluationLoading } = useComboEvaluation(detailViewComboId ?? undefined); + const { data: evaluation, isLoading: isEvaluationLoading } = useComboEvaluation( + detailViewComboId ?? undefined + ); + + // 커스텀 훅 + const modals = useCombinationModals(); + const deviceSelection = useDeviceSelection(); + const { sortOption, setSortOption, sortedCombos } = useCombinationSort(combos); + const combinationEdit = useCombinationEdit(combos); - // 유저 라이프스타일 태그 → LifestyleKey 변환 ("# Office" → "Office") + // 유저 라이프스타일 태그 변환 const lifestyleKey = useMemo(() => { const raw = userProfile?.lifestyleList?.[0]; if (!raw) return undefined; return raw.replace(/^#\s*/, '') as LifestyleKey; }, [userProfile]); - // 평가 데이터 → UI 카드 props 변환 + // 평가 데이터 변환 const evaluationCards = useMemo(() => { if (!evaluation) return null; return mapEvaluationToUI(evaluation, lifestyleKey); }, [evaluation, lifestyleKey]); - // 정렬된 조합 목록 - const sortedCombos = useMemo(() => { - const pinnedCombos = combos.filter(c => c.isPinned); - const unpinnedCombos = combos.filter(c => !c.isPinned); - - // 즐겨찾기는 pinnedAt 내림차순 (최근 즐겨찾기한 것이 위로) - pinnedCombos.sort((a, b) => { - const aTime = new Date(a.pinnedAt || 0).getTime(); - const bTime = new Date(b.pinnedAt || 0).getTime(); - return bTime - aTime; - }); - - // 일반 조합은 sortOption에 따라 정렬 - const sortUnpinned = (arr: ComboListItem[]) => { - switch (sortOption) { - case 'latest': - return arr.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - case 'oldest': - return arr.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); - case 'alphabetical': - return arr.sort((a, b) => a.comboName.localeCompare(b.comboName, 'ko')); - default: - return arr; - } - }; - - return [...pinnedCombos, ...sortUnpinned(unpinnedCombos)]; - }, [combos, sortOption]); - // 스크롤 감지 (하단 그라데이션용 + Top 버튼용) useEffect(() => { const handleScroll = () => { @@ -138,7 +87,7 @@ const MyPage = () => { const documentHeight = document.documentElement.scrollHeight; setIsAtBottom(scrollTop + windowHeight >= documentHeight - 50); - // 조합 3개 정도 스크롤 시 Top 버튼 표시 (약 800px) + // 조합 3개 정도 스크롤 시 Top 버튼 표시 if (combinationListRef.current) { const listTop = combinationListRef.current.offsetTop; const thirdCombinationVisible = scrollTop + windowHeight >= listTop + 800; @@ -166,41 +115,38 @@ const MyPage = () => { const handleClickOutside = (event: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { setOpenMenuIndex(null); - setHoveredMenuItem(null); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); - // 모달 열릴 때 배경 스크롤 방지 (position: fixed 방식) + // 모달 열릴 때 배경 스크롤 방지 useEffect(() => { - const isAnyModalOpen = showDeleteModal || showCombinationDeleteModal || showSaveModal || showDeleteSuccessModal || showSaveSuccessModal; + const isAnyModalOpen = + modals.showDeleteModal || + modals.showCombinationDeleteModal || + modals.showSaveModal || + modals.showDeleteSuccessModal || + modals.showSaveSuccessModal; if (isAnyModalOpen) { - // 현재 스크롤 위치 저장 const scrollY = window.scrollY; - - // body 고정 (스크롤 방지) document.body.style.position = 'fixed'; document.body.style.top = `-${scrollY}px`; document.body.style.width = '100%'; document.body.style.overflow = 'hidden'; } else { - // 저장된 스크롤 위치 복원 const scrollY = document.body.style.top; document.body.style.position = ''; document.body.style.top = ''; document.body.style.width = ''; document.body.style.overflow = ''; - - // 스크롤 위치로 이동 if (scrollY) { window.scrollTo(0, parseInt(scrollY || '0') * -1); } } - // cleanup: 컴포넌트 언마운트 시 원래대로 복구 return () => { const scrollY = document.body.style.top; document.body.style.position = ''; @@ -211,202 +157,80 @@ const MyPage = () => { window.scrollTo(0, parseInt(scrollY || '0') * -1); } }; - }, [showDeleteModal, showCombinationDeleteModal, showSaveModal, showDeleteSuccessModal, showSaveSuccessModal]); - - // 삭제 완료 팝업 자동 닫기 (DeviceSearchPage와 동일한 애니메이션) - useEffect(() => { - if (showDeleteSuccessModal) { - // 1. 0.8초 유지 - const holdTimer = setTimeout(() => { - setIsDeleteFadingOut(true); - - // 2. 0.2초 동안 fade-out 후 종료 - const closeTimer = setTimeout(() => { - setShowDeleteSuccessModal(false); - setIsDeleteFadingOut(false); - }, 200); - - return () => clearTimeout(closeTimer); - }, 800); - - return () => clearTimeout(holdTimer); - } - }, [showDeleteSuccessModal]); - - // 저장 완료 팝업 자동 닫기 (DeviceSearchPage와 동일한 애니메이션) - useEffect(() => { - if (showSaveSuccessModal) { - // 1. 0.8초 유지 - const holdTimer = setTimeout(() => { - setIsSaveFadingOut(true); - - // 2. 0.2초 동안 fade-out 후 종료 - const closeTimer = setTimeout(() => { - setShowSaveSuccessModal(false); - setIsSaveFadingOut(false); - }, 200); - - return () => clearTimeout(closeTimer); - }, 800); - - return () => clearTimeout(holdTimer); - } - }, [showSaveSuccessModal]); - - // 조합명 유효성 검사 함수 - const validateComboName = useCallback((name: string): string | null => { - // 1. 빈 값 체크 - if (name.length === 0) { - return '조합명을 입력해주세요.'; - } - - // 2. 공백만 입력 체크 - if (name.trim().length === 0) { - return '조합명을 한 글자 이상 입력해주세요.'; - } - - // 3. 최대 길이 체크 (20자) - if (name.length > 20) { - return '조합명은 최대 20자까지 입력 가능합니다.'; - } - - // 4. 중복 체크 (현재 수정 중인 조합 제외, trim 후 대소문자 구분 없이 비교) - if (editingComboId !== null) { - const isDuplicate = combos.some( - c => c.comboId !== editingComboId && - c.comboName.trim().toLowerCase() === name.trim().toLowerCase() - ); - if (isDuplicate) { - return '이미 존재하는 조합명입니다. 다른 이름을 시도해주세요.'; - } - } - - return null; // 유효함 - }, [combos, editingComboId]); - - // 조합명이 유효한지 여부 - const isComboNameValid = useMemo(() => { - return validateComboName(editingCombinationName) === null; - }, [editingCombinationName, validateComboName]); - - // 조합명 입력 핸들러 (길이 제한 + 실시간 검사) - const handleComboNameChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - - // 최대 길이 20자로 제한 (입력 자체를 막음) - if (newValue.length > 20) { - return; - } - - setEditingCombinationName(newValue); - - // 실시간 검사 (입력 중에는 빈 값/공백만 에러는 표시하지 않음) - if (newValue.length > 0 && newValue.trim().length > 0) { - const error = validateComboName(newValue); - setComboNameError(error); - } else { - setComboNameError(null); - } - }; + }, [ + modals.showDeleteModal, + modals.showCombinationDeleteModal, + modals.showSaveModal, + modals.showDeleteSuccessModal, + modals.showSaveSuccessModal, + ]); // 자세히보기 클릭 핸들러 const handleDetailView = (comboId: number) => { setSavedScrollPosition(window.scrollY); setDetailViewComboId(comboId); setOpenMenuIndex(null); - setSelectedDevices([]); + deviceSelection.clearSelection(); window.scrollTo(0, 0); }; // 뒤로가기 핸들러 const handleBackToNormal = () => { setDetailViewComboId(null); - setSelectedDevices([]); + deviceSelection.clearSelection(); window.scrollTo(0, savedScrollPosition); }; - // 전체 선택 핸들러 - const handleSelectAll = (deviceIds: number[]) => { - if (selectedDevices.length === deviceIds.length) { - setSelectedDevices([]); - } else { - setSelectedDevices(deviceIds); - } - }; - - // 개별 선택 핸들러 - const handleSelectDevice = (deviceId: number) => { - setSelectedDevices((prev) => - prev.includes(deviceId) ? prev.filter((id) => id !== deviceId) : [...prev, deviceId] - ); - }; - // 선택된 기기 삭제 핸들러 const handleDeleteDevices = async () => { - if (!detailViewComboId || selectedDevices.length === 0) return; + if (!detailViewComboId || deviceSelection.selectedDevices.length === 0) return; try { - // 선택된 모든 기기를 순차적으로 삭제 - for (const deviceId of selectedDevices) { + for (const deviceId of deviceSelection.selectedDevices) { await new Promise((resolve, reject) => { deleteDevice( { comboId: detailViewComboId, deviceId }, { - onSuccess: () => { - console.log(`기기 ${deviceId} 삭제 성공`); - resolve(); - }, - onError: (error) => { - console.error(`기기 ${deviceId} 삭제 실패:`, error); - reject(error); - }, + onSuccess: () => resolve(), + onError: (error) => reject(error), } ); }); } - // 모든 삭제 완료 후 - setSelectedDevices([]); - setShowDeleteModal(false); + deviceSelection.clearSelection(); + modals.closeDeleteModal(); - // 성공 팝업 표시 (0.3초 delay) setTimeout(() => { - setShowDeleteSuccessModal(true); + modals.openDeleteSuccessModal(); }, 300); - - console.log('모든 기기 삭제 완료'); } catch (error) { console.error('기기 삭제 중 오류 발생:', error); - // 에러가 발생해도 모달은 닫지 않고 사용자에게 재시도 기회 제공 } }; // 휴지통 클릭 핸들러 const handleTrashClick = () => { - if (selectedDevices.length > 0) { - setShowDeleteModal(true); + if (deviceSelection.selectedDevices.length > 0) { + modals.openDeleteModal(); } }; // 조합 삭제 핸들러 const handleDeleteCombination = () => { - if (deleteTargetComboId === null) return; + if (modals.deleteTargetComboId === null) return; - deleteCombo(deleteTargetComboId, { + deleteCombo(modals.deleteTargetComboId, { onSuccess: () => { - console.log('조합 삭제 성공'); - setShowCombinationDeleteModal(false); - setDeleteTargetComboId(null); + modals.closeCombinationDeleteModal(); - // 자세히보기 모드였다면 일반 모드로 복귀 if (detailViewComboId !== null) { setDetailViewComboId(null); - setSelectedDevices([]); + deviceSelection.clearSelection(); } - // 성공 팝업 표시 (0.3초 delay) setTimeout(() => { - setShowDeleteSuccessModal(true); + modals.openDeleteSuccessModal(); }, 300); }, onError: (error) => { @@ -417,47 +241,31 @@ const MyPage = () => { // Pin 토글 핸들러 const handleTogglePin = (e: React.MouseEvent, comboId: number) => { - e.stopPropagation(); // 카드 클릭 이벤트 전파 방지 - - // hover 상태 초기화 (즐겨찾기 토글 시 hover 아이콘 유지 방지) - setHoveredStarComboId(null); - - togglePin(comboId, { - onSuccess: () => { - console.log('Pin 상태 변경 성공'); - }, - onError: (error) => { - console.error('Pin 상태 변경 실패:', error); - }, - }); + e.stopPropagation(); + togglePin(comboId); }; // 조합명 저장 핸들러 const handleSaveCombinationName = () => { - if (editingComboId === null) return; + if (combinationEdit.editingComboId === null) return; - // 최종 검증 - const finalError = validateComboName(editingCombinationName); + const finalError = combinationEdit.validateComboName(combinationEdit.editingCombinationName); if (finalError) { - setComboNameError(finalError); + combinationEdit.setComboNameError(finalError); return; } - // trim된 값으로 저장 - const trimmedName = editingCombinationName.trim(); + const trimmedName = combinationEdit.editingCombinationName.trim(); updateCombo( - { comboId: editingComboId, comboName: trimmedName }, + { comboId: combinationEdit.editingComboId, comboName: trimmedName }, { onSuccess: () => { - setShowSaveModal(false); - setEditingComboId(null); - setEditingCombinationName(''); // state 초기화 - setComboNameError(null); // 에러 초기화 + modals.closeSaveModal(); + combinationEdit.stopEditing(); - // 성공 팝업 표시 (0.3초 delay) setTimeout(() => { - setShowSaveSuccessModal(true); + modals.openSaveSuccessModal(); }, 300); }, onError: (error) => { @@ -478,70 +286,11 @@ const MyPage = () => {
{/* 좌측 사이드바 */} - + {/* 우측 메인 콘텐츠 */}
{

내 조합

{detailViewComboId !== null ? ( )} - {/* 드롭다운 메뉴 */} {openMenuIndex === combination.comboId && ( -
- {/* 삭제하기 */} - - - {/* 조합명 수정하기 */} - - - {/* 자세히보기 - 기기가 있을 때만 표시 */} - {hasDevices && ( - - )} -
+ { + modals.openCombinationDeleteModal(combination.comboId); + setOpenMenuIndex(null); + }} + onRename={() => { + combinationEdit.startEditing( + combination.comboId, + combination.comboName + ); + setOpenMenuIndex(null); + }} + onDetail={hasDevices ? () => handleDetailView(combination.comboId) : undefined} + /> )}
)} {/* 상세보기 모드 */} {isDetailView ? ( -
e.stopPropagation()}> - {/* 뒤로가기 버튼 */} -
- -
- - {/* 조합 정보 */} -
-
-
-

조합{index + 1}

-

- 생성일: {formatDate(combination.createdAt)} -

-
-
-

{combination.comboName}

- {combination.isPinned ? ( - handleTogglePin(e, combination.comboId)} - className={`!w-22 !h-22 -mt-2 cursor-pointer transition-opacity ${ - hoveredStarComboId === combination.comboId ? 'opacity-80' : '' - }`} - onMouseEnter={() => setHoveredStarComboId(combination.comboId)} - onMouseLeave={() => setHoveredStarComboId(null)} - /> - ) : ( - <> - {hoveredStarComboId === combination.comboId ? ( - handleTogglePin(e, combination.comboId)} - className="!w-22 !h-22 -mt-2 cursor-pointer" - onMouseEnter={() => - setHoveredStarComboId(combination.comboId) - } - onMouseLeave={() => setHoveredStarComboId(null)} - /> - ) : ( - handleTogglePin(e, combination.comboId)} - className="!w-22 !h-22 -mt-2 cursor-pointer" - onMouseEnter={() => - setHoveredStarComboId(combination.comboId) - } - onMouseLeave={() => setHoveredStarComboId(null)} - /> - )} - - )} -
-
-
- - {/* 전체 선택 + 휴지통 */} -
-
-
- -

전체 선택하기

-
- -
-
- - {/* 안내 텍스트 */} -

- *마우스를 기기 위에 올려서 기기 상세정보를 확인하실 수도 있습니다. -

- - {/* 기기 그리드 (체크박스 포함) */} -
-
- {devices.map((device) => ( -
- 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'}`} - > -
- {/* 호버 오버레이 */} -
- - 보기 - -
-
-
-
-

- {device.name} -

- {/* 체크박스 - 기기명과 같은 높이 */} - -
-

- {device.brandName || '-'} -

-

- {device.deviceType || '-'} -

-
-
- ))} - {/* 기기 추가 버튼 */} - -
-
- - {/* 총 가격 */} -
-
-

총 가격

-
-

-

- {( - comboDetail?.totalPrice ?? combination.totalPrice - ).toLocaleString()} -

-
-
-
- - {/* 구분선 + 조합 평가 정보 (로딩 중에는 숨김) */} - {!isEvaluationLoading && ( - <> -
- -
-
-

- 조합평가 전문보기 -

-
- -
- {evaluationCards ? ( - evaluationCards.map((card) => ( - - )) - ) : ( -
-

- 조합 평가 정보가 아직 준비되지 않았습니다. -

-
- )} -
-
- - )} -
+ ) : ( /* 일반 모드 */ <> {hasDevices ? ( -
- {/* 조합 정보 (생성일 포함) */} -
- {editingComboId === combination.comboId ? ( - /* 수정 모드: 인풋박스 + 별 아이콘 + 에러 메시지 */ -
-
- e.stopPropagation()} - onBlur={() => { - // 포커스 아웃 시 최종 검증 - const error = validateComboName(editingCombinationName); - setComboNameError(error); - }} - maxLength={20} - className={`h-52 px-12 rounded-button font-body-1-sm text-gray-300 focus:outline-none ${ - comboNameError - ? 'border-2 border-warning' - : 'border border-blue-600' - }`} - autoFocus - /> - {combination.isPinned ? ( - handleTogglePin(e, combination.comboId)} - className={`!w-22 !h-22 -mt-2 cursor-pointer transition-opacity ${ - hoveredStarComboId === combination.comboId - ? 'opacity-80' - : '' - }`} - onMouseEnter={() => - setHoveredStarComboId(combination.comboId) - } - onMouseLeave={() => setHoveredStarComboId(null)} - /> - ) : ( - <> - {hoveredStarComboId === combination.comboId ? ( - handleTogglePin(e, combination.comboId)} - className="!w-22 !h-22 -mt-2 cursor-pointer" - onMouseEnter={() => - setHoveredStarComboId(combination.comboId) - } - onMouseLeave={() => setHoveredStarComboId(null)} - /> - ) : ( - handleTogglePin(e, combination.comboId)} - className="!w-22 !h-22 -mt-2 cursor-pointer" - onMouseEnter={() => - setHoveredStarComboId(combination.comboId) - } - onMouseLeave={() => setHoveredStarComboId(null)} - /> - )} - - )} -
- {comboNameError && ( -

- {comboNameError} -

- )} -
- ) : ( - /* 일반 모드: 조합 번호 + 생성일 + 조합명 */ -
-
-

조합{index + 1}

-

- 생성일: {formatDate(combination.createdAt)} -

-
-
-

- {combination.comboName} -

- {combination.isPinned ? ( - handleTogglePin(e, combination.comboId)} - className={`!w-22 !h-22 -mt-3 cursor-pointer transition-opacity ${ - hoveredStarComboId === combination.comboId - ? 'opacity-80' - : '' - }`} - onMouseEnter={() => - setHoveredStarComboId(combination.comboId) - } - onMouseLeave={() => setHoveredStarComboId(null)} - /> - ) : ( - <> - {hoveredStarComboId === combination.comboId ? ( - handleTogglePin(e, combination.comboId)} - className="!w-22 !h-22 -mt-3 cursor-pointer" - onMouseEnter={() => - setHoveredStarComboId(combination.comboId) - } - onMouseLeave={() => setHoveredStarComboId(null)} - /> - ) : ( - handleTogglePin(e, combination.comboId)} - className="!w-22 !h-22 -mt-3 cursor-pointer" - onMouseEnter={() => - setHoveredStarComboId(combination.comboId) - } - onMouseLeave={() => setHoveredStarComboId(null)} - /> - )} - - )} -
-
- )} - {/* Tags - API에서 태그 정보 제공 시 구현 */} -
- - {/* 기기 그리드 - 일반 모드에서도 기기 카드 표시 (그라데이션 포함) */} -
- {(() => { - // 그라데이션 임계값 설정 - const gradientThreshold = columns === 4 ? 9 : 7; - const shouldShowGradient = - combination.devices.length >= gradientThreshold; - const maxDisplay = columns === 4 ? 8 : 6; - const displayedDevices = shouldShowGradient - ? combination.devices.slice(0, maxDisplay) - : combination.devices; - - return ( - <> -
- {displayedDevices.map((device) => ( -
-
-
-

- {device.name} -

-

- {device.brandName} -

-

- {device.deviceType} -

-
-
- ))} -
- - {/* 그라데이션 오버레이 */} - {shouldShowGradient && ( -
- )} - - ); - })()} -
-
+ { + const error = combinationEdit.validateComboName(name); + combinationEdit.setComboNameError(error); + }} + /> ) : ( -
- {/* 조합 정보 (생성일 포함) */} -
- {/* 조합 번호 + 생성일 + 조합명 */} -
-
-

조합{index + 1}

-

- 생성일: {formatDate(combination.createdAt)} -

-
-
-

- {combination.comboName} -

- {combination.isPinned ? ( - handleTogglePin(e, combination.comboId)} - className={`!w-22 !h-22 -mt-3 cursor-pointer transition-opacity ${ - hoveredStarComboId === combination.comboId - ? 'opacity-80' - : '' - }`} - onMouseEnter={() => - setHoveredStarComboId(combination.comboId) - } - onMouseLeave={() => setHoveredStarComboId(null)} - /> - ) : ( - <> - {hoveredStarComboId === combination.comboId ? ( - handleTogglePin(e, combination.comboId)} - className="!w-22 !h-22 -mt-3 cursor-pointer" - onMouseEnter={() => - setHoveredStarComboId(combination.comboId) - } - onMouseLeave={() => setHoveredStarComboId(null)} - /> - ) : ( - handleTogglePin(e, combination.comboId)} - className="!w-22 !h-22 -mt-3 cursor-pointer" - onMouseEnter={() => - setHoveredStarComboId(combination.comboId) - } - onMouseLeave={() => setHoveredStarComboId(null)} - /> - )} - - )} -
-
- {/* Tags - API에서 태그 정보 제공 시 구현 */} -
- - {/* 빈 조합: 기기 추가 버튼 */} -
- -
-
+ )} )} @@ -1203,7 +496,7 @@ const MyPage = () => { })}
- {/* Top Button - 조합 3개 정도 스크롤 시 표시 */} + {/* Top Button */} {showTopButton && (
- {/* 기기 삭제 확인 모달 */} - {showDeleteModal && ( - <> - {/* 배경 오버레이 */} -
setShowDeleteModal(false)} - /> - {/* 모달 */} -
-
e.stopPropagation()} - > - {/* 아이콘 */} - - - {/* 텍스트 */} -

선택한 기기들을 삭제하시겠습니까?

- - {/* 버튼 그룹 */} -
- - -
-
-
- + {/* 모달들 */} + {modals.showDeleteModal && ( + )} - {/* 조합 삭제 확인 모달 */} - {showCombinationDeleteModal && - deleteTargetComboId !== null && - (() => { - const targetCombo = sortedCombos.find((c) => c.comboId === deleteTargetComboId); - if (!targetCombo) return null; - return ( - <> - {/* 배경 오버레이 */} -
{ - setShowCombinationDeleteModal(false); - setDeleteTargetComboId(null); - }} - /> - {/* 모달 */} -
-
e.stopPropagation()} - > - {/* 아이콘 */} - - - {/* 텍스트 */} -

- '{targetCombo.comboName}'을 - 삭제하시겠습니까? -

- - {/* 버튼 그룹 */} -
- - -
-
-
- - ); - })()} - - {/* 조합명 저장 확인 모달 */} - {showSaveModal && ( - <> - {/* 배경 오버레이 */} -
setShowSaveModal(false)} /> - {/* 모달 */} -
-
e.stopPropagation()} - > - {/* 아이콘 */} - - - {/* 텍스트 */} -

조합명을 저장하시겠습니까?

- - {/* 버튼 그룹 */} -
- - -
-
-
- + {modals.showCombinationDeleteModal && modals.deleteTargetComboId !== null && (() => { + const targetCombo = sortedCombos.find((c) => c.comboId === modals.deleteTargetComboId); + if (!targetCombo) return null; + return ( + + ); + })()} + + {modals.showSaveModal && ( + )} - {/* 삭제 완료 팝업 */} - {showDeleteSuccessModal && ( - <> - {/* 배경 오버레이 */} -
- {/* 팝업 */} -
-
- {/* 아이콘 */} - - {/* 텍스트 */} -

삭제 완료

-
-
- + {modals.showDeleteSuccessModal && ( + )} - {/* 저장 완료 팝업 */} - {showSaveSuccessModal && ( - <> - {/* 배경 오버레이 */} -
- {/* 팝업 */} -
-
- {/* 파란 체크 아이콘 */} - - {/* 텍스트 */} -

저장 완료!

-
-
- + {modals.showSaveSuccessModal && ( + )}
); }; -export default MyPage; \ No newline at end of file +export default MyPage; From ec6662d7b3d39ba99a802d3b56a53444633eb88b Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:49:15 +0900 Subject: [PATCH 16/28] refactor: useClickOutisde --- src/hooks/useClickOutside.ts | 22 ++++++++++++++++++++++ src/pages/my/MyPage.tsx | 11 ++--------- 2 files changed, 24 insertions(+), 9 deletions(-) create mode 100644 src/hooks/useClickOutside.ts diff --git a/src/hooks/useClickOutside.ts b/src/hooks/useClickOutside.ts new file mode 100644 index 00000000..9321961e --- /dev/null +++ b/src/hooks/useClickOutside.ts @@ -0,0 +1,22 @@ +import { useEffect, type RefObject } from 'react'; + +/** + * 요소 외부 클릭을 감지하는 훅 + * @param ref - 감지할 요소의 ref + * @param handler - 외부 클릭 시 실행할 콜백 + */ +export const useClickOutside = ( + ref: RefObject, + handler: (event: MouseEvent) => void +) => { + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + handler(event); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [ref, handler]); +}; diff --git a/src/pages/my/MyPage.tsx b/src/pages/my/MyPage.tsx index bcf723f8..9dcf5663 100644 --- a/src/pages/my/MyPage.tsx +++ b/src/pages/my/MyPage.tsx @@ -29,6 +29,7 @@ import { useCombinationModals } from '@/hooks/useCombinationModals'; import { useDeviceSelection } from '@/hooks/useDeviceSelection'; import { useCombinationSort } from '@/hooks/useCombinationSort'; import { useCombinationEdit } from '@/hooks/useCombinationEdit'; +import { useClickOutside } from '@/hooks/useClickOutside'; import { mapEvaluationToUI } from '@/utils/mapEvaluationToUI'; import { MYPAGE_SORT_OPTIONS } from '@/constants/combination'; import type { LifestyleKey } from '@/constants/evaluation/lifestyle'; @@ -111,15 +112,7 @@ const MyPage = () => { }, []); // 드롭다운 외부 클릭 시 닫기 - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setOpenMenuIndex(null); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); + useClickOutside(menuRef, () => setOpenMenuIndex(null)); // 모달 열릴 때 배경 스크롤 방지 useEffect(() => { From 52386a1106e9a7b126e36318430c889bc95fa8ea Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:50:15 +0900 Subject: [PATCH 17/28] =?UTF-8?q?refactor:=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=B0=A9=EC=A7=80=20=ED=9B=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useModalScrollLock.ts | 44 +++++++++++++++++++++++++++++ src/pages/my/MyPage.tsx | 50 ++++++--------------------------- 2 files changed, 52 insertions(+), 42 deletions(-) create mode 100644 src/hooks/useModalScrollLock.ts diff --git a/src/hooks/useModalScrollLock.ts b/src/hooks/useModalScrollLock.ts new file mode 100644 index 00000000..7f832201 --- /dev/null +++ b/src/hooks/useModalScrollLock.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; + +/** + * 모달이 열릴 때 배경 스크롤을 방지하는 훅 + * @param isOpen - 모달이 열려있는지 여부 + */ +export const useModalScrollLock = (isOpen: boolean) => { + useEffect(() => { + if (isOpen) { + // 현재 스크롤 위치 저장 + const scrollY = window.scrollY; + + // body 고정 (스크롤 방지) + document.body.style.position = 'fixed'; + document.body.style.top = `-${scrollY}px`; + document.body.style.width = '100%'; + document.body.style.overflow = 'hidden'; + } else { + // 저장된 스크롤 위치 복원 + const scrollY = document.body.style.top; + document.body.style.position = ''; + document.body.style.top = ''; + document.body.style.width = ''; + document.body.style.overflow = ''; + + // 스크롤 위치로 이동 + if (scrollY) { + window.scrollTo(0, parseInt(scrollY || '0') * -1); + } + } + + // cleanup: 컴포넌트 언마운트 시 원래대로 복구 + return () => { + const scrollY = document.body.style.top; + document.body.style.position = ''; + document.body.style.top = ''; + document.body.style.width = ''; + document.body.style.overflow = ''; + if (scrollY) { + window.scrollTo(0, parseInt(scrollY || '0') * -1); + } + }; + }, [isOpen]); +}; diff --git a/src/pages/my/MyPage.tsx b/src/pages/my/MyPage.tsx index 9dcf5663..c3608228 100644 --- a/src/pages/my/MyPage.tsx +++ b/src/pages/my/MyPage.tsx @@ -30,6 +30,7 @@ import { useDeviceSelection } from '@/hooks/useDeviceSelection'; import { useCombinationSort } from '@/hooks/useCombinationSort'; import { useCombinationEdit } from '@/hooks/useCombinationEdit'; import { useClickOutside } from '@/hooks/useClickOutside'; +import { useModalScrollLock } from '@/hooks/useModalScrollLock'; import { mapEvaluationToUI } from '@/utils/mapEvaluationToUI'; import { MYPAGE_SORT_OPTIONS } from '@/constants/combination'; import type { LifestyleKey } from '@/constants/evaluation/lifestyle'; @@ -115,48 +116,13 @@ const MyPage = () => { useClickOutside(menuRef, () => setOpenMenuIndex(null)); // 모달 열릴 때 배경 스크롤 방지 - useEffect(() => { - const isAnyModalOpen = - modals.showDeleteModal || - modals.showCombinationDeleteModal || - modals.showSaveModal || - modals.showDeleteSuccessModal || - modals.showSaveSuccessModal; - - if (isAnyModalOpen) { - const scrollY = window.scrollY; - document.body.style.position = 'fixed'; - document.body.style.top = `-${scrollY}px`; - document.body.style.width = '100%'; - document.body.style.overflow = 'hidden'; - } else { - const scrollY = document.body.style.top; - document.body.style.position = ''; - document.body.style.top = ''; - document.body.style.width = ''; - document.body.style.overflow = ''; - if (scrollY) { - window.scrollTo(0, parseInt(scrollY || '0') * -1); - } - } - - return () => { - const scrollY = document.body.style.top; - document.body.style.position = ''; - document.body.style.top = ''; - document.body.style.width = ''; - document.body.style.overflow = ''; - if (scrollY) { - window.scrollTo(0, parseInt(scrollY || '0') * -1); - } - }; - }, [ - modals.showDeleteModal, - modals.showCombinationDeleteModal, - modals.showSaveModal, - modals.showDeleteSuccessModal, - modals.showSaveSuccessModal, - ]); + const isAnyModalOpen = + modals.showDeleteModal || + modals.showCombinationDeleteModal || + modals.showSaveModal || + modals.showDeleteSuccessModal || + modals.showSaveSuccessModal; + useModalScrollLock(isAnyModalOpen); // 자세히보기 클릭 핸들러 const handleDetailView = (comboId: number) => { From ea93cb5c94bc3055f1faa9a2a7c067a3235698ef Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:51:13 +0900 Subject: [PATCH 18/28] =?UTF-8?q?refactor:=20=EC=8A=A4=ED=81=AC=EB=A1=A4?= =?UTF-8?q?=20=EB=B0=A9=EC=A7=80=20=ED=9B=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useMyPageScroll.ts | 42 ++++++++++++++++++++++++++++++++++++ src/pages/my/MyPage.tsx | 22 ++----------------- 2 files changed, 44 insertions(+), 20 deletions(-) create mode 100644 src/hooks/useMyPageScroll.ts diff --git a/src/hooks/useMyPageScroll.ts b/src/hooks/useMyPageScroll.ts new file mode 100644 index 00000000..1c65191a --- /dev/null +++ b/src/hooks/useMyPageScroll.ts @@ -0,0 +1,42 @@ +import { useState, useEffect, type RefObject } from 'react'; + +interface UseMyPageScrollReturn { + isAtBottom: boolean; + showTopButton: boolean; +} + +/** + * MyPage 전용 스크롤 상태 관리 훅 + * @param combinationListRef - 조합 목록 ref + * @returns isAtBottom - 하단 도달 여부, showTopButton - Top 버튼 표시 여부 + */ +export const useMyPageScroll = ( + combinationListRef: RefObject +): UseMyPageScrollReturn => { + const [isAtBottom, setIsAtBottom] = useState(false); + const [showTopButton, setShowTopButton] = useState(false); + + useEffect(() => { + const handleScroll = () => { + const scrollTop = window.scrollY; + const windowHeight = window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + + // 하단 도달 여부 (그라데이션용) + setIsAtBottom(scrollTop + windowHeight >= documentHeight - 50); + + // 조합 3개 정도 스크롤 시 Top 버튼 표시 (약 800px) + if (combinationListRef.current) { + const listTop = combinationListRef.current.offsetTop; + const thirdCombinationVisible = scrollTop + windowHeight >= listTop + 800; + setShowTopButton(thirdCombinationVisible); + } + }; + + window.addEventListener('scroll', handleScroll); + handleScroll(); + return () => window.removeEventListener('scroll', handleScroll); + }, [combinationListRef]); + + return { isAtBottom, showTopButton }; +}; diff --git a/src/pages/my/MyPage.tsx b/src/pages/my/MyPage.tsx index c3608228..0cc2faeb 100644 --- a/src/pages/my/MyPage.tsx +++ b/src/pages/my/MyPage.tsx @@ -31,6 +31,7 @@ import { useCombinationSort } from '@/hooks/useCombinationSort'; import { useCombinationEdit } from '@/hooks/useCombinationEdit'; import { useClickOutside } from '@/hooks/useClickOutside'; import { useModalScrollLock } from '@/hooks/useModalScrollLock'; +import { useMyPageScroll } from '@/hooks/useMyPageScroll'; import { mapEvaluationToUI } from '@/utils/mapEvaluationToUI'; import { MYPAGE_SORT_OPTIONS } from '@/constants/combination'; import type { LifestyleKey } from '@/constants/evaluation/lifestyle'; @@ -39,12 +40,10 @@ const MyPage = () => { const navigate = useNavigate(); // 상태 관리 - const [isAtBottom, setIsAtBottom] = useState(false); const [columns, setColumns] = useState<3 | 4>(4); const [openMenuIndex, setOpenMenuIndex] = useState(null); const [detailViewComboId, setDetailViewComboId] = useState(null); const [savedScrollPosition, setSavedScrollPosition] = useState(0); - const [showTopButton, setShowTopButton] = useState(false); const menuRef = useRef(null); const sidebarContentRef = useRef(null); @@ -82,24 +81,7 @@ const MyPage = () => { }, [evaluation, lifestyleKey]); // 스크롤 감지 (하단 그라데이션용 + Top 버튼용) - useEffect(() => { - const handleScroll = () => { - const scrollTop = window.scrollY; - const windowHeight = window.innerHeight; - const documentHeight = document.documentElement.scrollHeight; - setIsAtBottom(scrollTop + windowHeight >= documentHeight - 50); - - // 조합 3개 정도 스크롤 시 Top 버튼 표시 - if (combinationListRef.current) { - const listTop = combinationListRef.current.offsetTop; - const thirdCombinationVisible = scrollTop + windowHeight >= listTop + 800; - setShowTopButton(thirdCombinationVisible); - } - }; - window.addEventListener('scroll', handleScroll); - handleScroll(); - return () => window.removeEventListener('scroll', handleScroll); - }, []); + const { isAtBottom, showTopButton } = useMyPageScroll(combinationListRef); // 브레이크포인트 감지 (칼럼 수 반응형) useEffect(() => { From b99a586957eb1abaec9e7f35f326db63e598ca10 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:54:02 +0900 Subject: [PATCH 19/28] refactor: MyPage.tsx --- src/components/MyPage/CombinationList.tsx | 236 ++++++++++++++++++++++ src/pages/my/MyPage.tsx | 208 +++---------------- 2 files changed, 260 insertions(+), 184 deletions(-) create mode 100644 src/components/MyPage/CombinationList.tsx diff --git a/src/components/MyPage/CombinationList.tsx b/src/components/MyPage/CombinationList.tsx new file mode 100644 index 00000000..b897eb08 --- /dev/null +++ b/src/components/MyPage/CombinationList.tsx @@ -0,0 +1,236 @@ +import { type RefObject } from 'react'; +import SecondaryButton from '@/components/Button/SecondaryButton'; +import SortDropdown from '@/components/Filter/SortDropdown'; +import CombinationMenu from '@/components/MyPage/CombinationMenu'; +import CombinationCard from '@/components/MyPage/CombinationCard'; +import EmptyCombinationCard from '@/components/MyPage/EmptyCombinationCard'; +import CombinationDetailView from '@/components/MyPage/CombinationDetailView'; +import SettingMoreIcon from '@/assets/icons/settingmore.svg?react'; +import AlarmIcon from '@/assets/icons/alarm.svg?react'; +import { MYPAGE_SORT_OPTIONS } from '@/constants/combination'; +import type { ComboListItem } from '@/types/combo/combo'; + +interface CombinationListProps { + combinationListRef: RefObject; + sortedCombos: ComboListItem[]; + isLoading: boolean; + isError: boolean; + detailViewComboId: number | null; + comboDetail: any; + columns: 3 | 4; + sortOption: string; + setSortOption: (option: string) => void; + openMenuIndex: number | null; + setOpenMenuIndex: (index: number | null) => void; + menuRef: RefObject; + combinationEdit: any; + modals: any; + deviceSelection: any; + evaluationCards: any; + isEvaluationLoading: boolean; + handleDetailView: (comboId: number) => void; + handleBackToNormal: () => void; + handleTogglePin: (e: React.MouseEvent, comboId: number) => void; + handleTrashClick: () => void; +} + +const CombinationList = ({ + combinationListRef, + sortedCombos, + isLoading, + isError, + detailViewComboId, + comboDetail, + columns, + sortOption, + setSortOption, + openMenuIndex, + setOpenMenuIndex, + menuRef, + combinationEdit, + modals, + deviceSelection, + evaluationCards, + isEvaluationLoading, + handleDetailView, + handleBackToNormal, + handleTogglePin, + handleTrashClick, +}: CombinationListProps) => { + return ( +
+ {isLoading && ( +
+

조합 목록을 불러오는 중...

+
+ )} + {isError && ( +
+

조합 목록을 불러오는데 실패했습니다.

+
+ )} + {!isLoading && !isError && sortedCombos.length === 0 && ( +
+

등록된 조합이 없습니다.

+
+ )} + {sortedCombos.map((combination, index) => { + const isDetailView = detailViewComboId === combination.comboId; + const hasDevices = combination.deviceCount > 0; + const devices = isDetailView && comboDetail ? comboDetail.devices : []; + const deviceIds = devices.map((d) => d.deviceId); + + // 상세보기 모드일 때 선택된 조합만 표시 + if (detailViewComboId !== null && !isDetailView) { + return null; + } + + return ( +
+ {/* 추천 메시지 + 정렬 필터 - 상세보기 모드가 아닐 때만 표시 */} + {!isDetailView && ( +
+
+ +

+ {hasDevices + ? '추천하는 조합입니다. 기기 간 호환성이 우수하며 만족도가 높을 것입니다.' + : '-'} +

+
+ {index === 0 && sortedCombos.length > 1 && ( + + )} +
+ )} + + {/* 조합 카드 */} +
+ !isDetailView && + combinationEdit.editingComboId !== combination.comboId && + hasDevices && + handleDetailView(combination.comboId) + } + className={`rounded-card relative ${ + isDetailView + ? 'bg-blue-100' + : `bg-white shadow-[0_0_4px_rgba(0,0,0,0.25)] transition-shadow ${hasDevices ? 'cursor-pointer hover:shadow-[0_0_12px_rgba(0,105,240,0.5)]' : ''}` + }`} + > + {/* 일반 모드: Setting More 버튼 + 드롭다운 또는 저장하기 버튼 */} + {!isDetailView && ( +
+ {combinationEdit.editingComboId === combination.comboId ? ( + { + const error = combinationEdit.validateComboName( + combinationEdit.editingCombinationName + ); + combinationEdit.setComboNameError(error); + if (!error) { + modals.openSaveModal(); + } + }} + disabled={ + !combinationEdit.isComboNameValid || + combinationEdit.editingCombinationName.trim().length === 0 + } + className="w-150" + /> + ) : ( + + )} + + {openMenuIndex === combination.comboId && ( + { + modals.openCombinationDeleteModal(combination.comboId); + setOpenMenuIndex(null); + }} + onRename={() => { + combinationEdit.startEditing(combination.comboId, combination.comboName); + setOpenMenuIndex(null); + }} + onDetail={hasDevices ? () => handleDetailView(combination.comboId) : undefined} + /> + )} +
+ )} + + {/* 상세보기 모드 */} + {isDetailView ? ( + + ) : ( + /* 일반 모드 */ + <> + {hasDevices ? ( + { + const error = combinationEdit.validateComboName(name); + combinationEdit.setComboNameError(error); + }} + /> + ) : ( + + )} + + )} +
+
+ ); + })} +
+ ); +}; + +export default CombinationList; diff --git a/src/pages/my/MyPage.tsx b/src/pages/my/MyPage.tsx index 0cc2faeb..bcb2791d 100644 --- a/src/pages/my/MyPage.tsx +++ b/src/pages/my/MyPage.tsx @@ -2,20 +2,13 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import GNB from '@/components/Home/GNB'; import PrimaryButton from '@/components/Button/PrimaryButton'; -import SecondaryButton from '@/components/Button/SecondaryButton'; -import SortDropdown from '@/components/Filter/SortDropdown'; import MyPageSidebar from '@/components/MyPage/MyPageSidebar'; -import CombinationMenu from '@/components/MyPage/CombinationMenu'; -import CombinationCard from '@/components/MyPage/CombinationCard'; -import EmptyCombinationCard from '@/components/MyPage/EmptyCombinationCard'; -import CombinationDetailView from '@/components/MyPage/CombinationDetailView'; +import CombinationList from '@/components/MyPage/CombinationList'; import DeviceDeleteModal from '@/components/MyPage/DeviceDeleteModal'; import CombinationDeleteModal from '@/components/MyPage/CombinationDeleteModal'; import SaveNameModal from '@/components/MyPage/SaveNameModal'; import DeleteCompleteModal from '@/components/MyPage/DeleteCompleteModal'; import SaveCompleteModal from '@/components/DeviceSearch/SaveCompleteModal'; -import SettingMoreIcon from '@/assets/icons/settingmore.svg?react'; -import AlarmIcon from '@/assets/icons/alarm.svg?react'; import TopIcon from '@/assets/icons/top.svg?react'; import { useGetCombos } from '@/apis/combo/getCombos'; import { useGetCombo } from '@/apis/combo/getComboId'; @@ -33,7 +26,6 @@ import { useClickOutside } from '@/hooks/useClickOutside'; import { useModalScrollLock } from '@/hooks/useModalScrollLock'; import { useMyPageScroll } from '@/hooks/useMyPageScroll'; import { mapEvaluationToUI } from '@/utils/mapEvaluationToUI'; -import { MYPAGE_SORT_OPTIONS } from '@/constants/combination'; import type { LifestyleKey } from '@/constants/evaluation/lifestyle'; const MyPage = () => { @@ -261,181 +253,29 @@ const MyPage = () => {
{/* 조합 카드 목록 */} -
- {isLoading && ( -
-

조합 목록을 불러오는 중...

-
- )} - {isError && ( -
-

조합 목록을 불러오는데 실패했습니다.

-
- )} - {!isLoading && !isError && sortedCombos.length === 0 && ( -
-

등록된 조합이 없습니다.

-
- )} - {sortedCombos.map((combination, index) => { - const isDetailView = detailViewComboId === combination.comboId; - const hasDevices = combination.deviceCount > 0; - const devices = isDetailView && comboDetail ? comboDetail.devices : []; - const deviceIds = devices.map((d) => d.deviceId); - - // 상세보기 모드일 때 선택된 조합만 표시 - if (detailViewComboId !== null && !isDetailView) { - return null; - } - - return ( -
- {/* 추천 메시지 + 정렬 필터 - 상세보기 모드가 아닐 때만 표시 */} - {!isDetailView && ( -
-
- -

- {hasDevices - ? '추천하는 조합입니다. 기기 간 호환성이 우수하며 만족도가 높을 것입니다.' - : '-'} -

-
- {index === 0 && sortedCombos.length > 1 && ( - - )} -
- )} - - {/* 조합 카드 */} -
- !isDetailView && - combinationEdit.editingComboId !== combination.comboId && - hasDevices && - handleDetailView(combination.comboId) - } - className={`rounded-card relative ${ - isDetailView - ? 'bg-blue-100' - : `bg-white shadow-[0_0_4px_rgba(0,0,0,0.25)] transition-shadow ${hasDevices ? 'cursor-pointer hover:shadow-[0_0_12px_rgba(0,105,240,0.5)]' : ''}` - }`} - > - {/* 일반 모드: Setting More 버튼 + 드롭다운 또는 저장하기 버튼 */} - {!isDetailView && ( -
- {combinationEdit.editingComboId === combination.comboId ? ( - { - const error = combinationEdit.validateComboName( - combinationEdit.editingCombinationName - ); - combinationEdit.setComboNameError(error); - if (!error) { - modals.openSaveModal(); - } - }} - disabled={ - !combinationEdit.isComboNameValid || - combinationEdit.editingCombinationName.trim().length === 0 - } - className="w-150" - /> - ) : ( - - )} - - {openMenuIndex === combination.comboId && ( - { - modals.openCombinationDeleteModal(combination.comboId); - setOpenMenuIndex(null); - }} - onRename={() => { - combinationEdit.startEditing( - combination.comboId, - combination.comboName - ); - setOpenMenuIndex(null); - }} - onDetail={hasDevices ? () => handleDetailView(combination.comboId) : undefined} - /> - )} -
- )} - - {/* 상세보기 모드 */} - {isDetailView ? ( - - ) : ( - /* 일반 모드 */ - <> - {hasDevices ? ( - { - const error = combinationEdit.validateComboName(name); - combinationEdit.setComboNameError(error); - }} - /> - ) : ( - - )} - - )} -
-
- ); - })} -
+ {/* Top Button */} {showTopButton && ( From e153b60684dcd072674b8368f54cd9fcdd2f7907 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 12:58:37 +0900 Subject: [PATCH 20/28] =?UTF-8?q?refactor:=20=EC=BD=98=EC=86=94=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/my/MyPage.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/pages/my/MyPage.tsx b/src/pages/my/MyPage.tsx index bcb2791d..a3f36070 100644 --- a/src/pages/my/MyPage.tsx +++ b/src/pages/my/MyPage.tsx @@ -137,8 +137,8 @@ const MyPage = () => { setTimeout(() => { modals.openDeleteSuccessModal(); }, 300); - } catch (error) { - console.error('기기 삭제 중 오류 발생:', error); + } catch { + // 기기 삭제 실패 시 조용히 처리 } }; @@ -166,9 +166,6 @@ const MyPage = () => { modals.openDeleteSuccessModal(); }, 300); }, - onError: (error) => { - console.error('조합 삭제 실패:', error); - }, }); }; @@ -201,9 +198,6 @@ const MyPage = () => { modals.openSaveSuccessModal(); }, 300); }, - onError: (error) => { - console.error('조합명 수정 실패:', error); - }, } ); }; From bc12fa1c34aef0905be59637ec55d1d4cb3648d6 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 13:09:31 +0900 Subject: [PATCH 21/28] =?UTF-8?q?remove:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80,=20=EA=B2=80=EC=83=89=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=95=88=20=EC=93=B0=EC=9D=B4=EB=8A=94=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/mockData.ts | 154 --------------------------------- src/hooks/useRecentlyViewed.ts | 41 --------- 2 files changed, 195 deletions(-) delete mode 100644 src/hooks/useRecentlyViewed.ts diff --git a/src/constants/mockData.ts b/src/constants/mockData.ts index bc529339..338307d9 100644 --- a/src/constants/mockData.ts +++ b/src/constants/mockData.ts @@ -16,157 +16,3 @@ export interface DeviceSummary { color: string; image: string | null; } - -// 디자인 토큰 색상 사용 (중앙 관리) -export const PRODUCT_COLOR_CHIPS = { - BLACK: '#000000', - WHITE: '#FFFFFF', - BLUE: '#0069F0', -} as const; - -// 임시 데이터 (나중에 API로 대체) -export const MOCK_PRODUCTS: Product[] = Array.from({ length: 12 }, (_, i) => ({ - id: i + 1, - name: 'iPhone 15 pro', - category: '스마트폰', - price: 1550000, - image: null, - colors: [ - PRODUCT_COLOR_CHIPS.BLACK, - PRODUCT_COLOR_CHIPS.WHITE, - PRODUCT_COLOR_CHIPS.BLUE, - ], -})); - -// 사용자 조합 목록 (추후 API 연동) -export const MOCK_COMBINATIONS: UserCombination[] = [ - { - id: 1, - label: '조합 1', - name: 'iPhone 15 Pro 중심 조합', - isMain: true, - createdAt: '2025.11.01', - tags: [ - { name: '연동성', status: '최적' }, - { name: '편의성', status: '최적' }, - { name: '라이프스타일', status: '최적' }, - ], - }, - { - id: 2, - label: '조합 2', - name: 'M3 MacBook Air 중심 조합', - isMain: false, - createdAt: '2025.10.25', - tags: [ - { name: '연동성', status: '최적' }, - { name: '편의성', status: '최적' }, - { name: '라이프스타일', status: '최적' }, - ], - }, - { - id: 3, - label: '조합 3', - name: '사무실 세팅', - isMain: false, - createdAt: '2025.10.15', - tags: [ - { name: '연동성', status: '최적' }, - { name: '편의성', status: '최적' }, - { name: '라이프스타일', status: '최적' }, - ], - }, - { - id: 4, - label: '조합 4', - name: '새로 생성한 조합', - isMain: true, - createdAt: '2026.01.01', - tags: [ - { name: '연동성', status: '-' }, - { name: '편의성', status: '-' }, - { name: '라이프스타일', status: '-' }, - ], - }, - { - id: 5, - label: '조합 5', - name: '게이밍 셋업', - isMain: false, - createdAt: '2026.01.10', - tags: [ - { name: '연동성', status: '보통' }, - { name: '편의성', status: '최적' }, - { name: '라이프스타일', status: '보통' }, - ], - }, - { - id: 6, - label: '조합 6', - name: '출퇴근용 조합', - isMain: false, - createdAt: '2026.01.15', - tags: [ - { name: '연동성', status: '최적' }, - { name: '편의성', status: '보통' }, - { name: '라이프스타일', status: '최적' }, - ], - }, -]; - -// 조합별 기기 목록 (추후 API 연동) -export const MOCK_COMBINATION_DEVICES: Record = { - // 조합1: 상품 id와 겹치지 않는 id 사용 → 담기 버튼 테스트용 - 1: [ - { id: 101, name: 'iPhone 15 pro', chargingType: 'USB-C', color: '내추럴 티타늄', image: null }, - { id: 102, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 103, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 104, name: 'iPhone 15 pro', chargingType: 'USB-C', color: '내추럴 티타늄', image: null }, - { id: 105, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 106, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 107, name: 'iPhone 15 pro', chargingType: 'USB-C', color: '내추럴 티타늄', image: null }, - { id: 108, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 109, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 110, name: 'iPhone 15 pro', chargingType: 'USB-C', color: '내추럴 티타늄', image: null }, - ], - // 조합2: 상품 id 1~6과 동일 → "이미 담은 상품입니다." 테스트용 - 2: [ - { id: 1, name: 'iPhone 15 pro', chargingType: 'USB-C', color: '내추럴 티타늄', image: null }, - { id: 2, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 3, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 4, name: 'iPhone 15 pro', chargingType: 'USB-C', color: '내추럴 티타늄', image: null }, - { id: 5, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 6, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 7, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 8, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - ], - 3: [ - { id: 201, name: 'iPhone 15 pro', chargingType: 'USB-C', color: '내추럴 티타늄', image: null }, - { id: 202, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 203, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 204, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 205, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 206, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 207, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 208, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - ], - // 조합4: 새로 생성한 조합 (기기 없음) - 4: [], - // 조합5: 게이밍 셋업 - 5: [ - { id: 301, name: 'ROG Ally', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 302, name: 'ROG 게이밍 모니터', chargingType: 'USB-C', color: '블랙', image: null }, - { id: 303, name: '로지텍 G Pro 마우스', chargingType: 'USB-C', color: '블랙', image: null }, - ], - // 조합6: 출퇴근용 조합 - 6: [ - { id: 401, name: 'AirPods Pro 2세대', chargingType: 'USB-C', color: '화이트', image: null }, - { id: 402, name: 'Apple Watch Series 9', chargingType: 'MagSafe', color: '미드나이트', image: null }, - ], -}; - -// 최근에 본 기기 Mock 데이터 -export const MOCK_RECENTLY_VIEWED_DEVICES = [ - { id: 1, name: 'iPhone 15 pro', category: '스마트폰', price: 1550000, image: null, viewedAt: Date.now() }, - { id: 2, name: 'Galaxy S24', category: '스마트폰', price: 1200000, image: null, viewedAt: Date.now() - 1000 }, -]; diff --git a/src/hooks/useRecentlyViewed.ts b/src/hooks/useRecentlyViewed.ts deleted file mode 100644 index 2cda4785..00000000 --- a/src/hooks/useRecentlyViewed.ts +++ /dev/null @@ -1,41 +0,0 @@ -// import { useState, useEffect, useCallback } from 'react'; -// import type { RecentlyViewedDevice } from '@/types/recentlyViewed'; -// import { -// getRecentlyViewedDevices, -// addRecentlyViewedDevice, -// removeRecentlyViewedDevice, -// } from '@/utils/recentlyViewedStorage'; -// import { MOCK_RECENTLY_VIEWED_DEVICES } from '@/constants/mockData'; - -// export const useRecentlyViewed = () => { -// const [devices, setDevices] = useState([]); - -// // 초기 로드 (localStorage가 비어있으면 mockdata 사용) -// useEffect(() => { -// const storedDevices = getRecentlyViewedDevices(); -// if (storedDevices.length > 0) { -// setDevices(storedDevices); -// } else { -// setDevices(MOCK_RECENTLY_VIEWED_DEVICES); -// } -// }, []); - -// // 기기 추가 -// const addDevice = useCallback((device: Omit) => { -// addRecentlyViewedDevice(device); -// setDevices(getRecentlyViewedDevices()); -// }, []); - -// // 기기 제거 -// const removeDevice = useCallback((deviceId: number) => { -// removeRecentlyViewedDevice(deviceId); -// setDevices(getRecentlyViewedDevices()); -// }, []); - -// return { -// devices, -// addDevice, -// removeDevice, -// hasDevices: devices.length > 0, -// }; -// }; From 45de40e57c5313df430563777f416e0c424153c1 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 13:23:51 +0900 Subject: [PATCH 22/28] =?UTF-8?q?refactor:=20useCallback=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EB=A9=94=EB=AA=A8=EC=9D=B4?= =?UTF-8?q?=EC=A0=9C=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/my/MyPage.tsx | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/pages/my/MyPage.tsx b/src/pages/my/MyPage.tsx index a3f36070..1393cb30 100644 --- a/src/pages/my/MyPage.tsx +++ b/src/pages/my/MyPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import GNB from '@/components/Home/GNB'; import PrimaryButton from '@/components/Button/PrimaryButton'; @@ -99,23 +99,23 @@ const MyPage = () => { useModalScrollLock(isAnyModalOpen); // 자세히보기 클릭 핸들러 - const handleDetailView = (comboId: number) => { + const handleDetailView = useCallback((comboId: number) => { setSavedScrollPosition(window.scrollY); setDetailViewComboId(comboId); setOpenMenuIndex(null); deviceSelection.clearSelection(); window.scrollTo(0, 0); - }; + }, [deviceSelection]); // 뒤로가기 핸들러 - const handleBackToNormal = () => { + const handleBackToNormal = useCallback(() => { setDetailViewComboId(null); deviceSelection.clearSelection(); window.scrollTo(0, savedScrollPosition); - }; + }, [deviceSelection, savedScrollPosition]); // 선택된 기기 삭제 핸들러 - const handleDeleteDevices = async () => { + const handleDeleteDevices = useCallback(async () => { if (!detailViewComboId || deviceSelection.selectedDevices.length === 0) return; try { @@ -140,17 +140,17 @@ const MyPage = () => { } catch { // 기기 삭제 실패 시 조용히 처리 } - }; + }, [detailViewComboId, deviceSelection, deleteDevice, modals]); // 휴지통 클릭 핸들러 - const handleTrashClick = () => { + const handleTrashClick = useCallback(() => { if (deviceSelection.selectedDevices.length > 0) { modals.openDeleteModal(); } - }; + }, [deviceSelection.selectedDevices.length, modals]); // 조합 삭제 핸들러 - const handleDeleteCombination = () => { + const handleDeleteCombination = useCallback(() => { if (modals.deleteTargetComboId === null) return; deleteCombo(modals.deleteTargetComboId, { @@ -167,16 +167,16 @@ const MyPage = () => { }, 300); }, }); - }; + }, [modals, deleteCombo, detailViewComboId, deviceSelection]); // Pin 토글 핸들러 - const handleTogglePin = (e: React.MouseEvent, comboId: number) => { + const handleTogglePin = useCallback((e: React.MouseEvent, comboId: number) => { e.stopPropagation(); togglePin(comboId); - }; + }, [togglePin]); // 조합명 저장 핸들러 - const handleSaveCombinationName = () => { + const handleSaveCombinationName = useCallback(() => { if (combinationEdit.editingComboId === null) return; const finalError = combinationEdit.validateComboName(combinationEdit.editingCombinationName); @@ -200,12 +200,12 @@ const MyPage = () => { }, } ); - }; + }, [combinationEdit, updateCombo, modals]); // 맨 위로 스크롤 - const handleScrollToTop = () => { + const handleScrollToTop = useCallback(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); - }; + }, []); return (
From 39099b432ba6d0c2fc98e69c31f61d29e17d3670 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 13:27:00 +0900 Subject: [PATCH 23/28] =?UTF-8?q?refactor:=20=EC=9E=90=EC=8B=9D=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20React.memo=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MyPage/CombinationCard.tsx | 4 ++-- src/components/MyPage/CombinationDeleteModal.tsx | 3 ++- src/components/MyPage/CombinationDetailView.tsx | 4 ++-- src/components/MyPage/CombinationMenu.tsx | 4 ++-- src/components/MyPage/DeleteCompleteModal.tsx | 3 ++- src/components/MyPage/DeviceDeleteModal.tsx | 3 ++- src/components/MyPage/EmptyCombinationCard.tsx | 4 ++-- src/components/MyPage/MyPageSidebar.tsx | 4 ++-- src/components/MyPage/SaveNameModal.tsx | 3 ++- 9 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/components/MyPage/CombinationCard.tsx b/src/components/MyPage/CombinationCard.tsx index 0a54af0e..2f67f52a 100644 --- a/src/components/MyPage/CombinationCard.tsx +++ b/src/components/MyPage/CombinationCard.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, memo } from 'react'; import StarIcon from '@/assets/icons/star.svg?react'; import StarXIcon from '@/assets/icons/starx.svg?react'; import StarHoverIcon from '@/assets/icons/starhover.svg?react'; @@ -168,4 +168,4 @@ const CombinationCard = ({ ); }; -export default CombinationCard; +export default memo(CombinationCard); diff --git a/src/components/MyPage/CombinationDeleteModal.tsx b/src/components/MyPage/CombinationDeleteModal.tsx index a229938c..afd8a4d9 100644 --- a/src/components/MyPage/CombinationDeleteModal.tsx +++ b/src/components/MyPage/CombinationDeleteModal.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import RemoveIcon from '@/assets/icons/remove.svg?react'; interface CombinationDeleteModalProps { @@ -58,4 +59,4 @@ const CombinationDeleteModal = ({ ); }; -export default CombinationDeleteModal; +export default memo(CombinationDeleteModal); diff --git a/src/components/MyPage/CombinationDetailView.tsx b/src/components/MyPage/CombinationDetailView.tsx index 8047fdd3..b3d64ea2 100644 --- a/src/components/MyPage/CombinationDetailView.tsx +++ b/src/components/MyPage/CombinationDetailView.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, memo } from 'react'; import { useNavigate } from 'react-router-dom'; import BackIcon from '@/assets/icons/back.svg?react'; import StarIcon from '@/assets/icons/star.svg?react'; @@ -241,4 +241,4 @@ const CombinationDetailView = ({ ); }; -export default CombinationDetailView; +export default memo(CombinationDetailView); diff --git a/src/components/MyPage/CombinationMenu.tsx b/src/components/MyPage/CombinationMenu.tsx index c04e9a04..cc4c8234 100644 --- a/src/components/MyPage/CombinationMenu.tsx +++ b/src/components/MyPage/CombinationMenu.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, memo } from 'react'; interface CombinationMenuProps { hasDevices: boolean; @@ -65,4 +65,4 @@ const CombinationMenu = ({ hasDevices, onDelete, onRename, onDetail }: Combinati ); }; -export default CombinationMenu; +export default memo(CombinationMenu); diff --git a/src/components/MyPage/DeleteCompleteModal.tsx b/src/components/MyPage/DeleteCompleteModal.tsx index 69cac8bd..830fc596 100644 --- a/src/components/MyPage/DeleteCompleteModal.tsx +++ b/src/components/MyPage/DeleteCompleteModal.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import RemoveIcon from '@/assets/icons/remove.svg?react'; interface DeleteCompleteModalProps { @@ -28,4 +29,4 @@ const DeleteCompleteModal = ({ isFadingOut }: DeleteCompleteModalProps) => { ); }; -export default DeleteCompleteModal; +export default memo(DeleteCompleteModal); diff --git a/src/components/MyPage/DeviceDeleteModal.tsx b/src/components/MyPage/DeviceDeleteModal.tsx index e362bf81..90a6e5dc 100644 --- a/src/components/MyPage/DeviceDeleteModal.tsx +++ b/src/components/MyPage/DeviceDeleteModal.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import RemoveIcon from '@/assets/icons/remove.svg?react'; interface DeviceDeleteModalProps { @@ -50,4 +51,4 @@ const DeviceDeleteModal = ({ isDeleting, onConfirm, onCancel }: DeviceDeleteModa ); }; -export default DeviceDeleteModal; +export default memo(DeviceDeleteModal); diff --git a/src/components/MyPage/EmptyCombinationCard.tsx b/src/components/MyPage/EmptyCombinationCard.tsx index 78aa6019..79de2a30 100644 --- a/src/components/MyPage/EmptyCombinationCard.tsx +++ b/src/components/MyPage/EmptyCombinationCard.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, memo } from 'react'; import { useNavigate } from 'react-router-dom'; import StarIcon from '@/assets/icons/star.svg?react'; import StarXIcon from '@/assets/icons/starx.svg?react'; @@ -86,4 +86,4 @@ const EmptyCombinationCard = ({ ); }; -export default EmptyCombinationCard; +export default memo(EmptyCombinationCard); diff --git a/src/components/MyPage/MyPageSidebar.tsx b/src/components/MyPage/MyPageSidebar.tsx index 90e5e716..54362e62 100644 --- a/src/components/MyPage/MyPageSidebar.tsx +++ b/src/components/MyPage/MyPageSidebar.tsx @@ -1,4 +1,4 @@ -import { type RefObject } from 'react'; +import { type RefObject, memo } from 'react'; import { useNavigate } from 'react-router-dom'; import RoundedLifestyleTag from '@/components/Lifestyle/RoundedLifestyleTag'; import RecentlyViewedFloating from '@/components/RecentlyViewed/RecentlyViewedFloating'; @@ -85,4 +85,4 @@ const MyPageSidebar = ({ userProfile, isAuthLoading, sidebarContentRef }: MyPage ); }; -export default MyPageSidebar; +export default memo(MyPageSidebar); diff --git a/src/components/MyPage/SaveNameModal.tsx b/src/components/MyPage/SaveNameModal.tsx index f02c1b0a..6cb63ff5 100644 --- a/src/components/MyPage/SaveNameModal.tsx +++ b/src/components/MyPage/SaveNameModal.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import SaveIcon from '@/assets/icons/save.svg?react'; interface SaveNameModalProps { @@ -49,4 +50,4 @@ const SaveNameModal = ({ isSaving, onConfirm, onCancel }: SaveNameModalProps) => ); }; -export default SaveNameModal; +export default memo(SaveNameModal); From 9cc9694de1c2e3c5ae6dbce2e977e1a234a608cc Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 13:28:47 +0900 Subject: [PATCH 24/28] =?UTF-8?q?refactor:=20any=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MyPage/CombinationList.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/MyPage/CombinationList.tsx b/src/components/MyPage/CombinationList.tsx index b897eb08..f13558a8 100644 --- a/src/components/MyPage/CombinationList.tsx +++ b/src/components/MyPage/CombinationList.tsx @@ -8,7 +8,11 @@ import CombinationDetailView from '@/components/MyPage/CombinationDetailView'; import SettingMoreIcon from '@/assets/icons/settingmore.svg?react'; import AlarmIcon from '@/assets/icons/alarm.svg?react'; import { MYPAGE_SORT_OPTIONS } from '@/constants/combination'; -import type { ComboListItem } from '@/types/combo/combo'; +import type { ComboListItem, GetComboResult } from '@/types/combo/combo'; +import type { UseCombinationEditReturn } from '@/hooks/useCombinationEdit'; +import type { UseCombinationModalsReturn } from '@/hooks/useCombinationModals'; +import type { UseDeviceSelectionReturn } from '@/hooks/useDeviceSelection'; +import type { EvaluationCardUI } from '@/utils/mapEvaluationToUI'; interface CombinationListProps { combinationListRef: RefObject; @@ -16,17 +20,17 @@ interface CombinationListProps { isLoading: boolean; isError: boolean; detailViewComboId: number | null; - comboDetail: any; + comboDetail: GetComboResult | undefined; columns: 3 | 4; sortOption: string; setSortOption: (option: string) => void; openMenuIndex: number | null; setOpenMenuIndex: (index: number | null) => void; menuRef: RefObject; - combinationEdit: any; - modals: any; - deviceSelection: any; - evaluationCards: any; + combinationEdit: UseCombinationEditReturn; + modals: UseCombinationModalsReturn; + deviceSelection: UseDeviceSelectionReturn; + evaluationCards: EvaluationCardUI[] | null; isEvaluationLoading: boolean; handleDetailView: (comboId: number) => void; handleBackToNormal: () => void; From 6a16f967f780701c8c4773c4e4d2235bbeedea69 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 16:16:09 +0900 Subject: [PATCH 25/28] =?UTF-8?q?refactor:=20any=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/combination.ts | 2 +- src/hooks/useCombinationEdit.ts | 2 +- src/hooks/useCombinationModals.ts | 2 +- src/hooks/useDeviceSelection.ts | 2 +- src/pages/my/MyPage.tsx | 9 +++++---- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/constants/combination.ts b/src/constants/combination.ts index d0aaede8..3dda4978 100644 --- a/src/constants/combination.ts +++ b/src/constants/combination.ts @@ -49,4 +49,4 @@ export const MYPAGE_SORT_OPTIONS = [ { value: 'latest', label: '최근생성순' }, { value: 'oldest', label: '오래된순' }, { value: 'alphabetical', label: '가나다순' }, -] as const; +]; diff --git a/src/hooks/useCombinationEdit.ts b/src/hooks/useCombinationEdit.ts index d275784c..84bd4f71 100644 --- a/src/hooks/useCombinationEdit.ts +++ b/src/hooks/useCombinationEdit.ts @@ -1,7 +1,7 @@ import { useState, useCallback, useMemo } from 'react'; import type { ComboListItem } from '@/types/combo/combo'; -interface UseCombinationEditReturn { +export interface UseCombinationEditReturn { editingComboId: number | null; editingCombinationName: string; comboNameError: string | null; diff --git a/src/hooks/useCombinationModals.ts b/src/hooks/useCombinationModals.ts index 56bdca89..4f3b27eb 100644 --- a/src/hooks/useCombinationModals.ts +++ b/src/hooks/useCombinationModals.ts @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; -interface UseCombinationModalsReturn { +export interface UseCombinationModalsReturn { // 기기 삭제 모달 showDeleteModal: boolean; openDeleteModal: () => void; diff --git a/src/hooks/useDeviceSelection.ts b/src/hooks/useDeviceSelection.ts index 98e23346..87fa98a0 100644 --- a/src/hooks/useDeviceSelection.ts +++ b/src/hooks/useDeviceSelection.ts @@ -1,6 +1,6 @@ import { useState } from 'react'; -interface UseDeviceSelectionReturn { +export interface UseDeviceSelectionReturn { selectedDevices: number[]; handleSelectAll: (deviceIds: number[]) => void; handleSelectDevice: (deviceId: number) => void; diff --git a/src/pages/my/MyPage.tsx b/src/pages/my/MyPage.tsx index 1393cb30..b01b4ebf 100644 --- a/src/pages/my/MyPage.tsx +++ b/src/pages/my/MyPage.tsx @@ -37,9 +37,9 @@ const MyPage = () => { const [detailViewComboId, setDetailViewComboId] = useState(null); const [savedScrollPosition, setSavedScrollPosition] = useState(0); - const menuRef = useRef(null); - const sidebarContentRef = useRef(null); - const combinationListRef = useRef(null); + const menuRef = useRef(null!); + const sidebarContentRef = useRef(null!); + const combinationListRef = useRef(null!); // API 호출 const { data: combos = [], isLoading, isError } = useGetCombos(); @@ -87,7 +87,8 @@ const MyPage = () => { }, []); // 드롭다운 외부 클릭 시 닫기 - useClickOutside(menuRef, () => setOpenMenuIndex(null)); + const handleClickOutside = useCallback(() => setOpenMenuIndex(null), []); + useClickOutside(menuRef, handleClickOutside); // 모달 열릴 때 배경 스크롤 방지 const isAnyModalOpen = From 676b6ae12d15e2e51379af13cd66177e564aa46c Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 16:20:51 +0900 Subject: [PATCH 26/28] =?UTF-8?q?refactor:=20Lazy=20Loading=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=84=B1=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 41 +++++++++++++++++++++-- package.json | 2 ++ src/components/MyPage/CombinationList.tsx | 37 ++++++++++++++++++-- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index dc219916..2fddd212 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@react-oauth/google": "^0.13.4", "@tanstack/react-query": "^5.90.20", "@tanstack/react-query-devtools": "^5.91.2", + "@types/react-window": "^1.8.8", "axios": "^1.13.2", "clsx": "^2.1.1", "react": "^19.2.0", @@ -19,6 +20,7 @@ "react-hook-form": "^7.71.1", "react-router-dom": "^7.12.0", "react-spinners": "^0.17.0", + "react-window": "^2.2.6", "tailwind-merge": "^3.4.0", "zod": "^4.3.5", "zustand": "^5.0.10" @@ -75,6 +77,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1575,6 +1578,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -1928,6 +1932,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz", "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.20" }, @@ -2021,6 +2026,7 @@ "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2029,8 +2035,8 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2045,6 +2051,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.50.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.1.tgz", @@ -2090,6 +2105,7 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -2341,6 +2357,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2463,6 +2480,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2665,7 +2683,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -2897,6 +2914,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2957,6 +2975,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -4166,6 +4185,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4218,6 +4238,7 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4262,6 +4283,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4271,6 +4293,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4283,6 +4306,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -4352,6 +4376,16 @@ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-window": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.6.tgz", + "integrity": "sha512-v89O08xRdpCaEuf380B39D1C/0KgUDZA59xft6SVAjzjz/xQxSyXrgDWHymIsYI6TMrqE8WO+G0/PB9AGE8VNA==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4606,6 +4640,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4692,6 +4727,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4827,6 +4863,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 8f2465ba..49afd26e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@react-oauth/google": "^0.13.4", "@tanstack/react-query": "^5.90.20", "@tanstack/react-query-devtools": "^5.91.2", + "@types/react-window": "^1.8.8", "axios": "^1.13.2", "clsx": "^2.1.1", "react": "^19.2.0", @@ -21,6 +22,7 @@ "react-hook-form": "^7.71.1", "react-router-dom": "^7.12.0", "react-spinners": "^0.17.0", + "react-window": "^2.2.6", "tailwind-merge": "^3.4.0", "zod": "^4.3.5", "zustand": "^5.0.10" diff --git a/src/components/MyPage/CombinationList.tsx b/src/components/MyPage/CombinationList.tsx index f13558a8..f0e7b94d 100644 --- a/src/components/MyPage/CombinationList.tsx +++ b/src/components/MyPage/CombinationList.tsx @@ -1,4 +1,4 @@ -import { type RefObject } from 'react'; +import { type RefObject, useState, useMemo } from 'react'; import SecondaryButton from '@/components/Button/SecondaryButton'; import SortDropdown from '@/components/Filter/SortDropdown'; import CombinationMenu from '@/components/MyPage/CombinationMenu'; @@ -61,6 +61,27 @@ const CombinationList = ({ handleTogglePin, handleTrashClick, }: CombinationListProps) => { + // Lazy Loading: 초기 12개, 더 보기 클릭 시 12개씩 추가 + const INITIAL_DISPLAY_COUNT = 12; + const LOAD_MORE_COUNT = 12; + const [displayCount, setDisplayCount] = useState(INITIAL_DISPLAY_COUNT); + + // 실제 렌더링할 조합 목록 (Detail View가 아닐 때만 Lazy Loading 적용) + const displayedCombos = useMemo(() => { + if (detailViewComboId !== null) { + // Detail View: 모든 조합 표시 (필터링된 조합 찾기 위해) + return sortedCombos; + } + // Normal View: displayCount만큼만 표시 + return sortedCombos.slice(0, displayCount); + }, [sortedCombos, displayCount, detailViewComboId]); + + const hasMore = sortedCombos.length > displayCount && detailViewComboId === null; + + const handleLoadMore = () => { + setDisplayCount((prev) => prev + LOAD_MORE_COUNT); + }; + return (
{isLoading && ( @@ -78,7 +99,7 @@ const CombinationList = ({

등록된 조합이 없습니다.

)} - {sortedCombos.map((combination, index) => { + {displayedCombos.map((combination, index) => { const isDetailView = detailViewComboId === combination.comboId; const hasDevices = combination.deviceCount > 0; const devices = isDetailView && comboDetail ? comboDetail.devices : []; @@ -233,6 +254,18 @@ const CombinationList = ({
); })} + + {/* 더 보기 버튼 */} + {hasMore && ( +
+ +
+ )}
); }; From d4c39839b80aebe823b95f683bb2320cf6c100a0 Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 16:29:56 +0900 Subject: [PATCH 27/28] =?UTF-8?q?fix:=20=EB=B9=8C=EB=93=9C=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MyPage/CombinationDetailView.tsx | 3 ++- src/components/MyPage/MyPageSidebar.tsx | 4 ++-- src/constants/mockData.ts | 1 - src/hooks/useCombinationSort.ts | 8 ++++++-- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/MyPage/CombinationDetailView.tsx b/src/components/MyPage/CombinationDetailView.tsx index b3d64ea2..40ff6be0 100644 --- a/src/components/MyPage/CombinationDetailView.tsx +++ b/src/components/MyPage/CombinationDetailView.tsx @@ -12,6 +12,7 @@ import CombinationEvaluationCard from '@/components/Combination/CombinationEvalu import { formatDate } from '@/utils/format'; import type { ComboListItem } from '@/types/combo/combo'; import type { CombinationName } from '@/constants/combination'; +import type { Grade } from '@/constants/evaluation/grade'; interface Device { deviceId: number; @@ -221,7 +222,7 @@ const CombinationDetailView = ({ diff --git a/src/components/MyPage/MyPageSidebar.tsx b/src/components/MyPage/MyPageSidebar.tsx index 54362e62..a3dabcc4 100644 --- a/src/components/MyPage/MyPageSidebar.tsx +++ b/src/components/MyPage/MyPageSidebar.tsx @@ -6,10 +6,10 @@ import SettingIcon from '@/assets/icons/setting.svg?react'; import SupportIcon from '@/assets/icons/support.svg?react'; import Logo from '@/assets/logos/logo.svg?react'; import { formatDate } from '@/utils/format'; -import type { UserProfile } from '@/types/mypage/user'; +import type { UserProfileResult } from '@/types/mypage/user'; interface MyPageSidebarProps { - userProfile: UserProfile | null; + userProfile: UserProfileResult | null; isAuthLoading: boolean; sidebarContentRef: RefObject; } diff --git a/src/constants/mockData.ts b/src/constants/mockData.ts index 338307d9..c43de32f 100644 --- a/src/constants/mockData.ts +++ b/src/constants/mockData.ts @@ -1,4 +1,3 @@ -import { type UserCombination } from '@/types/devices'; export interface Product { id: number; diff --git a/src/hooks/useCombinationSort.ts b/src/hooks/useCombinationSort.ts index a6002b95..5e574c3e 100644 --- a/src/hooks/useCombinationSort.ts +++ b/src/hooks/useCombinationSort.ts @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useCallback } from 'react'; import type { ComboListItem } from '@/types/combo/combo'; type SortOption = 'latest' | 'oldest' | 'alphabetical'; @@ -10,7 +10,11 @@ interface UseCombinationSortReturn { } export const useCombinationSort = (combos: ComboListItem[]): UseCombinationSortReturn => { - const [sortOption, setSortOption] = useState('latest'); + const [sortOption, setSortOptionInternal] = useState('latest'); + + const setSortOption = useCallback((option: string) => { + setSortOptionInternal(option as SortOption); + }, []); // 정렬된 조합 목록 const sortedCombos = useMemo(() => { From 5b80d59703d8f1c1d1b44a61d709aae20a86f34e Mon Sep 17 00:00:00 2001 From: hun Date: Thu, 12 Feb 2026 16:30:40 +0900 Subject: [PATCH 28/28] =?UTF-8?q?refactor:=20mockdata.ts=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=B0=8F=20=EC=83=88=EB=A1=9C=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/DeviceSearch/DeviceDetailModal.tsx | 2 +- src/components/ProductCard/ProductCard.tsx | 2 +- src/{constants/mockData.ts => types/product.ts} | 9 --------- src/utils/mapSearchDevice.ts | 2 +- 4 files changed, 3 insertions(+), 12 deletions(-) rename src/{constants/mockData.ts => types/product.ts} (51%) diff --git a/src/components/DeviceSearch/DeviceDetailModal.tsx b/src/components/DeviceSearch/DeviceDetailModal.tsx index 86bf46d0..0b87b5c9 100644 --- a/src/components/DeviceSearch/DeviceDetailModal.tsx +++ b/src/components/DeviceSearch/DeviceDetailModal.tsx @@ -1,4 +1,4 @@ -import type { Product } from '@/constants/mockData'; +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'; diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx index 0d3d5e8b..21761a98 100644 --- a/src/components/ProductCard/ProductCard.tsx +++ b/src/components/ProductCard/ProductCard.tsx @@ -1,4 +1,4 @@ -import type { Product } from '@/constants/mockData'; +import type { Product } from '@/types/product'; interface ProductCardProps { product: Product; diff --git a/src/constants/mockData.ts b/src/types/product.ts similarity index 51% rename from src/constants/mockData.ts rename to src/types/product.ts index c43de32f..8d12cc4b 100644 --- a/src/constants/mockData.ts +++ b/src/types/product.ts @@ -1,4 +1,3 @@ - export interface Product { id: number; name: string; @@ -7,11 +6,3 @@ export interface Product { image: string | null; colors: string[]; } - -export interface DeviceSummary { - id: number; - name: string; - chargingType: string; - color: string; - image: string | null; -} diff --git a/src/utils/mapSearchDevice.ts b/src/utils/mapSearchDevice.ts index d264be60..f002bf06 100644 --- a/src/utils/mapSearchDevice.ts +++ b/src/utils/mapSearchDevice.ts @@ -1,5 +1,5 @@ import type { SearchDevice } from '@/types/devices'; -import type { Product } from '@/constants/mockData'; +import type { Product } from '@/types/product'; // SearchDevice를 Product 형식으로 변환 export const mapSearchDeviceToProduct = (device: SearchDevice): Product => {