diff --git a/src/app/(with-header)/activities/[id]/hooks/useDeleteActivity.ts b/src/app/(with-header)/activities/[id]/hooks/useDeleteActivity.ts index 3c293585..7d6dfff4 100644 --- a/src/app/(with-header)/activities/[id]/hooks/useDeleteActivity.ts +++ b/src/app/(with-header)/activities/[id]/hooks/useDeleteActivity.ts @@ -2,6 +2,7 @@ import { privateInstance } from '@/apis/privateInstance'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; const deleteActivity = async (id: string) => { const response = await privateInstance.delete(`/deleteActivity/${id}`); @@ -15,9 +16,12 @@ export const useDeleteActivity = () => { return useMutation({ mutationFn: deleteActivity, onSuccess: (_data) => { - queryClient.invalidateQueries({ queryKey: ['activity'] }); // 내 체험 관리 - queryClient.invalidateQueries({ queryKey: ['experiences'], exact: false }); // 모든 체험 리스트 - queryClient.invalidateQueries({ queryKey: ['popularExperiences'] }); // 인기 체험 + queryClient.invalidateQueries({ queryKey: ['activity'] }); + queryClient.invalidateQueries({ + queryKey: ['experiences'], + exact: false, + }); + queryClient.invalidateQueries({ queryKey: ['popularExperiences'] }); router.push(`/`); }, onError: (error: AxiosError) => { @@ -27,8 +31,7 @@ export const useDeleteActivity = () => { console.error('전체 에러:', error); - alert( - //토스트로 대체 + toast.error( responseData?.error || responseData?.message || error.message || diff --git a/src/app/(with-header)/mypage/activities/components/ActivityCard.tsx b/src/app/(with-header)/mypage/activities/components/ActivityCard.tsx index 47b3e1af..aa64a42c 100644 --- a/src/app/(with-header)/mypage/activities/components/ActivityCard.tsx +++ b/src/app/(with-header)/mypage/activities/components/ActivityCard.tsx @@ -21,6 +21,10 @@ export default function ActivityCard({ const { id, title, price, bannerImageUrl, rating, reviewCount } = activity; + const handleCardClick = () => { + router.push(`/activities/${id}`); + }; + const handleEdit = () => { router.push(`/myactivity/${id}`); }; @@ -31,7 +35,10 @@ export default function ActivityCard({ }; return ( -
+
{/* 이미지 영역 */}
{title} @@ -68,7 +75,10 @@ export default function ActivityCard({ {/* 더보기 옵션 */}
@@ -157,7 +184,34 @@ export default function MyActivitiesPage() { {/* 무한 스크롤 로딩 */} {isFetchingNextPage && ( -
+
+ {/* 이미지 영역 스켈레톤 */} +
+ + {/* 콘텐츠 영역 스켈레톤 */} +
+ {/* 별점 및 리뷰 스켈레톤 */} +
+
+
+
+
+
+
+ + {/* 제목 스켈레톤 */} +
+ + {/* 가격 + 더보기 버튼 영역 스켈레톤 */} +
+ {/* 가격 스켈레톤 */} +
+ + {/* 더보기 버튼 스켈레톤 */} +
+
+
+
)}
)} diff --git a/src/app/(with-header)/mypage/dashboard/components/ReservationInfoModal.tsx b/src/app/(with-header)/mypage/dashboard/components/ReservationInfoModal.tsx index 8d8465ce..174587b5 100644 --- a/src/app/(with-header)/mypage/dashboard/components/ReservationInfoModal.tsx +++ b/src/app/(with-header)/mypage/dashboard/components/ReservationInfoModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useReservedSchedules, useActivityReservations, @@ -9,7 +9,7 @@ import { import { DashboardFilterOption } from '@/types/dashboardTypes'; import { DASHBOARD_TAB_OPTIONS } from '@/constants/dashboardConstants'; import { - createTimeSlotOptions, + createFilteredTimeSlotOptions, getScheduleIdFromTimeSlot, getSelectedTimeSlotValue, } from '@/utils/timeSlotUtils'; @@ -17,6 +17,7 @@ import Dropdown from '@/components/Dropdown'; import ReservationActionButtons from './ReservationActionButtons'; import dayjs from 'dayjs'; import CloseIcon from '@assets/svg/close'; +import { toast } from 'sonner'; interface Props { isOpen: boolean; @@ -51,12 +52,26 @@ export default function ReservationInfoModal({ // 예약 상태 업데이트 const updateReservationMutation = useUpdateActivityReservationStatus(); - const timeSlotOptions = createTimeSlotOptions(schedules); + const timeSlotOptions = createFilteredTimeSlotOptions(schedules, activeTab); const selectedTimeSlotValue = getSelectedTimeSlotValue( schedules, selectedScheduleId, ); + // 탭 변경 시 현재 선택된 시간대가 유효한지 확인하고 초기화 + useEffect(() => { + if (selectedScheduleId && schedules) { + const currentSchedule = schedules.find( + (schedule) => schedule.scheduleId === selectedScheduleId, + ); + + // 현재 선택된 시간대가 새 탭에서 유효하지 않으면 초기화 + if (!currentSchedule || currentSchedule.count[activeTab] === 0) { + setSelectedScheduleId(null); + } + } + }, [activeTab, schedules, selectedScheduleId]); + // 예약 승인/거절 처리 const handleReservationAction = async ( reservationId: number, @@ -69,7 +84,7 @@ export default function ReservationInfoModal({ data: { status }, }); } catch { - alert('예약 처리에 실패했습니다.'); + toast.error('예약 처리에 실패했습니다.'); } }; diff --git a/src/app/(with-header)/mypage/dashboard/page.tsx b/src/app/(with-header)/mypage/dashboard/page.tsx index f9c15b6f..c3e3645b 100644 --- a/src/app/(with-header)/mypage/dashboard/page.tsx +++ b/src/app/(with-header)/mypage/dashboard/page.tsx @@ -9,6 +9,7 @@ import { useActivityOptions } from '@/hooks/useActivityOptions'; import EmptyDashboard from './components/EmptyDashboard'; import ReservationDashboardCalendar from './components/ReservationDashboardCalendar'; import ReservationInfoModal from './components/ReservationInfoModal'; +import CalendarSkeleton from './components/CalendarSkeleton'; export default function MyDashboardPage() { const [selectedActivityId, setSelectedActivityId] = useState( @@ -19,7 +20,11 @@ export default function MyDashboardPage() { // 내 체험 리스트 조회 const { data: activitiesData, isLoading, error } = useMyActivities(); - const { activityOptions, uniqueTitles } = useActivityOptions(activitiesData); + const { activityOptions, uniqueTitles, handleActivityChange } = + useActivityOptions(activitiesData, (activityId) => { + setSelectedActivityId(activityId); + setSelectedDate(''); + }); // 페이지 로드 시 첫 번째 체험 자동 선택 useEffect(() => { @@ -33,18 +38,6 @@ export default function MyDashboardPage() { } }, [activitiesData, selectedActivityId]); - // 체험 선택 -> 제목으로 ID 찾기 - const handleActivityChange = (selectedTitle: string) => { - const selectedOption = activityOptions.find( - (option) => option.label === selectedTitle, - ); - - if (selectedOption) { - setSelectedActivityId(parseInt(selectedOption.value)); - setSelectedDate(''); - } - }; - // 현재 선택된 체험의 제목 찾기 const selectedActivityTitle = activityOptions.find( @@ -76,7 +69,7 @@ export default function MyDashboardPage() {
{/* 달력 스켈레톤 */} -
+
); } @@ -129,7 +122,8 @@ export default function MyDashboardPage() { value={selectedActivityTitle} onChange={handleActivityChange} placeholder='체험을 선택하세요' - className='h-56' + className='h-56 min-w-0' + truncateText={true} />
diff --git a/src/app/(with-header)/mypage/layout.tsx b/src/app/(with-header)/mypage/layout.tsx index 22a07dc6..7013c353 100644 --- a/src/app/(with-header)/mypage/layout.tsx +++ b/src/app/(with-header)/mypage/layout.tsx @@ -2,7 +2,6 @@ import { ProfileNavigation } from './components'; import useResponsiveRouting from '@/hooks/useResponsiveRouting'; -import { useMyProfile } from '@/hooks/useMyPageQueries'; export default function MyPageLayout({ children, @@ -10,10 +9,9 @@ export default function MyPageLayout({ children: React.ReactNode; }) { const { mounted } = useResponsiveRouting(); - const { isLoading, error } = useMyProfile(); - // mounted + API 로딩 상태 모두 체크 - if (!mounted || isLoading) { + // mounted 상태만 체크 + if (!mounted) { return (
@@ -36,28 +34,15 @@ export default function MyPageLayout({
- {/* 메인 스켈레톤 */} -
+ {/* 메인 영역 */} +
); } - if (error) { - return ( -
-
-

- 로그인이 필요합니다 -

-

다시 로그인해주세요.

-
-
- ); - } - - // API 로딩 완료 + mounted 상태일 때만 실행 + // mounted 상태일 때만 실행 return (
@@ -66,7 +51,7 @@ export default function MyPageLayout({ {/* 우측 메인 콘텐츠 섹션 */} -
{children}
+
{children}
diff --git a/src/app/(with-header)/mypage/profile/page.tsx b/src/app/(with-header)/mypage/profile/page.tsx index 3f1bab5a..fb46eeec 100644 --- a/src/app/(with-header)/mypage/profile/page.tsx +++ b/src/app/(with-header)/mypage/profile/page.tsx @@ -5,14 +5,16 @@ import Input from '@/components/Input'; import Button from '@/components/Button'; import useMyPageStore from '@/stores/MyPage/useMyPageStore'; import { + validateNickname, validatePassword, validatePasswordConfirmation, } from '@/utils/validateInput'; -import { useUpdateProfile } from '@/hooks/useMyPageQueries'; +import { useUpdateProfile, useMyProfile } from '@/hooks/useMyPageQueries'; import { UpdateProfileRequest } from '@/types/mypageTypes'; export default function ProfilePage() { const { user } = useMyPageStore(); + const { isLoading, error } = useMyProfile(); const updateProfileMutation = useUpdateProfile(); @@ -26,10 +28,27 @@ export default function ProfilePage() { // 에러 상태 추가 const [errors, setErrors] = useState({ + nickname: '', newPassword: '', confirmPassword: '', }); + // API 요청 완료 후 비밀번호 필드 초기화 + useEffect(() => { + if (updateProfileMutation.isSuccess || updateProfileMutation.isError) { + setFormData((prev) => ({ + ...prev, + newPassword: '', + confirmPassword: '', + })); + setErrors((prev) => ({ + ...prev, + newPassword: '', + confirmPassword: '', + })); + } + }, [updateProfileMutation.isSuccess, updateProfileMutation.isError]); + // user 데이터가 로드되면 폼 업데이트 useEffect(() => { if (user) { @@ -51,28 +70,60 @@ export default function ProfilePage() { })); }; - // 비밀번호 유효성 검사 - const handlePasswordBlur = () => { + // 닉네임 유효성 검사 + const handleNicknameBlur = () => { setErrors((prev) => ({ ...prev, - newPassword: validatePassword(formData.newPassword), + nickname: validateNickname(formData.nickname), })); }; + // 비밀번호 유효성 검사 + const handlePasswordBlur = () => { + // 비밀번호를 입력한 경우에만 검증 + if (formData.newPassword) { + setErrors((prev) => ({ + ...prev, + newPassword: validatePassword(formData.newPassword), + })); + } + }; + // 비밀번호 확인 유효성 검사 const handleConfirmPasswordBlur = () => { - setErrors((prev) => ({ - ...prev, - confirmPassword: validatePasswordConfirmation( - formData.confirmPassword, - formData.newPassword, - ), - })); + // 비밀번호나 비밀번호 확인을 입력한 경우에만 검증 + if (formData.newPassword || formData.confirmPassword) { + setErrors((prev) => ({ + ...prev, + confirmPassword: validatePasswordConfirmation( + formData.confirmPassword, + formData.newPassword, + ), + })); + } + }; + + // 저장 버튼 비활성화 + const isButtonDisabled = () => { + // API 요청 중이면 비활성화 + if (updateProfileMutation.isPending) return true; + + // 닉네임 에러가 있으면 비활성화 + if (errors.nickname) return true; + + // 비밀번호를 입력했는데 에러가 있으면 비활성화 + if (formData.newPassword && errors.newPassword) return true; + + // 비밀번호 확인 에러가 있으면 비활성화 + if (formData.newPassword && errors.confirmPassword) return true; + + return false; }; // 저장 핸들러 const handleSave = () => { // 저장 전 최종 유효성 검사 + const nicknameError = validateNickname(formData.nickname); const passwordError = formData.newPassword ? validatePassword(formData.newPassword) : ''; @@ -83,8 +134,9 @@ export default function ProfilePage() { ) : ''; - if (passwordError || confirmPasswordError) { + if (nicknameError || passwordError || confirmPasswordError) { setErrors({ + nickname: nicknameError, newPassword: passwordError, confirmPassword: confirmPasswordError, }); @@ -92,7 +144,7 @@ export default function ProfilePage() { } const updateData: UpdateProfileRequest = { - nickname: formData.nickname, + nickname: formData.nickname.trim(), }; // 비밀번호가 입력된 경우에만 포함 @@ -103,19 +155,83 @@ export default function ProfilePage() { updateProfileMutation.mutate(updateData); }; + // 로딩 상태 처리 + if (isLoading) { + return ( +
+ {/* 제목과 저장하기 버튼 */} +
+
+
+
+ + {/* 폼 섹션 스켈레톤 */} +
+ {/* 닉네임 스켈레톤 */} +
+
+
+
+ + {/* 이메일 스켈레톤 */} +
+
+
+
+ + {/* 비밀번호 스켈레톤 */} +
+
+
+
+ + {/* 비밀번호 재입력 스켈레톤 */} +
+
+
+
+
+
+ ); + } + + // 에러 상태 + if (error) { + return ( + <> + {/* 제목과 저장하기 버튼 */} +
+

내 정보

+ +
+ + {/* 에러 메시지 */} +
+

사용자 정보를 불러오는데 실패했습니다.

+

{error.message}

+
+ + ); + } + return ( -
+
{/* 제목과 저장하기 버튼 */}
-

- 내 정보 -

+

내 정보

@@ -123,20 +239,22 @@ export default function ProfilePage() {
{/* 닉네임 */}
-
{/* 이메일 */}
-
diff --git a/src/app/(with-header)/mypage/reservations/components/ReviewModal.tsx b/src/app/(with-header)/mypage/reservations/components/ReviewModal.tsx index 0dd352e0..5765363d 100644 --- a/src/app/(with-header)/mypage/reservations/components/ReviewModal.tsx +++ b/src/app/(with-header)/mypage/reservations/components/ReviewModal.tsx @@ -6,6 +6,7 @@ import Modal from '@/components/Modal'; import Button from '@/components/Button'; import Rating from '@/components/Rating'; import Close from '@/../public/assets/svg/close'; +import { toast } from 'sonner'; interface ReviewModalProps { isOpen: boolean; @@ -37,11 +38,11 @@ export default function ReviewModal({ const handleSubmit = () => { if (rating === 0) { - alert('별점을 선택해주세요.'); + toast.error('별점을 선택해주세요.'); return; } if (content.trim() === '') { - alert('후기를 작성해주세요.'); + toast.error('후기를 작성해주세요.'); return; } onConfirm(rating, content); @@ -55,8 +56,12 @@ export default function ReviewModal({ return ( !open && handleClose()}> - -
+ +
{/* 헤더 */}

후기 작성

@@ -89,13 +94,13 @@ export default function ReviewModal({ {/* 체험 정보 */}
-

+

{activityTitle}

-
+
{activityDate} · {activityTime} · {headCount}명
-
+
₩{totalPrice?.toLocaleString()}
diff --git a/src/app/(with-header)/mypage/reservations/page.tsx b/src/app/(with-header)/mypage/reservations/page.tsx index 979c1ba5..b16dfced 100644 --- a/src/app/(with-header)/mypage/reservations/page.tsx +++ b/src/app/(with-header)/mypage/reservations/page.tsx @@ -45,14 +45,8 @@ export default function MyReservationsPage() { }); // 예약 리스트 조회 (무한 스크롤) - const { - data, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading, - error, - } = useMyReservations(filter || undefined); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useMyReservations(filter || undefined); // 예약 취소 뮤테이션 const cancelReservationMutation = useCancelReservation(); @@ -115,7 +109,6 @@ export default function MyReservationsPage() { }, { onSuccess: () => { - alert('후기가 작성되었습니다.'); setReviewModal({ isOpen: false, reservationId: null, @@ -127,9 +120,6 @@ export default function MyReservationsPage() { totalPrice: null, }); }, - onError: (error) => { - alert(`후기 작성 실패: ${error.message}`); - }, }, ); } @@ -167,27 +157,33 @@ export default function MyReservationsPage() { {[1, 2, 3].map((i) => (
- ))} -
-
- ); - } + className='flex h-128 w-full max-w-792 animate-pulse flex-row rounded-3xl bg-gray-200 sm:h-156 lg:h-204' + > + {/* 이미지 영역 스켈레톤 */} +
- // 에러 상태 - if (error) { - return ( -
-
-

- 예약 내역 -

- -
-
-

예약 내역을 불러오는데 실패했습니다.

-

{error.message}

+ {/* 콘텐츠 영역 스켈레톤 */} +
+ {/* 상태 라벨 스켈레톤 */} +
+ + {/* 제목 스켈레톤 */} +
+ + {/* 날짜 및 인원 정보 스켈레톤 */} +
+ + {/* 가격 + 버튼 영역 스켈레톤 */} +
+ {/* 가격 스켈레톤 */} +
+ + {/* 버튼 스켈레톤 */} +
+
+
+
+ ))}
); @@ -226,7 +222,31 @@ export default function MyReservationsPage() { {/* 무한 스크롤 로딩 */} {isFetchingNextPage && ( -
+
+ {/* 이미지 영역 스켈레톤 */} +
+ + {/* 콘텐츠 영역 스켈레톤 */} +
+ {/* 상태 라벨 스켈레톤 */} +
+ + {/* 제목 스켈레톤 */} +
+ + {/* 날짜 및 인원 정보 스켈레톤 */} +
+ + {/* 가격 + 버튼 영역 스켈레톤 */} +
+ {/* 가격 스켈레톤 */} +
+ + {/* 버튼 스켈레톤 */} +
+
+
+
)}
)} diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index aad3f90e..e6905d21 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -37,6 +37,7 @@ export default function Dropdown({ buttonClassName, listboxClassName, optionClassName, + truncateText = false, }: DropdownProps) { // 내부 상태 관리 const [internalValue, setInternalValue] = useState(''); @@ -132,6 +133,7 @@ export default function Dropdown({ 'bg-white text-lg font-normal', 'transition-all duration-200', 'focus:border-green-300 focus:outline-none', + 'overflow-hidden', disabled && 'cursor-not-allowed bg-gray-100 opacity-50', isOpen && !disabled && 'border-green-300', buttonClassName, @@ -141,7 +143,11 @@ export default function Dropdown({ aria-label={placeholder} > {selectedValue || placeholder} @@ -190,7 +196,8 @@ export default function Dropdown({ isSelected ? 'bg-nomad rounded text-white' : 'hover:bg-gray-100', - optionClassName,)} + optionClassName, + )} onClick={() => handleSelect(option)} onMouseEnter={() => setFocusedIndex(index)} > diff --git a/src/components/FloatingBox/hooks/useBookingMutation.ts b/src/components/FloatingBox/hooks/useBookingMutation.ts index fbc78c4a..ceebddcb 100644 --- a/src/components/FloatingBox/hooks/useBookingMutation.ts +++ b/src/components/FloatingBox/hooks/useBookingMutation.ts @@ -1,4 +1,4 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { privateInstance } from '@/apis/privateInstance'; import useBookingStore from '@/stores/Booking/useBookingStore'; import { toast } from 'sonner'; @@ -7,6 +7,7 @@ import { useParams } from 'next/navigation'; export function useBookingMutation(onSuccessCallback?: () => void) { const { id } = useParams(); + const queryClient = useQueryClient(); const { selectedTimeId, participants, @@ -24,6 +25,12 @@ export function useBookingMutation(onSuccessCallback?: () => void) { }); }, onSuccess: () => { + // 예약 내역 쿼리 무효화 + queryClient.invalidateQueries({ + queryKey: ['reservations'], + exact: false, + }); + toast.success('예약되었습니다!'); setSelectedDate(null); setSelectedTime(''); diff --git a/src/hooks/useActivityOptions.ts b/src/hooks/useActivityOptions.ts index ba17be5f..386e1f47 100644 --- a/src/hooks/useActivityOptions.ts +++ b/src/hooks/useActivityOptions.ts @@ -1,28 +1,71 @@ +'use client'; + +import { useQueryClient } from '@tanstack/react-query'; import { useMemo } from 'react'; import { MyActivitiesResponse } from '@/types/dashboardTypes'; -export function useActivityOptions(activitiesData?: MyActivitiesResponse) { - return useMemo(() => { - if (!activitiesData) return { activityOptions: [], uniqueTitles: [] }; - - const activityOptions = activitiesData.activities.map((activity) => { - const duplicateCount = activitiesData.activities.filter( - (a) => a.title === activity.title, - ).length; +interface ActivityOption { + value: string; + label: string; +} - const displayTitle = - duplicateCount > 1 - ? `${activity.title} (${activity.id})` - : activity.title; +export const useActivityOptions = ( + activitiesData: MyActivitiesResponse | undefined, + onActivityChange?: (activityId: number) => void, +) => { + const queryClient = useQueryClient(); + const { activityOptions, uniqueTitles } = useMemo(() => { + if (!activitiesData?.activities) { return { - value: activity.id.toString(), - label: displayTitle, + activityOptions: [] as ActivityOption[], + uniqueTitles: [] as string[], }; - }); + } + + const options: ActivityOption[] = activitiesData.activities.map( + (activity: { id: number; title: string }) => ({ + value: activity.id.toString(), + label: activity.title, + }), + ); - const uniqueTitles = activityOptions.map((option) => option.label); + const uniqueTitles = options.map((option) => option.label); - return { activityOptions, uniqueTitles }; + 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); + } + }; + + return { + activityOptions, + uniqueTitles, + handleActivityChange, + }; +}; diff --git a/src/hooks/useDashboardQueries.ts b/src/hooks/useDashboardQueries.ts index fcb3532d..943f54a8 100644 --- a/src/hooks/useDashboardQueries.ts +++ b/src/hooks/useDashboardQueries.ts @@ -6,6 +6,7 @@ import { useQueryClient, useInfiniteQuery, } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { getMyActivities, getMonthlyReservationDashboard, @@ -127,17 +128,17 @@ export const useUpdateActivityReservationStatus = () => { ], }); - // 성공 메시지 + // 성공 토스트 메시지 const statusText = { confirmed: '승인', declined: '거절', pending: '대기', }; - alert(`예약이 ${statusText[variables.data.status]}되었습니다.`); + toast.success(`예약이 ${statusText[variables.data.status]}되었습니다.`); }, onError: (error) => { - alert(`예약 상태 변경 실패: ${error.message}`); + toast.error(`예약 상태 변경 실패: ${error.message}`); }, }); }; @@ -182,10 +183,11 @@ export const useDeclineMultipleReservations = () => { variables.activityId, ], }); + toast.success('선택한 예약이 거절되었습니다.'); }, onError: (error) => { - alert(`예약 거절 실패: ${error.message}`); + toast.error(`예약 거절 실패: ${error.message}`); }, }); }; diff --git a/src/hooks/useMyActivitiesQueries.ts b/src/hooks/useMyActivitiesQueries.ts index 99037dda..59d7ae69 100644 --- a/src/hooks/useMyActivitiesQueries.ts +++ b/src/hooks/useMyActivitiesQueries.ts @@ -9,6 +9,7 @@ import { getMyActivitiesWithPagination, deleteMyActivity, } from '@/apis/myActivities'; +import { toast } from 'sonner'; export const MY_ACTIVITIES_QUERY_KEYS = { ALL: ['my-activities'] as const, @@ -40,10 +41,20 @@ export const useDeleteMyActivity = () => { queryClient.invalidateQueries({ queryKey: MY_ACTIVITIES_QUERY_KEYS.ALL, }); - alert('체험이 삭제되었습니다.'); + + // 홈페이지 체험 리스트 쿼리들 무효화 + queryClient.invalidateQueries({ + queryKey: ['popularExperiences'], + }); + queryClient.invalidateQueries({ + queryKey: ['experiences'], + exact: false, + }); + + toast.success('체험이 삭제되었습니다.'); }, onError: (error) => { - alert(`체험 삭제 실패: ${error.message}`); + toast.error(`체험 삭제 실패: ${error.message}`); }, }); }; diff --git a/src/hooks/useMyPageQueries.ts b/src/hooks/useMyPageQueries.ts index a384a520..68469bd4 100644 --- a/src/hooks/useMyPageQueries.ts +++ b/src/hooks/useMyPageQueries.ts @@ -10,6 +10,7 @@ import { UpdateProfileRequest } from '@/types/mypageTypes'; import useMyPageStore from '@/stores/MyPage/useMyPageStore'; import { useEffect } from 'react'; import useUserStore from '@/stores/authStore'; +import { toast } from 'sonner'; export const QUERY_KEYS = { PROFILE: ['mypage', 'profile'] as const, @@ -66,13 +67,13 @@ export const useUpdateProfile = () => { setLoading(false); // 캐시 업데이트 queryClient.setQueryData(QUERY_KEYS.PROFILE, mutation.data); - alert('프로필이 성공적으로 업데이트되었습니다!'); + toast.success('프로필이 성공적으로 업데이트되었습니다!'); } if (mutation.isError) { setError(mutation.error?.message || '프로필 업데이트에 실패했습니다.'); setLoading(false); - alert(`프로필 업데이트 실패: ${mutation.error?.message}`); + toast.error(`프로필 업데이트 실패: ${mutation.error?.message}`); } }, [ mutation.isPending, @@ -133,13 +134,13 @@ export const useUploadProfileImage = () => { queryClient.setQueryData(QUERY_KEYS.PROFILE, updatedUser); setLoading(false); - alert('프로필 이미지가 성공적으로 업로드되었습니다!'); + toast.success('프로필 이미지가 성공적으로 업로드되었습니다!'); } if (mutation.isError) { setError(mutation.error?.message || '이미지 업로드에 실패했습니다.'); setLoading(false); - alert(`이미지 업로드 실패: ${mutation.error?.message}`); + toast.error(`이미지 업로드 실패: ${mutation.error?.message}`); } }, [ mutation.isPending, diff --git a/src/hooks/useProfileImageUpload.ts b/src/hooks/useProfileImageUpload.ts index 44c083ee..d8ba610e 100644 --- a/src/hooks/useProfileImageUpload.ts +++ b/src/hooks/useProfileImageUpload.ts @@ -1,5 +1,6 @@ import { useRef } from 'react'; import { useUploadProfileImage } from './useMyPageQueries'; +import { toast } from 'sonner'; /** * 프로필 이미지 업로드를 위한 커스텀 훅 @@ -25,13 +26,13 @@ export const useProfileImageUpload = () => { if (file) { // 파일 타입 검증 if (!file.type.startsWith('image/')) { - alert('이미지 파일만 업로드 가능합니다.'); + toast.error('이미지 파일만 업로드 가능합니다.'); return; } // 파일 크기 검증 if (file.size > 5 * 1024 * 1024) { - alert('파일 크기는 5MB 이하여야 합니다.'); + toast.error('파일 크기는 5MB 이하여야 합니다.'); return; } diff --git a/src/hooks/useReservationQueries.ts b/src/hooks/useReservationQueries.ts index bbb758ed..55d4e49d 100644 --- a/src/hooks/useReservationQueries.ts +++ b/src/hooks/useReservationQueries.ts @@ -5,8 +5,16 @@ import { useQueryClient, useInfiniteQuery, } from '@tanstack/react-query'; -import { getMyReservations, updateMyReservation, createReview } from '@/apis/reservations'; -import { ReservationStatus, CreateReviewRequest } from '@/types/reservationTypes'; +import { + getMyReservations, + updateMyReservation, + createReview, +} from '@/apis/reservations'; +import { + ReservationStatus, + CreateReviewRequest, +} from '@/types/reservationTypes'; +import { toast } from 'sonner'; export const RESERVATION_QUERY_KEYS = { RESERVATIONS: ['reservations'] as const, @@ -45,10 +53,10 @@ export const useCancelReservation = () => { queryClient.invalidateQueries({ queryKey: RESERVATION_QUERY_KEYS.RESERVATIONS, }); - alert('예약이 취소되었습니다.'); + toast.success('예약이 취소되었습니다.'); }, onError: (error) => { - alert(`예약 취소 실패: ${error.message}`); + toast.error(`예약 취소 실패: ${error.message}`); }, }); }; @@ -58,13 +66,22 @@ export const useCreateReview = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ reservationId, data }: { reservationId: number; data: CreateReviewRequest }) => - createReview(reservationId, data), + mutationFn: ({ + reservationId, + data, + }: { + reservationId: number; + data: CreateReviewRequest; + }) => createReview(reservationId, data), onSuccess: () => { // 예약 리스트 캐시 무효화하여 reviewSubmitted 상태 업데이트 queryClient.invalidateQueries({ queryKey: RESERVATION_QUERY_KEYS.RESERVATIONS, }); + toast.success('후기가 작성되었습니다.'); + }, + onError: (error) => { + toast.error(`후기 작성 실패: ${error.message}`); }, }); }; diff --git a/src/types/dropdownTypes.ts b/src/types/dropdownTypes.ts index 34f3108d..c1fec3f0 100644 --- a/src/types/dropdownTypes.ts +++ b/src/types/dropdownTypes.ts @@ -24,5 +24,6 @@ export interface DropdownProps { disableScroll?: boolean; buttonClassName?: string; listboxClassName?: string; - optionClassName?: string; + optionClassName?: string; + truncateText?: boolean; } diff --git a/src/utils/timeSlotUtils.ts b/src/utils/timeSlotUtils.ts index eb657d71..af90425f 100644 --- a/src/utils/timeSlotUtils.ts +++ b/src/utils/timeSlotUtils.ts @@ -1,4 +1,7 @@ -import { ReservedSchedule } from '@/types/dashboardTypes'; +import { + ReservedSchedule, + DashboardFilterOption, +} from '@/types/dashboardTypes'; export const createTimeSlotOptions = (schedules: ReservedSchedule[] = []) => { return schedules.map( @@ -6,6 +9,21 @@ export const createTimeSlotOptions = (schedules: ReservedSchedule[] = []) => { ); }; +// 탭별로 필터링된 시간대 옵션 생성 +export const createFilteredTimeSlotOptions = ( + schedules: ReservedSchedule[] = [], + activeTab: DashboardFilterOption, +) => { + const filteredSchedules = schedules.filter((schedule) => { + // 해당 탭의 상태에 예약이 있는 시간대만 필터링 + return schedule.count[activeTab] > 0; + }); + + return filteredSchedules.map( + (schedule) => `${schedule.startTime} - ${schedule.endTime}`, + ); +}; + export const getScheduleIdFromTimeSlot = ( timeSlot: string, schedules: ReservedSchedule[] = [],