fixed claim cache for global mode

removed redundant code. fixed cache claiming logic with find item endopoint from geoquest api
This commit is contained in:
suddenlyoranges committed 2026-04-09 23:45:26 +01:00
1 parent c68eb160cc
commit 82e15508c2
12 files changed
+2789 -2274

No files matched your search

+12 -13
View File
@@ -1,11 +1,11 @@
import {StyleSheet, TextInput, View} from 'react-native'; import { StyleSheet, TextInput, View } from "react-native";
import {Button} from './Button'; import { Button } from "./Button";
const CodeJoinRow = ({ const CodeJoinRow = ({
value, value,
onChange, onChange,
placeholder, placeholder,
buttonLabel = 'Join', buttonLabel = "Join",
onSubmit, onSubmit,
disabled = false, disabled = false,
style, style,
@@ -39,37 +39,36 @@ const CodeJoinRow = ({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
inputRow: { inputRow: {
flexDirection: 'row', flexDirection: "row",
gap: 10, gap: 10,
marginBottom: 15, marginBottom: 15,
width: '100%', width: "100%",
paddingHorizontal: 20, paddingHorizontal: 20,
}, },
codeInput: { codeInput: {
flex: 1, flex: 1,
borderWidth: 1, borderWidth: 1,
borderColor: '#45475a', borderColor: "#45475a",
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 16, fontSize: 16,
color: '#cdd6f4', color: "#cdd6f4",
backgroundColor: '#313244', backgroundColor: "#313244",
}, },
inputDisabled: { inputDisabled: {
opacity: 0.5, opacity: 0.5,
}, },
joinButton: { joinButton: {
backgroundColor: '#bd93f9', backgroundColor: "#bd93f9",
borderColor: '#bd93f9', borderColor: "#bd93f9",
flex: 0, flex: 0,
paddingHorizontal: 20, paddingHorizontal: 20,
}, },
joinLabel: { joinLabel: {
color: '#1e1e2e', color: "#1e1e2e",
fontWeight: '600', fontWeight: "600",
}, },
}); });
export default CodeJoinRow; export default CodeJoinRow;
+12 -12
View File
@@ -1,6 +1,6 @@
import React, {useEffect, useRef, useState} from 'react'; import { useEffect, useRef, useState } from "react";
import {StyleSheet, Text, View} from 'react-native'; import { StyleSheet, Text, View } from "react-native";
import {CACHE_CLAIM_TIMER} from '../../utils/geoMath'; import { CACHE_CLAIM_TIMER } from "../../utils/geoMath";
const ClaimTimerView = ({ const ClaimTimerView = ({
cache, cache,
@@ -8,7 +8,7 @@ const ClaimTimerView = ({
isClaiming, isClaiming,
showClaimedPopup, showClaimedPopup,
claimDurationSeconds = CACHE_CLAIM_TIMER, claimDurationSeconds = CACHE_CLAIM_TIMER,
claimedPopupMessage = 'Cache Claimed!', claimedPopupMessage = "Cache Claimed!",
autoClaim = true, autoClaim = true,
}) => { }) => {
const [timeLeft, setTimeLeft] = useState(claimDurationSeconds); const [timeLeft, setTimeLeft] = useState(claimDurationSeconds);
@@ -75,30 +75,30 @@ const ClaimTimerView = ({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
overlay: { overlay: {
alignSelf: 'center', alignSelf: "center",
backgroundColor: 'rgba(0, 0, 0, 0.75)', backgroundColor: "rgba(0, 0, 0, 0.75)",
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 12, paddingVertical: 12,
borderRadius: 10, borderRadius: 10,
marginTop: 8, marginTop: 8,
}, },
text: { text: {
color: '#cdd6f4', color: "#cdd6f4",
fontSize: 16, fontSize: 16,
fontWeight: '700', fontWeight: "700",
}, },
claimedOverlay: { claimedOverlay: {
alignSelf: 'center', alignSelf: "center",
backgroundColor: '#a6e3a1', backgroundColor: "#a6e3a1",
paddingHorizontal: 20, paddingHorizontal: 20,
paddingVertical: 14, paddingVertical: 14,
borderRadius: 10, borderRadius: 10,
marginTop: 8, marginTop: 8,
}, },
claimedText: { claimedText: {
color: '#1e1e2e', color: "#1e1e2e",
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: "700",
}, },
}); });
+41 -39
View File
@@ -1,45 +1,47 @@
import React from 'react'; import MapView, { Marker, Polygon } from "react-native-maps";
import MapView, {Marker, Polygon} from 'react-native-maps'; import { getFovCone } from "../../utils/geoMath";
import {getFovCone} from '../../utils/geoMath';
const PlayerMapView = ({userLocation, visibleCaches, heading}) => { const PlayerMapView = ({ userLocation, visibleCaches, heading }) => {
if (!userLocation) return null; if (!userLocation) return null;
const coneCoords = (heading !== null && heading !== undefined) const coneCoords =
? getFovCone(userLocation, heading) heading !== null && heading !== undefined
: null; ? getFovCone(userLocation, heading)
: null;
return ( return (
<MapView <MapView
style={{flex: 1}} style={{ flex: 1 }}
provider="google" provider="google"
initialRegion={{...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}} initialRegion={{
scrollEnabled={true} ...userLocation,
zoomEnabled={true} latitudeDelta: 0.01,
rotateEnabled={false} longitudeDelta: 0.01,
pitchEnabled={false} }}
showsUserLocation scrollEnabled={true}
> zoomEnabled={true}
{(visibleCaches || []).map((cache) => ( rotateEnabled={false}
<Marker pitchEnabled={false}
key={cache.id} showsUserLocation
coordinate={cache.coordinates} >
pinColor="orange" {(visibleCaches || []).map((cache) => (
title={cache.clue} <Marker
/> key={cache.id}
))} coordinate={cache.coordinates}
{coneCoords && ( pinColor="orange"
<Polygon title={cache.clue}
coordinates={coneCoords} />
fillColor="rgba(66,133,244,0.28)" ))}
strokeColor="rgba(66,133,244,0.50)" {coneCoords && (
strokeWidth={1} <Polygon
/> coordinates={coneCoords}
)} fillColor="rgba(66,133,244,0.28)"
</MapView> strokeColor="rgba(66,133,244,0.50)"
); strokeWidth={1}
/>
)}
</MapView>
);
}; };
export default PlayerMapView; export default PlayerMapView;
+220 -193
View File
@@ -1,29 +1,38 @@
import React, {useEffect, useState} from 'react'; import { useEffect, useState } from "react";
import {ActivityIndicator, Alert, ScrollView, StyleSheet, Switch, Text, TextInput, View} from 'react-native'; import {
import Screen from '../layout/Screen'; ActivityIndicator,
import {Button, ButtonTray} from '../UI/Button'; Alert,
import useGameHook from '../../hooks/useGameHook'; ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
View,
} from "react-native";
import Screen from "../layout/Screen";
import { Button, ButtonTray } from "../UI/Button";
import useGameHook from "../../hooks/useGameHook";
const DepartmentSettingsScreen = ({route, navigation}) => { const DepartmentSettingsScreen = ({ route, navigation }) => {
const {SGid, Gid} = route.params; const { SGid, Gid } = route.params;
const {getSubgroup, updateSubgroup, getCaches, resetGame} = useGameHook(); const { getSubgroup, updateSubgroup, getCaches, resetGame } = useGameHook();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deptName, setDeptName] = useState(''); const [deptName, setDeptName] = useState("");
const [joinCode, setJoinCode] = useState(''); const [joinCode, setJoinCode] = useState("");
const [teamsEnabled, setTeamsEnabled] = useState(false); const [teamsEnabled, setTeamsEnabled] = useState(false);
const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20'); const [cacheTriggerMeters, setCacheTriggerMeters] = useState("20");
const [cacheCount, setCacheCount] = useState(0); const [cacheCount, setCacheCount] = useState(0);
// Handlers ------------------- // Handlers -------------------
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
const sg = await getSubgroup(SGid); const sg = await getSubgroup(SGid);
if (sg) { if (sg) {
setDeptName(sg.SubGroupName || ''); setDeptName(sg.SubGroupName || "");
setJoinCode(sg.JoinCode || ''); setJoinCode(sg.JoinCode || "");
setTeamsEnabled(Boolean(sg.TeamsEnabled)); setTeamsEnabled(Boolean(sg.TeamsEnabled));
setCacheTriggerMeters(String(sg.CacheTriggerMeters || 20)); setCacheTriggerMeters(String(sg.CacheTriggerMeters || 20));
} }
@@ -35,190 +44,208 @@ const DepartmentSettingsScreen = ({route, navigation}) => {
load(); load();
}, []); }, []);
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
await updateSubgroup(SGid, { await updateSubgroup(SGid, {
SubGroupName: deptName.trim(), SubGroupName: deptName.trim(),
TeamsEnabled: teamsEnabled, TeamsEnabled: teamsEnabled,
CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20, CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20,
}); });
setSaving(false); setSaving(false);
Alert.alert('Saved', 'Department settings updated.'); Alert.alert("Saved", "Department settings updated.");
}; };
const handleResetDepartment = () => { const handleResetDepartment = () => {
Alert.alert( Alert.alert(
'Reset Department', "Reset Department",
'This will reset all cache claims for this department. Are you sure?', "This will reset all cache claims for this department. Are you sure?",
[ [
{text: 'Cancel', style: 'cancel'}, { text: "Cancel", style: "cancel" },
{ {
text: 'Reset', text: "Reset",
style: 'destructive', style: "destructive",
onPress: async () => { onPress: async () => {
await resetGame(Gid, SGid); await resetGame(Gid, SGid);
Alert.alert('Done', 'Department progress has been reset.'); Alert.alert("Done", "Department progress has been reset.");
}, },
}, },
], ],
);
};
const handleManageCaches = () => {
navigation.navigate('MapScreen', {
selectedDepartmentSGid: SGid,
selectedDepartmentName: deptName || null,
});
};
// View -----------------------
if (loading) {
return (
<Screen showBack={true} style={styles.center}>
<ActivityIndicator size="large"/>
</Screen>
);
}
return (
<Screen showBack={true}>
<ScrollView contentContainerStyle={styles.formContainer} showsVerticalScrollIndicator={false}>
<Text style={styles.sectionTitle}>Department Settings</Text>
<Text style={styles.label}>Department Name</Text>
<TextInput
style={styles.input}
value={deptName}
onChangeText={setDeptName}
placeholder="Enter department name"
placeholderTextColor="#9ca3af"
/>
<View style={styles.codeSection}>
<Text style={styles.codeLabel}>Department Join Code</Text>
<Text style={styles.codeValue}>{joinCode || '—'}</Text>
</View>
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>Teams Enabled</Text>
<Switch
value={teamsEnabled}
onValueChange={setTeamsEnabled}
trackColor={{false: '#d1d5db', true: '#93c5fd'}}
thumbColor={teamsEnabled ? '#2563eb' : '#9ca3af'}
/>
</View>
<Text style={styles.label}>Cache Claim Distance (metres)</Text>
<TextInput
style={styles.input}
value={cacheTriggerMeters}
onChangeText={setCacheTriggerMeters}
keyboardType="numeric"
placeholder="20"
placeholderTextColor="#9ca3af"
/>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Total Caches</Text>
<Text style={styles.infoValue}>{cacheCount}</Text>
</View>
<View style={styles.saveWrap}>
<ButtonTray>
<Button
label={saving ? 'Saving...' : 'Save Settings'}
onClick={handleSave}
styleButton={styles.saveButton}
styleLabel={styles.saveLabel}
/>
</ButtonTray>
</View>
<View style={styles.mapWrap}>
<ButtonTray>
<Button
label="Manage Caches"
onClick={handleManageCaches}
styleButton={styles.mapButton}
styleLabel={styles.mapLabel}
/>
</ButtonTray>
</View>
<View style={styles.resetWrap}>
<Button
label="Reset Department"
onClick={handleResetDepartment}
styleButton={styles.resetButton}
styleLabel={styles.resetLabel}
/>
</View>
</ScrollView>
</Screen>
); );
};
const handleManageCaches = () => {
navigation.navigate("MapScreen", {
selectedDepartmentSGid: SGid,
selectedDepartmentName: deptName || null,
});
};
// View -----------------------
if (loading) {
return (
<Screen showBack={true} style={styles.center}>
<ActivityIndicator size="large" />
</Screen>
);
}
return (
<Screen showBack={true}>
<ScrollView
contentContainerStyle={styles.formContainer}
showsVerticalScrollIndicator={false}
>
<Text style={styles.sectionTitle}>Department Settings</Text>
<Text style={styles.label}>Department Name</Text>
<TextInput
style={styles.input}
value={deptName}
onChangeText={setDeptName}
placeholder="Enter department name"
placeholderTextColor="#9ca3af"
/>
<View style={styles.codeSection}>
<Text style={styles.codeLabel}>Department Join Code</Text>
<Text style={styles.codeValue}>{joinCode || "—"}</Text>
</View>
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>Teams Enabled</Text>
<Switch
value={teamsEnabled}
onValueChange={setTeamsEnabled}
trackColor={{ false: "#d1d5db", true: "#93c5fd" }}
thumbColor={teamsEnabled ? "#2563eb" : "#9ca3af"}
/>
</View>
<Text style={styles.label}>Cache Claim Distance (metres)</Text>
<TextInput
style={styles.input}
value={cacheTriggerMeters}
onChangeText={setCacheTriggerMeters}
keyboardType="numeric"
placeholder="20"
placeholderTextColor="#9ca3af"
/>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Total Caches</Text>
<Text style={styles.infoValue}>{cacheCount}</Text>
</View>
<View style={styles.saveWrap}>
<ButtonTray>
<Button
label={saving ? "Saving..." : "Save Settings"}
onClick={handleSave}
styleButton={styles.saveButton}
styleLabel={styles.saveLabel}
/>
</ButtonTray>
</View>
<View style={styles.mapWrap}>
<ButtonTray>
<Button
label="Manage Caches"
onClick={handleManageCaches}
styleButton={styles.mapButton}
styleLabel={styles.mapLabel}
/>
</ButtonTray>
</View>
<View style={styles.resetWrap}>
<Button
label="Reset Department"
onClick={handleResetDepartment}
styleButton={styles.resetButton}
styleLabel={styles.resetLabel}
/>
</View>
</ScrollView>
</Screen>
);
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
center: {justifyContent: 'center', alignItems: 'center'}, center: { justifyContent: "center", alignItems: "center" },
formContainer: {paddingBottom: 20}, formContainer: { paddingBottom: 20 },
sectionTitle: {fontSize: 20, fontWeight: '700', color: '#cdd6f4', marginBottom: 16}, sectionTitle: {
label: {fontSize: 14, fontWeight: '600', color: '#bac2de', marginBottom: 6, marginTop: 14}, fontSize: 20,
input: { fontWeight: "700",
borderWidth: 1, color: "#cdd6f4",
borderColor: '#45475a', marginBottom: 16,
borderRadius: 8, },
paddingHorizontal: 12, label: {
paddingVertical: 10, fontSize: 14,
fontSize: 16, fontWeight: "600",
color: '#cdd6f4', color: "#bac2de",
backgroundColor: '#313244', marginBottom: 6,
}, marginTop: 14,
codeSection: { },
flexDirection: 'row', input: {
justifyContent: 'space-between', borderWidth: 1,
alignItems: 'center', borderColor: "#45475a",
marginTop: 16, borderRadius: 8,
paddingVertical: 12, paddingHorizontal: 12,
paddingHorizontal: 12, paddingVertical: 10,
backgroundColor: '#313244', fontSize: 16,
borderRadius: 8, color: "#cdd6f4",
}, backgroundColor: "#313244",
codeLabel: {fontSize: 14, fontWeight: '600', color: '#bac2de'}, },
codeValue: {fontSize: 16, fontWeight: '700', color: '#bd93f9', letterSpacing: 2}, codeSection: {
toggleRow: { flexDirection: "row",
flexDirection: 'row', justifyContent: "space-between",
justifyContent: 'space-between', alignItems: "center",
alignItems: 'center', marginTop: 16,
marginTop: 16, paddingVertical: 12,
paddingVertical: 10, paddingHorizontal: 12,
borderBottomWidth: 1, backgroundColor: "#313244",
borderBottomColor: '#45475a', borderRadius: 8,
}, },
toggleLabel: {fontSize: 14, fontWeight: '600', color: '#bac2de'}, codeLabel: { fontSize: 14, fontWeight: "600", color: "#bac2de" },
infoRow: { codeValue: {
flexDirection: 'row', fontSize: 16,
justifyContent: 'space-between', fontWeight: "700",
alignItems: 'center', color: "#bd93f9",
marginTop: 16, letterSpacing: 2,
paddingVertical: 12, },
paddingHorizontal: 12, toggleRow: {
backgroundColor: '#313244', flexDirection: "row",
borderRadius: 8, justifyContent: "space-between",
}, alignItems: "center",
infoLabel: {fontSize: 14, fontWeight: '600', color: '#bac2de'}, marginTop: 16,
infoValue: {fontSize: 16, fontWeight: '700', color: '#cdd6f4'}, paddingVertical: 10,
saveWrap: {marginTop: 24}, borderBottomWidth: 1,
saveButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1'}, borderBottomColor: "#45475a",
saveLabel: {color: '#1e1e2e', fontWeight: '600'}, },
mapWrap: {marginTop: 16}, toggleLabel: { fontSize: 14, fontWeight: "600", color: "#bac2de" },
mapButton: {backgroundColor: '#bd93f9', borderColor: '#bd93f9'}, infoRow: {
mapLabel: {color: '#1e1e2e', fontWeight: '600'}, flexDirection: "row",
resetWrap: {marginTop: 30}, justifyContent: "space-between",
resetButton: {backgroundColor: '#86efac', borderColor: '#86efac'}, alignItems: "center",
resetLabel: {color: '#1e1e2e', fontWeight: '600'}, marginTop: 16,
paddingVertical: 12,
paddingHorizontal: 12,
backgroundColor: "#313244",
borderRadius: 8,
},
infoLabel: { fontSize: 14, fontWeight: "600", color: "#bac2de" },
infoValue: { fontSize: 16, fontWeight: "700", color: "#cdd6f4" },
saveWrap: { marginTop: 24 },
saveButton: { backgroundColor: "#a6e3a1", borderColor: "#a6e3a1" },
saveLabel: { color: "#1e1e2e", fontWeight: "600" },
mapWrap: { marginTop: 16 },
mapButton: { backgroundColor: "#bd93f9", borderColor: "#bd93f9" },
mapLabel: { color: "#1e1e2e", fontWeight: "600" },
resetWrap: { marginTop: 30 },
resetButton: { backgroundColor: "#86efac", borderColor: "#86efac" },
resetLabel: { color: "#1e1e2e", fontWeight: "600" },
}); });
export default DepartmentSettingsScreen; export default DepartmentSettingsScreen;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+27 -26
View File
@@ -1,11 +1,12 @@
import React, {useState} from 'react'; import { useState } from "react";
import {Pressable, StyleSheet, Text, TextInput, View} from 'react-native'; import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import useAuthHook from '../../hooks/useAuthHook'; import useAuthHook from "../../hooks/useAuthHook";
import Screen from '../layout/Screen'; import Screen from "../layout/Screen";
import {Button, ButtonTray} from '../UI/Button'; import { Button, ButtonTray } from "../UI/Button";
const LoginScreen = ({ navigation }) => { const LoginScreen = ({ navigation }) => {
const { email, password, error, isLoading, setEmail, setPassword, login } = useAuthHook(); const { email, password, error, isLoading, setEmail, setPassword, login } =
useAuthHook();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const handleLogin = async () => { const handleLogin = async () => {
@@ -17,10 +18,10 @@ const LoginScreen = ({ navigation }) => {
} }
const rootNav = navigation.getParent() || navigation; const rootNav = navigation.getParent() || navigation;
if (user.IsAcceptedAdmin) { if (user.IsAcceptedAdmin) {
rootNav.reset({ index: 0, routes: [{ name: 'Admin' }] }); rootNav.reset({ index: 0, routes: [{ name: "Admin" }] });
return; return;
} }
rootNav.reset({ index: 0, routes: [{ name: 'Game' }] }); rootNav.reset({ index: 0, routes: [{ name: "Game" }] });
}; };
return ( return (
@@ -52,14 +53,14 @@ const LoginScreen = ({ navigation }) => {
<ButtonTray> <ButtonTray>
<Button <Button
label={submitting ? 'Logging in...' : 'Login'} label={submitting ? "Logging in..." : "Login"}
onClick={handleLogin} onClick={handleLogin}
styleButton={styles.submitButton} styleButton={styles.submitButton}
styleLabel={styles.submitText} styleLabel={styles.submitText}
/> />
</ButtonTray> </ButtonTray>
<Pressable onPress={() => navigation.navigate('RegisterScreen')}> <Pressable onPress={() => navigation.navigate("RegisterScreen")}>
<Text style={styles.linkText}>Create new account</Text> <Text style={styles.linkText}>Create new account</Text>
</Pressable> </Pressable>
</View> </View>
@@ -69,45 +70,45 @@ const LoginScreen = ({ navigation }) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
justifyContent: 'center', justifyContent: "center",
}, },
content: { content: {
gap: 15, gap: 15,
}, },
title: { title: {
fontSize: 24, fontSize: 24,
fontWeight: 'bold', fontWeight: "bold",
textAlign: 'center', textAlign: "center",
marginBottom: 10, marginBottom: 10,
color: '#cdd6f4', color: "#cdd6f4",
}, },
input: { input: {
borderWidth: 1, borderWidth: 1,
borderColor: '#45475a', borderColor: "#45475a",
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 16, fontSize: 16,
color: '#cdd6f4', color: "#cdd6f4",
backgroundColor: '#313244', backgroundColor: "#313244",
}, },
errorText: { errorText: {
color: '#D92800', color: "#D92800",
textAlign: 'center', textAlign: "center",
marginTop: 5, marginTop: 5,
}, },
submitButton: { submitButton: {
backgroundColor: '#bd93f9', backgroundColor: "#bd93f9",
borderColor: '#bd93f9', borderColor: "#bd93f9",
}, },
submitText: { submitText: {
color: '#1e1e2e', color: "#1e1e2e",
fontWeight: '600', fontWeight: "600",
}, },
linkText: { linkText: {
textAlign: 'center', textAlign: "center",
color: '#bd93f9', color: "#bd93f9",
fontWeight: '600', fontWeight: "600",
paddingVertical: 10, paddingVertical: 10,
}, },
}); });
+506 -392
View File
@@ -1,411 +1,525 @@
import React, {useCallback, useEffect, useState} from 'react'; import { useCallback, useEffect, useState } from "react";
import {ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native'; import {
import Screen from '../layout/Screen'; ActivityIndicator,
import Card from '../UI/Card'; Alert,
import {Button} from '../UI/Button'; Pressable,
import PendingApprovalView from '../UI/PendingApprovalView'; ScrollView,
import useGameHook from '../../hooks/useGameHook'; StyleSheet,
import {getSession} from '../../hooks/SessionStore'; Text,
View,
} from "react-native";
import Screen from "../layout/Screen";
import Card from "../UI/Card";
import { Button } from "../UI/Button";
import PendingApprovalView from "../UI/PendingApprovalView";
import useGameHook from "../../hooks/useGameHook";
import { getSession } from "../../hooks/SessionStore";
const PlayersScreen = ({navigation, route}) => { const PlayersScreen = ({ navigation, route }) => {
// Initialisation ------------ // Initialisation ------------
const session = getSession(); const session = getSession();
const {getTeams, getTeamMembers, getGroupMembers, removeMember, resetPlayerProgress, getUser, getAdminWaitlist, approveAdmin, rejectAdmin, getSubgroups} = useGameHook(); const {
const isBusiness = Boolean(session.isBusiness); getTeams,
const isAdmin = Boolean(session.isAcceptedAdmin); getTeamMembers,
const selectedDepartment = route?.params?.department || null; getGroupMembers,
const effectiveSGid = isBusiness removeMember,
? ((isAdmin && selectedDepartment?.SGid) ? selectedDepartment.SGid : (isAdmin ? null : session.currentSGid)) resetPlayerProgress,
: null; getUser,
getAdminWaitlist,
approveAdmin,
rejectAdmin,
getSubgroups,
} = useGameHook();
const isBusiness = Boolean(session.isBusiness);
const isAdmin = Boolean(session.isAcceptedAdmin);
const selectedDepartment = route?.params?.department || null;
const effectiveSGid = isBusiness
? isAdmin && selectedDepartment?.SGid
? selectedDepartment.SGid
: isAdmin
? null
: session.currentSGid
: null;
// State ---------------------- // State ----------------------
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [players, setPlayers] = useState([]); const [players, setPlayers] = useState([]);
const [departmentList, setDepartmentList] = useState([]); const [departmentList, setDepartmentList] = useState([]);
const [adminTeam, setAdminTeam] = useState([]); const [adminTeam, setAdminTeam] = useState([]);
const [isCurrentUserAdminLead, setIsCurrentUserAdminLead] = useState(false); const [isCurrentUserAdminLead, setIsCurrentUserAdminLead] = useState(false);
const [adminTeamExpanded, setAdminTeamExpanded] = useState(false); const [adminTeamExpanded, setAdminTeamExpanded] = useState(false);
// Handlers ------------------- // Handlers -------------------
const loadData = useCallback(async () => {
if (!session.currentGid) { setLoading(false); return; }
if (isBusiness && isAdmin && !selectedDepartment) {
const sgs = await getSubgroups(session.currentGid);
setDepartmentList((sgs || []).filter((sg) => !sg.IsAdminGroup));
const allOrgMembers = await getGroupMembers(session.currentGid, null);
const seenAdminUids = new Set();
const acceptedAdmins = (allOrgMembers || [])
.filter((m) => m.IsAcceptedAdmin)
.sort((a, b) => Number(a.id) - Number(b.id))
.filter((m) => {
if (seenAdminUids.has(m.Uid)) return false;
seenAdminUids.add(m.Uid);
return true;
});
const adminRows = [];
for (const member of acceptedAdmins) {
const user = await getUser(member.Uid);
if (!user) continue;
adminRows.push({
Uid: member.Uid,
username: user.username || `User ${member.Uid}`,
membershipId: member.id,
});
}
setAdminTeam(adminRows);
setIsCurrentUserAdminLead(adminRows.length > 0 && adminRows[0].Uid === session.currentUid);
setPlayers([]);
setLoading(false);
return;
}
setAdminTeam([]);
setIsCurrentUserAdminLead(false);
// Load subgroup names for department display (business accounts)
let subgroupNameMap = {};
if (isBusiness) {
const sgs = await getSubgroups(session.currentGid);
for (const sg of (sgs || [])) {
if (!sg.IsAdminGroup) subgroupNameMap[sg.SGid] = sg.SubGroupName;
}
}
// Get ALL members (including admins)
const allMembers = await getGroupMembers(session.currentGid, effectiveSGid ?? null);
// Get admin waitlist entries
const waitlist = effectiveSGid ? [] : await getAdminWaitlist(session.currentGid);
// Deduplicate allMembers by Uid — a user may have two rows if they were a
// regular member before being approved as an admin. Prefer the admin row.
const seenUids = new Set();
const uniqueMembers = (allMembers || [])
.sort((a, b) => (b.IsAcceptedAdmin ? 1 : 0) - (a.IsAcceptedAdmin ? 1 : 0))
.filter((m) => { if (seenUids.has(m.Uid)) return false; seenUids.add(m.Uid); return true; });
// Build team lookup: { Uid -> { teamCode, teamName, isLeader } }
const allTeams = await getTeams(session.currentGid, effectiveSGid ?? null);
const teamInfoByUid = {};
for (const team of (allTeams || [])) {
const tms = await getTeamMembers(team.Tid);
for (const tm of (tms || [])) {
teamInfoByUid[tm.Uid] = {
teamCode: team.JoinCode,
teamName: team.TeamName,
isLeader: Boolean(tm.IsLeader),
};
}
}
// Build player list — include admins with tags
const playerList = [];
for (const m of uniqueMembers) {
// Skip the current admin viewing the list
if (m.Uid === session.currentUid) continue;
const user = await getUser(m.Uid);
if (user) {
// A group member may also be on the admin waitlist (applied after joining as a player).
// Check the waitlist first so they are treated as pending, not a regular player.
const waitlistEntry = (waitlist || []).find((w) => w.Uid === m.Uid);
let adminTag = null;
let waitlistId = null;
if (waitlistEntry) {
adminTag = '[Admin Awaiting Response]';
waitlistId = waitlistEntry.id;
} else if (m.IsAcceptedAdmin) {
adminTag = '[ADMIN]';
}
playerList.push({
...user,
membershipId: m.id,
team: teamInfoByUid[m.Uid] || null,
adminTag,
waitlistId,
departmentName: subgroupNameMap[m.SGid] || null,
});
}
}
// Include waitlisted users who are not yet group members at all
for (const w of (waitlist || [])) {
if (playerList.some((p) => p.Uid === w.Uid)) continue;
if (w.Uid === session.currentUid) continue;
const user = await getUser(w.Uid);
if (user) {
playerList.push({
...user,
membershipId: null,
team: null,
adminTag: '[Admin Awaiting Response]',
waitlistId: w.id,
departmentName: null,
});
}
}
setPlayers(playerList);
setLoading(false);
}, [session.currentGid, session.currentUid, session.currentSGid, isBusiness, isAdmin, selectedDepartment, selectedDepartment?.SGid, effectiveSGid]);
useEffect(() => { loadData(); }, [loadData]);
const handleRemovePlayer = async (membershipId) => {
await removeMember(membershipId);
await loadData();
};
const handleResetPlayerProgress = (player) => {
Alert.alert(
'Reset Progress',
`Reset all cache claims for ${player.username}?`,
[
{text: 'Cancel', style: 'cancel'},
{
text: 'Reset',
style: 'destructive',
onPress: async () => {
await resetPlayerProgress(session.currentGid, player.Uid, effectiveSGid);
Alert.alert('Done', `Progress for ${player.username} has been reset.`);
},
},
],
);
};
// Approve a waitlisted admin and refresh the list
const handleApproveAdmin = async (player) => {
await approveAdmin(player.waitlistId);
await loadData();
};
// Deny (remove from waitlist) a pending admin and refresh the list
const handleDenyAdmin = async (player) => {
await rejectAdmin(player.waitlistId);
await loadData();
};
const handleKickAdmin = (adminMember) => {
if (!isCurrentUserAdminLead || adminMember.Uid === session.currentUid) return;
Alert.alert(
'Kick Admin',
`Remove ${adminMember.username} from the admin team?`,
[
{text: 'Cancel', style: 'cancel'},
{
text: 'Kick',
style: 'destructive',
onPress: async () => {
await removeMember(adminMember.membershipId);
await loadData();
},
},
],
);
};
// View -----------------------
if (loading) {
return (
<Screen style={styles.center}>
<ActivityIndicator size="large"/>
</Screen>
);
}
const loadData = useCallback(async () => {
if (!session.currentGid) { if (!session.currentGid) {
return ( setLoading(false);
<Screen style={styles.center}> return;
<Text style={styles.body}>Create or join a game first.</Text>
</Screen>
);
}
if (session.isPendingAdmin && !session.isAcceptedAdmin) {
return <PendingApprovalView/>;
} }
if (isBusiness && isAdmin && !selectedDepartment) { if (isBusiness && isAdmin && !selectedDepartment) {
return ( const sgs = await getSubgroups(session.currentGid);
<Screen style={styles.container}> setDepartmentList((sgs || []).filter((sg) => !sg.IsAdminGroup));
<Text style={styles.sectionTitle}>Departments</Text>
<ScrollView style={styles.listSection}> const allOrgMembers = await getGroupMembers(session.currentGid, null);
<Card> const seenAdminUids = new Set();
<Pressable onPress={() => setAdminTeamExpanded((prev) => !prev)}> const acceptedAdmins = (allOrgMembers || [])
<View style={styles.playerRow}> .filter((m) => m.IsAcceptedAdmin)
<Text style={styles.playerName}>Admin Team</Text> .sort((a, b) => Number(a.id) - Number(b.id))
<Text style={styles.openLabel}>{adminTeamExpanded ? 'Close' : 'Open'}</Text> .filter((m) => {
</View> if (seenAdminUids.has(m.Uid)) return false;
</Pressable> seenAdminUids.add(m.Uid);
{adminTeamExpanded && ( return true;
<View style={styles.adminTeamWrap}> });
{adminTeam.map((adminMember, index) => (
<View key={adminMember.Uid} style={styles.adminTeamRow}> const adminRows = [];
<Text style={styles.adminTeamName} numberOfLines={1}> for (const member of acceptedAdmins) {
{adminMember.username}{index === 0 ? ' (Admin Lead)' : ''} const user = await getUser(member.Uid);
</Text> if (!user) continue;
{isCurrentUserAdminLead && adminMember.Uid !== session.currentUid && ( adminRows.push({
<Button Uid: member.Uid,
label="Kick Admin" username: user.username || `User ${member.Uid}`,
onClick={() => handleKickAdmin(adminMember)} membershipId: member.id,
styleButton={styles.removeButton} });
styleLabel={styles.kickLabel} }
/> setAdminTeam(adminRows);
)} setIsCurrentUserAdminLead(
</View> adminRows.length > 0 && adminRows[0].Uid === session.currentUid,
))} );
{adminTeam.length === 0 && ( setPlayers([]);
<Text style={styles.emptyText}>No approved admins yet.</Text> setLoading(false);
)} return;
</View>
)}
</Card>
{departmentList.map((department) => (
<Pressable
key={department.SGid}
onPress={() => navigation.navigate('PlayersScreen', {department})}
>
<Card>
<View style={styles.playerRow}>
<Text style={styles.playerName}>{department.SubGroupName}</Text>
<Text style={styles.openLabel}>Open</Text>
</View>
</Card>
</Pressable>
))}
{departmentList.length === 0 && (
<Text style={styles.emptyText}>No departments yet.</Text>
)}
</ScrollView>
</Screen>
);
} }
return ( setAdminTeam([]);
<Screen style={styles.container} showBack={Boolean(selectedDepartment)}> setIsCurrentUserAdminLead(false);
<Text style={styles.sectionTitle}>Players</Text>
<ScrollView style={styles.listSection}> // Load subgroup names for department display (business accounts)
{players.map((player) => ( let subgroupNameMap = {};
// Waitlisted admins get a light red tint so they stand out from regular players if (isBusiness) {
<Card key={player.Uid} style={player.waitlistId ? styles.waitlistCard : undefined}> const sgs = await getSubgroups(session.currentGid);
<View style={styles.playerRow}> for (const sg of sgs || []) {
<Text style={styles.playerName} numberOfLines={1}> if (!sg.IsAdminGroup) subgroupNameMap[sg.SGid] = sg.SubGroupName;
{player.username} }
{player.adminTag && ( }
<Text style={player.adminTag === '[ADMIN]' ? styles.adminBadge : styles.waitlistBadge}>
{' '}{player.adminTag} // Get ALL members (including admins)
</Text> const allMembers = await getGroupMembers(
)} session.currentGid,
{/* Show department name in brackets for non-admin players (org accounts) */} effectiveSGid ?? null,
{!player.adminTag && player.departmentName && (
<Text style={styles.departmentName}>
{' '}({player.departmentName})
</Text>
)}
</Text>
<View style={styles.playerMeta}>
{player.team?.isLeader && (
<Text style={styles.leaderBadge}>(Team Leader)</Text>
)}
{player.team && (
<Text style={styles.teamCodeBadge}>{player.team.teamCode}</Text>
)}
</View>
</View>
<View style={styles.actionRow}>
{/* Waitlisted admins get Approve / Deny instead of the normal player actions */}
{player.waitlistId ? (
<>
<Button
label="Approve"
onClick={() => handleApproveAdmin(player)}
styleButton={styles.approveButton}
styleLabel={styles.actionLabel}
/>
<Button
label="Deny"
onClick={() => handleDenyAdmin(player)}
styleButton={styles.denyButton}
styleLabel={styles.actionLabel}
/>
</>
) : (
<>
{!player.adminTag && (
<Button
label="Reset Progress"
onClick={() => handleResetPlayerProgress(player)}
styleButton={styles.resetButton}
styleLabel={styles.actionLabel}
/>
)}
{player.membershipId && (
<Button
label="Kick"
onClick={() => handleRemovePlayer(player.membershipId)}
styleButton={styles.removeButton}
styleLabel={styles.kickLabel}
/>
)}
</>
)}
</View>
</Card>
))}
{players.length === 0 && (
<Text style={styles.emptyText}>No players have joined yet.</Text>
)}
</ScrollView>
</Screen>
); );
// Get admin waitlist entries
const waitlist = effectiveSGid
? []
: await getAdminWaitlist(session.currentGid);
// Deduplicate allMembers by Uid — a user may have two rows if they were a
// regular member before being approved as an admin. Prefer the admin row.
const seenUids = new Set();
const uniqueMembers = (allMembers || [])
.sort((a, b) => (b.IsAcceptedAdmin ? 1 : 0) - (a.IsAcceptedAdmin ? 1 : 0))
.filter((m) => {
if (seenUids.has(m.Uid)) return false;
seenUids.add(m.Uid);
return true;
});
// Build team lookup: { Uid -> { teamCode, teamName, isLeader } }
const allTeams = await getTeams(session.currentGid, effectiveSGid ?? null);
const teamInfoByUid = {};
for (const team of allTeams || []) {
const tms = await getTeamMembers(team.Tid);
for (const tm of tms || []) {
teamInfoByUid[tm.Uid] = {
teamCode: team.JoinCode,
teamName: team.TeamName,
isLeader: Boolean(tm.IsLeader),
};
}
}
// Build player list — include admins with tags
const playerList = [];
for (const m of uniqueMembers) {
// Skip the current admin viewing the list
if (m.Uid === session.currentUid) continue;
const user = await getUser(m.Uid);
if (user) {
// A group member may also be on the admin waitlist (applied after joining as a player).
// Check the waitlist first so they are treated as pending, not a regular player.
const waitlistEntry = (waitlist || []).find((w) => w.Uid === m.Uid);
let adminTag = null;
let waitlistId = null;
if (waitlistEntry) {
adminTag = "[Admin Awaiting Response]";
waitlistId = waitlistEntry.id;
} else if (m.IsAcceptedAdmin) {
adminTag = "[ADMIN]";
}
playerList.push({
...user,
membershipId: m.id,
team: teamInfoByUid[m.Uid] || null,
adminTag,
waitlistId,
departmentName: subgroupNameMap[m.SGid] || null,
});
}
}
// Include waitlisted users who are not yet group members at all
for (const w of waitlist || []) {
if (playerList.some((p) => p.Uid === w.Uid)) continue;
if (w.Uid === session.currentUid) continue;
const user = await getUser(w.Uid);
if (user) {
playerList.push({
...user,
membershipId: null,
team: null,
adminTag: "[Admin Awaiting Response]",
waitlistId: w.id,
departmentName: null,
});
}
}
setPlayers(playerList);
setLoading(false);
}, [
session.currentGid,
session.currentUid,
session.currentSGid,
isBusiness,
isAdmin,
selectedDepartment,
selectedDepartment?.SGid,
effectiveSGid,
]);
useEffect(() => {
loadData();
}, [loadData]);
const handleRemovePlayer = async (membershipId) => {
await removeMember(membershipId);
await loadData();
};
const handleResetPlayerProgress = (player) => {
Alert.alert(
"Reset Progress",
`Reset all cache claims for ${player.username}?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Reset",
style: "destructive",
onPress: async () => {
await resetPlayerProgress(
session.currentGid,
player.Uid,
effectiveSGid,
);
Alert.alert(
"Done",
`Progress for ${player.username} has been reset.`,
);
},
},
],
);
};
// Approve a waitlisted admin and refresh the list
const handleApproveAdmin = async (player) => {
await approveAdmin(player.waitlistId);
await loadData();
};
// Deny (remove from waitlist) a pending admin and refresh the list
const handleDenyAdmin = async (player) => {
await rejectAdmin(player.waitlistId);
await loadData();
};
const handleKickAdmin = (adminMember) => {
if (!isCurrentUserAdminLead || adminMember.Uid === session.currentUid)
return;
Alert.alert(
"Kick Admin",
`Remove ${adminMember.username} from the admin team?`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Kick",
style: "destructive",
onPress: async () => {
await removeMember(adminMember.membershipId);
await loadData();
},
},
],
);
};
// View -----------------------
if (loading) {
return (
<Screen style={styles.center}>
<ActivityIndicator size="large" />
</Screen>
);
}
if (!session.currentGid) {
return (
<Screen style={styles.center}>
<Text style={styles.body}>Create or join a game first.</Text>
</Screen>
);
}
if (session.isPendingAdmin && !session.isAcceptedAdmin) {
return <PendingApprovalView />;
}
if (isBusiness && isAdmin && !selectedDepartment) {
return (
<Screen style={styles.container}>
<Text style={styles.sectionTitle}>Departments</Text>
<ScrollView style={styles.listSection}>
<Card>
<Pressable onPress={() => setAdminTeamExpanded((prev) => !prev)}>
<View style={styles.playerRow}>
<Text style={styles.playerName}>Admin Team</Text>
<Text style={styles.openLabel}>
{adminTeamExpanded ? "Close" : "Open"}
</Text>
</View>
</Pressable>
{adminTeamExpanded && (
<View style={styles.adminTeamWrap}>
{adminTeam.map((adminMember, index) => (
<View key={adminMember.Uid} style={styles.adminTeamRow}>
<Text style={styles.adminTeamName} numberOfLines={1}>
{adminMember.username}
{index === 0 ? " (Admin Lead)" : ""}
</Text>
{isCurrentUserAdminLead &&
adminMember.Uid !== session.currentUid && (
<Button
label="Kick Admin"
onClick={() => handleKickAdmin(adminMember)}
styleButton={styles.removeButton}
styleLabel={styles.kickLabel}
/>
)}
</View>
))}
{adminTeam.length === 0 && (
<Text style={styles.emptyText}>No approved admins yet.</Text>
)}
</View>
)}
</Card>
{departmentList.map((department) => (
<Pressable
key={department.SGid}
onPress={() =>
navigation.navigate("PlayersScreen", { department })
}
>
<Card>
<View style={styles.playerRow}>
<Text style={styles.playerName}>
{department.SubGroupName}
</Text>
<Text style={styles.openLabel}>Open</Text>
</View>
</Card>
</Pressable>
))}
{departmentList.length === 0 && (
<Text style={styles.emptyText}>No departments yet.</Text>
)}
</ScrollView>
</Screen>
);
}
return (
<Screen style={styles.container} showBack={Boolean(selectedDepartment)}>
<Text style={styles.sectionTitle}>Players</Text>
<ScrollView style={styles.listSection}>
{players.map((player) => (
// Waitlisted admins get a light red tint so they stand out from regular players
<Card
key={player.Uid}
style={player.waitlistId ? styles.waitlistCard : undefined}
>
<View style={styles.playerRow}>
<Text style={styles.playerName} numberOfLines={1}>
{player.username}
{player.adminTag && (
<Text
style={
player.adminTag === "[ADMIN]"
? styles.adminBadge
: styles.waitlistBadge
}
>
{" "}
{player.adminTag}
</Text>
)}
{/* Show department name in brackets for non-admin players (org accounts) */}
{!player.adminTag && player.departmentName && (
<Text style={styles.departmentName}>
{" "}
({player.departmentName})
</Text>
)}
</Text>
<View style={styles.playerMeta}>
{player.team?.isLeader && (
<Text style={styles.leaderBadge}>(Team Leader)</Text>
)}
{player.team && (
<Text style={styles.teamCodeBadge}>
{player.team.teamCode}
</Text>
)}
</View>
</View>
<View style={styles.actionRow}>
{/* Waitlisted admins get Approve / Deny instead of the normal player actions */}
{player.waitlistId ? (
<>
<Button
label="Approve"
onClick={() => handleApproveAdmin(player)}
styleButton={styles.approveButton}
styleLabel={styles.actionLabel}
/>
<Button
label="Deny"
onClick={() => handleDenyAdmin(player)}
styleButton={styles.denyButton}
styleLabel={styles.actionLabel}
/>
</>
) : (
<>
{!player.adminTag && (
<Button
label="Reset Progress"
onClick={() => handleResetPlayerProgress(player)}
styleButton={styles.resetButton}
styleLabel={styles.actionLabel}
/>
)}
{player.membershipId && (
<Button
label="Kick"
onClick={() => handleRemovePlayer(player.membershipId)}
styleButton={styles.removeButton}
styleLabel={styles.kickLabel}
/>
)}
</>
)}
</View>
</Card>
))}
{players.length === 0 && (
<Text style={styles.emptyText}>No players have joined yet.</Text>
)}
</ScrollView>
</Screen>
);
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
center: {justifyContent: 'center', alignItems: 'center', flex: 1}, center: { justifyContent: "center", alignItems: "center", flex: 1 },
container: {padding: 0}, container: { padding: 0 },
body: {color: '#bac2de', fontSize: 15}, body: { color: "#bac2de", fontSize: 15 },
sectionTitle: { sectionTitle: {
fontSize: 20, fontSize: 20,
fontWeight: '700', fontWeight: "700",
color: '#cdd6f4', color: "#cdd6f4",
marginBottom: 12, marginBottom: 12,
paddingHorizontal: 15, paddingHorizontal: 15,
paddingTop: 15, paddingTop: 15,
}, },
openLabel: {fontSize: 13, fontWeight: '700', color: '#bd93f9'}, openLabel: { fontSize: 13, fontWeight: "700", color: "#bd93f9" },
listSection: {flex: 1, paddingHorizontal: 15}, listSection: { flex: 1, paddingHorizontal: 15 },
emptyText: {color: '#6c7086', textAlign: 'center', marginTop: 30, fontSize: 14}, emptyText: {
playerRow: { color: "#6c7086",
flexDirection: 'row', textAlign: "center",
alignItems: 'center', marginTop: 30,
gap: 8, fontSize: 14,
marginBottom: 8, },
}, playerRow: {
playerName: {fontSize: 15, fontWeight: '600', color: '#cdd6f4', flex: 1}, flexDirection: "row",
playerMeta: {flexDirection: 'row', alignItems: 'center', gap: 6}, alignItems: "center",
leaderBadge: {fontSize: 11, color: '#bd93f9', fontWeight: '700'}, gap: 8,
teamCodeBadge: {fontSize: 12, fontWeight: '700', color: '#a6e3a1', letterSpacing: 1}, marginBottom: 8,
adminBadge: {fontSize: 12, fontWeight: '700', color: '#D92800'}, },
waitlistBadge: {fontSize: 11, fontWeight: '600', color: '#f4a460'}, playerName: { fontSize: 15, fontWeight: "600", color: "#cdd6f4", flex: 1 },
actionRow: {flexDirection: 'row', gap: 8}, playerMeta: { flexDirection: "row", alignItems: "center", gap: 6 },
resetButton: {backgroundColor: '#86efac', borderColor: '#86efac', minHeight: 36, flex: 1, paddingHorizontal: 10}, leaderBadge: { fontSize: 11, color: "#bd93f9", fontWeight: "700" },
removeButton: {backgroundColor: '#D92800', borderColor: '#D92800', minHeight: 36, flex: 1, paddingHorizontal: 10}, teamCodeBadge: {
actionLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13}, fontSize: 12,
kickLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13}, fontWeight: "700",
adminTeamWrap: {gap: 8}, color: "#a6e3a1",
adminTeamRow: {flexDirection: 'row', alignItems: 'center', gap: 8}, letterSpacing: 1,
adminTeamName: {flex: 1, fontSize: 14, color: '#cdd6f4', fontWeight: '600'}, },
departmentName: {fontSize: 14, color: '#6c7086', fontStyle: 'italic'}, adminBadge: { fontSize: 12, fontWeight: "700", color: "#D92800" },
waitlistCard: {backgroundColor: 'rgba(217, 40, 0, 0.15)', borderColor: '#D92800'}, waitlistBadge: { fontSize: 11, fontWeight: "600", color: "#f4a460" },
approveButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1', minHeight: 36, flex: 1, paddingHorizontal: 10}, actionRow: { flexDirection: "row", gap: 8 },
denyButton: {backgroundColor: '#D92800', borderColor: '#D92800', minHeight: 36, flex: 1, paddingHorizontal: 10}, resetButton: {
backgroundColor: "#86efac",
borderColor: "#86efac",
minHeight: 36,
flex: 1,
paddingHorizontal: 10,
},
removeButton: {
backgroundColor: "#D92800",
borderColor: "#D92800",
minHeight: 36,
flex: 1,
paddingHorizontal: 10,
},
actionLabel: { color: "#1e1e2e", fontWeight: "600", fontSize: 13 },
kickLabel: { color: "#ffffff", fontWeight: "600", fontSize: 13 },
adminTeamWrap: { gap: 8 },
adminTeamRow: { flexDirection: "row", alignItems: "center", gap: 8 },
adminTeamName: { flex: 1, fontSize: 14, color: "#cdd6f4", fontWeight: "600" },
departmentName: { fontSize: 14, color: "#6c7086", fontStyle: "italic" },
waitlistCard: {
backgroundColor: "rgba(217, 40, 0, 0.15)",
borderColor: "#D92800",
},
approveButton: {
backgroundColor: "#a6e3a1",
borderColor: "#a6e3a1",
minHeight: 36,
flex: 1,
paddingHorizontal: 10,
},
denyButton: {
backgroundColor: "#D92800",
borderColor: "#D92800",
minHeight: 36,
flex: 1,
paddingHorizontal: 10,
},
}); });
export default PlayersScreen; export default PlayersScreen;
+68 -43
View File
@@ -1,14 +1,23 @@
import React, {useState} from 'react'; import { useState } from "react";
import {Pressable, StyleSheet, Text, TextInput, View} from 'react-native'; import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import useAuthHook from '../../hooks/useAuthHook'; import useAuthHook from "../../hooks/useAuthHook";
import Screen from '../layout/Screen'; import Screen from "../layout/Screen";
import {Button, ButtonTray} from '../UI/Button'; import { Button, ButtonTray } from "../UI/Button";
const RegisterScreen = ({navigation}) => { const RegisterScreen = ({ navigation }) => {
const { const {
username, email, password, confirmPassword, accountType, username,
error, isLoading, email,
setUsername, setEmail, setPassword, setConfirmPassword, setAccountType, password,
confirmPassword,
accountType,
error,
isLoading,
setUsername,
setEmail,
setPassword,
setConfirmPassword,
setAccountType,
register, register,
} = useAuthHook(); } = useAuthHook();
@@ -21,10 +30,10 @@ const RegisterScreen = ({navigation}) => {
if (!user) return; if (!user) return;
const rootNav = navigation.getParent() || navigation; const rootNav = navigation.getParent() || navigation;
if (user.IsAcceptedAdmin) { if (user.IsAcceptedAdmin) {
rootNav.reset({index: 0, routes: [{name: 'Admin'}]}); rootNav.reset({ index: 0, routes: [{ name: "Admin" }] });
return; return;
} }
rootNav.reset({index: 0, routes: [{name: 'Game'}]}); rootNav.reset({ index: 0, routes: [{ name: "Game" }] });
}; };
return ( return (
@@ -71,20 +80,36 @@ const RegisterScreen = ({navigation}) => {
<View style={styles.typeRow}> <View style={styles.typeRow}>
<Pressable <Pressable
style={[styles.typeButton, accountType === 'Individual' && styles.typeButtonActive]} style={[
onPress={() => setAccountType('Individual')} styles.typeButton,
accountType === "Individual" && styles.typeButtonActive,
]}
onPress={() => setAccountType("Individual")}
disabled={isLoading} disabled={isLoading}
> >
<Text style={[styles.typeText, accountType === 'Individual' && styles.typeTextActive]}> <Text
style={[
styles.typeText,
accountType === "Individual" && styles.typeTextActive,
]}
>
Individual Individual
</Text> </Text>
</Pressable> </Pressable>
<Pressable <Pressable
style={[styles.typeButton, accountType === 'Business/School' && styles.typeButtonActive]} style={[
onPress={() => setAccountType('Business/School')} styles.typeButton,
accountType === "Business/School" && styles.typeButtonActive,
]}
onPress={() => setAccountType("Business/School")}
disabled={isLoading} disabled={isLoading}
> >
<Text style={[styles.typeText, accountType === 'Business/School' && styles.typeTextActive]}> <Text
style={[
styles.typeText,
accountType === "Business/School" && styles.typeTextActive,
]}
>
Business Business
</Text> </Text>
</Pressable> </Pressable>
@@ -94,14 +119,14 @@ const RegisterScreen = ({navigation}) => {
<ButtonTray> <ButtonTray>
<Button <Button
label={submitting ? 'Creating...' : 'Create Account'} label={submitting ? "Creating..." : "Create Account"}
onClick={handleRegister} onClick={handleRegister}
styleButton={styles.submitButton} styleButton={styles.submitButton}
styleLabel={styles.submitText} styleLabel={styles.submitText}
/> />
</ButtonTray> </ButtonTray>
<Pressable onPress={() => navigation.navigate('LoginScreen')}> <Pressable onPress={() => navigation.navigate("LoginScreen")}>
<Text style={styles.linkText}>Back to Login</Text> <Text style={styles.linkText}>Back to Login</Text>
</Pressable> </Pressable>
</View> </View>
@@ -111,68 +136,68 @@ const RegisterScreen = ({navigation}) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
justifyContent: 'center', justifyContent: "center",
}, },
content: { content: {
gap: 12, gap: 12,
}, },
title: { title: {
fontSize: 24, fontSize: 24,
fontWeight: 'bold', fontWeight: "bold",
textAlign: 'center', textAlign: "center",
marginBottom: 10, marginBottom: 10,
color: '#cdd6f4', color: "#cdd6f4",
}, },
input: { input: {
borderWidth: 1, borderWidth: 1,
borderColor: '#45475a', borderColor: "#45475a",
borderRadius: 8, borderRadius: 8,
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 10, paddingVertical: 10,
fontSize: 16, fontSize: 16,
color: '#cdd6f4', color: "#cdd6f4",
backgroundColor: '#313244', backgroundColor: "#313244",
}, },
typeRow: { typeRow: {
flexDirection: 'row', flexDirection: "row",
gap: 10, gap: 10,
}, },
typeButton: { typeButton: {
flex: 1, flex: 1,
borderWidth: 1, borderWidth: 1,
borderColor: '#45475a', borderColor: "#45475a",
borderRadius: 8, borderRadius: 8,
alignItems: 'center', alignItems: "center",
paddingVertical: 10, paddingVertical: 10,
}, },
typeButtonActive: { typeButtonActive: {
backgroundColor: '#bd93f9', backgroundColor: "#bd93f9",
borderColor: '#bd93f9', borderColor: "#bd93f9",
}, },
typeText: { typeText: {
color: '#bac2de', color: "#bac2de",
fontWeight: '600', fontWeight: "600",
}, },
typeTextActive: { typeTextActive: {
color: '#1e1e2e', color: "#1e1e2e",
}, },
errorText: { errorText: {
color: '#D92800', color: "#D92800",
textAlign: 'center', textAlign: "center",
marginTop: 5, marginTop: 5,
}, },
submitButton: { submitButton: {
backgroundColor: '#bd93f9', backgroundColor: "#bd93f9",
borderColor: '#bd93f9', borderColor: "#bd93f9",
}, },
submitText: { submitText: {
color: '#1e1e2e', color: "#1e1e2e",
fontWeight: '600', fontWeight: "600",
}, },
linkText: { linkText: {
textAlign: 'center', textAlign: "center",
color: '#bd93f9', color: "#bd93f9",
fontWeight: '600', fontWeight: "600",
paddingVertical: 10, paddingVertical: 10,
}, },
}); });
+505 -380
View File
@@ -1,20 +1,44 @@
import React, {useEffect, useState} from 'react'; import { useEffect, useState } from "react";
import {ActivityIndicator, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native'; import {
import Screen from '../layout/Screen'; ActivityIndicator,
import Card from '../UI/Card'; ScrollView,
import {Button, ButtonTray} from '../UI/Button'; StyleSheet,
import PendingApprovalView from '../UI/PendingApprovalView'; Text,
import useGameHook from '../../hooks/useGameHook'; TextInput,
import {getSession, setPendingAdmin, setSessionTeam, setSessionUser} from '../../hooks/SessionStore'; View,
} from "react-native";
import Screen from "../layout/Screen";
import Card from "../UI/Card";
import { Button, ButtonTray } from "../UI/Button";
import PendingApprovalView from "../UI/PendingApprovalView";
import useGameHook from "../../hooks/useGameHook";
import {
getSession,
setPendingAdmin,
setSessionTeam,
setSessionUser,
} from "../../hooks/SessionStore";
const TeamScreen = ({navigation}) => { const TeamScreen = ({ navigation }) => {
const session = getSession(); const session = getSession();
const isAdmin = session.isAcceptedAdmin; const isAdmin = session.isAcceptedAdmin;
const isBusiness = Boolean(session.isBusiness); const isBusiness = Boolean(session.isBusiness);
const {getLobby, getSubgroup, getTeam, createTeam, joinTeamByCode, getTeamMembers, leaveTeam, getUser, updateUser, updateTeam, getAdminWaitlist} = useGameHook(); const {
getLobby,
getSubgroup,
getTeam,
createTeam,
joinTeamByCode,
getTeamMembers,
leaveTeam,
getUser,
updateUser,
updateTeam,
getAdminWaitlist,
} = useGameHook();
const [activeTid, setActiveTid] = useState(session.currentTid); const [activeTid, setActiveTid] = useState(session.currentTid);
const [teamCode, setTeamCode] = useState(''); const [teamCode, setTeamCode] = useState("");
const [team, setTeam] = useState(null); const [team, setTeam] = useState(null);
const [members, setMembers] = useState([]); const [members, setMembers] = useState([]);
const [memberNames, setMemberNames] = useState({}); const [memberNames, setMemberNames] = useState({});
@@ -22,7 +46,7 @@ const TeamScreen = ({navigation}) => {
const [teamsDisabled, setTeamsDisabled] = useState(false); const [teamsDisabled, setTeamsDisabled] = useState(false);
const [onAdminWaitlist, setOnAdminWaitlist] = useState(false); const [onAdminWaitlist, setOnAdminWaitlist] = useState(false);
const [editingName, setEditingName] = useState(false); const [editingName, setEditingName] = useState(false);
const [teamNameDraft, setTeamNameDraft] = useState(''); const [teamNameDraft, setTeamNameDraft] = useState("");
const loadTeamData = async (tid) => { const loadTeamData = async (tid) => {
const t = await getTeam(tid); const t = await getTeam(tid);
@@ -30,402 +54,503 @@ const TeamScreen = ({navigation}) => {
const ms = await getTeamMembers(tid); const ms = await getTeamMembers(tid);
setMembers(ms || []); setMembers(ms || []);
const names = {}; const names = {};
for (const m of (ms || [])) { for (const m of ms || []) {
const u = await getUser(m.Uid); const u = await getUser(m.Uid);
if (u) names[m.Uid] = u.username; if (u) names[m.Uid] = u.username;
} }
setMemberNames(names); setMemberNames(names);
}; };
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
if (!session.currentGid) { setLoading(false); return; } if (!session.currentGid) {
setLoading(false);
return;
}
const group = await getLobby(session.currentGid); const group = await getLobby(session.currentGid);
let teamsAreEnabled = Boolean(group?.TeamsEnabled); let teamsAreEnabled = Boolean(group?.TeamsEnabled);
if (isBusiness && session.currentSGid) { if (isBusiness && session.currentSGid) {
const subgroup = await getSubgroup(session.currentSGid); const subgroup = await getSubgroup(session.currentSGid);
if (subgroup) teamsAreEnabled = Boolean(subgroup.TeamsEnabled); if (subgroup) teamsAreEnabled = Boolean(subgroup.TeamsEnabled);
} }
if (session.isPendingAdmin && !session.isAcceptedAdmin) { if (session.isPendingAdmin && !session.isAcceptedAdmin) {
const freshUser = await getUser(session.currentUid); const freshUser = await getUser(session.currentUid);
if (freshUser?.IsAcceptedAdmin) { if (freshUser?.IsAcceptedAdmin) {
setPendingAdmin(false); setPendingAdmin(false);
setSessionUser(freshUser); setSessionUser(freshUser);
setSessionTeam(freshUser.TGid ?? null); setSessionTeam(freshUser.TGid ?? null);
navigation.reset({index: 0, routes: [{name: 'Admin'}]}); navigation.reset({ index: 0, routes: [{ name: "Admin" }] });
return; return;
}
const waitlist = await getAdminWaitlist(session.currentGid, session.currentUid);
if (waitlist.length > 0) {
setOnAdminWaitlist(true);
setLoading(false);
return;
}
setPendingAdmin(false);
}
if (!teamsAreEnabled) {
setTeamsDisabled(true);
setLoading(false);
return;
}
setTeamsDisabled(false);
if (!activeTid) {
const waitlist = await getAdminWaitlist(session.currentGid, session.currentUid);
if (waitlist.length > 0) {
setOnAdminWaitlist(true);
setLoading(false);
return;
}
}
if (activeTid) await loadTeamData(activeTid);
setLoading(false);
};
load();
}, [activeTid]);
useEffect(() => {
if (!onAdminWaitlist && !session.isPendingAdmin) return;
let stopped = false;
const refreshWaitlistStatus = async () => {
const currentSession = getSession();
if (!currentSession.currentUid || !currentSession.currentGid) return;
const freshUser = await getUser(currentSession.currentUid);
if (stopped) return;
if (freshUser?.IsAcceptedAdmin) {
setPendingAdmin(false);
setSessionUser(freshUser);
setSessionTeam(freshUser.TGid ?? null);
navigation.reset({index: 0, routes: [{name: 'Admin'}]});
return;
}
const waitlist = await getAdminWaitlist(currentSession.currentGid, currentSession.currentUid);
if (stopped) return;
const isStillWaiting = waitlist.length > 0;
setOnAdminWaitlist(isStillWaiting);
if (!isStillWaiting) {
setPendingAdmin(false);
}
};
refreshWaitlistStatus();
const intervalId = setInterval(refreshWaitlistStatus, 5000);
return () => {
stopped = true;
clearInterval(intervalId);
};
}, [onAdminWaitlist, session.isPendingAdmin]);
const handleJoinTeam = async () => {
if (!teamCode.trim()) return;
// Force uppercase just in case a lowercase code is pasted
const code = teamCode.trim().toUpperCase();
const result = await joinTeamByCode({
JoinCode: code,
Uid: session.currentUid,
ExpectedSGid: session.currentSGid,
});
if (!result) return;
// If the server indicates this code matched the admin join code
if (result.adminWaitlist) {
setPendingAdmin(true);
setOnAdminWaitlist(true);
return;
} }
const waitlist = await getAdminWaitlist(
setSessionTeam(result.Tid); session.currentGid,
setActiveTid(result.Tid); session.currentUid,
}; );
if (waitlist.length > 0) {
const handleCreateTeam = async () => { setOnAdminWaitlist(true);
if (!session.currentGid) return; setLoading(false);
const result = await createTeam({Gid: session.currentGid, SGid: session.currentSGid, TeamName: 'My Team'}); return;
if (!result) return;
const membership = await joinTeamByCode({Tid: result.Tid, Uid: session.currentUid});
if (membership) {
setSessionTeam(result.Tid);
setActiveTid(result.Tid);
} }
}; setPendingAdmin(false);
}
const handleLeaveTeam = async () => { if (!teamsAreEnabled) {
const myMembership = members.find((m) => m.Uid === session.currentUid); setTeamsDisabled(true);
if (!myMembership) return; setLoading(false);
await leaveTeam(myMembership.id); return;
await updateUser(session.currentUid, {TGid: null}); }
setSessionTeam(null); setTeamsDisabled(false);
setActiveTid(null);
setTeam(null);
setMembers([]);
setMemberNames({});
};
const handleKickMember = async (member) => { if (!activeTid) {
await leaveTeam(member.id); const waitlist = await getAdminWaitlist(
await loadTeamData(activeTid); session.currentGid,
}; session.currentUid,
);
const handleRenameTeam = async () => { if (waitlist.length > 0) {
const trimmed = teamNameDraft.trim(); setOnAdminWaitlist(true);
if (!trimmed || !activeTid) return; setLoading(false);
const result = await updateTeam(activeTid, {TeamName: trimmed}); return;
if (result) {
setTeam(result);
setEditingName(false);
} }
}
if (activeTid) await loadTeamData(activeTid);
setLoading(false);
};
load();
}, [activeTid]);
useEffect(() => {
if (!onAdminWaitlist && !session.isPendingAdmin) return;
let stopped = false;
const refreshWaitlistStatus = async () => {
const currentSession = getSession();
if (!currentSession.currentUid || !currentSession.currentGid) return;
const freshUser = await getUser(currentSession.currentUid);
if (stopped) return;
if (freshUser?.IsAcceptedAdmin) {
setPendingAdmin(false);
setSessionUser(freshUser);
setSessionTeam(freshUser.TGid ?? null);
navigation.reset({ index: 0, routes: [{ name: "Admin" }] });
return;
}
const waitlist = await getAdminWaitlist(
currentSession.currentGid,
currentSession.currentUid,
);
if (stopped) return;
const isStillWaiting = waitlist.length > 0;
setOnAdminWaitlist(isStillWaiting);
if (!isStillWaiting) {
setPendingAdmin(false);
}
}; };
// Check if current user is the team leader refreshWaitlistStatus();
const intervalId = setInterval(refreshWaitlistStatus, 5000);
return () => {
stopped = true;
clearInterval(intervalId);
};
}, [onAdminWaitlist, session.isPendingAdmin]);
const handleJoinTeam = async () => {
if (!teamCode.trim()) return;
// Force uppercase just in case a lowercase code is pasted
const code = teamCode.trim().toUpperCase();
const result = await joinTeamByCode({
JoinCode: code,
Uid: session.currentUid,
ExpectedSGid: session.currentSGid,
});
if (!result) return;
// If the server indicates this code matched the admin join code
if (result.adminWaitlist) {
setPendingAdmin(true);
setOnAdminWaitlist(true);
return;
}
setSessionTeam(result.Tid);
setActiveTid(result.Tid);
};
const handleCreateTeam = async () => {
if (!session.currentGid) return;
const result = await createTeam({
Gid: session.currentGid,
SGid: session.currentSGid,
TeamName: "My Team",
});
if (!result) return;
const membership = await joinTeamByCode({
Tid: result.Tid,
Uid: session.currentUid,
});
if (membership) {
setSessionTeam(result.Tid);
setActiveTid(result.Tid);
}
};
const handleLeaveTeam = async () => {
const myMembership = members.find((m) => m.Uid === session.currentUid); const myMembership = members.find((m) => m.Uid === session.currentUid);
const isCurrentUserLeader = Boolean(myMembership?.IsLeader); if (!myMembership) return;
await leaveTeam(myMembership.id);
await updateUser(session.currentUid, { TGid: null });
setSessionTeam(null);
setActiveTid(null);
setTeam(null);
setMembers([]);
setMemberNames({});
};
// View ----------------------- const handleKickMember = async (member) => {
await leaveTeam(member.id);
await loadTeamData(activeTid);
};
if (loading) { const handleRenameTeam = async () => {
return ( const trimmed = teamNameDraft.trim();
<Screen style={styles.center}> if (!trimmed || !activeTid) return;
<ActivityIndicator size="large"/> const result = await updateTeam(activeTid, { TeamName: trimmed });
</Screen> if (result) {
); setTeam(result);
setEditingName(false);
} }
};
if (teamsDisabled) { // Check if current user is the team leader
return ( const myMembership = members.find((m) => m.Uid === session.currentUid);
<Screen style={styles.center}> const isCurrentUserLeader = Boolean(myMembership?.IsLeader);
<View style={styles.disabledWrap}>
<Text style={styles.disabledTitle}>Teams Disabled</Text>
<Text style={styles.disabledBody}>The admin has not enabled teams for this game.</Text>
</View>
</Screen>
);
}
// Waiting for admin approval // View -----------------------
if (onAdminWaitlist) {
return <PendingApprovalView message="Waiting to be approved for Admin Team"/>;
}
// Not in a team if (loading) {
if (!activeTid || !team) {
// Disable team actions until the user has joined a game
const notInGame = !session.currentGid;
return (
<Screen style={styles.center}>
{notInGame && (
<Text style={styles.notInGameText}>Join a game first to manage teams.</Text>
)}
<View style={styles.inputRow}>
<TextInput
style={[styles.codeInput, notInGame && styles.inputDisabled]}
placeholder="Enter Team Code"
placeholderTextColor="#9ca3af"
value={teamCode}
onChangeText={(text) => setTeamCode(text.toUpperCase())}
autoCapitalize="characters"
editable={!notInGame}
/>
<Button
label="Join"
onClick={handleJoinTeam}
styleButton={styles.joinButton}
styleLabel={styles.joinLabel}
disabled={notInGame}
/>
</View>
{!isAdmin && (
<View style={styles.fullRow}>
<ButtonTray>
<Button
label="Create Team"
onClick={handleCreateTeam}
styleButton={styles.createButton}
styleLabel={styles.createLabel}
disabled={notInGame}
/>
</ButtonTray>
</View>
)}
</Screen>
);
}
// In a team
return ( return (
<Screen style={styles.container}> <Screen style={styles.center}>
<View style={styles.teamHeader}> <ActivityIndicator size="large" />
<Text style={styles.teamCodeLabel}>Team Code:</Text> </Screen>
<Text style={styles.teamCodeValue}>{team.JoinCode}</Text>
</View>
{/* Team name display / edit */}
<View style={styles.teamNameSection}>
{editingName ? (
<View style={styles.renameRow}>
<TextInput
style={styles.renameInput}
value={teamNameDraft}
onChangeText={setTeamNameDraft}
placeholder="Team name"
placeholderTextColor="#9ca3af"
autoFocus
/>
<Button
label="Save"
onClick={handleRenameTeam}
styleButton={styles.renameSaveButton}
styleLabel={styles.renameSaveLabel}
/>
<Button
label="Cancel"
onClick={() => setEditingName(false)}
styleButton={styles.renameCancelButton}
styleLabel={styles.renameCancelLabel}
/>
</View>
) : (
<View style={styles.renameRow}>
<Text style={styles.teamNameText}>{team.TeamName || 'Unnamed Team'}</Text>
{isCurrentUserLeader && (
<Button
label="Rename"
onClick={() => { setTeamNameDraft(team.TeamName || ''); setEditingName(true); }}
styleButton={styles.renameButton}
styleLabel={styles.renameLabel}
/>
)}
</View>
)}
</View>
<ScrollView style={styles.memberList}>
{members.map((m) => (
// Highlight the current user's card so they can identify themselves
<Card key={m.id} style={m.Uid === session.currentUid ? styles.selfCard : undefined}>
<View style={styles.memberRow}>
<View style={styles.memberInfo}>
<Text style={styles.memberName}>
{memberNames[m.Uid] || `Player ${m.Uid}`}
</Text>
{m.IsLeader && <Text style={styles.leaderBadge}>(Team Leader)</Text>}
</View>
{isCurrentUserLeader && m.Uid !== session.currentUid && (
<Button
label="Kick"
onClick={() => handleKickMember(m)}
styleButton={styles.kickButton}
styleLabel={styles.kickLabel}
/>
)}
</View>
</Card>
))}
{members.length === 0 && (
<Text style={styles.emptyText}>No members yet.</Text>
)}
{/* Leave button lives inside the scroll so it is never obscured by the bottom nav bar */}
<View style={styles.leaveWrap}>
<Button
label="Leave Team"
onClick={handleLeaveTeam}
styleButton={styles.leaveButton}
styleLabel={styles.leaveLabel}
/>
</View>
</ScrollView>
</Screen>
); );
}
if (teamsDisabled) {
return (
<Screen style={styles.center}>
<View style={styles.disabledWrap}>
<Text style={styles.disabledTitle}>Teams Disabled</Text>
<Text style={styles.disabledBody}>
The admin has not enabled teams for this game.
</Text>
</View>
</Screen>
);
}
// Waiting for admin approval
if (onAdminWaitlist) {
return (
<PendingApprovalView message="Waiting to be approved for Admin Team" />
);
}
// Not in a team
if (!activeTid || !team) {
// Disable team actions until the user has joined a game
const notInGame = !session.currentGid;
return (
<Screen style={styles.center}>
{notInGame && (
<Text style={styles.notInGameText}>
Join a game first to manage teams.
</Text>
)}
<View style={styles.inputRow}>
<TextInput
style={[styles.codeInput, notInGame && styles.inputDisabled]}
placeholder="Enter Team Code"
placeholderTextColor="#9ca3af"
value={teamCode}
onChangeText={(text) => setTeamCode(text.toUpperCase())}
autoCapitalize="characters"
editable={!notInGame}
/>
<Button
label="Join"
onClick={handleJoinTeam}
styleButton={styles.joinButton}
styleLabel={styles.joinLabel}
disabled={notInGame}
/>
</View>
{!isAdmin && (
<View style={styles.fullRow}>
<ButtonTray>
<Button
label="Create Team"
onClick={handleCreateTeam}
styleButton={styles.createButton}
styleLabel={styles.createLabel}
disabled={notInGame}
/>
</ButtonTray>
</View>
)}
</Screen>
);
}
// In a team
return (
<Screen style={styles.container}>
<View style={styles.teamHeader}>
<Text style={styles.teamCodeLabel}>Team Code:</Text>
<Text style={styles.teamCodeValue}>{team.JoinCode}</Text>
</View>
{/* Team name display / edit */}
<View style={styles.teamNameSection}>
{editingName ? (
<View style={styles.renameRow}>
<TextInput
style={styles.renameInput}
value={teamNameDraft}
onChangeText={setTeamNameDraft}
placeholder="Team name"
placeholderTextColor="#9ca3af"
autoFocus
/>
<Button
label="Save"
onClick={handleRenameTeam}
styleButton={styles.renameSaveButton}
styleLabel={styles.renameSaveLabel}
/>
<Button
label="Cancel"
onClick={() => setEditingName(false)}
styleButton={styles.renameCancelButton}
styleLabel={styles.renameCancelLabel}
/>
</View>
) : (
<View style={styles.renameRow}>
<Text style={styles.teamNameText}>
{team.TeamName || "Unnamed Team"}
</Text>
{isCurrentUserLeader && (
<Button
label="Rename"
onClick={() => {
setTeamNameDraft(team.TeamName || "");
setEditingName(true);
}}
styleButton={styles.renameButton}
styleLabel={styles.renameLabel}
/>
)}
</View>
)}
</View>
<ScrollView style={styles.memberList}>
{members.map((m) => (
// Highlight the current user's card so they can identify themselves
<Card
key={m.id}
style={m.Uid === session.currentUid ? styles.selfCard : undefined}
>
<View style={styles.memberRow}>
<View style={styles.memberInfo}>
<Text style={styles.memberName}>
{memberNames[m.Uid] || `Player ${m.Uid}`}
</Text>
{m.IsLeader && (
<Text style={styles.leaderBadge}>(Team Leader)</Text>
)}
</View>
{isCurrentUserLeader && m.Uid !== session.currentUid && (
<Button
label="Kick"
onClick={() => handleKickMember(m)}
styleButton={styles.kickButton}
styleLabel={styles.kickLabel}
/>
)}
</View>
</Card>
))}
{members.length === 0 && (
<Text style={styles.emptyText}>No members yet.</Text>
)}
{/* Leave button lives inside the scroll so it is never obscured by the bottom nav bar */}
<View style={styles.leaveWrap}>
<Button
label="Leave Team"
onClick={handleLeaveTeam}
styleButton={styles.leaveButton}
styleLabel={styles.leaveLabel}
/>
</View>
</ScrollView>
</Screen>
);
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
center: {justifyContent: 'center', alignItems: 'center'}, center: { justifyContent: "center", alignItems: "center" },
container: {padding: 0}, container: { padding: 0 },
disabledWrap: {paddingHorizontal: 30, alignItems: 'center', opacity: 0.45}, disabledWrap: { paddingHorizontal: 30, alignItems: "center", opacity: 0.45 },
disabledTitle: {fontSize: 20, fontWeight: '700', color: '#ffffff', marginBottom: 10, textAlign: 'center'}, disabledTitle: {
disabledBody: {fontSize: 15, color: '#ffffff', textAlign: 'center'}, fontSize: 20,
notInGameText: {color: '#ffffff', fontSize: 15, fontWeight: '600', marginBottom: 16, textAlign: 'center', paddingHorizontal: 20}, fontWeight: "700",
inputDisabled: {backgroundColor: '#313244', opacity: 0.5}, color: "#ffffff",
inputRow: {flexDirection: 'row', gap: 10, marginBottom: 15, width: '100%', paddingHorizontal: 20}, marginBottom: 10,
fullRow: {width: '100%', paddingHorizontal: 20}, textAlign: "center",
codeInput: { },
flex: 1, disabledBody: { fontSize: 15, color: "#ffffff", textAlign: "center" },
borderWidth: 1, notInGameText: {
borderColor: '#45475a', color: "#ffffff",
borderRadius: 8, fontSize: 15,
paddingHorizontal: 12, fontWeight: "600",
paddingVertical: 10, marginBottom: 16,
fontSize: 16, textAlign: "center",
color: '#ffffff', paddingHorizontal: 20,
backgroundColor: '#313244', },
}, inputDisabled: { backgroundColor: "#313244", opacity: 0.5 },
joinButton: {backgroundColor: '#bd93f9', borderColor: '#bd93f9', flex: 0, paddingHorizontal: 20}, inputRow: {
joinLabel: {color: '#1e1e2e', fontWeight: '600'}, flexDirection: "row",
createButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1'}, gap: 10,
createLabel: {color: '#1e1e2e', fontWeight: '600'}, marginBottom: 15,
waitlistText: { width: "100%",
fontSize: 18, paddingHorizontal: 20,
fontWeight: '700', },
color: '#ffffff', fullRow: { width: "100%", paddingHorizontal: 20 },
textAlign: 'center', codeInput: {
paddingHorizontal: 30, flex: 1,
}, borderWidth: 1,
teamHeader: { borderColor: "#45475a",
backgroundColor: '#45475a', borderRadius: 8,
paddingVertical: 14, paddingHorizontal: 12,
paddingHorizontal: 16, paddingVertical: 10,
flexDirection: 'row', fontSize: 16,
alignItems: 'center', color: "#ffffff",
gap: 8, backgroundColor: "#313244",
}, },
teamCodeLabel: {color: '#ffffff', fontSize: 15, fontWeight: '600'}, joinButton: {
teamCodeValue: {color: '#ffffff', fontSize: 17, fontWeight: '700', letterSpacing: 2}, backgroundColor: "#bd93f9",
teamNameSection: {paddingHorizontal: 15, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#45475a'}, borderColor: "#bd93f9",
teamNameText: {fontSize: 18, fontWeight: '700', color: '#ffffff', flex: 1}, flex: 0,
renameRow: {flexDirection: 'row', alignItems: 'center', gap: 8}, paddingHorizontal: 20,
renameInput: { },
flex: 1, joinLabel: { color: "#1e1e2e", fontWeight: "600" },
borderWidth: 1, createButton: { backgroundColor: "#a6e3a1", borderColor: "#a6e3a1" },
borderColor: '#45475a', createLabel: { color: "#1e1e2e", fontWeight: "600" },
borderRadius: 8, waitlistText: {
paddingHorizontal: 12, fontSize: 18,
paddingVertical: 8, fontWeight: "700",
fontSize: 16, color: "#ffffff",
color: '#ffffff', textAlign: "center",
backgroundColor: '#313244', paddingHorizontal: 30,
}, },
renameButton: {backgroundColor: '#bd93f9', borderColor: '#bd93f9', flex: 0, minHeight: 36, paddingHorizontal: 14}, teamHeader: {
renameLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13}, backgroundColor: "#45475a",
renameSaveButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1', flex: 0, minHeight: 36, paddingHorizontal: 12}, paddingVertical: 14,
renameSaveLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13}, paddingHorizontal: 16,
renameCancelButton: {backgroundColor: '#6c7086', borderColor: '#6c7086', flex: 0, minHeight: 36, paddingHorizontal: 12}, flexDirection: "row",
renameCancelLabel: {color: '#cdd6f4', fontWeight: '600', fontSize: 13}, alignItems: "center",
memberList: {flex: 1, paddingHorizontal: 15, paddingTop: 10}, gap: 8,
memberRow: { },
flexDirection: 'row', teamCodeLabel: { color: "#ffffff", fontSize: 15, fontWeight: "600" },
justifyContent: 'space-between', teamCodeValue: {
alignItems: 'center', color: "#ffffff",
}, fontSize: 17,
memberInfo: {flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6}, fontWeight: "700",
memberName: {fontSize: 16, color: '#ffffff', fontWeight: '600'}, letterSpacing: 2,
leaderBadge: {fontSize: 12, color: '#bd93f9', fontWeight: '700'}, },
kickButton: {backgroundColor: '#D92800', borderColor: '#D92800', minHeight: 36, flex: 0, paddingHorizontal: 14}, teamNameSection: {
kickLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13}, paddingHorizontal: 15,
emptyText: {color: '#ffffff', textAlign: 'center', marginTop: 30, fontSize: 14}, paddingVertical: 10,
leaveWrap: {paddingHorizontal: 0, paddingTop: 10, paddingBottom: 16}, borderBottomWidth: 1,
leaveButton: {backgroundColor: '#D92800', borderColor: '#D92800'}, borderBottomColor: "#45475a",
leaveLabel: {color: '#1e1e2e', fontWeight: '600'}, },
selfCard: {backgroundColor: 'rgba(166, 227, 161, 0.15)', borderColor: '#a6e3a1'}, teamNameText: { fontSize: 18, fontWeight: "700", color: "#ffffff", flex: 1 },
renameRow: { flexDirection: "row", alignItems: "center", gap: 8 },
renameInput: {
flex: 1,
borderWidth: 1,
borderColor: "#45475a",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 16,
color: "#ffffff",
backgroundColor: "#313244",
},
renameButton: {
backgroundColor: "#bd93f9",
borderColor: "#bd93f9",
flex: 0,
minHeight: 36,
paddingHorizontal: 14,
},
renameLabel: { color: "#1e1e2e", fontWeight: "600", fontSize: 13 },
renameSaveButton: {
backgroundColor: "#a6e3a1",
borderColor: "#a6e3a1",
flex: 0,
minHeight: 36,
paddingHorizontal: 12,
},
renameSaveLabel: { color: "#1e1e2e", fontWeight: "600", fontSize: 13 },
renameCancelButton: {
backgroundColor: "#6c7086",
borderColor: "#6c7086",
flex: 0,
minHeight: 36,
paddingHorizontal: 12,
},
renameCancelLabel: { color: "#cdd6f4", fontWeight: "600", fontSize: 13 },
memberList: { flex: 1, paddingHorizontal: 15, paddingTop: 10 },
memberRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
memberInfo: { flex: 1, flexDirection: "row", alignItems: "center", gap: 6 },
memberName: { fontSize: 16, color: "#ffffff", fontWeight: "600" },
leaderBadge: { fontSize: 12, color: "#bd93f9", fontWeight: "700" },
kickButton: {
backgroundColor: "#D92800",
borderColor: "#D92800",
minHeight: 36,
flex: 0,
paddingHorizontal: 14,
},
kickLabel: { color: "#1e1e2e", fontWeight: "600", fontSize: 13 },
emptyText: {
color: "#ffffff",
textAlign: "center",
marginTop: 30,
fontSize: 14,
},
leaveWrap: { paddingHorizontal: 0, paddingTop: 10, paddingBottom: 16 },
leaveButton: { backgroundColor: "#D92800", borderColor: "#D92800" },
leaveLabel: { color: "#1e1e2e", fontWeight: "600" },
selfCard: {
backgroundColor: "rgba(166, 227, 161, 0.15)",
borderColor: "#a6e3a1",
},
}); });
export default TeamScreen; export default TeamScreen;
@@ -1,16 +1,16 @@
import {useCallback, useEffect, useMemo, useRef, 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, {Circle, Marker, Polygon} 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 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 usePlayerGame from "../../../hooks/usePlayerGame";
import {getSession} from "../../../hooks/SessionStore"; import { getSession } from "../../../hooks/SessionStore";
import {getFovCone, isInClaimCone} from "../../../utils/geoMath"; import { getFovCone, isInClaimCone } from "../../../utils/geoMath";
import {GAME_MODE} from "../../../utils/gameConstants"; import { GAME_MODE } from "../../../utils/gameConstants";
const DEFAULT_REGION = { const DEFAULT_REGION = {
latitude: 51.5074, latitude: 51.5074,
@@ -27,17 +27,19 @@ const GlobalMapScreen = ({ navigation, route }) => {
const session = getSession(); const session = getSession();
const routeEvent = route?.params?.event ?? null; const routeEvent = route?.params?.event ?? null;
const eventId = const eventId =
route?.params?.eventId ?? routeEvent?.EventID ?? session.currentGlobalEventId; route?.params?.eventId ??
const { getCachesByEvent, getFindsByPlayer, logFind } = useGlobalHook(); routeEvent?.EventID ??
session.currentGlobalEventId;
const { getCachesByEvent, getFindsByPlayer, claimCache } = useGlobalHook();
const globalApiRef = useRef({ const globalApiRef = useRef({
getCachesByEvent, getCachesByEvent,
getFindsByPlayer, getFindsByPlayer,
logFind, claimCache,
}); });
globalApiRef.current = { globalApiRef.current = {
getCachesByEvent, getCachesByEvent,
getFindsByPlayer, getFindsByPlayer,
logFind, claimCache,
}; };
// State ------------------------------- // State -------------------------------
@@ -53,7 +55,8 @@ const GlobalMapScreen = ({ navigation, route }) => {
route?.params?.selectedCacheId ?? route?.params?.cache?.CacheID ?? null, route?.params?.selectedCacheId ?? route?.params?.cache?.CacheID ?? null,
); );
const [claimedPopupVisible, setClaimedPopupVisible] = useState(false); const [claimedPopupVisible, setClaimedPopupVisible] = useState(false);
const [claimedPopupMessage, setClaimedPopupMessage] = useState("Cache Claimed!"); const [claimedPopupMessage, setClaimedPopupMessage] =
useState("Cache Claimed!");
const claimableCaches = useMemo( const claimableCaches = useMemo(
() => () =>
@@ -126,24 +129,29 @@ const GlobalMapScreen = ({ navigation, route }) => {
(cache) => String(cache.CacheID) === String(cacheId), (cache) => String(cache.CacheID) === String(cacheId),
); );
const result = await globalApiRef.current.logFind({ const result = await globalApiRef.current.claimCache(
FindPlayerID: session.currentGlobalPlayerId, session.currentGlobalPlayerId,
FindCacheID: claimedCache?.CacheID ?? cacheId, claimedCache,
FindDatetime: new Date().toISOString(), );
});
setIsClaiming(false); setIsClaiming(false);
if (result) { if (!result || result.error) {
const cacheName = claimedCache?.CacheName || "Cache"; setError(`Failed to claim cache: ${result?.error || "Unknown error"}`);
const points = Number(claimedCache?.CachePoints ?? 0); return;
setClaimedPopupMessage(`${cacheName} claimed! You gained +${points} points.`);
setClaimedPopupVisible(true);
setTimeout(() => setClaimedPopupVisible(false), 3000);
await loadData({ forceRefresh: true });
} }
// Optimistically mark as found — no loadData() to avoid overwriting
// this state with a stale server response.
setFoundCacheIds((prev) => [...prev, String(cacheId)]);
const cacheName = claimedCache?.CacheName || "Cache";
setClaimedPopupMessage(
`${cacheName} claimed! You gained +${result.points} points.`,
);
setClaimedPopupVisible(true);
setTimeout(() => setClaimedPopupVisible(false), 3000);
}, },
[caches, foundCacheIds, loadData, session.currentGlobalPlayerId, setIsClaiming], [caches, foundCacheIds, session.currentGlobalPlayerId, setIsClaiming],
); );
const handleCacheSelect = (cache) => { const handleCacheSelect = (cache) => {
@@ -234,7 +242,9 @@ const GlobalMapScreen = ({ navigation, route }) => {
useEffect(() => { useEffect(() => {
if (selectedCacheId) return; if (selectedCacheId) return;
setSelectedCacheId(route?.params?.cache?.CacheID ?? claimableCaches[0]?.id ?? null); setSelectedCacheId(
route?.params?.cache?.CacheID ?? claimableCaches[0]?.id ?? null,
);
}, [claimableCaches, route?.params?.cache?.CacheID, selectedCacheId]); }, [claimableCaches, route?.params?.cache?.CacheID, selectedCacheId]);
useEffect(() => { useEffect(() => {
@@ -335,33 +345,32 @@ const GlobalMapScreen = ({ navigation, route }) => {
region={mapRegion} region={mapRegion}
showsUserLocation showsUserLocation
> >
{(caches || []).map((cache) => { {(caches || [])
const found = foundCacheIds.includes(String(cache.CacheID)); .filter((cache) => !foundCacheIds.includes(String(cache.CacheID)))
const selected = selectedCacheId === cache.CacheID; .map((cache) => {
return ( const selected = selectedCacheId === cache.CacheID;
<Marker return (
key={cache.CacheID} <Marker
coordinate={{ key={cache.CacheID}
latitude: cache.CacheLatitude, coordinate={{
longitude: cache.CacheLongitude, latitude: cache.CacheLatitude,
}} longitude: cache.CacheLongitude,
title={cache.CacheName} }}
description={`${cache.CachePoints ?? 0} pts${found ? " · Found" : ""}`} title={cache.CacheName}
pinColor={ description={`${cache.CachePoints ?? 0} pts`}
found ? "#22c55e" : selected ? "#f59e0b" : "#ef4444" pinColor={selected ? "#f59e0b" : "#ef4444"}
} onPress={() => setSelectedCacheId(cache.CacheID)}
onPress={() => setSelectedCacheId(cache.CacheID)} onCalloutPress={() =>
onCalloutPress={() => navigation.navigate("GlobalCacheViewScreen", {
navigation.navigate("GlobalCacheViewScreen", { cache,
cache, event: routeEvent,
event: routeEvent, eventId,
eventId, isFound: false,
isFound: found, })
}) }
} />
/> );
); })}
})}
{claimTarget ? ( {claimTarget ? (
<Circle <Circle
@@ -383,7 +392,9 @@ const GlobalMapScreen = ({ navigation, route }) => {
</MapView> </MapView>
) : ( ) : (
<CacheList <CacheList
caches={caches} caches={(caches || []).filter(
(cache) => !foundCacheIds.includes(String(cache.CacheID)),
)}
foundCacheIds={foundCacheIds} foundCacheIds={foundCacheIds}
onSelect={handleCacheSelect} onSelect={handleCacheSelect}
/> />
+80 -180
View File
@@ -1,12 +1,6 @@
import {useCallback, useMemo} from "react"; 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 globalApiConfig from "../components/API/api.json";
import {
clearGlobalDataCache,
clearGlobalDataCacheByPrefix,
getOrFetchGlobalData,
writeGlobalDataCache,
} from "../utils/globalDataCache";
const GLOBAL_BASE = String( const GLOBAL_BASE = String(
process.env.EXPO_PUBLIC_GLOBAL_API_BASE || process.env.EXPO_PUBLIC_GLOBAL_API_BASE ||
@@ -24,26 +18,6 @@ const DEFAULT_GLOBAL_PROFILE_IMAGE_URL =
const isGlobalApiReady = () => Boolean(GLOBAL_BASE); 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. // Appends the API key as a query param when provided.
const withKey = (url) => { const withKey = (url) => {
if (!GLOBAL_KEY) return url; if (!GLOBAL_KEY) return url;
@@ -155,56 +129,33 @@ const useGlobalHook = () => {
// Handlers ---------------------------- // Handlers ----------------------------
// Events // Events
const getPublicEvents = useCallback( const getPublicEvents = useCallback(async () => {
async (options = {}) => { if (!isGlobalApiReady()) return [];
if (!isGlobalApiReady()) return []; const response = await API.get(withKey(endpoints.events));
return getOrFetchGlobalData( if (!response.isSuccess) return [];
CACHE_KEY.publicEvents, const rows = unwrapList(response.result);
async () => { return rows.filter((e) => e.EventIspublic);
const response = await API.get(withKey(endpoints.events)); }, [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 = useCallback( const getEvent = useCallback(
async (eventId, options = {}) => { async (eventId) => {
if (!isGlobalApiReady()) return null; if (!isGlobalApiReady()) return null;
return getOrFetchGlobalData( const response = await API.get(withKey(`${endpoints.events}/${eventId}`));
CACHE_KEY.event(eventId), if (!response.isSuccess) return null;
async () => { return unwrapSingle(response.result);
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], [endpoints.events],
); );
// Players // Players
const getPlayersByEvent = useCallback( const getPlayersByEvent = useCallback(
async (eventId, options = {}) => { async (eventId) => {
if (!isGlobalApiReady()) return []; if (!isGlobalApiReady()) return [];
return getOrFetchGlobalData( const response = await API.get(
CACHE_KEY.playersByEvent(eventId), withKey(`${endpoints.players}/events/${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 },
); );
if (!response.isSuccess) return [];
return unwrapList(response.result);
}, },
[endpoints.players], [endpoints.players],
); );
@@ -214,86 +165,55 @@ const useGlobalHook = () => {
if (!isGlobalApiReady()) return null; if (!isGlobalApiReady()) return null;
const response = await API.post(withKey(endpoints.players), data); const response = await API.post(withKey(endpoints.players), data);
if (!response.isSuccess) return null; if (!response.isSuccess) return null;
const joined = unwrapSingle(response.result); return unwrapSingle(response.result);
const eventId = data?.PlayerEventID ?? joined?.PlayerEventID;
if (eventId !== undefined && eventId !== null) {
clearGlobalDataCache(CACHE_KEY.playersByEvent(eventId));
}
return joined;
}, },
[endpoints.players], [endpoints.players],
); );
// Caches // Caches
const getCachesByEvent = useCallback( const getCachesByEvent = useCallback(
async (eventId, options = {}) => { async (eventId) => {
if (!isGlobalApiReady()) return []; if (!isGlobalApiReady()) return [];
return getOrFetchGlobalData( const response = await API.get(
CACHE_KEY.cachesByEvent(eventId), withKey(`${endpoints.caches}/events/${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 },
); );
if (!response.isSuccess) return [];
return unwrapList(response.result);
}, },
[endpoints.caches], [endpoints.caches],
); );
const getCache = useCallback( const getCache = useCallback(
async (cacheId, options = {}) => { async (cacheId) => {
if (!isGlobalApiReady()) return null; if (!isGlobalApiReady()) return null;
return getOrFetchGlobalData( const response = await API.get(withKey(`${endpoints.caches}/${cacheId}`));
CACHE_KEY.cacheById(cacheId), if (!response.isSuccess) return null;
async () => { return unwrapSingle(response.result);
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], [endpoints.caches],
); );
// Finds // Finds
const getFindsByEvent = useCallback( const getFindsByEvent = useCallback(
async (eventId, options = {}) => { async (eventId) => {
if (!isGlobalApiReady()) return []; if (!isGlobalApiReady()) return [];
return getOrFetchGlobalData( const response = await API.get(
CACHE_KEY.findsByEvent(eventId), withKey(`${endpoints.finds}/events/${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 },
); );
if (!response.isSuccess) return [];
return unwrapList(response.result);
}, },
[endpoints.finds], [endpoints.finds],
); );
const getFindsByPlayer = useCallback( const getFindsByPlayer = useCallback(
async (playerId, options = {}) => { async (playerId) => {
if (!isGlobalApiReady()) return []; if (!isGlobalApiReady()) return [];
return getOrFetchGlobalData( const response = await API.get(
CACHE_KEY.findsByPlayer(playerId), withKey(`${endpoints.finds}/players/${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 },
); );
if (!response.isSuccess) return [];
return unwrapList(response.result);
}, },
[endpoints.finds], [endpoints.finds],
); );
@@ -303,57 +223,53 @@ const useGlobalHook = () => {
if (!isGlobalApiReady()) return null; if (!isGlobalApiReady()) return null;
const response = await API.post(withKey(endpoints.finds), data); const response = await API.post(withKey(endpoints.finds), data);
if (!response.isSuccess) return null; if (!response.isSuccess) return null;
const find = unwrapSingle(response.result) || { ...data }; return unwrapSingle(response.result) || { ...data };
if (data?.FindPlayerID !== undefined && data?.FindPlayerID !== null) { },
clearGlobalDataCache(CACHE_KEY.findsByPlayer(data.FindPlayerID)); [endpoints.finds],
} );
// Event ID is not always present in payload, so clear all event-level find caches.
clearGlobalDataCacheByPrefix("global:finds:event:"); const claimCache = useCallback(
return find; async (playerId, cache) => {
if (!isGlobalApiReady() || !playerId || !cache) return null;
const response = await API.post(withKey(endpoints.finds), {
FindPlayerID: playerId,
FindCacheID: cache.CacheID,
FindDatetime: new Date().toISOString(),
FindImageURL: cache.CacheImageURL || DEFAULT_GLOBAL_PROFILE_IMAGE_URL,
});
if (!response.isSuccess)
return { error: response.message || "Unknown error" };
const find = unwrapSingle(response.result) || {
FindPlayerID: playerId,
FindCacheID: cache.CacheID,
};
return { find, points: Number(cache.CachePoints ?? 0) };
}, },
[endpoints.finds], [endpoints.finds],
); );
// Users // Users
const getUsers = useCallback( const getUsers = useCallback(async () => {
async (options = {}) => { if (!isGlobalApiReady()) return [];
if (!isGlobalApiReady()) return []; const response = await API.get(withKey(endpoints.users));
return getOrFetchGlobalData( if (!response.isSuccess) return [];
CACHE_KEY.users, return unwrapList(response.result);
async () => { }, [endpoints.users]);
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( const getUser = useCallback(
async (userId, options = {}) => { async (userId) => {
if (!isGlobalApiReady()) return null; if (!isGlobalApiReady()) return null;
// Some global API deployments expose list-only user routes.
const key = CACHE_KEY.userById(userId); const byIdResponse = await API.get(
return getOrFetchGlobalData( withKey(`${endpoints.users}/${userId}`),
key, );
async () => { if (byIdResponse.isSuccess) {
// Some global API deployments expose list-only user routes. const single = unwrapSingle(byIdResponse.result);
const byIdResponse = await API.get( if (single) return single;
withKey(`${endpoints.users}/${userId}`), }
); const users = await getUsers();
if (byIdResponse.isSuccess) { return (
const single = unwrapSingle(byIdResponse.result); users.find((user) => String(user.UserID) === String(userId)) || null
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], [endpoints.users, getUsers],
@@ -364,17 +280,7 @@ const useGlobalHook = () => {
if (!isGlobalApiReady()) return null; if (!isGlobalApiReady()) return null;
const response = await API.post(withKey(endpoints.users), payload); const response = await API.post(withKey(endpoints.users), payload);
if (!response.isSuccess) return null; if (!response.isSuccess) return null;
const created = unwrapSingle(response.result); return 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], [endpoints.users],
); );
@@ -387,14 +293,7 @@ const useGlobalHook = () => {
payload, payload,
); );
if (!response.isSuccess) return null; if (!response.isSuccess) return null;
const updated = unwrapSingle(response.result) || payload; return unwrapSingle(response.result) || payload;
writeGlobalDataCache(
CACHE_KEY.userById(userId),
updated,
CACHE_TTL.users,
);
clearGlobalDataCache(CACHE_KEY.users);
return updated;
}, },
[endpoints.users], [endpoints.users],
); );
@@ -420,7 +319,7 @@ const useGlobalHook = () => {
if (created) return created; if (created) return created;
// Final fallback: check list endpoint again after attempted creation. // Final fallback: check list endpoint again after attempted creation.
const refreshed = await getUsers({ forceRefresh: true }); const refreshed = await getUsers();
return ( return (
refreshed.find( refreshed.find(
(user) => String(user.UserUsername) === String(seed.UserUsername), (user) => String(user.UserUsername) === String(seed.UserUsername),
@@ -446,6 +345,7 @@ const useGlobalHook = () => {
getFindsByEvent, getFindsByEvent,
getFindsByPlayer, getFindsByPlayer,
logFind, logFind,
claimCache,
getUser, getUser,
createUser, createUser,
updateUser, updateUser,