From cb5a1d924a11df62b3da3641bedfe7ed840586c5 Mon Sep 17 00:00:00 2001 From: minseokim Date: Mon, 15 Dec 2025 10:36:57 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=ED=83=AD=EC=97=90=20=EB=AC=B4=ED=95=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/schedule/(components)/current.tsx | 47 ++++++++++++------- src/app/schedule/(components)/history.tsx | 47 ++++++++++++------- .../schedule/(components)/meeting-list.tsx | 35 +++++++++++++- src/app/schedule/(components)/my.tsx | 47 ++++++++++++------- 4 files changed, 121 insertions(+), 55 deletions(-) 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 ( From bdd4d7d6de9afbb96d20cae4934acd3bcbc492fe Mon Sep 17 00:00:00 2001 From: minseokim Date: Mon, 15 Dec 2025 13:45:54 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EA=B8=B0=EB=B3=B8=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20fallback=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/pages/user/profile/profile-card/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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}

From c416d85da8c174db5a5b4ff6b2f591d2df001971 Mon Sep 17 00:00:00 2001 From: minseokim Date: Mon, 15 Dec 2025 13:47:22 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=B9=B4=EB=93=9C=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EC=97=90=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20=ED=81=AC=EA=B8=B0=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared/card/card-profile/index.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/shared/card/card-profile/index.tsx b/src/components/shared/card/card-profile/index.tsx index 5917628f..3cbd2df3 100644 --- a/src/components/shared/card/card-profile/index.tsx +++ b/src/components/shared/card/card-profile/index.tsx @@ -1,5 +1,7 @@ import Image from 'next/image'; +import { DEFAULT_PROFILE_IMAGE } from 'constants/default-images'; + type CardProfileProps = { nickName: string; profileImage?: string | null; @@ -11,17 +13,17 @@ const DEFAULT_SIZE = 16; export const CardProfile = ({ nickName, profileImage, size = DEFAULT_SIZE }: CardProfileProps) => { return (
- {profileImage ? ( +
{nickName} - ) : ( -
- )} +
{nickName}
); From 9eec781788584a4a9ed261fc5487075da06b9a3f Mon Sep 17 00:00:00 2001 From: minseokim Date: Mon, 15 Dec 2025 13:49:23 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=EC=B9=B4=EB=93=9C=20=EC=8D=B8?= =?UTF-8?q?=EB=84=A4=EC=9D=BC=20ImageWithFallback=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared/card/card-thumbnail/index.tsx | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/components/shared/card/card-thumbnail/index.tsx b/src/components/shared/card/card-thumbnail/index.tsx index ce10596b..bd6643b9 100644 --- a/src/components/shared/card/card-thumbnail/index.tsx +++ b/src/components/shared/card/card-thumbnail/index.tsx @@ -1,26 +1,25 @@ -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, hasThumbnail }: CardThumbnailProps) => { return (
- {hasThumbnail && thumbnail ? ( - {title} - ) : null} +
); }; From b8b99a791577ae443c7899ce36257de7afe8f719 Mon Sep 17 00:00:00 2001 From: minseokim Date: Mon, 15 Dec 2025 13:50:36 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=EC=B9=B4=EB=93=9C=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/shared/card/index.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/components/shared/card/index.tsx b/src/components/shared/card/index.tsx index 36dc2af2..57d8dcea 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,8 @@ const Card = ({ leaveAndChatActions, tabType, }: CardProps) => { - const [imageError, setImageError] = useState(false); - const thumbnail = images?.[0]; - const hasThumbnail = !!thumbnail && !imageError; + const hasThumbnail = !!thumbnail; const cardTags = convertToCardTags(tags); const progress = calculateProgress(participantCount, maxParticipants); const shouldShowButtons = leaveAndChatActions && tabType !== 'past'; @@ -73,12 +69,7 @@ const Card = ({
- setImageError(true)} - /> +
From 67f0c9a534ec77d5fc16b2cf58d941348ec1ae11 Mon Sep 17 00:00:00 2001 From: Min Seo Kim Date: Mon, 15 Dec 2025 14:11:32 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=EC=B9=B4=EB=93=9C=20=EC=8D=B8?= =?UTF-8?q?=EB=84=A4=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Hope <96109009+HopeFullee@users.noreply.github.com> --- src/components/shared/card/card-thumbnail/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/shared/card/card-thumbnail/index.tsx b/src/components/shared/card/card-thumbnail/index.tsx index bd6643b9..89f7c5b5 100644 --- a/src/components/shared/card/card-thumbnail/index.tsx +++ b/src/components/shared/card/card-thumbnail/index.tsx @@ -17,7 +17,7 @@ export const CardThumbnail = ({ title, thumbnail, hasThumbnail }: CardThumbnailP alt={title} fallbackSrc={DEFAULT_GROUP_IMAGE} height={100} - src={hasThumbnail && thumbnail ? thumbnail : ''} + src={thumbnail} unoptimized />
From 96aa06aeab52f10eaf1b032c0beb7c25fa5e4c45 Mon Sep 17 00:00:00 2001 From: minseokim Date: Mon, 15 Dec 2025 14:22:00 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20fallback=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../shared/card/card-profile/index.tsx | 11 ++-------- .../shared/card/card-thumbnail/index.test.tsx | 20 +++++++------------ .../shared/card/card-thumbnail/index.tsx | 5 ++--- src/components/shared/card/index.test.tsx | 8 ++++---- src/components/shared/card/index.tsx | 3 +-- 5 files changed, 16 insertions(+), 31 deletions(-) diff --git a/src/components/shared/card/card-profile/index.tsx b/src/components/shared/card/card-profile/index.tsx index 3cbd2df3..f8f184d6 100644 --- a/src/components/shared/card/card-profile/index.tsx +++ b/src/components/shared/card/card-profile/index.tsx @@ -1,6 +1,4 @@ -import Image from 'next/image'; - -import { DEFAULT_PROFILE_IMAGE } from 'constants/default-images'; +import { ImageWithFallback } from '@/components/ui'; type CardProfileProps = { nickName: string; @@ -17,12 +15,7 @@ export const CardProfile = ({ nickName, profileImage, size = DEFAULT_SIZE }: Car className='relative shrink-0 overflow-hidden rounded-full' style={{ width: size, height: size }} > - {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 89f7c5b5..63bd4d94 100644 --- a/src/components/shared/card/card-thumbnail/index.tsx +++ b/src/components/shared/card/card-thumbnail/index.tsx @@ -5,10 +5,9 @@ import { ImageWithFallback } from '@/components/ui'; type CardThumbnailProps = { title: string; thumbnail?: string; - hasThumbnail: boolean; }; -export const CardThumbnail = ({ title, thumbnail, hasThumbnail }: CardThumbnailProps) => { +export const CardThumbnail = ({ title, thumbnail }: CardThumbnailProps) => { return (
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 57d8dcea..8c36f62e 100644 --- a/src/components/shared/card/index.tsx +++ b/src/components/shared/card/index.tsx @@ -52,7 +52,6 @@ const Card = ({ tabType, }: CardProps) => { const thumbnail = images?.[0]; - const hasThumbnail = !!thumbnail; const cardTags = convertToCardTags(tags); const progress = calculateProgress(participantCount, maxParticipants); const shouldShowButtons = leaveAndChatActions && tabType !== 'past'; @@ -69,7 +68,7 @@ const Card = ({
- +