diff --git a/src/api/book-club/react-query/likeOptimisticUpdate.ts b/src/api/book-club/react-query/likeOptimisticUpdate.ts index 5c02550..6d15ca3 100644 --- a/src/api/book-club/react-query/likeOptimisticUpdate.ts +++ b/src/api/book-club/react-query/likeOptimisticUpdate.ts @@ -17,14 +17,12 @@ export const likeOnMutate = async ( await queryClient.cancelQueries({ queryKey: detailQueryKey }); // 기존 캐시 데이터 저장 - const previousBookClubs = queryClient.getQueryData<{ bookClubs: BookClub[] }>( - listQueryKey, - ); + const previousBookClubs = queryClient.getQueryData(listQueryKey); const previousDetail = queryClient.getQueryData(detailQueryKey); // 캐시 데이터 업데이트 if (previousBookClubs) { - queryClient.setQueryData(listQueryKey, (old: any) => + queryClient.setQueryData(listQueryKey, (old: BookClub[] | undefined) => old?.map((club: BookClub) => club.id === id ? { ...club, isLiked } : club, ), @@ -42,7 +40,7 @@ export const likeOnError = ( queryClient: QueryClient, id: number, context: { - previousBookClubs?: { bookClubs: BookClub[] }; + previousBookClubs?: BookClub[]; previousDetail?: BookClub; }, ) => { diff --git a/src/app/bookclub/error.tsx b/src/app/bookclub/error.tsx new file mode 100644 index 0000000..a79ea96 --- /dev/null +++ b/src/app/bookclub/error.tsx @@ -0,0 +1,20 @@ +'use client'; + +import ErrorTemplate from '@/components/error/ErrorTemplate'; + +export default function BookClubError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 547bf18..48a6dc5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,7 +6,6 @@ import { Toast } from '@/components/toast/toast'; import { MSWComponent } from '@/components/MSWComponent'; import '@/styles/globals.css'; -import { LikeProvider } from '@/lib/contexts/LikeContext'; export const metadata: Metadata = { title: 'Bookco', @@ -26,13 +25,11 @@ export default function RootLayout({ strategy="beforeInteractive" /> - - -
- - {children} -
-
+ +
+ + {children} +
diff --git a/src/components/card/Card.tsx b/src/components/card/Card.tsx index adefce3..9b76ba0 100644 --- a/src/components/card/Card.tsx +++ b/src/components/card/Card.tsx @@ -7,7 +7,7 @@ import Avatar from '../avatar/Avatar'; import { LocationIcon, HostIcon, - // HeartIcon, + HeartIcon, RatingIcon, OnlineIcon, } from '../../../public/icons'; @@ -52,11 +52,10 @@ function CardBox({ children, className = '', ...props }: CardBoxProps) { function CardImage({ url, alt = '모임 이미지', - // isLiked, - // onLikeClick, + isLiked, + onLikeClick, className, isPast, - // isHost, ...props }: CardImageProps) { return ( @@ -74,11 +73,11 @@ function CardImage({ fill className={twMerge('object-cover', isPast && 'grayscale')} /> - {/* {isLiked !== undefined && !isHost && ( + {isLiked !== undefined && (
- )} */} + )} ); } diff --git a/src/features/bookclub/components/BookClubMainPage.tsx b/src/features/bookclub/components/BookClubMainPage.tsx index 96973ab..7bb92d4 100644 --- a/src/features/bookclub/components/BookClubMainPage.tsx +++ b/src/features/bookclub/components/BookClubMainPage.tsx @@ -9,21 +9,31 @@ import { useRouter } from 'next/navigation'; import Loading from '@/components/loading/Loading'; import { useQuery } from '@tanstack/react-query'; import { fetchBookClubs } from '@/lib/utils/fetchBookClubs'; +import { useEffect, useState } from 'react'; +import ErrorHandlingWrapper from '@/components/error/ErrorHandlingWrapper'; +import ErrorFallback from '@/components/error/ErrorFallback'; +import { getCookie } from '@/features/auth/utils/cookies'; function BookClubMainPage() { const { filters, updateFilters } = useBookClubList(); - const { data, isLoading, isFetching } = useQuery({ + const { data, isLoading } = useQuery({ queryKey: ['bookClubs', 'list', filters], - queryFn: () => fetchBookClubs(filters), - // enabled: false, // ✅ 서버에서 이미 가져왔기 때문에 클라이언트에서 다시 요청하지 않음 - // refetchOnMount: false, // ✅ 마운트 시 다시 데이터를 불러오지 않음 - staleTime: 1000 * 60, + queryFn: () => { + const token = getCookie('auth_token'); + return fetchBookClubs(filters, token || undefined); + }, }); + // console.log('클라이언트 데이터:', data); // 클라이언트의 데이터 확인 + const [isHydrated, setIsHydrated] = useState(false); const router = useRouter(); const user = useAuthStore((state) => state.user); const userName = user?.nickname || '북코'; + useEffect(() => { + setIsHydrated(true); + }, []); + const handleFilterChange = (newFilter: Partial) => { updateFilters(newFilter); }; @@ -50,14 +60,20 @@ function BookClubMainPage() { } /> - {isLoading || isFetching ? ( + + {isLoading || !isHydrated ? (
) : ( -
- -
+ } + > +
+ +
+
)} ); diff --git a/src/features/bookclub/components/ClubListSection.tsx b/src/features/bookclub/components/ClubListSection.tsx index 856c769..e160276 100644 --- a/src/features/bookclub/components/ClubListSection.tsx +++ b/src/features/bookclub/components/ClubListSection.tsx @@ -10,7 +10,6 @@ import { BookClub, BookClubParams } from '@/types/bookclubs'; import { useLikeClub, useLikeWithAuthCheck, useUnLikeClub } from '@/lib/hooks'; import { useAuthStore } from '@/store/authStore'; import PopUp from '@/components/pop-up/PopUp'; -import { queryClient } from '@/lib/utils/reactQueryProvider'; interface ClubListSectionProps { bookClubs: BookClub[]; @@ -19,6 +18,7 @@ interface ClubListSectionProps { function ClubListSection({ bookClubs = [], filter }: ClubListSectionProps) { const router = useRouter(); + const { isLikePopUpOpen, likePopUpLabel, @@ -31,13 +31,10 @@ function ClubListSection({ bookClubs = [], filter }: ClubListSectionProps) { useEffect(() => { checkLoginStatus(); - console.log('메인 페이지: ', bookClubs); }, [checkLoginStatus]); const today = useMemo(() => new Date(), []); - // console.log('🔍 ClubListSection 데이터:', bookClubs); - const handleLikeClub = (isLiked: boolean, id: number) => { if (!isLoggedIn) { onShowAuthPopUp(); @@ -49,12 +46,6 @@ function ClubListSection({ bookClubs = [], filter }: ClubListSectionProps) { } else { onConfirmLike(id); } - - queryClient.setQueryData(['bookClubs', 'list', filter], (oldData: any) => - oldData.map((club: BookClub) => - club.id === id ? { ...club, isLiked: !isLiked } : club, - ), - ); }; const handleLikePopUpConfirm = () => { diff --git a/src/features/club-details/components/HeaderSection.tsx b/src/features/club-details/components/HeaderSection.tsx index ea49dac..542d48b 100644 --- a/src/features/club-details/components/HeaderSection.tsx +++ b/src/features/club-details/components/HeaderSection.tsx @@ -17,7 +17,6 @@ import { useLikeWithAuthCheck, useUnLikeClub, } from '@/lib/hooks/index'; -import { useLikeContext } from '@/lib/contexts/LikeContext'; interface HeaderSectionProps { clubInfo: BookClub; @@ -46,8 +45,6 @@ function HeaderSection({ clubInfo, idAsNumber }: HeaderSectionProps) { } = useLikeWithAuthCheck(); const { onConfirmLike } = useLikeClub(); const { onConfirmUnLike } = useUnLikeClub(); - const { likedClubs, toggleLike } = useLikeContext(); - const isLiked = likedClubs?.has(clubInfo.id) ?? clubInfo.isLiked; const { isLoggedIn, checkLoginStatus, user } = useAuthStore(); @@ -68,14 +65,12 @@ function HeaderSection({ clubInfo, idAsNumber }: HeaderSectionProps) { !isLoggedIn ? router.push('/login') : handleJoin(clubInfo.id); }; - const handleLikeClub = () => { + const handleLikeClub = (isLiked: boolean) => { if (!isLoggedIn) { onShowAuthPopUp(); return; } - toggleLike(clubInfo.id, !isLiked); // ✅ 전역 상태 업데이트 - if (isLiked) { onConfirmUnLike(clubInfo.id); } else { @@ -107,7 +102,7 @@ function HeaderSection({ clubInfo, idAsNumber }: HeaderSectionProps) { clubInfo.endDate, new Date(), // TODO: new Date() 최적화 후 수정 ), - onLikeClick: handleLikeClub, + onLikeClick: () => handleLikeClub(clubInfo.isLiked), host: { id: clubInfo.hostId, name: clubInfo.hostNickname, diff --git a/src/lib/contexts/LikeContext.tsx b/src/lib/contexts/LikeContext.tsx deleted file mode 100644 index 5f49e16..0000000 --- a/src/lib/contexts/LikeContext.tsx +++ /dev/null @@ -1,99 +0,0 @@ -'use client'; - -import React, { - createContext, - useState, - useContext, - useCallback, - useEffect, -} from 'react'; -import { useAuthStore } from '@/store/authStore'; - -interface LikeContextType { - likedClubs: Set | undefined; - toggleLike: (clubId: number, isLiked?: boolean) => void; - isLiked: (clubId: number) => boolean; -} - -// 초깃값과 함께 컨텍스트 생성 -const LikeContext = createContext(undefined); - -export const LikeProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - const [likedClubs, setLikedClubs] = useState | undefined>( - undefined, - ); - const { isLoggedIn } = useAuthStore(); - - // // ✅ localStorage에서 찜한 목록 불러오기 (초기 로드) - useEffect(() => { - const storedLikes = localStorage.getItem('likedClubs'); - if (storedLikes) { - setLikedClubs(new Set(JSON.parse(storedLikes))); - } else { - setLikedClubs(new Set()); - } - }, []); - - // ✅ `localStorage`에서 찜 목록 다시 불러오기 (새로고침 시) - useEffect(() => { - if (typeof window !== 'undefined') { - const storedLikes = localStorage.getItem('likedClubs'); - setLikedClubs(storedLikes ? new Set(JSON.parse(storedLikes)) : new Set()); - } - }, []); - - // ✅ likedClubs가 변경될 때마다 `localStorage`에 저장 - useEffect(() => { - if (typeof window !== 'undefined' && likedClubs) { - localStorage.setItem( - 'likedClubs', - JSON.stringify(Array.from(likedClubs)), - ); - } - }, [likedClubs]); - - // ✅ 로그아웃 시 찜한 목록 초기화 - useEffect(() => { - if (!isLoggedIn) { - setLikedClubs(new Set()); // ✅ 찜한 상태 초기화 - localStorage.removeItem('likedClubs'); // ✅ localStorage에서도 삭제 - } - }, [isLoggedIn]); - - const toggleLike = useCallback((clubId: number, isLiked?: boolean) => { - setLikedClubs((prev) => { - if (!prev) return prev; // 로딩 중이면 변경 X - const newSet = new Set(prev); - if (isLiked !== undefined) { - isLiked ? newSet.add(clubId) : newSet.delete(clubId); - } else { - newSet.has(clubId) ? newSet.delete(clubId) : newSet.add(clubId); - } - localStorage.setItem('likedClubs', JSON.stringify(Array.from(newSet))); - return newSet; - }); - }, []); - - const isLiked = useCallback( - (clubId: number) => likedClubs?.has(clubId) ?? false, // `undefined`일 경우 `false` 반환 - [likedClubs], - ); - - // 컨텍스트 생서자로 데이터 제공 - return ( - - {children} - - ); -}; - -export const useLikeContext = () => { - // 컨텍스트 사용으로 데이터 얻기 - const context = useContext(LikeContext); - if (context === undefined) { - throw new Error('useLikeContext must be used within a LikeProvider'); - } - return context; -}; diff --git a/src/lib/utils/fetchBookClub.test.ts b/src/lib/utils/fetchBookClub.test.ts index 1873767..5867265 100644 --- a/src/lib/utils/fetchBookClub.test.ts +++ b/src/lib/utils/fetchBookClub.test.ts @@ -1,55 +1,75 @@ import { mockBookClubs } from '@/mocks/mockDatas'; import { fetchBookClubs } from './fetchBookClubs'; import { DEFAULT_FILTERS } from '@/constants/filters'; +import axios from 'axios'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; describe('fetchBookClubs', () => { beforeEach(() => { - global.fetch = jest.fn(); - }); - - afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); }); it('요청 성공 시 bookClubs를 반환해야 한다', async () => { - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue({ bookClubs: mockBookClubs }), + mockedAxios.get.mockResolvedValue({ + data: { bookClubs: mockBookClubs }, }); const result = await fetchBookClubs(DEFAULT_FILTERS); - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining(`${process.env.NEXT_PUBLIC_API_URL}/book-clubs?`), + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + expect(mockedAxios.get).toHaveBeenCalledWith( + `${process.env.NEXT_PUBLIC_API_URL}/book-clubs`, { - method: 'GET', + params: DEFAULT_FILTERS, headers: { - 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + Pragma: 'no-cache', }, }, ); expect(result).toEqual(mockBookClubs); }); + it('인증된 요청 시 토큰이 헤더에 포함되어야 한다', async () => { + mockedAxios.get.mockResolvedValue({ + data: { bookClubs: mockBookClubs }, + }); + + const token = 'test-token'; + await fetchBookClubs(DEFAULT_FILTERS, token); + + expect(mockedAxios.get).toHaveBeenCalledWith( + `${process.env.NEXT_PUBLIC_API_URL}/book-clubs`, + { + params: DEFAULT_FILTERS, + headers: { + Authorization: `Bearer ${token}`, + 'Cache-Control': 'no-cache', + Pragma: 'no-cache', + }, + }, + ); + }); + it('HTTP 에러 발생 시 빈 배열을 반환해야 한다', async () => { - (global.fetch as jest.Mock).mockResolvedValue({ - ok: false, - status: 500, + mockedAxios.get.mockResolvedValue({ + response: { status: 500 }, }); const result = await fetchBookClubs(DEFAULT_FILTERS); - expect(fetch).toHaveBeenCalledTimes(1); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); expect(result).toEqual([]); }); - it('fetch 호출 중 에러 발생 시 빈 배열을 반환해야 한다', async () => { - (global.fetch as jest.Mock).mockRejectedValue(new Error('Network Error')); + it('네트워크 에러 발생 시 빈 배열을 반환해야 한다', async () => { + mockedAxios.get.mockResolvedValue(new Error('Network Error')); const result = await fetchBookClubs(DEFAULT_FILTERS); - expect(fetch).toHaveBeenCalledTimes(1); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); expect(result).toEqual([]); }); }); diff --git a/src/lib/utils/fetchBookClubs.ts b/src/lib/utils/fetchBookClubs.ts index a0dba2e..66f0f81 100644 --- a/src/lib/utils/fetchBookClubs.ts +++ b/src/lib/utils/fetchBookClubs.ts @@ -1,31 +1,20 @@ import { BookClubParams } from '@/types/bookclubs'; +import axios from 'axios'; export async function fetchBookClubs(filters: BookClubParams, token?: string) { try { - // filters 객체를 URLSearchParams로 변환 - const queryParams = new URLSearchParams( - Object.entries(filters) - .filter(([, value]) => value !== undefined && value !== null) - .map(([key, value]) => [key, String(value)]), - ).toString(); + const baseURL = process.env.NEXT_PUBLIC_API_URL; - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/book-clubs?${queryParams}`, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, + const response = await axios.get(`${baseURL}/book-clubs`, { + params: filters, + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + 'Cache-Control': 'no-cache', + Pragma: 'no-cache', }, - ); + }); - if (!res.ok) { - throw new Error(`HTTP error! status: ${res.status}`); - } - - const response = await res.json(); - return response.bookClubs; + return response.data.bookClubs; } catch (error) { if (process.env.NODE_ENV === 'development') { console.error('Error:', error); // 개발 환경에서만 로그 출력