From fa933ecc7cc1c9c2ae80219d1bccf994eafa9f90 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sat, 1 Aug 2026 14:53:02 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor:=20invite=20=EC=A7=84=EC=9E=85=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=EB=A5=BC=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=EC=97=90=EC=84=9C=20RSC=20redirect=20?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../invite/[id]/_components/InviteClient.tsx | 153 ------------------ .../invite/[id]/_components/InviteInvalid.tsx | 48 ++++++ apps/web/src/app/invite/[id]/page.tsx | 33 +++- .../join/_apis/getInvitePreviewByCode.ts | 13 +- 4 files changed, 90 insertions(+), 157 deletions(-) delete mode 100644 apps/web/src/app/invite/[id]/_components/InviteClient.tsx create mode 100644 apps/web/src/app/invite/[id]/_components/InviteInvalid.tsx 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..a6ad5dcd --- /dev/null +++ b/apps/web/src/app/invite/[id]/_components/InviteInvalid.tsx @@ -0,0 +1,48 @@ +'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..ffeafa9b 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 '@/app/tournament/join/_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,30 @@ async function InvitePage({ params, searchParams }: InvitePageProps) { if (tournamentId === null) notFound(); - return ; + /** 코드 없이 진입 → 잘못된 링크 */ + if (!code) return ; + + /** + * redirect() 는 NEXT_REDIRECT 를 throw 하는 방식이라 try 안에 두면 catch 가 삼킨다. + * preview 조회만 감싸고 분기·redirect 는 밖에서 처리한다. + */ + 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=${code}`); } export default InvitePage; diff --git a/apps/web/src/app/tournament/join/_apis/getInvitePreviewByCode.ts b/apps/web/src/app/tournament/join/_apis/getInvitePreviewByCode.ts index 2d1687f2..fdfa102a 100644 --- a/apps/web/src/app/tournament/join/_apis/getInvitePreviewByCode.ts +++ b/apps/web/src/app/tournament/join/_apis/getInvitePreviewByCode.ts @@ -1,4 +1,7 @@ +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'; @@ -6,7 +9,7 @@ import type { GetInvitePreviewResponseT } from '../_types/join'; /** * 초대 코드로 토너먼트 미리보기. - * 홈 다이얼로그에서 6자리 코드만 입력하는 경로 전용. + * 홈 다이얼로그(코드 입력)와 invite RSC(링크 진입 분기)에서 사용. * 응답으로 받은 tournamentId 를 이후 /join 호출에 사용. * * 에러 코드: @@ -14,6 +17,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 } } From 0885d1919da505c7d955ba9c952502dbf29350a9 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sat, 1 Aug 2026 14:53:02 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20join=20=EC=9D=84=20invite=20=EC=97=90=EC=84=9C=20jo?= =?UTF-8?q?in=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[id]/_components/JoinPreviewClient.tsx | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) 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..ab0ffc19 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'; @@ -53,7 +54,7 @@ function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) const isComplete = isNicknameValid && !isCheckingNickname && !isPostJoinPending && !isPatchMePending; - const joinTournament = () => { + const joinTournament = useCallback(() => { postJoinMutation( { tournamentId, @@ -75,7 +76,7 @@ function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) }, } ); - }; + }, [inviteCode, postJoinMutation, router, tournamentId]); const handleConfirm = () => { if (!isComplete) return; @@ -93,6 +94,47 @@ function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) joinTournament(); }; + /** + * 회원은 닉네임 입력 없이 자동 참여 — 참여 방식(회원 자동/게스트 닉네임)은 이 페이지가 소유한다. + * mutation 을 effect 로 트리거하므로 리마운트·fast refresh 재호출을 ref 로 가드. + */ + const isMember = userData.identityType === 'MEMBER'; + const hasAutoJoinRunRef = useRef(false); + + useEffect(() => { + if (!isMember || hasAutoJoinRunRef.current) return; + hasAutoJoinRunRef.current = true; + + /** 이미 참여한 회원이 URL 직접 진입한 경우 — join 없이 바로 이동 */ + if (invitePreviewData.joined) { + router.replace(ROUTES.TOURNAMENT_CREATE(tournamentId)); + return; + } + + joinTournament(); + }, [isMember, invitePreviewData.joined, router, tournamentId, joinTournament]); + + if (isMember) { + return ( + <> +
+
+ +

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

+
+
+ + + + ); + } + return ( <>
From 46fe06ba6f3b0d0f544d3414cb0d37341547885d Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sat, 1 Aug 2026 21:13:04 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89?= =?UTF-8?q?=ED=8A=B8=20code=20=EC=9D=B8=EC=BD=94=EB=94=A9=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=B0=B8=EC=97=AC=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=8B=9C=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20UI=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/invite/[id]/page.tsx | 7 ++-- .../[id]/_components/JoinPreviewClient.tsx | 35 +++++++++++++------ 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/apps/web/src/app/invite/[id]/page.tsx b/apps/web/src/app/invite/[id]/page.tsx index ffeafa9b..ad0814ea 100644 --- a/apps/web/src/app/invite/[id]/page.tsx +++ b/apps/web/src/app/invite/[id]/page.tsx @@ -23,10 +23,7 @@ async function InvitePage({ params, searchParams }: InvitePageProps) { /** 코드 없이 진입 → 잘못된 링크 */ if (!code) return ; - /** - * redirect() 는 NEXT_REDIRECT 를 throw 하는 방식이라 try 안에 두면 catch 가 삼킨다. - * preview 조회만 감싸고 분기·redirect 는 밖에서 처리한다. - */ + /** redirect() 는 throw 방식이라 try 밖에서 호출 — preview 조회만 감싼다 */ let preview; try { preview = await getInvitePreviewByCode(code); @@ -43,7 +40,7 @@ async function InvitePage({ params, searchParams }: InvitePageProps) { if (preview.joined) redirect(ROUTES.TOURNAMENT_CREATE(tournamentId)); /** 미참여 → 참여 방식(회원 자동 / 게스트 닉네임 입력)은 join 페이지가 소유 */ - redirect(`${ROUTES.TOURNAMENT_JOIN_BY_LINK(tournamentId)}?code=${code}`); + 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 ab0ffc19..161f8bcb 100644 --- a/apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx +++ b/apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx @@ -42,6 +42,7 @@ function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) const [nickname, setNickname] = useState(userData.nickname); const [isTournamentErrorDialogOpen, setIsTournamentErrorDialogOpen] = useState(false); + const [isAutoJoinFailed, setIsAutoJoinFailed] = useState(false); const { isCheckingNickname, @@ -72,6 +73,7 @@ function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) return; } + setIsAutoJoinFailed(true); toast.warning('참여에 실패했어요. 잠시 후 다시 시도해주세요.'); }, } @@ -94,10 +96,7 @@ function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) joinTournament(); }; - /** - * 회원은 닉네임 입력 없이 자동 참여 — 참여 방식(회원 자동/게스트 닉네임)은 이 페이지가 소유한다. - * mutation 을 effect 로 트리거하므로 리마운트·fast refresh 재호출을 ref 로 가드. - */ + /** 회원은 닉네임 입력 없이 자동 참여 — 재호출은 ref 로 가드 */ const isMember = userData.identityType === 'MEMBER'; const hasAutoJoinRunRef = useRef(false); @@ -105,7 +104,7 @@ function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) if (!isMember || hasAutoJoinRunRef.current) return; hasAutoJoinRunRef.current = true; - /** 이미 참여한 회원이 URL 직접 진입한 경우 — join 없이 바로 이동 */ + /** 이미 참여한 회원 — join 없이 바로 이동 */ if (invitePreviewData.joined) { router.replace(ROUTES.TOURNAMENT_CREATE(tournamentId)); return; @@ -114,16 +113,30 @@ function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) joinTournament(); }, [isMember, invitePreviewData.joined, router, tournamentId, joinTournament]); + const handleRetryAutoJoin = () => { + setIsAutoJoinFailed(false); + joinTournament(); + }; + if (isMember) { return ( <>
-
- -

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

-
+ {isAutoJoinFailed ? ( +
+

참여에 실패했어요.

+ +
+ ) : ( +
+ +

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

+
+ )}
Date: Sun, 2 Aug 2026 01:20:01 +0900 Subject: [PATCH 4/6] =?UTF-8?q?style:=20prettier=20=ED=8F=AC=EB=A7=B7=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/invite/[id]/_components/InviteInvalid.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/app/invite/[id]/_components/InviteInvalid.tsx b/apps/web/src/app/invite/[id]/_components/InviteInvalid.tsx index a6ad5dcd..9964003f 100644 --- a/apps/web/src/app/invite/[id]/_components/InviteInvalid.tsx +++ b/apps/web/src/app/invite/[id]/_components/InviteInvalid.tsx @@ -13,8 +13,7 @@ type InviteInvalidProps = { }; function InviteInvalid({ showExpiredDialog = false }: InviteInvalidProps) { - const [isTournamentErrorDialogOpen, setIsTournamentErrorDialogOpen] = - useState(showExpiredDialog); + const [isTournamentErrorDialogOpen, setIsTournamentErrorDialogOpen] = useState(showExpiredDialog); return ( <> From 576ba715acd1c6d3f861ffddd8ba464c1de01802 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Mon, 3 Aug 2026 17:15:11 +0900 Subject: [PATCH 5/6] =?UTF-8?q?refactor:=20getInvitePreviewByCode=20?= =?UTF-8?q?=EB=A5=BC=20src/apis=20=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 개 이상 top-level 라우트(home, invite)에서 쓰이므로 라우트 전용 _apis 가 아닌 공용 src/apis 로 옮긴다. 응답 타입도 src/types/tournament.ts 로 함께 이동. --- .../join/_apis => apis}/getInvitePreviewByCode.ts | 3 +-- .../app/home/_components/InviteTournamentDialog.tsx | 2 +- apps/web/src/app/invite/[id]/page.tsx | 2 +- .../src/app/tournament/join/_apis/getInvitePreview.ts | 3 +-- apps/web/src/app/tournament/join/_types/join.ts | 10 ---------- apps/web/src/types/tournament.ts | 10 ++++++++++ 6 files changed, 14 insertions(+), 16 deletions(-) rename apps/web/src/{app/tournament/join/_apis => apis}/getInvitePreviewByCode.ts (94%) diff --git a/apps/web/src/app/tournament/join/_apis/getInvitePreviewByCode.ts b/apps/web/src/apis/getInvitePreviewByCode.ts similarity index 94% rename from apps/web/src/app/tournament/join/_apis/getInvitePreviewByCode.ts rename to apps/web/src/apis/getInvitePreviewByCode.ts index fdfa102a..c40f24e4 100644 --- a/apps/web/src/app/tournament/join/_apis/getInvitePreviewByCode.ts +++ b/apps/web/src/apis/getInvitePreviewByCode.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'; /** * 초대 코드로 토너먼트 미리보기. diff --git a/apps/web/src/app/home/_components/InviteTournamentDialog.tsx b/apps/web/src/app/home/_components/InviteTournamentDialog.tsx index c3e564cc..9f361cad 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]/page.tsx b/apps/web/src/app/invite/[id]/page.tsx index ad0814ea..96fd975b 100644 --- a/apps/web/src/app/invite/[id]/page.tsx +++ b/apps/web/src/app/invite/[id]/page.tsx @@ -1,7 +1,7 @@ import { isAxiosError } from 'axios'; import { notFound, redirect } from 'next/navigation'; -import { getInvitePreviewByCode } from '@/app/tournament/join/_apis/getInvitePreviewByCode'; +import { getInvitePreviewByCode } from '@/apis/getInvitePreviewByCode'; import { ROUTES } from '@/consts/route'; import type { ApiErrorResponseT } from '@/types/api'; import { parseIdParam } from '@/utils/parseIdParam'; 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; +}; From a99316740236499d938ea81c1adbe80de92df153 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Mon, 3 Aug 2026 17:15:21 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=B0=B8=EC=97=AC=20409=20=EC=8B=9C=20=EB=AC=B4?= =?UTF-8?q?=ED=95=9C=20=EC=8A=A4=ED=94=BC=EB=84=88=20=EA=B0=87=ED=9E=98=20?= =?UTF-8?q?=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 참여 성공 시 router.push -> replace 로 변경해 뒤로가기 재참여(409) 차단 - isAutoJoinFailed boolean 을 joining/retryable/blocked 상태로 통합 - 409 는 재시도가 무의미하므로 홈으로 가기 종료 화면 노출 --- .../[id]/_components/JoinPreviewClient.tsx | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) 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 161f8bcb..a8084619 100644 --- a/apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx +++ b/apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx @@ -30,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)'); @@ -42,7 +50,7 @@ function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) const [nickname, setNickname] = useState(userData.nickname); const [isTournamentErrorDialogOpen, setIsTournamentErrorDialogOpen] = useState(false); - const [isAutoJoinFailed, setIsAutoJoinFailed] = useState(false); + const [autoJoinStatus, setAutoJoinStatus] = useState('joining'); const { isCheckingNickname, @@ -62,18 +70,20 @@ function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) 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; } - setIsAutoJoinFailed(true); + setAutoJoinStatus('retryable'); toast.warning('참여에 실패했어요. 잠시 후 다시 시도해주세요.'); }, } @@ -114,7 +124,7 @@ function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) }, [isMember, invitePreviewData.joined, router, tournamentId, joinTournament]); const handleRetryAutoJoin = () => { - setIsAutoJoinFailed(false); + setAutoJoinStatus('joining'); joinTournament(); }; @@ -122,14 +132,27 @@ function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) return ( <>
- {isAutoJoinFailed ? ( + {autoJoinStatus === 'blocked' && ( +
+

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

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

참여에 실패했어요.

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