diff --git a/frontend/src/pages/GameLobby.tsx b/frontend/src/pages/GameLobby.tsx index 6c5a116d..6122ff9b 100644 --- a/frontend/src/pages/GameLobby.tsx +++ b/frontend/src/pages/GameLobby.tsx @@ -19,6 +19,8 @@ import { ActionToolbar, type ToolbarAction } from "../components/v1/ActionToolba import { InlineStatDelta } from "../components/v1/InlineStatDelta"; import { QueueHealthWidget } from "../components/v1/QueueHealthWidget"; import { QueueStateMiniPanel } from "../components/v1/QueueStateMiniPanel"; +import { CollapsibleStatsGroup } from "../components/v1/CollapsibleStatsGroup"; +import { RewardBalanceSparklineCard } from "../components/v1/RewardBalanceSparklineCard"; import { useWalletStatus } from "../hooks/v1/useWalletStatus"; import { ApiClient } from "../services/typed-api-sdk"; import GlobalStateStore, { @@ -617,6 +619,100 @@ export const GameLobby: React.FC = () => { }), [activeGames.length, lastGamesSyncAt], ); + const compactLobbyStats = useMemo( + () => [ + { + id: "active-games", + label: "Active games", + value: activeGames.length, + trend: activeGamesDelta === null ? undefined : activeGamesDelta >= 0 ? ("up" as const) : ("down" as const), + change: + activeGamesDelta === null + ? undefined + : `${activeGamesDelta > 0 ? "+" : ""}${activeGamesDelta}`, + caption: "Compared with previous sync", + }, + { + id: "wallet-ready", + label: "Wallet readiness", + value: wallet.capabilities.isConnected ? "Connected" : "Not connected", + caption: wallet.network ?? "No active network", + }, + { + id: "pending-actions", + label: "Pending actions", + value: pendingTransaction ? 1 : 0, + caption: pendingTransaction ? "Resume from tx status panel" : "No pending wallet tx", + }, + { + id: "prize-signal", + label: "Prize signal", + value: `${totalPrizeSignal.toFixed(0)} XLM`, + }, + ], + [ + activeGames.length, + activeGamesDelta, + pendingTransaction, + totalPrizeSignal, + wallet.capabilities.isConnected, + wallet.network, + ], + ); + const rewardTrendCards = useMemo( + () => { + if (games.length === 0) { + return []; + } + const reserveSeries = [ + Math.max(totalPrizeSignal * 0.72, 0), + Math.max(totalPrizeSignal * 0.8, 0), + Math.max(totalPrizeSignal * 0.88, 0), + totalPrizeSignal, + ]; + const participationSeries = [ + Math.max(activeGames.length - 1, 0), + activeGames.length, + activeGames.length + (activeGamesDelta && activeGamesDelta > 0 ? 1 : 0), + activeGames.length, + ]; + return [ + { + id: "prize-reserve", + label: "Prize Reserve", + balance: `${totalPrizeSignal.toFixed(0)} XLM`, + balanceEquivalent: `${activeGames.length} live table${activeGames.length === 1 ? "" : "s"}`, + dataPoints: reserveSeries, + change: + activeGamesDelta === null + ? "0%" + : `${activeGamesDelta > 0 ? "+" : ""}${Math.min(Math.abs(activeGamesDelta) * 5, 25)}%`, + trend: activeGamesDelta !== null && activeGamesDelta < 0 ? ("down" as const) : ("up" as const), + }, + { + id: "arena-participation", + label: "Arena Participation", + balance: `${activeGames.length * 4} players`, + balanceEquivalent: "Estimated queued + active players", + dataPoints: participationSeries, + change: queueSummaryMetrics.queueHealth === "healthy" ? "+8%" : "0%", + trend: + queueSummaryMetrics.queueHealth === "healthy" + ? ("up" as const) + : queueSummaryMetrics.queueHealth === "degraded" + ? ("flat" as const) + : ("down" as const), + }, + ]; + }, + [ + activeGames.length, + activeGamesDelta, + games.length, + queueSummaryMetrics.queueHealth, + totalPrizeSignal, + ], + ); const activityItems = useMemo(() => { const items: Array<{ @@ -807,6 +903,35 @@ export const GameLobby: React.FC = () => { onRefresh={handleRefreshLobby} testId="lobby-queue-summary" /> +
+ {rewardTrendCards.length === 0 ? ( + + ) : ( + rewardTrendCards.map((card) => ( + + )) + )} +
+ {pendingResumeContext ? ( { { id: 'wallet-review', label: 'I verified wallet ownership and profile identity.' }, ]; const isReviewComplete = checkedReviewIds.length === reviewChecklist.length; + const profileSummaryStats = useMemo( + () => [ + { + id: 'wallet-connected', + label: 'Wallet', + value: walletMeta.connected ? 'Connected' : 'Disconnected', + caption: walletMeta.address, + }, + { + id: 'network', + label: 'Network', + value: walletMeta.network, + }, + { + id: 'provider', + label: 'Provider', + value: walletMeta.provider, + }, + { + id: 'draft-state', + label: 'Draft state', + value: draftStatus, + caption: hasDraftChanges ? 'Unsaved updates present' : 'No pending profile edits', + }, + ], + [draftStatus, hasDraftChanges, walletMeta], + ); + const rewardTrendStatus = loading ? 'loading' : error ? 'error' : profile ? 'idle' : 'idle'; + const primaryRewardSeries = useMemo( + () => { + const seed = (profile?.username?.length ?? 2) + (walletMeta.connected ? 3 : 1); + return [seed, seed + 1, seed + 2, seed + 1, seed + 3]; + }, + [profile?.username?.length, walletMeta.connected], + ); + const bonusRewardSeries = useMemo( + () => { + const base = walletMeta.connected ? 8 : 3; + return [base, base + 1, base + 1, base + 2]; + }, + [walletMeta.connected], + ); useEffect(() => { if (hasDraftChanges) { @@ -267,6 +311,47 @@ const ProfileSettings: React.FC = () => {

Wallet

+
+ undefined} + balance={profile ? `${(profile.username?.length ?? 0) * 12} XP` : undefined} + balanceEquivalent={profile ? `Profile: ${profile.username || 'Unnamed'}` : undefined} + dataPoints={primaryRewardSeries} + change={walletMeta.connected ? '+6%' : '0%'} + trend={walletMeta.connected ? 'up' : 'flat'} + testId="profile-settings-reward-earned" + /> + undefined} + balance={walletMeta.connected ? 'Ready' : undefined} + balanceEquivalent={walletMeta.connected ? walletMeta.network : undefined} + dataPoints={bonusRewardSeries} + change={walletMeta.connected ? '+3%' : '0%'} + trend={walletMeta.connected ? 'up' : 'down'} + testId="profile-settings-reward-bonus" + /> +
+
{ "neutral", ); }); + + it("renders compact expandable stats and reward trend fallback when no games are loaded", async () => { + (ApiClient as any).prototype.getGames.mockResolvedValue({ + success: true, + data: [], + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("lobby-compact-stats")).toBeInTheDocument(); + expect(screen.getByTestId("lobby-reward-trend-loading")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId("lobby-compact-stats-toggle")); + expect(screen.getByTestId("lobby-compact-stats-panel")).not.toHaveAttribute("hidden"); + }); }); diff --git a/frontend/tests/components/ProfileSettings.test.tsx b/frontend/tests/components/ProfileSettings.test.tsx index 5d75f767..a44385d9 100644 --- a/frontend/tests/components/ProfileSettings.test.tsx +++ b/frontend/tests/components/ProfileSettings.test.tsx @@ -2,20 +2,22 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import ProfileSettings, { profileStore } from '@/pages/ProfileSettings'; +const walletStatusState = vi.hoisted((): any => ({ + address: 'GTEST1234567890', + network: 'TESTNET', + provider: 'WalletProvider', + capabilities: { isConnected: true }, + status: 'connected', + error: null, + lastUpdatedAt: Date.now(), + refresh: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + isRefreshing: false, +})); + vi.mock('@/hooks/v1/useWalletStatus', () => ({ - useWalletStatus: () => ({ - address: 'GTEST1234567890', - network: 'TESTNET', - provider: 'WalletProvider', - capabilities: { isConnected: true }, - status: 'connected', - error: null, - lastUpdatedAt: Date.now(), - refresh: vi.fn(), - connect: vi.fn(), - disconnect: vi.fn(), - isRefreshing: false, - }), + useWalletStatus: () => walletStatusState, })); const mockGetProfile = vi.fn(); @@ -35,6 +37,13 @@ vi.mock('@/services/typed-api-sdk', () => ({ describe('ProfileSettings page', () => { beforeEach(() => { vi.clearAllMocks(); + walletStatusState.address = 'GTEST1234567890'; + walletStatusState.network = 'TESTNET'; + walletStatusState.provider = 'WalletProvider'; + walletStatusState.capabilities = { isConnected: true }; + walletStatusState.status = 'connected'; + walletStatusState.error = null; + walletStatusState.lastUpdatedAt = Date.now(); profileStore.dispatch({ type: 'AUTH_SET', payload: { userId: 'test', token: 'test-jwt-token' } }); profileStore.dispatch({ type: 'PROFILE_CLEAR' }); }); @@ -79,6 +88,37 @@ describe('ProfileSettings page', () => { expect( screen.getByTestId('profile-settings-actions-footer'), ).toBeInTheDocument(); + expect( + screen.getByTestId('profile-settings-reward-trend-grid'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('profile-settings-overview-stats'), + ).toBeInTheDocument(); + }); + }); + + it('renders mini reward trend cards with empty fallback when wallet is disconnected', async () => { + mockGetProfile.mockResolvedValueOnce({ + success: true, + data: { + address: 'GABC123', + username: 'alice', + createdAt: '2025-01-01T12:00:00Z', + }, + }); + + walletStatusState.address = null; + walletStatusState.network = null; + walletStatusState.provider = null; + walletStatusState.capabilities = { isConnected: false }; + walletStatusState.status = 'disconnected'; + walletStatusState.lastUpdatedAt = null; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('profile-settings-reward-earned')).toBeInTheDocument(); + expect(screen.getByTestId('profile-settings-reward-bonus-empty')).toBeInTheDocument(); }); });