Add participant creation functionality to event API, including backend service and database integration. Update frontend to support participant registration and enhance UI for displaying registered players.
This commit is contained in:
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user