Add GlobalProfileScreen and integrate global user management features

- Introduced GlobalProfileScreen for user profile management.
- Implemented user data fetching and updating via global API.
- Added session handling for global user state.
- Updated navigation to include profile access from the top navbar.
- Created global data caching mechanisms for improved performance.
This commit is contained in:
bobbert committed 2026-04-09 20:49:43 +01:00
1 parent 2a83d0608f
commit 53fddc8220
15 files changed
+1129 -210

No files matched your search

+9
View File
@@ -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}
/>
<GameStackNav.Screen
name="GlobalProfileScreen"
component={GlobalProfileScreen}
/>
</GameStackNav.Navigator>
);
@@ -100,6 +105,10 @@ const AdminStack = () => (
name="GlobalLeaderboardScreen"
component={GlobalLeaderboardScreen}
/>
<AdminStackNav.Screen
name="GlobalProfileScreen"
component={GlobalProfileScreen}
/>
</AdminStackNav.Navigator>
);
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

+6
View File
@@ -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"
}
+14 -2
View File
@@ -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},
];
+3
View File
@@ -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}) => {
<TopNavbar
title={pageTitle}
showBack={showBack}
showProfile={isGlobalRoute}
navigation={navigation}
/>
)}
+23 -2
View File
@@ -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}) => {
</View>
<Text style={styles.title}>{title}</Text>
<View style={styles.rightSlot}>
{showProfile ? (
<Pressable onPress={handleProfile} style={styles.profileButton}>
<Text style={styles.profileText}>Profile</Text>
</Pressable>
) : null}
<Pressable onPress={handleLogout} style={styles.logoutButton}>
<Text style={styles.logoutText}>Logout</Text>
</Pressable>
@@ -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,
+30 -1
View File
@@ -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');
};
@@ -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 }) => {
<View
style={[
styles.statusBadge,
isFound ? styles.badgeFound : styles.badgeOpen,
initIsFound ? styles.badgeFound : styles.badgeOpen,
]}
>
<Text style={styles.statusText}>
{isFound ? "Already Found" : "Not Yet Found"}
{initIsFound ? "Already Found" : "Not Yet Found"}
</Text>
</View>
@@ -104,23 +64,17 @@ const GlobalCacheViewScreen = ({ navigation, route }) => {
<Text style={styles.value}>{cache.CacheClue}</Text>
</View>
) : null}
{!initIsFound ? (
<Text style={styles.claimHint}>
Claim this cache from the Global Map while within 30m and facing it.
</Text>
) : null}
</Card>
{isLogging ? (
<ActivityIndicator size="large" />
) : (
<ButtonTray>
{!isFound && (
<Button
label="Log Discovery"
onClick={handleLogFind}
styleButton={styles.logButton}
styleLabel={styles.logButtonLabel}
/>
)}
<Button label="Back" onClick={() => navigation.goBack()} />
</ButtonTray>
)}
<ButtonTray>
<Button label="Back to Map" onClick={handleBackToMap} />
</ButtonTray>
</View>
</Screen>
);
@@ -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",
},
});
@@ -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 }) => {
<Screen>
<View style={styles.container}>
<ButtonTray>
<Button label="Refresh" onClick={loadEvents} />
<Button label="Refresh" onClick={() => loadEvents({forceRefresh: true})} />
</ButtonTray>
{error ? <Text style={styles.error}>{error}</Text> : null}
@@ -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 }) => {
)}
<ButtonTray>
<Button label="Refresh" onClick={loadData} />
<Button label="Refresh" onClick={() => loadData({forceRefresh: true})} />
</ButtonTray>
</View>
</Screen>
+192 -33
View File
@@ -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 (
<Screen showBack>
<View style={styles.container}>
{/* Tab switcher */}
<View style={styles.tabs}>
<TabButton
label="Map"
@@ -153,8 +279,9 @@ const GlobalMapScreen = ({ navigation, route }) => {
{activeTab === "map" ? (
<MapView style={styles.map} region={mapRegion} showsUserLocation>
{caches.map((cache) => {
{(caches || []).map((cache) => {
const found = foundCacheIds.includes(cache.CacheID);
const selected = selectedCacheId === cache.CacheID;
return (
<Marker
key={cache.CacheID}
@@ -164,11 +291,35 @@ const GlobalMapScreen = ({ navigation, route }) => {
}}
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 ? (
<Circle
center={claimTarget.coordinates}
radius={GLOBAL_CLAIM_DISTANCE_METERS}
fillColor="rgba(245, 158, 11, 0.12)"
strokeColor="rgba(245, 158, 11, 0.8)"
/>
) : null}
{userLocation && heading !== null && heading !== undefined ? (
<Polygon
coordinates={getFovCone(userLocation, heading)}
fillColor="rgba(66,133,244,0.28)"
strokeColor="rgba(66,133,244,0.50)"
strokeWidth={1}
/>
) : null}
</MapView>
) : (
<CacheList
@@ -178,9 +329,17 @@ const GlobalMapScreen = ({ navigation, route }) => {
/>
)}
<ClaimTimerView
cache={claimTarget}
isClaiming={isClaiming}
onClaimSuccess={handleClaim}
showClaimedPopup={claimedPopupVisible}
/>
<ButtonTray>
<Button label="Details" onClick={handleOpenSelectedCache} disabled={!selectedCacheId} />
<Button label="Leaderboard" onClick={handleGotoLeaderboard} />
<Button label="Refresh" onClick={loadData} />
<Button label="Refresh" onClick={() => loadData({forceRefresh: true})} />
</ButtonTray>
</View>
</Screen>
@@ -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 (
<Screen showBack>
<View style={styles.center}>
<ActivityIndicator size="large" />
</View>
</Screen>
);
}
return (
<Screen showBack>
<ScrollView contentContainerStyle={styles.container}>
{error ? <Text style={styles.error}>{error}</Text> : null}
<View style={styles.avatarWrap}>
<Image
source={{uri: profile.UserImageURL || defaultGlobalProfileImageUrl}}
style={styles.avatar}
/>
</View>
<Text style={styles.label}>First Name</Text>
<TextInput
style={styles.input}
placeholder="First name"
placeholderTextColor="#9ca3af"
value={profile.UserFirstname}
onChangeText={(value) =>
setProfile((prev) => ({...prev, UserFirstname: value}))
}
/>
<Text style={styles.label}>Last Name</Text>
<TextInput
style={styles.input}
placeholder="Last name"
placeholderTextColor="#9ca3af"
value={profile.UserLastname}
onChangeText={(value) =>
setProfile((prev) => ({...prev, UserLastname: value}))
}
/>
<Text style={styles.label}>Phone Number</Text>
<TextInput
style={styles.input}
placeholder="00000"
placeholderTextColor="#9ca3af"
value={profile.UserPhone}
onChangeText={(value) =>
setProfile((prev) => ({...prev, UserPhone: value}))
}
/>
<Text style={styles.label}>Profile Image URL</Text>
<TextInput
style={styles.input}
placeholder="https://example.com/profile.png"
placeholderTextColor="#9ca3af"
value={profile.UserImageURL}
onChangeText={(value) =>
setProfile((prev) => ({...prev, UserImageURL: value}))
}
autoCapitalize="none"
/>
<Text style={styles.label}>Username</Text>
<TextInput
style={[styles.input, styles.disabledInput]}
value={profile.UserUsername}
editable={false}
/>
<ButtonTray>
<Button
label={isSaving ? "Saving..." : "Save Profile"}
onClick={handleSave}
disabled={isSaving}
/>
</ButtonTray>
</ScrollView>
</Screen>
);
};
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;
+34
View File
@@ -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,
};
+331 -54
View File
@@ -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,
};
};
+69
View File
@@ -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;
};