diff --git a/src/constants/queryKeys.ts b/src/constants/queryKeys.ts new file mode 100644 index 0000000..f16445b --- /dev/null +++ b/src/constants/queryKeys.ts @@ -0,0 +1,25 @@ +import type { GuestsParams } from '@/types/events'; + +type GuestListFilters = Omit; + +export const queryKeys = { + events: { + detailRoot: ['events', 'detail'] as const, + detailBase: (eventId: string) => + [...queryKeys.events.detailRoot, eventId] as const, + detail: (eventId: string, registrationId?: string | null) => + [ + ...queryKeys.events.detailBase(eventId), + registrationId ?? 'viewer', + ] as const, + guests: { + all: (eventId: string) => ['events', 'guests', eventId] as const, + list: (eventId: string, filters: GuestListFilters) => + [...queryKeys.events.guests.all(eventId), filters] as const, + }, + }, + legacy: { + myEvents: ['myEvents'] as const, + myRegistrations: ['myRegistrations'] as const, + }, +}; diff --git a/src/hooks/useEventDetail.ts b/src/hooks/useEventDetail.ts index a59e9ae..4eda866 100644 --- a/src/hooks/useEventDetail.ts +++ b/src/hooks/useEventDetail.ts @@ -1,25 +1,72 @@ import { deleteEvent, getEventDetail } from '@/api/events/event'; -import { getGuests, joinEvent } from '@/api/events/registrations'; +import { joinEvent } from '@/api/events/registrations'; import { deleteRegistration, getRegistrationDetail, patchRegistration, } from '@/api/registrations/registration'; +import { queryKeys } from '@/constants/queryKeys'; import useAuthStore from '@/hooks/useAuthStore'; +import useInvalidateEventRegistration from '@/hooks/useInvalidateEventRegistration'; import type { EventDetailResponse, JoinEventRequest } from '@/types/events'; -import type { Guest } from '@/types/schemas'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { isAxiosError } from 'axios'; -import { useCallback, useState } from 'react'; +import { useEffect } from 'react'; import { useSearchParams } from 'react-router'; +interface JoinEventVariables { + eventId: string; + data: JoinEventRequest; +} + +const getMergedEventDetail = async ( + eventId: string, + effectiveRegId?: string | null +): Promise => { + const eventRes = await getEventDetail(eventId); + let mergedData = eventRes.data; + + if (mergedData.viewer.status !== 'NONE' || !effectiveRegId) { + return mergedData; + } + + try { + const regRes = await getRegistrationDetail(effectiveRegId); + const canCancel = + regRes.status === 'CONFIRMED' || regRes.status === 'WAITLISTED'; + + // 비로그인 신청자는 이벤트 상세 응답만으로 본인 상태를 알 수 없어서 + // 저장해 둔 regId로 신청 상세를 병합해 같은 화면 상태를 구성합니다. + mergedData = { + ...mergedData, + viewer: { + ...mergedData.viewer, + status: regRes.status, + name: regRes.guestName, + waitlistPosition: regRes.waitlistPosition, + registrationPublicId: regRes.registrationPublicId, + reservationEmail: regRes.reservationEmail, + }, + capabilities: { + ...mergedData.capabilities, + apply: regRes.status === 'CANCELED', + wait: false, + cancel: canCancel, + }, + }; + } catch (regError) { + // regId가 만료되었거나 잘못된 경우에는 공개 상세만 보여줘야 합니다. + console.error('Failed to fetch guest registration info:', regError); + } + + return mergedData; +}; + export default function useEventDetail(id?: string) { - const [loading, setLoading] = useState(false); - const [isDeleted, setIsDeleted] = useState(false); - const [data, setData] = useState(null); const [searchParams] = useSearchParams(); - const [guests, setGuests] = useState([]); + const queryClient = useQueryClient(); + const invalidateEventRegistration = useInvalidateEventRegistration(); - // 1. 유저 식별용 ID 확보 (URL 파라미터 우선, 없으면 Zustand 스토어) const urlRegId = searchParams.get('regId'); const guestRegId = useAuthStore((state) => id ? state.guestRegistrations[id] : null @@ -29,165 +76,156 @@ export default function useEventDetail(id?: string) { const setGuestRegistration = useAuthStore( (state) => state.setGuestRegistration ); - const removeGuestRegistration = useAuthStore( - (state) => state.removeGuestRegistration - ); - // 2. 모임 상세 정보 로드 핸들러 - const handleFetchDetail = useCallback( - async (eventId: string) => { - setLoading(true); - setIsDeleted(false); - try { - // (1) 기본 모임 정보 가져오기 - const eventRes = await getEventDetail(eventId); - let mergedData = eventRes.data; - - // (2) 모임 이름을 페이지 제목으로 설정 - if (mergedData.event.title) { - document.title = `${mergedData.event.title} - 모이밍`; - } else { - document.title = '모임 상세 - 모이밍'; - } - - if (mergedData.viewer.status === 'NONE' && effectiveRegId) { - // (3) 비로그인 유저(NONE)인데 식별 ID가 있는 경우 유저 정보 추가 로드 - try { - const regRes = await getRegistrationDetail(effectiveRegId); - - // 데이터 병합: 기존 event 정보는 유지하고 viewer와 capabilities를 유저 정보로 갱신 - mergedData = { - ...mergedData, - viewer: { - ...mergedData.viewer, - status: regRes.status, - name: regRes.guestName, - waitlistPosition: regRes.waitlistPosition, - registrationPublicId: regRes.registrationPublicId, - reservationEmail: regRes.reservationEmail, - }, - capabilities: { - ...mergedData.capabilities, - cancel: true, // 내 정보를 조회했다는 것은 취소 권한이 있다는 의미 - apply: false, // 이미 신청했으므로 신청 버튼은 비활성화 - wait: false, - }, - }; - } catch (regError) { - // ID가 만료되었거나 잘못된 경우 조용히 기본 정보를 보여줌 (NONE 상태 유지) - console.error('Failed to fetch guest registration info:', regError); - } - } - - setData(mergedData); - return mergedData.viewer.status; - } catch (error: unknown) { - if (isAxiosError(error) && error.response?.status === 404) { - setIsDeleted(true); - } else { - console.error('Fetch event detail failed:', error); - } - return 'ERROR'; - } finally { - setLoading(false); + // 1. 이벤트 상세는 regId에 따라 viewer 상태가 달라질 수 있어 key에 함께 포함합니다. + const eventDetailQuery = useQuery({ + queryKey: id + ? queryKeys.events.detail(id, effectiveRegId) + : queryKeys.events.detail('__missing_event__'), + queryFn: () => { + if (!id) { + throw new Error('EVENT_ID_REQUIRED'); } + return getMergedEventDetail(id, effectiveRegId); }, - [effectiveRegId] - ); + enabled: Boolean(id), + retry: (failureCount, error) => { + if (isAxiosError(error) && error.response?.status === 404) return false; + if (isAxiosError(error) && error.response?.status === 401) return false; + if ( + error.message === 'TOKEN_EXPIRED_LOCAL' || + error.message === 'INVALID_TOKEN_FORMAT' + ) { + return false; + } + return failureCount < 3; + }, + }); - // 3. 참여자 전체 명단 로드 핸들러 - const handleFetchRegistrations = useCallback(async (eventId: string) => { - setLoading(true); - try { - const data = await getGuests(eventId); - setGuests(data.data.participants); - } catch (error: unknown) { - console.error('Fetch registrations error:', error); - } finally { - setLoading(false); - } - }, []); + useEffect(() => { + const title = eventDetailQuery.data?.event.title; + document.title = title ? `${title} - 모이밍` : '모임 상세 - 모이밍'; + }, [eventDetailQuery.data?.event.title]); + + // 2. 신청 성공 후 저장된 regId가 다음 상세 조회의 viewer 병합 기준이 됩니다. + const joinMutation = useMutation({ + mutationFn: ({ eventId, data }: JoinEventVariables) => + joinEvent(eventId, data), + onSuccess: async (response, { eventId }) => { + const regId = response.data.registrationPublicId; + + if (regId) { + setGuestRegistration(eventId, regId); + } + + await invalidateEventRegistration(eventId); + }, + }); + + const cancelMutation = useMutation({ + mutationFn: (registrationId: string) => deleteRegistration(registrationId), + onSuccess: async () => { + if (id) { + // 비로그인 사용자의 regId는 유지하고, 서버의 CANCELED 상태를 다시 조회합니다. + await invalidateEventRegistration(id); + } + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (eventId: string) => deleteEvent(eventId), + onSuccess: async (_response, eventId) => { + // 삭제 후 홈 목록과 상세/참여자 캐시가 서로 다른 상태를 보지 않게 함께 무효화합니다. + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.events.detailBase(eventId), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.events.guests.all(eventId), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.legacy.myEvents, + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.legacy.myRegistrations, + }), + ]); + }, + }); + + const banMutation = useMutation({ + mutationFn: (registrationId: string) => + patchRegistration(registrationId, { + status: 'BANNED', + }), + onSuccess: async () => { + if (id) { + await invalidateEventRegistration(id); + } + }, + }); - // 4. 참여 신청 로직 핸들러 const handleJoinEvent = async ( - id: string, + eventId: string, data: JoinEventRequest ): Promise => { - setLoading(true); try { - const response = await joinEvent(id, data); - if (response.status === 200 || response.status === 201) { - const regId = response.data.registrationPublicId; - - if (regId) { - setGuestRegistration(id, regId); - } - return true; - } - return false; + await joinMutation.mutateAsync({ eventId, data }); + return true; } catch (error: unknown) { console.error('Join event error:', error); return false; - } finally { - setLoading(false); } }; - // 5. 참여 취소 로직 핸들러 - const handleCancelEvent = async (registrationId: string) => { - setLoading(true); + const handleCancelEvent = async ( + registrationId: string + ): Promise => { try { - await deleteRegistration(registrationId); - if (id) { - removeGuestRegistration(id); - } + await cancelMutation.mutateAsync(registrationId); return true; - } catch (error) { + } catch (error: unknown) { console.error('Cancel event error:', error); return false; - } finally { - setLoading(false); } }; - // 6. 모임 삭제 로직 핸들러 - const handleDeleteEvent = async (eventId: string) => { - setLoading(true); + const handleDeleteEvent = async (eventId: string): Promise => { try { - await deleteEvent(eventId); + await deleteMutation.mutateAsync(eventId); return true; } catch (error: unknown) { console.error('Delete event error:', error); return false; - } finally { - setLoading(false); } }; - // 7. 참여 강제 취소 로직 핸들러 - const handleBanEvent = async (registrationId: string) => { - setLoading(true); + const handleBanEvent = async (registrationId: string): Promise => { try { - await patchRegistration(registrationId, { - status: 'BANNED', - }); - if (id) await handleFetchDetail(id); + await banMutation.mutateAsync(registrationId); return true; - } catch (error) { + } catch (error: unknown) { console.error('Ban event error:', error); return false; - } finally { - setLoading(false); } }; + const isDeleted = + isAxiosError(eventDetailQuery.error) && + eventDetailQuery.error.response?.status === 404; + const hasFetchError = eventDetailQuery.isError && !isDeleted; + return { - loading, - data, + loading: + eventDetailQuery.isLoading || + joinMutation.isPending || + cancelMutation.isPending || + deleteMutation.isPending || + banMutation.isPending, + data: eventDetailQuery.data ?? null, isDeleted, - guests, - handleFetchDetail, - handleFetchRegistrations, + hasFetchError, + isBanning: banMutation.isPending, handleJoinEvent, handleCancelEvent, handleDeleteEvent, diff --git a/src/hooks/useInfiniteGuests.ts b/src/hooks/useInfiniteGuests.ts index 0005aeb..334dd2e 100644 --- a/src/hooks/useInfiniteGuests.ts +++ b/src/hooks/useInfiniteGuests.ts @@ -1,4 +1,5 @@ import { getGuests } from '@/api/events/registrations'; +import { queryKeys } from '@/constants/queryKeys'; import type { GuestsParams, GuestsResponse } from '@/types/events'; import type { InfiniteData } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query'; @@ -16,11 +17,11 @@ export default function useInfiniteGuests({ GuestsResponse, Error, InfiniteData, - (string | object)[], + ReturnType, number | undefined >({ - // 필터 조건이 바뀔 때마다 새로운 쿼리로 인식하도록 설정 - queryKey: ['guests', eventId, filters], + // 이벤트 단위로 한 번에 invalidate할 수 있게 key 계층을 맞춥니다. + queryKey: queryKeys.events.guests.list(eventId, filters), queryFn: async ({ pageParam }) => { const response = await getGuests(eventId, { diff --git a/src/hooks/useInvalidateEventRegistration.ts b/src/hooks/useInvalidateEventRegistration.ts new file mode 100644 index 0000000..218331f --- /dev/null +++ b/src/hooks/useInvalidateEventRegistration.ts @@ -0,0 +1,27 @@ +import { queryKeys } from '@/constants/queryKeys'; +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; + +export default function useInvalidateEventRegistration() { + const queryClient = useQueryClient(); + + return useCallback( + async (eventId: string) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: queryKeys.events.detailBase(eventId), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.events.guests.all(eventId), + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.legacy.myRegistrations, + }), + queryClient.invalidateQueries({ + queryKey: queryKeys.legacy.myEvents, + }), + ]); + }, + [queryClient] + ); +} diff --git a/src/mocks/db/registration.db.ts b/src/mocks/db/registration.db.ts new file mode 100644 index 0000000..7ece8bd --- /dev/null +++ b/src/mocks/db/registration.db.ts @@ -0,0 +1,374 @@ +import type { + EventDetailResponse, + GuestsParams, + GuestsResponse, + JoinEventRequest, + UserPreview, +} from '@/types/events'; +import type { GetRegistrationResponse } from '@/types/registrations'; +import type { Guest, GuestStatus } from '@/types/schemas'; +import { eventDB } from './event.db'; + +const PAGE_SIZE = 10; +const registrationDB = new Map(); + +interface MockGuestOptions { + eventId: string; + index: number; + createdAtOffset: number; + status: GuestStatus; + waitingNum: number | null; + preview?: UserPreview; +} + +interface RegistrationLookup { + eventId: string; + registration: Guest; +} + +const findEvent = (eventId: string): EventDetailResponse | undefined => + eventDB.find((event) => event.event.publicId === eventId); + +const isGuestStatus = (status: string): status is GuestStatus => + status === 'CONFIRMED' || + status === 'WAITLISTED' || + status === 'CANCELED' || + status === 'BANNED'; + +const toGuestStatus = ( + status: EventDetailResponse['viewer']['status'] +): GuestStatus | null => (isGuestStatus(status) ? status : null); + +const createMockGuest = ({ + eventId, + index, + createdAtOffset, + status, + waitingNum, + preview, +}: MockGuestOptions): Guest => ({ + registrationId: `reg-${eventId}-${status.toLowerCase()}-${index}`, + name: + preview?.name || + `${status === 'WAITLISTED' ? '대기자' : '참여자'} ${index + 1}`, + email: `user${index + 1}@example.com`, + profileImage: preview?.profileImage || 'https://github.com/shadcn.png', + createdAt: new Date(Date.now() + createdAtOffset).toISOString(), + status, + waitingNum, +}); + +const createViewerGuest = ( + eventRecord: EventDetailResponse, + status: GuestStatus +): Guest | null => { + const registrationId = eventRecord.viewer.registrationPublicId; + if (!registrationId) return null; + + return { + registrationId, + name: eventRecord.viewer.name, + email: eventRecord.viewer.reservationEmail || null, + createdAt: new Date().toISOString(), + status, + waitingNum: + status === 'WAITLISTED' ? eventRecord.viewer.waitlistPosition || 1 : null, + }; +}; + +const seedRegistrations = (eventRecord: EventDetailResponse): Guest[] => { + const eventId = eventRecord.event.publicId; + const registrations: Guest[] = [ + ...Array.from({ length: eventRecord.event.confirmedCount }).map( + (_, index) => + createMockGuest({ + eventId, + index, + createdAtOffset: index, + status: 'CONFIRMED', + waitingNum: null, + preview: eventRecord.guestsPreview[index], + }) + ), + ...Array.from({ length: eventRecord.event.waitlistCount }).map((_, index) => + createMockGuest({ + eventId, + index, + createdAtOffset: eventRecord.event.confirmedCount + index, + status: 'WAITLISTED', + waitingNum: index + 1, + }) + ), + ]; + + const viewerStatus = toGuestStatus(eventRecord.viewer.status); + const viewerGuest = viewerStatus + ? createViewerGuest(eventRecord, viewerStatus) + : null; + + if (viewerGuest) { + // 상세 화면의 viewer와 참여자 목록 mock이 같은 신청자를 바라보게 맞춥니다. + const statusReplaceIndex = registrations.findIndex( + (registration) => registration.status === viewerStatus + ); + const waitlistReplaceIndex = + viewerStatus === 'WAITLISTED' + ? registrations.findIndex( + (registration) => + registration.status === 'WAITLISTED' && + registration.waitingNum === viewerGuest.waitingNum + ) + : -1; + const replaceIndex = + waitlistReplaceIndex >= 0 ? waitlistReplaceIndex : statusReplaceIndex; + + if (replaceIndex >= 0) { + registrations[replaceIndex] = viewerGuest; + } else { + registrations.push(viewerGuest); + } + } + + registrationDB.set(eventId, registrations); + return registrations; +}; + +const getEventRegistrations = (eventId: string): Guest[] => { + const registrations = registrationDB.get(eventId); + if (registrations) return registrations; + + const eventRecord = findEvent(eventId); + if (!eventRecord) return []; + + return seedRegistrations(eventRecord); +}; + +const sortWaitlist = (registrations: Guest[]) => + registrations + .filter((registration) => registration.status === 'WAITLISTED') + .sort((a, b) => { + const aWaitingNum = a.waitingNum ?? Number.MAX_SAFE_INTEGER; + const bWaitingNum = b.waitingNum ?? Number.MAX_SAFE_INTEGER; + return aWaitingNum - bWaitingNum; + }); + +const reindexWaitlist = (eventId: string) => { + sortWaitlist(getEventRegistrations(eventId)).forEach( + (registration, index) => { + registration.waitingNum = index + 1; + } + ); +}; + +const promoteFirstWaitlisted = (eventId: string) => { + const [nextRegistration] = sortWaitlist(getEventRegistrations(eventId)); + if (!nextRegistration) return; + + // 실제 백엔드 정책처럼 확정 취소 시 가장 빠른 대기자를 자동 승격합니다. + nextRegistration.status = 'CONFIRMED'; + nextRegistration.waitingNum = null; + reindexWaitlist(eventId); +}; + +const updateViewerCapabilities = ( + eventRecord: EventDetailResponse, + status: GuestStatus +) => { + eventRecord.capabilities = { + ...eventRecord.capabilities, + apply: status === 'CANCELED', + wait: false, + cancel: status === 'CONFIRMED' || status === 'WAITLISTED', + }; +}; + +const syncViewerFromRegistration = ( + eventRecord: EventDetailResponse, + registrations: Guest[] +) => { + const registrationId = eventRecord.viewer.registrationPublicId; + if (!registrationId) return; + + const registration = registrations.find( + (item) => item.registrationId === registrationId + ); + if (!registration) return; + + eventRecord.viewer = { + ...eventRecord.viewer, + status: registration.status, + name: registration.name, + waitlistPosition: registration.waitingNum ?? 0, + registrationPublicId: registration.registrationId, + reservationEmail: registration.email ?? eventRecord.viewer.reservationEmail, + }; + updateViewerCapabilities(eventRecord, registration.status); +}; + +const syncEventDerivedState = (eventId: string) => { + const eventRecord = findEvent(eventId); + if (!eventRecord) return; + + const registrations = getEventRegistrations(eventId); + const confirmedRegistrations = registrations.filter( + (registration) => registration.status === 'CONFIRMED' + ); + const waitlistedRegistrations = registrations.filter( + (registration) => registration.status === 'WAITLISTED' + ); + + // 참여자 변경 후 상세 preview/count도 같은 mock 상태에서 다시 계산합니다. + eventRecord.event.confirmedCount = confirmedRegistrations.length; + eventRecord.event.waitlistCount = waitlistedRegistrations.length; + eventRecord.guestsPreview = confirmedRegistrations + .slice(0, 5) + .map((registration, index) => ({ + id: index + 1, + name: registration.name, + profileImage: registration.profileImage, + })); + + syncViewerFromRegistration(eventRecord, registrations); +}; + +const findRegistration = ( + registrationId: string +): RegistrationLookup | null => { + for (const eventRecord of eventDB) { + const eventId = eventRecord.event.publicId; + const registration = getEventRegistrations(eventId).find( + (item) => item.registrationId === registrationId + ); + + if (registration) { + return { eventId, registration }; + } + } + + return null; +}; + +export const getGuestList = ( + eventId: string, + params: GuestsParams +): GuestsResponse => { + const startIndex = params.cursor ?? 0; + const participants = getEventRegistrations(eventId) + .filter((registration) => + params.status ? registration.status === params.status : true + ) + .sort((a, b) => { + if (params.orderBy === 'name') { + return a.name.localeCompare(b.name, 'ko'); + } + + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + }); + + const paginatedParticipants = participants.slice( + startIndex, + startIndex + PAGE_SIZE + ); + const hasNext = startIndex + PAGE_SIZE < participants.length; + + return { + participants: paginatedParticipants, + nextCursor: hasNext ? startIndex + PAGE_SIZE : 0, + hasNext, + totalCount: participants.length, + }; +}; + +export const createRegistration = ( + eventId: string, + data: JoinEventRequest +): Guest | null => { + const eventRecord = findEvent(eventId); + if (!eventRecord) return null; + + const registrations = getEventRegistrations(eventId); + const confirmedCount = registrations.filter( + (registration) => registration.status === 'CONFIRMED' + ).length; + const waitlistCount = registrations.filter( + (registration) => registration.status === 'WAITLISTED' + ).length; + const status: GuestStatus = + confirmedCount < eventRecord.event.capacity ? 'CONFIRMED' : 'WAITLISTED'; + + const registration: Guest = { + registrationId: `reg-${eventId}-${Date.now()}`, + name: data.guestName?.trim() || '예약자', + email: data.guestEmail?.trim() || null, + createdAt: new Date().toISOString(), + status, + waitingNum: status === 'WAITLISTED' ? waitlistCount + 1 : null, + }; + + registrations.push(registration); + eventRecord.viewer = { + ...eventRecord.viewer, + status, + name: registration.name, + waitlistPosition: registration.waitingNum ?? 0, + registrationPublicId: registration.registrationId, + reservationEmail: registration.email ?? '', + }; + updateViewerCapabilities(eventRecord, status); + syncEventDerivedState(eventId); + + return registration; +}; + +export const cancelRegistration = (registrationId: string): Guest | null => { + const result = findRegistration(registrationId); + if (!result) return null; + + const previousStatus = result.registration.status; + result.registration.status = 'CANCELED'; + result.registration.waitingNum = null; + + if (previousStatus === 'CONFIRMED') { + promoteFirstWaitlisted(result.eventId); + } + if (previousStatus === 'WAITLISTED') { + reindexWaitlist(result.eventId); + } + + syncEventDerivedState(result.eventId); + return result.registration; +}; + +export const banRegistration = (registrationId: string): Guest | null => { + const result = findRegistration(registrationId); + if (!result) return null; + + const previousStatus = result.registration.status; + result.registration.status = 'BANNED'; + result.registration.waitingNum = null; + + if (previousStatus === 'CONFIRMED') { + promoteFirstWaitlisted(result.eventId); + } + if (previousStatus === 'WAITLISTED') { + reindexWaitlist(result.eventId); + } + + syncEventDerivedState(result.eventId); + return result.registration; +}; + +export const getRegistrationDetailById = ( + registrationId: string +): GetRegistrationResponse | null => { + const result = findRegistration(registrationId); + if (!result) return null; + + return { + status: result.registration.status, + guestName: result.registration.name, + waitlistPosition: result.registration.waitingNum ?? 0, + registrationPublicId: result.registration.registrationId, + reservationEmail: result.registration.email ?? '', + }; +}; diff --git a/src/mocks/handlers/event.ts b/src/mocks/handlers/event.ts index 6a13c64..efae2f9 100644 --- a/src/mocks/handlers/event.ts +++ b/src/mocks/handlers/event.ts @@ -1,12 +1,46 @@ import type { CreateEventRequest, EventDetailResponse, - GuestsResponse, + GuestsParams, + JoinEventRequest, + JoinEventResponse, } from '@/types/events'; +import type { + PatchRegistrationRequest, + PatchRegistrationResponse, +} from '@/types/registrations'; import { http, HttpResponse, delay } from 'msw'; import { eventDB } from '../db/event.db'; +import { + banRegistration, + cancelRegistration, + createRegistration, + getGuestList, + getRegistrationDetailById, +} from '../db/registration.db'; import { path } from '../utils'; +const toGuestStatus = (status: string | null): GuestsParams['status'] => { + if ( + status === 'CONFIRMED' || + status === 'WAITLISTED' || + status === 'CANCELED' || + status === 'BANNED' + ) { + return status; + } + + return undefined; +}; + +const toOrderBy = (orderBy: string | null): GuestsParams['orderBy'] => { + if (orderBy === 'name' || orderBy === 'registeredAt') { + return orderBy; + } + + return undefined; +}; + export const eventHandlers = [ // 1. 내가 생성한/참여한 모임 목록 조회 (GET /events/me) // :id 보다 먼저 정의되어야 'me'를 id로 인식하지 않음 @@ -17,10 +51,9 @@ export const eventHandlers = [ await delay(300); - // eventDB에서 내가 참여한(viewer.status가 NONE이 아닌) 이벤트만 필터링하거나 - // 간단히 모든 이벤트를 내 이벤트로 간주하여 반환 (데모 목적) + // 현재 mock은 토큰을 해석하지 않으므로 viewer 상태로 홈 목록을 가볍게 흉내 냅니다. const myEvents = eventDB - // 실제로는 user token을 확인해서 필터링해야 하지만, 여기서는 예시로 일부만 반환하거나 전체 반환 + // 실제 서버에서는 로그인 유저 기준으로 생성한 모임만 내려줘야 합니다. .filter((e) => e.viewer.status === 'HOST' || e.viewer.status === 'NONE') .map((e) => ({ publicId: e.event.publicId, @@ -34,11 +67,11 @@ export const eventHandlers = [ waitlistCount: e.event.waitlistCount, })); - // Cursor pagination logic mock + // cursor가 있으면 해당 항목 다음부터 페이지를 시작합니다. let startIndex = 0; if (cursor) { const idx = myEvents.findIndex((e) => e.startsAt === cursor); - if (idx !== -1) startIndex = idx + 1; // start from next item + if (idx !== -1) startIndex = idx + 1; } const paginatedEvents = myEvents.slice(startIndex, startIndex + size); @@ -56,7 +89,7 @@ export const eventHandlers = [ // 2. 모임 상세 정보 조회 (GET /events/:id) http.get(path('/events/:id'), async ({ params }) => { - const { id } = params; + const id = String(params.id); await delay(300); const event = eventDB.find((e) => e.event.publicId === id); @@ -69,46 +102,90 @@ export const eventHandlers = [ }), // 3. 참여자 전체 명단 조회 (GET /events/:id/registrations) - http.get(path('/events/:id/registrations'), async () => { + http.get(path('/events/:id/registrations'), async ({ params, request }) => { + const eventId = String(params.id); + const url = new URL(request.url); + const cursor = url.searchParams.get('cursor'); + + await delay(200); + + return HttpResponse.json( + getGuestList(eventId, { + status: toGuestStatus(url.searchParams.get('status')), + orderBy: toOrderBy(url.searchParams.get('orderBy')), + cursor: cursor ? Number.parseInt(cursor, 10) : undefined, + }) + ); + }), + + // 4. 참여 신청 (POST /events/:id/registrations) + http.post(path('/events/:id/registrations'), async ({ params, request }) => { + const eventId = String(params.id); + const body = (await request.json()) as JoinEventRequest; + + await delay(200); + + const registration = createRegistration(eventId, body); + if (!registration) { + return new HttpResponse(null, { status: 404 }); + } + + return HttpResponse.json( + { + registrationPublicId: registration.registrationId, + }, + { status: 201 } + ); + }), + + // 5. 신청 상태 수정 (PATCH /registrations/:id) + http.patch(path('/registrations/:id'), async ({ params, request }) => { + const registrationId = String(params.id); + const body = (await request.json()) as PatchRegistrationRequest; + await delay(200); - return HttpResponse.json({ - participants: Array.from({ length: 10 }).map((_, i) => ({ - publicId: `guest-${i}`, - registrationId: `reg-id-${i}`, - name: `참여자 ${i + 1}`, - email: i < 8 ? `user${i}@example.com` : null, - status: i < 8 ? 'CONFIRMED' : 'WAITLISTED', - profileImage: i != 1 ? 'https://github.com/shadcn.png' : undefined, - createdAt: new Date().toISOString(), - waitingNum: i >= 8 ? i - 7 : null, - })), - nextCursor: 0, - hasNext: false, - totalCount: 10, + if (body.status !== 'BANNED') { + return new HttpResponse(null, { status: 400 }); + } + + const registration = banRegistration(registrationId); + if (!registration) { + return new HttpResponse(null, { status: 404 }); + } + + return HttpResponse.json({ + patchEmail: registration.email ?? '', }); }), - // 4. 비로그인 유저 개별 정보 조회 (GET /registrations/:regId) + // 6. 비로그인 유저 개별 정보 조회 (GET /registrations/:regId) http.get(path('/registrations/:regId'), async ({ params }) => { - const { regId } = params; + const regId = String(params.regId); await delay(200); - return HttpResponse.json({ - status: 'CONFIRMED', - waitlistPosition: 0, - registrationPublicId: regId as string, - reservationEmail: 'guest@example.com', - }); + const registration = getRegistrationDetailById(regId); + if (!registration) { + return new HttpResponse(null, { status: 404 }); + } + + return HttpResponse.json(registration); }), - // 신청 취소 (DELETE /registrations/:id) - http.delete(path('/registrations/:id'), async () => { + // 7. 신청 취소 (DELETE /registrations/:id) + http.delete(path('/registrations/:id'), async ({ params }) => { + const registrationId = String(params.id); await delay(200); + + const registration = cancelRegistration(registrationId); + if (!registration) { + return new HttpResponse(null, { status: 404 }); + } + return new HttpResponse(null, { status: 200 }); }), - // 5. 모임 생성 (POST /events) + // 8. 모임 생성 (POST /events) http.post(path('/events'), async ({ request }) => { const body = (await request.json()) as CreateEventRequest; await delay(500); diff --git a/src/routes/EventEdit.tsx b/src/routes/EventEdit.tsx index d64ea3a..c4e039d 100644 --- a/src/routes/EventEdit.tsx +++ b/src/routes/EventEdit.tsx @@ -3,8 +3,10 @@ import { EventForm } from '@/components/EventForm'; import type { FormValues } from '@/components/EventForm'; import LoadingSkeleton from '@/components/LoadingSkeleton'; import { Button } from '@/components/ui/button'; +import { queryKeys } from '@/constants/queryKeys'; import useAuthStore from '@/hooks/useAuthStore'; import useEventDetail from '@/hooks/useEventDetail'; +import { useQueryClient } from '@tanstack/react-query'; import { AlertCircle } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; @@ -14,12 +16,13 @@ export default function EventEdit() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const { loading: fetchLoading, data, isDeleted, - handleFetchDetail, + hasFetchError, } = useEventDetail(id); const [isSubmitting, setIsSubmitting] = useState(false); @@ -27,12 +30,10 @@ export default function EventEdit() { const user = useAuthStore((state) => state.user); useEffect(() => { - if (id) { - handleFetchDetail(id).then((status) => { - if (status === 'ERROR') navigate(`/event/${id}`); - }); + if (hasFetchError && id) { + navigate(`/event/${id}`); } - }, [id, handleFetchDetail, navigate]); + }, [hasFetchError, id, navigate]); const event = data?.event; @@ -116,6 +117,9 @@ export default function EventEdit() { const response = await updateEvent(id, payload); if (response.status === 201 || response.status === 200) { + await queryClient.invalidateQueries({ + queryKey: queryKeys.events.detailBase(id), + }); toast.success('모임이 수정되었습니다.'); navigate(`/event/${id}`); } diff --git a/src/routes/EventMain.tsx b/src/routes/EventMain.tsx index d6f312e..aea7842 100644 --- a/src/routes/EventMain.tsx +++ b/src/routes/EventMain.tsx @@ -1,13 +1,5 @@ -import { useQueryClient } from '@tanstack/react-query'; import { motion } from 'framer-motion'; -import { - AlertCircle, - Check, - // Dot, - Link as LinkIcon, - Loader, - X, -} from 'lucide-react'; +import { AlertCircle, Check, Link as LinkIcon, Loader, X } from 'lucide-react'; import { type ComponentProps, useEffect, useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router'; import { toast } from 'sonner'; @@ -44,32 +36,25 @@ import { formatEventDate, getRemainingTime } from '@/utils/date'; export default function EventMain() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const location = useLocation(); - const queryClient = useQueryClient(); - const fromHome = location.state?.fromHome; const { user, isLoggedIn } = useAuth(); - const { removeGuestRegistration } = useAuthStore(); // 1. 데이터 가져오기 및 권한 확인 const { loading, data, isDeleted, - handleFetchDetail, + hasFetchError, handleCancelEvent, handleJoinEvent, handleDeleteEvent, } = useEventDetail(id); const view = useEventView(data); - // 2. 초기 데이터 로드 useEffect(() => { - if (id) { - handleFetchDetail(id).then((status) => { - if (status === 'ERROR') navigate('/'); - }); + if (hasFetchError) { + navigate('/'); } - }, [id, handleFetchDetail, navigate]); + }, [hasFetchError, navigate]); if (isDeleted || !id) { return ( @@ -114,9 +99,7 @@ export default function EventMain() { guestEmail: user?.email, }); if (success) { - queryClient.resetQueries({ queryKey: ['myRegistrations'] }); toast.success('신청이 완료되었습니다.'); - navigate(0); } }; @@ -129,8 +112,6 @@ export default function EventMain() { const success = await handleCancelEvent(regId); if (success) { - queryClient.resetQueries({ queryKey: ['myRegistrations'] }); - removeGuestRegistration(id); toast.success('신청이 취소되었습니다.'); } }; @@ -138,8 +119,6 @@ export default function EventMain() { const onDeleteClick = async () => { const success = await handleDeleteEvent(id); if (success) { - queryClient.resetQueries({ queryKey: ['myEvents'] }); - queryClient.resetQueries({ queryKey: ['myRegistrations'] }); toast.success('모임이 삭제되었습니다.'); navigate('/'); } @@ -206,10 +185,6 @@ export default function EventMain() { {creator.email}
- {/* TODO: 모임 생성일시도 요청 - 15분 전 작성 - - */} {getRegistrationStatus(event)} diff --git a/src/routes/EventRegister.tsx b/src/routes/EventRegister.tsx index ee12115..51f68a2 100644 --- a/src/routes/EventRegister.tsx +++ b/src/routes/EventRegister.tsx @@ -5,7 +5,6 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import useAuthStore from '@/hooks/useAuthStore'; import type { JoinEventRequest } from '@/types/events'; -import { useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; import { ShortEventDetailContent } from '../components/EventDetailContent'; @@ -15,10 +14,9 @@ import useEventDetail from '../hooks/useEventDetail'; export default function EventRegister() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const queryClient = useQueryClient(); const setRedirectUrl = useAuthStore((state) => state.setRedirectUrl); - const { loading, data, handleFetchDetail, handleJoinEvent } = - useEventDetail(); + const { loading, data, isDeleted, hasFetchError, handleJoinEvent } = + useEventDetail(id); // 폼 상태 관리 const [formData, setFormData] = useState({ @@ -27,12 +25,10 @@ export default function EventRegister() { }); useEffect(() => { - if (id) { - handleFetchDetail(id).then((status) => { - if (status === 'ERROR') navigate('/'); - }); + if (!id || isDeleted || hasFetchError) { + navigate('/'); } - }, [id, handleFetchDetail, navigate]); + }, [id, isDeleted, hasFetchError, navigate]); if (loading || !data) { return ( @@ -62,7 +58,6 @@ export default function EventRegister() { const success = await handleJoinEvent(id, requestData); if (success) { - queryClient.resetQueries({ queryKey: ['myRegistrations'] }); // 신청 성공 시 성공 페이지로 이동 navigate(`/event/${id}`); } diff --git a/src/routes/Guests.tsx b/src/routes/Guests.tsx index 95ce5cd..92fd8e6 100644 --- a/src/routes/Guests.tsx +++ b/src/routes/Guests.tsx @@ -28,23 +28,17 @@ export default function Guests() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [activeTab, setActiveTab] = useState('CONFIRMED'); - const { handleBanEvent } = useEventDetail(id); + const { handleBanEvent, isBanning } = useEventDetail(id); // 1. 무한 스크롤 훅 연결 - const { - data, - isLoading, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - refetch, - } = useInfiniteGuests({ - eventId: id!, - filters: { - status: activeTab, - orderBy: 'registeredAt', - }, - }); + const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteGuests({ + eventId: id!, + filters: { + status: activeTab, + orderBy: 'registeredAt', + }, + }); const totalCount = data?.pages[0]?.totalCount ?? 0; @@ -61,7 +55,6 @@ export default function Guests() { const success = await handleBanEvent(regId); if (success) { toast.success(`${name} 님의 참여가 취소되었습니다.`); - refetch(); } }; @@ -136,7 +129,8 @@ export default function Guests() { @@ -155,6 +149,7 @@ export default function Guests() { 신청 유지하기 handleCancelGuest(guest.name, guest.registrationId) }