Compare commits

..

2 commits

Author SHA1 Message Date
d8121e74a4 Rebranding de l'application mobile 2026-03-30 10:38:01 +02:00
813ffe2171 Améliorations de stabilité de l'application 2026-03-24 10:06:59 +01:00
39 changed files with 3474 additions and 1058 deletions

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View file

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

View file

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

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 680 KiB

159
mobile/package-lock.json generated
View file

@ -22,7 +22,8 @@
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-safe-area-context": "~5.6.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": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
@ -3392,6 +3393,12 @@
"node": ">=0.6" "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": { "node_modules/bplist-creator": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@ -3907,6 +3914,56 @@
"node": ">=8" "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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -4041,6 +4098,61 @@
"node": ">=8" "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": { "node_modules/dotenv": {
"version": "16.4.7", "version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@ -4109,6 +4221,18 @@
"node": ">= 0.8" "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": { "node_modules/env-editor": {
"version": "0.4.2", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@ -6009,6 +6133,12 @@
"node": ">= 0.4" "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": { "node_modules/memoize-one": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "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": "^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": { "node_modules/nullthrows": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@ -7347,6 +7489,21 @@
"react-native": "*" "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": { "node_modules/react-native/node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",

View file

@ -6,7 +6,8 @@
"start": "expo start", "start": "expo start",
"dev": "sh ./scripts/start-dev.sh", "dev": "sh ./scripts/start-dev.sh",
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios" "ios": "expo run:ios",
"screenshots:ios": "node ./scripts/generate-screenshots.mjs"
}, },
"dependencies": { "dependencies": {
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@ -23,7 +24,8 @@
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-safe-area-context": "~5.6.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": { "devDependencies": {
"@types/react": "~19.1.0", "@types/react": "~19.1.0",

View file

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

View file

@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Linking } from "react-native"; import { Linking, StyleSheet, View } from "react-native";
import { import {
CommonActions,
NavigationContainer, NavigationContainer,
type LinkingOptions, type LinkingOptions,
useNavigationContainerRef, useNavigationContainerRef,
@ -9,36 +10,51 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import * as Notifications from "expo-notifications"; import * as Notifications from "expo-notifications";
import AppNavigator from "./navigation/AppNavigator"; import AppNavigator from "./navigation/AppNavigator";
import { normalizeScreenshotScene } from "./dev/screenshot-fixtures";
import type { RootStackParamList } from "./navigation/types"; import type { RootStackParamList } from "./navigation/types";
import { SessionProvider, useSession } from "./state/session-context"; import { SessionProvider, useSession } from "./state/session-context";
import { getNavigationTheme, useTheme } from "./theme"; import { getNavigationTheme, useTheme } from "./theme";
import { parseNotificationTarget, type NotificationTarget } from "./notifications"; import { parseNotificationTarget, type NotificationTarget } from "./notifications";
import ConnectionBanner from "./components/ConnectionBanner";
function extractGameId(url: string): string | null { function getUrlSegments(url: string): string[] {
try { try {
const parsed = new URL(url); 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:") { if (parsed.protocol === "https:" || parsed.protocol === "http:") {
const match = path.match(/^\/play\/?([^/]+)?/); return pathSegments;
const id = match?.[1]?.trim();
return id ? id : null;
} }
if (parsed.host === "play") { if (parsed.host) {
const id = path.replace(/^\//, "").trim(); return [parsed.host, ...pathSegments];
return id ? id : null;
} }
const fallbackMatch = path.match(/^\/play\/?([^/]+)?/); return pathSegments;
const fallbackId = fallbackMatch?.[1]?.trim();
return fallbackId ? fallbackId : null;
} catch { } 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) { function logDeepLink(url: string) {
if (!__DEV__) return; if (!__DEV__) return;
const gameId = extractGameId(url); 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() { function RootNavigationGate() {
@ -48,15 +64,26 @@ function RootNavigationGate() {
const lastTargetRef = useRef<keyof RootStackParamList | null>(null); const lastTargetRef = useRef<keyof RootStackParamList | null>(null);
const lastLinkRef = useRef<string | null>(null); const lastLinkRef = useRef<string | null>(null);
const pendingNotificationRef = useRef<NotificationTarget | null>(null); const pendingNotificationRef = useRef<NotificationTarget | null>(null);
const pendingScreenshotRef = useRef(false);
const lastNotificationIdRef = useRef<string | null>(null); const lastNotificationIdRef = useRef<string | null>(null);
const theme = useTheme(); const theme = useTheme();
const navigationTheme = getNavigationTheme(theme); const navigationTheme = getNavigationTheme(theme);
const handleSpecialUrl = useCallback(
(url: string) => {
const scene = extractScreenshotScene(url);
if (!scene) return false;
pendingScreenshotRef.current = true;
manager.activateScreenshotScene(scene);
return true;
},
[manager.activateScreenshotScene],
);
const linking = useMemo<LinkingOptions<RootStackParamList>>( const linking = useMemo<LinkingOptions<RootStackParamList>>(
() => ({ () => ({
prefixes: ["negopoly://", "https://negopoly.fr"], prefixes: ["negopoly://", "https://negopoly.fr"],
config: { config: {
screens: { screens: {
Entry: "play/:gameId", AgencyJoin: "play/:gameId",
}, },
}, },
getInitialURL: async () => { getInitialURL: async () => {
@ -64,6 +91,9 @@ function RootNavigationGate() {
if (url) { if (url) {
lastLinkRef.current = url; lastLinkRef.current = url;
logDeepLink(url); logDeepLink(url);
if (handleSpecialUrl(url)) {
return null;
}
} }
return url; return url;
}, },
@ -73,13 +103,14 @@ function RootNavigationGate() {
if (lastLinkRef.current === url) return; if (lastLinkRef.current === url) return;
lastLinkRef.current = url; lastLinkRef.current = url;
logDeepLink(url); logDeepLink(url);
if (handleSpecialUrl(url)) return;
listener(url); listener(url);
}; };
const subscription = Linking.addEventListener("url", onReceiveURL); const subscription = Linking.addEventListener("url", onReceiveURL);
return () => subscription.remove(); return () => subscription.remove();
}, },
}), }),
[], [handleSpecialUrl],
); );
const processPendingNotification = useCallback(() => { const processPendingNotification = useCallback(() => {
@ -93,27 +124,52 @@ function RootNavigationGate() {
if (pending.type === "chat") { if (pending.type === "chat") {
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs"; const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
const targetTab = manager.isBanker ? "BankerChat" : "PlayerChat"; const targetTab = manager.isBanker ? "BankerChat" : "PlayerChat";
navigationRef.navigate( navigationRef.dispatch(
targetStack as never, CommonActions.navigate({
{ name: targetStack,
params: {
screen: targetTab, screen: targetTab,
params: { params: {
screen: "ChatThread", screen: "ChatThread",
params: { chatId: pending.chatId }, params: { chatId: pending.chatId },
}, },
} as never, },
}),
); );
return; return;
} }
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs"; const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
const targetTab = manager.isBanker ? "BankerDashboard" : "PlayerHome"; const targetTab = manager.isBanker ? "BankerDashboard" : "PlayerHome";
navigationRef.navigate( navigationRef.dispatch(
targetStack as never, CommonActions.navigate({
{ screen: targetTab } as never, name: targetStack,
params: { screen: targetTab },
}),
); );
}, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]); }, [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( const handleNotificationResponse = useCallback(
(response: Notifications.NotificationResponse | null) => { (response: Notifications.NotificationResponse | null) => {
if (!response) return; if (!response) return;
@ -139,13 +195,27 @@ function RootNavigationGate() {
); );
useEffect(() => { useEffect(() => {
if (pendingScreenshotRef.current) {
return;
}
if (!navReady || !navigationRef.isReady()) 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; let target: keyof RootStackParamList;
if (!manager.sessionId) { if (!manager.sessionId) {
target = "Entry"; if (inEntryFlow) {
lastTargetRef.current = currentName ?? null;
return;
}
target = "EntryLanding";
} else if (!manager.session) { } else if (!manager.session) {
target = manager.connectionState === "error" ? "Entry" : "Lobby"; target = "Lobby";
} else if (manager.session.status === "lobby") { } else if (manager.session.status === "lobby") {
target = "Lobby"; target = "Lobby";
} else if (manager.isBanker) { } else if (manager.isBanker) {
@ -154,7 +224,6 @@ function RootNavigationGate() {
target = "PlayerTabs"; target = "PlayerTabs";
} }
const currentRoute = navigationRef.getCurrentRoute();
if (currentRoute?.name === target || lastTargetRef.current === target) { if (currentRoute?.name === target || lastTargetRef.current === target) {
return; return;
} }
@ -173,6 +242,10 @@ function RootNavigationGate() {
navigationRef, navigationRef,
]); ]);
useEffect(() => {
processPendingScreenshot();
}, [processPendingScreenshot]);
useEffect(() => { useEffect(() => {
processPendingNotification(); processPendingNotification();
}, [processPendingNotification]); }, [processPendingNotification]);
@ -195,6 +268,7 @@ function RootNavigationGate() {
}, [handleNotificationResponse]); }, [handleNotificationResponse]);
return ( return (
<View style={styles.container}>
<NavigationContainer <NavigationContainer
ref={navigationRef} ref={navigationRef}
onReady={() => setNavReady(true)} onReady={() => setNavReady(true)}
@ -202,7 +276,18 @@ function RootNavigationGate() {
theme={navigationTheme} theme={navigationTheme}
> >
<AppNavigator /> <AppNavigator />
<ConnectionBanner
visible={
Boolean(manager.sessionId) &&
manager.connectionState !== "idle" &&
manager.connectionState !== "open"
}
connectionState={manager.connectionState}
reconnectAttempt={manager.reconnectAttempt}
onRetry={manager.retryConnection}
/>
</NavigationContainer> </NavigationContainer>
</View>
); );
} }
@ -220,3 +305,9 @@ export default function App() {
</SafeAreaProvider> </SafeAreaProvider>
); );
} }
const styles = StyleSheet.create({
container: {
flex: 1,
},
});

View file

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

View file

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

View file

@ -0,0 +1,102 @@
import React, { useMemo } from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useI18n } from "../i18n";
import { useTheme, type AppTheme } from "../theme";
import type { SessionConnectionState } from "../state/connection";
type ConnectionBannerProps = {
connectionState: SessionConnectionState;
reconnectAttempt: number;
visible: boolean;
onRetry: () => void;
};
export default function ConnectionBanner({
connectionState,
reconnectAttempt,
visible,
onRetry,
}: ConnectionBannerProps) {
const { t } = useI18n();
const theme = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const insets = useSafeAreaInsets();
if (!visible) {
return null;
}
const title =
connectionState === "connecting"
? t("connection.connecting")
: t("connection.reconnecting");
const detail = t("connection.reconnectingDetail", {
count: reconnectAttempt || 1,
});
return (
<View
pointerEvents="box-none"
style={[styles.wrapper, { top: insets.top + 8 }]}
>
<View style={styles.banner}>
<View style={styles.copy}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.detail}>{detail}</Text>
</View>
<TouchableOpacity style={styles.button} onPress={onRetry}>
<Text style={styles.buttonText}>{t("common.retryNow")}</Text>
</TouchableOpacity>
</View>
</View>
);
}
const createStyles = (theme: AppTheme) =>
StyleSheet.create({
wrapper: {
position: "absolute",
left: 12,
right: 12,
zIndex: 20,
},
banner: {
borderRadius: 16,
paddingHorizontal: 14,
paddingVertical: 12,
backgroundColor: theme.colors.headerBackground,
borderWidth: 1,
borderColor: theme.colors.border,
flexDirection: "row",
alignItems: "center",
gap: 12,
shadowColor: "#000",
shadowOpacity: 0.12,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
},
copy: {
flex: 1,
gap: 2,
},
title: {
color: theme.colors.text,
fontWeight: "700",
},
detail: {
color: theme.colors.textMuted,
fontSize: 12,
},
button: {
borderRadius: 999,
backgroundColor: theme.colors.primary,
paddingHorizontal: 12,
paddingVertical: 10,
},
buttonText: {
color: theme.colors.primaryText,
fontWeight: "600",
},
});

View file

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

View file

@ -1,20 +1,21 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import type { TransactionKind } from "./shared/types";
type Locale = "en" | "fr"; type Locale = "en" | "fr";
const translations = { const translations = {
en: { en: {
"app.name": "Negopoly Companion", "app.name": "Crédit Mabligop",
"common.loading": "Loading...", "common.loading": "Loading...",
"common.loadingChats": "Loading chats...", "common.loadingChats": "Loading messages...",
"common.loadingChat": "Loading chat...", "common.loadingChat": "Loading conversation...",
"common.loadingLobby": "Joining lobby...", "common.loadingLobby": "Entering agency setup...",
"common.notice": "Notice:", "common.notice": "Notice:",
"common.online": "online", "common.online": "online",
"common.offline": "offline", "common.offline": "offline",
"common.dummy": "Dummy", "common.dummy": "Assisted customer",
"common.player": "Player", "common.player": "Customer",
"common.banker": "Banker", "common.banker": "Advisor",
"common.bank": "Bank", "common.bank": "Bank",
"common.from": "From", "common.from": "From",
"common.to": "To", "common.to": "To",
@ -22,6 +23,8 @@ const translations = {
"common.you": "You", "common.you": "You",
"common.join": "Join", "common.join": "Join",
"common.continue": "Continue", "common.continue": "Continue",
"common.retryNow": "Retry now",
"common.transactions": "Transactions",
"common.send": "Send", "common.send": "Send",
"common.reset": "Reset", "common.reset": "Reset",
"common.cancel": "Cancel", "common.cancel": "Cancel",
@ -30,100 +33,149 @@ const translations = {
"common.save": "Save", "common.save": "Save",
"common.load": "Load", "common.load": "Load",
"common.noReason": "No reason provided", "common.noReason": "No reason provided",
"entry.subtitle": "Create or join a session.", "entry.subtitle": "Open or access an agency.",
"entry.joinTitle": "Join a session", "entry.heroBadge": "Mobile banking companion",
"entry.sessionCode": "Session code", "entry.landingTitle": "Access your agency.",
"entry.newPlayer": "New player", "entry.landingBody":
"entry.playerName": "Player name", "Use your agency code to sign in, or open a new agency if you are the advisor in charge.",
"entry.takeoverTitle": "Take over dummy", "entry.landingFooter": "Real-time balances, transfers, and approvals for Crédit Mabligop agencies.",
"entry.alreadyConnected": "You are already connected.", "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.dummyId": "Dummy ID (select later)",
"entry.selectDummy": "Select a dummy", "entry.selectDummy": "Select an assisted customer",
"entry.yourNameOptional": "Your name (optional)", "entry.yourNameOptional": "Your name (optional)",
"entry.requestTakeover": "Request takeover", "entry.requestTakeover": "Request advisor approval",
"entry.noDummies": "No dummies available yet.", "entry.noDummies": "No assisted customer profiles are available yet.",
"entry.takeoverPending": "Waiting for the banker to approve your takeover.", "entry.takeoverPending": "Waiting for an advisor to approve your recovery request.",
"entry.createTitle": "Create a session", "entry.createTitle": "Advisor profile",
"entry.bankerName": "Banker name", "entry.createDescription":
"entry.openVault": "Open the vault", "The first profile created becomes the advisor supervising this agency.",
"entry.alert.enterCode": "Enter a session code", "entry.bankerName": "Advisor name",
"entry.alert.sessionNotFound": "Session not found", "entry.advisorName": "Advisor name",
"entry.alert.selectDummy": "Select a dummy player", "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.", "entry.alert.takeoverFailed": "Unable to request takeover. Please try again.",
"lobby.title": "Lobby", "lobby.title": "Agency setup",
"lobby.code": "Code: {code}", "lobby.code": "Agency code: {code}",
"lobby.startGame": "Start game", "lobby.startGame": "Open agency",
"lobby.addDummyTitle": "Add dummy player", "lobby.addDummyTitle": "Add an assisted customer",
"lobby.addDummySubtitle": "Create a player for someone without the app.", "lobby.addDummySubtitle":
"lobby.enterDummyName": "Enter a dummy name", "Create a supervised customer profile for someone without the mobile app.",
"lobby.addDummyButton": "Add dummy", "lobby.enterDummyName": "Enter an assisted customer name",
"session.exit": "Exit game", "lobby.addDummyButton": "Create assisted customer",
"session.exitPrompt": "Leave this game?", "lobby.heroAdvisor":
"session.exitMessage": "You can rejoin later with the session code.", "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.title": "Make a transfer",
"transfers.subtitle": "Move funds instantly between players.", "transfers.subtitle": "Move funds instantly between customers.",
"transfers.from": "From", "transfers.from": "From",
"transfers.to": "To", "transfers.to": "To",
"transfers.availableBalance": "Available balance", "transfers.availableBalance": "Available balance",
"transfers.noPlayers": "No other players available yet.", "transfers.noPlayers": "No other customers are available yet.",
"transfers.dummy": "Dummy player", "transfers.dummy": "Assisted customer",
"transfers.player": "Player", "transfers.player": "Customer",
"transfers.amount": "Amount", "transfers.amount": "Amount",
"transfers.note": "Note", "transfers.note": "Note",
"transfers.notePlaceholder": "What is this for?", "transfers.notePlaceholder": "What is this for?",
"transfers.sending": "Sending", "transfers.sending": "Sending",
"transfers.summary": "₦{amount} to {name}", "transfers.summary": "₦{amount} to {name}",
"transfers.selectPlayer": "Select a player", "transfers.selectPlayer": "Select a customer",
"transfers.send": "Send transfer", "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.balance": "Balance",
"home.recent": "Recent activity", "home.recent": "Recent operations",
"home.noActivity": "No activity yet.", "home.noActivity": "No activity yet.",
"blackout.title": "EMP", "blackout.title": "EMP",
"blackout.defaultReason": "EMP in effect", "blackout.defaultReason": "EMP in effect",
"blackout.active": "EMP active", "blackout.active": "EMP active",
"banker.dashboard.title": "Session activity", "banker.dashboard.title": "Agency activity",
"banker.tools.title": "Banker tools", "banker.tools.title": "Advisor controls",
"banker.tools.playersTab": "Players", "banker.tools.playersTab": "Customers",
"banker.tools.adminTab": "Admin", "banker.tools.adminTab": "Agency",
"banker.tools.playerOverview": "Player overview", "banker.tools.playerOverview": "Customer overview",
"banker.tools.noPlayers": "No players yet.", "banker.tools.noPlayers": "No customers yet.",
"banker.tools.adjust": "Adjust balance", "banker.tools.adjust": "Adjust balance",
"banker.tools.apply": "Apply", "banker.tools.apply": "Apply",
"banker.tools.forceTransfer": "Force transfer", "banker.tools.forceTransfer": "Force transfer",
"banker.tools.force": "Force", "banker.tools.force": "Force",
"banker.tools.createDummy": "Create dummy", "banker.tools.createDummy": "Create assisted customer",
"banker.tools.addDummy": "Add dummy", "banker.tools.addDummy": "Add assisted customer",
"banker.tools.blackout": "EMP", "banker.tools.blackout": "Blackout",
"banker.tools.blackoutActive": "EMP active", "banker.tools.blackoutActive": "Blackout active",
"banker.tools.blackoutReason": "EMP reason", "banker.tools.blackoutReason": "Blackout reason",
"banker.tools.blackoutEnable": "Enable EMP", "banker.tools.blackoutEnable": "Enable blackout",
"banker.tools.blackoutDisable": "Disable EMP", "banker.tools.blackoutDisable": "Disable blackout",
"banker.tools.trigger": "Trigger", "banker.tools.trigger": "Trigger",
"banker.tools.endSession": "End session", "banker.tools.endSession": "Close agency",
"banker.tools.playerId": "Player ID", "banker.tools.playerId": "Customer ID",
"banker.tools.amountAdjust": "Amount (+/-)", "banker.tools.amountAdjust": "Amount (+/-)",
"banker.tools.reason": "Reason", "banker.tools.reason": "Reason",
"banker.tools.fromPlayer": "From player ID", "banker.tools.fromPlayer": "From customer ID",
"banker.tools.toPlayer": "To player ID", "banker.tools.toPlayer": "To customer ID",
"banker.tools.amount": "Amount", "banker.tools.amount": "Amount",
"banker.tools.note": "Note", "banker.tools.note": "Note",
"banker.tools.dummyName": "Dummy name", "banker.tools.dummyName": "Assisted customer name",
"banker.tools.startingBalance": "Starting balance", "banker.tools.startingBalance": "Starting balance",
"banker.takeoverApprovals": "Takeover approvals", "banker.takeoverApprovals": "Recovery approvals",
"banker.wants": "wants {name}", "banker.wants": "requests {name}",
"banker.approve": "Approve", "banker.approve": "Approve",
"banker.stateTitle": "GameState", "banker.stateTitle": "Agency state",
"banker.stateSubtitle": "Export or restore the current session.", "banker.stateSubtitle": "Export or restore the current agency.",
"banker.downloadState": "Export GameState", "banker.downloadState": "Export agency state",
"banker.loadFromFile": "Load GameState", "banker.loadFromFile": "Load agency state",
"banker.importPlaceholder": "Paste GameState JSON here", "banker.importPlaceholder": "Paste agency state JSON here",
"banker.loadFromStorage": "Load from saved snapshots", "banker.loadFromStorage": "Load from saved snapshots",
"banker.stateDownloaded": "GameState exported.", "banker.stateDownloaded": "Agency state exported.",
"banker.stateDownloadError": "Unable to export GameState.", "banker.stateDownloadError": "Unable to export agency state.",
"banker.stateLoaded": "GameState loaded.", "banker.stateLoaded": "Agency state loaded.",
"banker.stateLoadError": "Unable to load GameState.", "banker.stateLoadError": "Unable to load agency state.",
"banker.stateLoadInvalid": "Invalid GameState JSON.", "banker.stateLoadInvalid": "Invalid agency state JSON.",
"banker.autosaveTitle": "AutoSave", "banker.autosaveTitle": "AutoSave",
"banker.autosaveSubtitle": "Save rolling snapshots on this device.", "banker.autosaveSubtitle": "Save rolling snapshots on this device.",
"banker.autosaveEnabled": "Enable AutoSave", "banker.autosaveEnabled": "Enable AutoSave",
@ -134,43 +186,47 @@ const translations = {
"banker.autosaveFailed": "AutoSave failed.", "banker.autosaveFailed": "AutoSave failed.",
"banker.noAutosaves": "No autosaves yet.", "banker.noAutosaves": "No autosaves yet.",
"banker.savedAt": "Saved {time}", "banker.savedAt": "Saved {time}",
"chat.title": "Chats", "chat.title": "Messages",
"chat.noMessages": "No messages yet", "chat.noMessages": "No messages yet",
"chat.global": "Global chat", "chat.global": "Agency channel",
"chat.newTitle": "New chat", "chat.newTitle": "New conversation",
"chat.direct": "Direct", "chat.direct": "Direct",
"chat.group": "Group", "chat.group": "Group",
"chat.groupName": "Group name", "chat.groupName": "Group name",
"chat.choosePlayers": "Choose players", "chat.choosePlayers": "Choose customers",
"chat.startChat": "Start chat", "chat.startChat": "Start conversation",
"chat.notFound": "Chat not found.", "chat.notFound": "Chat not found.",
"chat.messagePlaceholder": "Message", "chat.messagePlaceholder": "Message",
"tabs.home": "Home", "tabs.home": "Accounts",
"tabs.transfers": "Transfers", "tabs.transfers": "Payments",
"tabs.chat": "Chat", "tabs.chat": "Messages",
"tabs.dashboard": "Dashboard", "tabs.dashboard": "Agency",
"tabs.tools": "Tools", "tabs.tools": "Control",
"transaction.transfer": "Transfer", "transaction.transfer": "Transfer",
"transaction.banker_adjust": "Banker adjustment", "transaction.banker_adjust": "Advisor adjustment",
"transaction.banker_force_transfer": "Forced transfer", "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.parseResponse": "Unable to parse server response",
"error.createSession": "Unable to create session", "error.createSession": "Unable to open agency",
"error.joinSession": "Unable to join session", "error.joinSession": "Unable to access agency",
"error.loadSessionInfo": "Unable to load session info", "error.loadSessionInfo": "Unable to load agency info",
"error.connectionNotReady": "Connection not ready", "error.connectionNotReady": "Connection not ready",
"error.reconnecting": "Reconnecting to the agency. Please try again in a moment.",
}, },
fr: { fr: {
"app.name": "Negopoly Companion", "app.name": "Crédit Mabligop",
"common.loading": "Chargement...", "common.loading": "Chargement...",
"common.loadingChats": "Chargement des chats...", "common.loadingChats": "Chargement des messages...",
"common.loadingChat": "Chargement du chat...", "common.loadingChat": "Chargement de la conversation...",
"common.loadingLobby": "Connexion au lobby...", "common.loadingLobby": "Accès à la mise en place de l'agence...",
"common.notice": "Info :", "common.notice": "Info :",
"common.online": "en ligne", "common.online": "en ligne",
"common.offline": "hors ligne", "common.offline": "hors ligne",
"common.dummy": "Dummy", "common.dummy": "Client assisté",
"common.player": "Joueur", "common.player": "Client",
"common.banker": "Banquier", "common.banker": "Conseiller",
"common.bank": "Banque", "common.bank": "Banque",
"common.from": "De", "common.from": "De",
"common.to": "Vers", "common.to": "Vers",
@ -178,6 +234,8 @@ const translations = {
"common.you": "Vous", "common.you": "Vous",
"common.join": "Rejoindre", "common.join": "Rejoindre",
"common.continue": "Continuer", "common.continue": "Continuer",
"common.retryNow": "Réessayer",
"common.transactions": "Transactions",
"common.send": "Envoyer", "common.send": "Envoyer",
"common.reset": "Réinitialiser", "common.reset": "Réinitialiser",
"common.cancel": "Annuler", "common.cancel": "Annuler",
@ -186,100 +244,150 @@ const translations = {
"common.save": "Enregistrer", "common.save": "Enregistrer",
"common.load": "Charger", "common.load": "Charger",
"common.noReason": "Aucune raison fournie", "common.noReason": "Aucune raison fournie",
"entry.subtitle": "Créez ou rejoignez une session.", "entry.subtitle": "Ouvrez ou accédez à une agence.",
"entry.joinTitle": "Rejoindre une session", "entry.heroBadge": "Compagnon bancaire mobile",
"entry.sessionCode": "Code de session", "entry.landingTitle": "Accédez à votre agence.",
"entry.newPlayer": "Nouveau joueur", "entry.landingBody":
"entry.playerName": "Nom du joueur", "Utilisez votre code agence pour vous identifier, ou ouvrez une nouvelle agence si vous êtes le conseiller responsable.",
"entry.takeoverTitle": "Reprendre un dummy", "entry.landingFooter":
"entry.alreadyConnected": "Vous êtes déjà connecté.", "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.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.yourNameOptional": "Votre nom (optionnel)",
"entry.requestTakeover": "Demander la reprise", "entry.requestTakeover": "Demander l'accord du conseiller",
"entry.noDummies": "Aucun dummy disponible pour le moment.", "entry.noDummies": "Aucun profil client assisté n'est disponible pour le moment.",
"entry.takeoverPending": "En attente de l'approbation du banquier.", "entry.takeoverPending": "En attente de la validation du conseiller.",
"entry.createTitle": "Créer une session", "entry.createTitle": "Profil conseiller",
"entry.bankerName": "Nom du banquier", "entry.createDescription":
"entry.openVault": "Ouvrir le coffre", "Le premier profil créé devient le conseiller qui supervise cette agence.",
"entry.alert.enterCode": "Entrez un code de session", "entry.bankerName": "Nom du conseiller",
"entry.alert.sessionNotFound": "Session introuvable", "entry.advisorName": "Nom du conseiller",
"entry.alert.selectDummy": "Sélectionnez un dummy", "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.", "entry.alert.takeoverFailed": "Impossible de demander la reprise. Réessayez.",
"lobby.title": "Lobby", "lobby.title": "Mise en place de l'agence",
"lobby.code": "Code : {code}", "lobby.code": "Code agence : {code}",
"lobby.startGame": "Démarrer la partie", "lobby.startGame": "Ouvrir l'agence",
"lobby.addDummyTitle": "Ajouter un dummy", "lobby.addDummyTitle": "Ajouter un client assisté",
"lobby.addDummySubtitle": "Créez un joueur pour quelqu'un sans l'application.", "lobby.addDummySubtitle":
"lobby.enterDummyName": "Entrez un nom de dummy", "Créez un profil client supervisé pour quelqu'un sans l'application mobile.",
"lobby.addDummyButton": "Ajouter un dummy", "lobby.enterDummyName": "Entrez un nom de client assisté",
"session.exit": "Quitter la partie", "lobby.addDummyButton": "Créer le client assisté",
"session.exitPrompt": "Quitter cette session ?", "lobby.heroAdvisor":
"session.exitMessage": "Vous pourrez rejoindre plus tard avec le code.", "Finalisez les accès clients, examinez les récupérations et ouvrez l'agence quand tout est prêt.",
"transfers.title": "Faire un transfert", "lobby.heroCustomer":
"transfers.subtitle": "Transférez des fonds instantanément entre joueurs.", "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.from": "De",
"transfers.to": "Vers", "transfers.to": "Vers",
"transfers.availableBalance": "Solde disponible", "transfers.availableBalance": "Solde disponible",
"transfers.noPlayers": "Aucun autre joueur disponible.", "transfers.noPlayers": "Aucun autre client disponible pour le moment.",
"transfers.dummy": "Dummy", "transfers.dummy": "Client assisté",
"transfers.player": "Joueur", "transfers.player": "Client",
"transfers.amount": "Montant", "transfers.amount": "Montant",
"transfers.note": "Note", "transfers.note": "Note",
"transfers.notePlaceholder": "Pour quoi ?", "transfers.notePlaceholder": "Pour quoi ?",
"transfers.sending": "Envoi", "transfers.sending": "Envoi",
"transfers.summary": "₦{amount} à {name}", "transfers.summary": "₦{amount} à {name}",
"transfers.selectPlayer": "Choisissez un joueur", "transfers.selectPlayer": "Choisissez un client",
"transfers.send": "Envoyer le transfert", "transfers.send": "Envoyer le virement",
"transfers.error": "Choisissez un joueur et un montant valide.", "transfers.error": "Choisissez un client et un montant valide.",
"home.balance": "Solde", "home.balance": "Solde",
"home.recent": "Activité récente", "home.recent": "Opérations récentes",
"home.noActivity": "Aucune activité.", "home.noActivity": "Aucune activité.",
"blackout.title": "EMP", "blackout.title": "EMP",
"blackout.defaultReason": "EMP en cours", "blackout.defaultReason": "EMP en cours",
"blackout.active": "EMP actif", "blackout.active": "EMP actif",
"banker.dashboard.title": "Activité de la session", "banker.dashboard.title": "Activité de l'agence",
"banker.tools.title": "Outils banquier", "banker.tools.title": "Pilotage conseiller",
"banker.tools.playersTab": "Joueurs", "banker.tools.playersTab": "Clients",
"banker.tools.adminTab": "Admin", "banker.tools.adminTab": "Agence",
"banker.tools.playerOverview": "Vue joueur", "banker.tools.playerOverview": "Vue client",
"banker.tools.noPlayers": "Pas encore de joueurs.", "banker.tools.noPlayers": "Pas encore de clients.",
"banker.tools.adjust": "Ajuster le solde", "banker.tools.adjust": "Ajuster le solde",
"banker.tools.apply": "Appliquer", "banker.tools.apply": "Appliquer",
"banker.tools.forceTransfer": "Forcer un transfert", "banker.tools.forceTransfer": "Imposer un virement",
"banker.tools.force": "Forcer", "banker.tools.force": "Forcer",
"banker.tools.createDummy": "Créer un dummy", "banker.tools.createDummy": "Créer un client assisté",
"banker.tools.addDummy": "Ajouter un dummy", "banker.tools.addDummy": "Ajouter un client assisté",
"banker.tools.blackout": "EMP", "banker.tools.blackout": "Coupure",
"banker.tools.blackoutActive": "EMP actif", "banker.tools.blackoutActive": "Coupure active",
"banker.tools.blackoutReason": "Raison de l'EMP", "banker.tools.blackoutReason": "Raison de la coupure",
"banker.tools.blackoutEnable": "Activer l'EMP", "banker.tools.blackoutEnable": "Activer la coupure",
"banker.tools.blackoutDisable": "Désactiver l'EMP", "banker.tools.blackoutDisable": "Désactiver la coupure",
"banker.tools.trigger": "Déclencher", "banker.tools.trigger": "Déclencher",
"banker.tools.endSession": "Terminer la session", "banker.tools.endSession": "Fermer l'agence",
"banker.tools.playerId": "ID joueur", "banker.tools.playerId": "ID client",
"banker.tools.amountAdjust": "Montant (+/-)", "banker.tools.amountAdjust": "Montant (+/-)",
"banker.tools.reason": "Raison", "banker.tools.reason": "Raison",
"banker.tools.fromPlayer": "ID joueur source", "banker.tools.fromPlayer": "ID client source",
"banker.tools.toPlayer": "ID joueur cible", "banker.tools.toPlayer": "ID client cible",
"banker.tools.amount": "Montant", "banker.tools.amount": "Montant",
"banker.tools.note": "Note", "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.tools.startingBalance": "Solde de départ",
"banker.takeoverApprovals": "Approbations de reprise", "banker.takeoverApprovals": "Validations de récupération",
"banker.wants": "veut {name}", "banker.wants": "demande {name}",
"banker.approve": "Approuver", "banker.approve": "Approuver",
"banker.stateTitle": "État de partie", "banker.stateTitle": "État de l'agence",
"banker.stateSubtitle": "Exportez ou restaurez la session.", "banker.stateSubtitle": "Exportez ou restaurez l'agence actuelle.",
"banker.downloadState": "Exporter l'état", "banker.downloadState": "Exporter l'état de l'agence",
"banker.loadFromFile": "Charger l'état", "banker.loadFromFile": "Charger l'état de l'agence",
"banker.importPlaceholder": "Collez le JSON d'état ici", "banker.importPlaceholder": "Collez le JSON d'état de l'agence ici",
"banker.loadFromStorage": "Charger depuis les sauvegardes", "banker.loadFromStorage": "Charger depuis les sauvegardes",
"banker.stateDownloaded": "État exporté.", "banker.stateDownloaded": "État de l'agence exporté.",
"banker.stateDownloadError": "Impossible d'exporter l'état.", "banker.stateDownloadError": "Impossible d'exporter l'état de l'agence.",
"banker.stateLoaded": "État chargé.", "banker.stateLoaded": "État de l'agence chargé.",
"banker.stateLoadError": "Impossible de charger l'état.", "banker.stateLoadError": "Impossible de charger l'état de l'agence.",
"banker.stateLoadInvalid": "JSON d'état invalide.", "banker.stateLoadInvalid": "JSON d'état de l'agence invalide.",
"banker.autosaveTitle": "AutoSave", "banker.autosaveTitle": "AutoSave",
"banker.autosaveSubtitle": "Enregistrez des sauvegardes sur l'appareil.", "banker.autosaveSubtitle": "Enregistrez des sauvegardes sur l'appareil.",
"banker.autosaveEnabled": "Activer AutoSave", "banker.autosaveEnabled": "Activer AutoSave",
@ -290,30 +398,34 @@ const translations = {
"banker.autosaveFailed": "Échec de la sauvegarde.", "banker.autosaveFailed": "Échec de la sauvegarde.",
"banker.noAutosaves": "Aucune sauvegarde.", "banker.noAutosaves": "Aucune sauvegarde.",
"banker.savedAt": "Sauvé {time}", "banker.savedAt": "Sauvé {time}",
"chat.title": "Chats", "chat.title": "Messages",
"chat.noMessages": "Aucun message", "chat.noMessages": "Aucun message",
"chat.global": "Chat global", "chat.global": "Canal agence",
"chat.newTitle": "Nouveau chat", "chat.newTitle": "Nouvelle conversation",
"chat.direct": "Direct", "chat.direct": "Direct",
"chat.group": "Groupe", "chat.group": "Groupe",
"chat.groupName": "Nom du groupe", "chat.groupName": "Nom du groupe",
"chat.choosePlayers": "Choisir des joueurs", "chat.choosePlayers": "Choisir des clients",
"chat.startChat": "Démarrer le chat", "chat.startChat": "Démarrer la conversation",
"chat.notFound": "Chat introuvable.", "chat.notFound": "Chat introuvable.",
"chat.messagePlaceholder": "Message", "chat.messagePlaceholder": "Message",
"tabs.home": "Accueil", "tabs.home": "Comptes",
"tabs.transfers": "Transferts", "tabs.transfers": "Paiements",
"tabs.chat": "Chat", "tabs.chat": "Messages",
"tabs.dashboard": "Tableau", "tabs.dashboard": "Agence",
"tabs.tools": "Outils", "tabs.tools": "Pilotage",
"transaction.transfer": "Transfert", "transaction.transfer": "Transfert",
"transaction.banker_adjust": "Ajustement banquier", "transaction.banker_adjust": "Ajustement conseiller",
"transaction.banker_force_transfer": "Transfert forcé", "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.parseResponse": "Impossible de lire la réponse du serveur",
"error.createSession": "Impossible de créer la session", "error.createSession": "Impossible d'ouvrir l'agence",
"error.joinSession": "Impossible de rejoindre la session", "error.joinSession": "Impossible d'accéder à l'agence",
"error.loadSessionInfo": "Impossible de charger les infos de session", "error.loadSessionInfo": "Impossible de charger les infos de l'agence",
"error.connectionNotReady": "Connexion non prête", "error.connectionNotReady": "Connexion non prête",
"error.reconnecting": "Reconnexion à l'agence en cours. Réessayez dans un instant.",
}, },
} as const; } as const;
@ -326,7 +438,7 @@ export function getLocale(): Locale {
function translate(locale: Locale, key: I18nKey, vars?: Record<string, string | number>) { function translate(locale: Locale, key: I18nKey, vars?: Record<string, string | number>) {
const table = translations[locale] ?? translations.en; const table = translations[locale] ?? translations.en;
let template = table[key] ?? translations.en[key] ?? key; let template: string = table[key] ?? translations.en[key] ?? key;
if (vars) { if (vars) {
Object.entries(vars).forEach(([name, value]) => { Object.entries(vars).forEach(([name, value]) => {
template = template.replace(new RegExp(`\\{${name}\\}`, "g"), String(value)); template = template.replace(new RegExp(`\\{${name}\\}`, "g"), String(value));
@ -349,7 +461,7 @@ export function tStatic(key: I18nKey, vars?: Record<string, string | number>) {
} }
export function formatTransactionKind( export function formatTransactionKind(
kind: "transfer" | "banker_adjust" | "banker_force_transfer", kind: TransactionKind,
t: (key: I18nKey) => string, t: (key: I18nKey) => string,
) { ) {
return t(`transaction.${kind}` as I18nKey); return t(`transaction.${kind}` as I18nKey);

View file

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

View file

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

View file

@ -8,6 +8,8 @@ export type NotificationTarget =
Notifications.setNotificationHandler({ Notifications.setNotificationHandler({
handleNotification: async () => ({ handleNotification: async () => ({
shouldShowAlert: true, shouldShowAlert: true,
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true, shouldPlaySound: true,
shouldSetBadge: false, shouldSetBadge: false,
}), }),

View file

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

View file

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

View file

@ -27,10 +27,10 @@ function formatTransactionTimestamp(value: number) {
} }
function getTransactionLabel( function getTransactionLabel(
kind: string, kind: Transaction["kind"],
note: string | null | undefined, note: string | null | undefined,
t: ReturnType<typeof useI18n>["t"], t: ReturnType<typeof useI18n>["t"],
) { ): string {
if (kind === "banker_adjust" || kind === "banker_force_transfer") { if (kind === "banker_adjust" || kind === "banker_force_transfer") {
const trimmed = note?.trim(); const trimmed = note?.trim();
return trimmed || t("common.noReason"); return trimmed || t("common.noReason");
@ -43,15 +43,15 @@ function getTransactionDisplay(
viewerId: string | null | undefined, viewerId: string | null | undefined,
players: Player[], players: Player[],
t: ReturnType<typeof useI18n>["t"], t: ReturnType<typeof useI18n>["t"],
) { ): { label: string; subtitle: string; amount: string; outgoing: boolean } {
const absAmount = Math.abs(transaction.amount); const absAmount = Math.abs(transaction.amount);
const label = getTransactionLabel(transaction.kind, transaction.note, t); const label = getTransactionLabel(transaction.kind, transaction.note, t);
const findPlayer = (id: string | null) => players.find((player) => player.id === id); const findPlayer = (id: string | null) => players.find((player) => player.id === id);
const from = findPlayer(transaction.fromId); const from = findPlayer(transaction.fromId);
const to = findPlayer(transaction.toId); const to = findPlayer(transaction.toId);
let outgoing = false; let outgoing = false;
let counterparty = t("common.bank"); let counterparty: string = t("common.bank");
const timeLabel = formatTransactionTimestamp(transaction.createdAt); const timeLabel: string = formatTransactionTimestamp(transaction.createdAt);
if (transaction.kind === "banker_adjust") { if (transaction.kind === "banker_adjust") {
outgoing = transaction.amount < 0; outgoing = transaction.amount < 0;

View file

@ -47,10 +47,10 @@ function formatTransactionTimestamp(value: number) {
} }
function getTransactionLabel( function getTransactionLabel(
kind: string, kind: Transaction["kind"],
note: string | null | undefined, note: string | null | undefined,
t: ReturnType<typeof useI18n>["t"], t: ReturnType<typeof useI18n>["t"],
) { ): string {
if (kind === "banker_adjust" || kind === "banker_force_transfer") { if (kind === "banker_adjust" || kind === "banker_force_transfer") {
const trimmed = note?.trim(); const trimmed = note?.trim();
return trimmed || t("common.noReason"); return trimmed || t("common.noReason");
@ -63,15 +63,15 @@ function getTransactionDisplay(
viewerId: string | null | undefined, viewerId: string | null | undefined,
players: Player[], players: Player[],
t: ReturnType<typeof useI18n>["t"], t: ReturnType<typeof useI18n>["t"],
) { ): { label: string; subtitle: string; amount: string; outgoing: boolean } {
const absAmount = Math.abs(transaction.amount); const absAmount = Math.abs(transaction.amount);
const label = getTransactionLabel(transaction.kind, transaction.note, t); const label = getTransactionLabel(transaction.kind, transaction.note, t);
const findPlayer = (id: string | null) => players.find((player) => player.id === id); const findPlayer = (id: string | null) => players.find((player) => player.id === id);
const from = findPlayer(transaction.fromId); const from = findPlayer(transaction.fromId);
const to = findPlayer(transaction.toId); const to = findPlayer(transaction.toId);
let outgoing = false; let outgoing = false;
let counterparty = t("common.bank"); let counterparty: string = t("common.bank");
const timeLabel = formatTransactionTimestamp(transaction.createdAt); const timeLabel: string = formatTransactionTimestamp(transaction.createdAt);
if (transaction.kind === "banker_adjust") { if (transaction.kind === "banker_adjust") {
outgoing = transaction.amount < 0; outgoing = transaction.amount < 0;
@ -327,6 +327,9 @@ export default function BankerToolsScreen() {
); );
} }
const session = manager.session;
const me = manager.me;
const normalizedAdjustAmount = adjustAmount.replace(",", "."); const normalizedAdjustAmount = adjustAmount.replace(",", ".");
const adjustValue = Number(normalizedAdjustAmount); const adjustValue = Number(normalizedAdjustAmount);
const canAdjust = const canAdjust =
@ -436,7 +439,7 @@ export default function BankerToolsScreen() {
const display = getTransactionDisplay( const display = getTransactionDisplay(
transaction, transaction,
selectedPlayerId, selectedPlayerId,
manager.session?.players ?? [], session.players,
t, t,
); );
return ( return (
@ -695,14 +698,14 @@ export default function BankerToolsScreen() {
manager.sendMessage({ manager.sendMessage({
type: "banker_blackout", type: "banker_blackout",
sessionId: manager.sessionId, sessionId: manager.sessionId,
bankerId: manager.me?.id, bankerId: me.id,
active: !manager.session.blackoutActive, active: !session.blackoutActive,
reason: !manager.session.blackoutActive ? blackoutReason : null, reason: !session.blackoutActive ? blackoutReason : null,
}) })
} }
> >
<Text style={styles.buttonDangerText}> <Text style={styles.buttonDangerText}>
{manager.session.blackoutActive {session.blackoutActive
? t("banker.tools.blackoutDisable") ? t("banker.tools.blackoutDisable")
: t("banker.tools.blackoutEnable")} : t("banker.tools.blackoutEnable")}
</Text> </Text>
@ -713,7 +716,7 @@ export default function BankerToolsScreen() {
manager.sendMessage({ manager.sendMessage({
type: "banker_end", type: "banker_end",
sessionId: manager.sessionId, sessionId: manager.sessionId,
bankerId: manager.me?.id, bankerId: me.id,
}) })
} }
> >

View file

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

View file

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

View file

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

View file

@ -28,10 +28,10 @@ function formatTransactionTimestamp(value: number) {
} }
function getTransactionLabel( function getTransactionLabel(
kind: string, kind: Transaction["kind"],
note: string | null | undefined, note: string | null | undefined,
t: ReturnType<typeof useI18n>["t"], t: ReturnType<typeof useI18n>["t"],
) { ): string {
if (kind === "banker_adjust" || kind === "banker_force_transfer") { if (kind === "banker_adjust" || kind === "banker_force_transfer") {
const trimmed = note?.trim(); const trimmed = note?.trim();
return trimmed || t("common.noReason"); return trimmed || t("common.noReason");
@ -44,15 +44,15 @@ function getTransactionDisplay(
viewerId: string | null | undefined, viewerId: string | null | undefined,
players: Player[], players: Player[],
t: ReturnType<typeof useI18n>["t"], t: ReturnType<typeof useI18n>["t"],
) { ): { label: string; subtitle: string; amount: string; outgoing: boolean } {
const absAmount = Math.abs(transaction.amount); const absAmount = Math.abs(transaction.amount);
const label = getTransactionLabel(transaction.kind, transaction.note, t); const label = getTransactionLabel(transaction.kind, transaction.note, t);
const findPlayer = (id: string | null) => players.find((player) => player.id === id); const findPlayer = (id: string | null) => players.find((player) => player.id === id);
const from = findPlayer(transaction.fromId); const from = findPlayer(transaction.fromId);
const to = findPlayer(transaction.toId); const to = findPlayer(transaction.toId);
let outgoing = false; let outgoing = false;
let counterparty = t("common.bank"); let counterparty: string = t("common.bank");
const timeLabel = formatTransactionTimestamp(transaction.createdAt); const timeLabel: string = formatTransactionTimestamp(transaction.createdAt);
if (transaction.kind === "banker_adjust") { if (transaction.kind === "banker_adjust") {
outgoing = transaction.amount < 0; outgoing = transaction.amount < 0;

View file

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

View file

@ -64,6 +64,7 @@ export default function ChatThreadScreen() {
</View> </View>
); );
} }
const activeThread = thread;
const showEmp = manager.session.blackoutActive && !manager.isBanker; const showEmp = manager.session.blackoutActive && !manager.isBanker;
function handleSend() { function handleSend() {
@ -74,7 +75,7 @@ export default function ChatThreadScreen() {
sessionId: manager.sessionId, sessionId: manager.sessionId,
playerId: manager.me?.id, playerId: manager.me?.id,
body: message.trim(), body: message.trim(),
groupId: thread.id === "global" ? null : thread.id, groupId: activeThread.id === "global" ? null : activeThread.id,
}); });
setMessage(""); setMessage("");
} }
@ -87,7 +88,7 @@ export default function ChatThreadScreen() {
keyboardVerticalOffset={keyboardOffset} keyboardVerticalOffset={keyboardOffset}
> >
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.headerTitle}>{thread.name}</Text> <Text style={styles.headerTitle}>{activeThread.name}</Text>
<Text style={styles.headerSubtitle}>{threadKindLabel}</Text> <Text style={styles.headerSubtitle}>{threadKindLabel}</Text>
</View> </View>

View file

@ -0,0 +1,29 @@
import { expect, test } from "bun:test";
import {
CONNECTION_IDLE_TIMEOUT_MS,
RECONNECT_MAX_DELAY_MS,
getReconnectDelayMs,
isConnectionStale,
isTerminalSocketClose,
} from "./connection";
test("getReconnectDelayMs grows with backoff and caps at the max delay", () => {
expect(getReconnectDelayMs(0, () => 0)).toBe(800);
expect(getReconnectDelayMs(1, () => 0)).toBe(1600);
expect(getReconnectDelayMs(4, () => 0)).toBe(8000);
expect(getReconnectDelayMs(8, () => 1)).toBe(Math.round(RECONNECT_MAX_DELAY_MS * 1.2));
});
test("isConnectionStale respects the idle timeout", () => {
const now = 100_000;
expect(isConnectionStale(now - CONNECTION_IDLE_TIMEOUT_MS + 1, now)).toBe(false);
expect(isConnectionStale(now - CONNECTION_IDLE_TIMEOUT_MS - 1, now)).toBe(true);
expect(isConnectionStale(null, now)).toBe(false);
});
test("isTerminalSocketClose only resets on invalid session/player closes", () => {
expect(isTerminalSocketClose(1008, "Session not found")).toBe(true);
expect(isTerminalSocketClose(1008, "Player not found")).toBe(true);
expect(isTerminalSocketClose(4000, "Connection stale")).toBe(false);
expect(isTerminalSocketClose(1008, "Policy violation")).toBe(false);
});

View file

@ -0,0 +1,40 @@
export type SessionConnectionState =
| "idle"
| "connecting"
| "open"
| "reconnecting"
| "error";
export const CONNECTION_PING_INTERVAL_MS = 15_000;
export const CONNECTION_IDLE_TIMEOUT_MS = 45_000;
export const CONNECTION_WATCHDOG_INTERVAL_MS = 5_000;
export const RECONNECT_BASE_DELAY_MS = 1_000;
export const RECONNECT_MAX_DELAY_MS = 10_000;
export function getReconnectDelayMs(
attempt: number,
random: () => number = Math.random,
): number {
const normalizedAttempt = Math.max(0, attempt);
const baseDelay = Math.min(
RECONNECT_MAX_DELAY_MS,
RECONNECT_BASE_DELAY_MS * 2 ** normalizedAttempt,
);
const jitterMultiplier = 0.8 + random() * 0.4;
return Math.round(baseDelay * jitterMultiplier);
}
export function isTerminalSocketClose(code?: number, reason?: string): boolean {
return code === 1008 && /session not found|player not found/i.test(reason ?? "");
}
export function isConnectionStale(
lastActivityAt: number | null | undefined,
nowMs = Date.now(),
timeoutMs = CONNECTION_IDLE_TIMEOUT_MS,
): boolean {
if (!lastActivityAt) {
return false;
}
return nowMs - lastActivityAt > timeoutMs;
}

View file

@ -1,9 +1,23 @@
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 AsyncStorage from "@react-native-async-storage/async-storage";
import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types"; import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types";
import { getApiBaseUrl, getWsUrl } from "../config/api"; import { getApiBaseUrl, getWsUrl } from "../config/api";
import {
buildScreenshotFixture,
type ScreenshotFixture,
type ScreenshotScene,
} from "../dev/screenshot-fixtures";
import { tStatic } from "../i18n"; import { tStatic } from "../i18n";
import { registerForPushNotificationsAsync } from "../notifications"; import { registerForPushNotificationsAsync } from "../notifications";
import {
CONNECTION_PING_INTERVAL_MS,
CONNECTION_WATCHDOG_INTERVAL_MS,
type SessionConnectionState,
getReconnectDelayMs,
isConnectionStale,
isTerminalSocketClose,
} from "./connection";
const STORAGE_KEY = "negopoly:session"; const STORAGE_KEY = "negopoly:session";
@ -13,6 +27,12 @@ type StoredSession = {
playerId: string; playerId: string;
}; };
type IncomingMessage =
| { type: "state"; session: SessionSnapshot }
| { type: "error"; message: string }
| { type: "takeover_approved"; assignedPlayerId: string }
| { type: "pong" };
async function readStoredSession(): Promise<StoredSession | null> { async function readStoredSession(): Promise<StoredSession | null> {
try { try {
const raw = await AsyncStorage.getItem(STORAGE_KEY); const raw = await AsyncStorage.getItem(STORAGE_KEY);
@ -36,23 +56,276 @@ export function useSessionManager() {
const [sessionCode, setSessionCode] = useState(""); const [sessionCode, setSessionCode] = useState("");
const [playerId, setPlayerId] = useState(""); const [playerId, setPlayerId] = useState("");
const [session, setSession] = useState<SessionSnapshot | null>(null); const [session, setSession] = useState<SessionSnapshot | null>(null);
const [screenshot, setScreenshot] = useState<ScreenshotFixture | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [connectionState, setConnectionState] = useState< const [connectionState, setConnectionState] =
"idle" | "connecting" | "open" | "error" useState<SessionConnectionState>("idle");
>("idle");
const [tick, setTick] = useState(0);
const [pushToken, setPushToken] = useState<{ const [pushToken, setPushToken] = useState<{
token: string; token: string;
platform: "ios" | "android"; platform: "ios" | "android";
} | null>(null); } | null>(null);
const [reconnectAttempt, setReconnectAttempt] = useState(0);
const [lastActivityAt, setLastActivityAt] = useState<number | null>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const sessionIdRef = useRef(sessionId);
const sessionCodeRef = useRef(sessionCode);
const playerIdRef = useRef(playerId);
const sessionRef = useRef<SessionSnapshot | null>(session);
const connectionGenerationRef = useRef(0);
const reconnectAttemptRef = useRef(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const watchdogTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const suppressReconnectRef = useRef(false);
const lastActivityAtRef = useRef<number | null>(null);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
const lastPushRegistrationRef = useRef<string | null>(null); const lastPushRegistrationRef = useRef<string | null>(null);
const screenshotRef = useRef<ScreenshotFixture | null>(null);
function markActivity(at = Date.now()) {
lastActivityAtRef.current = at;
setLastActivityAt(at);
}
function clearReconnectTimer() {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
}
function clearSocketTimers() {
if (pingTimerRef.current) {
clearInterval(pingTimerRef.current);
pingTimerRef.current = null;
}
if (watchdogTimerRef.current) {
clearInterval(watchdogTimerRef.current);
watchdogTimerRef.current = null;
}
}
function closeSocket(ws: WebSocket | null, code?: number, reason?: string) {
if (!ws || ws.readyState === WebSocket.CLOSED) {
return;
}
try {
if (typeof code === "number") {
ws.close(code, reason);
return;
}
ws.close();
} catch {
// Ignore close failures.
}
}
function teardownConnection() {
clearReconnectTimer();
clearSocketTimers();
connectionGenerationRef.current += 1;
const ws = wsRef.current;
wsRef.current = null;
closeSocket(ws);
}
function scheduleReconnect(generation: number) {
if (
suppressReconnectRef.current ||
generation !== connectionGenerationRef.current ||
!sessionIdRef.current ||
!playerIdRef.current
) {
return;
}
clearReconnectTimer();
const nextAttempt = reconnectAttemptRef.current + 1;
reconnectAttemptRef.current = nextAttempt;
setReconnectAttempt(nextAttempt);
setConnectionState("reconnecting");
const delay = getReconnectDelayMs(nextAttempt - 1);
reconnectTimerRef.current = setTimeout(() => {
reconnectTimerRef.current = null;
openSocket("retry");
}, delay);
}
function startSocketTimers(ws: WebSocket, generation: number) {
clearSocketTimers();
pingTimerRef.current = setInterval(() => {
if (
generation !== connectionGenerationRef.current ||
ws.readyState !== WebSocket.OPEN
) {
return;
}
try {
ws.send(
JSON.stringify({
type: "ping",
sessionId: sessionIdRef.current,
playerId: playerIdRef.current,
}),
);
} catch {
closeSocket(ws, 4001, "Ping failed");
}
}, CONNECTION_PING_INTERVAL_MS);
watchdogTimerRef.current = setInterval(() => {
if (
generation !== connectionGenerationRef.current ||
appStateRef.current !== "active"
) {
return;
}
if (!isConnectionStale(lastActivityAtRef.current)) {
return;
}
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
closeSocket(ws, 4000, "Connection stale");
return;
}
scheduleReconnect(generation);
}, CONNECTION_WATCHDOG_INTERVAL_MS);
}
function openSocket(reason: "initial" | "retry" | "resume" | "manual") {
const targetSessionId = sessionIdRef.current;
const targetPlayerId = playerIdRef.current;
if (!targetSessionId || !targetPlayerId) {
return;
}
clearReconnectTimer();
clearSocketTimers();
const previousSocket = wsRef.current;
const generation = connectionGenerationRef.current + 1;
connectionGenerationRef.current = generation;
wsRef.current = null;
closeSocket(previousSocket);
const recovering = Boolean(sessionRef.current) || reason !== "initial";
setConnectionState(recovering ? "reconnecting" : "connecting");
const ws = new WebSocket(getWsUrl(targetSessionId, targetPlayerId));
wsRef.current = ws;
ws.onopen = () => {
if (connectionGenerationRef.current !== generation) {
return;
}
reconnectAttemptRef.current = 0;
setReconnectAttempt(0);
setConnectionState("open");
setError(null);
markActivity();
startSocketTimers(ws, generation);
};
ws.onmessage = (event) => {
if (connectionGenerationRef.current !== generation) {
return;
}
markActivity();
try {
const message = JSON.parse(event.data) as IncomingMessage;
if (message.type === "state") {
setSession(message.session);
return;
}
if (message.type === "error") {
setError(message.message);
return;
}
if (message.type === "takeover_approved") {
const assignedId = message.assignedPlayerId;
setPlayerId(assignedId);
if (sessionIdRef.current && sessionCodeRef.current) {
void writeStoredSession({
sessionId: sessionIdRef.current,
sessionCode: sessionCodeRef.current,
playerId: assignedId,
});
}
}
} catch {
setError(tStatic("error.parseResponse"));
}
};
ws.onerror = () => {
if (connectionGenerationRef.current !== generation) {
return;
}
setConnectionState("reconnecting");
};
ws.onclose = (event) => {
if (connectionGenerationRef.current !== generation) {
return;
}
if (wsRef.current === ws) {
wsRef.current = null;
}
clearSocketTimers();
const reasonText = typeof event?.reason === "string" ? event.reason : "";
if (isTerminalSocketClose(event?.code, reasonText)) {
setConnectionState("error");
void resetSession();
return;
}
if (
suppressReconnectRef.current ||
!sessionIdRef.current ||
!playerIdRef.current
) {
setConnectionState("idle");
return;
}
scheduleReconnect(generation);
};
}
function retryConnection() {
if (!sessionIdRef.current || !playerIdRef.current) {
return;
}
suppressReconnectRef.current = false;
reconnectAttemptRef.current = 0;
setReconnectAttempt(0);
openSocket("manual");
}
useEffect(() => {
sessionIdRef.current = sessionId;
sessionCodeRef.current = sessionCode;
playerIdRef.current = playerId;
}, [playerId, sessionCode, sessionId]);
useEffect(() => {
screenshotRef.current = screenshot;
}, [screenshot]);
useEffect(() => {
sessionRef.current = session;
}, [session]);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
readStoredSession().then((stored) => { readStoredSession().then((stored) => {
if (!mounted || !stored) return; if (!mounted || !stored || screenshotRef.current) return;
setSessionId(stored.sessionId); setSessionId(stored.sessionId);
setSessionCode(stored.sessionCode); setSessionCode(stored.sessionCode);
setPlayerId(stored.playerId); setPlayerId(stored.playerId);
@ -63,6 +336,7 @@ export function useSessionManager() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (screenshot) return;
let mounted = true; let mounted = true;
registerForPushNotificationsAsync().then((token) => { registerForPushNotificationsAsync().then((token) => {
if (!mounted) return; if (!mounted) return;
@ -71,17 +345,100 @@ export function useSessionManager() {
return () => { return () => {
mounted = false; mounted = false;
}; };
}, []); }, [screenshot]);
useEffect(() => { useEffect(() => {
const timer = setInterval(() => setTick((value) => value + 1), 1000); if (screenshot) return;
return () => clearInterval(timer); const subscription = AppState.addEventListener("change", (nextState) => {
}, []); const previousState = appStateRef.current;
appStateRef.current = nextState;
if (
previousState !== "active" &&
nextState === "active" &&
sessionIdRef.current &&
playerIdRef.current
) {
const socket = wsRef.current;
const socketOpen = socket?.readyState === WebSocket.OPEN;
if (!socketOpen || isConnectionStale(lastActivityAtRef.current)) {
retryConnection();
return;
}
try {
socket.send(
JSON.stringify({
type: "ping",
sessionId: sessionIdRef.current,
playerId: playerIdRef.current,
}),
);
} catch {
retryConnection();
}
}
});
return () => {
subscription.remove();
};
}, [screenshot]);
useEffect(() => { useEffect(() => {
if (screenshot) return;
if (!pushToken || !sessionId || !playerId) return; if (!pushToken || !sessionId || !playerId) return;
void registerPushTokenFor(sessionId, playerId); 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();
reconnectAttemptRef.current = 0;
setReconnectAttempt(0);
lastActivityAtRef.current = null;
setLastActivityAt(null);
setConnectionState("idle");
setSession(null);
return;
}
suppressReconnectRef.current = false;
reconnectAttemptRef.current = 0;
setReconnectAttempt(0);
openSocket("initial");
return () => {
teardownConnection();
};
}, [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) { async function registerPushTokenFor(targetSessionId: string, targetPlayerId: string) {
if (!pushToken) return; if (!pushToken) return;
@ -104,64 +461,6 @@ export function useSessionManager() {
} }
} }
useEffect(() => {
if (!sessionId || !playerId) {
setConnectionState("idle");
setSession(null);
return;
}
setConnectionState("connecting");
const ws = new WebSocket(getWsUrl(sessionId, playerId));
wsRef.current = ws;
ws.onopen = () => setConnectionState("open");
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === "state") {
setSession(message.session as SessionSnapshot);
}
if (message.type === "error") {
setError(message.message);
}
if (message.type === "takeover_approved") {
const assignedId = message.assignedPlayerId as string;
setPlayerId(assignedId);
if (sessionId && sessionCode) {
writeStoredSession({
sessionId,
sessionCode,
playerId: assignedId,
});
}
}
} catch {
setError(tStatic("error.parseResponse"));
}
};
ws.onerror = () => setConnectionState("error");
ws.onclose = (event) => {
setConnectionState("error");
const reason = typeof event?.reason === "string" ? event.reason : "";
if (event?.code === 1008 && /session not found|player not found/i.test(reason)) {
resetSession();
}
};
const pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping", sessionId, playerId }));
}
}, 15000);
return () => {
clearInterval(pingTimer);
ws.close();
};
}, [sessionId, playerId]);
async function requestTakeover( async function requestTakeover(
dummyId: string, dummyId: string,
overrideSessionId?: string, overrideSessionId?: string,
@ -214,7 +513,12 @@ export function useSessionManager() {
async function createSession(bankerName: string) { async function createSession(bankerName: string) {
setError(null); setError(null);
setScreenshot(null);
setSessionId("");
setSessionCode("");
setPlayerId("");
setSession(null); setSession(null);
try {
const response = await fetch(`${getApiBaseUrl()}/api/session`, { const response = await fetch(`${getApiBaseUrl()}/api/session`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -234,10 +538,18 @@ export function useSessionManager() {
playerId: data.playerId, playerId: data.playerId,
}); });
return data; return data;
} catch {
setError(tStatic("error.createSession"));
return null;
}
} }
async function joinSession(code: string, name: string) { async function joinSession(code: string, name: string) {
setError(null); setError(null);
setScreenshot(null);
setSessionId("");
setSessionCode("");
setPlayerId("");
setSession(null); setSession(null);
if (!code) { if (!code) {
setError(tStatic("entry.alert.enterCode")); setError(tStatic("entry.alert.enterCode"));
@ -249,6 +561,7 @@ export function useSessionManager() {
? storedNow.playerId ? storedNow.playerId
: undefined; : undefined;
try {
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/join`, { const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/join`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -268,6 +581,10 @@ export function useSessionManager() {
playerId: data.playerId, playerId: data.playerId,
}); });
return data; return data;
} catch {
setError(tStatic("error.joinSession"));
return null;
}
} }
async function requestTakeoverToken( async function requestTakeoverToken(
@ -304,6 +621,10 @@ export function useSessionManager() {
async function claimTakeover(code: string, token: string) { async function claimTakeover(code: string, token: string) {
try { try {
setScreenshot(null);
setSessionId("");
setSessionCode("");
setPlayerId("");
const response = await fetch( const response = await fetch(
`${getApiBaseUrl()}/api/session/${code}/takeover-claim`, `${getApiBaseUrl()}/api/session/${code}/takeover-claim`,
{ {
@ -339,23 +660,42 @@ export function useSessionManager() {
async function fetchSessionPreview(code: string): Promise<SessionPreview | null> { async function fetchSessionPreview(code: string): Promise<SessionPreview | null> {
if (!code) return null; if (!code) return null;
try {
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`); const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`);
if (!response.ok) { if (!response.ok) {
setError(tStatic("error.loadSessionInfo")); setError(tStatic("error.loadSessionInfo"));
return null; return null;
} }
return (await response.json()) as SessionPreview; return (await response.json()) as SessionPreview;
} catch {
setError(tStatic("error.loadSessionInfo"));
return null;
}
} }
function sendMessage(payload: Record<string, unknown>) { function sendMessage(payload: Record<string, unknown>) {
if (screenshotRef.current) {
return;
}
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
setError(tStatic("error.connectionNotReady")); retryConnection();
setError(
connectionState === "reconnecting" || connectionState === "connecting"
? tStatic("error.reconnecting")
: tStatic("error.connectionNotReady"),
);
return; return;
} }
wsRef.current.send(JSON.stringify(payload)); wsRef.current.send(JSON.stringify(payload));
} }
async function resetSession() { async function resetSession() {
suppressReconnectRef.current = true;
teardownConnection();
reconnectAttemptRef.current = 0;
setReconnectAttempt(0);
lastActivityAtRef.current = null;
setLastActivityAt(null);
try { try {
await clearStoredSession(); await clearStoredSession();
} catch { } catch {
@ -365,20 +705,14 @@ export function useSessionManager() {
setSessionCode(""); setSessionCode("");
setPlayerId(""); setPlayerId("");
setSession(null); setSession(null);
setScreenshot(null);
setError(null); setError(null);
setConnectionState("idle"); setConnectionState("idle");
} }
async function leaveSession() { async function leaveSession() {
const ws = wsRef.current; suppressReconnectRef.current = true;
wsRef.current = null; teardownConnection();
if (ws && ws.readyState !== WebSocket.CLOSED) {
try {
ws.close();
} catch {
// Ignore failures while closing the socket.
}
}
await resetSession(); await resetSession();
} }
@ -392,9 +726,10 @@ export function useSessionManager() {
session, session,
me, me,
isBanker, isBanker,
tick,
error, error,
connectionState, connectionState,
reconnectAttempt,
lastActivityAt,
setError, setError,
createSession, createSession,
joinSession, joinSession,
@ -404,9 +739,12 @@ export function useSessionManager() {
sendMessage, sendMessage,
resetSession, resetSession,
leaveSession, leaveSession,
retryConnection,
setSessionId, setSessionId,
setPlayerId, setPlayerId,
setSession, setSession,
requestTakeover, requestTakeover,
activateScreenshotScene,
screenshot,
}; };
} }

View file

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

View file

@ -88,6 +88,9 @@ export type ServerMessage =
type: "state"; type: "state";
session: SessionSnapshot; session: SessionSnapshot;
} }
| {
type: "pong";
}
| { | {
type: "error"; type: "error";
message: string; message: string;

102
server/websocket.test.ts Normal file
View file

@ -0,0 +1,102 @@
import { afterEach, expect, test } from "bun:test";
import { createSession, removeSession } from "./store";
import { joinSession } from "./domain";
import {
STALE_SOCKET_TIMEOUT_MS,
handleSocketMessage,
reapStaleSockets,
registerSocket,
resetWebsocketStateForTests,
unregisterSocket,
} from "./websocket";
const OPEN = 1;
const CLOSED = 3;
type FakeSocket = {
readyState: number;
sent: string[];
closes: Array<{ code?: number; reason?: string }>;
send: (payload: string) => void;
close: (code?: number, reason?: string) => void;
};
function createFakeSocket(): FakeSocket {
return {
readyState: OPEN,
sent: [],
closes: [],
send(payload: string) {
this.sent.push(payload);
},
close(code?: number, reason?: string) {
this.closes.push({ code, reason });
this.readyState = CLOSED;
},
};
}
afterEach(() => {
resetWebsocketStateForTests();
});
test("overlapping sockets do not disconnect a player until the last socket closes", () => {
const { session } = createSession("Banker");
const player = joinSession(session, "Jules");
const firstSocket = createFakeSocket();
const secondSocket = createFakeSocket();
registerSocket(firstSocket as unknown as WebSocket, session.id, player.id);
registerSocket(secondSocket as unknown as WebSocket, session.id, player.id);
unregisterSocket(firstSocket as unknown as WebSocket);
expect(session.players.get(player.id)?.connected).toBe(true);
expect(session.players.get(player.id)?.isDummy).toBe(false);
unregisterSocket(secondSocket as unknown as WebSocket);
expect(session.players.get(player.id)?.connected).toBe(false);
expect(session.players.get(player.id)?.isDummy).toBe(true);
removeSession(session.id);
});
test("stale sockets are reaped and disconnect the player", () => {
const { session } = createSession("Banker");
const player = joinSession(session, "Rosa");
const socket = createFakeSocket();
registerSocket(socket as unknown as WebSocket, session.id, player.id);
reapStaleSockets(Date.now() + STALE_SOCKET_TIMEOUT_MS + 1);
expect(socket.closes[0]).toEqual({ code: 4000, reason: "Connection stale" });
expect(session.players.get(player.id)?.connected).toBe(false);
expect(session.players.get(player.id)?.isDummy).toBe(true);
removeSession(session.id);
});
test("invalid session registration closes the socket with a terminal code", () => {
const socket = createFakeSocket();
registerSocket(socket as unknown as WebSocket, "missing-session", "missing-player");
expect(socket.closes[0]).toEqual({ code: 1008, reason: "Session not found" });
});
test("ping messages update liveness and emit a pong", () => {
const { session } = createSession("Banker");
const player = joinSession(session, "Nina");
const socket = createFakeSocket();
registerSocket(socket as unknown as WebSocket, session.id, player.id);
socket.sent = [];
handleSocketMessage(
socket as unknown as WebSocket,
JSON.stringify({ type: "ping", sessionId: session.id, playerId: player.id }),
);
expect(socket.sent.some((entry) => entry.includes('"type":"pong"'))).toBe(true);
removeSession(session.id);
});

View file

@ -20,10 +20,24 @@ import { getSession, removeSession } from "./store";
import { now } from "./util"; import { now } from "./util";
import { notifyChat, notifyTransaction } from "./notifications"; import { notifyChat, notifyTransaction } from "./notifications";
export const STALE_SOCKET_TIMEOUT_MS = 45_000;
const STALE_SOCKET_REAP_INTERVAL_MS = 10_000;
type SocketMeta = {
sessionId: string;
playerId: string;
lastSeenAt: number;
};
const socketsBySession = new Map<string, Set<WebSocket>>(); const socketsBySession = new Map<string, Set<WebSocket>>();
const metaBySocket = new WeakMap<WebSocket, { sessionId: string; playerId: string }>(); const socketsByPlayer = new Map<string, Set<WebSocket>>();
let metaBySocket = new WeakMap<WebSocket, SocketMeta>();
const testTimers = new Map<string, ReturnType<typeof setTimeout>>(); const testTimers = new Map<string, ReturnType<typeof setTimeout>>();
function playerSocketKey(sessionId: string, playerId: string): string {
return `${sessionId}:${playerId}`;
}
function randomInt(min: number, max: number): number { function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min; return Math.floor(Math.random() * (max - min + 1)) + min;
} }
@ -55,7 +69,7 @@ function runTestTransfer(sessionId: string): void {
let attempts = 0; let attempts = 0;
while (attempts < 5) { while (attempts < 5) {
const from = players[randomInt(0, players.length - 1)]; const from = players[randomInt(0, players.length - 1)];
let to = players[randomInt(0, players.length - 1)]; const to = players[randomInt(0, players.length - 1)];
if (to.id === from.id) { if (to.id === from.id) {
attempts += 1; attempts += 1;
continue; continue;
@ -99,6 +113,54 @@ function getSessionSockets(sessionId: string): Set<WebSocket> {
return set; return set;
} }
function getPlayerSockets(sessionId: string, playerId: string): Set<WebSocket> {
const key = playerSocketKey(sessionId, playerId);
let set = socketsByPlayer.get(key);
if (!set) {
set = new Set();
socketsByPlayer.set(key, set);
}
return set;
}
function deleteSocketFromTracking(ws: WebSocket, meta: SocketMeta): {
sessionSocketCount: number;
playerSocketCount: number;
} {
const sessionSockets = socketsBySession.get(meta.sessionId);
if (sessionSockets) {
sessionSockets.delete(ws);
if (sessionSockets.size === 0) {
socketsBySession.delete(meta.sessionId);
}
}
const playerKey = playerSocketKey(meta.sessionId, meta.playerId);
const playerSockets = socketsByPlayer.get(playerKey);
if (playerSockets) {
playerSockets.delete(ws);
if (playerSockets.size === 0) {
socketsByPlayer.delete(playerKey);
}
}
metaBySocket.delete(ws);
return {
sessionSocketCount: sessionSockets?.size ?? 0,
playerSocketCount: playerSockets?.size ?? 0,
};
}
function touchSocket(ws: WebSocket): SocketMeta | null {
const meta = metaBySocket.get(ws);
if (!meta) {
return null;
}
meta.lastSeenAt = now();
return meta;
}
export function registerSocket(ws: WebSocket, sessionId: string, playerId: string): void { export function registerSocket(ws: WebSocket, sessionId: string, playerId: string): void {
const session = getSession(sessionId); const session = getSession(sessionId);
if (!session) { if (!session) {
@ -110,12 +172,15 @@ export function registerSocket(ws: WebSocket, sessionId: string, playerId: strin
ws.close(1008, "Player not found"); ws.close(1008, "Player not found");
return; return;
} }
const meta: SocketMeta = { sessionId, playerId, lastSeenAt: now() };
metaBySocket.set(ws, meta);
getSessionSockets(sessionId).add(ws);
getPlayerSockets(sessionId, playerId).add(ws);
player.connected = true; player.connected = true;
player.isDummy = false; player.isDummy = false;
player.lastActiveAt = now(); player.lastActiveAt = meta.lastSeenAt;
metaBySocket.set(ws, { sessionId, playerId });
getSessionSockets(sessionId).add(ws);
sendStateToSession(session); sendStateToSession(session);
} }
@ -125,32 +190,64 @@ export function unregisterSocket(ws: WebSocket): void {
if (!meta) { if (!meta) {
return; return;
} }
const { sessionId, playerId } = meta; const { sessionId, playerId } = meta;
const { sessionSocketCount, playerSocketCount } = deleteSocketFromTracking(ws, meta);
const session = getSession(sessionId); const session = getSession(sessionId);
if (session) {
if (session && playerSocketCount === 0) {
disconnectPlayer(session, playerId); disconnectPlayer(session, playerId);
sendStateToSession(session); sendStateToSession(session);
} }
const set = socketsBySession.get(sessionId);
if (set) { if (sessionSocketCount === 0 && session?.isTest) {
set.delete(ws);
if (set.size === 0) {
socketsBySession.delete(sessionId);
if (session?.isTest) {
stopTestSimulation(sessionId); stopTestSimulation(sessionId);
removeSession(sessionId); removeSession(sessionId);
} }
}
export function reapStaleSockets(referenceNow = now()): void {
const staleSockets: WebSocket[] = [];
socketsBySession.forEach((sessionSockets) => {
sessionSockets.forEach((socket) => {
const meta = metaBySocket.get(socket);
if (!meta) {
return;
} }
if (referenceNow - meta.lastSeenAt <= STALE_SOCKET_TIMEOUT_MS) {
return;
} }
metaBySocket.delete(ws); staleSockets.push(socket);
});
});
staleSockets.forEach((socket) => {
try {
socket.close(4000, "Connection stale");
} catch {
// Ignore close failures.
}
unregisterSocket(socket);
});
}
export function resetWebsocketStateForTests(): void {
socketsBySession.clear();
socketsByPlayer.clear();
testTimers.forEach((timer) => clearTimeout(timer));
testTimers.clear();
metaBySocket = new WeakMap<WebSocket, SocketMeta>();
} }
export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): void { export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): void {
touchSocket(ws);
const messageText = typeof raw === "string" ? raw : new TextDecoder().decode(raw); const messageText = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
let parsed: ClientMessage; let parsed: ClientMessage;
try { try {
parsed = JSON.parse(messageText) as ClientMessage; parsed = JSON.parse(messageText) as ClientMessage;
} catch (error) { } catch {
send(ws, { type: "error", message: "Invalid message" }); send(ws, { type: "error", message: "Invalid message" });
return; return;
} }
@ -162,7 +259,7 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v
} }
try { try {
handleMessage(session, parsed); handleMessage(ws, session, parsed);
} catch (error) { } catch (error) {
const message = const message =
error instanceof DomainError error instanceof DomainError
@ -175,15 +272,14 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v
sendStateToSession(session); sendStateToSession(session);
} }
function handleMessage(session: Session, message: ClientMessage): void { function handleMessage(ws: WebSocket, session: Session, message: ClientMessage): void {
switch (message.type) { switch (message.type) {
case "chat_send": { case "chat_send": {
const chat = addChatMessage(session, message.playerId, message.body, message.groupId); const chat = addChatMessage(session, message.playerId, message.body, message.groupId);
notifyChat(session, chat); notifyChat(session, chat);
return; return;
} }
case "transfer": case "transfer": {
{
const transaction = transfer( const transaction = transfer(
session, session,
message.playerId, message.playerId,
@ -247,6 +343,7 @@ function handleMessage(session: Session, message: ClientMessage): void {
} }
case "ping": case "ping":
touchPlayer(session, message.playerId); touchPlayer(session, message.playerId);
send(ws, { type: "pong" });
return; return;
default: default:
return; return;
@ -295,6 +392,13 @@ function notifyTakeoverApproval(
if (meta.playerId === requesterId) { if (meta.playerId === requesterId) {
send(socket, { type: "takeover_approved", assignedPlayerId: assignedId }); send(socket, { type: "takeover_approved", assignedPlayerId: assignedId });
meta.playerId = assignedId; meta.playerId = assignedId;
deleteSocketFromTracking(socket, {
...meta,
playerId: requesterId,
});
getSessionSockets(sessionId).add(socket);
getPlayerSockets(sessionId, assignedId).add(socket);
metaBySocket.set(socket, meta);
} }
}); });
} }
@ -305,3 +409,7 @@ function send(ws: WebSocket, message: ServerMessage): void {
} }
ws.send(JSON.stringify(message)); ws.send(JSON.stringify(message));
} }
setInterval(() => {
reapStaleSockets();
}, STALE_SOCKET_REAP_INTERVAL_MS);