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;
-`;