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:
@@ -0,0 +1,127 @@
|
||||
<!--
|
||||
Pending invitations panel for the lobby `games` section. Surfaces
|
||||
user-bound invites that have not been redeemed or declined yet, with
|
||||
accept / decline actions. Accepted invites move the inviting game into
|
||||
`active-past`; declined invites disappear from the list.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { account } from "$lib/account-store.svelte";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import { lobbyData, describeLobbyError } from "$lib/lobby-data.svelte";
|
||||
import LobbyShell from "./lobby-shell.svelte";
|
||||
import {
|
||||
declineInvite,
|
||||
listMyGames,
|
||||
redeemInvite,
|
||||
type InviteSummary,
|
||||
} from "../../api/lobby";
|
||||
|
||||
let actionInFlight = $state<string | null>(null);
|
||||
let actionError = $state<string | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
lobbyData.ensure().then((client) => {
|
||||
if (client !== null) {
|
||||
account.ensure(client).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function acceptInvite(invite: InviteSummary): Promise<void> {
|
||||
const client = lobbyData.client;
|
||||
if (client === null) return;
|
||||
actionInFlight = invite.inviteId;
|
||||
actionError = null;
|
||||
try {
|
||||
await redeemInvite(client, invite.gameId, invite.inviteId);
|
||||
lobbyData.removeInvitation(invite.inviteId);
|
||||
lobbyData.setMyGames(await listMyGames(client));
|
||||
} catch (err) {
|
||||
actionError = describeLobbyError(err);
|
||||
} finally {
|
||||
actionInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectInvite(invite: InviteSummary): Promise<void> {
|
||||
const client = lobbyData.client;
|
||||
if (client === null) return;
|
||||
actionInFlight = invite.inviteId;
|
||||
actionError = null;
|
||||
try {
|
||||
await declineInvite(client, invite.gameId, invite.inviteId);
|
||||
lobbyData.removeInvitation(invite.inviteId);
|
||||
} catch (err) {
|
||||
actionError = describeLobbyError(err);
|
||||
} finally {
|
||||
actionInFlight = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<LobbyShell>
|
||||
{#if lobbyData.configError !== null}
|
||||
<p role="alert" data-testid="account-error">{lobbyData.configError}</p>
|
||||
{:else if lobbyData.error !== null}
|
||||
<p role="alert" data-testid="lobby-error">{lobbyData.error}</p>
|
||||
{/if}
|
||||
|
||||
{#if actionError !== null}
|
||||
<p role="alert" data-testid="lobby-invitation-error">{actionError}</p>
|
||||
{/if}
|
||||
|
||||
<section data-testid="lobby-games-invitations-section">
|
||||
<h2>{i18n.t("lobby.section.invitations")}</h2>
|
||||
{#if lobbyData.loading}
|
||||
<p role="status">{i18n.t("lobby.list_loading")}</p>
|
||||
{:else if lobbyData.invitations.length === 0}
|
||||
<p data-testid="lobby-invitations-empty">{i18n.t("lobby.invitations.empty")}</p>
|
||||
{:else}
|
||||
<ul class="card-list">
|
||||
{#each lobbyData.invitations as invite (invite.inviteId)}
|
||||
<li class="card">
|
||||
<strong>{invite.raceName}</strong>
|
||||
<span class="meta">{invite.gameId}</span>
|
||||
<div class="actions">
|
||||
<button
|
||||
onclick={() => acceptInvite(invite)}
|
||||
disabled={actionInFlight === invite.inviteId}
|
||||
data-testid="lobby-invite-accept"
|
||||
>
|
||||
{i18n.t("lobby.invitation.accept")}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => rejectInvite(invite)}
|
||||
disabled={actionInFlight === invite.inviteId}
|
||||
data-testid="lobby-invite-decline"
|
||||
>
|
||||
{i18n.t("lobby.invitation.decline")}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</LobbyShell>
|
||||
|
||||
<style>
|
||||
section { margin-bottom: var(--space-6); }
|
||||
section h2 { font-size: var(--text-lg); margin: 0 0 var(--space-3); }
|
||||
.card-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2); }
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-raised);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
.meta { color: var(--color-text-muted); font-size: var(--text-sm); }
|
||||
.actions { display: flex; gap: var(--space-2); margin-top: var(--space-1); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user