diff --git a/App.js b/App.js
index 1d85452..a3f4d8d 100644
--- a/App.js
+++ b/App.js
@@ -16,6 +16,7 @@ import GlobalEventsScreen from "./src/components/screens/global/GlobalEventsScre
import GlobalMapScreen from "./src/components/screens/global/GlobalMapScreen";
import GlobalCacheViewScreen from "./src/components/screens/global/GlobalCacheViewScreen";
import GlobalLeaderboardScreen from "./src/components/screens/global/GlobalLeaderboardScreen";
+import GlobalProfileScreen from "./src/components/screens/global/GlobalProfileScreen";
import {loadSession} from "./src/hooks/SessionStore";
const RootStack = createNativeStackNavigator();
@@ -63,6 +64,10 @@ const GameStack = () => (
name="GlobalLeaderboardScreen"
component={GlobalLeaderboardScreen}
/>
+
);
@@ -100,6 +105,10 @@ const AdminStack = () => (
name="GlobalLeaderboardScreen"
component={GlobalLeaderboardScreen}
/>
+
);
diff --git a/assets/images/default-pfp.png b/assets/images/default-pfp.png
new file mode 100644
index 0000000..3603454
Binary files /dev/null and b/assets/images/default-pfp.png differ
diff --git a/src/components/API/api.json b/src/components/API/api.json
new file mode 100644
index 0000000..1d10e9f
--- /dev/null
+++ b/src/components/API/api.json
@@ -0,0 +1,6 @@
+{
+ "GLOBAL_API_BASE_URL": "https://mark0s.com/geoquest/v1",
+ "GLOBAL_API_KEY": "v5ctyr",
+ "DEFAULT_GLOBAL_PROFILE_IMAGE_URL": "https://images.generated.photos/eL1-OlKDqGf1IaL_b2O8aSj7osDX_eFVHZEoJ0f3ZV0/rs:fit:256:256/czM6Ly9pY29uczgu/Z3Bob3Rvcy1wcm9k/LnBob3Rvcy92M18w/NzYwNjc0LmpwZw.jpg"
+}
+
diff --git a/src/components/layout/BottomNavbar.js b/src/components/layout/BottomNavbar.js
index bd17f0d..8fbed0b 100644
--- a/src/components/layout/BottomNavbar.js
+++ b/src/components/layout/BottomNavbar.js
@@ -92,10 +92,22 @@ const BottomNavbar = ({navigation, routeName, routeParams}) => {
...(inGame ? [{label: 'Leave', route: '__leave__', onPress: handleLeaveGame}] : []),
];
+ const globalEventParams = session.currentGlobalEventId
+ ? {eventId: session.currentGlobalEventId}
+ : undefined;
+
const globalTabs = [
{label: 'Events', route: 'GlobalEventsScreen', onPress: () => navigation.navigate('GlobalEventsScreen')},
- {label: 'Map', route: 'GlobalMapScreen', onPress: () => navigation.navigate('GlobalMapScreen')},
- {label: 'Leaderboard', route: 'GlobalLeaderboardScreen', onPress: () => navigation.navigate('GlobalLeaderboardScreen')},
+ {
+ label: 'Map',
+ route: 'GlobalMapScreen',
+ onPress: () => navigation.navigate('GlobalMapScreen', globalEventParams),
+ },
+ {
+ label: 'Leaderboard',
+ route: 'GlobalLeaderboardScreen',
+ onPress: () => navigation.navigate('GlobalLeaderboardScreen', globalEventParams),
+ },
{label: 'Exit', route: '__exit_global__', onPress: handleExitGlobal},
];
diff --git a/src/components/layout/Screen.js b/src/components/layout/Screen.js
index 8c60b9f..b424220 100644
--- a/src/components/layout/Screen.js
+++ b/src/components/layout/Screen.js
@@ -25,12 +25,14 @@ const Screen = ({children, style, showBack = false}) => {
GlobalMapScreen: 'Global Map',
GlobalCacheViewScreen: 'Cache Details',
GlobalLeaderboardScreen: 'Global Leaderboard',
+ GlobalProfileScreen: 'Global Profile',
};
// State ----------------------
const routeName = route.name;
const isAuthScreen = routeName === 'LoginScreen' || routeName === 'RegisterScreen';
+ const isGlobalRoute = routeName.startsWith('Global');
const pageTitle = titleMap[routeName] || 'Treasure Hunt';
// Handlers -------------------
@@ -50,6 +52,7 @@ const Screen = ({children, style, showBack = false}) => {
)}
diff --git a/src/components/layout/TopNavbar.js b/src/components/layout/TopNavbar.js
index 934120a..8504236 100644
--- a/src/components/layout/TopNavbar.js
+++ b/src/components/layout/TopNavbar.js
@@ -2,7 +2,7 @@ import {Pressable, StyleSheet, Text, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {clearSession} from '../../hooks/SessionStore';
-const TopNavbar = ({title, showBack, navigation}) => {
+const TopNavbar = ({title, showBack, showProfile = false, navigation}) => {
// Initialisation ------------
const insets = useSafeAreaInsets();
@@ -23,6 +23,10 @@ const TopNavbar = ({title, showBack, navigation}) => {
});
};
+ const handleProfile = () => {
+ navigation.navigate('GlobalProfileScreen');
+ };
+
// View -----------------------
return (
@@ -36,6 +40,11 @@ const TopNavbar = ({title, showBack, navigation}) => {
{title}
+ {showProfile ? (
+
+ Profile
+
+ ) : null}
Logout
@@ -61,8 +70,10 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
},
rightSlot: {
- width: 72,
+ width: 148,
alignItems: 'flex-end',
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
},
iconButton: {
paddingVertical: 8,
@@ -80,6 +91,16 @@ const styles = StyleSheet.create({
fontWeight: '700',
color: '#ffffff',
},
+ profileButton: {
+ paddingVertical: 6,
+ paddingHorizontal: 8,
+ marginRight: 4,
+ },
+ profileText: {
+ color: '#ffffff',
+ fontSize: 13,
+ fontWeight: '600',
+ },
logoutButton: {
paddingVertical: 6,
paddingHorizontal: 8,
diff --git a/src/components/screens/MapScreen.js b/src/components/screens/MapScreen.js
index 5455674..afdd1b8 100644
--- a/src/components/screens/MapScreen.js
+++ b/src/components/screens/MapScreen.js
@@ -12,10 +12,12 @@ import ClaimTimerView from '../gameplay/ClaimTimerView';
import PlayerMapView from '../gameplay/PlayerMapView';
import Card from '../UI/Card';
import useGameHook from '../../hooks/useGameHook';
+import useGlobalHook from '../../hooks/useGlobalHook';
import usePlayerGame from '../../hooks/usePlayerGame';
import {
clearPendingOrgJoin,
getSession,
+ setGlobalUserId,
setPendingAdmin,
setPendingOrgJoin,
setSelectedCache,
@@ -34,6 +36,7 @@ const MapScreen = ({navigation, route}) => {
const session = getSession();
const isBusiness = Boolean(session.isBusiness);
const {getCaches, claimCache, upsertCache, deleteCache, joinPrivateGame, joinTeamByCode, createPrivateGame, getGroups, getLobby, getUser, getSubgroups, getGroupByOrgCode} = useGameHook();
+ const {ensureGlobalUser, isGlobalApiReady} = useGlobalHook();
// Stable ref to prevent infinite re-render loops
const getCachesRef = useRef(getCaches);
@@ -107,6 +110,23 @@ const MapScreen = ({navigation, route}) => {
? {...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}
: DEFAULT_REGION;
+ useEffect(() => {
+ if (session.isBusiness || session.currentGameMode !== GAME_MODE.GLOBAL) return;
+
+ if (session.currentGlobalEventId && session.currentGlobalPlayerId) {
+ navigation.replace('GlobalMapScreen', {eventId: session.currentGlobalEventId});
+ return;
+ }
+
+ navigation.replace('GlobalEventsScreen');
+ }, [
+ navigation,
+ session.currentGameMode,
+ session.currentGlobalEventId,
+ session.currentGlobalPlayerId,
+ session.isBusiness,
+ ]);
+
useEffect(() => {
if (!session.currentGid) return;
getLobbyRef.current(session.currentGid).then(setGroupInfo);
@@ -291,7 +311,16 @@ const MapScreen = ({navigation, route}) => {
setQuickJoiningCode(null);
};
- const handleEnterGlobal = () => {
+ const handleEnterGlobal = async () => {
+ if (isGlobalApiReady()) {
+ const privateUser = await getUser(session.currentUid);
+ const globalUser = await ensureGlobalUser(session.currentUid, privateUser || null);
+ if (!globalUser?.UserID) {
+ setError('Could not initialise your global account. Please try again.');
+ return;
+ }
+ setGlobalUserId(globalUser.UserID);
+ }
setSessionMode(GAME_MODE.GLOBAL);
navigation.navigate('GlobalEventsScreen');
};
diff --git a/src/components/screens/global/GlobalCacheViewScreen.js b/src/components/screens/global/GlobalCacheViewScreen.js
index 327d607..257244c 100644
--- a/src/components/screens/global/GlobalCacheViewScreen.js
+++ b/src/components/screens/global/GlobalCacheViewScreen.js
@@ -1,72 +1,32 @@
-import {useEffect, useState} from "react";
-import {ActivityIndicator, Alert, StyleSheet, Text, View} from "react-native";
+import {useEffect} from "react";
+import {StyleSheet, Text, View} from "react-native";
import Screen from "../../layout/Screen";
import {Button, ButtonTray} from "../../UI/Button";
import Card from "../../UI/Card";
-import useGlobalHook from "../../../hooks/useGlobalHook";
import {getSession} from "../../../hooks/SessionStore";
import {GAME_MODE} from "../../../utils/gameConstants";
const GlobalCacheViewScreen = ({ navigation, route }) => {
// Initialisations ---------------------
- const { cache, isFound: initIsFound, onFindLogged } = route.params;
- const { logFind } = useGlobalHook();
+ const { cache, isFound: initIsFound } = route.params;
const session = getSession();
- // State -------------------------------
-
- const [isFound, setIsFound] = useState(initIsFound);
- const [isLogging, setIsLogging] = useState(false);
-
// Handlers ----------------------------
- const handleLogFind = async () => {
- if (!session.currentGlobalPlayerId) {
- Alert.alert(
- "Not joined",
- "You must join the event before logging a find.",
- );
- return;
- }
-
- Alert.alert(
- "Log Discovery",
- `Record that you found "${cache.CacheName}" and earn ${cache.CachePoints ?? 0} points?`,
- [
- { text: "Cancel", style: "cancel" },
- {
- text: "Log Find",
- onPress: async () => {
- setIsLogging(true);
- const result = await logFind({
- FindPlayerID: session.currentGlobalPlayerId,
- FindCacheID: cache.CacheID,
- FindDatetime: new Date().toISOString(),
- });
- setIsLogging(false);
- if (result) {
- setIsFound(true);
- if (onFindLogged) onFindLogged();
- Alert.alert(
- "Discovery logged!",
- `+${cache.CachePoints ?? 0} points earned.`,
- );
- } else {
- Alert.alert("Error", "Could not log find. Please try again.");
- }
- },
- },
- ],
- );
- };
-
useEffect(() => {
if (session.isBusiness || session.currentGameMode !== GAME_MODE.GLOBAL) {
navigation.replace("MapScreen");
}
}, [navigation, session.currentGameMode, session.isBusiness]);
+ const handleBackToMap = () => {
+ navigation.navigate("GlobalMapScreen", {
+ eventId: cache.CacheEventID ?? session.currentGlobalEventId,
+ selectedCacheId: cache.CacheID,
+ });
+ };
+
// View --------------------------------
return (
@@ -78,11 +38,11 @@ const GlobalCacheViewScreen = ({ navigation, route }) => {
- {isFound ? "Already Found" : "Not Yet Found"}
+ {initIsFound ? "Already Found" : "Not Yet Found"}
@@ -104,23 +64,17 @@ const GlobalCacheViewScreen = ({ navigation, route }) => {
{cache.CacheClue}
) : null}
+
+ {!initIsFound ? (
+
+ Claim this cache from the Global Map while within 30m and facing it.
+
+ ) : null}
- {isLogging ? (
-
- ) : (
-
- {!isFound && (
-
- )}
-
- )}
+
+
+
);
@@ -170,14 +124,11 @@ const styles = StyleSheet.create({
fontSize: 15,
color: "#111827",
},
- logButton: {
- backgroundColor: "#16a34a",
- borderColor: "#16a34a",
- flex: 2,
- },
- logButtonLabel: {
- color: "#fff",
- fontWeight: "700",
+ claimHint: {
+ marginTop: 6,
+ color: "#2563eb",
+ fontSize: 13,
+ fontWeight: "600",
},
});
diff --git a/src/components/screens/global/GlobalEventsScreen.js b/src/components/screens/global/GlobalEventsScreen.js
index 8d5d5dd..e069645 100644
--- a/src/components/screens/global/GlobalEventsScreen.js
+++ b/src/components/screens/global/GlobalEventsScreen.js
@@ -10,17 +10,24 @@ import {GAME_MODE} from "../../../utils/gameConstants";
const GlobalEventsScreen = ({ navigation }) => {
// Initialisations ---------------------
- const { getPublicEvents, getPlayersByEvent, joinEvent } = useGlobalHook();
+ const {
+ getPublicEvents,
+ getPlayersByEvent,
+ joinEvent,
+ isGlobalApiReady,
+ } = useGlobalHook();
const session = getSession();
const globalApiRef = useRef({
getPublicEvents,
getPlayersByEvent,
joinEvent,
+ isGlobalApiReady,
});
globalApiRef.current = {
getPublicEvents,
getPlayersByEvent,
joinEvent,
+ isGlobalApiReady,
};
// State -------------------------------
@@ -32,10 +39,18 @@ const GlobalEventsScreen = ({ navigation }) => {
// Handlers ----------------------------
- const loadEvents = useCallback(async () => {
+ const loadEvents = useCallback(async (options = {}) => {
setIsLoading(true);
setError("");
- const data = await globalApiRef.current.getPublicEvents();
+
+ if (!globalApiRef.current.isGlobalApiReady()) {
+ setEvents([]);
+ setError("Global API URL is not configured yet. Add it in src/components/API/api.json.");
+ setIsLoading(false);
+ return;
+ }
+
+ const data = await globalApiRef.current.getPublicEvents(options);
setEvents(data);
setIsLoading(false);
}, []);
@@ -52,27 +67,59 @@ const GlobalEventsScreen = ({ navigation }) => {
setError("");
setJoining(event.EventID);
+ if (!globalApiRef.current.isGlobalApiReady()) {
+ setError("Global API URL is not configured yet. Add it in src/components/API/api.json.");
+ setJoining(null);
+ return;
+ }
+
+ const globalUserId = session.currentGlobalUserId ?? session.currentUid;
+
+ const pickPlayerId = (player) => {
+ if (!player) return null;
+ const id = player.PlayerID ?? player.playerId ?? null;
+ return id !== undefined && id !== null ? id : null;
+ };
+
// Check if already a player
- const players = await globalApiRef.current.getPlayersByEvent(event.EventID);
+ const players = await globalApiRef.current.getPlayersByEvent(event.EventID, {forceRefresh: true});
const existing = (players || []).find(
- (p) => p.PlayerUserID === session.currentUid,
+ (p) => String(p.PlayerUserID) === String(globalUserId),
);
- if (existing) {
- setGlobalSession(event.EventID, existing.PlayerID);
+ const existingPlayerId = pickPlayerId(existing);
+ if (existingPlayerId !== null) {
+ setGlobalSession(event.EventID, existingPlayerId);
+ navigation.navigate("GlobalMapScreen", { eventId: event.EventID });
+ setJoining(null);
+ return;
+ }
+
+ // Join the event
+ const player = await globalApiRef.current.joinEvent({
+ PlayerUserID: globalUserId,
+ PlayerEventID: event.EventID,
+ });
+
+ const joinedPlayerId = pickPlayerId(player);
+ if (joinedPlayerId !== null) {
+ setGlobalSession(event.EventID, joinedPlayerId);
+ navigation.navigate("GlobalMapScreen", { eventId: event.EventID });
+ setJoining(null);
+ return;
+ }
+
+ // If create fails due duplicate race/server behavior, recover by re-reading players.
+ const refreshedPlayers = await globalApiRef.current.getPlayersByEvent(event.EventID, {forceRefresh: true});
+ const recovered = (refreshedPlayers || []).find(
+ (p) => String(p.PlayerUserID) === String(globalUserId),
+ );
+ const recoveredPlayerId = pickPlayerId(recovered);
+ if (recoveredPlayerId !== null) {
+ setGlobalSession(event.EventID, recoveredPlayerId);
navigation.navigate("GlobalMapScreen", { eventId: event.EventID });
} else {
- // Join the event
- const player = await globalApiRef.current.joinEvent({
- PlayerUserID: session.currentUid,
- PlayerEventID: event.EventID,
- });
- if (player) {
- setGlobalSession(event.EventID, player.PlayerID);
- navigation.navigate("GlobalMapScreen", { eventId: event.EventID });
- } else {
- setError("Failed to join event. Please try again.");
- }
+ setError("Failed to join event. Please try again.");
}
setJoining(null);
@@ -84,7 +131,7 @@ const GlobalEventsScreen = ({ navigation }) => {
-
+
{error ? {error} : null}
diff --git a/src/components/screens/global/GlobalLeaderboardScreen.js b/src/components/screens/global/GlobalLeaderboardScreen.js
index 30dffb8..4270eab 100644
--- a/src/components/screens/global/GlobalLeaderboardScreen.js
+++ b/src/components/screens/global/GlobalLeaderboardScreen.js
@@ -1,4 +1,4 @@
-import {useCallback, useEffect, useState} from "react";
+import {useCallback, useEffect, useRef, useState} from "react";
import {ActivityIndicator, ScrollView, StyleSheet, Text, View,} from "react-native";
import Screen from "../../layout/Screen";
import {Button, ButtonTray} from "../../UI/Button";
@@ -15,6 +15,16 @@ const GlobalLeaderboardScreen = ({ navigation, route }) => {
const { getFindsByEvent, getCachesByEvent, getPlayersByEvent } =
useGlobalHook();
const session = getSession();
+ const globalApiRef = useRef({
+ getFindsByEvent,
+ getCachesByEvent,
+ getPlayersByEvent,
+ });
+ globalApiRef.current = {
+ getFindsByEvent,
+ getCachesByEvent,
+ getPlayersByEvent,
+ };
// State -------------------------------
@@ -25,25 +35,27 @@ const GlobalLeaderboardScreen = ({ navigation, route }) => {
// Handlers ----------------------------
- const loadData = useCallback(async () => {
+ const loadData = useCallback(async (options = {}) => {
+ if (!eventId) return;
+
setIsLoading(true);
const [finds, caches, players] = await Promise.all([
- getFindsByEvent(eventId),
- getCachesByEvent(eventId),
- getPlayersByEvent(eventId),
+ globalApiRef.current.getFindsByEvent(eventId, options),
+ globalApiRef.current.getCachesByEvent(eventId, options),
+ globalApiRef.current.getPlayersByEvent(eventId, options),
]);
// Build cache points lookup
const cacheMap = {};
(caches || []).forEach((c) => {
- cacheMap[c.CacheID] = c;
+ cacheMap[String(c.CacheID)] = c;
});
// Augment finds with cache info for FindList
const augmented = (finds || []).map((f) => ({
...f,
- FindCache: f.FindCache || cacheMap[f.FindCacheID] || null,
+ FindCache: f.FindCache || cacheMap[String(f.FindCacheID)] || null,
}));
// Sort recent first
@@ -52,41 +64,74 @@ const GlobalLeaderboardScreen = ({ navigation, route }) => {
);
setRecentFinds(sorted.slice(0, 50));
- // Aggregate points per player
+ // Start ranking map with all players so zero-point players are visible.
const pointsMap = {};
+ (players || []).forEach((p) => {
+ const pid = p.PlayerID;
+ pointsMap[String(pid)] = {
+ playerId: pid,
+ points: Number(p.PlayerPoints || 0),
+ finds: 0,
+ name: p.PlayerUser?.UserUsername || `Player #${p.PlayerUserID}`,
+ };
+ });
+
+ // Aggregate points and finds from recorded discoveries.
augmented.forEach((f) => {
const pid = f.FindPlayerID;
- const pts = f.FindCache?.CachePoints ?? 0;
- if (!pointsMap[pid])
- pointsMap[pid] = { playerId: pid, points: 0, finds: 0, name: null };
- pointsMap[pid].points += pts;
- pointsMap[pid].finds += 1;
- });
+ const key = String(pid);
+ const pts = Number(f.FindCache?.CachePoints || 0);
- // Attach usernames from players list
- (players || []).forEach((p) => {
- if (pointsMap[p.PlayerID]) {
- pointsMap[p.PlayerID].name =
- p.PlayerUser?.UserUsername || `Player #${p.PlayerUserID}`;
+ if (!pointsMap[key]) {
+ pointsMap[key] = {
+ playerId: pid,
+ points: 0,
+ finds: 0,
+ name: `Player #${f.FindPlayerID}`,
+ };
}
+
+ pointsMap[key].points += pts;
+ pointsMap[key].finds += 1;
+ });
+
+ const ranked = Object.values(pointsMap).sort((a, b) => {
+ if (b.points !== a.points) return b.points - a.points;
+ if (b.finds !== a.finds) return b.finds - a.finds;
+ return String(a.name || "").localeCompare(String(b.name || ""));
});
- const ranked = Object.values(pointsMap).sort((a, b) => b.points - a.points);
setRankings(ranked);
setIsLoading(false);
- }, [eventId, getFindsByEvent, getCachesByEvent, getPlayersByEvent]);
+ }, [eventId]);
useEffect(() => {
if (session.isBusiness || session.currentGameMode !== GAME_MODE.GLOBAL) {
navigation.replace("MapScreen");
return;
}
- if (!eventId) {
+ if (!session.currentGlobalPlayerId) {
navigation.replace("GlobalEventsScreen");
return;
}
+ if (!eventId) {
+ navigation.replace("GlobalEventsScreen");
+ }
+ }, [
+ eventId,
+ navigation,
+ session.currentGameMode,
+ session.currentGlobalPlayerId,
+ session.isBusiness,
+ ]);
+
+ useEffect(() => {
+ if (!eventId || session.currentGameMode !== GAME_MODE.GLOBAL || !session.currentGlobalPlayerId) {
+ return;
+ }
+
loadData();
- }, [eventId, loadData, navigation, session.currentGameMode, session.isBusiness]);
+ }, [eventId, loadData, session.currentGameMode, session.currentGlobalPlayerId]);
// View --------------------------------
@@ -154,7 +199,7 @@ const GlobalLeaderboardScreen = ({ navigation, route }) => {
)}
-
+
diff --git a/src/components/screens/global/GlobalMapScreen.js b/src/components/screens/global/GlobalMapScreen.js
index c567691..eada159 100644
--- a/src/components/screens/global/GlobalMapScreen.js
+++ b/src/components/screens/global/GlobalMapScreen.js
@@ -1,12 +1,15 @@
-import {useCallback, useEffect, useState} from "react";
+import {useCallback, useEffect, useMemo, useRef, useState} from "react";
import {ActivityIndicator, StyleSheet, Text, View} from "react-native";
-import MapView, {Marker} from "react-native-maps";
+import MapView, {Circle, Marker, Polygon} from "react-native-maps";
import * as Location from "expo-location";
import Screen from "../../layout/Screen";
import {Button, ButtonTray} from "../../UI/Button";
+import ClaimTimerView from "../../gameplay/ClaimTimerView";
import CacheList from "../../../entity/cache/CacheList";
import useGlobalHook from "../../../hooks/useGlobalHook";
+import usePlayerGame from "../../../hooks/usePlayerGame";
import {getSession} from "../../../hooks/SessionStore";
+import {getFovCone} from "../../../utils/geoMath";
import {GAME_MODE} from "../../../utils/gameConstants";
const DEFAULT_REGION = {
@@ -16,54 +19,177 @@ const DEFAULT_REGION = {
longitudeDelta: 0.05,
};
+const GLOBAL_CLAIM_DISTANCE_METERS = 30;
+
const GlobalMapScreen = ({ navigation, route }) => {
// Initialisations ---------------------
- const eventId = route?.params?.eventId ?? getSession().currentGlobalEventId;
- const { getCachesByEvent, getFindsByPlayer } = useGlobalHook();
const session = getSession();
+ const eventId = route?.params?.eventId ?? session.currentGlobalEventId;
+ const { getCachesByEvent, getFindsByPlayer, logFind } = useGlobalHook();
+ const globalApiRef = useRef({
+ getCachesByEvent,
+ getFindsByPlayer,
+ logFind,
+ });
+ globalApiRef.current = {
+ getCachesByEvent,
+ getFindsByPlayer,
+ logFind,
+ };
// State -------------------------------
const [caches, setCaches] = useState([]);
const [foundCacheIds, setFoundCacheIds] = useState([]);
const [userLocation, setUserLocation] = useState(null);
+ const [heading, setHeading] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState("map");
const [error, setError] = useState("");
+ const [selectedCacheId, setSelectedCacheId] = useState(null);
+ const [claimedPopupVisible, setClaimedPopupVisible] = useState(false);
+
+ const claimableCaches = useMemo(
+ () =>
+ (caches || [])
+ .filter((cache) => !foundCacheIds.includes(cache.CacheID))
+ .map((cache) => ({
+ id: cache.CacheID,
+ clue: cache.CacheClue || cache.CacheName,
+ name: cache.CacheName,
+ coordinates: {
+ latitude: cache.CacheLatitude,
+ longitude: cache.CacheLongitude,
+ },
+ raw: cache,
+ })),
+ [caches, foundCacheIds],
+ );
+
+ const { visibleCaches, isClaiming, setIsClaiming } = usePlayerGame(
+ userLocation,
+ heading,
+ claimableCaches,
+ GLOBAL_CLAIM_DISTANCE_METERS,
+ selectedCacheId,
+ );
// Handlers ----------------------------
- const loadData = useCallback(async () => {
+ const loadData = useCallback(async (options = {}) => {
+ if (!eventId) return;
+
setIsLoading(true);
setError("");
const [cacheData, findsData] = await Promise.all([
- getCachesByEvent(eventId),
+ globalApiRef.current.getCachesByEvent(eventId, options),
session.currentGlobalPlayerId
- ? getFindsByPlayer(session.currentGlobalPlayerId)
+ ? globalApiRef.current.getFindsByPlayer(session.currentGlobalPlayerId, options)
: Promise.resolve([]),
]);
- setCaches(cacheData);
+ setCaches(cacheData || []);
setFoundCacheIds((findsData || []).map((f) => f.FindCacheID));
setIsLoading(false);
- }, [eventId, getCachesByEvent, getFindsByPlayer, session.currentGlobalPlayerId]);
+ }, [eventId, session.currentGlobalPlayerId]);
+
+ const handleClaim = useCallback(
+ async (cacheId) => {
+ if (!session.currentGlobalPlayerId) {
+ setIsClaiming(false);
+ return;
+ }
+
+ const alreadyFound = foundCacheIds.includes(cacheId);
+ if (alreadyFound) {
+ setIsClaiming(false);
+ return;
+ }
+
+ const result = await globalApiRef.current.logFind({
+ FindPlayerID: session.currentGlobalPlayerId,
+ FindCacheID: cacheId,
+ FindDatetime: new Date().toISOString(),
+ });
+
+ setIsClaiming(false);
+
+ if (result) {
+ setClaimedPopupVisible(true);
+ setTimeout(() => setClaimedPopupVisible(false), 3000);
+ await loadData({forceRefresh: true});
+ }
+ },
+ [foundCacheIds, loadData, session.currentGlobalPlayerId, setIsClaiming],
+ );
+
+ const handleCacheSelect = (cache) => {
+ setSelectedCacheId(cache.CacheID);
+ setActiveTab("map");
+ };
+
+ const handleOpenSelectedCache = () => {
+ const selected = (caches || []).find((cache) => cache.CacheID === selectedCacheId);
+ if (!selected) return;
+ navigation.navigate("GlobalCacheViewScreen", {
+ cache: selected,
+ isFound: foundCacheIds.includes(selected.CacheID),
+ });
+ };
+
+ const handleGotoLeaderboard = () => {
+ navigation.navigate("GlobalLeaderboardScreen", { eventId });
+ };
useEffect(() => {
if (session.isBusiness || session.currentGameMode !== GAME_MODE.GLOBAL) {
navigation.replace("MapScreen");
return;
}
- if (!eventId) {
+
+ if (!session.currentGlobalPlayerId) {
navigation.replace("GlobalEventsScreen");
return;
}
- loadData();
- }, [eventId, loadData, navigation, session.currentGameMode, session.isBusiness]);
+
+ if (!eventId) {
+ navigation.replace("GlobalEventsScreen");
+ }
+ }, [
+ eventId,
+ navigation,
+ session.currentGameMode,
+ session.currentGlobalPlayerId,
+ session.isBusiness,
+ ]);
useEffect(() => {
- let sub;
+ if (!eventId || session.currentGameMode !== GAME_MODE.GLOBAL || !session.currentGlobalPlayerId) {
+ return;
+ }
+
+ loadData();
+ }, [eventId, loadData, session.currentGameMode, session.currentGlobalPlayerId]);
+
+ useEffect(() => {
+ const routeSelected = route?.params?.selectedCacheId;
+ if (!routeSelected) return;
+ setSelectedCacheId(routeSelected);
+ }, [route?.params?.selectedCacheId]);
+
+ useEffect(() => {
+ if (selectedCacheId && claimableCaches.some((cache) => cache.id === selectedCacheId)) {
+ return;
+ }
+ setSelectedCacheId(claimableCaches[0]?.id || null);
+ }, [claimableCaches, selectedCacheId]);
+
+ useEffect(() => {
+ let locationSub;
+ let headingSub;
+
const start = async () => {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted") {
@@ -87,11 +213,11 @@ const GlobalMapScreen = ({ navigation, route }) => {
});
}
- sub = await Location.watchPositionAsync(
+ locationSub = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.Balanced,
- distanceInterval: 5,
- timeInterval: 3000,
+ distanceInterval: 3,
+ timeInterval: 1500,
},
(pos) =>
setUserLocation({
@@ -99,10 +225,21 @@ const GlobalMapScreen = ({ navigation, route }) => {
longitude: pos.coords.longitude,
}),
);
+
+ headingSub = await Location.watchHeadingAsync((headingData) => {
+ const raw =
+ headingData.trueHeading >= 0
+ ? headingData.trueHeading
+ : headingData.magHeading;
+ setHeading(raw);
+ });
};
+
start();
+
return () => {
- if (sub) sub.remove();
+ if (locationSub) locationSub.remove();
+ if (headingSub) headingSub.remove();
};
}, []);
@@ -110,17 +247,7 @@ const GlobalMapScreen = ({ navigation, route }) => {
? { ...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01 }
: DEFAULT_REGION;
- const handleCacheSelect = (cache) => {
- navigation.navigate("GlobalCacheViewScreen", {
- cache,
- isFound: foundCacheIds.includes(cache.CacheID),
- onFindLogged: loadData,
- });
- };
-
- const handleGotoLeaderboard = () => {
- navigation.navigate("GlobalLeaderboardScreen", { eventId });
- };
+ const claimTarget = visibleCaches[0] || null;
// View --------------------------------
@@ -135,7 +262,6 @@ const GlobalMapScreen = ({ navigation, route }) => {
return (
- {/* Tab switcher */}
{
{activeTab === "map" ? (
- {caches.map((cache) => {
+ {(caches || []).map((cache) => {
const found = foundCacheIds.includes(cache.CacheID);
+ const selected = selectedCacheId === cache.CacheID;
return (
{
}}
title={cache.CacheName}
description={`${cache.CachePoints ?? 0} pts${found ? " · Found" : ""}`}
- pinColor={found ? "#22c55e" : "#ef4444"}
- onCalloutPress={() => handleCacheSelect(cache)}
+ pinColor={found ? "#22c55e" : selected ? "#f59e0b" : "#ef4444"}
+ onPress={() => setSelectedCacheId(cache.CacheID)}
+ onCalloutPress={() =>
+ navigation.navigate("GlobalCacheViewScreen", {
+ cache,
+ isFound: found,
+ })
+ }
/>
);
})}
+
+ {claimTarget ? (
+
+ ) : null}
+
+ {userLocation && heading !== null && heading !== undefined ? (
+
+ ) : null}
) : (
{
/>
)}
+
+
+
-
+
diff --git a/src/components/screens/global/GlobalProfileScreen.js b/src/components/screens/global/GlobalProfileScreen.js
new file mode 100644
index 0000000..3e46eea
--- /dev/null
+++ b/src/components/screens/global/GlobalProfileScreen.js
@@ -0,0 +1,257 @@
+import {useCallback, useEffect, useRef, useState} from "react";
+import {ActivityIndicator, Image, ScrollView, StyleSheet, Text, TextInput, View} from "react-native";
+import Screen from "../../layout/Screen";
+import {Button, ButtonTray} from "../../UI/Button";
+import useGlobalHook from "../../../hooks/useGlobalHook";
+import {getSession} from "../../../hooks/SessionStore";
+import {GAME_MODE} from "../../../utils/gameConstants";
+
+const EMPTY_FORM = {
+ UserFirstname: "",
+ UserLastname: "",
+ UserPhone: "00000",
+ UserUsername: "",
+ UserImageURL: "",
+};
+
+const GlobalProfileScreen = ({navigation}) => {
+ // Initialisations ---------------------
+
+ const session = getSession();
+ const {
+ getUser,
+ updateUser,
+ isGlobalApiReady,
+ defaultGlobalProfileImageUrl,
+ } = useGlobalHook();
+ const globalApiRef = useRef({
+ getUser,
+ updateUser,
+ isGlobalApiReady,
+ });
+ globalApiRef.current = {
+ getUser,
+ updateUser,
+ isGlobalApiReady,
+ };
+
+ // State -------------------------------
+
+ const [profile, setProfile] = useState(EMPTY_FORM);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ // Handlers ----------------------------
+
+ const hydrateProfile = useCallback(async (options = {}) => {
+ setIsLoading(true);
+ setError("");
+
+ if (session.isBusiness || session.currentGameMode !== GAME_MODE.GLOBAL) {
+ navigation.replace("MapScreen");
+ return;
+ }
+
+ if (!globalApiRef.current.isGlobalApiReady()) {
+ setError("Global API URL is not configured yet. Add it in src/components/API/api.json.");
+ setIsLoading(false);
+ return;
+ }
+
+ const globalUserId = session.currentGlobalUserId ?? session.currentUid;
+ const latest = await globalApiRef.current.getUser(globalUserId, options);
+ if (!latest) {
+ setError("Global profile not found. Tap Join Global Game first to initialise it.");
+ setIsLoading(false);
+ return;
+ }
+
+ setProfile({
+ UserFirstname: latest.UserFirstname || "",
+ UserLastname: latest.UserLastname || "",
+ UserPhone: latest.UserPhone || "00000",
+ UserUsername: latest.UserUsername || "",
+ UserImageURL: latest.UserImageURL || defaultGlobalProfileImageUrl,
+ });
+ setIsLoading(false);
+ }, [
+ defaultGlobalProfileImageUrl,
+ navigation,
+ session.currentGameMode,
+ session.currentGlobalUserId,
+ session.currentUid,
+ session.isBusiness,
+ ]);
+
+ useEffect(() => {
+ hydrateProfile();
+ }, [hydrateProfile]);
+
+ const handleSave = async () => {
+ setError("");
+ setIsSaving(true);
+
+ const globalUserId = session.currentGlobalUserId ?? session.currentUid;
+ const payload = {
+ UserID: globalUserId,
+ UserFirstname: profile.UserFirstname.trim() || "Global",
+ UserLastname: profile.UserLastname.trim() || "User",
+ UserPhone: profile.UserPhone.trim() || "00000",
+ UserUsername: profile.UserUsername.trim() || `global_${globalUserId}`,
+ UserImageURL: profile.UserImageURL.trim() || defaultGlobalProfileImageUrl,
+ UserTimestamp: Date.now(),
+ };
+
+ const updated = await globalApiRef.current.updateUser(globalUserId, payload);
+ if (!updated) {
+ setError("Failed to save profile. Please try again.");
+ setIsSaving(false);
+ return;
+ }
+
+ const latest = await globalApiRef.current.getUser(globalUserId, {forceRefresh: true});
+ setProfile({
+ UserFirstname: latest?.UserFirstname || payload.UserFirstname,
+ UserLastname: latest?.UserLastname || payload.UserLastname,
+ UserPhone: latest?.UserPhone || payload.UserPhone,
+ UserUsername: latest?.UserUsername || payload.UserUsername,
+ UserImageURL: latest?.UserImageURL || payload.UserImageURL,
+ });
+ setIsSaving(false);
+ };
+
+ // View --------------------------------
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {error ? {error} : null}
+
+
+
+
+
+ First Name
+
+ setProfile((prev) => ({...prev, UserFirstname: value}))
+ }
+ />
+
+ Last Name
+
+ setProfile((prev) => ({...prev, UserLastname: value}))
+ }
+ />
+
+ Phone Number
+
+ setProfile((prev) => ({...prev, UserPhone: value}))
+ }
+ />
+
+ Profile Image URL
+
+ setProfile((prev) => ({...prev, UserImageURL: value}))
+ }
+ autoCapitalize="none"
+ />
+
+ Username
+
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ paddingBottom: 24,
+ gap: 10,
+ },
+ center: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ error: {
+ color: "#dc2626",
+ fontSize: 14,
+ },
+ avatarWrap: {
+ alignItems: "center",
+ marginBottom: 8,
+ },
+ avatar: {
+ width: 96,
+ height: 96,
+ borderRadius: 48,
+ backgroundColor: "#111827",
+ },
+ label: {
+ fontSize: 13,
+ color: "#9ca3af",
+ fontWeight: "600",
+ },
+ input: {
+ borderWidth: 1,
+ borderColor: "#45475a",
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ fontSize: 16,
+ color: "#cdd6f4",
+ backgroundColor: "#313244",
+ },
+ disabledInput: {
+ opacity: 0.7,
+ },
+});
+
+export default GlobalProfileScreen;
+
diff --git a/src/hooks/SessionStore.js b/src/hooks/SessionStore.js
index 8e7af57..82253ed 100644
--- a/src/hooks/SessionStore.js
+++ b/src/hooks/SessionStore.js
@@ -13,9 +13,12 @@ const session = {
isPendingAdmin: false,
teamsEnabled: false,
currentGameMode: null,
+ // Temporary state for business join flow between company code and department code.
+ pendingOrgJoin: null,
// Global gameplay
currentGlobalEventId: null,
currentGlobalPlayerId: null,
+ currentGlobalUserId: null,
selectedCacheId: null,
};
@@ -34,6 +37,7 @@ const setSessionUser = (user) => {
// New login/register starts in regular flow; global flow requires entering GLOBAL code.
session.currentGlobalEventId = null;
session.currentGlobalPlayerId = null;
+ session.currentGlobalUserId = null;
session.currentGameMode = user?.Gid ? GAME_MODE.REGULAR : null;
persistSession();
};
@@ -62,6 +66,16 @@ const setPendingAdmin = (val) => {
persistSession();
};
+const setPendingOrgJoin = (payload) => {
+ session.pendingOrgJoin = payload || null;
+ persistSession();
+};
+
+const clearPendingOrgJoin = () => {
+ session.pendingOrgJoin = null;
+ persistSession();
+};
+
const setSelectedCache = (cacheId) => {
session.selectedCacheId = cacheId ?? null;
persistSession();
@@ -76,6 +90,11 @@ const setGlobalSession = (eventId, playerId) => {
persistSession();
};
+const setGlobalUserId = (userId) => {
+ session.currentGlobalUserId = userId ?? null;
+ persistSession();
+};
+
const setSessionMode = (mode) => {
session.currentGameMode = mode ?? null;
persistSession();
@@ -98,7 +117,9 @@ const clearSession = () => {
session.currentGameMode = null;
session.currentGlobalEventId = null;
session.currentGlobalPlayerId = null;
+ session.currentGlobalUserId = null;
session.isPendingAdmin = false;
+ session.pendingOrgJoin = null;
AsyncStorage.removeItem(STORAGE_KEY).catch(() => {});
};
@@ -109,6 +130,7 @@ const clearGameSession = () => {
session.currentTid = null;
session.isAcceptedAdmin = false;
session.isPendingAdmin = false;
+ session.pendingOrgJoin = null;
session.teamsEnabled = false;
session.selectedCacheId = null;
if (!session.currentGlobalEventId) session.currentGameMode = null;
@@ -128,6 +150,15 @@ const loadSession = async () => {
const saved = JSON.parse(raw);
Object.assign(session, saved);
}
+
+ // Keep mode coherent with persisted ids so navigation can restore correctly.
+ if (session.currentGlobalEventId) {
+ session.currentGameMode = GAME_MODE.GLOBAL;
+ } else if (session.currentGid) {
+ session.currentGameMode = GAME_MODE.REGULAR;
+ } else if (!session.currentGlobalEventId) {
+ session.currentGameMode = null;
+ }
} catch (_) {
// Ignore read errors — start fresh
}
@@ -140,12 +171,15 @@ export {
setSessionTeam,
setSessionTeamsEnabled,
setPendingAdmin,
+ setPendingOrgJoin,
+ clearPendingOrgJoin,
setSelectedCache,
getSession,
clearSession,
clearGameSession,
loadSession,
setGlobalSession,
+ setGlobalUserId,
clearGlobalSession,
setSessionMode,
};
diff --git a/src/hooks/useGlobalHook.js b/src/hooks/useGlobalHook.js
index 734c824..40cad81 100644
--- a/src/hooks/useGlobalHook.js
+++ b/src/hooks/useGlobalHook.js
@@ -1,92 +1,366 @@
+import {useCallback, useMemo} from "react";
import API from "../components/API/API";
+import globalApiConfig from "../components/API/api.json";
+import {
+ clearGlobalDataCache,
+ clearGlobalDataCacheByPrefix,
+ getOrFetchGlobalData,
+ writeGlobalDataCache,
+} from "../utils/globalDataCache";
-const GLOBAL_BASE = process.env.EXPO_PUBLIC_GLOBAL_API_BASE;
-const GLOBAL_KEY = process.env.EXPO_PUBLIC_GLOBAL_API_KEY;
+const GLOBAL_BASE = String(
+ process.env.EXPO_PUBLIC_GLOBAL_API_BASE || globalApiConfig.GLOBAL_API_BASE_URL || "",
+).trim();
+const GLOBAL_KEY = String(
+ process.env.EXPO_PUBLIC_GLOBAL_API_KEY || globalApiConfig.GLOBAL_API_KEY || "",
+).trim();
+const DEFAULT_GLOBAL_PROFILE_IMAGE_URL =
+ String(globalApiConfig.DEFAULT_GLOBAL_PROFILE_IMAGE_URL || "").trim() ||
+ "https://placehold.co/256x256/png";
-// Appends the API key as a query param to any URL
+const isGlobalApiReady = () => Boolean(GLOBAL_BASE);
+
+const CACHE_TTL = {
+ events: 20000,
+ players: 12000,
+ caches: 20000,
+ finds: 8000,
+ users: 15000,
+};
+
+const CACHE_KEY = {
+ publicEvents: "global:events:public",
+ users: "global:users",
+ event: (eventId) => `global:event:${eventId}`,
+ playersByEvent: (eventId) => `global:players:event:${eventId}`,
+ cachesByEvent: (eventId) => `global:caches:event:${eventId}`,
+ cacheById: (cacheId) => `global:cache:${cacheId}`,
+ findsByEvent: (eventId) => `global:finds:event:${eventId}`,
+ findsByPlayer: (playerId) => `global:finds:player:${playerId}`,
+ userById: (userId) => `global:user:${userId}`,
+};
+
+// Appends the API key as a query param when provided.
const withKey = (url) => {
+ if (!GLOBAL_KEY) return url;
const separator = url.includes("?") ? "&" : "?";
- return `${url}${separator}key=${GLOBAL_KEY}`;
+ return `${url}${separator}key=${encodeURIComponent(GLOBAL_KEY)}`;
+};
+
+const unwrapList = (payload) => {
+ if (!payload) return [];
+ if (Array.isArray(payload)) return payload;
+ if (Array.isArray(payload.value)) return payload.value;
+ if (Array.isArray(payload.Value)) return payload.Value;
+ return [];
+};
+
+const unwrapSingle = (payload) => {
+ if (!payload) return null;
+ if (Array.isArray(payload)) return payload[0] || null;
+ if (Array.isArray(payload.value)) return payload.value[0] || null;
+ if (Array.isArray(payload.Value)) return payload.Value[0] || null;
+ if (payload.value && typeof payload.value === "object") return payload.value;
+ if (payload.Value && typeof payload.Value === "object") return payload.Value;
+ return typeof payload === "object" ? payload : null;
+};
+
+const randomToken = (size = 6) => {
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+ let token = "";
+ for (let i = 0; i < size; i += 1) {
+ token += chars.charAt(Math.floor(Math.random() * chars.length));
+ }
+ return token;
+};
+
+const firstNonEmpty = (...values) => {
+ for (const value of values) {
+ const normalized = String(value || "").trim();
+ if (normalized) return normalized;
+ }
+ return "";
+};
+
+const withMinLength = (value, min, fallback) => {
+ const normalized = firstNonEmpty(value);
+ if (normalized.length >= min) return normalized;
+ return fallback;
+};
+
+const splitUsername = (username) => {
+ const cleaned = String(username || "").trim();
+ if (!cleaned) return {first: "Global", last: "User"};
+ const parts = cleaned.split(/[\s._-]+/).filter(Boolean);
+ if (parts.length === 1) {
+ return {first: parts[0], last: "User"};
+ }
+ return {
+ first: parts[0],
+ last: parts.slice(1).join(" "),
+ };
+};
+
+const buildGlobalUserPayload = (userId, privateUser = null) => {
+ const privateUsername = firstNonEmpty(privateUser?.username);
+ const nameParts = splitUsername(privateUsername);
+ const syncedUsername = `mad_uid_${userId}`;
+
+ return {
+ UserID: userId,
+ UserFirstname: firstNonEmpty(privateUser?.firstName, nameParts.first, "Global"),
+ UserLastname: firstNonEmpty(privateUser?.lastName, nameParts.last, "User"),
+ // The live global API validates phone length >= 12.
+ UserPhone: withMinLength(privateUser?.phone, 12, "000000000000"),
+ UserUsername: syncedUsername,
+ // The live global API validates password length >= 8.
+ UserPassword: withMinLength(
+ privateUser?.passwordHash,
+ 8,
+ `PW_${randomToken(10)}`,
+ ),
+ UserLatitude: Number((Math.random() * 180 - 90).toFixed(6)),
+ UserLongitude: Number((Math.random() * 360 - 180).toFixed(6)),
+ UserTimestamp: Date.now(),
+ UserImageURL: firstNonEmpty(
+ privateUser?.imageUrl,
+ DEFAULT_GLOBAL_PROFILE_IMAGE_URL,
+ ),
+ };
};
const useGlobalHook = () => {
// Initialisations ---------------------
- const eventsEndpoint = `${GLOBAL_BASE}/api/events`;
- const playersEndpoint = `${GLOBAL_BASE}/api/players`;
- const cachesEndpoint = `${GLOBAL_BASE}/api/caches`;
- const findsEndpoint = `${GLOBAL_BASE}/api/finds`;
- const usersEndpoint = `${GLOBAL_BASE}/api/users`;
+ const endpoints = useMemo(
+ () => ({
+ events: `${GLOBAL_BASE}/api/events`,
+ players: `${GLOBAL_BASE}/api/players`,
+ caches: `${GLOBAL_BASE}/api/caches`,
+ finds: `${GLOBAL_BASE}/api/finds`,
+ users: `${GLOBAL_BASE}/api/users`,
+ }),
+ [],
+ );
// Handlers ----------------------------
// Events
- const getPublicEvents = async () => {
- const response = await API.get(withKey(eventsEndpoint));
- if (!response.isSuccess) return [];
- return response.result.filter((e) => e.EventIspublic);
- };
+ const getPublicEvents = useCallback(async (options = {}) => {
+ if (!isGlobalApiReady()) return [];
+ return getOrFetchGlobalData(
+ CACHE_KEY.publicEvents,
+ async () => {
+ const response = await API.get(withKey(endpoints.events));
+ if (!response.isSuccess) return [];
+ const rows = unwrapList(response.result);
+ return rows.filter((e) => e.EventIspublic);
+ },
+ {ttlMs: CACHE_TTL.events, forceRefresh: options.forceRefresh},
+ );
+ }, [endpoints.events]);
- const getEvent = async (eventId) => {
- const response = await API.get(withKey(`${eventsEndpoint}/${eventId}`));
- return response.isSuccess ? response.result : null;
- };
+ const getEvent = useCallback(async (eventId, options = {}) => {
+ if (!isGlobalApiReady()) return null;
+ return getOrFetchGlobalData(
+ CACHE_KEY.event(eventId),
+ async () => {
+ const response = await API.get(withKey(`${endpoints.events}/${eventId}`));
+ if (!response.isSuccess) return null;
+ return unwrapSingle(response.result);
+ },
+ {ttlMs: CACHE_TTL.events, forceRefresh: options.forceRefresh},
+ );
+ }, [endpoints.events]);
// Players
- const getPlayersByEvent = async (eventId) => {
- const response = await API.get(
- withKey(`${playersEndpoint}/events/${eventId}`),
+ const getPlayersByEvent = useCallback(async (eventId, options = {}) => {
+ if (!isGlobalApiReady()) return [];
+ return getOrFetchGlobalData(
+ CACHE_KEY.playersByEvent(eventId),
+ async () => {
+ const response = await API.get(withKey(`${endpoints.players}/events/${eventId}`));
+ if (!response.isSuccess) return [];
+ return unwrapList(response.result);
+ },
+ {ttlMs: CACHE_TTL.players, forceRefresh: options.forceRefresh},
);
- return response.isSuccess ? response.result : [];
- };
+ }, [endpoints.players]);
- const joinEvent = async (data) => {
- const response = await API.post(withKey(playersEndpoint), data);
- return response.isSuccess ? response.result : null;
- };
+ const joinEvent = useCallback(async (data) => {
+ if (!isGlobalApiReady()) return null;
+ const response = await API.post(withKey(endpoints.players), data);
+ if (!response.isSuccess) return null;
+ const joined = unwrapSingle(response.result);
+ const eventId = data?.PlayerEventID ?? joined?.PlayerEventID;
+ if (eventId !== undefined && eventId !== null) {
+ clearGlobalDataCache(CACHE_KEY.playersByEvent(eventId));
+ }
+ return joined;
+ }, [endpoints.players]);
// Caches
- const getCachesByEvent = async (eventId) => {
- const response = await API.get(
- withKey(`${cachesEndpoint}/events/${eventId}`),
+ const getCachesByEvent = useCallback(async (eventId, options = {}) => {
+ if (!isGlobalApiReady()) return [];
+ return getOrFetchGlobalData(
+ CACHE_KEY.cachesByEvent(eventId),
+ async () => {
+ const response = await API.get(withKey(`${endpoints.caches}/events/${eventId}`));
+ if (!response.isSuccess) return [];
+ return unwrapList(response.result);
+ },
+ {ttlMs: CACHE_TTL.caches, forceRefresh: options.forceRefresh},
);
- return response.isSuccess ? response.result : [];
- };
+ }, [endpoints.caches]);
- const getCache = async (cacheId) => {
- const response = await API.get(withKey(`${cachesEndpoint}/${cacheId}`));
- return response.isSuccess ? response.result : null;
- };
+ const getCache = useCallback(async (cacheId, options = {}) => {
+ if (!isGlobalApiReady()) return null;
+ return getOrFetchGlobalData(
+ CACHE_KEY.cacheById(cacheId),
+ async () => {
+ const response = await API.get(withKey(`${endpoints.caches}/${cacheId}`));
+ if (!response.isSuccess) return null;
+ return unwrapSingle(response.result);
+ },
+ {ttlMs: CACHE_TTL.caches, forceRefresh: options.forceRefresh},
+ );
+ }, [endpoints.caches]);
// Finds
- const getFindsByEvent = async (eventId) => {
- const response = await API.get(
- withKey(`${findsEndpoint}/events/${eventId}`),
+ const getFindsByEvent = useCallback(async (eventId, options = {}) => {
+ if (!isGlobalApiReady()) return [];
+ return getOrFetchGlobalData(
+ CACHE_KEY.findsByEvent(eventId),
+ async () => {
+ const response = await API.get(withKey(`${endpoints.finds}/events/${eventId}`));
+ if (!response.isSuccess) return [];
+ return unwrapList(response.result);
+ },
+ {ttlMs: CACHE_TTL.finds, forceRefresh: options.forceRefresh},
);
- return response.isSuccess ? response.result : [];
- };
+ }, [endpoints.finds]);
- const getFindsByPlayer = async (playerId) => {
- const response = await API.get(
- withKey(`${findsEndpoint}/players/${playerId}`),
+ const getFindsByPlayer = useCallback(async (playerId, options = {}) => {
+ if (!isGlobalApiReady()) return [];
+ return getOrFetchGlobalData(
+ CACHE_KEY.findsByPlayer(playerId),
+ async () => {
+ const response = await API.get(withKey(`${endpoints.finds}/players/${playerId}`));
+ if (!response.isSuccess) return [];
+ return unwrapList(response.result);
+ },
+ {ttlMs: CACHE_TTL.finds, forceRefresh: options.forceRefresh},
);
- return response.isSuccess ? response.result : [];
- };
+ }, [endpoints.finds]);
- const logFind = async (data) => {
- const response = await API.post(withKey(findsEndpoint), data);
- return response.isSuccess ? response.result : null;
- };
+ const logFind = useCallback(async (data) => {
+ if (!isGlobalApiReady()) return null;
+ const response = await API.post(withKey(endpoints.finds), data);
+ if (!response.isSuccess) return null;
+ const find = unwrapSingle(response.result);
+ if (data?.FindPlayerID !== undefined && data?.FindPlayerID !== null) {
+ clearGlobalDataCache(CACHE_KEY.findsByPlayer(data.FindPlayerID));
+ }
+ // Event ID is not always present in payload, so clear all event-level find caches.
+ clearGlobalDataCacheByPrefix("global:finds:event:");
+ return find;
+ }, [endpoints.finds]);
// Users
- const getUser = async (userId) => {
- const response = await API.get(withKey(`${usersEndpoint}/${userId}`));
- return response.isSuccess ? response.result : null;
- };
+ const getUsers = useCallback(async (options = {}) => {
+ if (!isGlobalApiReady()) return [];
+ return getOrFetchGlobalData(
+ CACHE_KEY.users,
+ async () => {
+ const response = await API.get(withKey(endpoints.users));
+ if (!response.isSuccess) return [];
+ return unwrapList(response.result);
+ },
+ {ttlMs: CACHE_TTL.users, forceRefresh: options.forceRefresh},
+ );
+ }, [endpoints.users]);
+
+ const getUser = useCallback(async (userId, options = {}) => {
+ if (!isGlobalApiReady()) return null;
+
+ const key = CACHE_KEY.userById(userId);
+ return getOrFetchGlobalData(
+ key,
+ async () => {
+ // Some global API deployments expose list-only user routes.
+ const byIdResponse = await API.get(withKey(`${endpoints.users}/${userId}`));
+ if (byIdResponse.isSuccess) {
+ const single = unwrapSingle(byIdResponse.result);
+ if (single) return single;
+ }
+
+ const users = await getUsers({forceRefresh: options.forceRefresh});
+ return users.find((user) => String(user.UserID) === String(userId)) || null;
+ },
+ {ttlMs: CACHE_TTL.users, forceRefresh: options.forceRefresh},
+ );
+ }, [endpoints.users, getUsers]);
+
+ const createUser = useCallback(async (payload) => {
+ if (!isGlobalApiReady()) return null;
+ const response = await API.post(withKey(endpoints.users), payload);
+ if (!response.isSuccess) return null;
+ const created = unwrapSingle(response.result);
+ const createdUserId = created?.UserID ?? payload?.UserID;
+ if (createdUserId !== undefined && createdUserId !== null) {
+ writeGlobalDataCache(
+ CACHE_KEY.userById(createdUserId),
+ created || payload,
+ CACHE_TTL.users,
+ );
+ }
+ clearGlobalDataCache(CACHE_KEY.users);
+ return created;
+ }, [endpoints.users]);
+
+ const updateUser = useCallback(async (userId, payload) => {
+ if (!isGlobalApiReady()) return null;
+ const response = await API.put(withKey(`${endpoints.users}/${userId}`), payload);
+ if (!response.isSuccess) return null;
+ const updated = unwrapSingle(response.result) || payload;
+ writeGlobalDataCache(CACHE_KEY.userById(userId), updated, CACHE_TTL.users);
+ clearGlobalDataCache(CACHE_KEY.users);
+ return updated;
+ }, [endpoints.users]);
+
+ // Only called by Join Global Game flow to mirror a private user into global API schema.
+ const ensureGlobalUser = useCallback(async (userId, privateUser = null) => {
+ if (!userId || !isGlobalApiReady()) return null;
+
+ const seed = buildGlobalUserPayload(userId, privateUser);
+
+ // Primary lookup by deterministic username bridge.
+ const users = await getUsers();
+ const bySyncedUsername = users.find(
+ (user) => String(user.UserUsername) === String(seed.UserUsername),
+ );
+ if (bySyncedUsername) return bySyncedUsername;
+
+ const existing = await getUser(userId);
+ if (existing) return existing;
+
+ const created = await createUser(seed);
+ if (created) return created;
+
+ // Final fallback: check list endpoint again after attempted creation.
+ const refreshed = await getUsers({forceRefresh: true});
+ return (
+ refreshed.find((user) => String(user.UserUsername) === String(seed.UserUsername)) ||
+ refreshed.find((user) => String(user.UserID) === String(userId)) ||
+ null
+ );
+ }, [createUser, getUser, getUsers]);
// Return --------------------------------
return {
+ isGlobalApiReady,
+ defaultGlobalProfileImageUrl: DEFAULT_GLOBAL_PROFILE_IMAGE_URL,
getPublicEvents,
getEvent,
getPlayersByEvent,
@@ -97,6 +371,9 @@ const useGlobalHook = () => {
getFindsByPlayer,
logFind,
getUser,
+ createUser,
+ updateUser,
+ ensureGlobalUser,
};
};
diff --git a/src/utils/globalDataCache.js b/src/utils/globalDataCache.js
new file mode 100644
index 0000000..c1298fc
--- /dev/null
+++ b/src/utils/globalDataCache.js
@@ -0,0 +1,69 @@
+const cacheStore = new Map();
+const inFlightStore = new Map();
+
+const now = () => Date.now();
+
+export const readGlobalDataCache = (key) => {
+ const entry = cacheStore.get(key);
+ if (!entry) return null;
+
+ if (entry.expiresAt <= now()) {
+ cacheStore.delete(key);
+ return null;
+ }
+
+ return entry.value;
+};
+
+export const writeGlobalDataCache = (key, value, ttlMs = 15000) => {
+ cacheStore.set(key, {
+ value,
+ expiresAt: now() + Math.max(0, Number(ttlMs) || 0),
+ });
+ return value;
+};
+
+export const clearGlobalDataCache = (key) => {
+ cacheStore.delete(key);
+ inFlightStore.delete(key);
+};
+
+export const clearGlobalDataCacheByPrefix = (prefix) => {
+ Array.from(cacheStore.keys())
+ .filter((key) => key.startsWith(prefix))
+ .forEach((key) => cacheStore.delete(key));
+
+ Array.from(inFlightStore.keys())
+ .filter((key) => key.startsWith(prefix))
+ .forEach((key) => inFlightStore.delete(key));
+};
+
+export const getOrFetchGlobalData = async (
+ key,
+ fetcher,
+ {ttlMs = 15000, forceRefresh = false} = {},
+) => {
+ if (!forceRefresh) {
+ const cached = readGlobalDataCache(key);
+ if (cached !== null) return cached;
+ } else {
+ clearGlobalDataCache(key);
+ }
+
+ const activePromise = inFlightStore.get(key);
+ if (activePromise) return activePromise;
+
+ const request = (async () => {
+ try {
+ const fresh = await fetcher();
+ writeGlobalDataCache(key, fresh, ttlMs);
+ return fresh;
+ } finally {
+ inFlightStore.delete(key);
+ }
+ })();
+
+ inFlightStore.set(key, request);
+ return request;
+};
+