mirror of
https://github.com/mudabbir-ahmad/MAD-Treasure-Hunt.git
synced 2026-10-08 03:00:20 +00:00
Implement business subgroup management: add create, update, and delete functionality for subgroups; enhance team settings with subgroup selection and cache claim distance adjustments
also fixed the naming scheme on the db from game-data.json to caches.json and refactored code to reflect that change. plus added player highlighting
This commit is contained in:
1 parent
9a9db74369
commit
70ed43f12b
7 files changed
+604
-234
No files matched your search
+100
-143
@@ -250,10 +250,6 @@ addRoute('POST', '/groups', async (req, res) => {
|
|||||||
});
|
});
|
||||||
writeJson('subgroup-memberships.json', memberships);
|
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
|
// Update the creating user
|
||||||
const users = readJson('users.json');
|
const users = readJson('users.json');
|
||||||
@@ -666,136 +662,131 @@ addRoute('POST', '/admin-waitlist/:id/approve', async (req, res, params) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ======================================================================
|
// ======================================================================
|
||||||
// GAME DATA CRUDL
|
// CACHES CRUDL (standalone — reads/writes caches.json)
|
||||||
// ======================================================================
|
// ======================================================================
|
||||||
|
|
||||||
addRoute('GET', '/game-data', (req, res) => {
|
// Helper: convert storage row to API response format
|
||||||
sendJson(res, 200, readJson('game-data.json'));
|
const toCacheApi = (cache) => ({
|
||||||
|
id: cache.CacheId,
|
||||||
|
name: cache.Title || '',
|
||||||
|
clue: cache.Clue || '',
|
||||||
|
coordinates: { latitude: cache.Latitude, longitude: cache.Longitude },
|
||||||
|
radius: cache.TriggerMeters,
|
||||||
|
groupId: cache.Gid,
|
||||||
|
subgroupId: cache.SGid,
|
||||||
|
Claims: cache.Claims || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
addRoute('GET', '/game-data/:gid', (req, res, params) => {
|
// GET /caches — list caches for a group (optional SGid filter)
|
||||||
const rows = readJson('game-data.json');
|
addRoute('GET', '/caches', (req, res, params, query) => {
|
||||||
const row = rows.find((r) => String(r.Gid) === params.gid);
|
let rows = readJson('caches.json');
|
||||||
if (!row) return sendJson(res, 404, { message: 'Game data not found' });
|
if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid);
|
||||||
sendJson(res, 200, row);
|
if (query.SGid) rows = rows.filter((r) => String(r.SGid) === query.SGid);
|
||||||
|
sendJson(res, 200, rows.map(toCacheApi));
|
||||||
});
|
});
|
||||||
|
|
||||||
// ======================================================================
|
// POST /caches — create a new cache
|
||||||
// CACHES (nested under game-data) CRUDL
|
addRoute('POST', '/caches', async (req, res) => {
|
||||||
// ======================================================================
|
const payload = await parseBody(req);
|
||||||
|
const rows = readJson('caches.json');
|
||||||
|
|
||||||
// Helper: convert storage cache to API response format
|
const cache = {
|
||||||
const toCacheApi = (cache, gid) => {
|
CacheId: nextId(rows, 'CacheId'),
|
||||||
// Migrate old single-claim format to Claims array
|
Gid: payload.Gid,
|
||||||
let claims = cache.Claims || [];
|
SGid: payload.SGid || null,
|
||||||
if (claims.length === 0 && cache.ClaimedByUid) {
|
Title: payload.Title || '',
|
||||||
claims = [{ Uid: cache.ClaimedByUid, Tid: cache.ClaimedByTid || null, ClaimedAt: null }];
|
Clue: payload.Clue || '',
|
||||||
}
|
Latitude: payload.Latitude || 0,
|
||||||
return {
|
Longitude: payload.Longitude || 0,
|
||||||
id: cache.CacheId,
|
TriggerMeters: payload.TriggerMeters || 20,
|
||||||
coordinates: { latitude: cache.Latitude, longitude: cache.Longitude },
|
Claims: [],
|
||||||
radius: cache.TriggerMeters,
|
|
||||||
clue: cache.Title,
|
|
||||||
groupId: Number(gid),
|
|
||||||
subgroupId: cache.SGid,
|
|
||||||
Claims: claims,
|
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: convert API payload to storage cache format
|
rows.push(cache);
|
||||||
const toCacheStorage = (data, cacheId) => ({
|
writeJson('caches.json', rows);
|
||||||
CacheId: cacheId,
|
sendJson(res, 201, toCacheApi(cache));
|
||||||
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),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /game-data/:gid/caches — list caches for a group
|
// POST /caches/reset — clear all claims for every cache in a game
|
||||||
addRoute('GET', '/game-data/:gid/caches', (req, res, params, query) => {
|
addRoute('POST', '/caches/reset', async (req, res, params, query) => {
|
||||||
const rows = readJson('game-data.json');
|
const rows = readJson('caches.json');
|
||||||
const entry = rows.find((r) => String(r.Gid) === params.gid);
|
const gid = query.Gid;
|
||||||
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
|
if (!gid) return sendJson(res, 400, { message: 'Gid query parameter required' });
|
||||||
|
|
||||||
let caches = (entry.GeoCaches || []).map((c) => toCacheApi(c, params.gid));
|
for (const cache of rows) {
|
||||||
if (query.SGid) caches = caches.filter((c) => String(c.subgroupId) === query.SGid);
|
if (String(cache.Gid) === gid) cache.Claims = [];
|
||||||
sendJson(res, 200, caches);
|
}
|
||||||
|
|
||||||
|
writeJson('caches.json', rows);
|
||||||
|
sendJson(res, 200, { message: 'Game reset successfully' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /game-data/:gid/caches/:cacheId — read single cache
|
// POST /caches/reset-player/:uid — remove all claims by a specific user
|
||||||
addRoute('GET', '/game-data/:gid/caches/:cacheId', (req, res, params) => {
|
addRoute('POST', '/caches/reset-player/:uid', async (req, res, params, query) => {
|
||||||
const rows = readJson('game-data.json');
|
const rows = readJson('caches.json');
|
||||||
const entry = rows.find((r) => String(r.Gid) === params.gid);
|
const gid = query.Gid;
|
||||||
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
|
if (!gid) return sendJson(res, 400, { message: 'Gid query parameter required' });
|
||||||
|
|
||||||
const cache = (entry.GeoCaches || []).find((c) => c.CacheId === params.cacheId);
|
const uid = Number(params.uid);
|
||||||
if (!cache) return sendJson(res, 404, { message: 'Cache not found' });
|
for (const cache of rows) {
|
||||||
sendJson(res, 200, toCacheApi(cache, params.gid));
|
if (String(cache.Gid) === gid && cache.Claims) {
|
||||||
|
cache.Claims = cache.Claims.filter((c) => c.Uid !== uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJson('caches.json', rows);
|
||||||
|
sendJson(res, 200, { message: 'Player progress reset' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /game-data/:gid/caches — create a new cache
|
// GET /caches/:id — read a single cache
|
||||||
addRoute('POST', '/game-data/:gid/caches', async (req, res, params) => {
|
addRoute('GET', '/caches/:id', (req, res, params) => {
|
||||||
|
const rows = readJson('caches.json');
|
||||||
|
const row = rows.find((r) => String(r.CacheId) === params.id);
|
||||||
|
if (!row) return sendJson(res, 404, { message: 'Cache not found' });
|
||||||
|
sendJson(res, 200, toCacheApi(row));
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /caches/:id — update a cache
|
||||||
|
addRoute('PUT', '/caches/:id', async (req, res, params) => {
|
||||||
const payload = await parseBody(req);
|
const payload = await parseBody(req);
|
||||||
const rows = readJson('game-data.json');
|
const rows = readJson('caches.json');
|
||||||
const entry = rows.find((r) => String(r.Gid) === params.gid);
|
const idx = rows.findIndex((r) => String(r.CacheId) === params.id);
|
||||||
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), Claims: [] };
|
|
||||||
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' });
|
if (idx === -1) return sendJson(res, 404, { message: 'Cache not found' });
|
||||||
|
|
||||||
const existing = entry.GeoCaches[idx];
|
const existing = rows[idx];
|
||||||
const updated = { ...existing, ...toCacheStorage(payload, params.cacheId) };
|
rows[idx] = {
|
||||||
// Preserve claims — property updates must not overwrite the Claims array
|
...existing,
|
||||||
updated.Claims = existing.Claims || [];
|
Title: payload.Title != null ? payload.Title : existing.Title,
|
||||||
entry.GeoCaches[idx] = updated;
|
Clue: payload.Clue != null ? payload.Clue : existing.Clue,
|
||||||
writeJson('game-data.json', rows);
|
Latitude: payload.Latitude != null ? payload.Latitude : existing.Latitude,
|
||||||
sendJson(res, 200, toCacheApi(updated, params.gid));
|
Longitude: payload.Longitude != null ? payload.Longitude : existing.Longitude,
|
||||||
|
TriggerMeters: payload.TriggerMeters != null ? payload.TriggerMeters : existing.TriggerMeters,
|
||||||
|
SGid: payload.SGid != null ? payload.SGid : existing.SGid,
|
||||||
|
// Preserve CacheId, Gid and Claims — updates must not overwrite these
|
||||||
|
CacheId: existing.CacheId,
|
||||||
|
Gid: existing.Gid,
|
||||||
|
Claims: existing.Claims || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
writeJson('caches.json', rows);
|
||||||
|
sendJson(res, 200, toCacheApi(rows[idx]));
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /game-data/:gid/caches/:cacheId — delete a cache
|
// DELETE /caches/:id — delete a cache
|
||||||
addRoute('DELETE', '/game-data/:gid/caches/:cacheId', (req, res, params) => {
|
addRoute('DELETE', '/caches/:id', (req, res, params) => {
|
||||||
const rows = readJson('game-data.json');
|
const rows = readJson('caches.json');
|
||||||
const entry = rows.find((r) => String(r.Gid) === params.gid);
|
const idx = rows.findIndex((r) => String(r.CacheId) === params.id);
|
||||||
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' });
|
if (idx === -1) return sendJson(res, 404, { message: 'Cache not found' });
|
||||||
|
rows.splice(idx, 1);
|
||||||
entry.GeoCaches.splice(idx, 1);
|
writeJson('caches.json', rows);
|
||||||
writeJson('game-data.json', rows);
|
|
||||||
sendJson(res, 204, null);
|
sendJson(res, 204, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /game-data/:gid/caches/:cacheId/claim — claim a cache (multi-team)
|
// POST /caches/:id/claim — claim a cache (team-aware: prevents same team from claiming again)
|
||||||
addRoute('POST', '/game-data/:gid/caches/:cacheId/claim', async (req, res, params) => {
|
addRoute('POST', '/caches/:id/claim', async (req, res, params) => {
|
||||||
const payload = await parseBody(req);
|
const payload = await parseBody(req);
|
||||||
const rows = readJson('game-data.json');
|
const rows = readJson('caches.json');
|
||||||
const entry = rows.find((r) => String(r.Gid) === params.gid);
|
const cache = rows.find((r) => String(r.CacheId) === params.id);
|
||||||
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
|
|
||||||
|
|
||||||
const cache = (entry.GeoCaches || []).find((c) => c.CacheId === params.cacheId);
|
|
||||||
if (!cache) return sendJson(res, 404, { message: 'Cache not found' });
|
if (!cache) return sendJson(res, 404, { message: 'Cache not found' });
|
||||||
|
|
||||||
if (!cache.Claims) cache.Claims = [];
|
if (!cache.Claims) cache.Claims = [];
|
||||||
@@ -814,42 +805,8 @@ addRoute('POST', '/game-data/:gid/caches/:cacheId/claim', async (req, res, param
|
|||||||
ClaimedAt: new Date().toISOString(),
|
ClaimedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
writeJson('game-data.json', rows);
|
writeJson('caches.json', rows);
|
||||||
sendJson(res, 200, toCacheApi(cache, params.gid));
|
sendJson(res, 200, toCacheApi(cache));
|
||||||
});
|
|
||||||
|
|
||||||
// POST /game-data/:gid/reset — reset all cache claims for a game
|
|
||||||
addRoute('POST', '/game-data/:gid/reset', async (req, res, params) => {
|
|
||||||
const rows = readJson('game-data.json');
|
|
||||||
const entry = rows.find((r) => String(r.Gid) === params.gid);
|
|
||||||
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
|
|
||||||
|
|
||||||
for (const cache of (entry.GeoCaches || [])) {
|
|
||||||
cache.Claims = [];
|
|
||||||
// Clean up legacy fields
|
|
||||||
delete cache.ClaimedByUid;
|
|
||||||
delete cache.ClaimedByTid;
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJson('game-data.json', rows);
|
|
||||||
sendJson(res, 200, { message: 'Game reset successfully' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /game-data/:gid/reset-player/:uid — remove all claims by a specific user
|
|
||||||
addRoute('POST', '/game-data/:gid/reset-player/:uid', async (req, res, params) => {
|
|
||||||
const rows = readJson('game-data.json');
|
|
||||||
const entry = rows.find((r) => String(r.Gid) === params.gid);
|
|
||||||
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
|
|
||||||
|
|
||||||
const uid = Number(params.uid);
|
|
||||||
for (const cache of (entry.GeoCaches || [])) {
|
|
||||||
if (cache.Claims) {
|
|
||||||
cache.Claims = cache.Claims.filter((c) => c.Uid !== uid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJson('game-data.json', rows);
|
|
||||||
sendJson(res, 200, { message: 'Player progress reset' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ======================================================================
|
// ======================================================================
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const GameSettingsScreen = () => {
|
|||||||
// Initialisation ------------
|
// Initialisation ------------
|
||||||
|
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
const {getLobby, updateGroup, getSubgroups, resetGame, getAdminWaitlist, approveAdmin, rejectAdmin, getUser} = useGameHook();
|
const {getLobby, updateGroup, getSubgroups, createSubgroup, updateSubgroup, deleteSubgroup, resetGame, getAdminWaitlist, approveAdmin, rejectAdmin, getUser} = useGameHook();
|
||||||
const isBusiness = Boolean(session.isBusiness);
|
const isBusiness = Boolean(session.isBusiness);
|
||||||
|
|
||||||
// State ----------------------
|
// State ----------------------
|
||||||
@@ -19,12 +19,16 @@ const GameSettingsScreen = () => {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [groupName, setGroupName] = useState('');
|
const [groupName, setGroupName] = useState('');
|
||||||
const [teamsEnabled, setTeamsEnabled] = useState(session.teamsEnabled);
|
const [teamsEnabled, setTeamsEnabled] = useState(session.teamsEnabled);
|
||||||
const [maxSubgroups, setMaxSubgroups] = useState('1');
|
|
||||||
const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
|
const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
|
||||||
const [adminJoinCode, setAdminJoinCode] = useState('');
|
const [adminJoinCode, setAdminJoinCode] = useState('');
|
||||||
const [memberJoinCode, setMemberJoinCode] = useState('');
|
const [memberJoinCode, setMemberJoinCode] = useState('');
|
||||||
const [waitlist, setWaitlist] = useState([]);
|
const [waitlist, setWaitlist] = useState([]);
|
||||||
const [waitlistNames, setWaitlistNames] = useState({});
|
const [waitlistNames, setWaitlistNames] = useState({});
|
||||||
|
// Business subgroup management
|
||||||
|
const [subgroupList, setSubgroupList] = useState([]);
|
||||||
|
const [newDeptName, setNewDeptName] = useState('');
|
||||||
|
const [editingSGid, setEditingSGid] = useState(null);
|
||||||
|
const [editDeptTrigger, setEditDeptTrigger] = useState('20');
|
||||||
|
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
|
|
||||||
@@ -40,6 +44,15 @@ const GameSettingsScreen = () => {
|
|||||||
setWaitlistNames(names);
|
setWaitlistNames(names);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSubgroups = async () => {
|
||||||
|
if (!session.currentGid) return;
|
||||||
|
const sgs = await getSubgroups(session.currentGid);
|
||||||
|
setSubgroupList(sgs || []);
|
||||||
|
// Set the default member join code from first non-admin subgroup
|
||||||
|
const memberSg = (sgs || []).find((sg) => !sg.IsAdminGroup);
|
||||||
|
if (memberSg) setMemberJoinCode(memberSg.JoinCode || '');
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!session.currentGid) {
|
if (!session.currentGid) {
|
||||||
@@ -53,13 +66,10 @@ const GameSettingsScreen = () => {
|
|||||||
setTeamsEnabled(serverTeams);
|
setTeamsEnabled(serverTeams);
|
||||||
// Keep session in sync so the toggle survives app reloads
|
// Keep session in sync so the toggle survives app reloads
|
||||||
setSessionTeamsEnabled(serverTeams);
|
setSessionTeamsEnabled(serverTeams);
|
||||||
setMaxSubgroups(String(group.MaxMemberSubgroups || 1));
|
|
||||||
setCacheTriggerMeters(String(group.CacheTriggerMeters || 20));
|
setCacheTriggerMeters(String(group.CacheTriggerMeters || 20));
|
||||||
setAdminJoinCode(group.AdminJoinCode || '');
|
setAdminJoinCode(group.AdminJoinCode || '');
|
||||||
}
|
}
|
||||||
const sgs = await getSubgroups(session.currentGid);
|
await loadSubgroups();
|
||||||
const memberSg = (sgs || []).find((sg) => !sg.IsAdminGroup);
|
|
||||||
if (memberSg) setMemberJoinCode(memberSg.JoinCode || '');
|
|
||||||
await loadWaitlist();
|
await loadWaitlist();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
@@ -72,7 +82,6 @@ const GameSettingsScreen = () => {
|
|||||||
const result = await updateGroup(session.currentGid, {
|
const result = await updateGroup(session.currentGid, {
|
||||||
GroupName: groupName.trim(),
|
GroupName: groupName.trim(),
|
||||||
TeamsEnabled: teamsEnabled,
|
TeamsEnabled: teamsEnabled,
|
||||||
MaxMemberSubgroups: parseInt(maxSubgroups) || 1,
|
|
||||||
CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20,
|
CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20,
|
||||||
});
|
});
|
||||||
// Persist the new toggle value so the session reflects it after a reload
|
// Persist the new toggle value so the session reflects it after a reload
|
||||||
@@ -81,14 +90,14 @@ const GameSettingsScreen = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleResetGame = () => {
|
const handleResetGame = () => {
|
||||||
Alert.alert('Reset Entire Game', 'This will clear ALL cache claims for every player. Are you sure?', [
|
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: 'Cancel', style: 'cancel'},
|
||||||
{
|
{
|
||||||
text: 'Reset',
|
text: 'Reset',
|
||||||
style: 'destructive',
|
style: 'destructive',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
await resetGame(session.currentGid);
|
await resetGame(session.currentGid);
|
||||||
Alert.alert('Done', 'All cache claims have been reset.');
|
Alert.alert('Done', 'All caches and player progress have been reset.');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -104,6 +113,41 @@ const GameSettingsScreen = () => {
|
|||||||
await loadWaitlist();
|
await loadWaitlist();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Business — create a new department (subgroup)
|
||||||
|
const handleCreateDepartment = async () => {
|
||||||
|
if (!newDeptName.trim() || !session.currentGid) return;
|
||||||
|
await createSubgroup({
|
||||||
|
SubGroupName: newDeptName.trim(),
|
||||||
|
Gid: session.currentGid,
|
||||||
|
IsAdminGroup: false,
|
||||||
|
CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20,
|
||||||
|
});
|
||||||
|
setNewDeptName('');
|
||||||
|
await loadSubgroups();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Business — save per-subgroup trigger distance
|
||||||
|
const handleSaveDeptTrigger = async (sgid) => {
|
||||||
|
await updateSubgroup(sgid, {CacheTriggerMeters: parseInt(editDeptTrigger) || 20});
|
||||||
|
setEditingSGid(null);
|
||||||
|
await loadSubgroups();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 loadSubgroups();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
// View -----------------------
|
// View -----------------------
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -122,6 +166,9 @@ const GameSettingsScreen = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter subgroups for the department list (non-admin only)
|
||||||
|
const departments = subgroupList.filter((sg) => !sg.IsAdminGroup);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
<ScrollView contentContainerStyle={styles.formContainer} showsVerticalScrollIndicator={false}>
|
<ScrollView contentContainerStyle={styles.formContainer} showsVerticalScrollIndicator={false}>
|
||||||
@@ -146,20 +193,6 @@ const GameSettingsScreen = () => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isBusiness && (
|
|
||||||
<>
|
|
||||||
<Text style={styles.label}>Max Member Subgroups</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={maxSubgroups}
|
|
||||||
onChangeText={setMaxSubgroups}
|
|
||||||
keyboardType="numeric"
|
|
||||||
placeholder="1"
|
|
||||||
placeholderTextColor="#9ca3af"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text style={styles.label}>Cache Claim Distance (metres)</Text>
|
<Text style={styles.label}>Cache Claim Distance (metres)</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
@@ -191,6 +224,83 @@ const GameSettingsScreen = () => {
|
|||||||
</ButtonTray>
|
</ButtonTray>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Business: Departments / Subgroups management */}
|
||||||
|
{isBusiness && (
|
||||||
|
<View style={styles.deptSection}>
|
||||||
|
<Text style={styles.sectionTitle}>Departments</Text>
|
||||||
|
{departments.map((sg) => (
|
||||||
|
<Card key={sg.SGid}>
|
||||||
|
<View style={styles.deptRow}>
|
||||||
|
<View style={styles.deptInfo}>
|
||||||
|
<Text style={styles.deptName}>{sg.SubGroupName}</Text>
|
||||||
|
<Text style={styles.deptCode}>Join Code: {sg.JoinCode || '—'}</Text>
|
||||||
|
<Text style={styles.deptTrigger}>Claim Distance: {sg.CacheTriggerMeters || 20}m</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.deptActions}>
|
||||||
|
<Button
|
||||||
|
label="Edit"
|
||||||
|
onClick={() => { setEditingSGid(sg.SGid); setEditDeptTrigger(String(sg.CacheTriggerMeters || 20)); }}
|
||||||
|
styleButton={styles.deptEditButton}
|
||||||
|
styleLabel={styles.deptBtnLabel}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Delete"
|
||||||
|
onClick={() => handleDeleteDepartment(sg)}
|
||||||
|
styleButton={styles.deptDeleteButton}
|
||||||
|
styleLabel={styles.deptBtnLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{editingSGid === sg.SGid && (
|
||||||
|
<View style={styles.deptEditRow}>
|
||||||
|
<Text style={styles.label}>Claim Distance (metres)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={editDeptTrigger}
|
||||||
|
onChangeText={setEditDeptTrigger}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="20"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
/>
|
||||||
|
<ButtonTray>
|
||||||
|
<Button
|
||||||
|
label="Save"
|
||||||
|
onClick={() => handleSaveDeptTrigger(sg.SGid)}
|
||||||
|
styleButton={styles.deptSaveButton}
|
||||||
|
styleLabel={styles.deptBtnLabel}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Cancel"
|
||||||
|
onClick={() => setEditingSGid(null)}
|
||||||
|
styleButton={styles.deptCancelButton}
|
||||||
|
styleLabel={styles.deptBtnLabel}
|
||||||
|
/>
|
||||||
|
</ButtonTray>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
{departments.length === 0 && (
|
||||||
|
<Text style={styles.emptyText}>No departments yet.</Text>
|
||||||
|
)}
|
||||||
|
<View style={styles.createDeptRow}>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, {flex: 1}]}
|
||||||
|
value={newDeptName}
|
||||||
|
onChangeText={setNewDeptName}
|
||||||
|
placeholder="New department name"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Add"
|
||||||
|
onClick={handleCreateDepartment}
|
||||||
|
styleButton={styles.createDeptButton}
|
||||||
|
styleLabel={styles.createDeptLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Admin Waitlist */}
|
{/* Admin Waitlist */}
|
||||||
{waitlist.length > 0 && (
|
{waitlist.length > 0 && (
|
||||||
<View style={styles.waitlistSection}>
|
<View style={styles.waitlistSection}>
|
||||||
@@ -276,6 +386,24 @@ const styles = StyleSheet.create({
|
|||||||
saveWrap: {marginTop: 24},
|
saveWrap: {marginTop: 24},
|
||||||
saveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
|
saveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
|
||||||
saveLabel: {color: '#ffffff', fontWeight: '600'},
|
saveLabel: {color: '#ffffff', fontWeight: '600'},
|
||||||
|
// Department / subgroup styles
|
||||||
|
deptSection: {marginTop: 30},
|
||||||
|
deptRow: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start'},
|
||||||
|
deptInfo: {flex: 1},
|
||||||
|
deptName: {fontSize: 15, fontWeight: '700', color: '#1f2937'},
|
||||||
|
deptCode: {fontSize: 13, fontWeight: '600', color: '#2563eb', marginTop: 2, letterSpacing: 1},
|
||||||
|
deptTrigger: {fontSize: 12, color: '#6b7280', marginTop: 2},
|
||||||
|
deptActions: {flexDirection: 'row', gap: 6},
|
||||||
|
deptEditButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', minHeight: 36, flex: 0, paddingHorizontal: 10},
|
||||||
|
deptDeleteButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 0, paddingHorizontal: 10},
|
||||||
|
deptBtnLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
|
||||||
|
deptEditRow: {marginTop: 10, paddingTop: 10, borderTopWidth: 1, borderTopColor: '#e5e7eb'},
|
||||||
|
deptSaveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
|
||||||
|
deptCancelButton: {backgroundColor: '#6b7280', borderColor: '#6b7280'},
|
||||||
|
createDeptRow: {flexDirection: 'row', gap: 10, marginTop: 12, alignItems: 'center'},
|
||||||
|
createDeptButton: {backgroundColor: '#16a34a', borderColor: '#16a34a', flex: 0, paddingHorizontal: 16},
|
||||||
|
createDeptLabel: {color: '#ffffff', fontWeight: '600'},
|
||||||
|
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 10, fontSize: 14},
|
||||||
// Admin waitlist styles
|
// Admin waitlist styles
|
||||||
waitlistSection: {marginTop: 30},
|
waitlistSection: {marginTop: 30},
|
||||||
waitlistRow: {
|
waitlistRow: {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, {useCallback, useEffect, useState} from 'react';
|
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 Screen from '../layout/Screen';
|
||||||
import Card from '../UI/Card';
|
import Card from '../UI/Card';
|
||||||
|
import {Button} from '../UI/Button';
|
||||||
import useGameHook from '../../hooks/useGameHook';
|
import useGameHook from '../../hooks/useGameHook';
|
||||||
import {getSession} from '../../hooks/SessionStore';
|
import {getSession} from '../../hooks/SessionStore';
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ const LeaderboardScreen = () => {
|
|||||||
|
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
const isAdmin = session.isAcceptedAdmin;
|
const isAdmin = session.isAcceptedAdmin;
|
||||||
const {getLobby, getTeams, getTeamMembers, getGroupMembers, getCaches, getUser} = useGameHook();
|
const {getLobby, getTeams, getTeamMembers, getGroupMembers, getCaches, getUser, deleteTeam, resetPlayerProgress} = useGameHook();
|
||||||
|
|
||||||
// State ----------------------
|
// State ----------------------
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ const LeaderboardScreen = () => {
|
|||||||
const [playerRankings, setPlayerRankings] = useState([]);
|
const [playerRankings, setPlayerRankings] = useState([]);
|
||||||
const [myTeamRanking, setMyTeamRanking] = useState([]);
|
const [myTeamRanking, setMyTeamRanking] = useState([]);
|
||||||
const [expandedTeam, setExpandedTeam] = useState(null);
|
const [expandedTeam, setExpandedTeam] = useState(null);
|
||||||
|
const [totalCacheCount, setTotalCacheCount] = useState(0);
|
||||||
|
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
|
|
||||||
@@ -32,6 +34,8 @@ const LeaderboardScreen = () => {
|
|||||||
setTeamsEnabled(isTeams);
|
setTeamsEnabled(isTeams);
|
||||||
|
|
||||||
const caches = await getCaches(session.currentGid, null);
|
const caches = await getCaches(session.currentGid, null);
|
||||||
|
const total = (caches || []).length;
|
||||||
|
setTotalCacheCount(total);
|
||||||
|
|
||||||
if (isTeams) {
|
if (isTeams) {
|
||||||
// Build team rankings
|
// Build team rankings
|
||||||
@@ -98,6 +102,37 @@ const LeaderboardScreen = () => {
|
|||||||
|
|
||||||
useEffect(() => { loadData(); }, [loadData]);
|
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);
|
||||||
|
Alert.alert('Done', `Progress for ${player.name} has been reset.`);
|
||||||
|
await loadData();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
// View -----------------------
|
// View -----------------------
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -116,34 +151,94 @@ const LeaderboardScreen = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: render an expandable team card
|
// Helper: render an expandable team card (admin version with delete + reset)
|
||||||
|
const renderAdminTeamCard = (team, index) => (
|
||||||
|
<Card key={team.tid}>
|
||||||
|
<Pressable onPress={() => setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}>
|
||||||
|
<View style={styles.rankRow}>
|
||||||
|
<Text style={styles.rank}>#{index + 1}</Text>
|
||||||
|
<Text style={styles.rankName}>{team.name} ({team.code})</Text>
|
||||||
|
<Text style={styles.score}>{team.totalCaches}/{totalCacheCount}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
{expandedTeam === team.tid && (
|
||||||
|
<View>
|
||||||
|
{team.members.map((m) => (
|
||||||
|
// Highlight the current user's row so they can identify themselves
|
||||||
|
<View key={m.uid} style={[styles.subRow, m.uid === session.currentUid && styles.selfSubRow]}>
|
||||||
|
<Text style={[styles.subName, {flex: 1}]}>
|
||||||
|
{m.name}{m.isLeader ? ' (Team Leader)' : ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.subScore}>{m.caches}/{totalCacheCount}</Text>
|
||||||
|
<Pressable
|
||||||
|
style={styles.resetMiniButton}
|
||||||
|
onPress={() => handleResetPlayer(m)}
|
||||||
|
>
|
||||||
|
<Text style={styles.resetMiniLabel}>Reset</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<View style={styles.deleteTeamWrap}>
|
||||||
|
<Button
|
||||||
|
label="Delete Team"
|
||||||
|
onClick={() => handleDeleteTeam(team)}
|
||||||
|
styleButton={styles.deleteTeamButton}
|
||||||
|
styleLabel={styles.deleteTeamLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper: render an expandable team card (player version)
|
||||||
const renderTeamCard = (team, index) => (
|
const renderTeamCard = (team, index) => (
|
||||||
<Pressable key={team.tid} onPress={() => setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}>
|
<Pressable key={team.tid} onPress={() => setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}>
|
||||||
<Card>
|
<Card>
|
||||||
<View style={styles.rankRow}>
|
<View style={styles.rankRow}>
|
||||||
<Text style={styles.rank}>#{index + 1}</Text>
|
<Text style={styles.rank}>#{index + 1}</Text>
|
||||||
<Text style={styles.rankName}>{team.name}</Text>
|
<Text style={styles.rankName}>{team.name} ({team.code})</Text>
|
||||||
<Text style={styles.score}>{team.totalCaches} cache(s)</Text>
|
<Text style={styles.score}>{team.totalCaches}/{totalCacheCount}</Text>
|
||||||
</View>
|
</View>
|
||||||
{expandedTeam === team.tid && team.members.map((m) => (
|
{expandedTeam === team.tid && team.members.map((m) => (
|
||||||
<View key={m.uid} style={styles.subRow}>
|
// Highlight the current user's row so they can identify themselves
|
||||||
|
<View key={m.uid} style={[styles.subRow, m.uid === session.currentUid && styles.selfSubRow]}>
|
||||||
<Text style={styles.subName}>
|
<Text style={styles.subName}>
|
||||||
{m.name}{m.isLeader ? ' (Team Leader)' : ''}
|
{m.name}{m.isLeader ? ' (Team Leader)' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.subScore}>{m.caches}</Text>
|
<Text style={styles.subScore}>{m.caches}/{totalCacheCount}</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</Card>
|
</Card>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper: render a simple player card
|
// Helper: render an admin player card (with reset button)
|
||||||
const renderPlayerCard = (player, index) => (
|
const renderAdminPlayerCard = (player, index) => (
|
||||||
<Card key={player.uid}>
|
// Highlight the current user's card so they can identify themselves
|
||||||
|
<Card key={player.uid} style={player.uid === session.currentUid ? styles.selfCard : undefined}>
|
||||||
<View style={styles.rankRow}>
|
<View style={styles.rankRow}>
|
||||||
<Text style={styles.rank}>#{index + 1}</Text>
|
<Text style={styles.rank}>#{index + 1}</Text>
|
||||||
<Text style={styles.rankName}>{player.name}</Text>
|
<Text style={styles.rankName}>{player.name}</Text>
|
||||||
<Text style={styles.score}>{player.caches} cache(s)</Text>
|
<Text style={styles.score}>{player.caches}/{totalCacheCount}</Text>
|
||||||
|
<Pressable
|
||||||
|
style={styles.resetMiniButton}
|
||||||
|
onPress={() => handleResetPlayer(player)}
|
||||||
|
>
|
||||||
|
<Text style={styles.resetMiniLabel}>Reset</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper: render a simple player card
|
||||||
|
const renderPlayerCard = (player, index) => (
|
||||||
|
// Highlight the current user's card so they can identify themselves
|
||||||
|
<Card key={player.uid} style={player.uid === session.currentUid ? styles.selfCard : undefined}>
|
||||||
|
<View style={styles.rankRow}>
|
||||||
|
<Text style={styles.rank}>#{index + 1}</Text>
|
||||||
|
<Text style={styles.rankName}>{player.name}</Text>
|
||||||
|
<Text style={styles.score}>{player.caches}/{totalCacheCount}</Text>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -155,7 +250,7 @@ const LeaderboardScreen = () => {
|
|||||||
return (
|
return (
|
||||||
<Screen style={styles.container}>
|
<Screen style={styles.container}>
|
||||||
<ScrollView style={styles.listSection}>
|
<ScrollView style={styles.listSection}>
|
||||||
{teamRankings.map((team, index) => renderTeamCard(team, index))}
|
{teamRankings.map((team, index) => renderAdminTeamCard(team, index))}
|
||||||
{teamRankings.length === 0 && (
|
{teamRankings.length === 0 && (
|
||||||
<Text style={styles.emptyText}>No teams yet.</Text>
|
<Text style={styles.emptyText}>No teams yet.</Text>
|
||||||
)}
|
)}
|
||||||
@@ -164,11 +259,11 @@ const LeaderboardScreen = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Teams disabled: show player list
|
// Teams disabled: show player list with reset buttons
|
||||||
return (
|
return (
|
||||||
<Screen style={styles.container}>
|
<Screen style={styles.container}>
|
||||||
<ScrollView style={styles.listSection}>
|
<ScrollView style={styles.listSection}>
|
||||||
{playerRankings.map((player, index) => renderPlayerCard(player, index))}
|
{playerRankings.map((player, index) => renderAdminPlayerCard(player, index))}
|
||||||
{playerRankings.length === 0 && (
|
{playerRankings.length === 0 && (
|
||||||
<Text style={styles.emptyText}>No players yet.</Text>
|
<Text style={styles.emptyText}>No players yet.</Text>
|
||||||
)}
|
)}
|
||||||
@@ -208,13 +303,14 @@ const LeaderboardScreen = () => {
|
|||||||
) : (
|
) : (
|
||||||
<ScrollView style={styles.listSection}>
|
<ScrollView style={styles.listSection}>
|
||||||
{myTeamRanking.map((m, index) => (
|
{myTeamRanking.map((m, index) => (
|
||||||
<Card key={m.uid}>
|
// Highlight the current user's card so they can identify themselves
|
||||||
|
<Card key={m.uid} style={m.uid === session.currentUid ? styles.selfCard : undefined}>
|
||||||
<View style={styles.rankRow}>
|
<View style={styles.rankRow}>
|
||||||
<Text style={styles.rank}>#{index + 1}</Text>
|
<Text style={styles.rank}>#{index + 1}</Text>
|
||||||
<Text style={styles.rankName}>
|
<Text style={styles.rankName}>
|
||||||
{m.name}{m.isLeader ? ' (Team Leader)' : ''}
|
{m.name}{m.isLeader ? ' (Team Leader)' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.score}>{m.caches} cache(s)</Text>
|
<Text style={styles.score}>{m.caches}/{totalCacheCount}</Text>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -271,15 +367,31 @@ const styles = StyleSheet.create({
|
|||||||
score: {fontSize: 14, fontWeight: '600', color: '#16a34a'},
|
score: {fontSize: 14, fontWeight: '600', color: '#16a34a'},
|
||||||
subRow: {
|
subRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
alignItems: 'center',
|
||||||
paddingLeft: 42,
|
paddingLeft: 42,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
borderTopColor: '#f3f4f6',
|
borderTopColor: '#f3f4f6',
|
||||||
},
|
},
|
||||||
subName: {fontSize: 13, color: '#4b5563'},
|
subName: {fontSize: 13, color: '#4b5563'},
|
||||||
subScore: {fontSize: 13, fontWeight: '600', color: '#16a34a'},
|
subScore: {fontSize: 13, fontWeight: '600', color: '#16a34a', marginRight: 8},
|
||||||
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
|
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
|
||||||
|
// Admin delete team button
|
||||||
|
deleteTeamWrap: {marginTop: 8, paddingLeft: 42},
|
||||||
|
deleteTeamButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36},
|
||||||
|
deleteTeamLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
|
||||||
|
// Admin reset mini button (inline)
|
||||||
|
resetMiniButton: {
|
||||||
|
backgroundColor: '#f59e0b',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
marginLeft: 6,
|
||||||
|
},
|
||||||
|
resetMiniLabel: {color: '#ffffff', fontWeight: '600', fontSize: 12},
|
||||||
|
// Current user self-highlight (light green card / sub-row)
|
||||||
|
selfCard: {backgroundColor: '#dcfce7', borderColor: '#86efac'},
|
||||||
|
selfSubRow: {backgroundColor: '#dcfce7'},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default LeaderboardScreen;
|
export default LeaderboardScreen;
|
||||||
@@ -2,6 +2,7 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
|||||||
import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
|
import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
|
||||||
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
|
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
|
||||||
import * as Location from 'expo-location';
|
import * as Location from 'expo-location';
|
||||||
|
import {useFocusEffect} from '@react-navigation/native';
|
||||||
import Screen from '../layout/Screen';
|
import Screen from '../layout/Screen';
|
||||||
import {Button, ButtonTray} from '../UI/Button';
|
import {Button, ButtonTray} from '../UI/Button';
|
||||||
import CacheCardItem from '../gameplay/CacheCardItem';
|
import CacheCardItem from '../gameplay/CacheCardItem';
|
||||||
@@ -19,7 +20,7 @@ const MapScreen = ({navigation}) => {
|
|||||||
// Initialisation ------------
|
// Initialisation ------------
|
||||||
|
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
const {getCaches, claimCache, upsertCache, deleteCache, joinPrivateGame, createPrivateGame, getLobby, getUser} = useGameHook();
|
const {getCaches, claimCache, upsertCache, deleteCache, joinPrivateGame, createPrivateGame, getLobby, getUser, getSubgroups} = useGameHook();
|
||||||
|
|
||||||
// State ----------------------
|
// State ----------------------
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ const MapScreen = ({navigation}) => {
|
|||||||
const [groupInfo, setGroupInfo] = useState(null);
|
const [groupInfo, setGroupInfo] = useState(null);
|
||||||
const [selectedCacheId, setSelectedCacheIdState] = useState(session.selectedCacheId);
|
const [selectedCacheId, setSelectedCacheIdState] = useState(session.selectedCacheId);
|
||||||
const [claimedPopupVisible, setClaimedPopupVisible] = useState(false);
|
const [claimedPopupVisible, setClaimedPopupVisible] = useState(false);
|
||||||
|
const [subgroups, setSubgroups] = useState([]);
|
||||||
|
|
||||||
// Admin cache creation / editing state
|
// Admin cache creation / editing state
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
@@ -75,19 +77,33 @@ const MapScreen = ({navigation}) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session.currentGid) return;
|
if (!session.currentGid) return;
|
||||||
getLobby(session.currentGid).then(setGroupInfo);
|
getLobby(session.currentGid).then(setGroupInfo);
|
||||||
|
getSubgroups(session.currentGid).then(setSubgroups);
|
||||||
}, [inGame]);
|
}, [inGame]);
|
||||||
|
|
||||||
|
// Default member subgroup SGid for cache creation (first non-admin subgroup)
|
||||||
|
const defaultMemberSGid = subgroups.find((sg) => !sg.IsAdminGroup)?.SGid || null;
|
||||||
|
|
||||||
|
// Always call getSession() fresh inside the callback so we never read a
|
||||||
|
// stale currentGid from the closure — matches the server's /caches endpoint
|
||||||
const loadCaches = useCallback(async () => {
|
const loadCaches = useCallback(async () => {
|
||||||
if (!session.currentGid) {
|
const currentGid = getSession().currentGid;
|
||||||
|
if (!currentGid) {
|
||||||
setCacheRecords([]);
|
setCacheRecords([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rows = await getCaches(session.currentGid, null);
|
const rows = await getCaches(currentGid, null);
|
||||||
setCacheRecords(rows || []);
|
setCacheRecords(rows || []);
|
||||||
}, [inGame, isAdmin]);
|
}, [getCaches]);
|
||||||
|
|
||||||
|
// Run once on mount
|
||||||
useEffect(() => { loadCaches(); }, [loadCaches]);
|
useEffect(() => { loadCaches(); }, [loadCaches]);
|
||||||
|
|
||||||
|
// Re-fetch caches every time this screen gains focus so the list is always
|
||||||
|
// up-to-date after navigating away and back (e.g. Settings → Map)
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => { loadCaches(); }, [loadCaches])
|
||||||
|
);
|
||||||
|
|
||||||
// Auto-select the first cache for the player when caches load and none is selected
|
// Auto-select the first cache for the player when caches load and none is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPlayer || cacheRecords.length === 0) return;
|
if (!isPlayer || cacheRecords.length === 0) return;
|
||||||
@@ -222,7 +238,7 @@ const MapScreen = ({navigation}) => {
|
|||||||
latitude: newCoord.latitude,
|
latitude: newCoord.latitude,
|
||||||
longitude: newCoord.longitude,
|
longitude: newCoord.longitude,
|
||||||
clue: newClue.trim(),
|
clue: newClue.trim(),
|
||||||
subgroupId: 1,
|
subgroupId: defaultMemberSGid,
|
||||||
};
|
};
|
||||||
if (editingCacheId) payload.cacheId = editingCacheId;
|
if (editingCacheId) payload.cacheId = editingCacheId;
|
||||||
await upsertCache(payload);
|
await upsertCache(payload);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const PlayersScreen = () => {
|
|||||||
// Initialisation ------------
|
// Initialisation ------------
|
||||||
|
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
const {getTeams, getTeamMembers, getGroupMembers, removeMember, resetPlayerProgress, getUser} = useGameHook();
|
const {getTeams, getTeamMembers, getGroupMembers, removeMember, resetPlayerProgress, getUser, getAdminWaitlist} = useGameHook();
|
||||||
|
|
||||||
// State ----------------------
|
// State ----------------------
|
||||||
|
|
||||||
@@ -22,9 +22,11 @@ const PlayersScreen = () => {
|
|||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!session.currentGid) { setLoading(false); return; }
|
if (!session.currentGid) { setLoading(false); return; }
|
||||||
|
|
||||||
// Get all non-admin players
|
// Get ALL members (including admins)
|
||||||
const allMembers = await getGroupMembers(session.currentGid);
|
const allMembers = await getGroupMembers(session.currentGid);
|
||||||
const nonAdminMembers = (allMembers || []).filter((m) => !m.IsAcceptedAdmin);
|
|
||||||
|
// Get admin waitlist entries
|
||||||
|
const waitlist = await getAdminWaitlist(session.currentGid);
|
||||||
|
|
||||||
// Build team lookup: { Uid -> { teamCode, teamName, isLeader } }
|
// Build team lookup: { Uid -> { teamCode, teamName, isLeader } }
|
||||||
const allTeams = await getTeams(session.currentGid);
|
const allTeams = await getTeams(session.currentGid);
|
||||||
@@ -40,18 +42,40 @@ const PlayersScreen = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build player list
|
// Build player list — include admins with tags
|
||||||
const playerList = [];
|
const playerList = [];
|
||||||
for (const m of nonAdminMembers) {
|
for (const m of (allMembers || [])) {
|
||||||
|
// Skip the current admin viewing the list
|
||||||
|
if (m.Uid === session.currentUid) continue;
|
||||||
const user = await getUser(m.Uid);
|
const user = await getUser(m.Uid);
|
||||||
if (user) {
|
if (user) {
|
||||||
|
let adminTag = null;
|
||||||
|
if (m.IsAcceptedAdmin) adminTag = '[ADMIN]';
|
||||||
playerList.push({
|
playerList.push({
|
||||||
...user,
|
...user,
|
||||||
membershipId: m.id,
|
membershipId: m.id,
|
||||||
team: teamInfoByUid[m.Uid] || null,
|
team: teamInfoByUid[m.Uid] || null,
|
||||||
|
adminTag,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include waitlisted users who are not yet group members
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setPlayers(playerList);
|
setPlayers(playerList);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [session.currentGid]);
|
}, [session.currentGid]);
|
||||||
@@ -106,7 +130,14 @@ const PlayersScreen = () => {
|
|||||||
{players.map((player) => (
|
{players.map((player) => (
|
||||||
<Card key={player.Uid}>
|
<Card key={player.Uid}>
|
||||||
<View style={styles.playerRow}>
|
<View style={styles.playerRow}>
|
||||||
<Text style={styles.playerName} numberOfLines={1}>{player.username}</Text>
|
<Text style={styles.playerName} numberOfLines={1}>
|
||||||
|
{player.username}
|
||||||
|
{player.adminTag && (
|
||||||
|
<Text style={player.adminTag === '[ADMIN]' ? styles.adminBadge : styles.waitlistBadge}>
|
||||||
|
{' '}{player.adminTag}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
<View style={styles.playerMeta}>
|
<View style={styles.playerMeta}>
|
||||||
{player.team?.isLeader && (
|
{player.team?.isLeader && (
|
||||||
<Text style={styles.leaderBadge}>(Team Leader)</Text>
|
<Text style={styles.leaderBadge}>(Team Leader)</Text>
|
||||||
@@ -117,18 +148,22 @@ const PlayersScreen = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.actionRow}>
|
<View style={styles.actionRow}>
|
||||||
<Button
|
{!player.adminTag && (
|
||||||
label="Reset Progress"
|
<Button
|
||||||
onClick={() => handleResetPlayerProgress(player)}
|
label="Reset Progress"
|
||||||
styleButton={styles.resetButton}
|
onClick={() => handleResetPlayerProgress(player)}
|
||||||
styleLabel={styles.actionLabel}
|
styleButton={styles.resetButton}
|
||||||
/>
|
styleLabel={styles.actionLabel}
|
||||||
<Button
|
/>
|
||||||
label="Kick"
|
)}
|
||||||
onClick={() => handleRemovePlayer(player.membershipId)}
|
{player.membershipId && (
|
||||||
styleButton={styles.removeButton}
|
<Button
|
||||||
styleLabel={styles.actionLabel}
|
label="Kick"
|
||||||
/>
|
onClick={() => handleRemovePlayer(player.membershipId)}
|
||||||
|
styleButton={styles.removeButton}
|
||||||
|
styleLabel={styles.actionLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -164,6 +199,8 @@ const styles = StyleSheet.create({
|
|||||||
playerMeta: {flexDirection: 'row', alignItems: 'center', gap: 6},
|
playerMeta: {flexDirection: 'row', alignItems: 'center', gap: 6},
|
||||||
leaderBadge: {fontSize: 11, color: '#2563eb', fontWeight: '700'},
|
leaderBadge: {fontSize: 11, color: '#2563eb', fontWeight: '700'},
|
||||||
teamCodeBadge: {fontSize: 12, fontWeight: '700', color: '#16a34a', letterSpacing: 1},
|
teamCodeBadge: {fontSize: 12, fontWeight: '700', color: '#16a34a', letterSpacing: 1},
|
||||||
|
adminBadge: {fontSize: 12, fontWeight: '700', color: '#dc2626'},
|
||||||
|
waitlistBadge: {fontSize: 11, fontWeight: '600', color: '#f59e0b'},
|
||||||
actionRow: {flexDirection: 'row', gap: 8},
|
actionRow: {flexDirection: 'row', gap: 8},
|
||||||
resetButton: {backgroundColor: '#f59e0b', borderColor: '#f59e0b', minHeight: 36, flex: 1, paddingHorizontal: 10},
|
resetButton: {backgroundColor: '#f59e0b', borderColor: '#f59e0b', minHeight: 36, flex: 1, paddingHorizontal: 10},
|
||||||
removeButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 1, paddingHorizontal: 10},
|
removeButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 1, paddingHorizontal: 10},
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const TeamScreen = () => {
|
|||||||
|
|
||||||
const session = getSession();
|
const session = getSession();
|
||||||
const isAdmin = session.isAcceptedAdmin;
|
const isAdmin = session.isAcceptedAdmin;
|
||||||
const {getTeam, createTeam, joinTeamByCode, getTeamMembers, leaveTeam, getUser, updateUser, getAdminWaitlist} = useGameHook();
|
const {getTeam, createTeam, joinTeamByCode, getTeamMembers, leaveTeam, getUser, updateUser, updateTeam, getAdminWaitlist} = useGameHook();
|
||||||
|
|
||||||
// State ----------------------
|
// State ----------------------
|
||||||
|
|
||||||
@@ -22,6 +22,8 @@ const TeamScreen = () => {
|
|||||||
const [memberNames, setMemberNames] = useState({});
|
const [memberNames, setMemberNames] = useState({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [onAdminWaitlist, setOnAdminWaitlist] = useState(false);
|
const [onAdminWaitlist, setOnAdminWaitlist] = useState(false);
|
||||||
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
const [teamNameDraft, setTeamNameDraft] = useState('');
|
||||||
|
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
|
|
||||||
@@ -74,7 +76,7 @@ const TeamScreen = () => {
|
|||||||
|
|
||||||
const handleCreateTeam = async () => {
|
const handleCreateTeam = async () => {
|
||||||
if (!session.currentGid) return;
|
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;
|
if (!result) return;
|
||||||
const membership = await joinTeamByCode({Tid: result.Tid, Uid: session.currentUid});
|
const membership = await joinTeamByCode({Tid: result.Tid, Uid: session.currentUid});
|
||||||
if (membership) {
|
if (membership) {
|
||||||
@@ -100,6 +102,16 @@ const TeamScreen = () => {
|
|||||||
await loadTeamData(activeTid);
|
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
|
// Check if current user is the team leader
|
||||||
const myMembership = members.find((m) => m.Uid === session.currentUid);
|
const myMembership = members.find((m) => m.Uid === session.currentUid);
|
||||||
const isCurrentUserLeader = Boolean(myMembership?.IsLeader);
|
const isCurrentUserLeader = Boolean(myMembership?.IsLeader);
|
||||||
@@ -166,9 +178,51 @@ const TeamScreen = () => {
|
|||||||
<Text style={styles.teamCodeLabel}>Team Code:</Text>
|
<Text style={styles.teamCodeLabel}>Team Code:</Text>
|
||||||
<Text style={styles.teamCodeValue}>{team.JoinCode}</Text>
|
<Text style={styles.teamCodeValue}>{team.JoinCode}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Team name display / edit */}
|
||||||
|
<View style={styles.teamNameSection}>
|
||||||
|
{editingName ? (
|
||||||
|
<View style={styles.renameRow}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.renameInput}
|
||||||
|
value={teamNameDraft}
|
||||||
|
onChangeText={setTeamNameDraft}
|
||||||
|
placeholder="Team name"
|
||||||
|
placeholderTextColor="#9ca3af"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Save"
|
||||||
|
onClick={handleRenameTeam}
|
||||||
|
styleButton={styles.renameSaveButton}
|
||||||
|
styleLabel={styles.renameSaveLabel}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Cancel"
|
||||||
|
onClick={() => setEditingName(false)}
|
||||||
|
styleButton={styles.renameCancelButton}
|
||||||
|
styleLabel={styles.renameCancelLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.renameRow}>
|
||||||
|
<Text style={styles.teamNameText}>{team.TeamName || 'Unnamed Team'}</Text>
|
||||||
|
{isCurrentUserLeader && (
|
||||||
|
<Button
|
||||||
|
label="Rename"
|
||||||
|
onClick={() => { setTeamNameDraft(team.TeamName || ''); setEditingName(true); }}
|
||||||
|
styleButton={styles.renameButton}
|
||||||
|
styleLabel={styles.renameLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<ScrollView style={styles.memberList}>
|
<ScrollView style={styles.memberList}>
|
||||||
{members.map((m) => (
|
{members.map((m) => (
|
||||||
<Card key={m.id}>
|
// Highlight the current user's card so they can identify themselves
|
||||||
|
<Card key={m.id} style={m.Uid === session.currentUid ? styles.selfCard : undefined}>
|
||||||
<View style={styles.memberRow}>
|
<View style={styles.memberRow}>
|
||||||
<View style={styles.memberInfo}>
|
<View style={styles.memberInfo}>
|
||||||
<Text style={styles.memberName}>
|
<Text style={styles.memberName}>
|
||||||
@@ -190,15 +244,16 @@ const TeamScreen = () => {
|
|||||||
{members.length === 0 && (
|
{members.length === 0 && (
|
||||||
<Text style={styles.emptyText}>No members yet.</Text>
|
<Text style={styles.emptyText}>No members yet.</Text>
|
||||||
)}
|
)}
|
||||||
|
{/* Leave button lives inside the scroll so it is never obscured by the bottom nav bar */}
|
||||||
|
<View style={styles.leaveWrap}>
|
||||||
|
<Button
|
||||||
|
label="Leave Team"
|
||||||
|
onClick={handleLeaveTeam}
|
||||||
|
styleButton={styles.leaveButton}
|
||||||
|
styleLabel={styles.leaveLabel}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<View style={styles.leaveWrap}>
|
|
||||||
<Button
|
|
||||||
label="Leave Team"
|
|
||||||
onClick={handleLeaveTeam}
|
|
||||||
styleButton={styles.leaveButton}
|
|
||||||
styleLabel={styles.leaveLabel}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -240,6 +295,27 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
teamCodeLabel: {color: '#d1d5db', fontSize: 15, fontWeight: '600'},
|
teamCodeLabel: {color: '#d1d5db', fontSize: 15, fontWeight: '600'},
|
||||||
teamCodeValue: {color: '#ffffff', fontSize: 17, fontWeight: '700', letterSpacing: 2},
|
teamCodeValue: {color: '#ffffff', fontSize: 17, fontWeight: '700', letterSpacing: 2},
|
||||||
|
// Team name / rename section
|
||||||
|
teamNameSection: {paddingHorizontal: 15, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#e5e7eb'},
|
||||||
|
teamNameText: {fontSize: 18, fontWeight: '700', color: '#1f2937', flex: 1},
|
||||||
|
renameRow: {flexDirection: 'row', alignItems: 'center', gap: 8},
|
||||||
|
renameInput: {
|
||||||
|
flex: 1,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#1f2937',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
},
|
||||||
|
renameButton: {backgroundColor: '#2563eb', borderColor: '#2563eb', flex: 0, minHeight: 36, paddingHorizontal: 14},
|
||||||
|
renameLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
|
||||||
|
renameSaveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a', flex: 0, minHeight: 36, paddingHorizontal: 12},
|
||||||
|
renameSaveLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
|
||||||
|
renameCancelButton: {backgroundColor: '#6b7280', borderColor: '#6b7280', flex: 0, minHeight: 36, paddingHorizontal: 12},
|
||||||
|
renameCancelLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
|
||||||
memberList: {flex: 1, paddingHorizontal: 15, paddingTop: 10},
|
memberList: {flex: 1, paddingHorizontal: 15, paddingTop: 10},
|
||||||
memberRow: {
|
memberRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -252,9 +328,11 @@ const styles = StyleSheet.create({
|
|||||||
kickButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 0, paddingHorizontal: 14},
|
kickButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 0, paddingHorizontal: 14},
|
||||||
kickLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
|
kickLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
|
||||||
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
|
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
|
||||||
leaveWrap: {paddingHorizontal: 15, paddingVertical: 10},
|
leaveWrap: {paddingHorizontal: 0, paddingTop: 10, paddingBottom: 16},
|
||||||
leaveButton: {backgroundColor: '#dc2626', borderColor: '#dc2626'},
|
leaveButton: {backgroundColor: '#dc2626', borderColor: '#dc2626'},
|
||||||
leaveLabel: {color: '#ffffff', fontWeight: '600'},
|
leaveLabel: {color: '#ffffff', fontWeight: '600'},
|
||||||
|
// Current user self-highlight (light green card)
|
||||||
|
selfCard: {backgroundColor: '#dcfce7', borderColor: '#86efac'},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default TeamScreen;
|
export default TeamScreen;
|
||||||
+57
-15
@@ -9,7 +9,7 @@ const useGameHook = () => {
|
|||||||
const subgroupMembershipsEndpoint = `${API_BASE_URL}/subgroup-memberships`;
|
const subgroupMembershipsEndpoint = `${API_BASE_URL}/subgroup-memberships`;
|
||||||
const teamsEndpoint = `${API_BASE_URL}/teams`;
|
const teamsEndpoint = `${API_BASE_URL}/teams`;
|
||||||
const teamMembersEndpoint = `${API_BASE_URL}/team-members`;
|
const teamMembersEndpoint = `${API_BASE_URL}/team-members`;
|
||||||
const gameDataEndpoint = `${API_BASE_URL}/game-data`;
|
const cachesEndpoint = `${API_BASE_URL}/caches`;
|
||||||
const adminWaitlistEndpoint = `${API_BASE_URL}/admin-waitlist`;
|
const adminWaitlistEndpoint = `${API_BASE_URL}/admin-waitlist`;
|
||||||
|
|
||||||
// Handlers -------------------
|
// Handlers -------------------
|
||||||
@@ -41,11 +41,32 @@ const useGameHook = () => {
|
|||||||
return response.isSuccess ? response.result : null;
|
return response.isSuccess ? response.result : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Subgroups
|
||||||
const getSubgroups = async (gid) => {
|
const getSubgroups = async (gid) => {
|
||||||
const response = await API.get(`${subgroupsEndpoint}?Gid=${gid}`);
|
const response = await API.get(`${subgroupsEndpoint}?Gid=${gid}`);
|
||||||
return response.isSuccess ? response.result : [];
|
return response.isSuccess ? response.result : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getSubgroup = async (sgid) => {
|
||||||
|
const response = await API.get(`${subgroupsEndpoint}/${sgid}`);
|
||||||
|
return response.isSuccess ? response.result : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 memberships
|
// Subgroup memberships
|
||||||
const joinPrivateGame = async (payload) => {
|
const joinPrivateGame = async (payload) => {
|
||||||
const response = await API.post(subgroupMembershipsEndpoint, payload);
|
const response = await API.post(subgroupMembershipsEndpoint, payload);
|
||||||
@@ -78,6 +99,16 @@ const useGameHook = () => {
|
|||||||
return response.isSuccess ? response.result : null;
|
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 joinTeamByCode = async (payload) => {
|
const joinTeamByCode = async (payload) => {
|
||||||
const response = await API.post(teamMembersEndpoint, payload);
|
const response = await API.post(teamMembersEndpoint, payload);
|
||||||
return response.isSuccess ? response.result : null;
|
return response.isSuccess ? response.result : null;
|
||||||
@@ -94,10 +125,10 @@ const useGameHook = () => {
|
|||||||
return response.isSuccess;
|
return response.isSuccess;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Caches
|
// Caches (standalone resource)
|
||||||
const getCaches = async (gid, sgid = null) => {
|
const getCaches = async (gid, sgid = null) => {
|
||||||
let url = `${gameDataEndpoint}/${gid}/caches`;
|
let url = `${cachesEndpoint}?Gid=${gid}`;
|
||||||
if (sgid !== null) url += `?SGid=${sgid}`;
|
if (sgid !== null) url += `&SGid=${sgid}`;
|
||||||
const response = await API.get(url);
|
const response = await API.get(url);
|
||||||
return response.isSuccess ? response.result : [];
|
return response.isSuccess ? response.result : [];
|
||||||
};
|
};
|
||||||
@@ -105,41 +136,46 @@ const useGameHook = () => {
|
|||||||
const upsertCache = async (payload) => {
|
const upsertCache = async (payload) => {
|
||||||
if (payload.cacheId) {
|
if (payload.cacheId) {
|
||||||
const response = await API.put(
|
const response = await API.put(
|
||||||
`${gameDataEndpoint}/${payload.gid}/caches/${payload.cacheId}`,
|
`${cachesEndpoint}/${payload.cacheId}`,
|
||||||
payload,
|
payload,
|
||||||
);
|
);
|
||||||
return response.isSuccess ? response.result : null;
|
return response.isSuccess ? response.result : null;
|
||||||
}
|
}
|
||||||
const response = await API.post(
|
const response = await API.post(cachesEndpoint, {
|
||||||
`${gameDataEndpoint}/${payload.gid}/caches`,
|
Gid: payload.gid,
|
||||||
payload,
|
SGid: payload.subgroupId,
|
||||||
);
|
Title: payload.name,
|
||||||
|
Clue: payload.clue,
|
||||||
|
Latitude: payload.latitude,
|
||||||
|
Longitude: payload.longitude,
|
||||||
|
TriggerMeters: payload.radius,
|
||||||
|
});
|
||||||
return response.isSuccess ? response.result : null;
|
return response.isSuccess ? response.result : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Claim a cache via the multi-team claim endpoint
|
// Claim a cache — team-aware: prevents same team from claiming again
|
||||||
const claimCache = async (payload) => {
|
const claimCache = async (payload) => {
|
||||||
const response = await API.post(
|
const response = await API.post(
|
||||||
`${gameDataEndpoint}/${payload.gid}/caches/${payload.cacheId}/claim`,
|
`${cachesEndpoint}/${payload.cacheId}/claim`,
|
||||||
{Uid: payload.uid, Tid: payload.tid},
|
{Uid: payload.uid, Tid: payload.tid},
|
||||||
);
|
);
|
||||||
return response.isSuccess ? response.result : null;
|
return response.isSuccess ? response.result : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCache = async (gid, cacheId) => {
|
const deleteCache = async (gid, cacheId) => {
|
||||||
const response = await API.delete(`${gameDataEndpoint}/${gid}/caches/${cacheId}`);
|
const response = await API.delete(`${cachesEndpoint}/${cacheId}`);
|
||||||
return response.isSuccess;
|
return response.isSuccess;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Game reset — clear all claims for a game
|
// Game reset — remove all caches for a game
|
||||||
const resetGame = async (gid) => {
|
const resetGame = async (gid) => {
|
||||||
const response = await API.post(`${gameDataEndpoint}/${gid}/reset`, {});
|
const response = await API.post(`${cachesEndpoint}/reset?Gid=${gid}`, {});
|
||||||
return response.isSuccess;
|
return response.isSuccess;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Player progress reset — clear all claims by a specific user
|
// Player progress reset — clear all claims by a specific user
|
||||||
const resetPlayerProgress = async (gid, uid) => {
|
const resetPlayerProgress = async (gid, uid) => {
|
||||||
const response = await API.post(`${gameDataEndpoint}/${gid}/reset-player/${uid}`, {});
|
const response = await API.post(`${cachesEndpoint}/reset-player/${uid}?Gid=${gid}`, {});
|
||||||
return response.isSuccess;
|
return response.isSuccess;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -170,12 +206,18 @@ const useGameHook = () => {
|
|||||||
getLobby,
|
getLobby,
|
||||||
updateGroup,
|
updateGroup,
|
||||||
getSubgroups,
|
getSubgroups,
|
||||||
|
getSubgroup,
|
||||||
|
createSubgroup,
|
||||||
|
updateSubgroup,
|
||||||
|
deleteSubgroup,
|
||||||
joinPrivateGame,
|
joinPrivateGame,
|
||||||
getGroupMembers,
|
getGroupMembers,
|
||||||
removeMember,
|
removeMember,
|
||||||
getTeams,
|
getTeams,
|
||||||
getTeam,
|
getTeam,
|
||||||
createTeam,
|
createTeam,
|
||||||
|
updateTeam,
|
||||||
|
deleteTeam,
|
||||||
joinTeamByCode,
|
joinTeamByCode,
|
||||||
getTeamMembers,
|
getTeamMembers,
|
||||||
leaveTeam,
|
leaveTeam,
|
||||||
|
|||||||
Reference in new issue
Block a user