|
|
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
|
|
} from '@tanstack/react-router'
|
|
|
|
|
import {
|
|
|
|
|
api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent,
|
|
|
|
|
type PlayerRole, type RsvpStatus, type Series, type Session, type Team,
|
|
|
|
|
type PlayerRole, type Registration, type RsvpStatus, type Series, type Session, type Team,
|
|
|
|
|
} from './api/client'
|
|
|
|
|
import {
|
|
|
|
|
demoBalanceCandidates, demoBracket, demoEvents, demoProfile,
|
|
|
|
|
@@ -144,10 +144,12 @@ function EventPage() {
|
|
|
|
|
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 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"><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<Badge tone="success">Registration open</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>Your latest response is sent to 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} 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>{Object.values(counts).reduce((a, b) => a + b, 0)} responses</p></div><UsersRound /></div><div className="attendance-bar"><span /><span /><span /></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>{session.data.account.role === 'admin' && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside>
|
|
|
|
|
</div></div>
|
|
|
|
|
</div><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>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ProfilePage() {
|
|
|
|
|
@@ -234,7 +236,38 @@ function EventSetup({ event, onCreated }: { event?: MixEvent; onCreated: (event:
|
|
|
|
|
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 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: [],
|
|
|
|
|
},
|
|
|
|
|
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: () => {
|
|
|
|
|
@@ -245,7 +278,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"><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} 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></div>)}</div>{override.isError && <p className="error-note">{override.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"><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} 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></div>)}</div>{override.isError && <p className="error-note">{override.error.message}</p>}</section>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function BalanceView({ eventId }: { eventId: string }) {
|
|
|
|
|
|