Bruker rapporterte fra ekte mobilbruk (teecup.golf, live): THRU-etiketten sto lavere enn I DAG/TOTAL. Thru-verdien manglet samme min-h-10 som ScoreShape (brukt av I dag/Total) gir de andre kolonnene -- hele Thru-kolonnen ble dermed lavere og sentrerte etiketten ut av linje. Verifisert rettet i scratch før utrulling. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
663 lines
25 KiB
TypeScript
663 lines
25 KiB
TypeScript
"use client"
|
||
|
||
// Offentlig "Følg live" for INDIVIDUELLE turneringer (2026-08-21) --
|
||
// motstykket til public-live.tsx (Cup-format). Se CHANGELOG.md samme dag
|
||
// for hele bakgrunnen (bevisst utsatt siden 2026-08-14, FEATURE_BACKLOG.md
|
||
// "Flaggturnering Del B", nå bygget etter grundig todelt analyse).
|
||
//
|
||
// Visuelt: Claude-skrevet V0-eksport (v0-prompt-public-individual-live.md),
|
||
// presentasjonskomponentene under er UENDRET fra eksporten. Denne filen
|
||
// bytter ut mock-datalaget med ekte fetching + sanntid.
|
||
//
|
||
// VIKTIG forenkling fra mock til ekte data: mock-en modellerte hver runde
|
||
// som sitt eget, helt separate leaderboard. Den ekte backend-modellen
|
||
// (individual_tournaments.py _compute_individual_standings) beregner ETT
|
||
// SAMLET, kumulativt turnering-leaderboard -- "i dag"/"thru" gjelder
|
||
// alltid gjeldende/siste påbegynte runde, "total" er summen på tvers av
|
||
// ALLE spilte runder. Rundevelgeren endrer derfor IKKE selve
|
||
// leaderboard-radene (position/thru/i dag/total er alltid de samme,
|
||
// uansett hvilken rundefane som er valgt) -- den styrer KUN hvilken
|
||
// rundes hull-for-hull-rutenett som lastes når en rad utvides.
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react"
|
||
import { ArrowLeft, Radio, Check, ChevronDown } from "lucide-react"
|
||
import { cn } from "@/lib/utils"
|
||
import { Wordmark } from "@/components/wordmark"
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* API-typer (matcher app/routers/registration.py sine nye endepunkt) *
|
||
* ------------------------------------------------------------------ */
|
||
|
||
type RoundStatus = "not_started" | "in_progress" | "completed"
|
||
|
||
type ApiRound = {
|
||
id: string
|
||
sequence: number
|
||
name: string
|
||
course_name: string
|
||
scheduled_at: string | null
|
||
status: RoundStatus
|
||
}
|
||
|
||
type Tone = "under" | "even" | "over" | null
|
||
|
||
type ApiRoundCell = { round_number: number; label: string; tone: Tone }
|
||
|
||
type PlayerStatus = "active" | "dsq" | "rtd" | "dnf" | "dns"
|
||
|
||
type ApiLeaderboardEntry = {
|
||
tournament_participant_id: string
|
||
player_name: string
|
||
class_id: string | null
|
||
class_name: string | null
|
||
position: string | null
|
||
is_leader: boolean
|
||
today_label: string | null
|
||
thru_label: string | null
|
||
total_label: string | null
|
||
rank: number | null
|
||
cut: boolean
|
||
status: PlayerStatus
|
||
handicap_index: number | null
|
||
rounds: ApiRoundCell[]
|
||
}
|
||
|
||
type ApiHole = { hole_number: number; par: number; gross_strokes: number | null }
|
||
|
||
type ApiTournamentInfo = { name: string; format_type: string; status: string }
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Visningstyper for hull-rutenettet (per runde, hentes lat ved utvidelse)
|
||
* ------------------------------------------------------------------ */
|
||
|
||
type HoleResult = { holeNumber: number; par: number; toParTone: Tone; label: string }
|
||
|
||
function toneOf(strokesOverPar: number): Tone {
|
||
return strokesOverPar < 0 ? "under" : strokesOverPar > 0 ? "over" : "even"
|
||
}
|
||
|
||
function holesFromApi(holes: ApiHole[]): HoleResult[] {
|
||
return holes.map((h) => {
|
||
if (h.gross_strokes == null) return { holeNumber: h.hole_number, par: h.par, toParTone: null, label: "" }
|
||
return {
|
||
holeNumber: h.hole_number,
|
||
par: h.par,
|
||
toParTone: toneOf(h.gross_strokes - h.par),
|
||
label: String(h.gross_strokes),
|
||
}
|
||
})
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Delt golfscore-visuelt språk -- UENDRET fra V0-eksporten. *
|
||
* under = sirkel · over = avrundet firkant · even = ren tekst. *
|
||
* emphasis = fylt (leder); ellers tint + kant. *
|
||
* ------------------------------------------------------------------ */
|
||
|
||
function ScoreShape({
|
||
label,
|
||
tone,
|
||
emphasis = false,
|
||
size = "md",
|
||
}: {
|
||
label: string | null
|
||
tone: Tone
|
||
emphasis?: boolean
|
||
size?: "sm" | "md"
|
||
}) {
|
||
const dims = size === "sm" ? "min-h-7 min-w-7 px-1 text-xs" : "min-h-10 min-w-10 px-2 text-sm"
|
||
const base = cn("inline-flex items-center justify-center font-bold tabular-nums", dims)
|
||
|
||
if (label == null) {
|
||
return (
|
||
<span className={cn(base, "text-muted-foreground")} aria-hidden="true">
|
||
–
|
||
</span>
|
||
)
|
||
}
|
||
if (tone === "even") {
|
||
return (
|
||
<span className={cn(base, "text-foreground")}>
|
||
{label}
|
||
<span className="sr-only"> likt med par</span>
|
||
</span>
|
||
)
|
||
}
|
||
if (tone == null) {
|
||
return <span className={cn(base, "text-muted-foreground")}>{label}</span>
|
||
}
|
||
|
||
const under = tone === "under"
|
||
const shape = under ? "rounded-full" : "rounded-[5px]"
|
||
const skin = emphasis
|
||
? under
|
||
? "bg-primary text-primary-foreground"
|
||
: "bg-brand-orange text-brand-orange-foreground"
|
||
: under
|
||
? "border-2 border-primary/35 bg-primary/10 text-primary"
|
||
: "border-2 border-brand-orange/35 bg-brand-orange/10 text-brand-orange"
|
||
|
||
return (
|
||
<span className={cn(base, shape, skin)}>
|
||
{label}
|
||
<span className="sr-only">{under ? " under par" : " over par"}</span>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function RankBadge({ position, isLeader }: { position: string | null; isLeader: boolean }) {
|
||
if (!position) {
|
||
return (
|
||
<span className="inline-flex min-h-8 min-w-9 items-center justify-center rounded-lg bg-muted px-1.5 text-sm font-bold text-muted-foreground">
|
||
–
|
||
</span>
|
||
)
|
||
}
|
||
return (
|
||
<span
|
||
className={cn(
|
||
"inline-flex min-h-8 min-w-9 items-center justify-center rounded-lg px-1.5 text-sm font-extrabold tabular-nums",
|
||
isLeader ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
|
||
)}
|
||
>
|
||
{position}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function StatusPill({ label }: { label: string }) {
|
||
return (
|
||
<span className="inline-flex min-h-8 items-center justify-center rounded-md border border-dashed border-border bg-muted/40 px-2.5 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||
{label}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function RoundStatusBadge({ status }: { status: RoundStatus }) {
|
||
if (status === "in_progress") {
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 self-start rounded-full bg-primary px-2.5 py-1 text-xs font-bold text-primary-foreground">
|
||
<Radio aria-hidden="true" className="size-3.5 animate-pulse" />
|
||
Pågår nå
|
||
</span>
|
||
)
|
||
}
|
||
if (status === "completed") {
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 self-start text-xs font-semibold text-muted-foreground">
|
||
<Check aria-hidden="true" className="size-3.5" />
|
||
Fullført
|
||
</span>
|
||
)
|
||
}
|
||
return <span className="self-start text-xs font-semibold text-muted-foreground">Ikke startet</span>
|
||
}
|
||
|
||
const STATUS_LABEL: Record<Exclude<PlayerStatus, "active">, string> = {
|
||
dsq: "DSQ",
|
||
rtd: "RTD",
|
||
dnf: "DNF",
|
||
dns: "DNS",
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Utvidbart hull-for-hull-rutenett *
|
||
* ------------------------------------------------------------------ */
|
||
|
||
function HoleNine({ holes, label }: { holes: HoleResult[]; label: string }) {
|
||
const parSum = holes.reduce((s, h) => s + h.par, 0)
|
||
const strokeSum = holes.reduce((s, h) => s + (h.label ? Number(h.label) : 0), 0)
|
||
return (
|
||
<div className="overflow-hidden rounded-xl border border-border">
|
||
<div className="grid grid-cols-[2rem_repeat(9,minmax(0,1fr))_2.4rem] items-center bg-muted/60 text-center text-[11px] font-semibold text-muted-foreground">
|
||
<div className="py-1">{label}</div>
|
||
{holes.map((h) => (
|
||
<div key={h.holeNumber} className="py-1">
|
||
{h.holeNumber}
|
||
</div>
|
||
))}
|
||
<div className="py-1">Sum</div>
|
||
</div>
|
||
<div className="grid grid-cols-[2rem_repeat(9,minmax(0,1fr))_2.4rem] items-center border-t border-border text-center text-[11px] text-muted-foreground">
|
||
<div className="py-1 font-semibold">Par</div>
|
||
{holes.map((h) => (
|
||
<div key={h.holeNumber} className="py-1 tabular-nums">
|
||
{h.par}
|
||
</div>
|
||
))}
|
||
<div className="py-1 font-semibold tabular-nums">{parSum}</div>
|
||
</div>
|
||
<div className="grid grid-cols-[2rem_repeat(9,minmax(0,1fr))_2.4rem] items-center justify-items-center border-t border-border py-1 text-center">
|
||
<div className="text-[11px] font-semibold text-foreground">Deg</div>
|
||
{holes.map((h) =>
|
||
h.label ? (
|
||
<ScoreShape key={h.holeNumber} label={h.label} tone={h.toParTone} size="sm" />
|
||
) : (
|
||
<span key={h.holeNumber} className="text-xs text-muted-foreground" aria-hidden="true">
|
||
–
|
||
</span>
|
||
),
|
||
)}
|
||
<div className="text-xs font-bold tabular-nums text-foreground">{strokeSum || "–"}</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function HoleGrid({ holes }: { holes: HoleResult[] }) {
|
||
if (holes.length === 0) {
|
||
return <p className="px-3 pb-3 text-sm text-muted-foreground">Ingen hull registrert for denne runden ennå.</p>
|
||
}
|
||
return (
|
||
<div className="flex flex-col gap-2 px-3 pb-3">
|
||
<HoleNine holes={holes.slice(0, 9)} label="Ut" />
|
||
{holes.length > 9 && <HoleNine holes={holes.slice(9)} label="Inn" />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Leaderboard-rad -- henter hull-for-hull LAT, for VALGT runde, *
|
||
* kun når raden faktisk utvides. *
|
||
* ------------------------------------------------------------------ */
|
||
|
||
function LeaderboardRow({
|
||
entry,
|
||
organizationlessBase,
|
||
roundId,
|
||
}: {
|
||
entry: ApiLeaderboardEntry
|
||
organizationlessBase: string
|
||
roundId: string | null
|
||
}) {
|
||
const [open, setOpen] = useState(false)
|
||
const [holes, setHoles] = useState<HoleResult[] | null>(null)
|
||
const [loadingHoles, setLoadingHoles] = useState(false)
|
||
const panelId = `holes-${entry.tournament_participant_id}`
|
||
const isStatus = entry.status !== "active"
|
||
|
||
// Rundevalg endret seg mens raden var åpen -- kast cachet data, hent på nytt.
|
||
useEffect(() => {
|
||
setHoles(null)
|
||
}, [roundId])
|
||
|
||
async function toggle() {
|
||
const next = !open
|
||
setOpen(next)
|
||
if (next && roundId && holes === null) {
|
||
setLoadingHoles(true)
|
||
try {
|
||
const res = await fetch(
|
||
`${organizationlessBase}/rounds/${roundId}/participants/${entry.tournament_participant_id}/holes`,
|
||
{ credentials: "include" },
|
||
)
|
||
if (res.ok) {
|
||
const data: ApiHole[] = await res.json()
|
||
setHoles(holesFromApi(data))
|
||
}
|
||
} finally {
|
||
setLoadingHoles(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
const rowInner = (
|
||
<>
|
||
<RankBadge position={entry.position} isLeader={entry.is_leader} />
|
||
<span className="flex min-w-0 flex-1 flex-col">
|
||
{/* Navnet får bryte til ny linje i stedet for å avkortes med "..." på
|
||
smale skjermer (360px) -- funnet 2026-08-21 under scratch-
|
||
verifisering: et reelt navn ("Erol Haagenrud") kuttet ned til
|
||
"Erol Haag..." er under lesbarhetsbarren (CLAUDE.md, ufravikelig).
|
||
Undertittelen (HCP/klasse, mindre viktig) beholder truncate. */}
|
||
<span className="text-pretty text-[15px] font-bold leading-tight text-foreground">{entry.player_name}</span>
|
||
<span className="truncate text-xs text-muted-foreground">
|
||
{entry.handicap_index != null && `HCP ${entry.handicap_index.toFixed(1).replace(".", ",")}`}
|
||
{entry.handicap_index != null && entry.class_name ? " · " : ""}
|
||
{entry.class_name}
|
||
</span>
|
||
</span>
|
||
|
||
<span className="flex flex-col items-center gap-0.5">
|
||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Thru</span>
|
||
{/* min-h-10 matcher ScoreShape sin høyde (I dag/Total-kolonnene) --
|
||
uten denne blir hele Thru-kolonnen lavere og etiketten sentreres
|
||
ut av linje med de to andre (funnet i ekte mobilbruk 2026-08-21). */}
|
||
<span className="flex min-h-10 items-center text-sm font-bold tabular-nums text-foreground">
|
||
{entry.thru_label ?? "-"}
|
||
</span>
|
||
</span>
|
||
|
||
<span className="flex flex-col items-center gap-0.5">
|
||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">I dag</span>
|
||
{isStatus ? (
|
||
<span className="text-sm text-muted-foreground" aria-hidden="true">
|
||
–
|
||
</span>
|
||
) : (
|
||
<ScoreShape
|
||
label={entry.today_label}
|
||
tone={entry.today_label?.startsWith("-") || entry.today_label?.startsWith("−") ? "under" : entry.today_label?.startsWith("+") ? "over" : entry.today_label === "E" ? "even" : null}
|
||
/>
|
||
)}
|
||
</span>
|
||
|
||
<span className="flex flex-col items-center gap-0.5">
|
||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Total</span>
|
||
{isStatus || entry.cut ? (
|
||
<StatusPill label={entry.cut ? "Kuttet" : STATUS_LABEL[entry.status as Exclude<PlayerStatus, "active">]} />
|
||
) : (
|
||
<ScoreShape
|
||
label={entry.total_label}
|
||
tone={entry.total_label?.startsWith("-") || entry.total_label?.startsWith("−") ? "under" : entry.total_label?.startsWith("+") ? "over" : entry.total_label === "E" ? "even" : null}
|
||
emphasis={entry.is_leader}
|
||
/>
|
||
)}
|
||
</span>
|
||
|
||
<ChevronDown
|
||
aria-hidden="true"
|
||
className={cn(
|
||
"size-5 shrink-0 text-muted-foreground transition-transform duration-200",
|
||
roundId ? (open ? "rotate-180" : "") : "invisible",
|
||
)}
|
||
/>
|
||
</>
|
||
)
|
||
|
||
if (!roundId) {
|
||
return <li className="flex items-center gap-2 px-2.5 py-3 sm:gap-2.5 sm:px-4">{rowInner}</li>
|
||
}
|
||
|
||
return (
|
||
<li>
|
||
<button
|
||
type="button"
|
||
onClick={() => void toggle()}
|
||
aria-expanded={open}
|
||
aria-controls={panelId}
|
||
className="flex w-full items-center gap-2 px-2.5 py-3 text-left sm:gap-2.5 transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset sm:px-4"
|
||
>
|
||
{rowInner}
|
||
<span className="sr-only">{open ? "Skjul hull for hull" : "Vis hull for hull"}</span>
|
||
</button>
|
||
<div
|
||
id={panelId}
|
||
className={cn(
|
||
"grid transition-all duration-300 ease-out",
|
||
open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
|
||
)}
|
||
>
|
||
<div className="overflow-hidden">
|
||
<div className={cn(!open && "invisible")} aria-hidden={!open}>
|
||
{loadingHoles ? (
|
||
<p className="px-3 pb-3 text-sm text-muted-foreground">Laster hull …</p>
|
||
) : (
|
||
<HoleGrid holes={holes ?? []} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
)
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Side *
|
||
* ------------------------------------------------------------------ */
|
||
|
||
function pickDefaultRound(rounds: ApiRound[]): string | null {
|
||
const live = rounds.find((r) => r.status === "in_progress")
|
||
if (live) return live.id
|
||
const completed = [...rounds].reverse().find((r) => r.status === "completed")
|
||
if (completed) return completed.id
|
||
return rounds[0]?.id ?? null
|
||
}
|
||
|
||
function rankKey(e: ApiLeaderboardEntry): number {
|
||
if (e.position) return Number.parseInt(e.position.replace(/\D/g, ""), 10) || 999
|
||
return 1000
|
||
}
|
||
|
||
export function PublicIndividualLive({ tournamentId, code }: { tournamentId: string; code?: string }) {
|
||
const codeParam = code ? `?code=${encodeURIComponent(code)}` : ""
|
||
const base = `/public/tournaments/${tournamentId}`
|
||
|
||
const [tournament, setTournament] = useState<ApiTournamentInfo | null>(null)
|
||
const [rounds, setRounds] = useState<ApiRound[]>([])
|
||
const [entries, setEntries] = useState<ApiLeaderboardEntry[]>([])
|
||
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
|
||
const [classFilter, setClassFilter] = useState<string>("all")
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
async function loadAll(isFirst: boolean) {
|
||
const [tRes, rRes, lRes] = await Promise.all([
|
||
isFirst ? fetch(`${base}${codeParam}`, { credentials: "include" }) : Promise.resolve(null),
|
||
fetch(`${base}/rounds${codeParam}`, { credentials: "include" }),
|
||
fetch(`${base}/individual-leaderboard${codeParam}`, { credentials: "include" }),
|
||
])
|
||
if (tRes?.ok) {
|
||
const data: ApiTournamentInfo = await tRes.json()
|
||
setTournament(data)
|
||
}
|
||
if (rRes.ok) {
|
||
const data: ApiRound[] = await rRes.json()
|
||
setRounds(data)
|
||
if (isFirst) setSelectedRoundId(pickDefaultRound(data))
|
||
}
|
||
if (lRes.ok) {
|
||
const data: ApiLeaderboardEntry[] = await lRes.json()
|
||
setEntries(data)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
loadAll(true).finally(() => {
|
||
if (!cancelled) setLoading(false)
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [tournamentId])
|
||
|
||
// Sanntid -- SAMME WebSocket-kanal og "noe endret, hent på nytt"-mønster
|
||
// som public-live.tsx (Cup-format) allerede bruker, ADR-027.
|
||
const isFirstRefresh = useRef(true)
|
||
useEffect(() => {
|
||
const proto = window.location.protocol === "https:" ? "wss" : "ws"
|
||
const ws = new WebSocket(
|
||
`${proto}://${window.location.host}/ws/public/tournaments/${tournamentId}/live${codeParam}`,
|
||
)
|
||
ws.onmessage = () => {
|
||
if (isFirstRefresh.current) {
|
||
isFirstRefresh.current = false
|
||
return
|
||
}
|
||
void loadAll(false)
|
||
}
|
||
return () => ws.close()
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [tournamentId])
|
||
|
||
const classes = useMemo(
|
||
() => Array.from(new Set(entries.map((e) => e.class_name).filter((c): c is string => !!c))),
|
||
[entries],
|
||
)
|
||
|
||
const visible = useMemo(() => {
|
||
const filtered = classFilter === "all" ? entries : entries.filter((e) => e.class_name === classFilter)
|
||
return [...filtered].sort((a, b) => rankKey(a) - rankKey(b))
|
||
}, [entries, classFilter])
|
||
|
||
const advancing = visible.filter((e) => e.status === "active" && !e.cut)
|
||
const belowCut = visible.filter((e) => e.cut || e.status !== "active")
|
||
const hasCut = belowCut.length > 0
|
||
|
||
const isFinished = tournament?.status === "completed" || tournament?.status === "archived"
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background">
|
||
<div
|
||
aria-hidden="true"
|
||
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-dvh bg-background text-foreground">
|
||
<header className="sticky top-0 z-20 border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/70">
|
||
<div className="mx-auto flex max-w-3xl items-center gap-3 px-4 py-3">
|
||
<a
|
||
href={`/t/${tournamentId}${codeParam}`}
|
||
aria-label="Tilbake til turneringen"
|
||
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
>
|
||
<ArrowLeft aria-hidden="true" className="size-5" />
|
||
</a>
|
||
|
||
<div className="flex min-w-0 flex-1 flex-col">
|
||
<span className="flex items-center gap-1.5">
|
||
<span
|
||
aria-hidden="true"
|
||
className={cn("size-2 rounded-full", isFinished ? "bg-muted-foreground" : "bg-primary animate-pulse")}
|
||
/>
|
||
<span className="text-[11px] font-bold uppercase tracking-[0.15em] text-muted-foreground">
|
||
{isFinished ? "Avsluttet" : "Følg live"}
|
||
</span>
|
||
</span>
|
||
<h1 className="truncate text-lg font-extrabold leading-tight text-foreground text-balance">
|
||
{tournament?.name ?? "Turnering"}
|
||
</h1>
|
||
</div>
|
||
|
||
<Wordmark compact />
|
||
</div>
|
||
</header>
|
||
|
||
<main className="mx-auto flex max-w-3xl flex-col gap-5 px-4 py-5">
|
||
{rounds.length > 0 && (
|
||
<nav aria-label="Velg runde" className="-mx-4 overflow-x-auto px-4">
|
||
<ul className="flex gap-2.5">
|
||
{rounds.map((r) => {
|
||
const selected = r.id === selectedRoundId
|
||
const date = r.scheduled_at
|
||
? new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" }).format(
|
||
new Date(r.scheduled_at),
|
||
)
|
||
: null
|
||
return (
|
||
<li key={r.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedRoundId(r.id)}
|
||
aria-pressed={selected}
|
||
className={cn(
|
||
"flex min-w-[10.5rem] flex-col gap-1.5 rounded-2xl border p-3 text-left transition-colors",
|
||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||
selected
|
||
? "border-primary bg-primary/5 ring-1 ring-primary"
|
||
: "border-border bg-card hover:bg-muted/50",
|
||
)}
|
||
>
|
||
<span className="text-sm font-extrabold text-foreground">{r.name}</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{r.course_name}
|
||
{date ? ` · ${date}` : ""}
|
||
</span>
|
||
<RoundStatusBadge status={r.status} />
|
||
</button>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
</nav>
|
||
)}
|
||
|
||
<section aria-label="Resultatliste" className="flex flex-col gap-3">
|
||
{classes.length > 0 && entries.length > 0 && (
|
||
<div
|
||
role="group"
|
||
aria-label="Filtrer på klasse"
|
||
className="flex flex-wrap gap-1.5 rounded-xl border border-border bg-card p-1"
|
||
>
|
||
{[{ key: "all", label: "Alle" }, ...classes.map((c) => ({ key: c, label: c }))].map((opt) => {
|
||
const active = classFilter === opt.key
|
||
return (
|
||
<button
|
||
key={opt.key}
|
||
type="button"
|
||
onClick={() => setClassFilter(opt.key)}
|
||
aria-pressed={active}
|
||
className={cn(
|
||
"min-h-9 flex-1 rounded-lg px-3 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||
active ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted",
|
||
)}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{entries.length === 0 ? (
|
||
<div className="rounded-2xl border border-dashed border-border bg-card px-6 py-12 text-center">
|
||
<p className="text-sm font-semibold text-foreground">Ingen resultater ennå</p>
|
||
<p className="mt-1 text-sm text-muted-foreground text-pretty">
|
||
Ingen resultater registrert ennå for denne turneringen.
|
||
</p>
|
||
</div>
|
||
) : visible.length === 0 ? (
|
||
<div className="rounded-2xl border border-dashed border-border bg-card px-6 py-12 text-center">
|
||
<p className="text-sm text-muted-foreground">Ingen spillere i denne klassen.</p>
|
||
</div>
|
||
) : (
|
||
<ol className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/[0.06]">
|
||
{advancing.map((e) => (
|
||
<LeaderboardRow key={e.tournament_participant_id} entry={e} organizationlessBase={base} roundId={selectedRoundId} />
|
||
))}
|
||
|
||
{hasCut && (
|
||
<li aria-hidden="true" className="flex items-center gap-3 bg-muted/50 px-4 py-2">
|
||
<span className="h-px flex-1 bg-border" />
|
||
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||
Cut · {advancing.length} videre
|
||
</span>
|
||
<span className="h-px flex-1 bg-border" />
|
||
</li>
|
||
)}
|
||
|
||
{belowCut.map((e) => (
|
||
<LeaderboardRow key={e.tournament_participant_id} entry={e} organizationlessBase={base} roundId={selectedRoundId} />
|
||
))}
|
||
</ol>
|
||
)}
|
||
|
||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 px-1 text-xs text-muted-foreground">
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="flex size-5 items-center justify-center rounded-full border-2 border-primary/35 bg-primary/10 text-[10px] font-bold text-primary">
|
||
−
|
||
</span>
|
||
Under par
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="text-[11px] font-bold text-foreground">E</span>
|
||
Par
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="flex size-5 items-center justify-center rounded-[4px] border-2 border-brand-orange/35 bg-brand-orange/10 text-[10px] font-bold text-brand-orange">
|
||
+
|
||
</span>
|
||
Over par
|
||
</span>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|