diff --git a/DB/DB-instructions.md b/DB/DB-instructions.md index 37e479f..b5fefdd 100644 --- a/DB/DB-instructions.md +++ b/DB/DB-instructions.md @@ -1,4 +1,4 @@ -# Database Structure +# Database Structure & Game Rules This project uses split JSON files in `DB/` so each collection behaves like its own REST endpoint. @@ -10,9 +10,9 @@ This project uses split JSON files in `DB/` so each collection behaves like its ## Files -- `users.json`: user accounts and role state (`Uid`, `Gid`, `SGid`, `TGid`, `IsAcceptedAdmin`). -- `groups.json`: top-level game groups (`Gid`), admin approvals, and group settings. -- `subgroups.json`: subgroup definitions within each group (`SGid` scoped by `Gid`), join codes, cache trigger defaults. +- `users.json`: user accounts and role state (`Uid`, `Gid`, `SGid`, `TGid`, `isBusiness`, `IsAcceptedAdmin`). +- `groups.json`: top-level game groups (`Gid`), admin approvals, group settings, creator metadata (`CreatedByUid`, `IsBusinessGroup`), and subgroup limits (`MaxMemberSubgroups`). +- `subgroups.json`: subgroup definitions within each group (`SGid` scoped by `Gid`), join codes, cache trigger defaults, and game status (`IsGameStarted`). - `subgroup-memberships.json`: membership bridge from `Uid` to `Gid`/`SGid`. - `admin-waitlist.json`: pending admin requests for groups when active admins already exist. - `teams.json`: team entities for non-admin subgroup players. @@ -25,13 +25,79 @@ This project uses split JSON files in `DB/` so each collection behaves like its - `Uid`: unique user ID. - `Gid`: unique group/game ID. -- `SGid`: subgroup ID within a group (`0` is admin subgroup). +- `SGid`: subgroup ID within a group (`0` is admin subgroup; `1+` are member subgroups). - `Tid`: unique team ID. - New IDs are generated from the max existing value in each endpoint collection (`users`, `groups`, `teams`) so no separate counter file is required. +## User Types: Individual vs Business + +### Individual Users (`isBusiness: false`) + +**Private Game Creation Rules:** +- Individuals can create **one private game per account**. +- Auto-creates: + - **Subgroup 0**: Admin subgroup (empty on creation). + - **Subgroup 1**: Members subgroup (individual joins as member, not admin). +- `MaxMemberSubgroups` is set to `1` (no additional subgroups can be created). +- Individual has `SGid = 1` and `IsAcceptedAdmin = false`. +- If an individual tries to create a game again, returns the existing game. + +**Team Participation:** +- Individuals can create and join teams **only in subgroup 1**. +- Cannot access admin functions or view Admin Hub. +- Game management is simplified: "Manage Game" screen instead of "Admin Hub". + +### Business/School Users (`isBusiness: true`) + +**Private Game Creation Rules:** +- Businesses can create **unlimited private games** (tracked per creator `CreatedByUid`). +- Auto-creates: + - **Subgroup 0**: Admin subgroup (business becomes member). + - **No automatic subgroups 1+** (must be created manually). +- `MaxMemberSubgroups` is set to `999` (supports up to 999 subgroups). +- Business creator has `SGid = 0` and `IsAcceptedAdmin = true` (immediately accepted). + +**Admin Approval Flow:** +- When a new admin joins via "Join as Admin for Private Game": + - If an active admin already exists (`hasActiveAdmin` check for SGid 0, IsAcceptedAdmin true): + - Request added to `admin-waitlist.json` with status `pending`. + - User is notified: "Admin access request submitted. You will see admin tools after approval." + - If **no active admin exists**: + - User is auto-promoted to accepted admin immediately. + - `IsAcceptedAdmin` is set to `true`. + +**Team Participation & Admin Restrictions:** +- **Admins cannot create or join teams** (checked via `canJoinTeamForSubgroup` logic). +- Admins in subgroup 0 see a warning in Game Lobby: "Admins cannot participate in teams. Manage your game from the Admin Hub instead." +- Team controls are hidden for admin users. + +## Subgroup Visibility & Management + +### Manage Subgroups Page + +- **Only business users can access** (individuals see an informational message). +- **Only displays subgroups with `SGid > 0`** (member subgroups). + - Subgroup 0 (admin) is hidden to avoid confusion. +- **Features:** + - Search bar to filter by subgroup name or ID. + - Card-based display showing: + - Bold subgroup name + - `ID: [SGid]` + - `Status: [Active|Inactive]` (based on `IsGameStarted` flag) + - `Teams: [true|false]` + - Create form at bottom for adding new subgroups (up to `MaxMemberSubgroups` limit). + +### Visibility API + +- `getVisibleSubgroups(gid)` returns only subgroups where `SGid > 0`. +- Used by ManageSubgroupsPage for filtering. +- Maintains admin-only (subgroup 0) separation in UI. + ## Admin Logic -- Group creator is set as admin (`SGid = 0`) and accepted. +- Group creator is auto-set as admin (`SGid = 0`) only if **business account**. +- Individual creators are set to subgroup 1 (members) and are never admins. +- Admins cannot create/join teams (blocked by `canJoinTeamForSubgroup` check). - New admin join requests go into `admin-waitlist.json` if active admins exist. - If no active admin exists, the requester is auto-promoted to accepted admin. - Group-level `ApprovedAdmins` stores accepted admin `Uid` values. @@ -42,3 +108,27 @@ This project uses split JSON files in `DB/` so each collection behaves like its - format: `YYYY-MM-DDTHH:mmZ` - example: `2026-03-17T14:42Z` +## Summary: Game Flow Decision Tree + +``` +User Registration +├─ Individual (isBusiness = false) +│ └─ Login → "Manage Private Game" +│ ├─ Has created game before? +│ │ ├─ Yes → "Manage Game" screen (team manager) +│ │ └─ No → "Create Game" screen → creates game with subgroups 0 & 1 +│ └─ Never sees Admin Hub +│ +└─ Business (isBusiness = true) + └─ Login → "Manage Private Game" + ├─ "Create New Private Game" → creates game with subgroup 0 only + └─ "Join as Admin for Private Game" → requests admin access + ├─ Active admin exists? + │ ├─ Yes → queued in admin-waitlist (pending) + │ └─ No → auto-promoted (accepted) + └─ Once accepted → Admin Hub access + ├─ Create a Game + ├─ Manage Existing Game (blocked from teams) + └─ Manage Subgroups (create/search member subgroups) +``` + diff --git a/README.md b/README.md index 40e3dbb..7a02635 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@ 1. Install dependencies ```bash - npm install + npm i ``` 2. Start the app ```bash - npx expo start + expo start --android ``` @@ -28,10 +28,9 @@ This project uses split JSON files in `DB/` so each collection behaves like its ## Files -- `meta.json`: global version and numeric ID counters. -- `users.json`: user accounts and role state (`Uid`, `Gid`, `SGid`, `TGid`, `IsAcceptedAdmin`). -- `groups.json`: top-level game groups (`Gid`), admin approvals, and group settings. -- `subgroups.json`: subgroup definitions within each group (`SGid` scoped by `Gid`), join codes, cache trigger defaults. +- `users.json`: user accounts and role state (`Uid`, `Gid`, `SGid`, `TGid`, `isBusiness`, `IsAcceptedAdmin`). +- `groups.json`: top-level game groups (`Gid`), admin approvals, group settings, creator metadata (`CreatedByUid`, `IsBusinessGroup`), and subgroup limits (`MaxMemberSubgroups`). +- `subgroups.json`: subgroup definitions within each group (`SGid` scoped by `Gid`), join codes, cache trigger defaults, and game status (`IsGameStarted`). - `subgroup-memberships.json`: membership bridge from `Uid` to `Gid`/`SGid`. - `admin-waitlist.json`: pending admin requests for groups when active admins already exist. - `teams.json`: team entities for non-admin subgroup players. @@ -44,15 +43,86 @@ This project uses split JSON files in `DB/` so each collection behaves like its - `Uid`: unique user ID. - `Gid`: unique group/game ID. -- `SGid`: subgroup ID within a group (`0` is admin subgroup). +- `SGid`: subgroup ID within a group (`0` is admin subgroup; `1+` are member subgroups). - `Tid`: unique team ID. +- New IDs are generated from the max existing value in each endpoint collection (`users`, `groups`, `teams`) so no separate counter file is required. + +## User Types and Private Game Rules + +### Individual Users (`isBusiness: false`) + +- **Private Game Creation**: Individuals can create **one** private game per account. + - Auto-creates **subgroup 0** (admins) and **subgroup 1** (members/self). + - `MaxMemberSubgroups` is set to `1` (cannot create additional subgroups). + - Individual becomes a **member** of subgroup 1, not an admin. + - Cannot view "Admin Hub" options; instead see "Manage Game" UI for active team management. + +- **Team Participation**: Individuals can create and join teams within their subgroup 1. + - Admins (those in subgroup 0) are blocked from team participation. + +### Business/School Users (`isBusiness: true`) + +- **Private Game Creation**: Businesses can create private games with flexible subgroup management. + - Auto-creates **subgroup 0** (admins) only; no auto-member subgroup. + - `MaxMemberSubgroups` is set to `999` (can create up to 999 subgroups). + - Business creator becomes an **accepted admin** of subgroup 0 immediately. + +- **Admin Participation**: Business admins (SGid 0, IsAcceptedAdmin true) **cannot participate in teams**. + - Game Lobby shows warning message and hides team creation/join controls. + - Admins must manage subgroups and games from Admin Hub and Manage Subgroups pages. + +- **Admin Approval Flow**: New admins joining a business group go into `admin-waitlist.json` if an active admin already exists. + - Auto-promoted if no active admin exists. + - Promoted admins gain accepted admin status. + +## Subgroup Visibility + +- **Manage Subgroups Page**: Only displays subgroups with `SGid > 0` (member subgroups). + - Subgroup 0 (admins) is hidden in the UI to avoid confusion. + - Features search by name/ID and card-based display with status and team info. + - Only visible to business users; individual users see an informational message. ## Admin Logic -- Group creator is set as admin (`SGid = 0`) and accepted. +- Group creator is auto-set as admin (`SGid = 0`) if business account. - New admin join requests go into `admin-waitlist.json` if active admins exist. - If no active admin exists, the requester is auto-promoted to accepted admin. - Group-level `ApprovedAdmins` stores accepted admin `Uid` values. +- Admins cannot create or join teams; they manage the game and subgroups. + +## Game Flow + +### Login / Registration + +1. User registers as Individual or Business/School. +2. Session stores `isBusiness` and `IsAcceptedAdmin` flags. + +### Individual Private Game Flow + +1. Individual selects "Manage Private Game" option. +2. Check if they have an existing private game: + - **If existing**: Navigate to "Manage Game" screen → "Open Team Manager" → join/create team. + - **If new**: Navigate to "Create Game" screen → create game → then to "Manage Game". +3. Individual can only manage their single team in subgroup 1. +4. Cannot access Admin Hub or Subgroup management. + +### Business Private Game Flow + +1. Business user selects "Manage Private Game" option. +2. Presented with: + - **Create New Private Game**: Creates a game with admin subgroup 0 only. + - **Join as Admin for Private Game**: Submit request to join an existing business group. +3. Once admin (accepted or auto-promoted), access Admin Hub: + - Create a Game + - Manage Existing Game (team/lobby controls) + - Manage Subgroups (search, filter, create member subgroups) +4. Cannot create/join teams in Game Lobby (shows warning). + +### Team Management + +- Teams can only be created/joined in **non-admin subgroups** (`SGid > 0`). +- Admin users (SGid 0 + IsAcceptedAdmin) are blocked from team participation. +- Teams are scoped per subgroup. ## Timestamp Format diff --git a/app/(routes)/create-game.tsx b/app/(routes)/create-game.tsx new file mode 100644 index 0000000..429a5e9 --- /dev/null +++ b/app/(routes)/create-game.tsx @@ -0,0 +1,3 @@ +import CreateGamePage from '../Screens/CreateGamePage'; + +export default CreateGamePage; diff --git a/app/(routes)/manage-game.tsx b/app/(routes)/manage-game.tsx new file mode 100644 index 0000000..84bbfa6 --- /dev/null +++ b/app/(routes)/manage-game.tsx @@ -0,0 +1,3 @@ +import ManageGamePage from '../Screens/ManageGamePage'; + +export default ManageGamePage; diff --git a/app/API/API.js b/app/API/API.js index 59c4854..a608f3c 100644 --- a/app/API/API.js +++ b/app/API/API.js @@ -58,6 +58,9 @@ const localPost = (endpoint, payload) => { if (resource === 'groups' && payload.action === 'approveAdmin') { return dbController.approveAdmin(payload.data); } + if (resource === 'groups' && payload.action === 'createdByUser') { + return dbController.getCreatedPrivateGameByUser(payload.data.userId); + } if (resource === 'subgroups' && payload.action === 'create') { return dbController.createSubgroup(payload.data); } @@ -167,6 +170,7 @@ const getGameTypes = async () => { const joinPrivateGame = async (payload) => APIPost('groups', { action: 'joinPrivate', data: payload }); const joinAsAdmin = async (payload) => APIPost('groups', { action: 'joinAsAdmin', data: payload }); const createPrivateGame = async (payload) => APIPost('groups', { action: 'createPrivate', data: payload }); +const getCreatedPrivateGame = async (userId) => APIPost('groups', { action: 'createdByUser', data: { userId } }); const requestAdminAccess = async (payload) => APIPost('groups', { action: 'requestAdmin', data: payload }); const approveAdminAccess = async (payload) => APIPost('groups', { action: 'approveAdmin', data: payload }); const createSubgroup = async (payload) => APIPost('subgroups', { action: 'create', data: payload }); @@ -176,6 +180,12 @@ const getSubgroups = async (gid) => { } return APIFetch(`groups/${gid}/subgroups`); }; +const getVisibleSubgroups = async (gid) => { + if (apiConfig.useLocalDb) { + return dbController.getVisibleSubgroups(gid); + } + return APIFetch(`groups/${gid}/subgroups?visibleOnly=true`); +}; const updateSubgroupCache = async (payload) => APIPut('subgroups/cache', payload); const updateGroupSettings = async (payload) => APIPut('groups/settings', payload); const createTeam = async (payload) => APIPost('teams', { action: 'create', data: payload }); @@ -215,10 +225,12 @@ export { joinPrivateGame, joinAsAdmin, createPrivateGame, + getCreatedPrivateGame, requestAdminAccess, approveAdminAccess, createSubgroup, getSubgroups, + getVisibleSubgroups, updateSubgroupCache, updateGroupSettings, createTeam, @@ -227,3 +239,11 @@ export { getTeam, getMapPoints, }; + +// Default export to satisfy Expo Router route file requirement when the folder is scanned. +// This module primarily provides named exports; returning null as a component is safe +// and prevents the router warning about missing default export. +export default function APIPlaceholder() { + return null; +} + diff --git a/app/Model/DbController.js b/app/Model/DbController.js index 1e14dc9..e179919 100644 --- a/app/Model/DbController.js +++ b/app/Model/DbController.js @@ -96,6 +96,12 @@ class DbController { return this.db.groups.find((group) => group.Gid === Number(gid)) || null; } + getCreatedPrivateGameByUser(uid) { + return this.db.groups.find( + (group) => group.CreatedByUid === Number(uid) && group.GameType === 'private', + ) || null; + } + register({ username, email, password, confirmPassword, accountType }) { if (!username || !email || !password || !confirmPassword) { throw new Error('Missing required registration fields'); @@ -213,7 +219,13 @@ class DbController { throw new Error('User not found'); } + const existing = this.getCreatedPrivateGameByUser(user.Uid); + if (existing) { + return existing; + } + const gid = this.nextId('group'); + const isBusinessGroup = Boolean(isBusiness); const group = new GroupModel({ Gid: gid, GroupName: groupName, @@ -221,9 +233,10 @@ class DbController { GameType: gameType, AdminJoinCode: this.generateUniqueJoinCode(this.db.groups, 'AdminJoinCode'), CreatedByUid: user.Uid, - MaxMemberSubgroups: isBusiness ? 999 : 1, + MaxMemberSubgroups: isBusinessGroup ? 999 : 1, TeamsEnabled: true, - ApprovedAdmins: [user.Uid], + IsBusinessGroup: isBusinessGroup, + ApprovedAdmins: isBusinessGroup ? [user.Uid] : [], CreatedAt: this.toMinuteIso(), }); @@ -236,15 +249,21 @@ class DbController { Gid: group.Gid, JoinCode: null, IsAdminGroup: true, + IsGameStarted: false, CacheTriggerMeters: 20, }); - this.createSubgroup({ gid: group.Gid, subgroupName: 'Members', cacheTriggerMeters: 20 }); + if (!isBusinessGroup) { + this.createSubgroup({ gid: group.Gid, subgroupName: 'Members', cacheTriggerMeters: 20 }); + } - this.ensureMembership(user.Uid, group.Gid, 0, true); + const ownerSgid = isBusinessGroup ? 0 : 1; + const ownerIsAcceptedAdmin = isBusinessGroup; + + this.ensureMembership(user.Uid, group.Gid, ownerSgid, ownerIsAcceptedAdmin); user.Gid = group.Gid; - user.SGid = 0; - user.IsAcceptedAdmin = true; + user.SGid = ownerSgid; + user.IsAcceptedAdmin = ownerIsAcceptedAdmin; return group.toJSON(); } @@ -356,6 +375,7 @@ class DbController { Gid: group.Gid, JoinCode: this.generateUniqueJoinCode(this.db.subgroups.filter((entry) => !entry.IsAdminGroup), 'JoinCode'), IsAdminGroup: false, + IsGameStarted: false, CacheTriggerMeters: cacheTriggerMeters, }; @@ -367,6 +387,10 @@ class DbController { return this.db.subgroups.filter((subgroup) => subgroup.Gid === Number(gid)); } + getVisibleSubgroups(gid) { + return this.getSubgroups(gid).filter((subgroup) => Number(subgroup.SGid) > 0); + } + updateSubgroupCacheSettings({ gid, sgid, triggerMeters }) { const subgroup = this.db.subgroups.find( (entry) => entry.Gid === Number(gid) && entry.SGid === Number(sgid), diff --git a/app/Model/GroupModel.js b/app/Model/GroupModel.js index 001e493..a0f5c35 100644 --- a/app/Model/GroupModel.js +++ b/app/Model/GroupModel.js @@ -8,6 +8,7 @@ class GroupModel { this.CreatedByUid = payload.CreatedByUid; this.MaxMemberSubgroups = payload.MaxMemberSubgroups; this.TeamsEnabled = Boolean(payload.TeamsEnabled); + this.IsBusinessGroup = payload.IsBusinessGroup !== undefined ? Boolean(payload.IsBusinessGroup) : true; this.ApprovedAdmins = payload.ApprovedAdmins || []; this.CreatedAt = payload.CreatedAt; } @@ -22,6 +23,7 @@ class GroupModel { CreatedByUid: this.CreatedByUid, MaxMemberSubgroups: this.MaxMemberSubgroups, TeamsEnabled: this.TeamsEnabled, + IsBusinessGroup: this.IsBusinessGroup, ApprovedAdmins: this.ApprovedAdmins, CreatedAt: this.CreatedAt, }; diff --git a/app/Model/SessionStore.js b/app/Model/SessionStore.js index d1e9ea2..9ec9486 100644 --- a/app/Model/SessionStore.js +++ b/app/Model/SessionStore.js @@ -3,6 +3,8 @@ const session = { currentGid: null, currentSGid: null, currentTGid: null, + currentIsBusiness: null, + currentIsAcceptedAdmin: false, }; const setSessionUser = (user) => { @@ -10,6 +12,8 @@ const setSessionUser = (user) => { session.currentGid = user?.Gid ?? null; session.currentSGid = user?.SGid ?? null; session.currentTGid = user?.TGid ?? null; + session.currentIsBusiness = user?.isBusiness ?? null; + session.currentIsAcceptedAdmin = Boolean(user?.IsAcceptedAdmin); }; const setSessionGroup = (gid, sgid = null) => { @@ -19,12 +23,23 @@ const setSessionGroup = (gid, sgid = null) => { } }; +const setSessionMembership = ({ gid = null, sgid = null, isAcceptedAdmin = false } = {}) => { + session.currentGid = gid; + session.currentSGid = sgid; + session.currentIsAcceptedAdmin = Boolean(isAcceptedAdmin); +}; + const setSessionTeam = (tgid) => { session.currentTGid = tgid ?? null; }; const getSession = () => ({ ...session }); -export { setSessionUser, setSessionGroup, setSessionTeam, getSession }; +export { setSessionUser, setSessionGroup, setSessionMembership, setSessionTeam, getSession }; + +// Default placeholder export to avoid Expo Router treating this non-component file as a route without a default. +export default function SessionStorePlaceholder() { + return null; +} diff --git a/app/Screens/AdminHubPage.jsx b/app/Screens/AdminHubPage.jsx index c087de5..e53f1be 100644 --- a/app/Screens/AdminHubPage.jsx +++ b/app/Screens/AdminHubPage.jsx @@ -1,12 +1,14 @@ import React from 'react'; -import { View, Text, Pressable, StyleSheet } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import {Pressable, ScrollView, StyleSheet, Text, View} from 'react-native'; +import {useNavigation} from '@react-navigation/native'; import AdminFooterNavbar from '../components/AdminFooterNavbar'; import useAdminViewModel from '../ViewModel/useAdminViewModel'; import ScreenHeader from '../components/ScreenHeader'; +import {getSession} from '../Model/SessionStore'; const AdminHubPage = () => { const navigation = useNavigation(); + const { currentIsBusiness } = getSession(); const { createGame } = useAdminViewModel(); const handleCreateGame = async () => { @@ -19,19 +21,38 @@ const AdminHubPage = () => { navigation.navigate('(routes)/game-lobby'); }; + if (!currentIsBusiness) { + return ( + + + + + As an individual, your game is managed automatically. You can view and manage your active team from the Game Lobby. + + navigation.navigate('(routes)/game-lobby')}> + Go to Game Lobby + + + + + ); + } + return ( - - Create a Game - - navigation.navigate('(routes)/game-lobby')}> - Manage Existing Game - - navigation.navigate('(routes)/manage-subgroups')}> - Manage Subgroups - + + + Create a Game + + navigation.navigate('(routes)/game-lobby')}> + Manage Existing Game + + navigation.navigate('(routes)/manage-subgroups')}> + Manage Subgroups + + @@ -49,11 +70,21 @@ const styles = StyleSheet.create({ padding: 20, gap: 12, }, + message: { + color: '#4b5563', + fontSize: 15, + lineHeight: 22, + marginBottom: 20, + }, + buttonList: { + flex: 1, + }, button: { backgroundColor: '#2563eb', borderRadius: 8, paddingVertical: 12, alignItems: 'center', + marginBottom: 10, }, buttonText: { color: '#ffffff', diff --git a/app/Screens/CreateGamePage.jsx b/app/Screens/CreateGamePage.jsx new file mode 100644 index 0000000..0b880fb --- /dev/null +++ b/app/Screens/CreateGamePage.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import {StyleSheet, Text, View} from 'react-native'; +import ScreenHeader from '../components/ScreenHeader'; + +const CreateGamePage = () => { + return ( + + + + Create game setup goes here. + + + ); +}; + +const styles = StyleSheet.create({ + wrapper: { + flex: 1, + backgroundColor: '#ffffff', + }, + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + body: { + color: '#4b5563', + fontSize: 15, + }, +}); + +export default CreateGamePage; + diff --git a/app/Screens/GameLobbyPage.jsx b/app/Screens/GameLobbyPage.jsx index 1d78c94..f37a649 100644 --- a/app/Screens/GameLobbyPage.jsx +++ b/app/Screens/GameLobbyPage.jsx @@ -1,15 +1,19 @@ -import React, { useState } from 'react'; -import { View, Text, Pressable, TextInput, StyleSheet } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import React, {useState} from 'react'; +import {Pressable, StyleSheet, Text, TextInput, View} from 'react-native'; +import {useNavigation} from '@react-navigation/native'; import MemberFooterNavbar from '../components/MemberFooterNavbar'; import useLobbyViewModel from '../ViewModel/useLobbyViewModel'; import ScreenHeader from '../components/ScreenHeader'; +import {getSession} from '../Model/SessionStore'; const GameLobbyPage = () => { const navigation = useNavigation(); + const { currentSGid, currentIsAcceptedAdmin } = getSession(); const { teamCode, error, setTeamCode, createLobbyTeam, joinLobbyTeam } = useLobbyViewModel(); const [hasTeam, setHasTeam] = useState(false); + const isAdminUser = currentSGid === 0 && currentIsAcceptedAdmin; + const handleCreateTeam = async () => { const team = await createLobbyTeam({ teamName: `Team-${Date.now().toString().slice(-4)}`, @@ -34,20 +38,31 @@ const GameLobbyPage = () => { - - - Join Team - - - Create Team - + {isAdminUser && ( + + + Admins cannot participate in teams. Manage your game from the Admin Hub instead. + + + )} + {!isAdminUser && ( + <> + + + Join Team + + + Create Team + + + )} navigation.navigate('(routes)/map')}> Open Map @@ -95,6 +110,19 @@ const styles = StyleSheet.create({ errorText: { color: '#dc2626', }, + adminMessage: { + backgroundColor: '#fef3c7', + borderWidth: 1, + borderColor: '#fcd34d', + borderRadius: 8, + padding: 12, + marginBottom: 12, + }, + adminMessageText: { + color: '#92400e', + fontSize: 13, + fontWeight: '500', + }, }); export default GameLobbyPage; diff --git a/app/Screens/ManageGamePage.jsx b/app/Screens/ManageGamePage.jsx new file mode 100644 index 0000000..9b15b44 --- /dev/null +++ b/app/Screens/ManageGamePage.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import {Pressable, StyleSheet, Text, View} from 'react-native'; +import {useNavigation} from '@react-navigation/native'; +import ScreenHeader from '../components/ScreenHeader'; + +const ManageGamePage = () => { + const navigation = useNavigation(); + + return ( + + + + Manage your active team from here. + navigation.navigate('(routes)/team')}> + Open Team Manager + + + + ); +}; + +const styles = StyleSheet.create({ + wrapper: { + flex: 1, + backgroundColor: '#ffffff', + }, + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + gap: 12, + padding: 20, + }, + body: { + color: '#4b5563', + fontSize: 15, + }, + button: { + backgroundColor: '#2563eb', + borderRadius: 8, + paddingHorizontal: 16, + paddingVertical: 12, + }, + buttonText: { + color: '#ffffff', + fontWeight: '600', + }, +}); + +export default ManageGamePage; + diff --git a/app/Screens/ManagePrivateSubtaskPage.jsx b/app/Screens/ManagePrivateSubtaskPage.jsx index 4869054..f7d91c6 100644 --- a/app/Screens/ManagePrivateSubtaskPage.jsx +++ b/app/Screens/ManagePrivateSubtaskPage.jsx @@ -1,6 +1,6 @@ -import React, { useState } from 'react'; -import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import React, {useState} from 'react'; +import {Pressable, StyleSheet, Text, TextInput, View} from 'react-native'; +import {useNavigation} from '@react-navigation/native'; import useManagePrivateSubtaskViewModel from '../ViewModel/useManagePrivateSubtaskViewModel'; import ScreenHeader from '../components/ScreenHeader'; @@ -9,14 +9,25 @@ const ManagePrivateSubtaskPage = () => { const { businessOrSchoolName, error, + isBusinessUser, setBusinessOrSchoolName, createNewPrivateGame, + resolveIndividualPrivateRoute, joinAdminForPrivate, } = useManagePrivateSubtaskViewModel(); const [submitting, setSubmitting] = useState(false); const handleCreate = async () => { setSubmitting(true); + if (!isBusinessUser) { + const nextRoute = await resolveIndividualPrivateRoute(); + setSubmitting(false); + if (nextRoute) { + navigation.navigate(nextRoute); + } + return; + } + const group = await createNewPrivateGame(); setSubmitting(false); if (!group) { @@ -40,20 +51,22 @@ const ManagePrivateSubtaskPage = () => { - {submitting ? 'Please wait...' : 'Create New Private Game'} + {submitting ? 'Please wait...' : isBusinessUser ? 'Create New Private Game' : 'Create or Manage My Game'} - - Business/School Name is only required for admin join. - - - Join as Admin for Private Game - - + {isBusinessUser && ( + + Business/School Name is only required for admin join. + + + Join as Admin for Private Game + + + )} {!!error && {error}} diff --git a/app/Screens/ManageSubgroupsPage.jsx b/app/Screens/ManageSubgroupsPage.jsx index e2ab794..d15d4bd 100644 --- a/app/Screens/ManageSubgroupsPage.jsx +++ b/app/Screens/ManageSubgroupsPage.jsx @@ -1,54 +1,93 @@ import React from 'react'; -import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native'; +import {Pressable, ScrollView, StyleSheet, Text, TextInput, View} from 'react-native'; import useSubgroupAdminViewModel from '../ViewModel/useSubgroupAdminViewModel'; import AdminFooterNavbar from '../components/AdminFooterNavbar'; import ScreenHeader from '../components/ScreenHeader'; +import EntityCard from '../components/UI/EntityCard'; +import SearchBar from '../components/UI/SearchBar'; +import {getSession} from '../Model/SessionStore'; const ManageSubgroupsPage = () => { const { - subgroups, + filteredSubgroups, + searchQuery, subgroupName, cacheTriggerMeters, error, + setSearchQuery, setSubgroupName, setCacheTriggerMeters, addSubgroup, updateCacheForSubgroup, } = useSubgroupAdminViewModel(); + const { currentIsBusiness } = getSession(); + + if (!currentIsBusiness) { + return ( + + + + + Subgroups are managed automatically for individual accounts. + + + + + ); + } return ( - {subgroups.map((subgroup) => ( - - - SGid {subgroup.SGid} | {subgroup.SubGroupName} | Code: {subgroup.JoinCode || 'ADMIN'} | Trigger: {subgroup.CacheTriggerMeters}m - - updateCacheForSubgroup(subgroup.SGid, (subgroup.CacheTriggerMeters || 20) + 5)} - > - +5m - - - ))} - - - - Create Subgroup + + + Create Subgroup + + + {filteredSubgroups.length === 0 && ( + + {searchQuery ? 'No subgroups match your search.' : 'No subgroups yet. Create one to get started.'} + + )} + {filteredSubgroups.map((subgroup) => ( + { + updateCacheForSubgroup(subgroup.SGid, (subgroup.CacheTriggerMeters || 20) + 5); + }} + /> + ))} + + + Create New Subgroup + + + + Create + + {!!error && {error}} @@ -63,30 +102,50 @@ const styles = StyleSheet.create({ }, container: { flex: 1, - gap: 10, padding: 20, + flexDirection: 'column', }, - row: { - borderWidth: 1, - borderColor: '#d1d5db', - borderRadius: 8, - padding: 10, - gap: 8, + message: { + textAlign: 'center', + color: '#6b7280', + fontSize: 15, }, - rowText: { - color: '#111827', - fontSize: 13, - }, - inlineButton: { - alignSelf: 'flex-start', + createButton: { backgroundColor: '#2563eb', - paddingHorizontal: 10, - paddingVertical: 6, - borderRadius: 6, + borderWidth: 1, + borderColor: '#2563eb', + borderRadius: 8, + paddingVertical: 10, + paddingHorizontal: 12, + alignItems: 'center', + marginBottom: 12, }, - inlineButtonText: { + createButtonText: { color: '#ffffff', fontWeight: '600', + fontSize: 14, + }, + cardList: { + flex: 1, + gap: 8, + }, + emptyText: { + textAlign: 'center', + color: '#9ca3af', + fontSize: 14, + marginTop: 20, + }, + createForm: { + gap: 10, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: '#d1d5db', + marginTop: 12, + }, + formTitle: { + fontSize: 13, + fontWeight: '600', + color: '#111827', }, input: { borderWidth: 1, @@ -95,18 +154,19 @@ const styles = StyleSheet.create({ paddingHorizontal: 12, paddingVertical: 10, }, - button: { - backgroundColor: '#2563eb', + submitButton: { + backgroundColor: '#4b5563', borderRadius: 8, alignItems: 'center', - paddingVertical: 12, + paddingVertical: 10, }, - buttonText: { + submitButtonText: { color: '#ffffff', fontWeight: '600', }, errorText: { color: '#dc2626', + marginTop: 8, }, }); diff --git a/app/ViewModel/useManagePrivateSubtaskViewModel.js b/app/ViewModel/useManagePrivateSubtaskViewModel.js index d8005f3..30ed155 100644 --- a/app/ViewModel/useManagePrivateSubtaskViewModel.js +++ b/app/ViewModel/useManagePrivateSubtaskViewModel.js @@ -1,11 +1,21 @@ -import { useState } from 'react'; -import { createPrivateGame, joinAsAdmin } from '../API/API'; -import { getSession, setSessionGroup } from '../Model/SessionStore'; +import {useState} from 'react'; +import {createPrivateGame, getCreatedPrivateGame, joinAsAdmin} from '../API/API'; +import {getSession, setSessionMembership} from '../Model/SessionStore'; const useManagePrivateSubtaskViewModel = () => { const [businessOrSchoolName, setBusinessOrSchoolName] = useState(''); const [error, setError] = useState(''); + const isBusinessUser = Boolean(getSession().currentIsBusiness); + + const findExistingPrivateGame = async () => { + const { currentUid } = getSession(); + if (!currentUid) { + return null; + } + return getCreatedPrivateGame(currentUid); + }; + const createNewPrivateGame = async () => { setError(''); const { currentUid } = getSession(); @@ -14,9 +24,15 @@ const useManagePrivateSubtaskViewModel = () => { userId: currentUid, groupName: businessOrSchoolName.trim() || 'New Private Game', businessOrSchoolName: businessOrSchoolName.trim() || 'Unnamed Business', - isBusiness: true, + isBusiness: isBusinessUser, }); - setSessionGroup(group.Gid, 0); + + if (isBusinessUser) { + setSessionMembership({ gid: group.Gid, sgid: 0, isAcceptedAdmin: true }); + } else { + setSessionMembership({ gid: group.Gid, sgid: 1, isAcceptedAdmin: false }); + } + return group; } catch (e) { setError(e.message); @@ -24,6 +40,26 @@ const useManagePrivateSubtaskViewModel = () => { } }; + const resolveIndividualPrivateRoute = async () => { + setError(''); + try { + const existingGroup = await findExistingPrivateGame(); + if (existingGroup) { + setSessionMembership({ gid: existingGroup.Gid, sgid: 1, isAcceptedAdmin: false }); + return '(routes)/manage-game'; + } + + const created = await createNewPrivateGame(); + if (!created) { + return null; + } + return '(routes)/create-game'; + } catch (e) { + setError(e.message); + return null; + } + }; + const joinAdminForPrivate = async () => { setError(''); const { currentUid } = getSession(); @@ -34,7 +70,10 @@ const useManagePrivateSubtaskViewModel = () => { }); const gid = response?.Gid ?? null; if (gid !== null) { - setSessionGroup(gid, 0); + setSessionMembership({ gid, sgid: 0, isAcceptedAdmin: !response.queued }); + } + if (response?.queued) { + setError('Admin access request submitted. You will see admin tools after approval.'); } return response; } catch (e) { @@ -46,8 +85,10 @@ const useManagePrivateSubtaskViewModel = () => { return { businessOrSchoolName, error, + isBusinessUser, setBusinessOrSchoolName, createNewPrivateGame, + resolveIndividualPrivateRoute, joinAdminForPrivate, }; }; diff --git a/app/ViewModel/useSubgroupAdminViewModel.js b/app/ViewModel/useSubgroupAdminViewModel.js index c20c9d9..6937337 100644 --- a/app/ViewModel/useSubgroupAdminViewModel.js +++ b/app/ViewModel/useSubgroupAdminViewModel.js @@ -1,22 +1,35 @@ -import { useCallback, useEffect, useState } from 'react'; -import { createSubgroup, getSubgroups, updateSubgroupCache } from '../API/API'; -import { getSession } from '../Model/SessionStore'; +import {useCallback, useEffect, useMemo, useState} from 'react'; +import {createSubgroup, getVisibleSubgroups, updateSubgroupCache} from '../API/API'; +import {getSession} from '../Model/SessionStore'; const useSubgroupAdminViewModel = () => { const [subgroups, setSubgroups] = useState([]); const [subgroupName, setSubgroupName] = useState(''); const [cacheTriggerMeters, setCacheTriggerMeters] = useState('20'); const [error, setError] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); const { currentGid } = getSession(); const loadSubgroups = useCallback(async () => { if (!currentGid) { return; } - const rows = await getSubgroups(currentGid); + const rows = await getVisibleSubgroups(currentGid); setSubgroups(rows); }, [currentGid]); + const filteredSubgroups = useMemo(() => { + if (!searchQuery.trim()) { + return subgroups; + } + const q = searchQuery.trim().toLowerCase(); + return subgroups.filter((sg) => { + const nameMatch = sg.SubGroupName.toLowerCase().includes(q); + const idMatch = String(sg.SGid) === q; + return nameMatch || idMatch; + }); + }, [subgroups, searchQuery]); + useEffect(() => { loadSubgroups(); }, [loadSubgroups]); @@ -52,9 +65,12 @@ const useSubgroupAdminViewModel = () => { return { subgroups, + filteredSubgroups, + searchQuery, subgroupName, cacheTriggerMeters, error, + setSearchQuery, setSubgroupName, setCacheTriggerMeters, addSubgroup, diff --git a/app/_layout.tsx b/app/_layout.tsx index 5fc8c04..259b8ab 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,14 +1,31 @@ -import { Stack } from "expo-router"; +import {Stack} from "expo-router"; export default function RootLayout() { return ( - - - + + + + + diff --git a/app/components/ScreenHeader.jsx b/app/components/ScreenHeader.jsx index ca9cb6b..52e94d4 100644 --- a/app/components/ScreenHeader.jsx +++ b/app/components/ScreenHeader.jsx @@ -1,13 +1,69 @@ import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import {Pressable, StyleSheet, Text, View} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {useRouter, useSegments} from 'expo-router'; +import {getSession, setSessionGroup, setSessionTeam, setSessionUser} from '../Model/SessionStore'; -const ScreenHeader = ({ title }) => { +const ScreenHeader = ({ title, showBack = true }) => { const insets = useSafeAreaInsets(); + const router = useRouter(); + const segments = useSegments(); + const session = getSession(); + + // Check if user is logged in by checking if currentUid exists + const isLoggedIn = Boolean(session.currentUid); + + // Pages where back navigation should be disabled + const blockedLastSegments = ['login', 'register', 'index']; + + // compute last meaningful segment (filter out route groups like "(routes)") + const meaningfulSegments = segments.filter((s) => !s.startsWith('(')); + const lastSegment = meaningfulSegments.length > 0 ? meaningfulSegments[meaningfulSegments.length - 1] : 'index'; + const isBlocked = blockedLastSegments.includes(lastSegment); + + + const handleBack = () => { + if (isBlocked || !showBack) return; + try { + router.back(); + } catch { + // fallback: nothing + } + }; + + const handleLogout = () => { + // clear session and route to login (replace so user cannot go back) + try { + setSessionUser(null); + setSessionGroup(null); + setSessionTeam(null); + } catch { + // ignore + } + try { + router.replace('/login'); + } catch { + // noop + } + }; return ( + {showBack && !isBlocked ? ( + + ← + + ) : ( + + )} {title} + {isLoggedIn ? ( + + ⎋ + + ) : ( + + )} ); }; @@ -18,13 +74,34 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingBottom: 12, minHeight: 72, - justifyContent: 'flex-end', + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'space-between', }, title: { color: '#ffffff', fontSize: 20, fontWeight: '700', }, + backButton: { + width: 44, + height: 44, + justifyContent: 'center', + alignItems: 'center', + }, + backText: { + color: '#ffffff', + fontSize: 22, + lineHeight: 22, + }, + backButtonPlaceholder: { + width: 44, + height: 44, + }, + rightPlaceholder: { + width: 44, + height: 44, + }, }); export default ScreenHeader; diff --git a/app/components/UI/EntityCard.jsx b/app/components/UI/EntityCard.jsx new file mode 100644 index 0000000..9458b2a --- /dev/null +++ b/app/components/UI/EntityCard.jsx @@ -0,0 +1,64 @@ +import React from 'react'; +import {Pressable, StyleSheet, Text, View} from 'react-native'; + +const EntityCard = ({ + title, + subtitle, + metaLabel1, + metaValue1, + metaLabel2, + metaValue2, + metaLabel3, + metaValue3, + onPress, +}) => { + return ( + + {title} + {subtitle && {subtitle}} + + {metaLabel1 && metaValue1 && ( + {metaLabel1}: {metaValue1} + )} + {metaLabel2 && metaValue2 && ( + {metaLabel2}: {metaValue2} + )} + {metaLabel3 && metaValue3 && ( + {metaLabel3}: {metaValue3} + )} + + + ); +}; + +const styles = StyleSheet.create({ + card: { + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 8, + padding: 12, + marginBottom: 10, + backgroundColor: '#ffffff', + }, + title: { + fontSize: 15, + fontWeight: '700', + color: '#111827', + marginBottom: 4, + }, + subtitle: { + fontSize: 13, + color: '#6b7280', + marginBottom: 8, + }, + metaContainer: { + gap: 4, + }, + metaText: { + fontSize: 13, + color: '#4b5563', + }, +}); + +export default EntityCard; + diff --git a/app/components/UI/SearchBar.jsx b/app/components/UI/SearchBar.jsx new file mode 100644 index 0000000..b7a111c --- /dev/null +++ b/app/components/UI/SearchBar.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import {StyleSheet, TextInput, View} from 'react-native'; + +const SearchBar = ({ placeholder = 'Search...', value, onChangeText, style = {} }) => { + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + marginBottom: 12, + }, + input: { + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 14, + color: '#111827', + }, +}); + +export default SearchBar; + diff --git a/package-lock.json b/package-lock.json index bb71bee..4e2e1f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11133,9 +11133,10 @@ } }, "node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", "engines": { "node": ">=18.17" }