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 {Button} from './Button';
import { StyleSheet, TextInput, View } from "react-native";
import { Button } from "./Button";
const CodeJoinRow = ({
value,
onChange,
placeholder,
buttonLabel = 'Join',
buttonLabel = "Join",
onSubmit,
disabled = false,
style,
@@ -39,37 +39,36 @@ const CodeJoinRow = ({
const styles = StyleSheet.create({
inputRow: {
flexDirection: 'row',
flexDirection: "row",
gap: 10,
marginBottom: 15,
width: '100%',
width: "100%",
paddingHorizontal: 20,
},
codeInput: {
flex: 1,
borderWidth: 1,
borderColor: '#45475a',
borderColor: "#45475a",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#cdd6f4',
backgroundColor: '#313244',
color: "#cdd6f4",
backgroundColor: "#313244",
},
inputDisabled: {
opacity: 0.5,
},
joinButton: {
backgroundColor: '#bd93f9',
borderColor: '#bd93f9',
backgroundColor: "#bd93f9",
borderColor: "#bd93f9",
flex: 0,
paddingHorizontal: 20,
},
joinLabel: {
color: '#1e1e2e',
fontWeight: '600',
color: "#1e1e2e",
fontWeight: "600",
},
});
export default CodeJoinRow;
+12 -12
View File
@@ -1,6 +1,6 @@
import React, {useEffect, useRef, useState} from 'react';
import {StyleSheet, Text, View} from 'react-native';
import {CACHE_CLAIM_TIMER} from '../../utils/geoMath';
import { useEffect, useRef, useState } from "react";
import { StyleSheet, Text, View } from "react-native";
import { CACHE_CLAIM_TIMER } from "../../utils/geoMath";
const ClaimTimerView = ({
cache,
@@ -8,7 +8,7 @@ const ClaimTimerView = ({
isClaiming,
showClaimedPopup,
claimDurationSeconds = CACHE_CLAIM_TIMER,
claimedPopupMessage = 'Cache Claimed!',
claimedPopupMessage = "Cache Claimed!",
autoClaim = true,
}) => {
const [timeLeft, setTimeLeft] = useState(claimDurationSeconds);
@@ -75,30 +75,30 @@ const ClaimTimerView = ({
const styles = StyleSheet.create({
overlay: {
alignSelf: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
alignSelf: "center",
backgroundColor: "rgba(0, 0, 0, 0.75)",
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 10,
marginTop: 8,
},
text: {
color: '#cdd6f4',
color: "#cdd6f4",
fontSize: 16,
fontWeight: '700',
fontWeight: "700",
},
claimedOverlay: {
alignSelf: 'center',
backgroundColor: '#a6e3a1',
alignSelf: "center",
backgroundColor: "#a6e3a1",
paddingHorizontal: 20,
paddingVertical: 14,
borderRadius: 10,
marginTop: 8,
},
claimedText: {
color: '#1e1e2e',
color: "#1e1e2e",
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 {getFovCone} from '../../utils/geoMath';
import MapView, { Marker, Polygon } from "react-native-maps";
import { getFovCone } from "../../utils/geoMath";
const PlayerMapView = ({userLocation, visibleCaches, heading}) => {
if (!userLocation) return null;
const PlayerMapView = ({ userLocation, visibleCaches, heading }) => {
if (!userLocation) return null;
const coneCoords = (heading !== null && heading !== undefined)
? getFovCone(userLocation, heading)
: null;
const coneCoords =
heading !== null && heading !== undefined
? getFovCone(userLocation, heading)
: null;
return (
<MapView
style={{flex: 1}}
provider="google"
initialRegion={{...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}}
scrollEnabled={true}
zoomEnabled={true}
rotateEnabled={false}
pitchEnabled={false}
showsUserLocation
>
{(visibleCaches || []).map((cache) => (
<Marker
key={cache.id}
coordinate={cache.coordinates}
pinColor="orange"
title={cache.clue}
/>
))}
{coneCoords && (
<Polygon
coordinates={coneCoords}
fillColor="rgba(66,133,244,0.28)"
strokeColor="rgba(66,133,244,0.50)"
strokeWidth={1}
/>
)}
</MapView>
);
return (
<MapView
style={{ flex: 1 }}
provider="google"
initialRegion={{
...userLocation,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
scrollEnabled={true}
zoomEnabled={true}
rotateEnabled={false}
pitchEnabled={false}
showsUserLocation
>
{(visibleCaches || []).map((cache) => (
<Marker
key={cache.id}
coordinate={cache.coordinates}
pinColor="orange"
title={cache.clue}
/>
))}
{coneCoords && (
<Polygon
coordinates={coneCoords}
fillColor="rgba(66,133,244,0.28)"
strokeColor="rgba(66,133,244,0.50)"
strokeWidth={1}
/>
)}
</MapView>
);
};
export default PlayerMapView;
+220 -193
View File
@@ -1,29 +1,38 @@
import React, {useEffect, useState} from 'react';
import {ActivityIndicator, Alert, 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';
import { useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
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 {SGid, Gid} = route.params;
const {getSubgroup, updateSubgroup, getCaches, resetGame} = useGameHook();
const DepartmentSettingsScreen = ({ route, navigation }) => {
const { SGid, Gid } = route.params;
const { getSubgroup, updateSubgroup, getCaches, resetGame } = useGameHook();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [deptName, setDeptName] = useState('');
const [joinCode, setJoinCode] = useState('');
const [deptName, setDeptName] = useState("");
const [joinCode, setJoinCode] = useState("");
const [teamsEnabled, setTeamsEnabled] = useState(false);
const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
const [cacheTriggerMeters, setCacheTriggerMeters] = useState("20");
const [cacheCount, setCacheCount] = useState(0);
// Handlers -------------------
// Handlers -------------------
useEffect(() => {
const load = async () => {
const sg = await getSubgroup(SGid);
if (sg) {
setDeptName(sg.SubGroupName || '');
setJoinCode(sg.JoinCode || '');
setDeptName(sg.SubGroupName || "");
setJoinCode(sg.JoinCode || "");
setTeamsEnabled(Boolean(sg.TeamsEnabled));
setCacheTriggerMeters(String(sg.CacheTriggerMeters || 20));
}
@@ -35,190 +44,208 @@ const DepartmentSettingsScreen = ({route, navigation}) => {
load();
}, []);
const handleSave = async () => {
setSaving(true);
await updateSubgroup(SGid, {
SubGroupName: deptName.trim(),
TeamsEnabled: teamsEnabled,
CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20,
});
setSaving(false);
Alert.alert('Saved', 'Department settings updated.');
};
const handleSave = async () => {
setSaving(true);
await updateSubgroup(SGid, {
SubGroupName: deptName.trim(),
TeamsEnabled: teamsEnabled,
CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20,
});
setSaving(false);
Alert.alert("Saved", "Department settings updated.");
};
const handleResetDepartment = () => {
Alert.alert(
'Reset Department',
'This will reset all cache claims for this department. Are you sure?',
[
{text: 'Cancel', style: 'cancel'},
{
text: 'Reset',
style: 'destructive',
onPress: async () => {
await resetGame(Gid, SGid);
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 handleResetDepartment = () => {
Alert.alert(
"Reset Department",
"This will reset all cache claims for this department. Are you sure?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Reset",
style: "destructive",
onPress: async () => {
await resetGame(Gid, SGid);
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 styles = StyleSheet.create({
center: {justifyContent: 'center', alignItems: 'center'},
formContainer: {paddingBottom: 20},
sectionTitle: {fontSize: 20, fontWeight: '700', color: '#cdd6f4', marginBottom: 16},
label: {fontSize: 14, fontWeight: '600', color: '#bac2de', marginBottom: 6, marginTop: 14},
input: {
borderWidth: 1,
borderColor: '#45475a',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#cdd6f4',
backgroundColor: '#313244',
},
codeSection: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 16,
paddingVertical: 12,
paddingHorizontal: 12,
backgroundColor: '#313244',
borderRadius: 8,
},
codeLabel: {fontSize: 14, fontWeight: '600', color: '#bac2de'},
codeValue: {fontSize: 16, fontWeight: '700', color: '#bd93f9', letterSpacing: 2},
toggleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 16,
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: '#45475a',
},
toggleLabel: {fontSize: 14, fontWeight: '600', color: '#bac2de'},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
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'},
center: { justifyContent: "center", alignItems: "center" },
formContainer: { paddingBottom: 20 },
sectionTitle: {
fontSize: 20,
fontWeight: "700",
color: "#cdd6f4",
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: "600",
color: "#bac2de",
marginBottom: 6,
marginTop: 14,
},
input: {
borderWidth: 1,
borderColor: "#45475a",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: "#cdd6f4",
backgroundColor: "#313244",
},
codeSection: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: 16,
paddingVertical: 12,
paddingHorizontal: 12,
backgroundColor: "#313244",
borderRadius: 8,
},
codeLabel: { fontSize: 14, fontWeight: "600", color: "#bac2de" },
codeValue: {
fontSize: 16,
fontWeight: "700",
color: "#bd93f9",
letterSpacing: 2,
},
toggleRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: 16,
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: "#45475a",
},
toggleLabel: { fontSize: 14, fontWeight: "600", color: "#bac2de" },
infoRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
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;
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 {Pressable, StyleSheet, Text, TextInput, View} from 'react-native';
import useAuthHook from '../../hooks/useAuthHook';
import Screen from '../layout/Screen';
import {Button, ButtonTray} from '../UI/Button';
import { useState } from "react";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import useAuthHook from "../../hooks/useAuthHook";
import Screen from "../layout/Screen";
import { Button, ButtonTray } from "../UI/Button";
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 handleLogin = async () => {
@@ -17,10 +18,10 @@ const LoginScreen = ({ navigation }) => {
}
const rootNav = navigation.getParent() || navigation;
if (user.IsAcceptedAdmin) {
rootNav.reset({ index: 0, routes: [{ name: 'Admin' }] });
rootNav.reset({ index: 0, routes: [{ name: "Admin" }] });
return;
}
rootNav.reset({ index: 0, routes: [{ name: 'Game' }] });
rootNav.reset({ index: 0, routes: [{ name: "Game" }] });
};
return (
@@ -52,14 +53,14 @@ const LoginScreen = ({ navigation }) => {
<ButtonTray>
<Button
label={submitting ? 'Logging in...' : 'Login'}
label={submitting ? "Logging in..." : "Login"}
onClick={handleLogin}
styleButton={styles.submitButton}
styleLabel={styles.submitText}
/>
</ButtonTray>
<Pressable onPress={() => navigation.navigate('RegisterScreen')}>
<Pressable onPress={() => navigation.navigate("RegisterScreen")}>
<Text style={styles.linkText}>Create new account</Text>
</Pressable>
</View>
@@ -69,45 +70,45 @@ const LoginScreen = ({ navigation }) => {
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
justifyContent: "center",
},
content: {
gap: 15,
},
title: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
fontWeight: "bold",
textAlign: "center",
marginBottom: 10,
color: '#cdd6f4',
color: "#cdd6f4",
},
input: {
borderWidth: 1,
borderColor: '#45475a',
borderColor: "#45475a",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#cdd6f4',
backgroundColor: '#313244',
color: "#cdd6f4",
backgroundColor: "#313244",
},
errorText: {
color: '#D92800',
textAlign: 'center',
color: "#D92800",
textAlign: "center",
marginTop: 5,
},
submitButton: {
backgroundColor: '#bd93f9',
borderColor: '#bd93f9',
backgroundColor: "#bd93f9",
borderColor: "#bd93f9",
},
submitText: {
color: '#1e1e2e',
fontWeight: '600',
color: "#1e1e2e",
fontWeight: "600",
},
linkText: {
textAlign: 'center',
color: '#bd93f9',
fontWeight: '600',
textAlign: "center",
color: "#bd93f9",
fontWeight: "600",
paddingVertical: 10,
},
});
+506 -392
View File
@@ -1,411 +1,525 @@
import React, {useCallback, useEffect, useState} from 'react';
import {ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, 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';
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
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}) => {
// Initialisation ------------
const PlayersScreen = ({ navigation, route }) => {
// Initialisation ------------
const session = getSession();
const {getTeams, getTeamMembers, getGroupMembers, removeMember, resetPlayerProgress, 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;
const session = getSession();
const {
getTeams,
getTeamMembers,
getGroupMembers,
removeMember,
resetPlayerProgress,
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 [players, setPlayers] = useState([]);
const [departmentList, setDepartmentList] = useState([]);
const [adminTeam, setAdminTeam] = useState([]);
const [isCurrentUserAdminLead, setIsCurrentUserAdminLead] = useState(false);
const [adminTeamExpanded, setAdminTeamExpanded] = useState(false);
const [loading, setLoading] = useState(true);
const [players, setPlayers] = useState([]);
const [departmentList, setDepartmentList] = useState([]);
const [adminTeam, setAdminTeam] = useState([]);
const [isCurrentUserAdminLead, setIsCurrentUserAdminLead] = useState(false);
const [adminTeamExpanded, setAdminTeamExpanded] = useState(false);
// 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>
);
}
// Handlers -------------------
const loadData = useCallback(async () => {
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/>;
setLoading(false);
return;
}
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>
);
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;
}
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>
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>
);
}
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({
center: {justifyContent: 'center', alignItems: 'center', flex: 1},
container: {padding: 0},
body: {color: '#bac2de', fontSize: 15},
sectionTitle: {
fontSize: 20,
fontWeight: '700',
color: '#cdd6f4',
marginBottom: 12,
paddingHorizontal: 15,
paddingTop: 15,
},
openLabel: {fontSize: 13, fontWeight: '700', color: '#bd93f9'},
listSection: {flex: 1, paddingHorizontal: 15},
emptyText: {color: '#6c7086', textAlign: 'center', marginTop: 30, fontSize: 14},
playerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 8,
},
playerName: {fontSize: 15, fontWeight: '600', color: '#cdd6f4', flex: 1},
playerMeta: {flexDirection: 'row', alignItems: 'center', gap: 6},
leaderBadge: {fontSize: 11, color: '#bd93f9', fontWeight: '700'},
teamCodeBadge: {fontSize: 12, fontWeight: '700', color: '#a6e3a1', letterSpacing: 1},
adminBadge: {fontSize: 12, fontWeight: '700', color: '#D92800'},
waitlistBadge: {fontSize: 11, fontWeight: '600', color: '#f4a460'},
actionRow: {flexDirection: 'row', gap: 8},
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},
center: { justifyContent: "center", alignItems: "center", flex: 1 },
container: { padding: 0 },
body: { color: "#bac2de", fontSize: 15 },
sectionTitle: {
fontSize: 20,
fontWeight: "700",
color: "#cdd6f4",
marginBottom: 12,
paddingHorizontal: 15,
paddingTop: 15,
},
openLabel: { fontSize: 13, fontWeight: "700", color: "#bd93f9" },
listSection: { flex: 1, paddingHorizontal: 15 },
emptyText: {
color: "#6c7086",
textAlign: "center",
marginTop: 30,
fontSize: 14,
},
playerRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
marginBottom: 8,
},
playerName: { fontSize: 15, fontWeight: "600", color: "#cdd6f4", flex: 1 },
playerMeta: { flexDirection: "row", alignItems: "center", gap: 6 },
leaderBadge: { fontSize: 11, color: "#bd93f9", fontWeight: "700" },
teamCodeBadge: {
fontSize: 12,
fontWeight: "700",
color: "#a6e3a1",
letterSpacing: 1,
},
adminBadge: { fontSize: 12, fontWeight: "700", color: "#D92800" },
waitlistBadge: { fontSize: 11, fontWeight: "600", color: "#f4a460" },
actionRow: { flexDirection: "row", gap: 8 },
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;
+68 -43
View File
@@ -1,14 +1,23 @@
import React, {useState} from 'react';
import {Pressable, StyleSheet, Text, TextInput, View} from 'react-native';
import useAuthHook from '../../hooks/useAuthHook';
import Screen from '../layout/Screen';
import {Button, ButtonTray} from '../UI/Button';
import { useState } from "react";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import useAuthHook from "../../hooks/useAuthHook";
import Screen from "../layout/Screen";
import { Button, ButtonTray } from "../UI/Button";
const RegisterScreen = ({navigation}) => {
const RegisterScreen = ({ navigation }) => {
const {
username, email, password, confirmPassword, accountType,
error, isLoading,
setUsername, setEmail, setPassword, setConfirmPassword, setAccountType,
username,
email,
password,
confirmPassword,
accountType,
error,
isLoading,
setUsername,
setEmail,
setPassword,
setConfirmPassword,
setAccountType,
register,
} = useAuthHook();
@@ -21,10 +30,10 @@ const RegisterScreen = ({navigation}) => {
if (!user) return;
const rootNav = navigation.getParent() || navigation;
if (user.IsAcceptedAdmin) {
rootNav.reset({index: 0, routes: [{name: 'Admin'}]});
rootNav.reset({ index: 0, routes: [{ name: "Admin" }] });
return;
}
rootNav.reset({index: 0, routes: [{name: 'Game'}]});
rootNav.reset({ index: 0, routes: [{ name: "Game" }] });
};
return (
@@ -71,20 +80,36 @@ const RegisterScreen = ({navigation}) => {
<View style={styles.typeRow}>
<Pressable
style={[styles.typeButton, accountType === 'Individual' && styles.typeButtonActive]}
onPress={() => setAccountType('Individual')}
style={[
styles.typeButton,
accountType === "Individual" && styles.typeButtonActive,
]}
onPress={() => setAccountType("Individual")}
disabled={isLoading}
>
<Text style={[styles.typeText, accountType === 'Individual' && styles.typeTextActive]}>
<Text
style={[
styles.typeText,
accountType === "Individual" && styles.typeTextActive,
]}
>
Individual
</Text>
</Pressable>
<Pressable
style={[styles.typeButton, accountType === 'Business/School' && styles.typeButtonActive]}
onPress={() => setAccountType('Business/School')}
style={[
styles.typeButton,
accountType === "Business/School" && styles.typeButtonActive,
]}
onPress={() => setAccountType("Business/School")}
disabled={isLoading}
>
<Text style={[styles.typeText, accountType === 'Business/School' && styles.typeTextActive]}>
<Text
style={[
styles.typeText,
accountType === "Business/School" && styles.typeTextActive,
]}
>
Business
</Text>
</Pressable>
@@ -94,14 +119,14 @@ const RegisterScreen = ({navigation}) => {
<ButtonTray>
<Button
label={submitting ? 'Creating...' : 'Create Account'}
label={submitting ? "Creating..." : "Create Account"}
onClick={handleRegister}
styleButton={styles.submitButton}
styleLabel={styles.submitText}
/>
</ButtonTray>
<Pressable onPress={() => navigation.navigate('LoginScreen')}>
<Pressable onPress={() => navigation.navigate("LoginScreen")}>
<Text style={styles.linkText}>Back to Login</Text>
</Pressable>
</View>
@@ -111,68 +136,68 @@ const RegisterScreen = ({navigation}) => {
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
justifyContent: "center",
},
content: {
gap: 12,
},
title: {
fontSize: 24,
fontWeight: 'bold',
textAlign: 'center',
fontWeight: "bold",
textAlign: "center",
marginBottom: 10,
color: '#cdd6f4',
color: "#cdd6f4",
},
input: {
borderWidth: 1,
borderColor: '#45475a',
borderColor: "#45475a",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#cdd6f4',
backgroundColor: '#313244',
color: "#cdd6f4",
backgroundColor: "#313244",
},
typeRow: {
flexDirection: 'row',
flexDirection: "row",
gap: 10,
},
typeButton: {
flex: 1,
borderWidth: 1,
borderColor: '#45475a',
borderColor: "#45475a",
borderRadius: 8,
alignItems: 'center',
alignItems: "center",
paddingVertical: 10,
},
typeButtonActive: {
backgroundColor: '#bd93f9',
borderColor: '#bd93f9',
backgroundColor: "#bd93f9",
borderColor: "#bd93f9",
},
typeText: {
color: '#bac2de',
fontWeight: '600',
color: "#bac2de",
fontWeight: "600",
},
typeTextActive: {
color: '#1e1e2e',
color: "#1e1e2e",
},
errorText: {
color: '#D92800',
textAlign: 'center',
color: "#D92800",
textAlign: "center",
marginTop: 5,
},
submitButton: {
backgroundColor: '#bd93f9',
borderColor: '#bd93f9',
backgroundColor: "#bd93f9",
borderColor: "#bd93f9",
},
submitText: {
color: '#1e1e2e',
fontWeight: '600',
color: "#1e1e2e",
fontWeight: "600",
},
linkText: {
textAlign: 'center',
color: '#bd93f9',
fontWeight: '600',
textAlign: "center",
color: "#bd93f9",
fontWeight: "600",
paddingVertical: 10,
},
});
+505 -380
View File
@@ -1,20 +1,44 @@
import React, {useEffect, useState} from 'react';
import {ActivityIndicator, ScrollView, StyleSheet, Text, TextInput, 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';
import { useEffect, useState } from "react";
import {
ActivityIndicator,
ScrollView,
StyleSheet,
Text,
TextInput,
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 isAdmin = session.isAcceptedAdmin;
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 [teamCode, setTeamCode] = useState('');
const [teamCode, setTeamCode] = useState("");
const [team, setTeam] = useState(null);
const [members, setMembers] = useState([]);
const [memberNames, setMemberNames] = useState({});
@@ -22,7 +46,7 @@ const TeamScreen = ({navigation}) => {
const [teamsDisabled, setTeamsDisabled] = useState(false);
const [onAdminWaitlist, setOnAdminWaitlist] = useState(false);
const [editingName, setEditingName] = useState(false);
const [teamNameDraft, setTeamNameDraft] = useState('');
const [teamNameDraft, setTeamNameDraft] = useState("");
const loadTeamData = async (tid) => {
const t = await getTeam(tid);
@@ -30,402 +54,503 @@ const TeamScreen = ({navigation}) => {
const ms = await getTeamMembers(tid);
setMembers(ms || []);
const names = {};
for (const m of (ms || [])) {
for (const m of ms || []) {
const u = await getUser(m.Uid);
if (u) names[m.Uid] = u.username;
}
setMemberNames(names);
};
useEffect(() => {
const load = async () => {
if (!session.currentGid) { setLoading(false); return; }
useEffect(() => {
const load = async () => {
if (!session.currentGid) {
setLoading(false);
return;
}
const group = await getLobby(session.currentGid);
let teamsAreEnabled = Boolean(group?.TeamsEnabled);
const group = await getLobby(session.currentGid);
let teamsAreEnabled = Boolean(group?.TeamsEnabled);
if (isBusiness && session.currentSGid) {
const subgroup = await getSubgroup(session.currentSGid);
if (subgroup) teamsAreEnabled = Boolean(subgroup.TeamsEnabled);
}
if (isBusiness && session.currentSGid) {
const subgroup = await getSubgroup(session.currentSGid);
if (subgroup) teamsAreEnabled = Boolean(subgroup.TeamsEnabled);
}
if (session.isPendingAdmin && !session.isAcceptedAdmin) {
const freshUser = await getUser(session.currentUid);
if (freshUser?.IsAcceptedAdmin) {
setPendingAdmin(false);
setSessionUser(freshUser);
setSessionTeam(freshUser.TGid ?? null);
navigation.reset({index: 0, routes: [{name: 'Admin'}]});
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;
if (session.isPendingAdmin && !session.isAcceptedAdmin) {
const freshUser = await getUser(session.currentUid);
if (freshUser?.IsAcceptedAdmin) {
setPendingAdmin(false);
setSessionUser(freshUser);
setSessionTeam(freshUser.TGid ?? null);
navigation.reset({ index: 0, routes: [{ name: "Admin" }] });
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 waitlist = await getAdminWaitlist(
session.currentGid,
session.currentUid,
);
if (waitlist.length > 0) {
setOnAdminWaitlist(true);
setLoading(false);
return;
}
};
setPendingAdmin(false);
}
const handleLeaveTeam = async () => {
const myMembership = members.find((m) => m.Uid === session.currentUid);
if (!myMembership) return;
await leaveTeam(myMembership.id);
await updateUser(session.currentUid, {TGid: null});
setSessionTeam(null);
setActiveTid(null);
setTeam(null);
setMembers([]);
setMemberNames({});
};
if (!teamsAreEnabled) {
setTeamsDisabled(true);
setLoading(false);
return;
}
setTeamsDisabled(false);
const handleKickMember = async (member) => {
await leaveTeam(member.id);
await loadTeamData(activeTid);
};
const handleRenameTeam = async () => {
const trimmed = teamNameDraft.trim();
if (!trimmed || !activeTid) return;
const result = await updateTeam(activeTid, {TeamName: trimmed});
if (result) {
setTeam(result);
setEditingName(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);
}
};
// 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 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) {
return (
<Screen style={styles.center}>
<ActivityIndicator size="large"/>
</Screen>
);
const handleRenameTeam = async () => {
const trimmed = teamNameDraft.trim();
if (!trimmed || !activeTid) return;
const result = await updateTeam(activeTid, { TeamName: trimmed });
if (result) {
setTeam(result);
setEditingName(false);
}
};
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>
);
}
// Check if current user is the team leader
const myMembership = members.find((m) => m.Uid === session.currentUid);
const isCurrentUserLeader = Boolean(myMembership?.IsLeader);
// Waiting for admin approval
if (onAdminWaitlist) {
return <PendingApprovalView message="Waiting to be approved for Admin Team"/>;
}
// View -----------------------
// 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
if (loading) {
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>
<Screen style={styles.center}>
<ActivityIndicator size="large" />
</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({
center: {justifyContent: 'center', alignItems: 'center'},
container: {padding: 0},
disabledWrap: {paddingHorizontal: 30, alignItems: 'center', opacity: 0.45},
disabledTitle: {fontSize: 20, fontWeight: '700', color: '#ffffff', marginBottom: 10, textAlign: 'center'},
disabledBody: {fontSize: 15, color: '#ffffff', textAlign: 'center'},
notInGameText: {color: '#ffffff', fontSize: 15, fontWeight: '600', marginBottom: 16, textAlign: 'center', paddingHorizontal: 20},
inputDisabled: {backgroundColor: '#313244', opacity: 0.5},
inputRow: {flexDirection: 'row', gap: 10, marginBottom: 15, width: '100%', paddingHorizontal: 20},
fullRow: {width: '100%', paddingHorizontal: 20},
codeInput: {
flex: 1,
borderWidth: 1,
borderColor: '#45475a',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#ffffff',
backgroundColor: '#313244',
},
joinButton: {backgroundColor: '#bd93f9', borderColor: '#bd93f9', flex: 0, paddingHorizontal: 20},
joinLabel: {color: '#1e1e2e', fontWeight: '600'},
createButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1'},
createLabel: {color: '#1e1e2e', fontWeight: '600'},
waitlistText: {
fontSize: 18,
fontWeight: '700',
color: '#ffffff',
textAlign: 'center',
paddingHorizontal: 30,
},
teamHeader: {
backgroundColor: '#45475a',
paddingVertical: 14,
paddingHorizontal: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
teamCodeLabel: {color: '#ffffff', fontSize: 15, fontWeight: '600'},
teamCodeValue: {color: '#ffffff', fontSize: 17, fontWeight: '700', letterSpacing: 2},
teamNameSection: {paddingHorizontal: 15, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#45475a'},
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'},
center: { justifyContent: "center", alignItems: "center" },
container: { padding: 0 },
disabledWrap: { paddingHorizontal: 30, alignItems: "center", opacity: 0.45 },
disabledTitle: {
fontSize: 20,
fontWeight: "700",
color: "#ffffff",
marginBottom: 10,
textAlign: "center",
},
disabledBody: { fontSize: 15, color: "#ffffff", textAlign: "center" },
notInGameText: {
color: "#ffffff",
fontSize: 15,
fontWeight: "600",
marginBottom: 16,
textAlign: "center",
paddingHorizontal: 20,
},
inputDisabled: { backgroundColor: "#313244", opacity: 0.5 },
inputRow: {
flexDirection: "row",
gap: 10,
marginBottom: 15,
width: "100%",
paddingHorizontal: 20,
},
fullRow: { width: "100%", paddingHorizontal: 20 },
codeInput: {
flex: 1,
borderWidth: 1,
borderColor: "#45475a",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: "#ffffff",
backgroundColor: "#313244",
},
joinButton: {
backgroundColor: "#bd93f9",
borderColor: "#bd93f9",
flex: 0,
paddingHorizontal: 20,
},
joinLabel: { color: "#1e1e2e", fontWeight: "600" },
createButton: { backgroundColor: "#a6e3a1", borderColor: "#a6e3a1" },
createLabel: { color: "#1e1e2e", fontWeight: "600" },
waitlistText: {
fontSize: 18,
fontWeight: "700",
color: "#ffffff",
textAlign: "center",
paddingHorizontal: 30,
},
teamHeader: {
backgroundColor: "#45475a",
paddingVertical: 14,
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
gap: 8,
},
teamCodeLabel: { color: "#ffffff", fontSize: 15, fontWeight: "600" },
teamCodeValue: {
color: "#ffffff",
fontSize: 17,
fontWeight: "700",
letterSpacing: 2,
},
teamNameSection: {
paddingHorizontal: 15,
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: "#45475a",
},
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;
@@ -1,16 +1,16 @@
import {useCallback, useEffect, useMemo, useRef, useState} from "react";
import {ActivityIndicator, StyleSheet, Text, View} from "react-native";
import MapView, {Circle, Marker, Polygon} from "react-native-maps";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import MapView, { Circle, Marker, Polygon } from "react-native-maps";
import * as Location from "expo-location";
import Screen from "../../layout/Screen";
import {Button, ButtonTray} from "../../UI/Button";
import { Button, ButtonTray } from "../../UI/Button";
import ClaimTimerView from "../../gameplay/ClaimTimerView";
import CacheList from "../../../entity/cache/CacheList";
import useGlobalHook from "../../../hooks/useGlobalHook";
import usePlayerGame from "../../../hooks/usePlayerGame";
import {getSession} from "../../../hooks/SessionStore";
import {getFovCone, isInClaimCone} from "../../../utils/geoMath";
import {GAME_MODE} from "../../../utils/gameConstants";
import { getSession } from "../../../hooks/SessionStore";
import { getFovCone, isInClaimCone } from "../../../utils/geoMath";
import { GAME_MODE } from "../../../utils/gameConstants";
const DEFAULT_REGION = {
latitude: 51.5074,
@@ -27,17 +27,19 @@ const GlobalMapScreen = ({ navigation, route }) => {
const session = getSession();
const routeEvent = route?.params?.event ?? null;
const eventId =
route?.params?.eventId ?? routeEvent?.EventID ?? session.currentGlobalEventId;
const { getCachesByEvent, getFindsByPlayer, logFind } = useGlobalHook();
route?.params?.eventId ??
routeEvent?.EventID ??
session.currentGlobalEventId;
const { getCachesByEvent, getFindsByPlayer, claimCache } = useGlobalHook();
const globalApiRef = useRef({
getCachesByEvent,
getFindsByPlayer,
logFind,
claimCache,
});
globalApiRef.current = {
getCachesByEvent,
getFindsByPlayer,
logFind,
claimCache,
};
// State -------------------------------
@@ -53,7 +55,8 @@ const GlobalMapScreen = ({ navigation, route }) => {
route?.params?.selectedCacheId ?? route?.params?.cache?.CacheID ?? null,
);
const [claimedPopupVisible, setClaimedPopupVisible] = useState(false);
const [claimedPopupMessage, setClaimedPopupMessage] = useState("Cache Claimed!");
const [claimedPopupMessage, setClaimedPopupMessage] =
useState("Cache Claimed!");
const claimableCaches = useMemo(
() =>
@@ -126,24 +129,29 @@ const GlobalMapScreen = ({ navigation, route }) => {
(cache) => String(cache.CacheID) === String(cacheId),
);
const result = await globalApiRef.current.logFind({
FindPlayerID: session.currentGlobalPlayerId,
FindCacheID: claimedCache?.CacheID ?? cacheId,
FindDatetime: new Date().toISOString(),
});
const result = await globalApiRef.current.claimCache(
session.currentGlobalPlayerId,
claimedCache,
);
setIsClaiming(false);
if (result) {
const cacheName = claimedCache?.CacheName || "Cache";
const points = Number(claimedCache?.CachePoints ?? 0);
setClaimedPopupMessage(`${cacheName} claimed! You gained +${points} points.`);
setClaimedPopupVisible(true);
setTimeout(() => setClaimedPopupVisible(false), 3000);
await loadData({ forceRefresh: true });
if (!result || result.error) {
setError(`Failed to claim cache: ${result?.error || "Unknown error"}`);
return;
}
// 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) => {
@@ -234,7 +242,9 @@ const GlobalMapScreen = ({ navigation, route }) => {
useEffect(() => {
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]);
useEffect(() => {
@@ -335,33 +345,32 @@ const GlobalMapScreen = ({ navigation, route }) => {
region={mapRegion}
showsUserLocation
>
{(caches || []).map((cache) => {
const found = foundCacheIds.includes(String(cache.CacheID));
const selected = selectedCacheId === cache.CacheID;
return (
<Marker
key={cache.CacheID}
coordinate={{
latitude: cache.CacheLatitude,
longitude: cache.CacheLongitude,
}}
title={cache.CacheName}
description={`${cache.CachePoints ?? 0} pts${found ? " · Found" : ""}`}
pinColor={
found ? "#22c55e" : selected ? "#f59e0b" : "#ef4444"
}
onPress={() => setSelectedCacheId(cache.CacheID)}
onCalloutPress={() =>
navigation.navigate("GlobalCacheViewScreen", {
cache,
event: routeEvent,
eventId,
isFound: found,
})
}
/>
);
})}
{(caches || [])
.filter((cache) => !foundCacheIds.includes(String(cache.CacheID)))
.map((cache) => {
const selected = selectedCacheId === cache.CacheID;
return (
<Marker
key={cache.CacheID}
coordinate={{
latitude: cache.CacheLatitude,
longitude: cache.CacheLongitude,
}}
title={cache.CacheName}
description={`${cache.CachePoints ?? 0} pts`}
pinColor={selected ? "#f59e0b" : "#ef4444"}
onPress={() => setSelectedCacheId(cache.CacheID)}
onCalloutPress={() =>
navigation.navigate("GlobalCacheViewScreen", {
cache,
event: routeEvent,
eventId,
isFound: false,
})
}
/>
);
})}
{claimTarget ? (
<Circle
@@ -383,7 +392,9 @@ const GlobalMapScreen = ({ navigation, route }) => {
</MapView>
) : (
<CacheList
caches={caches}
caches={(caches || []).filter(
(cache) => !foundCacheIds.includes(String(cache.CacheID)),
)}
foundCacheIds={foundCacheIds}
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 globalApiConfig from "../components/API/api.json";
import {
clearGlobalDataCache,
clearGlobalDataCacheByPrefix,
getOrFetchGlobalData,
writeGlobalDataCache,
} from "../utils/globalDataCache";
const GLOBAL_BASE = String(
process.env.EXPO_PUBLIC_GLOBAL_API_BASE ||
@@ -24,26 +18,6 @@ const DEFAULT_GLOBAL_PROFILE_IMAGE_URL =
const isGlobalApiReady = () => Boolean(GLOBAL_BASE);
const CACHE_TTL = {
events: 20000,
players: 12000,
caches: 20000,
finds: 8000,
users: 15000,
};
const CACHE_KEY = {
publicEvents: "global:events:public",
users: "global:users",
event: (eventId) => `global:event:${eventId}`,
playersByEvent: (eventId) => `global:players:event:${eventId}`,
cachesByEvent: (eventId) => `global:caches:event:${eventId}`,
cacheById: (cacheId) => `global:cache:${cacheId}`,
findsByEvent: (eventId) => `global:finds:event:${eventId}`,
findsByPlayer: (playerId) => `global:finds:player:${playerId}`,
userById: (userId) => `global:user:${userId}`,
};
// Appends the API key as a query param when provided.
const withKey = (url) => {
if (!GLOBAL_KEY) return url;
@@ -155,56 +129,33 @@ const useGlobalHook = () => {
// Handlers ----------------------------
// Events
const getPublicEvents = useCallback(
async (options = {}) => {
if (!isGlobalApiReady()) return [];
return getOrFetchGlobalData(
CACHE_KEY.publicEvents,
async () => {
const response = await API.get(withKey(endpoints.events));
if (!response.isSuccess) return [];
const rows = unwrapList(response.result);
return rows.filter((e) => e.EventIspublic);
},
{ ttlMs: CACHE_TTL.events, forceRefresh: options.forceRefresh },
);
},
[endpoints.events],
);
const getPublicEvents = useCallback(async () => {
if (!isGlobalApiReady()) return [];
const response = await API.get(withKey(endpoints.events));
if (!response.isSuccess) return [];
const rows = unwrapList(response.result);
return rows.filter((e) => e.EventIspublic);
}, [endpoints.events]);
const getEvent = useCallback(
async (eventId, options = {}) => {
async (eventId) => {
if (!isGlobalApiReady()) return null;
return getOrFetchGlobalData(
CACHE_KEY.event(eventId),
async () => {
const response = await API.get(
withKey(`${endpoints.events}/${eventId}`),
);
if (!response.isSuccess) return null;
return unwrapSingle(response.result);
},
{ ttlMs: CACHE_TTL.events, forceRefresh: options.forceRefresh },
);
const response = await API.get(withKey(`${endpoints.events}/${eventId}`));
if (!response.isSuccess) return null;
return unwrapSingle(response.result);
},
[endpoints.events],
);
// Players
const getPlayersByEvent = useCallback(
async (eventId, options = {}) => {
async (eventId) => {
if (!isGlobalApiReady()) return [];
return getOrFetchGlobalData(
CACHE_KEY.playersByEvent(eventId),
async () => {
const response = await API.get(
withKey(`${endpoints.players}/events/${eventId}`),
);
if (!response.isSuccess) return [];
return unwrapList(response.result);
},
{ ttlMs: CACHE_TTL.players, forceRefresh: options.forceRefresh },
const response = await API.get(
withKey(`${endpoints.players}/events/${eventId}`),
);
if (!response.isSuccess) return [];
return unwrapList(response.result);
},
[endpoints.players],
);
@@ -214,86 +165,55 @@ const useGlobalHook = () => {
if (!isGlobalApiReady()) return null;
const response = await API.post(withKey(endpoints.players), data);
if (!response.isSuccess) return null;
const joined = unwrapSingle(response.result);
const eventId = data?.PlayerEventID ?? joined?.PlayerEventID;
if (eventId !== undefined && eventId !== null) {
clearGlobalDataCache(CACHE_KEY.playersByEvent(eventId));
}
return joined;
return unwrapSingle(response.result);
},
[endpoints.players],
);
// Caches
const getCachesByEvent = useCallback(
async (eventId, options = {}) => {
async (eventId) => {
if (!isGlobalApiReady()) return [];
return getOrFetchGlobalData(
CACHE_KEY.cachesByEvent(eventId),
async () => {
const response = await API.get(
withKey(`${endpoints.caches}/events/${eventId}`),
);
if (!response.isSuccess) return [];
return unwrapList(response.result);
},
{ ttlMs: CACHE_TTL.caches, forceRefresh: options.forceRefresh },
const response = await API.get(
withKey(`${endpoints.caches}/events/${eventId}`),
);
if (!response.isSuccess) return [];
return unwrapList(response.result);
},
[endpoints.caches],
);
const getCache = useCallback(
async (cacheId, options = {}) => {
async (cacheId) => {
if (!isGlobalApiReady()) return null;
return getOrFetchGlobalData(
CACHE_KEY.cacheById(cacheId),
async () => {
const response = await API.get(
withKey(`${endpoints.caches}/${cacheId}`),
);
if (!response.isSuccess) return null;
return unwrapSingle(response.result);
},
{ ttlMs: CACHE_TTL.caches, forceRefresh: options.forceRefresh },
);
const response = await API.get(withKey(`${endpoints.caches}/${cacheId}`));
if (!response.isSuccess) return null;
return unwrapSingle(response.result);
},
[endpoints.caches],
);
// Finds
const getFindsByEvent = useCallback(
async (eventId, options = {}) => {
async (eventId) => {
if (!isGlobalApiReady()) return [];
return getOrFetchGlobalData(
CACHE_KEY.findsByEvent(eventId),
async () => {
const response = await API.get(
withKey(`${endpoints.finds}/events/${eventId}`),
);
if (!response.isSuccess) return [];
return unwrapList(response.result);
},
{ ttlMs: CACHE_TTL.finds, forceRefresh: options.forceRefresh },
const response = await API.get(
withKey(`${endpoints.finds}/events/${eventId}`),
);
if (!response.isSuccess) return [];
return unwrapList(response.result);
},
[endpoints.finds],
);
const getFindsByPlayer = useCallback(
async (playerId, options = {}) => {
async (playerId) => {
if (!isGlobalApiReady()) return [];
return getOrFetchGlobalData(
CACHE_KEY.findsByPlayer(playerId),
async () => {
const response = await API.get(
withKey(`${endpoints.finds}/players/${playerId}`),
);
if (!response.isSuccess) return [];
return unwrapList(response.result);
},
{ ttlMs: CACHE_TTL.finds, forceRefresh: options.forceRefresh },
const response = await API.get(
withKey(`${endpoints.finds}/players/${playerId}`),
);
if (!response.isSuccess) return [];
return unwrapList(response.result);
},
[endpoints.finds],
);
@@ -303,57 +223,53 @@ const useGlobalHook = () => {
if (!isGlobalApiReady()) return null;
const response = await API.post(withKey(endpoints.finds), data);
if (!response.isSuccess) return null;
const find = unwrapSingle(response.result) || { ...data };
if (data?.FindPlayerID !== undefined && data?.FindPlayerID !== null) {
clearGlobalDataCache(CACHE_KEY.findsByPlayer(data.FindPlayerID));
}
// Event ID is not always present in payload, so clear all event-level find caches.
clearGlobalDataCacheByPrefix("global:finds:event:");
return find;
return unwrapSingle(response.result) || { ...data };
},
[endpoints.finds],
);
const claimCache = useCallback(
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],
);
// Users
const getUsers = useCallback(
async (options = {}) => {
if (!isGlobalApiReady()) return [];
return getOrFetchGlobalData(
CACHE_KEY.users,
async () => {
const response = await API.get(withKey(endpoints.users));
if (!response.isSuccess) return [];
return unwrapList(response.result);
},
{ ttlMs: CACHE_TTL.users, forceRefresh: options.forceRefresh },
);
},
[endpoints.users],
);
const getUsers = useCallback(async () => {
if (!isGlobalApiReady()) return [];
const response = await API.get(withKey(endpoints.users));
if (!response.isSuccess) return [];
return unwrapList(response.result);
}, [endpoints.users]);
const getUser = useCallback(
async (userId, options = {}) => {
async (userId) => {
if (!isGlobalApiReady()) return null;
const key = CACHE_KEY.userById(userId);
return getOrFetchGlobalData(
key,
async () => {
// Some global API deployments expose list-only user routes.
const byIdResponse = await API.get(
withKey(`${endpoints.users}/${userId}`),
);
if (byIdResponse.isSuccess) {
const single = unwrapSingle(byIdResponse.result);
if (single) return single;
}
const users = await getUsers({ forceRefresh: options.forceRefresh });
return (
users.find((user) => String(user.UserID) === String(userId)) || null
);
},
{ ttlMs: CACHE_TTL.users, forceRefresh: options.forceRefresh },
// Some global API deployments expose list-only user routes.
const byIdResponse = await API.get(
withKey(`${endpoints.users}/${userId}`),
);
if (byIdResponse.isSuccess) {
const single = unwrapSingle(byIdResponse.result);
if (single) return single;
}
const users = await getUsers();
return (
users.find((user) => String(user.UserID) === String(userId)) || null
);
},
[endpoints.users, getUsers],
@@ -364,17 +280,7 @@ const useGlobalHook = () => {
if (!isGlobalApiReady()) return null;
const response = await API.post(withKey(endpoints.users), payload);
if (!response.isSuccess) return null;
const created = unwrapSingle(response.result);
const createdUserId = created?.UserID ?? payload?.UserID;
if (createdUserId !== undefined && createdUserId !== null) {
writeGlobalDataCache(
CACHE_KEY.userById(createdUserId),
created || payload,
CACHE_TTL.users,
);
}
clearGlobalDataCache(CACHE_KEY.users);
return created;
return unwrapSingle(response.result);
},
[endpoints.users],
);
@@ -387,14 +293,7 @@ const useGlobalHook = () => {
payload,
);
if (!response.isSuccess) return null;
const updated = unwrapSingle(response.result) || payload;
writeGlobalDataCache(
CACHE_KEY.userById(userId),
updated,
CACHE_TTL.users,
);
clearGlobalDataCache(CACHE_KEY.users);
return updated;
return unwrapSingle(response.result) || payload;
},
[endpoints.users],
);
@@ -420,7 +319,7 @@ const useGlobalHook = () => {
if (created) return created;
// Final fallback: check list endpoint again after attempted creation.
const refreshed = await getUsers({ forceRefresh: true });
const refreshed = await getUsers();
return (
refreshed.find(
(user) => String(user.UserUsername) === String(seed.UserUsername),
@@ -446,6 +345,7 @@ const useGlobalHook = () => {
getFindsByEvent,
getFindsByPlayer,
logFind,
claimCache,
getUser,
createUser,
updateUser,