009ea560f9
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>
128 lines
3.8 KiB
Svelte
128 lines
3.8 KiB
Svelte
<!--
|
|
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>
|