teecup/frontend/components/round-detail.tsx
Erol Haagenrud 926a1a5275 All three changes are implemented and scratch-verified (22/22 checks passed, test_isolation.sql still 12/12, frontend typechecks cleanly). Summary of what's ready to ship:
Enkeltbane-anlegg (Tjøme m.fl.): banenavnet droppes nå når anlegget bare har én bane — "Tjøme Golfklubb" i stedet for "Tjøme Golfklubb – Hovedbanen". Anlegg med flere baner (f.eks. Ålesund) beholder fortsatt kombinert navn. Gjelder både turnering-import og frittstående runder.
Navngi runder: nytt valgfritt name-felt på round (migrasjon 024_round_name.sql), settbart ved opprettelse og redigerbart/fjernbart senere via "Rediger runde". Vises på tvers av rundeliste, rundeside, scorekort og statistikk (faller tilbake til banenavn når ikke satt).
Land før hjemmeklubb: "Land" er nå en nedtrekksliste (kun "Norge" foreløpig, klargjort for flere), og "Hjemmeklubb" er en søkbar liste mot teeoffs ekte klubbregister (gjenbruker det eksisterende /rounds/official-search-endepunktet — ingen ny backend-kode). Gjelder både profil-fullføring og kontoinnstillinger.
2026-07-25 06:29:09 +02:00

2120 lines
84 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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, useRef, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
CalendarDays,
Check,
ChevronDown,
Clock,
Trash2,
ChevronLeft,
ChevronRight,
Flag,
MapPin,
Minus,
Plus,
Search,
Settings2,
Target,
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
name: string | null
course_name_snapshot: string
tee_name_snapshot: string
played_at: string
start_hole: number
holes_planned: number
started_at: string | null
completed_at: string | null
participants: ApiParticipant[]
}
// Utslagstid vises i klokkeslett-format, og tidsbruk beregnes som
// completed_at - started_at (begge ekte tidsstempler) -- ingen egen
// lagret varighet, kun utledet ved visning.
const timeFormatter = new Intl.DateTimeFormat("no-NO", { hour: "2-digit", minute: "2-digit" })
function formatTime(value: string) {
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return value
return timeFormatter.format(parsed)
}
function formatDuration(startedAt: string, completedAt: string): string | null {
const start = new Date(startedAt).getTime()
const end = new Date(completedAt).getTime()
if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return null
const totalMinutes = Math.round((end - start) / 60000)
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
if (hours === 0) return `${minutes} min`
return `${hours}t ${minutes}min`
}
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
strokes_received: 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 router = useRouter()
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 [showAddGuest, setShowAddGuest] = useState(false)
const [completing, setCompleting] = useState(false)
const [deleting, setDeleting] = useState(false)
const [showEditRound, setShowEditRound] = useState(false)
// "Flere detaljer" er kollapset som default (etterspurt av bruker
// 2026-07-25 -- scorekortet var "veldig dårlig designet", altfor mye
// stablet oppå hverandre for et enkelt slag-registrering) slik at en
// rask slag-/putt-registrering ikke krever noe scrolling i det hele tatt.
const [detailsOpen, setDetailsOpen] = useState(false)
// Hull-panelet gjenbrukes ved bytte av hull -- "Forrige"/"Neste hull" ligger
// NEDERST i panelet, så uten dette ville brukeren blitt stående scrollet
// helt ned (der knappene er) mens det NYE hullets Slag-felt (øverst i
// panelet) er utenfor skjermen (rapportert av bruker 2026-07-25).
const holePanelRef = useRef<HTMLElement>(null)
function scrollToHolePanel() {
holePanelRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })
}
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
// (sirkulært over 18 hull), men KUN så mange hull som runden faktisk
// planlegger (holes_planned) -- en 9-hulls runde 10-18 skal aldri vise
// hull 1-9 i det hele tatt, og "Forrige" fra hull 10 skal gå til hull
// 18 (siste i settet), ikke hull 9 (funnet 2026-07-24: brukte tidligere
// alltid 18, uansett holes_planned).
const holeOrder = round
? Array.from({ length: round.holes_planned }, (_, 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 + holeOrder.length) % holeOrder.length])
scrollToHolePanel()
}
function goNext() {
const i = holeOrder.indexOf(activeHole)
setCurrentHole(holeOrder[(i + 1) % holeOrder.length])
scrollToHolePanel()
}
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)
}
}
async function deleteRound() {
if (!confirm("Slette denne runden permanent? Dette kan ikke angres, og gjelder for alle spillerne i runden.")) return
setDeleting(true)
try {
const res = await fetch(`/rounds/${roundId}`, { method: "DELETE", credentials: "include" })
if (!res.ok) throw new Error()
router.replace("/my-rounds")
} catch {
setError("Klarte ikke å slette runden. Prøv igjen.")
setDeleting(false)
}
}
// Retter opp feil bane/utslag eller feil antall hull underveis i runden
// (2026-07-24) -- rører ALDRI allerede registrerte slag/putter/etc.,
// kun rating-grunnlaget. Backend avviser tydelig (400) hvis en deltaker
// ville mistet HCP-sporing, uten å skrive noe.
async function patchRound(body: Record<string, unknown>): Promise<{ ok: true } | { ok: false; message: string }> {
const res = await fetch(`/rounds/${roundId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
})
if (!res.ok) {
const errBody = await res.json().catch(() => null)
return { ok: false, message: errBody?.detail?.message ?? "Klarte ikke å oppdatere runden. Prøv igjen." }
}
const updated: ApiRound = await res.json()
setRound(updated)
// Par/stroke-indeks kan ha endret seg for ALLE deltakere -- hent hull-
// dataen på nytt for alle vi allerede har lastet, ikke bare aktiv spiller.
const participantIds = Object.keys(holesByParticipant)
for (const pid of participantIds) {
await loadHoles(pid)
}
return { ok: true }
}
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.name?.trim() || 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">
{round.name?.trim() && (
<span className="flex items-center gap-1.5 truncate">
<MapPin aria-hidden="true" className="size-4 shrink-0" />
{round.course_name_snapshot}
</span>
)}
<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>
{round.started_at && (
<span className="flex items-center gap-1.5 tabular-nums">
<Clock aria-hidden="true" className="size-4 shrink-0" />
{formatTime(round.started_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>
)}
{/* Rediger/Fullfør/Slett samlet ØVERST, bevisst tonet ned (samme
nøytrale trigger-stil som hverandre) -- disse lå tidligere som
store, fremtredende knapper RETT under "Neste hull"-navigasjonen
nederst i hull-panelet, som gjorde det altfor lett å trykke feil
ved et uhell mens man bare skulle bla mellom hull (rapportert av
bruker 2026-07-25). Ved å flytte dem hit må man aktivt scrolle OPP
forbi hull-registreringen for å nå dem i det hele tatt. */}
<div className="mb-5 flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setShowEditRound((v) => !v)}
aria-expanded={showEditRound}
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<Settings2 aria-hidden="true" className="size-4" />
Rediger runde
</button>
{!completed && (
<button
type="button"
disabled={completing}
onClick={finishRound}
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-60"
>
<Check aria-hidden="true" className="size-4" />
{completing ? "Fullfører…" : "Fullfør runde"}
</button>
)}
<button
type="button"
disabled={deleting}
onClick={deleteRound}
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-60"
>
<Trash2 aria-hidden="true" className="size-4" />
{deleting ? "Sletter…" : "Slett runde"}
</button>
</div>
{showEditRound && (
<EditRoundPanel round={round} onPatch={patchRound} onClose={() => setShowEditRound(false)} />
)}
</div>
{/* Completed summary banner */}
{completed && (
<CompletedBanner
players={players}
duration={round.started_at && round.completed_at ? formatDuration(round.started_at, round.completed_at) : null}
roundId={roundId}
/>
)}
{/* 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)}
/>
)}
{/* Skjules når runden er fullført (2026-07-25, rapportert av bruker
som "veldig mye dobbel informasjon") -- CompletedBanner sin
"Se full rundestatistikk"-lenke dekker nå akkurat det samme,
langt grundigere. Fortsatt nyttig som live fremdriftsoversikt
mens runden pågår. */}
{!completed && <ScoreSoFar holes={apiHoles} order={holeOrder} />}
{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)
scrollToHolePanel()
}}
/>
{/* Current hole panel */}
{hole && (
<section
ref={holePanelRef}
className="mt-5 flex scroll-mt-28 flex-col gap-6 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"
>
{/* Høyre kolonne reserverer plass ved siden av hull-headeren
til en fremtidig avstandsmåling-indikator (ikke bygget
ennå, men avklart 2026-07-25 at det kommer) -- GIR-merket
ligger under den reserverte plassen slik at et senere
avstand-chip her ikke krever noen layout-endring. */}
<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} · Hcp {hole.index}
</h2>
<div className="flex shrink-0 flex-col items-end gap-2">
{showGir && (
<span className="rounded-full bg-primary px-3 py-1.5 text-sm font-bold text-primary-foreground">
GIR
</span>
)}
</div>
</div>
{/* Slag/Putter og (ved statLevel="full") alle detaljene vises
ALLTID samlet under hverandre -- ikke bak en fane man må
oppdage og trykke på (funnet 2026-07-25: brukeren huket
av "All statistikk", men fikk aldri se noe utover
slag/putter siden det lå bak en fane). */}
<div className="flex flex-col gap-6">
<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+"
parValue={hole.par}
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+"
maxValue={currentStat.strokes ?? undefined}
onChange={(v) => updateStat({ putts: v })}
readOnly={readOnly}
/>
)}
{activePlayer.statLevel === "full" && (
<>
{/* Rett under antall putter (etterspurt av bruker 2026-07-25) --
hører naturlig sammen med Putter-feltet rett over, ikke med
kølle/retning/chip-gruppen lenger ned. */}
<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}
/>
{/* Alt annet lever bak én kollapsbar seksjon, lukket som
default -- panelet forblir kort for det vanlige
slag-/putt-registreringstilfellet. */}
<div className="overflow-hidden rounded-2xl border border-border">
<button
type="button"
onClick={() => setDetailsOpen((v) => !v)}
aria-expanded={detailsOpen}
className="flex min-h-14 w-full items-center justify-between gap-2 px-4 py-3 text-left text-base font-bold text-foreground transition-colors hover:bg-accent/50"
>
Flere detaljer
<ChevronDown
aria-hidden="true"
className={cn(
"size-5 shrink-0 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 && (
<DirectionCross
label="Utslag"
variant="horizontal"
centerLabel="Fairway"
value={currentStat.teeShot}
onChange={(v) => updateStat({ teeShot: v as TeeShot })}
readOnly={readOnly}
/>
)}
<DirectionCross
label="Innspill"
variant="full"
centerLabel="Traff"
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} max={currentStat.strokes ?? undefined} />
<Stepper label="Bunker" value={currentStat.bunker} onChange={(v) => updateStat({ bunker: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
<Stepper label="Straffeslag" value={currentStat.penalty} onChange={(v) => updateStat({ penalty: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
</div>
<NumberPicker
label="Anywayslag"
value={currentStat.anywayStrokes}
directValues={[0, 1, 2, 3, 4, 5, 6]}
expandValues={[7, 8, 9, 10]}
expandLabel="7+"
maxValue={currentStat.strokes ?? undefined}
onChange={(v) => updateStat({ anywayStrokes: v })}
readOnly={readOnly}
/>
</div>
)}
</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>
)}
</>
)}
</main>
</div>
)
}
// --- Completed banner ------------------------------------------------------
function CompletedBanner({
players,
duration,
roundId,
}: {
players: Player[]
duration: string | null
roundId: string
}) {
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>
{duration && (
<div className="ml-auto flex flex-col items-end">
<span className="text-xs font-medium text-muted-foreground">Tid brukt</span>
<span className="flex items-center gap-1.5 text-lg font-extrabold tabular-nums text-foreground">
<Clock aria-hidden="true" className="size-4 text-primary" />
{duration}
</span>
</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 className="flex flex-col gap-2 sm:flex-row">
<Link
href={`/my-rounds/${roundId}/scorecard`}
className="flex min-h-12 flex-1 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
>
Se scorekort
<ChevronRight aria-hidden="true" className="size-5" />
</Link>
<Link
href={`/my-rounds/${roundId}/stats`}
className="flex min-h-12 flex-1 items-center justify-center gap-1.5 rounded-2xl border border-primary/40 bg-card px-5 text-base font-bold text-foreground transition-colors hover:bg-accent/50"
>
Se full rundestatistikk
<ChevronRight aria-hidden="true" className="size-5" />
</Link>
</div>
</div>
)
}
// --- Score så langt (kompakt linje + utvidbar full oversikt) --------------
// Etterspurt av bruker 2026-07-25 med referanse til en annen golf-app --
// samme "Vis full oversikt"-mønster som allerede etablert i
// session-scorecard.tsx (turnering-scoring), tilpasset til en enkelt
// spillers frittstående runde (ingen motstander-side å vise her).
//
// Utvidet SAMME DAG, etterspurt av bruker: netto-/stableford-sum, putt-/
// kølle-statistikk-totaler, og grafisk fairway-/innspill-fordeling +
// gjennomsnittlig score-til-par splittet på treff/bom. Appen har ingen
// egen "spilleform"-innstilling for frittstående runder -- stableford
// beregnes derfor alltid ut fra netto score der det er mulig (krever
// registrert HCP), uavhengig av om brukeren "egentlig" spiller slagspill
// eller stableford -- rent informativt, ikke en offisiell poengsum.
// Standard stableford-poengtabell: netto dobbel bogey eller dårligere = 0,
// netto par = 2, hvert slag bedre/dårligere enn par gir ett poeng mer/mindre.
function stablefordPoints(netScore: number, par: number): number {
return Math.max(0, 2 - (netScore - par))
}
function avgScoreToPar(holes: ApiHole[]): number | null {
const played = holes.filter((h) => h.played && h.score !== null)
if (played.length === 0) return null
const diffSum = played.reduce((sum, h) => sum + ((h.score as number) - h.par), 0)
return diffSum / played.length
}
function formatSignedAvg(n: number): string {
const fixed = Math.abs(n).toFixed(2)
return n > 0 ? `+${fixed}` : n < 0 ? `-${fixed}` : fixed
}
function ScoreSoFar({ holes, order }: { holes: ApiHole[] | undefined; order: number[] }) {
const [expanded, setExpanded] = useState(false)
if (!holes || holes.length === 0) return null
const orderedHoles = order
.map((n) => holes.find((h) => h.hole_number === n))
.filter((h): h is ApiHole => h !== undefined)
const playedSoFar = orderedHoles.filter((h) => h.played && h.score !== null)
if (playedSoFar.length === 0) return null
const totalStrokes = playedSoFar.reduce((sum, h) => sum + (h.score ?? 0), 0)
const totalPar = playedSoFar.reduce((sum, h) => sum + h.par, 0)
const toPar = totalStrokes - totalPar
const toParLabel = toPar === 0 ? "Par" : toPar > 0 ? `+${toPar}` : `${toPar}`
// Netto/stableford krever at deltakeren har en beregnet course handicap
// (strokes_received er da satt på ALLE hull, ellers på ingen) -- uten
// det vises rett og slett ikke disse to (f.eks. en gjest uten HCP).
const withNetto = playedSoFar.filter((h) => h.strokes_received !== null)
const hasNetto = withNetto.length > 0
const nettoTotal = hasNetto
? withNetto.reduce((sum, h) => sum + ((h.score as number) - (h.strokes_received as number)), 0)
: null
const stablefordTotal = hasNetto
? withNetto.reduce(
(sum, h) => sum + stablefordPoints((h.score as number) - (h.strokes_received as number), h.par),
0,
)
: null
const puttsPlayed = orderedHoles.filter((h) => h.putts !== null)
const puttsTotal = puttsPlayed.length > 0 ? puttsPlayed.reduce((sum, h) => sum + (h.putts as number), 0) : null
const chipPlayed = orderedHoles.filter((h) => h.chip_count !== null)
const chipTotal = chipPlayed.length > 0 ? chipPlayed.reduce((sum, h) => sum + (h.chip_count as number), 0) : null
const bunkerPlayed = orderedHoles.filter((h) => h.bunker_shot_count !== null)
const bunkerTotal =
bunkerPlayed.length > 0 ? bunkerPlayed.reduce((sum, h) => sum + (h.bunker_shot_count as number), 0) : null
const penaltyPlayed = orderedHoles.filter((h) => h.penalty_strokes !== null)
const penaltyTotal =
penaltyPlayed.length > 0 ? penaltyPlayed.reduce((sum, h) => sum + (h.penalty_strokes as number), 0) : null
const anywayPlayed = orderedHoles.filter((h) => h.anyway_strokes !== null)
const anywayTotal =
anywayPlayed.length > 0 ? anywayPlayed.reduce((sum, h) => sum + (h.anyway_strokes as number), 0) : null
// Fairwaytreff spores kun på hull som ikke er par 3 (samme regel som
// registrerings-skjemaet -- DirectionCross for Utslag vises ikke der).
const fairwayTracked = orderedHoles.filter((h) => h.par !== 3 && h.tee_shot_result !== null)
const fairwayHit = fairwayTracked.filter((h) => h.tee_shot_result === "fairway")
const fairwayLeft = fairwayTracked.filter((h) => h.tee_shot_result === "left")
const fairwayRight = fairwayTracked.filter((h) => h.tee_shot_result === "right")
const fairwayMiss = [...fairwayLeft, ...fairwayRight]
const avgToParFairwayHit = avgScoreToPar(fairwayHit)
const avgToParFairwayMiss = avgScoreToPar(fairwayMiss)
const approachTracked = orderedHoles.filter((h) => h.approach_result !== null)
const approachHit = approachTracked.filter((h) => h.approach_result === "hit")
const approachShort = approachTracked.filter((h) => h.approach_result === "short")
const approachLong = approachTracked.filter((h) => h.approach_result === "long")
const approachLeft = approachTracked.filter((h) => h.approach_result === "left")
const approachRight = approachTracked.filter((h) => h.approach_result === "right")
const approachMiss = [...approachShort, ...approachLong, ...approachLeft, ...approachRight]
const avgToParApproachHit = avgScoreToPar(approachHit)
const avgToParApproachMiss = avgScoreToPar(approachMiss)
let running = 0
return (
<div className="mb-4 overflow-hidden rounded-2xl border border-border bg-card">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
>
<span className="text-sm font-semibold text-foreground text-pretty">
langt: {playedSoFar.length} hull · {totalStrokes} slag · {toParLabel} til par
{hasNetto && ` · Netto ${nettoTotal! > 0 ? "+" : ""}${nettoTotal}`}
</span>
<span className="shrink-0 text-sm font-semibold text-primary">{expanded ? "Skjul" : "Vis full oversikt"}</span>
</button>
{expanded && (
<div className="border-t border-border">
<div className="grid grid-cols-2 gap-2 p-4 sm:grid-cols-3">
<StatPill label="Slag" value={String(totalStrokes)} />
<StatPill label="Til par" value={toParLabel} />
{hasNetto && <StatPill label="Netto" value={nettoTotal! > 0 ? `+${nettoTotal}` : String(nettoTotal)} />}
{hasNetto && <StatPill label="Stableford" value={`${stablefordTotal} poeng`} />}
{puttsTotal !== null && <StatPill label="Putt" value={String(puttsTotal)} />}
{chipTotal !== null && <StatPill label="Chip" value={String(chipTotal)} />}
{bunkerTotal !== null && <StatPill label="Bunker" value={String(bunkerTotal)} />}
{penaltyTotal !== null && <StatPill label="Straffeslag" value={String(penaltyTotal)} />}
{anywayTotal !== null && <StatPill label="Anywayslag" value={String(anywayTotal)} />}
</div>
<div className="overflow-x-auto border-t border-border">
<table className="w-full min-w-[420px] text-sm">
<thead>
<tr className="border-b border-border text-left text-xs font-bold uppercase tracking-wide text-muted-foreground">
<th className="px-3 py-2">Hull</th>
<th className="px-3 py-2">Par</th>
<th className="px-3 py-2">Score</th>
<th className="px-3 py-2">Netto</th>
{hasNetto && <th className="px-3 py-2">Stableford</th>}
<th className="px-3 py-2">Sum</th>
</tr>
</thead>
<tbody>
{orderedHoles.map((h) => {
const played = h.played && h.score !== null
if (played) running += h.score as number
const netto = played && h.strokes_received !== null ? (h.score as number) - h.strokes_received : null
const points = played && netto !== null ? stablefordPoints(netto, h.par) : null
return (
<tr key={h.hole_number} className="border-b border-border last:border-b-0">
<td className="px-3 py-2 font-bold tabular-nums text-foreground">{h.hole_number}</td>
<td className="px-3 py-2 tabular-nums text-muted-foreground">{h.par}</td>
<td className="px-3 py-2 tabular-nums font-semibold text-foreground">{played ? h.score : ""}</td>
<td className="px-3 py-2 tabular-nums text-muted-foreground">{netto ?? ""}</td>
{hasNetto && (
<td className="px-3 py-2 tabular-nums text-muted-foreground">{points ?? ""}</td>
)}
<td className="px-3 py-2 tabular-nums font-bold text-foreground">{played ? running : ""}</td>
</tr>
)
})}
</tbody>
</table>
</div>
{fairwayTracked.length > 0 && (
<div className="flex flex-col gap-2 border-t border-border p-4">
<span className="text-sm font-bold text-foreground">Fairwaytreff</span>
<DistributionBar
segments={[
{ label: "Fairway", count: fairwayHit.length, colorClass: "bg-emerald-500" },
{ label: "Venstre", count: fairwayLeft.length, colorClass: "bg-amber-500" },
{ label: "Høyre", count: fairwayRight.length, colorClass: "bg-rose-500" },
]}
/>
{(avgToParFairwayHit !== null || avgToParFairwayMiss !== null) && (
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold text-muted-foreground">
{avgToParFairwayHit !== null && (
<span>
Til par ved fairwaytreff:{" "}
<strong className="text-foreground">{formatSignedAvg(avgToParFairwayHit)}</strong>
</span>
)}
{avgToParFairwayMiss !== null && (
<span>
Til par ved bom fairway:{" "}
<strong className="text-foreground">{formatSignedAvg(avgToParFairwayMiss)}</strong>
</span>
)}
</div>
)}
</div>
)}
{approachTracked.length > 0 && (
<div className="flex flex-col gap-2 border-t border-border p-4">
<span className="text-sm font-bold text-foreground">Innspill</span>
<DistributionBar
segments={[
{ label: "Traff", count: approachHit.length, colorClass: "bg-emerald-500" },
{ label: "Kort", count: approachShort.length, colorClass: "bg-amber-500" },
{ label: "Langt", count: approachLong.length, colorClass: "bg-orange-500" },
{ label: "Venstre", count: approachLeft.length, colorClass: "bg-sky-500" },
{ label: "Høyre", count: approachRight.length, colorClass: "bg-rose-500" },
]}
/>
{(avgToParApproachHit !== null || avgToParApproachMiss !== null) && (
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold text-muted-foreground">
{avgToParApproachHit !== null && (
<span>
Til par ved treff innspill:{" "}
<strong className="text-foreground">{formatSignedAvg(avgToParApproachHit)}</strong>
</span>
)}
{avgToParApproachMiss !== null && (
<span>
Til par ved bom innspill:{" "}
<strong className="text-foreground">{formatSignedAvg(avgToParApproachMiss)}</strong>
</span>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
)
}
function StatPill({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col items-center justify-center gap-0.5 rounded-xl border border-border bg-background px-2 py-2.5 text-center">
<span className="text-lg font-extrabold tabular-nums text-foreground">{value}</span>
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
</div>
)
}
// Ett rektangel delt i fargede segmenter proporsjonalt med antall --
// samme visuelle idé som SegmentedBar i tournament-leaderboard.tsx, men
// generalisert til N kategorier (ikke bare to lag).
function DistributionBar({ segments }: { segments: { label: string; count: number; colorClass: string }[] }) {
const total = segments.reduce((sum, s) => sum + s.count, 0)
if (total === 0) return null
return (
<div className="flex flex-col gap-2">
<div className="flex h-5 w-full overflow-hidden rounded-full border border-border">
{segments
.filter((s) => s.count > 0)
.map((s) => (
<div key={s.label} aria-hidden="true" style={{ width: `${(s.count / total) * 100}%` }} className={s.colorClass} />
))}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1">
{segments
.filter((s) => s.count > 0)
.map((s) => (
<span key={s.label} className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<span aria-hidden="true" className={cn("inline-block size-2.5 rounded-full", s.colorClass)} />
{s.label} {Math.round((s.count / total) * 100)}% ({s.count})
</span>
))}
</div>
</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>
)
}
// --- Rediger runde (bane/utslag/antall hull) -------------------------------
// Retter opp feil bane eller feil antall hull underveis -- rører ALDRI
// allerede registrerte slag/putter/etc. (backend garanterer dette, se
// PATCH /rounds/{id}). Kun tilgjengelig før runden er fullført.
type ApiFacility = { slug: string; name: string; city: string | null; county: string | null }
type ApiOfficialCourseOption = { teeoff_course_id: number; name: string; is_main_course: boolean; tees: { name: string }[] }
type ApiPersonalCourseSummary = { id: string; name: string }
type ApiPersonalCourseDetail = { id: string; name: string; tees: { name: string }[] }
// Konverterer til/fra <input type="datetime-local">-verdiformatet
// ("YYYY-MM-DDTHH:mm", lokal tid) -- selve lagringen skjer som ekte
// tidsstempler (UTC), kun visningen er lokal.
function toDatetimeLocalValue(iso: string | null): string {
if (!iso) return ""
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ""
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000)
return local.toISOString().slice(0, 16)
}
function fromDatetimeLocalValue(value: string): string | null {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d.toISOString()
}
function EditRoundPanel({
round,
onPatch,
onClose,
}: {
round: ApiRound
onPatch: (body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
onClose: () => void
}) {
const isCompleted = round.completed_at !== null
const [name, setName] = useState(round.name ?? "")
const [holesPlanned, setHolesPlanned] = useState<9 | 18>(round.holes_planned === 9 ? 9 : 18)
const [startHole, setStartHole] = useState(String(round.start_hole))
const [startedAt, setStartedAt] = useState(toDatetimeLocalValue(round.started_at))
const [completedAt, setCompletedAt] = useState(toDatetimeLocalValue(round.completed_at))
const [saving, setSaving] = useState(false)
const [showChangeCourse, setShowChangeCourse] = useState(false)
const [error, setError] = useState<string | null>(null)
const hasMetadataChanges =
name.trim() !== (round.name ?? "") ||
(!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) ||
toDatetimeLocalValue(round.started_at) !== startedAt ||
(isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt)
async function saveMetadata() {
setSaving(true)
setError(null)
const body: Record<string, unknown> = {}
// Tom streng betyr "fjern navnet" (backend-kontrakt) -- send den derfor
// alltid med når feltet faktisk er endret, aldri utelatt for å tømme.
if (name.trim() !== (round.name ?? "")) body.name = name.trim()
if (!isCompleted) {
if (holesPlanned !== round.holes_planned) body.holes_planned = holesPlanned
if (Number(startHole) !== round.start_hole) body.start_hole = Number(startHole)
}
if (toDatetimeLocalValue(round.started_at) !== startedAt) body.started_at = fromDatetimeLocalValue(startedAt)
if (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) {
body.completed_at = fromDatetimeLocalValue(completedAt)
}
const result = await onPatch(body)
if (!result.ok) setError(result.message)
setSaving(false)
}
return (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-bold text-foreground">Rediger runde</span>
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-8 rounded-lg text-muted-foreground" aria-label="Lukk">
<X aria-hidden="true" className="size-4" />
</Button>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
<div className="flex flex-col gap-2">
<Label htmlFor="edit-round-name" className="text-sm font-semibold">
Navn runden <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id="edit-round-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={round.course_name_snapshot}
maxLength={200}
className="h-11 rounded-xl text-base"
/>
</div>
{!isCompleted && (
<>
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Antall hull</span>
<div className="flex gap-2">
{([9, 18] as const).map((n) => (
<button
key={n}
type="button"
onClick={() => setHolesPlanned(n)}
aria-pressed={holesPlanned === n}
className={cn(
"h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
holesPlanned === n
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-accent/50",
)}
>
{n} hull
</button>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="edit-start-hole" className="text-sm font-semibold">
Starthull
</Label>
<select
id="edit-start-hole"
value={startHole}
onChange={(e) => setStartHole(e.target.value)}
className="h-11 rounded-xl border border-border bg-background px-3 text-base font-semibold text-foreground"
>
{Array.from({ length: 18 }, (_, i) => i + 1).map((n) => (
<option key={n} value={String(n)}>
Hull {n}
</option>
))}
</select>
</div>
</>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="edit-started-at" className="text-sm font-semibold">
Utslagstid
</Label>
<Input
id="edit-started-at"
type="datetime-local"
value={startedAt}
onChange={(e) => setStartedAt(e.target.value)}
className="h-11 rounded-xl text-base"
/>
</div>
{isCompleted && (
<div className="flex flex-col gap-2">
<Label htmlFor="edit-completed-at" className="text-sm font-semibold">
Fullført-tidspunkt
</Label>
<Input
id="edit-completed-at"
type="datetime-local"
value={completedAt}
onChange={(e) => setCompletedAt(e.target.value)}
className="h-11 rounded-xl text-base"
/>
</div>
)}
{hasMetadataChanges && (
<Button type="button" disabled={saving} onClick={saveMetadata} className="h-11 rounded-xl text-sm font-bold">
{saving ? "Lagrer…" : "Lagre endringer"}
</Button>
)}
{!isCompleted && (
<div className="border-t border-border pt-4">
{showChangeCourse ? (
<ChangeCourseForm onPatch={onPatch} onCancel={() => setShowChangeCourse(false)} />
) : (
<button
type="button"
onClick={() => setShowChangeCourse(true)}
className="inline-flex min-h-11 items-center gap-1.5 text-sm font-semibold text-primary"
>
<Search aria-hidden="true" className="size-4" />
Bytt bane
</button>
)}
</div>
)}
</div>
)
}
function ChangeCourseForm({
onPatch,
onCancel,
}: {
onPatch: (body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
onCancel: () => void
}) {
const [source, setSource] = useState<"teeoff" | "custom" | null>(null)
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
// Teeoff-søk
const [query, setQuery] = useState("")
const [facilities, setFacilities] = useState<ApiFacility[] | null>(null)
const [selectedFacility, setSelectedFacility] = useState<ApiFacility | null>(null)
const [officialCourses, setOfficialCourses] = useState<ApiOfficialCourseOption[] | null>(null)
const [selectedOfficialCourse, setSelectedOfficialCourse] = useState<ApiOfficialCourseOption | null>(null)
// Egen bane-søk
const [customQuery, setCustomQuery] = useState("")
const [customResults, setCustomResults] = useState<ApiPersonalCourseSummary[]>([])
const [selectedCustomCourse, setSelectedCustomCourse] = useState<ApiPersonalCourseDetail | null>(null)
const [teeName, setTeeName] = useState("")
useEffect(() => {
if (source !== "custom") return
let cancelled = false
const timer = setTimeout(async () => {
try {
const res = await fetch(`/personal-courses?q=${encodeURIComponent(customQuery.trim())}`, { credentials: "include" })
if (res.ok && !cancelled) setCustomResults(await res.json())
} catch {
// Stille -- listen blir bare uendret, ingen kritisk feil å vise her.
}
}, 250)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [source, customQuery])
async function searchFacilities() {
setError(null)
try {
const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query.trim())}`, { credentials: "include" })
if (!res.ok) throw new Error()
setFacilities(await res.json())
} catch {
setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
}
}
async function pickFacility(facility: ApiFacility) {
setSelectedFacility(facility)
setError(null)
try {
const res = await fetch(`/rounds/official-search/${facility.slug}`, { credentials: "include" })
if (!res.ok) throw new Error()
const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
setOfficialCourses(detail.courses)
} catch {
setError("Klarte ikke å hente baner for dette anlegget.")
}
}
async function pickCustomCourse(course: ApiPersonalCourseSummary) {
setError(null)
try {
const res = await fetch(`/personal-courses/${course.id}`, { credentials: "include" })
if (!res.ok) throw new Error()
setSelectedCustomCourse(await res.json())
} catch {
setError("Klarte ikke å hente banedetaljer.")
}
}
const availableTees = selectedOfficialCourse?.tees ?? selectedCustomCourse?.tees ?? []
async function handleSubmit() {
if (!teeName) return
setSubmitting(true)
setError(null)
const body =
source === "teeoff" && selectedFacility && selectedOfficialCourse
? {
course_source: "teeoff",
teeoff_facility_slug: selectedFacility.slug,
teeoff_course_id: selectedOfficialCourse.teeoff_course_id,
tee_name: teeName,
}
: selectedCustomCourse
? { course_source: "custom", personal_course_id: selectedCustomCourse.id, tee_name: teeName }
: null
if (!body) {
setSubmitting(false)
return
}
const result = await onPatch(body)
setSubmitting(false)
if (!result.ok) {
setError(result.message)
return
}
onCancel()
}
return (
<div className="flex flex-col gap-3">
<p className="text-xs leading-relaxed text-muted-foreground text-pretty">
Endrer kun banen og utslaget videre -- allerede registrerte slag/putter for spilte hull røres ikke.
</p>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{source === null && (
<div className="grid grid-cols-2 gap-2">
<Button type="button" variant="outline" onClick={() => setSource("teeoff")} className="h-11 rounded-xl text-sm font-bold">
Offisiell bane
</Button>
<Button type="button" variant="outline" onClick={() => setSource("custom")} className="h-11 rounded-xl text-sm font-bold">
Egen bane
</Button>
</div>
)}
{source === "teeoff" && !selectedFacility && (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
searchFacilities()
}
}}
placeholder="Søk klubbnavn…"
className="h-11 flex-1 rounded-xl text-base"
/>
<Button type="button" onClick={searchFacilities} className="h-11 shrink-0 rounded-xl text-sm font-bold">
Søk
</Button>
</div>
{facilities && (
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
{facilities.map((f) => (
<li key={f.slug} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() => pickFacility(f)}
className="flex w-full flex-col px-3 py-2.5 text-left hover:bg-accent/60"
>
<span className="text-sm font-semibold text-foreground">{f.name}</span>
{(f.city || f.county) && (
<span className="text-xs text-muted-foreground">{[f.city, f.county].filter(Boolean).join(", ")}</span>
)}
</button>
</li>
))}
{facilities.length === 0 && <li className="px-3 py-2.5 text-center text-sm text-muted-foreground">Ingen treff.</li>}
</ul>
)}
</div>
)}
{source === "teeoff" && selectedFacility && !selectedOfficialCourse && (
<div className="flex flex-col gap-2">
<button type="button" onClick={() => setSelectedFacility(null)} className="self-start text-sm font-semibold text-muted-foreground">
{selectedFacility.name}
</button>
{officialCourses === null ? (
<p className="text-sm text-muted-foreground">Laster baner</p>
) : (
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
{officialCourses.map((c) => (
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() => setSelectedOfficialCourse(c)}
className="flex w-full items-center justify-between px-3 py-2.5 text-left text-sm font-semibold text-foreground hover:bg-accent/60"
>
{c.name}
</button>
</li>
))}
</ul>
)}
</div>
)}
{source === "custom" && !selectedCustomCourse && (
<div className="flex flex-col gap-2">
<Input
autoFocus
value={customQuery}
onChange={(e) => setCustomQuery(e.target.value)}
placeholder="Søk egendefinert bane…"
className="h-11 rounded-xl text-base"
/>
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
{customResults.map((c) => (
<li key={c.id} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() => pickCustomCourse(c)}
className="flex w-full px-3 py-2.5 text-left text-sm font-semibold text-foreground hover:bg-accent/60"
>
{c.name}
</button>
</li>
))}
{customResults.length === 0 && (
<li className="px-3 py-2.5 text-center text-sm text-muted-foreground">Ingen treff.</li>
)}
</ul>
</div>
)}
{(selectedOfficialCourse || selectedCustomCourse) && (
<div className="flex flex-col gap-3">
<span className="text-sm font-bold text-foreground">
{selectedOfficialCourse?.name ?? selectedCustomCourse?.name}
</span>
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Utslag</span>
<div className="flex flex-wrap gap-2">
{availableTees.map((t) => (
<button
key={t.name}
type="button"
onClick={() => setTeeName(t.name)}
aria-pressed={teeName === t.name}
className={cn(
"h-10 rounded-lg border px-3 text-sm font-semibold transition-colors",
teeName === t.name
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-accent/50",
)}
>
{t.name}
</button>
))}
</div>
</div>
<Button type="button" disabled={submitting || !teeName} onClick={handleSubmit} className="h-11 rounded-xl text-sm font-bold">
{submitting ? "Bytter bane…" : "Bytt bane"}
</Button>
</div>
)}
<Button type="button" variant="ghost" onClick={onCancel} className="h-10 self-start rounded-xl text-sm font-semibold text-muted-foreground">
Avbryt
</Button>
</div>
)
}
// --- 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,
parValue,
maxValue,
onChange,
readOnly,
}: {
label: string
value: number | null
directValues: number[]
expandValues: number[]
expandLabel: string
// Merker knappen som tilsvarer hullets par med en liten "par"-bildetekst
// (kun brukt for Slag-velgeren) -- gjør det tydelig hva som er par uten
// å måtte huske det fra hull-overskriften.
parValue?: number
// Skjuler valg høyere enn dette (f.eks. Putter kan aldri overstige
// antall slag registrert på hullet) -- ingen vits i å tilby et tall som
// uansett ville vært selvmotsigende.
maxValue?: number
onChange: (value: number) => void
readOnly: boolean
}) {
const cap = (values: number[]) => (maxValue === undefined ? values : values.filter((n) => n <= maxValue))
const cappedDirect = cap(directValues)
const cappedExpand = cap(expandValues)
const valueInExpand = value !== null && cappedExpand.includes(value)
const [expanded, setExpanded] = useState(false)
const showExpanded = expanded || valueInExpand
const visibleValues = showExpanded ? [...cappedDirect, ...cappedExpand] : cappedDirect
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
{/* Numpad: tre kolonner, knappene fyller bredden -- store trykkflater
for bruk utendørs, ofte med sol på skjermen (V0-runde 2026-07-24). */}
<div className="grid max-w-sm grid-cols-3 gap-2.5">
{visibleValues.map((n) => {
const selected = value === n
const isPar = n === parValue
return (
<button
key={n}
type="button"
disabled={readOnly}
onClick={() => onChange(n)}
aria-label={isPar ? `${label} ${n}, par` : `${label} ${n}`}
aria-pressed={selected}
className={cn(
"flex h-16 flex-col items-center justify-center gap-0 rounded-2xl border text-2xl font-extrabold leading-none tabular-nums transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border bg-card text-foreground hover:bg-accent/50",
readOnly && !selected && "opacity-40",
)}
>
<span>{n}</span>
{isPar && (
<span className={cn("text-[11px] font-bold leading-none", selected ? "text-primary-foreground/80" : "text-muted-foreground")}>
par
</span>
)}
</button>
)
})}
{!showExpanded && !readOnly && cappedExpand.length > 0 && (
<button
type="button"
onClick={() => setExpanded(true)}
className="col-span-3 flex h-14 items-center justify-center rounded-2xl border border-dashed border-border bg-card text-lg font-bold text-foreground transition-colors hover:bg-accent/50"
>
{expandLabel}
</button>
)}
</div>
</div>
)
}
// --- Retningskors (D-pad for Utslag / Innspill) -----------------------------
function DirButton({
icon: Icon,
label,
selected,
onClick,
readOnly,
}: {
icon: typeof ArrowUp
label: string
selected: boolean
onClick: () => void
readOnly: boolean
}) {
return (
<button
type="button"
disabled={readOnly}
onClick={onClick}
aria-pressed={selected}
className={cn(
"flex min-h-16 flex-col items-center justify-center gap-1 rounded-2xl border px-2 py-2 transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border bg-card text-foreground hover:bg-accent/50",
readOnly && !selected && "opacity-40",
)}
>
<Icon aria-hidden="true" className="size-6" />
<span className="text-sm font-bold">{label}</span>
</button>
)
}
function DirectionCross({
label,
value,
onChange,
readOnly,
variant,
centerLabel,
}: {
label: string
value: string | null
onChange: (value: string) => void
readOnly: boolean
// "horizontal" = kun venstre/senter/høyre (Utslag); "full" = 5-veis (Innspill)
variant: "horizontal" | "full"
centerLabel: string
}) {
const spacer = <div aria-hidden="true" />
return (
<div className="flex flex-col gap-2">
<span className="text-base font-semibold text-foreground">{label}</span>
<div className="grid max-w-sm grid-cols-3 gap-2.5">
{variant === "full" && (
<>
{spacer}
<DirButton icon={ArrowUp} label="Langt" selected={value === "long"} onClick={() => onChange("long")} readOnly={readOnly} />
{spacer}
</>
)}
<DirButton icon={ArrowLeft} label="Venstre" selected={value === "left"} onClick={() => onChange("left")} readOnly={readOnly} />
<DirButton
icon={Target}
label={centerLabel}
selected={value === (variant === "full" ? "hit" : "fairway")}
onClick={() => onChange(variant === "full" ? "hit" : "fairway")}
readOnly={readOnly}
/>
<DirButton icon={ArrowRight} label="Høyre" selected={value === "right"} onClick={() => onChange("right")} readOnly={readOnly} />
{variant === "full" && (
<>
{spacer}
<DirButton icon={ArrowDown} label="Kort" selected={value === "short"} onClick={() => onChange("short")} readOnly={readOnly} />
{spacer}
</>
)}
</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,
max,
}: {
label: string
value: number
onChange: (value: number) => void
readOnly: boolean
// Kan ikke telle høyere enn antall slag registrert på hullet -- f.eks.
// umulig å ha chippet flere ganger enn totalt antall slag.
max?: number
}) {
const atMax = max !== undefined && value >= max
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 || atMax}
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>
)
}