Add GlobalProfileScreen and integrate global user management features

- Introduced GlobalProfileScreen for user profile management.
- Implemented user data fetching and updating via global API.
- Added session handling for global user state.
- Updated navigation to include profile access from the top navbar.
- Created global data caching mechanisms for improved performance.
This commit is contained in:
bobbert committed 2026-04-09 20:49:43 +01:00
1 parent 2a83d0608f
commit 53fddc8220
15 files changed
+1129 -210

No files matched your search

+331 -54
View File
@@ -1,92 +1,366 @@
import {useCallback, useMemo} from "react";
import API from "../components/API/API";
import globalApiConfig from "../components/API/api.json";
import {
clearGlobalDataCache,
clearGlobalDataCacheByPrefix,
getOrFetchGlobalData,
writeGlobalDataCache,
} from "../utils/globalDataCache";
const GLOBAL_BASE = process.env.EXPO_PUBLIC_GLOBAL_API_BASE;
const GLOBAL_KEY = process.env.EXPO_PUBLIC_GLOBAL_API_KEY;
const GLOBAL_BASE = String(
process.env.EXPO_PUBLIC_GLOBAL_API_BASE || globalApiConfig.GLOBAL_API_BASE_URL || "",
).trim();
const GLOBAL_KEY = String(
process.env.EXPO_PUBLIC_GLOBAL_API_KEY || globalApiConfig.GLOBAL_API_KEY || "",
).trim();
const DEFAULT_GLOBAL_PROFILE_IMAGE_URL =
String(globalApiConfig.DEFAULT_GLOBAL_PROFILE_IMAGE_URL || "").trim() ||
"https://placehold.co/256x256/png";
// Appends the API key as a query param to any URL
const isGlobalApiReady = () => Boolean(GLOBAL_BASE);
const CACHE_TTL = {
events: 20000,
players: 12000,
caches: 20000,
finds: 8000,
users: 15000,
};
const CACHE_KEY = {
publicEvents: "global:events:public",
users: "global:users",
event: (eventId) => `global:event:${eventId}`,
playersByEvent: (eventId) => `global:players:event:${eventId}`,
cachesByEvent: (eventId) => `global:caches:event:${eventId}`,
cacheById: (cacheId) => `global:cache:${cacheId}`,
findsByEvent: (eventId) => `global:finds:event:${eventId}`,
findsByPlayer: (playerId) => `global:finds:player:${playerId}`,
userById: (userId) => `global:user:${userId}`,
};
// Appends the API key as a query param when provided.
const withKey = (url) => {
if (!GLOBAL_KEY) return url;
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}key=${GLOBAL_KEY}`;
return `${url}${separator}key=${encodeURIComponent(GLOBAL_KEY)}`;
};
const unwrapList = (payload) => {
if (!payload) return [];
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload.value)) return payload.value;
if (Array.isArray(payload.Value)) return payload.Value;
return [];
};
const unwrapSingle = (payload) => {
if (!payload) return null;
if (Array.isArray(payload)) return payload[0] || null;
if (Array.isArray(payload.value)) return payload.value[0] || null;
if (Array.isArray(payload.Value)) return payload.Value[0] || null;
if (payload.value && typeof payload.value === "object") return payload.value;
if (payload.Value && typeof payload.Value === "object") return payload.Value;
return typeof payload === "object" ? payload : null;
};
const randomToken = (size = 6) => {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let token = "";
for (let i = 0; i < size; i += 1) {
token += chars.charAt(Math.floor(Math.random() * chars.length));
}
return token;
};
const firstNonEmpty = (...values) => {
for (const value of values) {
const normalized = String(value || "").trim();
if (normalized) return normalized;
}
return "";
};
const withMinLength = (value, min, fallback) => {
const normalized = firstNonEmpty(value);
if (normalized.length >= min) return normalized;
return fallback;
};
const splitUsername = (username) => {
const cleaned = String(username || "").trim();
if (!cleaned) return {first: "Global", last: "User"};
const parts = cleaned.split(/[\s._-]+/).filter(Boolean);
if (parts.length === 1) {
return {first: parts[0], last: "User"};
}
return {
first: parts[0],
last: parts.slice(1).join(" "),
};
};
const buildGlobalUserPayload = (userId, privateUser = null) => {
const privateUsername = firstNonEmpty(privateUser?.username);
const nameParts = splitUsername(privateUsername);
const syncedUsername = `mad_uid_${userId}`;
return {
UserID: userId,
UserFirstname: firstNonEmpty(privateUser?.firstName, nameParts.first, "Global"),
UserLastname: firstNonEmpty(privateUser?.lastName, nameParts.last, "User"),
// The live global API validates phone length >= 12.
UserPhone: withMinLength(privateUser?.phone, 12, "000000000000"),
UserUsername: syncedUsername,
// The live global API validates password length >= 8.
UserPassword: withMinLength(
privateUser?.passwordHash,
8,
`PW_${randomToken(10)}`,
),
UserLatitude: Number((Math.random() * 180 - 90).toFixed(6)),
UserLongitude: Number((Math.random() * 360 - 180).toFixed(6)),
UserTimestamp: Date.now(),
UserImageURL: firstNonEmpty(
privateUser?.imageUrl,
DEFAULT_GLOBAL_PROFILE_IMAGE_URL,
),
};
};
const useGlobalHook = () => {
// Initialisations ---------------------
const eventsEndpoint = `${GLOBAL_BASE}/api/events`;
const playersEndpoint = `${GLOBAL_BASE}/api/players`;
const cachesEndpoint = `${GLOBAL_BASE}/api/caches`;
const findsEndpoint = `${GLOBAL_BASE}/api/finds`;
const usersEndpoint = `${GLOBAL_BASE}/api/users`;
const endpoints = useMemo(
() => ({
events: `${GLOBAL_BASE}/api/events`,
players: `${GLOBAL_BASE}/api/players`,
caches: `${GLOBAL_BASE}/api/caches`,
finds: `${GLOBAL_BASE}/api/finds`,
users: `${GLOBAL_BASE}/api/users`,
}),
[],
);
// Handlers ----------------------------
// Events
const getPublicEvents = async () => {
const response = await API.get(withKey(eventsEndpoint));
if (!response.isSuccess) return [];
return response.result.filter((e) => e.EventIspublic);
};
const getPublicEvents = useCallback(async (options = {}) => {
if (!isGlobalApiReady()) return [];
return getOrFetchGlobalData(
CACHE_KEY.publicEvents,
async () => {
const response = await API.get(withKey(endpoints.events));
if (!response.isSuccess) return [];
const rows = unwrapList(response.result);
return rows.filter((e) => e.EventIspublic);
},
{ttlMs: CACHE_TTL.events, forceRefresh: options.forceRefresh},
);
}, [endpoints.events]);
const getEvent = async (eventId) => {
const response = await API.get(withKey(`${eventsEndpoint}/${eventId}`));
return response.isSuccess ? response.result : null;
};
const getEvent = useCallback(async (eventId, options = {}) => {
if (!isGlobalApiReady()) return null;
return getOrFetchGlobalData(
CACHE_KEY.event(eventId),
async () => {
const response = await API.get(withKey(`${endpoints.events}/${eventId}`));
if (!response.isSuccess) return null;
return unwrapSingle(response.result);
},
{ttlMs: CACHE_TTL.events, forceRefresh: options.forceRefresh},
);
}, [endpoints.events]);
// Players
const getPlayersByEvent = async (eventId) => {
const response = await API.get(
withKey(`${playersEndpoint}/events/${eventId}`),
const getPlayersByEvent = useCallback(async (eventId, options = {}) => {
if (!isGlobalApiReady()) return [];
return getOrFetchGlobalData(
CACHE_KEY.playersByEvent(eventId),
async () => {
const response = await API.get(withKey(`${endpoints.players}/events/${eventId}`));
if (!response.isSuccess) return [];
return unwrapList(response.result);
},
{ttlMs: CACHE_TTL.players, forceRefresh: options.forceRefresh},
);
return response.isSuccess ? response.result : [];
};
}, [endpoints.players]);
const joinEvent = async (data) => {
const response = await API.post(withKey(playersEndpoint), data);
return response.isSuccess ? response.result : null;
};
const joinEvent = useCallback(async (data) => {
if (!isGlobalApiReady()) return null;
const response = await API.post(withKey(endpoints.players), data);
if (!response.isSuccess) return null;
const joined = unwrapSingle(response.result);
const eventId = data?.PlayerEventID ?? joined?.PlayerEventID;
if (eventId !== undefined && eventId !== null) {
clearGlobalDataCache(CACHE_KEY.playersByEvent(eventId));
}
return joined;
}, [endpoints.players]);
// Caches
const getCachesByEvent = async (eventId) => {
const response = await API.get(
withKey(`${cachesEndpoint}/events/${eventId}`),
const getCachesByEvent = useCallback(async (eventId, options = {}) => {
if (!isGlobalApiReady()) return [];
return getOrFetchGlobalData(
CACHE_KEY.cachesByEvent(eventId),
async () => {
const response = await API.get(withKey(`${endpoints.caches}/events/${eventId}`));
if (!response.isSuccess) return [];
return unwrapList(response.result);
},
{ttlMs: CACHE_TTL.caches, forceRefresh: options.forceRefresh},
);
return response.isSuccess ? response.result : [];
};
}, [endpoints.caches]);
const getCache = async (cacheId) => {
const response = await API.get(withKey(`${cachesEndpoint}/${cacheId}`));
return response.isSuccess ? response.result : null;
};
const getCache = useCallback(async (cacheId, options = {}) => {
if (!isGlobalApiReady()) return null;
return getOrFetchGlobalData(
CACHE_KEY.cacheById(cacheId),
async () => {
const response = await API.get(withKey(`${endpoints.caches}/${cacheId}`));
if (!response.isSuccess) return null;
return unwrapSingle(response.result);
},
{ttlMs: CACHE_TTL.caches, forceRefresh: options.forceRefresh},
);
}, [endpoints.caches]);
// Finds
const getFindsByEvent = async (eventId) => {
const response = await API.get(
withKey(`${findsEndpoint}/events/${eventId}`),
const getFindsByEvent = useCallback(async (eventId, options = {}) => {
if (!isGlobalApiReady()) return [];
return getOrFetchGlobalData(
CACHE_KEY.findsByEvent(eventId),
async () => {
const response = await API.get(withKey(`${endpoints.finds}/events/${eventId}`));
if (!response.isSuccess) return [];
return unwrapList(response.result);
},
{ttlMs: CACHE_TTL.finds, forceRefresh: options.forceRefresh},
);
return response.isSuccess ? response.result : [];
};
}, [endpoints.finds]);
const getFindsByPlayer = async (playerId) => {
const response = await API.get(
withKey(`${findsEndpoint}/players/${playerId}`),
const getFindsByPlayer = useCallback(async (playerId, options = {}) => {
if (!isGlobalApiReady()) return [];
return getOrFetchGlobalData(
CACHE_KEY.findsByPlayer(playerId),
async () => {
const response = await API.get(withKey(`${endpoints.finds}/players/${playerId}`));
if (!response.isSuccess) return [];
return unwrapList(response.result);
},
{ttlMs: CACHE_TTL.finds, forceRefresh: options.forceRefresh},
);
return response.isSuccess ? response.result : [];
};
}, [endpoints.finds]);
const logFind = async (data) => {
const response = await API.post(withKey(findsEndpoint), data);
return response.isSuccess ? response.result : null;
};
const logFind = useCallback(async (data) => {
if (!isGlobalApiReady()) return null;
const response = await API.post(withKey(endpoints.finds), data);
if (!response.isSuccess) return null;
const find = unwrapSingle(response.result);
if (data?.FindPlayerID !== undefined && data?.FindPlayerID !== null) {
clearGlobalDataCache(CACHE_KEY.findsByPlayer(data.FindPlayerID));
}
// Event ID is not always present in payload, so clear all event-level find caches.
clearGlobalDataCacheByPrefix("global:finds:event:");
return find;
}, [endpoints.finds]);
// Users
const getUser = async (userId) => {
const response = await API.get(withKey(`${usersEndpoint}/${userId}`));
return response.isSuccess ? response.result : null;
};
const getUsers = useCallback(async (options = {}) => {
if (!isGlobalApiReady()) return [];
return getOrFetchGlobalData(
CACHE_KEY.users,
async () => {
const response = await API.get(withKey(endpoints.users));
if (!response.isSuccess) return [];
return unwrapList(response.result);
},
{ttlMs: CACHE_TTL.users, forceRefresh: options.forceRefresh},
);
}, [endpoints.users]);
const getUser = useCallback(async (userId, options = {}) => {
if (!isGlobalApiReady()) return null;
const key = CACHE_KEY.userById(userId);
return getOrFetchGlobalData(
key,
async () => {
// Some global API deployments expose list-only user routes.
const byIdResponse = await API.get(withKey(`${endpoints.users}/${userId}`));
if (byIdResponse.isSuccess) {
const single = unwrapSingle(byIdResponse.result);
if (single) return single;
}
const users = await getUsers({forceRefresh: options.forceRefresh});
return users.find((user) => String(user.UserID) === String(userId)) || null;
},
{ttlMs: CACHE_TTL.users, forceRefresh: options.forceRefresh},
);
}, [endpoints.users, getUsers]);
const createUser = useCallback(async (payload) => {
if (!isGlobalApiReady()) return null;
const response = await API.post(withKey(endpoints.users), payload);
if (!response.isSuccess) return null;
const created = unwrapSingle(response.result);
const createdUserId = created?.UserID ?? payload?.UserID;
if (createdUserId !== undefined && createdUserId !== null) {
writeGlobalDataCache(
CACHE_KEY.userById(createdUserId),
created || payload,
CACHE_TTL.users,
);
}
clearGlobalDataCache(CACHE_KEY.users);
return created;
}, [endpoints.users]);
const updateUser = useCallback(async (userId, payload) => {
if (!isGlobalApiReady()) return null;
const response = await API.put(withKey(`${endpoints.users}/${userId}`), payload);
if (!response.isSuccess) return null;
const updated = unwrapSingle(response.result) || payload;
writeGlobalDataCache(CACHE_KEY.userById(userId), updated, CACHE_TTL.users);
clearGlobalDataCache(CACHE_KEY.users);
return updated;
}, [endpoints.users]);
// Only called by Join Global Game flow to mirror a private user into global API schema.
const ensureGlobalUser = useCallback(async (userId, privateUser = null) => {
if (!userId || !isGlobalApiReady()) return null;
const seed = buildGlobalUserPayload(userId, privateUser);
// Primary lookup by deterministic username bridge.
const users = await getUsers();
const bySyncedUsername = users.find(
(user) => String(user.UserUsername) === String(seed.UserUsername),
);
if (bySyncedUsername) return bySyncedUsername;
const existing = await getUser(userId);
if (existing) return existing;
const created = await createUser(seed);
if (created) return created;
// Final fallback: check list endpoint again after attempted creation.
const refreshed = await getUsers({forceRefresh: true});
return (
refreshed.find((user) => String(user.UserUsername) === String(seed.UserUsername)) ||
refreshed.find((user) => String(user.UserID) === String(userId)) ||
null
);
}, [createUser, getUser, getUsers]);
// Return --------------------------------
return {
isGlobalApiReady,
defaultGlobalProfileImageUrl: DEFAULT_GLOBAL_PROFILE_IMAGE_URL,
getPublicEvents,
getEvent,
getPlayersByEvent,
@@ -97,6 +371,9 @@ const useGlobalHook = () => {
getFindsByPlayer,
logFind,
getUser,
createUser,
updateUser,
ensureGlobalUser,
};
};