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 GlobalMapScreen from "./src/components/screens/global/GlobalMapScreen";
|
||||||
import GlobalCacheViewScreen from "./src/components/screens/global/GlobalCacheViewScreen";
|
import GlobalCacheViewScreen from "./src/components/screens/global/GlobalCacheViewScreen";
|
||||||
import GlobalLeaderboardScreen from "./src/components/screens/global/GlobalLeaderboardScreen";
|
import GlobalLeaderboardScreen from "./src/components/screens/global/GlobalLeaderboardScreen";
|
||||||
|
import GlobalProfileScreen from "./src/components/screens/global/GlobalProfileScreen";
|
||||||
import {loadSession} from "./src/hooks/SessionStore";
|
import {loadSession} from "./src/hooks/SessionStore";
|
||||||
|
|
||||||
const RootStack = createNativeStackNavigator();
|
const RootStack = createNativeStackNavigator();
|
||||||
@@ -63,6 +64,10 @@ const GameStack = () => (
|
|||||||
name="GlobalLeaderboardScreen"
|
name="GlobalLeaderboardScreen"
|
||||||
component={GlobalLeaderboardScreen}
|
component={GlobalLeaderboardScreen}
|
||||||
/>
|
/>
|
||||||
|
<GameStackNav.Screen
|
||||||
|
name="GlobalProfileScreen"
|
||||||
|
component={GlobalProfileScreen}
|
||||||
|
/>
|
||||||
</GameStackNav.Navigator>
|
</GameStackNav.Navigator>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -100,6 +105,10 @@ const AdminStack = () => (
|
|||||||
name="GlobalLeaderboardScreen"
|
name="GlobalLeaderboardScreen"
|
||||||
component={GlobalLeaderboardScreen}
|
component={GlobalLeaderboardScreen}
|
||||||
/>
|
/>
|
||||||
|
<AdminStackNav.Screen
|
||||||
|
name="GlobalProfileScreen"
|
||||||
|
component={GlobalProfileScreen}
|
||||||
|
/>
|
||||||
</AdminStackNav.Navigator>
|
</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}] : []),
|
...(inGame ? [{label: 'Leave', route: '__leave__', onPress: handleLeaveGame}] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const globalEventParams = session.currentGlobalEventId
|
||||||
|
? {eventId: session.currentGlobalEventId}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const globalTabs = [
|
const globalTabs = [
|
||||||
{label: 'Events', route: 'GlobalEventsScreen', onPress: () => navigation.navigate('GlobalEventsScreen')},
|
{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},
|
{label: 'Exit', route: '__exit_global__', onPress: handleExitGlobal},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ const Screen = ({children, style, showBack = false}) => {
|
|||||||
GlobalMapScreen: 'Global Map',
|
GlobalMapScreen: 'Global Map',
|
||||||
GlobalCacheViewScreen: 'Cache Details',
|
GlobalCacheViewScreen: 'Cache Details',
|
||||||
GlobalLeaderboardScreen: 'Global Leaderboard',
|
GlobalLeaderboardScreen: 'Global Leaderboard',
|
||||||
|
GlobalProfileScreen: 'Global Profile',
|
||||||
};
|
};
|
||||||
|
|
||||||
// State ----------------------
|
// State ----------------------
|
||||||
|
|
||||||
const routeName = route.name;
|
const routeName = route.name;
|
||||||
const isAuthScreen = routeName === 'LoginScreen' || routeName === 'RegisterScreen';
|
const isAuthScreen = routeName === 'LoginScreen' || routeName === 'RegisterScreen';
|
||||||
|
const isGlobalRoute = routeName.startsWith('Global');
|
||||||
const pageTitle = titleMap[routeName] || 'Treasure Hunt';
|
const pageTitle = titleMap[routeName] || 'Treasure Hunt';
|
||||||
|
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
@@ -50,6 +52,7 @@ const Screen = ({children, style, showBack = false}) => {
|
|||||||
<TopNavbar
|
<TopNavbar
|
||||||
title={pageTitle}
|
title={pageTitle}
|
||||||
showBack={showBack}
|
showBack={showBack}
|
||||||
|
showProfile={isGlobalRoute}
|
||||||
navigation={navigation}
|
navigation={navigation}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {Pressable, StyleSheet, Text, View} from 'react-native';
|
|||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||||
import {clearSession} from '../../hooks/SessionStore';
|
import {clearSession} from '../../hooks/SessionStore';
|
||||||
|
|
||||||
const TopNavbar = ({title, showBack, navigation}) => {
|
const TopNavbar = ({title, showBack, showProfile = false, navigation}) => {
|
||||||
// Initialisation ------------
|
// Initialisation ------------
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -23,6 +23,10 @@ const TopNavbar = ({title, showBack, navigation}) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleProfile = () => {
|
||||||
|
navigation.navigate('GlobalProfileScreen');
|
||||||
|
};
|
||||||
|
|
||||||
// View -----------------------
|
// View -----------------------
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,6 +40,11 @@ const TopNavbar = ({title, showBack, navigation}) => {
|
|||||||
</View>
|
</View>
|
||||||
<Text style={styles.title}>{title}</Text>
|
<Text style={styles.title}>{title}</Text>
|
||||||
<View style={styles.rightSlot}>
|
<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}>
|
<Pressable onPress={handleLogout} style={styles.logoutButton}>
|
||||||
<Text style={styles.logoutText}>Logout</Text>
|
<Text style={styles.logoutText}>Logout</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -61,8 +70,10 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
},
|
},
|
||||||
rightSlot: {
|
rightSlot: {
|
||||||
width: 72,
|
width: 148,
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
},
|
},
|
||||||
iconButton: {
|
iconButton: {
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
@@ -80,6 +91,16 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
},
|
},
|
||||||
|
profileButton: {
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
profileText: {
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
logoutButton: {
|
logoutButton: {
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import ClaimTimerView from '../gameplay/ClaimTimerView';
|
|||||||
import PlayerMapView from '../gameplay/PlayerMapView';
|
import PlayerMapView from '../gameplay/PlayerMapView';
|
||||||
import Card from '../UI/Card';
|
import Card from '../UI/Card';
|
||||||
import useGameHook from '../../hooks/useGameHook';
|
import useGameHook from '../../hooks/useGameHook';
|
||||||
|
import useGlobalHook from '../../hooks/useGlobalHook';
|
||||||
import usePlayerGame from '../../hooks/usePlayerGame';
|
import usePlayerGame from '../../hooks/usePlayerGame';
|
||||||
import {
|
import {
|
||||||
clearPendingOrgJoin,
|
clearPendingOrgJoin,
|
||||||
getSession,
|
getSession,
|
||||||
|
setGlobalUserId,
|
||||||
setPendingAdmin,
|
setPendingAdmin,
|
||||||
setPendingOrgJoin,
|
setPendingOrgJoin,
|
||||||
setSelectedCache,
|
setSelectedCache,
|
||||||
@@ -34,6 +36,7 @@ const MapScreen = ({navigation, route}) => {
|
|||||||
const session = getSession();
|
const session = getSession();
|
||||||
const isBusiness = Boolean(session.isBusiness);
|
const isBusiness = Boolean(session.isBusiness);
|
||||||
const {getCaches, claimCache, upsertCache, deleteCache, joinPrivateGame, joinTeamByCode, createPrivateGame, getGroups, getLobby, getUser, getSubgroups, getGroupByOrgCode} = useGameHook();
|
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
|
// Stable ref to prevent infinite re-render loops
|
||||||
const getCachesRef = useRef(getCaches);
|
const getCachesRef = useRef(getCaches);
|
||||||
@@ -107,6 +110,23 @@ const MapScreen = ({navigation, route}) => {
|
|||||||
? {...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}
|
? {...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}
|
||||||
: DEFAULT_REGION;
|
: 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(() => {
|
useEffect(() => {
|
||||||
if (!session.currentGid) return;
|
if (!session.currentGid) return;
|
||||||
getLobbyRef.current(session.currentGid).then(setGroupInfo);
|
getLobbyRef.current(session.currentGid).then(setGroupInfo);
|
||||||
@@ -291,7 +311,16 @@ const MapScreen = ({navigation, route}) => {
|
|||||||
setQuickJoiningCode(null);
|
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);
|
setSessionMode(GAME_MODE.GLOBAL);
|
||||||
navigation.navigate('GlobalEventsScreen');
|
navigation.navigate('GlobalEventsScreen');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,72 +1,32 @@
|
|||||||
import {useEffect, useState} from "react";
|
import {useEffect} from "react";
|
||||||
import {ActivityIndicator, Alert, StyleSheet, Text, View} from "react-native";
|
import {StyleSheet, Text, View} from "react-native";
|
||||||
import Screen from "../../layout/Screen";
|
import Screen from "../../layout/Screen";
|
||||||
import {Button, ButtonTray} from "../../UI/Button";
|
import {Button, ButtonTray} from "../../UI/Button";
|
||||||
import Card from "../../UI/Card";
|
import Card from "../../UI/Card";
|
||||||
import useGlobalHook from "../../../hooks/useGlobalHook";
|
|
||||||
import {getSession} from "../../../hooks/SessionStore";
|
import {getSession} from "../../../hooks/SessionStore";
|
||||||
import {GAME_MODE} from "../../../utils/gameConstants";
|
import {GAME_MODE} from "../../../utils/gameConstants";
|
||||||
|
|
||||||
const GlobalCacheViewScreen = ({ navigation, route }) => {
|
const GlobalCacheViewScreen = ({ navigation, route }) => {
|
||||||
// Initialisations ---------------------
|
// Initialisations ---------------------
|
||||||
|
|
||||||
const { cache, isFound: initIsFound, onFindLogged } = route.params;
|
const { cache, isFound: initIsFound } = route.params;
|
||||||
const { logFind } = useGlobalHook();
|
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
|
|
||||||
// State -------------------------------
|
|
||||||
|
|
||||||
const [isFound, setIsFound] = useState(initIsFound);
|
|
||||||
const [isLogging, setIsLogging] = useState(false);
|
|
||||||
|
|
||||||
// Handlers ----------------------------
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (session.isBusiness || session.currentGameMode !== GAME_MODE.GLOBAL) {
|
if (session.isBusiness || session.currentGameMode !== GAME_MODE.GLOBAL) {
|
||||||
navigation.replace("MapScreen");
|
navigation.replace("MapScreen");
|
||||||
}
|
}
|
||||||
}, [navigation, session.currentGameMode, session.isBusiness]);
|
}, [navigation, session.currentGameMode, session.isBusiness]);
|
||||||
|
|
||||||
|
const handleBackToMap = () => {
|
||||||
|
navigation.navigate("GlobalMapScreen", {
|
||||||
|
eventId: cache.CacheEventID ?? session.currentGlobalEventId,
|
||||||
|
selectedCacheId: cache.CacheID,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// View --------------------------------
|
// View --------------------------------
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -78,11 +38,11 @@ const GlobalCacheViewScreen = ({ navigation, route }) => {
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.statusBadge,
|
styles.statusBadge,
|
||||||
isFound ? styles.badgeFound : styles.badgeOpen,
|
initIsFound ? styles.badgeFound : styles.badgeOpen,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.statusText}>
|
<Text style={styles.statusText}>
|
||||||
{isFound ? "Already Found" : "Not Yet Found"}
|
{initIsFound ? "Already Found" : "Not Yet Found"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -104,23 +64,17 @@ const GlobalCacheViewScreen = ({ navigation, route }) => {
|
|||||||
<Text style={styles.value}>{cache.CacheClue}</Text>
|
<Text style={styles.value}>{cache.CacheClue}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{!initIsFound ? (
|
||||||
|
<Text style={styles.claimHint}>
|
||||||
|
Claim this cache from the Global Map while within 30m and facing it.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{isLogging ? (
|
<ButtonTray>
|
||||||
<ActivityIndicator size="large" />
|
<Button label="Back to Map" onClick={handleBackToMap} />
|
||||||
) : (
|
</ButtonTray>
|
||||||
<ButtonTray>
|
|
||||||
{!isFound && (
|
|
||||||
<Button
|
|
||||||
label="Log Discovery"
|
|
||||||
onClick={handleLogFind}
|
|
||||||
styleButton={styles.logButton}
|
|
||||||
styleLabel={styles.logButtonLabel}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button label="Back" onClick={() => navigation.goBack()} />
|
|
||||||
</ButtonTray>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
@@ -170,14 +124,11 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
color: "#111827",
|
color: "#111827",
|
||||||
},
|
},
|
||||||
logButton: {
|
claimHint: {
|
||||||
backgroundColor: "#16a34a",
|
marginTop: 6,
|
||||||
borderColor: "#16a34a",
|
color: "#2563eb",
|
||||||
flex: 2,
|
fontSize: 13,
|
||||||
},
|
fontWeight: "600",
|
||||||
logButtonLabel: {
|
|
||||||
color: "#fff",
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,24 @@ import {GAME_MODE} from "../../../utils/gameConstants";
|
|||||||
const GlobalEventsScreen = ({ navigation }) => {
|
const GlobalEventsScreen = ({ navigation }) => {
|
||||||
// Initialisations ---------------------
|
// Initialisations ---------------------
|
||||||
|
|
||||||
const { getPublicEvents, getPlayersByEvent, joinEvent } = useGlobalHook();
|
const {
|
||||||
|
getPublicEvents,
|
||||||
|
getPlayersByEvent,
|
||||||
|
joinEvent,
|
||||||
|
isGlobalApiReady,
|
||||||
|
} = useGlobalHook();
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
const globalApiRef = useRef({
|
const globalApiRef = useRef({
|
||||||
getPublicEvents,
|
getPublicEvents,
|
||||||
getPlayersByEvent,
|
getPlayersByEvent,
|
||||||
joinEvent,
|
joinEvent,
|
||||||
|
isGlobalApiReady,
|
||||||
});
|
});
|
||||||
globalApiRef.current = {
|
globalApiRef.current = {
|
||||||
getPublicEvents,
|
getPublicEvents,
|
||||||
getPlayersByEvent,
|
getPlayersByEvent,
|
||||||
joinEvent,
|
joinEvent,
|
||||||
|
isGlobalApiReady,
|
||||||
};
|
};
|
||||||
|
|
||||||
// State -------------------------------
|
// State -------------------------------
|
||||||
@@ -32,10 +39,18 @@ const GlobalEventsScreen = ({ navigation }) => {
|
|||||||
|
|
||||||
// Handlers ----------------------------
|
// Handlers ----------------------------
|
||||||
|
|
||||||
const loadEvents = useCallback(async () => {
|
const loadEvents = useCallback(async (options = {}) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError("");
|
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);
|
setEvents(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -52,27 +67,59 @@ const GlobalEventsScreen = ({ navigation }) => {
|
|||||||
setError("");
|
setError("");
|
||||||
setJoining(event.EventID);
|
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
|
// 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(
|
const existing = (players || []).find(
|
||||||
(p) => p.PlayerUserID === session.currentUid,
|
(p) => String(p.PlayerUserID) === String(globalUserId),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existing) {
|
const existingPlayerId = pickPlayerId(existing);
|
||||||
setGlobalSession(event.EventID, existing.PlayerID);
|
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 });
|
navigation.navigate("GlobalMapScreen", { eventId: event.EventID });
|
||||||
} else {
|
} else {
|
||||||
// Join the event
|
setError("Failed to join event. Please try again.");
|
||||||
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.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setJoining(null);
|
setJoining(null);
|
||||||
@@ -84,7 +131,7 @@ const GlobalEventsScreen = ({ navigation }) => {
|
|||||||
<Screen>
|
<Screen>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<ButtonTray>
|
<ButtonTray>
|
||||||
<Button label="Refresh" onClick={loadEvents} />
|
<Button label="Refresh" onClick={() => loadEvents({forceRefresh: true})} />
|
||||||
</ButtonTray>
|
</ButtonTray>
|
||||||
|
|
||||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
{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 {ActivityIndicator, ScrollView, StyleSheet, Text, View,} from "react-native";
|
||||||
import Screen from "../../layout/Screen";
|
import Screen from "../../layout/Screen";
|
||||||
import {Button, ButtonTray} from "../../UI/Button";
|
import {Button, ButtonTray} from "../../UI/Button";
|
||||||
@@ -15,6 +15,16 @@ const GlobalLeaderboardScreen = ({ navigation, route }) => {
|
|||||||
const { getFindsByEvent, getCachesByEvent, getPlayersByEvent } =
|
const { getFindsByEvent, getCachesByEvent, getPlayersByEvent } =
|
||||||
useGlobalHook();
|
useGlobalHook();
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
|
const globalApiRef = useRef({
|
||||||
|
getFindsByEvent,
|
||||||
|
getCachesByEvent,
|
||||||
|
getPlayersByEvent,
|
||||||
|
});
|
||||||
|
globalApiRef.current = {
|
||||||
|
getFindsByEvent,
|
||||||
|
getCachesByEvent,
|
||||||
|
getPlayersByEvent,
|
||||||
|
};
|
||||||
|
|
||||||
// State -------------------------------
|
// State -------------------------------
|
||||||
|
|
||||||
@@ -25,25 +35,27 @@ const GlobalLeaderboardScreen = ({ navigation, route }) => {
|
|||||||
|
|
||||||
// Handlers ----------------------------
|
// Handlers ----------------------------
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async (options = {}) => {
|
||||||
|
if (!eventId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const [finds, caches, players] = await Promise.all([
|
const [finds, caches, players] = await Promise.all([
|
||||||
getFindsByEvent(eventId),
|
globalApiRef.current.getFindsByEvent(eventId, options),
|
||||||
getCachesByEvent(eventId),
|
globalApiRef.current.getCachesByEvent(eventId, options),
|
||||||
getPlayersByEvent(eventId),
|
globalApiRef.current.getPlayersByEvent(eventId, options),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build cache points lookup
|
// Build cache points lookup
|
||||||
const cacheMap = {};
|
const cacheMap = {};
|
||||||
(caches || []).forEach((c) => {
|
(caches || []).forEach((c) => {
|
||||||
cacheMap[c.CacheID] = c;
|
cacheMap[String(c.CacheID)] = c;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Augment finds with cache info for FindList
|
// Augment finds with cache info for FindList
|
||||||
const augmented = (finds || []).map((f) => ({
|
const augmented = (finds || []).map((f) => ({
|
||||||
...f,
|
...f,
|
||||||
FindCache: f.FindCache || cacheMap[f.FindCacheID] || null,
|
FindCache: f.FindCache || cacheMap[String(f.FindCacheID)] || null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Sort recent first
|
// Sort recent first
|
||||||
@@ -52,41 +64,74 @@ const GlobalLeaderboardScreen = ({ navigation, route }) => {
|
|||||||
);
|
);
|
||||||
setRecentFinds(sorted.slice(0, 50));
|
setRecentFinds(sorted.slice(0, 50));
|
||||||
|
|
||||||
// Aggregate points per player
|
// Start ranking map with all players so zero-point players are visible.
|
||||||
const pointsMap = {};
|
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) => {
|
augmented.forEach((f) => {
|
||||||
const pid = f.FindPlayerID;
|
const pid = f.FindPlayerID;
|
||||||
const pts = f.FindCache?.CachePoints ?? 0;
|
const key = String(pid);
|
||||||
if (!pointsMap[pid])
|
const pts = Number(f.FindCache?.CachePoints || 0);
|
||||||
pointsMap[pid] = { playerId: pid, points: 0, finds: 0, name: null };
|
|
||||||
pointsMap[pid].points += pts;
|
|
||||||
pointsMap[pid].finds += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Attach usernames from players list
|
if (!pointsMap[key]) {
|
||||||
(players || []).forEach((p) => {
|
pointsMap[key] = {
|
||||||
if (pointsMap[p.PlayerID]) {
|
playerId: pid,
|
||||||
pointsMap[p.PlayerID].name =
|
points: 0,
|
||||||
p.PlayerUser?.UserUsername || `Player #${p.PlayerUserID}`;
|
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);
|
setRankings(ranked);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, [eventId, getFindsByEvent, getCachesByEvent, getPlayersByEvent]);
|
}, [eventId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session.isBusiness || session.currentGameMode !== GAME_MODE.GLOBAL) {
|
if (session.isBusiness || session.currentGameMode !== GAME_MODE.GLOBAL) {
|
||||||
navigation.replace("MapScreen");
|
navigation.replace("MapScreen");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!eventId) {
|
if (!session.currentGlobalPlayerId) {
|
||||||
navigation.replace("GlobalEventsScreen");
|
navigation.replace("GlobalEventsScreen");
|
||||||
return;
|
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();
|
loadData();
|
||||||
}, [eventId, loadData, navigation, session.currentGameMode, session.isBusiness]);
|
}, [eventId, loadData, session.currentGameMode, session.currentGlobalPlayerId]);
|
||||||
|
|
||||||
// View --------------------------------
|
// View --------------------------------
|
||||||
|
|
||||||
@@ -154,7 +199,7 @@ const GlobalLeaderboardScreen = ({ navigation, route }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ButtonTray>
|
<ButtonTray>
|
||||||
<Button label="Refresh" onClick={loadData} />
|
<Button label="Refresh" onClick={() => loadData({forceRefresh: true})} />
|
||||||
</ButtonTray>
|
</ButtonTray>
|
||||||
</View>
|
</View>
|
||||||
</Screen>
|
</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 {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 * as Location from "expo-location";
|
||||||
import Screen from "../../layout/Screen";
|
import Screen from "../../layout/Screen";
|
||||||
import {Button, ButtonTray} from "../../UI/Button";
|
import {Button, ButtonTray} from "../../UI/Button";
|
||||||
|
import ClaimTimerView from "../../gameplay/ClaimTimerView";
|
||||||
import CacheList from "../../../entity/cache/CacheList";
|
import CacheList from "../../../entity/cache/CacheList";
|
||||||
import useGlobalHook from "../../../hooks/useGlobalHook";
|
import useGlobalHook from "../../../hooks/useGlobalHook";
|
||||||
|
import usePlayerGame from "../../../hooks/usePlayerGame";
|
||||||
import {getSession} from "../../../hooks/SessionStore";
|
import {getSession} from "../../../hooks/SessionStore";
|
||||||
|
import {getFovCone} from "../../../utils/geoMath";
|
||||||
import {GAME_MODE} from "../../../utils/gameConstants";
|
import {GAME_MODE} from "../../../utils/gameConstants";
|
||||||
|
|
||||||
const DEFAULT_REGION = {
|
const DEFAULT_REGION = {
|
||||||
@@ -16,54 +19,177 @@ const DEFAULT_REGION = {
|
|||||||
longitudeDelta: 0.05,
|
longitudeDelta: 0.05,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const GLOBAL_CLAIM_DISTANCE_METERS = 30;
|
||||||
|
|
||||||
const GlobalMapScreen = ({ navigation, route }) => {
|
const GlobalMapScreen = ({ navigation, route }) => {
|
||||||
// Initialisations ---------------------
|
// Initialisations ---------------------
|
||||||
|
|
||||||
const eventId = route?.params?.eventId ?? getSession().currentGlobalEventId;
|
|
||||||
const { getCachesByEvent, getFindsByPlayer } = useGlobalHook();
|
|
||||||
const session = getSession();
|
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 -------------------------------
|
// State -------------------------------
|
||||||
|
|
||||||
const [caches, setCaches] = useState([]);
|
const [caches, setCaches] = useState([]);
|
||||||
const [foundCacheIds, setFoundCacheIds] = useState([]);
|
const [foundCacheIds, setFoundCacheIds] = useState([]);
|
||||||
const [userLocation, setUserLocation] = useState(null);
|
const [userLocation, setUserLocation] = useState(null);
|
||||||
|
const [heading, setHeading] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState("map");
|
const [activeTab, setActiveTab] = useState("map");
|
||||||
const [error, setError] = useState("");
|
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 ----------------------------
|
// Handlers ----------------------------
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async (options = {}) => {
|
||||||
|
if (!eventId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
const [cacheData, findsData] = await Promise.all([
|
const [cacheData, findsData] = await Promise.all([
|
||||||
getCachesByEvent(eventId),
|
globalApiRef.current.getCachesByEvent(eventId, options),
|
||||||
session.currentGlobalPlayerId
|
session.currentGlobalPlayerId
|
||||||
? getFindsByPlayer(session.currentGlobalPlayerId)
|
? globalApiRef.current.getFindsByPlayer(session.currentGlobalPlayerId, options)
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setCaches(cacheData);
|
setCaches(cacheData || []);
|
||||||
setFoundCacheIds((findsData || []).map((f) => f.FindCacheID));
|
setFoundCacheIds((findsData || []).map((f) => f.FindCacheID));
|
||||||
setIsLoading(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (session.isBusiness || session.currentGameMode !== GAME_MODE.GLOBAL) {
|
if (session.isBusiness || session.currentGameMode !== GAME_MODE.GLOBAL) {
|
||||||
navigation.replace("MapScreen");
|
navigation.replace("MapScreen");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!eventId) {
|
|
||||||
|
if (!session.currentGlobalPlayerId) {
|
||||||
navigation.replace("GlobalEventsScreen");
|
navigation.replace("GlobalEventsScreen");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadData();
|
|
||||||
}, [eventId, loadData, navigation, session.currentGameMode, session.isBusiness]);
|
if (!eventId) {
|
||||||
|
navigation.replace("GlobalEventsScreen");
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
eventId,
|
||||||
|
navigation,
|
||||||
|
session.currentGameMode,
|
||||||
|
session.currentGlobalPlayerId,
|
||||||
|
session.isBusiness,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
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 start = async () => {
|
||||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||||
if (status !== "granted") {
|
if (status !== "granted") {
|
||||||
@@ -87,11 +213,11 @@ const GlobalMapScreen = ({ navigation, route }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
sub = await Location.watchPositionAsync(
|
locationSub = await Location.watchPositionAsync(
|
||||||
{
|
{
|
||||||
accuracy: Location.Accuracy.Balanced,
|
accuracy: Location.Accuracy.Balanced,
|
||||||
distanceInterval: 5,
|
distanceInterval: 3,
|
||||||
timeInterval: 3000,
|
timeInterval: 1500,
|
||||||
},
|
},
|
||||||
(pos) =>
|
(pos) =>
|
||||||
setUserLocation({
|
setUserLocation({
|
||||||
@@ -99,10 +225,21 @@ const GlobalMapScreen = ({ navigation, route }) => {
|
|||||||
longitude: pos.coords.longitude,
|
longitude: pos.coords.longitude,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
headingSub = await Location.watchHeadingAsync((headingData) => {
|
||||||
|
const raw =
|
||||||
|
headingData.trueHeading >= 0
|
||||||
|
? headingData.trueHeading
|
||||||
|
: headingData.magHeading;
|
||||||
|
setHeading(raw);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
start();
|
start();
|
||||||
|
|
||||||
return () => {
|
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 }
|
? { ...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01 }
|
||||||
: DEFAULT_REGION;
|
: DEFAULT_REGION;
|
||||||
|
|
||||||
const handleCacheSelect = (cache) => {
|
const claimTarget = visibleCaches[0] || null;
|
||||||
navigation.navigate("GlobalCacheViewScreen", {
|
|
||||||
cache,
|
|
||||||
isFound: foundCacheIds.includes(cache.CacheID),
|
|
||||||
onFindLogged: loadData,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGotoLeaderboard = () => {
|
|
||||||
navigation.navigate("GlobalLeaderboardScreen", { eventId });
|
|
||||||
};
|
|
||||||
|
|
||||||
// View --------------------------------
|
// View --------------------------------
|
||||||
|
|
||||||
@@ -135,7 +262,6 @@ const GlobalMapScreen = ({ navigation, route }) => {
|
|||||||
return (
|
return (
|
||||||
<Screen showBack>
|
<Screen showBack>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Tab switcher */}
|
|
||||||
<View style={styles.tabs}>
|
<View style={styles.tabs}>
|
||||||
<TabButton
|
<TabButton
|
||||||
label="Map"
|
label="Map"
|
||||||
@@ -153,8 +279,9 @@ const GlobalMapScreen = ({ navigation, route }) => {
|
|||||||
|
|
||||||
{activeTab === "map" ? (
|
{activeTab === "map" ? (
|
||||||
<MapView style={styles.map} region={mapRegion} showsUserLocation>
|
<MapView style={styles.map} region={mapRegion} showsUserLocation>
|
||||||
{caches.map((cache) => {
|
{(caches || []).map((cache) => {
|
||||||
const found = foundCacheIds.includes(cache.CacheID);
|
const found = foundCacheIds.includes(cache.CacheID);
|
||||||
|
const selected = selectedCacheId === cache.CacheID;
|
||||||
return (
|
return (
|
||||||
<Marker
|
<Marker
|
||||||
key={cache.CacheID}
|
key={cache.CacheID}
|
||||||
@@ -164,11 +291,35 @@ const GlobalMapScreen = ({ navigation, route }) => {
|
|||||||
}}
|
}}
|
||||||
title={cache.CacheName}
|
title={cache.CacheName}
|
||||||
description={`${cache.CachePoints ?? 0} pts${found ? " · Found" : ""}`}
|
description={`${cache.CachePoints ?? 0} pts${found ? " · Found" : ""}`}
|
||||||
pinColor={found ? "#22c55e" : "#ef4444"}
|
pinColor={found ? "#22c55e" : selected ? "#f59e0b" : "#ef4444"}
|
||||||
onCalloutPress={() => handleCacheSelect(cache)}
|
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>
|
</MapView>
|
||||||
) : (
|
) : (
|
||||||
<CacheList
|
<CacheList
|
||||||
@@ -178,9 +329,17 @@ const GlobalMapScreen = ({ navigation, route }) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ClaimTimerView
|
||||||
|
cache={claimTarget}
|
||||||
|
isClaiming={isClaiming}
|
||||||
|
onClaimSuccess={handleClaim}
|
||||||
|
showClaimedPopup={claimedPopupVisible}
|
||||||
|
/>
|
||||||
|
|
||||||
<ButtonTray>
|
<ButtonTray>
|
||||||
|
<Button label="Details" onClick={handleOpenSelectedCache} disabled={!selectedCacheId} />
|
||||||
<Button label="Leaderboard" onClick={handleGotoLeaderboard} />
|
<Button label="Leaderboard" onClick={handleGotoLeaderboard} />
|
||||||
<Button label="Refresh" onClick={loadData} />
|
<Button label="Refresh" onClick={() => loadData({forceRefresh: true})} />
|
||||||
</ButtonTray>
|
</ButtonTray>
|
||||||
</View>
|
</View>
|
||||||
</Screen>
|
</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,
|
isPendingAdmin: false,
|
||||||
teamsEnabled: false,
|
teamsEnabled: false,
|
||||||
currentGameMode: null,
|
currentGameMode: null,
|
||||||
|
// Temporary state for business join flow between company code and department code.
|
||||||
|
pendingOrgJoin: null,
|
||||||
// Global gameplay
|
// Global gameplay
|
||||||
currentGlobalEventId: null,
|
currentGlobalEventId: null,
|
||||||
currentGlobalPlayerId: null,
|
currentGlobalPlayerId: null,
|
||||||
|
currentGlobalUserId: null,
|
||||||
selectedCacheId: null,
|
selectedCacheId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,6 +37,7 @@ const setSessionUser = (user) => {
|
|||||||
// New login/register starts in regular flow; global flow requires entering GLOBAL code.
|
// New login/register starts in regular flow; global flow requires entering GLOBAL code.
|
||||||
session.currentGlobalEventId = null;
|
session.currentGlobalEventId = null;
|
||||||
session.currentGlobalPlayerId = null;
|
session.currentGlobalPlayerId = null;
|
||||||
|
session.currentGlobalUserId = null;
|
||||||
session.currentGameMode = user?.Gid ? GAME_MODE.REGULAR : null;
|
session.currentGameMode = user?.Gid ? GAME_MODE.REGULAR : null;
|
||||||
persistSession();
|
persistSession();
|
||||||
};
|
};
|
||||||
@@ -62,6 +66,16 @@ const setPendingAdmin = (val) => {
|
|||||||
persistSession();
|
persistSession();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setPendingOrgJoin = (payload) => {
|
||||||
|
session.pendingOrgJoin = payload || null;
|
||||||
|
persistSession();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearPendingOrgJoin = () => {
|
||||||
|
session.pendingOrgJoin = null;
|
||||||
|
persistSession();
|
||||||
|
};
|
||||||
|
|
||||||
const setSelectedCache = (cacheId) => {
|
const setSelectedCache = (cacheId) => {
|
||||||
session.selectedCacheId = cacheId ?? null;
|
session.selectedCacheId = cacheId ?? null;
|
||||||
persistSession();
|
persistSession();
|
||||||
@@ -76,6 +90,11 @@ const setGlobalSession = (eventId, playerId) => {
|
|||||||
persistSession();
|
persistSession();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setGlobalUserId = (userId) => {
|
||||||
|
session.currentGlobalUserId = userId ?? null;
|
||||||
|
persistSession();
|
||||||
|
};
|
||||||
|
|
||||||
const setSessionMode = (mode) => {
|
const setSessionMode = (mode) => {
|
||||||
session.currentGameMode = mode ?? null;
|
session.currentGameMode = mode ?? null;
|
||||||
persistSession();
|
persistSession();
|
||||||
@@ -98,7 +117,9 @@ const clearSession = () => {
|
|||||||
session.currentGameMode = null;
|
session.currentGameMode = null;
|
||||||
session.currentGlobalEventId = null;
|
session.currentGlobalEventId = null;
|
||||||
session.currentGlobalPlayerId = null;
|
session.currentGlobalPlayerId = null;
|
||||||
|
session.currentGlobalUserId = null;
|
||||||
session.isPendingAdmin = false;
|
session.isPendingAdmin = false;
|
||||||
|
session.pendingOrgJoin = null;
|
||||||
AsyncStorage.removeItem(STORAGE_KEY).catch(() => {});
|
AsyncStorage.removeItem(STORAGE_KEY).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,6 +130,7 @@ const clearGameSession = () => {
|
|||||||
session.currentTid = null;
|
session.currentTid = null;
|
||||||
session.isAcceptedAdmin = false;
|
session.isAcceptedAdmin = false;
|
||||||
session.isPendingAdmin = false;
|
session.isPendingAdmin = false;
|
||||||
|
session.pendingOrgJoin = null;
|
||||||
session.teamsEnabled = false;
|
session.teamsEnabled = false;
|
||||||
session.selectedCacheId = null;
|
session.selectedCacheId = null;
|
||||||
if (!session.currentGlobalEventId) session.currentGameMode = null;
|
if (!session.currentGlobalEventId) session.currentGameMode = null;
|
||||||
@@ -128,6 +150,15 @@ const loadSession = async () => {
|
|||||||
const saved = JSON.parse(raw);
|
const saved = JSON.parse(raw);
|
||||||
Object.assign(session, saved);
|
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 (_) {
|
} catch (_) {
|
||||||
// Ignore read errors — start fresh
|
// Ignore read errors — start fresh
|
||||||
}
|
}
|
||||||
@@ -140,12 +171,15 @@ export {
|
|||||||
setSessionTeam,
|
setSessionTeam,
|
||||||
setSessionTeamsEnabled,
|
setSessionTeamsEnabled,
|
||||||
setPendingAdmin,
|
setPendingAdmin,
|
||||||
|
setPendingOrgJoin,
|
||||||
|
clearPendingOrgJoin,
|
||||||
setSelectedCache,
|
setSelectedCache,
|
||||||
getSession,
|
getSession,
|
||||||
clearSession,
|
clearSession,
|
||||||
clearGameSession,
|
clearGameSession,
|
||||||
loadSession,
|
loadSession,
|
||||||
setGlobalSession,
|
setGlobalSession,
|
||||||
|
setGlobalUserId,
|
||||||
clearGlobalSession,
|
clearGlobalSession,
|
||||||
setSessionMode,
|
setSessionMode,
|
||||||
};
|
};
|
||||||
+331
-54
@@ -1,92 +1,366 @@
|
|||||||
|
import {useCallback, useMemo} from "react";
|
||||||
import API from "../components/API/API";
|
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_BASE = String(
|
||||||
const GLOBAL_KEY = process.env.EXPO_PUBLIC_GLOBAL_API_KEY;
|
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) => {
|
const withKey = (url) => {
|
||||||
|
if (!GLOBAL_KEY) return url;
|
||||||
const separator = url.includes("?") ? "&" : "?";
|
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 = () => {
|
const useGlobalHook = () => {
|
||||||
// Initialisations ---------------------
|
// Initialisations ---------------------
|
||||||
|
|
||||||
const eventsEndpoint = `${GLOBAL_BASE}/api/events`;
|
const endpoints = useMemo(
|
||||||
const playersEndpoint = `${GLOBAL_BASE}/api/players`;
|
() => ({
|
||||||
const cachesEndpoint = `${GLOBAL_BASE}/api/caches`;
|
events: `${GLOBAL_BASE}/api/events`,
|
||||||
const findsEndpoint = `${GLOBAL_BASE}/api/finds`;
|
players: `${GLOBAL_BASE}/api/players`,
|
||||||
const usersEndpoint = `${GLOBAL_BASE}/api/users`;
|
caches: `${GLOBAL_BASE}/api/caches`,
|
||||||
|
finds: `${GLOBAL_BASE}/api/finds`,
|
||||||
|
users: `${GLOBAL_BASE}/api/users`,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Handlers ----------------------------
|
// Handlers ----------------------------
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
const getPublicEvents = async () => {
|
const getPublicEvents = useCallback(async (options = {}) => {
|
||||||
const response = await API.get(withKey(eventsEndpoint));
|
if (!isGlobalApiReady()) return [];
|
||||||
if (!response.isSuccess) return [];
|
return getOrFetchGlobalData(
|
||||||
return response.result.filter((e) => e.EventIspublic);
|
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 getEvent = useCallback(async (eventId, options = {}) => {
|
||||||
const response = await API.get(withKey(`${eventsEndpoint}/${eventId}`));
|
if (!isGlobalApiReady()) return null;
|
||||||
return response.isSuccess ? response.result : 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
|
// Players
|
||||||
const getPlayersByEvent = async (eventId) => {
|
const getPlayersByEvent = useCallback(async (eventId, options = {}) => {
|
||||||
const response = await API.get(
|
if (!isGlobalApiReady()) return [];
|
||||||
withKey(`${playersEndpoint}/events/${eventId}`),
|
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 joinEvent = useCallback(async (data) => {
|
||||||
const response = await API.post(withKey(playersEndpoint), data);
|
if (!isGlobalApiReady()) return null;
|
||||||
return response.isSuccess ? response.result : 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
|
// Caches
|
||||||
const getCachesByEvent = async (eventId) => {
|
const getCachesByEvent = useCallback(async (eventId, options = {}) => {
|
||||||
const response = await API.get(
|
if (!isGlobalApiReady()) return [];
|
||||||
withKey(`${cachesEndpoint}/events/${eventId}`),
|
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 getCache = useCallback(async (cacheId, options = {}) => {
|
||||||
const response = await API.get(withKey(`${cachesEndpoint}/${cacheId}`));
|
if (!isGlobalApiReady()) return null;
|
||||||
return response.isSuccess ? response.result : 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
|
// Finds
|
||||||
const getFindsByEvent = async (eventId) => {
|
const getFindsByEvent = useCallback(async (eventId, options = {}) => {
|
||||||
const response = await API.get(
|
if (!isGlobalApiReady()) return [];
|
||||||
withKey(`${findsEndpoint}/events/${eventId}`),
|
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 getFindsByPlayer = useCallback(async (playerId, options = {}) => {
|
||||||
const response = await API.get(
|
if (!isGlobalApiReady()) return [];
|
||||||
withKey(`${findsEndpoint}/players/${playerId}`),
|
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 logFind = useCallback(async (data) => {
|
||||||
const response = await API.post(withKey(findsEndpoint), data);
|
if (!isGlobalApiReady()) return null;
|
||||||
return response.isSuccess ? response.result : 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
|
// Users
|
||||||
const getUser = async (userId) => {
|
const getUsers = useCallback(async (options = {}) => {
|
||||||
const response = await API.get(withKey(`${usersEndpoint}/${userId}`));
|
if (!isGlobalApiReady()) return [];
|
||||||
return response.isSuccess ? response.result : null;
|
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 --------------------------------
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
isGlobalApiReady,
|
||||||
|
defaultGlobalProfileImageUrl: DEFAULT_GLOBAL_PROFILE_IMAGE_URL,
|
||||||
getPublicEvents,
|
getPublicEvents,
|
||||||
getEvent,
|
getEvent,
|
||||||
getPlayersByEvent,
|
getPlayersByEvent,
|
||||||
@@ -97,6 +371,9 @@ const useGlobalHook = () => {
|
|||||||
getFindsByPlayer,
|
getFindsByPlayer,
|
||||||
logFind,
|
logFind,
|
||||||
getUser,
|
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