From 1d966cafc8410e6cf8d2b35a625dd112a6a60ea1 Mon Sep 17 00:00:00 2001 From: "HEIHEI\\hey" Date: Fri, 27 Mar 2026 11:38:34 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EB=B9=99=EA=B3=A0=20=EC=A0=9C=EB=AA=A9=20a?= =?UTF-8?q?pi=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Bingo.jsx | 160 ++++++++++++++++++++++++++------------------ 1 file changed, 96 insertions(+), 64 deletions(-) diff --git a/src/pages/Bingo.jsx b/src/pages/Bingo.jsx index 74d8027..f9746e5 100644 --- a/src/pages/Bingo.jsx +++ b/src/pages/Bingo.jsx @@ -30,11 +30,11 @@ const getErrorMessage = (error, fallback = "요청에 실패했습니다.") => { const getImageUrlByCellId = (cellId, imageUrl) => { if (imageUrl) { if (imageUrl.startsWith("http")) return imageUrl; - if (imageUrl.startsWith("/")) return `${API_BASE_URL}${imageUrl}`; - return imageUrl; + if (imageUrl.startsWith("/")) return imageUrl; + return `/${imageUrl}`; } - return `${API_BASE_URL}/v1/bingo/image/${cellId}`; + return `/api/v1/bingo/image/${cellId}`; }; const getCellColor = (status) => { @@ -207,8 +207,31 @@ const normalizeMyTeam = (raw) => { }; }; +const getMissionTitle = (source, fallback = "") => { + return ( + source?.mission?.title ?? + source?.missionTitle ?? + source?.title ?? + source?.missionName ?? + fallback + ); +}; + +const getMissionDescription = (source, fallback = "") => { + return ( + source?.mission?.description ?? + source?.mission?.discription ?? + source?.missionDescription ?? + source?.description ?? + source?.discription ?? + source?.missionContent ?? + fallback + ); +}; + const getPendingTeamName = (source) => { return ( + source?.displayData?.teamName ?? source?.pendingTeamName ?? source?.processingTeamName ?? source?.uploadTeamName ?? @@ -230,6 +253,7 @@ const getPendingTeamName = (source) => { const getOccupiedTeamName = (source) => { return ( + source?.displayData?.teamName ?? source?.occupiedTeamName ?? source?.ownerTeamName ?? source?.teamName ?? @@ -243,6 +267,38 @@ const getOccupiedTeamName = (source) => { ); }; +const getRemainingTime = (source) => { + return ( + source?.displayData?.remainingTime ?? + source?.remainingTime ?? + source?.remaining ?? + null + ); +}; + +const getUploadedImageUrl = ( + source, + cellId, + rawStatus, + fallbackWhenExists = false, +) => { + const imageUrl = source?.displayData?.imageUrl ?? source?.imageUrl ?? null; + + if (imageUrl) { + return getImageUrlByCellId(cellId, imageUrl); + } + + if ( + fallbackWhenExists || + rawStatus === "PROCESSING" || + rawStatus === "OCCUPIED" + ) { + return getImageUrlByCellId(cellId); + } + + return ""; +}; + const normalizeGlobalCell = (cell) => { const cellId = cell?.cellId ?? cell?.id; const rawStatus = cell?.status ?? cell?.cellStatus ?? cell?.state; @@ -252,29 +308,18 @@ const normalizeGlobalCell = (cell) => { cell?.processStartedAt ?? cell?.startedAt ?? null; - const remainingTime = cell?.remainingTime ?? null; + const remainingTime = getRemainingTime(cell); return { cellId, - missionTitle: - cell?.missionTitle ?? - cell?.title ?? - cell?.missionName ?? - `미션 ${cellId}`, - missionDescription: - cell?.missionDescription ?? - cell?.description ?? - cell?.missionContent ?? - "", + missionTitle: getMissionTitle(cell, ""), + missionDescription: getMissionDescription(cell, ""), status: globalStatusToUiStatus(rawStatus), pendingTeamId: cell?.pendingTeamId ?? cell?.processingTeamId ?? null, pendingTeamName: getPendingTeamName(cell), occupiedTeamId: cell?.occupiedTeamId ?? cell?.teamId ?? null, occupiedTeamName: getOccupiedTeamName(cell), - uploadedImageUrl: - rawStatus === "PROCESSING" || rawStatus === "OCCUPIED" || cell?.imageUrl - ? getImageUrlByCellId(cellId, cell?.imageUrl) - : "", + uploadedImageUrl: getUploadedImageUrl(cell, cellId, rawStatus), uploadedAt, remainingTime, remainingSeconds: @@ -283,6 +328,7 @@ const normalizeGlobalCell = (cell) => { : parseRemainingSeconds(remainingTime, uploadedAt), borderType: null, isMine: false, + statusLabel: cell?.displayData?.statusLabel ?? null, }; }; @@ -307,22 +353,12 @@ const normalizeTeamCell = (cell, myTeamId) => { cell?.startedAt ?? null); - const remainingTime = isProcessingOthers - ? null - : (cell?.remainingTime ?? null); + const remainingTime = isProcessingOthers ? null : getRemainingTime(cell); return { cellId, - missionTitle: - cell?.missionTitle ?? - cell?.title ?? - cell?.missionName ?? - `미션 ${cellId}`, - missionDescription: - cell?.missionDescription ?? - cell?.description ?? - cell?.missionContent ?? - "", + missionTitle: getMissionTitle(cell, `미션 ${cellId}`), + missionDescription: getMissionDescription(cell, ""), status: uiStatus, pendingTeamId: isProcessingOthers ? null @@ -330,15 +366,15 @@ const normalizeTeamCell = (cell, myTeamId) => { ? myTeamId : (cell?.pendingTeamId ?? cell?.processingTeamId ?? null), pendingTeamName: isProcessingOthers ? null : getPendingTeamName(cell), - occupiedTeamName: getOccupiedTeamName(cell), occupiedTeamId: borderType === "BLACK" ? myTeamId : (cell?.occupiedTeamId ?? cell?.teamId ?? null), occupiedTeamName: getOccupiedTeamName(cell), uploadedImageUrl: - !isProcessingOthers && (borderType !== "NONE" || cell?.imageUrl) - ? getImageUrlByCellId(cellId, cell?.imageUrl) + !isProcessingOthers && + (borderType !== "NONE" || cell?.imageUrl || cell?.displayData?.imageUrl) + ? getUploadedImageUrl(cell, cellId, borderType, true) : "", uploadedAt, remainingTime, @@ -349,6 +385,7 @@ const normalizeTeamCell = (cell, myTeamId) => { : parseRemainingSeconds(remainingTime, uploadedAt), borderType: isProcessingOthers ? "NONE" : borderType, isMine: isProcessingOthers ? false : isMine, + statusLabel: cell?.displayData?.statusLabel ?? null, }; }; @@ -361,29 +398,18 @@ const normalizeGlobalDetail = (detail) => { detail?.processStartedAt ?? detail?.startedAt ?? null; - const remainingTime = detail?.remainingTime ?? detail?.remaining ?? null; + const remainingTime = getRemainingTime(detail); return { cellId, - missionTitle: - detail?.missionTitle ?? - detail?.title ?? - detail?.missionName ?? - `미션 ${cellId}`, - missionDescription: - detail?.missionDescription ?? - detail?.description ?? - detail?.missionContent ?? - "", + missionTitle: getMissionTitle(detail, `미션 ${cellId}`), + missionDescription: getMissionDescription(detail, ""), status: globalStatusToUiStatus(rawStatus), pendingTeamId: detail?.pendingTeamId ?? detail?.processingTeamId ?? null, pendingTeamName: getPendingTeamName(detail), occupiedTeamId: detail?.occupiedTeamId ?? detail?.teamId ?? null, occupiedTeamName: getOccupiedTeamName(detail), - uploadedImageUrl: - rawStatus === "PROCESSING" || rawStatus === "OCCUPIED" || detail?.imageUrl - ? getImageUrlByCellId(cellId, detail?.imageUrl) - : "", + uploadedImageUrl: getUploadedImageUrl(detail, cellId, rawStatus), uploadedAt, remainingTime, remainingSeconds: @@ -392,6 +418,7 @@ const normalizeGlobalDetail = (detail) => { : parseRemainingSeconds(remainingTime, uploadedAt), borderType: null, isMine: false, + statusLabel: detail?.displayData?.statusLabel ?? null, }; }; @@ -416,22 +443,12 @@ const normalizeTeamDetail = (detail, myTeamId) => { detail?.startedAt ?? null); - const remainingTime = isProcessingOthers - ? null - : (detail?.remainingTime ?? detail?.remaining ?? null); + const remainingTime = isProcessingOthers ? null : getRemainingTime(detail); return { cellId, - missionTitle: - detail?.missionTitle ?? - detail?.title ?? - detail?.missionName ?? - `미션 ${cellId}`, - missionDescription: - detail?.missionDescription ?? - detail?.description ?? - detail?.missionContent ?? - "", + missionTitle: getMissionTitle(detail, `미션 ${cellId}`), + missionDescription: getMissionDescription(detail, ""), status: uiStatus, pendingTeamId: isProcessingOthers ? null @@ -445,8 +462,11 @@ const normalizeTeamDetail = (detail, myTeamId) => { : (detail?.occupiedTeamId ?? detail?.teamId ?? null), occupiedTeamName: getOccupiedTeamName(detail), uploadedImageUrl: - !isProcessingOthers && (borderType !== "NONE" || detail?.imageUrl) - ? getImageUrlByCellId(cellId, detail?.imageUrl) + !isProcessingOthers && + (borderType !== "NONE" || + detail?.imageUrl || + detail?.displayData?.imageUrl) + ? getUploadedImageUrl(detail, cellId, borderType, true) : "", uploadedAt, remainingTime, @@ -457,6 +477,7 @@ const normalizeTeamDetail = (detail, myTeamId) => { : parseRemainingSeconds(remainingTime, uploadedAt), borderType: isProcessingOthers ? "NONE" : borderType, isMine: isProcessingOthers ? false : isMine, + statusLabel: detail?.displayData?.statusLabel ?? null, }; }; @@ -887,6 +908,16 @@ function NewBingo() { 미션 내용 : {selectedDetail.missionDescription} + {selectedDetail.status === "PENDING" && + selectedDetail.remainingTime && ( + <> + 남은 시간 : + + {formatRemainingTime(selectedDetail, now)} + + + )} + {isTeamBoard ? ( selectedTeamCell?.uploadedImageUrl ? ( selectedTeamCell?.isMine ? ( @@ -1277,6 +1308,7 @@ const Toast = styled.div` font-weight: 600; z-index: 12000; `; + const TeamHiddenPlaceholder = styled.div` width: 100%; aspect-ratio: 1 / 0.72; From 56c984245e15b8197512a1e82f41ecf21b66a373 Mon Sep 17 00:00:00 2001 From: "HEIHEI\\hey" Date: Fri, 22 May 2026 16:25:36 +0900 Subject: [PATCH 2/2] =?UTF-8?q?Fix:=20=EB=B9=99=EA=B3=A0=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Bingo.jsx | 1068 ++++++++++++++----------------------------- 1 file changed, 347 insertions(+), 721 deletions(-) diff --git a/src/pages/Bingo.jsx b/src/pages/Bingo.jsx index f9746e5..454c058 100644 --- a/src/pages/Bingo.jsx +++ b/src/pages/Bingo.jsx @@ -8,751 +8,417 @@ import { IoClose } from "react-icons/io5"; import axiosInstance from "../axiosInstance"; const HOLD_HOURS = 12; -const HOLD_MS = HOLD_HOURS * 60 * 60 * 1000; -const API_BASE_URL = axiosInstance?.defaults?.baseURL || ""; +// ─── API helpers ──────────────────────────────────────────────────────────── -const unwrapResponse = (res) => { - if (res?.data !== undefined) return res.data; - return res; -}; - -const getErrorMessage = (error, fallback = "요청에 실패했습니다.") => { - return ( - error?.response?.data?.message || - error?.response?.data?.error || - error?.response?.data?.detail || - error?.message || - fallback - ); -}; - -const getImageUrlByCellId = (cellId, imageUrl) => { - if (imageUrl) { - if (imageUrl.startsWith("http")) return imageUrl; - if (imageUrl.startsWith("/")) return imageUrl; - return `/${imageUrl}`; - } - - return `/api/v1/bingo/image/${cellId}`; -}; - -const getCellColor = (status) => { - if (status === "PENDING") return "#FFF36A"; - if (status === "OCCUPIED") return "#FF5A5A"; - return "#FFFFFF"; -}; - -const getPanelBgColor = (status) => { - if (status === "PENDING") return "#FFFBE0"; - if (status === "OCCUPIED") return "#FFE2DD"; - return "#FFFFFF"; -}; - -const globalStatusToUiStatus = (status) => { - const normalized = String(status ?? "").toUpperCase(); - - if ( - normalized === "PROCESSING" || - normalized === "PENDING" || - normalized === "YELLOW" || - normalized === "IN_PROGRESS" - ) { - return "PENDING"; - } - - if ( - normalized === "OCCUPIED" || - normalized === "COMPLETE" || - normalized === "COMPLETED" || - normalized === "CONFIRMED" || - normalized === "SUCCESS" || - normalized === "RED" || - normalized === "BLACK" || - normalized === "GRAY" - ) { - return "OCCUPIED"; - } - - return "AVAILABLE"; -}; - -const teamBorderTypeToUiStatus = (borderType) => { - const normalized = String(borderType ?? "").toUpperCase(); - - if ( - normalized === "PROCESSING_OURS" || - normalized === "PROCESSING" || - normalized === "PENDING" || - normalized === "YELLOW" - ) { - return "PENDING"; - } - - if ( - normalized === "BLACK" || - normalized === "GRAY" || - normalized === "OCCUPIED" || - normalized === "COMPLETE" || - normalized === "COMPLETED" || - normalized === "CONFIRMED" || - normalized === "SUCCESS" || - normalized === "RED" - ) { - return "OCCUPIED"; - } - - return "AVAILABLE"; -}; - -const parseRemainingSeconds = (remainingTime, uploadedAt) => { - if (typeof remainingTime === "number" && Number.isFinite(remainingTime)) { - return Math.max(0, Math.floor(remainingTime)); - } - - if (typeof remainingTime === "string") { - const trimmed = remainingTime.trim(); - - if (/^\d+$/.test(trimmed)) { - return Math.max(0, Number(trimmed)); - } - - const parts = trimmed.split(":").map(Number); - - if ( - parts.length === 3 && - parts.every((part) => Number.isFinite(part) && part >= 0) - ) { - const [hours, minutes, seconds] = parts; - return hours * 3600 + minutes * 60 + seconds; - } - - if ( - parts.length === 2 && - parts.every((part) => Number.isFinite(part) && part >= 0) - ) { - const [minutes, seconds] = parts; - return minutes * 60 + seconds; - } - } - - if (uploadedAt) { - const uploadedTime = new Date(uploadedAt).getTime(); - - if (!Number.isNaN(uploadedTime)) { - return Math.max( - 0, - Math.floor((uploadedTime + HOLD_MS - Date.now()) / 1000), - ); - } - } +const getErrorMessage = (error, fallback = "요청에 실패했습니다.") => + error?.response?.data?.message || + error?.response?.data?.error || + error?.message || + fallback; - return null; +const resolveImageUrl = (url) => { + if (!url) return ""; + if (url.startsWith("http")) return url; + return `https://api.z0.co.kr${url.startsWith("/") ? "" : "/"}${url}`; }; -const formatRemainingTime = (detail, now) => { - if (!detail) return "12:00:00"; - - if (typeof detail.remainingSeconds === "number") { - const remain = Math.max(0, detail.remainingSeconds); - const hours = String(Math.floor(remain / 3600)).padStart(2, "0"); - const minutes = String(Math.floor((remain % 3600) / 60)).padStart(2, "0"); - const seconds = String(remain % 60).padStart(2, "0"); - return `${hours}:${minutes}:${seconds}`; - } - - if (detail.uploadedAt) { - const uploadedTime = new Date(detail.uploadedAt).getTime(); - - if (!Number.isNaN(uploadedTime)) { - const remain = Math.max(0, uploadedTime + HOLD_MS - now); - const hours = String(Math.floor(remain / (1000 * 60 * 60))).padStart( - 2, - "0", - ); - const minutes = String( - Math.floor((remain % (1000 * 60 * 60)) / (1000 * 60)), - ).padStart(2, "0"); - const seconds = String( - Math.floor((remain % (1000 * 60)) / 1000), - ).padStart(2, "0"); - - return `${hours}:${minutes}:${seconds}`; - } - } - - if (detail.remainingTime) { - return detail.remainingTime; - } - - return "12:00:00"; -}; - -const normalizeMyTeam = (raw) => { - const data = unwrapResponse(raw); - +// ─── normalizers ──────────────────────────────────────────────────────────── + +const normalizeGlobalCell = (cell) => ({ + cellId: cell.cellId, + status: + cell.status === "OCCUPIED" + ? "OCCUPIED" + : cell.status === "PROCESSING" + ? "PENDING" + : "AVAILABLE", + missionTitle: cell.mission ?? cell.missionTitle ?? "", + imageUrl: resolveImageUrl(cell.imageUrl), + teamName: cell.teamName ?? null, + borderType: null, +}); + +const normalizeTeamCell = (cell) => { + const bt = cell.borderType ?? "NONE"; + const uiStatus = + bt === "BLACK" || bt === "GRAY" + ? "OCCUPIED" + : bt === "PROCESSING_OURS" || bt === "PROCESSING_OTHERS" + ? "PENDING" + : "AVAILABLE"; return { - teamId: - data?.teamId ?? - data?.id ?? - Number(localStorage.getItem("teamId")) ?? - Number(sessionStorage.getItem("teamId")) ?? - null, - teamName: - data?.teamName ?? - data?.name ?? - localStorage.getItem("teamName") ?? - sessionStorage.getItem("teamName") ?? - "우리팀", - }; -}; - -const getMissionTitle = (source, fallback = "") => { - return ( - source?.mission?.title ?? - source?.missionTitle ?? - source?.title ?? - source?.missionName ?? - fallback - ); -}; - -const getMissionDescription = (source, fallback = "") => { - return ( - source?.mission?.description ?? - source?.mission?.discription ?? - source?.missionDescription ?? - source?.description ?? - source?.discription ?? - source?.missionContent ?? - fallback - ); -}; - -const getPendingTeamName = (source) => { - return ( - source?.displayData?.teamName ?? - source?.pendingTeamName ?? - source?.processingTeamName ?? - source?.uploadTeamName ?? - source?.uploaderTeamName ?? - source?.pendingTeam?.teamName ?? - source?.pendingTeam?.name ?? - source?.processingTeam?.teamName ?? - source?.processingTeam?.name ?? - source?.uploadTeam?.teamName ?? - source?.uploadTeam?.name ?? - source?.uploaderTeam?.teamName ?? - source?.uploaderTeam?.name ?? - source?.team?.teamName ?? - source?.team?.name ?? - source?.teamName ?? - null - ); -}; - -const getOccupiedTeamName = (source) => { - return ( - source?.displayData?.teamName ?? - source?.occupiedTeamName ?? - source?.ownerTeamName ?? - source?.teamName ?? - source?.occupiedTeam?.teamName ?? - source?.occupiedTeam?.name ?? - source?.ownerTeam?.teamName ?? - source?.ownerTeam?.name ?? - source?.team?.teamName ?? - source?.team?.name ?? - null - ); -}; - -const getRemainingTime = (source) => { - return ( - source?.displayData?.remainingTime ?? - source?.remainingTime ?? - source?.remaining ?? - null - ); -}; - -const getUploadedImageUrl = ( - source, - cellId, - rawStatus, - fallbackWhenExists = false, -) => { - const imageUrl = source?.displayData?.imageUrl ?? source?.imageUrl ?? null; - - if (imageUrl) { - return getImageUrlByCellId(cellId, imageUrl); - } - - if ( - fallbackWhenExists || - rawStatus === "PROCESSING" || - rawStatus === "OCCUPIED" - ) { - return getImageUrlByCellId(cellId); - } - - return ""; -}; - -const normalizeGlobalCell = (cell) => { - const cellId = cell?.cellId ?? cell?.id; - const rawStatus = cell?.status ?? cell?.cellStatus ?? cell?.state; - const uploadedAt = - cell?.uploadedAt ?? - cell?.createdAt ?? - cell?.processStartedAt ?? - cell?.startedAt ?? - null; - const remainingTime = getRemainingTime(cell); - - return { - cellId, - missionTitle: getMissionTitle(cell, ""), - missionDescription: getMissionDescription(cell, ""), - status: globalStatusToUiStatus(rawStatus), - pendingTeamId: cell?.pendingTeamId ?? cell?.processingTeamId ?? null, - pendingTeamName: getPendingTeamName(cell), - occupiedTeamId: cell?.occupiedTeamId ?? cell?.teamId ?? null, - occupiedTeamName: getOccupiedTeamName(cell), - uploadedImageUrl: getUploadedImageUrl(cell, cellId, rawStatus), - uploadedAt, - remainingTime, - remainingSeconds: - typeof cell?.remainingSeconds === "number" - ? cell.remainingSeconds - : parseRemainingSeconds(remainingTime, uploadedAt), - borderType: null, - isMine: false, - statusLabel: cell?.displayData?.statusLabel ?? null, - }; -}; - -const normalizeTeamCell = (cell, myTeamId) => { - const cellId = cell?.cellId ?? cell?.id; - const borderType = cell?.borderType ?? cell?.status ?? "NONE"; - const uiStatus = teamBorderTypeToUiStatus(borderType); - const isProcessingOthers = borderType === "PROCESSING_OTHERS"; - - const isMine = - borderType === "PROCESSING_OURS" || - borderType === "BLACK" || - cell?.isMine === true || - cell?.pendingTeamId === myTeamId || - cell?.occupiedTeamId === myTeamId; - - const uploadedAt = isProcessingOthers - ? null - : (cell?.uploadedAt ?? - cell?.createdAt ?? - cell?.processStartedAt ?? - cell?.startedAt ?? - null); - - const remainingTime = isProcessingOthers ? null : getRemainingTime(cell); - - return { - cellId, - missionTitle: getMissionTitle(cell, `미션 ${cellId}`), - missionDescription: getMissionDescription(cell, ""), + cellId: cell.cellId, status: uiStatus, - pendingTeamId: isProcessingOthers - ? null - : borderType === "PROCESSING_OURS" - ? myTeamId - : (cell?.pendingTeamId ?? cell?.processingTeamId ?? null), - pendingTeamName: isProcessingOthers ? null : getPendingTeamName(cell), - occupiedTeamId: - borderType === "BLACK" - ? myTeamId - : (cell?.occupiedTeamId ?? cell?.teamId ?? null), - occupiedTeamName: getOccupiedTeamName(cell), - uploadedImageUrl: - !isProcessingOthers && - (borderType !== "NONE" || cell?.imageUrl || cell?.displayData?.imageUrl) - ? getUploadedImageUrl(cell, cellId, borderType, true) - : "", - uploadedAt, - remainingTime, - remainingSeconds: isProcessingOthers - ? null - : typeof cell?.remainingSeconds === "number" - ? cell.remainingSeconds - : parseRemainingSeconds(remainingTime, uploadedAt), - borderType: isProcessingOthers ? "NONE" : borderType, - isMine: isProcessingOthers ? false : isMine, - statusLabel: cell?.displayData?.statusLabel ?? null, + borderType: bt, + isOurTeam: cell.isOurTeam ?? false, + imageUrl: resolveImageUrl(cell.imageUrl), + missionTitle: cell.mission ?? cell.missionTitle ?? "", }; }; -const normalizeGlobalDetail = (detail) => { - const cellId = detail?.cellId ?? detail?.id; - const rawStatus = detail?.status ?? detail?.cellStatus ?? detail?.state; - const uploadedAt = - detail?.uploadedAt ?? - detail?.createdAt ?? - detail?.processStartedAt ?? - detail?.startedAt ?? - null; - const remainingTime = getRemainingTime(detail); +const normalizeGlobalDetail = (d) => ({ + cellId: d.cellId, + status: + d.status === "OCCUPIED" + ? "OCCUPIED" + : d.status === "PROCESSING" + ? "PENDING" + : "AVAILABLE", + missionTitle: d.mission?.title ?? "", + missionDescription: d.mission?.description ?? d.mission?.discription ?? "", + imageUrl: resolveImageUrl(d.displayData?.imageUrl ?? ""), + teamName: d.displayData?.teamName ?? null, + remainingTime: d.displayData?.remainingTime ?? null, + statusLabel: d.displayData?.statusLabel ?? null, + remainingSeconds: null, + isMine: false, + borderType: null, +}); + +const normalizeTeamDetail = (d) => { + const bt = d.borderType ?? d.status ?? "NONE"; + const uiStatus = + bt === "BLACK" || bt === "GRAY" || d.status === "OCCUPIED" + ? "OCCUPIED" + : bt === "PROCESSING_OURS" || + bt === "PROCESSING_OTHERS" || + d.status === "PROCESSING" + ? "PENDING" + : "AVAILABLE"; + + const isMine = bt === "PROCESSING_OURS" || bt === "BLACK"; return { - cellId, - missionTitle: getMissionTitle(detail, `미션 ${cellId}`), - missionDescription: getMissionDescription(detail, ""), - status: globalStatusToUiStatus(rawStatus), - pendingTeamId: detail?.pendingTeamId ?? detail?.processingTeamId ?? null, - pendingTeamName: getPendingTeamName(detail), - occupiedTeamId: detail?.occupiedTeamId ?? detail?.teamId ?? null, - occupiedTeamName: getOccupiedTeamName(detail), - uploadedImageUrl: getUploadedImageUrl(detail, cellId, rawStatus), - uploadedAt, - remainingTime, - remainingSeconds: - typeof detail?.remainingSeconds === "number" - ? detail.remainingSeconds - : parseRemainingSeconds(remainingTime, uploadedAt), - borderType: null, - isMine: false, - statusLabel: detail?.displayData?.statusLabel ?? null, - }; -}; - -const normalizeTeamDetail = (detail, myTeamId) => { - const cellId = detail?.cellId ?? detail?.id; - const borderType = detail?.borderType ?? detail?.status ?? "NONE"; - const uiStatus = teamBorderTypeToUiStatus(borderType); - const isProcessingOthers = borderType === "PROCESSING_OTHERS"; - - const isMine = - borderType === "PROCESSING_OURS" || - borderType === "BLACK" || - detail?.isMine === true || - detail?.pendingTeamId === myTeamId || - detail?.occupiedTeamId === myTeamId; - - const uploadedAt = isProcessingOthers - ? null - : (detail?.uploadedAt ?? - detail?.createdAt ?? - detail?.processStartedAt ?? - detail?.startedAt ?? - null); - - const remainingTime = isProcessingOthers ? null : getRemainingTime(detail); - - return { - cellId, - missionTitle: getMissionTitle(detail, `미션 ${cellId}`), - missionDescription: getMissionDescription(detail, ""), + cellId: d.cellId, status: uiStatus, - pendingTeamId: isProcessingOthers - ? null - : borderType === "PROCESSING_OURS" - ? myTeamId - : (detail?.pendingTeamId ?? detail?.processingTeamId ?? null), - pendingTeamName: isProcessingOthers ? null : getPendingTeamName(detail), - occupiedTeamId: - borderType === "BLACK" - ? myTeamId - : (detail?.occupiedTeamId ?? detail?.teamId ?? null), - occupiedTeamName: getOccupiedTeamName(detail), - uploadedImageUrl: - !isProcessingOthers && - (borderType !== "NONE" || - detail?.imageUrl || - detail?.displayData?.imageUrl) - ? getUploadedImageUrl(detail, cellId, borderType, true) - : "", - uploadedAt, - remainingTime, - remainingSeconds: isProcessingOthers - ? null - : typeof detail?.remainingSeconds === "number" - ? detail.remainingSeconds - : parseRemainingSeconds(remainingTime, uploadedAt), - borderType: isProcessingOthers ? "NONE" : borderType, - isMine: isProcessingOthers ? false : isMine, - statusLabel: detail?.displayData?.statusLabel ?? null, + borderType: bt, + isMine, + missionTitle: d.mission?.title ?? "", + missionDescription: d.mission?.description ?? d.mission?.discription ?? "", + imageUrl: resolveImageUrl(d.displayData?.imageUrl ?? ""), + teamName: d.displayData?.teamName ?? null, + remainingSeconds: d.displayData?.remainingSeconds ?? null, + remainingTime: d.displayData?.remainingTime ?? null, + isConfirmed: d.displayData?.isConfirmed ?? false, }; }; -const extractCells = (raw) => { - const data = unwrapResponse(raw); - return Array.isArray(data) ? data : data?.cells || []; -}; +// ─── API calls ────────────────────────────────────────────────────────────── async function getMyTeamInfo() { try { const res = await axiosInstance.get("/v1/team/me"); - return normalizeMyTeam(res); - } catch (error) { - return normalizeMyTeam({}); + const d = res?.data ?? res; + return { + teamId: d?.teamId ?? d?.id ?? null, + teamName: d?.teamName ?? d?.name ?? "우리팀", + }; + } catch { + return { teamId: null, teamName: "우리팀" }; } } async function getGlobalBoard() { const res = await axiosInstance.get("/v1/bingo/global"); - return extractCells(res); + const d = res?.data ?? res; + return (d?.cells ?? (Array.isArray(d) ? d : [])).map(normalizeGlobalCell); } async function getTeamBoard() { const res = await axiosInstance.get("/v1/bingo/team"); - return unwrapResponse(res); + const d = res?.data ?? res; + return { + teamName: d?.teamName ?? null, + cells: (d?.cells ?? (Array.isArray(d) ? d : [])).map(normalizeTeamCell), + }; } -async function getGlobalMissionDetail(cellId) { +async function getGlobalDetail(cellId) { const res = await axiosInstance.get(`/v1/bingo/global/${cellId}`); - return unwrapResponse(res); + return normalizeGlobalDetail(res?.data ?? res); } -async function getTeamMissionDetail(cellId) { +async function getTeamDetail(cellId) { const res = await axiosInstance.get(`/v1/bingo/team/${cellId}`); - return unwrapResponse(res); + return normalizeTeamDetail(res?.data ?? res); } -async function postMissionPhotoUpload({ cellId, file }) { +async function uploadMissionPhoto(cellId, file, method = "post") { const formData = new FormData(); formData.append("image", file); - - const res = await axiosInstance.post( + const res = await axiosInstance[method]( `/v1/bingo/team/${cellId}/upload`, formData, - { - headers: { - "Content-Type": "multipart/form-data", - }, - }, + { headers: { "Content-Type": "multipart/form-data" } }, ); - - return unwrapResponse(res); + return res?.data ?? res; } -async function patchMissionPhotoUpload({ cellId, file }) { - const formData = new FormData(); - formData.append("image", file); +// ─── UI helpers ───────────────────────────────────────────────────────────── - const res = await axiosInstance.patch( - `/v1/bingo/team/${cellId}/upload`, - formData, - { - headers: { - "Content-Type": "multipart/form-data", - }, - }, - ); +const getCellColor = (status) => { + if (status === "PENDING") return "#FFF36A"; + if (status === "OCCUPIED") return "#FF5A5A"; + return "#FFFFFF"; +}; - return unwrapResponse(res); -} +const getPanelBgColor = (status) => { + if (status === "PENDING") return "#FFFBE0"; + if (status === "OCCUPIED") return "#FFE2DD"; + return "#FFFFFF"; +}; + +const formatSeconds = (totalSeconds) => { + if (totalSeconds == null || totalSeconds < 0) return null; + const s = Math.floor(totalSeconds); + const h = String(Math.floor(s / 3600)).padStart(2, "0"); + const m = String(Math.floor((s % 3600) / 60)).padStart(2, "0"); + const sec = String(s % 60).padStart(2, "0"); + return `${h}:${m}:${sec}`; +}; + +// ─── Component ────────────────────────────────────────────────────────────── function NewBingo() { const [myTeam, setMyTeam] = useState(null); const [globalBoard, setGlobalBoard] = useState([]); const [teamBoard, setTeamBoard] = useState([]); + const [teamName, setTeamName] = useState(null); const [selectedCellId, setSelectedCellId] = useState(null); const [selectedBoardType, setSelectedBoardType] = useState("main"); const [selectedDetail, setSelectedDetail] = useState(null); const [toast, setToast] = useState(""); - const [now, setNow] = useState(Date.now()); - const [selectedTeamCell, setSelectedTeamCell] = useState(null); - const fileInputRef = useRef(null); - const isMainBoard = selectedBoardType === "main"; const isTeamBoard = selectedBoardType === "team"; + const isMainBoard = selectedBoardType === "main"; const isUploadDisabled = - !selectedDetail || - selectedDetail.status === "OCCUPIED" || - (selectedDetail.status === "PENDING" && - !selectedDetail.isMine && - selectedDetail.borderType === "PROCESSING_OTHERS"); + !selectedDetail || selectedDetail.status === "OCCUPIED" || !isTeamBoard; + + // ── data loading ────────────────────────────────────────────────────────── const loadBoards = useCallback(async () => { try { - const myTeamInfo = await getMyTeamInfo(); - setMyTeam(myTeamInfo); - - const [globalCellsRaw, teamRaw] = await Promise.all([ + const [myTeamInfo, globalCells, teamData] = await Promise.all([ + getMyTeamInfo(), getGlobalBoard(), getTeamBoard(), ]); - - const teamCellsRaw = Array.isArray(teamRaw) - ? teamRaw - : teamRaw?.cells || []; - const teamInfoFromBoard = { - teamId: - myTeamInfo?.teamId ?? - teamRaw?.teamId ?? - teamRaw?.id ?? - Number(localStorage.getItem("teamId")) ?? - Number(sessionStorage.getItem("teamId")) ?? - null, - teamName: - myTeamInfo?.teamName ?? - teamRaw?.teamName ?? - teamRaw?.name ?? - localStorage.getItem("teamName") ?? - sessionStorage.getItem("teamName") ?? - "우리팀", - }; - - setMyTeam(teamInfoFromBoard); - setGlobalBoard(globalCellsRaw.map(normalizeGlobalCell)); - setTeamBoard( - teamCellsRaw.map((cell) => - normalizeTeamCell(cell, teamInfoFromBoard.teamId), - ), - ); + setMyTeam(myTeamInfo); + setGlobalBoard(globalCells); + setTeamName(teamData.teamName ?? myTeamInfo.teamName); + setTeamBoard(teamData.cells); } catch (error) { setToast(getErrorMessage(error, "빙고판 정보를 불러오지 못했습니다.")); } }, []); - const refreshSelectedDetail = useCallback( - async (cellId, boardType = selectedBoardType, teamInfo = myTeam) => { - if (!cellId) return; - - try { - if (boardType === "team") { - const detailRaw = await getTeamMissionDetail(cellId); - setSelectedDetail( - normalizeTeamDetail(detailRaw, teamInfo?.teamId ?? null), - ); - } else { - const detailRaw = await getGlobalMissionDetail(cellId); - setSelectedDetail(normalizeGlobalDetail(detailRaw)); - } - } catch (error) { - setToast( - getErrorMessage(error, "미션 상세 정보를 불러오지 못했습니다."), - ); - } - }, - [myTeam, selectedBoardType], - ); + const refreshDetail = useCallback(async (cellId, boardType) => { + if (!cellId) return; + try { + const detail = + boardType === "team" + ? await getTeamDetail(cellId) + : await getGlobalDetail(cellId); + setSelectedDetail(detail); + } catch (error) { + setToast(getErrorMessage(error, "미션 정보를 불러오지 못했습니다.")); + } + }, []); + + // ── effects ─────────────────────────────────────────────────────────────── useEffect(() => { loadBoards(); }, [loadBoards]); + // 1초마다 remainingSeconds 카운트다운 useEffect(() => { const timer = setInterval(() => { - setNow(Date.now()); - setSelectedDetail((prev) => { - if (!prev) return prev; - if (prev.status !== "PENDING") return prev; - - const nextSeconds = - typeof prev.remainingSeconds === "number" - ? Math.max(0, prev.remainingSeconds - 1) - : parseRemainingSeconds(prev.remainingTime, prev.uploadedAt); - + if (!prev || prev.status !== "PENDING") return prev; + if (prev.remainingSeconds == null) return prev; return { ...prev, - remainingSeconds: nextSeconds, + remainingSeconds: Math.max(0, prev.remainingSeconds - 1), }; }); }, 1000); - return () => clearInterval(timer); }, []); + // 10초마다 자동 새로고침 useEffect(() => { const timer = setInterval(async () => { await loadBoards(); - if (selectedCellId) { - await refreshSelectedDetail(selectedCellId, selectedBoardType, myTeam); + await refreshDetail(selectedCellId, selectedBoardType); } }, 10000); - return () => clearInterval(timer); - }, [ - loadBoards, - refreshSelectedDetail, - selectedCellId, - selectedBoardType, - myTeam, - ]); + }, [loadBoards, refreshDetail, selectedCellId, selectedBoardType]); + // 토스트 자동 제거 useEffect(() => { if (!toast) return; const timer = setTimeout(() => setToast(""), 2200); return () => clearTimeout(timer); }, [toast]); + // ── handlers ────────────────────────────────────────────────────────────── + const handleOpenDetail = async (cellId, boardType) => { setSelectedCellId(cellId); setSelectedBoardType(boardType); + setSelectedDetail(null); + // 보드에서 borderType 미리 가져오기 if (boardType === "team") { - const clickedTeamCell = - teamBoard.find((cell) => cell.cellId === cellId) || null; - setSelectedTeamCell(clickedTeamCell); - } else { - setSelectedTeamCell(null); + const boardCell = teamBoard.find((c) => c.cellId === cellId); + if (boardCell?.borderType === "PROCESSING_OTHERS") { + // 상세 API 호출하되 borderType 강제 주입 + try { + const detail = await getTeamDetail(cellId); + setSelectedDetail({ ...detail, borderType: "PROCESSING_OTHERS" }); + } catch (error) { + setToast(getErrorMessage(error, "미션 정보를 불러오지 못했습니다.")); + } + return; + } } - await refreshSelectedDetail(cellId, boardType, myTeam); + await refreshDetail(cellId, boardType); }; const handleCloseDetail = () => { setSelectedCellId(null); setSelectedDetail(null); - setSelectedTeamCell(null); }; const handleUploadClick = () => { - if (!isTeamBoard) return; if (isUploadDisabled) return; fileInputRef.current?.click(); }; - const handleFileChange = async (event) => { - const file = event.target.files?.[0]; + const handleFileChange = async (e) => { + const file = e.target.files?.[0]; if (!file || !selectedDetail) return; try { const isMyPending = - selectedDetail.status === "PENDING" && - (selectedDetail.isMine || - selectedDetail.borderType === "PROCESSING_OURS" || - selectedDetail.pendingTeamId === myTeam?.teamId); - - await (isMyPending - ? patchMissionPhotoUpload({ - cellId: selectedDetail.cellId, - file, - }) - : postMissionPhotoUpload({ - cellId: selectedDetail.cellId, - file, - })); + selectedDetail.status === "PENDING" && selectedDetail.isMine; + + await uploadMissionPhoto( + selectedDetail.cellId, + file, + isMyPending ? "patch" : "post", + ); setToast( isMyPending - ? "업로드한 사진이 수정되었습니다." - : "미션 사진이 업로드되었습니다.", + ? "사진이 수정되었습니다." + : "미션 사진이 업로드되었습니다. 12시간 타이머가 시작됩니다!", ); await loadBoards(); - await refreshSelectedDetail(selectedDetail.cellId, "team", myTeam); + await refreshDetail(selectedDetail.cellId, "team"); } catch (error) { setToast(getErrorMessage(error, "업로드에 실패했습니다.")); } finally { - event.target.value = ""; + e.target.value = ""; } }; + // ── render helpers ──────────────────────────────────────────────────────── + + const isProcessingOthers = (boardType, cell) => + boardType === "team" && cell?.borderType === "PROCESSING_OTHERS"; + + const renderBoardCell = (cell, boardType) => { + const showImage = + (cell.status === "OCCUPIED" || cell.status === "PENDING") && + cell.imageUrl && + !isProcessingOthers(boardType, cell); + + // 팀 빙고에서 다른 팀 진행 중인 칸은 흰색 + const bgColor = + boardType === "team" && cell.borderType === "PROCESSING_OTHERS" + ? "#FFFFFF" + : getCellColor(cell.status); + + return ( + handleOpenDetail(cell.cellId, boardType)} + > + {showImage ? ( + + ) : ( + {cell.missionTitle} + )} + + ); + }; + + const renderUploadButton = () => { + if (!isTeamBoard || !selectedDetail) return null; + + let label = "미션 사진 업로드"; + if (selectedDetail.status === "OCCUPIED") label = "미션 성공!"; + else if (selectedDetail.status === "PENDING") { + label = selectedDetail.isMine ? "사진 수정하기" : "사진 업로드"; + } + + return ( + <> + + + {label} + + + ); + }; + + const renderDetailImage = () => { + if (!selectedDetail) return null; + + // PROCESSING_OTHERS: 다른 팀 사진 숨기고 빈 placeholder + if (isTeamBoard && selectedDetail.borderType === "PROCESSING_OTHERS") { + return 사진; + } + + if (selectedDetail.imageUrl) { + return ( + + ); + } + + return 사진; + }; + + const displayTeamName = teamName ?? myTeam?.teamName ?? ""; + + // ── JSX ─────────────────────────────────────────────────────────────────── + return (
@@ -760,6 +426,7 @@ function NewBingo() { + {/* ── Main Bingo ── */} Main Bingo @@ -771,23 +438,7 @@ function NewBingo() { - {globalBoard.map((cell) => ( - handleOpenDetail(cell.cellId, "main")} - > - {cell.status === "OCCUPIED" && cell.uploadedImageUrl ? ( - - ) : ( - {cell.missionTitle} - )} - - ))} + {globalBoard.map((cell) => renderBoardCell(cell, "main"))} @@ -795,7 +446,6 @@ function NewBingo() { CAUTION ! - @@ -803,12 +453,11 @@ function NewBingo() { 표시됩니다. - 미션 사진을 업로드 한 후 12시간 동안 다른 팀이 업로드 하지 - 않으면 해당 칸이 주황색으로 변하며 빙고 칸 차지 성공!! + 않으면 해당 칸이 빨간색으로 변하며 빙고 칸 차지 성공!! @@ -816,10 +465,11 @@ function NewBingo() { + {/* ── Team Bingo ── */} - {myTeam - ? `${myTeam.teamName.replace("팀", "")}팀 Bingo` + {displayTeamName + ? `${displayTeamName.replace("팀", "")}팀 Bingo` : "Team Bingo"} @@ -829,25 +479,7 @@ function NewBingo() { - {teamBoard.map((cell) => ( - handleOpenDetail(cell.cellId, "team")} - > - {(cell.status === "OCCUPIED" || - cell.status === "PENDING") && - cell.uploadedImageUrl ? ( - - ) : ( - {cell.missionTitle} - )} - - ))} + {teamBoard.map((cell) => renderBoardCell(cell, "team"))} @@ -855,7 +487,6 @@ function NewBingo() { CAUTION ! - @@ -863,12 +494,11 @@ function NewBingo() { 표시됩니다. - 미션 사진을 업로드 한 후 12시간 동안 다른 팀이 업로드 하지 - 않으면 해당 칸이 주황색으로 변하며 빙고 칸 차지 성공!! + 않으면 해당 칸이 빨간색으로 변하며 빙고 칸 차지 성공!! @@ -877,7 +507,8 @@ function NewBingo() { - {selectedCellId && selectedDetail && ( + {/* ── Side Panel ── */} + {selectedCellId && ( <> @@ -888,11 +519,17 @@ function NewBingo() { - + {isMainBoard ? "Main Bingo" - : `${myTeam?.teamName || "Team"} Bingo`} + : `${displayTeamName || "Team"} Bingo`} @@ -903,67 +540,54 @@ function NewBingo() { - {selectedDetail.missionTitle} - - 미션 내용 : - {selectedDetail.missionDescription} - - {selectedDetail.status === "PENDING" && - selectedDetail.remainingTime && ( - <> - 남은 시간 : - - {formatRemainingTime(selectedDetail, now)} - - - )} - - {isTeamBoard ? ( - selectedTeamCell?.uploadedImageUrl ? ( - selectedTeamCell?.isMine ? ( - - ) : ( - - ) - ) : ( - 사진 - ) - ) : selectedDetail.uploadedImageUrl ? ( - - ) : ( - 사진 - )} - - {isTeamBoard && ( + {selectedDetail ? ( <> - - - - {selectedDetail.status === "OCCUPIED" - ? "미션 성공!" - : selectedDetail.status === "PENDING" - ? selectedDetail.isMine - ? "사진 수정하기" - : "미션 진행 중" - : "미션 사진 업로드"} - + {selectedDetail.missionTitle} + + 미션 내용 : + + {selectedDetail.missionDescription || + "미션 내용이 없습니다."} + + + {isTeamBoard && + selectedDetail.borderType === "PROCESSING_OTHERS" ? ( + <> + {renderDetailImage()} + {renderUploadButton()} + + ) : ( + <> + {selectedDetail.status === "PENDING" && + selectedDetail.remainingSeconds != null && ( + <> + 남은 시간 : + + {formatSeconds(selectedDetail.remainingSeconds) ?? + selectedDetail.remainingTime ?? + ""} + + + )} + + {selectedDetail.teamName && ( + <> + + {selectedDetail.status === "OCCUPIED" + ? "점유 팀 :" + : "진행 중인 팀 :"} + + {selectedDetail.teamName} + + )} + + {renderDetailImage()} + {renderUploadButton()} + + )} + ) : ( + 불러오는 중... )} @@ -977,6 +601,8 @@ function NewBingo() { export default NewBingo; +// ─── Styled Components ─────────────────────────────────────────────────────── + const Root = styled.div` min-height: 100vh; background: linear-gradient(180deg, #1c1c1c 0%, #002d56 100%); @@ -1276,6 +902,14 @@ const PreviewImage = styled.img` margin-bottom: 18px; `; +const TeamHiddenPlaceholder = styled.div` + width: 100%; + aspect-ratio: 1 / 0.72; + border-radius: 10px; + background: #d9d9d9; + margin-bottom: 18px; +`; + const HiddenFileInput = styled.input` display: none; `; @@ -1308,11 +942,3 @@ const Toast = styled.div` font-weight: 600; z-index: 12000; `; - -const TeamHiddenPlaceholder = styled.div` - width: 100%; - aspect-ratio: 1 / 0.72; - border-radius: 10px; - background: #d9d9d9; - margin-bottom: 18px; -`;