diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..7d64b738 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "cockple-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "5174", "--strictPort"], + "port": 5174 + } + ] +} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 69a5afa4..3b1c46c4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,7 @@ name: git push into another repo to deploy to vercel on: push: - branches: [main] + branches: [main, develop] jobs: build: @@ -25,6 +25,6 @@ jobs: destination-repository-name: Cockple_FE user-email: ${{ secrets.EMAIL }} commit-message: ${{ github.event.commits[0].message }} - target-branch: main + target-branch: ${{ github.ref_name }} - name: Test get variable exported by push-to-another-repository run: echo $DESTINATION_CLONED_DIRECTORY diff --git a/package-lock.json b/package-lock.json index 179996fb..6b667424 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "cockple", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", "@headlessui/react": "^2.2.4", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.84.1", @@ -1613,6 +1614,45 @@ "storybook": "^0.0.0-0 || ^9.0.0 || ^9.1.0-0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", diff --git a/package.json b/package.json index f6e6b70c..f711dc51 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build-storybook": "storybook build" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", "@headlessui/react": "^2.2.4", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.84.1", diff --git a/src/App.tsx b/src/App.tsx index 89403817..4fc44806 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { MemberDefault, ExerciseDetailApply, MyExerciseDetail, + GameManagerPage, ViceLeaderDefault, EditGroupInfoDefault, } from "./pages/group"; @@ -32,6 +33,7 @@ import { CreateExercise } from "./pages/group/CreateExercise"; import KakaoLogin from "./pages/login/KakaoLogin"; import OnboardingLayout from "./pages/onboarding/onBoardingLayout"; import { useRawWsConnect } from "./hooks/useRawWsConnect"; +import { useSeedChatUnread } from "./hooks/useSeedChatUnread"; import { resolveMemberId } from "./utils/auth"; import { NoNavbarLayout } from "./layout/NoPtLayout"; @@ -222,6 +224,14 @@ const router = createBrowserRouter([ path: "/group/Mygroup/MyExerciseDetail/:exerciseId", element: , }, + { + path: "/Group/MyGroup/Detail/Exercise_Detail/EndGame/:exerciseId", + element: , + }, + { + path: "/Group/MyGroup/Detail/Exercise_Detail/ChangeHost/:exerciseId", + element: , + }, { path: "/group/Mygroup/ExerciseDetailApplye", element: , @@ -280,6 +290,8 @@ function App() { const memberId = resolveMemberId() ?? 0; useRawWsConnect({ memberId, origin: import.meta.env.VITE_WS_ORIGIN }); + // [임시] 채팅 탭 진입 전에도 안읽음 뱃지가 뜨도록 앱 진입 시 1회 seed + useSeedChatUnread(); useEffect(() => { // 스플래시 화면이 한 번도 표시되지 않은 경우에만 실행 if (!hasShownSplash) { diff --git a/src/api/chat/rawWs.ts b/src/api/chat/rawWs.ts index c366592f..c512aab4 100644 --- a/src/api/chat/rawWs.ts +++ b/src/api/chat/rawWs.ts @@ -1,16 +1,23 @@ // api/chat/rawWs.ts -// SockJS 전용 (STOMP 미사용). 기존 함수명/시그니처 유지. - -// SockJS는 WebSocket 연결 시점에 dynamic import로 로드 -import useUserStore from "../../store/useUserStore"; - -let ws: WebSocket | null = null; +// CHAT 도메인. GAME과 함께 /ws/realtime 단일 연결(api/realtime/connection.ts)을 공유한다. +// 기존 /ws/chats 시절의 평문 { type, ... } 메시지 대신 이제 { version, domain:"CHAT", action/type, requestId, payload/data } envelope을 쓰지만, +// 이 파일 아래의 IncomingMessage 형태(및 함수 시그니처)는 기존과 동일하게 유지해서 이 파일을 쓰는 다른 코드는 손대지 않아도 되게 했다. +// ⚠️ CHAT 도메인의 새 응답 envelope 필드명은 백엔드 공지에서 요청(SUBSCRIBE) 예시만 확인됐고 응답 쪽은 GAME 도메인과의 유사성으로 추정한 것이라, +// 아래 toIncomingMessage()의 매핑이 실제와 다르면 여기만 고치면 된다. +import { + connectRealtimeWs, + disconnectRealtimeWs, + isRealtimeWsOpen, + realtimeWsState, + sendRealtimeFireAndForget, + addRealtimeListener, + addRealtimeOpenListener, + addRealtimeCloseListener, + type ResponseEnvelope, +} from "../realtime/connection"; export type WsStatus = "idle" | "connecting" | "open" | "closed" | "error"; -// 서버 발신 타입들 -//type ServerMessageType = 'CONNECT' | 'ERROR' | 'SUBSCRIBE' | 'SEND' | 'SYSTEM'; - // 서버 → 클라이언트(브로드캐스트)=================================== export type BcFile = { fileKey: string; @@ -20,12 +27,6 @@ export type BcFile = { }; export type BcImage = { - //🌟 - // imgKey: string; - // imgOrder: number; - // originalFileName: string; - // fileSize: number; - // fileType: string; imageId: number; imageUrl: string; imgOrder: number; @@ -48,7 +49,7 @@ export type ErrorResponse = { message: string; }; -// --- 🌟채팅방목록 구독 +// --- 채팅방목록 구독 export type ChatRoomListUpdate = { type: "CHAT_ROOM_LIST_UPDATE"; chatRoomId: number; @@ -61,12 +62,6 @@ export type ChatRoomListUpdate = { timestamp: string; }; -// 🌟export type SubscriptionResponse = { -// type: "SUBSCRIBE"; -// chatRoomId: number; -// message: string; -// timestamp: string; -// }; export type SubscriptionResponse = | { type: "SUBSCRIBE"; @@ -81,12 +76,6 @@ export type SubscriptionResponse = timestamp: string; }; -// 🌟export type UnsubscribeResponse = { -// type: "UNSUBSCRIBE"; // 문서/예시 상 불일치 대비 -// chatRoomId: number; -// message: string; -// timestamp: string; -// }; export type UnsubscribeResponse = | { type: "UNSUBSCRIBE"; @@ -101,7 +90,7 @@ export type UnsubscribeResponse = timestamp: string; }; -// --- 🌟내비바/채팅 탭 안읽음 뱃지용 (memberId 스코프, 방 구독과 무관하게 옴) +// --- 내비바/채팅 탭 안읽음 뱃지용 (memberId 스코프, 방 구독과 무관하게 옴) export type UnreadStatusUpdate = { type: "UNREAD_STATUS_UPDATE"; hasUnread: boolean; @@ -110,7 +99,6 @@ export type UnreadStatusUpdate = { timestamp: string; }; -// 새 브로드캐스트 포맷 export type BroadcastMessage = { type: "SEND"; chatRoomId: number; @@ -132,294 +120,184 @@ export type IncomingMessage = | SubscriptionResponse | UnsubscribeResponse | BroadcastMessage - | ChatRoomListUpdate // 🌟 (채팅방 목록을 구독한 상대방에게 보내는 업데이트) - | UnreadStatusUpdate; // 🌟 내비바/채팅 탭 안읽음 뱃지 + | ChatRoomListUpdate + | UnreadStatusUpdate; + +// CHAT 도메인 envelope(ResponseEnvelope) → 기존 평문 IncomingMessage로 변환 +const toIncomingMessage = (env: ResponseEnvelope): IncomingMessage | null => { + const data = (env.data ?? {}) as Record; + const timestamp = (data.timestamp as string | undefined) ?? env.timestamp; + + switch (env.type) { + case "CONNECT": + return { + type: "CONNECT", + memberId: data.memberId as number, + memberName: data.memberName as string, + connectedAt: (data.connectedAt as string) ?? timestamp, + message: data.message as string, + }; + case "ERROR": { + const err = env.error ?? (data as { code?: string; message?: string }); + return { + type: "ERROR", + errorCode: err?.code ?? "UNKNOWN", + message: err?.message ?? "오류가 발생했어요.", + }; + } + case "SUBSCRIBE": + case "SUBSCRIBED": + return { + type: "SUBSCRIBE", + ...data, + message: (data.message as string) ?? "", + timestamp, + } as SubscriptionResponse; + case "UNSUBSCRIBE": + case "UNSUBSCRIBED": + return { + type: "UNSUBSCRIBE", + ...data, + message: (data.message as string) ?? "", + timestamp, + } as UnsubscribeResponse; + case "SEND": + return { + type: "SEND", + chatRoomId: data.chatRoomId as number, + messageId: data.messageId as number, + content: (data.content as string | null) ?? null, + files: (data.files as BcFile[] | null) ?? null, + images: (data.images as BcImage[] | null) ?? null, + senderId: (data.senderId as number | null) ?? null, + senderName: data.senderName as string, + senderProfileImageUrl: + (data.senderProfileImageUrl as string | null) ?? null, + messageType: data.messageType as "TEXT" | "SYSTEM" | null | undefined, + timestamp, + unreadCount: data.unreadCount as number | undefined, + }; + case "CHAT_ROOM_LIST_UPDATE": + return { + type: "CHAT_ROOM_LIST_UPDATE", + chatRoomId: data.chatRoomId as number, + lastMessage: data.lastMessage as ChatRoomListUpdate["lastMessage"], + newUnreadCount: data.newUnreadCount as number, + timestamp, + }; + case "UNREAD_STATUS_UPDATE": + return { + type: "UNREAD_STATUS_UPDATE", + hasUnread: data.hasUnread as boolean, + hasPartyUnread: data.hasPartyUnread as boolean, + hasDirectUnread: data.hasDirectUnread as boolean, + timestamp, + }; + // 목록 구독/해제 ack — 클라이언트가 따로 처리할 게 없어 조용히 무시 + case "SUBSCRIBE_CHAT_LIST": + case "UNSUBSCRIBE_CHAT_LIST": + default: + return null; + } +}; //현재 구독 중인 방 목록을 전역으로 유지 (클라이언트 단의 '의도' 상태) // 서버는 Redis에 실제 구독을 보관/복원하므로 재연결시 재구독 전송은 불필요 const currentRooms = new Set(); -// 재연결 백오프 -let reconnectTimer: number | null = null; -let reconnectAttempt = 0; -let isManualClose = false; // 🌟 수동 종료 플래그 추가 - -//🌟전역 리스너(Event Bus) +//전역 리스너(Event Bus) type MsgListener = (data: IncomingMessage) => void; const listeners = new Set(); export const addWsListener = (fn: MsgListener) => { + const off = addRealtimeListener(env => { + if (env.domain !== "CHAT") return; + const msg = toIncomingMessage(env); + if (msg) fn(msg); + }); listeners.add(fn); return () => { listeners.delete(fn); + off(); }; }; -// 연결 상태 리스너: 소켓을 누가 만들었는지와 무관하게 모든 구독자에게 통지 -type OpenListener = () => void; -type CloseListener = (ev?: CloseEvent) => void; -const openListeners = new Set(); -const closeListeners = new Set(); - -export const addWsOpenListener = (fn: OpenListener) => { - openListeners.add(fn); - return () => { - openListeners.delete(fn); - }; -}; - -export const addWsCloseListener = (fn: CloseListener) => { - closeListeners.add(fn); - return () => { - closeListeners.delete(fn); - }; -}; - -// 동시 호출 시 소켓이 중복 생성되지 않도록 진행 중인 연결 시도를 공유 -let connectPromise: Promise | null = null; - -const WS_ORIGIN = ( - import.meta.env.VITE_WS_ORIGIN ?? window.location.origin -).replace(/\/$/, ""); -const WS_PATH = (import.meta.env.VITE_WS_PATH ?? "/ws/chats").replace( - /\/$/, - "", -); - -const buildSockUrl = (origin?: string) => { - const base = (origin ?? WS_ORIGIN) + WS_PATH; - return base; // SockJS는 http/https 사용 -}; - -//토큰 유틸 & 가드 -//const getToken = () => localStorage.getItem("accessToken") || ""; -const getToken = () => { - const { user } = useUserStore.getState(); - return user?.accessToken ?? localStorage.getItem("accessToken") ?? ""; -}; -const hasToken = () => !!getToken(); - -export type WsSendFile = { - fileKey: string; - originalFileName: string; - fileSize: number; - fileType: string; -}; - -export type WsSendImage = { - imgKey: string; - imgOrder: number; - originalFileName: string; - fileSize: number; - fileType: string; -}; - -type WsSendEnvelope = { - type: "SEND"; - chatRoomId: number; - content: string | null; - files: WsSendFile[] | null; - images: WsSendImage[] | null; -}; - -type OutgoingMessage = - | { type: "SUBSCRIBE"; chatRoomId: number } - | { type: "UNSUBSCRIBE"; chatRoomId: number } - | { type: "SUBSCRIBE_CHAT_LIST"; memberRooms: number[] } // 🌟목록 탭용 - | { type: "UNSUBSCRIBE_CHAT_LIST"; memberRooms: number[] } // 🌟목록 탭용 - | WsSendEnvelope; - -const sendJSON = (msg: OutgoingMessage) => { - //if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(msg)); - return true; - } - console.warn("[WS] not open. drop:", msg); - return false; -}; +export const addWsOpenListener = addRealtimeOpenListener; +export const addWsCloseListener = addRealtimeCloseListener; -// --------- 공개 API ---------- -export const connectRawWs = async ({ - memberId, +// --------- 공개 API (연결은 realtime/connection.ts로 위임) ---------- +export const connectRawWs = ({ origin, }: { - memberId: number; + memberId?: number; // 신규 /ws/realtime은 token만 사용 — 호환을 위해 인자만 유지 origin?: string; -}) => { - // accessToken 없으면 연결 시도 안 함 - if (!hasToken()) { - console.info("[WS] skipped: no accessToken"); - return null; - } - - if ( - ws && - (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) - ) { - return ws; - } +} = {}) => connectRealtimeWs({ origin }); - // 동시 호출 시 소켓 중복 생성 방지 (SockJS dynamic import 대기 구간 레이스) - if (connectPromise) return connectPromise; - - connectPromise = (async () => { - const base = buildSockUrl(origin); - const url = new URL(base); - url.searchParams.set("memberId", String(memberId)); - url.searchParams.set("token", getToken()); // 서버가 헤더 대신 쿼리 파라미터로 읽는 형태라면 유지 - - // SockJS dynamic import (초기 번들에서 제외) - const { default: SockJS } = await import("sockjs-client"); - const sock = new SockJS(url.toString()); - ws = sock as WebSocket; - - // readyState가 OPEN이 되면 onopen 호출 (등록된 모든 구독자에게 통지) - sock.onopen = () => { - reconnectAttempt = 0; - isManualClose = false; // 연결 성공 시 플래그 초기화 - openListeners.forEach(fn => { - try { - fn(); - } catch (err) { - console.warn("ws open listener err", err); - } - }); - }; - - sock.onmessage = (e: MessageEvent) => { - try { - const parsed: IncomingMessage = JSON.parse(e.data); - console.log("[WS←]", parsed.type, parsed); - - // 전역 리스너 브로드캐스트 - listeners.forEach(fn => { - try { - fn(parsed); - } catch (err) { - console.warn("ws listener err", err); - } - }); - } catch { - console.warn("[SockJS] Non-JSON message:", e.data); - } - }; - - sock.onerror = (ev: Event) => { - console.warn("[WS error]", ev); - }; +export const disconnectRawWs = () => disconnectRealtimeWs(); - sock.onclose = (ev: CloseEvent) => { - console.warn("[WS close]", ev.code, ev.reason); - closeListeners.forEach(fn => { - try { - fn(ev); - } catch (err) { - console.warn("ws close listener err", err); - } - }); - ws = null; - connectPromise = null; - - // 🌟 수동으로 끊은 경우 재연결 시도 안 함 - if (isManualClose) { - console.log("[WS] Manual disconnect. No reconnect."); - return; - } - - // 토큰 없으면 재시도 안 함 - if (!hasToken()) return; - - // 백오프 재연결 - if (!reconnectTimer) { - const delay = Math.min(500 * 2 ** reconnectAttempt, 8000); - reconnectTimer = window.setTimeout(() => { - reconnectTimer = null; - reconnectAttempt++; - connectRawWs({ memberId, origin }); - }, delay); - } - }; - - return ws!; - })(); +export const rawWsState = () => realtimeWsState(); // 0/1/2/3 +export const isRawWsOpen = () => isRealtimeWsOpen(); - const result = await connectPromise; - connectPromise = null; - return result; -}; +const sendChat = (action: string, payload: Record) => + sendRealtimeFireAndForget("CHAT", action, payload); -export const disconnectRawWs = () => { - if ( - ws && - (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) - ) { - isManualClose = true; // 🌟 수동 종료임을 명시 - ws.close(); - } - ws = null; -}; - -export const rawWsState = () => ws?.readyState; // 0/1/2/3 -export const isRawWsOpen = () => ws?.readyState === WebSocket.OPEN; - -//🌟 export const subscribeChatList = (roomIds: number[]) => { if (!roomIds.length) return; - const ok = sendJSON({ type: "SUBSCRIBE_CHAT_LIST", memberRooms: roomIds }); - console.log("[WS→] SUBSCRIBE_CHAT_LIST", roomIds, ok ? "OK" : "DEFER"); + sendChat("SUBSCRIBE_CHAT_LIST", { memberRooms: roomIds }); }; export const subscribeRoom = (roomId: number) => { if (currentRooms.has(roomId)) return; // 중복 방지 currentRooms.add(roomId); - const ok = sendJSON({ type: "SUBSCRIBE", chatRoomId: roomId }); - console.log("[WS→] SUBSCRIBE", roomId, ok ? "OK" : "DEFER"); + sendChat("SUBSCRIBE", { chatRoomId: roomId }); }; -// export const subscribeMany = (roomIds: number[]) => { roomIds.forEach(id => subscribeRoom(id)); }; -//🌟 export const unsubscribeChatList = (roomIds: number[]) => { if (!roomIds.length) return; - const ok = sendJSON({ type: "UNSUBSCRIBE_CHAT_LIST", memberRooms: roomIds }); - console.log("[WS→] UNSUBSCRIBE_CHAT_LIST", roomIds, ok ? "OK" : "DEFER"); + sendChat("UNSUBSCRIBE_CHAT_LIST", { memberRooms: roomIds }); }; -// export const unsubscribeRoom = (roomId: number) => { if (!currentRooms.has(roomId)) return; currentRooms.delete(roomId); - const ok = sendJSON({ type: "UNSUBSCRIBE", chatRoomId: roomId }); - if (!ok) { - // 소켓이 닫혀있으면 재접속 시 자동 재구독되지 않도록만 유지. - console.warn("[WS] UNSUBSCRIBE send failed (socket closed)"); - } + sendChat("UNSUBSCRIBE", { chatRoomId: roomId }); }; -// export const unsubscribeAll = () => { // 서버가 구독을 영속화하므로, 진짜로 모두 끊고 싶을 때만 호출하세요. - [...currentRooms].forEach(id => - sendJSON({ type: "UNSUBSCRIBE", chatRoomId: id }), - ); - + [...currentRooms].forEach(id => sendChat("UNSUBSCRIBE", { chatRoomId: id })); currentRooms.clear(); }; // 채팅 SEND +export type WsSendFile = { + fileKey: string; + originalFileName: string; + fileSize: number; + fileType: string; +}; + +export type WsSendImage = { + imgKey: string; + imgOrder: number; + originalFileName: string; + fileSize: number; + fileType: string; +}; + // ---------- 송신 헬퍼 (혼합 허용) ---------- export const sendTextWS = (chatRoomId: number, content: string) => - sendJSON({ type: "SEND", chatRoomId, content, files: null, images: null }); + sendChat("SEND", { chatRoomId, content, files: null, images: null }); export const sendImagesWS = (chatRoomId: number, images: WsSendImage[]) => - sendJSON({ type: "SEND", chatRoomId, content: null, files: null, images }); + sendChat("SEND", { chatRoomId, content: null, files: null, images }); export const sendFilesWS = (chatRoomId: number, files: WsSendFile[]) => - sendJSON({ type: "SEND", chatRoomId, content: null, files, images: null }); + sendChat("SEND", { chatRoomId, content: null, files, images: null }); export const sendMixedWS = (args: { chatRoomId: number; @@ -427,8 +305,7 @@ export const sendMixedWS = (args: { files?: WsSendFile[] | null; images?: WsSendImage[] | null; }) => - sendJSON({ - type: "SEND", + sendChat("SEND", { chatRoomId: args.chatRoomId, content: args.content ?? null, files: args.files ?? null, diff --git a/src/api/exercise/InviteGuestApi.ts b/src/api/exercise/InviteGuestApi.ts index 69a107a8..cc48739c 100644 --- a/src/api/exercise/InviteGuestApi.ts +++ b/src/api/exercise/InviteGuestApi.ts @@ -74,7 +74,7 @@ export const useDeleteInviteForm = (exerciseId: number) => { }); }, onError: err => { - console.log(err); + console.error(err); if (axiosLib.isAxiosError(err)) { if (err.response?.data?.code === "EXERCISE304") { alert(err.response.data.message); diff --git a/src/api/exercise/exercises.ts b/src/api/exercise/exercises.ts index 37cb5231..74588532 100644 --- a/src/api/exercise/exercises.ts +++ b/src/api/exercise/exercises.ts @@ -58,6 +58,7 @@ export interface MemberProps { // 최종 변환 타입 export interface ExerciseDetailResponse { partyId: number; + gameBoardId: number; notice: string; placeName: string; placeAddress: string; @@ -74,6 +75,7 @@ export interface ExerciseDetailResponse { interface RawExerciseResponse { isManager: boolean; isWithdrawn?: boolean; + gameBoardId: number; info: { notice: string; buildingName: string; @@ -174,6 +176,7 @@ export const getExerciseDetail = async ( return { partyId: exerciseId, + gameBoardId: raw.gameBoardId, notice: raw.info.notice, placeName: raw.info.buildingName, placeAddress: raw.info.location, @@ -220,3 +223,4 @@ export const deleteExercise = async (exerciseId: number) => { throw error.response?.data || error; } }; + diff --git a/src/api/game/board.ts b/src/api/game/board.ts new file mode 100644 index 00000000..e0c23427 --- /dev/null +++ b/src/api/game/board.ts @@ -0,0 +1,48 @@ +// 게임 코트 보드 조회 (REST, GET /api/game-boards/{gameBoardId}) +// 명단을 제외한 게임코트/대기열 스냅샷. 웹소켓 각 액션 응답의 board/data와 동일한 shape(GameBoardDTO.Response). +import api from "../api"; +import type { CommonResponse } from "../../types/common"; + +export type CourtStatus = "EMPTY" | "PLAYING"; + +export interface GameBoardPlayer { + gameBoardMemberId: number; + name: string; + level: string; + playerOrder: number; +} + +export interface GameBoardCourtGame { + gameId: number; + startedAt: string; // 경과 시간은 클라가 now - startedAt으로 계산 + players: GameBoardPlayer[]; +} + +export interface GameBoardCourt { + courtId: number; + courtNo: number; + courtName: string; + status: CourtStatus; + game: GameBoardCourtGame | null; // EMPTY면 null +} + +export interface GameBoardWaiting { + gameId: number; // status=WAITING + waitingOrder: number; // 화면 대기 N번. 정렬 인덱스(저장값 아님) + players: GameBoardPlayer[]; +} + +export interface GameBoardResponse { + courtCount: number; // 0이면 코트 생성 화면 분기 + courts: GameBoardCourt[]; + waitings: GameBoardWaiting[]; +} + +export const getGameBoard = async ( + gameBoardId: number, +): Promise => { + const response = await api.get>( + `/api/game-boards/${gameBoardId}`, + ); + return response.data.data; +}; diff --git a/src/api/game/courts.ts b/src/api/game/courts.ts new file mode 100644 index 00000000..8eb08c3a --- /dev/null +++ b/src/api/game/courts.ts @@ -0,0 +1,35 @@ +// 게임판 코트 관리 (REST, PUT /api/game-boards/{gameBoardId}/courts) +import api from "../api"; +import type { CommonResponse } from "../../types/common"; + +export interface UpdateCourtItem { + courtId?: number; // 있으면 유지(이름 변경 가능), 없으면 신규 생성. 기존 id가 요청에서 빠지면 삭제 + courtName?: string; // 생략 시 courtNo 기반 기본값(01 코트 등) +} + +export interface UpdateCourtsRequest { + courts: UpdateCourtItem[]; +} + +export interface CourtItem { + courtId: number; + courtNo: number; + courtName: string; +} + +export interface UpdateCourtsResponseData { + gameBoardId: number; + courts: CourtItem[]; +} + +// 400: 입력값 오류 / 404: 게임판 또는 코트를 찾을 수 없음 +export const updateCourts = async ( + gameBoardId: number, + payload: UpdateCourtsRequest, +): Promise => { + const response = await api.put>( + `/api/game-boards/${gameBoardId}/courts`, + payload, + ); + return response.data.data; +}; diff --git a/src/api/game/duplicateCheck.ts b/src/api/game/duplicateCheck.ts new file mode 100644 index 00000000..7c55f70f --- /dev/null +++ b/src/api/game/duplicateCheck.ts @@ -0,0 +1,32 @@ +// 게임 중복 체크 (REST, GET /api/game-boards/{gameBoardId}/games/duplicate-check) +// 선택 인원 쌍 별 대전 횟수 제공 +import qs from "qs"; +import api from "../api"; +import type { CommonResponse } from "../../types/common"; + +export interface DuplicateCheckPair { + memberIdA: number; + memberIdB: number; + count: number; // 그날 누적 대전 횟수 + playedInLastGame: boolean; +} + +export interface DuplicateCheckResponse { + pairs: DuplicateCheckPair[]; // 4명이면 6쌍 +} + +export const getGameDuplicateCheck = async ( + gameBoardId: number, + gameBoardMemberIds: number[], +): Promise => { + const response = await api.get>( + `/api/game-boards/${gameBoardId}/games/duplicate-check`, + { + params: { gameBoardMemberId: gameBoardMemberIds }, + paramsSerializer: { + serialize: params => qs.stringify(params, { arrayFormat: "repeat" }), // gameBoardMemberId=101&gameBoardMemberId=102 형식 + }, + }, + ); + return response.data.data; +}; diff --git a/src/api/game/game.ts b/src/api/game/game.ts new file mode 100644 index 00000000..9889155c --- /dev/null +++ b/src/api/game/game.ts @@ -0,0 +1,97 @@ +import api from "../api"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; + +export interface CompletedGamePlayer { + name: string; + tag: string; + team?: "A" | "B"; +} + +export interface CompletedGame { + gameId: number; + courtNo: number; + durationMin: number; + players: CompletedGamePlayer[]; +} + +export interface CompletedGamesResponse { + games: CompletedGame[]; + nextCursor: string | null; + hasNext: boolean; +} + +export const getCompletedGames = async ( + gameBoardId: number, + params?: { + courtNo?: number; + cursor?: string; + size?: number; + } +): Promise => { + const { data } = await api.get(`/api/game-boards/${gameBoardId}/games/completed`, { + params, + }); + return data.data; +}; + +// 게임 진행자 수정 +export const changeGameHost = async ( + exerciseId: number, + participantId: number +) => { + const { data } = await api.patch(`/api/exercises/${exerciseId}/game-host`, { + participantId, + }); + return data; +}; + +export interface GameHostParticipant { + participantId: number; + profileImageUrl: string | null; + partyPosition: string; + isGameHost: boolean; + name: string; + gender: string; + level: string; + lastExerciseDate: string; +} + +export interface GameHostCandidatesResponse { + totalCount: number; + participants: GameHostParticipant[]; +} + +export const getGameHostCandidates = async ( + exerciseId: number +): Promise => { + const { data } = await api.get(`/api/exercises/${exerciseId}/game-host`); + return data.data; +}; + +export const useGetGameHostCandidates = (exerciseId: number) => { + return useQuery({ + queryKey: ["gameHostCandidates", exerciseId], + queryFn: () => getGameHostCandidates(exerciseId), + enabled: !!exerciseId, + }); +}; + +export const useGetCompletedGames = ( + gameBoardId: number, + courtNo?: number, + size: number = 20 +) => { + return useInfiniteQuery({ + queryKey: ["completedGames", gameBoardId, courtNo], + queryFn: ({ pageParam }) => + getCompletedGames(gameBoardId, { + courtNo, + cursor: pageParam, + size, + }), + getNextPageParam: (lastPage) => + lastPage.hasNext ? lastPage.nextCursor : undefined, + initialPageParam: undefined as string | undefined, + }); +}; + diff --git a/src/api/game/members.ts b/src/api/game/members.ts new file mode 100644 index 00000000..11017fb6 --- /dev/null +++ b/src/api/game/members.ts @@ -0,0 +1,95 @@ +// 게임판 명단 조회 (REST, GET /api/game-boards/{gameBoardId}/gameBoardMembers) +import qs from "qs"; +import api from "../api"; +import type { CommonResponse } from "../../types/common"; + +export interface GameBoardMember { + gameBoardMemberId: number; + inGame: boolean; // 운동 참여 여부 + waiting: boolean; // 대기 참여 여부 + participating: boolean; // 참여 여부 + gameCount: number; // 게임 참여 횟수 + profileImageUrl: string | null; + name: string; + gender: string; // 한글 표시값 ("남성" | "여성") + ageGroup: string; // 한글 표시값 (예: "30대") + level: string; // 한글 표시값 (예: "A조") + shuttlecockSubmitted: boolean; +} + +export interface GameBoardMembersResponse { + totalCount: number; // 필터와 무관한 전체 명단 수 + gameBoardMembers: GameBoardMember[]; +} + +// level/gender는 한글 표시값을 그대로 전달 (예: level=A조&level=B조, gender=남성). 서로 다른 필터는 AND, 같은 level 다중 선택은 OR. +export interface GetGameBoardMembersParams { + level?: string[]; + gender?: "남성" | "여성"; + shuttlecockSubmitted?: boolean; +} + +export const getGameBoardMembers = async ( + gameBoardId: number, + params: GetGameBoardMembersParams = {}, +): Promise => { + const response = await api.get>( + `/api/game-boards/${gameBoardId}/gameBoardMembers`, + { + params, + paramsSerializer: { + serialize: p => qs.stringify(p, { arrayFormat: "repeat" }), // level=A조&level=B조 형식 + }, + }, + ); + return response.data.data; +}; + +export interface GameBoardMemberPayload { + name: string; + gender: "남성" | "여성"; // 서버는 한글 표시값만 허용 + level: string; // 한글 급수 라벨 (예: "D조", "급수없음") + ageGroup?: string; // 한글 나이대 라벨 (예: "30대") +} + +export type CreateGameBoardMemberRequest = GameBoardMemberPayload; +export type UpdateGameBoardMemberRequest = GameBoardMemberPayload; + +export interface CreateGameBoardMemberResponseData { + gameBoardMemberId: number; +} + +export const createGameBoardMember = async ( + gameBoardId: number, + player: CreateGameBoardMemberRequest, +): Promise => { + const response = await api.post>( + `/api/game-boards/${gameBoardId}/gameBoardMembers`, + player, + ); + return response.data.data; +}; + +// 참여 상태 변경 (PATCH /api/game-boards/{boardId}/gameBoardMembers/{gameBoardMemberId}/participation) +export const updateGameBoardMemberParticipation = async ( + gameBoardId: number, + gameBoardMemberId: number, + participating: boolean, +): Promise => { + await api.patch>( + `/api/game-boards/${gameBoardId}/gameBoardMembers/${gameBoardMemberId}/participation`, + { participating }, + ); +}; + +// 플레이어 정보 수정 (PATCH /api/game-boards/{boardId}/gameBoardMembers/{gameBoardMemberId}) +export const updateGameBoardMember = async ( + gameBoardId: number, + gameBoardMemberId: number, + player: UpdateGameBoardMemberRequest, +): Promise => { + await api.patch>( + `/api/game-boards/${gameBoardId}/gameBoardMembers/${gameBoardMemberId}`, + player, + ); +}; diff --git a/src/api/game/randomMatch.ts b/src/api/game/randomMatch.ts new file mode 100644 index 00000000..195774ee --- /dev/null +++ b/src/api/game/randomMatch.ts @@ -0,0 +1,18 @@ +// 게임 랜덤 매칭 추천 (REST, POST /api/game-boards/{gameBoardId}/games/random-match) +// 명단에서 바로 게임 가능한 선수 4명을 서버가 추천. 결과는 저장되지 않으며, +// 확정 시 기존 웹소켓 CREATE_GAME을 호출해야 함. +import api from "../api"; +import type { CommonResponse } from "../../types/common"; + +export interface RandomMatchResponse { + gameBoardMemberIds: number[]; // 추천된 멤버 Id 목록 (예: [12, 18, 25, 31]) +} + +export const postRandomMatch = async ( + gameBoardId: number, +): Promise => { + const response = await api.post>( + `/api/game-boards/${gameBoardId}/games/random-match`, + ); + return response.data.data; +}; diff --git a/src/api/game/rawWs.ts b/src/api/game/rawWs.ts new file mode 100644 index 00000000..79df5212 --- /dev/null +++ b/src/api/game/rawWs.ts @@ -0,0 +1,157 @@ +// api/game/rawWs.ts +// GAME 도메인. CHAT과 함께 /ws/realtime 단일 연결(api/realtime/connection.ts)을 공유한다. +import { + connectRealtimeWs, + disconnectRealtimeWs, + isRealtimeWsOpen, + realtimeWsState, + sendRealtimeRequest, + addRealtimeListener, + addRealtimeOpenListener, + addRealtimeCloseListener, + type ResponseEnvelope, +} from "../realtime/connection"; +import type { GameBoardPlayer, GameBoardResponse } from "./board"; + +export const GAME_DOMAIN = "GAME" as const; + +// ---------- 요청 envelope ---------- +export type GameAction = + | "SUBSCRIBE" + | "UNSUBSCRIBE" + | "CREATE_GAME" + | "START_GAME" + | "COMPLETE_GAME" + | "DELETE_GAME" + | "MOVE_COURT" + | "MOVE_TO_WAITING"; + +// ---------- 응답 envelope ---------- +export type GameResponseType = + | "SUBSCRIBED" + | "UNSUBSCRIBED" + | "BOARD_UPDATED" + | "MEMBERS_UPDATED" + | "GAME_CREATED" + | "GAME_DELETED" + | "ERROR"; + +export type GameErrorPayload = { code: string; message: string }; + +// GameBoardDTO.Response (게임 코트 보드 조회 Response와 동일한 스냅샷) +export type GameBoardSnapshot = GameBoardResponse; + +export type GameResponseEnvelope = ResponseEnvelope & { + type: GameResponseType; +}; + +// --------- 공개 API (연결은 realtime/connection.ts로 위임) ---------- +export const connectGameWs = (opts: { origin?: string } = {}) => + connectRealtimeWs(opts); + +export const disconnectGameWs = () => disconnectRealtimeWs(); + +export const gameWsState = () => realtimeWsState(); +export const isGameWsOpen = () => isRealtimeWsOpen(); + +// GAME 도메인 메시지만 걸러서 전달 +export const addGameWsListener = (fn: (msg: GameResponseEnvelope) => void) => + addRealtimeListener(msg => { + if (msg.domain === "GAME") fn(msg as GameResponseEnvelope); + }); + +export const addGameWsOpenListener = addRealtimeOpenListener; +export const addGameWsCloseListener = addRealtimeCloseListener; + +// requestId 기반 요청 → 매칭되는 응답(or ERROR)을 Promise로 반환 +export const gameWsRequest = < + TData = unknown, + TPayload = Record, +>( + action: GameAction, + payload: TPayload, +) => + sendRealtimeRequest( + "GAME", + action, + payload, + ) as Promise>; + +// ---------- 액션별 헬퍼 ---------- +// 각 액션의 payload/data 필드는 액션별 API 명세가 확정되면 구체 타입으로 교체한다. +export const subscribeGameBoard = (gameBoardId: number) => + gameWsRequest<{ gameBoardId: number }>("SUBSCRIBE", { gameBoardId }); + +export const unsubscribeGameBoard = (gameBoardId: number) => + gameWsRequest<{ gameBoardId: number }>("UNSUBSCRIBE", { gameBoardId }); + +// 대기열에 게임 생성 +export type CreateGamePayload = { + gameBoardId: number; + gameBoardMemberIds: number[]; // 최대 4명, 배열 순서 = playerOrder +}; + +export type CreateGameData = { + gameId: number; + board: GameBoardSnapshot; +}; + +export const createGameWS = (payload: CreateGamePayload) => + gameWsRequest("CREATE_GAME", payload); + +// 대기열에 있는 팀을 게임 코트로 옮길 때 사용 +export type StartGamePayload = { + gameBoardId: number; + gameId: number; + courtId: number; +}; + +export const startGameWS = (payload: StartGamePayload) => + gameWsRequest("START_GAME", payload); + +// 게임 완료 시 코트를 비우고 팀 멤버들의 게임횟수를 증가 +export type CompleteGamePayload = { + gameBoardId: number; + gameId: number; +}; + +export const completeGameWS = (payload: CompleteGamePayload) => + gameWsRequest("COMPLETE_GAME", payload); + +// 복원용 플레이어 정보 — 보드 조회의 PlayerDto(GameBoardPlayer)와 동일하다고 가정. 다르면 교체 필요 +export type PlayerInfo = GameBoardPlayer; + +// 게임 취소 / 대기열 삭제 시 사용 +export type DeleteGamePayload = { + gameBoardId: number; + gameId: number; + restore?: boolean; // true면 삭제 게임 플레이어를 복원용으로 반환 (기본 false) +}; + +export type DeleteGameData = { + gameId: number; + players: PlayerInfo[]; // restore=false면 빈 배열 + board: GameBoardSnapshot; +}; + +export const deleteGameWS = (payload: DeleteGamePayload) => + gameWsRequest("DELETE_GAME", payload); + +// 이미 코트에 배정된 경기를 다른 코트로 옮길 때 사용 (피그마상 불필요해 보이나 추후 대비) +export type MoveCourtPayload = { + gameBoardId: number; + courtId: number; + targetCourtNo: number; +}; + +export const moveCourtWS = (payload: MoveCourtPayload) => + gameWsRequest("MOVE_COURT", payload); + +// 진행중인 게임을 대기열 맨 앞으로 이동. 코트는 비워짐 +export type MoveToWaitingPayload = { + gameBoardId: number; + gameId: number; +}; + +export const moveToWaitingWS = (payload: MoveToWaitingPayload) => + gameWsRequest("MOVE_TO_WAITING", payload); diff --git a/src/api/member/getMyLocation.ts b/src/api/member/getMyLocation.ts index d8827228..d6c3f84a 100644 --- a/src/api/member/getMyLocation.ts +++ b/src/api/member/getMyLocation.ts @@ -12,6 +12,6 @@ export const getMyLocation = async () => { const res = await api.get("/api/my/location"); return res.data.data; } catch (err) { - console.log("회원 위치 조회 API 실패:", err); + console.error("회원 위치 조회 API 실패:", err); } }; diff --git a/src/api/member/my.ts b/src/api/member/my.ts index 3ecdd483..26b68e01 100644 --- a/src/api/member/my.ts +++ b/src/api/member/my.ts @@ -106,10 +106,7 @@ export const postMyProfileLocation = async (payload: AddLocationPayload) => { export const deleteAddress = async (memberAddrId: number) => { try { const res = await api.delete(`/api/my/profile/locations/${memberAddrId}`); - if (res.data.success) { - console.log("주소 삭제 성공", res.data.message); - // 삭제 성공 시 상태 갱신이나 UI 업데이트 수행 - } else { + if (!res.data.success) { console.error("주소 삭제 실패", res.data.message); } } catch (err) { @@ -124,9 +121,7 @@ export const setMainAddress = async (memberAddrId: number) => { `/api/my/profile/locations/${memberAddrId}`, {}, ); - if (res.data.success) { - console.log("대표 주소 변경 성공", res.data.message); - } else { + if (!res.data.success) { console.error("대표 주소 변경 실패", res.data.message); } } catch (err) { diff --git a/src/api/member/onboarding.ts b/src/api/member/onboarding.ts index 0808fe1a..39825c85 100644 --- a/src/api/member/onboarding.ts +++ b/src/api/member/onboarding.ts @@ -31,8 +31,7 @@ export const usePostOnboarding = () => { const navigate = useNavigate(); return useMutation({ mutationFn: (body: OnBoardingRequest) => postOnboarding(body), - onSuccess: data => { - console.log(data); + onSuccess: () => { navigate("/onboarding/confirm/start"); }, onError: err => { @@ -63,8 +62,7 @@ export const usePostKeywords = () => { partyId: number; keywords: string[]; }) => postKeywords(keywords, partyId), - onSuccess: (data, v) => { - console.log(data); + onSuccess: (_data, v) => { navigate(`/group/making/member/${v.partyId}`); }, onError: err => { diff --git a/src/api/party/getJoinParty.ts b/src/api/party/getJoinParty.ts index 021af9b8..19f716e8 100644 --- a/src/api/party/getJoinParty.ts +++ b/src/api/party/getJoinParty.ts @@ -5,6 +5,6 @@ export const getJoinParty = async (partyId: number) => { const { data } = await api.post(`/api/parties/${partyId}/join-requests`); return data; } catch (err) { - console.log(err); + console.error(err); } }; diff --git a/src/api/realtime/connection.ts b/src/api/realtime/connection.ts new file mode 100644 index 00000000..2cf3860c --- /dev/null +++ b/src/api/realtime/connection.ts @@ -0,0 +1,279 @@ +// api/realtime/connection.ts +// CHAT과 GAME 도메인이 함께 쓰는 단일 SockJS 연결. +// 엔드포인트: GET /ws/realtime?token={accessToken} (기존 /ws/chats를 대체, 도메인은 envelope.domain으로 라우팅) +// 두 도메인이 각자 별도 연결을 열면 서버에서 중복 발송될 수 있어(백엔드 공지) 연결은 반드시 하나만 유지한다. +// api/chat/rawWs.ts, api/game/rawWs.ts는 이 모듈을 통해 envelope을 주고받는 얇은 래퍼다. +import useUserStore from "../../store/useUserStore"; + +let ws: WebSocket | null = null; + +export const REALTIME_PROTOCOL_VERSION = 1; +export type RealtimeDomain = "CHAT" | "GAME"; + +export type RequestEnvelope> = { + version: number; + domain: RealtimeDomain; + action: string; + requestId: string; + payload: TPayload; +}; + +export type ErrorPayload = { code: string; message: string }; + +export type ResponseEnvelope = { + version: number; + domain: RealtimeDomain; + type: string; + requestId?: string; + data?: TData; + // 에러 데이터가 data 대신 별도 error 필드로 올 가능성 대비 + error?: ErrorPayload; + timestamp: string; +}; + +type MsgListener = (msg: ResponseEnvelope) => void; +const listeners = new Set(); + +export const addRealtimeListener = (fn: MsgListener) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; +}; + +type OpenListener = () => void; +type CloseListener = (ev?: CloseEvent) => void; +const openListeners = new Set(); +const closeListeners = new Set(); + +export const addRealtimeOpenListener = (fn: OpenListener) => { + openListeners.add(fn); + return () => { + openListeners.delete(fn); + }; +}; + +export const addRealtimeCloseListener = (fn: CloseListener) => { + closeListeners.add(fn); + return () => { + closeListeners.delete(fn); + }; +}; + +type PendingEntry = { + resolve: (msg: ResponseEnvelope) => void; + reject: (err: Error) => void; + timer: number; +}; +const pending = new Map(); +const REQUEST_TIMEOUT_MS = 10000; + +let reconnectTimer: number | null = null; +let reconnectAttempt = 0; +let isManualClose = false; +let connectPromise: Promise | null = null; + +const WS_ORIGIN = ( + import.meta.env.VITE_WS_ORIGIN ?? window.location.origin +).replace(/\/$/, ""); +const WS_REALTIME_PATH = ( + import.meta.env.VITE_WS_REALTIME_PATH ?? "/ws/realtime" +).replace(/\/$/, ""); + +const buildWsUrl = (origin?: string) => (origin ?? WS_ORIGIN) + WS_REALTIME_PATH; // SockJS는 http/https 사용 + +const getToken = () => { + const { user } = useUserStore.getState(); + return user?.accessToken ?? localStorage.getItem("accessToken") ?? ""; +}; +const hasToken = () => !!getToken(); + +const genRequestId = () => + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : `req-${Date.now()}-${Math.random().toString(36).slice(2)}`; + +const rejectAllPending = (reason: string) => { + pending.forEach(({ reject, timer }) => { + window.clearTimeout(timer); + reject(new Error(reason)); + }); + pending.clear(); +}; + +export const connectRealtimeWs = async ({ + origin, +}: { origin?: string } = {}) => { + if (!hasToken()) { + return null; + } + + if ( + ws && + (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) + ) { + return ws; + } + + if (connectPromise) return connectPromise; + + connectPromise = (async () => { + const url = new URL(buildWsUrl(origin)); + url.searchParams.set("token", getToken()); + + // SockJS dynamic import (초기 번들에서 제외) + const { default: SockJS } = await import("sockjs-client"); + const sock = new SockJS(url.toString()); + ws = sock as WebSocket; + + sock.onopen = () => { + reconnectAttempt = 0; + isManualClose = false; + openListeners.forEach(fn => { + try { + fn(); + } catch { + // 개별 리스너 오류가 다른 리스너로 전파되지 않도록 무시 + } + }); + }; + + sock.onmessage = (e: MessageEvent) => { + try { + const parsed: ResponseEnvelope = JSON.parse(e.data); + + if (parsed.requestId && pending.has(parsed.requestId)) { + const entry = pending.get(parsed.requestId)!; + window.clearTimeout(entry.timer); + pending.delete(parsed.requestId); + if (parsed.type === "ERROR") { + const err = parsed.error ?? (parsed.data as ErrorPayload | undefined); + entry.reject(new Error(err?.message ?? "REALTIME_WS_ERROR")); + } else { + entry.resolve(parsed); + } + } + + // 요청자 응답 여부와 무관하게 도메인별 구독자에게도 전달 + listeners.forEach(fn => { + try { + fn(parsed); + } catch { + // 개별 리스너 오류가 다른 리스너로 전파되지 않도록 무시 + } + }); + } catch { + // JSON이 아닌 메시지는 무시 + } + }; + + sock.onerror = () => {}; + + sock.onclose = (ev: CloseEvent) => { + rejectAllPending("REALTIME_WS_CLOSED"); + closeListeners.forEach(fn => { + try { + fn(ev); + } catch { + // 개별 리스너 오류가 다른 리스너로 전파되지 않도록 무시 + } + }); + ws = null; + connectPromise = null; + + if (isManualClose) { + return; + } + + if (!hasToken()) return; + + if (!reconnectTimer) { + const delay = Math.min(500 * 2 ** reconnectAttempt, 8000); + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + reconnectAttempt++; + connectRealtimeWs({ origin }); + }, delay); + } + }; + + return ws!; + })(); + + const result = await connectPromise; + connectPromise = null; + return result; +}; + +export const disconnectRealtimeWs = () => { + if ( + ws && + (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) + ) { + isManualClose = true; + ws.close(); + } + ws = null; + rejectAllPending("REALTIME_WS_DISCONNECTED"); +}; + +export const realtimeWsState = () => ws?.readyState; // 0/1/2/3 +export const isRealtimeWsOpen = () => ws?.readyState === WebSocket.OPEN; + +// requestId 기반 요청 → 매칭되는 응답(or ERROR)을 Promise로 반환 +export const sendRealtimeRequest = < + TData = unknown, + TPayload = Record, +>( + domain: RealtimeDomain, + action: string, + payload: TPayload, +): Promise> => { + if (!ws || ws.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("REALTIME_WS_NOT_OPEN")); + } + + const requestId = genRequestId(); + const envelope: RequestEnvelope = { + version: REALTIME_PROTOCOL_VERSION, + domain, + action, + requestId, + payload, + }; + + return new Promise>((resolve, reject) => { + const timer = window.setTimeout(() => { + pending.delete(requestId); + reject(new Error("REALTIME_WS_TIMEOUT")); + }, REQUEST_TIMEOUT_MS); + + pending.set(requestId, { + resolve: resolve as (msg: ResponseEnvelope) => void, + reject, + timer, + }); + + ws!.send(JSON.stringify(envelope)); + }); +}; + +// 응답(ack)을 기다리지 않는 전송. ack/브로드캐스트는 addRealtimeListener로 별도 수신한다. +export const sendRealtimeFireAndForget = ( + domain: RealtimeDomain, + action: string, + payload: Record, +) => { + if (!ws || ws.readyState !== WebSocket.OPEN) { + return false; + } + const envelope: RequestEnvelope = { + version: REALTIME_PROTOCOL_VERSION, + domain, + action, + requestId: genRequestId(), + payload, + }; + ws.send(JSON.stringify(envelope)); + return true; +}; diff --git a/src/assets/icons/sparkle_filled.svg b/src/assets/icons/sparkle_filled.svg new file mode 100644 index 00000000..57318ec8 --- /dev/null +++ b/src/assets/icons/sparkle_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/common/contentcard/ContentCardL.tsx b/src/components/common/contentcard/ContentCardL.tsx index 8f4cf516..06195108 100644 --- a/src/components/common/contentcard/ContentCardL.tsx +++ b/src/components/common/contentcard/ContentCardL.tsx @@ -138,7 +138,7 @@ export const ContentCardL = ({ await queryClient.invalidateQueries({ queryKey: ["partyCalendar"] }); await queryClient.invalidateQueries({ queryKey: ["partyDetail"] }); } catch (err) { - console.log("운동 신청, 취소 오류: ", err); + console.error("운동 신청, 취소 오류: ", err); setIsStarted(originalIsJoined); } finally { setIsLoading(false); diff --git a/src/components/common/contentcard/Member.tsx b/src/components/common/contentcard/Member.tsx index 4400e6a5..65b25efe 100644 --- a/src/components/common/contentcard/Member.tsx +++ b/src/components/common/contentcard/Member.tsx @@ -106,6 +106,7 @@ const MemberInfo = ({ isGuest, guestName, isLeader, + isManager, position, }: Partial) => { const formattedDate = lastExerciseDate?.replace(/-/g, "."); @@ -118,6 +119,11 @@ const MemberInfo = ({ {!isLeader && position === "sub_leader" && ( )} + {isManager && ( + + ★ 게임 진행자 + + )}
{gender === "FEMALE" ? ( @@ -168,6 +174,7 @@ const ListMemberLayout = ({ onAppointClick, useDeleteModal, onDelete, + isManager, } = props; const getNumberText = () => { @@ -204,7 +211,7 @@ const ListMemberLayout = ({ /> - {selectMode && !isLeader && ( + {selectMode && !isLeader && !isManager && ( { diff --git a/src/components/common/system/header/MainHeader.tsx b/src/components/common/system/header/MainHeader.tsx index 6a41c7b7..ad689f73 100644 --- a/src/components/common/system/header/MainHeader.tsx +++ b/src/components/common/system/header/MainHeader.tsx @@ -37,7 +37,7 @@ export const MainHeader = ({ setHasNotification(data.data.existNewNotification); } catch (err) { - console.log("알람 존재여부 api 오류: ", err); + console.error("알람 존재여부 api 오류: ", err); } }; diff --git a/src/components/home/MyExerciseCalendar.tsx b/src/components/home/MyExerciseCalendar.tsx index 23fff96d..2903b95b 100644 --- a/src/components/home/MyExerciseCalendar.tsx +++ b/src/components/home/MyExerciseCalendar.tsx @@ -195,7 +195,7 @@ export const MyExerciseCalendar = ({ setCount }: MyExerciseCalendarProps) => { setIsInitialLoading(false); } } catch (err) { - console.log(err); + console.error(err); setIsInitialLoading(false); } finally { inFlightRef.current = false; diff --git a/src/firebase.ts b/src/firebase.ts index 84864166..17531714 100644 --- a/src/firebase.ts +++ b/src/firebase.ts @@ -29,17 +29,13 @@ export async function requestFcmToken(): Promise { return null; } - console.log("[FCM] 현재 권한 상태:", Notification.permission); - if (Notification.permission === "denied") { console.warn("[FCM] 알림 권한이 차단(denied) 상태입니다."); return null; } if (Notification.permission === "default") { - console.log("[FCM] 권한 요청 팝업 호출"); const permission = await Notification.requestPermission(); - console.log("[FCM] 권한 요청 결과:", permission); if (permission !== "granted") { return null; } @@ -55,7 +51,6 @@ export async function requestFcmToken(): Promise { }); if (token) { - console.log("[FCM] 토큰 발급 성공"); await sendTokenToServer(token); } else { console.warn("[FCM] 토큰 발급 실패(빈 토큰)"); diff --git a/src/hooks/useFcmToken.ts b/src/hooks/useFcmToken.ts index 05fba94a..c59dc35d 100644 --- a/src/hooks/useFcmToken.ts +++ b/src/hooks/useFcmToken.ts @@ -12,11 +12,7 @@ export function useFcmToken() { return; } - console.log("[FCM] useFcmToken mounted. permission:", Notification.permission); - - const unsubscribe = onMessage(messaging, (payload) => { - console.log("[FCM] 포그라운드 알림 수신:", payload); - }); + const unsubscribe = onMessage(messaging, () => {}); const cleanupUserGestureListeners = () => { document.removeEventListener("click", handleFirstGesture); @@ -25,7 +21,6 @@ export function useFcmToken() { }; const handleFirstGesture = () => { - console.log("[FCM] 사용자 제스처 감지 -> 토큰 등록 시도"); cleanupUserGestureListeners(); registerToken(); }; @@ -38,12 +33,9 @@ export function useFcmToken() { document.addEventListener("click", handleFirstGesture); document.addEventListener("touchstart", handleFirstGesture); document.addEventListener("keydown", handleFirstGesture); - } else { - console.info("[FCM] 알림 권한이 차단되어 있습니다. 브라우저 설정에서 허용이 필요합니다."); } return () => { - console.log("[FCM] useFcmToken cleanup"); cleanupUserGestureListeners(); unsubscribe(); }; diff --git a/src/hooks/useGameWs.ts b/src/hooks/useGameWs.ts new file mode 100644 index 00000000..a83ca512 --- /dev/null +++ b/src/hooks/useGameWs.ts @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from "react"; +import { + connectGameWs, + disconnectGameWs, + subscribeGameBoard, + unsubscribeGameBoard, + createGameWS, + startGameWS, + completeGameWS, + deleteGameWS, + moveCourtWS, + moveToWaitingWS, + addGameWsListener, + addGameWsOpenListener, + addGameWsCloseListener, + isGameWsOpen, + type GameResponseEnvelope, +} from "../api/game/rawWs"; +import useUserStore from "../store/useUserStore"; + +// 게임판 화면 진입 시 연결하고, 이탈 시 해당 보드를 구독 해제한다. +// (소켓 연결 자체는 다른 게임판 화면과 공유될 수 있어 연결 종료는 하지 않음) +export const useGameWs = (opts: { gameBoardId?: number; origin?: string } = {}) => { + const [isOpen, setIsOpen] = useState(false); + const [lastMessage, setLastMessage] = useState( + null, + ); + const mounted = useRef(false); + + const token = + useUserStore(s => s.user?.accessToken) ?? + localStorage.getItem("accessToken") ?? + ""; + + useEffect(() => { + mounted.current = true; + + if (!token) { + setIsOpen(false); + disconnectGameWs(); + return () => { + mounted.current = false; + }; + } + + connectGameWs({ origin: opts.origin }); + + if (isGameWsOpen()) setIsOpen(true); + const offOpen = addGameWsOpenListener(() => mounted.current && setIsOpen(true)); + const offClose = addGameWsCloseListener( + () => mounted.current && setIsOpen(false), + ); + const offMsg = addGameWsListener(msg => { + if (!mounted.current) return; + setLastMessage(msg); + }); + + return () => { + mounted.current = false; + offOpen(); + offClose(); + offMsg(); + }; + }, [token, opts.origin]); + + // 보드 구독/해제: 소켓 open 이후에 시도, 언마운트 시 자동 해제 + useEffect(() => { + if (!isOpen || !opts.gameBoardId) return; + + subscribeGameBoard(opts.gameBoardId).catch(() => {}); + + return () => { + unsubscribeGameBoard(opts.gameBoardId!).catch(() => {}); + }; + }, [isOpen, opts.gameBoardId]); + + return { + isOpen, + lastMessage, + createGame: createGameWS, + startGame: startGameWS, + completeGame: completeGameWS, + deleteGame: deleteGameWS, + moveCourt: moveCourtWS, + moveToWaiting: moveToWaitingWS, + }; +}; diff --git a/src/hooks/useRawWsConnect.ts b/src/hooks/useRawWsConnect.ts index 695900ca..3ad9260e 100644 --- a/src/hooks/useRawWsConnect.ts +++ b/src/hooks/useRawWsConnect.ts @@ -78,15 +78,6 @@ export const useRawWsConnect = (opts: { unreadCount: m.newUnreadCount ?? 0, }); } - - // 해제 ACK 로깅 - if ( - (msg.type === "UNSUBSCRIBE" || msg.type === "SUBSCRIBE") && - "message" in msg && - "chatRoomId" in msg - ) { - console.log(`[WS] ${msg.type} ACK #${msg.chatRoomId}: ${msg.message}`); - } }); return () => { diff --git a/src/hooks/useSeedChatUnread.ts b/src/hooks/useSeedChatUnread.ts new file mode 100644 index 00000000..fd16a827 --- /dev/null +++ b/src/hooks/useSeedChatUnread.ts @@ -0,0 +1,53 @@ +import { useEffect } from "react"; +import { + getGroupChatRooms, + getPersonalChatRooms, +} from "../api/chat/chatList"; +import { useChatWsStore } from "../store/useChatWsStore"; +import useUserStore from "../store/useUserStore"; +import { resolveMemberId } from "../utils/auth"; + +// [임시 조치] 하단 네비 채팅 안읽음 뱃지는 +// 1) UNREAD_STATUS_UPDATE 웹소켓 push (접속 이후 상태가 "변할 때"만 옴) +// 2) 채팅 탭(ChatPage)에서만 채워지던 useChatWsStore.meta +// 두 가지에만 의존한다. 그래서 채팅 탭을 한 번도 안 들어가면, 앱을 켜기 전에 쌓여 있던 +// 안읽음이 메인 화면 뱃지에 반영되지 않는다 (채팅 탭 진입 후에야 뜸). +// +// 근본 해결은 서버가 소켓 접속/구독 직후 현재 안읽음 상태를 1회 push하는 것. +// 그 전까지의 임시 방편으로, 앱 진입 시(로그인 상태) 채팅방 목록을 한 번 조회해 +// store를 seed 한다. 서버 push가 적용되면 이 훅은 제거 가능. +let seededFor: number | null = null; + +export const useSeedChatUnread = () => { + const hydrateFromAPI = useChatWsStore(s => s.hydrateFromAPI); + const token = + useUserStore(s => s.user?.accessToken) ?? + localStorage.getItem("accessToken") ?? + ""; + const memberId = resolveMemberId() ?? 0; + + useEffect(() => { + if (!token || !memberId || seededFor === memberId) return; + seededFor = memberId; + + (async () => { + try { + const [groupRes, personalRes] = await Promise.all([ + getGroupChatRooms(), + getPersonalChatRooms(), + ]); + hydrateFromAPI( + [...groupRes.content, ...personalRes.content].map(r => ({ + chatRoomId: r.chatRoomId, + lastMessage: r.lastMessage?.content ?? null, + timestamp: r.lastMessage?.timestamp ?? null, + unreadCount: r.unreadCount ?? 0, + })), + ); + } catch (err) { + seededFor = null; // 실패 시 다음 기회에 재시도 + console.error("[unread] 초기 안읽음 상태 seed 실패", err); + } + })(); + }, [token, memberId, hydrateFromAPI]); +}; diff --git a/src/layout/GroupLayout.tsx b/src/layout/GroupLayout.tsx index f6d19d31..f7ce4785 100644 --- a/src/layout/GroupLayout.tsx +++ b/src/layout/GroupLayout.tsx @@ -147,7 +147,7 @@ export const GroupLayout = () => { }, }); } catch (err) { - console.log(err); + console.error(err); } }; diff --git a/src/layout/PrivateRoute.tsx b/src/layout/PrivateRoute.tsx index fd82e7d1..f3f57e77 100644 --- a/src/layout/PrivateRoute.tsx +++ b/src/layout/PrivateRoute.tsx @@ -10,6 +10,7 @@ interface PrivateRouteProps { export const PrivateRoute = ({ children }: PrivateRouteProps) => { const navigate = useNavigate(); const isAuthenticated = localStorage.getItem("accessToken"); + useEffect(() => { if (!isAuthenticated) { navigate("/login"); diff --git a/src/main.tsx b/src/main.tsx index bcdd3fa7..ae52ba30 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -22,9 +22,6 @@ if ("serviceWorker" in navigator) { .finally(() => { navigator.serviceWorker .register("/firebase-messaging-sw.js") - .then(() => { - console.log("[FCM] firebase-messaging-sw 등록 완료"); - }) .catch((err) => { console.error("Service Worker 등록 실패:", err); }); diff --git a/src/pages/alarm/AlertPage.tsx b/src/pages/alarm/AlertPage.tsx index c0cafd4f..2a0eec21 100644 --- a/src/pages/alarm/AlertPage.tsx +++ b/src/pages/alarm/AlertPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import AlertInvite from "../../components/common/contentcard/alertTest/AlertInvite"; @@ -13,14 +13,17 @@ import api from "../../api/api"; import { PageHeader } from "../../components/common/system/header/PageHeader"; import { EmptyState } from "../../components/alert/EmptyState"; import AlertTest1 from "../../components/common/contentcard/alertTest/AlertTest1"; -import type { AlertListResponse, ResponseAlertDto } from "../../types/alert"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AlertListResponse, ResponseAlertDto, AlertListPage } from "../../types/alert"; +import { useMutation, useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { AlertSkeleton } from "../../components/alert/AlertSkeleton"; import DefaultGroupImg from "@/assets/icons/defaultGroupImg.svg?url"; -const fetchNotifications = async (): Promise => { - const response = await api.get("/api/notifications"); - return response.data.data.notifications; +const fetchNotifications = async ({ pageParam = null }: { pageParam?: number | null }): Promise => { + const cursorParam = pageParam ? `&cursor=${pageParam}` : ""; + const response = await api.get( + `/api/v2/notifications?destination=APP${cursorParam}` + ); + return response.data.data; }; export const AlertPage = () => { @@ -31,20 +34,43 @@ export const AlertPage = () => { const [modalType, setModalType] = useState<"approve" | "reject" | null>(null); const { - data: notifications = [], + data, + fetchNextPage, + hasNextPage, isLoading, isError, - } = useQuery({ + } = useInfiniteQuery({ queryKey: ["notifications"], queryFn: fetchNotifications, + initialPageParam: null as number | null, + getNextPageParam: (lastPage) => (lastPage.hasNext ? lastPage.nextCursor : undefined), staleTime: 1000 * 60, }); + const notifications = data?.pages.flatMap(page => page.notifications) || []; + // INVITE/CHANGE/SIMPLE 만 노출 const visibleNotifications = notifications.filter(alert => ["INVITE", "CHANGE", "SIMPLE"].includes(alert.type), ); + const observerRef = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + entries => { + if (entries[0].isIntersecting && hasNextPage && !isLoading) { + fetchNextPage(); + } + }, + { threshold: 0.1 }, + ); + if (observerRef.current) { + observer.observe(observerRef.current); + } + return () => observer.disconnect(); + }, [hasNextPage, fetchNextPage, isLoading]); + const handleAccept = (id: number) => { setTargetId(id); setModalType("approve"); @@ -56,8 +82,6 @@ export const AlertPage = () => { }; const handleDetail = (partyId: number, data?: ResponseAlertDto["data"]) => { - console.log("모임 페이지로 이동", partyId); - if (data?.exerciseDate && data?.exerciseId) { navigate(`/group/${partyId}`, { state: { @@ -92,10 +116,8 @@ export const AlertPage = () => { // 🌟CHANGE/SIMPLE 읽음 처리 공통 뮤테이션 const markReadMutation = useMutation({ mutationFn: async (notification: ResponseAlertDto) => { - const { notificationId, type } = notification; - - // 현재 구현처럼 쿼리스트링로 전송 - await api.patch(`/api/notifications/${notificationId}?type=${type}`); + const { notificationId } = notification; + await api.patch(`/api/v2/notifications/${notificationId}/read`); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["notifications"] }); @@ -110,19 +132,13 @@ export const AlertPage = () => { const { notificationId, partyId } = notification; const invitationId = extractInvitationId(notification.data); - // 알림 상태 수정 (INVITE → INVITE_ACCEPT) - await api.patch( - `/api/notifications/${notificationId}?type=INVITE_ACCEPT`, - ); + // V2: 알림 읽음 처리만 하고, 수락은 초대 API로 별도 호출 + await api.patch(`/api/v2/notifications/${notificationId}/read`); if (partyId && invitationId) { await api.patch(`/api/parties/invitations/${invitationId}`, { action: "APPROVE", }); - } else if (partyId && !invitationId) { - console.log("모임 있지만 invitationId 없음"); - } else { - console.log("모임도 없고 invitationId도 없음"); } }, onSuccess: () => { @@ -138,9 +154,8 @@ export const AlertPage = () => { mutationFn: async (notification: ResponseAlertDto) => { const { notificationId, data } = notification; - await api.patch( - `/api/notifications/${notificationId}?type=INVITE_REJECT`, - ); + // V2: 알림 읽음 처리만 하고, 거절은 초대 API로 별도 호출 + await api.patch(`/api/v2/notifications/${notificationId}/read`); if (data?.invitationId) { await api.patch(`/api/parties/invitations/${data.invitationId}`, { @@ -218,6 +233,7 @@ export const AlertPage = () => { ), ) )} + {hasNextPage &&
}
{modalType === "approve" && selectedAlert && ( diff --git a/src/pages/group/CreateExercise.tsx b/src/pages/group/CreateExercise.tsx index ccf4b626..81b85dc6 100644 --- a/src/pages/group/CreateExercise.tsx +++ b/src/pages/group/CreateExercise.tsx @@ -8,7 +8,7 @@ import RedCircle from "@/assets/icons/cicle_s_red.svg?react"; import { DropBox } from "../../components/common/DropBox"; import { TitleBtn } from "../../components/group/main/create_exercise/TitleBtn"; import { TextField } from "../../components/group/main/create_exercise/TextField"; -import GR400_L from "../../components/common/Btn_Static/Text/GR400_L"; +import Grad_GR400_L from "../../components/common/Btn_Static/Text/Grad_GR400_L"; import { LocationField } from "../../components/common/LocationField"; import { useLocation, @@ -272,13 +272,15 @@ export const CreateExercise = () => {
-
+
+ +
{errorMsg && (

{errorMsg}

)} - { currentCount={exercise.currentParticipants} totalCount={exercise.maxCapacity} like={exercise.isBookmarked} - onToggleFavorite={id => console.log(`Toggled favorite: ${id}`)} />
)) diff --git a/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx b/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx new file mode 100644 index 00000000..fc522b5e --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/CourtCard.tsx @@ -0,0 +1,416 @@ +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type TouchEvent as ReactTouchEvent, +} from "react"; +import { createPortal } from "react-dom"; +import clsx from "clsx"; +import { useDraggable, useDroppable } from "@dnd-kit/core"; +import Pen from "@/assets/icons/pen.svg"; +import Reject from "@/assets/icons/reject.svg"; +import type { GamePlayer } from "./mockGameBoardData"; + +export const courtDroppableId = (courtId: number) => `court-${courtId}`; +export const waitingDraggableId = (waitingGroupId: number) => + `waiting-${waitingGroupId}`; + +// 대기/코트 뱃지에서는 "급수 없음"을 "없음"으로만 표기한다. +const formatBadgeGroup = (group: string) => + group.replace(/^급수\s*없음$/, "없음"); + +export const PlayerBadge = ({ name, group, color }: GamePlayer) => ( +
+ + {name} + + + {formatBadgeGroup(group)} + +
+); + +interface CourtCardProps { + courtId: number; + label: string; + timer?: string; + players: GamePlayer[] | null; + onComplete?: () => void; + onReturnToWaiting?: () => void; + onCancelGame?: () => void; +} + +export const CourtCard = ({ + courtId, + label, + timer, + players, + onComplete, + onReturnToWaiting, + onCancelGame, +}: CourtCardProps) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); + const cardRef = useRef(null); + const menuRef = useRef(null); + const longPressTimer = useRef | null>(null); + const suppressNextClick = useRef(false); + const { setNodeRef: setDropRef, isOver } = useDroppable({ + id: courtDroppableId(courtId), + }); + + useEffect(() => { + if (!isMenuOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + if (cardRef.current?.contains(e.target as Node)) { + suppressNextClick.current = true; + } + setIsMenuOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isMenuOpen]); + + const openMenuAt = (x: number, y: number) => { + setMenuPosition(clampMenuPosition(x, y, MENU_WIDTH, MENU_HEIGHT)); + setIsMenuOpen(true); + }; + + const clearLongPressTimer = () => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }; + + const handleContextMenu = (e: ReactMouseEvent) => { + e.preventDefault(); + openMenuAt(e.clientX, e.clientY); + }; + + const handleTouchStart = (e: ReactTouchEvent) => { + const touch = e.touches[0]; + clearLongPressTimer(); + longPressTimer.current = setTimeout(() => { + openMenuAt(touch.clientX, touch.clientY); + }, LONG_PRESS_MS); + }; + + if (!players) { + return ( +
+
+ {label} +
+
+ 빈 코트 +
+
+ ); + } + + return ( +
{ + cardRef.current = node; + setDropRef(node); + }} + className={clsx( + "flex w-[12.5rem] shrink-0 cursor-pointer flex-col gap-2 rounded-2xl bg-white p-2 shadow-ds100 transition-colors", + isOver && "ring-2 ring-gr-500", + )} + onClick={() => { + if (suppressNextClick.current) { + suppressNextClick.current = false; + return; + } + onReturnToWaiting?.(); + }} + onContextMenu={handleContextMenu} + onTouchStart={handleTouchStart} + onTouchEnd={clearLongPressTimer} + onTouchMove={clearLongPressTimer} + > +
+
+ {label} + {timer && {timer}} +
+ +
+
+ {players.map(p => ( + + ))} +
+ + {isMenuOpen && + createPortal( +
e.stopPropagation()} + > + +
+ +
, + document.body, + )} +
+ ); +}; + +interface WaitingCardProps { + waitingGroupId: number; + label: string; + players: GamePlayer[]; + courts: { id: number; label: string }[]; + onMoveToCourt?: (courtId: number) => void; + onChange?: () => void; + onReject?: () => void; +} + +const LONG_PRESS_MS = 600; +const MENU_WIDTH = 149; +const MENU_HEIGHT = 84; +const MENU_EDGE_MARGIN = 8; + +const clampMenuPosition = ( + x: number, + y: number, + width: number, + height: number, +) => ({ + left: Math.min( + Math.max(x - width, MENU_EDGE_MARGIN), + window.innerWidth - width - MENU_EDGE_MARGIN, + ), + top: Math.min( + Math.max(y, MENU_EDGE_MARGIN), + window.innerHeight - height - MENU_EDGE_MARGIN, + ), +}); + +export const WaitingCard = ({ + waitingGroupId, + label, + players, + courts, + onMoveToCourt, + onChange, + onReject, +}: WaitingCardProps) => { + const { + attributes: dragAttributes, + listeners: dragListeners, + setNodeRef: setDragRef, + isDragging, + } = useDraggable({ id: waitingDraggableId(waitingGroupId) }); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); + const [clickPoint, setClickPoint] = useState<{ x: number; y: number } | null>( + null, + ); + const cardRef = useRef(null); + const menuRef = useRef(null); + const longPressTimer = useRef | null>(null); + + useEffect(() => { + if (!isMenuOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if ( + menuRef.current && + !menuRef.current.contains(e.target as Node) && + !cardRef.current?.contains(e.target as Node) + ) { + setIsMenuOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isMenuOpen]); + + // 코트 개수에 따라 메뉴 높이가 달라지므로 렌더된 실제 크기를 측정해 위치를 보정한다. + // clickPoint는 openMenuAt마다 새 객체로 갱신되어, 메뉴가 이미 열려 있는 상태에서 + // 같은 카드를 다시 우클릭/롱프레스해도 이 effect가 다시 실행된다. + useLayoutEffect(() => { + if (!isMenuOpen || !clickPoint || !menuRef.current) return; + const rect = menuRef.current.getBoundingClientRect(); + const corrected = clampMenuPosition( + clickPoint.x, + clickPoint.y, + rect.width, + rect.height, + ); + setMenuPosition(prev => + prev.top === corrected.top && prev.left === corrected.left + ? prev + : corrected, + ); + }, [isMenuOpen, clickPoint, courts.length]); + + const openMenuAt = (x: number, y: number) => { + setClickPoint({ x, y }); + setMenuPosition(clampMenuPosition(x, y, MENU_WIDTH, 0)); + setIsMenuOpen(true); + }; + + const clearLongPressTimer = () => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }; + + const handleContextMenu = (e: ReactMouseEvent) => { + e.preventDefault(); + openMenuAt(e.clientX, e.clientY); + }; + + const handleTouchStart = (e: ReactTouchEvent) => { + const touch = e.touches[0]; + clearLongPressTimer(); + longPressTimer.current = setTimeout(() => { + openMenuAt(touch.clientX, touch.clientY); + }, LONG_PRESS_MS); + }; + + return ( +
{ + cardRef.current = node; + setDragRef(node); + }} + className={clsx( + "flex w-[12.5rem] shrink-0 touch-pan-x flex-col gap-2 rounded-2xl bg-white p-2 shadow-ds100", + isDragging && "opacity-40", + )} + onContextMenu={handleContextMenu} + onTouchStart={handleTouchStart} + onTouchEnd={clearLongPressTimer} + onTouchMove={clearLongPressTimer} + {...dragAttributes} + {...dragListeners} + > +
+ {label} +
+ + +
+
+
+ {players.map(p => ( + + ))} +
+ + {isMenuOpen && + createPortal( +
+ {courts.map((court, index) => ( +
+ {index > 0 &&
} + +
+ ))} +
+ + +
, + document.body, + )} +
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/CourtManageBottomSheet.tsx b/src/pages/group/MyGroupDetail/GameBoard/CourtManageBottomSheet.tsx new file mode 100644 index 00000000..4d871c3f --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/CourtManageBottomSheet.tsx @@ -0,0 +1,139 @@ +import { useState } from "react"; +import clsx from "clsx"; +import Dismiss from "@/assets/icons/dismiss.svg"; +import Add from "@/assets/icons/add.svg"; +import { useLockBodyScroll } from "@/hooks/useLockBodyScroll"; +import GR400_L from "@/components/common/Btn_Static/Text/GR400_L"; +import GR400_M from "@/components/common/Btn_Static/Text/GR400_M"; + +export interface CourtManageItem { + courtId?: number; // 있으면 기존 코트(이름 변경), 없으면 신규 생성 + courtName: string; +} + +interface CourtManageBottomSheetProps { + variant?: "sheet" | "overlay"; + courts: CourtManageItem[]; + onClose: () => void; + onSave: (courts: CourtManageItem[]) => void; +} + +export const CourtManageBottomSheet = ({ + variant = "sheet", + courts, + onClose, + onSave, +}: CourtManageBottomSheetProps) => { + useLockBodyScroll(true); + const [items, setItems] = useState(courts); + const [newLabel, setNewLabel] = useState(""); + const [isNewLabelFocused, setIsNewLabelFocused] = useState(false); + + const handleAdd = () => { + const trimmed = newLabel.trim(); + if (!trimmed) return; + setItems(prev => [...prev, { courtName: trimmed }]); + setNewLabel(""); + }; + + const handleRemove = (index: number) => { + setItems(prev => prev.filter((_, i) => i !== index)); + }; + + const handleSave = () => { + const trimmed = newLabel.trim(); + onSave(trimmed ? [...items, { courtName: trimmed }] : items); + onClose(); + }; + + const isOverlay = variant === "overlay"; + + const content = ( + <> + 코트 관리 + +
+ {items.map((item, index) => ( +
+ + {item.courtName} + + {index !== 0 && ( + + )} +
+ ))} + +
+ setNewLabel(e.target.value)} + onFocus={() => setIsNewLabelFocused(true)} + onBlur={() => setIsNewLabelFocused(false)} + onKeyDown={e => { + if (e.key === "Enter") { + e.preventDefault(); + handleAdd(); + } + }} + className={clsx( + "w-full rounded-xl border px-3 py-2.5 body-md-500 text-black outline-none", + isNewLabelFocused ? "border-[#87c95e]" : "border-gy-200", + )} + /> + +
+
+ +
+ {isOverlay ? ( + + ) : ( + + )} +
+ + ); + + if (isOverlay) { + return ( +
+
e.stopPropagation()} + > + {content} +
+
+ ); + } + + return ( +
+
e.stopPropagation()} + > + {content} +
+
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameAddPlayerModal.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameAddPlayerModal.tsx new file mode 100644 index 00000000..a6cbea92 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameAddPlayerModal.tsx @@ -0,0 +1,143 @@ +import { useState } from "react"; +import { PageHeader } from "@/components/common/system/header/PageHeader"; +import InputField from "@/components/common/Search_Filed/InputField"; +import TextBox from "@/components/common/Text_Box/TextBox"; +import DropCheckBox from "@/components/common/Drop_Box/DropCheckBox"; +import { DropBox } from "@/components/common/DropBox"; +import Grad_GR400_L from "@/components/common/Btn_Static/Text/Grad_GR400_L"; +import GR400_M from "@/components/common/Btn_Static/Text/GR400_M"; +import Circle_RedIcon from "@/assets/icons/cicle_s_red.svg?url"; +import { LEVEL_KEY } from "@/constants/options"; +import { useLockBodyScroll } from "@/hooks/useLockBodyScroll"; + +const AGE_OPTIONS = ["10대", "20대", "30대", "40대", "50대", "60대 이상"]; + +export interface NewGamePlayer { + name: string; + gender: "MALE" | "FEMALE"; + level: string; + ageGroup: string; +} + +interface GameAddPlayerModalProps { + variant?: "sheet" | "overlay"; + onClose: () => void; + onSubmit: (player: NewGamePlayer) => void; +} + +export const GameAddPlayerModal = ({ + variant = "sheet", + onClose, + onSubmit, +}: GameAddPlayerModalProps) => { + useLockBodyScroll(true); + const [name, setName] = useState(""); + const [gender, setGender] = useState<"MALE" | "FEMALE" | null>(null); + const [level, setLevel] = useState(""); + const [ageGroup, setAgeGroup] = useState(""); + + const levelOptions = LEVEL_KEY.slice(1); + const isLevelValid = level === "disabled" || levelOptions.includes(level); + const isFormValid = name.trim().length > 0 && gender !== null && isLevelValid; + + const handleSubmit = () => { + if (!isFormValid || gender === null) return; + onSubmit({ + name: name.trim(), + gender, + level: level === "disabled" ? "급수없음" : level, + ageGroup, + }); + }; + + const fields = ( + <> + setName(e.target.value.slice(0, 17))} + /> + +
+
+ 성별 + +
+
+ setGender(prev => (prev === "MALE" ? null : "MALE"))} + className="w-19" + > + 남성 + + + setGender(prev => (prev === "FEMALE" ? null : "FEMALE")) + } + className="w-19" + > + 여성 + +
+
+ + setLevel(val ?? "")} + /> + +
+ 나이대 + ({ value }))} + value={ageGroup} + onChange={setAgeGroup} + /> +
+ + ); + + if (variant === "overlay") { + return ( +
+
e.stopPropagation()} + > + 명단 추가 +
{fields}
+ +
+
+ ); + } + + return ( +
+ +
{fields}
+ +
+ +
+
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx new file mode 100644 index 00000000..5ce64273 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTab.tsx @@ -0,0 +1,741 @@ +import { useEffect, useState } from "react"; +import clsx from "clsx"; +import { + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import AddWhite from "@/assets/icons/add_white.svg"; +import Sparkle from "@/assets/icons/sparkle_filled.svg"; +import Dismiss from "@/assets/icons/dismiss.svg"; +import FilterBtn from "@/components/common/DynamicBtn/FilterBtn"; +import { getGameBoard, type GameBoardResponse } from "@/api/game/board"; +import { + createGameBoardMember, + getGameBoardMembers, + updateGameBoardMember, + updateGameBoardMemberParticipation, + type GameBoardMember, +} from "@/api/game/members"; +import { updateCourts } from "@/api/game/courts"; +import { postRandomMatch } from "@/api/game/randomMatch"; +import { useGameWs } from "@/hooks/useGameWs"; +import { + CourtCard, + WaitingCard, + PlayerBadge, + courtDroppableId, + waitingDraggableId, +} from "./CourtCard"; +import { GameMemberCard } from "./GameMemberCard"; +import { + type CourtGroup, + type GameMember, + type WaitingGroup, +} from "./mockGameBoardData"; +import { GameAddPlayerModal } from "./GameAddPlayerModal"; +import { + GameEditPlayerModal, + type EditedGamePlayer, +} from "./GameEditPlayerModal"; +import { GameBoardWebView } from "./GameBoardWebView"; +import { GameBoardTabSkeleton } from "./GameBoardTabSkeleton"; +import { GameRandomMatchFailModal } from "./GameRandomMatchFailModal"; +import { + CourtManageBottomSheet, + type CourtManageItem, +} from "./CourtManageBottomSheet"; +import { GameFilterPage } from "./GameFilterPage"; +import { GameEndModal } from "./GameEndModal"; +import { GameDuplicateCheckModal } from "./GameDuplicateCheckModal"; +import { + formatElapsed, + toBoardViewModel, + toGameBoardMemberPayload, + toGameBoardMembersParams, + toGameMember, + type GameBoardMemberFilters, +} from "./gameBoardAdapter"; + +const MAX_SELECTED_MEMBERS = 4; + +interface GameBoardTabProps { + gameBoardId: number; + isManager: boolean; +} + +export const GameBoardTab = ({ gameBoardId, isManager }: GameBoardTabProps) => { + const [isLoading, setIsLoading] = useState(true); + const [selectedIds, setSelectedIds] = useState([]); + const [members, setMembers] = useState([]); + const [courts, setCourts] = useState([]); + const [waitingGroups, setWaitingGroups] = useState([]); + const [isAddPlayerOpen, setIsAddPlayerOpen] = useState(false); + const [editingMemberId, setEditingMemberId] = useState(null); + const [isWebViewOpen, setIsWebViewOpen] = useState(false); + const [isFilterOpen, setIsFilterOpen] = useState(false); + const [isDuplicateCheckOpen, setIsDuplicateCheckOpen] = useState(false); + const [completingCourtId, setCompletingCourtId] = useState( + null, + ); + const [isRandomMatchFailOpen, setIsRandomMatchFailOpen] = useState(false); + const [courtManageVariant, setCourtManageVariant] = useState< + "sheet" | "overlay" | null + >(null); + const [filters, setFilters] = useState({ + levels: [], + gender: "전체", + shuttle: null, + }); + const [activeDragGroup, setActiveDragGroup] = useState( + null, + ); + + const dndSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + ); + + const gameWs = useGameWs({ gameBoardId }); + + const isFilterActive = + filters.levels.length > 0 || + (filters.gender !== null && filters.gender !== "전체") || + filters.shuttle !== null; + + const applyBoard = (board: GameBoardResponse) => { + const { courts: nextCourts, waitingGroups: nextWaitingGroups } = + toBoardViewModel(board); + setCourts(nextCourts); + setWaitingGroups(nextWaitingGroups); + if (board.courtCount === 0) { + setCourtManageVariant(prev => prev ?? "sheet"); + } + }; + + const applyMembersResponse = (res: { + gameBoardMembers: GameBoardMember[]; + }) => { + setMembers(res.gameBoardMembers.map(toGameMember)); + }; + + const refreshMembers = () => { + getGameBoardMembers(gameBoardId, toGameBoardMembersParams(filters)).then( + applyMembersResponse, + ); + }; + + const handleApplyFilters = (next: GameBoardMemberFilters) => { + setFilters(next); + getGameBoardMembers(gameBoardId, toGameBoardMembersParams(next)).then( + applyMembersResponse, + ); + }; + + useEffect(() => { + setIsLoading(true); + Promise.all([ + getGameBoard(gameBoardId), + getGameBoardMembers(gameBoardId, toGameBoardMembersParams(filters)), + ]) + .then(([board, memberRes]) => { + applyBoard(board); + setMembers(memberRes.gameBoardMembers.map(toGameMember)); + }) + .finally(() => setIsLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gameBoardId]); + + // 진행중인 코트의 경과 시간 스톱워치 (startedAt 기준 1초마다 재계산) + useEffect(() => { + const id = setInterval(() => { + setCourts(prev => + prev.map(c => + c.startedAt ? { ...c, timer: formatElapsed(c.startedAt) } : c, + ), + ); + }, 1000); + return () => clearInterval(id); + }, []); + + // 다른 클라이언트의 변경사항 브로드캐스트 반영 + useEffect(() => { + const msg = gameWs.lastMessage; + if (!msg) return; + + if (msg.type === "BOARD_UPDATED" && msg.data) { + applyBoard(msg.data as GameBoardResponse); + refreshMembers(); + } else if ( + (msg.type === "GAME_CREATED" || msg.type === "GAME_DELETED") && + msg.data && + typeof msg.data === "object" && + "board" in msg.data + ) { + applyBoard((msg.data as { board: GameBoardResponse }).board); + refreshMembers(); + } else if (msg.type === "MEMBERS_UPDATED") { + refreshMembers(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gameWs.lastMessage]); + + const toggleSelect = (id: number) => { + setSelectedIds(prev => { + if (prev.includes(id)) return prev.filter(v => v !== id); + if (prev.length >= MAX_SELECTED_MEMBERS) return prev; + return [...prev, id]; + }); + }; + + const selectedMembers = members.filter(m => selectedIds.includes(m.id)); + const editingMember = members.find(m => m.id === editingMemberId) ?? null; + + const handleSaveMemberEdit = async (updated: EditedGamePlayer) => { + if (editingMemberId === null) return; + const memberId = editingMemberId; + try { + await updateGameBoardMember( + gameBoardId, + memberId, + toGameBoardMemberPayload(updated), + ); + setMembers(prev => + prev.map(m => + m.id === memberId + ? { + ...m, + name: updated.name, + gender: updated.gender, + group: updated.level, + ageGroup: updated.ageGroup, + } + : m, + ), + ); + setEditingMemberId(null); + } catch (err) { + console.error("[GAME] 플레이어 정보 수정 실패", err); + alert("정보 수정에 실패했어요."); + } + }; + + const handleAddPlayer = async (player: { + name: string; + gender: "MALE" | "FEMALE"; + level: string; + ageGroup: string; + }) => { + try { + await createGameBoardMember(gameBoardId, toGameBoardMemberPayload(player)); + refreshMembers(); + setIsAddPlayerOpen(false); + } catch (err) { + console.error("[GAME] 명단 추가 실패", err); + alert("명단 추가에 실패했어요."); + } + }; + + const handleToggleParticipation = async (id: number) => { + const member = members.find(m => m.id === id); + if (!member) return; + const nextParticipating = member.tags.includes("미참"); + try { + await updateGameBoardMemberParticipation( + gameBoardId, + id, + nextParticipating, + ); + setMembers(prev => + prev.map(m => { + if (m.id !== id) return m; + return nextParticipating + ? { + ...m, + tags: m.tags.filter(t => t !== "미참"), + selectable: true, + } + : { ...m, tags: ["미참"], selectable: false }; + }), + ); + setSelectedIds(prev => prev.filter(v => v !== id)); + } catch (err) { + console.error("[GAME] 참여 상태 변경 실패", err); + alert("참여 상태 변경에 실패했어요."); + } + }; + + const handleAddToWaitingQueue = async () => { + if (selectedMembers.length === 0) return; + try { + await gameWs.createGame({ + gameBoardId, + gameBoardMemberIds: selectedMembers.map(m => m.id), + }); + setSelectedIds([]); + } catch (err) { + console.error("[GAME] CREATE_GAME 실패", err); + alert("대기열 추가에 실패했어요."); + } + }; + + const handleChangeWaitingGroup = async (group: WaitingGroup) => { + try { + const res = await gameWs.deleteGame({ + gameBoardId, + gameId: group.gameId, + restore: true, + }); + const restoredIds = res.data?.players.map(p => p.gameBoardMemberId); + setSelectedIds(restoredIds?.length ? restoredIds : group.memberIds); + } catch (err) { + console.error("[GAME] DELETE_GAME(restore) 실패", err); + alert("대기열 변경에 실패했어요."); + } + }; + + const handleRemoveWaitingGroup = async (id: number) => { + const group = waitingGroups.find(g => g.id === id); + if (!group) return; + try { + await gameWs.deleteGame({ + gameBoardId, + gameId: group.gameId, + restore: false, + }); + } catch (err) { + console.error("[GAME] DELETE_GAME 실패", err); + alert("대기열 삭제에 실패했어요."); + } + }; + + const handleMoveToCourt = async (waitingGroupId: number, courtId: number) => { + const group = waitingGroups.find(g => g.id === waitingGroupId); + if (!group) return; + try { + await gameWs.startGame({ gameBoardId, gameId: group.gameId, courtId }); + } catch (err) { + console.error("[GAME] START_GAME 실패", err); + alert("게임 시작에 실패했어요."); + } + }; + + // 경기중인 코트에 대기열 매치를 드래그해 놓았을 때: 기존 매치를 대기로 보내고 그 자리를 새 매치가 차지한다. + const handleSwapCourtMatch = async ( + waitingGroupId: number, + courtId: number, + ) => { + const group = waitingGroups.find(g => g.id === waitingGroupId); + const court = courts.find(c => c.id === courtId); + if (!group || !court?.gameId) return; + try { + await gameWs.moveToWaiting({ gameBoardId, gameId: court.gameId }); + await gameWs.startGame({ gameBoardId, gameId: group.gameId, courtId }); + } catch (err) { + console.error("[GAME] 코트 교체 실패", err); + alert("코트 교체에 실패했어요."); + } + }; + + const handleDragStart = (event: DragStartEvent) => { + const group = waitingGroups.find( + g => waitingDraggableId(g.id) === event.active.id, + ); + setActiveDragGroup(group ?? null); + }; + + const handleDragEnd = (event: DragEndEvent) => { + setActiveDragGroup(null); + const { active, over } = event; + if (!over) return; + const group = waitingGroups.find( + g => waitingDraggableId(g.id) === active.id, + ); + const court = courts.find(c => courtDroppableId(c.id) === over.id); + if (!group || !court) return; + if (court.players) { + handleSwapCourtMatch(group.id, court.id); + } else { + handleMoveToCourt(group.id, court.id); + } + }; + + const handleCompleteCourt = async (courtId: number) => { + const court = courts.find(c => c.id === courtId); + if (!court?.gameId) return; + try { + await gameWs.completeGame({ gameBoardId, gameId: court.gameId }); + } catch (err) { + console.error("[GAME] COMPLETE_GAME 실패", err); + alert("게임 완료에 실패했어요."); + } finally { + setCompletingCourtId(null); + } + }; + + const handleReturnToWaiting = async (courtId: number) => { + const court = courts.find(c => c.id === courtId); + if (!court?.gameId) return; + try { + await gameWs.moveToWaiting({ gameBoardId, gameId: court.gameId }); + } catch (err) { + console.error("[GAME] MOVE_TO_WAITING 실패", err); + alert("대기로 되돌리기에 실패했어요."); + } + }; + + const handleCancelCourtGame = async (courtId: number) => { + const court = courts.find(c => c.id === courtId); + if (!court?.gameId) return; + try { + const res = await gameWs.deleteGame({ + gameBoardId, + gameId: court.gameId, + restore: true, + }); + const restoredIds = res.data?.players.map(p => p.gameBoardMemberId); + setSelectedIds(restoredIds?.length ? restoredIds : []); + } catch (err) { + console.error("[GAME] 경기 취소 실패", err); + alert("경기 취소에 실패했어요."); + } + }; + + const handleAutoMatch = async () => { + try { + const { gameBoardMemberIds } = await postRandomMatch(gameBoardId); + setSelectedIds(gameBoardMemberIds); + } catch (err) { + console.error("[GAME] RANDOM_MATCH 실패", err); + setIsRandomMatchFailOpen(true); + } + }; + + const handleSaveCourts = async (items: CourtManageItem[]) => { + try { + const res = await updateCourts(gameBoardId, { + courts: items.map(item => ({ + courtId: item.courtId, + courtName: item.courtName, + })), + }); + setCourts(prev => + res.courts.map(updatedCourt => { + const existing = prev.find(c => c.id === updatedCourt.courtId); + return { + id: updatedCourt.courtId, + label: updatedCourt.courtName, + gameId: existing?.gameId, + startedAt: existing?.startedAt, + timer: existing?.timer, + players: existing?.players ?? null, + }; + }), + ); + setCourtManageVariant(null); + } catch (err) { + console.error("[GAME] 코트 저장 실패", err); + alert("코트 저장에 실패했어요."); + } + }; + + if (isLoading) { + return ; + } + + return ( + +
+ {/* 게임 코트 */} +
+
+ 게임 코트 + {isManager && ( +
+ + +
+ )} +
+
+
+
+ {courts.map(court => ( + setCompletingCourtId(court.id)} + onReturnToWaiting={() => handleReturnToWaiting(court.id)} + onCancelGame={() => handleCancelCourtGame(court.id)} + /> + ))} +
+
+
+
+ + {/* 대기 */} +
+
+ 대기 +
+ {waitingGroups.length === 0 ? ( +
+ + 대기중인 팀이 없어요 + +
+ ) : ( +
+
+
+ {waitingGroups.map(group => ( + + handleMoveToCourt(group.id, courtId) + } + onChange={() => handleChangeWaitingGroup(group)} + onReject={() => handleRemoveWaitingGroup(group.id)} + /> + ))} +
+
+
+ )} +
+ + {/* 명단 */} +
+
+ 명단 + {isManager && ( + + )} +
+
+ + 전체 {members.length} + + setIsFilterOpen(true)} + > + 필터 + +
+
+ {members.map(member => ( + toggleSelect(member.id)} + onEditInfo={() => setEditingMemberId(member.id)} + onToggleParticipation={() => + handleToggleParticipation(member.id) + } + /> + ))} +
+
+ + {/* 하단 선택 바 */} + {isManager && ( +
+ {selectedMembers.length > 0 && ( +
+ {selectedMembers.map((m, i) => ( + + ))} +
+ )} +
+
+ + {selectedIds.length} + + 선택됨 +
+ + {selectedIds.length > 0 ? ( + + ) : ( + + )} +
+
+ )} + + {isWebViewOpen && ( + setIsDuplicateCheckOpen(true)} + onAutoMatch={handleAutoMatch} + members={members} + selectedIds={selectedIds} + toggleSelect={toggleSelect} + onToggleParticipation={handleToggleParticipation} + onEditMember={setEditingMemberId} + onAddPlayer={() => setIsAddPlayerOpen(true)} + onManageCourts={() => setCourtManageVariant("overlay")} + onOpenFilter={() => setIsFilterOpen(true)} + onClose={() => setIsWebViewOpen(false)} + /> + )} + + {isAddPlayerOpen && ( + setIsAddPlayerOpen(false)} + onSubmit={handleAddPlayer} + /> + )} + + {editingMember && ( + setEditingMemberId(null)} + onSubmit={handleSaveMemberEdit} + /> + )} + + {courtManageVariant && ( + ({ courtId: c.id, courtName: c.label }))} + onClose={() => setCourtManageVariant(null)} + onSave={handleSaveCourts} + /> + )} + + {isFilterOpen && ( + setIsFilterOpen(false)} + /> + )} + + {completingCourtId !== null && ( + setCompletingCourtId(null)} + onConfirm={() => handleCompleteCourt(completingCourtId)} + /> + )} + + {isRandomMatchFailOpen && ( + setIsRandomMatchFailOpen(false)} + /> + )} + + {isDuplicateCheckOpen && ( + setIsDuplicateCheckOpen(false)} + onConfirm={() => { + handleAddToWaitingQueue(); + setIsDuplicateCheckOpen(false); + }} + /> + )} +
+ + {activeDragGroup ? ( +
+ + {activeDragGroup.label} + +
+ {activeDragGroup.players.map(p => ( + + ))} +
+
+ ) : null} +
+
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardTabSkeleton.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTabSkeleton.tsx new file mode 100644 index 00000000..0b7d1389 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardTabSkeleton.tsx @@ -0,0 +1,113 @@ +// 게임판(게임 코트 / 대기 / 명단) 로딩 스켈레톤 UI + +const SkeletonBox = ({ className = "" }: { className?: string }) => ( +
+); + +// 코트/대기 카드 안에 들어가는 선수 뱃지 자리 +const PlayerBadgeSkeleton = () => ( + +); + +// 코트 카드 한 장 (경기 중 상태 기준: 헤더 + 선수 4명) +const CourtCardSkeleton = () => ( +
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+); + +// 대기 카드 한 장 +const WaitingCardSkeleton = () => ( +
+
+ +
+ + +
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+); + +// 명단 멤버 카드 한 장 +const MemberCardSkeleton = () => ( +
+
+ + +
+
+ + +
+
+ + + +
+
+); + +export const GameBoardTabSkeleton = () => ( +
+ {/* 게임 코트 */} +
+
+ +
+ + +
+
+
+
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+
+
+ + {/* 대기 */} +
+ +
+
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+
+
+ + {/* 명단 */} +
+
+ + +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+
+); diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx new file mode 100644 index 00000000..5489a75c --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameBoardWebView.tsx @@ -0,0 +1,234 @@ +import { useState } from "react"; +import clsx from "clsx"; +import { DndContext } from "@dnd-kit/core"; +import AddWhite from "@/assets/icons/add_white.svg"; +import Sparkle from "@/assets/icons/sparkle_filled.svg"; +import Dismiss from "@/assets/icons/dismiss.svg"; +import ArrowLeft from "@/assets/icons/arrow_left.svg"; +import Filter from "@/assets/icons/filter.svg"; +import { CourtCard, WaitingCard } from "./CourtCard"; +import { GameMemberCard } from "./GameMemberCard"; +import { GameEndModal } from "./GameEndModal"; +import { + type CourtGroup, + type GameMember, + type WaitingGroup, +} from "./mockGameBoardData"; + +interface GameBoardWebViewProps { + courts: CourtGroup[]; + onCompleteCourt: (courtId: number) => void; + waitingGroups: WaitingGroup[]; + onRemoveWaitingGroup: (id: number) => void; + onMoveToCourt: (waitingGroupId: number, courtId: number) => void; + onChangeWaitingGroup: (group: WaitingGroup) => void; + onAddToWaitingQueue: () => void; + onAutoMatch: () => void; + members: GameMember[]; + selectedIds: number[]; + toggleSelect: (id: number) => void; + onToggleParticipation: (id: number) => void; + onEditMember: (id: number) => void; + onAddPlayer: () => void; + onManageCourts: () => void; + onOpenFilter: () => void; + onClose: () => void; +} + +export const GameBoardWebView = ({ + courts, + onCompleteCourt, + waitingGroups, + onRemoveWaitingGroup, + onMoveToCourt, + onChangeWaitingGroup, + onAddToWaitingQueue, + onAutoMatch, + members, + selectedIds, + toggleSelect, + onToggleParticipation, + onEditMember, + onAddPlayer, + onManageCourts, + onOpenFilter, + onClose, +}: GameBoardWebViewProps) => { + const selectedMembers = members.filter(m => selectedIds.includes(m.id)); + const [completingCourtId, setCompletingCourtId] = useState( + null, + ); + + return ( +
+
+ + 게임판 +
+ +
+ + {/* 게임 코트 */} +
+
+ 게임 코트 + +
+
+
+
+ {courts.map(court => ( + setCompletingCourtId(court.id)} + /> + ))} +
+
+
+
+ + {/* 대기 */} +
+
+ 대기 +
+ {waitingGroups.length === 0 ? ( +
+ + 대기중인 팀이 없어요 + +
+ ) : ( +
+
+
+ {waitingGroups.map(group => ( + + onMoveToCourt(group.id, courtId) + } + onChange={() => onChangeWaitingGroup(group)} + onReject={() => onRemoveWaitingGroup(group.id)} + /> + ))} +
+
+
+ )} +
+
+ + {/* 명단 */} +
+
+
+ 명단 + + +
+ +
+ {selectedMembers.length > 0 && ( +
+ {selectedMembers.map((m, i) => ( + + ))} +
+ )} +
+ + {selectedIds.length} + + 선택됨 +
+ + +
+
+ +
+ {members.map(member => ( + toggleSelect(member.id)} + onEditInfo={() => onEditMember(member.id)} + onToggleParticipation={() => onToggleParticipation(member.id)} + /> + ))} +
+
+
+ + {completingCourtId !== null && ( + setCompletingCourtId(null)} + onConfirm={() => { + onCompleteCourt(completingCourtId); + setCompletingCourtId(null); + }} + /> + )} +
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameDuplicateCheckModal.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameDuplicateCheckModal.tsx new file mode 100644 index 00000000..19af93a9 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameDuplicateCheckModal.tsx @@ -0,0 +1,387 @@ +import { useEffect, useState } from "react"; +import DefaultProfile from "@/assets/images/base_profile_img.png"; +import GR400_L from "@/components/common/Btn_Static/Text/GR400_L"; +import GR400_M from "@/components/common/Btn_Static/Text/GR400_M"; +import { useLockBodyScroll } from "@/hooks/useLockBodyScroll"; +import { getGameDuplicateCheck } from "@/api/game/duplicateCheck"; +import { + getMatchupKey, + type GameMember, + type MatchupInfo, + type MatchupType, +} from "./mockGameBoardData"; + +const LEGEND_ITEMS: { + type: MatchupType; + label: string; + dotClass: string; + textClass: string; +}[] = [ + { + type: "recent", + label: "직전 경기", + dotClass: "bg-rd-500", + textClass: "text-rd-500", + }, + { + type: "first", + label: "첫 경기", + dotClass: "bg-gy-500", + textClass: "text-gy-700", + }, + { + type: "previous", + label: "이전 경기", + dotClass: "bg-gr-700", + textClass: "text-gr-700", + }, +]; + +const MATCHUP_BADGE_STYLE: Record = { + recent: "border border-rd-500 shadow-[0_0_4px_rgba(246,45,45,0.16)]", + previous: "border border-gr-500 shadow-[0_0_4px_rgba(26,187,101,0.16)]", + first: "shadow-[0_0_8px_rgba(18,18,18,0.16)]", +}; + +const MATCHUP_LINE_COLOR: Record = { + recent: "#F62D2D", + previous: "#1ABB65", + first: "#E4E7EA", +}; + +interface MatchBadgeProps { + className?: string; + info: { count: number; type: MatchupType }; +} + +const MatchBadge = ({ className, info }: MatchBadgeProps) => ( +
+ {info.count} +
+); + +const Avatar = ({ member }: { member: GameMember }) => ( + {`${member.name} +); + +// 정확히 4명 선택 시, 4명이 이루는 다이아몬드 대진 관계(변 4개 + 대각선 2개 = 총 6쌍)를 그대로 시각화한다. +const DiamondMatchup = ({ + members, + matchups, +}: { + members: GameMember[]; + matchups: Map; +}) => { + const [topLeft, bottomLeft, topRight, bottomRight] = members; + const infoFor = (a: GameMember, b: GameMember) => + matchups.get(getMatchupKey(a.id, b.id)) ?? { + count: 0, + type: "first" as const, + }; + + return ( +
+ {/* overflow-visible: 외곽 4개 선이 viewBox 경계(0/160)에 걸쳐 있어 기본 clip 시 + stroke의 절반이 잘려 대각선보다 얇게 보이므로, 잘리지 않도록 노출한다. */} + + + + + + + + + + {/* 노드는 아바타 폭(w-10)으로 고정해 그리드 꼭짓점(x=92/252)에 정확히 중앙 정렬한다. + 이름은 whitespace-nowrap으로 양옆 대칭 오버플로우시켜 아바타 위치에 영향을 주지 않게 한다. */} +
+ + {topLeft.name} + + +
+
+ + + {bottomLeft.name} + +
+
+ + {topRight.name} + + +
+
+ + + {bottomRight.name} + +
+ + + + + + + +
+ ); +}; + +// 4명이 아닌 인원수(1~3명, 5명 이상)에서는 다이아몬드 도형이 성립하지 않으므로, +// 동일한 배지 색 규칙을 유지하는 쌍별 리스트로 대체한다. +const PairListMatchup = ({ + members, + matchups, +}: { + members: GameMember[]; + matchups: Map; +}) => { + const pairs: [GameMember, GameMember][] = []; + for (let i = 0; i < members.length; i++) { + for (let j = i + 1; j < members.length; j++) { + pairs.push([members[i], members[j]]); + } + } + + if (pairs.length === 0) { + return ( +
+ 비교할 상대가 없어요. +
+ ); + } + + return ( +
+ {pairs.map(([a, b]) => { + const info = matchups.get(getMatchupKey(a.id, b.id)) ?? { + count: 0, + type: "first" as const, + }; + return ( +
+
+ + {a.name} + + +
+
+ +
+
+ + {b.name} + + +
+
+ ); + })} +
+ ); +}; + +interface GameDuplicateCheckModalProps { + variant?: "sheet" | "overlay"; + gameBoardId: number; + members: GameMember[]; + onClose: () => void; + onConfirm: () => void; +} + +const deriveMatchupType = ( + count: number, + playedInLastGame: boolean, +): MatchupType => + playedInLastGame ? "recent" : count === 0 ? "first" : "previous"; + +export const GameDuplicateCheckModal = ({ + variant = "sheet", + gameBoardId, + members, + onClose, + onConfirm, +}: GameDuplicateCheckModalProps) => { + useLockBodyScroll(true); + const isOverlay = variant === "overlay"; + + const [matchups, setMatchups] = useState | null>( + null, + ); + + useEffect(() => { + let active = true; + setMatchups(null); + getGameDuplicateCheck( + gameBoardId, + members.map(m => m.id), + ).then(res => { + if (!active) return; + const next = new Map(); + res.pairs.forEach(p => { + next.set(getMatchupKey(p.memberIdA, p.memberIdB), { + count: p.count, + type: deriveMatchupType(p.count, p.playedInLastGame), + }); + }); + setMatchups(next); + }); + return () => { + active = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gameBoardId, members.map(m => m.id).join(",")]); + + const content = ( + <> + 게임 중복 체크 + +
+
+ 이 조합, 얼마나 겹쳤을까요? + + 선수들이 이전에 서로 경기한 횟수에요. + +
+
+ {LEGEND_ITEMS.map(item => ( +
+ + + {item.label} + +
+ ))} +
+
+ + {matchups === null ? ( +
+ 불러오는 중이에요... +
+ ) : members.length === 4 ? ( + + ) : ( + + )} + + {isOverlay ? ( + + ) : ( + + )} + + ); + + if (isOverlay) { + return ( +
+
e.stopPropagation()} + > + {content} +
+
+ ); + } + + return ( +
+
e.stopPropagation()} + > + {content} +
+
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx new file mode 100644 index 00000000..55c9edf7 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameEditPlayerModal.tsx @@ -0,0 +1,150 @@ +import { useState } from "react"; +import { PageHeader } from "@/components/common/system/header/PageHeader"; +import InputField from "@/components/common/Search_Filed/InputField"; +import TextBox from "@/components/common/Text_Box/TextBox"; +import DropCheckBox from "@/components/common/Drop_Box/DropCheckBox"; +import { DropBox } from "@/components/common/DropBox"; +import Grad_GR400_L from "@/components/common/Btn_Static/Text/Grad_GR400_L"; +import GR400_M from "@/components/common/Btn_Static/Text/GR400_M"; +import Circle_RedIcon from "@/assets/icons/cicle_s_red.svg?url"; +import { LEVEL_KEY } from "@/constants/options"; +import { useLockBodyScroll } from "@/hooks/useLockBodyScroll"; +import type { GameMember } from "./mockGameBoardData"; + +const AGE_OPTIONS = ["10대", "20대", "30대", "40대", "50대", "60대 이상"]; + +export interface EditedGamePlayer { + name: string; + gender: "MALE" | "FEMALE"; + level: string; + ageGroup: string; +} + +interface GameEditPlayerModalProps { + variant?: "sheet" | "overlay"; + member: GameMember; + onClose: () => void; + onSubmit: (player: EditedGamePlayer) => void; +} + +export const GameEditPlayerModal = ({ + variant = "sheet", + member, + onClose, + onSubmit, +}: GameEditPlayerModalProps) => { + useLockBodyScroll(true); + const [name, setName] = useState(member.name); + const [gender, setGender] = useState<"MALE" | "FEMALE" | null>( + member.gender, + ); + const [level, setLevel] = useState( + member.group === "급수없음" ? "disabled" : member.group, + ); + const [ageGroup, setAgeGroup] = useState(member.ageGroup); + + const levelOptions = LEVEL_KEY.slice(1); + const isLevelValid = level === "disabled" || levelOptions.includes(level); + const isFormValid = name.trim().length > 0 && gender !== null && isLevelValid; + + const handleSubmit = () => { + if (!isFormValid || gender === null) return; + onSubmit({ + name: name.trim(), + gender, + level: level === "disabled" ? "급수없음" : level, + ageGroup, + }); + }; + + const fields = ( + <> + setName(e.target.value.slice(0, 17))} + /> + +
+
+ 성별 + +
+
+ setGender(prev => (prev === "MALE" ? null : "MALE"))} + className="w-19" + > + 남성 + + + setGender(prev => (prev === "FEMALE" ? null : "FEMALE")) + } + className="w-19" + > + 여성 + +
+
+ + setLevel(val ?? "")} + /> + +
+ 나이대 + ({ value }))} + value={ageGroup} + onChange={setAgeGroup} + /> +
+ + ); + + if (variant === "overlay") { + return ( +
+
e.stopPropagation()} + > + 정보 수정 +
{fields}
+ +
+
+ ); + } + + return ( +
+ +
{fields}
+ +
+ +
+
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameEndModal.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameEndModal.tsx new file mode 100644 index 00000000..bc720825 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameEndModal.tsx @@ -0,0 +1,46 @@ +import Dismiss from "@/assets/icons/dismiss.svg"; +import ExerciseFilled from "@/assets/icons/Exercise_filled.svg"; + +interface GameEndModalProps { + onClose: () => void; + onConfirm: () => void; +} + +export const GameEndModal = ({ onClose, onConfirm }: GameEndModalProps) => ( +
+
e.stopPropagation()} + > +
+
+ +
+ +
+ + 게임을 완료하시겠어요? + + 완료한 게임은 '게임 완료' 탭에서 확인할 수 있어요. + +
+ + +
+
+); diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameFilterPage.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameFilterPage.tsx new file mode 100644 index 00000000..cd472b75 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameFilterPage.tsx @@ -0,0 +1,191 @@ +import { useState } from "react"; +import clsx from "clsx"; +import { PageHeader } from "@/components/common/system/header/PageHeader"; +import TextBox from "@/components/common/Text_Box/TextBox"; +import GR400_M from "@/components/common/Btn_Static/Text/GR400_M"; +import Refresh from "@/assets/icons/refresh.svg"; +import ArrowUp from "@/assets/icons/arrow_up.svg"; +import { LEVEL_KEY } from "@/constants/options"; +import { useLockBodyScroll } from "@/hooks/useLockBodyScroll"; +import type { GameBoardMemberFilters } from "./gameBoardAdapter"; + +const LEVEL_OPTIONS = [...LEVEL_KEY, "급수없음"]; +const GENDER_OPTIONS = ["전체", "남성", "여성"]; +const SHUTTLE_OPTIONS = ["제출함", "미제출"]; + +interface FilterToggleSectionProps { + title: string; + options: string[]; + isSelected: (option: string) => boolean; + onSelect: (value: string) => void; +} + +const FilterToggleSection = ({ + title, + options, + isSelected, + onSelect, +}: FilterToggleSectionProps) => { + const [isOpen, setIsOpen] = useState(true); + + return ( +
+
+ {title} + +
+ {isOpen && ( +
+ {options.map(option => ( + onSelect(option)} + className="w-19" + > + {option} + + ))} +
+ )} +
+ ); +}; + +interface GameFilterPageProps { + variant?: "sheet" | "overlay"; + initialFilters: GameBoardMemberFilters; + onApply: (filters: GameBoardMemberFilters) => void; + onClose: () => void; +} + +export const GameFilterPage = ({ + variant = "sheet", + initialFilters, + onApply, + onClose, +}: GameFilterPageProps) => { + useLockBodyScroll(true); + const [levels, setLevels] = useState(initialFilters.levels); + const [gender, setGender] = useState(initialFilters.gender); + const [shuttle, setShuttle] = useState( + initialFilters.shuttle, + ); + + // "전체" 선택 시 개별 급수 선택을 모두 해제, 개별 급수 선택 시 다중 토글 + const handleToggleLevel = (option: string) => { + if (option === "전체") { + setLevels([]); + return; + } + setLevels(prev => + prev.includes(option) + ? prev.filter(l => l !== option) + : [...prev, option], + ); + }; + + const handleReset = () => { + setLevels([]); + setGender("전체"); + setShuttle(null); + }; + + const handleApply = () => { + onApply({ levels, gender, shuttle }); + onClose(); + }; + + const isOverlay = variant === "overlay"; + + const sections = ( + <> + + option === "전체" ? levels.length === 0 : levels.includes(option) + } + onSelect={handleToggleLevel} + /> +
+ + gender === option} + onSelect={setGender} + /> +
+ + shuttle === option} + onSelect={value => + setShuttle(prev => (prev === value ? null : value)) + } + /> + + ); + + if (isOverlay) { + return ( +
+
e.stopPropagation()} + > + 필터 +
{sections}
+
+ +
+ +
+
+
+
+ ); + } + + return ( +
+ + +
{sections}
+ +
+ +
+ +
+
+
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx new file mode 100644 index 00000000..658cd378 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameMemberCard.tsx @@ -0,0 +1,233 @@ +import { + useEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type TouchEvent as ReactTouchEvent, +} from "react"; +import { createPortal } from "react-dom"; +import clsx from "clsx"; +import DefaultProfile from "@/assets/images/base_profile_img.png"; +import Female from "@/assets/icons/female.svg"; +import Male from "@/assets/icons/male.svg"; +import CircleS from "@/assets/icons/circle_s.svg"; +import Exercise from "@/assets/icons/Exercise.svg"; +import ExerciseFilled from "@/assets/icons/Exercise_filled.svg"; +import type { GameMember, MemberTag } from "./mockGameBoardData"; + +const TAG_STYLE: Record = { + 운동: "bg-gr-100 text-gr-700", + 대기: "bg-[#fff4d2] text-[#d96303]", + 미참: "bg-gy-100 text-gy-700", +}; + +const LONG_PRESS_MS = 600; +const MENU_WIDTH = 149; +const MENU_HEIGHT = 84; +const MENU_EDGE_MARGIN = 8; + +interface GameMemberCardProps { + member: GameMember; + selected: boolean; + onToggleSelect: () => void; + onEditInfo?: () => void; + onToggleParticipation?: () => void; +} + +export const GameMemberCard = ({ + member, + selected, + onToggleSelect, + onEditInfo, + onToggleParticipation, +}: GameMemberCardProps) => { + const { name, gender, ageGroup, group, playCount, tags, imgUrl, selectable } = + member; + const isWithdrawn = tags.includes("미참"); + + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); + const cardRef = useRef(null); + const menuRef = useRef(null); + const longPressTimer = useRef | null>(null); + const suppressNextClick = useRef(false); + + useEffect(() => { + if (!isMenuOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + if (cardRef.current?.contains(e.target as Node)) { + suppressNextClick.current = true; + } + setIsMenuOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isMenuOpen]); + + const openMenuAt = (x: number, y: number) => { + const left = Math.min( + Math.max(x - MENU_WIDTH, MENU_EDGE_MARGIN), + window.innerWidth - MENU_WIDTH - MENU_EDGE_MARGIN, + ); + const top = Math.min( + Math.max(y, MENU_EDGE_MARGIN), + window.innerHeight - MENU_HEIGHT - MENU_EDGE_MARGIN, + ); + setMenuPosition({ top, left }); + setIsMenuOpen(true); + }; + + const clearLongPressTimer = () => { + if (longPressTimer.current) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + }; + + const handleContextMenu = (e: ReactMouseEvent) => { + e.preventDefault(); + openMenuAt(e.clientX, e.clientY); + }; + + const handleTouchStart = (e: ReactTouchEvent) => { + const touch = e.touches[0]; + clearLongPressTimer(); + longPressTimer.current = setTimeout(() => { + openMenuAt(touch.clientX, touch.clientY); + }, LONG_PRESS_MS); + }; + + return ( +
{ + if (suppressNextClick.current) { + suppressNextClick.current = false; + return; + } + if (selectable) onToggleSelect(); + }} + onContextMenu={handleContextMenu} + onTouchStart={handleTouchStart} + onTouchEnd={clearLongPressTimer} + onTouchMove={clearLongPressTimer} + className={clsx( + "flex w-[10.3125rem] flex-col gap-2 rounded-2xl p-2 text-left", + selectable ? "cursor-pointer" : "cursor-default", + isWithdrawn + ? "bg-gy-100 opacity-50 shadow-ds100" + : selected + ? "bg-[#f1f9f3] shadow-ds200-gr" + : "bg-white shadow-ds100", + )} + > +
+
+ {tags.map(tag => ( + + {tag} + + ))} +
+ + {playCount}회 + +
+ +
+ {`${name} + {name} +
+ +
+
+ {gender && ( + + )} + {group} + + + {ageGroup} + +
+ { + e.stopPropagation(); + if (suppressNextClick.current) { + suppressNextClick.current = false; + return; + } + if (selectable) onToggleSelect(); + }} + > + 선택 + +
+ + {isMenuOpen && + createPortal( +
e.stopPropagation()} + > + +
+ +
, + document.body, + )} +
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/GameBoard/GameRandomMatchFailModal.tsx b/src/pages/group/MyGroupDetail/GameBoard/GameRandomMatchFailModal.tsx new file mode 100644 index 00000000..ce29da57 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/GameRandomMatchFailModal.tsx @@ -0,0 +1,48 @@ +import Dismiss from "@/assets/icons/dismiss.svg"; +import EmojiSurprise from "@/assets/icons/emoji_surprise.svg"; + +interface GameRandomMatchFailModalProps { + onClose: () => void; +} + +// 랜덤(자동) 매칭 실패 시 노출되는 안내 모달 +export const GameRandomMatchFailModal = ({ + onClose, +}: GameRandomMatchFailModalProps) => ( +
+
e.stopPropagation()} + > +
+
+ +
+ +
+ + 매칭 실패 + + 매칭에 실패했습니다. 명단을 확인해주세요. + +
+ + +
+
+); diff --git a/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts b/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts new file mode 100644 index 00000000..6f03c59a --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/gameBoardAdapter.ts @@ -0,0 +1,129 @@ +// 게임판 REST/WS 응답(GameBoardDTO 등)을 화면에서 쓰는 view model(CourtGroup/WaitingGroup/GameMember)로 변환한다. +import type { + GameBoardCourt, + GameBoardPlayer, + GameBoardResponse, + GameBoardWaiting, +} from "@/api/game/board"; +import type { + GameBoardMember, + GameBoardMemberPayload, + GetGameBoardMembersParams, +} from "@/api/game/members"; +import type { + CourtGroup, + GameMember, + GamePlayer, + WaitingGroup, +} from "./mockGameBoardData"; + +// 서버가 타임존 표시 없이 UTC 시각을 내려줄 때, Date가 이를 로컬 시간으로 오인해 +// 9시간(한국 UTC+9)만큼 어긋나는 것을 막기 위해 명시적으로 UTC로 해석한다. +const parseServerDate = (value: string) => + new Date(/[Zz]|[+-]\d{2}:\d{2}$/.test(value) ? value : `${value}Z`); + +export const formatElapsed = (startedAt: string) => { + const ms = Date.now() - parseServerDate(startedAt).getTime(); + const totalSec = Math.max(0, Math.floor(ms / 1000)); + const mm = String(Math.floor(totalSec / 60)).padStart(2, "0"); + const ss = String(totalSec % 60).padStart(2, "0"); + return `${mm}:${ss}`; +}; + +const toGamePlayer = (p: GameBoardPlayer): GamePlayer => ({ + id: p.gameBoardMemberId, + name: p.name, + group: p.level, + color: p.playerOrder % 2 === 0 ? "pink" : "blue", +}); + +export const toCourtGroup = (c: GameBoardCourt): CourtGroup => ({ + id: c.courtId, + label: c.courtName, + gameId: c.game?.gameId, + startedAt: c.game?.startedAt, + timer: c.game ? formatElapsed(c.game.startedAt) : undefined, + players: c.game ? c.game.players.map(toGamePlayer) : null, +}); + +export const toWaitingGroup = (w: GameBoardWaiting): WaitingGroup => ({ + id: w.gameId, + gameId: w.gameId, + label: `대기 ${w.waitingOrder}번`, + players: w.players.map(toGamePlayer), + memberIds: w.players.map(p => p.gameBoardMemberId), +}); + +export const toBoardViewModel = (board: GameBoardResponse) => ({ + courts: board.courts.map(toCourtGroup), + waitingGroups: board.waitings.map(toWaitingGroup), +}); + +const GENDER_KO_TO_EN: Record = { + 남성: "MALE", + 여성: "FEMALE", +}; + +const GENDER_EN_TO_KO: Record<"MALE" | "FEMALE", "남성" | "여성"> = { + MALE: "남성", + FEMALE: "여성", +}; + +// 명단 추가/수정 payload: 서버는 gender를 한글("남성"/"여성")로만 받는다. +export const toGameBoardMemberPayload = (player: { + name: string; + gender: "MALE" | "FEMALE"; + level: string; + ageGroup?: string; +}): GameBoardMemberPayload => ({ + name: player.name, + gender: GENDER_EN_TO_KO[player.gender], + level: player.level, + ageGroup: player.ageGroup || undefined, +}); + +// 노출 조건 (판정 우선순위): 경기중 > 대기열 포함 > 운동 불참 > 뱃지 없음. 한 번에 하나만 노출한다. +export const toGameMember = (m: GameBoardMember): GameMember => { + const tags: GameMember["tags"] = m.inGame + ? ["운동"] + : m.waiting + ? ["대기"] + : !m.participating + ? ["미참"] + : []; + + return { + id: m.gameBoardMemberId, + name: m.name, + gender: GENDER_KO_TO_EN[m.gender], + ageGroup: m.ageGroup, + group: m.level, + playCount: m.gameCount, + tags: [...tags], + imgUrl: m.profileImageUrl, + selectable: m.participating, + }; +}; + +export interface GameBoardMemberFilters { + levels: string[]; // 한글 급수 라벨, 다중 선택 + gender: string | null; // "전체" | "남성" | "여성" + shuttle: string | null; // "제출함" | "미제출" | null +} + +// level/gender는 한글 표시값을 그대로 API 쿼리에 전달한다 (명단조회 API 스펙). +export const toGameBoardMembersParams = ( + filters: GameBoardMemberFilters, +): GetGameBoardMembersParams => ({ + level: filters.levels.length ? filters.levels : undefined, + gender: + filters.gender === "남성" || filters.gender === "여성" + ? filters.gender + : undefined, + shuttlecockSubmitted: + filters.shuttle === "제출함" + ? true + : filters.shuttle === "미제출" + ? false + : undefined, +}); diff --git a/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts b/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts new file mode 100644 index 00000000..efd404d1 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameBoard/mockGameBoardData.ts @@ -0,0 +1,50 @@ +export interface GamePlayer { + id: number; + name: string; + group: string; + color: "pink" | "blue"; +} + +export interface CourtGroup { + id: number; // courtId + label: string; + gameId?: number; // 진행중인 게임 id (완료/대기이동 액션에 필요) + timer?: string; // startedAt으로부터 계산된 표시용 "MM:SS" + startedAt?: string; // 스톱워치 기준 시각 + players: GamePlayer[] | null; // null → 빈 코트 +} + +export interface WaitingGroup { + id: number; + gameId: number; // 대기중인 게임 id (삭제/시작 액션에 필요) + label: string; + players: GamePlayer[]; + memberIds: number[]; +} + +export type MemberTag = "운동" | "대기" | "미참"; + +export interface GameMember { + id: number; + name: string; + gender: "MALE" | "FEMALE"; + ageGroup: string; + group: string; + playCount: number; + tags: MemberTag[]; + imgUrl?: string | null; + selectable: boolean; +} + +// 직전 경기: 가장 최근에 함께 뛴 조합 / 첫 경기: 한 번도 함께 뛴 적 없는 조합 / 이전 경기: 그 외 함께 뛴 적 있는 조합 +export type MatchupType = "recent" | "first" | "previous"; + +export interface MatchupInfo { + count: number; + type: MatchupType; +} + +export const getMatchupKey = (memberIdA: number, memberIdB: number) => + memberIdA < memberIdB + ? `${memberIdA}-${memberIdB}` + : `${memberIdB}-${memberIdA}`; diff --git a/src/pages/group/MyGroupDetail/GameFinishedTab.tsx b/src/pages/group/MyGroupDetail/GameFinishedTab.tsx new file mode 100644 index 00000000..72406026 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameFinishedTab.tsx @@ -0,0 +1,164 @@ +import React, { useState, useMemo } from "react"; +import { useGetCompletedGames } from "../../../api/game/game"; + +export interface PlayerInfo { + name: string; + tag: string; + team?: "A" | "B"; +} + +export interface MatchInfo { + id: number; + duration: string; + endTime: string; + teamA: PlayerInfo[]; + teamB: PlayerInfo[]; +} + +interface GameFinishedTabProps { + exerciseId: number; +} + +export const GameFinishedTab = ({ exerciseId }: GameFinishedTabProps) => { + const [courtNo, setCourtNo] = useState(1); + + const { data, fetchNextPage, hasNextPage, isLoading } = useGetCompletedGames( + exerciseId, + courtNo + ); + + const matches: MatchInfo[] = useMemo(() => { + if (!data) return []; + + return data.pages.flatMap((page) => + page.games.map((game) => { + const teamA = game.players.filter(p => p.team === "A"); + const teamB = game.players.filter(p => p.team === "B"); + + const finalTeamA = teamA.length > 0 ? teamA : game.players.slice(0, Math.ceil(game.players.length / 2)); + const finalTeamB = teamB.length > 0 ? teamB : game.players.slice(Math.ceil(game.players.length / 2)); + + return { + id: game.gameId, + duration: `${game.durationMin}분`, + endTime: "완료", + teamA: finalTeamA.map(p => ({ name: p.name, tag: p.tag })), + teamB: finalTeamB.map(p => ({ name: p.name, tag: p.tag })) + }; + }) + ); + }, [data]); + + const handlePrevCourt = () => { + if (courtNo > 1) setCourtNo((prev) => prev - 1); + }; + + const handleNextCourt = () => { + setCourtNo((prev) => prev + 1); + }; + + const scrollRef = React.useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [startY, setStartY] = useState(0); + const [scrollTop, setScrollTop] = useState(0); + + const onMouseDown = (e: React.MouseEvent) => { + if (!scrollRef.current) return; + setIsDragging(true); + setStartY(e.pageY - scrollRef.current.offsetTop); + setScrollTop(scrollRef.current.scrollTop); + }; + + const onMouseLeave = () => setIsDragging(false); + const onMouseUp = () => setIsDragging(false); + + const onMouseMove = (e: React.MouseEvent) => { + if (!isDragging || !scrollRef.current) return; + e.preventDefault(); + const y = e.pageY - scrollRef.current.offsetTop; + const walk = (y - startY) * 1.5; // 스크롤 민감도 + scrollRef.current.scrollTop = scrollTop - walk; + }; + + return ( +
+ {/* 코트명 */} +
+ {String(courtNo).padStart(2, '0')} 코트 +
+ +
+ {/* 왼쪽 화살표 */} + + + {/* 매치 리스트 */} +
+ {isLoading &&
로딩 중...
} + {!isLoading && matches.length === 0 && ( +
완료된 게임이 없습니다.
+ )} + {matches.map(match => ( +
+
+ {match.duration} + {match.endTime} +
+
+
+ {match.teamA.map((p, idx) => ( +
+ {p.name} + {p.tag} +
+ ))} +
+
+ {match.teamB.map((p, idx) => ( +
+ {p.name} + {p.tag} +
+ ))} +
+
+
+ ))} + {hasNextPage && ( + + )} +
+ + {/* 오른쪽 화살표 */} + +
+
+ ); +}; + diff --git a/src/pages/group/MyGroupDetail/GameManagerPage.tsx b/src/pages/group/MyGroupDetail/GameManagerPage.tsx new file mode 100644 index 00000000..f0ebd2b3 --- /dev/null +++ b/src/pages/group/MyGroupDetail/GameManagerPage.tsx @@ -0,0 +1,199 @@ +import { useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { PageHeader } from "../../../components/common/system/header/PageHeader"; +import { Member, type MemberProps } from "../../../components/common/contentcard/Member"; +import { changeGameHost, useGetGameHostCandidates } from "../../../api/game/game"; +import { useQueryClient } from "@tanstack/react-query"; +import DismissIcon from "../../../assets/icons/dismiss.svg?react"; +import Search from "../../../assets/icons/search.svg?react"; +import Grad_GR400_L from "../../../components/common/Btn_Static/Text/Grad_GR400_L"; + +export const GameManagerPage = () => { + const navigate = useNavigate(); + const { exerciseId } = useParams<{ exerciseId: string }>(); + const exerciseIdNumber = Number(exerciseId); + const queryClient = useQueryClient(); + const [searchTerm, setSearchTerm] = useState(""); + + const { data } = useGetGameHostCandidates(exerciseIdNumber); + const totalCount = data?.totalCount || 0; + + const members: MemberProps[] = data?.participants.map(p => ({ + participantId: p.participantId, + // memberId가 응답에 없으므로 일단 participantId로 임시 사용 또는 제외 + memberId: p.participantId, + status: "Participating" as const, + name: p.name, + gender: p.gender as "MALE" | "FEMALE", + level: p.level, + lastExerciseDate: p.lastExerciseDate, + imgUrl: p.profileImageUrl, + // partyPosition: "모임장", "부모임장", "일반 회원" 등 한글 또는 영문 처리 + isLeader: p.partyPosition === "OWNER" || p.partyPosition === "MANAGER" || p.partyPosition === "PARTY_MANAGER" || p.partyPosition === "모임장", + isManager: p.isGameHost, + position: p.partyPosition === "OWNER" || p.partyPosition === "MANAGER" || p.partyPosition === "PARTY_MANAGER" || p.partyPosition === "모임장" + ? "leader" + : p.partyPosition === "SUBOWNER" || p.partyPosition === "PARTY_SUBMANAGER" || p.partyPosition === "부모임장" + ? "sub_leader" + : null, + })) || []; + + const [selectedMember, setSelectedMember] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectMode, setSelectMode] = useState(false); + + + + const handleAppointClick = (member: MemberProps) => { + if (member.isManager) { + // 이미 지정된 경우 취소하거나 무시 + return; + } + setSelectedMember(member); + setIsModalOpen(true); + }; + + const handleConfirmAppoint = async () => { + if (!selectedMember || selectedMember.participantId === undefined) return; + + try { + await changeGameHost(exerciseIdNumber, selectedMember.participantId); + queryClient.invalidateQueries({ + queryKey: ["gameHostCandidates", exerciseIdNumber], + }); + + setIsModalOpen(false); + setSelectMode(false); + alert("게임 진행자가 변경되었습니다."); + + } catch (error: any) { + alert(error?.message || "게임 진행자 변경에 실패했습니다."); + } + }; + + // 검색 기능 + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value); + }; + + const filteredMembers = members.filter((member) => { + const term = searchTerm.toLowerCase(); + if (!term) return true; + return ( + member.name?.toLowerCase().includes(term) || + member.level?.toLowerCase().includes(term) + ); + }); + + + return ( +
setSelectMode(false)} + > + navigate(-1)} /> + +
e.stopPropagation()}> + {/* 검색창 */} +
+
+ + + + +
+
+ + {/* 인원 표시 */} +
+
+
+ +

{totalCount}

+
+
+
+ + {/* 멤버 리스트 */} +
+ {filteredMembers.map((member, idx) => ( +
+ { + if (!selectMode && member.memberId) { + navigate(`/mypage/profile/${member.memberId}`); + } + }} + onAppointClick={() => handleAppointClick(member)} + /> +
+
+ ))} +
+
+ + {/* 하단버튼 */} + {!selectMode && ( +
e.stopPropagation()} + > + setSelectMode(true)} + /> +
+ )} + + {/* 게임 진행자 지정 모달 */} + {isModalOpen && ( +
setIsModalOpen(false)} + > +
e.stopPropagation()} + > + setIsModalOpen(false)} + /> + +
+ + + + +

게임 진행자로 지정하시겠어요?

+ +
+

'지정하기'를 누르시면,

+

게임 진행 권한이 부여됩니다.

+
+
+ +
+ +
+
+
+ )} +
+ ); +}; diff --git a/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx b/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx index b76b7fc2..7648384e 100644 --- a/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx +++ b/src/pages/group/MyGroupDetail/MyExerciseDetail.tsx @@ -10,6 +10,9 @@ import { Modal_ExDel } from "../../../components/group/Modal_ExDel copy"; import { useState, useEffect } from "react"; import { getModalConfig } from "../../../components/group/modalConfig"; import { SortBottomSheet } from "../../../components/common/SortBottomSheet"; +import TabSelector from "../../../components/common/TabSelector"; +import { GameBoardTab } from "./GameBoard/GameBoardTab"; +import { GameFinishedTab } from "./GameFinishedTab"; import { getExerciseDetail, cancelSelf, @@ -35,8 +38,35 @@ export const MyExerciseDetail = () => { const { exerciseId } = useParams<{ exerciseId: string }>(); const exerciseIdNumber = Number(exerciseId); - const [detail, setDetail] = useState(null); - const [members, setMembers] = useState([]); + const [detail, setDetail] = useState({ + notice: "API 에러(CORS/Network)로 인해 출력된 임시 화면입니다.", + placeName: "테스트 체육관 코트", + placeAddress: "서울특별시 강남구 테헤란로 123", + participantGenderCount: { male: 2, female: 2 }, + participantsCount: 4, + isManager: true, + participantMembers: [], + waitingMembers: [], + waitingGenderCount: { male: 0, female: 0 }, + } as any); + const [members, setMembers] = useState([ + { + participantId: 1, + memberId: 1, + status: "Participating", + name: "김셰익스피어", + gender: "MALE", + level: "A조", + isMe: true, + isLeader: true, + position: "leader", + imgUrl: null, + canCancel: true, + isGuest: false, + inviterName: "", + isWithdrawn: false, + } + ]); // const [participantsCount, setParticipantsCount] = useState(0); const [waitingMembers, setWaitingMembers] = useState([]); @@ -51,6 +81,28 @@ export const MyExerciseDetail = () => { const [isWithdrawnModal, setIsWithdrawnModal] = useState(false); + const location = window.location; + type TabType = "detail" | "game" | "finished"; + const [activeTab, setActiveTab] = useState(() => { + if (location.pathname.includes("/EndGame")) return "finished"; + return "detail"; + }); + const tabOptions = [ + { label: "운동 상세", value: "detail" }, + { label: "게임판", value: "game" }, + { label: "게임 완료", value: "finished" }, + ]; + + const handleTabChange = (value: TabType) => { + setActiveTab(value); + const returnPathQuery = returnPath !== -1 ? `?returnPath=${returnPath}` : ""; + if (value === "finished") { + window.history.replaceState(null, "", `/Group/MyGroup/Detail/Exercise_Detail/EndGame/${exerciseIdNumber}${returnPathQuery}`); + } else { + window.history.replaceState(null, "", `/group/Mygroup/MyExerciseDetail/${exerciseIdNumber}${returnPathQuery}`); + } + }; + // 운동 상세 조회 useEffect(() => { if (exerciseIdNumber) { @@ -73,8 +125,8 @@ export const MyExerciseDetail = () => { p.position === "PARTY_MANAGER", position: p.position === "OWNER" || - p.position === "MANAGER" || - p.position === "PARTY_MANAGER" + p.position === "MANAGER" || + p.position === "PARTY_MANAGER" ? "leader" : p.position === "SUBOWNER" || p.position === "PARTY_SUBMANAGER" ? "sub_leader" @@ -162,138 +214,77 @@ export const MyExerciseDetail = () => { }} /> -
- {/* 장소 정보 */} -
- {detail.notice && ( - <> -
- -

{detail.notice}

-
- - )} -
- -
-

- {detail.placeName} -

-

{detail.placeAddress}

-
-
-
- - {/* 참여 인원 */} -
-
-
- - - {detail.participantGenderCount.male + - detail.participantGenderCount.female}{" "} - / {detail.participantsCount} - -
-
- -

- {detail.participantGenderCount.female} -

- -

- {detail.participantGenderCount.male} -

-
-
-
+ handleTabChange(val as TabType)} + /> - {/* 참여 멤버 리스트 */} - {members.map((member, idx) => { - const modalConfig = getModalConfig( - member.status, - isCurrentUserLeader, - member.isMe ?? false, - member.name, - ); - const handleIsUSer = () => { - if (member.isWithdrawn) { - setIsWithdrawnModal(true); - } else { - navigate(`/mypage/profile/${member.memberId}`); - } - }; - return ( -
- { - if (member.participantId !== undefined) { - handleDeleteMember(member.participantId, { - isLeaderAction: isCurrentUserLeader && !member.isMe, - isGuest: member.isGuest, - }); - } - }} - showDeleteButton={ - isCurrentUserLeader || (member.isMe && !isCurrentUserLeader) - } - modalConfig={modalConfig ?? undefined} - /> -
- {isWithdrawnModal && ( - setIsWithdrawnModal(false)} - /> +
+ {activeTab === "detail" && ( +
+ {/* 장소 정보 */} +
+ {detail.notice && ( + <> +
+ +

{detail.notice}

+
+ )} -
- ); - })} - - {/* 대기 인원 */} - {waitingMembers.length > 0 && ( -
-
-
- -

{waitingCount}

+
+ +
+

+ {detail.placeName} +

+

{detail.placeAddress}

+
-
- -

- {detail.waitingGenderCount?.female ?? 0} -

- -

- {detail.waitingGenderCount?.male ?? 0} -

+
+ + {/* 참여 인원 */} +
+
+
+ + + {detail.participantGenderCount.male + + detail.participantGenderCount.female}{" "} + / {detail.participantsCount} + +
+
+ +

+ {detail.participantGenderCount.female} +

+ +

+ {detail.participantGenderCount.male} +

+
- {waitingMembers.map((member, idx) => { + {/* 참여 멤버 리스트 */} + {members.map((member, idx) => { const modalConfig = getModalConfig( member.status, isCurrentUserLeader, member.isMe ?? false, member.name, ); - const handleIsUSer = () => { if (member.isWithdrawn) { setIsWithdrawnModal(true); - // } else { - // navigate(`/mypage/profile/${member.memberId}`); + } else { + navigate(`/mypage/profile/${member.memberId}`); } }; - return ( -
+
{ } }} showDeleteButton={ - isCurrentUserLeader || - (member.isMe && !isCurrentUserLeader) + isCurrentUserLeader || (member.isMe && !isCurrentUserLeader) } modalConfig={modalConfig ?? undefined} />
+ {isWithdrawnModal && ( + setIsWithdrawnModal(false)} + /> + )}
); })} + + {/* 대기 인원 */} + {waitingMembers.length > 0 && ( +
+
+
+ +

{waitingCount}

+
+
+ +

+ {detail.waitingGenderCount?.female ?? 0} +

+ +

+ {detail.waitingGenderCount?.male ?? 0} +

+
+
+ + {waitingMembers.map((member, idx) => { + const modalConfig = getModalConfig( + member.status, + isCurrentUserLeader, + member.isMe ?? false, + member.name, + ); + + const handleIsUSer = () => { + if (member.isWithdrawn) { + setIsWithdrawnModal(true); + // } else { + // navigate(`/mypage/profile/${member.memberId}`); + } + }; + + return ( +
+ { + if (member.participantId !== undefined) { + handleDeleteMember(member.participantId, { + isLeaderAction: isCurrentUserLeader && !member.isMe, + isGuest: member.isGuest, + }); + } + }} + showDeleteButton={ + isCurrentUserLeader || + (member.isMe && !isCurrentUserLeader) + } + modalConfig={modalConfig ?? undefined} + /> +
+
+ ); + })} +
+ )}
)} + + {activeTab === "game" && ( + + )} + + {activeTab === "finished" && }
setIsSortOpen(false)} - options={["운동 수정하기", "운동 삭제하기"]} + options={["게임 진행자 관리", "운동 수정하기", "운동 삭제하기"]} onSelect={option => { + if (option === "게임 진행자 관리") { + navigate( + `/Group/MyGroup/Detail/Exercise_Detail/ChangeHost/${exerciseId}?returnPath=${returnPath}`, + ); + } if (option === "운동 수정하기") { navigate( `/group/exercise/edit/${exerciseId}?returnPath=${returnPath}`, diff --git a/src/pages/group/groupMaking/GroupMember.tsx b/src/pages/group/groupMaking/GroupMember.tsx index 4ab4205d..f7b053f7 100644 --- a/src/pages/group/groupMaking/GroupMember.tsx +++ b/src/pages/group/groupMaking/GroupMember.tsx @@ -33,7 +33,7 @@ export const GroupMember = () => { //디바운스 const debouncedSearch = useDebounce(search, 200); - const { data: page, isLoading } = useMemberInfinite({ + const { data: page } = useMemberInfinite({ levelSearch: debouncedSearch, page: 0, size: 10, @@ -89,9 +89,6 @@ export const GroupMember = () => { })), [members], ); - if (!isLoading) { - console.log(page); - } const filteredMembers = useMemo(() => { const filterText = debouncedSearch.trim().toLowerCase(); if (!filterText) return memberList; diff --git a/src/pages/group/index.ts b/src/pages/group/index.ts index 9339c731..93093eea 100644 --- a/src/pages/group/index.ts +++ b/src/pages/group/index.ts @@ -5,3 +5,4 @@ export { ViceLeaderDefault } from "./AdminMenu/ViceLeaderDefault"; export { EditGroupInfoDefault } from "./AdminMenu/EditGroupInfoDefault"; export { ExerciseDetail } from "./MyGroupDetail/ExerciseDetail"; // 대기 신청하는 화면 export { MyExerciseDetail } from "./MyGroupDetail/MyExerciseDetail"; //MyExerciseDetail 내 운동 상세 +export { GameManagerPage } from "./MyGroupDetail/GameManagerPage"; diff --git a/src/pages/home/EditLocationPage.tsx b/src/pages/home/EditLocationPage.tsx index 10ac9dd5..ae1e0ef3 100644 --- a/src/pages/home/EditLocationPage.tsx +++ b/src/pages/home/EditLocationPage.tsx @@ -48,7 +48,7 @@ export const EditLocationPage = () => { setSelectedId(mainAddr.addrId); } } catch (err) { - console.log("등록된 위치 불러오기 오류: ", err); + console.error("등록된 위치 불러오기 오류: ", err); } }; diff --git a/src/pages/login/KakaoLogin.tsx b/src/pages/login/KakaoLogin.tsx index a27381e0..0874093b 100644 --- a/src/pages/login/KakaoLogin.tsx +++ b/src/pages/login/KakaoLogin.tsx @@ -34,7 +34,6 @@ export default function KakaoLogin() { }; setUser(newUserData); localStorage.setItem("accessToken", data.accessToken); - console.log(data); if (data.isNewMember && data.needsOnboarding) { //onboarding으로 //미사용자 @@ -45,7 +44,7 @@ export default function KakaoLogin() { } }, onError: error => { - console.log(error); + console.error(error); }, }); return
; diff --git a/vite.config.ts b/vite.config.ts index 668105db..a9bc1fa3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -87,6 +87,17 @@ export default defineConfig(({ mode }) => { server: { host: true, + proxy: { + "/api": { + target: "https://api.cockple.site", + changeOrigin: true, + }, + "/ws": { + target: "https://api.cockple.site", + changeOrigin: true, + ws: true, + }, + }, }, define: {