Files
mixmaker/frontend/src/App.tsx
lemintare ffa50f20a7
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled
Fix preferred player name resolution
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 17:15:42 +03:00

986 lines
100 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type FormEvent, type ReactNode } from 'react'
import {
Bell, CalendarDays, Check, ChevronLeft, ChevronRight, CircleHelp, Clock3, ExternalLink, Gamepad2, ListFilter, LogOut,
Menu, Radio, RefreshCw, Search, Shield, Sparkles, Swords, Trophy, UserRound,
UsersRound, X, Zap,
} from 'lucide-react'
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Link, Outlet, RouterProvider, createRootRouteWithContext, createRoute,
createRouter, redirect, useRouterState,
} from '@tanstack/react-router'
import {
api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent,
type Player, type PlayerRole, type PublicPlayerProfile, type RawAccount, type RawRosterDraft, type Registration, type RsvpStatus, type Series, type Session, type Team,
} from './api/client'
import {
demoBalanceCandidates, demoBracket, demoCommunitySettings, demoEvents, demoPlayerProfiles, demoProfile,
demoMode, demoRegistrations, demoSeries, demoSession,
} from './api/demo'
import { LanguageSelector } from './i18n'
import { useLanguage } from './i18n-context'
import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks'
import { activeHeroBanMap } from './series-utils'
import { playTurnSound, primeTurnSound, shouldNotifyTurn, type TurnSnapshot } from './turn-notification'
import { BracketEditor } from './components/bracket-editor/BracketEditor'
type RouterContext = { session: Session | null }
const isStaff = (role?: Session['account']['role']) => role === 'admin' || role === 'moderator'
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false }, mutations: { retry: 0 } } })
const rootRoute = createRootRouteWithContext<RouterContext>()({ component: Outlet })
const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: '/login', component: LoginPage })
const protectedRoute = createRoute({
getParentRoute: () => rootRoute, id: '_protected', component: AppShell,
beforeLoad: ({ context }) => { if (!context.session) throw redirect({ to: '/login' }) },
})
const indexRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/', beforeLoad: () => { throw redirect({ to: '/events' }) } })
const profileRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/profile', component: ProfilePage })
const playerRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/players/$playerId', component: PublicPlayerProfilePage })
const faqRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/faq', component: FAQPage })
const eventsRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events', component: EventsPage })
const eventRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events/$eventId', component: EventPage })
const liveEntryRoute = createRoute({
getParentRoute: () => protectedRoute,
path: '/events/$eventId/live',
validateSearch: (search: Record<string, unknown>) => ({ seriesId: typeof search.seriesId === 'string' ? search.seriesId : undefined }),
component: LiveEntryPage,
})
const liveRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/series/$seriesId', component: LiveSeriesPage })
const spectatorRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/watch/$seriesId', component: SpectatorPage })
const bracketRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/bracket/$eventId', component: BracketPage })
const adminRoute = createRoute({
getParentRoute: () => protectedRoute, path: '/admin', component: AdminPage,
beforeLoad: ({ context }) => { if (!isStaff(context.session?.account.role)) throw redirect({ to: '/events' }) },
})
const routeTree = rootRoute.addChildren([loginRoute, protectedRoute.addChildren([
indexRoute, profileRoute, playerRoute, faqRoute, eventsRoute, eventRoute, liveEntryRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute,
])])
const router = createRouter({ routeTree, context: { session: null } })
declare module '@tanstack/react-router' { interface Register { router: typeof router } }
const mainNavItems = [
{ to: '/events', label: 'Events', icon: CalendarDays },
{ to: '/faq', label: 'FAQ', icon: CircleHelp },
...(demoMode ? [
{ to: '/series/$seriesId' as const, params: { seriesId: 'series-1' }, label: 'Live', icon: Radio },
{ to: '/bracket/$eventId' as const, params: { eventId: 'bracket-1' }, label: 'Bracket', icon: Trophy },
] : []),
]
function Brand() {
return <Link to="/events" className="brand" aria-label="Mixmaker home"><span className="brand-mark"><Swords aria-hidden="true" /></span><span><strong>MIX</strong>MAKER</span></Link>
}
function AppShell() {
const [open, setOpen] = useState(false)
const [realtimeStatus, setRealtimeStatus] = useState<'connected' | 'reconnecting'>('reconnecting')
const client = useQueryClient()
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
useEffect(() => {
window.addEventListener('pointerdown', primeTurnSound, { once: true })
window.addEventListener('keydown', primeTurnSound, { once: true })
return () => {
window.removeEventListener('pointerdown', primeTurnSound)
window.removeEventListener('keydown', primeTurnSound)
}
}, [])
useEffect(() => subscribeToEvents('*', (message) => {
const [scope, id] = message.topic.split(':', 2)
if (scope === 'event' && id) {
void client.invalidateQueries({ queryKey: ['event', id] })
void client.invalidateQueries({ queryKey: ['registrations', id] })
void client.invalidateQueries({ queryKey: ['roster', id] })
void client.invalidateQueries({ queryKey: ['teams', id] })
void client.invalidateQueries({ queryKey: ['tournament', id] })
void client.invalidateQueries({ queryKey: ['events'] })
} else if (scope === 'series' && id) {
void client.invalidateQueries({ queryKey: ['series', id] })
} else if (scope === 'tournament') {
void client.invalidateQueries({ queryKey: ['tournament'] })
} else if (scope === 'bracket' && id) {
void client.invalidateQueries({ queryKey: ['bracket-draft', id] })
} else if (scope === 'team') {
void client.invalidateQueries({ queryKey: ['teams'] })
void client.invalidateQueries({ queryKey: ['series'] })
} else if (message.topic === 'events') {
void client.invalidateQueries({ queryKey: ['events'] })
} else if (message.topic === 'players') {
void client.invalidateQueries({ queryKey: ['players'] })
void client.invalidateQueries({ queryKey: ['profile'] })
} else if (message.topic === 'accounts') {
void client.invalidateQueries({ queryKey: ['session'] })
void client.invalidateQueries({ queryKey: ['accounts'] })
}
}, setRealtimeStatus), [client])
const logout = useMutation({
mutationFn: api.logout,
onSuccess: () => {
queryClient.clear()
void router.navigate({ to: '/login' })
},
})
const initials = session.data?.player.displayName.slice(0, 2).toUpperCase() ?? 'MM'
const navItems = isStaff(session.data?.account.role)
? [...mainNavItems, { to: '/admin' as const, label: session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin', icon: Shield }]
: mainNavItems
return <div className="app-shell">
<header className="topbar">
<Brand />
<nav className="desktop-nav" aria-label="Primary navigation">{navItems.map(({ icon: Icon, ...item }) => <Link key={item.label} {...item} activeProps={{ className: 'active' }}><Icon />{item.label}</Link>)}</nav>
<div className="account"><LanguageSelector /><span className={`live-dot ${realtimeStatus}`} title={realtimeStatus === 'connected' ? 'Realtime connected' : 'Realtime reconnecting'} /><Link to="/profile" className="avatar" aria-label="Open profile">{initials}</Link><button className="icon-button" onClick={() => logout.mutate()} aria-label="Sign out" title="Sign out"><LogOut /></button><button className="icon-button mobile-menu" onClick={() => setOpen(!open)} aria-label="Toggle navigation" aria-expanded={open}>{open ? <X /> : <Menu />}</button></div>
</header>
{open && <nav className="mobile-nav" aria-label="Mobile navigation">{[...navItems, { to: '/profile' as const, label: 'Profile', icon: UserRound }].map(({ icon: Icon, ...item }) => <Link key={item.label} {...item} onClick={() => setOpen(false)}><Icon />{item.label}</Link>)}</nav>}
<main><Outlet /></main>
<nav className="bottom-nav" aria-label="Quick navigation">{navItems.map(({ icon: Icon, ...item }) => <Link key={item.label} {...item} activeProps={{ className: 'active' }}><Icon /><span>{item.label}</span></Link>)}</nav>
</div>
}
function LoginPage() {
return <div className="login-page">
<div className="login-orb login-orb-orange" /><div className="login-orb login-orb-blue" />
<section className="login-card"><div className="login-brand-row"><Brand /><LanguageSelector /></div><span className="eyebrow">Community scrims, organized</span><h1>Ready up.<br /><span>Play together.</span></h1><p>Balance teams, run drafts, and follow every map from one esports control room.</p><a className="button discord-button" href="/api/auth/discord"><Gamepad2 />Continue with Discord</a><small>Signing in creates a secure session. We never receive your Discord password.</small></section>
<div className="login-stats"><span><strong>5v5</strong> role-locked teams</span><span><strong>LIVE</strong> synchronized drafts</span><span><strong>BO3</strong> tournament series</span></div>
</div>
}
function PageHeader({ eyebrow, title, description, actions }: { eyebrow: string; title: string; description?: string; actions?: ReactNode }) {
return <header className="page-header"><div><span className="eyebrow">{eyebrow}</span><h1>{title}</h1>{description && <p>{description}</p>}</div>{actions && <div className="page-actions">{actions}</div>}</header>
}
function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'danger' | 'neutral' | 'blue' }) { return <span className={`badge badge-${tone}`}>{children}</span> }
function LoadingState({ label = 'Loading match data…' }: { label?: string }) { return <div className="state-card" role="status"><RefreshCw className="spin" /><h2>{label}</h2><p>Syncing with the server.</p></div> }
function ErrorState({ retry, message }: { retry?: () => void; message?: string }) { return <div className="state-card" role="alert"><CircleHelp /><h2>Couldnt load this view</h2><p>{message || 'Your data is safe. Check your connection and try again.'}</p>{retry && <button className="button secondary" onClick={retry}>Try again</button>}</div> }
function PlayerLink({ player, className }: { player: Pick<Player, 'id' | 'displayName'>; className?: string }) {
return <Link className={`player-link ${className ?? ''}`.trim()} to="/players/$playerId" params={{ playerId: player.id }}>{player.displayName}</Link>
}
function EventsPage() {
const [filter, setFilter] = useState<'upcoming' | 'all'>('upcoming')
const events = useQuery({ queryKey: ['events'], queryFn: api.events, initialData: demoMode ? demoEvents : undefined })
if (events.isLoading) return <LoadingState label="Loading events…" />
if (events.isError) return <ErrorState retry={() => void events.refetch()} />
if (!events.data) return <LoadingState label="Loading events…" />
const visible = filter === 'upcoming' ? events.data.filter((e) => e.status !== 'completed') : events.data
const liveEvent = events.data.find((event) => event.workflowState === 'Live')
return <div className="page">
<PageHeader eyebrow="Scrim schedule" title="Your events" description="Join a mix, check the roster, and get match-ready." actions={<div className="segmented"><button className={filter === 'upcoming' ? 'active' : ''} onClick={() => setFilter('upcoming')}>Upcoming</button><button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>All</button></div>} />
{liveEvent && <FeaturedLiveEvent event={liveEvent} />}
<div className="section-title"><div><h2>Coming up</h2><p>{visible.length} scheduled events</p></div><button className="icon-button" aria-label="Filter events"><ListFilter /></button></div>
{visible.length === 0 ? <div className="state-card"><CalendarDays /><h2>No events yet</h2><p>New community nights will appear here.</p></div> : <div className="event-grid">{visible.map((event) => <EventCard key={event.id} event={event} />)}</div>}
</div>
}
function FeaturedLiveEvent({ event }: { event: MixEvent }) {
const series = useQuery({
queryKey: ['series', event.activeSeriesId],
queryFn: () => api.series(event.activeSeriesId!),
enabled: Boolean(event.activeSeriesId) && !event.tournamentId,
initialData: demoMode && event.activeSeriesId === demoSeries.id ? demoSeries : undefined,
})
const tournament = useQuery({
queryKey: ['tournament', event.id],
queryFn: () => api.bracket(event.id),
enabled: Boolean(event.tournamentId),
initialData: demoMode && event.tournamentId ? demoBracket : undefined,
})
const match = series.data
const liveMatches = tournament.data?.matches.filter((item) => item.status === 'live') ?? []
const hasScore = Boolean(match) || liveMatches.length > 0
return <section className={`featured-event ${hasScore ? '' : 'without-score'} ${liveMatches.length > 1 ? 'multiple-matches' : ''}`}><div><Badge tone="live"><Radio />Live now</Badge><h2>{event.title}</h2><p>{liveMatches.length > 0 ? <>{liveMatches.length} <span>active matches</span></> : match ? `${match.roundLabel} · ${match.currentStep.title}` : 'The scrim has started'}</p></div>{liveMatches.length > 0 ? <div className="featured-match-list">{liveMatches.map((item) => <div className="featured-match-score" key={item.id}><small>{item.label}</small><span>{item.teamAlpha ?? 'TBD'}</span><strong>{item.scoreAlpha ?? 0}<i>:</i>{item.scoreBeta ?? 0}</strong><span>{item.teamBeta ?? 'TBD'}</span></div>)}</div> : match && <div className="featured-score"><span>{match.teamAlpha.name}</span><strong>{match.score.alpha}<i>:</i>{match.score.beta}</strong><span>{match.teamBeta.name}</span></div>}<Link className="button primary" to="/events/$eventId/live" params={{ eventId: event.id }} search={{ seriesId: undefined }}>Open live <ChevronRight /></Link></section>
}
function EventCard({ event }: { event: MixEvent }) {
const date = new Date(event.startsAt)
const statusBadge = event.status === 'cancelled' ? <Badge tone="danger"><X />Cancelled</Badge>
: event.status === 'completed' ? <Badge tone="success"><Check />Completed</Badge>
: event.status === 'live' ? <Badge tone="live"><Radio />LIVE</Badge>
: <Badge tone={event.myRsvp === 'going' ? 'success' : 'warning'}>{event.myRsvp === 'going' ? <Check /> : <Clock3 />}{event.myRsvp === 'going' ? 'Going' : 'Maybe'}</Badge>
return <Link className={`card event-card status-${event.status}`} to="/events/$eventId" params={{ eventId: event.id }} aria-label={`Open ${event.title}`}><div className="date-block"><strong>{date.toLocaleDateString(undefined, { day: '2-digit' })}</strong><span>{date.toLocaleDateString(undefined, { month: 'short' }).toUpperCase()}</span></div><div className="event-card-body"><div className="event-meta">{statusBadge}<span>{date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span></div><h3>{event.title}</h3><p>{event.description}</p><div className="event-footer"><span><UsersRound />{event.counts.going} going</span><span className="event-open">View event <ChevronRight /></span></div></div></Link>
}
function EventPage() {
const { eventId } = eventRoute.useParams()
const client = useQueryClient()
const demoEvent = demoEvents.find((event) => event.id === eventId)
const eventQuery = useQuery({ queryKey: ['event', eventId], queryFn: () => api.event(eventId), initialData: demoMode ? demoEvent : undefined })
const registrations = useQuery({ queryKey: ['registrations', eventId], queryFn: () => api.registrations(eventId), initialData: demoMode ? demoRegistrations : undefined })
const teams = useQuery({ queryKey: ['teams', eventId], queryFn: () => api.teams(eventId), initialData: demoMode && demoEvent && ['RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'].includes(demoEvent.workflowState) ? demoBalanceCandidates[0].teams : undefined })
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
const rsvpMutation = useMutation({
mutationFn: (status: RsvpStatus) => api.setRsvp(eventId, session.data!.player.id, status),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ['event', eventId] })
void client.invalidateQueries({ queryKey: ['events'] })
void client.invalidateQueries({ queryKey: ['registrations', eventId] })
},
})
const labels: Record<RsvpStatus, string> = { going: 'Going', maybe: 'Maybe', not_going: 'Cant go' }
if (eventQuery.isLoading || registrations.isLoading || session.isLoading) return <LoadingState label="Loading event…" />
if (eventQuery.isError || registrations.isError || session.isError || !eventQuery.data || !session.data) return <ErrorState retry={() => { void eventQuery.refetch(); void registrations.refetch(); void session.refetch() }} />
const event = eventQuery.data
const rsvp = registrations.data?.find((registration) => registration.player.id === session.data.player.id)?.status ?? event.myRsvp
const counts = registrations.data?.reduce((total, registration) => ({ ...total, [registration.status]: total[registration.status] + 1 }), { going: 0, maybe: 0, not_going: 0 }) ?? event.counts
const attendanceTotal = Object.values(counts).reduce((total, count) => total + count, 0)
const statusOrder: Record<RsvpStatus, number> = { going: 0, maybe: 1, not_going: 2 }
const attendees = [...(registrations.data ?? [])].sort((a, b) => statusOrder[a.status] - statusOrder[b.status] || a.player.displayName.localeCompare(b.player.displayName))
return <div className={`page event-detail status-${event.status}`}><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<>{event.status !== 'cancelled' && event.workflowState === 'Live' && <Link className="button primary" to="/events/$eventId/live" params={{ eventId }} search={{ seriesId: undefined }}>Open live</Link>}{event.workflowState === 'Completed' && event.activeSeriesId && !event.tournamentId && <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.activeSeriesId }}>Match history</Link>}{event.status !== 'cancelled' && event.tournamentId && <Link className="button secondary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}<Badge tone={event.workflowState === 'Cancelled' ? 'danger' : event.workflowState === 'Live' ? 'live' : event.workflowState === 'Completed' || event.workflowState === 'RegistrationOpen' ? 'success' : 'neutral'}>{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')}</Badge></>} /><div className="detail-layout">
<section className="card event-hero"><div className="event-time"><CalendarDays /><div><span>Starts</span><strong>{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="event-time"><Clock3 /><div><span>Registration closes</span><strong>{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="divider" /><h2>Are you playing?</h2><p>{event.workflowState === 'RegistrationOpen' ? 'Your latest response is sent to the organizer.' : 'Registration has been closed by the organizer.'}</p><div className="rsvp-control" role="group" aria-label="RSVP status">{(Object.keys(labels) as RsvpStatus[]).map((status) => <button key={status} disabled={rsvpMutation.isPending || event.workflowState !== 'RegistrationOpen'} className={rsvp === status ? `active ${status}` : ''} aria-pressed={rsvp === status} onClick={() => rsvpMutation.mutate(status)}>{status === 'going' ? <Check /> : status === 'maybe' ? <CircleHelp /> : <X />}{labels[status]}</button>)}</div>{rsvpMutation.isSuccess && <div className="save-note"><Check />Response saved · Server confirmed just now</div>}{rsvpMutation.isError && <p className="error-note">{rsvpMutation.error.message}</p>}</section>
<aside className="card roster-preview"><div className="section-title"><div><h2>Attendance</h2><p>{attendanceTotal} responses</p></div><UsersRound /></div><div className="attendance-bar">{attendanceTotal > 0 && <>{counts.going > 0 && <span className="going" style={{ flexGrow: counts.going, width: 0 }} />}{counts.maybe > 0 && <span className="maybe" style={{ flexGrow: counts.maybe, width: 0 }} />}{counts.not_going > 0 && <span className="not-going" style={{ flexGrow: counts.not_going, width: 0 }} />}</>}</div><div className="attendance-stats"><span><i className="green" />Going <strong>{counts.going}</strong></span><span><i className="yellow" />Maybe <strong>{counts.maybe}</strong></span><span><i />No <strong>{counts.not_going}</strong></span></div>{isStaff(session.data.account.role) && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside>
</div>{teams.data && teams.data.length > 0 && <section className="event-teams"><div className="section-title"><div><h2>Balanced teams</h2><p>Confirmed match rosters are always available here.</p></div><Swords /></div><div className="teams-grid">{teams.data.map((team) => <PublicTeamCard key={team.id} team={team} />)}</div></section>}<section className="card public-roster"><div className="section-title"><div><h2>Registered players</h2><p>Everyone can see who responded and their current status.</p></div><UsersRound /></div>{attendees.length === 0 ? <p className="empty-note">No responses yet.</p> : <div className="public-roster-grid">{attendees.map((registration) => <div className="public-roster-player" key={registration.id}><span className="mini-avatar">{registration.player.displayName.slice(0, 2).toUpperCase()}</span><PlayerLink player={registration.player} /><Badge tone={registration.status === 'going' ? 'success' : registration.status === 'maybe' ? 'warning' : 'neutral'}>{labels[registration.status]}</Badge></div>)}</div>}</section></div>
}
function LiveEntryPage() {
const { eventId } = liveEntryRoute.useParams()
const { seriesId: requestedSeriesId } = liveEntryRoute.useSearch()
const event = useQuery({ queryKey: ['event', eventId], queryFn: () => api.event(eventId), initialData: demoMode ? demoEvents.find((item) => item.id === eventId) : undefined })
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
const teams = useQuery({ queryKey: ['teams', eventId], queryFn: () => api.teams(eventId), initialData: demoMode ? demoBalanceCandidates[0].teams : undefined })
const bracket = useQuery({
queryKey: ['tournament', eventId],
queryFn: () => api.bracket(eventId),
enabled: Boolean(event.data?.tournamentId),
initialData: demoMode && event.data?.tournamentId ? demoBracket : undefined,
})
const loading = event.isLoading || session.isLoading || teams.isLoading || (Boolean(event.data?.tournamentId) && bracket.isLoading)
const error = event.error ?? session.error ?? teams.error ?? bracket.error
const teamIds = useMemo(() => new Set((teams.data ?? []).filter((team) => team.members.some(({ player }) => player.id === session.data?.player.id)).map((team) => team.id)), [session.data?.player.id, teams.data])
const matches = bracket.data?.matches ?? []
const activeMatches = matches.filter((match) => match.status === 'live' && match.seriesId)
const requested = requestedSeriesId ? matches.find((match) => match.seriesId === requestedSeriesId) : undefined
const ownMatch = activeMatches.find((match) => teamIds.has(match.teamAlphaId ?? '') || teamIds.has(match.teamBetaId ?? ''))
const target = requested ?? ownMatch ?? activeMatches[0] ?? matches.find((match) => match.seriesId)
const targetSeriesId = event.data?.tournamentId ? target?.seriesId : event.data?.activeSeriesId
const targetIsOwn = event.data?.tournamentId
? Boolean(target && (teamIds.has(target.teamAlphaId ?? '') || teamIds.has(target.teamBetaId ?? '')))
: teamIds.size > 0
const control = isStaff(session.data?.account.role) || targetIsOwn
useEffect(() => {
if (loading || error || !targetSeriesId) return
if (control) void router.navigate({ to: '/series/$seriesId', params: { seriesId: targetSeriesId }, replace: true })
else void router.navigate({ to: '/watch/$seriesId', params: { seriesId: targetSeriesId }, replace: true })
}, [control, error, loading, targetSeriesId])
if (error) return <ErrorState message={error.message} retry={() => { void event.refetch(); void session.refetch(); void teams.refetch(); void bracket.refetch() }} />
if (!loading && !targetSeriesId) return <ErrorState message="No active series is available for this event." />
return <LoadingState label="Opening your live match…" />
}
function FAQPage() {
const settings = useQuery({ queryKey: ['community-settings'], queryFn: api.communitySettings, initialData: demoMode ? demoCommunitySettings : undefined })
return <div className="page faq-page"><PageHeader eyebrow="Rules and help" title="Frequently asked questions" description="Everything players and captains need before the scrim starts." /><div className="faq-grid">
<section className="card faq-card community-faq"><span><Bell /></span><div><h2>Join the community Discord</h2><p>Discord is where event announcements, role updates, and match notifications arrive. Join the server and enable the bot roles you want to be notified about.</p>{settings.data?.discordInviteUrl && <a className="button secondary" href={settings.data.discordInviteUrl} target="_blank" rel="noreferrer">Open Discord <ExternalLink /></a>}</div></section>
<section className="card faq-card"><span>01</span><div><h2>How are teams balanced?</h2><p>The server builds role-locked 1 Tank / 2 Damage / 2 Support teams. Rank balance is the main goal. Preferred roles, preferred teammates, and up to three avoided teammates are soft preferences and never override a fair valid roster.</p></div></section>
<section className="card faq-card"><span>02</span><div><h2>How does registration work?</h2><p>Choose Going, Maybe, or Cant go before registration closes. Only Going players enter balancing. After an admin closes registration, normal RSVP changes are locked.</p></div></section>
<section className="card faq-card"><span>03</span><div><h2>What happens before the scrim?</h2><p>An admin generates balance candidates, chooses one, may swap players of the same role or use the reserve, assigns captains, confirms the rosters, and starts the scrim.</p></div></section>
<section className="card faq-card"><span>04</span><div><h2>How is the first map selected?</h2><p>The initial coin toss decides which team bans first. Teams alternate map bans until one map remains. That map becomes the first map of the series.</p></div></section>
<section className="card faq-card"><span>05</span><div><h2>Who picks later maps?</h2><p>After a captain or admin records a map result, the team that lost that map immediately chooses the next map from the next ruleset pool. Already played maps are unavailable. After a draw, the initial coin-toss loser receives the pick.</p></div></section>
<section className="card faq-card"><span>06</span><div><h2>How do hero bans work?</h2><p>Before every map, teams make two bans each in alternating order. The initial coin-toss loser bans first. A teams two bans must use different roles, and a team cannot repeat its own hero ban during the same series. It may ban a hero previously banned by the opponent.</p></div></section>
<section className="card faq-card"><span>07</span><div><h2>How does Bo3 scoring work?</h2><p>The first team to win two maps wins the series. A captain selects the result after each map; admins can correct mistakes. Tournament winners advance automatically.</p></div></section>
<section className="card faq-card"><span>08</span><div><h2>What can captains do?</h2><p>Captains can perform actions for their team: map bans, later-map picks, and hero bans. Admins can perform or correct any authoritative action. Every command is versioned and recorded in the match history.</p></div></section>
</div></div>
}
function PublicPlayerProfilePage() {
const { playerId } = playerRoute.useParams()
const language = useLanguage()
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
const fallback = demoPlayerProfiles.find((player) => player.id === playerId)
const profile = useQuery({
queryKey: ['player-profile', playerId],
queryFn: () => api.playerProfile(playerId),
initialData: demoMode ? fallback : undefined,
retry: false,
})
if (profile.isLoading || session.isLoading) return <LoadingState label="Loading player profile…" />
if (profile.isError || session.isError || !profile.data) return <ErrorState message="This player profile could not be found." retry={() => void profile.refetch()} />
const player: PublicPlayerProfile = profile.data
const stats = player.stats
const winRate = stats.winRate <= 1 ? Math.round(stats.winRate * 100) : Math.round(stats.winRate)
const roles: Array<{ key: PlayerRole; label: string; icon: ReactNode }> = [
{ key: 'tank', label: 'Tank', icon: <Shield /> },
{ key: 'damage', label: 'Damage', icon: <Zap /> },
{ key: 'support', label: 'Support', icon: <Sparkles /> },
]
return <div className="page player-profile-page">
<PageHeader eyebrow="Player profile" title={player.displayName} description="Competitive history and role ranks." actions={session.data?.player.id === player.id ? <Link className="button secondary" to="/profile">Edit profile</Link> : undefined} />
<section className="profile-identity card"><div className="large-avatar">{player.displayName.slice(0, 2).toUpperCase()}</div><div><h2>{player.displayName}</h2>{player.battleTag && <p>{player.battleTag}</p>}<Badge tone={player.isGuest ? 'neutral' : 'blue'}>{player.isGuest ? 'Guest player' : 'Discord player'}</Badge></div></section>
<div className="public-profile-grid">
<section className="card profile-ranks"><div className="section-title"><div><h2>Role ranks</h2><p>Current competitive ratings</p></div></div>{roles.map((role) => <div className={`profile-rank role-${role.key}`} key={role.key}><span className="role-icon">{role.icon}</span><span><small>{role.label}</small><strong>{rankLabel(player.ratings[role.key], language)}</strong></span></div>)}</section>
<section className="profile-stat-grid" aria-label="Player statistics">
<div className="card"><span>Series played</span><strong>{stats.seriesPlayed}</strong></div>
<div className="card"><span>Series won</span><strong>{stats.seriesWon}</strong></div>
<div className="card"><span>Series lost</span><strong>{stats.seriesLost}</strong></div>
<div className="card highlight"><span>Win rate</span><strong>{winRate}%</strong></div>
</section>
</div>
<section className="card map-record"><div className="section-title"><div><h2>Map record</h2><p>{stats.mapsPlayed} maps played</p></div></div><div><span><strong>{stats.mapsWon}</strong>Won</span><span><strong>{stats.mapsLost}</strong>Lost</span><span><strong>{stats.mapsDrawn}</strong>Drawn</span></div></section>
<section className="card recent-matches"><div className="section-title"><div><h2>Recent matches</h2><p>Latest completed series</p></div></div>{player.recentMatches.length === 0 ? <p className="empty-note">No completed matches yet.</p> : <div>{player.recentMatches.map((match) => <Link to="/watch/$seriesId" params={{ seriesId: match.seriesId }} className="recent-match" key={match.seriesId}><div><Badge tone={match.outcome === 'win' ? 'success' : 'danger'}>{match.outcome}</Badge><span><strong>{match.eventName}</strong><small>{new Date(match.startedAt).toLocaleDateString()}</small></span></div><div className="recent-match-score"><span>{match.teamName}</span><strong>{match.scoreFor}<i>:</i>{match.scoreAgainst}</strong><span>{match.opponentTeamName}</span></div><ChevronRight /></Link>)}</div>}</section>
</div>
}
function ProfilePage() {
const client = useQueryClient()
const language = useLanguage()
const options = useMemo(() => rankOptions(language), [language])
const profile = useQuery({ queryKey: ['profile'], queryFn: api.profile, initialData: demoMode ? demoProfile : undefined })
const players = useQuery({ queryKey: ['players'], queryFn: api.players, initialData: demoMode ? demoPlayerProfiles : undefined })
const [ratings, setRatings] = useState<Record<PlayerRole, RankOrdinal>>({ tank: 20, damage: 20, support: 20 })
const [displayName, setDisplayName] = useState('')
const [battleTag, setBattleTag] = useState('')
const [preferredRoles, setPreferredRoles] = useState<PlayerRole[]>([])
const [preferredPlayerIds, setPreferredPlayerIds] = useState<string[]>([])
const [avoidedPlayerIds, setAvoidedPlayerIds] = useState<string[]>([])
useEffect(() => {
if (profile.data) {
setDisplayName(profile.data.displayName)
setBattleTag(profile.data.battleTag ?? '')
setRatings({ tank: profile.data.ratings.tank, damage: profile.data.ratings.damage, support: profile.data.ratings.support })
setPreferredRoles(profile.data.preferredRoles)
setPreferredPlayerIds(profile.data.preferredPlayerIds)
setAvoidedPlayerIds(profile.data.avoidedPlayerIds)
}
}, [profile.data])
useEffect(() => {
if (players.data) {
const eligible = new Set(players.data.filter((player) => Boolean(player.accountId)).map((player) => player.id))
setPreferredPlayerIds((current) => current.filter((id) => eligible.has(id)))
setAvoidedPlayerIds((current) => current.filter((id) => eligible.has(id)))
}
}, [players.data])
const update = useMutation({
mutationFn: () => demoMode
? Promise.resolve({ ...profile.data!, displayName: displayName.trim(), battleTag: battleTag.trim() || undefined, ratings: { ...ratings, updatedAt: new Date().toISOString() }, preferredRoles, preferredPlayerIds, avoidedPlayerIds })
: api.updateRatings(displayName.trim(), battleTag, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds),
onSuccess: (player) => {
client.setQueryData(['profile'], player)
void client.invalidateQueries({ queryKey: ['session'] })
},
})
if (profile.isLoading || players.isLoading) return <LoadingState label="Loading profile…" />
if (profile.isError || players.isError) return <ErrorState retry={() => { void profile.refetch(); void players.refetch() }} />
if (!profile.data || !players.data) return <LoadingState label="Loading profile…" />
const roles: Array<{ key: PlayerRole; label: string; hint: string }> = [{ key: 'tank', label: 'Tank', hint: 'Space, pressure, and frontline' }, { key: 'damage', label: 'Damage', hint: 'Eliminations and map control' }, { key: 'support', label: 'Support', hint: 'Sustain, utility, and tempo' }]
const submit = (e: FormEvent) => { e.preventDefault(); update.mutate() }
const eligiblePlayers = players.data.filter((player) => player.id !== profile.data.id && Boolean(player.accountId))
return <div className="page narrow"><PageHeader eyebrow="Player profile" title="Set your competitive ranks" description="Your role ranks help the server build fair, role-locked teams." /><section className="profile-banner card"><div className="large-avatar">{displayName.slice(0, 2).toUpperCase()}</div><div><h2>{displayName || profile.data.displayName}</h2>{battleTag && <p>{battleTag}</p>}<Badge tone="blue"><Gamepad2 />Connected via Discord</Badge></div></section><form className="card ratings-form" onSubmit={submit}><section className="identity-fields"><label>Nickname<input required minLength={2} maxLength={32} autoComplete="nickname" value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label><label>BattleTag <small>Optional</small><input maxLength={33} placeholder="Player#1234" value={battleTag} onChange={(event) => setBattleTag(event.target.value)} /></label><p>Nicknames are unique across the community.</p></section><div className="section-title"><div><h2>Competitive ranks</h2><p>Select your current Overwatch rank for each role.</p></div><span>Updated {new Date(profile.data.ratings.updatedAt).toLocaleDateString()}</span></div>{roles.map((role) => <label className={`rating-row role-${role.key}`} key={role.key}><div><span className="role-icon">{role.key === 'tank' ? <Shield /> : role.key === 'damage' ? <Zap /> : <Sparkles />}</span><span><strong>{role.label}</strong><small>{role.hint}</small></span></div><select id={`rating-${role.key}`} className="rank-select" value={ratings[role.key]} onChange={(e) => setRatings({ ...ratings, [role.key]: toRankOrdinal(Number(e.target.value)) })}>{options.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>)}<section className="preference-section"><div><h2>Preferred roles</h2><p>Choose every role you enjoy playing.</p></div><div className="preference-chips">{roles.map((role) => <button type="button" aria-pressed={preferredRoles.includes(role.key)} className={preferredRoles.includes(role.key) ? 'active' : ''} key={role.key} onClick={() => setPreferredRoles((current) => current.includes(role.key) ? current.filter((item) => item !== role.key) : [...current, role.key])}>{role.label}</button>)}</div></section><section className="preference-section"><div><h2>Preferred teammates</h2><p>Choose up to 3 players. The balancer treats these as preferences, not guarantees.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !avoidedPlayerIds.includes(player.id)).map((player) => { const selected = preferredPlayerIds.includes(player.id); const disabled = !selected && preferredPlayerIds.length >= 3; return <label className={selected ? 'selected' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setPreferredPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><section className="preference-section avoid-section"><div><h2>Avoided teammates</h2><p>Choose up to 3 players. This is a soft preference, so balance can still place you together when necessary.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !preferredPlayerIds.includes(player.id)).map((player) => { const selected = avoidedPlayerIds.includes(player.id); const disabled = !selected && avoidedPlayerIds.length >= 3; return <label className={selected ? 'selected avoided' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setAvoidedPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><div className="form-footer"><p><Shield />Only you can edit your ranks and preferences.{update.isError && ` ${update.error.message}`}</p><button className="button primary" type="submit" disabled={update.isPending || !displayName.trim()}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save profile'}</button></div></form></div>
}
type AdminTab = 'events' | 'participants' | 'balance' | 'moderators' | 'community'
function AdminPage() {
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
const [tab, setTab] = useState<AdminTab>('events')
const [createMode, setCreateMode] = useState(false)
const events = useQuery({ queryKey: ['events'], queryFn: api.events, initialData: demoMode ? demoEvents : undefined })
const [selectedId, setSelectedId] = useState<string>()
const selected = events.data?.find((event) => event.id === selectedId) ?? events.data?.[0]
useEffect(() => { if (!selectedId && events.data?.[0]) setSelectedId(events.data[0].id) }, [events.data, selectedId])
if (events.isLoading || session.isLoading) return <LoadingState label="Loading admin control room…" />
if (events.isError || session.isError) return <ErrorState retry={() => { void events.refetch(); void session.refetch() }} />
const tabs: AdminTab[] = ['events', 'participants', 'balance', ...(session.data?.account.role === 'admin' ? ['moderators' as const, 'community' as const] : [])]
const standaloneTab = tab === 'moderators' || tab === 'community'
return <div className="page"><PageHeader eyebrow={session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin control room'} title={tab === 'moderators' ? 'Moderator access' : tab === 'community' ? 'Community settings' : createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={tab === 'moderators' ? 'Grant moderation access to trusted Discord users.' : tab === 'community' ? 'Manage the links shown to every community member.' : selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={!standaloneTab ? <>{events.data && events.data.length > 0 && <select aria-label="Selected event" value={selected?.id ?? ''} onChange={(e) => { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data.map((event) => <option value={event.id} key={event.id}>{event.title}</option>)}</select>}<button className="button secondary" onClick={() => { setCreateMode(true); setTab('events') }}><Sparkles />New event</button></> : undefined} /><div className="tabs" role="tablist">{tabs.map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && !['events', 'moderators', 'community'].includes(item)} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : item === 'balance' ? 'Balance teams' : item === 'community' ? 'Community' : 'Moderators'}</button>)}</div>{tab === 'events' && <EventSetup event={createMode ? undefined : selected} onCreated={(event) => { setCreateMode(false); setSelectedId(event.id) }} onDeleted={() => { setSelectedId(undefined); setCreateMode(false) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}{tab === 'moderators' && session.data?.account.role === 'admin' && <ModeratorManagement />}{tab === 'community' && session.data?.account.role === 'admin' && <CommunitySettingsPanel />}</div>
}
function CommunitySettingsPanel() {
const client = useQueryClient()
const settings = useQuery({ queryKey: ['community-settings'], queryFn: api.communitySettings, initialData: demoMode ? demoCommunitySettings : undefined })
const [discordInviteUrl, setDiscordInviteUrl] = useState('')
useEffect(() => { if (settings.data) setDiscordInviteUrl(settings.data.discordInviteUrl) }, [settings.data])
const update = useMutation({
mutationFn: () => demoMode ? Promise.resolve({ discordInviteUrl: discordInviteUrl.trim() }) : api.updateCommunitySettings(discordInviteUrl.trim()),
onSuccess: (saved) => client.setQueryData(['community-settings'], saved),
})
if (settings.isLoading) return <LoadingState label="Loading community settings…" />
if (settings.isError) return <ErrorState message={settings.error.message} retry={() => void settings.refetch()} />
return <form className="card community-settings" onSubmit={(event) => { event.preventDefault(); update.mutate() }}><div className="section-title"><div><h2>Community Discord</h2><p>This invite is shown in FAQ to signed-in players.</p></div><Gamepad2 /></div><label>Discord invite URL<input type="url" inputMode="url" placeholder="https://discord.gg/…" value={discordInviteUrl} onChange={(event) => setDiscordInviteUrl(event.target.value)} /></label><div className="form-footer"><p>{update.isError ? update.error.message : 'Leave empty to hide the invite button.'}</p><button className="button primary" disabled={update.isPending}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save settings'}</button></div></form>
}
function ModeratorManagement() {
const client = useQueryClient()
const accounts = useQuery({ queryKey: ['accounts'], queryFn: api.accounts })
const update = useMutation({
mutationFn: ({ accountId, moderator }: { accountId: string; moderator: boolean }) => api.setModerator(accountId, moderator),
onSuccess: (account) => client.setQueryData<RawAccount[]>(['accounts'], (current) => current?.map((item) => item.id === account.id ? account : item)),
onError: () => void accounts.refetch(),
})
if (accounts.isLoading) return <LoadingState label="Loading accounts…" />
if (accounts.isError || !accounts.data) return <ErrorState message={accounts.error?.message} retry={() => void accounts.refetch()} />
return <section className="card moderator-panel"><div className="section-title"><div><h2>Moderators</h2><p>Moderators can manage events, participants, rosters, and live scrims. Only administrators can change this list.</p></div><Badge tone="blue">{accounts.data.filter((account) => account.role === 'moderator').length} active</Badge></div><div className="moderator-list">{accounts.data.map((account) => <div key={account.id}><span className="mini-avatar">{account.username.slice(0, 2).toUpperCase()}</span><span><strong>{account.username}</strong><small>Discord ID · {account.discordId}</small></span>{account.role === 'admin' ? <Badge tone="warning">Administrator</Badge> : <label className="moderator-toggle"><input type="checkbox" checked={account.role === 'moderator'} disabled={update.isPending} onChange={(event) => update.mutate({ accountId: account.id, moderator: event.target.checked })} /><span>{account.role === 'moderator' ? 'Moderator' : 'Player'}</span></label>}</div>)}</div>{update.isError && <p className="error-note">{update.error.message}</p>}</section>
}
function toLocalInput(value: string) {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '' : new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16)
}
function eventInput(event?: MixEvent): EventInput {
const start = new Date(Date.now() + 86_400_000)
const end = new Date(start.getTime() + 2 * 3_600_000)
return {
name: event?.title ?? '',
description: event?.description ?? '',
startsAt: toLocalInput(event?.startsAt ?? start.toISOString()),
endsAt: toLocalInput(event?.endsAt ?? end.toISOString()),
registrationDeadline: toLocalInput(event?.startsAt ?? start.toISOString()),
}
}
function EventSetup({ event, onCreated, onDeleted }: { event?: MixEvent; onCreated: (event: MixEvent) => void; onDeleted: () => void }) {
const client = useQueryClient()
const language = useLanguage()
const initialForm = useMemo(() => eventInput(event), [event])
const [form, setForm] = useState<EventInput>(initialForm)
const [pingEveryone, setPingEveryone] = useState(true)
useEffect(() => {
setForm(initialForm)
setPingEveryone(true)
}, [initialForm])
const save = useMutation({
mutationFn: () => {
const payload = { ...form, startsAt: new Date(form.startsAt).toISOString(), endsAt: new Date(form.endsAt).toISOString(), registrationDeadline: new Date(form.registrationDeadline).toISOString() }
return event ? api.updateEvent(event.id, payload) : api.createEvent({ ...payload, pingEveryone })
},
onSuccess: (saved) => {
void client.invalidateQueries({ queryKey: ['events'] })
onCreated(saved)
},
})
const cancel = useMutation({
mutationFn: () => {
if (!event) return Promise.reject(new Error('Event is required'))
if (demoMode) return Promise.resolve({ ...event, status: 'cancelled' as const, workflowState: 'Cancelled' as const, version: event.version + 1 })
return api.cancelEvent(event.id, event.version)
},
onSuccess: (cancelled) => {
client.setQueryData<MixEvent[]>(['events'], (current) => current?.map((item) => item.id === cancelled.id ? cancelled : item))
void client.invalidateQueries({ queryKey: ['events'] })
void client.invalidateQueries({ queryKey: ['event', cancelled.id] })
onCreated(cancelled)
},
})
const remove = useMutation({
mutationFn: async () => {
if (!event) return
if (!demoMode) await api.deleteEvent(event.id)
},
onSuccess: () => {
client.setQueryData<MixEvent[]>(['events'], (current) => current?.filter((item) => item.id !== event?.id))
void client.invalidateQueries({ queryKey: ['events'] })
onDeleted()
},
})
const set = (key: keyof EventInput, value: string) => setForm((current) => ({ ...current, [key]: value }))
const setStart = (value: string) => setForm((current) => {
if (event) return { ...current, startsAt: value, registrationDeadline: value }
const start = new Date(value)
return {
...current,
startsAt: value,
registrationDeadline: value,
endsAt: Number.isNaN(start.getTime()) ? current.endsAt : toLocalInput(new Date(start.getTime() + 2 * 3_600_000).toISOString()),
}
})
return <div className="admin-grid">
<form className="card settings-card" onSubmit={(e) => { e.preventDefault(); save.mutate() }}>
<div className="section-title"><div><h2>{event ? 'Event settings' : 'New event'}</h2><p>Scheduling and registration</p></div>{event && <div className="event-admin-actions"><Badge tone={event.status === 'cancelled' ? 'danger' : event.status === 'live' ? 'live' : 'success'}>{event.status === 'cancelled' ? 'Cancelled' : event.status === 'completed' ? 'Completed' : event.status === 'live' ? 'LIVE' : 'Published'}</Badge><button className="button danger-outline" type="button" disabled={cancel.isPending || event.status === 'cancelled' || event.status === 'completed'} onClick={() => cancel.mutate()}>Cancel event</button><button className="icon-button delete-event" type="button" disabled={remove.isPending} aria-label="Delete event" title="Delete event" onClick={() => { if (window.confirm(language === 'ru' ? 'Удалить это событие навсегда?' : 'Delete this event permanently?')) remove.mutate() }}><X /></button></div>}</div>
<div className="field-grid"><label>Event name<input required value={form.name} onChange={(e) => set('name', e.target.value)} /></label><label>Start time<input required type="datetime-local" value={form.startsAt} onChange={(e) => setStart(e.target.value)} /></label><label>End time<input required type="datetime-local" value={form.endsAt} onChange={(e) => set('endsAt', e.target.value)} /></label><label>Registration deadline<input required readOnly type="datetime-local" value={form.registrationDeadline} /></label></div>
<label>Description<textarea value={form.description} onChange={(e) => set('description', e.target.value)} /></label>
{!event && <label className="announcement-toggle"><input type="checkbox" checked={pingEveryone} onChange={(e) => setPingEveryone(e.target.checked)} /><span><strong>Ping everyone in Discord</strong><small>The announcement is always posted; this controls the @everyone notification.</small></span></label>}
<div className="form-footer"><p>{save.isError ? save.error.message : cancel.isError ? cancel.error.message : remove.isError ? remove.error.message : 'Times are sent to the server in UTC.'}</p><button className="button primary" disabled={save.isPending}>{save.isPending ? 'Saving…' : event ? 'Save changes' : 'Create event'}</button></div>
</form>
<aside className="card admin-summary"><h2>Server authority</h2><div className="readiness-score"><Shield /><span>Validation runs on the backend</span></div><ul><li><Check />UTC scheduling</li><li><Check />Audited admin changes</li><li><Check />Registration deadline rules</li></ul></aside>
</div>
}
function Participants({ eventId }: { eventId: string }) {
const client = useQueryClient()
const language = useLanguage()
const options = useMemo(() => rankOptions(language), [language])
const [displayName, setDisplayName] = useState('')
const [status, setStatus] = useState<RsvpStatus>('going')
const [ratings, setRatings] = useState<Record<PlayerRole, RankOrdinal>>({ tank: 13, damage: 13, support: 13 })
const rows = useQuery({ queryKey: ['registrations', eventId], queryFn: () => api.registrations(eventId), initialData: demoMode ? demoRegistrations : undefined })
const players = useQuery({ queryKey: ['players'], queryFn: api.players, initialData: demoMode ? demoPlayerProfiles : undefined })
const create = useMutation({
mutationFn: () => {
if (!demoMode) return api.createParticipant(eventId, displayName.trim(), ratings, status)
const now = new Date().toISOString()
const id = `guest-${Date.now()}`
return Promise.resolve<Registration>({
id: `${eventId}:${id}`,
player: {
id,
displayName: displayName.trim(),
ratings: { ...ratings, updatedAt: now },
preferredRoles: [],
preferredPlayerIds: [],
avoidedPlayerIds: [],
},
status,
updatedAt: now,
changedBy: { id: demoSession.account.id, displayName: demoSession.account.displayName, source: 'admin' },
})
},
onSuccess: (registration) => {
client.setQueryData<Registration[]>(['registrations', eventId], (current) => [...(current ?? []), registration])
setDisplayName('')
setRatings({ tank: 13, damage: 13, support: 13 })
setStatus('going')
if (!demoMode) void client.invalidateQueries({ queryKey: ['events'] })
},
})
const override = useMutation({
mutationFn: ({ playerId, status }: { playerId: string; status: RsvpStatus }) => api.setRsvp(eventId, playerId, status),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ['registrations', eventId] })
void client.invalidateQueries({ queryKey: ['events'] })
},
})
const remove = useMutation({
mutationFn: (playerId: string) => demoMode ? Promise.resolve() : api.removeParticipant(eventId, playerId),
onSuccess: (_, playerId) => {
client.setQueryData<Registration[]>(['registrations', eventId], (current) => current?.filter((registration) => registration.player.id !== playerId) ?? [])
if (!demoMode) void client.invalidateQueries({ queryKey: ['events'] })
},
})
if (rows.isLoading || players.isLoading) return <LoadingState label="Loading participants…" />
if (rows.isError || players.isError || !rows.data || !players.data) return <ErrorState retry={() => { void rows.refetch(); void players.refetch() }} />
const names = new Map(players.data.map((player) => [player.id, player.displayName]))
return <section className="card table-card"><form className="guest-participant-form" onSubmit={(event) => { event.preventDefault(); create.mutate() }}><div><h2>Add participant</h2><p>Create a guest player when they cannot sign in with Discord.</p></div><div className="guest-participant-fields"><label>Nickname<input required maxLength={80} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>{(['tank', 'damage', 'support'] as PlayerRole[]).map((role) => <label key={role}>{role === 'tank' ? 'Tank rank' : role === 'damage' ? 'Damage rank' : 'Support rank'}<select value={ratings[role]} onChange={(event) => setRatings((current) => ({ ...current, [role]: toRankOrdinal(Number(event.target.value)) }))}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>)}<label>Status<select value={status} onChange={(event) => setStatus(event.target.value as RsvpStatus)}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></label><button className="button primary" disabled={create.isPending || !displayName.trim()}>{create.isPending ? 'Adding…' : 'Add participant'}</button></div>{create.isError && <p className="error-note">{create.error.message}</p>}</form><div className="section-title"><div><h2>Participant responses</h2><p>Admin overrides are attributed and audited.</p></div><div className="count-pills"><Badge tone="success">{rows.data.filter((r) => r.status === 'going').length} going</Badge><Badge tone="warning">{rows.data.filter((r) => r.status === 'maybe').length} maybe</Badge></div></div><div className="participant-list">{rows.data.map((r) => <div className="participant-row" key={r.id}><div className="mini-avatar">{r.player.displayName.slice(0, 2).toUpperCase()}</div><div className="participant-name"><PlayerLink player={r.player} /><span>{r.player.preferredRoles.map((role) => role[0].toUpperCase() + role.slice(1)).join(' · ') || 'No preferred roles'}{r.player.preferredPlayerIds.length > 0 && <> · Preferred with {r.player.preferredPlayerIds.map((id) => names.get(id) ?? id.slice(0, 6)).join(', ')}</>}{r.changedBy?.source === 'admin' && <> · <em>set by {r.changedBy.displayName}</em></>}</span></div><div className="mini-ratings"><span>T · {rankLabel(r.player.ratings.tank, language)}</span><span>D · {rankLabel(r.player.ratings.damage, language)}</span><span>S · {rankLabel(r.player.ratings.support, language)}</span></div><select disabled={override.isPending || remove.isPending} aria-label={`RSVP for ${r.player.displayName}`} value={r.status} onChange={(e) => override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select><button className="icon-button remove-participant" type="button" disabled={remove.isPending} aria-label={`Remove ${r.player.displayName} from event`} title="Remove from event" onClick={() => remove.mutate(r.player.id)}><X /></button></div>)}</div>{(override.isError || remove.isError) && <p className="error-note">{override.error?.message ?? remove.error?.message}</p>}</section>
}
function BalanceView({ eventId }: { eventId: string }) {
if (demoMode) return <LegacyBalanceView eventId={eventId} />
return <WorkflowControl eventId={eventId} />
}
function WorkflowControl({ eventId }: { eventId: string }) {
const client = useQueryClient()
const event = useQuery({ queryKey: ['event', eventId], queryFn: () => api.event(eventId) })
const players = useQuery({ queryKey: ['players'], queryFn: api.players })
const roster = useQuery({
queryKey: ['roster', eventId],
queryFn: () => api.roster(eventId),
enabled: Boolean(event.data && ['RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'].includes(event.data.workflowState)),
retry: false,
})
const bracketDraft = useQuery({
queryKey: ['bracket-draft', eventId],
queryFn: () => api.bracketDraft(eventId),
enabled: event.data?.workflowState === 'BracketDraft',
retry: false,
})
const bracketTeams = useQuery({
queryKey: ['teams', eventId],
queryFn: () => api.teams(eventId),
enabled: event.data?.workflowState === 'BracketDraft',
})
const [candidates, setCandidates] = useState<Awaited<ReturnType<typeof api.generateWorkflowBalance>>['candidates']>([])
const [selectedCandidate, setSelectedCandidate] = useState(0)
const refresh = useCallback(() => {
void client.invalidateQueries({ queryKey: ['event', eventId] })
void client.invalidateQueries({ queryKey: ['events'] })
void client.invalidateQueries({ queryKey: ['roster', eventId] })
void client.invalidateQueries({ queryKey: ['teams', eventId] })
}, [client, eventId])
const close = useMutation({
mutationFn: () => api.closeRegistration(eventId, event.data!.rulesetId, event.data!.version),
onSuccess: refresh,
onError: refresh,
})
const generate = useMutation({
mutationFn: () => api.generateWorkflowBalance(eventId, event.data!.version),
onSuccess: (result) => {
setCandidates(result.candidates)
setSelectedCandidate(0)
refresh()
},
onError: refresh,
})
const select = useMutation({
mutationFn: () => api.selectWorkflowBalance(eventId, candidates[selectedCandidate], event.data!.version),
onSuccess: refresh,
onError: refresh,
})
const confirm = useMutation({
mutationFn: () => api.confirmRosters(eventId, event.data!.version, roster.data!.version),
onSuccess: refresh,
onError: refresh,
})
const initializeBracket = useMutation({
mutationFn: () => api.initializeBracket(eventId, event.data!.version),
onSuccess: (draft) => {
client.setQueryData(['bracket-draft', eventId], draft)
refresh()
},
onError: refresh,
})
const saveBracket = useMutation({
mutationFn: (matches: Parameters<typeof api.updateBracket>[1]) => api.updateBracket(eventId, matches, bracketDraft.data!.version),
onSuccess: (draft) => client.setQueryData(['bracket-draft', eventId], draft),
onError: () => void bracketDraft.refetch(),
})
const resetBracket = useMutation({
mutationFn: () => api.resetBracket(eventId, bracketDraft.data!.version),
onSuccess: (draft) => client.setQueryData(['bracket-draft', eventId], draft),
onError: () => void bracketDraft.refetch(),
})
const confirmBracket = useMutation({
mutationFn: () => api.confirmBracket(eventId, bracketDraft.data!.version),
onSuccess: (draft) => client.setQueryData(['bracket-draft', eventId], draft),
onError: () => void bracketDraft.refetch(),
})
const start = useMutation({
mutationFn: () => api.startScrim(eventId, event.data!.version),
onSuccess: (result) => {
refresh()
if (result.series) {
client.setQueryData(['series', result.series.id], result.series)
void router.navigate({ to: '/series/$seriesId', params: { seriesId: result.series.id } })
}
else if (result.tournament) void router.navigate({ to: '/bracket/$eventId', params: { eventId } })
},
onError: refresh,
})
const back = useMutation({
mutationFn: () => api.revertWorkflowStage(eventId, event.data!.version),
onSuccess: (updated) => {
if (updated.workflowState === 'RegistrationClosed') setCandidates([])
if (updated.workflowState === 'Balancing') client.removeQueries({ queryKey: ['roster', eventId] })
if (updated.workflowState === 'RostersConfirmed') client.removeQueries({ queryKey: ['bracket-draft', eventId] })
refresh()
},
onError: refresh,
})
if (event.isLoading || players.isLoading) return <LoadingState label="Loading scrim workflow…" />
if (event.isError || players.isError || !event.data || !players.data) return <ErrorState retry={() => { void event.refetch(); void players.refetch() }} />
const state = event.data.workflowState
const error = close.error ?? generate.error ?? select.error ?? confirm.error ?? initializeBracket.error ?? saveBracket.error ?? resetBracket.error ?? confirmBracket.error ?? start.error ?? back.error ?? bracketDraft.error ?? bracketTeams.error
return <div className="workflow-control">
<section className="card workflow-progress">
{(['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'] as const).map((step, index) => {
const activeIndex = ['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'].indexOf(state)
return <div className={index <= activeIndex ? 'done' : ''} key={step}><span>{index + 1}</span><strong>{step.replace(/([a-z])([A-Z])/g, '$1 $2')}</strong></div>
})}
</section>
{state !== 'RegistrationOpen' && state !== 'Live' && state !== 'Completed' && state !== 'Cancelled' && <div className="workflow-back"><button className="button secondary" disabled={back.isPending} onClick={() => back.mutate()}>{back.isPending ? 'Going back…' : '← Back'}</button><small>Return to the previous setup stage</small></div>}
{state === 'RegistrationOpen' && <WorkflowAction title="Close registration" description="Freeze the Going participant list before balancing." button="Close registration" pending={close.isPending} onClick={() => close.mutate()} />}
{state === 'RegistrationClosed' && <WorkflowAction title="Generate fair teams" description="The optimizer considers ranks, preferred roles, and preferred teammates." button="Generate balance" pending={generate.isPending} onClick={() => generate.mutate()} />}
{state === 'Balancing' && candidates.length === 0 && <WorkflowAction title="Generate balance candidates" description="Generate again after a refresh to choose one of the server candidates." button="Generate candidates" pending={generate.isPending} onClick={() => generate.mutate()} />}
{state === 'Balancing' && candidates.length > 0 && <div className="balance-layout"><aside className="candidate-list">{candidates.map((candidate, index) => <button className={`candidate-card ${selectedCandidate === index ? 'active' : ''}`} key={candidate.id} onClick={() => setSelectedCandidate(index)}><span className="candidate-rank">0{index + 1}</span><span><strong>Score {candidate.score}</strong><small>{candidate.explanations[0]}</small></span><ChevronRight /></button>)}</aside><section><div className="teams-grid">{candidates[selectedCandidate].teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} allowCaptainAssignment={false} />)}</div><div className="sticky-action"><p>Selecting creates a versioned roster draft.</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}>Use this balance</button></div></section></div>}
{roster.data && ['RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'].includes(state) && <WorkflowRosterEditor eventId={eventId} roster={roster.data} players={players.data} locked={state !== 'RostersDraft'} onChanged={refresh} />}
{state === 'RostersDraft' && roster.data && <WorkflowAction title="Confirm rosters" description="Every team must have a valid 1/2/2 lineup and a captain." button="Confirm rosters" pending={confirm.isPending} onClick={() => confirm.mutate()} />}
{state === 'RostersConfirmed' && <WorkflowAction title="Configure bracket" description="Build the match order and connect winner or loser feeds before starting." button="Open bracket editor" pending={initializeBracket.isPending} onClick={() => initializeBracket.mutate()} />}
{state === 'BracketDraft' && (bracketDraft.isLoading || bracketTeams.isLoading) && <LoadingState label="Loading bracket editor…" />}
{state === 'BracketDraft' && bracketDraft.data && bracketTeams.data && <BracketEditor draft={bracketDraft.data} teams={bracketTeams.data} pending={saveBracket.isPending || resetBracket.isPending || confirmBracket.isPending || start.isPending} error={error?.message} onSave={(matches) => saveBracket.mutate(matches)} onReset={() => resetBracket.mutate()} onConfirm={() => confirmBracket.mutate()} onStart={() => start.mutate()} />}
{state === 'Live' && <section className="card workflow-action"><div><h2>Scrim is live</h2><p>Coin toss, bans, and map results are now available.</p></div>{event.data.activeSeriesId ? <Link className="button primary" to="/series/$seriesId" params={{ seriesId: event.data.activeSeriesId }}>Open series</Link> : <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}</section>}
{state === 'Completed' && <section className="card workflow-action"><div><h2>Scrim completed</h2><p>The final result and full audit trail are preserved.</p></div>{event.data.tournamentId ? <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link> : event.data.activeSeriesId ? <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.data.activeSeriesId }}>Match history</Link> : null}</section>}
{error && <p className="error-note">{error.message}</p>}
</div>
}
function WorkflowAction({ title, description, button, pending, onClick }: { title: string; description: string; button: string; pending: boolean; onClick: () => void }) {
return <section className="card workflow-action"><div><h2>{title}</h2><p>{description}</p></div><button className="button primary" disabled={pending} onClick={onClick}>{pending ? 'Working…' : button}</button></section>
}
type RosterDrag = { kind: 'team'; teamId: string; playerId: string; role: 'Tank' | 'Damage' | 'Support' } | { kind: 'reserve'; playerId: string }
function WorkflowRosterEditor({ eventId, roster, players, locked, onChanged }: { eventId: string; roster: RawRosterDraft; players: Player[]; locked: boolean; onChanged: () => void }) {
const language = useLanguage()
const client = useQueryClient()
const names = new Map(players.map((player) => [player.id, player.displayName]))
const mutate = useMutation({
mutationFn: (action:
| { kind: 'swap'; teamAId: string; playerAId: string; teamBId: string; playerBId: string }
| { kind: 'substitute'; teamId: string; outgoingPlayerId: string; reservePlayerId: string }
| { kind: 'move'; fromTeamId: string; playerId: string; toTeamId: string; role: 'Tank' | 'Damage' | 'Support' }
| { kind: 'place'; teamId: string; reservePlayerId: string; role: 'Tank' | 'Damage' | 'Support' }
| { kind: 'remove'; teamId: string; playerId: string }
| { kind: 'captain'; teamId: string; playerId: string }) => {
if (action.kind === 'swap') return api.swapRoster(eventId, {
teamAId: action.teamAId, playerAId: action.playerAId, teamBId: action.teamBId, playerBId: action.playerBId, expectedVersion: roster.version,
})
if (action.kind === 'substitute') return api.substituteRoster(eventId, {
teamId: action.teamId, outgoingPlayerId: action.outgoingPlayerId, reservePlayerId: action.reservePlayerId, expectedVersion: roster.version,
})
if (action.kind === 'move') return api.moveRosterPlayer(eventId, {
fromTeamId: action.fromTeamId, playerId: action.playerId, toTeamId: action.toTeamId, role: action.role, expectedVersion: roster.version,
})
if (action.kind === 'place') return api.placeReservePlayer(eventId, {
teamId: action.teamId, reservePlayerId: action.reservePlayerId, role: action.role, expectedVersion: roster.version,
})
if (action.kind === 'remove') return api.removeRosterPlayer(eventId, {
teamId: action.teamId, playerId: action.playerId, expectedVersion: roster.version,
})
return api.setRosterCaptain(eventId, action.teamId, action.playerId, roster.version)
},
onSuccess: (updated) => {
client.setQueryData(['roster', eventId], updated)
onChanged()
},
onError: onChanged,
})
const startDrag = (event: DragEvent, payload: RosterDrag) => {
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('application/x-mixmaker-roster', JSON.stringify(payload))
}
const readDrag = (event: DragEvent): RosterDrag | undefined => {
try {
return JSON.parse(event.dataTransfer.getData('application/x-mixmaker-roster')) as RosterDrag
} catch {
return undefined
}
}
const dropOnSlot = (event: DragEvent, teamId: string, slot: RawRosterDraft['teams'][number]['slots'][number]) => {
event.preventDefault()
const source = readDrag(event)
if (!source || locked || mutate.isPending) return
if (source.kind === 'reserve') {
mutate.mutate(slot.playerId
? { kind: 'substitute', teamId, outgoingPlayerId: slot.playerId, reservePlayerId: source.playerId }
: { kind: 'place', teamId, reservePlayerId: source.playerId, role: slot.role })
} else if (source.playerId !== slot.playerId) {
mutate.mutate(slot.playerId
? { kind: 'swap', teamAId: source.teamId, playerAId: source.playerId, teamBId: teamId, playerBId: slot.playerId }
: { kind: 'move', fromTeamId: source.teamId, playerId: source.playerId, toTeamId: teamId, role: slot.role })
}
}
return <section className="card roster-editor">
<div className="section-title"><div><h2>Roster editor</h2><p>Version {roster.version} · drag players into any role slot or through reserve.</p></div>{roster.confirmed && <Badge tone="success"><Check />Locked</Badge>}</div>
<div className="roster-editor-body"><div className="teams-grid">{roster.teams.map((team) => <article className="workflow-team" key={team.id}><header><h3>{team.name}</h3><label>Captain<select disabled={locked || mutate.isPending} value={team.captainPlayerId} onChange={(event) => mutate.mutate({ kind: 'captain', teamId: team.id, playerId: event.target.value })}><option value="">Choose captain</option>{team.slots.filter((slot) => slot.playerId).map((slot) => <option value={slot.playerId} key={slot.playerId}>{names.get(slot.playerId) ?? slot.playerId}</option>)}</select></label></header>{team.slots.map((slot, slotIndex) => <div className={`roster-slot ${slot.playerId ? '' : 'empty'}`} key={`${slot.role}-${slotIndex}`} draggable={!locked && Boolean(slot.playerId)} onDragStart={(event) => { if (slot.playerId) startDrag(event, { kind: 'team', teamId: team.id, playerId: slot.playerId, role: slot.role }) }} onDragOver={(event) => { if (!locked) event.preventDefault() }} onDrop={(event) => dropOnSlot(event, team.id, slot)}><span className={`role-chip ${slot.role.toLowerCase()}`}>{slot.role[0]}</span>{slot.playerId ? <><strong>{names.get(slot.playerId) ?? slot.playerId}</strong><small>{rankLabel(slot.rating, language)}</small>{!locked && <button className="icon-button slot-remove" type="button" aria-label={`Move ${names.get(slot.playerId) ?? slot.playerId} to reserve`} onClick={() => mutate.mutate({ kind: 'remove', teamId: team.id, playerId: slot.playerId })}><X /></button>}</> : <><strong>Empty</strong><small>{slot.role}</small></>}</div>)}</article>)}</div>
<aside className="roster-reserve" onDragOver={(event) => { if (!locked) event.preventDefault() }} onDrop={(event) => { event.preventDefault(); const source = readDrag(event); if (!locked && source?.kind === 'team') mutate.mutate({ kind: 'remove', teamId: source.teamId, playerId: source.playerId }) }}><div><UsersRound /><span><strong>Reserve</strong><small>Drop a player here to remove them from a team</small></span></div><div className="reserve-list">{roster.reserve.length === 0 ? <p>Reserve is empty</p> : roster.reserve.map((id) => <button type="button" draggable={!locked} onDragStart={(event) => startDrag(event, { kind: 'reserve', playerId: id })} key={id}><span className="mini-avatar">{(names.get(id) ?? id).slice(0, 2).toUpperCase()}</span><strong>{names.get(id) ?? id}</strong></button>)}</div></aside>
</div>
{mutate.isError && <p className="error-note">{mutate.error.message}</p>}
</section>
}
function LegacyBalanceView({ eventId }: { eventId: string }) {
const client = useQueryClient()
const existingTeams = useQuery({ queryKey: ['teams', eventId], queryFn: () => api.teams(eventId), initialData: demoMode ? demoBalanceCandidates[0].teams : undefined })
const generate = useMutation({ mutationFn: () => api.generateBalance(eventId) })
const candidates = useMemo(() => generate.data ?? (demoMode ? demoBalanceCandidates : []), [generate.data])
const [selected, setSelected] = useState<string>()
useEffect(() => { if (!selected && candidates[0]) setSelected(candidates[0].id) }, [candidates, selected])
const candidate = candidates.find((item) => item.id === selected) ?? candidates[0]
const select = useMutation({
mutationFn: () => api.selectBalance(eventId, candidate),
onSuccess: () => void client.invalidateQueries({ queryKey: ['teams', eventId] }),
})
if (existingTeams.isLoading) return <LoadingState label="Loading teams…" />
if (!candidate) return <div className="state-card"><Sparkles /><h2>Generate balance candidates</h2><p>The backend uses confirmed Going players and returns three ranked alternatives.</p><button className="button primary" disabled={generate.isPending} onClick={() => generate.mutate()}>{generate.isPending ? 'Balancing…' : 'Generate on server'}</button>{generate.isError && <p className="error-note">{generate.error.message}</p>}{existingTeams.data && existingTeams.data.length > 0 && <div className="teams-grid">{existingTeams.data.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} />)}</div>}</div>
return <div className="balance-layout"><aside className="candidate-list"><div className="section-title"><div><h2>Server candidates</h2><p>Lower imbalance score is better</p></div><button className="icon-button" aria-label="Generate again" disabled={generate.isPending} onClick={() => generate.mutate()}><RefreshCw /></button></div>{candidates.map((item, i) => <button key={item.id} className={`candidate-card ${selected === item.id ? 'active' : ''}`} onClick={() => setSelected(item.id)}><span className="candidate-rank">0{i + 1}</span><span><strong>Score {item.score}</strong><small>Computed by backend balancer</small></span><ChevronRight /></button>)}</aside><section className="balance-main"><div className="balance-score card"><div><span className="eyebrow">Imbalance score</span><strong>{candidate.score}</strong></div><div className="balance-explanations"><span>Preference fit</span>{candidate.explanations.map((explanation) => <small key={explanation}><Check />{explanation}</small>)}</div><p>Lower scores represent closer team and role totals. The server remains the source of truth for roster validity.</p></div><div className="teams-grid">{candidate.teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} />)}</div><div className="reserve-bar card"><div><UsersRound /><span><strong>Reserve · {candidate.reserve.length}</strong><small>Not assigned to a complete 5v5 roster</small></span></div><div>{candidate.reserve.map((p) => <Badge key={p.id}>{p.displayName}</Badge>)}</div></div><div className="sticky-action"><p>{select.isError ? select.error.message : 'The server will save these rosters and unlock captain assignment.'}</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}><Check />{select.isPending ? 'Saving…' : 'Use this balance'}</button></div></section></div>
}
function TeamCard({ team, eventId, allowCaptainAssignment = true }: { team: Team; eventId: string; allowCaptainAssignment?: boolean }) {
const client = useQueryClient()
const language = useLanguage()
const [captain, setCaptain] = useState(team.captainId ?? '')
useEffect(() => setCaptain(team.captainId ?? ''), [team.captainId])
const assign = useMutation({
mutationFn: (playerId: string) => api.assignCaptain(team.id, playerId),
onSuccess: () => void client.invalidateQueries({ queryKey: ['teams', eventId] }),
})
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><strong className="team-rank">{rankLabel(team.averageRating, language)}<small> avg</small></strong></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><PlayerLink player={player} /><span>{rankLabel(player.ratings[assignedRole], language)}</span></div>)}</div>{allowCaptainAssignment && <label className="captain-select"><span><Trophy />Team captain</span><select value={captain} disabled={assign.isPending} onChange={(e) => { setCaptain(e.target.value); assign.mutate(e.target.value) }}><option value="">Select from roster</option>{team.members.map(({ player }) => <option value={player.id} key={player.id}>{player.displayName}</option>)}</select>{assign.isError && <small className="error-note">{assign.error.message}</small>}</label>}</article>
}
function PublicTeamCard({ team }: { team: Team }) {
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><Badge tone={team.side === 'alpha' ? 'warning' : 'blue'}>{team.members.length}/5</Badge></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><PlayerLink player={player} />{player.id === team.captainId && <Badge tone="neutral">Captain</Badge>}</div>)}</div></article>
}
function LiveSeriesPage() {
const { seriesId } = liveRoute.useParams()
const client = useQueryClient()
const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined })
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
const [selected, setSelected] = useState<string>()
const [outcome, setOutcome] = useState<MapOutcome>('TeamAWin')
const [heroRoleFilter, setHeroRoleFilter] = useState<'all' | PlayerRole>('all')
const [heroSearch, setHeroSearch] = useState('')
const previousTurn = useRef<(TurnSnapshot & { seriesId: string }) | undefined>(undefined)
const participant = Boolean(series.data && session.data && [series.data.teamAlpha, series.data.teamBeta].some((team) => team.members.some(({ player }) => player.id === session.data?.player.id)))
const playerSide = series.data && session.data
? series.data.teamAlpha.members.some(({ player }) => player.id === session.data?.player.id) ? 'alpha'
: series.data.teamBeta.members.some(({ player }) => player.id === session.data?.player.id) ? 'beta' : undefined
: undefined
useEffect(() => {
if (!series.data) return
const current = { seriesId, activeTeam: series.data.currentStep.activeTeam, version: series.data.version }
const previous = previousTurn.current?.seriesId === seriesId ? previousTurn.current : undefined
if (shouldNotifyTurn(previous, current, playerSide)) playTurnSound()
previousTurn.current = current
}, [playerSide, series.data, seriesId])
useEffect(() => {
if (series.data && session.data && !participant && !isStaff(session.data.account.role)) {
void router.navigate({ to: '/watch/$seriesId', params: { seriesId }, replace: true })
}
}, [participant, series.data, seriesId, session.data])
const result = useMutation({
mutationFn: () => {
const data = series.data!
if (demoMode) return api.recordResult(seriesId, `Map ${data.maps.length + 1}`, outcome, data.version)
const actingTeamId = isStaff(session.data?.account.role)
? data.teamAlpha.id
: data.teamAlpha.captainId === session.data?.player.id ? data.teamAlpha.id
: data.teamBeta.captainId === session.data?.player.id ? data.teamBeta.id : ''
return api.recordSeriesMap(seriesId, outcome, actingTeamId, data.version)
},
onSuccess: (updated) => {
client.setQueryData(['series', seriesId], updated)
},
onError: () => void series.refetch(),
})
const action = useMutation({
mutationFn: () => {
const data = series.data!
const teamId = data.currentStep.activeTeam === 'beta' ? data.teamBeta.id : data.teamAlpha.id
if (data.currentStep.kind === 'coin_toss') return api.tossSeriesCoin(seriesId, `${Date.now()}-${crypto.randomUUID()}`, teamId, data.version)
if (data.currentStep.kind === 'map_ban') return api.banSeriesMap(seriesId, teamId, selected!, data.version)
if (data.currentStep.kind === 'map_pick') return api.pickSeriesMap(seriesId, teamId, selected!, data.version)
if (data.currentStep.kind === 'hero_ban') return api.banSeriesHero(seriesId, teamId, selected!, data.version)
return Promise.resolve(data)
},
onSuccess: (updated) => {
client.setQueryData(['series', seriesId], updated)
setSelected(undefined)
},
onError: () => void series.refetch(),
})
if (series.isLoading || session.isLoading) return <LoadingState />
if ((series.isError && !series.data) || (session.isError && !session.data)) return <ErrorState message={series.error?.message ?? session.error?.message} retry={() => { void series.refetch(); void session.refetch() }} />
if (!series.data || !session.data) return <LoadingState />
const data = series.data
const activeSide = data.currentStep.activeTeam ?? 'alpha'
const activeTeam = activeSide === 'alpha' ? data.teamAlpha : data.teamBeta
const canAct = isStaff(session.data.account.role) || activeTeam.captainId === session.data.player.id
const canRecord = isStaff(session.data.account.role) || data.teamAlpha.captainId === session.data.player.id || data.teamBeta.captainId === session.data.player.id
const normalizedHeroSearch = heroSearch.trim().toLocaleLowerCase()
const visibleOptions = data.currentStep.kind === 'hero_ban'
? data.options.filter((option) => (heroRoleFilter === 'all' || option.role === heroRoleFilter) && option.name.toLocaleLowerCase().includes(normalizedHeroSearch))
: data.options
return <div className="page live-page">
<PageHeader eyebrow={`${data.roundLabel} · ${data.status}`} title="Series control" description={canAct || canRecord ? 'Actions are validated and recorded by the server.' : 'You are playing in this match. Captain actions are read-only for team members.'} actions={<Link className="button secondary" to="/watch/$seriesId" params={{ seriesId: data.id }}><Radio />Spectator view</Link>} />
<Scoreboard series={data} />
<div className="live-layout">
<section className={`turn-card team-${activeSide}`}>
<div className="turn-header"><div><span className="eyebrow">Current step</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p></div>{data.currentStep.activeTeam && <div className="turn-team"><span>Acting team</span><strong>{activeTeam.name}</strong></div>}</div>
{data.currentStep.kind === 'result' ? <form className="result-form" onSubmit={(event) => { event.preventDefault(); if (canRecord) result.mutate() }}><label>Outcome<select disabled={!canRecord} value={outcome} onChange={(event) => setOutcome(event.target.value as MapOutcome)}><option value="TeamAWin">{data.teamAlpha.name} wins</option><option value="TeamBWin">{data.teamBeta.name} wins</option><option value="Draw">Draw</option></select></label><button className="button team-action" disabled={!canRecord || result.isPending}>{canRecord ? result.isPending ? 'Recording…' : 'Record result' : 'Captain action'}</button>{result.isError && <p className="error-note">{result.error.message}</p>}</form>
: data.currentStep.kind === 'complete' ? <div className="turn-footer"><p><Check />Series completed and bracket updated.</p></div>
: <>{data.currentStep.kind === 'hero_ban' && <div className="hero-filter-tools"><label className="hero-search"><Search /><input type="search" value={heroSearch} placeholder="Search hero…" aria-label="Search hero" onChange={(event) => { setHeroSearch(event.target.value); setSelected(undefined) }} /></label><div className="hero-role-filter" role="tablist" aria-label="Filter heroes by role">{([['all', 'All'], ['tank', 'Tank'], ['damage', 'DPS'], ['support', 'Support']] as const).map(([role, label]) => <button type="button" role="tab" aria-selected={heroRoleFilter === role} className={heroRoleFilter === role ? 'active' : ''} key={role} onClick={() => { setHeroRoleFilter(role); setSelected(undefined) }}>{label}<small>{role === 'all' ? data.options.length : data.options.filter((option) => option.role === role).length}</small></button>)}</div></div>}<div className="draft-options" role="radiogroup" aria-label="Available draft choices">{visibleOptions.length === 0 ? <p className="empty-note">No heroes found.</p> : visibleOptions.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled || !canAct} className={selected === option.id ? 'selected' : ''} onClick={() => setSelected(option.id)}><span className={`role-chip ${option.role}`}>{option.role?.slice(0, 1).toUpperCase()}</span><strong>{option.name}</strong>{option.disabled ? <small>{option.disabledReason}</small> : <Check />}</button>)}</div><div className="turn-footer"><p><Shield />{canAct ? 'Draft commands use server-side validation.' : `Waiting for ${activeTeam.name} captain.`}</p><button className="button team-action" disabled={!canAct || (data.currentStep.kind !== 'coin_toss' && !selected) || action.isPending} onClick={() => action.mutate()}>{canAct ? action.isPending ? 'Confirming…' : data.currentStep.kind === 'coin_toss' ? 'Toss coin' : 'Confirm action' : 'Captain action'} <ChevronRight /></button></div>{action.isError && <p className="error-note">{action.error.message}</p>}</>}
</section>
<aside className="match-sidebar"><HeroBanDisplay series={data} /><SeriesBanHistory series={data} /><MapTimeline series={data} /><AuditLog series={data} /></aside>
</div>
</div>
}
function Scoreboard({ series }: { series: Series }) {
const controlPage = useRouterState({ select: (state) => state.location.pathname.startsWith('/series/') })
const session = useQuery({ queryKey: ['session'], queryFn: api.session, enabled: controlPage, staleTime: Infinity })
const alphaCaptain = series.teamAlpha.members.find(({ player }) => player.id === series.teamAlpha.captainId)?.player
const betaCaptain = series.teamBeta.members.find(({ player }) => player.id === series.teamBeta.captainId)?.player
const canRenameAlpha = controlPage && (isStaff(session.data?.account.role) || session.data?.player.id === series.teamAlpha.captainId)
const canRenameBeta = controlPage && (isStaff(session.data?.account.role) || session.data?.player.id === series.teamBeta.captainId)
return <><TournamentStrip series={series} /><section className="scoreboard" aria-label="Current series score"><div className="score-team alpha"><span>Team Alpha</span><h2>{series.teamAlpha.name}</h2><small>{alphaCaptain ? <>Captain · <PlayerLink player={alphaCaptain} /></> : 'Captain not assigned'}</small></div><div className="series-score"><span>Best of 3</span><strong>{series.score.alpha}<i>:</i>{series.score.beta}</strong><Badge tone="live"><Radio />{series.status}</Badge></div><div className="score-team beta"><span>Team Beta</span><h2>{series.teamBeta.name}</h2><small>{betaCaptain ? <>Captain · <PlayerLink player={betaCaptain} /></> : 'Captain not assigned'}</small></div></section>{(canRenameAlpha || canRenameBeta) && <div className="team-name-controls">{canRenameAlpha && <TeamNameEditor team={series.teamAlpha} seriesId={series.id} />}{canRenameBeta && <TeamNameEditor team={series.teamBeta} seriesId={series.id} />}</div>}</>
}
function TournamentStrip({ series }: { series: Series }) {
const bracket = useQuery({
queryKey: ['tournament', series.eventId],
queryFn: () => api.bracket(series.eventId),
enabled: Boolean(series.tournamentId),
initialData: demoMode && series.tournamentId ? demoBracket : undefined,
})
if (!series.tournamentId || !bracket.data) return null
const current = bracket.data.matches.find((match) => match.seriesId === series.id)
const activeRound = bracket.data.matches.find((match) => match.status === 'live')?.round
const activeMatches = bracket.data.matches.filter((match) => match.status === 'live' && match.round === activeRound && match.seriesId)
const currentIndex = activeMatches.findIndex((match) => match.seriesId === series.id)
const previous = currentIndex > 0 ? activeMatches[currentIndex - 1] : undefined
const next = currentIndex >= 0 && currentIndex < activeMatches.length - 1 ? activeMatches[currentIndex + 1] : activeMatches.find((match) => match.seriesId !== series.id)
const matchBody = (match: (typeof bracket.data.matches)[number]) => <><span>{match.teamAlpha ?? 'TBD'} <b>{match.scoreAlpha ?? '—'}</b></span><span>{match.teamBeta ?? 'TBD'} <b>{match.scoreBeta ?? '—'}</b></span></>
return <section className="tournament-strip">
<header><span><Trophy />Tournament bracket</span><Link to="/bracket/$eventId" params={{ eventId: series.eventId }}>Open full bracket</Link></header>
<div className="tournament-strip-body">
{previous?.seriesId ? <Link className="match-arrow" aria-label="Previous live match" to="/events/$eventId/live" params={{ eventId: series.eventId }} search={{ seriesId: previous.seriesId }}><ChevronLeft /></Link> : <span className="match-arrow disabled"><ChevronLeft /></span>}
<div className="mini-bracket">{bracket.data.rounds.map((round, roundIndex) => <div key={round}><small>{round}</small>{bracket.data.matches.filter((match) => match.round === roundIndex).map((match) => match.status === 'pending' || match.status === 'ready'
? <div className={`mini-match ${match.status}`} key={match.id}>{matchBody(match)}</div>
: <Link className={`mini-match ${match.seriesId === series.id ? 'current' : ''} ${match.status}`} key={match.id} to="/events/$eventId/live" params={{ eventId: series.eventId }} search={{ seriesId: match.seriesId }}>{matchBody(match)}</Link>)}</div>)}</div>
{next?.seriesId ? <Link className="match-arrow" aria-label="Next live match" to="/events/$eventId/live" params={{ eventId: series.eventId }} search={{ seriesId: next.seriesId }}><ChevronRight /></Link> : <span className="match-arrow disabled"><ChevronRight /></span>}
</div>
{current && <p>Current match · {current.label}</p>}
</section>
}
function TeamNameEditor({ team, seriesId }: { team: Team; seriesId: string }) {
const client = useQueryClient()
const [name, setName] = useState(team.name)
useEffect(() => setName(team.name), [team.name])
const rename = useMutation({
mutationFn: () => api.renameTeam(team.id, name),
onSuccess: (updated) => {
client.setQueryData<Series>(['series', seriesId], (current) => current ? {
...current,
teamAlpha: current.teamAlpha.id === updated.id ? { ...current.teamAlpha, name: updated.name } : current.teamAlpha,
teamBeta: current.teamBeta.id === updated.id ? { ...current.teamBeta, name: updated.name } : current.teamBeta,
} : current)
void client.invalidateQueries({ queryKey: ['teams'] })
},
})
return <form className="team-name-editor" onSubmit={(event) => { event.preventDefault(); rename.mutate() }}><label><span>Team name</span><input minLength={2} maxLength={32} required value={name} onChange={(event) => setName(event.target.value)} /></label><button className="button secondary" disabled={rename.isPending || name.trim() === team.name}>{rename.isPending ? 'Saving…' : 'Rename'}</button>{rename.isError && <small className="error-note">{rename.error.message}</small>}</form>
}
function HeroBanDisplay({ series }: { series: Series }) {
const activeMap = activeHeroBanMap(series)
if (!activeMap) return null
const bans = activeMap.heroBans
return <section className="card hero-ban-panel"><div className="section-title"><div><h2>Hero bans</h2><p>{activeMap.name}</p></div><Badge tone="warning">{bans.length}/4</Badge></div><div className="ban-display">{Array.from({ length: 4 }, (_, index) => bans[index] ? <div key={`${bans[index].hero}-${index}`}><span>Ban {index + 1}</span><strong>{bans[index].hero}</strong><small>{bans[index].role}</small></div> : <div className="pending-ban" key={`pending-${index}`}><span>Ban {index + 1}</span><strong>Pending</strong></div>)}</div></section>
}
function SeriesBanHistory({ series }: { series: Series }) {
const hasBans = series.heroBanHistory.alpha.length > 0 || series.heroBanHistory.beta.length > 0
if (!hasBans) return null
return <section className="card series-ban-history"><div className="section-title"><div><h2>Bo3 hero ban history</h2><p>A team cannot repeat its own hero ban.</p></div></div><div className="series-ban-teams">{([['alpha', series.teamAlpha], ['beta', series.teamBeta]] as const).map(([side, team]) => <div className={`team-${side}`} key={side}><strong>{team.name}</strong><div>{series.heroBanHistory[side].length > 0 ? series.heroBanHistory[side].map((hero) => <span key={hero}>{hero}</span>) : <small>No bans yet</small>}</div></div>)}</div></section>
}
function MapTimeline({ series }: { series: Series }) { return <section className="card map-timeline"><div className="section-title"><div><h2>Map timeline</h2><p>First to 2 wins</p></div></div>{series.maps.length === 0 ? <p className="empty-note">No results recorded.</p> : series.maps.map((map) => <div className={`map-row ${map.status}`} key={map.number}><span>0{map.number}</span><div><strong>{map.name}</strong><small>{map.mode} · {map.status}</small></div>{map.winner && <Badge tone={map.winner === 'alpha' ? 'warning' : 'blue'}>{map.winner === 'alpha' ? 'A' : map.winner === 'beta' ? 'B' : 'Draw'}</Badge>}</div>)}</section> }
function AuditLog({ series }: { series: Series }) { return <section className="card audit-log"><div className="section-title"><div><h2>Match history</h2><p>All server actions · newest first</p></div><Badge tone="neutral">{series.audit.length}</Badge></div>{series.audit.length === 0 ? <p className="empty-note">No actions yet.</p> : <div className="audit-log-list" tabIndex={0}>{[...series.audit].reverse().map((a) => <div key={a.id}><span className="history-dot" /><p><strong>{a.summary}</strong><small>{a.actorName} · {new Date(a.createdAt).toLocaleString(undefined, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}</small></p></div>)}</div>}</section> }
function SpectatorPage() {
const { seriesId } = spectatorRoute.useParams()
const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined })
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
if (series.isLoading || session.isLoading) return <LoadingState />
if (!series.data || !session.data) return <ErrorState message={series.error?.message ?? session.error?.message} retry={() => { void series.refetch(); void session.refetch() }} />
const data = series.data
const participant = [data.teamAlpha, data.teamBeta].some((team) => team.members.some(({ player }) => player.id === session.data.player.id))
const canOpenControl = participant || isStaff(session.data.account.role)
return <div className="page spectator-page"><PageHeader eyebrow="Spectator mode" title={data.roundLabel} description="Read-only live view · Updates automatically" actions={<>{canOpenControl && <Link className="button secondary" to="/series/$seriesId" params={{ seriesId: data.id }}><Shield />Series control</Link>}<Badge tone="success"><span className="live-dot" />Connected</Badge></>} /><Scoreboard series={data} /><section className="spectator-focus"><span className="eyebrow">{data.status}</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p></section><HeroBanDisplay series={data} /><SeriesBanHistory series={data} /><div className="spectator-grid"><MapTimeline series={data} /><AuditLog series={data} /></div></div>
}
function BracketPage() {
const { eventId } = bracketRoute.useParams()
const bracket = useQuery({ queryKey: ['tournament', eventId], queryFn: () => api.bracket(eventId), initialData: demoMode ? demoBracket : undefined })
if (bracket.isLoading) return <LoadingState label="Loading bracket…" />
if (bracket.isError || !bracket.data) return <ErrorState retry={() => void bracket.refetch()} />
const data = bracket.data
return <div className="page bracket-page"><PageHeader eyebrow="Single elimination" title={data.title} description="Winners advance automatically when the server completes a series." actions={<Badge tone="live"><Radio />{data.matches.filter((match) => match.status === 'live').length} live</Badge>} /><div className="bracket-scroll" tabIndex={0} aria-label="Tournament bracket">{data.rounds.map((round, ri) => <section className="bracket-round" key={round}><header><span>Round {ri + 1}</span><h2>{round}</h2></header><div className="bracket-matches">{data.matches.filter((m) => m.round === ri).map((m) => <article className={`bracket-match ${m.status}`} key={m.id}><div className="match-label"><span>{m.label}</span>{m.status === 'live' && <Badge tone="live">Live</Badge>}</div><div className={m.winner === 'alpha' ? 'winner' : ''}><span>{m.teamAlpha ?? 'TBD'}</span><strong>{m.scoreAlpha ?? '—'}</strong></div><div className={m.winner === 'beta' ? 'winner' : ''}><span>{m.teamBeta ?? 'TBD'}</span><strong>{m.scoreBeta ?? '—'}</strong></div>{m.seriesId && <Link to="/events/$eventId/live" params={{ eventId }} search={{ seriesId: m.seriesId }}>Open series <ChevronRight /></Link>}</article>)}</div></section>)}</div><p className="bracket-help"><ChevronRight />Scroll horizontally to follow the bracket on smaller screens.</p></div>
}
function App() {
return <QueryClientProvider client={queryClient}><AppRouter /></QueryClientProvider>
}
function AppRouter() {
if (window.location.pathname === '/login') {
return <RouterProvider router={router} context={{ session: null }} />
}
return <SessionRouter />
}
function SessionRouter() {
const session = useQuery({
queryKey: ['session'],
queryFn: api.session,
initialData: demoMode ? demoSession : undefined,
retry: false,
})
const context = useMemo(() => ({ session: session.data ?? null }), [session.data])
if (session.isLoading) return <LoadingState label="Restoring your session…" />
return <RouterProvider router={router} context={context} />
}
export default App