diff --git a/src/apis/courses.ts b/src/apis/courses.ts index c8a8767e..98343af7 100644 --- a/src/apis/courses.ts +++ b/src/apis/courses.ts @@ -1,4 +1,4 @@ -import { apiClient } from './common'; +import { apiClient, normalizeApiError } from './common'; export type CourseDetailItem = | { @@ -68,6 +68,14 @@ export async function getCourseDetail( return data; } +export async function deleteCourse(courseId: number): Promise { + try { + await apiClient.delete(`/courses/${courseId}`); + } catch (error) { + throw normalizeApiError(error); + } +} + // 코스·문화콘텐츠 좋아요 등록은 PUT이다(장소만 POST). 여러 번 눌러도 같은 // 결과가 되도록 백엔드가 바꿨다. export async function addCourseLike( diff --git a/src/components/common/CourseCard.tsx b/src/components/common/CourseCard.tsx index 9df4bba4..af2129f9 100644 --- a/src/components/common/CourseCard.tsx +++ b/src/components/common/CourseCard.tsx @@ -15,6 +15,7 @@ import people from '../../assets/icons/people.svg'; import { useScaleFrame } from '../../hooks/useScaleFrame'; +import ReviewActionMenu from './ReviewActionMenu'; import TagChip, { type TagType } from './TagChip'; function getCompanionIcon(label?: string | null) { @@ -67,8 +68,13 @@ export interface CourseCardProps { companion: string; tags: TagType[]; liked?: boolean; + /** 본인이 등록한 코스면 좋아요 대신 더보기(수정/삭제) 메뉴를 같은 자리에 띄운다. */ + isMine?: boolean; onClick?: () => void; onLikeClick?: () => void; + onEditClick?: () => void; + showEdit?: boolean; + onDeleteClick?: () => void; } function useVisibleItemCount(itemsKey: string, itemCount: number) { @@ -138,8 +144,12 @@ function CourseCard({ companion, tags, liked = false, + isMine = false, onClick, onLikeClick, + onEditClick, + showEdit, + onDeleteClick, }: CourseCardProps) { const { outerRef, innerRef, scale, scaledHeight } = useScaleFrame(CARD_DESIGN_WIDTH); @@ -148,7 +158,7 @@ function CourseCard({ { key: 'duration', icon: calendar, label: duration }, { key: 'courseType', icon: location, label: courseType }, { key: 'companion', icon: people, label: companion }, - ]; + ].filter((item) => Boolean(item.label)); const metaKey = metaItems.map((item) => item.label).join('|'); const tagsKey = tags.join('|'); @@ -309,23 +319,33 @@ function CourseCard({ - {/* Like */} - + ) : ( + + )} ); diff --git a/src/components/common/PromotionCard.tsx b/src/components/common/PromotionCard.tsx index feb7c481..ae5a0c13 100644 --- a/src/components/common/PromotionCard.tsx +++ b/src/components/common/PromotionCard.tsx @@ -4,8 +4,8 @@ import oheart from '../../assets/icons/oheart.svg'; import { useScaleFrame } from '../../hooks/useScaleFrame'; -// 모든 수치는 Figma 390 디자인 기준 리터럴 px. -// 개별 scale 계산 대신 useScaleFrame이 전체를 한 번에 scale한다. +import ReviewActionMenu from './ReviewActionMenu'; + const CARD_DESIGN_WIDTH = 342; const HEART_SIZE = 16; const HEART_TOP = 8; @@ -16,9 +16,6 @@ const PROFILE_GAP = 8; const HEADER_PADDING_X = 12; const HEADER_PADDING_TOP = 12; const HEADER_PADDING_BOTTOM = 8; -// 프로필 헤더(아바타+이름/날짜) 블록의 실제 렌더 높이. 좋아요 버튼이 -// 카드 전체 기준 좌표로 옮겨가면서, 이미지 상단이 아니라 이 높이만큼 -// 아래에서부터 HEART_TOP을 더해야 원래와 같은 위치(이미지 우상단)에 온다. const PROFILE_HEADER_HEIGHT = HEADER_PADDING_TOP + AVATAR_SIZE + HEADER_PADDING_BOTTOM; const NAME_SIZE = 14; @@ -46,8 +43,11 @@ export interface PromotionCardProps { title: string; description: string; location: string; + isMine?: boolean; liked?: boolean; onClick?: () => void; + onEditClick?: () => void; + onDeleteClick?: () => void; onLikeClick?: () => void; className?: string; } @@ -60,8 +60,11 @@ function PromotionCard({ title, description, location: locationText, + isMine = false, liked = false, onClick, + onEditClick, + onDeleteClick, onLikeClick, className = '', }: PromotionCardProps) { @@ -74,10 +77,6 @@ function PromotionCard({ className={`w-full overflow-hidden ${className}`} style={{ height: scaledHeight }} > - {/* 카드 이동(role="button")과 좋아요 버튼을 형제 컨트롤로 분리하기 - 위한 래퍼. transform/너비는 원래 카드 요소가 갖던 것을 그대로 - 옮겨왔고, useScaleFrame의 innerRef는 안쪽 카드 요소(w-full)에 - 둬서 offsetHeight 계산(콘텐츠 높이 그대로)에는 영향이 없다. */}
- {/* Profile header */}
- - -
-

- {profileName} -

- -

- {date} -

+
+ + +
+

+ {profileName} +

+

+ {date} +

+
+ + {isMine && (onEditClick || onDeleteClick) ? ( + + ) : null}
- {/* Image */} {title} - {/* Body */}
{title} -

{description}

-
-
- {onLikeClick && ( + {!isMine && onLikeClick ? ( - )} + ) : null}
); diff --git a/src/components/common/ReviewActionMenu.tsx b/src/components/common/ReviewActionMenu.tsx index a882e8b2..f2691be3 100644 --- a/src/components/common/ReviewActionMenu.tsx +++ b/src/components/common/ReviewActionMenu.tsx @@ -17,9 +17,12 @@ export interface ReviewActionMenuProps { * 코스 후기 전체보기)은 수정을 열지 않는다. */ onEditClick?: () => void; + showEdit?: boolean; onDeleteClick?: () => void; /** 카드마다 버튼이 놓이는 자리가 달라 트리거 배치는 밖에서 정한다. */ triggerClassName?: string; + /** 카드 종류마다 스크린리더 안내를 다르게 하려면 넘긴다. */ + ariaLabel?: string; } /** @@ -30,8 +33,10 @@ export interface ReviewActionMenuProps { */ function ReviewActionMenu({ onEditClick, + showEdit = false, onDeleteClick, triggerClassName = '-mt-[3px] -mr-[3px] ml-2', + ariaLabel = '리뷰 메뉴', }: ReviewActionMenuProps) { const scale = useGlobalScale(); const triggerId = useId(); @@ -95,11 +100,9 @@ function ReviewActionMenu({ updatePanelStyle(); window.addEventListener('resize', updatePanelStyle); - window.addEventListener('scroll', updatePanelStyle, true); return () => { window.removeEventListener('resize', updatePanelStyle); - window.removeEventListener('scroll', updatePanelStyle, true); }; }, [isOpen, scale]); @@ -112,9 +115,14 @@ function ReviewActionMenu({ // 붙이지만, 핸들러를 넘기지 않은 호출부에서 눌러도 아무 일 없는 항목이 // 남지 않게 한다. const menuItems = [ - { key: 'edit', label: '수정', action: onEditClick }, - { key: 'delete', label: '삭제', action: onDeleteClick }, - ].filter((item) => Boolean(item.action)); + { + key: 'edit', + label: '수정', + action: onEditClick, + show: showEdit || Boolean(onEditClick), + }, + { key: 'delete', label: '삭제', action: onDeleteClick, show: Boolean(onDeleteClick) }, + ].filter((item) => item.show); if (menuItems.length === 0) { return null; @@ -126,7 +134,7 @@ function ReviewActionMenu({ id={triggerId} ref={triggerRef} type="button" - aria-label="리뷰 메뉴" + aria-label={ariaLabel} aria-haspopup="menu" aria-expanded={isOpen} aria-controls={menuId} diff --git a/src/components/layout/AuthSidebar.tsx b/src/components/layout/AuthSidebar.tsx index 16fd6c0a..f038a3ea 100644 --- a/src/components/layout/AuthSidebar.tsx +++ b/src/components/layout/AuthSidebar.tsx @@ -37,7 +37,7 @@ const LOGOUT_GAP = 12; const MY_MENU: { label: string; path?: string }[] = [ { label: '여행기록', path: '/travel-record' }, { label: '좋아요', path: '/likes' }, - { label: '내가 등록한 게시물' }, + { label: '내가 등록한 게시물', path: '/my-posts' }, ]; interface AuthSidebarProps { diff --git a/src/constants/courseFilterLayout.ts b/src/constants/courseFilterLayout.ts index 7c422850..b00c3909 100644 --- a/src/constants/courseFilterLayout.ts +++ b/src/constants/courseFilterLayout.ts @@ -26,6 +26,21 @@ const LIKED_ITEM_FILTER_COLUMN_CLASS_NAMES: Record = { export const getLikedItemFilterColumnClassName = (filterKey: string) => LIKED_ITEM_FILTER_COLUMN_CLASS_NAMES[filterKey] ?? 'col-start-1'; +/** + * 내가 등록한 게시물 필터는 칩이 2개(분류/정렬)뿐이라, 좋아요 필터와 같은 + * auto + 1fr 스페이서로 정렬 칩만 오른쪽 끝에 붙인다. + */ +export const MY_POST_FILTER_GRID_CLASS_NAME = + 'grid w-[342px] origin-top-left grid-cols-[auto_1fr_auto] items-center gap-2 max-[389px]:[transform:scale(calc((100vw_-_48px)/342px))]'; + +const MY_POST_FILTER_COLUMN_CLASS_NAMES: Record = { + category: 'col-start-1', + sort: 'col-start-3 justify-self-end', +}; + +export const getMyPostFilterColumnClassName = (filterKey: string) => + MY_POST_FILTER_COLUMN_CLASS_NAMES[filterKey] ?? 'col-start-1'; + const COURSE_FILTER_COLUMN_CLASS_NAMES: Record = { transport: 'col-start-1', duration: 'col-start-3', diff --git a/src/hooks/useCourses.ts b/src/hooks/useCourses.ts index 86c0ac1b..0fd81d32 100644 --- a/src/hooks/useCourses.ts +++ b/src/hooks/useCourses.ts @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { type InfiniteData, useInfiniteQuery, + useMutation, useQueries, useQuery, useQueryClient, @@ -17,7 +18,7 @@ import { getPopularCourses, getRecommendedCourses, } from '../apis/courses.api'; -import { getCourseDetail } from '../apis/courses'; +import { deleteCourse, getCourseDetail } from '../apis/courses'; import type { CourseDetailResult } from '../apis/courses'; import type { NormalizedApiError } from '../apis/common'; import type { @@ -27,6 +28,7 @@ import type { GetPopularCoursesParams, RecommendedCourse, } from '../types/course.type'; +import type { GetMyPostsResponse } from '../types/user.type'; const DETAIL_STALE_TIME = 1000 * 60; const DETAIL_GC_TIME = 1000 * 60 * 5; @@ -84,6 +86,80 @@ export function useRecommendedCourses() { }); } +export function useCourseDelete() { + const [targetCourseId, setTargetCourseId] = useState(null); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + + const deleteCourseMutation = useMutation({ + mutationFn: deleteCourse, + onMutate: async (courseId) => { + await queryClient.cancelQueries({ queryKey: ['myPosts'] }); + + const previousMyPosts = queryClient.getQueriesData< + InfiniteData + >({ queryKey: ['myPosts'] }); + + queryClient.setQueriesData>( + { queryKey: ['myPosts'] }, + (data) => + data + ? { + ...data, + pages: data.pages.map((page) => ({ + ...page, + items: page.items.filter( + (item) => item.course?.id !== courseId, + ), + })), + } + : data, + ); + + return { previousMyPosts }; + }, + onError: (_, __, context) => { + context?.previousMyPosts.forEach(([queryKey, data]) => { + queryClient.setQueryData(queryKey, data); + }); + }, + onSuccess: (_, courseId) => { + void queryClient.invalidateQueries({ queryKey: ['courses'] }); + void queryClient.invalidateQueries({ queryKey: ['popularCourses'] }); + void queryClient.invalidateQueries({ queryKey: ['recommendedCourses'] }); + queryClient.removeQueries({ queryKey: ['courseDetail', courseId] }); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ['myPosts'] }); + }, + }); + + const closeDialog = () => setTargetCourseId(null); + + const confirmDelete = async () => { + if (targetCourseId === null || deleteCourseMutation.isPending) return; + + try { + await deleteCourseMutation.mutateAsync(targetCourseId); + closeDialog(); + showToast('코스를 삭제했어요.'); + } catch (error) { + closeDialog(); + showToast(getApiErrorMessage(error, '코스를 삭제하지 못했어요.')); + } + }; + + return { + requestDelete: setTargetCourseId, + dialogProps: { + isOpen: targetCourseId !== null, + isPending: deleteCourseMutation.isPending, + onCancel: closeDialog, + onConfirm: () => void confirmDelete(), + }, + }; +} + /** * 코스 상세. 여기도/동네 코스 모두 GET /courses/{courseId} 하나를 쓴다. * @@ -164,4 +240,3 @@ export function useNavigateToCourseDetail() { return { goToCourseDetail, isResolvingCourse }; } - diff --git a/src/hooks/useMyPosts.ts b/src/hooks/useMyPosts.ts new file mode 100644 index 00000000..02e48e64 --- /dev/null +++ b/src/hooks/useMyPosts.ts @@ -0,0 +1,49 @@ +import { type InfiniteData, useInfiniteQuery } from '@tanstack/react-query'; + +import { getMyPosts } from '../apis/users.api'; +import type { GetMyPostsResponse, MyPostCategory } from '../types/user.type'; + +const MY_POSTS_PAGE_SIZE = 10; + +interface MyPostsPageParam { + cursorCreatedAt?: string; + cursorId?: number; +} + +export function useMyPosts( + category: MyPostCategory, + sort: 'LATEST' | 'OLDEST', + keyword: string +) { + return useInfiniteQuery< + GetMyPostsResponse, + Error, + InfiniteData, + [string, MyPostCategory, 'LATEST' | 'OLDEST', string], + MyPostsPageParam + >({ + queryKey: ['myPosts', category, sort, keyword], + queryFn: ({ pageParam }) => + getMyPosts({ + category, + sort, + size: MY_POSTS_PAGE_SIZE, + keyword: keyword.trim() || undefined, + ...pageParam, + }), + initialPageParam: {}, + // cursorValue와 cursorId는 반드시 함께 보내야 한다(코스 후기 목록과 같은 규칙). + getNextPageParam: (lastPage) => + lastPage.hasNext && + lastPage.cursorId !== null && + lastPage.cursorValue !== null + ? { + cursorCreatedAt: String(lastPage.cursorValue), + cursorId: lastPage.cursorId, + } + : undefined, + }); +} + +export const getMyPostsFromPages = (pages: GetMyPostsResponse[] | undefined) => + pages?.flatMap((page) => page.items) ?? []; diff --git a/src/hooks/useReviews.ts b/src/hooks/useReviews.ts index b29c1566..8f600c7a 100644 --- a/src/hooks/useReviews.ts +++ b/src/hooks/useReviews.ts @@ -1,6 +1,7 @@ import { useState } from 'react'; import { type InfiniteData, + type QueryKey, useInfiniteQuery, useMutation, useQuery, @@ -30,6 +31,7 @@ import type { UpdateReviewRequest, UpdateReviewResponse, } from '../types/review.type'; +import type { GetMyPostsResponse } from '../types/user.type'; const REVIEW_NOT_FOUND_CODE = 'REVIEW4041'; const COURSE_NOT_FOUND_CODE = 'COURSE4041'; @@ -73,6 +75,10 @@ interface UpdateReviewParams { request: UpdateReviewRequest; } +interface MyPostsSnapshot { + previousMyPosts: [QueryKey, InfiniteData | undefined][]; +} + const REVIEWS_PAGE_SIZE = 10; /** @@ -243,6 +249,7 @@ export function useCreateCourseReview() { void queryClient.invalidateQueries({ queryKey: ['reviews'] }); void queryClient.invalidateQueries({ queryKey: ['recentReviews'] }); void queryClient.invalidateQueries({ queryKey: ['myReviewIds'] }); + void queryClient.invalidateQueries({ queryKey: ['myPosts'] }); }, }); } @@ -263,6 +270,7 @@ function useUpdateReview() { void queryClient.invalidateQueries({ queryKey: ['courseReviews'] }); void queryClient.invalidateQueries({ queryKey: ['reviews'] }); void queryClient.invalidateQueries({ queryKey: ['recentReviews'] }); + void queryClient.invalidateQueries({ queryKey: ['myPosts'] }); }, }); } @@ -270,7 +278,7 @@ function useUpdateReview() { function useDeleteReview() { const queryClient = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: async (reviewId) => { try { await deleteReview(reviewId); @@ -283,6 +291,36 @@ function useDeleteReview() { } } }, + onMutate: async (reviewId) => { + await queryClient.cancelQueries({ queryKey: ['myPosts'] }); + + const previousMyPosts = queryClient.getQueriesData< + InfiniteData + >({ queryKey: ['myPosts'] }); + + queryClient.setQueriesData>( + { queryKey: ['myPosts'] }, + (data) => + data + ? { + ...data, + pages: data.pages.map((page) => ({ + ...page, + items: page.items.filter( + (item) => item.review?.reviewId !== reviewId, + ), + })), + } + : data, + ); + + return { previousMyPosts }; + }, + onError: (_, __, context) => { + context?.previousMyPosts.forEach(([queryKey, data]) => { + queryClient.setQueryData(queryKey, data); + }); + }, onSuccess: (_, reviewId) => { // invalidateQueries의 재조회를 기다리는 동안 목록이 삭제된 리뷰를 // 그대로 한 번 더 그리는 구간이 생긴다. 캐시에서 먼저 걷어낸다. @@ -321,6 +359,9 @@ function useDeleteReview() { void queryClient.invalidateQueries({ queryKey: ['recentReviews'] }); void queryClient.invalidateQueries({ queryKey: ['myReviewIds'] }); }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ['myPosts'] }); + }, }); } diff --git a/src/pages/my-posts/constants/filters.ts b/src/pages/my-posts/constants/filters.ts new file mode 100644 index 00000000..8602b1e0 --- /dev/null +++ b/src/pages/my-posts/constants/filters.ts @@ -0,0 +1,29 @@ +import type { MyPostCategory } from '../../../types/user.type'; + +export const MY_POST_CATEGORY_OPTIONS = [ + '전체', + '코스', + '후기', +] as const; + +export const MY_POST_SORT_OPTIONS = ['최신순', '오래된 순'] as const; + +export type MyPostFilterKey = 'category' | 'sort'; + +export type MyPostSelectedFilters = Record; + +export const initialMyPostSelectedFilters: MyPostSelectedFilters = { + category: MY_POST_CATEGORY_OPTIONS[0], + sort: MY_POST_SORT_OPTIONS[0], +}; + +export const myPostCategoryByLabel: Record = { + 전체: 'ALL', + 코스: 'COURSE', + 후기: 'REVIEW', +}; + +export const myPostSortByLabel: Record = { + 최신순: 'LATEST', + '오래된 순': 'OLDEST', +}; diff --git a/src/pages/my-posts/hooks/useMyPostFilters.ts b/src/pages/my-posts/hooks/useMyPostFilters.ts new file mode 100644 index 00000000..ce20f05c --- /dev/null +++ b/src/pages/my-posts/hooks/useMyPostFilters.ts @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState } from 'react'; + +import { + initialMyPostSelectedFilters, + type MyPostFilterKey, + type MyPostSelectedFilters, +} from '../constants/filters'; + +function useMyPostFilters() { + const filterContainerRef = useRef(null); + const [openFilterKey, setOpenFilterKey] = useState( + null + ); + const [selectedFilters, setSelectedFilters] = useState( + initialMyPostSelectedFilters + ); + + useEffect(() => { + if (!openFilterKey) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + if (filterContainerRef.current?.contains(event.target as Node)) { + return; + } + + setOpenFilterKey(null); + }; + + document.addEventListener('pointerdown', handlePointerDown); + + return () => { + document.removeEventListener('pointerdown', handlePointerDown); + }; + }, [openFilterKey]); + + const handleFilterToggle = (filterKey: MyPostFilterKey) => { + setOpenFilterKey((currentFilterKey) => + currentFilterKey === filterKey ? null : filterKey + ); + }; + + const handleFilterSelect = (filterKey: MyPostFilterKey, option: string) => { + setSelectedFilters((currentFilters) => ({ + ...currentFilters, + [filterKey]: option, + })); + setOpenFilterKey(null); + }; + + return { + filterContainerRef, + openFilterKey, + selectedFilters, + handleFilterToggle, + handleFilterSelect, + }; +} + +export default useMyPostFilters; diff --git a/src/pages/my-posts/index.tsx b/src/pages/my-posts/index.tsx new file mode 100644 index 00000000..991cc55b --- /dev/null +++ b/src/pages/my-posts/index.tsx @@ -0,0 +1,344 @@ +import { useCallback, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { + CourseCard, + CourseCardSkeleton, + CourseFilterBar, + CourseReviewCard, + ConfirmDialog, + PromotionCard, + ReviewDeleteDialog, + ReviewDetailModal, + ReviewEditModal, + SearchBar, +} from '../../components/common'; +import { + getMyPostFilterColumnClassName, + MY_POST_FILTER_GRID_CLASS_NAME, +} from '../../constants/courseFilterLayout'; +import { useAuth } from '../../hooks/useAuth'; +import { useCourseDelete, useNavigateToCourseDetail } from '../../hooks/useCourses'; +import { useGlobalScale } from '../../hooks/useGlobalScale'; +import useInfiniteScroll from '../../hooks/useInfiniteScroll'; +import { getMyPostsFromPages, useMyPosts } from '../../hooks/useMyPosts'; +import { + useReviewDelete, + useReviewDetailModal, + useReviewEdit, +} from '../../hooks/useReviews'; +import { formatBusinessPromotionDate } from '../local-business/mappers/businessPromotionMapper'; +import { toContentTagIds } from '../../utils/contentTags'; +import { + toCompanionLabel, + toDurationLabel, + toTransportLabel, +} from '../../utils/courseEnumLabels'; +import { buildLocalBusinessDetailPath } from '../../utils/routes'; + +import { + MY_POST_CATEGORY_OPTIONS, + MY_POST_SORT_OPTIONS, + myPostCategoryByLabel, + myPostSortByLabel, + type MyPostFilterKey, +} from './constants/filters'; +import useMyPostFilters from './hooks/useMyPostFilters'; +import { toMyPostReviewCardProps } from './utils/myPostReviewCard'; + +const PAGE_PADDING_X = 24; +const PAGE_PADDING_TOP = 12; +const PAGE_PADDING_BOTTOM = 40; +const TITLE_SIZE = 18; +const TITLE_LINE_HEIGHT = 22; +const DESCRIPTION_MARGIN_TOP = 5; +const DESCRIPTION_SIZE = 12; +const DESCRIPTION_LINE_HEIGHT = 17; +const SEARCH_MARGIN_TOP = 16; +const FILTER_MARGIN_TOP = 10; +const LIST_MARGIN_TOP = 24; +const LIST_GAP = 16; +const EMPTY_MARGIN_TOP = 40; +const EMPTY_TEXT_SIZE = 13; +const RETRY_BUTTON_PADDING_X = 16; +const RETRY_BUTTON_PADDING_Y = 8; +const RETRY_BUTTON_TEXT_SIZE = 14; +const SKELETON_COUNT = 3; + +function MyPostsPage() { + const scale = useGlobalScale(); + const navigate = useNavigate(); + const { userId } = useAuth(); + const { goToCourseDetail } = useNavigateToCourseDetail(); + const [keyword, setKeyword] = useState(''); + + const { + filterContainerRef, + openFilterKey, + selectedFilters, + handleFilterToggle, + handleFilterSelect, + } = useMyPostFilters(); + + const category = myPostCategoryByLabel[selectedFilters.category]; + const sort = myPostSortByLabel[selectedFilters.sort]; + + const { + data, + isPending, + isError, + refetch, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useMyPosts(category, sort, keyword); + + const items = getMyPostsFromPages(data?.pages); + + const { requestDelete, dialogProps } = useReviewDelete(); + const { + requestDelete: requestCourseDelete, + dialogProps: courseDeleteDialogProps, + } = useCourseDelete(); + const { requestEdit, editorProps } = useReviewEdit(); + + const reviewsForModal = items.flatMap((item) => + item.review + ? [toMyPostReviewCardProps(item.review, item.course)] + : [] + ); + const { openedReview, openReview, closeReview } = + useReviewDetailModal(reviewsForModal); + + const handleIntersect = useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + const loadMoreRef = useInfiniteScroll({ + enabled: Boolean(hasNextPage) && !isFetchingNextPage, + onIntersect: handleIntersect, + }); + + const filterGroups = [ + { key: 'category', options: MY_POST_CATEGORY_OPTIONS }, + { key: 'sort', options: MY_POST_SORT_OPTIONS }, + ] as const satisfies readonly { + key: MyPostFilterKey; + options: readonly string[]; + }[]; + + const authorDisplayName = userId ? `회원 #${userId}` : '나'; + + const renderMessage = (message: string) => ( +

+ {message} +

+ ); + + return ( +
+
+

+ 등록한 코스와 후기 +

+

+ 직접 등록한 코스와 후기를 확인해 보세요 +

+
+ +
+ +
+ + + + {isPending ? ( +
+ {Array.from({ length: SKELETON_COUNT }).map((_, index) => ( + + ))} +
+ ) : isError ? ( +
+

+ 게시물을 불러오지 못했어요. +

+ +
+ ) : items.length === 0 ? ( + renderMessage('등록한 게시물이 없습니다.') + ) : ( +
+ {items.map((item) => { + if (item.review) { + const review = item.review; + const reviewCard = toMyPostReviewCardProps(review, item.course); + + return ( + void goToCourseDetail(reviewCard.courseId as number) + : undefined + } + onLongPress={() => openReview(reviewCard.id)} + onEditClick={() => + requestEdit({ + id: reviewCard.id, + content: reviewCard.content, + rating: reviewCard.rating, + editableImages: [], + }) + } + onDeleteClick={() => requestDelete(reviewCard.id)} + /> + ); + } + + if (item.course) { + const course = item.course; + + return ( + void goToCourseDetail(course.id)} + showEdit + onDeleteClick={() => requestCourseDelete(course.id)} + /> + ); + } + + if (item.promotion) { + const promotion = item.promotion; + + return ( + + navigate(buildLocalBusinessDetailPath(promotion.placeId)) + } + /> + ); + } + + return null; + })} + + + )} + + + + + +
+ ); +} + +export default MyPostsPage; diff --git a/src/pages/my-posts/utils/myPostReviewCard.ts b/src/pages/my-posts/utils/myPostReviewCard.ts new file mode 100644 index 00000000..e1ce6977 --- /dev/null +++ b/src/pages/my-posts/utils/myPostReviewCard.ts @@ -0,0 +1,39 @@ +import { + toCompanionLabel, + toDurationLabel, + toReviewerMetaLabel, + toTransportLabel, +} from '../../../utils/courseEnumLabels'; +import { toContentTagIds } from '../../../utils/contentTags'; + +import type { MyCourseSummary, MyReview } from '../../../types/user.type'; + +/** + * 내 게시물의 리뷰 항목을 CourseReviewCard props로 바꾼다. + * + * /recent-review-courses의 toReviewCourseCardProps(utils/reviewCard.ts)와 + * 같은 방식이다. course 정보가 아직 안 내려오는 리뷰는 빈 값으로 두고, + * CourseReviewCard가 값이 없는 항목을 알아서 건너뛴다. + */ +export function toMyPostReviewCardProps( + review: MyReview, + fallbackCourse?: MyCourseSummary, +) { + const course = review.course ?? fallbackCourse; + + return { + id: review.reviewId, + courseId: course?.id, + image: course?.thumbnailUrl ?? '', + title: course?.title ?? '', + duration: course ? toDurationLabel(course.durationType) : '', + courseType: course ? toTransportLabel(course.transportType) : '', + companion: course ? toCompanionLabel(course.companionType) : undefined, + tags: course ? toContentTagIds(course.hashtags) : undefined, + profileImage: review.reviewerProfileImage, + nickname: review.reviewerName, + meta: toReviewerMetaLabel(review.ageGroup, review.gender), + content: review.content, + rating: review.rating, + }; +} diff --git a/src/router/AppRouter.tsx b/src/router/AppRouter.tsx index 7e620bd3..84ae7426 100644 --- a/src/router/AppRouter.tsx +++ b/src/router/AppRouter.tsx @@ -62,6 +62,7 @@ import LocalCourseDetailPage from '../pages/detail/local-course'; import FestivalDetailPage from '../pages/detail/festival'; import RegionInfoPage from '../pages/region-info'; import LikesPage from '../pages/likes'; +import MyPostsPage from '../pages/my-posts'; import RecentReviewCoursesPage from '../pages/recent-review-courses'; import CourseReviewsPage from '../pages/course-reviews'; import ProfilePage from '../pages/profile'; @@ -160,6 +161,7 @@ function AppRouter() { } /> } /> } /> + } /> diff --git a/src/types/user.type.ts b/src/types/user.type.ts index 512ed8d7..0b94fb5d 100644 --- a/src/types/user.type.ts +++ b/src/types/user.type.ts @@ -1,7 +1,26 @@ // GET /users/me/posts 응답 중 프론트가 실제로 쓰는 부분만 정의한다. // enum은 다른 응답과 마찬가지로 string으로 받고 표시 시점에 매핑한다. -export type MyPostCategory = 'ALL' | 'COURSE' | 'REVIEW'; +export type MyPostCategory = 'ALL' | 'COURSE' | 'REVIEW' | 'PROMOTION'; + +/** + * 리뷰 카드에 코스 정보를 함께 그릴 때 쓰는 요약. + * GET /reviews의 ReviewCourseSummary(review.type.ts)와 같은 모양이다. + */ +export interface MyCourseSummary { + id: number; + title: string; + thumbnailUrl: string; + durationType: string; + transportType: string; + companionType: string; + hashtags: string[]; +} + +export interface MyCourse extends MyCourseSummary { + content: string; + createdAt: string; +} // 스웨거 기준: GET /users/me // role은 USER | ADMIN | BUSINESS지만 유니온으로 좁히지 않는다. @@ -24,10 +43,29 @@ export interface MyReview { rating: number; content: string; createdAt: string; + /** 백엔드가 코스 정보를 함께 내려줄 때만 채워진다(현재 응답 예시엔 없음). */ + course?: MyCourseSummary; +} + +export interface MyPromotion { + promotionId: number; + placeId: number; + placeName: string; + promotionCategory: string; + roadAddress: string; + thumbnailImageUrl: string; + shortDescription: string; + hashtags: string[]; + status: string; + likeCount: number; + createdAt: string; + updatedAt: string; } export interface MyPost { + course?: MyCourse; review?: MyReview; + promotion?: MyPromotion; } export interface GetMyPostsParams {