mirror of
https://github.com/mudabbir-ahmad/MAD-Treasure-Hunt.git
synced 2026-10-08 03:00:20 +00:00
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:
1 parent
e34cbba9b5
commit
9a9db74369
16 files changed
+623
-116
No files matched your search
+149
-9
@@ -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) => {
|
||||||
|
// Migrate old single-claim format to Claims array
|
||||||
|
let claims = cache.Claims || [];
|
||||||
|
if (claims.length === 0 && cache.ClaimedByUid) {
|
||||||
|
claims = [{ Uid: cache.ClaimedByUid, Tid: cache.ClaimedByTid || null, ClaimedAt: null }];
|
||||||
|
}
|
||||||
|
return {
|
||||||
id: cache.CacheId,
|
id: cache.CacheId,
|
||||||
coordinates: { latitude: cache.Latitude, longitude: cache.Longitude },
|
coordinates: { latitude: cache.Latitude, longitude: cache.Longitude },
|
||||||
radius: cache.TriggerMeters,
|
radius: cache.TriggerMeters,
|
||||||
clue: cache.Title,
|
clue: cache.Title,
|
||||||
groupId: Number(gid),
|
groupId: Number(gid),
|
||||||
subgroupId: cache.SGid,
|
subgroupId: cache.SGid,
|
||||||
ClaimedByUid: cache.ClaimedByUid || null,
|
Claims: claims,
|
||||||
ClaimedByTid: cache.ClaimedByTid || null,
|
};
|
||||||
});
|
};
|
||||||
|
|
||||||
// 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
|
||||||
// ======================================================================
|
// ======================================================================
|
||||||
|
|||||||
@@ -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},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 */}
|
||||||
|
{(visibleCaches || []).map((cache) => (
|
||||||
|
<React.Fragment key={cache.id}>
|
||||||
|
<Marker
|
||||||
|
coordinate={cache.coordinates}
|
||||||
|
title={cache.clue}
|
||||||
|
pinColor="#facc15"
|
||||||
|
/>
|
||||||
<Circle
|
<Circle
|
||||||
center={visibleCache.coordinates}
|
center={cache.coordinates}
|
||||||
radius={claimDistance}
|
radius={claimDistance}
|
||||||
fillColor="rgba(250, 204, 21, 0.20)"
|
fillColor="rgba(250, 204, 21, 0.20)"
|
||||||
strokeColor="rgba(250, 204, 21, 0.90)"
|
strokeColor="rgba(250, 204, 21, 0.90)"
|
||||||
/>
|
/>
|
||||||
)}
|
</React.Fragment>
|
||||||
|
))}
|
||||||
{coneCoords && (
|
{coneCoords && (
|
||||||
<Polygon
|
<Polygon
|
||||||
coordinates={coneCoords}
|
coordinates={coneCoords}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ const styles = StyleSheet.create({
|
|||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: 15,
|
padding: 15,
|
||||||
|
zIndex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,28 +116,8 @@ const LeaderboardScreen = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Teams enabled — tabbed view
|
// Helper: render an expandable team card
|
||||||
if (teamsEnabled) {
|
const renderTeamCard = (team, index) => (
|
||||||
return (
|
|
||||||
<Screen style={styles.container}>
|
|
||||||
<View style={styles.tabRow}>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.tabButton, activeTab === 'global' && styles.tabButtonActive]}
|
|
||||||
onPress={() => setActiveTab('global')}
|
|
||||||
>
|
|
||||||
<Text style={[styles.tabLabel, activeTab === 'global' && styles.tabLabelActive]}>Global</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.tabButton, activeTab === 'team' && styles.tabButtonActive]}
|
|
||||||
onPress={() => setActiveTab('team')}
|
|
||||||
>
|
|
||||||
<Text style={[styles.tabLabel, activeTab === 'team' && styles.tabLabelActive]}>Team</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{activeTab === 'global' ? (
|
|
||||||
<ScrollView style={styles.listSection}>
|
|
||||||
{globalRanking.map((team, index) => (
|
|
||||||
<Pressable key={team.tid} onPress={() => setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}>
|
<Pressable key={team.tid} onPress={() => setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}>
|
||||||
<Card>
|
<Card>
|
||||||
<View style={styles.rankRow}>
|
<View style={styles.rankRow}>
|
||||||
@@ -141,32 +128,97 @@ const LeaderboardScreen = () => {
|
|||||||
{expandedTeam === team.tid && team.members.map((m) => (
|
{expandedTeam === team.tid && team.members.map((m) => (
|
||||||
<View key={m.uid} style={styles.subRow}>
|
<View key={m.uid} style={styles.subRow}>
|
||||||
<Text style={styles.subName}>
|
<Text style={styles.subName}>
|
||||||
{m.name}{m.isLeader ? ' (Leader)' : ''}
|
{m.name}{m.isLeader ? ' (Team Leader)' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.subScore}>{m.caches}</Text>
|
<Text style={styles.subScore}>{m.caches}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</Card>
|
</Card>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
);
|
||||||
{globalRanking.length === 0 && (
|
|
||||||
|
// 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) {
|
||||||
|
return (
|
||||||
|
<Screen style={styles.container}>
|
||||||
|
<View style={styles.tabRow}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.tabButton, activeTab === 'game' && styles.tabButtonActive]}
|
||||||
|
onPress={() => setActiveTab('game')}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabLabel, activeTab === 'game' && styles.tabLabelActive]}>GAME</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.tabButton, activeTab === 'team' && styles.tabButtonActive]}
|
||||||
|
onPress={() => setActiveTab('team')}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabLabel, activeTab === 'team' && styles.tabLabelActive]}>TEAM</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{activeTab === 'game' ? (
|
||||||
|
<ScrollView style={styles.listSection}>
|
||||||
|
{teamRankings.map((team, index) => renderTeamCard(team, index))}
|
||||||
|
{teamRankings.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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 !== '') {
|
||||||
|
prevIdsRef.current = '';
|
||||||
|
setVisibleCaches([]);
|
||||||
setIsClaiming(false);
|
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;
|
||||||
|
|||||||
Reference in new issue
Block a user