diff --git a/src/app/schedule/_components/card.tsx b/src/app/schedule/_components/card.tsx new file mode 100644 index 00000000..df2657cc --- /dev/null +++ b/src/app/schedule/_components/card.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { useQuery } from '@tanstack/react-query'; + +import { API } from '@/api'; +import { GroupModal } from '@/components/pages/group/group-modal'; +import CardComponent from '@/components/shared/card'; +import { useModal } from '@/components/ui'; +import { formatDateTime } from '@/lib/formatDateTime'; +import { groupKeys } from '@/lib/query-key/query-key-group'; +import { GroupListItemResponse } from '@/types/service/group'; + +type TabType = 'current' | 'myPost' | 'past'; + +interface ScheduleCardProps { + createdBy: GroupListItemResponse['createdBy']; + groupId: string; + isFinished: boolean; + isHost: boolean; + isPending: boolean; + joinPolicy: GroupListItemResponse['joinPolicy']; + meeting: GroupListItemResponse; + modalType: 'pending' | 'leave' | 'delete'; + shouldFetchChatRoomId: boolean; + showActions: boolean; + tabType: TabType; +} + +export const ScheduleCard = ({ + createdBy, + groupId, + isFinished, + isHost, + isPending, + joinPolicy, + meeting, + modalType, + shouldFetchChatRoomId, + showActions, + tabType, +}: ScheduleCardProps) => { + const router = useRouter(); + const { open } = useModal(); + + const { data: groupDetails } = useQuery({ + queryKey: groupKeys.detail(groupId), + queryFn: () => API.groupService.getGroupDetails({ groupId }), + enabled: shouldFetchChatRoomId, + }); + + const handleChatClick = () => { + if (!groupDetails?.chatRoomId) return; + router.push(`/message/chat/${groupDetails.chatRoomId}`); + }; + + const handleLeaveClick = () => { + open(); + }; + + const handleCardClick = () => { + router.push(`/group/${groupId}`); + }; + + return ( + + ); +}; diff --git a/src/app/schedule/_components/constants.tsx b/src/app/schedule/_components/constants.tsx new file mode 100644 index 00000000..1530bf4a --- /dev/null +++ b/src/app/schedule/_components/constants.tsx @@ -0,0 +1,44 @@ +import { type ReactNode } from 'react'; + +export type TabType = 'current' | 'myPost' | 'past'; + +const DEFAULT_BUTTON_WIDTH = 'w-31'; + +export const EMPTY_STATE_CONFIG: Record< + TabType, + { text: ReactNode; buttonText: string; buttonWidth: string } +> = { + current: { + text: ( + <> + 현재 참여 중인 모임이 없어요. +
+ 지금 바로 모임을 참여해보세요! + + ), + buttonText: '모임 보러가기', + buttonWidth: DEFAULT_BUTTON_WIDTH, + }, + myPost: { + text: ( + <> + 아직 생성한 모임이 없어요. +
+ 지금 바로 모임을 만들어보세요! + + ), + buttonText: '모임 만들기', + buttonWidth: DEFAULT_BUTTON_WIDTH, + }, + past: { + text: ( + <> + 아직 참여한 모임이 없어요. +
+ 마음에 드는 모임을 발견해보세요! + + ), + buttonText: '모임 보러가기', + buttonWidth: DEFAULT_BUTTON_WIDTH, + }, +} as const; diff --git a/src/app/schedule/_components/current.tsx b/src/app/schedule/_components/current.tsx index 4efce5e2..058e4612 100644 --- a/src/app/schedule/_components/current.tsx +++ b/src/app/schedule/_components/current.tsx @@ -4,26 +4,34 @@ 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 { groupKeys } from '@/lib/query-key/query-key-group'; import { GroupListItemResponse } from '@/types/service/group'; -import { MeetingList } from './meeting-list'; +import { Meetings } from './meetings'; export default function Current() { - const { items, error, fetchNextPage, hasNextPage, isFetchingNextPage, completedMessage } = - useInfiniteScroll({ - queryFn: async ({ cursor, size }) => { - return await API.groupService.getMyGroups({ - type: 'current', - cursor, - size, - myStatuses: ['ATTEND', 'PENDING'], - }); - }, - queryKey: ['myGroups', 'current'], - pageSize: 10, - errorMessage: '현재 모임 목록을 불러오는데 실패했습니다.', - completedMessage: '모든 현재 모임을 불러왔습니다.', - }); + const { + items, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetching, + completedMessage, + } = useInfiniteScroll({ + queryFn: async ({ cursor, size }) => { + return await API.groupService.getMyGroups({ + type: 'current', + cursor, + size, + myStatuses: ['ATTEND', 'PENDING'], + }); + }, + queryKey: groupKeys.myGroupsList('current') as ['myGroups', 'current'], + pageSize: 10, + errorMessage: '현재 모임 목록을 불러오는데 실패했습니다.', + completedMessage: '모든 현재 모임을 불러왔습니다.', + }); const sentinelRef = useIntersectionObserver({ onIntersect: () => { @@ -36,13 +44,13 @@ export default function Current() { }); return ( - ({ - queryFn: async ({ cursor, size }) => { - return await API.groupService.getMyGroups({ type: 'past', cursor, size }); - }, - queryKey: ['myGroups', 'past'], - pageSize: 10, - errorMessage: '모임 이력을 불러오는데 실패했습니다.', - completedMessage: '모든 모임 이력을 불러왔습니다.', - }); + const { + items, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetching, + completedMessage, + } = useInfiniteScroll({ + queryFn: async ({ cursor, size }) => { + return await API.groupService.getMyGroups({ type: 'past', cursor, size }); + }, + queryKey: groupKeys.myGroupsList('past') as ['myGroups', 'past'], + pageSize: 10, + errorMessage: '모임 이력을 불러오는데 실패했습니다.', + completedMessage: '모든 모임 이력을 불러왔습니다.', + }); const sentinelRef = useIntersectionObserver({ onIntersect: () => { @@ -31,12 +39,13 @@ export default function History() { }); return ( - - 현재 참여 중인 모임이 없어요. -
- 지금 바로 모임을 참여해보세요! - - ), - buttonText: '모임 보러가기', - buttonWidth: 'w-31', - }, - myPost: { - text: ( - <> - 아직 생성한 모임이 없어요. -
- 지금 바로 모임을 만들어보세요! - - ), - buttonText: '모임 만들기', - buttonWidth: 'w-31', - }, - past: { - text: ( - <> - 아직 참여한 모임이 없어요. -
- 마음에 드는 모임을 발견해보세요! - - ), - buttonText: '모임 보러가기', - buttonWidth: 'w-31', - }, -} as const; - -type MeetingListProps = { - meetings: GroupListItemResponse[]; - tabType: TabType; - emptyStateType: TabType; - emptyStatePath: string; - showActions: boolean; - leaveActionText?: string; - error?: Error | null; - hasNextPage?: boolean; - sentinelRef?: RefObject; - completedMessage?: string; -}; - -export const MeetingList = ({ - meetings, - tabType, - emptyStateType, - emptyStatePath, - showActions, - leaveActionText, - error, - hasNextPage, - sentinelRef, - completedMessage, -}: MeetingListProps) => { - const router = useRouter(); - - if (meetings.length === 0 && !error) { - const config = EMPTY_STATE_CONFIG[emptyStateType]; - return ( -
- {config.text} - - -
- ); - } - - return ( -
- {error && meetings.length === 0 && ( - window.location.reload()} - /> - )} - - {meetings.map((meeting) => ( - console.log(leaveActionText || '모임 탈퇴', meeting.id), - onChat: () => router.push(`/chat/${meeting.id}`), - } - : undefined - } - location={meeting.location} - maxParticipants={meeting.maxParticipants} - nickName={meeting.createdBy.nickName} - participantCount={meeting.participantCount} - profileImage={meeting.createdBy.profileImage} - tabType={tabType} - tags={meeting.tags} - title={meeting.title} - onClick={() => router.push(`/group/${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/meetings.tsx b/src/app/schedule/_components/meetings.tsx new file mode 100644 index 00000000..4b085ec1 --- /dev/null +++ b/src/app/schedule/_components/meetings.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { RefObject } from 'react'; + +import { EmptyState } from '@/components/layout/empty-state'; +import { ErrorMessage } from '@/components/shared'; +import { Button } from '@/components/ui'; +import { GroupListItemResponse } from '@/types/service/group'; + +import { ScheduleCard } from './card'; +import { EMPTY_STATE_CONFIG } from './constants'; + +type TabType = 'current' | 'myPost' | 'past'; + +const getModalType = ( + meeting: GroupListItemResponse, + tabType: TabType, +): 'pending' | 'leave' | 'delete' => { + if (tabType === 'myPost' || (tabType === 'current' && meeting.myMembership?.role === 'HOST')) { + return 'delete'; + } + if (tabType === 'current' && meeting.myMembership?.status === 'PENDING') { + return 'pending'; + } + return 'leave'; +}; + +type MeetingsProps = { + meetings: GroupListItemResponse[]; + tabType: TabType; + emptyStateType: TabType; + emptyStatePath: string; + showActions: boolean; + error?: Error | null; + hasNextPage?: boolean; + isLoading?: boolean; + sentinelRef?: RefObject; + completedMessage?: string; +}; + +const MIN_HEIGHT = 'min-h-[calc(100vh-156px)]'; + +export const Meetings = ({ + meetings, + tabType, + emptyStateType, + emptyStatePath, + showActions, + error, + hasNextPage, + isLoading, + sentinelRef, + completedMessage, +}: MeetingsProps) => { + const router = useRouter(); + + const isEmpty = meetings.length === 0; + const hasError = !!error; + const isLoadingState = !!isLoading; + const showLoading = isEmpty && isLoadingState && !hasError; + const showEmptyState = isEmpty && !hasError && !isLoadingState; + const showErrorOnly = hasError && isEmpty; + const showErrorWithData = hasError && !isEmpty; + + if (showLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (showEmptyState) { + const config = EMPTY_STATE_CONFIG[emptyStateType]; + const handleEmptyStateClick = () => router.push(emptyStatePath); + + return ( +
+ {config.text} + + +
+ ); + } + + const handleRetry = () => window.location.reload(); + + return ( +
+ {showErrorOnly && ( + + )} + + {meetings.map((meeting) => { + const groupId = String(meeting.id); + const myMembership = meeting.myMembership; + const isPending = myMembership?.status === 'PENDING'; + const isFinished = meeting.status === 'FINISHED'; + const isHost = myMembership?.role === 'HOST'; + const createdBy = meeting.createdBy; + const shouldFetchChatRoomId = showActions && !isPending && !isFinished; + + return ( + + ); + })} + + {showErrorWithData && ( + + )} + + {hasNextPage && !hasError && sentinelRef &&
} + + {!hasNextPage && !isEmpty && !hasError && completedMessage && ( +
{completedMessage}
+ )} +
+ ); +}; diff --git a/src/app/schedule/_components/my.tsx b/src/app/schedule/_components/my.tsx index d715ada0..27167855 100644 --- a/src/app/schedule/_components/my.tsx +++ b/src/app/schedule/_components/my.tsx @@ -4,26 +4,34 @@ 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 { groupKeys } from '@/lib/query-key/query-key-group'; import { GroupListItemResponse } from '@/types/service/group'; -import { MeetingList } from './meeting-list'; +import { Meetings } from './meetings'; export default function My() { - const { items, error, fetchNextPage, hasNextPage, isFetchingNextPage, completedMessage } = - useInfiniteScroll({ - queryFn: async ({ cursor, size }) => { - return await API.groupService.getMyGroups({ - type: 'myPost', - cursor, - size, - filter: 'ALL', - }); - }, - queryKey: ['myGroups', 'myPost'], - pageSize: 10, - errorMessage: '나의 모임 목록을 불러오는데 실패했습니다.', - completedMessage: '모든 나의 모임을 불러왔습니다.', - }); + const { + items, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetching, + completedMessage, + } = useInfiniteScroll({ + queryFn: async ({ cursor, size }) => { + return await API.groupService.getMyGroups({ + type: 'myPost', + cursor, + size, + filter: 'ALL', + }); + }, + queryKey: groupKeys.myGroupsList('myPost') as ['myGroups', 'myPost'], + pageSize: 10, + errorMessage: '나의 모임 목록을 불러오는데 실패했습니다.', + completedMessage: '모든 나의 모임을 불러왔습니다.', + }); const sentinelRef = useIntersectionObserver({ onIntersect: () => { @@ -36,13 +44,13 @@ export default function My() { }); return ( - ; + groupId?: string; } interface KickProps { @@ -26,6 +27,7 @@ interface KickProps { targetUserId: string; targetUserName: string; }; + groupId?: string; } type Props = BaseProps | KickProps; @@ -33,8 +35,10 @@ type Props = BaseProps | KickProps; export const GroupModal = (props: Props) => { const { type } = props; - const { groupId } = useParams() as { groupId: string }; + const params = useParams(); + const groupId = props.groupId || (params as { groupId: string }).groupId; const { replace } = useRouter(); + const pathname = usePathname(); const { run } = useToast(); const { mutateAsync: attendMutate, isPending: isAttending } = useAttendGroup({ groupId }); @@ -62,7 +66,9 @@ export const GroupModal = (props: Props) => { delete: async () => { await deleteMutate(); await revalidateGroupAction(groupId); - replace('/'); + if (!pathname.startsWith('/schedule')) { + replace('/'); + } }, kick: () => kickMutate(), }; diff --git a/src/components/shared/card/index.tsx b/src/components/shared/card/index.tsx index 52264478..7815089a 100644 --- a/src/components/shared/card/index.tsx +++ b/src/components/shared/card/index.tsx @@ -137,14 +137,17 @@ const Card = ({ {shouldShowButtons && ( )} diff --git a/src/hooks/use-group/use-group-delete/index.ts b/src/hooks/use-group/use-group-delete/index.ts index c2c6f59a..8a346f21 100644 --- a/src/hooks/use-group/use-group-delete/index.ts +++ b/src/hooks/use-group/use-group-delete/index.ts @@ -13,6 +13,7 @@ export const useDeleteGroup = (params: GroupIdParams) => { onSuccess: async () => { queryClient.removeQueries({ queryKey: groupKeys.detail(params.groupId) }); queryClient.invalidateQueries({ queryKey: groupKeys.lists() }); + queryClient.invalidateQueries({ queryKey: groupKeys.myGroups() }); console.log('모임 삭제 성공.'); }, onError: () => { diff --git a/src/hooks/use-group/use-group-get-my-list/index.ts b/src/hooks/use-group/use-group-get-my-groups/index.ts similarity index 87% rename from src/hooks/use-group/use-group-get-my-list/index.ts rename to src/hooks/use-group/use-group-get-my-groups/index.ts index 930698c0..148bbb5c 100644 --- a/src/hooks/use-group/use-group-get-my-list/index.ts +++ b/src/hooks/use-group/use-group-get-my-groups/index.ts @@ -6,7 +6,7 @@ import { GetMyGroupsPayload } from '@/types/service/group'; export const useGetMyGroups = (payload: GetMyGroupsPayload) => { const query = useQuery({ - queryKey: groupKeys.myList(payload), + queryKey: groupKeys.myGroupsList(payload.type), queryFn: () => API.groupService.getMyGroups(payload), }); return query; diff --git a/src/hooks/use-group/use-group-leave/index.ts b/src/hooks/use-group/use-group-leave/index.ts index b11f1597..2a81a859 100644 --- a/src/hooks/use-group/use-group-leave/index.ts +++ b/src/hooks/use-group/use-group-leave/index.ts @@ -11,6 +11,7 @@ export const useLeaveGroup = (params: GroupIdParams) => { mutationFn: () => API.groupService.leaveGroup(params), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: groupKeys.detail(params.groupId) }); + await queryClient.invalidateQueries({ queryKey: groupKeys.myGroups() }); console.log('모임 탈퇴 성공.'); }, onError: () => { diff --git a/src/lib/query-key/query-key-group/index.ts b/src/lib/query-key/query-key-group/index.ts index bc2f3313..0b05b0a0 100644 --- a/src/lib/query-key/query-key-group/index.ts +++ b/src/lib/query-key/query-key-group/index.ts @@ -3,9 +3,8 @@ export const groupKeys = { lists: () => [...groupKeys.all, 'list'] as const, list: (filters: { keyword?: string; cursor?: number; size: number }) => [...groupKeys.lists(), filters] as const, - myLists: () => [...groupKeys.all, 'me'] as const, - myList: (filters: { type: 'current' | 'myPost' | 'past'; cursor?: number; size: number }) => - [...groupKeys.myLists(), filters] as const, + myGroups: () => ['myGroups'] as const, + myGroupsList: (type: 'current' | 'myPost' | 'past') => [...groupKeys.myGroups(), type] as const, detail: (groupId: string) => [...groupKeys.all, groupId] as const, joinRequests: (groupId: string, status: string = 'PENDING') => ['joinRequests', groupId, status] as const,