diff --git a/src/api/service/follower-service/index.ts b/src/api/service/follower-service/index.ts index ffeff2f2..9e4c1916 100644 --- a/src/api/service/follower-service/index.ts +++ b/src/api/service/follower-service/index.ts @@ -1,11 +1,11 @@ import { api } from '@/api/core'; -import { GetFollowerParams, GetFollowerResponse } from '@/types/service/follow'; +import { GetFollowParams, GetFollowResponse } from '@/types/service/follow'; import { FollowPathParams } from '@/types/service/user'; export const followerServiceRemote = () => ({ // 팔로워 목록 조회 - getFollowers: async ({ userId, cursor, size = 20 }: GetFollowerParams) => { - return api.get(`/users/${userId}/follow`, { + getFollowers: async ({ userId, cursor, size = 20 }: GetFollowParams) => { + return api.get(`/users/${userId}/follow`, { params: { cursor, size, @@ -13,6 +13,20 @@ export const followerServiceRemote = () => ({ }); }, + getFollowerList: async (params: GetFollowParams) => { + const { userId, ...restParams } = params; + return await api.get(`/users/${userId}/follower`, { + params: { ...restParams }, + }); + }, + + getFolloweeList: async (params: GetFollowParams) => { + const { userId, ...restParams } = params; + return await api.get(`/users/${userId}/follow`, { + params: { ...restParams }, + }); + }, + // 팔로워 등록 addFollower: async (params: FollowPathParams) => { return api.post(`/users/follow`, null, { diff --git a/src/app/(user)/profile/[userId]/page.test.tsx b/src/app/(user)/profile/[userId]/page.test.tsx index 8619a5cd..5803f4e3 100644 --- a/src/app/(user)/profile/[userId]/page.test.tsx +++ b/src/app/(user)/profile/[userId]/page.test.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, render, screen } from '@testing-library/react'; import { http, HttpResponse } from 'msw'; +import { ModalProvider } from '@/components/ui'; import { formatISO } from '@/lib/formatDateTime'; import { server } from '@/mock/server'; import { createMockSuccessResponse } from '@/mock/service/common/common-mock'; @@ -35,7 +36,9 @@ const renderWithQueryClient = async (component: React.ReactElement) => { await act(async () => { renderResult = render( - {component} + + {component} + , ); }); diff --git a/src/components/pages/message/message-following-list/index.test.tsx b/src/components/pages/message/message-following-list/index.test.tsx index bc7a5f1d..4307f480 100644 --- a/src/components/pages/message/message-following-list/index.test.tsx +++ b/src/components/pages/message/message-following-list/index.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; -import { GetFollowerResponse } from '@/types/service/follow'; +import { GetFollowResponse } from '@/types/service/follow'; import { FollowingList } from '.'; @@ -8,7 +8,7 @@ jest.mock('../message-following-card', () => ({ FollowingCard: ({ nickname }: { nickname: string }) =>
{nickname}
, })); -const TEST_ITEMS: GetFollowerResponse = { +const TEST_ITEMS: GetFollowResponse = { items: [ { followId: 0, diff --git a/src/components/pages/message/message-following-list/index.tsx b/src/components/pages/message/message-following-list/index.tsx index 9c113c51..9e12d117 100644 --- a/src/components/pages/message/message-following-list/index.tsx +++ b/src/components/pages/message/message-following-list/index.tsx @@ -1,11 +1,11 @@ 'use client'; -import { Follower } from '@/types/service/follow'; +import { FollowItem } from '@/types/service/follow'; import { FollowingCard } from '../message-following-card'; interface FollowingListProps { - items: Follower[]; + items: FollowItem[]; } export const FollowingList = ({ items }: FollowingListProps) => { diff --git a/src/components/pages/user/profile/index.ts b/src/components/pages/user/profile/index.ts index af2d2801..e7b7d6e9 100644 --- a/src/components/pages/user/profile/index.ts +++ b/src/components/pages/user/profile/index.ts @@ -3,4 +3,5 @@ export { ProfileDescription } from './profile-description'; export { ProfileDescriptionBadge } from './profile-description-badge'; export { ProfileEditModal } from './profile-edit-modal'; export { ProfileFollowsBadge } from './profile-follows-badge'; +export { ProfileFollowsModal } from './profile-follows-modal'; export { ProfileInfo } from './profile-info'; diff --git a/src/components/pages/user/profile/profile-follows-badge/index.tsx b/src/components/pages/user/profile/profile-follows-badge/index.tsx index c18cf004..72cc0d90 100644 --- a/src/components/pages/user/profile/profile-follows-badge/index.tsx +++ b/src/components/pages/user/profile/profile-follows-badge/index.tsx @@ -1,33 +1,80 @@ +import { useEffect } from 'react'; import { Fragment } from 'react/jsx-runtime'; +import { useQueryClient } from '@tanstack/react-query'; + +import { API } from '@/api'; +import { useModal } from '@/components/ui'; +import { followKeys } from '@/lib/query-key/query-key-follow'; +import { FollowType } from '@/types/service/follow'; import { User } from '@/types/service/user'; +import { ProfileFollowsModal } from '../profile-follows-modal'; + interface Props { user: User; } export const ProfileFollowsBadge = ({ user }: Props) => { - const listMap = [ + const { open } = useModal(); + + const queryClient = useQueryClient(); + + const listMap: { + label: string; + type: FollowType; + value: string; + }[] = [ { label: '팔로워', + type: 'followers', value: user.followersCnt.toLocaleString(), }, { label: '팔로잉', + type: 'followees', value: user.followeesCnt.toLocaleString(), }, ]; const listLength = listMap.length; + const handleFollowsBadgeClick = (type: FollowType) => { + open(); + }; + + // 팔로워/팔로우 모달 정보를 미리 불러옴 + useEffect(() => { + const prefetch = async () => { + await Promise.all([ + queryClient.prefetchInfiniteQuery({ + queryFn: () => API.followerService.getFollowerList({ userId: user.userId }), + queryKey: followKeys.followers(user.userId), + initialPageParam: undefined, + }), + queryClient.prefetchInfiniteQuery({ + queryFn: () => API.followerService.getFolloweeList({ userId: user.userId }), + queryKey: followKeys.followees(user.userId), + initialPageParam: undefined, + }), + ]); + }; + prefetch(); + }, [queryClient, user.userId]); + return (
{listMap.map((item, index) => ( -
- {item.value} +
+ {index < listLength - 1 &&
} ))} diff --git a/src/components/pages/user/profile/profile-follows-modal/index.tsx b/src/components/pages/user/profile/profile-follows-modal/index.tsx new file mode 100644 index 00000000..b59b4917 --- /dev/null +++ b/src/components/pages/user/profile/profile-follows-modal/index.tsx @@ -0,0 +1,105 @@ +import Link from 'next/link'; + +import { Suspense } from 'react'; + +import { UseSuspenseInfiniteQueryResult } from '@tanstack/react-query'; + +import { + ImageWithFallback, + ModalContent, + ModalDescription, + ModalTitle, + useModal, +} from '@/components/ui'; +import { useGetFolloweesInfinite, useGetFollowersInfinite } from '@/hooks/use-follower'; +import { useIntersectionObserver } from '@/hooks/use-intersection-observer'; +import { CommonErrorResponse } from '@/types/service/common'; +import { FollowItem, FollowType } from '@/types/service/follow'; +import { User } from '@/types/service/user'; + +interface Props { + user: User; + type: FollowType; +} + +export const ProfileFollowsModal = ({ user, type }: Props) => { + const title: Record = { + followers: '팔로워', + followees: '팔로잉', + }; + + const followsCount: Record = { + followers: user.followersCnt, + followees: user.followeesCnt, + }; + + return ( + + + {title[type]} + {followsCount[type]} + + + {`${title[type]} 목록을 확인할 수 있는 모달입니다.`} + + {/* todo: suspense fallback 디자인 필요 */} + 로딩중...}> + {type === 'followers' && } + {type === 'followees' && } + + + ); +}; + +const Followers = ({ user }: { user: User }) => { + const query = useGetFollowersInfinite({ userId: user.userId }); + return ; +}; + +const Followees = ({ user }: { user: User }) => { + const query = useGetFolloweesInfinite({ userId: user.userId }); + return ; +}; + +const FollowList = ({ + query, +}: { + query: UseSuspenseInfiniteQueryResult; +}) => { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = query; + const { close } = useModal(); + + const fetchObserverRef = useIntersectionObserver({ + onIntersect: () => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + }); + + return ( +
+ {data?.map((item) => ( + + +
+

{item.nickname}

+

{item.profileMessage}

+
+ + ))} + {hasNextPage &&
} +
+ ); +}; diff --git a/src/components/pages/user/profile/profile-info/index.tsx b/src/components/pages/user/profile/profile-info/index.tsx index e6121901..1a74f741 100644 --- a/src/components/pages/user/profile/profile-info/index.tsx +++ b/src/components/pages/user/profile/profile-info/index.tsx @@ -1,4 +1,5 @@ 'use client'; + import { Button } from '@/components/ui'; import { useFollowUser, useUnfollowUser } from '@/hooks/use-user'; import { User } from '@/types/service/user'; @@ -12,9 +13,8 @@ interface Props { } export const ProfileInfo = ({ user }: Props) => { - const { mutate: followUser } = useFollowUser(); - - const { mutate: unfollowUser } = useUnfollowUser(); + const { mutate: followUser } = useFollowUser(user.userId); + const { mutate: unfollowUser } = useUnfollowUser(user.userId); const handleFollowClick = () => { followUser({ followNickname: user.nickName }); diff --git a/src/components/ui/modal/index.tsx b/src/components/ui/modal/index.tsx index eeb396ed..0e8fa6f5 100644 --- a/src/components/ui/modal/index.tsx +++ b/src/components/ui/modal/index.tsx @@ -248,7 +248,7 @@ export const ModalContent = ({ children, className }: ModalContentProps) => { }; interface ModalTitleProps { - children: string; + children: React.ReactNode; className?: string; } diff --git a/src/hooks/use-follower/index.ts b/src/hooks/use-follower/index.ts index 89006a67..ba493dff 100644 --- a/src/hooks/use-follower/index.ts +++ b/src/hooks/use-follower/index.ts @@ -1,2 +1,4 @@ -export { useAddFollowers } from './use-follower-add/index'; -export { useGetFollowers } from './use-follower-get/index'; +export { useGetFolloweesInfinite } from './use-followee-list-get'; +export { useAddFollowers } from './use-follower-add'; +export { useGetFollowers } from './use-follower-get'; +export { useGetFollowersInfinite } from './use-follower-list-get'; diff --git a/src/hooks/use-follower/use-followee-list-get/index.ts b/src/hooks/use-follower/use-followee-list-get/index.ts new file mode 100644 index 00000000..45d7b60a --- /dev/null +++ b/src/hooks/use-follower/use-followee-list-get/index.ts @@ -0,0 +1,16 @@ +import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; + +import { API } from '@/api'; +import { followKeys } from '@/lib/query-key/query-key-follow'; +import { GetFollowParams } from '@/types/service/follow'; + +export const useGetFolloweesInfinite = (params: GetFollowParams) => { + return useSuspenseInfiniteQuery({ + queryFn: ({ pageParam }) => + API.followerService.getFolloweeList({ ...params, cursor: pageParam }), + queryKey: followKeys.followees(params.userId), + initialPageParam: params?.cursor, + getNextPageParam: (lastPage) => lastPage.nextCursor, + select: (data) => data.pages?.flatMap((page) => page.items) || [], + }); +}; diff --git a/src/hooks/use-follower/use-follower-list-get/index.ts b/src/hooks/use-follower/use-follower-list-get/index.ts new file mode 100644 index 00000000..a3c56bef --- /dev/null +++ b/src/hooks/use-follower/use-follower-list-get/index.ts @@ -0,0 +1,16 @@ +import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; + +import { API } from '@/api'; +import { followKeys } from '@/lib/query-key/query-key-follow'; +import { GetFollowParams } from '@/types/service/follow'; + +export const useGetFollowersInfinite = (params: GetFollowParams) => { + return useSuspenseInfiniteQuery({ + queryFn: ({ pageParam }) => + API.followerService.getFollowerList({ ...params, cursor: pageParam }), + queryKey: followKeys.followers(params.userId), + initialPageParam: params?.cursor, + getNextPageParam: (lastPage) => lastPage.nextCursor, + select: (data) => data.pages?.flatMap((page) => page.items) || [], + }); +}; diff --git a/src/hooks/use-user/use-user-follow/index.ts b/src/hooks/use-user/use-user-follow/index.ts index 4cad8381..10e3efd1 100644 --- a/src/hooks/use-user/use-user-follow/index.ts +++ b/src/hooks/use-user/use-user-follow/index.ts @@ -1,21 +1,19 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { API } from '@/api'; +import { followKeys } from '@/lib/query-key/query-key-follow'; import { userKeys } from '@/lib/query-key/query-key-user'; import { FollowPathParams } from '@/types/service/user'; -export const useFollowUser = () => { +export const useFollowUser = (userId: number) => { const queryClient = useQueryClient(); const query = useMutation({ mutationFn: (params: FollowPathParams) => API.userService.followUser(params), onSuccess: (_data, _variables, _context) => { - // todo: GetUser는 ID로 호출, follow는 nickname으로 진행 => querykey 타입 불일치로 인한 전체 querykey 삭제 적용 (임시) - queryClient.invalidateQueries({ queryKey: userKeys.all }); - console.log('요청 성공'); - }, - onError: () => { - console.log('요청 실패'); + queryClient.invalidateQueries({ queryKey: userKeys.item(userId) }); + queryClient.invalidateQueries({ queryKey: followKeys.followers(userId) }); }, + onError: () => {}, }); return query; }; diff --git a/src/hooks/use-user/use-user-unfollow/index.ts b/src/hooks/use-user/use-user-unfollow/index.ts index 9ef8b59d..36a5084b 100644 --- a/src/hooks/use-user/use-user-unfollow/index.ts +++ b/src/hooks/use-user/use-user-unfollow/index.ts @@ -1,20 +1,19 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { API } from '@/api'; +import { followKeys } from '@/lib/query-key/query-key-follow'; import { userKeys } from '@/lib/query-key/query-key-user'; import { UnfollowQueryParams } from '@/types/service/user'; -export const useUnfollowUser = () => { +export const useUnfollowUser = (userId: number) => { const queryClient = useQueryClient(); const query = useMutation({ mutationFn: (params: UnfollowQueryParams) => API.userService.unfollowUser(params), onSuccess: (_data, _variables, _context) => { - queryClient.invalidateQueries({ queryKey: userKeys.all }); - console.log('요청 성공'); - }, - onError: () => { - console.log('요청 실패'); + queryClient.invalidateQueries({ queryKey: userKeys.item(userId) }); + queryClient.invalidateQueries({ queryKey: followKeys.followers(userId) }); }, + onError: () => {}, }); return query; }; diff --git a/src/lib/query-key/query-key-follow/index.ts b/src/lib/query-key/query-key-follow/index.ts new file mode 100644 index 00000000..b154730a --- /dev/null +++ b/src/lib/query-key/query-key-follow/index.ts @@ -0,0 +1,4 @@ +export const followKeys = { + followers: (userId: number) => ['followers', userId], + followees: (userId: number) => ['followees', userId], +}; diff --git a/src/mock/service/followers/followers-handler.ts b/src/mock/service/followers/followers-handler.ts index f95875df..2c80fa5a 100644 --- a/src/mock/service/followers/followers-handler.ts +++ b/src/mock/service/followers/followers-handler.ts @@ -17,4 +17,18 @@ const getFollowersMock = http.get(`*/users/:userId/follow`, () => { return HttpResponse.json(createMockSuccessResponse(mockFollowingItems)); }); -export const followerHandlers = [getFollowersMock]; +const getFolloweesMock = http.get(`*/users/:userId/follower`, () => { + if (!mockFollowingItems) { + return HttpResponse.json( + createMockErrorResponse({ + status: 404, + detail: '팔로잉이 없습니다.', + errorCode: 'F001', + }), + { status: 404 }, + ); + } + return HttpResponse.json(createMockSuccessResponse(mockFollowingItems)); +}); + +export const followerHandlers = [getFollowersMock, getFolloweesMock]; diff --git a/src/types/service/follow.ts b/src/types/service/follow.ts index 04927632..59486003 100644 --- a/src/types/service/follow.ts +++ b/src/types/service/follow.ts @@ -1,5 +1,5 @@ // 기본 팔로우 타입 -export interface Follower { +export interface FollowItem { followId: number; userId: number; nickname: string; @@ -8,13 +8,13 @@ export interface Follower { } // 팔로우 목록 조회 응답 -export interface GetFollowerResponse { - items: Follower[]; +export interface GetFollowResponse { + items: FollowItem[]; nextCursor: number | null; } // 팔로우 목록 조회 Parameters -export interface GetFollowerParams { +export interface GetFollowParams { userId: number; cursor?: number | null; size?: number; @@ -24,3 +24,6 @@ export interface GetFollowerParams { export interface AddFollowParams { followNickname: string; } + +// 팔로우 유형 +export type FollowType = 'followers' | 'followees';