diff --git a/src/app/meeting/_features/CardWrapper.tsx b/src/app/meeting/_features/CardWrapper.tsx index c8fc61c..393c0d5 100644 --- a/src/app/meeting/_features/CardWrapper.tsx +++ b/src/app/meeting/_features/CardWrapper.tsx @@ -25,7 +25,7 @@ const CardWrapper = ({ meetingId }: { meetingId: number }) => { - - {name} + + {name}
diff --git a/src/app/meeting/_features/MeetingList.tsx b/src/app/meeting/_features/MeetingList.tsx index d72747b..1be1229 100644 --- a/src/app/meeting/_features/MeetingList.tsx +++ b/src/app/meeting/_features/MeetingList.tsx @@ -46,6 +46,9 @@ const MeetingList = () => { }, ); + const allMeetings: SearchMeeting[] = + data?.pages.flatMap((page) => page.content) || []; + const lastMeetingRef = useInfiniteScroll({ fetchNextPage, isFetchingNextPage, @@ -140,40 +143,35 @@ const MeetingList = () => { {/* 모임 리스트 웹뷰 */} {breakpoint === 'desktop' && (
- {data?.pages.map((page, pageIndex) => ( -
- {page.content.map((meeting: SearchMeeting) => { - return ( - - - - ); - })} -
+ {allMeetings.map((meeting) => ( + + + ))}
)} @@ -181,87 +179,77 @@ const MeetingList = () => { {/* 모임 리스트 테블릿뷰 */} {breakpoint === 'tablet' && (
- {data?.pages.map((page, pageIndex) => ( -
- {page.content.map((meeting: SearchMeeting) => { - return ( - - - - ); - })} -
+ {allMeetings.map((meeting) => ( + + + ))}
)} {/* 모임 리스트 모바일뷰 */} {breakpoint === 'mobile' && ( -
- {data?.pages.map((page, pageIndex) => ( -
- {page.content.map((meeting: SearchMeeting) => { - return ( - - - - ); - })} -
+
+ {allMeetings.map((meeting) => ( + + + ))}
)} diff --git a/src/components/common/Dropdown.tsx b/src/components/common/Dropdown.tsx index ad94081..c981fea 100644 --- a/src/components/common/Dropdown.tsx +++ b/src/components/common/Dropdown.tsx @@ -125,7 +125,7 @@ const Dropdown = ({ !className?.includes('w-[460px]'); return ( - + void; searchQuery?: IMeetingSearchCondition; + showLikeButton?: boolean; isLike?: boolean; likesCount?: number; skills?: string[]; @@ -55,41 +46,20 @@ const HorizonCard = ({ value, total = 100, searchQuery, + showLikeButton = true, likesCount, skills, }: HorizonCardProps) => { - const { showToast } = useToast(); - const queryClient = useQueryClient(); - const { mutate: likeMutation } = useLikeMeeting(meetingId, { - onSuccess: () => { - invalidateMeetingQuery(); - }, - onError: () => { - showToast('잠시 후 다시 시도해주세요', 'error', { duration: 3000 }); - }, + const { toggleLike } = useLikeHandler({ + meetingId, + category, + searchQuery, + onAuthRequired: () => setIsLoginModalOpen(true), }); - const { mutate: cancellikeMutation } = useCancelLikeMeeting(meetingId, { - onSuccess: () => { - invalidateMeetingQuery(); - }, - onError: () => { - showToast('잠시 후 다시 시도해주세요', 'error', { duration: 3000 }); - }, - }); - - const invalidateMeetingQuery = () => { - if (category && searchQuery) { - queryClient.invalidateQueries({ - queryKey: MEETING_QUERY_KEYS.meetings(category, searchQuery), - }); - } - queryClient.invalidateQueries({ - queryKey: MEETING_QUERY_KEYS.topMeetings(category), - }); - queryClient.invalidateQueries({ - queryKey: meetingKeys.detailInfo(meetingId), - }); + const handleLikeButton = (e: React.MouseEvent) => { + e.stopPropagation(); + toggleLike(isLike); }; // TODO: 리팩토링 예정 @@ -100,25 +70,6 @@ const HorizonCard = ({ if (onClick && !id) onClick(meetingId); }; - const handleLikeButton = async (e: React.MouseEvent) => { - e.stopPropagation(); - const token = await getAccessToken(); - - // 토큰 없으면 로그인 안내 팝업 노출 - if (!token) { - setIsLoginModalOpen(true); - return; - } - - if (!isLike) { - likeMutation(); - } - - if (isLike) { - cancellikeMutation(); - } - }; - const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); const router = useRouter(); @@ -145,23 +96,24 @@ const HorizonCard = ({ >

로그인이 필요한 서비스 입니다.

- - + {showLikeButton && ( + + )}
void; isLike?: boolean; + showLikeButton?: boolean; searchQuery?: IMeetingSearchCondition; value: number; total: number; @@ -52,69 +43,27 @@ const VerticalCard = ({ location, onClick, isLike = false, - likesCount, + showLikeButton = true, searchQuery, value, total = 100, skills, }: VerticalCardProps) => { - const { showToast } = useToast(); - const queryClient = useQueryClient(); - const { mutate: likeMutation } = useLikeMeeting(meetingId, { - onSuccess: () => { - invalidateMeetingQuery(); - }, - onError: () => { - showToast('잠시 후 다시 시도해주세요', 'error', { duration: 3000 }); - }, - }); - - const { mutate: cancellikeMutation } = useCancelLikeMeeting(meetingId, { - onSuccess: () => { - invalidateMeetingQuery(); - }, - onError: () => { - showToast('잠시 후 다시 시도해주세요', 'error', { duration: 3000 }); - }, - }); - - // TODO: 리팩토링 예정 - const invalidateMeetingQuery = () => { - if (category && searchQuery) { - queryClient.invalidateQueries({ - queryKey: MEETING_QUERY_KEYS.meetings(category, searchQuery), - }); - } - queryClient.invalidateQueries({ - queryKey: MEETING_QUERY_KEYS.topMeetings(category), - }); - queryClient.invalidateQueries({ - queryKey: meetingKeys.detailInfo(meetingId), - }); - }; - const handleClickCard = () => { if (isLoginModalOpen) return; if (onClick) onClick(meetingId); }; - const handleLikeButton = async (e: React.MouseEvent) => { - e.stopPropagation(); - const token = await getAccessToken(); - - // 토큰 없으면 로그인 안내 팝업 노출 - if (!token) { - setIsLoginModalOpen(true); - return; - } - - if (!isLike) { - likeMutation(); - } + const { toggleLike } = useLikeHandler({ + meetingId, + category, + searchQuery, + onAuthRequired: () => setIsLoginModalOpen(true), + }); - if (isLike) { - cancellikeMutation(); - } + const handleLikeButton = (e: React.MouseEvent) => { + e.stopPropagation(); + toggleLike(isLike); }; const [isLoginModalOpen, setIsLoginModalOpen] = useState(false); @@ -129,7 +78,7 @@ const VerticalCard = ({ return (
@@ -151,7 +100,7 @@ const VerticalCard = ({
)} card_thumbnail {title} -
+ {showLikeButton && ( -
{likesCount}
-
+ )}
diff --git a/src/hooks/common/useLikeHandler.ts b/src/hooks/common/useLikeHandler.ts new file mode 100644 index 0000000..56285ce --- /dev/null +++ b/src/hooks/common/useLikeHandler.ts @@ -0,0 +1,248 @@ +import { useToast } from '@/components/common/ToastContext'; +import { + useCancelLikeMeeting, + useLikeMeeting, +} from '@/hooks/mutations/useMeetingMutation'; +import { + MEETING_QUERY_KEYS, + meetingKeys, +} from '@/hooks/queries/useMeetingQueries'; +import { getAccessToken } from '@/lib/serverActions'; +import { InfiniteData, QueryKey, useQueryClient } from '@tanstack/react-query'; +import { useParams } from 'next/navigation'; +import { + IMeetingSearchCondition, + Paginated, + SearchMeeting, + TopMeeting, +} from 'types/meeting'; + +interface UseLikeHandlerProps { + meetingId: number; + category: string; + searchQuery?: IMeetingSearchCondition; + onAuthRequired?: () => void; +} + +const useLikeHandler = ({ + meetingId, + category, + searchQuery, + onAuthRequired, +}: UseLikeHandlerProps) => { + const queryClient = useQueryClient(); + + // 배열 캐시 업데이트 + const updateCacheArray = ( + queryKey: QueryKey, + meetingId: number, + isLike: boolean, + likeCount: number, + ) => { + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) return oldData; + return oldData.map((meeting) => + meeting.meetingId === meetingId + ? { ...meeting, isLike, likesCount: meeting.likesCount + likeCount } + : meeting, + ); + }); + }; + + // 객체 캐시 업데이트 + const updateCacheObject = ( + queryKey: QueryKey, + isLike: boolean, + likeCount: number, + ) => { + queryClient.setQueryData(queryKey, (oldData) => { + if (!oldData) return oldData; + return { ...oldData, isLike, likesCount: oldData.likesCount + likeCount }; + }); + }; + + // 무한스크롤 배열 캐시 업데이트 + const updateInfiniteMeetingCache = ( + queryKey: QueryKey, + meetingId: number, + isLike: boolean, + likeCount: number, + ) => { + queryClient.setQueryData>>( + queryKey, + (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + pages: oldData.pages.map((page) => ({ + ...page, + content: page.content.map((item) => + item.meetingId === meetingId + ? { ...item, isLike, likesCount: item.likesCount + likeCount } + : item, + ), + })), + }; + }, + ); + }; + + const { showToast } = useToast(); + const { id } = useParams(); + + const { mutate: likeMutation } = useLikeMeeting(meetingId, { + onMutate: () => { + // 모임 목록 + if (category && searchQuery) { + updateInfiniteMeetingCache( + MEETING_QUERY_KEYS.meetings(category, searchQuery), + meetingId, + true, + 1, + ); + return; + } + + // 추천모임 + if (category && !id) { + updateCacheArray( + MEETING_QUERY_KEYS.topMeetings(category), + meetingId, + true, + 1, + ); + return; + } + + // 상세모임 + if (id) { + updateCacheObject(meetingKeys.detailInfo(meetingId), true, 1); + return; + } + }, + onSuccess: () => { + showToast('찜한 모임에 추가되었습니다!', 'success', { duration: 2000 }); + }, + onError: () => { + // TODO: 내가 만든 모임 좋아요 할 경우 toast 문구 변경 + showToast('잠시 후 다시 시도해주세요', 'error', { duration: 3000 }); + + // 모임 목록 + if (category && searchQuery) { + updateInfiniteMeetingCache( + MEETING_QUERY_KEYS.meetings(category, searchQuery), + meetingId, + false, + -1, + ); + return; + } + + // 추천모임 + if (category && !id) { + updateCacheArray( + MEETING_QUERY_KEYS.topMeetings(category), + meetingId, + false, + -1, + ); + return; + } + + // 상세모임 + if (id) { + updateCacheObject(meetingKeys.detailInfo(meetingId), false, -1); + return; + } + }, + }); + + const { mutate: cancelLikeMutation } = useCancelLikeMeeting(meetingId, { + onMutate: () => { + // 모임 목록 + if (category && searchQuery) { + updateInfiniteMeetingCache( + MEETING_QUERY_KEYS.meetings(category, searchQuery), + meetingId, + false, + -1, + ); + return; + } + + // 추천모임 + if (category && !id) { + updateCacheArray( + MEETING_QUERY_KEYS.topMeetings(category), + meetingId, + false, + -1, + ); + return; + } + + // 상세모임 + if (id) { + updateCacheObject(meetingKeys.detailInfo(meetingId), false, -1); + return; + } + }, + onSuccess: () => { + showToast('찜한 모임에서 삭제되었습니다!', 'success', { duration: 2000 }); + }, + onError: () => { + // 오류 처리 + showToast('잠시 후 다시 시도해주세요', 'error', { duration: 3000 }); + + // 모임 목록 + if (category && searchQuery) { + updateInfiniteMeetingCache( + MEETING_QUERY_KEYS.meetings(category, searchQuery), + meetingId, + true, + 1, + ); + return; + } + + // 추천모임 + if (category && !id) { + updateCacheArray( + MEETING_QUERY_KEYS.topMeetings(category), + meetingId, + true, + 1, + ); + return; + } + + // 상세모임 + if (id) { + updateCacheObject(meetingKeys.detailInfo(meetingId), true, 1); + return; + } + }, + }); + + // 좋아요 토글 함수 + const toggleLike = async (isLiked: boolean) => { + const token = await getAccessToken(); + if (!token) { + // 로그인 상태가 아니라면, onAuthRequired 콜백 실행 + if (onAuthRequired) { + onAuthRequired(); + } + return; + } + + if (!isLiked) { + likeMutation(); + } else { + cancelLikeMutation(); + } + }; + + return { toggleLike }; +}; + +export default useLikeHandler; diff --git a/src/hooks/common/useMediaQuery.ts b/src/hooks/common/useMediaQuery.ts index df4a74e..94fc963 100644 --- a/src/hooks/common/useMediaQuery.ts +++ b/src/hooks/common/useMediaQuery.ts @@ -9,9 +9,9 @@ const useMediaQuery = () => { useEffect(() => { const handleResize = () => { const width = window.innerWidth; - if (width > 376 && width <= 745) { + if (width > 376 && width < 745) { setBreakpoint('mobile'); - } else if (width > 745 && width <= 1020) { + } else if (width >= 745 && width <= 1020) { setBreakpoint('tablet'); } else if (width > 1020) { setBreakpoint('desktop'); diff --git a/src/hooks/queries/useMeetingQueries.ts b/src/hooks/queries/useMeetingQueries.ts index eb40a62..2d22d42 100644 --- a/src/hooks/queries/useMeetingQueries.ts +++ b/src/hooks/queries/useMeetingQueries.ts @@ -7,6 +7,7 @@ import { } from 'service/api/meeting'; import type { CategoryTitle, IMeetingSearchCondition } from 'types/meeting'; +// 검색 쿼리 객체 기술 스킬 정렬 const getSortedSearchQuery = ( searchQueryObj: IMeetingSearchCondition, ): IMeetingSearchCondition => ({ @@ -14,6 +15,7 @@ const getSortedSearchQuery = ( skillArray: [...searchQueryObj.skillArray].sort(), }); +// 모임 쿼리키 const MEETING_QUERY_KEYS = { topMeetings: (category: string) => ['topMeetings', category] as const, meetings: (category: string, searchQueryObj: IMeetingSearchCondition) => { @@ -33,6 +35,8 @@ const MEETING_QUERY_KEYS = { id, ] as const, }; + +// 추천 모임 const useTopMeetings = (category: CategoryTitle, options = {}) => { return useQuery({ queryKey: MEETING_QUERY_KEYS.topMeetings(category), @@ -41,6 +45,7 @@ const useTopMeetings = (category: CategoryTitle, options = {}) => { }); }; +// 모임 리스트 const useInfiniteSearchMeetings = ( category: CategoryTitle, searchQueryObj: IMeetingSearchCondition, diff --git a/src/service/api/meeting.ts b/src/service/api/meeting.ts index 65ae7f1..34cff72 100644 --- a/src/service/api/meeting.ts +++ b/src/service/api/meeting.ts @@ -14,12 +14,9 @@ const getTopMeetings = async ( ): Promise => { const token = await getAccessToken(); - const res = await (token ? authAPI : basicAPI).get( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/meetings/top`, - { - params: { categoryTitle }, - }, - ); + const res = await (token ? authAPI : basicAPI).get(`/api/v1/meetings/top`, { + params: { categoryTitle }, + }); return res.data.data; }; @@ -33,7 +30,7 @@ const getMeetings = async ( const token = await getAccessToken(); const res = await (token ? authAPI : basicAPI).post( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/meetings/search?categoryTitle=${category}`, + `/api/v1/meetings/search?categoryTitle=${category}`, newSearchQueryObj, );