Files
galaxy-game/ui/frontend/src/routes/+page.svelte
T
Ilia Denisov 009ea560f9
Tests · Go / test (push) Successful in 2m17s
Tests · UI / test (push) Waiting to run
feat(lobby): F8-04b hierarchical sidebar + paid-tier gate for create-game
Reshape the lobby UI from a single Overview into a two-level sidebar
(games · profile · DEV synthetic-reports) with four games sub-panels
(active-past · recruitment · invitations · private-games). Move the
`create new game` button into the private-games panel, merge the
applications section into recruitment cards as status chips, and add
DEV-only synthetic-report loader as a top-level screen.

Add a paid-tier gate at backend `lobby.game.create`: free callers get
`403 forbidden` before the lobby service is invoked. The UI hides the
private-games sub-panel + create button on free tier (DEV affordances
flag overrides). Update every integration test that creates a game to
use a new `testenv.PromoteToPaid` helper; add a new
`TestLobbyFlow_FreeUserCreateGameForbidden`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:53:53 +02:00

121 lines
4.8 KiB
Svelte

<script lang="ts">
// Single-route screen dispatcher for the app-shell. There are no
// per-screen routes: the visible screen is selected from in-memory
// state (`session.status` for the auth gate, `appScreen.screen` for
// the authenticated screen) rather than from the URL. The root
// layout intercepts the `loading` and `unsupported` session states
// before this component renders, so here `session.status` is either
// `anonymous` (login) or `authenticated` (lobby / create / game).
import { onMount } from "svelte";
import { dev } from "$app/environment";
import { session } from "$lib/session-store.svelte";
import {
appScreen,
activeView,
type AppScreen,
type GameView,
type GameViewState,
} from "$lib/app-nav.svelte";
import LoginScreen from "$lib/screens/login-screen.svelte";
import LobbyScreen from "$lib/screens/lobby-screen.svelte";
import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte";
import ProfileScreen from "$lib/screens/profile-screen.svelte";
import GameShell from "$lib/game/game-shell.svelte";
import GamesActivePastScreen from "$lib/screens/games-active-past-screen.svelte";
import GamesRecruitmentScreen from "$lib/screens/games-recruitment-screen.svelte";
import GamesInvitationsScreen from "$lib/screens/games-invitations-screen.svelte";
import GamesPrivateGamesScreen from "$lib/screens/games-private-games-screen.svelte";
import SyntheticReportsScreen from "$lib/screens/synthetic-reports-screen.svelte";
import { pushState } from "$app/navigation";
import { page } from "$app/state";
// Dev-only navigation affordance for the Playwright e2e suite. The
// single-URL app-shell has no per-screen / per-view routes, so a
// spec can no longer drive the UI by `page.goto("/games/:id/:view")`.
// Instead the suite seeds the session, loads `/` (which lands on the
// authenticated lobby), then calls `window.__galaxyNav.enterGame(...)`
// to switch the in-memory screen and view. Guarded by `dev` so it is
// stripped from the production bundle — `import.meta.env.DEV` (and the
// SvelteKit `dev` re-export) is statically `false` there, so the
// whole `onMount` body tree-shakes away.
type ViewParams = Omit<GameViewState, "view">;
interface NavSurface {
enterGame(gameId: string, view?: GameView, params?: ViewParams): void;
select(view: GameView, params?: ViewParams): void;
go(screen: AppScreen, opts?: { gameId?: string }): void;
}
type NavWindow = typeof globalThis & { __galaxyNav?: NavSurface };
onMount(() => {
if (!dev) return;
(window as NavWindow).__galaxyNav = {
enterGame(gameId, view = "map", params = {}): void {
activeView.select(view, params);
appScreen.go("game", { gameId });
},
select(view, params = {}): void {
activeView.select(view, params);
},
go(screen, opts = {}): void {
appScreen.go(screen, opts);
},
};
});
// Screen-level browser history (Back → lobby) without changing the URL.
// On the first authenticated render, stamp a restored overlay (game /
// lobby-create) on top of the load entry so Back falls through to lobby.
let historyStamped = $state(false);
$effect(() => {
if (session.status === "authenticated" && !historyStamped) {
historyStamped = true;
if (appScreen.screen === "game" && appScreen.gameId !== null) {
pushState("", { screen: "game", gameId: appScreen.gameId });
} else if (appScreen.screen === "lobby-create") {
pushState("", { screen: "lobby-create" });
} else if (appScreen.screen === "profile") {
pushState("", { screen: "profile" });
}
}
});
// Sync the store from history on Back/Forward (popstate updates
// `page.state`). Skipped until the baseline is stamped so it never
// clobbers the restored screen on first render.
$effect(() => {
if (!historyStamped) return;
appScreen.syncFromHistory(page.state.screen, page.state.gameId ?? null);
});
</script>
{#if session.status === "authenticated"}
{#if appScreen.screen === "lobby-create"}
<LobbyCreateScreen />
{:else if appScreen.screen === "profile"}
<ProfileScreen />
{:else if appScreen.screen === "game" && appScreen.gameId !== null}
<GameShell />
{:else if appScreen.screen === "games-active-past"}
<GamesActivePastScreen />
{:else if appScreen.screen === "games-recruitment"}
<GamesRecruitmentScreen />
{:else if appScreen.screen === "games-invitations"}
<GamesInvitationsScreen />
{:else if appScreen.screen === "games-private-games"}
<GamesPrivateGamesScreen />
{:else if appScreen.screen === "synthetic-reports"}
<SyntheticReportsScreen />
{:else}
<!--
Default authenticated screen. Covers the historical `lobby`
alias and any restored snapshot that lost its game id. The
`LobbyScreen` resolver navigates to `games-recruitment` on
mount; the shell then re-routes to a more appropriate
sub-page if visibility rules allow.
-->
<LobbyScreen />
{/if}
{:else}
<LoginScreen />
{/if}