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
11 changes: 8 additions & 3 deletions src/api/service/follower-service/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { api } from '@/api/core';
import { GetFollowerResponse } from '@/types/service/follow';
import { GetFollowerParams, GetFollowerResponse } from '@/types/service/follow';
import { FollowPathParams } from '@/types/service/user';

export const followerServiceRemote = () => ({
// 팔로워 목록 조회
getFollowers: async ({ userId }: { userId: number }) => {
return api.get<GetFollowerResponse>(`/users/${userId}/follow`);
getFollowers: async ({ userId, cursor, size = 20 }: GetFollowerParams) => {
return api.get<GetFollowerResponse>(`/users/${userId}/follow`, {
params: {
cursor,
size,
},
});
},

// 팔로워 등록
Expand Down
74 changes: 64 additions & 10 deletions src/app/message/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import { useEffect, useState } from 'react';

import Cookies from 'js-cookie';

import { API } from '@/api';
import { Chat, FollowingList, FollowingNone, FollowingSearch } from '@/components/pages/message';
import { TabNavigation } from '@/components/shared';
import { useGetFollowers } from '@/hooks/use-follower/use-follower-get';
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';

const SOCIAL_TABS = [
{ label: '팔로잉', value: 'following' },
Expand All @@ -17,16 +20,47 @@ const SOCIAL_TABS = [

export default function FollowingPage() {
const [userId, setUserId] = useState(0);
const { data: followers } = useGetFollowers({ userId }, { enabled: !!userId });
const params = useSearchParams();
const tab = params.get('tab') || 'following';

useEffect(() => {
const id = Cookies.get('userId');
// eslint-disable-next-line react-hooks/set-state-in-effect
setUserId(Number(id));
}, []);
console.log(followers);
const params = useSearchParams();
const tab = params.get('tab') || 'following';

// 1. 무한 스크롤 훅 호출
const {
items: followers,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
completedMessage,
} = useInfiniteScroll({
queryFn: async ({ cursor, size }) => {
return await API.followerService.getFollowers({
userId,
cursor,
size,
});
},
queryKey: ['followers', userId],
completedMessage: '모든 팔로잉을 불러왔습니다.',
enabled: !!userId,
});

// 2. IntersectionObserver로 스크롤 감지
const sentinelRef = useIntersectionObserver({
onIntersect: () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
enabled: hasNextPage && error === null,
threshold: INTERSECTION_OBSERVER_THRESHOLD,
});

return (
<div className='min-h-screen bg-[#F1F5F9]'>
<TabNavigation basePath='/message' tabs={SOCIAL_TABS} />
Expand All @@ -35,13 +69,33 @@ export default function FollowingPage() {
{tab === 'following' && (
<>
<FollowingSearch userId={userId} />
{!error && followers && followers.length > 0 ? (
<>
<FollowingList items={followers} />

{/* 3. Sentinel 요소 (필수!) */}
{hasNextPage && <div ref={sentinelRef} className='h-1' />}

{/* 4. 다음 페이지 로딩 중 */}
{isFetchingNextPage && (
<div className='flex items-center justify-center p-4'>
<span className='text-gray-500'>더 불러오는 중...</span>
</div>
)}

{followers && followers.items.length > 0 ? (
<FollowingList items={followers} />
{/* 5. 완료 메시지 */}
{!hasNextPage && (
<div className='flex items-center justify-center p-4'>
<span className='text-gray-500'>{completedMessage}</span>
</div>
)}
</>
) : (
<div className='flex flex-1 items-center justify-center'>
<FollowingNone />
</div>
!error && (
<div className='flex flex-1 items-center justify-center'>
<FollowingNone />
</div>
)
)}
</>
)}
Expand Down
3 changes: 0 additions & 3 deletions src/components/pages/message/message-following-card/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,6 @@ export const FollowingCard = ({
className='flex cursor-pointer items-center gap-3 bg-white p-5'
onClick={handleClick}
>
{/* <div className='size-12 rounded-full'>
<ImageWithFallback className='object-cover' alt='프로필 이미지' fill src={profileImage} />
</div> */}
<div className='relative size-12 overflow-hidden rounded-full'>
<ImageWithFallback
className='object-cover'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe('Following List 컴포넌트 테스트', () => {
});

test('모든 아이템이 렌더링 되는지 테스트', () => {
render(<FollowingList items={TEST_ITEMS} />);
render(<FollowingList items={TEST_ITEMS.items} />);

TEST_ITEMS.items.forEach((item) => {
expect(screen.getByText(item.nickname)).toBeInTheDocument();
Expand Down
6 changes: 3 additions & 3 deletions src/components/pages/message/message-following-list/index.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
'use client';

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

import { FollowingCard } from '../message-following-card';

interface FollowingListProps {
items: GetFollowerResponse;
items: Follower[];
}

export const FollowingList = ({ items }: FollowingListProps) => {
return (
<div>
{items.items.map((item) => (
{items.map((item) => (
<FollowingCard
key={item.userId}
nickname={item.nickname}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export const FollowingNone = () => {
return <div className='text-gray-600'>아직 팔로우 한 사람이 없어요.</div>;
return (
<div className='mt-60 flex flex-col items-center gap-2 text-gray-600'>
<span>아직 팔로우 한 사람이 없어요.</span>
<span>마음에 드는 유저를 팔로우 해보세요!</span>
</div>
);
};
53 changes: 39 additions & 14 deletions src/components/pages/message/message-following-search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,59 @@ import { useAddFollowers } from '@/hooks/use-follower';
const FollowerModal = ({ userId }: { userId: number }) => {
const { close } = useModal();
const [nickname, setNickname] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const { mutate: addFollower } = useAddFollowers({ userId });

const handleConfirm = () => {
addFollower({ followNickname: nickname });
close();
if (!nickname.trim()) {
setErrorMessage('닉네임을 입력해주세요.');
return;
}

setErrorMessage(''); // 에러 메세지 초기화.

addFollower(
{ followNickname: nickname },
{
onSuccess: () => {
close();
},
onError: () => {
setErrorMessage('존재하지 않는 유저입니다.');
},
},
);
};

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setNickname(value);
if (errorMessage) {
setErrorMessage('');
}
};

// 모달 모양 바뀌면 적용하기!
return (
<ModalContent>
<ModalContent className='mx-8'>
<ModalTitle className='mb-3'>팔로우 할 닉네임을 입력하세요</ModalTitle>
<Input
className='text-text-sm-medium mb-3 w-full rounded-3xl bg-gray-100 px-4 py-2.5 text-gray-800'
iconButton={<Icon id='search' className='absolute top-2.5 right-3 size-5 text-gray-500' />}
placeholder='nickname'
onChange={handleChange}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleConfirm();
<div className='mb-3'>
<Input
className='text-text-sm-medium w-full rounded-3xl bg-gray-100 px-4 py-2.5 text-gray-800'
iconButton={
<Icon id='search' className='absolute top-2.5 right-3 size-5 text-gray-500' />
}
}}
/>

placeholder='nickname'
value={nickname}
onChange={handleChange}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleConfirm();
}
}}
/>
{errorMessage && <p className='text-error-500 mt-2 text-sm'>{errorMessage}</p>}
</div>
<div className='flex w-full flex-row gap-2'>
<Button size='sm' variant='tertiary' onClick={close}>
취소
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/use-group/use-group-infinite-list/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface UseInfiniteScrollParams<TItem, TQueryKey extends unknown[] = un
enableLogging?: boolean;
// 모든 데이터 로드 완료 메시지 (선택, 기본값: "모든 데이터를 불러왔습니다.")
completedMessage?: string;
enabled?: boolean;
}

// 범용 무한 스크롤 반환 타입
Expand Down Expand Up @@ -87,6 +88,7 @@ export function useInfiniteScroll<TItem, TQueryKey extends unknown[] = unknown[]
staleTime = STALE_TIME,
errorMessage = DEFAULT_ERROR_MESSAGE,
enableLogging = true,
enabled = true,
completedMessage = '모든 데이터를 불러왔습니다.',
}: UseInfiniteScrollParams<TItem, TQueryKey>): UseInfiniteScrollReturn<TItem> {
const queryClient = useQueryClient();
Expand All @@ -102,6 +104,7 @@ export function useInfiniteScroll<TItem, TQueryKey extends unknown[] = unknown[]
number | undefined
>({
queryKey,
enabled,
queryFn: async ({ pageParam }) => {
// 다음 페이지 요청 시작 로그
if (pageParam !== undefined && enableLogging) {
Expand Down
7 changes: 7 additions & 0 deletions src/types/service/follow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export interface GetFollowerResponse {
nextCursor: number | null;
}

// 팔로우 목록 조회 Parameters
export interface GetFollowerParams {
userId: number;
cursor?: number | null;
size?: number;
}

// 팔로우 등록 Parameters
export interface AddFollowParams {
followNickname: string;
Expand Down