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/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/MyPage/CombinationCard.tsx b/src/components/MyPage/CombinationCard.tsx new file mode 100644 index 00000000..2f67f52a --- /dev/null +++ b/src/components/MyPage/CombinationCard.tsx @@ -0,0 +1,171 @@ +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'; +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 memo(CombinationCard); diff --git a/src/components/MyPage/CombinationDeleteModal.tsx b/src/components/MyPage/CombinationDeleteModal.tsx new file mode 100644 index 00000000..afd8a4d9 --- /dev/null +++ b/src/components/MyPage/CombinationDeleteModal.tsx @@ -0,0 +1,62 @@ +import { memo } from 'react'; +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 memo(CombinationDeleteModal); diff --git a/src/components/MyPage/CombinationDetailView.tsx b/src/components/MyPage/CombinationDetailView.tsx new file mode 100644 index 00000000..40ff6be0 --- /dev/null +++ b/src/components/MyPage/CombinationDetailView.tsx @@ -0,0 +1,245 @@ +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'; +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'; +import type { Grade } from '@/constants/evaluation/grade'; + +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 memo(CombinationDetailView); diff --git a/src/components/MyPage/CombinationList.tsx b/src/components/MyPage/CombinationList.tsx new file mode 100644 index 00000000..f0e7b94d --- /dev/null +++ b/src/components/MyPage/CombinationList.tsx @@ -0,0 +1,273 @@ +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'; +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, 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; + sortedCombos: ComboListItem[]; + isLoading: boolean; + isError: boolean; + detailViewComboId: number | null; + comboDetail: GetComboResult | undefined; + columns: 3 | 4; + sortOption: string; + setSortOption: (option: string) => void; + openMenuIndex: number | null; + setOpenMenuIndex: (index: number | null) => void; + menuRef: RefObject; + combinationEdit: UseCombinationEditReturn; + modals: UseCombinationModalsReturn; + deviceSelection: UseDeviceSelectionReturn; + evaluationCards: EvaluationCardUI[] | null; + 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) => { + // 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 && ( +
+

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

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

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

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

등록된 조합이 없습니다.

+
+ )} + {displayedCombos.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); + }} + /> + ) : ( + + )} + + )} +
+
+ ); + })} + + {/* 더 보기 버튼 */} + {hasMore && ( +
+ +
+ )} +
+ ); +}; + +export default CombinationList; diff --git a/src/components/MyPage/CombinationMenu.tsx b/src/components/MyPage/CombinationMenu.tsx new file mode 100644 index 00000000..cc4c8234 --- /dev/null +++ b/src/components/MyPage/CombinationMenu.tsx @@ -0,0 +1,68 @@ +import { useState, memo } 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 memo(CombinationMenu); diff --git a/src/components/MyPage/DeleteCompleteModal.tsx b/src/components/MyPage/DeleteCompleteModal.tsx new file mode 100644 index 00000000..830fc596 --- /dev/null +++ b/src/components/MyPage/DeleteCompleteModal.tsx @@ -0,0 +1,32 @@ +import { memo } from 'react'; +import RemoveIcon from '@/assets/icons/remove.svg?react'; + +interface DeleteCompleteModalProps { + isFadingOut: boolean; +} + +const DeleteCompleteModal = ({ isFadingOut }: DeleteCompleteModalProps) => { + return ( + <> + {/* 배경 오버레이 */} +
+ + {/* 팝업 */} +
+
+ {/* 아이콘 */} + + + {/* 텍스트 */} +

삭제 완료

+
+
+ + ); +}; + +export default memo(DeleteCompleteModal); diff --git a/src/components/MyPage/DeviceDeleteModal.tsx b/src/components/MyPage/DeviceDeleteModal.tsx new file mode 100644 index 00000000..90a6e5dc --- /dev/null +++ b/src/components/MyPage/DeviceDeleteModal.tsx @@ -0,0 +1,54 @@ +import { memo } from 'react'; +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 memo(DeviceDeleteModal); diff --git a/src/components/MyPage/EmptyCombinationCard.tsx b/src/components/MyPage/EmptyCombinationCard.tsx new file mode 100644 index 00000000..79de2a30 --- /dev/null +++ b/src/components/MyPage/EmptyCombinationCard.tsx @@ -0,0 +1,89 @@ +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'; +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 memo(EmptyCombinationCard); diff --git a/src/components/MyPage/MyPageSidebar.tsx b/src/components/MyPage/MyPageSidebar.tsx new file mode 100644 index 00000000..a3dabcc4 --- /dev/null +++ b/src/components/MyPage/MyPageSidebar.tsx @@ -0,0 +1,88 @@ +import { type RefObject, memo } 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 { UserProfileResult } from '@/types/mypage/user'; + +interface MyPageSidebarProps { + userProfile: UserProfileResult | null; + isAuthLoading: boolean; + sidebarContentRef: RefObject; +} + +const MyPageSidebar = ({ userProfile, isAuthLoading, sidebarContentRef }: MyPageSidebarProps) => { + const navigate = useNavigate(); + + return ( + + ); +}; + +export default memo(MyPageSidebar); diff --git a/src/components/MyPage/SaveNameModal.tsx b/src/components/MyPage/SaveNameModal.tsx new file mode 100644 index 00000000..6cb63ff5 --- /dev/null +++ b/src/components/MyPage/SaveNameModal.tsx @@ -0,0 +1,53 @@ +import { memo } from 'react'; +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 memo(SaveNameModal); 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/combination.ts b/src/constants/combination.ts index ef05bee6..3dda4978 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: '가나다순' }, +]; diff --git a/src/constants/mockData.ts b/src/constants/mockData.ts deleted file mode 100644 index bc529339..00000000 --- a/src/constants/mockData.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { type UserCombination } from '@/types/devices'; - -export interface Product { - id: number; - name: string; - category: string; - price: number; - image: string | null; - colors: string[]; -} - -export interface DeviceSummary { - id: number; - name: string; - chargingType: string; - 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/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/hooks/useCombinationEdit.ts b/src/hooks/useCombinationEdit.ts new file mode 100644 index 00000000..84bd4f71 --- /dev/null +++ b/src/hooks/useCombinationEdit.ts @@ -0,0 +1,106 @@ +import { useState, useCallback, useMemo } from 'react'; +import type { ComboListItem } from '@/types/combo/combo'; + +export 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, + }; +}; diff --git a/src/hooks/useCombinationModals.ts b/src/hooks/useCombinationModals.ts new file mode 100644 index 00000000..4f3b27eb --- /dev/null +++ b/src/hooks/useCombinationModals.ts @@ -0,0 +1,119 @@ +import { useState, useEffect } from 'react'; + +export 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), + }; +}; diff --git a/src/hooks/useCombinationSort.ts b/src/hooks/useCombinationSort.ts new file mode 100644 index 00000000..5e574c3e --- /dev/null +++ b/src/hooks/useCombinationSort.ts @@ -0,0 +1,53 @@ +import { useState, useMemo, useCallback } 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, setSortOptionInternal] = useState('latest'); + + const setSortOption = useCallback((option: string) => { + setSortOptionInternal(option as SortOption); + }, []); + + // 정렬된 조합 목록 + 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, + }; +}; diff --git a/src/hooks/useDeviceSelection.ts b/src/hooks/useDeviceSelection.ts new file mode 100644 index 00000000..87fa98a0 --- /dev/null +++ b/src/hooks/useDeviceSelection.ts @@ -0,0 +1,40 @@ +import { useState } from 'react'; + +export 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, + }; +}; 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/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/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, -// }; -// }; diff --git a/src/pages/my/MyPage.tsx b/src/pages/my/MyPage.tsx index b1e3a079..b01b4ebf 100644 --- a/src/pages/my/MyPage.tsx +++ b/src/pages/my/MyPage.tsx @@ -2,27 +2,14 @@ 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'; -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 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 MyPageSidebar from '@/components/MyPage/MyPageSidebar'; +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 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,53 +17,29 @@ 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 { useClickOutside } from '@/hooks/useClickOutside'; +import { useModalScrollLock } from '@/hooks/useModalScrollLock'; +import { useMyPageScroll } from '@/hooks/useMyPageScroll'; import { mapEvaluationToUI } from '@/utils/mapEvaluationToUI'; 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); + + const menuRef = useRef(null!); + const sidebarContentRef = useRef(null!); + const combinationListRef = useRef(null!); // API 호출 const { data: combos = [], isLoading, isError } = useGetCombos(); @@ -86,69 +49,31 @@ 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 + ); - // 유저 라이프스타일 태그 → LifestyleKey 변환 ("# Office" → "Office") + // 커스텀 훅 + const modals = useCombinationModals(); + const deviceSelection = useDeviceSelection(); + const { sortOption, setSortOption, sortedCombos } = useCombinationSort(combos); + const combinationEdit = useCombinationEdit(combos); + + // 유저 라이프스타일 태그 변환 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 = () => { - 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); - }, []); + const { isAtBottom, showTopButton } = useMyPageScroll(combinationListRef); // 브레이크포인트 감지 (칼럼 수 반응형) useEffect(() => { @@ -162,315 +87,126 @@ const MyPage = () => { }, []); // 드롭다운 외부 클릭 시 닫기 - useEffect(() => { - 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; - - 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 = ''; - document.body.style.top = ''; - document.body.style.width = ''; - document.body.style.overflow = ''; - if (scrollY) { - 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); - } - }; + const handleClickOutside = useCallback(() => setOpenMenuIndex(null), []); + useClickOutside(menuRef, handleClickOutside); + + // 모달 열릴 때 배경 스크롤 방지 + const isAnyModalOpen = + modals.showDeleteModal || + modals.showCombinationDeleteModal || + modals.showSaveModal || + modals.showDeleteSuccessModal || + modals.showSaveSuccessModal; + useModalScrollLock(isAnyModalOpen); // 자세히보기 클릭 핸들러 - const handleDetailView = (comboId: number) => { + const handleDetailView = useCallback((comboId: number) => { setSavedScrollPosition(window.scrollY); setDetailViewComboId(comboId); setOpenMenuIndex(null); - setSelectedDevices([]); + deviceSelection.clearSelection(); window.scrollTo(0, 0); - }; + }, [deviceSelection]); // 뒤로가기 핸들러 - const handleBackToNormal = () => { + const handleBackToNormal = useCallback(() => { 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] - ); - }; + }, [deviceSelection, savedScrollPosition]); // 선택된 기기 삭제 핸들러 - const handleDeleteDevices = async () => { - if (!detailViewComboId || selectedDevices.length === 0) return; + const handleDeleteDevices = useCallback(async () => { + 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); - // 에러가 발생해도 모달은 닫지 않고 사용자에게 재시도 기회 제공 + } catch { + // 기기 삭제 실패 시 조용히 처리 } - }; + }, [detailViewComboId, deviceSelection, deleteDevice, modals]); // 휴지통 클릭 핸들러 - const handleTrashClick = () => { - if (selectedDevices.length > 0) { - setShowDeleteModal(true); + const handleTrashClick = useCallback(() => { + if (deviceSelection.selectedDevices.length > 0) { + modals.openDeleteModal(); } - }; + }, [deviceSelection.selectedDevices.length, modals]); // 조합 삭제 핸들러 - const handleDeleteCombination = () => { - if (deleteTargetComboId === null) return; + const handleDeleteCombination = useCallback(() => { + 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) => { - console.error('조합 삭제 실패:', error); - }, }); - }; + }, [modals, deleteCombo, detailViewComboId, deviceSelection]); // 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); - }, - }); - }; + const handleTogglePin = useCallback((e: React.MouseEvent, comboId: number) => { + e.stopPropagation(); + togglePin(comboId); + }, [togglePin]); // 조합명 저장 핸들러 - const handleSaveCombinationName = () => { - if (editingComboId === null) return; + const handleSaveCombinationName = useCallback(() => { + 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) => { - console.error('조합명 수정 실패:', error); - }, } ); - }; + }, [combinationEdit, updateCombo, modals]); // 맨 위로 스크롤 - const handleScrollToTop = () => { + const handleScrollToTop = useCallback(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); - }; + }, []); return (
@@ -478,70 +214,11 @@ const MyPage = () => {
{/* 좌측 사이드바 */} - + {/* 우측 메인 콘텐츠 */}
{

내 조합

{detailViewComboId !== null ? (
{/* 조합 카드 목록 */} -
- {isLoading && ( -
-

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

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

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

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

등록된 조합이 없습니다.

-
- )} - {sortedCombos.map((combination, index) => { - const isDetailView = detailViewComboId === combination.comboId; - const hasDevices = combination.deviceCount > 0; - - // 상세보기 모드: 선택된 조합만 표시하고, 상세 정보의 devices 사용 - 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 && - 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 && ( -
- {editingComboId === combination.comboId ? ( - /* 수정 모드: 저장하기 버튼 */ - { - // 최종 검증 후 모달 표시 - const error = validateComboName(editingCombinationName); - setComboNameError(error); - - if (!error) { - setShowSaveModal(true); - } - }} - disabled={ - !isComboNameValid || editingCombinationName.trim().length === 0 - } - className="w-150" - /> - ) : ( - /* 일반 모드: ... 버튼 */ - - )} - - {/* 드롭다운 메뉴 */} - {openMenuIndex === combination.comboId && ( -
- {/* 삭제하기 */} - - - {/* 조합명 수정하기 */} - - - {/* 자세히보기 - 기기가 있을 때만 표시 */} - {hasDevices && ( - - )} -
- )} -
- )} - - {/* 상세보기 모드 */} - {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 && ( -
- )} - - ); - })()} -
-
- ) : ( -
- {/* 조합 정보 (생성일 포함) */} -
- {/* 조합 번호 + 생성일 + 조합명 */} -
-
-

조합{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에서 태그 정보 제공 시 구현 */} -
- - {/* 빈 조합: 기기 추가 버튼 */} -
- -
-
- )} - - )} -
-
- ); - })} -
+ - {/* 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; diff --git a/src/types/product.ts b/src/types/product.ts new file mode 100644 index 00000000..8d12cc4b --- /dev/null +++ b/src/types/product.ts @@ -0,0 +1,8 @@ +export interface Product { + id: number; + name: string; + category: string; + price: number; + image: string | null; + colors: string[]; +} 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}`; +} 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 => {