Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (컴포넌트)
{
Expand Down
7 changes: 6 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
31 changes: 31 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,34 @@ export type PaginatedResponse<T> = {
/** 전체 페이지 수 */
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<T, C = unknown> = {
/** 현재 페이지의 아이템 배열 */
items: T[]
/** 페이지 크기 (한 페이지당 아이템 수) */
pageSize: number
/** 다음 페이지 존재 여부 */
hasNext: boolean
/** 다음 페이지 커서 (없으면 null) */
nextCursor: C | null
/** 전체 아이템 수 (첫 페이지 응답에만 포함될 수 있음) */
totalCount?: number
}
25 changes: 25 additions & 0 deletions src/features/gatherings/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
interface EmptyStateProps {
type?: 'all' | 'favorites'
}

export default function EmptyState({ type = 'all' }: EmptyStateProps) {
const message =
type === 'all' ? (
<>
아직 참여 중인 모임이 없어요.
<br />첫 번째 모임을 시작해 보세요!
</>
) : (
<>
즐겨찾기한 모임이 없어요.
<br />
자주 방문하는 모임을 즐겨찾기에 추가해 보세요!
</>
)

return (
<div className="flex h-35 items-center justify-center rounded-base border border-grey-300">
<p className="text-center text-grey-600 typo-subtitle2">{message}</p>
</div>
)
}
84 changes: 84 additions & 0 deletions src/features/gatherings/components/GatheringCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="relative flex h-35 cursor-pointer flex-col justify-between rounded-base border border-grey-300 bg-white p-medium transition-colors hover:border-grey-400"
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onClick()
}
}}
>
{/* 상단 영역: 배지 + 모임 이름 */}
<div className="flex flex-col gap-xsmall">
{isLeader && (
<span className="w-fit rounded-tiny bg-primary-150 px-xsmall py-xtiny text-primary-400 typo-body5">
모임장
</span>
)}
<p className="typo-subtitle2 text-black line-clamp-1">{gatheringName}</p>
</div>

{/* 하단 영역: 메타 정보 */}
<div className="flex items-center gap-small text-grey-600 typo-body4">
<span>멤버 {totalMembers}명</span>
<span className="h-3 w-px bg-grey-400" />
<span>시작한지 {daysFromJoined}일</span>
<span className="h-3 w-px bg-grey-400" />
<span>약속 {totalMeetings}회</span>
</div>

{/* 즐겨찾기 버튼 */}
<button
type="button"
className="absolute right-medium top-medium p-1 cursor-pointer"
onClick={handleFavoriteClick}
aria-label={isFavorite ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
<Star
className={cn(
'size-6',
isFavorite ? 'fill-yellow-200 text-yellow-200' : 'fill-none text-grey-400'
)}
/>
</button>
</div>
)
}
2 changes: 2 additions & 0 deletions src/features/gatherings/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as EmptyState } from './EmptyState'
export { default as GatheringCard } from './GatheringCard'
46 changes: 46 additions & 0 deletions src/features/gatherings/gatherings.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { GATHERINGS_ENDPOINTS } from './gatherings.endpoints'
import type {
CreateGatheringRequest,
CreateGatheringResponse,
FavoriteGatheringListResponse,
GatheringByInviteCodeResponse,
GatheringJoinResponse,
GatheringListResponse,
GetGatheringsParams,
} from './gatherings.types'

/**
Expand Down Expand Up @@ -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<ApiResponse<GatheringListResponse>>(
GATHERINGS_ENDPOINTS.BASE,
{
params,
}
)
return response.data
}

/**
* 즐겨찾기 모임 목록 조회
*
* @returns 즐겨찾기 모임 목록 (최대 4개)
*/
export const getFavoriteGatherings = async () => {
const response = await apiClient.get<ApiResponse<FavoriteGatheringListResponse>>(
GATHERINGS_ENDPOINTS.FAVORITES
)
return response.data
}

/**
* 모임 즐겨찾기 토글
*
* @param gatheringId - 모임 ID
*/
export const toggleFavorite = async (gatheringId: number) => {
const response = await apiClient.patch<ApiResponse<null>>(
GATHERINGS_ENDPOINTS.TOGGLE_FAVORITE(gatheringId)
)
return response.data
}
4 changes: 4 additions & 0 deletions src/features/gatherings/gatherings.endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
53 changes: 53 additions & 0 deletions src/features/gatherings/gatherings.types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { CursorPaginatedResponse } from '@/api'

/** 모임 기본 정보 (공통) */
export interface GatheringBase {
/** 모임 이름 */
Expand Down Expand Up @@ -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<GatheringListItem, GatheringCursor>

/** 즐겨찾기 모임 목록 응답 */
export interface FavoriteGatheringListResponse {
/** 즐겨찾기 모임 목록 */
gatherings: GatheringListItem[]
}

/** 모임 목록 조회 파라미터 */
export interface GetGatheringsParams {
/** 페이지 크기 (기본: 9) */
pageSize?: number
/** 커서 - 마지막 항목의 가입일시 (ISO 8601) */
cursorJoinedAt?: string
/** 커서 - 마지막 항목의 ID */
cursorId?: number
}
1 change: 1 addition & 0 deletions src/features/gatherings/hooks/gatheringQueryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/features/gatherings/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -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'
20 changes: 20 additions & 0 deletions src/features/gatherings/hooks/useFavoriteGatherings.ts
Original file line number Diff line number Diff line change
@@ -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<FavoriteGatheringListResponse, ApiError>({
queryKey: gatheringQueryKeys.favorites(),
queryFn: async () => {
const response = await getFavoriteGatherings()
return response.data
},
})
}
53 changes: 53 additions & 0 deletions src/features/gatherings/hooks/useGatherings.ts
Original file line number Diff line number Diff line change
@@ -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<GatheringListResponse, PageParam>,
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
},
})
}
Loading
Loading