diff --git a/apps/app/apis/postSocialLogin.ts b/apps/app/apis/postSocialLogin.ts index 3f9c7800..5a8e73bf 100644 --- a/apps/app/apis/postSocialLogin.ts +++ b/apps/app/apis/postSocialLogin.ts @@ -8,17 +8,14 @@ export const postSocialLogin = async ( provider: SocialProviderT, body: PostSocialLoginRequestT ): Promise => { - const response = await fetch( - `${process.env.EXPO_PUBLIC_API_URL}/api/v1/auth/login/${provider}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Client-Type': 'app', - }, - body: JSON.stringify(body), - } - ); + const response = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/api/v1/auth/login/${provider}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'app', + }, + body: JSON.stringify(body), + }); const data = await response.json(); if (!response.ok) throw new Error(data.detail ?? '로그인에 실패했습니다.'); diff --git a/apps/app/hooks/useWebviewCookieSync.ts b/apps/app/hooks/useWebviewCookieSync.ts index 8ddb68f2..93d4adc5 100644 --- a/apps/app/hooks/useWebviewCookieSync.ts +++ b/apps/app/hooks/useWebviewCookieSync.ts @@ -22,11 +22,19 @@ export const useWebviewCookieSync = () => { const useWebKit = Platform.OS === 'ios'; if (accessToken) { - await CookieManager.set(WEB_URL, { name: 'access_token', value: accessToken, path: '/' }, useWebKit); + await CookieManager.set( + WEB_URL, + { name: 'access_token', value: accessToken, path: '/' }, + useWebKit + ); } if (refreshToken) { - await CookieManager.set(WEB_URL, { name: 'refresh_token', value: refreshToken, path: '/' }, useWebKit); + await CookieManager.set( + WEB_URL, + { name: 'refresh_token', value: refreshToken, path: '/' }, + useWebKit + ); } setIsSynced(true); diff --git a/apps/app/utils/handleImage.ts b/apps/app/utils/handleImage.ts index a58f71e6..dea0eea5 100644 --- a/apps/app/utils/handleImage.ts +++ b/apps/app/utils/handleImage.ts @@ -64,7 +64,10 @@ export const handleOpenImagePicker = async ({ requestId, maxCount }: OpenImagePi }); if (result.canceled || result.assets.length === 0) { - WebBridge.postMessage({ type: WEBBRIDGE_MESSAGE_TYPE.APP_RES_IMAGE_PICKER_CANCEL, payload: { requestId } }); + WebBridge.postMessage({ + type: WEBBRIDGE_MESSAGE_TYPE.APP_RES_IMAGE_PICKER_CANCEL, + payload: { requestId }, + }); return; } diff --git a/apps/web/src/apis/client.ts b/apps/web/src/apis/client.ts index 9f53c341..febaa61f 100644 --- a/apps/web/src/apis/client.ts +++ b/apps/web/src/apis/client.ts @@ -61,10 +61,10 @@ clientApi.interceptors.response.use( isRefreshing = true; try { - /** 토큰 갱신 */ + /** 토큰 갱신 — body 없는 POST 라도 빈 객체로 보내야 일부 백엔드가 415 안 던진다. */ const { data } = await axios.post( `${process.env.NEXT_PUBLIC_API_URL}${ENDPOINTS.AUTH_TOKEN_REFRESH}`, - null, + {}, { withCredentials: true, } diff --git a/apps/web/src/apis/getNicknameCheck.ts b/apps/web/src/apis/getNicknameCheck.ts new file mode 100644 index 00000000..129aee9f --- /dev/null +++ b/apps/web/src/apis/getNicknameCheck.ts @@ -0,0 +1,28 @@ +import { environmentManager } from '@tanstack/react-query'; + +import { ENDPOINTS } from '@/consts/api'; +import type { ApiResponseT } from '@/types/api'; +import type { GetNicknameCheckResponseT } from '@/types/user'; + +import { clientApi } from './client'; +import { serverApi } from './server'; + +/** + * 닉네임 중복 체크. + * 본인의 현재 닉네임은 서버가 자동으로 통과시킨다 (재확인 흐름 호환). + */ +export const getNicknameCheck = async (nickname: string) => { + if (environmentManager.isServer()) { + const { data } = await serverApi.get>( + ENDPOINTS.USERS_NICKNAME_CHECK, + { params: { nickname } } + ); + return data.data; + } + + const { data } = await clientApi.get>( + ENDPOINTS.USERS_NICKNAME_CHECK, + { params: { nickname } } + ); + return data.data; +}; diff --git a/apps/web/src/app/archive/_components/WishlistContent.tsx b/apps/web/src/app/archive/_components/WishlistContent.tsx index 79200cb7..b5989f91 100644 --- a/apps/web/src/app/archive/_components/WishlistContent.tsx +++ b/apps/web/src/app/archive/_components/WishlistContent.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { useGetWishlist } from '@/hooks/useGetWishlist'; + import { useWishlistDelete } from '../_hooks/useDeleteWishes'; import { useShareIntentWish } from '../_hooks/useShareIntentWish'; import WishAddDialog from './WishAddDialog'; diff --git a/apps/web/src/app/archive/_components/wish-grid/index.tsx b/apps/web/src/app/archive/_components/wish-grid/index.tsx index 351f22a3..56f8abaa 100644 --- a/apps/web/src/app/archive/_components/wish-grid/index.tsx +++ b/apps/web/src/app/archive/_components/wish-grid/index.tsx @@ -19,7 +19,9 @@ function WishGrid({ items, isDeleteMode = false, selectedIds, onToggleSelect }: if (item.status === 'FAILED') return ; else if (item.status === 'PROCESSING') return ; - const card = ; + const card = ( + + ); if (isDeleteMode) { const isSelected = selectedIds?.has(item.id) ?? false; @@ -31,7 +33,7 @@ function WishGrid({ items, isDeleteMode = false, selectedIds, onToggleSelect }: aria-pressed={isSelected} className="relative text-left transition-opacity active:opacity-80" > - {card} + {isSelected ? ( diff --git a/apps/web/src/app/archive/wish/[id]/_components/ItemEditForm.tsx b/apps/web/src/app/archive/wish/[id]/_components/ItemEditForm.tsx index 35b438b7..2c7db918 100644 --- a/apps/web/src/app/archive/wish/[id]/_components/ItemEditForm.tsx +++ b/apps/web/src/app/archive/wish/[id]/_components/ItemEditForm.tsx @@ -7,7 +7,6 @@ import BottomCta from '@/components/bottom-cta'; import Button from '@/components/button'; import Input from '@/components/input'; import Spacing from '@/components/spacing'; - import type { ItemStatusT } from '@/types/item'; import formatPrice from '@/utils/formatPrice'; diff --git a/apps/web/src/app/auth/callback/[provider]/_apis/postSocialLogin.ts b/apps/web/src/app/auth/callback/[provider]/_apis/postSocialLogin.ts index 12277e06..43c00e86 100644 --- a/apps/web/src/app/auth/callback/[provider]/_apis/postSocialLogin.ts +++ b/apps/web/src/app/auth/callback/[provider]/_apis/postSocialLogin.ts @@ -1,8 +1,8 @@ +import type { SocialProviderT } from '@piki/core'; + import { clientApi } from '@/apis/client'; import { ENDPOINTS } from '@/consts/api'; import type { ApiResponseT } from '@/types/api'; -import type { SocialProviderT } from '@piki/core'; - import type { PostSocialLoginResponseT } from '@/types/auth'; type PostSocialLoginRequestT = { diff --git a/apps/web/src/app/auth/callback/apple/route.ts b/apps/web/src/app/auth/callback/apple/route.ts index 39a96549..f9a69521 100644 --- a/apps/web/src/app/auth/callback/apple/route.ts +++ b/apps/web/src/app/auth/callback/apple/route.ts @@ -14,14 +14,11 @@ export async function POST(request: NextRequest) { const redirectUri = `${request.nextUrl.origin}/auth/callback/apple`; try { - const apiResponse = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/auth/login/apple`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code, redirectUri, state }), - } - ); + const apiResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/auth/login/apple`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, redirectUri, state }), + }); if (!apiResponse.ok) { return NextResponse.redirect(loginUrl, { status: 302 }); diff --git a/apps/web/src/app/home/_assets/sad-face.svg b/apps/web/src/app/home/_assets/sad-face.svg new file mode 100644 index 00000000..9687def4 --- /dev/null +++ b/apps/web/src/app/home/_assets/sad-face.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/src/app/home/_components/InviteTournamentButton.tsx b/apps/web/src/app/home/_components/InviteTournamentButton.tsx new file mode 100644 index 00000000..1518e8e8 --- /dev/null +++ b/apps/web/src/app/home/_components/InviteTournamentButton.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useState } from 'react'; + +import { LoginIconOutline } from '@/assets/icons'; + +import InviteCodeDialog from './invite-code-dialog/InviteCodeDialog'; + +function InviteTournamentButton() { + const [isOpen, setIsOpen] = useState(false); + + const handleOpen = () => setIsOpen(true); + + return ( + <> + + + + + ); +} + +export default InviteTournamentButton; diff --git a/apps/web/src/app/home/_components/TournamentList.tsx b/apps/web/src/app/home/_components/TournamentList.tsx index 1eb31190..055a54b1 100644 --- a/apps/web/src/app/home/_components/TournamentList.tsx +++ b/apps/web/src/app/home/_components/TournamentList.tsx @@ -4,11 +4,14 @@ import TournamentCard from '@/components/tournament-card'; import type { UserT } from '@/components/user-profile-group/userProfile.const'; import { useGetTournamentList } from '@/hooks/useGetTournamentList'; -// TODO: 백엔드가 정식 프사 처리 도입 시 imageUrl 반영 +// 비회원(GUEST)은 서버가 dicebear 자동 아바타를 내려주는데, +// 디자인상 비회원은 기본 SVG 프로필을 노출하므로 무시한다. +const isGeneratedAvatar = (url: string) => url.includes('api.dicebear.com'); const toUsers = (imageUrls: string[]): UserT[] => - imageUrls.map((_, index) => ({ + imageUrls.map((url, index) => ({ id: index, profileType: index % 2 === 0 ? 'blue' : 'yellow', + ...(isGeneratedAvatar(url) ? {} : { imageUrl: url }), })); function TorunamentList() { diff --git a/apps/web/src/app/home/_components/invite-code-dialog/InvalidCodeDialog.tsx b/apps/web/src/app/home/_components/invite-code-dialog/InvalidCodeDialog.tsx new file mode 100644 index 00000000..1289c965 --- /dev/null +++ b/apps/web/src/app/home/_components/invite-code-dialog/InvalidCodeDialog.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/dialog'; + +import SadFace from '../../_assets/sad-face.svg'; + +type InvalidCodeDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +function InvalidCodeDialog({ open, onOpenChange }: InvalidCodeDialogProps) { + const handleClose = () => onOpenChange(false); + + return ( + + + + +
+ + 코드가 유효하지 않아요 + + + 입력한 코드와 일치하는 토너먼트가 없어요. +
+ 코드를 다시 확인해주세요. +
+
+ + +
+
+ ); +} + +export default InvalidCodeDialog; diff --git a/apps/web/src/app/home/_components/invite-code-dialog/InviteCodeDialog.tsx b/apps/web/src/app/home/_components/invite-code-dialog/InviteCodeDialog.tsx new file mode 100644 index 00000000..5c61a4b2 --- /dev/null +++ b/apps/web/src/app/home/_components/invite-code-dialog/InviteCodeDialog.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { useMutation } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +import { getInvitePreviewByCode } from '@/app/tournament/join/_apis/getInvitePreviewByCode'; +import { + CODE_LENGTH, + isValidInviteCodeFormat, +} from '@/app/tournament/join/_utils/verifyInviteCode'; +import Button from '@/components/button'; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/dialog'; +import Input from '@/components/input'; +import Spinner from '@/components/spinner'; + +import InvalidCodeDialog from './InvalidCodeDialog'; + +type InviteCodeDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +function InviteCodeDialog({ open, onOpenChange }: InviteCodeDialogProps) { + const router = useRouter(); + const [code, setCode] = useState(''); + const [showFormatError, setShowFormatError] = useState(false); + const [isInvalidDialogOpen, setIsInvalidDialogOpen] = useState(false); + + const { mutate: previewMutation, isPending: isPreviewPending } = useMutation({ + mutationFn: getInvitePreviewByCode, + // mutation 진행 중 사용자가 input 을 바꿀 수 있으므로 + // 검증에 사용한 변수 (variables) 를 그대로 라우팅에 쓴다 — state 의 code 를 다시 읽지 않는다. + onSuccess: (data, enteredCode) => { + onOpenChange(false); + reset(); + router.push(`/tournament/join/${data.tournamentId}?code=${enteredCode}`); + }, + onError: error => { + // 400: 코드 없음 / 409: 만료 — 둘 다 사용자에게 InvalidCodeDialog 안내 + if (isAxiosError(error)) { + const status = error.response?.status; + if (status === 400 || status === 409) { + setIsInvalidDialogOpen(true); + return; + } + } + setIsInvalidDialogOpen(true); + }, + }); + + const isComplete = code.length === CODE_LENGTH; + const canSubmit = isComplete && !isPreviewPending; + + const reset = () => { + setCode(''); + setShowFormatError(false); + }; + + const handleOpenChange = (next: boolean) => { + if (!next) reset(); + onOpenChange(next); + }; + + const handleChange = (next: string) => { + setCode(next.slice(0, CODE_LENGTH).toUpperCase()); + if (showFormatError) setShowFormatError(false); + }; + + const handleSubmit = () => { + if (!canSubmit) return; + + if (!isValidInviteCodeFormat(code)) { + setShowFormatError(true); + return; + } + + previewMutation(code); + }; + + return ( + <> + + + + 초대받은 토너먼트 + + 초대 코드를 입력해 입장합니다. + + handleChange(event.target.value)} + aria-invalid={showFormatError} + {...(showFormatError + ? { helperText: '영문 대문자 3자 + 숫자 3자로 입력해주세요.' } + : {})} + maxLength={CODE_LENGTH} + autoCapitalize="characters" + autoCorrect="off" + spellCheck={false} + autoFocus + /> + + + + + + + + ); +} + +export default InviteCodeDialog; diff --git a/apps/web/src/app/home/page.tsx b/apps/web/src/app/home/page.tsx index 3587a6df..15e3affd 100644 --- a/apps/web/src/app/home/page.tsx +++ b/apps/web/src/app/home/page.tsx @@ -1,7 +1,6 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { getTournamentList } from '@/apis/getTournamentList'; -import { LoginIconOutline } from '@/assets/icons'; import PikiLogo from '@/assets/images/piki-logo.svg'; import BottomTabBar from '@/components/bottom-tab-bar'; import { Header, HeaderIcon } from '@/components/header'; @@ -9,6 +8,7 @@ import { getQueryClient } from '@/utils/queryClient'; import AddWishHomeDialog from './_components/AddWishHomeDialog'; import CreateTournamentDialog from './_components/CreateTournamentDialog'; +import InviteTournamentButton from './_components/InviteTournamentButton'; import TorunamentList from './_components/TournamentList'; async function HomePage() { @@ -47,13 +47,7 @@ async function HomePage() { {/* 초대 토너먼트 입장 */} - + diff --git a/apps/web/src/app/invite/[id]/_components/InviteClient.tsx b/apps/web/src/app/invite/[id]/_components/InviteClient.tsx new file mode 100644 index 00000000..3e14aa55 --- /dev/null +++ b/apps/web/src/app/invite/[id]/_components/InviteClient.tsx @@ -0,0 +1,83 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; + +import { getInvitePreviewByCode } from '@/app/tournament/join/_apis/getInvitePreviewByCode'; +import Button from '@/components/button'; +import Spinner from '@/components/spinner'; +import { ROUTES } from '@/consts/route'; + +type InviteClientProps = { + tournamentId: number; + inviteCode: string; +}; + +type InviteStateT = 'loading' | 'invalid'; + +function InviteClient({ tournamentId, inviteCode }: InviteClientProps) { + const router = useRouter(); + const [state, setState] = useState('loading'); + const hasRunRef = useRef(false); + + useEffect(() => { + if (hasRunRef.current) return; + hasRunRef.current = true; + + const run = async () => { + // 코드 없이 진입 → 잘못된 링크 + if (!inviteCode) { + setState('invalid'); + return; + } + + try { + const preview = await getInvitePreviewByCode(inviteCode); + // 코드의 토너먼트가 URL path와 다르면 잘못된 링크 + if (preview.tournamentId !== tournamentId) { + setState('invalid'); + return; + } + router.replace(`${ROUTES.TOURNAMENT_JOIN_BY_LINK(tournamentId)}?code=${inviteCode}`); + } catch { + // 400 (코드 불일치) / 409 (만료) / 네트워크 등 — 모두 "잘못된 링크" 안내로 통합한다. + setState('invalid'); + } + }; + + void run(); + }, [router, tournamentId, inviteCode]); + + if (state === 'loading') { + return ( +
+
+ +

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

+
+
+ ); + } + + return ( +
+
+

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

+

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

+
+ + + + +
+ ); +} + +export default InviteClient; diff --git a/apps/web/src/app/invite/[id]/page.tsx b/apps/web/src/app/invite/[id]/page.tsx new file mode 100644 index 00000000..abb66ef6 --- /dev/null +++ b/apps/web/src/app/invite/[id]/page.tsx @@ -0,0 +1,22 @@ +import { notFound } from 'next/navigation'; + +import { parseIdParam } from '@/utils/parseIdParam'; + +import InviteClient from './_components/InviteClient'; + +type InvitePageProps = { + params: Promise<{ id: string }>; + searchParams: Promise<{ code?: string }>; +}; + +async function InvitePage({ params, searchParams }: InvitePageProps) { + const { id } = await params; + const { code } = await searchParams; + const tournamentId = parseIdParam(id); + + if (tournamentId === null) notFound(); + + return ; +} + +export default InvitePage; diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index ee49e546..dc727dfa 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,10 +1,9 @@ +import { WEBVIEW_UA_TOKEN } from '@piki/core'; import type { Metadata } from 'next'; import localFont from 'next/font/local'; import { headers } from 'next/headers'; import React from 'react'; -import { WEBVIEW_UA_TOKEN } from '@piki/core'; - import { cn } from '@/utils/cn'; import Providers from '../components/Providers'; @@ -63,7 +62,7 @@ async function RootLayout({ )} - + {/** TEMP: max width 임시 값 */}
diff --git a/apps/web/src/app/login/_apis/getAuthUrl.ts b/apps/web/src/app/login/_apis/getAuthUrl.ts index 55b9bab1..e885366b 100644 --- a/apps/web/src/app/login/_apis/getAuthUrl.ts +++ b/apps/web/src/app/login/_apis/getAuthUrl.ts @@ -1,8 +1,9 @@ +import type { SocialProviderT } from '@piki/core'; + import { clientApi } from '@/apis/client'; import { ENDPOINTS } from '@/consts/api'; import { ROUTES } from '@/consts/route'; import type { ApiResponseT } from '@/types/api'; -import type { SocialProviderT } from '@piki/core'; import type { GetAuthUrlResponseT } from '../_types/login'; diff --git a/apps/web/src/app/login/_apis/postGuestLogin.ts b/apps/web/src/app/login/_apis/postGuestLogin.ts index d8aaaa9b..9c4f48b8 100644 --- a/apps/web/src/app/login/_apis/postGuestLogin.ts +++ b/apps/web/src/app/login/_apis/postGuestLogin.ts @@ -6,9 +6,10 @@ import type { ApiResponseT } from '@/types/api'; import type { PostGuestLoginResponseT } from '../_types/login'; export const postGuestLogin = async () => { + // body 없는 POST 라도 빈 객체로 보내야 일부 백엔드가 Content-Type 인식 후 처리한다 (415 방지). const { data } = await clientApi.post>( ENDPOINTS.AUTH_GUEST, - null + {} ); return data.data; diff --git a/apps/web/src/app/login/_components/SocialLoginButton.tsx b/apps/web/src/app/login/_components/SocialLoginButton.tsx index 02af553a..3ce5142b 100644 --- a/apps/web/src/app/login/_components/SocialLoginButton.tsx +++ b/apps/web/src/app/login/_components/SocialLoginButton.tsx @@ -1,8 +1,7 @@ 'use client'; -import type { ReactNode } from 'react'; - import { cva } from 'class-variance-authority'; +import type { ReactNode } from 'react'; import { cn } from '@/utils/cn'; @@ -19,18 +18,15 @@ const socialButtonStyles = cva( } ); -const labelStyles = cva( - 'text-[16px] font-semibold leading-6 tracking-[-0.312px]', - { - variants: { - variant: { - google: 'text-text-neutral-primary', - apple: 'text-white', - kakao: 'text-text-neutral-primary', - }, +const labelStyles = cva('text-[16px] font-semibold leading-6 tracking-[-0.312px]', { + variants: { + variant: { + google: 'text-text-neutral-primary', + apple: 'text-white', + kakao: 'text-text-neutral-primary', }, - } -); + }, +}); type SocialLoginButtonProps = { variant: 'google' | 'apple' | 'kakao'; diff --git a/apps/web/src/app/mypage/_components/LogoutMenuItem.tsx b/apps/web/src/app/mypage/_components/LogoutMenuItem.tsx index 4b372431..defec8cb 100644 --- a/apps/web/src/app/mypage/_components/LogoutMenuItem.tsx +++ b/apps/web/src/app/mypage/_components/LogoutMenuItem.tsx @@ -11,6 +11,7 @@ import { DialogTitle, DialogTrigger, } from '@/components/dialog'; + import { usePostLogout } from '../_hooks/usePostLogout'; function LogoutMenuItem() { diff --git a/apps/web/src/app/mypage/edit/_components/EditForm.tsx b/apps/web/src/app/mypage/edit/_components/EditForm.tsx index 24c84387..7c128099 100644 --- a/apps/web/src/app/mypage/edit/_components/EditForm.tsx +++ b/apps/web/src/app/mypage/edit/_components/EditForm.tsx @@ -5,7 +5,6 @@ import { type FormEvent, useState } from 'react'; import BottomCta from '@/components/bottom-cta'; import Button from '@/components/button'; import Spacing from '@/components/spacing'; - import { useGetMe } from '@/hooks/useGetMe'; import { useNicknameValidation } from '../_hooks/useNicknameValidation'; diff --git a/apps/web/src/app/play/[id]/_apis/postFromPlayLink.ts b/apps/web/src/app/play/[id]/_apis/postFromPlayLink.ts new file mode 100644 index 00000000..5f5ec0d4 --- /dev/null +++ b/apps/web/src/app/play/[id]/_apis/postFromPlayLink.ts @@ -0,0 +1,22 @@ +import { clientApi } from '@/apis/client'; +import { ENDPOINTS } from '@/consts/api'; +import type { ApiResponseT } from '@/types/api'; + +import type { PostFromPlayLinkResponseT } from '../_types/play'; + +/** + * 플레이 링크로 토너먼트 복제 생성. + * 호출자가 새 토너먼트의 소유자가 되며, 같은 아이템 구성으로 PENDING 상태로 시작된다. + * + * 에러 코드: + * - 401: 미인증 — 클라에서 게스트 자동 발급 후 재시도 + * - 404: 원본 토너먼트 없음 또는 플레이 링크 미생성 + * - 409: 플레이 링크 만료 또는 이미 동일 링크로 토너먼트 생성한 경우 + */ +export const postFromPlayLink = async (sourceTournamentId: number) => { + const { data } = await clientApi.post>( + ENDPOINTS.TOURNAMENT_FROM_PLAY_LINK(sourceTournamentId), + {} + ); + return data.data; +}; diff --git a/apps/web/src/app/play/[id]/_components/PlayClient.tsx b/apps/web/src/app/play/[id]/_components/PlayClient.tsx new file mode 100644 index 00000000..c13b0ecd --- /dev/null +++ b/apps/web/src/app/play/[id]/_components/PlayClient.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { isAxiosError } from 'axios'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; + +import { postGuestLogin } from '@/app/login/_apis/postGuestLogin'; +import { getTournament } from '@/app/tournament/[id]/_common/_apis/getTournament'; +import Button from '@/components/button'; +import Spinner from '@/components/spinner'; +import { ROUTES } from '@/consts/route'; + +import { postFromPlayLink } from '../_apis/postFromPlayLink'; + +type PlayClientProps = { + sourceTournamentId: number; +}; + +type PlayStateT = 'loading' | 'expired'; + +function PlayClient({ sourceTournamentId }: PlayClientProps) { + const router = useRouter(); + const [state, setState] = useState('loading'); + const hasRunRef = useRef(false); + + useEffect(() => { + // StrictMode/dev double-invoke 방지 + if (hasRunRef.current) return; + hasRunRef.current = true; + + // CLONE 의 status 에 따라 적절한 화면으로 라우팅한다. + // - PENDING: 아직 본인 매치를 시작 안 한 상태 → create (바구니 미리보기 + 시작 버튼) + // - IN_PROGRESS: 매치 진행 중 → match 로 이어서 + // - COMPLETED: 이미 끝낸 토너먼트 (재진입) → result 로 결과 다시 보기 + const goToTournament = async (id: number) => { + const data = await getTournament(id); + if (data.status === 'COMPLETED') { + router.replace(ROUTES.TOURNAMENT_RESULT(id)); + return; + } + if (data.status === 'IN_PROGRESS' && !data.pending) { + router.replace(ROUTES.TOURNAMENT_MATCH(id)); + return; + } + router.replace(ROUTES.TOURNAMENT_CREATE(id)); + }; + + /** + * 인증 누락으로 인한 실패인지 판단. + * 비로그인 상태에서는 백엔드가 401 을 던지지만, axios interceptor 가 자동 refresh 를 시도하다가 + * refresh 도 실패해서 400 (refresh 토큰 없음) 으로 변환돼서 올라온다. 둘 다 게스트 발급으로 회복 시도. + */ + const isUnauthenticated = (error: unknown) => { + if (!isAxiosError(error)) return false; + const status = error.response?.status; + return status === 401 || status === 400; + }; + + const run = async () => { + try { + const newTournamentId = await postFromPlayLink(sourceTournamentId); + await goToTournament(newTournamentId); + } catch (error) { + if (isUnauthenticated(error)) { + try { + await postGuestLogin(); + const newTournamentId = await postFromPlayLink(sourceTournamentId); + await goToTournament(newTournamentId); + return; + } catch { + setState('expired'); + return; + } + } + // 404 (없음) / 409 (만료) — 만료 안내로 통합. + // 같은 사용자의 재진입은 백엔드가 idempotent 로 처리해 200 + 기존 CLONE id 반환한다. + setState('expired'); + } + }; + + void run(); + }, [router, sourceTournamentId]); + + if (state === 'loading') { + return ( +
+
+ +

토너먼트를 준비하고 있어요...

+
+
+ ); + } + + return ( +
+
+

플레이 링크가 유효하지 않아요

+

+ 만료됐거나 이미 진행한 토너먼트일 수 있어요. +
+ 공유한 친구에게 새 링크를 요청해주세요. +

+
+ + + + +
+ ); +} + +export default PlayClient; diff --git a/apps/web/src/app/play/[id]/_types/play.ts b/apps/web/src/app/play/[id]/_types/play.ts new file mode 100644 index 00000000..95e99244 --- /dev/null +++ b/apps/web/src/app/play/[id]/_types/play.ts @@ -0,0 +1,2 @@ +/** 플레이 링크로 복제 토너먼트 생성 응답 — 생성된 새 tournamentId */ +export type PostFromPlayLinkResponseT = number; diff --git a/apps/web/src/app/play/[id]/page.tsx b/apps/web/src/app/play/[id]/page.tsx new file mode 100644 index 00000000..99891f69 --- /dev/null +++ b/apps/web/src/app/play/[id]/page.tsx @@ -0,0 +1,20 @@ +import { notFound } from 'next/navigation'; + +import { parseIdParam } from '@/utils/parseIdParam'; + +import PlayClient from './_components/PlayClient'; + +type PlayPageProps = { + params: Promise<{ id: string }>; +}; + +async function PlayPage({ params }: PlayPageProps) { + const { id } = await params; + const sourceTournamentId = parseIdParam(id); + + if (sourceTournamentId === null) notFound(); + + return ; +} + +export default PlayPage; diff --git a/apps/web/src/app/tournament/[id]/_common/_hooks/useGetTournament.ts b/apps/web/src/app/tournament/[id]/_common/_hooks/useGetTournament.ts index 9e67c68d..deba7dc7 100644 --- a/apps/web/src/app/tournament/[id]/_common/_hooks/useGetTournament.ts +++ b/apps/web/src/app/tournament/[id]/_common/_hooks/useGetTournament.ts @@ -6,6 +6,9 @@ export const useGetTournament = (tournamentId: number) => { const { data: tournamentData } = useSuspenseQuery({ queryKey: ['tournament', tournamentId], queryFn: () => getTournament(tournamentId), + // 참여자(isOwner=false)는 주최자가 ROOT 를 시작(`ownerStarted=true`) 했는지 감지하려고 주기적으로 polling. + // 주최자/완료 화면에선 사실상 무해 (focus refetch 도 함께 동작). + refetchInterval: 30_000, }); return { tournamentData }; diff --git a/apps/web/src/app/tournament/[id]/_common/_types/tournamentResponse.ts b/apps/web/src/app/tournament/[id]/_common/_types/tournamentResponse.ts index 17de6232..ae8c3e0e 100644 --- a/apps/web/src/app/tournament/[id]/_common/_types/tournamentResponse.ts +++ b/apps/web/src/app/tournament/[id]/_common/_types/tournamentResponse.ts @@ -11,22 +11,72 @@ export type TournamentParticipantT = { profileImage: string; }; -/** PENDING — 토너먼트 아이템 담는 중 */ +/** + * `pending` 필드 페이로드의 item — PENDING 단계라 name/imageUrl/price 등이 아직 없을 수 있다. + */ +export type TournamentPendingItemT = Partial & { + tournamentItemId: number; + itemId: number; +}; + +/** + * `pending` 필드 페이로드. + * status=PENDING 또는 status=IN_PROGRESS (참여자 대기 케이스) 일 때 내려온다. + */ +type TournamentPendingPayloadT = { + /** + * 주최자가 ROOT 토너먼트를 시작했는지 여부. + * - false (status=PENDING): "주최자가 시작해야..." 안내 + * - true (status=IN_PROGRESS): 참여자도 본인 CLONE 시작 가능 + */ + ownerStarted: boolean; + /** 초대 코드. `ownerStarted=true` 이면 이미 초대 기간이 종료돼 null */ + inviteCode: string | null; + /** 초대 코드 만료 시각 (ISO 8601). `ownerStarted=true` 이면 null */ + inviteExpiresAt: string | null; + items: TournamentPendingItemT[]; + participants: TournamentParticipantT[]; +}; + +/** PENDING — 토너먼트 아이템 담는 중. */ export type GetTournamentPendingResponseT = { tournamentId: number; name: string; + /** 요청자가 토너먼트 소유자(주최자)인지 여부 */ + isOwner: boolean; + /** ROOT(원본)이면 true, CLONE(플레이 링크/멤버 시작으로 복제된 인스턴스)이면 false */ + isRoot: boolean; status: Extract; - pending: { - items: Array & { tournamentItemId: number; itemId: number }>; - participants: TournamentParticipantT[]; - }; + pending: TournamentPendingPayloadT; +}; + +/** + * IN_PROGRESS — 참여자가 본인 매치를 아직 시작하지 않은 대기 상태. + * 주최자가 ROOT 를 시작했지만 참여자(isOwner=false)는 본인 CLONE 시작 전. + * 응답은 PENDING 과 동일한 `pending` 페이로드를 받지만 `ownerStarted=true`. + */ +export type GetTournamentMemberWaitingResponseT = { + tournamentId: number; + name: string; + isOwner: boolean; + isRoot: boolean; + status: Extract; + pending: TournamentPendingPayloadT; + inProgress?: undefined; }; -/** IN_PROGRESS — 진행 중 (재진입 시 복원용) */ +/** IN_PROGRESS — 본인 인스턴스의 매치가 진행 중 (재진입 시 복원용). */ export type GetTournamentInProgressResponseT = { tournamentId: number; name: string; + /** 요청자가 토너먼트 소유자(주최자)인지 여부 */ + isOwner: boolean; + /** ROOT(원본)이면 true, CLONE 이면 false */ + isRoot: boolean; + /** CLONE 일 때만 존재 — 원본(ROOT) 토너먼트 id. group-result 호출 등에서 사용. */ + sourceTournamentId?: number; status: Extract; + pending?: undefined; inProgress: { currentRound: number; lastHistory: TournamentMatchHistoryT | null; @@ -38,21 +88,41 @@ export type GetTournamentInProgressResponseT = { export type GetTournamentCompletedResponseT = { tournamentId: number; name: string; + /** 요청자가 토너먼트 소유자(주최자)인지 여부 */ + isOwner: boolean; + /** ROOT(원본)이면 true, CLONE 이면 false */ + isRoot: boolean; + /** CLONE 일 때만 존재 — 원본(ROOT) 토너먼트 id. group-result 호출 등에서 사용. */ + sourceTournamentId?: number; status: Extract; completed: { result: TournamentRankingT[]; + /** 참여자 2명 이상이면 true — 친구 토너먼트 결과 보기 버튼 노출용 */ + hasGroupResult: boolean; + /** 플레이 링크 만료 시각 (ISO 8601). 아직 링크 생성 전이면 응답에 없음 */ + playLinkExpiresAt?: string; }; }; export type GetTournamentResponseT = | GetTournamentPendingResponseT + | GetTournamentMemberWaitingResponseT | GetTournamentInProgressResponseT | GetTournamentCompletedResponseT; +/** + * 시작 응답. + * - 주최자(ROOT): 본인 tournamentId 반환 + * - 참여자(CLONE): 새로 생성된 CLONE tournamentId 반환 (이후 본인 ID 로 진행) + */ export type PostStartTournamentResponseT = { + tournamentId: number; items: TournamentItemT[]; }; +/** 플레이 링크 생성 응답 — playLinkExpiresAt 문자열만 반환 */ +export type PostPlayLinkResponseT = string; + export type PostRecordMatchRequestT = TournamentMatchHistoryT; /** diff --git a/apps/web/src/app/tournament/[id]/create/_apis/getTournament.ts b/apps/web/src/app/tournament/[id]/create/_apis/getTournament.ts deleted file mode 100644 index 52f70b22..00000000 --- a/apps/web/src/app/tournament/[id]/create/_apis/getTournament.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 { GetTournamentResponseT } from '../_types/tournament'; - -export const getTournament = async (tournamentId: number) => { - const id = Number(tournamentId); - - if (environmentManager.isServer()) { - const { data } = await serverApi.get>( - ENDPOINTS.TOURNAMENT(id) - ); - - return data.data; - } - - const { data } = await clientApi.get>( - ENDPOINTS.TOURNAMENT(id) - ); - - return data.data; -}; diff --git a/apps/web/src/app/tournament/[id]/create/_apis/postTournamentStart.ts b/apps/web/src/app/tournament/[id]/create/_apis/postTournamentStart.ts index 0d3887e0..b27269ca 100644 --- a/apps/web/src/app/tournament/[id]/create/_apis/postTournamentStart.ts +++ b/apps/web/src/app/tournament/[id]/create/_apis/postTournamentStart.ts @@ -2,10 +2,10 @@ import { clientApi } from '@/apis/client'; import { ENDPOINTS } from '@/consts/api'; import type { ApiResponseT } from '@/types/api'; -import type { PostTournamentStartResponseT } from '../_types/tournament'; +import type { PostStartTournamentResponseT } from '../../_common/_types/tournamentResponse'; export const postTournamentStart = async (tournamentId: number) => { - const { data } = await clientApi.post>( + const { data } = await clientApi.post>( ENDPOINTS.TOURNAMENT_START(Number(tournamentId)) ); diff --git a/apps/web/src/app/tournament/[id]/create/_assets/confirm-exit-basket.svg b/apps/web/src/app/tournament/[id]/create/_assets/confirm-exit-basket.svg new file mode 100644 index 00000000..6c30f46e --- /dev/null +++ b/apps/web/src/app/tournament/[id]/create/_assets/confirm-exit-basket.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/src/app/tournament/[id]/create/_assets/confirm-start-face.svg b/apps/web/src/app/tournament/[id]/create/_assets/confirm-start-face.svg new file mode 100644 index 00000000..9687def4 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/create/_assets/confirm-start-face.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx b/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx index 11a0645f..b674e914 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx @@ -1,17 +1,29 @@ 'use client'; +import { useState } from 'react'; + import { Dialog } from '@/components/dialog'; import GetItemDialogContent from '@/components/get-item-dialog'; import { QUERY_ACTION } from '@/consts/queryAction'; import { useQueryAction } from '@/hooks/useQueryAction'; -import { useGetTournament } from '../_hooks/useGetTournament'; +import { + type JoinConfirmPayloadT, + type JoinWelcomePayloadT, + consumeJoinConfirmFor, + consumeJoinWelcomeFor, +} from '../../../join/_utils/joinSession'; +import { useGetTournament } from '../../_common/_hooks/useGetTournament'; +import { useCountdown } from '../_hooks/useCountdown'; import { useScrollToLast } from '../_hooks/useScrollToLast'; -import InviteFriends from './invite-friends/InviteFriends'; +import DepositCountdown from './deposit-countdown/DepositCountdown'; +import MemberJoinConfirmDialog from './member-join-confirm-dialog/MemberJoinConfirmDialog'; +import ParticipantPanel from './participant-panel/ParticipantPanel'; import TournamentHeader from './tournament-header/TournamentHeader'; import TournamentItemBasketStatus from './tournament-item-basket-status/TournamentItemBasketStatus'; import TournamentItemBasketCarousel from './tournament-item-basket/TournamentItemBasketCarousel'; import TournamentStartButton from './tournament-start-button/TournamentStartButton'; +import WelcomeJoinDialog from './welcome-join-dialog/WelcomeJoinDialog'; type TournamentCreateClientProps = { tournamentId: number; @@ -21,44 +33,126 @@ function TournamentCreateClient({ tournamentId }: TournamentCreateClientProps) { const { scrollToLast, onScrolled } = useScrollToLast(); const { tournamentData } = useGetTournament(tournamentId); + // 주최자(ROOT)는 시작/완료 시점에 mutation/페이지에서 직접 라우팅한다. + // 참여자(CLONE 생성 예정)는 ROOT status 변화에 따라 자동 라우팅하지 않고, + // 본인이 "시작" 버튼을 눌러 CLONE 을 만들고 그 ID 로 매치 화면 진입한다. + // → status 기반 자동 라우팅이 필요 없다. + + // create 화면은 pending payload 가 있는 상태(PENDING / IN_PROGRESS-MemberWaiting) 만 다룬다. + // 그 외 상태는 RSC/match·result 페이지에서 라우팅되므로 여기서는 빈 값으로 안전 처리. + const pending = 'pending' in tournamentData ? tournamentData.pending : null; + + // 주최자가 ROOT 를 시작한 후(ownerStarted=true) 에는 초대 기간이 이미 종료된 상태라 + // inviteExpiresAt 이 null 이고 담기 마감 카운트다운도 의미 없다. + const ownerStarted = pending?.ownerStarted ?? false; + const depositDeadline = pending?.inviteExpiresAt ?? ''; + const { isExpired } = useCountdown(depositDeadline); + // 담기 마감 = 초대 코드 만료 시점 (둘은 동일 정책으로 운영). + // 단, 주최자는 만료 영향 없이 본인 토너먼트에 후보를 담을 수 있다. + // ownerStarted 면 어차피 시작 흐름으로 넘어가야 하므로 마감 처리하지 않는다. + const isDepositClosed = !tournamentData.isOwner && !ownerStarted && isExpired; + // 비회원(GUEST) 은 서버가 dicebear 자동 아바타를 내려주는데, + // 우리 디자인상 비회원은 기본 SVG 프로필을 노출해야 하므로 무시한다. + const isGeneratedAvatar = (url: string) => url.includes('api.dicebear.com'); + const participants = (pending?.participants ?? []).map(p => ({ + user: { + id: p.userId, + name: p.nickname, + profileType: 'blue' as const, + ...(isGeneratedAvatar(p.profileImage) ? {} : { imageUrl: p.profileImage }), + }, + itemCount: 0, + })); + const hasFriends = participants.length > 1; + + const [welcomePayload, setWelcomePayload] = useState(() => + consumeJoinWelcomeFor(tournamentId) + ); + const [confirmPayload, setConfirmPayload] = useState(() => + consumeJoinConfirmFor(tournamentId) + ); + const isParticipant = !tournamentData.isOwner; + // 참여자는 주최자가 ROOT 를 시작한 후(ownerStarted=true) 부터 본인 CLONE 시작 가능. + const isWaitingForOwnerStart = isParticipant && pending?.ownerStarted === false; + const { isActive: isGetItemDialogOpen, setIsActive: setIsGetItemDialogOpen } = useQueryAction({ action: QUERY_ACTION.VALUE.OPEN_GET_ITEM_DIALOG, }); + const handleCloseWelcome = () => setWelcomePayload(null); + const handleCloseConfirm = () => setConfirmPayload(null); + return (
- - + + item.status === 'PROCESSING') ?? false - } - count={tournamentData.pending?.items.length ?? 0} + isProcessing={pending?.items.some(item => item.status === 'PROCESSING') ?? false} + count={pending?.items.length ?? 0} + isDepositClosed={isDepositClosed} />
-
+
+ {hasFriends && !ownerStarted && !isDepositClosed && ( + + )} item.status === 'PROCESSING' || item.status === 'FAILED' - ) ?? false + pending?.items.some(item => item.status === 'PROCESSING' || item.status === 'FAILED') ?? + false } + hasFriends={hasFriends} + isWaitingForOwnerStart={isWaitingForOwnerStart} + isDepositClosed={isDepositClosed} + isParticipant={isParticipant} />
+ + {welcomePayload && ( + { + if (!open) handleCloseWelcome(); + }} + nickname={welcomePayload.nickname} + profileType={welcomePayload.profileType} + onConfirm={handleCloseWelcome} + /> + )} + + {confirmPayload && ( + { + if (!open) handleCloseConfirm(); + }} + nickname={confirmPayload.nickname} + profileType={confirmPayload.profileType} + tournamentName={confirmPayload.tournamentName} + itemCount={confirmPayload.itemCount} + participantCount={confirmPayload.participantCount} + onConfirm={handleCloseConfirm} + /> + )}
); } diff --git a/apps/web/src/app/tournament/[id]/create/_components/deposit-countdown/DepositCountdown.tsx b/apps/web/src/app/tournament/[id]/create/_components/deposit-countdown/DepositCountdown.tsx new file mode 100644 index 00000000..3d4aec95 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/create/_components/deposit-countdown/DepositCountdown.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { TimerIconFill } from '@/assets/icons/fill'; + +import { useCountdown } from '../../_hooks/useCountdown'; + +type DepositCountdownProps = { + deadline: Date | string | number; +}; + +function DepositCountdown({ deadline }: DepositCountdownProps) { + const { remaining } = useCountdown(deadline); + + return ( +
+ +

{remaining ?? '--:--:--'} 후 담기 종료

+
+ ); +} + +export default DepositCountdown; diff --git a/apps/web/src/app/tournament/[id]/create/_components/invite-friends/InviteFriends.tsx b/apps/web/src/app/tournament/[id]/create/_components/invite-friends/InviteFriends.tsx deleted file mode 100644 index ed481a88..00000000 --- a/apps/web/src/app/tournament/[id]/create/_components/invite-friends/InviteFriends.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { AddIconOutline } from '@/assets/icons'; - -function InviteFriends() { - return ( -
-
-
-
-
-
-
-
-

친구와 함께 담아보세요

-
-
- -
- ); -} - -export default InviteFriends; diff --git a/apps/web/src/app/tournament/[id]/create/_components/invite-friends/InviteFriendsDialog.tsx b/apps/web/src/app/tournament/[id]/create/_components/invite-friends/InviteFriendsDialog.tsx new file mode 100644 index 00000000..afce6779 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/create/_components/invite-friends/InviteFriendsDialog.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useMemo } from 'react'; +import { toast } from 'sonner'; + +import { CheckIconFill, StopwatchIconFill } from '@/assets/icons/fill'; +import Button from '@/components/button'; +import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/drawer'; +import { share } from '@/utils/share'; + +type InviteFriendsDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + inviteUrl?: string; + /** ISO 8601 — 초대 코드 만료 시각 */ + inviteExpiresAt?: string; +}; + +const isSameDay = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate(); + +const formatExpiresInfo = (expiresAt: string | undefined) => { + if (!expiresAt) return null; + const expires = new Date(expiresAt); + if (Number.isNaN(expires.getTime())) return null; + + const now = new Date(); + const remainingMs = expires.getTime() - now.getTime(); + if (remainingMs <= 0) return { remainingLabel: '마감', absoluteLabel: '만료됨' }; + + const totalMinutes = Math.floor(remainingMs / 60_000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + const remainingLabel = (() => { + if (totalMinutes < 60) return `${totalMinutes}분 후 마감`; + if (minutes === 0) return `${hours}시간 후 마감`; + return `${hours}시간 ${minutes}분 후 마감`; + })(); + + const hh = String(expires.getHours()).padStart(2, '0'); + const mm = String(expires.getMinutes()).padStart(2, '0'); + const dayPrefix = (() => { + if (isSameDay(now, expires)) return '오늘'; + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + if (isSameDay(tomorrow, expires)) return '내일'; + return `${expires.getMonth() + 1}월 ${expires.getDate()}일`; + })(); + + return { remainingLabel, absoluteLabel: `${dayPrefix} ${hh}:${mm}까지` }; +}; + +function InviteFriendsDialog({ + open, + onOpenChange, + inviteUrl, + inviteExpiresAt, +}: InviteFriendsDialogProps) { + const expiresInfo = useMemo(() => formatExpiresInfo(inviteExpiresAt), [inviteExpiresAt]); + + const handleSendInviteLink = async () => { + if (!inviteUrl) return; + + const result = await share({ + title: 'piki 토너먼트 초대', + text: '친구와 함께 piki 토너먼트에 담아봐요!', + url: inviteUrl, + }); + + if (result === 'shared' || result === 'copied') { + toast.success('링크를 성공적으로 공유했어요.'); + } + if (result === 'failed') toast.warning('공유에 실패했어요. 다시 시도해주세요.'); + }; + + return ( + + +
+
+ 친구 초대하기 + + 초대 링크를 보내 친구와 함께 담을 수 있어요. + +
+ + {expiresInfo && ( +
+
+
+ +
+
+

{expiresInfo.remainingLabel}

+

{expiresInfo.absoluteLabel}

+
+
+
+ )} + +
+
+ +

+ 최대 8명까지 초대할 수 있어요. +

+
+
+ +

+ 설정한 기한이 지나면 후보를 담을 수 없어요. +

+
+
+ + +
+
+
+ ); +} + +export default InviteFriendsDialog; diff --git a/apps/web/src/app/tournament/[id]/create/_components/member-join-confirm-dialog/MemberJoinConfirmDialog.tsx b/apps/web/src/app/tournament/[id]/create/_components/member-join-confirm-dialog/MemberJoinConfirmDialog.tsx new file mode 100644 index 00000000..99ce58f0 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/create/_components/member-join-confirm-dialog/MemberJoinConfirmDialog.tsx @@ -0,0 +1,60 @@ +'use client'; + +import Button from '@/components/button'; +import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/drawer'; +import { PROFILE_SVG, type ProfileTypeT } from '@/components/user-profile-group/userProfile.const'; + +type MemberJoinConfirmDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + nickname: string; + profileType: ProfileTypeT; + tournamentName: string; + itemCount: number; + participantCount: number; + onConfirm: () => void; +}; + +function MemberJoinConfirmDialog({ + open, + onOpenChange, + nickname, + profileType, + tournamentName, + itemCount, + participantCount, + onConfirm, +}: MemberJoinConfirmDialogProps) { + const ProfileSvg = PROFILE_SVG[profileType]; + + return ( + + +
+
+ +
+ {nickname} + + 이 프로필로 참여할게요. + +
+
+ +
+

{tournamentName}

+

+ 후보 {itemCount}개 · 참여 {participantCount}명 +

+
+ + +
+
+
+ ); +} + +export default MemberJoinConfirmDialog; diff --git a/apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantChip.tsx b/apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantChip.tsx new file mode 100644 index 00000000..8ff4f983 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantChip.tsx @@ -0,0 +1,19 @@ +import UserProfile from '@/components/user-profile-group/UserProfile'; +import type { UserT } from '@/components/user-profile-group/userProfile.const'; + +type ParticipantChipProps = { + user: UserT; + itemCount: number; +}; + +function ParticipantChip({ user, itemCount }: ParticipantChipProps) { + return ( + + + {user.name ?? '익명'} + {itemCount}개 + + ); +} + +export default ParticipantChip; diff --git a/apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantPanel.tsx b/apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantPanel.tsx new file mode 100644 index 00000000..5e7bfdb7 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantPanel.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { useState } from 'react'; + +import { AddIconOutline, ChevronDownIconOutline, ChevronUpIconOutline } from '@/assets/icons'; +import UserProfileGroup from '@/components/user-profile-group'; +import type { UserT } from '@/components/user-profile-group/userProfile.const'; + +import InviteFriendsDialog from '../invite-friends/InviteFriendsDialog'; +import ParticipantChip from './ParticipantChip'; + +const buildInviteUrl = (tournamentId: string, inviteCode: string) => { + if (typeof window === 'undefined') return `/invite/${tournamentId}?code=${inviteCode}`; + return `${window.location.origin}/invite/${tournamentId}?code=${inviteCode}`; +}; + +type ParticipantT = { + user: UserT; + itemCount: number; +}; + +type ParticipantPanelProps = { + participants: ParticipantT[]; + /** 토너먼트 초대 코드 (PENDING 상태에서만 존재) */ + inviteCode?: string; + /** 초대 코드 만료 시각 (ISO 8601) */ + inviteExpiresAt?: string; +}; + +const PLACEHOLDER_AVATAR_COUNT = 2; + +const getCollapsedLabel = (participants: ParticipantT[]) => { + const names = participants.map(p => p.user.name).filter((name): name is string => Boolean(name)); + if (names.length === 0) return '나'; + const head = names.slice(0, 3).join(' · '); + const rest = names.length - 3; + if (rest <= 0) return head; + return `${head} 외 ${rest}명`; +}; + +function ParticipantPanel({ participants, inviteCode, inviteExpiresAt }: ParticipantPanelProps) { + const { id: tournamentId } = useParams<{ id: string }>(); + const [isExpanded, setIsExpanded] = useState(false); + const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false); + + const hasFriends = participants.length > 1; + const label = getCollapsedLabel(participants); + const users = participants.map(p => p.user); + const inviteUrl = inviteCode ? buildInviteUrl(tournamentId, inviteCode) : ''; + + const handleToggleExpand = () => setIsExpanded(prev => !prev); + const handleOpenInvite = () => setIsInviteDialogOpen(true); + + return ( + <> + {hasFriends ? ( +
+ + + {isExpanded && ( +
+ {participants.map(({ user, itemCount }) => ( + + ))} + +
+ )} +
+ ) : ( + + )} + + + + ); +} + +export default ParticipantPanel; diff --git a/apps/web/src/app/tournament/[id]/create/_components/tournament-header/ConfirmExitDialog.tsx b/apps/web/src/app/tournament/[id]/create/_components/tournament-header/ConfirmExitDialog.tsx new file mode 100644 index 00000000..a2c5901f --- /dev/null +++ b/apps/web/src/app/tournament/[id]/create/_components/tournament-header/ConfirmExitDialog.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/dialog'; + +import ConfirmExitBasket from '../../_assets/confirm-exit-basket.svg'; + +type ConfirmExitDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}; + +function ConfirmExitDialog({ open, onOpenChange, onConfirm }: ConfirmExitDialogProps) { + const handleCancel = () => onOpenChange(false); + + return ( + + + + +
+ + 담기를 잠깐 멈출까요? + + + 지금까지 담은 후보는 +
+ 자동으로 저장돼 있어요. +
+
+ +
+ + +
+
+
+ ); +} + +export default ConfirmExitDialog; diff --git a/apps/web/src/app/tournament/[id]/create/_components/tournament-header/TournamentHeader.tsx b/apps/web/src/app/tournament/[id]/create/_components/tournament-header/TournamentHeader.tsx index 1f42bd3d..509965b1 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/tournament-header/TournamentHeader.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/tournament-header/TournamentHeader.tsx @@ -1,12 +1,56 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +import { ChevronBackwardIconFill } from '@/assets/icons/fill'; + +import ConfirmExitDialog from './ConfirmExitDialog'; + type TournamentHeaderProps = { name: string; + hasFriends: boolean; }; -function TournamentHeader({ name }: TournamentHeaderProps) { +function TournamentHeader({ name, hasFriends }: TournamentHeaderProps) { + const router = useRouter(); + const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false); + + const handleBackClick = () => { + if (hasFriends) { + setIsExitConfirmOpen(true); + return; + } + router.back(); + }; + + const handleConfirmExit = () => { + setIsExitConfirmOpen(false); + router.back(); + }; + return ( -
-

{name}

-
+ <> +
+ +

+ {name} +

+
+ + + ); } diff --git a/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket-status/TournamentItemBasketStatus.tsx b/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket-status/TournamentItemBasketStatus.tsx index 06b0aba5..cf64d89e 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket-status/TournamentItemBasketStatus.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket-status/TournamentItemBasketStatus.tsx @@ -1,10 +1,16 @@ +import { cn } from '@/utils/cn'; type TournamentItemBasketStatusProps = { isProcessing: boolean; count: number; + isDepositClosed?: boolean; }; -function TournamentItemBasketStatus({ isProcessing, count }: TournamentItemBasketStatusProps) { +function TournamentItemBasketStatus({ + isProcessing, + count, + isDepositClosed = false, +}: TournamentItemBasketStatusProps) { const label = (() => { if (isProcessing) return '담는 중...'; if (count < 2) return '최소 2개 이상 담아주세요'; @@ -13,7 +19,14 @@ function TournamentItemBasketStatus({ isProcessing, count }: TournamentItemBaske return (
- + {label}
diff --git a/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/EmptyBasketSlot.tsx b/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/EmptyBasketSlot.tsx index bf21cf95..16a2927a 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/EmptyBasketSlot.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/EmptyBasketSlot.tsx @@ -4,13 +4,19 @@ import { EMPTY_BASKET_IMAGES } from '../../_consts/tournamentItemBasket'; type EmptyBasketSlotProps = { slotIndex: number; + isDepositClosed?: boolean; }; -function EmptyBasketSlot({ slotIndex }: EmptyBasketSlotProps) { +function EmptyBasketSlot({ slotIndex, isDepositClosed = false }: EmptyBasketSlotProps) { + if (isDepositClosed) { + // TODO: 디자이너가 전달한 빈 슬롯 SVG로 교체 + return
; + } + const imageUrl = EMPTY_BASKET_IMAGES[slotIndex % EMPTY_BASKET_IMAGES.length]; return ( -
+
); diff --git a/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/TournamentBasketItem.tsx b/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/TournamentBasketItem.tsx index bd8dcc40..78245d16 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/TournamentBasketItem.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/TournamentBasketItem.tsx @@ -1,9 +1,10 @@ import ProductImage from '@/app/tournament/[id]/create/_components/product-image'; -import type { TournamentItemT } from '@/types/tournament'; import { cn } from '@/utils/cn'; +import type { TournamentPendingItemT } from '../../../_common/_types/tournamentResponse'; + type TournamentBasketItemProps = { - item: TournamentItemT; + item: TournamentPendingItemT; index: number; onClick?: () => void; }; diff --git a/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/TournamentItemBasket.tsx b/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/TournamentItemBasket.tsx index e9e2f5ec..cf82c163 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/TournamentItemBasket.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/TournamentItemBasket.tsx @@ -6,8 +6,8 @@ import AddIcon from '@/assets/icons/fill/add.svg'; import Button from '@/components/button'; import { Dialog, DialogTrigger } from '@/components/dialog'; import GetItemDialogContent from '@/components/get-item-dialog'; -import type { TournamentItemT } from '@/types/tournament'; +import type { TournamentPendingItemT } from '../../../_common/_types/tournamentResponse'; import basketImg from '../../_assets/basket-gray.png'; import { ITEMS_PER_BASKET } from '../../_consts/tournamentItemBasket'; import EmptyBasketSlot from './EmptyBasketSlot'; @@ -16,17 +16,23 @@ import TournamentItemFailedModal from './TournamentItemFailedDrawer'; type TournamentItemBasketProps = { basketIndex: number; - items: TournamentItemT[]; - maxHeight?: number | null; + items: TournamentPendingItemT[]; + maxHeight?: number; + isDepositClosed?: boolean; }; -function TournamentItemBasket({ basketIndex, items, maxHeight }: TournamentItemBasketProps) { +function TournamentItemBasket({ + basketIndex, + items, + maxHeight, + isDepositClosed = false, +}: TournamentItemBasketProps) { const { id } = useParams<{ id: string }>(); const tournamentId = Number(id); const basketMaxWidth = maxHeight ? (maxHeight * 356) / 464 : null; - const [failedItem, setFailedItem] = useState(null); + const [failedItem, setFailedItem] = useState(null); const handleItemClick = (item: TournamentItemBasketProps['items'][number]) => { // if (item.status === 'READY') @@ -60,16 +66,22 @@ function TournamentItemBasket({ basketIndex, items, maxHeight }: TournamentItemB onClick={() => handleItemClick(item)} /> ); - return ; + return ( + + ); })} - {items.length < ITEMS_PER_BASKET && ( + {!isDepositClosed && ( diff --git a/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/TournamentItemBasketCarousel.tsx b/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/TournamentItemBasketCarousel.tsx index 9c4160f9..8045d584 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/TournamentItemBasketCarousel.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/TournamentItemBasketCarousel.tsx @@ -1,67 +1,97 @@ 'use client'; -import { useEffect, useRef } from 'react'; +import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { toast } from 'sonner'; - -import useContainerHeight from '@/hooks/useContainerHeight'; - -import { Carousel, CarouselContent, CarouselItem } from '@/components/carousel'; -import type { TournamentItemT } from '@/types/tournament'; +import { Carousel, type CarouselApi, CarouselContent, CarouselItem } from '@/components/carousel'; import { cn } from '@/utils/cn'; +import type { TournamentPendingItemT } from '../../../_common/_types/tournamentResponse'; import { BASKET_CAROUSEL_SLIDE_SIZE_PERCENT, ITEMS_PER_BASKET, } from '../../_consts/tournamentItemBasket'; -import { useBasketCarousel } from '../../_hooks/useBasketCarousel'; +import { getActiveBasketCount, getBasketIndexForLastItem } from '../../_utils/tournamentItemBasket'; import CarouselIndicator from './CarouselIndicator'; import TournamentItemBasket from './TournamentItemBasket'; type TournamentItemBasketCarouselProps = { - items?: TournamentItemT[]; + items?: TournamentPendingItemT[]; scrollToLast?: boolean; onScrolled?: () => void; + isDepositClosed?: boolean; }; function TournamentItemBasketCarousel({ items = [], scrollToLast = false, onScrolled, + isDepositClosed = false, }: TournamentItemBasketCarouselProps) { - const { - carouselApi, - setCarouselApi, - currentIndex, - activeBasketCount, - isCarouselEnabled, - handleIndicatorSelect, - } = useBasketCarousel({ items, scrollToLast, onScrolled }); - - const prevBasketCountRef = useRef(activeBasketCount); + const [carouselApi, setCarouselApi] = useState(); + const [currentIndex, setCurrentIndex] = useState(0); + + const activeBasketCount = useMemo(() => getActiveBasketCount(items.length), [items.length]); + + const prevItemCountRef = useRef(scrollToLast ? 0 : items.length); + + const isCarouselEnabled = activeBasketCount > 1; + + /** 담기 완료 시 마지막 아이템이 있는 바구니로 이동 (링크 담기: 실시간 / 위시 담기: 페이지 재진입) */ useEffect(() => { - if (activeBasketCount > prevBasketCountRef.current) { - toast.info('카트가 꽉 찼어요! 새 카트를 만들었어요.'); + if (!isCarouselEnabled) { + prevItemCountRef.current = items.length; + return; } - prevBasketCountRef.current = activeBasketCount; - }, [activeBasketCount]); - const { ref: containerRef, height: containerHeight } = useContainerHeight(); - const { ref: indicatorRef, height: indicatorHeight } = useContainerHeight(); - const gap = isCarouselEnabled ? 16 : 0; - const basketMaxHeight = - containerHeight != null ? containerHeight - (indicatorHeight ?? 0) - gap : null; + if (!carouselApi) return; + + if (items.length > prevItemCountRef.current) { + carouselApi.scrollTo(getBasketIndexForLastItem(items.length)); + onScrolled?.(); + } + + prevItemCountRef.current = items.length; + }, [carouselApi, isCarouselEnabled, items.length, onScrolled]); + + /** 초기 이미지 위치 틀어짐 방지 */ + useLayoutEffect(() => { + if (!carouselApi) return; + + carouselApi.reInit(); + carouselApi.scrollTo(carouselApi.selectedScrollSnap(), true); + }, [carouselApi, activeBasketCount]); + + useEffect(() => { + if (!carouselApi) return; + + const handleSelect = () => setCurrentIndex(carouselApi.selectedScrollSnap()); + + const handleReInit = () => { + carouselApi.scrollTo(carouselApi.selectedScrollSnap(), true); + }; + + handleSelect(); + carouselApi.on('select', handleSelect); + carouselApi.on('reInit', handleReInit); + + return () => { + carouselApi.off('select', handleSelect); + carouselApi.off('reInit', handleReInit); + }; + }, [carouselApi]); + + const handleIndicatorSelect = (index: number) => carouselApi?.scrollTo(index); if (!isCarouselEnabled) { return ( -
- +
+
); } return ( -
+
))} -
- -
+
); } -export default TournamentItemBasketCarousel; +export default memo(TournamentItemBasketCarousel); diff --git a/apps/web/src/app/tournament/[id]/create/_components/tournament-start-button/ConfirmStartDialog.tsx b/apps/web/src/app/tournament/[id]/create/_components/tournament-start-button/ConfirmStartDialog.tsx new file mode 100644 index 00000000..e062eee6 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/create/_components/tournament-start-button/ConfirmStartDialog.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/dialog'; + +import ConfirmStartFace from '../../_assets/confirm-start-face.svg'; + +type ConfirmStartDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}; + +function ConfirmStartDialog({ open, onOpenChange, onConfirm }: ConfirmStartDialogProps) { + const handleCancel = () => onOpenChange(false); + + return ( + + + + +
+ + 토너먼트를 정말 바로 시작할까요? + + + 바로 시작하면 담지 못한 친구가 +
+ 있을 수 있어요. +
+
+ +
+ + +
+
+
+ ); +} + +export default ConfirmStartDialog; diff --git a/apps/web/src/app/tournament/[id]/create/_components/tournament-start-button/TournamentStartButton.tsx b/apps/web/src/app/tournament/[id]/create/_components/tournament-start-button/TournamentStartButton.tsx index 09486826..f4d2af97 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/tournament-start-button/TournamentStartButton.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/tournament-start-button/TournamentStartButton.tsx @@ -1,33 +1,101 @@ 'use client'; +import { useEffect, useState } from 'react'; + import Button from '@/components/button'; import { usePostTournamentStart } from '../../_hooks/usePostTournamentStart'; +import ConfirmStartDialog from './ConfirmStartDialog'; + +const PARTICIPANT_TOOLTIP_DURATION_MS = 3_000; type TournamentStartButtonProps = { count: number; tournamentId: number; hasUnreadyItem: boolean; + hasFriends: boolean; + isWaitingForOwnerStart: boolean; + isDepositClosed?: boolean; + isParticipant?: boolean; }; function TournamentStartButton({ count, tournamentId, hasUnreadyItem, + hasFriends, + isWaitingForOwnerStart, + isDepositClosed = false, + isParticipant = false, }: TournamentStartButtonProps) { + const [isConfirmOpen, setIsConfirmOpen] = useState(false); const { postTournamentStartMutation, isPostTournamentStartPending } = usePostTournamentStart(tournamentId); + const [isTooltipVisible, setIsTooltipVisible] = useState(isWaitingForOwnerStart); + + useEffect(() => { + if (!isWaitingForOwnerStart) return; + const timeoutId = window.setTimeout( + () => setIsTooltipVisible(false), + PARTICIPANT_TOOLTIP_DURATION_MS + ); + return () => window.clearTimeout(timeoutId); + }, [isWaitingForOwnerStart]); + + const startTournament = () => postTournamentStartMutation(); + + const handleClick = () => { + if (isWaitingForOwnerStart) return; + // 참여자는 본인 CLONE 만 만드는 거라 "담지 못한 친구" 안내가 필요 없음 — 바로 시작 + if (isParticipant) { + startTournament(); + return; + } + if (hasFriends) { + setIsConfirmOpen(true); + return; + } + startTournament(); + }; + + const handleConfirm = () => { + setIsConfirmOpen(false); + startTournament(); + }; + + const isDisabled = isWaitingForOwnerStart || (!isDepositClosed && (count < 2 || hasUnreadyItem)); + return ( - + <> + {isTooltipVisible && ( +
+ + 주최자가 시작해야 플레이 할 수 있어요 + + +
+ )} + + + + + ); } diff --git a/apps/web/src/app/tournament/[id]/create/_components/welcome-join-dialog/WelcomeJoinDialog.tsx b/apps/web/src/app/tournament/[id]/create/_components/welcome-join-dialog/WelcomeJoinDialog.tsx new file mode 100644 index 00000000..f701e290 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/create/_components/welcome-join-dialog/WelcomeJoinDialog.tsx @@ -0,0 +1,51 @@ +'use client'; + +import Button from '@/components/button'; +import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/drawer'; +import { PROFILE_SVG, type ProfileTypeT } from '@/components/user-profile-group/userProfile.const'; + +type WelcomeJoinDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + nickname: string; + profileType: ProfileTypeT; + onConfirm: () => void; +}; + +function WelcomeJoinDialog({ + open, + onOpenChange, + nickname, + profileType, + onConfirm, +}: WelcomeJoinDialogProps) { + const ProfileSvg = PROFILE_SVG[profileType]; + + return ( + + +
+
+ +
+ + {nickname}님, + + + 프로필 등록이 완료됐어요 +
+ 상품을 담아보세요! +
+
+
+ + +
+
+
+ ); +} + +export default WelcomeJoinDialog; diff --git a/apps/web/src/app/tournament/[id]/create/_hooks/useCountdown.ts b/apps/web/src/app/tournament/[id]/create/_hooks/useCountdown.ts new file mode 100644 index 00000000..176195bf --- /dev/null +++ b/apps/web/src/app/tournament/[id]/create/_hooks/useCountdown.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react'; + +const pad = (value: number) => value.toString().padStart(2, '0'); + +const formatRemaining = (ms: number) => { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; +}; + +/** + * 마감 시각까지 남은 시간을 `HH:MM:SS` 형식으로 반환. + * SSR/CSR 하이드레이션 불일치를 피하기 위해 마운트 후에만 계산. + */ +export const useCountdown = (deadline: Date | string | number) => { + const target = typeof deadline === 'object' ? deadline.getTime() : new Date(deadline).getTime(); + const [remaining, setRemaining] = useState(null); + + useEffect(() => { + const tick = () => setRemaining(formatRemaining(target - Date.now())); + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [target]); + + return { remaining, isExpired: remaining === '00:00:00' }; +}; diff --git a/apps/web/src/app/tournament/[id]/create/_hooks/useGetTournament.ts b/apps/web/src/app/tournament/[id]/create/_hooks/useGetTournament.ts deleted file mode 100644 index 3f1f6b93..00000000 --- a/apps/web/src/app/tournament/[id]/create/_hooks/useGetTournament.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useSuspenseQuery } from '@tanstack/react-query'; - -import { getTournament } from '../_apis/getTournament'; - -export const useGetTournament = (tournamentId: number) => { - const { data: tournamentData } = useSuspenseQuery({ - queryKey: ['tournament', tournamentId], - queryFn: () => getTournament(tournamentId), - refetchInterval: 30_000, - }); - - return { tournamentData }; -}; diff --git a/apps/web/src/app/tournament/[id]/create/_hooks/useGetTournamentItem.ts b/apps/web/src/app/tournament/[id]/create/_hooks/useGetTournamentItem.ts index a228adac..123e4640 100644 --- a/apps/web/src/app/tournament/[id]/create/_hooks/useGetTournamentItem.ts +++ b/apps/web/src/app/tournament/[id]/create/_hooks/useGetTournamentItem.ts @@ -1,7 +1,6 @@ -import { useEffect } from 'react'; - import type { Query } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; import { getTournamentItem } from '../_apis/getTournamentItem'; import type { GetTournamentItemResponseT } from '../_types/tournament'; diff --git a/apps/web/src/app/tournament/[id]/create/_hooks/usePostTournamentItemLink.ts b/apps/web/src/app/tournament/[id]/create/_hooks/usePostTournamentItemLink.ts index 5e05561a..8a528f8c 100644 --- a/apps/web/src/app/tournament/[id]/create/_hooks/usePostTournamentItemLink.ts +++ b/apps/web/src/app/tournament/[id]/create/_hooks/usePostTournamentItemLink.ts @@ -1,6 +1,5 @@ -import { useState } from 'react'; - import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; import { postTournamentItemLink } from '../_apis/postTournamentItemLink'; import { useGetTournamentItem } from './useGetTournamentItem'; diff --git a/apps/web/src/app/tournament/[id]/create/_hooks/usePostTournamentStart.ts b/apps/web/src/app/tournament/[id]/create/_hooks/usePostTournamentStart.ts index 57a2eb5f..ae0d807c 100644 --- a/apps/web/src/app/tournament/[id]/create/_hooks/usePostTournamentStart.ts +++ b/apps/web/src/app/tournament/[id]/create/_hooks/usePostTournamentStart.ts @@ -11,8 +11,11 @@ export const usePostTournamentStart = (tournamentId: number) => { const { mutate: postTournamentStartMutation, isPending: isPostTournamentStartPending } = useMutation({ mutationFn: () => postTournamentStart(tournamentId), - onSuccess: () => { - router.push(ROUTES.TOURNAMENT_MATCH(tournamentId)); + // 응답의 tournamentId 로 라우팅. + // - 주최자: ROOT ID (요청 tournamentId 와 동일) + // - 참여자: 새로 생성된 CLONE ID (이후 본인 인스턴스로 진행) + onSuccess: ({ tournamentId: nextTournamentId }) => { + router.push(ROUTES.TOURNAMENT_MATCH(nextTournamentId)); }, }); diff --git a/apps/web/src/app/tournament/[id]/create/_types/tournament.ts b/apps/web/src/app/tournament/[id]/create/_types/tournament.ts index 946d59eb..96848e5d 100644 --- a/apps/web/src/app/tournament/[id]/create/_types/tournament.ts +++ b/apps/web/src/app/tournament/[id]/create/_types/tournament.ts @@ -1,38 +1,9 @@ -import type { StaticImageData } from 'next/image'; - import type { ItemStatusT } from '@/types/item'; -import type { TournamentItemT, TournamentStatusT } from '@/types/tournament'; - -export type TournamentParticipantT = { - userId: string; - nickname: string; - profileImage: string; -}; - -export type WishBasketItemT = { - tournamentItemId: number | string; - imageUrl: string | StaticImageData | null; - status?: ItemStatusT; -}; - -export type GetTournamentResponseT = { - tournamentId: number; - name: string; - status: TournamentStatusT; - pending?: { - items: TournamentItemT[]; - participants: TournamentParticipantT[]; - }; -}; export type PostTournamentItemLinkResponseT = { tournamentItemId: number; }; -export type PostTournamentStartResponseT = { - items: Omit[]; -}; - export type GetTournamentItemResponseT = { tournamentItemId: number; itemId: number; diff --git a/apps/web/src/app/tournament/[id]/create/by-wish/_components/ByWishContent.tsx b/apps/web/src/app/tournament/[id]/create/by-wish/_components/ByWishContent.tsx index 52231c07..0ff6de49 100644 --- a/apps/web/src/app/tournament/[id]/create/by-wish/_components/ByWishContent.tsx +++ b/apps/web/src/app/tournament/[id]/create/by-wish/_components/ByWishContent.tsx @@ -6,7 +6,7 @@ import { toast } from 'sonner'; import Button from '@/components/button'; import { useGetWishlist } from '@/hooks/useGetWishlist'; -import { useGetTournament } from '../../_hooks/useGetTournament'; +import { useGetTournament } from '../../../_common/_hooks/useGetTournament'; import { MAX_SELECT } from '../_consts/selectLimits'; import { usePostTournamentItemsByWish } from '../_hooks/usePostTournamentItemsByWish'; import useWishSelection from '../_hooks/useWishSelection'; @@ -24,7 +24,8 @@ function ByWishContent({ tournamentId }: ByWishContentProps) { const { postTournamentItemsByWishMutation, isPostTournamentItemsByWishPending } = usePostTournamentItemsByWish(tournamentId); - const existingItemIds = new Set(tournamentData.pending?.items.map(i => i.itemId) ?? []); + const pending = 'pending' in tournamentData ? tournamentData.pending : null; + const existingItemIds = new Set(pending?.items.map(i => i.itemId) ?? []); const items = wishlistData?.filter( item => @@ -51,7 +52,7 @@ function ByWishContent({ tournamentId }: ByWishContentProps) { diff --git a/apps/web/src/app/tournament/[id]/create/by-wish/_components/WishSelectHeader.tsx b/apps/web/src/app/tournament/[id]/create/by-wish/_components/WishSelectHeader.tsx index d43cc995..5b79656a 100644 --- a/apps/web/src/app/tournament/[id]/create/by-wish/_components/WishSelectHeader.tsx +++ b/apps/web/src/app/tournament/[id]/create/by-wish/_components/WishSelectHeader.tsx @@ -24,7 +24,9 @@ function WishSelectHeader({ return (

- {tournamentCandidateCount}개가 + + {tournamentCandidateCount}개가 + {' 담겨있어요\n더 추가하고 싶은 상품을 골라보세요.'} diff --git a/apps/web/src/app/tournament/[id]/item/[itemId]/_components/ItemEditForm.tsx b/apps/web/src/app/tournament/[id]/item/[itemId]/_components/ItemEditForm.tsx index 7f9e1891..6ac8a5db 100644 --- a/apps/web/src/app/tournament/[id]/item/[itemId]/_components/ItemEditForm.tsx +++ b/apps/web/src/app/tournament/[id]/item/[itemId]/_components/ItemEditForm.tsx @@ -7,7 +7,6 @@ import BottomCta from '@/components/bottom-cta'; import Button from '@/components/button'; import Input from '@/components/input'; import Spacing from '@/components/spacing'; - import type { ItemStatusT } from '@/types/item'; import { cn } from '@/utils/cn'; import formatPrice from '@/utils/formatPrice'; diff --git a/apps/web/src/app/tournament/[id]/match/_hooks/useTournament.ts b/apps/web/src/app/tournament/[id]/match/_hooks/useTournament.ts index d6855600..9e30a415 100644 --- a/apps/web/src/app/tournament/[id]/match/_hooks/useTournament.ts +++ b/apps/web/src/app/tournament/[id]/match/_hooks/useTournament.ts @@ -8,35 +8,29 @@ import { ROUTES } from '@/consts/route'; import type { TournamentItemT } from '@/types/tournament'; import { getTournament } from '../../_common/_apis/getTournament'; -import type { - GetTournamentCompletedResponseT, - GetTournamentInProgressResponseT, -} from '../../_common/_types/tournamentResponse'; +import type { GetTournamentInProgressResponseT } from '../../_common/_types/tournamentResponse'; import { type TransitionStageT, getRoundLabel, getTransitionStage } from '../_consts/rounds'; import { pairByPriceAsc, shufflePairs } from '../_utils/pairItems'; import { usePostRecordMatch } from './usePostRecordMatch'; type UseTournamentArgs = { tournamentId: number; + /** 현재 미사용 — TournamentClient 호출 시그니처 유지를 위해 인자만 받는다 */ tournamentName: string; inProgress: GetTournamentInProgressResponseT['inProgress']; }; -const useTournament = ({ tournamentId, tournamentName, inProgress }: UseTournamentArgs) => { +const useTournament = ({ tournamentId, inProgress }: UseTournamentArgs) => { const router = useRouter(); const queryClient = useQueryClient(); const { postRecordMatchMutation } = usePostRecordMatch({ tournamentId, onSuccess: data => { - // 결승 응답에 포함된 result로 결과 페이지의 GET을 건너뛰도록 캐시 선갱신 if (!data) return; - const completed: GetTournamentCompletedResponseT = { - tournamentId, - name: tournamentName, - status: 'COMPLETED', - completed: { result: data.result }, - }; - queryClient.setQueryData(['tournament', tournamentId], completed); + // 결승 종료 후 result 페이지가 권위 응답(hasGroupResult/playLinkExpiresAt/isRoot 등) + // 을 받아야 하므로 클라 캐시는 비워 두고 SSR fresh data 로 채우게 한다. + // (시드해두면 stale 한 hasGroupResult=false 가 클라에 남아 카드 노출이 늦어짐) + queryClient.removeQueries({ queryKey: ['tournament', tournamentId] }); }, }); @@ -74,7 +68,7 @@ const useTournament = ({ tournamentId, tournamentName, inProgress }: UseTourname const next = await getTournament(tournamentId); queryClient.setQueryData(['tournament', tournamentId], next); - if (next.status !== 'IN_PROGRESS') return; + if (next.status !== 'IN_PROGRESS' || !next.inProgress) return; setCurrentRound(next.inProgress.currentRound); setRemainingItems(next.inProgress.remainingItems); }; diff --git a/apps/web/src/app/tournament/[id]/match/page.tsx b/apps/web/src/app/tournament/[id]/match/page.tsx index 440e2441..9fbd0716 100644 --- a/apps/web/src/app/tournament/[id]/match/page.tsx +++ b/apps/web/src/app/tournament/[id]/match/page.tsx @@ -24,15 +24,35 @@ async function TournamentPage({ params }: TournamentPageProps) { redirect(ROUTES.TOURNAMENT_RESULT(tournamentId)); } + // 참여자가 본인 매치를 시작하기 전 (IN_PROGRESS + pending 페이로드) + // — 아직 진행할 게 없으므로 create(대기) 화면으로 돌려보낸다. + if (tournamentData.status === 'IN_PROGRESS' && tournamentData.pending) { + redirect(ROUTES.TOURNAMENT_CREATE(tournamentId)); + } + let hydratedTournament: GetTournamentInProgressResponseT; + let playTournamentId = tournamentId; if (tournamentData.status === 'PENDING') { try { - const { items } = await postStartTournament(tournamentId); + // 응답 tournamentId 활용: + // - 주최자(ROOT): 요청 tournamentId 와 동일 + // - 참여자(CLONE): 새로 생성된 CLONE id (이후 본인 인스턴스로 진행) + const { tournamentId: nextTournamentId, items } = await postStartTournament(tournamentId); + + // CLONE 이 생성됐다면 본인 인스턴스 URL 로 이동 (재진입 시 IN_PROGRESS 분기로 흘러감) + if (nextTournamentId !== tournamentId) { + redirect(ROUTES.TOURNAMENT_MATCH(nextTournamentId)); + } + // start 후 서버는 IN_PROGRESS로 전환됨 — 클라 캐시도 IN_PROGRESS 형태로 시드 + playTournamentId = nextTournamentId; hydratedTournament = { - tournamentId, + tournamentId: nextTournamentId, name: tournamentData.name, + isOwner: tournamentData.isOwner, + // CLONE 이 만들어졌다면 isRoot=false, ROOT 그대로면 원본 값 승계 + isRoot: nextTournamentId === tournamentId ? tournamentData.isRoot : false, status: 'IN_PROGRESS', inProgress: { currentRound: items.length, @@ -48,23 +68,24 @@ async function TournamentPage({ params }: TournamentPageProps) { if (latest.status === 'COMPLETED') { redirect(ROUTES.TOURNAMENT_RESULT(tournamentId)); } - if (latest.status === 'PENDING') { - // 409이면서 여전히 PENDING — 예상 밖, 그대로 던짐 + if (latest.status === 'PENDING' || latest.pending) { + // 409이면서 여전히 PENDING 또는 멤버 대기 상태 — 예상 밖, 그대로 던짐 throw error; } hydratedTournament = latest; } } else { + // tournamentData.status === 'IN_PROGRESS' && !tournamentData.pending — 매치 진행 중 hydratedTournament = tournamentData; } const queryClient = getQueryClient(); - queryClient.setQueryData(['tournament', tournamentId], hydratedTournament); + queryClient.setQueryData(['tournament', playTournamentId], hydratedTournament); return ( diff --git a/apps/web/src/app/tournament/[id]/result/_apis/getGroupResult.ts b/apps/web/src/app/tournament/[id]/result/_apis/getGroupResult.ts new file mode 100644 index 00000000..1e62cc3d --- /dev/null +++ b/apps/web/src/app/tournament/[id]/result/_apis/getGroupResult.ts @@ -0,0 +1,32 @@ +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 { GetGroupResultResponseT } from '../_types/groupResult'; + +/** + * 그룹 결과 조회. + * 완료된 토너먼트 + 플레이 링크로 복제된 모든 토너먼트의 결과를 비교해 rank별 chosenBy 반환. + * + * 에러 코드: + * - 401: 미인증 + * - 403: 토너먼트 참여자가 아님 + * - 404: 토너먼트 없음 + * - 409: COMPLETED가 아닌 토너먼트 + */ +export const getGroupResult = async (tournamentId: number) => { + if (environmentManager.isServer()) { + const { data } = await serverApi.get>( + ENDPOINTS.TOURNAMENT_GROUP_RESULT(tournamentId) + ); + return data.data; + } + + const { data } = await clientApi.get>( + ENDPOINTS.TOURNAMENT_GROUP_RESULT(tournamentId) + ); + return data.data; +}; diff --git a/apps/web/src/app/tournament/[id]/result/_apis/postPlayLink.ts b/apps/web/src/app/tournament/[id]/result/_apis/postPlayLink.ts new file mode 100644 index 00000000..42c0e0d0 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/result/_apis/postPlayLink.ts @@ -0,0 +1,19 @@ +import { clientApi } from '@/apis/client'; +import { ENDPOINTS } from '@/consts/api'; +import type { ApiResponseT } from '@/types/api'; + +import type { PostPlayLinkResponseT } from '../../_common/_types/tournamentResponse'; + +/** + * 플레이 링크 생성/갱신 (소유자 전용). + * 응답으로 `playLinkExpiresAt` (ISO datetime 문자열) 만 반환. + * 실제 공유 URL 은 클라이언트에서 `tournamentId` 기반으로 구성한다. + */ +export const postPlayLink = async (tournamentId: number) => { + // body 없는 POST 라도 빈 객체로 보내야 일부 백엔드가 Content-Type 인식 후 처리한다 (415 방지). + const { data } = await clientApi.post>( + ENDPOINTS.TOURNAMENT_PLAY_LINK(tournamentId), + {} + ); + return data.data; +}; diff --git a/apps/web/src/app/tournament/[id]/result/_components/ReceiptDrawMachine.tsx b/apps/web/src/app/tournament/[id]/result/_components/ReceiptDrawMachine.tsx index 71e1ec3a..2da44c11 100644 --- a/apps/web/src/app/tournament/[id]/result/_components/ReceiptDrawMachine.tsx +++ b/apps/web/src/app/tournament/[id]/result/_components/ReceiptDrawMachine.tsx @@ -28,9 +28,17 @@ type ReceiptDrawMachineProps = { tournamentName: string; result: RankedProductT[]; date: Date; + canSharePlayLink: boolean; + onSharePlayLink?: () => void; }; -function ReceiptDrawMachine({ tournamentName, result, date }: ReceiptDrawMachineProps) { +function ReceiptDrawMachine({ + tournamentName, + result, + date, + canSharePlayLink, + onSharePlayLink, +}: ReceiptDrawMachineProps) { const animationScopeRef = useRef(null); const printerFrameRef = useRef(null); const receiptPaperRef = useRef(null); @@ -142,7 +150,12 @@ function ReceiptDrawMachine({ tournamentName, result, date }: ReceiptDrawMachine {/* 영수증 종이 영역 공간 확보 (layout reserved) */}

- +
{/* 영수증 마스크 — 슬롯 위치(top)부터 컨테이너 끝(bottom-0)까지, 프린터 위로(z-40) 덮음 */} @@ -154,7 +167,13 @@ function ReceiptDrawMachine({ tournamentName, result, date }: ReceiptDrawMachine ref={receiptPaperRef} className="pointer-events-auto mx-auto h-fit w-[74%] will-change-transform" > - +
diff --git a/apps/web/src/app/tournament/[id]/result/_components/ReceiptPaper.tsx b/apps/web/src/app/tournament/[id]/result/_components/ReceiptPaper.tsx index bef8009f..d5edc190 100644 --- a/apps/web/src/app/tournament/[id]/result/_components/ReceiptPaper.tsx +++ b/apps/web/src/app/tournament/[id]/result/_components/ReceiptPaper.tsx @@ -8,6 +8,7 @@ import ReceiptZigzag from '@/assets/images/tournament/result/receipt-zigzag.svg' import { cn } from '@/utils/cn'; import type { RankedProductT } from '../../_common/_types/tournament'; +import { formatDate, formatPrice, formatTime } from '../_utils/formatReceipt'; const kodeMono = Kode_Mono({ subsets: ['latin'], weight: ['400', '500', '600', '700'] }); @@ -15,23 +16,15 @@ type ReceiptPaperProps = { tournamentName: string; result: RankedProductT[]; date: Date; + /** + * 플레이 링크 공유 가능 여부. + * ROOT 토너먼트의 소유자(isRoot && isOwner)만 노출. + * CLONE 의 소유자(친구 초대로 참여 → CLONE 생성한 사람) 는 노출하지 않는다. + */ + canSharePlayLink: boolean; + onSharePlayLink?: () => void; }; -const formatDate = (date: Date) => { - const dd = String(date.getDate()).padStart(2, '0'); - const mm = String(date.getMonth() + 1).padStart(2, '0'); - const yyyy = date.getFullYear(); - return `${dd}/${mm}/${yyyy}`; -}; - -const formatTime = (date: Date) => { - const hh = String(date.getHours()).padStart(2, '0'); - const mm = String(date.getMinutes()).padStart(2, '0'); - return `${hh}:${mm}`; -}; - -const formatPrice = (price: number) => `${price.toLocaleString('ko-KR')}원`; - const SectionDivider = () =>
; const PlaceLabel = ({ label }: { label: string }) => ( @@ -43,7 +36,7 @@ const PlaceLabel = ({ label }: { label: string }) => ( ); const ReceiptPaper = forwardRef(function ReceiptPaper( - { tournamentName, result, date }, + { tournamentName, result, date, canSharePlayLink, onSharePlayLink }, ref ) { const [first, second, third, fourth] = result; @@ -132,10 +125,16 @@ const ReceiptPaper = forwardRef(function Rece
- {/* 공유 액션 */} + {/* 공유 액션 — 플레이 링크 공유는 주최자만 노출 */}
} label="이미지 공유" /> - } label="플레이 링크 공유" /> + {canSharePlayLink && ( + } + label="플레이 링크 공유" + onClick={onSharePlayLink} + /> + )}
{/* 영수증 하단 톱니 */} @@ -200,11 +199,12 @@ function RankedRowSmall({ product, index }: RankedRowProps) { type ShareActionProps = { icon: React.ReactNode; label: string; + onClick?: () => void; }; -function ShareAction({ icon, label }: ShareActionProps) { +function ShareAction({ icon, label, onClick }: ShareActionProps) { return ( - -
+ + ); } diff --git a/apps/web/src/app/tournament/[id]/result/_components/group-result-entry-card/GroupResultEntryCard.tsx b/apps/web/src/app/tournament/[id]/result/_components/group-result-entry-card/GroupResultEntryCard.tsx new file mode 100644 index 00000000..7ae927dc --- /dev/null +++ b/apps/web/src/app/tournament/[id]/result/_components/group-result-entry-card/GroupResultEntryCard.tsx @@ -0,0 +1,37 @@ +'use client'; + +import Link from 'next/link'; + +import { ChevronForwardIconFill } from '@/assets/icons/fill'; +import UserProfileGroup from '@/components/user-profile-group'; +import type { UserT } from '@/components/user-profile-group/userProfile.const'; +import { ROUTES } from '@/consts/route'; + +type GroupResultEntryCardProps = { + tournamentId: number; +}; + +// TODO: 백엔드가 completed 응답에 participants 를 내려주면 그것으로 교체. +// 현재는 시안 일치를 위해 placeholder 아바타 3개로 표시한다. +const PLACEHOLDER_USERS: UserT[] = [ + { id: 'placeholder-1', profileType: 'blue' }, + { id: 'placeholder-2', profileType: 'yellow' }, + { id: 'placeholder-3', profileType: 'blue' }, +]; + +function GroupResultEntryCard({ tournamentId }: GroupResultEntryCardProps) { + return ( + +
+ +

친구 토너먼트 결과보기

+
+ + + ); +} + +export default GroupResultEntryCard; diff --git a/apps/web/src/app/tournament/[id]/result/_components/plate-share-dialog/PlateShareDialog.tsx b/apps/web/src/app/tournament/[id]/result/_components/plate-share-dialog/PlateShareDialog.tsx new file mode 100644 index 00000000..186a342a --- /dev/null +++ b/apps/web/src/app/tournament/[id]/result/_components/plate-share-dialog/PlateShareDialog.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { toast } from 'sonner'; + +import { CheckIconFill } from '@/assets/icons/fill'; +import Button from '@/components/button'; +import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/drawer'; +import Spinner from '@/components/spinner'; +import { share } from '@/utils/share'; + +import { usePostPlayLink } from '../../_hooks/usePostPlayLink'; + +type PlateShareDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + tournamentId: number; + /** 서버 응답의 playLinkExpiresAt — 아직 생성 전이면 undefined */ + initialPlayLinkExpiresAt?: string; +}; + +const buildPlayLinkUrl = (tournamentId: number) => { + if (typeof window === 'undefined') return `/play/${tournamentId}`; + return `${window.location.origin}/play/${tournamentId}`; +}; + +function PlateShareDialog({ + open, + onOpenChange, + tournamentId, + initialPlayLinkExpiresAt, +}: PlateShareDialogProps) { + const { postPlayLinkMutation, isPostPlayLinkPending } = usePostPlayLink(tournamentId); + + // 이미 만들어진 플레이 링크가 있으면 mutation 건너뛰고 바로 공유한다. + const hasExistingPlayLink = Boolean(initialPlayLinkExpiresAt); + + const handleSendPlayLink = async () => { + if (!hasExistingPlayLink) { + try { + await postPlayLinkMutation(); + } catch { + toast.warning('공유 링크를 생성하지 못했어요. 다시 시도해주세요.'); + return; + } + } + + const result = await share({ + title: 'piki 토너먼트 플레이', + text: '나만의 piki 토너먼트를 친구가 플레이할 수 있어요!', + url: buildPlayLinkUrl(tournamentId), + }); + + if (result === 'shared' || result === 'copied') { + toast.success('링크를 성공적으로 공유했어요.'); + onOpenChange(false); + return; + } + if (result === 'failed') toast.warning('공유에 실패했어요. 다시 시도해주세요.'); + }; + + return ( + + +
+
+ + 토너먼트 플레이 공유 + + + 링크를 받은 친구는 기한 내에 플레이할 수 있어요. + +
+ +
+ +

+ 공유 시점으로부터 14일 후 자동 마감돼요. +

+
+ + +
+
+
+ ); +} + +export default PlateShareDialog; diff --git a/apps/web/src/app/tournament/[id]/result/_hooks/useGetGroupResult.ts b/apps/web/src/app/tournament/[id]/result/_hooks/useGetGroupResult.ts new file mode 100644 index 00000000..97f0fc45 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/result/_hooks/useGetGroupResult.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getGroupResult } from '../_apis/getGroupResult'; + +export const useGetGroupResult = (tournamentId: number) => { + const { + data: groupResultData, + isPending: isGroupResultPending, + isError: isGroupResultError, + } = useQuery({ + queryKey: ['groupResult', tournamentId], + queryFn: () => getGroupResult(tournamentId), + retry: false, + }); + + return { groupResultData, isGroupResultPending, isGroupResultError }; +}; diff --git a/apps/web/src/app/tournament/[id]/result/_hooks/usePostPlayLink.ts b/apps/web/src/app/tournament/[id]/result/_hooks/usePostPlayLink.ts new file mode 100644 index 00000000..0db4dd47 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/result/_hooks/usePostPlayLink.ts @@ -0,0 +1,11 @@ +import { useMutation } from '@tanstack/react-query'; + +import { postPlayLink } from '../_apis/postPlayLink'; + +export const usePostPlayLink = (tournamentId: number) => { + const { mutateAsync: postPlayLinkMutation, isPending: isPostPlayLinkPending } = useMutation({ + mutationFn: () => postPlayLink(tournamentId), + }); + + return { postPlayLinkMutation, isPostPlayLinkPending }; +}; diff --git a/apps/web/src/app/tournament/[id]/result/_types/groupResult.ts b/apps/web/src/app/tournament/[id]/result/_types/groupResult.ts new file mode 100644 index 00000000..9c9c56db --- /dev/null +++ b/apps/web/src/app/tournament/[id]/result/_types/groupResult.ts @@ -0,0 +1,20 @@ +export type GroupResultParticipantT = { + userId: string; + nickname: string; + profileImage: string; +}; + +export type GroupResultItemT = { + rank: number; + itemId: number; + name: string; + price: number; + currency: string; + imageUrl?: string; + /** 이 rank에 이 아이템을 선택한 참여자 목록 */ + chosenBy: GroupResultParticipantT[]; +}; + +export type GetGroupResultResponseT = { + items: GroupResultItemT[]; +}; diff --git a/apps/web/src/app/tournament/[id]/result/_utils/formatReceipt.ts b/apps/web/src/app/tournament/[id]/result/_utils/formatReceipt.ts new file mode 100644 index 00000000..28cf440c --- /dev/null +++ b/apps/web/src/app/tournament/[id]/result/_utils/formatReceipt.ts @@ -0,0 +1,14 @@ +export const formatDate = (date: Date) => { + const dd = String(date.getDate()).padStart(2, '0'); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const yyyy = date.getFullYear(); + return `${dd}/${mm}/${yyyy}`; +}; + +export const formatTime = (date: Date) => { + const hh = String(date.getHours()).padStart(2, '0'); + const mm = String(date.getMinutes()).padStart(2, '0'); + return `${hh}:${mm}`; +}; + +export const formatPrice = (price: number) => `${price.toLocaleString('ko-KR')}원`; diff --git a/apps/web/src/app/tournament/[id]/result/group/_components/GroupResultClient.tsx b/apps/web/src/app/tournament/[id]/result/group/_components/GroupResultClient.tsx new file mode 100644 index 00000000..a5b2fb40 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/result/group/_components/GroupResultClient.tsx @@ -0,0 +1,267 @@ +'use client'; + +import { Kode_Mono } from 'next/font/google'; +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; + +import { ChevronBackwardIconFill } from '@/assets/icons/fill'; +import PikiReceiptLogo from '@/assets/images/piki-receipt-logo.svg'; +import ReceiptZigzag from '@/assets/images/tournament/result/receipt-zigzag.svg'; +import { cn } from '@/utils/cn'; + +import { useGetTournament } from '../../../_common/_hooks/useGetTournament'; +import { useGetGroupResult } from '../../_hooks/useGetGroupResult'; +import type { GroupResultItemT, GroupResultParticipantT } from '../../_types/groupResult'; +import { formatDate, formatPrice, formatTime } from '../../_utils/formatReceipt'; + +const kodeMono = Kode_Mono({ subsets: ['latin'], weight: ['400', '500', '600', '700'] }); + +type GroupResultClientProps = { + tournamentId: number; +}; + +const buildChosenByLabel = (chosenBy: GroupResultParticipantT[]) => { + const [first, ...rest] = chosenBy; + if (!first) return ''; + if (rest.length === 0) return first.nickname; + return `${first.nickname} 외 ${rest.length}명`; +}; + +const SectionDivider = () =>
; + +const PlaceLabel = ({ label }: { label: string }) => ( +

+ ****************** + {label} + ****************** +

+); + +function GroupResultClient({ tournamentId }: GroupResultClientProps) { + const router = useRouter(); + const { tournamentData } = useGetTournament(tournamentId); + const { groupResultData, isGroupResultPending, isGroupResultError } = + useGetGroupResult(tournamentId); + + const date = new Date(); + const tournamentName = tournamentData.name; + + // 친구가 아직 본인 매치를 시작 안 했거나, 권한 없음 등으로 데이터를 받지 못한 경우. + if (isGroupResultPending || isGroupResultError || !groupResultData) { + return ( +
+
+ +

+ 친구 토너먼트 결과 +

+
+
+

아직 친구 결과가 없어요

+

+ 친구가 토너먼트를 완료하면 +
+ 결과를 비교해볼 수 있어요. +

+
+
+ ); + } + + const sortedItems = [...groupResultData.items].sort((a, b) => a.rank - b.rank); + const firstItem = sortedItems.find(item => item.rank === 1); + const otherItems = sortedItems.filter(item => item.rank !== 1); + + return ( +
+
+ +

+ 친구 토너먼트 결과 +

+
+ +
+
+ {/* PIKI 로고 + 헤드라인 */} +
+ +

+ FROM ENDLESS WISHLISTS, +
+ TO TODAY'S ONE PICK. +

+
+ +
+ {/* 날짜 / 시간 */} +
+ + {formatDate(date)} + + + {formatTime(date)} + +
+ + + + {/* 토너먼트 이름 */} +
+

Tournament name

+

{tournamentName}

+
+ + + +
+

Tournament Results

+
+ + + + {/* 1st Place — 강조 카드 */} + {firstItem && ( +
+ + +
+ )} + + {/* Others — 2위 이하 */} + {otherItems.length > 0 && ( +
+ +
    + {otherItems.map(item => ( + + ))} +
+
+ )} + + + +

+ @piki.day +

+
+ + +
+
+
+ ); +} + +function FirstPlaceCard({ item }: { item: GroupResultItemT }) { + const chosenByLabel = buildChosenByLabel(item.chosenBy); + + return ( +
+
+ {item.imageUrl ? ( + {item.name} + ) : ( +
+ )} +
+ {chosenByLabel && ( +

+ {chosenByLabel} +

+ )} +

+ {item.name} +

+

+ {formatPrice(item.price)} +

+
+
+ + {item.chosenBy.length > 0 && ( +
    + {item.chosenBy.map(participant => ( +
  • + {participant.nickname} + + {participant.nickname} + +
  • + ))} +
+ )} +
+ ); +} + +function OtherPlaceRow({ item }: { item: GroupResultItemT }) { + const count = item.chosenBy.length; + const firstChooser = item.chosenBy[0]; + + return ( +
  • +
    +

    + {item.name} +

    +

    {count}명

    +
    + {firstChooser && ( +
    + {firstChooser.nickname} + + {firstChooser.nickname} + +
    + )} +
  • + ); +} + +export default GroupResultClient; diff --git a/apps/web/src/app/tournament/[id]/result/group/page.tsx b/apps/web/src/app/tournament/[id]/result/group/page.tsx new file mode 100644 index 00000000..1cec31e5 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/result/group/page.tsx @@ -0,0 +1,39 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { notFound } from 'next/navigation'; + +import { getQueryClient } from '@/utils/queryClient'; + +import { parseTournamentId } from '../../_common/_utils/parseTournamentId'; +import { getGroupResult } from '../_apis/getGroupResult'; +import GroupResultClient from './_components/GroupResultClient'; + +type GroupResultPageProps = { + params: Promise<{ id: string }>; +}; + +async function GroupResultPage({ params }: GroupResultPageProps) { + const { id } = await params; + const tournamentId = parseTournamentId(id); + + if (tournamentId === null) notFound(); + + const queryClient = getQueryClient(); + // 진입 카드를 isRoot 만으로 노출하기 때문에 친구 0명/권한 없음 등으로 실패할 수 있다. + // prefetch 가 실패해도 페이지는 그대로 보여주고, 클라이언트가 useQuery 에러 분기로 안내한다. + try { + await queryClient.prefetchQuery({ + queryKey: ['groupResult', tournamentId], + queryFn: () => getGroupResult(tournamentId), + }); + } catch { + // 무시 — 클라이언트가 에러 상태로 자체 안내 + } + + return ( + + + + ); +} + +export default GroupResultPage; diff --git a/apps/web/src/app/tournament/[id]/result/page.tsx b/apps/web/src/app/tournament/[id]/result/page.tsx index bf155cd0..b43edae5 100644 --- a/apps/web/src/app/tournament/[id]/result/page.tsx +++ b/apps/web/src/app/tournament/[id]/result/page.tsx @@ -17,17 +17,12 @@ async function TournamentResultPage({ params }: TournamentResultPageProps) { const tournamentId = Number(id); const queryClient = getQueryClient(); - await queryClient.prefetchQuery({ - queryKey: ['tournament', tournamentId], - queryFn: () => getTournament(tournamentId), - }); + // prefetchQuery 가 아닌 직접 fetch — 매치 결승 직후 시드(hasGroupResult=false 등)는 + // 결과 페이지에서는 권위 응답으로 반드시 덮어써야 한다. + const tournamentData = await getTournament(tournamentId); + queryClient.setQueryData(['tournament', tournamentId], tournamentData); - const tournamentData = queryClient.getQueryData([ - 'tournament', - tournamentId, - ]); - - if (tournamentData && tournamentData.status !== 'COMPLETED') { + if (tournamentData.status !== 'COMPLETED') { redirect(ROUTES.TOURNAMENT_MATCH(tournamentId)); } diff --git a/apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx b/apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx new file mode 100644 index 00000000..377c015f --- /dev/null +++ b/apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +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 { useGetNicknameCheck } from '@/hooks/useGetNicknameCheck'; + +import { DEFAULT_RANDOM_NICKNAME } from '../../_consts/randomNickname'; +import { useGetInvitePreview } from '../../_hooks/useGetInvitePreview'; +import { usePostJoinGuest } from '../../_hooks/usePostJoinGuest'; +import { setJoinWelcome } from '../../_utils/joinSession'; + +type JoinPreviewClientProps = { + tournamentId: number; + /** 친구 초대 코드 — invite 진입 시 query 로 전달됨. 백엔드 join/guest 호출 시 필수 */ + inviteCode: string; +}; + +const MAX_NICKNAME_LENGTH = 10; + +function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) { + const router = useRouter(); + const [nickname, setNickname] = useState(DEFAULT_RANDOM_NICKNAME.nickname); + + const { invitePreviewData } = useGetInvitePreview(tournamentId); + const trimmedNickname = nickname.trim(); + const { nicknameCheckData, isNicknameCheckFetching } = useGetNicknameCheck(nickname); + const { postJoinGuestMutation, isPostJoinGuestPending } = usePostJoinGuest(); + + const isNicknameAvailable = nicknameCheckData?.available !== false; + const isComplete = + trimmedNickname.length > 0 && + isNicknameAvailable && + !isNicknameCheckFetching && + !isPostJoinGuestPending; + + const handleConfirm = async () => { + if (!isComplete) return; + + try { + const response = await postJoinGuestMutation({ + tournamentId, + body: { + nickname: trimmedNickname, + ...(inviteCode ? { inviteCode } : {}), + }, + }); + setJoinWelcome({ + tournamentId: response.tournamentId, + nickname: response.nickname, + profileType: DEFAULT_RANDOM_NICKNAME.profileType, + }); + router.push(`/tournament/${response.tournamentId}/create`); + } catch { + toast.warning('참여에 실패했어요. 잠시 후 다시 시도해주세요.'); + } + }; + + return ( +
    +
    + +
    +

    초대받은 토너먼트

    +
    +

    + {invitePreviewData.tournamentName} +

    +

    + 후보 {invitePreviewData.itemCount}개 · 참여 {invitePreviewData.participantCount}명 +

    +
    +
    + +
    + setNickname(event.target.value)} + right={} + maxLength={MAX_NICKNAME_LENGTH} + aria-invalid={!isNicknameAvailable} + {...(!isNicknameAvailable ? { helperText: '이미 사용 중인 닉네임이에요.' } : {})} + /> +
    + +
    + +
    +
    + ); +} + +export default JoinPreviewClient; diff --git a/apps/web/src/app/tournament/join/[id]/page.tsx b/apps/web/src/app/tournament/join/[id]/page.tsx new file mode 100644 index 00000000..5134f989 --- /dev/null +++ b/apps/web/src/app/tournament/join/[id]/page.tsx @@ -0,0 +1,35 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { notFound } from 'next/navigation'; + +import { parseIdParam } from '@/utils/parseIdParam'; +import { getQueryClient } from '@/utils/queryClient'; + +import { getInvitePreview } from '../_apis/getInvitePreview'; +import JoinPreviewClient from './_components/JoinPreviewClient'; + +type TournamentJoinPageProps = { + params: Promise<{ id: string }>; + searchParams: Promise<{ code?: string }>; +}; + +async function TournamentJoinPage({ params, searchParams }: TournamentJoinPageProps) { + const { id } = await params; + const { code } = await searchParams; + const tournamentId = parseIdParam(id); + + if (tournamentId === null) notFound(); + + const queryClient = getQueryClient(); + await queryClient.prefetchQuery({ + queryKey: ['invitePreview', tournamentId], + queryFn: () => getInvitePreview(tournamentId), + }); + + return ( + + + + ); +} + +export default TournamentJoinPage; diff --git a/apps/web/src/app/tournament/join/_apis/getInvitePreview.ts b/apps/web/src/app/tournament/join/_apis/getInvitePreview.ts new file mode 100644 index 00000000..6ff73d57 --- /dev/null +++ b/apps/web/src/app/tournament/join/_apis/getInvitePreview.ts @@ -0,0 +1,30 @@ +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'; + +/** + * 토너먼트 ID로 미리보기. 인증 불필요. + * 링크 직접 접근 경로 (`piki.today/invite/{tournamentId}?code=XXX`). + */ +export const getInvitePreview = async (tournamentId: number, inviteCode?: string) => { + const params = inviteCode ? { inviteCode } : {}; + + if (environmentManager.isServer()) { + const { data } = await serverApi.get>( + ENDPOINTS.TOURNAMENT_INVITE_PREVIEW(tournamentId), + { params } + ); + return data.data; + } + + const { data } = await clientApi.get>( + ENDPOINTS.TOURNAMENT_INVITE_PREVIEW(tournamentId), + { params } + ); + return data.data; +}; diff --git a/apps/web/src/app/tournament/join/_apis/getInvitePreviewByCode.ts b/apps/web/src/app/tournament/join/_apis/getInvitePreviewByCode.ts new file mode 100644 index 00000000..ae65b7bc --- /dev/null +++ b/apps/web/src/app/tournament/join/_apis/getInvitePreviewByCode.ts @@ -0,0 +1,22 @@ +import { clientApi } from '@/apis/client'; +import { ENDPOINTS } from '@/consts/api'; +import type { ApiResponseT } from '@/types/api'; + +import type { GetInvitePreviewResponseT } from '../_types/join'; + +/** + * 초대 코드로 토너먼트 미리보기. + * 홈 다이얼로그에서 6자리 코드만 입력하는 경로 전용. + * 응답으로 받은 tournamentId 를 이후 /join, /join/guest 호출에 사용. + * + * 에러 코드: + * - 400: 코드에 해당하는 토너먼트 없음 (`초대 코드가 올바르지 않습니다.`) + * - 409: PENDING 아닌 상태 또는 만료 (`초대 링크가 만료되었습니다.`) + */ +export const getInvitePreviewByCode = async (code: string) => { + const { data } = await clientApi.get>( + ENDPOINTS.TOURNAMENT_INVITE_PREVIEW_BY_CODE, + { params: { code } } + ); + return data.data; +}; diff --git a/apps/web/src/app/tournament/join/_apis/postJoinGuest.ts b/apps/web/src/app/tournament/join/_apis/postJoinGuest.ts new file mode 100644 index 00000000..c09e5bbc --- /dev/null +++ b/apps/web/src/app/tournament/join/_apis/postJoinGuest.ts @@ -0,0 +1,23 @@ +import { clientApi } from '@/apis/client'; +import { ENDPOINTS } from '@/consts/api'; +import type { ApiResponseT } from '@/types/api'; + +import type { PostJoinGuestRequestT, PostJoinGuestResponseT } from '../_types/join'; + +type PostJoinGuestParamsT = { + tournamentId: number; + body: PostJoinGuestRequestT; +}; + +/** + * 비회원 게스트 토너먼트 참여. + * 닉네임을 받아 게스트 계정 생성 + 토너먼트 참여를 한 번에 처리. + * 응답으로 토큰 쌍과 생성된 사용자 정보가 반환된다 (서버는 동시에 HttpOnly 쿠키로도 토큰 발급). + */ +export const postJoinGuest = async ({ tournamentId, body }: PostJoinGuestParamsT) => { + const { data } = await clientApi.post>( + ENDPOINTS.TOURNAMENT_JOIN_GUEST(tournamentId), + body + ); + return data.data; +}; diff --git a/apps/web/src/app/tournament/join/_consts/randomNickname.ts b/apps/web/src/app/tournament/join/_consts/randomNickname.ts new file mode 100644 index 00000000..71db3d66 --- /dev/null +++ b/apps/web/src/app/tournament/join/_consts/randomNickname.ts @@ -0,0 +1,20 @@ +import type { ProfileTypeT } from '@/components/user-profile-group/userProfile.const'; + +export type RandomNicknameT = { + nickname: string; + profileType: ProfileTypeT; +}; + +/** + * 랜덤 닉네임 후보 (mock). + * API 연동 후 서버에서 발급받은 닉네임으로 교체. + */ +export const RANDOM_NICKNAMES: RandomNicknameT[] = [ + { nickname: '지친 루피', profileType: 'yellow' }, + { nickname: '게으른 사자', profileType: 'blue' }, + { nickname: '행복한 토끼', profileType: 'yellow' }, + { nickname: '활기찬 강쥐', profileType: 'blue' }, + { nickname: '느긋한 키티', profileType: 'yellow' }, +]; + +export const DEFAULT_RANDOM_NICKNAME = RANDOM_NICKNAMES[0]!; diff --git a/apps/web/src/app/tournament/join/_hooks/useGetInvitePreview.ts b/apps/web/src/app/tournament/join/_hooks/useGetInvitePreview.ts new file mode 100644 index 00000000..122b59f4 --- /dev/null +++ b/apps/web/src/app/tournament/join/_hooks/useGetInvitePreview.ts @@ -0,0 +1,12 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; + +import { getInvitePreview } from '../_apis/getInvitePreview'; + +export const useGetInvitePreview = (tournamentId: number) => { + const { data: invitePreviewData } = useSuspenseQuery({ + queryKey: ['invitePreview', tournamentId], + queryFn: () => getInvitePreview(tournamentId), + }); + + return { invitePreviewData }; +}; diff --git a/apps/web/src/app/tournament/join/_hooks/usePostJoinGuest.ts b/apps/web/src/app/tournament/join/_hooks/usePostJoinGuest.ts new file mode 100644 index 00000000..35488e6b --- /dev/null +++ b/apps/web/src/app/tournament/join/_hooks/usePostJoinGuest.ts @@ -0,0 +1,11 @@ +import { useMutation } from '@tanstack/react-query'; + +import { postJoinGuest } from '../_apis/postJoinGuest'; + +export const usePostJoinGuest = () => { + const { mutateAsync: postJoinGuestMutation, isPending: isPostJoinGuestPending } = useMutation({ + mutationFn: postJoinGuest, + }); + + return { postJoinGuestMutation, isPostJoinGuestPending }; +}; diff --git a/apps/web/src/app/tournament/join/_types/join.ts b/apps/web/src/app/tournament/join/_types/join.ts new file mode 100644 index 00000000..3dec4b38 --- /dev/null +++ b/apps/web/src/app/tournament/join/_types/join.ts @@ -0,0 +1,28 @@ +/** 초대 코드 / 토너먼트 미리보기 응답 */ +export type GetInvitePreviewResponseT = { + tournamentId: number; + tournamentName: string; + itemCount: number; + participantCount: number; +}; + +export type PostJoinGuestRequestT = { + /** 영문 대문자 3 + 숫자 3 (서버 패턴: [A-Z]{3}\d{3}). 링크 직접 진입 시 생략 가능 */ + inviteCode?: string; + /** 최대 10자 */ + nickname: string; +}; + +export type PostJoinGuestResponseT = { + accessToken: string; + refreshToken: string; + userId: string; + nickname: string; + profileImage: string; + tournamentId: number; +}; + +export type PostJoinRequestT = { + /** 영문 대문자 3 + 숫자 3 (서버 패턴: [A-Z]{3}\d{3}). 링크 직접 진입 시 생략 가능 */ + inviteCode?: string; +}; diff --git a/apps/web/src/app/tournament/join/_utils/joinSession.ts b/apps/web/src/app/tournament/join/_utils/joinSession.ts new file mode 100644 index 00000000..65df21d2 --- /dev/null +++ b/apps/web/src/app/tournament/join/_utils/joinSession.ts @@ -0,0 +1,55 @@ +import type { ProfileTypeT } from '@/components/user-profile-group/userProfile.const'; + +const WELCOME_KEY = 'piki:joinWelcome'; +const CONFIRM_KEY = 'piki:joinConfirm'; + +export type JoinWelcomePayloadT = { + tournamentId: number; + nickname: string; + profileType: ProfileTypeT; +}; + +export type JoinConfirmPayloadT = { + tournamentId: number; + nickname: string; + profileType: ProfileTypeT; + tournamentName: string; + itemCount: number; + participantCount: number; +}; + +const writeJson = (key: string, value: unknown) => { + if (typeof window === 'undefined') return; + sessionStorage.setItem(key, JSON.stringify(value)); +}; + +const readJson = (key: string): T | null => { + if (typeof window === 'undefined') return null; + const raw = sessionStorage.getItem(key); + if (!raw) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +}; + +/** 비회원 닉네임 설정 완료 후 환영 노출용 */ +export const setJoinWelcome = (payload: JoinWelcomePayloadT) => writeJson(WELCOME_KEY, payload); + +export const consumeJoinWelcomeFor = (tournamentId: number): JoinWelcomePayloadT | null => { + const payload = readJson(WELCOME_KEY); + if (!payload || payload.tournamentId !== tournamentId) return null; + sessionStorage.removeItem(WELCOME_KEY); + return payload; +}; + +/** 회원이 초대 코드만 입력하고 바로 토너먼트로 진입할 때 확인 다이얼로그 노출용 */ +export const setJoinConfirm = (payload: JoinConfirmPayloadT) => writeJson(CONFIRM_KEY, payload); + +export const consumeJoinConfirmFor = (tournamentId: number): JoinConfirmPayloadT | null => { + const payload = readJson(CONFIRM_KEY); + if (!payload || payload.tournamentId !== tournamentId) return null; + sessionStorage.removeItem(CONFIRM_KEY); + return payload; +}; diff --git a/apps/web/src/app/tournament/join/_utils/verifyInviteCode.ts b/apps/web/src/app/tournament/join/_utils/verifyInviteCode.ts new file mode 100644 index 00000000..c3c7c0f1 --- /dev/null +++ b/apps/web/src/app/tournament/join/_utils/verifyInviteCode.ts @@ -0,0 +1,10 @@ +export const CODE_LENGTH = 6; + +/** 영문 대문자 3 + 숫자 3 — 서버 정의 패턴 [A-Z]{3}\d{3} */ +const CODE_FORMAT_PATTERN = /^[A-Z]{3}\d{3}$/; + +/** + * 클라이언트 형식 검증. + * 통과 시 서버에 `getInvitePreviewByCode` 호출로 실 검증 위임. + */ +export const isValidInviteCodeFormat = (code: string) => CODE_FORMAT_PATTERN.test(code); diff --git a/apps/web/src/assets/icons/fill/timer.svg b/apps/web/src/assets/icons/fill/timer.svg index f70074b0..ed3b4a0a 100644 --- a/apps/web/src/assets/icons/fill/timer.svg +++ b/apps/web/src/assets/icons/fill/timer.svg @@ -1,10 +1,5 @@ - - - - - - - - - + + + + diff --git a/apps/web/src/components/get-item-dialog/ByImageDialog.tsx b/apps/web/src/components/get-item-dialog/ByImageDialog.tsx index 62953cfd..d0fd5017 100644 --- a/apps/web/src/components/get-item-dialog/ByImageDialog.tsx +++ b/apps/web/src/components/get-item-dialog/ByImageDialog.tsx @@ -15,7 +15,6 @@ import type { ItemTypeT } from '@/types/item'; import Spacing from '../spacing'; - const MAX_IMAGE_COUNT = 5; type Props = { diff --git a/apps/web/src/components/get-item-dialog/ByLinkDialog.tsx b/apps/web/src/components/get-item-dialog/ByLinkDialog.tsx index 09c4d704..3b7c6d78 100644 --- a/apps/web/src/components/get-item-dialog/ByLinkDialog.tsx +++ b/apps/web/src/components/get-item-dialog/ByLinkDialog.tsx @@ -98,7 +98,13 @@ function ByLinkDialog({ type, open, onOpenChange }: ByLinkProps) { autoFocus inputMode="url" /> -