diff --git a/App.js b/App.js
index 542d48d..fb2944e 100644
--- a/App.js
+++ b/App.js
@@ -9,6 +9,7 @@ import TeamScreen from "./src/components/screens/TeamScreen";
import MapScreen from "./src/components/screens/MapScreen";
import LeaderboardScreen from "./src/components/screens/LeaderboardScreen";
import GameSettingsScreen from "./src/components/screens/GameSettingsScreen";
+import DepartmentSettingsScreen from "./src/components/screens/DepartmentSettingsScreen";
import PlayersScreen from "./src/components/screens/PlayersScreen";
import ExpandedMapScreen from "./src/components/screens/ExpandedMapScreen";
import GameSelectionScreen from "./src/components/screens/GameSelectionScreen";
@@ -105,6 +106,15 @@ const AdminStack = () => (
component={GlobalLeaderboardScreen}
/>
+
+
+
+
+
+
+
+
+
);
export const App = () => {
diff --git a/README.md b/README.md
index dee5871..c7483a3 100644
--- a/README.md
+++ b/README.md
@@ -6,12 +6,32 @@ Treasure Hunt mobile app built with React Native and Expo.
```bash
npm i
-npx expo start --android
+npm start
```
## Notes
- The app accesses game/auth data through the centralized API endpoint via `src/components/API/API.js`.
-- Navigation is handled with React Navigation native stack.
-- Sometimes you might have to switch the API Endpoint URL. This is just Because of the fact that when I'm using the app I can't access the API endpoint, from the global URL. Therefore, I need my local API Server's IP as the URL
-- Just Uncomment the `API_ENDPOINT` variable in `src/components/API/API.js` to use the actual API Server Which is `api.bobby.ip-ddns.com` [Do not add any port thing here as it uses port 80 by default].
+- IF encountering network issues, ensure the API endpoint is set to 'http://api.bobby.ip-ddns.com' and not 'http://192.168.8.100:3000'.
+- The reason for 'http://192.168.8.100:3000' is due to the API server being on my (Mudabbir's) home network. I need to use local ip due to loop back restrictions with home network(needed for security).
+- Just uncomment line 1 in `src/components/API/API.js` to use the actual API Server Which is `api.bobby.ip-ddns.com` [Do not add any port thing here as it uses port 80 by default].
+
+
+# DB Structure:
+
+API endpoint: `http://api.bobby.ip-ddns.com/`
+
+### Tables:
+- `users`
+- `teams`
+- `team-members`
+- `subgroups`
+- `subgroup-members`
+- `groups`
+- `game-types`
+- `db`
+- `caches`
+- `admin-waitlist`
+
+#### Append Table to API URL to view table's json data on browser.
+I.e: `http://api.bobby.ip-ddns.com/users` or `http://api.bobby.ip-ddns.com/subgroups-members`
\ No newline at end of file
diff --git a/app.json b/app.json
index 3f8c7e5..36d53e2 100644
--- a/app.json
+++ b/app.json
@@ -16,6 +16,9 @@
"supportsTablet": true,
"infoPlist": {
"NSLocationWhenInUseUsageDescription": "This app needs your location for the treasure hunt."
+ },
+ "config": {
+ "googleMapsApiKey": "AIzaSyA-G0tSzzcM5z6HyI23s8dzKDLCOrekhbw"
}
},
"android": {
diff --git a/assets/icons/Leaderboard-icon.png b/assets/icons/Leaderboard-icon.png
new file mode 100644
index 0000000..e04806b
Binary files /dev/null and b/assets/icons/Leaderboard-icon.png differ
diff --git a/assets/icons/Map-icon.png b/assets/icons/Map-icon.png
new file mode 100644
index 0000000..51d6f8a
Binary files /dev/null and b/assets/icons/Map-icon.png differ
diff --git a/assets/icons/Teams-icon.png b/assets/icons/Teams-icon.png
new file mode 100644
index 0000000..8ca3dfc
Binary files /dev/null and b/assets/icons/Teams-icon.png differ
diff --git a/assets/icons/settings-icon.png b/assets/icons/settings-icon.png
new file mode 100644
index 0000000..94ef7da
Binary files /dev/null and b/assets/icons/settings-icon.png differ
diff --git a/server/package.json b/server/package.json
deleted file mode 100644
index 9ed7c53..0000000
--- a/server/package.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "name": "mad-treasure-hunt-server",
- "version": "1.0.0",
- "description": "RESTful CRUDL API server for MAD Treasure Hunt",
- "private": true,
- "main": "server.js",
- "scripts": {
- "start": "node server.js",
- "dev": "node --watch server.js"
- },
- "engines": {
- "node": ">=18.0.0"
- }
-}
diff --git a/server/server.js b/server/server.js
deleted file mode 100644
index ec7d464..0000000
--- a/server/server.js
+++ /dev/null
@@ -1,770 +0,0 @@
-const http = require('http');
-const fs = require('fs');
-const path = require('path');
-
-// --- Configuration ---
-
-const PORT = process.env.PORT || 3000;
-const DATA_DIR = path.join(__dirname, 'data');
-
-// --- Data Helpers ---
-
-const readJson = (fileName) => JSON.parse(fs.readFileSync(path.join(DATA_DIR, fileName), 'utf-8'));
-const writeJson = (fileName, data) => fs.writeFileSync(path.join(DATA_DIR, fileName), JSON.stringify(data, null, 2));
-
-// --- Utility Helpers ---
-
-const hashPassword = (value) => {
- let hash = 0;
- for (let i = 0; i < value.length; i += 1) {
- hash = (hash << 5) - hash + value.charCodeAt(i);
- hash |= 0;
- }
- return `H_${Math.abs(hash)}`;
-};
-
-const nextId = (rows, key) => rows.reduce((max, row) => {
- const val = Number(row[key] || 0);
- return val > max ? val : max;
-}, 0) + 1;
-
-const generateCode = (len = 5) => {
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
- let code = '';
- for (let i = 0; i < len; i++) code += chars.charAt(Math.floor(Math.random() * chars.length));
- return code;
-};
-
-// --- Request / Response Helpers ---
-
-const parseBody = (req) => new Promise((resolve, reject) => {
- let body = '';
- req.on('data', (chunk) => { body += chunk.toString(); });
- req.on('end', () => {
- try { resolve(body ? JSON.parse(body) : {}); }
- catch { reject(new Error('Invalid JSON body')); }
- });
- req.on('error', reject);
-});
-
-const sendJson = (res, statusCode, payload) => {
- const headers = {
- 'Access-Control-Allow-Origin': '*',
- 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
- 'Access-Control-Allow-Headers': 'Content-Type',
- };
- if (statusCode === 204) {
- res.writeHead(204, headers);
- res.end();
- return;
- }
- headers['Content-Type'] = 'application/json';
- res.writeHead(statusCode, headers);
- res.end(JSON.stringify(payload));
-};
-
-// --- Simple Router ---
-
-const routes = [];
-
-const addRoute = (method, pattern, handler) => {
- const keys = [];
- const regexStr = pattern.replace(/:([a-zA-Z]+)/g, (_, key) => {
- keys.push(key);
- return '([^/]+)';
- });
- routes.push({ method, regex: new RegExp(`^${regexStr}$`), keys, handler });
-};
-
-const matchRoute = (method, pathname) => {
- for (const r of routes) {
- if (r.method !== method) continue;
- const match = pathname.match(r.regex);
- if (match) {
- const params = {};
- r.keys.forEach((key, i) => { params[key] = match[i + 1]; });
- return { handler: r.handler, params };
- }
- }
- return null;
-};
-
-// ======================================================================
-// AUTH ROUTES
-// ======================================================================
-
-addRoute('POST', '/auth/login', async (req, res) => {
- const payload = await parseBody(req);
- const users = readJson('users.json');
- const user = users.find((u) => u.email.toLowerCase() === String(payload.email || '').toLowerCase());
- if (!user) return sendJson(res, 400, { message: 'Email not found' });
- if (user.passwordHash !== hashPassword(String(payload.password || '')))
- return sendJson(res, 400, { message: 'Invalid password' });
- sendJson(res, 200, user);
-});
-
-addRoute('POST', '/auth/register', async (req, res) => {
- const payload = await parseBody(req);
- const username = String(payload.username || '').trim();
- const email = String(payload.email || '').trim().toLowerCase();
- const password = String(payload.password || '');
- const confirmPassword = String(payload.confirmPassword || '');
- const accountType = String(payload.accountType || 'Individual');
-
- if (!username || !email || !password || !confirmPassword)
- return sendJson(res, 400, { message: 'Missing required registration fields' });
- if (password !== confirmPassword)
- return sendJson(res, 400, { message: 'Passwords do not match' });
-
- const users = readJson('users.json');
- if (users.some((u) => u.email.toLowerCase() === email))
- return sendJson(res, 400, { message: 'Email already in use' });
-
- const user = {
- Uid: nextId(users, 'Uid'),
- username,
- email,
- passwordHash: hashPassword(password),
- isBusiness: accountType === 'Business/School',
- IsAcceptedAdmin: false,
- Gid: null,
- SGid: null,
- TGid: null,
- };
-
- users.push(user);
- writeJson('users.json', users);
- sendJson(res, 201, user);
-});
-
-// ======================================================================
-// USERS CRUDL
-// ======================================================================
-
-addRoute('GET', '/users', (req, res) => {
- sendJson(res, 200, readJson('users.json'));
-});
-
-addRoute('GET', '/users/:id', (req, res, params) => {
- const users = readJson('users.json');
- const user = users.find((u) => String(u.Uid) === params.id);
- if (!user) return sendJson(res, 404, { message: 'User not found' });
- sendJson(res, 200, user);
-});
-
-addRoute('PUT', '/users/:id', async (req, res, params) => {
- const payload = await parseBody(req);
- const users = readJson('users.json');
- const idx = users.findIndex((u) => String(u.Uid) === params.id);
- if (idx === -1) return sendJson(res, 404, { message: 'User not found' });
- users[idx] = { ...users[idx], ...payload, Uid: users[idx].Uid };
- writeJson('users.json', users);
- sendJson(res, 200, users[idx]);
-});
-
-addRoute('DELETE', '/users/:id', (req, res, params) => {
- const users = readJson('users.json');
- const idx = users.findIndex((u) => String(u.Uid) === params.id);
- if (idx === -1) return sendJson(res, 404, { message: 'User not found' });
- users.splice(idx, 1);
- writeJson('users.json', users);
- sendJson(res, 204, null);
-});
-
-// ======================================================================
-// GAME TYPES (read-only list)
-// ======================================================================
-
-addRoute('GET', '/game-types', (req, res) => {
- sendJson(res, 200, readJson('game-types.json'));
-});
-
-// ======================================================================
-// GROUPS CRUDL
-// ======================================================================
-
-addRoute('GET', '/groups', (req, res, params, query) => {
- let groups = readJson('groups.json');
- if (query.CreatedByUid) groups = groups.filter((g) => String(g.CreatedByUid) === query.CreatedByUid);
- sendJson(res, 200, groups);
-});
-
-addRoute('GET', '/groups/:gid', (req, res, params) => {
- const groups = readJson('groups.json');
- const group = groups.find((g) => String(g.Gid) === params.gid);
- if (!group) return sendJson(res, 404, { message: 'Group not found' });
- sendJson(res, 200, group);
-});
-
-addRoute('POST', '/groups', async (req, res) => {
- const payload = await parseBody(req);
- const groups = readJson('groups.json');
-
- const group = {
- Gid: nextId(groups, 'Gid'),
- GroupName: payload.GroupName || 'Unnamed Group',
- BusinessOrSchoolName: payload.BusinessOrSchoolName || null,
- GameType: payload.GameType || 'private',
- AdminJoinCode: generateCode(),
- CreatedByUid: payload.CreatedByUid,
- MaxMemberSubgroups: payload.MaxMemberSubgroups || 1,
- TeamsEnabled: payload.TeamsEnabled !== undefined ? payload.TeamsEnabled : true,
- CacheTriggerMeters: payload.CacheTriggerMeters || 20,
- ApprovedAdmins: [payload.CreatedByUid],
- CreatedAt: new Date().toISOString(),
- };
- groups.push(group);
- writeJson('groups.json', groups);
-
- // Create default admin and member subgroups
- const subgroups = readJson('subgroups.json');
- const adminSg = {
- SGid: nextId(subgroups, 'SGid'),
- SubGroupName: 'Admins',
- Gid: group.Gid,
- JoinCode: null,
- IsAdminGroup: true,
- CacheTriggerMeters: 20,
- };
- subgroups.push(adminSg);
-
- const memberSg = {
- SGid: nextId(subgroups, 'SGid'),
- SubGroupName: 'Members',
- Gid: group.Gid,
- JoinCode: generateCode(),
- IsAdminGroup: false,
- CacheTriggerMeters: 20,
- };
- subgroups.push(memberSg);
- writeJson('subgroups.json', subgroups);
-
- // Add creator as admin membership
- const memberships = readJson('subgroup-memberships.json');
- memberships.push({
- id: nextId(memberships, 'id'),
- Uid: payload.CreatedByUid,
- Gid: group.Gid,
- SGid: adminSg.SGid,
- IsAcceptedAdmin: true,
- });
- writeJson('subgroup-memberships.json', memberships);
-
- // Initialise empty game-data entry
- const gameData = readJson('game-data.json');
- gameData.push({ Gid: group.Gid, GeoCaches: [] });
- writeJson('game-data.json', gameData);
-
- // Update the creating user
- const users = readJson('users.json');
- const uIdx = users.findIndex((u) => u.Uid === payload.CreatedByUid);
- if (uIdx !== -1) {
- users[uIdx].Gid = group.Gid;
- users[uIdx].SGid = adminSg.SGid;
- users[uIdx].IsAcceptedAdmin = true;
- writeJson('users.json', users);
- }
-
- sendJson(res, 201, group);
-});
-
-addRoute('PUT', '/groups/:gid', async (req, res, params) => {
- const payload = await parseBody(req);
- const groups = readJson('groups.json');
- const idx = groups.findIndex((g) => String(g.Gid) === params.gid);
- if (idx === -1) return sendJson(res, 404, { message: 'Group not found' });
- groups[idx] = { ...groups[idx], ...payload, Gid: groups[idx].Gid };
- writeJson('groups.json', groups);
- sendJson(res, 200, groups[idx]);
-});
-
-addRoute('DELETE', '/groups/:gid', (req, res, params) => {
- const groups = readJson('groups.json');
- const idx = groups.findIndex((g) => String(g.Gid) === params.gid);
- if (idx === -1) return sendJson(res, 404, { message: 'Group not found' });
- groups.splice(idx, 1);
- writeJson('groups.json', groups);
- sendJson(res, 204, null);
-});
-
-// ======================================================================
-// SUBGROUPS CRUDL
-// ======================================================================
-
-addRoute('GET', '/subgroups', (req, res, params, query) => {
- let rows = readJson('subgroups.json');
- if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid);
- sendJson(res, 200, rows);
-});
-
-addRoute('GET', '/subgroups/:id', (req, res, params) => {
- const rows = readJson('subgroups.json');
- const row = rows.find((r) => String(r.SGid) === params.id);
- if (!row) return sendJson(res, 404, { message: 'Subgroup not found' });
- sendJson(res, 200, row);
-});
-
-addRoute('POST', '/subgroups', async (req, res) => {
- const payload = await parseBody(req);
- const rows = readJson('subgroups.json');
- payload.SGid = nextId(rows, 'SGid');
- if (!payload.JoinCode) payload.JoinCode = generateCode();
- rows.push(payload);
- writeJson('subgroups.json', rows);
- sendJson(res, 201, payload);
-});
-
-addRoute('PUT', '/subgroups/:id', async (req, res, params) => {
- const payload = await parseBody(req);
- const rows = readJson('subgroups.json');
- const idx = rows.findIndex((r) => String(r.SGid) === params.id);
- if (idx === -1) return sendJson(res, 404, { message: 'Subgroup not found' });
- rows[idx] = { ...rows[idx], ...payload, SGid: rows[idx].SGid };
- writeJson('subgroups.json', rows);
- sendJson(res, 200, rows[idx]);
-});
-
-addRoute('DELETE', '/subgroups/:id', (req, res, params) => {
- const rows = readJson('subgroups.json');
- const idx = rows.findIndex((r) => String(r.SGid) === params.id);
- if (idx === -1) return sendJson(res, 404, { message: 'Subgroup not found' });
- rows.splice(idx, 1);
- writeJson('subgroups.json', rows);
- sendJson(res, 204, null);
-});
-
-// ======================================================================
-// SUBGROUP MEMBERSHIPS CRUDL
-// ======================================================================
-
-addRoute('GET', '/subgroup-memberships', (req, res, params, query) => {
- let rows = readJson('subgroup-memberships.json');
- if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid);
- if (query.Uid) rows = rows.filter((r) => String(r.Uid) === query.Uid);
- if (query.SGid) rows = rows.filter((r) => String(r.SGid) === query.SGid);
- sendJson(res, 200, rows);
-});
-
-addRoute('GET', '/subgroup-memberships/:id', (req, res, params) => {
- const rows = readJson('subgroup-memberships.json');
- const row = rows.find((r) => String(r.id) === params.id);
- if (!row) return sendJson(res, 404, { message: 'Membership not found' });
- sendJson(res, 200, row);
-});
-
-addRoute('POST', '/subgroup-memberships', async (req, res) => {
- const payload = await parseBody(req);
- const rows = readJson('subgroup-memberships.json');
-
- // If a JoinCode was supplied, look up the matching subgroup
- if (payload.JoinCode) {
- const subgroups = readJson('subgroups.json');
- const sg = subgroups.find((s) => s.JoinCode === String(payload.JoinCode).toUpperCase());
- if (!sg) return sendJson(res, 400, { message: 'Invalid join code' });
- payload.Gid = sg.Gid;
- payload.SGid = sg.SGid;
- }
-
- payload.id = nextId(rows, 'id');
- payload.IsAcceptedAdmin = payload.IsAcceptedAdmin || false;
- rows.push(payload);
- writeJson('subgroup-memberships.json', rows);
-
- // Update user record with group / subgroup
- const users = readJson('users.json');
- const uIdx = users.findIndex((u) => u.Uid === payload.Uid);
- if (uIdx !== -1) {
- users[uIdx].Gid = payload.Gid;
- users[uIdx].SGid = payload.SGid;
- writeJson('users.json', users);
- }
-
- sendJson(res, 201, payload);
-});
-
-addRoute('PUT', '/subgroup-memberships/:id', async (req, res, params) => {
- const payload = await parseBody(req);
- const rows = readJson('subgroup-memberships.json');
- const idx = rows.findIndex((r) => String(r.id) === params.id);
- if (idx === -1) return sendJson(res, 404, { message: 'Membership not found' });
- rows[idx] = { ...rows[idx], ...payload, id: rows[idx].id };
- writeJson('subgroup-memberships.json', rows);
- sendJson(res, 200, rows[idx]);
-});
-
-addRoute('DELETE', '/subgroup-memberships/:id', (req, res, params) => {
- const rows = readJson('subgroup-memberships.json');
- const idx = rows.findIndex((r) => String(r.id) === params.id);
- if (idx === -1) return sendJson(res, 404, { message: 'Membership not found' });
-
- const removed = rows[idx];
- rows.splice(idx, 1);
- writeJson('subgroup-memberships.json', rows);
-
- // Cascade: remove from team if in one
- const teamMembers = readJson('team-members.json');
- const tmIdx = teamMembers.findIndex((r) => r.Uid === removed.Uid);
- if (tmIdx !== -1) {
- const tmRemoved = teamMembers[tmIdx];
- teamMembers.splice(tmIdx, 1);
- // Transfer leadership to longest-serving member if leader left
- if (tmRemoved.IsLeader) {
- const remaining = teamMembers.filter((r) => r.Tid === tmRemoved.Tid);
- if (remaining.length > 0) {
- remaining.sort((a, b) => new Date(a.JoinedAt || 0) - new Date(b.JoinedAt || 0));
- remaining[0].IsLeader = true;
- }
- }
- writeJson('team-members.json', teamMembers);
- }
-
- // Clear user game data
- const users = readJson('users.json');
- const uIdx = users.findIndex((u) => u.Uid === removed.Uid);
- if (uIdx !== -1) {
- users[uIdx].Gid = null;
- users[uIdx].SGid = null;
- users[uIdx].TGid = null;
- users[uIdx].IsAcceptedAdmin = false;
- writeJson('users.json', users);
- }
-
- sendJson(res, 204, null);
-});
-
-// ======================================================================
-// TEAMS CRUDL
-// ======================================================================
-
-addRoute('GET', '/teams', (req, res, params, query) => {
- let rows = readJson('teams.json');
- if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid);
- sendJson(res, 200, rows);
-});
-
-addRoute('GET', '/teams/:id', (req, res, params) => {
- const rows = readJson('teams.json');
- const row = rows.find((r) => String(r.Tid) === params.id);
- if (!row) return sendJson(res, 404, { message: 'Team not found' });
- sendJson(res, 200, row);
-});
-
-addRoute('POST', '/teams', async (req, res) => {
- const payload = await parseBody(req);
- const rows = readJson('teams.json');
- payload.Tid = nextId(rows, 'Tid');
- if (!payload.JoinCode) payload.JoinCode = generateCode();
- rows.push(payload);
- writeJson('teams.json', rows);
- sendJson(res, 201, payload);
-});
-
-addRoute('PUT', '/teams/:id', async (req, res, params) => {
- const payload = await parseBody(req);
- const rows = readJson('teams.json');
- const idx = rows.findIndex((r) => String(r.Tid) === params.id);
- if (idx === -1) return sendJson(res, 404, { message: 'Team not found' });
- rows[idx] = { ...rows[idx], ...payload, Tid: rows[idx].Tid };
- writeJson('teams.json', rows);
- sendJson(res, 200, rows[idx]);
-});
-
-addRoute('DELETE', '/teams/:id', (req, res, params) => {
- const rows = readJson('teams.json');
- const idx = rows.findIndex((r) => String(r.Tid) === params.id);
- if (idx === -1) return sendJson(res, 404, { message: 'Team not found' });
- rows.splice(idx, 1);
- writeJson('teams.json', rows);
- sendJson(res, 204, null);
-});
-
-// ======================================================================
-// TEAM MEMBERS CRUDL
-// ======================================================================
-
-addRoute('GET', '/team-members', (req, res, params, query) => {
- let rows = readJson('team-members.json');
- if (query.Tid) rows = rows.filter((r) => String(r.Tid) === query.Tid);
- if (query.Uid) rows = rows.filter((r) => String(r.Uid) === query.Uid);
- sendJson(res, 200, rows);
-});
-
-addRoute('GET', '/team-members/:id', (req, res, params) => {
- const rows = readJson('team-members.json');
- const row = rows.find((r) => String(r.id) === params.id);
- if (!row) return sendJson(res, 404, { message: 'Team member not found' });
- sendJson(res, 200, row);
-});
-
-addRoute('POST', '/team-members', async (req, res) => {
- const payload = await parseBody(req);
- const rows = readJson('team-members.json');
-
- // If a JoinCode was supplied, look up the matching team
- if (payload.JoinCode) {
- const teams = readJson('teams.json');
- const team = teams.find((t) => t.JoinCode === String(payload.JoinCode).toUpperCase());
- if (!team) return sendJson(res, 400, { message: 'Invalid team join code' });
- payload.Tid = team.Tid;
- }
-
- // Auto-set leader if first member in team
- const existing = rows.filter((r) => r.Tid === payload.Tid);
- payload.id = nextId(rows, 'id');
- payload.JoinedAt = new Date().toISOString();
- payload.IsLeader = existing.length === 0;
- rows.push(payload);
- writeJson('team-members.json', rows);
-
- // Update user TGid
- const users = readJson('users.json');
- const uIdx = users.findIndex((u) => u.Uid === payload.Uid);
- if (uIdx !== -1) {
- users[uIdx].TGid = payload.Tid;
- writeJson('users.json', users);
- }
-
- sendJson(res, 201, payload);
-});
-
-addRoute('PUT', '/team-members/:id', async (req, res, params) => {
- const payload = await parseBody(req);
- const rows = readJson('team-members.json');
- const idx = rows.findIndex((r) => String(r.id) === params.id);
- if (idx === -1) return sendJson(res, 404, { message: 'Team member not found' });
- rows[idx] = { ...rows[idx], ...payload, id: rows[idx].id };
- writeJson('team-members.json', rows);
- sendJson(res, 200, rows[idx]);
-});
-
-addRoute('DELETE', '/team-members/:id', (req, res, params) => {
- const rows = readJson('team-members.json');
- const idx = rows.findIndex((r) => String(r.id) === params.id);
- if (idx === -1) return sendJson(res, 404, { message: 'Team member not found' });
-
- const removed = rows[idx];
- rows.splice(idx, 1);
-
- // Transfer leadership to longest-serving member if leader left
- if (removed.IsLeader) {
- const remaining = rows.filter((r) => r.Tid === removed.Tid);
- if (remaining.length > 0) {
- remaining.sort((a, b) => new Date(a.JoinedAt || 0) - new Date(b.JoinedAt || 0));
- remaining[0].IsLeader = true;
- }
- }
-
- writeJson('team-members.json', rows);
-
- // Clear user TGid
- const users = readJson('users.json');
- const uIdx = users.findIndex((u) => u.Uid === removed.Uid);
- if (uIdx !== -1) {
- users[uIdx].TGid = null;
- writeJson('users.json', users);
- }
-
- sendJson(res, 204, null);
-});
-
-// ======================================================================
-// ADMIN WAITLIST CRUDL
-// ======================================================================
-
-addRoute('GET', '/admin-waitlist', (req, res, params, query) => {
- let rows = readJson('admin-waitlist.json');
- if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid);
- sendJson(res, 200, rows);
-});
-
-addRoute('POST', '/admin-waitlist', async (req, res) => {
- const payload = await parseBody(req);
- const rows = readJson('admin-waitlist.json');
- payload.id = nextId(rows, 'id');
- rows.push(payload);
- writeJson('admin-waitlist.json', rows);
- sendJson(res, 201, payload);
-});
-
-addRoute('DELETE', '/admin-waitlist/:id', (req, res, params) => {
- const rows = readJson('admin-waitlist.json');
- const idx = rows.findIndex((r) => String(r.id) === params.id);
- if (idx === -1) return sendJson(res, 404, { message: 'Waitlist entry not found' });
- rows.splice(idx, 1);
- writeJson('admin-waitlist.json', rows);
- sendJson(res, 204, null);
-});
-
-// ======================================================================
-// GAME DATA CRUDL
-// ======================================================================
-
-addRoute('GET', '/game-data', (req, res) => {
- sendJson(res, 200, readJson('game-data.json'));
-});
-
-addRoute('GET', '/game-data/:gid', (req, res, params) => {
- const rows = readJson('game-data.json');
- const row = rows.find((r) => String(r.Gid) === params.gid);
- if (!row) return sendJson(res, 404, { message: 'Game data not found' });
- sendJson(res, 200, row);
-});
-
-// ======================================================================
-// CACHES (nested under game-data) CRUDL
-// ======================================================================
-
-// Helper: convert storage cache to API response format
-const toCacheApi = (cache, gid) => ({
- id: cache.CacheId,
- coordinates: { latitude: cache.Latitude, longitude: cache.Longitude },
- radius: cache.TriggerMeters,
- clue: cache.Title,
- groupId: Number(gid),
- subgroupId: cache.SGid,
- ClaimedByUid: cache.ClaimedByUid || null,
- ClaimedByTid: cache.ClaimedByTid || null,
-});
-
-// Helper: convert API payload to storage cache format
-const toCacheStorage = (data, cacheId) => ({
- CacheId: cacheId,
- Title: data.clue || data.Title || '',
- Latitude: data.latitude != null ? data.latitude : (data.Latitude || 0),
- Longitude: data.longitude != null ? data.longitude : (data.Longitude || 0),
- TriggerMeters: data.radius || data.TriggerMeters || 20,
- 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
-addRoute('GET', '/game-data/:gid/caches', (req, res, params, query) => {
- 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' });
-
- let caches = (entry.GeoCaches || []).map((c) => toCacheApi(c, params.gid));
- if (query.SGid) caches = caches.filter((c) => String(c.subgroupId) === query.SGid);
- sendJson(res, 200, caches);
-});
-
-// GET /game-data/:gid/caches/:cacheId — read single cache
-addRoute('GET', '/game-data/:gid/caches/:cacheId', (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 cache = (entry.GeoCaches || []).find((c) => c.CacheId === params.cacheId);
- if (!cache) return sendJson(res, 404, { message: 'Cache not found' });
- sendJson(res, 200, toCacheApi(cache, params.gid));
-});
-
-// POST /game-data/:gid/caches — create a new cache
-addRoute('POST', '/game-data/:gid/caches', 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 existingIds = (entry.GeoCaches || []).map((c) => {
- const num = parseInt(String(c.CacheId).replace(/\D/g, ''), 10);
- return isNaN(num) ? 0 : num;
- });
- const nextNum = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1;
- const cacheId = `C${nextNum}`;
-
- const stored = toCacheStorage(payload, cacheId);
- if (!entry.GeoCaches) entry.GeoCaches = [];
- entry.GeoCaches.push(stored);
- writeJson('game-data.json', rows);
- sendJson(res, 201, toCacheApi(stored, params.gid));
-});
-
-// PUT /game-data/:gid/caches/:cacheId — update a cache
-addRoute('PUT', '/game-data/:gid/caches/:cacheId', 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 idx = (entry.GeoCaches || []).findIndex((c) => c.CacheId === params.cacheId);
- if (idx === -1) return sendJson(res, 404, { message: 'Cache not found' });
-
- const existing = entry.GeoCaches[idx];
- const updated = { ...existing, ...toCacheStorage(payload, params.cacheId) };
- entry.GeoCaches[idx] = updated;
- writeJson('game-data.json', rows);
- sendJson(res, 200, toCacheApi(updated, params.gid));
-});
-
-// DELETE /game-data/:gid/caches/:cacheId — delete a cache
-addRoute('DELETE', '/game-data/:gid/caches/:cacheId', (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 idx = (entry.GeoCaches || []).findIndex((c) => c.CacheId === params.cacheId);
- if (idx === -1) return sendJson(res, 404, { message: 'Cache not found' });
-
- entry.GeoCaches.splice(idx, 1);
- writeJson('game-data.json', rows);
- sendJson(res, 204, null);
-});
-
-// ======================================================================
-// HEALTH CHECK
-// ======================================================================
-
-addRoute('GET', '/health', (req, res) => {
- sendJson(res, 200, { ok: true });
-});
-
-// ======================================================================
-// SERVER
-// ======================================================================
-
-const server = http.createServer(async (req, res) => {
- // CORS preflight
- if (req.method === 'OPTIONS') return sendJson(res, 200, { ok: true });
-
- const url = new URL(req.url, 'http://localhost');
- const pathname = url.pathname.replace(/\/+$/, '') || '/';
- const query = Object.fromEntries(url.searchParams);
-
- const matched = matchRoute(req.method, pathname);
- if (matched) {
- try {
- await matched.handler(req, res, matched.params, query);
- } catch (err) {
- sendJson(res, 500, { message: err.message });
- }
- return;
- }
-
- sendJson(res, 404, { message: 'Not found' });
-});
-
-server.listen(PORT, '0.0.0.0', () => {
- process.stdout.write(`Server running on http://0.0.0.0:${PORT}\n`);
- process.stdout.write(` -> Local: http://localhost:${PORT}\n`);
- // Log the LAN IP so users know what to point to from other devices
- const nets = require('os').networkInterfaces();
- for (const name of Object.keys(nets)) {
- for (const net of nets[name]) {
- if (net.family === 'IPv4' && !net.internal) {
- process.stdout.write(` -> Network: http://${net.address}:${PORT}\n`);
- }
- }
- }
-});
-
-server.on('error', (err) => {
- if (err.code === 'EADDRINUSE') {
- process.stderr.write(`ERROR: Port ${PORT} is already in use.\n`);
- } else {
- process.stderr.write(`ERROR: ${err.message}\n`);
- }
- process.exit(1);
-});
-
diff --git a/src/components/UI/Button.js b/src/components/UI/Button.js
index 3c39ec2..efa099d 100644
--- a/src/components/UI/Button.js
+++ b/src/components/UI/Button.js
@@ -1,15 +1,15 @@
import {Pressable, StyleSheet, Text, View} from "react-native";
-export const Button = ({ label, icon, onClick, styleLabel, styleButton }) => {
+export const Button = ({ label, icon, onClick, styleLabel, styleButton, disabled }) => {
// Initialisation -------------
// State ----------------------
// Handlers -------------------
// View -----------------------
return (
-
+
{icon ? icon : null}
- {label}
+ {label}
);
};
@@ -44,5 +44,12 @@ const styles = StyleSheet.create({
flexDirection: "row",
gap: 15,
},
+ // Greyed-out state for buttons that should not be interactive
+ buttonDisabled: {
+ opacity: 0.4,
+ },
+ labelDisabled: {
+ color: '#9ca3af',
+ },
});
diff --git a/src/components/UI/Card.js b/src/components/UI/Card.js
index 22d6d3f..e248505 100644
--- a/src/components/UI/Card.js
+++ b/src/components/UI/Card.js
@@ -13,12 +13,12 @@ const Card = ({children, style}) => {
const styles = StyleSheet.create({
card: {
- backgroundColor: '#ffffff',
+ backgroundColor: '#313244',
borderRadius: 8,
padding: 12,
marginBottom: 8,
borderWidth: 1,
- borderColor: '#e5e7eb',
+ borderColor: '#45475a',
},
});
diff --git a/src/components/UI/PendingApprovalView.js b/src/components/UI/PendingApprovalView.js
new file mode 100644
index 0000000..28aef23
--- /dev/null
+++ b/src/components/UI/PendingApprovalView.js
@@ -0,0 +1,16 @@
+import {StyleSheet, Text} from 'react-native';
+import Screen from '../layout/Screen';
+
+const PendingApprovalView = ({message = 'Waiting for admin approval. You will get access once approved.'}) => (
+
+ {message}
+
+);
+
+const styles = StyleSheet.create({
+ center: {justifyContent: 'center', alignItems: 'center', flex: 1},
+ body: {color: '#bac2de', fontSize: 15, textAlign: 'center', paddingHorizontal: 20},
+});
+
+export default PendingApprovalView;
+
diff --git a/src/components/gameplay/CacheCardItem.js b/src/components/gameplay/CacheCardItem.js
index f5c9341..97fb74c 100644
--- a/src/components/gameplay/CacheCardItem.js
+++ b/src/components/gameplay/CacheCardItem.js
@@ -2,29 +2,28 @@ import {StyleSheet, Text, View} from 'react-native';
import Card from '../UI/Card';
import {Button} from '../UI/Button';
-const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => {
-// Initialisation -------------
+const CacheCardItem = ({cache, isAdmin, isClaimed, isSelected, onEdit, onDelete, onSelect, disabled, departmentName}) => {
+ // Display name fallback for cache without a name
+ const displayName = cache.name || cache.clue || 'Unnamed Cache';
- const isClaimed = Boolean(cache.ClaimedByUid);
- const statusText = isClaimed ? 'Claimed' : 'Available';
- const statusColor = isClaimed ? '#9ca3af' : '#16a34a';
- const displayName = cache.name || cache.clue || 'Unnamed Cache';
-
-// State ----------------------
-// Handlers -------------------
-// View -----------------------
-
- return (
-
-
-
- {displayName}
- {cache.name ? (
- {cache.clue}
- ) : null}
-
- {statusText}
-
+ return (
+
+
+
+ {displayName}
+ {isAdmin && departmentName ? (
+ Department: {departmentName}
+ ) : null}
+ {cache.name ? (
+ {cache.clue}
+ ) : null}
+
+ {!isAdmin && !disabled && (
+
+ {isClaimed ? 'Claimed' : 'Available'}
+
+ )}
+
{isAdmin ? (
<>
@@ -41,12 +40,35 @@ const CacheCardItem = ({cache, isAdmin, onEdit, onDelete, onSelect}) => {
styleLabel={styles.btnLabel}
/>
>
+ ) : disabled ? (
+
@@ -61,33 +83,56 @@ const styles = StyleSheet.create({
alignItems: 'center',
marginBottom: 8,
},
- clue: {fontSize: 13, color: '#6b7280', marginTop: 2},
+ clue: {fontSize: 13, color: '#9ca3af', marginTop: 2},
+ departmentText: {fontSize: 13, color: '#bd93f9', marginTop: 2, fontWeight: '600'},
nameWrap: {flex: 1, marginRight: 8},
- name: {fontSize: 15, fontWeight: '600', color: '#1f2937'},
+ name: {fontSize: 15, fontWeight: '600', color: '#cdd6f4'},
+ claimedText: {color: '#6c7086'},
status: {fontSize: 13, fontWeight: '600'},
actions: {flexDirection: 'row', gap: 8},
editBtn: {
- backgroundColor: '#2563eb',
- borderColor: '#2563eb',
+ backgroundColor: '#bd93f9',
+ borderColor: '#bd93f9',
minHeight: 36,
paddingHorizontal: 14,
flex: 1,
},
deleteBtn: {
- backgroundColor: '#dc2626',
- borderColor: '#dc2626',
+ backgroundColor: '#D92800',
+ borderColor: '#D92800',
minHeight: 36,
paddingHorizontal: 14,
flex: 1,
},
selectBtn: {
- backgroundColor: '#16a34a',
- borderColor: '#16a34a',
+ backgroundColor: '#a6e3a1',
+ borderColor: '#a6e3a1',
minHeight: 36,
paddingHorizontal: 14,
flex: 1,
},
- btnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
+ selectedBtn: {
+ backgroundColor: '#6c7086',
+ borderColor: '#6c7086',
+ minHeight: 36,
+ paddingHorizontal: 14,
+ flex: 1,
+ },
+ claimedBtn: {
+ backgroundColor: '#45475a',
+ borderColor: '#45475a',
+ minHeight: 36,
+ paddingHorizontal: 14,
+ flex: 1,
+ },
+ claimedBtnLabel: {color: '#6c7086', fontWeight: '600', fontSize: 13},
+ selectedBtnLabel: {color: '#cdd6f4', fontWeight: '600', fontSize: 13},
+ selectBtnLabel: {color: '#000000', fontWeight: '600', fontSize: 13},
+ selectedCard: {borderColor: '#bd93f9', borderWidth: 2},
+ claimedCard: {opacity: 0.5},
+ disabledCard: {opacity: 0.45},
+ disabledText: {color: '#6c7086'},
+ btnLabel: {color: '#cdd6f4', fontWeight: '600', fontSize: 13},
});
export default CacheCardItem;
diff --git a/src/components/gameplay/ClaimTimerView.js b/src/components/gameplay/ClaimTimerView.js
index c38315a..45b8c0f 100644
--- a/src/components/gameplay/ClaimTimerView.js
+++ b/src/components/gameplay/ClaimTimerView.js
@@ -1,30 +1,37 @@
-import React, {useEffect, useState} from 'react';
+import React, {useEffect, useRef, useState} from 'react';
import {StyleSheet, Text, View} from 'react-native';
import {CACHE_CLAIM_TIMER} from '../../utils/geoMath';
-const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => {
-// Initialisation -------------
-// State ----------------------
-
+const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming, showClaimedPopup }) => {
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 -------------------
useEffect(() => {
let timer;
- if (isClaiming && timeLeft > 0) {
+ if (isClaiming && cache && timeLeft > 0) {
timer = setTimeout(() => setTimeLeft((prev) => prev - 1), 1000);
} else if (isClaiming && timeLeft === 0 && cache) {
- onClaimSuccess(cache.id);
- } else {
+ onClaimRef.current(cache.id);
+ } else if (!isClaiming) {
setTimeLeft(CACHE_CLAIM_TIMER);
}
return () => clearTimeout(timer);
- }, [isClaiming, timeLeft, cache, onClaimSuccess]);
+ }, [isClaiming, timeLeft, cache]);
-// View -----------------------
- if (!isClaiming) {
+ // Show "Cache Claimed!" popup after a successful claim
+ if (showClaimedPopup) {
+ return (
+
+ Cache Claimed!
+
+ );
+ }
+
+ if (!isClaiming || !cache) {
return null;
}
@@ -38,17 +45,30 @@ const ClaimTimerView = ({ cache, onClaimSuccess, isClaiming }) => {
const styles = StyleSheet.create({
overlay: {
alignSelf: 'center',
- backgroundColor: 'rgba(0,0,0,0.75)',
+ backgroundColor: 'rgba(0, 0, 0, 0.75)',
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 10,
marginTop: 8,
},
text: {
- color: '#ffffff',
+ color: '#cdd6f4',
fontSize: 16,
fontWeight: '700',
},
+ claimedOverlay: {
+ alignSelf: 'center',
+ backgroundColor: '#a6e3a1',
+ paddingHorizontal: 20,
+ paddingVertical: 14,
+ borderRadius: 10,
+ marginTop: 8,
+ },
+ claimedText: {
+ color: '#1e1e2e',
+ fontSize: 18,
+ fontWeight: '700',
+ },
});
export default ClaimTimerView;
diff --git a/src/components/gameplay/PlayerMapView.js b/src/components/gameplay/PlayerMapView.js
index a888b2e..5ff1f4b 100644
--- a/src/components/gameplay/PlayerMapView.js
+++ b/src/components/gameplay/PlayerMapView.js
@@ -1,12 +1,8 @@
-import MapView, {Circle, Polygon} from 'react-native-maps';
+import React from 'react';
+import MapView, {Marker, Polygon} from 'react-native-maps';
import {getFovCone} from '../../utils/geoMath';
-const PlayerMapView = ({userLocation, visibleCache, heading, claimDistance}) => {
-// Initialisation -------------
-// State ----------------------
-// Handlers -------------------
-// View -----------------------
-
+const PlayerMapView = ({userLocation, visibleCaches, heading}) => {
if (!userLocation) return null;
const coneCoords = (heading !== null && heading !== undefined)
@@ -16,21 +12,22 @@ const PlayerMapView = ({userLocation, visibleCache, heading, claimDistance}) =>
return (
- {visibleCache && (
- (
+
- )}
+ ))}
{coneCoords && (
};
export default PlayerMapView;
+
+
diff --git a/src/components/layout/BottomNavbar.js b/src/components/layout/BottomNavbar.js
index 5e210c0..3a7ab1a 100644
--- a/src/components/layout/BottomNavbar.js
+++ b/src/components/layout/BottomNavbar.js
@@ -5,6 +5,8 @@ import API, { API_BASE_URL } from "../API/API";
const BottomNavbar = ({ navigation, routeName }) => {
// Initialisation ------------
+const BottomNavbar = ({navigation, routeName, routeParams}) => {
+// Initialisation ------------
const insets = useSafeAreaInsets();
const session = getSession();
@@ -73,6 +75,48 @@ const BottomNavbar = ({ navigation, routeName }) => {
];
const tabs = session.isAcceptedAdmin ? adminTabs : playerTabs;
+ const selectedDepartment = routeName === 'DepartmentSettingsScreen' && routeParams?.SGid
+ ? {SGid: routeParams.SGid, SubGroupName: routeParams.SubGroupName || routeParams.selectedDepartmentName || 'Department'}
+ : null;
+
+ const adminTabs = [
+ {label: 'Settings', route: 'GameSettingsScreen', onPress: () => navigation.navigate('GameSettingsScreen')},
+ {
+ label: 'Map',
+ route: 'MapScreen',
+ onPress: () => navigation.navigate('MapScreen', {
+ selectedDepartmentSGid: null,
+ selectedDepartmentName: null,
+ }),
+ },
+ {
+ label: 'Players',
+ route: 'PlayersScreen',
+ onPress: () => navigation.navigate('PlayersScreen', selectedDepartment ? {department: selectedDepartment} : undefined),
+ },
+ {
+ label: 'Leaderboard',
+ route: 'LeaderboardScreen',
+ onPress: () => navigation.navigate('LeaderboardScreen', selectedDepartment ? {department: selectedDepartment} : undefined),
+ },
+ ];
+
+ // Only show the Leave tab when the player is actually in a game
+ const inGame = Boolean(session.currentGid);
+
+ const playerTabs = [
+ {label: 'Team', route: 'TeamScreen', onPress: () => navigation.navigate('TeamScreen')},
+ {label: 'Map', route: 'MapScreen', onPress: () => navigation.navigate('MapScreen')},
+ {label: 'Leaderboard', route: 'LeaderboardScreen', onPress: () => navigation.navigate('LeaderboardScreen')},
+ ...(inGame ? [{label: 'Leave', route: '__leave__', onPress: () => handleLeaveGame()}] : []),
+ ];
+
+ const tabs = session.isAcceptedAdmin ? adminTabs : playerTabs;
+ const pendingTabs = [
+ {label: 'Pending', route: 'TeamScreen', onPress: () => navigation.navigate('TeamScreen')},
+ ...(inGame ? [{label: 'Leave', route: '__leave__', onPress: () => handleLeaveGame()}] : []),
+ ];
+ const visibleTabs = (session.isPendingAdmin && !session.isAcceptedAdmin) ? pendingTabs : tabs;
// State ----------------------
// Handlers -------------------
@@ -98,6 +142,25 @@ const BottomNavbar = ({ navigation, routeName }) => {
},
]);
};
+ const handleLeaveGame = () => {
+ Alert.alert('Leave Game', 'Are you sure you want to leave this game?', [
+ {text: 'Cancel', style: 'cancel'},
+ {
+ text: 'Leave',
+ style: 'destructive',
+ onPress: async () => {
+ const s = getSession();
+ const endpoint = `${API_BASE_URL}/subgroup-members?Uid=${s.currentUid}&Gid=${s.currentGid}`;
+ const res = await API.get(endpoint);
+ if (res.isSuccess && res.result.length > 0) {
+ await API.delete(`${API_BASE_URL}/subgroup-members/${res.result[0].id}`);
+ }
+ clearGameSession();
+ navigation.reset({index: 0, routes: [{name: 'Game'}]});
+ },
+ },
+ ]);
+ };
// View -----------------------
@@ -169,6 +232,64 @@ const styles = StyleSheet.create({
tabTextLeave: {
color: "#fca5a5",
},
+ return (
+
+ {visibleTabs.map((tab) => {
+ const isActive = routeName === tab.route;
+ const isLeave = tab.route === '__leave__';
+ return (
+
+
+ {tab.label}
+
+
+ );
+ })}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ minHeight: 64,
+ borderTopWidth: 1,
+ borderTopColor: '#45475a',
+ backgroundColor: '#313244',
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-around',
+ paddingHorizontal: 8,
+ zIndex: 10,
+ },
+ tab: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 12,
+ marginHorizontal: 4,
+ },
+ tabActive: {
+ backgroundColor: '#45475a',
+ },
+ tabLeave: {
+ backgroundColor: 'rgba(243, 139, 168, 0.15)',
+ },
+ tabText: {
+ color: '#bac2de',
+ fontSize: 12,
+ fontWeight: '600',
+ textAlign: 'center',
+ },
+ tabTextActive: {
+ color: '#cdd6f4',
+ },
+ tabTextLeave: {
+ color: '#D92800',
+ },
});
export default BottomNavbar;
diff --git a/src/components/layout/Screen.js b/src/components/layout/Screen.js
index 9342601..8311165 100644
--- a/src/components/layout/Screen.js
+++ b/src/components/layout/Screen.js
@@ -18,6 +18,7 @@ const Screen = ({ children, style, showBack = false }) => {
MapScreen: "Map",
GameSettingsScreen: "Game Settings",
GameSelectionScreen: "Games",
+ DepartmentSettingsScreen: "Department Settings",
PlayersScreen: "Players",
ExpandedMapScreen: "Map View",
GlobalEventsScreen: "Global Events",
@@ -57,6 +58,7 @@ const Screen = ({ children, style, showBack = false }) => {
{!isAuthScreen && (
)}
+ {!isAuthScreen && }
);
@@ -65,11 +67,13 @@ const Screen = ({ children, style, showBack = false }) => {
const styles = StyleSheet.create({
screen: {
flex: 1,
- backgroundColor: "#fff",
+ backgroundColor: '#1e1e2e',
},
content: {
flex: 1,
padding: 15,
+ zIndex: 1,
+ overflow: 'hidden',
},
});
diff --git a/src/components/layout/TopNavbar.js b/src/components/layout/TopNavbar.js
index 751094d..b7bdd91 100644
--- a/src/components/layout/TopNavbar.js
+++ b/src/components/layout/TopNavbar.js
@@ -47,12 +47,13 @@ const styles = StyleSheet.create({
container: {
minHeight: 56,
borderBottomWidth: 1,
- borderBottomColor: '#333333',
+ borderBottomColor: '#45475a',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
- backgroundColor: '#000000',
+ backgroundColor: '#313244',
+ zIndex: 10,
},
leftSlot: {
width: 72,
diff --git a/src/components/screens/DepartmentSettingsScreen.js b/src/components/screens/DepartmentSettingsScreen.js
new file mode 100644
index 0000000..1d6824a
--- /dev/null
+++ b/src/components/screens/DepartmentSettingsScreen.js
@@ -0,0 +1,224 @@
+import React, {useEffect, useState} from 'react';
+import {ActivityIndicator, Alert, ScrollView, StyleSheet, Switch, Text, TextInput, View} from 'react-native';
+import Screen from '../layout/Screen';
+import {Button, ButtonTray} from '../UI/Button';
+import useGameHook from '../../hooks/useGameHook';
+
+const DepartmentSettingsScreen = ({route, navigation}) => {
+ const {SGid, Gid} = route.params;
+ const {getSubgroup, updateSubgroup, getCaches, resetGame} = useGameHook();
+
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [deptName, setDeptName] = useState('');
+ const [joinCode, setJoinCode] = useState('');
+ const [teamsEnabled, setTeamsEnabled] = useState(false);
+ const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
+ const [cacheCount, setCacheCount] = useState(0);
+
+// Handlers -------------------
+
+ useEffect(() => {
+ const load = async () => {
+ const sg = await getSubgroup(SGid);
+ if (sg) {
+ setDeptName(sg.SubGroupName || '');
+ setJoinCode(sg.JoinCode || '');
+ setTeamsEnabled(Boolean(sg.TeamsEnabled));
+ setCacheTriggerMeters(String(sg.CacheTriggerMeters || 20));
+ }
+ // Load cache count for this department
+ const caches = await getCaches(Gid, SGid);
+ setCacheCount((caches || []).length);
+ setLoading(false);
+ };
+ load();
+ }, []);
+
+ const handleSave = async () => {
+ setSaving(true);
+ await updateSubgroup(SGid, {
+ SubGroupName: deptName.trim(),
+ TeamsEnabled: teamsEnabled,
+ CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20,
+ });
+ setSaving(false);
+ Alert.alert('Saved', 'Department settings updated.');
+ };
+
+ const handleResetDepartment = () => {
+ Alert.alert(
+ 'Reset Department',
+ 'This will reset all cache claims for this department. Are you sure?',
+ [
+ {text: 'Cancel', style: 'cancel'},
+ {
+ text: 'Reset',
+ style: 'destructive',
+ onPress: async () => {
+ await resetGame(Gid, SGid);
+ Alert.alert('Done', 'Department progress has been reset.');
+ },
+ },
+ ],
+ );
+ };
+
+ const handleManageCaches = () => {
+ navigation.navigate('MapScreen', {
+ selectedDepartmentSGid: SGid,
+ selectedDepartmentName: deptName || null,
+ });
+ };
+
+// View -----------------------
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+ Department Settings
+
+ Department Name
+
+
+
+ Department Join Code
+ {joinCode || '—'}
+
+
+
+ Teams Enabled
+
+
+
+ Cache Claim Distance (metres)
+
+
+
+ Total Caches
+ {cacheCount}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ center: {justifyContent: 'center', alignItems: 'center'},
+ formContainer: {paddingBottom: 20},
+ sectionTitle: {fontSize: 20, fontWeight: '700', color: '#cdd6f4', marginBottom: 16},
+ label: {fontSize: 14, fontWeight: '600', color: '#bac2de', marginBottom: 6, marginTop: 14},
+ input: {
+ borderWidth: 1,
+ borderColor: '#45475a',
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ fontSize: 16,
+ color: '#cdd6f4',
+ backgroundColor: '#313244',
+ },
+ codeSection: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginTop: 16,
+ paddingVertical: 12,
+ paddingHorizontal: 12,
+ backgroundColor: '#313244',
+ borderRadius: 8,
+ },
+ codeLabel: {fontSize: 14, fontWeight: '600', color: '#bac2de'},
+ codeValue: {fontSize: 16, fontWeight: '700', color: '#bd93f9', letterSpacing: 2},
+ toggleRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginTop: 16,
+ paddingVertical: 10,
+ borderBottomWidth: 1,
+ borderBottomColor: '#45475a',
+ },
+ toggleLabel: {fontSize: 14, fontWeight: '600', color: '#bac2de'},
+ infoRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginTop: 16,
+ paddingVertical: 12,
+ paddingHorizontal: 12,
+ backgroundColor: '#313244',
+ borderRadius: 8,
+ },
+ infoLabel: {fontSize: 14, fontWeight: '600', color: '#bac2de'},
+ infoValue: {fontSize: 16, fontWeight: '700', color: '#cdd6f4'},
+ saveWrap: {marginTop: 24},
+ saveButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1'},
+ saveLabel: {color: '#1e1e2e', fontWeight: '600'},
+ mapWrap: {marginTop: 16},
+ mapButton: {backgroundColor: '#bd93f9', borderColor: '#bd93f9'},
+ mapLabel: {color: '#1e1e2e', fontWeight: '600'},
+ resetWrap: {marginTop: 30},
+ resetButton: {backgroundColor: '#86efac', borderColor: '#86efac'},
+ resetLabel: {color: '#1e1e2e', fontWeight: '600'},
+});
+
+export default DepartmentSettingsScreen;
+
diff --git a/src/components/screens/ExpandedMapScreen.js b/src/components/screens/ExpandedMapScreen.js
index bc08f26..8396083 100644
--- a/src/components/screens/ExpandedMapScreen.js
+++ b/src/components/screens/ExpandedMapScreen.js
@@ -3,25 +3,18 @@ import {StyleSheet, View} from 'react-native';
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
import * as Location from 'expo-location';
import Screen from '../layout/Screen';
-import {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 ExpandedMapScreen = ({route}) => {
-// Initialisation ------------
-
- const {isAdmin, cacheRecords: cacheStr, claimDistance: routeClaimDistance, userLocation: routeLocation} = route.params || {};
+ const {isAdmin, cacheRecords: cacheStr, claimDistance: routeClaimDistance, userLocation: routeLocation, selectedCacheId} = route.params || {};
const caches = cacheStr ? JSON.parse(cacheStr) : [];
const claimDistance = routeClaimDistance || 20;
-// State ----------------------
-
- // Seed with the location passed from MapScreen so the map opens centred on the user immediately
const [userLocation, setUserLocation] = useState(routeLocation || null);
const [heading, setHeading] = useState(null);
-// Handlers -------------------
-
useEffect(() => {
let locationSub;
let headingSub;
@@ -30,7 +23,6 @@ const ExpandedMapScreen = ({route}) => {
const {status} = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') return;
- // Only do a fresh one-shot fix if MapScreen didn't supply a location
if (!routeLocation) {
const last = await Location.getLastKnownPositionAsync();
if (last) {
@@ -46,7 +38,6 @@ const ExpandedMapScreen = ({route}) => {
(next) => setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude}),
);
- // Use OS-fused heading from expo-location
headingSub = await Location.watchHeadingAsync((headingData) => {
const raw = headingData.trueHeading >= 0 ? headingData.trueHeading : headingData.magHeading;
setHeading(raw);
@@ -64,28 +55,25 @@ const ExpandedMapScreen = ({route}) => {
? {...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}
: DEFAULT_REGION;
-// View -----------------------
+ const hasHeading = heading !== null;
+ const coneCoords = (userLocation && hasHeading) ? getFovCone(userLocation, heading) : null;
- const coneCoords = (userLocation && heading !== null)
- ? getFovCone(userLocation, heading)
- : null;
+ const visiblePlayerCaches = (!isAdmin && userLocation && hasHeading && selectedCacheId)
+ ? caches.filter((c) => c.id === selectedCacheId && isInClaimCone(heading, userLocation, c.coordinates, claimDistance))
+ : [];
return (
- {/* Only admins see cache pin locations */}
{isAdmin && caches.map((cache) => (
-
+
{
/>
))}
- {/* Heading FOV cone */}
+ {!isAdmin && visiblePlayerCaches.map((cache) => (
+
+ ))}
{coneCoords && (
{
-// Initialisation ------------
+const GameSettingsScreen = ({navigation}) => {
+ const session = getSession();
+ const {getLobby, updateGroup, getSubgroups, createSubgroup, deleteSubgroup, resetGame, getAdminWaitlist, approveAdmin, rejectAdmin, getUser, disbandTeams} = useGameHook();
+ const gameApiRef = useRef({
+ getLobby,
+ getSubgroups,
+ getAdminWaitlist,
+ getUser,
+ });
+ gameApiRef.current = {
+ getLobby,
+ getSubgroups,
+ getAdminWaitlist,
+ getUser,
+ };
+ const isMountedRef = useRef(true);
+ const isRefreshingRef = useRef(false);
+ const hasHydratedRef = useRef(false);
- const session = getSession();
- const {getLobby, updateGroup, getSubgroups} = useGameHook();
- const isBusiness = Boolean(session.isBusiness);
-
-// State ----------------------
-
- const [loading, setLoading] = useState(true);
- const [saving, setSaving] = useState(false);
- const [groupName, setGroupName] = useState('');
- const [teamsEnabled, setTeamsEnabled] = useState(session.teamsEnabled);
- const [maxSubgroups, setMaxSubgroups] = useState('1');
- const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
- const [adminJoinCode, setAdminJoinCode] = useState('');
- const [memberJoinCode, setMemberJoinCode] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [groupName, setGroupName] = useState('');
+ const [businessName, setBusinessName] = useState('');
+ const [teamsEnabled, setTeamsEnabled] = useState(session.teamsEnabled);
+ const [serverTeamsEnabled, setServerTeamsEnabled] = useState(session.teamsEnabled);
+ const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
+ const [adminJoinCode, setAdminJoinCode] = useState('');
+ const [orgJoinCode, setOrgJoinCode] = useState('');
+ const [memberJoinCode, setMemberJoinCode] = useState('');
+ const [waitlist, setWaitlist] = useState([]);
+ const [waitlistNames, setWaitlistNames] = useState({});
+ const [isBusinessGroup, setIsBusinessGroup] = useState(Boolean(session.isBusiness));
+ const [subgroupList, setSubgroupList] = useState([]);
+ const [newDeptName, setNewDeptName] = useState('');
// Handlers -------------------
- useEffect(() => {
- const load = async () => {
- if (!session.currentGid) {
- setLoading(false);
- return;
- }
- const group = await getLobby(session.currentGid);
+ useEffect(() => () => {
+ isMountedRef.current = false;
+ }, []);
+
+ const refreshSettings = useCallback(async ({showLoader = false} = {}) => {
+ const activeSession = getSession();
+ if (!activeSession.currentGid) {
+ if (isMountedRef.current) setLoading(false);
+ return;
+ }
+
+ // Guard against re-entrant focus refresh calls.
+ if (isRefreshingRef.current) return;
+ isRefreshingRef.current = true;
+
+ if (showLoader && isMountedRef.current) setLoading(true);
+
+ try {
+ const [group, sgs, waitlistRows] = await Promise.all([
+ gameApiRef.current.getLobby(activeSession.currentGid),
+ gameApiRef.current.getSubgroups(activeSession.currentGid),
+ gameApiRef.current.getAdminWaitlist(activeSession.currentGid),
+ ]);
+
+ if (!isMountedRef.current) return;
+
if (group) {
setGroupName(group.GroupName || '');
+ setBusinessName(group.BusinessOrSchoolName || '');
+ // Organisation mode
+ setIsBusinessGroup(Boolean(group.BusinessOrSchoolName));
+
const serverTeams = Boolean(group.TeamsEnabled);
setTeamsEnabled(serverTeams);
- // Keep session in sync so the toggle survives app reloads
+ setServerTeamsEnabled(serverTeams);
setSessionTeamsEnabled(serverTeams);
- setMaxSubgroups(String(group.MaxMemberSubgroups || 1));
+
setCacheTriggerMeters(String(group.CacheTriggerMeters || 20));
setAdminJoinCode(group.AdminJoinCode || '');
+ setOrgJoinCode(group.OrgJoinCode || '');
}
- const sgs = await getSubgroups(session.currentGid);
+
+ setSubgroupList(sgs || []);
const memberSg = (sgs || []).find((sg) => !sg.IsAdminGroup);
- if (memberSg) setMemberJoinCode(memberSg.JoinCode || '');
- setLoading(false);
- };
- load();
+ setMemberJoinCode(memberSg?.JoinCode || '');
+
+ setWaitlist(waitlistRows || []);
+ const names = {};
+ for (const entry of (waitlistRows || [])) {
+ const user = await gameApiRef.current.getUser(entry.Uid);
+ if (user) names[entry.Uid] = user.username;
+ }
+ if (!isMountedRef.current) return;
+ setWaitlistNames(names);
+ hasHydratedRef.current = true;
+ } finally {
+ if (isMountedRef.current && (showLoader || !hasHydratedRef.current)) {
+ setLoading(false);
+ }
+ isRefreshingRef.current = false;
+ }
}, []);
- const handleSave = async () => {
- if (!session.currentGid) return;
+ useFocusEffect(
+ useCallback(() => {
+ refreshSettings({showLoader: !hasHydratedRef.current});
+ }, [refreshSettings]),
+ );
+
+ // --- Individual save ---
+ const saveSettings = async () => {
setSaving(true);
const result = await updateGroup(session.currentGid, {
GroupName: groupName.trim(),
TeamsEnabled: teamsEnabled,
- MaxMemberSubgroups: parseInt(maxSubgroups) || 1,
CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20,
});
- // Persist the new toggle value so the session reflects it after a reload
- if (result) setSessionTeamsEnabled(teamsEnabled);
+ if (result) {
+ if (serverTeamsEnabled && !teamsEnabled) {
+ await disbandTeams(session.currentGid);
+ }
+ setSessionTeamsEnabled(teamsEnabled);
+ setServerTeamsEnabled(teamsEnabled);
+ }
setSaving(false);
};
+ const handleSave = async () => {
+ if (!session.currentGid) return;
+ // Business save — only save business name
+ if (isBusinessGroup) {
+ setSaving(true);
+ await updateGroup(session.currentGid, {
+ BusinessOrSchoolName: businessName.trim(),
+ GroupName: businessName.trim(),
+ });
+ setSaving(false);
+ return;
+ }
+ // Individual save — check teams toggle
+ if (serverTeamsEnabled && !teamsEnabled) {
+ Alert.alert(
+ 'Disable Teams',
+ 'This will disband all existing teams and remove all players from their teams. Continue?',
+ [
+ {text: 'Cancel', style: 'cancel'},
+ {text: 'Disable', style: 'destructive', onPress: saveSettings},
+ ],
+ );
+ return;
+ }
+ await saveSettings();
+ };
+
+ const handleResetGame = () => {
+ Alert.alert('Reset Entire Game', 'This will remove ALL saved caches and reset progress for every player. Are you sure?', [
+ {text: 'Cancel', style: 'cancel'},
+ {
+ text: 'Reset',
+ style: 'destructive',
+ onPress: async () => {
+ await resetGame(session.currentGid);
+ Alert.alert('Done', 'All caches and player progress have been reset.');
+ },
+ },
+ ]);
+ };
+
+ const handleApproveAdmin = async (entry) => {
+ await approveAdmin(entry.id);
+ await refreshSettings();
+ };
+
+ const handleRejectAdmin = async (entry) => {
+ await rejectAdmin(entry.id);
+ await refreshSettings();
+ };
+
+ // Business — create a new department (subgroup)
+ const handleCreateDepartment = async () => {
+ const deptCount = subgroupList.filter((sg) => !sg.IsAdminGroup).length;
+ const name = newDeptName.trim() || `Department ${deptCount + 1}`;
+ if (!session.currentGid) return;
+ await createSubgroup({
+ SubGroupName: name,
+ Gid: session.currentGid,
+ IsAdminGroup: false,
+ CacheTriggerMeters: 20,
+ });
+ setNewDeptName('');
+ await refreshSettings();
+ };
+
+ // Business — delete a department
+ const handleDeleteDepartment = (sg) => {
+ Alert.alert('Delete Department', `Delete "${sg.SubGroupName}" and all its data?`, [
+ {text: 'Cancel', style: 'cancel'},
+ {
+ text: 'Delete',
+ style: 'destructive',
+ onPress: async () => {
+ await deleteSubgroup(sg.SGid);
+ await refreshSettings();
+ },
+ },
+ ]);
+ };
+
+ // Business — navigate to department settings
+ const handleEnterDepartment = (sg) => {
+ navigation.navigate('DepartmentSettingsScreen', {
+ SGid: sg.SGid,
+ Gid: session.currentGid,
+ SubGroupName: sg.SubGroupName,
+ });
+ };
+
// View -----------------------
if (loading) {
@@ -82,6 +239,147 @@ const GameSettingsScreen = () => {
);
}
+ // Filter subgroups for the department list (non-admin only)
+ const departments = subgroupList.filter((sg) => !sg.IsAdminGroup);
+
+ // ===== Business Admin View =====
+ if (isBusinessGroup) {
+ return (
+
+
+ Game Configuration :
+
+ Organisation Name:
+
+
+
+ Admin join code:
+ {adminJoinCode || '—'}
+
+
+
+ Org join code:
+ {orgJoinCode || '—'}
+
+
+
+
+
+
+
+
+ {/* Departments List */}
+
+ Departments:
+ {departments.map((sg, index) => (
+
+ Department {index + 1}:
+
+
+ Department Name
+ {sg.SubGroupName}
+
+
+ handleEnterDepartment(sg)}
+ styleButton={styles.deptOpenButton}
+ styleLabel={styles.deptBtnLabel}
+ />
+
+
+
+
+ Department Join code
+ {sg.JoinCode || '—'}
+
+
+ handleDeleteDepartment(sg)}
+ styleButton={styles.deptDeleteButton}
+ styleLabel={styles.deptBtnLabel}
+ />
+
+
+
+ ))}
+ {departments.length === 0 && (
+ No departments yet.
+ )}
+
+
+
+
+
+
+ {/* Admin Waitlist */}
+ {waitlist.length > 0 && (
+
+ Admin Waitlist
+ {waitlist.map((entry) => (
+
+
+
+ {waitlistNames[entry.Uid] || `User ${entry.Uid}`}
+
+
+ handleApproveAdmin(entry)}
+ styleButton={styles.approveButton}
+ styleLabel={styles.approveBtnLabel}
+ />
+ handleRejectAdmin(entry)}
+ styleButton={styles.rejectButton}
+ styleLabel={styles.rejectBtnLabel}
+ />
+
+
+
+ ))}
+
+ )}
+
+ {/* Reset Entire Game */}
+
+
+
+
+
+ );
+ }
+
+ // ===== Individual Admin View =====
return (
@@ -106,20 +404,6 @@ const GameSettingsScreen = () => {
/>
- {isBusiness && (
- <>
- Max Member Subgroups
-
- >
- )}
-
Cache Claim Distance (metres)
{
- Player Join Code
+ Game Join Code
{memberJoinCode || '—'}
@@ -150,6 +434,46 @@ const GameSettingsScreen = () => {
/>
+
+ {/* Admin Waitlist */}
+ {waitlist.length > 0 && (
+
+ Admin Waitlist
+ {waitlist.map((entry) => (
+
+
+
+ {waitlistNames[entry.Uid] || `User ${entry.Uid}`}
+
+
+ handleApproveAdmin(entry)}
+ styleButton={styles.approveButton}
+ styleLabel={styles.approveBtnLabel}
+ />
+ handleRejectAdmin(entry)}
+ styleButton={styles.rejectButton}
+ styleLabel={styles.rejectBtnLabel}
+ />
+
+
+
+ ))}
+
+ )}
+
+ {/* Reset Entire Game */}
+
+
+
);
@@ -158,18 +482,18 @@ const GameSettingsScreen = () => {
const styles = StyleSheet.create({
center: {justifyContent: 'center', alignItems: 'center'},
formContainer: {paddingBottom: 20},
- sectionTitle: {fontSize: 20, fontWeight: '700', color: '#1f2937', marginBottom: 16},
- label: {fontSize: 14, fontWeight: '600', color: '#374151', marginBottom: 6, marginTop: 14},
- body: {color: '#4b5563', fontSize: 15},
+ sectionTitle: {fontSize: 20, fontWeight: '700', color: '#cdd6f4', marginBottom: 16},
+ label: {fontSize: 14, fontWeight: '600', color: '#bac2de', marginBottom: 6, marginTop: 14},
+ body: {color: '#bac2de', fontSize: 15},
input: {
borderWidth: 1,
- borderColor: '#d1d5db',
+ borderColor: '#45475a',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
- color: '#1f2937',
- backgroundColor: '#ffffff',
+ color: '#cdd6f4',
+ backgroundColor: '#313244',
},
toggleRow: {
flexDirection: 'row',
@@ -178,9 +502,9 @@ const styles = StyleSheet.create({
marginTop: 16,
paddingVertical: 10,
borderBottomWidth: 1,
- borderBottomColor: '#e5e7eb',
+ borderBottomColor: '#45475a',
},
- toggleLabel: {fontSize: 14, fontWeight: '600', color: '#374151'},
+ toggleLabel: {fontSize: 14, fontWeight: '600', color: '#bac2de'},
codeSection: {
flexDirection: 'row',
justifyContent: 'space-between',
@@ -188,14 +512,44 @@ const styles = StyleSheet.create({
marginTop: 16,
paddingVertical: 12,
paddingHorizontal: 12,
- backgroundColor: '#f3f4f6',
+ backgroundColor: '#313244',
borderRadius: 8,
},
- codeLabel: {fontSize: 14, fontWeight: '600', color: '#374151'},
- codeValue: {fontSize: 16, fontWeight: '700', color: '#2563eb', letterSpacing: 2},
+ codeLabel: {fontSize: 14, fontWeight: '600', color: '#bac2de'},
+ codeValue: {fontSize: 16, fontWeight: '700', color: '#bd93f9', letterSpacing: 2},
saveWrap: {marginTop: 24},
- saveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
- saveLabel: {color: '#ffffff', fontWeight: '600'},
+ saveButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1'},
+ saveLabel: {color: '#1e1e2e', fontWeight: '600'},
+ deptSection: {marginTop: 30},
+ deptIndexLabel: {fontSize: 14, fontWeight: '700', color: '#bac2de', marginBottom: 8},
+ deptCardRow: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8},
+ deptCardInfo: {flex: 1, paddingRight: 10},
+ deptCardActions: {flexDirection: 'row', gap: 6, flex: 0},
+ deptInfoLabel: {fontSize: 12, fontWeight: '600', color: '#6c7086', marginBottom: 2},
+ deptName: {fontSize: 15, fontWeight: '700', color: '#cdd6f4'},
+ deptCode: {fontSize: 13, fontWeight: '600', color: '#bd93f9', marginTop: 2, letterSpacing: 1},
+ deptDeleteButton: {backgroundColor: '#D92800', borderColor: '#D92800', minHeight: 36, flex: 0, paddingHorizontal: 10},
+ deptOpenButton: {backgroundColor: '#bd93f9', borderColor: '#bd93f9', minHeight: 36, flex: 0, paddingHorizontal: 10},
+ deptBtnLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13},
+ createDeptRow: {flexDirection: 'row', gap: 10, marginTop: 12, alignItems: 'center'},
+ createDeptButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1', flex: 0, paddingHorizontal: 16},
+ createDeptLabel: {color: '#1e1e2e', fontWeight: '600'},
+ emptyText: {color: '#6c7086', textAlign: 'center', marginTop: 10, fontSize: 14},
+ waitlistSection: {marginTop: 30},
+ waitlistRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ waitlistName: {fontSize: 15, fontWeight: '600', color: '#cdd6f4', flex: 1},
+ waitlistActions: {flexDirection: 'row', gap: 6},
+ approveButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1', minHeight: 36, flex: 0, paddingHorizontal: 10},
+ approveBtnLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13},
+ rejectButton: {backgroundColor: '#D92800', borderColor: '#D92800', minHeight: 36, flex: 0, paddingHorizontal: 10},
+ rejectBtnLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13},
+ resetSection: {marginTop: 30},
+ resetButton: {backgroundColor: '#D92800', borderColor: '#D92800'},
+ resetLabel: {color: '#ffffff', fontWeight: '600'},
});
export default GameSettingsScreen;
diff --git a/src/components/screens/LeaderboardScreen.js b/src/components/screens/LeaderboardScreen.js
index 47cdd9a..670da31 100644
--- a/src/components/screens/LeaderboardScreen.js
+++ b/src/components/screens/LeaderboardScreen.js
@@ -1,24 +1,35 @@
import React, {useCallback, useEffect, useState} from 'react';
-import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
+import {ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
import Screen from '../layout/Screen';
import Card from '../UI/Card';
+import {Button} from '../UI/Button';
+import PendingApprovalView from '../UI/PendingApprovalView';
import useGameHook from '../../hooks/useGameHook';
import {getSession} from '../../hooks/SessionStore';
-const LeaderboardScreen = () => {
+const LeaderboardScreen = ({navigation, route}) => {
// Initialisation ------------
const session = getSession();
- const {getLobby, getTeams, getTeamMembers, getGroupMembers, getCaches, getUser} = useGameHook();
+ const isAdmin = session.isAcceptedAdmin;
+ const isBusiness = Boolean(session.isBusiness);
+ const selectedDepartment = route?.params?.department || null;
+ const effectiveSGid = isBusiness
+ ? ((isAdmin && selectedDepartment?.SGid) ? selectedDepartment.SGid : (isAdmin ? null : session.currentSGid))
+ : null;
+ const {getLobby, getSubgroup, getTeams, getTeamMembers, getGroupMembers, getCaches, getUser, deleteTeam, resetPlayerProgress, resetTeamProgress, getSubgroups} = useGameHook();
// State ----------------------
const [loading, setLoading] = useState(true);
const [teamsEnabled, setTeamsEnabled] = useState(false);
- const [activeTab, setActiveTab] = useState('global');
- const [globalRanking, setGlobalRanking] = useState([]);
- const [teamRanking, setTeamRanking] = useState([]);
+ const [activeTab, setActiveTab] = useState('game');
+ const [teamRankings, setTeamRankings] = useState([]);
+ const [playerRankings, setPlayerRankings] = useState([]);
+ const [myTeamRanking, setMyTeamRanking] = useState([]);
const [expandedTeam, setExpandedTeam] = useState(null);
+ const [totalCacheCount, setTotalCacheCount] = useState(0);
+ const [departmentList, setDepartmentList] = useState([]);
// Handlers -------------------
@@ -26,15 +37,28 @@ const LeaderboardScreen = () => {
if (!session.currentGid) { setLoading(false); return; }
const group = await getLobby(session.currentGid);
- const isTeams = Boolean(group?.TeamsEnabled);
+
+ let isTeams = Boolean(group?.TeamsEnabled);
+ if (effectiveSGid !== null && effectiveSGid !== undefined) {
+ const subgroup = await getSubgroup(effectiveSGid);
+ if (subgroup) isTeams = Boolean(subgroup.TeamsEnabled);
+ }
setTeamsEnabled(isTeams);
- const caches = await getCaches(session.currentGid, null);
- const claimedCaches = (caches || []).filter((c) => c.ClaimedByUid);
+ if (isBusiness && isAdmin && !selectedDepartment) {
+ const sgs = await getSubgroups(session.currentGid);
+ setDepartmentList((sgs || []).filter((sg) => !sg.IsAdminGroup));
+ setLoading(false);
+ return;
+ }
+
+ const caches = await getCaches(session.currentGid, effectiveSGid ?? null);
+ const total = (caches || []).length;
+ setTotalCacheCount(total);
if (isTeams) {
// Build team rankings
- const teams = await getTeams(session.currentGid);
+ const teams = await getTeams(session.currentGid, effectiveSGid ?? null);
const teamData = [];
for (const team of (teams || [])) {
@@ -42,7 +66,9 @@ const LeaderboardScreen = () => {
const memberData = [];
for (const m of (members || [])) {
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({
uid: m.Uid,
name: user?.username || `Player ${m.Uid}`,
@@ -51,7 +77,9 @@ const LeaderboardScreen = () => {
});
}
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({
tid: team.Tid,
name: team.TeamName || 'Unnamed Team',
@@ -62,20 +90,22 @@ const LeaderboardScreen = () => {
}
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) {
const myTeam = teamData.find((t) => t.tid === session.currentTid);
- setTeamRanking(myTeam ? myTeam.members : []);
+ setMyTeamRanking(myTeam ? myTeam.members : []);
}
} else {
// Build player rankings (no teams)
- const allMembers = await getGroupMembers(session.currentGid);
+ const scopedMembers = await getGroupMembers(session.currentGid, effectiveSGid ?? null);
const playerData = [];
- for (const m of (allMembers || []).filter((m) => !m.IsAcceptedAdmin)) {
+ for (const m of (scopedMembers || []).filter((mem) => !mem.IsAcceptedAdmin)) {
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({
uid: m.Uid,
name: user?.username || `Player ${m.Uid}`,
@@ -83,14 +113,60 @@ const LeaderboardScreen = () => {
});
}
playerData.sort((a, b) => b.caches - a.caches);
- setGlobalRanking(playerData);
+ setPlayerRankings(playerData);
}
setLoading(false);
- }, [session.currentGid, session.currentTid]);
+ }, [session.currentGid, session.currentTid, session.currentSGid, isBusiness, isAdmin, selectedDepartment, selectedDepartment?.SGid, effectiveSGid]);
useEffect(() => { loadData(); }, [loadData]);
+ // Admin handler: delete a team and reload
+ const handleDeleteTeam = (team) => {
+ Alert.alert('Delete Team', `Delete "${team.name}" and kick all its members?`, [
+ {text: 'Cancel', style: 'cancel'},
+ {
+ text: 'Delete',
+ style: 'destructive',
+ onPress: async () => {
+ await deleteTeam(team.tid);
+ await loadData();
+ },
+ },
+ ]);
+ };
+
+ // Admin handler: reset a single player's progress
+ const handleResetPlayer = (player) => {
+ Alert.alert('Reset Progress', `Reset all cache claims for ${player.name}?`, [
+ {text: 'Cancel', style: 'cancel'},
+ {
+ text: 'Reset',
+ style: 'destructive',
+ onPress: async () => {
+ await resetPlayerProgress(session.currentGid, player.uid, effectiveSGid);
+ Alert.alert('Done', `Progress for ${player.name} has been reset.`);
+ await loadData();
+ },
+ },
+ ]);
+ };
+
+ const handleResetTeam = (team) => {
+ Alert.alert('Reset Team Progress', `Reset all cache claims for team "${team.name}"?`, [
+ {text: 'Cancel', style: 'cancel'},
+ {
+ text: 'Reset',
+ style: 'destructive',
+ onPress: async () => {
+ await resetTeamProgress(session.currentGid, team.tid, effectiveSGid);
+ Alert.alert('Done', `Progress for team "${team.name}" has been reset.`);
+ await loadData();
+ },
+ },
+ ]);
+ };
+
// View -----------------------
if (loading) {
@@ -109,64 +185,212 @@ const LeaderboardScreen = () => {
);
}
- // Teams enabled — tabbed view
+ if (session.isPendingAdmin && !session.isAcceptedAdmin) {
+ return ;
+ }
+
+ if (isBusiness && isAdmin && !selectedDepartment) {
+ return (
+
+ Departments
+
+ {departmentList.map((department) => (
+ navigation.navigate('LeaderboardScreen', {department})}
+ >
+
+
+ {department.SubGroupName}
+ Open
+
+
+
+ ))}
+ {departmentList.length === 0 && (
+ No departments yet.
+ )}
+
+
+ );
+ }
+
+ // Helper: render an expandable team card (admin version with delete + reset)
+ const renderAdminTeamCard = (team, index) => (
+
+ setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}>
+
+ #{index + 1}
+ {team.name} ({team.code})
+ {team.totalCaches}/{totalCacheCount}
+
+
+ {expandedTeam === team.tid && (
+
+ {team.members.map((m) => (
+ // Highlight the current user's row so they can identify themselves
+
+
+ {m.name}{m.isLeader ? ' (Team Leader)' : ''}
+
+ {m.caches}/{totalCacheCount}
+ handleResetPlayer(m)}
+ >
+ Reset
+
+
+ ))}
+
+ handleResetTeam(team)}
+ styleButton={styles.resetTeamButton}
+ styleLabel={styles.deleteTeamLabel}
+ />
+ handleDeleteTeam(team)}
+ styleButton={styles.deleteTeamButton}
+ styleLabel={styles.deleteTeamDeleteLabel}
+ />
+
+
+ )}
+
+ );
+
+ // Helper: render an expandable team card (player version)
+ const renderTeamCard = (team, index) => (
+ setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}>
+
+
+ #{index + 1}
+ {team.name} ({team.code})
+ {team.totalCaches}/{totalCacheCount}
+
+ {expandedTeam === team.tid && team.members.map((m) => (
+ // Highlight the current user's row so they can identify themselves
+
+
+ {m.name}{m.isLeader ? ' (Team Leader)' : ''}
+
+ {m.caches}/{totalCacheCount}
+
+ ))}
+
+
+ );
+
+ // Helper: render an admin player card (with reset button)
+ const renderAdminPlayerCard = (player, index) => (
+ // Highlight the current user's card so they can identify themselves
+
+
+ #{index + 1}
+ {player.name}
+ {player.caches}/{totalCacheCount}
+ handleResetPlayer(player)}
+ >
+ Reset
+
+
+
+ );
+
+ // Helper: render a simple player card
+ const renderPlayerCard = (player, index) => (
+ // Highlight the current user's card so they can identify themselves
+
+
+ #{index + 1}
+ {player.name}
+ {player.caches}/{totalCacheCount}
+
+
+ );
+
+ // ---------- ADMIN LEADERBOARD ----------
+ if (isAdmin) {
+ // Teams enabled: show team list (no tabs for admin)
+ if (teamsEnabled) {
+ return (
+
+ {selectedDepartment && (
+ {selectedDepartment.SubGroupName}
+ )}
+
+ {teamRankings.map((team, index) => renderAdminTeamCard(team, index))}
+ {teamRankings.length === 0 && (
+ No teams yet.
+ )}
+
+
+ );
+ }
+
+ // Teams disabled: show player list with reset buttons
+ return (
+
+ {selectedDepartment && (
+ {selectedDepartment.SubGroupName}
+ )}
+
+ {playerRankings.map((player, index) => renderAdminPlayerCard(player, index))}
+ {playerRankings.length === 0 && (
+ No players yet.
+ )}
+
+
+ );
+ }
+
+ // ---------- PLAYER LEADERBOARD ----------
+
+ // Teams enabled — tabbed view (GAME / TEAM)
if (teamsEnabled) {
return (
setActiveTab('global')}
+ style={[styles.tabButton, activeTab === 'game' && styles.tabButtonActive]}
+ onPress={() => setActiveTab('game')}
>
- Global
+ GAME
setActiveTab('team')}
>
- Team
+ TEAM
- {activeTab === 'global' ? (
+ {activeTab === 'game' ? (
- {globalRanking.map((team, index) => (
- setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}>
-
-
- #{index + 1}
- {team.name}
- {team.totalCaches} cache(s)
-
- {expandedTeam === team.tid && team.members.map((m) => (
-
-
- {m.name}{m.isLeader ? ' (Leader)' : ''}
-
- {m.caches}
-
- ))}
-
-
- ))}
- {globalRanking.length === 0 && (
+ {teamRankings.map((team, index) => renderTeamCard(team, index))}
+ {teamRankings.length === 0 && (
No teams yet.
)}
) : (
- {teamRanking.map((m, index) => (
-
+ {myTeamRanking.map((m, index) => (
+ // Highlight the current user's card so they can identify themselves
+
#{index + 1}
- {m.name}{m.isLeader ? ' (Leader)' : ''}
+ {m.name}{m.isLeader ? ' (Team Leader)' : ''}
- {m.caches} cache(s)
+ {m.caches}/{totalCacheCount}
))}
- {teamRanking.length === 0 && (
+ {myTeamRanking.length === 0 && (
Join a team to see your team leaderboard.
)}
@@ -175,20 +399,12 @@ const LeaderboardScreen = () => {
);
}
- // No teams — simple player ranking
+ // No teams — simple player ranking (GAME tab only, no TEAM tab)
return (
- {globalRanking.map((player, index) => (
-
-
- #{index + 1}
- {player.name}
- {player.caches} cache(s)
-
-
- ))}
- {globalRanking.length === 0 && (
+ {playerRankings.map((player, index) => renderPlayerCard(player, index))}
+ {playerRankings.length === 0 && (
No players yet.
)}
@@ -199,11 +415,13 @@ const LeaderboardScreen = () => {
const styles = StyleSheet.create({
center: {justifyContent: 'center', alignItems: 'center'},
container: {padding: 0},
- body: {color: '#4b5563', fontSize: 15},
+ body: {color: '#bac2de', fontSize: 15},
+ departmentTitle: {fontSize: 20, fontWeight: '700', color: '#cdd6f4', paddingHorizontal: 15, paddingTop: 15, marginBottom: 6},
+ openLabel: {fontSize: 13, fontWeight: '700', color: '#bd93f9'},
tabRow: {
flexDirection: 'row',
borderBottomWidth: 1,
- borderBottomColor: '#e5e7eb',
+ borderBottomColor: '#45475a',
},
tabButton: {
flex: 1,
@@ -212,30 +430,45 @@ const styles = StyleSheet.create({
},
tabButtonActive: {
borderBottomWidth: 2,
- borderBottomColor: '#2563eb',
+ borderBottomColor: '#bd93f9',
},
- tabLabel: {fontSize: 15, fontWeight: '600', color: '#6b7280'},
- tabLabelActive: {color: '#2563eb'},
+ tabLabel: {fontSize: 15, fontWeight: '600', color: '#6c7086'},
+ tabLabelActive: {color: '#bd93f9'},
listSection: {flex: 1, paddingHorizontal: 15, paddingTop: 10},
rankRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
- rank: {fontSize: 16, fontWeight: '700', color: '#374151', width: 32},
- rankName: {flex: 1, fontSize: 15, fontWeight: '600', color: '#1f2937'},
- score: {fontSize: 14, fontWeight: '600', color: '#16a34a'},
+ rank: {fontSize: 16, fontWeight: '700', color: '#bac2de', width: 32},
+ rankName: {flex: 1, fontSize: 15, fontWeight: '600', color: '#cdd6f4'},
+ score: {fontSize: 14, fontWeight: '600', color: '#a6e3a1'},
subRow: {
flexDirection: 'row',
- justifyContent: 'space-between',
+ alignItems: 'center',
paddingLeft: 42,
paddingVertical: 6,
borderTopWidth: 1,
- borderTopColor: '#f3f4f6',
+ borderTopColor: '#45475a',
},
- subName: {fontSize: 13, color: '#4b5563'},
- subScore: {fontSize: 13, fontWeight: '600', color: '#16a34a'},
- emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
+ subName: {fontSize: 13, color: '#bac2de'},
+ subScore: {fontSize: 13, fontWeight: '600', color: '#a6e3a1', marginRight: 8},
+ emptyText: {color: '#6c7086', textAlign: 'center', marginTop: 30, fontSize: 14},
+ deleteTeamWrap: {marginTop: 8, paddingLeft: 42, flexDirection: 'row', gap: 8},
+ resetTeamButton: {backgroundColor: '#f4a460', borderColor: '#f4a460', minHeight: 36, flex: 1},
+ deleteTeamButton: {backgroundColor: '#D92800', borderColor: '#D92800', minHeight: 36, flex: 1},
+ deleteTeamLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13},
+ deleteTeamDeleteLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
+ resetMiniButton: {
+ backgroundColor: '#86efac',
+ borderRadius: 6,
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ marginLeft: 6,
+ },
+ resetMiniLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 12},
+ selfCard: {backgroundColor: 'rgba(166, 227, 161, 0.15)', borderColor: '#a6e3a1'},
+ selfSubRow: {backgroundColor: 'rgba(166, 227, 161, 0.15)'},
});
export default LeaderboardScreen;
diff --git a/src/components/screens/LoginScreen.js b/src/components/screens/LoginScreen.js
index 88bbbae..c37c0e3 100644
--- a/src/components/screens/LoginScreen.js
+++ b/src/components/screens/LoginScreen.js
@@ -5,16 +5,9 @@ import Screen from '../layout/Screen';
import {Button, ButtonTray} from '../UI/Button';
const LoginScreen = ({ navigation }) => {
-// Initialisation ------------->
-
const { email, password, error, isLoading, setEmail, setPassword, login } = useAuthHook();
-
-// State ----------------------
-
const [submitting, setSubmitting] = useState(false);
-// Handlers -------------------
-
const handleLogin = async () => {
setSubmitting(true);
const user = await login();
@@ -29,8 +22,6 @@ const LoginScreen = ({ navigation }) => {
navigation.reset({ index: 0, routes: [{ name: 'Game' }] });
};
-// View -----------------------
-
return (
@@ -39,7 +30,7 @@ const LoginScreen = ({ navigation }) => {
{
{
-// Initialisation ------------
+const MapScreen = ({navigation, route}) => {
+ const session = getSession();
+ const isBusiness = Boolean(session.isBusiness);
+ const {getCaches, claimCache, upsertCache, deleteCache, joinPrivateGame, joinTeamByCode, createPrivateGame, getLobby, getUser, getSubgroups, getGroupByOrgCode} = useGameHook();
- const session = getSession();
- const {getCaches, claimCache, upsertCache, deleteCache, joinPrivateGame, createPrivateGame, getLobby, getUser} = useGameHook();
+ // Stable ref to prevent infinite re-render loops
+ const getCachesRef = useRef(getCaches);
+ getCachesRef.current = getCaches;
+ const lastAppliedRouteDepartmentRef = useRef(null);
-// State ----------------------
+ const [inGame, setInGame] = useState(Boolean(session.currentGid));
+ const [isAdmin, setIsAdmin] = useState(session.isAcceptedAdmin);
+ const [userLocation, setUserLocation] = useState(null);
+ const [heading, setHeading] = useState(null);
+ const [playerLoading, setPlayerLoading] = useState(true);
+ const [cacheRecords, setCacheRecords] = useState([]);
+ const [error, setError] = useState('');
+ const [gameCode, setGameCode] = useState('');
+ const [groupInfo, setGroupInfo] = useState(null);
+ const [selectedCacheId, setSelectedCacheIdState] = useState(session.selectedCacheId);
+ const [claimedPopupVisible, setClaimedPopupVisible] = useState(false);
+ const [subgroups, setSubgroups] = useState([]);
- const [inGame, setInGame] = useState(Boolean(session.currentGid));
- const [isAdmin, setIsAdmin] = useState(session.isAcceptedAdmin);
- const [userLocation, setUserLocation] = useState(null);
- const [heading, setHeading] = useState(null);
- const [playerLoading, setPlayerLoading] = useState(true);
- const [cacheRecords, setCacheRecords] = useState([]);
- const [error, setError] = useState('');
- const [gameCode, setGameCode] = useState('');
- const [groupInfo, setGroupInfo] = useState(null);
+ // Business join flow state
+ const [joinStep, setJoinStep] = useState(null);
+ const [orgCode, setOrgCode] = useState('');
+ const [deptCode, setDeptCode] = useState('');
+ const [matchedGroup, setMatchedGroup] = useState(null);
+ const [joinError, setJoinError] = useState('');
- // Admin cache creation / editing state
- const [isCreating, setIsCreating] = useState(false);
- const [editingCacheId, setEditingCacheId] = useState(null);
- const [newCoord, setNewCoord] = useState(null);
- const [newName, setNewName] = useState('');
- const [newClue, setNewClue] = useState('');
+ // Admin cache creation / editing state
+ const [isCreating, setIsCreating] = useState(false);
+ const [editingCacheId, setEditingCacheId] = useState(null);
+ const [newCoord, setNewCoord] = useState(null);
+ const [newName, setNewName] = useState('');
+ const [newClue, setNewClue] = useState('');
+ const [selectedCacheSubgroupId, setSelectedCacheSubgroupId] = useState(null);
+ const [departmentDropdownOpen, setDepartmentDropdownOpen] = useState(false);
- // Global claim distance comes from the group's CacheTriggerMeters setting
- const claimDistance = groupInfo?.CacheTriggerMeters || 20;
+ const claimDistance = groupInfo?.CacheTriggerMeters || 20;
+ const isPlayer = inGame && !isAdmin;
+ const requiresTeam = Boolean(isPlayer && groupInfo?.TeamsEnabled && !session.currentTid);
+ const EMPTY_CACHES = useMemo(() => [], []);
- const isPlayer = inGame && !isAdmin;
- const {visibleCache, isClaiming, setIsClaiming} = usePlayerGame(
- isPlayer ? userLocation : null,
- isPlayer ? heading : null,
- isPlayer ? cacheRecords : [],
+ // Caches not yet claimed by the current user's team (or user if no team)
+ const activeCachesForPlayer = useMemo(() => {
+ if (!isPlayer) return EMPTY_CACHES;
+ 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, EMPTY_CACHES]);
+
+ const {visibleCaches, isClaiming, setIsClaiming} = usePlayerGame(
+ (isPlayer && !requiresTeam) ? userLocation : null,
+ (isPlayer && !requiresTeam) ? heading : null,
+ requiresTeam ? EMPTY_CACHES : activeCachesForPlayer,
claimDistance,
+ selectedCacheId,
);
- const mapRegion = userLocation
- ? {...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}
- : DEFAULT_REGION;
+ const mapRegion = userLocation
+ ? {...userLocation, latitudeDelta: 0.01, longitudeDelta: 0.01}
+ : DEFAULT_REGION;
-// Handlers -------------------
+ useEffect(() => {
+ if (!session.currentGid) return;
+ getLobby(session.currentGid).then(setGroupInfo);
+ getSubgroups(session.currentGid).then(setSubgroups);
+ }, [inGame]);
+
+ // Default member subgroup SGid for cache creation (first non-admin subgroup)
+ const departments = useMemo(
+ () => subgroups.filter((sg) => !sg.IsAdminGroup),
+ [subgroups],
+ );
+ const defaultMemberSGid = departments[0]?.SGid || null;
+ const departmentNamesById = useMemo(() => {
+ const map = {};
+ for (const subgroup of departments) {
+ map[String(subgroup.SGid)] = subgroup.SubGroupName || `Department ${subgroup.SGid}`;
+ }
+ return map;
+ }, [departments]);
+ const routeDepartmentSGid = route?.params?.selectedDepartmentSGid;
+ const isDepartmentScopedMap = routeDepartmentSGid !== null && routeDepartmentSGid !== undefined;
+ const effectiveSGid = isBusiness
+ ? (isAdmin ? (isDepartmentScopedMap ? routeDepartmentSGid : null) : (session.currentSGid ?? null))
+ : null;
useEffect(() => {
- if (!session.currentGid) return;
- getLobby(session.currentGid).then(setGroupInfo);
- }, [inGame]);
+ if (!isAdmin || departments.length === 0) return;
+ const routeSelectedSgid = route?.params?.selectedDepartmentSGid;
+ const hasRouteDepartment = departments.some((sg) => String(sg.SGid) === String(routeSelectedSgid));
+ const shouldApplyRouteDepartment =
+ hasRouteDepartment && String(lastAppliedRouteDepartmentRef.current) !== String(routeSelectedSgid);
+ if (shouldApplyRouteDepartment) {
+ setSelectedCacheSubgroupId(routeSelectedSgid);
+ lastAppliedRouteDepartmentRef.current = routeSelectedSgid;
+ return;
+ }
+
+ const currentStillValid = departments.some((sg) => String(sg.SGid) === String(selectedCacheSubgroupId));
+ if (!currentStillValid) {
+ setSelectedCacheSubgroupId(defaultMemberSGid);
+ }
+ }, [route?.params?.selectedDepartmentSGid, departments, isAdmin, selectedCacheSubgroupId, defaultMemberSGid]);
const loadCaches = useCallback(async () => {
- if (!session.currentGid) {
+ const currentGid = getSession().currentGid;
+ if (!currentGid) {
setCacheRecords([]);
return;
}
- const rows = await getCaches(session.currentGid, null);
+ const rows = await getCachesRef.current(currentGid, effectiveSGid ?? null);
setCacheRecords(rows || []);
- }, [inGame, isAdmin]);
+ }, [effectiveSGid]);
+ // Run once on mount
useEffect(() => { loadCaches(); }, [loadCaches]);
+ // Re-fetch caches when screen gains focus
+ useFocusEffect(
+ useCallback(() => { loadCaches(); }, [loadCaches])
+ );
+
+ // Auto-select first unclaimed cache when list loads
+ useEffect(() => {
+ if (!isPlayer || activeCachesForPlayer.length === 0) return;
+ if (selectedCacheId && activeCachesForPlayer.some((c) => c.id === selectedCacheId)) return;
+ const firstId = activeCachesForPlayer[0].id;
+ setSelectedCacheIdState(firstId);
+ setSelectedCache(firstId);
+ }, [activeCachesForPlayer, isPlayer]);
+
// Location tracking
useEffect(() => {
if (!inGame) return;
@@ -87,7 +169,6 @@ const MapScreen = ({navigation}) => {
return;
}
- // Try cached position first for instant load, fall back to fresh fix
const last = await Location.getLastKnownPositionAsync();
if (last) {
setUserLocation({latitude: last.coords.latitude, longitude: last.coords.longitude});
@@ -102,7 +183,6 @@ const MapScreen = ({navigation}) => {
(next) => setUserLocation({latitude: next.coords.latitude, longitude: next.coords.longitude}),
);
- // Use OS-fused heading from expo-location
headingSub = await Location.watchHeadingAsync((headingData) => {
const raw = headingData.trueHeading >= 0 ? headingData.trueHeading : headingData.magHeading;
setHeading(raw);
@@ -121,6 +201,14 @@ const MapScreen = ({navigation}) => {
const result = await joinPrivateGame({JoinCode: gameCode.trim().toUpperCase(), Uid: session.currentUid});
if (!result) return;
setSessionGroup(result.Gid, result.SGid);
+ setPendingAdmin(false);
+
+ const freshUser = await getUser(session.currentUid);
+ if (freshUser) {
+ setSessionUser(freshUser);
+ setSessionTeam(freshUser.TGid ?? null);
+ }
+
setInGame(true);
setIsAdmin(false);
navigation.navigate('TeamScreen');
@@ -134,77 +222,171 @@ const MapScreen = ({navigation}) => {
MaxMemberSubgroups: 1,
});
if (!result) return;
+ setPendingAdmin(false);
const freshUser = await getUser(session.currentUid);
if (freshUser) {
setSessionUser(freshUser);
setInGame(true);
setIsAdmin(true);
- navigation.navigate('GameSettingsScreen');
+ navigation.reset({index: 0, routes: [{name: 'Admin'}]});
}
};
- const handleClaim = async (cacheId) => {
+ // Business — create a new company
+ const handleCreateCompany = async () => {
+ const result = await createPrivateGame({
+ GroupName: 'My Company',
+ BusinessOrSchoolName: 'My Company',
+ CreatedByUid: session.currentUid,
+ TeamsEnabled: false,
+ MaxMemberSubgroups: 1,
+ isBusiness: true,
+ });
+ if (!result) return;
+ setPendingAdmin(false);
+ const freshUser = await getUser(session.currentUid);
+ if (freshUser) {
+ setSessionUser(freshUser);
+ setInGame(true);
+ setIsAdmin(true);
+ navigation.reset({index: 0, routes: [{name: 'Admin'}]});
+ }
+ };
+
+ // Business — verify the org code
+ const handleVerifyOrgCode = async () => {
+ setJoinError('');
+ if (!orgCode.trim()) return;
+ const group = await getGroupByOrgCode(orgCode.trim().toUpperCase());
+ if (!group) {
+ setJoinError('Organisation not found. Check your code and try again.');
+ return;
+ }
+ setMatchedGroup(group);
+ setJoinStep('deptCode');
+ };
+
+ // Business — join a department within the matched org
+ const handleJoinDepartment = async () => {
+ setJoinError('');
+ if (!deptCode.trim()) return;
+ const enteredCode = deptCode.trim().toUpperCase();
+ const adminCode = String(matchedGroup?.AdminJoinCode || '').trim().toUpperCase();
+
+ // If the code matches the org admin code, submit an admin waitlist request
+ if (adminCode && enteredCode === adminCode) {
+ const waitlistResult = await joinTeamByCode({
+ JoinCode: enteredCode,
+ Uid: session.currentUid,
+ ExpectedGid: matchedGroup.Gid,
+ });
+ if (!waitlistResult || !waitlistResult.adminWaitlist) {
+ setJoinError('Could not submit admin request. Please try again.');
+ return;
+ }
+ setSessionGroup(matchedGroup.Gid, null);
+ setSessionTeam(null);
+ setPendingAdmin(true);
+ setInGame(true);
+ setIsAdmin(false);
+ setJoinStep(null);
+ setDeptCode('');
+ navigation.navigate('TeamScreen');
+ return;
+ }
+
+ const result = await joinPrivateGame({
+ JoinCode: enteredCode,
+ Uid: session.currentUid,
+ ExpectedGid: matchedGroup.Gid,
+ });
+ if (!result) {
+ setJoinError('Invalid department code. Check the code and try again.');
+ return;
+ }
+ setSessionGroup(result.Gid, result.SGid);
+ setPendingAdmin(false);
+ const freshUser = await getUser(session.currentUid);
+ if (freshUser) {
+ setSessionUser(freshUser);
+ setSessionTeam(freshUser.TGid ?? null);
+ }
+ setInGame(true);
+ setIsAdmin(false);
+ setJoinStep(null);
+ navigation.navigate('TeamScreen');
+ };
+
+ const handleClaim = useCallback(async (cacheId) => {
if (!session.currentGid) return;
- await claimCache({
+ const result = await claimCache({
gid: session.currentGid,
cacheId,
uid: session.currentUid,
tid: session.currentTid,
+ sgid: effectiveSGid,
});
setIsClaiming(false);
+ if (result) {
+ setClaimedPopupVisible(true);
+ setTimeout(() => setClaimedPopupVisible(false), 3000);
+ }
await loadCaches();
- };
+ }, [session.currentGid, session.currentUid, session.currentTid, effectiveSGid, loadCaches]);
- // Admin — open create form
const handleCreateCachePress = () => {
const fallback = userLocation || {latitude: mapRegion.latitude, longitude: mapRegion.longitude};
setEditingCacheId(null);
setNewCoord(fallback);
setNewName('');
setNewClue('');
+ if (!selectedCacheSubgroupId && defaultMemberSGid) setSelectedCacheSubgroupId(defaultMemberSGid);
+ setDepartmentDropdownOpen(false);
setIsCreating(true);
};
- // Admin — open edit form
const handleEditCache = (cache) => {
setEditingCacheId(cache.id);
setNewCoord(cache.coordinates);
setNewName(cache.name || '');
setNewClue(cache.clue || '');
+ setSelectedCacheSubgroupId(cache.subgroupId || defaultMemberSGid);
+ setDepartmentDropdownOpen(false);
setIsCreating(true);
};
- // Admin — delete cache
const handleDeleteCache = async (cache) => {
if (!session.currentGid) return;
await deleteCache(session.currentGid, cache.id);
await loadCaches();
};
- // Admin — save create or edit
const handleSaveCache = async () => {
if (!newCoord || !newClue.trim() || !session.currentGid) return;
+ const subgroupId = selectedCacheSubgroupId || defaultMemberSGid;
+ if (!subgroupId) return;
const payload = {
gid: session.currentGid,
name: newName.trim(),
latitude: newCoord.latitude,
longitude: newCoord.longitude,
clue: newClue.trim(),
- subgroupId: 1,
+ subgroupId,
};
if (editingCacheId) payload.cacheId = editingCacheId;
await upsertCache(payload);
setIsCreating(false);
setEditingCacheId(null);
+ setDepartmentDropdownOpen(false);
await loadCaches();
};
const handleCancelCreate = () => {
setIsCreating(false);
setEditingCacheId(null);
+ setDepartmentDropdownOpen(false);
};
- // Navigate to expanded map view, passing the live location so it opens centred on the user
const handleExpandMap = () => {
navigation.navigate('ExpandedMapScreen', {
isAdmin,
@@ -212,17 +394,101 @@ const MapScreen = ({navigation}) => {
heading: heading || 0,
claimDistance,
userLocation: userLocation || null,
+ selectedCacheId,
});
};
- // Player — select cache
const handleSelectCache = (cache) => {
- // Future: scroll map to cache location
+ setSelectedCacheIdState(cache.id);
+ setSelectedCache(cache.id);
};
+ const isCacheClaimedByMe = (cache) => {
+ const claims = cache.Claims || [];
+ if (session.currentTid) return claims.some((c) => c.Tid === session.currentTid);
+ return claims.some((c) => c.Uid === session.currentUid);
+ };
+
+ const showAdminDepartmentLabel = Boolean(isAdmin && groupInfo?.BusinessOrSchoolName);
+
// View -----------------------
- // Not in a game
+ // Not in a game — Business flow
+ if (!inGame && session.isBusiness) {
+ // Step 2: Enter department code
+ if (joinStep === 'deptCode' && matchedGroup) {
+ return (
+
+ Join {matchedGroup.BusinessOrSchoolName || 'Company'}
+ Enter your department code to join
+
+ setDeptCode(text.toUpperCase())}
+ autoCapitalize="characters"
+ />
+
+
+ {joinError ? {joinError} : null}
+
+
+ { setJoinStep(null); setDeptCode(''); setJoinError(''); }}
+ styleButton={styles.backButton}
+ styleLabel={styles.backLabel}
+ />
+
+
+
+ );
+ }
+
+ // Default: Join company or Create company choice
+ return (
+
+ Welcome
+ Set up your organisation or join an existing one
+
+ setOrgCode(text.toUpperCase())}
+ autoCapitalize="characters"
+ />
+
+
+ {joinError ? {joinError} : null}
+
+
+
+
+
+
+ );
+ }
+
+ // Not in a game — Individual flow
if (!inGame) {
return (
@@ -232,7 +498,7 @@ const MapScreen = ({navigation}) => {
placeholder="Enter Game Code"
placeholderTextColor="#9ca3af"
value={gameCode}
- onChangeText={setGameCode}
+ onChangeText={(text) => setGameCode(text.toUpperCase())}
autoCapitalize="characters"
/>
{
{
+ Department
+ setDepartmentDropdownOpen((prev) => !prev)}
+ >
+
+ {departmentNamesById[String(selectedCacheSubgroupId)] || 'Select department'}
+
+ {departmentDropdownOpen ? '▲' : '▼'}
+
+ {departmentDropdownOpen && (
+
+ {departments.map((department) => (
+ {
+ setSelectedCacheSubgroupId(department.SGid);
+ setDepartmentDropdownOpen(false);
+ }}
+ >
+ {department.SubGroupName}
+
+ ))}
+
+ )}
{
);
}
- // Admin — normal view (map + create button + cache list)
+ // Admin view
if (isAdmin) {
return (
{
key={cache.id}
cache={cache}
isAdmin={true}
+ departmentName={showAdminDepartmentLabel ? (departmentNamesById[String(cache.subgroupId)] || 'Unassigned') : null}
onEdit={handleEditCache}
onDelete={handleDeleteCache}
/>
@@ -420,6 +715,10 @@ const MapScreen = ({navigation}) => {
);
}
+ if (session.isPendingAdmin && !session.isAcceptedAdmin) {
+ return ;
+ }
+
// Player — loading location
if (playerLoading) {
return (
@@ -439,40 +738,47 @@ const MapScreen = ({navigation}) => {
);
}
- // Player in game
- const teamsWarning = groupInfo?.TeamsEnabled && !session.currentTid;
+ const claimTarget = visibleCaches[0] || null;
return (
- {teamsWarning && (
- You are not in a team. Teams are required for this game!
+ {requiresTeam && (
+ You are not in a team. Join a team to start claiming caches!
)}
⛶
-
+ {!requiresTeam && (
+
+ )}
- {cacheRecords.map((cache) => (
-
- ))}
+ {cacheRecords.map((cache) => {
+ const claimed = isCacheClaimedByMe(cache);
+ return (
+
+ );
+ })}
{cacheRecords.length === 0 && (
No caches available yet.
)}
@@ -486,27 +792,31 @@ const styles = StyleSheet.create({
center: {justifyContent: 'center', alignItems: 'center'},
containerMap: {padding: 0},
mapContainer: {height: 200},
- error: {color: '#dc2626', fontSize: 15},
- loadingText: {color: '#6b7280', fontSize: 14, marginTop: 10},
+ error: {color: '#D92800', fontSize: 15},
+ loadingText: {color: '#6c7086', fontSize: 14, marginTop: 10},
inputRow: {flexDirection: 'row', gap: 10, marginBottom: 15, width: '100%', paddingHorizontal: 20},
fullRow: {width: '100%', paddingHorizontal: 20},
codeInput: {
flex: 1,
borderWidth: 1,
- borderColor: '#d1d5db',
+ borderColor: '#45475a',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
- color: '#1f2937',
- backgroundColor: '#ffffff',
- },
- joinButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, paddingHorizontal: 20},
- joinLabel: {color: '#ffffff', fontWeight: '600'},
- createButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
- createLabel: {color: '#ffffff', fontWeight: '600'},
+ color: '#cdd6f4',
+ backgroundColor: '#313244',
+ }, joinButton: {backgroundColor: '#bd93f9', borderColor: '#bd93f9', flex: 0, paddingHorizontal: 20},
+ joinLabel: {color: '#1e1e2e', fontWeight: '600'},
+ createButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1'},
+ createLabel: {color: '#1e1e2e', fontWeight: '600'},
+ bizTitle: {fontSize: 22, fontWeight: '700', color: '#cdd6f4', marginBottom: 6, textAlign: 'center'},
+ bizSubtitle: {fontSize: 14, color: '#bac2de', textAlign: 'center', marginBottom: 20, paddingHorizontal: 20},
+ joinError: {color: '#D92800', textAlign: 'center', marginTop: 8, marginBottom: 4, fontSize: 14, paddingHorizontal: 20},
+ backButton: {backgroundColor: '#6c7086', borderColor: '#6c7086'},
+ backLabel: {color: '#cdd6f4', fontWeight: '600'},
warning: {
- color: '#dc2626',
+ color: '#D92800',
fontWeight: 'bold',
textAlign: 'center',
paddingVertical: 8,
@@ -516,7 +826,7 @@ const styles = StyleSheet.create({
position: 'absolute',
top: 10,
left: 10,
- backgroundColor: 'rgba(0,0,0,0.6)',
+ backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderRadius: 6,
width: 36,
height: 36,
@@ -524,42 +834,64 @@ const styles = StyleSheet.create({
justifyContent: 'center',
zIndex: 10,
},
- expandIcon: {color: '#ffffff', fontSize: 18, fontWeight: '700'},
- // Admin create button + scrollable cache list
+ expandIcon: {color: '#cdd6f4', fontSize: 18, fontWeight: '700'},
createCacheWrap: {
paddingHorizontal: 12,
paddingVertical: 10,
- backgroundColor: '#ffffff',
+ backgroundColor: '#313244',
borderBottomWidth: 1,
- borderBottomColor: '#e5e7eb',
+ borderBottomColor: '#45475a',
},
- createCacheButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0},
- createCacheLabel: {color: '#ffffff', fontWeight: '600'},
- cacheListWrap: {flex: 1, backgroundColor: '#f3f4f6'},
+ createCacheButton: {backgroundColor: '#bd93f9', borderColor: '#bd93f9', flex: 0},
+ createCacheLabel: {color: '#1e1e2e', fontWeight: '600'},
+ cacheListWrap: {flex: 1, backgroundColor: '#1e1e2e'},
cacheListContent: {paddingHorizontal: 12, paddingTop: 10, paddingBottom: 12},
- // Cache editor form
formSection: {flex: 1, paddingHorizontal: 12, paddingVertical: 10},
formInput: {
borderWidth: 1,
- borderColor: '#d1d5db',
+ borderColor: '#45475a',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
- color: '#1f2937',
- backgroundColor: '#ffffff',
+ color: '#cdd6f4',
+ backgroundColor: '#313244',
marginBottom: 10,
},
+ departmentLabel: {fontSize: 13, fontWeight: '600', color: '#bac2de', marginBottom: 6},
+ departmentSelector: {
+ borderWidth: 1,
+ borderColor: '#45475a',
+ borderRadius: 8,
+ backgroundColor: '#313244',
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 8,
+ },
+ departmentSelectorText: {color: '#cdd6f4', fontSize: 15, fontWeight: '500'},
+ departmentSelectorChevron: {color: '#6c7086', fontSize: 12, fontWeight: '700'},
+ departmentMenu: {
+ borderWidth: 1,
+ borderColor: '#45475a',
+ borderRadius: 8,
+ backgroundColor: '#313244',
+ marginBottom: 10,
+ overflow: 'hidden',
+ },
+ departmentOption: {paddingHorizontal: 12, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#45475a'},
+ departmentOptionText: {color: '#cdd6f4', fontSize: 14},
coordRow: {flexDirection: 'row', gap: 10},
coordField: {flex: 1},
- coordLabel: {fontSize: 12, fontWeight: '600', color: '#6b7280', marginBottom: 4},
- saveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
- saveLabel: {color: '#ffffff', fontWeight: '600'},
- cancelButton: {backgroundColor: '#6b7280', borderColor: '#6b7280'},
- cancelLabel: {color: '#ffffff', fontWeight: '600'},
- // Player cache section
- cacheSection: {flex: 1, backgroundColor: '#f3f4f6', paddingHorizontal: 12, paddingTop: 10},
- emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 20, fontSize: 14},
+ coordLabel: {fontSize: 12, fontWeight: '600', color: '#6c7086', marginBottom: 4},
+ saveButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1'},
+ saveLabel: {color: '#1e1e2e', fontWeight: '600'},
+ cancelButton: {backgroundColor: '#6c7086', borderColor: '#6c7086'},
+ cancelLabel: {color: '#cdd6f4', fontWeight: '600'},
+ cacheSection: {flex: 1, backgroundColor: '#1e1e2e', paddingHorizontal: 12, paddingTop: 10},
+ emptyText: {color: '#6c7086', textAlign: 'center', marginTop: 20, fontSize: 14},
mapWrap: {flex: 1},
});
diff --git a/src/components/screens/PlayersScreen.js b/src/components/screens/PlayersScreen.js
index 5997253..af0b5f5 100644
--- a/src/components/screens/PlayersScreen.js
+++ b/src/components/screens/PlayersScreen.js
@@ -1,33 +1,97 @@
import React, {useCallback, useEffect, useState} from 'react';
-import {ActivityIndicator, ScrollView, StyleSheet, Text, View} from 'react-native';
+import {ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
import Screen from '../layout/Screen';
import Card from '../UI/Card';
import {Button} from '../UI/Button';
+import PendingApprovalView from '../UI/PendingApprovalView';
import useGameHook from '../../hooks/useGameHook';
import {getSession} from '../../hooks/SessionStore';
-const PlayersScreen = () => {
+const PlayersScreen = ({navigation, route}) => {
// Initialisation ------------
const session = getSession();
- const {getTeams, getTeamMembers, getGroupMembers, removeMember, getUser} = useGameHook();
+ const {getTeams, getTeamMembers, getGroupMembers, removeMember, resetPlayerProgress, getUser, getAdminWaitlist, approveAdmin, rejectAdmin, getSubgroups} = useGameHook();
+ const isBusiness = Boolean(session.isBusiness);
+ const isAdmin = Boolean(session.isAcceptedAdmin);
+ const selectedDepartment = route?.params?.department || null;
+ const effectiveSGid = isBusiness
+ ? ((isAdmin && selectedDepartment?.SGid) ? selectedDepartment.SGid : (isAdmin ? null : session.currentSGid))
+ : null;
// State ----------------------
const [loading, setLoading] = useState(true);
const [players, setPlayers] = useState([]);
+ const [departmentList, setDepartmentList] = useState([]);
+ const [adminTeam, setAdminTeam] = useState([]);
+ const [isCurrentUserAdminLead, setIsCurrentUserAdminLead] = useState(false);
+ const [adminTeamExpanded, setAdminTeamExpanded] = useState(false);
// Handlers -------------------
const loadData = useCallback(async () => {
if (!session.currentGid) { setLoading(false); return; }
- // Get all non-admin players
- const allMembers = await getGroupMembers(session.currentGid);
- const nonAdminMembers = (allMembers || []).filter((m) => !m.IsAcceptedAdmin);
+ if (isBusiness && isAdmin && !selectedDepartment) {
+ const sgs = await getSubgroups(session.currentGid);
+ setDepartmentList((sgs || []).filter((sg) => !sg.IsAdminGroup));
+
+ const allOrgMembers = await getGroupMembers(session.currentGid, null);
+ const seenAdminUids = new Set();
+ const acceptedAdmins = (allOrgMembers || [])
+ .filter((m) => m.IsAcceptedAdmin)
+ .sort((a, b) => Number(a.id) - Number(b.id))
+ .filter((m) => {
+ if (seenAdminUids.has(m.Uid)) return false;
+ seenAdminUids.add(m.Uid);
+ return true;
+ });
+
+ const adminRows = [];
+ for (const member of acceptedAdmins) {
+ const user = await getUser(member.Uid);
+ if (!user) continue;
+ adminRows.push({
+ Uid: member.Uid,
+ username: user.username || `User ${member.Uid}`,
+ membershipId: member.id,
+ });
+ }
+ setAdminTeam(adminRows);
+ setIsCurrentUserAdminLead(adminRows.length > 0 && adminRows[0].Uid === session.currentUid);
+ setPlayers([]);
+ setLoading(false);
+ return;
+ }
+
+ setAdminTeam([]);
+ setIsCurrentUserAdminLead(false);
+
+ // Load subgroup names for department display (business accounts)
+ let subgroupNameMap = {};
+ if (isBusiness) {
+ const sgs = await getSubgroups(session.currentGid);
+ for (const sg of (sgs || [])) {
+ if (!sg.IsAdminGroup) subgroupNameMap[sg.SGid] = sg.SubGroupName;
+ }
+ }
+
+ // Get ALL members (including admins)
+ const allMembers = await getGroupMembers(session.currentGid, effectiveSGid ?? null);
+
+ // Get admin waitlist entries
+ const waitlist = effectiveSGid ? [] : await getAdminWaitlist(session.currentGid);
+
+ // Deduplicate allMembers by Uid — a user may have two rows if they were a
+ // regular member before being approved as an admin. Prefer the admin row.
+ const seenUids = new Set();
+ const uniqueMembers = (allMembers || [])
+ .sort((a, b) => (b.IsAcceptedAdmin ? 1 : 0) - (a.IsAcceptedAdmin ? 1 : 0))
+ .filter((m) => { if (seenUids.has(m.Uid)) return false; seenUids.add(m.Uid); return true; });
// Build team lookup: { Uid -> { teamCode, teamName, isLeader } }
- const allTeams = await getTeams(session.currentGid);
+ const allTeams = await getTeams(session.currentGid, effectiveSGid ?? null);
const teamInfoByUid = {};
for (const team of (allTeams || [])) {
const tms = await getTeamMembers(team.Tid);
@@ -40,21 +104,55 @@ const PlayersScreen = () => {
}
}
- // Build player list
+ // Build player list — include admins with tags
const playerList = [];
- for (const m of nonAdminMembers) {
+ for (const m of uniqueMembers) {
+ // Skip the current admin viewing the list
+ if (m.Uid === session.currentUid) continue;
const user = await getUser(m.Uid);
if (user) {
+ // A group member may also be on the admin waitlist (applied after joining as a player).
+ // Check the waitlist first so they are treated as pending, not a regular player.
+ const waitlistEntry = (waitlist || []).find((w) => w.Uid === m.Uid);
+ let adminTag = null;
+ let waitlistId = null;
+ if (waitlistEntry) {
+ adminTag = '[Admin Awaiting Response]';
+ waitlistId = waitlistEntry.id;
+ } else if (m.IsAcceptedAdmin) {
+ adminTag = '[ADMIN]';
+ }
playerList.push({
...user,
membershipId: m.id,
team: teamInfoByUid[m.Uid] || null,
+ adminTag,
+ waitlistId,
+ departmentName: subgroupNameMap[m.SGid] || null,
});
}
}
+
+ // Include waitlisted users who are not yet group members at all
+ for (const w of (waitlist || [])) {
+ if (playerList.some((p) => p.Uid === w.Uid)) continue;
+ if (w.Uid === session.currentUid) continue;
+ const user = await getUser(w.Uid);
+ if (user) {
+ playerList.push({
+ ...user,
+ membershipId: null,
+ team: null,
+ adminTag: '[Admin Awaiting Response]',
+ waitlistId: w.id,
+ departmentName: null,
+ });
+ }
+ }
+
setPlayers(playerList);
setLoading(false);
- }, [session.currentGid]);
+ }, [session.currentGid, session.currentUid, session.currentSGid, isBusiness, isAdmin, selectedDepartment, selectedDepartment?.SGid, effectiveSGid]);
useEffect(() => { loadData(); }, [loadData]);
@@ -63,6 +161,55 @@ const PlayersScreen = () => {
await loadData();
};
+ const handleResetPlayerProgress = (player) => {
+ Alert.alert(
+ 'Reset Progress',
+ `Reset all cache claims for ${player.username}?`,
+ [
+ {text: 'Cancel', style: 'cancel'},
+ {
+ text: 'Reset',
+ style: 'destructive',
+ onPress: async () => {
+ await resetPlayerProgress(session.currentGid, player.Uid, effectiveSGid);
+ Alert.alert('Done', `Progress for ${player.username} has been reset.`);
+ },
+ },
+ ],
+ );
+ };
+
+ // Approve a waitlisted admin and refresh the list
+ const handleApproveAdmin = async (player) => {
+ await approveAdmin(player.waitlistId);
+ await loadData();
+ };
+
+ // Deny (remove from waitlist) a pending admin and refresh the list
+ const handleDenyAdmin = async (player) => {
+ await rejectAdmin(player.waitlistId);
+ await loadData();
+ };
+
+ const handleKickAdmin = (adminMember) => {
+ if (!isCurrentUserAdminLead || adminMember.Uid === session.currentUid) return;
+ Alert.alert(
+ 'Kick Admin',
+ `Remove ${adminMember.username} from the admin team?`,
+ [
+ {text: 'Cancel', style: 'cancel'},
+ {
+ text: 'Kick',
+ style: 'destructive',
+ onPress: async () => {
+ await removeMember(adminMember.membershipId);
+ await loadData();
+ },
+ },
+ ],
+ );
+ };
+
// View -----------------------
if (loading) {
@@ -81,14 +228,88 @@ const PlayersScreen = () => {
);
}
+ if (session.isPendingAdmin && !session.isAcceptedAdmin) {
+ return ;
+ }
+
+ if (isBusiness && isAdmin && !selectedDepartment) {
+ return (
+
+ Departments
+
+
+ setAdminTeamExpanded((prev) => !prev)}>
+
+ Admin Team
+ {adminTeamExpanded ? 'Close' : 'Open'}
+
+
+ {adminTeamExpanded && (
+
+ {adminTeam.map((adminMember, index) => (
+
+
+ {adminMember.username}{index === 0 ? ' (Admin Lead)' : ''}
+
+ {isCurrentUserAdminLead && adminMember.Uid !== session.currentUid && (
+ handleKickAdmin(adminMember)}
+ styleButton={styles.removeButton}
+ styleLabel={styles.kickLabel}
+ />
+ )}
+
+ ))}
+ {adminTeam.length === 0 && (
+ No approved admins yet.
+ )}
+
+ )}
+
+ {departmentList.map((department) => (
+ navigation.navigate('PlayersScreen', {department})}
+ >
+
+
+ {department.SubGroupName}
+ Open
+
+
+
+ ))}
+ {departmentList.length === 0 && (
+ No departments yet.
+ )}
+
+
+ );
+ }
+
return (
-
+
Players
{players.map((player) => (
-
+ // Waitlisted admins get a light red tint so they stand out from regular players
+
- {player.username}
+
+ {player.username}
+ {player.adminTag && (
+
+ {' '}{player.adminTag}
+
+ )}
+ {/* Show department name in brackets for non-admin players (org accounts) */}
+ {!player.adminTag && player.departmentName && (
+
+ {' '}({player.departmentName})
+
+ )}
+
{player.team?.isLeader && (
(Team Leader)
@@ -97,12 +318,44 @@ const PlayersScreen = () => {
{player.team.teamCode}
)}
- handleRemovePlayer(player.membershipId)}
- styleButton={styles.removeButton}
- styleLabel={styles.removeLabel}
- />
+
+
+ {/* Waitlisted admins get Approve / Deny instead of the normal player actions */}
+ {player.waitlistId ? (
+ <>
+ handleApproveAdmin(player)}
+ styleButton={styles.approveButton}
+ styleLabel={styles.actionLabel}
+ />
+ handleDenyAdmin(player)}
+ styleButton={styles.denyButton}
+ styleLabel={styles.actionLabel}
+ />
+ >
+ ) : (
+ <>
+ {!player.adminTag && (
+ handleResetPlayerProgress(player)}
+ styleButton={styles.resetButton}
+ styleLabel={styles.actionLabel}
+ />
+ )}
+ {player.membershipId && (
+ handleRemovePlayer(player.membershipId)}
+ styleButton={styles.removeButton}
+ styleLabel={styles.kickLabel}
+ />
+ )}
+ >
+ )}
))}
@@ -117,29 +370,42 @@ const PlayersScreen = () => {
const styles = StyleSheet.create({
center: {justifyContent: 'center', alignItems: 'center', flex: 1},
container: {padding: 0},
- body: {color: '#4b5563', fontSize: 15},
+ body: {color: '#bac2de', fontSize: 15},
sectionTitle: {
fontSize: 20,
fontWeight: '700',
- color: '#1f2937',
+ color: '#cdd6f4',
marginBottom: 12,
paddingHorizontal: 15,
paddingTop: 15,
},
+ openLabel: {fontSize: 13, fontWeight: '700', color: '#bd93f9'},
listSection: {flex: 1, paddingHorizontal: 15},
- emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
+ emptyText: {color: '#6c7086', textAlign: 'center', marginTop: 30, fontSize: 14},
playerRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
+ marginBottom: 8,
},
- playerName: {fontSize: 15, fontWeight: '600', color: '#1f2937', flex: 1},
+ playerName: {fontSize: 15, fontWeight: '600', color: '#cdd6f4', flex: 1},
playerMeta: {flexDirection: 'row', alignItems: 'center', gap: 6},
- leaderBadge: {fontSize: 11, color: '#2563eb', fontWeight: '700'},
- teamCodeBadge: {fontSize: 12, fontWeight: '700', color: '#16a34a', letterSpacing: 1},
- removeButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 0, paddingHorizontal: 12},
- removeLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
+ leaderBadge: {fontSize: 11, color: '#bd93f9', fontWeight: '700'},
+ teamCodeBadge: {fontSize: 12, fontWeight: '700', color: '#a6e3a1', letterSpacing: 1},
+ adminBadge: {fontSize: 12, fontWeight: '700', color: '#D92800'},
+ waitlistBadge: {fontSize: 11, fontWeight: '600', color: '#f4a460'},
+ actionRow: {flexDirection: 'row', gap: 8},
+ resetButton: {backgroundColor: '#86efac', borderColor: '#86efac', minHeight: 36, flex: 1, paddingHorizontal: 10},
+ removeButton: {backgroundColor: '#D92800', borderColor: '#D92800', minHeight: 36, flex: 1, paddingHorizontal: 10},
+ actionLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13},
+ kickLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
+ adminTeamWrap: {gap: 8},
+ adminTeamRow: {flexDirection: 'row', alignItems: 'center', gap: 8},
+ adminTeamName: {flex: 1, fontSize: 14, color: '#cdd6f4', fontWeight: '600'},
+ departmentName: {fontSize: 14, color: '#6c7086', fontStyle: 'italic'},
+ waitlistCard: {backgroundColor: 'rgba(217, 40, 0, 0.15)', borderColor: '#D92800'},
+ approveButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1', minHeight: 36, flex: 1, paddingHorizontal: 10},
+ denyButton: {backgroundColor: '#D92800', borderColor: '#D92800', minHeight: 36, flex: 1, paddingHorizontal: 10},
});
export default PlayersScreen;
-
diff --git a/src/components/screens/RegisterScreen.js b/src/components/screens/RegisterScreen.js
index 9c6774f..613cf1a 100644
--- a/src/components/screens/RegisterScreen.js
+++ b/src/components/screens/RegisterScreen.js
@@ -5,182 +5,175 @@ import Screen from '../layout/Screen';
import {Button, ButtonTray} from '../UI/Button';
const RegisterScreen = ({navigation}) => {
-// Initialisation ------------
+ const {
+ username, email, password, confirmPassword, accountType,
+ error, isLoading,
+ setUsername, setEmail, setPassword, setConfirmPassword, setAccountType,
+ register,
+ } = useAuthHook();
- const {
- username, email, password, confirmPassword, accountType,
- error, isLoading,
- setUsername, setEmail, setPassword, setConfirmPassword, setAccountType,
- register,
- } = useAuthHook();
+ const [submitting, setSubmitting] = useState(false);
-// State ----------------------
+ const handleRegister = async () => {
+ setSubmitting(true);
+ const user = await register();
+ setSubmitting(false);
+ if (!user) return;
+ if (user.IsAcceptedAdmin) {
+ navigation.reset({index: 0, routes: [{name: 'Admin'}]});
+ return;
+ }
+ navigation.reset({index: 0, routes: [{name: 'Game'}]});
+ };
- const [submitting, setSubmitting] = useState(false);
+ return (
+
+
+ Create Account
-// Handlers -------------------
+
+
+
+
- const handleRegister = async () => {
- setSubmitting(true);
- const user = await register();
- setSubmitting(false);
- if (!user) return;
- if (user.IsAcceptedAdmin) {
- navigation.reset({index: 0, routes: [{name: 'Admin'}]});
- return;
- }
- navigation.reset({index: 0, routes: [{name: 'Game'}]});
- };
+
+ setAccountType('Individual')}
+ disabled={isLoading}
+ >
+
+ Individual
+
+
+ setAccountType('Business/School')}
+ disabled={isLoading}
+ >
+
+ Business
+
+
+
-// View -----------------------
+ {error ? {error} : null}
- return (
-
-
- Create Account
+
+
+
-
-
-
-
-
-
- setAccountType('Individual')}
- disabled={isLoading}
- >
-
- Individual
-
-
- setAccountType('Business/School')}
- disabled={isLoading}
- >
-
- Business
-
-
-
-
- {error ? {error} : null}
-
-
-
-
-
- navigation.navigate('Auth', {screen: 'LoginScreen'})}>
- Back to Login
-
-
-
- );
+ navigation.navigate('Auth', {screen: 'LoginScreen'})}>
+ Back to Login
+
+
+
+ );
};
const styles = StyleSheet.create({
- container: {
- justifyContent: 'center',
- },
- content: {
- gap: 12,
- },
- title: {
- fontSize: 24,
- fontWeight: 'bold',
- textAlign: 'center',
- marginBottom: 10,
- },
- input: {
- borderWidth: 1,
- borderColor: '#d1d5db',
- borderRadius: 8,
- paddingHorizontal: 12,
- paddingVertical: 10,
- fontSize: 16,
- color: '#1f2937',
- backgroundColor: '#ffffff',
- },
- typeRow: {
- flexDirection: 'row',
- gap: 10,
- },
- typeButton: {
- flex: 1,
- borderWidth: 1,
- borderColor: '#cbd5e1',
- borderRadius: 8,
- alignItems: 'center',
- paddingVertical: 10,
- },
- typeButtonActive: {
- backgroundColor: '#2563eb',
- borderColor: '#2563eb',
- },
- typeText: {
- color: '#1f2937',
- fontWeight: '600',
- },
- typeTextActive: {
- color: '#ffffff',
- },
- errorText: {
- color: '#dc2626',
- textAlign: 'center',
- marginTop: 5,
- },
- submitButton: {
- backgroundColor: '#2563eb',
- borderColor: '#2563eb',
- },
- submitText: {
- color: '#ffffff',
- fontWeight: '600',
- },
- linkText: {
- textAlign: 'center',
- color: '#2563eb',
- fontWeight: '600',
- paddingVertical: 10,
- },
+ container: {
+ justifyContent: 'center',
+ },
+ content: {
+ gap: 12,
+ },
+ title: {
+ fontSize: 24,
+ fontWeight: 'bold',
+ textAlign: 'center',
+ marginBottom: 10,
+ color: '#cdd6f4',
+ },
+ input: {
+ borderWidth: 1,
+ borderColor: '#45475a',
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ fontSize: 16,
+ color: '#cdd6f4',
+ backgroundColor: '#313244',
+ },
+ typeRow: {
+ flexDirection: 'row',
+ gap: 10,
+ },
+ typeButton: {
+ flex: 1,
+ borderWidth: 1,
+ borderColor: '#45475a',
+ borderRadius: 8,
+ alignItems: 'center',
+ paddingVertical: 10,
+ },
+ typeButtonActive: {
+ backgroundColor: '#bd93f9',
+ borderColor: '#bd93f9',
+ },
+ typeText: {
+ color: '#bac2de',
+ fontWeight: '600',
+ },
+ typeTextActive: {
+ color: '#1e1e2e',
+ },
+ errorText: {
+ color: '#D92800',
+ textAlign: 'center',
+ marginTop: 5,
+ },
+ submitButton: {
+ backgroundColor: '#bd93f9',
+ borderColor: '#bd93f9',
+ },
+ submitText: {
+ color: '#1e1e2e',
+ fontWeight: '600',
+ },
+ linkText: {
+ textAlign: 'center',
+ color: '#bd93f9',
+ fontWeight: '600',
+ paddingVertical: 10,
+ },
});
export default RegisterScreen;
diff --git a/src/components/screens/TeamScreen.js b/src/components/screens/TeamScreen.js
index 0324f7f..e7f7d1a 100644
--- a/src/components/screens/TeamScreen.js
+++ b/src/components/screens/TeamScreen.js
@@ -3,59 +3,154 @@ import {ActivityIndicator, ScrollView, StyleSheet, Text, TextInput, View} from '
import Screen from '../layout/Screen';
import Card from '../UI/Card';
import {Button, ButtonTray} from '../UI/Button';
+import PendingApprovalView from '../UI/PendingApprovalView';
import useGameHook from '../../hooks/useGameHook';
-import {getSession, setSessionTeam} from '../../hooks/SessionStore';
+import {getSession, setPendingAdmin, setSessionTeam, setSessionUser} from '../../hooks/SessionStore';
-const TeamScreen = () => {
-// Initialisation ------------
+const TeamScreen = ({navigation}) => {
+ const session = getSession();
+ const isAdmin = session.isAcceptedAdmin;
+ const isBusiness = Boolean(session.isBusiness);
+ const {getLobby, getSubgroup, getTeam, createTeam, joinTeamByCode, getTeamMembers, leaveTeam, getUser, updateUser, updateTeam, getAdminWaitlist} = useGameHook();
- const session = getSession();
- const isAdmin = session.isAcceptedAdmin;
- const {getTeam, createTeam, joinTeamByCode, getTeamMembers, leaveTeam, getUser, updateUser} = useGameHook();
+ const [activeTid, setActiveTid] = useState(session.currentTid);
+ const [teamCode, setTeamCode] = useState('');
+ const [team, setTeam] = useState(null);
+ const [members, setMembers] = useState([]);
+ const [memberNames, setMemberNames] = useState({});
+ const [loading, setLoading] = useState(true);
+ const [teamsDisabled, setTeamsDisabled] = useState(false);
+ const [onAdminWaitlist, setOnAdminWaitlist] = useState(false);
+ const [editingName, setEditingName] = useState(false);
+ const [teamNameDraft, setTeamNameDraft] = useState('');
-// State ----------------------
-
- const [activeTid, setActiveTid] = useState(session.currentTid);
- const [teamCode, setTeamCode] = useState('');
- const [team, setTeam] = useState(null);
- const [members, setMembers] = useState([]);
- const [memberNames, setMemberNames] = useState({});
- const [loading, setLoading] = useState(true);
-
-// Handlers -------------------
-
- const loadTeamData = async (tid) => {
- const t = await getTeam(tid);
- setTeam(t);
- const ms = await getTeamMembers(tid);
- setMembers(ms || []);
- const names = {};
- for (const m of (ms || [])) {
- const u = await getUser(m.Uid);
- if (u) names[m.Uid] = u.username;
- }
- setMemberNames(names);
- };
+ const loadTeamData = async (tid) => {
+ const t = await getTeam(tid);
+ setTeam(t);
+ const ms = await getTeamMembers(tid);
+ setMembers(ms || []);
+ const names = {};
+ for (const m of (ms || [])) {
+ const u = await getUser(m.Uid);
+ if (u) names[m.Uid] = u.username;
+ }
+ setMemberNames(names);
+ };
useEffect(() => {
const load = async () => {
+ if (!session.currentGid) { setLoading(false); return; }
+
+ const group = await getLobby(session.currentGid);
+ let teamsAreEnabled = Boolean(group?.TeamsEnabled);
+
+ if (isBusiness && session.currentSGid) {
+ const subgroup = await getSubgroup(session.currentSGid);
+ if (subgroup) teamsAreEnabled = Boolean(subgroup.TeamsEnabled);
+ }
+
+ if (session.isPendingAdmin && !session.isAcceptedAdmin) {
+ const freshUser = await getUser(session.currentUid);
+ if (freshUser?.IsAcceptedAdmin) {
+ setPendingAdmin(false);
+ setSessionUser(freshUser);
+ setSessionTeam(freshUser.TGid ?? null);
+ navigation.reset({index: 0, routes: [{name: 'Admin'}]});
+ return;
+ }
+ const waitlist = await getAdminWaitlist(session.currentGid, session.currentUid);
+ if (waitlist.length > 0) {
+ setOnAdminWaitlist(true);
+ setLoading(false);
+ return;
+ }
+ setPendingAdmin(false);
+ }
+
+ if (!teamsAreEnabled) {
+ setTeamsDisabled(true);
+ setLoading(false);
+ return;
+ }
+ setTeamsDisabled(false);
+
+ if (!activeTid) {
+ const waitlist = await getAdminWaitlist(session.currentGid, session.currentUid);
+ if (waitlist.length > 0) {
+ setOnAdminWaitlist(true);
+ setLoading(false);
+ return;
+ }
+ }
if (activeTid) await loadTeamData(activeTid);
setLoading(false);
};
load();
}, [activeTid]);
+ useEffect(() => {
+ if (!onAdminWaitlist && !session.isPendingAdmin) return;
+
+ let stopped = false;
+ const refreshWaitlistStatus = async () => {
+ const currentSession = getSession();
+ if (!currentSession.currentUid || !currentSession.currentGid) return;
+
+ const freshUser = await getUser(currentSession.currentUid);
+ if (stopped) return;
+
+ if (freshUser?.IsAcceptedAdmin) {
+ setPendingAdmin(false);
+ setSessionUser(freshUser);
+ setSessionTeam(freshUser.TGid ?? null);
+ navigation.reset({index: 0, routes: [{name: 'Admin'}]});
+ return;
+ }
+
+ const waitlist = await getAdminWaitlist(currentSession.currentGid, currentSession.currentUid);
+ if (stopped) return;
+
+ const isStillWaiting = waitlist.length > 0;
+ setOnAdminWaitlist(isStillWaiting);
+ if (!isStillWaiting) {
+ setPendingAdmin(false);
+ }
+ };
+
+ refreshWaitlistStatus();
+ const intervalId = setInterval(refreshWaitlistStatus, 5000);
+
+ return () => {
+ stopped = true;
+ clearInterval(intervalId);
+ };
+ }, [onAdminWaitlist, session.isPendingAdmin]);
+
const handleJoinTeam = async () => {
if (!teamCode.trim()) return;
- 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,
+ ExpectedSGid: session.currentSGid,
+ });
if (!result) return;
+
+ // If the server indicates this code matched the admin join code
+ if (result.adminWaitlist) {
+ setPendingAdmin(true);
+ setOnAdminWaitlist(true);
+ return;
+ }
+
setSessionTeam(result.Tid);
setActiveTid(result.Tid);
};
const handleCreateTeam = async () => {
if (!session.currentGid) return;
- const result = await createTeam({Gid: session.currentGid, TeamName: 'My Team'});
+ const result = await createTeam({Gid: session.currentGid, SGid: session.currentSGid, TeamName: 'My Team'});
if (!result) return;
const membership = await joinTeamByCode({Tid: result.Tid, Uid: session.currentUid});
if (membership) {
@@ -81,6 +176,16 @@ const TeamScreen = () => {
await loadTeamData(activeTid);
};
+ const handleRenameTeam = async () => {
+ const trimmed = teamNameDraft.trim();
+ if (!trimmed || !activeTid) return;
+ const result = await updateTeam(activeTid, {TeamName: trimmed});
+ if (result) {
+ setTeam(result);
+ setEditingName(false);
+ }
+ };
+
// Check if current user is the team leader
const myMembership = members.find((m) => m.Uid === session.currentUid);
const isCurrentUserLeader = Boolean(myMembership?.IsLeader);
@@ -95,24 +200,48 @@ const TeamScreen = () => {
);
}
- // Not in a team
- if (!activeTid || !team) {
+ if (teamsDisabled) {
return (
+
+ Teams Disabled
+ The admin has not enabled teams for this game.
+
+
+ );
+ }
+
+ // Waiting for admin approval
+ if (onAdminWaitlist) {
+ return ;
+ }
+
+ // Not in a team
+ if (!activeTid || !team) {
+ // Disable team actions until the user has joined a game
+ const notInGame = !session.currentGid;
+
+ return (
+
+ {notInGame && (
+ Join a game first to manage teams.
+ )}
setTeamCode(text.toUpperCase())}
autoCapitalize="characters"
+ editable={!notInGame}
/>
{!isAdmin && (
@@ -123,6 +252,7 @@ const TeamScreen = () => {
onClick={handleCreateTeam}
styleButton={styles.createButton}
styleLabel={styles.createLabel}
+ disabled={notInGame}
/>
@@ -138,9 +268,51 @@ const TeamScreen = () => {
Team Code:
{team.JoinCode}
+
+ {/* Team name display / edit */}
+
+ {editingName ? (
+
+
+
+ setEditingName(false)}
+ styleButton={styles.renameCancelButton}
+ styleLabel={styles.renameCancelLabel}
+ />
+
+ ) : (
+
+ {team.TeamName || 'Unnamed Team'}
+ {isCurrentUserLeader && (
+ { setTeamNameDraft(team.TeamName || ''); setEditingName(true); }}
+ styleButton={styles.renameButton}
+ styleLabel={styles.renameLabel}
+ />
+ )}
+
+ )}
+
+
{members.map((m) => (
-
+ // Highlight the current user's card so they can identify themselves
+
@@ -162,15 +334,16 @@ const TeamScreen = () => {
{members.length === 0 && (
No members yet.
)}
+ {/* Leave button lives inside the scroll so it is never obscured by the bottom nav bar */}
+
+
+
-
-
-
);
};
@@ -178,33 +351,65 @@ const TeamScreen = () => {
const styles = StyleSheet.create({
center: {justifyContent: 'center', alignItems: 'center'},
container: {padding: 0},
+ disabledWrap: {paddingHorizontal: 30, alignItems: 'center', opacity: 0.45},
+ disabledTitle: {fontSize: 20, fontWeight: '700', color: '#ffffff', marginBottom: 10, textAlign: 'center'},
+ disabledBody: {fontSize: 15, color: '#ffffff', textAlign: 'center'},
+ notInGameText: {color: '#ffffff', fontSize: 15, fontWeight: '600', marginBottom: 16, textAlign: 'center', paddingHorizontal: 20},
+ inputDisabled: {backgroundColor: '#313244', opacity: 0.5},
inputRow: {flexDirection: 'row', gap: 10, marginBottom: 15, width: '100%', paddingHorizontal: 20},
fullRow: {width: '100%', paddingHorizontal: 20},
codeInput: {
flex: 1,
borderWidth: 1,
- borderColor: '#d1d5db',
+ borderColor: '#45475a',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
- color: '#1f2937',
- backgroundColor: '#ffffff',
+ color: '#ffffff',
+ backgroundColor: '#313244',
+ },
+ joinButton: {backgroundColor: '#bd93f9', borderColor: '#bd93f9', flex: 0, paddingHorizontal: 20},
+ joinLabel: {color: '#1e1e2e', fontWeight: '600'},
+ createButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1'},
+ createLabel: {color: '#1e1e2e', fontWeight: '600'},
+ waitlistText: {
+ fontSize: 18,
+ fontWeight: '700',
+ color: '#ffffff',
+ textAlign: 'center',
+ paddingHorizontal: 30,
},
- joinButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, paddingHorizontal: 20},
- joinLabel: {color: '#ffffff', fontWeight: '600'},
- createButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
- createLabel: {color: '#ffffff', fontWeight: '600'},
teamHeader: {
- backgroundColor: '#374151',
+ backgroundColor: '#45475a',
paddingVertical: 14,
paddingHorizontal: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
- teamCodeLabel: {color: '#d1d5db', fontSize: 15, fontWeight: '600'},
+ teamCodeLabel: {color: '#ffffff', fontSize: 15, fontWeight: '600'},
teamCodeValue: {color: '#ffffff', fontSize: 17, fontWeight: '700', letterSpacing: 2},
+ teamNameSection: {paddingHorizontal: 15, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#45475a'},
+ teamNameText: {fontSize: 18, fontWeight: '700', color: '#ffffff', flex: 1},
+ renameRow: {flexDirection: 'row', alignItems: 'center', gap: 8},
+ renameInput: {
+ flex: 1,
+ borderWidth: 1,
+ borderColor: '#45475a',
+ borderRadius: 8,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ fontSize: 16,
+ color: '#ffffff',
+ backgroundColor: '#313244',
+ },
+ renameButton: {backgroundColor: '#bd93f9', borderColor: '#bd93f9', flex: 0, minHeight: 36, paddingHorizontal: 14},
+ renameLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13},
+ renameSaveButton: {backgroundColor: '#a6e3a1', borderColor: '#a6e3a1', flex: 0, minHeight: 36, paddingHorizontal: 12},
+ renameSaveLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13},
+ renameCancelButton: {backgroundColor: '#6c7086', borderColor: '#6c7086', flex: 0, minHeight: 36, paddingHorizontal: 12},
+ renameCancelLabel: {color: '#cdd6f4', fontWeight: '600', fontSize: 13},
memberList: {flex: 1, paddingHorizontal: 15, paddingTop: 10},
memberRow: {
flexDirection: 'row',
@@ -212,14 +417,15 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
memberInfo: {flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6},
- memberName: {fontSize: 16, color: '#1f2937', fontWeight: '600'},
- leaderBadge: {fontSize: 12, color: '#2563eb', fontWeight: '700'},
- kickButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 0, paddingHorizontal: 14},
- kickLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
- emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
- leaveWrap: {paddingHorizontal: 15, paddingVertical: 10},
- leaveButton: {backgroundColor: '#dc2626', borderColor: '#dc2626'},
- leaveLabel: {color: '#ffffff', fontWeight: '600'},
+ memberName: {fontSize: 16, color: '#ffffff', fontWeight: '600'},
+ leaderBadge: {fontSize: 12, color: '#bd93f9', fontWeight: '700'},
+ kickButton: {backgroundColor: '#D92800', borderColor: '#D92800', minHeight: 36, flex: 0, paddingHorizontal: 14},
+ kickLabel: {color: '#1e1e2e', fontWeight: '600', fontSize: 13},
+ emptyText: {color: '#ffffff', textAlign: 'center', marginTop: 30, fontSize: 14},
+ leaveWrap: {paddingHorizontal: 0, paddingTop: 10, paddingBottom: 16},
+ leaveButton: {backgroundColor: '#D92800', borderColor: '#D92800'},
+ leaveLabel: {color: '#1e1e2e', fontWeight: '600'},
+ selfCard: {backgroundColor: 'rgba(166, 227, 161, 0.15)', borderColor: '#a6e3a1'},
});
export default TeamScreen;
diff --git a/src/hooks/SessionStore.js b/src/hooks/SessionStore.js
index 341b68d..47ded78 100644
--- a/src/hooks/SessionStore.js
+++ b/src/hooks/SessionStore.js
@@ -11,10 +11,12 @@ const session = {
currentTid: null,
isBusiness: null,
isAcceptedAdmin: false,
+ isPendingAdmin: false,
teamsEnabled: false,
// Global gameplay
currentGlobalEventId: null,
currentGlobalPlayerId: null,
+ selectedCacheId: null,
};
// --- Handlers ---
@@ -26,13 +28,16 @@ const setSessionUser = (user) => {
session.currentTid = user?.TGid ?? null;
session.isBusiness = user?.isBusiness ?? null;
session.isAcceptedAdmin = Boolean(user?.IsAcceptedAdmin);
+ session.isPendingAdmin = Boolean(
+ user?.isBusiness && user?.Gid && !user?.SGid && !user?.IsAcceptedAdmin,
+ );
persistSession();
};
const setSessionGroup = (gid, sgid = null) => {
session.currentGid = gid ?? null;
- if (sgid !== null && sgid !== undefined) {
- session.currentSGid = sgid;
+ if (sgid !== undefined) {
+ session.currentSGid = sgid ?? null;
}
persistSession();
};
@@ -47,6 +52,16 @@ const setSessionTeamsEnabled = (val) => {
persistSession();
};
+const setPendingAdmin = (val) => {
+ session.isPendingAdmin = Boolean(val);
+ persistSession();
+};
+
+const setSelectedCache = (cacheId) => {
+ session.selectedCacheId = cacheId ?? null;
+ persistSession();
+};
+
const getSession = () => ({ ...session });
const setGlobalSession = (eventId, playerId) => {
@@ -70,6 +85,7 @@ const clearSession = () => {
session.isAcceptedAdmin = false;
session.currentGlobalEventId = null;
session.currentGlobalPlayerId = null;
+ session.isPendingAdmin = false;
AsyncStorage.removeItem(STORAGE_KEY).catch(() => {});
};
@@ -79,7 +95,9 @@ const clearGameSession = () => {
session.currentSGid = null;
session.currentTid = null;
session.isAcceptedAdmin = false;
+ session.isPendingAdmin = false;
session.teamsEnabled = false;
+ session.selectedCacheId = null;
persistSession();
};
@@ -114,3 +132,4 @@ export {
setGlobalSession,
clearGlobalSession,
};
+export { setSessionUser, setSessionGroup, setSessionTeam, setSessionTeamsEnabled, setPendingAdmin, setSelectedCache, getSession, clearSession, clearGameSession, loadSession };
diff --git a/src/hooks/useGameHook.js b/src/hooks/useGameHook.js
index 06822ce..4385a71 100644
--- a/src/hooks/useGameHook.js
+++ b/src/hooks/useGameHook.js
@@ -6,10 +6,11 @@ const useGameHook = () => {
const usersEndpoint = `${API_BASE_URL}/users`;
const groupsEndpoint = `${API_BASE_URL}/groups`;
const subgroupsEndpoint = `${API_BASE_URL}/subgroups`;
- const subgroupMembershipsEndpoint = `${API_BASE_URL}/subgroup-memberships`;
+ const subgroupMembersEndpoint = `${API_BASE_URL}/subgroup-members`;
const teamsEndpoint = `${API_BASE_URL}/teams`;
const teamMembersEndpoint = `${API_BASE_URL}/team-members`;
- const gameDataEndpoint = `${API_BASE_URL}/game-data`;
+ const cachesEndpoint = `${API_BASE_URL}/caches`;
+ const adminWaitlistEndpoint = `${API_BASE_URL}/admin-waitlist`;
// Handlers -------------------
@@ -40,30 +41,71 @@ const useGameHook = () => {
return response.isSuccess ? response.result : null;
};
+ // Look up a group by its Organisation Join Code
+ const getGroupByOrgCode = async (code) => {
+ const normalizedCode = String(code || '').trim().toUpperCase();
+ if (!normalizedCode) return null;
+ const response = await API.get(`${groupsEndpoint}?OrgJoinCode=${encodeURIComponent(normalizedCode)}`);
+ if (response.isSuccess && response.result.length > 0) return response.result[0];
+ return null;
+ };
+
+ // Subgroups
const getSubgroups = async (gid) => {
const response = await API.get(`${subgroupsEndpoint}?Gid=${gid}`);
return response.isSuccess ? response.result : [];
};
- // Subgroup memberships
- const joinPrivateGame = async (payload) => {
- const response = await API.post(subgroupMembershipsEndpoint, payload);
+ const getSubgroup = async (sgid) => {
+ const response = await API.get(`${subgroupsEndpoint}/${sgid}`);
return response.isSuccess ? response.result : null;
};
- const getGroupMembers = async (gid) => {
- const response = await API.get(`${subgroupMembershipsEndpoint}?Gid=${gid}`);
+ const createSubgroup = async (payload) => {
+ const response = await API.post(subgroupsEndpoint, payload);
+ return response.isSuccess ? response.result : null;
+ };
+
+ const updateSubgroup = async (sgid, data) => {
+ const response = await API.put(`${subgroupsEndpoint}/${sgid}`, data);
+ return response.isSuccess ? response.result : null;
+ };
+
+ const deleteSubgroup = async (sgid) => {
+ const response = await API.delete(`${subgroupsEndpoint}/${sgid}`);
+ return response.isSuccess;
+ };
+
+ // Subgroup members
+ const joinPrivateGame = async (payload) => {
+ const data = {
+ JoinCode: payload.JoinCode,
+ Uid: payload.Uid,
+ };
+ if (payload.ExpectedGid !== undefined && payload.ExpectedGid !== null) {
+ data.ExpectedGid = payload.ExpectedGid;
+ }
+ const response = await API.post(subgroupMembersEndpoint, data);
+ return response.isSuccess ? response.result : null;
+ };
+
+ const getGroupMembers = async (gid, sgid = null) => {
+ let url = `${subgroupMembersEndpoint}?Gid=${gid}`;
+ if (sgid !== null && sgid !== undefined) url += `&SGid=${sgid}`;
+ const response = await API.get(url);
return response.isSuccess ? response.result : [];
};
const removeMember = async (membershipId) => {
- const response = await API.delete(`${subgroupMembershipsEndpoint}/${membershipId}`);
+ const response = await API.delete(`${subgroupMembersEndpoint}/${membershipId}`);
return response.isSuccess;
};
// Teams
- const getTeams = async (gid) => {
- const response = await API.get(`${teamsEndpoint}?Gid=${gid}`);
+ const getTeams = async (gid, sgid = null) => {
+ let url = `${teamsEndpoint}?Gid=${gid}`;
+ if (sgid !== null && sgid !== undefined) url += `&SGid=${sgid}`;
+ const response = await API.get(url);
return response.isSuccess ? response.result : [];
};
@@ -77,8 +119,34 @@ const useGameHook = () => {
return response.isSuccess ? response.result : null;
};
+ const updateTeam = async (tid, data) => {
+ const response = await API.put(`${teamsEndpoint}/${tid}`, data);
+ return response.isSuccess ? response.result : null;
+ };
+
+ const deleteTeam = async (tid) => {
+ const response = await API.delete(`${teamsEndpoint}/${tid}`);
+ return response.isSuccess;
+ };
+
+ const disbandTeams = async (gid) => {
+ const response = await API.post(`${teamsEndpoint}/disband?Gid=${gid}`, {});
+ return response.isSuccess;
+ };
+
const joinTeamByCode = async (payload) => {
- const response = await API.post(teamMembersEndpoint, payload);
+ const data = {
+ Uid: payload.Uid,
+ };
+ if (payload.JoinCode !== undefined) data.JoinCode = payload.JoinCode;
+ if (payload.Tid !== undefined) data.Tid = payload.Tid;
+ if (payload.ExpectedGid !== undefined && payload.ExpectedGid !== null) {
+ data.ExpectedGid = payload.ExpectedGid;
+ }
+ if (payload.ExpectedSGid !== undefined && payload.ExpectedSGid !== null) {
+ data.ExpectedSGid = payload.ExpectedSGid;
+ }
+ const response = await API.post(teamMembersEndpoint, data);
return response.isSuccess ? response.result : null;
};
@@ -93,39 +161,86 @@ const useGameHook = () => {
return response.isSuccess;
};
- // Caches
const getCaches = async (gid, sgid = null) => {
- let url = `${gameDataEndpoint}/${gid}/caches`;
- if (sgid !== null) url += `?SGid=${sgid}`;
+ let url = `${cachesEndpoint}?Gid=${gid}`;
+ if (sgid !== null) url += `&SGid=${sgid}`;
const response = await API.get(url);
return response.isSuccess ? response.result : [];
};
const upsertCache = async (payload) => {
+ const data = {
+ Gid: payload.gid,
+ SGid: payload.subgroupId,
+ Title: payload.name,
+ Clue: payload.clue,
+ Latitude: payload.latitude,
+ Longitude: payload.longitude,
+ TriggerMeters: payload.radius,
+ };
if (payload.cacheId) {
- const response = await API.put(
- `${gameDataEndpoint}/${payload.gid}/caches/${payload.cacheId}`,
- payload,
- );
+ const response = await API.put(`${cachesEndpoint}/${payload.cacheId}`, data);
return response.isSuccess ? response.result : null;
}
- const response = await API.post(
- `${gameDataEndpoint}/${payload.gid}/caches`,
- payload,
- );
+ const response = await API.post(cachesEndpoint, data);
return response.isSuccess ? response.result : null;
};
const claimCache = async (payload) => {
- const response = await API.put(
- `${gameDataEndpoint}/${payload.gid}/caches/${payload.cacheId}`,
- {ClaimedByUid: payload.uid, ClaimedByTid: payload.tid},
+ let url = `${cachesEndpoint}/${payload.cacheId}/claim`;
+ if (payload.sgid !== null && payload.sgid !== undefined) {
+ url += `?SGid=${payload.sgid}`;
+ }
+ const response = await API.post(
+ url,
+ {Uid: payload.uid, Tid: payload.tid, SGid: payload.sgid},
);
return response.isSuccess ? response.result : null;
};
const deleteCache = async (gid, cacheId) => {
- const response = await API.delete(`${gameDataEndpoint}/${gid}/caches/${cacheId}`);
+ const response = await API.delete(`${cachesEndpoint}/${cacheId}`);
+ return response.isSuccess;
+ };
+
+ // Game reset — remove all caches for a game (optionally scoped to a subgroup)
+ const resetGame = async (gid, sgid = null) => {
+ let url = `${cachesEndpoint}/reset?Gid=${gid}`;
+ if (sgid !== null) url += `&SGid=${sgid}`;
+ const response = await API.post(url, {});
+ return response.isSuccess;
+ };
+
+ // Player progress reset — clear all claims by a specific user
+ const resetPlayerProgress = async (gid, uid, sgid = null) => {
+ let url = `${cachesEndpoint}/reset-player/${uid}?Gid=${gid}`;
+ if (sgid !== null && sgid !== undefined) url += `&SGid=${sgid}`;
+ const response = await API.post(url, {});
+ return response.isSuccess;
+ };
+
+ const resetTeamProgress = async (gid, tid, sgid = null) => {
+ let url = `${cachesEndpoint}/reset-team/${tid}?Gid=${gid}`;
+ if (sgid !== null && sgid !== undefined) url += `&SGid=${sgid}`;
+ const response = await API.post(url, {});
+ 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;
};
@@ -137,13 +252,21 @@ const useGameHook = () => {
createPrivateGame,
getLobby,
updateGroup,
+ getGroupByOrgCode,
getSubgroups,
+ getSubgroup,
+ createSubgroup,
+ updateSubgroup,
+ deleteSubgroup,
joinPrivateGame,
getGroupMembers,
removeMember,
getTeams,
getTeam,
createTeam,
+ updateTeam,
+ deleteTeam,
+ disbandTeams,
joinTeamByCode,
getTeamMembers,
leaveTeam,
@@ -151,6 +274,12 @@ const useGameHook = () => {
upsertCache,
claimCache,
deleteCache,
+ resetGame,
+ resetPlayerProgress,
+ resetTeamProgress,
+ getAdminWaitlist,
+ approveAdmin,
+ rejectAdmin,
};
};
diff --git a/src/hooks/usePlayerGame.js b/src/hooks/usePlayerGame.js
index 99e6dcc..85b5370 100644
--- a/src/hooks/usePlayerGame.js
+++ b/src/hooks/usePlayerGame.js
@@ -1,35 +1,36 @@
-import {useEffect, useState} from 'react';
+import {useEffect, useRef, useState} from 'react';
import {isInClaimCone} from '../utils/geoMath';
-const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistance) => {
-// State ----------------------
-
- const [visibleCache, setVisibleCache] = useState(null);
+const usePlayerGame = (playerLocation, playerHeading, activeCaches, claimDistance, selectedCacheId) => {
+ const [visibleCaches, setVisibleCaches] = useState([]);
const [isClaiming, setIsClaiming] = useState(false);
-
-// Handlers -------------------
+ const prevIdRef = useRef('');
useEffect(() => {
- if (!playerLocation || playerHeading === null || playerHeading === undefined) {
- setVisibleCache(null);
- setIsClaiming(false);
+ if (!playerLocation || playerHeading === null || playerHeading === undefined || !selectedCacheId) {
+ if (prevIdRef.current !== '') {
+ prevIdRef.current = '';
+ setVisibleCaches([]);
+ setIsClaiming(false);
+ }
return;
}
- // Find the first cache that falls inside the invisible claim cone
- // (same FOV angle as the visible cone, but radius = admin-set claimDistance)
- const cacheInCone = (activeCaches || []).find((cache) =>
- isInClaimCone(playerHeading, playerLocation, cache.coordinates, claimDistance)
- );
+ const selected = (activeCaches || []).find((c) => c.id === selectedCacheId);
+ const visible = selected && isInClaimCone(playerHeading, playerLocation, selected.coordinates, claimDistance);
+ const newId = visible ? String(selected.id) : '';
- setVisibleCache(cacheInCone || null);
- setIsClaiming(Boolean(cacheInCone));
- }, [playerLocation, playerHeading, activeCaches, claimDistance]);
+ if (newId !== prevIdRef.current) {
+ prevIdRef.current = newId;
+ setVisibleCaches(visible ? [selected] : []);
+ setIsClaiming(Boolean(visible));
+ }
+ }, [playerLocation, playerHeading, activeCaches, claimDistance, selectedCacheId]);
-// Return ---------------------
-
- return {visibleCache, isClaiming, setIsClaiming};
+ return {visibleCaches, isClaiming, setIsClaiming};
};
export default usePlayerGame;
+
+