diff --git a/src/app/(main)/mypage/_component/MyGatheringList.tsx b/src/app/(main)/mypage/_component/MyGatheringList.tsx index 5dcd832a..01ecbaf5 100644 --- a/src/app/(main)/mypage/_component/MyGatheringList.tsx +++ b/src/app/(main)/mypage/_component/MyGatheringList.tsx @@ -5,10 +5,19 @@ import Card from '@/app/components/Card/Card'; import InfiniteScroll from '@/app/components/InfiniteScroll/InfiniteScroll'; import ReviewModal from '@/app/components/Modal/ReviewModal'; import { useState } from 'react'; +import { UserJoinedGatheringsData } from '@/types/data.type'; +import useParticipation from '@/hooks/useParticipation'; +import { UserData } from '@/types/client.type'; -const MyGatheringList = () => { +interface MyGatheringListProps { + user: UserData | null; + initData: UserJoinedGatheringsData[]; +} + +const MyGatheringList = ({ initData, user }: MyGatheringListProps) => { const [isModalOpen, setIsModalOpen] = useState(false); const [cardId, setCardId] = useState(0); + const { handleWithdrawClickWithId } = useParticipation(user); const handleOpenModal = (id: number) => { setCardId(id); @@ -22,21 +31,20 @@ const MyGatheringList = () => { return ( <> ( - console.log('Save Discard')} - data={item} - > + { item.isCompleted ? handleOpenModal(item.id) - : console.log('Cancel gathering'); + : handleWithdrawClickWithId(item.id, ['myGatherings']); }} /> diff --git a/src/app/(main)/mypage/loading.tsx b/src/app/(main)/mypage/loading.tsx new file mode 100644 index 00000000..3bb70580 --- /dev/null +++ b/src/app/(main)/mypage/loading.tsx @@ -0,0 +1,13 @@ +'use client'; + +import Loader from '@/app/components/Loader/Loader'; + +const Loading = () => { + return ( +
+ ; +
+ ); +}; + +export default Loading; diff --git a/src/app/(main)/mypage/page.tsx b/src/app/(main)/mypage/page.tsx index 3b3fc690..80af5d6d 100644 --- a/src/app/(main)/mypage/page.tsx +++ b/src/app/(main)/mypage/page.tsx @@ -1,13 +1,17 @@ import { Metadata } from 'next'; import MyGatheringList from './_component/MyGatheringList'; +import getMyGatherings from '@/app/api/actions/gatherings/getMyGatherings'; +import { getUserData } from '@/app/api/actions/mypage/getUserData'; export const metadata: Metadata = { title: '나의 모임 | Soothe With Me', description: 'Soothe With Me 나의 모임 페이지입니다.', }; -const Mygatherings = () => { - return ; +const Mygatherings = async () => { + const myGatherings = await getMyGatherings(); + const user = await getUserData(); + return ; }; export default Mygatherings; diff --git a/src/app/api/actions/gatherings/getMyGatherings.ts b/src/app/api/actions/gatherings/getMyGatherings.ts new file mode 100644 index 00000000..549d0f41 --- /dev/null +++ b/src/app/api/actions/gatherings/getMyGatherings.ts @@ -0,0 +1,34 @@ +'use server'; + +import { getCookie } from '@/actions/auth/cookie/cookie'; +import { GatheringType } from '@/types/data.type'; + +const getMyGatherings = async () => { + const token = await getCookie('token'); + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/gatherings/joined?limit=10&offset=0`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }, + ); + + if (!res.ok) { + throw new Error('모임을 불러오지 못했습니다.'); + } + + const data: GatheringType[] = await res.json(); + + return data; + } catch (error) { + throw new Error( + error instanceof Error ? error.message : '모임을 불러오지 못했습니다.', + ); + } +}; + +export default getMyGatherings; diff --git a/src/app/api/gatherings/joined/route.ts b/src/app/api/gatherings/joined/route.ts index ed1b3c9e..fa370f72 100644 --- a/src/app/api/gatherings/joined/route.ts +++ b/src/app/api/gatherings/joined/route.ts @@ -1,11 +1,15 @@ import { getCookie } from '@/actions/auth/cookie/cookie'; -import { DEFAULT_LIMIT, DEFAULT_OFFSET } from '@/constants/common'; +import { + DEFAULT_GATHERINGS_LIMIT, + DEFAULT_GATHERINGS_OFFSET, +} from '@/constants/common'; import { NextResponse } from 'next/server'; export async function GET(req: Request) { const { searchParams } = new URL(req.url); - const offset = Number(searchParams.get('offset')) || DEFAULT_OFFSET; - const limit = Number(searchParams.get('limit')) || DEFAULT_LIMIT; + const offset = + Number(searchParams.get('offset')) || DEFAULT_GATHERINGS_OFFSET; + const limit = Number(searchParams.get('limit')) || DEFAULT_GATHERINGS_LIMIT; const token = await getCookie('token'); @@ -22,10 +26,8 @@ export async function GET(req: Request) { const data = await response.json(); - const paginatedData = data.slice(offset, offset + limit); - - // 전체 데이터 길이와 비교하여 다음 페이지가 있는지 결정 - const hasNextPage = paginatedData.length === limit; + // 데이터 길이와 비교하여 다음 페이지가 있는지 결정 + const hasNextPage = data.length === limit; return NextResponse.json({ data: data, // API에서 반환하는 데이터 diff --git a/src/app/api/gatherings/service/getMyGathergins.ts b/src/app/api/gatherings/service/getMyGathergins.ts index 8b48f0f3..9720c77d 100644 --- a/src/app/api/gatherings/service/getMyGathergins.ts +++ b/src/app/api/gatherings/service/getMyGathergins.ts @@ -1,10 +1,14 @@ 'use client'; +import { + DEFAULT_GATHERINGS_LIMIT, + DEFAULT_GATHERINGS_OFFSET, +} from '@/constants/common'; import { FetchGatheringsResponse } from '@/types/data.type'; const getMyGathergins = async ( - offset = 0, - limit = 5, + offset = DEFAULT_GATHERINGS_OFFSET, + limit = DEFAULT_GATHERINGS_LIMIT, ): Promise => { const response = await fetch( `/api/gatherings/joined?offset=${offset}&limit=${limit}`, diff --git a/src/app/components/BottomFloatingBar/BottomFloatingBar.test.tsx b/src/app/components/BottomFloatingBar/BottomFloatingBar.test.tsx index a3386b56..fa9aa0b1 100644 --- a/src/app/components/BottomFloatingBar/BottomFloatingBar.test.tsx +++ b/src/app/components/BottomFloatingBar/BottomFloatingBar.test.tsx @@ -1,10 +1,12 @@ 'use client'; -import { render, screen } from '@testing-library/react'; +import { render, screen, RenderOptions } from '@testing-library/react'; import { useRouter } from 'next/navigation'; import BottomFloatingBar from './BottomFloatingBar'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { UserData } from '@/types/client.type'; import { GatheringParticipantsType } from '@/types/data.type'; +import { ReactNode } from 'react'; import '@testing-library/jest-dom'; // 유저데이터 모킹 @@ -24,6 +26,16 @@ jest.mock('next/navigation', () => ({ useParams: jest.fn(() => ({ id: '1' })), })); +// QueryClient 인스턴스 생성 +const queryClient = new QueryClient(); + +// QueryClientProvider로 감싸주는 헬퍼 함수 정의 +const renderWithQueryClient = (ui: ReactNode, options?: RenderOptions) => + render( + {ui}, + options, + ); + describe('BottomFloatingBar 컴포넌트 테스트', () => { const mockRouter = { push: jest.fn() }; @@ -33,7 +45,8 @@ describe('BottomFloatingBar 컴포넌트 테스트', () => { // 참가자일 때 기본 렌더링 확인 it('renders correctly when the user is a participant', () => { - render( + renderWithQueryClient( + // 헬퍼 함수 사용 { // 주최자일 때 기본 렌더링 확인 it('renders correctly when the user is the organizer', () => { - render( + renderWithQueryClient( + // 헬퍼 함수 사용 { } interface InfiniteScrollProps { + initData: T[]; queryKey: string[]; queryFn: (offset?: number) => Promise>; limit?: number; emptyText: string; + errorText: string; renderItem: (item: T, index: number) => JSX.Element; } const InfiniteScroll = ({ + initData, queryKey, queryFn, - limit = DEFAULT_LIMIT, + limit = DEFAULT_GATHERINGS_LIMIT, emptyText, + errorText, renderItem, }: InfiniteScrollProps) => { - const { ref, inView } = useInView({ - triggerOnce: false, - threshold: 1.0, + const [topGradientVisible, setTopGradientVisible] = useState(false); + const [bottomGradientVisible, setBottomGradientVisible] = useState(false); + + const { ref, inView } = useInView({ threshold: 0 }); + const { ref: firstGatheringRef, inView: firstInView } = useInView({ + threshold: 0, + }); + const { ref: lastGatheringRef, inView: lastInView } = useInView({ + threshold: 0, }); const { data, isError, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({ queryKey, - queryFn: async ({ pageParam = DEFAULT_LIMIT }) => queryFn(pageParam), + queryFn: async ({ pageParam = DEFAULT_GATHERINGS_LIMIT }) => + queryFn(pageParam), getNextPageParam: (lastPage) => { return lastPage.hasNextPage ? lastPage.offset + limit : undefined; }, - initialPageParam: 0, + initialPageParam: DEFAULT_GATHERINGS_OFFSET, + initialData: { + pages: [ + { + hasNextPage: initData.length === DEFAULT_GATHERINGS_LIMIT, + offset: DEFAULT_GATHERINGS_OFFSET, + data: initData, + }, + ], + pageParams: [DEFAULT_GATHERINGS_OFFSET], + }, }); useEffect(() => { + setTopGradientVisible(!firstInView); + setBottomGradientVisible(!lastInView); if (inView && hasNextPage) { fetchNextPage(); } - }, [inView]); + }, [inView, firstInView, lastInView]); // 데이터가 없거나 비어있을 때 emptyText를 표시 - if (!data || data.pages.length === 0) { + if (!data || data.pages[0].data.length === 0) { return (
{emptyText} @@ -60,25 +87,47 @@ const InfiniteScroll = ({ if (isError) return (
- 모임을 불러오지 못했습니다. + {errorText}
); + const allItems = data.pages.flatMap((page) => page.data); return ( <> + {/* Top gradient */} +
+ + {/* Bottom gradient */} +
+
    - {data && - data.pages.map((page) => - page.data.map((item, index: number) => ( -
  • {renderItem(item, index)}
  • // 사용자 정의 컴포넌트를 렌더링 - )), - )} + {allItems.map((item, index) => ( +
  • + {renderItem(item, index)} +
  • + ))}
{isFetching && ( -
-
- - Loading... +
+ +
)} diff --git a/src/app/components/Modal/ReviewModal.tsx b/src/app/components/Modal/ReviewModal.tsx index 0672e1b6..55300ad8 100644 --- a/src/app/components/Modal/ReviewModal.tsx +++ b/src/app/components/Modal/ReviewModal.tsx @@ -61,6 +61,7 @@ const ReviewModal = ({ gatheringId, onClose }: ReviewModalProps) => { name='리뷰 등록' type='button' variant={score !== 0 && comment ? 'default' : 'gray'} + disabled={score === 0 || comment.length === 0} onClick={handleSubmit} />
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 79da0c09..35dd522b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -25,13 +25,13 @@ export default async function RootLayout({ }: Readonly<{ children: ReactNode; }>) { - const userData = await getUserData(); + const user = await getUserData(); const token = await getCookie('token'); return ( - +
{children}
diff --git a/src/constants/common.ts b/src/constants/common.ts index 87cc27e3..6ba8db23 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -24,8 +24,8 @@ export const REVIEWS_PER_PAGE = 4; export const MIN_PARTICIPANTS = 5; // 무한스크롤 관련 데이터 패칭 상수 -export const DEFAULT_OFFSET = 0; -export const DEFAULT_LIMIT = 5; +export const DEFAULT_GATHERINGS_OFFSET = 0; +export const DEFAULT_GATHERINGS_LIMIT = 10; export const LIMIT_PER_REQUEST = 10; diff --git a/src/hooks/useParticipation.ts b/src/hooks/useParticipation.ts index 60ff73b1..45ec32de 100644 --- a/src/hooks/useParticipation.ts +++ b/src/hooks/useParticipation.ts @@ -6,9 +6,11 @@ import deleteGatheringToWithdraw from '@/app/api/actions/gatherings/deleteGather import { UserData } from '@/types/client.type'; import toast from 'react-hot-toast'; +import { useQueryClient } from '@tanstack/react-query'; export default function useParticipation(user: UserData | null) { const params = useParams(); + const queryClient = useQueryClient(); const [hasParticipated, setHasParticipated] = useState(false); const [isShowPopup, setIsShowPopup] = useState(false); @@ -48,6 +50,20 @@ export default function useParticipation(user: UserData | null) { } }; + const handleWithdrawClickWithId = async (id: number, queryKey: string[]) => { + const { success, message } = await deleteGatheringToWithdraw(id); + + if (!success) { + toast.error(message); + return; + } + // 쿼리 무효화 함수 + await queryClient.invalidateQueries({ queryKey }); // querykey 무효화시켜서 취소한 모임 반영하여 최신화 + + toast.success(message); + setHasParticipated(false); + }; + return { hasParticipated, setHasParticipated, @@ -55,5 +71,6 @@ export default function useParticipation(user: UserData | null) { setIsShowPopup, handleJoinClick, handleWithdrawClick, + handleWithdrawClickWithId, }; } diff --git a/src/hooks/useReviews/useReveiws.ts b/src/hooks/useReviews/useReveiws.ts index 84957e15..9574cc0e 100644 --- a/src/hooks/useReviews/useReveiws.ts +++ b/src/hooks/useReviews/useReveiws.ts @@ -5,7 +5,10 @@ import getReviewScore from '@/app/api/actions/reviews/getReviewScore'; import useFilterState from './useFilterState'; import buildParams from './buildParams'; import { ReviewScoreType, ReviewsType } from '@/types/data.type'; -import { DEFAULT_OFFSET, LIMIT_PER_REQUEST } from '@/constants/common'; +import { + DEFAULT_GATHERINGS_OFFSET, + LIMIT_PER_REQUEST, +} from '@/constants/common'; const useReviews = ( initialReviewsData: ReviewsType[], @@ -57,7 +60,7 @@ const useReviews = ( error: reviewsError, } = useInfiniteQuery({ queryKey: ['reviews', filteringOptions, activeTab, selectedChip], - queryFn: async ({ pageParam = DEFAULT_OFFSET }) => { + queryFn: async ({ pageParam = DEFAULT_GATHERINGS_OFFSET }) => { try { const params = getParams(); const response = await getReviewList({ @@ -78,10 +81,10 @@ const useReviews = ( ? allPages.length * LIMIT_PER_REQUEST : undefined; }, - initialPageParam: DEFAULT_OFFSET, + initialPageParam: DEFAULT_GATHERINGS_OFFSET, initialData: { pages: [initialReviewsData], - pageParams: [DEFAULT_OFFSET], + pageParams: [DEFAULT_GATHERINGS_OFFSET], }, }); diff --git a/src/hooks/useUserCreated.ts b/src/hooks/useUserCreated.ts index a6b349ab..fe870112 100644 --- a/src/hooks/useUserCreated.ts +++ b/src/hooks/useUserCreated.ts @@ -2,7 +2,7 @@ import { useInfiniteQuery } from '@tanstack/react-query'; import getGatherings from '@/app/api/actions/gatherings/getGatherings'; import { GatheringType } from '@/types/data.type'; import { - DEFAULT_OFFSET, + DEFAULT_GATHERINGS_OFFSET, LIMIT_PER_REQUEST, SORT_OPTIONS_MAP, } from '@/constants/common'; @@ -20,7 +20,7 @@ export const useUserCreated = ( error, } = useInfiniteQuery({ queryKey: ['created', createdBy], - queryFn: async ({ pageParam = DEFAULT_OFFSET }) => { + queryFn: async ({ pageParam = DEFAULT_GATHERINGS_OFFSET }) => { try { const response = await getGatherings({ createdBy: createdBy, @@ -42,10 +42,10 @@ export const useUserCreated = ( ? allPages.length * LIMIT_PER_REQUEST : undefined; }, - initialPageParam: DEFAULT_OFFSET, + initialPageParam: DEFAULT_GATHERINGS_OFFSET, initialData: { pages: [initialGatheringList], - pageParams: [DEFAULT_OFFSET], + pageParams: [DEFAULT_GATHERINGS_OFFSET], }, });