mirror of
https://github.com/mudabbir-ahmad/MAD-Treasure-Hunt.git
synced 2026-10-07 19: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
+586
-216
No files matched your search
+101
-144
@@ -250,10 +250,6 @@ addRoute('POST', '/groups', async (req, res) => {
|
||||
});
|
||||
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');
|
||||
@@ -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) => {
|
||||
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) => {
|
||||
// Migrate old single-claim format to Claims array
|
||||
let claims = cache.Claims || [];
|
||||
if (claims.length === 0 && cache.ClaimedByUid) {
|
||||
claims = [{ Uid: cache.ClaimedByUid, Tid: cache.ClaimedByTid || null, ClaimedAt: null }];
|
||||
}
|
||||
return {
|
||||
// Helper: convert storage row to API response format
|
||||
const toCacheApi = (cache) => ({
|
||||
id: cache.CacheId,
|
||||
name: cache.Title || '',
|
||||
clue: cache.Clue || '',
|
||||
coordinates: { latitude: cache.Latitude, longitude: cache.Longitude },
|
||||
radius: cache.TriggerMeters,
|
||||
clue: cache.Title,
|
||||
groupId: Number(gid),
|
||||
groupId: cache.Gid,
|
||||
subgroupId: cache.SGid,
|
||||
Claims: claims,
|
||||
};
|
||||
Claims: cache.Claims || [],
|
||||
});
|
||||
|
||||
// GET /caches — list caches for a group (optional SGid filter)
|
||||
addRoute('GET', '/caches', (req, res, params, query) => {
|
||||
let rows = readJson('caches.json');
|
||||
if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid);
|
||||
if (query.SGid) rows = rows.filter((r) => String(r.SGid) === query.SGid);
|
||||
sendJson(res, 200, rows.map(toCacheApi));
|
||||
});
|
||||
|
||||
// POST /caches — create a new cache
|
||||
addRoute('POST', '/caches', async (req, res) => {
|
||||
const payload = await parseBody(req);
|
||||
const rows = readJson('caches.json');
|
||||
|
||||
const cache = {
|
||||
CacheId: nextId(rows, 'CacheId'),
|
||||
Gid: payload.Gid,
|
||||
SGid: payload.SGid || null,
|
||||
Title: payload.Title || '',
|
||||
Clue: payload.Clue || '',
|
||||
Latitude: payload.Latitude || 0,
|
||||
Longitude: payload.Longitude || 0,
|
||||
TriggerMeters: payload.TriggerMeters || 20,
|
||||
Claims: [],
|
||||
};
|
||||
|
||||
// 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),
|
||||
rows.push(cache);
|
||||
writeJson('caches.json', rows);
|
||||
sendJson(res, 201, toCacheApi(cache));
|
||||
});
|
||||
|
||||
// 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' });
|
||||
// POST /caches/reset — clear all claims for every cache in a game
|
||||
addRoute('POST', '/caches/reset', async (req, res, params, query) => {
|
||||
const rows = readJson('caches.json');
|
||||
const gid = query.Gid;
|
||||
if (!gid) return sendJson(res, 400, { message: 'Gid query parameter required' });
|
||||
|
||||
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);
|
||||
for (const cache of rows) {
|
||||
if (String(cache.Gid) === gid) cache.Claims = [];
|
||||
}
|
||||
|
||||
writeJson('caches.json', rows);
|
||||
sendJson(res, 200, { message: 'Game reset successfully' });
|
||||
});
|
||||
|
||||
// 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' });
|
||||
// POST /caches/reset-player/:uid — remove all claims by a specific user
|
||||
addRoute('POST', '/caches/reset-player/:uid', async (req, res, params, query) => {
|
||||
const rows = readJson('caches.json');
|
||||
const gid = query.Gid;
|
||||
if (!gid) return sendJson(res, 400, { message: 'Gid query parameter required' });
|
||||
|
||||
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));
|
||||
const uid = Number(params.uid);
|
||||
for (const cache of rows) {
|
||||
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
|
||||
addRoute('POST', '/game-data/:gid/caches', async (req, res, params) => {
|
||||
// GET /caches/:id — read a single cache
|
||||
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 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), 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);
|
||||
const rows = readJson('caches.json');
|
||||
const idx = rows.findIndex((r) => String(r.CacheId) === params.id);
|
||||
if (idx === -1) return sendJson(res, 404, { message: 'Cache not found' });
|
||||
|
||||
const existing = entry.GeoCaches[idx];
|
||||
const updated = { ...existing, ...toCacheStorage(payload, params.cacheId) };
|
||||
// Preserve claims — property updates must not overwrite the Claims array
|
||||
updated.Claims = existing.Claims || [];
|
||||
entry.GeoCaches[idx] = updated;
|
||||
writeJson('game-data.json', rows);
|
||||
sendJson(res, 200, toCacheApi(updated, params.gid));
|
||||
const existing = rows[idx];
|
||||
rows[idx] = {
|
||||
...existing,
|
||||
Title: payload.Title != null ? payload.Title : existing.Title,
|
||||
Clue: payload.Clue != null ? payload.Clue : existing.Clue,
|
||||
Latitude: payload.Latitude != null ? payload.Latitude : existing.Latitude,
|
||||
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
|
||||
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);
|
||||
// DELETE /caches/:id — delete a cache
|
||||
addRoute('DELETE', '/caches/:id', (req, res, params) => {
|
||||
const rows = readJson('caches.json');
|
||||
const idx = rows.findIndex((r) => String(r.CacheId) === params.id);
|
||||
if (idx === -1) return sendJson(res, 404, { message: 'Cache not found' });
|
||||
|
||||
entry.GeoCaches.splice(idx, 1);
|
||||
writeJson('game-data.json', rows);
|
||||
rows.splice(idx, 1);
|
||||
writeJson('caches.json', rows);
|
||||
sendJson(res, 204, null);
|
||||
});
|
||||
|
||||
// POST /game-data/:gid/caches/:cacheId/claim — claim a cache (multi-team)
|
||||
addRoute('POST', '/game-data/:gid/caches/:cacheId/claim', async (req, res, params) => {
|
||||
// POST /caches/:id/claim — claim a cache (team-aware: prevents same team from claiming again)
|
||||
addRoute('POST', '/caches/:id/claim', async (req, res, params) => {
|
||||
const payload = await parseBody(req);
|
||||
const rows = readJson('game-data.json');
|
||||
const entry = rows.find((r) => String(r.Gid) === params.gid);
|
||||
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
|
||||
|
||||
const cache = (entry.GeoCaches || []).find((c) => c.CacheId === params.cacheId);
|
||||
const rows = readJson('caches.json');
|
||||
const cache = rows.find((r) => String(r.CacheId) === params.id);
|
||||
if (!cache) return sendJson(res, 404, { message: 'Cache not found' });
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
writeJson('game-data.json', rows);
|
||||
sendJson(res, 200, toCacheApi(cache, params.gid));
|
||||
});
|
||||
|
||||
// POST /game-data/:gid/reset — reset all cache claims for a game
|
||||
addRoute('POST', '/game-data/:gid/reset', async (req, res, params) => {
|
||||
const rows = readJson('game-data.json');
|
||||
const entry = rows.find((r) => String(r.Gid) === params.gid);
|
||||
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
|
||||
|
||||
for (const cache of (entry.GeoCaches || [])) {
|
||||
cache.Claims = [];
|
||||
// Clean up legacy fields
|
||||
delete cache.ClaimedByUid;
|
||||
delete cache.ClaimedByTid;
|
||||
}
|
||||
|
||||
writeJson('game-data.json', rows);
|
||||
sendJson(res, 200, { message: 'Game reset successfully' });
|
||||
});
|
||||
|
||||
// POST /game-data/:gid/reset-player/:uid — remove all claims by a specific user
|
||||
addRoute('POST', '/game-data/:gid/reset-player/:uid', async (req, res, params) => {
|
||||
const rows = readJson('game-data.json');
|
||||
const entry = rows.find((r) => String(r.Gid) === params.gid);
|
||||
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
|
||||
|
||||
const uid = Number(params.uid);
|
||||
for (const cache of (entry.GeoCaches || [])) {
|
||||
if (cache.Claims) {
|
||||
cache.Claims = cache.Claims.filter((c) => c.Uid !== uid);
|
||||
}
|
||||
}
|
||||
|
||||
writeJson('game-data.json', rows);
|
||||
sendJson(res, 200, { message: 'Player progress reset' });
|
||||
writeJson('caches.json', rows);
|
||||
sendJson(res, 200, toCacheApi(cache));
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
|
||||
@@ -10,7 +10,7 @@ const GameSettingsScreen = () => {
|
||||
// Initialisation ------------
|
||||
|
||||
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);
|
||||
|
||||
// State ----------------------
|
||||
@@ -19,12 +19,16 @@ const GameSettingsScreen = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [teamsEnabled, setTeamsEnabled] = useState(session.teamsEnabled);
|
||||
const [maxSubgroups, setMaxSubgroups] = useState('1');
|
||||
const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20');
|
||||
const [adminJoinCode, setAdminJoinCode] = useState('');
|
||||
const [memberJoinCode, setMemberJoinCode] = useState('');
|
||||
const [waitlist, setWaitlist] = 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 -------------------
|
||||
|
||||
@@ -40,6 +44,15 @@ const GameSettingsScreen = () => {
|
||||
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(() => {
|
||||
const load = async () => {
|
||||
if (!session.currentGid) {
|
||||
@@ -53,13 +66,10 @@ const GameSettingsScreen = () => {
|
||||
setTeamsEnabled(serverTeams);
|
||||
// Keep session in sync so the toggle survives app reloads
|
||||
setSessionTeamsEnabled(serverTeams);
|
||||
setMaxSubgroups(String(group.MaxMemberSubgroups || 1));
|
||||
setCacheTriggerMeters(String(group.CacheTriggerMeters || 20));
|
||||
setAdminJoinCode(group.AdminJoinCode || '');
|
||||
}
|
||||
const sgs = await getSubgroups(session.currentGid);
|
||||
const memberSg = (sgs || []).find((sg) => !sg.IsAdminGroup);
|
||||
if (memberSg) setMemberJoinCode(memberSg.JoinCode || '');
|
||||
await loadSubgroups();
|
||||
await loadWaitlist();
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -72,7 +82,6 @@ const GameSettingsScreen = () => {
|
||||
const result = await updateGroup(session.currentGid, {
|
||||
GroupName: groupName.trim(),
|
||||
TeamsEnabled: teamsEnabled,
|
||||
MaxMemberSubgroups: parseInt(maxSubgroups) || 1,
|
||||
CacheTriggerMeters: parseInt(cacheTriggerMeters) || 20,
|
||||
});
|
||||
// Persist the new toggle value so the session reflects it after a reload
|
||||
@@ -81,14 +90,14 @@ const GameSettingsScreen = () => {
|
||||
};
|
||||
|
||||
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: 'Reset',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
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();
|
||||
};
|
||||
|
||||
// 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 -----------------------
|
||||
|
||||
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 (
|
||||
<Screen>
|
||||
<ScrollView contentContainerStyle={styles.formContainer} showsVerticalScrollIndicator={false}>
|
||||
@@ -146,20 +193,6 @@ const GameSettingsScreen = () => {
|
||||
/>
|
||||
</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>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
@@ -191,6 +224,83 @@ const GameSettingsScreen = () => {
|
||||
</ButtonTray>
|
||||
</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 */}
|
||||
{waitlist.length > 0 && (
|
||||
<View style={styles.waitlistSection}>
|
||||
@@ -276,6 +386,24 @@ const styles = StyleSheet.create({
|
||||
saveWrap: {marginTop: 24},
|
||||
saveButton: {backgroundColor: '#16a34a', borderColor: '#16a34a'},
|
||||
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
|
||||
waitlistSection: {marginTop: 30},
|
||||
waitlistRow: {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
|
||||
import {ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';
|
||||
import Screen from '../layout/Screen';
|
||||
import Card from '../UI/Card';
|
||||
import {Button} from '../UI/Button';
|
||||
import useGameHook from '../../hooks/useGameHook';
|
||||
import {getSession} from '../../hooks/SessionStore';
|
||||
|
||||
@@ -10,7 +11,7 @@ const LeaderboardScreen = () => {
|
||||
|
||||
const session = getSession();
|
||||
const isAdmin = session.isAcceptedAdmin;
|
||||
const {getLobby, getTeams, getTeamMembers, getGroupMembers, getCaches, getUser} = useGameHook();
|
||||
const {getLobby, getTeams, getTeamMembers, getGroupMembers, getCaches, getUser, deleteTeam, resetPlayerProgress} = useGameHook();
|
||||
|
||||
// State ----------------------
|
||||
|
||||
@@ -21,6 +22,7 @@ const LeaderboardScreen = () => {
|
||||
const [playerRankings, setPlayerRankings] = useState([]);
|
||||
const [myTeamRanking, setMyTeamRanking] = useState([]);
|
||||
const [expandedTeam, setExpandedTeam] = useState(null);
|
||||
const [totalCacheCount, setTotalCacheCount] = useState(0);
|
||||
|
||||
// Handlers -------------------
|
||||
|
||||
@@ -32,6 +34,8 @@ const LeaderboardScreen = () => {
|
||||
setTeamsEnabled(isTeams);
|
||||
|
||||
const caches = await getCaches(session.currentGid, null);
|
||||
const total = (caches || []).length;
|
||||
setTotalCacheCount(total);
|
||||
|
||||
if (isTeams) {
|
||||
// Build team rankings
|
||||
@@ -98,6 +102,37 @@ const LeaderboardScreen = () => {
|
||||
|
||||
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 -----------------------
|
||||
|
||||
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) => (
|
||||
<Pressable key={team.tid} onPress={() => setExpandedTeam(expandedTeam === team.tid ? null : team.tid)}>
|
||||
<Card>
|
||||
<View style={styles.rankRow}>
|
||||
<Text style={styles.rank}>#{index + 1}</Text>
|
||||
<Text style={styles.rankName}>{team.name}</Text>
|
||||
<Text style={styles.score}>{team.totalCaches} cache(s)</Text>
|
||||
<Text style={styles.rankName}>{team.name} ({team.code})</Text>
|
||||
<Text style={styles.score}>{team.totalCaches}/{totalCacheCount}</Text>
|
||||
</View>
|
||||
{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}>
|
||||
{m.name}{m.isLeader ? ' (Team Leader)' : ''}
|
||||
</Text>
|
||||
<Text style={styles.subScore}>{m.caches}</Text>
|
||||
<Text style={styles.subScore}>{m.caches}/{totalCacheCount}</Text>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
// Helper: render a simple player card
|
||||
const renderPlayerCard = (player, index) => (
|
||||
<Card key={player.uid}>
|
||||
// Helper: render an admin player card (with reset button)
|
||||
const renderAdminPlayerCard = (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} 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>
|
||||
</Card>
|
||||
);
|
||||
@@ -155,7 +250,7 @@ const LeaderboardScreen = () => {
|
||||
return (
|
||||
<Screen style={styles.container}>
|
||||
<ScrollView style={styles.listSection}>
|
||||
{teamRankings.map((team, index) => renderTeamCard(team, index))}
|
||||
{teamRankings.map((team, index) => renderAdminTeamCard(team, index))}
|
||||
{teamRankings.length === 0 && (
|
||||
<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 (
|
||||
<Screen style={styles.container}>
|
||||
<ScrollView style={styles.listSection}>
|
||||
{playerRankings.map((player, index) => renderPlayerCard(player, index))}
|
||||
{playerRankings.map((player, index) => renderAdminPlayerCard(player, index))}
|
||||
{playerRankings.length === 0 && (
|
||||
<Text style={styles.emptyText}>No players yet.</Text>
|
||||
)}
|
||||
@@ -208,13 +303,14 @@ const LeaderboardScreen = () => {
|
||||
) : (
|
||||
<ScrollView style={styles.listSection}>
|
||||
{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}>
|
||||
<Text style={styles.rank}>#{index + 1}</Text>
|
||||
<Text style={styles.rankName}>
|
||||
{m.name}{m.isLeader ? ' (Team Leader)' : ''}
|
||||
</Text>
|
||||
<Text style={styles.score}>{m.caches} cache(s)</Text>
|
||||
<Text style={styles.score}>{m.caches}/{totalCacheCount}</Text>
|
||||
</View>
|
||||
</Card>
|
||||
))}
|
||||
@@ -271,15 +367,31 @@ const styles = StyleSheet.create({
|
||||
score: {fontSize: 14, fontWeight: '600', color: '#16a34a'},
|
||||
subRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 42,
|
||||
paddingVertical: 6,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f3f4f6',
|
||||
},
|
||||
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},
|
||||
// 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;
|
||||
@@ -2,6 +2,7 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native';
|
||||
import MapView, {Circle, Marker, Polygon} from 'react-native-maps';
|
||||
import * as Location from 'expo-location';
|
||||
import {useFocusEffect} from '@react-navigation/native';
|
||||
import Screen from '../layout/Screen';
|
||||
import {Button, ButtonTray} from '../UI/Button';
|
||||
import CacheCardItem from '../gameplay/CacheCardItem';
|
||||
@@ -19,7 +20,7 @@ const MapScreen = ({navigation}) => {
|
||||
// Initialisation ------------
|
||||
|
||||
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 ----------------------
|
||||
|
||||
@@ -34,6 +35,7 @@ const MapScreen = ({navigation}) => {
|
||||
const [groupInfo, setGroupInfo] = useState(null);
|
||||
const [selectedCacheId, setSelectedCacheIdState] = useState(session.selectedCacheId);
|
||||
const [claimedPopupVisible, setClaimedPopupVisible] = useState(false);
|
||||
const [subgroups, setSubgroups] = useState([]);
|
||||
|
||||
// Admin cache creation / editing state
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
@@ -75,19 +77,33 @@ const MapScreen = ({navigation}) => {
|
||||
useEffect(() => {
|
||||
if (!session.currentGid) return;
|
||||
getLobby(session.currentGid).then(setGroupInfo);
|
||||
getSubgroups(session.currentGid).then(setSubgroups);
|
||||
}, [inGame]);
|
||||
|
||||
// Default member subgroup SGid for cache creation (first non-admin subgroup)
|
||||
const 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 () => {
|
||||
if (!session.currentGid) {
|
||||
const currentGid = getSession().currentGid;
|
||||
if (!currentGid) {
|
||||
setCacheRecords([]);
|
||||
return;
|
||||
}
|
||||
const rows = await getCaches(session.currentGid, null);
|
||||
const rows = await getCaches(currentGid, null);
|
||||
setCacheRecords(rows || []);
|
||||
}, [inGame, isAdmin]);
|
||||
}, [getCaches]);
|
||||
|
||||
// Run once on mount
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!isPlayer || cacheRecords.length === 0) return;
|
||||
@@ -222,7 +238,7 @@ const MapScreen = ({navigation}) => {
|
||||
latitude: newCoord.latitude,
|
||||
longitude: newCoord.longitude,
|
||||
clue: newClue.trim(),
|
||||
subgroupId: 1,
|
||||
subgroupId: defaultMemberSGid,
|
||||
};
|
||||
if (editingCacheId) payload.cacheId = editingCacheId;
|
||||
await upsertCache(payload);
|
||||
|
||||
@@ -10,7 +10,7 @@ const PlayersScreen = () => {
|
||||
// Initialisation ------------
|
||||
|
||||
const session = getSession();
|
||||
const {getTeams, getTeamMembers, getGroupMembers, removeMember, resetPlayerProgress, getUser} = useGameHook();
|
||||
const {getTeams, getTeamMembers, getGroupMembers, removeMember, resetPlayerProgress, getUser, getAdminWaitlist} = useGameHook();
|
||||
|
||||
// State ----------------------
|
||||
|
||||
@@ -22,9 +22,11 @@ const PlayersScreen = () => {
|
||||
const loadData = useCallback(async () => {
|
||||
if (!session.currentGid) { setLoading(false); return; }
|
||||
|
||||
// Get all non-admin players
|
||||
// Get ALL members (including admins)
|
||||
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 } }
|
||||
const allTeams = await getTeams(session.currentGid);
|
||||
@@ -40,18 +42,40 @@ const PlayersScreen = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Build player list
|
||||
// Build player list — include admins with tags
|
||||
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);
|
||||
if (user) {
|
||||
let adminTag = null;
|
||||
if (m.IsAcceptedAdmin) adminTag = '[ADMIN]';
|
||||
playerList.push({
|
||||
...user,
|
||||
membershipId: m.id,
|
||||
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);
|
||||
setLoading(false);
|
||||
}, [session.currentGid]);
|
||||
@@ -106,7 +130,14 @@ const PlayersScreen = () => {
|
||||
{players.map((player) => (
|
||||
<Card key={player.Uid}>
|
||||
<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}>
|
||||
{player.team?.isLeader && (
|
||||
<Text style={styles.leaderBadge}>(Team Leader)</Text>
|
||||
@@ -117,18 +148,22 @@ const PlayersScreen = () => {
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.actionRow}>
|
||||
{!player.adminTag && (
|
||||
<Button
|
||||
label="Reset Progress"
|
||||
onClick={() => handleResetPlayerProgress(player)}
|
||||
styleButton={styles.resetButton}
|
||||
styleLabel={styles.actionLabel}
|
||||
/>
|
||||
)}
|
||||
{player.membershipId && (
|
||||
<Button
|
||||
label="Kick"
|
||||
onClick={() => handleRemovePlayer(player.membershipId)}
|
||||
styleButton={styles.removeButton}
|
||||
styleLabel={styles.actionLabel}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Card>
|
||||
))}
|
||||
@@ -164,6 +199,8 @@ const styles = StyleSheet.create({
|
||||
playerMeta: {flexDirection: 'row', alignItems: 'center', gap: 6},
|
||||
leaderBadge: {fontSize: 11, color: '#2563eb', fontWeight: '700'},
|
||||
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},
|
||||
resetButton: {backgroundColor: '#f59e0b', borderColor: '#f59e0b', 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 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 ----------------------
|
||||
|
||||
@@ -22,6 +22,8 @@ const TeamScreen = () => {
|
||||
const [memberNames, setMemberNames] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [onAdminWaitlist, setOnAdminWaitlist] = useState(false);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [teamNameDraft, setTeamNameDraft] = useState('');
|
||||
|
||||
// Handlers -------------------
|
||||
|
||||
@@ -74,7 +76,7 @@ const TeamScreen = () => {
|
||||
|
||||
const handleCreateTeam = async () => {
|
||||
if (!session.currentGid) return;
|
||||
const result = await createTeam({Gid: session.currentGid, TeamName: 'My Team'});
|
||||
const result = await createTeam({Gid: session.currentGid, SGid: session.currentSGid, TeamName: 'My Team'});
|
||||
if (!result) return;
|
||||
const membership = await joinTeamByCode({Tid: result.Tid, Uid: session.currentUid});
|
||||
if (membership) {
|
||||
@@ -100,6 +102,16 @@ const TeamScreen = () => {
|
||||
await loadTeamData(activeTid);
|
||||
};
|
||||
|
||||
const handleRenameTeam = async () => {
|
||||
const trimmed = teamNameDraft.trim();
|
||||
if (!trimmed || !activeTid) return;
|
||||
const result = await updateTeam(activeTid, {TeamName: trimmed});
|
||||
if (result) {
|
||||
setTeam(result);
|
||||
setEditingName(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if current user is the team leader
|
||||
const myMembership = members.find((m) => m.Uid === session.currentUid);
|
||||
const isCurrentUserLeader = Boolean(myMembership?.IsLeader);
|
||||
@@ -166,9 +178,51 @@ const TeamScreen = () => {
|
||||
<Text style={styles.teamCodeLabel}>Team Code:</Text>
|
||||
<Text style={styles.teamCodeValue}>{team.JoinCode}</Text>
|
||||
</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}>
|
||||
{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.memberInfo}>
|
||||
<Text style={styles.memberName}>
|
||||
@@ -190,7 +244,7 @@ const TeamScreen = () => {
|
||||
{members.length === 0 && (
|
||||
<Text style={styles.emptyText}>No members yet.</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
{/* Leave button lives inside the scroll so it is never obscured by the bottom nav bar */}
|
||||
<View style={styles.leaveWrap}>
|
||||
<Button
|
||||
label="Leave Team"
|
||||
@@ -199,6 +253,7 @@ const TeamScreen = () => {
|
||||
styleLabel={styles.leaveLabel}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Screen>
|
||||
);
|
||||
};
|
||||
@@ -240,6 +295,27 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
teamCodeLabel: {color: '#d1d5db', fontSize: 15, fontWeight: '600'},
|
||||
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},
|
||||
memberRow: {
|
||||
flexDirection: 'row',
|
||||
@@ -252,9 +328,11 @@ const styles = StyleSheet.create({
|
||||
kickButton: {backgroundColor: '#dc2626', borderColor: '#dc2626', minHeight: 36, flex: 0, paddingHorizontal: 14},
|
||||
kickLabel: {color: '#ffffff', fontWeight: '600', fontSize: 13},
|
||||
emptyText: {color: '#9ca3af', textAlign: 'center', marginTop: 30, fontSize: 14},
|
||||
leaveWrap: {paddingHorizontal: 15, paddingVertical: 10},
|
||||
leaveWrap: {paddingHorizontal: 0, paddingTop: 10, paddingBottom: 16},
|
||||
leaveButton: {backgroundColor: '#dc2626', borderColor: '#dc2626'},
|
||||
leaveLabel: {color: '#ffffff', fontWeight: '600'},
|
||||
// Current user self-highlight (light green card)
|
||||
selfCard: {backgroundColor: '#dcfce7', borderColor: '#86efac'},
|
||||
});
|
||||
|
||||
export default TeamScreen;
|
||||
+57
-15
@@ -9,7 +9,7 @@ const useGameHook = () => {
|
||||
const subgroupMembershipsEndpoint = `${API_BASE_URL}/subgroup-memberships`;
|
||||
const teamsEndpoint = `${API_BASE_URL}/teams`;
|
||||
const teamMembersEndpoint = `${API_BASE_URL}/team-members`;
|
||||
const gameDataEndpoint = `${API_BASE_URL}/game-data`;
|
||||
const cachesEndpoint = `${API_BASE_URL}/caches`;
|
||||
const adminWaitlistEndpoint = `${API_BASE_URL}/admin-waitlist`;
|
||||
|
||||
// Handlers -------------------
|
||||
@@ -41,11 +41,32 @@ const useGameHook = () => {
|
||||
return response.isSuccess ? response.result : null;
|
||||
};
|
||||
|
||||
// Subgroups
|
||||
const getSubgroups = async (gid) => {
|
||||
const response = await API.get(`${subgroupsEndpoint}?Gid=${gid}`);
|
||||
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
|
||||
const joinPrivateGame = async (payload) => {
|
||||
const response = await API.post(subgroupMembershipsEndpoint, payload);
|
||||
@@ -78,6 +99,16 @@ const useGameHook = () => {
|
||||
return response.isSuccess ? response.result : null;
|
||||
};
|
||||
|
||||
const updateTeam = async (tid, data) => {
|
||||
const response = await API.put(`${teamsEndpoint}/${tid}`, data);
|
||||
return response.isSuccess ? response.result : null;
|
||||
};
|
||||
|
||||
const deleteTeam = async (tid) => {
|
||||
const response = await API.delete(`${teamsEndpoint}/${tid}`);
|
||||
return response.isSuccess;
|
||||
};
|
||||
|
||||
const joinTeamByCode = async (payload) => {
|
||||
const response = await API.post(teamMembersEndpoint, payload);
|
||||
return response.isSuccess ? response.result : null;
|
||||
@@ -94,10 +125,10 @@ const useGameHook = () => {
|
||||
return response.isSuccess;
|
||||
};
|
||||
|
||||
// Caches
|
||||
// Caches (standalone resource)
|
||||
const getCaches = async (gid, sgid = null) => {
|
||||
let url = `${gameDataEndpoint}/${gid}/caches`;
|
||||
if (sgid !== null) url += `?SGid=${sgid}`;
|
||||
let url = `${cachesEndpoint}?Gid=${gid}`;
|
||||
if (sgid !== null) url += `&SGid=${sgid}`;
|
||||
const response = await API.get(url);
|
||||
return response.isSuccess ? response.result : [];
|
||||
};
|
||||
@@ -105,41 +136,46 @@ const useGameHook = () => {
|
||||
const upsertCache = async (payload) => {
|
||||
if (payload.cacheId) {
|
||||
const response = await API.put(
|
||||
`${gameDataEndpoint}/${payload.gid}/caches/${payload.cacheId}`,
|
||||
`${cachesEndpoint}/${payload.cacheId}`,
|
||||
payload,
|
||||
);
|
||||
return response.isSuccess ? response.result : null;
|
||||
}
|
||||
const response = await API.post(
|
||||
`${gameDataEndpoint}/${payload.gid}/caches`,
|
||||
payload,
|
||||
);
|
||||
const response = await API.post(cachesEndpoint, {
|
||||
Gid: payload.gid,
|
||||
SGid: payload.subgroupId,
|
||||
Title: payload.name,
|
||||
Clue: payload.clue,
|
||||
Latitude: payload.latitude,
|
||||
Longitude: payload.longitude,
|
||||
TriggerMeters: payload.radius,
|
||||
});
|
||||
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 response = await API.post(
|
||||
`${gameDataEndpoint}/${payload.gid}/caches/${payload.cacheId}/claim`,
|
||||
`${cachesEndpoint}/${payload.cacheId}/claim`,
|
||||
{Uid: payload.uid, Tid: payload.tid},
|
||||
);
|
||||
return response.isSuccess ? response.result : null;
|
||||
};
|
||||
|
||||
const deleteCache = async (gid, cacheId) => {
|
||||
const response = await API.delete(`${gameDataEndpoint}/${gid}/caches/${cacheId}`);
|
||||
const response = await API.delete(`${cachesEndpoint}/${cacheId}`);
|
||||
return response.isSuccess;
|
||||
};
|
||||
|
||||
// Game reset — clear all claims for a game
|
||||
// Game reset — remove all caches for a game
|
||||
const resetGame = async (gid) => {
|
||||
const response = await API.post(`${gameDataEndpoint}/${gid}/reset`, {});
|
||||
const response = await API.post(`${cachesEndpoint}/reset?Gid=${gid}`, {});
|
||||
return response.isSuccess;
|
||||
};
|
||||
|
||||
// Player progress reset — clear all claims by a specific user
|
||||
const resetPlayerProgress = async (gid, uid) => {
|
||||
const response = await API.post(`${gameDataEndpoint}/${gid}/reset-player/${uid}`, {});
|
||||
const response = await API.post(`${cachesEndpoint}/reset-player/${uid}?Gid=${gid}`, {});
|
||||
return response.isSuccess;
|
||||
};
|
||||
|
||||
@@ -170,12 +206,18 @@ const useGameHook = () => {
|
||||
getLobby,
|
||||
updateGroup,
|
||||
getSubgroups,
|
||||
getSubgroup,
|
||||
createSubgroup,
|
||||
updateSubgroup,
|
||||
deleteSubgroup,
|
||||
joinPrivateGame,
|
||||
getGroupMembers,
|
||||
removeMember,
|
||||
getTeams,
|
||||
getTeam,
|
||||
createTeam,
|
||||
updateTeam,
|
||||
deleteTeam,
|
||||
joinTeamByCode,
|
||||
getTeamMembers,
|
||||
leaveTeam,
|
||||
|
||||
Reference in new issue
Block a user