diff --git a/mobile/README.md b/mobile/README.md
index 3f9fecb..0c988f2 100644
--- a/mobile/README.md
+++ b/mobile/README.md
@@ -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
+```
diff --git a/mobile/app.json b/mobile/app.json
index 1757c91..a1330bc 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -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,
diff --git a/mobile/assets/AppIcon.icon/Assets/CréditMabligopTest1.png b/mobile/assets/AppIcon.icon/Assets/CréditMabligopTest1.png
new file mode 100644
index 0000000..47a8cfc
Binary files /dev/null and b/mobile/assets/AppIcon.icon/Assets/CréditMabligopTest1.png differ
diff --git a/mobile/assets/AppIcon.icon/Assets/LBTRDlogo.png b/mobile/assets/AppIcon.icon/Assets/LBTRDlogo.png
new file mode 100644
index 0000000..20cc826
Binary files /dev/null and b/mobile/assets/AppIcon.icon/Assets/LBTRDlogo.png differ
diff --git a/mobile/assets/AppIcon.icon/icon.json b/mobile/assets/AppIcon.icon/icon.json
new file mode 100644
index 0000000..c38c1d1
--- /dev/null
+++ b/mobile/assets/AppIcon.icon/icon.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/mobile/assets/CréditMabligopIcon.svg b/mobile/assets/CréditMabligopIcon.svg
new file mode 100644
index 0000000..c877803
--- /dev/null
+++ b/mobile/assets/CréditMabligopIcon.svg
@@ -0,0 +1,23 @@
+
+
\ No newline at end of file
diff --git a/mobile/assets/adaptive-icon.png b/mobile/assets/adaptive-icon.png
index a7743a5..b090618 100644
Binary files a/mobile/assets/adaptive-icon.png and b/mobile/assets/adaptive-icon.png differ
diff --git a/mobile/assets/favicon.png b/mobile/assets/favicon.png
index e75f697..da83d39 100644
Binary files a/mobile/assets/favicon.png and b/mobile/assets/favicon.png differ
diff --git a/mobile/assets/icon.png b/mobile/assets/icon.png
index a7743a5..b090618 100644
Binary files a/mobile/assets/icon.png and b/mobile/assets/icon.png differ
diff --git a/mobile/assets/splash-icon.png b/mobile/assets/splash-icon.png
index a7743a5..166b64c 100644
Binary files a/mobile/assets/splash-icon.png and b/mobile/assets/splash-icon.png differ
diff --git a/mobile/package-lock.json b/mobile/package-lock.json
index 044fe34..1c4772c 100644
--- a/mobile/package-lock.json
+++ b/mobile/package-lock.json
@@ -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",
diff --git a/mobile/package.json b/mobile/package.json
index eedf290..a1073c9 100644
--- a/mobile/package.json
+++ b/mobile/package.json
@@ -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",
diff --git a/mobile/scripts/generate-screenshots.mjs b/mobile/scripts/generate-screenshots.mjs
new file mode 100644
index 0000000..e1a8d0c
--- /dev/null
+++ b/mobile/scripts/generate-screenshots.mjs
@@ -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);
+}
diff --git a/mobile/src/App.tsx b/mobile/src/App.tsx
index 49948aa..f77e172 100644
--- a/mobile/src/App.tsx
+++ b/mobile/src/App.tsx
@@ -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(null);
const lastLinkRef = useRef(null);
const pendingNotificationRef = useRef(null);
+ const pendingScreenshotRef = useRef(false);
const lastNotificationIdRef = useRef(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>(
() => ({
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]);
diff --git a/mobile/src/components/BrandLockup.tsx b/mobile/src/components/BrandLockup.tsx
new file mode 100644
index 0000000..9ca9b06
--- /dev/null
+++ b/mobile/src/components/BrandLockup.tsx
@@ -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 (
+
+
+
+
+
+
+ Crédit Mabligop
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+
+ );
+}
+
+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,
+ },
+ });
+};
diff --git a/mobile/src/components/BrandMark.tsx b/mobile/src/components/BrandMark.tsx
new file mode 100644
index 0000000..140bd6f
--- /dev/null
+++ b/mobile/src/components/BrandMark.tsx
@@ -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 (
+
+ );
+}
diff --git a/mobile/src/dev/screenshot-fixtures.ts b/mobile/src/dev/screenshot-fixtures.ts
new file mode 100644
index 0000000..20a8676
--- /dev/null
+++ b/mobile/src/dev/screenshot-fixtures.ts
@@ -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;
+ };
+ 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" },
+ },
+ },
+ },
+ };
+}
diff --git a/mobile/src/i18n.ts b/mobile/src/i18n.ts
index d4a73c3..7f9e536 100644
--- a/mobile/src/i18n.ts
+++ b/mobile/src/i18n.ts
@@ -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;
diff --git a/mobile/src/navigation/AppNavigator.tsx b/mobile/src/navigation/AppNavigator.tsx
index 12c1a2a..d9691e9 100644
--- a/mobile/src/navigation/AppNavigator.tsx
+++ b/mobile/src/navigation/AppNavigator.tsx
@@ -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();
const BankerTabs = createBottomTabNavigator();
const ChatStack = createNativeStackNavigator();
+function buildHeaderTitle(section: string) {
+ return () => ;
+}
+
function ChatStackNavigator() {
const { t } = useI18n();
const theme = useTheme();
@@ -37,22 +44,23 @@ function ChatStackNavigator() {
headerShadowVisible: false,
contentStyle: { backgroundColor: theme.colors.background },
headerRight: () => ,
+ headerTitleAlign: "left",
}}
>
);
@@ -75,6 +83,7 @@ export function PlayerTabsNavigator() {
headerStyle: { backgroundColor: theme.colors.headerBackground },
headerTintColor: theme.colors.headerText,
headerRight: () => ,
+ headerTitleAlign: "left",
}}
>
(
),
@@ -92,6 +102,7 @@ export function PlayerTabsNavigator() {
component={PlayerTransfersScreen}
options={{
title: t("tabs.transfers"),
+ headerTitle: buildHeaderTitle(t("tabs.transfers")),
tabBarIcon: ({ color, size }) => (
),
@@ -129,6 +140,7 @@ export function BankerTabsNavigator() {
headerStyle: { backgroundColor: theme.colors.headerBackground },
headerTintColor: theme.colors.headerText,
headerRight: () => ,
+ headerTitleAlign: "left",
}}
>
(
),
@@ -146,6 +159,7 @@ export function BankerTabsNavigator() {
component={BankerToolsScreen}
options={{
title: t("tabs.tools"),
+ headerTitle: buildHeaderTitle(t("tabs.tools")),
tabBarIcon: ({ color, size }) => (
),
@@ -167,10 +181,10 @@ export function BankerTabsNavigator() {
}
export default function AppNavigator() {
- const { t } = useI18n();
const theme = useTheme();
return (
-
+
+
+
>();
+ 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 (
+
+
+
+ {t("entry.createStepTitle")}
+ {t("entry.createStepSubtitle")}
+
+
+
+ {t("entry.createTitle")}
+ {t("entry.createDescription")}
+
+
+ {t("entry.openAgency")}
+
+
+
+ navigation.replace("AgencyJoin")}
+ >
+ {t("entry.linkAccessExisting")}
+
+
+ );
+}
+
+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",
+ },
+ });
diff --git a/mobile/src/screens/AgencyJoinScreen.tsx b/mobile/src/screens/AgencyJoinScreen.tsx
new file mode 100644
index 0000000..980940f
--- /dev/null
+++ b/mobile/src/screens/AgencyJoinScreen.tsx
@@ -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>();
+ const route = useRoute>();
+ const manager = useSession();
+ const { t } = useI18n();
+ const theme = useTheme();
+ const styles = useMemo(() => createStyles(theme), [theme]);
+ const placeholderColor = theme.colors.placeholder;
+ const handledLinkRef = useRef(null);
+ const insets = useSafeAreaInsets();
+ const [joinCode, setJoinCode] = useState("");
+ const [joinStep, setJoinStep] = useState<"code" | "choice">("code");
+ const [joinPreview, setJoinPreview] = useState(null);
+ const [joinName, setJoinName] = useState("");
+ const [takeoverName, setTakeoverName] = useState("");
+ const [takeoverDummyId, setTakeoverDummyId] = useState("");
+ const [showDummyOptions, setShowDummyOptions] = useState(false);
+ const [takeoverToken, setTakeoverToken] = useState(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 | 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 (
+
+
+
+ {t("entry.joinStepTitle")}
+ {t("entry.joinStepSubtitle")}
+
+
+
+ {t("entry.joinTitle")}
+ {t("entry.joinDescription")}
+ {
+ setJoinCode(value.toUpperCase());
+ if (joinStep === "choice") {
+ setJoinStep("code");
+ setJoinPreview(null);
+ setJoinName("");
+ setTakeoverName("");
+ setTakeoverDummyId("");
+ setShowDummyOptions(false);
+ }
+ }}
+ />
+
+ {t("common.continue")}
+
+
+
+ {joinStep === "choice" && joinPreview ? (
+ <>
+
+ {t("entry.previewLabel")}
+
+ {t("entry.agencyCodeValue", { code: joinPreview.code })}
+
+
+ {t("entry.previewCustomers", { count: joinPreview.players.length })}
+
+
+
+
+ {t("entry.newPlayer")}
+ {t("entry.newCustomerDescription")}
+
+
+ {t("entry.joinAsCustomer")}
+
+
+
+
+ {t("entry.takeoverTitle")}
+ {t("entry.recoverCustomerDescription")}
+ {takeoverDisabled ? (
+ {t("entry.alreadyConnected")}
+ ) : takeoverWaiting ? (
+
+ {t("entry.takeoverPending")}
+ {
+ setTakeoverToken(null);
+ setTakeoverWaiting(false);
+ }}
+ >
+ {t("common.cancel")}
+
+
+ ) : (
+ <>
+
+ {
+ if (dummyOptions.length === 0) return;
+ setShowDummyOptions((prev) => !prev);
+ }}
+ >
+
+ {dummyOptions.find((player) => player.id === takeoverDummyId)?.name
+ ? `${dummyOptions.find((player) => player.id === takeoverDummyId)?.name} · ${takeoverDummyId}`
+ : t("entry.selectDummy")}
+
+
+ {showDummyOptions && dummyOptions.length > 0 ? (
+
+ {dummyOptions.map((player) => (
+ {
+ setTakeoverDummyId(player.id);
+ setShowDummyOptions(false);
+ }}
+ >
+ {player.name}
+ {player.id}
+
+ ))}
+
+ ) : null}
+
+
+
+ {t("entry.requestTakeover")}
+
+ >
+ )}
+ {!takeoverDisabled && dummyOptions.length === 0 ? (
+ {t("entry.noDummies")}
+ ) : null}
+
+ >
+ ) : null}
+
+ navigation.replace("AgencyCreate")}
+ >
+ {t("entry.linkOpenNew")}
+
+
+ );
+}
+
+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",
+ },
+ });
diff --git a/mobile/src/screens/EntryLandingScreen.tsx b/mobile/src/screens/EntryLandingScreen.tsx
new file mode 100644
index 0000000..061962a
--- /dev/null
+++ b/mobile/src/screens/EntryLandingScreen.tsx
@@ -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>();
+ const { t } = useI18n();
+ const theme = useTheme();
+ const styles = useMemo(() => createStyles(theme), [theme]);
+ const insets = useSafeAreaInsets();
+
+ return (
+
+
+
+ {t("entry.landingTitle")}
+ {t("entry.landingBody")}
+
+
+
+ navigation.navigate("AgencyJoin")}
+ >
+ {t("entry.accessAgency")}
+
+
+ navigation.navigate("AgencyCreate")}
+ >
+ {t("entry.openAgency")}
+
+
+
+ {t("entry.landingFooter")}
+
+ );
+}
+
+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",
+ },
+ });
diff --git a/mobile/src/screens/EntryScreen.tsx b/mobile/src/screens/EntryScreen.tsx
deleted file mode 100644
index 67f1b03..0000000
--- a/mobile/src/screens/EntryScreen.tsx
+++ /dev/null
@@ -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>();
- const route = useRoute>();
- const manager = useSession();
- const { t } = useI18n();
- const theme = useTheme();
- const styles = useMemo(() => createStyles(theme), [theme]);
- const placeholderColor = theme.colors.placeholder;
- const handledLinkRef = useRef(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(null);
- const [joinName, setJoinName] = useState("");
- const [takeoverName, setTakeoverName] = useState("");
- const [takeoverDummyId, setTakeoverDummyId] = useState("");
- const [showDummyOptions, setShowDummyOptions] = useState(false);
- const [takeoverToken, setTakeoverToken] = useState(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 | 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 (
-
- {t("app.name")}
- {t("entry.subtitle")}
-
-
- {t("entry.joinTitle")}
- {
- setJoinCode(value.toUpperCase());
- if (joinStep === "choice") {
- setJoinStep("code");
- setJoinPreview(null);
- setJoinName("");
- setTakeoverName("");
- setTakeoverDummyId("");
- setShowDummyOptions(false);
- }
- }}
- />
-
- {joinStep === "code" ? (
-
- {t("common.continue")}
-
- ) : null}
-
- {joinStep === "choice" && joinPreview ? (
-
-
- {t("entry.newPlayer")}
-
-
- {t("common.join")}
-
-
-
-
- {t("entry.takeoverTitle")}
- {takeoverDisabled ? (
- {t("entry.alreadyConnected")}
- ) : (
- <>
- {takeoverWaiting ? (
-
- {t("entry.takeoverPending")}
- {
- setTakeoverToken(null);
- setTakeoverWaiting(false);
- }}
- >
- {t("common.cancel")}
-
-
- ) : (
- <>
-
- {
- if (dummyOptions.length === 0) return;
- setShowDummyOptions((prev) => !prev);
- }}
- >
-
- {dummyOptions.find((player) => player.id === takeoverDummyId)?.name
- ? `${dummyOptions.find((player) => player.id === takeoverDummyId)?.name} · ${takeoverDummyId}`
- : t("entry.selectDummy")}
-
-
- {showDummyOptions && dummyOptions.length > 0 ? (
-
- {dummyOptions.map((player) => (
- {
- setTakeoverDummyId(player.id);
- setShowDummyOptions(false);
- }}
- >
- {player.name}
- {player.id}
-
- ))}
-
- ) : null}
-
-
-
-
- {t("entry.requestTakeover")}
-
-
- >
- )}
- >
- )}
- {!takeoverDisabled && dummyOptions.length === 0 ? (
- {t("entry.noDummies")}
- ) : null}
-
-
- ) : null}
-
-
-
- {t("entry.createTitle")}
-
-
- {t("entry.openVault")}
-
-
-
- );
-}
-
-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,
- },
- });
diff --git a/mobile/src/screens/LobbyScreen.tsx b/mobile/src/screens/LobbyScreen.tsx
index d389bf7..e164194 100644
--- a/mobile/src/screens/LobbyScreen.tsx
+++ b/mobile/src/screens/LobbyScreen.tsx
@@ -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>();
@@ -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 (
-
- {t("common.loadingLobby")}
+
+
+ {t("common.loadingLobby")}
{manager.error ? {manager.error} : null}
@@ -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 (
-
- {t("lobby.title")}
- {t("lobby.code", { code: session.code })}
+
+
+
+ {t("lobby.code", { code: session.code })}
+
+ {manager.isBanker ? t("lobby.heroAdvisor") : t("lobby.heroCustomer")}
+
+
+
+
+ {customers.length}
+ {t("lobby.customers")}
+
+
+ {assistedCount}
+ {t("lobby.assisted")}
+
+
+
+
{pendingTakeover ? (
- {t("entry.takeoverPending")}
+
+ {t("entry.takeoverPending")}
+
) : null}
- item.id}
- contentContainerStyle={styles.list}
- renderItem={({ item }) => (
-
-
- {item.name}
-
- {item.role === "banker" ? t("common.banker") : t("common.player")}{" "}
- {item.isDummy ? `- ${t("common.dummy")}` : ""}
+
+ {t("lobby.rosterTitle")}
+
+ {session.players.map((item) => (
+
+
+ {item.name}
+
+ {item.role === "banker" ? t("common.banker") : t("common.player")}
+ {item.isDummy ? ` · ${t("common.dummy")}` : ""}
+
+
+
+ {item.connected ? t("common.online") : t("common.offline")}
-
- {item.connected ? t("common.online") : t("common.offline")}
-
-
- )}
- />
+ ))}
+
+
{manager.isBanker && pendingRequests.length > 0 ? (
@@ -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,
+ })
}
>
{t("banker.approve")}
@@ -145,10 +166,10 @@ export default function LobbyScreen() {
) : null}
- {manager.isBanker && session.status === "lobby" && (
+ {manager.isBanker ? (
{t("lobby.addDummyTitle")}
- {t("lobby.addDummySubtitle")}
+ {t("lobby.addDummySubtitle")}
{t("lobby.addDummyButton")}
+ ) : (
+
+ {t("lobby.waitingTitle")}
+ {t("lobby.waitingBody")}
+
)}
- {canStart && (
+ {canStart ? (
@@ -196,128 +222,213 @@ export default function LobbyScreen() {
>
{t("lobby.startGame")}
- )}
+ ) : null}
-
+
);
}
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",
},
});
diff --git a/mobile/src/screens/PlayerTransfersScreen.tsx b/mobile/src/screens/PlayerTransfersScreen.tsx
index 79065b1..b903948 100644
--- a/mobile/src/screens/PlayerTransfersScreen.tsx
+++ b/mobile/src/screens/PlayerTransfersScreen.tsx
@@ -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(",", ".");
diff --git a/mobile/src/state/session.ts b/mobile/src/state/session.ts
index 83572fa..4ec6a8b 100644
--- a/mobile/src/state/session.ts
+++ b/mobile/src/state/session.ts
@@ -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(null);
+ const [screenshot, setScreenshot] = useState(null);
const [error, setError] = useState(null);
const [connectionState, setConnectionState] =
useState("idle");
@@ -75,6 +81,7 @@ export function useSessionManager() {
const lastActivityAtRef = useRef(null);
const appStateRef = useRef(AppState.currentState);
const lastPushRegistrationRef = useRef(null);
+ const screenshotRef = useRef(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) {
+ 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,
};
}
diff --git a/mobile/src/theme.ts b/mobile/src/theme.ts
index ed4e824..708768b 100644
--- a/mobile/src/theme.ts
+++ b/mobile/src/theme.ts
@@ -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",
},
};