Enhance cache management and UI: implement multi-team cache claiming, add admin waitlist functionality, and improve leaderboard display for better user experience

This commit is contained in:
bobbert committed 2026-04-08 20:42:11 +01:00
1 parent e34cbba9b5
commit 9a9db74369
16 files changed
+636 -129

No files matched your search

+155 -15
View File
@@ -500,10 +500,30 @@ addRoute('POST', '/team-members', async (req, res) => {
const payload = await parseBody(req); const payload = await parseBody(req);
const rows = readJson('team-members.json'); const rows = readJson('team-members.json');
// If a JoinCode was supplied, look up the matching team // If a JoinCode was supplied, check if it matches an admin join code first
if (payload.JoinCode) { if (payload.JoinCode) {
const code = String(payload.JoinCode).toUpperCase();
const groups = readJson('groups.json');
const adminGroup = groups.find((g) => g.AdminJoinCode === code);
if (adminGroup) {
// Add to admin waitlist instead of joining as team member
const waitlist = readJson('admin-waitlist.json');
if (waitlist.some((w) => w.Uid === payload.Uid && w.Gid === adminGroup.Gid)) {
return sendJson(res, 200, { adminWaitlist: true, message: 'Already on waitlist' });
}
const entry = {
id: nextId(waitlist, 'id'),
Uid: payload.Uid,
Gid: adminGroup.Gid,
RequestedAt: new Date().toISOString(),
};
waitlist.push(entry);
writeJson('admin-waitlist.json', waitlist);
return sendJson(res, 200, { adminWaitlist: true, ...entry });
}
const teams = readJson('teams.json'); const teams = readJson('teams.json');
const team = teams.find((t) => t.JoinCode === String(payload.JoinCode).toUpperCase()); const team = teams.find((t) => t.JoinCode === code);
if (!team) return sendJson(res, 400, { message: 'Invalid team join code' }); if (!team) return sendJson(res, 400, { message: 'Invalid team join code' });
payload.Tid = team.Tid; payload.Tid = team.Tid;
} }
@@ -574,6 +594,7 @@ addRoute('DELETE', '/team-members/:id', (req, res, params) => {
addRoute('GET', '/admin-waitlist', (req, res, params, query) => { addRoute('GET', '/admin-waitlist', (req, res, params, query) => {
let rows = readJson('admin-waitlist.json'); let rows = readJson('admin-waitlist.json');
if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid); if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid);
if (query.Uid) rows = rows.filter((r) => String(r.Uid) === query.Uid);
sendJson(res, 200, rows); sendJson(res, 200, rows);
}); });
@@ -595,6 +616,55 @@ addRoute('DELETE', '/admin-waitlist/:id', (req, res, params) => {
sendJson(res, 204, null); sendJson(res, 204, null);
}); });
// Approve an admin from the waitlist
addRoute('POST', '/admin-waitlist/:id/approve', async (req, res, params) => {
const waitlist = readJson('admin-waitlist.json');
const idx = waitlist.findIndex((w) => String(w.id) === params.id);
if (idx === -1) return sendJson(res, 404, { message: 'Waitlist entry not found' });
const entry = waitlist[idx];
waitlist.splice(idx, 1);
writeJson('admin-waitlist.json', waitlist);
// Find the admin subgroup for this game
const subgroups = readJson('subgroups.json');
const adminSg = subgroups.find((sg) => sg.Gid === entry.Gid && sg.IsAdminGroup);
if (!adminSg) return sendJson(res, 400, { message: 'Admin subgroup not found' });
// Create subgroup membership
const memberships = readJson('subgroup-memberships.json');
const membership = {
id: nextId(memberships, 'id'),
Uid: entry.Uid,
Gid: entry.Gid,
SGid: adminSg.SGid,
IsAcceptedAdmin: true,
};
memberships.push(membership);
writeJson('subgroup-memberships.json', memberships);
// Update user record
const users = readJson('users.json');
const uIdx = users.findIndex((u) => u.Uid === entry.Uid);
if (uIdx !== -1) {
users[uIdx].Gid = entry.Gid;
users[uIdx].SGid = adminSg.SGid;
users[uIdx].IsAcceptedAdmin = true;
writeJson('users.json', users);
}
// Add to approved admins list in group
const groups = readJson('groups.json');
const gIdx = groups.findIndex((g) => g.Gid === entry.Gid);
if (gIdx !== -1) {
if (!groups[gIdx].ApprovedAdmins) groups[gIdx].ApprovedAdmins = [];
groups[gIdx].ApprovedAdmins.push(entry.Uid);
writeJson('groups.json', groups);
}
sendJson(res, 200, membership);
});
// ====================================================================== // ======================================================================
// GAME DATA CRUDL // GAME DATA CRUDL
// ====================================================================== // ======================================================================
@@ -615,16 +685,22 @@ addRoute('GET', '/game-data/:gid', (req, res, params) => {
// ====================================================================== // ======================================================================
// Helper: convert storage cache to API response format // Helper: convert storage cache to API response format
const toCacheApi = (cache, gid) => ({ const toCacheApi = (cache, gid) => {
id: cache.CacheId, // Migrate old single-claim format to Claims array
coordinates: { latitude: cache.Latitude, longitude: cache.Longitude }, let claims = cache.Claims || [];
radius: cache.TriggerMeters, if (claims.length === 0 && cache.ClaimedByUid) {
clue: cache.Title, claims = [{ Uid: cache.ClaimedByUid, Tid: cache.ClaimedByTid || null, ClaimedAt: null }];
groupId: Number(gid), }
subgroupId: cache.SGid, return {
ClaimedByUid: cache.ClaimedByUid || null, id: cache.CacheId,
ClaimedByTid: cache.ClaimedByTid || null, coordinates: { latitude: cache.Latitude, longitude: cache.Longitude },
}); radius: cache.TriggerMeters,
clue: cache.Title,
groupId: Number(gid),
subgroupId: cache.SGid,
Claims: claims,
};
};
// Helper: convert API payload to storage cache format // Helper: convert API payload to storage cache format
const toCacheStorage = (data, cacheId) => ({ const toCacheStorage = (data, cacheId) => ({
@@ -634,8 +710,6 @@ const toCacheStorage = (data, cacheId) => ({
Longitude: data.longitude != null ? data.longitude : (data.Longitude || 0), Longitude: data.longitude != null ? data.longitude : (data.Longitude || 0),
TriggerMeters: data.radius || data.TriggerMeters || 20, TriggerMeters: data.radius || data.TriggerMeters || 20,
SGid: data.subgroupId != null ? data.subgroupId : (data.SGid || null), SGid: data.subgroupId != null ? data.subgroupId : (data.SGid || null),
ClaimedByUid: data.ClaimedByUid || null,
ClaimedByTid: data.ClaimedByTid || null,
}); });
// GET /game-data/:gid/caches — list caches for a group // GET /game-data/:gid/caches — list caches for a group
@@ -674,7 +748,7 @@ addRoute('POST', '/game-data/:gid/caches', async (req, res, params) => {
const nextNum = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1; const nextNum = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1;
const cacheId = `C${nextNum}`; const cacheId = `C${nextNum}`;
const stored = toCacheStorage(payload, cacheId); const stored = { ...toCacheStorage(payload, cacheId), Claims: [] };
if (!entry.GeoCaches) entry.GeoCaches = []; if (!entry.GeoCaches) entry.GeoCaches = [];
entry.GeoCaches.push(stored); entry.GeoCaches.push(stored);
writeJson('game-data.json', rows); writeJson('game-data.json', rows);
@@ -693,6 +767,8 @@ addRoute('PUT', '/game-data/:gid/caches/:cacheId', async (req, res, params) => {
const existing = entry.GeoCaches[idx]; const existing = entry.GeoCaches[idx];
const updated = { ...existing, ...toCacheStorage(payload, params.cacheId) }; const updated = { ...existing, ...toCacheStorage(payload, params.cacheId) };
// Preserve claims — property updates must not overwrite the Claims array
updated.Claims = existing.Claims || [];
entry.GeoCaches[idx] = updated; entry.GeoCaches[idx] = updated;
writeJson('game-data.json', rows); writeJson('game-data.json', rows);
sendJson(res, 200, toCacheApi(updated, params.gid)); sendJson(res, 200, toCacheApi(updated, params.gid));
@@ -712,6 +788,70 @@ addRoute('DELETE', '/game-data/:gid/caches/:cacheId', (req, res, params) => {
sendJson(res, 204, null); sendJson(res, 204, null);
}); });
// POST /game-data/:gid/caches/:cacheId/claim — claim a cache (multi-team)
addRoute('POST', '/game-data/:gid/caches/:cacheId/claim', async (req, res, params) => {
const payload = await parseBody(req);
const rows = readJson('game-data.json');
const entry = rows.find((r) => String(r.Gid) === params.gid);
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
const cache = (entry.GeoCaches || []).find((c) => c.CacheId === params.cacheId);
if (!cache) return sendJson(res, 404, { message: 'Cache not found' });
if (!cache.Claims) cache.Claims = [];
// Prevent the same team (or individual if no team) from claiming twice
if (payload.Tid && cache.Claims.some((c) => c.Tid === payload.Tid)) {
return sendJson(res, 400, { message: 'Your team has already claimed this cache' });
}
if (!payload.Tid && cache.Claims.some((c) => c.Uid === payload.Uid && !c.Tid)) {
return sendJson(res, 400, { message: 'You have already claimed this cache' });
}
cache.Claims.push({
Uid: payload.Uid,
Tid: payload.Tid || null,
ClaimedAt: new Date().toISOString(),
});
writeJson('game-data.json', rows);
sendJson(res, 200, toCacheApi(cache, params.gid));
});
// POST /game-data/:gid/reset — reset all cache claims for a game
addRoute('POST', '/game-data/:gid/reset', async (req, res, params) => {
const rows = readJson('game-data.json');
const entry = rows.find((r) => String(r.Gid) === params.gid);
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
for (const cache of (entry.GeoCaches || [])) {
cache.Claims = [];
// Clean up legacy fields
delete cache.ClaimedByUid;
delete cache.ClaimedByTid;
}
writeJson('game-data.json', rows);
sendJson(res, 200, { message: 'Game reset successfully' });
});
// POST /game-data/:gid/reset-player/:uid — remove all claims by a specific user
addRoute('POST', '/game-data/:gid/reset-player/:uid', async (req, res, params) => {
const rows = readJson('game-data.json');
const entry = rows.find((r) => String(r.Gid) === params.gid);
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
const uid = Number(params.uid);
for (const cache of (entry.GeoCaches || [])) {
if (cache.Claims) {
cache.Claims = cache.Claims.filter((c) => c.Uid !== uid);
}
}
writeJson('game-data.json', rows);
sendJson(res, 200, { message: 'Player progress reset' });
});
// ====================================================================== // ======================================================================
// HEALTH CHECK // HEALTH CHECK
// ====================================================================== // ======================================================================
+21 -5
View File
@@ -2,12 +2,12 @@ import {StyleSheet, Text, View} from 'react-native';
import Card from '../UI/Card'; import Card from '../UI/Card';
import {Button} from '../UI/Button'; import {Button} from '../UI/Button';
const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => { const CacheCardItem = ({cache, isAdmin, isSelected, onEdit, onDelete, onSelect}) => {
// Initialisation ------------- // Initialisation -------------
const isClaimed = Boolean(cache.ClaimedByUid); const hasClaims = (cache.Claims || []).length > 0;
const statusText = isClaimed ? 'Claimed' : 'Available'; const statusText = hasClaims ? 'Claimed' : 'Available';
const statusColor = isClaimed ? '#9ca3af' : '#16a34a'; const statusColor = hasClaims ? '#9ca3af' : '#16a34a';
const displayName = cache.name || cache.clue || 'Unnamed Cache'; const displayName = cache.name || cache.clue || 'Unnamed Cache';
// State ---------------------- // State ----------------------
@@ -15,7 +15,7 @@ const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => {
// View ----------------------- // View -----------------------
return ( return (
<Card> <Card style={isSelected ? styles.selectedCard : null}>
<View style={styles.topRow}> <View style={styles.topRow}>
<View style={styles.nameWrap}> <View style={styles.nameWrap}>
<Text style={styles.name} numberOfLines={1}>{displayName}</Text> <Text style={styles.name} numberOfLines={1}>{displayName}</Text>
@@ -41,6 +41,13 @@ const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => {
styleLabel={styles.btnLabel} styleLabel={styles.btnLabel}
/> />
</> </>
) : isSelected ? (
<Button
label="Cache Currently Selected"
onClick={() => {}}
styleButton={styles.selectedBtn}
styleLabel={styles.selectedBtnLabel}
/>
) : ( ) : (
<Button <Button
label="Select Cache" label="Select Cache"
@@ -87,6 +94,15 @@ const styles = StyleSheet.create({
paddingHorizontal: 14, paddingHorizontal: 14,
flex: 1, flex: 1,
}, },
selectedBtn: {
backgroundColor: '#6b7280',
borderColor: '#6b7280',
minHeight: 36,
paddingHorizontal: 14,
flex: 1,
},
selectedBtnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
selectedCard: {borderColor: '#2563eb', borderWidth: 2},
btnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13}, btnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
}); });
+32 -7
View File
@@ -1,30 +1,42 @@
import React, {useEffect, useState} from 'react'; import React, {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 = ({ cache, onClaimSuccess, isClaiming }) => { const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming, showClaimedPopup }) => {
// Initialisation ------------- // Initialisation -------------
// State ---------------------- // State ----------------------
const [timeLeft, setTimeLeft] = useState(CACHE_CLAIM_TIMER); const [timeLeft, setTimeLeft] = useState(CACHE_CLAIM_TIMER);
// Use a ref for the callback so it does not appear in the dependency array
const onClaimRef = useRef(onClaimSuccess);
onClaimRef.current = onClaimSuccess;
// Handlers ------------------- // Handlers -------------------
useEffect(() => { useEffect(() => {
let timer; let timer;
if (isClaiming && timeLeft > 0) { if (isClaiming && cache && timeLeft > 0) {
timer = setTimeout(() => setTimeLeft((prev) => prev - 1), 1000); timer = setTimeout(() => setTimeLeft((prev) => prev - 1), 1000);
} else if (isClaiming && timeLeft === 0 && cache) { } else if (isClaiming && timeLeft === 0 && cache) {
onClaimSuccess(cache.id); onClaimRef.current(cache.id);
} else { } else if (!isClaiming) {
setTimeLeft(CACHE_CLAIM_TIMER); setTimeLeft(CACHE_CLAIM_TIMER);
} }
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [isClaiming, timeLeft, cache, onClaimSuccess]); }, [isClaiming, timeLeft, cache]);
// View ----------------------- // View -----------------------
if (!isClaiming) { // Show "Cache Claimed!" popup after a successful claim
if (showClaimedPopup) {
return (
<View style={styles.claimedOverlay}>
<Text style={styles.claimedText}>Cache Claimed!</Text>
</View>
);
}
if (!isClaiming || !cache) {
return null; return null;
} }
@@ -49,6 +61,19 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
fontWeight: '700', fontWeight: '700',
}, },
claimedOverlay: {
alignSelf: 'center',
backgroundColor: '#16a34a',
paddingHorizontal: 20,
paddingVertical: 14,
borderRadius: 10,
marginTop: 8,
},
claimedText: {
color: '#ffffff',
fontSize: 18,
fontWeight: '700',
},
}); });
export default ClaimTimerView; export default ClaimTimerView;
+19 -10
View File
@@ -1,7 +1,8 @@
import MapView, {Circle, Polygon} from 'react-native-maps'; import React from 'react';
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
import {getFovCone} from '../../utils/geoMath'; import {getFovCone} from '../../utils/geoMath';
const PlayerMapView = ({userLocation, visibleCache, heading, claimDistance}) => { const PlayerMapView = ({userLocation, visibleCaches, heading, claimDistance}) => {
// Initialisation ------------- // Initialisation -------------
// State ---------------------- // State ----------------------
// Handlers ------------------- // Handlers -------------------
@@ -23,14 +24,22 @@ const PlayerMapView = ({userLocation, visibleCache, heading, claimDistance}) =>
pitchEnabled={false} pitchEnabled={false}
showsUserLocation showsUserLocation
> >
{visibleCache && ( {/* Show cache markers for all caches currently inside the FOV cone */}
<Circle {(visibleCaches || []).map((cache) => (
center={visibleCache.coordinates} <React.Fragment key={cache.id}>
radius={claimDistance} <Marker
fillColor="rgba(250, 204, 21, 0.20)" coordinate={cache.coordinates}
strokeColor="rgba(250, 204, 21, 0.90)" title={cache.clue}
/> pinColor="#facc15"
)} />
<Circle
center={cache.coordinates}
radius={claimDistance}
fillColor="rgba(250, 204, 21, 0.20)"
strokeColor="rgba(250, 204, 21, 0.90)"
/>
</React.Fragment>
))}
{coneCoords && ( {coneCoords && (
<Polygon <Polygon
coordinates={coneCoords} coordinates={coneCoords}
+1
View File
@@ -81,6 +81,7 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-around', justifyContent: 'space-around',
paddingHorizontal: 8, paddingHorizontal: 8,
zIndex: 10,
}, },
tab: { tab: {
flex: 1, flex: 1,
+2
View File
@@ -48,6 +48,8 @@ const styles = StyleSheet.create({
content: { content: {
flex: 1, flex: 1,
padding: 15, padding: 15,
zIndex: 1,
overflow: "hidden",
}, },
}); });
+1
View File
@@ -53,6 +53,7 @@ const styles = StyleSheet.create({
justifyContent: 'space-between', justifyContent: 'space-between',
paddingHorizontal: 12, paddingHorizontal: 12,
backgroundColor: '#000000', backgroundColor: '#000000',
zIndex: 10,
}, },
leftSlot: { leftSlot: {
width: 72, width: 72,
+23 -2
View File
@@ -3,7 +3,7 @@ import {StyleSheet, 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 {getFovCone} from '../../utils/geoMath'; import {getFovCone, isInClaimCone} from '../../utils/geoMath';
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01}; const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.01, longitudeDelta: 0.01};
@@ -70,6 +70,11 @@ const ExpandedMapScreen = ({route}) => {
? getFovCone(userLocation, heading) ? getFovCone(userLocation, heading)
: null; : null;
// For players, show caches that currently fall within the FOV cone
const visiblePlayerCaches = (!isAdmin && userLocation && heading !== null)
? caches.filter((c) => isInClaimCone(heading, userLocation, c.coordinates, claimDistance))
: [];
return ( return (
<Screen showBack={true} style={styles.container}> <Screen showBack={true} style={styles.container}>
<View style={styles.mapWrap}> <View style={styles.mapWrap}>
@@ -78,7 +83,7 @@ const ExpandedMapScreen = ({route}) => {
initialRegion={region} initialRegion={region}
showsUserLocation showsUserLocation
> >
{/* Only admins see cache pin locations */} {/* Admins see all cache pin locations */}
{isAdmin && caches.map((cache) => ( {isAdmin && caches.map((cache) => (
<React.Fragment key={cache.id}> <React.Fragment key={cache.id}>
<Marker <Marker
@@ -94,6 +99,22 @@ const ExpandedMapScreen = ({route}) => {
/> />
</React.Fragment> </React.Fragment>
))} ))}
{/* Players only see caches that are inside the FOV cone */}
{!isAdmin && visiblePlayerCaches.map((cache) => (
<React.Fragment key={cache.id}>
<Marker
coordinate={cache.coordinates}
title={cache.clue}
pinColor="#facc15"
/>
<Circle
center={cache.coordinates}
radius={claimDistance}
fillColor="rgba(250, 204, 21, 0.20)"
strokeColor="rgba(250, 204, 21, 0.90)"
/>
</React.Fragment>
))}
{/* Heading FOV cone */} {/* Heading FOV cone */}
{coneCoords && ( {coneCoords && (
<Polygon <Polygon
+99 -2
View File
@@ -1,6 +1,7 @@
import React, {useEffect, useState} from 'react'; import React, {useEffect, useState} from 'react';
import {ActivityIndicator, ScrollView, StyleSheet, Switch, Text, TextInput, View} from 'react-native'; import {ActivityIndicator, Alert, ScrollView, StyleSheet, Switch, Text, TextInput, View} from 'react-native';
import Screen from '../layout/Screen'; import Screen from '../layout/Screen';
import Card from '../UI/Card';
import {Button, ButtonTray} from '../UI/Button'; import {Button, ButtonTray} from '../UI/Button';
import useGameHook from '../../hooks/useGameHook'; import useGameHook from '../../hooks/useGameHook';
import {getSession, setSessionTeamsEnabled} from '../../hooks/SessionStore'; import {getSession, setSessionTeamsEnabled} from '../../hooks/SessionStore';
@@ -9,7 +10,7 @@ const GameSettingsScreen = () => {
// Initialisation ------------ // Initialisation ------------
const session = getSession(); const session = getSession();
const {getLobby, updateGroup, getSubgroups} = useGameHook(); const {getLobby, updateGroup, getSubgroups, resetGame, getAdminWaitlist, approveAdmin, rejectAdmin, getUser} = useGameHook();
const isBusiness = Boolean(session.isBusiness); const isBusiness = Boolean(session.isBusiness);
// State ---------------------- // State ----------------------
@@ -22,9 +23,23 @@ const GameSettingsScreen = () => {
const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20'); const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
const [adminJoinCode, setAdminJoinCode] = useState(''); const [adminJoinCode, setAdminJoinCode] = useState('');
const [memberJoinCode, setMemberJoinCode] = useState(''); const [memberJoinCode, setMemberJoinCode] = useState('');
const [waitlist, setWaitlist] = useState([]);
const [waitlistNames, setWaitlistNames] = useState({});
// Handlers ------------------- // Handlers -------------------
const loadWaitlist = async () => {
if (!session.currentGid) return;
const rows = await getAdminWaitlist(session.currentGid);
setWaitlist(rows || []);
const names = {};
for (const entry of (rows || [])) {
const u = await getUser(entry.Uid);
if (u) names[entry.Uid] = u.username;
}
setWaitlistNames(names);
};
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
if (!session.currentGid) { if (!session.currentGid) {
@@ -45,6 +60,7 @@ const GameSettingsScreen = () => {
const sgs = await getSubgroups(session.currentGid); const sgs = await getSubgroups(session.currentGid);
const memberSg = (sgs || []).find((sg) => !sg.IsAdminGroup); const memberSg = (sgs || []).find((sg) => !sg.IsAdminGroup);
if (memberSg) setMemberJoinCode(memberSg.JoinCode || ''); if (memberSg) setMemberJoinCode(memberSg.JoinCode || '');
await loadWaitlist();
setLoading(false); setLoading(false);
}; };
load(); load();
@@ -64,6 +80,30 @@ const GameSettingsScreen = () => {
setSaving(false); setSaving(false);
}; };
const handleResetGame = () => {
Alert.alert('Reset Entire Game', 'This will clear ALL cache claims for every player. Are you sure?', [
{text: 'Cancel', style: 'cancel'},
{
text: 'Reset',
style: 'destructive',
onPress: async () => {
await resetGame(session.currentGid);
Alert.alert('Done', 'All cache claims have been reset.');
},
},
]);
};
const handleApproveAdmin = async (entry) => {
await approveAdmin(entry.id);
await loadWaitlist();
};
const handleRejectAdmin = async (entry) => {
await rejectAdmin(entry.id);
await loadWaitlist();
};
// View ----------------------- // View -----------------------
if (loading) { if (loading) {
@@ -150,6 +190,46 @@ const GameSettingsScreen = () => {
/> />
</ButtonTray> </ButtonTray>
</View> </View>
{/* Admin Waitlist */}
{waitlist.length > 0 && (
<View style={styles.waitlistSection}>
<Text style={styles.sectionTitle}>Admin Waitlist</Text>
{waitlist.map((entry) => (
<Card key={entry.id}>
<View style={styles.waitlistRow}>
<Text style={styles.waitlistName}>
{waitlistNames[entry.Uid] || `User ${entry.Uid}`}
</Text>
<View style={styles.waitlistActions}>
<Button
label="Approve"
onClick={() => handleApproveAdmin(entry)}
styleButton={styles.approveButton}
styleLabel={styles.approveBtnLabel}
/>
<Button
label="Reject"
onClick={() => handleRejectAdmin(entry)}
styleButton={styles.rejectButton}
styleLabel={styles.rejectBtnLabel}
/>
</View>
</View>
</Card>
))}
</View>
)}
{/* Reset Entire Game */}
<View style={styles.resetSection}>
<Button
label="Reset Entire Game"
onClick={handleResetGame}
styleButton={styles.resetButton}
styleLabel={styles.resetLabel}
/>
</View>
</ScrollView> </ScrollView>
</Screen> </Screen>
); );
@@ -196,6 +276,23 @@ const styles = StyleSheet.create({
saveWrap: {marginTop: 24}, saveWrap: {marginTop: 24},
saveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'}, saveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
saveLabel: {color: '#ffffff', fontWeight: '600'}, saveLabel: {color: '#ffffff', fontWeight: '600'},
// Admin waitlist styles
waitlistSection: {marginTop: 30},
waitlistRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
waitlistName: {fontSize: 15, fontWeight: '600', color: '#1f2937', flex: 1},
waitlistActions: {flexDirection: 'row', gap: 6},
approveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a', minHeight: 36, flex: 0, paddingHorizontal: 10},
approveBtnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
rejectButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 0, paddingHorizontal: 10},
rejectBtnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
// Reset game styles
resetSection: {marginTop: 30},
resetButton: {backgroundColor: '#dc2626', borderColor: '#dc2626'},
resetLabel: {color: '#ffffff', fontWeight: '600'},
}); });
export default GameSettingsScreen; export default GameSettingsScreen;
+96 -52
View File
@@ -9,15 +9,17 @@ const LeaderboardScreen = () => {
// Initialisation ------------ // Initialisation ------------
const session = getSession(); const session = getSession();
const isAdmin = session.isAcceptedAdmin;
const {getLobby, getTeams, getTeamMembers, getGroupMembers, getCaches, getUser} = useGameHook(); const {getLobby, getTeams, getTeamMembers, getGroupMembers, getCaches, getUser} = useGameHook();
// State ---------------------- // State ----------------------
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [teamsEnabled, setTeamsEnabled] = useState(false); const [teamsEnabled, setTeamsEnabled] = useState(false);
const [activeTab, setActiveTab] = useState('global'); const [activeTab, setActiveTab] = useState('game');
const [globalRanking, setGlobalRanking] = useState([]); const [teamRankings, setTeamRankings] = useState([]);
const [teamRanking, setTeamRanking] = useState([]); const [playerRankings, setPlayerRankings] = useState([]);
const [myTeamRanking, setMyTeamRanking] = useState([]);
const [expandedTeam, setExpandedTeam] = useState(null); const [expandedTeam, setExpandedTeam] = useState(null);
// Handlers ------------------- // Handlers -------------------
@@ -30,7 +32,6 @@ const LeaderboardScreen = () => {
setTeamsEnabled(isTeams); setTeamsEnabled(isTeams);
const caches = await getCaches(session.currentGid, null); const caches = await getCaches(session.currentGid, null);
const claimedCaches = (caches || []).filter((c) => c.ClaimedByUid);
if (isTeams) { if (isTeams) {
// Build team rankings // Build team rankings
@@ -42,7 +43,9 @@ const LeaderboardScreen = () => {
const memberData = []; const memberData = [];
for (const m of (members || [])) { for (const m of (members || [])) {
const user = await getUser(m.Uid); const user = await getUser(m.Uid);
const count = claimedCaches.filter((c) => c.ClaimedByUid === m.Uid).length; const count = (caches || []).filter((c) =>
(c.Claims || []).some((cl) => cl.Uid === m.Uid)
).length;
memberData.push({ memberData.push({
uid: m.Uid, uid: m.Uid,
name: user?.username || `Player ${m.Uid}`, name: user?.username || `Player ${m.Uid}`,
@@ -51,7 +54,9 @@ const LeaderboardScreen = () => {
}); });
} }
memberData.sort((a, b) => b.caches - a.caches); memberData.sort((a, b) => b.caches - a.caches);
const totalCaches = memberData.reduce((sum, m) => sum + m.caches, 0); const totalCaches = (caches || []).filter((c) =>
(c.Claims || []).some((cl) => cl.Tid === team.Tid)
).length;
teamData.push({ teamData.push({
tid: team.Tid, tid: team.Tid,
name: team.TeamName || 'Unnamed Team', name: team.TeamName || 'Unnamed Team',
@@ -62,20 +67,22 @@ const LeaderboardScreen = () => {
} }
teamData.sort((a, b) => b.totalCaches - a.totalCaches); teamData.sort((a, b) => b.totalCaches - a.totalCaches);
setGlobalRanking(teamData); setTeamRankings(teamData);
// Build current team ranking // Build current user's team ranking (for TEAM tab)
if (session.currentTid) { if (session.currentTid) {
const myTeam = teamData.find((t) => t.tid === session.currentTid); const myTeam = teamData.find((t) => t.tid === session.currentTid);
setTeamRanking(myTeam ? myTeam.members : []); setMyTeamRanking(myTeam ? myTeam.members : []);
} }
} else { } else {
// Build player rankings (no teams) // Build player rankings (no teams)
const allMembers = await getGroupMembers(session.currentGid); const allMembers = await getGroupMembers(session.currentGid);
const playerData = []; const playerData = [];
for (const m of (allMembers || []).filter((m) => !m.IsAcceptedAdmin)) { for (const m of (allMembers || []).filter((mem) => !mem.IsAcceptedAdmin)) {
const user = await getUser(m.Uid); const user = await getUser(m.Uid);
const count = claimedCaches.filter((c) => c.ClaimedByUid === m.Uid).length; const count = (caches || []).filter((c) =>
(c.Claims || []).some((cl) => cl.Uid === m.Uid)
).length;
playerData.push({ playerData.push({
uid: m.Uid, uid: m.Uid,
name: user?.username || `Player ${m.Uid}`, name: user?.username || `Player ${m.Uid}`,
@@ -83,7 +90,7 @@ const LeaderboardScreen = () => {
}); });
} }
playerData.sort((a, b) => b.caches - a.caches); playerData.sort((a, b) => b.caches - a.caches);
setGlobalRanking(playerData); setPlayerRankings(playerData);
} }
setLoading(false); setLoading(false);
@@ -109,64 +116,109 @@ const LeaderboardScreen = () => {
); );
} }
// Teams enabled — tabbed view // Helper: render an expandable team card
const renderTeamCard = (team, index) => (
<Pressable key={team.tid} onPress={() => setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}>
<Card>
<View style={styles.rankRow}>
<Text style={styles.rank}>#{index + 1}</Text>
<Text style={styles.rankName}>{team.name}</Text>
<Text style={styles.score}>{team.totalCaches} cache(s)</Text>
</View>
{expandedTeam === team.tid && team.members.map((m) => (
<View key={m.uid} style={styles.subRow}>
<Text style={styles.subName}>
{m.name}{m.isLeader ? ' (Team Leader)' : ''}
</Text>
<Text style={styles.subScore}>{m.caches}</Text>
</View>
))}
</Card>
</Pressable>
);
// Helper: render a simple player card
const renderPlayerCard = (player, index) => (
<Card key={player.uid}>
<View style={styles.rankRow}>
<Text style={styles.rank}>#{index + 1}</Text>
<Text style={styles.rankName}>{player.name}</Text>
<Text style={styles.score}>{player.caches} cache(s)</Text>
</View>
</Card>
);
// ---------- ADMIN LEADERBOARD ----------
if (isAdmin) {
// Teams enabled: show team list (no tabs for admin)
if (teamsEnabled) {
return (
<Screen style={styles.container}>
<ScrollView style={styles.listSection}>
{teamRankings.map((team, index) => renderTeamCard(team, index))}
{teamRankings.length === 0 && (
<Text style={styles.emptyText}>No teams yet.</Text>
)}
</ScrollView>
</Screen>
);
}
// Teams disabled: show player list
return (
<Screen style={styles.container}>
<ScrollView style={styles.listSection}>
{playerRankings.map((player, index) => renderPlayerCard(player, index))}
{playerRankings.length === 0 && (
<Text style={styles.emptyText}>No players yet.</Text>
)}
</ScrollView>
</Screen>
);
}
// ---------- PLAYER LEADERBOARD ----------
// Teams enabled — tabbed view (GAME / TEAM)
if (teamsEnabled) { if (teamsEnabled) {
return ( return (
<Screen style={styles.container}> <Screen style={styles.container}>
<View style={styles.tabRow}> <View style={styles.tabRow}>
<Pressable <Pressable
style={[styles.tabButton, activeTab === 'global' && styles.tabButtonActive]} style={[styles.tabButton, activeTab === 'game' && styles.tabButtonActive]}
onPress={() => setActiveTab('global')} onPress={() => setActiveTab('game')}
> >
<Text style={[styles.tabLabel, activeTab === 'global' && styles.tabLabelActive]}>Global</Text> <Text style={[styles.tabLabel, activeTab === 'game' && styles.tabLabelActive]}>GAME</Text>
</Pressable> </Pressable>
<Pressable <Pressable
style={[styles.tabButton, activeTab === 'team' && styles.tabButtonActive]} style={[styles.tabButton, activeTab === 'team' && styles.tabButtonActive]}
onPress={() => setActiveTab('team')} onPress={() => setActiveTab('team')}
> >
<Text style={[styles.tabLabel, activeTab === 'team' && styles.tabLabelActive]}>Team</Text> <Text style={[styles.tabLabel, activeTab === 'team' && styles.tabLabelActive]}>TEAM</Text>
</Pressable> </Pressable>
</View> </View>
{activeTab === 'global' ? ( {activeTab === 'game' ? (
<ScrollView style={styles.listSection}> <ScrollView style={styles.listSection}>
{globalRanking.map((team, index) => ( {teamRankings.map((team, index) => renderTeamCard(team, index))}
<Pressable key={team.tid} onPress={() => setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}> {teamRankings.length === 0 && (
<Card>
<View style={styles.rankRow}>
<Text style={styles.rank}>#{index + 1}</Text>
<Text style={styles.rankName}>{team.name}</Text>
<Text style={styles.score}>{team.totalCaches} cache(s)</Text>
</View>
{expandedTeam === team.tid && team.members.map((m) => (
<View key={m.uid} style={styles.subRow}>
<Text style={styles.subName}>
{m.name}{m.isLeader ? ' (Leader)' : ''}
</Text>
<Text style={styles.subScore}>{m.caches}</Text>
</View>
))}
</Card>
</Pressable>
))}
{globalRanking.length === 0 && (
<Text style={styles.emptyText}>No teams yet.</Text> <Text style={styles.emptyText}>No teams yet.</Text>
)} )}
</ScrollView> </ScrollView>
) : ( ) : (
<ScrollView style={styles.listSection}> <ScrollView style={styles.listSection}>
{teamRanking.map((m, index) => ( {myTeamRanking.map((m, index) => (
<Card key={m.uid}> <Card key={m.uid}>
<View style={styles.rankRow}> <View style={styles.rankRow}>
<Text style={styles.rank}>#{index + 1}</Text> <Text style={styles.rank}>#{index + 1}</Text>
<Text style={styles.rankName}> <Text style={styles.rankName}>
{m.name}{m.isLeader ? ' (Leader)' : ''} {m.name}{m.isLeader ? ' (Team Leader)' : ''}
</Text> </Text>
<Text style={styles.score}>{m.caches} cache(s)</Text> <Text style={styles.score}>{m.caches} cache(s)</Text>
</View> </View>
</Card> </Card>
))} ))}
{teamRanking.length === 0 && ( {myTeamRanking.length === 0 && (
<Text style={styles.emptyText}>Join a team to see your team leaderboard.</Text> <Text style={styles.emptyText}>Join a team to see your team leaderboard.</Text>
)} )}
</ScrollView> </ScrollView>
@@ -175,20 +227,12 @@ const LeaderboardScreen = () => {
); );
} }
// No teams — simple player ranking // No teams — simple player ranking (GAME tab only, no TEAM tab)
return ( return (
<Screen style={styles.container}> <Screen style={styles.container}>
<ScrollView style={styles.listSection}> <ScrollView style={styles.listSection}>
{globalRanking.map((player, index) => ( {playerRankings.map((player, index) => renderPlayerCard(player, index))}
<Card key={player.uid}> {playerRankings.length === 0 && (
<View style={styles.rankRow}>
<Text style={styles.rank}>#{index + 1}</Text>
<Text style={styles.rankName}>{player.name}</Text>
<Text style={styles.score}>{player.caches} cache(s)</Text>
</View>
</Card>
))}
{globalRanking.length === 0 && (
<Text style={styles.emptyText}>No players yet.</Text> <Text style={styles.emptyText}>No players yet.</Text>
)} )}
</ScrollView> </ScrollView>
+49 -13
View File
@@ -1,4 +1,4 @@
import React, {useCallback, useEffect, useState} from 'react'; import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native'; import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, 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';
@@ -9,7 +9,7 @@ import ClaimTimerView from '../gameplay/ClaimTimerView';
import PlayerMapView from '../gameplay/PlayerMapView'; import PlayerMapView from '../gameplay/PlayerMapView';
import useGameHook from '../../hooks/useGameHook'; import useGameHook from '../../hooks/useGameHook';
import usePlayerGame from '../../hooks/usePlayerGame'; import usePlayerGame from '../../hooks/usePlayerGame';
import {getSession, setSessionGroup, setSessionUser} from '../../hooks/SessionStore'; import {getSession, setSelectedCache, setSessionGroup, setSessionUser} from '../../hooks/SessionStore';
import {getFovCone} from '../../utils/geoMath'; import {getFovCone} from '../../utils/geoMath';
const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.05, longitudeDelta: 0.05}; const DEFAULT_REGION = {latitude: 51.5074, longitude: -0.1278, latitudeDelta: 0.05, longitudeDelta: 0.05};
@@ -32,6 +32,8 @@ const MapScreen = ({navigation}) => {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [gameCode, setGameCode] = useState(''); const [gameCode, setGameCode] = useState('');
const [groupInfo, setGroupInfo] = useState(null); const [groupInfo, setGroupInfo] = useState(null);
const [selectedCacheId, setSelectedCacheIdState] = useState(session.selectedCacheId);
const [claimedPopupVisible, setClaimedPopupVisible] = useState(false);
// Admin cache creation / editing state // Admin cache creation / editing state
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
@@ -44,10 +46,23 @@ const MapScreen = ({navigation}) => {
const claimDistance = groupInfo?.CacheTriggerMeters || 20; const claimDistance = groupInfo?.CacheTriggerMeters || 20;
const isPlayer = inGame && !isAdmin; const isPlayer = inGame && !isAdmin;
const {visibleCache, isClaiming, setIsClaiming} = usePlayerGame(
// Filter out caches already claimed by the current user's team (or by the user if no team)
const activeCachesForPlayer = useMemo(() => {
if (!isPlayer) return [];
return cacheRecords.filter((cache) => {
const claims = cache.Claims || [];
if (session.currentTid) {
return !claims.some((c) => c.Tid === session.currentTid);
}
return !claims.some((c) => c.Uid === session.currentUid);
});
}, [cacheRecords, isPlayer, session.currentTid, session.currentUid]);
const {visibleCaches, isClaiming, setIsClaiming} = usePlayerGame(
isPlayer ? userLocation : null, isPlayer ? userLocation : null,
isPlayer ? heading : null, isPlayer ? heading : null,
isPlayer ? cacheRecords : [], isPlayer ? activeCachesForPlayer : [],
claimDistance, claimDistance,
); );
@@ -73,6 +88,17 @@ const MapScreen = ({navigation}) => {
useEffect(() => { loadCaches(); }, [loadCaches]); useEffect(() => { loadCaches(); }, [loadCaches]);
// Auto-select the first cache for the player when caches load and none is selected
useEffect(() => {
if (!isPlayer || cacheRecords.length === 0) return;
// If persisted selection still exists in the list, keep it
if (selectedCacheId && cacheRecords.some((c) => c.id === selectedCacheId)) return;
// Otherwise auto-select the first cache
const firstId = cacheRecords[0].id;
setSelectedCacheIdState(firstId);
setSelectedCache(firstId);
}, [cacheRecords, isPlayer]);
// Location tracking // Location tracking
useEffect(() => { useEffect(() => {
if (!inGame) return; if (!inGame) return;
@@ -118,6 +144,7 @@ const MapScreen = ({navigation}) => {
const handleJoinGame = async () => { const handleJoinGame = async () => {
if (!gameCode.trim()) return; if (!gameCode.trim()) return;
// Force uppercase just in case a lowercase code is pasted
const result = await joinPrivateGame({JoinCode: gameCode.trim().toUpperCase(), Uid: session.currentUid}); const result = await joinPrivateGame({JoinCode: gameCode.trim().toUpperCase(), Uid: session.currentUid});
if (!result) return; if (!result) return;
setSessionGroup(result.Gid, result.SGid); setSessionGroup(result.Gid, result.SGid);
@@ -143,17 +170,22 @@ const MapScreen = ({navigation}) => {
} }
}; };
const handleClaim = async (cacheId) => { const handleClaim = useCallback(async (cacheId) => {
if (!session.currentGid) return; if (!session.currentGid) return;
await claimCache({ const result = await claimCache({
gid: session.currentGid, gid: session.currentGid,
cacheId, cacheId,
uid: session.currentUid, uid: session.currentUid,
tid: session.currentTid, tid: session.currentTid,
}); });
setIsClaiming(false); setIsClaiming(false);
if (result) {
// Show "Cache Claimed!" popup for 3 seconds
setClaimedPopupVisible(true);
setTimeout(() => setClaimedPopupVisible(false), 3000);
}
await loadCaches(); await loadCaches();
}; }, [session.currentGid, session.currentUid, session.currentTid, loadCaches]);
// Admin — open create form // Admin — open create form
const handleCreateCachePress = () => { const handleCreateCachePress = () => {
@@ -215,9 +247,10 @@ const MapScreen = ({navigation}) => {
}); });
}; };
// Player — select cache // Player — select cache (persist selection)
const handleSelectCache = (cache) => { const handleSelectCache = (cache) => {
// Future: scroll map to cache location setSelectedCacheIdState(cache.id);
setSelectedCache(cache.id);
}; };
// View ----------------------- // View -----------------------
@@ -232,7 +265,7 @@ const MapScreen = ({navigation}) => {
placeholder="Enter Game Code" placeholder="Enter Game Code"
placeholderTextColor="#9ca3af" placeholderTextColor="#9ca3af"
value={gameCode} value={gameCode}
onChangeText={setGameCode} onChangeText={(text) => setGameCode(text.toUpperCase())}
autoCapitalize="characters" autoCapitalize="characters"
/> />
<Button <Button
@@ -439,8 +472,9 @@ const MapScreen = ({navigation}) => {
); );
} }
// Player in game // Player in game — use the first visible cache for the claim timer
const teamsWarning = groupInfo?.TeamsEnabled && !session.currentTid; const teamsWarning = groupInfo?.TeamsEnabled && !session.currentTid;
const claimTarget = visibleCaches.length > 0 ? visibleCaches[0] : null;
return ( return (
<Screen style={styles.containerMap}> <Screen style={styles.containerMap}>
@@ -450,7 +484,7 @@ const MapScreen = ({navigation}) => {
<View style={styles.mapContainer}> <View style={styles.mapContainer}>
<PlayerMapView <PlayerMapView
userLocation={userLocation} userLocation={userLocation}
visibleCache={visibleCache} visibleCaches={visibleCaches}
heading={heading} heading={heading}
claimDistance={claimDistance} claimDistance={claimDistance}
/> />
@@ -459,9 +493,10 @@ const MapScreen = ({navigation}) => {
</Pressable> </Pressable>
</View> </View>
<ClaimTimerView <ClaimTimerView
cache={visibleCache} cache={claimTarget}
isClaiming={isClaiming} isClaiming={isClaiming}
onClaimSuccess={handleClaim} onClaimSuccess={handleClaim}
showClaimedPopup={claimedPopupVisible}
/> />
<View style={styles.cacheSection}> <View style={styles.cacheSection}>
<ScrollView> <ScrollView>
@@ -470,6 +505,7 @@ const MapScreen = ({navigation}) => {
key={cache.id} key={cache.id}
cache={cache} cache={cache}
isAdmin={false} isAdmin={false}
isSelected={selectedCacheId === cache.id}
onSelect={handleSelectCache} onSelect={handleSelectCache}
/> />
))} ))}
+34 -6
View File
@@ -1,5 +1,5 @@
import React, {useCallback, useEffect, useState} from 'react'; import React, {useCallback, useEffect, useState} from 'react';
import {ActivityIndicator, ScrollView, StyleSheet, Text, View} from 'react-native'; import {ActivityIndicator, Alert, ScrollView, StyleSheet, Text, View} from 'react-native';
import Screen from '../layout/Screen'; import Screen from '../layout/Screen';
import Card from '../UI/Card'; import Card from '../UI/Card';
import {Button} from '../UI/Button'; import {Button} from '../UI/Button';
@@ -10,7 +10,7 @@ const PlayersScreen = () => {
// Initialisation ------------ // Initialisation ------------
const session = getSession(); const session = getSession();
const {getTeams, getTeamMembers, getGroupMembers, removeMember, getUser} = useGameHook(); const {getTeams, getTeamMembers, getGroupMembers, removeMember, resetPlayerProgress, getUser} = useGameHook();
// State ---------------------- // State ----------------------
@@ -63,6 +63,24 @@ const PlayersScreen = () => {
await loadData(); 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);
Alert.alert('Done', `Progress for ${player.username} has been reset.`);
},
},
],
);
};
// View ----------------------- // View -----------------------
if (loading) { if (loading) {
@@ -97,11 +115,19 @@ const PlayersScreen = () => {
<Text style={styles.teamCodeBadge}>{player.team.teamCode}</Text> <Text style={styles.teamCodeBadge}>{player.team.teamCode}</Text>
)} )}
</View> </View>
</View>
<View style={styles.actionRow}>
<Button
label="Reset Progress"
onClick={() => handleResetPlayerProgress(player)}
styleButton={styles.resetButton}
styleLabel={styles.actionLabel}
/>
<Button <Button
label="Kick" label="Kick"
onClick={() => handleRemovePlayer(player.membershipId)} onClick={() => handleRemovePlayer(player.membershipId)}
styleButton={styles.removeButton} styleButton={styles.removeButton}
styleLabel={styles.removeLabel} styleLabel={styles.actionLabel}
/> />
</View> </View>
</Card> </Card>
@@ -132,14 +158,16 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: 8,
marginBottom: 8,
}, },
playerName: {fontSize: 15, fontWeight: '600', color: '#1f2937', flex: 1}, playerName: {fontSize: 15, fontWeight: '600', color: '#1f2937', flex: 1},
playerMeta: {flexDirection: 'row', alignItems: 'center', gap: 6}, playerMeta: {flexDirection: 'row', alignItems: 'center', gap: 6},
leaderBadge: {fontSize: 11, color: '#2563eb', fontWeight: '700'}, leaderBadge: {fontSize: 11, color: '#2563eb', fontWeight: '700'},
teamCodeBadge: {fontSize: 12, fontWeight: '700', color: '#16a34a', letterSpacing: 1}, teamCodeBadge: {fontSize: 12, fontWeight: '700', color: '#16a34a', letterSpacing: 1},
removeButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 0, paddingHorizontal: 12}, actionRow: {flexDirection: 'row', gap: 8},
removeLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13}, resetButton: {backgroundColor: '#f59e0b', borderColor: '#f59e0b', minHeight: 36, flex: 1, paddingHorizontal: 10},
removeButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 1, paddingHorizontal: 10},
actionLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
}); });
export default PlayersScreen; export default PlayersScreen;
+38 -3
View File
@@ -11,7 +11,7 @@ const TeamScreen = () => {
const session = getSession(); const session = getSession();
const isAdmin = session.isAcceptedAdmin; const isAdmin = session.isAcceptedAdmin;
const {getTeam, createTeam, joinTeamByCode, getTeamMembers, leaveTeam, getUser, updateUser} = useGameHook(); const {getTeam, createTeam, joinTeamByCode, getTeamMembers, leaveTeam, getUser, updateUser, getAdminWaitlist} = useGameHook();
// State ---------------------- // State ----------------------
@@ -21,6 +21,7 @@ const TeamScreen = () => {
const [members, setMembers] = useState([]); const [members, setMembers] = useState([]);
const [memberNames, setMemberNames] = useState({}); const [memberNames, setMemberNames] = useState({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [onAdminWaitlist, setOnAdminWaitlist] = useState(false);
// Handlers ------------------- // Handlers -------------------
@@ -39,6 +40,15 @@ const TeamScreen = () => {
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
// Check if the user is on the admin waitlist
if (session.currentGid && !activeTid) {
const waitlist = await getAdminWaitlist(session.currentGid, session.currentUid);
if (waitlist.length > 0) {
setOnAdminWaitlist(true);
setLoading(false);
return;
}
}
if (activeTid) await loadTeamData(activeTid); if (activeTid) await loadTeamData(activeTid);
setLoading(false); setLoading(false);
}; };
@@ -47,8 +57,17 @@ const TeamScreen = () => {
const handleJoinTeam = async () => { const handleJoinTeam = async () => {
if (!teamCode.trim()) return; if (!teamCode.trim()) return;
const result = await joinTeamByCode({JoinCode: teamCode.trim().toUpperCase(), Uid: session.currentUid}); // Force uppercase just in case a lowercase code is pasted
const code = teamCode.trim().toUpperCase();
const result = await joinTeamByCode({JoinCode: code, Uid: session.currentUid});
if (!result) return; if (!result) return;
// If the server indicates this code matched the admin join code
if (result.adminWaitlist) {
setOnAdminWaitlist(true);
return;
}
setSessionTeam(result.Tid); setSessionTeam(result.Tid);
setActiveTid(result.Tid); setActiveTid(result.Tid);
}; };
@@ -95,6 +114,15 @@ const TeamScreen = () => {
); );
} }
// Waiting for admin approval
if (onAdminWaitlist) {
return (
<Screen style={styles.center}>
<Text style={styles.waitlistText}>Waiting to be approved for Admin Team</Text>
</Screen>
);
}
// Not in a team // Not in a team
if (!activeTid || !team) { if (!activeTid || !team) {
return ( return (
@@ -105,7 +133,7 @@ const TeamScreen = () => {
placeholder="Enter Team Code" placeholder="Enter Team Code"
placeholderTextColor="#9ca3af" placeholderTextColor="#9ca3af"
value={teamCode} value={teamCode}
onChangeText={setTeamCode} onChangeText={(text) => setTeamCode(text.toUpperCase())}
autoCapitalize="characters" autoCapitalize="characters"
/> />
<Button <Button
@@ -195,6 +223,13 @@ const styles = StyleSheet.create({
joinLabel: {color: '#ffffff', fontWeight: '600'}, joinLabel: {color: '#ffffff', fontWeight: '600'},
createButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'}, createButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
createLabel: {color: '#ffffff', fontWeight: '600'}, createLabel: {color: '#ffffff', fontWeight: '600'},
waitlistText: {
fontSize: 18,
fontWeight: '700',
color: '#374151',
textAlign: 'center',
paddingHorizontal: 30,
},
teamHeader: { teamHeader: {
backgroundColor: '#374151', backgroundColor: '#374151',
paddingVertical: 14, paddingVertical: 14,
+8 -1
View File
@@ -12,6 +12,7 @@ const session = {
isBusiness: null, isBusiness: null,
isAcceptedAdmin: false, isAcceptedAdmin: false,
teamsEnabled: false, teamsEnabled: false,
selectedCacheId: null,
}; };
// --- Handlers --- // --- Handlers ---
@@ -44,6 +45,11 @@ const setSessionTeamsEnabled = (val) => {
persistSession(); persistSession();
}; };
const setSelectedCache = (cacheId) => {
session.selectedCacheId = cacheId ?? null;
persistSession();
};
const getSession = () => ({ ...session }); const getSession = () => ({ ...session });
const clearSession = () => { const clearSession = () => {
@@ -63,6 +69,7 @@ const clearGameSession = () => {
session.currentTid = null; session.currentTid = null;
session.isAcceptedAdmin = false; session.isAcceptedAdmin = false;
session.teamsEnabled = false; session.teamsEnabled = false;
session.selectedCacheId = null;
persistSession(); persistSession();
}; };
@@ -85,4 +92,4 @@ const loadSession = async () => {
return { ...session }; return { ...session };
}; };
export { setSessionUser, setSessionGroup, setSessionTeam, setSessionTeamsEnabled, getSession, clearSession, clearGameSession, loadSession }; export { setSessionUser, setSessionGroup, setSessionTeam, setSessionTeamsEnabled, setSelectedCache, getSession, clearSession, clearGameSession, loadSession };
+40 -3
View File
@@ -10,6 +10,7 @@ const useGameHook = () => {
const teamsEndpoint = `${API_BASE_URL}/teams`; const teamsEndpoint = `${API_BASE_URL}/teams`;
const teamMembersEndpoint = `${API_BASE_URL}/team-members`; const teamMembersEndpoint = `${API_BASE_URL}/team-members`;
const gameDataEndpoint = `${API_BASE_URL}/game-data`; const gameDataEndpoint = `${API_BASE_URL}/game-data`;
const adminWaitlistEndpoint = `${API_BASE_URL}/admin-waitlist`;
// Handlers ------------------- // Handlers -------------------
@@ -116,10 +117,11 @@ const useGameHook = () => {
return response.isSuccess ? response.result : null; return response.isSuccess ? response.result : null;
}; };
// Claim a cache via the multi-team claim endpoint
const claimCache = async (payload) => { const claimCache = async (payload) => {
const response = await API.put( const response = await API.post(
`${gameDataEndpoint}/${payload.gid}/caches/${payload.cacheId}`, `${gameDataEndpoint}/${payload.gid}/caches/${payload.cacheId}/claim`,
{ClaimedByUid: payload.uid, ClaimedByTid: payload.tid}, {Uid: payload.uid, Tid: payload.tid},
); );
return response.isSuccess ? response.result : null; return response.isSuccess ? response.result : null;
}; };
@@ -129,6 +131,36 @@ const useGameHook = () => {
return response.isSuccess; return response.isSuccess;
}; };
// Game reset — clear all claims for a game
const resetGame = async (gid) => {
const response = await API.post(`${gameDataEndpoint}/${gid}/reset`, {});
return response.isSuccess;
};
// Player progress reset — clear all claims by a specific user
const resetPlayerProgress = async (gid, uid) => {
const response = await API.post(`${gameDataEndpoint}/${gid}/reset-player/${uid}`, {});
return response.isSuccess;
};
// Admin waitlist
const getAdminWaitlist = async (gid, uid = null) => {
let url = `${adminWaitlistEndpoint}?Gid=${gid}`;
if (uid !== null) url += `&Uid=${uid}`;
const response = await API.get(url);
return response.isSuccess ? response.result : [];
};
const approveAdmin = async (waitlistId) => {
const response = await API.post(`${adminWaitlistEndpoint}/${waitlistId}/approve`, {});
return response.isSuccess ? response.result : null;
};
const rejectAdmin = async (waitlistId) => {
const response = await API.delete(`${adminWaitlistEndpoint}/${waitlistId}`);
return response.isSuccess;
};
// Return --------------------- // Return ---------------------
return { return {
@@ -151,6 +183,11 @@ const useGameHook = () => {
upsertCache, upsertCache,
claimCache, claimCache,
deleteCache, deleteCache,
resetGame,
resetPlayerProgress,
getAdminWaitlist,
approveAdmin,
rejectAdmin,
}; };
}; };
+18 -10
View File
@@ -1,34 +1,42 @@
import {useEffect, useState} from 'react'; import {useEffect, useRef, useState} from 'react';
import {isInClaimCone} from '../utils/geoMath'; import {isInClaimCone} from '../utils/geoMath';
const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistance) => { const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistance) => {
// State ---------------------- // State ----------------------
const [visibleCache, setVisibleCache] = useState(null); const [visibleCaches, setVisibleCaches] = useState([]);
const [isClaiming, setIsClaiming] = useState(false); const [isClaiming, setIsClaiming] = useState(false);
const prevIdsRef = useRef('');
// Handlers ------------------- // Handlers -------------------
useEffect(() => { useEffect(() => {
if (!playerLocation || playerHeading === null || playerHeading === undefined) { if (!playerLocation || playerHeading === null || playerHeading === undefined) {
setVisibleCache(null); if (prevIdsRef.current !== '') {
setIsClaiming(false); prevIdsRef.current = '';
setVisibleCaches([]);
setIsClaiming(false);
}
return; return;
} }
// Find the first cache that falls inside the invisible claim cone // Find all caches that fall inside the claim cone
// (same FOV angle as the visible cone, but radius = admin-set claimDistance) const inCone = (activeCaches || []).filter((cache) =>
const cacheInCone = (activeCaches || []).find((cache) =>
isInClaimCone(playerHeading, playerLocation, cache.coordinates, claimDistance) isInClaimCone(playerHeading, playerLocation, cache.coordinates, claimDistance)
); );
setVisibleCache(cacheInCone || null); // Only update state when the set of visible caches actually changes
setIsClaiming(Boolean(cacheInCone)); const newIds = inCone.map((c) => c.id).join(',');
if (newIds !== prevIdsRef.current) {
prevIdsRef.current = newIds;
setVisibleCaches(inCone);
setIsClaiming(inCone.length > 0);
}
}, [playerLocation, playerHeading, activeCaches, claimDistance]); }, [playerLocation, playerHeading, activeCaches, claimDistance]);
// Return --------------------- // Return ---------------------
return {visibleCache, isClaiming, setIsClaiming}; return {visibleCaches, isClaiming, setIsClaiming};
}; };
export default usePlayerGame; export default usePlayerGame;