Rebranding de l'application mobile

This commit is contained in:
Feror 2026-03-30 10:38:01 +02:00
parent 813ffe2171
commit d8121e74a4
28 changed files with 2583 additions and 875 deletions

View file

@ -1,4 +1,4 @@
# Negopoly Mobile (React Native)
# Crédit Mabligop Mobile (React Native)
## Development server
Set the dev API base URL to your machine IP so the app can reach the Bun server.
@ -15,3 +15,21 @@ The app will use `https://negopoly.fr` automatically in production builds.
```
npm run dev
```
## App Store Screenshots
Generate the player-facing iOS screenshots with:
```sh
npm run screenshots:ios
```
The script builds the iOS app in `Release` for the simulator, opens the app with special deep links for the player flows only, and writes PNGs into `../ScreenShots/`.
It requires Xcode with the iOS Simulator platform/runtime installed and simulator devices matching the default targets (`iPhone 17 Pro Max` and `iPad Air 13-inch`).
Optional flags:
```sh
npm run screenshots:ios -- --skip-build
npm run screenshots:ios -- --output ../MyScreenShots
```

View file

@ -1,6 +1,6 @@
{
"expo": {
"name": "Negopoly Companion",
"name": "Crédit Mabligop",
"slug": "negopoly-companion",
"version": "1.0.0",
"platforms": ["ios", "android"],
@ -12,20 +12,21 @@
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
"backgroundColor": "#f5efe6"
},
"ios": {
"bundleIdentifier": "fr.negopoly.app",
"supportsTablet": true,
"appleTeamId": "VD9WQ6BYX2",
"associatedDomains": ["applinks:negopoly.fr"]
"associatedDomains": ["applinks:negopoly.fr"],
"icon": "./assets/AppIcon.icon"
},
"android": {
"package": "fr.negopoly.app",
"googleServicesFile": "./google-services.json",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
"backgroundColor": "#f5efe6"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View file

@ -0,0 +1,95 @@
{
"fill" : {
"automatic-gradient" : "display-p3:0.96783,0.94809,0.82815,1.00000"
},
"groups" : [
{
"layers" : [
{
"fill-specializations" : [
{
"appearance" : "dark",
"value" : {
"automatic-gradient" : "display-p3:0.97626,0.96665,1.00000,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"solid" : "srgb:1.00000,1.00000,1.00000,1.00000"
}
}
],
"glass" : false,
"hidden" : false,
"image-name" : "LBTRDlogo.png",
"name" : "LBTRDlogo",
"position" : {
"scale" : 0.08,
"translation-in-points" : [
357.9426799115098,
-350.3083527939079
]
}
},
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "display-p3:0.68235,0.52549,0.14510,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"linear-gradient" : [
"display-p3:0.96863,0.93725,0.54118,1.00000",
"display-p3:0.68235,0.52549,0.14510,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.7841231069456777,
"y" : 0.910632131195883
},
"stop" : {
"x" : 0.3724596068255535,
"y" : 0.3659584826368382
}
}
}
},
{
"appearance" : "tinted",
"value" : {
"solid" : "display-p3:0.97310,1.00000,0.94620,1.00000"
}
}
],
"image-name" : "CréditMabligopTest1.png",
"name" : "CréditMabligopTest1",
"position" : {
"scale" : 1.5,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 297.65 263.4">
<defs>
<style>
.cls-1 {
stroke-linejoin: round;
}
.cls-1, .cls-2 {
fill: none;
stroke: #231f20;
stroke-linecap: round;
stroke-width: 25px;
}
.cls-2 {
stroke-miterlimit: 10;
}
</style>
</defs>
<path class="cls-2" d="M122.02,12.5h-33.51c-6.08,0-11.69,3.24-14.73,8.51L14.78,123.19c-3.04,5.26-3.04,11.75,0,17.01l59,102.19c3.04,5.26,8.66,8.51,14.73,8.51h33.51"/>
<polyline class="cls-1" points="164.84 250.9 164.84 12.5 215.82 152.6 216.33 12.5 285.15 131.7 216.33 250.9"/>
</svg>

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 680 KiB

159
mobile/package-lock.json generated
View file

@ -22,7 +22,8 @@
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0"
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1"
},
"devDependencies": {
"@types/react": "~19.1.0",
@ -3392,6 +3393,12 @@
"node": ">=0.6"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/bplist-creator": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@ -3907,6 +3914,56 @@
"node": ">=8"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-tree/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -4041,6 +4098,61 @@
"node": ">=8"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@ -4109,6 +4221,18 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-editor": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@ -6009,6 +6133,12 @@
"node": ">= 0.4"
}
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@ -6601,6 +6731,18 @@
"node": "^16.14.0 || >=18.0.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nullthrows": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@ -7347,6 +7489,21 @@
"react-native": "*"
}
},
"node_modules/react-native-svg": {
"version": "15.12.1",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz",
"integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3",
"warn-once": "0.1.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",

View file

@ -6,7 +6,8 @@
"start": "expo start",
"dev": "sh ./scripts/start-dev.sh",
"android": "expo run:android",
"ios": "expo run:ios"
"ios": "expo run:ios",
"screenshots:ios": "node ./scripts/generate-screenshots.mjs"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
@ -23,7 +24,8 @@
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0"
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1"
},
"devDependencies": {
"@types/react": "~19.1.0",

View file

@ -0,0 +1,226 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const mobileRoot = resolve(__dirname, "..");
const derivedDataPath = resolve(mobileRoot, "ios", "build", "screenshots");
const appPath = resolve(
derivedDataPath,
"Build",
"Products",
"Release-iphonesimulator",
"mobile.app",
);
const defaultOutputRoot = resolve(mobileRoot, "..", "ScreenShots");
const bundleId = "fr.negopoly.app";
const launchWaitMs = 4500;
const sceneWaitMs = 2200;
const targetDevices = [
{ matchName: "iPhone 17 Pro Max", outputFolder: "iPhone 17 Pro Max" },
{ matchName: "iPad Air 13-inch", outputFolder: "iPad Air 13-inch" },
];
const scenes = [
{ slug: "start", fileName: "Start.png" },
{ slug: "lobby", fileName: "Lobby.png" },
{ slug: "home", fileName: "Home.png" },
{ slug: "transfers", fileName: "Transfers.png" },
{ slug: "chat", fileName: "Chat.png" },
];
function parseArgs(argv) {
const options = {
skipBuild: false,
outputRoot: defaultOutputRoot,
};
for (let index = 0; index < argv.length; index += 1) {
const current = argv[index];
if (current === "--skip-build") {
options.skipBuild = true;
continue;
}
if (current === "--output") {
const nextValue = argv[index + 1];
if (!nextValue) {
throw new Error("Missing value after --output");
}
options.outputRoot = resolve(mobileRoot, nextValue);
index += 1;
continue;
}
throw new Error(`Unknown argument: ${current}`);
}
return options;
}
function run(command, args, options = {}) {
const label = `${command} ${args.join(" ")}`;
console.log(`\n> ${label}`);
return execFileSync(command, args, {
cwd: mobileRoot,
encoding: "utf8",
stdio: options.stdio ?? "pipe",
});
}
function runQuiet(command, args) {
try {
return run(command, args);
} catch {
return "";
}
}
function sleep(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function listAvailableDevices() {
const output = run("xcrun", ["simctl", "list", "devices", "available", "--json"]);
const payload = JSON.parse(output);
return Object.entries(payload.devices).flatMap(([runtime, devices]) =>
devices.map((device) => ({
runtime,
name: device.name,
udid: device.udid,
state: device.state,
isAvailable: device.isAvailable,
})),
);
}
function resolveDevice(matchName, devices) {
const matches = devices
.filter((device) => device.isAvailable)
.filter((device) => device.name === matchName || device.name.startsWith(matchName))
.sort((left, right) => {
if (left.state === "Booted" && right.state !== "Booted") return -1;
if (left.state !== "Booted" && right.state === "Booted") return 1;
return right.runtime.localeCompare(left.runtime);
});
if (matches.length === 0) {
throw new Error(`No available simulator found for "${matchName}"`);
}
return matches[0];
}
function ensureBooted(udid) {
runQuiet("xcrun", ["simctl", "boot", udid]);
run("xcrun", ["simctl", "bootstatus", udid, "-b"]);
}
function setSimulatorAppearance(udid) {
runQuiet("xcrun", ["simctl", "ui", udid, "appearance", "light"]);
runQuiet("xcrun", ["simctl", "status_bar", udid, "clear"]);
runQuiet("xcrun", [
"simctl",
"status_bar",
udid,
"override",
"--time",
"9:41",
"--dataNetwork",
"wifi",
"--wifiBars",
"3",
"--batteryState",
"charged",
"--batteryLevel",
"100",
]);
}
function buildReleaseApp(buildDestinationUdid) {
run(
"xcodebuild",
[
"-workspace",
"ios/mobile.xcworkspace",
"-scheme",
"mobile",
"-configuration",
"Release",
"-destination",
`platform=iOS Simulator,id=${buildDestinationUdid}`,
"-derivedDataPath",
derivedDataPath,
"build",
],
{ stdio: "inherit" },
);
if (!existsSync(appPath)) {
throw new Error(`Built app not found at ${appPath}`);
}
}
function installApp(udid) {
runQuiet("xcrun", ["simctl", "terminate", udid, bundleId]);
runQuiet("xcrun", ["simctl", "uninstall", udid, bundleId]);
run("xcrun", ["simctl", "install", udid, appPath]);
}
function openScene(udid, scene, waitMs) {
run("xcrun", ["simctl", "openurl", udid, `negopoly://screenshot/${scene}`]);
sleep(waitMs);
}
function captureScene(udid, destinationPath) {
run("xcrun", ["simctl", "io", udid, "screenshot", destinationPath]);
}
function main() {
const options = parseArgs(process.argv.slice(2));
const devices = listAvailableDevices();
const resolvedTargets = targetDevices.map((target) => ({
...target,
simulator: resolveDevice(target.matchName, devices),
}));
if (!options.skipBuild) {
buildReleaseApp(resolvedTargets[0].simulator.udid);
} else if (!existsSync(appPath)) {
throw new Error(`Cannot use --skip-build because ${appPath} does not exist`);
}
mkdirSync(options.outputRoot, { recursive: true });
for (const target of resolvedTargets) {
const { simulator } = target;
const deviceOutputDir = join(options.outputRoot, target.outputFolder);
console.log(`\n## ${target.outputFolder} (${simulator.name})`);
mkdirSync(deviceOutputDir, { recursive: true });
ensureBooted(simulator.udid);
setSimulatorAppearance(simulator.udid);
installApp(simulator.udid);
for (const [index, scene] of scenes.entries()) {
const targetPath = join(deviceOutputDir, scene.fileName);
openScene(simulator.udid, scene.slug, index === 0 ? launchWaitMs : sceneWaitMs);
captureScene(simulator.udid, targetPath);
console.log(`Saved ${targetPath}`);
}
runQuiet("xcrun", ["simctl", "terminate", simulator.udid, bundleId]);
runQuiet("xcrun", ["simctl", "status_bar", simulator.udid, "clear"]);
}
}
try {
main();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`\nScreenshot generation failed: ${message}`);
process.exit(1);
}

View file

@ -10,37 +10,51 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";
import * as Notifications from "expo-notifications";
import AppNavigator from "./navigation/AppNavigator";
import { normalizeScreenshotScene } from "./dev/screenshot-fixtures";
import type { RootStackParamList } from "./navigation/types";
import { SessionProvider, useSession } from "./state/session-context";
import { getNavigationTheme, useTheme } from "./theme";
import { parseNotificationTarget, type NotificationTarget } from "./notifications";
import ConnectionBanner from "./components/ConnectionBanner";
function extractGameId(url: string): string | null {
function getUrlSegments(url: string): string[] {
try {
const parsed = new URL(url);
const path = parsed.pathname || "";
const pathSegments = (parsed.pathname || "")
.split("/")
.map((segment) => segment.trim())
.filter(Boolean);
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
const match = path.match(/^\/play\/?([^/]+)?/);
const id = match?.[1]?.trim();
return id ? id : null;
return pathSegments;
}
if (parsed.host === "play") {
const id = path.replace(/^\//, "").trim();
return id ? id : null;
if (parsed.host) {
return [parsed.host, ...pathSegments];
}
const fallbackMatch = path.match(/^\/play\/?([^/]+)?/);
const fallbackId = fallbackMatch?.[1]?.trim();
return fallbackId ? fallbackId : null;
return pathSegments;
} catch {
return null;
return [];
}
}
function extractGameId(url: string): string | null {
const segments = getUrlSegments(url);
if (segments[0] !== "play") return null;
return segments[1] || null;
}
function extractScreenshotScene(url: string) {
const segments = getUrlSegments(url);
if (segments[0] !== "screenshot") return null;
return normalizeScreenshotScene(segments[1]);
}
function logDeepLink(url: string) {
if (!__DEV__) return;
const gameId = extractGameId(url);
console.log(`[deep-link] url=${url} gameId=${gameId ?? "invalid"}`);
const scene = extractScreenshotScene(url);
console.log(
`[deep-link] url=${url} gameId=${gameId ?? "invalid"} screenshot=${scene ?? "none"}`,
);
}
function RootNavigationGate() {
@ -50,15 +64,26 @@ function RootNavigationGate() {
const lastTargetRef = useRef<keyof RootStackParamList | null>(null);
const lastLinkRef = useRef<string | null>(null);
const pendingNotificationRef = useRef<NotificationTarget | null>(null);
const pendingScreenshotRef = useRef(false);
const lastNotificationIdRef = useRef<string | null>(null);
const theme = useTheme();
const navigationTheme = getNavigationTheme(theme);
const handleSpecialUrl = useCallback(
(url: string) => {
const scene = extractScreenshotScene(url);
if (!scene) return false;
pendingScreenshotRef.current = true;
manager.activateScreenshotScene(scene);
return true;
},
[manager.activateScreenshotScene],
);
const linking = useMemo<LinkingOptions<RootStackParamList>>(
() => ({
prefixes: ["negopoly://", "https://negopoly.fr"],
config: {
screens: {
Entry: "play/:gameId",
AgencyJoin: "play/:gameId",
},
},
getInitialURL: async () => {
@ -66,6 +91,9 @@ function RootNavigationGate() {
if (url) {
lastLinkRef.current = url;
logDeepLink(url);
if (handleSpecialUrl(url)) {
return null;
}
}
return url;
},
@ -75,13 +103,14 @@ function RootNavigationGate() {
if (lastLinkRef.current === url) return;
lastLinkRef.current = url;
logDeepLink(url);
if (handleSpecialUrl(url)) return;
listener(url);
};
const subscription = Linking.addEventListener("url", onReceiveURL);
return () => subscription.remove();
},
}),
[],
[handleSpecialUrl],
);
const processPendingNotification = useCallback(() => {
@ -120,6 +149,27 @@ function RootNavigationGate() {
);
}, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]);
const processPendingScreenshot = useCallback(() => {
if (!pendingScreenshotRef.current) return;
if (!navReady || !navigationRef.isReady()) return;
const target = manager.screenshot?.navigationTarget;
if (!target) return;
pendingScreenshotRef.current = false;
navigationRef.dispatch(
CommonActions.reset({
index: 0,
routes: [
{
name: target.root,
params: target.params,
},
],
}),
);
lastTargetRef.current = target.root;
}, [manager.screenshot, navReady, navigationRef]);
const handleNotificationResponse = useCallback(
(response: Notifications.NotificationResponse | null) => {
if (!response) return;
@ -145,11 +195,25 @@ function RootNavigationGate() {
);
useEffect(() => {
if (pendingScreenshotRef.current) {
return;
}
if (!navReady || !navigationRef.isReady()) return;
const currentRoute = navigationRef.getCurrentRoute();
const currentName = currentRoute?.name;
const inEntryFlow =
currentName === "EntryLanding" ||
currentName === "AgencyJoin" ||
currentName === "AgencyCreate";
let target: keyof RootStackParamList;
if (!manager.sessionId) {
target = "Entry";
if (inEntryFlow) {
lastTargetRef.current = currentName ?? null;
return;
}
target = "EntryLanding";
} else if (!manager.session) {
target = "Lobby";
} else if (manager.session.status === "lobby") {
@ -160,7 +224,6 @@ function RootNavigationGate() {
target = "PlayerTabs";
}
const currentRoute = navigationRef.getCurrentRoute();
if (currentRoute?.name === target || lastTargetRef.current === target) {
return;
}
@ -179,6 +242,10 @@ function RootNavigationGate() {
navigationRef,
]);
useEffect(() => {
processPendingScreenshot();
}, [processPendingScreenshot]);
useEffect(() => {
processPendingNotification();
}, [processPendingNotification]);

View file

@ -0,0 +1,104 @@
import React, { useMemo } from "react";
import { StyleSheet, Text, View } from "react-native";
import BrandMark from "./BrandMark";
import { useTheme, type AppTheme } from "../theme";
type BrandLockupProps = {
subtitle?: string;
variant?: "hero" | "header";
onDark?: boolean;
};
export default function BrandLockup({
subtitle,
variant = "hero",
onDark = false,
}: BrandLockupProps) {
const theme = useTheme();
const styles = useMemo(() => createStyles(theme, onDark), [theme, onDark]);
const isHero = variant === "hero";
const markColor = onDark
? theme.colors.brandText
: theme.dark
? theme.colors.brandText
: theme.colors.brandAccent;
return (
<View style={[styles.container, isHero ? styles.containerHero : styles.containerHeader]}>
<View style={[styles.markWrap, isHero ? styles.markWrapHero : styles.markWrapHeader]}>
<BrandMark color={markColor} size={isHero ? 44 : 24} />
</View>
<View style={styles.copy}>
<Text style={[styles.title, isHero ? styles.titleHero : styles.titleHeader]}>
Crédit Mabligop
</Text>
{subtitle ? (
<Text style={[styles.subtitle, isHero ? styles.subtitleHero : styles.subtitleHeader]}>
{subtitle}
</Text>
) : null}
</View>
</View>
);
}
const createStyles = (theme: AppTheme, onDark: boolean) => {
const titleColor = onDark ? theme.colors.brandText : theme.colors.text;
const subtitleColor = onDark ? theme.colors.brandTextMuted : theme.colors.textMuted;
return StyleSheet.create({
container: {
flexDirection: "row",
alignItems: "center",
},
containerHero: {
gap: 14,
},
containerHeader: {
gap: 10,
},
markWrap: {
alignItems: "center",
justifyContent: "center",
},
markWrapHero: {
width: 56,
height: 56,
borderRadius: 18,
backgroundColor: onDark ? theme.colors.brandSurfaceAlt : theme.colors.accentSurface,
},
markWrapHeader: {
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: onDark ? theme.colors.brandSurfaceAlt : theme.colors.accentSurface,
},
copy: {
justifyContent: "center",
},
title: {
fontWeight: "800",
color: titleColor,
},
titleHero: {
fontSize: 24,
letterSpacing: -0.5,
},
titleHeader: {
fontSize: 15,
letterSpacing: -0.2,
},
subtitle: {
color: subtitleColor,
},
subtitleHero: {
marginTop: 4,
fontSize: 12,
letterSpacing: 1.1,
textTransform: "uppercase",
},
subtitleHeader: {
fontSize: 11,
},
});
};

View file

@ -0,0 +1,43 @@
import React from "react";
import Svg, { Path, Polyline } from "react-native-svg";
import { useTheme } from "../theme";
type BrandMarkProps = {
color?: string;
size?: number;
};
const VIEWBOX_WIDTH = 297.65;
const VIEWBOX_HEIGHT = 263.4;
export default function BrandMark({
color,
size = 34,
}: BrandMarkProps) {
const theme = useTheme();
const stroke = color ?? (theme.dark ? theme.colors.brandText : theme.colors.brandAccent);
return (
<Svg
width={size}
height={(size * VIEWBOX_HEIGHT) / VIEWBOX_WIDTH}
viewBox="0 0 297.65 263.4"
fill="none"
>
<Path
d="M122.02,12.5h-33.51c-6.08,0-11.69,3.24-14.73,8.51L14.78,123.19c-3.04,5.26-3.04,11.75,0,17.01l59,102.19c3.04,5.26,8.66,8.51,14.73,8.51h33.51"
stroke={stroke}
strokeWidth={25}
strokeLinecap="round"
strokeMiterlimit={10}
/>
<Polyline
points="164.84 250.9 164.84 12.5 215.82 152.6 216.33 12.5 285.15 131.7 216.33 250.9"
stroke={stroke}
strokeWidth={25}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}

View file

@ -0,0 +1,344 @@
import type { SessionSnapshot } from "../shared/types";
export type ScreenshotScene = "start" | "lobby" | "home" | "transfers" | "chat";
export type ScreenshotFixture = {
scene: ScreenshotScene;
sessionId: string;
sessionCode: string;
playerId: string;
session: SessionSnapshot | null;
navigationTarget: {
root: "EntryLanding" | "Lobby" | "PlayerTabs";
params?: Record<string, unknown>;
};
transferDraft?: {
targetId: string;
amount: string;
note: string;
};
};
const bankerId = "banker-ngozi";
const meId = "player-awa";
const malikId = "player-malik";
const inesId = "player-ines";
const yemiId = "player-yemi";
const activeSessionId = "screenshot-active-session";
const lobbySessionId = "screenshot-lobby-session";
const sessionCode = "MABLI";
const baseTime = new Date("2026-03-30T09:41:00+02:00").getTime();
function createActiveSession(): SessionSnapshot {
return {
id: activeSessionId,
code: sessionCode,
status: "active",
createdAt: baseTime - 60 * 60 * 1000,
bankerId,
blackoutActive: false,
blackoutReason: null,
players: [
{
id: bankerId,
name: "Ngozi",
role: "banker",
balance: 0,
connected: true,
isDummy: false,
joinedAt: baseTime - 60 * 60 * 1000,
lastActiveAt: baseTime - 2 * 60 * 1000,
},
{
id: meId,
name: "Awa",
role: "player",
balance: 1840,
connected: true,
isDummy: false,
joinedAt: baseTime - 55 * 60 * 1000,
lastActiveAt: baseTime - 60 * 1000,
},
{
id: malikId,
name: "Malik",
role: "player",
balance: 1325,
connected: true,
isDummy: false,
joinedAt: baseTime - 53 * 60 * 1000,
lastActiveAt: baseTime - 90 * 1000,
},
{
id: inesId,
name: "Ines",
role: "player",
balance: 960,
connected: true,
isDummy: false,
joinedAt: baseTime - 51 * 60 * 1000,
lastActiveAt: baseTime - 4 * 60 * 1000,
},
{
id: yemiId,
name: "Yemi",
role: "player",
balance: 780,
connected: false,
isDummy: true,
joinedAt: baseTime - 49 * 60 * 1000,
lastActiveAt: baseTime - 20 * 60 * 1000,
},
],
transactions: [
{
id: "tx-bank-boost",
kind: "banker_adjust",
fromId: null,
toId: meId,
amount: 1200,
note: "Opening allowance",
createdAt: baseTime - 46 * 60 * 1000,
initiatedBy: "banker",
},
{
id: "tx-street-food",
kind: "transfer",
fromId: meId,
toId: malikId,
amount: 90,
note: "Street food",
createdAt: baseTime - 16 * 60 * 1000,
initiatedBy: "player",
},
{
id: "tx-rent-share",
kind: "transfer",
fromId: inesId,
toId: meId,
amount: 240,
note: "Rent split",
createdAt: baseTime - 12 * 60 * 1000,
initiatedBy: "player",
},
{
id: "tx-taxi",
kind: "transfer",
fromId: meId,
toId: inesId,
amount: 45,
note: "Taxi",
createdAt: baseTime - 8 * 60 * 1000,
initiatedBy: "player",
},
{
id: "tx-voucher",
kind: "banker_adjust",
fromId: null,
toId: meId,
amount: 535,
note: "District voucher",
createdAt: baseTime - 3 * 60 * 1000,
initiatedBy: "banker",
},
],
groups: [
{
id: "group-direct-malik",
name: "Awa & Malik",
memberIds: [meId, malikId],
createdAt: baseTime - 20 * 60 * 1000,
createdBy: meId,
},
{
id: "group-vendors",
name: "Market run",
memberIds: [meId, malikId, inesId],
createdAt: baseTime - 25 * 60 * 1000,
createdBy: inesId,
},
],
chats: [
{
id: "chat-global-1",
fromId: bankerId,
body: "Agency is open. Keep your receipts for every transfer.",
createdAt: baseTime - 28 * 60 * 1000,
groupId: null,
},
{
id: "chat-group-1",
fromId: inesId,
body: "Meeting at the riverside market in ten minutes.",
createdAt: baseTime - 18 * 60 * 1000,
groupId: "group-vendors",
},
{
id: "chat-direct-1",
fromId: malikId,
body: "I covered the snacks. Send me 90 when you can.",
createdAt: baseTime - 11 * 60 * 1000,
groupId: "group-direct-malik",
},
{
id: "chat-direct-2",
fromId: meId,
body: "Done. I am also paying for the taxi with Ines.",
createdAt: baseTime - 9 * 60 * 1000,
groupId: "group-direct-malik",
},
{
id: "chat-direct-3",
fromId: malikId,
body: "Perfect. Meet us near the orange stand.",
createdAt: baseTime - 6 * 60 * 1000,
groupId: "group-direct-malik",
},
],
takeoverRequests: [],
};
}
function createLobbySession(): SessionSnapshot {
return {
id: lobbySessionId,
code: sessionCode,
status: "lobby",
createdAt: baseTime - 20 * 60 * 1000,
bankerId,
blackoutActive: false,
blackoutReason: null,
players: [
{
id: bankerId,
name: "Ngozi",
role: "banker",
balance: 0,
connected: true,
isDummy: false,
joinedAt: baseTime - 20 * 60 * 1000,
lastActiveAt: baseTime - 60 * 1000,
},
{
id: meId,
name: "Awa",
role: "player",
balance: 1500,
connected: true,
isDummy: false,
joinedAt: baseTime - 18 * 60 * 1000,
lastActiveAt: baseTime - 90 * 1000,
},
{
id: malikId,
name: "Malik",
role: "player",
balance: 1500,
connected: true,
isDummy: false,
joinedAt: baseTime - 15 * 60 * 1000,
lastActiveAt: baseTime - 4 * 60 * 1000,
},
{
id: yemiId,
name: "Yemi",
role: "player",
balance: 1500,
connected: false,
isDummy: true,
joinedAt: baseTime - 10 * 60 * 1000,
lastActiveAt: baseTime - 10 * 60 * 1000,
},
],
transactions: [],
groups: [],
chats: [],
takeoverRequests: [],
};
}
export function normalizeScreenshotScene(value: string | null | undefined): ScreenshotScene | null {
if (!value) return null;
const normalized = value.trim().toLowerCase();
if (normalized === "start") return "start";
if (normalized === "lobby") return "lobby";
if (normalized === "home") return "home";
if (normalized === "transfers") return "transfers";
if (normalized === "chat") return "chat";
return null;
}
export function buildScreenshotFixture(scene: ScreenshotScene): ScreenshotFixture {
if (scene === "start") {
return {
scene,
sessionId: "",
sessionCode: "",
playerId: "",
session: null,
navigationTarget: { root: "EntryLanding" },
};
}
if (scene === "lobby") {
return {
scene,
sessionId: lobbySessionId,
sessionCode,
playerId: meId,
session: createLobbySession(),
navigationTarget: { root: "Lobby" },
};
}
if (scene === "home") {
return {
scene,
sessionId: activeSessionId,
sessionCode,
playerId: meId,
session: createActiveSession(),
navigationTarget: {
root: "PlayerTabs",
params: { screen: "PlayerHome" },
},
};
}
if (scene === "transfers") {
return {
scene,
sessionId: activeSessionId,
sessionCode,
playerId: meId,
session: createActiveSession(),
navigationTarget: {
root: "PlayerTabs",
params: { screen: "PlayerTransfers" },
},
transferDraft: {
targetId: malikId,
amount: "90",
note: "Street food",
},
};
}
return {
scene,
sessionId: activeSessionId,
sessionCode,
playerId: meId,
session: createActiveSession(),
navigationTarget: {
root: "PlayerTabs",
params: {
screen: "PlayerChat",
params: {
screen: "ChatThread",
params: { chatId: "group-direct-malik" },
},
},
},
};
}

View file

@ -5,17 +5,17 @@ type Locale = "en" | "fr";
const translations = {
en: {
"app.name": "Negopoly Companion",
"app.name": "Crédit Mabligop",
"common.loading": "Loading...",
"common.loadingChats": "Loading chats...",
"common.loadingChat": "Loading chat...",
"common.loadingLobby": "Joining lobby...",
"common.loadingChats": "Loading messages...",
"common.loadingChat": "Loading conversation...",
"common.loadingLobby": "Entering agency setup...",
"common.notice": "Notice:",
"common.online": "online",
"common.offline": "offline",
"common.dummy": "Dummy",
"common.player": "Player",
"common.banker": "Banker",
"common.dummy": "Assisted customer",
"common.player": "Customer",
"common.banker": "Advisor",
"common.bank": "Bank",
"common.from": "From",
"common.to": "To",
@ -33,100 +33,149 @@ const translations = {
"common.save": "Save",
"common.load": "Load",
"common.noReason": "No reason provided",
"entry.subtitle": "Create or join a session.",
"entry.joinTitle": "Join a session",
"entry.sessionCode": "Session code",
"entry.newPlayer": "New player",
"entry.playerName": "Player name",
"entry.takeoverTitle": "Take over dummy",
"entry.alreadyConnected": "You are already connected.",
"entry.subtitle": "Open or access an agency.",
"entry.heroBadge": "Mobile banking companion",
"entry.landingTitle": "Access your agency.",
"entry.landingBody":
"Use your agency code to sign in, or open a new agency if you are the advisor in charge.",
"entry.landingFooter": "Real-time balances, transfers, and approvals for Crédit Mabligop agencies.",
"entry.metricRealtimeValue": "Live",
"entry.metricRealtimeLabel": "Transfers and balances stay synchronized.",
"entry.metricSupervisedValue": "Advisor-led",
"entry.metricSupervisedLabel": "Agencies stay under advisor control.",
"entry.primaryEyebrow": "Customer access",
"entry.secondaryEyebrow": "Advisor access",
"entry.accessAgency": "Access an agency",
"entry.accessAgencyHint":
"Enter your agency code, identify yourself, and recover an existing profile if needed.",
"entry.openAgency": "Open an agency",
"entry.openAgencyHint":
"Launch a supervised agency, invite customers, and approve assisted-profile recovery.",
"entry.joinStepTitle": "Identify your agency.",
"entry.joinStepSubtitle":
"Enter the agency code shared by your advisor to view the available customer access options.",
"entry.createStepTitle": "Open a new agency.",
"entry.createStepSubtitle":
"Create an advisor-controlled space for balances, transfers, approvals, and assisted customers.",
"entry.joinTitle": "Agency access",
"entry.joinDescription": "Use your agency code to retrieve available customer profiles.",
"entry.sessionCode": "Agency code",
"entry.previewLabel": "Agency found",
"entry.agencyCodeValue": "Agency code: {code}",
"entry.previewCustomers": "{count} customer profiles currently registered.",
"entry.newPlayer": "Join as a new customer",
"entry.newCustomerDescription":
"Create a fresh customer profile and enter the agency immediately.",
"entry.joinAsCustomer": "Join agency",
"entry.playerName": "Customer name",
"entry.takeoverTitle": "Recover an existing profile",
"entry.recoverCustomerDescription":
"Request access to an assisted customer profile. An advisor must approve the recovery.",
"entry.alreadyConnected": "This customer profile is already connected.",
"entry.dummyId": "Dummy ID (select later)",
"entry.selectDummy": "Select a dummy",
"entry.selectDummy": "Select an assisted customer",
"entry.yourNameOptional": "Your name (optional)",
"entry.requestTakeover": "Request takeover",
"entry.noDummies": "No dummies available yet.",
"entry.takeoverPending": "Waiting for the banker to approve your takeover.",
"entry.createTitle": "Create a session",
"entry.bankerName": "Banker name",
"entry.openVault": "Open the vault",
"entry.alert.enterCode": "Enter a session code",
"entry.alert.sessionNotFound": "Session not found",
"entry.alert.selectDummy": "Select a dummy player",
"entry.requestTakeover": "Request advisor approval",
"entry.noDummies": "No assisted customer profiles are available yet.",
"entry.takeoverPending": "Waiting for an advisor to approve your recovery request.",
"entry.createTitle": "Advisor profile",
"entry.createDescription":
"The first profile created becomes the advisor supervising this agency.",
"entry.bankerName": "Advisor name",
"entry.advisorName": "Advisor name",
"entry.openVault": "Open agency",
"entry.linkAccessExisting": "Access an existing agency instead",
"entry.linkOpenNew": "Open a new agency instead",
"entry.alert.enterCode": "Enter an agency code",
"entry.alert.enterAdvisorName": "Enter an advisor name",
"entry.alert.sessionNotFound": "Agency not found",
"entry.alert.selectDummy": "Select an assisted customer",
"entry.alert.takeoverFailed": "Unable to request takeover. Please try again.",
"lobby.title": "Lobby",
"lobby.code": "Code: {code}",
"lobby.startGame": "Start game",
"lobby.addDummyTitle": "Add dummy player",
"lobby.addDummySubtitle": "Create a player for someone without the app.",
"lobby.enterDummyName": "Enter a dummy name",
"lobby.addDummyButton": "Add dummy",
"session.exit": "Exit game",
"session.exitPrompt": "Leave this game?",
"session.exitMessage": "You can rejoin later with the session code.",
"lobby.title": "Agency setup",
"lobby.code": "Agency code: {code}",
"lobby.startGame": "Open agency",
"lobby.addDummyTitle": "Add an assisted customer",
"lobby.addDummySubtitle":
"Create a supervised customer profile for someone without the mobile app.",
"lobby.enterDummyName": "Enter an assisted customer name",
"lobby.addDummyButton": "Create assisted customer",
"lobby.heroAdvisor":
"Finalize customer access, review recoveries, and open the agency when everything is ready.",
"lobby.heroCustomer":
"Your advisor is preparing the agency. You will enter as soon as the agency opens.",
"lobby.customers": "Customers",
"lobby.assisted": "Assisted",
"lobby.rosterTitle": "Agency roster",
"lobby.waitingTitle": "Waiting for agency opening",
"lobby.waitingBody":
"Transfers, balances, and conversations unlock once the advisor opens the agency.",
"session.exit": "Leave agency",
"session.exitPrompt": "Leave this agency?",
"session.exitMessage": "You can access it again later with the agency code.",
"transfers.title": "Make a transfer",
"transfers.subtitle": "Move funds instantly between players.",
"transfers.subtitle": "Move funds instantly between customers.",
"transfers.from": "From",
"transfers.to": "To",
"transfers.availableBalance": "Available balance",
"transfers.noPlayers": "No other players available yet.",
"transfers.dummy": "Dummy player",
"transfers.player": "Player",
"transfers.noPlayers": "No other customers are available yet.",
"transfers.dummy": "Assisted customer",
"transfers.player": "Customer",
"transfers.amount": "Amount",
"transfers.note": "Note",
"transfers.notePlaceholder": "What is this for?",
"transfers.sending": "Sending",
"transfers.summary": "₦{amount} to {name}",
"transfers.selectPlayer": "Select a player",
"transfers.selectPlayer": "Select a customer",
"transfers.send": "Send transfer",
"transfers.error": "Choose a player and a valid amount.",
"transfers.error": "Choose a customer and a valid amount.",
"home.balance": "Balance",
"home.recent": "Recent activity",
"home.recent": "Recent operations",
"home.noActivity": "No activity yet.",
"blackout.title": "EMP",
"blackout.defaultReason": "EMP in effect",
"blackout.active": "EMP active",
"banker.dashboard.title": "Session activity",
"banker.tools.title": "Banker tools",
"banker.tools.playersTab": "Players",
"banker.tools.adminTab": "Admin",
"banker.tools.playerOverview": "Player overview",
"banker.tools.noPlayers": "No players yet.",
"banker.dashboard.title": "Agency activity",
"banker.tools.title": "Advisor controls",
"banker.tools.playersTab": "Customers",
"banker.tools.adminTab": "Agency",
"banker.tools.playerOverview": "Customer overview",
"banker.tools.noPlayers": "No customers yet.",
"banker.tools.adjust": "Adjust balance",
"banker.tools.apply": "Apply",
"banker.tools.forceTransfer": "Force transfer",
"banker.tools.force": "Force",
"banker.tools.createDummy": "Create dummy",
"banker.tools.addDummy": "Add dummy",
"banker.tools.blackout": "EMP",
"banker.tools.blackoutActive": "EMP active",
"banker.tools.blackoutReason": "EMP reason",
"banker.tools.blackoutEnable": "Enable EMP",
"banker.tools.blackoutDisable": "Disable EMP",
"banker.tools.createDummy": "Create assisted customer",
"banker.tools.addDummy": "Add assisted customer",
"banker.tools.blackout": "Blackout",
"banker.tools.blackoutActive": "Blackout active",
"banker.tools.blackoutReason": "Blackout reason",
"banker.tools.blackoutEnable": "Enable blackout",
"banker.tools.blackoutDisable": "Disable blackout",
"banker.tools.trigger": "Trigger",
"banker.tools.endSession": "End session",
"banker.tools.playerId": "Player ID",
"banker.tools.endSession": "Close agency",
"banker.tools.playerId": "Customer ID",
"banker.tools.amountAdjust": "Amount (+/-)",
"banker.tools.reason": "Reason",
"banker.tools.fromPlayer": "From player ID",
"banker.tools.toPlayer": "To player ID",
"banker.tools.fromPlayer": "From customer ID",
"banker.tools.toPlayer": "To customer ID",
"banker.tools.amount": "Amount",
"banker.tools.note": "Note",
"banker.tools.dummyName": "Dummy name",
"banker.tools.dummyName": "Assisted customer name",
"banker.tools.startingBalance": "Starting balance",
"banker.takeoverApprovals": "Takeover approvals",
"banker.wants": "wants {name}",
"banker.takeoverApprovals": "Recovery approvals",
"banker.wants": "requests {name}",
"banker.approve": "Approve",
"banker.stateTitle": "GameState",
"banker.stateSubtitle": "Export or restore the current session.",
"banker.downloadState": "Export GameState",
"banker.loadFromFile": "Load GameState",
"banker.importPlaceholder": "Paste GameState JSON here",
"banker.stateTitle": "Agency state",
"banker.stateSubtitle": "Export or restore the current agency.",
"banker.downloadState": "Export agency state",
"banker.loadFromFile": "Load agency state",
"banker.importPlaceholder": "Paste agency state JSON here",
"banker.loadFromStorage": "Load from saved snapshots",
"banker.stateDownloaded": "GameState exported.",
"banker.stateDownloadError": "Unable to export GameState.",
"banker.stateLoaded": "GameState loaded.",
"banker.stateLoadError": "Unable to load GameState.",
"banker.stateLoadInvalid": "Invalid GameState JSON.",
"banker.stateDownloaded": "Agency state exported.",
"banker.stateDownloadError": "Unable to export agency state.",
"banker.stateLoaded": "Agency state loaded.",
"banker.stateLoadError": "Unable to load agency state.",
"banker.stateLoadInvalid": "Invalid agency state JSON.",
"banker.autosaveTitle": "AutoSave",
"banker.autosaveSubtitle": "Save rolling snapshots on this device.",
"banker.autosaveEnabled": "Enable AutoSave",
@ -137,47 +186,47 @@ const translations = {
"banker.autosaveFailed": "AutoSave failed.",
"banker.noAutosaves": "No autosaves yet.",
"banker.savedAt": "Saved {time}",
"chat.title": "Chats",
"chat.title": "Messages",
"chat.noMessages": "No messages yet",
"chat.global": "Global chat",
"chat.newTitle": "New chat",
"chat.global": "Agency channel",
"chat.newTitle": "New conversation",
"chat.direct": "Direct",
"chat.group": "Group",
"chat.groupName": "Group name",
"chat.choosePlayers": "Choose players",
"chat.startChat": "Start chat",
"chat.choosePlayers": "Choose customers",
"chat.startChat": "Start conversation",
"chat.notFound": "Chat not found.",
"chat.messagePlaceholder": "Message",
"tabs.home": "Home",
"tabs.transfers": "Transfers",
"tabs.chat": "Chat",
"tabs.dashboard": "Dashboard",
"tabs.tools": "Tools",
"tabs.home": "Accounts",
"tabs.transfers": "Payments",
"tabs.chat": "Messages",
"tabs.dashboard": "Agency",
"tabs.tools": "Control",
"transaction.transfer": "Transfer",
"transaction.banker_adjust": "Banker adjustment",
"transaction.banker_force_transfer": "Forced transfer",
"connection.connecting": "Connecting to your game",
"connection.reconnecting": "Reconnecting to your game",
"transaction.banker_adjust": "Advisor adjustment",
"transaction.banker_force_transfer": "Advisor transfer",
"connection.connecting": "Connecting to your agency",
"connection.reconnecting": "Reconnecting to your agency",
"connection.reconnectingDetail": "Attempt {count}. Live updates will resume automatically.",
"error.parseResponse": "Unable to parse server response",
"error.createSession": "Unable to create session",
"error.joinSession": "Unable to join session",
"error.loadSessionInfo": "Unable to load session info",
"error.createSession": "Unable to open agency",
"error.joinSession": "Unable to access agency",
"error.loadSessionInfo": "Unable to load agency info",
"error.connectionNotReady": "Connection not ready",
"error.reconnecting": "Reconnecting to the game. Please try again in a moment.",
"error.reconnecting": "Reconnecting to the agency. Please try again in a moment.",
},
fr: {
"app.name": "Negopoly Companion",
"app.name": "Crédit Mabligop",
"common.loading": "Chargement...",
"common.loadingChats": "Chargement des chats...",
"common.loadingChat": "Chargement du chat...",
"common.loadingLobby": "Connexion au lobby...",
"common.loadingChats": "Chargement des messages...",
"common.loadingChat": "Chargement de la conversation...",
"common.loadingLobby": "Accès à la mise en place de l'agence...",
"common.notice": "Info :",
"common.online": "en ligne",
"common.offline": "hors ligne",
"common.dummy": "Dummy",
"common.player": "Joueur",
"common.banker": "Banquier",
"common.dummy": "Client assisté",
"common.player": "Client",
"common.banker": "Conseiller",
"common.bank": "Banque",
"common.from": "De",
"common.to": "Vers",
@ -195,100 +244,150 @@ const translations = {
"common.save": "Enregistrer",
"common.load": "Charger",
"common.noReason": "Aucune raison fournie",
"entry.subtitle": "Créez ou rejoignez une session.",
"entry.joinTitle": "Rejoindre une session",
"entry.sessionCode": "Code de session",
"entry.newPlayer": "Nouveau joueur",
"entry.playerName": "Nom du joueur",
"entry.takeoverTitle": "Reprendre un dummy",
"entry.alreadyConnected": "Vous êtes déjà connecté.",
"entry.subtitle": "Ouvrez ou accédez à une agence.",
"entry.heroBadge": "Compagnon bancaire mobile",
"entry.landingTitle": "Accédez à votre agence.",
"entry.landingBody":
"Utilisez votre code agence pour vous identifier, ou ouvrez une nouvelle agence si vous êtes le conseiller responsable.",
"entry.landingFooter":
"Soldes, virements et validations en temps réel pour les agences Crédit Mabligop.",
"entry.metricRealtimeValue": "Temps réel",
"entry.metricRealtimeLabel": "Transferts et soldes restent synchronisés.",
"entry.metricSupervisedValue": "Supervisée",
"entry.metricSupervisedLabel": "Chaque agence reste sous contrôle conseiller.",
"entry.primaryEyebrow": "Accès client",
"entry.secondaryEyebrow": "Accès conseiller",
"entry.accessAgency": "Accéder à une agence",
"entry.accessAgencyHint":
"Saisissez votre code agence, identifiez-vous, puis récupérez un profil existant si besoin.",
"entry.openAgency": "Ouvrir une agence",
"entry.openAgencyHint":
"Lancez une agence supervisée, invitez des clients et validez les récupérations de profils assistés.",
"entry.joinStepTitle": "Identifiez votre agence.",
"entry.joinStepSubtitle":
"Saisissez le code transmis par votre conseiller pour afficher les options d'accès client disponibles.",
"entry.createStepTitle": "Ouvrez une nouvelle agence.",
"entry.createStepSubtitle":
"Créez un espace piloté par un conseiller pour les soldes, virements, validations et clients assistés.",
"entry.joinTitle": "Accès agence",
"entry.joinDescription": "Utilisez votre code agence pour retrouver les profils clients disponibles.",
"entry.sessionCode": "Code agence",
"entry.previewLabel": "Agence trouvée",
"entry.agencyCodeValue": "Code agence : {code}",
"entry.previewCustomers": "{count} profils clients actuellement enregistrés.",
"entry.newPlayer": "Rejoindre comme nouveau client",
"entry.newCustomerDescription":
"Créez un nouveau profil client et entrez immédiatement dans l'agence.",
"entry.joinAsCustomer": "Rejoindre l'agence",
"entry.playerName": "Nom du client",
"entry.takeoverTitle": "Récupérer un profil existant",
"entry.recoverCustomerDescription":
"Demandez l'accès à un profil client assisté. Un conseiller doit valider la récupération.",
"entry.alreadyConnected": "Ce profil client est déjà connecté.",
"entry.dummyId": "ID du dummy (plus tard)",
"entry.selectDummy": "Sélectionnez un dummy",
"entry.selectDummy": "Sélectionnez un client assisté",
"entry.yourNameOptional": "Votre nom (optionnel)",
"entry.requestTakeover": "Demander la reprise",
"entry.noDummies": "Aucun dummy disponible pour le moment.",
"entry.takeoverPending": "En attente de l'approbation du banquier.",
"entry.createTitle": "Créer une session",
"entry.bankerName": "Nom du banquier",
"entry.openVault": "Ouvrir le coffre",
"entry.alert.enterCode": "Entrez un code de session",
"entry.alert.sessionNotFound": "Session introuvable",
"entry.alert.selectDummy": "Sélectionnez un dummy",
"entry.requestTakeover": "Demander l'accord du conseiller",
"entry.noDummies": "Aucun profil client assisté n'est disponible pour le moment.",
"entry.takeoverPending": "En attente de la validation du conseiller.",
"entry.createTitle": "Profil conseiller",
"entry.createDescription":
"Le premier profil créé devient le conseiller qui supervise cette agence.",
"entry.bankerName": "Nom du conseiller",
"entry.advisorName": "Nom du conseiller",
"entry.openVault": "Ouvrir l'agence",
"entry.linkAccessExisting": "Accéder plutôt à une agence existante",
"entry.linkOpenNew": "Ouvrir plutôt une nouvelle agence",
"entry.alert.enterCode": "Entrez un code agence",
"entry.alert.enterAdvisorName": "Entrez un nom de conseiller",
"entry.alert.sessionNotFound": "Agence introuvable",
"entry.alert.selectDummy": "Sélectionnez un client assisté",
"entry.alert.takeoverFailed": "Impossible de demander la reprise. Réessayez.",
"lobby.title": "Lobby",
"lobby.code": "Code : {code}",
"lobby.startGame": "Démarrer la partie",
"lobby.addDummyTitle": "Ajouter un dummy",
"lobby.addDummySubtitle": "Créez un joueur pour quelqu'un sans l'application.",
"lobby.enterDummyName": "Entrez un nom de dummy",
"lobby.addDummyButton": "Ajouter un dummy",
"session.exit": "Quitter la partie",
"session.exitPrompt": "Quitter cette session ?",
"session.exitMessage": "Vous pourrez rejoindre plus tard avec le code.",
"transfers.title": "Faire un transfert",
"transfers.subtitle": "Transférez des fonds instantanément entre joueurs.",
"lobby.title": "Mise en place de l'agence",
"lobby.code": "Code agence : {code}",
"lobby.startGame": "Ouvrir l'agence",
"lobby.addDummyTitle": "Ajouter un client assisté",
"lobby.addDummySubtitle":
"Créez un profil client supervisé pour quelqu'un sans l'application mobile.",
"lobby.enterDummyName": "Entrez un nom de client assisté",
"lobby.addDummyButton": "Créer le client assisté",
"lobby.heroAdvisor":
"Finalisez les accès clients, examinez les récupérations et ouvrez l'agence quand tout est prêt.",
"lobby.heroCustomer":
"Votre conseiller prépare l'agence. Vous y entrerez dès son ouverture.",
"lobby.customers": "Clients",
"lobby.assisted": "Assistés",
"lobby.rosterTitle": "Registre de l'agence",
"lobby.waitingTitle": "En attente de l'ouverture de l'agence",
"lobby.waitingBody":
"Virements, soldes et conversations seront disponibles dès que le conseiller ouvrira l'agence.",
"session.exit": "Quitter l'agence",
"session.exitPrompt": "Quitter cette agence ?",
"session.exitMessage": "Vous pourrez y accéder plus tard avec le code agence.",
"transfers.title": "Effectuer un virement",
"transfers.subtitle": "Transférez des fonds instantanément entre clients.",
"transfers.from": "De",
"transfers.to": "Vers",
"transfers.availableBalance": "Solde disponible",
"transfers.noPlayers": "Aucun autre joueur disponible.",
"transfers.dummy": "Dummy",
"transfers.player": "Joueur",
"transfers.noPlayers": "Aucun autre client disponible pour le moment.",
"transfers.dummy": "Client assisté",
"transfers.player": "Client",
"transfers.amount": "Montant",
"transfers.note": "Note",
"transfers.notePlaceholder": "Pour quoi ?",
"transfers.sending": "Envoi",
"transfers.summary": "₦{amount} à {name}",
"transfers.selectPlayer": "Choisissez un joueur",
"transfers.send": "Envoyer le transfert",
"transfers.error": "Choisissez un joueur et un montant valide.",
"transfers.selectPlayer": "Choisissez un client",
"transfers.send": "Envoyer le virement",
"transfers.error": "Choisissez un client et un montant valide.",
"home.balance": "Solde",
"home.recent": "Activité récente",
"home.recent": "Opérations récentes",
"home.noActivity": "Aucune activité.",
"blackout.title": "EMP",
"blackout.defaultReason": "EMP en cours",
"blackout.active": "EMP actif",
"banker.dashboard.title": "Activité de la session",
"banker.tools.title": "Outils banquier",
"banker.tools.playersTab": "Joueurs",
"banker.tools.adminTab": "Admin",
"banker.tools.playerOverview": "Vue joueur",
"banker.tools.noPlayers": "Pas encore de joueurs.",
"banker.dashboard.title": "Activité de l'agence",
"banker.tools.title": "Pilotage conseiller",
"banker.tools.playersTab": "Clients",
"banker.tools.adminTab": "Agence",
"banker.tools.playerOverview": "Vue client",
"banker.tools.noPlayers": "Pas encore de clients.",
"banker.tools.adjust": "Ajuster le solde",
"banker.tools.apply": "Appliquer",
"banker.tools.forceTransfer": "Forcer un transfert",
"banker.tools.forceTransfer": "Imposer un virement",
"banker.tools.force": "Forcer",
"banker.tools.createDummy": "Créer un dummy",
"banker.tools.addDummy": "Ajouter un dummy",
"banker.tools.blackout": "EMP",
"banker.tools.blackoutActive": "EMP actif",
"banker.tools.blackoutReason": "Raison de l'EMP",
"banker.tools.blackoutEnable": "Activer l'EMP",
"banker.tools.blackoutDisable": "Désactiver l'EMP",
"banker.tools.createDummy": "Créer un client assisté",
"banker.tools.addDummy": "Ajouter un client assisté",
"banker.tools.blackout": "Coupure",
"banker.tools.blackoutActive": "Coupure active",
"banker.tools.blackoutReason": "Raison de la coupure",
"banker.tools.blackoutEnable": "Activer la coupure",
"banker.tools.blackoutDisable": "Désactiver la coupure",
"banker.tools.trigger": "Déclencher",
"banker.tools.endSession": "Terminer la session",
"banker.tools.playerId": "ID joueur",
"banker.tools.endSession": "Fermer l'agence",
"banker.tools.playerId": "ID client",
"banker.tools.amountAdjust": "Montant (+/-)",
"banker.tools.reason": "Raison",
"banker.tools.fromPlayer": "ID joueur source",
"banker.tools.toPlayer": "ID joueur cible",
"banker.tools.fromPlayer": "ID client source",
"banker.tools.toPlayer": "ID client cible",
"banker.tools.amount": "Montant",
"banker.tools.note": "Note",
"banker.tools.dummyName": "Nom du dummy",
"banker.tools.dummyName": "Nom du client assisté",
"banker.tools.startingBalance": "Solde de départ",
"banker.takeoverApprovals": "Approbations de reprise",
"banker.wants": "veut {name}",
"banker.takeoverApprovals": "Validations de récupération",
"banker.wants": "demande {name}",
"banker.approve": "Approuver",
"banker.stateTitle": "État de partie",
"banker.stateSubtitle": "Exportez ou restaurez la session.",
"banker.downloadState": "Exporter l'état",
"banker.loadFromFile": "Charger l'état",
"banker.importPlaceholder": "Collez le JSON d'état ici",
"banker.stateTitle": "État de l'agence",
"banker.stateSubtitle": "Exportez ou restaurez l'agence actuelle.",
"banker.downloadState": "Exporter l'état de l'agence",
"banker.loadFromFile": "Charger l'état de l'agence",
"banker.importPlaceholder": "Collez le JSON d'état de l'agence ici",
"banker.loadFromStorage": "Charger depuis les sauvegardes",
"banker.stateDownloaded": "État exporté.",
"banker.stateDownloadError": "Impossible d'exporter l'état.",
"banker.stateLoaded": "État chargé.",
"banker.stateLoadError": "Impossible de charger l'état.",
"banker.stateLoadInvalid": "JSON d'état invalide.",
"banker.stateDownloaded": "État de l'agence exporté.",
"banker.stateDownloadError": "Impossible d'exporter l'état de l'agence.",
"banker.stateLoaded": "État de l'agence chargé.",
"banker.stateLoadError": "Impossible de charger l'état de l'agence.",
"banker.stateLoadInvalid": "JSON d'état de l'agence invalide.",
"banker.autosaveTitle": "AutoSave",
"banker.autosaveSubtitle": "Enregistrez des sauvegardes sur l'appareil.",
"banker.autosaveEnabled": "Activer AutoSave",
@ -299,34 +398,34 @@ const translations = {
"banker.autosaveFailed": "Échec de la sauvegarde.",
"banker.noAutosaves": "Aucune sauvegarde.",
"banker.savedAt": "Sauvé {time}",
"chat.title": "Chats",
"chat.title": "Messages",
"chat.noMessages": "Aucun message",
"chat.global": "Chat global",
"chat.newTitle": "Nouveau chat",
"chat.global": "Canal agence",
"chat.newTitle": "Nouvelle conversation",
"chat.direct": "Direct",
"chat.group": "Groupe",
"chat.groupName": "Nom du groupe",
"chat.choosePlayers": "Choisir des joueurs",
"chat.startChat": "Démarrer le chat",
"chat.choosePlayers": "Choisir des clients",
"chat.startChat": "Démarrer la conversation",
"chat.notFound": "Chat introuvable.",
"chat.messagePlaceholder": "Message",
"tabs.home": "Accueil",
"tabs.transfers": "Transferts",
"tabs.chat": "Chat",
"tabs.dashboard": "Tableau",
"tabs.tools": "Outils",
"tabs.home": "Comptes",
"tabs.transfers": "Paiements",
"tabs.chat": "Messages",
"tabs.dashboard": "Agence",
"tabs.tools": "Pilotage",
"transaction.transfer": "Transfert",
"transaction.banker_adjust": "Ajustement banquier",
"transaction.banker_force_transfer": "Transfert forcé",
"connection.connecting": "Connexion à la partie",
"connection.reconnecting": "Reconnexion à la partie",
"transaction.banker_adjust": "Ajustement conseiller",
"transaction.banker_force_transfer": "Virement imposé",
"connection.connecting": "Connexion à votre agence",
"connection.reconnecting": "Reconnexion à votre agence",
"connection.reconnectingDetail": "Tentative {count}. Les mises à jour vont reprendre automatiquement.",
"error.parseResponse": "Impossible de lire la réponse du serveur",
"error.createSession": "Impossible de créer la session",
"error.joinSession": "Impossible de rejoindre la session",
"error.loadSessionInfo": "Impossible de charger les infos de session",
"error.createSession": "Impossible d'ouvrir l'agence",
"error.joinSession": "Impossible d'accéder à l'agence",
"error.loadSessionInfo": "Impossible de charger les infos de l'agence",
"error.connectionNotReady": "Connexion non prête",
"error.reconnecting": "Reconnexion à la partie en cours. Réessayez dans un instant.",
"error.reconnecting": "Reconnexion à l'agence en cours. Réessayez dans un instant.",
},
} as const;

View file

@ -2,7 +2,10 @@ import React from "react";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { Ionicons } from "@expo/vector-icons";
import EntryScreen from "../screens/EntryScreen";
import BrandLockup from "../components/BrandLockup";
import EntryLandingScreen from "../screens/EntryLandingScreen";
import AgencyJoinScreen from "../screens/AgencyJoinScreen";
import AgencyCreateScreen from "../screens/AgencyCreateScreen";
import LobbyScreen from "../screens/LobbyScreen";
import PlayerHomeScreen from "../screens/PlayerHomeScreen";
import PlayerTransfersScreen from "../screens/PlayerTransfersScreen";
@ -26,6 +29,10 @@ const PlayerTabs = createBottomTabNavigator<PlayerTabsParamList>();
const BankerTabs = createBottomTabNavigator<BankerTabsParamList>();
const ChatStack = createNativeStackNavigator<ChatStackParamList>();
function buildHeaderTitle(section: string) {
return () => <BrandLockup variant="header" subtitle={section} />;
}
function ChatStackNavigator() {
const { t } = useI18n();
const theme = useTheme();
@ -37,22 +44,23 @@ function ChatStackNavigator() {
headerShadowVisible: false,
contentStyle: { backgroundColor: theme.colors.background },
headerRight: () => <ExitGameButton />,
headerTitleAlign: "left",
}}
>
<ChatStack.Screen
name="ChatList"
component={ChatListScreen}
options={{ title: t("chat.title") }}
options={{ headerTitle: buildHeaderTitle(t("chat.title")) }}
/>
<ChatStack.Screen
name="ChatThread"
component={ChatThreadScreen}
options={{ title: t("tabs.chat") }}
options={{ headerTitle: buildHeaderTitle(t("tabs.chat")) }}
/>
<ChatStack.Screen
name="ChatNew"
component={ChatNewScreen}
options={{ title: t("chat.newTitle") }}
options={{ headerTitle: buildHeaderTitle(t("chat.newTitle")) }}
/>
</ChatStack.Navigator>
);
@ -75,6 +83,7 @@ export function PlayerTabsNavigator() {
headerStyle: { backgroundColor: theme.colors.headerBackground },
headerTintColor: theme.colors.headerText,
headerRight: () => <ExitGameButton />,
headerTitleAlign: "left",
}}
>
<PlayerTabs.Screen
@ -82,6 +91,7 @@ export function PlayerTabsNavigator() {
component={PlayerHomeScreen}
options={{
title: t("tabs.home"),
headerTitle: buildHeaderTitle(t("tabs.home")),
tabBarIcon: ({ color, size }) => (
<Ionicons name="wallet-outline" size={size} color={color} />
),
@ -92,6 +102,7 @@ export function PlayerTabsNavigator() {
component={PlayerTransfersScreen}
options={{
title: t("tabs.transfers"),
headerTitle: buildHeaderTitle(t("tabs.transfers")),
tabBarIcon: ({ color, size }) => (
<Ionicons name="swap-horizontal-outline" size={size} color={color} />
),
@ -129,6 +140,7 @@ export function BankerTabsNavigator() {
headerStyle: { backgroundColor: theme.colors.headerBackground },
headerTintColor: theme.colors.headerText,
headerRight: () => <ExitGameButton />,
headerTitleAlign: "left",
}}
>
<BankerTabs.Screen
@ -136,6 +148,7 @@ export function BankerTabsNavigator() {
component={BankerDashboardScreen}
options={{
title: t("tabs.dashboard"),
headerTitle: buildHeaderTitle(t("tabs.dashboard")),
tabBarIcon: ({ color, size }) => (
<Ionicons name="stats-chart-outline" size={size} color={color} />
),
@ -146,6 +159,7 @@ export function BankerTabsNavigator() {
component={BankerToolsScreen}
options={{
title: t("tabs.tools"),
headerTitle: buildHeaderTitle(t("tabs.tools")),
tabBarIcon: ({ color, size }) => (
<Ionicons name="construct-outline" size={size} color={color} />
),
@ -167,10 +181,10 @@ export function BankerTabsNavigator() {
}
export default function AppNavigator() {
const { t } = useI18n();
const theme = useTheme();
return (
<RootStack.Navigator
initialRouteName="EntryLanding"
screenOptions={{
headerStyle: { backgroundColor: theme.colors.headerBackground },
headerTintColor: theme.colors.headerText,
@ -178,7 +192,21 @@ export default function AppNavigator() {
contentStyle: { backgroundColor: theme.colors.background },
}}
>
<RootStack.Screen name="Entry" component={EntryScreen} options={{ headerShown: false }} />
<RootStack.Screen
name="EntryLanding"
component={EntryLandingScreen}
options={{ headerShown: false }}
/>
<RootStack.Screen
name="AgencyJoin"
component={AgencyJoinScreen}
options={{ headerShown: false }}
/>
<RootStack.Screen
name="AgencyCreate"
component={AgencyCreateScreen}
options={{ headerShown: false }}
/>
<RootStack.Screen
name="Lobby"
component={LobbyScreen}

View file

@ -1,5 +1,7 @@
export type RootStackParamList = {
Entry: { gameId?: string } | undefined;
EntryLanding: undefined;
AgencyJoin: { gameId?: string } | undefined;
AgencyCreate: undefined;
Lobby: undefined;
PlayerTabs: undefined;
BankerTabs: undefined;

View file

@ -0,0 +1,161 @@
import React, { useMemo, useState } from "react";
import {
Alert,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import BrandLockup from "../components/BrandLockup";
import { useI18n } from "../i18n";
import type { RootStackParamList } from "../navigation/types";
import { useSession } from "../state/session-context";
import { useTheme, type AppTheme } from "../theme";
export default function AgencyCreateScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const manager = useSession();
const { t } = useI18n();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const placeholderColor = theme.colors.placeholder;
const insets = useSafeAreaInsets();
const [advisorName, setAdvisorName] = useState("");
async function handleCreate() {
const normalized = advisorName.trim();
if (!normalized) {
Alert.alert(t("entry.alert.enterAdvisorName"));
return;
}
const data = await manager.createSession(normalized);
if (data) {
navigation.replace("Lobby");
}
}
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={[
styles.container,
{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 24,
paddingLeft: insets.left + 20,
paddingRight: insets.right + 20,
},
]}
>
<View style={styles.hero}>
<BrandLockup variant="hero" subtitle={t("entry.heroBadge")} onDark />
<Text style={styles.heroTitle}>{t("entry.createStepTitle")}</Text>
<Text style={styles.heroBody}>{t("entry.createStepSubtitle")}</Text>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>{t("entry.createTitle")}</Text>
<Text style={styles.cardSubtitle}>{t("entry.createDescription")}</Text>
<TextInput
style={styles.input}
placeholder={t("entry.advisorName")}
placeholderTextColor={placeholderColor}
value={advisorName}
onChangeText={setAdvisorName}
/>
<TouchableOpacity style={styles.button} onPress={handleCreate}>
<Text style={styles.buttonText}>{t("entry.openAgency")}</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.linkButton}
onPress={() => navigation.replace("AgencyJoin")}
>
<Text style={styles.linkText}>{t("entry.linkAccessExisting")}</Text>
</TouchableOpacity>
</ScrollView>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
scroll: {
flex: 1,
backgroundColor: theme.colors.background,
},
container: {
gap: 18,
backgroundColor: theme.colors.background,
},
hero: {
borderRadius: 28,
padding: 22,
gap: 12,
backgroundColor: theme.colors.brandSurface,
borderWidth: 1,
borderColor: theme.colors.brandSurfaceAlt,
},
heroTitle: {
color: theme.colors.brandText,
fontSize: 28,
fontWeight: "800",
letterSpacing: -0.8,
},
heroBody: {
color: theme.colors.brandTextMuted,
fontSize: 15,
lineHeight: 22,
},
card: {
backgroundColor: theme.colors.surface,
borderRadius: 24,
padding: 20,
gap: 14,
borderWidth: 1,
borderColor: theme.colors.border,
},
cardTitle: {
color: theme.colors.text,
fontSize: 22,
fontWeight: "700",
letterSpacing: -0.4,
},
cardSubtitle: {
color: theme.colors.textMuted,
fontSize: 14,
lineHeight: 21,
},
input: {
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.inputBackground,
color: theme.colors.inputText,
borderRadius: 16,
paddingHorizontal: 14,
paddingVertical: 13,
},
button: {
backgroundColor: theme.colors.primary,
borderRadius: 999,
paddingVertical: 14,
alignItems: "center",
},
buttonText: {
color: theme.colors.primaryText,
fontWeight: "700",
},
linkButton: {
paddingVertical: 12,
alignItems: "center",
},
linkText: {
color: theme.colors.textMuted,
fontWeight: "600",
},
});

View file

@ -0,0 +1,485 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useNavigation, useRoute } from "@react-navigation/native";
import type { RouteProp } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import BrandLockup from "../components/BrandLockup";
import { useI18n } from "../i18n";
import type { RootStackParamList } from "../navigation/types";
import { useSession } from "../state/session-context";
import type { SessionPreview } from "../shared/types";
import { useTheme, type AppTheme } from "../theme";
export default function AgencyJoinScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RouteProp<RootStackParamList, "AgencyJoin">>();
const manager = useSession();
const { t } = useI18n();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const placeholderColor = theme.colors.placeholder;
const handledLinkRef = useRef<string | null>(null);
const insets = useSafeAreaInsets();
const [joinCode, setJoinCode] = useState("");
const [joinStep, setJoinStep] = useState<"code" | "choice">("code");
const [joinPreview, setJoinPreview] = useState<SessionPreview | null>(null);
const [joinName, setJoinName] = useState("");
const [takeoverName, setTakeoverName] = useState("");
const [takeoverDummyId, setTakeoverDummyId] = useState("");
const [showDummyOptions, setShowDummyOptions] = useState(false);
const [takeoverToken, setTakeoverToken] = useState<string | null>(null);
const [takeoverWaiting, setTakeoverWaiting] = useState(false);
const dummyOptions = useMemo(
() => joinPreview?.players.filter((player) => player.isDummy) ?? [],
[joinPreview],
);
const storedPlayer = joinPreview?.players.find((player) => player.id === manager.playerId);
const takeoverDisabled = storedPlayer?.connected === true;
async function resolvePreview(code: string) {
const preview = await manager.fetchSessionPreview(code);
if (!preview) {
Alert.alert(t("entry.alert.sessionNotFound"));
return null;
}
setJoinPreview(preview);
setJoinStep("choice");
return preview;
}
async function handleJoinPreview() {
const normalized = joinCode.trim().toUpperCase();
if (!normalized) {
Alert.alert(t("entry.alert.enterCode"));
return;
}
await resolvePreview(normalized);
}
useEffect(() => {
const raw = route.params?.gameId;
if (typeof raw !== "string") {
return;
}
const normalized = raw.trim().toUpperCase();
if (!normalized || handledLinkRef.current === normalized) {
return;
}
handledLinkRef.current = normalized;
setJoinCode(normalized);
setJoinStep("code");
setJoinPreview(null);
setJoinName("");
setTakeoverName("");
setTakeoverDummyId("");
setTakeoverToken(null);
setTakeoverWaiting(false);
void resolvePreview(normalized);
}, [route.params?.gameId]);
async function handleJoinNew() {
if (!joinPreview) return;
const data = await manager.joinSession(joinPreview.code, joinName.trim());
if (data) {
navigation.replace("Lobby");
}
}
async function handleTakeover() {
if (!joinPreview) return;
if (!takeoverDummyId) {
Alert.alert(t("entry.alert.selectDummy"));
return;
}
setTakeoverWaiting(true);
const selectedDummy = joinPreview.players.find((player) => player.id === takeoverDummyId);
const fallbackName = takeoverName.trim() || selectedDummy?.name || "";
const token = await manager.requestTakeoverToken(
joinPreview.code,
takeoverDummyId,
fallbackName,
);
if (!token) {
setTakeoverWaiting(false);
if (manager.error) {
Alert.alert(manager.error);
}
return;
}
setTakeoverToken(token);
}
useEffect(() => {
if (joinStep === "code" || !joinPreview) {
setShowDummyOptions(false);
setTakeoverToken(null);
setTakeoverWaiting(false);
}
}, [joinStep, joinPreview]);
useEffect(() => {
if (!takeoverToken || !joinPreview) return;
let cancelled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
const data = await manager.claimTakeover(joinPreview.code, takeoverToken);
if (cancelled) return;
if (data) {
setTakeoverWaiting(false);
setTakeoverToken(null);
navigation.replace("Lobby");
return;
}
timeout = setTimeout(poll, 2000);
};
void poll();
return () => {
cancelled = true;
if (timeout) clearTimeout(timeout);
};
}, [joinPreview, takeoverToken, manager, navigation]);
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={[
styles.container,
{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 24,
paddingLeft: insets.left + 20,
paddingRight: insets.right + 20,
},
]}
>
<View style={styles.hero}>
<BrandLockup variant="hero" subtitle={t("entry.heroBadge")} onDark />
<Text style={styles.heroTitle}>{t("entry.joinStepTitle")}</Text>
<Text style={styles.heroBody}>{t("entry.joinStepSubtitle")}</Text>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>{t("entry.joinTitle")}</Text>
<Text style={styles.cardSubtitle}>{t("entry.joinDescription")}</Text>
<TextInput
style={styles.input}
placeholder={t("entry.sessionCode")}
placeholderTextColor={placeholderColor}
autoCapitalize="characters"
value={joinCode}
onChangeText={(value) => {
setJoinCode(value.toUpperCase());
if (joinStep === "choice") {
setJoinStep("code");
setJoinPreview(null);
setJoinName("");
setTakeoverName("");
setTakeoverDummyId("");
setShowDummyOptions(false);
}
}}
/>
<TouchableOpacity style={styles.button} onPress={handleJoinPreview}>
<Text style={styles.buttonText}>{t("common.continue")}</Text>
</TouchableOpacity>
</View>
{joinStep === "choice" && joinPreview ? (
<>
<View style={styles.previewCard}>
<Text style={styles.previewEyebrow}>{t("entry.previewLabel")}</Text>
<Text style={styles.previewTitle}>
{t("entry.agencyCodeValue", { code: joinPreview.code })}
</Text>
<Text style={styles.previewBody}>
{t("entry.previewCustomers", { count: joinPreview.players.length })}
</Text>
</View>
<View style={styles.choiceCard}>
<Text style={styles.choiceTitle}>{t("entry.newPlayer")}</Text>
<Text style={styles.choiceBody}>{t("entry.newCustomerDescription")}</Text>
<TextInput
style={styles.input}
placeholder={t("entry.playerName")}
placeholderTextColor={placeholderColor}
value={joinName}
onChangeText={setJoinName}
/>
<TouchableOpacity style={styles.buttonSecondary} onPress={handleJoinNew}>
<Text style={styles.buttonSecondaryText}>{t("entry.joinAsCustomer")}</Text>
</TouchableOpacity>
</View>
<View style={styles.choiceCard}>
<Text style={styles.choiceTitle}>{t("entry.takeoverTitle")}</Text>
<Text style={styles.choiceBody}>{t("entry.recoverCustomerDescription")}</Text>
{takeoverDisabled ? (
<Text style={styles.helper}>{t("entry.alreadyConnected")}</Text>
) : takeoverWaiting ? (
<View style={styles.pendingBox}>
<Text style={styles.helper}>{t("entry.takeoverPending")}</Text>
<TouchableOpacity
style={styles.buttonSecondary}
onPress={() => {
setTakeoverToken(null);
setTakeoverWaiting(false);
}}
>
<Text style={styles.buttonSecondaryText}>{t("common.cancel")}</Text>
</TouchableOpacity>
</View>
) : (
<>
<View style={styles.dropdown}>
<TouchableOpacity
style={styles.dropdownButton}
onPress={() => {
if (dummyOptions.length === 0) return;
setShowDummyOptions((prev) => !prev);
}}
>
<Text style={styles.dropdownText}>
{dummyOptions.find((player) => player.id === takeoverDummyId)?.name
? `${dummyOptions.find((player) => player.id === takeoverDummyId)?.name} · ${takeoverDummyId}`
: t("entry.selectDummy")}
</Text>
</TouchableOpacity>
{showDummyOptions && dummyOptions.length > 0 ? (
<View style={styles.dropdownList}>
{dummyOptions.map((player) => (
<TouchableOpacity
key={player.id}
style={[
styles.dropdownItem,
player.id === takeoverDummyId ? styles.dropdownItemActive : null,
]}
onPress={() => {
setTakeoverDummyId(player.id);
setShowDummyOptions(false);
}}
>
<Text style={styles.dropdownItemText}>{player.name}</Text>
<Text style={styles.dropdownItemMeta}>{player.id}</Text>
</TouchableOpacity>
))}
</View>
) : null}
</View>
<TextInput
style={styles.input}
placeholder={t("entry.yourNameOptional")}
placeholderTextColor={placeholderColor}
value={takeoverName}
onChangeText={setTakeoverName}
/>
<TouchableOpacity style={styles.buttonSecondary} onPress={handleTakeover}>
<Text style={styles.buttonSecondaryText}>{t("entry.requestTakeover")}</Text>
</TouchableOpacity>
</>
)}
{!takeoverDisabled && dummyOptions.length === 0 ? (
<Text style={styles.helper}>{t("entry.noDummies")}</Text>
) : null}
</View>
</>
) : null}
<TouchableOpacity
style={styles.linkButton}
onPress={() => navigation.replace("AgencyCreate")}
>
<Text style={styles.linkText}>{t("entry.linkOpenNew")}</Text>
</TouchableOpacity>
</ScrollView>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
scroll: {
flex: 1,
backgroundColor: theme.colors.background,
},
container: {
gap: 18,
backgroundColor: theme.colors.background,
},
hero: {
borderRadius: 28,
padding: 22,
gap: 12,
backgroundColor: theme.colors.brandSurface,
borderWidth: 1,
borderColor: theme.colors.brandSurfaceAlt,
},
heroTitle: {
color: theme.colors.brandText,
fontSize: 28,
fontWeight: "800",
letterSpacing: -0.8,
},
heroBody: {
color: theme.colors.brandTextMuted,
fontSize: 15,
lineHeight: 22,
},
card: {
backgroundColor: theme.colors.surface,
borderRadius: 24,
padding: 20,
gap: 12,
borderWidth: 1,
borderColor: theme.colors.border,
},
cardTitle: {
color: theme.colors.text,
fontSize: 22,
fontWeight: "700",
letterSpacing: -0.4,
},
cardSubtitle: {
color: theme.colors.textMuted,
fontSize: 14,
lineHeight: 21,
},
input: {
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.inputBackground,
color: theme.colors.inputText,
borderRadius: 16,
paddingHorizontal: 14,
paddingVertical: 13,
},
button: {
backgroundColor: theme.colors.primary,
paddingVertical: 14,
borderRadius: 999,
alignItems: "center",
},
buttonText: {
color: theme.colors.primaryText,
fontWeight: "700",
},
previewCard: {
borderRadius: 24,
padding: 20,
gap: 8,
backgroundColor: theme.colors.accentSurface,
borderWidth: 1,
borderColor: theme.colors.borderMuted,
},
previewEyebrow: {
color: theme.colors.accent,
fontSize: 12,
fontWeight: "700",
letterSpacing: 1.1,
textTransform: "uppercase",
},
previewTitle: {
color: theme.colors.text,
fontSize: 22,
fontWeight: "800",
letterSpacing: -0.5,
},
previewBody: {
color: theme.colors.textMuted,
},
choiceCard: {
backgroundColor: theme.colors.surface,
borderRadius: 24,
padding: 20,
gap: 12,
borderWidth: 1,
borderColor: theme.colors.border,
},
choiceTitle: {
fontSize: 18,
fontWeight: "700",
color: theme.colors.text,
},
choiceBody: {
color: theme.colors.textMuted,
lineHeight: 20,
},
dropdown: {
gap: 6,
},
dropdownButton: {
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.inputBackground,
borderRadius: 16,
paddingHorizontal: 14,
paddingVertical: 13,
},
dropdownText: {
color: theme.colors.inputText,
fontWeight: "600",
},
dropdownList: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 16,
backgroundColor: theme.colors.surface,
overflow: "hidden",
},
pendingBox: {
gap: 10,
backgroundColor: theme.colors.surfaceAlt,
borderRadius: 16,
padding: 14,
borderWidth: 1,
borderColor: theme.colors.borderMuted,
},
dropdownItem: {
paddingHorizontal: 14,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: theme.colors.borderMuted,
},
dropdownItemActive: {
backgroundColor: theme.colors.accentSurface,
},
dropdownItemText: {
fontWeight: "600",
color: theme.colors.text,
},
dropdownItemMeta: {
color: theme.colors.textMuted,
fontSize: 12,
},
buttonSecondary: {
backgroundColor: theme.colors.secondary,
paddingVertical: 14,
borderRadius: 999,
alignItems: "center",
},
buttonSecondaryText: {
color: theme.colors.secondaryText,
fontWeight: "700",
},
helper: {
fontSize: 12,
color: theme.colors.textMuted,
},
linkButton: {
paddingVertical: 12,
alignItems: "center",
},
linkText: {
color: theme.colors.textMuted,
fontWeight: "600",
},
});

View file

@ -0,0 +1,126 @@
import React, { useMemo } from "react";
import {
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import BrandLockup from "../components/BrandLockup";
import { useI18n } from "../i18n";
import type { RootStackParamList } from "../navigation/types";
import { useTheme, type AppTheme } from "../theme";
export default function EntryLandingScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { t } = useI18n();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const insets = useSafeAreaInsets();
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={[
styles.container,
{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 24,
paddingLeft: insets.left + 20,
paddingRight: insets.right + 20,
},
]}
>
<View style={styles.hero}>
<BrandLockup variant="hero" subtitle={t("entry.heroBadge")} onDark />
<Text style={styles.heroTitle}>{t("entry.landingTitle")}</Text>
<Text style={styles.heroBody}>{t("entry.landingBody")}</Text>
</View>
<View style={styles.actions}>
<TouchableOpacity
style={styles.primaryButton}
onPress={() => navigation.navigate("AgencyJoin")}
>
<Text style={styles.primaryButtonText}>{t("entry.accessAgency")}</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.secondaryButton}
onPress={() => navigation.navigate("AgencyCreate")}
>
<Text style={styles.secondaryButtonText}>{t("entry.openAgency")}</Text>
</TouchableOpacity>
</View>
<Text style={styles.footerNote}>{t("entry.landingFooter")}</Text>
</ScrollView>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
scroll: {
flex: 1,
backgroundColor: theme.colors.background,
},
container: {
gap: 18,
backgroundColor: theme.colors.background,
},
hero: {
borderRadius: 28,
padding: 22,
gap: 14,
backgroundColor: theme.colors.brandSurface,
borderWidth: 1,
borderColor: theme.colors.brandSurfaceAlt,
},
heroTitle: {
color: theme.colors.brandText,
fontSize: 28,
fontWeight: "800",
letterSpacing: -1,
},
heroBody: {
color: theme.colors.brandTextMuted,
lineHeight: 21,
fontSize: 14,
},
actions: {
gap: 12,
},
primaryButton: {
borderRadius: 999,
paddingVertical: 16,
alignItems: "center",
backgroundColor: theme.colors.primary,
},
primaryButtonText: {
color: theme.colors.primaryText,
fontSize: 16,
fontWeight: "700",
},
secondaryButton: {
borderRadius: 999,
paddingVertical: 16,
alignItems: "center",
backgroundColor: theme.colors.surface,
borderWidth: 1,
borderColor: theme.colors.border,
},
secondaryButtonText: {
color: theme.colors.text,
fontSize: 16,
fontWeight: "700",
},
footerNote: {
color: theme.colors.textMuted,
fontSize: 13,
lineHeight: 19,
textAlign: "center",
},
});

View file

@ -1,456 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useNavigation, useRoute } from "@react-navigation/native";
import type { RouteProp } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { RootStackParamList } from "../navigation/types";
import { useSession } from "../state/session-context";
import type { SessionPreview } from "../shared/types";
import { useI18n } from "../i18n";
import { useTheme } from "../theme";
import type { AppTheme } from "../theme";
export default function EntryScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RouteProp<RootStackParamList, "Entry">>();
const manager = useSession();
const { t } = useI18n();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const placeholderColor = theme.colors.placeholder;
const handledLinkRef = useRef<string | null>(null);
const insets = useSafeAreaInsets();
const contentStyle = useMemo(
() => [
styles.container,
{
paddingTop: insets.top + 20,
paddingBottom: insets.bottom + 20,
paddingLeft: insets.left + 20,
paddingRight: insets.right + 20,
},
],
[styles.container, insets.top, insets.bottom, insets.left, insets.right],
);
const [createName, setCreateName] = useState("");
const [joinCode, setJoinCode] = useState("");
const [joinStep, setJoinStep] = useState<"code" | "choice">("code");
const [joinPreview, setJoinPreview] = useState<SessionPreview | null>(null);
const [joinName, setJoinName] = useState("");
const [takeoverName, setTakeoverName] = useState("");
const [takeoverDummyId, setTakeoverDummyId] = useState("");
const [showDummyOptions, setShowDummyOptions] = useState(false);
const [takeoverToken, setTakeoverToken] = useState<string | null>(null);
const [takeoverWaiting, setTakeoverWaiting] = useState(false);
const dummyOptions = useMemo(
() => joinPreview?.players.filter((player) => player.isDummy) ?? [],
[joinPreview],
);
const storedPlayer = joinPreview?.players.find((player) => player.id === manager.playerId);
const takeoverDisabled = storedPlayer?.connected === true;
async function handleCreate() {
const data = await manager.createSession(createName.trim());
if (data) {
navigation.replace("Lobby");
}
}
async function handleJoinPreview() {
if (!joinCode.trim()) {
Alert.alert(t("entry.alert.enterCode"));
return;
}
const preview = await manager.fetchSessionPreview(joinCode.trim().toUpperCase());
if (!preview) {
Alert.alert(t("entry.alert.sessionNotFound"));
return;
}
setJoinPreview(preview);
setJoinStep("choice");
}
useEffect(() => {
const raw = route.params?.gameId;
if (typeof raw !== "string") return;
const normalized = raw.trim();
if (!normalized) {
if (__DEV__) {
console.log("[deep-link] invalid gameId");
}
return;
}
const code = normalized.toUpperCase();
if (handledLinkRef.current === code) return;
handledLinkRef.current = code;
if (__DEV__) {
console.log(`[deep-link] navigating to session ${code}`);
}
setJoinCode(code);
setJoinStep("code");
setJoinPreview(null);
setJoinName("");
setTakeoverName("");
setTakeoverDummyId("");
setTakeoverToken(null);
setTakeoverWaiting(false);
manager.fetchSessionPreview(code).then((preview) => {
if (!preview) {
Alert.alert(t("entry.alert.sessionNotFound"));
return;
}
setJoinPreview(preview);
setJoinStep("choice");
});
}, [route.params?.gameId, manager, t]);
async function handleJoinNew() {
if (!joinPreview) return;
const data = await manager.joinSession(joinPreview.code, joinName.trim());
if (data) {
navigation.replace("Lobby");
}
}
async function handleTakeover() {
if (!joinPreview) return;
if (!takeoverDummyId) {
Alert.alert(t("entry.alert.selectDummy"));
return;
}
setTakeoverWaiting(true);
const selectedDummy = joinPreview.players.find((player) => player.id === takeoverDummyId);
const fallbackName = takeoverName.trim() || selectedDummy?.name || "";
const token = await manager.requestTakeoverToken(
joinPreview.code,
takeoverDummyId,
fallbackName,
);
if (!token) {
setTakeoverWaiting(false);
if (manager.error) {
Alert.alert(manager.error);
}
return;
}
setTakeoverToken(token);
}
useEffect(() => {
if (joinStep === "code" || !joinPreview) {
setShowDummyOptions(false);
setTakeoverToken(null);
setTakeoverWaiting(false);
}
}, [joinStep, joinPreview]);
useEffect(() => {
if (!takeoverToken || !joinPreview) return;
let cancelled = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
const data = await manager.claimTakeover(joinPreview.code, takeoverToken);
if (cancelled) return;
if (data) {
setTakeoverWaiting(false);
setTakeoverToken(null);
navigation.replace("Lobby");
return;
}
timeout = setTimeout(poll, 2000);
};
poll();
return () => {
cancelled = true;
if (timeout) clearTimeout(timeout);
};
}, [takeoverToken, joinPreview, manager, navigation]);
return (
<ScrollView style={styles.scroll} contentContainerStyle={contentStyle}>
<Text style={styles.title}>{t("app.name")}</Text>
<Text style={styles.subtitle}>{t("entry.subtitle")}</Text>
<View style={styles.card}>
<Text style={styles.cardTitle}>{t("entry.joinTitle")}</Text>
<TextInput
style={styles.input}
placeholder={t("entry.sessionCode")}
placeholderTextColor={placeholderColor}
autoCapitalize="characters"
value={joinCode}
onChangeText={(value) => {
setJoinCode(value.toUpperCase());
if (joinStep === "choice") {
setJoinStep("code");
setJoinPreview(null);
setJoinName("");
setTakeoverName("");
setTakeoverDummyId("");
setShowDummyOptions(false);
}
}}
/>
{joinStep === "code" ? (
<TouchableOpacity style={styles.buttonSecondary} onPress={handleJoinPreview}>
<Text style={styles.buttonSecondaryText}>{t("common.continue")}</Text>
</TouchableOpacity>
) : null}
{joinStep === "choice" && joinPreview ? (
<View style={styles.choiceGrid}>
<View style={styles.choiceCard}>
<Text style={styles.choiceTitle}>{t("entry.newPlayer")}</Text>
<TextInput
style={styles.input}
placeholder={t("entry.playerName")}
placeholderTextColor={placeholderColor}
value={joinName}
onChangeText={setJoinName}
/>
<TouchableOpacity style={styles.buttonSecondary} onPress={handleJoinNew}>
<Text style={styles.buttonSecondaryText}>{t("common.join")}</Text>
</TouchableOpacity>
</View>
<View style={styles.choiceCard}>
<Text style={styles.choiceTitle}>{t("entry.takeoverTitle")}</Text>
{takeoverDisabled ? (
<Text style={styles.helper}>{t("entry.alreadyConnected")}</Text>
) : (
<>
{takeoverWaiting ? (
<View style={styles.pendingBox}>
<Text style={styles.helper}>{t("entry.takeoverPending")}</Text>
<TouchableOpacity
style={styles.buttonSecondary}
onPress={() => {
setTakeoverToken(null);
setTakeoverWaiting(false);
}}
>
<Text style={styles.buttonSecondaryText}>{t("common.cancel")}</Text>
</TouchableOpacity>
</View>
) : (
<>
<View style={styles.dropdown}>
<TouchableOpacity
style={styles.dropdownButton}
onPress={() => {
if (dummyOptions.length === 0) return;
setShowDummyOptions((prev) => !prev);
}}
>
<Text style={styles.dropdownText}>
{dummyOptions.find((player) => player.id === takeoverDummyId)?.name
? `${dummyOptions.find((player) => player.id === takeoverDummyId)?.name} · ${takeoverDummyId}`
: t("entry.selectDummy")}
</Text>
</TouchableOpacity>
{showDummyOptions && dummyOptions.length > 0 ? (
<View style={styles.dropdownList}>
{dummyOptions.map((player) => (
<TouchableOpacity
key={player.id}
style={[
styles.dropdownItem,
player.id === takeoverDummyId
? styles.dropdownItemActive
: null,
]}
onPress={() => {
setTakeoverDummyId(player.id);
setShowDummyOptions(false);
}}
>
<Text style={styles.dropdownItemText}>{player.name}</Text>
<Text style={styles.dropdownItemMeta}>{player.id}</Text>
</TouchableOpacity>
))}
</View>
) : null}
</View>
<TextInput
style={styles.input}
placeholder={t("entry.yourNameOptional")}
placeholderTextColor={placeholderColor}
value={takeoverName}
onChangeText={setTakeoverName}
/>
<TouchableOpacity style={styles.buttonSecondary} onPress={handleTakeover}>
<Text style={styles.buttonSecondaryText}>
{t("entry.requestTakeover")}
</Text>
</TouchableOpacity>
</>
)}
</>
)}
{!takeoverDisabled && dummyOptions.length === 0 ? (
<Text style={styles.helper}>{t("entry.noDummies")}</Text>
) : null}
</View>
</View>
) : null}
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>{t("entry.createTitle")}</Text>
<TextInput
style={styles.input}
placeholder={t("entry.bankerName")}
placeholderTextColor={placeholderColor}
value={createName}
onChangeText={setCreateName}
/>
<TouchableOpacity style={styles.button} onPress={handleCreate}>
<Text style={styles.buttonText}>{t("entry.openVault")}</Text>
</TouchableOpacity>
</View>
</ScrollView>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
scroll: {
flex: 1,
backgroundColor: theme.colors.background,
},
container: {
padding: 0,
gap: 16,
},
title: {
fontSize: 28,
fontWeight: "700",
color: theme.colors.text,
},
subtitle: {
fontSize: 16,
color: theme.colors.textMuted,
},
card: {
backgroundColor: theme.colors.surface,
borderRadius: 16,
padding: 16,
shadowColor: "#000",
shadowOpacity: theme.dark ? 0.2 : 0.08,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
},
cardTitle: {
fontSize: 18,
fontWeight: "600",
marginBottom: 12,
color: theme.colors.text,
},
input: {
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.inputBackground,
color: theme.colors.inputText,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 10,
},
dropdown: {
gap: 6,
marginBottom: 10,
},
dropdownButton: {
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.inputBackground,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
},
dropdownText: {
color: theme.colors.inputText,
fontWeight: "600",
},
dropdownList: {
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: 12,
backgroundColor: theme.colors.surface,
overflow: "hidden",
},
pendingBox: {
gap: 10,
backgroundColor: theme.colors.surfaceAlt,
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: theme.colors.borderMuted,
},
dropdownItem: {
paddingHorizontal: 12,
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: theme.colors.borderMuted,
},
dropdownItemActive: {
backgroundColor: theme.colors.accentSurface,
},
dropdownItemText: {
fontWeight: "600",
color: theme.colors.text,
},
dropdownItemMeta: {
color: theme.colors.textMuted,
fontSize: 12,
},
button: {
backgroundColor: theme.colors.primary,
paddingVertical: 12,
borderRadius: 999,
alignItems: "center",
},
buttonText: {
color: theme.colors.primaryText,
fontWeight: "600",
},
buttonSecondary: {
backgroundColor: theme.colors.secondary,
paddingVertical: 12,
borderRadius: 999,
alignItems: "center",
},
buttonSecondaryText: {
color: theme.colors.secondaryText,
fontWeight: "600",
},
choiceGrid: {
marginTop: 12,
gap: 12,
},
choiceCard: {
backgroundColor: theme.colors.surfaceAlt,
borderRadius: 12,
padding: 12,
},
choiceTitle: {
fontWeight: "600",
marginBottom: 8,
color: theme.colors.text,
},
helper: {
fontSize: 12,
color: theme.colors.textMuted,
},
});

View file

@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from "react";
import {
FlatList,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
@ -11,12 +11,12 @@ import {
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import BrandLockup from "../components/BrandLockup";
import ExitGameButton from "../components/ExitGameButton";
import { useI18n } from "../i18n";
import type { RootStackParamList } from "../navigation/types";
import { useSession } from "../state/session-context";
import { useI18n } from "../i18n";
import { useTheme } from "../theme";
import type { AppTheme } from "../theme";
import ExitGameButton from "../components/ExitGameButton";
import { useTheme, type AppTheme } from "../theme";
export default function LobbyScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
@ -29,24 +29,8 @@ export default function LobbyScreen() {
const [dummyBalance, setDummyBalance] = useState("1500");
const insets = useSafeAreaInsets();
const topInset = insets.top || (Platform.OS === "ios" ? 44 : 0);
const containerStyle = useMemo(
() => [
styles.container,
{
paddingTop: topInset + 20,
paddingBottom: insets.bottom + 20,
paddingLeft: insets.left + 20,
paddingRight: insets.right + 20,
},
],
[
styles.container,
topInset,
insets.bottom,
insets.left,
insets.right,
],
);
const customers = manager.session?.players.filter((player) => player.role !== "banker") ?? [];
const assistedCount = customers.filter((player) => player.isDummy).length;
useEffect(() => {
if (!manager.session || !manager.me) return;
@ -57,8 +41,19 @@ export default function LobbyScreen() {
if (!manager.session || !manager.me) {
return (
<View style={containerStyle}>
<Text style={styles.title}>{t("common.loadingLobby")}</Text>
<View
style={[
styles.loadingContainer,
{
paddingTop: topInset + 20,
paddingBottom: insets.bottom + 20,
paddingLeft: insets.left + 20,
paddingRight: insets.right + 20,
},
]}
>
<BrandLockup variant="hero" subtitle={t("lobby.title")} />
<Text style={styles.loadingText}>{t("common.loadingLobby")}</Text>
{manager.error ? <Text style={styles.helper}>{manager.error}</Text> : null}
<ExitGameButton mode="full" />
</View>
@ -67,43 +62,69 @@ export default function LobbyScreen() {
const session = manager.session;
const me = manager.me;
const canStart = manager.isBanker && session.status === "lobby";
const pendingTakeover = session.takeoverRequests.find(
(request) =>
request.requesterId === manager.playerId && request.status === "pending",
(request) => request.requesterId === manager.playerId && request.status === "pending",
);
const pendingRequests = manager.isBanker
? session.takeoverRequests.filter((request) => request.status === "pending")
: [];
return (
<View style={containerStyle}>
<Text style={styles.title}>{t("lobby.title")}</Text>
<Text style={styles.subtitle}>{t("lobby.code", { code: session.code })}</Text>
<ScrollView
style={styles.scroll}
contentContainerStyle={{
paddingTop: topInset + 16,
paddingBottom: insets.bottom + 24,
paddingLeft: insets.left + 20,
paddingRight: insets.right + 20,
gap: 16,
}}
>
<View style={styles.hero}>
<BrandLockup variant="hero" subtitle={t("lobby.title")} onDark />
<Text style={styles.heroTitle}>{t("lobby.code", { code: session.code })}</Text>
<Text style={styles.heroBody}>
{manager.isBanker ? t("lobby.heroAdvisor") : t("lobby.heroCustomer")}
</Text>
<View style={styles.metrics}>
<View style={styles.metricCard}>
<Text style={styles.metricValue}>{customers.length}</Text>
<Text style={styles.metricLabel}>{t("lobby.customers")}</Text>
</View>
<View style={styles.metricCard}>
<Text style={styles.metricValue}>{assistedCount}</Text>
<Text style={styles.metricLabel}>{t("lobby.assisted")}</Text>
</View>
</View>
</View>
{pendingTakeover ? (
<Text style={styles.helper}>{t("entry.takeoverPending")}</Text>
<View style={styles.noticeCard}>
<Text style={styles.noticeText}>{t("entry.takeoverPending")}</Text>
</View>
) : null}
<FlatList
data={session.players}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<View style={styles.listItem}>
<View>
<Text style={styles.playerName}>{item.name}</Text>
<Text style={styles.playerMeta}>
{item.role === "banker" ? t("common.banker") : t("common.player")}{" "}
{item.isDummy ? `- ${t("common.dummy")}` : ""}
<View style={styles.card}>
<Text style={styles.cardTitle}>{t("lobby.rosterTitle")}</Text>
<View style={styles.roster}>
{session.players.map((item) => (
<View key={item.id} style={styles.listItem}>
<View style={styles.listCopy}>
<Text style={styles.playerName}>{item.name}</Text>
<Text style={styles.playerMeta}>
{item.role === "banker" ? t("common.banker") : t("common.player")}
{item.isDummy ? ` · ${t("common.dummy")}` : ""}
</Text>
</View>
<Text style={styles.statusText}>
{item.connected ? t("common.online") : t("common.offline")}
</Text>
</View>
<Text style={styles.playerMeta}>
{item.connected ? t("common.online") : t("common.offline")}
</Text>
</View>
)}
/>
))}
</View>
</View>
{manager.isBanker && pendingRequests.length > 0 ? (
<View style={styles.card}>
@ -128,12 +149,12 @@ export default function LobbyScreen() {
style={styles.buttonSmall}
onPress={() =>
manager.sendMessage({
type: "banker_takeover_approve",
sessionId: manager.sessionId,
bankerId: me.id,
dummyId: request.dummyId,
requesterId: request.requesterId,
})
type: "banker_takeover_approve",
sessionId: manager.sessionId,
bankerId: me.id,
dummyId: request.dummyId,
requesterId: request.requesterId,
})
}
>
<Text style={styles.buttonSmallText}>{t("banker.approve")}</Text>
@ -145,10 +166,10 @@ export default function LobbyScreen() {
</View>
) : null}
{manager.isBanker && session.status === "lobby" && (
{manager.isBanker ? (
<View style={styles.card}>
<Text style={styles.cardTitle}>{t("lobby.addDummyTitle")}</Text>
<Text style={styles.helper}>{t("lobby.addDummySubtitle")}</Text>
<Text style={styles.cardSubtitle}>{t("lobby.addDummySubtitle")}</Text>
<TextInput
style={styles.input}
placeholder={t("lobby.enterDummyName")}
@ -181,9 +202,14 @@ export default function LobbyScreen() {
<Text style={styles.buttonSecondaryText}>{t("lobby.addDummyButton")}</Text>
</TouchableOpacity>
</View>
) : (
<View style={styles.waitingCard}>
<Text style={styles.waitingTitle}>{t("lobby.waitingTitle")}</Text>
<Text style={styles.waitingBody}>{t("lobby.waitingBody")}</Text>
</View>
)}
{canStart && (
{canStart ? (
<TouchableOpacity
style={styles.button}
onPress={() =>
@ -196,128 +222,213 @@ export default function LobbyScreen() {
>
<Text style={styles.buttonText}>{t("lobby.startGame")}</Text>
</TouchableOpacity>
)}
) : null}
<ExitGameButton mode="full" />
</View>
</ScrollView>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
container: {
scroll: {
flex: 1,
paddingHorizontal: 0,
paddingBottom: 0,
gap: 12,
backgroundColor: theme.colors.background,
},
title: {
fontSize: 24,
fontWeight: "700",
loadingContainer: {
flex: 1,
gap: 16,
backgroundColor: theme.colors.background,
justifyContent: "center",
},
loadingText: {
color: theme.colors.text,
fontSize: 20,
fontWeight: "700",
},
subtitle: {
hero: {
borderRadius: 28,
padding: 22,
gap: 12,
backgroundColor: theme.colors.brandSurface,
borderWidth: 1,
borderColor: theme.colors.brandSurfaceAlt,
},
heroTitle: {
color: theme.colors.brandText,
fontSize: 26,
fontWeight: "800",
letterSpacing: -0.8,
},
heroBody: {
color: theme.colors.brandTextMuted,
fontSize: 15,
lineHeight: 22,
},
metrics: {
flexDirection: "row",
gap: 12,
},
metricCard: {
flex: 1,
borderRadius: 18,
padding: 14,
backgroundColor: theme.colors.brandSurfaceAlt,
gap: 4,
},
metricValue: {
color: theme.colors.brandText,
fontSize: 22,
fontWeight: "800",
},
metricLabel: {
color: theme.colors.brandTextMuted,
fontSize: 12,
textTransform: "uppercase",
letterSpacing: 1,
},
noticeCard: {
borderRadius: 18,
padding: 14,
backgroundColor: theme.colors.warningSurface,
borderWidth: 1,
borderColor: theme.colors.warningBorder,
},
noticeText: {
color: theme.colors.warningTextStrong,
fontWeight: "600",
},
card: {
backgroundColor: theme.colors.surface,
borderRadius: 24,
padding: 20,
gap: 12,
borderWidth: 1,
borderColor: theme.colors.border,
},
cardTitle: {
color: theme.colors.text,
fontSize: 20,
fontWeight: "700",
},
cardSubtitle: {
color: theme.colors.textMuted,
lineHeight: 21,
},
list: {
roster: {
gap: 10,
paddingBottom: 20,
},
listItem: {
backgroundColor: theme.colors.surface,
borderRadius: 12,
padding: 12,
backgroundColor: theme.colors.surfaceAlt,
borderRadius: 18,
padding: 14,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: 12,
},
listCopy: {
flex: 1,
gap: 3,
},
playerName: {
fontWeight: "600",
fontWeight: "700",
color: theme.colors.text,
},
playerMeta: {
fontSize: 12,
color: theme.colors.textMuted,
},
card: {
backgroundColor: theme.colors.surface,
borderRadius: 16,
padding: 16,
gap: 10,
borderWidth: 1,
borderColor: theme.colors.borderMuted,
},
cardTitle: {
fontWeight: "600",
color: theme.colors.text,
},
helper: {
statusText: {
color: theme.colors.textMuted,
fontSize: 12,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.8,
},
input: {
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.inputBackground,
color: theme.colors.inputText,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 16,
paddingHorizontal: 14,
paddingVertical: 13,
},
button: {
backgroundColor: theme.colors.primary,
paddingVertical: 14,
paddingVertical: 15,
borderRadius: 999,
alignItems: "center",
},
buttonText: {
color: theme.colors.primaryText,
fontWeight: "600",
fontWeight: "700",
},
buttonSecondary: {
backgroundColor: theme.colors.secondary,
paddingVertical: 12,
paddingVertical: 14,
borderRadius: 999,
alignItems: "center",
},
buttonSecondaryText: {
color: theme.colors.secondaryText,
fontWeight: "600",
fontWeight: "700",
},
waitingCard: {
backgroundColor: theme.colors.accentSurface,
borderRadius: 24,
padding: 20,
gap: 8,
borderWidth: 1,
borderColor: theme.colors.borderMuted,
},
waitingTitle: {
color: theme.colors.text,
fontSize: 18,
fontWeight: "700",
},
waitingBody: {
color: theme.colors.textMuted,
lineHeight: 21,
},
helper: {
color: theme.colors.textMuted,
fontSize: 12,
},
takeoverList: {
gap: 10,
},
takeoverRow: {
borderRadius: 18,
padding: 14,
backgroundColor: theme.colors.surfaceAlt,
borderWidth: 1,
borderColor: theme.colors.borderMuted,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: 6,
borderBottomWidth: 1,
borderBottomColor: theme.colors.borderMuted,
gap: 12,
},
takeoverMeta: {
flex: 1,
paddingRight: 12,
gap: 3,
},
takeoverName: {
fontWeight: "600",
fontWeight: "700",
color: theme.colors.text,
},
takeoverSub: {
fontSize: 12,
color: theme.colors.textMuted,
marginTop: 2,
fontSize: 12,
},
buttonSmall: {
backgroundColor: theme.colors.secondary,
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: theme.colors.primary,
borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 10,
},
buttonSmallText: {
color: theme.colors.secondaryText,
fontWeight: "600",
fontSize: 12,
color: theme.colors.primaryText,
fontWeight: "700",
},
});

View file

@ -31,9 +31,10 @@ export default function PlayerTransfersScreen() {
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const placeholderColor = theme.colors.placeholder;
const screenshotDraft = manager.screenshot?.transferDraft;
const [targetId, setTargetId] = useState("");
const [amount, setAmount] = useState("");
const [note, setNote] = useState("");
const [amount, setAmount] = useState(() => screenshotDraft?.amount ?? "");
const [note, setNote] = useState(() => screenshotDraft?.note ?? "");
const [errorText, setErrorText] = useState("");
const eligible = useMemo(
@ -50,6 +51,18 @@ export default function PlayerTransfersScreen() {
}
}, [eligible, targetId]);
useEffect(() => {
if (!screenshotDraft?.targetId) return;
if (!eligible.some((player) => player.id === screenshotDraft.targetId)) return;
setTargetId(screenshotDraft.targetId);
}, [eligible, screenshotDraft?.targetId]);
useEffect(() => {
if (!screenshotDraft) return;
setAmount(screenshotDraft.amount);
setNote(screenshotDraft.note);
}, [screenshotDraft]);
const selectedPlayer = eligible.find((player) => player.id === targetId);
const quickAmounts = [10, 25, 50, 100];
const normalizedAmount = amount.replace(",", ".");

View file

@ -1,8 +1,13 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { AppState, type AppStateStatus } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types";
import { getApiBaseUrl, getWsUrl } from "../config/api";
import {
buildScreenshotFixture,
type ScreenshotFixture,
type ScreenshotScene,
} from "../dev/screenshot-fixtures";
import { tStatic } from "../i18n";
import { registerForPushNotificationsAsync } from "../notifications";
import {
@ -51,6 +56,7 @@ export function useSessionManager() {
const [sessionCode, setSessionCode] = useState("");
const [playerId, setPlayerId] = useState("");
const [session, setSession] = useState<SessionSnapshot | null>(null);
const [screenshot, setScreenshot] = useState<ScreenshotFixture | null>(null);
const [error, setError] = useState<string | null>(null);
const [connectionState, setConnectionState] =
useState<SessionConnectionState>("idle");
@ -75,6 +81,7 @@ export function useSessionManager() {
const lastActivityAtRef = useRef<number | null>(null);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
const lastPushRegistrationRef = useRef<string | null>(null);
const screenshotRef = useRef<ScreenshotFixture | null>(null);
function markActivity(at = Date.now()) {
lastActivityAtRef.current = at;
@ -307,6 +314,10 @@ export function useSessionManager() {
playerIdRef.current = playerId;
}, [playerId, sessionCode, sessionId]);
useEffect(() => {
screenshotRef.current = screenshot;
}, [screenshot]);
useEffect(() => {
sessionRef.current = session;
}, [session]);
@ -314,7 +325,7 @@ export function useSessionManager() {
useEffect(() => {
let mounted = true;
readStoredSession().then((stored) => {
if (!mounted || !stored) return;
if (!mounted || !stored || screenshotRef.current) return;
setSessionId(stored.sessionId);
setSessionCode(stored.sessionCode);
setPlayerId(stored.playerId);
@ -325,6 +336,7 @@ export function useSessionManager() {
}, []);
useEffect(() => {
if (screenshot) return;
let mounted = true;
registerForPushNotificationsAsync().then((token) => {
if (!mounted) return;
@ -333,9 +345,10 @@ export function useSessionManager() {
return () => {
mounted = false;
};
}, []);
}, [screenshot]);
useEffect(() => {
if (screenshot) return;
const subscription = AppState.addEventListener("change", (nextState) => {
const previousState = appStateRef.current;
appStateRef.current = nextState;
@ -369,14 +382,25 @@ export function useSessionManager() {
return () => {
subscription.remove();
};
}, []);
}, [screenshot]);
useEffect(() => {
if (screenshot) return;
if (!pushToken || !sessionId || !playerId) return;
void registerPushTokenFor(sessionId, playerId);
}, [pushToken, sessionId, playerId]);
}, [pushToken, screenshot, sessionId, playerId]);
useEffect(() => {
if (screenshot) {
suppressReconnectRef.current = true;
teardownConnection();
reconnectAttemptRef.current = 0;
setReconnectAttempt(0);
lastActivityAtRef.current = null;
setLastActivityAt(null);
setConnectionState("idle");
return;
}
if (!sessionId || !playerId) {
suppressReconnectRef.current = true;
teardownConnection();
@ -397,7 +421,24 @@ export function useSessionManager() {
return () => {
teardownConnection();
};
}, [playerId, sessionId]);
}, [playerId, screenshot, sessionId]);
const activateScreenshotScene = useCallback((scene: ScreenshotScene) => {
const fixture = buildScreenshotFixture(scene);
suppressReconnectRef.current = true;
teardownConnection();
reconnectAttemptRef.current = 0;
setReconnectAttempt(0);
lastActivityAtRef.current = null;
setLastActivityAt(null);
setConnectionState("idle");
setError(null);
setScreenshot(fixture);
setSessionId(fixture.sessionId);
setSessionCode(fixture.sessionCode);
setPlayerId(fixture.playerId);
setSession(fixture.session);
}, []);
async function registerPushTokenFor(targetSessionId: string, targetPlayerId: string) {
if (!pushToken) return;
@ -472,6 +513,10 @@ export function useSessionManager() {
async function createSession(bankerName: string) {
setError(null);
setScreenshot(null);
setSessionId("");
setSessionCode("");
setPlayerId("");
setSession(null);
try {
const response = await fetch(`${getApiBaseUrl()}/api/session`, {
@ -501,6 +546,10 @@ export function useSessionManager() {
async function joinSession(code: string, name: string) {
setError(null);
setScreenshot(null);
setSessionId("");
setSessionCode("");
setPlayerId("");
setSession(null);
if (!code) {
setError(tStatic("entry.alert.enterCode"));
@ -572,6 +621,10 @@ export function useSessionManager() {
async function claimTakeover(code: string, token: string) {
try {
setScreenshot(null);
setSessionId("");
setSessionCode("");
setPlayerId("");
const response = await fetch(
`${getApiBaseUrl()}/api/session/${code}/takeover-claim`,
{
@ -621,6 +674,9 @@ export function useSessionManager() {
}
function sendMessage(payload: Record<string, unknown>) {
if (screenshotRef.current) {
return;
}
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
retryConnection();
setError(
@ -649,6 +705,7 @@ export function useSessionManager() {
setSessionCode("");
setPlayerId("");
setSession(null);
setScreenshot(null);
setError(null);
setConnectionState("idle");
}
@ -687,5 +744,7 @@ export function useSessionManager() {
setPlayerId,
setSession,
requestTakeover,
activateScreenshotScene,
screenshot,
};
}

View file

@ -59,102 +59,102 @@ export type AppTheme = {
const lightTheme: AppTheme = {
dark: false,
colors: {
background: "#f7f7f9",
surface: "#ffffff",
surfaceAlt: "#f6f8fa",
text: "#0b1a2b",
textMuted: "#6b7280",
border: "#d8dee5",
borderMuted: "#e2e8f0",
primary: "#1b8b75",
primaryText: "#ffffff",
secondary: "#e7ecef",
secondaryText: "#0c1824",
accent: "#14b8a6",
accentText: "#042f2e",
accentSurface: "#ecfdf9",
danger: "#b91c1c",
warningSurface: "#fff6e5",
warningBorder: "#fde7c1",
warningText: "#b45309",
warningTextStrong: "#7c2d12",
brandSurface: "#0b1a2b",
brandSurfaceAlt: "#1f334d",
brandText: "#f8fafc",
brandTextMuted: "#9fb3c8",
brandAccent: "#14b8a6",
brandAccentText: "#042f2e",
avatarSurface: "#0f172a",
avatarText: "#e2e8f0",
chipBackground: "#ffffff",
chipBorder: "#e2e8f0",
chipText: "#0f172a",
chipActiveBackground: "#0f172a",
chipActiveText: "#f8fafc",
listAvatarBackground: "#e6f6f2",
listAvatarText: "#1b8b75",
bubbleMe: "#dff7ef",
inputBackground: "#ffffff",
inputText: "#0b1a2b",
placeholder: "#9aa6b2",
tabActive: "#0f172a",
tabInactive: "#94a3b8",
headerBackground: "#ffffff",
headerText: "#0b1a2b",
action: "#0f172a",
actionText: "#f8fafc",
radioBorder: "#cbd5f5",
background: "#f5efe6",
surface: "#fffdf8",
surfaceAlt: "#efe4d5",
text: "#162132",
textMuted: "#6d6559",
border: "#d7c8b5",
borderMuted: "#e8decf",
primary: "#162132",
primaryText: "#fffaf2",
secondary: "#e7dac9",
secondaryText: "#162132",
accent: "#b49053",
accentText: "#241a08",
accentSurface: "#f8eedf",
danger: "#a13b2d",
warningSurface: "#f5e8c8",
warningBorder: "#dec89a",
warningText: "#8b621d",
warningTextStrong: "#67470b",
brandSurface: "#162132",
brandSurfaceAlt: "#23314a",
brandText: "#fff8ee",
brandTextMuted: "#cfbea0",
brandAccent: "#b49053",
brandAccentText: "#241a08",
avatarSurface: "#23314a",
avatarText: "#fff8ee",
chipBackground: "#fffaf2",
chipBorder: "#dfd0bd",
chipText: "#162132",
chipActiveBackground: "#162132",
chipActiveText: "#fff8ee",
listAvatarBackground: "#ede1cf",
listAvatarText: "#7a6135",
bubbleMe: "#efe3d2",
inputBackground: "#fffaf2",
inputText: "#162132",
placeholder: "#9e907b",
tabActive: "#162132",
tabInactive: "#978b78",
headerBackground: "#fff8ee",
headerText: "#162132",
action: "#162132",
actionText: "#fff8ee",
radioBorder: "#d4c1a0",
},
};
const darkTheme: AppTheme = {
dark: true,
colors: {
background: "#0b0f14",
surface: "#111922",
surfaceAlt: "#0f1620",
text: "#f8fafc",
textMuted: "#a7b4c5",
border: "#1f2a37",
borderMuted: "#243244",
primary: "#1fbf98",
primaryText: "#ffffff",
secondary: "#1f2a37",
secondaryText: "#e2e8f0",
accent: "#2dd4bf",
accentText: "#04221b",
accentSurface: "#0f2a24",
danger: "#f87171",
warningSurface: "#2a1f0b",
warningBorder: "#5f3b11",
warningText: "#f59e0b",
warningTextStrong: "#fbbf24",
brandSurface: "#101a27",
brandSurfaceAlt: "#1b2b3f",
brandText: "#f8fafc",
brandTextMuted: "#9fb3c8",
brandAccent: "#2dd4bf",
brandAccentText: "#04221b",
avatarSurface: "#1e293b",
avatarText: "#e2e8f0",
chipBackground: "#111922",
chipBorder: "#273244",
chipText: "#e2e8f0",
chipActiveBackground: "#2dd4bf",
chipActiveText: "#04221b",
listAvatarBackground: "#0f2a24",
listAvatarText: "#5eead4",
bubbleMe: "#103128",
inputBackground: "#0f1620",
inputText: "#f8fafc",
placeholder: "#7f90a6",
tabActive: "#e2e8f0",
tabInactive: "#64748b",
headerBackground: "#111922",
headerText: "#f8fafc",
action: "#e2e8f0",
actionText: "#0b1a2b",
radioBorder: "#334155",
background: "#0e1420",
surface: "#141d2c",
surfaceAlt: "#1a2537",
text: "#f8f2e7",
textMuted: "#b1a48e",
border: "#243248",
borderMuted: "#2d3c55",
primary: "#f4ead9",
primaryText: "#162132",
secondary: "#243248",
secondaryText: "#f8f2e7",
accent: "#c5a56a",
accentText: "#251a08",
accentSurface: "#2c2417",
danger: "#ef8b7f",
warningSurface: "#342712",
warningBorder: "#715426",
warningText: "#e5b96a",
warningTextStrong: "#f3d598",
brandSurface: "#121a29",
brandSurfaceAlt: "#1a2740",
brandText: "#fff8ee",
brandTextMuted: "#cebda0",
brandAccent: "#c5a56a",
brandAccentText: "#251a08",
avatarSurface: "#26344d",
avatarText: "#fff8ee",
chipBackground: "#141d2c",
chipBorder: "#32425e",
chipText: "#f8f2e7",
chipActiveBackground: "#c5a56a",
chipActiveText: "#251a08",
listAvatarBackground: "#2d2418",
listAvatarText: "#e4c688",
bubbleMe: "#26311b",
inputBackground: "#101828",
inputText: "#f8f2e7",
placeholder: "#7d8aa0",
tabActive: "#f8f2e7",
tabInactive: "#7f8ca2",
headerBackground: "#121a29",
headerText: "#f8f2e7",
action: "#f4ead9",
actionText: "#162132",
radioBorder: "#43526e",
},
};