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
Comment thread
ychany marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
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 호출에 사용.
*
* 에러 코드:
* - 400: 코드에 해당하는 토너먼트 없음 (`초대 코드가 올바르지 않습니다.`)
* - 409: PENDING 아닌 상태 또는 만료 (`초대 링크가 만료되었습니다.`)
*/
export const getInvitePreviewByCode = async (code: string) => {
if (environmentManager.isServer()) {
const { data } = await serverApi.get<ApiResponseT<GetInvitePreviewResponseT>>(
ENDPOINTS.TOURNAMENT_INVITE_PREVIEW_BY_CODE,
{ params: { code } }
);
return data.data;
}

const { data } = await clientApi.get<ApiResponseT<GetInvitePreviewResponseT>>(
ENDPOINTS.TOURNAMENT_INVITE_PREVIEW_BY_CODE,
{ params: { code } }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
153 changes: 0 additions & 153 deletions apps/web/src/app/invite/[id]/_components/InviteClient.tsx

This file was deleted.

47 changes: 47 additions & 0 deletions apps/web/src/app/invite/[id]/_components/InviteInvalid.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<main className="flex min-h-dvh flex-col items-center justify-center gap-6 bg-bg-layer-basement px-5 pt-padding-top">
<div className="flex flex-col items-center gap-2">
<p className="heading-1-bold text-text-neutral-primary">초대 링크가 유효하지 않아요</p>
<p className="text-center body-1-medium text-text-neutral-tertiary">
만료됐거나 잘못된 링크일 수 있어요.
<br />
친구에게 새 링크를 요청해주세요.
</p>
</div>

<Link href={ROUTES.HOME} className="w-full max-w-80">
<Button size="lg" variant="primary" className="w-full">
홈으로 가기
</Button>
</Link>
</main>

{/** TODO: 409는 초대 코드 만료, 이미 참여 중, 이미 시작된 토너먼트 등 여러 경우가 있음 따라서 타입을 동적으로 설정할 수 있어야 하나, 서버에서 에러코드를 내려주지 않기 때문에 일단 단일 타입으로 처리*/}
<TournamentErrorDialog
type="LINK_EXPIRED"
open={isTournamentErrorDialogOpen}
onOpenChange={setIsTournamentErrorDialogOpen}
/>
</>
);
}

export default InviteInvalid;
30 changes: 27 additions & 3 deletions apps/web/src/app/invite/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 }>;
Expand All @@ -16,7 +20,27 @@ async function InvitePage({ params, searchParams }: InvitePageProps) {

if (tournamentId === null) notFound();

return <InviteClient tournamentId={tournamentId} inviteCode={code ?? ''} />;
/** 코드 없이 진입 → 잘못된 링크 */
if (!code) return <InviteInvalid />;

/** redirect() 는 throw 방식이라 try 밖에서 호출 — preview 조회만 감싼다 */
let preview;
try {
preview = await getInvitePreviewByCode(code);
} catch (error) {
/** 409(만료·비활성 초대)는 만료 다이얼로그 노출, 그 외(400 코드 불일치 등)는 안내 화면만 */
const isExpired = isAxiosError<ApiErrorResponseT>(error) && error.response?.status === 409;
return <InviteInvalid showExpiredDialog={isExpired} />;
}

/** 코드의 토너먼트가 URL path 와 다르면 잘못된 링크 */
if (preview.tournamentId !== tournamentId) return <InviteInvalid />;

/** 이미 참여한 유저(회원·게스트 공통) → join 건너뛰고 토너먼트로 바로 진입 */
if (preview.joined) redirect(ROUTES.TOURNAMENT_CREATE(tournamentId));

/** 미참여 → 참여 방식(회원 자동 / 게스트 닉네임 입력)은 join 페이지가 소유 */
redirect(`${ROUTES.TOURNAMENT_JOIN_BY_LINK(tournamentId)}?code=${encodeURIComponent(code)}`);
}

export default InvitePage;
Loading
Loading