From cc49bccaf5ef83ac17bb12fce7c4629905939222 Mon Sep 17 00:00:00 2001 From: LeeCh0129 Date: Sat, 2 Aug 2025 16:10:27 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=B2=B4=ED=97=98?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20API,=20=ED=9B=85=20=EB=B0=8F=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/assets/svg/moreOptionsIcon.tsx | 18 ++++ src/apis/myActivities.ts | 28 +++++ .../activities/components/ActivityCard.tsx | 102 ++++++++++++++++++ .../components/DeleteActivityModal.tsx | 65 +++++++++++ .../activities/components/EmptyActivities.tsx | 17 +++ src/hooks/useMyActivitiesQueries.ts | 49 +++++++++ 6 files changed, 279 insertions(+) create mode 100644 public/assets/svg/moreOptionsIcon.tsx create mode 100644 src/apis/myActivities.ts create mode 100644 src/app/(with-header)/mypage/activities/components/ActivityCard.tsx create mode 100644 src/app/(with-header)/mypage/activities/components/DeleteActivityModal.tsx create mode 100644 src/app/(with-header)/mypage/activities/components/EmptyActivities.tsx create mode 100644 src/hooks/useMyActivitiesQueries.ts diff --git a/public/assets/svg/moreOptionsIcon.tsx b/public/assets/svg/moreOptionsIcon.tsx new file mode 100644 index 0000000..746c253 --- /dev/null +++ b/public/assets/svg/moreOptionsIcon.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +const MoreOptionsIcon = ({ size = 24, ...props }) => ( + + + + + +); + +export default MoreOptionsIcon; diff --git a/src/apis/myActivities.ts b/src/apis/myActivities.ts new file mode 100644 index 0000000..cd03443 --- /dev/null +++ b/src/apis/myActivities.ts @@ -0,0 +1,28 @@ +import { privateInstance } from './privateInstance'; +import { MyActivitiesResponse } from '@/types/dashboardTypes'; + +/** + * 내 체험 리스트 조회 (무한 스크롤용) + * GET /my-activities + */ +export const getMyActivitiesWithPagination = async (params?: { + cursorId?: number; + size?: number; +}): Promise => { + const queryParams = new URLSearchParams(); + if (params?.cursorId) + queryParams.append('cursorId', params.cursorId.toString()); + if (params?.size) queryParams.append('size', params.size.toString()); + + const url = `/my-activities${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + const response = await privateInstance.get(url); + return response.data; +}; + +/** + * 내 체험 삭제 + * DELETE /deleteActivity/{id} + */ +export const deleteMyActivity = async (id: number): Promise => { + await privateInstance.delete(`/deleteActivity/${id}`); +}; diff --git a/src/app/(with-header)/mypage/activities/components/ActivityCard.tsx b/src/app/(with-header)/mypage/activities/components/ActivityCard.tsx new file mode 100644 index 0000000..9d6ca40 --- /dev/null +++ b/src/app/(with-header)/mypage/activities/components/ActivityCard.tsx @@ -0,0 +1,102 @@ +'use client'; + +import Image from 'next/image'; +import { useState } from 'react'; +import { MyActivity } from '@/types/dashboardTypes'; +import { useRouter } from 'next/navigation'; +import Star from '@assets/svg/star'; +import MoreOptionsIcon from '@assets/svg/moreOptionsIcon'; + +interface ActivityCardProps { + activity: MyActivity; + onDelete: (activityId: number) => void; +} + +export default function ActivityCard({ + activity, + onDelete, +}: ActivityCardProps) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const router = useRouter(); + + const { id, title, price, bannerImageUrl, rating, reviewCount } = activity; + + const handleEdit = () => { + router.push(`/myactivity/${id}`); + }; + + const handleDelete = () => { + onDelete(id); + setIsMenuOpen(false); + }; + + return ( +
+ {/* 이미지 영역 */} +
+ {title} +
+ + {/* 콘텐츠 영역 */} +
+ {/* 별점 및 리뷰 */} +
+
+ + {rating} + + ({reviewCount}) + +
+
+ + {/* 제목 */} +
+

{title}

+
+ +
+ {/* 가격 */} +

+ ₩{price.toLocaleString()} / 인 +

+ + {/* 더보기 옵션 */} +
+ + + {isMenuOpen && ( + <> +
setIsMenuOpen(false)} + /> + + {/* 드롭다운 메뉴 */} +
+ + +
+ + )} +
+
+
+
+ ); +} diff --git a/src/app/(with-header)/mypage/activities/components/DeleteActivityModal.tsx b/src/app/(with-header)/mypage/activities/components/DeleteActivityModal.tsx new file mode 100644 index 0000000..f23bd8e --- /dev/null +++ b/src/app/(with-header)/mypage/activities/components/DeleteActivityModal.tsx @@ -0,0 +1,65 @@ +'use client'; + +import Modal from '@/components/Modal'; +import Button from '@/components/Button'; +import CheckIcon from '@assets/svg/check'; + +interface DeleteActivityModalProps { + isOpen: boolean; + onCancel: () => void; + onConfirm: () => void; + isLoading?: boolean; +} + +export default function DeleteActivityModal({ + isOpen, + onCancel, + onConfirm, + isLoading = false, +}: DeleteActivityModalProps) { + return ( + !open && onCancel()}> + +
+ {/* 체크 아이콘 */} +
+ +
+ + {/* 메시지 */} +

+ 체험을 삭제하시겠어요? +

+ + {/* 버튼 */} +
+ + +
+
+
+
+ ); +} diff --git a/src/app/(with-header)/mypage/activities/components/EmptyActivities.tsx b/src/app/(with-header)/mypage/activities/components/EmptyActivities.tsx new file mode 100644 index 0000000..41e50f8 --- /dev/null +++ b/src/app/(with-header)/mypage/activities/components/EmptyActivities.tsx @@ -0,0 +1,17 @@ +import EmptyDocumentIcon from '@assets/svg/empty-document'; + +export default function EmptyActivities() { + return ( +
+ {/* 빈 상태 아이콘 */} +
+ +
+ + {/* 빈 상태 메시지 */} +

+ 아직 등록한 체험이 없어요 +

+
+ ); +} diff --git a/src/hooks/useMyActivitiesQueries.ts b/src/hooks/useMyActivitiesQueries.ts new file mode 100644 index 0000000..99037dd --- /dev/null +++ b/src/hooks/useMyActivitiesQueries.ts @@ -0,0 +1,49 @@ +'use client'; + +import { + useMutation, + useQueryClient, + useInfiniteQuery, +} from '@tanstack/react-query'; +import { + getMyActivitiesWithPagination, + deleteMyActivity, +} from '@/apis/myActivities'; + +export const MY_ACTIVITIES_QUERY_KEYS = { + ALL: ['my-activities'] as const, + INFINITE: ['my-activities', 'infinite'] as const, +} as const; + +// 내 체험 리스트 조회 (무한 스크롤) +export const useMyActivitiesInfinite = () => { + return useInfiniteQuery({ + queryKey: MY_ACTIVITIES_QUERY_KEYS.INFINITE, + queryFn: ({ pageParam = 0 }) => + getMyActivitiesWithPagination({ cursorId: pageParam, size: 10 }), + getNextPageParam: (lastPage) => { + return lastPage.activities.length === 10 ? lastPage.cursorId : undefined; + }, + initialPageParam: 0, + staleTime: 1000 * 60 * 5, + }); +}; + +// 내 체험 삭제 +export const useDeleteMyActivity = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteMyActivity, + onSuccess: () => { + // 내 체험 관련 쿼리들 무효화 + queryClient.invalidateQueries({ + queryKey: MY_ACTIVITIES_QUERY_KEYS.ALL, + }); + alert('체험이 삭제되었습니다.'); + }, + onError: (error) => { + alert(`체험 삭제 실패: ${error.message}`); + }, + }); +}; From fe6f07c3219d3cea61b1be427feb1fbf55fd76b7 Mon Sep 17 00:00:00 2001 From: LeeCh0129 Date: Sat, 2 Aug 2025 16:11:02 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=B2=B4=ED=97=98=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B2=B4=ED=97=98=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(with-header)/myactivity/layout.tsx | 74 ++++++++ .../(with-header)/mypage/activities/page.tsx | 177 +++++++++++++++++- .../components/CancelReservationModal.tsx | 4 +- 3 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 src/app/(with-header)/myactivity/layout.tsx diff --git a/src/app/(with-header)/myactivity/layout.tsx b/src/app/(with-header)/myactivity/layout.tsx new file mode 100644 index 0000000..36ce8d2 --- /dev/null +++ b/src/app/(with-header)/myactivity/layout.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { ProfileNavigation } from '@/app/(with-header)/mypage/components'; +import useResponsiveRouting from '@/hooks/useResponsiveRouting'; +import { useMyProfile } from '@/hooks/useMyPageQueries'; + +export default function MyActivityLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { mounted } = useResponsiveRouting(); + const { isLoading, error } = useMyProfile(); + + // mounted + API 로딩 상태 모두 체크 + if (!mounted || isLoading) { + return ( +
+
+
+ {/* 좌측 프로필 네비게이션 스켈레톤 - 데스크톱/태블릿 */} +
+
+ {/* 프로필 이미지 영역 */} +
+
+
+ {/* 메뉴 리스트 영역 */} +
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+
+ {/* 메인 스켈레톤 */} +
+
+
+
+ ); + } + + if (error) { + return ( +
+
+

+ 로그인이 필요합니다 +

+

다시 로그인해주세요.

+
+
+ ); + } + + // API 로딩 완료 + mounted 상태일 때만 실행 + return ( +
+
+
+ {/* 좌측 프로필 네비게이션 섹션 - 데스크톱/태블릿에서만 표시 */} + + + {/* 우측 메인 콘텐츠 섹션 */} +
{children}
+
+
+
+ ); +} diff --git a/src/app/(with-header)/mypage/activities/page.tsx b/src/app/(with-header)/mypage/activities/page.tsx index 4d564d9..c979ad3 100644 --- a/src/app/(with-header)/mypage/activities/page.tsx +++ b/src/app/(with-header)/mypage/activities/page.tsx @@ -1,18 +1,175 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { + useMyActivitiesInfinite, + useDeleteMyActivity, +} from '@/hooks/useMyActivitiesQueries'; +import useInfiniteScroll from '@/hooks/useInfiniteScroll'; +import Button from '@/components/Button'; +import ActivityCard from './components/ActivityCard'; +import EmptyActivities from './components/EmptyActivities'; +import DeleteActivityModal from './components/DeleteActivityModal'; + export default function MyActivitiesPage() { + const router = useRouter(); + const [deleteModal, setDeleteModal] = useState<{ + isOpen: boolean; + activityId: number | null; + }>({ + isOpen: false, + activityId: null, + }); + + // 내 체험 리스트 조회 + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + error, + } = useMyActivitiesInfinite(); + + // 체험 삭제 + const deleteActivityMutation = useDeleteMyActivity(); + + // 무한 스크롤 훅 + const { lastElementRef } = useInfiniteScroll({ + hasNextPage, + isFetchingNextPage, + isLoading, + fetchNextPage, + }); + + // 체험 등록하기 + const handleCreateActivity = () => { + router.push('/myactivity'); + }; + + const handleDeleteClick = (activityId: number) => { + setDeleteModal({ + isOpen: true, + activityId, + }); + }; + + const handleDeleteConfirm = () => { + if (deleteModal.activityId) { + deleteActivityMutation.mutate(deleteModal.activityId); + } + setDeleteModal({ isOpen: false, activityId: null }); + }; + + const handleDeleteClose = () => { + setDeleteModal({ isOpen: false, activityId: null }); + }; + + // 전체 체험 목록 + const allActivities = data?.pages.flatMap((page) => page.activities) ?? []; + + // 로딩 상태 + if (isLoading) { + return ( +
+
+

+ 내 체험 관리 +

+ +
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+ ); + } + + // 에러 상태 + if (error) { + return ( +
+
+

+ 내 체험 관리 +

+ +
+
+

체험 목록을 불러오는데 실패했습니다.

+

{error.message}

+
+
+ ); + } + return ( <> - {/* 제목 */} -
-

- 내 체험 관리 -

-
+
+ {/* 제목 */} +
+

+ 내 체험 관리 +

+ +
- {/* 내 체험 관리 컨텐츠 */} -
-

내 체험 관리 페이지입니다.

- {/* TODO: 내 체험 관리 컴포넌트 구현 */} + {/* 체험 목록 */} + {allActivities.length === 0 ? ( + + ) : ( +
+ {allActivities.map((activity, index) => ( +
+ +
+ ))} + + {/* 무한 스크롤 로딩 */} + {isFetchingNextPage && ( +
+ )} +
+ )}
+ + {/* 삭제 확인 모달 */} + ); } diff --git a/src/app/(with-header)/mypage/reservations/components/CancelReservationModal.tsx b/src/app/(with-header)/mypage/reservations/components/CancelReservationModal.tsx index eedefe2..fd9f79d 100644 --- a/src/app/(with-header)/mypage/reservations/components/CancelReservationModal.tsx +++ b/src/app/(with-header)/mypage/reservations/components/CancelReservationModal.tsx @@ -19,7 +19,7 @@ export default function CancelReservationModal({ }: CancelReservationModalProps) { return ( !open && onCancel()}> - +