Compare commits

..

No commits in common. "d8121e74a4e33ffc85adf1d26edb5cb5542ab4f8" and "62beda2bf721d317770f2b3d0c43ef7eb9e95ea2" have entirely different histories.

39 changed files with 1062 additions and 3478 deletions

View file

@ -1,4 +1,4 @@
# Crédit Mabligop Mobile (React Native) # Negopoly 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,21 +15,3 @@ 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": "Crédit Mabligop", "name": "Negopoly Companion",
"slug": "negopoly-companion", "slug": "negopoly-companion",
"version": "1.0.0", "version": "1.0.0",
"platforms": ["ios", "android"], "platforms": ["ios", "android"],
@ -12,21 +12,20 @@
"splash": { "splash": {
"image": "./assets/splash-icon.png", "image": "./assets/splash-icon.png",
"resizeMode": "contain", "resizeMode": "contain",
"backgroundColor": "#f5efe6" "backgroundColor": "#ffffff"
}, },
"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": "#f5efe6" "backgroundColor": "#ffffff"
}, },
"edgeToEdgeEnabled": true, "edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false, "predictiveBackGestureEnabled": false,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

View file

@ -1,95 +0,0 @@
{
"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

@ -1,23 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 KiB

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 KiB

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 680 KiB

After

Width:  |  Height:  |  Size: 618 KiB

159
mobile/package-lock.json generated
View file

@ -22,8 +22,7 @@
"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",
@ -3393,12 +3392,6 @@
"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",
@ -3914,56 +3907,6 @@
"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",
@ -4098,61 +4041,6 @@
"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",
@ -4221,18 +4109,6 @@
"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",
@ -6133,12 +6009,6 @@
"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",
@ -6731,18 +6601,6 @@
"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",
@ -7489,21 +7347,6 @@
"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,8 +6,7 @@
"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",
@ -24,8 +23,7 @@
"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

@ -1,226 +0,0 @@
#!/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,7 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Linking, StyleSheet, View } from "react-native"; import { Linking } from "react-native";
import { import {
CommonActions,
NavigationContainer, NavigationContainer,
type LinkingOptions, type LinkingOptions,
useNavigationContainerRef, useNavigationContainerRef,
@ -10,51 +9,36 @@ 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 getUrlSegments(url: string): string[] {
try {
const parsed = new URL(url);
const pathSegments = (parsed.pathname || "")
.split("/")
.map((segment) => segment.trim())
.filter(Boolean);
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
return pathSegments;
}
if (parsed.host) {
return [parsed.host, ...pathSegments];
}
return pathSegments;
} catch {
return [];
}
}
function extractGameId(url: string): string | null { function extractGameId(url: string): string | null {
const segments = getUrlSegments(url); try {
if (segments[0] !== "play") return null; const parsed = new URL(url);
return segments[1] || null; const path = parsed.pathname || "";
} if (parsed.protocol === "https:" || parsed.protocol === "http:") {
const match = path.match(/^\/play\/?([^/]+)?/);
function extractScreenshotScene(url: string) { const id = match?.[1]?.trim();
const segments = getUrlSegments(url); return id ? id : null;
if (segments[0] !== "screenshot") return null; }
return normalizeScreenshotScene(segments[1]); if (parsed.host === "play") {
const id = path.replace(/^\//, "").trim();
return id ? id : null;
}
const fallbackMatch = path.match(/^\/play\/?([^/]+)?/);
const fallbackId = fallbackMatch?.[1]?.trim();
return fallbackId ? fallbackId : null;
} catch {
return null;
}
} }
function logDeepLink(url: string) { function logDeepLink(url: string) {
if (!__DEV__) return; if (!__DEV__) return;
const gameId = extractGameId(url); const gameId = extractGameId(url);
const scene = extractScreenshotScene(url); console.log(`[deep-link] url=${url} gameId=${gameId ?? "invalid"}`);
console.log(
`[deep-link] url=${url} gameId=${gameId ?? "invalid"} screenshot=${scene ?? "none"}`,
);
} }
function RootNavigationGate() { function RootNavigationGate() {
@ -64,26 +48,15 @@ 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: {
AgencyJoin: "play/:gameId", Entry: "play/:gameId",
}, },
}, },
getInitialURL: async () => { getInitialURL: async () => {
@ -91,9 +64,6 @@ 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;
}, },
@ -103,14 +73,13 @@ 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(() => {
@ -124,52 +93,27 @@ 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.dispatch( navigationRef.navigate(
CommonActions.navigate({ targetStack as never,
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.dispatch( navigationRef.navigate(
CommonActions.navigate({ targetStack as never,
name: targetStack, { screen: targetTab } as never,
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;
@ -195,27 +139,13 @@ 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) {
if (inEntryFlow) { target = "Entry";
lastTargetRef.current = currentName ?? null;
return;
}
target = "EntryLanding";
} else if (!manager.session) { } else if (!manager.session) {
target = "Lobby"; target = manager.connectionState === "error" ? "Entry" : "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) {
@ -224,6 +154,7 @@ 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;
} }
@ -242,10 +173,6 @@ function RootNavigationGate() {
navigationRef, navigationRef,
]); ]);
useEffect(() => {
processPendingScreenshot();
}, [processPendingScreenshot]);
useEffect(() => { useEffect(() => {
processPendingNotification(); processPendingNotification();
}, [processPendingNotification]); }, [processPendingNotification]);
@ -268,7 +195,6 @@ function RootNavigationGate() {
}, [handleNotificationResponse]); }, [handleNotificationResponse]);
return ( return (
<View style={styles.container}>
<NavigationContainer <NavigationContainer
ref={navigationRef} ref={navigationRef}
onReady={() => setNavReady(true)} onReady={() => setNavReady(true)}
@ -276,18 +202,7 @@ 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>
); );
} }
@ -305,9 +220,3 @@ export default function App() {
</SafeAreaProvider> </SafeAreaProvider>
); );
} }
const styles = StyleSheet.create({
container: {
flex: 1,
},
});

View file

@ -1,104 +0,0 @@
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

@ -1,43 +0,0 @@
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

@ -1,102 +0,0 @@
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

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

View file

@ -2,10 +2,7 @@ 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 BrandLockup from "../components/BrandLockup"; import EntryScreen from "../screens/EntryScreen";
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";
@ -29,10 +26,6 @@ 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();
@ -44,23 +37,22 @@ 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={{ headerTitle: buildHeaderTitle(t("chat.title")) }} options={{ title: t("chat.title") }}
/> />
<ChatStack.Screen <ChatStack.Screen
name="ChatThread" name="ChatThread"
component={ChatThreadScreen} component={ChatThreadScreen}
options={{ headerTitle: buildHeaderTitle(t("tabs.chat")) }} options={{ title: t("tabs.chat") }}
/> />
<ChatStack.Screen <ChatStack.Screen
name="ChatNew" name="ChatNew"
component={ChatNewScreen} component={ChatNewScreen}
options={{ headerTitle: buildHeaderTitle(t("chat.newTitle")) }} options={{ title: t("chat.newTitle") }}
/> />
</ChatStack.Navigator> </ChatStack.Navigator>
); );
@ -83,7 +75,6 @@ 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
@ -91,7 +82,6 @@ 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} />
), ),
@ -102,7 +92,6 @@ 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} />
), ),
@ -140,7 +129,6 @@ 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
@ -148,7 +136,6 @@ 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} />
), ),
@ -159,7 +146,6 @@ 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} />
), ),
@ -181,10 +167,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,
@ -192,21 +178,7 @@ export default function AppNavigator() {
contentStyle: { backgroundColor: theme.colors.background }, contentStyle: { backgroundColor: theme.colors.background },
}} }}
> >
<RootStack.Screen <RootStack.Screen name="Entry" component={EntryScreen} options={{ headerShown: false }} />
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,7 +1,5 @@
export type RootStackParamList = { export type RootStackParamList = {
EntryLanding: undefined; Entry: { gameId?: string } | undefined;
AgencyJoin: { gameId?: string } | undefined;
AgencyCreate: undefined;
Lobby: undefined; Lobby: undefined;
PlayerTabs: undefined; PlayerTabs: undefined;
BankerTabs: undefined; BankerTabs: undefined;

View file

@ -8,8 +8,6 @@ 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

@ -1,161 +0,0 @@
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

@ -1,485 +0,0 @@
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: Transaction["kind"], kind: string,
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: string = t("common.bank"); let counterparty = t("common.bank");
const timeLabel: string = formatTransactionTimestamp(transaction.createdAt); const timeLabel = 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: Transaction["kind"], kind: string,
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: string = t("common.bank"); let counterparty = t("common.bank");
const timeLabel: string = formatTransactionTimestamp(transaction.createdAt); const timeLabel = formatTransactionTimestamp(transaction.createdAt);
if (transaction.kind === "banker_adjust") { if (transaction.kind === "banker_adjust") {
outgoing = transaction.amount < 0; outgoing = transaction.amount < 0;
@ -327,9 +327,6 @@ 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 =
@ -439,7 +436,7 @@ export default function BankerToolsScreen() {
const display = getTransactionDisplay( const display = getTransactionDisplay(
transaction, transaction,
selectedPlayerId, selectedPlayerId,
session.players, manager.session?.players ?? [],
t, t,
); );
return ( return (
@ -698,14 +695,14 @@ export default function BankerToolsScreen() {
manager.sendMessage({ manager.sendMessage({
type: "banker_blackout", type: "banker_blackout",
sessionId: manager.sessionId, sessionId: manager.sessionId,
bankerId: me.id, bankerId: manager.me?.id,
active: !session.blackoutActive, active: !manager.session.blackoutActive,
reason: !session.blackoutActive ? blackoutReason : null, reason: !manager.session.blackoutActive ? blackoutReason : null,
}) })
} }
> >
<Text style={styles.buttonDangerText}> <Text style={styles.buttonDangerText}>
{session.blackoutActive {manager.session.blackoutActive
? t("banker.tools.blackoutDisable") ? t("banker.tools.blackoutDisable")
: t("banker.tools.blackoutEnable")} : t("banker.tools.blackoutEnable")}
</Text> </Text>
@ -716,7 +713,7 @@ export default function BankerToolsScreen() {
manager.sendMessage({ manager.sendMessage({
type: "banker_end", type: "banker_end",
sessionId: manager.sessionId, sessionId: manager.sessionId,
bankerId: me.id, bankerId: manager.me?.id,
}) })
} }
> >

View file

@ -1,126 +0,0 @@
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

@ -0,0 +1,456 @@
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 { useTheme, type AppTheme } from "../theme"; import { useI18n } from "../i18n";
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,8 +29,24 @@ 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 customers = manager.session?.players.filter((player) => player.role !== "banker") ?? []; const containerStyle = useMemo(
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;
@ -41,90 +57,50 @@ export default function LobbyScreen() {
if (!manager.session || !manager.me) { if (!manager.session || !manager.me) {
return ( return (
<View <View style={containerStyle}>
style={[ <Text style={styles.title}>{t("common.loadingLobby")}</Text>
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 session = manager.session; const canStart = manager.isBanker && manager.session.status === "lobby";
const me = manager.me; const pendingTakeover = manager.session.takeoverRequests.find(
const canStart = manager.isBanker && session.status === "lobby"; (request) =>
const pendingTakeover = session.takeoverRequests.find( request.requesterId === manager.playerId && request.status === "pending",
(request) => request.requesterId === manager.playerId && request.status === "pending",
); );
const pendingRequests = manager.isBanker const pendingRequests = manager.isBanker
? session.takeoverRequests.filter((request) => request.status === "pending") ? manager.session.takeoverRequests.filter((request) => request.status === "pending")
: []; : [];
return ( return (
<ScrollView <View style={containerStyle}>
style={styles.scroll} <Text style={styles.title}>{t("lobby.title")}</Text>
contentContainerStyle={{ <Text style={styles.subtitle}>{t("lobby.code", { code: manager.session.code })}</Text>
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 ? (
<View style={styles.noticeCard}> <Text style={styles.helper}>{t("entry.takeoverPending")}</Text>
<Text style={styles.noticeText}>{t("entry.takeoverPending")}</Text>
</View>
) : null} ) : null}
<View style={styles.card}> <FlatList
<Text style={styles.cardTitle}>{t("lobby.rosterTitle")}</Text> data={manager.session.players}
<View style={styles.roster}> keyExtractor={(item) => item.id}
{session.players.map((item) => ( contentContainerStyle={styles.list}
<View key={item.id} style={styles.listItem}> renderItem={({ item }) => (
<View style={styles.listCopy}> <View style={styles.listItem}>
<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.statusText}> <Text style={styles.playerMeta}>
{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}>
@ -132,9 +108,10 @@ export default function LobbyScreen() {
<View style={styles.takeoverList}> <View style={styles.takeoverList}>
{pendingRequests.map((request) => { {pendingRequests.map((request) => {
const requester = const requester =
session.players.find((player) => player.id === request.requesterId) ?? null; manager.session.players.find((player) => player.id === request.requesterId) ??
null;
const dummy = const dummy =
session.players.find((player) => player.id === request.dummyId) ?? null; manager.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 (
@ -151,7 +128,7 @@ export default function LobbyScreen() {
manager.sendMessage({ manager.sendMessage({
type: "banker_takeover_approve", type: "banker_takeover_approve",
sessionId: manager.sessionId, sessionId: manager.sessionId,
bankerId: me.id, bankerId: manager.me?.id,
dummyId: request.dummyId, dummyId: request.dummyId,
requesterId: request.requesterId, requesterId: request.requesterId,
}) })
@ -166,10 +143,10 @@ export default function LobbyScreen() {
</View> </View>
) : null} ) : null}
{manager.isBanker ? ( {manager.isBanker && manager.session.status === "lobby" && (
<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.cardSubtitle}>{t("lobby.addDummySubtitle")}</Text> <Text style={styles.helper}>{t("lobby.addDummySubtitle")}</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder={t("lobby.enterDummyName")} placeholder={t("lobby.enterDummyName")}
@ -191,7 +168,7 @@ export default function LobbyScreen() {
manager.sendMessage({ manager.sendMessage({
type: "banker_create_dummy", type: "banker_create_dummy",
sessionId: manager.sessionId, sessionId: manager.sessionId,
bankerId: me.id, bankerId: manager.me?.id,
name: dummyName, name: dummyName,
balance: Number(dummyBalance) || undefined, balance: Number(dummyBalance) || undefined,
}); });
@ -202,233 +179,143 @@ 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: me.id, bankerId: manager.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" />
</ScrollView> </View>
); );
} }
const createStyles = (theme: AppTheme) => const createStyles = (theme: AppTheme) =>
StyleSheet.create({ StyleSheet.create({
scroll: { container: {
flex: 1, flex: 1,
paddingHorizontal: 0,
paddingBottom: 0,
gap: 12,
backgroundColor: theme.colors.background, backgroundColor: theme.colors.background,
}, },
loadingContainer: { title: {
flex: 1, fontSize: 24,
gap: 16,
backgroundColor: theme.colors.background,
justifyContent: "center",
},
loadingText: {
color: theme.colors.text,
fontSize: 20,
fontWeight: "700", fontWeight: "700",
},
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, color: theme.colors.text,
fontSize: 20,
fontWeight: "700",
}, },
cardSubtitle: { subtitle: {
color: theme.colors.textMuted, color: theme.colors.textMuted,
lineHeight: 21,
}, },
roster: { list: {
gap: 10, gap: 10,
paddingBottom: 20,
}, },
listItem: { listItem: {
backgroundColor: theme.colors.surfaceAlt, backgroundColor: theme.colors.surface,
borderRadius: 18, borderRadius: 12,
padding: 14, padding: 12,
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
gap: 12,
},
listCopy: {
flex: 1,
gap: 3,
}, },
playerName: { playerName: {
fontWeight: "700", fontWeight: "600",
color: theme.colors.text, color: theme.colors.text,
}, },
playerMeta: { playerMeta: {
fontSize: 12, fontSize: 12,
color: theme.colors.textMuted, color: theme.colors.textMuted,
}, },
statusText: { card: {
backgroundColor: theme.colors.surface,
borderRadius: 16,
padding: 16,
gap: 10,
borderWidth: 1,
borderColor: theme.colors.borderMuted,
},
cardTitle: {
fontWeight: "600",
color: theme.colors.text,
},
helper: {
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: 16, borderRadius: 12,
paddingHorizontal: 14, paddingHorizontal: 12,
paddingVertical: 13, paddingVertical: 10,
}, },
button: { button: {
backgroundColor: theme.colors.primary, backgroundColor: theme.colors.primary,
paddingVertical: 15, paddingVertical: 14,
borderRadius: 999, borderRadius: 999,
alignItems: "center", alignItems: "center",
}, },
buttonText: { buttonText: {
color: theme.colors.primaryText, color: theme.colors.primaryText,
fontWeight: "700", fontWeight: "600",
}, },
buttonSecondary: { buttonSecondary: {
backgroundColor: theme.colors.secondary, backgroundColor: theme.colors.secondary,
paddingVertical: 14, paddingVertical: 12,
borderRadius: 999, borderRadius: 999,
alignItems: "center", alignItems: "center",
}, },
buttonSecondaryText: { buttonSecondaryText: {
color: theme.colors.secondaryText, color: theme.colors.secondaryText,
fontWeight: "700", fontWeight: "600",
},
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",
gap: 12, justifyContent: "space-between",
paddingVertical: 6,
borderBottomWidth: 1,
borderBottomColor: theme.colors.borderMuted,
}, },
takeoverMeta: { takeoverMeta: {
flex: 1, flex: 1,
gap: 3, paddingRight: 12,
}, },
takeoverName: { takeoverName: {
fontWeight: "700", fontWeight: "600",
color: theme.colors.text, color: theme.colors.text,
}, },
takeoverSub: { takeoverSub: {
color: theme.colors.textMuted,
fontSize: 12, fontSize: 12,
color: theme.colors.textMuted,
marginTop: 2,
}, },
buttonSmall: { buttonSmall: {
backgroundColor: theme.colors.primary, backgroundColor: theme.colors.secondary,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999, borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 10,
}, },
buttonSmallText: { buttonSmallText: {
color: theme.colors.primaryText, color: theme.colors.secondaryText,
fontWeight: "700", fontWeight: "600",
fontSize: 12,
}, },
}); });

View file

@ -28,10 +28,10 @@ function formatTransactionTimestamp(value: number) {
} }
function getTransactionLabel( function getTransactionLabel(
kind: Transaction["kind"], kind: string,
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: string = t("common.bank"); let counterparty = t("common.bank");
const timeLabel: string = formatTransactionTimestamp(transaction.createdAt); const timeLabel = formatTransactionTimestamp(transaction.createdAt);
if (transaction.kind === "banker_adjust") { if (transaction.kind === "banker_adjust") {
outgoing = transaction.amount < 0; outgoing = transaction.amount < 0;

View file

@ -31,10 +31,9 @@ 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(() => screenshotDraft?.amount ?? ""); const [amount, setAmount] = useState("");
const [note, setNote] = useState(() => screenshotDraft?.note ?? ""); const [note, setNote] = useState("");
const [errorText, setErrorText] = useState(""); const [errorText, setErrorText] = useState("");
const eligible = useMemo( const eligible = useMemo(
@ -51,18 +50,6 @@ 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,7 +64,6 @@ 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() {
@ -75,7 +74,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: activeThread.id === "global" ? null : activeThread.id, groupId: thread.id === "global" ? null : thread.id,
}); });
setMessage(""); setMessage("");
} }
@ -88,7 +87,7 @@ export default function ChatThreadScreen() {
keyboardVerticalOffset={keyboardOffset} keyboardVerticalOffset={keyboardOffset}
> >
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.headerTitle}>{activeThread.name}</Text> <Text style={styles.headerTitle}>{thread.name}</Text>
<Text style={styles.headerSubtitle}>{threadKindLabel}</Text> <Text style={styles.headerSubtitle}>{threadKindLabel}</Text>
</View> </View>

View file

@ -1,29 +0,0 @@
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

@ -1,40 +0,0 @@
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,23 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { 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";
@ -27,12 +13,6 @@ 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);
@ -56,276 +36,23 @@ 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] = const [connectionState, setConnectionState] = useState<
useState<SessionConnectionState>("idle"); "idle" | "connecting" | "open" | "error"
>("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 || screenshotRef.current) return; if (!mounted || !stored) return;
setSessionId(stored.sessionId); setSessionId(stored.sessionId);
setSessionCode(stored.sessionCode); setSessionCode(stored.sessionCode);
setPlayerId(stored.playerId); setPlayerId(stored.playerId);
@ -336,7 +63,6 @@ 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;
@ -345,100 +71,17 @@ export function useSessionManager() {
return () => { return () => {
mounted = false; mounted = false;
}; };
}, [screenshot]); }, []);
useEffect(() => { useEffect(() => {
if (screenshot) return; const timer = setInterval(() => setTick((value) => value + 1), 1000);
const subscription = AppState.addEventListener("change", (nextState) => { return () => clearInterval(timer);
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, screenshot, sessionId, playerId]); }, [pushToken, 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;
@ -461,6 +104,64 @@ 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,
@ -513,12 +214,7 @@ 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" },
@ -538,18 +234,10 @@ 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"));
@ -561,7 +249,6 @@ 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" },
@ -581,10 +268,6 @@ 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(
@ -621,10 +304,6 @@ 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`,
{ {
@ -660,42 +339,23 @@ 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) {
retryConnection(); setError(tStatic("error.connectionNotReady"));
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 {
@ -705,14 +365,20 @@ 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() {
suppressReconnectRef.current = true; const ws = wsRef.current;
teardownConnection(); wsRef.current = null;
if (ws && ws.readyState !== WebSocket.CLOSED) {
try {
ws.close();
} catch {
// Ignore failures while closing the socket.
}
}
await resetSession(); await resetSession();
} }
@ -726,10 +392,9 @@ export function useSessionManager() {
session, session,
me, me,
isBanker, isBanker,
tick,
error, error,
connectionState, connectionState,
reconnectAttempt,
lastActivityAt,
setError, setError,
createSession, createSession,
joinSession, joinSession,
@ -739,12 +404,9 @@ 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: "#f5efe6", background: "#f7f7f9",
surface: "#fffdf8", surface: "#ffffff",
surfaceAlt: "#efe4d5", surfaceAlt: "#f6f8fa",
text: "#162132", text: "#0b1a2b",
textMuted: "#6d6559", textMuted: "#6b7280",
border: "#d7c8b5", border: "#d8dee5",
borderMuted: "#e8decf", borderMuted: "#e2e8f0",
primary: "#162132", primary: "#1b8b75",
primaryText: "#fffaf2", primaryText: "#ffffff",
secondary: "#e7dac9", secondary: "#e7ecef",
secondaryText: "#162132", secondaryText: "#0c1824",
accent: "#b49053", accent: "#14b8a6",
accentText: "#241a08", accentText: "#042f2e",
accentSurface: "#f8eedf", accentSurface: "#ecfdf9",
danger: "#a13b2d", danger: "#b91c1c",
warningSurface: "#f5e8c8", warningSurface: "#fff6e5",
warningBorder: "#dec89a", warningBorder: "#fde7c1",
warningText: "#8b621d", warningText: "#b45309",
warningTextStrong: "#67470b", warningTextStrong: "#7c2d12",
brandSurface: "#162132", brandSurface: "#0b1a2b",
brandSurfaceAlt: "#23314a", brandSurfaceAlt: "#1f334d",
brandText: "#fff8ee", brandText: "#f8fafc",
brandTextMuted: "#cfbea0", brandTextMuted: "#9fb3c8",
brandAccent: "#b49053", brandAccent: "#14b8a6",
brandAccentText: "#241a08", brandAccentText: "#042f2e",
avatarSurface: "#23314a", avatarSurface: "#0f172a",
avatarText: "#fff8ee", avatarText: "#e2e8f0",
chipBackground: "#fffaf2", chipBackground: "#ffffff",
chipBorder: "#dfd0bd", chipBorder: "#e2e8f0",
chipText: "#162132", chipText: "#0f172a",
chipActiveBackground: "#162132", chipActiveBackground: "#0f172a",
chipActiveText: "#fff8ee", chipActiveText: "#f8fafc",
listAvatarBackground: "#ede1cf", listAvatarBackground: "#e6f6f2",
listAvatarText: "#7a6135", listAvatarText: "#1b8b75",
bubbleMe: "#efe3d2", bubbleMe: "#dff7ef",
inputBackground: "#fffaf2", inputBackground: "#ffffff",
inputText: "#162132", inputText: "#0b1a2b",
placeholder: "#9e907b", placeholder: "#9aa6b2",
tabActive: "#162132", tabActive: "#0f172a",
tabInactive: "#978b78", tabInactive: "#94a3b8",
headerBackground: "#fff8ee", headerBackground: "#ffffff",
headerText: "#162132", headerText: "#0b1a2b",
action: "#162132", action: "#0f172a",
actionText: "#fff8ee", actionText: "#f8fafc",
radioBorder: "#d4c1a0", radioBorder: "#cbd5f5",
}, },
}; };
const darkTheme: AppTheme = { const darkTheme: AppTheme = {
dark: true, dark: true,
colors: { colors: {
background: "#0e1420", background: "#0b0f14",
surface: "#141d2c", surface: "#111922",
surfaceAlt: "#1a2537", surfaceAlt: "#0f1620",
text: "#f8f2e7", text: "#f8fafc",
textMuted: "#b1a48e", textMuted: "#a7b4c5",
border: "#243248", border: "#1f2a37",
borderMuted: "#2d3c55", borderMuted: "#243244",
primary: "#f4ead9", primary: "#1fbf98",
primaryText: "#162132", primaryText: "#ffffff",
secondary: "#243248", secondary: "#1f2a37",
secondaryText: "#f8f2e7", secondaryText: "#e2e8f0",
accent: "#c5a56a", accent: "#2dd4bf",
accentText: "#251a08", accentText: "#04221b",
accentSurface: "#2c2417", accentSurface: "#0f2a24",
danger: "#ef8b7f", danger: "#f87171",
warningSurface: "#342712", warningSurface: "#2a1f0b",
warningBorder: "#715426", warningBorder: "#5f3b11",
warningText: "#e5b96a", warningText: "#f59e0b",
warningTextStrong: "#f3d598", warningTextStrong: "#fbbf24",
brandSurface: "#121a29", brandSurface: "#101a27",
brandSurfaceAlt: "#1a2740", brandSurfaceAlt: "#1b2b3f",
brandText: "#fff8ee", brandText: "#f8fafc",
brandTextMuted: "#cebda0", brandTextMuted: "#9fb3c8",
brandAccent: "#c5a56a", brandAccent: "#2dd4bf",
brandAccentText: "#251a08", brandAccentText: "#04221b",
avatarSurface: "#26344d", avatarSurface: "#1e293b",
avatarText: "#fff8ee", avatarText: "#e2e8f0",
chipBackground: "#141d2c", chipBackground: "#111922",
chipBorder: "#32425e", chipBorder: "#273244",
chipText: "#f8f2e7", chipText: "#e2e8f0",
chipActiveBackground: "#c5a56a", chipActiveBackground: "#2dd4bf",
chipActiveText: "#251a08", chipActiveText: "#04221b",
listAvatarBackground: "#2d2418", listAvatarBackground: "#0f2a24",
listAvatarText: "#e4c688", listAvatarText: "#5eead4",
bubbleMe: "#26311b", bubbleMe: "#103128",
inputBackground: "#101828", inputBackground: "#0f1620",
inputText: "#f8f2e7", inputText: "#f8fafc",
placeholder: "#7d8aa0", placeholder: "#7f90a6",
tabActive: "#f8f2e7", tabActive: "#e2e8f0",
tabInactive: "#7f8ca2", tabInactive: "#64748b",
headerBackground: "#121a29", headerBackground: "#111922",
headerText: "#f8f2e7", headerText: "#f8fafc",
action: "#f4ead9", action: "#e2e8f0",
actionText: "#162132", actionText: "#0b1a2b",
radioBorder: "#43526e", radioBorder: "#334155",
}, },
}; };

View file

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

View file

@ -1,102 +0,0 @@
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,24 +20,10 @@ 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 socketsByPlayer = new Map<string, Set<WebSocket>>(); const metaBySocket = new WeakMap<WebSocket, { sessionId: string; playerId: string }>();
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;
} }
@ -69,7 +55,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)];
const to = players[randomInt(0, players.length - 1)]; let to = players[randomInt(0, players.length - 1)];
if (to.id === from.id) { if (to.id === from.id) {
attempts += 1; attempts += 1;
continue; continue;
@ -113,54 +99,6 @@ 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) {
@ -172,15 +110,12 @@ 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 = meta.lastSeenAt; player.lastActiveAt = now();
metaBySocket.set(ws, { sessionId, playerId });
getSessionSockets(sessionId).add(ws);
sendStateToSession(session); sendStateToSession(session);
} }
@ -190,64 +125,32 @@ 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 (sessionSocketCount === 0 && session?.isTest) { if (set) {
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;
} }
staleSockets.push(socket); metaBySocket.delete(ws);
});
});
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 { } catch (error) {
send(ws, { type: "error", message: "Invalid message" }); send(ws, { type: "error", message: "Invalid message" });
return; return;
} }
@ -259,7 +162,7 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v
} }
try { try {
handleMessage(ws, session, parsed); handleMessage(session, parsed);
} catch (error) { } catch (error) {
const message = const message =
error instanceof DomainError error instanceof DomainError
@ -272,14 +175,15 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v
sendStateToSession(session); sendStateToSession(session);
} }
function handleMessage(ws: WebSocket, session: Session, message: ClientMessage): void { function handleMessage(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,
@ -343,7 +247,6 @@ function handleMessage(ws: WebSocket, session: Session, message: ClientMessage):
} }
case "ping": case "ping":
touchPlayer(session, message.playerId); touchPlayer(session, message.playerId);
send(ws, { type: "pong" });
return; return;
default: default:
return; return;
@ -392,13 +295,6 @@ 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);
} }
}); });
} }
@ -409,7 +305,3 @@ function send(ws: WebSocket, message: ServerMessage): void {
} }
ws.send(JSON.stringify(message)); ws.send(JSON.stringify(message));
} }
setInterval(() => {
reapStaleSockets();
}, STALE_SOCKET_REAP_INTERVAL_MS);