diff --git a/apps/web/src/app/tournament/[id]/match/page.tsx b/apps/web/src/app/tournament/[id]/match/page.tsx index 2964b7c7..c03c7aef 100644 --- a/apps/web/src/app/tournament/[id]/match/page.tsx +++ b/apps/web/src/app/tournament/[id]/match/page.tsx @@ -1,12 +1,20 @@ +import { ERROR_CODE } from '@piki/core'; import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { isAxiosError } from 'axios'; import { redirect } from 'next/navigation'; +import { QUERY_ACTION } from '@/consts/queryAction'; import { ROUTES } from '@/consts/route'; +import { MIN_TOURNAMENT_ITEM_COUNT } from '@/consts/tournament'; +import { getApiErrorCode } from '@/utils/apiError'; +import { hasParsingItems } from '@/utils/item'; import { getQueryClient } from '@/utils/queryClient'; import { getTournament } from '../_common/_apis/getTournament'; -import type { GetTournamentInProgressResponseT } from '../_common/_types/tournamentResponse'; +import type { + GetTournamentInProgressResponseT, + TournamentPendingItemT, +} from '../_common/_types/tournamentResponse'; import { postStartTournament } from './_apis/postStartTournament'; import TournamentClient from './_components/TournamentClient'; @@ -14,6 +22,27 @@ type TournamentPageProps = { params: Promise<{ id: string }>; }; +/** 서버가 시작을 거부하는 사유들 — 에러 화면 대신 대기실로 돌려보낸다 */ +const NOT_STARTABLE_CODES: string[] = [ + ERROR_CODE.TOURNAMENT_INVALID_ITEM_COUNT, + ERROR_CODE.TOURNAMENT_ITEM_NOT_READY_TO_START, + ERROR_CODE.TOURNAMENT_ITEM_PRICE_REQUIRED, +]; + +const isNotStartableError = (error: unknown) => { + const code = getApiErrorCode(error); + return code !== null && NOT_STARTABLE_CODES.includes(code); +}; + +const notStartablePath = (tournamentId: number) => + `${ROUTES.TOURNAMENT_CREATE(tournamentId)}?${QUERY_ACTION.KEY}=${QUERY_ACTION.VALUE.TOURNAMENT_NOT_STARTABLE}`; + +/** 대기실 시작 버튼과 동일한 기준 — 후보 2개 이상, 추출 중·실패 상품 없음 */ +const isStartable = (items: TournamentPendingItemT[]) => + items.length >= MIN_TOURNAMENT_ITEM_COUNT && + !hasParsingItems(items) && + !items.some(item => item.status === 'FAILED'); + async function TournamentPage({ params }: TournamentPageProps) { const { id } = await params; const tournamentId = Number(id); @@ -34,6 +63,12 @@ async function TournamentPage({ params }: TournamentPageProps) { let playTournamentId = tournamentId; if (tournamentData.status === 'PENDING') { + // 대기실 시작 버튼과 같은 기준으로 미리 거른다 — 그냥 start 를 부르면 + // 서버가 400(TOURNAMENT-007) 을 주고 전역 에러 화면으로 떨어진다. + if (!isStartable(tournamentData.pending?.items ?? [])) { + redirect(notStartablePath(tournamentId)); + } + try { // 응답 tournamentId 활용: // - 주최자(ROOT): 요청 tournamentId 와 동일 @@ -60,8 +95,16 @@ async function TournamentPage({ params }: TournamentPageProps) { hydratedTournament = started; } catch (error) { + if (!isAxiosError(error)) throw error; + + // 사전 검사를 통과했더라도 그 사이 상태가 바뀔 수 있다. + // 시작 불가 사유는 에러 화면 대신 대기실로 돌려보낸다. + if (isNotStartableError(error)) { + redirect(notStartablePath(tournamentId)); + } + // 409: 다른 탭/요청이 먼저 start 호출한 경우 — 서버 권위 상태로 복구 - if (!isAxiosError(error) || error.response?.status !== 409) throw error; + if (error.response?.status !== 409) throw error; const latest = await getTournament(tournamentId); if (latest.status === 'COMPLETED') { diff --git a/apps/web/src/consts/queryAction.ts b/apps/web/src/consts/queryAction.ts index b18f5464..0ad14b89 100644 --- a/apps/web/src/consts/queryAction.ts +++ b/apps/web/src/consts/queryAction.ts @@ -12,6 +12,7 @@ export const QUERY_ACTION = { TOURNAMENT_ITEM_NOT_FOUND: 'tournament-item-not-found', // 삭제된 토너먼트 아이템 접근 시 create 로 폴백 후 토스트 TOURNAMENT_FORBIDDEN: 'tournament-forbidden', // 참여 권한 없는 토너먼트 접근 시 홈으로 폴백 후 토스트 TOURNAMENT_NOT_FOUND: 'tournament-not-found', // 삭제된 토너먼트에 액션을 시도한 경우 홈으로 폴백 후 토스트 + TOURNAMENT_NOT_STARTABLE: 'tournament-not-startable', // 시작 조건 미충족 상태로 /match 직접 진입 시 create 로 폴백 후 토스트 }, } as const; diff --git a/apps/web/src/consts/queryActionToast.ts b/apps/web/src/consts/queryActionToast.ts index 7f36270e..0cf04095 100644 --- a/apps/web/src/consts/queryActionToast.ts +++ b/apps/web/src/consts/queryActionToast.ts @@ -33,4 +33,9 @@ export const QUERY_ACTION_TOAST: Partial