@idosgames/mcp 0.1.20 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +10 -10
- package/registry/index.json +141 -141
- package/registry/modules/ai.json +7 -7
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/boosts.json +3 -3
- package/registry/modules/character.json +7 -7
- package/registry/modules/chat.json +7 -7
- package/registry/modules/collections.json +7 -7
- package/registry/modules/coop-events.json +7 -7
- package/registry/modules/craft.json +7 -7
- package/registry/modules/deals.json +7 -7
- package/registry/modules/events.json +7 -7
- package/registry/modules/friends.json +7 -7
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +7 -7
- package/registry/modules/leaderboard.json +7 -7
- package/registry/modules/lootboxes.json +3 -3
- package/registry/modules/mailbox.json +3 -3
- package/registry/modules/marketplace.json +7 -7
- package/registry/modules/mini-game.json +7 -7
- package/registry/modules/multiplayer.json +7 -7
- package/registry/modules/premium.json +7 -7
- package/registry/modules/profile.json +7 -7
- package/registry/modules/pvp.json +3 -3
- package/registry/modules/quests.json +3 -3
- package/registry/modules/rewards.json +7 -7
- package/registry/modules/season.json +7 -7
- package/registry/modules/store.json +7 -7
- package/registry/modules/tutorials.json +7 -7
- package/registry/modules/voxelcraft.json +6 -6
- package/registry/modules/workshop.json +7 -7
- package/registry/skills/authentication.json +1 -1
- package/registry/skills/idosgames-module-contract.json +1 -1
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@idosgames/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"description": "MCP server that serves the iDosGames Module & Skills Registry to AI coding agents (Claude Code, Codex, Cursor…): list/pull composable game modules and the host scaffold, and load skills for @idosgames/core, the module contract, and composition.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/registry/host.json
CHANGED
|
@@ -15,15 +15,15 @@
|
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
17
|
"path": "IDOS.md",
|
|
18
|
-
"content": "# IDOS.md — project guide\n\nEvery AI agent working on this game reads this file first: the platform's AI editor loads it into\ncontext on every run, and external agents reach it through `AGENTS.md` / `CLAUDE.md`. Keep it short\nand evergreen — what the game is, how the project is laid out, rules that hold for all of it. The\nhistory of individual features does NOT belong here (see \"Feature history\" at the end).\n\n## This game\n\n<!-- 2–4 lines, filled in once the direction is clear: genre, core loop, target platform, tone.\n Update it when the concept changes — not with every feature. -->\n\n_Not described yet._\n\n## Installed modules\n\nMirror of the `modules` array in `src/modules.ts` (the source of truth), maintained by the\nplatform — do not edit by hand.\n\n<!-- idos:modules:start -->\n\n_None yet._\n<!-- idos:modules:end -->\n\n## How the project is built\n\n**The base + modules** on the iDosGames SDK. The base is sign-in and the lobby; everything else — the\nsystems (shop, heroes, leaderboards…) and the games — is a module. After sign-in the player lands in\nthe lobby: its tabs are the systems' screens, \"Play\" launches a game, and a small button over the\ngame returns to the lobby.\n\n- `src/main.tsx` — creates ONE `IDosGamesClient` and calls `mountHost(...)` with the base first, then\n the modules. The host signs the player in before any module runs; modules never create a client or\n log in themselves.\n- `src/base/` — **the base**, ordinary code of this project: `login/` (the sign-in and play-access\n screens), `lobby/` (tabs, balances, wallet, profile, \"Play\"), `module.ts` (`baseModule`). It takes\n the shared-UI roles `currency-bar`, `wallet`, `account`, `menu`, `modes`, so a game hides its own\n balances and wallet by itself (`ctx.sharedUi.shouldDraw`) — don't edit games for that.\n- `src/app.config.ts` — what happens after sign-in (`start`: `\"lobby\"` or a game's mode id) and the\n order of the lobby's tabs. The look, animations and sounds
|
|
18
|
+
"content": "# IDOS.md — project guide\n\nEvery AI agent working on this game reads this file first: the platform's AI editor loads it into\ncontext on every run, and external agents reach it through `AGENTS.md` / `CLAUDE.md`. Keep it short\nand evergreen — what the game is, how the project is laid out, rules that hold for all of it. The\nhistory of individual features does NOT belong here (see \"Feature history\" at the end).\n\n## This game\n\n<!-- 2–4 lines, filled in once the direction is clear: genre, core loop, target platform, tone.\n Update it when the concept changes — not with every feature. -->\n\n_Not described yet._\n\n## Installed modules\n\nMirror of the `modules` array in `src/modules.ts` (the source of truth), maintained by the\nplatform — do not edit by hand.\n\n<!-- idos:modules:start -->\n\n_None yet._\n<!-- idos:modules:end -->\n\n## How the project is built\n\n**The base + modules** on the iDosGames SDK. The base is sign-in and the lobby; everything else — the\nsystems (shop, heroes, leaderboards…) and the games — is a module. After sign-in the player lands in\nthe lobby: its tabs are the systems' screens, \"Play\" launches a game, and a small button over the\ngame returns to the lobby.\n\n- `src/main.tsx` — creates ONE `IDosGamesClient` and calls `mountHost(...)` with the base first, then\n the modules. The host signs the player in before any module runs; modules never create a client or\n log in themselves.\n- `src/base/` — **the base**, ordinary code of this project: `login/` (the sign-in and play-access\n screens), `lobby/` (tabs, balances, wallet, profile, \"Play\"), `module.ts` (`baseModule`). It takes\n the shared-UI roles `currency-bar`, `wallet`, `account`, `menu`, `modes`, so a game hides its own\n balances and wallet by itself (`ctx.sharedUi.shouldDraw`) — don't edit games for that.\n- `src/app.config.ts` — what happens after sign-in (`start`: `\"lobby\"` or a game's mode id) and the\n order of the lobby's tabs. The look, animations and sounds of the WHOLE app — sign-in included:\n `src/ui.config.ts` (the only file a restyle edits; see the `idosgames-ui-kit` skill).\n- `src/modules.ts` — the composition list. **Regenerated by the platform** whenever modules are\n installed or removed, so it holds only the imports and the `modules` array — nothing else. It is\n also the list of what the app has: remove a system here and its tab is gone.\n- `src/modules/<id>/` — one folder per module: the catalog's systems and games and the game's own\n modules alike. A system registers its screen with `ctx.features.register` (a lobby tab) and hides it\n while the title has nothing for it (`available: false` + `ctx.features.setAvailable`); a game\n registers a route (`ctx.registerRoute`) and gets a \"Play\" button.\n- `src/shared/` — created when needed: code two or more of this game's modules share.\n- `src/idos.title.ts` — **generated, do not edit.** The project's identity: which Title's data this\n game reads and writes, baked in so a packaged mobile build or an iframe embed still knows it.\n- `src/config.ts` — **platform-owned, do not edit** (the build packs the platform's copy over it).\n Resolves the effective title and environment: identity from `src/idos.title.ts`, DEV vs PROD from\n the address (`{id}-dev.idos.games` vs `{id}.idos.games`) — never from the link's query. Never read\n the title from the URL yourself: use `client.titleID`, or `ctx.titleId` inside a module's `setup()`.\n- `src/env.ts` — build-time values from `.env.local` (local development only).\n- `src/walletLogin.tsx` — the wallet sign-in button, generated for web3 titles; safe to edit.\n- `idos.modules.lock.json` — **platform-owned, do not edit.** Which modules came from the catalog,\n their versions and file fingerprints — so updating a module never silently overwrites your edits.\n\n## Where new code goes\n\nFull standard: the `idosgames-project-structure` skill — load it before creating a module, touching\n`src/shared/`, or moving code around.\n\n- A catalog module already does it (a shop, heroes, leaderboards, rewards, …) → install that module\n instead of rebuilding it.\n- It extends an existing module → change that module. The lobby, sign-in, balances, wallet and\n profile → change `src/base/`.\n- It is its own mode, screen or system → a NEW module `src/modules/<feature-id>/` (kebab-case):\n `index.ts` exporting `<camelCaseId>Module`, `module.ts`, `module.meta.json`, and as needed\n `components/` (React UI), `game/` (engine & simulation), `data/` (static tables), `react/` (hooks,\n context). Register it in `src/modules.ts`. A screen → `ctx.features.register` (it becomes a lobby\n tab); a mode of its own → `ctx.registerRoute` (it gets a \"Play\" button).\n- To open another system's screen from your code: `ctx.features.open(\"store\")` — never import it. To\n switch modes: `ctx.navigate(\"lobby\")` or a game's mode id. Other UI shown over every mode → a panel\n with `activeOnly: false`.\n- Modules never reach into each other: a module imports only its own folder, `src/shared/` and npm\n packages — never another module's files or host files. Durable shared state lives in the SDK\n client; live signals go through `ctx.events` with typed topics\n (`defineTopic(\"<module-id>:<event>@1\", shape({…}))`) declared in the module's `module.meta.json`;\n to listen to another module, copy its topic, never import it.\n- `src/shared/` holds code TWO or more modules need (code one module needs stays inside it), by\n purpose — `shared/ui/`, `shared/types/`, `shared/utils/`: types, constants, UI, pure helpers; no\n game state or logic. It never imports a module.\n- When a change adds a new responsibility to a file past ~400 lines, put that part in its own file.\n\n## The SDK is npm packages — its source is NOT in this project\n\n`@idosgames/core`, `@idosgames/react`, `@idosgames/module-sdk`, `@idosgames/app-shell` are\ndependencies in `package.json`. There is no `node_modules` to read and no `.d.ts` to inspect here,\nso **you cannot discover the SDK's API by reading files in this project.**\n\n1. **Load the matching skill first.** Skills are the authoritative recipes for this SDK — one per\n service (store, currency, item, quest, leaderboard, auth, blockchain, …).\n2. Then copy the patterns from the modules already in `src/modules/`.\n3. Never invent an SDK method, hook, or type name — it looks plausible and fails the build.\n\n`client.<service>.<action>()` returns `OperationResult<T>` (check `isOk` / `isFail`; it does not\nthrow). In React, reach the client with `useIDosGamesClient()` and player state with\n`useUserState()` from `@idosgames/react`.\n\n## Backend features live in the Title configuration, not in this repo\n\nCurrencies, items, characters, store offers, quests, lootboxes, leaderboards and seasons are\nconfigured per environment in the platform. There is **no config file in this project** — do not\ncreate `config/*.json`; it would be ignored. Write code against ids that exist in the title; if a\nfeature needs an entity that is not configured yet, name exactly what has to be added instead of\ninventing ids.\n\n## Stack & running\n\n- React 19 + TypeScript (strict) + Vite 8. Game engines (three.js, Phaser) arrive with the modules\n that use them.\n- In the platform's AI editor there is no terminal and no package install: the project is built on\n the server after each turn and shown in the live preview. Use only what `package.json` lists.\n- Locally (exported project or an external agent): `npm install`, then `npm run dev`,\n `npm run build`, `npm run typecheck`.\n- Client code ships to players: never put secrets, keys or tokens in any file here.\n- Assets are not stored in the project — generated images/audio/3D are CDN URLs; reference those.\n\n## Feature history — how this project remembers\n\nThis file stays short on purpose: it is loaded on every run, so every paragraph here is paid for\nby all future work. When a game system or feature is done and something about it is worth\nremembering that the code does not show — why it is designed this way, a non-obvious constraint,\na decision the user made — write it to `docs/feature-history/<slug>.md` (one file per system;\nupdate that file when the system changes) and add ONE line to `docs/feature-history/README.md`:\n`- [Title](slug.md) — one-line gist`. Never append a feature write-up to this file. Those entries\nare not loaded automatically: open the relevant one before changing that system.\n"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
"path": "index.html",
|
|
22
|
-
"content": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>Host Shell · IDosGames SDK</title>\n <style>\n html,\n body,\n #app {\n margin: 0;\n height: 100%;\n background: #
|
|
22
|
+
"content": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>Host Shell · IDosGames SDK</title>\n <style>\n html,\n body,\n #app {\n margin: 0;\n height: 100%;\n /* The theme's default bgBottom (src/ui.config.ts); mountHost repaints it from the theme. */\n background: #0d1033;\n }\n </style>\n </head>\n <body>\n <div id=\"app\"></div>\n <script type=\"module\" src=\"/src/main.tsx\"></script>\n </body>\n</html>\n"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"path": "package.json",
|
|
26
|
-
"content": "{\n \"name\": \"@idosgames/base-template\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The base every AI Coder project starts from: sign-in and the lobby (src/base/) on the host shell. The systems (shop, heroes, leaderboards…) and the games are modules plugged in via src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.
|
|
26
|
+
"content": "{\n \"name\": \"@idosgames/base-template\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"description\": \"The base every AI Coder project starts from: sign-in and the lobby (src/base/) on the host shell. The systems (shop, heroes, leaderboards…) and the games are modules plugged in via src/modules.ts.\",\n \"scripts\": {\n \"dev\": \"vite\",\n \"build\": \"vite build\",\n \"preview\": \"vite preview\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\"\n },\n \"//\": \"Versions are pinned exactly: this is a seed for AI Coder projects, which build offline against a dependency allowlist baked at these versions (see scripts/pack-builder.mjs). Modules bring their own engine deps (three/phaser) when added.\",\n \"dependencies\": {\n \"@idosgames/app-shell\": \"0.10.0\",\n \"@idosgames/core\": \"0.20.0\",\n \"@idosgames/module-sdk\": \"0.8.1\",\n \"@idosgames/react\": \"0.4.2\",\n \"@idosgames/wallet\": \"0.6.1\",\n \"@reown/appkit\": \"1.8.23\",\n \"@reown/appkit-adapter-wagmi\": \"1.8.23\",\n \"@tanstack/react-query\": \"5.101.2\",\n \"react\": \"19.2.7\",\n \"react-dom\": \"19.2.7\",\n \"wagmi\": \"3.7.2\"\n },\n \"devDependencies\": {\n \"@types/react\": \"19.2.17\",\n \"@types/react-dom\": \"19.2.3\",\n \"@vitejs/plugin-react\": \"6.0.4\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"8.1.5\"\n }\n}\n"
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
"path": "public/sw.js",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
45
|
"path": "src/base/lobby/Account.tsx",
|
|
46
|
-
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n
|
|
46
|
+
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport type { FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Button,\n Icon,\n outlined,\n Popup,\n useGameLayout,\n useUiKit,\n v,\n well,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// The player's corner of the lobby header. With the profile module installed (and available) the\n// button opens its screen; otherwise a small popup with the player ID (tap to copy) and \"Log out\".\n// The base takes the \"account\" role, so the host never floats its own copy over the tabs.\n\nexport function Account({\n features,\n}: {\n features: FeatureRegistry;\n}): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const layout = useGameLayout();\n const [open, setOpen] = useState(false);\n const [copied, setCopied] = useState(false);\n const userID = client.auth.context?.userID ?? null;\n const hasProfile = features.get(\"profile\")?.available === true;\n\n const copy = (): void => {\n if (!userID) return;\n void (async () => {\n try {\n await navigator.clipboard.writeText(userID);\n } catch {\n const area = document.createElement(\"textarea\");\n area.value = userID;\n document.body.appendChild(area);\n area.select();\n document.execCommand(\"copy\");\n area.remove();\n }\n setCopied(true);\n window.setTimeout(() => setCopied(false), 1500);\n })();\n };\n const logout = (): void => void client.auth.logout();\n const shortID =\n userID && userID.length > 12\n ? `${userID.slice(0, 5)}…${userID.slice(-5)}`\n : userID;\n const onProfile = (): void => {\n kit.play(\"click\");\n if (hasProfile) features.open(\"profile\");\n else setOpen(true);\n };\n\n return (\n <>\n <button\n type=\"button\"\n className=\"idos-press\"\n aria-label={t(\"profile\")}\n onClick={onProfile}\n style={chip}\n data-tutorial-anchor=\"lobby:profile\"\n >\n <Icon glyph=\"user\" size={layout === \"phone\" ? 30 : 24} />\n {layout !== \"phone\" && shortID ? (\n <span\n style={{\n fontFamily: \"ui-monospace, Consolas, monospace\",\n fontSize: 13,\n }}\n >\n {shortID}\n </span>\n ) : null}\n </button>\n {open ? (\n <Popup title={t(\"profile\")} onClose={() => setOpen(false)} width={380}>\n <div style={{ display: \"grid\", justifyItems: \"center\", gap: 8 }}>\n <Icon glyph=\"user\" size={64} />\n {userID ? (\n <>\n <div style={{ ...outlined, fontSize: 13, color: v.textDim }}>\n {t(\"playerId\")}\n </div>\n <div\n style={{\n ...outlined,\n fontFamily: \"ui-monospace, Consolas, monospace\",\n fontSize: 14,\n wordBreak: \"break-all\",\n textAlign: \"center\",\n }}\n >\n {userID}\n </div>\n </>\n ) : null}\n </div>\n {userID ? (\n <Button tone=\"blue\" onClick={copy}>\n <Icon glyph={copied ? \"check\" : \"copy\"} size={20} />{\" \"}\n {copied ? t(\"copied\") : t(\"copyId\")}\n </Button>\n ) : null}\n <Button tone=\"grey\" onClick={logout}>\n <Icon glyph=\"logout\" size={20} /> {t(\"logout\")}\n </Button>\n </Popup>\n ) : null}\n </>\n );\n}\n\nconst chip: CSSProperties = {\n ...outlined,\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n padding: 3,\n paddingRight: 10,\n borderRadius: 999,\n ...well,\n cursor: \"pointer\",\n whiteSpace: \"nowrap\",\n};\n"
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
49
|
"path": "src/base/lobby/BackToLobby.tsx",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
"path": "src/base/lobby/Lobby.tsx",
|
|
58
|
-
"content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { FeatureEntry, FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n EmptyState,\n FeatureGrid,\n Icon,\n IconButton,\n ResourceCounter,\n outlined,\n screenBackground,\n uiT,\n useCatalog,\n useLayout,\n useUiKit,\n v,\n type Layout,\n} from \"@idosgames/react/ui\";\nimport type { AppConfig } from \"../app-config\";\nimport { Account } from \"./Account\";\nimport { WalletButton } from \"./WalletButton\";\nimport { MORE, arrangeFeatures, splitTabs } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The lobby — the app's home. Its tabs are the modules' features (ctx.features — the shop, heroes,\n// leaders, rewards, the marketplace… and \"Play\" with a button per game); the lobby only lays them\n// out, in three layouts by its width:\n// - phone: balances, the wallet and the profile on top, the screen, 4 tabs + \"More\" at the bottom;\n// - tablet: the same frame, the tab bar a centred pill with room for 6;\n// - desktop: every feature in a side rail, grouped; the screen gets up to 1120px.\n// It is also the PRESENTER of features while on screen: `features.open(id)` from any module switches\n// the lobby to that screen. A feature the title has nothing for yet (`available: false`) is not\n// shown; remove a module from src/modules.ts and its tab is gone.\n\nexport interface LobbyOptions {\n features: FeatureRegistry;\n /** `lobby` of src/app.config.ts: tabs to put first / to lift out of \"More\". */\n lobby: AppConfig[\"lobby\"];\n /** Show the wallet button — a web3 title on an EVM network. */\n wallet: boolean;\n /**\n * A screen asked for while the lobby was not on screen (a game's \"Open for friends\"): the base\n * switched to the lobby, the lobby opens it on mount. See `createBaseModule`.\n */\n pending?: { take(): { id: string; args?: Record<string, unknown> } | null };\n}\n\nconst CONTENT_MAX: Record<Layout, number> = {\n phone: 760,\n tablet: 900,\n desktop: 1120,\n};\n\nexport function makeLobbyPanel(options: LobbyOptions): ComponentType {\n return function LobbyPanel(): ReactNode {\n return <Lobby options={options} />;\n };\n}\n\ninterface Active {\n id: string;\n args?: Record<string, unknown>;\n}\n\nfunction Lobby({ options }: { options: LobbyOptions }): ReactNode {\n const { features } = options;\n const all = useSyncExternalStore(\n features.subscribe,\n features.list,\n features.list,\n );\n const list = arrangeFeatures(\n all.filter((f) => f.available),\n options.lobby,\n );\n const state = useUserState();\n const catalog = useCatalog();\n const kit = useUiKit();\n const rootRef = useRef<HTMLDivElement>(null);\n const layout = useLayout(rootRef);\n const [active, setActive] = useState<Active | null>(\n () => options.pending?.take() ?? null,\n );\n\n // While the lobby is on screen, \"open feature X\" from any module switches the lobby to X.\n useEffect(\n () =>\n features.setPresenter((id, args) => {\n setActive({ id, args });\n return true;\n }),\n [features],\n );\n\n const { tabs, more } = splitTabs(list, layout);\n const first = tabs[0];\n const current: Active | null =\n active && (active.id === MORE || list.some((f) => f.id === active.id))\n ? active\n : first\n ? { id: first.id }\n : null;\n const entry =\n current && current.id !== MORE ? features.get(current.id) : undefined;\n const inMore = current !== null && more.some((f) => f.id === current.id);\n const go = (id: string): void => setActive({ id });\n\n const balances = Object.entries(state?.InventoryV2?.VirtualCurrencies ?? {})\n .filter(\n ([id]) =>\n catalog.currencies[id] !== undefined ||\n Object.keys(catalog.currencies).length === 0,\n )\n .sort(([a], [b]) => a.localeCompare(b));\n\n const desktop = layout === \"desktop\";\n const pad = layout === \"phone\" ? 14 : 24;\n const frame: CSSProperties = desktop\n ? {\n gridTemplateColumns: \"232px minmax(0, 1fr)\",\n gridTemplateRows: \"auto minmax(0, 1fr)\",\n gridTemplateAreas: `\"tabs header\" \"tabs content\"`,\n }\n : {\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gridTemplateRows: \"auto minmax(0, 1fr) auto\",\n gridTemplateAreas: `\"header\" \"content\" \"tabs\"`,\n };\n\n const Screen = entry?.Screen;\n const moreBadge = more.reduce((s, f) => s + (f.badge > 0 ? 1 : 0), 0);\n const inventoryInMore = more.some((f) => f.id === \"inventory\");\n\n return (\n <div\n ref={rootRef}\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n ...frame,\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n overflow: \"hidden\",\n pointerEvents: \"auto\",\n }}\n >\n {/* Balances on the left, the wallet and the profile on the right */}\n <div\n style={{\n gridArea: \"header\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n padding: `12px ${pad}px 6px`,\n minHeight: 20,\n }}\n >\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n flex: \"1 1 auto\",\n minWidth: 0,\n }}\n >\n {balances.map(([id, c]) => (\n <ResourceCounter\n key={id}\n currencyID={id}\n amount={Number(c?.Amount ?? 0)}\n onClick={\n list.some((f) => f.id === \"store\")\n ? () => go(\"store\")\n : undefined\n }\n />\n ))}\n </div>\n {options.wallet ? <WalletButton /> : null}\n <Account features={features} />\n </div>\n\n {/* The screen */}\n <div\n className=\"idos-scroll\"\n style={{\n gridArea: \"content\",\n overflowY: \"auto\",\n padding: `6px ${pad}px ${desktop ? 28 : 18}px`,\n }}\n >\n <div\n key={current?.id ?? \"none\"}\n style={{\n maxWidth: CONTENT_MAX[layout],\n margin: \"0 auto\",\n animation: `idos-fade-in ${v.normal} ease both`,\n }}\n >\n {list.length === 0 ? (\n <EmptyState glyph=\"box\" text={t(\"noFeatures\")} />\n ) : current?.id === MORE ? (\n <FeatureGrid items={more} onOpen={go} />\n ) : Screen && entry ? (\n <>\n {inMore && !desktop ? (\n <BackToMore\n label={catalog.localize(entry.label)}\n icon={entry.icon}\n onBack={() => go(MORE)}\n />\n ) : null}\n <Screen\n args={current?.args}\n close={() =>\n inMore ? go(MORE) : first ? go(first.id) : undefined\n }\n />\n </>\n ) : null}\n </div>\n </div>\n\n {list.length === 0 ? null : desktop ? (\n <SideRail\n features={tabs}\n current={current?.id ?? null}\n onPick={(id) => {\n kit.play(\"tab\");\n go(id);\n }}\n />\n ) : (\n <BottomTabs\n tabs={tabs}\n more={\n more.length > 0\n ? { badge: moreBadge, counter: inventoryInMore }\n : null\n }\n current={inMore ? MORE : (current?.id ?? null)}\n pill={layout === \"tablet\"}\n onPick={(id) => {\n kit.play(\"tab\");\n go(id);\n }}\n />\n )}\n </div>\n );\n}\n\nfunction BackToMore({\n label,\n icon,\n onBack,\n}: {\n label: string;\n icon?: string;\n onBack: () => void;\n}): ReactNode {\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n margin: \"0 0 12px\",\n }}\n >\n <IconButton glyph=\"back\" label={uiT(\"back\")} onClick={onBack} size={36} />\n {icon ? <Icon glyph={icon} size={28} /> : null}\n <span style={{ ...outlined, fontSize: 19 }}>{label}</span>\n </div>\n );\n}\n\n/** Phone: a full-width tab strip at the bottom. Tablet: the same tabs as a centred pill. */\nfunction BottomTabs({\n tabs,\n more,\n current,\n pill,\n onPick,\n}: {\n tabs: FeatureEntry[];\n more: { badge: number; counter: boolean } | null;\n current: string | null;\n pill: boolean;\n onPick: (id: string) => void;\n}): ReactNode {\n const items = [\n ...tabs.map((f) => ({\n id: f.id,\n label: f.label,\n icon: f.icon ?? \"box\",\n badge: f.badge,\n })),\n ...(more\n ? [{ id: MORE, label: t(\"more\"), icon: \"menu\", badge: more.badge }]\n : []),\n ];\n return (\n <nav\n style={{\n gridArea: \"tabs\",\n display: \"grid\",\n gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))`,\n gap: items.length >= 5 ? 2 : 6,\n padding: `8px ${items.length >= 5 ? 4 : 10}px calc(8px + env(safe-area-inset-bottom))`,\n background: \"linear-gradient(180deg, #1b2063, #10133d)\",\n // Longhands only: a shorthand `border` next to `borderTop` confuses React's style diffing.\n borderTop: `2px solid ${v.panelEdge}`,\n ...(pill\n ? {\n borderLeft: `2px solid ${v.panelEdge}`,\n borderRight: `2px solid ${v.panelEdge}`,\n borderBottom: `2px solid ${v.panelEdge}`,\n width: `min(${Math.max(520, items.length * 110)}px, calc(100% - 32px))`,\n justifySelf: \"center\",\n margin: \"0 0 12px\",\n padding: \"8px 10px\",\n borderRadius: 22,\n boxShadow: `0 6px 0 ${v.shadow}`,\n }\n : {}),\n }}\n >\n {items.map((tb) => {\n const active = tb.id === current;\n return (\n <button\n key={tb.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => onPick(tb.id)}\n data-tutorial-anchor={`tab:${tb.id}`}\n data-resource-counter={\n tb.id === \"inventory\" || (tb.id === MORE && more?.counter)\n ? \"inventory\"\n : undefined\n }\n style={{\n position: \"relative\",\n border: \"none\",\n borderRadius: 14,\n padding: pill ? \"8px 6px\" : \"6px 4px\",\n cursor: \"pointer\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n minWidth: 0,\n background: active\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"transparent\",\n boxShadow: active\n ? \"0 3px 0 #164f94, inset 0 2px 0 rgba(255,255,255,.3)\"\n : \"none\",\n transform: active ? \"translateY(-4px)\" : \"none\",\n transition: `transform ${v.fast} ease`,\n }}\n >\n <Icon glyph={tb.icon} size={active ? 30 : 26} />\n <span\n style={{\n ...outlined,\n fontSize: pill ? 13 : items.length >= 5 ? 10.5 : 12,\n letterSpacing: items.length >= 5 && !pill ? -0.2 : 0.2,\n color: active ? v.text : v.textDim,\n maxWidth: \"100%\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {tb.label}\n </span>\n <Badge\n count={tb.badge}\n style={{ top: 0, right: \"calc(50% - 26px)\" }}\n />\n </button>\n );\n })}\n </nav>\n );\n}\n\n/** Desktop: every feature in a rail on the left — the main ones first, the rest grouped. */\nfunction SideRail({\n features,\n current,\n onPick,\n}: {\n features: FeatureEntry[];\n current: string | null;\n onPick: (id: string) => void;\n}): ReactNode {\n const main = features.filter((f) => f.primary);\n const rest = features.filter((f) => !f.primary);\n const row = (f: FeatureEntry): ReactNode => {\n const active = f.id === current;\n return (\n <button\n key={f.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => onPick(f.id)}\n data-tutorial-anchor={`tab:${f.id}`}\n data-resource-counter={f.id === \"inventory\" ? \"inventory\" : undefined}\n style={{\n position: \"relative\",\n border: \"none\",\n borderRadius: 16,\n padding: \"10px 14px\",\n cursor: \"pointer\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n textAlign: \"left\",\n background: active\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"rgba(255,255,255,.04)\",\n boxShadow: active\n ? \"0 4px 0 #164f94, inset 0 2px 0 rgba(255,255,255,.3)\"\n : \"none\",\n transform: active ? \"translateX(4px)\" : \"none\",\n transition: `transform ${v.fast} ease`,\n }}\n >\n <Icon glyph={f.icon ?? \"box\"} size={f.primary ? 34 : 28} />\n <span\n style={{\n ...outlined,\n fontSize: f.primary ? 17 : 15,\n color: active ? v.text : v.textDim,\n }}\n >\n {f.label}\n </span>\n <Badge count={f.badge} style={{ top: 6, right: 8 }} />\n </button>\n );\n };\n return (\n <nav\n className=\"idos-scroll\"\n style={{\n gridArea: \"tabs\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n padding: \"20px 14px\",\n background: \"linear-gradient(180deg, #1b2063, #10133d)\",\n borderRight: `2px solid ${v.panelEdge}`,\n overflowY: \"auto\",\n }}\n >\n {main.map(row)}\n {rest.length > 0 ? (\n <div\n style={{\n height: 2,\n background: v.panelEdge,\n opacity: 0.5,\n margin: \"6px 4px\",\n }}\n />\n ) : null}\n {rest.map(row)}\n </nav>\n );\n}\n"
|
|
58
|
+
"content": "import {\n useEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ComponentType,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport type { FeatureEntry, FeatureRegistry } from \"@idosgames/module-sdk\";\nimport { useUserState } from \"@idosgames/react\";\nimport {\n Badge,\n EmptyState,\n FeatureGrid,\n Icon,\n IconButton,\n type Layout,\n ON_TONE,\n outlined,\n ResourceCounter,\n screenBackground,\n uiT,\n useCatalog,\n useLayout,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport type { AppConfig } from \"../app-config\";\nimport { Account } from \"./Account\";\nimport { WalletButton } from \"./WalletButton\";\nimport { MORE, arrangeFeatures, splitTabs } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The lobby — the app's home. Its tabs are the modules' features (ctx.features — the shop, heroes,\n// leaders, rewards, the marketplace… and \"Play\" with a button per game); the lobby only lays them\n// out, in three layouts by its width:\n// - phone: balances, the wallet and the profile on top, the screen, 4 tabs + \"More\" at the bottom;\n// - tablet: the same frame, the tab bar a centred pill with room for 6;\n// - desktop: every feature in a side rail, grouped; the screen gets up to 1120px.\n// It is also the PRESENTER of features while on screen: `features.open(id)` from any module switches\n// the lobby to that screen. A feature the title has nothing for yet (`available: false`) is not\n// shown; remove a module from src/modules.ts and its tab is gone.\n\nexport interface LobbyOptions {\n features: FeatureRegistry;\n /** `lobby` of src/app.config.ts: tabs to put first / to lift out of \"More\". */\n lobby: AppConfig[\"lobby\"];\n /** Show the wallet button — a web3 title on an EVM network. */\n wallet: boolean;\n /**\n * A screen asked for while the lobby was not on screen (a game's \"Open for friends\"): the base\n * switched to the lobby, the lobby opens it on mount. See `createBaseModule`.\n */\n pending?: { take(): { id: string; args?: Record<string, unknown> } | null };\n}\n\nconst CONTENT_MAX: Record<Layout, number> = {\n phone: 760,\n tablet: 900,\n desktop: 1120,\n};\n\nexport function makeLobbyPanel(options: LobbyOptions): ComponentType {\n return function LobbyPanel(): ReactNode {\n return <Lobby options={options} />;\n };\n}\n\ninterface Active {\n id: string;\n args?: Record<string, unknown>;\n}\n\nfunction Lobby({ options }: { options: LobbyOptions }): ReactNode {\n const { features } = options;\n const all = useSyncExternalStore(\n features.subscribe,\n features.list,\n features.list,\n );\n const list = arrangeFeatures(\n all.filter((f) => f.available),\n options.lobby,\n );\n const state = useUserState();\n const catalog = useCatalog();\n const kit = useUiKit();\n const rootRef = useRef<HTMLDivElement>(null);\n const layout = useLayout(rootRef);\n const [active, setActive] = useState<Active | null>(\n () => options.pending?.take() ?? null,\n );\n\n // While the lobby is on screen, \"open feature X\" from any module switches the lobby to X.\n useEffect(\n () =>\n features.setPresenter((id, args) => {\n setActive({ id, args });\n return true;\n }),\n [features],\n );\n\n const { tabs, more } = splitTabs(list, layout);\n const first = tabs[0];\n const current: Active | null =\n active && (active.id === MORE || list.some((f) => f.id === active.id))\n ? active\n : first\n ? { id: first.id }\n : null;\n const entry =\n current && current.id !== MORE ? features.get(current.id) : undefined;\n const inMore = current !== null && more.some((f) => f.id === current.id);\n const go = (id: string): void => setActive({ id });\n\n const balances = Object.entries(state?.InventoryV2?.VirtualCurrencies ?? {})\n .filter(\n ([id]) =>\n catalog.currencies[id] !== undefined ||\n Object.keys(catalog.currencies).length === 0,\n )\n .sort(([a], [b]) => a.localeCompare(b));\n\n const desktop = layout === \"desktop\";\n const pad = layout === \"phone\" ? 14 : 24;\n const frame: CSSProperties = desktop\n ? {\n gridTemplateColumns: \"232px minmax(0, 1fr)\",\n gridTemplateRows: \"auto minmax(0, 1fr)\",\n gridTemplateAreas: `\"tabs header\" \"tabs content\"`,\n }\n : {\n gridTemplateColumns: \"minmax(0, 1fr)\",\n gridTemplateRows: \"auto minmax(0, 1fr) auto\",\n gridTemplateAreas: `\"header\" \"content\" \"tabs\"`,\n };\n\n const Screen = entry?.Screen;\n const moreBadge = more.reduce((s, f) => s + (f.badge > 0 ? 1 : 0), 0);\n const inventoryInMore = more.some((f) => f.id === \"inventory\");\n\n return (\n <div\n ref={rootRef}\n style={{\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n ...frame,\n background: screenBackground,\n fontFamily: v.font,\n color: v.text,\n overflow: \"hidden\",\n pointerEvents: \"auto\",\n }}\n >\n {/* Balances on the left, the wallet and the profile on the right */}\n <div\n style={{\n gridArea: \"header\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 8,\n padding: `12px ${pad}px 6px`,\n minHeight: 20,\n }}\n >\n <div\n style={{\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n flex: \"1 1 auto\",\n minWidth: 0,\n }}\n >\n {balances.map(([id, c]) => (\n <ResourceCounter\n key={id}\n currencyID={id}\n amount={Number(c?.Amount ?? 0)}\n onClick={\n list.some((f) => f.id === \"store\")\n ? () => go(\"store\")\n : undefined\n }\n />\n ))}\n </div>\n {options.wallet ? <WalletButton /> : null}\n <Account features={features} />\n </div>\n\n {/* The screen */}\n <div\n className=\"idos-scroll\"\n style={{\n gridArea: \"content\",\n overflowY: \"auto\",\n padding: `6px ${pad}px ${desktop ? 28 : 18}px`,\n }}\n >\n <div\n key={current?.id ?? \"none\"}\n style={{\n maxWidth: CONTENT_MAX[layout],\n margin: \"0 auto\",\n animation: `idos-fade-in ${v.normal} ease both`,\n }}\n >\n {list.length === 0 ? (\n <EmptyState glyph=\"box\" text={t(\"noFeatures\")} />\n ) : current?.id === MORE ? (\n <FeatureGrid items={more} onOpen={go} />\n ) : Screen && entry ? (\n <>\n {inMore && !desktop ? (\n <BackToMore\n label={catalog.localize(entry.label)}\n icon={entry.icon}\n onBack={() => go(MORE)}\n />\n ) : null}\n <Screen\n args={current?.args}\n close={() =>\n inMore ? go(MORE) : first ? go(first.id) : undefined\n }\n />\n </>\n ) : null}\n </div>\n </div>\n\n {list.length === 0 ? null : desktop ? (\n <SideRail\n features={tabs}\n current={current?.id ?? null}\n onPick={(id) => {\n kit.play(\"tab\");\n go(id);\n }}\n />\n ) : (\n <BottomTabs\n tabs={tabs}\n more={\n more.length > 0\n ? { badge: moreBadge, counter: inventoryInMore }\n : null\n }\n current={inMore ? MORE : (current?.id ?? null)}\n pill={layout === \"tablet\"}\n onPick={(id) => {\n kit.play(\"tab\");\n go(id);\n }}\n />\n )}\n </div>\n );\n}\n\nfunction BackToMore({\n label,\n icon,\n onBack,\n}: {\n label: string;\n icon?: string;\n onBack: () => void;\n}): ReactNode {\n return (\n <div\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n margin: \"0 0 12px\",\n }}\n >\n <IconButton glyph=\"back\" label={uiT(\"back\")} onClick={onBack} size={36} />\n {icon ? <Icon glyph={icon} size={28} /> : null}\n <span style={{ ...outlined, fontSize: 19 }}>{label}</span>\n </div>\n );\n}\n\n/** Phone: a full-width tab strip at the bottom. Tablet: the same tabs as a centred pill. */\nfunction BottomTabs({\n tabs,\n more,\n current,\n pill,\n onPick,\n}: {\n tabs: FeatureEntry[];\n more: { badge: number; counter: boolean } | null;\n current: string | null;\n pill: boolean;\n onPick: (id: string) => void;\n}): ReactNode {\n const items = [\n ...tabs.map((f) => ({\n id: f.id,\n label: f.label,\n icon: f.icon ?? \"box\",\n badge: f.badge,\n })),\n ...(more\n ? [{ id: MORE, label: t(\"more\"), icon: \"menu\", badge: more.badge }]\n : []),\n ];\n return (\n <nav\n style={{\n gridArea: \"tabs\",\n display: \"grid\",\n gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))`,\n gap: items.length >= 5 ? 2 : 6,\n padding: `8px ${items.length >= 5 ? 4 : 10}px calc(8px + env(safe-area-inset-bottom))`,\n background: `linear-gradient(180deg, ${v.panelDeep}, ${v.bgBottom})`,\n // Longhands only: a shorthand `border` next to `borderTop` confuses React's style diffing.\n borderTop: `2px solid ${v.panelEdge}`,\n ...(pill\n ? {\n borderLeft: `2px solid ${v.panelEdge}`,\n borderRight: `2px solid ${v.panelEdge}`,\n borderBottom: `2px solid ${v.panelEdge}`,\n width: `min(${Math.max(520, items.length * 110)}px, calc(100% - 32px))`,\n justifySelf: \"center\",\n margin: \"0 0 12px\",\n padding: \"8px 10px\",\n borderRadius: 22,\n boxShadow: `0 6px 0 ${v.shadow}`,\n }\n : {}),\n }}\n >\n {items.map((tb) => {\n const active = tb.id === current;\n return (\n <button\n key={tb.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => onPick(tb.id)}\n data-tutorial-anchor={`tab:${tb.id}`}\n data-resource-counter={\n tb.id === \"inventory\" || (tb.id === MORE && more?.counter)\n ? \"inventory\"\n : undefined\n }\n style={{\n position: \"relative\",\n border: \"none\",\n borderRadius: 14,\n padding: pill ? \"8px 6px\" : \"6px 4px\",\n cursor: \"pointer\",\n display: \"grid\",\n justifyItems: \"center\",\n gap: 2,\n minWidth: 0,\n background: active\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : \"transparent\",\n boxShadow: active\n ? `0 3px 0 color-mix(in srgb, ${v.blueDeep} 70%, black), inset 0 2px 0 rgba(255,255,255,.3)`\n : \"none\",\n transform: active ? \"translateY(-4px)\" : \"none\",\n transition: `transform ${v.fast} ease`,\n }}\n >\n <Icon glyph={tb.icon} size={active ? 30 : 26} />\n <span\n style={{\n ...outlined,\n fontSize: pill ? 13 : items.length >= 5 ? 10.5 : 12,\n letterSpacing: items.length >= 5 && !pill ? -0.2 : 0.2,\n ...(active ? ON_TONE.blue : { color: v.textDim }),\n maxWidth: \"100%\",\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {tb.label}\n </span>\n <Badge\n count={tb.badge}\n style={{ top: 0, right: \"calc(50% - 26px)\" }}\n />\n </button>\n );\n })}\n </nav>\n );\n}\n\n/** Desktop: every feature in a rail on the left — the main ones first, the rest grouped. */\nfunction SideRail({\n features,\n current,\n onPick,\n}: {\n features: FeatureEntry[];\n current: string | null;\n onPick: (id: string) => void;\n}): ReactNode {\n const main = features.filter((f) => f.primary);\n const rest = features.filter((f) => !f.primary);\n const row = (f: FeatureEntry): ReactNode => {\n const active = f.id === current;\n return (\n <button\n key={f.id}\n type=\"button\"\n className=\"idos-press\"\n onClick={() => onPick(f.id)}\n data-tutorial-anchor={`tab:${f.id}`}\n data-resource-counter={f.id === \"inventory\" ? \"inventory\" : undefined}\n style={{\n position: \"relative\",\n border: \"none\",\n borderRadius: 16,\n padding: \"10px 14px\",\n cursor: \"pointer\",\n display: \"flex\",\n alignItems: \"center\",\n gap: 12,\n textAlign: \"left\",\n background: active\n ? `linear-gradient(180deg, ${v.blue}, ${v.blueDeep})`\n : `color-mix(in srgb, ${v.text} 5%, transparent)`,\n boxShadow: active\n ? `0 4px 0 color-mix(in srgb, ${v.blueDeep} 70%, black), inset 0 2px 0 rgba(255,255,255,.3)`\n : \"none\",\n transform: active ? \"translateX(4px)\" : \"none\",\n transition: `transform ${v.fast} ease`,\n }}\n >\n <Icon glyph={f.icon ?? \"box\"} size={f.primary ? 34 : 28} />\n <span\n style={{\n ...outlined,\n fontSize: f.primary ? 17 : 15,\n ...(active ? ON_TONE.blue : { color: v.textDim }),\n }}\n >\n {f.label}\n </span>\n <Badge count={f.badge} style={{ top: 6, right: 8 }} />\n </button>\n );\n };\n return (\n <nav\n className=\"idos-scroll\"\n style={{\n gridArea: \"tabs\",\n display: \"flex\",\n flexDirection: \"column\",\n gap: 8,\n padding: \"20px 14px\",\n background: `linear-gradient(180deg, ${v.panelDeep}, ${v.bgBottom})`,\n borderRight: `2px solid ${v.panelEdge}`,\n overflowY: \"auto\",\n }}\n >\n {main.map(row)}\n {rest.length > 0 ? (\n <div\n style={{\n height: 2,\n background: v.panelEdge,\n opacity: 0.5,\n margin: \"6px 4px\",\n }}\n />\n ) : null}\n {rest.map(row)}\n </nav>\n );\n}\n"
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
"path": "src/base/lobby/model.ts",
|
|
@@ -63,23 +63,23 @@
|
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
"path": "src/base/lobby/PlayScreen.tsx",
|
|
66
|
-
"content": "import {\n useSyncExternalStore,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n ModeEntry,\n ModeRegistry,\n MultiplayerRegistry,\n} from \"@idosgames/module-sdk\";\nimport {\n Button,\n Icon,\n outlined,\n panel,\n useStagger,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { gameModes } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The \"Play\" tab of the lobby: a button per installed game. One game — one big card; several — a\n// card each. Pressing it switches the host to the game's mode; the \"To the lobby\" button (see\n// BackToLobby.tsx) brings the player back. A game that plays online (registered in ctx.multiplayer)\n// also gets \"With friends\" — its rooms in the multiplayer system — while that system is on the app.\n\nexport function makePlayScreen(\n modes: ModeRegistry,\n navigate: (modeId: string) => void,\n online: MultiplayerRegistry,\n features: FeatureRegistry,\n): ComponentType<FeatureScreenProps> {\n return function PlayScreen(): ReactNode {\n const list = useSyncExternalStore(modes.subscribe, modes.list, modes.list);\n const onlineGames = useSyncExternalStore(\n online.subscribe,\n online.listGames,\n online.listGames,\n );\n const rooms = useSyncExternalStore(\n features.subscribe,\n () => features.get(\"multiplayer\")?.available === true,\n () => false,\n );\n const games = gameModes(list);\n const stagger = useStagger();\n const kit = useUiKit();\n const start = (mode: ModeEntry): void => {\n kit.play(\"open\");\n navigate(mode.id);\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gap: 14,\n gridTemplateColumns:\n games.length > 1\n ? \"repeat(auto-fill, minmax(240px, 1fr))\"\n : \"minmax(0, 1fr)\",\n maxWidth: games.length > 1 ? undefined : 520,\n margin: \"0 auto\",\n }}\n >\n {games.map((game, i) => (\n <div\n key={game.id}\n className=\"idos-shine\"\n style={{\n ...panel,\n ...stagger(i),\n padding: games.length > 1 ? 18 : 26,\n display: \"grid\",\n gap: 14,\n justifyItems: \"center\",\n background:
|
|
66
|
+
"content": "import {\n useSyncExternalStore,\n type ComponentType,\n type ReactNode,\n} from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n ModeEntry,\n ModeRegistry,\n MultiplayerRegistry,\n} from \"@idosgames/module-sdk\";\nimport {\n Button,\n Icon,\n outlined,\n panel,\n useStagger,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { gameModes } from \"./model\";\nimport { t } from \"./i18n\";\n\n// The \"Play\" tab of the lobby: a button per installed game. One game — one big card; several — a\n// card each. Pressing it switches the host to the game's mode; the \"To the lobby\" button (see\n// BackToLobby.tsx) brings the player back. A game that plays online (registered in ctx.multiplayer)\n// also gets \"With friends\" — its rooms in the multiplayer system — while that system is on the app.\n\nexport function makePlayScreen(\n modes: ModeRegistry,\n navigate: (modeId: string) => void,\n online: MultiplayerRegistry,\n features: FeatureRegistry,\n): ComponentType<FeatureScreenProps> {\n return function PlayScreen(): ReactNode {\n const list = useSyncExternalStore(modes.subscribe, modes.list, modes.list);\n const onlineGames = useSyncExternalStore(\n online.subscribe,\n online.listGames,\n online.listGames,\n );\n const rooms = useSyncExternalStore(\n features.subscribe,\n () => features.get(\"multiplayer\")?.available === true,\n () => false,\n );\n const games = gameModes(list);\n const stagger = useStagger();\n const kit = useUiKit();\n const start = (mode: ModeEntry): void => {\n kit.play(\"open\");\n navigate(mode.id);\n };\n\n return (\n <div\n style={{\n display: \"grid\",\n gap: 14,\n gridTemplateColumns:\n games.length > 1\n ? \"repeat(auto-fill, minmax(240px, 1fr))\"\n : \"minmax(0, 1fr)\",\n maxWidth: games.length > 1 ? undefined : 520,\n margin: \"0 auto\",\n }}\n >\n {games.map((game, i) => (\n <div\n key={game.id}\n className=\"idos-shine\"\n style={{\n ...panel,\n ...stagger(i),\n padding: games.length > 1 ? 18 : 26,\n display: \"grid\",\n gap: 14,\n justifyItems: \"center\",\n background: `linear-gradient(180deg, color-mix(in srgb, ${v.blue} 30%, ${v.panel}) 0%, ${v.panelDeep} 100%)`,\n }}\n >\n <div className=\"idos-bounce\">\n <Icon\n glyph={game.icon ?? \"star\"}\n size={games.length > 1 ? 56 : 84}\n />\n </div>\n <div style={{ ...outlined, fontSize: 22, textAlign: \"center\" }}>\n {game.label}\n </div>\n <Button\n attract\n size=\"lg\"\n style={{ minWidth: 180 }}\n onClick={() => start(game)}\n data-tutorial-anchor={`play:${game.id}`}\n >\n ▶ {t(\"play\")}\n </Button>\n {rooms && onlineGames.some((g) => g.mode === game.id) ? (\n <Button\n tone=\"blue\"\n style={{ minWidth: 180 }}\n onClick={() => features.open(\"multiplayer\", { mode: game.id })}\n data-tutorial-anchor={`play-friends:${game.id}`}\n >\n <Icon glyph=\"friends\" size={18} /> {t(\"withFriends\")}\n </Button>\n ) : null}\n </div>\n ))}\n {games.length === 0 ? (\n <div style={{ ...outlined, color: v.textDim, textAlign: \"center\" }}>\n {t(\"noFeatures\")}\n </div>\n ) : null}\n </div>\n );\n };\n}\n"
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
"path": "src/base/lobby/WalletButton.tsx",
|
|
70
|
-
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport {\n LazyWalletPanel,\n isEmbeddedInPlatform,\n isEmbeddedOnForeignSite,\n openPlatformWalletPanel,\n} from \"@idosgames/wallet/react/lazy\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Icon,\n
|
|
70
|
+
"content": "import { useState, type CSSProperties, type ReactNode } from \"react\";\nimport {\n LazyWalletPanel,\n isEmbeddedInPlatform,\n isEmbeddedOnForeignSite,\n openPlatformWalletPanel,\n} from \"@idosgames/wallet/react/lazy\";\nimport { useIDosGamesClient } from \"@idosgames/react\";\nimport {\n Icon,\n outlined,\n Popup,\n useGameLayout,\n useUiKit,\n well,\n} from \"@idosgames/react/ui\";\nimport { t } from \"./i18n\";\n\n// The app's one wallet entry (deposit / withdraw), next to the balances in the lobby header, on any\n// web3 title — EVM or Solana, the panel is the same and picks the chain by itself.\n//\n// Where the game runs decides what the button does (owner, 2026-09-29):\n// - inside idosgames.com's frame — crypto goes through the SITE's wallet card only, so the button\n// asks the page to show it (`openPlatformWalletPanel`); the game never opens a wallet there;\n// - framed by somebody else's site — no button: there is no wallet of ours to show;\n// - by its own link (`{id}.idos.games`, a custom domain, the AI Coder preview) — the game's own\n// panel with its own wallet connection.\n//\n// Import is \"@idosgames/wallet/react/lazy\", NOT \"@idosgames/wallet/react\": the latter pulls Reown\n// AppKit into the startup graph. The wallet config is memoised per project id, so a wallet connected\n// on the sign-in screen is already connected here.\n\nexport function WalletButton(): ReactNode {\n const client = useIDosGamesClient();\n const kit = useUiKit();\n const layout = useGameLayout();\n const [open, setOpen] = useState(false);\n const embedded = isEmbeddedInPlatform();\n\n if (isEmbeddedOnForeignSite()) return null;\n\n return (\n <>\n <button\n type=\"button\"\n className=\"idos-press\"\n aria-label={t(\"wallet\")}\n onClick={() => {\n kit.play(\"click\");\n if (embedded) void openPlatformWalletPanel();\n else setOpen(true);\n }}\n style={chip}\n data-tutorial-anchor=\"lobby:wallet\"\n >\n <Icon glyph=\"👛\" size={layout === \"phone\" ? 26 : 22} />\n {layout !== \"phone\" ? <span>{t(\"wallet\")}</span> : null}\n </button>\n {open && !embedded ? (\n <Popup title={t(\"wallet\")} onClose={() => setOpen(false)} width={420}>\n <LazyWalletPanel client={client} appName=\"iDosGames\" />\n </Popup>\n ) : null}\n </>\n );\n}\n\nconst chip: CSSProperties = {\n ...outlined,\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: 6,\n padding: \"3px 10px 3px 5px\",\n borderRadius: 999,\n ...well,\n cursor: \"pointer\",\n whiteSpace: \"nowrap\",\n fontSize: 14,\n};\n"
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
"path": "src/base/login/loginIcons.ts",
|
|
74
|
-
"content": "import type { CSSProperties } from \"react\";\nimport {
|
|
74
|
+
"content": "import type { CSSProperties } from \"react\";\nimport { markDataUrl } from \"./logo\";\n\n// Icons of the sign-in buttons (./LoginScreen), inlined as SVG data-URIs like the logo: no asset\n// pipeline and no image files in the project.\n//\n// The provider marks (Google, Telegram) keep the providers' own colours - they tell the player which\n// account the button uses, and Google asks for exactly that mark. Everything else (wallet, iDos,\n// Apple, e-mail, guest) is ONE colour taken from the theme (src/ui.config.ts): an image cannot read a\n// CSS variable, so the screen builds these with the resolved colours.\n\nconst svg = (body: string, viewBox = \"0 0 24 24\"): string =>\n `data:image/svg+xml;utf8,${encodeURIComponent(\n `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"${viewBox}\">${body}</svg>`,\n )}`;\n\nconst line = (paths: string, color: string): string =>\n svg(\n `<g fill=\"none\" stroke=\"${color}\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">${paths}</g>`,\n );\n\n/** `ink` — text on the secondary buttons, `onPrimary` — text on the primary (wallet) button. */\nexport const loginIcons = ({\n ink,\n dim,\n onPrimary,\n}: {\n ink: string;\n dim: string;\n onPrimary: string;\n}) => ({\n wallet: line(\n '<path d=\"M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1\"/><path d=\"M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4\"/>',\n onPrimary,\n ),\n idos: markDataUrl(ink),\n // The multicolor G on a white disc: on the translucent button it would lose its colors.\n google: svg(\n '<circle cx=\"24\" cy=\"24\" r=\"24\" fill=\"#fff\"/><g transform=\"translate(9 9) scale(.625)\"><path fill=\"#FFC107\" d=\"M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z\"/><path fill=\"#FF3D00\" d=\"m6.306 14.691 6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z\"/><path fill=\"#4CAF50\" d=\"M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z\"/><path fill=\"#1976D2\" d=\"M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002 6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z\"/></g>',\n \"0 0 48 48\",\n ),\n apple: svg(\n `<path fill=\"${ink}\" d=\"M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701\"/>`,\n ),\n // Telegram's own mark: the blue disc with the white plane (the path is the disc with the plane cut\n // out, so the plane shows the white circle under it).\n telegram: svg(\n '<circle cx=\"12\" cy=\"12\" r=\"11\" fill=\"#fff\"/><path fill=\"#27a7e7\" d=\"M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z\"/>',\n ),\n email: line(\n '<rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"/><path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/>',\n ink,\n ),\n guest: line(\n '<circle cx=\"12\" cy=\"8\" r=\"5\"/><path d=\"M20 21a8 8 0 0 0-16 0\"/>',\n dim,\n ),\n});\n\n/**\n * An icon at the left edge of a full-width button, with the label kept centered: the same padding\n * on both sides. A background, not a child element, so it also fits the wallet button, whose\n * content the wallet package draws and only takes a `style` from here.\n */\nexport function withIcon(icon: string, under?: string): CSSProperties {\n // `under` — the button's own fill (a gradient), kept as a second image layer below the icon.\n return {\n backgroundImage: under ? `url(\"${icon}\"), ${under}` : `url(\"${icon}\")`,\n backgroundRepeat: \"no-repeat\",\n backgroundPosition: under ? \"14px center, 0 0\" : \"14px center\",\n backgroundSize: under ? \"20px 20px, 100% 100%\" : \"20px 20px\",\n paddingLeft: \"44px\",\n paddingRight: \"44px\",\n };\n}\n"
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
77
|
"path": "src/base/login/LoginScreen.tsx",
|
|
78
|
-
"content": "import { useEffect, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { beginSsoRedirect, type AuthOptionsResponse } from \"@idosgames/core\";\nimport type { LoginScreenProps } from \"@idosgames/app-shell\";\nimport { ENV_GOOGLE_CLIENT_ID } from \"../../env\";\nimport { LOGO_DATA_URL } from \"./logo\";\nimport { LOGIN_ICONS, withIcon } from \"./loginIcons\";\n\n// The Login scene. The host runtime owns WHEN this is shown (the auth gate in\n// @idosgames/app-shell); this file owns what it LOOKS like and which providers it offers.\n// Edit freely — branding, layout, copy, buttons.\n//\n// Providers on `client.auth`: loginWithDeviceID (guest), loginWithEmail + registerWithEmail,\n// loginWithGoogle, loginWithApple, loginWithTelegram, loginWithWallet, loginWithSsoCode, plus\n// forgotPassword/resetPassword.\n//\n// A provider is only rendered when it can actually complete, so players never meet a dead button:\n// guest / email - always available, no external setup (guest only on an open title).\n// Google - the title's own Google sign-in (platform setting) or VITE_IDOS_GOOGLE_CLIENT_ID;\n// the Google Identity script is loaded on demand.\n// Apple - when the title set up Sign in with Apple for the web (a Services ID).\n// Telegram - only inside a Telegram Mini App (it signs in with the app's initData).\n// wallet - always offered; ./walletLogin owns the wagmi config and the challenge network.\n// iDos Games - only where the platform accepts a return_to (see isSsoAvailable below).\n//\n// The title's login mode decides the rest (platform setting: Blockchain -> login mode):\n// Open - every method;\n// AnyWithLinkedWallet - every method except guest (a guest cannot link a wallet);\n// WalletOnly - the wallet and iDos Games only: an iDos Games account plays with a wallet\n// linked to it (one wallet - one account), everything else is refused.\n\n// Куда платформа соглашается вернуть одноразовый код. Список повторяет allowlist на бэкенде\n// (SsoService.AllowedOrigins) НАМЕРЕННО: здесь он решает только, показывать ли кнопку, а\n// настоящий барьер стоит на сервере. Показать кнопку там, где сервер откажет, — значит\n// пообещать игроку вход, который не состоится.\nconst SSO_ORIGINS = [\n \"https://cloud.idosgames.com\",\n \"https://idosgames.com\",\n \"https://www.idosgames.com\",\n];\n\n// Свои адреса игры: {titleid}.idos.games и {titleid}-dev.idos.games (её DEV-копия). Сервер примет\n// каждый ТОЛЬКО для этого же тайтла.\nconst GAME_HOST = /^https:\\/\\/[a-z0-9]{8}(-dev)?\\.idos\\.games$/;\n\nfunction isSsoAvailable(): boolean {\n if (typeof window === \"undefined\") return false;\n const { origin } = window.location;\n if (SSO_ORIGINS.includes(origin) || GAME_HOST.test(origin)) return true;\n\n // Свой домен издателя (play.brand.com) id тайтла не несёт, и по origin не понять, сработает ли\n // вход. Страницу, которую отдаёт платформа, её сервер помечает этой меткой — а на свой домен он\n // отдаёт игру, только когда домен подключён к ЭТОМУ тайтлу. В превью и на чужом хостинге метки нет.\n return (\n typeof document !== \"undefined\" &&\n document.querySelector('meta[name=\"idos-game-host\"]') !== null\n );\n}\n\nexport interface LoginScreenExtras {\n /**\n * Wallet sign-in. Supplied by ./walletLogin (wired in main.tsx), which renders the ready-made\n * `WalletLogin` from `@idosgames/wallet/react`: connect → sign the challenge → session, then\n * `onAuthenticated()`. The screen passes its own `style` so the button matches the theme.\n *\n * A remembered wallet session comes back on the next launch by its refresh token (no new\n * signature); the server re-checks the token balance on it when the title's login mode needs one.\n */\n renderWalletLogin?: (props: {\n client: LoginScreenProps[\"client\"];\n onAuthenticated: () => void;\n disabled: boolean;\n style?: CSSProperties;\n }) => ReactNode;\n}\n\n/** Пауза повторной отправки, когда сервер её не назвал. Совпадает со значением платформы. */\nconst DEFAULT_RESEND_COOLDOWN = 60;\n\n/**\n * Служебный код отказа → фраза, которую можно показать игроку.\n *\n * ⚠ Без этого экран входа показывал коды КАК ЕСТЬ: игрок видел «EMAIL_SENDER_NOT_CONFIGURED» или\n * «VERIFICATION_CODE_ATTEMPTS_EXCEEDED» вместо объяснения. Это шаблон, с которого начинается\n * каждая игра издателя, поэтому такое уезжает сразу всем.\n *\n * Незнакомый код возвращается как есть — намеренно: издателю на стенде он полезнее, чем общая\n * фраза «что-то пошло не так», а список ниже растёт по мере появления новых.\n */\nfunction humanizeAuthError(code: string | undefined): string {\n switch (code) {\n case \"EMAIL_SENDER_NOT_CONFIGURED\":\n return \"Sign-in by e-mail is unavailable in this game right now. Try another way to sign in.\";\n case \"INVALID_VERIFICATION_CODE\":\n return \"That code is not right. Check the e-mail and try again.\";\n case \"VERIFICATION_CODE_ATTEMPTS_EXCEEDED\":\n return \"Too many wrong attempts. Ask for a new code.\";\n case \"INCORRECT_EMAIL_OR_PASSWORD\":\n return \"Wrong e-mail or password.\";\n case \"INCORRECT_EMAIL\":\n return \"That does not look like an e-mail address.\";\n case \"PASSWORD_LENGTH_INVALID\":\n return \"The password must be 8 to 100 characters long.\";\n case \"TOO_MANY_FAILED_ATTEMPTS\":\n return \"Too many attempts. Please try again a little later.\";\n case \"RATE_LIMIT_EXCEEDED\":\n return \"Too many requests. Please try again in a moment.\";\n case \"PLAY_ACCESS_WALLET_LOGIN_ONLY\":\n return \"This game can only be entered with a crypto wallet or your iDos Games account.\";\n case \"PLAY_ACCESS_WALLET_USED_BY_ANOTHER_ACCOUNT\":\n return \"This wallet already plays with another account.\";\n case \"WALLET_UNLINK_COOLDOWN\":\n return \"This wallet was unlinked from another account less than 7 days ago. Try again after the 7 days.\";\n case \"APPLE_EMAIL_MISSING\":\n return \"Apple did not share an e-mail address. Try again and allow it, or use another way to sign in.\";\n case \"PLAY_ACCESS_GUEST_DISABLED\":\n return \"Guest play is off in this game. Sign in to link a crypto wallet.\";\n case \"PLAY_ACCESS_CHECK_FAILED\":\n return \"Could not check your token balance right now. Please try again in a moment.\";\n default:\n return code ?? \"Something went wrong. Please try again.\";\n }\n}\n\ntype Mode = \"menu\" | \"email\" | \"verify\" | \"forgot\" | \"reset\";\n\nconst GOOGLE_SCRIPT = \"https://accounts.google.com/gsi/client\";\nconst APPLE_SCRIPT =\n \"https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js\";\n\n/** Loads a provider's script once; resolves when it has run. */\nfunction loadScript(src: string): Promise<void> {\n if (typeof document === \"undefined\")\n return Promise.reject(new Error(\"no document\"));\n const existing = document.querySelector<HTMLScriptElement>(\n `script[src=\"${src}\"]`,\n );\n if (existing?.dataset.loaded === \"1\") return Promise.resolve();\n return new Promise((resolve, reject) => {\n const el = existing ?? document.createElement(\"script\");\n el.addEventListener(\"load\", () => {\n el.dataset.loaded = \"1\";\n resolve();\n });\n el.addEventListener(\"error\", () =>\n reject(new Error(`could not load ${src}`)),\n );\n if (!existing) {\n el.src = src;\n el.async = true;\n document.head.appendChild(el);\n }\n });\n}\n\n/** A random nonce for the provider's request: the server checks the token carries it, then burns it. */\nfunction randomNonce(): string {\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n return Array.from(bytes, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\n/** Inside a Telegram Mini App the app hands over signed initData - the one thing Telegram login needs. */\nfunction hasTelegramInitData(): boolean {\n const tg = (globalThis as { Telegram?: { WebApp?: { initData?: string } } })\n .Telegram;\n return Boolean(tg?.WebApp?.initData);\n}\n\n/** Minimal Sign in with Apple JS surface - declared here so the template needs no Apple typings. */\ntype AppleIdentity = {\n auth: {\n init(config: {\n clientId: string;\n scope: string;\n redirectURI: string;\n usePopup: boolean;\n nonce?: string;\n }): void;\n signIn(): Promise<{\n authorization: { id_token: string };\n user?: { name?: { firstName?: string; lastName?: string } };\n }>;\n };\n};\n\n/** Minimal Google Identity surface — declared here so the template needs no @types/google.accounts. */\ntype GoogleIdentity = {\n accounts: {\n id: {\n initialize(config: {\n client_id: string;\n callback: (response: { credential?: string }) => void;\n }): void;\n prompt(): void;\n };\n };\n};\n\nexport function LoginScreen({\n client,\n onAuthenticated,\n renderWalletLogin,\n}: LoginScreenProps & LoginScreenExtras): ReactNode {\n const [mode, setMode] = useState<Mode>(\"menu\");\n const [busy, setBusy] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const [email, setEmail] = useState(\"\");\n const [password, setPassword] = useState(\"\");\n const [registering, setRegistering] = useState(false);\n const [code, setCode] = useState(\"\");\n const [resendIn, setResendIn] = useState(0);\n const [resendCooldown, setResendCooldown] = useState(DEFAULT_RESEND_COOLDOWN);\n\n const [remember, setRemember] = useState(true);\n\n const [notice, setNotice] = useState<string | null>(null);\n\n // The title's sign-in settings, read before login: the login mode (so the screen never offers a\n // method the server refuses anyway) and which providers the title set up (Google, Apple). Until\n // they arrive the open-title set is shown - the server check is the real barrier, this only\n // keeps dead buttons off the screen.\n const [options, setOptions] = useState<AuthOptionsResponse | null>(null);\n useEffect(() => {\n let active = true;\n void client.auth.getAuthOptions().then((result) => {\n if (active && result.ok) setOptions(result.data);\n });\n return () => {\n active = false;\n };\n }, [client]);\n const loginMode = options?.PlayAccessMode ?? \"Open\";\n const walletOnly = loginMode === \"WalletOnly\";\n const guestAllowed = loginMode === \"Open\";\n const googleClientID =\n ENV_GOOGLE_CLIENT_ID ||\n (options?.Google?.Enabled ? (options.Google.WebClientID ?? \"\") : \"\");\n const appleServiceID = options?.Apple?.Enabled\n ? (options.Apple.ServiceID ?? \"\")\n : \"\";\n const telegram = hasTelegramInitData();\n const sso = isSsoAvailable();\n\n /** Every provider goes through here, so one place owns the busy flag and the error surface. */\n const run = async (\n login: () => Promise<{ ok: boolean; error?: string }>,\n ): Promise<void> => {\n setBusy(true);\n setError(null);\n // \"Remember me\" is read when the login completes, so set it before starting one. Off = this\n // session works normally but is not written to storage, so the next launch lands here again.\n client.auth.setRememberSession(remember);\n const result = await login();\n if (result.ok) {\n onAuthenticated();\n return;\n }\n setError(\n humanizeAuthError(result.error) ?? \"Sign-in failed. Please try again.\",\n );\n setBusy(false);\n };\n\n /**\n * Регистрация — единственный провайдер, который НЕ обязательно заканчивается входом.\n *\n * Когда тайтл требует подтверждения адреса (а это значение платформы), сервер только отправляет\n * код, и аккаунта ещё нет. Поэтому она идёт мимо `run`: тот на успехе сразу зовёт\n * `onAuthenticated()`, а здесь на успехе надо показать экран ввода кода.\n */\n const register = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n client.auth.setRememberSession(remember);\n\n const result = await client.auth.registerWithEmail(email, password);\n\n if (!result.ok) {\n setError(\n humanizeAuthError(result.error) ?? \"Sign-up failed. Please try again.\",\n );\n setBusy(false);\n return;\n }\n\n // Аккаунта ещё нет — он появится на подтверждении. Уйти в игру здесь значило бы показать\n // пустую сессию.\n setCode(\"\");\n\n // Паузу задаёт СЕРВЕР (её настраивает издатель), поэтому запоминаем её и дальше берём\n // отсюда. Раньше первый отсчёт шёл от ответа, а каждый следующий — от захардкоженных 60\n // секунд: у тайтла с другой настройкой кнопка либо открывалась раньше, чем сервер согласен\n // слать (нажатие впустую, ответ всё равно успешный), либо держалась закрытой дольше нужного.\n const cooldown = result.data.resendCooldownSeconds ?? 0;\n setResendCooldown(cooldown > 0 ? cooldown : DEFAULT_RESEND_COOLDOWN);\n\n setResendIn(cooldown);\n setMode(\"verify\");\n setBusy(false);\n };\n\n const resendCode = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n\n const result = await client.auth.resendVerificationCode(email);\n\n // Ответ почти всегда успешный — начата регистрация или нет, выдержана пауза или нет. Иначе\n // эта кнопка отвечала бы на вопрос «заведён ли такой адрес». Поэтому и таймер заводим всегда.\n //\n // ⚠ Но ОДИН отказ отсюда приходит и его нельзя глотать: «слать нечем» (у тайтла и у\n // платформы нет отправителя). Он про конфигурацию сервера, а не про адрес, поэтому и\n // безопасен, и обязателен — иначе игрок жмёт кнопку до посинения, ожидая письма, которого\n // никто не отправлял.\n if (!result.ok) setError(humanizeAuthError(result.error));\n\n setResendIn(resendCooldown);\n setBusy(false);\n };\n\n // Обратный отсчёт до следующей отправки. Без него игрок жмёт «ещё раз» вслепую, а сервер молча\n // отказывает — и выглядит это как сломанная кнопка.\n useEffect(() => {\n if (resendIn <= 0) return;\n const id = setTimeout(() => setResendIn((v) => v - 1), 1000);\n return () => clearTimeout(id);\n }, [resendIn]);\n\n const signInWithGoogle = async (): Promise<void> => {\n try {\n await loadScript(GOOGLE_SCRIPT);\n } catch {\n // Handled below: no global means the script is unavailable.\n }\n const google = (globalThis as { google?: GoogleIdentity }).google;\n if (!google) {\n setError(\n \"Google sign-in is unavailable: the Google Identity script did not load.\",\n );\n return;\n }\n setError(null);\n google.accounts.id.initialize({\n client_id: googleClientID,\n callback: (response) => {\n if (!response.credential) {\n setError(\"Google sign-in was cancelled.\");\n return;\n }\n void run(() => client.auth.loginWithGoogle(response.credential ?? \"\"));\n },\n });\n google.accounts.id.prompt();\n };\n\n const signInWithApple = async (): Promise<void> => {\n setError(null);\n try {\n await loadScript(APPLE_SCRIPT);\n } catch {\n setError(\"Apple sign-in is unavailable: the Apple script did not load.\");\n return;\n }\n const apple = (globalThis as { AppleID?: AppleIdentity }).AppleID;\n if (!apple) {\n setError(\"Apple sign-in is unavailable: the Apple script did not load.\");\n return;\n }\n const nonce = randomNonce();\n apple.auth.init({\n clientId: appleServiceID,\n scope: \"name email\",\n // Must be a return URL registered for this Services ID; with a popup Apple only checks it.\n redirectURI: `${window.location.origin}/`,\n usePopup: true,\n nonce,\n });\n try {\n const response = await apple.auth.signIn();\n const name = [\n response.user?.name?.firstName,\n response.user?.name?.lastName,\n ]\n .filter(Boolean)\n .join(\" \");\n await run(() =>\n client.auth.loginWithApple(response.authorization.id_token, {\n nonce,\n name,\n }),\n );\n } catch {\n // The player closed Apple's window - nothing to report.\n }\n };\n\n /** \"Forgot password\": the server e-mails a code; the next step sets the new password with it. */\n const sendResetCode = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n const result = await client.auth.forgotPassword(email);\n setBusy(false);\n // Like the resend: the answer does not say whether the address has an account - except \"there\n // is nothing to send with\", which is about the server and must be shown.\n if (!result.ok && result.error === \"EMAIL_SENDER_NOT_CONFIGURED\") {\n setError(humanizeAuthError(result.error));\n return;\n }\n setCode(\"\");\n setPassword(\"\");\n setMode(\"reset\");\n };\n\n const resetPassword = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n const result = await client.auth.resetPassword(\n email,\n code.trim(),\n password,\n );\n setBusy(false);\n if (!result.ok) {\n setError(humanizeAuthError(result.error));\n return;\n }\n setPassword(\"\");\n setNotice(\"Password changed. Sign in with the new one.\");\n setMode(\"email\");\n };\n\n return (\n <div style={styles.root}>\n {/* Placeholder color is a pseudo-element, unreachable from inline styles — this one rule is\n the whole reason for the style tag. */}\n <style>{`.idos-input::placeholder { color: rgba(255, 255, 255, 0.65); }`}</style>\n <div style={styles.card}>\n <img src={LOGO_DATA_URL} alt=\"iDos Games\" style={styles.logo} />\n <h1 style={styles.title}>Sign in</h1>\n\n {mode === \"menu\" && (\n <div style={styles.stack}>\n {renderWalletLogin?.({\n client,\n onAuthenticated,\n disabled: busy,\n style: {\n ...styles.button,\n ...styles.primary,\n ...withIcon(LOGIN_ICONS.wallet),\n },\n })}\n\n {/* Вход платформенным аккаунтом: уходим на idosgames.com/sso и возвращаемся сюда\n с одноразовым кодом, который AuthGate обменяет сам. Кнопка нужна только тем,\n кто открыл игру НАПРЯМУЮ: пришедший с сайта уже вернулся с кодом и этот экран\n не увидит вовсе.\n\n Скрыта там, где SSO заведомо откажет — бэкенд принимает return_to только со\n своих origin'ов, и в превью/на localhost показывать кнопку значило бы обещать\n игроку то, что не сработает. */}\n {sso && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(LOGIN_ICONS.idos) }}\n onClick={() => beginSsoRedirect({ titleID: client.titleID })}\n disabled={busy}\n >\n Continue with iDos Games\n </button>\n )}\n\n {walletOnly && sso && (\n <p style={styles.hint}>\n Signing in with iDos Games? Link your wallet after that - the\n game is played with a wallet.\n </p>\n )}\n\n {!walletOnly && googleClientID && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(LOGIN_ICONS.google) }}\n onClick={() => void signInWithGoogle()}\n disabled={busy}\n >\n Continue with Google\n </button>\n )}\n\n {!walletOnly && appleServiceID && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(LOGIN_ICONS.apple) }}\n onClick={() => void signInWithApple()}\n disabled={busy}\n >\n Continue with Apple\n </button>\n )}\n\n {!walletOnly && telegram && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(LOGIN_ICONS.telegram) }}\n onClick={() => void run(() => client.auth.loginWithTelegram())}\n disabled={busy}\n >\n Continue with Telegram\n </button>\n )}\n\n {!walletOnly && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(LOGIN_ICONS.email) }}\n onClick={() => setMode(\"email\")}\n disabled={busy}\n >\n Continue with email\n </button>\n )}\n\n {guestAllowed && (\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void run(() => client.auth.loginWithDeviceID())}\n disabled={busy}\n >\n <span style={styles.inlineIcon}>\n <img src={LOGIN_ICONS.guest} alt=\"\" width={18} height={18} />\n {busy ? \"Signing in…\" : \"Play as guest\"}\n </span>\n </button>\n )}\n </div>\n )}\n\n {mode === \"email\" && (\n <div style={styles.stack}>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"email\"\n placeholder=\"Email\"\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"password\"\n placeholder=\"Password\"\n value={password}\n onChange={(e) => setPassword(e.target.value)}\n disabled={busy}\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() =>\n registering\n ? void register()\n : void run(() => client.auth.loginWithEmail(email, password))\n }\n disabled={busy || !email || !password}\n >\n {busy\n ? \"Please wait…\"\n : registering\n ? \"Create account\"\n : \"Sign in\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => setRegistering((v) => !v)}\n disabled={busy}\n >\n {registering ? \"I already have an account\" : \"Create an account\"}\n </button>\n {!registering && (\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"forgot\");\n setError(null);\n setNotice(null);\n }}\n disabled={busy}\n >\n Forgot password?\n </button>\n )}\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"menu\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {mode === \"verify\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n We sent a code to <strong>{email}</strong>. Enter it to finish\n creating your account.\n </p>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"text\"\n inputMode=\"numeric\"\n autoComplete=\"one-time-code\"\n placeholder=\"Confirmation code\"\n value={code}\n onChange={(e) => setCode(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() =>\n void run(() =>\n client.auth.confirmEmailRegistration(email, code),\n )\n }\n disabled={busy || !code}\n >\n {busy ? \"Please wait…\" : \"Confirm\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void resendCode()}\n disabled={busy || resendIn > 0}\n >\n {resendIn > 0\n ? `Send again in ${resendIn}s`\n : \"Send the code again\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"email\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {mode === \"forgot\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n Enter your e-mail - we will send a code to set a new password.\n </p>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"email\"\n placeholder=\"Email\"\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() => void sendResetCode()}\n disabled={busy || !email}\n >\n {busy ? \"Please wait...\" : \"Send the code\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"email\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {mode === \"reset\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n If <strong>{email}</strong> has an account, a code is on its way.\n Enter it and choose a new password.\n </p>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"text\"\n inputMode=\"numeric\"\n autoComplete=\"one-time-code\"\n placeholder=\"Code from the e-mail\"\n value={code}\n onChange={(e) => setCode(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"password\"\n autoComplete=\"new-password\"\n placeholder=\"New password\"\n value={password}\n onChange={(e) => setPassword(e.target.value)}\n disabled={busy}\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() => void resetPassword()}\n disabled={busy || !code || !password}\n >\n {busy ? \"Please wait...\" : \"Set the new password\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"forgot\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {notice && !error && <p style={styles.hint}>{notice}</p>}\n\n {/* Applies to every provider above: remembered sessions come back by their refresh token. */}\n <label style={{ ...styles.remember, opacity: busy ? 0.6 : 1 }}>\n <input\n type=\"checkbox\"\n checked={remember}\n disabled={busy}\n onChange={(e) => setRemember(e.target.checked)}\n style={styles.switchInput}\n />\n <span\n style={{\n ...styles.switchTrack,\n background: remember ? \"#fff\" : \"rgba(255, 255, 255, 0.3)\",\n }}\n >\n <span\n style={{\n ...styles.switchKnob,\n left: remember ? \"21px\" : \"3px\",\n background: remember ? \"#0d66fe\" : \"#fff\",\n }}\n />\n </span>\n Remember me\n </label>\n\n {error && <p style={styles.error}>{error}</p>}\n </div>\n </div>\n );\n}\n\n// Brand look: iDos Games blue with the white logo; controls are translucent white on top of it,\n// the primary action is solid white with blue text.\nconst styles: Record<string, CSSProperties> = {\n root: {\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n placeItems: \"center\",\n background: \"#0d66fe\",\n color: \"#fff\",\n font: \"14px system-ui, sans-serif\",\n },\n card: { width: \"min(340px, 88vw)\", display: \"grid\", gap: \"18px\" },\n logo: {\n width: \"180px\",\n justifySelf: \"center\",\n userSelect: \"none\",\n pointerEvents: \"none\",\n },\n remember: {\n display: \"flex\",\n alignItems: \"center\",\n gap: \"10px\",\n justifySelf: \"center\",\n cursor: \"pointer\",\n color: \"rgba(255, 255, 255, 0.85)\",\n },\n // The switch: a hidden real checkbox (keyboard/a11y) with a drawn track + knob on top.\n switchInput: { position: \"absolute\", opacity: 0, width: 0, height: 0 },\n switchTrack: {\n position: \"relative\",\n width: \"42px\",\n height: \"24px\",\n borderRadius: \"12px\",\n transition: \"background 0.15s\",\n flexShrink: 0,\n },\n switchKnob: {\n position: \"absolute\",\n top: \"3px\",\n width: \"18px\",\n height: \"18px\",\n borderRadius: \"50%\",\n transition: \"left 0.15s, background 0.15s\",\n },\n title: { margin: 0, fontSize: \"22px\", fontWeight: 600, textAlign: \"center\" },\n stack: { display: \"grid\", gap: \"10px\" },\n button: {\n padding: \"11px 16px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255, 255, 255, 0.4)\",\n // backgroundColor, not the `background` shorthand: the shorthand would wipe the icon\n // (backgroundImage from ./loginIcons) - here and on the wallet button, whose own default style\n // uses the shorthand.\n backgroundColor: \"rgba(255, 255, 255, 0.14)\",\n color: \"inherit\",\n font: \"inherit\",\n cursor: \"pointer\",\n },\n primary: {\n backgroundColor: \"#fff\",\n borderColor: \"#fff\",\n color: \"#0d66fe\",\n fontWeight: 600,\n },\n ghost: {\n padding: \"8px\",\n border: \"none\",\n background: \"none\",\n color: \"rgba(255, 255, 255, 0.85)\",\n font: \"inherit\",\n cursor: \"pointer\",\n },\n inlineIcon: {\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: \"8px\",\n },\n input: {\n padding: \"11px 12px\",\n borderRadius: \"8px\",\n border: \"1px solid rgba(255, 255, 255, 0.35)\",\n background: \"rgba(255, 255, 255, 0.12)\",\n color: \"inherit\",\n font: \"inherit\",\n },\n error: { margin: 0, color: \"#ffd7d7\", textAlign: \"center\" },\n hint: {\n margin: 0,\n color: \"rgba(255, 255, 255, 0.85)\",\n textAlign: \"center\",\n fontSize: \"14px\",\n lineHeight: 1.45,\n },\n};\n"
|
|
78
|
+
"content": "import {\n useEffect,\n useMemo,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport { beginSsoRedirect, type AuthOptionsResponse } from \"@idosgames/core\";\nimport type { LoginScreenProps } from \"@idosgames/app-shell\";\nimport { ENV_GOOGLE_CLIENT_ID } from \"../../env\";\nimport {\n buttonStyle,\n outlined,\n panel,\n screenBackground,\n useUiKit,\n v,\n} from \"@idosgames/react/ui\";\nimport { logoDataUrl } from \"./logo\";\nimport { loginIcons, withIcon } from \"./loginIcons\";\n\n// The Login scene. The host runtime owns WHEN this is shown (the auth gate in\n// @idosgames/app-shell); this file owns what it LOOKS like and which providers it offers.\n// Edit freely — branding, layout, copy, buttons. Colours, fonts and corner radii are NOT set here: they\n// come from the theme in src/ui.config.ts (the same tokens the lobby and every system use), so a new\n// look is one edit there and sign-in follows it.\n//\n// Providers on `client.auth`: loginWithDeviceID (guest), loginWithEmail + registerWithEmail,\n// loginWithGoogle, loginWithApple, loginWithTelegram, loginWithWallet, loginWithSsoCode, plus\n// forgotPassword/resetPassword.\n//\n// A provider is only rendered when it can actually complete, so players never meet a dead button:\n// guest / email - always available, no external setup (guest only on an open title).\n// Google - the title's own Google sign-in (platform setting) or VITE_IDOS_GOOGLE_CLIENT_ID;\n// the Google Identity script is loaded on demand.\n// Apple - when the title set up Sign in with Apple for the web (a Services ID).\n// Telegram - only inside a Telegram Mini App (it signs in with the app's initData).\n// wallet - always offered; ./walletLogin owns the wagmi config and the challenge network.\n// iDos Games - only where the platform accepts a return_to (see isSsoAvailable below).\n//\n// The title's login mode decides the rest (platform setting: Blockchain -> login mode):\n// Open - every method;\n// AnyWithLinkedWallet - every method except guest (a guest cannot link a wallet);\n// WalletOnly - the wallet and iDos Games only: an iDos Games account plays with a wallet\n// linked to it (one wallet - one account), everything else is refused.\n\n// Куда платформа соглашается вернуть одноразовый код. Список повторяет allowlist на бэкенде\n// (SsoService.AllowedOrigins) НАМЕРЕННО: здесь он решает только, показывать ли кнопку, а\n// настоящий барьер стоит на сервере. Показать кнопку там, где сервер откажет, — значит\n// пообещать игроку вход, который не состоится.\nconst SSO_ORIGINS = [\n \"https://cloud.idosgames.com\",\n \"https://idosgames.com\",\n \"https://www.idosgames.com\",\n];\n\n// Свои адреса игры: {titleid}.idos.games и {titleid}-dev.idos.games (её DEV-копия). Сервер примет\n// каждый ТОЛЬКО для этого же тайтла.\nconst GAME_HOST = /^https:\\/\\/[a-z0-9]{8}(-dev)?\\.idos\\.games$/;\n\nfunction isSsoAvailable(): boolean {\n if (typeof window === \"undefined\") return false;\n const { origin } = window.location;\n if (SSO_ORIGINS.includes(origin) || GAME_HOST.test(origin)) return true;\n\n // Свой домен издателя (play.brand.com) id тайтла не несёт, и по origin не понять, сработает ли\n // вход. Страницу, которую отдаёт платформа, её сервер помечает этой меткой — а на свой домен он\n // отдаёт игру, только когда домен подключён к ЭТОМУ тайтлу. В превью и на чужом хостинге метки нет.\n return (\n typeof document !== \"undefined\" &&\n document.querySelector('meta[name=\"idos-game-host\"]') !== null\n );\n}\n\nexport interface LoginScreenExtras {\n /**\n * Wallet sign-in. Supplied by ./walletLogin (wired in main.tsx), which renders the ready-made\n * `WalletLogin` from `@idosgames/wallet/react`: connect → sign the challenge → session, then\n * `onAuthenticated()`. The screen passes its own `style` so the button matches the theme.\n *\n * A remembered wallet session comes back on the next launch by its refresh token (no new\n * signature); the server re-checks the token balance on it when the title's login mode needs one.\n */\n renderWalletLogin?: (props: {\n client: LoginScreenProps[\"client\"];\n onAuthenticated: () => void;\n disabled: boolean;\n style?: CSSProperties;\n }) => ReactNode;\n}\n\n/** Пауза повторной отправки, когда сервер её не назвал. Совпадает со значением платформы. */\nconst DEFAULT_RESEND_COOLDOWN = 60;\n\n/**\n * Служебный код отказа → фраза, которую можно показать игроку.\n *\n * ⚠ Без этого экран входа показывал коды КАК ЕСТЬ: игрок видел «EMAIL_SENDER_NOT_CONFIGURED» или\n * «VERIFICATION_CODE_ATTEMPTS_EXCEEDED» вместо объяснения. Это шаблон, с которого начинается\n * каждая игра издателя, поэтому такое уезжает сразу всем.\n *\n * Незнакомый код возвращается как есть — намеренно: издателю на стенде он полезнее, чем общая\n * фраза «что-то пошло не так», а список ниже растёт по мере появления новых.\n */\nfunction humanizeAuthError(code: string | undefined): string {\n switch (code) {\n case \"EMAIL_SENDER_NOT_CONFIGURED\":\n return \"Sign-in by e-mail is unavailable in this game right now. Try another way to sign in.\";\n case \"INVALID_VERIFICATION_CODE\":\n return \"That code is not right. Check the e-mail and try again.\";\n case \"VERIFICATION_CODE_ATTEMPTS_EXCEEDED\":\n return \"Too many wrong attempts. Ask for a new code.\";\n case \"INCORRECT_EMAIL_OR_PASSWORD\":\n return \"Wrong e-mail or password.\";\n case \"INCORRECT_EMAIL\":\n return \"That does not look like an e-mail address.\";\n case \"PASSWORD_LENGTH_INVALID\":\n return \"The password must be 8 to 100 characters long.\";\n case \"TOO_MANY_FAILED_ATTEMPTS\":\n return \"Too many attempts. Please try again a little later.\";\n case \"RATE_LIMIT_EXCEEDED\":\n return \"Too many requests. Please try again in a moment.\";\n case \"PLAY_ACCESS_WALLET_LOGIN_ONLY\":\n return \"This game can only be entered with a crypto wallet or your iDos Games account.\";\n case \"PLAY_ACCESS_WALLET_USED_BY_ANOTHER_ACCOUNT\":\n return \"This wallet already plays with another account.\";\n case \"WALLET_UNLINK_COOLDOWN\":\n return \"This wallet was unlinked from another account less than 7 days ago. Try again after the 7 days.\";\n case \"APPLE_EMAIL_MISSING\":\n return \"Apple did not share an e-mail address. Try again and allow it, or use another way to sign in.\";\n case \"PLAY_ACCESS_GUEST_DISABLED\":\n return \"Guest play is off in this game. Sign in to link a crypto wallet.\";\n case \"PLAY_ACCESS_CHECK_FAILED\":\n return \"Could not check your token balance right now. Please try again in a moment.\";\n default:\n return code ?? \"Something went wrong. Please try again.\";\n }\n}\n\ntype Mode = \"menu\" | \"email\" | \"verify\" | \"forgot\" | \"reset\";\n\nconst GOOGLE_SCRIPT = \"https://accounts.google.com/gsi/client\";\nconst APPLE_SCRIPT =\n \"https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js\";\n\n/** Loads a provider's script once; resolves when it has run. */\nfunction loadScript(src: string): Promise<void> {\n if (typeof document === \"undefined\")\n return Promise.reject(new Error(\"no document\"));\n const existing = document.querySelector<HTMLScriptElement>(\n `script[src=\"${src}\"]`,\n );\n if (existing?.dataset.loaded === \"1\") return Promise.resolve();\n return new Promise((resolve, reject) => {\n const el = existing ?? document.createElement(\"script\");\n el.addEventListener(\"load\", () => {\n el.dataset.loaded = \"1\";\n resolve();\n });\n el.addEventListener(\"error\", () =>\n reject(new Error(`could not load ${src}`)),\n );\n if (!existing) {\n el.src = src;\n el.async = true;\n document.head.appendChild(el);\n }\n });\n}\n\n/** A random nonce for the provider's request: the server checks the token carries it, then burns it. */\nfunction randomNonce(): string {\n const bytes = new Uint8Array(16);\n crypto.getRandomValues(bytes);\n return Array.from(bytes, (b) => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\n/** Inside a Telegram Mini App the app hands over signed initData - the one thing Telegram login needs. */\nfunction hasTelegramInitData(): boolean {\n const tg = (globalThis as { Telegram?: { WebApp?: { initData?: string } } })\n .Telegram;\n return Boolean(tg?.WebApp?.initData);\n}\n\n/** Minimal Sign in with Apple JS surface - declared here so the template needs no Apple typings. */\ntype AppleIdentity = {\n auth: {\n init(config: {\n clientId: string;\n scope: string;\n redirectURI: string;\n usePopup: boolean;\n nonce?: string;\n }): void;\n signIn(): Promise<{\n authorization: { id_token: string };\n user?: { name?: { firstName?: string; lastName?: string } };\n }>;\n };\n};\n\n/** Minimal Google Identity surface — declared here so the template needs no @types/google.accounts. */\ntype GoogleIdentity = {\n accounts: {\n id: {\n initialize(config: {\n client_id: string;\n callback: (response: { credential?: string }) => void;\n }): void;\n prompt(): void;\n };\n };\n};\n\nexport function LoginScreen({\n client,\n onAuthenticated,\n renderWalletLogin,\n}: LoginScreenProps & LoginScreenExtras): ReactNode {\n const [mode, setMode] = useState<Mode>(\"menu\");\n const [busy, setBusy] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const [email, setEmail] = useState(\"\");\n const [password, setPassword] = useState(\"\");\n const [registering, setRegistering] = useState(false);\n const [code, setCode] = useState(\"\");\n const [resendIn, setResendIn] = useState(0);\n const [resendCooldown, setResendCooldown] = useState(DEFAULT_RESEND_COOLDOWN);\n\n const [remember, setRemember] = useState(true);\n // Images cannot read CSS variables — the logo and the icons are painted with the resolved theme.\n const theme = useUiKit().config.theme;\n const logo = useMemo(() => logoDataUrl(theme.text), [theme.text]);\n const icons = useMemo(\n () =>\n loginIcons({\n ink: theme.text,\n dim: theme.textDim,\n onPrimary: theme.onGreen,\n }),\n [theme.text, theme.textDim, theme.onGreen],\n );\n\n const [notice, setNotice] = useState<string | null>(null);\n\n // The title's sign-in settings, read before login: the login mode (so the screen never offers a\n // method the server refuses anyway) and which providers the title set up (Google, Apple). Until\n // they arrive the open-title set is shown - the server check is the real barrier, this only\n // keeps dead buttons off the screen.\n const [options, setOptions] = useState<AuthOptionsResponse | null>(null);\n useEffect(() => {\n let active = true;\n void client.auth.getAuthOptions().then((result) => {\n if (active && result.ok) setOptions(result.data);\n });\n return () => {\n active = false;\n };\n }, [client]);\n const loginMode = options?.PlayAccessMode ?? \"Open\";\n const walletOnly = loginMode === \"WalletOnly\";\n const guestAllowed = loginMode === \"Open\";\n const googleClientID =\n ENV_GOOGLE_CLIENT_ID ||\n (options?.Google?.Enabled ? (options.Google.WebClientID ?? \"\") : \"\");\n const appleServiceID = options?.Apple?.Enabled\n ? (options.Apple.ServiceID ?? \"\")\n : \"\";\n const telegram = hasTelegramInitData();\n const sso = isSsoAvailable();\n\n /** Every provider goes through here, so one place owns the busy flag and the error surface. */\n const run = async (\n login: () => Promise<{ ok: boolean; error?: string }>,\n ): Promise<void> => {\n setBusy(true);\n setError(null);\n // \"Remember me\" is read when the login completes, so set it before starting one. Off = this\n // session works normally but is not written to storage, so the next launch lands here again.\n client.auth.setRememberSession(remember);\n const result = await login();\n if (result.ok) {\n onAuthenticated();\n return;\n }\n setError(\n humanizeAuthError(result.error) ?? \"Sign-in failed. Please try again.\",\n );\n setBusy(false);\n };\n\n /**\n * Регистрация — единственный провайдер, который НЕ обязательно заканчивается входом.\n *\n * Когда тайтл требует подтверждения адреса (а это значение платформы), сервер только отправляет\n * код, и аккаунта ещё нет. Поэтому она идёт мимо `run`: тот на успехе сразу зовёт\n * `onAuthenticated()`, а здесь на успехе надо показать экран ввода кода.\n */\n const register = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n client.auth.setRememberSession(remember);\n\n const result = await client.auth.registerWithEmail(email, password);\n\n if (!result.ok) {\n setError(\n humanizeAuthError(result.error) ?? \"Sign-up failed. Please try again.\",\n );\n setBusy(false);\n return;\n }\n\n // Аккаунта ещё нет — он появится на подтверждении. Уйти в игру здесь значило бы показать\n // пустую сессию.\n setCode(\"\");\n\n // Паузу задаёт СЕРВЕР (её настраивает издатель), поэтому запоминаем её и дальше берём\n // отсюда. Раньше первый отсчёт шёл от ответа, а каждый следующий — от захардкоженных 60\n // секунд: у тайтла с другой настройкой кнопка либо открывалась раньше, чем сервер согласен\n // слать (нажатие впустую, ответ всё равно успешный), либо держалась закрытой дольше нужного.\n const cooldown = result.data.resendCooldownSeconds ?? 0;\n setResendCooldown(cooldown > 0 ? cooldown : DEFAULT_RESEND_COOLDOWN);\n\n setResendIn(cooldown);\n setMode(\"verify\");\n setBusy(false);\n };\n\n const resendCode = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n\n const result = await client.auth.resendVerificationCode(email);\n\n // Ответ почти всегда успешный — начата регистрация или нет, выдержана пауза или нет. Иначе\n // эта кнопка отвечала бы на вопрос «заведён ли такой адрес». Поэтому и таймер заводим всегда.\n //\n // ⚠ Но ОДИН отказ отсюда приходит и его нельзя глотать: «слать нечем» (у тайтла и у\n // платформы нет отправителя). Он про конфигурацию сервера, а не про адрес, поэтому и\n // безопасен, и обязателен — иначе игрок жмёт кнопку до посинения, ожидая письма, которого\n // никто не отправлял.\n if (!result.ok) setError(humanizeAuthError(result.error));\n\n setResendIn(resendCooldown);\n setBusy(false);\n };\n\n // Обратный отсчёт до следующей отправки. Без него игрок жмёт «ещё раз» вслепую, а сервер молча\n // отказывает — и выглядит это как сломанная кнопка.\n useEffect(() => {\n if (resendIn <= 0) return;\n const id = setTimeout(() => setResendIn((v) => v - 1), 1000);\n return () => clearTimeout(id);\n }, [resendIn]);\n\n const signInWithGoogle = async (): Promise<void> => {\n try {\n await loadScript(GOOGLE_SCRIPT);\n } catch {\n // Handled below: no global means the script is unavailable.\n }\n const google = (globalThis as { google?: GoogleIdentity }).google;\n if (!google) {\n setError(\n \"Google sign-in is unavailable: the Google Identity script did not load.\",\n );\n return;\n }\n setError(null);\n google.accounts.id.initialize({\n client_id: googleClientID,\n callback: (response) => {\n if (!response.credential) {\n setError(\"Google sign-in was cancelled.\");\n return;\n }\n void run(() => client.auth.loginWithGoogle(response.credential ?? \"\"));\n },\n });\n google.accounts.id.prompt();\n };\n\n const signInWithApple = async (): Promise<void> => {\n setError(null);\n try {\n await loadScript(APPLE_SCRIPT);\n } catch {\n setError(\"Apple sign-in is unavailable: the Apple script did not load.\");\n return;\n }\n const apple = (globalThis as { AppleID?: AppleIdentity }).AppleID;\n if (!apple) {\n setError(\"Apple sign-in is unavailable: the Apple script did not load.\");\n return;\n }\n const nonce = randomNonce();\n apple.auth.init({\n clientId: appleServiceID,\n scope: \"name email\",\n // Must be a return URL registered for this Services ID; with a popup Apple only checks it.\n redirectURI: `${window.location.origin}/`,\n usePopup: true,\n nonce,\n });\n try {\n const response = await apple.auth.signIn();\n const name = [\n response.user?.name?.firstName,\n response.user?.name?.lastName,\n ]\n .filter(Boolean)\n .join(\" \");\n await run(() =>\n client.auth.loginWithApple(response.authorization.id_token, {\n nonce,\n name,\n }),\n );\n } catch {\n // The player closed Apple's window - nothing to report.\n }\n };\n\n /** \"Forgot password\": the server e-mails a code; the next step sets the new password with it. */\n const sendResetCode = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n const result = await client.auth.forgotPassword(email);\n setBusy(false);\n // Like the resend: the answer does not say whether the address has an account - except \"there\n // is nothing to send with\", which is about the server and must be shown.\n if (!result.ok && result.error === \"EMAIL_SENDER_NOT_CONFIGURED\") {\n setError(humanizeAuthError(result.error));\n return;\n }\n setCode(\"\");\n setPassword(\"\");\n setMode(\"reset\");\n };\n\n const resetPassword = async (): Promise<void> => {\n setBusy(true);\n setError(null);\n const result = await client.auth.resetPassword(\n email,\n code.trim(),\n password,\n );\n setBusy(false);\n if (!result.ok) {\n setError(humanizeAuthError(result.error));\n return;\n }\n setPassword(\"\");\n setNotice(\"Password changed. Sign in with the new one.\");\n setMode(\"email\");\n };\n\n return (\n <div style={styles.root}>\n {/* Placeholder color is a pseudo-element, unreachable from inline styles — this one rule is\n the whole reason for the style tag. */}\n <style>{`.idos-input::placeholder { color: ${v.textDim}; opacity: 1; }`}</style>\n <div style={styles.card}>\n <img src={logo} alt=\"iDos Games\" style={styles.logo} />\n <h1 style={styles.title}>Sign in</h1>\n\n {mode === \"menu\" && (\n <div style={styles.stack}>\n {renderWalletLogin?.({\n client,\n onAuthenticated,\n disabled: busy,\n style: {\n ...styles.button,\n ...styles.primary,\n ...withIcon(icons.wallet, PRIMARY_FILL),\n },\n })}\n\n {/* Вход платформенным аккаунтом: уходим на idosgames.com/sso и возвращаемся сюда\n с одноразовым кодом, который AuthGate обменяет сам. Кнопка нужна только тем,\n кто открыл игру НАПРЯМУЮ: пришедший с сайта уже вернулся с кодом и этот экран\n не увидит вовсе.\n\n Скрыта там, где SSO заведомо откажет — бэкенд принимает return_to только со\n своих origin'ов, и в превью/на localhost показывать кнопку значило бы обещать\n игроку то, что не сработает. */}\n {sso && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(icons.idos) }}\n onClick={() => beginSsoRedirect({ titleID: client.titleID })}\n disabled={busy}\n >\n Continue with iDos Games\n </button>\n )}\n\n {walletOnly && sso && (\n <p style={styles.hint}>\n Signing in with iDos Games? Link your wallet after that - the\n game is played with a wallet.\n </p>\n )}\n\n {!walletOnly && googleClientID && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(icons.google) }}\n onClick={() => void signInWithGoogle()}\n disabled={busy}\n >\n Continue with Google\n </button>\n )}\n\n {!walletOnly && appleServiceID && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(icons.apple) }}\n onClick={() => void signInWithApple()}\n disabled={busy}\n >\n Continue with Apple\n </button>\n )}\n\n {!walletOnly && telegram && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(icons.telegram) }}\n onClick={() => void run(() => client.auth.loginWithTelegram())}\n disabled={busy}\n >\n Continue with Telegram\n </button>\n )}\n\n {!walletOnly && (\n <button\n type=\"button\"\n style={{ ...styles.button, ...withIcon(icons.email) }}\n onClick={() => setMode(\"email\")}\n disabled={busy}\n >\n Continue with email\n </button>\n )}\n\n {guestAllowed && (\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void run(() => client.auth.loginWithDeviceID())}\n disabled={busy}\n >\n <span style={styles.inlineIcon}>\n <img src={icons.guest} alt=\"\" width={18} height={18} />\n {busy ? \"Signing in…\" : \"Play as guest\"}\n </span>\n </button>\n )}\n </div>\n )}\n\n {mode === \"email\" && (\n <div style={styles.stack}>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"email\"\n placeholder=\"Email\"\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"password\"\n placeholder=\"Password\"\n value={password}\n onChange={(e) => setPassword(e.target.value)}\n disabled={busy}\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() =>\n registering\n ? void register()\n : void run(() => client.auth.loginWithEmail(email, password))\n }\n disabled={busy || !email || !password}\n >\n {busy\n ? \"Please wait…\"\n : registering\n ? \"Create account\"\n : \"Sign in\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => setRegistering((v) => !v)}\n disabled={busy}\n >\n {registering ? \"I already have an account\" : \"Create an account\"}\n </button>\n {!registering && (\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"forgot\");\n setError(null);\n setNotice(null);\n }}\n disabled={busy}\n >\n Forgot password?\n </button>\n )}\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"menu\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {mode === \"verify\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n We sent a code to <strong>{email}</strong>. Enter it to finish\n creating your account.\n </p>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"text\"\n inputMode=\"numeric\"\n autoComplete=\"one-time-code\"\n placeholder=\"Confirmation code\"\n value={code}\n onChange={(e) => setCode(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() =>\n void run(() =>\n client.auth.confirmEmailRegistration(email, code),\n )\n }\n disabled={busy || !code}\n >\n {busy ? \"Please wait…\" : \"Confirm\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => void resendCode()}\n disabled={busy || resendIn > 0}\n >\n {resendIn > 0\n ? `Send again in ${resendIn}s`\n : \"Send the code again\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"email\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {mode === \"forgot\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n Enter your e-mail - we will send a code to set a new password.\n </p>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"email\"\n placeholder=\"Email\"\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() => void sendResetCode()}\n disabled={busy || !email}\n >\n {busy ? \"Please wait...\" : \"Send the code\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"email\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {mode === \"reset\" && (\n <div style={styles.stack}>\n <p style={styles.hint}>\n If <strong>{email}</strong> has an account, a code is on its way.\n Enter it and choose a new password.\n </p>\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"text\"\n inputMode=\"numeric\"\n autoComplete=\"one-time-code\"\n placeholder=\"Code from the e-mail\"\n value={code}\n onChange={(e) => setCode(e.target.value)}\n disabled={busy}\n autoFocus\n />\n <input\n className=\"idos-input\"\n style={styles.input}\n type=\"password\"\n autoComplete=\"new-password\"\n placeholder=\"New password\"\n value={password}\n onChange={(e) => setPassword(e.target.value)}\n disabled={busy}\n />\n <button\n type=\"button\"\n style={{ ...styles.button, ...styles.primary }}\n onClick={() => void resetPassword()}\n disabled={busy || !code || !password}\n >\n {busy ? \"Please wait...\" : \"Set the new password\"}\n </button>\n <button\n type=\"button\"\n style={styles.ghost}\n onClick={() => {\n setMode(\"forgot\");\n setError(null);\n }}\n disabled={busy}\n >\n Back\n </button>\n </div>\n )}\n\n {notice && !error && <p style={styles.hint}>{notice}</p>}\n\n {/* Applies to every provider above: remembered sessions come back by their refresh token. */}\n <label style={{ ...styles.remember, opacity: busy ? 0.6 : 1 }}>\n <input\n type=\"checkbox\"\n checked={remember}\n disabled={busy}\n onChange={(e) => setRemember(e.target.checked)}\n style={styles.switchInput}\n />\n <span\n style={{\n ...styles.switchTrack,\n background: remember ? v.green : v.wellSoft,\n }}\n >\n <span\n style={{\n ...styles.switchKnob,\n left: remember ? \"21px\" : \"3px\",\n }}\n />\n </span>\n Remember me\n </label>\n\n {error && <p style={styles.error}>{error}</p>}\n </div>\n </div>\n );\n}\n\n// The look is the theme's (src/ui.config.ts, through the UI kit's CSS variables): the screen's\n// gradient, a panel for the card, the kit's green button for the primary action, recessed fields.\n// Change the theme, not these values.\n\n/** The primary button's fill, drawn under the icon layer (see withIcon). */\nconst PRIMARY_FILL = `linear-gradient(180deg, ${v.green} 0%, ${v.greenDeep} 100%)`;\n\nconst styles: Record<string, CSSProperties> = {\n root: {\n position: \"absolute\",\n inset: 0,\n display: \"grid\",\n // Centred by the card's auto margins, not placeItems: a card taller than the screen (a phone in\n // landscape) then starts at the top and scrolls, instead of overflowing above it out of reach.\n padding: \"16px\",\n boxSizing: \"border-box\",\n overflowY: \"auto\",\n background: screenBackground,\n color: v.text,\n fontFamily: v.font,\n fontSize: \"15px\",\n },\n card: {\n ...panel,\n margin: \"auto\",\n width: \"min(360px, 90vw)\",\n boxSizing: \"border-box\",\n padding: \"24px 20px 18px\",\n display: \"grid\",\n gap: \"16px\",\n },\n logo: {\n width: \"160px\",\n justifySelf: \"center\",\n userSelect: \"none\",\n pointerEvents: \"none\",\n },\n remember: {\n display: \"flex\",\n alignItems: \"center\",\n gap: \"10px\",\n justifySelf: \"center\",\n cursor: \"pointer\",\n color: v.textDim,\n fontWeight: 700,\n },\n // The switch: a hidden real checkbox (keyboard/a11y) with a drawn track + knob on top.\n switchInput: { position: \"absolute\", opacity: 0, width: 0, height: 0 },\n switchTrack: {\n position: \"relative\",\n width: \"42px\",\n height: \"24px\",\n borderRadius: \"12px\",\n boxShadow: `inset 0 0 0 1px ${v.line}`,\n transition: \"background 0.15s\",\n flexShrink: 0,\n },\n switchKnob: {\n position: \"absolute\",\n top: \"3px\",\n width: \"18px\",\n height: \"18px\",\n borderRadius: \"50%\",\n background: \"#fff\",\n boxShadow: \"0 1px 3px rgba(0, 0, 0, 0.3)\",\n transition: \"left 0.15s\",\n },\n title: {\n ...outlined,\n margin: 0,\n fontSize: \"24px\",\n fontFamily: v.font,\n textAlign: \"center\",\n },\n stack: { display: \"grid\", gap: \"10px\" },\n button: {\n padding: \"11px 16px\",\n minHeight: \"46px\",\n borderRadius: v.buttonRadius,\n border: `1px solid ${v.line}`,\n // backgroundColor, not the `background` shorthand: the shorthand would wipe the icon\n // (backgroundImage from ./loginIcons) - here and on the wallet button, whose own default style\n // uses the shorthand.\n backgroundColor: v.wellSoft,\n color: v.text,\n font: \"inherit\",\n fontWeight: 700,\n cursor: \"pointer\",\n },\n // The kit's green button — its text colour and outline follow the theme — with the fill as an image\n // layer, so an icon can sit on top of it.\n primary: {\n ...buttonStyle(\"green\"),\n background: undefined,\n backgroundImage: PRIMARY_FILL,\n border: \"none\",\n width: \"100%\",\n font: \"inherit\",\n fontWeight: 800,\n },\n ghost: {\n padding: \"8px\",\n border: \"none\",\n background: \"none\",\n color: v.textDim,\n font: \"inherit\",\n fontWeight: 700,\n cursor: \"pointer\",\n },\n inlineIcon: {\n display: \"inline-flex\",\n alignItems: \"center\",\n gap: \"8px\",\n },\n input: {\n padding: \"12px 14px\",\n borderRadius: v.buttonRadius,\n border: `1px solid ${v.line}`,\n background: v.well,\n boxShadow: v.wellShadow,\n color: v.text,\n font: \"inherit\",\n outline: \"none\",\n },\n error: { margin: 0, color: v.red, fontWeight: 700, textAlign: \"center\" },\n hint: {\n margin: 0,\n color: v.textDim,\n textAlign: \"center\",\n fontSize: \"14px\",\n lineHeight: 1.45,\n },\n};\n"
|
|
79
79
|
},
|
|
80
80
|
{
|
|
81
81
|
"path": "src/base/login/logo.ts",
|
|
82
|
-
"content": "// The iDos Games logo
|
|
82
|
+
"content": "// The iDos Games logo, inlined as an SVG data-URI so it needs no asset pipeline: it survives the\n// template seed (text files only) and needs no image file in the project. Swap the paths to rebrand.\n// Painted in ONE colour passed in — the sign-in screen gives it the theme's text colour\n// (src/ui.config.ts), so the logo follows the theme instead of carrying its own.\n//\n// Composition mirrors the original brand mark: the spartan helmet on top, \"iDOS GAMES\" in one\n// line below it. The three path groups are the untouched brand-source paths (helmet / iDOS /\n// GAMES), positioned with transforms.\n\n// The helmet alone: the logo reuses it, and so does the small square mark (MARK_DATA_URL).\nconst HELMET_PATHS = `<path d=\"M147.333 12.333c-13.6 2.534-12.533 1.2-12.933 15.067l-.4 12.267-4.533-11.067c-2.534-6-5.067-10.933-5.734-10.933-2.666 0-27.866 8.666-36.533 12.666-11.733 5.334-26.667 14.8-36.133 22.934l-6.934 5.866L51.6 69.267c8.133 11.066 7.333 11.466-5.067 2.933-7.6-5.067-8.4-5.333-11.466-3.733-3.467 1.866-18.4 17.333-20.4 21.2-.8 1.6 7.066 8.8 33.733 30.933l34.667 28.933 8.8-8.266c14.666-13.867 28.666-22 48.133-28.267 9.067-2.933 14.8-3.733 28.533-4.4 34.267-1.333 60 7.467 81.6 28.133 21.6 20.4 34.934 49.6 37.467 81.734l.8 11.2 14.8 4.533c8.133 2.533 23.733 7.333 34.667 10.8 10.933 3.333 20.4 5.6 21.066 4.933 1.6-1.6 6.134-20.533 7.734-32.666.8-6 1.333-17.6 1.066-25.6l-.4-14.667-8-1.067c-4.4-.533-10.133-1.466-12.666-1.866l-4.667-.8 4.667-1.2c16.933-4.534 18.666-5.2 18.666-8 0-5.467-9.6-31.334-16.666-44.8-4-7.6-10.667-18.667-14.934-24.667-9.866-13.867-31.333-35.467-45.2-45.2-10.4-7.467-34.4-20.933-37.2-21.067-.8 0-4 4-7.333 8.8-3.2 4.8-6.133 8.134-6.4 7.334s.4-5.6 1.6-10.667c1.2-5.2 2.133-9.6 2.133-9.867 0-.933-19.866-6.133-34.4-9.066-14.8-2.934-49.333-4.4-59.6-2.534\"/><path d=\"M157.867 125.533c-21.067 3.334-42.134 14.8-57.467 31.334-13.333 14.133-13.733 12.8 5.6 22 9.2 4.4 16.667 8.266 16.667 8.533s-1.334.4-2.934.133c-13.866-2.133-41.6-5.866-41.733-5.6-.267.267-7.467 28.534-16.267 63.067l-16 62.667 7.334.4c4 .266 7.466.266 7.6 0 .266-.267 3.866-13.867 8.133-30.4 4.267-16.534 8.267-30.8 8.933-31.734.934-1.2 11.067-1.6 42.4-1.6 32.134 0 41.2.4 41.2 1.6 0 .934-1.733 4.667-3.866 8.4L153.733 261l-35.066 6.933c-19.334 3.867-35.2 7.2-35.467 7.467-.667.667-29.867 113.467-29.467 113.867.267.133 23.734-3.6 52.4-8.534l52-8.8 20.4-39.866c11.2-22 20.667-39.734 20.934-39.467.4.533-1.334 26.8-2.8 40.8l-.667 6.933 6.267-3.2c21.2-10.666 52.4-13.466 86.933-8l5.867.934-5.6-7.334C286.4 318.6 281.2 310.6 278 304.867c-5.867-10.134-6-10.8-6-21.867 0-6.267 1.467-20 3.333-30.667 1.867-10.666 3.334-19.466 3.334-19.466 0-.134-9.067-4.267-20.134-9.334s-19.466-9.333-18.8-9.6c.8-.266 7.734.8 15.6 2.267 7.734 1.6 14.4 2.8 14.8 2.8 1.2 0 .4-10.533-1.466-18-7.2-27.733-28-53.467-53.734-66-16.4-8-40.8-12-57.066-9.467\"/><path d=\"M235.6 346.333c-24 4.534-44.133 13.867-68.267 31.467-14 10.267-28.533 18.133-47.733 25.867-23.467 9.466-52.8 32.666-64.4 50.666l-2.533 4L58 454.2c9.333-7.333 30.133-19.467 49.333-28.667C173.733 393.667 246.4 382.2 312.8 393c11.2 1.733 39.2 8.533 45.067 10.8 2.266.8 2.266.667.8-2.267-4-7.6-15.6-21.866-25.867-31.466-12-11.467-24.667-18.934-37.6-22.4-11.467-3.067-46.267-3.867-59.6-1.334\"/>`;\n\nconst logoSvg = (fill: string): string =>\n `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 860 690\" fill=\"${fill}\"><g transform=\"translate(243 -2)\">${HELMET_PATHS}</g><g transform=\"translate(-460 467)\"><path d=\"M721.533 34.467c-14.933 5.466-22 17.466-22.933 38.666-.933 24.134 3.467 32.8 30.267 59.867 19.6 19.867 22.933 25.2 22.933 37.867.133 13.466-6.133 20.133-16.933 18.4-9.067-1.467-12.534-7.867-12.534-22.667V159H696.6l.667 13.333c.533 11.467 1.2 14.4 4.933 22 7.067 14.267 16.8 19.6 36.4 19.734 10.667.133 12.267-.134 20.667-4.4 7.6-3.867 9.733-5.867 13.2-11.734 7.6-12.533 9.066-32 3.733-47.6-3.733-11.066-10.667-20.4-27.867-37.2-19.866-19.6-22.533-24.133-22.666-38.4 0-8.933.4-10.533 3.2-13.6 6.266-6.666 18.266-5.6 22.133 2 1.067 2 2 7.067 2 11.2v7.334h27.067l-.934-9.2c-1.6-18.267-8.266-30.267-20-36.4-7.2-3.867-29.2-4.8-37.6-1.6M630.067 34.867c-11.734 4.666-18 11.6-21.734 24.4-1.866 6.4-2 15.333-1.733 67.2l.4 59.866 3.733 7.6c6.8 13.867 18.534 20.267 36.934 20.267 13.733 0 22.933-3.333 30.666-11.067 10.934-11.066 11.2-12.533 11.734-74.666.266-35.734-.134-56.934-1.067-62.8-2.667-16.134-11.6-27.467-24.8-31.467-8.933-2.667-26.4-2.267-34.133.667m28.666 26.8 3.6 4v56.666c0 40.934-.4 57.467-1.6 60-3.6 8-15.6 9.734-22.666 3.467l-4.4-3.867-.4-56.533c-.267-51.733-.134-56.8 2-61.067 3.066-5.866 7.6-8.266 14.4-7.466 3.733.4 6.4 1.866 9.066 4.8M511.667 123.133v89.334l29.066-.4c28.8-.4 28.934-.4 36.667-4.267 9.467-4.533 14.4-10.933 17.467-22.4 2-7.333 2.266-15.467 1.866-65.733l-.4-57.334-4.266-8.8c-8.267-16.666-17.2-19.866-55.334-19.866h-25.066zm53.6-60.266 4.4 3.866.4 52.8c.266 35.067-.134 54.4-1.067 58-2 7.467-5.733 9.6-18.667 10.4l-10.666.8V59h10.666c9.867 0 10.934.267 14.934 3.867M471.667 145v67.333h26.666V77.667h-26.666zM471.667 48.333V63h26.666V33.667h-26.666z\"/></g><g transform=\"translate(-106 244)\"><path d=\"M893.4 259.733c-6.8 3.467-9.2 5.6-12.667 11.467-10.666 17.733-9.2 42.933 3.467 62.267 2.8 4.266 12.267 14.8 20.933 23.333 9.867 9.467 17.067 17.733 18.934 21.6 5.6 11.333 5.2 25.333-.934 30.8-3.466 3.067-11.333 4.133-16.133 2.133-5.867-2.4-8-7.466-8-19.333v-10h-27.067l.8 11.6c1.2 15.867 3.867 24.133 10.267 31.333 7.467 8.534 16.4 12.134 30 12.267 19.067.133 30.133-5.733 37.333-19.867 3.734-7.466 4-8.666 4-24 0-16 0-16.133-5.2-26.666-4.4-9.2-7.733-13.2-23.066-28.534-21.067-21.066-24.8-27.066-24.934-40.4-.133-10.4 2.8-15.6 9.734-17.6 10.133-2.933 17.2 3.334 18.4 16.267l.8 8.267H955.4l-.8-10.8c-.533-8.4-1.6-12.667-4.8-19.2-7.067-14.134-16.667-19.334-36.133-19.334-10.667 0-12.534.4-20.267 4.4M791 346v89.333h76v-24h-48v-54.666h37.333v-24H819V282h48v-25.333h-76zM661.667 346v89.333h22.666l.134-63.066c0-34.534.533-61.734 1.066-60.267.534 1.467 5.467 29.867 10.8 62.933l9.867 60.4h13.067c7.2 0 13.066-.4 13.066-1.066 0-3.067 18.8-124.8 19.334-125.334.4-.4.666 27.867.666 62.8v63.6h25.334V256.667h-38l-9.2 61.6c-5.067 34-9.6 62.133-10 62.666-.534.534-5.334-26.4-10.667-60-5.467-33.466-10.133-61.733-10.533-62.666-.4-1.2-5.467-1.6-19.067-1.6h-18.533zM586.467 258.267c-.8 2.666-27.467 172.4-27.467 174.933 0 1.867 1.733 2.133 12 2.133h11.867l.8-4.4c.533-2.266 1.6-9.333 2.4-15.6l1.6-11.333 17.6-.4 17.466-.4 1.467 8.4c.8 4.667 1.733 11.867 2.267 16l.933 7.733h13.733c7.6 0 13.867-.266 13.867-.666 0-.8-26.533-168.134-27.467-173.734l-.8-4.266h-19.866c-14.8 0-20.134.4-20.4 1.6M613 336.533c3.333 23.067 5.867 42.134 5.6 42.4-.4.267-6.533.267-13.867.134l-13.2-.4 4.934-33.334c2.8-18.4 5.733-38.4 6.666-44.666C604.2 292.533 605 290.133 605.8 292c.533 1.467 3.867 21.467 7.2 44.533M497.267 257.2c-10.134 3.733-15.2 8.267-19.867 17.733L473 284l-.4 59.067c-.4 51.2-.133 60.133 1.733 66.533 5.6 19.333 17.067 27.333 39.467 27.467 10.267.133 12.267-.267 20.133-4.134 9.334-4.533 13.867-9.733 17.867-20.266 2-4.934 2.4-12 2.933-41.067l.534-34.933h-38.934v23.866l6.4.4 6.267.4v19.334c0 13.466-.533 20.666-1.867 23.733-5.066 12-23.333 10.4-26.8-2.4-.933-3.6-1.333-23.2-1.066-59.067.4-48.533.666-54.133 2.666-57.333 3.334-4.8 11.2-7.067 17.6-4.933 6.934 2.266 9.067 7.2 9.867 22.933l.667 13.067H555.4l-.8-15.734c-.8-18-3.067-25.6-9.867-33.466-7.466-8.8-13.466-11.2-29.066-11.734-9.067-.266-14.934.134-18.4 1.467\"/></g></svg>`;\n\n/** White iDos Games logo (helmet over wordmark) as an `<img src>`-ready data-URI. */\nexport const logoDataUrl = (color: string): string =>\n `data:image/svg+xml;utf8,${encodeURIComponent(logoSvg(color))}`;\n\n/**\n * The helmet on its own, square, for small places (the \"Continue with iDos Games\" button icon).\n * The viewBox is the helmet's bounding box padded to a square.\n */\nexport const markDataUrl = (color: string): string =>\n `data:image/svg+xml;utf8,${encodeURIComponent(\n `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"-33 11 448 448\" fill=\"${color}\">${HELMET_PATHS}</svg>`,\n )}`;\n"
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
"path": "src/base/login/PlayAccessScreen.tsx",
|