diff --git a/mobile/README.md b/mobile/README.md index 3f9fecb..0c988f2 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -1,4 +1,4 @@ -# Negopoly Mobile (React Native) +# Crédit Mabligop Mobile (React Native) ## Development server Set the dev API base URL to your machine IP so the app can reach the Bun server. @@ -15,3 +15,21 @@ The app will use `https://negopoly.fr` automatically in production builds. ``` npm run dev ``` + +## App Store Screenshots +Generate the player-facing iOS screenshots with: + +```sh +npm run screenshots:ios +``` + +The script builds the iOS app in `Release` for the simulator, opens the app with special deep links for the player flows only, and writes PNGs into `../ScreenShots/`. + +It requires Xcode with the iOS Simulator platform/runtime installed and simulator devices matching the default targets (`iPhone 17 Pro Max` and `iPad Air 13-inch`). + +Optional flags: + +```sh +npm run screenshots:ios -- --skip-build +npm run screenshots:ios -- --output ../MyScreenShots +``` diff --git a/mobile/app.json b/mobile/app.json index 1757c91..a1330bc 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -1,6 +1,6 @@ { "expo": { - "name": "Negopoly Companion", + "name": "Crédit Mabligop", "slug": "negopoly-companion", "version": "1.0.0", "platforms": ["ios", "android"], @@ -12,20 +12,21 @@ "splash": { "image": "./assets/splash-icon.png", "resizeMode": "contain", - "backgroundColor": "#ffffff" + "backgroundColor": "#f5efe6" }, "ios": { "bundleIdentifier": "fr.negopoly.app", "supportsTablet": true, "appleTeamId": "VD9WQ6BYX2", - "associatedDomains": ["applinks:negopoly.fr"] + "associatedDomains": ["applinks:negopoly.fr"], + "icon": "./assets/AppIcon.icon" }, "android": { "package": "fr.negopoly.app", "googleServicesFile": "./google-services.json", "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", - "backgroundColor": "#ffffff" + "backgroundColor": "#f5efe6" }, "edgeToEdgeEnabled": true, "predictiveBackGestureEnabled": false, diff --git a/mobile/assets/AppIcon.icon/Assets/CréditMabligopTest1.png b/mobile/assets/AppIcon.icon/Assets/CréditMabligopTest1.png new file mode 100644 index 0000000..47a8cfc Binary files /dev/null and b/mobile/assets/AppIcon.icon/Assets/CréditMabligopTest1.png differ diff --git a/mobile/assets/AppIcon.icon/Assets/LBTRDlogo.png b/mobile/assets/AppIcon.icon/Assets/LBTRDlogo.png new file mode 100644 index 0000000..20cc826 Binary files /dev/null and b/mobile/assets/AppIcon.icon/Assets/LBTRDlogo.png differ diff --git a/mobile/assets/AppIcon.icon/icon.json b/mobile/assets/AppIcon.icon/icon.json new file mode 100644 index 0000000..c38c1d1 --- /dev/null +++ b/mobile/assets/AppIcon.icon/icon.json @@ -0,0 +1,95 @@ +{ + "fill" : { + "automatic-gradient" : "display-p3:0.96783,0.94809,0.82815,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "fill-specializations" : [ + { + "appearance" : "dark", + "value" : { + "automatic-gradient" : "display-p3:0.97626,0.96665,1.00000,1.00000" + } + }, + { + "appearance" : "tinted", + "value" : { + "solid" : "srgb:1.00000,1.00000,1.00000,1.00000" + } + } + ], + "glass" : false, + "hidden" : false, + "image-name" : "LBTRDlogo.png", + "name" : "LBTRDlogo", + "position" : { + "scale" : 0.08, + "translation-in-points" : [ + 357.9426799115098, + -350.3083527939079 + ] + } + }, + { + "fill-specializations" : [ + { + "value" : { + "automatic-gradient" : "display-p3:0.68235,0.52549,0.14510,1.00000" + } + }, + { + "appearance" : "dark", + "value" : { + "linear-gradient" : [ + "display-p3:0.96863,0.93725,0.54118,1.00000", + "display-p3:0.68235,0.52549,0.14510,1.00000" + ], + "orientation" : { + "start" : { + "x" : 0.7841231069456777, + "y" : 0.910632131195883 + }, + "stop" : { + "x" : 0.3724596068255535, + "y" : 0.3659584826368382 + } + } + } + }, + { + "appearance" : "tinted", + "value" : { + "solid" : "display-p3:0.97310,1.00000,0.94620,1.00000" + } + } + ], + "image-name" : "CréditMabligopTest1.png", + "name" : "CréditMabligopTest1", + "position" : { + "scale" : 1.5, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/mobile/assets/CréditMabligopIcon.svg b/mobile/assets/CréditMabligopIcon.svg new file mode 100644 index 0000000..c877803 --- /dev/null +++ b/mobile/assets/CréditMabligopIcon.svg @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/mobile/assets/adaptive-icon.png b/mobile/assets/adaptive-icon.png index a7743a5..b090618 100644 Binary files a/mobile/assets/adaptive-icon.png and b/mobile/assets/adaptive-icon.png differ diff --git a/mobile/assets/favicon.png b/mobile/assets/favicon.png index e75f697..da83d39 100644 Binary files a/mobile/assets/favicon.png and b/mobile/assets/favicon.png differ diff --git a/mobile/assets/icon.png b/mobile/assets/icon.png index a7743a5..b090618 100644 Binary files a/mobile/assets/icon.png and b/mobile/assets/icon.png differ diff --git a/mobile/assets/splash-icon.png b/mobile/assets/splash-icon.png index a7743a5..166b64c 100644 Binary files a/mobile/assets/splash-icon.png and b/mobile/assets/splash-icon.png differ diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 044fe34..1c4772c 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -22,7 +22,8 @@ "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", "react-native-safe-area-context": "~5.6.0", - "react-native-screens": "~4.16.0" + "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1" }, "devDependencies": { "@types/react": "~19.1.0", @@ -3392,6 +3393,12 @@ "node": ">=0.6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -3907,6 +3914,56 @@ "node": ">=8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4041,6 +4098,61 @@ "node": ">=8" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -4109,6 +4221,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-editor": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", @@ -6009,6 +6133,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -6601,6 +6731,18 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -7347,6 +7489,21 @@ "react-native": "*" } }, + "node_modules/react-native-svg": { + "version": "15.12.1", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz", + "integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native/node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", diff --git a/mobile/package.json b/mobile/package.json index eedf290..a1073c9 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -6,7 +6,8 @@ "start": "expo start", "dev": "sh ./scripts/start-dev.sh", "android": "expo run:android", - "ios": "expo run:ios" + "ios": "expo run:ios", + "screenshots:ios": "node ./scripts/generate-screenshots.mjs" }, "dependencies": { "@react-native-async-storage/async-storage": "2.2.0", @@ -23,7 +24,8 @@ "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", "react-native-safe-area-context": "~5.6.0", - "react-native-screens": "~4.16.0" + "react-native-screens": "~4.16.0", + "react-native-svg": "15.12.1" }, "devDependencies": { "@types/react": "~19.1.0", diff --git a/mobile/scripts/generate-screenshots.mjs b/mobile/scripts/generate-screenshots.mjs new file mode 100644 index 0000000..e1a8d0c --- /dev/null +++ b/mobile/scripts/generate-screenshots.mjs @@ -0,0 +1,226 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const mobileRoot = resolve(__dirname, ".."); +const derivedDataPath = resolve(mobileRoot, "ios", "build", "screenshots"); +const appPath = resolve( + derivedDataPath, + "Build", + "Products", + "Release-iphonesimulator", + "mobile.app", +); +const defaultOutputRoot = resolve(mobileRoot, "..", "ScreenShots"); +const bundleId = "fr.negopoly.app"; +const launchWaitMs = 4500; +const sceneWaitMs = 2200; + +const targetDevices = [ + { matchName: "iPhone 17 Pro Max", outputFolder: "iPhone 17 Pro Max" }, + { matchName: "iPad Air 13-inch", outputFolder: "iPad Air 13-inch" }, +]; + +const scenes = [ + { slug: "start", fileName: "Start.png" }, + { slug: "lobby", fileName: "Lobby.png" }, + { slug: "home", fileName: "Home.png" }, + { slug: "transfers", fileName: "Transfers.png" }, + { slug: "chat", fileName: "Chat.png" }, +]; + +function parseArgs(argv) { + const options = { + skipBuild: false, + outputRoot: defaultOutputRoot, + }; + + for (let index = 0; index < argv.length; index += 1) { + const current = argv[index]; + if (current === "--skip-build") { + options.skipBuild = true; + continue; + } + if (current === "--output") { + const nextValue = argv[index + 1]; + if (!nextValue) { + throw new Error("Missing value after --output"); + } + options.outputRoot = resolve(mobileRoot, nextValue); + index += 1; + continue; + } + throw new Error(`Unknown argument: ${current}`); + } + + return options; +} + +function run(command, args, options = {}) { + const label = `${command} ${args.join(" ")}`; + console.log(`\n> ${label}`); + return execFileSync(command, args, { + cwd: mobileRoot, + encoding: "utf8", + stdio: options.stdio ?? "pipe", + }); +} + +function runQuiet(command, args) { + try { + return run(command, args); + } catch { + return ""; + } +} + +function sleep(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function listAvailableDevices() { + const output = run("xcrun", ["simctl", "list", "devices", "available", "--json"]); + const payload = JSON.parse(output); + return Object.entries(payload.devices).flatMap(([runtime, devices]) => + devices.map((device) => ({ + runtime, + name: device.name, + udid: device.udid, + state: device.state, + isAvailable: device.isAvailable, + })), + ); +} + +function resolveDevice(matchName, devices) { + const matches = devices + .filter((device) => device.isAvailable) + .filter((device) => device.name === matchName || device.name.startsWith(matchName)) + .sort((left, right) => { + if (left.state === "Booted" && right.state !== "Booted") return -1; + if (left.state !== "Booted" && right.state === "Booted") return 1; + return right.runtime.localeCompare(left.runtime); + }); + + if (matches.length === 0) { + throw new Error(`No available simulator found for "${matchName}"`); + } + + return matches[0]; +} + +function ensureBooted(udid) { + runQuiet("xcrun", ["simctl", "boot", udid]); + run("xcrun", ["simctl", "bootstatus", udid, "-b"]); +} + +function setSimulatorAppearance(udid) { + runQuiet("xcrun", ["simctl", "ui", udid, "appearance", "light"]); + runQuiet("xcrun", ["simctl", "status_bar", udid, "clear"]); + runQuiet("xcrun", [ + "simctl", + "status_bar", + udid, + "override", + "--time", + "9:41", + "--dataNetwork", + "wifi", + "--wifiBars", + "3", + "--batteryState", + "charged", + "--batteryLevel", + "100", + ]); +} + +function buildReleaseApp(buildDestinationUdid) { + run( + "xcodebuild", + [ + "-workspace", + "ios/mobile.xcworkspace", + "-scheme", + "mobile", + "-configuration", + "Release", + "-destination", + `platform=iOS Simulator,id=${buildDestinationUdid}`, + "-derivedDataPath", + derivedDataPath, + "build", + ], + { stdio: "inherit" }, + ); + + if (!existsSync(appPath)) { + throw new Error(`Built app not found at ${appPath}`); + } +} + +function installApp(udid) { + runQuiet("xcrun", ["simctl", "terminate", udid, bundleId]); + runQuiet("xcrun", ["simctl", "uninstall", udid, bundleId]); + run("xcrun", ["simctl", "install", udid, appPath]); +} + +function openScene(udid, scene, waitMs) { + run("xcrun", ["simctl", "openurl", udid, `negopoly://screenshot/${scene}`]); + sleep(waitMs); +} + +function captureScene(udid, destinationPath) { + run("xcrun", ["simctl", "io", udid, "screenshot", destinationPath]); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + const devices = listAvailableDevices(); + const resolvedTargets = targetDevices.map((target) => ({ + ...target, + simulator: resolveDevice(target.matchName, devices), + })); + + if (!options.skipBuild) { + buildReleaseApp(resolvedTargets[0].simulator.udid); + } else if (!existsSync(appPath)) { + throw new Error(`Cannot use --skip-build because ${appPath} does not exist`); + } + + mkdirSync(options.outputRoot, { recursive: true }); + + for (const target of resolvedTargets) { + const { simulator } = target; + const deviceOutputDir = join(options.outputRoot, target.outputFolder); + + console.log(`\n## ${target.outputFolder} (${simulator.name})`); + mkdirSync(deviceOutputDir, { recursive: true }); + ensureBooted(simulator.udid); + setSimulatorAppearance(simulator.udid); + installApp(simulator.udid); + + for (const [index, scene] of scenes.entries()) { + const targetPath = join(deviceOutputDir, scene.fileName); + openScene(simulator.udid, scene.slug, index === 0 ? launchWaitMs : sceneWaitMs); + captureScene(simulator.udid, targetPath); + console.log(`Saved ${targetPath}`); + } + + runQuiet("xcrun", ["simctl", "terminate", simulator.udid, bundleId]); + runQuiet("xcrun", ["simctl", "status_bar", simulator.udid, "clear"]); + } +} + +try { + main(); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`\nScreenshot generation failed: ${message}`); + process.exit(1); +} diff --git a/mobile/src/App.tsx b/mobile/src/App.tsx index 49948aa..f77e172 100644 --- a/mobile/src/App.tsx +++ b/mobile/src/App.tsx @@ -10,37 +10,51 @@ import { SafeAreaProvider } from "react-native-safe-area-context"; import { StatusBar } from "expo-status-bar"; import * as Notifications from "expo-notifications"; import AppNavigator from "./navigation/AppNavigator"; +import { normalizeScreenshotScene } from "./dev/screenshot-fixtures"; import type { RootStackParamList } from "./navigation/types"; import { SessionProvider, useSession } from "./state/session-context"; import { getNavigationTheme, useTheme } from "./theme"; import { parseNotificationTarget, type NotificationTarget } from "./notifications"; import ConnectionBanner from "./components/ConnectionBanner"; -function extractGameId(url: string): string | null { +function getUrlSegments(url: string): string[] { try { const parsed = new URL(url); - const path = parsed.pathname || ""; + const pathSegments = (parsed.pathname || "") + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean); if (parsed.protocol === "https:" || parsed.protocol === "http:") { - const match = path.match(/^\/play\/?([^/]+)?/); - const id = match?.[1]?.trim(); - return id ? id : null; + return pathSegments; } - if (parsed.host === "play") { - const id = path.replace(/^\//, "").trim(); - return id ? id : null; + if (parsed.host) { + return [parsed.host, ...pathSegments]; } - const fallbackMatch = path.match(/^\/play\/?([^/]+)?/); - const fallbackId = fallbackMatch?.[1]?.trim(); - return fallbackId ? fallbackId : null; + return pathSegments; } catch { - return null; + return []; } } +function extractGameId(url: string): string | null { + const segments = getUrlSegments(url); + if (segments[0] !== "play") return null; + return segments[1] || null; +} + +function extractScreenshotScene(url: string) { + const segments = getUrlSegments(url); + if (segments[0] !== "screenshot") return null; + return normalizeScreenshotScene(segments[1]); +} + function logDeepLink(url: string) { if (!__DEV__) return; const gameId = extractGameId(url); - console.log(`[deep-link] url=${url} gameId=${gameId ?? "invalid"}`); + const scene = extractScreenshotScene(url); + console.log( + `[deep-link] url=${url} gameId=${gameId ?? "invalid"} screenshot=${scene ?? "none"}`, + ); } function RootNavigationGate() { @@ -50,15 +64,26 @@ function RootNavigationGate() { const lastTargetRef = useRef(null); const lastLinkRef = useRef(null); const pendingNotificationRef = useRef(null); + const pendingScreenshotRef = useRef(false); const lastNotificationIdRef = useRef(null); const theme = useTheme(); const navigationTheme = getNavigationTheme(theme); + const handleSpecialUrl = useCallback( + (url: string) => { + const scene = extractScreenshotScene(url); + if (!scene) return false; + pendingScreenshotRef.current = true; + manager.activateScreenshotScene(scene); + return true; + }, + [manager.activateScreenshotScene], + ); const linking = useMemo>( () => ({ prefixes: ["negopoly://", "https://negopoly.fr"], config: { screens: { - Entry: "play/:gameId", + AgencyJoin: "play/:gameId", }, }, getInitialURL: async () => { @@ -66,6 +91,9 @@ function RootNavigationGate() { if (url) { lastLinkRef.current = url; logDeepLink(url); + if (handleSpecialUrl(url)) { + return null; + } } return url; }, @@ -75,13 +103,14 @@ function RootNavigationGate() { if (lastLinkRef.current === url) return; lastLinkRef.current = url; logDeepLink(url); + if (handleSpecialUrl(url)) return; listener(url); }; const subscription = Linking.addEventListener("url", onReceiveURL); return () => subscription.remove(); }, }), - [], + [handleSpecialUrl], ); const processPendingNotification = useCallback(() => { @@ -120,6 +149,27 @@ function RootNavigationGate() { ); }, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]); + const processPendingScreenshot = useCallback(() => { + if (!pendingScreenshotRef.current) return; + if (!navReady || !navigationRef.isReady()) return; + const target = manager.screenshot?.navigationTarget; + if (!target) return; + + pendingScreenshotRef.current = false; + navigationRef.dispatch( + CommonActions.reset({ + index: 0, + routes: [ + { + name: target.root, + params: target.params, + }, + ], + }), + ); + lastTargetRef.current = target.root; + }, [manager.screenshot, navReady, navigationRef]); + const handleNotificationResponse = useCallback( (response: Notifications.NotificationResponse | null) => { if (!response) return; @@ -145,11 +195,25 @@ function RootNavigationGate() { ); useEffect(() => { + if (pendingScreenshotRef.current) { + return; + } if (!navReady || !navigationRef.isReady()) return; + const currentRoute = navigationRef.getCurrentRoute(); + const currentName = currentRoute?.name; + const inEntryFlow = + currentName === "EntryLanding" || + currentName === "AgencyJoin" || + currentName === "AgencyCreate"; + let target: keyof RootStackParamList; if (!manager.sessionId) { - target = "Entry"; + if (inEntryFlow) { + lastTargetRef.current = currentName ?? null; + return; + } + target = "EntryLanding"; } else if (!manager.session) { target = "Lobby"; } else if (manager.session.status === "lobby") { @@ -160,7 +224,6 @@ function RootNavigationGate() { target = "PlayerTabs"; } - const currentRoute = navigationRef.getCurrentRoute(); if (currentRoute?.name === target || lastTargetRef.current === target) { return; } @@ -179,6 +242,10 @@ function RootNavigationGate() { navigationRef, ]); + useEffect(() => { + processPendingScreenshot(); + }, [processPendingScreenshot]); + useEffect(() => { processPendingNotification(); }, [processPendingNotification]); diff --git a/mobile/src/components/BrandLockup.tsx b/mobile/src/components/BrandLockup.tsx new file mode 100644 index 0000000..9ca9b06 --- /dev/null +++ b/mobile/src/components/BrandLockup.tsx @@ -0,0 +1,104 @@ +import React, { useMemo } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import BrandMark from "./BrandMark"; +import { useTheme, type AppTheme } from "../theme"; + +type BrandLockupProps = { + subtitle?: string; + variant?: "hero" | "header"; + onDark?: boolean; +}; + +export default function BrandLockup({ + subtitle, + variant = "hero", + onDark = false, +}: BrandLockupProps) { + const theme = useTheme(); + const styles = useMemo(() => createStyles(theme, onDark), [theme, onDark]); + const isHero = variant === "hero"; + const markColor = onDark + ? theme.colors.brandText + : theme.dark + ? theme.colors.brandText + : theme.colors.brandAccent; + + return ( + + + + + + + Crédit Mabligop + + {subtitle ? ( + + {subtitle} + + ) : null} + + + ); +} + +const createStyles = (theme: AppTheme, onDark: boolean) => { + const titleColor = onDark ? theme.colors.brandText : theme.colors.text; + const subtitleColor = onDark ? theme.colors.brandTextMuted : theme.colors.textMuted; + + return StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "center", + }, + containerHero: { + gap: 14, + }, + containerHeader: { + gap: 10, + }, + markWrap: { + alignItems: "center", + justifyContent: "center", + }, + markWrapHero: { + width: 56, + height: 56, + borderRadius: 18, + backgroundColor: onDark ? theme.colors.brandSurfaceAlt : theme.colors.accentSurface, + }, + markWrapHeader: { + width: 36, + height: 36, + borderRadius: 12, + backgroundColor: onDark ? theme.colors.brandSurfaceAlt : theme.colors.accentSurface, + }, + copy: { + justifyContent: "center", + }, + title: { + fontWeight: "800", + color: titleColor, + }, + titleHero: { + fontSize: 24, + letterSpacing: -0.5, + }, + titleHeader: { + fontSize: 15, + letterSpacing: -0.2, + }, + subtitle: { + color: subtitleColor, + }, + subtitleHero: { + marginTop: 4, + fontSize: 12, + letterSpacing: 1.1, + textTransform: "uppercase", + }, + subtitleHeader: { + fontSize: 11, + }, + }); +}; diff --git a/mobile/src/components/BrandMark.tsx b/mobile/src/components/BrandMark.tsx new file mode 100644 index 0000000..140bd6f --- /dev/null +++ b/mobile/src/components/BrandMark.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import Svg, { Path, Polyline } from "react-native-svg"; +import { useTheme } from "../theme"; + +type BrandMarkProps = { + color?: string; + size?: number; +}; + +const VIEWBOX_WIDTH = 297.65; +const VIEWBOX_HEIGHT = 263.4; + +export default function BrandMark({ + color, + size = 34, +}: BrandMarkProps) { + const theme = useTheme(); + const stroke = color ?? (theme.dark ? theme.colors.brandText : theme.colors.brandAccent); + + return ( + + + + + ); +} diff --git a/mobile/src/dev/screenshot-fixtures.ts b/mobile/src/dev/screenshot-fixtures.ts new file mode 100644 index 0000000..20a8676 --- /dev/null +++ b/mobile/src/dev/screenshot-fixtures.ts @@ -0,0 +1,344 @@ +import type { SessionSnapshot } from "../shared/types"; + +export type ScreenshotScene = "start" | "lobby" | "home" | "transfers" | "chat"; + +export type ScreenshotFixture = { + scene: ScreenshotScene; + sessionId: string; + sessionCode: string; + playerId: string; + session: SessionSnapshot | null; + navigationTarget: { + root: "EntryLanding" | "Lobby" | "PlayerTabs"; + params?: Record; + }; + transferDraft?: { + targetId: string; + amount: string; + note: string; + }; +}; + +const bankerId = "banker-ngozi"; +const meId = "player-awa"; +const malikId = "player-malik"; +const inesId = "player-ines"; +const yemiId = "player-yemi"; +const activeSessionId = "screenshot-active-session"; +const lobbySessionId = "screenshot-lobby-session"; +const sessionCode = "MABLI"; +const baseTime = new Date("2026-03-30T09:41:00+02:00").getTime(); + +function createActiveSession(): SessionSnapshot { + return { + id: activeSessionId, + code: sessionCode, + status: "active", + createdAt: baseTime - 60 * 60 * 1000, + bankerId, + blackoutActive: false, + blackoutReason: null, + players: [ + { + id: bankerId, + name: "Ngozi", + role: "banker", + balance: 0, + connected: true, + isDummy: false, + joinedAt: baseTime - 60 * 60 * 1000, + lastActiveAt: baseTime - 2 * 60 * 1000, + }, + { + id: meId, + name: "Awa", + role: "player", + balance: 1840, + connected: true, + isDummy: false, + joinedAt: baseTime - 55 * 60 * 1000, + lastActiveAt: baseTime - 60 * 1000, + }, + { + id: malikId, + name: "Malik", + role: "player", + balance: 1325, + connected: true, + isDummy: false, + joinedAt: baseTime - 53 * 60 * 1000, + lastActiveAt: baseTime - 90 * 1000, + }, + { + id: inesId, + name: "Ines", + role: "player", + balance: 960, + connected: true, + isDummy: false, + joinedAt: baseTime - 51 * 60 * 1000, + lastActiveAt: baseTime - 4 * 60 * 1000, + }, + { + id: yemiId, + name: "Yemi", + role: "player", + balance: 780, + connected: false, + isDummy: true, + joinedAt: baseTime - 49 * 60 * 1000, + lastActiveAt: baseTime - 20 * 60 * 1000, + }, + ], + transactions: [ + { + id: "tx-bank-boost", + kind: "banker_adjust", + fromId: null, + toId: meId, + amount: 1200, + note: "Opening allowance", + createdAt: baseTime - 46 * 60 * 1000, + initiatedBy: "banker", + }, + { + id: "tx-street-food", + kind: "transfer", + fromId: meId, + toId: malikId, + amount: 90, + note: "Street food", + createdAt: baseTime - 16 * 60 * 1000, + initiatedBy: "player", + }, + { + id: "tx-rent-share", + kind: "transfer", + fromId: inesId, + toId: meId, + amount: 240, + note: "Rent split", + createdAt: baseTime - 12 * 60 * 1000, + initiatedBy: "player", + }, + { + id: "tx-taxi", + kind: "transfer", + fromId: meId, + toId: inesId, + amount: 45, + note: "Taxi", + createdAt: baseTime - 8 * 60 * 1000, + initiatedBy: "player", + }, + { + id: "tx-voucher", + kind: "banker_adjust", + fromId: null, + toId: meId, + amount: 535, + note: "District voucher", + createdAt: baseTime - 3 * 60 * 1000, + initiatedBy: "banker", + }, + ], + groups: [ + { + id: "group-direct-malik", + name: "Awa & Malik", + memberIds: [meId, malikId], + createdAt: baseTime - 20 * 60 * 1000, + createdBy: meId, + }, + { + id: "group-vendors", + name: "Market run", + memberIds: [meId, malikId, inesId], + createdAt: baseTime - 25 * 60 * 1000, + createdBy: inesId, + }, + ], + chats: [ + { + id: "chat-global-1", + fromId: bankerId, + body: "Agency is open. Keep your receipts for every transfer.", + createdAt: baseTime - 28 * 60 * 1000, + groupId: null, + }, + { + id: "chat-group-1", + fromId: inesId, + body: "Meeting at the riverside market in ten minutes.", + createdAt: baseTime - 18 * 60 * 1000, + groupId: "group-vendors", + }, + { + id: "chat-direct-1", + fromId: malikId, + body: "I covered the snacks. Send me 90 when you can.", + createdAt: baseTime - 11 * 60 * 1000, + groupId: "group-direct-malik", + }, + { + id: "chat-direct-2", + fromId: meId, + body: "Done. I am also paying for the taxi with Ines.", + createdAt: baseTime - 9 * 60 * 1000, + groupId: "group-direct-malik", + }, + { + id: "chat-direct-3", + fromId: malikId, + body: "Perfect. Meet us near the orange stand.", + createdAt: baseTime - 6 * 60 * 1000, + groupId: "group-direct-malik", + }, + ], + takeoverRequests: [], + }; +} + +function createLobbySession(): SessionSnapshot { + return { + id: lobbySessionId, + code: sessionCode, + status: "lobby", + createdAt: baseTime - 20 * 60 * 1000, + bankerId, + blackoutActive: false, + blackoutReason: null, + players: [ + { + id: bankerId, + name: "Ngozi", + role: "banker", + balance: 0, + connected: true, + isDummy: false, + joinedAt: baseTime - 20 * 60 * 1000, + lastActiveAt: baseTime - 60 * 1000, + }, + { + id: meId, + name: "Awa", + role: "player", + balance: 1500, + connected: true, + isDummy: false, + joinedAt: baseTime - 18 * 60 * 1000, + lastActiveAt: baseTime - 90 * 1000, + }, + { + id: malikId, + name: "Malik", + role: "player", + balance: 1500, + connected: true, + isDummy: false, + joinedAt: baseTime - 15 * 60 * 1000, + lastActiveAt: baseTime - 4 * 60 * 1000, + }, + { + id: yemiId, + name: "Yemi", + role: "player", + balance: 1500, + connected: false, + isDummy: true, + joinedAt: baseTime - 10 * 60 * 1000, + lastActiveAt: baseTime - 10 * 60 * 1000, + }, + ], + transactions: [], + groups: [], + chats: [], + takeoverRequests: [], + }; +} + +export function normalizeScreenshotScene(value: string | null | undefined): ScreenshotScene | null { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + if (normalized === "start") return "start"; + if (normalized === "lobby") return "lobby"; + if (normalized === "home") return "home"; + if (normalized === "transfers") return "transfers"; + if (normalized === "chat") return "chat"; + return null; +} + +export function buildScreenshotFixture(scene: ScreenshotScene): ScreenshotFixture { + if (scene === "start") { + return { + scene, + sessionId: "", + sessionCode: "", + playerId: "", + session: null, + navigationTarget: { root: "EntryLanding" }, + }; + } + + if (scene === "lobby") { + return { + scene, + sessionId: lobbySessionId, + sessionCode, + playerId: meId, + session: createLobbySession(), + navigationTarget: { root: "Lobby" }, + }; + } + + if (scene === "home") { + return { + scene, + sessionId: activeSessionId, + sessionCode, + playerId: meId, + session: createActiveSession(), + navigationTarget: { + root: "PlayerTabs", + params: { screen: "PlayerHome" }, + }, + }; + } + + if (scene === "transfers") { + return { + scene, + sessionId: activeSessionId, + sessionCode, + playerId: meId, + session: createActiveSession(), + navigationTarget: { + root: "PlayerTabs", + params: { screen: "PlayerTransfers" }, + }, + transferDraft: { + targetId: malikId, + amount: "90", + note: "Street food", + }, + }; + } + + return { + scene, + sessionId: activeSessionId, + sessionCode, + playerId: meId, + session: createActiveSession(), + navigationTarget: { + root: "PlayerTabs", + params: { + screen: "PlayerChat", + params: { + screen: "ChatThread", + params: { chatId: "group-direct-malik" }, + }, + }, + }, + }; +} diff --git a/mobile/src/i18n.ts b/mobile/src/i18n.ts index d4a73c3..7f9e536 100644 --- a/mobile/src/i18n.ts +++ b/mobile/src/i18n.ts @@ -5,17 +5,17 @@ type Locale = "en" | "fr"; const translations = { en: { - "app.name": "Negopoly Companion", + "app.name": "Crédit Mabligop", "common.loading": "Loading...", - "common.loadingChats": "Loading chats...", - "common.loadingChat": "Loading chat...", - "common.loadingLobby": "Joining lobby...", + "common.loadingChats": "Loading messages...", + "common.loadingChat": "Loading conversation...", + "common.loadingLobby": "Entering agency setup...", "common.notice": "Notice:", "common.online": "online", "common.offline": "offline", - "common.dummy": "Dummy", - "common.player": "Player", - "common.banker": "Banker", + "common.dummy": "Assisted customer", + "common.player": "Customer", + "common.banker": "Advisor", "common.bank": "Bank", "common.from": "From", "common.to": "To", @@ -33,100 +33,149 @@ const translations = { "common.save": "Save", "common.load": "Load", "common.noReason": "No reason provided", - "entry.subtitle": "Create or join a session.", - "entry.joinTitle": "Join a session", - "entry.sessionCode": "Session code", - "entry.newPlayer": "New player", - "entry.playerName": "Player name", - "entry.takeoverTitle": "Take over dummy", - "entry.alreadyConnected": "You are already connected.", + "entry.subtitle": "Open or access an agency.", + "entry.heroBadge": "Mobile banking companion", + "entry.landingTitle": "Access your agency.", + "entry.landingBody": + "Use your agency code to sign in, or open a new agency if you are the advisor in charge.", + "entry.landingFooter": "Real-time balances, transfers, and approvals for Crédit Mabligop agencies.", + "entry.metricRealtimeValue": "Live", + "entry.metricRealtimeLabel": "Transfers and balances stay synchronized.", + "entry.metricSupervisedValue": "Advisor-led", + "entry.metricSupervisedLabel": "Agencies stay under advisor control.", + "entry.primaryEyebrow": "Customer access", + "entry.secondaryEyebrow": "Advisor access", + "entry.accessAgency": "Access an agency", + "entry.accessAgencyHint": + "Enter your agency code, identify yourself, and recover an existing profile if needed.", + "entry.openAgency": "Open an agency", + "entry.openAgencyHint": + "Launch a supervised agency, invite customers, and approve assisted-profile recovery.", + "entry.joinStepTitle": "Identify your agency.", + "entry.joinStepSubtitle": + "Enter the agency code shared by your advisor to view the available customer access options.", + "entry.createStepTitle": "Open a new agency.", + "entry.createStepSubtitle": + "Create an advisor-controlled space for balances, transfers, approvals, and assisted customers.", + "entry.joinTitle": "Agency access", + "entry.joinDescription": "Use your agency code to retrieve available customer profiles.", + "entry.sessionCode": "Agency code", + "entry.previewLabel": "Agency found", + "entry.agencyCodeValue": "Agency code: {code}", + "entry.previewCustomers": "{count} customer profiles currently registered.", + "entry.newPlayer": "Join as a new customer", + "entry.newCustomerDescription": + "Create a fresh customer profile and enter the agency immediately.", + "entry.joinAsCustomer": "Join agency", + "entry.playerName": "Customer name", + "entry.takeoverTitle": "Recover an existing profile", + "entry.recoverCustomerDescription": + "Request access to an assisted customer profile. An advisor must approve the recovery.", + "entry.alreadyConnected": "This customer profile is already connected.", "entry.dummyId": "Dummy ID (select later)", - "entry.selectDummy": "Select a dummy", + "entry.selectDummy": "Select an assisted customer", "entry.yourNameOptional": "Your name (optional)", - "entry.requestTakeover": "Request takeover", - "entry.noDummies": "No dummies available yet.", - "entry.takeoverPending": "Waiting for the banker to approve your takeover.", - "entry.createTitle": "Create a session", - "entry.bankerName": "Banker name", - "entry.openVault": "Open the vault", - "entry.alert.enterCode": "Enter a session code", - "entry.alert.sessionNotFound": "Session not found", - "entry.alert.selectDummy": "Select a dummy player", + "entry.requestTakeover": "Request advisor approval", + "entry.noDummies": "No assisted customer profiles are available yet.", + "entry.takeoverPending": "Waiting for an advisor to approve your recovery request.", + "entry.createTitle": "Advisor profile", + "entry.createDescription": + "The first profile created becomes the advisor supervising this agency.", + "entry.bankerName": "Advisor name", + "entry.advisorName": "Advisor name", + "entry.openVault": "Open agency", + "entry.linkAccessExisting": "Access an existing agency instead", + "entry.linkOpenNew": "Open a new agency instead", + "entry.alert.enterCode": "Enter an agency code", + "entry.alert.enterAdvisorName": "Enter an advisor name", + "entry.alert.sessionNotFound": "Agency not found", + "entry.alert.selectDummy": "Select an assisted customer", "entry.alert.takeoverFailed": "Unable to request takeover. Please try again.", - "lobby.title": "Lobby", - "lobby.code": "Code: {code}", - "lobby.startGame": "Start game", - "lobby.addDummyTitle": "Add dummy player", - "lobby.addDummySubtitle": "Create a player for someone without the app.", - "lobby.enterDummyName": "Enter a dummy name", - "lobby.addDummyButton": "Add dummy", - "session.exit": "Exit game", - "session.exitPrompt": "Leave this game?", - "session.exitMessage": "You can rejoin later with the session code.", + "lobby.title": "Agency setup", + "lobby.code": "Agency code: {code}", + "lobby.startGame": "Open agency", + "lobby.addDummyTitle": "Add an assisted customer", + "lobby.addDummySubtitle": + "Create a supervised customer profile for someone without the mobile app.", + "lobby.enterDummyName": "Enter an assisted customer name", + "lobby.addDummyButton": "Create assisted customer", + "lobby.heroAdvisor": + "Finalize customer access, review recoveries, and open the agency when everything is ready.", + "lobby.heroCustomer": + "Your advisor is preparing the agency. You will enter as soon as the agency opens.", + "lobby.customers": "Customers", + "lobby.assisted": "Assisted", + "lobby.rosterTitle": "Agency roster", + "lobby.waitingTitle": "Waiting for agency opening", + "lobby.waitingBody": + "Transfers, balances, and conversations unlock once the advisor opens the agency.", + "session.exit": "Leave agency", + "session.exitPrompt": "Leave this agency?", + "session.exitMessage": "You can access it again later with the agency code.", "transfers.title": "Make a transfer", - "transfers.subtitle": "Move funds instantly between players.", + "transfers.subtitle": "Move funds instantly between customers.", "transfers.from": "From", "transfers.to": "To", "transfers.availableBalance": "Available balance", - "transfers.noPlayers": "No other players available yet.", - "transfers.dummy": "Dummy player", - "transfers.player": "Player", + "transfers.noPlayers": "No other customers are available yet.", + "transfers.dummy": "Assisted customer", + "transfers.player": "Customer", "transfers.amount": "Amount", "transfers.note": "Note", "transfers.notePlaceholder": "What is this for?", "transfers.sending": "Sending", "transfers.summary": "₦{amount} to {name}", - "transfers.selectPlayer": "Select a player", + "transfers.selectPlayer": "Select a customer", "transfers.send": "Send transfer", - "transfers.error": "Choose a player and a valid amount.", + "transfers.error": "Choose a customer and a valid amount.", "home.balance": "Balance", - "home.recent": "Recent activity", + "home.recent": "Recent operations", "home.noActivity": "No activity yet.", "blackout.title": "EMP", "blackout.defaultReason": "EMP in effect", "blackout.active": "EMP active", - "banker.dashboard.title": "Session activity", - "banker.tools.title": "Banker tools", - "banker.tools.playersTab": "Players", - "banker.tools.adminTab": "Admin", - "banker.tools.playerOverview": "Player overview", - "banker.tools.noPlayers": "No players yet.", + "banker.dashboard.title": "Agency activity", + "banker.tools.title": "Advisor controls", + "banker.tools.playersTab": "Customers", + "banker.tools.adminTab": "Agency", + "banker.tools.playerOverview": "Customer overview", + "banker.tools.noPlayers": "No customers yet.", "banker.tools.adjust": "Adjust balance", "banker.tools.apply": "Apply", "banker.tools.forceTransfer": "Force transfer", "banker.tools.force": "Force", - "banker.tools.createDummy": "Create dummy", - "banker.tools.addDummy": "Add dummy", - "banker.tools.blackout": "EMP", - "banker.tools.blackoutActive": "EMP active", - "banker.tools.blackoutReason": "EMP reason", - "banker.tools.blackoutEnable": "Enable EMP", - "banker.tools.blackoutDisable": "Disable EMP", + "banker.tools.createDummy": "Create assisted customer", + "banker.tools.addDummy": "Add assisted customer", + "banker.tools.blackout": "Blackout", + "banker.tools.blackoutActive": "Blackout active", + "banker.tools.blackoutReason": "Blackout reason", + "banker.tools.blackoutEnable": "Enable blackout", + "banker.tools.blackoutDisable": "Disable blackout", "banker.tools.trigger": "Trigger", - "banker.tools.endSession": "End session", - "banker.tools.playerId": "Player ID", + "banker.tools.endSession": "Close agency", + "banker.tools.playerId": "Customer ID", "banker.tools.amountAdjust": "Amount (+/-)", "banker.tools.reason": "Reason", - "banker.tools.fromPlayer": "From player ID", - "banker.tools.toPlayer": "To player ID", + "banker.tools.fromPlayer": "From customer ID", + "banker.tools.toPlayer": "To customer ID", "banker.tools.amount": "Amount", "banker.tools.note": "Note", - "banker.tools.dummyName": "Dummy name", + "banker.tools.dummyName": "Assisted customer name", "banker.tools.startingBalance": "Starting balance", - "banker.takeoverApprovals": "Takeover approvals", - "banker.wants": "wants {name}", + "banker.takeoverApprovals": "Recovery approvals", + "banker.wants": "requests {name}", "banker.approve": "Approve", - "banker.stateTitle": "GameState", - "banker.stateSubtitle": "Export or restore the current session.", - "banker.downloadState": "Export GameState", - "banker.loadFromFile": "Load GameState", - "banker.importPlaceholder": "Paste GameState JSON here", + "banker.stateTitle": "Agency state", + "banker.stateSubtitle": "Export or restore the current agency.", + "banker.downloadState": "Export agency state", + "banker.loadFromFile": "Load agency state", + "banker.importPlaceholder": "Paste agency state JSON here", "banker.loadFromStorage": "Load from saved snapshots", - "banker.stateDownloaded": "GameState exported.", - "banker.stateDownloadError": "Unable to export GameState.", - "banker.stateLoaded": "GameState loaded.", - "banker.stateLoadError": "Unable to load GameState.", - "banker.stateLoadInvalid": "Invalid GameState JSON.", + "banker.stateDownloaded": "Agency state exported.", + "banker.stateDownloadError": "Unable to export agency state.", + "banker.stateLoaded": "Agency state loaded.", + "banker.stateLoadError": "Unable to load agency state.", + "banker.stateLoadInvalid": "Invalid agency state JSON.", "banker.autosaveTitle": "AutoSave", "banker.autosaveSubtitle": "Save rolling snapshots on this device.", "banker.autosaveEnabled": "Enable AutoSave", @@ -137,47 +186,47 @@ const translations = { "banker.autosaveFailed": "AutoSave failed.", "banker.noAutosaves": "No autosaves yet.", "banker.savedAt": "Saved {time}", - "chat.title": "Chats", + "chat.title": "Messages", "chat.noMessages": "No messages yet", - "chat.global": "Global chat", - "chat.newTitle": "New chat", + "chat.global": "Agency channel", + "chat.newTitle": "New conversation", "chat.direct": "Direct", "chat.group": "Group", "chat.groupName": "Group name", - "chat.choosePlayers": "Choose players", - "chat.startChat": "Start chat", + "chat.choosePlayers": "Choose customers", + "chat.startChat": "Start conversation", "chat.notFound": "Chat not found.", "chat.messagePlaceholder": "Message", - "tabs.home": "Home", - "tabs.transfers": "Transfers", - "tabs.chat": "Chat", - "tabs.dashboard": "Dashboard", - "tabs.tools": "Tools", + "tabs.home": "Accounts", + "tabs.transfers": "Payments", + "tabs.chat": "Messages", + "tabs.dashboard": "Agency", + "tabs.tools": "Control", "transaction.transfer": "Transfer", - "transaction.banker_adjust": "Banker adjustment", - "transaction.banker_force_transfer": "Forced transfer", - "connection.connecting": "Connecting to your game", - "connection.reconnecting": "Reconnecting to your game", + "transaction.banker_adjust": "Advisor adjustment", + "transaction.banker_force_transfer": "Advisor transfer", + "connection.connecting": "Connecting to your agency", + "connection.reconnecting": "Reconnecting to your agency", "connection.reconnectingDetail": "Attempt {count}. Live updates will resume automatically.", "error.parseResponse": "Unable to parse server response", - "error.createSession": "Unable to create session", - "error.joinSession": "Unable to join session", - "error.loadSessionInfo": "Unable to load session info", + "error.createSession": "Unable to open agency", + "error.joinSession": "Unable to access agency", + "error.loadSessionInfo": "Unable to load agency info", "error.connectionNotReady": "Connection not ready", - "error.reconnecting": "Reconnecting to the game. Please try again in a moment.", + "error.reconnecting": "Reconnecting to the agency. Please try again in a moment.", }, fr: { - "app.name": "Negopoly Companion", + "app.name": "Crédit Mabligop", "common.loading": "Chargement...", - "common.loadingChats": "Chargement des chats...", - "common.loadingChat": "Chargement du chat...", - "common.loadingLobby": "Connexion au lobby...", + "common.loadingChats": "Chargement des messages...", + "common.loadingChat": "Chargement de la conversation...", + "common.loadingLobby": "Accès à la mise en place de l'agence...", "common.notice": "Info :", "common.online": "en ligne", "common.offline": "hors ligne", - "common.dummy": "Dummy", - "common.player": "Joueur", - "common.banker": "Banquier", + "common.dummy": "Client assisté", + "common.player": "Client", + "common.banker": "Conseiller", "common.bank": "Banque", "common.from": "De", "common.to": "Vers", @@ -195,100 +244,150 @@ const translations = { "common.save": "Enregistrer", "common.load": "Charger", "common.noReason": "Aucune raison fournie", - "entry.subtitle": "Créez ou rejoignez une session.", - "entry.joinTitle": "Rejoindre une session", - "entry.sessionCode": "Code de session", - "entry.newPlayer": "Nouveau joueur", - "entry.playerName": "Nom du joueur", - "entry.takeoverTitle": "Reprendre un dummy", - "entry.alreadyConnected": "Vous êtes déjà connecté.", + "entry.subtitle": "Ouvrez ou accédez à une agence.", + "entry.heroBadge": "Compagnon bancaire mobile", + "entry.landingTitle": "Accédez à votre agence.", + "entry.landingBody": + "Utilisez votre code agence pour vous identifier, ou ouvrez une nouvelle agence si vous êtes le conseiller responsable.", + "entry.landingFooter": + "Soldes, virements et validations en temps réel pour les agences Crédit Mabligop.", + "entry.metricRealtimeValue": "Temps réel", + "entry.metricRealtimeLabel": "Transferts et soldes restent synchronisés.", + "entry.metricSupervisedValue": "Supervisée", + "entry.metricSupervisedLabel": "Chaque agence reste sous contrôle conseiller.", + "entry.primaryEyebrow": "Accès client", + "entry.secondaryEyebrow": "Accès conseiller", + "entry.accessAgency": "Accéder à une agence", + "entry.accessAgencyHint": + "Saisissez votre code agence, identifiez-vous, puis récupérez un profil existant si besoin.", + "entry.openAgency": "Ouvrir une agence", + "entry.openAgencyHint": + "Lancez une agence supervisée, invitez des clients et validez les récupérations de profils assistés.", + "entry.joinStepTitle": "Identifiez votre agence.", + "entry.joinStepSubtitle": + "Saisissez le code transmis par votre conseiller pour afficher les options d'accès client disponibles.", + "entry.createStepTitle": "Ouvrez une nouvelle agence.", + "entry.createStepSubtitle": + "Créez un espace piloté par un conseiller pour les soldes, virements, validations et clients assistés.", + "entry.joinTitle": "Accès agence", + "entry.joinDescription": "Utilisez votre code agence pour retrouver les profils clients disponibles.", + "entry.sessionCode": "Code agence", + "entry.previewLabel": "Agence trouvée", + "entry.agencyCodeValue": "Code agence : {code}", + "entry.previewCustomers": "{count} profils clients actuellement enregistrés.", + "entry.newPlayer": "Rejoindre comme nouveau client", + "entry.newCustomerDescription": + "Créez un nouveau profil client et entrez immédiatement dans l'agence.", + "entry.joinAsCustomer": "Rejoindre l'agence", + "entry.playerName": "Nom du client", + "entry.takeoverTitle": "Récupérer un profil existant", + "entry.recoverCustomerDescription": + "Demandez l'accès à un profil client assisté. Un conseiller doit valider la récupération.", + "entry.alreadyConnected": "Ce profil client est déjà connecté.", "entry.dummyId": "ID du dummy (plus tard)", - "entry.selectDummy": "Sélectionnez un dummy", + "entry.selectDummy": "Sélectionnez un client assisté", "entry.yourNameOptional": "Votre nom (optionnel)", - "entry.requestTakeover": "Demander la reprise", - "entry.noDummies": "Aucun dummy disponible pour le moment.", - "entry.takeoverPending": "En attente de l'approbation du banquier.", - "entry.createTitle": "Créer une session", - "entry.bankerName": "Nom du banquier", - "entry.openVault": "Ouvrir le coffre", - "entry.alert.enterCode": "Entrez un code de session", - "entry.alert.sessionNotFound": "Session introuvable", - "entry.alert.selectDummy": "Sélectionnez un dummy", + "entry.requestTakeover": "Demander l'accord du conseiller", + "entry.noDummies": "Aucun profil client assisté n'est disponible pour le moment.", + "entry.takeoverPending": "En attente de la validation du conseiller.", + "entry.createTitle": "Profil conseiller", + "entry.createDescription": + "Le premier profil créé devient le conseiller qui supervise cette agence.", + "entry.bankerName": "Nom du conseiller", + "entry.advisorName": "Nom du conseiller", + "entry.openVault": "Ouvrir l'agence", + "entry.linkAccessExisting": "Accéder plutôt à une agence existante", + "entry.linkOpenNew": "Ouvrir plutôt une nouvelle agence", + "entry.alert.enterCode": "Entrez un code agence", + "entry.alert.enterAdvisorName": "Entrez un nom de conseiller", + "entry.alert.sessionNotFound": "Agence introuvable", + "entry.alert.selectDummy": "Sélectionnez un client assisté", "entry.alert.takeoverFailed": "Impossible de demander la reprise. Réessayez.", - "lobby.title": "Lobby", - "lobby.code": "Code : {code}", - "lobby.startGame": "Démarrer la partie", - "lobby.addDummyTitle": "Ajouter un dummy", - "lobby.addDummySubtitle": "Créez un joueur pour quelqu'un sans l'application.", - "lobby.enterDummyName": "Entrez un nom de dummy", - "lobby.addDummyButton": "Ajouter un dummy", - "session.exit": "Quitter la partie", - "session.exitPrompt": "Quitter cette session ?", - "session.exitMessage": "Vous pourrez rejoindre plus tard avec le code.", - "transfers.title": "Faire un transfert", - "transfers.subtitle": "Transférez des fonds instantanément entre joueurs.", + "lobby.title": "Mise en place de l'agence", + "lobby.code": "Code agence : {code}", + "lobby.startGame": "Ouvrir l'agence", + "lobby.addDummyTitle": "Ajouter un client assisté", + "lobby.addDummySubtitle": + "Créez un profil client supervisé pour quelqu'un sans l'application mobile.", + "lobby.enterDummyName": "Entrez un nom de client assisté", + "lobby.addDummyButton": "Créer le client assisté", + "lobby.heroAdvisor": + "Finalisez les accès clients, examinez les récupérations et ouvrez l'agence quand tout est prêt.", + "lobby.heroCustomer": + "Votre conseiller prépare l'agence. Vous y entrerez dès son ouverture.", + "lobby.customers": "Clients", + "lobby.assisted": "Assistés", + "lobby.rosterTitle": "Registre de l'agence", + "lobby.waitingTitle": "En attente de l'ouverture de l'agence", + "lobby.waitingBody": + "Virements, soldes et conversations seront disponibles dès que le conseiller ouvrira l'agence.", + "session.exit": "Quitter l'agence", + "session.exitPrompt": "Quitter cette agence ?", + "session.exitMessage": "Vous pourrez y accéder plus tard avec le code agence.", + "transfers.title": "Effectuer un virement", + "transfers.subtitle": "Transférez des fonds instantanément entre clients.", "transfers.from": "De", "transfers.to": "Vers", "transfers.availableBalance": "Solde disponible", - "transfers.noPlayers": "Aucun autre joueur disponible.", - "transfers.dummy": "Dummy", - "transfers.player": "Joueur", + "transfers.noPlayers": "Aucun autre client disponible pour le moment.", + "transfers.dummy": "Client assisté", + "transfers.player": "Client", "transfers.amount": "Montant", "transfers.note": "Note", "transfers.notePlaceholder": "Pour quoi ?", "transfers.sending": "Envoi", "transfers.summary": "₦{amount} à {name}", - "transfers.selectPlayer": "Choisissez un joueur", - "transfers.send": "Envoyer le transfert", - "transfers.error": "Choisissez un joueur et un montant valide.", + "transfers.selectPlayer": "Choisissez un client", + "transfers.send": "Envoyer le virement", + "transfers.error": "Choisissez un client et un montant valide.", "home.balance": "Solde", - "home.recent": "Activité récente", + "home.recent": "Opérations récentes", "home.noActivity": "Aucune activité.", "blackout.title": "EMP", "blackout.defaultReason": "EMP en cours", "blackout.active": "EMP actif", - "banker.dashboard.title": "Activité de la session", - "banker.tools.title": "Outils banquier", - "banker.tools.playersTab": "Joueurs", - "banker.tools.adminTab": "Admin", - "banker.tools.playerOverview": "Vue joueur", - "banker.tools.noPlayers": "Pas encore de joueurs.", + "banker.dashboard.title": "Activité de l'agence", + "banker.tools.title": "Pilotage conseiller", + "banker.tools.playersTab": "Clients", + "banker.tools.adminTab": "Agence", + "banker.tools.playerOverview": "Vue client", + "banker.tools.noPlayers": "Pas encore de clients.", "banker.tools.adjust": "Ajuster le solde", "banker.tools.apply": "Appliquer", - "banker.tools.forceTransfer": "Forcer un transfert", + "banker.tools.forceTransfer": "Imposer un virement", "banker.tools.force": "Forcer", - "banker.tools.createDummy": "Créer un dummy", - "banker.tools.addDummy": "Ajouter un dummy", - "banker.tools.blackout": "EMP", - "banker.tools.blackoutActive": "EMP actif", - "banker.tools.blackoutReason": "Raison de l'EMP", - "banker.tools.blackoutEnable": "Activer l'EMP", - "banker.tools.blackoutDisable": "Désactiver l'EMP", + "banker.tools.createDummy": "Créer un client assisté", + "banker.tools.addDummy": "Ajouter un client assisté", + "banker.tools.blackout": "Coupure", + "banker.tools.blackoutActive": "Coupure active", + "banker.tools.blackoutReason": "Raison de la coupure", + "banker.tools.blackoutEnable": "Activer la coupure", + "banker.tools.blackoutDisable": "Désactiver la coupure", "banker.tools.trigger": "Déclencher", - "banker.tools.endSession": "Terminer la session", - "banker.tools.playerId": "ID joueur", + "banker.tools.endSession": "Fermer l'agence", + "banker.tools.playerId": "ID client", "banker.tools.amountAdjust": "Montant (+/-)", "banker.tools.reason": "Raison", - "banker.tools.fromPlayer": "ID joueur source", - "banker.tools.toPlayer": "ID joueur cible", + "banker.tools.fromPlayer": "ID client source", + "banker.tools.toPlayer": "ID client cible", "banker.tools.amount": "Montant", "banker.tools.note": "Note", - "banker.tools.dummyName": "Nom du dummy", + "banker.tools.dummyName": "Nom du client assisté", "banker.tools.startingBalance": "Solde de départ", - "banker.takeoverApprovals": "Approbations de reprise", - "banker.wants": "veut {name}", + "banker.takeoverApprovals": "Validations de récupération", + "banker.wants": "demande {name}", "banker.approve": "Approuver", - "banker.stateTitle": "État de partie", - "banker.stateSubtitle": "Exportez ou restaurez la session.", - "banker.downloadState": "Exporter l'état", - "banker.loadFromFile": "Charger l'état", - "banker.importPlaceholder": "Collez le JSON d'état ici", + "banker.stateTitle": "État de l'agence", + "banker.stateSubtitle": "Exportez ou restaurez l'agence actuelle.", + "banker.downloadState": "Exporter l'état de l'agence", + "banker.loadFromFile": "Charger l'état de l'agence", + "banker.importPlaceholder": "Collez le JSON d'état de l'agence ici", "banker.loadFromStorage": "Charger depuis les sauvegardes", - "banker.stateDownloaded": "État exporté.", - "banker.stateDownloadError": "Impossible d'exporter l'état.", - "banker.stateLoaded": "État chargé.", - "banker.stateLoadError": "Impossible de charger l'état.", - "banker.stateLoadInvalid": "JSON d'état invalide.", + "banker.stateDownloaded": "État de l'agence exporté.", + "banker.stateDownloadError": "Impossible d'exporter l'état de l'agence.", + "banker.stateLoaded": "État de l'agence chargé.", + "banker.stateLoadError": "Impossible de charger l'état de l'agence.", + "banker.stateLoadInvalid": "JSON d'état de l'agence invalide.", "banker.autosaveTitle": "AutoSave", "banker.autosaveSubtitle": "Enregistrez des sauvegardes sur l'appareil.", "banker.autosaveEnabled": "Activer AutoSave", @@ -299,34 +398,34 @@ const translations = { "banker.autosaveFailed": "Échec de la sauvegarde.", "banker.noAutosaves": "Aucune sauvegarde.", "banker.savedAt": "Sauvé {time}", - "chat.title": "Chats", + "chat.title": "Messages", "chat.noMessages": "Aucun message", - "chat.global": "Chat global", - "chat.newTitle": "Nouveau chat", + "chat.global": "Canal agence", + "chat.newTitle": "Nouvelle conversation", "chat.direct": "Direct", "chat.group": "Groupe", "chat.groupName": "Nom du groupe", - "chat.choosePlayers": "Choisir des joueurs", - "chat.startChat": "Démarrer le chat", + "chat.choosePlayers": "Choisir des clients", + "chat.startChat": "Démarrer la conversation", "chat.notFound": "Chat introuvable.", "chat.messagePlaceholder": "Message", - "tabs.home": "Accueil", - "tabs.transfers": "Transferts", - "tabs.chat": "Chat", - "tabs.dashboard": "Tableau", - "tabs.tools": "Outils", + "tabs.home": "Comptes", + "tabs.transfers": "Paiements", + "tabs.chat": "Messages", + "tabs.dashboard": "Agence", + "tabs.tools": "Pilotage", "transaction.transfer": "Transfert", - "transaction.banker_adjust": "Ajustement banquier", - "transaction.banker_force_transfer": "Transfert forcé", - "connection.connecting": "Connexion à la partie", - "connection.reconnecting": "Reconnexion à la partie", + "transaction.banker_adjust": "Ajustement conseiller", + "transaction.banker_force_transfer": "Virement imposé", + "connection.connecting": "Connexion à votre agence", + "connection.reconnecting": "Reconnexion à votre agence", "connection.reconnectingDetail": "Tentative {count}. Les mises à jour vont reprendre automatiquement.", "error.parseResponse": "Impossible de lire la réponse du serveur", - "error.createSession": "Impossible de créer la session", - "error.joinSession": "Impossible de rejoindre la session", - "error.loadSessionInfo": "Impossible de charger les infos de session", + "error.createSession": "Impossible d'ouvrir l'agence", + "error.joinSession": "Impossible d'accéder à l'agence", + "error.loadSessionInfo": "Impossible de charger les infos de l'agence", "error.connectionNotReady": "Connexion non prête", - "error.reconnecting": "Reconnexion à la partie en cours. Réessayez dans un instant.", + "error.reconnecting": "Reconnexion à l'agence en cours. Réessayez dans un instant.", }, } as const; diff --git a/mobile/src/navigation/AppNavigator.tsx b/mobile/src/navigation/AppNavigator.tsx index 12c1a2a..d9691e9 100644 --- a/mobile/src/navigation/AppNavigator.tsx +++ b/mobile/src/navigation/AppNavigator.tsx @@ -2,7 +2,10 @@ import React from "react"; import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; import { createNativeStackNavigator } from "@react-navigation/native-stack"; import { Ionicons } from "@expo/vector-icons"; -import EntryScreen from "../screens/EntryScreen"; +import BrandLockup from "../components/BrandLockup"; +import EntryLandingScreen from "../screens/EntryLandingScreen"; +import AgencyJoinScreen from "../screens/AgencyJoinScreen"; +import AgencyCreateScreen from "../screens/AgencyCreateScreen"; import LobbyScreen from "../screens/LobbyScreen"; import PlayerHomeScreen from "../screens/PlayerHomeScreen"; import PlayerTransfersScreen from "../screens/PlayerTransfersScreen"; @@ -26,6 +29,10 @@ const PlayerTabs = createBottomTabNavigator(); const BankerTabs = createBottomTabNavigator(); const ChatStack = createNativeStackNavigator(); +function buildHeaderTitle(section: string) { + return () => ; +} + function ChatStackNavigator() { const { t } = useI18n(); const theme = useTheme(); @@ -37,22 +44,23 @@ function ChatStackNavigator() { headerShadowVisible: false, contentStyle: { backgroundColor: theme.colors.background }, headerRight: () => , + headerTitleAlign: "left", }} > ); @@ -75,6 +83,7 @@ export function PlayerTabsNavigator() { headerStyle: { backgroundColor: theme.colors.headerBackground }, headerTintColor: theme.colors.headerText, headerRight: () => , + headerTitleAlign: "left", }} > ( ), @@ -92,6 +102,7 @@ export function PlayerTabsNavigator() { component={PlayerTransfersScreen} options={{ title: t("tabs.transfers"), + headerTitle: buildHeaderTitle(t("tabs.transfers")), tabBarIcon: ({ color, size }) => ( ), @@ -129,6 +140,7 @@ export function BankerTabsNavigator() { headerStyle: { backgroundColor: theme.colors.headerBackground }, headerTintColor: theme.colors.headerText, headerRight: () => , + headerTitleAlign: "left", }} > ( ), @@ -146,6 +159,7 @@ export function BankerTabsNavigator() { component={BankerToolsScreen} options={{ title: t("tabs.tools"), + headerTitle: buildHeaderTitle(t("tabs.tools")), tabBarIcon: ({ color, size }) => ( ), @@ -167,10 +181,10 @@ export function BankerTabsNavigator() { } export default function AppNavigator() { - const { t } = useI18n(); const theme = useTheme(); return ( - + + + >(); + const manager = useSession(); + const { t } = useI18n(); + const theme = useTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); + const placeholderColor = theme.colors.placeholder; + const insets = useSafeAreaInsets(); + const [advisorName, setAdvisorName] = useState(""); + + async function handleCreate() { + const normalized = advisorName.trim(); + if (!normalized) { + Alert.alert(t("entry.alert.enterAdvisorName")); + return; + } + const data = await manager.createSession(normalized); + if (data) { + navigation.replace("Lobby"); + } + } + + return ( + + + + {t("entry.createStepTitle")} + {t("entry.createStepSubtitle")} + + + + {t("entry.createTitle")} + {t("entry.createDescription")} + + + {t("entry.openAgency")} + + + + navigation.replace("AgencyJoin")} + > + {t("entry.linkAccessExisting")} + + + ); +} + +const createStyles = (theme: AppTheme) => + StyleSheet.create({ + scroll: { + flex: 1, + backgroundColor: theme.colors.background, + }, + container: { + gap: 18, + backgroundColor: theme.colors.background, + }, + hero: { + borderRadius: 28, + padding: 22, + gap: 12, + backgroundColor: theme.colors.brandSurface, + borderWidth: 1, + borderColor: theme.colors.brandSurfaceAlt, + }, + heroTitle: { + color: theme.colors.brandText, + fontSize: 28, + fontWeight: "800", + letterSpacing: -0.8, + }, + heroBody: { + color: theme.colors.brandTextMuted, + fontSize: 15, + lineHeight: 22, + }, + card: { + backgroundColor: theme.colors.surface, + borderRadius: 24, + padding: 20, + gap: 14, + borderWidth: 1, + borderColor: theme.colors.border, + }, + cardTitle: { + color: theme.colors.text, + fontSize: 22, + fontWeight: "700", + letterSpacing: -0.4, + }, + cardSubtitle: { + color: theme.colors.textMuted, + fontSize: 14, + lineHeight: 21, + }, + input: { + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.inputBackground, + color: theme.colors.inputText, + borderRadius: 16, + paddingHorizontal: 14, + paddingVertical: 13, + }, + button: { + backgroundColor: theme.colors.primary, + borderRadius: 999, + paddingVertical: 14, + alignItems: "center", + }, + buttonText: { + color: theme.colors.primaryText, + fontWeight: "700", + }, + linkButton: { + paddingVertical: 12, + alignItems: "center", + }, + linkText: { + color: theme.colors.textMuted, + fontWeight: "600", + }, + }); diff --git a/mobile/src/screens/AgencyJoinScreen.tsx b/mobile/src/screens/AgencyJoinScreen.tsx new file mode 100644 index 0000000..980940f --- /dev/null +++ b/mobile/src/screens/AgencyJoinScreen.tsx @@ -0,0 +1,485 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + Alert, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { useNavigation, useRoute } from "@react-navigation/native"; +import type { RouteProp } from "@react-navigation/native"; +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import BrandLockup from "../components/BrandLockup"; +import { useI18n } from "../i18n"; +import type { RootStackParamList } from "../navigation/types"; +import { useSession } from "../state/session-context"; +import type { SessionPreview } from "../shared/types"; +import { useTheme, type AppTheme } from "../theme"; + +export default function AgencyJoinScreen() { + const navigation = useNavigation>(); + const route = useRoute>(); + const manager = useSession(); + const { t } = useI18n(); + const theme = useTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); + const placeholderColor = theme.colors.placeholder; + const handledLinkRef = useRef(null); + const insets = useSafeAreaInsets(); + const [joinCode, setJoinCode] = useState(""); + const [joinStep, setJoinStep] = useState<"code" | "choice">("code"); + const [joinPreview, setJoinPreview] = useState(null); + const [joinName, setJoinName] = useState(""); + const [takeoverName, setTakeoverName] = useState(""); + const [takeoverDummyId, setTakeoverDummyId] = useState(""); + const [showDummyOptions, setShowDummyOptions] = useState(false); + const [takeoverToken, setTakeoverToken] = useState(null); + const [takeoverWaiting, setTakeoverWaiting] = useState(false); + + const dummyOptions = useMemo( + () => joinPreview?.players.filter((player) => player.isDummy) ?? [], + [joinPreview], + ); + const storedPlayer = joinPreview?.players.find((player) => player.id === manager.playerId); + const takeoverDisabled = storedPlayer?.connected === true; + + async function resolvePreview(code: string) { + const preview = await manager.fetchSessionPreview(code); + if (!preview) { + Alert.alert(t("entry.alert.sessionNotFound")); + return null; + } + setJoinPreview(preview); + setJoinStep("choice"); + return preview; + } + + async function handleJoinPreview() { + const normalized = joinCode.trim().toUpperCase(); + if (!normalized) { + Alert.alert(t("entry.alert.enterCode")); + return; + } + await resolvePreview(normalized); + } + + useEffect(() => { + const raw = route.params?.gameId; + if (typeof raw !== "string") { + return; + } + const normalized = raw.trim().toUpperCase(); + if (!normalized || handledLinkRef.current === normalized) { + return; + } + handledLinkRef.current = normalized; + setJoinCode(normalized); + setJoinStep("code"); + setJoinPreview(null); + setJoinName(""); + setTakeoverName(""); + setTakeoverDummyId(""); + setTakeoverToken(null); + setTakeoverWaiting(false); + void resolvePreview(normalized); + }, [route.params?.gameId]); + + async function handleJoinNew() { + if (!joinPreview) return; + const data = await manager.joinSession(joinPreview.code, joinName.trim()); + if (data) { + navigation.replace("Lobby"); + } + } + + async function handleTakeover() { + if (!joinPreview) return; + if (!takeoverDummyId) { + Alert.alert(t("entry.alert.selectDummy")); + return; + } + setTakeoverWaiting(true); + const selectedDummy = joinPreview.players.find((player) => player.id === takeoverDummyId); + const fallbackName = takeoverName.trim() || selectedDummy?.name || ""; + const token = await manager.requestTakeoverToken( + joinPreview.code, + takeoverDummyId, + fallbackName, + ); + if (!token) { + setTakeoverWaiting(false); + if (manager.error) { + Alert.alert(manager.error); + } + return; + } + setTakeoverToken(token); + } + + useEffect(() => { + if (joinStep === "code" || !joinPreview) { + setShowDummyOptions(false); + setTakeoverToken(null); + setTakeoverWaiting(false); + } + }, [joinStep, joinPreview]); + + useEffect(() => { + if (!takeoverToken || !joinPreview) return; + let cancelled = false; + let timeout: ReturnType | null = null; + const poll = async () => { + const data = await manager.claimTakeover(joinPreview.code, takeoverToken); + if (cancelled) return; + if (data) { + setTakeoverWaiting(false); + setTakeoverToken(null); + navigation.replace("Lobby"); + return; + } + timeout = setTimeout(poll, 2000); + }; + void poll(); + return () => { + cancelled = true; + if (timeout) clearTimeout(timeout); + }; + }, [joinPreview, takeoverToken, manager, navigation]); + + return ( + + + + {t("entry.joinStepTitle")} + {t("entry.joinStepSubtitle")} + + + + {t("entry.joinTitle")} + {t("entry.joinDescription")} + { + setJoinCode(value.toUpperCase()); + if (joinStep === "choice") { + setJoinStep("code"); + setJoinPreview(null); + setJoinName(""); + setTakeoverName(""); + setTakeoverDummyId(""); + setShowDummyOptions(false); + } + }} + /> + + {t("common.continue")} + + + + {joinStep === "choice" && joinPreview ? ( + <> + + {t("entry.previewLabel")} + + {t("entry.agencyCodeValue", { code: joinPreview.code })} + + + {t("entry.previewCustomers", { count: joinPreview.players.length })} + + + + + {t("entry.newPlayer")} + {t("entry.newCustomerDescription")} + + + {t("entry.joinAsCustomer")} + + + + + {t("entry.takeoverTitle")} + {t("entry.recoverCustomerDescription")} + {takeoverDisabled ? ( + {t("entry.alreadyConnected")} + ) : takeoverWaiting ? ( + + {t("entry.takeoverPending")} + { + setTakeoverToken(null); + setTakeoverWaiting(false); + }} + > + {t("common.cancel")} + + + ) : ( + <> + + { + if (dummyOptions.length === 0) return; + setShowDummyOptions((prev) => !prev); + }} + > + + {dummyOptions.find((player) => player.id === takeoverDummyId)?.name + ? `${dummyOptions.find((player) => player.id === takeoverDummyId)?.name} · ${takeoverDummyId}` + : t("entry.selectDummy")} + + + {showDummyOptions && dummyOptions.length > 0 ? ( + + {dummyOptions.map((player) => ( + { + setTakeoverDummyId(player.id); + setShowDummyOptions(false); + }} + > + {player.name} + {player.id} + + ))} + + ) : null} + + + + {t("entry.requestTakeover")} + + + )} + {!takeoverDisabled && dummyOptions.length === 0 ? ( + {t("entry.noDummies")} + ) : null} + + + ) : null} + + navigation.replace("AgencyCreate")} + > + {t("entry.linkOpenNew")} + + + ); +} + +const createStyles = (theme: AppTheme) => + StyleSheet.create({ + scroll: { + flex: 1, + backgroundColor: theme.colors.background, + }, + container: { + gap: 18, + backgroundColor: theme.colors.background, + }, + hero: { + borderRadius: 28, + padding: 22, + gap: 12, + backgroundColor: theme.colors.brandSurface, + borderWidth: 1, + borderColor: theme.colors.brandSurfaceAlt, + }, + heroTitle: { + color: theme.colors.brandText, + fontSize: 28, + fontWeight: "800", + letterSpacing: -0.8, + }, + heroBody: { + color: theme.colors.brandTextMuted, + fontSize: 15, + lineHeight: 22, + }, + card: { + backgroundColor: theme.colors.surface, + borderRadius: 24, + padding: 20, + gap: 12, + borderWidth: 1, + borderColor: theme.colors.border, + }, + cardTitle: { + color: theme.colors.text, + fontSize: 22, + fontWeight: "700", + letterSpacing: -0.4, + }, + cardSubtitle: { + color: theme.colors.textMuted, + fontSize: 14, + lineHeight: 21, + }, + input: { + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.inputBackground, + color: theme.colors.inputText, + borderRadius: 16, + paddingHorizontal: 14, + paddingVertical: 13, + }, + button: { + backgroundColor: theme.colors.primary, + paddingVertical: 14, + borderRadius: 999, + alignItems: "center", + }, + buttonText: { + color: theme.colors.primaryText, + fontWeight: "700", + }, + previewCard: { + borderRadius: 24, + padding: 20, + gap: 8, + backgroundColor: theme.colors.accentSurface, + borderWidth: 1, + borderColor: theme.colors.borderMuted, + }, + previewEyebrow: { + color: theme.colors.accent, + fontSize: 12, + fontWeight: "700", + letterSpacing: 1.1, + textTransform: "uppercase", + }, + previewTitle: { + color: theme.colors.text, + fontSize: 22, + fontWeight: "800", + letterSpacing: -0.5, + }, + previewBody: { + color: theme.colors.textMuted, + }, + choiceCard: { + backgroundColor: theme.colors.surface, + borderRadius: 24, + padding: 20, + gap: 12, + borderWidth: 1, + borderColor: theme.colors.border, + }, + choiceTitle: { + fontSize: 18, + fontWeight: "700", + color: theme.colors.text, + }, + choiceBody: { + color: theme.colors.textMuted, + lineHeight: 20, + }, + dropdown: { + gap: 6, + }, + dropdownButton: { + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.inputBackground, + borderRadius: 16, + paddingHorizontal: 14, + paddingVertical: 13, + }, + dropdownText: { + color: theme.colors.inputText, + fontWeight: "600", + }, + dropdownList: { + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: 16, + backgroundColor: theme.colors.surface, + overflow: "hidden", + }, + pendingBox: { + gap: 10, + backgroundColor: theme.colors.surfaceAlt, + borderRadius: 16, + padding: 14, + borderWidth: 1, + borderColor: theme.colors.borderMuted, + }, + dropdownItem: { + paddingHorizontal: 14, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: theme.colors.borderMuted, + }, + dropdownItemActive: { + backgroundColor: theme.colors.accentSurface, + }, + dropdownItemText: { + fontWeight: "600", + color: theme.colors.text, + }, + dropdownItemMeta: { + color: theme.colors.textMuted, + fontSize: 12, + }, + buttonSecondary: { + backgroundColor: theme.colors.secondary, + paddingVertical: 14, + borderRadius: 999, + alignItems: "center", + }, + buttonSecondaryText: { + color: theme.colors.secondaryText, + fontWeight: "700", + }, + helper: { + fontSize: 12, + color: theme.colors.textMuted, + }, + linkButton: { + paddingVertical: 12, + alignItems: "center", + }, + linkText: { + color: theme.colors.textMuted, + fontWeight: "600", + }, + }); diff --git a/mobile/src/screens/EntryLandingScreen.tsx b/mobile/src/screens/EntryLandingScreen.tsx new file mode 100644 index 0000000..061962a --- /dev/null +++ b/mobile/src/screens/EntryLandingScreen.tsx @@ -0,0 +1,126 @@ +import React, { useMemo } from "react"; +import { + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { useNavigation } from "@react-navigation/native"; +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import BrandLockup from "../components/BrandLockup"; +import { useI18n } from "../i18n"; +import type { RootStackParamList } from "../navigation/types"; +import { useTheme, type AppTheme } from "../theme"; + +export default function EntryLandingScreen() { + const navigation = useNavigation>(); + const { t } = useI18n(); + const theme = useTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); + const insets = useSafeAreaInsets(); + + return ( + + + + {t("entry.landingTitle")} + {t("entry.landingBody")} + + + + navigation.navigate("AgencyJoin")} + > + {t("entry.accessAgency")} + + + navigation.navigate("AgencyCreate")} + > + {t("entry.openAgency")} + + + + {t("entry.landingFooter")} + + ); +} + +const createStyles = (theme: AppTheme) => + StyleSheet.create({ + scroll: { + flex: 1, + backgroundColor: theme.colors.background, + }, + container: { + gap: 18, + backgroundColor: theme.colors.background, + }, + hero: { + borderRadius: 28, + padding: 22, + gap: 14, + backgroundColor: theme.colors.brandSurface, + borderWidth: 1, + borderColor: theme.colors.brandSurfaceAlt, + }, + heroTitle: { + color: theme.colors.brandText, + fontSize: 28, + fontWeight: "800", + letterSpacing: -1, + }, + heroBody: { + color: theme.colors.brandTextMuted, + lineHeight: 21, + fontSize: 14, + }, + actions: { + gap: 12, + }, + primaryButton: { + borderRadius: 999, + paddingVertical: 16, + alignItems: "center", + backgroundColor: theme.colors.primary, + }, + primaryButtonText: { + color: theme.colors.primaryText, + fontSize: 16, + fontWeight: "700", + }, + secondaryButton: { + borderRadius: 999, + paddingVertical: 16, + alignItems: "center", + backgroundColor: theme.colors.surface, + borderWidth: 1, + borderColor: theme.colors.border, + }, + secondaryButtonText: { + color: theme.colors.text, + fontSize: 16, + fontWeight: "700", + }, + footerNote: { + color: theme.colors.textMuted, + fontSize: 13, + lineHeight: 19, + textAlign: "center", + }, + }); diff --git a/mobile/src/screens/EntryScreen.tsx b/mobile/src/screens/EntryScreen.tsx deleted file mode 100644 index 67f1b03..0000000 --- a/mobile/src/screens/EntryScreen.tsx +++ /dev/null @@ -1,456 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { - Alert, - ScrollView, - StyleSheet, - Text, - TextInput, - TouchableOpacity, - View, -} from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useNavigation, useRoute } from "@react-navigation/native"; -import type { RouteProp } from "@react-navigation/native"; -import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; -import type { RootStackParamList } from "../navigation/types"; -import { useSession } from "../state/session-context"; -import type { SessionPreview } from "../shared/types"; -import { useI18n } from "../i18n"; -import { useTheme } from "../theme"; -import type { AppTheme } from "../theme"; - -export default function EntryScreen() { - const navigation = useNavigation>(); - const route = useRoute>(); - const manager = useSession(); - const { t } = useI18n(); - const theme = useTheme(); - const styles = useMemo(() => createStyles(theme), [theme]); - const placeholderColor = theme.colors.placeholder; - const handledLinkRef = useRef(null); - const insets = useSafeAreaInsets(); - const contentStyle = useMemo( - () => [ - styles.container, - { - paddingTop: insets.top + 20, - paddingBottom: insets.bottom + 20, - paddingLeft: insets.left + 20, - paddingRight: insets.right + 20, - }, - ], - [styles.container, insets.top, insets.bottom, insets.left, insets.right], - ); - const [createName, setCreateName] = useState(""); - const [joinCode, setJoinCode] = useState(""); - const [joinStep, setJoinStep] = useState<"code" | "choice">("code"); - const [joinPreview, setJoinPreview] = useState(null); - const [joinName, setJoinName] = useState(""); - const [takeoverName, setTakeoverName] = useState(""); - const [takeoverDummyId, setTakeoverDummyId] = useState(""); - const [showDummyOptions, setShowDummyOptions] = useState(false); - const [takeoverToken, setTakeoverToken] = useState(null); - const [takeoverWaiting, setTakeoverWaiting] = useState(false); - - const dummyOptions = useMemo( - () => joinPreview?.players.filter((player) => player.isDummy) ?? [], - [joinPreview], - ); - const storedPlayer = joinPreview?.players.find((player) => player.id === manager.playerId); - const takeoverDisabled = storedPlayer?.connected === true; - - async function handleCreate() { - const data = await manager.createSession(createName.trim()); - if (data) { - navigation.replace("Lobby"); - } - } - - async function handleJoinPreview() { - if (!joinCode.trim()) { - Alert.alert(t("entry.alert.enterCode")); - return; - } - const preview = await manager.fetchSessionPreview(joinCode.trim().toUpperCase()); - if (!preview) { - Alert.alert(t("entry.alert.sessionNotFound")); - return; - } - setJoinPreview(preview); - setJoinStep("choice"); - } - - useEffect(() => { - const raw = route.params?.gameId; - if (typeof raw !== "string") return; - const normalized = raw.trim(); - if (!normalized) { - if (__DEV__) { - console.log("[deep-link] invalid gameId"); - } - return; - } - const code = normalized.toUpperCase(); - if (handledLinkRef.current === code) return; - handledLinkRef.current = code; - if (__DEV__) { - console.log(`[deep-link] navigating to session ${code}`); - } - setJoinCode(code); - setJoinStep("code"); - setJoinPreview(null); - setJoinName(""); - setTakeoverName(""); - setTakeoverDummyId(""); - setTakeoverToken(null); - setTakeoverWaiting(false); - manager.fetchSessionPreview(code).then((preview) => { - if (!preview) { - Alert.alert(t("entry.alert.sessionNotFound")); - return; - } - setJoinPreview(preview); - setJoinStep("choice"); - }); - }, [route.params?.gameId, manager, t]); - - async function handleJoinNew() { - if (!joinPreview) return; - const data = await manager.joinSession(joinPreview.code, joinName.trim()); - if (data) { - navigation.replace("Lobby"); - } - } - - async function handleTakeover() { - if (!joinPreview) return; - if (!takeoverDummyId) { - Alert.alert(t("entry.alert.selectDummy")); - return; - } - setTakeoverWaiting(true); - const selectedDummy = joinPreview.players.find((player) => player.id === takeoverDummyId); - const fallbackName = takeoverName.trim() || selectedDummy?.name || ""; - const token = await manager.requestTakeoverToken( - joinPreview.code, - takeoverDummyId, - fallbackName, - ); - if (!token) { - setTakeoverWaiting(false); - if (manager.error) { - Alert.alert(manager.error); - } - return; - } - setTakeoverToken(token); - } - - useEffect(() => { - if (joinStep === "code" || !joinPreview) { - setShowDummyOptions(false); - setTakeoverToken(null); - setTakeoverWaiting(false); - } - }, [joinStep, joinPreview]); - - useEffect(() => { - if (!takeoverToken || !joinPreview) return; - let cancelled = false; - let timeout: ReturnType | null = null; - const poll = async () => { - const data = await manager.claimTakeover(joinPreview.code, takeoverToken); - if (cancelled) return; - if (data) { - setTakeoverWaiting(false); - setTakeoverToken(null); - navigation.replace("Lobby"); - return; - } - timeout = setTimeout(poll, 2000); - }; - poll(); - return () => { - cancelled = true; - if (timeout) clearTimeout(timeout); - }; - }, [takeoverToken, joinPreview, manager, navigation]); - - return ( - - {t("app.name")} - {t("entry.subtitle")} - - - {t("entry.joinTitle")} - { - setJoinCode(value.toUpperCase()); - if (joinStep === "choice") { - setJoinStep("code"); - setJoinPreview(null); - setJoinName(""); - setTakeoverName(""); - setTakeoverDummyId(""); - setShowDummyOptions(false); - } - }} - /> - - {joinStep === "code" ? ( - - {t("common.continue")} - - ) : null} - - {joinStep === "choice" && joinPreview ? ( - - - {t("entry.newPlayer")} - - - {t("common.join")} - - - - - {t("entry.takeoverTitle")} - {takeoverDisabled ? ( - {t("entry.alreadyConnected")} - ) : ( - <> - {takeoverWaiting ? ( - - {t("entry.takeoverPending")} - { - setTakeoverToken(null); - setTakeoverWaiting(false); - }} - > - {t("common.cancel")} - - - ) : ( - <> - - { - if (dummyOptions.length === 0) return; - setShowDummyOptions((prev) => !prev); - }} - > - - {dummyOptions.find((player) => player.id === takeoverDummyId)?.name - ? `${dummyOptions.find((player) => player.id === takeoverDummyId)?.name} · ${takeoverDummyId}` - : t("entry.selectDummy")} - - - {showDummyOptions && dummyOptions.length > 0 ? ( - - {dummyOptions.map((player) => ( - { - setTakeoverDummyId(player.id); - setShowDummyOptions(false); - }} - > - {player.name} - {player.id} - - ))} - - ) : null} - - - - - {t("entry.requestTakeover")} - - - - )} - - )} - {!takeoverDisabled && dummyOptions.length === 0 ? ( - {t("entry.noDummies")} - ) : null} - - - ) : null} - - - - {t("entry.createTitle")} - - - {t("entry.openVault")} - - - - ); -} - -const createStyles = (theme: AppTheme) => - StyleSheet.create({ - scroll: { - flex: 1, - backgroundColor: theme.colors.background, - }, - container: { - padding: 0, - gap: 16, - }, - title: { - fontSize: 28, - fontWeight: "700", - color: theme.colors.text, - }, - subtitle: { - fontSize: 16, - color: theme.colors.textMuted, - }, - card: { - backgroundColor: theme.colors.surface, - borderRadius: 16, - padding: 16, - shadowColor: "#000", - shadowOpacity: theme.dark ? 0.2 : 0.08, - shadowRadius: 12, - shadowOffset: { width: 0, height: 6 }, - }, - cardTitle: { - fontSize: 18, - fontWeight: "600", - marginBottom: 12, - color: theme.colors.text, - }, - input: { - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.inputBackground, - color: theme.colors.inputText, - borderRadius: 12, - paddingHorizontal: 12, - paddingVertical: 10, - marginBottom: 10, - }, - dropdown: { - gap: 6, - marginBottom: 10, - }, - dropdownButton: { - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.inputBackground, - borderRadius: 12, - paddingHorizontal: 12, - paddingVertical: 10, - }, - dropdownText: { - color: theme.colors.inputText, - fontWeight: "600", - }, - dropdownList: { - borderWidth: 1, - borderColor: theme.colors.border, - borderRadius: 12, - backgroundColor: theme.colors.surface, - overflow: "hidden", - }, - pendingBox: { - gap: 10, - backgroundColor: theme.colors.surfaceAlt, - borderRadius: 12, - padding: 12, - borderWidth: 1, - borderColor: theme.colors.borderMuted, - }, - dropdownItem: { - paddingHorizontal: 12, - paddingVertical: 10, - borderBottomWidth: 1, - borderBottomColor: theme.colors.borderMuted, - }, - dropdownItemActive: { - backgroundColor: theme.colors.accentSurface, - }, - dropdownItemText: { - fontWeight: "600", - color: theme.colors.text, - }, - dropdownItemMeta: { - color: theme.colors.textMuted, - fontSize: 12, - }, - button: { - backgroundColor: theme.colors.primary, - paddingVertical: 12, - borderRadius: 999, - alignItems: "center", - }, - buttonText: { - color: theme.colors.primaryText, - fontWeight: "600", - }, - buttonSecondary: { - backgroundColor: theme.colors.secondary, - paddingVertical: 12, - borderRadius: 999, - alignItems: "center", - }, - buttonSecondaryText: { - color: theme.colors.secondaryText, - fontWeight: "600", - }, - choiceGrid: { - marginTop: 12, - gap: 12, - }, - choiceCard: { - backgroundColor: theme.colors.surfaceAlt, - borderRadius: 12, - padding: 12, - }, - choiceTitle: { - fontWeight: "600", - marginBottom: 8, - color: theme.colors.text, - }, - helper: { - fontSize: 12, - color: theme.colors.textMuted, - }, - }); diff --git a/mobile/src/screens/LobbyScreen.tsx b/mobile/src/screens/LobbyScreen.tsx index d389bf7..e164194 100644 --- a/mobile/src/screens/LobbyScreen.tsx +++ b/mobile/src/screens/LobbyScreen.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { - FlatList, Platform, + ScrollView, StyleSheet, Text, TextInput, @@ -11,12 +11,12 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useNavigation } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import BrandLockup from "../components/BrandLockup"; +import ExitGameButton from "../components/ExitGameButton"; +import { useI18n } from "../i18n"; import type { RootStackParamList } from "../navigation/types"; import { useSession } from "../state/session-context"; -import { useI18n } from "../i18n"; -import { useTheme } from "../theme"; -import type { AppTheme } from "../theme"; -import ExitGameButton from "../components/ExitGameButton"; +import { useTheme, type AppTheme } from "../theme"; export default function LobbyScreen() { const navigation = useNavigation>(); @@ -29,24 +29,8 @@ export default function LobbyScreen() { const [dummyBalance, setDummyBalance] = useState("1500"); const insets = useSafeAreaInsets(); const topInset = insets.top || (Platform.OS === "ios" ? 44 : 0); - const containerStyle = useMemo( - () => [ - styles.container, - { - paddingTop: topInset + 20, - paddingBottom: insets.bottom + 20, - paddingLeft: insets.left + 20, - paddingRight: insets.right + 20, - }, - ], - [ - styles.container, - topInset, - insets.bottom, - insets.left, - insets.right, - ], - ); + const customers = manager.session?.players.filter((player) => player.role !== "banker") ?? []; + const assistedCount = customers.filter((player) => player.isDummy).length; useEffect(() => { if (!manager.session || !manager.me) return; @@ -57,8 +41,19 @@ export default function LobbyScreen() { if (!manager.session || !manager.me) { return ( - - {t("common.loadingLobby")} + + + {t("common.loadingLobby")} {manager.error ? {manager.error} : null} @@ -67,43 +62,69 @@ export default function LobbyScreen() { const session = manager.session; const me = manager.me; - const canStart = manager.isBanker && session.status === "lobby"; const pendingTakeover = session.takeoverRequests.find( - (request) => - request.requesterId === manager.playerId && request.status === "pending", + (request) => request.requesterId === manager.playerId && request.status === "pending", ); const pendingRequests = manager.isBanker ? session.takeoverRequests.filter((request) => request.status === "pending") : []; return ( - - {t("lobby.title")} - {t("lobby.code", { code: session.code })} + + + + {t("lobby.code", { code: session.code })} + + {manager.isBanker ? t("lobby.heroAdvisor") : t("lobby.heroCustomer")} + + + + + {customers.length} + {t("lobby.customers")} + + + {assistedCount} + {t("lobby.assisted")} + + + + {pendingTakeover ? ( - {t("entry.takeoverPending")} + + {t("entry.takeoverPending")} + ) : null} - item.id} - contentContainerStyle={styles.list} - renderItem={({ item }) => ( - - - {item.name} - - {item.role === "banker" ? t("common.banker") : t("common.player")}{" "} - {item.isDummy ? `- ${t("common.dummy")}` : ""} + + {t("lobby.rosterTitle")} + + {session.players.map((item) => ( + + + {item.name} + + {item.role === "banker" ? t("common.banker") : t("common.player")} + {item.isDummy ? ` · ${t("common.dummy")}` : ""} + + + + {item.connected ? t("common.online") : t("common.offline")} - - {item.connected ? t("common.online") : t("common.offline")} - - - )} - /> + ))} + + {manager.isBanker && pendingRequests.length > 0 ? ( @@ -128,12 +149,12 @@ export default function LobbyScreen() { style={styles.buttonSmall} onPress={() => manager.sendMessage({ - type: "banker_takeover_approve", - sessionId: manager.sessionId, - bankerId: me.id, - dummyId: request.dummyId, - requesterId: request.requesterId, - }) + type: "banker_takeover_approve", + sessionId: manager.sessionId, + bankerId: me.id, + dummyId: request.dummyId, + requesterId: request.requesterId, + }) } > {t("banker.approve")} @@ -145,10 +166,10 @@ export default function LobbyScreen() { ) : null} - {manager.isBanker && session.status === "lobby" && ( + {manager.isBanker ? ( {t("lobby.addDummyTitle")} - {t("lobby.addDummySubtitle")} + {t("lobby.addDummySubtitle")} {t("lobby.addDummyButton")} + ) : ( + + {t("lobby.waitingTitle")} + {t("lobby.waitingBody")} + )} - {canStart && ( + {canStart ? ( @@ -196,128 +222,213 @@ export default function LobbyScreen() { > {t("lobby.startGame")} - )} + ) : null} - + ); } const createStyles = (theme: AppTheme) => StyleSheet.create({ - container: { + scroll: { flex: 1, - paddingHorizontal: 0, - paddingBottom: 0, - gap: 12, backgroundColor: theme.colors.background, }, - title: { - fontSize: 24, - fontWeight: "700", + loadingContainer: { + flex: 1, + gap: 16, + backgroundColor: theme.colors.background, + justifyContent: "center", + }, + loadingText: { color: theme.colors.text, + fontSize: 20, + fontWeight: "700", }, - subtitle: { + hero: { + borderRadius: 28, + padding: 22, + gap: 12, + backgroundColor: theme.colors.brandSurface, + borderWidth: 1, + borderColor: theme.colors.brandSurfaceAlt, + }, + heroTitle: { + color: theme.colors.brandText, + fontSize: 26, + fontWeight: "800", + letterSpacing: -0.8, + }, + heroBody: { + color: theme.colors.brandTextMuted, + fontSize: 15, + lineHeight: 22, + }, + metrics: { + flexDirection: "row", + gap: 12, + }, + metricCard: { + flex: 1, + borderRadius: 18, + padding: 14, + backgroundColor: theme.colors.brandSurfaceAlt, + gap: 4, + }, + metricValue: { + color: theme.colors.brandText, + fontSize: 22, + fontWeight: "800", + }, + metricLabel: { + color: theme.colors.brandTextMuted, + fontSize: 12, + textTransform: "uppercase", + letterSpacing: 1, + }, + noticeCard: { + borderRadius: 18, + padding: 14, + backgroundColor: theme.colors.warningSurface, + borderWidth: 1, + borderColor: theme.colors.warningBorder, + }, + noticeText: { + color: theme.colors.warningTextStrong, + fontWeight: "600", + }, + card: { + backgroundColor: theme.colors.surface, + borderRadius: 24, + padding: 20, + gap: 12, + borderWidth: 1, + borderColor: theme.colors.border, + }, + cardTitle: { + color: theme.colors.text, + fontSize: 20, + fontWeight: "700", + }, + cardSubtitle: { color: theme.colors.textMuted, + lineHeight: 21, }, - list: { + roster: { gap: 10, - paddingBottom: 20, }, listItem: { - backgroundColor: theme.colors.surface, - borderRadius: 12, - padding: 12, + backgroundColor: theme.colors.surfaceAlt, + borderRadius: 18, + padding: 14, flexDirection: "row", justifyContent: "space-between", alignItems: "center", + gap: 12, + }, + listCopy: { + flex: 1, + gap: 3, }, playerName: { - fontWeight: "600", + fontWeight: "700", color: theme.colors.text, }, playerMeta: { fontSize: 12, color: theme.colors.textMuted, }, - card: { - backgroundColor: theme.colors.surface, - borderRadius: 16, - padding: 16, - gap: 10, - borderWidth: 1, - borderColor: theme.colors.borderMuted, - }, - cardTitle: { - fontWeight: "600", - color: theme.colors.text, - }, - helper: { + statusText: { color: theme.colors.textMuted, fontSize: 12, + fontWeight: "600", + textTransform: "uppercase", + letterSpacing: 0.8, }, input: { borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.inputBackground, color: theme.colors.inputText, - borderRadius: 12, - paddingHorizontal: 12, - paddingVertical: 10, + borderRadius: 16, + paddingHorizontal: 14, + paddingVertical: 13, }, button: { backgroundColor: theme.colors.primary, - paddingVertical: 14, + paddingVertical: 15, borderRadius: 999, alignItems: "center", }, buttonText: { color: theme.colors.primaryText, - fontWeight: "600", + fontWeight: "700", }, buttonSecondary: { backgroundColor: theme.colors.secondary, - paddingVertical: 12, + paddingVertical: 14, borderRadius: 999, alignItems: "center", }, buttonSecondaryText: { color: theme.colors.secondaryText, - fontWeight: "600", + fontWeight: "700", + }, + waitingCard: { + backgroundColor: theme.colors.accentSurface, + borderRadius: 24, + padding: 20, + gap: 8, + borderWidth: 1, + borderColor: theme.colors.borderMuted, + }, + waitingTitle: { + color: theme.colors.text, + fontSize: 18, + fontWeight: "700", + }, + waitingBody: { + color: theme.colors.textMuted, + lineHeight: 21, + }, + helper: { + color: theme.colors.textMuted, + fontSize: 12, }, takeoverList: { gap: 10, }, takeoverRow: { + borderRadius: 18, + padding: 14, + backgroundColor: theme.colors.surfaceAlt, + borderWidth: 1, + borderColor: theme.colors.borderMuted, flexDirection: "row", alignItems: "center", - justifyContent: "space-between", - paddingVertical: 6, - borderBottomWidth: 1, - borderBottomColor: theme.colors.borderMuted, + gap: 12, }, takeoverMeta: { flex: 1, - paddingRight: 12, + gap: 3, }, takeoverName: { - fontWeight: "600", + fontWeight: "700", color: theme.colors.text, }, takeoverSub: { - fontSize: 12, color: theme.colors.textMuted, - marginTop: 2, + fontSize: 12, }, buttonSmall: { - backgroundColor: theme.colors.secondary, - paddingHorizontal: 12, - paddingVertical: 6, + backgroundColor: theme.colors.primary, borderRadius: 999, + paddingHorizontal: 14, + paddingVertical: 10, }, buttonSmallText: { - color: theme.colors.secondaryText, - fontWeight: "600", - fontSize: 12, + color: theme.colors.primaryText, + fontWeight: "700", }, }); diff --git a/mobile/src/screens/PlayerTransfersScreen.tsx b/mobile/src/screens/PlayerTransfersScreen.tsx index 79065b1..b903948 100644 --- a/mobile/src/screens/PlayerTransfersScreen.tsx +++ b/mobile/src/screens/PlayerTransfersScreen.tsx @@ -31,9 +31,10 @@ export default function PlayerTransfersScreen() { const theme = useTheme(); const styles = useMemo(() => createStyles(theme), [theme]); const placeholderColor = theme.colors.placeholder; + const screenshotDraft = manager.screenshot?.transferDraft; const [targetId, setTargetId] = useState(""); - const [amount, setAmount] = useState(""); - const [note, setNote] = useState(""); + const [amount, setAmount] = useState(() => screenshotDraft?.amount ?? ""); + const [note, setNote] = useState(() => screenshotDraft?.note ?? ""); const [errorText, setErrorText] = useState(""); const eligible = useMemo( @@ -50,6 +51,18 @@ export default function PlayerTransfersScreen() { } }, [eligible, targetId]); + useEffect(() => { + if (!screenshotDraft?.targetId) return; + if (!eligible.some((player) => player.id === screenshotDraft.targetId)) return; + setTargetId(screenshotDraft.targetId); + }, [eligible, screenshotDraft?.targetId]); + + useEffect(() => { + if (!screenshotDraft) return; + setAmount(screenshotDraft.amount); + setNote(screenshotDraft.note); + }, [screenshotDraft]); + const selectedPlayer = eligible.find((player) => player.id === targetId); const quickAmounts = [10, 25, 50, 100]; const normalizedAmount = amount.replace(",", "."); diff --git a/mobile/src/state/session.ts b/mobile/src/state/session.ts index 83572fa..4ec6a8b 100644 --- a/mobile/src/state/session.ts +++ b/mobile/src/state/session.ts @@ -1,8 +1,13 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { AppState, type AppStateStatus } from "react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types"; import { getApiBaseUrl, getWsUrl } from "../config/api"; +import { + buildScreenshotFixture, + type ScreenshotFixture, + type ScreenshotScene, +} from "../dev/screenshot-fixtures"; import { tStatic } from "../i18n"; import { registerForPushNotificationsAsync } from "../notifications"; import { @@ -51,6 +56,7 @@ export function useSessionManager() { const [sessionCode, setSessionCode] = useState(""); const [playerId, setPlayerId] = useState(""); const [session, setSession] = useState(null); + const [screenshot, setScreenshot] = useState(null); const [error, setError] = useState(null); const [connectionState, setConnectionState] = useState("idle"); @@ -75,6 +81,7 @@ export function useSessionManager() { const lastActivityAtRef = useRef(null); const appStateRef = useRef(AppState.currentState); const lastPushRegistrationRef = useRef(null); + const screenshotRef = useRef(null); function markActivity(at = Date.now()) { lastActivityAtRef.current = at; @@ -307,6 +314,10 @@ export function useSessionManager() { playerIdRef.current = playerId; }, [playerId, sessionCode, sessionId]); + useEffect(() => { + screenshotRef.current = screenshot; + }, [screenshot]); + useEffect(() => { sessionRef.current = session; }, [session]); @@ -314,7 +325,7 @@ export function useSessionManager() { useEffect(() => { let mounted = true; readStoredSession().then((stored) => { - if (!mounted || !stored) return; + if (!mounted || !stored || screenshotRef.current) return; setSessionId(stored.sessionId); setSessionCode(stored.sessionCode); setPlayerId(stored.playerId); @@ -325,6 +336,7 @@ export function useSessionManager() { }, []); useEffect(() => { + if (screenshot) return; let mounted = true; registerForPushNotificationsAsync().then((token) => { if (!mounted) return; @@ -333,9 +345,10 @@ export function useSessionManager() { return () => { mounted = false; }; - }, []); + }, [screenshot]); useEffect(() => { + if (screenshot) return; const subscription = AppState.addEventListener("change", (nextState) => { const previousState = appStateRef.current; appStateRef.current = nextState; @@ -369,14 +382,25 @@ export function useSessionManager() { return () => { subscription.remove(); }; - }, []); + }, [screenshot]); useEffect(() => { + if (screenshot) return; if (!pushToken || !sessionId || !playerId) return; void registerPushTokenFor(sessionId, playerId); - }, [pushToken, sessionId, playerId]); + }, [pushToken, screenshot, sessionId, playerId]); useEffect(() => { + if (screenshot) { + suppressReconnectRef.current = true; + teardownConnection(); + reconnectAttemptRef.current = 0; + setReconnectAttempt(0); + lastActivityAtRef.current = null; + setLastActivityAt(null); + setConnectionState("idle"); + return; + } if (!sessionId || !playerId) { suppressReconnectRef.current = true; teardownConnection(); @@ -397,7 +421,24 @@ export function useSessionManager() { return () => { teardownConnection(); }; - }, [playerId, sessionId]); + }, [playerId, screenshot, sessionId]); + + const activateScreenshotScene = useCallback((scene: ScreenshotScene) => { + const fixture = buildScreenshotFixture(scene); + suppressReconnectRef.current = true; + teardownConnection(); + reconnectAttemptRef.current = 0; + setReconnectAttempt(0); + lastActivityAtRef.current = null; + setLastActivityAt(null); + setConnectionState("idle"); + setError(null); + setScreenshot(fixture); + setSessionId(fixture.sessionId); + setSessionCode(fixture.sessionCode); + setPlayerId(fixture.playerId); + setSession(fixture.session); + }, []); async function registerPushTokenFor(targetSessionId: string, targetPlayerId: string) { if (!pushToken) return; @@ -472,6 +513,10 @@ export function useSessionManager() { async function createSession(bankerName: string) { setError(null); + setScreenshot(null); + setSessionId(""); + setSessionCode(""); + setPlayerId(""); setSession(null); try { const response = await fetch(`${getApiBaseUrl()}/api/session`, { @@ -501,6 +546,10 @@ export function useSessionManager() { async function joinSession(code: string, name: string) { setError(null); + setScreenshot(null); + setSessionId(""); + setSessionCode(""); + setPlayerId(""); setSession(null); if (!code) { setError(tStatic("entry.alert.enterCode")); @@ -572,6 +621,10 @@ export function useSessionManager() { async function claimTakeover(code: string, token: string) { try { + setScreenshot(null); + setSessionId(""); + setSessionCode(""); + setPlayerId(""); const response = await fetch( `${getApiBaseUrl()}/api/session/${code}/takeover-claim`, { @@ -621,6 +674,9 @@ export function useSessionManager() { } function sendMessage(payload: Record) { + if (screenshotRef.current) { + return; + } if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { retryConnection(); setError( @@ -649,6 +705,7 @@ export function useSessionManager() { setSessionCode(""); setPlayerId(""); setSession(null); + setScreenshot(null); setError(null); setConnectionState("idle"); } @@ -687,5 +744,7 @@ export function useSessionManager() { setPlayerId, setSession, requestTakeover, + activateScreenshotScene, + screenshot, }; } diff --git a/mobile/src/theme.ts b/mobile/src/theme.ts index ed4e824..708768b 100644 --- a/mobile/src/theme.ts +++ b/mobile/src/theme.ts @@ -59,102 +59,102 @@ export type AppTheme = { const lightTheme: AppTheme = { dark: false, colors: { - background: "#f7f7f9", - surface: "#ffffff", - surfaceAlt: "#f6f8fa", - text: "#0b1a2b", - textMuted: "#6b7280", - border: "#d8dee5", - borderMuted: "#e2e8f0", - primary: "#1b8b75", - primaryText: "#ffffff", - secondary: "#e7ecef", - secondaryText: "#0c1824", - accent: "#14b8a6", - accentText: "#042f2e", - accentSurface: "#ecfdf9", - danger: "#b91c1c", - warningSurface: "#fff6e5", - warningBorder: "#fde7c1", - warningText: "#b45309", - warningTextStrong: "#7c2d12", - brandSurface: "#0b1a2b", - brandSurfaceAlt: "#1f334d", - brandText: "#f8fafc", - brandTextMuted: "#9fb3c8", - brandAccent: "#14b8a6", - brandAccentText: "#042f2e", - avatarSurface: "#0f172a", - avatarText: "#e2e8f0", - chipBackground: "#ffffff", - chipBorder: "#e2e8f0", - chipText: "#0f172a", - chipActiveBackground: "#0f172a", - chipActiveText: "#f8fafc", - listAvatarBackground: "#e6f6f2", - listAvatarText: "#1b8b75", - bubbleMe: "#dff7ef", - inputBackground: "#ffffff", - inputText: "#0b1a2b", - placeholder: "#9aa6b2", - tabActive: "#0f172a", - tabInactive: "#94a3b8", - headerBackground: "#ffffff", - headerText: "#0b1a2b", - action: "#0f172a", - actionText: "#f8fafc", - radioBorder: "#cbd5f5", + background: "#f5efe6", + surface: "#fffdf8", + surfaceAlt: "#efe4d5", + text: "#162132", + textMuted: "#6d6559", + border: "#d7c8b5", + borderMuted: "#e8decf", + primary: "#162132", + primaryText: "#fffaf2", + secondary: "#e7dac9", + secondaryText: "#162132", + accent: "#b49053", + accentText: "#241a08", + accentSurface: "#f8eedf", + danger: "#a13b2d", + warningSurface: "#f5e8c8", + warningBorder: "#dec89a", + warningText: "#8b621d", + warningTextStrong: "#67470b", + brandSurface: "#162132", + brandSurfaceAlt: "#23314a", + brandText: "#fff8ee", + brandTextMuted: "#cfbea0", + brandAccent: "#b49053", + brandAccentText: "#241a08", + avatarSurface: "#23314a", + avatarText: "#fff8ee", + chipBackground: "#fffaf2", + chipBorder: "#dfd0bd", + chipText: "#162132", + chipActiveBackground: "#162132", + chipActiveText: "#fff8ee", + listAvatarBackground: "#ede1cf", + listAvatarText: "#7a6135", + bubbleMe: "#efe3d2", + inputBackground: "#fffaf2", + inputText: "#162132", + placeholder: "#9e907b", + tabActive: "#162132", + tabInactive: "#978b78", + headerBackground: "#fff8ee", + headerText: "#162132", + action: "#162132", + actionText: "#fff8ee", + radioBorder: "#d4c1a0", }, }; const darkTheme: AppTheme = { dark: true, colors: { - background: "#0b0f14", - surface: "#111922", - surfaceAlt: "#0f1620", - text: "#f8fafc", - textMuted: "#a7b4c5", - border: "#1f2a37", - borderMuted: "#243244", - primary: "#1fbf98", - primaryText: "#ffffff", - secondary: "#1f2a37", - secondaryText: "#e2e8f0", - accent: "#2dd4bf", - accentText: "#04221b", - accentSurface: "#0f2a24", - danger: "#f87171", - warningSurface: "#2a1f0b", - warningBorder: "#5f3b11", - warningText: "#f59e0b", - warningTextStrong: "#fbbf24", - brandSurface: "#101a27", - brandSurfaceAlt: "#1b2b3f", - brandText: "#f8fafc", - brandTextMuted: "#9fb3c8", - brandAccent: "#2dd4bf", - brandAccentText: "#04221b", - avatarSurface: "#1e293b", - avatarText: "#e2e8f0", - chipBackground: "#111922", - chipBorder: "#273244", - chipText: "#e2e8f0", - chipActiveBackground: "#2dd4bf", - chipActiveText: "#04221b", - listAvatarBackground: "#0f2a24", - listAvatarText: "#5eead4", - bubbleMe: "#103128", - inputBackground: "#0f1620", - inputText: "#f8fafc", - placeholder: "#7f90a6", - tabActive: "#e2e8f0", - tabInactive: "#64748b", - headerBackground: "#111922", - headerText: "#f8fafc", - action: "#e2e8f0", - actionText: "#0b1a2b", - radioBorder: "#334155", + background: "#0e1420", + surface: "#141d2c", + surfaceAlt: "#1a2537", + text: "#f8f2e7", + textMuted: "#b1a48e", + border: "#243248", + borderMuted: "#2d3c55", + primary: "#f4ead9", + primaryText: "#162132", + secondary: "#243248", + secondaryText: "#f8f2e7", + accent: "#c5a56a", + accentText: "#251a08", + accentSurface: "#2c2417", + danger: "#ef8b7f", + warningSurface: "#342712", + warningBorder: "#715426", + warningText: "#e5b96a", + warningTextStrong: "#f3d598", + brandSurface: "#121a29", + brandSurfaceAlt: "#1a2740", + brandText: "#fff8ee", + brandTextMuted: "#cebda0", + brandAccent: "#c5a56a", + brandAccentText: "#251a08", + avatarSurface: "#26344d", + avatarText: "#fff8ee", + chipBackground: "#141d2c", + chipBorder: "#32425e", + chipText: "#f8f2e7", + chipActiveBackground: "#c5a56a", + chipActiveText: "#251a08", + listAvatarBackground: "#2d2418", + listAvatarText: "#e4c688", + bubbleMe: "#26311b", + inputBackground: "#101828", + inputText: "#f8f2e7", + placeholder: "#7d8aa0", + tabActive: "#f8f2e7", + tabInactive: "#7f8ca2", + headerBackground: "#121a29", + headerText: "#f8f2e7", + action: "#f4ead9", + actionText: "#162132", + radioBorder: "#43526e", }, };