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>
This commit is contained in:
@@ -1,36 +1,110 @@
|
||||
<!--
|
||||
Shared chrome for the post-login "site"-style pages — the lobby
|
||||
landing and the editable profile. Renders a left page-list sidebar
|
||||
(mirroring the project site's VitePress layout) plus a top identity
|
||||
strip ("Player-xxxx" → opens profile, logout). Children fill the
|
||||
right-hand column. Pages mark themselves active via `activePage`.
|
||||
Shared chrome for the post-login lobby pages and the profile screen.
|
||||
Renders a left page-list sidebar (mirroring the project site's
|
||||
VitePress layout) plus a top identity strip ("Player-xxxx" → opens
|
||||
profile, logout). Children fill the right-hand column.
|
||||
|
||||
F8-04b extends the sidebar to a two-level hierarchy:
|
||||
- top-level items: `games` (with submenu), `profile`, and DEV-only
|
||||
`synthetic test reports`;
|
||||
- `games` submenu: `active-past` (hidden when the player has no games),
|
||||
`recruitment` (always), `invitations` (always), `private games`
|
||||
(paid-tier only; DEV overrides).
|
||||
|
||||
Desktop (>640px): the submenu stays expanded as long as the active
|
||||
screen is one of the games sub-panels. Mobile (≤640px, existing
|
||||
horizontal-strip breakpoint): the `games` item becomes a dropdown
|
||||
labeled "games · {active-sub} ▾"; tapping toggles the popover, tapping
|
||||
outside or pressing Escape closes it, and re-selecting the active
|
||||
sub-item is a no-op (mirrors the F8-02 idiom from issue #45).
|
||||
|
||||
The identity strip reads directly from the session-wide `account`
|
||||
store so navigating Overview ⇄ Profile never re-renders an empty
|
||||
placeholder: both screens populate the same cache through
|
||||
`account.ensure(client)` and the shell renders the latest value.
|
||||
store so navigating between sub-pages never re-renders an empty
|
||||
placeholder.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { appScreen } from "$lib/app-nav.svelte";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { onMount, type Snippet } from "svelte";
|
||||
import {
|
||||
appScreen,
|
||||
isLobbySubScreen,
|
||||
LOBBY_SUB_SCREENS,
|
||||
type AppScreen,
|
||||
} from "$lib/app-nav.svelte";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import { session } from "$lib/session-store.svelte";
|
||||
import { account } from "$lib/account-store.svelte";
|
||||
|
||||
type Page = "overview" | "profile";
|
||||
import { lobbyData } from "$lib/lobby-data.svelte";
|
||||
|
||||
interface Props {
|
||||
activePage: Page;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { activePage, children }: Props = $props();
|
||||
let { children }: Props = $props();
|
||||
|
||||
const PAGES: ReadonlyArray<{ id: Page; labelKey: "lobby.nav.overview" | "lobby.nav.profile"; screen: "lobby" | "profile" }> = [
|
||||
{ id: "overview", labelKey: "lobby.nav.overview", screen: "lobby" },
|
||||
{ id: "profile", labelKey: "lobby.nav.profile", screen: "profile" },
|
||||
const DEV_AFFORDANCES =
|
||||
import.meta.env.VITE_GALAXY_DEV_AFFORDANCES === "true";
|
||||
|
||||
type GamesSubId = (typeof LOBBY_SUB_SCREENS)[number];
|
||||
|
||||
interface GamesSubItem {
|
||||
id: GamesSubId;
|
||||
labelKey: TranslationKey;
|
||||
visible: () => boolean;
|
||||
}
|
||||
|
||||
const GAMES_SUBS: ReadonlyArray<GamesSubItem> = [
|
||||
{
|
||||
id: "games-active-past",
|
||||
labelKey: "lobby.nav.games.active_past",
|
||||
visible: () => lobbyData.myGames.length > 0,
|
||||
},
|
||||
{
|
||||
id: "games-recruitment",
|
||||
labelKey: "lobby.nav.games.recruitment",
|
||||
visible: () => true,
|
||||
},
|
||||
{
|
||||
id: "games-invitations",
|
||||
labelKey: "lobby.nav.games.invitations",
|
||||
visible: () => true,
|
||||
},
|
||||
{
|
||||
id: "games-private-games",
|
||||
labelKey: "lobby.nav.games.private_games",
|
||||
visible: () =>
|
||||
DEV_AFFORDANCES || account.current?.entitlement.isPaid === true,
|
||||
},
|
||||
];
|
||||
|
||||
function visibleGamesSubs(): ReadonlyArray<GamesSubItem> {
|
||||
return GAMES_SUBS.filter((sub) => sub.visible());
|
||||
}
|
||||
|
||||
function firstVisibleGamesScreen(): AppScreen {
|
||||
const visible = visibleGamesSubs();
|
||||
// recruitment is unconditionally visible, so visible is never
|
||||
// empty — but keep the fallback for type safety.
|
||||
return visible[0]?.id ?? "games-recruitment";
|
||||
}
|
||||
|
||||
function gotoScreen(screen: AppScreen): void {
|
||||
if (appScreen.screen !== screen) {
|
||||
appScreen.go(screen);
|
||||
}
|
||||
}
|
||||
|
||||
function gotoGamesParent(): void {
|
||||
gotoScreen(firstVisibleGamesScreen());
|
||||
}
|
||||
|
||||
let activeGamesSub = $derived.by<GamesSubItem | null>(() => {
|
||||
return GAMES_SUBS.find((s) => s.id === appScreen.screen) ?? null;
|
||||
});
|
||||
|
||||
let gamesActive = $derived.by(() => isLobbySubScreen(appScreen.screen));
|
||||
let profileActive = $derived.by(() => appScreen.screen === "profile");
|
||||
let syntheticActive = $derived.by(() => appScreen.screen === "synthetic-reports");
|
||||
|
||||
let identityLabel = $derived.by(() => {
|
||||
const current = account.current;
|
||||
if (current !== null) {
|
||||
@@ -41,19 +115,79 @@ placeholder: both screens populate the same cache through
|
||||
return i18n.t("lobby.account_loading");
|
||||
});
|
||||
|
||||
function gotoPage(screen: "lobby" | "profile"): void {
|
||||
if (appScreen.screen !== screen) {
|
||||
appScreen.go(screen);
|
||||
}
|
||||
}
|
||||
|
||||
function gotoProfile(): void {
|
||||
gotoPage("profile");
|
||||
gotoScreen("profile");
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await session.signOut("user");
|
||||
}
|
||||
|
||||
// Mobile dropdown state for the games submenu. Closed on subitem
|
||||
// selection, click outside, Escape, and on tap of the toggle when
|
||||
// already open.
|
||||
let mobileMenuOpen = $state(false);
|
||||
let mobileMenuEl: HTMLElement | null = null;
|
||||
|
||||
function toggleMobileMenu(): void {
|
||||
mobileMenuOpen = !mobileMenuOpen;
|
||||
}
|
||||
|
||||
function selectMobileSub(screen: AppScreen): void {
|
||||
mobileMenuOpen = false;
|
||||
gotoScreen(screen);
|
||||
}
|
||||
|
||||
function handleMobileKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === "Escape" && mobileMenuOpen) {
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDocumentPointerDown(event: PointerEvent): void {
|
||||
if (!mobileMenuOpen) return;
|
||||
if (mobileMenuEl !== null && event.target instanceof Node) {
|
||||
if (!mobileMenuEl.contains(event.target)) {
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("pointerdown", handleDocumentPointerDown);
|
||||
document.addEventListener("keydown", handleMobileKeydown);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handleDocumentPointerDown);
|
||||
document.removeEventListener("keydown", handleMobileKeydown);
|
||||
};
|
||||
});
|
||||
|
||||
// Persisted snapshot may restore the user onto `games-private-games`
|
||||
// after a tier downgrade or onto `synthetic-reports` in a prod
|
||||
// bundle that strips the DEV affordance. Resolve quietly to the
|
||||
// first visible games sub-page instead of letting the dispatcher
|
||||
// render an empty shell.
|
||||
$effect(() => {
|
||||
const screen = appScreen.screen;
|
||||
if (screen === "games-private-games") {
|
||||
if (!DEV_AFFORDANCES && account.current?.entitlement.isPaid !== true) {
|
||||
appScreen.go(firstVisibleGamesScreen());
|
||||
}
|
||||
} else if (screen === "synthetic-reports") {
|
||||
if (!DEV_AFFORDANCES) {
|
||||
appScreen.go(firstVisibleGamesScreen());
|
||||
}
|
||||
} else if (screen === "games-active-past") {
|
||||
// Hide-when-empty is asymmetric: we only kick the user out if
|
||||
// the lobby-data store has actually reported zero games (not
|
||||
// during the initial `loading=true` window). Otherwise a
|
||||
// fresh navigation would bounce off this screen before the
|
||||
// fan-out resolves.
|
||||
if (!lobbyData.loading && lobbyData.myGames.length === 0) {
|
||||
appScreen.go(firstVisibleGamesScreen());
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
|
||||
@@ -73,21 +207,116 @@ placeholder: both screens populate the same cache through
|
||||
</header>
|
||||
<div class="body">
|
||||
<nav class="sidebar" aria-label={i18n.t("lobby.nav.aria_label")}>
|
||||
<ul>
|
||||
{#each PAGES as page (page.id)}
|
||||
<ul class="top-list">
|
||||
<li class="games-item">
|
||||
<button
|
||||
type="button"
|
||||
class="nav-link parent"
|
||||
class:active={gamesActive}
|
||||
aria-current={gamesActive ? "page" : undefined}
|
||||
onclick={gotoGamesParent}
|
||||
data-testid="lobby-nav-games"
|
||||
>
|
||||
{i18n.t("lobby.nav.games")}
|
||||
</button>
|
||||
|
||||
<!-- Desktop submenu: always expanded when in `games-*` -->
|
||||
<ul
|
||||
class="submenu desktop-only"
|
||||
aria-label={i18n.t("lobby.nav.games.aria_label")}
|
||||
>
|
||||
{#each visibleGamesSubs() as sub (sub.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-link sub"
|
||||
class:active={appScreen.screen === sub.id}
|
||||
aria-current={appScreen.screen === sub.id ? "page" : undefined}
|
||||
onclick={() => gotoScreen(sub.id)}
|
||||
data-testid="lobby-nav-{sub.id}"
|
||||
>
|
||||
{i18n.t(sub.labelKey)}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<!-- Mobile dropdown: only the active sub is shown as a
|
||||
button, tap toggles the popover with the visible
|
||||
subs. Re-tap on the active sub is a no-op. -->
|
||||
<div
|
||||
class="mobile-dropdown mobile-only"
|
||||
bind:this={mobileMenuEl}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-link mobile-toggle"
|
||||
class:active={gamesActive}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
onclick={toggleMobileMenu}
|
||||
data-testid="lobby-nav-games-mobile"
|
||||
>
|
||||
{i18n.t("lobby.nav.games.mobile_toggle", {
|
||||
label: activeGamesSub
|
||||
? i18n.t(activeGamesSub.labelKey)
|
||||
: i18n.t("lobby.nav.games.recruitment"),
|
||||
})}
|
||||
<span aria-hidden="true">▾</span>
|
||||
</button>
|
||||
{#if mobileMenuOpen}
|
||||
<ul
|
||||
class="mobile-popover"
|
||||
role="listbox"
|
||||
aria-label={i18n.t("lobby.nav.games.aria_label")}
|
||||
>
|
||||
{#each visibleGamesSubs() as sub (sub.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="popover-item"
|
||||
class:active={appScreen.screen === sub.id}
|
||||
role="option"
|
||||
aria-selected={appScreen.screen === sub.id}
|
||||
onclick={() => selectMobileSub(sub.id)}
|
||||
data-testid="lobby-nav-{sub.id}-mobile"
|
||||
>
|
||||
{i18n.t(sub.labelKey)}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-link"
|
||||
class:active={profileActive}
|
||||
aria-current={profileActive ? "page" : undefined}
|
||||
onclick={gotoProfile}
|
||||
data-testid="lobby-nav-profile"
|
||||
>
|
||||
{i18n.t("lobby.nav.profile")}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{#if DEV_AFFORDANCES}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-link"
|
||||
class:active={activePage === page.id}
|
||||
aria-current={activePage === page.id ? "page" : undefined}
|
||||
onclick={() => gotoPage(page.screen)}
|
||||
data-testid="lobby-nav-{page.id}"
|
||||
class:active={syntheticActive}
|
||||
aria-current={syntheticActive ? "page" : undefined}
|
||||
onclick={() => gotoScreen("synthetic-reports")}
|
||||
data-testid="lobby-nav-synthetic-reports"
|
||||
>
|
||||
{i18n.t(page.labelKey)}
|
||||
{i18n.t("lobby.nav.synthetic_reports")}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
</nav>
|
||||
<main id="main-content" tabindex="-1" class="content">
|
||||
@@ -160,7 +389,8 @@ placeholder: both screens populate the same cache through
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.sidebar ul {
|
||||
.top-list,
|
||||
.submenu {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -169,6 +399,11 @@ placeholder: both screens populate the same cache through
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.submenu {
|
||||
margin-left: var(--space-3);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -183,6 +418,16 @@ placeholder: both screens populate the same cache through
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-link.sub {
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
}
|
||||
|
||||
.nav-link.parent.active {
|
||||
color: var(--color-text);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text);
|
||||
@@ -200,6 +445,62 @@ placeholder: both screens populate the same cache through
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.mobile-dropdown {
|
||||
position: relative;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mobile-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-1));
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--space-1);
|
||||
background: var(--color-surface-raised);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm, 0 4px 12px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.popover-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popover-item:hover {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.popover-item.active {
|
||||
color: var(--color-accent);
|
||||
background: var(--color-accent-subtle);
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.body {
|
||||
flex-direction: column;
|
||||
@@ -210,7 +511,7 @@ placeholder: both screens populate the same cache through
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
.sidebar ul {
|
||||
.top-list {
|
||||
flex-direction: row;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
@@ -219,9 +520,24 @@ placeholder: both screens populate the same cache through
|
||||
white-space: nowrap;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
}
|
||||
.games-item {
|
||||
position: relative;
|
||||
}
|
||||
.desktop-only {
|
||||
display: none;
|
||||
}
|
||||
.mobile-only,
|
||||
.mobile-dropdown {
|
||||
display: block;
|
||||
}
|
||||
.content {
|
||||
padding: var(--space-4);
|
||||
max-width: none;
|
||||
}
|
||||
/* The games-item's parent button is replaced by the mobile
|
||||
dropdown toggle. */
|
||||
.nav-link.parent {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user