diff --git a/src/app/schedule/(components)/current.tsx b/src/app/schedule/(components)/current.tsx index 746d57fb..80802ced 100644 --- a/src/app/schedule/(components)/current.tsx +++ b/src/app/schedule/(components)/current.tsx @@ -1,34 +1,45 @@ 'use client'; -import { useGetMyGroups } from '@/hooks/use-group/use-group-get-my-list'; +import { API } from '@/api'; +import { useInfiniteScroll } from '@/hooks/use-group/use-group-infinite-list'; +import { useIntersectionObserver } from '@/hooks/use-intersection-observer'; +import { INTERSECTION_OBSERVER_THRESHOLD } from '@/lib/constants/group-list'; +import { GroupListItemResponse } from '@/types/service/group'; import { MeetingList } from './meeting-list'; export default function Current() { - const { data, isLoading, error } = useGetMyGroups({ type: 'current', size: 10 }); + const { items, error, fetchNextPage, hasNextPage, isFetchingNextPage, completedMessage } = + useInfiniteScroll({ + queryFn: async ({ cursor, size }) => { + return await API.groupService.getMyGroups({ type: 'current', cursor, size }); + }, + queryKey: ['myGroups', 'current'], + pageSize: 10, + errorMessage: '현재 모임 목록을 불러오는데 실패했습니다.', + completedMessage: '모든 현재 모임을 불러왔습니다.', + }); - if (isLoading) { - return ( -
-
로딩 중...
-
- ); - } - - if (error) { - return ( -
-
데이터를 불러오는 중 오류가 발생했습니다.
-
- ); - } + const sentinelRef = useIntersectionObserver({ + onIntersect: () => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + enabled: hasNextPage && error === null, + threshold: INTERSECTION_OBSERVER_THRESHOLD, + }); return ( diff --git a/src/app/schedule/(components)/history.tsx b/src/app/schedule/(components)/history.tsx index 1d80c142..cee6eb48 100644 --- a/src/app/schedule/(components)/history.tsx +++ b/src/app/schedule/(components)/history.tsx @@ -1,33 +1,44 @@ 'use client'; -import { useGetMyGroups } from '@/hooks/use-group/use-group-get-my-list'; +import { API } from '@/api'; +import { useInfiniteScroll } from '@/hooks/use-group/use-group-infinite-list'; +import { useIntersectionObserver } from '@/hooks/use-intersection-observer'; +import { INTERSECTION_OBSERVER_THRESHOLD } from '@/lib/constants/group-list'; +import { GroupListItemResponse } from '@/types/service/group'; import { MeetingList } from './meeting-list'; export default function History() { - const { data, isLoading, error } = useGetMyGroups({ type: 'past', size: 10 }); + const { items, error, fetchNextPage, hasNextPage, isFetchingNextPage, completedMessage } = + useInfiniteScroll({ + queryFn: async ({ cursor, size }) => { + return await API.groupService.getMyGroups({ type: 'past', cursor, size }); + }, + queryKey: ['myGroups', 'past'], + pageSize: 10, + errorMessage: '모임 이력을 불러오는데 실패했습니다.', + completedMessage: '모든 모임 이력을 불러왔습니다.', + }); - if (isLoading) { - return ( -
-
로딩 중...
-
- ); - } - - if (error) { - return ( -
-
데이터를 불러오는 중 오류가 발생했습니다.
-
- ); - } + const sentinelRef = useIntersectionObserver({ + onIntersect: () => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + enabled: hasNextPage && error === null, + threshold: INTERSECTION_OBSERVER_THRESHOLD, + }); return ( diff --git a/src/app/schedule/(components)/meeting-list.tsx b/src/app/schedule/(components)/meeting-list.tsx index 5547c84a..e4d69011 100644 --- a/src/app/schedule/(components)/meeting-list.tsx +++ b/src/app/schedule/(components)/meeting-list.tsx @@ -2,6 +2,9 @@ import { useRouter } from 'next/navigation'; +import { RefObject } from 'react'; + +import { ErrorMessage } from '@/components/shared'; import Card from '@/components/shared/card'; import { formatDateTime } from '@/lib/formatDateTime'; import { GroupListItemResponse } from '@/types/service/group'; @@ -17,6 +20,10 @@ type MeetingListProps = { emptyStatePath: string; showActions: boolean; leaveActionText?: string; + error?: Error | null; + hasNextPage?: boolean; + sentinelRef?: RefObject; + completedMessage?: string; }; export const MeetingList = ({ @@ -26,15 +33,27 @@ export const MeetingList = ({ emptyStatePath, showActions, leaveActionText, + error, + hasNextPage, + sentinelRef, + completedMessage, }: MeetingListProps) => { const router = useRouter(); - if (meetings.length === 0) { + if (meetings.length === 0 && !error) { return router.push(emptyStatePath)} />; } return (
+ {error && meetings.length === 0 && ( + window.location.reload()} + /> + )} + {meetings.map((meeting) => ( router.push(`/meetup/${meeting.id}`)} /> ))} + + {error && meetings.length > 0 && ( + window.location.reload()} + /> + )} + + {hasNextPage && !error && sentinelRef &&
} + + {!hasNextPage && meetings.length > 0 && !error && completedMessage && ( +
{completedMessage}
+ )}
); }; diff --git a/src/app/schedule/(components)/my.tsx b/src/app/schedule/(components)/my.tsx index 49b14664..cf1677ec 100644 --- a/src/app/schedule/(components)/my.tsx +++ b/src/app/schedule/(components)/my.tsx @@ -1,34 +1,45 @@ 'use client'; -import { useGetMyGroups } from '@/hooks/use-group/use-group-get-my-list'; +import { API } from '@/api'; +import { useInfiniteScroll } from '@/hooks/use-group/use-group-infinite-list'; +import { useIntersectionObserver } from '@/hooks/use-intersection-observer'; +import { INTERSECTION_OBSERVER_THRESHOLD } from '@/lib/constants/group-list'; +import { GroupListItemResponse } from '@/types/service/group'; import { MeetingList } from './meeting-list'; export default function My() { - const { data, isLoading, error } = useGetMyGroups({ type: 'myPost', size: 10 }); + const { items, error, fetchNextPage, hasNextPage, isFetchingNextPage, completedMessage } = + useInfiniteScroll({ + queryFn: async ({ cursor, size }) => { + return await API.groupService.getMyGroups({ type: 'myPost', cursor, size }); + }, + queryKey: ['myGroups', 'myPost'], + pageSize: 10, + errorMessage: '나의 모임 목록을 불러오는데 실패했습니다.', + completedMessage: '모든 나의 모임을 불러왔습니다.', + }); - if (isLoading) { - return ( -
-
로딩 중...
-
- ); - } - - if (error) { - return ( -
-
데이터를 불러오는 중 오류가 발생했습니다.
-
- ); - } + const sentinelRef = useIntersectionObserver({ + onIntersect: () => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + enabled: hasNextPage && error === null, + threshold: INTERSECTION_OBSERVER_THRESHOLD, + }); return ( diff --git a/src/components/pages/user/profile/profile-card/index.tsx b/src/components/pages/user/profile/profile-card/index.tsx index a0f485db..a0001357 100644 --- a/src/components/pages/user/profile/profile-card/index.tsx +++ b/src/components/pages/user/profile/profile-card/index.tsx @@ -1,3 +1,5 @@ +import { DEFAULT_PROFILE_IMAGE } from 'constants/default-images'; + import { ImageWithFallback } from '@/components/ui'; import { User } from '@/types/service/user'; @@ -13,9 +15,10 @@ export const ProfileCard = ({ user }: Props) => {

{nickName}

diff --git a/src/components/shared/card/card-profile/index.tsx b/src/components/shared/card/card-profile/index.tsx index 5917628f..f8f184d6 100644 --- a/src/components/shared/card/card-profile/index.tsx +++ b/src/components/shared/card/card-profile/index.tsx @@ -1,4 +1,4 @@ -import Image from 'next/image'; +import { ImageWithFallback } from '@/components/ui'; type CardProfileProps = { nickName: string; @@ -11,17 +11,12 @@ const DEFAULT_SIZE = 16; export const CardProfile = ({ nickName, profileImage, size = DEFAULT_SIZE }: CardProfileProps) => { return (
- {profileImage ? ( - {nickName} - ) : ( -
- )} +
+ +
{nickName}
); diff --git a/src/components/shared/card/card-thumbnail/index.test.tsx b/src/components/shared/card/card-thumbnail/index.test.tsx index 02c4fa36..a65a510a 100644 --- a/src/components/shared/card/card-thumbnail/index.test.tsx +++ b/src/components/shared/card/card-thumbnail/index.test.tsx @@ -5,24 +5,18 @@ import { CardThumbnail } from '.'; describe('CardThumbnail', () => { const defaultProps = { title: '썸네일 테스트', - onError: jest.fn(), }; - test('썸네일이 없으면 회색 배경 박스만 렌더링된다', () => { - render(); + test('썸네일이 없으면 기본 그룹 이미지를 렌더링한다', () => { + render(); - // 이미지가 없으므로 alt로 찾을 수 있는 이미지 요소가 없어야 한다 - expect(screen.queryByAltText(defaultProps.title)).not.toBeInTheDocument(); + // 썸네일이 없으면 기본 그룹 이미지가 fallback으로 렌더링되어야 한다 + const img = screen.getByAltText(defaultProps.title); + expect(img).toBeInTheDocument(); }); - test('hasThumbnail과 thumbnail이 모두 truthy이면 이미지가 렌더링된다', () => { - render( - , - ); + test('thumbnail이 있으면 이미지가 렌더링된다', () => { + render(); const img = screen.getByAltText(defaultProps.title); expect(img).toBeInTheDocument(); diff --git a/src/components/shared/card/card-thumbnail/index.tsx b/src/components/shared/card/card-thumbnail/index.tsx index ce10596b..63bd4d94 100644 --- a/src/components/shared/card/card-thumbnail/index.tsx +++ b/src/components/shared/card/card-thumbnail/index.tsx @@ -1,26 +1,24 @@ -import Image from 'next/image'; +import { DEFAULT_GROUP_IMAGE } from 'constants/default-images'; + +import { ImageWithFallback } from '@/components/ui'; type CardThumbnailProps = { title: string; thumbnail?: string; - hasThumbnail: boolean; - onError: () => void; }; -export const CardThumbnail = ({ title, thumbnail, hasThumbnail, onError }: CardThumbnailProps) => { +export const CardThumbnail = ({ title, thumbnail }: CardThumbnailProps) => { return (
- {hasThumbnail && thumbnail ? ( - {title} - ) : null} +
); }; diff --git a/src/components/shared/card/index.test.tsx b/src/components/shared/card/index.test.tsx index d402e7f7..6465b3f7 100644 --- a/src/components/shared/card/index.test.tsx +++ b/src/components/shared/card/index.test.tsx @@ -27,12 +27,12 @@ describe('Card', () => { ).toBeInTheDocument(); }); - test('프로필 이미지가 없으면 회색 원형 플레이스홀더를 렌더링한다', () => { + test('프로필 이미지가 없으면 기본 프로필 이미지를 렌더링한다', () => { render(); - // profileImage가 null이면 nickName을 alt로 가진 img 요소가 없어야 한다 - const profileImg = screen.queryByRole('img', { name: defaultProps.nickName }); - expect(profileImg).not.toBeInTheDocument(); + // profileImage가 null이면 기본 프로필 이미지가 렌더링되어야 한다 + const profileImg = screen.getByRole('img', { name: defaultProps.nickName }); + expect(profileImg).toBeInTheDocument(); }); test('onClick이 전달되면 카드 전체가 클릭 가능하고 핸들러가 호출된다', async () => { diff --git a/src/components/shared/card/index.tsx b/src/components/shared/card/index.tsx index 36dc2af2..8c36f62e 100644 --- a/src/components/shared/card/index.tsx +++ b/src/components/shared/card/index.tsx @@ -1,7 +1,5 @@ 'use client'; -import { useState } from 'react'; - import { Button } from '@/components/ui'; import { cn } from '@/lib/utils'; @@ -53,10 +51,7 @@ const Card = ({ leaveAndChatActions, tabType, }: CardProps) => { - const [imageError, setImageError] = useState(false); - const thumbnail = images?.[0]; - const hasThumbnail = !!thumbnail && !imageError; const cardTags = convertToCardTags(tags); const progress = calculateProgress(participantCount, maxParticipants); const shouldShowButtons = leaveAndChatActions && tabType !== 'past'; @@ -73,12 +68,7 @@ const Card = ({
- setImageError(true)} - /> +