diff --git a/App.js b/App.js
index 6ba295b..77a2d4f 100644
--- a/App.js
+++ b/App.js
@@ -1,5 +1,6 @@
import {NavigationContainer} from "@react-navigation/native";
import {createNativeStackNavigator} from "@react-navigation/native-stack";
+import {SafeAreaProvider} from "react-native-safe-area-context";
import LoginScreen from "./src/components/screens/LoginScreen";
import RegisterScreen from "./src/components/screens/RegisterScreen";
import GameTypeScreen from "./src/components/screens/GameTypeScreen";
@@ -14,58 +15,63 @@ import LeaderboardScreen from "./src/components/screens/LeaderboardScreen";
import AdminHubScreen from "./src/components/screens/AdminHubScreen";
import ManageSubgroupsScreen from "./src/components/screens/ManageSubgroupsScreen";
-const Stack = createNativeStackNavigator();
+const RootStack = createNativeStackNavigator();
+const AuthStackNav = createNativeStackNavigator();
+const GameStackNav = createNativeStackNavigator();
+const AdminStackNav = createNativeStackNavigator();
const AuthStack = () => (
-
-
-
-
+
+
+
);
const GameStack = () => (
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
);
const AdminStack = () => (
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
);
export const App = () => {
return (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
);
};
diff --git a/DB/admin-waitlist.json b/DB/admin-waitlist.json
deleted file mode 100644
index 7dd4387..0000000
--- a/DB/admin-waitlist.json
+++ /dev/null
@@ -1,2 +0,0 @@
-[]
-
diff --git a/DB/db.json b/DB/db.json
deleted file mode 100644
index 9494e23..0000000
--- a/DB/db.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "databaseMode": "split-endpoints",
- "index": {
- "users": "users.json",
- "groups": "groups.json",
- "subgroups": "subgroups.json",
- "subgroup_memberships": "subgroup-memberships.json",
- "admin_waitlist": "admin-waitlist.json",
- "teams": "teams.json",
- "team_members": "team-members.json",
- "game_data": "game-data.json",
- "game_types": "game-types.json"
- }
-}
diff --git a/DB/game-data.json b/DB/game-data.json
deleted file mode 100644
index e333c4d..0000000
--- a/DB/game-data.json
+++ /dev/null
@@ -1,16 +0,0 @@
-[
- {
- "Gid": 1,
- "GeoCaches": [
- {
- "CacheId": "C1",
- "Title": "Main Entrance",
- "Latitude": 0,
- "Longitude": 0,
- "TriggerMeters": 20,
- "SGid": 1
- }
- ]
- }
-]
-
diff --git a/DB/game-types.json b/DB/game-types.json
deleted file mode 100644
index 9b3ab44..0000000
--- a/DB/game-types.json
+++ /dev/null
@@ -1,6 +0,0 @@
-[
- "Join Global Game",
- "Join Private Game",
- "Manage Private Game"
-]
-
diff --git a/DB/groups.json b/DB/groups.json
deleted file mode 100644
index 1909370..0000000
--- a/DB/groups.json
+++ /dev/null
@@ -1,15 +0,0 @@
-[
- {
- "Gid": 1,
- "GroupName": "Main Private Game",
- "BusinessOrSchoolName": "MAD Academy",
- "GameType": "private",
- "AdminJoinCode": "A91TQ",
- "CreatedByUid": 1,
- "MaxMemberSubgroups": 1,
- "TeamsEnabled": true,
- "ApprovedAdmins": [1],
- "CreatedAt": "2026-03-17T00:00Z"
- }
-]
-
diff --git a/DB/subgroup-memberships.json b/DB/subgroup-memberships.json
deleted file mode 100644
index 7119cff..0000000
--- a/DB/subgroup-memberships.json
+++ /dev/null
@@ -1,15 +0,0 @@
-[
- {
- "Uid": 1,
- "Gid": 1,
- "SGid": 0,
- "IsAcceptedAdmin": true
- },
- {
- "Uid": 2,
- "Gid": 1,
- "SGid": 1,
- "IsAcceptedAdmin": false
- }
-]
-
diff --git a/DB/subgroups.json b/DB/subgroups.json
deleted file mode 100644
index baa5636..0000000
--- a/DB/subgroups.json
+++ /dev/null
@@ -1,19 +0,0 @@
-[
- {
- "SGid": 0,
- "SubGroupName": "Admins",
- "Gid": 1,
- "JoinCode": null,
- "IsAdminGroup": true,
- "CacheTriggerMeters": 20
- },
- {
- "SGid": 1,
- "SubGroupName": "Members",
- "Gid": 1,
- "JoinCode": "M4P8R",
- "IsAdminGroup": false,
- "CacheTriggerMeters": 20
- }
-]
-
diff --git a/DB/team-members.json b/DB/team-members.json
deleted file mode 100644
index 7dd4387..0000000
--- a/DB/team-members.json
+++ /dev/null
@@ -1,2 +0,0 @@
-[]
-
diff --git a/DB/teams.json b/DB/teams.json
deleted file mode 100644
index 7dd4387..0000000
--- a/DB/teams.json
+++ /dev/null
@@ -1,2 +0,0 @@
-[]
-
diff --git a/DB/users.json b/DB/users.json
deleted file mode 100644
index eb2715d..0000000
--- a/DB/users.json
+++ /dev/null
@@ -1,25 +0,0 @@
-[
- {
- "Uid": 1,
- "username": "admin",
- "email": "admin@mad.local",
- "passwordHash": "H_145737963",
- "isBusiness": true,
- "IsAcceptedAdmin": true,
- "Gid": 1,
- "SGid": 0,
- "TGid": null
- },
- {
- "Uid": 2,
- "username": "member1",
- "email": "member1@mad.local",
- "passwordHash": "H_1322967599",
- "isBusiness": false,
- "IsAcceptedAdmin": false,
- "Gid": 1,
- "SGid": 1,
- "TGid": null
- }
-]
-
diff --git a/app.json b/app.json
index be4b335..4a8162a 100644
--- a/app.json
+++ b/app.json
@@ -20,7 +20,7 @@
"foregroundImage": "./assets/images/android-icon-monochrome.png",
"backgroundColor": "#ffffff"
},
- "edgeToEdgeEnabled": true
+ "edgeToEdgeEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..9ed7c53
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,14 @@
+{
+ "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
new file mode 100644
index 0000000..1653266
--- /dev/null
+++ b/server/server.js
@@ -0,0 +1,713 @@
+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,
+ 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 === payload.JoinCode);
+ 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' });
+ rows.splice(idx, 1);
+ writeJson('subgroup-memberships.json', rows);
+ 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 === payload.JoinCode);
+ if (!team) return sendJson(res, 400, { message: 'Invalid team join code' });
+ payload.Tid = team.Tid;
+ }
+
+ payload.id = nextId(rows, 'id');
+ 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' });
+ rows.splice(idx, 1);
+ writeJson('team-members.json', rows);
+ 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/API/API.js b/src/components/API/API.js
index 050895c..3e701b0 100644
--- a/src/components/API/API.js
+++ b/src/components/API/API.js
@@ -1,4 +1,18 @@
-export const API_BASE_URL = 'http://localhost:3000';
+import Constants from 'expo-constants';
+
+const getBaseUrl = () => {
+ // In Expo Go the debuggerHost is "192.168.x.x:8081" — grab the IP
+ const debuggerHost = Constants.expoGoConfig?.debuggerHost;
+ if (debuggerHost) {
+ const ip = debuggerHost.split(':')[0];
+ return `http://${ip}:3000`;
+ }
+ // Fallback for web / production
+ return 'http://localhost:3000';
+};
+
+export const API_BASE_URL = getBaseUrl();
+//export const API_BASE_URL = 'http://api.bobby.ip-ddns.com'; // Production server URL
const API = {};
diff --git a/src/components/layout/BottomNavbar.js b/src/components/layout/BottomNavbar.js
index 54473df..ebb3446 100644
--- a/src/components/layout/BottomNavbar.js
+++ b/src/components/layout/BottomNavbar.js
@@ -1,8 +1,11 @@
import {Pressable, StyleSheet, Text, View} from 'react-native';
+import {useSafeAreaInsets} from 'react-native-safe-area-context';
const BottomNavbar = ({ navigation, routeName }) => {
// Initialisation ------------
+ const insets = useSafeAreaInsets();
+
const tabs = [
{
label: 'Map',
@@ -26,7 +29,7 @@ const BottomNavbar = ({ navigation, routeName }) => {
// View -----------------------
return (
-
+
{tabs.map((tab) => {
const isActive = routeName === tab.route;
return (
@@ -47,8 +50,8 @@ const styles = StyleSheet.create({
container: {
minHeight: 64,
borderTopWidth: 1,
- borderTopColor: '#e5e7eb',
- backgroundColor: '#ffffff',
+ borderTopColor: '#555555',
+ backgroundColor: '#4a4a4a',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
@@ -63,16 +66,16 @@ const styles = StyleSheet.create({
marginHorizontal: 4,
},
tabActive: {
- backgroundColor: '#eff6ff',
+ backgroundColor: '#5c5c5c',
},
tabText: {
- color: '#374151',
+ color: '#d1d5db',
fontSize: 12,
fontWeight: '600',
textAlign: 'center',
},
tabTextActive: {
- color: '#2563eb',
+ color: '#ffffff',
},
});
diff --git a/src/components/layout/Screen.js b/src/components/layout/Screen.js
index 3382560..d82d9c3 100644
--- a/src/components/layout/Screen.js
+++ b/src/components/layout/Screen.js
@@ -1,6 +1,7 @@
import {StatusBar} from "expo-status-bar";
import {StyleSheet, View} from "react-native";
import {useNavigation, useRoute} from "@react-navigation/native";
+import {useSafeAreaInsets} from "react-native-safe-area-context";
import TopNavbar from "./TopNavbar";
import BottomNavbar from "./BottomNavbar";
@@ -9,6 +10,7 @@ const Screen = ({ children, style }) => {
const navigation = useNavigation();
const route = useRoute();
+ const insets = useSafeAreaInsets();
const titleMap = {
GameTypeScreen: "Game Type",
@@ -35,9 +37,9 @@ const Screen = ({ children, style }) => {
// View -----------------------
return (
-
+
{!isAuthScreen && }
- {children}
+ {children}
{!isAuthScreen && }
diff --git a/src/components/layout/TopNavbar.js b/src/components/layout/TopNavbar.js
index fc70bb6..751094d 100644
--- a/src/components/layout/TopNavbar.js
+++ b/src/components/layout/TopNavbar.js
@@ -1,8 +1,12 @@
import {Pressable, StyleSheet, Text, View} from 'react-native';
+import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {clearSession} from '../../hooks/SessionStore';
const TopNavbar = ({title, showBack, navigation}) => {
// Initialisation ------------
+
+ const insets = useSafeAreaInsets();
+
// State ----------------------
// Handlers -------------------
@@ -21,7 +25,7 @@ const TopNavbar = ({title, showBack, navigation}) => {
// View -----------------------
return (
-
+
{showBack ? (
@@ -43,12 +47,12 @@ const styles = StyleSheet.create({
container: {
minHeight: 56,
borderBottomWidth: 1,
- borderBottomColor: '#e5e7eb',
+ borderBottomColor: '#333333',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
- backgroundColor: '#ffffff',
+ backgroundColor: '#000000',
},
leftSlot: {
width: 72,
@@ -64,7 +68,7 @@ const styles = StyleSheet.create({
},
iconText: {
fontSize: 20,
- color: '#111827',
+ color: '#ffffff',
fontWeight: '700',
},
title: {
@@ -72,14 +76,14 @@ const styles = StyleSheet.create({
textAlign: 'center',
fontSize: 18,
fontWeight: '700',
- color: '#111827',
+ color: '#ffffff',
},
logoutButton: {
paddingVertical: 6,
paddingHorizontal: 8,
},
logoutText: {
- color: '#2563eb',
+ color: '#ffffff',
fontSize: 13,
fontWeight: '600',
},