mirror of
https://github.com/mudabbir-ahmad/MAD-Treasure-Hunt.git
synced 2026-10-07 19:00:20 +00:00
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:
1 parent
2a83d0608f
commit
53fddc8220
15 files changed
+1129
-210
No files matched your search
@@ -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 |
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
];
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user