teecup/frontend/components/round-detail.tsx
Erol Haagenrud 65f876e6dc Oppsummering av det som er fikset/bygget i denne runden:
Starthull-bugen (og trolig GIR-avviket den forårsaket) — fikset
Kølle-bag i profilen (28 faste kølletyper, maks 14) — bygget
Anywayslag-statistikkfelt — bygget
Valgfritt statistikknivå per deltaker (Kun slag / Slag og putter / All statistikk, default Kun slag) — bygget
Putt-avstand som faste bøtter i stedet for fritekst — bygget
«Hullet er spilt»-avkrysningen fjernet — gjort
Bekreft, så kjører jeg migrasjonen og ruller ut.
2026-07-24 06:27:35 +02:00

1067 lines
39 KiB
TypeScript

"use client"
// Hull-for-hull-registrering for en frittstående runde (ADR-033).
// Presentasjon fra V0, datalag skrevet om fra mock til ekte fetch/PATCH.
//
// VIKTIG kontraktsdetalj (bekreftet i scratch): hull-PATCH-endepunktet er
// IKKE et ekte delvis-PATCH -- det skriver ALLE felt ved hvert kall. Derfor
// slår updateStat() alltid sammen med gjeldende hull-data FØR den sender,
// aldri kun det ene feltet som ble endret.
import type React from "react"
import { useCallback, useEffect, useState } from "react"
import Link from "next/link"
import {
ArrowLeft,
CalendarDays,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Flag,
MapPin,
Minus,
Plus,
Trophy,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
// --- Types -----------------------------------------------------------------
type Gender = "male" | "female" | "other"
type ApiGender = "m" | "f" | "x"
type TeeShot = "left" | "fairway" | "right"
type Approach = "left" | "short" | "hit" | "long" | "right"
type StatLevel = "strokes_only" | "strokes_and_putts" | "full"
type PuttBucket = "<1m" | "<2m" | "<3m" | "<5m" | "<8m" | "8m+"
type Player = {
id: string
name: string
gender: Gender
hcp: number | null
isSelf: boolean
countsForHandicap: boolean
scoreDifferential: number | null
statLevel: StatLevel
}
type HoleStat = {
played: boolean
strokes: number | null
putts: number | null
club: string
teeShot: TeeShot | null
approach: Approach | null
chip: number
bunker: number
penalty: number
firstPuttBucket: PuttBucket | null
anywayStrokes: number | null
}
type Hole = {
holeNumber: number
par: number
index: number
}
function emptyStat(): HoleStat {
return {
played: false,
strokes: null,
putts: null,
club: "",
teeShot: null,
approach: null,
chip: 0,
bunker: 0,
penalty: 0,
firstPuttBucket: null,
anywayStrokes: null,
}
}
function statKey(playerId: string, holeNumber: number) {
return `${playerId}:${holeNumber}`
}
function apiGenderToUi(g: ApiGender): Gender {
return g === "m" ? "male" : g === "f" ? "female" : "other"
}
function uiGenderToApi(g: Gender): ApiGender {
return g === "male" ? "m" : g === "female" ? "f" : "x"
}
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
function formatDate(value: string) {
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return dateFormatter.format(parsed)
}
// --- API-typer ---------------------------------------------------------------
type ApiParticipant = {
id: string
user_id: string | null
guest_name: string | null
is_owner: boolean
gender: ApiGender
handicap_index_snapshot: number | null
course_handicap_snapshot: number | null
counts_for_handicap: boolean
score_differential: number | null
stat_level: StatLevel
}
type ApiRound = {
id: string
course_name_snapshot: string
tee_name_snapshot: string
played_at: string
start_hole: number
holes_planned: number
completed_at: string | null
participants: ApiParticipant[]
}
type ApiHole = {
hole_number: number
par: number
stroke_index: number
played: boolean
score: number | null
putts: number | null
club_off_tee: string | null
tee_shot_result: TeeShot | null
approach_result: Approach | null
chip_count: number | null
bunker_shot_count: number | null
penalty_strokes: number | null
first_putt_distance_bucket: PuttBucket | null
anyway_strokes: number | null
}
function apiHoleToStat(h: ApiHole): HoleStat {
return {
played: h.played,
strokes: h.score,
putts: h.putts,
club: h.club_off_tee ?? "",
teeShot: h.tee_shot_result,
approach: h.approach_result,
chip: h.chip_count ?? 0,
bunker: h.bunker_shot_count ?? 0,
penalty: h.penalty_strokes ?? 0,
firstPuttBucket: h.first_putt_distance_bucket,
anywayStrokes: h.anyway_strokes,
}
}
function statToPatchBody(s: HoleStat) {
return {
played: s.played,
score: s.strokes,
putts: s.putts,
club_off_tee: s.club.trim() === "" ? null : s.club,
tee_shot_result: s.teeShot,
approach_result: s.approach,
chip_count: s.chip,
bunker_shot_count: s.bunker,
penalty_strokes: s.penalty,
first_putt_distance_bucket: s.firstPuttBucket,
anyway_strokes: s.anywayStrokes,
}
}
function playerLabel(p: ApiParticipant): string {
return p.is_owner ? "Deg" : p.guest_name ?? "Gjest"
}
// --- Component -------------------------------------------------------------
export function RoundDetail({ roundId }: { roundId: string }) {
const [round, setRound] = useState<ApiRound | null>(null)
const [error, setError] = useState<string | null>(null)
const [activePlayerId, setActivePlayerId] = useState<string | null>(null)
const [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({})
// Eierens egen kølle-bag (personlig profil) -- brukt til å tilby et
// knapp-utvalg for "Kølle brukt ved utslaget" i stedet for fritekst, kun
// for eieren selv (gjester har ingen profil å hente dette fra).
const [ownBagClubs, setOwnBagClubs] = useState<string[]>([])
// `null` betyr "ikke satt ennå" -- MÅ være null, ikke f.eks. 1, siden 1
// er en gyldig, truthy hullverdi og ville gjort `prev || start_hole`
// lenger ned til en no-op (funnet 2026-07-24: runden åpnet alltid på
// hull 1 uansett faktisk starthull).
const [currentHole, setCurrentHole] = useState<number | null>(null)
const [detailsOpen, setDetailsOpen] = useState(false)
const [showAddGuest, setShowAddGuest] = useState(false)
const [completing, setCompleting] = useState(false)
const loadRound = useCallback(async () => {
try {
const res = await fetch(`/rounds/${roundId}`, { credentials: "include" })
if (res.status === 403 || res.status === 404) {
setError("Denne runden finnes ikke, eller du har ikke tilgang til den.")
return
}
if (!res.ok) throw new Error(`round: ${res.status}`)
const data: ApiRound = await res.json()
setRound(data)
setActivePlayerId((prev) => prev ?? data.participants.find((p) => p.is_owner)?.id ?? data.participants[0]?.id ?? null)
setCurrentHole((prev) => prev ?? data.start_hole)
} catch {
setError("Klarte ikke å hente runden. Prøv igjen om litt.")
}
}, [roundId])
useEffect(() => {
void loadRound()
}, [loadRound])
useEffect(() => {
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: { bag_clubs: string[] } | null) => {
if (!cancelled && data) setOwnBagClubs(data.bag_clubs)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
const loadHoles = useCallback(
async (participantId: string) => {
const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" })
if (!res.ok) return
const data: ApiHole[] = await res.json()
setHolesByParticipant((prev) => ({ ...prev, [participantId]: data }))
},
[roundId],
)
useEffect(() => {
if (activePlayerId && !holesByParticipant[activePlayerId]) {
void loadHoles(activePlayerId)
}
}, [activePlayerId, holesByParticipant, loadHoles])
const completed = round?.completed_at != null
const readOnly = completed
const players: Player[] =
round?.participants.map((p) => ({
id: p.id,
name: playerLabel(p),
gender: apiGenderToUi(p.gender),
hcp: p.handicap_index_snapshot,
isSelf: p.is_owner,
countsForHandicap: p.counts_for_handicap,
scoreDifferential: p.score_differential,
statLevel: p.stat_level,
})) ?? []
const activePlayer = players.find((p) => p.id === activePlayerId) ?? players[0] ?? null
const apiHoles = activePlayerId ? holesByParticipant[activePlayerId] : undefined
const holes: Hole[] = (apiHoles ?? []).map((h) => ({ holeNumber: h.hole_number, par: h.par, index: h.stroke_index }))
// Alltid en tallverdi å regne videre på selv rett etter mount, FØR
// loadRound() har satt currentHole fra round.start_hole.
const activeHole = currentHole ?? round?.start_hole ?? 1
const hole = holes.find((h) => h.holeNumber === activeHole) ?? null
// Navigasjonsrekkefølge starter på øktens starthull og går rundt (18
// hull, sirkulært) -- ikke bare stigende 1..18, som ville vist feil
// hull i fokus for en runde som starter et annet sted enn hull 1.
const holeOrder = round ? Array.from({ length: 18 }, (_, i) => ((round.start_hole - 1 + i) % 18) + 1) : []
const orderedHoles = holeOrder.map((n) => holes.find((h) => h.holeNumber === n)).filter((h): h is Hole => h !== undefined)
const currentApiHole = apiHoles?.find((h) => h.hole_number === activeHole) ?? null
const currentStat = currentApiHole ? apiHoleToStat(currentApiHole) : emptyStat()
const showGir =
currentStat.played &&
currentStat.strokes !== null &&
currentStat.putts !== null &&
hole !== null &&
currentStat.strokes - currentStat.putts <= hole.par - 2
async function updateStat(patch: Partial<HoleStat>) {
if (readOnly || !activePlayerId || !currentApiHole) return
const merged: HoleStat = { ...currentStat, ...patch }
const res = await fetch(`/rounds/${roundId}/participants/${activePlayerId}/holes/${activeHole}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(statToPatchBody(merged)),
})
if (!res.ok) return
const updated: ApiHole = await res.json()
setHolesByParticipant((prev) => ({
...prev,
[activePlayerId]: (prev[activePlayerId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)),
}))
}
function holeIsPlayed(holeNumber: number) {
return apiHoles?.find((h) => h.hole_number === holeNumber)?.played ?? false
}
function goPrev() {
const i = holeOrder.indexOf(activeHole)
setCurrentHole(holeOrder[(i - 1 + 18) % 18])
setDetailsOpen(false)
}
function goNext() {
const i = holeOrder.indexOf(activeHole)
setCurrentHole(holeOrder[(i + 1) % 18])
setDetailsOpen(false)
}
async function addGuest(name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) {
const res = await fetch(`/rounds/${roundId}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
guest_name: name,
gender: uiGenderToApi(gender),
handicap_index: hcp,
stat_level: statLevel,
}),
})
if (!res.ok) {
setError("Klarte ikke å legge til spilleren. Sjekk at banen har en rating for valgt kjønn.")
return
}
setShowAddGuest(false)
const created: ApiParticipant = await res.json()
await loadRound()
setActivePlayerId(created.id)
}
async function removeGuest(id: string) {
if (!confirm("Fjerne denne spilleren fra runden?")) return
const res = await fetch(`/rounds/${roundId}/participants/${id}`, { method: "DELETE", credentials: "include" })
if (!res.ok) return
if (activePlayerId === id) setActivePlayerId(round?.participants.find((p) => p.is_owner)?.id ?? null)
await loadRound()
}
async function updateStatLevel(participantId: string, statLevel: StatLevel) {
const res = await fetch(`/rounds/${roundId}/participants/${participantId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ stat_level: statLevel }),
})
if (!res.ok) return
const updated: ApiParticipant = await res.json()
setRound((prev) =>
prev ? { ...prev, participants: prev.participants.map((p) => (p.id === updated.id ? updated : p)) } : prev,
)
}
async function finishRound() {
if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return
setCompleting(true)
try {
const res = await fetch(`/rounds/${roundId}/complete`, { method: "POST", credentials: "include" })
if (!res.ok) throw new Error()
setRound(await res.json())
} catch {
setError("Klarte ikke å fullføre runden. Prøv igjen.")
} finally {
setCompleting(false)
}
}
if (error) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<p className="text-base font-medium text-destructive">{error}</p>
<Link href="/my-rounds" className="text-base font-semibold text-primary underline underline-offset-2">
Tilbake til egne runder
</Link>
</div>
)
}
if (!round || !activePlayer) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center 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="flex min-h-[100dvh] flex-col bg-background">
{/* Sticky header */}
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
<div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-5 py-4">
<Link
href="/my-rounds"
aria-label="Tilbake til egne runder"
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col gap-0.5">
<h1 className="flex items-center gap-2 truncate text-xl font-extrabold tracking-tight text-foreground">
<MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" />
<span className="truncate">{round.course_name_snapshot}</span>
</h1>
<div className="flex flex-wrap items-center gap-x-4 gap-y-0.5 text-sm font-medium text-muted-foreground">
<span className="flex items-center gap-1.5">
<Flag aria-hidden="true" className="size-4 shrink-0" />
{round.tee_name_snapshot}
</span>
<span className="flex items-center gap-1.5 tabular-nums">
<CalendarDays aria-hidden="true" className="size-4 shrink-0" />
{formatDate(round.played_at)}
</span>
</div>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
{error && (
<p role="alert" className="mb-4 text-base font-medium text-destructive">
{error}
</p>
)}
{/* Completed summary banner */}
{completed && <CompletedBanner players={players} />}
{/* Participant tabs */}
<PlayerTabs
players={players}
activePlayerId={activePlayer.id}
onSelect={setActivePlayerId}
onRemove={removeGuest}
onAdd={() => setShowAddGuest((v) => !v)}
addOpen={showAddGuest}
readOnly={readOnly}
/>
{!readOnly && (
<StatLevelPicker
value={activePlayer.statLevel}
onChange={(v) => updateStatLevel(activePlayer.id, v)}
/>
)}
{showAddGuest && !readOnly && <AddGuestForm onAdd={addGuest} onCancel={() => setShowAddGuest(false)} />}
{/* Hole navigation */}
{holes.length === 0 ? (
<div className="mt-5 flex justify-center py-8">
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
) : (
<>
<HoleNav
holes={orderedHoles}
currentHole={activeHole}
isPlayed={holeIsPlayed}
onSelect={(n) => {
setCurrentHole(n)
setDetailsOpen(false)
}}
/>
{/* Current hole panel */}
{hole && (
<section className="mt-5 flex flex-col gap-6 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
<div className="flex items-start justify-between gap-3">
<h2 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
Hull {hole.holeNumber} · Par {hole.par} · Idx {hole.index}
</h2>
{showGir && (
<span className="shrink-0 rounded-full bg-primary px-3 py-1.5 text-sm font-bold text-primary-foreground">
GIR
</span>
)}
</div>
<NumberPicker
label="Slag"
value={currentStat.strokes}
directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]}
expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}
expandLabel="10+"
onChange={(v) => updateStat({ strokes: v, played: true })}
readOnly={readOnly}
/>
{activePlayer.statLevel !== "strokes_only" && (
<NumberPicker
label="Putter"
value={currentStat.putts}
directValues={[0, 1, 2, 3, 4, 5, 6]}
expandValues={[7, 8, 9, 10]}
expandLabel="7+"
onChange={(v) => updateStat({ putts: v })}
readOnly={readOnly}
/>
)}
{activePlayer.statLevel === "full" && (
<div className="rounded-2xl border border-border">
<button
type="button"
onClick={() => setDetailsOpen((v) => !v)}
aria-expanded={detailsOpen}
className="flex min-h-12 w-full items-center justify-between gap-2 px-4 py-3 text-left text-base font-semibold text-foreground"
>
Flere detaljer
<ChevronDown
aria-hidden="true"
className={cn("size-5 text-muted-foreground transition-transform", detailsOpen && "rotate-180")}
/>
</button>
{detailsOpen && (
<div className="flex flex-col gap-6 border-t border-border p-4 sm:p-5">
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">Kølle brukt ved utslaget</Label>
{activePlayer.isSelf && ownBagClubs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{ownBagClubs.map((club) => {
const selected = currentStat.club === club
return (
<button
key={club}
type="button"
disabled={readOnly}
onClick={() => updateStat({ club })}
aria-pressed={selected}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{club}
</button>
)
})}
</div>
) : (
<Input
id="club"
value={currentStat.club}
onChange={(e) => updateStat({ club: e.target.value })}
placeholder="F.eks. Driver, 3-jern"
disabled={readOnly}
className="h-12 rounded-2xl text-base"
/>
)}
</div>
{hole.par !== 3 && (
<ChoiceRow
label="Utslag"
options={[
{ value: "left", label: "Venstre" },
{ value: "fairway", label: "Fairway" },
{ value: "right", label: "Høyre" },
]}
value={currentStat.teeShot}
onChange={(v) => updateStat({ teeShot: v as TeeShot })}
readOnly={readOnly}
/>
)}
<ChoiceRow
label="Innspill"
options={[
{ value: "left", label: "Venstre" },
{ value: "short", label: "Kort" },
{ value: "hit", label: "Traff" },
{ value: "long", label: "Langt" },
{ value: "right", label: "Høyre" },
]}
value={currentStat.approach}
onChange={(v) => updateStat({ approach: v as Approach })}
readOnly={readOnly}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Stepper label="Chip" value={currentStat.chip} onChange={(v) => updateStat({ chip: v })} readOnly={readOnly} />
<Stepper label="Bunker" value={currentStat.bunker} onChange={(v) => updateStat({ bunker: v })} readOnly={readOnly} />
<Stepper label="Straffeslag" value={currentStat.penalty} onChange={(v) => updateStat({ penalty: v })} readOnly={readOnly} />
</div>
<ChoiceRow
label="Avstand første putt"
options={[
{ value: "<1m", label: "<1m" },
{ value: "<2m", label: "<2m" },
{ value: "<3m", label: "<3m" },
{ value: "<5m", label: "<5m" },
{ value: "<8m", label: "<8m" },
{ value: "8m+", label: "8m+" },
]}
value={currentStat.firstPuttBucket}
onChange={(v) => updateStat({ firstPuttBucket: v as PuttBucket })}
readOnly={readOnly}
/>
<NumberPicker
label="Anywayslag"
value={currentStat.anywayStrokes}
directValues={[0, 1, 2, 3, 4, 5, 6]}
expandValues={[7, 8, 9, 10]}
expandLabel="7+"
onChange={(v) => updateStat({ anywayStrokes: v })}
readOnly={readOnly}
/>
</div>
)}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold">
<ChevronLeft aria-hidden="true" className="size-5" />
Forrige
</Button>
<Button type="button" variant="outline" onClick={goNext} className="h-14 rounded-2xl text-base font-bold">
Neste hull
<ChevronRight aria-hidden="true" className="size-5" />
</Button>
</div>
</section>
)}
</>
)}
{!completed && (
<Button type="button" disabled={completing} onClick={finishRound} className="mt-6 h-14 w-full rounded-2xl text-base font-bold shadow-sm">
<Check aria-hidden="true" className="size-5" />
{completing ? "Fullfører…" : "Fullfør runde"}
</Button>
)}
</main>
</div>
)
}
// --- Completed banner ------------------------------------------------------
function CompletedBanner({ players }: { players: Player[] }) {
return (
<div className="mb-6 flex flex-col gap-4 rounded-3xl border border-primary/30 bg-primary/10 p-5 sm:p-6">
<div className="flex items-center gap-3">
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm">
<Trophy aria-hidden="true" className="size-6" />
</div>
<div className="flex flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-primary">Ferdigspilt</span>
<h2 className="text-xl font-extrabold tracking-tight text-foreground">Runde fullført</h2>
</div>
</div>
<ul className="flex flex-col divide-y divide-primary/20 overflow-hidden rounded-2xl border border-primary/20 bg-card">
{players.map((player) => {
const counts = player.countsForHandicap && player.scoreDifferential !== null
return (
<li key={player.id} className="flex items-center justify-between gap-3 px-4 py-3">
<span className="text-base font-bold text-foreground">{player.name}</span>
{counts ? (
<span className="flex items-baseline gap-1.5">
<span className="text-xs font-medium text-muted-foreground">Differensial</span>
<span className="text-lg font-extrabold tabular-nums text-foreground">
{player.scoreDifferential!.toFixed(1).replace(".", ",")}
</span>
</span>
) : (
<span className="text-sm font-semibold text-muted-foreground">Telte ikke mot HCP</span>
)}
</li>
)
})}
</ul>
</div>
)
}
// --- Player tabs -----------------------------------------------------------
function PlayerTabs({
players,
activePlayerId,
onSelect,
onRemove,
onAdd,
addOpen,
readOnly,
}: {
players: Player[]
activePlayerId: string
onSelect: (id: string) => void
onRemove: (id: string) => void
onAdd: () => void
addOpen: boolean
readOnly: boolean
}) {
return (
<div className="-mx-5 overflow-x-auto px-5">
<div role="tablist" aria-label="Velg spiller" className="flex items-center gap-2">
{players.map((player) => {
const active = player.id === activePlayerId
const canRemove = !player.isSelf && !readOnly
return (
<div
key={player.id}
className={cn(
"flex min-h-12 shrink-0 items-center gap-1 rounded-2xl border pl-4 transition-colors",
canRemove ? "pr-1.5" : "pr-4",
active ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
<button type="button" role="tab" aria-selected={active} onClick={() => onSelect(player.id)} className="text-base font-bold">
{player.name}
</button>
{canRemove && (
<button
type="button"
onClick={() => onRemove(player.id)}
aria-label={`Fjern ${player.name}`}
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-xl transition-colors",
active ? "text-primary-foreground/80 hover:bg-primary-foreground/20" : "text-muted-foreground hover:bg-accent",
)}
>
<X aria-hidden="true" className="size-4" />
</button>
)}
</div>
)
})}
{!readOnly && (
<button
type="button"
onClick={onAdd}
aria-label="Legg til gjestespiller"
aria-expanded={addOpen}
className="flex min-h-12 shrink-0 items-center gap-1.5 rounded-2xl border border-dashed border-border bg-card px-4 text-base font-semibold text-foreground transition-colors hover:bg-accent/50"
>
<Plus aria-hidden="true" className="size-5" />
Gjest
</button>
)}
</div>
</div>
)
}
// --- Add guest form --------------------------------------------------------
function AddGuestForm({
onAdd,
onCancel,
}: {
onAdd: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
onCancel: () => void
}) {
const [name, setName] = useState("")
const [gender, setGender] = useState<Gender>("male")
const [hcp, setHcp] = useState("")
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
onAdd(trimmed, gender, parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null, statLevel)
}
return (
<form onSubmit={handleSubmit} className="mt-3 flex flex-col gap-5 rounded-2xl border border-dashed border-border bg-card/50 p-4 sm:p-5">
<div className="flex flex-col gap-2">
<Label htmlFor="guest-name" className="text-base font-semibold">
Navn
</Label>
<Input id="guest-name" autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="Gjestens navn" className="h-12 rounded-2xl text-base" />
</div>
<ChoiceRow
label="Kjønn"
options={[
{ value: "male", label: "Mann" },
{ value: "female", label: "Kvinne" },
{ value: "other", label: "Annet" },
]}
value={gender}
onChange={(v) => setGender(v as Gender)}
readOnly={false}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="guest-hcp" className="text-base font-semibold">
HCP <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id="guest-hcp"
inputMode="decimal"
type="number"
step="0.1"
value={hcp}
onChange={(e) => setHcp(e.target.value)}
placeholder="F.eks. 18"
className="h-12 rounded-2xl text-base"
/>
</div>
<ChoiceRow
label="Statistikk for denne spilleren"
options={[
{ value: "strokes_only", label: "Kun slag" },
{ value: "strokes_and_putts", label: "Slag og putter" },
{ value: "full", label: "All statistikk" },
]}
value={statLevel}
onChange={(v) => setStatLevel(v as StatLevel)}
readOnly={false}
/>
<div className="flex gap-3">
<Button type="submit" className="h-12 flex-1 rounded-2xl text-base font-bold">
Legg til
</Button>
<Button type="button" variant="ghost" onClick={onCancel} className="h-12 rounded-2xl px-6 text-base font-semibold">
Avbryt
</Button>
</div>
</form>
)
}
// --- Hole navigation -------------------------------------------------------
function HoleNav({
holes,
currentHole,
isPlayed,
onSelect,
}: {
holes: Hole[]
currentHole: number
isPlayed: (holeNumber: number) => boolean
onSelect: (holeNumber: number) => void
}) {
return (
<nav aria-label="Velg hull" className="-mx-5 mt-5 overflow-x-auto px-5">
<div className="flex gap-2 pb-1">
{holes.map((h) => {
const active = h.holeNumber === currentHole
const played = isPlayed(h.holeNumber)
return (
<button
key={h.holeNumber}
type="button"
onClick={() => onSelect(h.holeNumber)}
aria-current={active ? "true" : undefined}
aria-label={`Hull ${h.holeNumber}${played ? ", spilt" : ""}`}
className={cn(
"flex size-12 shrink-0 items-center justify-center rounded-2xl border text-base font-bold tabular-nums transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: played
? "border-primary/40 bg-primary/10 text-foreground hover:bg-primary/20"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{h.holeNumber}
</button>
)
})}
</div>
</nav>
)
}
// --- Statistikknivå-velger ---------------------------------------------------
// "Hullet er spilt" fantes tidligere som egen avkrysning, men var reelt
// overflødig -- score settes allerede automatisk til "spilt" idet et
// slagtall velges (se onChange på Slag-NumberPicker under). Fjernet
// 2026-07-24 på brukerens eksplisitte bekreftelse.
function StatLevelPicker({ value, onChange }: { value: StatLevel; onChange: (v: StatLevel) => void }) {
const options: { value: StatLevel; label: string }[] = [
{ value: "strokes_only", label: "Kun slag" },
{ value: "strokes_and_putts", label: "Slag og putter" },
{ value: "full", label: "All statistikk" },
]
return (
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Statistikk</span>
<div className="flex gap-1.5">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
aria-pressed={value === opt.value}
className={cn(
"rounded-full px-3 py-1.5 text-xs font-bold transition-colors",
value === opt.value
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent",
)}
>
{opt.label}
</button>
))}
</div>
</div>
)
}
// --- Number picker ---------------------------------------------------------
function NumberPicker({
label,
value,
directValues,
expandValues,
expandLabel,
onChange,
readOnly,
}: {
label: string
value: number | null
directValues: number[]
expandValues: number[]
expandLabel: string
onChange: (value: number) => void
readOnly: boolean
}) {
const valueInExpand = value !== null && expandValues.includes(value)
const [expanded, setExpanded] = useState(false)
const showExpanded = expanded || valueInExpand
const visibleValues = showExpanded ? [...directValues, ...expandValues] : directValues
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
<div className="flex flex-wrap gap-2">
{visibleValues.map((n) => {
const selected = value === n
return (
<button
key={n}
type="button"
disabled={readOnly}
onClick={() => onChange(n)}
aria-label={`${label} ${n}`}
aria-pressed={selected}
className={cn(
"flex size-12 items-center justify-center rounded-2xl border text-lg font-bold tabular-nums transition-colors disabled:opacity-100",
selected ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
readOnly && !selected && "opacity-40",
)}
>
{n}
</button>
)
})}
{!showExpanded && !readOnly && (
<button
type="button"
onClick={() => setExpanded(true)}
className="flex h-12 min-w-12 items-center justify-center rounded-2xl border border-dashed border-border bg-card px-3 text-base font-bold text-foreground transition-colors hover:bg-accent/50"
>
{expandLabel}
</button>
)}
</div>
</div>
)
}
// --- Choice row (segmented buttons) ----------------------------------------
function ChoiceRow({
label,
options,
value,
onChange,
readOnly,
}: {
label: string
options: { value: string; label: string }[]
value: string | null
onChange: (value: string) => void
readOnly: boolean
}) {
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
<div className="flex flex-wrap gap-2">
{options.map((opt) => {
const selected = value === opt.value
return (
<button
key={opt.value}
type="button"
disabled={readOnly}
onClick={() => onChange(opt.value)}
aria-pressed={selected}
className={cn(
"flex min-h-12 flex-1 items-center justify-center rounded-2xl border px-4 text-base font-bold transition-colors disabled:opacity-100",
selected ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
readOnly && !selected && "opacity-40",
)}
>
{opt.label}
</button>
)
})}
</div>
</div>
)
}
// --- Stepper (+/-) ---------------------------------------------------------
function Stepper({ label, value, onChange, readOnly }: { label: string; value: number; onChange: (value: number) => void; readOnly: boolean }) {
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
<div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-card p-1.5">
<button
type="button"
disabled={readOnly || value <= 0}
onClick={() => onChange(Math.max(0, value - 1))}
aria-label={`Færre ${label}`}
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
>
<Minus aria-hidden="true" className="size-5" />
</button>
<span className="min-w-8 text-center text-xl font-extrabold tabular-nums text-foreground">{value}</span>
<button
type="button"
disabled={readOnly}
onClick={() => onChange(value + 1)}
aria-label={`Flere ${label}`}
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
>
<Plus aria-hidden="true" className="size-5" />
</button>
</div>
</div>
)
}