From a52d326956512fa008c8f8ba2a252c32ae1a9d6e Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Wed, 10 Jun 2026 01:36:21 +0900 Subject: [PATCH 01/13] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit 52c225d4d2aee357e54c5fa151983dfbe333171d Author: joyeongchan Date: Tue Jun 9 21:52:08 2026 +0900 chore: format commit 4f452101031a78be902fbca9f542ef955b32cd52 Author: joyeongchan Date: Tue Jun 9 21:31:28 2026 +0900 refactor: 외부 라우트 import 를 절대경로로 변경 commit de4f8b495c27594a47da77ffca69ab6f401b6ba8 Author: joyeongchan Date: Tue Jun 9 19:00:18 2026 +0900 fix: 친구 토너먼트 결과보기 카드 노출 조건 단순화 + 에러 안내 commit 143ca1e3b6a35ff7fc31cbc1e34a51e44dc79db2 Author: joyeongchan Date: Tue Jun 9 18:42:01 2026 +0900 fix: 플레이 링크 진입 후 CLONE 상태에 따라 라우팅 분기 commit e8cfd721b4899ce53402ee6c1f85841a8b4215ce Author: joyeongchan Date: Tue Jun 9 18:39:49 2026 +0900 fix: 비로그인 진입 시 PlayClient 게스트 발급 회복 (401/400 둘 다 처리) commit 3bcfaa3f3b4c5fc6cd1542b03ba316fa85b46bc7 Author: joyeongchan Date: Tue Jun 9 18:31:34 2026 +0900 fix: 플레이 링크 진입 시 토너먼트 상태에 따라 적절 화면 라우팅 commit 31cf810dc32eea326dd1564fdd0e922f4e8bc56f Author: joyeongchan Date: Tue Jun 9 18:28:26 2026 +0900 fix: 결승 직후 토너먼트 캐시 시드 제거 (그룹 결과 카드 즉시 노출) commit 8f6cc22e4102b6df559f647c13e0915c4f9a3ee2 Author: joyeongchan Date: Tue Jun 9 16:27:51 2026 +0900 chore: from-play-link idempotent 동작 주석 갱신 commit 534dbed411a7d70f7ed35397e980c31a47d0a21d Author: joyeongchan Date: Tue Jun 9 14:07:22 2026 +0900 fix: 결과 페이지에서 토너먼트 캐시를 권위 응답으로 강제 덮어쓰기 commit eec26dbd5dc7712f70048c55d80a514851dcb923 Author: joyeongchan Date: Tue Jun 9 04:26:29 2026 +0900 chore : format commit 7c0bb2b3ea4f0d85a761e91d2452e41cbde97bc6 Author: joyeongchan Date: Tue Jun 9 04:22:59 2026 +0900 fix: 그룹 결과 카드를 ROOT 토너먼트에서만 노출 commit e813384b8a4bb6d10527ae6fd0f6e9071efa7229 Author: joyeongchan Date: Mon Jun 8 20:56:22 2026 +0900 feat: ownerStarted/isRoot 기반 참여자 대기·플레이 링크 공유 분기 commit 6d7805a76104d80a07e20e5f382d3b918b73a3bb Author: joyeongchan Date: Mon Jun 8 20:43:08 2026 +0900 fix: 친구 토너먼트 결과보기 카드에 아바타 그룹 추가 (시안 일치) commit 02ae3d46f1a0ff4556f2d36cede7f285b3ec69aa Author: joyeongchan Date: Mon Jun 8 20:36:39 2026 +0900 refactor: PlateShareDialog 시안 일치 (스톱워치/체크리스트 제거) commit 98100078b087745a1e896ee282ac383368bd5aea Author: joyeongchan Date: Mon Jun 8 19:24:41 2026 +0900 feat: 참여자 토너먼트 진행 (CLONE 구조 대응) commit 0990df0f65cf68c7df10f3b4f646a028ef759e90 Author: joyeongchan Date: Sun Jun 7 05:18:02 2026 +0900 fix: refresh/guest/play-link POST body를 빈 객체로 통일 commit c5e70d69088aa44309975365edc4468b126c1541 Author: joyeongchan Date: Sun Jun 7 05:12:06 2026 +0900 fix: 비회원 게스트 참여 시 inviteCode 전달 commit 9d55b3a0c0f93ae136b6fd1388d8583c93a15631 Author: joyeongchan Date: Sun Jun 7 04:31:11 2026 +0900 feat: 참여자 화면이 status 변화 감지해 자동 라우팅 commit bca0b400cb38ca6078038686f83385f9a67c9d34 Author: joyeongchan Date: Sun Jun 7 04:08:54 2026 +0900 fix: 결과 화면 하단 단일 CTA로 정리 commit f654e7301cb251b99b72e4f044bc41a36d68be4c Author: joyeongchan Date: Sun Jun 7 04:05:54 2026 +0900 fix: body에 suppressHydrationWarning 추가 (확장 프로그램 대응) commit bb1bc17ec1a91ae3e3d77b1cd5576b217a6ff799 Author: joyeongchan Date: Sun Jun 7 04:00:31 2026 +0900 feat: 그룹 결과 화면 1st 강조 + Others 축약 형태로 재구성 commit 57d088b00a7fb6293d929fb8aad145391597550b Author: joyeongchan Date: Sun Jun 7 03:55:22 2026 +0900 fix: 회원 프로필 이미지 정사각형 컨테이너로 렌더링 commit 1189b795ea38bc98a1cbf6b1b9a74c22e03e9160 Author: joyeongchan Date: Sun Jun 7 03:53:27 2026 +0900 fix: 비회원 참가자 프로필을 기본 SVG로 표시 (홈/토너먼트 일관 적용) commit a73264dc96e7775931646a76831120ce8e43699b Author: joyeongchan Date: Sun Jun 7 03:51:38 2026 +0900 fix: 비회원 참가자 프로필을 기본 SVG로 표시 commit a3b7ce15edd5b8281aaae2b620c9b39fb380b2e3 Author: joyeongchan Date: Sun Jun 7 03:43:26 2026 +0900 feat: 친구 초대 링크 진입 라우트 추가 commit 5c1380444dd5037309b71ff58a8efb73eb9a90d8 Author: joyeongchan Date: Sun Jun 7 03:22:42 2026 +0900 fix: 플레이 링크 공유 주최자 분기 + 기존 링크 재사용 commit 3726ce21f70c1309c0614cf553e428cea263dcce Author: joyeongchan Date: Sun Jun 7 03:15:03 2026 +0900 fix: WishGrid 리스트 항목에 누락된 key prop 추가 commit fd45030ceefe00ad8d88ed0b1a77e2b64bacf853 Author: joyeongchan Date: Sun Jun 7 03:12:06 2026 +0900 fix: 참여자 mock 제거 및 친구 없을 때 카운트다운/시작 확인 분기 commit 4488d2cdd889c72fbc9bc22b9f3bf047df1fe3c8 Author: joyeongchan Date: Sun Jun 7 03:03:53 2026 +0900 fix: 주최자는 초대 만료와 무관하게 담기 가능하도록 수정 commit 7307ee778b785452101f417f0105071356bd5fc0 Author: joyeongchan Date: Sun Jun 7 02:35:08 2026 +0900 feat: 그룹 결과 보기 화면 추가 및 결과 진입 카드 연동 commit ea2ddad6f287cc541150ab74b52604799cbdc6e0 Author: joyeongchan Date: Sun Jun 7 02:19:01 2026 +0900 feat: 플레이 링크 진입 시 토너먼트 자동 복제 및 라우팅 commit d970c4a7e7e21a5c8ad91a949cd17621c38e3d91 Author: joyeongchan Date: Sun Jun 7 01:43:30 2026 +0900 chore: prettier commit 8460097df93cc63130b0af062587e2dd9caa4582 Author: joyeongchan Date: Sun Jun 7 01:27:39 2026 +0900 feat: 플레이 링크 만료 시각 서버 응답 기반으로 표시 commit 9f032eea1151c1f69bd54ff3f0c624b945a3f7eb Author: joyeongchan Date: Sun Jun 7 01:21:19 2026 +0900 feat: 초대 코드 검증 API 연동 및 미리보기 흐름 실 데이터화 commit 939b7581d4a0d7835b08d8ce51bb38cf20bbdb9c Author: joyeongchan Date: Sun Jun 7 00:03:57 2026 +0900 refactor: 담기 마감을 초대 코드 만료 시간으로 통일 commit 03515cb1a2be65a8f70184a64f9ee804a73c483b Author: joyeongchan Date: Sat Jun 6 03:33:11 2026 +0900 feat: 친구 초대 다이얼로그 동적 데이터 연동 (만료시간/링크) commit 77a18063163758a87e0c9b7c72ac42e026de30ae Author: joyeongchan Date: Sat Jun 6 03:25:24 2026 +0900 feat: 비회원 게스트 토너먼트 참여 API 연동 commit 76a9a4e4f0abbb2ea29640db38c7088ed21091a1 Author: joyeongchan Date: Sat Jun 6 03:16:41 2026 +0900 feat: 플레이 링크 생성 API 연동 및 공유 URL 동적 구성 commit 3622c244b7620d033916c1c57133f2f7cd5d2edd Author: joyeongchan Date: Sat Jun 6 03:04:28 2026 +0900 refactor: 주최자 분기를 tournamentData.isOwner로 통일 commit 9678e2a2fc58d9949aeeadd099761dc0138e674f Author: joyeongchan Date: Sat Jun 6 02:56:28 2026 +0900 feat: 닉네임 중복 체크 API 연동 commit 118eec3e41da62f1a12c4c0cd35bbe9bf702f6e6 Author: joyeongchan Date: Sat Jun 6 02:36:24 2026 +0900 docs: 소셜 토너먼트 API 연동 계획 문서 추가 commit 8b7d83290bfeffe0688fae705499d40a74cc70d8 Author: joyeongchan Date: Sat Jun 6 02:20:07 2026 +0900 chore: 슬랙봇 임시완화 복구 commit c38bc5f3c3539ae03cf5ae75ea132bfbf13ed807 Author: joyeongchan Date: Sat Jun 6 02:19:24 2026 +0900 chore: 슬랙봇 누락 commit 0a4624dbee4300d0cbfc977324eaeefa989feda2 Author: joyeongchan Date: Sat Jun 6 02:14:13 2026 +0900 chore: prettier format commit 496717daab7cc6f3eb214bc59599c009ee2fc23f Merge: b2cb0f7 c858551 Author: joyeongchan Date: Sat Jun 6 02:11:22 2026 +0900 merge: dev 머지 + components/common 경로 리네임 반영 commit b2cb0f73e0e2bb861c802a5e9e9b96cc74661a69 Author: joyeongchan Date: Sat Jun 6 01:55:52 2026 +0900 chore: format commit ae1c2b8e326a827c5b6030d404e23dfe09a702e7 Author: joyeongchan Date: Thu Jun 4 04:12:40 2026 +0900 feat: 홈 초대 코드 다이얼로그 + 형식/불일치 에러 분기 구현 commit 2a112f2342fb334268e53e076ac2acced84ee47c Author: joyeongchan Date: Thu Jun 4 02:54:50 2026 +0900 feat: 참여자 패널 친구 유무에 따른 UI 분기 commit a2a860ba492e256580fcaac6918a5a4adb3d6716 Author: joyeongchan Date: Thu Jun 4 02:29:23 2026 +0900 chore: mock 담기 종료 시간 1분 조정 commit 0590e74b21a93c9a258c856cf130ac959c6bd000 Author: joyeongchan Date: Thu Jun 4 02:22:10 2026 +0900 feat: 초대 참여자에게 시작 버튼 비활성 + 안내 툴팁 표시 commit 43a8960f597b0e391a92186b70c072a46acb86e1 Author: joyeongchan Date: Thu Jun 4 02:05:46 2026 +0900 fix: 담기 종료 후 슬롯 UI 정리 + 캐러셀 memo 처리 commit e3a7d8b0c8e0fe52b3e26db6ecb72ae528476ac7 Author: joyeongchan Date: Wed Jun 3 22:28:59 2026 +0900 feat: 담기 종료 이후 상태별 UI 분기 처리 commit efb5d97a3898ff12fe590fdf1cea112c925981e2 Author: joyeongchan Date: Wed Jun 3 20:09:47 2026 +0900 feat: 회원/비회원 초대 참여 분기 + 회원용 확인 다이얼로그 추가 commit 3839f695722aef9fa56924f3d9809b1fbd69e30f Author: joyeongchan Date: Wed Jun 3 20:01:12 2026 +0900 fix: 초대 환영 바텀시트 대상 토너먼트에서만 노출 commit a5575e2fe15093114287c9be0f43b17c9e58ae40 Author: joyeongchan Date: Wed Jun 3 19:56:43 2026 +0900 feat: 초대 참여 후 환영 바텀시트 자동 노출 commit 06088a1ee594a8f9d4e0311e86a8bebf9bd8c010 Author: joyeongchan Date: Wed Jun 3 19:45:29 2026 +0900 fix: 초대 참여하기 화면 풀스크린 페이지로 변경 commit 1b111887727a0f68d3941dc66ecd06b2e5059225 Author: joyeongchan Date: Wed Jun 3 19:42:40 2026 +0900 chore: 임시 mock 초대 코드 111111로 변경 commit 2ca22170b8f3f7ac71f8ba282a8de5abe101afb9 Author: joyeongchan Date: Wed Jun 3 19:39:20 2026 +0900 feat: 초대 참여 닉네임 확인 화면 UI 구현 commit 6eb6e7fa41f3424d0515fcc22ce24682c8049670 Author: joyeongchan Date: Wed Jun 3 18:43:26 2026 +0900 style: 초대 코드 화면 타이포·간격 시안 매칭 commit c012c39683a9a8d347e643ff9bcad3a25590a8c4 Author: joyeongchan Date: Wed Jun 3 18:41:25 2026 +0900 fix: 홈 초대 토너먼트 입장 버튼 라우팅 연결 commit 46e1b9510211f9fa3f31e0e4d2b560686d06e205 Author: joyeongchan Date: Wed Jun 3 18:37:11 2026 +0900 fix: 초대 코드 에러 헬퍼 텍스트 디자인에 맞게 수정 commit 46f7399b0821031fe1308315739b0328f5fbfa0d Author: joyeongchan Date: Wed Jun 3 17:51:21 2026 +0900 refactor: 초대 코드 페이지 경로 invite → join 변경 commit 3b4f9858e5e88fe6125086e15a12f2d5355cb161 Author: joyeongchan Date: Wed Jun 3 17:48:29 2026 +0900 feat: 초대 코드 입력 화면 UI 구현 commit 3273be5a53806754b6a2f238f2ee8b308fa9c16a Author: joyeongchan Date: Wed Jun 3 16:31:06 2026 +0900 refactor: 익명 핸들러 분리 + 사용 안 하는 InviteFriends 제거 commit 8cf458009e8eaaccb2b13ec774334a3e670046c1 Author: joyeongchan Date: Wed Jun 3 04:26:02 2026 +0900 chore: format commit 229ab7ae5da7cb8a40a4cf50652d7cb23e85b4ec Author: joyeongchan Date: Wed Jun 3 04:25:00 2026 +0900 feat: 결과 화면 플레이트 공유 다이얼로그 구현 commit 4841629ea8d9f6fb8b3a71e3ee3fd3cfe6ead797 Author: joyeongchan Date: Wed Jun 3 04:06:52 2026 +0900 refactor: 친구 초대/참여자 패널 아이콘 색상 토큰 정리 commit c3ceccdb210a91809a718be9bc559d08dc0423f3 Author: joyeongchan Date: Wed Jun 3 04:03:44 2026 +0900 fix: 토너먼트 시작/뒤로가기 모달 아이콘 디자인 교체 commit 84c3dd4d3f56f9fad2ca576c60e4e0d99f70fe07 Author: joyeongchan Date: Wed Jun 3 03:59:28 2026 +0900 fix: 친구 mock 머지 임계값을 2명 이상으로 조정 commit 3fd0f3c0f7ca44b3768a9b6a4f9a7340b1552760 Author: joyeongchan Date: Wed Jun 3 03:44:21 2026 +0900 refactor: 친구 포함 여부 판단을 tournamentData participants로 변경 commit c60ac1df73a8e119eaf6e303eb20f749a78ad1e1 Author: joyeongchan Date: Wed Jun 3 03:42:12 2026 +0900 feat: 친구 포함 토너먼트 시작 확인 모달 추가 commit cf30ac693af513fd2dda6ecfc76755c26eb52537 Author: joyeongchan Date: Wed Jun 3 03:34:03 2026 +0900 feat: 후보 담기 마감 카운트다운 UI 구현 commit a71c8c3b8c2a6d9856370ae40f515f90387ed2e3 Author: joyeongchan Date: Wed Jun 3 03:21:44 2026 +0900 feat: 토너먼트 참여자 패널 collapsed/expanded UI 구현 commit ecdfe2ad0ef564f0e1868a18f52168abf743f6a1 Author: joyeongchan Date: Wed Jun 3 03:09:26 2026 +0900 fix: 초대 링크 공유 토스트 메시지 통일 commit 11ecd7b7c5aab6f965ce85bf13a8b5c3bf317ecd Author: joyeongchan Date: Wed Jun 3 03:06:15 2026 +0900 feat: 친구 초대 링크 공유 시트 연동 / 임시 더미 url commit 6274dd7bf55b0ca7ad7df42b224a09221efcfa85 Author: joyeongchan Date: Wed Jun 3 02:59:52 2026 +0900 feat: 토너먼트 친구 초대 바텀시트 다이얼로그 구현 --- apps/web/src/apis/client.ts | 11 +- apps/web/src/apis/getNicknameCheck.ts | 28 ++ .../archive/_components/wish-grid/index.tsx | 8 +- apps/web/src/app/home/_assets/sad-face.svg | 6 + .../_components/InviteTournamentButton.tsx | 30 ++ .../app/home/_components/TournamentList.tsx | 7 +- .../invite-code-dialog/InvalidCodeDialog.tsx | 43 ++ .../invite-code-dialog/InviteCodeDialog.tsx | 117 +++++ apps/web/src/app/home/page.tsx | 10 +- .../invite/[id]/_components/InviteClient.tsx | 88 ++++ apps/web/src/app/invite/[id]/page.tsx | 22 + apps/web/src/app/layout.tsx | 2 +- .../web/src/app/login/_apis/postGuestLogin.ts | 3 +- .../app/login/_components/LoginButtons.tsx | 10 +- .../login/_components/SocialLoginButton.tsx | 22 +- apps/web/src/app/login/page.tsx | 2 +- .../app/play/[id]/_components/PlayClient.tsx | 116 +++++ apps/web/src/app/play/[id]/page.tsx | 20 + .../src/app/play/_apis/postFromPlayLink.ts | 22 + apps/web/src/app/play/_types/play.ts | 2 + .../[id]/_common/_types/tournamentResponse.ts | 70 ++- .../create/_assets/confirm-exit-basket.svg | 3 + .../create/_assets/confirm-start-face.svg | 6 + .../_components/TournamentCreateClient.tsx | 98 ++++- .../deposit-countdown/DepositCountdown.tsx | 22 + .../invite-friends/InviteFriends.tsx | 21 - .../invite-friends/InviteFriendsDialog.tsx | 128 ++++++ .../MemberJoinConfirmDialog.tsx | 60 +++ .../participant-panel/ParticipantChip.tsx | 19 + .../participant-panel/ParticipantPanel.tsx | 128 ++++++ .../tournament-header/ConfirmExitDialog.tsx | 58 +++ .../tournament-header/TournamentHeader.tsx | 54 ++- .../TournamentItemBasketStatus.tsx | 17 +- .../EmptyBasketSlot.tsx | 10 +- .../TournamentItemBasket.tsx | 22 +- .../TournamentItemBasketCarousel.tsx | 104 +++-- .../ConfirmStartDialog.tsx | 53 +++ .../TournamentStartButton.tsx | 86 +++- .../welcome-join-dialog/WelcomeJoinDialog.tsx | 51 +++ .../[id]/create/_hooks/useCountdown.ts | 29 ++ .../create/_hooks/useGetTournamentItem.ts | 3 +- .../_hooks/usePostTournamentItemLink.ts | 3 +- .../create/_hooks/usePostTournamentStart.ts | 7 +- .../[id]/create/_types/tournament.ts | 20 + .../by-wish/_components/WishSelectHeader.tsx | 4 +- .../src/app/tournament/[id]/create/page.tsx | 3 +- .../[id]/match/_hooks/useTournament.ts | 22 +- .../src/app/tournament/[id]/match/page.tsx | 33 +- .../[id]/result/_apis/getGroupResult.ts | 32 ++ .../[id]/result/_apis/postPlayLink.ts | 19 + .../result/_components/ReceiptDrawMachine.tsx | 25 +- .../[id]/result/_components/ReceiptPaper.tsx | 24 +- .../[id]/result/_components/ResultClient.tsx | 47 +- .../GroupResultEntryCard.tsx | 37 ++ .../plate-share-dialog/PlateShareDialog.tsx | 96 +++++ .../[id]/result/_hooks/useGetGroupResult.ts | 17 + .../[id]/result/_hooks/usePostPlayLink.ts | 11 + .../[id]/result/_types/groupResult.ts | 20 + .../group/_components/GroupResultClient.tsx | 281 ++++++++++++ .../app/tournament/[id]/result/group/page.tsx | 39 ++ .../src/app/tournament/[id]/result/page.tsx | 15 +- .../[id]/_components/JoinPreviewClient.tsx | 108 +++++ .../web/src/app/tournament/join/[id]/page.tsx | 35 ++ .../tournament/join/_apis/getInvitePreview.ts | 30 ++ .../join/_apis/getInvitePreviewByCode.ts | 22 + .../tournament/join/_apis/postJoinGuest.ts | 23 + .../tournament/join/_consts/randomNickname.ts | 20 + .../join/_hooks/useGetInvitePreview.ts | 12 + .../join/_hooks/usePostJoinGuest.ts | 11 + .../src/app/tournament/join/_types/join.ts | 28 ++ .../app/tournament/join/_utils/joinSession.ts | 55 +++ .../join/_utils/verifyInviteCode.ts | 10 + apps/web/src/assets/icons/fill/timer.svg | 13 +- .../user-profile-group/UserProfile.tsx | 18 +- apps/web/src/consts/api.ts | 12 + apps/web/src/consts/route.ts | 4 +- apps/web/src/hooks/useGetNicknameCheck.ts | 38 ++ apps/web/src/types/user.ts | 5 + apps/web/src/utils/share.ts | 44 ++ docs/social-tournament-api-integration.md | 408 ++++++++++++++++++ 80 files changed, 3057 insertions(+), 205 deletions(-) create mode 100644 apps/web/src/apis/getNicknameCheck.ts create mode 100644 apps/web/src/app/home/_assets/sad-face.svg create mode 100644 apps/web/src/app/home/_components/InviteTournamentButton.tsx create mode 100644 apps/web/src/app/home/_components/invite-code-dialog/InvalidCodeDialog.tsx create mode 100644 apps/web/src/app/home/_components/invite-code-dialog/InviteCodeDialog.tsx create mode 100644 apps/web/src/app/invite/[id]/_components/InviteClient.tsx create mode 100644 apps/web/src/app/invite/[id]/page.tsx create mode 100644 apps/web/src/app/play/[id]/_components/PlayClient.tsx create mode 100644 apps/web/src/app/play/[id]/page.tsx create mode 100644 apps/web/src/app/play/_apis/postFromPlayLink.ts create mode 100644 apps/web/src/app/play/_types/play.ts create mode 100644 apps/web/src/app/tournament/[id]/create/_assets/confirm-exit-basket.svg create mode 100644 apps/web/src/app/tournament/[id]/create/_assets/confirm-start-face.svg create mode 100644 apps/web/src/app/tournament/[id]/create/_components/deposit-countdown/DepositCountdown.tsx delete mode 100644 apps/web/src/app/tournament/[id]/create/_components/invite-friends/InviteFriends.tsx create mode 100644 apps/web/src/app/tournament/[id]/create/_components/invite-friends/InviteFriendsDialog.tsx create mode 100644 apps/web/src/app/tournament/[id]/create/_components/member-join-confirm-dialog/MemberJoinConfirmDialog.tsx create mode 100644 apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantChip.tsx create mode 100644 apps/web/src/app/tournament/[id]/create/_components/participant-panel/ParticipantPanel.tsx create mode 100644 apps/web/src/app/tournament/[id]/create/_components/tournament-header/ConfirmExitDialog.tsx create mode 100644 apps/web/src/app/tournament/[id]/create/_components/tournament-start-button/ConfirmStartDialog.tsx create mode 100644 apps/web/src/app/tournament/[id]/create/_components/welcome-join-dialog/WelcomeJoinDialog.tsx create mode 100644 apps/web/src/app/tournament/[id]/create/_hooks/useCountdown.ts create mode 100644 apps/web/src/app/tournament/[id]/result/_apis/getGroupResult.ts create mode 100644 apps/web/src/app/tournament/[id]/result/_apis/postPlayLink.ts create mode 100644 apps/web/src/app/tournament/[id]/result/_components/group-result-entry-card/GroupResultEntryCard.tsx create mode 100644 apps/web/src/app/tournament/[id]/result/_components/plate-share-dialog/PlateShareDialog.tsx create mode 100644 apps/web/src/app/tournament/[id]/result/_hooks/useGetGroupResult.ts create mode 100644 apps/web/src/app/tournament/[id]/result/_hooks/usePostPlayLink.ts create mode 100644 apps/web/src/app/tournament/[id]/result/_types/groupResult.ts create mode 100644 apps/web/src/app/tournament/[id]/result/group/_components/GroupResultClient.tsx create mode 100644 apps/web/src/app/tournament/[id]/result/group/page.tsx create mode 100644 apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx create mode 100644 apps/web/src/app/tournament/join/[id]/page.tsx create mode 100644 apps/web/src/app/tournament/join/_apis/getInvitePreview.ts create mode 100644 apps/web/src/app/tournament/join/_apis/getInvitePreviewByCode.ts create mode 100644 apps/web/src/app/tournament/join/_apis/postJoinGuest.ts create mode 100644 apps/web/src/app/tournament/join/_consts/randomNickname.ts create mode 100644 apps/web/src/app/tournament/join/_hooks/useGetInvitePreview.ts create mode 100644 apps/web/src/app/tournament/join/_hooks/usePostJoinGuest.ts create mode 100644 apps/web/src/app/tournament/join/_types/join.ts create mode 100644 apps/web/src/app/tournament/join/_utils/joinSession.ts create mode 100644 apps/web/src/app/tournament/join/_utils/verifyInviteCode.ts create mode 100644 apps/web/src/hooks/useGetNicknameCheck.ts create mode 100644 apps/web/src/utils/share.ts create mode 100644 docs/social-tournament-api-integration.md diff --git a/apps/web/src/apis/client.ts b/apps/web/src/apis/client.ts index 0bb34ce3..2e048e9d 100644 --- a/apps/web/src/apis/client.ts +++ b/apps/web/src/apis/client.ts @@ -62,9 +62,14 @@ clientApi.interceptors.response.use( isRefreshing = true; try { - await axios.post('/api/v1/auth/token/refresh', null, { - withCredentials: true, - }); + // body 없는 POST 라도 빈 객체로 보내야 일부 백엔드가 Content-Type 인식 후 처리한다 (415 방지). + await axios.post( + '/api/v1/auth/token/refresh', + {}, + { + withCredentials: true, + } + ); processQueue(null); return clientApi(originalRequest); } catch (refreshError) { 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/wish-grid/index.tsx b/apps/web/src/app/archive/_components/wish-grid/index.tsx index 62c9fae9..64422724 100644 --- a/apps/web/src/app/archive/_components/wish-grid/index.tsx +++ b/apps/web/src/app/archive/_components/wish-grid/index.tsx @@ -19,8 +19,6 @@ function WishGrid({ items, isDeleteMode = false, selectedIds, onToggleSelect }: if (item.status === 'FAILED') return ; else if (item.status === 'PROCESSING') return ; - const card = ; - if (isDeleteMode) { const isSelected = selectedIds?.has(item.id) ?? false; return ( @@ -31,7 +29,7 @@ function WishGrid({ items, isDeleteMode = false, selectedIds, onToggleSelect }: aria-pressed={isSelected} className="relative text-left transition-opacity active:opacity-80" > - {card} + {isSelected ? ( @@ -43,7 +41,9 @@ function WishGrid({ items, isDeleteMode = false, selectedIds, onToggleSelect }: ); } - return <>{card}; + return ( + + ); })} ); 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..b539b64b --- /dev/null +++ b/apps/web/src/app/home/_components/invite-code-dialog/InviteCodeDialog.tsx @@ -0,0 +1,117 @@ +'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, + onSuccess: data => { + const enteredCode = code; + 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 ccd12bb1..2b7d65c5 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..d6025286 --- /dev/null +++ b/apps/web/src/app/invite/[id]/_components/InviteClient.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { isAxiosError } from 'axios'; +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 (error) { + // 400 (코드 불일치) / 409 (만료) — 잘못된 링크로 통합 + if (isAxiosError(error)) { + setState('invalid'); + return; + } + 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 fcc40ec3..c4bf3661 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -56,7 +56,7 @@ function RootLayout({ )} - + {/** TEMP: max width 임시 값 */}
diff --git a/apps/web/src/app/login/_apis/postGuestLogin.ts b/apps/web/src/app/login/_apis/postGuestLogin.ts index 126ac550..73fc7121 100644 --- a/apps/web/src/app/login/_apis/postGuestLogin.ts +++ b/apps/web/src/app/login/_apis/postGuestLogin.ts @@ -4,9 +4,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>( '/api/v1/auth/guest', - null + {} ); return data.data; diff --git a/apps/web/src/app/login/_components/LoginButtons.tsx b/apps/web/src/app/login/_components/LoginButtons.tsx index b0179732..76999ccc 100644 --- a/apps/web/src/app/login/_components/LoginButtons.tsx +++ b/apps/web/src/app/login/_components/LoginButtons.tsx @@ -76,11 +76,15 @@ function LoginButtons() { 비회원으로 시작하기 -

+

가입 시{' '} - 이용약관 + + 이용약관 + {' 및 '} - 개인정보 처리방침 + + 개인정보 처리방침 + 에 동의하게 됩니다.

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/login/page.tsx b/apps/web/src/app/login/page.tsx index c09400f6..3c6a1aa4 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -7,7 +7,7 @@ function LoginPage() {
-

+

{'매일 쌓여만 가던\n위시리스트가 오늘의 결정으로'}

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..12015877 --- /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); + goToTournament(newTournamentId); + } catch (error) { + if (isUnauthenticated(error)) { + try { + await postGuestLogin(); + const newTournamentId = await postFromPlayLink(sourceTournamentId); + 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]/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/play/_apis/postFromPlayLink.ts b/apps/web/src/app/play/_apis/postFromPlayLink.ts new file mode 100644 index 00000000..5f5ec0d4 --- /dev/null +++ b/apps/web/src/app/play/_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/_types/play.ts b/apps/web/src/app/play/_types/play.ts new file mode 100644 index 00000000..95e99244 --- /dev/null +++ b/apps/web/src/app/play/_types/play.ts @@ -0,0 +1,2 @@ +/** 플레이 링크로 복제 토너먼트 생성 응답 — 생성된 새 tournamentId */ +export type PostFromPlayLinkResponseT = number; 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..aa9279aa 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,62 @@ export type TournamentParticipantT = { profileImage: string; }; -/** PENDING — 토너먼트 아이템 담는 중 */ +/** + * `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: Array & { tournamentItemId: number; itemId: number }>; + 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; status: Extract; + pending?: undefined; inProgress: { currentRound: number; lastHistory: TournamentMatchHistoryT | null; @@ -38,21 +78,39 @@ export type GetTournamentInProgressResponseT = { export type GetTournamentCompletedResponseT = { tournamentId: number; name: string; + /** 요청자가 토너먼트 소유자(주최자)인지 여부 */ + isOwner: boolean; + /** ROOT(원본)이면 true, CLONE 이면 false */ + isRoot: boolean; 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/_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 b09fb898..f6391b85 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx @@ -1,40 +1,97 @@ '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 { + type JoinConfirmPayloadT, + type JoinWelcomePayloadT, + consumeJoinConfirmFor, + consumeJoinWelcomeFor, +} from '../../../join/_utils/joinSession'; +import { useCountdown } from '../_hooks/useCountdown'; import { useGetTournament } from '../_hooks/useGetTournament'; 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: string; }; function TournamentCreateClient({ tournamentId }: TournamentCreateClientProps) { + const numericTournamentId = Number(tournamentId); const { scrollToLast, onScrolled } = useScrollToLast(); - const { tournamentData } = useGetTournament(Number(tournamentId)); + const { tournamentData } = useGetTournament(numericTournamentId); + + // 주최자(ROOT)는 시작/완료 시점에 mutation/페이지에서 직접 라우팅한다. + // 참여자(CLONE 생성 예정)는 ROOT status 변화에 따라 자동 라우팅하지 않고, + // 본인이 "시작" 버튼을 눌러 CLONE 을 만들고 그 ID 로 매치 화면 진입한다. + // → status 기반 자동 라우팅이 필요 없다. + + // 주최자가 ROOT 를 시작한 후(ownerStarted=true) 에는 초대 기간이 이미 종료된 상태라 + // inviteExpiresAt 이 null 이고 담기 마감 카운트다운도 의미 없다. + const ownerStarted = tournamentData.pending?.ownerStarted ?? false; + const depositDeadline = tournamentData.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 = (tournamentData.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(numericTournamentId) + ); + const [confirmPayload, setConfirmPayload] = useState(() => + consumeJoinConfirmFor(numericTournamentId) + ); + const isParticipant = !tournamentData.isOwner; 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} + isDepositClosed={isDepositClosed} />
@@ -42,9 +99,13 @@ function TournamentCreateClient({ tournamentId }: TournamentCreateClientProps) { items={tournamentData.pending?.items} scrollToLast={scrollToLast} onScrolled={onScrolled} + isDepositClosed={isDepositClosed} /> -
+
+ {hasFriends && !ownerStarted && !isDepositClosed && ( + + )} item.status === 'PROCESSING' || item.status === 'FAILED' ) ?? false } + 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..15381387 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,60 @@ +'use client'; + +import { useParams, useRouter } from 'next/navigation'; +import { useState } from 'react'; + +import { ChevronBackwardIconFill } from '@/assets/icons/fill'; + +import { useGetTournament } from '../../_hooks/useGetTournament'; +import ConfirmExitDialog from './ConfirmExitDialog'; + type TournamentHeaderProps = { name: string; }; function TournamentHeader({ name }: TournamentHeaderProps) { + const router = useRouter(); + const { id: tournamentId } = useParams<{ id: string }>(); + const { tournamentData } = useGetTournament(Number(tournamentId)); + const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false); + + const hasFriends = (tournamentData.pending?.participants.length ?? 0) > 1; + + 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/TournamentItemBasket.tsx b/apps/web/src/app/tournament/[id]/create/_components/tournament-item-basket/TournamentItemBasket.tsx index bcc3b75a..8ae8216c 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 @@ -18,10 +18,16 @@ import TournamentItemFailedModal from './TournamentItemFailedDrawer'; type TournamentItemBasketProps = { basketIndex: number; items: TournamentItemT[]; - maxHeight?: number | null; + maxHeight?: number; + isDepositClosed?: boolean; }; -function TournamentItemBasket({ basketIndex, items, maxHeight }: TournamentItemBasketProps) { +function TournamentItemBasket({ + basketIndex, + items, + maxHeight, + isDepositClosed = false, +}: TournamentItemBasketProps) { const { id: _tournamentId } = useParams<{ id: string }>(); const tournamentId = parseIdParam(_tournamentId); const basketMaxWidth = maxHeight ? (maxHeight * 356) / 464 : null; @@ -62,16 +68,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..c33118be 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,12 +1,8 @@ '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 { Carousel, type CarouselApi, CarouselContent, CarouselItem } from '@/components/carousel'; import type { TournamentItemT } from '@/types/tournament'; import { cn } from '@/utils/cn'; @@ -14,7 +10,7 @@ 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'; @@ -22,46 +18,80 @@ type TournamentItemBasketCarouselProps = { items?: TournamentItemT[]; 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 f03d85b3..acd12f53 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,34 +1,104 @@ 'use client'; +import { useEffect, useState } from 'react'; + import Button from '@/components/button'; import Spinner from '@/components/spinner'; +import { useGetTournament } from '../../_hooks/useGetTournament'; import { usePostTournamentStart } from '../../_hooks/usePostTournamentStart'; +import ConfirmStartDialog from './ConfirmStartDialog'; + +const PARTICIPANT_TOOLTIP_DURATION_MS = 3_000; type TournamentStartButtonProps = { count: number; tournamentId: string; hasUnreadyItem: boolean; + isDepositClosed?: boolean; + isParticipant?: boolean; }; function TournamentStartButton({ count, tournamentId, hasUnreadyItem, + isDepositClosed = false, + isParticipant = false, }: TournamentStartButtonProps) { + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const { tournamentData } = useGetTournament(Number(tournamentId)); const { postTournamentStartMutation, isPostTournamentStartPending } = usePostTournamentStart( Number(tournamentId) ); + // 참여자는 주최자가 ROOT 를 시작한 후(ownerStarted=true) 부터 본인 CLONE 시작 가능. + // ownerStarted=false 면 아직 주최자 시작 전 — "주최자가 시작해야..." 툴팁 노출. + const isWaitingForOwnerStart = isParticipant && tournamentData.pending?.ownerStarted === false; + + 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 hasFriends = (tournamentData.pending?.participants.length ?? 0) > 1; + + 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 || + isPostTournamentStartPending || + (!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/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..4064d7a4 100644 --- a/apps/web/src/app/tournament/[id]/create/_types/tournament.ts +++ b/apps/web/src/app/tournament/[id]/create/_types/tournament.ts @@ -18,8 +18,22 @@ export type WishBasketItemT = { export type GetTournamentResponseT = { tournamentId: number; name: string; + /** 요청자가 토너먼트 소유자(주최자)인지 여부 */ + isOwner: boolean; + /** ROOT(원본)이면 true, CLONE(플레이 링크/멤버 시작으로 복제된 인스턴스)이면 false */ + isRoot: boolean; status: TournamentStatusT; pending?: { + /** + * 주최자가 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: TournamentItemT[]; participants: TournamentParticipantT[]; }; @@ -29,7 +43,13 @@ export type PostTournamentItemLinkResponseT = { tournamentItemId: number; }; +/** + * 시작 응답. + * - 주최자(ROOT): 본인 tournamentId 반환 + * - 참여자(CLONE): 새로 생성된 CLONE tournamentId 반환 (이후 본인 ID 로 진행) + */ export type PostTournamentStartResponseT = { + tournamentId: number; items: Omit[]; }; 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]/create/page.tsx b/apps/web/src/app/tournament/[id]/create/page.tsx index 64b4725a..73d7be33 100644 --- a/apps/web/src/app/tournament/[id]/create/page.tsx +++ b/apps/web/src/app/tournament/[id]/create/page.tsx @@ -1,6 +1,5 @@ -import { Suspense } from 'react'; - import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { Suspense } from 'react'; import { getQueryClient } from '@/utils/queryClient'; 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 c225e221..198fa158 100644 --- a/apps/web/src/app/tournament/[id]/match/page.tsx +++ b/apps/web/src/app/tournament/[id]/match/page.tsx @@ -29,15 +29,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, @@ -53,23 +73,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..0749f385 100644 --- a/apps/web/src/app/tournament/[id]/result/_components/ReceiptPaper.tsx +++ b/apps/web/src/app/tournament/[id]/result/_components/ReceiptPaper.tsx @@ -15,6 +15,13 @@ type ReceiptPaperProps = { tournamentName: string; result: RankedProductT[]; date: Date; + /** + * 플레이 링크 공유 가능 여부. + * ROOT 토너먼트의 소유자(isRoot && isOwner)만 노출. + * CLONE 의 소유자(친구 초대로 참여 → CLONE 생성한 사람) 는 노출하지 않는다. + */ + canSharePlayLink: boolean; + onSharePlayLink?: () => void; }; const formatDate = (date: Date) => { @@ -43,7 +50,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 +139,16 @@ const ReceiptPaper = forwardRef(function Rece
- {/* 공유 액션 */} + {/* 공유 액션 — 플레이 링크 공유는 주최자만 노출 */}
} label="이미지 공유" /> - } label="플레이 링크 공유" /> + {canSharePlayLink && ( + } + label="플레이 링크 공유" + onClick={onSharePlayLink} + /> + )}
{/* 영수증 하단 톱니 */} @@ -200,11 +213,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/group/_components/GroupResultClient.tsx b/apps/web/src/app/tournament/[id]/result/group/_components/GroupResultClient.tsx new file mode 100644 index 00000000..32148485 --- /dev/null +++ b/apps/web/src/app/tournament/[id]/result/group/_components/GroupResultClient.tsx @@ -0,0 +1,281 @@ +'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'; + +const kodeMono = Kode_Mono({ subsets: ['latin'], weight: ['400', '500', '600', '700'] }); + +type GroupResultClientProps = { + tournamentId: number; +}; + +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 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 6ffbf7c8..2dfd979d 100644 --- a/apps/web/src/app/tournament/[id]/result/page.tsx +++ b/apps/web/src/app/tournament/[id]/result/page.tsx @@ -22,17 +22,12 @@ async function TournamentResultPage({ params }: TournamentResultPageProps) { } 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..c9a886d4 --- /dev/null +++ b/apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { notFound, 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 | null; + /** 친구 초대 코드 — 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); + + if (tournamentId === null) notFound(); + + 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/user-profile-group/UserProfile.tsx b/apps/web/src/components/user-profile-group/UserProfile.tsx index cb471405..51f1fd2a 100644 --- a/apps/web/src/components/user-profile-group/UserProfile.tsx +++ b/apps/web/src/components/user-profile-group/UserProfile.tsx @@ -14,16 +14,20 @@ function UserProfile({ user, className }: UserProfileProps) { if (user.imageUrl) { return ( - + > + + ); } diff --git a/apps/web/src/consts/api.ts b/apps/web/src/consts/api.ts index 1304fc66..76244a9f 100644 --- a/apps/web/src/consts/api.ts +++ b/apps/web/src/consts/api.ts @@ -11,6 +11,10 @@ export const ENDPOINTS = { WISHLIST: (id: number) => `/api/v1/wishlists/${id}`, WISH_OCR: '/api/v1/wishlists/images', + /** 유저 */ + USERS_ME: '/api/v1/users/me', + USERS_NICKNAME_CHECK: '/api/v1/users/nickname/check', + /** 토너먼트 */ TOURNAMENTS: '/api/v1/tournaments', TOURNAMENT: (id: number) => `/api/v1/tournaments/${id}`, @@ -20,4 +24,12 @@ export const ENDPOINTS = { TOURNAMENT_OCR: (id: number) => `/api/v1/tournaments/${id}/items/images`, TOURNAMENT_ITEM: (id: number, ItemId: number) => `/api/v1/tournaments/${id}/items/${ItemId}`, TOURNAMENT_ITEMS_FROM_WISH: (id: number) => `/api/v1/tournaments/${id}/items/wish`, + TOURNAMENT_PLAY_LINK: (id: number) => `/api/v1/tournaments/${id}/play-link`, + TOURNAMENT_JOIN: (id: number) => `/api/v1/tournaments/${id}/join`, + TOURNAMENT_JOIN_GUEST: (id: number) => `/api/v1/tournaments/${id}/join/guest`, + TOURNAMENT_INVITE_PREVIEW: (id: number) => `/api/v1/tournaments/${id}/invite-preview`, + TOURNAMENT_INVITE_PREVIEW_BY_CODE: '/api/v1/tournaments/by-invite-code', + TOURNAMENT_FROM_PLAY_LINK: (sourceTournamentId: number) => + `/api/v1/tournaments/${sourceTournamentId}/from-play-link`, + TOURNAMENT_GROUP_RESULT: (id: number) => `/api/v1/tournaments/${id}/group-result`, } as const; diff --git a/apps/web/src/consts/route.ts b/apps/web/src/consts/route.ts index 6167dec4..b0e13464 100644 --- a/apps/web/src/consts/route.ts +++ b/apps/web/src/consts/route.ts @@ -5,7 +5,6 @@ export const ROUTES = { ROOT: '/', HOME: '/home', LOGIN: '/login', - TOURNAMENT_JOIN_BY_CODE: '/tournament/join', TOURNAMENT_JOIN_BY_LINK: (id: number) => `/tournament/join/${id}`, /** 2. Member Only */ @@ -22,4 +21,7 @@ export const ROUTES = { TOURNAMENT_LOADING: (tournamentId: number) => `/tournament/${tournamentId}/loading`, TOURNAMENT_MATCH: (tournamentId: number) => `/tournament/${tournamentId}/match`, TOURNAMENT_RESULT: (tournamentId: number) => `/tournament/${tournamentId}/result`, + TOURNAMENT_GROUP_RESULT: (tournamentId: number) => `/tournament/${tournamentId}/result/group`, + /** 플레이 링크 진입 — 친구가 받은 URL 클릭 시 */ + PLAY_FROM_LINK: (sourceTournamentId: number) => `/play/${sourceTournamentId}`, } as const; diff --git a/apps/web/src/hooks/useGetNicknameCheck.ts b/apps/web/src/hooks/useGetNicknameCheck.ts new file mode 100644 index 00000000..6185c6ac --- /dev/null +++ b/apps/web/src/hooks/useGetNicknameCheck.ts @@ -0,0 +1,38 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; + +import { getNicknameCheck } from '@/apis/getNicknameCheck'; + +const NICKNAME_MAX_LENGTH = 10; +const DEBOUNCE_MS = 300; + +/** + * 닉네임 중복 체크 훅. + * - 입력값을 debounce 후 조회 (불필요한 호출 최소화) + * - 빈 값 / 길이 초과 / debounce 진행 중에는 조회 비활성 + */ +export const useGetNicknameCheck = (nickname: string) => { + const [debouncedNickname, setDebouncedNickname] = useState(nickname); + + useEffect(() => { + const timeoutId = window.setTimeout(() => setDebouncedNickname(nickname), DEBOUNCE_MS); + return () => window.clearTimeout(timeoutId); + }, [nickname]); + + const trimmed = debouncedNickname.trim(); + const isValidLength = trimmed.length > 0 && trimmed.length <= NICKNAME_MAX_LENGTH; + const isPendingDebounce = nickname.trim() !== trimmed; + + const { data: nicknameCheckData, isFetching: isNicknameCheckFetching } = useQuery({ + queryKey: ['nicknameCheck', trimmed], + queryFn: () => getNicknameCheck(trimmed), + enabled: isValidLength, + placeholderData: keepPreviousData, + staleTime: 30_000, + }); + + return { + nicknameCheckData, + isNicknameCheckFetching: isNicknameCheckFetching || isPendingDebounce, + }; +}; diff --git a/apps/web/src/types/user.ts b/apps/web/src/types/user.ts index b0101875..7860eb0b 100644 --- a/apps/web/src/types/user.ts +++ b/apps/web/src/types/user.ts @@ -12,3 +12,8 @@ export type UserT = { /** 유저 식별 타입 */ identityType: UserIdentityTypeT; }; + +/** 닉네임 중복 체크 응답 */ +export type GetNicknameCheckResponseT = { + available: boolean; +}; diff --git a/apps/web/src/utils/share.ts b/apps/web/src/utils/share.ts new file mode 100644 index 00000000..0a0b7aa0 --- /dev/null +++ b/apps/web/src/utils/share.ts @@ -0,0 +1,44 @@ +type ShareDataT = { + title?: string; + text?: string; + url?: string; +}; + +const canUseWebShare = (data: ShareDataT) => { + if (typeof navigator === 'undefined') return false; + if (typeof navigator.share !== 'function') return false; + if (typeof navigator.canShare === 'function' && !navigator.canShare(data)) return false; + return true; +}; + +const copyToClipboard = async (text: string) => { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + throw new Error('Clipboard API unavailable'); +}; + +export type ShareResultT = 'shared' | 'copied' | 'cancelled' | 'failed'; + +export const share = async (data: ShareDataT): Promise => { + if (canUseWebShare(data)) { + try { + await navigator.share(data); + return 'shared'; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') return 'cancelled'; + // Web Share 실패 시 clipboard로 fallback + } + } + + const fallbackText = data.url ?? data.text ?? ''; + if (!fallbackText) return 'failed'; + + try { + await copyToClipboard(fallbackText); + return 'copied'; + } catch { + return 'failed'; + } +}; diff --git a/docs/social-tournament-api-integration.md b/docs/social-tournament-api-integration.md new file mode 100644 index 00000000..4323d8a4 --- /dev/null +++ b/docs/social-tournament-api-integration.md @@ -0,0 +1,408 @@ +# 소셜 토너먼트 API 연동 계획 (#157) + +> #139 / #140 UI 작업 PR 이 dev 머지된 이후, mock 으로 강제 분기해둔 초대·참여·공유 플로우를 실제 API 로 교체한다. +> 이 문서는 **api docs(`/v3/api-docs`) 대비 미적용 endpoint** 와 **각 endpoint 구체 명세 + 클라이언트 적용 위치**를 정리한다. + +브랜치: `feat/157-social-tournament-api-integration` +선행 PR: #139 (친구 초대/공유 UI), #140 (초대 코드 입력/참여자 입장 UI) + +--- + +## 0. 공통 응답 래퍼 + +모든 응답은 `ApiResponseBody` 로 래핑됨. + +```json +{ + "data": { + /* 실제 응답 */ + }, + "detail": "정상적으로 처리되었습니다.", + "pageResponse": { "nextCursor": null, "hasNext": false } +} +``` + +- 클라이언트는 axios 인터셉터에서 `.data.data` 를 풀어 사용 +- 실패 시 `data: null`, `detail`/`code` 에 사유 + +--- + +## 1. 미적용 endpoint 전체 리스트 + +### Tournament — 소셜 / 공유 / 그룹 결과 + +| 메서드 | endpoint | 인증 | 비고 | +| ------ | -------------------------------------------------------- | ------------------ | --------------------------------- | +| GET | `/api/v1/tournaments/{id}/invite-preview?inviteCode=XXX` | 불필요 | 코드 검증 + 미리보기 | +| POST | `/api/v1/tournaments/{id}/join` | JWT (GUEST/MEMBER) | 회원 참여 | +| POST | `/api/v1/tournaments/{id}/join/guest` | 불필요 | 비회원 게스트 계정 생성 + 참여 | +| POST | `/api/v1/tournaments/{id}/play-link` | JWT (소유자) | 플레이 링크 생성 (만료 14일 고정) | +| GET | `/api/v1/tournaments/{id}/play-link-info` | 불필요 | 플레이 링크 정보 | +| POST | `/api/v1/tournaments/{sourceId}/from-play-link` | JWT | 플레이 링크로 복제 토너먼트 생성 | +| GET | `/api/v1/tournaments/{id}/group-result` | JWT | 그룹 결과 조회 | + +### User + +| 메서드 | endpoint | 인증 | 비고 | +| ------ | ------------------------------------------- | ---------------- | -------------------------------------------- | +| GET | `/api/v1/users/me` | JWT | 코드에서 직접 호출 중. `ENDPOINTS` 상수 누락 | +| PATCH | `/api/v1/users/me` | JWT (GUEST 포함) | 닉네임 수정 | +| GET | `/api/v1/users/nickname/check?nickname=XXX` | JWT | 닉네임 중복 체크 (최대 10자) | + +### Notification + +| 메서드 | endpoint | 인증 | 비고 | +| ------ | --------------------------------- | ---- | ---------------------- | +| GET | `/api/v1/notifications/subscribe` | JWT | SSE 실시간 알림 스트림 | + +--- + +## 2. 각 endpoint 상세 + 클라이언트 적용 + +### 2.1 GET `/tournaments/{id}/invite-preview` + +#### 명세 + +- query: `inviteCode?: string` — 코드 입력 경로에서만 전달, 직접 링크 진입은 생략 +- 응답 + ```json + { + "tournamentId": 1, + "tournamentName": "내 토너먼트", + "itemCount": 8, + "participantCount": 2 + } + ``` +- 실패: 만료된 초대 → 400, 코드 불일치 → 400 + +#### 적용 위치 + +- `apps/web/src/app/home/_components/invite-code-dialog/InviteCodeDialog.tsx` +- 현재: `verifyInviteCode(code)` 로컬 mock +- 변경: 코드만으로는 토너먼트를 못 찾으므로 **초대 링크에 tournamentId 가 함께 들어와야 함** (예: `piki.today/invite/{tournamentId}?code=ABC123`) + - 디자인상 코드 6자만 입력하는 UX 라 백엔드/디자인과 한 번 합의 필요 + - 임시 대안: 본인 토너먼트 리스트에서 `inviteCode` 매칭으로 우회 + +#### 신규 작업 + +- `apps/web/src/app/tournament/join/_apis/getInvitePreview.ts` +- `useGetInvitePreview` 훅 +- 응답 타입 `GetInvitePreviewResponseT` → `src/types/tournament.ts` 또는 `_types/tournament.ts` +- 현재 `mocks/tournamentPreview.ts` 삭제 + +--- + +### 2.2 POST `/tournaments/{id}/join` + +#### 명세 + +- body + ```ts + type JoinTournamentRequest = { + inviteCode?: string | null; // pattern: [A-Z]{3}\d{3} + }; + ``` +- 응답 데이터: `null` +- JWT 필요 (GUEST/MEMBER 모두 허용) +- 실패: 만료/이미 참여 → 409, 코드 불일치 → 400 + +#### 적용 위치 + +- `apps/web/src/app/tournament/join/_components/InviteClient.tsx` +- 현재: `getMe` → identityType 분기 후 `getTournamentList` 첫 ID 로 라우팅 (mock) +- 변경: identityType 분기 후 `join` 호출 → 응답은 `null` 이라 후속 라우팅은 `/tournament/{tournamentId}/create` 로 (path 의 tournamentId 그대로 사용) + +#### 신규 작업 + +- `apps/web/src/app/tournament/join/_apis/postJoin.ts` +- `usePostJoin` (mutation) + +--- + +### 2.3 POST `/tournaments/{id}/join/guest` + +#### 명세 + +- body + ```ts + type JoinTournamentAsGuestRequest = { + inviteCode?: string | null; + nickname: string; // maxLength: 10, required + }; + ``` +- 응답 + ```json + { + "accessToken": "eyJ...", + "refreshToken": "eyJ...", + "userId": "uuid", + "nickname": "멋진친구", + "profileImage": "https://...", + "tournamentId": 1 + } + ``` +- 인증 불필요, 응답으로 토큰 쌍 발급 + +#### 적용 위치 + +- `apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx` +- 현재: `getTournamentList` 첫 ID 로 라우팅 (mock) + `setJoinWelcome` sessionStorage +- 변경: `/join/guest` 호출 → 토큰 저장(쿠키 기반이면 자동, 아니면 axios client 갱신) → `/tournament/{tournamentId}/create` + +#### 신규 작업 + +- `apps/web/src/app/tournament/join/_apis/postJoinGuest.ts` +- `usePostJoinGuest` (mutation) +- 토큰 저장 처리 — 현재 `auth/guest` 응답이 HttpOnly 쿠키로 내려옴. `/join/guest` 도 동일하게 쿠키 기반이면 별도 클라 작업 없음. 응답 body 의 `accessToken` 사용이 fallback 인지 확인 필요. + +--- + +### 2.4 GET `/users/nickname/check` + +#### 명세 + +- query: `nickname: string` (required, maxLength 10) +- 응답: `{ available: boolean }` +- 본인의 현재 닉네임은 자동 통과 (서버에서 처리) + +#### 적용 위치 + +- `apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx` +- 닉네임 input `onBlur` 또는 디바운스 후 호출 → 결과에 따라 헬퍼/CTA 비활성 + +#### 신규 작업 + +- `apps/web/src/apis/getNicknameCheck.ts` +- `useGetNicknameCheck(debouncedNickname)` 훅 (옵션: `enabled` 로 debounce 결과 활용) + +--- + +### 2.5 PATCH `/users/me` + +#### 명세 + +- body: `{ nickname: string }` (현재는 nickname 만) +- 응답: 수정된 `UserT` +- GUEST 도 호출 가능 + +#### 적용 위치 + +- **분담 정리 필요** + - `join/guest` 가 닉네임을 받음 → 게스트 계정 신규 생성 시점에 닉네임 확정 + - `PATCH /users/me` → 이미 게스트/회원 인 사용자의 닉네임 변경 (마이페이지 등) +- 현재 PR 범위에서는 `JoinPreviewClient` 의 닉네임 수정은 `join/guest` body 로 전달하면 됨 +- `PATCH /users/me` 는 별도 마이페이지 PR 로 분리해도 OK + +#### 신규 작업 (별도 PR 가능) + +- `apps/web/src/apis/patchMe.ts` +- `usePatchMe` (mutation) +- 성공 시 `['me']` 쿼리 invalidate + +--- + +### 2.6 POST `/tournaments/{id}/play-link` + +#### 명세 + +- body 없음 +- 응답: `string` (ISO datetime, `playLinkExpiresAt`) +- 소유자(`isOwner`) 만 호출, 만료 14일 고정, 이미 있으면 갱신 +- 실패: COMPLETED 아님 → 409 + +#### 적용 위치 + +- `apps/web/src/app/tournament/[id]/result/_components/plate-share-dialog/PlateShareDialog.tsx` +- 현재: `plateUrl` 더미 (`https://piki.today/tournament/{id}/plate`) +- 변경: 다이얼로그 오픈 시 또는 `플레이 링크 보내기` 클릭 시 호출 → URL 생성 → share API 전달 + - URL 형식: 디자인/백엔드 합의 필요 (예: `piki.today/play/{tournamentId}` — sourceTournamentId 기반) + +#### 신규 작업 + +- `apps/web/src/app/tournament/[id]/result/_apis/postPlayLink.ts` +- `usePostPlayLink(tournamentId)` (mutation) + +--- + +### 2.7 GET `/tournaments/{id}/play-link-info` + +#### 명세 + +- 인증 불필요 +- 응답 + ```json + { + "sourceTournamentId": 1, + "tournamentName": "내 토너먼트", + "itemCount": 8, + "playLinkExpiresAt": "2026-06-09T22:00:00" + } + ``` +- 실패: 만료/없음 → 404 + +#### 적용 위치 + +- 친구가 플레이 링크로 진입한 화면에서 사용 +- 현재 PR 범위 외 (별도 진입점 화면 필요) + +#### 신규 작업 (후속) + +- 라우트 추가: `apps/web/src/app/play/[id]/page.tsx` 또는 `/tournament/play/[id]` (백엔드 URL 컨벤션과 합의) +- `getPlayLinkInfo.ts` + `useGetPlayLinkInfo` 훅 +- 진입 화면: 토너먼트 정보 표시 + `토너먼트 시작하기` → `/tournaments/{sourceId}/from-play-link` 호출 + +--- + +### 2.8 POST `/tournaments/{sourceTournamentId}/from-play-link` + +#### 명세 + +- body 없음 +- 응답: `number` (새로 생성된 tournamentId) +- 응답 토너먼트는 PENDING 상태로 아이템 복사 완료, 바로 시작 가능 + +#### 적용 위치 + +- 2.7 의 후속 — 친구가 `토너먼트 시작하기` 클릭 시 +- 응답 tournamentId 로 `/tournament/{newId}/create` 라우팅 (아이템 이미 차있으므로 곧바로 진행 가능) + +#### 신규 작업 (후속) + +- `apps/web/src/app/play/[id]/_apis/postFromPlayLink.ts` +- `usePostFromPlayLink(sourceTournamentId)` (mutation) + +--- + +### 2.9 GET `/tournaments/{id}/group-result` + +#### 명세 + +- JWT 필요 +- 응답 + ```json + { + "items": [ + { + "rank": 1, + "itemId": 10, + "name": "...", + "price": 129000, + "currency": "KRW", + "imageUrl": "https://...", + "chosenBy": [{ "userId": "...", "nickname": "...", "profileImage": "..." }] + } + ] + } + ``` +- 원본 + 플레이 링크 복제본 모두 합쳐서 비교 + +#### 적용 위치 + +- `apps/web/src/app/tournament/[id]/result/_components/ResultClient.tsx` +- 조건: `tournamentData.completed.hasGroupResult === true` (참여자 2명 이상) +- 진입점: 결과 화면 상단/하단에 `친구 토너먼트 결과 보기` 버튼 노출 (디자인 합의 필요) + +#### 신규 작업 + +- `apps/web/src/app/tournament/[id]/result/_apis/getGroupResult.ts` +- `useGetGroupResult(tournamentId)` 훅 (조건부 enabled) +- 그룹 결과 화면 컴포넌트 — 디자인 시안 필요 + +--- + +### 2.10 GET `/notifications/subscribe` (SSE) + +#### 명세 + +- `text/event-stream` 으로 응답 (ApiResponseBody 래퍼 없음) +- 이벤트 + - `connect`: 구독 직후 1회 (`data="connected"`) + - `notification`: `NotificationSsePayload` JSON + ```ts + type NotificationSsePayload = { + id: number; + type: + | 'TOURNAMENT_JOINED' + | 'TOURNAMENT_ITEM_ADDED' + | 'ITEM_PARSING_COMPLETED' + | 'ITEM_PARSING_FAILED'; + title: string; + body: string; + refId: number; // 딥링크 분기용 + }; + ``` + - 15초 간격 하트비트 +- 30분 후 타임아웃 → 클라 재연결 필요 + +#### 적용 위치 (검토) + +- `apps/web/src/components/Providers.tsx` 또는 전역 layout +- 인증 유저 확인 후 EventSource 연결 → 이벤트별로 `queryClient.invalidateQueries(['tournament', refId])` 등 처리 +- 30초 polling 과 중복 가능 → polling 정책 함께 합의 + +#### 신규 작업 (후속, 별도 PR 권장) + +- `apps/web/src/hooks/useNotificationStream.ts` +- 재연결 로직 (지수 백오프), 탭 visibility 처리 +- `queryClient` 기반 캐시 무효화 + +--- + +## 3. mock / 임시 코드 정리 체크리스트 + +API 연동 완료 시 일괄 제거 또는 정리: + +- [ ] `apps/web/src/mocks/participants.ts` +- [ ] `apps/web/src/mocks/tournamentPreview.ts` +- [ ] `apps/web/src/mocks/deposit.ts` (`MOCK_DEPOSIT_DURATION_MS = 60_000`) +- [ ] `apps/web/src/app/tournament/[id]/create/_apis/getTournament.ts` 의 `withMockParticipants` 헬퍼 +- [ ] `apps/web/src/app/tournament/join/_utils/verifyInviteCode.ts` (정답 `111111` 하드코딩, 형식 패턴 등 전체) +- [ ] `apps/web/src/app/tournament/join/_utils/joinSession.ts` 의 `markAsParticipant` / `isParticipantOf` / `PARTICIPANT_KEY` (서버 `isOwner` 로 대체) +- [ ] `consumeJoinWelcomeFor` / `consumeJoinConfirmFor` (참여 직후 다이얼로그 노출용) — 유지 여부 결정 +- [ ] 친구 초대 URL 더미 `https://piki.today/invite/temp` (`MOCK_INVITE_URL` 위치) +- [ ] 플레이 링크 URL 더미 `https://piki.today/tournament/{id}/plate` + +--- + +## 4. UI 누락 / 시안 대비 미구현 (#139 / #140 작업 잔여) + +- [ ] **친구 초대 다이얼로그 만료 시간 동적 표시** — `tournamentData.pending.inviteExpiresAt` +- [ ] **친구 초대 '변경' 버튼** — 만료 시간 변경 다이얼로그 (정책: 30분 기본 / 최대 24시간, `inviteDurationMinutes` 파라미터) +- [ ] **친구 초대 인원 정책 점검** — 안내 텍스트 `최대 7명까지 초대할 수 있어요` ↔ 정책(최대 8명) 일치 여부 확인 +- [ ] **결과 공유 다이얼로그 만료 시간 표시** — `playLinkExpiresAt`. 서버 정책상 14일 고정이라 '변경' 버튼 디자인 재확인 필요 +- [ ] **이미지 공유** — 영수증 하단 `이미지 공유` placeholder (Web Share API file 또는 html2canvas) +- [ ] **빈 슬롯 SVG (담기 종료 후)** — `EmptyBasketSlot.tsx` 디자이너 SVG 적용 + +--- + +## 5. 의존성 / 합의 필요 사항 + +- **초대 링크 URL 구조** — 코드만으로는 토너먼트 못 찾으므로 `piki.today/invite/{tournamentId}?code=XXX` 형태 합의 필요. UX 상 코드 입력 다이얼로그가 별도로 있으니, **링크 진입** vs **수동 코드 입력** 두 경로의 URL 정책 정리 필요. +- **플레이 링크 URL 구조** — `piki.today/play/{sourceTournamentId}` 가 자연스러움. 디자인 + 백엔드 합의. +- **닉네임 발급 책임** — `join/guest` 는 닉네임을 받는데, 게스트 발급은 `auth/guest` 가 따로 함. 두 흐름 분담: + - 비회원이 코드 입력 → 닉네임 페이지 → `join/guest` (계정 + 참여 동시) + - 일반 회원 가입 → `auth/guest` (자동 닉네임) → 이후 `PATCH /users/me` 로 수정 +- **SSE vs polling** — 현재 30초 polling 적용 중. SSE 도입 시 polling 제거 가능. 정책 합의. +- **그룹 결과 화면 디자인** — Figma 에 시안 있는지 확인 필요. + +--- + +## 6. 작업 순서 제안 + +기능 단위로 묶고, 각 PR 안에서 필요한 endpoint 상수·타입은 그 PR 이 함께 추가한다. +(한 번에 모아서 정리하는 사전 작업은 두지 않음 — PR 작게 유지) + +1. **초대 코드 검증 + 참여 흐름** — `invite-preview` + `join` + `join/guest` + - `InviteCodeDialog`, `InviteClient`, `JoinPreviewClient` 의 mock 일괄 교체 + - 토큰 처리 (`join/guest` 응답) + - 관련 mock (`tournamentPreview.ts`, `verifyInviteCode.ts` 정답 코드) 제거 +2. **닉네임 중복 체크** — `nickname/check` + - `JoinPreviewClient` input 인라인 검증 +3. **`isOwner` 기반 주최자 분기 교체** + - `joinSession.ts` 의 `isParticipantOf` / `markAsParticipant` / `PARTICIPANT_KEY` 제거 + - `TournamentCreateClient` 에서 `tournamentData.isOwner` 사용 +4. **플레이 링크 생성/공유** — `play-link` + - `PlateShareDialog` 연동, 더미 URL 제거 +5. **(별도 PR)** `play-link-info` + `from-play-link` — 친구 진입 라우트 신규 +6. **(별도 PR)** `group-result` — 그룹 결과 화면 신규 (디자인 시안 필요) +7. **(별도 PR)** SSE 도입 검토 — 30초 polling 대체 여부 From dd3fe59e4a9c8f7a325516d75c23634391498738 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Wed, 10 Jun 2026 02:09:28 +0900 Subject: [PATCH 02/13] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=A4=91=20=EC=9E=85=EB=A0=A5=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20=EC=8B=9C=20race=20condition=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/_components/invite-code-dialog/InviteCodeDialog.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index b539b64b..5c61a4b2 100644 --- a/apps/web/src/app/home/_components/invite-code-dialog/InviteCodeDialog.tsx +++ b/apps/web/src/app/home/_components/invite-code-dialog/InviteCodeDialog.tsx @@ -30,8 +30,9 @@ function InviteCodeDialog({ open, onOpenChange }: InviteCodeDialogProps) { const { mutate: previewMutation, isPending: isPreviewPending } = useMutation({ mutationFn: getInvitePreviewByCode, - onSuccess: data => { - const enteredCode = code; + // mutation 진행 중 사용자가 input 을 바꿀 수 있으므로 + // 검증에 사용한 변수 (variables) 를 그대로 라우팅에 쓴다 — state 의 code 를 다시 읽지 않는다. + onSuccess: (data, enteredCode) => { onOpenChange(false); reset(); router.push(`/tournament/join/${data.tournamentId}?code=${enteredCode}`); From 70d173fd5b3e7f7a7d5dbb84e27144b8d97e04ef Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Wed, 10 Jun 2026 02:10:51 +0900 Subject: [PATCH 03/13] =?UTF-8?q?fix:=20PlayClient=20=EC=9D=98=20goToTourn?= =?UTF-8?q?ament=20=ED=98=B8=EC=B6=9C=EC=97=90=20await=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EB=AC=B4=ED=95=9C=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/play/[id]/_components/PlayClient.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/play/[id]/_components/PlayClient.tsx b/apps/web/src/app/play/[id]/_components/PlayClient.tsx index 12015877..ef63effe 100644 --- a/apps/web/src/app/play/[id]/_components/PlayClient.tsx +++ b/apps/web/src/app/play/[id]/_components/PlayClient.tsx @@ -60,13 +60,13 @@ function PlayClient({ sourceTournamentId }: PlayClientProps) { const run = async () => { try { const newTournamentId = await postFromPlayLink(sourceTournamentId); - goToTournament(newTournamentId); + await goToTournament(newTournamentId); } catch (error) { if (isUnauthenticated(error)) { try { await postGuestLogin(); const newTournamentId = await postFromPlayLink(sourceTournamentId); - goToTournament(newTournamentId); + await goToTournament(newTournamentId); return; } catch { setState('expired'); From ffa43e3fed7e7e7285984c350a9617e25bf97f74 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Wed, 10 Jun 2026 09:37:04 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=8F=84=20=EC=B9=9C=EA=B5=AC=20=ED=86=A0=EB=84=88=EB=A8=BC?= =?UTF-8?q?=ED=8A=B8=20=EA=B2=B0=EA=B3=BC=EB=B3=B4=EA=B8=B0=20(sourceTourn?= =?UTF-8?q?amentId=20=ED=99=9C=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[id]/_common/_types/tournamentResponse.ts | 4 ++++ .../[id]/result/_components/ResultClient.tsx | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) 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 aa9279aa..5d08b884 100644 --- a/apps/web/src/app/tournament/[id]/_common/_types/tournamentResponse.ts +++ b/apps/web/src/app/tournament/[id]/_common/_types/tournamentResponse.ts @@ -65,6 +65,8 @@ export type GetTournamentInProgressResponseT = { isOwner: boolean; /** ROOT(원본)이면 true, CLONE 이면 false */ isRoot: boolean; + /** CLONE 일 때만 존재 — 원본(ROOT) 토너먼트 id. group-result 호출 등에서 사용. */ + sourceTournamentId?: number; status: Extract; pending?: undefined; inProgress: { @@ -82,6 +84,8 @@ export type GetTournamentCompletedResponseT = { isOwner: boolean; /** ROOT(원본)이면 true, CLONE 이면 false */ isRoot: boolean; + /** CLONE 일 때만 존재 — 원본(ROOT) 토너먼트 id. group-result 호출 등에서 사용. */ + sourceTournamentId?: number; status: Extract; completed: { result: TournamentRankingT[]; diff --git a/apps/web/src/app/tournament/[id]/result/_components/ResultClient.tsx b/apps/web/src/app/tournament/[id]/result/_components/ResultClient.tsx index a5a29d4c..f330c231 100644 --- a/apps/web/src/app/tournament/[id]/result/_components/ResultClient.tsx +++ b/apps/web/src/app/tournament/[id]/result/_components/ResultClient.tsx @@ -64,11 +64,19 @@ function ResultClient({ tournamentId }: ResultClientProps) { /> {/* - ROOT 토너먼트(친구 초대로 진행)에서는 무조건 진입 카드 노출. - - 친구 유무는 클릭 시 group-result API 응답으로 판단 (캐시 의존 X) - - 플레이 링크 게스트(CLONE) 는 노출 안 함 — 백엔드가 403 으로 막는다 + 친구 토너먼트 결과보기 카드 노출 + 라우팅. + - 그룹 결과 조회는 ROOT 토너먼트 id 로만 가능 (백엔드 정책) + - ROOT 사용자(주최자 / 친구 초대 멤버): 본인 id 가 ROOT 라 그대로 사용 + - CLONE 사용자(플레이 링크 게스트): 응답의 sourceTournamentId 로 ROOT 지정 + - 친구 유무는 클릭 시 group-result API 응답으로 판단한다 (캐시 의존 X) */} - {tournamentData.isRoot && } +
    {/* 하단 버튼 — 시안상 단일 CTA */} From 2f1a307942c99d1fff213baf4775ee4e6350f9fe Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sat, 13 Jun 2026 02:23:49 +0900 Subject: [PATCH 05/13] =?UTF-8?q?refactor:=20InviteClient=20=EC=9D=98=20?= =?UTF-8?q?=EC=9D=98=EB=AF=B8=20=EC=97=86=EB=8A=94=20Axios=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=B6=84=EA=B8=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/src/app/invite/[id]/_components/InviteClient.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/web/src/app/invite/[id]/_components/InviteClient.tsx b/apps/web/src/app/invite/[id]/_components/InviteClient.tsx index d6025286..3e14aa55 100644 --- a/apps/web/src/app/invite/[id]/_components/InviteClient.tsx +++ b/apps/web/src/app/invite/[id]/_components/InviteClient.tsx @@ -1,6 +1,5 @@ 'use client'; -import { isAxiosError } from 'axios'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; @@ -41,12 +40,8 @@ function InviteClient({ tournamentId, inviteCode }: InviteClientProps) { return; } router.replace(`${ROUTES.TOURNAMENT_JOIN_BY_LINK(tournamentId)}?code=${inviteCode}`); - } catch (error) { - // 400 (코드 불일치) / 409 (만료) — 잘못된 링크로 통합 - if (isAxiosError(error)) { - setState('invalid'); - return; - } + } catch { + // 400 (코드 불일치) / 409 (만료) / 네트워크 등 — 모두 "잘못된 링크" 안내로 통합한다. setState('invalid'); } }; From 1f337df31a277e50bbd0f479d664ea2c68e5379f Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sat, 13 Jun 2026 02:28:33 +0900 Subject: [PATCH 06/13] =?UTF-8?q?refactor:=20play=20=5Fapis/=5Ftypes=20?= =?UTF-8?q?=EB=A5=BC=20[id]=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/play/{ => [id]}/_apis/postFromPlayLink.ts | 0 apps/web/src/app/play/[id]/_components/PlayClient.tsx | 2 +- apps/web/src/app/play/{ => [id]}/_types/play.ts | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename apps/web/src/app/play/{ => [id]}/_apis/postFromPlayLink.ts (100%) rename apps/web/src/app/play/{ => [id]}/_types/play.ts (100%) diff --git a/apps/web/src/app/play/_apis/postFromPlayLink.ts b/apps/web/src/app/play/[id]/_apis/postFromPlayLink.ts similarity index 100% rename from apps/web/src/app/play/_apis/postFromPlayLink.ts rename to apps/web/src/app/play/[id]/_apis/postFromPlayLink.ts diff --git a/apps/web/src/app/play/[id]/_components/PlayClient.tsx b/apps/web/src/app/play/[id]/_components/PlayClient.tsx index ef63effe..c13b0ecd 100644 --- a/apps/web/src/app/play/[id]/_components/PlayClient.tsx +++ b/apps/web/src/app/play/[id]/_components/PlayClient.tsx @@ -11,7 +11,7 @@ import Button from '@/components/button'; import Spinner from '@/components/spinner'; import { ROUTES } from '@/consts/route'; -import { postFromPlayLink } from '../../_apis/postFromPlayLink'; +import { postFromPlayLink } from '../_apis/postFromPlayLink'; type PlayClientProps = { sourceTournamentId: number; diff --git a/apps/web/src/app/play/_types/play.ts b/apps/web/src/app/play/[id]/_types/play.ts similarity index 100% rename from apps/web/src/app/play/_types/play.ts rename to apps/web/src/app/play/[id]/_types/play.ts From 3c6a7b74f49e1797b4bdfe30f708b9ba783e9113 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sat, 13 Jun 2026 15:28:57 +0900 Subject: [PATCH 07/13] =?UTF-8?q?refactor:=20=ED=86=A0=EB=84=88=EB=A8=BC?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=91=EB=8B=B5=20=ED=83=80=EC=9E=85/=ED=9B=85?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=5Fcom?= =?UTF-8?q?mon=20=EC=9C=BC=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[id]/_common/_hooks/useGetTournament.ts | 3 ++ .../[id]/_common/_types/tournamentResponse.ts | 10 +++- .../[id]/create/_apis/getTournament.ts | 26 ---------- .../[id]/create/_apis/postTournamentStart.ts | 4 +- .../_components/TournamentCreateClient.tsx | 28 ++++++----- .../tournament-header/TournamentHeader.tsx | 5 +- .../TournamentBasketItem.tsx | 5 +- .../TournamentItemBasket.tsx | 7 +-- .../TournamentItemBasketCarousel.tsx | 5 +- .../TournamentStartButton.tsx | 7 +-- .../[id]/create/_hooks/useGetTournament.ts | 13 ----- .../[id]/create/_types/tournament.ts | 49 ------------------- .../by-wish/_components/ByWishContent.tsx | 7 +-- .../src/app/tournament/[id]/create/page.tsx | 2 +- 14 files changed, 51 insertions(+), 120 deletions(-) delete mode 100644 apps/web/src/app/tournament/[id]/create/_apis/getTournament.ts delete mode 100644 apps/web/src/app/tournament/[id]/create/_hooks/useGetTournament.ts 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 5d08b884..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,6 +11,14 @@ export type TournamentParticipantT = { profileImage: string; }; +/** + * `pending` 필드 페이로드의 item — PENDING 단계라 name/imageUrl/price 등이 아직 없을 수 있다. + */ +export type TournamentPendingItemT = Partial & { + tournamentItemId: number; + itemId: number; +}; + /** * `pending` 필드 페이로드. * status=PENDING 또는 status=IN_PROGRESS (참여자 대기 케이스) 일 때 내려온다. @@ -26,7 +34,7 @@ type TournamentPendingPayloadT = { inviteCode: string | null; /** 초대 코드 만료 시각 (ISO 8601). `ownerStarted=true` 이면 null */ inviteExpiresAt: string | null; - items: Array & { tournamentItemId: number; itemId: number }>; + items: TournamentPendingItemT[]; participants: TournamentParticipantT[]; }; 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/_components/TournamentCreateClient.tsx b/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx index f6391b85..d48994ff 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx @@ -14,7 +14,7 @@ import { consumeJoinWelcomeFor, } from '../../../join/_utils/joinSession'; import { useCountdown } from '../_hooks/useCountdown'; -import { useGetTournament } from '../_hooks/useGetTournament'; +import { useGetTournament } from '../../_common/_hooks/useGetTournament'; import { useScrollToLast } from '../_hooks/useScrollToLast'; import DepositCountdown from './deposit-countdown/DepositCountdown'; import MemberJoinConfirmDialog from './member-join-confirm-dialog/MemberJoinConfirmDialog'; @@ -39,10 +39,14 @@ function TournamentCreateClient({ tournamentId }: TournamentCreateClientProps) { // 본인이 "시작" 버튼을 눌러 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 = tournamentData.pending?.ownerStarted ?? false; - const depositDeadline = tournamentData.pending?.inviteExpiresAt ?? ''; + const ownerStarted = pending?.ownerStarted ?? false; + const depositDeadline = pending?.inviteExpiresAt ?? ''; const { isExpired } = useCountdown(depositDeadline); // 담기 마감 = 초대 코드 만료 시점 (둘은 동일 정책으로 운영). // 단, 주최자는 만료 영향 없이 본인 토너먼트에 후보를 담을 수 있다. @@ -51,7 +55,7 @@ function TournamentCreateClient({ tournamentId }: TournamentCreateClientProps) { // 비회원(GUEST) 은 서버가 dicebear 자동 아바타를 내려주는데, // 우리 디자인상 비회원은 기본 SVG 프로필을 노출해야 하므로 무시한다. const isGeneratedAvatar = (url: string) => url.includes('api.dicebear.com'); - const participants = (tournamentData.pending?.participants ?? []).map(p => ({ + const participants = (pending?.participants ?? []).map(p => ({ user: { id: p.userId, name: p.nickname, @@ -83,20 +87,18 @@ function TournamentCreateClient({ tournamentId }: TournamentCreateClientProps) { 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} />
    )} item.status === 'PROCESSING' || item.status === 'FAILED' ) ?? false } 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 15381387..7840585d 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 @@ -5,7 +5,7 @@ import { useState } from 'react'; import { ChevronBackwardIconFill } from '@/assets/icons/fill'; -import { useGetTournament } from '../../_hooks/useGetTournament'; +import { useGetTournament } from '../../../_common/_hooks/useGetTournament'; import ConfirmExitDialog from './ConfirmExitDialog'; type TournamentHeaderProps = { @@ -18,7 +18,8 @@ function TournamentHeader({ name }: TournamentHeaderProps) { const { tournamentData } = useGetTournament(Number(tournamentId)); const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false); - const hasFriends = (tournamentData.pending?.participants.length ?? 0) > 1; + const pending = 'pending' in tournamentData ? tournamentData.pending : null; + const hasFriends = (pending?.participants.length ?? 0) > 1; const handleBackClick = () => { if (hasFriends) { 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 8ae8216c..abc458ae 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,9 +6,10 @@ 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 { parseIdParam } from '@/utils/parseIdParam'; +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'; @@ -17,7 +18,7 @@ import TournamentItemFailedModal from './TournamentItemFailedDrawer'; type TournamentItemBasketProps = { basketIndex: number; - items: TournamentItemT[]; + items: TournamentPendingItemT[]; maxHeight?: number; isDepositClosed?: boolean; }; @@ -32,7 +33,7 @@ function TournamentItemBasket({ const tournamentId = parseIdParam(_tournamentId); const basketMaxWidth = maxHeight ? (maxHeight * 356) / 464 : null; - const [failedItem, setFailedItem] = useState(null); + const [failedItem, setFailedItem] = useState(null); const handleItemClick = (item: TournamentItemBasketProps['items'][number]) => { if (!tournamentId) return; 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 c33118be..df3bece0 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 @@ -3,9 +3,10 @@ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Carousel, type CarouselApi, CarouselContent, CarouselItem } from '@/components/carousel'; -import type { TournamentItemT } from '@/types/tournament'; import { cn } from '@/utils/cn'; +import type { TournamentPendingItemT } from '../../../_common/_types/tournamentResponse'; + import { BASKET_CAROUSEL_SLIDE_SIZE_PERCENT, ITEMS_PER_BASKET, @@ -15,7 +16,7 @@ import CarouselIndicator from './CarouselIndicator'; import TournamentItemBasket from './TournamentItemBasket'; type TournamentItemBasketCarouselProps = { - items?: TournamentItemT[]; + items?: TournamentPendingItemT[]; scrollToLast?: boolean; onScrolled?: () => void; isDepositClosed?: boolean; 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 acd12f53..48664c97 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 @@ -5,7 +5,7 @@ import { useEffect, useState } from 'react'; import Button from '@/components/button'; import Spinner from '@/components/spinner'; -import { useGetTournament } from '../../_hooks/useGetTournament'; +import { useGetTournament } from '../../../_common/_hooks/useGetTournament'; import { usePostTournamentStart } from '../../_hooks/usePostTournamentStart'; import ConfirmStartDialog from './ConfirmStartDialog'; @@ -32,9 +32,10 @@ function TournamentStartButton({ Number(tournamentId) ); + const pending = 'pending' in tournamentData ? tournamentData.pending : null; // 참여자는 주최자가 ROOT 를 시작한 후(ownerStarted=true) 부터 본인 CLONE 시작 가능. // ownerStarted=false 면 아직 주최자 시작 전 — "주최자가 시작해야..." 툴팁 노출. - const isWaitingForOwnerStart = isParticipant && tournamentData.pending?.ownerStarted === false; + const isWaitingForOwnerStart = isParticipant && pending?.ownerStarted === false; const [isTooltipVisible, setIsTooltipVisible] = useState(isWaitingForOwnerStart); @@ -47,7 +48,7 @@ function TournamentStartButton({ return () => window.clearTimeout(timeoutId); }, [isWaitingForOwnerStart]); - const hasFriends = (tournamentData.pending?.participants.length ?? 0) > 1; + const hasFriends = (pending?.participants.length ?? 0) > 1; const startTournament = () => postTournamentStartMutation(); 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/_types/tournament.ts b/apps/web/src/app/tournament/[id]/create/_types/tournament.ts index 4064d7a4..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,58 +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; - /** 요청자가 토너먼트 소유자(주최자)인지 여부 */ - isOwner: boolean; - /** ROOT(원본)이면 true, CLONE(플레이 링크/멤버 시작으로 복제된 인스턴스)이면 false */ - isRoot: boolean; - status: TournamentStatusT; - pending?: { - /** - * 주최자가 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: TournamentItemT[]; - participants: TournamentParticipantT[]; - }; -}; export type PostTournamentItemLinkResponseT = { tournamentItemId: number; }; -/** - * 시작 응답. - * - 주최자(ROOT): 본인 tournamentId 반환 - * - 참여자(CLONE): 새로 생성된 CLONE tournamentId 반환 (이후 본인 ID 로 진행) - */ -export type PostTournamentStartResponseT = { - tournamentId: number; - 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 5c28f034..de8ae022 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/page.tsx b/apps/web/src/app/tournament/[id]/create/page.tsx index 73d7be33..840c1a44 100644 --- a/apps/web/src/app/tournament/[id]/create/page.tsx +++ b/apps/web/src/app/tournament/[id]/create/page.tsx @@ -3,7 +3,7 @@ import { Suspense } from 'react'; import { getQueryClient } from '@/utils/queryClient'; -import { getTournament } from './_apis/getTournament'; +import { getTournament } from '../_common/_apis/getTournament'; import TournamentCreateClient from './_components/TournamentCreateClient'; type TournamentCreatePageProps = { From a81ee233eb16dd04a72d8a42740eb812c8f28890 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sat, 13 Jun 2026 15:31:42 +0900 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20=ED=86=A0=EB=84=88=EB=A8=BC?= =?UTF-8?q?=ED=8A=B8=20prefetch=20queryKey=20=EC=9D=98=20id=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20number=20=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/tournament/[id]/create/page.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/tournament/[id]/create/page.tsx b/apps/web/src/app/tournament/[id]/create/page.tsx index 840c1a44..a8f251b6 100644 --- a/apps/web/src/app/tournament/[id]/create/page.tsx +++ b/apps/web/src/app/tournament/[id]/create/page.tsx @@ -12,11 +12,12 @@ type TournamentCreatePageProps = { async function TournamentCreatePage({ params }: TournamentCreatePageProps) { const { id: tournamentId } = await params; + const numericTournamentId = Number(tournamentId); const queryClient = getQueryClient(); await queryClient.prefetchQuery({ - queryKey: ['tournament', tournamentId], - queryFn: () => getTournament(Number(tournamentId)), + queryKey: ['tournament', numericTournamentId], + queryFn: () => getTournament(numericTournamentId), }); return ( From 60c7a784e8444d31038990cc0a8336f534511aac Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sat, 13 Jun 2026 15:33:34 +0900 Subject: [PATCH 09/13] =?UTF-8?q?refactor:=20create=20=EC=9E=90=EC=8B=9D?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=9D=98=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20useGetTournament=20=ED=98=B8=EC=B6=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../create/_components/TournamentCreateClient.tsx | 6 +++++- .../tournament-header/TournamentHeader.tsx | 11 +++-------- .../TournamentStartButton.tsx | 13 ++++--------- 3 files changed, 12 insertions(+), 18 deletions(-) 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 d48994ff..eb87ce27 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx @@ -73,6 +73,8 @@ function TournamentCreateClient({ tournamentId }: TournamentCreateClientProps) { consumeJoinConfirmFor(numericTournamentId) ); 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, @@ -84,7 +86,7 @@ function TournamentCreateClient({ tournamentId }: TournamentCreateClientProps) { return (
    - + item.status === 'PROCESSING' || item.status === 'FAILED' ) ?? false } + hasFriends={hasFriends} + isWaitingForOwnerStart={isWaitingForOwnerStart} isDepositClosed={isDepositClosed} isParticipant={isParticipant} /> 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 7840585d..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,26 +1,21 @@ 'use client'; -import { useParams, useRouter } from 'next/navigation'; +import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { ChevronBackwardIconFill } from '@/assets/icons/fill'; -import { useGetTournament } from '../../../_common/_hooks/useGetTournament'; import ConfirmExitDialog from './ConfirmExitDialog'; type TournamentHeaderProps = { name: string; + hasFriends: boolean; }; -function TournamentHeader({ name }: TournamentHeaderProps) { +function TournamentHeader({ name, hasFriends }: TournamentHeaderProps) { const router = useRouter(); - const { id: tournamentId } = useParams<{ id: string }>(); - const { tournamentData } = useGetTournament(Number(tournamentId)); const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false); - const pending = 'pending' in tournamentData ? tournamentData.pending : null; - const hasFriends = (pending?.participants.length ?? 0) > 1; - const handleBackClick = () => { if (hasFriends) { setIsExitConfirmOpen(true); 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 48664c97..2ee98ba4 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 @@ -5,7 +5,6 @@ import { useEffect, useState } from 'react'; import Button from '@/components/button'; import Spinner from '@/components/spinner'; -import { useGetTournament } from '../../../_common/_hooks/useGetTournament'; import { usePostTournamentStart } from '../../_hooks/usePostTournamentStart'; import ConfirmStartDialog from './ConfirmStartDialog'; @@ -15,6 +14,8 @@ type TournamentStartButtonProps = { count: number; tournamentId: string; hasUnreadyItem: boolean; + hasFriends: boolean; + isWaitingForOwnerStart: boolean; isDepositClosed?: boolean; isParticipant?: boolean; }; @@ -23,20 +24,16 @@ function TournamentStartButton({ count, tournamentId, hasUnreadyItem, + hasFriends, + isWaitingForOwnerStart, isDepositClosed = false, isParticipant = false, }: TournamentStartButtonProps) { const [isConfirmOpen, setIsConfirmOpen] = useState(false); - const { tournamentData } = useGetTournament(Number(tournamentId)); const { postTournamentStartMutation, isPostTournamentStartPending } = usePostTournamentStart( Number(tournamentId) ); - const pending = 'pending' in tournamentData ? tournamentData.pending : null; - // 참여자는 주최자가 ROOT 를 시작한 후(ownerStarted=true) 부터 본인 CLONE 시작 가능. - // ownerStarted=false 면 아직 주최자 시작 전 — "주최자가 시작해야..." 툴팁 노출. - const isWaitingForOwnerStart = isParticipant && pending?.ownerStarted === false; - const [isTooltipVisible, setIsTooltipVisible] = useState(isWaitingForOwnerStart); useEffect(() => { @@ -48,8 +45,6 @@ function TournamentStartButton({ return () => window.clearTimeout(timeoutId); }, [isWaitingForOwnerStart]); - const hasFriends = (pending?.participants.length ?? 0) > 1; - const startTournament = () => postTournamentStartMutation(); const handleClick = () => { From f6852c13a9296386d5475c5f275cd29a6d0b7047 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sat, 13 Jun 2026 15:39:01 +0900 Subject: [PATCH 10/13] =?UTF-8?q?refactor:=20result=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=98=81=EC=88=98=EC=A6=9D=20=ED=8F=AC=EB=A7=B7=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=EC=9D=84=20=5Futils=20=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[id]/result/_components/ReceiptPaper.tsx | 16 +--------------- .../[id]/result/_utils/formatReceipt.ts | 14 ++++++++++++++ .../group/_components/GroupResultClient.tsx | 16 +--------------- 3 files changed, 16 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/app/tournament/[id]/result/_utils/formatReceipt.ts 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 0749f385..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'] }); @@ -24,21 +25,6 @@ type ReceiptPaperProps = { 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 }) => ( 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 index 32148485..a5b2fb40 100644 --- a/apps/web/src/app/tournament/[id]/result/group/_components/GroupResultClient.tsx +++ b/apps/web/src/app/tournament/[id]/result/group/_components/GroupResultClient.tsx @@ -12,6 +12,7 @@ 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'] }); @@ -19,21 +20,6 @@ type GroupResultClientProps = { tournamentId: number; }; -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 buildChosenByLabel = (chosenBy: GroupResultParticipantT[]) => { const [first, ...rest] = chosenBy; if (!first) return ''; From 22e94ceb2ea29dbce22b08104373102cd574f0ab Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sat, 13 Jun 2026 15:40:00 +0900 Subject: [PATCH 11/13] =?UTF-8?q?chore:=20TournamentItemBasket=20=EC=9D=98?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=20=EB=B3=80=EC=88=98=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=5F=20prefix=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tournament-item-basket/TournamentItemBasket.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 abc458ae..326304e0 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 @@ -29,8 +29,8 @@ function TournamentItemBasket({ maxHeight, isDepositClosed = false, }: TournamentItemBasketProps) { - const { id: _tournamentId } = useParams<{ id: string }>(); - const tournamentId = parseIdParam(_tournamentId); + const { id } = useParams<{ id: string }>(); + const tournamentId = parseIdParam(id); const basketMaxWidth = maxHeight ? (maxHeight * 356) / 464 : null; const [failedItem, setFailedItem] = useState(null); From e6fdea6e2dd1f2058fa87ed31d2434b525a9f064 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sat, 13 Jun 2026 15:40:53 +0900 Subject: [PATCH 12/13] =?UTF-8?q?refactor:=20JoinPreviewClient=20=EC=9D=98?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=20null=20=EC=B2=B4=ED=81=AC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20tournamentId=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=A2=81=ED=9E=88=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tournament/join/[id]/_components/JoinPreviewClient.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx b/apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx index c9a886d4..377c015f 100644 --- a/apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx +++ b/apps/web/src/app/tournament/join/[id]/_components/JoinPreviewClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import { notFound, useRouter } from 'next/navigation'; +import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { toast } from 'sonner'; @@ -17,7 +17,7 @@ import { usePostJoinGuest } from '../../_hooks/usePostJoinGuest'; import { setJoinWelcome } from '../../_utils/joinSession'; type JoinPreviewClientProps = { - tournamentId: number | null; + tournamentId: number; /** 친구 초대 코드 — invite 진입 시 query 로 전달됨. 백엔드 join/guest 호출 시 필수 */ inviteCode: string; }; @@ -28,8 +28,6 @@ function JoinPreviewClient({ tournamentId, inviteCode }: JoinPreviewClientProps) const router = useRouter(); const [nickname, setNickname] = useState(DEFAULT_RANDOM_NICKNAME.nickname); - if (tournamentId === null) notFound(); - const { invitePreviewData } = useGetInvitePreview(tournamentId); const trimmedNickname = nickname.trim(); const { nicknameCheckData, isNicknameCheckFetching } = useGetNicknameCheck(nickname); From 7c61c41d2d3f1b31b546d2718d53083bf81b2f35 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Sun, 14 Jun 2026 03:38:20 +0900 Subject: [PATCH 13/13] chore: format --- apps/app/apis/postSocialLogin.ts | 19 ++++++++----------- apps/app/hooks/useWebviewCookieSync.ts | 12 ++++++++++-- apps/app/utils/handleImage.ts | 5 ++++- .../archive/_components/WishlistContent.tsx | 1 + .../wish/[id]/_components/ItemEditForm.tsx | 1 - .../[provider]/_apis/postSocialLogin.ts | 4 ++-- apps/web/src/app/auth/callback/apple/route.ts | 13 +++++-------- apps/web/src/app/layout.tsx | 3 +-- apps/web/src/app/login/_apis/getAuthUrl.ts | 3 ++- .../app/login/_components/LoginButtons.tsx | 1 - .../app/mypage/_components/LogoutMenuItem.tsx | 1 + .../app/mypage/edit/_components/EditForm.tsx | 1 - .../_components/TournamentCreateClient.tsx | 7 +++---- .../TournamentItemBasket.tsx | 1 - .../TournamentItemBasketCarousel.tsx | 1 - .../TournamentStartButton.tsx | 3 +-- .../[itemId]/_components/ItemEditForm.tsx | 1 - .../get-item-dialog/ByImageDialog.tsx | 1 - .../get-item-dialog/ByLinkDialog.tsx | 8 +++++++- apps/web/src/hooks/usePostWishLink.ts | 4 +--- apps/web/src/utils/pushNotificationRoute.ts | 2 +- packages/core/src/index.ts | 6 +++++- 22 files changed, 52 insertions(+), 46 deletions(-) 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/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/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/layout.tsx b/apps/web/src/app/layout.tsx index dcbe829b..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'; 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/_components/LoginButtons.tsx b/apps/web/src/app/login/_components/LoginButtons.tsx index 6fee93b6..b5902372 100644 --- a/apps/web/src/app/login/_components/LoginButtons.tsx +++ b/apps/web/src/app/login/_components/LoginButtons.tsx @@ -115,7 +115,6 @@ function LoginButtons({ redirect }: LoginButtonsProps) {

    - 가입 시{' '} 이용약관 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/tournament/[id]/create/_components/TournamentCreateClient.tsx b/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx index f27e690f..b674e914 100644 --- a/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx +++ b/apps/web/src/app/tournament/[id]/create/_components/TournamentCreateClient.tsx @@ -13,8 +13,8 @@ import { consumeJoinConfirmFor, consumeJoinWelcomeFor, } from '../../../join/_utils/joinSession'; -import { useCountdown } from '../_hooks/useCountdown'; import { useGetTournament } from '../../_common/_hooks/useGetTournament'; +import { useCountdown } from '../_hooks/useCountdown'; import { useScrollToLast } from '../_hooks/useScrollToLast'; import DepositCountdown from './deposit-countdown/DepositCountdown'; import MemberJoinConfirmDialog from './member-join-confirm-dialog/MemberJoinConfirmDialog'; @@ -113,9 +113,8 @@ function TournamentCreateClient({ tournamentId }: TournamentCreateClientProps) { count={pending?.items.length ?? 0} tournamentId={tournamentId} hasUnreadyItem={ - pending?.items.some( - item => item.status === 'PROCESSING' || item.status === 'FAILED' - ) ?? false + pending?.items.some(item => item.status === 'PROCESSING' || item.status === 'FAILED') ?? + false } hasFriends={hasFriends} isWaitingForOwnerStart={isWaitingForOwnerStart} 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 5e9189b1..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 @@ -8,7 +8,6 @@ import { Dialog, DialogTrigger } from '@/components/dialog'; import GetItemDialogContent from '@/components/get-item-dialog'; 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'; 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 df3bece0..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 @@ -6,7 +6,6 @@ import { Carousel, type CarouselApi, CarouselContent, CarouselItem } from '@/com import { cn } from '@/utils/cn'; import type { TournamentPendingItemT } from '../../../_common/_types/tournamentResponse'; - import { BASKET_CAROUSEL_SLIDE_SIZE_PERCENT, ITEMS_PER_BASKET, 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 509d3adf..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 @@ -64,8 +64,7 @@ function TournamentStartButton({ startTournament(); }; - const isDisabled = - isWaitingForOwnerStart || (!isDepositClosed && (count < 2 || hasUnreadyItem)); + const isDisabled = isWaitingForOwnerStart || (!isDepositClosed && (count < 2 || hasUnreadyItem)); return ( <> 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/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" /> - diff --git a/apps/web/src/hooks/usePostWishLink.ts b/apps/web/src/hooks/usePostWishLink.ts index 04a57fd9..e396a0b1 100644 --- a/apps/web/src/hooks/usePostWishLink.ts +++ b/apps/web/src/hooks/usePostWishLink.ts @@ -33,9 +33,7 @@ export const usePostWishLink = () => { if (error.response.status === 400) toast.error(error.response.data.detail); else if (error.response.status === 403) { toast.error(error.response.data.detail); - router.replace( - getLoginPath(`${window.location.pathname}${window.location.search}`) - ); + router.replace(getLoginPath(`${window.location.pathname}${window.location.search}`)); } }, }); diff --git a/apps/web/src/utils/pushNotificationRoute.ts b/apps/web/src/utils/pushNotificationRoute.ts index 8f372d84..c44d5a5e 100644 --- a/apps/web/src/utils/pushNotificationRoute.ts +++ b/apps/web/src/utils/pushNotificationRoute.ts @@ -1,4 +1,4 @@ -import { PUSH_NOTIFICATION_TYPE, type DeepLinkPayloadT } from '@piki/core'; +import { type DeepLinkPayloadT, PUSH_NOTIFICATION_TYPE } from '@piki/core'; import { ROUTES } from '@/consts/route'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 34367d09..0a47a35f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,7 +1,11 @@ /** 상수 */ export { WEBVIEW_UA_TOKEN } from './consts/client'; export { SUPPORTED_IMAGE_MIME_TYPES } from './consts/image'; -export { PUSH_NOTIFICATION_TYPE, WEBBRIDGE_MESSAGE_TYPE, WEB_REQ_READY_PAYLOAD_TYPE } from './consts/webBridge'; +export { + PUSH_NOTIFICATION_TYPE, + WEBBRIDGE_MESSAGE_TYPE, + WEB_REQ_READY_PAYLOAD_TYPE, +} from './consts/webBridge'; /** 타입 */ export type {