Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions src/app/(with-header)/mypage/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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) => {
Expand Down Expand Up @@ -118,8 +117,9 @@ export default function MyDashboardPage() {
{/* 체험 선택 드롭다운 */}
<div className='mb-48 w-full max-w-792'>
<Dropdown
options={uniqueTitles}
value={selectedActivityTitle}
options={[]}
optionData={activityOptions}
value={selectedActivityValue}
onChange={handleActivityChange}
placeholder='체험을 선택하세요'
className='h-56 min-w-0'
Expand Down
19 changes: 9 additions & 10 deletions src/app/(with-header)/mypage/reservations/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useMyReservations,
useCancelReservation,
useCreateReview,
invalidateActivityQueries,
} from '@/hooks/useReservationQueries';
import { FilterOption } from '@/constants/reservationConstants';
import useInfiniteScroll from '@/hooks/useInfiniteScroll';
Expand Down Expand Up @@ -102,23 +103,21 @@ export default function MyReservationsPage() {
// 후기 작성 확인
const handleReviewConfirm = (rating: number, content: string) => {
if (reviewModal.reservationId) {
const reservation = allReservations.find(
(r) => r.id === reviewModal.reservationId,
);

createReviewMutation.mutate(
{
reservationId: reviewModal.reservationId,
data: { rating, content },
},
{
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);
}
Comment on lines +109 to +123
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

리뷰 생성 후 캐시 무효화 로직이 잘 구현되었습니다.

후기 작성 성공 시 관련 액티비티 쿼리들을 무효화하여 실시간 업데이트를 보장합니다. 예약 정보를 찾아 액티비티 ID를 전달하는 로직도 적절합니다.

다만 reservation을 찾지 못할 경우에 대한 방어 로직을 고려해보세요.

선택적 개선사항으로 다음과 같은 방어 로직을 추가할 수 있습니다:

  onSuccess: () => {
    // 성공 후 추가 쿼리 무효화
-   if (reservation?.activity.id) {
+   if (reservation?.activity?.id) {
      invalidateActivityQueries(reservation.activity.id);
+   } else {
+     console.warn('예약 정보를 찾을 수 없어 캐시 무효화를 건너뜁니다.');
    }
  },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const reservation = allReservations.find(
(r) => r.id === reviewModal.reservationId,
);
createReviewMutation.mutate(
{
reservationId: reviewModal.reservationId,
data: { rating, content },
},
{
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);
}
const reservation = allReservations.find(
(r) => r.id === reviewModal.reservationId,
);
createReviewMutation.mutate(
{
reservationId: reviewModal.reservationId,
data: { rating, content },
},
{
onSuccess: () => {
// 성공 후 추가 쿼리 무효화
if (reservation?.activity?.id) {
invalidateActivityQueries(reservation.activity.id);
} else {
console.warn('예약 정보를 찾을 수 없어 캐시 무효화를 건너뜁니다.');
}
},
},
);
🤖 Prompt for AI Agents
In src/app/(with-header)/mypage/reservations/page.tsx around lines 109 to 123,
the code finds a reservation by ID but lacks handling for the case when no
matching reservation is found. Add a defensive check to verify that reservation
exists before accessing its properties, such as reservation.activity.id, to
prevent runtime errors. If reservation is undefined, skip the cache invalidation
logic or handle the case appropriately.

},
},
);
Expand Down
39 changes: 26 additions & 13 deletions src/components/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -28,6 +28,7 @@ import { DropdownProps } from '@/types/dropdownTypes';
*/
export default function Dropdown<T extends string>({
options,
optionData,
value,
onChange,
placeholder,
Expand All @@ -47,22 +48,34 @@ export default function Dropdown<T extends string>({
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(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);
setFocusedIndex(-1);
});

// 값 선택 핸들러
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();
Expand All @@ -77,7 +90,7 @@ export default function Dropdown<T extends string>({
case ' ':
e.preventDefault();
if (isOpen && focusedIndex >= 0) {
handleSelect(options[focusedIndex]);
handleSelect(finalOptions[focusedIndex]);
} else {
setIsOpen(!isOpen);
}
Expand All @@ -88,7 +101,7 @@ export default function Dropdown<T extends string>({
if (!isOpen) {
setIsOpen(true);
} else {
setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0));
setFocusedIndex((prev) => (prev < finalOptions.length - 1 ? prev + 1 : 0));
}
break;

Expand All @@ -97,7 +110,7 @@ export default function Dropdown<T extends string>({
if (!isOpen) {
setIsOpen(true);
} else {
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1));
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : finalOptions.length - 1));
}
break;

Expand Down Expand Up @@ -149,7 +162,7 @@ export default function Dropdown<T extends string>({
truncateText && 'flex-1 truncate text-left',
)}
>
{selectedValue || placeholder}
{displayValue || placeholder}
</span>
<ChevronIcon
size={24}
Expand Down Expand Up @@ -179,13 +192,13 @@ export default function Dropdown<T extends string>({
listboxClassName,
)}
>
{options.map((option, index) => {
const isSelected = option === selectedValue;
{finalOptions.map((optionItem, index) => {
const isSelected = optionItem.value === selectedValue;
const isFocused = index === focusedIndex;

return (
<li
key={option}
key={`${optionItem.value}-${index}`}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

키 값으로 배열 인덱스 사용을 개선해주세요.

배열 인덱스를 키로 사용하면 성능 문제가 발생할 수 있습니다.

다음과 같이 수정하는 것을 권장합니다:

-                    key={`${optionItem.value}-${index}`}
+                    key={optionItem.value}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
key={`${optionItem.value}-${index}`}
key={optionItem.value}
🤖 Prompt for AI Agents
In src/components/Dropdown.tsx at line 201, avoid using the array index as part
of the key prop because it can cause performance issues. Instead, use a unique
and stable identifier from the optionItem itself, such as optionItem.value
alone, assuming it is unique. Update the key to use only optionItem.value or
another unique property without appending the index.

id={`dropdown-option-${index}`}
role='option'
aria-selected={isSelected}
Expand All @@ -198,7 +211,7 @@ export default function Dropdown<T extends string>({
: 'hover:bg-gray-100',
optionClassName,
)}
onClick={() => handleSelect(option)}
onClick={() => handleSelect(optionItem)}
onMouseEnter={() => setFocusedIndex(index)}
>
{/* 아이콘 영역 */}
Expand All @@ -211,7 +224,7 @@ export default function Dropdown<T extends string>({
/>
)}
</div> */}
<span>{option}</span>
<span>{optionItem.label}</span>
</li>
);
})}
Expand Down
58 changes: 22 additions & 36 deletions src/hooks/useActivityOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
};
21 changes: 20 additions & 1 deletion src/hooks/useMyActivitiesQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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('체험 삭제에 실패했습니다. 다시 시도해주세요.');
}
},
});
};
23 changes: 22 additions & 1 deletion src/hooks/useReservationQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,35 @@ export const useCreateReview = () => {
data: CreateReviewRequest;
}) => createReview(reservationId, data),
onSuccess: () => {
// 예약 리스트 캐시 무효화하여 reviewSubmitted 상태 업데이트
// 기존 쿼리 무효화
queryClient.invalidateQueries({
queryKey: RESERVATION_QUERY_KEYS.RESERVATIONS,
});

toast.success('후기가 작성되었습니다.');
},
onError: (error) => {
toast.error(`후기 작성 실패: ${error.message}`);
},
});
};

// 쿼리 무효화 함수
export const invalidateActivityQueries = (activityId: number) => {
const queryClient = useQueryClient();

queryClient.invalidateQueries({
queryKey: ['popularExperiences'],
});
queryClient.invalidateQueries({
queryKey: ['experiences'],
exact: false,
});
queryClient.invalidateQueries({
queryKey: ['activity', activityId.toString()],
});
queryClient.invalidateQueries({
queryKey: ['reviews'],
exact: false,
});
};
6 changes: 6 additions & 0 deletions src/types/dropdownTypes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { ClassValue } from 'clsx';

export interface DropdownOption {
value: string;
label: string;
}

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