Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
47 changes: 29 additions & 18 deletions src/app/schedule/(components)/current.tsx
Original file line number Diff line number Diff line change
@@ -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<GroupListItemResponse, ['myGroups', 'current']>({
queryFn: async ({ cursor, size }) => {
return await API.groupService.getMyGroups({ type: 'current', cursor, size });
},
queryKey: ['myGroups', 'current'],
pageSize: 10,
errorMessage: '현재 모임 목록을 불러오는데 실패했습니다.',
completedMessage: '모든 현재 모임을 불러왔습니다.',
});

if (isLoading) {
return (
<div className='flex items-center justify-center py-8'>
<div className='text-gray-500'>로딩 중...</div>
</div>
);
}

if (error) {
return (
<div className='flex items-center justify-center py-8'>
<div className='text-red-500'>데이터를 불러오는 중 오류가 발생했습니다.</div>
</div>
);
}
const sentinelRef = useIntersectionObserver({
onIntersect: () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
enabled: hasNextPage && error === null,
threshold: INTERSECTION_OBSERVER_THRESHOLD,
});

return (
<MeetingList
completedMessage={completedMessage}
emptyStatePath='/'
emptyStateType='current'
error={error}
hasNextPage={hasNextPage}
leaveActionText='모임 탈퇴'
meetings={data?.items || []}
meetings={items}
sentinelRef={sentinelRef}
showActions={true}
tabType='current'
/>
Expand Down
47 changes: 29 additions & 18 deletions src/app/schedule/(components)/history.tsx
Original file line number Diff line number Diff line change
@@ -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<GroupListItemResponse, ['myGroups', 'past']>({
queryFn: async ({ cursor, size }) => {
return await API.groupService.getMyGroups({ type: 'past', cursor, size });
},
queryKey: ['myGroups', 'past'],
pageSize: 10,
errorMessage: '모임 이력을 불러오는데 실패했습니다.',
completedMessage: '모든 모임 이력을 불러왔습니다.',
});

if (isLoading) {
return (
<div className='flex items-center justify-center py-8'>
<div className='text-gray-500'>로딩 중...</div>
</div>
);
}

if (error) {
return (
<div className='flex items-center justify-center py-8'>
<div className='text-red-500'>데이터를 불러오는 중 오류가 발생했습니다.</div>
</div>
);
}
const sentinelRef = useIntersectionObserver({
onIntersect: () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
enabled: hasNextPage && error === null,
threshold: INTERSECTION_OBSERVER_THRESHOLD,
});

return (
<MeetingList
completedMessage={completedMessage}
emptyStatePath='/'
emptyStateType='past'
meetings={data?.items || []}
error={error}
hasNextPage={hasNextPage}
meetings={items}
sentinelRef={sentinelRef}
showActions={false}
tabType='past'
/>
Expand Down
35 changes: 34 additions & 1 deletion src/app/schedule/(components)/meeting-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -17,6 +20,10 @@ type MeetingListProps = {
emptyStatePath: string;
showActions: boolean;
leaveActionText?: string;
error?: Error | null;
hasNextPage?: boolean;
sentinelRef?: RefObject<HTMLDivElement | null>;
completedMessage?: string;
};

export const MeetingList = ({
Expand All @@ -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 <EmptyState type={emptyStateType} onButtonClick={() => router.push(emptyStatePath)} />;
}

return (
<section className='flex w-full flex-col gap-4 px-4 py-4'>
{error && meetings.length === 0 && (
<ErrorMessage
className='py-12'
message={error.message}
onRetry={() => window.location.reload()}
/>
)}

{meetings.map((meeting) => (
<Card
key={meeting.id}
Expand All @@ -59,6 +78,20 @@ export const MeetingList = ({
onClick={() => router.push(`/meetup/${meeting.id}`)}
/>
))}

{error && meetings.length > 0 && (
<ErrorMessage
className='py-8'
message={error.message}
onRetry={() => window.location.reload()}
/>
)}

{hasNextPage && !error && sentinelRef && <div ref={sentinelRef} className='h-1' />}

{!hasNextPage && meetings.length > 0 && !error && completedMessage && (
<div className='py-8 text-center text-gray-500'>{completedMessage}</div>
)}
</section>
);
};
47 changes: 29 additions & 18 deletions src/app/schedule/(components)/my.tsx
Original file line number Diff line number Diff line change
@@ -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<GroupListItemResponse, ['myGroups', 'myPost']>({
queryFn: async ({ cursor, size }) => {
return await API.groupService.getMyGroups({ type: 'myPost', cursor, size });
},
queryKey: ['myGroups', 'myPost'],
pageSize: 10,
errorMessage: '나의 모임 목록을 불러오는데 실패했습니다.',
completedMessage: '모든 나의 모임을 불러왔습니다.',
});

if (isLoading) {
return (
<div className='flex items-center justify-center py-8'>
<div className='text-gray-500'>로딩 중...</div>
</div>
);
}

if (error) {
return (
<div className='flex items-center justify-center py-8'>
<div className='text-red-500'>데이터를 불러오는 중 오류가 발생했습니다.</div>
</div>
);
}
const sentinelRef = useIntersectionObserver({
onIntersect: () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
enabled: hasNextPage && error === null,
threshold: INTERSECTION_OBSERVER_THRESHOLD,
});

return (
<MeetingList
completedMessage={completedMessage}
emptyStatePath='/post-meetup'
emptyStateType='myPost'
error={error}
hasNextPage={hasNextPage}
leaveActionText='모임 취소'
meetings={data?.items || []}
meetings={items}
sentinelRef={sentinelRef}
showActions={true}
tabType='myPost'
/>
Expand Down
5 changes: 4 additions & 1 deletion src/components/pages/user/profile/profile-card/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { DEFAULT_PROFILE_IMAGE } from 'constants/default-images';

import { ImageWithFallback } from '@/components/ui';
import { User } from '@/types/service/user';

Expand All @@ -13,9 +15,10 @@ export const ProfileCard = ({ user }: Props) => {
<ImageWithFallback
className='object-cover'
alt='프로필 이미지'
fallbackSrc={DEFAULT_PROFILE_IMAGE}
fill
loading='eager'
src={profileImage}
src={profileImage || ''}
/>
</div>
<h1 className='text-text-xl-bold text-gray-900'>{nickName}</h1>
Expand Down
19 changes: 7 additions & 12 deletions src/components/shared/card/card-profile/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Image from 'next/image';
import { ImageWithFallback } from '@/components/ui';

type CardProfileProps = {
nickName: string;
Expand All @@ -11,17 +11,12 @@ const DEFAULT_SIZE = 16;
export const CardProfile = ({ nickName, profileImage, size = DEFAULT_SIZE }: CardProfileProps) => {
return (
<div className='mt-3 flex items-center gap-1.5'>
{profileImage ? (
<Image
width={size}
className='rounded-full object-cover'
alt={nickName}
height={size}
src={profileImage}
/>
) : (
<div className='rounded-full bg-gray-600' style={{ width: size, height: size }} />
)}
<div
className='relative shrink-0 overflow-hidden rounded-full'
style={{ width: size, height: size }}
>
<ImageWithFallback className='object-cover' alt={nickName} fill src={profileImage || ''} />
</div>
<span className='text-text-xs-medium text-gray-900'>{nickName}</span>
</div>
);
Expand Down
20 changes: 7 additions & 13 deletions src/components/shared/card/card-thumbnail/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,18 @@ import { CardThumbnail } from '.';
describe('CardThumbnail', () => {
const defaultProps = {
title: '썸네일 테스트',
onError: jest.fn(),
};

test('썸네일이 없으면 회색 배경 박스만 렌더링된다', () => {
render(<CardThumbnail {...defaultProps} hasThumbnail={false} />);
test('썸네일이 없으면 기본 그룹 이미지를 렌더링한다', () => {
render(<CardThumbnail {...defaultProps} />);

// 이미지가 없으므로 alt로 찾을 수 있는 이미지 요소가 없어야 한다
expect(screen.queryByAltText(defaultProps.title)).not.toBeInTheDocument();
// 썸네일이 없으면 기본 그룹 이미지가 fallback으로 렌더링되어야 한다
const img = screen.getByAltText(defaultProps.title);
expect(img).toBeInTheDocument();
});

test('hasThumbnail과 thumbnail이 모두 truthy이면 이미지가 렌더링된다', () => {
render(
<CardThumbnail
{...defaultProps}
hasThumbnail
thumbnail='https://example.com/thumbnail.jpg'
/>,
);
test('thumbnail이 있으면 이미지가 렌더링된다', () => {
render(<CardThumbnail {...defaultProps} thumbnail='https://example.com/thumbnail.jpg' />);

const img = screen.getByAltText(defaultProps.title);
expect(img).toBeInTheDocument();
Expand Down
28 changes: 13 additions & 15 deletions src/components/shared/card/card-thumbnail/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='relative h-[100px] w-[100px] shrink-0 overflow-hidden rounded-2xl bg-gray-200'>
{hasThumbnail && thumbnail ? (
<Image
width={100}
className='h-full w-full object-cover'
alt={title}
height={100}
src={thumbnail}
unoptimized
onError={onError}
/>
) : null}
<ImageWithFallback
width={100}
className='h-full w-full object-cover'
alt={title}
fallbackSrc={DEFAULT_GROUP_IMAGE}
height={100}
src={thumbnail ?? ''}
unoptimized
/>
</div>
);
};
8 changes: 4 additions & 4 deletions src/components/shared/card/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ describe('Card', () => {
).toBeInTheDocument();
});

test('프로필 이미지가 없으면 회색 원형 플레이스홀더를 렌더링한다', () => {
test('프로필 이미지가 없으면 기본 프로필 이미지를 렌더링한다', () => {
render(<Card {...defaultProps} />);

// 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 () => {
Expand Down
Loading