diff --git a/apps/web/src/app/home/_apis/postCreateTournament.ts b/apps/web/src/apis/postCreateTournament.ts similarity index 93% rename from apps/web/src/app/home/_apis/postCreateTournament.ts rename to apps/web/src/apis/postCreateTournament.ts index 81b48e57..4503a030 100644 --- a/apps/web/src/app/home/_apis/postCreateTournament.ts +++ b/apps/web/src/apis/postCreateTournament.ts @@ -1,11 +1,10 @@ import { clientApi } from '@/apis/client'; import { ENDPOINTS } from '@/consts/api'; import type { ApiResponseT } from '@/types/api'; - import type { PostCreateTournamentRequestT, PostCreateTournamentResponseT, -} from '../_types/tournament'; +} from '@/types/tournament'; export const postCreateTournament = async (body: PostCreateTournamentRequestT) => { const { data } = await clientApi.post>( diff --git a/apps/web/src/app/archive/_common/_components/ArchivePageLayout.tsx b/apps/web/src/app/archive/_common/_components/ArchivePageLayout.tsx deleted file mode 100644 index 4885c43e..00000000 --- a/apps/web/src/app/archive/_common/_components/ArchivePageLayout.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Header, HeaderIcon } from '@/components/header'; - -type Props = { - title: string; - children: React.ReactNode; -}; - -function ArchivePageLayout({ title, children }: Props) { - return ( -
-
-
-
} /> -

- {title} -

-
-
- {children} -
- ); -} - -export default ArchivePageLayout; diff --git a/apps/web/src/app/archive/tournament/_components/ArchiveTournamentClient.tsx b/apps/web/src/app/archive/tournament/_components/ArchiveTournamentClient.tsx deleted file mode 100644 index 36ae85a6..00000000 --- a/apps/web/src/app/archive/tournament/_components/ArchiveTournamentClient.tsx +++ /dev/null @@ -1,37 +0,0 @@ -'use client'; - -import { Suspense, useState } from 'react'; - -import type { TournamentStatusT } from '@/types/tournament'; - -import ArchivePageLayout from '../../_common/_components/ArchivePageLayout'; -import TournamentHistoryList from './TournamentHistoryList'; -import TournamentStatusTab, { type TournamentStatusTabT } from './TournamentStatusTab'; - -const STATUS_BY_TAB: Record = { - 'in-progress': ['PENDING', 'IN_PROGRESS'], - completed: ['COMPLETED'], -}; - -function ArchiveTournamentClient() { - const [activeTab, setActiveTab] = useState('in-progress'); - - return ( - - - -

- 토너먼트를 불러오는 중이에요 -

- - } - > - -
-
- ); -} - -export default ArchiveTournamentClient; diff --git a/apps/web/src/app/archive/tournament/_components/TournamentFab.tsx b/apps/web/src/app/archive/tournament/_components/TournamentFab.tsx new file mode 100644 index 00000000..f06758e6 --- /dev/null +++ b/apps/web/src/app/archive/tournament/_components/TournamentFab.tsx @@ -0,0 +1,25 @@ +import { AddIconFill } from '@/assets/icons'; +import Button from '@/components/button'; +import CreateTournamentDialogContent from '@/components/common/create-tournament-dialog'; +import { Dialog, DialogTrigger } from '@/components/dialog'; + +function TournamentFab() { + return ( + + + + + + + ); +} + +export default TournamentFab; diff --git a/apps/web/src/app/archive/tournament/_components/TournamentHistoryContent.tsx b/apps/web/src/app/archive/tournament/_components/TournamentHistoryContent.tsx deleted file mode 100644 index ec562773..00000000 --- a/apps/web/src/app/archive/tournament/_components/TournamentHistoryContent.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; - -import { getTournamentList } from '@/apis/getTournamentList'; -import type { TournamentStatusT } from '@/types/tournament'; -import { getQueryClient } from '@/utils/queryClient'; - -import ArchiveTournamentClient from './ArchiveTournamentClient'; - -const DEFAULT_STATUSES: TournamentStatusT[] = ['PENDING', 'IN_PROGRESS']; - -async function TournamentHistoryContent() { - const queryClient = getQueryClient(); - - await queryClient.prefetchQuery({ - queryKey: ['tournamentList', DEFAULT_STATUSES], - queryFn: () => getTournamentList(DEFAULT_STATUSES), - }); - - return ( - - - - ); -} - -export default TournamentHistoryContent; diff --git a/apps/web/src/app/archive/tournament/_components/TournamentHistoryList.tsx b/apps/web/src/app/archive/tournament/_components/TournamentHistoryList.tsx index 4246edbc..4e2de0d0 100644 --- a/apps/web/src/app/archive/tournament/_components/TournamentHistoryList.tsx +++ b/apps/web/src/app/archive/tournament/_components/TournamentHistoryList.tsx @@ -20,7 +20,7 @@ function TournamentHistoryList({ statuses }: Props) { ); return ( -
+ <> {tournamentListData.map(tournament => ( ))} -
+ ); } diff --git a/apps/web/src/app/archive/tournament/_components/TournamentHistorySection.tsx b/apps/web/src/app/archive/tournament/_components/TournamentHistorySection.tsx new file mode 100644 index 00000000..e0459ad8 --- /dev/null +++ b/apps/web/src/app/archive/tournament/_components/TournamentHistorySection.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { Suspense, useState } from 'react'; + +import Spacing from '@/components/spacing'; + +import { STATUS_BY_TAB, type TournamentStatusTabT } from '../_consts/tournamentTab'; +import TournamentHistoryList from './TournamentHistoryList'; +import TournamentHistorySkeleton from './TournamentHistorySkeleton'; +import TournamentStatusTab from './TournamentStatusTab'; + +type Props = { + initialTab: TournamentStatusTabT; +}; + +function TournamentHistorySection({ initialTab }: Props) { + const [activeTab, setActiveTab] = useState(initialTab); + + /** 서버 왕복 없이 주소만 동기화 */ + const handleTabChange = (tab: TournamentStatusTabT) => { + setActiveTab(tab); + window.history.replaceState(null, '', `?tab=${tab}`); + }; + + return ( + <> +
+

내 토너먼트

+ + +
+
+ }> + + +
+ + ); +} + +export default TournamentHistorySection; diff --git a/apps/web/src/app/archive/tournament/_components/TournamentHistorySkeleton.tsx b/apps/web/src/app/archive/tournament/_components/TournamentHistorySkeleton.tsx new file mode 100644 index 00000000..69c88875 --- /dev/null +++ b/apps/web/src/app/archive/tournament/_components/TournamentHistorySkeleton.tsx @@ -0,0 +1,15 @@ +import TournamentCardSkeleton from '@/components/tournament-card/TournamentCardSkeleton'; + +const SKELETON_COUNT = 4; + +function TournamentHistorySkeleton() { + return ( + <> + {Array.from({ length: SKELETON_COUNT }).map((_, index) => ( + + ))} + + ); +} + +export default TournamentHistorySkeleton; diff --git a/apps/web/src/app/archive/tournament/_components/TournamentStatusTab.tsx b/apps/web/src/app/archive/tournament/_components/TournamentStatusTab.tsx index 7577c49f..2304c9c2 100644 --- a/apps/web/src/app/archive/tournament/_components/TournamentStatusTab.tsx +++ b/apps/web/src/app/archive/tournament/_components/TournamentStatusTab.tsx @@ -1,34 +1,42 @@ 'use client'; +import type { ComponentType, SVGProps } from 'react'; + +import { BasketIconOutline, ReciptIconOutline } from '@/assets/icons'; import { cn } from '@/utils/cn'; -export type TournamentStatusTabT = 'in-progress' | 'completed'; +import type { TournamentStatusTabT } from '../_consts/tournamentTab'; type Props = { activeTab: TournamentStatusTabT; onTabChange: (tab: TournamentStatusTabT) => void; }; -const TABS: { value: TournamentStatusTabT; label: string }[] = [ - { value: 'in-progress', label: '진행 중' }, - { value: 'completed', label: '완료' }, +const TABS: { + value: TournamentStatusTabT; + label: string; + Icon: ComponentType>; +}[] = [ + { value: 'ongoing', label: '진행 중', Icon: BasketIconOutline }, + { value: 'completed', label: '완료', Icon: ReciptIconOutline }, ]; function TournamentStatusTab({ activeTab, onTabChange }: Props) { return (
- {TABS.map(tab => ( + {TABS.map(({ value, label, Icon }) => ( diff --git a/apps/web/src/app/archive/tournament/_consts/tournamentTab.ts b/apps/web/src/app/archive/tournament/_consts/tournamentTab.ts new file mode 100644 index 00000000..802c0db7 --- /dev/null +++ b/apps/web/src/app/archive/tournament/_consts/tournamentTab.ts @@ -0,0 +1,13 @@ +import type { TournamentStatusT } from '@/types/tournament'; + +export type TournamentStatusTabT = 'ongoing' | 'completed'; + +/** + * 탭 → 조회할 토너먼트 상태 매핑 + * - 진행 중(ongoing) 탭: 미완료(담는 중 + 플레이 중) 토너먼트 + * - 완료(completed) 탭: 완료된 토너먼트 + */ +export const STATUS_BY_TAB: Record = { + ongoing: ['PENDING', 'IN_PROGRESS'], + completed: ['COMPLETED'], +}; diff --git a/apps/web/src/app/archive/tournament/page.tsx b/apps/web/src/app/archive/tournament/page.tsx index 27d4cdc6..dde48825 100644 --- a/apps/web/src/app/archive/tournament/page.tsx +++ b/apps/web/src/app/archive/tournament/page.tsx @@ -1,13 +1,36 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; + +import { getTournamentList } from '@/apis/getTournamentList'; import BottomTabBar from '@/components/bottom-tab-bar'; +import { getQueryClient } from '@/utils/queryClient'; + +import TournamentFab from './_components/TournamentFab'; +import TournamentHistorySection from './_components/TournamentHistorySection'; +import { STATUS_BY_TAB, type TournamentStatusTabT } from './_consts/tournamentTab'; + +type Props = { + searchParams: Promise<{ tab?: string }>; +}; + +async function ArchiveTournamentPage({ searchParams }: Props) { + const { tab } = await searchParams; + const initialTab: TournamentStatusTabT = tab === 'completed' ? 'completed' : 'ongoing'; -import TournamentHistoryContent from './_components/TournamentHistoryContent'; + const queryClient = getQueryClient(); + const statuses = STATUS_BY_TAB[initialTab]; + await queryClient.prefetchQuery({ + queryKey: ['tournamentList', statuses], + queryFn: () => getTournamentList(statuses), + }); -async function ArchiveTournamentPage() { return ( - <> - +
+ + + + - +
); } diff --git a/apps/web/src/app/home/_components/CreateTournamentDialog.tsx b/apps/web/src/app/home/_components/CreateTournamentDialog.tsx deleted file mode 100644 index be74490c..00000000 --- a/apps/web/src/app/home/_components/CreateTournamentDialog.tsx +++ /dev/null @@ -1,80 +0,0 @@ -'use client'; - -import { useState } from 'react'; - -import { BasketIconFill, EditIconFill } from '@/assets/icons'; -import Button from '@/components/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogTitle, - DialogTrigger, -} from '@/components/dialog'; -import Input from '@/components/input'; - -import { usePostCreateTournament } from '../_hooks/usePostCreateTournament'; - -/** 토너먼트 생성 시 기본 초대 마감 시각 — 현재 + 30분. */ -const DEFAULT_INVITE_DURATION_MINUTES = 30; - -function CreateTournamentDialog() { - const [open, setOpen] = useState(false); - const [name, setName] = useState(''); - const { postCreateTournamentMutation, isPostCreateTournamentPending } = usePostCreateTournament(); - - const trimmedName = name.trim(); - const isDisabled = trimmedName.length === 0 || isPostCreateTournamentPending; - - const handleCreate = () => { - if (isDisabled) return; - postCreateTournamentMutation( - { name: trimmedName, inviteDurationMinutes: DEFAULT_INVITE_DURATION_MINUTES }, - { - onSuccess: () => { - setOpen(false); - setName(''); - }, - } - ); - }; - - return ( - - - - - - 새 토너먼트 생성 다이얼로그 - - 새 토너먼트 - -
- setName(e.target.value)} - autoFocus - maxLength={30} - right={isDisabled ? : null} - /> - -
-
-
- ); -} - -export default CreateTournamentDialog; diff --git a/apps/web/src/app/home/_components/tournament-list/TournamentListSkeleton.tsx b/apps/web/src/app/home/_components/tournament-list/TournamentListSkeleton.tsx deleted file mode 100644 index 28f76c3f..00000000 --- a/apps/web/src/app/home/_components/tournament-list/TournamentListSkeleton.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import Skeleton from '@/components/skeleton'; - -/** prefetch 되는 동안 노출할 카드 개수. 리스트 limit(3)과 맞춤. */ -const SKELETON_COUNT = 3; - -function TournamentCardSkeleton() { - return ( -
- {/* 썸네일 */} -
- -
- - {/* 텍스트 영역 */} -
-
- - -
-
- - -
-
-
- ); -} - -function TournamentListSkeleton() { - return ( - <> - {Array.from({ length: SKELETON_COUNT }).map((_, index) => ( - - ))} - - ); -} - -export default TournamentListSkeleton; diff --git a/apps/web/src/app/home/_components/tournament-list/index.tsx b/apps/web/src/app/home/_components/tournament-list/index.tsx index 53968c4b..f05728df 100644 --- a/apps/web/src/app/home/_components/tournament-list/index.tsx +++ b/apps/web/src/app/home/_components/tournament-list/index.tsx @@ -2,10 +2,10 @@ import Link from 'next/link'; import { Suspense } from 'react'; import { ChevronForwardIconFill } from '@/assets/icons'; +import TournamentCardSkeleton from '@/components/tournament-card/TournamentCardSkeleton'; import { ROUTES } from '@/consts/route'; import type { TournamentStatusT } from '@/types/tournament'; -import TournamentListSkeleton from './TournamentListSkeleton'; import TournamentListClient from './client'; const TOURNAMENT_LIST_STATUS: TournamentStatusT[] = ['PENDING', 'IN_PROGRESS']; @@ -20,7 +20,15 @@ function TournamentList() {
- }> + + {Array.from({ length: 3 }).map((_, index) => ( + + ))} + + } + > diff --git a/apps/web/src/app/home/_types/tournament.ts b/apps/web/src/app/home/_types/tournament.ts deleted file mode 100644 index 69753f28..00000000 --- a/apps/web/src/app/home/_types/tournament.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type PostCreateTournamentRequestT = { - name: string; - /** 초대 마감까지 남은 분 단위 시간 (1~1440). 미지정 시 서버 기본값 사용. */ - inviteDurationMinutes?: number; -}; - -export type PostCreateTournamentResponseT = { - tournamentId: number; -}; diff --git a/apps/web/src/app/home/page.tsx b/apps/web/src/app/home/page.tsx index 543513c1..70840486 100644 --- a/apps/web/src/app/home/page.tsx +++ b/apps/web/src/app/home/page.tsx @@ -1,14 +1,16 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { getMe } from '@/apis/getMe'; +import { BasketIconFill } from '@/assets/icons'; import PiKiLogo from '@/assets/images/piki-logo-text.svg'; import BottomTabBar from '@/components/bottom-tab-bar'; +import CreateTournamentDialogContent from '@/components/common/create-tournament-dialog'; +import { Dialog, DialogTrigger } from '@/components/dialog'; import { Header, HeaderIcon } from '@/components/header'; import Spacing from '@/components/spacing'; import { getQueryClient } from '@/utils/queryClient'; import AddWishHomeDialog from './_components/AddWishHomeDialog'; -import CreateTournamentDialog from './_components/CreateTournamentDialog'; import InviteTournamentDialog from './_components/InviteTournamentDialog'; import MemberOnlyToast from './_components/MemberOnlyToast'; import TournamentList from './_components/tournament-list'; @@ -35,7 +37,21 @@ async function HomePage() { {/** 위시 추가·토너먼트 생성·토너먼트 초대 */}
- + + + + + +
diff --git a/apps/web/src/components/common/create-tournament-dialog/index.tsx b/apps/web/src/components/common/create-tournament-dialog/index.tsx new file mode 100644 index 00000000..89c61ae2 --- /dev/null +++ b/apps/web/src/components/common/create-tournament-dialog/index.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useState } from 'react'; + +import { EditIconFill } from '@/assets/icons'; +import Button from '@/components/button'; +import { DialogContent, DialogDescription, DialogTitle } from '@/components/dialog'; +import Input from '@/components/input'; +import { usePostCreateTournament } from '@/hooks/usePostCreateTournament'; + +/** 토너먼트 생성 시 기본 초대 마감 시각 — 현재 + 30분. */ +const DEFAULT_INVITE_DURATION_MINUTES = 30; + +function CreateTournamentDialogContent() { + const [name, setName] = useState(''); + const { postCreateTournamentMutation, isPostCreateTournamentPending } = usePostCreateTournament(); + + const trimmedName = name.trim(); + const isDisabled = trimmedName.length === 0 || isPostCreateTournamentPending; + + const handleCreate = () => { + if (isDisabled) return; + postCreateTournamentMutation({ + name: trimmedName, + inviteDurationMinutes: DEFAULT_INVITE_DURATION_MINUTES, + }); + }; + + return ( + + 새 토너먼트 생성 다이얼로그 + + 새 토너먼트 + +
+ setName(e.target.value)} + autoFocus + maxLength={30} + right={isDisabled ? : null} + /> + +
+
+ ); +} + +export default CreateTournamentDialogContent; diff --git a/apps/web/src/components/tournament-card/TournamentCardSkeleton.tsx b/apps/web/src/components/tournament-card/TournamentCardSkeleton.tsx new file mode 100644 index 00000000..b0fd11e2 --- /dev/null +++ b/apps/web/src/components/tournament-card/TournamentCardSkeleton.tsx @@ -0,0 +1,30 @@ +import Skeleton from '@/components/skeleton'; + +function TournamentCardSkeleton() { + return ( +
+ {/* 이미지 썸네일 */} +
+
+ +
+ +
+ + {/* 텍스트 영역 */} +
+
+ {/* 상태 칩 */} + + {/* 이름 */} + +
+ + {/* 참여자 프로필 */} + +
+
+ ); +} + +export default TournamentCardSkeleton; diff --git a/apps/web/src/app/home/_hooks/usePostCreateTournament.ts b/apps/web/src/hooks/usePostCreateTournament.ts similarity index 93% rename from apps/web/src/app/home/_hooks/usePostCreateTournament.ts rename to apps/web/src/hooks/usePostCreateTournament.ts index 40e0fc6c..6acf542d 100644 --- a/apps/web/src/app/home/_hooks/usePostCreateTournament.ts +++ b/apps/web/src/hooks/usePostCreateTournament.ts @@ -1,12 +1,11 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; +import { postCreateTournament } from '@/apis/postCreateTournament'; import { ANALYTICS_EVENT } from '@/consts/analytics'; import { ROUTES } from '@/consts/route'; import { logAnalyticsEvent } from '@/utils/analytics'; -import { postCreateTournament } from '../_apis/postCreateTournament'; - export const usePostCreateTournament = () => { const router = useRouter(); const queryClient = useQueryClient(); diff --git a/apps/web/src/types/tournament.ts b/apps/web/src/types/tournament.ts index dec87f4b..9bedfa37 100644 --- a/apps/web/src/types/tournament.ts +++ b/apps/web/src/types/tournament.ts @@ -36,3 +36,13 @@ export type GetTournamentListResponseT = { export type PostTournamentOCRResponseT = { itemIds: number[]; }; + +export type PostCreateTournamentRequestT = { + name: string; + /** 초대 마감까지 남은 분 단위 시간 (1~1440). 미지정 시 서버 기본값 사용. */ + inviteDurationMinutes?: number; +}; + +export type PostCreateTournamentResponseT = { + tournamentId: number; +};