diff --git a/src/app/(with-header)/mypage/dashboard/page.tsx b/src/app/(with-header)/mypage/dashboard/page.tsx index c3e3645..c6aa7c4 100644 --- a/src/app/(with-header)/mypage/dashboard/page.tsx +++ b/src/app/(with-header)/mypage/dashboard/page.tsx @@ -20,11 +20,13 @@ export default function MyDashboardPage() { // 내 체험 리스트 조회 const { data: activitiesData, isLoading, error } = useMyActivities(); - const { activityOptions, uniqueTitles, handleActivityChange } = - useActivityOptions(activitiesData, (activityId) => { + const { activityOptions, handleActivityChange } = useActivityOptions( + activitiesData, + (activityId) => { setSelectedActivityId(activityId); setSelectedDate(''); - }); + }, + ); // 페이지 로드 시 첫 번째 체험 자동 선택 useEffect(() => { @@ -38,11 +40,8 @@ export default function MyDashboardPage() { } }, [activitiesData, selectedActivityId]); - // 현재 선택된 체험의 제목 찾기 - const selectedActivityTitle = - activityOptions.find( - (option) => parseInt(option.value) === selectedActivityId, - )?.label || ''; + // 현재 선택된 체험의 ID를 문자열로 변환 + const selectedActivityValue = selectedActivityId?.toString() || ''; // 날짜 클릭 (모달 열기) const handleDateClick = (date: string) => { @@ -118,8 +117,9 @@ export default function MyDashboardPage() { {/* 체험 선택 드롭다운 */}
{ if (reviewModal.reservationId) { + const reservation = allReservations.find( + (r) => r.id === reviewModal.reservationId, + ); + createReviewMutation.mutate( { reservationId: reviewModal.reservationId, @@ -109,16 +117,10 @@ export default function MyReservationsPage() { }, { onSuccess: () => { - setReviewModal({ - isOpen: false, - reservationId: null, - activityTitle: null, - activityImage: null, - activityDate: null, - activityTime: null, - headCount: null, - totalPrice: null, - }); + // 성공 후 추가 쿼리 무효화 + if (reservation?.activity.id) { + invalidateActivityQueries(reservation.activity.id); + } }, }, ); diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index e6905d2..4fc0495 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -5,7 +5,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import cn from '@lib/cn'; import useOutsideClick from '@hooks/useOutsideClick'; import ChevronIcon from '@assets/svg/chevron'; -import { DropdownProps } from '@/types/dropdownTypes'; +import { DropdownProps, DropdownOption } from '@/types/dropdownTypes'; // import CheckIcon from '@assets/svg/check'; /** @@ -28,6 +28,7 @@ import { DropdownProps } from '@/types/dropdownTypes'; */ export default function Dropdown({ options, + optionData, value, onChange, placeholder, @@ -47,10 +48,21 @@ export default function Dropdown({ const dropdownRef = useRef(null); const buttonRef = useRef(null); + // optionData가 제공되면 우선 사용, 없으면 options 사용 + const useOptionData = !!optionData; + const finalOptions = useOptionData + ? optionData! + : options.map(opt => ({ value: opt, label: opt })); + // 내/외부 상태 관리 판별 const isControlled = value !== undefined; const selectedValue = isControlled ? value : internalValue; + // 선택된 값에 해당하는 표시 텍스트 찾기 + const displayValue = useOptionData + ? finalOptions.find(opt => opt.value === selectedValue)?.label || '' + : selectedValue; + // 외부 클릭 감지 useOutsideClick(dropdownRef, () => { setIsOpen(false); @@ -58,11 +70,12 @@ export default function Dropdown({ }); // 값 선택 핸들러 - const handleSelect = (option: T) => { + const handleSelect = (optionData: DropdownOption) => { + const selectedValue = optionData.value as T; if (!isControlled) { - setInternalValue(option); + setInternalValue(selectedValue); } - onChange?.(option); + onChange?.(selectedValue); setIsOpen(false); setFocusedIndex(-1); buttonRef.current?.focus(); @@ -77,7 +90,7 @@ export default function Dropdown({ case ' ': e.preventDefault(); if (isOpen && focusedIndex >= 0) { - handleSelect(options[focusedIndex]); + handleSelect(finalOptions[focusedIndex]); } else { setIsOpen(!isOpen); } @@ -88,7 +101,7 @@ export default function Dropdown({ if (!isOpen) { setIsOpen(true); } else { - setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0)); + setFocusedIndex((prev) => (prev < finalOptions.length - 1 ? prev + 1 : 0)); } break; @@ -97,7 +110,7 @@ export default function Dropdown({ if (!isOpen) { setIsOpen(true); } else { - setFocusedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1)); + setFocusedIndex((prev) => (prev > 0 ? prev - 1 : finalOptions.length - 1)); } break; @@ -149,7 +162,7 @@ export default function Dropdown({ truncateText && 'flex-1 truncate text-left', )} > - {selectedValue || placeholder} + {displayValue || placeholder} ({ listboxClassName, )} > - {options.map((option, index) => { - const isSelected = option === selectedValue; + {finalOptions.map((optionItem, index) => { + const isSelected = optionItem.value === selectedValue; const isFocused = index === focusedIndex; return (
  • ({ : 'hover:bg-gray-100', optionClassName, )} - onClick={() => handleSelect(option)} + onClick={() => handleSelect(optionItem)} onMouseEnter={() => setFocusedIndex(index)} > {/* 아이콘 영역 */} @@ -211,7 +224,7 @@ export default function Dropdown({ /> )}
  • */} - {option} + {optionItem.label} ); })} diff --git a/src/hooks/useActivityOptions.ts b/src/hooks/useActivityOptions.ts index 386e1f4..a83abb3 100644 --- a/src/hooks/useActivityOptions.ts +++ b/src/hooks/useActivityOptions.ts @@ -15,57 +15,43 @@ export const useActivityOptions = ( ) => { const queryClient = useQueryClient(); - const { activityOptions, uniqueTitles } = useMemo(() => { + const activityOptions = useMemo(() => { if (!activitiesData?.activities) { - return { - activityOptions: [] as ActivityOption[], - uniqueTitles: [] as string[], - }; + return [] as ActivityOption[]; } - const options: ActivityOption[] = activitiesData.activities.map( + return activitiesData.activities.map( (activity: { id: number; title: string }) => ({ value: activity.id.toString(), label: activity.title, }), ); - - const uniqueTitles = options.map((option) => option.label); - - return { activityOptions: options, uniqueTitles }; }, [activitiesData]); // 체험 변경 시 쿼리 무효화 함수 - const handleActivityChange = (selectedTitle: string): void => { - const selectedOption = activityOptions.find( - (option: ActivityOption) => option.label === selectedTitle, - ); - - if (selectedOption) { - const activityId = parseInt(selectedOption.value); - - // 관련 쿼리들 무효화 - queryClient.invalidateQueries({ - queryKey: ['reservedSchedules'], - exact: false, - }); - queryClient.invalidateQueries({ - queryKey: ['activityReservations'], - exact: false, - }); - queryClient.invalidateQueries({ - queryKey: ['monthlyReservationDashboard'], - exact: false, - }); - - // 콜백 호출 - onActivityChange?.(activityId); - } + const handleActivityChange = (selectedValue: string): void => { + const activityId = parseInt(selectedValue); + + // 관련 쿼리들 무효화 + queryClient.invalidateQueries({ + queryKey: ['reservedSchedules'], + exact: false, + }); + queryClient.invalidateQueries({ + queryKey: ['activityReservations'], + exact: false, + }); + queryClient.invalidateQueries({ + queryKey: ['monthlyReservationDashboard'], + exact: false, + }); + + // 콜백 호출 + onActivityChange?.(activityId); }; return { activityOptions, - uniqueTitles, handleActivityChange, }; }; diff --git a/src/hooks/useMyActivitiesQueries.ts b/src/hooks/useMyActivitiesQueries.ts index 59d7ae6..27fb0b3 100644 --- a/src/hooks/useMyActivitiesQueries.ts +++ b/src/hooks/useMyActivitiesQueries.ts @@ -10,6 +10,7 @@ import { deleteMyActivity, } from '@/apis/myActivities'; import { toast } from 'sonner'; +import { AxiosError } from 'axios'; export const MY_ACTIVITIES_QUERY_KEYS = { ALL: ['my-activities'] as const, @@ -54,7 +55,25 @@ export const useDeleteMyActivity = () => { toast.success('체험이 삭제되었습니다.'); }, onError: (error) => { - toast.error(`체험 삭제 실패: ${error.message}`); + const axiosError = error as AxiosError; + const status = axiosError.response?.status; + + switch (status) { + case 400: + toast.error('예약이 있는 체험은 삭제할 수 없습니다.'); + break; + case 401: + toast.error('로그인이 필요합니다.'); + break; + case 403: + toast.error('삭제 권한이 없습니다.'); + break; + case 404: + toast.error('존재하지 않는 체험입니다.'); + break; + default: + toast.error('체험 삭제에 실패했습니다. 다시 시도해주세요.'); + } }, }); }; diff --git a/src/hooks/useReservationQueries.ts b/src/hooks/useReservationQueries.ts index 55d4e49..14f67bb 100644 --- a/src/hooks/useReservationQueries.ts +++ b/src/hooks/useReservationQueries.ts @@ -74,10 +74,11 @@ export const useCreateReview = () => { data: CreateReviewRequest; }) => createReview(reservationId, data), onSuccess: () => { - // 예약 리스트 캐시 무효화하여 reviewSubmitted 상태 업데이트 + // 기존 쿼리 무효화 queryClient.invalidateQueries({ queryKey: RESERVATION_QUERY_KEYS.RESERVATIONS, }); + toast.success('후기가 작성되었습니다.'); }, onError: (error) => { @@ -85,3 +86,25 @@ export const useCreateReview = () => { }, }); }; + +// 쿼리 무효화 훅 +export const useInvalidateActivityQueries = () => { + const queryClient = useQueryClient(); + + return (activityId: number) => { + queryClient.invalidateQueries({ + queryKey: ['popularExperiences'], + }); + queryClient.invalidateQueries({ + queryKey: ['experiences'], + exact: false, + }); + queryClient.invalidateQueries({ + queryKey: ['activity', activityId.toString()], + }); + queryClient.invalidateQueries({ + queryKey: ['reviews'], + exact: false, + }); + }; +}; diff --git a/src/types/dropdownTypes.ts b/src/types/dropdownTypes.ts index c1fec3f..b65123b 100644 --- a/src/types/dropdownTypes.ts +++ b/src/types/dropdownTypes.ts @@ -1,5 +1,10 @@ import { ClassValue } from 'clsx'; +export interface DropdownOption { + value: string; + label: string; +} + /** * 범용 Dropdown 컴포넌트의 props 정의입니다. * 제네릭 타입 T를 사용하여 다양한 문자열 옵션 타입을 지원합니다. @@ -16,6 +21,7 @@ import { ClassValue } from 'clsx'; */ export interface DropdownProps { options: readonly T[]; + optionData?: readonly DropdownOption[]; value?: T | ''; onChange?: (value: T) => void; placeholder?: string;