diff --git a/apps/web/src/app/tournament/join/_apis/getInvitePreviewByCode.ts b/apps/web/src/apis/getInvitePreviewByCode.ts similarity index 59% rename from apps/web/src/app/tournament/join/_apis/getInvitePreviewByCode.ts rename to apps/web/src/apis/getInvitePreviewByCode.ts index 2d1687f2..c40f24e4 100644 --- a/apps/web/src/app/tournament/join/_apis/getInvitePreviewByCode.ts +++ b/apps/web/src/apis/getInvitePreviewByCode.ts @@ -1,12 +1,14 @@ +import { environmentManager } from '@tanstack/react-query'; + import { clientApi } from '@/apis/client'; +import { serverApi } from '@/apis/server'; import { ENDPOINTS } from '@/consts/api'; import type { ApiResponseT } from '@/types/api'; - -import type { GetInvitePreviewResponseT } from '../_types/join'; +import type { GetInvitePreviewResponseT } from '@/types/tournament'; /** * 초대 코드로 토너먼트 미리보기. - * 홈 다이얼로그에서 6자리 코드만 입력하는 경로 전용. + * 홈 다이얼로그(코드 입력)와 invite RSC(링크 진입 분기)에서 사용. * 응답으로 받은 tournamentId 를 이후 /join 호출에 사용. * * 에러 코드: @@ -14,6 +16,14 @@ import type { GetInvitePreviewResponseT } from '../_types/join'; * - 409: PENDING 아닌 상태 또는 만료 (`초대 링크가 만료되었습니다.`) */ export const getInvitePreviewByCode = async (code: string) => { + if (environmentManager.isServer()) { + const { data } = await serverApi.get>( + ENDPOINTS.TOURNAMENT_INVITE_PREVIEW_BY_CODE, + { params: { code } } + ); + return data.data; + } + const { data } = await clientApi.get>( ENDPOINTS.TOURNAMENT_INVITE_PREVIEW_BY_CODE, { params: { code } } diff --git a/apps/web/src/app/home/_components/InviteTournamentDialog.tsx b/apps/web/src/app/home/_components/InviteTournamentDialog.tsx index a5503954..d43998f0 100644 --- a/apps/web/src/app/home/_components/InviteTournamentDialog.tsx +++ b/apps/web/src/app/home/_components/InviteTournamentDialog.tsx @@ -5,7 +5,7 @@ import { isAxiosError } from 'axios'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; -import { getInvitePreviewByCode } from '@/app/tournament/join/_apis/getInvitePreviewByCode'; +import { getInvitePreviewByCode } from '@/apis/getInvitePreviewByCode'; import { CODE_LENGTH, isValidInviteCodeFormat, diff --git a/apps/web/src/app/invite/[id]/_components/InviteClient.tsx b/apps/web/src/app/invite/[id]/_components/InviteClient.tsx deleted file mode 100644 index b9be1e69..00000000 --- a/apps/web/src/app/invite/[id]/_components/InviteClient.tsx +++ /dev/null @@ -1,153 +0,0 @@ -'use client'; - -import { isAxiosError } from 'axios'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { useEffect, useRef, useState } from 'react'; - -import { getMe } from '@/apis/getMe'; -import { getInvitePreviewByCode } from '@/app/tournament/join/_apis/getInvitePreviewByCode'; -import { postJoin } from '@/app/tournament/join/_apis/postJoin'; -import Button from '@/components/button'; -import Spinner from '@/components/spinner'; -import TournamentErrorDialog from '@/components/tournament-error-dialog'; -import { QUERY_ACTION } from '@/consts/queryAction'; -import { ROUTES } from '@/consts/route'; -import type { ApiErrorResponseT } from '@/types/api'; - -type InviteClientProps = { - tournamentId: number; - inviteCode: string; -}; - -type InviteStateT = 'loading' | 'invalid'; - -function InviteClient({ tournamentId, inviteCode }: InviteClientProps) { - const router = useRouter(); - const [state, setState] = useState('loading'); - const [isTournamentErrorDialogOpen, setIsTournamentErrorDialogOpen] = useState(false); - - const hasRunRef = useRef(false); - - useEffect(() => { - if (hasRunRef.current) return; - hasRunRef.current = true; - - const joinAsMemberAndGoToCreate = async () => { - try { - await postJoin({ - tournamentId, - body: { inviteCode }, - }); - - router.replace( - `${ROUTES.TOURNAMENT_CREATE(tournamentId)}?${QUERY_ACTION.KEY}=${QUERY_ACTION.VALUE.WELCOME_JOIN}` - ); - } catch (error) { - if (isAxiosError(error) && error.response?.status === 409) { - setState('invalid'); - setIsTournamentErrorDialogOpen(true); - return; - } - - setState('invalid'); - } - }; - - const run = async () => { - /** 코드 없이 진입 → 잘못된 링크 */ - if (!inviteCode) { - setState('invalid'); - return; - } - - try { - const preview = await getInvitePreviewByCode(inviteCode); - /** 코드의 토너먼트가 URL path와 다르면 잘못된 링크 */ - if (preview.tournamentId !== tournamentId) { - setState('invalid'); - return; - } - - /** - * 이미 참여한 유저(멤버·게스트 공통)가 같은 링크로 재진입하면 join 플로우를 건너뛰고 - * 토너먼트로 바로 진입. preview 응답의 joined 로 판별 — 별도 조회 없이 preview 한 번으로 끝난다. - */ - if (preview.joined) { - router.replace(ROUTES.TOURNAMENT_CREATE(tournamentId)); - return; - } - - const user = await getMe().catch(() => null); - if (user?.identityType === 'MEMBER') { - await joinAsMemberAndGoToCreate(); - return; - } - - router.replace(`${ROUTES.TOURNAMENT_JOIN_BY_LINK(tournamentId)}?code=${inviteCode}`); - } catch (error) { - if (isAxiosError(error) && error.response?.status === 409) { - setState('invalid'); - setIsTournamentErrorDialogOpen(true); - return; - } - - /** 400 (코드 불일치) / 네트워크 등 */ - setState('invalid'); - } - }; - - void run(); - }, [router, tournamentId, inviteCode]); - - if (state === 'loading') { - return ( - <> -
-
- -

- 초대 정보를 확인하고 있어요... -

-
-
- - - - ); - } - - return ( - <> -
-
-

초대 링크가 유효하지 않아요

-

- 만료됐거나 잘못된 링크일 수 있어요. -
- 친구에게 새 링크를 요청해주세요. -

-
- - - - -
- - {/** TODO: 409는 초대 코드 만료, 이미 참여 중, 이미 시작된 토너먼트 등 여러 경우가 있음 따라서 타입을 동적으로 설정할 수 있어야 하나, 서버에서 에러코드를 내려주지 않기 때문에 일단 단일 타입으로 처리*/} - - - ); -} - -export default InviteClient; diff --git a/apps/web/src/app/invite/[id]/_components/InviteInvalid.tsx b/apps/web/src/app/invite/[id]/_components/InviteInvalid.tsx new file mode 100644 index 00000000..9964003f --- /dev/null +++ b/apps/web/src/app/invite/[id]/_components/InviteInvalid.tsx @@ -0,0 +1,47 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; + +import Button from '@/components/button'; +import TournamentErrorDialog from '@/components/tournament-error-dialog'; +import { ROUTES } from '@/consts/route'; + +type InviteInvalidProps = { + /** 초대 만료(409) 로 진입한 경우 만료 다이얼로그를 함께 노출 */ + showExpiredDialog?: boolean; +}; + +function InviteInvalid({ showExpiredDialog = false }: InviteInvalidProps) { + const [isTournamentErrorDialogOpen, setIsTournamentErrorDialogOpen] = useState(showExpiredDialog); + + return ( + <> +
+
+

초대 링크가 유효하지 않아요

+

+ 만료됐거나 잘못된 링크일 수 있어요. +
+ 친구에게 새 링크를 요청해주세요. +

+
+ + + + +
+ + {/** TODO: 409는 초대 코드 만료, 이미 참여 중, 이미 시작된 토너먼트 등 여러 경우가 있음 따라서 타입을 동적으로 설정할 수 있어야 하나, 서버에서 에러코드를 내려주지 않기 때문에 일단 단일 타입으로 처리*/} + + + ); +} + +export default InviteInvalid; diff --git a/apps/web/src/app/invite/[id]/page.tsx b/apps/web/src/app/invite/[id]/page.tsx index abb66ef6..96fd975b 100644 --- a/apps/web/src/app/invite/[id]/page.tsx +++ b/apps/web/src/app/invite/[id]/page.tsx @@ -1,8 +1,12 @@ -import { notFound } from 'next/navigation'; +import { isAxiosError } from 'axios'; +import { notFound, redirect } from 'next/navigation'; +import { getInvitePreviewByCode } from '@/apis/getInvitePreviewByCode'; +import { ROUTES } from '@/consts/route'; +import type { ApiErrorResponseT } from '@/types/api'; import { parseIdParam } from '@/utils/parseIdParam'; -import InviteClient from './_components/InviteClient'; +import InviteInvalid from './_components/InviteInvalid'; type InvitePageProps = { params: Promise<{ id: string }>; @@ -16,7 +20,27 @@ async function InvitePage({ params, searchParams }: InvitePageProps) { if (tournamentId === null) notFound(); - return ; + /** 코드 없이 진입 → 잘못된 링크 */ + if (!code) return ; + + /** redirect() 는 throw 방식이라 try 밖에서 호출 — preview 조회만 감싼다 */ + let preview; + try { + preview = await getInvitePreviewByCode(code); + } catch (error) { + /** 409(만료·비활성 초대)는 만료 다이얼로그 노출, 그 외(400 코드 불일치 등)는 안내 화면만 */ + const isExpired = isAxiosError(error) && error.response?.status === 409; + return ; + } + + /** 코드의 토너먼트가 URL path 와 다르면 잘못된 링크 */ + if (preview.tournamentId !== tournamentId) return ; + + /** 이미 참여한 유저(회원·게스트 공통) → join 건너뛰고 토너먼트로 바로 진입 */ + if (preview.joined) redirect(ROUTES.TOURNAMENT_CREATE(tournamentId)); + + /** 미참여 → 참여 방식(회원 자동 / 게스트 닉네임 입력)은 join 페이지가 소유 */ + redirect(`${ROUTES.TOURNAMENT_JOIN_BY_LINK(tournamentId)}?code=${encodeURIComponent(code)}`); } export default InvitePage; diff --git a/apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx b/apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx index ce03daa8..a8084619 100644 --- a/apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx +++ b/apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx @@ -2,7 +2,7 @@ import { isAxiosError } from 'axios'; import { useRouter } from 'next/navigation'; -import { useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { usePatchMe } from '@/app/mypage/edit/_hooks/usePatchMe'; @@ -10,6 +10,7 @@ import { EditIconFill } from '@/assets/icons/fill'; import Button from '@/components/button'; import { Header } from '@/components/header'; import Input from '@/components/input'; +import Spinner from '@/components/spinner'; import TournamentErrorDialog from '@/components/tournament-error-dialog'; import { QUERY_ACTION } from '@/consts/queryAction'; import { ROUTES } from '@/consts/route'; @@ -29,6 +30,14 @@ type JoinPreviewClientProps = { const MAX_NICKNAME_LENGTH = 10; +/** + * 회원 자동 참여 화면 상태. + * - joining: 참여 요청 진행 중 (스피너) + * - retryable: 일시적 실패 — 재시도 가능 + * - blocked: 409(이미 참여 / 만료) — 재시도가 무의미하므로 종료 화면 + */ +type AutoJoinStatusT = 'joining' | 'retryable' | 'blocked'; + function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) { /** 이 페이지는 흰색 배경(bg-layer-default) — iOS 노치 영역까지 흰색으로 칠해야 자연스럽다. */ usePageBackground('var(--color-bg-layer-default)'); @@ -41,6 +50,7 @@ function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) const [nickname, setNickname] = useState(userData.nickname); const [isTournamentErrorDialogOpen, setIsTournamentErrorDialogOpen] = useState(false); + const [autoJoinStatus, setAutoJoinStatus] = useState('joining'); const { isCheckingNickname, @@ -53,29 +63,32 @@ function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) const isComplete = isNicknameValid && !isCheckingNickname && !isPostJoinPending && !isPatchMePending; - const joinTournament = () => { + const joinTournament = useCallback(() => { postJoinMutation( { tournamentId, body: { ...(inviteCode ? { inviteCode } : {}) }, }, { + /** 참여 완료 후 뒤로가기로 join 화면에 돌아오면 재참여(409)가 되므로 히스토리에서 제거 */ onSuccess: () => { - router.push( + router.replace( `${ROUTES.TOURNAMENT_CREATE(tournamentId)}?${QUERY_ACTION.KEY}=${QUERY_ACTION.VALUE.WELCOME_JOIN}` ); }, onError: error => { if (isAxiosError(error) && error.response?.status === 409) { + setAutoJoinStatus('blocked'); setIsTournamentErrorDialogOpen(true); return; } + setAutoJoinStatus('retryable'); toast.warning('참여에 실패했어요. 잠시 후 다시 시도해주세요.'); }, } ); - }; + }, [inviteCode, postJoinMutation, router, tournamentId]); const handleConfirm = () => { if (!isComplete) return; @@ -93,6 +106,71 @@ function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) joinTournament(); }; + /** 회원은 닉네임 입력 없이 자동 참여 — 재호출은 ref 로 가드 */ + const isMember = userData.identityType === 'MEMBER'; + const hasAutoJoinRunRef = useRef(false); + + useEffect(() => { + if (!isMember || hasAutoJoinRunRef.current) return; + hasAutoJoinRunRef.current = true; + + /** 이미 참여한 회원 — join 없이 바로 이동 */ + if (invitePreviewData.joined) { + router.replace(ROUTES.TOURNAMENT_CREATE(tournamentId)); + return; + } + + joinTournament(); + }, [isMember, invitePreviewData.joined, router, tournamentId, joinTournament]); + + const handleRetryAutoJoin = () => { + setAutoJoinStatus('joining'); + joinTournament(); + }; + + if (isMember) { + return ( + <> +
+ {autoJoinStatus === 'blocked' && ( +
+

+ 참여할 수 없는 토너먼트예요. +

+ +
+ )} + + {autoJoinStatus === 'retryable' && ( +
+

참여에 실패했어요.

+ +
+ )} + + {autoJoinStatus === 'joining' && ( +
+ +

+ 토너먼트에 참여하고 있어요... +

+
+ )} +
+ + + + ); + } + return ( <>
diff --git a/apps/web/src/app/tournament/join/_apis/getInvitePreview.ts b/apps/web/src/app/tournament/join/_apis/getInvitePreview.ts index 6ff73d57..de082536 100644 --- a/apps/web/src/app/tournament/join/_apis/getInvitePreview.ts +++ b/apps/web/src/app/tournament/join/_apis/getInvitePreview.ts @@ -4,8 +4,7 @@ import { clientApi } from '@/apis/client'; import { serverApi } from '@/apis/server'; import { ENDPOINTS } from '@/consts/api'; import type { ApiResponseT } from '@/types/api'; - -import type { GetInvitePreviewResponseT } from '../_types/join'; +import type { GetInvitePreviewResponseT } from '@/types/tournament'; /** * 토너먼트 ID로 미리보기. 인증 불필요. diff --git a/apps/web/src/app/tournament/join/_types/join.ts b/apps/web/src/app/tournament/join/_types/join.ts index b32ebe75..77aa7f45 100644 --- a/apps/web/src/app/tournament/join/_types/join.ts +++ b/apps/web/src/app/tournament/join/_types/join.ts @@ -1,13 +1,3 @@ -/** 초대 코드 / 토너먼트 미리보기 응답 */ -export type GetInvitePreviewResponseT = { - tournamentId: number; - tournamentName: string; - itemCount: number; - participantCount: number; - /** 요청자(게스트/회원)가 이미 이 토너먼트에 참여 중인지. 미인증·미참여면 false */ - joined: boolean; -}; - export type PostJoinRequestT = { /** 영문 대문자 3 + 숫자 3 (서버 패턴: [A-Z]{3}\d{3}). 링크 직접 진입 시 생략 가능 */ inviteCode?: string; diff --git a/apps/web/src/types/tournament.ts b/apps/web/src/types/tournament.ts index 9bedfa37..00dd7e57 100644 --- a/apps/web/src/types/tournament.ts +++ b/apps/web/src/types/tournament.ts @@ -46,3 +46,13 @@ export type PostCreateTournamentRequestT = { export type PostCreateTournamentResponseT = { tournamentId: number; }; + +/** 초대 코드 / 토너먼트 미리보기 응답 */ +export type GetInvitePreviewResponseT = { + tournamentId: number; + tournamentName: string; + itemCount: number; + participantCount: number; + /** 요청자(게스트/회원)가 이미 이 토너먼트에 참여 중인지. 미인증·미참여면 false */ + joined: boolean; +};