144 lines
5.3 KiB
TypeScript
144 lines
5.3 KiB
TypeScript
|
|
"use client"
|
|||
|
|
|
|||
|
|
// "Spillere og runde", read-only tilskuer-variant (ADR-044, 2026-08-06).
|
|||
|
|
// Bevisst IKKE et forsøk på å gjenbruke round-detail.tsx sin PlayerList
|
|||
|
|
// via en canManage=false-prop -- den er en lokal, ikke-eksportert funksjon
|
|||
|
|
// tett koblet til eier-autentiserte handlere (onPatchParticipant m.fl.),
|
|||
|
|
// og selv canManage dekker i dag ikke alt konsekvent der (Fullfør runde-
|
|||
|
|
// knappen er kun gated på !completed). Denne komponenten inneholder rett
|
|||
|
|
// og slett INGEN handlings-kode (ingen +Medspiller, ingen Rediger/Fullfør/
|
|||
|
|
// Slett) -- ingen risiko for at en eier-handling glipper gjennom.
|
|||
|
|
|
|||
|
|
import Link from "next/link"
|
|||
|
|
import { Users } from "lucide-react"
|
|||
|
|
|
|||
|
|
export type ApiPublicParticipant = {
|
|||
|
|
id: string
|
|||
|
|
display_name: string
|
|||
|
|
is_owner: boolean
|
|||
|
|
tee_name_snapshot: string
|
|||
|
|
handicap_index_snapshot: number | null
|
|||
|
|
course_handicap_snapshot: number | null
|
|||
|
|
round_side_id: string | null
|
|||
|
|
playing_handicap: number | null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type ApiLeaderboardEntry = {
|
|||
|
|
participant_id: string
|
|||
|
|
display_name: string
|
|||
|
|
holes_played: number
|
|||
|
|
total_score: number | null
|
|||
|
|
score_to_par: number | null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export type ApiFlightSummary = {
|
|||
|
|
round_id: string
|
|||
|
|
name: string | null
|
|||
|
|
course_name_snapshot: string
|
|||
|
|
played_at: string
|
|||
|
|
started_at: string | null
|
|||
|
|
completed_at: string | null
|
|||
|
|
participant_count: number
|
|||
|
|
is_anchor: boolean
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatToPar(value: number): string {
|
|||
|
|
if (value === 0) return "E"
|
|||
|
|
return value > 0 ? `+${value}` : `−${Math.abs(value)}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ParticipantCard({ participant, progress }: { participant: ApiPublicParticipant; progress: ApiLeaderboardEntry | null }) {
|
|||
|
|
// Tildelte slag: allowance-justert spillehandicap der satt, ellers
|
|||
|
|
// course handicap -- samme prioritet som eiersidens PlayerList bruker
|
|||
|
|
// for "Tildelte slag".
|
|||
|
|
const strokesGiven = participant.playing_handicap ?? participant.course_handicap_snapshot
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
|||
|
|
<div className="flex flex-wrap items-center gap-2">
|
|||
|
|
<span className="text-base font-bold text-foreground">{participant.display_name}</span>
|
|||
|
|
{participant.is_owner && (
|
|||
|
|
<span className="rounded-full border border-border px-2 py-0.5 text-xs font-semibold text-muted-foreground">
|
|||
|
|
Eier
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<p className="text-sm text-muted-foreground">
|
|||
|
|
Utslag: <span className="font-semibold text-foreground">{participant.tee_name_snapshot}</span>
|
|||
|
|
{participant.handicap_index_snapshot !== null && (
|
|||
|
|
<>
|
|||
|
|
{" · "}HCP: <span className="font-semibold text-foreground">{participant.handicap_index_snapshot.toFixed(1)}</span>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
{strokesGiven !== null && (
|
|||
|
|
<>
|
|||
|
|
{" · "}Tildelte slag: <span className="font-semibold text-foreground">{strokesGiven}</span>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</p>
|
|||
|
|
{progress && (
|
|||
|
|
<p className="text-sm text-muted-foreground">
|
|||
|
|
Så langt: <span className="font-semibold text-foreground">{progress.holes_played} hull</span>
|
|||
|
|
{progress.total_score !== null && (
|
|||
|
|
<>
|
|||
|
|
{" · "}
|
|||
|
|
<span className="font-semibold text-foreground">{progress.total_score} slag</span>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
{progress.score_to_par !== null && (
|
|||
|
|
<>
|
|||
|
|
{" · "}
|
|||
|
|
<span className="font-semibold text-foreground">{formatToPar(progress.score_to_par)} til par</span>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function WatchPlayers({
|
|||
|
|
participants,
|
|||
|
|
leaderboardEntries,
|
|||
|
|
flightGroup,
|
|||
|
|
}: {
|
|||
|
|
participants: ApiPublicParticipant[]
|
|||
|
|
leaderboardEntries: ApiLeaderboardEntry[]
|
|||
|
|
flightGroup: { flight_group_id: string | null; flights: ApiFlightSummary[] } | null
|
|||
|
|
}) {
|
|||
|
|
const progressByParticipant = new Map(leaderboardEntries.map((e) => [e.participant_id, e]))
|
|||
|
|
const otherFlights = (flightGroup?.flights ?? []).filter((f) => !f.is_anchor)
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<section className="flex flex-col gap-4" aria-label="Spillere og runde">
|
|||
|
|
{otherFlights.length > 0 && (
|
|||
|
|
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-4">
|
|||
|
|
<span className="flex items-center gap-1.5 text-sm font-bold text-foreground">
|
|||
|
|
<Users aria-hidden="true" className="size-4 text-primary" />
|
|||
|
|
Flere flighter deler dette leaderboardet
|
|||
|
|
</span>
|
|||
|
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
|||
|
|
{otherFlights.map((f) => (
|
|||
|
|
<li key={f.round_id}>
|
|||
|
|
<Link
|
|||
|
|
href={`/watch/${f.round_id}`}
|
|||
|
|
className="flex min-h-11 items-center justify-between gap-2 px-3 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-accent/50"
|
|||
|
|
>
|
|||
|
|
<span>{f.name || "Flight"}</span>
|
|||
|
|
<span className="text-xs font-medium text-muted-foreground">{f.participant_count} spillere</span>
|
|||
|
|
</Link>
|
|||
|
|
</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="flex flex-col gap-3">
|
|||
|
|
{participants.map((p) => (
|
|||
|
|
<ParticipantCard key={p.id} participant={p} progress={progressByParticipant.get(p.id) ?? null} />
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
)
|
|||
|
|
}
|