diff --git a/src/pages/Bingo.jsx b/src/pages/Bingo.jsx index 74d8027..454c058 100644 --- a/src/pages/Bingo.jsx +++ b/src/pages/Bingo.jsx @@ -8,730 +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 `${API_BASE_URL}${imageUrl}`; - return imageUrl; - } - - return `${API_BASE_URL}/v1/bingo/image/${cellId}`; -}; - -const getCellColor = (status) => { - if (status === "PENDING") return "#FFF36A"; - if (status === "OCCUPIED") return "#FF5A5A"; - return "#FFFFFF"; -}; +const getErrorMessage = (error, fallback = "요청에 실패했습니다.") => + error?.response?.data?.message || + error?.response?.data?.error || + error?.message || + fallback; -const getPanelBgColor = (status) => { - if (status === "PENDING") return "#FFFBE0"; - if (status === "OCCUPIED") return "#FFE2DD"; - return "#FFFFFF"; +const resolveImageUrl = (url) => { + if (!url) return ""; + if (url.startsWith("http")) return url; + return `https://api.z0.co.kr${url.startsWith("/") ? "" : "/"}${url}`; }; -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), - ); - } - } - - return null; -}; - -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); - - 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 getPendingTeamName = (source) => { - return ( - 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?.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 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 = cell?.remainingTime ?? null; - - return { - cellId, - missionTitle: - cell?.missionTitle ?? - cell?.title ?? - cell?.missionName ?? - `미션 ${cellId}`, - missionDescription: - cell?.missionDescription ?? - cell?.description ?? - cell?.missionContent ?? - "", - 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) - : "", - uploadedAt, - remainingTime, - remainingSeconds: - typeof cell?.remainingSeconds === "number" - ? cell.remainingSeconds - : parseRemainingSeconds(remainingTime, uploadedAt), - borderType: null, - isMine: false, - }; -}; - -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 - : (cell?.remainingTime ?? null); - +// ─── 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 { - cellId, - missionTitle: - cell?.missionTitle ?? - cell?.title ?? - cell?.missionName ?? - `미션 ${cellId}`, - missionDescription: - cell?.missionDescription ?? - cell?.description ?? - cell?.missionContent ?? - "", + cellId: cell.cellId, status: uiStatus, - pendingTeamId: isProcessingOthers - ? null - : borderType === "PROCESSING_OURS" - ? 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) - : "", - uploadedAt, - remainingTime, - remainingSeconds: isProcessingOthers - ? null - : typeof cell?.remainingSeconds === "number" - ? cell.remainingSeconds - : parseRemainingSeconds(remainingTime, uploadedAt), - borderType: isProcessingOthers ? "NONE" : borderType, - isMine: isProcessingOthers ? false : isMine, + 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 = detail?.remainingTime ?? detail?.remaining ?? null; +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: - detail?.missionTitle ?? - detail?.title ?? - detail?.missionName ?? - `미션 ${cellId}`, - missionDescription: - detail?.missionDescription ?? - detail?.description ?? - detail?.missionContent ?? - "", - 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) - : "", - uploadedAt, - remainingTime, - remainingSeconds: - typeof detail?.remainingSeconds === "number" - ? detail.remainingSeconds - : parseRemainingSeconds(remainingTime, uploadedAt), - borderType: null, - isMine: false, - }; -}; - -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 - : (detail?.remainingTime ?? detail?.remaining ?? null); - - return { - cellId, - missionTitle: - detail?.missionTitle ?? - detail?.title ?? - detail?.missionName ?? - `미션 ${cellId}`, - missionDescription: - detail?.missionDescription ?? - detail?.description ?? - detail?.missionContent ?? - "", + 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) - ? getImageUrlByCellId(cellId, detail?.imageUrl) - : "", - uploadedAt, - remainingTime, - remainingSeconds: isProcessingOthers - ? null - : typeof detail?.remainingSeconds === "number" - ? detail.remainingSeconds - : parseRemainingSeconds(remainingTime, uploadedAt), - borderType: isProcessingOthers ? "NONE" : borderType, - isMine: isProcessingOthers ? false : isMine, + 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 (
@@ -739,6 +426,7 @@ function NewBingo() { + {/* ── Main Bingo ── */} Main Bingo @@ -750,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"))} @@ -774,7 +446,6 @@ function NewBingo() { CAUTION ! - @@ -782,12 +453,11 @@ function NewBingo() { 표시됩니다. - 미션 사진을 업로드 한 후 12시간 동안 다른 팀이 업로드 하지 - 않으면 해당 칸이 주황색으로 변하며 빙고 칸 차지 성공!! + 않으면 해당 칸이 빨간색으로 변하며 빙고 칸 차지 성공!! @@ -795,10 +465,11 @@ function NewBingo() { + {/* ── Team Bingo ── */} - {myTeam - ? `${myTeam.teamName.replace("팀", "")}팀 Bingo` + {displayTeamName + ? `${displayTeamName.replace("팀", "")}팀 Bingo` : "Team Bingo"} @@ -808,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"))} @@ -834,7 +487,6 @@ function NewBingo() { CAUTION ! - @@ -842,12 +494,11 @@ function NewBingo() { 표시됩니다. - 미션 사진을 업로드 한 후 12시간 동안 다른 팀이 업로드 하지 - 않으면 해당 칸이 주황색으로 변하며 빙고 칸 차지 성공!! + 않으면 해당 칸이 빨간색으로 변하며 빙고 칸 차지 성공!! @@ -856,7 +507,8 @@ function NewBingo() { - {selectedCellId && selectedDetail && ( + {/* ── Side Panel ── */} + {selectedCellId && ( <> @@ -867,11 +519,17 @@ function NewBingo() { - + {isMainBoard ? "Main Bingo" - : `${myTeam?.teamName || "Team"} Bingo`} + : `${displayTeamName || "Team"} Bingo`} @@ -882,57 +540,54 @@ function NewBingo() { - {selectedDetail.missionTitle} - - 미션 내용 : - {selectedDetail.missionDescription} - - {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()} + + )} + ) : ( + 불러오는 중... )} @@ -946,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%); @@ -1245,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; `; @@ -1277,10 +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; -`;