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"
}