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
40 changes: 18 additions & 22 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import type { Metadata } from 'next';

import { InfiniteData } from '@tanstack/react-query';
import { Suspense } from 'react';

import { API } from '@/api';
import GroupList from '@/components/pages/group-list';
import { GroupSearchBar } from '@/components/pages/group-list/group-search-bar';
import { CardSkeleton } from '@/components/shared/card/card-skeleton';
import { GROUP_LIST_PAGE_SIZE } from '@/lib/constants/group-list';
import { generateHomeMetadata } from '@/lib/metadata/home';
import { GetGroupsResponse } from '@/types/service/group';

export const dynamic = 'force-dynamic';

interface HomePageProps {
searchParams: Promise<{ keyword?: string }>;
Expand All @@ -20,26 +17,25 @@ export const generateMetadata = async ({ searchParams }: HomePageProps): Promise
return await generateHomeMetadata(params.keyword);
};

export default async function HomePage({ searchParams }: HomePageProps) {
const params = await searchParams;
const keyword = params.keyword;

const response = await API.groupService.getGroups({
size: GROUP_LIST_PAGE_SIZE,
keyword,
});

// React Query의 useInfiniteQuery에 맞는 initialData 형태로 변환
const initialData: InfiniteData<GetGroupsResponse, number | undefined> = {
pages: [response],
pageParams: [undefined], // 첫 페이지는 cursor가 없으므로 undefined
};

// 초기 데이터를 전달해서 무한 스크롤 시작
export default async function HomePage(_props: HomePageProps) {
return (
<>
<GroupSearchBar />
<GroupList initialData={initialData} initialKeyword={keyword} />
<Suspense fallback={<GroupListSkeleton />}>
<GroupList />
</Suspense>
</>
);
}

const GroupListSkeleton = () => (
<section className={`min-h-[calc(100vh-168px)] bg-[#F1F5F9]`}>
<div className='flex w-full flex-col px-4 py-4'>
<div className='flex w-full flex-col gap-4'>
{Array.from({ length: GROUP_LIST_PAGE_SIZE }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
</div>
</section>
);
41 changes: 41 additions & 0 deletions src/components/pages/group-list/group-list-content/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useRouter } from 'next/navigation';

import Card from '@/components/shared/card';
import { formatDateTime } from '@/lib/formatDateTime';
import { GroupListItemResponse } from '@/types/service/group';

interface GroupListContentProps {
items: GroupListItemResponse[];
keyword?: string;
}

export const GroupListContent = ({ items, keyword }: GroupListContentProps) => {
const router = useRouter();
const hasKeyword = Boolean(keyword);

return (
<div
className={`flex w-full flex-col gap-4 ${hasKeyword ? 'mt-3' : 'py-4'}`}
aria-label={hasKeyword ? `${keyword} 검색 결과` : '모임 목록'}
role='list'
>
{items.map((meeting) => (
<Card
key={meeting.id}
dateTime={formatDateTime(meeting.startTime)}
images={meeting.images}
isFinished={meeting.status === 'FINISHED'}
isPending={meeting.myMembership?.status === 'PENDING'}
location={meeting.location}
maxParticipants={meeting.maxParticipants}
nickName={meeting.createdBy.nickName}
participantCount={meeting.participantCount}
profileImage={meeting.createdBy.profileImage}
tags={meeting.tags}
title={meeting.title}
onClick={() => router.push(`/group/${meeting.id}`)}
/>
))}
</div>
);
};
32 changes: 32 additions & 0 deletions src/components/pages/group-list/group-list-empty/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useRouter } from 'next/navigation';

import { EmptyState } from '@/components/layout/empty-state';
import { Button } from '@/components/ui';
import {
GROUP_LIST_CREATE_BUTTON_WIDTH,
GROUP_LIST_EMPTY_BUTTON_TOP_MARGIN,
GROUP_LIST_EMPTY_MIN_HEIGHT,
} from '@/lib/constants/group-list';

export const GroupListEmpty = () => {
const router = useRouter();

return (
<div
className={`relative flex ${GROUP_LIST_EMPTY_MIN_HEIGHT} flex-col items-center justify-center py-8`}
>
<EmptyState>
아직 모임이 없어요.
<br />
지금 바로 모임을 만들어보세요!
</EmptyState>

<Button
className={`bg-mint-500 text-text-sm-bold text-mono-white hover:bg-mint-600 active:bg-mint-700 relative z-10 ${GROUP_LIST_EMPTY_BUTTON_TOP_MARGIN} h-10 ${GROUP_LIST_CREATE_BUTTON_WIDTH} rounded-xl`}
onClick={() => router.push('/create-group')}
>
모임 만들기
</Button>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { type RefObject } from 'react';

interface GroupListInfiniteScrollProps {
sentinelRef: RefObject<HTMLDivElement | null>;
hasNextPage: boolean;
isFetchingNextPage: boolean;
completedMessage: string;
hasError: boolean;
}

export const GroupListInfiniteScroll = ({
sentinelRef,
hasNextPage,
isFetchingNextPage,
completedMessage,
hasError,
}: GroupListInfiniteScrollProps) => {
if (hasNextPage && !hasError) {
return (
<>
<div ref={sentinelRef} aria-hidden='true' className='h-1' />
{isFetchingNextPage && (
<div
className='py-8 text-center text-gray-500'
aria-label='더 많은 모임을 불러오는 중입니다'
aria-live='polite'
role='status'
>
<div className='flex items-center justify-center gap-2'>
<div className='border-t-mint-500 h-5 w-5 animate-spin rounded-full border-2 border-gray-300' />
<span>더 많은 모임을 불러오는 중...</span>
</div>
</div>
)}
</>
);
}

if (!hasNextPage && !hasError) {
return (
<div className='py-8 text-center text-gray-500' aria-live='polite' role='status'>
{completedMessage}
</div>
);
}

return null;
};
14 changes: 14 additions & 0 deletions src/components/pages/group-list/group-list-loading/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { CardSkeleton } from '@/components/shared/card/card-skeleton';
import { GROUP_LIST_MIN_HEIGHT, GROUP_LIST_PAGE_SIZE } from '@/lib/constants/group-list';

export const GroupListLoading = () => (
<section className={`${GROUP_LIST_MIN_HEIGHT} bg-[#F1F5F9]`}>
<div className='flex w-full flex-col px-4 py-4'>
<div className='flex w-full flex-col gap-4'>
{Array.from({ length: GROUP_LIST_PAGE_SIZE }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
</div>
</section>
);
13 changes: 13 additions & 0 deletions src/components/pages/group-list/group-list-search-empty/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { EmptyState } from '@/components/layout/empty-state';
import {
GROUP_LIST_SEARCH_EMPTY_HEIGHT,
GROUP_LIST_SEARCH_EMPTY_TOP_MARGIN,
} from '@/lib/constants/group-list';

export const GroupListSearchEmpty = () => (
<div
className={`relative ${GROUP_LIST_SEARCH_EMPTY_TOP_MARGIN} flex ${GROUP_LIST_SEARCH_EMPTY_HEIGHT} flex-col items-center justify-center`}
>
<EmptyState>검색 결과가 없어요.</EmptyState>
</div>
);
Loading