diff --git a/eslint.config.js b/eslint.config.js index d6b3e63..375b80e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -42,6 +42,13 @@ export default defineConfig([ { selector: 'variable', format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + leadingUnderscore: 'allow', + }, + // 매개변수: camelCase, 언더스코어로 시작 가능 (unused params) + { + selector: 'parameter', + format: ['camelCase'], + leadingUnderscore: 'allow', }, // 함수: camelCase, PascalCase (컴포넌트) { diff --git a/src/api/index.ts b/src/api/index.ts index 28a1467..12ab87a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,4 +3,9 @@ export { API_BASE, API_PATHS } from './endpoints' export type { ErrorCodeType } from './errors' export { ApiError, ErrorCode, ErrorMessage } from './errors' export { setupInterceptors } from './interceptors' -export type { ApiErrorResponse, ApiResponse, PaginatedResponse } from './types' +export type { + ApiErrorResponse, + ApiResponse, + CursorPaginatedResponse, + PaginatedResponse, +} from './types' diff --git a/src/api/types.ts b/src/api/types.ts index 4620621..4a55394 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -78,3 +78,34 @@ export type PaginatedResponse = { /** 전체 페이지 수 */ totalPages: number } + +/** + * 커서 기반 페이지네이션 응답 타입 + * + * @template T - 아이템의 타입 + * @template C - 커서의 타입 + * + * @example + * ```typescript + * // 커서 기반 페이지네이션 응답 예시 + * { + * "items": [{ "id": 1, "name": "모임1" }, { "id": 2, "name": "모임2" }], + * "pageSize": 10, + * "hasNext": true, + * "nextCursor": { "joinedAt": "2024-01-01T00:00:00", "id": 2 }, + * "totalCount": 50 + * } + * ``` + */ +export type CursorPaginatedResponse = { + /** 현재 페이지의 아이템 배열 */ + items: T[] + /** 페이지 크기 (한 페이지당 아이템 수) */ + pageSize: number + /** 다음 페이지 존재 여부 */ + hasNext: boolean + /** 다음 페이지 커서 (없으면 null) */ + nextCursor: C | null + /** 전체 아이템 수 (첫 페이지 응답에만 포함될 수 있음) */ + totalCount?: number +} diff --git a/src/features/gatherings/components/EmptyState.tsx b/src/features/gatherings/components/EmptyState.tsx new file mode 100644 index 0000000..248dd7b --- /dev/null +++ b/src/features/gatherings/components/EmptyState.tsx @@ -0,0 +1,25 @@ +interface EmptyStateProps { + type?: 'all' | 'favorites' +} + +export default function EmptyState({ type = 'all' }: EmptyStateProps) { + const message = + type === 'all' ? ( + <> + 아직 참여 중인 모임이 없어요. +
첫 번째 모임을 시작해 보세요! + + ) : ( + <> + 즐겨찾기한 모임이 없어요. +
+ 자주 방문하는 모임을 즐겨찾기에 추가해 보세요! + + ) + + return ( +
+

{message}

+
+ ) +} diff --git a/src/features/gatherings/components/GatheringCard.tsx b/src/features/gatherings/components/GatheringCard.tsx new file mode 100644 index 0000000..d0494cd --- /dev/null +++ b/src/features/gatherings/components/GatheringCard.tsx @@ -0,0 +1,84 @@ +import { Star } from 'lucide-react' +import type { MouseEvent } from 'react' + +import { cn } from '@/shared/lib/utils' + +import type { GatheringListItem } from '../gatherings.types' + +interface GatheringCardProps { + gathering: GatheringListItem + onFavoriteToggle: (gatheringId: number) => void + onClick: () => void +} + +export default function GatheringCard({ + gathering, + onFavoriteToggle, + onClick, +}: GatheringCardProps) { + const { + gatheringId, + gatheringName, + isFavorite, + totalMembers, + totalMeetings, + currentUserRole, + daysFromJoined, + } = gathering + + const isLeader = currentUserRole === 'LEADER' + + const handleFavoriteClick = (e: MouseEvent) => { + e.stopPropagation() + onFavoriteToggle(gatheringId) + } + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick() + } + }} + > + {/* 상단 영역: 배지 + 모임 이름 */} +
+ {isLeader && ( + + 모임장 + + )} +

{gatheringName}

+
+ + {/* 하단 영역: 메타 정보 */} +
+ 멤버 {totalMembers}명 + + 시작한지 {daysFromJoined}일 + + 약속 {totalMeetings}회 +
+ + {/* 즐겨찾기 버튼 */} + +
+ ) +} diff --git a/src/features/gatherings/components/index.ts b/src/features/gatherings/components/index.ts new file mode 100644 index 0000000..4ea95ab --- /dev/null +++ b/src/features/gatherings/components/index.ts @@ -0,0 +1,2 @@ +export { default as EmptyState } from './EmptyState' +export { default as GatheringCard } from './GatheringCard' diff --git a/src/features/gatherings/gatherings.api.ts b/src/features/gatherings/gatherings.api.ts index 7c40713..06a6c0a 100644 --- a/src/features/gatherings/gatherings.api.ts +++ b/src/features/gatherings/gatherings.api.ts @@ -4,8 +4,11 @@ import { GATHERINGS_ENDPOINTS } from './gatherings.endpoints' import type { CreateGatheringRequest, CreateGatheringResponse, + FavoriteGatheringListResponse, GatheringByInviteCodeResponse, GatheringJoinResponse, + GatheringListResponse, + GetGatheringsParams, } from './gatherings.types' /** @@ -49,3 +52,46 @@ export const joinGathering = async (invitationCode: string) => { ) return response.data } + +/** + * 내 모임 전체 목록 조회 (커서 기반 무한 스크롤) + * + * @param params - 조회 파라미터 + * @param params.pageSize - 페이지 크기 (기본: 9) + * @param params.cursorJoinedAt - 마지막 항목의 가입일시 (ISO 8601) + * @param params.cursorId - 마지막 항목의 ID + * @returns 모임 목록 및 페이지네이션 정보 + */ +export const getGatherings = async (params?: GetGatheringsParams) => { + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.BASE, + { + params, + } + ) + return response.data +} + +/** + * 즐겨찾기 모임 목록 조회 + * + * @returns 즐겨찾기 모임 목록 (최대 4개) + */ +export const getFavoriteGatherings = async () => { + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.FAVORITES + ) + return response.data +} + +/** + * 모임 즐겨찾기 토글 + * + * @param gatheringId - 모임 ID + */ +export const toggleFavorite = async (gatheringId: number) => { + const response = await apiClient.patch>( + GATHERINGS_ENDPOINTS.TOGGLE_FAVORITE(gatheringId) + ) + return response.data +} diff --git a/src/features/gatherings/gatherings.endpoints.ts b/src/features/gatherings/gatherings.endpoints.ts index 6f82784..d491a03 100644 --- a/src/features/gatherings/gatherings.endpoints.ts +++ b/src/features/gatherings/gatherings.endpoints.ts @@ -3,6 +3,10 @@ import { API_PATHS } from '@/api' export const GATHERINGS_ENDPOINTS = { /** 모임 목록/생성 */ BASE: API_PATHS.GATHERINGS, + /** 즐겨찾기 모임 목록 조회 */ + FAVORITES: `${API_PATHS.GATHERINGS}/favorites`, + /** 즐겨찾기 토글 */ + TOGGLE_FAVORITE: (gatheringId: number) => `${API_PATHS.GATHERINGS}/${gatheringId}/favorites`, /** 초대 코드로 모임 정보 조회 / 가입 신청 */ JOIN_REQUEST: (invitationCode: string) => `${API_PATHS.GATHERINGS}/join-request/${invitationCode}`, diff --git a/src/features/gatherings/gatherings.types.ts b/src/features/gatherings/gatherings.types.ts index 0fddb93..452d0ec 100644 --- a/src/features/gatherings/gatherings.types.ts +++ b/src/features/gatherings/gatherings.types.ts @@ -1,3 +1,5 @@ +import type { CursorPaginatedResponse } from '@/api' + /** 모임 기본 정보 (공통) */ export interface GatheringBase { /** 모임 이름 */ @@ -43,3 +45,54 @@ export interface GatheringJoinResponse { /** 가입 상태 */ memberStatus: GatheringMemberStatus } + +/** 모임 상태 */ +export type GatheringStatus = 'ACTIVE' | 'INACTIVE' + +/** 모임 내 사용자 역할 */ +export type GatheringUserRole = 'LEADER' | 'MEMBER' + +/** 모임 목록 아이템 */ +export interface GatheringListItem { + /** 모임 ID */ + gatheringId: number + /** 모임 이름 */ + gatheringName: string + /** 즐겨찾기 여부 */ + isFavorite: boolean + /** 모임 상태 */ + gatheringStatus: GatheringStatus + /** 전체 멤버 수 */ + totalMembers: number + /** 전체 약속 수 */ + totalMeetings: number + /** 현재 사용자 역할 */ + currentUserRole: GatheringUserRole + /** 가입 후 경과 일수 */ + daysFromJoined: number +} + +/** 커서 정보 (서버 응답) */ +export interface GatheringCursor { + joinedAt: string + gatheringMemberId: number +} + +/** 모임 목록 응답 (커서 기반 페이지네이션) */ +export type GatheringListResponse = CursorPaginatedResponse + +/** 즐겨찾기 모임 목록 응답 */ +export interface FavoriteGatheringListResponse { + /** 즐겨찾기 모임 목록 */ + gatherings: GatheringListItem[] +} + +/** 모임 목록 조회 파라미터 */ +export interface GetGatheringsParams { + /** 페이지 크기 (기본: 9) */ + pageSize?: number + /** 커서 - 마지막 항목의 가입일시 (ISO 8601) */ + cursorJoinedAt?: string + /** 커서 - 마지막 항목의 ID */ + cursorId?: number +} diff --git a/src/features/gatherings/hooks/gatheringQueryKeys.ts b/src/features/gatherings/hooks/gatheringQueryKeys.ts index 5110181..c3100b9 100644 --- a/src/features/gatherings/hooks/gatheringQueryKeys.ts +++ b/src/features/gatherings/hooks/gatheringQueryKeys.ts @@ -4,6 +4,7 @@ export const gatheringQueryKeys = { all: ['gatherings'] as const, lists: () => [...gatheringQueryKeys.all, 'list'] as const, + favorites: () => [...gatheringQueryKeys.all, 'favorites'] as const, detail: (id: number | string) => [...gatheringQueryKeys.all, 'detail', id] as const, byInviteCode: (invitationCode: string) => [...gatheringQueryKeys.all, 'invite', invitationCode] as const, diff --git a/src/features/gatherings/hooks/index.ts b/src/features/gatherings/hooks/index.ts index 49ddaab..11c588f 100644 --- a/src/features/gatherings/hooks/index.ts +++ b/src/features/gatherings/hooks/index.ts @@ -1,4 +1,7 @@ export * from './gatheringQueryKeys' export * from './useCreateGathering' +export * from './useFavoriteGatherings' export * from './useGatheringByInviteCode' +export * from './useGatherings' export * from './useJoinGathering' +export * from './useToggleFavorite' diff --git a/src/features/gatherings/hooks/useFavoriteGatherings.ts b/src/features/gatherings/hooks/useFavoriteGatherings.ts new file mode 100644 index 0000000..52a9c6c --- /dev/null +++ b/src/features/gatherings/hooks/useFavoriteGatherings.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query' + +import type { ApiError } from '@/api' + +import { getFavoriteGatherings } from '../gatherings.api' +import type { FavoriteGatheringListResponse } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** + * 즐겨찾기 모임 목록 조회 훅 + */ +export const useFavoriteGatherings = () => { + return useQuery({ + queryKey: gatheringQueryKeys.favorites(), + queryFn: async () => { + const response = await getFavoriteGatherings() + return response.data + }, + }) +} diff --git a/src/features/gatherings/hooks/useGatherings.ts b/src/features/gatherings/hooks/useGatherings.ts new file mode 100644 index 0000000..4489127 --- /dev/null +++ b/src/features/gatherings/hooks/useGatherings.ts @@ -0,0 +1,53 @@ +import { type InfiniteData, useInfiniteQuery } from '@tanstack/react-query' + +import { getGatherings } from '../gatherings.api' +import type { GatheringCursor, GatheringListResponse } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** 초기 로드 개수 (3열 × 4행) */ +const INITIAL_PAGE_SIZE = 12 +/** 추가 로드 개수 (3열 × 3행) */ +const NEXT_PAGE_SIZE = 9 + +/** 페이지 파라미터 타입: undefined = 첫 페이지, GatheringCursor = 다음 페이지 */ +type PageParam = GatheringCursor | undefined + +/** + * 내 모임 전체 목록 조회 훅 (무한 스크롤) + * + * @example + * ```tsx + * const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useGatherings() + * + * // 모임 목록 접근 + * const gatherings = data?.pages.flatMap(page => page.items) ?? [] + * + * // 다음 페이지 로드 + * if (hasNextPage) fetchNextPage() + * ``` + */ +export const useGatherings = () => { + return useInfiniteQuery< + GatheringListResponse, + Error, + InfiniteData, + readonly string[], + PageParam + >({ + queryKey: gatheringQueryKeys.lists(), + queryFn: async ({ pageParam }) => { + const isFirstPage = !pageParam + const response = await getGatherings({ + pageSize: isFirstPage ? INITIAL_PAGE_SIZE : NEXT_PAGE_SIZE, + cursorJoinedAt: pageParam?.joinedAt, + cursorId: pageParam?.gatheringMemberId, + }) + return response.data + }, + initialPageParam: undefined, + getNextPageParam: (lastPage): PageParam => { + if (!lastPage.hasNext || !lastPage.nextCursor) return undefined + return lastPage.nextCursor + }, + }) +} diff --git a/src/features/gatherings/hooks/useToggleFavorite.ts b/src/features/gatherings/hooks/useToggleFavorite.ts new file mode 100644 index 0000000..719121b --- /dev/null +++ b/src/features/gatherings/hooks/useToggleFavorite.ts @@ -0,0 +1,87 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import type { ApiError } from '@/api' + +import { toggleFavorite } from '../gatherings.api' +import type { FavoriteGatheringListResponse, GatheringListResponse } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** + * 모임 즐겨찾기 토글 훅 + * + * - Optimistic update 적용 + * - 실패 시 롤백 + */ +interface ToggleFavoriteContext { + previousLists: unknown + previousFavorites: unknown +} + +export const useToggleFavorite = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (gatheringId: number) => { + await toggleFavorite(gatheringId) + }, + onMutate: async (gatheringId) => { + // 진행 중인 쿼리 취소 + await queryClient.cancelQueries({ queryKey: gatheringQueryKeys.lists() }) + await queryClient.cancelQueries({ queryKey: gatheringQueryKeys.favorites() }) + + // 이전 데이터 스냅샷 + const previousLists = queryClient.getQueryData(gatheringQueryKeys.lists()) + const previousFavorites = queryClient.getQueryData(gatheringQueryKeys.favorites()) + + // Optimistic update - 목록에서 isFavorite 토글 + queryClient.setQueryData<{ pages: GatheringListResponse[]; pageParams: unknown[] }>( + gatheringQueryKeys.lists(), + (old) => { + if (!old) return old + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((item) => + item.gatheringId === gatheringId ? { ...item, isFavorite: !item.isFavorite } : item + ), + })), + } + } + ) + + // Optimistic update - 즐겨찾기 목록 + queryClient.setQueryData( + gatheringQueryKeys.favorites(), + (old) => { + if (!old) return old + const existing = old.gatherings.find((g) => g.gatheringId === gatheringId) + if (existing) { + // 즐겨찾기에서 제거 + return { + ...old, + gatherings: old.gatherings.filter((g) => g.gatheringId !== gatheringId), + } + } + return old + } + ) + + return { previousLists, previousFavorites } + }, + onError: (error, id, context) => { + console.error('Failed to toggle favorite:', { error, gatheringId: id }) + // 에러 시 롤백 + if (context?.previousLists) { + queryClient.setQueryData(gatheringQueryKeys.lists(), context.previousLists) + } + if (context?.previousFavorites) { + queryClient.setQueryData(gatheringQueryKeys.favorites(), context.previousFavorites) + } + }, + onSettled: () => { + // 즐겨찾기 목록만 최신 데이터로 갱신 (전체 목록은 optimistic update로 충분) + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.favorites() }) + }, + }) +} diff --git a/src/features/gatherings/index.ts b/src/features/gatherings/index.ts index a356069..368e484 100644 --- a/src/features/gatherings/index.ts +++ b/src/features/gatherings/index.ts @@ -1,6 +1,9 @@ // Hooks export * from './hooks' +// Components +export * from './components' + // API export * from './gatherings.api' @@ -8,8 +11,15 @@ export * from './gatherings.api' export type { CreateGatheringRequest, CreateGatheringResponse, + FavoriteGatheringListResponse, GatheringBase, GatheringByInviteCodeResponse, + GatheringCursor, GatheringJoinResponse, + GatheringListItem, + GatheringListResponse, GatheringMemberStatus, + GatheringStatus, + GatheringUserRole, + GetGatheringsParams, } from './gatherings.types' diff --git a/src/features/meetings/hooks/useCancelJoinMeeting.ts b/src/features/meetings/hooks/useCancelJoinMeeting.ts index 3d80a28..d424b74 100644 --- a/src/features/meetings/hooks/useCancelJoinMeeting.ts +++ b/src/features/meetings/hooks/useCancelJoinMeeting.ts @@ -24,8 +24,7 @@ export const useCancelJoinMeeting = () => { return useMutation, ApiError, number>({ mutationFn: (meetingId: number) => cancelJoinMeeting(meetingId), - onSuccess: (data, variables) => { - void data // 사용하지 않는 파라미터 + onSuccess: (_data, variables) => { // 약속 상세 캐시 무효화 queryClient.invalidateQueries({ queryKey: meetingQueryKeys.detail(variables), diff --git a/src/features/meetings/hooks/useJoinMeeting.ts b/src/features/meetings/hooks/useJoinMeeting.ts index 34aa980..ea63b82 100644 --- a/src/features/meetings/hooks/useJoinMeeting.ts +++ b/src/features/meetings/hooks/useJoinMeeting.ts @@ -24,8 +24,7 @@ export const useJoinMeeting = () => { return useMutation, ApiError, number>({ mutationFn: (meetingId: number) => joinMeeting(meetingId), - onSuccess: (data, variables) => { - void data // 사용하지 않는 파라미터 + onSuccess: (_data, variables) => { // 약속 상세 캐시 무효화 queryClient.invalidateQueries({ queryKey: meetingQueryKeys.detail(variables), diff --git a/src/pages/Gatherings/GatheringListPage.tsx b/src/pages/Gatherings/GatheringListPage.tsx index 69516fe..ecbef2d 100644 --- a/src/pages/Gatherings/GatheringListPage.tsx +++ b/src/pages/Gatherings/GatheringListPage.tsx @@ -1,3 +1,161 @@ +import { useCallback, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { type ApiError, ErrorCode } from '@/api' +import { + EmptyState, + GatheringCard, + useFavoriteGatherings, + useGatherings, + useToggleFavorite, +} from '@/features/gatherings' +import { ROUTES } from '@/shared/constants' +import { useInfiniteScroll } from '@/shared/hooks' +import { Button, Tabs, TabsList, TabsTrigger } from '@/shared/ui' +import { useGlobalModalStore } from '@/store' + +type TabValue = 'all' | 'favorites' + export default function GatheringListPage() { - return
Gathering List Page
+ const navigate = useNavigate() + const { openAlert } = useGlobalModalStore() + const [activeTab, setActiveTab] = useState('all') + + // 전체 모임 목록 (무한 스크롤) + const { + data: gatheringsData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + } = useGatherings() + + // 즐겨찾기 모임 목록 + const { data: favoritesData, isLoading: isFavoritesLoading } = useFavoriteGatherings() + + // 즐겨찾기 토글 + const { mutate: toggleFavorite } = useToggleFavorite() + + // 모임 목록 평탄화 + const gatherings = gatheringsData?.pages.flatMap((page) => page.items) ?? [] + const favorites = favoritesData?.gatherings ?? [] + + // 전체 개수 (첫 페이지 응답의 totalCount 사용) + const totalCount = gatheringsData?.pages[0]?.totalCount ?? 0 + const favoritesCount = favorites.length + + // 무한 스크롤 + const observerRef = useInfiniteScroll(fetchNextPage, { + hasNextPage, + isFetchingNextPage, + isLoading, + enabled: activeTab === 'all', + }) + + // 즐겨찾기 토글 핸들러 + const handleFavoriteToggle = useCallback( + (gatheringId: number) => { + toggleFavorite(gatheringId, { + onError: (error: ApiError) => { + if (error.is(ErrorCode.FAVORITE_LIMIT_EXCEEDED)) { + openAlert('알림', '즐겨찾기는 최대 4개까지만 등록할 수 있습니다.') + } else { + openAlert('오류', '즐겨찾기 변경에 실패했습니다.') + } + }, + }) + }, + [toggleFavorite, openAlert] + ) + + // 카드 클릭 핸들러 + const handleCardClick = useCallback( + (gatheringId: number) => { + navigate(ROUTES.GATHERING_DETAIL(gatheringId)) + }, + [navigate] + ) + + // 모임 만들기 버튼 클릭 + const handleCreateClick = () => { + navigate(ROUTES.GATHERING_CREATE) + } + + return ( +
+ {/* 타이틀 */} +

독서모임

+ + {/* 탭 + 버튼 영역 */} +
+ setActiveTab(value as TabValue)}> + + + 전체 + + + 즐겨찾기 + + + + +
+ + {/* 컨텐츠 영역 */} + {activeTab === 'all' && ( + <> + {isLoading ? ( +
+

로딩 중...

+
+ ) : gatherings.length === 0 ? ( + + ) : ( + <> +
+ {gatherings.map((gathering) => ( + handleCardClick(gathering.gatheringId)} + /> + ))} +
+ {/* 무한 스크롤 트리거 - 그리드 아래에 위치 */} + {hasNextPage &&
} + + )} + {isFetchingNextPage && ( +
+

로딩 중...

+
+ )} + + )} + + {activeTab === 'favorites' && ( + <> + {isFavoritesLoading ? ( +
+

로딩 중...

+
+ ) : favorites.length === 0 ? ( + + ) : ( +
+ {favorites.map((gathering) => ( + handleCardClick(gathering.gatheringId)} + /> + ))} +
+ )} + + )} +
+ ) } diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 2884370..53ac97b 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1 +1,2 @@ export * from './useDebounce' +export * from './useInfiniteScroll' diff --git a/src/shared/hooks/useInfiniteScroll.ts b/src/shared/hooks/useInfiniteScroll.ts new file mode 100644 index 0000000..f0dae3a --- /dev/null +++ b/src/shared/hooks/useInfiniteScroll.ts @@ -0,0 +1,85 @@ +import { useEffect, useRef } from 'react' + +interface UseInfiniteScrollOptions { + /** 다음 페이지가 있는지 여부 */ + hasNextPage: boolean | undefined + /** 다음 페이지를 가져오는 중인지 */ + isFetchingNextPage: boolean + /** 초기 로딩 중인지 */ + isLoading?: boolean + /** 활성화 여부 (탭 전환 등에서 사용) */ + enabled?: boolean + /** Intersection Observer threshold (기본: 0.1) */ + threshold?: number + /** Intersection Observer rootMargin (기본: '200px') */ + rootMargin?: string +} + +/** + * 무한 스크롤을 위한 Intersection Observer 훅 + * + * @param fetchNextPage - 다음 페이지를 가져오는 함수 + * @param options - 옵션 + * @returns observerRef - 감지할 요소에 연결할 ref + * + * @example + * ```tsx + * const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery(...) + * + * const observerRef = useInfiniteScroll(fetchNextPage, { + * hasNextPage, + * isFetchingNextPage, + * isLoading, + * enabled: activeTab === 'all', + * }) + * + * return ( + * <> + *
{items.map(...)}
+ * {hasNextPage &&
} + * + * ) + * ``` + */ +export const useInfiniteScroll = (fetchNextPage: () => void, options: UseInfiniteScrollOptions) => { + const { + hasNextPage, + isFetchingNextPage, + isLoading = false, + enabled = true, + threshold = 0.1, + rootMargin = '200px', + } = options + + const observerRef = useRef(null) + + useEffect(() => { + // 비브라우저 환경 또는 IntersectionObserver 미지원 환경 체크 + if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') return + + // 비활성화 상태이거나 로딩 중이거나 다음 페이지가 없으면 옵저버 설정 안함 + if (!observerRef.current || !enabled || isLoading || !hasNextPage) return + + const observer = new IntersectionObserver( + (entries) => { + if (!entries[0].isIntersecting || !hasNextPage || isFetchingNextPage) return + + // 페이지에 스크롤이 있는지 확인 + const hasScroll = document.documentElement.scrollHeight > window.innerHeight + // 스크롤이 없으면 바로 로드, 스크롤이 있으면 스크롤이 발생한 경우에만 로드 + const shouldFetch = !hasScroll || window.scrollY > 0 + + if (shouldFetch) { + fetchNextPage() + } + }, + { threshold, rootMargin } + ) + + observer.observe(observerRef.current) + + return () => observer.disconnect() + }, [enabled, hasNextPage, isFetchingNextPage, isLoading, fetchNextPage, threshold, rootMargin]) + + return observerRef +}