diff --git a/src/app/(user-page)/my-meeting/_features/Tab.tsx b/src/app/(user-page)/my-meeting/_features/Tab.tsx index 9dcf139..032ccb2 100644 --- a/src/app/(user-page)/my-meeting/_features/Tab.tsx +++ b/src/app/(user-page)/my-meeting/_features/Tab.tsx @@ -3,12 +3,16 @@ import { Button } from '@/components/ui/Button'; import { useRouter } from 'next/navigation'; -const tabList = [ - { label: '내가 만든 모임', value: 'created' }, - { label: '내가 참여하고 있는 모임', value: 'joined' }, -]; +interface TabProps { + type: string; + tabList: { + label: string; + value: string; + url: string; + }[]; +} -const Tab = ({ type }: { type: string }) => { +const Tab = ({ type, tabList }: TabProps) => { const router = useRouter(); return ( @@ -18,7 +22,7 @@ const Tab = ({ type }: { type: string }) => { key={item.value} className="w-fit px-4" variant={type === item.value ? 'solid' : 'default'} - onClick={() => router.push(`/my-meeting/my?type=${item.value}`)} + onClick={() => router.push(`${item.url}?type=${item.value}`)} > {item.label} diff --git a/src/app/(user-page)/my-meeting/_features/Writable.tsx b/src/app/(user-page)/my-meeting/_features/Writable.tsx new file mode 100644 index 0000000..b5d8ccf --- /dev/null +++ b/src/app/(user-page)/my-meeting/_features/Writable.tsx @@ -0,0 +1,133 @@ +'use client'; + +import HorizonCard from '@/components/ui/HorizonCard'; +import useInfiniteScroll from '@/hooks/common/useInfiniteScroll'; +import { useInfiniteWritableMyMeetingsQueries } from '@/hooks/queries/useMyCommentQueries'; +import { translateCategoryNameToEng } from '@/util/searchFilter'; +import { useRouter } from 'next/navigation'; +import { MyComment } from 'types/myComment'; + +import MeetingListSkeleton from './skeletons/SkeletonMeetingList'; + +const Writable = () => { + const router = useRouter(); + const { + data: meetingData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + error, + } = useInfiniteWritableMyMeetingsQueries(); + + const allMeetings: MyComment[] = + meetingData?.pages.flatMap((page) => page.content) || []; + + const lastMeetingRef = useInfiniteScroll({ + fetchNextPage, + isFetchingNextPage, + hasNextPage, + }); + + if (error) { +
+ 에러가 발생했습니다.
+ 다시 시도해주세요. +
; + } + + if (isLoading) { + return ; + } + + const handleMoveDetailPage = (meetingId: number, category: string) => { + const categoryEng = translateCategoryNameToEng(category); + router.push(`/meeting/${categoryEng}/${meetingId}`); + }; + + return ( +
+ {allMeetings.length === 0 && ( +
+ 리뷰 작성 가능한 모임이 없습니다.
+ 모임에 참여하고 리뷰를 작성해보세요! +
+ )} +
+ {allMeetings.map((meeting) => ( +
+ {/* 데스크탑 */} +
+ + handleMoveDetailPage(meeting.meetingId, meeting.categoryTitle) + } + key={meeting.meetingId} + title={meeting.meetingTitle} + thumbnailUrl={meeting.thumbnail} + location={meeting.location} + total={meeting.maxMember} + value={meeting.memberCount} + className="flex-row" + showLikeButton={false} + meetingId={meeting.meetingId} + category={''} + > +
+ + {/* 태블릿 */} +
+ + handleMoveDetailPage(meeting.meetingId, meeting.categoryTitle) + } + key={meeting.meetingId} + title={meeting.meetingTitle} + thumbnailUrl={meeting.thumbnail} + location={meeting.location} + total={meeting.maxMember} + value={meeting.memberCount} + thumbnailHeight={160} + thumbnailWidth={160} + meetingId={meeting.meetingId} + category={''} + showLikeButton={false} + /> +
+ + {/* 모바일 */} +
+ + handleMoveDetailPage(meeting.meetingId, meeting.categoryTitle) + } + key={meeting.meetingId} + title={meeting.meetingTitle} + thumbnailUrl={meeting.thumbnail} + location={meeting.location} + total={meeting.maxMember} + value={meeting.memberCount} + thumbnailHeight={80} + thumbnailWidth={80} + meetingId={meeting.meetingId} + category={''} + showLikeButton={false} + /> +
+
+ ))} + + {/* 무한 스크롤을 위한 별도의 Observer 요소 */} + {hasNextPage && ( +
+ )} +
+
+ ); +}; + +export default Writable; diff --git a/src/app/(user-page)/my-meeting/_features/Written.tsx b/src/app/(user-page)/my-meeting/_features/Written.tsx new file mode 100644 index 0000000..97fc9a5 --- /dev/null +++ b/src/app/(user-page)/my-meeting/_features/Written.tsx @@ -0,0 +1,151 @@ +'use client'; + +import ReviewItem from '@/app/meeting/_features/ReviewItem'; +import HorizonCard from '@/components/ui/HorizonCard'; +import useInfiniteScroll from '@/hooks/common/useInfiniteScroll'; +import { useInfiniteWrittenMyCommentQueries } from '@/hooks/queries/useMyCommentQueries'; +import { translateCategoryNameToEng } from '@/util/searchFilter'; +import { useRouter } from 'next/navigation'; +import { MyComment } from 'types/myComment'; + +import MeetingListSkeleton from './skeletons/SkeletonMeetingList'; + +const Written = () => { + const router = useRouter(); + const { + data: commentData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + error, + } = useInfiniteWrittenMyCommentQueries(); + + const allComments: MyComment[] = + commentData?.pages.flatMap((page) => page.content) || []; + + const lastMeetingRef = useInfiniteScroll({ + fetchNextPage, + isFetchingNextPage, + hasNextPage, + }); + + if (error) { +
+ 에러가 발생했습니다.
+ 다시 시도해주세요. +
; + } + + if (isLoading) { + return ; + } + + const handleMoveDetailPage = (meetingId: number, category: string) => { + const categoryEng = translateCategoryNameToEng(category); + router.push(`/meeting/${categoryEng}/${meetingId}`); + }; + + return ( +
+ {/* 데이터가 없는 경우 표시 */} + {allComments.length === 0 && ( +
+ 작성한 리뷰가 없습니다.
+ 모임에 참여하고 리뷰를 작성해보세요! +
+ )} +
+ {allComments.map((comment) => ( +
+ {/* 데스크탑 */} +
+ + handleMoveDetailPage(comment.meetingId, comment.categoryTitle) + } + key={comment.meetingId} + title={comment.meetingTitle} + thumbnailUrl={comment.thumbnail} + location={comment.location} + total={comment.maxMember} + value={comment.memberCount} + className="flex-row p-0" + meetingId={comment.meetingId} + showLikeButton={false} + category={''} + > + +
+ + {/* 태블릿 */} +
+ + handleMoveDetailPage(comment.meetingId, comment.categoryTitle) + } + key={comment.meetingId} + title={comment.meetingTitle} + thumbnailUrl={comment.thumbnail} + location={comment.location} + total={comment.maxMember} + value={comment.memberCount} + thumbnailHeight={160} + thumbnailWidth={160} + className="p-0" + meetingId={comment.meetingId} + showLikeButton={false} + category={''} + /> + +
+ + {/* 모바일 */} +
+ + handleMoveDetailPage(comment.meetingId, comment.categoryTitle) + } + key={comment.meetingId} + title={comment.meetingTitle} + thumbnailUrl={comment.thumbnail} + location={comment.location} + total={comment.maxMember} + value={comment.memberCount} + thumbnailHeight={80} + thumbnailWidth={80} + className="" + meetingId={comment.meetingId} + category={''} + showLikeButton={false} + /> + +
+
+ ))} + + {/* 무한 스크롤을 위한 별도의 Observer 요소 */} + {hasNextPage && ( +
+ )} +
+
+ ); +}; +export default Written; diff --git a/src/app/(user-page)/my-meeting/comments/page.tsx b/src/app/(user-page)/my-meeting/comments/page.tsx index 27b8b8c..3aa4615 100644 --- a/src/app/(user-page)/my-meeting/comments/page.tsx +++ b/src/app/(user-page)/my-meeting/comments/page.tsx @@ -1,5 +1,61 @@ import NotYet from '@/components/common/NotYet'; +import { MY_COMMENT_KEY } from '@/hooks/queries/useMyCommentQueries'; +import { + HydrationBoundary, + QueryClient, + dehydrate, +} from '@tanstack/react-query'; +import { MY_COMMENT_TAB_LIST } from 'constants/mypage/mypageConstant'; +import { + getMyMeetingsWritableComments, + getMyWrittenComments, +} from 'service/api/mycomments'; +import { Paginated } from 'types/meeting'; +import { MyComment } from 'types/myComment'; -export default function CommentsPage() { - return ; +import Tab from '../_features/Tab'; +import Writable from '../_features/Writable'; +import Written from '../_features/Written'; + +export default async function CommentsPage({ + searchParams, +}: { + searchParams: { type: string }; +}) { + const type = searchParams?.type; + + const queryClient = new QueryClient(); + + if (type === 'writable') { + // 작성 가능한 리뷰 prefetch + await queryClient.prefetchInfiniteQuery({ + queryKey: MY_COMMENT_KEY.written, + queryFn: ({ pageParam }) => getMyMeetingsWritableComments(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage: Paginated) => { + return lastPage.nextCursor ?? null; + }, + }); + } else { + // 작성한 리뷰 prefetch + await queryClient.prefetchInfiniteQuery({ + queryKey: MY_COMMENT_KEY.written, + queryFn: ({ pageParam }) => getMyWrittenComments(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage: Paginated) => { + return lastPage.nextCursor ?? null; + }, + }); + } + + return ( +
+
+ +
+ + {type === 'writable' ? : } + +
+ ); } diff --git a/src/app/(user-page)/my-meeting/my/page.tsx b/src/app/(user-page)/my-meeting/my/page.tsx index f363515..0c59914 100644 --- a/src/app/(user-page)/my-meeting/my/page.tsx +++ b/src/app/(user-page)/my-meeting/my/page.tsx @@ -6,6 +6,7 @@ import { QueryClient, dehydrate, } from '@tanstack/react-query'; +import { MY_MEETING_TAB_LIST } from 'constants/mypage/mypageConstant'; import { getMyMeetingManage, getMyMeetingParticipated, @@ -56,7 +57,7 @@ export default async function Page({ return (
- +
{type === 'created' ? : } diff --git a/src/app/meeting/_features/ReviewItem.tsx b/src/app/meeting/_features/ReviewItem.tsx index 05e590b..5b6630d 100644 --- a/src/app/meeting/_features/ReviewItem.tsx +++ b/src/app/meeting/_features/ReviewItem.tsx @@ -3,20 +3,36 @@ import { getRelativeTime } from '@/util/date'; import Image from 'next/image'; import { Comment } from 'service/api/comment'; -const ReviewItem = ({ comment }: { comment: Comment }) => { +interface ReviewItemProps { + className?: string; + comment: Comment; + isMine?: boolean; +} + +const ReviewItem = ({ + comment, + isMine = false, + className, +}: ReviewItemProps) => { return ( -
-
-
- 유저 프로필 이미지 -

{comment.userName}

-
+
+
+ {!isMine && comment.profilePic && comment.userName && ( +
+ 유저 프로필 이미지 +

{comment.userName}

+
+ )}
diff --git a/src/constants/mypage/mypageConstant.tsx b/src/constants/mypage/mypageConstant.tsx index 01e6dd3..b1a2a7c 100644 --- a/src/constants/mypage/mypageConstant.tsx +++ b/src/constants/mypage/mypageConstant.tsx @@ -100,3 +100,29 @@ export const LOCATION_OPTIONS = [ { value: '제주', label: '제주' }, { value: '선택 안함', label: '선택 안함' }, ]; + +export const MY_MEETING_TAB_LIST = [ + { + label: '내가 만든 모임', + value: 'created', + url: '/my-meeting/my', + }, + { + label: '내가 참여하고 있는 모임', + value: 'joined', + url: '/my-meeting/my', + }, +]; + +export const MY_COMMENT_TAB_LIST = [ + { + label: '리뷰 작성 가능한 모임', + value: 'writable', + url: '/my-meeting/comments', + }, + { + label: '작성한 리뷰', + value: 'written', + url: '/my-meeting/comments', + }, +]; diff --git a/src/hooks/queries/useMyCommentQueries.ts b/src/hooks/queries/useMyCommentQueries.ts new file mode 100644 index 0000000..891a4a8 --- /dev/null +++ b/src/hooks/queries/useMyCommentQueries.ts @@ -0,0 +1,32 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { + getMyMeetingsWritableComments, + getMyWrittenComments, +} from 'service/api/mycomments'; + +export const MY_COMMENT_KEY = { + writable: ['mycomments', 'writable'] as const, + written: ['mycomments', 'written'] as const, +}; + +export const useInfiniteWrittenMyCommentQueries = () => { + return useInfiniteQuery({ + queryKey: MY_COMMENT_KEY.written, + queryFn: ({ pageParam }) => getMyWrittenComments(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => { + return lastPage.nextCursor ?? null; + }, + }); +}; + +export const useInfiniteWritableMyMeetingsQueries = () => { + return useInfiniteQuery({ + queryKey: MY_COMMENT_KEY.writable, + queryFn: ({ pageParam }) => getMyMeetingsWritableComments(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => { + return lastPage.nextCursor ?? null; + }, + }); +}; diff --git a/src/middleware.ts b/src/middleware.ts index 801e696..177213e 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -11,6 +11,14 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(new URL('/login', request.url)); } + const { pathname, searchParams } = request.nextUrl; + + // /my-meeting/comments' 경로에서 type 쿼리 파라미터가 없으면 writable로 세팅 + if (pathname === '/my-meeting/comments' && !searchParams.has('type')) { + searchParams.set('type', 'writable'); + return NextResponse.redirect(request.nextUrl); + } + // 인증된 사용자는 요청을 계속 진행 return NextResponse.next(); } diff --git a/src/service/api/comment.ts b/src/service/api/comment.ts index 5375bd7..e8e1888 100644 --- a/src/service/api/comment.ts +++ b/src/service/api/comment.ts @@ -14,8 +14,8 @@ export interface Comment { content: string; createdAt: string; meetingId: number; - userName: string; - profilePic: string; + userName?: string; + profilePic?: string; } export interface Comments { diff --git a/src/service/api/endpoints.ts b/src/service/api/endpoints.ts index cf38d52..6f876d6 100644 --- a/src/service/api/endpoints.ts +++ b/src/service/api/endpoints.ts @@ -14,6 +14,7 @@ export const mypageURL = { contact: `${CURRENT_API_VERSION}/mypage/contact`, skills: `${CURRENT_API_VERSION}/mypage/skills`, comments: `${CURRENT_API_VERSION}/mypage/comments`, + mettingComments: `${CURRENT_API_VERSION}/mypage/meeting-comment`, banner: `${CURRENT_API_VERSION}/mypage/banner`, }; diff --git a/src/service/api/meeting.ts b/src/service/api/meeting.ts index eb38ff8..8d2d12f 100644 --- a/src/service/api/meeting.ts +++ b/src/service/api/meeting.ts @@ -26,7 +26,6 @@ const getMeetings = async ( searchQueryObj: IMeetingSearchCondition, ): Promise> => { const newSearchQueryObj = { ...searchQueryObj, lastMeetingId: pageParams }; - const token = await getAccessToken(); const res = await axiosInstance.post( `/api/v1/meetings/search?categoryTitle=${category}`, diff --git a/src/service/api/mycomments.ts b/src/service/api/mycomments.ts new file mode 100644 index 0000000..e07d25b --- /dev/null +++ b/src/service/api/mycomments.ts @@ -0,0 +1,27 @@ +import axiosInstance from '@/lib/axios/axiosInstance'; +import { Paginated } from 'types/meeting'; +import { MyComment } from 'types/myComment'; + +import { mypageURL } from './endpoints'; + +const getMyWrittenComments = async ( + lastCommentId: number, +): Promise> => { + const res = await axiosInstance.get( + `${mypageURL.comments}?lastCommentId=${lastCommentId}&size=${3}`, + ); + + return res.data.data; +}; + +const getMyMeetingsWritableComments = async ( + lastCommentId: number, +): Promise> => { + const res = await axiosInstance.get( + `${mypageURL.mettingComments}?lastMeetingId=${lastCommentId}&size=${3}`, + ); + + return res.data.data; +}; + +export { getMyWrittenComments, getMyMeetingsWritableComments }; diff --git a/src/types/myComment.ts b/src/types/myComment.ts new file mode 100644 index 0000000..c0f3584 --- /dev/null +++ b/src/types/myComment.ts @@ -0,0 +1,12 @@ +import type { Comment } from '../service/api/comment'; + +interface MyComment extends Comment { + meetingTitle: string; + thumbnail: string; + location: string; + memberCount: number; + maxMember: number; + categoryTitle: string; +} + +export type { MyComment };