Rebranding de l'application mobile

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View file

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

View file

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

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 680 KiB

159
mobile/package-lock.json generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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