+
+ setSelectedModeId(parseModeFilterValue(event.target.value))}
+ style={{
+ padding: '8px 10px',
+ borderRadius: '8px',
+ border: '1px solid rgba(255,180,60,0.24)',
+ background: 'rgba(255,255,255,0.04)',
+ color: '#fff2c8',
+ fontSize: '12px',
+ fontWeight: 700,
+ }}
+ >
+ All official modes
+ {OFFICIAL_MATCH_MODES.map((mode) => (
+
+ {mode.label}
+
+ ))}
+
{focusGuestId && onClearGuestFocus && (
Loading archived matches...
) : matches.length === 0 ? (
-
No archived matches yet. Start a game and make a move to populate history.
+
+ {focusGuestId
+ ? 'No archived matches exist for this player seat in the selected mode yet.'
+ : 'No archived matches yet for the selected mode. Start a game and make a move to populate history.'}
+
) : (
matches.map(match => {
const selected = match.matchId === selectedMatchId;
@@ -338,7 +375,14 @@ export default function HistoryPage({
}}
>
-
{match.matchId}
+
+ {formatMatchPlayers({
+ whiteName: match.whiteName,
+ whiteHandle: match.whiteAccountHandle,
+ blackName: match.blackName,
+ blackHandle: match.blackAccountHandle,
+ })}
+
- {resultLabel(match)}
+ {formatMatchResult({
+ status: match.status,
+ winner: match.winner,
+ finishReason: match.finishReason,
+ })}
{match.moveCount} moves
{match.lastMove ? ` · last ${match.lastMove}` : ''}
- {` · ${queueLabel(match)}`}
+ {` · ${formatLabel(match)}`}
{playersLabel(match)}
@@ -389,7 +437,7 @@ export default function HistoryPage({
Match Detail
- Snapshot-backed detail from the platform archive. This is the first persistence layer before Postgres and Redis.
+ Canonical replay destination for archived Chess404 games. Shared replay links and guest archive links resolve through this surface instead of ephemeral list state.
@@ -426,8 +474,65 @@ export default function HistoryPage({
border: '1px solid rgba(255,165,40,0.1)',
}}
>
-
{selectedMatch.matchId}
+
{resultLabel(selectedMatch)}
{playersLabel(selectedMatch)}
+
{formatLabel(selectedMatch)} · archived {formatDateTime(selectedMatch.updatedAt)}
+
+ void shareReplayLink()}
+ style={{
+ padding: '8px 12px',
+ borderRadius: '10px',
+ border: '1px solid rgba(255,180,60,0.24)',
+ background: 'rgba(255,180,60,0.08)',
+ color: '#fff0c6',
+ fontSize: '11px',
+ fontWeight: 800,
+ cursor: 'pointer',
+ }}
+ >
+ Copy replay link
+
+ {focusGuestId && (
+ void shareGuestHistoryLink()}
+ style={{
+ padding: '8px 12px',
+ borderRadius: '10px',
+ border: '1px solid rgba(110,170,255,0.22)',
+ background: 'rgba(54,102,184,0.12)',
+ color: '#dcecff',
+ fontSize: '11px',
+ fontWeight: 800,
+ cursor: 'pointer',
+ }}
+ >
+ Copy guest archive link
+
+ )}
+ {selectedMatch.status === 'active' && onWatchLiveMatch && (
+ onWatchLiveMatch(selectedMatch.matchId)}
+ style={{
+ padding: '8px 12px',
+ borderRadius: '10px',
+ border: '1px solid rgba(110,170,255,0.28)',
+ background: 'linear-gradient(180deg, rgba(54,102,184,0.24) 0%, rgba(24,40,82,0.34) 100%)',
+ color: '#e5f0ff',
+ fontSize: '11px',
+ fontWeight: 800,
+ cursor: 'pointer',
+ }}
+ >
+ Watch live board
+
+ )}
+
+ {notice && (
+
+ {notice}
+
+ )}
{selectedMatch.whiteGuestId && (
- {playerIdentityLabel(selectedMatch.whiteName, selectedMatch.whiteGuestId, selectedMatch.whiteAccountHandle, 'White guest')}
+ {playerIdentityLabel(selectedMatch.whiteName, selectedMatch.whiteGuestId, selectedMatch.whiteAccountHandle, 'White player')}
)}
{selectedMatch.blackGuestId && (
@@ -460,17 +565,18 @@ export default function HistoryPage({
cursor: onOpenGuest ? 'pointer' : 'default',
}}
>
- {playerIdentityLabel(selectedMatch.blackName, selectedMatch.blackGuestId, selectedMatch.blackAccountHandle, 'Black guest')}
+ {playerIdentityLabel(selectedMatch.blackName, selectedMatch.blackGuestId, selectedMatch.blackAccountHandle, 'Black player')}
)}
Status: {snapshot.status}
Winner: {snapshot.winner ?? 'none'}
-
Queue: {queueLabel(selectedMatch)}
+
Finish: {finishReasonLabel(selectedMatch.finishReason) ?? 'unspecified'}
+
Format: {formatLabel(selectedMatch)}
Turn: {activeReplayFrame?.turn ?? snapshot.turn}
-
White account: {selectedMatch.whiteAccountHandle ? `@${selectedMatch.whiteAccountHandle}` : selectedMatch.whiteAccountId ?? 'guest-only'}
-
Black account: {selectedMatch.blackAccountHandle ? `@${selectedMatch.blackAccountHandle}` : selectedMatch.blackAccountId ?? 'guest-only'}
+
White: {selectedMatch.whiteAccountHandle ? `@${selectedMatch.whiteAccountHandle}` : selectedMatch.whiteName ?? 'Guest'}
+
Black: {selectedMatch.blackAccountHandle ? `@${selectedMatch.blackAccountHandle}` : selectedMatch.blackName ?? 'Guest'}
Moves: {selectedMatch.moveCount}
Created: {formatDateTime(selectedMatch.createdAt)}
Updated: {formatDateTime(selectedMatch.updatedAt)}
@@ -478,7 +584,7 @@ export default function HistoryPage({
Rules: {snapshot.rulesVersion}
- {renderBoardPreview(previewBoard)}
+ {previewBoard ? : null}
@@ -551,13 +657,16 @@ export default function HistoryPage({
White hand
-
{listCardNames(snapshot.whiteHand)}
+
{hiddenArchiveCountLabel(selectedMatch.whiteHandCount)}
Black hand
-
{listCardNames(snapshot.blackHand)}
+
{hiddenArchiveCountLabel(selectedMatch.blackHandCount)}
+
+ Hidden hands and private chat content are stripped from the public replay surface. Replay keeps the board, move list, clocks, and safe event timeline only.
+
Active effects
{activeEffects.length === 0 ? (
@@ -661,8 +770,8 @@ export default function HistoryPage({
-
-
Chat & Snapshot JSON
-
-
Chat log
- {snapshot.chatMessages.length === 0 ? (
-
No chat messages archived for this match.
- ) : (
-
- {snapshot.chatMessages.map((message, index) => (
-
-
- {message.sender}
- {formatDateTime(message.sentAt)}
-
-
{message.text}
-
- ))}
-
- )}
+
Replay Notes
+
+ Public replay keeps the authoritative board progression, move history, clocks, result, and safe event timeline. Private seat secrets, hidden hands, and chat contents are intentionally removed.
+
+
+
+
Archived chat
+
{selectedMatch.chatMessageCount ?? 0} hidden
+
+
+
Replay frames
+
{replayFrames.length}
+
-
- {JSON.stringify(selectedMatch, null, 2)}
-
)}
diff --git a/apps/web/src/InboxPage.tsx b/apps/web/src/InboxPage.tsx
new file mode 100644
index 0000000..759d98c
--- /dev/null
+++ b/apps/web/src/InboxPage.tsx
@@ -0,0 +1,376 @@
+import React from 'react';
+import { type MatchModeId } from '@chess404/contracts';
+import {
+ fetchAccountNotificationOverview,
+ markAccountNotificationRead,
+ markAllAccountNotificationsRead,
+ type AccountNotificationOverview,
+ type AccountNotificationView,
+} from './lib/platform-service';
+import { modeLabel } from './lib/match-labels';
+
+interface InboxPageProps {
+ accountId?: string | null;
+ sessionToken?: string | null;
+ liveRefreshToken?: number;
+ onOpenProfile?: (handle: string) => void;
+ onOpenFriends?: () => void;
+ onUnreadCountChange?: (count: number) => void;
+}
+
+function formatDateTime(value?: string): string {
+ if (!value) {
+ return 'Unknown';
+ }
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+ return date.toLocaleString();
+}
+
+
+function describeNotification(notification: AccountNotificationView): {
+ title: string;
+ detail: string;
+ accent: string;
+ border: string;
+ background: string;
+ actionLabel: string;
+} {
+ const handle = `@${notification.actor.handle}`;
+ switch (notification.kind) {
+ case 'friend_request_received':
+ return {
+ title: `${handle} sent you a friend request`,
+ detail: 'Open Friends to accept or decline it.',
+ accent: '#b7d8ff',
+ border: '1px solid rgba(80,140,220,0.28)',
+ background: 'linear-gradient(180deg, rgba(38,56,88,0.32) 0%, rgba(18,24,40,0.18) 100%)',
+ actionLabel: 'Open Friends',
+ };
+ case 'friend_request_accepted':
+ return {
+ title: `${handle} accepted your friend request`,
+ detail: 'Your friends graph is now connected for challenges and future lobbies.',
+ accent: '#d7ffd8',
+ border: '1px solid rgba(86,204,120,0.26)',
+ background: 'linear-gradient(180deg, rgba(28,72,40,0.26) 0%, rgba(18,26,22,0.16) 100%)',
+ actionLabel: 'Open Friends',
+ };
+ case 'direct_challenge_received':
+ return {
+ title: `${handle} challenged you to ${modeLabel(notification.modeId)}`,
+ detail: 'Open Friends to accept or decline the live match invite.',
+ accent: '#ffe7a6',
+ border: '1px solid rgba(220,170,80,0.32)',
+ background: 'linear-gradient(180deg, rgba(88,58,24,0.28) 0%, rgba(26,20,14,0.18) 100%)',
+ actionLabel: 'Open Friends',
+ };
+ case 'direct_challenge_accepted':
+ return {
+ title: `${handle} accepted your direct challenge`,
+ detail: `Your ${modeLabel(notification.modeId)} match is ready.`,
+ accent: '#ffd9b0',
+ border: '1px solid rgba(220,132,76,0.30)',
+ background: 'linear-gradient(180deg, rgba(92,48,26,0.30) 0%, rgba(28,18,14,0.16) 100%)',
+ actionLabel: 'Open Friends',
+ };
+ case 'direct_challenge_declined':
+ return {
+ title: `${handle} declined your direct challenge`,
+ detail: 'You can send a different invite later.',
+ accent: '#ffd0d0',
+ border: '1px solid rgba(204,92,92,0.28)',
+ background: 'linear-gradient(180deg, rgba(88,34,34,0.30) 0%, rgba(22,14,14,0.16) 100%)',
+ actionLabel: 'Open Friends',
+ };
+ case 'direct_challenge_cancelled':
+ default:
+ return {
+ title: `${handle} cancelled a direct challenge`,
+ detail: 'The pending invite is no longer active.',
+ accent: '#f0ddbe',
+ border: '1px solid rgba(255,255,255,0.12)',
+ background: 'linear-gradient(180deg, rgba(255,255,255,0.06) 0%, rgba(18,16,14,0.12) 100%)',
+ actionLabel: 'Open Friends',
+ };
+ }
+}
+
+export default function InboxPage({
+ accountId = null,
+ sessionToken = null,
+ liveRefreshToken = 0,
+ onOpenProfile,
+ onOpenFriends,
+ onUnreadCountChange,
+}: InboxPageProps): React.ReactElement {
+ const [overview, setOverview] = React.useState
(null);
+ const [loading, setLoading] = React.useState(false);
+ const [error, setError] = React.useState('');
+ const [notice, setNotice] = React.useState('');
+ const [busyNotificationId, setBusyNotificationId] = React.useState(null);
+
+ const loadOverview = React.useCallback(async () => {
+ if (!accountId || !sessionToken) {
+ setOverview(null);
+ onUnreadCountChange?.(0);
+ return;
+ }
+ setLoading(true);
+ setError('');
+ try {
+ const nextOverview = await fetchAccountNotificationOverview({
+ accountId,
+ sessionToken,
+ limit: 48,
+ });
+ setOverview(nextOverview);
+ onUnreadCountChange?.(nextOverview.unreadCount);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load inbox.');
+ } finally {
+ setLoading(false);
+ }
+ }, [accountId, onUnreadCountChange, sessionToken]);
+
+ React.useEffect(() => {
+ if (busyNotificationId) {
+ return;
+ }
+ void loadOverview();
+ }, [busyNotificationId, liveRefreshToken, loadOverview]);
+
+ const markRead = React.useCallback(async (notification: AccountNotificationView) => {
+ if (!accountId || !sessionToken || notification.readAt) {
+ return;
+ }
+ setBusyNotificationId(notification.notificationId);
+ setError('');
+ setNotice('');
+ try {
+ const nextOverview = await markAccountNotificationRead({
+ accountId,
+ sessionToken,
+ notificationId: notification.notificationId,
+ });
+ setOverview(nextOverview);
+ onUnreadCountChange?.(nextOverview.unreadCount);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to mark notification as read.');
+ } finally {
+ setBusyNotificationId(null);
+ }
+ }, [accountId, onUnreadCountChange, sessionToken]);
+
+ const markAllRead = React.useCallback(async () => {
+ if (!accountId || !sessionToken) {
+ return;
+ }
+ setBusyNotificationId('__all__');
+ setError('');
+ setNotice('');
+ try {
+ const nextOverview = await markAllAccountNotificationsRead({
+ accountId,
+ sessionToken,
+ });
+ setOverview(nextOverview);
+ onUnreadCountChange?.(nextOverview.unreadCount);
+ setNotice(nextOverview.unreadCount === 0 ? 'Inbox marked as read.' : 'Unread state refreshed.');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to mark inbox as read.');
+ } finally {
+ setBusyNotificationId(null);
+ }
+ }, [accountId, onUnreadCountChange, sessionToken]);
+
+ const renderNotification = React.useCallback((notification: AccountNotificationView) => {
+ const descriptor = describeNotification(notification);
+ const unread = !notification.readAt;
+ return (
+
+
+
+
+
+ {unread ? 'Unread' : 'Read'}
+
+ onOpenProfile?.(notification.actor.handle)}
+ style={{
+ padding: 0,
+ background: 'transparent',
+ border: 'none',
+ color: descriptor.accent,
+ fontWeight: 700,
+ cursor: 'pointer',
+ textAlign: 'left',
+ }}
+ >
+ @{notification.actor.handle}
+
+
+
+ {descriptor.title}
+
+
+ {descriptor.detail}
+
+
+ {formatDateTime(notification.updatedAt)}
+
+
+
+ onOpenFriends?.()}
+ style={{
+ padding: '10px 12px',
+ borderRadius: '10px',
+ border: '1px solid rgba(255,255,255,0.12)',
+ background: 'rgba(255,255,255,0.06)',
+ color: '#fff6dc',
+ fontWeight: 700,
+ cursor: 'pointer',
+ }}
+ >
+ {descriptor.actionLabel}
+
+ void markRead(notification)}
+ disabled={!unread || busyNotificationId === notification.notificationId}
+ style={{
+ padding: '10px 12px',
+ borderRadius: '10px',
+ border: unread ? '1px solid rgba(255,215,0,0.22)' : '1px solid rgba(255,255,255,0.08)',
+ background: unread ? 'rgba(255,215,0,0.08)' : 'rgba(255,255,255,0.025)',
+ color: unread ? '#ffe7a6' : 'rgba(255,232,180,0.48)',
+ fontWeight: 600,
+ cursor: unread ? 'pointer' : 'default',
+ }}
+ >
+ {busyNotificationId === notification.notificationId ? 'Updating…' : (unread ? 'Mark Read' : 'Already Read')}
+
+
+
+
+ );
+ }, [busyNotificationId, markRead, onOpenFriends, onOpenProfile]);
+
+ if (!accountId || !sessionToken) {
+ return (
+
+ Inbox
+
+ Sign in with a claimed account to unlock your persistent social inbox for friend requests, direct challenges, and platform notifications.
+
+
+ );
+ }
+
+ const notifications = overview?.notifications ?? [];
+
+ return (
+
+
+
+
Inbox
+
+ Persistent social updates for your account: friend graph activity, direct challenge flow, and live platform signals that should survive page switches and offline time.
+
+
+
+
+ {overview?.unreadCount ?? 0} unread
+
+
void markAllRead()}
+ disabled={!overview || overview.unreadCount === 0 || busyNotificationId === '__all__'}
+ style={{
+ padding: '10px 14px',
+ borderRadius: '12px',
+ border: '1px solid rgba(255,255,255,0.12)',
+ background: 'rgba(255,255,255,0.06)',
+ color: '#fff6dc',
+ fontWeight: 700,
+ cursor: overview?.unreadCount ? 'pointer' : 'default',
+ }}
+ >
+ {busyNotificationId === '__all__' ? 'Updating…' : 'Mark All Read'}
+
+
void loadOverview()}
+ style={{
+ padding: '10px 14px',
+ borderRadius: '12px',
+ border: '1px solid rgba(255,255,255,0.12)',
+ background: 'rgba(255,255,255,0.04)',
+ color: 'rgba(255,232,180,0.88)',
+ fontWeight: 600,
+ cursor: 'pointer',
+ }}
+ >
+ Refresh
+
+
+
+
+ {notice ? (
+
+ {notice}
+
+ ) : null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {loading && !overview ? (
+ Loading inbox…
+ ) : null}
+
+ {!loading && notifications.length === 0 ? (
+
+ Your inbox is clear. As friends send requests or direct challenges, this account-level feed will keep the history in one place.
+
+ ) : null}
+
+
+ {notifications.map(renderNotification)}
+
+
+ );
+}
diff --git a/apps/web/src/LobbiesPage.tsx b/apps/web/src/LobbiesPage.tsx
new file mode 100644
index 0000000..9b26bcf
--- /dev/null
+++ b/apps/web/src/LobbiesPage.tsx
@@ -0,0 +1,215 @@
+import React from 'react';
+import { DEFAULT_MATCH_MODE_ID, OFFICIAL_MATCH_MODES, type MatchModeId, type PieceColor } from '@chess404/contracts';
+import { createPrivateMatch, type PrivateMatchIdentity } from './lib/private-match-service';
+import { writeStoredRoomMeta } from './lib/match-service';
+
+interface LobbiesPageProps {
+ identity: PrivateMatchIdentity | null;
+ displayName?: string | null;
+ hostedRuntime: boolean;
+ embedded?: boolean;
+}
+
+export default function LobbiesPage({ identity, displayName, hostedRuntime, embedded = false }: LobbiesPageProps): React.ReactElement {
+ const [modeId, setModeId] = React.useState(DEFAULT_MATCH_MODE_ID);
+ const [preferredSeat, setPreferredSeat] = React.useState('white');
+ const [creating, setCreating] = React.useState(false);
+ const [error, setError] = React.useState('');
+ const [created, setCreated] = React.useState<{ matchId: string; inviteUrl: string; waitingForOpponent: boolean } | null>(null);
+
+ const handleCreate = React.useCallback(async () => {
+ if (!identity?.guestId) {
+ setError('Your hosted player session is still loading.');
+ return;
+ }
+ setCreating(true);
+ setError('');
+ try {
+ const result = await createPrivateMatch({
+ identity,
+ modeId,
+ preferredSeat,
+ clockSeconds: 600,
+ });
+ const inviteUrl = `${window.location.origin}/match/${encodeURIComponent(result.matchId)}`;
+ writeStoredRoomMeta(result.matchId, {
+ queue: 'direct',
+ modeId,
+ clockSeconds: 600,
+ viewerSeat: result.seatColor,
+ whiteGuestId: result.snapshot.match.whiteGuestId,
+ blackGuestId: result.snapshot.match.blackGuestId,
+ whiteAccountId: result.snapshot.match.whiteAccountId,
+ blackAccountId: result.snapshot.match.blackAccountId,
+ whiteName: result.snapshot.match.whiteName,
+ blackName: result.snapshot.match.blackName,
+ whitePlayerSecret: result.seatColor === 'white' ? result.claim?.playerSecret : undefined,
+ blackPlayerSecret: result.seatColor === 'black' ? result.claim?.playerSecret : undefined,
+ whiteClaimToken: result.seatColor === 'white' ? result.claim?.claimToken : undefined,
+ blackClaimToken: result.seatColor === 'black' ? result.claim?.claimToken : undefined,
+ whiteClaimExpiresAt: result.seatColor === 'white' ? result.claim?.expiresAt : undefined,
+ blackClaimExpiresAt: result.seatColor === 'black' ? result.claim?.expiresAt : undefined,
+ });
+ setCreated({
+ matchId: result.matchId,
+ inviteUrl,
+ waitingForOpponent: result.waitingForOpponent,
+ });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to create private room.');
+ } finally {
+ setCreating(false);
+ }
+ }, [identity, modeId, preferredSeat]);
+
+ const copyInviteLink = React.useCallback(async () => {
+ if (!created?.inviteUrl) return;
+ await navigator.clipboard.writeText(created.inviteUrl);
+ }, [created?.inviteUrl]);
+
+ const openRoom = React.useCallback(() => {
+ if (!created?.matchId) return;
+ window.location.href = `/match/${encodeURIComponent(created.matchId)}`;
+ }, [created?.matchId]);
+
+ return (
+
+ {!embedded ? (
+
+
Private Lobbies
+
+ Create a private invite room, share the link, and let the second device auto-join the empty seat like a real quick-pair platform should.
+
+
+ ) : null}
+
+
+
+
Create Invite Match
+
+ Current player: {displayName ?? identity?.guestId ?? 'Loading...'}
+
+
+
+
+ Mode
+ setModeId(event.target.value as MatchModeId)} style={selectStyle}>
+ {OFFICIAL_MATCH_MODES.map(mode => (
+ {mode.label}
+ ))}
+
+
+
+
+ Your Seat
+
+ {(['white', 'black'] as PieceColor[]).map(color => (
+ setPreferredSeat(color)}
+ style={{
+ padding: '10px 16px',
+ borderRadius: '999px',
+ border: preferredSeat === color ? '1px solid rgba(110,200,255,0.55)' : '1px solid rgba(255,255,255,0.10)',
+ background: preferredSeat === color ? 'rgba(54,116,255,0.22)' : 'rgba(255,255,255,0.04)',
+ color: preferredSeat === color ? '#dbe8ff' : 'rgba(214,224,255,0.68)',
+ cursor: 'pointer',
+ fontWeight: 800,
+ fontSize: '12px',
+ textTransform: 'capitalize',
+ }}
+ >
+ {color}
+
+ ))}
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
{ void handleCreate(); }}
+ disabled={creating || !identity?.guestId}
+ style={{
+ marginTop: '18px',
+ width: '100%',
+ padding: '13px 16px',
+ borderRadius: '12px',
+ border: '1px solid rgba(122,166,255,0.34)',
+ background: creating ? 'rgba(100,100,120,0.2)' : 'linear-gradient(135deg, rgba(52,110,255,0.92), rgba(68,160,255,0.92))',
+ color: '#f7fbff',
+ fontSize: '13px',
+ fontWeight: 900,
+ cursor: creating || !identity?.guestId ? 'not-allowed' : 'pointer',
+ boxShadow: creating ? 'none' : '0 16px 36px rgba(30,100,255,0.24)',
+ }}
+ >
+ {creating ? 'Creating lobby...' : 'Create Private Invite Match'}
+
+
+
+
+
Invite Flow
+
+ Share the room link with a friend. The first browser already owns one seat. The second device opens the canonical match route and claims the empty seat.
+
+
+ {created ? (
+
+
+
Invite Link
+
{created.inviteUrl}
+
+
+ { void copyInviteLink(); }} style={secondaryButtonStyle}>Copy Invite Link
+ Open Match Route
+
+
+ {created.waitingForOpponent
+ ? 'Room is live and waiting for the second seat to join.'
+ : 'Both seats are already claimed. New visitors will spectate or see the room as full.'}
+
+
+ ) : (
+
+ Create a lobby first, then copy the invite link to your phone or another browser.
+ {!hostedRuntime &&
Localhost still keeps its dev behavior, but hosted private lobbies are now the real platform path.
}
+
+ )}
+
+
+
+ );
+}
+
+const selectStyle: React.CSSProperties = {
+ padding: '12px 14px',
+ borderRadius: '12px',
+ border: '1px solid rgba(255,255,255,0.12)',
+ background: 'rgba(255,255,255,0.05)',
+ color: '#eef4ff',
+ fontSize: '13px',
+ outline: 'none',
+};
+
+const primaryButtonStyle: React.CSSProperties = {
+ padding: '11px 14px',
+ borderRadius: '10px',
+ border: '1px solid rgba(122,166,255,0.34)',
+ background: 'linear-gradient(135deg, rgba(52,110,255,0.92), rgba(68,160,255,0.92))',
+ color: '#f7fbff',
+ fontWeight: 900,
+ fontSize: '12px',
+ cursor: 'pointer',
+};
+
+const secondaryButtonStyle: React.CSSProperties = {
+ ...primaryButtonStyle,
+ background: 'rgba(255,255,255,0.06)',
+ border: '1px solid rgba(255,255,255,0.14)',
+};
diff --git a/apps/web/src/ModesPage.tsx b/apps/web/src/ModesPage.tsx
new file mode 100644
index 0000000..2197314
--- /dev/null
+++ b/apps/web/src/ModesPage.tsx
@@ -0,0 +1,207 @@
+import React from 'react';
+import { OFFICIAL_MATCH_MODES } from '@chess404/contracts';
+import type { MatchModeId } from '@chess404/contracts';
+import type { QueueName, QueueSnapshot } from './lib/matchmaking-service';
+import { fetchQueueSnapshots } from './lib/matchmaking-service';
+
+interface ModesPageProps {
+ onPlayMode?: (modeId: MatchModeId, queue: QueueName) => void;
+}
+
+function emptySnapshot(queue: QueueName, modeId: MatchModeId): QueueSnapshot {
+ return {
+ queue,
+ modeId,
+ queuedCount: 0,
+ matchedCount: 0,
+ cancelledCount: 0,
+ };
+}
+
+function resolveSnapshot(snapshots: QueueSnapshot[], queue: QueueName, modeId: MatchModeId): QueueSnapshot {
+ return snapshots.find((snapshot) => snapshot.queue === queue && snapshot.modeId === modeId) ?? emptySnapshot(queue, modeId);
+}
+
+export default function ModesPage({ onPlayMode }: ModesPageProps): React.ReactElement {
+ const [snapshots, setSnapshots] = React.useState([]);
+ const [loading, setLoading] = React.useState(true);
+ const [error, setError] = React.useState('');
+
+ const refresh = React.useCallback(async () => {
+ setLoading(true);
+ setError('');
+ try {
+ const payload = await fetchQueueSnapshots();
+ setSnapshots(payload.snapshots);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load live queue health.');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ React.useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ return (
+
+
+
+
+
+
Official Modes
+
+ Curated competitive formats with live queue health, clear rules, and direct play entry into the selected lane.
+
+
+
void refresh()}
+ style={{
+ padding: '8px 12px',
+ borderRadius: '8px',
+ border: '1px solid rgba(255,180,60,0.35)',
+ background: 'linear-gradient(180deg, rgba(200,134,10,0.32) 0%, rgba(122,79,8,0.4) 100%)',
+ color: '#fff2c8',
+ fontSize: '12px',
+ fontWeight: 700,
+ cursor: 'pointer',
+ }}
+ >
+ Refresh Live Health
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading ? (
+
Loading mode health...
+ ) : (
+
+ {OFFICIAL_MATCH_MODES.map((mode) => {
+ const casual = resolveSnapshot(snapshots, 'casual', mode.id);
+ const rated = resolveSnapshot(snapshots, 'rated', mode.id);
+ const queuedTotal = casual.queuedCount + rated.queuedCount;
+ const matchedTotal = casual.matchedCount + rated.matchedCount;
+ return (
+
+
+
+
{mode.label}
+
+ {mode.rulesSummary}
+
+
+
+
Waiting
+
{queuedTotal}
+
+
+
+
+
+
Casual Waiting
+
{casual.queuedCount}
+
+
+
Rated Waiting
+
{rated.queuedCount}
+
+
+
Recently Matched
+
{matchedTotal}
+
+
+
+
+ onPlayMode?.(mode.id, 'casual')}
+ style={{
+ flex: 1,
+ minWidth: '140px',
+ padding: '11px 14px',
+ borderRadius: '10px',
+ border: '1px solid rgba(255,180,60,0.28)',
+ background: 'rgba(255,180,60,0.08)',
+ color: '#fff0c6',
+ fontSize: '12px',
+ fontWeight: 800,
+ cursor: 'pointer',
+ }}
+ >
+ Play Casual
+
+ onPlayMode?.(mode.id, 'rated')}
+ style={{
+ flex: 1,
+ minWidth: '140px',
+ padding: '11px 14px',
+ borderRadius: '10px',
+ border: '1px solid rgba(110,170,255,0.32)',
+ background: 'linear-gradient(180deg, rgba(54,102,184,0.24) 0%, rgba(24,40,82,0.34) 100%)',
+ color: '#e5f0ff',
+ fontSize: '12px',
+ fontWeight: 800,
+ cursor: 'pointer',
+ }}
+ >
+ Play Rated
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/PlayHubPage.tsx b/apps/web/src/PlayHubPage.tsx
new file mode 100644
index 0000000..72b0328
--- /dev/null
+++ b/apps/web/src/PlayHubPage.tsx
@@ -0,0 +1,273 @@
+import React from 'react';
+import { type MatchModeId, type PieceColor } from '@chess404/contracts';
+import type { GuestProfile } from './lib/platform-service';
+import type { QueueName, QueueTicket } from './lib/matchmaking-service';
+import type { PrivateMatchIdentity } from './lib/private-match-service';
+import { modeLabel, queueLabel } from './lib/match-labels';
+import QueuePage from './QueuePage';
+import LobbiesPage from './LobbiesPage';
+
+interface PlayHubPageProps {
+ hostedRuntime: boolean;
+ whiteProfile: GuestProfile | null;
+ blackProfile: GuestProfile | null;
+ preferredQueue?: QueueName | null;
+ preferredModeId?: MatchModeId | null;
+ queueRecovery?: {
+ white: QueueTicket | null;
+ black: QueueTicket | null;
+ } | null;
+ identity: PrivateMatchIdentity | null;
+ displayName?: string | null;
+ activeMatchId?: string | null;
+ activeMatchQueue?: QueueName | 'direct' | null;
+ activeMatchModeId?: MatchModeId | null;
+ boardStatusLabel: string;
+ viewerSeat?: PieceColor | null;
+ matchDestinationNotice?: string | null;
+ onReturnToMatch?: () => void;
+ onCopyMatchLink?: (matchId: string) => void;
+}
+
+
+function viewerRoleLabel(viewerSeat?: PieceColor | null): string {
+ if (viewerSeat === 'white') {
+ return 'Playing as White';
+ }
+ if (viewerSeat === 'black') {
+ return 'Playing as Black';
+ }
+ return 'Spectating read-only';
+}
+
+export default function PlayHubPage({
+ hostedRuntime,
+ whiteProfile,
+ blackProfile,
+ preferredQueue = null,
+ preferredModeId = null,
+ queueRecovery = null,
+ identity,
+ displayName = null,
+ activeMatchId = null,
+ activeMatchQueue = null,
+ activeMatchModeId = null,
+ boardStatusLabel,
+ viewerSeat = null,
+ matchDestinationNotice = null,
+ onReturnToMatch,
+ onCopyMatchLink,
+}: PlayHubPageProps): React.ReactElement {
+ return (
+
+
+
+
+ Play
+
+
+ Queue into a real match or create a private invite
+
+
+ Choose an official mode, enter casual or rated quick pair, or open one clean invite room for a friend. The live board only opens when a real match is ready.
+
+
+
+ {activeMatchId ? (
+
+
+
+
+ Active Match
+
+
+ {boardStatusLabel}
+
+
+ Your live game stays separate from the play hub so you can queue again later, reopen the board instantly, or share the public match destination.
+
+
+
+
+
+ Return To Match
+
+ onCopyMatchLink?.(activeMatchId)} style={secondaryActionStyle}>
+ Share Match Link
+
+
+
+
+
+
+
+
+
+
+ {matchDestinationNotice ? (
+
+ {matchDestinationNotice}
+
+ ) : null}
+
+ ) : (
+
+
+
+
+ )}
+
+
+
+
+
+ Quick Pair
+
+
+ Official modes and queue lane selection stay together
+
+
+ Pick the official mode and the competitive lane from one surface, then wait here until a real opponent is assigned.
+
+
+
+
+
+
+
+
+
+ Play A Friend
+
+
+ Create one real room and share one clean invite link
+
+
+ The first browser owns one seat, the second device claims the other seat, and the waiting room becomes the match destination.
+
+
+
+
+
+
+
+
+ );
+}
+
+function MetaTile(props: { label: string; value: string }): React.ReactElement {
+ return (
+
+
+ {props.label}
+
+
+ {props.value}
+
+
+ );
+}
+
+function LaunchTile(props: { eyebrow: string; title: string; detail: string }): React.ReactElement {
+ return (
+
+
+ {props.eyebrow}
+
+
+ {props.title}
+
+
+ {props.detail}
+
+
+ );
+}
+
+const primaryActionStyle: React.CSSProperties = {
+ padding: '11px 16px',
+ borderRadius: '10px',
+ border: '1px solid rgba(122,166,255,0.36)',
+ background: 'linear-gradient(180deg, rgba(58,110,210,0.95) 0%, rgba(28,54,112,0.98) 100%)',
+ color: '#f7fbff',
+ fontWeight: 800,
+ fontSize: '12px',
+ cursor: 'pointer',
+};
+
+const secondaryActionStyle: React.CSSProperties = {
+ ...primaryActionStyle,
+ background: 'rgba(255,255,255,0.05)',
+ border: '1px solid rgba(255,255,255,0.12)',
+ color: 'rgba(244,247,255,0.88)',
+};
diff --git a/apps/web/src/ProfilesPage.tsx b/apps/web/src/ProfilesPage.tsx
new file mode 100644
index 0000000..58de2a6
--- /dev/null
+++ b/apps/web/src/ProfilesPage.tsx
@@ -0,0 +1,1050 @@
+import React from 'react';
+import { OFFICIAL_MATCH_MODES } from '@chess404/contracts';
+import type { MatchModeId } from '@chess404/contracts';
+import {
+ blockAccount,
+ fetchAccountArchivedMatches,
+ fetchAccountByHandle,
+ fetchAccountLeaderboard,
+ fetchModerationOverview,
+ submitPlayerReport,
+ unblockAccount,
+ type AccountProfile,
+ type AccountBlockView,
+ type AccountLeaderboardSummary,
+ type AccountSeasonSummary,
+ type MatchArchiveEntry,
+ type ModerationOverview,
+ type PlayerReportView,
+ type SeasonOption,
+} from './lib/platform-service';
+
+interface ProfilesPageProps {
+ focusHandle?: string | null;
+ viewerHandle?: string | null;
+ accountId?: string | null;
+ sessionToken?: string | null;
+ onSelectHandle?: (handle: string) => void;
+ onOpenReplay?: (matchId: string) => void;
+ onOpenAccount?: () => void;
+}
+
+function formatDateTime(value: string): string {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+ return date.toLocaleString();
+}
+
+function formatRatingDelta(delta: number): string {
+ return delta > 0 ? `+${delta}` : `${delta}`;
+}
+
+function parseModeFilterValue(value: string): MatchModeId | '' {
+ return OFFICIAL_MATCH_MODES.some((mode) => mode.id === value as MatchModeId) ? (value as MatchModeId) : '';
+}
+
+function describeSeason(summary?: AccountSeasonSummary): string {
+ if (!summary) {
+ return 'No official season record yet';
+ }
+ return `${summary.label}: ${summary.matchesPlayed} matches, ${formatRatingDelta(summary.netDelta)}`;
+}
+
+function formatWinRate(wins: number, matchesPlayed: number): string {
+ if (matchesPlayed <= 0) {
+ return '--';
+ }
+ return `${Math.round((wins / matchesPlayed) * 100)}%`;
+}
+
+function describeMatchOutcome(entry: MatchArchiveEntry, accountId: string): string {
+ if (entry.winner === 'draw') {
+ return 'Draw';
+ }
+ if (entry.whiteAccountId === accountId) {
+ return entry.winner === 'white' ? 'Win' : 'Loss';
+ }
+ if (entry.blackAccountId === accountId) {
+ return entry.winner === 'black' ? 'Win' : 'Loss';
+ }
+ return entry.status === 'active' ? 'Live' : 'Archived';
+}
+
+function describeOpponent(entry: MatchArchiveEntry, accountId: string): string {
+ if (entry.whiteAccountId === accountId) {
+ return entry.blackAccountHandle ? `@${entry.blackAccountHandle}` : (entry.blackName ?? entry.blackGuestId ?? 'Unknown');
+ }
+ if (entry.blackAccountId === accountId) {
+ return entry.whiteAccountHandle ? `@${entry.whiteAccountHandle}` : (entry.whiteName ?? entry.whiteGuestId ?? 'Unknown');
+ }
+ return entry.whiteName ?? entry.blackName ?? 'Unknown opponent';
+}
+
+export default function ProfilesPage({
+ focusHandle = null,
+ viewerHandle = null,
+ accountId = null,
+ sessionToken = null,
+ onSelectHandle,
+ onOpenReplay,
+ onOpenAccount,
+}: ProfilesPageProps): React.ReactElement {
+ const [searchInput, setSearchInput] = React.useState('');
+ const [submittedQuery, setSubmittedQuery] = React.useState('');
+ const [selectedModeId, setSelectedModeId] = React.useState('');
+ const [selectedSeasonId, setSelectedSeasonId] = React.useState('');
+ const [directory, setDirectory] = React.useState([]);
+ const [seasons, setSeasons] = React.useState([]);
+ const [directorySummary, setDirectorySummary] = React.useState(undefined);
+ const [directoryLoading, setDirectoryLoading] = React.useState(true);
+ const [directoryError, setDirectoryError] = React.useState('');
+ const [profile, setProfile] = React.useState(null);
+ const [profileLoading, setProfileLoading] = React.useState(false);
+ const [profileError, setProfileError] = React.useState('');
+ const [recentMatches, setRecentMatches] = React.useState([]);
+ const [recentMatchesLoading, setRecentMatchesLoading] = React.useState(false);
+ const [recentMatchesError, setRecentMatchesError] = React.useState('');
+ const [moderationOverview, setModerationOverview] = React.useState(null);
+ const [moderationLoading, setModerationLoading] = React.useState(false);
+ const [moderationError, setModerationError] = React.useState('');
+ const [moderationBusy, setModerationBusy] = React.useState(false);
+ const [reportCategory, setReportCategory] = React.useState('abuse');
+ const [reportDetails, setReportDetails] = React.useState('');
+ const [notice, setNotice] = React.useState('');
+
+ const resolvedFocusHandle = (focusHandle ?? '').trim().toLowerCase();
+ const authenticatedViewer = Boolean(accountId && sessionToken);
+
+ React.useEffect(() => {
+ if (!resolvedFocusHandle) {
+ return;
+ }
+ setSearchInput(resolvedFocusHandle);
+ }, [resolvedFocusHandle]);
+
+ const refreshDirectory = React.useCallback(async (query: string, modeId: MatchModeId | '') => {
+ setDirectoryLoading(true);
+ setDirectoryError('');
+ setDirectorySummary(undefined);
+ try {
+ const payload = await fetchAccountLeaderboard(40, 'rating', undefined, modeId || undefined, query || undefined);
+ setDirectory(payload.accounts);
+ setSeasons(payload.seasons);
+ setDirectorySummary(payload.summary);
+ } catch (err) {
+ setDirectoryError(err instanceof Error ? err.message : 'Failed to load public profiles.');
+ setDirectorySummary(undefined);
+ } finally {
+ setDirectoryLoading(false);
+ }
+ }, []);
+
+ React.useEffect(() => {
+ void refreshDirectory(submittedQuery, selectedModeId);
+ }, [refreshDirectory, selectedModeId, submittedQuery]);
+
+ React.useEffect(() => {
+ if (!resolvedFocusHandle) {
+ setProfile(null);
+ setProfileError('');
+ setSelectedSeasonId('');
+ return;
+ }
+
+ let cancelled = false;
+ setProfileLoading(true);
+ setProfileError('');
+
+ void fetchAccountByHandle(resolvedFocusHandle, selectedSeasonId || undefined, selectedModeId || undefined)
+ .then((nextProfile) => {
+ if (cancelled) {
+ return;
+ }
+ setProfile(nextProfile);
+ if (selectedSeasonId && !nextProfile.selectedSeason) {
+ setSelectedSeasonId('');
+ }
+ })
+ .catch((err: unknown) => {
+ if (cancelled) {
+ return;
+ }
+ setProfile(null);
+ setProfileError(err instanceof Error ? err.message : 'Failed to load public profile.');
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setProfileLoading(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [resolvedFocusHandle, selectedModeId, selectedSeasonId]);
+
+ React.useEffect(() => {
+ if (!profile?.accountId) {
+ setRecentMatches([]);
+ setRecentMatchesLoading(false);
+ setRecentMatchesError('');
+ return;
+ }
+
+ let cancelled = false;
+ setRecentMatchesLoading(true);
+ setRecentMatchesError('');
+
+ void fetchAccountArchivedMatches(profile.accountId, 8, selectedSeasonId || undefined, selectedModeId || undefined)
+ .then((matches) => {
+ if (!cancelled) {
+ setRecentMatches(matches);
+ }
+ })
+ .catch((err: unknown) => {
+ if (!cancelled) {
+ setRecentMatches([]);
+ setRecentMatchesError(err instanceof Error ? err.message : 'Failed to load public profile matches.');
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setRecentMatchesLoading(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [profile?.accountId, selectedModeId, selectedSeasonId]);
+
+ React.useEffect(() => {
+ if (!authenticatedViewer || !accountId || !sessionToken || !profile?.accountId || profile.accountId === accountId) {
+ setModerationOverview(null);
+ setModerationLoading(false);
+ setModerationError('');
+ return;
+ }
+
+ let cancelled = false;
+ setModerationLoading(true);
+ setModerationError('');
+
+ void fetchModerationOverview({ accountId, sessionToken })
+ .then((overview) => {
+ if (!cancelled) {
+ setModerationOverview(overview);
+ }
+ })
+ .catch((err: unknown) => {
+ if (!cancelled) {
+ setModerationOverview(null);
+ setModerationError(err instanceof Error ? err.message : 'Failed to load trust controls.');
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setModerationLoading(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [accountId, authenticatedViewer, profile?.accountId, sessionToken]);
+
+ const openHandle = React.useCallback((handle: string) => {
+ const normalized = handle.trim().toLowerCase();
+ if (!normalized) {
+ return;
+ }
+ setProfileError('');
+ setNotice('');
+ onSelectHandle?.(normalized);
+ }, [onSelectHandle]);
+
+ const submitDirectorySearch = React.useCallback(() => {
+ setSubmittedQuery(searchInput.trim().toLowerCase());
+ }, [searchInput]);
+
+ const copyProfileLink = React.useCallback(async () => {
+ if (!profile || typeof window === 'undefined') {
+ return;
+ }
+ const profileUrl = `${window.location.origin}/?profile=${encodeURIComponent(profile.handle)}`;
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(profileUrl);
+ } else {
+ const textArea = document.createElement('textarea');
+ textArea.value = profileUrl;
+ textArea.style.position = 'fixed';
+ textArea.style.opacity = '0';
+ document.body.appendChild(textArea);
+ textArea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textArea);
+ }
+ setNotice('Profile link copied.');
+ } catch {
+ setNotice('Copy failed. You can still share the current profile URL from the address bar.');
+ }
+ }, [profile]);
+
+ const outgoingBlock = React.useMemo(() => {
+ if (!profile?.accountId) {
+ return null;
+ }
+ return moderationOverview?.outgoingBlocks.find((block) => block.account.accountId === profile.accountId) ?? null;
+ }, [moderationOverview, profile?.accountId]);
+
+ const incomingBlock = React.useMemo(() => {
+ if (!profile?.accountId) {
+ return null;
+ }
+ return moderationOverview?.incomingBlocks.find((block) => block.account.accountId === profile.accountId) ?? null;
+ }, [moderationOverview, profile?.accountId]);
+
+ const submittedReportsForFocus = React.useMemo(() => {
+ if (!profile?.accountId) {
+ return [];
+ }
+ return moderationOverview?.submittedReports.filter((report) => report.target.accountId === profile.accountId) ?? [];
+ }, [moderationOverview, profile?.accountId]);
+
+ const handleBlockToggle = React.useCallback(async () => {
+ if (!accountId || !sessionToken || !profile?.accountId || moderationBusy) {
+ return;
+ }
+ setModerationBusy(true);
+ setModerationError('');
+ setNotice('');
+ try {
+ const nextOverview = outgoingBlock
+ ? await unblockAccount({ accountId, sessionToken, targetAccountId: profile.accountId })
+ : await blockAccount({
+ accountId,
+ sessionToken,
+ targetAccountId: profile.accountId,
+ reason: submittedReportsForFocus.length > 0 ? 'Escalated from prior reports' : '',
+ });
+ setModerationOverview(nextOverview);
+ setNotice(outgoingBlock ? `Unblocked @${profile.handle}` : `Blocked @${profile.handle}`);
+ } catch (err) {
+ setModerationError(err instanceof Error ? err.message : 'Failed to update account block.');
+ } finally {
+ setModerationBusy(false);
+ }
+ }, [accountId, moderationBusy, outgoingBlock, profile?.accountId, profile?.handle, sessionToken, submittedReportsForFocus.length]);
+
+ const handleSubmitReport = React.useCallback(async () => {
+ if (!accountId || !sessionToken || !profile?.accountId || moderationBusy) {
+ return;
+ }
+ setModerationBusy(true);
+ setModerationError('');
+ setNotice('');
+ try {
+ const nextOverview = await submitPlayerReport({
+ accountId,
+ sessionToken,
+ targetAccountId: profile.accountId,
+ category: reportCategory,
+ details: reportDetails.trim(),
+ });
+ setModerationOverview(nextOverview);
+ setReportDetails('');
+ setNotice(`Report submitted for @${profile.handle}`);
+ } catch (err) {
+ setModerationError(err instanceof Error ? err.message : 'Failed to submit player report.');
+ } finally {
+ setModerationBusy(false);
+ }
+ }, [accountId, moderationBusy, profile?.accountId, profile?.handle, reportCategory, reportDetails, sessionToken]);
+
+ const focusedOwnProfile = Boolean(accountId && profile?.accountId && profile.accountId === accountId);
+
+ const highlightedSeason = profile?.selectedSeason ?? profile?.currentSeason;
+ const highlightedMatchesPlayed = highlightedSeason?.matchesPlayed ?? profile?.matchesPlayed ?? 0;
+ const highlightedWins = highlightedSeason?.wins ?? profile?.wins ?? 0;
+ const highlightedPeak = highlightedSeason?.peakRating ?? profile?.rating ?? 1200;
+ const highlightedDelta = highlightedSeason?.netDelta ?? 0;
+ const recentForm = React.useMemo(() => {
+ const history = profile?.ratingHistory ?? [];
+ if (history.length === 0) {
+ return 'No recent rated results';
+ }
+ return history.slice(-5).map((entry) => {
+ switch (entry.result) {
+ case 'win':
+ return 'W';
+ case 'loss':
+ return 'L';
+ default:
+ return 'D';
+ }
+ }).join(' · ');
+ }, [profile?.ratingHistory]);
+ const spotlightBadges = React.useMemo(() => {
+ if (!profile?.accountId || !directorySummary) {
+ return [] as string[];
+ }
+ const badges: string[] = [];
+ if (directorySummary.leader?.accountId === profile.accountId) {
+ badges.push('Current leader');
+ }
+ if (directorySummary.biggestClimber?.accountId === profile.accountId) {
+ badges.push('Biggest climb');
+ }
+ if (directorySummary.highestPeak?.accountId === profile.accountId) {
+ badges.push('Peak holder');
+ }
+ if (directorySummary.mostActive?.accountId === profile.accountId) {
+ badges.push('Most active');
+ }
+ return badges;
+ }, [directorySummary, profile?.accountId]);
+
+ return (
+
+
+
+
Public Profiles
+
+ Search claimed handles, inspect official-mode ladders, and open shareable public profile views for Chess404 players.
+
+
+
+
+ setSearchInput(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ submitDirectorySearch();
+ }
+ }}
+ placeholder="Search by handle"
+ style={{
+ flex: 1,
+ padding: '10px 12px',
+ borderRadius: '10px',
+ border: '1px solid rgba(255,180,60,0.24)',
+ background: 'rgba(255,255,255,0.04)',
+ color: '#fff2c8',
+ fontSize: '12px',
+ fontWeight: 700,
+ outline: 'none',
+ }}
+ />
+
+ Search
+
+
+
+
+ {viewerHandle && (
+ openHandle(viewerHandle)}
+ style={{
+ padding: '8px 10px',
+ borderRadius: '999px',
+ border: '1px solid rgba(255,180,60,0.22)',
+ background: 'rgba(255,180,60,0.08)',
+ color: '#ffe7a9',
+ fontSize: '11px',
+ fontWeight: 800,
+ cursor: 'pointer',
+ }}
+ >
+ Open my profile
+
+ )}
+ {resolvedFocusHandle && (
+ openHandle(resolvedFocusHandle)}
+ style={{
+ padding: '8px 10px',
+ borderRadius: '999px',
+ border: '1px solid rgba(255,180,60,0.16)',
+ background: 'rgba(255,255,255,0.03)',
+ color: '#fff2c8',
+ fontSize: '11px',
+ fontWeight: 800,
+ cursor: 'pointer',
+ }}
+ >
+ Refresh focus
+
+ )}
+
+
+
setSelectedModeId(parseModeFilterValue(event.target.value))}
+ style={{
+ padding: '9px 10px',
+ borderRadius: '10px',
+ border: '1px solid rgba(255,180,60,0.24)',
+ background: 'rgba(255,255,255,0.04)',
+ color: '#fff2c8',
+ fontSize: '12px',
+ fontWeight: 700,
+ }}
+ >
+ All official modes
+ {OFFICIAL_MATCH_MODES.map((mode) => (
+
+ {mode.label}
+
+ ))}
+
+
+
+
+
+ {directoryError && (
+
+ {directoryError}
+
+ )}
+
+ {directoryLoading ? (
+
Loading public profiles...
+ ) : directory.length === 0 ? (
+
+ {submittedQuery
+ ? 'No claimed handles match that search yet.'
+ : 'No claimed public profiles exist yet. Open the Account tab to claim a handle.'}
+
+ ) : (
+
+ {directory.map((account) => {
+ const season = account.selectedSeason ?? account.currentSeason;
+ const focused = resolvedFocusHandle === account.handle;
+ return (
+
openHandle(account.handle)}
+ style={{
+ textAlign: 'left',
+ cursor: 'pointer',
+ borderRadius: '14px',
+ border: focused ? '1px solid rgba(255,190,90,0.34)' : '1px solid rgba(255,165,40,0.12)',
+ background: focused
+ ? 'linear-gradient(180deg, rgba(200,134,10,0.2) 0%, rgba(70,42,8,0.22) 100%)'
+ : 'linear-gradient(180deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.02) 100%)',
+ boxShadow: '0 10px 28px rgba(0,0,0,0.24)',
+ padding: '14px 15px 13px',
+ display: 'grid',
+ gap: '8px',
+ color: 'inherit',
+ }}
+ >
+
+
+
+ {account.displayName ?? account.handle}
+
+
+ @{account.handle}
+
+
+
+ {account.selectedSeason?.ratingEnd ?? account.rating ?? 1200}
+
+
+
+ {account.matchesPlayed ?? 0} matches - {account.wins ?? 0}W {account.losses ?? 0}L {account.draws ?? 0}D
+
+
+ {describeSeason(season)}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+
Profile Focus
+
+ Public handles tie rankings, replay history, and future social identity into one player-facing destination.
+
+
+
+
+ {profileError && (
+
+ {profileError}
+
+ )}
+
+ {notice && (
+
+ {notice}
+
+ )}
+
+ {moderationError && (
+
+ {moderationError}
+
+ )}
+
+ {profileLoading ? (
+
Loading public profile...
+ ) : !profile ? (
+
+
+
♟
+
+ {resolvedFocusHandle ? 'That handle is not live yet' : 'Choose a player profile'}
+
+
+ {resolvedFocusHandle
+ ? 'The requested handle could not be resolved to a claimed Chess404 account. Try another search or open a visible ladder profile from the directory.'
+ : 'Open a claimed handle from the directory to see ratings, season momentum, spotlight badges, and recent replay history in one shareable player page.'}
+
+ {onOpenAccount ? (
+
+
+ Open Account
+
+
+ ) : null}
+
+
+ ) : (
+
+
+
+
+
{profile.displayName ?? profile.handle}
+
@{profile.handle}
+
+ Joined {formatDateTime(profile.createdAt)} · last seen {formatDateTime(profile.lastSeenAt)}
+
+
+
+
{highlightedSeason?.ratingEnd ?? profile.rating ?? 1200}
+
+ {describeSeason(highlightedSeason)}
+
+
+
+
+
+ void copyProfileLink()}
+ style={{
+ padding: '8px 10px',
+ borderRadius: '999px',
+ border: '1px solid rgba(255,210,120,0.28)',
+ background: 'rgba(255,200,100,0.14)',
+ color: '#ffe8af',
+ fontSize: '11px',
+ fontWeight: 800,
+ cursor: 'pointer',
+ }}
+ >
+ Copy profile link
+
+ {viewerHandle && viewerHandle === profile.handle && (
+
+ Your claimed handle
+
+ )}
+ {spotlightBadges.map((badge) => (
+
+ {badge}
+
+ ))}
+
+
+
+
+
+
+
Competitive snapshot
+
+ {directorySummary?.seasonLabel ?? 'Current ladder'} · {selectedModeId ? OFFICIAL_MATCH_MODES.find((mode) => mode.id === selectedModeId)?.label ?? 'Official mode' : 'All official modes'}
+
+
+ {directorySummary?.leader?.accountId === profile.accountId && (
+
Leading this lane now
+ )}
+
+
+
+
Win rate
+
{formatWinRate(highlightedWins, highlightedMatchesPlayed)}
+
{highlightedWins} wins across {highlightedMatchesPlayed} matches
+
+
+
Peak rating
+
{highlightedPeak}
+
{highlightedSeason?.label ?? 'Overall ladder peak'}
+
+
+
Season delta
+
= 0 ? '#7ce3aa' : '#ffb1a7', fontSize: '22px', fontWeight: 900, marginTop: '6px' }}>{formatRatingDelta(highlightedDelta)}
+
{highlightedSeason ? highlightedSeason.label : 'No season delta yet'}
+
+
+
Recent form
+
{recentForm}
+
Last five rated decisions in this lane
+
+
+
+
+
+
+
+
Trust & Safety
+
+ Blocks shut down new friend requests and direct challenges between two accounts. Reports create a moderation record against the player profile you are viewing.
+
+
+ {moderationLoading && (
+
Loading trust state...
+ )}
+
+
+ {!authenticatedViewer ? (
+
+
+ Sign in with a claimed Chess404 account to block players or file reports.
+
+ {onOpenAccount && (
+
+ Open account
+
+ )}
+
+ ) : focusedOwnProfile ? (
+
+ This is your own public profile. Trust controls appear when you inspect another player.
+
+ ) : (
+ <>
+ {(outgoingBlock || incomingBlock) && (
+
+ {outgoingBlock && (
+
+ You blocked @{profile.handle}{outgoingBlock.reason ? ` - ${outgoingBlock.reason}` : ''}.
+
+ )}
+ {incomingBlock && (
+
+ @{profile.handle} has blocked you. Social actions should remain locked from either side.
+
+ )}
+
+ )}
+
+
+ void handleBlockToggle()}
+ disabled={moderationBusy}
+ style={{
+ padding: '9px 12px',
+ borderRadius: '999px',
+ border: outgoingBlock ? '1px solid rgba(90,170,130,0.28)' : '1px solid rgba(231,76,60,0.28)',
+ background: outgoingBlock ? 'rgba(52,120,82,0.18)' : 'rgba(140,24,24,0.22)',
+ color: outgoingBlock ? '#d8ffe7' : '#ffd3c7',
+ fontSize: '11px',
+ fontWeight: 800,
+ cursor: moderationBusy ? 'wait' : 'pointer',
+ opacity: moderationBusy ? 0.7 : 1,
+ }}
+ >
+ {outgoingBlock ? 'Unblock player' : 'Block player'}
+
+
+ {submittedReportsForFocus.length > 0 ? `${submittedReportsForFocus.length} report(s) already submitted` : 'No prior reports submitted from this account'}
+
+
+
+
+
+ setReportCategory(event.target.value)}
+ style={{
+ padding: '9px 10px',
+ borderRadius: '10px',
+ border: '1px solid rgba(255,180,60,0.24)',
+ background: 'rgba(255,255,255,0.04)',
+ color: '#fff2c8',
+ fontSize: '12px',
+ fontWeight: 700,
+ }}
+ >
+ {['abuse', 'harassment', 'spam', 'impersonation', 'cheating', 'other'].map((category) => (
+
+ {category}
+
+ ))}
+
+ void handleSubmitReport()}
+ disabled={moderationBusy}
+ style={{
+ padding: '9px 12px',
+ borderRadius: '999px',
+ border: '1px solid rgba(255,180,60,0.26)',
+ background: 'rgba(255,180,60,0.12)',
+ color: '#ffe8af',
+ fontSize: '11px',
+ fontWeight: 800,
+ cursor: moderationBusy ? 'wait' : 'pointer',
+ opacity: moderationBusy ? 0.7 : 1,
+ }}
+ >
+ Submit report
+
+
+
+ >
+ )}
+
+
+
+ {[
+ { label: 'Matches', value: profile.matchesPlayed ?? 0, color: '#d8eaff' },
+ { label: 'Wins', value: profile.wins ?? 0, color: '#8ef0b6' },
+ { label: 'Losses', value: profile.losses ?? 0, color: '#ffb3a0' },
+ { label: 'Draws', value: profile.draws ?? 0, color: '#ffe2a5' },
+ ].map((stat) => (
+
+
{stat.label}
+
{stat.value}
+
+ ))}
+
+
+ {seasons.length > 0 && (
+
+ setSelectedSeasonId('')}
+ style={{
+ padding: '8px 10px',
+ borderRadius: '999px',
+ border: selectedSeasonId === '' ? '1px solid rgba(255,180,60,0.38)' : '1px solid rgba(255,180,60,0.18)',
+ background: selectedSeasonId === '' ? 'rgba(255,180,60,0.14)' : 'rgba(255,255,255,0.03)',
+ color: '#ffe9b1',
+ fontSize: '11px',
+ fontWeight: 800,
+ cursor: 'pointer',
+ }}
+ >
+ All seasons
+
+ {seasons.map((season) => (
+ setSelectedSeasonId(season.seasonId)}
+ style={{
+ padding: '8px 10px',
+ borderRadius: '999px',
+ border: selectedSeasonId === season.seasonId ? '1px solid rgba(255,180,60,0.38)' : '1px solid rgba(255,180,60,0.18)',
+ background: selectedSeasonId === season.seasonId ? 'rgba(255,180,60,0.14)' : 'rgba(255,255,255,0.03)',
+ color: '#ffe9b1',
+ fontSize: '11px',
+ fontWeight: 800,
+ cursor: 'pointer',
+ }}
+ >
+ {season.label}
+
+ ))}
+
+ )}
+
+
+
Recent account results
+ {recentMatchesLoading ? (
+
Loading archived matches...
+ ) : recentMatchesError ? (
+
{recentMatchesError}
+ ) : recentMatches.length === 0 ? (
+
No archived account matches match the current filters yet.
+ ) : (
+
+ {recentMatches.map((match) => (
+
onOpenReplay?.(match.matchId)}
+ style={{
+ textAlign: 'left',
+ cursor: onOpenReplay ? 'pointer' : 'default',
+ padding: '12px 12px 11px',
+ borderRadius: '12px',
+ border: '1px solid rgba(255,165,40,0.12)',
+ background: 'linear-gradient(180deg, rgba(18,23,36,0.95) 0%, rgba(11,14,24,0.94) 100%)',
+ color: '#fff2c8',
+ }}
+ >
+
+
+ {describeMatchOutcome(match, profile.accountId)} vs {describeOpponent(match, profile.accountId)}
+
+
+ {match.queue ?? 'direct'} · {match.status}
+
+
+
+ {match.moveCount} moves · updated {formatDateTime(match.updatedAt)}
+
+
+ ))}
+
+ )}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/QueuePage.tsx b/apps/web/src/QueuePage.tsx
index 8a3351a..d8130c0 100644
--- a/apps/web/src/QueuePage.tsx
+++ b/apps/web/src/QueuePage.tsx
@@ -1,12 +1,20 @@
import React from 'react';
+import type { MatchModeId } from '@chess404/contracts';
+import { DEFAULT_MATCH_MODE_ID, OFFICIAL_MATCH_MODES } from '@chess404/contracts';
import type { GuestProfile } from './lib/platform-service';
-import type { QueueName, QueueTicket } from './lib/matchmaking-service';
-import { cancelTicket, enqueueGuest, fetchQueueTickets, fetchTicket } from './lib/matchmaking-service';
-import { ensureMatch, readStoredRoomMeta, resolveSeatSecret, writeStoredRoomMeta } from './lib/match-service';
+import type { QueueName, QueueSnapshot, QueueTicket } from './lib/matchmaking-service';
+import { cancelTicket, enqueueGuest, fetchQueueSnapshots, fetchQueueTickets, fetchTicket } from './lib/matchmaking-service';
+import { ensureMatch, readStoredRoomMeta, resolveSeatSecret, writeStoredRoomMeta, type StoredRoomMeta } from './lib/match-service';
interface QueuePageProps {
whiteProfile: GuestProfile | null;
blackProfile: GuestProfile | null;
+ preferredQueue?: QueueName | null;
+ preferredModeId?: MatchModeId | null;
+ recoveredWhiteTicket?: QueueTicket | null;
+ recoveredBlackTicket?: QueueTicket | null;
+ recoveryReady?: boolean;
+ embedded?: boolean;
}
type QueueSide = 'white' | 'black';
@@ -14,10 +22,12 @@ type QueueSide = 'white' | 'black';
interface StoredTicketRef {
ticketId: string;
queue: QueueName;
+ modeId: MatchModeId;
}
const DEFAULT_QUEUE: QueueName = 'casual';
const QUEUE_SELECTION_STORAGE_KEY = 'chess404.queue.selection';
+const MODE_SELECTION_STORAGE_KEY = 'chess404.mode.selection';
function queueTicketStorageKey(side: QueueSide): string {
return `chess404.queue.${side}.ticket`;
@@ -31,6 +41,10 @@ function accountIdStorageKey(side: QueueSide): string {
return `chess404.account.${side}.id`;
}
+function accountTokenStorageKey(side: QueueSide): string {
+ return `chess404.account.${side}.token`;
+}
+
function readStoredQueueSelection(): QueueName {
if (typeof window === 'undefined') {
return DEFAULT_QUEUE;
@@ -39,6 +53,17 @@ function readStoredQueueSelection(): QueueName {
return value === 'rated' ? 'rated' : DEFAULT_QUEUE;
}
+function normalizeModeId(value?: string | null): MatchModeId {
+ return value === 'hidden_cards' ? 'hidden_cards' : DEFAULT_MATCH_MODE_ID;
+}
+
+function readStoredModeSelection(): MatchModeId {
+ if (typeof window === 'undefined') {
+ return DEFAULT_MATCH_MODE_ID;
+ }
+ return normalizeModeId(window.localStorage.getItem(MODE_SELECTION_STORAGE_KEY));
+}
+
function readStoredTicketRef(side: QueueSide): StoredTicketRef | null {
if (typeof window === 'undefined') {
return null;
@@ -55,6 +80,7 @@ function readStoredTicketRef(side: QueueSide): StoredTicketRef | null {
return {
ticketId: parsed.ticketId,
queue: parsed.queue === 'rated' ? 'rated' : DEFAULT_QUEUE,
+ modeId: normalizeModeId(parsed.modeId),
};
} catch {
return null;
@@ -72,6 +98,7 @@ function writeStoredTicketRef(side: QueueSide, ticket: QueueTicket | null): void
window.localStorage.setItem(queueTicketStorageKey(side), JSON.stringify({
ticketId: ticket.ticketId,
queue: ticket.queue,
+ modeId: ticket.modeId ?? DEFAULT_MATCH_MODE_ID,
}));
}
@@ -96,6 +123,16 @@ function readStoredAccountId(side: QueueSide): string | null {
return window.localStorage.getItem(accountIdStorageKey(side));
}
+function readStoredAccountSession(side: QueueSide): { accountId: string | null; sessionToken: string | null } {
+ if (typeof window === 'undefined') {
+ return { accountId: null, sessionToken: null };
+ }
+ return {
+ accountId: window.localStorage.getItem(accountIdStorageKey(side)),
+ sessionToken: window.localStorage.getItem(accountTokenStorageKey(side)),
+ };
+}
+
function formatDateTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
@@ -104,25 +141,65 @@ function formatDateTime(value: string): string {
return date.toLocaleString();
}
-export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps): React.ReactElement {
+function modeLabel(modeId?: MatchModeId): string {
+ return OFFICIAL_MATCH_MODES.find(mode => mode.id === normalizeModeId(modeId))?.label ?? 'Open Cards';
+}
+
+export default function QueuePage({
+ whiteProfile,
+ blackProfile,
+ preferredQueue = null,
+ preferredModeId = null,
+ recoveredWhiteTicket = null,
+ recoveredBlackTicket = null,
+ recoveryReady = false,
+ embedded = false,
+}: QueuePageProps): React.ReactElement {
+ const [hostedRuntime, setHostedRuntime] = React.useState(false);
const [queue, setQueue] = React.useState(() => readStoredQueueSelection());
+ const [modeId, setModeId] = React.useState(() => readStoredModeSelection());
const [whiteTicket, setWhiteTicket] = React.useState(null);
const [blackTicket, setBlackTicket] = React.useState(null);
const [queueTickets, setQueueTickets] = React.useState([]);
+ const [queueSnapshot, setQueueSnapshot] = React.useState(null);
const [error, setError] = React.useState('');
const [loading, setLoading] = React.useState(false);
const [restoringTickets, setRestoringTickets] = React.useState(true);
-
- const refreshQueue = React.useCallback(async (queueName: QueueName) => {
- const tickets = await fetchQueueTickets(queueName);
+ const hostedAutoOpenMatchRef = React.useRef(null);
+ const whiteStoredAccount = readStoredAccountSession('white');
+ const blackStoredAccount = readStoredAccountSession('black');
+ const whiteRatedReady = Boolean((whiteStoredAccount.accountId ?? '').trim() && (whiteStoredAccount.sessionToken ?? '').trim());
+ const blackRatedReady = Boolean((blackStoredAccount.accountId ?? '').trim() && (blackStoredAccount.sessionToken ?? '').trim());
+ const hostedRatedReady = whiteRatedReady;
+
+ const refreshQueue = React.useCallback(async (queueName: QueueName, nextModeId: MatchModeId) => {
+ const [tickets, snapshotPayload] = await Promise.all([
+ fetchQueueTickets(queueName, nextModeId),
+ fetchQueueSnapshots(queueName, nextModeId),
+ ]);
setQueueTickets(tickets);
+ setQueueSnapshot(snapshotPayload.snapshots[0] ?? {
+ queue: queueName,
+ modeId: nextModeId,
+ queuedCount: 0,
+ matchedCount: 0,
+ cancelledCount: 0,
+ });
+ }, []);
+
+ React.useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ const hostname = window.location.hostname.toLowerCase();
+ setHostedRuntime(hostname !== 'localhost' && hostname !== '127.0.0.1');
}, []);
React.useEffect(() => {
- void refreshQueue(queue).catch(err => {
+ void refreshQueue(queue, modeId).catch(err => {
setError(err instanceof Error ? err.message : 'Failed to load queue state.');
});
- }, [queue, refreshQueue]);
+ }, [queue, modeId, refreshQueue]);
React.useEffect(() => {
if (typeof window === 'undefined') {
@@ -131,12 +208,25 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps
window.localStorage.setItem(QUEUE_SELECTION_STORAGE_KEY, queue);
}, [queue]);
+ React.useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ window.localStorage.setItem(MODE_SELECTION_STORAGE_KEY, modeId);
+ }, [modeId]);
+
React.useEffect(() => {
let cancelled = false;
const restoreTickets = async () => {
const whiteRef = readStoredTicketRef('white');
const blackRef = readStoredTicketRef('black');
+ const activeRef = whiteRef ?? blackRef;
+
+ if (activeRef) {
+ setQueue(activeRef.queue);
+ setModeId(activeRef.modeId);
+ }
if (!whiteRef && !blackRef) {
setRestoringTickets(false);
@@ -196,6 +286,119 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps
writeStoredTicketRef('black', blackTicket);
}, [blackTicket]);
+ React.useEffect(() => {
+ if (!recoveryReady) {
+ return;
+ }
+
+ const nextWhite = recoveredWhiteTicket && recoveredWhiteTicket.status === 'queued' ? recoveredWhiteTicket : null;
+ const nextBlack = recoveredBlackTicket && recoveredBlackTicket.status === 'queued' ? recoveredBlackTicket : null;
+
+ setWhiteTicket(current => {
+ if (nextWhite) {
+ if (current?.ticketId === nextWhite.ticketId && current.status === nextWhite.status) {
+ return current;
+ }
+ return nextWhite;
+ }
+ if (current?.status === 'queued') {
+ clearStoredTicketRef('white');
+ return null;
+ }
+ return current;
+ });
+
+ setBlackTicket(current => {
+ if (nextBlack) {
+ if (current?.ticketId === nextBlack.ticketId && current.status === nextBlack.status) {
+ return current;
+ }
+ return nextBlack;
+ }
+ if (current?.status === 'queued') {
+ clearStoredTicketRef('black');
+ return null;
+ }
+ return current;
+ });
+
+ const activeTicket = nextWhite ?? nextBlack;
+ if (activeTicket) {
+ setQueue(activeTicket.queue);
+ setModeId(normalizeModeId(activeTicket.modeId));
+ }
+ }, [recoveryReady, recoveredWhiteTicket, recoveredBlackTicket]);
+
+ React.useEffect(() => {
+ if (!hostedRuntime) {
+ return;
+ }
+ clearStoredTicketRef('black');
+ setBlackTicket(null);
+ }, [hostedRuntime]);
+
+ React.useEffect(() => {
+ if (!preferredQueue || whiteTicket || blackTicket) {
+ return;
+ }
+ setQueue(preferredQueue);
+ }, [preferredQueue, whiteTicket, blackTicket]);
+
+ React.useEffect(() => {
+ if (!preferredModeId || whiteTicket || blackTicket) {
+ return;
+ }
+ setModeId(normalizeModeId(preferredModeId));
+ }, [preferredModeId, whiteTicket, blackTicket]);
+
+ const buildHostedAssignedRoomMeta = React.useCallback((ticket: QueueTicket, profile: GuestProfile | null): StoredRoomMeta | null => {
+ if (!ticket.assignedRoom || !ticket.seatColor || !profile) {
+ return null;
+ }
+ const existingRoomMeta = readStoredRoomMeta(ticket.assignedRoom);
+ const currentAccountId = readStoredAccountId('white') ?? undefined;
+ const viewerSeat = ticket.seatColor;
+ const opponentGuestId = ticket.matchedWith || undefined;
+ const opponentName = ticket.opponentName || existingRoomMeta?.[viewerSeat === 'white' ? 'blackName' : 'whiteName'];
+
+ return {
+ ...existingRoomMeta,
+ queue: ticket.queue,
+ modeId: ticket.modeId ?? existingRoomMeta?.modeId ?? DEFAULT_MATCH_MODE_ID,
+ viewerSeat,
+ whiteGuestId: viewerSeat === 'white' ? profile.guestId : opponentGuestId ?? existingRoomMeta?.whiteGuestId,
+ blackGuestId: viewerSeat === 'black' ? profile.guestId : opponentGuestId ?? existingRoomMeta?.blackGuestId,
+ whiteAccountId: viewerSeat === 'white' ? currentAccountId : existingRoomMeta?.whiteAccountId,
+ blackAccountId: viewerSeat === 'black' ? currentAccountId : existingRoomMeta?.blackAccountId,
+ whiteName: viewerSeat === 'white' ? profile.displayName : opponentName ?? existingRoomMeta?.whiteName,
+ blackName: viewerSeat === 'black' ? profile.displayName : opponentName ?? existingRoomMeta?.blackName,
+ };
+ }, []);
+
+ React.useEffect(() => {
+ if (!hostedRuntime || restoringTickets) {
+ return;
+ }
+ const ticket = whiteTicket;
+ if (!ticket || ticket.status !== 'matched' || !ticket.assignedRoom || !ticket.seatColor || !whiteProfile) {
+ if (!ticket || ticket.status !== 'matched') {
+ hostedAutoOpenMatchRef.current = null;
+ }
+ return;
+ }
+ if (hostedAutoOpenMatchRef.current === ticket.assignedRoom) {
+ return;
+ }
+ const roomMeta = buildHostedAssignedRoomMeta(ticket, whiteProfile);
+ if (!roomMeta) {
+ return;
+ }
+ hostedAutoOpenMatchRef.current = ticket.assignedRoom;
+ writeStoredRoomMeta(ticket.assignedRoom, roomMeta);
+ clearStoredTicketRef('white');
+ window.location.href = `/match/${encodeURIComponent(ticket.assignedRoom)}`;
+ }, [hostedRuntime, restoringTickets, whiteTicket, whiteProfile, buildHostedAssignedRoomMeta]);
+
React.useEffect(() => {
if (restoringTickets) {
return;
@@ -220,14 +423,14 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps
})
);
}
- tasks.push(refreshQueue(queue));
+ tasks.push(refreshQueue(queue, modeId));
void Promise.all(tasks).catch(() => {
// Keep last visible state if polling fails.
});
}, 2500);
return () => window.clearInterval(interval);
- }, [whiteTicket, blackTicket, queue, refreshQueue, restoringTickets]);
+ }, [whiteTicket, blackTicket, queue, modeId, refreshQueue, restoringTickets]);
const handleJoin = React.useCallback(async (side: 'white' | 'black') => {
const profile = side === 'white' ? whiteProfile : blackProfile;
@@ -235,22 +438,32 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps
setError('Guest profile is still loading. Try again in a moment.');
return;
}
+ const accountIdentity = readStoredAccountSession(side);
+ if (queue === 'rated' && (!(accountIdentity.accountId ?? '').trim() || !(accountIdentity.sessionToken ?? '').trim())) {
+ setError(hostedRuntime
+ ? 'Rated queue requires a signed-in Chess404 account. Sign in first, then join rated.'
+ : `${side === 'white' ? 'White' : 'Black'} needs a signed-in Chess404 account before joining rated queue.`);
+ return;
+ }
setLoading(true);
setError('');
try {
- const result = await enqueueGuest(profile.guestId, queue, profile.rating);
+ const result = await enqueueGuest(profile.guestId, queue, modeId, profile.rating, profile.displayName, {
+ accountId: accountIdentity.accountId ?? undefined,
+ accountSessionToken: accountIdentity.sessionToken ?? undefined,
+ });
if (side === 'white') {
setWhiteTicket(result.ticket);
} else {
setBlackTicket(result.ticket);
}
- await refreshQueue(queue);
+ await refreshQueue(queue, modeId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join queue.');
} finally {
setLoading(false);
}
- }, [whiteProfile, blackProfile, queue, refreshQueue]);
+ }, [whiteProfile, blackProfile, queue, modeId, refreshQueue, hostedRuntime]);
const handleCancel = React.useCallback(async (side: 'white' | 'black') => {
const ticket = side === 'white' ? whiteTicket : blackTicket;
@@ -266,18 +479,28 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps
} else {
setBlackTicket(result.ticket);
}
- await refreshQueue(queue);
+ await refreshQueue(queue, modeId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to cancel queue ticket.');
} finally {
setLoading(false);
}
- }, [whiteTicket, blackTicket, queue, refreshQueue]);
+ }, [whiteTicket, blackTicket, queue, modeId, refreshQueue]);
+
+ const openAccount = React.useCallback(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ window.location.href = '/account';
+ }, []);
const handleOpenMatch = React.useCallback(async (ticket: QueueTicket) => {
if (!ticket.assignedRoom) {
return;
}
+ if (hostedRuntime) {
+ return;
+ }
setLoading(true);
setError('');
try {
@@ -285,20 +508,22 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps
const roomMeta = {
...existingRoomMeta,
queue,
+ modeId: ticket.modeId ?? existingRoomMeta?.modeId ?? modeId,
whiteGuestId: existingRoomMeta?.whiteGuestId ?? whiteProfile?.guestId,
- blackGuestId: existingRoomMeta?.blackGuestId ?? blackProfile?.guestId,
+ blackGuestId: existingRoomMeta?.blackGuestId ?? (hostedRuntime ? undefined : blackProfile?.guestId),
whiteAccountId: existingRoomMeta?.whiteAccountId ?? readStoredAccountId('white') ?? undefined,
- blackAccountId: existingRoomMeta?.blackAccountId ?? readStoredAccountId('black') ?? undefined,
+ blackAccountId: existingRoomMeta?.blackAccountId ?? (hostedRuntime ? undefined : readStoredAccountId('black') ?? undefined),
whiteName: existingRoomMeta?.whiteName ?? whiteProfile?.displayName,
- blackName: existingRoomMeta?.blackName ?? blackProfile?.displayName,
+ blackName: existingRoomMeta?.blackName ?? (hostedRuntime ? undefined : blackProfile?.displayName),
whitePlayerSecret: resolveSeatSecret(existingRoomMeta?.whitePlayerSecret, readStoredGuestSessionSecret('white')),
- blackPlayerSecret: resolveSeatSecret(existingRoomMeta?.blackPlayerSecret, readStoredGuestSessionSecret('black')),
+ blackPlayerSecret: resolveSeatSecret(existingRoomMeta?.blackPlayerSecret, hostedRuntime ? null : readStoredGuestSessionSecret('black')),
};
writeStoredRoomMeta(ticket.assignedRoom, roomMeta);
await ensureMatch({
matchId: ticket.assignedRoom,
clockSeconds: 600,
queue: roomMeta.queue,
+ modeId: roomMeta.modeId,
whiteGuestId: roomMeta.whiteGuestId,
blackGuestId: roomMeta.blackGuestId,
whiteAccountId: roomMeta.whiteAccountId,
@@ -309,37 +534,51 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps
blackPlayerSecret: roomMeta.blackPlayerSecret,
});
clearStoredTicketRef('white');
- clearStoredTicketRef('black');
- window.location.href = `/?match=${encodeURIComponent(ticket.assignedRoom)}`;
+ if (!hostedRuntime) {
+ clearStoredTicketRef('black');
+ }
+ window.location.href = `/match/${encodeURIComponent(ticket.assignedRoom)}`;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to open matched room.');
} finally {
setLoading(false);
}
- }, [queue, whiteProfile, blackProfile]);
+ }, [queue, modeId, whiteProfile, blackProfile, hostedRuntime]);
const queueCard = (
side: 'white' | 'black',
profile: GuestProfile | null,
- ticket: QueueTicket | null
- ): React.ReactElement => (
-
-
+ ticket: QueueTicket | null,
+ title?: string,
+ ): React.ReactElement => {
+ const ratedReady = side === 'white' ? whiteRatedReady : blackRatedReady;
+ const ratedBlocked = queue === 'rated' && !ratedReady;
+ const playerTitle = title ?? (hostedRuntime ? 'Your player' : profile?.displayName ?? 'Loading...');
+ const cardBackground = hostedRuntime
+ ? 'linear-gradient(180deg, rgba(16,28,44,0.78) 0%, rgba(8,16,28,0.9) 100%)'
+ : side === 'white'
+ ? 'linear-gradient(180deg, rgba(10,40,18,0.78) 0%, rgba(8,20,12,0.88) 100%)'
+ : 'linear-gradient(180deg, rgba(34,12,58,0.78) 0%, rgba(16,10,32,0.88) 100%)';
+ const cardBorder = hostedRuntime
+ ? '1px solid rgba(120,190,255,0.22)'
+ : side === 'white'
+ ? '1px solid rgba(60,220,110,0.24)'
+ : '1px solid rgba(180,110,255,0.24)';
+
+ return (
+
+
-
{profile?.displayName ?? 'Loading...'}
-
♟ {profile?.rating ?? 1200}
+
{playerTitle}
+
Rating {profile?.rating ?? 1200}
-
+
{ticket ? (
<>
-
Queue: {ticket.queue}
-
Ticket: {ticket.ticketId}
+
Lane: {ticket.queue === 'rated' ? 'Rated Quick Pair' : 'Casual Quick Pair'}
+
Mode: {modeLabel(ticket.modeId)}
Updated: {formatDateTime(ticket.updatedAt)}
- {ticket.matchedWith &&
Matched with: {ticket.matchedWith}
}
- {ticket.assignedRoom &&
Assigned room: {ticket.assignedRoom}
}
+ {ticket.status === 'queued' ?
Searching for another player in this official mode now.
: null}
+ {ticket.seatColor &&
Seat: {ticket.seatColor === 'white' ? 'White pieces' : 'Black pieces'}
}
+ {ticket.opponentName &&
Opponent: {ticket.opponentName}
}
+ {ticket.assignedRoom &&
Your live match is ready.
}
>
+ ) : ratedBlocked ? (
+
Rated quick pair is locked until this player is signed in with a Chess404 account.
) : (
-
Not in queue yet. Join the selected queue to create a local matchmaking ticket.
+
{hostedRuntime ? 'Not in queue yet. Choose a lane and join when you are ready to be paired.' : 'Not in queue yet. Choose a lane and create a local matchmaking ticket when ready.'}
)}
-
+
-
+
{!ticket || ticket.status === 'cancelled' ? (
void handleJoin(side)}
- disabled={loading || restoringTickets || !profile}
+ disabled={loading || restoringTickets || !profile || ratedBlocked}
style={{
flex: 1,
padding: '10px 12px',
@@ -382,10 +625,13 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps
background: 'linear-gradient(180deg, rgba(200,134,10,0.32) 0%, rgba(122,79,8,0.42) 100%)',
color: '#fff2c8',
fontWeight: 800,
- cursor: loading || restoringTickets || !profile ? 'not-allowed' : 'pointer',
+ cursor: loading || restoringTickets || !profile || ratedBlocked ? 'not-allowed' : 'pointer',
+ opacity: ratedBlocked ? 0.72 : 1,
}}
>
- Join {queue}
+ {!profile
+ ? 'Preparing player...'
+ : `Join ${queue === 'rated' ? 'Rated' : 'Casual'} - ${modeLabel(modeId)}`}
) : ticket.status === 'queued' ? (
Cancel Ticket
+ ) : hostedRuntime ? (
+
+ Matched - opening game...
+
) : (
void handleOpenMatch(ticket)}
@@ -422,15 +683,33 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps
Open match
)}
+
-
- );
+ );
+ };
+
+ const visiblePlayerCount = hostedRuntime
+ ? (queueSnapshot?.queuedCount ?? 0) + (queueSnapshot?.matchedCount ?? 0)
+ : queueTickets.length;
+ const waitingCount = hostedRuntime
+ ? (queueSnapshot?.queuedCount ?? 0)
+ : queueTickets.filter((entry) => entry.status === 'queued').length;
+ const matchesFormingCount = hostedRuntime
+ ? (queueSnapshot?.matchedCount ?? 0)
+ : queueTickets.filter((entry) => entry.status === 'matched').length;
+ const showEmptyActivity = hostedRuntime ? visiblePlayerCount === 0 : queueTickets.length === 0;
return (
-
+
Queue Control
- Local platform queue tickets with simple auto-match when a second player joins.
+ {hostedRuntime
+ ? 'Quick pair for one hosted player. Casual stays open to guests, and rated unlocks after account sign-in.'
+ : 'Local platform queue tickets with simple auto-match when a second player joins.'}
{restoringTickets && (
- Resuming saved queue tickets from this browser...
+ Resuming queue state from backend recovery...
)}
@@ -472,15 +753,78 @@ export default function QueuePage({ whiteProfile, blackProfile }: QueuePageProps
opacity: restoringTickets ? 0.7 : 1,
}}
>
- {name}
+ {hostedRuntime && name === 'rated' && !hostedRatedReady ? 'rated (sign in)' : name}
+
+ ))}
+
+
+ {OFFICIAL_MATCH_MODES.map(mode => (
+ setModeId(mode.id)}
+ disabled={restoringTickets}
+ style={{
+ flex: 1,
+ padding: '9px 12px',
+ borderRadius: '9px',
+ border: modeId === mode.id ? '1px solid rgba(120,190,255,0.34)' : '1px solid rgba(255,255,255,0.08)',
+ background: modeId === mode.id
+ ? 'linear-gradient(180deg, rgba(54,102,184,0.24) 0%, rgba(24,40,82,0.34) 100%)'
+ : 'rgba(255,255,255,0.03)',
+ color: modeId === mode.id ? '#e5f0ff' : 'rgba(210,225,255,0.75)',
+ fontSize: '11px',
+ fontWeight: 800,
+ cursor: restoringTickets ? 'not-allowed' : 'pointer',
+ opacity: restoringTickets ? 0.7 : 1,
+ }}
+ title={mode.rulesSummary}
+ >
+ {mode.shortLabel}
))}
+
+ {OFFICIAL_MATCH_MODES.find(mode => mode.id === modeId)?.rulesSummary}
+
+ {queue === 'rated' && (
+
+ {hostedRuntime
+ ? 'Rated quick pair is account-only for launch. Sign in once, then come back here to queue rated with the same hosted player.'
+ : 'Rated queue is account-only for launch-quality matchmaking. Sign in each player seat before joining rated. Casual queue still allows guest play.'}
+ {hostedRuntime && !hostedRatedReady ? (
+
+ Sign in to join rated
+
+ ) : null}
+
+ )}
- {queueCard('white', whiteProfile, whiteTicket)}
- {queueCard('black', blackProfile, blackTicket)}
+ {queueCard('white', whiteProfile, whiteTicket, hostedRuntime ? 'Your player' : undefined)}
+ {!hostedRuntime && queueCard('black', blackProfile, blackTicket)}
{error && (
-
Live Queue Snapshot
+
Queue Activity
- Current tickets in the selected queue. This is the first real matchmaking surface before region routing and rating bands.
+ See how busy the selected lane is without exposing raw internal ticket details.
- {queueTickets.length === 0 ? (
-
No tickets in {queue} yet.
+ {showEmptyActivity ? (
+
No tickets in {queue} - {modeLabel(modeId)} yet.
) : (
-
- {queueTickets.map(ticket => (
-
-
-
{ticket.ticketId}
-
- {ticket.status}
+
+
+
+
Players visible
+
{visiblePlayerCount}
+
+
+
Waiting now
+
{waitingCount}
+
+
+
Matches forming
+
{matchesFormingCount}
+
+
+ {hostedRuntime ? (
+
+ Hosted play keeps this activity view high-level on purpose. You can see whether the lane is moving without exposing raw player ticket diagnostics.
+
+ ) : (
+
+ {queueTickets.map(ticket => (
+
+
+
+ {ticket.displayName?.trim() || (ticket.status === 'matched' ? 'Matched player' : 'Queued player')}
+
+
+ {ticket.status === 'matched' ? 'match ready' : ticket.status}
+
+
+
+
Lane: {ticket.queue === 'rated' ? 'Rated Quick Pair' : 'Casual Quick Pair'}
+
Mode: {modeLabel(ticket.modeId)}
+
Rating: {ticket.rating}
+
{ticket.status === 'matched' ? 'Paired' : 'Queued'}: {formatDateTime(ticket.updatedAt)}
+ {ticket.opponentName &&
Opponent: {ticket.opponentName}
}
+ {ticket.assignedRoom &&
Board opening now.
}
-
-
Guest: {ticket.guestId}
-
Rating: {ticket.rating}
-
Created: {formatDateTime(ticket.createdAt)}
- {ticket.assignedRoom &&
Room: {ticket.assignedRoom}
}
- {ticket.matchedWith &&
Vs: {ticket.matchedWith}
}
-
+ ))}
- ))}
+ )}
)}
diff --git a/apps/web/src/RankingsPage.tsx b/apps/web/src/RankingsPage.tsx
index 1a23c29..3f37666 100644
--- a/apps/web/src/RankingsPage.tsx
+++ b/apps/web/src/RankingsPage.tsx
@@ -1,17 +1,13 @@
import React from 'react';
-import type { AccountProfile, AccountSeasonSummary, SeasonOption } from './lib/platform-service';
+import { OFFICIAL_MATCH_MODES } from '@chess404/contracts';
+import type { MatchModeId } from '@chess404/contracts';
+import type { AccountLeaderboardSummary, AccountLeaderboardSpotlight, AccountProfile, AccountSeasonSummary, SeasonOption } from './lib/platform-service';
+import { formatLastSeenLabel } from './lib/display';
import { fetchAccountLeaderboard } from './lib/platform-service';
interface RankingsPageProps {
onViewGuest?: (guestId: string) => void;
-}
-
-function formatDateTime(value: string): string {
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) {
- return value;
- }
- return date.toLocaleString();
+ onViewAccount?: (handle: string) => void;
}
function formatRatingDelta(delta: number): string {
@@ -29,20 +25,53 @@ function describeSeason(summary?: AccountSeasonSummary): string {
return `${summary.label}: ${summary.matchesPlayed} matches, ${formatRatingDelta(summary.netDelta)}`;
}
-export default function RankingsPage({ onViewGuest }: RankingsPageProps): React.ReactElement {
+function parseModeFilterValue(value: string): MatchModeId | '' {
+ return OFFICIAL_MATCH_MODES.some((mode) => mode.id === value as MatchModeId) ? (value as MatchModeId) : '';
+}
+
+function formatWinRate(spotlight?: AccountLeaderboardSpotlight): string {
+ if (!spotlight || spotlight.matchesPlayed <= 0) {
+ return '--';
+ }
+ const winRate = Math.round((spotlight.wins / spotlight.matchesPlayed) * 100);
+ return `${winRate}%`;
+}
+
+function renderSpotlightLabel(summary: AccountLeaderboardSummary | undefined, selectedModeId: MatchModeId | ''): string {
+ if (summary?.seasonLabel?.trim()) {
+ return summary.seasonLabel;
+ }
+ if (selectedModeId) {
+ return `${OFFICIAL_MATCH_MODES.find((mode) => mode.id === selectedModeId)?.label ?? 'Mode'} ladder`;
+ }
+ return 'Current ladder';
+}
+
+function describeSparseLane(selectedModeId: MatchModeId | '', selectedSeasonId: string): string {
+ if (selectedModeId || selectedSeasonId) {
+ return 'No claimed account has posted rated results for this exact lane yet.';
+ }
+ return 'No claimed accounts are on the board yet. Open Account, claim a handle, and play your first rated match to seed the ladder.';
+}
+
+export default function RankingsPage({ onViewGuest, onViewAccount }: RankingsPageProps): React.ReactElement {
const [accounts, setAccounts] = React.useState
([]);
const [seasons, setSeasons] = React.useState([]);
+ const [summary, setSummary] = React.useState(undefined);
const [selectedSeasonId, setSelectedSeasonId] = React.useState('');
+ const [selectedModeId, setSelectedModeId] = React.useState('');
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState('');
- const loadRankings = React.useCallback(async (seasonId?: string) => {
+ const loadRankings = React.useCallback(async (seasonId?: string, modeId?: MatchModeId) => {
setLoading(true);
setError('');
+ setSummary(undefined);
try {
- const payload = await fetchAccountLeaderboard(50, 'rating', seasonId);
+ const payload = await fetchAccountLeaderboard(50, 'rating', seasonId, modeId);
setAccounts(payload.accounts);
setSeasons(payload.seasons);
+ setSummary(payload.summary);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load rankings.');
} finally {
@@ -51,8 +80,8 @@ export default function RankingsPage({ onViewGuest }: RankingsPageProps): React.
}, []);
React.useEffect(() => {
- void loadRankings(selectedSeasonId || undefined);
- }, [loadRankings, selectedSeasonId]);
+ void loadRankings(selectedSeasonId || undefined, selectedModeId || undefined);
+ }, [loadRankings, selectedModeId, selectedSeasonId]);
return (
@@ -75,10 +104,30 @@ export default function RankingsPage({ onViewGuest }: RankingsPageProps): React.
Account Rankings
- Claimed account leaderboard with current ladder totals and season-aware momentum filters.
+ Track the strongest claimed accounts by official mode, season, and rated form.
+ setSelectedModeId(parseModeFilterValue(event.target.value))}
+ style={{
+ padding: '8px 10px',
+ borderRadius: '8px',
+ border: '1px solid rgba(255,180,60,0.24)',
+ background: 'rgba(255,255,255,0.04)',
+ color: '#fff2c8',
+ fontSize: '12px',
+ fontWeight: 700,
+ }}
+ >
+ All official modes
+ {OFFICIAL_MATCH_MODES.map((mode) => (
+
+ {mode.label}
+
+ ))}
+
setSelectedSeasonId(event.target.value)}
@@ -100,7 +149,7 @@ export default function RankingsPage({ onViewGuest }: RankingsPageProps): React.
))}
void loadRankings(selectedSeasonId || undefined)}
+ onClick={() => void loadRankings(selectedSeasonId || undefined, selectedModeId || undefined)}
style={{
padding: '8px 12px',
borderRadius: '8px',
@@ -119,6 +168,65 @@ export default function RankingsPage({ onViewGuest }: RankingsPageProps): React.
+ {!loading && summary && accounts.length > 1 && (
+
+
+
+
{renderSpotlightLabel(summary, selectedModeId)}
+
{summary.playerCount}
+
+ players in this lane · {summary.matchCount} rated results tracked
+
+
+ {[
+ { label: 'Leader', spotlight: summary.leader, value: summary.leader ? `${summary.leader.rating}` : '--', detail: summary.leader ? `${summary.leader.matchesPlayed} matches · ${formatWinRate(summary.leader)} win rate` : 'No leader yet' },
+ { label: 'Biggest climb', spotlight: summary.biggestClimber, value: summary.biggestClimber ? formatRatingDelta(summary.biggestClimber.netDelta) : '--', detail: summary.biggestClimber ? `${summary.biggestClimber.matchesPlayed} matches · rating ${summary.biggestClimber.rating}` : 'No climb data yet' },
+ { label: 'Peak holder', spotlight: summary.highestPeak, value: summary.highestPeak ? `${summary.highestPeak.peakRating}` : '--', detail: summary.highestPeak ? `${summary.highestPeak.matchesPlayed} matches · ${summary.highestPeak.displayName}` : 'No peak yet' },
+ { label: 'Most active', spotlight: summary.mostActive, value: summary.mostActive ? `${summary.mostActive.matchesPlayed}` : '--', detail: summary.mostActive ? `${summary.mostActive.displayName} · ${formatWinRate(summary.mostActive)} win rate` : 'No volume yet' },
+ ].map((card) => (
+
+
{card.label}
+
{card.value}
+
+ {card.spotlight ? `@${card.spotlight.handle}` : 'Waiting for results'}
+
+
+ {card.detail}
+
+
+ ))}
+
+
+ )}
+
+ {!loading && summary && accounts.length === 1 && (
+
+
+ First account on this lane
+
+
+ @{accounts[0]?.handle} sets the first benchmark
+
+
+ {renderSpotlightLabel(summary, selectedModeId)} has only one claimed competitor right now. More rated results will unlock richer ladder comparisons and momentum cards.
+
+
+ )}
+
{error && (
Loading rankings...
) : accounts.length === 0 ? (
-
- {selectedSeasonId
- ? 'No accounts have season activity for that filter yet.'
- : 'No claimed accounts yet. Open the Account tab to claim a handle and start building the account ladder.'}
+
+ {describeSparseLane(selectedModeId, selectedSeasonId)}
) : (
@@ -185,11 +301,17 @@ export default function RankingsPage({ onViewGuest }: RankingsPageProps): React.
{season
? `${season.label} peak ${season.peakRating}`
- : `Last seen ${formatDateTime(account.lastSeenAt)}`}
+ : formatLastSeenLabel(account.lastSeenAt)}
onViewGuest?.(account.primaryGuestId)}
+ onClick={() => {
+ if (onViewAccount) {
+ onViewAccount(account.handle);
+ return;
+ }
+ onViewGuest?.(account.primaryGuestId);
+ }}
style={{
padding: '7px 10px',
borderRadius: '8px',
diff --git a/apps/web/src/StatusPage.tsx b/apps/web/src/StatusPage.tsx
index 2c0b238..8b04dd4 100644
--- a/apps/web/src/StatusPage.tsx
+++ b/apps/web/src/StatusPage.tsx
@@ -11,8 +11,8 @@ export default function StatusPage() {
setError(null);
try {
setSnapshot(await fetchSystemStatus());
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to load service status');
+ } catch {
+ setError('Status checks are temporarily unavailable. This internal operator surface no longer exposes raw upstream errors in the public shell.');
} finally {
setLoading(false);
}
@@ -32,10 +32,10 @@ export default function StatusPage() {
}}>
-
System Status
-
Operations snapshot
+
Internal Status
+
Operator health surface
- Live service health for match authority, platform data, and matchmaking.
+ Internal checks for match authority, platform state, and matchmaking readiness. This page is no longer part of the public-facing navigation story.
+
+ Normal players should not need this page. It exists for internal verification, deploy checks, and operator triage.
+
+
- Gateway control plane: {snapshot.gateway.status === 'ok' ? 'ready' : 'degraded'}
+ Service readiness: {snapshot.gateway.status === 'ok' ? 'stable' : 'attention needed'}
- The gateway now aggregates backend readiness across realtime, platform, and matchmaking services.
+ Gateway aggregation rolls up the internal health of realtime, platform, and matchmaking services.
@@ -112,10 +125,10 @@ export default function StatusPage() {
}}>
{label}
- {service.healthy ? 'Healthy' : 'Unreachable'}
+ {service.healthy ? 'Healthy' : 'Needs attention'}
- {service.error ? service.error : `HTTP ${service.statusCode ?? 0}`}
+ {service.healthy ? 'Responding to gateway checks' : 'Operator check required'}
))}
diff --git a/apps/web/src/WatchPage.tsx b/apps/web/src/WatchPage.tsx
new file mode 100644
index 0000000..ffa6705
--- /dev/null
+++ b/apps/web/src/WatchPage.tsx
@@ -0,0 +1,342 @@
+import React from 'react';
+import { OFFICIAL_MATCH_MODES } from '@chess404/contracts';
+import type { MatchModeId } from '@chess404/contracts';
+import BoardPreview from './components/match/BoardPreview';
+import { fetchArchivedMatches, type MatchArchiveEntry, type PublicMatchStatusFilter } from './lib/platform-service';
+import { formatDateTime, formatMatchPlayers, formatMatchResult, formatModeLabel } from './lib/display';
+import { buildLiveMatchUrl, buildReplayPageUrl, copyTextToClipboard } from './lib/session-storage';
+import { modeLabel, queueLabel } from './lib/match-labels';
+
+interface WatchPageProps {
+ onWatchMatch?: (matchId: string) => void;
+ onOpenReplay?: (matchId: string) => void;
+}
+
+
+function resultLabel(entry: MatchArchiveEntry): string {
+ return formatMatchResult({
+ status: entry.status,
+ winner: entry.winner,
+ finishReason: entry.finishReason,
+ });
+}
+export default function WatchPage({ onWatchMatch, onOpenReplay }: WatchPageProps): React.ReactElement {
+ const [status, setStatus] = React.useState
('active');
+ const [modeId, setModeId] = React.useState('');
+ const [matches, setMatches] = React.useState([]);
+ const [loading, setLoading] = React.useState(true);
+ const [error, setError] = React.useState('');
+ const [notice, setNotice] = React.useState('');
+
+ const refresh = React.useCallback(async () => {
+ setLoading(true);
+ setError('');
+ try {
+ setMatches(await fetchArchivedMatches(24, modeId || undefined, status));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load public match feed.');
+ } finally {
+ setLoading(false);
+ }
+ }, [modeId, status]);
+
+ const copyLiveLink = React.useCallback(async (matchId: string) => {
+ const matchUrl = buildLiveMatchUrl(matchId);
+ if (!matchUrl) {
+ return;
+ }
+ try {
+ const copied = await copyTextToClipboard(matchUrl);
+ setNotice(copied ? 'Live match link copied.' : matchUrl);
+ } catch {
+ setNotice(matchUrl);
+ }
+ }, []);
+
+ const copyReplayLink = React.useCallback(async (matchId: string) => {
+ const replayUrl = buildReplayPageUrl(matchId);
+ if (!replayUrl) {
+ return;
+ }
+ try {
+ const copied = await copyTextToClipboard(replayUrl);
+ setNotice(copied ? 'Replay link copied.' : replayUrl);
+ } catch {
+ setNotice(replayUrl);
+ }
+ }, []);
+
+ React.useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ React.useEffect(() => {
+ setNotice('');
+ }, [modeId, status]);
+
+ return (
+
+
+
+
+
+
Watch Chess404
+
+ Live spectating and replay discovery start here. Track active public games by official mode, jump into recent finished matches, and copy stable live or replay links that resolve into canonical match destinations.
+
+
+
void refresh()}
+ style={{
+ padding: '8px 12px',
+ borderRadius: '8px',
+ border: '1px solid rgba(255,180,60,0.35)',
+ background: 'linear-gradient(180deg, rgba(200,134,10,0.32) 0%, rgba(122,79,8,0.4) 100%)',
+ color: '#fff2c8',
+ fontSize: '12px',
+ fontWeight: 700,
+ cursor: 'pointer',
+ }}
+ >
+ Refresh Feed
+
+
+
+
+ {(['active', 'finished'] as PublicMatchStatusFilter[]).map((value) => (
+ setStatus(value)}
+ style={{
+ padding: '9px 12px',
+ borderRadius: '9px',
+ border: status === value ? '1px solid rgba(255,180,60,0.3)' : '1px solid rgba(255,255,255,0.08)',
+ background: status === value
+ ? 'linear-gradient(180deg, rgba(200,134,10,0.22) 0%, rgba(70,42,8,0.34) 100%)'
+ : 'rgba(255,255,255,0.03)',
+ color: status === value ? '#fff1c7' : 'rgba(255,232,180,0.75)',
+ textTransform: 'uppercase',
+ fontSize: '11px',
+ fontWeight: 800,
+ cursor: 'pointer',
+ }}
+ >
+ {value === 'active' ? 'Live Games' : 'Recent Replays'}
+
+ ))}
+ setModeId(OFFICIAL_MATCH_MODES.some((mode) => mode.id === event.target.value) ? (event.target.value as MatchModeId) : '')}
+ style={{
+ minWidth: '180px',
+ padding: '9px 12px',
+ borderRadius: '9px',
+ border: '1px solid rgba(255,255,255,0.10)',
+ background: 'rgba(255,255,255,0.04)',
+ color: '#fff2c8',
+ fontSize: '12px',
+ fontWeight: 700,
+ }}
+ >
+ All official modes
+ {OFFICIAL_MATCH_MODES.map((mode) => (
+
+ {mode.label}
+
+ ))}
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {notice && (
+
+ {notice}
+
+ )}
+
+ {loading ? (
+
Loading public match feed...
+ ) : matches.length === 0 ? (
+
+ {status === 'active'
+ ? `No live public games are visible for ${modeId ? formatModeLabel(modeId) : 'the selected filters'} yet.`
+ : `No finished public matches are available for ${modeId ? formatModeLabel(modeId) : 'the selected filters'} yet.`}
+
+ ) : (
+
+ {matches.map((entry) => (
+