"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import styles from "./map.module.css"; import { getHuntTimer, setHuntTimer, clearHuntTimer, formatElapsedTime, } from "@/utils/timerService"; export default function MapPage() { const { data: session, status } = useSession(); const [loading, setLoading] = useState(true); const [mapError, setMapError] = useState(false); const [error, setError] = useState(null); const [teamData, setTeamData] = useState(null); const [elapsedTime, setElapsedTime] = useState("00:00:00"); const [mapSvg, setMapSvg] = useState(null); const [clues, setClues] = useState([]); const mapContainerRef = useRef(null); const router = useRouter(); const isMounted = useRef(true); // Add local state for hunt active status const [huntActive, setHuntActive] = useState(null); // Use useCallback for functions called in useEffect to avoid dependency issues const softRedirect = useCallback( (url) => { if (isMounted.current) { router.push(url); } }, [router] ); // Hard redirect function (for when a full page reload is needed) const hardRedirect = useCallback((url) => { if (typeof window !== "undefined" && isMounted.current) { window.location.href = url; } }, []); // Add function to fetch team hunt start time const fetchTeamHuntStartTime = useCallback(async () => { try { // First check localStorage for cached start time const cachedStartTime = localStorage.getItem("teamHuntStartTime"); // If we have a cached time, use it if (cachedStartTime) { return cachedStartTime; } // Otherwise fetch from the server - use effectiveTeamId which is derived from session const effectiveTeamId = session?.user?.teamId; if (effectiveTeamId) { const response = await fetch( `/api/teams/${effectiveTeamId}/hunt-session`, { method: "GET", headers: { "Content-Type": "application/json", "Cache-Control": "no-cache, no-store, must-revalidate", }, credentials: "include", } ); if (response.ok) { const data = await response.json(); if (data.startTime) { // Cache the start time for future use localStorage.setItem("teamHuntStartTime", data.startTime); return data.startTime; } } } // Fallback to current time if we couldn't get a start time return new Date().toISOString(); } catch (error) { console.error("Failed to fetch team hunt start time:", error); return new Date().toISOString(); } }, []); // No dependencies to avoid re-creation - we'll handle session internally // IMPORTANT: Move fetchTeamData up here, before it's referenced in useEffect dependencies const fetchTeamData = useCallback(async () => { if (!isMounted.current) return; try { setLoading(true); setError(null); const response = await fetch("/api/user/team", { method: "GET", headers: { "Content-Type": "application/json", "Cache-Control": "no-cache, no-store, must-revalidate", }, credentials: "include", }); if (!response.ok) { // Handle specific error cases without immediate redirect const errorData = await response.json().catch(() => ({})); if (response.status === 401) { // Set error but don't redirect immediately - let the session handling take care of it setError( "Authentication error: " + (errorData.error || "Not logged in") ); setLoading(false); return; } else if ( response.status === 404 || errorData.error?.includes("not part of a team") ) { setError("You need to join a team before viewing the map"); setLoading(false); // Give user time to read the error message before redirect setTimeout(() => { if (isMounted.current) { softRedirect("/team-selection"); } }, 2000); return; } else { throw new Error(errorData.error || "Failed to fetch team data"); } } const data = await response.json(); if (isMounted.current) { setTeamData(data); setLoading(false); } } catch (err) { console.error("Error fetching team data:", err); if (isMounted.current) { setError(err.message); setLoading(false); } } }, [softRedirect]); // Function to fetch clues const fetchClues = useCallback(async () => { try { const response = await fetch("/api/clues", { headers: { "Cache-Control": "no-cache, no-store, must-revalidate" }, credentials: "include", }); if (response.ok) { const cluesData = await response.json(); setClues(cluesData); } } catch (error) { console.error("Failed to fetch clues:", error); } }, []); // Function to load and enhance the SVG map const loadAndEnhanceMap = useCallback(async () => { try { const response = await fetch("/images/map.svg"); if (!response.ok) { setMapError(true); return; } const svgText = await response.text(); setMapSvg(svgText); } catch (error) { console.error("Failed to load map SVG:", error); setMapError(true); } }, []); // Function to enhance SVG with clickable areas const enhanceSvgMap = useCallback(() => { if (!mapContainerRef.current || !clues.length) return; // Find all circles in the SVG const circleSvgElements = mapContainerRef.current.querySelectorAll("circle"); console.log(`Found ${circleSvgElements.length} circles in SVG`); // Find the highest available clue ID (first unfound clue) let nextAvailableClueIndex = clues.findIndex((clue) => !clue.found); if (nextAvailableClueIndex === -1) { // If all clues are found, allow access to all nextAvailableClueIndex = clues.length; } // Convert NodeList to Array for easier manipulation Array.from(circleSvgElements).forEach((circle, index) => { // Only process if we have a clue for this index if (clues[index]) { // Check if this clue is available (either found or next in sequence) const isAvailable = clues[index].found || index <= nextAvailableClueIndex; // Make circle interactive circle.style.cursor = isAvailable ? "pointer" : "not-allowed"; // Set appropriate colors if (clues[index].found) { // Found clues are green circle.style.fill = "#4caf50"; circle.style.stroke = "#2e7d32"; } else if (isAvailable) { // Next available clue is orange circle.style.fill = "#ff9800"; circle.style.stroke = "#d87f00"; } else { // Unavailable clues are grey circle.style.fill = "#9e9e9e"; circle.style.stroke = "#757575"; } circle.style.strokeWidth = "2"; // Add click handler to navigate to the clue page, but only if available circle.onclick = (e) => { e.preventDefault(); e.stopPropagation(); if (isAvailable) { console.log(`Navigating to clue ${clues[index].id}`); router.push(`/clue/${clues[index].id}`); } else { alert("Complete previous clues first to unlock this one!"); } }; // Add hover effects circle.onmouseenter = () => { if (isAvailable) { circle.style.fill = "#ffb74d"; // Lighter orange for hover } }; circle.onmouseleave = () => { if (clues[index].found) { circle.style.fill = "#4caf50"; // Back to green for found clues } else if (isAvailable) { circle.style.fill = "#ff9800"; // Back to orange for available clues } else { circle.style.fill = "#9e9e9e"; // Back to grey for unavailable clues } }; // Add tooltip/title const label = document.createElementNS( "http://www.w3.org/2000/svg", "title" ); label.textContent = `Clue #${index + 1}: ${ clues[index].title || "Mystery clue" }${!isAvailable ? " (Locked)" : ""}`; circle.appendChild(label); } }); }, [clues, router]); // Clean up function for unmounting useEffect(() => { return () => { isMounted.current = false; }; }, []); // Initial hunt status check useEffect(() => { // Check if hunt is active checkHuntAndTimer(); // Set up periodic checks const interval = setInterval(checkHuntAndTimer, 3000); return () => clearInterval(interval); }, []); // Handle authentication and hunt status useEffect(() => { // Only redirect if explicitly not authenticated if (status === "unauthenticated") { softRedirect("/login"); return; } // If hunt is not active, redirect to waiting page if (huntActive === false) { console.log("Hunt is not active, redirecting to waiting page"); softRedirect("/waiting"); return; } // Only fetch team data if authenticated and hunt is active or status unknown if (status === "authenticated" && huntActive !== false) { fetchTeamData(); loadAndEnhanceMap(); fetchClues(); } }, [ status, huntActive, softRedirect, fetchTeamData, loadAndEnhanceMap, fetchClues, ]); // Add effect to enhance SVG once it's loaded and clues are fetched useEffect(() => { if (mapSvg && clues.length > 0) { console.log("SVG and clues loaded, enhancing map..."); // Let the DOM update first with the SVG content const enhanceTimeout = setTimeout(() => { enhanceSvgMap(); }, 200); return () => clearTimeout(enhanceTimeout); } }, [mapSvg, clues, enhanceSvgMap]); // Function to check hunt status and timer async function checkHuntAndTimer() { if (!isMounted.current) return; try { const response = await fetch("/api/hunt-status", { method: "GET", headers: { "Content-Type": "application/json", "Cache-Control": "no-cache, no-store, must-revalidate", }, credentials: "include", }); if (response.ok) { const data = await response.json(); console.log("Hunt status check from map:", data); // If hunt is not active, check if it has ended if (!data.active) { console.log("Hunt not active, checking if it ended..."); // Check if we had an active hunt before const timerData = getHuntTimer(); if (timerData && timerData.isActive) { // Hunt was active before but not now - it has ended console.log("Hunt has ended, redirecting to leaderboard"); clearHuntTimer(); // Clean up all timer data // Use the appropriate redirect method if (isMounted.current) { hardRedirect("/leaderboard?huntEnded=true"); } return; } // Otherwise just show the waiting page if (isMounted.current) { setHuntActive(false); softRedirect("/waiting"); } return; } // If timer endTime exists and has passed, redirect to leaderboard if (data.endTime && new Date(data.endTime) <= new Date()) { console.log("Timer expired, redirecting to leaderboard"); clearHuntTimer(); // Clean up all timer data if (isMounted.current) { softRedirect("/leaderboard?huntEnded=true"); } return; } // Otherwise, hunt is active and timer is running if (isMounted.current) { setHuntActive(true); // Update hunt timer data with the server's values if (data.startTime) { setHuntTimer(data.startTime, data.endTime); } } } } catch (err) { console.error("Failed to check hunt status:", err); } } // Use team hunt start time for elapsed time calculation useEffect(() => { // Initialize the timer without waiting for teamData // This ensures timer starts as soon as possible const initializeTimer = async () => { try { // First try to get timer data from the centralized service let timerData = getHuntTimer(); // If no timer data yet, fetch it from API if (!timerData || !timerData.startTime) { console.log("No timer data found, fetching from API..."); const startTimeStr = await fetchTeamHuntStartTime(); console.log("Got start time:", startTimeStr); if (startTimeStr) { setHuntTimer(startTimeStr); timerData = getHuntTimer(); } } // Set up interval for timer updates const timerInterval = setInterval(() => { if (!isMounted.current) { clearInterval(timerInterval); return; } const currentTimerData = getHuntTimer(); if (currentTimerData && currentTimerData.startTime) { const formattedTime = formatElapsedTime(currentTimerData.startTime); console.log("Updated timer:", formattedTime); setElapsedTime(formattedTime); } else { setElapsedTime("00:00:00"); } }, 1000); // Initial timer update if (timerData && timerData.startTime) { setElapsedTime(formatElapsedTime(timerData.startTime)); } return () => { clearInterval(timerInterval); }; } catch (error) { console.error("Error initializing timer:", error); } }; initializeTimer(); }, []); // Empty dependency array since fetchTeamHuntStartTime is defined inside the component // Check hunt status every second to ensure immediate updates useEffect(() => { const checkHuntStatus = async () => { if (!isMounted.current) return; try { const timestamp = Date.now(); // Add cache-busting timestamp const response = await fetch(`/api/hunt-status?t=${timestamp}`, { method: "GET", headers: { "Content-Type": "application/json", "Cache-Control": "no-store, no-cache, must-revalidate", Pragma: "no-cache", }, credentials: "include", }); if (!response.ok) { console.error("Failed to fetch hunt status:", response.statusText); return; } const data = await response.json(); console.log("Map page hunt status check:", data); // If hunt is not active, force redirect to waiting page if (!data.active) { console.log("Hunt not active, redirecting to waiting page"); if (isMounted.current) { softRedirect("/waiting"); } return; } // If timer endTime exists and has passed, redirect to leaderboard if (data.endTime && new Date(data.endTime) <= new Date()) { console.log("Timer expired, redirecting to leaderboard"); if (isMounted.current) { softRedirect("/leaderboard?huntEnded=true"); } return; } // Otherwise, hunt is active and timer is running if (isMounted.current) { setHuntActive(true); } } catch (err) { console.error("Failed to check hunt status:", err); } }; // Check immediately and every second checkHuntStatus(); const intervalId = setInterval(checkHuntStatus, 1000); return () => clearInterval(intervalId); }, [softRedirect]); if (status === "loading" || loading) { return (
The treasure hunt has not started yet.
{error}
We couldn't load the treasure map. Please try again.