From c5234b4c40146b9e24a31e898aa428fd424361c1 Mon Sep 17 00:00:00 2001 From: mgYang53 <50770004+mgYang53@users.noreply.github.com> Date: Tue, 3 Feb 2026 23:09:11 +0900 Subject: [PATCH 01/19] =?UTF-8?q?refactor:=20develop=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=20=EA=B8=B0=EB=B0=98=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20=EC=A0=84=ED=99=98=20(#54)=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI: main, develop 브랜치 PR 시 실행 - CD: main 브랜치 머지 시에만 배포 --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4754f91..e9ddc27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: pull_request: - branches: [main, dev] + branches: [main, develop] concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fc8fee7..2e2b109 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,7 @@ name: Deploy Frontend on: pull_request: - branches: [main, dev] + branches: [main] types: [closed] jobs: From f920275550385af4006ed39674ca835560f64d06 Mon Sep 17 00:00:00 2001 From: mgYang53 <50770004+mgYang53@users.noreply.github.com> Date: Thu, 5 Feb 2026 00:08:07 +0900 Subject: [PATCH 02/19] =?UTF-8?q?feat:=20=EB=AA=A8=EC=9E=84=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#39)=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 모임 목록 페이지 구현 (#39) - 전체/즐겨찾기 탭 UI 및 카운트 배지 구현 - 커서 기반 무한 스크롤 (첫 페이지 12개, 이후 9개) - 즐겨찾기 토글 (optimistic update, 최대 4개 제한) - GatheringCard, EmptyState 컴포넌트 추가 - useInfiniteScroll 재사용 가능 훅 추출 - CursorPaginatedResponse 제네릭 타입 추가 * style: prettier 포맷 적용 (#39) * fix: 코드 리뷰 지적사항 수정 (#39) - GatheringCard 키보드 접근성 개선 (role, tabIndex, onKeyDown) - TypeScript 타입 에러 수정 (MouseEvent import) - 즐겨찾기 탭 로딩 상태 처리 추가 - useInfiniteScroll SSR/테스트 환경 가드 추가 * style: eslint에서 언더스코어 변수명 허용 설정 추가 (#39) --- eslint.config.js | 7 + src/api/index.ts | 7 +- src/api/types.ts | 31 ++++ .../gatherings/components/EmptyState.tsx | 25 +++ .../gatherings/components/GatheringCard.tsx | 84 +++++++++ src/features/gatherings/components/index.ts | 2 + src/features/gatherings/gatherings.api.ts | 46 +++++ .../gatherings/gatherings.endpoints.ts | 4 + src/features/gatherings/gatherings.types.ts | 53 ++++++ .../gatherings/hooks/gatheringQueryKeys.ts | 1 + src/features/gatherings/hooks/index.ts | 3 + .../gatherings/hooks/useFavoriteGatherings.ts | 20 +++ .../gatherings/hooks/useGatherings.ts | 53 ++++++ .../gatherings/hooks/useToggleFavorite.ts | 87 ++++++++++ src/features/gatherings/index.ts | 10 ++ .../meetings/hooks/useCancelJoinMeeting.ts | 3 +- src/features/meetings/hooks/useJoinMeeting.ts | 3 +- src/pages/Gatherings/GatheringListPage.tsx | 160 +++++++++++++++++- src/shared/hooks/index.ts | 1 + src/shared/hooks/useInfiniteScroll.ts | 85 ++++++++++ 20 files changed, 679 insertions(+), 6 deletions(-) create mode 100644 src/features/gatherings/components/EmptyState.tsx create mode 100644 src/features/gatherings/components/GatheringCard.tsx create mode 100644 src/features/gatherings/components/index.ts create mode 100644 src/features/gatherings/hooks/useFavoriteGatherings.ts create mode 100644 src/features/gatherings/hooks/useGatherings.ts create mode 100644 src/features/gatherings/hooks/useToggleFavorite.ts create mode 100644 src/shared/hooks/useInfiniteScroll.ts diff --git a/eslint.config.js b/eslint.config.js index d6b3e63..375b80e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -42,6 +42,13 @@ export default defineConfig([ { selector: 'variable', format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + leadingUnderscore: 'allow', + }, + // 매개변수: camelCase, 언더스코어로 시작 가능 (unused params) + { + selector: 'parameter', + format: ['camelCase'], + leadingUnderscore: 'allow', }, // 함수: camelCase, PascalCase (컴포넌트) { diff --git a/src/api/index.ts b/src/api/index.ts index 28a1467..12ab87a 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,4 +3,9 @@ export { API_BASE, API_PATHS } from './endpoints' export type { ErrorCodeType } from './errors' export { ApiError, ErrorCode, ErrorMessage } from './errors' export { setupInterceptors } from './interceptors' -export type { ApiErrorResponse, ApiResponse, PaginatedResponse } from './types' +export type { + ApiErrorResponse, + ApiResponse, + CursorPaginatedResponse, + PaginatedResponse, +} from './types' diff --git a/src/api/types.ts b/src/api/types.ts index 4620621..4a55394 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -78,3 +78,34 @@ export type PaginatedResponse = { /** 전체 페이지 수 */ totalPages: number } + +/** + * 커서 기반 페이지네이션 응답 타입 + * + * @template T - 아이템의 타입 + * @template C - 커서의 타입 + * + * @example + * ```typescript + * // 커서 기반 페이지네이션 응답 예시 + * { + * "items": [{ "id": 1, "name": "모임1" }, { "id": 2, "name": "모임2" }], + * "pageSize": 10, + * "hasNext": true, + * "nextCursor": { "joinedAt": "2024-01-01T00:00:00", "id": 2 }, + * "totalCount": 50 + * } + * ``` + */ +export type CursorPaginatedResponse = { + /** 현재 페이지의 아이템 배열 */ + items: T[] + /** 페이지 크기 (한 페이지당 아이템 수) */ + pageSize: number + /** 다음 페이지 존재 여부 */ + hasNext: boolean + /** 다음 페이지 커서 (없으면 null) */ + nextCursor: C | null + /** 전체 아이템 수 (첫 페이지 응답에만 포함될 수 있음) */ + totalCount?: number +} diff --git a/src/features/gatherings/components/EmptyState.tsx b/src/features/gatherings/components/EmptyState.tsx new file mode 100644 index 0000000..248dd7b --- /dev/null +++ b/src/features/gatherings/components/EmptyState.tsx @@ -0,0 +1,25 @@ +interface EmptyStateProps { + type?: 'all' | 'favorites' +} + +export default function EmptyState({ type = 'all' }: EmptyStateProps) { + const message = + type === 'all' ? ( + <> + 아직 참여 중인 모임이 없어요. +
첫 번째 모임을 시작해 보세요! + + ) : ( + <> + 즐겨찾기한 모임이 없어요. +
+ 자주 방문하는 모임을 즐겨찾기에 추가해 보세요! + + ) + + return ( +
+

{message}

+
+ ) +} diff --git a/src/features/gatherings/components/GatheringCard.tsx b/src/features/gatherings/components/GatheringCard.tsx new file mode 100644 index 0000000..d0494cd --- /dev/null +++ b/src/features/gatherings/components/GatheringCard.tsx @@ -0,0 +1,84 @@ +import { Star } from 'lucide-react' +import type { MouseEvent } from 'react' + +import { cn } from '@/shared/lib/utils' + +import type { GatheringListItem } from '../gatherings.types' + +interface GatheringCardProps { + gathering: GatheringListItem + onFavoriteToggle: (gatheringId: number) => void + onClick: () => void +} + +export default function GatheringCard({ + gathering, + onFavoriteToggle, + onClick, +}: GatheringCardProps) { + const { + gatheringId, + gatheringName, + isFavorite, + totalMembers, + totalMeetings, + currentUserRole, + daysFromJoined, + } = gathering + + const isLeader = currentUserRole === 'LEADER' + + const handleFavoriteClick = (e: MouseEvent) => { + e.stopPropagation() + onFavoriteToggle(gatheringId) + } + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClick() + } + }} + > + {/* 상단 영역: 배지 + 모임 이름 */} +
+ {isLeader && ( + + 모임장 + + )} +

{gatheringName}

+
+ + {/* 하단 영역: 메타 정보 */} +
+ 멤버 {totalMembers}명 + + 시작한지 {daysFromJoined}일 + + 약속 {totalMeetings}회 +
+ + {/* 즐겨찾기 버튼 */} + +
+ ) +} diff --git a/src/features/gatherings/components/index.ts b/src/features/gatherings/components/index.ts new file mode 100644 index 0000000..4ea95ab --- /dev/null +++ b/src/features/gatherings/components/index.ts @@ -0,0 +1,2 @@ +export { default as EmptyState } from './EmptyState' +export { default as GatheringCard } from './GatheringCard' diff --git a/src/features/gatherings/gatherings.api.ts b/src/features/gatherings/gatherings.api.ts index 7c40713..06a6c0a 100644 --- a/src/features/gatherings/gatherings.api.ts +++ b/src/features/gatherings/gatherings.api.ts @@ -4,8 +4,11 @@ import { GATHERINGS_ENDPOINTS } from './gatherings.endpoints' import type { CreateGatheringRequest, CreateGatheringResponse, + FavoriteGatheringListResponse, GatheringByInviteCodeResponse, GatheringJoinResponse, + GatheringListResponse, + GetGatheringsParams, } from './gatherings.types' /** @@ -49,3 +52,46 @@ export const joinGathering = async (invitationCode: string) => { ) return response.data } + +/** + * 내 모임 전체 목록 조회 (커서 기반 무한 스크롤) + * + * @param params - 조회 파라미터 + * @param params.pageSize - 페이지 크기 (기본: 9) + * @param params.cursorJoinedAt - 마지막 항목의 가입일시 (ISO 8601) + * @param params.cursorId - 마지막 항목의 ID + * @returns 모임 목록 및 페이지네이션 정보 + */ +export const getGatherings = async (params?: GetGatheringsParams) => { + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.BASE, + { + params, + } + ) + return response.data +} + +/** + * 즐겨찾기 모임 목록 조회 + * + * @returns 즐겨찾기 모임 목록 (최대 4개) + */ +export const getFavoriteGatherings = async () => { + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.FAVORITES + ) + return response.data +} + +/** + * 모임 즐겨찾기 토글 + * + * @param gatheringId - 모임 ID + */ +export const toggleFavorite = async (gatheringId: number) => { + const response = await apiClient.patch>( + GATHERINGS_ENDPOINTS.TOGGLE_FAVORITE(gatheringId) + ) + return response.data +} diff --git a/src/features/gatherings/gatherings.endpoints.ts b/src/features/gatherings/gatherings.endpoints.ts index 6f82784..d491a03 100644 --- a/src/features/gatherings/gatherings.endpoints.ts +++ b/src/features/gatherings/gatherings.endpoints.ts @@ -3,6 +3,10 @@ import { API_PATHS } from '@/api' export const GATHERINGS_ENDPOINTS = { /** 모임 목록/생성 */ BASE: API_PATHS.GATHERINGS, + /** 즐겨찾기 모임 목록 조회 */ + FAVORITES: `${API_PATHS.GATHERINGS}/favorites`, + /** 즐겨찾기 토글 */ + TOGGLE_FAVORITE: (gatheringId: number) => `${API_PATHS.GATHERINGS}/${gatheringId}/favorites`, /** 초대 코드로 모임 정보 조회 / 가입 신청 */ JOIN_REQUEST: (invitationCode: string) => `${API_PATHS.GATHERINGS}/join-request/${invitationCode}`, diff --git a/src/features/gatherings/gatherings.types.ts b/src/features/gatherings/gatherings.types.ts index 0fddb93..452d0ec 100644 --- a/src/features/gatherings/gatherings.types.ts +++ b/src/features/gatherings/gatherings.types.ts @@ -1,3 +1,5 @@ +import type { CursorPaginatedResponse } from '@/api' + /** 모임 기본 정보 (공통) */ export interface GatheringBase { /** 모임 이름 */ @@ -43,3 +45,54 @@ export interface GatheringJoinResponse { /** 가입 상태 */ memberStatus: GatheringMemberStatus } + +/** 모임 상태 */ +export type GatheringStatus = 'ACTIVE' | 'INACTIVE' + +/** 모임 내 사용자 역할 */ +export type GatheringUserRole = 'LEADER' | 'MEMBER' + +/** 모임 목록 아이템 */ +export interface GatheringListItem { + /** 모임 ID */ + gatheringId: number + /** 모임 이름 */ + gatheringName: string + /** 즐겨찾기 여부 */ + isFavorite: boolean + /** 모임 상태 */ + gatheringStatus: GatheringStatus + /** 전체 멤버 수 */ + totalMembers: number + /** 전체 약속 수 */ + totalMeetings: number + /** 현재 사용자 역할 */ + currentUserRole: GatheringUserRole + /** 가입 후 경과 일수 */ + daysFromJoined: number +} + +/** 커서 정보 (서버 응답) */ +export interface GatheringCursor { + joinedAt: string + gatheringMemberId: number +} + +/** 모임 목록 응답 (커서 기반 페이지네이션) */ +export type GatheringListResponse = CursorPaginatedResponse + +/** 즐겨찾기 모임 목록 응답 */ +export interface FavoriteGatheringListResponse { + /** 즐겨찾기 모임 목록 */ + gatherings: GatheringListItem[] +} + +/** 모임 목록 조회 파라미터 */ +export interface GetGatheringsParams { + /** 페이지 크기 (기본: 9) */ + pageSize?: number + /** 커서 - 마지막 항목의 가입일시 (ISO 8601) */ + cursorJoinedAt?: string + /** 커서 - 마지막 항목의 ID */ + cursorId?: number +} diff --git a/src/features/gatherings/hooks/gatheringQueryKeys.ts b/src/features/gatherings/hooks/gatheringQueryKeys.ts index 5110181..c3100b9 100644 --- a/src/features/gatherings/hooks/gatheringQueryKeys.ts +++ b/src/features/gatherings/hooks/gatheringQueryKeys.ts @@ -4,6 +4,7 @@ export const gatheringQueryKeys = { all: ['gatherings'] as const, lists: () => [...gatheringQueryKeys.all, 'list'] as const, + favorites: () => [...gatheringQueryKeys.all, 'favorites'] as const, detail: (id: number | string) => [...gatheringQueryKeys.all, 'detail', id] as const, byInviteCode: (invitationCode: string) => [...gatheringQueryKeys.all, 'invite', invitationCode] as const, diff --git a/src/features/gatherings/hooks/index.ts b/src/features/gatherings/hooks/index.ts index 49ddaab..11c588f 100644 --- a/src/features/gatherings/hooks/index.ts +++ b/src/features/gatherings/hooks/index.ts @@ -1,4 +1,7 @@ export * from './gatheringQueryKeys' export * from './useCreateGathering' +export * from './useFavoriteGatherings' export * from './useGatheringByInviteCode' +export * from './useGatherings' export * from './useJoinGathering' +export * from './useToggleFavorite' diff --git a/src/features/gatherings/hooks/useFavoriteGatherings.ts b/src/features/gatherings/hooks/useFavoriteGatherings.ts new file mode 100644 index 0000000..52a9c6c --- /dev/null +++ b/src/features/gatherings/hooks/useFavoriteGatherings.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query' + +import type { ApiError } from '@/api' + +import { getFavoriteGatherings } from '../gatherings.api' +import type { FavoriteGatheringListResponse } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** + * 즐겨찾기 모임 목록 조회 훅 + */ +export const useFavoriteGatherings = () => { + return useQuery({ + queryKey: gatheringQueryKeys.favorites(), + queryFn: async () => { + const response = await getFavoriteGatherings() + return response.data + }, + }) +} diff --git a/src/features/gatherings/hooks/useGatherings.ts b/src/features/gatherings/hooks/useGatherings.ts new file mode 100644 index 0000000..4489127 --- /dev/null +++ b/src/features/gatherings/hooks/useGatherings.ts @@ -0,0 +1,53 @@ +import { type InfiniteData, useInfiniteQuery } from '@tanstack/react-query' + +import { getGatherings } from '../gatherings.api' +import type { GatheringCursor, GatheringListResponse } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** 초기 로드 개수 (3열 × 4행) */ +const INITIAL_PAGE_SIZE = 12 +/** 추가 로드 개수 (3열 × 3행) */ +const NEXT_PAGE_SIZE = 9 + +/** 페이지 파라미터 타입: undefined = 첫 페이지, GatheringCursor = 다음 페이지 */ +type PageParam = GatheringCursor | undefined + +/** + * 내 모임 전체 목록 조회 훅 (무한 스크롤) + * + * @example + * ```tsx + * const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useGatherings() + * + * // 모임 목록 접근 + * const gatherings = data?.pages.flatMap(page => page.items) ?? [] + * + * // 다음 페이지 로드 + * if (hasNextPage) fetchNextPage() + * ``` + */ +export const useGatherings = () => { + return useInfiniteQuery< + GatheringListResponse, + Error, + InfiniteData, + readonly string[], + PageParam + >({ + queryKey: gatheringQueryKeys.lists(), + queryFn: async ({ pageParam }) => { + const isFirstPage = !pageParam + const response = await getGatherings({ + pageSize: isFirstPage ? INITIAL_PAGE_SIZE : NEXT_PAGE_SIZE, + cursorJoinedAt: pageParam?.joinedAt, + cursorId: pageParam?.gatheringMemberId, + }) + return response.data + }, + initialPageParam: undefined, + getNextPageParam: (lastPage): PageParam => { + if (!lastPage.hasNext || !lastPage.nextCursor) return undefined + return lastPage.nextCursor + }, + }) +} diff --git a/src/features/gatherings/hooks/useToggleFavorite.ts b/src/features/gatherings/hooks/useToggleFavorite.ts new file mode 100644 index 0000000..719121b --- /dev/null +++ b/src/features/gatherings/hooks/useToggleFavorite.ts @@ -0,0 +1,87 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import type { ApiError } from '@/api' + +import { toggleFavorite } from '../gatherings.api' +import type { FavoriteGatheringListResponse, GatheringListResponse } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** + * 모임 즐겨찾기 토글 훅 + * + * - Optimistic update 적용 + * - 실패 시 롤백 + */ +interface ToggleFavoriteContext { + previousLists: unknown + previousFavorites: unknown +} + +export const useToggleFavorite = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (gatheringId: number) => { + await toggleFavorite(gatheringId) + }, + onMutate: async (gatheringId) => { + // 진행 중인 쿼리 취소 + await queryClient.cancelQueries({ queryKey: gatheringQueryKeys.lists() }) + await queryClient.cancelQueries({ queryKey: gatheringQueryKeys.favorites() }) + + // 이전 데이터 스냅샷 + const previousLists = queryClient.getQueryData(gatheringQueryKeys.lists()) + const previousFavorites = queryClient.getQueryData(gatheringQueryKeys.favorites()) + + // Optimistic update - 목록에서 isFavorite 토글 + queryClient.setQueryData<{ pages: GatheringListResponse[]; pageParams: unknown[] }>( + gatheringQueryKeys.lists(), + (old) => { + if (!old) return old + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((item) => + item.gatheringId === gatheringId ? { ...item, isFavorite: !item.isFavorite } : item + ), + })), + } + } + ) + + // Optimistic update - 즐겨찾기 목록 + queryClient.setQueryData( + gatheringQueryKeys.favorites(), + (old) => { + if (!old) return old + const existing = old.gatherings.find((g) => g.gatheringId === gatheringId) + if (existing) { + // 즐겨찾기에서 제거 + return { + ...old, + gatherings: old.gatherings.filter((g) => g.gatheringId !== gatheringId), + } + } + return old + } + ) + + return { previousLists, previousFavorites } + }, + onError: (error, id, context) => { + console.error('Failed to toggle favorite:', { error, gatheringId: id }) + // 에러 시 롤백 + if (context?.previousLists) { + queryClient.setQueryData(gatheringQueryKeys.lists(), context.previousLists) + } + if (context?.previousFavorites) { + queryClient.setQueryData(gatheringQueryKeys.favorites(), context.previousFavorites) + } + }, + onSettled: () => { + // 즐겨찾기 목록만 최신 데이터로 갱신 (전체 목록은 optimistic update로 충분) + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.favorites() }) + }, + }) +} diff --git a/src/features/gatherings/index.ts b/src/features/gatherings/index.ts index a356069..368e484 100644 --- a/src/features/gatherings/index.ts +++ b/src/features/gatherings/index.ts @@ -1,6 +1,9 @@ // Hooks export * from './hooks' +// Components +export * from './components' + // API export * from './gatherings.api' @@ -8,8 +11,15 @@ export * from './gatherings.api' export type { CreateGatheringRequest, CreateGatheringResponse, + FavoriteGatheringListResponse, GatheringBase, GatheringByInviteCodeResponse, + GatheringCursor, GatheringJoinResponse, + GatheringListItem, + GatheringListResponse, GatheringMemberStatus, + GatheringStatus, + GatheringUserRole, + GetGatheringsParams, } from './gatherings.types' diff --git a/src/features/meetings/hooks/useCancelJoinMeeting.ts b/src/features/meetings/hooks/useCancelJoinMeeting.ts index 3d80a28..d424b74 100644 --- a/src/features/meetings/hooks/useCancelJoinMeeting.ts +++ b/src/features/meetings/hooks/useCancelJoinMeeting.ts @@ -24,8 +24,7 @@ export const useCancelJoinMeeting = () => { return useMutation, ApiError, number>({ mutationFn: (meetingId: number) => cancelJoinMeeting(meetingId), - onSuccess: (data, variables) => { - void data // 사용하지 않는 파라미터 + onSuccess: (_data, variables) => { // 약속 상세 캐시 무효화 queryClient.invalidateQueries({ queryKey: meetingQueryKeys.detail(variables), diff --git a/src/features/meetings/hooks/useJoinMeeting.ts b/src/features/meetings/hooks/useJoinMeeting.ts index 34aa980..ea63b82 100644 --- a/src/features/meetings/hooks/useJoinMeeting.ts +++ b/src/features/meetings/hooks/useJoinMeeting.ts @@ -24,8 +24,7 @@ export const useJoinMeeting = () => { return useMutation, ApiError, number>({ mutationFn: (meetingId: number) => joinMeeting(meetingId), - onSuccess: (data, variables) => { - void data // 사용하지 않는 파라미터 + onSuccess: (_data, variables) => { // 약속 상세 캐시 무효화 queryClient.invalidateQueries({ queryKey: meetingQueryKeys.detail(variables), diff --git a/src/pages/Gatherings/GatheringListPage.tsx b/src/pages/Gatherings/GatheringListPage.tsx index 69516fe..ecbef2d 100644 --- a/src/pages/Gatherings/GatheringListPage.tsx +++ b/src/pages/Gatherings/GatheringListPage.tsx @@ -1,3 +1,161 @@ +import { useCallback, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { type ApiError, ErrorCode } from '@/api' +import { + EmptyState, + GatheringCard, + useFavoriteGatherings, + useGatherings, + useToggleFavorite, +} from '@/features/gatherings' +import { ROUTES } from '@/shared/constants' +import { useInfiniteScroll } from '@/shared/hooks' +import { Button, Tabs, TabsList, TabsTrigger } from '@/shared/ui' +import { useGlobalModalStore } from '@/store' + +type TabValue = 'all' | 'favorites' + export default function GatheringListPage() { - return
Gathering List Page
+ const navigate = useNavigate() + const { openAlert } = useGlobalModalStore() + const [activeTab, setActiveTab] = useState('all') + + // 전체 모임 목록 (무한 스크롤) + const { + data: gatheringsData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + } = useGatherings() + + // 즐겨찾기 모임 목록 + const { data: favoritesData, isLoading: isFavoritesLoading } = useFavoriteGatherings() + + // 즐겨찾기 토글 + const { mutate: toggleFavorite } = useToggleFavorite() + + // 모임 목록 평탄화 + const gatherings = gatheringsData?.pages.flatMap((page) => page.items) ?? [] + const favorites = favoritesData?.gatherings ?? [] + + // 전체 개수 (첫 페이지 응답의 totalCount 사용) + const totalCount = gatheringsData?.pages[0]?.totalCount ?? 0 + const favoritesCount = favorites.length + + // 무한 스크롤 + const observerRef = useInfiniteScroll(fetchNextPage, { + hasNextPage, + isFetchingNextPage, + isLoading, + enabled: activeTab === 'all', + }) + + // 즐겨찾기 토글 핸들러 + const handleFavoriteToggle = useCallback( + (gatheringId: number) => { + toggleFavorite(gatheringId, { + onError: (error: ApiError) => { + if (error.is(ErrorCode.FAVORITE_LIMIT_EXCEEDED)) { + openAlert('알림', '즐겨찾기는 최대 4개까지만 등록할 수 있습니다.') + } else { + openAlert('오류', '즐겨찾기 변경에 실패했습니다.') + } + }, + }) + }, + [toggleFavorite, openAlert] + ) + + // 카드 클릭 핸들러 + const handleCardClick = useCallback( + (gatheringId: number) => { + navigate(ROUTES.GATHERING_DETAIL(gatheringId)) + }, + [navigate] + ) + + // 모임 만들기 버튼 클릭 + const handleCreateClick = () => { + navigate(ROUTES.GATHERING_CREATE) + } + + return ( +
+ {/* 타이틀 */} +

독서모임

+ + {/* 탭 + 버튼 영역 */} +
+ setActiveTab(value as TabValue)}> + + + 전체 + + + 즐겨찾기 + + + + +
+ + {/* 컨텐츠 영역 */} + {activeTab === 'all' && ( + <> + {isLoading ? ( +
+

로딩 중...

+
+ ) : gatherings.length === 0 ? ( + + ) : ( + <> +
+ {gatherings.map((gathering) => ( + handleCardClick(gathering.gatheringId)} + /> + ))} +
+ {/* 무한 스크롤 트리거 - 그리드 아래에 위치 */} + {hasNextPage &&
} + + )} + {isFetchingNextPage && ( +
+

로딩 중...

+
+ )} + + )} + + {activeTab === 'favorites' && ( + <> + {isFavoritesLoading ? ( +
+

로딩 중...

+
+ ) : favorites.length === 0 ? ( + + ) : ( +
+ {favorites.map((gathering) => ( + handleCardClick(gathering.gatheringId)} + /> + ))} +
+ )} + + )} +
+ ) } diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 2884370..53ac97b 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1 +1,2 @@ export * from './useDebounce' +export * from './useInfiniteScroll' diff --git a/src/shared/hooks/useInfiniteScroll.ts b/src/shared/hooks/useInfiniteScroll.ts new file mode 100644 index 0000000..f0dae3a --- /dev/null +++ b/src/shared/hooks/useInfiniteScroll.ts @@ -0,0 +1,85 @@ +import { useEffect, useRef } from 'react' + +interface UseInfiniteScrollOptions { + /** 다음 페이지가 있는지 여부 */ + hasNextPage: boolean | undefined + /** 다음 페이지를 가져오는 중인지 */ + isFetchingNextPage: boolean + /** 초기 로딩 중인지 */ + isLoading?: boolean + /** 활성화 여부 (탭 전환 등에서 사용) */ + enabled?: boolean + /** Intersection Observer threshold (기본: 0.1) */ + threshold?: number + /** Intersection Observer rootMargin (기본: '200px') */ + rootMargin?: string +} + +/** + * 무한 스크롤을 위한 Intersection Observer 훅 + * + * @param fetchNextPage - 다음 페이지를 가져오는 함수 + * @param options - 옵션 + * @returns observerRef - 감지할 요소에 연결할 ref + * + * @example + * ```tsx + * const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery(...) + * + * const observerRef = useInfiniteScroll(fetchNextPage, { + * hasNextPage, + * isFetchingNextPage, + * isLoading, + * enabled: activeTab === 'all', + * }) + * + * return ( + * <> + *
{items.map(...)}
+ * {hasNextPage &&
} + * + * ) + * ``` + */ +export const useInfiniteScroll = (fetchNextPage: () => void, options: UseInfiniteScrollOptions) => { + const { + hasNextPage, + isFetchingNextPage, + isLoading = false, + enabled = true, + threshold = 0.1, + rootMargin = '200px', + } = options + + const observerRef = useRef(null) + + useEffect(() => { + // 비브라우저 환경 또는 IntersectionObserver 미지원 환경 체크 + if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') return + + // 비활성화 상태이거나 로딩 중이거나 다음 페이지가 없으면 옵저버 설정 안함 + if (!observerRef.current || !enabled || isLoading || !hasNextPage) return + + const observer = new IntersectionObserver( + (entries) => { + if (!entries[0].isIntersecting || !hasNextPage || isFetchingNextPage) return + + // 페이지에 스크롤이 있는지 확인 + const hasScroll = document.documentElement.scrollHeight > window.innerHeight + // 스크롤이 없으면 바로 로드, 스크롤이 있으면 스크롤이 발생한 경우에만 로드 + const shouldFetch = !hasScroll || window.scrollY > 0 + + if (shouldFetch) { + fetchNextPage() + } + }, + { threshold, rootMargin } + ) + + observer.observe(observerRef.current) + + return () => observer.disconnect() + }, [enabled, hasNextPage, isFetchingNextPage, isLoading, fetchNextPage, threshold, rootMargin]) + + return observerRef +} From bd8247832d89e63016938cc8706f737b0d1775d0 Mon Sep 17 00:00:00 2001 From: haeun <110523397+haruyam15@users.noreply.github.com> Date: Sat, 7 Feb 2026 21:52:46 +0900 Subject: [PATCH 03/19] =?UTF-8?q?[feat]=20=EC=A3=BC=EC=A0=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20UI=20=EB=B0=8F=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 주제 목록 UI 및 기능 구현 (#46) - 확정된 주제와 제안된 주제를 분리하여 표시하는 목록 컴포넌트 추가 - 무한 스크롤 및 가상화(@tanstack/react-virtual)를 통한 성능 최적화 - 주제 좋아요, 삭제 기능 구현 - 빈 상태 및 로딩 스켈레톤 UI 추가 - TanStack Query를 활용한 서버 상태 관리 * style: 이중언더스코어 제거(#46) * design: EmptyTopickList 디자인수정 (#46) * style: 린트, 프리티어 적용 (#46) * refactor: 주제 목록 가상화 제거 및 낙관적 업데이트 개선 (#46) TanStack Virtual을 사용한 가상화 기능을 제거하고 일반 리스트 렌더링으로 변경. 무한 스크롤 훅은 shared/hooks로 이동하여 재사용성 향상. 좋아요 낙관적 업데이트 로직을 개선하여 모든 관련 쿼리에 일관되게 적용. Query Key 관리를 topicQueryKeys로 통일하여 유지보수성 향상. * chore: 카카오지도 환경변수 추가(#46) --- .github/workflows/deploy.yml | 1 + .../topics/components/ConfirmedTopicList.tsx | 56 ++ .../topics/components/DefaultTopicCard.tsx | 18 + .../topics/components/EmptyTopicList.tsx | 9 + .../topics/components/ProposedTopicList.tsx | 67 +++ src/features/topics/components/TopicCard.tsx | 111 ++++ .../topics/components/TopicHeader.tsx | 103 ++++ .../topics/components/TopicListSkeleton.tsx | 27 + src/features/topics/components/index.ts | 7 + src/features/topics/hooks/index.ts | 5 + src/features/topics/hooks/topicQueryKeys.ts | 28 + .../topics/hooks/useConfirmedTopics.ts | 67 +++ src/features/topics/hooks/useDeleteTopic.ts | 52 ++ src/features/topics/hooks/useLikeTopic.ts | 111 ++++ .../topics/hooks/useProposedTopics.ts | 67 +++ src/features/topics/index.ts | 16 + src/features/topics/topics.api.ts | 178 +++++++ src/features/topics/topics.endpoints.ts | 19 + src/features/topics/topics.mock.ts | 491 ++++++++++++++++++ src/features/topics/topics.types.ts | 200 +++++++ src/pages/Meetings/MeetingDetailPage.tsx | 137 ++++- src/shared/constants/pagination.ts | 2 + src/shared/ui/LikeButton.tsx | 17 +- 23 files changed, 1780 insertions(+), 9 deletions(-) create mode 100644 src/features/topics/components/ConfirmedTopicList.tsx create mode 100644 src/features/topics/components/DefaultTopicCard.tsx create mode 100644 src/features/topics/components/EmptyTopicList.tsx create mode 100644 src/features/topics/components/ProposedTopicList.tsx create mode 100644 src/features/topics/components/TopicCard.tsx create mode 100644 src/features/topics/components/TopicHeader.tsx create mode 100644 src/features/topics/components/TopicListSkeleton.tsx create mode 100644 src/features/topics/components/index.ts create mode 100644 src/features/topics/hooks/index.ts create mode 100644 src/features/topics/hooks/topicQueryKeys.ts create mode 100644 src/features/topics/hooks/useConfirmedTopics.ts create mode 100644 src/features/topics/hooks/useDeleteTopic.ts create mode 100644 src/features/topics/hooks/useLikeTopic.ts create mode 100644 src/features/topics/hooks/useProposedTopics.ts create mode 100644 src/features/topics/index.ts create mode 100644 src/features/topics/topics.api.ts create mode 100644 src/features/topics/topics.endpoints.ts create mode 100644 src/features/topics/topics.mock.ts create mode 100644 src/features/topics/topics.types.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2e2b109..90be25e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,6 +32,7 @@ jobs: env: VITE_API_URL: ${{ secrets.VITE_API_URL }} VITE_APP_URL: ${{ secrets.VITE_APP_URL }} + VITE_KAKAO_MAP_KEY: ${{ secrets.VITE_KAKAO_MAP_KEY }} - name: Deploy to EC2 uses: appleboy/scp-action@v0.1.7 diff --git a/src/features/topics/components/ConfirmedTopicList.tsx b/src/features/topics/components/ConfirmedTopicList.tsx new file mode 100644 index 0000000..72504d1 --- /dev/null +++ b/src/features/topics/components/ConfirmedTopicList.tsx @@ -0,0 +1,56 @@ +import { useInfiniteScroll } from '@/shared/hooks/useInfiniteScroll' + +import type { ConfirmedTopicItem } from '../topics.types' +import EmptyTopicList from './EmptyTopicList' +import TopicCard from './TopicCard' +import TopicListSkeleton from './TopicListSkeleton' + +type ConfirmedTopicListProps = { + topics: ConfirmedTopicItem[] + hasNextPage: boolean + isFetchingNextPage: boolean + onLoadMore: () => void + pageSize?: number +} + +export default function ConfirmedTopicList({ + topics, + hasNextPage, + isFetchingNextPage, + onLoadMore, + pageSize = 5, +}: ConfirmedTopicListProps) { + // 무한 스크롤: IntersectionObserver로 다음 페이지 로드 + const observerRef = useInfiniteScroll(onLoadMore, { + hasNextPage, + isFetchingNextPage, + }) + + if (topics.length === 0) { + return + } + + return ( +
+
    + {topics.map((topic) => ( +
  • + +
  • + ))} +
+ {/* 무한 스크롤 로딩 상태 */} + {isFetchingNextPage && } + + {/* 무한 스크롤 트리거 */} + {hasNextPage && !isFetchingNextPage &&
} +
+ ) +} diff --git a/src/features/topics/components/DefaultTopicCard.tsx b/src/features/topics/components/DefaultTopicCard.tsx new file mode 100644 index 0000000..7b26dee --- /dev/null +++ b/src/features/topics/components/DefaultTopicCard.tsx @@ -0,0 +1,18 @@ +import { Badge, Card } from '@/shared/ui' + +export default function DefaultTopicCard() { + return ( + +
+

기본주제

+ + 자유형 + +
+

자유롭게 이야기해봅시다.

+
+

제안 : 도크도크

+
+
+ ) +} diff --git a/src/features/topics/components/EmptyTopicList.tsx b/src/features/topics/components/EmptyTopicList.tsx new file mode 100644 index 0000000..9600457 --- /dev/null +++ b/src/features/topics/components/EmptyTopicList.tsx @@ -0,0 +1,9 @@ +import { Card } from '@/shared/ui' + +export default function EmptyTopicList() { + return ( + +

아직 확정된 주제가 없어요

+
+ ) +} diff --git a/src/features/topics/components/ProposedTopicList.tsx b/src/features/topics/components/ProposedTopicList.tsx new file mode 100644 index 0000000..ae7112f --- /dev/null +++ b/src/features/topics/components/ProposedTopicList.tsx @@ -0,0 +1,67 @@ +import { useInfiniteScroll } from '@/shared/hooks/useInfiniteScroll' + +import type { ProposedTopicItem } from '../topics.types' +import DefaultTopicCard from './DefaultTopicCard' +import TopicCard from './TopicCard' +import TopicListSkeleton from './TopicListSkeleton' + +type ProposedTopicListProps = { + topics: ProposedTopicItem[] + hasNextPage: boolean + isFetchingNextPage: boolean + onLoadMore: () => void + pageSize?: number + gatheringId: number + meetingId: number +} + +export default function ProposedTopicList({ + topics, + hasNextPage, + isFetchingNextPage, + onLoadMore, + pageSize = 5, + gatheringId, + meetingId, +}: ProposedTopicListProps) { + // 무한 스크롤: IntersectionObserver로 다음 페이지 로드 + const observerRef = useInfiniteScroll(onLoadMore, { + hasNextPage, + isFetchingNextPage, + }) + + return ( +
+ {/* 기본 주제는 항상 표시 */} + + + {/* 제안된 주제 목록 */} + {topics.length > 0 && ( +
    + {topics.map((topic) => ( +
  • + +
  • + ))} +
+ )} + + {/* 무한 스크롤 로딩 상태 */} + {isFetchingNextPage && } + + {/* 무한 스크롤 트리거 */} + {hasNextPage && !isFetchingNextPage &&
} +
+ ) +} diff --git a/src/features/topics/components/TopicCard.tsx b/src/features/topics/components/TopicCard.tsx new file mode 100644 index 0000000..0e2cd0d --- /dev/null +++ b/src/features/topics/components/TopicCard.tsx @@ -0,0 +1,111 @@ +import { Badge, Card, LikeButton, TextButton } from '@/shared/ui' +import { useGlobalModalStore } from '@/store' + +import { useDeleteTopic, useLikeTopic } from '../hooks' + +type TopicCardProps = { + title: string + topicTypeLabel: string + description: string + createdByNickname: string + likeCount: number + isLiked?: boolean + isLikeDisabled?: boolean + canDelete?: boolean + gatheringId?: number + meetingId?: number + topicId?: number +} + +export default function TopicCard({ + title, + topicTypeLabel, + description, + createdByNickname, + likeCount, + isLiked = false, + isLikeDisabled = false, + canDelete = false, + gatheringId, + meetingId, + topicId, +}: TopicCardProps) { + const { openConfirm, openAlert, openError } = useGlobalModalStore() + const deleteMutation = useDeleteTopic() + const likeMutation = useLikeTopic() + + const handleDelete = async () => { + if (!gatheringId || !meetingId || !topicId) { + openError('삭제 실패', '주제 삭제에 필요한 정보가 없습니다.') + return + } + + const confirmed = await openConfirm('주제 삭제', '정말 이 주제를 삭제하시겠습니까?', { + confirmText: '삭제', + variant: 'danger', + }) + + if (!confirmed) { + return + } + + deleteMutation.mutate( + { gatheringId, meetingId, topicId }, + // TODO: 토스트 컴포넌트로 교체 예정 + { + onSuccess: () => { + openAlert('삭제 완료', '주제가 삭제되었습니다.') + }, + onError: (error) => { + openError('삭제 실패', error.userMessage) + }, + } + ) + } + + const handleLike = () => { + if (!gatheringId || !meetingId || !topicId) { + return + } + + if (likeMutation.isPending) return + + likeMutation.mutate( + { gatheringId, meetingId, topicId }, + { + onError: (error) => { + // TODO: 토스트 컴포넌트로 교체 예정 + alert(`좋아요 처리 중 오류가 발생했습니다: ${error.userMessage}`) + }, + } + ) + } + return ( + +
+
+

{title}

+ + {topicTypeLabel} + +
+ {canDelete && ( + + 삭제하기 + + )} +
+

{description}

+
+

제안 : {createdByNickname}

+ +
+
+ ) +} diff --git a/src/features/topics/components/TopicHeader.tsx b/src/features/topics/components/TopicHeader.tsx new file mode 100644 index 0000000..329d88d --- /dev/null +++ b/src/features/topics/components/TopicHeader.tsx @@ -0,0 +1,103 @@ +import { format } from 'date-fns' +import { Check } from 'lucide-react' + +import { Button } from '@/shared/ui' + +type ProposedHeaderProps = { + activeTab: 'PROPOSED' + actions: { canConfirm: boolean; canSuggest: boolean } + confirmedTopic: boolean + confirmedTopicDate: string | null +} + +type ConfirmedHeaderProps = { + activeTab: 'CONFIRMED' + actions: { canViewPreOpinions: boolean; canWritePreOpinions: boolean } + confirmedTopic: boolean + confirmedTopicDate: string | null +} + +type TopicHeaderProps = ProposedHeaderProps | ConfirmedHeaderProps + +export default function TopicHeader(props: TopicHeaderProps) { + return ( + <> + {/* 제안탭 */} + {props.activeTab === 'PROPOSED' && ( +
+
+ {props.confirmedTopic && props.confirmedTopicDate ? ( + // 주제 확정됨 + <> +

+ 주제 제안이 마감되었어요. 확정된 주제를 확인해보세요! +

+

+ {format(props.confirmedTopicDate, 'yyyy.MM.dd HH:mm')} 마감 +

+ + ) : ( + // 주제 제안 중 + <> +

+ 약속에서 나누고 싶은 주제를 제안해보세요 +

+

+ 주제를 미리 정하면 우리 모임이 훨씬 풍성하고 즐거워질 거예요 +

+ + )} +
+ +
+ {props.actions.canConfirm && ( + + )} + + {props.actions.canSuggest && } +
+
+ )} + + {/* 제안탭 */} + + {/* 확정탭 */} + {props.activeTab === 'CONFIRMED' && ( +
+
+ {props.confirmedTopic ? ( + // 주제 확정됨 + <> +

+ 주제가 확정되었어요! +

+

+ 나의 생각을 미리 정리해서 공유하면 다른 멤버들의 의견도 바로 확인할 수 있어요 +

+ + ) : ( + // 주제 제안 중 + <> +

약속장이 주제를 선정하고 있어요

+

+ 주제가 확정되면 사전 의견을 남길 수 있는 창이 열려요 +

+ + )} +
+ +
+ + + +
+
+ )} + {/* 확정탭 */} + + ) +} diff --git a/src/features/topics/components/TopicListSkeleton.tsx b/src/features/topics/components/TopicListSkeleton.tsx new file mode 100644 index 0000000..a27c2d9 --- /dev/null +++ b/src/features/topics/components/TopicListSkeleton.tsx @@ -0,0 +1,27 @@ +import { Card } from '@/shared/ui' + +type TopicListSkeletonProps = { + count?: number +} + +export default function TopicListSkeleton({ count = 5 }: TopicListSkeletonProps) { + return ( + <> + {[...Array(count).keys()].map((i) => ( + +
+
+
+
+
+
+
+
+
+
+
+ + ))} + + ) +} diff --git a/src/features/topics/components/index.ts b/src/features/topics/components/index.ts new file mode 100644 index 0000000..5825bdb --- /dev/null +++ b/src/features/topics/components/index.ts @@ -0,0 +1,7 @@ +export { default as ConfirmedTopicList } from './ConfirmedTopicList' +export { default as DefaultTopicCard } from './DefaultTopicCard' +export { default as EmptyTopicList } from './EmptyTopicList' +export { default as ProposedTopicList } from './ProposedTopicList' +export { default as TopicCard } from './TopicCard' +export { default as TopicHeader } from './TopicHeader' +export { default as TopicListSkeleton } from './TopicListSkeleton' diff --git a/src/features/topics/hooks/index.ts b/src/features/topics/hooks/index.ts new file mode 100644 index 0000000..78bb37a --- /dev/null +++ b/src/features/topics/hooks/index.ts @@ -0,0 +1,5 @@ +export * from './topicQueryKeys' +export * from './useConfirmedTopics' +export * from './useDeleteTopic' +export * from './useLikeTopic' +export * from './useProposedTopics' diff --git a/src/features/topics/hooks/topicQueryKeys.ts b/src/features/topics/hooks/topicQueryKeys.ts new file mode 100644 index 0000000..9693991 --- /dev/null +++ b/src/features/topics/hooks/topicQueryKeys.ts @@ -0,0 +1,28 @@ +/** + * @file topicQueryKeys.ts + * @description 주제 관련 Query Key Factory + */ + +import type { GetConfirmedTopicsParams, GetProposedTopicsParams } from '../topics.types' + +/** + * Query Key Factory + * + * @description + * 주제 관련 Query Key를 일관되게 관리하기 위한 팩토리 함수 + */ +export const topicQueryKeys = { + all: ['topics'] as const, + + // 제안된 주제 관련 + proposed: () => [...topicQueryKeys.all, 'proposed'] as const, + proposedLists: () => [...topicQueryKeys.proposed(), 'list'] as const, + proposedList: (params: GetProposedTopicsParams) => + [...topicQueryKeys.proposedLists(), params] as const, + + // 확정된 주제 관련 + confirmed: () => [...topicQueryKeys.all, 'confirmed'] as const, + confirmedLists: () => [...topicQueryKeys.confirmed(), 'list'] as const, + confirmedList: (params: GetConfirmedTopicsParams) => + [...topicQueryKeys.confirmedLists(), params] as const, +} diff --git a/src/features/topics/hooks/useConfirmedTopics.ts b/src/features/topics/hooks/useConfirmedTopics.ts new file mode 100644 index 0000000..7b3ad8a --- /dev/null +++ b/src/features/topics/hooks/useConfirmedTopics.ts @@ -0,0 +1,67 @@ +/** + * @file useConfirmedTopics.ts + * @description 확정된 주제 조회 훅 (무한 스크롤) + */ + +import { type InfiniteData, useInfiniteQuery } from '@tanstack/react-query' + +import type { ApiError } from '@/api' + +import { getConfirmedTopics } from '../topics.api' +import type { + ConfirmedTopicCursor, + GetConfirmedTopicsParams, + GetConfirmedTopicsResponse, +} from '../topics.types' +import { topicQueryKeys } from './topicQueryKeys' + +/** + * 확정된 주제 조회 훅 (무한 스크롤) + * + * @description + * TanStack Query의 useInfiniteQuery를 사용하여 약속의 확정된 주제 목록을 무한 스크롤로 조회합니다. + * 확정 순서 기준 오름차순으로 정렬되며, 커서 기반 페이지네이션을 지원합니다. + * + * @param params - 조회 파라미터 + * @param params.gatheringId - 모임 식별자 + * @param params.meetingId - 약속 식별자 + * @param params.pageSize - 페이지 크기 (기본값: 5) + * + * @returns TanStack Query 무한 스크롤 결과 객체 + */ +export const useConfirmedTopics = ( + params: Omit +) => { + const { gatheringId, meetingId, pageSize } = params + const isValidParams = + !Number.isNaN(gatheringId) && gatheringId > 0 && !Number.isNaN(meetingId) && meetingId > 0 + + return useInfiniteQuery< + GetConfirmedTopicsResponse, + ApiError, + InfiniteData, + ReturnType, + ConfirmedTopicCursor | null + >({ + queryKey: topicQueryKeys.confirmedList({ gatheringId, meetingId, pageSize }), + queryFn: ({ pageParam }: { pageParam: ConfirmedTopicCursor | null }) => + getConfirmedTopics({ + gatheringId, + meetingId, + pageSize, + // 첫 페이지: 커서 없이 요청 (pageParam = null), 다음 페이지: nextCursor 사용 + cursorConfirmOrder: pageParam?.confirmOrder, + cursorTopicId: pageParam?.topicId, + }), + // gatheringId와 meetingId가 유효할 때만 쿼리 실행 + enabled: isValidParams, + // 초기 페이지 파라미터 (첫 페이지는 커서 파라미터 없이 요청) + initialPageParam: null, + // 다음 페이지 파라미터 가져오기 + getNextPageParam: (lastPage) => { + return lastPage.hasNext ? lastPage.nextCursor : null + }, + // 캐시 데이터 10분간 유지 + gcTime: 10 * 60 * 1000, + }) +} diff --git a/src/features/topics/hooks/useDeleteTopic.ts b/src/features/topics/hooks/useDeleteTopic.ts new file mode 100644 index 0000000..6043df0 --- /dev/null +++ b/src/features/topics/hooks/useDeleteTopic.ts @@ -0,0 +1,52 @@ +/** + * @file useDeleteTopic.ts + * @description 주제 삭제 mutation 훅 + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError } from '@/api/errors' + +import { deleteTopic } from '../topics.api' +import type { DeleteTopicParams } from '../topics.types' +import { topicQueryKeys } from './topicQueryKeys' + +/** + * 주제 삭제 mutation 훅 + * + * @description + * 주제를 삭제하고 관련 쿼리 캐시를 무효화합니다. + * - 제안된 주제 리스트 캐시 무효화 + * + * @example + * ```tsx + * const deleteMutation = useDeleteTopic() + * deleteMutation.mutate( + * { gatheringId: 1, meetingId: 2, topicId: 3 }, + * { + * onSuccess: () => { + * console.log('주제가 삭제되었습니다.') + * }, + * onError: (error) => { + * console.error('삭제 실패:', error.userMessage) + * }, + * } + * ) + * ``` + */ +export const useDeleteTopic = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (params: DeleteTopicParams) => deleteTopic(params), + onSuccess: (_, variables) => { + // 제안된 주제 무효화 + queryClient.invalidateQueries({ + queryKey: topicQueryKeys.proposedList({ + gatheringId: variables.gatheringId, + meetingId: variables.meetingId, + }), + }) + }, + }) +} diff --git a/src/features/topics/hooks/useLikeTopic.ts b/src/features/topics/hooks/useLikeTopic.ts new file mode 100644 index 0000000..27e9801 --- /dev/null +++ b/src/features/topics/hooks/useLikeTopic.ts @@ -0,0 +1,111 @@ +/** + * @file useLikeTopic.ts + * @description 주제 좋아요 토글 mutation 훅 (낙관적 업데이트) + */ + +import type { InfiniteData } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { ApiError } from '@/api/errors' + +import { likeTopicToggle } from '../topics.api' +import type { GetProposedTopicsResponse, LikeTopicParams, LikeTopicResponse } from '../topics.types' +import { topicQueryKeys } from './topicQueryKeys' + +/** + * 주제 좋아요 토글 mutation 훅 (낙관적 업데이트) + * + * @description + * 주제 좋아요를 토글하고 낙관적 업데이트를 적용합니다. + * - onMutate: 즉시 UI 업데이트 (isLiked 토글, likeCount 증감) + * - onError: 실패 시 이전 상태로 롤백 + * - onSettled: 서버 데이터와 동기화 + * + * @example + * ```tsx + * const likeMutation = useLikeTopic() + * likeMutation.mutate({ + * gatheringId: 1, + * meetingId: 2, + * topicId: 3, + * }) + * ``` + */ + +type LikeTopicContext = { + previousQueries: Array<{ + queryKey: readonly unknown[] + data: InfiniteData + }> +} + +export const useLikeTopic = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (params: LikeTopicParams) => likeTopicToggle(params), + + // 낙관적 업데이트: 즉시 UI 업데이트 + onMutate: async (variables) => { + const { topicId } = variables + + // Partial matching을 위한 base 쿼리 키 (pageSize와 무관하게 모든 쿼리 매칭) + const baseQueryKey = topicQueryKeys.proposedLists() + + // 진행 중인 모든 관련 쿼리 취소 (낙관적 업데이트 덮어쓰기 방지) + await queryClient.cancelQueries({ queryKey: baseQueryKey }) + + // 모든 매칭되는 쿼리의 이전 데이터 스냅샷 저장 + const previousQueries = queryClient + .getQueriesData>({ queryKey: baseQueryKey }) + .map(([queryKey, data]) => ({ queryKey, data: data! })) + .filter((query) => query.data !== undefined) + + // 모든 관련 캐시에 낙관적 업데이트 적용 + previousQueries.forEach(({ queryKey }) => { + queryClient.setQueryData>(queryKey, (old) => { + if (!old) return old + + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + items: page.items.map((topic) => { + if (topic.topicId === topicId) { + const newIsLiked = !topic.isLiked + return { + ...topic, + isLiked: newIsLiked, + likeCount: newIsLiked ? topic.likeCount + 1 : topic.likeCount - 1, + } + } + return topic + }), + })), + } + }) + }) + + // 롤백을 위한 이전 데이터 반환 + return { previousQueries } + }, + + // 에러 발생 시 롤백 + onError: (_error, _variables, context) => { + if (context?.previousQueries) { + // 저장된 모든 쿼리를 이전 상태로 복원 + context.previousQueries.forEach(({ queryKey, data }) => { + queryClient.setQueryData(queryKey, data) + }) + } + }, + + // 성공/실패 여부와 상관없이 서버 데이터와 동기화 + onSettled: () => { + // 모든 제안된 주제 목록 쿼리 무효화 + queryClient.invalidateQueries({ + queryKey: topicQueryKeys.proposedLists(), + }) + }, + }) +} diff --git a/src/features/topics/hooks/useProposedTopics.ts b/src/features/topics/hooks/useProposedTopics.ts new file mode 100644 index 0000000..60a8d2a --- /dev/null +++ b/src/features/topics/hooks/useProposedTopics.ts @@ -0,0 +1,67 @@ +/** + * @file useProposedTopics.ts + * @description 제안된 주제 조회 훅 (무한 스크롤) + */ + +import { type InfiniteData, useInfiniteQuery } from '@tanstack/react-query' + +import type { ApiError } from '@/api' + +import { getProposedTopics } from '../topics.api' +import type { + GetProposedTopicsParams, + GetProposedTopicsResponse, + ProposedTopicCursor, +} from '../topics.types' +import { topicQueryKeys } from './topicQueryKeys' + +/** + * 제안된 주제 조회 훅 (무한 스크롤) + * + * @description + * TanStack Query의 useInfiniteQuery를 사용하여 약속에 제안된 주제 목록을 무한 스크롤로 조회합니다. + * 좋아요 수 기준 내림차순으로 정렬되며, 커서 기반 페이지네이션을 지원합니다. + * + * @param params - 조회 파라미터 + * @param params.gatheringId - 모임 식별자 + * @param params.meetingId - 약속 식별자 + * @param params.pageSize - 페이지 크기 (기본값: 5) + * + * @returns TanStack Query 무한 스크롤 결과 객체 + */ +export const useProposedTopics = ( + params: Omit +) => { + const { gatheringId, meetingId, pageSize } = params + const isValidParams = + !Number.isNaN(gatheringId) && gatheringId > 0 && !Number.isNaN(meetingId) && meetingId > 0 + + return useInfiniteQuery< + GetProposedTopicsResponse, + ApiError, + InfiniteData, + ReturnType, + ProposedTopicCursor | null + >({ + queryKey: topicQueryKeys.proposedList({ gatheringId, meetingId, pageSize }), + queryFn: ({ pageParam }: { pageParam: ProposedTopicCursor | null }) => + getProposedTopics({ + gatheringId, + meetingId, + pageSize, + // 첫 페이지: 커서 없이 요청 (pageParam = null), 다음 페이지: nextCursor 사용 + cursorLikeCount: pageParam?.likeCount, + cursorTopicId: pageParam?.topicId, + }), + // gatheringId와 meetingId가 유효할 때만 쿼리 실행 + enabled: isValidParams, + // 초기 페이지 파라미터 (첫 페이지는 커서 파라미터 없이 요청) + initialPageParam: null, + // 다음 페이지 파라미터 가져오기 + getNextPageParam: (lastPage) => { + return lastPage.hasNext ? lastPage.nextCursor : null + }, + // 캐시 데이터 10분간 유지 + gcTime: 10 * 60 * 1000, + }) +} diff --git a/src/features/topics/index.ts b/src/features/topics/index.ts new file mode 100644 index 0000000..4661f4e --- /dev/null +++ b/src/features/topics/index.ts @@ -0,0 +1,16 @@ +// Components +export * from './components' + +// Hooks +export * from './hooks' + +// // Utils +// export * from './lib' + +// API +export * from './topics.api' +export * from './topics.endpoints' +export * from './topics.mock' + +// Types +export * from './topics.types' diff --git a/src/features/topics/topics.api.ts b/src/features/topics/topics.api.ts new file mode 100644 index 0000000..7f831a4 --- /dev/null +++ b/src/features/topics/topics.api.ts @@ -0,0 +1,178 @@ +/** + * @file topics.api.ts + * @description Topic API 요청 함수 + */ + +import { api } from '@/api/client' +import { PAGE_SIZES } from '@/shared/constants' + +import { TOPICS_ENDPOINTS } from './topics.endpoints' +import { getMockConfirmedTopics, getMockProposedTopics } from './topics.mock' +import type { + DeleteTopicParams, + GetConfirmedTopicsParams, + GetConfirmedTopicsResponse, + GetProposedTopicsParams, + GetProposedTopicsResponse, + LikeTopicParams, + LikeTopicResponse, +} from './topics.types' + +/** + * 목데이터 사용 여부 + * @description 로그인 기능 개발 전까지 true로 설정하여 목데이터 사용 + * TODO: 로그인 기능 완료 후 false로 변경하여 실제 API 호출 + */ +const USE_MOCK_DATA = true + +/** + * 제안된 주제 조회 + * + * @description + * 약속에 제안된 주제 목록을 커서 기반 페이지네이션으로 조회합니다. + * 좋아요 수 기준 내림차순으로 정렬됩니다. + * + * @param params - 조회 파라미터 + * @param params.gatheringId - 모임 식별자 + * @param params.meetingId - 약속 식별자 + * @param params.pageSize - 페이지 크기 (기본값: 5) + * @param params.cursorLikeCount - 커서: 이전 페이지 마지막 항목의 좋아요 수 + * @param params.cursorTopicId - 커서: 이전 페이지 마지막 항목의 주제 ID + * + * @returns 제안된 주제 목록 및 액션 정보 + */ +export const getProposedTopics = async ( + params: GetProposedTopicsParams +): Promise => { + const { + gatheringId, + meetingId, + pageSize = PAGE_SIZES.TOPICS, + cursorLikeCount, + cursorTopicId, + } = params + + // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용 + // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거 + if (USE_MOCK_DATA) { + // 실제 API 호출을 시뮬레이션하기 위한 지연 + await new Promise((resolve) => setTimeout(resolve, 500)) + return getMockProposedTopics(pageSize, cursorLikeCount, cursorTopicId) + } + + // 실제 API 호출 (로그인 완료 후 사용) + return api.get(TOPICS_ENDPOINTS.PROPOSED(gatheringId, meetingId), { + params: { + pageSize, + cursorLikeCount, + cursorTopicId, + }, + }) +} + +/** + * 확정된 주제 조회 + * + * @description + * 약속의 확정된 주제 목록을 커서 기반 페이지네이션으로 조회합니다. + * 확정 순서 기준 오름차순으로 정렬됩니다. + * + * @param params - 조회 파라미터 + * @param params.gatheringId - 모임 식별자 + * @param params.meetingId - 약속 식별자 + * @param params.pageSize - 페이지 크기 (기본값: 5) + * @param params.cursorConfirmOrder - 커서: 이전 페이지 마지막 항목의 확정 순서 + * @param params.cursorTopicId - 커서: 이전 페이지 마지막 항목의 주제 ID + * + * @returns 확정된 주제 목록 및 액션 정보 + */ +export const getConfirmedTopics = async ( + params: GetConfirmedTopicsParams +): Promise => { + const { + gatheringId, + meetingId, + pageSize = PAGE_SIZES.TOPICS, + cursorConfirmOrder, + cursorTopicId, + } = params + + // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용 + // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거 + if (USE_MOCK_DATA) { + // 실제 API 호출을 시뮬레이션하기 위한 지연 + await new Promise((resolve) => setTimeout(resolve, 500)) + return getMockConfirmedTopics(pageSize, cursorConfirmOrder, cursorTopicId) + } + + // 실제 API 호출 (로그인 완료 후 사용) + return api.get(TOPICS_ENDPOINTS.CONFIRMED(gatheringId, meetingId), { + params: { + pageSize, + cursorConfirmOrder, + cursorTopicId, + }, + }) +} + +/** + * 주제 삭제 + * + * @description + * 주제를 삭제합니다. 삭제 권한이 있는 경우에만 삭제가 가능합니다. + * + * @param params - 삭제 파라미터 + * @param params.gatheringId - 모임 식별자 + * @param params.meetingId - 약속 식별자 + * @param params.topicId - 주제 식별자 + * + * @returns void (성공 시 응답 데이터 없음) + */ +export const deleteTopic = async (params: DeleteTopicParams): Promise => { + const { gatheringId, meetingId, topicId } = params + + // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용 + // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거 + if (USE_MOCK_DATA) { + // 실제 API 호출을 시뮬레이션하기 위한 지연 + await new Promise((resolve) => setTimeout(resolve, 500)) + return + } + + // 실제 API 호출 (로그인 완료 후 사용) + return api.delete(TOPICS_ENDPOINTS.DELETE(gatheringId, meetingId, topicId)) +} + +/** + * 주제 좋아요 토글 + * + * @description + * 주제 좋아요를 토글합니다. 좋아요 상태이면 취소하고, 아니면 좋아요를 추가합니다. + * + * @param params - 좋아요 토글 파라미터 + * @param params.gatheringId - 모임 식별자 + * @param params.meetingId - 약속 식별자 + * @param params.topicId - 주제 식별자 + * + * @returns 좋아요 토글 결과 (주제 ID, 좋아요 상태, 새로운 좋아요 수) + */ +export const likeTopicToggle = async (params: LikeTopicParams): Promise => { + const { gatheringId, meetingId, topicId } = params + + // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용 + // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거 + if (USE_MOCK_DATA) { + // 실제 API 호출을 시뮬레이션하기 위한 지연 + await new Promise((resolve) => setTimeout(resolve, 300)) + // 목 응답 (랜덤하게 좋아요/취소) + const liked = Math.random() > 0.5 + return { + topicId, + liked, + newCount: liked ? Math.floor(Math.random() * 10) + 1 : Math.floor(Math.random() * 5), + } + } + + // 실제 API 호출 (로그인 완료 후 사용) + return api.post(TOPICS_ENDPOINTS.LIKE_TOGGLE(gatheringId, meetingId, topicId)) +} diff --git a/src/features/topics/topics.endpoints.ts b/src/features/topics/topics.endpoints.ts new file mode 100644 index 0000000..f9a8140 --- /dev/null +++ b/src/features/topics/topics.endpoints.ts @@ -0,0 +1,19 @@ +import { API_PATHS } from '@/api' + +export const TOPICS_ENDPOINTS = { + // 제안된 주제 조회 (GET /api/gatherings/{gatheringId}/meetings/{meetingId}/topics) + PROPOSED: (gatheringId: number, meetingId: number) => + `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/topics`, + + // 확정된 주제 조회 (GET /api/gatherings/{gatheringId}/meetings/{meetingId}/confirm-topics) + CONFIRMED: (gatheringId: number, meetingId: number) => + `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/confirm-topics`, + + // 주제 삭제 (DELETE /api/gatherings/{gatheringId}/meetings/{meetingId}/topics/{topicId}) + DELETE: (gatheringId: number, meetingId: number, topicId: number) => + `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/topics/${topicId}`, + + // 주제 좋아요 토글 (POST /api/gatherings/{gatheringId}/meetings/{meetingId}/topics/{topicId}/likes) + LIKE_TOGGLE: (gatheringId: number, meetingId: number, topicId: number) => + `${API_PATHS.GATHERINGS}/${gatheringId}/meetings/${meetingId}/topics/${topicId}/likes`, +} as const diff --git a/src/features/topics/topics.mock.ts b/src/features/topics/topics.mock.ts new file mode 100644 index 0000000..69feca6 --- /dev/null +++ b/src/features/topics/topics.mock.ts @@ -0,0 +1,491 @@ +/** + * @file topics.mock.ts + * @description Topic API 목데이터 + */ + +import type { + ConfirmedTopicItem, + GetConfirmedTopicsResponse, + GetProposedTopicsResponse, + ProposedTopicItem, +} from './topics.types' + +/** + * 제안된 주제 목데이터 + */ +const mockProposedTopics: ProposedTopicItem[] = [ + { + topicId: 1, + meetingId: 1, + title: '이 책의 핵심 메시지는 무엇인가?', + description: '저자가 전달하고자 하는 핵심 메시지에 대해 토론합니다.', + topicType: 'DISCUSSION', + topicTypeLabel: '토론형', + topicStatus: 'PROPOSED', + likeCount: 25, + isLiked: true, + canDelete: true, + createdByInfo: { + userId: 1, + nickname: '독서왕', + }, + }, + { + topicId: 2, + meetingId: 1, + title: '주인공의 심리 변화 과정', + description: '주인공이 겪는 내면의 변화를 분석해봅시다.', + topicType: 'CHARACTER_ANALYSIS', + topicTypeLabel: '인물 분석형', + topicStatus: 'PROPOSED', + likeCount: 20, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 2, + nickname: '문학소녀', + }, + }, + { + topicId: 3, + meetingId: 1, + title: '책을 읽고 느낀 감정 공유', + description: '이 책을 읽으며 느낀 솔직한 감정들을 나눠요.', + topicType: 'EMOTION', + topicTypeLabel: '감정 공유형', + topicStatus: 'PROPOSED', + likeCount: 18, + isLiked: true, + canDelete: false, + createdByInfo: { + userId: 3, + nickname: '감성독서', + }, + }, + { + topicId: 4, + meetingId: 1, + title: '작가의 문체와 서술 방식', + description: '이 작품만의 독특한 문체와 서술 기법을 살펴봅니다.', + topicType: 'STRUCTURE', + topicTypeLabel: '구조 분석형', + topicStatus: 'PROPOSED', + likeCount: 15, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 4, + nickname: '분석가', + }, + }, + { + topicId: 5, + meetingId: 1, + title: '비슷한 경험이 있었나요?', + description: '책 속 상황과 유사한 개인적 경험을 공유해봐요.', + topicType: 'EXPERIENCE', + topicTypeLabel: '경험 연결형', + topicStatus: 'PROPOSED', + likeCount: 13, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 5, + nickname: '공감왕', + }, + }, + { + topicId: 6, + meetingId: 1, + title: '이 책이 주는 교훈', + description: '책을 통해 얻은 삶의 교훈을 이야기해봅시다.', + topicType: 'DISCUSSION', + topicTypeLabel: '토론형', + topicStatus: 'PROPOSED', + likeCount: 12, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 6, + nickname: '철학자', + }, + }, + { + topicId: 7, + meetingId: 1, + title: '현대 사회와의 연결고리', + description: '작품 속 이야기가 현대 사회와 어떻게 연결되는지 논의합니다.', + topicType: 'COMPARISON', + topicTypeLabel: '비교 분석형', + topicStatus: 'PROPOSED', + likeCount: 10, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 7, + nickname: '사회학도', + }, + }, + { + topicId: 8, + meetingId: 1, + title: '인상 깊었던 장면', + description: '가장 인상 깊었던 장면과 그 이유를 공유해요.', + topicType: 'EMOTION', + topicTypeLabel: '감정 공유형', + topicStatus: 'PROPOSED', + likeCount: 9, + isLiked: true, + canDelete: false, + createdByInfo: { + userId: 8, + nickname: '열정독서', + }, + }, + { + topicId: 9, + meetingId: 1, + title: '작가가 전달하려던 메시지', + description: '작가의 의도와 숨겨진 메시지를 파헤쳐봅시다.', + topicType: 'IN_DEPTH', + topicTypeLabel: '심층 분석형', + topicStatus: 'PROPOSED', + likeCount: 8, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 9, + nickname: '심층분석가', + }, + }, + { + topicId: 10, + meetingId: 1, + title: '다른 작품과의 비교', + description: '비슷한 주제의 다른 작품들과 비교해봅시다.', + topicType: 'COMPARISON', + topicTypeLabel: '비교 분석형', + topicStatus: 'PROPOSED', + likeCount: 7, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 10, + nickname: '비교문학', + }, + }, + { + topicId: 11, + meetingId: 1, + title: '등장인물들의 관계', + description: '등장인물들 간의 복잡한 관계를 분석합니다.', + topicType: 'CHARACTER_ANALYSIS', + topicTypeLabel: '인물 분석형', + topicStatus: 'PROPOSED', + likeCount: 6, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 11, + nickname: '관계분석', + }, + }, + { + topicId: 12, + meetingId: 1, + title: '책의 배경과 시대상', + description: '작품의 배경이 되는 시대와 사회상을 이해해봅시다.', + topicType: 'STRUCTURE', + topicTypeLabel: '구조 분석형', + topicStatus: 'PROPOSED', + likeCount: 5, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 12, + nickname: '역사탐구', + }, + }, + { + topicId: 13, + meetingId: 1, + title: '이 책으로 만든 창작물', + description: '책에서 영감을 받아 짧은 글이나 그림을 만들어봐요.', + topicType: 'CREATIVE', + topicTypeLabel: '창작형', + topicStatus: 'PROPOSED', + likeCount: 4, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 13, + nickname: '창작러', + }, + }, + { + topicId: 14, + meetingId: 1, + title: '나라면 어떻게 했을까?', + description: '주인공의 입장이 되어 나의 선택을 상상해봅시다.', + topicType: 'EXPERIENCE', + topicTypeLabel: '경험 연결형', + topicStatus: 'PROPOSED', + likeCount: 3, + isLiked: true, + canDelete: false, + createdByInfo: { + userId: 14, + nickname: '상상력', + }, + }, + { + topicId: 15, + meetingId: 1, + title: '책의 결말에 대한 생각', + description: '책의 결말이 적절했는지, 다른 결말은 없었을지 토론합니다.', + topicType: 'DISCUSSION', + topicTypeLabel: '토론형', + topicStatus: 'PROPOSED', + likeCount: 2, + isLiked: false, + canDelete: false, + createdByInfo: { + userId: 15, + nickname: '결말탐구', + }, + }, +] + +/** + * 확정된 주제 목데이터 + */ +const mockConfirmedTopics: ConfirmedTopicItem[] = [ + { + topicId: 20, + title: '데미안에서 자기 자신이란?', + description: '주인공이 찾아가는 자아의 의미를 탐구합니다.', + topicType: 'IN_DEPTH', + topicTypeLabel: '심층 분석형', + likeCount: 5, + confirmOrder: 1, + createdByInfo: { + userId: 1, + nickname: '독서왕', + }, + }, + { + topicId: 21, + title: '새와 알의 상징', + description: '작품 속 핵심 상징인 새와 알이 의미하는 바를 논의합니다.', + topicType: 'DISCUSSION', + topicTypeLabel: '토론형', + likeCount: 5, + confirmOrder: 2, + createdByInfo: { + userId: 2, + nickname: '문학소녀', + }, + }, + { + topicId: 22, + title: '싱클레어의 성장 과정', + description: '주인공 싱클레어의 정신적 성장을 단계별로 분석합니다.', + topicType: 'CHARACTER_ANALYSIS', + topicTypeLabel: '인물 분석형', + likeCount: 5, + confirmOrder: 3, + createdByInfo: { + userId: 3, + nickname: '감성독서', + }, + }, + { + topicId: 23, + title: '빛과 어둠의 이중성', + description: '작품 속에서 빛과 어둠이 상징하는 의미를 탐구합니다.', + topicType: 'IN_DEPTH', + topicTypeLabel: '심층 분석형', + likeCount: 5, + confirmOrder: 4, + createdByInfo: { + userId: 4, + nickname: '분석가', + }, + }, + { + topicId: 24, + title: '데미안이라는 인물의 의미', + description: '데미안이 싱클레어에게 미친 영향을 분석합니다.', + topicTypeLabel: '인물 분석형', + likeCount: 5, + topicType: 'CHARACTER_ANALYSIS', + confirmOrder: 5, + createdByInfo: { + userId: 5, + nickname: '공감왕', + }, + }, + { + topicId: 25, + title: '아브락사스의 상징', + description: '선과 악을 초월한 신, 아브락사스의 의미를 논의합니다.', + topicType: 'DISCUSSION', + topicTypeLabel: '토론형', + likeCount: 2, + confirmOrder: 6, + createdByInfo: { + userId: 6, + nickname: '철학자', + }, + }, + { + topicId: 26, + title: '꿈과 현실의 경계', + description: '작품 속 꿈과 현실이 혼재하는 장면들을 분석합니다.', + topicType: 'STRUCTURE', + topicTypeLabel: '구조 분석형', + likeCount: 1, + confirmOrder: 7, + createdByInfo: { + userId: 7, + nickname: '사회학도', + }, + }, + { + topicId: 27, + title: '에바 부인의 역할', + description: '싱클레어의 정신적 어머니 역할을 한 에바 부인을 논의합니다.', + topicType: 'CHARACTER_ANALYSIS', + topicTypeLabel: '인물 분석형', + likeCount: 0, + confirmOrder: 8, + createdByInfo: { + userId: 8, + nickname: '열정독서', + }, + }, + { + topicId: 28, + title: '종교적 상징과 의미', + description: '작품에 나타난 다양한 종교적 상징들을 탐구합니다.', + topicType: 'IN_DEPTH', + topicTypeLabel: '심층 분석형', + likeCount: 5, + confirmOrder: 9, + createdByInfo: { + userId: 9, + nickname: '심층분석가', + }, + }, + { + topicId: 29, + title: '성장소설로서의 데미안', + description: '성장소설 장르로서 데미안이 가진 특징을 분석합니다.', + topicType: 'STRUCTURE', + topicTypeLabel: '구조 분석형', + likeCount: 5, + confirmOrder: 10, + createdByInfo: { + userId: 10, + nickname: '비교문학', + }, + }, +] + +/** + * 제안된 주제 목데이터 반환 함수 + * + * @description + * 실제 API 호출을 시뮬레이션하여 제안된 주제 목데이터를 커서 기반 페이지네이션 형태로 반환합니다. + */ +export const getMockProposedTopics = ( + pageSize: number = 10, + cursorLikeCount?: number, + cursorTopicId?: number +): GetProposedTopicsResponse => { + let items = [...mockProposedTopics] + + // 커서가 있으면 해당 커서 이후의 데이터만 필터링 + if (cursorLikeCount !== undefined && cursorTopicId !== undefined) { + const cursorIndex = items.findIndex( + (item) => item.likeCount === cursorLikeCount && item.topicId === cursorTopicId + ) + if (cursorIndex !== -1) { + items = items.slice(cursorIndex + 1) + } + } + + // 페이지 크기만큼 자르기 + const pageItems = items.slice(0, pageSize) + const hasNext = items.length > pageSize + + // 다음 커서 생성 + const nextCursor = + hasNext && pageItems.length > 0 + ? { + likeCount: pageItems[pageItems.length - 1].likeCount, + topicId: pageItems[pageItems.length - 1].topicId, + } + : null + + return { + items: pageItems, + pageSize, + hasNext, + nextCursor, + totalCount: cursorLikeCount === undefined ? mockProposedTopics.length : undefined, + actions: { + canConfirm: false, + canSuggest: true, + }, + } +} + +/** + * 확정된 주제 목데이터 반환 함수 + * + * @description + * 실제 API 호출을 시뮬레이션하여 확정된 주제 목데이터를 커서 기반 페이지네이션 형태로 반환합니다. + */ +export const getMockConfirmedTopics = ( + pageSize: number = 10, + cursorConfirmOrder?: number, + cursorTopicId?: number +): GetConfirmedTopicsResponse => { + let items = [...mockConfirmedTopics] + + // 커서가 있으면 해당 커서 이후의 데이터만 필터링 + if (cursorConfirmOrder !== undefined && cursorTopicId !== undefined) { + const cursorIndex = items.findIndex( + (item) => item.confirmOrder === cursorConfirmOrder && item.topicId === cursorTopicId + ) + if (cursorIndex !== -1) { + items = items.slice(cursorIndex + 1) + } + } + + // 페이지 크기만큼 자르기 + const pageItems = items.slice(0, pageSize) + const hasNext = items.length > pageSize + + // 다음 커서 생성 + const nextCursor = + hasNext && pageItems.length > 0 + ? { + confirmOrder: pageItems[pageItems.length - 1].confirmOrder, + topicId: pageItems[pageItems.length - 1].topicId, + } + : null + + return { + items: pageItems, + pageSize, + hasNext, + nextCursor, + totalCount: cursorConfirmOrder === undefined ? mockConfirmedTopics.length : undefined, + actions: { + canViewPreOpinions: true, + canWritePreOpinions: false, + }, + } +} diff --git a/src/features/topics/topics.types.ts b/src/features/topics/topics.types.ts new file mode 100644 index 0000000..68db60b --- /dev/null +++ b/src/features/topics/topics.types.ts @@ -0,0 +1,200 @@ +/** + * @file topics.types.ts + * @description Topic API 관련 타입 정의 + */ + +import type { CursorPaginatedResponse } from '@/api/types' + +/** + * 주제 상태 타입 + */ +export type TopicStatus = 'PROPOSED' | 'CONFIRMED' + +/** + * 주제 타입 + */ +export type TopicType = + | 'FREE' + | 'DISCUSSION' + | 'EMOTION' + | 'EXPERIENCE' + | 'CHARACTER_ANALYSIS' + | 'COMPARISON' + | 'STRUCTURE' + | 'IN_DEPTH' + | 'CREATIVE' + | 'CUSTOM' + +/** + * 주제 아이템 타입 (제안된 주제) + */ +export type ProposedTopicItem = { + /** 주제 ID */ + topicId: number + /** 약속 ID */ + meetingId: number + /** 주제 제목 */ + title: string + /** 주제 설명 */ + description: string + /** 주제 타입 */ + topicType: TopicType + /** 주제 타입 라벨 (한국어) */ + topicTypeLabel: string + /** 주제 상태 */ + topicStatus: TopicStatus + /** 좋아요 수 */ + likeCount: number + /** 좋아요 여부 */ + isLiked: boolean + /** 삭제 가능 여부 */ + canDelete: boolean + /** 생성자 정보 */ + createdByInfo: { + userId: number + nickname: string + } +} + +/** + * 확정된 주제 아이템 타입 + */ +export type ConfirmedTopicItem = { + /** 주제 ID */ + topicId: number + /** 주제 제목 */ + title: string + /** 주제 설명 */ + description: string + /** 주제 타입 */ + topicType: TopicType + /** 주제 타입 라벨 (한국어) */ + topicTypeLabel: string + /** 확정 순서 */ + confirmOrder: number + /** 좋아요 수 */ + likeCount: number + /** 생성자 정보 */ + createdByInfo: { + userId: number + nickname: string + } +} + +/** + * 커서 타입 (제안된 주제용) + */ +export type ProposedTopicCursor = { + likeCount: number + topicId: number +} + +/** + * 커서 타입 (확정된 주제용) + */ +export type ConfirmedTopicCursor = { + confirmOrder: number + topicId: number +} + +// CursorPaginatedResponse는 @/api/types에서 import하여 사용 + +/** + * 제안된 주제 조회 요청 파라미터 + */ +export type GetProposedTopicsParams = { + /** 모임 식별자 */ + gatheringId: number + /** 약속 식별자 */ + meetingId: number + /** 페이지 크기 (기본값: 10) */ + pageSize?: number + /** 커서: 이전 페이지 마지막 항목의 좋아요 수 */ + cursorLikeCount?: number + /** 커서: 이전 페이지 마지막 항목의 주제 ID */ + cursorTopicId?: number +} + +/** + * 확정된 주제 조회 요청 파라미터 + */ +export type GetConfirmedTopicsParams = { + /** 모임 식별자 */ + gatheringId: number + /** 약속 식별자 */ + meetingId: number + /** 페이지 크기 (기본값: 10) */ + pageSize?: number + /** 커서: 이전 페이지 마지막 항목의 확정 순서 */ + cursorConfirmOrder?: number + /** 커서: 이전 페이지 마지막 항목의 주제 ID */ + cursorTopicId?: number +} + +/** + * 제안된 주제 조회 응답 타입 + */ +export type GetProposedTopicsResponse = CursorPaginatedResponse< + ProposedTopicItem, + ProposedTopicCursor +> & { + /** 액션 권한 정보 */ + actions: { + /** 주제 확정 가능 여부 */ + canConfirm: boolean + /** 주제 제안 가능 여부 */ + canSuggest: boolean + } +} + +/** + * 확정된 주제 조회 응답 타입 + */ +export type GetConfirmedTopicsResponse = CursorPaginatedResponse< + ConfirmedTopicItem, + ConfirmedTopicCursor +> & { + /** 액션 권한 정의 */ + actions: { + /** 사전 의견 조회 가능 여부 */ + canViewPreOpinions: boolean + /** 사전 의견 작성 가능 여부 */ + canWritePreOpinions: boolean + } +} + +/** + * 주제 삭제 요청 파라미터 + */ +export type DeleteTopicParams = { + /** 모임 식별자 */ + gatheringId: number + /** 약속 식별자 */ + meetingId: number + /** 주제 식별자 */ + topicId: number +} + +/** + * 주제 좋아요 토글 요청 파라미터 + */ +export type LikeTopicParams = { + /** 모임 식별자 */ + gatheringId: number + /** 약속 식별자 */ + meetingId: number + /** 주제 식별자 */ + topicId: number +} + +/** + * 주제 좋아요 토글 응답 + */ +export type LikeTopicResponse = { + /** 주제 식별자 */ + topicId: number + /** 좋아요 상태 */ + liked: boolean + /** 새로운 좋아요 수 */ + newCount: number +} diff --git a/src/pages/Meetings/MeetingDetailPage.tsx b/src/pages/Meetings/MeetingDetailPage.tsx index 3602d8a..7686f4f 100644 --- a/src/pages/Meetings/MeetingDetailPage.tsx +++ b/src/pages/Meetings/MeetingDetailPage.tsx @@ -1,4 +1,5 @@ import { ChevronLeft } from 'lucide-react' +import { useEffect, useState } from 'react' import { useParams } from 'react-router-dom' import { @@ -7,13 +8,63 @@ import { MeetingDetailInfo, useMeetingDetail, } from '@/features/meetings' -import { TextButton } from '@/shared/ui' +import type { + GetConfirmedTopicsResponse, + GetProposedTopicsResponse, + TopicStatus, +} from '@/features/topics' +import { + ConfirmedTopicList, + ProposedTopicList, + TopicHeader, + useConfirmedTopics, + useProposedTopics, +} from '@/features/topics' +import { Tabs, TabsContent, TabsList, TabsTrigger, TextButton } from '@/shared/ui' export default function MeetingDetailPage() { - const { meetingId } = useParams<{ gatheringId: string; meetingId: string }>() + const { gatheringId, meetingId } = useParams<{ gatheringId: string; meetingId: string }>() + + const [activeTab, setActiveTab] = useState('PROPOSED') const { data: meeting, isLoading, error } = useMeetingDetail(Number(meetingId)) + // 제안된 주제 조회 (무한 스크롤) + const { + data: proposedTopicsInfiniteData, + isLoading: isProposedLoading, + error: proposedError, + fetchNextPage: fetchNextProposedPage, + hasNextPage: hasNextProposedPage, + isFetchingNextPage: isFetchingNextProposedPage, + } = useProposedTopics({ + gatheringId: Number(gatheringId), + meetingId: Number(meetingId), + }) + + // 확정된 주제 조회 (무한 스크롤) + const { + data: confirmedTopicsInfiniteData, + isLoading: isConfirmedLoading, + error: confirmedError, + fetchNextPage: fetchNextConfirmedPage, + hasNextPage: hasNextConfirmedPage, + isFetchingNextPage: isFetchingNextConfirmedPage, + } = useConfirmedTopics({ + gatheringId: Number(gatheringId), + meetingId: Number(meetingId), + }) + + // 에러 처리 + useEffect(() => { + if (proposedError) { + alert(`제안된 주제 조회 실패: ${proposedError.userMessage}`) + } + if (confirmedError) { + alert(`확정된 주제 조회 실패: ${confirmedError.userMessage}`) + } + }, [proposedError, confirmedError]) + if (error) { return (
@@ -58,10 +109,86 @@ export default function MeetingDetailPage() {
{/* 약속 로딩 적용 */} -
- {/* 주제 로딩 적용 */} +

주제

- {/* 주제 로딩 적용 */} + + setActiveTab(value as TopicStatus)} + className="gap-medium" + > + + + 제안 + + + 확정된 주제 + + + + {isProposedLoading ? ( +
+

로딩 중...

+
+ ) : proposedTopicsInfiniteData ? ( +
+ + page.items + )} + hasNextPage={hasNextProposedPage} + isFetchingNextPage={isFetchingNextProposedPage} + onLoadMore={fetchNextProposedPage} + pageSize={5} + gatheringId={Number(gatheringId)} + meetingId={Number(meetingId)} + /> +
+ ) : null} +
+ + + {isConfirmedLoading ? ( +
+

로딩 중...

+
+ ) : confirmedTopicsInfiniteData ? ( +
+ + page.items + )} + hasNextPage={hasNextConfirmedPage} + isFetchingNextPage={isFetchingNextConfirmedPage} + onLoadMore={fetchNextConfirmedPage} + pageSize={5} + /> +
+ ) : null} +
+
diff --git a/src/shared/constants/pagination.ts b/src/shared/constants/pagination.ts index 5ba2b00..d1fb3f2 100644 --- a/src/shared/constants/pagination.ts +++ b/src/shared/constants/pagination.ts @@ -20,4 +20,6 @@ export const DEFAULT_SHOW_PAGES = 5 export const PAGE_SIZES = { /** 약속 승인 관리 리스트 페이지 사이즈 */ MEETING_APPROVALS: 10, + /** 주제 목록 페이지 사이즈 */ + TOPICS: 5, } as const diff --git a/src/shared/ui/LikeButton.tsx b/src/shared/ui/LikeButton.tsx index 2710a4f..cdd5cea 100644 --- a/src/shared/ui/LikeButton.tsx +++ b/src/shared/ui/LikeButton.tsx @@ -8,6 +8,7 @@ export interface LikeButtonProps { onClick?: (liked: boolean) => void disabled?: boolean className?: string + isPending?: boolean } /** @@ -27,9 +28,12 @@ function LikeButton({ onClick, disabled = false, className, + isPending = false, }: LikeButtonProps) { + const isButtonDisabled = disabled || isPending + const handleClick = () => { - if (disabled) return + if (isButtonDisabled) return onClick?.(!isLiked) } @@ -39,14 +43,19 @@ function LikeButton({ data-slot="like-button" data-liked={isLiked} onClick={handleClick} - disabled={disabled} + disabled={isButtonDisabled} className={cn( 'inline-flex items-center gap-tiny rounded-small px-[10px] py-tiny typo-caption1 transition-colors', isLiked ? 'border border-primary-300 bg-primary-100 text-primary-300' : 'border border-grey-400 bg-white text-grey-700', - disabled && 'border-transparent', - !disabled && 'cursor-pointer', + + // 의미적 disabled (기존 유지) + disabled && 'border-transparent opacity-100', + + // 정상 + !isButtonDisabled && 'cursor-pointer', + className )} > From 7b897fdd42e0b969dc1b6c2f2eedd351fcd45e31 Mon Sep 17 00:00:00 2001 From: choiyoungae <109134495+choiyoungae@users.noreply.github.com> Date: Sat, 7 Feb 2026 21:54:47 +0900 Subject: [PATCH 04/19] =?UTF-8?q?[feat]=20=EB=82=B4=20=EC=B1=85=EC=9E=A5?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84=20(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 내 책장 조회 구현 (#53) * feat: 내 책장 편집 구현 (#53) * feat: 책 추가 기능 구현 (#53) * refactor: BookList 및 BookListPage 코드 포맷팅 정리 (#53) * refactor: 책 목록 탭 필터링 및 에러 처리 개선 (#53) * fix: 필터링된 책 목록 기준으로 전체 선택 동작 수정 (#53) * fix: 필터 변경 시 전체 선택 오동작 수정 (#53) * style: handleFilteredBooksChange 콜백 포맷팅 정리 (#53) * refactor: 카운트 조회 로직 및 편집 모드 종료 조건 단순화 (#53) * refactor: 커서기반 페이지네이션 공통 타입 적용 (#53) * refactor: 책 검색 모달 확장성 반영 (#53) * refactor: 코드 포맷팅 (#53) * fix: 불필요한 effect 재실행 방지 및 createBook 에러 처리 추가 (#53) * fix: 비활성 탭 filteredBookIds 덮어쓰기 및 createBook 에러 처리 수정 (#53) --- src/features/book/book.api.ts | 191 ++++++++++++ src/features/book/book.types.ts | 107 ++++++- src/features/book/components/BookCard.tsx | 111 +++++++ src/features/book/components/BookList.tsx | 277 ++++++++++++++++++ .../book/components/BookReviewModal.tsx | 2 + .../book/components/BookSearchModal.tsx | 173 +++++++++++ src/features/book/components/index.ts | 14 + src/features/book/hooks/index.ts | 4 + src/features/book/hooks/useBooks.ts | 61 ++++ src/features/book/hooks/useCreateBook.ts | 43 +++ src/features/book/hooks/useDeleteBook.ts | 32 ++ src/features/book/hooks/useSearchBooks.ts | 56 ++++ src/features/book/index.ts | 16 + src/pages/Books/BookListPage.tsx | 252 +++++++++++++++- src/shared/ui/Tabs.tsx | 8 +- 15 files changed, 1325 insertions(+), 22 deletions(-) create mode 100644 src/features/book/components/BookCard.tsx create mode 100644 src/features/book/components/BookList.tsx create mode 100644 src/features/book/components/BookSearchModal.tsx create mode 100644 src/features/book/components/index.ts create mode 100644 src/features/book/hooks/useBooks.ts create mode 100644 src/features/book/hooks/useCreateBook.ts create mode 100644 src/features/book/hooks/useDeleteBook.ts create mode 100644 src/features/book/hooks/useSearchBooks.ts diff --git a/src/features/book/book.api.ts b/src/features/book/book.api.ts index 6e2e677..a3c2eba 100644 --- a/src/features/book/book.api.ts +++ b/src/features/book/book.api.ts @@ -8,16 +8,22 @@ import { ApiError, ErrorCode } from '@/api/errors' import type { BookDetail, + BookListItem, BookReview, + CreateBookBody, CreateBookRecordBody, CreateBookReviewBody, GetBookRecordsParams, GetBookRecordsResponse, GetBookReviewHistoryParams, GetBookReviewHistoryResponse, + GetBooksParams, + GetBooksResponse, GetGatheringsParams, GetGatheringsResponse, PersonalRecord, + SearchBooksParams, + SearchBooksResponse, UpdateBookRecordBody, } from './book.types' @@ -37,6 +43,40 @@ const mockBookDetail: BookDetail = { thumbnail: 'https://contents.kyobobook.co.kr/sih/fit-in/458x0/pdt/9791189327156.jpg', } +const mockBookListItems: BookListItem[] = [ + { + bookId: 1, + title: '우리에게는 매일 철학이 필요하다', + publisher: '피터 홀린스', + authors: '피터 홀린스', + bookReadingStatus: 'READING', + thumbnail: 'https://contents.kyobobook.co.kr/sih/fit-in/458x0/pdt/9791189327156.jpg', + rating: 0.5, + gatheringNames: ['책책책 책을 읽자', 'FCDE'], + }, + { + bookId: 2, + title: '데미안', + publisher: '민음사', + authors: '헤르만 헤세', + bookReadingStatus: 'READING', + thumbnail: 'https://contents.kyobobook.co.kr/sih/fit-in/458x0/pdt/9788937460449.jpg', + rating: 4.5, + gatheringNames: ['주말 독서 모임'], + }, + { + bookId: 3, + title: '1984', + publisher: '민음사', + authors: '조지 오웰', + bookReadingStatus: 'COMPLETED', + thumbnail: + 'https://i.namu.wiki/i/OuId9i6YhTdBIk5XDIZWVre8GtdOv_OaaXSL_WlGUvPisTnbN2jwn0lf_b8sJp_bjBLoKgl6Fa4-enbgZJIRLA.webp', + rating: 5, + gatheringNames: [], + }, +] + const mockBookReview: BookReview = { reviewId: 1, bookId: 1, @@ -570,6 +610,25 @@ export async function deleteBookRecord(personalBookId: number, recordId: number) return api.delete(`/api/book/${personalBookId}/records/${recordId}`) } +/** + * 책 삭제 + * + * @param bookId - 삭제할 책 ID + * + * @example + * ```typescript + * await deleteBook(1) + * ``` + */ +export async function deleteBook(bookId: number): Promise { + if (USE_MOCK) { + await delay(MOCK_DELAY) + return + } + + return api.delete(`/api/book/${bookId}`) +} + /** * 책 평가 생성 * @@ -605,6 +664,92 @@ export async function createBookReview( return api.post(`/api/book/${bookId}/reviews`, body) } +// ============================================================ +// Book List (책 목록) API +// ============================================================ + +/** + * 책 목록 조회 + * + * 커서 기반 페이지네이션을 지원하며, 상태별 필터링이 가능합니다. + * + * @param params - 조회 파라미터 (status, pageSize, cursorAddedAt, cursorBookId) + * @returns 책 목록 및 페이지네이션 정보 + * + * @example + * ```typescript + * // 전체 책 목록 조회 + * const result = await getBooks() + * + * // 읽는 중인 책만 조회 + * const readingBooks = await getBooks({ status: 'READING' }) + * + * // 다음 페이지 조회 + * const nextPage = await getBooks({ + * cursorAddedAt: result.nextCursor?.addedAt, + * cursorBookId: result.nextCursor?.bookId, + * }) + * ``` + */ +export async function getBooks(params: GetBooksParams = {}): Promise { + if (USE_MOCK) { + await delay(MOCK_DELAY) + return filterMockBooks(params) + } + + return api.get('/api/book', { params }) +} + +function filterMockBooks(params: GetBooksParams): GetBooksResponse { + const { status, gatheringId, ratingMin, ratingMax, sort = 'LATEST' } = params + + let filteredItems = [...mockBookListItems] + + // 상태 필터 + if (status) { + filteredItems = filteredItems.filter((item) => item.bookReadingStatus === status) + } + + // 모임 필터 - gatheringId가 있으면 해당 모임 이름을 가진 책만 필터링 + if (gatheringId !== undefined) { + const gathering = mockGatheringsResponse.items.find((g) => g.gatheringId === gatheringId) + if (gathering) { + filteredItems = filteredItems.filter((item) => + item.gatheringNames.includes(gathering.gatheringName) + ) + } + } + + // 별점 필터 - 선택한 범위 내의 별점만 필터링 (정수 기준) + if (ratingMin !== undefined && ratingMax !== undefined && ratingMin > 0) { + filteredItems = filteredItems.filter((item) => { + const floorRating = Math.floor(item.rating) + return floorRating >= ratingMin && floorRating <= ratingMax + }) + } + + // 정렬 처리 (bookId 기준으로 시뮬레이션) + const sortMultiplier = sort === 'LATEST' ? -1 : 1 + filteredItems.sort((a, b) => sortMultiplier * (a.bookId - b.bookId)) + + const readingCount = mockBookListItems.filter( + (item) => item.bookReadingStatus === 'READING' + ).length + const completedCount = mockBookListItems.filter( + (item) => item.bookReadingStatus === 'COMPLETED' + ).length + + return { + items: filteredItems, + pageSize: params.pageSize ?? 10, + hasNext: false, + nextCursor: null, + totalCount: mockBookListItems.length, + readingCount, + completedCount, + } +} + function filterMockBookRecords( data: GetBookRecordsResponse, params: GetBookRecordsParams @@ -666,3 +811,49 @@ function filterMockBookRecords( meetingPreOpinions, } } + +// ============================================================ +// Book Search (도서 검색) API +// ============================================================ + +/** + * 도서 검색 + * + * 외부 API를 통해 도서를 검색합니다. + * 페이지 기반 페이지네이션을 지원합니다. + * + * @param params - 검색 파라미터 (query, page) + * @returns 검색된 도서 목록 및 페이지네이션 정보 + * + * @example + * ```typescript + * const result = await searchBooks({ query: '데미안' }) + * console.log(result.items) // 검색된 도서 목록 + * ``` + */ +export async function searchBooks(params: SearchBooksParams): Promise { + return api.get('/api/book/search', { params }) +} + +/** + * 책 등록 + * + * 검색한 도서를 내 책장에 등록합니다. + * + * @param body - 책 등록 요청 바디 + * @returns 등록된 책 상세 정보 + * + * @example + * ```typescript + * const book = await createBook({ + * title: '데미안', + * authors: '헤르만 헤세', + * publisher: '민음사', + * isbn: '9788937460449', + * thumbnail: 'https://...', + * }) + * ``` + */ +export async function createBook(body: CreateBookBody): Promise { + return api.post('/api/book', body) +} diff --git a/src/features/book/book.types.ts b/src/features/book/book.types.ts index 673bb27..ef13fe1 100644 --- a/src/features/book/book.types.ts +++ b/src/features/book/book.types.ts @@ -3,8 +3,10 @@ * @description Book 도메인 관련 타입 정의 */ +import type { CursorPaginatedResponse } from '@/api/types' + /** 책 읽기 상태 */ -export type BookReadingStatus = 'READING' | 'COMPLETED' | 'PENDING' +export type BookReadingStatus = 'READING' | 'COMPLETED' /** 책 상세 정보 */ export interface BookDetail { @@ -16,6 +18,47 @@ export interface BookDetail { thumbnail: string } +// ============================================================ +// Book List (책 목록) 관련 타입 +// ============================================================ + +/** 책 목록 아이템 */ +export interface BookListItem { + bookId: number + title: string + publisher: string + authors: string + bookReadingStatus: BookReadingStatus + thumbnail: string + rating: number + gatheringNames: string[] +} + +/** 책 목록 조회 요청 파라미터 */ +export interface GetBooksParams { + status?: BookReadingStatus + gatheringId?: number + ratingMin?: number + ratingMax?: number + sort?: RecordSortType + pageSize?: number + cursorAddedAt?: string + cursorBookId?: number +} + +/** 책 목록 조회 커서 */ +export interface BookListCursor { + addedAt: string + bookId: number +} + +/** 책 목록 조회 응답 */ +export interface GetBooksResponse extends CursorPaginatedResponse { + totalCount: number + readingCount: number + completedCount: number +} + /** 리뷰 키워드 종류 */ type ReviewKeywordType = 'BOOK' | 'IMPRESSION' @@ -65,12 +108,7 @@ export interface GetGatheringsParams { } /** 모임 목록 조회 응답 */ -export interface GetGatheringsResponse { - items: Gathering[] - pageSize: number - hasNext: boolean - nextCursor: string | null -} +export type GetGatheringsResponse = CursorPaginatedResponse // ============================================================ // Book Records (감상 기록) 관련 타입 @@ -216,14 +254,10 @@ export interface GetBookReviewHistoryParams { } /** 책 평가 히스토리 조회 응답 */ -export interface GetBookReviewHistoryResponse { - items: BookReviewHistoryItem[] - pageSize: number - hasNext: boolean - nextCursor: { - historyId: number | null - } -} +export type GetBookReviewHistoryResponse = CursorPaginatedResponse< + BookReviewHistoryItem, + { historyId: number | null } +> /** 감상 기록 생성 요청 바디 */ export interface CreateBookRecordBody { @@ -265,3 +299,46 @@ export interface CreateBookReviewBody { rating: number keywordIds: number[] } + +// ============================================================ +// Book Search (도서 검색) 관련 타입 +// ============================================================ + +/** 검색된 도서 아이템 */ +export interface SearchBookItem { + title: string + contents: string + authors: string[] + publisher: string + isbn: string + thumbnail: string +} + +/** 도서 검색 요청 파라미터 */ +export interface SearchBooksParams { + query: string + page?: number + pageSize?: number +} + +/** 도서 검색 커서 */ +export interface SearchBooksCursor { + page: number +} + +/** 도서 검색 응답 */ +export interface SearchBooksResponse extends CursorPaginatedResponse< + SearchBookItem, + SearchBooksCursor +> { + totalCount: number +} + +/** 책 등록 요청 바디 */ +export interface CreateBookBody { + title: string + authors: string + publisher: string + isbn: string + thumbnail: string +} diff --git a/src/features/book/components/BookCard.tsx b/src/features/book/components/BookCard.tsx new file mode 100644 index 0000000..c99c7c5 --- /dev/null +++ b/src/features/book/components/BookCard.tsx @@ -0,0 +1,111 @@ +import { Star } from 'lucide-react' +import { Link } from 'react-router-dom' + +import { Badge, Checkbox } from '@/shared/ui' + +import type { BookListItem } from '../book.types' + +type BookCardProps = { + book: BookListItem + /** 필터에서 선택된 모임명 (일치하는 Badge는 green으로 표시) */ + selectedGatheringName?: string + /** 편집 모드 여부 */ + isEditMode?: boolean + /** 선택 여부 (편집 모드에서 사용) */ + isSelected?: boolean + /** 선택 토글 핸들러 (편집 모드에서 사용) */ + onSelectToggle?: (bookId: number) => void +} + +/** + * 책 카드 컴포넌트 + * + * 책 목록에서 개별 책을 표시하는 카드입니다. + * 썸네일, 제목, 저자, 별점, 소속 모임을 표시합니다. + * + * @example + * ```tsx + * + * + * // 편집 모드 + * toggleSelection(id)} + * /> + * ``` + */ +function BookCard({ + book, + selectedGatheringName, + isEditMode = false, + isSelected = false, + onSelectToggle, +}: BookCardProps) { + const { bookId, title, authors, thumbnail, rating, gatheringNames } = book + + const handleClick = (e: React.MouseEvent) => { + if (isEditMode) { + e.preventDefault() + onSelectToggle?.(bookId) + } + } + + const CardContent = ( +
+ {/* 책 표지 */} +
+ {`${title} + {isEditMode && ( +
+ onSelectToggle?.(bookId)} + onClick={(e) => e.stopPropagation()} + /> +
+ )} +
+ + {/* 책 정보 */} +
+

{title}

+

{authors}

+ + {/* 별점 */} +
+ + {rating.toFixed(1)} +
+ + {/* 모임 태그 */} + {gatheringNames.length > 0 && ( +
+ {gatheringNames.map((name) => ( + + {name} + + ))} +
+ )} +
+
+ ) + + if (isEditMode) { + return ( +
+ {CardContent} +
+ ) + } + + return ( + + {CardContent} + + ) +} + +export default BookCard diff --git a/src/features/book/components/BookList.tsx b/src/features/book/components/BookList.tsx new file mode 100644 index 0000000..af0b211 --- /dev/null +++ b/src/features/book/components/BookList.tsx @@ -0,0 +1,277 @@ +import { useEffect, useMemo, useRef, useState } from 'react' + +import { + FilterDropdown, + StarRatingFilter, + type StarRatingRange, + Tabs, + TabsList, + TabsTrigger, +} from '@/shared/ui' + +import type { BookReadingStatus, RecordSortType } from '../book.types' +import { useBooks, useMyGatherings } from '../hooks' +import BookCard from './BookCard' + +type BookListProps = { + status?: BookReadingStatus + /** 현재 활성 탭인지 여부 (비활성 탭에서 onFilteredBooksChange 호출 방지) */ + isActive?: boolean + /** 편집 모드 여부 */ + isEditMode?: boolean + /** 선택된 책 ID 목록 */ + selectedBookIds?: Set + /** 선택 토글 핸들러 */ + onSelectToggle?: (bookId: number) => void + /** 필터링된 책 목록이 변경될 때 호출되는 콜백 */ + onFilteredBooksChange?: (bookIds: number[]) => void +} + +/** + * 책 목록 컴포넌트 + * + * 상태에 따라 책 목록을 조회하고 그리드로 표시합니다. + * 무한스크롤, 로딩, 에러, 빈 상태를 처리합니다. + * + * @example + * ```tsx + * // 전체 책 목록 + * + * + * // 읽는 중인 책만 + * + * + * // 읽기 완료된 책만 + * + * + * // 편집 모드 + * toggleSelection(id)} + * /> + * ``` + */ +function BookList({ + status, + isActive = true, + isEditMode = false, + selectedBookIds, + onSelectToggle, + onFilteredBooksChange, +}: BookListProps) { + // 필터 상태 + const [selectedGathering, setSelectedGathering] = useState('') + const [rating, setRating] = useState(null) + const [sortType, setSortType] = useState('LATEST') + const [openDropdown, setOpenDropdown] = useState<'gathering' | null>(null) + + // 무한스크롤 감지용 ref + const loadMoreRef = useRef(null) + + // 모임 목록 조회 + const { + data: gatheringsData, + isLoading: isGatheringsLoading, + fetchNextPage: fetchNextGatherings, + hasNextPage: hasNextGatherings, + } = useMyGatherings() + + const gatherings = gatheringsData?.pages.flatMap((page) => page.items) ?? [] + + // 선택된 모임명 찾기 + const selectedGatheringName = gatherings.find( + (g) => String(g.gatheringId) === selectedGathering + )?.gatheringName + + // 책 목록 조회 (필터 적용, 무한스크롤) + const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useBooks({ + status, + gatheringId: selectedGathering ? Number(selectedGathering) : undefined, + ratingMin: rating?.min, + ratingMax: rating?.max, + sort: sortType, + }) + + // 무한스크롤 Intersection Observer + useEffect(() => { + const target = loadMoreRef.current + if (!target) return + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, + { threshold: 0.1 } + ) + + observer.observe(target) + return () => observer.disconnect() + }, [hasNextPage, isFetchingNextPage, fetchNextPage]) + + const handleGatheringChange = (value: string) => { + setSelectedGathering(value) + } + + // 모든 페이지의 책 목록 합치기 (참조 안정성을 위해 메모이제이션) + const books = useMemo(() => data?.pages.flatMap((page) => page.items) ?? [], [data?.pages]) + + // 필터링된 책 ID 목록 + const bookIds = useMemo(() => books.map((book) => book.bookId), [books]) + + // 필터링된 책 목록이 변경될 때 부모에 알림 (활성 탭일 때만) + useEffect(() => { + if (isActive) { + onFilteredBooksChange?.(bookIds) + } + }, [bookIds, onFilteredBooksChange, isActive]) + + if (isError) { + return ( +
+

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

+
+ ) + } + + const isEmpty = !isLoading && books.length === 0 + + return ( +
+
+
+ setOpenDropdown(open ? 'gathering' : null)} + > + {gatherings.map((gathering) => ( + + {gathering.gatheringName} + + ))} + {hasNextGatherings && ( + + )} + + +
+ setSortType(v as RecordSortType)}> + + + 최신순 + + · + + 오래된순 + + + +
+ {isLoading ? ( + + ) : isEmpty ? ( + + ) : ( + <> +
+ {books.map((book) => ( + + ))} +
+ {/* 무한스크롤 트리거 */} +
+ {isFetchingNextPage && } +
+ + )} +
+ ) +} + +function BookListSkeleton() { + return ( +
+ {[...Array(6).keys()].map((index) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ) +} + +function BookListLoadingMore() { + return ( +
+
+
+ ) +} + +function BookListEmpty({ + status, + hasFilters, +}: { + status?: BookReadingStatus + hasFilters?: boolean +}) { + const getMessage = () => { + if (hasFilters) { + return '조건에 맞는 책이 없어요.' + } + + switch (status) { + case 'READING': + return '기록 중인 책이 없어요.' + case 'COMPLETED': + return '기록 완료인 책이 없어요.' + default: + return ( + <> + 저장된 책이 없어요. +
첫 책을 추가해보세요! + + ) + } + } + + return ( +
+

{getMessage()}

+
+ ) +} + +export default BookList diff --git a/src/features/book/components/BookReviewModal.tsx b/src/features/book/components/BookReviewModal.tsx index 4a9f182..3d3f662 100644 --- a/src/features/book/components/BookReviewModal.tsx +++ b/src/features/book/components/BookReviewModal.tsx @@ -337,3 +337,5 @@ export function BookReviewModal({ bookId, open, onOpenChange }: BookReviewModalP ) } + +export default BookReviewModal diff --git a/src/features/book/components/BookSearchModal.tsx b/src/features/book/components/BookSearchModal.tsx new file mode 100644 index 0000000..c87d2ae --- /dev/null +++ b/src/features/book/components/BookSearchModal.tsx @@ -0,0 +1,173 @@ +/** + * @file BookSearchModal.tsx + * @description 도서 검색 모달 컴포넌트 + */ + +import { useCallback, useEffect, useRef, useState } from 'react' + +import { Modal, ModalBody, ModalContent, ModalHeader, ModalTitle, SearchField } from '@/shared/ui' + +import type { SearchBookItem } from '../book.types' +import { useSearchBooks } from '../hooks/useSearchBooks' + +export interface BookSearchModalProps { + /** 모달 열림 상태 */ + open: boolean + /** 모달 열림 상태 변경 핸들러 */ + onOpenChange: (open: boolean) => void + /** 책 선택 시 호출되는 핸들러 */ + onSelectBook: (book: SearchBookItem) => void | Promise + /** 책 선택 처리 중 여부 (true일 경우 중복 선택 방지) */ + isPending?: boolean +} + +/** + * 도서 검색 모달 + * + * 외부 API를 통해 도서를 검색하고 내 책장에 등록할 수 있는 모달입니다. + * 디바운싱과 무한스크롤을 지원합니다. + * + * @example + * ```tsx + * { + * await registerBook({ title: book.title, ... }) + * }} + * isPending={isRegistering} + * /> + * ``` + */ +export default function BookSearchModal({ + open, + onOpenChange, + onSelectBook, + isPending, +}: BookSearchModalProps) { + const [searchQuery, setSearchQuery] = useState('') + const [debouncedQuery, setDebouncedQuery] = useState('') + const listRef = useRef(null) + + // 디바운싱: 입력 후 300ms 후에 검색 + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(searchQuery) + }, 300) + + return () => clearTimeout(timer) + }, [searchQuery]) + + // 도서 검색 + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useSearchBooks(debouncedQuery) + + const books = data?.pages.flatMap((page) => page.items) ?? [] + + // 무한스크롤 처리 + const handleScroll = useCallback(() => { + if (!listRef.current || isFetchingNextPage || !hasNextPage) return + + const { scrollTop, scrollHeight, clientHeight } = listRef.current + if (scrollHeight - scrollTop - clientHeight < 100) { + fetchNextPage() + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage]) + + // 책 선택 + const handleSelectBook = async (book: SearchBookItem) => { + if (isPending) return + + try { + await onSelectBook(book) + handleOpenChange(false) + } catch { + // 에러는 onSelectBook 호출부에서 처리됨 + } + } + + // 모달 닫을 때 상태 초기화 + const resetState = () => { + setSearchQuery('') + setDebouncedQuery('') + } + + const handleOpenChange = (nextOpen: boolean) => { + onOpenChange(nextOpen) + if (!nextOpen) resetState() + } + + return ( + + + + 도서 검색 + + + + setSearchQuery(e.target.value)} + /> + +
+
+ {books.map((book) => ( + handleSelectBook(book)} + disabled={isPending} + /> + ))} + {isFetchingNextPage && ( +
로딩 중...
+ )} +
+
+
+
+
+ ) +} + +interface BookSearchItemProps { + book: SearchBookItem + onClick: () => void + disabled?: boolean +} + +function BookSearchItem({ book, onClick, disabled }: BookSearchItemProps) { + return ( + + ) +} diff --git a/src/features/book/components/index.ts b/src/features/book/components/index.ts new file mode 100644 index 0000000..1379027 --- /dev/null +++ b/src/features/book/components/index.ts @@ -0,0 +1,14 @@ +export { default as BookCard } from './BookCard' +export { default as BookInfo } from './BookInfo' +export { default as BookList } from './BookList' +export { default as BookLogList } from './BookLogList' +export { default as BookLogModal } from './BookLogModal' +export { default as BookReview } from './BookReview' +export { default as BookReviewModal } from './BookReviewModal' +export { default as BookSearchModal } from './BookSearchModal' +export { default as ExcerptBlock } from './ExcerptBlock' +export { default as MeetingGroupRecordItem } from './MeetingGroupRecordItem' +export { default as MeetingPreOpinionItem } from './MeetingPreOpinionItem' +export { default as MeetingRetrospectiveItem } from './MeetingRetrospectiveItem' +export { default as PersonalRecordItem } from './PersonalRecordItem' +export { default as ReviewHistoryCard } from './ReviewHistoryCard' diff --git a/src/features/book/hooks/index.ts b/src/features/book/hooks/index.ts index 080ddb5..55488ea 100644 --- a/src/features/book/hooks/index.ts +++ b/src/features/book/hooks/index.ts @@ -2,8 +2,12 @@ export * from './useBookDetail' export * from './useBookRecords' export * from './useBookReview' export * from './useBookReviewHistory' +export * from './useBooks' +export * from './useCreateBook' export * from './useCreateBookRecord' export * from './useCreateBookReview' +export * from './useDeleteBook' export * from './useDeleteBookRecord' export * from './useMyGatherings' +export * from './useSearchBooks' export * from './useUpdateBookRecord' diff --git a/src/features/book/hooks/useBooks.ts b/src/features/book/hooks/useBooks.ts new file mode 100644 index 0000000..6da108b --- /dev/null +++ b/src/features/book/hooks/useBooks.ts @@ -0,0 +1,61 @@ +/** + * @file useBooks.ts + * @description 책 목록 조회 훅 (무한스크롤 지원) + */ + +import { useInfiniteQuery } from '@tanstack/react-query' + +import { getBooks } from '../book.api' +import type { GetBooksParams, GetBooksResponse } from '../book.types' +import { bookKeys } from './useBookDetail' + +/** 책 목록 쿼리 키 확장 */ +export const bookListKeys = { + ...bookKeys, + list: (params?: Omit) => + [...bookKeys.all, 'list', params ?? {}] as const, +} + +/** + * 책 목록을 조회하는 훅 (무한스크롤 지원) + * + * @param params - 필터링/정렬 파라미터 (status, gatheringId, rating, sort) + * + * @example + * ```tsx + * function BookListPage() { + * const { + * data, + * fetchNextPage, + * hasNextPage, + * isFetchingNextPage, + * } = useBooks({ status: 'READING' }) + * + * const books = data?.pages.flatMap(page => page.items) ?? [] + * const totalCount = data?.pages[0]?.totalCount ?? 0 + * + * return ( + *
+ *

전체: {totalCount}권

+ * {books.map(book => )} + * {hasNextPage && ( + * + * )} + *
+ * ) + * } + * ``` + */ +export function useBooks(params?: Omit) { + return useInfiniteQuery({ + queryKey: bookListKeys.list(params), + queryFn: ({ pageParam }) => + getBooks({ + ...params, + cursorAddedAt: pageParam?.addedAt, + cursorBookId: pageParam?.bookId, + }), + initialPageParam: undefined as GetBooksResponse['nextCursor'] | undefined, + getNextPageParam: (lastPage) => (lastPage.hasNext ? lastPage.nextCursor : undefined), + }) +} diff --git a/src/features/book/hooks/useCreateBook.ts b/src/features/book/hooks/useCreateBook.ts new file mode 100644 index 0000000..5dc8594 --- /dev/null +++ b/src/features/book/hooks/useCreateBook.ts @@ -0,0 +1,43 @@ +/** + * @file useCreateBook.ts + * @description 책 등록 훅 + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { createBook } from '../book.api' +import type { CreateBookBody } from '../book.types' +import { bookListKeys } from './useBooks' + +/** + * 책을 등록하는 뮤테이션 훅 + * + * @example + * ```tsx + * function BookSearchModal() { + * const { mutateAsync: registerBook, isPending } = useCreateBook() + * + * const handleSelect = async (book: SearchBookItem) => { + * await registerBook({ + * title: book.title, + * authors: book.authors.join(', '), + * publisher: book.publisher, + * isbn: book.isbn, + * thumbnail: book.thumbnail, + * }) + * } + * + * return (...) + * } + * ``` + */ +export function useCreateBook() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (body: CreateBookBody) => createBook(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bookListKeys.all }) + }, + }) +} diff --git a/src/features/book/hooks/useDeleteBook.ts b/src/features/book/hooks/useDeleteBook.ts new file mode 100644 index 0000000..c04b935 --- /dev/null +++ b/src/features/book/hooks/useDeleteBook.ts @@ -0,0 +1,32 @@ +/** + * @file useDeleteBook.ts + * @description 책 삭제 뮤테이션 훅 + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { deleteBook } from '../book.api' +import { bookListKeys } from './useBooks' + +/** + * 책을 삭제하는 뮤테이션 훅 + * + * @example + * ```tsx + * const { mutate, mutateAsync } = useDeleteBook() + * mutate(bookId) + * + * // 여러 책 삭제 + * await Promise.all(bookIds.map(id => mutateAsync(id))) + * ``` + */ +export function useDeleteBook() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (bookId: number) => deleteBook(bookId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bookListKeys.all }) + }, + }) +} diff --git a/src/features/book/hooks/useSearchBooks.ts b/src/features/book/hooks/useSearchBooks.ts new file mode 100644 index 0000000..2e6bebb --- /dev/null +++ b/src/features/book/hooks/useSearchBooks.ts @@ -0,0 +1,56 @@ +/** + * @file useSearchBooks.ts + * @description 도서 검색 훅 (무한스크롤 지원) + */ + +import { useInfiniteQuery } from '@tanstack/react-query' + +import { searchBooks } from '../book.api' + +/** 도서 검색 쿼리 키 */ +export const searchBooksKeys = { + all: ['searchBooks'] as const, + search: (query: string) => [...searchBooksKeys.all, query] as const, +} + +/** + * 도서를 검색하는 훅 (무한스크롤 지원) + * + * @param query - 검색어 + * + * @example + * ```tsx + * function BookSearchModal() { + * const [query, setQuery] = useState('') + * const { + * data, + * fetchNextPage, + * hasNextPage, + * isFetching, + * } = useSearchBooks(query) + * + * const books = data?.pages.flatMap(page => page.items) ?? [] + * + * return ( + *
+ * setQuery(e.target.value)} /> + * {books.map(book => )} + *
+ * ) + * } + * ``` + */ +export function useSearchBooks(query: string) { + return useInfiniteQuery({ + queryKey: searchBooksKeys.search(query), + queryFn: ({ pageParam }) => + searchBooks({ + query, + page: pageParam, + }), + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.hasNext ? (lastPage.nextCursor?.page ?? undefined) : undefined, + enabled: query.trim().length > 0, + }) +} diff --git a/src/features/book/index.ts b/src/features/book/index.ts index e83b2e5..8f3ba92 100644 --- a/src/features/book/index.ts +++ b/src/features/book/index.ts @@ -1,3 +1,19 @@ export * from './book.api' export * from './book.types' +export { + BookCard, + BookInfo, + BookList, + BookLogList, + BookLogModal, + BookReview as BookReviewComponent, + BookReviewModal, + BookSearchModal, + ExcerptBlock, + MeetingGroupRecordItem, + MeetingPreOpinionItem, + MeetingRetrospectiveItem, + PersonalRecordItem, + ReviewHistoryCard, +} from './components' export * from './hooks' diff --git a/src/pages/Books/BookListPage.tsx b/src/pages/Books/BookListPage.tsx index 550f12b..b569d97 100644 --- a/src/pages/Books/BookListPage.tsx +++ b/src/pages/Books/BookListPage.tsx @@ -1,3 +1,253 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { SearchBookItem } from '@/features/book' +import { BookList, BookSearchModal, useBooks, useCreateBook, useDeleteBook } from '@/features/book' +import { Button, Tabs, TabsContent, TabsList, TabsTrigger, TextButton } from '@/shared/ui' +import { useGlobalModalStore } from '@/store' + export default function BookListPage() { - return
Book List Page
+ const { mutateAsync: deleteBook } = useDeleteBook() + const { mutateAsync: createBook, isPending: isCreating } = useCreateBook() + const { openConfirm } = useGlobalModalStore() + + // 현재 활성 탭 상태 + const [activeTab, setActiveTab] = useState<'all' | 'reading' | 'completed'>('all') + + // 편집 모드 상태 + const [isEditMode, setIsEditMode] = useState(false) + const [selectedBookIds, setSelectedBookIds] = useState>(new Set()) + + // 편집 모드 ref (콜백 안정성을 위해) + const isEditModeRef = useRef(isEditMode) + useEffect(() => { + isEditModeRef.current = isEditMode + }, [isEditMode]) + + // 도서 검색 모달 상태 + const [isSearchModalOpen, setIsSearchModalOpen] = useState(false) + + // 현재 탭에서 필터링된 책 ID 목록 (BookList에서 전달받음) + const [filteredBookIds, setFilteredBookIds] = useState([]) + + // 카운트 전용 쿼리 (탭과 무관하게 전체 데이터에서 카운트 조회) + const { data: countData } = useBooks() + + // 첫 페이지에서 카운트 정보 가져오기 + const firstPage = countData?.pages[0] + const totalCount = firstPage?.totalCount ?? 0 + const readingCount = firstPage?.readingCount ?? 0 + const completedCount = firstPage?.completedCount ?? 0 + + // 선택 토글 + const handleSelectToggle = (bookId: number) => { + setSelectedBookIds((prev) => { + const next = new Set(prev) + if (next.has(bookId)) { + next.delete(bookId) + } else { + next.add(bookId) + } + return next + }) + } + + // 전체 선택 (현재 화면에 표시된 책 기준, 멤버십 기반) + const handleSelectAll = () => { + const allSelected = + filteredBookIds.length > 0 && filteredBookIds.every((id) => selectedBookIds.has(id)) + + if (allSelected) { + // 전체 해제 + setSelectedBookIds(new Set()) + } else { + // 전체 선택 + setSelectedBookIds(new Set(filteredBookIds)) + } + } + + // 삭제하기 + const handleDelete = async () => { + if (selectedBookIds.size === 0) return + + const confirmed = await openConfirm( + '책 삭제하기', + '책장 속 책을 삭제하면 해당 책의 감상 기록도 모두 삭제되며,\n이 과정은 되돌릴 수 없어요. 삭제를 진행할까요?', + { + confirmText: '삭제', + variant: 'danger', + } + ) + + if (!confirmed) return + + const bookIds = [...selectedBookIds] + const results = await Promise.allSettled(bookIds.map((id) => deleteBook(id))) + + // 성공한 ID만 선택 해제 + const succeededIds = new Set( + results + .map((result, index) => (result.status === 'fulfilled' ? bookIds[index] : null)) + .filter((id): id is number => id !== null) + ) + + const failedCount = results.filter((r) => r.status === 'rejected').length + + if (failedCount > 0) { + await openConfirm( + '삭제 실패', + `${bookIds.length}권 중 ${failedCount}권 삭제에 실패했습니다.\n잠시 후 다시 시도해주세요.`, + { confirmText: '확인' } + ) + } + + // 성공한 항목만 선택에서 제거 + setSelectedBookIds((prev) => { + const next = new Set(prev) + succeededIds.forEach((id) => { + next.delete(id) + }) + return next + }) + + if (failedCount === 0) { + setIsEditMode(false) + } + } + + // 편집 모드 토글 + const handleEditModeToggle = () => { + if (isEditMode) { + // 편집 모드 종료 시 선택 초기화 + setSelectedBookIds(new Set()) + } + setIsEditMode(!isEditMode) + } + + // 탭 변경 핸들러 + const handleTabChange = (value: string) => { + setActiveTab(value as 'all' | 'reading' | 'completed') + // 탭 변경 시 선택 초기화 + setSelectedBookIds(new Set()) + } + + // 필터링된 책 목록 변경 핸들러 (필터 변경 시 selectedBookIds도 정리) + const handleFilteredBooksChange = useCallback((bookIds: number[]) => { + setFilteredBookIds(bookIds) + + // 편집 모드일 때만 selectedBookIds 정리 + if (isEditModeRef.current) { + const filteredSet = new Set(bookIds) + setSelectedBookIds((prev) => { + const cleaned = new Set([...prev].filter((id) => filteredSet.has(id))) + if (cleaned.size !== prev.size) { + return cleaned + } + return prev + }) + } + }, []) + + // 멤버십 기반 전체 선택 여부 확인 + const isAllSelected = + filteredBookIds.length > 0 && filteredBookIds.every((id) => selectedBookIds.has(id)) + + return ( +
+

내 책장

+ +
+ + + 전체 + + + 기록 중 + + + 기록 완료 + + +
+ {isEditMode ? ( + <> + + {isAllSelected ? '전체해제' : '전체선택'} + + + 삭제하기 + + {/* 취소 */} + + ) : ( + <> + + + + )} +
+
+ + + + + + + + + +
+ + { + try { + await createBook({ + title: book.title, + authors: book.authors.join(', '), + publisher: book.publisher, + isbn: book.isbn, + thumbnail: book.thumbnail, + }) + } catch { + openConfirm('등록 실패', '책 등록에 실패했습니다.\n잠시 후 다시 시도해주세요.', { + confirmText: '확인', + }) + } + }} + isPending={isCreating} + /> +
+ ) } diff --git a/src/shared/ui/Tabs.tsx b/src/shared/ui/Tabs.tsx index 6312562..024d9c2 100644 --- a/src/shared/ui/Tabs.tsx +++ b/src/shared/ui/Tabs.tsx @@ -26,11 +26,7 @@ const TabsSizeContext = React.createContext('small') */ function Tabs({ className, ...props }: React.ComponentProps) { return ( - + ) } @@ -44,7 +40,7 @@ function TabsList({ className, size = 'small', ...props }: TabsListProps) { From 9a9998105fae1603c81b1cd7258be6c26227dcff Mon Sep 17 00:00:00 2001 From: mgYang53 <50770004+mgYang53@users.noreply.github.com> Date: Sun, 8 Feb 2026 23:07:58 +0900 Subject: [PATCH 05/19] =?UTF-8?q?feat:=20=EB=AA=A8=EC=9E=84=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#50)=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 공유 UI 및 유틸 확장 (#50) * feat: 모임 상세 데이터 레이어 구현 (#50) * feat: 모임 상세 컴포넌트 구현 (#50) * feat: 전체 너비 레이아웃 및 라우팅 추가 (#50) * feat: 모임 상세 페이지 구현 (#50) * fix: coderabbit 리뷰 피드백 반영 (#50) --- .../gatherings/components/EmptyState.tsx | 49 +++-- .../components/GatheringBookCard.tsx | 55 ++++++ .../components/GatheringBookshelfSection.tsx | 97 ++++++++++ .../components/GatheringDetailHeader.tsx | 72 ++++++++ .../components/GatheringDetailInfo.tsx | 93 ++++++++++ .../components/GatheringMeetingCard.tsx | 171 ++++++++++++++++++ .../components/GatheringMeetingSection.tsx | 163 +++++++++++++++++ src/features/gatherings/components/index.ts | 6 + src/features/gatherings/gatherings.api.ts | 63 +++++++ .../gatherings/gatherings.endpoints.ts | 8 + src/features/gatherings/gatherings.types.ts | 124 ++++++++++++- .../gatherings/hooks/gatheringQueryKeys.ts | 11 ++ src/features/gatherings/hooks/index.ts | 4 + .../gatherings/hooks/useGatheringBooks.ts | 47 +++++ .../gatherings/hooks/useGatheringDetail.ts | 30 +++ .../gatherings/hooks/useGatheringMeetings.ts | 58 ++++++ .../gatherings/hooks/useGatherings.ts | 6 +- .../gatherings/hooks/useMeetingTabCounts.ts | 27 +++ .../gatherings/hooks/useToggleFavorite.ts | 40 +++- src/features/gatherings/index.ts | 13 ++ src/features/gatherings/lib/meetingStatus.ts | 55 ++++++ src/pages/Gatherings/GatheringDetailPage.tsx | 133 +++++++++++++- src/pages/Gatherings/GatheringListPage.tsx | 2 +- src/routes/index.tsx | 14 +- src/shared/constants/pagination.ts | 4 + src/shared/constants/routes.ts | 1 + src/shared/hooks/index.ts | 1 + src/shared/hooks/useScrollCollapse.ts | 55 ++++++ src/shared/layout/FullWidthLayout.tsx | 21 +++ src/shared/layout/components/Header.tsx | 2 +- src/shared/layout/index.ts | 1 + src/shared/lib/date.ts | 73 +++++++- src/shared/styles/theme.css | 14 ++ src/shared/ui/Badge.tsx | 1 + 34 files changed, 1477 insertions(+), 37 deletions(-) create mode 100644 src/features/gatherings/components/GatheringBookCard.tsx create mode 100644 src/features/gatherings/components/GatheringBookshelfSection.tsx create mode 100644 src/features/gatherings/components/GatheringDetailHeader.tsx create mode 100644 src/features/gatherings/components/GatheringDetailInfo.tsx create mode 100644 src/features/gatherings/components/GatheringMeetingCard.tsx create mode 100644 src/features/gatherings/components/GatheringMeetingSection.tsx create mode 100644 src/features/gatherings/hooks/useGatheringBooks.ts create mode 100644 src/features/gatherings/hooks/useGatheringDetail.ts create mode 100644 src/features/gatherings/hooks/useGatheringMeetings.ts create mode 100644 src/features/gatherings/hooks/useMeetingTabCounts.ts create mode 100644 src/features/gatherings/lib/meetingStatus.ts create mode 100644 src/shared/hooks/useScrollCollapse.ts create mode 100644 src/shared/layout/FullWidthLayout.tsx diff --git a/src/features/gatherings/components/EmptyState.tsx b/src/features/gatherings/components/EmptyState.tsx index 248dd7b..54360fa 100644 --- a/src/features/gatherings/components/EmptyState.tsx +++ b/src/features/gatherings/components/EmptyState.tsx @@ -1,25 +1,42 @@ +type EmptyStateType = 'all' | 'favorites' | 'meetings' | 'bookshelf' + interface EmptyStateProps { - type?: 'all' | 'favorites' + type?: EmptyStateType } -export default function EmptyState({ type = 'all' }: EmptyStateProps) { - const message = - type === 'all' ? ( - <> - 아직 참여 중인 모임이 없어요. -
첫 번째 모임을 시작해 보세요! - - ) : ( - <> - 즐겨찾기한 모임이 없어요. -
- 자주 방문하는 모임을 즐겨찾기에 추가해 보세요! - - ) +const EMPTY_STATE_MESSAGES: Record = { + all: ( + <> + 아직 참여 중인 모임이 없어요. +
첫 번째 모임을 시작해 보세요! + + ), + favorites: ( + <> + 즐겨찾기한 모임이 없어요. +
+ 자주 방문하는 모임을 즐겨찾기에 추가해 보세요! + + ), + meetings: ( + <> + 등록된 약속이 없어요. +
첫 약속을 추가해보세요! + + ), + bookshelf: ( + <> + 함께 읽은 책이 없어요 +
+ 약속을 만들어 책을 추가해보세요! + + ), +} +export default function EmptyState({ type = 'all' }: EmptyStateProps) { return (
-

{message}

+

{EMPTY_STATE_MESSAGES[type]}

) } diff --git a/src/features/gatherings/components/GatheringBookCard.tsx b/src/features/gatherings/components/GatheringBookCard.tsx new file mode 100644 index 0000000..82bf955 --- /dev/null +++ b/src/features/gatherings/components/GatheringBookCard.tsx @@ -0,0 +1,55 @@ +import { Book, Star } from 'lucide-react' +import { useNavigate } from 'react-router-dom' + +import { ROUTES } from '@/shared/constants' + +import type { GatheringBookItem } from '../gatherings.types' + +interface GatheringBookCardProps { + book: GatheringBookItem +} + +export default function GatheringBookCard({ book }: GatheringBookCardProps) { + const navigate = useNavigate() + + const { bookId, bookName, author, thumbnail, ratingAverage } = book + + const handleClick = () => { + navigate(ROUTES.BOOK_DETAIL(bookId)) + } + + return ( +
+ {/* 책 커버 */} +
+ {thumbnail ? ( + {bookName} + ) : ( +
+ +
+ )} +
+ + {/* 책 정보 */} +
+ {/* 제목 + 저자 */} +
+

{bookName}

+

{author}

+
+ + {/* 모임 평균 평점 */} + {ratingAverage !== null && ( +
+ 모임 평균 +
+ + {ratingAverage.toFixed(1)} +
+
+ )} +
+
+ ) +} diff --git a/src/features/gatherings/components/GatheringBookshelfSection.tsx b/src/features/gatherings/components/GatheringBookshelfSection.tsx new file mode 100644 index 0000000..19b3b34 --- /dev/null +++ b/src/features/gatherings/components/GatheringBookshelfSection.tsx @@ -0,0 +1,97 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { useRef } from 'react' + +import { useGatheringBooks } from '../hooks/useGatheringBooks' +import EmptyState from './EmptyState' +import GatheringBookCard from './GatheringBookCard' + +interface GatheringBookshelfSectionProps { + gatheringId: number +} + +/** 캐러셀 스크롤 이동량 (픽셀) */ +const SCROLL_AMOUNT = 300 + +export default function GatheringBookshelfSection({ gatheringId }: GatheringBookshelfSectionProps) { + const scrollContainerRef = useRef(null) + const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useGatheringBooks({ + gatheringId, + size: 20, + }) + + const books = data?.pages.flatMap((page) => page.items) ?? [] + const hasBooks = books.length > 0 + + const handleScrollLeft = () => { + scrollContainerRef.current?.scrollBy({ left: -SCROLL_AMOUNT, behavior: 'smooth' }) + } + + const handleScrollRight = async () => { + const container = scrollContainerRef.current + if (!container) return + + // 스크롤 끝에 도달했는지 확인 (여유값 10px) + const isAtEnd = container.scrollLeft + container.clientWidth >= container.scrollWidth - 10 + + if (isAtEnd && hasNextPage && !isFetchingNextPage) { + await fetchNextPage() + } + + container.scrollBy({ left: SCROLL_AMOUNT, behavior: 'smooth' }) + } + + if (isLoading) { + return ( +
+

모임 책장

+
로딩 중...
+
+ ) + } + + return ( +
+ {/* 헤더: 제목 + 좌우 화살표 (데이터 있을 때만) */} +
+

모임 책장

+ {hasBooks && ( +
+ + +
+ )} +
+ + {/* 책 목록 또는 Empty State */} + {hasBooks ? ( +
+ {books.map((book) => ( +
+ +
+ ))} +
+ ) : ( + + )} +
+ ) +} diff --git a/src/features/gatherings/components/GatheringDetailHeader.tsx b/src/features/gatherings/components/GatheringDetailHeader.tsx new file mode 100644 index 0000000..f54312d --- /dev/null +++ b/src/features/gatherings/components/GatheringDetailHeader.tsx @@ -0,0 +1,72 @@ +import { Link2, Settings, Star } from 'lucide-react' + +import { cn } from '@/shared/lib/utils' +import { TextButton } from '@/shared/ui' + +import type { GatheringUserRole } from '../gatherings.types' + +interface GatheringDetailHeaderProps { + gatheringName: string + isFavorite: boolean + currentUserRole: GatheringUserRole + /** 스크롤되어 헤더가 고정된 상태인지 */ + isSticky?: boolean + onFavoriteToggle: () => void + onSettingsClick: () => void + onInviteClick: () => void +} + +export default function GatheringDetailHeader({ + gatheringName, + isFavorite, + currentUserRole, + isSticky = false, + onFavoriteToggle, + onSettingsClick, + onInviteClick, +}: GatheringDetailHeaderProps) { + const isLeader = currentUserRole === 'LEADER' + + return ( +
+
+ {/* 좌측: 모임명 + 즐겨찾기 */} +
+

{gatheringName}

+ +
+ + {/* 우측: 설정/초대링크 버튼 */} +
+ {/* 모임장 전용 - 설정 버튼 */} + {isLeader && ( + + 설정 + + )} + {/* 초대링크 버튼 */} + + 초대링크 + +
+
+
+ ) +} diff --git a/src/features/gatherings/components/GatheringDetailInfo.tsx b/src/features/gatherings/components/GatheringDetailInfo.tsx new file mode 100644 index 0000000..7e760f2 --- /dev/null +++ b/src/features/gatherings/components/GatheringDetailInfo.tsx @@ -0,0 +1,93 @@ +import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarImage } from '@/shared/ui' + +import type { GatheringMember } from '../gatherings.types' + +interface GatheringDetailInfoProps { + daysFromCreation: number + totalMeetings: number + totalMembers: number + members: GatheringMember[] + description: string | null +} + +/** 멤버 아바타 그룹에 표시할 최대 인원 수 */ +const MAX_VISIBLE_MEMBERS = 5 + +export default function GatheringDetailInfo({ + daysFromCreation, + totalMeetings, + totalMembers, + members, + description, +}: GatheringDetailInfoProps) { + const leader = members.find((m) => m.role === 'LEADER') + const otherMembers = members.filter((m) => m.role !== 'LEADER') + const visibleMembers = otherMembers.slice(0, MAX_VISIBLE_MEMBERS) + const remainingCount = totalMembers - 1 - visibleMembers.length + + return ( +
+ {/* 첫 번째 줄: 통계 정보 (좌측) + 모임장 (우측) */} +
+ {/* 통계 정보 */} +
+ 시작한지 {daysFromCreation}일 + + 약속 {totalMeetings}회 + + 총 구성원 {totalMembers}명 +
+ + {/* 모임장 + 멤버 그룹 */} +
+ {/* 모임장 */} + {leader && ( +
+ 모임장 + + + {leader.nickname.slice(0, 1)} + +
+ )} + + {/* 멤버 아바타 그룹 (멤버가 있을 때만) */} + {otherMembers.length > 0 && ( + + {visibleMembers.map((member) => ( + + + {member.nickname.slice(0, 1)} + + ))} + {remainingCount > 0 && ( + ({ + id: String(m.gatheringMemberId), + name: m.nickname, + src: m.profileImageUrl ?? undefined, + }))} + preview={ + otherMembers[MAX_VISIBLE_MEMBERS] + ? { + name: otherMembers[MAX_VISIBLE_MEMBERS].nickname, + src: otherMembers[MAX_VISIBLE_MEMBERS].profileImageUrl ?? undefined, + } + : undefined + } + > + +{remainingCount} + + )} + + )} +
+
+ + {/* 두 번째 줄: 모임 설명 */} + {description && ( +

{description}

+ )} +
+ ) +} diff --git a/src/features/gatherings/components/GatheringMeetingCard.tsx b/src/features/gatherings/components/GatheringMeetingCard.tsx new file mode 100644 index 0000000..89107ba --- /dev/null +++ b/src/features/gatherings/components/GatheringMeetingCard.tsx @@ -0,0 +1,171 @@ +import { BarChart3, FileQuestion, NotebookPen } from 'lucide-react' +import { useNavigate } from 'react-router-dom' + +import { ROUTES } from '@/shared/constants/routes' +import { formatToDateTimeRange, getDdayText } from '@/shared/lib/date' +import { cn } from '@/shared/lib/utils' +import { Badge } from '@/shared/ui/Badge' + +import type { GatheringMeetingItem } from '../gatherings.types' +import { getMeetingDisplayStatus } from '../lib/meetingStatus' + +interface GatheringMeetingCardProps { + meeting: GatheringMeetingItem + /** 모임 ID */ + gatheringId: number + /** 약속장 여부 */ + isHost?: boolean +} + +export default function GatheringMeetingCard({ + meeting, + gatheringId, + isHost = false, +}: GatheringMeetingCardProps) { + const navigate = useNavigate() + + const { meetingId, meetingName, bookName, startDateTime, endDateTime } = meeting + + const status = getMeetingDisplayStatus(startDateTime, endDateTime) + const ddayText = getDdayText(startDateTime, endDateTime) + const formattedDate = formatToDateTimeRange(startDateTime, endDateTime) + + const isOngoing = status === 'IN_PROGRESS' + + const handleClick = () => { + navigate(ROUTES.MEETING_DETAIL(gatheringId, meetingId)) + } + + // 사전답변, 약속회고, 개인회고 버튼 핸들러 + const handlePreAnswerClick = (e: React.MouseEvent) => { + e.stopPropagation() + // TODO: 사전답변 작성/조회 페이지로 이동 + console.log('사전답변 클릭') + } + + const handleMeetingReviewClick = (e: React.MouseEvent) => { + e.stopPropagation() + // TODO: 약속회고 페이지로 이동 + console.log('약속회고 클릭') + } + + const handlePersonalReviewClick = (e: React.MouseEvent) => { + e.stopPropagation() + // TODO: 개인회고 작성/조회 페이지로 이동 + console.log('개인회고 클릭') + } + + // 약속 중 카드 (빨간 배경) + if (isOngoing) { + return ( +
+
+ {/* 상태 배지 - Badge 컴포넌트는 red color가 accent-200/300 사용 */} + + 약속 중 + + + {/* 약속 정보 */} +
+
+
+ {meetingName} + | + {bookName} +
+ {isHost && ( + + 약속장 + + )} +
+ {formattedDate} +
+
+
+ ) + } + + // 예정/종료 카드 + const isUpcoming = status === 'UPCOMING' + const statusLabel = isUpcoming ? '예정' : '종료' + const badgeColor = isUpcoming ? 'yellow' : 'grey' + const ddayColor = isUpcoming ? 'text-yellow-300' : 'text-grey-600' + + return ( +
+ {/* 좌측: 배지 + 정보 */} +
+ {/* 상태 배지 */} + + {statusLabel} + + + {/* 약속 정보 */} +
+
+
+ {meetingName} + | + {bookName} +
+ {isHost && ( + + 약속장 + + )} +
+
+ {ddayText && ( + {ddayText} + )} + {formattedDate} +
+
+
+ + {/* 우측: 사전답변, 약속회고, 개인회고 버튼 */} +
+ {/* 사전답변 */} + + +
+ + {/* 약속회고 */} + + +
+ + {/* 개인회고 */} + +
+
+ ) +} diff --git a/src/features/gatherings/components/GatheringMeetingSection.tsx b/src/features/gatherings/components/GatheringMeetingSection.tsx new file mode 100644 index 0000000..bd1a19a --- /dev/null +++ b/src/features/gatherings/components/GatheringMeetingSection.tsx @@ -0,0 +1,163 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { useUserProfile } from '@/features/user' +import { PAGE_SIZES, ROUTES } from '@/shared/constants' +import { Button, Pagination, Tabs, TabsList, TabsTrigger } from '@/shared/ui' + +import type { GatheringUserRole, MeetingFilter } from '../gatherings.types' +import { useGatheringMeetings, useMeetingTabCounts } from '../hooks' +import { sortMeetings } from '../lib/meetingStatus' +import EmptyState from './EmptyState' +import GatheringMeetingCard from './GatheringMeetingCard' + +interface GatheringMeetingSectionProps { + gatheringId: number + currentUserRole: GatheringUserRole +} + +/** 탭 필터 목록 */ +const TAB_FILTERS: MeetingFilter[] = ['ALL', 'UPCOMING', 'DONE', 'JOINED'] + +const FILTER_LABELS: Record = { + ALL: '전체 약속', + UPCOMING: '예정된 약속', + DONE: '종료한 약속', + JOINED: '내가 참여한 약속', +} + +export default function GatheringMeetingSection({ + gatheringId, + currentUserRole, +}: GatheringMeetingSectionProps) { + const navigate = useNavigate() + const [activeTab, setActiveTab] = useState('ALL') + const [currentPage, setCurrentPage] = useState(0) + + const isLeader = currentUserRole === 'LEADER' + + // 현재 사용자 정보 + const { data: currentUser } = useUserProfile() + const currentUserNickname = currentUser?.nickname ?? '' + + // 탭별 카운트 조회 (서버 API) + const { data: tabCounts } = useMeetingTabCounts(gatheringId) + + // 약속 목록 조회 (서버 페이지네이션) + const { data, isLoading } = useGatheringMeetings({ + gatheringId, + filter: activeTab, + page: currentPage, + size: PAGE_SIZES.GATHERING_MEETINGS, + }) + + // 서버에서 받은 데이터 + const meetings = data?.items ?? [] + const totalPages = data?.totalPages ?? 0 + const totalCount = data?.totalCount ?? 0 + + // 정렬: 진행 중 → 예정 → 종료 (sortMeetings에서 처리) + const displayMeetings = sortMeetings(meetings) + + // 탭 변경 시 페이지 초기화 + const handleTabChange = (filter: MeetingFilter) => { + setActiveTab(filter) + setCurrentPage(0) + } + + // 약속 설정 버튼 핸들러 + const handleMeetingSettings = () => { + navigate(ROUTES.MEETING_SETTING(gatheringId)) + } + + // 약속 만들기 버튼 핸들러 + const handleCreateMeeting = () => { + navigate(ROUTES.MEETING_CREATE(gatheringId)) + } + + // 탭별 카운트 (서버 데이터 또는 0) + const getTabCount = (filter: MeetingFilter): number => { + if (!tabCounts) return 0 + switch (filter) { + case 'ALL': + return tabCounts.all + case 'UPCOMING': + return tabCounts.upcoming + case 'DONE': + return tabCounts.done + case 'JOINED': + return tabCounts.joined + } + } + + return ( +
+ {/* 섹션 헤더: 약속 + 탭들 + 버튼들 (한 줄) */} +
+
+ {/* 섹션 제목 */} +

약속

+ + {/* 탭들 */} + handleTabChange(value as MeetingFilter)} + > + + {TAB_FILTERS.map((filter) => ( + + {FILTER_LABELS[filter]} + + ))} + + +
+ + {/* 버튼들 (모임장만) */} + {isLeader && ( +
+ + +
+ )} +
+ + {/* 약속 목록 */} + {isLoading ? ( +
로딩 중...
+ ) : totalCount === 0 ? ( + + ) : ( +
+ {displayMeetings.map((meeting) => ( + + ))} +
+ )} + + {/* 페이지네이션 */} + {totalPages > 1 && ( + + )} +
+ ) +} diff --git a/src/features/gatherings/components/index.ts b/src/features/gatherings/components/index.ts index 4ea95ab..43695be 100644 --- a/src/features/gatherings/components/index.ts +++ b/src/features/gatherings/components/index.ts @@ -1,2 +1,8 @@ export { default as EmptyState } from './EmptyState' +export { default as GatheringBookCard } from './GatheringBookCard' +export { default as GatheringBookshelfSection } from './GatheringBookshelfSection' export { default as GatheringCard } from './GatheringCard' +export { default as GatheringDetailHeader } from './GatheringDetailHeader' +export { default as GatheringDetailInfo } from './GatheringDetailInfo' +export { default as GatheringMeetingCard } from './GatheringMeetingCard' +export { default as GatheringMeetingSection } from './GatheringMeetingSection' diff --git a/src/features/gatherings/gatherings.api.ts b/src/features/gatherings/gatherings.api.ts index 06a6c0a..40c728c 100644 --- a/src/features/gatherings/gatherings.api.ts +++ b/src/features/gatherings/gatherings.api.ts @@ -5,10 +5,16 @@ import type { CreateGatheringRequest, CreateGatheringResponse, FavoriteGatheringListResponse, + GatheringBookListResponse, GatheringByInviteCodeResponse, + GatheringDetailResponse, GatheringJoinResponse, GatheringListResponse, + GatheringMeetingListResponse, + GetGatheringBooksParams, + GetGatheringMeetingsParams, GetGatheringsParams, + MeetingTabCountsResponse, } from './gatherings.types' /** @@ -95,3 +101,60 @@ export const toggleFavorite = async (gatheringId: number) => { ) return response.data } + +/** + * 모임 상세 조회 + * + * @param gatheringId - 모임 ID + * @returns 모임 상세 정보 + */ +export const getGatheringDetail = async (gatheringId: number) => { + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.DETAIL(gatheringId) + ) + return response.data +} + +/** + * 모임 약속 목록 조회 (페이지 기반) + * + * @param params - 조회 파라미터 + * @returns 약속 목록 및 페이지네이션 정보 + */ +export const getGatheringMeetings = async (params: GetGatheringMeetingsParams) => { + const { gatheringId, ...queryParams } = params + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.MEETINGS(gatheringId), + { params: queryParams } + ) + return response.data +} + +/** + * 모임 책장 조회 (페이지 기반) + * + * @param params - 조회 파라미터 + * @returns 책 목록 및 페이지네이션 정보 + */ +export const getGatheringBooks = async (params: GetGatheringBooksParams) => { + const { gatheringId, ...queryParams } = params + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.BOOKS(gatheringId), + { params: queryParams } + ) + return response.data +} + +/** + * 모임 약속 탭별 카운트 조회 + * + * @param gatheringId - 모임 ID + * @returns 탭별 약속 카운트 + */ +export const getMeetingTabCounts = async (gatheringId: number) => { + const response = await apiClient.get>( + GATHERINGS_ENDPOINTS.MEETING_TAB_COUNTS, + { params: { gatheringId } } + ) + return response.data +} diff --git a/src/features/gatherings/gatherings.endpoints.ts b/src/features/gatherings/gatherings.endpoints.ts index d491a03..e85c4db 100644 --- a/src/features/gatherings/gatherings.endpoints.ts +++ b/src/features/gatherings/gatherings.endpoints.ts @@ -3,6 +3,8 @@ import { API_PATHS } from '@/api' export const GATHERINGS_ENDPOINTS = { /** 모임 목록/생성 */ BASE: API_PATHS.GATHERINGS, + /** 모임 상세 조회 */ + DETAIL: (gatheringId: number) => `${API_PATHS.GATHERINGS}/${gatheringId}`, /** 즐겨찾기 모임 목록 조회 */ FAVORITES: `${API_PATHS.GATHERINGS}/favorites`, /** 즐겨찾기 토글 */ @@ -10,4 +12,10 @@ export const GATHERINGS_ENDPOINTS = { /** 초대 코드로 모임 정보 조회 / 가입 신청 */ JOIN_REQUEST: (invitationCode: string) => `${API_PATHS.GATHERINGS}/join-request/${invitationCode}`, + /** 모임 약속 목록 조회 */ + MEETINGS: (gatheringId: number) => `${API_PATHS.GATHERINGS}/${gatheringId}/meetings`, + /** 모임 책장 조회 */ + BOOKS: (gatheringId: number) => `${API_PATHS.GATHERINGS}/${gatheringId}/books`, + /** 약속 탭별 카운트 조회 */ + MEETING_TAB_COUNTS: `${API_PATHS.MEETINGS}/tab-counts`, } as const diff --git a/src/features/gatherings/gatherings.types.ts b/src/features/gatherings/gatherings.types.ts index 452d0ec..c1390fb 100644 --- a/src/features/gatherings/gatherings.types.ts +++ b/src/features/gatherings/gatherings.types.ts @@ -1,4 +1,5 @@ -import type { CursorPaginatedResponse } from '@/api' +import type { CursorPaginatedResponse, PaginatedResponse } from '@/api' +import type { MeetingStatus } from '@/features/meetings' /** 모임 기본 정보 (공통) */ export interface GatheringBase { @@ -96,3 +97,124 @@ export interface GetGatheringsParams { /** 커서 - 마지막 항목의 ID */ cursorId?: number } + +// ========== 모임 상세 조회 ========== + +/** 모임 멤버 정보 */ +export interface GatheringMember { + /** 모임 멤버 ID */ + gatheringMemberId: number + /** 사용자 ID */ + userId: number + /** 닉네임 */ + nickname: string + /** 프로필 이미지 URL */ + profileImageUrl: string | null + /** 역할 */ + role: GatheringUserRole +} + +/** 모임 상세 응답 */ +export interface GatheringDetailResponse { + /** 모임 ID */ + gatheringId: number + /** 모임 이름 */ + gatheringName: string + /** 모임 설명 */ + description: string | null + /** 모임 상태 */ + gatheringStatus: GatheringStatus + /** 즐겨찾기 여부 */ + isFavorite: boolean + /** 초대 링크 (초대 코드) */ + invitationLink: string + /** 모임 생성 후 경과 일수 */ + daysFromCreation: number + /** 현재 사용자 역할 */ + currentUserRole: GatheringUserRole + /** 멤버 목록 */ + members: GatheringMember[] + /** 전체 멤버 수 */ + totalMembers: number + /** 전체 약속 수 */ + totalMeetings: number +} + +// ========== 모임 약속 목록 ========== + +/** 약속 필터 타입 */ +export type MeetingFilter = 'ALL' | 'UPCOMING' | 'DONE' | 'JOINED' + +/** 모임 약속 아이템 */ +export interface GatheringMeetingItem { + /** 약속 ID */ + meetingId: number + /** 약속 이름 */ + meetingName: string + /** 약속장 이름 */ + meetingLeaderName: string + /** 책 이름 */ + bookName: string + /** 시작 일시 (ISO 8601) */ + startDateTime: string + /** 종료 일시 (ISO 8601) */ + endDateTime: string + /** 약속 상태 */ + meetingStatus: MeetingStatus +} + +/** 모임 약속 목록 응답 (페이지 기반) */ +export type GatheringMeetingListResponse = PaginatedResponse + +/** 모임 약속 목록 조회 파라미터 */ +export interface GetGatheringMeetingsParams { + /** 모임 ID */ + gatheringId: number + /** 필터 */ + filter?: MeetingFilter + /** 페이지 번호 (0부터 시작) */ + page?: number + /** 페이지 크기 */ + size?: number +} + +/** 약속 탭별 카운트 응답 */ +export interface MeetingTabCountsResponse { + /** 전체 확정된 약속 수 */ + all: number + /** 다가오는 약속 수 (3일 이내) */ + upcoming: number + /** 완료된 약속 수 */ + done: number + /** 내가 참여한 완료 약속 수 */ + joined: number +} + +// ========== 모임 책장 ========== + +/** 모임 책장 아이템 */ +export interface GatheringBookItem { + /** 책 ID */ + bookId: number + /** 책 이름 */ + bookName: string + /** 저자 */ + author: string + /** 책 표지 URL */ + thumbnail: string | null + /** 평균 평점 (없으면 null) */ + ratingAverage: number | null +} + +/** 모임 책장 응답 (페이지 기반) */ +export type GatheringBookListResponse = PaginatedResponse + +/** 모임 책장 조회 파라미터 */ +export interface GetGatheringBooksParams { + /** 모임 ID */ + gatheringId: number + /** 페이지 번호 (0부터 시작) */ + page?: number + /** 페이지 크기 */ + size?: number +} diff --git a/src/features/gatherings/hooks/gatheringQueryKeys.ts b/src/features/gatherings/hooks/gatheringQueryKeys.ts index c3100b9..3c85d0f 100644 --- a/src/features/gatherings/hooks/gatheringQueryKeys.ts +++ b/src/features/gatherings/hooks/gatheringQueryKeys.ts @@ -1,3 +1,5 @@ +import type { MeetingFilter } from '../gatherings.types' + /** * 모임 관련 Query Key를 일관되게 관리하기 위한 팩토리 함수 */ @@ -8,4 +10,13 @@ export const gatheringQueryKeys = { detail: (id: number | string) => [...gatheringQueryKeys.all, 'detail', id] as const, byInviteCode: (invitationCode: string) => [...gatheringQueryKeys.all, 'invite', invitationCode] as const, + // 모임 약속 관련 키 + meetings: (gatheringId: number) => + [...gatheringQueryKeys.detail(gatheringId), 'meetings'] as const, + meetingsByFilter: (gatheringId: number, filter: MeetingFilter) => + [...gatheringQueryKeys.meetings(gatheringId), filter] as const, + meetingTabCounts: (gatheringId: number) => + [...gatheringQueryKeys.meetings(gatheringId), 'tabCounts'] as const, + // 모임 책장 관련 키 + books: (gatheringId: number) => [...gatheringQueryKeys.detail(gatheringId), 'books'] as const, } as const diff --git a/src/features/gatherings/hooks/index.ts b/src/features/gatherings/hooks/index.ts index 11c588f..1062003 100644 --- a/src/features/gatherings/hooks/index.ts +++ b/src/features/gatherings/hooks/index.ts @@ -1,7 +1,11 @@ export * from './gatheringQueryKeys' export * from './useCreateGathering' export * from './useFavoriteGatherings' +export * from './useGatheringBooks' export * from './useGatheringByInviteCode' +export * from './useGatheringDetail' +export * from './useGatheringMeetings' export * from './useGatherings' export * from './useJoinGathering' +export * from './useMeetingTabCounts' export * from './useToggleFavorite' diff --git a/src/features/gatherings/hooks/useGatheringBooks.ts b/src/features/gatherings/hooks/useGatheringBooks.ts new file mode 100644 index 0000000..0d25378 --- /dev/null +++ b/src/features/gatherings/hooks/useGatheringBooks.ts @@ -0,0 +1,47 @@ +import { useInfiniteQuery } from '@tanstack/react-query' + +import { PAGE_SIZES } from '@/shared/constants' + +import { getGatheringBooks } from '../gatherings.api' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +interface UseGatheringBooksOptions { + gatheringId: number + size?: number +} + +/** + * 모임 책장 조회 훅 (무한 스크롤) + * + * @param options - 조회 옵션 + * @param options.gatheringId - 모임 ID + * @param options.size - 페이지 크기 (기본: 12) + * @returns 책장 목록 무한 쿼리 결과 + * + * @example + * ```tsx + * const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useGatheringBooks({ + * gatheringId: 1, + * }) + * + * const books = data?.pages.flatMap(page => page.items) ?? [] + * ``` + */ +export const useGatheringBooks = ({ + gatheringId, + size = PAGE_SIZES.GATHERING_BOOKS, +}: UseGatheringBooksOptions) => { + return useInfiniteQuery({ + queryKey: [...gatheringQueryKeys.books(gatheringId), size], + queryFn: async ({ pageParam }) => { + const response = await getGatheringBooks({ gatheringId, page: pageParam, size }) + return response.data + }, + initialPageParam: 0, + getNextPageParam: (lastPage) => { + if (lastPage.currentPage >= lastPage.totalPages - 1) return undefined + return lastPage.currentPage + 1 + }, + enabled: gatheringId > 0, + }) +} diff --git a/src/features/gatherings/hooks/useGatheringDetail.ts b/src/features/gatherings/hooks/useGatheringDetail.ts new file mode 100644 index 0000000..df3fb53 --- /dev/null +++ b/src/features/gatherings/hooks/useGatheringDetail.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query' + +import { getGatheringDetail } from '../gatherings.api' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** + * 모임 상세 정보 조회 훅 + * + * @param gatheringId - 모임 ID + * @returns 모임 상세 정보 쿼리 결과 + * + * @example + * ```tsx + * const { data, isLoading, error } = useGatheringDetail(gatheringId) + * + * if (data) { + * const { gatheringName, currentUserRole, members } = data + * } + * ``` + */ +export const useGatheringDetail = (gatheringId: number) => { + return useQuery({ + queryKey: gatheringQueryKeys.detail(gatheringId), + queryFn: async () => { + const response = await getGatheringDetail(gatheringId) + return response.data + }, + enabled: gatheringId > 0, + }) +} diff --git a/src/features/gatherings/hooks/useGatheringMeetings.ts b/src/features/gatherings/hooks/useGatheringMeetings.ts new file mode 100644 index 0000000..4664651 --- /dev/null +++ b/src/features/gatherings/hooks/useGatheringMeetings.ts @@ -0,0 +1,58 @@ +import { useQuery } from '@tanstack/react-query' + +import { PAGE_SIZES } from '@/shared/constants' + +import { getGatheringMeetings } from '../gatherings.api' +import type { MeetingFilter } from '../gatherings.types' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +interface UseGatheringMeetingsOptions { + gatheringId: number + filter?: MeetingFilter + page?: number + size?: number +} + +/** + * 모임 약속 목록 조회 훅 (서버 페이지네이션) + * + * @param options - 조회 옵션 + * @param options.gatheringId - 모임 ID + * @param options.filter - 필터 (기본: ALL) + * @param options.page - 페이지 번호 (0부터 시작, 기본: 0) + * @param options.size - 페이지 크기 + * @returns 약속 목록 쿼리 결과 + * + * @example + * ```tsx + * const { data, isLoading } = useGatheringMeetings({ + * gatheringId: 1, + * filter: 'UPCOMING', + * page: 0, + * size: 5, + * }) + * + * const meetings = data?.items ?? [] + * const totalPages = data?.totalPages ?? 0 + * ``` + */ +export const useGatheringMeetings = ({ + gatheringId, + filter = 'ALL', + page = 0, + size = PAGE_SIZES.GATHERING_MEETINGS, +}: UseGatheringMeetingsOptions) => { + return useQuery({ + queryKey: [...gatheringQueryKeys.meetingsByFilter(gatheringId, filter), page, size], + queryFn: async () => { + const response = await getGatheringMeetings({ + gatheringId, + filter, + page, + size, + }) + return response.data + }, + enabled: gatheringId > 0, + }) +} diff --git a/src/features/gatherings/hooks/useGatherings.ts b/src/features/gatherings/hooks/useGatherings.ts index 4489127..d962396 100644 --- a/src/features/gatherings/hooks/useGatherings.ts +++ b/src/features/gatherings/hooks/useGatherings.ts @@ -35,7 +35,7 @@ export const useGatherings = () => { PageParam >({ queryKey: gatheringQueryKeys.lists(), - queryFn: async ({ pageParam }) => { + queryFn: async ({ pageParam }: { pageParam: PageParam }) => { const isFirstPage = !pageParam const response = await getGatherings({ pageSize: isFirstPage ? INITIAL_PAGE_SIZE : NEXT_PAGE_SIZE, @@ -44,8 +44,8 @@ export const useGatherings = () => { }) return response.data }, - initialPageParam: undefined, - getNextPageParam: (lastPage): PageParam => { + initialPageParam: undefined as PageParam, + getNextPageParam: (lastPage: GatheringListResponse): PageParam => { if (!lastPage.hasNext || !lastPage.nextCursor) return undefined return lastPage.nextCursor }, diff --git a/src/features/gatherings/hooks/useMeetingTabCounts.ts b/src/features/gatherings/hooks/useMeetingTabCounts.ts new file mode 100644 index 0000000..8556d31 --- /dev/null +++ b/src/features/gatherings/hooks/useMeetingTabCounts.ts @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query' + +import { getMeetingTabCounts } from '../gatherings.api' +import { gatheringQueryKeys } from './gatheringQueryKeys' + +/** + * 모임 약속 탭별 카운트 조회 훅 + * + * @param gatheringId - 모임 ID + * @returns 탭별 약속 카운트 + * + * @example + * ```tsx + * const { data: tabCounts } = useMeetingTabCounts(gatheringId) + * // tabCounts?.all, tabCounts?.upcoming, tabCounts?.done, tabCounts?.joined + * ``` + */ +export const useMeetingTabCounts = (gatheringId: number) => { + return useQuery({ + queryKey: gatheringQueryKeys.meetingTabCounts(gatheringId), + queryFn: async () => { + const response = await getMeetingTabCounts(gatheringId) + return response.data + }, + enabled: gatheringId > 0, + }) +} diff --git a/src/features/gatherings/hooks/useToggleFavorite.ts b/src/features/gatherings/hooks/useToggleFavorite.ts index 719121b..d793964 100644 --- a/src/features/gatherings/hooks/useToggleFavorite.ts +++ b/src/features/gatherings/hooks/useToggleFavorite.ts @@ -1,9 +1,13 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query' +import { type InfiniteData, useMutation, useQueryClient } from '@tanstack/react-query' import type { ApiError } from '@/api' import { toggleFavorite } from '../gatherings.api' -import type { FavoriteGatheringListResponse, GatheringListResponse } from '../gatherings.types' +import type { + FavoriteGatheringListResponse, + GatheringDetailResponse, + GatheringListResponse, +} from '../gatherings.types' import { gatheringQueryKeys } from './gatheringQueryKeys' /** @@ -15,6 +19,7 @@ import { gatheringQueryKeys } from './gatheringQueryKeys' interface ToggleFavoriteContext { previousLists: unknown previousFavorites: unknown + previousDetail: unknown } export const useToggleFavorite = () => { @@ -26,15 +31,19 @@ export const useToggleFavorite = () => { }, onMutate: async (gatheringId) => { // 진행 중인 쿼리 취소 - await queryClient.cancelQueries({ queryKey: gatheringQueryKeys.lists() }) - await queryClient.cancelQueries({ queryKey: gatheringQueryKeys.favorites() }) + await Promise.all([ + queryClient.cancelQueries({ queryKey: gatheringQueryKeys.lists() }), + queryClient.cancelQueries({ queryKey: gatheringQueryKeys.favorites() }), + queryClient.cancelQueries({ queryKey: gatheringQueryKeys.detail(gatheringId) }), + ]) // 이전 데이터 스냅샷 const previousLists = queryClient.getQueryData(gatheringQueryKeys.lists()) const previousFavorites = queryClient.getQueryData(gatheringQueryKeys.favorites()) + const previousDetail = queryClient.getQueryData(gatheringQueryKeys.detail(gatheringId)) // Optimistic update - 목록에서 isFavorite 토글 - queryClient.setQueryData<{ pages: GatheringListResponse[]; pageParams: unknown[] }>( + queryClient.setQueryData>( gatheringQueryKeys.lists(), (old) => { if (!old) return old @@ -67,10 +76,19 @@ export const useToggleFavorite = () => { } ) - return { previousLists, previousFavorites } + // Optimistic update - 상세 페이지 + queryClient.setQueryData( + gatheringQueryKeys.detail(gatheringId), + (old) => { + if (!old) return old + return { ...old, isFavorite: !old.isFavorite } + } + ) + + return { previousLists, previousFavorites, previousDetail } }, - onError: (error, id, context) => { - console.error('Failed to toggle favorite:', { error, gatheringId: id }) + onError: (error, gatheringId, context) => { + console.error('Failed to toggle favorite:', { error, gatheringId }) // 에러 시 롤백 if (context?.previousLists) { queryClient.setQueryData(gatheringQueryKeys.lists(), context.previousLists) @@ -78,10 +96,14 @@ export const useToggleFavorite = () => { if (context?.previousFavorites) { queryClient.setQueryData(gatheringQueryKeys.favorites(), context.previousFavorites) } + if (context?.previousDetail) { + queryClient.setQueryData(gatheringQueryKeys.detail(gatheringId), context.previousDetail) + } }, - onSettled: () => { + onSettled: (_data, _error, gatheringId) => { // 즐겨찾기 목록만 최신 데이터로 갱신 (전체 목록은 optimistic update로 충분) queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.favorites() }) + queryClient.invalidateQueries({ queryKey: gatheringQueryKeys.detail(gatheringId) }) }, }) } diff --git a/src/features/gatherings/index.ts b/src/features/gatherings/index.ts index 368e484..f1b27ed 100644 --- a/src/features/gatherings/index.ts +++ b/src/features/gatherings/index.ts @@ -7,19 +7,32 @@ export * from './components' // API export * from './gatherings.api' +// Lib +export * from './lib/meetingStatus' + // Types export type { CreateGatheringRequest, CreateGatheringResponse, FavoriteGatheringListResponse, GatheringBase, + GatheringBookItem, + GatheringBookListResponse, GatheringByInviteCodeResponse, GatheringCursor, + GatheringDetailResponse, GatheringJoinResponse, GatheringListItem, GatheringListResponse, + GatheringMeetingItem, + GatheringMeetingListResponse, + GatheringMember, GatheringMemberStatus, GatheringStatus, GatheringUserRole, + GetGatheringBooksParams, + GetGatheringMeetingsParams, GetGatheringsParams, + MeetingFilter, + MeetingTabCountsResponse, } from './gatherings.types' diff --git a/src/features/gatherings/lib/meetingStatus.ts b/src/features/gatherings/lib/meetingStatus.ts new file mode 100644 index 0000000..306c566 --- /dev/null +++ b/src/features/gatherings/lib/meetingStatus.ts @@ -0,0 +1,55 @@ +import type { GatheringMeetingItem } from '../gatherings.types' + +/** 약속 표시 상태 타입 */ +export type MeetingDisplayStatus = 'UPCOMING' | 'IN_PROGRESS' | 'DONE' + +/** + * 약속 시간 기반 상태 계산 + */ +export const getMeetingDisplayStatus = ( + startDateTime: string, + endDateTime: string +): MeetingDisplayStatus => { + const now = new Date() + const start = new Date(startDateTime) + const end = new Date(endDateTime) + + if (now < start) return 'UPCOMING' + if (now >= start && now <= end) return 'IN_PROGRESS' + return 'DONE' +} + +/** + * 약속 정렬 함수 + * 1. 약속 중 → 최상단 + * 2. 예정 → 오늘 기준 가까운 순 + * 3. 종료 → 오늘 기준 가까운 순 + */ +export const sortMeetings = (meetings: GatheringMeetingItem[]): GatheringMeetingItem[] => { + return [...meetings].sort((a, b) => { + const statusA = getMeetingDisplayStatus(a.startDateTime, a.endDateTime) + const statusB = getMeetingDisplayStatus(b.startDateTime, b.endDateTime) + + // 약속 중이 최상단 + if (statusA === 'IN_PROGRESS' && statusB !== 'IN_PROGRESS') return -1 + if (statusA !== 'IN_PROGRESS' && statusB === 'IN_PROGRESS') return 1 + + // 예정 > 종료 순서 + if (statusA === 'UPCOMING' && statusB === 'DONE') return -1 + if (statusA === 'DONE' && statusB === 'UPCOMING') return 1 + + // 같은 상태 내에서 정렬 + const startA = new Date(a.startDateTime) + const startB = new Date(b.startDateTime) + + if (statusA === 'UPCOMING') { + // 예정: 가까운 미래 순 (오름차순) + return startA.getTime() - startB.getTime() + } else { + // 종료: 최근 종료 순 (내림차순) - endDateTime 기준 + const endA = new Date(a.endDateTime) + const endB = new Date(b.endDateTime) + return endB.getTime() - endA.getTime() + } + }) +} diff --git a/src/pages/Gatherings/GatheringDetailPage.tsx b/src/pages/Gatherings/GatheringDetailPage.tsx index fbae97e..36b0371 100644 --- a/src/pages/Gatherings/GatheringDetailPage.tsx +++ b/src/pages/Gatherings/GatheringDetailPage.tsx @@ -1,3 +1,134 @@ +import { useCallback, useEffect } from 'react' +import { useNavigate, useParams } from 'react-router-dom' + +import { type ApiError, ErrorCode } from '@/api' +import { + GatheringBookshelfSection, + GatheringDetailHeader, + GatheringDetailInfo, + GatheringMeetingSection, + useGatheringDetail, + useToggleFavorite, +} from '@/features/gatherings' +import { ROUTES } from '@/shared/constants' +import { useScrollCollapse } from '@/shared/hooks' +import { useGlobalModalStore } from '@/store/globalModalStore' + export default function GatheringDetailPage() { - return
Gathering Detail Page
+ const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const { openAlert, openError } = useGlobalModalStore() + + const parsedId = id ? Number(id) : NaN + const gatheringId = Number.isFinite(parsedId) ? parsedId : 0 + + // 스크롤 상태 (헤더 접힘 여부) + const isHeaderCollapsed = useScrollCollapse({ collapseThreshold: 100, expandThreshold: 20 }) + + // 모임 상세 조회 + const { data: gathering, isLoading, error } = useGatheringDetail(gatheringId) + + // 즐겨찾기 토글 + const { mutate: toggleFavorite } = useToggleFavorite() + + // 즐겨찾기 핸들러 + const handleFavoriteToggle = useCallback(() => { + if (!gathering) return + + toggleFavorite(gatheringId, { + onError: (error: ApiError) => { + if (error.is(ErrorCode.FAVORITE_LIMIT_EXCEEDED)) { + openAlert('알림', '즐겨찾기는 최대 4개까지만 등록할 수 있습니다.') + } else { + openAlert('오류', '즐겨찾기 변경에 실패했습니다.') + } + }, + }) + }, [gatheringId, gathering, toggleFavorite, openAlert]) + + // 설정 버튼 핸들러 + const handleSettingsClick = useCallback(() => { + navigate(ROUTES.GATHERING_SETTING(gatheringId)) + }, [navigate, gatheringId]) + + // 초대 링크 복사 핸들러 + const handleInviteClick = useCallback(async () => { + if (!gathering?.invitationLink) return + + try { + const inviteUrl = `${window.location.origin}/invite/${gathering.invitationLink}` + await navigator.clipboard.writeText(inviteUrl) + openAlert('알림', '초대 링크가 복사되었습니다.') + } catch { + openAlert('오류', '링크 복사에 실패했습니다.') + } + }, [gathering, openAlert]) + + // 유효하지 않은 ID 처리 + useEffect(() => { + if (gatheringId === 0) { + openError('오류', '잘못된 모임 ID입니다.', () => { + navigate(ROUTES.GATHERINGS, { replace: true }) + }) + } + }, [gatheringId, navigate, openError]) + + // API 에러 처리 + useEffect(() => { + if (error) { + openError('오류', '모임 정보를 불러오는데 실패했습니다.', () => { + // 브라우저 히스토리가 없으면 모임 목록으로 이동 + if (window.history.length > 1) { + navigate(-1) + } else { + navigate(ROUTES.GATHERINGS, { replace: true }) + } + }) + } + }, [error, navigate, openError]) + + // 로딩 상태 + if (isLoading || !gathering) { + return ( +
+

로딩 중...

+
+ ) + } + + return ( +
+ {/* 헤더 (sticky, 전체 너비) */} + + + {/* 컨텐츠 영역 (패딩 적용) */} +
+ {/* 모임 정보 (헤더 접힘 시 숨김) */} + + + {/* 약속 섹션 */} + + + {/* 모임 책장 섹션 */} + +
+
+ ) } diff --git a/src/pages/Gatherings/GatheringListPage.tsx b/src/pages/Gatherings/GatheringListPage.tsx index ecbef2d..326003a 100644 --- a/src/pages/Gatherings/GatheringListPage.tsx +++ b/src/pages/Gatherings/GatheringListPage.tsx @@ -89,7 +89,7 @@ export default function GatheringListPage() { {/* 탭 + 버튼 영역 */}
setActiveTab(value as TabValue)}> - + 전체 diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 8d08899..62d4717 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -18,7 +18,7 @@ import { RecordListPage, } from '@/pages' import { ROUTES } from '@/shared/constants' -import { AuthLayout, MainLayout, RootLayout } from '@/shared/layout' +import { AuthLayout, FullWidthLayout, MainLayout, RootLayout } from '@/shared/layout' import { PrivateRoute } from './PrivateRoute' import { PublicRoute } from './PublicRoute' @@ -92,10 +92,22 @@ export const router = createBrowserRouter([ path: ROUTES.GATHERING_CREATE, element: , }, + ], + }, + // 전체 너비 레이아웃 페이지 (GNB 있음, 컨텐츠 패딩 없음) + { + element: , + children: [ { path: `${ROUTES.GATHERINGS}/:id`, element: , }, + ], + }, + // 메인 페이지들 계속 (GNB 있음) + { + element: , + children: [ { path: `${ROUTES.GATHERINGS}/:gatheringId/meetings/:meetingId`, element: , diff --git a/src/shared/constants/pagination.ts b/src/shared/constants/pagination.ts index d1fb3f2..1929158 100644 --- a/src/shared/constants/pagination.ts +++ b/src/shared/constants/pagination.ts @@ -22,4 +22,8 @@ export const PAGE_SIZES = { MEETING_APPROVALS: 10, /** 주제 목록 페이지 사이즈 */ TOPICS: 5, + /** 모임 약속 목록 페이지 사이즈 */ + GATHERING_MEETINGS: 5, + /** 모임 책장 페이지 사이즈 */ + GATHERING_BOOKS: 12, } as const diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts index f5e7b67..47f6330 100644 --- a/src/shared/constants/routes.ts +++ b/src/shared/constants/routes.ts @@ -16,6 +16,7 @@ export const ROUTES = { GATHERINGS: '/gatherings', GATHERING_DETAIL: (id: number | string) => `/gatherings/${id}`, GATHERING_CREATE: '/gatherings/create', + GATHERING_SETTING: (id: number | string) => `/gatherings/${id}/settings`, // Invite INVITE_BASE: '/invite', diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 53ac97b..1dfcf26 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1,2 +1,3 @@ export * from './useDebounce' export * from './useInfiniteScroll' +export * from './useScrollCollapse' diff --git a/src/shared/hooks/useScrollCollapse.ts b/src/shared/hooks/useScrollCollapse.ts new file mode 100644 index 0000000..59f6967 --- /dev/null +++ b/src/shared/hooks/useScrollCollapse.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react' + +interface UseScrollCollapseOptions { + /** 접히는 기준 스크롤 위치 (기본: 100px) */ + collapseThreshold?: number + /** 펼쳐지는 기준 스크롤 위치 (기본: 20px) */ + expandThreshold?: number +} + +/** + * 스크롤 위치에 따른 접힘/펼침 상태 관리 훅 + * + * - hysteresis 적용으로 떨림 방지 + * - 접힌 상태에서는 거의 맨 위로 스크롤해야 펼쳐짐 + * - 펼쳐진 상태에서는 일정 위치 넘어야 접힘 + * + * @param options - 설정 옵션 + * @returns isCollapsed - 접힌 상태 여부 + * + * @example + * ```tsx + * const isCollapsed = useScrollCollapse({ collapseThreshold: 100, expandThreshold: 20 }) + * + * return ( + *
+ * ... + *
+ * ) + * ``` + */ +export const useScrollCollapse = (options: UseScrollCollapseOptions = {}) => { + const { collapseThreshold = 100, expandThreshold = 20 } = options + + const [isCollapsed, setIsCollapsed] = useState(false) + + useEffect(() => { + const handleScroll = () => { + const scrollY = window.scrollY + + setIsCollapsed((prev) => { + // 접힌 상태에서는 거의 맨 위로 스크롤해야 펼쳐짐 + if (prev) { + return scrollY > expandThreshold + } + // 펼쳐진 상태에서는 collapseThreshold 넘어야 접힘 + return scrollY > collapseThreshold + }) + } + + window.addEventListener('scroll', handleScroll, { passive: true }) + return () => window.removeEventListener('scroll', handleScroll) + }, [collapseThreshold, expandThreshold]) + + return isCollapsed +} diff --git a/src/shared/layout/FullWidthLayout.tsx b/src/shared/layout/FullWidthLayout.tsx new file mode 100644 index 0000000..6e42ca2 --- /dev/null +++ b/src/shared/layout/FullWidthLayout.tsx @@ -0,0 +1,21 @@ +import { Outlet } from 'react-router-dom' + +import { Header } from './components' + +/** + * FullWidthLayout (전체 너비 레이아웃) + * + * - MainLayout과 다르게 main에 패딩/max-width를 적용하지 않음 + * - 페이지 내부에서 sticky 헤더가 전체 너비를 사용할 수 있도록 함 + * - 컨텐츠 영역 패딩은 페이지 컴포넌트에서 직접 처리 + */ +export default function FullWidthLayout() { + return ( + <> +
+
+ +
+ + ) +} diff --git a/src/shared/layout/components/Header.tsx b/src/shared/layout/components/Header.tsx index 63811e5..38137b8 100644 --- a/src/shared/layout/components/Header.tsx +++ b/src/shared/layout/components/Header.tsx @@ -42,7 +42,7 @@ export default function Header() { } return ( -
+