+ {foundNow && (
+
+
🎉
+
New Clue Discovered!
+
+ )}
+
+
{elapsedTime}
+
{clue.title}
@@ -165,59 +168,13 @@ export default function CluePage({ params }) {
}
.clue-scroll {
- background-color: #f5e6ca;
- border: 2px solid #9c6b30;
+ background-color: #f8f3e3;
border-radius: 10px;
padding: 30px;
- max-width: 800px;
width: 100%;
- box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
+ max-width: 600px;
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
position: relative;
- }
-
- .clue-scroll::before,
- .clue-scroll::after {
- content: "";
- position: absolute;
- height: 30px;
- width: 100%;
- left: 0;
- }
-
- .clue-scroll::before {
- top: 0;
- background: linear-gradient(
- 180deg,
- rgba(156, 107, 48, 0.2) 0%,
- rgba(156, 107, 48, 0) 100%
- );
- border-radius: 8px 8px 0 0;
- }
-
- .clue-scroll::after {
- bottom: 0;
- background: linear-gradient(
- 0deg,
- rgba(156, 107, 48, 0.2) 0%,
- rgba(156, 107, 48, 0) 100%
- );
- border-radius: 0 0 8px 8px;
- }
-
- .clue-title {
- color: #5d4037;
- font-size: 2rem;
- text-align: center;
- margin-bottom: 20px;
- font-family: "Pirates", cursive, serif;
- border-bottom: 2px dashed #9c6b30;
- padding-bottom: 15px;
- }
-
- .clue-content {
- padding: 20px;
- background-color: #f9f3e3;
- border-radius: 8px;
margin-bottom: 20px;
border: 1px dashed #9c6b30;
}
@@ -253,51 +210,109 @@ export default function CluePage({ params }) {
padding: 10px 20px;
border: none;
border-radius: 5px;
- font-size: 1rem;
cursor: pointer;
- transition: background-color 0.3s, transform 0.2s;
+ font-weight: bold;
+ transition: all 0.2s;
}
.map-button {
- background-color: #9c6b30;
+ background-color: #68543c;
color: white;
}
.home-button {
- background-color: #5d4037;
+ background-color: #a98467;
color: white;
}
.map-button:hover,
.home-button:hover {
- transform: translateY(-3px);
- }
-
- .map-button:hover {
- background-color: #7d562a;
- }
-
- .home-button:hover {
- background-color: #4a332e;
+ transform: scale(1.05);
}
.loading,
.error,
.not-found {
- padding: 50px;
text-align: center;
- border: 2px dashed #9c6b30;
- border-radius: 8px;
- margin: 30px 0;
- color: #5d4037;
- background: #f5e6ca;
- font-size: 1.2rem;
+ padding: 30px;
+ }
+
+ .loading-spinner {
+ border: 4px solid #f3f3f3;
+ border-top: 4px solid #9c6b30;
+ border-radius: 50%;
+ width: 40px;
+ height: 40px;
+ animation: spin 1s linear infinite;
+ margin: 0 auto 20px;
+ }
+
+ @keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
}
.error {
- border-color: #d32f2f;
color: #d32f2f;
}
+
+ .clue-title {
+ color: #68543c;
+ font-size: 2rem;
+ text-align: center;
+ margin-bottom: 20px;
+ border-bottom: 2px solid #9c6b30;
+ padding-bottom: 10px;
+ }
+
+ .timer-display {
+ position: absolute;
+ top: 15px;
+ right: 15px;
+ background-color: rgba(0, 0, 0, 0.1);
+ padding: 5px 10px;
+ border-radius: 5px;
+ font-family: monospace;
+ font-weight: bold;
+ font-size: 1rem;
+ }
+
+ .new-discovery {
+ background-color: #feefb3;
+ border: 1px solid #9f6000;
+ color: #9f6000;
+ padding: 15px;
+ border-radius: 5px;
+ margin-bottom: 20px;
+ display: flex;
+ align-items: center;
+ animation: pulse 2s infinite;
+ }
+
+ @keyframes pulse {
+ 0% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.7;
+ }
+ 100% {
+ opacity: 1;
+ }
+ }
+
+ .discovery-icon {
+ font-size: 1.5rem;
+ margin-right: 10px;
+ }
+
+ .discovery-text {
+ font-weight: bold;
+ }
`}
);
diff --git a/app/(main)/map/map.module.css b/app/(main)/map/map.module.css
index 07ab6af..568c217 100644
--- a/app/(main)/map/map.module.css
+++ b/app/(main)/map/map.module.css
@@ -5,7 +5,7 @@
height: 100vh; /* Full viewport height */
overflow: hidden; /* Hide overflow at page level */
position: relative;
- background-color: #69B5A8;
+ background-color: #69b5a8;
}
.map-banner {
@@ -59,40 +59,69 @@
/* Map container takes all available space */
.map-container {
flex: 1; /* Take all available space */
- overflow: auto; /* Enable scrolling */
position: relative;
width: 100%;
height: 100%;
- min-height: calc(100vh - 120px); /* Adjust for header and navbar height */
- background-color: #69B5A8; /* Updated background color */
+ background-color: #69b5a8; /* Background color */
+ overflow: auto; /* Enable scrolling */
}
.treasure-map {
+ position: relative; /* Changed from absolute to relative */
width: 100%;
height: 100%;
+ min-width: 100%;
+ min-height: 100%;
+}
+
+/* SVG container for better scrolling and zooming */
+.svg-container {
position: relative;
+ width: 100%;
+ height: 100%;
display: flex;
justify-content: center;
align-items: center;
+ overflow: auto;
}
-/* New class for the background map image */
-.map-background {
- width: 100%;
- height: 100%;
+.svg-container svg {
+ min-width: 100%;
min-height: 100%;
+ width: auto;
+ height: auto;
+ display: block;
+ transform-origin: center center;
+}
+
+/* Map background - display as full-page map */
+.map-background {
position: absolute;
top: 0;
left: 0;
- right: 0;
- bottom: 0;
- background-image: url("/images/map.svg"); /* Updated path to the correct SVG file */
- background-size: contain;
+ width: 100%;
+ height: 100%;
+ min-width: 100%;
+ min-height: 100%;
+ background-image: url("/images/map.svg");
+ background-size: contain; /* Change back to contain for proper aspect ratio */
background-position: center;
background-repeat: no-repeat;
- z-index: 5; /* Ensure the map is above the background */
+ z-index: 5;
}
+/* Circle elements in the SVG map - attach to a container class */
+.svg-container circle {
+ cursor: pointer;
+ transition: fill 0.3s ease, transform 0.3s ease;
+}
+
+.svg-container circle:hover {
+ fill: #ffb74d !important;
+ transform: scale(1.1);
+}
+
+/* Remaining styles unchanged */
.map-svg {
width: 100% !important; /* Override Next.js Image default width */
height: auto !important; /* Override Next.js Image default height */
diff --git a/app/(main)/map/page.js b/app/(main)/map/page.js
index 89ba2e9..923c62a 100644
--- a/app/(main)/map/page.js
+++ b/app/(main)/map/page.js
@@ -1,9 +1,15 @@
"use client";
-import { useState, useEffect, useRef } from "react";
+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();
@@ -12,14 +18,34 @@ export default function MapPage() {
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 = async () => {
+ const fetchTeamHuntStartTime = useCallback(async () => {
try {
// First check localStorage for cached start time
const cachedStartTime = localStorage.getItem("teamHuntStartTime");
@@ -59,124 +85,12 @@ export default function MapPage() {
console.error("Failed to fetch team hunt start time:", error);
return new Date().toISOString();
}
- };
+ }, [session?.user?.teamId]);
- // Initial hunt status check
- useEffect(() => {
- // Check if hunt is active
- checkHuntAndTimer();
+ // IMPORTANT: Move fetchTeamData up here, before it's referenced in useEffect dependencies
+ const fetchTeamData = useCallback(async () => {
+ if (!isMounted.current) return;
- // 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") {
- router.push("/login");
- return;
- }
-
- // If hunt is not active, redirect to waiting page
- if (huntActive === false) {
- console.log("Hunt is not active, redirecting to waiting page");
- router.push("/waiting");
- return;
- }
-
- // Only fetch team data if authenticated and hunt is active or status unknown
- if (status === "authenticated" && huntActive !== false) {
- fetchTeamData();
- }
- }, [status, huntActive, router]);
-
- // Function to check hunt status and timer
- async function checkHuntAndTimer() {
- 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 (using localStorage)
- const wasActive = localStorage.getItem("teamHuntStartTime");
-
- if (wasActive) {
- // Hunt was active before but not now - it ended
- console.log("Hunt has ended, redirecting to leaderboard");
- localStorage.removeItem("teamHuntStartTime"); // Clean up
- window.location.href = "/leaderboard?huntEnded=true";
- return;
- }
-
- // Otherwise just show the waiting page
- setHuntActive(false);
- router.push("/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");
- // Clean up the storage
- localStorage.removeItem("teamHuntStartTime");
- router.push("/leaderboard?huntEnded=true");
- return;
- }
-
- // Otherwise, hunt is active and timer is running
- setHuntActive(true);
- }
- } catch (err) {
- console.error("Failed to check hunt status:", err);
- }
- }
-
- // Use team hunt start time for elapsed time calculation
- useEffect(() => {
- if (!teamData?.team) return;
-
- let startTimePromise = fetchTeamHuntStartTime();
- startTimePromise.then((startTimeStr) => {
- // Parse the ISO string to milliseconds
- const startTime = new Date(startTimeStr).getTime();
-
- const timer = setInterval(() => {
- const now = Date.now();
- const elapsed = Math.floor((now - startTime) / 1000);
-
- const hours = Math.floor(elapsed / 3600)
- .toString()
- .padStart(2, "0");
- const minutes = Math.floor((elapsed % 3600) / 60)
- .toString()
- .padStart(2, "0");
- const seconds = Math.floor(elapsed % 60)
- .toString()
- .padStart(2, "0");
-
- setElapsedTime(`${hours}:${minutes}:${seconds}`);
- }, 1000);
-
- return () => clearInterval(timer);
- });
- }, [teamData, session]);
-
- const fetchTeamData = async () => {
try {
setLoading(true);
setError(null);
@@ -207,7 +121,11 @@ export default function MapPage() {
setError("You need to join a team before viewing the map");
setLoading(false);
// Give user time to read the error message before redirect
- setTimeout(() => router.push("/team-selection"), 2000);
+ setTimeout(() => {
+ if (isMounted.current) {
+ softRedirect("/team-selection");
+ }
+ }, 2000);
return;
} else {
throw new Error(errorData.error || "Failed to fetch team data");
@@ -215,18 +133,279 @@ export default function MapPage() {
}
const data = await response.json();
- setTeamData(data);
- setLoading(false);
+ if (isMounted.current) {
+ setTeamData(data);
+ setLoading(false);
+ }
} catch (err) {
console.error("Error fetching team data:", err);
- setError(err.message);
- setLoading(false);
+ 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");
+
+ // Convert NodeList to Array for easier manipulation
+ Array.from(circleSvgElements).forEach((circle, index) => {
+ // Only process if we have a clue for this circle
+ if (clues[index]) {
+ // Make circle interactive
+ circle.style.cursor = "pointer";
+ circle.style.fill = "#ff9800";
+ circle.style.stroke = "#d87f00";
+ circle.style.strokeWidth = "2";
+
+ // Add click handler to navigate to the clue page
+ circle.onclick = () => {
+ router.push(`/clue/${clues[index].id}`);
+ };
+
+ // Add hover effects
+ circle.onmouseenter = () => {
+ circle.style.fill = "#ffb74d";
+ circle.style.transform = "scale(1.1)";
+ circle.style.transition = "all 0.3s ease";
+ };
+
+ circle.onmouseleave = () => {
+ circle.style.fill = "#ff9800";
+ circle.style.transform = "scale(1)";
+ };
+
+ // Optional: Add label or tooltip
+ const label = document.createElementNS(
+ "http://www.w3.org/2000/svg",
+ "title"
+ );
+ label.textContent = `Clue #${index + 1}: ${
+ clues[index].title || "Mystery clue"
+ }`;
+ 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) {
+ // Let the DOM update first with the SVG content
+ setTimeout(() => {
+ enhanceSvgMap();
+ }, 100);
+ }
+ }, [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(() => {
+ if (!teamData?.team) return;
+
+ // Get hunt timer data from the centralized service
+ let timerData = getHuntTimer();
+
+ // If no timer data yet, fetch it
+ if (!timerData) {
+ fetchTeamHuntStartTime().then((startTimeStr) => {
+ setHuntTimer(startTimeStr);
+ timerData = getHuntTimer();
+ startTimer(timerData);
+ });
+ } else {
+ startTimer(timerData);
+ }
+
+ function startTimer(timerData) {
+ const timer = setInterval(() => {
+ if (!isMounted.current) {
+ clearInterval(timer);
+ return;
+ }
+
+ // Refresh timer data on each interval to catch any updates
+ const currentTimerData = getHuntTimer();
+ if (!currentTimerData || !currentTimerData.isActive) {
+ setElapsedTime("00:00:00");
+ return;
+ }
+
+ setElapsedTime(formatElapsedTime(currentTimerData.startTime));
+ }, 1000);
+
+ return () => clearInterval(timer);
+ }
+
+ // Cleanup function
+ return () => {
+ isMounted.current = false;
+ };
+ }, [teamData, fetchTeamHuntStartTime]);
// 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}`, {
@@ -250,19 +429,25 @@ export default function MapPage() {
// If hunt is not active, force redirect to waiting page
if (!data.active) {
console.log("Hunt not active, redirecting to waiting page");
- window.location.href = "/waiting";
+ 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");
- window.location.href = "/leaderboard?huntEnded=true";
+ if (isMounted.current) {
+ softRedirect("/leaderboard?huntEnded=true");
+ }
return;
}
// Otherwise, hunt is active and timer is running
- setHuntActive(true);
+ if (isMounted.current) {
+ setHuntActive(true);
+ }
} catch (err) {
console.error("Failed to check hunt status:", err);
}
@@ -272,7 +457,7 @@ export default function MapPage() {
checkHuntStatus();
const intervalId = setInterval(checkHuntStatus, 1000);
return () => clearInterval(intervalId);
- }, []);
+ }, [softRedirect]);
if (status === "loading" || loading) {
return (
@@ -289,7 +474,7 @@ export default function MapPage() {
Hunt Not Active
The treasure hunt has not started yet.
-
@@ -311,7 +496,7 @@ export default function MapPage() {
Error Loading Map
We couldn't load the treasure map. Please try again.
-
Retry
+
Retry
);
}
@@ -329,19 +514,25 @@ export default function MapPage() {
{teamData?.team?.code || ""}
- {/* Main map content area - showing map as background */}
+ {/* Main map content area - map fills the entire container */}
- {/* Navigation bar - Map as first button */}
+ {/* Navigation bar at the bottom */}