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
20 changes: 17 additions & 3 deletions src/api/service/follower-service/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
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<GetFollowerResponse>(`/users/${userId}/follow`, {
getFollowers: async ({ userId, cursor, size = 20 }: GetFollowParams) => {
return api.get<GetFollowResponse>(`/users/${userId}/follow`, {
params: {
cursor,
size,
},
});
},

getFollowerList: async (params: GetFollowParams) => {
const { userId, ...restParams } = params;
return await api.get<GetFollowResponse>(`/users/${userId}/follower`, {
params: { ...restParams },
});
},

getFolloweeList: async (params: GetFollowParams) => {
const { userId, ...restParams } = params;
return await api.get<GetFollowResponse>(`/users/${userId}/follow`, {
params: { ...restParams },
});
},

// 팔로워 등록
addFollower: async (params: FollowPathParams) => {
return api.post<string>(`/users/follow`, null, {
Expand Down
5 changes: 4 additions & 1 deletion src/app/(user)/profile/[userId]/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -35,7 +36,9 @@ const renderWithQueryClient = async (component: React.ReactElement) => {
await act(async () => {
renderResult = render(
<QueryClientProvider client={testQueryClient}>
<AuthProvider hasRefreshToken={false}>{component}</AuthProvider>
<AuthProvider hasRefreshToken={false}>
<ModalProvider>{component}</ModalProvider>
</AuthProvider>
</QueryClientProvider>,
);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { render, screen } from '@testing-library/react';

import { GetFollowerResponse } from '@/types/service/follow';
import { GetFollowResponse } from '@/types/service/follow';

import { FollowingList } from '.';

jest.mock('../message-following-card', () => ({
FollowingCard: ({ nickname }: { nickname: string }) => <div>{nickname}</div>,
}));

const TEST_ITEMS: GetFollowerResponse = {
const TEST_ITEMS: GetFollowResponse = {
items: [
{
followId: 0,
Expand Down
4 changes: 2 additions & 2 deletions src/components/pages/message/message-following-list/index.tsx
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions src/components/pages/user/profile/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
55 changes: 51 additions & 4 deletions src/components/pages/user/profile/profile-follows-badge/index.tsx
Original file line number Diff line number Diff line change
@@ -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(<ProfileFollowsModal type={type} user={user} />);
};

// 팔로워/팔로우 모달 정보를 미리 불러옴
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 (
<div className='flex-center bg-mono-white shadow-card mb-4 rounded-3xl py-4'>
{listMap.map((item, index) => (
<Fragment key={item.label}>
<div className='flex-col-center w-full gap-0.75 py-0.75'>
<span className='text-text-xl-bold text-gray-800'>{item.value}</span>
<button
className='flex-col-center group mx-4 w-full cursor-pointer gap-0.75 py-0.75'
onClick={() => handleFollowsBadgeClick(item.type)}
>
<span className='text-text-xl-bold group-hover:text-mint-500 text-gray-800 transition-colors duration-300'>
{item.value}
</span>
<span className='text-text-xs-medium text-gray-500'>{item.label}</span>
</div>
</button>
{index < listLength - 1 && <div className='h-10 w-1 border-r-1 border-gray-200' />}
</Fragment>
))}
Expand Down
105 changes: 105 additions & 0 deletions src/components/pages/user/profile/profile-follows-modal/index.tsx
Original file line number Diff line number Diff line change
@@ -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<FollowType, string> = {
followers: '팔로워',
followees: '팔로잉',
};

const followsCount: Record<FollowType, number> = {
followers: user.followersCnt,
followees: user.followeesCnt,
};

return (
<ModalContent className='max-w-90'>
<ModalTitle className='flex flex-row gap-1'>
<span>{title[type]}</span>
<span className='text-mint-500'>{followsCount[type]}</span>
</ModalTitle>
<ModalDescription className='sr-only'>
{`${title[type]} 목록을 확인할 수 있는 모달입니다.`}
</ModalDescription>
{/* todo: suspense fallback 디자인 필요 */}
<Suspense fallback={<span>로딩중...</span>}>
{type === 'followers' && <Followers user={user} />}
{type === 'followees' && <Followees user={user} />}
</Suspense>
</ModalContent>
);
};

const Followers = ({ user }: { user: User }) => {
const query = useGetFollowersInfinite({ userId: user.userId });
return <FollowList query={query} />;
};

const Followees = ({ user }: { user: User }) => {
const query = useGetFolloweesInfinite({ userId: user.userId });
return <FollowList query={query} />;
};

const FollowList = ({
query,
}: {
query: UseSuspenseInfiniteQueryResult<FollowItem[], CommonErrorResponse>;
}) => {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = query;
const { close } = useModal();

const fetchObserverRef = useIntersectionObserver({
onIntersect: () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
});

return (
<div className='scrollbar-thin mt-4 flex h-96 flex-col overflow-y-scroll'>
{data?.map((item) => (
<Link
key={item.userId}
href={`/profile/${item.userId}`}
className='flow-row flex gap-4 py-2'
onClick={close}
>
<ImageWithFallback
width={48}
className='aspect-square rounded-full'
alt='프로필 이미지'
height={48}
src={item.profileImage}
/>
<div>
<p className='text-text-md-bold text-gray-800'>{item.nickname}</p>
<p className='text-text-sm-medium text-gray-600'>{item.profileMessage}</p>
</div>
</Link>
))}
{hasNextPage && <div ref={fetchObserverRef}></div>}
</div>
);
};
6 changes: 3 additions & 3 deletions src/components/pages/user/profile/profile-info/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
'use client';

import { Button } from '@/components/ui';
import { useFollowUser, useUnfollowUser } from '@/hooks/use-user';
import { User } from '@/types/service/user';
Expand All @@ -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 });
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/modal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export const ModalContent = ({ children, className }: ModalContentProps) => {
};

interface ModalTitleProps {
children: string;
children: React.ReactNode;
className?: string;
}

Expand Down
6 changes: 4 additions & 2 deletions src/hooks/use-follower/index.ts
Original file line number Diff line number Diff line change
@@ -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';
16 changes: 16 additions & 0 deletions src/hooks/use-follower/use-followee-list-get/index.ts
Original file line number Diff line number Diff line change
@@ -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) || [],
});
};
16 changes: 16 additions & 0 deletions src/hooks/use-follower/use-follower-list-get/index.ts
Original file line number Diff line number Diff line change
@@ -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) || [],
});
};
12 changes: 5 additions & 7 deletions src/hooks/use-user/use-user-follow/index.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading