Compare commits
2 commits
62beda2bf7
...
d8121e74a4
| Author | SHA1 | Date | |
|---|---|---|---|
| d8121e74a4 | |||
| 813ffe2171 |
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
BIN
mobile/assets/AppIcon.icon/Assets/CréditMabligopTest1.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
mobile/assets/AppIcon.icon/Assets/LBTRDlogo.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
95
mobile/assets/AppIcon.icon/icon.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
mobile/assets/CréditMabligopIcon.svg
Normal 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 |
|
Before Width: | Height: | Size: 618 KiB After Width: | Height: | Size: 918 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 618 KiB After Width: | Height: | Size: 918 KiB |
|
Before Width: | Height: | Size: 618 KiB After Width: | Height: | Size: 680 KiB |
159
mobile/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
226
mobile/scripts/generate-screenshots.mjs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Linking } from "react-native";
|
import { Linking, StyleSheet, View } from "react-native";
|
||||||
import {
|
import {
|
||||||
|
CommonActions,
|
||||||
NavigationContainer,
|
NavigationContainer,
|
||||||
type LinkingOptions,
|
type LinkingOptions,
|
||||||
useNavigationContainerRef,
|
useNavigationContainerRef,
|
||||||
|
|
@ -9,36 +10,51 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
import AppNavigator from "./navigation/AppNavigator";
|
import AppNavigator from "./navigation/AppNavigator";
|
||||||
|
import { normalizeScreenshotScene } from "./dev/screenshot-fixtures";
|
||||||
import type { RootStackParamList } from "./navigation/types";
|
import type { RootStackParamList } from "./navigation/types";
|
||||||
import { SessionProvider, useSession } from "./state/session-context";
|
import { SessionProvider, useSession } from "./state/session-context";
|
||||||
import { getNavigationTheme, useTheme } from "./theme";
|
import { getNavigationTheme, useTheme } from "./theme";
|
||||||
import { parseNotificationTarget, type NotificationTarget } from "./notifications";
|
import { parseNotificationTarget, type NotificationTarget } from "./notifications";
|
||||||
|
import ConnectionBanner from "./components/ConnectionBanner";
|
||||||
|
|
||||||
function extractGameId(url: string): string | null {
|
function getUrlSegments(url: string): string[] {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
const path = parsed.pathname || "";
|
const pathSegments = (parsed.pathname || "")
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => segment.trim())
|
||||||
|
.filter(Boolean);
|
||||||
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
|
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
|
||||||
const match = path.match(/^\/play\/?([^/]+)?/);
|
return pathSegments;
|
||||||
const id = match?.[1]?.trim();
|
|
||||||
return id ? id : null;
|
|
||||||
}
|
}
|
||||||
if (parsed.host === "play") {
|
if (parsed.host) {
|
||||||
const id = path.replace(/^\//, "").trim();
|
return [parsed.host, ...pathSegments];
|
||||||
return id ? id : null;
|
|
||||||
}
|
}
|
||||||
const fallbackMatch = path.match(/^\/play\/?([^/]+)?/);
|
return pathSegments;
|
||||||
const fallbackId = fallbackMatch?.[1]?.trim();
|
|
||||||
return fallbackId ? fallbackId : null;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractGameId(url: string): string | null {
|
||||||
|
const segments = getUrlSegments(url);
|
||||||
|
if (segments[0] !== "play") return null;
|
||||||
|
return segments[1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractScreenshotScene(url: string) {
|
||||||
|
const segments = getUrlSegments(url);
|
||||||
|
if (segments[0] !== "screenshot") return null;
|
||||||
|
return normalizeScreenshotScene(segments[1]);
|
||||||
|
}
|
||||||
|
|
||||||
function logDeepLink(url: string) {
|
function logDeepLink(url: string) {
|
||||||
if (!__DEV__) return;
|
if (!__DEV__) return;
|
||||||
const gameId = extractGameId(url);
|
const gameId = extractGameId(url);
|
||||||
console.log(`[deep-link] url=${url} gameId=${gameId ?? "invalid"}`);
|
const scene = extractScreenshotScene(url);
|
||||||
|
console.log(
|
||||||
|
`[deep-link] url=${url} gameId=${gameId ?? "invalid"} screenshot=${scene ?? "none"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RootNavigationGate() {
|
function RootNavigationGate() {
|
||||||
|
|
@ -48,15 +64,26 @@ function RootNavigationGate() {
|
||||||
const lastTargetRef = useRef<keyof RootStackParamList | null>(null);
|
const lastTargetRef = useRef<keyof RootStackParamList | null>(null);
|
||||||
const lastLinkRef = useRef<string | null>(null);
|
const lastLinkRef = useRef<string | null>(null);
|
||||||
const pendingNotificationRef = useRef<NotificationTarget | null>(null);
|
const pendingNotificationRef = useRef<NotificationTarget | null>(null);
|
||||||
|
const pendingScreenshotRef = useRef(false);
|
||||||
const lastNotificationIdRef = useRef<string | null>(null);
|
const lastNotificationIdRef = useRef<string | null>(null);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const navigationTheme = getNavigationTheme(theme);
|
const navigationTheme = getNavigationTheme(theme);
|
||||||
|
const handleSpecialUrl = useCallback(
|
||||||
|
(url: string) => {
|
||||||
|
const scene = extractScreenshotScene(url);
|
||||||
|
if (!scene) return false;
|
||||||
|
pendingScreenshotRef.current = true;
|
||||||
|
manager.activateScreenshotScene(scene);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[manager.activateScreenshotScene],
|
||||||
|
);
|
||||||
const linking = useMemo<LinkingOptions<RootStackParamList>>(
|
const linking = useMemo<LinkingOptions<RootStackParamList>>(
|
||||||
() => ({
|
() => ({
|
||||||
prefixes: ["negopoly://", "https://negopoly.fr"],
|
prefixes: ["negopoly://", "https://negopoly.fr"],
|
||||||
config: {
|
config: {
|
||||||
screens: {
|
screens: {
|
||||||
Entry: "play/:gameId",
|
AgencyJoin: "play/:gameId",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
getInitialURL: async () => {
|
getInitialURL: async () => {
|
||||||
|
|
@ -64,6 +91,9 @@ function RootNavigationGate() {
|
||||||
if (url) {
|
if (url) {
|
||||||
lastLinkRef.current = url;
|
lastLinkRef.current = url;
|
||||||
logDeepLink(url);
|
logDeepLink(url);
|
||||||
|
if (handleSpecialUrl(url)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
},
|
},
|
||||||
|
|
@ -73,13 +103,14 @@ function RootNavigationGate() {
|
||||||
if (lastLinkRef.current === url) return;
|
if (lastLinkRef.current === url) return;
|
||||||
lastLinkRef.current = url;
|
lastLinkRef.current = url;
|
||||||
logDeepLink(url);
|
logDeepLink(url);
|
||||||
|
if (handleSpecialUrl(url)) return;
|
||||||
listener(url);
|
listener(url);
|
||||||
};
|
};
|
||||||
const subscription = Linking.addEventListener("url", onReceiveURL);
|
const subscription = Linking.addEventListener("url", onReceiveURL);
|
||||||
return () => subscription.remove();
|
return () => subscription.remove();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[],
|
[handleSpecialUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
const processPendingNotification = useCallback(() => {
|
const processPendingNotification = useCallback(() => {
|
||||||
|
|
@ -93,27 +124,52 @@ function RootNavigationGate() {
|
||||||
if (pending.type === "chat") {
|
if (pending.type === "chat") {
|
||||||
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
|
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
|
||||||
const targetTab = manager.isBanker ? "BankerChat" : "PlayerChat";
|
const targetTab = manager.isBanker ? "BankerChat" : "PlayerChat";
|
||||||
navigationRef.navigate(
|
navigationRef.dispatch(
|
||||||
targetStack as never,
|
CommonActions.navigate({
|
||||||
{
|
name: targetStack,
|
||||||
screen: targetTab,
|
|
||||||
params: {
|
params: {
|
||||||
screen: "ChatThread",
|
screen: targetTab,
|
||||||
params: { chatId: pending.chatId },
|
params: {
|
||||||
|
screen: "ChatThread",
|
||||||
|
params: { chatId: pending.chatId },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as never,
|
}),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
|
const targetStack = manager.isBanker ? "BankerTabs" : "PlayerTabs";
|
||||||
const targetTab = manager.isBanker ? "BankerDashboard" : "PlayerHome";
|
const targetTab = manager.isBanker ? "BankerDashboard" : "PlayerHome";
|
||||||
navigationRef.navigate(
|
navigationRef.dispatch(
|
||||||
targetStack as never,
|
CommonActions.navigate({
|
||||||
{ screen: targetTab } as never,
|
name: targetStack,
|
||||||
|
params: { screen: targetTab },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]);
|
}, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]);
|
||||||
|
|
||||||
|
const processPendingScreenshot = useCallback(() => {
|
||||||
|
if (!pendingScreenshotRef.current) return;
|
||||||
|
if (!navReady || !navigationRef.isReady()) return;
|
||||||
|
const target = manager.screenshot?.navigationTarget;
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
pendingScreenshotRef.current = false;
|
||||||
|
navigationRef.dispatch(
|
||||||
|
CommonActions.reset({
|
||||||
|
index: 0,
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
name: target.root,
|
||||||
|
params: target.params,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
lastTargetRef.current = target.root;
|
||||||
|
}, [manager.screenshot, navReady, navigationRef]);
|
||||||
|
|
||||||
const handleNotificationResponse = useCallback(
|
const handleNotificationResponse = useCallback(
|
||||||
(response: Notifications.NotificationResponse | null) => {
|
(response: Notifications.NotificationResponse | null) => {
|
||||||
if (!response) return;
|
if (!response) return;
|
||||||
|
|
@ -139,13 +195,27 @@ function RootNavigationGate() {
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (pendingScreenshotRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!navReady || !navigationRef.isReady()) return;
|
if (!navReady || !navigationRef.isReady()) return;
|
||||||
|
|
||||||
|
const currentRoute = navigationRef.getCurrentRoute();
|
||||||
|
const currentName = currentRoute?.name;
|
||||||
|
const inEntryFlow =
|
||||||
|
currentName === "EntryLanding" ||
|
||||||
|
currentName === "AgencyJoin" ||
|
||||||
|
currentName === "AgencyCreate";
|
||||||
|
|
||||||
let target: keyof RootStackParamList;
|
let target: keyof RootStackParamList;
|
||||||
if (!manager.sessionId) {
|
if (!manager.sessionId) {
|
||||||
target = "Entry";
|
if (inEntryFlow) {
|
||||||
|
lastTargetRef.current = currentName ?? null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target = "EntryLanding";
|
||||||
} else if (!manager.session) {
|
} else if (!manager.session) {
|
||||||
target = manager.connectionState === "error" ? "Entry" : "Lobby";
|
target = "Lobby";
|
||||||
} else if (manager.session.status === "lobby") {
|
} else if (manager.session.status === "lobby") {
|
||||||
target = "Lobby";
|
target = "Lobby";
|
||||||
} else if (manager.isBanker) {
|
} else if (manager.isBanker) {
|
||||||
|
|
@ -154,7 +224,6 @@ function RootNavigationGate() {
|
||||||
target = "PlayerTabs";
|
target = "PlayerTabs";
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentRoute = navigationRef.getCurrentRoute();
|
|
||||||
if (currentRoute?.name === target || lastTargetRef.current === target) {
|
if (currentRoute?.name === target || lastTargetRef.current === target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -173,6 +242,10 @@ function RootNavigationGate() {
|
||||||
navigationRef,
|
navigationRef,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
processPendingScreenshot();
|
||||||
|
}, [processPendingScreenshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
processPendingNotification();
|
processPendingNotification();
|
||||||
}, [processPendingNotification]);
|
}, [processPendingNotification]);
|
||||||
|
|
@ -195,14 +268,26 @@ function RootNavigationGate() {
|
||||||
}, [handleNotificationResponse]);
|
}, [handleNotificationResponse]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationContainer
|
<View style={styles.container}>
|
||||||
ref={navigationRef}
|
<NavigationContainer
|
||||||
onReady={() => setNavReady(true)}
|
ref={navigationRef}
|
||||||
linking={linking}
|
onReady={() => setNavReady(true)}
|
||||||
theme={navigationTheme}
|
linking={linking}
|
||||||
>
|
theme={navigationTheme}
|
||||||
<AppNavigator />
|
>
|
||||||
</NavigationContainer>
|
<AppNavigator />
|
||||||
|
<ConnectionBanner
|
||||||
|
visible={
|
||||||
|
Boolean(manager.sessionId) &&
|
||||||
|
manager.connectionState !== "idle" &&
|
||||||
|
manager.connectionState !== "open"
|
||||||
|
}
|
||||||
|
connectionState={manager.connectionState}
|
||||||
|
reconnectAttempt={manager.reconnectAttempt}
|
||||||
|
onRetry={manager.retryConnection}
|
||||||
|
/>
|
||||||
|
</NavigationContainer>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,3 +305,9 @@ export default function App() {
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
104
mobile/src/components/BrandLockup.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
43
mobile/src/components/BrandMark.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
mobile/src/components/ConnectionBanner.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useI18n } from "../i18n";
|
||||||
|
import { useTheme, type AppTheme } from "../theme";
|
||||||
|
import type { SessionConnectionState } from "../state/connection";
|
||||||
|
|
||||||
|
type ConnectionBannerProps = {
|
||||||
|
connectionState: SessionConnectionState;
|
||||||
|
reconnectAttempt: number;
|
||||||
|
visible: boolean;
|
||||||
|
onRetry: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ConnectionBanner({
|
||||||
|
connectionState,
|
||||||
|
reconnectAttempt,
|
||||||
|
visible,
|
||||||
|
onRetry,
|
||||||
|
}: ConnectionBannerProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const theme = useTheme();
|
||||||
|
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title =
|
||||||
|
connectionState === "connecting"
|
||||||
|
? t("connection.connecting")
|
||||||
|
: t("connection.reconnecting");
|
||||||
|
const detail = t("connection.reconnectingDetail", {
|
||||||
|
count: reconnectAttempt || 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
pointerEvents="box-none"
|
||||||
|
style={[styles.wrapper, { top: insets.top + 8 }]}
|
||||||
|
>
|
||||||
|
<View style={styles.banner}>
|
||||||
|
<View style={styles.copy}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
<Text style={styles.detail}>{detail}</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity style={styles.button} onPress={onRetry}>
|
||||||
|
<Text style={styles.buttonText}>{t("common.retryNow")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createStyles = (theme: AppTheme) =>
|
||||||
|
StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
zIndex: 20,
|
||||||
|
},
|
||||||
|
banner: {
|
||||||
|
borderRadius: 16,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
backgroundColor: theme.colors.headerBackground,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 10,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
copy: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: theme.colors.text,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
detail: {
|
||||||
|
color: theme.colors.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: theme.colors.primaryText,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
344
mobile/src/dev/screenshot-fixtures.ts
Normal 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" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,21 @@
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
|
import type { TransactionKind } from "./shared/types";
|
||||||
|
|
||||||
type Locale = "en" | "fr";
|
type Locale = "en" | "fr";
|
||||||
|
|
||||||
const translations = {
|
const translations = {
|
||||||
en: {
|
en: {
|
||||||
"app.name": "Negopoly Companion",
|
"app.name": "Crédit Mabligop",
|
||||||
"common.loading": "Loading...",
|
"common.loading": "Loading...",
|
||||||
"common.loadingChats": "Loading chats...",
|
"common.loadingChats": "Loading messages...",
|
||||||
"common.loadingChat": "Loading chat...",
|
"common.loadingChat": "Loading conversation...",
|
||||||
"common.loadingLobby": "Joining lobby...",
|
"common.loadingLobby": "Entering agency setup...",
|
||||||
"common.notice": "Notice:",
|
"common.notice": "Notice:",
|
||||||
"common.online": "online",
|
"common.online": "online",
|
||||||
"common.offline": "offline",
|
"common.offline": "offline",
|
||||||
"common.dummy": "Dummy",
|
"common.dummy": "Assisted customer",
|
||||||
"common.player": "Player",
|
"common.player": "Customer",
|
||||||
"common.banker": "Banker",
|
"common.banker": "Advisor",
|
||||||
"common.bank": "Bank",
|
"common.bank": "Bank",
|
||||||
"common.from": "From",
|
"common.from": "From",
|
||||||
"common.to": "To",
|
"common.to": "To",
|
||||||
|
|
@ -22,6 +23,8 @@ const translations = {
|
||||||
"common.you": "You",
|
"common.you": "You",
|
||||||
"common.join": "Join",
|
"common.join": "Join",
|
||||||
"common.continue": "Continue",
|
"common.continue": "Continue",
|
||||||
|
"common.retryNow": "Retry now",
|
||||||
|
"common.transactions": "Transactions",
|
||||||
"common.send": "Send",
|
"common.send": "Send",
|
||||||
"common.reset": "Reset",
|
"common.reset": "Reset",
|
||||||
"common.cancel": "Cancel",
|
"common.cancel": "Cancel",
|
||||||
|
|
@ -30,100 +33,149 @@ const translations = {
|
||||||
"common.save": "Save",
|
"common.save": "Save",
|
||||||
"common.load": "Load",
|
"common.load": "Load",
|
||||||
"common.noReason": "No reason provided",
|
"common.noReason": "No reason provided",
|
||||||
"entry.subtitle": "Create or join a session.",
|
"entry.subtitle": "Open or access an agency.",
|
||||||
"entry.joinTitle": "Join a session",
|
"entry.heroBadge": "Mobile banking companion",
|
||||||
"entry.sessionCode": "Session code",
|
"entry.landingTitle": "Access your agency.",
|
||||||
"entry.newPlayer": "New player",
|
"entry.landingBody":
|
||||||
"entry.playerName": "Player name",
|
"Use your agency code to sign in, or open a new agency if you are the advisor in charge.",
|
||||||
"entry.takeoverTitle": "Take over dummy",
|
"entry.landingFooter": "Real-time balances, transfers, and approvals for Crédit Mabligop agencies.",
|
||||||
"entry.alreadyConnected": "You are already connected.",
|
"entry.metricRealtimeValue": "Live",
|
||||||
|
"entry.metricRealtimeLabel": "Transfers and balances stay synchronized.",
|
||||||
|
"entry.metricSupervisedValue": "Advisor-led",
|
||||||
|
"entry.metricSupervisedLabel": "Agencies stay under advisor control.",
|
||||||
|
"entry.primaryEyebrow": "Customer access",
|
||||||
|
"entry.secondaryEyebrow": "Advisor access",
|
||||||
|
"entry.accessAgency": "Access an agency",
|
||||||
|
"entry.accessAgencyHint":
|
||||||
|
"Enter your agency code, identify yourself, and recover an existing profile if needed.",
|
||||||
|
"entry.openAgency": "Open an agency",
|
||||||
|
"entry.openAgencyHint":
|
||||||
|
"Launch a supervised agency, invite customers, and approve assisted-profile recovery.",
|
||||||
|
"entry.joinStepTitle": "Identify your agency.",
|
||||||
|
"entry.joinStepSubtitle":
|
||||||
|
"Enter the agency code shared by your advisor to view the available customer access options.",
|
||||||
|
"entry.createStepTitle": "Open a new agency.",
|
||||||
|
"entry.createStepSubtitle":
|
||||||
|
"Create an advisor-controlled space for balances, transfers, approvals, and assisted customers.",
|
||||||
|
"entry.joinTitle": "Agency access",
|
||||||
|
"entry.joinDescription": "Use your agency code to retrieve available customer profiles.",
|
||||||
|
"entry.sessionCode": "Agency code",
|
||||||
|
"entry.previewLabel": "Agency found",
|
||||||
|
"entry.agencyCodeValue": "Agency code: {code}",
|
||||||
|
"entry.previewCustomers": "{count} customer profiles currently registered.",
|
||||||
|
"entry.newPlayer": "Join as a new customer",
|
||||||
|
"entry.newCustomerDescription":
|
||||||
|
"Create a fresh customer profile and enter the agency immediately.",
|
||||||
|
"entry.joinAsCustomer": "Join agency",
|
||||||
|
"entry.playerName": "Customer name",
|
||||||
|
"entry.takeoverTitle": "Recover an existing profile",
|
||||||
|
"entry.recoverCustomerDescription":
|
||||||
|
"Request access to an assisted customer profile. An advisor must approve the recovery.",
|
||||||
|
"entry.alreadyConnected": "This customer profile is already connected.",
|
||||||
"entry.dummyId": "Dummy ID (select later)",
|
"entry.dummyId": "Dummy ID (select later)",
|
||||||
"entry.selectDummy": "Select a dummy",
|
"entry.selectDummy": "Select an assisted customer",
|
||||||
"entry.yourNameOptional": "Your name (optional)",
|
"entry.yourNameOptional": "Your name (optional)",
|
||||||
"entry.requestTakeover": "Request takeover",
|
"entry.requestTakeover": "Request advisor approval",
|
||||||
"entry.noDummies": "No dummies available yet.",
|
"entry.noDummies": "No assisted customer profiles are available yet.",
|
||||||
"entry.takeoverPending": "Waiting for the banker to approve your takeover.",
|
"entry.takeoverPending": "Waiting for an advisor to approve your recovery request.",
|
||||||
"entry.createTitle": "Create a session",
|
"entry.createTitle": "Advisor profile",
|
||||||
"entry.bankerName": "Banker name",
|
"entry.createDescription":
|
||||||
"entry.openVault": "Open the vault",
|
"The first profile created becomes the advisor supervising this agency.",
|
||||||
"entry.alert.enterCode": "Enter a session code",
|
"entry.bankerName": "Advisor name",
|
||||||
"entry.alert.sessionNotFound": "Session not found",
|
"entry.advisorName": "Advisor name",
|
||||||
"entry.alert.selectDummy": "Select a dummy player",
|
"entry.openVault": "Open agency",
|
||||||
|
"entry.linkAccessExisting": "Access an existing agency instead",
|
||||||
|
"entry.linkOpenNew": "Open a new agency instead",
|
||||||
|
"entry.alert.enterCode": "Enter an agency code",
|
||||||
|
"entry.alert.enterAdvisorName": "Enter an advisor name",
|
||||||
|
"entry.alert.sessionNotFound": "Agency not found",
|
||||||
|
"entry.alert.selectDummy": "Select an assisted customer",
|
||||||
"entry.alert.takeoverFailed": "Unable to request takeover. Please try again.",
|
"entry.alert.takeoverFailed": "Unable to request takeover. Please try again.",
|
||||||
"lobby.title": "Lobby",
|
"lobby.title": "Agency setup",
|
||||||
"lobby.code": "Code: {code}",
|
"lobby.code": "Agency code: {code}",
|
||||||
"lobby.startGame": "Start game",
|
"lobby.startGame": "Open agency",
|
||||||
"lobby.addDummyTitle": "Add dummy player",
|
"lobby.addDummyTitle": "Add an assisted customer",
|
||||||
"lobby.addDummySubtitle": "Create a player for someone without the app.",
|
"lobby.addDummySubtitle":
|
||||||
"lobby.enterDummyName": "Enter a dummy name",
|
"Create a supervised customer profile for someone without the mobile app.",
|
||||||
"lobby.addDummyButton": "Add dummy",
|
"lobby.enterDummyName": "Enter an assisted customer name",
|
||||||
"session.exit": "Exit game",
|
"lobby.addDummyButton": "Create assisted customer",
|
||||||
"session.exitPrompt": "Leave this game?",
|
"lobby.heroAdvisor":
|
||||||
"session.exitMessage": "You can rejoin later with the session code.",
|
"Finalize customer access, review recoveries, and open the agency when everything is ready.",
|
||||||
|
"lobby.heroCustomer":
|
||||||
|
"Your advisor is preparing the agency. You will enter as soon as the agency opens.",
|
||||||
|
"lobby.customers": "Customers",
|
||||||
|
"lobby.assisted": "Assisted",
|
||||||
|
"lobby.rosterTitle": "Agency roster",
|
||||||
|
"lobby.waitingTitle": "Waiting for agency opening",
|
||||||
|
"lobby.waitingBody":
|
||||||
|
"Transfers, balances, and conversations unlock once the advisor opens the agency.",
|
||||||
|
"session.exit": "Leave agency",
|
||||||
|
"session.exitPrompt": "Leave this agency?",
|
||||||
|
"session.exitMessage": "You can access it again later with the agency code.",
|
||||||
"transfers.title": "Make a transfer",
|
"transfers.title": "Make a transfer",
|
||||||
"transfers.subtitle": "Move funds instantly between players.",
|
"transfers.subtitle": "Move funds instantly between customers.",
|
||||||
"transfers.from": "From",
|
"transfers.from": "From",
|
||||||
"transfers.to": "To",
|
"transfers.to": "To",
|
||||||
"transfers.availableBalance": "Available balance",
|
"transfers.availableBalance": "Available balance",
|
||||||
"transfers.noPlayers": "No other players available yet.",
|
"transfers.noPlayers": "No other customers are available yet.",
|
||||||
"transfers.dummy": "Dummy player",
|
"transfers.dummy": "Assisted customer",
|
||||||
"transfers.player": "Player",
|
"transfers.player": "Customer",
|
||||||
"transfers.amount": "Amount",
|
"transfers.amount": "Amount",
|
||||||
"transfers.note": "Note",
|
"transfers.note": "Note",
|
||||||
"transfers.notePlaceholder": "What is this for?",
|
"transfers.notePlaceholder": "What is this for?",
|
||||||
"transfers.sending": "Sending",
|
"transfers.sending": "Sending",
|
||||||
"transfers.summary": "₦{amount} to {name}",
|
"transfers.summary": "₦{amount} to {name}",
|
||||||
"transfers.selectPlayer": "Select a player",
|
"transfers.selectPlayer": "Select a customer",
|
||||||
"transfers.send": "Send transfer",
|
"transfers.send": "Send transfer",
|
||||||
"transfers.error": "Choose a player and a valid amount.",
|
"transfers.error": "Choose a customer and a valid amount.",
|
||||||
"home.balance": "Balance",
|
"home.balance": "Balance",
|
||||||
"home.recent": "Recent activity",
|
"home.recent": "Recent operations",
|
||||||
"home.noActivity": "No activity yet.",
|
"home.noActivity": "No activity yet.",
|
||||||
"blackout.title": "EMP",
|
"blackout.title": "EMP",
|
||||||
"blackout.defaultReason": "EMP in effect",
|
"blackout.defaultReason": "EMP in effect",
|
||||||
"blackout.active": "EMP active",
|
"blackout.active": "EMP active",
|
||||||
"banker.dashboard.title": "Session activity",
|
"banker.dashboard.title": "Agency activity",
|
||||||
"banker.tools.title": "Banker tools",
|
"banker.tools.title": "Advisor controls",
|
||||||
"banker.tools.playersTab": "Players",
|
"banker.tools.playersTab": "Customers",
|
||||||
"banker.tools.adminTab": "Admin",
|
"banker.tools.adminTab": "Agency",
|
||||||
"banker.tools.playerOverview": "Player overview",
|
"banker.tools.playerOverview": "Customer overview",
|
||||||
"banker.tools.noPlayers": "No players yet.",
|
"banker.tools.noPlayers": "No customers yet.",
|
||||||
"banker.tools.adjust": "Adjust balance",
|
"banker.tools.adjust": "Adjust balance",
|
||||||
"banker.tools.apply": "Apply",
|
"banker.tools.apply": "Apply",
|
||||||
"banker.tools.forceTransfer": "Force transfer",
|
"banker.tools.forceTransfer": "Force transfer",
|
||||||
"banker.tools.force": "Force",
|
"banker.tools.force": "Force",
|
||||||
"banker.tools.createDummy": "Create dummy",
|
"banker.tools.createDummy": "Create assisted customer",
|
||||||
"banker.tools.addDummy": "Add dummy",
|
"banker.tools.addDummy": "Add assisted customer",
|
||||||
"banker.tools.blackout": "EMP",
|
"banker.tools.blackout": "Blackout",
|
||||||
"banker.tools.blackoutActive": "EMP active",
|
"banker.tools.blackoutActive": "Blackout active",
|
||||||
"banker.tools.blackoutReason": "EMP reason",
|
"banker.tools.blackoutReason": "Blackout reason",
|
||||||
"banker.tools.blackoutEnable": "Enable EMP",
|
"banker.tools.blackoutEnable": "Enable blackout",
|
||||||
"banker.tools.blackoutDisable": "Disable EMP",
|
"banker.tools.blackoutDisable": "Disable blackout",
|
||||||
"banker.tools.trigger": "Trigger",
|
"banker.tools.trigger": "Trigger",
|
||||||
"banker.tools.endSession": "End session",
|
"banker.tools.endSession": "Close agency",
|
||||||
"banker.tools.playerId": "Player ID",
|
"banker.tools.playerId": "Customer ID",
|
||||||
"banker.tools.amountAdjust": "Amount (+/-)",
|
"banker.tools.amountAdjust": "Amount (+/-)",
|
||||||
"banker.tools.reason": "Reason",
|
"banker.tools.reason": "Reason",
|
||||||
"banker.tools.fromPlayer": "From player ID",
|
"banker.tools.fromPlayer": "From customer ID",
|
||||||
"banker.tools.toPlayer": "To player ID",
|
"banker.tools.toPlayer": "To customer ID",
|
||||||
"banker.tools.amount": "Amount",
|
"banker.tools.amount": "Amount",
|
||||||
"banker.tools.note": "Note",
|
"banker.tools.note": "Note",
|
||||||
"banker.tools.dummyName": "Dummy name",
|
"banker.tools.dummyName": "Assisted customer name",
|
||||||
"banker.tools.startingBalance": "Starting balance",
|
"banker.tools.startingBalance": "Starting balance",
|
||||||
"banker.takeoverApprovals": "Takeover approvals",
|
"banker.takeoverApprovals": "Recovery approvals",
|
||||||
"banker.wants": "wants {name}",
|
"banker.wants": "requests {name}",
|
||||||
"banker.approve": "Approve",
|
"banker.approve": "Approve",
|
||||||
"banker.stateTitle": "GameState",
|
"banker.stateTitle": "Agency state",
|
||||||
"banker.stateSubtitle": "Export or restore the current session.",
|
"banker.stateSubtitle": "Export or restore the current agency.",
|
||||||
"banker.downloadState": "Export GameState",
|
"banker.downloadState": "Export agency state",
|
||||||
"banker.loadFromFile": "Load GameState",
|
"banker.loadFromFile": "Load agency state",
|
||||||
"banker.importPlaceholder": "Paste GameState JSON here",
|
"banker.importPlaceholder": "Paste agency state JSON here",
|
||||||
"banker.loadFromStorage": "Load from saved snapshots",
|
"banker.loadFromStorage": "Load from saved snapshots",
|
||||||
"banker.stateDownloaded": "GameState exported.",
|
"banker.stateDownloaded": "Agency state exported.",
|
||||||
"banker.stateDownloadError": "Unable to export GameState.",
|
"banker.stateDownloadError": "Unable to export agency state.",
|
||||||
"banker.stateLoaded": "GameState loaded.",
|
"banker.stateLoaded": "Agency state loaded.",
|
||||||
"banker.stateLoadError": "Unable to load GameState.",
|
"banker.stateLoadError": "Unable to load agency state.",
|
||||||
"banker.stateLoadInvalid": "Invalid GameState JSON.",
|
"banker.stateLoadInvalid": "Invalid agency state JSON.",
|
||||||
"banker.autosaveTitle": "AutoSave",
|
"banker.autosaveTitle": "AutoSave",
|
||||||
"banker.autosaveSubtitle": "Save rolling snapshots on this device.",
|
"banker.autosaveSubtitle": "Save rolling snapshots on this device.",
|
||||||
"banker.autosaveEnabled": "Enable AutoSave",
|
"banker.autosaveEnabled": "Enable AutoSave",
|
||||||
|
|
@ -134,43 +186,47 @@ const translations = {
|
||||||
"banker.autosaveFailed": "AutoSave failed.",
|
"banker.autosaveFailed": "AutoSave failed.",
|
||||||
"banker.noAutosaves": "No autosaves yet.",
|
"banker.noAutosaves": "No autosaves yet.",
|
||||||
"banker.savedAt": "Saved {time}",
|
"banker.savedAt": "Saved {time}",
|
||||||
"chat.title": "Chats",
|
"chat.title": "Messages",
|
||||||
"chat.noMessages": "No messages yet",
|
"chat.noMessages": "No messages yet",
|
||||||
"chat.global": "Global chat",
|
"chat.global": "Agency channel",
|
||||||
"chat.newTitle": "New chat",
|
"chat.newTitle": "New conversation",
|
||||||
"chat.direct": "Direct",
|
"chat.direct": "Direct",
|
||||||
"chat.group": "Group",
|
"chat.group": "Group",
|
||||||
"chat.groupName": "Group name",
|
"chat.groupName": "Group name",
|
||||||
"chat.choosePlayers": "Choose players",
|
"chat.choosePlayers": "Choose customers",
|
||||||
"chat.startChat": "Start chat",
|
"chat.startChat": "Start conversation",
|
||||||
"chat.notFound": "Chat not found.",
|
"chat.notFound": "Chat not found.",
|
||||||
"chat.messagePlaceholder": "Message",
|
"chat.messagePlaceholder": "Message",
|
||||||
"tabs.home": "Home",
|
"tabs.home": "Accounts",
|
||||||
"tabs.transfers": "Transfers",
|
"tabs.transfers": "Payments",
|
||||||
"tabs.chat": "Chat",
|
"tabs.chat": "Messages",
|
||||||
"tabs.dashboard": "Dashboard",
|
"tabs.dashboard": "Agency",
|
||||||
"tabs.tools": "Tools",
|
"tabs.tools": "Control",
|
||||||
"transaction.transfer": "Transfer",
|
"transaction.transfer": "Transfer",
|
||||||
"transaction.banker_adjust": "Banker adjustment",
|
"transaction.banker_adjust": "Advisor adjustment",
|
||||||
"transaction.banker_force_transfer": "Forced transfer",
|
"transaction.banker_force_transfer": "Advisor transfer",
|
||||||
|
"connection.connecting": "Connecting to your agency",
|
||||||
|
"connection.reconnecting": "Reconnecting to your agency",
|
||||||
|
"connection.reconnectingDetail": "Attempt {count}. Live updates will resume automatically.",
|
||||||
"error.parseResponse": "Unable to parse server response",
|
"error.parseResponse": "Unable to parse server response",
|
||||||
"error.createSession": "Unable to create session",
|
"error.createSession": "Unable to open agency",
|
||||||
"error.joinSession": "Unable to join session",
|
"error.joinSession": "Unable to access agency",
|
||||||
"error.loadSessionInfo": "Unable to load session info",
|
"error.loadSessionInfo": "Unable to load agency info",
|
||||||
"error.connectionNotReady": "Connection not ready",
|
"error.connectionNotReady": "Connection not ready",
|
||||||
|
"error.reconnecting": "Reconnecting to the agency. Please try again in a moment.",
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
"app.name": "Negopoly Companion",
|
"app.name": "Crédit Mabligop",
|
||||||
"common.loading": "Chargement...",
|
"common.loading": "Chargement...",
|
||||||
"common.loadingChats": "Chargement des chats...",
|
"common.loadingChats": "Chargement des messages...",
|
||||||
"common.loadingChat": "Chargement du chat...",
|
"common.loadingChat": "Chargement de la conversation...",
|
||||||
"common.loadingLobby": "Connexion au lobby...",
|
"common.loadingLobby": "Accès à la mise en place de l'agence...",
|
||||||
"common.notice": "Info :",
|
"common.notice": "Info :",
|
||||||
"common.online": "en ligne",
|
"common.online": "en ligne",
|
||||||
"common.offline": "hors ligne",
|
"common.offline": "hors ligne",
|
||||||
"common.dummy": "Dummy",
|
"common.dummy": "Client assisté",
|
||||||
"common.player": "Joueur",
|
"common.player": "Client",
|
||||||
"common.banker": "Banquier",
|
"common.banker": "Conseiller",
|
||||||
"common.bank": "Banque",
|
"common.bank": "Banque",
|
||||||
"common.from": "De",
|
"common.from": "De",
|
||||||
"common.to": "Vers",
|
"common.to": "Vers",
|
||||||
|
|
@ -178,6 +234,8 @@ const translations = {
|
||||||
"common.you": "Vous",
|
"common.you": "Vous",
|
||||||
"common.join": "Rejoindre",
|
"common.join": "Rejoindre",
|
||||||
"common.continue": "Continuer",
|
"common.continue": "Continuer",
|
||||||
|
"common.retryNow": "Réessayer",
|
||||||
|
"common.transactions": "Transactions",
|
||||||
"common.send": "Envoyer",
|
"common.send": "Envoyer",
|
||||||
"common.reset": "Réinitialiser",
|
"common.reset": "Réinitialiser",
|
||||||
"common.cancel": "Annuler",
|
"common.cancel": "Annuler",
|
||||||
|
|
@ -186,100 +244,150 @@ const translations = {
|
||||||
"common.save": "Enregistrer",
|
"common.save": "Enregistrer",
|
||||||
"common.load": "Charger",
|
"common.load": "Charger",
|
||||||
"common.noReason": "Aucune raison fournie",
|
"common.noReason": "Aucune raison fournie",
|
||||||
"entry.subtitle": "Créez ou rejoignez une session.",
|
"entry.subtitle": "Ouvrez ou accédez à une agence.",
|
||||||
"entry.joinTitle": "Rejoindre une session",
|
"entry.heroBadge": "Compagnon bancaire mobile",
|
||||||
"entry.sessionCode": "Code de session",
|
"entry.landingTitle": "Accédez à votre agence.",
|
||||||
"entry.newPlayer": "Nouveau joueur",
|
"entry.landingBody":
|
||||||
"entry.playerName": "Nom du joueur",
|
"Utilisez votre code agence pour vous identifier, ou ouvrez une nouvelle agence si vous êtes le conseiller responsable.",
|
||||||
"entry.takeoverTitle": "Reprendre un dummy",
|
"entry.landingFooter":
|
||||||
"entry.alreadyConnected": "Vous êtes déjà connecté.",
|
"Soldes, virements et validations en temps réel pour les agences Crédit Mabligop.",
|
||||||
|
"entry.metricRealtimeValue": "Temps réel",
|
||||||
|
"entry.metricRealtimeLabel": "Transferts et soldes restent synchronisés.",
|
||||||
|
"entry.metricSupervisedValue": "Supervisée",
|
||||||
|
"entry.metricSupervisedLabel": "Chaque agence reste sous contrôle conseiller.",
|
||||||
|
"entry.primaryEyebrow": "Accès client",
|
||||||
|
"entry.secondaryEyebrow": "Accès conseiller",
|
||||||
|
"entry.accessAgency": "Accéder à une agence",
|
||||||
|
"entry.accessAgencyHint":
|
||||||
|
"Saisissez votre code agence, identifiez-vous, puis récupérez un profil existant si besoin.",
|
||||||
|
"entry.openAgency": "Ouvrir une agence",
|
||||||
|
"entry.openAgencyHint":
|
||||||
|
"Lancez une agence supervisée, invitez des clients et validez les récupérations de profils assistés.",
|
||||||
|
"entry.joinStepTitle": "Identifiez votre agence.",
|
||||||
|
"entry.joinStepSubtitle":
|
||||||
|
"Saisissez le code transmis par votre conseiller pour afficher les options d'accès client disponibles.",
|
||||||
|
"entry.createStepTitle": "Ouvrez une nouvelle agence.",
|
||||||
|
"entry.createStepSubtitle":
|
||||||
|
"Créez un espace piloté par un conseiller pour les soldes, virements, validations et clients assistés.",
|
||||||
|
"entry.joinTitle": "Accès agence",
|
||||||
|
"entry.joinDescription": "Utilisez votre code agence pour retrouver les profils clients disponibles.",
|
||||||
|
"entry.sessionCode": "Code agence",
|
||||||
|
"entry.previewLabel": "Agence trouvée",
|
||||||
|
"entry.agencyCodeValue": "Code agence : {code}",
|
||||||
|
"entry.previewCustomers": "{count} profils clients actuellement enregistrés.",
|
||||||
|
"entry.newPlayer": "Rejoindre comme nouveau client",
|
||||||
|
"entry.newCustomerDescription":
|
||||||
|
"Créez un nouveau profil client et entrez immédiatement dans l'agence.",
|
||||||
|
"entry.joinAsCustomer": "Rejoindre l'agence",
|
||||||
|
"entry.playerName": "Nom du client",
|
||||||
|
"entry.takeoverTitle": "Récupérer un profil existant",
|
||||||
|
"entry.recoverCustomerDescription":
|
||||||
|
"Demandez l'accès à un profil client assisté. Un conseiller doit valider la récupération.",
|
||||||
|
"entry.alreadyConnected": "Ce profil client est déjà connecté.",
|
||||||
"entry.dummyId": "ID du dummy (plus tard)",
|
"entry.dummyId": "ID du dummy (plus tard)",
|
||||||
"entry.selectDummy": "Sélectionnez un dummy",
|
"entry.selectDummy": "Sélectionnez un client assisté",
|
||||||
"entry.yourNameOptional": "Votre nom (optionnel)",
|
"entry.yourNameOptional": "Votre nom (optionnel)",
|
||||||
"entry.requestTakeover": "Demander la reprise",
|
"entry.requestTakeover": "Demander l'accord du conseiller",
|
||||||
"entry.noDummies": "Aucun dummy disponible pour le moment.",
|
"entry.noDummies": "Aucun profil client assisté n'est disponible pour le moment.",
|
||||||
"entry.takeoverPending": "En attente de l'approbation du banquier.",
|
"entry.takeoverPending": "En attente de la validation du conseiller.",
|
||||||
"entry.createTitle": "Créer une session",
|
"entry.createTitle": "Profil conseiller",
|
||||||
"entry.bankerName": "Nom du banquier",
|
"entry.createDescription":
|
||||||
"entry.openVault": "Ouvrir le coffre",
|
"Le premier profil créé devient le conseiller qui supervise cette agence.",
|
||||||
"entry.alert.enterCode": "Entrez un code de session",
|
"entry.bankerName": "Nom du conseiller",
|
||||||
"entry.alert.sessionNotFound": "Session introuvable",
|
"entry.advisorName": "Nom du conseiller",
|
||||||
"entry.alert.selectDummy": "Sélectionnez un dummy",
|
"entry.openVault": "Ouvrir l'agence",
|
||||||
|
"entry.linkAccessExisting": "Accéder plutôt à une agence existante",
|
||||||
|
"entry.linkOpenNew": "Ouvrir plutôt une nouvelle agence",
|
||||||
|
"entry.alert.enterCode": "Entrez un code agence",
|
||||||
|
"entry.alert.enterAdvisorName": "Entrez un nom de conseiller",
|
||||||
|
"entry.alert.sessionNotFound": "Agence introuvable",
|
||||||
|
"entry.alert.selectDummy": "Sélectionnez un client assisté",
|
||||||
"entry.alert.takeoverFailed": "Impossible de demander la reprise. Réessayez.",
|
"entry.alert.takeoverFailed": "Impossible de demander la reprise. Réessayez.",
|
||||||
"lobby.title": "Lobby",
|
"lobby.title": "Mise en place de l'agence",
|
||||||
"lobby.code": "Code : {code}",
|
"lobby.code": "Code agence : {code}",
|
||||||
"lobby.startGame": "Démarrer la partie",
|
"lobby.startGame": "Ouvrir l'agence",
|
||||||
"lobby.addDummyTitle": "Ajouter un dummy",
|
"lobby.addDummyTitle": "Ajouter un client assisté",
|
||||||
"lobby.addDummySubtitle": "Créez un joueur pour quelqu'un sans l'application.",
|
"lobby.addDummySubtitle":
|
||||||
"lobby.enterDummyName": "Entrez un nom de dummy",
|
"Créez un profil client supervisé pour quelqu'un sans l'application mobile.",
|
||||||
"lobby.addDummyButton": "Ajouter un dummy",
|
"lobby.enterDummyName": "Entrez un nom de client assisté",
|
||||||
"session.exit": "Quitter la partie",
|
"lobby.addDummyButton": "Créer le client assisté",
|
||||||
"session.exitPrompt": "Quitter cette session ?",
|
"lobby.heroAdvisor":
|
||||||
"session.exitMessage": "Vous pourrez rejoindre plus tard avec le code.",
|
"Finalisez les accès clients, examinez les récupérations et ouvrez l'agence quand tout est prêt.",
|
||||||
"transfers.title": "Faire un transfert",
|
"lobby.heroCustomer":
|
||||||
"transfers.subtitle": "Transférez des fonds instantanément entre joueurs.",
|
"Votre conseiller prépare l'agence. Vous y entrerez dès son ouverture.",
|
||||||
|
"lobby.customers": "Clients",
|
||||||
|
"lobby.assisted": "Assistés",
|
||||||
|
"lobby.rosterTitle": "Registre de l'agence",
|
||||||
|
"lobby.waitingTitle": "En attente de l'ouverture de l'agence",
|
||||||
|
"lobby.waitingBody":
|
||||||
|
"Virements, soldes et conversations seront disponibles dès que le conseiller ouvrira l'agence.",
|
||||||
|
"session.exit": "Quitter l'agence",
|
||||||
|
"session.exitPrompt": "Quitter cette agence ?",
|
||||||
|
"session.exitMessage": "Vous pourrez y accéder plus tard avec le code agence.",
|
||||||
|
"transfers.title": "Effectuer un virement",
|
||||||
|
"transfers.subtitle": "Transférez des fonds instantanément entre clients.",
|
||||||
"transfers.from": "De",
|
"transfers.from": "De",
|
||||||
"transfers.to": "Vers",
|
"transfers.to": "Vers",
|
||||||
"transfers.availableBalance": "Solde disponible",
|
"transfers.availableBalance": "Solde disponible",
|
||||||
"transfers.noPlayers": "Aucun autre joueur disponible.",
|
"transfers.noPlayers": "Aucun autre client disponible pour le moment.",
|
||||||
"transfers.dummy": "Dummy",
|
"transfers.dummy": "Client assisté",
|
||||||
"transfers.player": "Joueur",
|
"transfers.player": "Client",
|
||||||
"transfers.amount": "Montant",
|
"transfers.amount": "Montant",
|
||||||
"transfers.note": "Note",
|
"transfers.note": "Note",
|
||||||
"transfers.notePlaceholder": "Pour quoi ?",
|
"transfers.notePlaceholder": "Pour quoi ?",
|
||||||
"transfers.sending": "Envoi",
|
"transfers.sending": "Envoi",
|
||||||
"transfers.summary": "₦{amount} à {name}",
|
"transfers.summary": "₦{amount} à {name}",
|
||||||
"transfers.selectPlayer": "Choisissez un joueur",
|
"transfers.selectPlayer": "Choisissez un client",
|
||||||
"transfers.send": "Envoyer le transfert",
|
"transfers.send": "Envoyer le virement",
|
||||||
"transfers.error": "Choisissez un joueur et un montant valide.",
|
"transfers.error": "Choisissez un client et un montant valide.",
|
||||||
"home.balance": "Solde",
|
"home.balance": "Solde",
|
||||||
"home.recent": "Activité récente",
|
"home.recent": "Opérations récentes",
|
||||||
"home.noActivity": "Aucune activité.",
|
"home.noActivity": "Aucune activité.",
|
||||||
"blackout.title": "EMP",
|
"blackout.title": "EMP",
|
||||||
"blackout.defaultReason": "EMP en cours",
|
"blackout.defaultReason": "EMP en cours",
|
||||||
"blackout.active": "EMP actif",
|
"blackout.active": "EMP actif",
|
||||||
"banker.dashboard.title": "Activité de la session",
|
"banker.dashboard.title": "Activité de l'agence",
|
||||||
"banker.tools.title": "Outils banquier",
|
"banker.tools.title": "Pilotage conseiller",
|
||||||
"banker.tools.playersTab": "Joueurs",
|
"banker.tools.playersTab": "Clients",
|
||||||
"banker.tools.adminTab": "Admin",
|
"banker.tools.adminTab": "Agence",
|
||||||
"banker.tools.playerOverview": "Vue joueur",
|
"banker.tools.playerOverview": "Vue client",
|
||||||
"banker.tools.noPlayers": "Pas encore de joueurs.",
|
"banker.tools.noPlayers": "Pas encore de clients.",
|
||||||
"banker.tools.adjust": "Ajuster le solde",
|
"banker.tools.adjust": "Ajuster le solde",
|
||||||
"banker.tools.apply": "Appliquer",
|
"banker.tools.apply": "Appliquer",
|
||||||
"banker.tools.forceTransfer": "Forcer un transfert",
|
"banker.tools.forceTransfer": "Imposer un virement",
|
||||||
"banker.tools.force": "Forcer",
|
"banker.tools.force": "Forcer",
|
||||||
"banker.tools.createDummy": "Créer un dummy",
|
"banker.tools.createDummy": "Créer un client assisté",
|
||||||
"banker.tools.addDummy": "Ajouter un dummy",
|
"banker.tools.addDummy": "Ajouter un client assisté",
|
||||||
"banker.tools.blackout": "EMP",
|
"banker.tools.blackout": "Coupure",
|
||||||
"banker.tools.blackoutActive": "EMP actif",
|
"banker.tools.blackoutActive": "Coupure active",
|
||||||
"banker.tools.blackoutReason": "Raison de l'EMP",
|
"banker.tools.blackoutReason": "Raison de la coupure",
|
||||||
"banker.tools.blackoutEnable": "Activer l'EMP",
|
"banker.tools.blackoutEnable": "Activer la coupure",
|
||||||
"banker.tools.blackoutDisable": "Désactiver l'EMP",
|
"banker.tools.blackoutDisable": "Désactiver la coupure",
|
||||||
"banker.tools.trigger": "Déclencher",
|
"banker.tools.trigger": "Déclencher",
|
||||||
"banker.tools.endSession": "Terminer la session",
|
"banker.tools.endSession": "Fermer l'agence",
|
||||||
"banker.tools.playerId": "ID joueur",
|
"banker.tools.playerId": "ID client",
|
||||||
"banker.tools.amountAdjust": "Montant (+/-)",
|
"banker.tools.amountAdjust": "Montant (+/-)",
|
||||||
"banker.tools.reason": "Raison",
|
"banker.tools.reason": "Raison",
|
||||||
"banker.tools.fromPlayer": "ID joueur source",
|
"banker.tools.fromPlayer": "ID client source",
|
||||||
"banker.tools.toPlayer": "ID joueur cible",
|
"banker.tools.toPlayer": "ID client cible",
|
||||||
"banker.tools.amount": "Montant",
|
"banker.tools.amount": "Montant",
|
||||||
"banker.tools.note": "Note",
|
"banker.tools.note": "Note",
|
||||||
"banker.tools.dummyName": "Nom du dummy",
|
"banker.tools.dummyName": "Nom du client assisté",
|
||||||
"banker.tools.startingBalance": "Solde de départ",
|
"banker.tools.startingBalance": "Solde de départ",
|
||||||
"banker.takeoverApprovals": "Approbations de reprise",
|
"banker.takeoverApprovals": "Validations de récupération",
|
||||||
"banker.wants": "veut {name}",
|
"banker.wants": "demande {name}",
|
||||||
"banker.approve": "Approuver",
|
"banker.approve": "Approuver",
|
||||||
"banker.stateTitle": "État de partie",
|
"banker.stateTitle": "État de l'agence",
|
||||||
"banker.stateSubtitle": "Exportez ou restaurez la session.",
|
"banker.stateSubtitle": "Exportez ou restaurez l'agence actuelle.",
|
||||||
"banker.downloadState": "Exporter l'état",
|
"banker.downloadState": "Exporter l'état de l'agence",
|
||||||
"banker.loadFromFile": "Charger l'état",
|
"banker.loadFromFile": "Charger l'état de l'agence",
|
||||||
"banker.importPlaceholder": "Collez le JSON d'état ici",
|
"banker.importPlaceholder": "Collez le JSON d'état de l'agence ici",
|
||||||
"banker.loadFromStorage": "Charger depuis les sauvegardes",
|
"banker.loadFromStorage": "Charger depuis les sauvegardes",
|
||||||
"banker.stateDownloaded": "État exporté.",
|
"banker.stateDownloaded": "État de l'agence exporté.",
|
||||||
"banker.stateDownloadError": "Impossible d'exporter l'état.",
|
"banker.stateDownloadError": "Impossible d'exporter l'état de l'agence.",
|
||||||
"banker.stateLoaded": "État chargé.",
|
"banker.stateLoaded": "État de l'agence chargé.",
|
||||||
"banker.stateLoadError": "Impossible de charger l'état.",
|
"banker.stateLoadError": "Impossible de charger l'état de l'agence.",
|
||||||
"banker.stateLoadInvalid": "JSON d'état invalide.",
|
"banker.stateLoadInvalid": "JSON d'état de l'agence invalide.",
|
||||||
"banker.autosaveTitle": "AutoSave",
|
"banker.autosaveTitle": "AutoSave",
|
||||||
"banker.autosaveSubtitle": "Enregistrez des sauvegardes sur l'appareil.",
|
"banker.autosaveSubtitle": "Enregistrez des sauvegardes sur l'appareil.",
|
||||||
"banker.autosaveEnabled": "Activer AutoSave",
|
"banker.autosaveEnabled": "Activer AutoSave",
|
||||||
|
|
@ -290,30 +398,34 @@ const translations = {
|
||||||
"banker.autosaveFailed": "Échec de la sauvegarde.",
|
"banker.autosaveFailed": "Échec de la sauvegarde.",
|
||||||
"banker.noAutosaves": "Aucune sauvegarde.",
|
"banker.noAutosaves": "Aucune sauvegarde.",
|
||||||
"banker.savedAt": "Sauvé {time}",
|
"banker.savedAt": "Sauvé {time}",
|
||||||
"chat.title": "Chats",
|
"chat.title": "Messages",
|
||||||
"chat.noMessages": "Aucun message",
|
"chat.noMessages": "Aucun message",
|
||||||
"chat.global": "Chat global",
|
"chat.global": "Canal agence",
|
||||||
"chat.newTitle": "Nouveau chat",
|
"chat.newTitle": "Nouvelle conversation",
|
||||||
"chat.direct": "Direct",
|
"chat.direct": "Direct",
|
||||||
"chat.group": "Groupe",
|
"chat.group": "Groupe",
|
||||||
"chat.groupName": "Nom du groupe",
|
"chat.groupName": "Nom du groupe",
|
||||||
"chat.choosePlayers": "Choisir des joueurs",
|
"chat.choosePlayers": "Choisir des clients",
|
||||||
"chat.startChat": "Démarrer le chat",
|
"chat.startChat": "Démarrer la conversation",
|
||||||
"chat.notFound": "Chat introuvable.",
|
"chat.notFound": "Chat introuvable.",
|
||||||
"chat.messagePlaceholder": "Message",
|
"chat.messagePlaceholder": "Message",
|
||||||
"tabs.home": "Accueil",
|
"tabs.home": "Comptes",
|
||||||
"tabs.transfers": "Transferts",
|
"tabs.transfers": "Paiements",
|
||||||
"tabs.chat": "Chat",
|
"tabs.chat": "Messages",
|
||||||
"tabs.dashboard": "Tableau",
|
"tabs.dashboard": "Agence",
|
||||||
"tabs.tools": "Outils",
|
"tabs.tools": "Pilotage",
|
||||||
"transaction.transfer": "Transfert",
|
"transaction.transfer": "Transfert",
|
||||||
"transaction.banker_adjust": "Ajustement banquier",
|
"transaction.banker_adjust": "Ajustement conseiller",
|
||||||
"transaction.banker_force_transfer": "Transfert forcé",
|
"transaction.banker_force_transfer": "Virement imposé",
|
||||||
|
"connection.connecting": "Connexion à votre agence",
|
||||||
|
"connection.reconnecting": "Reconnexion à votre agence",
|
||||||
|
"connection.reconnectingDetail": "Tentative {count}. Les mises à jour vont reprendre automatiquement.",
|
||||||
"error.parseResponse": "Impossible de lire la réponse du serveur",
|
"error.parseResponse": "Impossible de lire la réponse du serveur",
|
||||||
"error.createSession": "Impossible de créer la session",
|
"error.createSession": "Impossible d'ouvrir l'agence",
|
||||||
"error.joinSession": "Impossible de rejoindre la session",
|
"error.joinSession": "Impossible d'accéder à l'agence",
|
||||||
"error.loadSessionInfo": "Impossible de charger les infos de session",
|
"error.loadSessionInfo": "Impossible de charger les infos de l'agence",
|
||||||
"error.connectionNotReady": "Connexion non prête",
|
"error.connectionNotReady": "Connexion non prête",
|
||||||
|
"error.reconnecting": "Reconnexion à l'agence en cours. Réessayez dans un instant.",
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
@ -326,7 +438,7 @@ export function getLocale(): Locale {
|
||||||
|
|
||||||
function translate(locale: Locale, key: I18nKey, vars?: Record<string, string | number>) {
|
function translate(locale: Locale, key: I18nKey, vars?: Record<string, string | number>) {
|
||||||
const table = translations[locale] ?? translations.en;
|
const table = translations[locale] ?? translations.en;
|
||||||
let template = table[key] ?? translations.en[key] ?? key;
|
let template: string = table[key] ?? translations.en[key] ?? key;
|
||||||
if (vars) {
|
if (vars) {
|
||||||
Object.entries(vars).forEach(([name, value]) => {
|
Object.entries(vars).forEach(([name, value]) => {
|
||||||
template = template.replace(new RegExp(`\\{${name}\\}`, "g"), String(value));
|
template = template.replace(new RegExp(`\\{${name}\\}`, "g"), String(value));
|
||||||
|
|
@ -349,7 +461,7 @@ export function tStatic(key: I18nKey, vars?: Record<string, string | number>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTransactionKind(
|
export function formatTransactionKind(
|
||||||
kind: "transfer" | "banker_adjust" | "banker_force_transfer",
|
kind: TransactionKind,
|
||||||
t: (key: I18nKey) => string,
|
t: (key: I18nKey) => string,
|
||||||
) {
|
) {
|
||||||
return t(`transaction.${kind}` as I18nKey);
|
return t(`transaction.${kind}` as I18nKey);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ export type NotificationTarget =
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowAlert: true,
|
shouldShowAlert: true,
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
shouldPlaySound: true,
|
shouldPlaySound: true,
|
||||||
shouldSetBadge: false,
|
shouldSetBadge: false,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
161
mobile/src/screens/AgencyCreateScreen.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
485
mobile/src/screens/AgencyJoinScreen.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -27,10 +27,10 @@ function formatTransactionTimestamp(value: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTransactionLabel(
|
function getTransactionLabel(
|
||||||
kind: string,
|
kind: Transaction["kind"],
|
||||||
note: string | null | undefined,
|
note: string | null | undefined,
|
||||||
t: ReturnType<typeof useI18n>["t"],
|
t: ReturnType<typeof useI18n>["t"],
|
||||||
) {
|
): string {
|
||||||
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
|
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
|
||||||
const trimmed = note?.trim();
|
const trimmed = note?.trim();
|
||||||
return trimmed || t("common.noReason");
|
return trimmed || t("common.noReason");
|
||||||
|
|
@ -43,15 +43,15 @@ function getTransactionDisplay(
|
||||||
viewerId: string | null | undefined,
|
viewerId: string | null | undefined,
|
||||||
players: Player[],
|
players: Player[],
|
||||||
t: ReturnType<typeof useI18n>["t"],
|
t: ReturnType<typeof useI18n>["t"],
|
||||||
) {
|
): { label: string; subtitle: string; amount: string; outgoing: boolean } {
|
||||||
const absAmount = Math.abs(transaction.amount);
|
const absAmount = Math.abs(transaction.amount);
|
||||||
const label = getTransactionLabel(transaction.kind, transaction.note, t);
|
const label = getTransactionLabel(transaction.kind, transaction.note, t);
|
||||||
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
|
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
|
||||||
const from = findPlayer(transaction.fromId);
|
const from = findPlayer(transaction.fromId);
|
||||||
const to = findPlayer(transaction.toId);
|
const to = findPlayer(transaction.toId);
|
||||||
let outgoing = false;
|
let outgoing = false;
|
||||||
let counterparty = t("common.bank");
|
let counterparty: string = t("common.bank");
|
||||||
const timeLabel = formatTransactionTimestamp(transaction.createdAt);
|
const timeLabel: string = formatTransactionTimestamp(transaction.createdAt);
|
||||||
|
|
||||||
if (transaction.kind === "banker_adjust") {
|
if (transaction.kind === "banker_adjust") {
|
||||||
outgoing = transaction.amount < 0;
|
outgoing = transaction.amount < 0;
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,10 @@ function formatTransactionTimestamp(value: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTransactionLabel(
|
function getTransactionLabel(
|
||||||
kind: string,
|
kind: Transaction["kind"],
|
||||||
note: string | null | undefined,
|
note: string | null | undefined,
|
||||||
t: ReturnType<typeof useI18n>["t"],
|
t: ReturnType<typeof useI18n>["t"],
|
||||||
) {
|
): string {
|
||||||
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
|
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
|
||||||
const trimmed = note?.trim();
|
const trimmed = note?.trim();
|
||||||
return trimmed || t("common.noReason");
|
return trimmed || t("common.noReason");
|
||||||
|
|
@ -63,15 +63,15 @@ function getTransactionDisplay(
|
||||||
viewerId: string | null | undefined,
|
viewerId: string | null | undefined,
|
||||||
players: Player[],
|
players: Player[],
|
||||||
t: ReturnType<typeof useI18n>["t"],
|
t: ReturnType<typeof useI18n>["t"],
|
||||||
) {
|
): { label: string; subtitle: string; amount: string; outgoing: boolean } {
|
||||||
const absAmount = Math.abs(transaction.amount);
|
const absAmount = Math.abs(transaction.amount);
|
||||||
const label = getTransactionLabel(transaction.kind, transaction.note, t);
|
const label = getTransactionLabel(transaction.kind, transaction.note, t);
|
||||||
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
|
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
|
||||||
const from = findPlayer(transaction.fromId);
|
const from = findPlayer(transaction.fromId);
|
||||||
const to = findPlayer(transaction.toId);
|
const to = findPlayer(transaction.toId);
|
||||||
let outgoing = false;
|
let outgoing = false;
|
||||||
let counterparty = t("common.bank");
|
let counterparty: string = t("common.bank");
|
||||||
const timeLabel = formatTransactionTimestamp(transaction.createdAt);
|
const timeLabel: string = formatTransactionTimestamp(transaction.createdAt);
|
||||||
|
|
||||||
if (transaction.kind === "banker_adjust") {
|
if (transaction.kind === "banker_adjust") {
|
||||||
outgoing = transaction.amount < 0;
|
outgoing = transaction.amount < 0;
|
||||||
|
|
@ -327,6 +327,9 @@ export default function BankerToolsScreen() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = manager.session;
|
||||||
|
const me = manager.me;
|
||||||
|
|
||||||
const normalizedAdjustAmount = adjustAmount.replace(",", ".");
|
const normalizedAdjustAmount = adjustAmount.replace(",", ".");
|
||||||
const adjustValue = Number(normalizedAdjustAmount);
|
const adjustValue = Number(normalizedAdjustAmount);
|
||||||
const canAdjust =
|
const canAdjust =
|
||||||
|
|
@ -436,7 +439,7 @@ export default function BankerToolsScreen() {
|
||||||
const display = getTransactionDisplay(
|
const display = getTransactionDisplay(
|
||||||
transaction,
|
transaction,
|
||||||
selectedPlayerId,
|
selectedPlayerId,
|
||||||
manager.session?.players ?? [],
|
session.players,
|
||||||
t,
|
t,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
|
|
@ -695,14 +698,14 @@ export default function BankerToolsScreen() {
|
||||||
manager.sendMessage({
|
manager.sendMessage({
|
||||||
type: "banker_blackout",
|
type: "banker_blackout",
|
||||||
sessionId: manager.sessionId,
|
sessionId: manager.sessionId,
|
||||||
bankerId: manager.me?.id,
|
bankerId: me.id,
|
||||||
active: !manager.session.blackoutActive,
|
active: !session.blackoutActive,
|
||||||
reason: !manager.session.blackoutActive ? blackoutReason : null,
|
reason: !session.blackoutActive ? blackoutReason : null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text style={styles.buttonDangerText}>
|
<Text style={styles.buttonDangerText}>
|
||||||
{manager.session.blackoutActive
|
{session.blackoutActive
|
||||||
? t("banker.tools.blackoutDisable")
|
? t("banker.tools.blackoutDisable")
|
||||||
: t("banker.tools.blackoutEnable")}
|
: t("banker.tools.blackoutEnable")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -713,7 +716,7 @@ export default function BankerToolsScreen() {
|
||||||
manager.sendMessage({
|
manager.sendMessage({
|
||||||
type: "banker_end",
|
type: "banker_end",
|
||||||
sessionId: manager.sessionId,
|
sessionId: manager.sessionId,
|
||||||
bankerId: manager.me?.id,
|
bankerId: me.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
126
mobile/src/screens/EntryLandingScreen.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
FlatList,
|
|
||||||
Platform,
|
Platform,
|
||||||
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
|
@ -11,12 +11,12 @@ import {
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useNavigation } from "@react-navigation/native";
|
import { useNavigation } from "@react-navigation/native";
|
||||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||||
|
import BrandLockup from "../components/BrandLockup";
|
||||||
|
import ExitGameButton from "../components/ExitGameButton";
|
||||||
|
import { useI18n } from "../i18n";
|
||||||
import type { RootStackParamList } from "../navigation/types";
|
import type { RootStackParamList } from "../navigation/types";
|
||||||
import { useSession } from "../state/session-context";
|
import { useSession } from "../state/session-context";
|
||||||
import { useI18n } from "../i18n";
|
import { useTheme, type AppTheme } from "../theme";
|
||||||
import { useTheme } from "../theme";
|
|
||||||
import type { AppTheme } from "../theme";
|
|
||||||
import ExitGameButton from "../components/ExitGameButton";
|
|
||||||
|
|
||||||
export default function LobbyScreen() {
|
export default function LobbyScreen() {
|
||||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
|
@ -29,24 +29,8 @@ export default function LobbyScreen() {
|
||||||
const [dummyBalance, setDummyBalance] = useState("1500");
|
const [dummyBalance, setDummyBalance] = useState("1500");
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const topInset = insets.top || (Platform.OS === "ios" ? 44 : 0);
|
const topInset = insets.top || (Platform.OS === "ios" ? 44 : 0);
|
||||||
const containerStyle = useMemo(
|
const customers = manager.session?.players.filter((player) => player.role !== "banker") ?? [];
|
||||||
() => [
|
const assistedCount = customers.filter((player) => player.isDummy).length;
|
||||||
styles.container,
|
|
||||||
{
|
|
||||||
paddingTop: topInset + 20,
|
|
||||||
paddingBottom: insets.bottom + 20,
|
|
||||||
paddingLeft: insets.left + 20,
|
|
||||||
paddingRight: insets.right + 20,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
styles.container,
|
|
||||||
topInset,
|
|
||||||
insets.bottom,
|
|
||||||
insets.left,
|
|
||||||
insets.right,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!manager.session || !manager.me) return;
|
if (!manager.session || !manager.me) return;
|
||||||
|
|
@ -57,50 +41,90 @@ export default function LobbyScreen() {
|
||||||
|
|
||||||
if (!manager.session || !manager.me) {
|
if (!manager.session || !manager.me) {
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<View
|
||||||
<Text style={styles.title}>{t("common.loadingLobby")}</Text>
|
style={[
|
||||||
|
styles.loadingContainer,
|
||||||
|
{
|
||||||
|
paddingTop: topInset + 20,
|
||||||
|
paddingBottom: insets.bottom + 20,
|
||||||
|
paddingLeft: insets.left + 20,
|
||||||
|
paddingRight: insets.right + 20,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<BrandLockup variant="hero" subtitle={t("lobby.title")} />
|
||||||
|
<Text style={styles.loadingText}>{t("common.loadingLobby")}</Text>
|
||||||
{manager.error ? <Text style={styles.helper}>{manager.error}</Text> : null}
|
{manager.error ? <Text style={styles.helper}>{manager.error}</Text> : null}
|
||||||
<ExitGameButton mode="full" />
|
<ExitGameButton mode="full" />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const canStart = manager.isBanker && manager.session.status === "lobby";
|
const session = manager.session;
|
||||||
const pendingTakeover = manager.session.takeoverRequests.find(
|
const me = manager.me;
|
||||||
(request) =>
|
const canStart = manager.isBanker && session.status === "lobby";
|
||||||
request.requesterId === manager.playerId && request.status === "pending",
|
const pendingTakeover = session.takeoverRequests.find(
|
||||||
|
(request) => request.requesterId === manager.playerId && request.status === "pending",
|
||||||
);
|
);
|
||||||
const pendingRequests = manager.isBanker
|
const pendingRequests = manager.isBanker
|
||||||
? manager.session.takeoverRequests.filter((request) => request.status === "pending")
|
? session.takeoverRequests.filter((request) => request.status === "pending")
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<ScrollView
|
||||||
<Text style={styles.title}>{t("lobby.title")}</Text>
|
style={styles.scroll}
|
||||||
<Text style={styles.subtitle}>{t("lobby.code", { code: manager.session.code })}</Text>
|
contentContainerStyle={{
|
||||||
|
paddingTop: topInset + 16,
|
||||||
|
paddingBottom: insets.bottom + 24,
|
||||||
|
paddingLeft: insets.left + 20,
|
||||||
|
paddingRight: insets.right + 20,
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<BrandLockup variant="hero" subtitle={t("lobby.title")} onDark />
|
||||||
|
<Text style={styles.heroTitle}>{t("lobby.code", { code: session.code })}</Text>
|
||||||
|
<Text style={styles.heroBody}>
|
||||||
|
{manager.isBanker ? t("lobby.heroAdvisor") : t("lobby.heroCustomer")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.metrics}>
|
||||||
|
<View style={styles.metricCard}>
|
||||||
|
<Text style={styles.metricValue}>{customers.length}</Text>
|
||||||
|
<Text style={styles.metricLabel}>{t("lobby.customers")}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metricCard}>
|
||||||
|
<Text style={styles.metricValue}>{assistedCount}</Text>
|
||||||
|
<Text style={styles.metricLabel}>{t("lobby.assisted")}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
{pendingTakeover ? (
|
{pendingTakeover ? (
|
||||||
<Text style={styles.helper}>{t("entry.takeoverPending")}</Text>
|
<View style={styles.noticeCard}>
|
||||||
|
<Text style={styles.noticeText}>{t("entry.takeoverPending")}</Text>
|
||||||
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<FlatList
|
<View style={styles.card}>
|
||||||
data={manager.session.players}
|
<Text style={styles.cardTitle}>{t("lobby.rosterTitle")}</Text>
|
||||||
keyExtractor={(item) => item.id}
|
<View style={styles.roster}>
|
||||||
contentContainerStyle={styles.list}
|
{session.players.map((item) => (
|
||||||
renderItem={({ item }) => (
|
<View key={item.id} style={styles.listItem}>
|
||||||
<View style={styles.listItem}>
|
<View style={styles.listCopy}>
|
||||||
<View>
|
<Text style={styles.playerName}>{item.name}</Text>
|
||||||
<Text style={styles.playerName}>{item.name}</Text>
|
<Text style={styles.playerMeta}>
|
||||||
<Text style={styles.playerMeta}>
|
{item.role === "banker" ? t("common.banker") : t("common.player")}
|
||||||
{item.role === "banker" ? t("common.banker") : t("common.player")}{" "}
|
{item.isDummy ? ` · ${t("common.dummy")}` : ""}
|
||||||
{item.isDummy ? `- ${t("common.dummy")}` : ""}
|
</Text>
|
||||||
|
</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}>
|
||||||
|
|
@ -108,10 +132,9 @@ export default function LobbyScreen() {
|
||||||
<View style={styles.takeoverList}>
|
<View style={styles.takeoverList}>
|
||||||
{pendingRequests.map((request) => {
|
{pendingRequests.map((request) => {
|
||||||
const requester =
|
const requester =
|
||||||
manager.session.players.find((player) => player.id === request.requesterId) ??
|
session.players.find((player) => player.id === request.requesterId) ?? null;
|
||||||
null;
|
|
||||||
const dummy =
|
const dummy =
|
||||||
manager.session.players.find((player) => player.id === request.dummyId) ?? null;
|
session.players.find((player) => player.id === request.dummyId) ?? null;
|
||||||
const requesterName =
|
const requesterName =
|
||||||
requester?.name ?? request.requesterName ?? t("common.player");
|
requester?.name ?? request.requesterName ?? t("common.player");
|
||||||
return (
|
return (
|
||||||
|
|
@ -128,7 +151,7 @@ export default function LobbyScreen() {
|
||||||
manager.sendMessage({
|
manager.sendMessage({
|
||||||
type: "banker_takeover_approve",
|
type: "banker_takeover_approve",
|
||||||
sessionId: manager.sessionId,
|
sessionId: manager.sessionId,
|
||||||
bankerId: manager.me?.id,
|
bankerId: me.id,
|
||||||
dummyId: request.dummyId,
|
dummyId: request.dummyId,
|
||||||
requesterId: request.requesterId,
|
requesterId: request.requesterId,
|
||||||
})
|
})
|
||||||
|
|
@ -143,10 +166,10 @@ export default function LobbyScreen() {
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{manager.isBanker && manager.session.status === "lobby" && (
|
{manager.isBanker ? (
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.cardTitle}>{t("lobby.addDummyTitle")}</Text>
|
<Text style={styles.cardTitle}>{t("lobby.addDummyTitle")}</Text>
|
||||||
<Text style={styles.helper}>{t("lobby.addDummySubtitle")}</Text>
|
<Text style={styles.cardSubtitle}>{t("lobby.addDummySubtitle")}</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder={t("lobby.enterDummyName")}
|
placeholder={t("lobby.enterDummyName")}
|
||||||
|
|
@ -168,7 +191,7 @@ export default function LobbyScreen() {
|
||||||
manager.sendMessage({
|
manager.sendMessage({
|
||||||
type: "banker_create_dummy",
|
type: "banker_create_dummy",
|
||||||
sessionId: manager.sessionId,
|
sessionId: manager.sessionId,
|
||||||
bankerId: manager.me?.id,
|
bankerId: me.id,
|
||||||
name: dummyName,
|
name: dummyName,
|
||||||
balance: Number(dummyBalance) || undefined,
|
balance: Number(dummyBalance) || undefined,
|
||||||
});
|
});
|
||||||
|
|
@ -179,143 +202,233 @@ export default function LobbyScreen() {
|
||||||
<Text style={styles.buttonSecondaryText}>{t("lobby.addDummyButton")}</Text>
|
<Text style={styles.buttonSecondaryText}>{t("lobby.addDummyButton")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.waitingCard}>
|
||||||
|
<Text style={styles.waitingTitle}>{t("lobby.waitingTitle")}</Text>
|
||||||
|
<Text style={styles.waitingBody}>{t("lobby.waitingBody")}</Text>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canStart && (
|
{canStart ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.button}
|
style={styles.button}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
manager.sendMessage({
|
manager.sendMessage({
|
||||||
type: "banker_start",
|
type: "banker_start",
|
||||||
sessionId: manager.sessionId,
|
sessionId: manager.sessionId,
|
||||||
bankerId: manager.me?.id,
|
bankerId: me.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text style={styles.buttonText}>{t("lobby.startGame")}</Text>
|
<Text style={styles.buttonText}>{t("lobby.startGame")}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
<ExitGameButton mode="full" />
|
<ExitGameButton mode="full" />
|
||||||
</View>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const createStyles = (theme: AppTheme) =>
|
const createStyles = (theme: AppTheme) =>
|
||||||
StyleSheet.create({
|
StyleSheet.create({
|
||||||
container: {
|
scroll: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingHorizontal: 0,
|
|
||||||
paddingBottom: 0,
|
|
||||||
gap: 12,
|
|
||||||
backgroundColor: theme.colors.background,
|
backgroundColor: theme.colors.background,
|
||||||
},
|
},
|
||||||
title: {
|
loadingContainer: {
|
||||||
fontSize: 24,
|
flex: 1,
|
||||||
fontWeight: "700",
|
gap: 16,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
color: theme.colors.text,
|
color: theme.colors.text,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "700",
|
||||||
},
|
},
|
||||||
subtitle: {
|
hero: {
|
||||||
|
borderRadius: 28,
|
||||||
|
padding: 22,
|
||||||
|
gap: 12,
|
||||||
|
backgroundColor: theme.colors.brandSurface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.brandSurfaceAlt,
|
||||||
|
},
|
||||||
|
heroTitle: {
|
||||||
|
color: theme.colors.brandText,
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: "800",
|
||||||
|
letterSpacing: -0.8,
|
||||||
|
},
|
||||||
|
heroBody: {
|
||||||
|
color: theme.colors.brandTextMuted,
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
metricCard: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 18,
|
||||||
|
padding: 14,
|
||||||
|
backgroundColor: theme.colors.brandSurfaceAlt,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
metricValue: {
|
||||||
|
color: theme.colors.brandText,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "800",
|
||||||
|
},
|
||||||
|
metricLabel: {
|
||||||
|
color: theme.colors.brandTextMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1,
|
||||||
|
},
|
||||||
|
noticeCard: {
|
||||||
|
borderRadius: 18,
|
||||||
|
padding: 14,
|
||||||
|
backgroundColor: theme.colors.warningSurface,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.warningBorder,
|
||||||
|
},
|
||||||
|
noticeText: {
|
||||||
|
color: theme.colors.warningTextStrong,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
backgroundColor: theme.colors.surface,
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 20,
|
||||||
|
gap: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
color: theme.colors.text,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
cardSubtitle: {
|
||||||
color: theme.colors.textMuted,
|
color: theme.colors.textMuted,
|
||||||
|
lineHeight: 21,
|
||||||
},
|
},
|
||||||
list: {
|
roster: {
|
||||||
gap: 10,
|
gap: 10,
|
||||||
paddingBottom: 20,
|
|
||||||
},
|
},
|
||||||
listItem: {
|
listItem: {
|
||||||
backgroundColor: theme.colors.surface,
|
backgroundColor: theme.colors.surfaceAlt,
|
||||||
borderRadius: 12,
|
borderRadius: 18,
|
||||||
padding: 12,
|
padding: 14,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
listCopy: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 3,
|
||||||
},
|
},
|
||||||
playerName: {
|
playerName: {
|
||||||
fontWeight: "600",
|
fontWeight: "700",
|
||||||
color: theme.colors.text,
|
color: theme.colors.text,
|
||||||
},
|
},
|
||||||
playerMeta: {
|
playerMeta: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: theme.colors.textMuted,
|
color: theme.colors.textMuted,
|
||||||
},
|
},
|
||||||
card: {
|
statusText: {
|
||||||
backgroundColor: theme.colors.surface,
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 16,
|
|
||||||
gap: 10,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.borderMuted,
|
|
||||||
},
|
|
||||||
cardTitle: {
|
|
||||||
fontWeight: "600",
|
|
||||||
color: theme.colors.text,
|
|
||||||
},
|
|
||||||
helper: {
|
|
||||||
color: theme.colors.textMuted,
|
color: theme.colors.textMuted,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
fontWeight: "600",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.8,
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
backgroundColor: theme.colors.inputBackground,
|
backgroundColor: theme.colors.inputBackground,
|
||||||
color: theme.colors.inputText,
|
color: theme.colors.inputText,
|
||||||
borderRadius: 12,
|
borderRadius: 16,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 10,
|
paddingVertical: 13,
|
||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
backgroundColor: theme.colors.primary,
|
backgroundColor: theme.colors.primary,
|
||||||
paddingVertical: 14,
|
paddingVertical: 15,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
buttonText: {
|
buttonText: {
|
||||||
color: theme.colors.primaryText,
|
color: theme.colors.primaryText,
|
||||||
fontWeight: "600",
|
fontWeight: "700",
|
||||||
},
|
},
|
||||||
buttonSecondary: {
|
buttonSecondary: {
|
||||||
backgroundColor: theme.colors.secondary,
|
backgroundColor: theme.colors.secondary,
|
||||||
paddingVertical: 12,
|
paddingVertical: 14,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
buttonSecondaryText: {
|
buttonSecondaryText: {
|
||||||
color: theme.colors.secondaryText,
|
color: theme.colors.secondaryText,
|
||||||
fontWeight: "600",
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
waitingCard: {
|
||||||
|
backgroundColor: theme.colors.accentSurface,
|
||||||
|
borderRadius: 24,
|
||||||
|
padding: 20,
|
||||||
|
gap: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.borderMuted,
|
||||||
|
},
|
||||||
|
waitingTitle: {
|
||||||
|
color: theme.colors.text,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
waitingBody: {
|
||||||
|
color: theme.colors.textMuted,
|
||||||
|
lineHeight: 21,
|
||||||
|
},
|
||||||
|
helper: {
|
||||||
|
color: theme.colors.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
},
|
},
|
||||||
takeoverList: {
|
takeoverList: {
|
||||||
gap: 10,
|
gap: 10,
|
||||||
},
|
},
|
||||||
takeoverRow: {
|
takeoverRow: {
|
||||||
|
borderRadius: 18,
|
||||||
|
padding: 14,
|
||||||
|
backgroundColor: theme.colors.surfaceAlt,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.borderMuted,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
gap: 12,
|
||||||
paddingVertical: 6,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: theme.colors.borderMuted,
|
|
||||||
},
|
},
|
||||||
takeoverMeta: {
|
takeoverMeta: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingRight: 12,
|
gap: 3,
|
||||||
},
|
},
|
||||||
takeoverName: {
|
takeoverName: {
|
||||||
fontWeight: "600",
|
fontWeight: "700",
|
||||||
color: theme.colors.text,
|
color: theme.colors.text,
|
||||||
},
|
},
|
||||||
takeoverSub: {
|
takeoverSub: {
|
||||||
fontSize: 12,
|
|
||||||
color: theme.colors.textMuted,
|
color: theme.colors.textMuted,
|
||||||
marginTop: 2,
|
fontSize: 12,
|
||||||
},
|
},
|
||||||
buttonSmall: {
|
buttonSmall: {
|
||||||
backgroundColor: theme.colors.secondary,
|
backgroundColor: theme.colors.primary,
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 6,
|
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
},
|
},
|
||||||
buttonSmallText: {
|
buttonSmallText: {
|
||||||
color: theme.colors.secondaryText,
|
color: theme.colors.primaryText,
|
||||||
fontWeight: "600",
|
fontWeight: "700",
|
||||||
fontSize: 12,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,10 @@ function formatTransactionTimestamp(value: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTransactionLabel(
|
function getTransactionLabel(
|
||||||
kind: string,
|
kind: Transaction["kind"],
|
||||||
note: string | null | undefined,
|
note: string | null | undefined,
|
||||||
t: ReturnType<typeof useI18n>["t"],
|
t: ReturnType<typeof useI18n>["t"],
|
||||||
) {
|
): string {
|
||||||
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
|
if (kind === "banker_adjust" || kind === "banker_force_transfer") {
|
||||||
const trimmed = note?.trim();
|
const trimmed = note?.trim();
|
||||||
return trimmed || t("common.noReason");
|
return trimmed || t("common.noReason");
|
||||||
|
|
@ -44,15 +44,15 @@ function getTransactionDisplay(
|
||||||
viewerId: string | null | undefined,
|
viewerId: string | null | undefined,
|
||||||
players: Player[],
|
players: Player[],
|
||||||
t: ReturnType<typeof useI18n>["t"],
|
t: ReturnType<typeof useI18n>["t"],
|
||||||
) {
|
): { label: string; subtitle: string; amount: string; outgoing: boolean } {
|
||||||
const absAmount = Math.abs(transaction.amount);
|
const absAmount = Math.abs(transaction.amount);
|
||||||
const label = getTransactionLabel(transaction.kind, transaction.note, t);
|
const label = getTransactionLabel(transaction.kind, transaction.note, t);
|
||||||
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
|
const findPlayer = (id: string | null) => players.find((player) => player.id === id);
|
||||||
const from = findPlayer(transaction.fromId);
|
const from = findPlayer(transaction.fromId);
|
||||||
const to = findPlayer(transaction.toId);
|
const to = findPlayer(transaction.toId);
|
||||||
let outgoing = false;
|
let outgoing = false;
|
||||||
let counterparty = t("common.bank");
|
let counterparty: string = t("common.bank");
|
||||||
const timeLabel = formatTransactionTimestamp(transaction.createdAt);
|
const timeLabel: string = formatTransactionTimestamp(transaction.createdAt);
|
||||||
|
|
||||||
if (transaction.kind === "banker_adjust") {
|
if (transaction.kind === "banker_adjust") {
|
||||||
outgoing = transaction.amount < 0;
|
outgoing = transaction.amount < 0;
|
||||||
|
|
|
||||||
|
|
@ -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(",", ".");
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ export default function ChatThreadScreen() {
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const activeThread = thread;
|
||||||
const showEmp = manager.session.blackoutActive && !manager.isBanker;
|
const showEmp = manager.session.blackoutActive && !manager.isBanker;
|
||||||
|
|
||||||
function handleSend() {
|
function handleSend() {
|
||||||
|
|
@ -74,7 +75,7 @@ export default function ChatThreadScreen() {
|
||||||
sessionId: manager.sessionId,
|
sessionId: manager.sessionId,
|
||||||
playerId: manager.me?.id,
|
playerId: manager.me?.id,
|
||||||
body: message.trim(),
|
body: message.trim(),
|
||||||
groupId: thread.id === "global" ? null : thread.id,
|
groupId: activeThread.id === "global" ? null : activeThread.id,
|
||||||
});
|
});
|
||||||
setMessage("");
|
setMessage("");
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +88,7 @@ export default function ChatThreadScreen() {
|
||||||
keyboardVerticalOffset={keyboardOffset}
|
keyboardVerticalOffset={keyboardOffset}
|
||||||
>
|
>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.headerTitle}>{thread.name}</Text>
|
<Text style={styles.headerTitle}>{activeThread.name}</Text>
|
||||||
<Text style={styles.headerSubtitle}>{threadKindLabel}</Text>
|
<Text style={styles.headerSubtitle}>{threadKindLabel}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
|
||||||
29
mobile/src/state/connection.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
CONNECTION_IDLE_TIMEOUT_MS,
|
||||||
|
RECONNECT_MAX_DELAY_MS,
|
||||||
|
getReconnectDelayMs,
|
||||||
|
isConnectionStale,
|
||||||
|
isTerminalSocketClose,
|
||||||
|
} from "./connection";
|
||||||
|
|
||||||
|
test("getReconnectDelayMs grows with backoff and caps at the max delay", () => {
|
||||||
|
expect(getReconnectDelayMs(0, () => 0)).toBe(800);
|
||||||
|
expect(getReconnectDelayMs(1, () => 0)).toBe(1600);
|
||||||
|
expect(getReconnectDelayMs(4, () => 0)).toBe(8000);
|
||||||
|
expect(getReconnectDelayMs(8, () => 1)).toBe(Math.round(RECONNECT_MAX_DELAY_MS * 1.2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isConnectionStale respects the idle timeout", () => {
|
||||||
|
const now = 100_000;
|
||||||
|
expect(isConnectionStale(now - CONNECTION_IDLE_TIMEOUT_MS + 1, now)).toBe(false);
|
||||||
|
expect(isConnectionStale(now - CONNECTION_IDLE_TIMEOUT_MS - 1, now)).toBe(true);
|
||||||
|
expect(isConnectionStale(null, now)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isTerminalSocketClose only resets on invalid session/player closes", () => {
|
||||||
|
expect(isTerminalSocketClose(1008, "Session not found")).toBe(true);
|
||||||
|
expect(isTerminalSocketClose(1008, "Player not found")).toBe(true);
|
||||||
|
expect(isTerminalSocketClose(4000, "Connection stale")).toBe(false);
|
||||||
|
expect(isTerminalSocketClose(1008, "Policy violation")).toBe(false);
|
||||||
|
});
|
||||||
40
mobile/src/state/connection.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
export type SessionConnectionState =
|
||||||
|
| "idle"
|
||||||
|
| "connecting"
|
||||||
|
| "open"
|
||||||
|
| "reconnecting"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export const CONNECTION_PING_INTERVAL_MS = 15_000;
|
||||||
|
export const CONNECTION_IDLE_TIMEOUT_MS = 45_000;
|
||||||
|
export const CONNECTION_WATCHDOG_INTERVAL_MS = 5_000;
|
||||||
|
export const RECONNECT_BASE_DELAY_MS = 1_000;
|
||||||
|
export const RECONNECT_MAX_DELAY_MS = 10_000;
|
||||||
|
|
||||||
|
export function getReconnectDelayMs(
|
||||||
|
attempt: number,
|
||||||
|
random: () => number = Math.random,
|
||||||
|
): number {
|
||||||
|
const normalizedAttempt = Math.max(0, attempt);
|
||||||
|
const baseDelay = Math.min(
|
||||||
|
RECONNECT_MAX_DELAY_MS,
|
||||||
|
RECONNECT_BASE_DELAY_MS * 2 ** normalizedAttempt,
|
||||||
|
);
|
||||||
|
const jitterMultiplier = 0.8 + random() * 0.4;
|
||||||
|
return Math.round(baseDelay * jitterMultiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTerminalSocketClose(code?: number, reason?: string): boolean {
|
||||||
|
return code === 1008 && /session not found|player not found/i.test(reason ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isConnectionStale(
|
||||||
|
lastActivityAt: number | null | undefined,
|
||||||
|
nowMs = Date.now(),
|
||||||
|
timeoutMs = CONNECTION_IDLE_TIMEOUT_MS,
|
||||||
|
): boolean {
|
||||||
|
if (!lastActivityAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return nowMs - lastActivityAt > timeoutMs;
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,23 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { AppState, type AppStateStatus } from "react-native";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types";
|
import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types";
|
||||||
import { getApiBaseUrl, getWsUrl } from "../config/api";
|
import { getApiBaseUrl, getWsUrl } from "../config/api";
|
||||||
|
import {
|
||||||
|
buildScreenshotFixture,
|
||||||
|
type ScreenshotFixture,
|
||||||
|
type ScreenshotScene,
|
||||||
|
} from "../dev/screenshot-fixtures";
|
||||||
import { tStatic } from "../i18n";
|
import { tStatic } from "../i18n";
|
||||||
import { registerForPushNotificationsAsync } from "../notifications";
|
import { registerForPushNotificationsAsync } from "../notifications";
|
||||||
|
import {
|
||||||
|
CONNECTION_PING_INTERVAL_MS,
|
||||||
|
CONNECTION_WATCHDOG_INTERVAL_MS,
|
||||||
|
type SessionConnectionState,
|
||||||
|
getReconnectDelayMs,
|
||||||
|
isConnectionStale,
|
||||||
|
isTerminalSocketClose,
|
||||||
|
} from "./connection";
|
||||||
|
|
||||||
const STORAGE_KEY = "negopoly:session";
|
const STORAGE_KEY = "negopoly:session";
|
||||||
|
|
||||||
|
|
@ -13,6 +27,12 @@ type StoredSession = {
|
||||||
playerId: string;
|
playerId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type IncomingMessage =
|
||||||
|
| { type: "state"; session: SessionSnapshot }
|
||||||
|
| { type: "error"; message: string }
|
||||||
|
| { type: "takeover_approved"; assignedPlayerId: string }
|
||||||
|
| { type: "pong" };
|
||||||
|
|
||||||
async function readStoredSession(): Promise<StoredSession | null> {
|
async function readStoredSession(): Promise<StoredSession | null> {
|
||||||
try {
|
try {
|
||||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||||
|
|
@ -36,23 +56,276 @@ export function useSessionManager() {
|
||||||
const [sessionCode, setSessionCode] = useState("");
|
const [sessionCode, setSessionCode] = useState("");
|
||||||
const [playerId, setPlayerId] = useState("");
|
const [playerId, setPlayerId] = useState("");
|
||||||
const [session, setSession] = useState<SessionSnapshot | null>(null);
|
const [session, setSession] = useState<SessionSnapshot | null>(null);
|
||||||
|
const [screenshot, setScreenshot] = useState<ScreenshotFixture | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [connectionState, setConnectionState] = useState<
|
const [connectionState, setConnectionState] =
|
||||||
"idle" | "connecting" | "open" | "error"
|
useState<SessionConnectionState>("idle");
|
||||||
>("idle");
|
|
||||||
const [tick, setTick] = useState(0);
|
|
||||||
const [pushToken, setPushToken] = useState<{
|
const [pushToken, setPushToken] = useState<{
|
||||||
token: string;
|
token: string;
|
||||||
platform: "ios" | "android";
|
platform: "ios" | "android";
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [reconnectAttempt, setReconnectAttempt] = useState(0);
|
||||||
|
const [lastActivityAt, setLastActivityAt] = useState<number | null>(null);
|
||||||
|
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const sessionIdRef = useRef(sessionId);
|
||||||
|
const sessionCodeRef = useRef(sessionCode);
|
||||||
|
const playerIdRef = useRef(playerId);
|
||||||
|
const sessionRef = useRef<SessionSnapshot | null>(session);
|
||||||
|
const connectionGenerationRef = useRef(0);
|
||||||
|
const reconnectAttemptRef = useRef(0);
|
||||||
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const watchdogTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const suppressReconnectRef = useRef(false);
|
||||||
|
const lastActivityAtRef = useRef<number | null>(null);
|
||||||
|
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
|
||||||
const lastPushRegistrationRef = useRef<string | null>(null);
|
const lastPushRegistrationRef = useRef<string | null>(null);
|
||||||
|
const screenshotRef = useRef<ScreenshotFixture | null>(null);
|
||||||
|
|
||||||
|
function markActivity(at = Date.now()) {
|
||||||
|
lastActivityAtRef.current = at;
|
||||||
|
setLastActivityAt(at);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearReconnectTimer() {
|
||||||
|
if (reconnectTimerRef.current) {
|
||||||
|
clearTimeout(reconnectTimerRef.current);
|
||||||
|
reconnectTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSocketTimers() {
|
||||||
|
if (pingTimerRef.current) {
|
||||||
|
clearInterval(pingTimerRef.current);
|
||||||
|
pingTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (watchdogTimerRef.current) {
|
||||||
|
clearInterval(watchdogTimerRef.current);
|
||||||
|
watchdogTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSocket(ws: WebSocket | null, code?: number, reason?: string) {
|
||||||
|
if (!ws || ws.readyState === WebSocket.CLOSED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (typeof code === "number") {
|
||||||
|
ws.close(code, reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore close failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function teardownConnection() {
|
||||||
|
clearReconnectTimer();
|
||||||
|
clearSocketTimers();
|
||||||
|
connectionGenerationRef.current += 1;
|
||||||
|
const ws = wsRef.current;
|
||||||
|
wsRef.current = null;
|
||||||
|
closeSocket(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect(generation: number) {
|
||||||
|
if (
|
||||||
|
suppressReconnectRef.current ||
|
||||||
|
generation !== connectionGenerationRef.current ||
|
||||||
|
!sessionIdRef.current ||
|
||||||
|
!playerIdRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearReconnectTimer();
|
||||||
|
const nextAttempt = reconnectAttemptRef.current + 1;
|
||||||
|
reconnectAttemptRef.current = nextAttempt;
|
||||||
|
setReconnectAttempt(nextAttempt);
|
||||||
|
setConnectionState("reconnecting");
|
||||||
|
|
||||||
|
const delay = getReconnectDelayMs(nextAttempt - 1);
|
||||||
|
reconnectTimerRef.current = setTimeout(() => {
|
||||||
|
reconnectTimerRef.current = null;
|
||||||
|
openSocket("retry");
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSocketTimers(ws: WebSocket, generation: number) {
|
||||||
|
clearSocketTimers();
|
||||||
|
|
||||||
|
pingTimerRef.current = setInterval(() => {
|
||||||
|
if (
|
||||||
|
generation !== connectionGenerationRef.current ||
|
||||||
|
ws.readyState !== WebSocket.OPEN
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "ping",
|
||||||
|
sessionId: sessionIdRef.current,
|
||||||
|
playerId: playerIdRef.current,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
closeSocket(ws, 4001, "Ping failed");
|
||||||
|
}
|
||||||
|
}, CONNECTION_PING_INTERVAL_MS);
|
||||||
|
|
||||||
|
watchdogTimerRef.current = setInterval(() => {
|
||||||
|
if (
|
||||||
|
generation !== connectionGenerationRef.current ||
|
||||||
|
appStateRef.current !== "active"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isConnectionStale(lastActivityAtRef.current)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||||
|
closeSocket(ws, 4000, "Connection stale");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleReconnect(generation);
|
||||||
|
}, CONNECTION_WATCHDOG_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSocket(reason: "initial" | "retry" | "resume" | "manual") {
|
||||||
|
const targetSessionId = sessionIdRef.current;
|
||||||
|
const targetPlayerId = playerIdRef.current;
|
||||||
|
if (!targetSessionId || !targetPlayerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearReconnectTimer();
|
||||||
|
clearSocketTimers();
|
||||||
|
|
||||||
|
const previousSocket = wsRef.current;
|
||||||
|
const generation = connectionGenerationRef.current + 1;
|
||||||
|
connectionGenerationRef.current = generation;
|
||||||
|
wsRef.current = null;
|
||||||
|
closeSocket(previousSocket);
|
||||||
|
|
||||||
|
const recovering = Boolean(sessionRef.current) || reason !== "initial";
|
||||||
|
setConnectionState(recovering ? "reconnecting" : "connecting");
|
||||||
|
|
||||||
|
const ws = new WebSocket(getWsUrl(targetSessionId, targetPlayerId));
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (connectionGenerationRef.current !== generation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reconnectAttemptRef.current = 0;
|
||||||
|
setReconnectAttempt(0);
|
||||||
|
setConnectionState("open");
|
||||||
|
setError(null);
|
||||||
|
markActivity();
|
||||||
|
startSocketTimers(ws, generation);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
if (connectionGenerationRef.current !== generation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
markActivity();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data) as IncomingMessage;
|
||||||
|
if (message.type === "state") {
|
||||||
|
setSession(message.session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type === "error") {
|
||||||
|
setError(message.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type === "takeover_approved") {
|
||||||
|
const assignedId = message.assignedPlayerId;
|
||||||
|
setPlayerId(assignedId);
|
||||||
|
if (sessionIdRef.current && sessionCodeRef.current) {
|
||||||
|
void writeStoredSession({
|
||||||
|
sessionId: sessionIdRef.current,
|
||||||
|
sessionCode: sessionCodeRef.current,
|
||||||
|
playerId: assignedId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(tStatic("error.parseResponse"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
if (connectionGenerationRef.current !== generation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConnectionState("reconnecting");
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
if (connectionGenerationRef.current !== generation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wsRef.current === ws) {
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
clearSocketTimers();
|
||||||
|
|
||||||
|
const reasonText = typeof event?.reason === "string" ? event.reason : "";
|
||||||
|
if (isTerminalSocketClose(event?.code, reasonText)) {
|
||||||
|
setConnectionState("error");
|
||||||
|
void resetSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
suppressReconnectRef.current ||
|
||||||
|
!sessionIdRef.current ||
|
||||||
|
!playerIdRef.current
|
||||||
|
) {
|
||||||
|
setConnectionState("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleReconnect(generation);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryConnection() {
|
||||||
|
if (!sessionIdRef.current || !playerIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suppressReconnectRef.current = false;
|
||||||
|
reconnectAttemptRef.current = 0;
|
||||||
|
setReconnectAttempt(0);
|
||||||
|
openSocket("manual");
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionIdRef.current = sessionId;
|
||||||
|
sessionCodeRef.current = sessionCode;
|
||||||
|
playerIdRef.current = playerId;
|
||||||
|
}, [playerId, sessionCode, sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
screenshotRef.current = screenshot;
|
||||||
|
}, [screenshot]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionRef.current = session;
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
readStoredSession().then((stored) => {
|
readStoredSession().then((stored) => {
|
||||||
if (!mounted || !stored) return;
|
if (!mounted || !stored || screenshotRef.current) return;
|
||||||
setSessionId(stored.sessionId);
|
setSessionId(stored.sessionId);
|
||||||
setSessionCode(stored.sessionCode);
|
setSessionCode(stored.sessionCode);
|
||||||
setPlayerId(stored.playerId);
|
setPlayerId(stored.playerId);
|
||||||
|
|
@ -63,6 +336,7 @@ export function useSessionManager() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (screenshot) return;
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
registerForPushNotificationsAsync().then((token) => {
|
registerForPushNotificationsAsync().then((token) => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
@ -71,17 +345,100 @@ export function useSessionManager() {
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [screenshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => setTick((value) => value + 1), 1000);
|
if (screenshot) return;
|
||||||
return () => clearInterval(timer);
|
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||||
}, []);
|
const previousState = appStateRef.current;
|
||||||
|
appStateRef.current = nextState;
|
||||||
|
|
||||||
|
if (
|
||||||
|
previousState !== "active" &&
|
||||||
|
nextState === "active" &&
|
||||||
|
sessionIdRef.current &&
|
||||||
|
playerIdRef.current
|
||||||
|
) {
|
||||||
|
const socket = wsRef.current;
|
||||||
|
const socketOpen = socket?.readyState === WebSocket.OPEN;
|
||||||
|
if (!socketOpen || isConnectionStale(lastActivityAtRef.current)) {
|
||||||
|
retryConnection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "ping",
|
||||||
|
sessionId: sessionIdRef.current,
|
||||||
|
playerId: playerIdRef.current,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
retryConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, [screenshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (screenshot) return;
|
||||||
if (!pushToken || !sessionId || !playerId) return;
|
if (!pushToken || !sessionId || !playerId) return;
|
||||||
void registerPushTokenFor(sessionId, playerId);
|
void registerPushTokenFor(sessionId, playerId);
|
||||||
}, [pushToken, sessionId, playerId]);
|
}, [pushToken, screenshot, sessionId, playerId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (screenshot) {
|
||||||
|
suppressReconnectRef.current = true;
|
||||||
|
teardownConnection();
|
||||||
|
reconnectAttemptRef.current = 0;
|
||||||
|
setReconnectAttempt(0);
|
||||||
|
lastActivityAtRef.current = null;
|
||||||
|
setLastActivityAt(null);
|
||||||
|
setConnectionState("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sessionId || !playerId) {
|
||||||
|
suppressReconnectRef.current = true;
|
||||||
|
teardownConnection();
|
||||||
|
reconnectAttemptRef.current = 0;
|
||||||
|
setReconnectAttempt(0);
|
||||||
|
lastActivityAtRef.current = null;
|
||||||
|
setLastActivityAt(null);
|
||||||
|
setConnectionState("idle");
|
||||||
|
setSession(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
suppressReconnectRef.current = false;
|
||||||
|
reconnectAttemptRef.current = 0;
|
||||||
|
setReconnectAttempt(0);
|
||||||
|
openSocket("initial");
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
teardownConnection();
|
||||||
|
};
|
||||||
|
}, [playerId, screenshot, sessionId]);
|
||||||
|
|
||||||
|
const activateScreenshotScene = useCallback((scene: ScreenshotScene) => {
|
||||||
|
const fixture = buildScreenshotFixture(scene);
|
||||||
|
suppressReconnectRef.current = true;
|
||||||
|
teardownConnection();
|
||||||
|
reconnectAttemptRef.current = 0;
|
||||||
|
setReconnectAttempt(0);
|
||||||
|
lastActivityAtRef.current = null;
|
||||||
|
setLastActivityAt(null);
|
||||||
|
setConnectionState("idle");
|
||||||
|
setError(null);
|
||||||
|
setScreenshot(fixture);
|
||||||
|
setSessionId(fixture.sessionId);
|
||||||
|
setSessionCode(fixture.sessionCode);
|
||||||
|
setPlayerId(fixture.playerId);
|
||||||
|
setSession(fixture.session);
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function registerPushTokenFor(targetSessionId: string, targetPlayerId: string) {
|
async function registerPushTokenFor(targetSessionId: string, targetPlayerId: string) {
|
||||||
if (!pushToken) return;
|
if (!pushToken) return;
|
||||||
|
|
@ -104,64 +461,6 @@ export function useSessionManager() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!sessionId || !playerId) {
|
|
||||||
setConnectionState("idle");
|
|
||||||
setSession(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setConnectionState("connecting");
|
|
||||||
const ws = new WebSocket(getWsUrl(sessionId, playerId));
|
|
||||||
wsRef.current = ws;
|
|
||||||
|
|
||||||
ws.onopen = () => setConnectionState("open");
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(event.data);
|
|
||||||
if (message.type === "state") {
|
|
||||||
setSession(message.session as SessionSnapshot);
|
|
||||||
}
|
|
||||||
if (message.type === "error") {
|
|
||||||
setError(message.message);
|
|
||||||
}
|
|
||||||
if (message.type === "takeover_approved") {
|
|
||||||
const assignedId = message.assignedPlayerId as string;
|
|
||||||
setPlayerId(assignedId);
|
|
||||||
if (sessionId && sessionCode) {
|
|
||||||
writeStoredSession({
|
|
||||||
sessionId,
|
|
||||||
sessionCode,
|
|
||||||
playerId: assignedId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setError(tStatic("error.parseResponse"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => setConnectionState("error");
|
|
||||||
ws.onclose = (event) => {
|
|
||||||
setConnectionState("error");
|
|
||||||
const reason = typeof event?.reason === "string" ? event.reason : "";
|
|
||||||
if (event?.code === 1008 && /session not found|player not found/i.test(reason)) {
|
|
||||||
resetSession();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pingTimer = setInterval(() => {
|
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(JSON.stringify({ type: "ping", sessionId, playerId }));
|
|
||||||
}
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(pingTimer);
|
|
||||||
ws.close();
|
|
||||||
};
|
|
||||||
}, [sessionId, playerId]);
|
|
||||||
|
|
||||||
async function requestTakeover(
|
async function requestTakeover(
|
||||||
dummyId: string,
|
dummyId: string,
|
||||||
overrideSessionId?: string,
|
overrideSessionId?: string,
|
||||||
|
|
@ -214,30 +513,43 @@ 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);
|
||||||
const response = await fetch(`${getApiBaseUrl()}/api/session`, {
|
try {
|
||||||
method: "POST",
|
const response = await fetch(`${getApiBaseUrl()}/api/session`, {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "POST",
|
||||||
body: JSON.stringify({ bankerName }),
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
body: JSON.stringify({ bankerName }),
|
||||||
if (!response.ok) {
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(tStatic("error.createSession"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as JoinResponse;
|
||||||
|
setSessionId(data.sessionId);
|
||||||
|
setSessionCode(data.sessionCode);
|
||||||
|
setPlayerId(data.playerId);
|
||||||
|
await writeStoredSession({
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
sessionCode: data.sessionCode,
|
||||||
|
playerId: data.playerId,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
setError(tStatic("error.createSession"));
|
setError(tStatic("error.createSession"));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const data = (await response.json()) as JoinResponse;
|
|
||||||
setSessionId(data.sessionId);
|
|
||||||
setSessionCode(data.sessionCode);
|
|
||||||
setPlayerId(data.playerId);
|
|
||||||
await writeStoredSession({
|
|
||||||
sessionId: data.sessionId,
|
|
||||||
sessionCode: data.sessionCode,
|
|
||||||
playerId: data.playerId,
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function joinSession(code: string, name: string) {
|
async function joinSession(code: string, name: string) {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setScreenshot(null);
|
||||||
|
setSessionId("");
|
||||||
|
setSessionCode("");
|
||||||
|
setPlayerId("");
|
||||||
setSession(null);
|
setSession(null);
|
||||||
if (!code) {
|
if (!code) {
|
||||||
setError(tStatic("entry.alert.enterCode"));
|
setError(tStatic("entry.alert.enterCode"));
|
||||||
|
|
@ -249,25 +561,30 @@ export function useSessionManager() {
|
||||||
? storedNow.playerId
|
? storedNow.playerId
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/join`, {
|
try {
|
||||||
method: "POST",
|
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/join`, {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "POST",
|
||||||
body: JSON.stringify({ name, playerId: reusePlayerId }),
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
body: JSON.stringify({ name, playerId: reusePlayerId }),
|
||||||
if (!response.ok) {
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(tStatic("error.joinSession"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as JoinResponse;
|
||||||
|
setSessionId(data.sessionId);
|
||||||
|
setSessionCode(data.sessionCode);
|
||||||
|
setPlayerId(data.playerId);
|
||||||
|
await writeStoredSession({
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
sessionCode: data.sessionCode,
|
||||||
|
playerId: data.playerId,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
setError(tStatic("error.joinSession"));
|
setError(tStatic("error.joinSession"));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const data = (await response.json()) as JoinResponse;
|
|
||||||
setSessionId(data.sessionId);
|
|
||||||
setSessionCode(data.sessionCode);
|
|
||||||
setPlayerId(data.playerId);
|
|
||||||
await writeStoredSession({
|
|
||||||
sessionId: data.sessionId,
|
|
||||||
sessionCode: data.sessionCode,
|
|
||||||
playerId: data.playerId,
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestTakeoverToken(
|
async function requestTakeoverToken(
|
||||||
|
|
@ -304,6 +621,10 @@ export function useSessionManager() {
|
||||||
|
|
||||||
async function claimTakeover(code: string, token: string) {
|
async function claimTakeover(code: string, token: string) {
|
||||||
try {
|
try {
|
||||||
|
setScreenshot(null);
|
||||||
|
setSessionId("");
|
||||||
|
setSessionCode("");
|
||||||
|
setPlayerId("");
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${getApiBaseUrl()}/api/session/${code}/takeover-claim`,
|
`${getApiBaseUrl()}/api/session/${code}/takeover-claim`,
|
||||||
{
|
{
|
||||||
|
|
@ -339,23 +660,42 @@ export function useSessionManager() {
|
||||||
|
|
||||||
async function fetchSessionPreview(code: string): Promise<SessionPreview | null> {
|
async function fetchSessionPreview(code: string): Promise<SessionPreview | null> {
|
||||||
if (!code) return null;
|
if (!code) return null;
|
||||||
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`);
|
try {
|
||||||
if (!response.ok) {
|
const response = await fetch(`${getApiBaseUrl()}/api/session/${code}/info`);
|
||||||
|
if (!response.ok) {
|
||||||
|
setError(tStatic("error.loadSessionInfo"));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (await response.json()) as SessionPreview;
|
||||||
|
} catch {
|
||||||
setError(tStatic("error.loadSessionInfo"));
|
setError(tStatic("error.loadSessionInfo"));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (await response.json()) as SessionPreview;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendMessage(payload: Record<string, unknown>) {
|
function sendMessage(payload: Record<string, unknown>) {
|
||||||
|
if (screenshotRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||||
setError(tStatic("error.connectionNotReady"));
|
retryConnection();
|
||||||
|
setError(
|
||||||
|
connectionState === "reconnecting" || connectionState === "connecting"
|
||||||
|
? tStatic("error.reconnecting")
|
||||||
|
: tStatic("error.connectionNotReady"),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
wsRef.current.send(JSON.stringify(payload));
|
wsRef.current.send(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetSession() {
|
async function resetSession() {
|
||||||
|
suppressReconnectRef.current = true;
|
||||||
|
teardownConnection();
|
||||||
|
reconnectAttemptRef.current = 0;
|
||||||
|
setReconnectAttempt(0);
|
||||||
|
lastActivityAtRef.current = null;
|
||||||
|
setLastActivityAt(null);
|
||||||
try {
|
try {
|
||||||
await clearStoredSession();
|
await clearStoredSession();
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -365,20 +705,14 @@ export function useSessionManager() {
|
||||||
setSessionCode("");
|
setSessionCode("");
|
||||||
setPlayerId("");
|
setPlayerId("");
|
||||||
setSession(null);
|
setSession(null);
|
||||||
|
setScreenshot(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setConnectionState("idle");
|
setConnectionState("idle");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function leaveSession() {
|
async function leaveSession() {
|
||||||
const ws = wsRef.current;
|
suppressReconnectRef.current = true;
|
||||||
wsRef.current = null;
|
teardownConnection();
|
||||||
if (ws && ws.readyState !== WebSocket.CLOSED) {
|
|
||||||
try {
|
|
||||||
ws.close();
|
|
||||||
} catch {
|
|
||||||
// Ignore failures while closing the socket.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await resetSession();
|
await resetSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -392,9 +726,10 @@ export function useSessionManager() {
|
||||||
session,
|
session,
|
||||||
me,
|
me,
|
||||||
isBanker,
|
isBanker,
|
||||||
tick,
|
|
||||||
error,
|
error,
|
||||||
connectionState,
|
connectionState,
|
||||||
|
reconnectAttempt,
|
||||||
|
lastActivityAt,
|
||||||
setError,
|
setError,
|
||||||
createSession,
|
createSession,
|
||||||
joinSession,
|
joinSession,
|
||||||
|
|
@ -404,9 +739,12 @@ export function useSessionManager() {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
resetSession,
|
resetSession,
|
||||||
leaveSession,
|
leaveSession,
|
||||||
|
retryConnection,
|
||||||
setSessionId,
|
setSessionId,
|
||||||
setPlayerId,
|
setPlayerId,
|
||||||
setSession,
|
setSession,
|
||||||
requestTakeover,
|
requestTakeover,
|
||||||
|
activateScreenshotScene,
|
||||||
|
screenshot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,9 @@ export type ServerMessage =
|
||||||
type: "state";
|
type: "state";
|
||||||
session: SessionSnapshot;
|
session: SessionSnapshot;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "pong";
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "error";
|
type: "error";
|
||||||
message: string;
|
message: string;
|
||||||
|
|
|
||||||
102
server/websocket.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { afterEach, expect, test } from "bun:test";
|
||||||
|
import { createSession, removeSession } from "./store";
|
||||||
|
import { joinSession } from "./domain";
|
||||||
|
import {
|
||||||
|
STALE_SOCKET_TIMEOUT_MS,
|
||||||
|
handleSocketMessage,
|
||||||
|
reapStaleSockets,
|
||||||
|
registerSocket,
|
||||||
|
resetWebsocketStateForTests,
|
||||||
|
unregisterSocket,
|
||||||
|
} from "./websocket";
|
||||||
|
|
||||||
|
const OPEN = 1;
|
||||||
|
const CLOSED = 3;
|
||||||
|
|
||||||
|
type FakeSocket = {
|
||||||
|
readyState: number;
|
||||||
|
sent: string[];
|
||||||
|
closes: Array<{ code?: number; reason?: string }>;
|
||||||
|
send: (payload: string) => void;
|
||||||
|
close: (code?: number, reason?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createFakeSocket(): FakeSocket {
|
||||||
|
return {
|
||||||
|
readyState: OPEN,
|
||||||
|
sent: [],
|
||||||
|
closes: [],
|
||||||
|
send(payload: string) {
|
||||||
|
this.sent.push(payload);
|
||||||
|
},
|
||||||
|
close(code?: number, reason?: string) {
|
||||||
|
this.closes.push({ code, reason });
|
||||||
|
this.readyState = CLOSED;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
resetWebsocketStateForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overlapping sockets do not disconnect a player until the last socket closes", () => {
|
||||||
|
const { session } = createSession("Banker");
|
||||||
|
const player = joinSession(session, "Jules");
|
||||||
|
const firstSocket = createFakeSocket();
|
||||||
|
const secondSocket = createFakeSocket();
|
||||||
|
|
||||||
|
registerSocket(firstSocket as unknown as WebSocket, session.id, player.id);
|
||||||
|
registerSocket(secondSocket as unknown as WebSocket, session.id, player.id);
|
||||||
|
|
||||||
|
unregisterSocket(firstSocket as unknown as WebSocket);
|
||||||
|
expect(session.players.get(player.id)?.connected).toBe(true);
|
||||||
|
expect(session.players.get(player.id)?.isDummy).toBe(false);
|
||||||
|
|
||||||
|
unregisterSocket(secondSocket as unknown as WebSocket);
|
||||||
|
expect(session.players.get(player.id)?.connected).toBe(false);
|
||||||
|
expect(session.players.get(player.id)?.isDummy).toBe(true);
|
||||||
|
|
||||||
|
removeSession(session.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stale sockets are reaped and disconnect the player", () => {
|
||||||
|
const { session } = createSession("Banker");
|
||||||
|
const player = joinSession(session, "Rosa");
|
||||||
|
const socket = createFakeSocket();
|
||||||
|
|
||||||
|
registerSocket(socket as unknown as WebSocket, session.id, player.id);
|
||||||
|
reapStaleSockets(Date.now() + STALE_SOCKET_TIMEOUT_MS + 1);
|
||||||
|
|
||||||
|
expect(socket.closes[0]).toEqual({ code: 4000, reason: "Connection stale" });
|
||||||
|
expect(session.players.get(player.id)?.connected).toBe(false);
|
||||||
|
expect(session.players.get(player.id)?.isDummy).toBe(true);
|
||||||
|
|
||||||
|
removeSession(session.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid session registration closes the socket with a terminal code", () => {
|
||||||
|
const socket = createFakeSocket();
|
||||||
|
|
||||||
|
registerSocket(socket as unknown as WebSocket, "missing-session", "missing-player");
|
||||||
|
|
||||||
|
expect(socket.closes[0]).toEqual({ code: 1008, reason: "Session not found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ping messages update liveness and emit a pong", () => {
|
||||||
|
const { session } = createSession("Banker");
|
||||||
|
const player = joinSession(session, "Nina");
|
||||||
|
const socket = createFakeSocket();
|
||||||
|
|
||||||
|
registerSocket(socket as unknown as WebSocket, session.id, player.id);
|
||||||
|
socket.sent = [];
|
||||||
|
|
||||||
|
handleSocketMessage(
|
||||||
|
socket as unknown as WebSocket,
|
||||||
|
JSON.stringify({ type: "ping", sessionId: session.id, playerId: player.id }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(socket.sent.some((entry) => entry.includes('"type":"pong"'))).toBe(true);
|
||||||
|
|
||||||
|
removeSession(session.id);
|
||||||
|
});
|
||||||
|
|
@ -20,10 +20,24 @@ import { getSession, removeSession } from "./store";
|
||||||
import { now } from "./util";
|
import { now } from "./util";
|
||||||
import { notifyChat, notifyTransaction } from "./notifications";
|
import { notifyChat, notifyTransaction } from "./notifications";
|
||||||
|
|
||||||
|
export const STALE_SOCKET_TIMEOUT_MS = 45_000;
|
||||||
|
const STALE_SOCKET_REAP_INTERVAL_MS = 10_000;
|
||||||
|
|
||||||
|
type SocketMeta = {
|
||||||
|
sessionId: string;
|
||||||
|
playerId: string;
|
||||||
|
lastSeenAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
const socketsBySession = new Map<string, Set<WebSocket>>();
|
const socketsBySession = new Map<string, Set<WebSocket>>();
|
||||||
const metaBySocket = new WeakMap<WebSocket, { sessionId: string; playerId: string }>();
|
const socketsByPlayer = new Map<string, Set<WebSocket>>();
|
||||||
|
let metaBySocket = new WeakMap<WebSocket, SocketMeta>();
|
||||||
const testTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const testTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
function playerSocketKey(sessionId: string, playerId: string): string {
|
||||||
|
return `${sessionId}:${playerId}`;
|
||||||
|
}
|
||||||
|
|
||||||
function randomInt(min: number, max: number): number {
|
function randomInt(min: number, max: number): number {
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +69,7 @@ function runTestTransfer(sessionId: string): void {
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
while (attempts < 5) {
|
while (attempts < 5) {
|
||||||
const from = players[randomInt(0, players.length - 1)];
|
const from = players[randomInt(0, players.length - 1)];
|
||||||
let to = players[randomInt(0, players.length - 1)];
|
const to = players[randomInt(0, players.length - 1)];
|
||||||
if (to.id === from.id) {
|
if (to.id === from.id) {
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -99,6 +113,54 @@ function getSessionSockets(sessionId: string): Set<WebSocket> {
|
||||||
return set;
|
return set;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPlayerSockets(sessionId: string, playerId: string): Set<WebSocket> {
|
||||||
|
const key = playerSocketKey(sessionId, playerId);
|
||||||
|
let set = socketsByPlayer.get(key);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
socketsByPlayer.set(key, set);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSocketFromTracking(ws: WebSocket, meta: SocketMeta): {
|
||||||
|
sessionSocketCount: number;
|
||||||
|
playerSocketCount: number;
|
||||||
|
} {
|
||||||
|
const sessionSockets = socketsBySession.get(meta.sessionId);
|
||||||
|
if (sessionSockets) {
|
||||||
|
sessionSockets.delete(ws);
|
||||||
|
if (sessionSockets.size === 0) {
|
||||||
|
socketsBySession.delete(meta.sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerKey = playerSocketKey(meta.sessionId, meta.playerId);
|
||||||
|
const playerSockets = socketsByPlayer.get(playerKey);
|
||||||
|
if (playerSockets) {
|
||||||
|
playerSockets.delete(ws);
|
||||||
|
if (playerSockets.size === 0) {
|
||||||
|
socketsByPlayer.delete(playerKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metaBySocket.delete(ws);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionSocketCount: sessionSockets?.size ?? 0,
|
||||||
|
playerSocketCount: playerSockets?.size ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function touchSocket(ws: WebSocket): SocketMeta | null {
|
||||||
|
const meta = metaBySocket.get(ws);
|
||||||
|
if (!meta) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
meta.lastSeenAt = now();
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
export function registerSocket(ws: WebSocket, sessionId: string, playerId: string): void {
|
export function registerSocket(ws: WebSocket, sessionId: string, playerId: string): void {
|
||||||
const session = getSession(sessionId);
|
const session = getSession(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|
@ -110,12 +172,15 @@ export function registerSocket(ws: WebSocket, sessionId: string, playerId: strin
|
||||||
ws.close(1008, "Player not found");
|
ws.close(1008, "Player not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const meta: SocketMeta = { sessionId, playerId, lastSeenAt: now() };
|
||||||
|
metaBySocket.set(ws, meta);
|
||||||
|
getSessionSockets(sessionId).add(ws);
|
||||||
|
getPlayerSockets(sessionId, playerId).add(ws);
|
||||||
|
|
||||||
player.connected = true;
|
player.connected = true;
|
||||||
player.isDummy = false;
|
player.isDummy = false;
|
||||||
player.lastActiveAt = now();
|
player.lastActiveAt = meta.lastSeenAt;
|
||||||
|
|
||||||
metaBySocket.set(ws, { sessionId, playerId });
|
|
||||||
getSessionSockets(sessionId).add(ws);
|
|
||||||
|
|
||||||
sendStateToSession(session);
|
sendStateToSession(session);
|
||||||
}
|
}
|
||||||
|
|
@ -125,32 +190,64 @@ export function unregisterSocket(ws: WebSocket): void {
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessionId, playerId } = meta;
|
const { sessionId, playerId } = meta;
|
||||||
|
const { sessionSocketCount, playerSocketCount } = deleteSocketFromTracking(ws, meta);
|
||||||
const session = getSession(sessionId);
|
const session = getSession(sessionId);
|
||||||
if (session) {
|
|
||||||
|
if (session && playerSocketCount === 0) {
|
||||||
disconnectPlayer(session, playerId);
|
disconnectPlayer(session, playerId);
|
||||||
sendStateToSession(session);
|
sendStateToSession(session);
|
||||||
}
|
}
|
||||||
const set = socketsBySession.get(sessionId);
|
|
||||||
if (set) {
|
if (sessionSocketCount === 0 && session?.isTest) {
|
||||||
set.delete(ws);
|
stopTestSimulation(sessionId);
|
||||||
if (set.size === 0) {
|
removeSession(sessionId);
|
||||||
socketsBySession.delete(sessionId);
|
|
||||||
if (session?.isTest) {
|
|
||||||
stopTestSimulation(sessionId);
|
|
||||||
removeSession(sessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
metaBySocket.delete(ws);
|
}
|
||||||
|
|
||||||
|
export function reapStaleSockets(referenceNow = now()): void {
|
||||||
|
const staleSockets: WebSocket[] = [];
|
||||||
|
|
||||||
|
socketsBySession.forEach((sessionSockets) => {
|
||||||
|
sessionSockets.forEach((socket) => {
|
||||||
|
const meta = metaBySocket.get(socket);
|
||||||
|
if (!meta) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (referenceNow - meta.lastSeenAt <= STALE_SOCKET_TIMEOUT_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
staleSockets.push(socket);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
staleSockets.forEach((socket) => {
|
||||||
|
try {
|
||||||
|
socket.close(4000, "Connection stale");
|
||||||
|
} catch {
|
||||||
|
// Ignore close failures.
|
||||||
|
}
|
||||||
|
unregisterSocket(socket);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetWebsocketStateForTests(): void {
|
||||||
|
socketsBySession.clear();
|
||||||
|
socketsByPlayer.clear();
|
||||||
|
testTimers.forEach((timer) => clearTimeout(timer));
|
||||||
|
testTimers.clear();
|
||||||
|
metaBySocket = new WeakMap<WebSocket, SocketMeta>();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): void {
|
export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): void {
|
||||||
|
touchSocket(ws);
|
||||||
|
|
||||||
const messageText = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
|
const messageText = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
|
||||||
let parsed: ClientMessage;
|
let parsed: ClientMessage;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(messageText) as ClientMessage;
|
parsed = JSON.parse(messageText) as ClientMessage;
|
||||||
} catch (error) {
|
} catch {
|
||||||
send(ws, { type: "error", message: "Invalid message" });
|
send(ws, { type: "error", message: "Invalid message" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -162,7 +259,7 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
handleMessage(session, parsed);
|
handleMessage(ws, session, parsed);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof DomainError
|
error instanceof DomainError
|
||||||
|
|
@ -175,25 +272,24 @@ export function handleSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): v
|
||||||
sendStateToSession(session);
|
sendStateToSession(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessage(session: Session, message: ClientMessage): void {
|
function handleMessage(ws: WebSocket, session: Session, message: ClientMessage): void {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case "chat_send": {
|
case "chat_send": {
|
||||||
const chat = addChatMessage(session, message.playerId, message.body, message.groupId);
|
const chat = addChatMessage(session, message.playerId, message.body, message.groupId);
|
||||||
notifyChat(session, chat);
|
notifyChat(session, chat);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case "transfer":
|
case "transfer": {
|
||||||
{
|
const transaction = transfer(
|
||||||
const transaction = transfer(
|
session,
|
||||||
session,
|
message.playerId,
|
||||||
message.playerId,
|
message.toPlayerId,
|
||||||
message.toPlayerId,
|
message.amount,
|
||||||
message.amount,
|
message.note,
|
||||||
message.note,
|
);
|
||||||
);
|
notifyTransaction(session, transaction);
|
||||||
notifyTransaction(session, transaction);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
case "banker_adjust": {
|
case "banker_adjust": {
|
||||||
const transaction = bankerAdjust(
|
const transaction = bankerAdjust(
|
||||||
session,
|
session,
|
||||||
|
|
@ -247,6 +343,7 @@ function handleMessage(session: Session, message: ClientMessage): void {
|
||||||
}
|
}
|
||||||
case "ping":
|
case "ping":
|
||||||
touchPlayer(session, message.playerId);
|
touchPlayer(session, message.playerId);
|
||||||
|
send(ws, { type: "pong" });
|
||||||
return;
|
return;
|
||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
|
|
@ -295,6 +392,13 @@ function notifyTakeoverApproval(
|
||||||
if (meta.playerId === requesterId) {
|
if (meta.playerId === requesterId) {
|
||||||
send(socket, { type: "takeover_approved", assignedPlayerId: assignedId });
|
send(socket, { type: "takeover_approved", assignedPlayerId: assignedId });
|
||||||
meta.playerId = assignedId;
|
meta.playerId = assignedId;
|
||||||
|
deleteSocketFromTracking(socket, {
|
||||||
|
...meta,
|
||||||
|
playerId: requesterId,
|
||||||
|
});
|
||||||
|
getSessionSockets(sessionId).add(socket);
|
||||||
|
getPlayerSockets(sessionId, assignedId).add(socket);
|
||||||
|
metaBySocket.set(socket, meta);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -305,3 +409,7 @@ function send(ws: WebSocket, message: ServerMessage): void {
|
||||||
}
|
}
|
||||||
ws.send(JSON.stringify(message));
|
ws.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
reapStaleSockets();
|
||||||
|
}, STALE_SOCKET_REAP_INTERVAL_MS);
|
||||||
|
|
|
||||||