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:
bobbert committed 2026-04-09 00:13:28 +01:00
1 parent 9a9db74369
commit 70ed43f12b
7 files changed
+586 -216

No files matched your search

+101 -144
View File
@@ -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) => ({
});
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 {
id: cache.CacheId, id: cache.CacheId,
name: cache.Title || '',
clue: cache.Clue || '',
coordinates: { latitude: cache.Latitude, longitude: cache.Longitude }, coordinates: { latitude: cache.Latitude, longitude: cache.Longitude },
radius: cache.TriggerMeters, radius: cache.TriggerMeters,
clue: cache.Title, groupId: cache.Gid,
groupId: Number(gid),
subgroupId: cache.SGid, 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 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' });
}); });
// ====================================================================== // ======================================================================
+151 -23
View File
@@ -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: {
+130 -18
View File
@@ -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;
+21 -5
View File
@@ -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);
+43 -6
View File
@@ -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}>
{!player.adminTag && (
<Button <Button
label="Reset Progress" label="Reset Progress"
onClick={() => handleResetPlayerProgress(player)} onClick={() => handleResetPlayerProgress(player)}
styleButton={styles.resetButton} styleButton={styles.resetButton}
styleLabel={styles.actionLabel} styleLabel={styles.actionLabel}
/> />
)}
{player.membershipId && (
<Button <Button
label="Kick" label="Kick"
onClick={() => handleRemovePlayer(player.membershipId)} onClick={() => handleRemovePlayer(player.membershipId)}
styleButton={styles.removeButton} styleButton={styles.removeButton}
styleLabel={styles.actionLabel} 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},
+83 -5
View File
@@ -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,7 +244,7 @@ const TeamScreen = () => {
{members.length === 0 && ( {members.length === 0 && (
<Text style={styles.emptyText}>No members yet.</Text> <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}> <View style={styles.leaveWrap}>
<Button <Button
label="Leave Team" label="Leave Team"
@@ -199,6 +253,7 @@ const TeamScreen = () => {
styleLabel={styles.leaveLabel} styleLabel={styles.leaveLabel}
/> />
</View> </View>
</ScrollView>
</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
View File
@@ -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,