Skip to content

Commit d7315cc

Browse files
authored
Merge pull request #141 from codeit-2team/fix/139
Fix/139 3차 QA 수정
2 parents 4eac5f9 + da18261 commit d7315cc

File tree

7 files changed

+120
-71
lines changed

7 files changed

+120
-71
lines changed

src/app/(with-header)/mypage/dashboard/page.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ export default function MyDashboardPage() {
2020

2121
// 내 체험 리스트 조회
2222
const { data: activitiesData, isLoading, error } = useMyActivities();
23-
const { activityOptions, uniqueTitles, handleActivityChange } =
24-
useActivityOptions(activitiesData, (activityId) => {
23+
const { activityOptions, handleActivityChange } = useActivityOptions(
24+
activitiesData,
25+
(activityId) => {
2526
setSelectedActivityId(activityId);
2627
setSelectedDate('');
27-
});
28+
},
29+
);
2830

2931
// 페이지 로드 시 첫 번째 체험 자동 선택
3032
useEffect(() => {
@@ -38,11 +40,8 @@ export default function MyDashboardPage() {
3840
}
3941
}, [activitiesData, selectedActivityId]);
4042

41-
// 현재 선택된 체험의 제목 찾기
42-
const selectedActivityTitle =
43-
activityOptions.find(
44-
(option) => parseInt(option.value) === selectedActivityId,
45-
)?.label || '';
43+
// 현재 선택된 체험의 ID를 문자열로 변환
44+
const selectedActivityValue = selectedActivityId?.toString() || '';
4645

4746
// 날짜 클릭 (모달 열기)
4847
const handleDateClick = (date: string) => {
@@ -118,8 +117,9 @@ export default function MyDashboardPage() {
118117
{/* 체험 선택 드롭다운 */}
119118
<div className='mb-48 w-full max-w-792'>
120119
<Dropdown
121-
options={uniqueTitles}
122-
value={selectedActivityTitle}
120+
options={[]}
121+
optionData={activityOptions}
122+
value={selectedActivityValue}
123123
onChange={handleActivityChange}
124124
placeholder='체험을 선택하세요'
125125
className='h-56 min-w-0'

src/app/(with-header)/mypage/reservations/page.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
useMyReservations,
66
useCancelReservation,
77
useCreateReview,
8+
useInvalidateActivityQueries,
89
} from '@/hooks/useReservationQueries';
910
import { FilterOption } from '@/constants/reservationConstants';
1011
import useInfiniteScroll from '@/hooks/useInfiniteScroll';
@@ -54,6 +55,9 @@ export default function MyReservationsPage() {
5455
// 후기 작성 뮤테이션
5556
const createReviewMutation = useCreateReview();
5657

58+
// 쿼리 무효화 훅
59+
const invalidateActivityQueries = useInvalidateActivityQueries();
60+
5761
// 무한 스크롤 훅
5862
const { lastElementRef } = useInfiniteScroll({
5963
hasNextPage,
@@ -102,23 +106,21 @@ export default function MyReservationsPage() {
102106
// 후기 작성 확인
103107
const handleReviewConfirm = (rating: number, content: string) => {
104108
if (reviewModal.reservationId) {
109+
const reservation = allReservations.find(
110+
(r) => r.id === reviewModal.reservationId,
111+
);
112+
105113
createReviewMutation.mutate(
106114
{
107115
reservationId: reviewModal.reservationId,
108116
data: { rating, content },
109117
},
110118
{
111119
onSuccess: () => {
112-
setReviewModal({
113-
isOpen: false,
114-
reservationId: null,
115-
activityTitle: null,
116-
activityImage: null,
117-
activityDate: null,
118-
activityTime: null,
119-
headCount: null,
120-
totalPrice: null,
121-
});
120+
// 성공 후 추가 쿼리 무효화
121+
if (reservation?.activity.id) {
122+
invalidateActivityQueries(reservation.activity.id);
123+
}
122124
},
123125
},
124126
);

src/components/Dropdown.tsx

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { motion, AnimatePresence } from 'framer-motion';
55
import cn from '@lib/cn';
66
import useOutsideClick from '@hooks/useOutsideClick';
77
import ChevronIcon from '@assets/svg/chevron';
8-
import { DropdownProps } from '@/types/dropdownTypes';
8+
import { DropdownProps, DropdownOption } from '@/types/dropdownTypes';
99
// import CheckIcon from '@assets/svg/check';
1010

1111
/**
@@ -28,6 +28,7 @@ import { DropdownProps } from '@/types/dropdownTypes';
2828
*/
2929
export default function Dropdown<T extends string>({
3030
options,
31+
optionData,
3132
value,
3233
onChange,
3334
placeholder,
@@ -47,22 +48,34 @@ export default function Dropdown<T extends string>({
4748
const dropdownRef = useRef<HTMLDivElement>(null);
4849
const buttonRef = useRef<HTMLButtonElement>(null);
4950

51+
// optionData가 제공되면 우선 사용, 없으면 options 사용
52+
const useOptionData = !!optionData;
53+
const finalOptions = useOptionData
54+
? optionData!
55+
: options.map(opt => ({ value: opt, label: opt }));
56+
5057
// 내/외부 상태 관리 판별
5158
const isControlled = value !== undefined;
5259
const selectedValue = isControlled ? value : internalValue;
5360

61+
// 선택된 값에 해당하는 표시 텍스트 찾기
62+
const displayValue = useOptionData
63+
? finalOptions.find(opt => opt.value === selectedValue)?.label || ''
64+
: selectedValue;
65+
5466
// 외부 클릭 감지
5567
useOutsideClick(dropdownRef, () => {
5668
setIsOpen(false);
5769
setFocusedIndex(-1);
5870
});
5971

6072
// 값 선택 핸들러
61-
const handleSelect = (option: T) => {
73+
const handleSelect = (optionData: DropdownOption) => {
74+
const selectedValue = optionData.value as T;
6275
if (!isControlled) {
63-
setInternalValue(option);
76+
setInternalValue(selectedValue);
6477
}
65-
onChange?.(option);
78+
onChange?.(selectedValue);
6679
setIsOpen(false);
6780
setFocusedIndex(-1);
6881
buttonRef.current?.focus();
@@ -77,7 +90,7 @@ export default function Dropdown<T extends string>({
7790
case ' ':
7891
e.preventDefault();
7992
if (isOpen && focusedIndex >= 0) {
80-
handleSelect(options[focusedIndex]);
93+
handleSelect(finalOptions[focusedIndex]);
8194
} else {
8295
setIsOpen(!isOpen);
8396
}
@@ -88,7 +101,7 @@ export default function Dropdown<T extends string>({
88101
if (!isOpen) {
89102
setIsOpen(true);
90103
} else {
91-
setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0));
104+
setFocusedIndex((prev) => (prev < finalOptions.length - 1 ? prev + 1 : 0));
92105
}
93106
break;
94107

@@ -97,7 +110,7 @@ export default function Dropdown<T extends string>({
97110
if (!isOpen) {
98111
setIsOpen(true);
99112
} else {
100-
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1));
113+
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : finalOptions.length - 1));
101114
}
102115
break;
103116

@@ -149,7 +162,7 @@ export default function Dropdown<T extends string>({
149162
truncateText && 'flex-1 truncate text-left',
150163
)}
151164
>
152-
{selectedValue || placeholder}
165+
{displayValue || placeholder}
153166
</span>
154167
<ChevronIcon
155168
size={24}
@@ -179,13 +192,13 @@ export default function Dropdown<T extends string>({
179192
listboxClassName,
180193
)}
181194
>
182-
{options.map((option, index) => {
183-
const isSelected = option === selectedValue;
195+
{finalOptions.map((optionItem, index) => {
196+
const isSelected = optionItem.value === selectedValue;
184197
const isFocused = index === focusedIndex;
185198

186199
return (
187200
<li
188-
key={option}
201+
key={`${optionItem.value}-${index}`}
189202
id={`dropdown-option-${index}`}
190203
role='option'
191204
aria-selected={isSelected}
@@ -198,7 +211,7 @@ export default function Dropdown<T extends string>({
198211
: 'hover:bg-gray-100',
199212
optionClassName,
200213
)}
201-
onClick={() => handleSelect(option)}
214+
onClick={() => handleSelect(optionItem)}
202215
onMouseEnter={() => setFocusedIndex(index)}
203216
>
204217
{/* 아이콘 영역 */}
@@ -211,7 +224,7 @@ export default function Dropdown<T extends string>({
211224
/>
212225
)}
213226
</div> */}
214-
<span>{option}</span>
227+
<span>{optionItem.label}</span>
215228
</li>
216229
);
217230
})}

src/hooks/useActivityOptions.ts

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,57 +15,43 @@ export const useActivityOptions = (
1515
) => {
1616
const queryClient = useQueryClient();
1717

18-
const { activityOptions, uniqueTitles } = useMemo(() => {
18+
const activityOptions = useMemo(() => {
1919
if (!activitiesData?.activities) {
20-
return {
21-
activityOptions: [] as ActivityOption[],
22-
uniqueTitles: [] as string[],
23-
};
20+
return [] as ActivityOption[];
2421
}
2522

26-
const options: ActivityOption[] = activitiesData.activities.map(
23+
return activitiesData.activities.map(
2724
(activity: { id: number; title: string }) => ({
2825
value: activity.id.toString(),
2926
label: activity.title,
3027
}),
3128
);
32-
33-
const uniqueTitles = options.map((option) => option.label);
34-
35-
return { activityOptions: options, uniqueTitles };
3629
}, [activitiesData]);
3730

3831
// 체험 변경 시 쿼리 무효화 함수
39-
const handleActivityChange = (selectedTitle: string): void => {
40-
const selectedOption = activityOptions.find(
41-
(option: ActivityOption) => option.label === selectedTitle,
42-
);
43-
44-
if (selectedOption) {
45-
const activityId = parseInt(selectedOption.value);
46-
47-
// 관련 쿼리들 무효화
48-
queryClient.invalidateQueries({
49-
queryKey: ['reservedSchedules'],
50-
exact: false,
51-
});
52-
queryClient.invalidateQueries({
53-
queryKey: ['activityReservations'],
54-
exact: false,
55-
});
56-
queryClient.invalidateQueries({
57-
queryKey: ['monthlyReservationDashboard'],
58-
exact: false,
59-
});
60-
61-
// 콜백 호출
62-
onActivityChange?.(activityId);
63-
}
32+
const handleActivityChange = (selectedValue: string): void => {
33+
const activityId = parseInt(selectedValue);
34+
35+
// 관련 쿼리들 무효화
36+
queryClient.invalidateQueries({
37+
queryKey: ['reservedSchedules'],
38+
exact: false,
39+
});
40+
queryClient.invalidateQueries({
41+
queryKey: ['activityReservations'],
42+
exact: false,
43+
});
44+
queryClient.invalidateQueries({
45+
queryKey: ['monthlyReservationDashboard'],
46+
exact: false,
47+
});
48+
49+
// 콜백 호출
50+
onActivityChange?.(activityId);
6451
};
6552

6653
return {
6754
activityOptions,
68-
uniqueTitles,
6955
handleActivityChange,
7056
};
7157
};

src/hooks/useMyActivitiesQueries.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
deleteMyActivity,
1111
} from '@/apis/myActivities';
1212
import { toast } from 'sonner';
13+
import { AxiosError } from 'axios';
1314

1415
export const MY_ACTIVITIES_QUERY_KEYS = {
1516
ALL: ['my-activities'] as const,
@@ -54,7 +55,25 @@ export const useDeleteMyActivity = () => {
5455
toast.success('체험이 삭제되었습니다.');
5556
},
5657
onError: (error) => {
57-
toast.error(`체험 삭제 실패: ${error.message}`);
58+
const axiosError = error as AxiosError;
59+
const status = axiosError.response?.status;
60+
61+
switch (status) {
62+
case 400:
63+
toast.error('예약이 있는 체험은 삭제할 수 없습니다.');
64+
break;
65+
case 401:
66+
toast.error('로그인이 필요합니다.');
67+
break;
68+
case 403:
69+
toast.error('삭제 권한이 없습니다.');
70+
break;
71+
case 404:
72+
toast.error('존재하지 않는 체험입니다.');
73+
break;
74+
default:
75+
toast.error('체험 삭제에 실패했습니다. 다시 시도해주세요.');
76+
}
5877
},
5978
});
6079
};

src/hooks/useReservationQueries.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,37 @@ export const useCreateReview = () => {
7474
data: CreateReviewRequest;
7575
}) => createReview(reservationId, data),
7676
onSuccess: () => {
77-
// 예약 리스트 캐시 무효화하여 reviewSubmitted 상태 업데이트
77+
// 기존 쿼리 무효화
7878
queryClient.invalidateQueries({
7979
queryKey: RESERVATION_QUERY_KEYS.RESERVATIONS,
8080
});
81+
8182
toast.success('후기가 작성되었습니다.');
8283
},
8384
onError: (error) => {
8485
toast.error(`후기 작성 실패: ${error.message}`);
8586
},
8687
});
8788
};
89+
90+
// 쿼리 무효화 훅
91+
export const useInvalidateActivityQueries = () => {
92+
const queryClient = useQueryClient();
93+
94+
return (activityId: number) => {
95+
queryClient.invalidateQueries({
96+
queryKey: ['popularExperiences'],
97+
});
98+
queryClient.invalidateQueries({
99+
queryKey: ['experiences'],
100+
exact: false,
101+
});
102+
queryClient.invalidateQueries({
103+
queryKey: ['activity', activityId.toString()],
104+
});
105+
queryClient.invalidateQueries({
106+
queryKey: ['reviews'],
107+
exact: false,
108+
});
109+
};
110+
};

src/types/dropdownTypes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { ClassValue } from 'clsx';
22

3+
export interface DropdownOption {
4+
value: string;
5+
label: string;
6+
}
7+
38
/**
49
* 범용 Dropdown 컴포넌트의 props 정의입니다.
510
* 제네릭 타입 T를 사용하여 다양한 문자열 옵션 타입을 지원합니다.
@@ -16,6 +21,7 @@ import { ClassValue } from 'clsx';
1621
*/
1722
export interface DropdownProps<T extends string> {
1823
options: readonly T[];
24+
optionData?: readonly DropdownOption[];
1925
value?: T | '';
2026
onChange?: (value: T) => void;
2127
placeholder?: string;

0 commit comments

Comments
 (0)