Add player profiles and community settings
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 21:43:48 +03:00
parent 5e0825f43f
commit fa4edc0df5
23 changed files with 1280 additions and 57 deletions

View File

@@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import App from './App'
import { demoSeries } from './api/demo'
import { LanguageProvider } from './i18n'
import { activeHeroBanMap } from './series-utils'
describe('Mixmaker frontend', () => {
@@ -12,7 +13,7 @@ describe('Mixmaker frontend', () => {
it('renders the event dashboard', async () => {
window.history.replaceState({}, '', '/events')
render(<App />)
render(<LanguageProvider><App /></LanguageProvider>)
expect(await screen.findByRole('heading', { name: 'Your events' })).toBeInTheDocument()
expect(screen.getByText('Saturday Night Scrim')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Open Summer Clash #4' })).toHaveClass('status-live')
@@ -23,13 +24,13 @@ describe('Mixmaker frontend', () => {
it('renders the Discord login screen', async () => {
window.history.replaceState({}, '', '/login')
render(<App />)
render(<LanguageProvider><App /></LanguageProvider>)
expect(await screen.findByRole('link', { name: /continue with discord/i })).toHaveAttribute('href', '/api/auth/discord')
})
it('locks RSVP and exposes live navigation after the scrim starts', async () => {
window.history.replaceState({}, '', '/events/event-3')
render(<App />)
render(<LanguageProvider><App /></LanguageProvider>)
expect(await screen.findByRole('link', { name: /^open live$/i })).toHaveAttribute('href', '/events/event-3/live')
expect(screen.getByRole('heading', { name: 'Balanced teams' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Ember Wolves' })).toBeInTheDocument()
@@ -70,5 +71,42 @@ describe('Mixmaker frontend', () => {
expect(await screen.findByRole('heading', { name: 'Frequently asked questions' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Who picks later maps?' })).toBeInTheDocument()
expect(screen.getByText(/team that lost that map immediately chooses/i)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /open discord/i })).toHaveAttribute('href', 'https://discord.gg/mixmaker')
})
it('opens linked players and renders profile stats and history', async () => {
window.history.replaceState({}, '', '/players/p2')
render(<LanguageProvider><App /></LanguageProvider>)
expect(await screen.findByRole('heading', { name: 'Nova', level: 1 })).toBeInTheDocument()
expect(screen.getByText('Nova#1002')).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'Player statistics' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Recent matches' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Summer Clash #4/i })).toHaveAttribute('href', '/watch/series-1')
})
it('links player names without wrapping RSVP controls', async () => {
window.history.replaceState({}, '', '/events/event-3')
render(<App />)
const nova = await screen.findAllByRole('link', { name: 'Nova' })
expect(nova.some((link) => link.getAttribute('href') === '/players/p2')).toBe(true)
expect(screen.getByRole('button', { name: 'Going' })).toBeDisabled()
})
it('exposes community settings to administrators', async () => {
window.history.replaceState({}, '', '/admin')
render(<LanguageProvider><App /></LanguageProvider>)
fireEvent.click(await screen.findByRole('tab', { name: 'Community' }))
expect(screen.getByRole('heading', { name: 'Community Discord' })).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: 'Discord invite URL' })).toHaveValue('https://discord.gg/mixmaker')
})
it('edits player identity', async () => {
window.history.replaceState({}, '', '/profile')
render(<LanguageProvider><App /></LanguageProvider>)
const nickname = await screen.findByRole('textbox', { name: 'Nickname' })
fireEvent.change(nickname, { target: { value: 'Lemintare Prime' } })
fireEvent.change(screen.getByRole('textbox', { name: /BattleTag/i }), { target: { value: 'Lemi#2026' } })
fireEvent.click(screen.getByRole('button', { name: 'Save profile' }))
expect(await screen.findByText('Saved')).toBeInTheDocument()
})
})

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type FormEvent, type ReactNode } from 'react'
import {
CalendarDays, Check, ChevronLeft, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut,
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'
@@ -11,10 +11,10 @@ import {
} from '@tanstack/react-router'
import {
api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent,
type Player, type PlayerRole, type RawAccount, type RawRosterDraft, type Registration, type RsvpStatus, type Series, type Session, type Team,
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, demoEvents, demoProfile,
demoBalanceCandidates, demoBracket, demoCommunitySettings, demoEvents, demoPlayerProfiles, demoProfile,
demoMode, demoRegistrations, demoSeries, demoSession,
} from './api/demo'
import { LanguageSelector } from './i18n'
@@ -35,6 +35,7 @@ const protectedRoute = createRoute({
})
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 })
@@ -52,7 +53,7 @@ const adminRoute = createRoute({
beforeLoad: ({ context }) => { if (!isStaff(context.session?.account.role)) throw redirect({ to: '/events' }) },
})
const routeTree = rootRoute.addChildren([loginRoute, protectedRoute.addChildren([
indexRoute, profileRoute, faqRoute, eventsRoute, eventRoute, liveEntryRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute,
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 } }
@@ -118,7 +119,7 @@ function AppShell() {
void router.navigate({ to: '/login' })
},
})
const initials = session.data?.account.displayName.slice(0, 2).toUpperCase() ?? 'MM'
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
@@ -148,6 +149,9 @@ function PageHeader({ eyebrow, title, description, actions }: { eyebrow: string;
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')
@@ -221,7 +225,7 @@ function EventPage() {
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><strong>{registration.player.displayName}</strong><Badge tone={registration.status === 'going' ? 'success' : registration.status === 'maybe' ? 'warning' : 'neutral'}>{labels[registration.status]}</Badge></div>)}</div>}</section></div>
</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() {
@@ -260,7 +264,9 @@ function LiveEntryPage() {
}
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>
@@ -272,6 +278,44 @@ function FAQPage() {
</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()
@@ -279,11 +323,15 @@ function ProfilePage() {
const profile = useQuery({ queryKey: ['profile'], queryFn: api.profile, initialData: demoMode ? demoProfile : undefined })
const players = useQuery({ queryKey: ['players'], queryFn: api.players, initialData: demoMode ? demoRegistrations.map((registration) => registration.player) : 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)
@@ -298,7 +346,9 @@ function ProfilePage() {
}
}, [players.data])
const update = useMutation({
mutationFn: () => api.updateRatings(profile.data!.displayName, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds),
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'] })
@@ -310,10 +360,10 @@ function ProfilePage() {
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">{profile.data.displayName.slice(0, 2).toUpperCase()}</div><div><h2>{profile.data.displayName}</h2><p>{profile.data.battleTag}</p><Badge tone="blue"><Gamepad2 />Connected via Discord</Badge></div></section><form className="card ratings-form" onSubmit={submit}><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}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save profile'}</button></div></form></div>
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'
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')
@@ -324,8 +374,23 @@ function AdminPage() {
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] : [])]
return <div className="page"><PageHeader eyebrow={session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin control room'} title={tab === 'moderators' ? 'Moderator access' : createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={tab === 'moderators' ? 'Grant moderation access to trusted Discord users.' : selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={tab !== 'moderators' ? <>{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 && item !== 'events' && item !== 'moderators'} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : item === 'balance' ? 'Balance teams' : '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 />}</div>
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() {
@@ -478,7 +543,7 @@ function Participants({ eventId }: { eventId: string }) {
if (rows.isLoading) return <LoadingState label="Loading participants…" />
if (rows.isError || !rows.data) return <ErrorState retry={() => void rows.refetch()} />
const names = new Map(rows.data.map((registration) => [registration.player.id, registration.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"><strong>{r.player.displayName}</strong><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>
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 }) {
@@ -712,11 +777,11 @@ function TeamCard({ team, eventId, allowCaptainAssignment = true }: { team: Team
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><strong>{player.displayName}</strong><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>
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><strong>{player.displayName}</strong>{player.id === team.captainId && <Badge tone="neutral">Captain</Badge>}</div>)}</div></article>
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() {
@@ -807,11 +872,11 @@ function LiveSeriesPage() {
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.displayName
const betaCaptain = series.teamBeta.members.find(({ player }) => player.id === series.teamBeta.captainId)?.player.displayName
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 · ${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 · ${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>}</>
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 }) {

View File

@@ -5,6 +5,7 @@ const player: RawPlayer = {
id: 'player-1',
accountId: 'account-1',
displayName: 'Nova',
battleTag: 'Nova#1234',
ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['Tank'],
preferredPlayerIds: ['player-2'],
@@ -19,6 +20,7 @@ describe('backend DTO adapters', () => {
it('adapts backend role ratings without changing their values', () => {
expect(adaptPlayer(player)).toMatchObject({
displayName: 'Nova',
battleTag: 'Nova#1234',
ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['tank'],
preferredPlayerIds: ['player-2'],
@@ -118,10 +120,38 @@ describe('actual backend routes', () => {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await api.updateRatings('Nova', { tank: 37, damage: 29, support: 25 }, ['tank', 'damage'], ['player-2'], ['player-3'])
await api.updateRatings('Nova Prime', 'Nova#5678', { tank: 37, damage: 29, support: 25 }, ['tank', 'damage'], ['player-2'], ['player-3'])
expect(fetchMock).toHaveBeenCalledWith('/api/me/player', expect.objectContaining({ method: 'PATCH' }))
expect(fetchMock.mock.calls[0][1]?.body).toContain('"preferredRoles":["Tank","Damage"]')
expect(fetchMock.mock.calls[0][1]?.body).toContain('"avoidedPlayerIds":["player-3"]')
expect(fetchMock.mock.calls[0][1]?.body).toContain('"displayName":"Nova Prime"')
expect(fetchMock.mock.calls[0][1]?.body).toContain('"battleTag":"Nova#5678"')
})
it('loads public profile history and updates community settings', async () => {
const publicProfile = {
player: {
id: player.id,
displayName: player.displayName,
battleTag: player.battleTag,
ratings: player.ratings,
isGuest: false,
},
summary: { seriesPlayed: 3, seriesWon: 2, seriesLost: 1, winRate: 2 / 3, mapsPlayed: 7, mapsWon: 4, mapsLost: 2, mapsDrawn: 1 },
recentMatches: [{ seriesId: 'series-1', eventId: 'event-1', eventName: 'Cup', eventDate: '2026-07-18T00:00:00Z', ownTeamId: 'a', ownTeamName: 'A', opponentTeamId: 'b', opponentTeamName: 'B', ownScore: 2, opponentScore: 1, outcome: 'win' }],
}
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response(JSON.stringify(publicProfile), { status: 200, headers: { 'Content-Type': 'application/json' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({ discordInviteUrl: 'https://discord.gg/test' }), { status: 200, headers: { 'Content-Type': 'application/json' } }))
const profile = await api.playerProfile(player.id)
expect(profile.stats.mapsDrawn).toBe(1)
expect(profile.recentMatches[0].outcome).toBe('win')
await api.updateCommunitySettings('https://discord.gg/test')
expect(fetchMock.mock.calls[0][0]).toBe('/api/players/player-1')
expect(fetchMock.mock.calls[1]).toEqual(['/api/community/settings', expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ discordInviteUrl: 'https://discord.gg/test' }),
})])
})
it('sends backend RSVP enum values to the player-specific route', async () => {

View File

@@ -18,6 +18,35 @@ export interface Player {
preferredPlayerIds: string[]
avoidedPlayerIds: string[]
}
export interface PlayerStats {
seriesPlayed: number
seriesWon: number
seriesLost: number
winRate: number
mapsPlayed: number
mapsWon: number
mapsLost: number
mapsDrawn: number
}
export interface RecentMatch {
seriesId: string
eventId: string
eventName: string
startedAt: string
teamId: string
teamName: string
opponentTeamId: string
opponentTeamName: string
scoreFor: number
scoreAgainst: number
outcome: 'win' | 'loss'
}
export interface PublicPlayerProfile extends Player {
isGuest: boolean
stats: PlayerStats
recentMatches: RecentMatch[]
}
export interface CommunitySettings { discordInviteUrl: string }
export interface Session {
account: { id: string; discordId: string; displayName: string; avatarUrl?: string; role: AccountRole }
player: Player
@@ -154,13 +183,36 @@ export interface RawAccount {
}
export interface RawRatings { tank: number; damage: number; support: number }
export interface RawPlayer {
id: string; accountId: string; displayName: string; ratings: RawRatings
id: string; accountId: string; displayName: string; battleTag?: string; ratings: RawRatings
preferredRoles?: Array<'Tank' | 'Damage' | 'Support'>
preferredPlayerIds?: string[]
avoidedPlayerIds?: string[]
createdAt: string; updatedAt: string
}
export interface RawSession { account: RawAccount; player: RawPlayer }
export interface RawPlayerProfile {
player: {
id: string
displayName: string
battleTag: string
ratings: RawRatings
isGuest: boolean
}
summary: PlayerStats
recentMatches: Array<{
seriesId: string
eventId: string
eventName: string
eventDate: string
ownTeamId: string
ownTeamName: string
opponentTeamId: string
opponentTeamName: string
ownScore: number
opponentScore: number
outcome: 'win' | 'loss'
}>
}
export interface RawEvent {
id: string; name: string; description: string; startsAt: string; endsAt: string
registrationDeadline: string; createdBy: string; createdAt: string; updatedAt: string
@@ -223,6 +275,7 @@ export function adaptPlayer(raw: RawPlayer): Player {
id: raw.id,
accountId: raw.accountId,
displayName: raw.displayName,
battleTag: raw.battleTag || undefined,
ratings,
preferredRoles,
preferredPlayerIds: raw.preferredPlayerIds ?? [],
@@ -529,6 +582,7 @@ export const api = {
profile: async () => adaptPlayer((await raw.session()).player),
updateRatings: async (
displayName: string,
battleTag: string,
ratings: Pick<Ratings, PlayerRole>,
preferredRoles: PlayerRole[],
preferredPlayerIds: string[],
@@ -538,13 +592,50 @@ export const api = {
method: 'PATCH',
body: JSON.stringify({
displayName,
battleTag: battleTag.trim(),
ratings: { tank: ratings.tank, damage: ratings.damage, support: ratings.support },
preferredRoles: preferredRoles.map((role) => role === 'tank' ? 'Tank' : role === 'damage' ? 'Damage' : 'Support'),
preferredPlayerIds,
avoidedPlayerIds,
}),
})),
playerProfile: async (playerId: string): Promise<PublicPlayerProfile> => {
const profile = await request<RawPlayerProfile>(`/players/${playerId}`)
return {
id: profile.player.id,
accountId: profile.player.isGuest ? undefined : 'linked',
displayName: profile.player.displayName,
battleTag: profile.player.battleTag || undefined,
ratings: {
tank: toRankOrdinal(profile.player.ratings.tank),
damage: toRankOrdinal(profile.player.ratings.damage),
support: toRankOrdinal(profile.player.ratings.support),
updatedAt: '',
},
preferredRoles: [],
preferredPlayerIds: [],
avoidedPlayerIds: [],
isGuest: profile.player.isGuest,
stats: profile.summary,
recentMatches: profile.recentMatches.map((match) => ({
seriesId: match.seriesId,
eventId: match.eventId,
eventName: match.eventName,
startedAt: match.eventDate,
teamId: match.ownTeamId,
teamName: match.ownTeamName,
opponentTeamId: match.opponentTeamId,
opponentTeamName: match.opponentTeamName,
scoreFor: match.ownScore,
scoreAgainst: match.opponentScore,
outcome: match.outcome,
})),
} satisfies PublicPlayerProfile
},
players: async () => (await raw.players()).map(adaptPlayer),
communitySettings: () => request<CommunitySettings>('/community/settings'),
updateCommunitySettings: (discordInviteUrl: string) =>
request<CommunitySettings>('/community/settings', { method: 'PUT', body: JSON.stringify({ discordInviteUrl }) }),
accounts: () => request<RawAccount[]>('/accounts'),
setModerator: (accountId: string, moderator: boolean) =>
request<RawAccount>(`/accounts/${accountId}/moderator`, { method: 'PATCH', body: JSON.stringify({ moderator }) }),

View File

@@ -4,6 +4,7 @@ import type {
BracketDraft,
MixEvent,
Player,
PublicPlayerProfile,
Registration,
Series,
Session,
@@ -307,3 +308,50 @@ export const demoBracketDraft: BracketDraft = {
{ id: 'draft-m3', round: 2, order: 0, slotA: { kind: 'Winner', matchId: 'draft-m1' }, slotB: { kind: 'Winner', matchId: 'draft-m2' } },
],
}
export const demoCommunitySettings = {
discordInviteUrl: 'https://discord.gg/mixmaker',
}
export const demoPlayerProfiles: PublicPlayerProfile[] = players.map((player, index) => ({
...player,
isGuest: !player.accountId,
stats: {
seriesPlayed: 18 + index,
seriesWon: 11 + (index % 4),
seriesLost: 7 + (index % 3),
winRate: Math.round(((11 + (index % 4)) / (18 + index)) * 100),
mapsPlayed: 42 + index * 2,
mapsWon: 25 + index,
mapsLost: 14 + index,
mapsDrawn: 3,
},
recentMatches: [
{
seriesId: 'series-1',
eventId: 'event-3',
eventName: 'Summer Clash #4',
startedAt: daysFromNow(-2, 20),
teamId: 'team-alpha',
teamName: 'Ember Wolves',
opponentTeamId: 'team-beta',
opponentTeamName: 'Azure Phantoms',
scoreFor: 2,
scoreAgainst: 1,
outcome: 'win',
},
{
seriesId: 'series-2',
eventId: 'event-5',
eventName: 'Completed Friday Mix',
startedAt: daysFromNow(-7, 20),
teamId: 'team-alpha',
teamName: 'Ember Wolves',
opponentTeamId: 'team-beta',
opponentTeamName: 'Neon Foxes',
scoreFor: 1,
scoreAgainst: 2,
outcome: 'loss',
},
],
}))

View File

@@ -88,6 +88,29 @@ const translations: Record<string, string> = {
'Set your competitive ranks': 'Укажите соревновательные ранги',
'Your role ranks help the server build fair, role-locked teams.': 'Ранги по ролям помогают серверу создавать честные команды.',
'Connected via Discord': 'Подключено через Discord',
'Edit profile': 'Редактировать профиль',
'Loading player profile…': 'Загрузка профиля игрока…',
'This player profile could not be found.': 'Профиль этого игрока не найден.',
'Community player': 'Игрок сообщества',
'Discord player': 'Игрок Discord',
'Guest player': 'Гостевой игрок',
'Role ranks': 'Ранги по ролям',
'Current competitive ratings': 'Текущие соревновательные рейтинги',
'Competitive history and role ranks.': 'История матчей и ранги по ролям.',
'Player statistics': 'Статистика игрока',
'Series played': 'Серий сыграно',
'Series won': 'Серий выиграно',
'Series lost': 'Серий проиграно',
'Win rate': 'Процент побед',
'Map record': 'Статистика карт',
'Won': 'Победы',
'Lost': 'Поражения',
'Drawn': 'Ничьи',
'Recent matches': 'Недавние матчи',
'Latest completed series': 'Последние завершённые серии',
'No completed matches yet.': 'Завершённых матчей пока нет.',
'win': 'победа',
'loss': 'поражение',
'Role ratings': 'Рейтинги ролей',
'Use the full 1100 range honestly.': 'Честно используйте весь диапазон от 1 до 100.',
'Competitive ranks': 'Соревновательные ранги',
@@ -120,6 +143,15 @@ const translations: Record<string, string> = {
'Event setup': 'Настройки события',
'Participants': 'Участники',
'Balance teams': 'Баланс команд',
'Community': 'Сообщество',
'Community settings': 'Настройки сообщества',
'Manage the links shown to every community member.': 'Управляйте ссылками, доступными всем участникам сообщества.',
'Loading community settings…': 'Загрузка настроек сообщества…',
'Community Discord': 'Discord сообщества',
'This invite is shown in FAQ to signed-in players.': 'Это приглашение показывается авторизованным игрокам в FAQ.',
'Discord invite URL': 'Ссылка-приглашение Discord',
'Leave empty to hide the invite button.': 'Оставьте поле пустым, чтобы скрыть кнопку приглашения.',
'Save settings': 'Сохранить настройки',
'Event settings': 'Настройки события',
'Scheduling and registration': 'Расписание и регистрация',
'Published': 'Опубликовано',
@@ -148,6 +180,9 @@ const translations: Record<string, string> = {
'Add participant': 'Добавить участника',
'Create a guest player when they cannot sign in with Discord.': 'Создайте гостевого игрока, если он не может войти через Discord.',
'Nickname': 'Ник',
'BattleTag': 'BattleTag',
'Optional': 'Необязательно',
'Nicknames are unique across the community.': 'Ники уникальны в пределах сообщества.',
'Tank rank': 'Ранг танка',
'Damage rank': 'Ранг урона',
'Support rank': 'Ранг поддержки',
@@ -287,6 +322,9 @@ const translations: Record<string, string> = {
'Rules and help': 'Правила и помощь',
'Frequently asked questions': 'Частые вопросы',
'Everything players and captains need before the scrim starts.': 'Всё, что нужно знать игрокам и капитанам до начала скрима.',
'Join the community Discord': 'Присоединяйтесь к Discord сообщества',
'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.': 'В Discord приходят анонсы событий, обновления ролей и уведомления о матчах. Присоединяйтесь к серверу и включите роли бота, уведомления от которых хотите получать.',
'Open Discord': 'Открыть Discord',
'How are teams balanced?': 'Как балансируются команды?',
'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.': 'Сервер собирает команды 1 танк / 2 урона / 2 поддержки. Главная цель — баланс рангов. Предпочитаемые роли, напарники и до трёх avoid-игроков являются мягкими пожеланиями и не важнее честного корректного состава.',
'How does registration work?': 'Как работает регистрация?',
@@ -312,6 +350,7 @@ function translate(value: string) {
[/^(\d+) scheduled events$/, (m) => `${m[1]} запланированных событий`],
[/^(\d+) going$/, (m) => `${m[1]} идут`],
[/^(\d+) responses$/, (m) => `${m[1]} ответов`],
[/^(\d+) maps played$/, (m) => `${m[1]} карт сыграно`],
[/^Updated (.+)$/, (m) => `Обновлено ${m[1]}`],
[/^Registration closes (.+)\.$/, (m) => `Регистрация закрывается ${m[1]}.`],
[/^Score (\d+)$/, (m) => `Оценка ${m[1]}`],

View File

@@ -152,6 +152,33 @@ main { min-height: calc(100vh - 72px); }
.profile-banner { padding: 24px; display: flex; align-items: center; gap: 18px; margin-bottom: 16px; }
.large-avatar { width: 70px; height: 70px; font-size: 21px; }
.profile-banner h2 { font-size: 21px; }.profile-banner p { color: var(--muted); margin: 3px 0 10px; font-size: 12px; }
.player-link { color: #f5f7f9; font-weight: 750; transition: color .15s; }
.player-link:hover { color: var(--orange); text-decoration: underline; text-underline-offset: 3px; }
.identity-fields { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 14px; padding-bottom: 22px; }
.identity-fields label,.community-settings label { display: grid; gap: 7px; color: #b6bcc4; font-size: 11px; font-weight: 700; }
.identity-fields label small { color: var(--muted); font-weight: 500; }
.identity-fields > p { grid-column: 1/-1; color: var(--muted); font-size: 10px; }
.player-profile-page { max-width: 1050px; }
.profile-identity { display: flex; align-items: center; gap: 18px; padding: 22px; margin-bottom: 14px; }
.profile-identity h2 { font-size: 22px; }.profile-identity p { margin: 4px 0 9px; color: var(--muted); font-size: 12px; }
.public-profile-grid { display: grid; grid-template-columns: 1fr 1.65fr; gap: 14px; }
.profile-ranks { padding: 20px; }.profile-ranks .section-title { margin-top: 0; }
.profile-rank { display: flex; align-items: center; gap: 11px; padding: 12px 0; border-top: 1px solid var(--border); }
.profile-rank > span:last-child { display: grid; gap: 3px; }.profile-rank small { color: var(--muted); font-size: 9px; text-transform: uppercase; }.profile-rank strong { font-size: 14px; }
.profile-stat-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 10px; }
.profile-stat-grid > div { min-height: 120px; padding: 20px; display: flex; flex-direction: column; justify-content: space-between; }
.profile-stat-grid span { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .1em; }.profile-stat-grid strong { font-size: 32px; }
.profile-stat-grid .highlight { border-color: #71401f; background: linear-gradient(145deg,var(--orange-soft),var(--surface)); }.profile-stat-grid .highlight strong { color: var(--orange); }
.map-record,.recent-matches { margin-top: 14px; padding: 20px; }.map-record .section-title,.recent-matches .section-title { margin-top: 0; }
.map-record > div:last-child { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; }
.map-record > div:last-child span { padding: 14px; display: flex; align-items: baseline; gap: 8px; border: 1px solid var(--border); border-radius: 7px; color: var(--muted); font-size: 10px; }.map-record > div:last-child strong { color: #fff; font-size: 23px; }
.recent-match { min-height: 72px; padding: 12px 0; display: grid; grid-template-columns: minmax(180px,1fr) minmax(300px,1.4fr) auto; align-items: center; gap: 16px; border-top: 1px solid var(--border); }
.recent-match > div:first-child { display: flex; align-items: center; gap: 10px; }.recent-match > div:first-child > span { display: grid; gap: 4px; }.recent-match small { color: var(--muted); font-size: 9px; }
.recent-match-score { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 10px; text-align: center; }.recent-match-score span { color: var(--muted); font-size: 10px; }.recent-match-score strong { font-size: 20px; }.recent-match-score i { padding: 0 5px; color: var(--muted); font-style: normal; }
.recent-match > svg { color: var(--orange); }
.community-faq { grid-column: 1/-1; min-height: 140px; border-color: #334073; background: linear-gradient(135deg,#171d3c,var(--surface)); }
.community-faq > span svg { color: #7289da; width: 20px; height: 20px; }.community-faq .button { margin-top: 14px; }
.community-settings { max-width: 720px; padding: 24px; }.community-settings .section-title { margin-top: 0; }.community-settings .section-title > svg { color: #7289da; width: 25px; height: 25px; }
.ratings-form { padding: 28px; }
.ratings-form > .section-title { margin-top: 0; }
.ratings-form > .section-title > span { color: var(--muted); font-size: 11px; }
@@ -315,7 +342,8 @@ textarea { min-height: 85px; resize: vertical; }
.event-card { grid-template-columns: 70px 1fr; }.date-block strong { font-size: 28px; }.event-card-body { padding: 17px; }
.detail-layout { gap: 10px; }.event-hero,.roster-preview,.public-roster { padding: 20px; }.rsvp-control { grid-template-columns: 1fr; }.rsvp-control button { justify-content: flex-start; padding-left: 18px; }.public-roster-grid { grid-template-columns: 1fr; }
.faq-grid { grid-template-columns: 1fr; }.faq-card { min-height: 0; }
.profile-banner { padding: 18px; }.ratings-form { padding: 18px; }.rating-row { grid-template-columns: 1fr; }.rank-select { min-height: 46px; }.form-footer { align-items: stretch; flex-direction: column; }.form-footer .button { width: 100%; }
.profile-banner { padding: 18px; }.ratings-form { padding: 18px; }.identity-fields,.public-profile-grid { grid-template-columns: 1fr; }.rating-row { grid-template-columns: 1fr; }.rank-select { min-height: 46px; }.form-footer { align-items: stretch; flex-direction: column; }.form-footer .button { width: 100%; }
.profile-stat-grid { grid-template-columns: repeat(2,1fr); }.profile-stat-grid > div { min-height: 100px; padding: 15px; }.map-record > div:last-child { grid-template-columns: 1fr; }.recent-match { grid-template-columns: 1fr auto; }.recent-match-score { grid-column: 1/-1; grid-row: 2; }.recent-match > svg { grid-column: 2; grid-row: 1; }
.teammate-grid { grid-template-columns: repeat(2,1fr); }
.settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid,.guest-participant-fields { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px 36px; padding: 9px 0; }.mini-ratings { display: none; }
.candidate-list { grid-template-columns: 1fr; }.teams-grid { grid-template-columns: 1fr; }.balance-score { grid-template-columns: 1.4fr 1fr 1fr; padding: 15px; gap: 10px; }.reserve-bar,.sticky-action { align-items: stretch; flex-direction: column; }.sticky-action .button { width: 100%; }