teecup/frontend/components/round-new.tsx
Erol Haagenrud c15c68efca Update Todos
Add non-org-scoped teeoff search + personal-course detail endpoints to rounds.py

Build /rounds list page + component

Build /rounds/new creation flow (course search/create, tee, date, holes)

Build /rounds/[id] detail page (participants, hole-by-hole stats, complete)

Link new feature from dashboard

Scratch-verify backend additions + typecheck production build

Deploy live after user confirmation

Update ADR-033/FEATURE_BACKLOG/CLAUDE.md status
Frontend for standalone round tracking is built and fully verified in scratch — 22 backend contract checks plus a live end-to-end test against both course sources (custom bane and real teeoff lookup), and a clean typechecked production build (docker build --target builder) showing all new routes (/rounds, /rounds/new, /rounds/[id]).

What's new, in summary:

Backend: three additions to app/routers/rounds.py — non-org-scoped teeoff facility/course search (/rounds/official-search*), a personal-course detail endpoint exposing tee/gender info, and a GET .../holes endpoint (needed since RoundOut never carried hole-level data). Also changed the hole-PATCH endpoint to return the full updated row instead of {"ok": true}. No migration — no schema touched.
Frontend: /rounds (list), /rounds/new (course search or create, tee/date/holes-planned), /rounds/[id] (participants, hole-by-hole stat entry with GIR auto-derived, complete-round flow with HCP differential summary). Linked from the dashboard both as a header shortcut and a prominent "Egne runder" entry card.
One thing worth flagging: the hole-PATCH endpoint replaces all fields every call rather than doing a true partial update, so the frontend always merges the current hole state before sending a patch — I confirmed this contract explicitly in scratch (a naive partial PATCH silently nulls out unrelated fields).
2026-07-23 05:51:26 +02:00

834 lines
30 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"
// Opprett en frittstående runde (ADR-033). To bane-kilder: offisiell
// teeoff-bane (live oppslag, Beslutning C -- ingen lokal kopi lagres) eller
// en egendefinert bane (organisasjonsuavhengig katalog, søkbar på tvers av
// alle brukere -- samme "søk før du oppretter"-idé som org-banene).
import type React from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, Check, ChevronRight, Plus, Search, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
type Gender = "m" | "f"
type TeeOption = { name: string; genders: Gender[] }
type SelectedCourse = {
source: "teeoff" | "custom"
teeoffFacilitySlug?: string
teeoffCourseId?: number
personalCourseId?: string
name: string
tees: TeeOption[]
}
type Step = "source" | "teeoff" | "custom-search" | "custom-create" | "confirm"
export function NewRound() {
const router = useRouter()
const [step, setStep] = useState<Step>("source")
const [selected, setSelected] = useState<SelectedCourse | null>(null)
const [ownGender, setOwnGender] = useState<Gender | null>(null)
const [loadingMe, setLoadingMe] = useState(true)
useEffect(() => {
let cancelled = false
async function loadMe() {
try {
const res = await fetch("/auth/me", { credentials: "include" })
if (res.status === 401) {
router.replace("/")
return
}
const data: { gender: Gender | null } = await res.json()
if (!cancelled) setOwnGender(data.gender)
} finally {
if (!cancelled) setLoadingMe(false)
}
}
void loadMe()
return () => {
cancelled = true
}
}, [router])
function pickCourse(course: SelectedCourse) {
setSelected(course)
setStep("confirm")
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-2xl items-center justify-between gap-4 px-5 py-4">
<Link
href="/rounds"
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-4" />
Egne runder
</Link>
<Wordmark compact />
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-8 sm:py-10">
<h1 className="mb-6 text-2xl font-extrabold tracking-tight text-foreground">Ny runde</h1>
{loadingMe ? (
<div className="flex justify-center py-12">
<div
aria-hidden="true"
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
) : !ownGender ? (
<div className="rounded-2xl border border-border bg-card p-5 text-sm text-muted-foreground">
Profilen din mangler registrert kjønn, som trengs for å beregne banehandicap riktig.{" "}
<Link href="/account" className="font-semibold text-primary underline underline-offset-2">
til kontoinnstillinger
</Link>
.
</div>
) : step === "source" ? (
<SourceChoice
onPickTeeoff={() => setStep("teeoff")}
onPickCustom={() => setStep("custom-search")}
/>
) : step === "teeoff" ? (
<TeeoffCourseSearch onBack={() => setStep("source")} onPick={pickCourse} />
) : step === "custom-search" ? (
<CustomCourseSearch
onBack={() => setStep("source")}
onPick={pickCourse}
onCreateNew={() => setStep("custom-create")}
/>
) : step === "custom-create" ? (
<CustomCourseCreate onBack={() => setStep("custom-search")} onCreated={pickCourse} />
) : selected ? (
<ConfirmRound course={selected} ownGender={ownGender} onBack={() => setStep("source")} />
) : null}
</main>
</div>
)
}
// --- Steg 1: velg kilde ------------------------------------------------------
function SourceChoice({ onPickTeeoff, onPickCustom }: { onPickTeeoff: () => void; onPickCustom: () => void }) {
return (
<div className="flex flex-col gap-4">
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Hvor spilte du runden?
</p>
<button
type="button"
onClick={onPickTeeoff}
className="flex items-center justify-between gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50"
>
<div className="flex flex-col gap-1">
<span className="text-base font-bold text-foreground">Offisiell bane</span>
<span className="text-sm text-muted-foreground">Søk opp klubben i teeoff sitt register</span>
</div>
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
</button>
<button
type="button"
onClick={onPickCustom}
className="flex items-center justify-between gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50"
>
<div className="flex flex-col gap-1">
<span className="text-base font-bold text-foreground">Egen bane</span>
<span className="text-sm text-muted-foreground">
Banen finnes ikke i teeoff -- søk opp eller opprett den selv
</span>
</div>
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
</button>
</div>
)
}
// --- Steg 2a: teeoff-søk -----------------------------------------------------
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: TeeOption[] }
function TeeoffCourseSearch({
onBack,
onPick,
}: {
onBack: () => void
onPick: (course: SelectedCourse) => void
}) {
const [query, setQuery] = useState("")
const [facilities, setFacilities] = useState<ApiFacility[] | null>(null)
const [selectedFacility, setSelectedFacility] = useState<ApiFacility | null>(null)
const [courses, setCourses] = useState<ApiOfficialCourseOption[] | null>(null)
const [searching, setSearching] = useState(false)
const [error, setError] = useState<string | null>(null)
async function runSearch() {
setSearching(true)
setError(null)
try {
const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query.trim())}`, {
credentials: "include",
})
if (!res.ok) throw new Error(`search: ${res.status}`)
setFacilities(await res.json())
} catch {
setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
setFacilities([])
} finally {
setSearching(false)
}
}
async function pickFacility(facility: ApiFacility) {
setSelectedFacility(facility)
setError(null)
setCourses(null)
try {
const res = await fetch(`/rounds/official-search/${facility.slug}`, { credentials: "include" })
if (!res.ok) throw new Error(`facility detail: ${res.status}`)
const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
setCourses(detail.courses)
} catch {
setError("Klarte ikke å hente baner for dette anlegget.")
setCourses([])
}
}
return (
<div className="flex flex-col gap-4">
{!selectedFacility ? (
<>
<BackLink onClick={onBack} label="Bane-kilde" />
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
<div className="flex items-center gap-2">
<Input
autoFocus
placeholder="Søk klubbnavn…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
runSearch()
}
}}
className="h-12 flex-1 rounded-xl text-base"
/>
<Button type="button" onClick={runSearch} disabled={searching} className="h-12 shrink-0 rounded-xl font-bold">
<Search aria-hidden="true" className="size-4" />
{searching ? "Søker…" : "Søk"}
</Button>
</div>
{facilities && (
<ul className="flex flex-col overflow-hidden rounded-2xl 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-4 py-3 text-left transition-colors hover:bg-accent/60"
>
<span className="text-base font-semibold text-foreground">{f.name}</span>
{(f.city || f.county) && (
<span className="text-sm text-muted-foreground">{[f.city, f.county].filter(Boolean).join(", ")}</span>
)}
</button>
</li>
))}
{facilities.length === 0 && (
<li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
)}
</ul>
)}
</>
) : (
<>
<BackLink onClick={() => setSelectedFacility(null)} label={selectedFacility.name} />
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{courses === null ? (
<p className="text-sm text-muted-foreground">Laster baner</p>
) : (
<ul className="flex flex-col overflow-hidden rounded-2xl border border-border">
{courses.map((c) => (
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() =>
onPick({
source: "teeoff",
teeoffFacilitySlug: selectedFacility.slug,
teeoffCourseId: c.teeoff_course_id,
name: `${selectedFacility.name} ${c.name}`,
tees: c.tees,
})
}
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-accent/60"
>
<span className="text-base font-semibold text-foreground">{c.name}</span>
<ChevronRight aria-hidden="true" className="size-4 text-muted-foreground" />
</button>
</li>
))}
{courses.length === 0 && (
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
Ingen 18-hulls baner registrert hos dette anlegget ennå.
</li>
)}
</ul>
)}
</>
)}
</div>
)
}
// --- Steg 2b: egen bane -- søk eksisterende ---------------------------------
type ApiPersonalCourse = { id: string; name: string }
function CustomCourseSearch({
onBack,
onPick,
onCreateNew,
}: {
onBack: () => void
onPick: (course: SelectedCourse) => void
onCreateNew: () => void
}) {
const [query, setQuery] = useState("")
const [results, setResults] = useState<ApiPersonalCourse[]>([])
const [error, setError] = useState<string | null>(null)
const [resolving, setResolving] = useState(false)
useEffect(() => {
let cancelled = false
const timer = setTimeout(async () => {
try {
const res = await fetch(`/personal-courses?q=${encodeURIComponent(query.trim())}`, { credentials: "include" })
if (!res.ok) throw new Error(`search: ${res.status}`)
const data: ApiPersonalCourse[] = await res.json()
if (!cancelled) setResults(data)
} catch {
if (!cancelled) setError("Klarte ikke å søke i egendefinerte baner akkurat nå.")
}
}, 250)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [query])
async function pick(course: ApiPersonalCourse) {
setResolving(true)
setError(null)
try {
const res = await fetch(`/personal-courses/${course.id}`, { credentials: "include" })
if (!res.ok) throw new Error(`detail: ${res.status}`)
const detail: { tees: TeeOption[] } = await res.json()
onPick({ source: "custom", personalCourseId: course.id, name: course.name, tees: detail.tees })
} catch {
setError("Klarte ikke å hente banedetaljer. Prøv igjen.")
} finally {
setResolving(false)
}
}
return (
<div className="flex flex-col gap-4">
<BackLink onClick={onBack} label="Bane-kilde" />
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
<Input
autoFocus
placeholder="Søk egendefinert bane…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-12 rounded-xl text-base"
/>
<ul className="flex flex-col overflow-hidden rounded-2xl border border-border">
{results.map((c) => (
<li key={c.id} className="border-b border-border last:border-b-0">
<button
type="button"
disabled={resolving}
onClick={() => pick(c)}
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-accent/60 disabled:opacity-50"
>
<span className="text-base font-semibold text-foreground">{c.name}</span>
<ChevronRight aria-hidden="true" className="size-4 text-muted-foreground" />
</button>
</li>
))}
{results.length === 0 && (
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
{query.trim() ? "Ingen treff." : "Skriv for å søke, eller opprett en ny bane under."}
</li>
)}
</ul>
<Button
type="button"
variant="outline"
onClick={onCreateNew}
className="h-12 self-start rounded-2xl border-dashed text-base font-bold"
>
<Plus aria-hidden="true" className="size-5" />
Opprett ny bane
</Button>
</div>
)
}
// --- Steg 2c: egen bane -- opprett ny ---------------------------------------
const DEFAULT_PARS = [4, 4, 3, 5, 4, 4, 3, 5, 4, 4, 4, 3, 5, 4, 4, 3, 5, 4]
type HoleDraft = { par: number; strokeIndex: number }
type TeeRatingDraft = { courseRating: string; slopeRating: string; par: string }
type TeeDraft = { name: string; m: TeeRatingDraft | null; f: TeeRatingDraft | null }
function CustomCourseCreate({
onBack,
onCreated,
}: {
onBack: () => void
onCreated: (course: SelectedCourse) => void
}) {
const [name, setName] = useState("")
const [holes, setHoles] = useState<HoleDraft[]>(
DEFAULT_PARS.map((par, i) => ({ par, strokeIndex: i + 1 })),
)
const [tees, setTees] = useState<TeeDraft[]>([
{ name: "Gul", m: { courseRating: "", slopeRating: "", par: "72" }, f: null },
])
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const parSum = holes.reduce((s, h) => s + h.par, 0)
const indexPermutationValid = new Set(holes.map((h) => h.strokeIndex)).size === 18
function updateHole(i: number, patch: Partial<HoleDraft>) {
setHoles((prev) => prev.map((h, idx) => (idx === i ? { ...h, ...patch } : h)))
}
function updateTee(i: number, patch: Partial<TeeDraft>) {
setTees((prev) => prev.map((t, idx) => (idx === i ? { ...t, ...patch } : t)))
}
const teesValid = tees.every(
(t) =>
t.name.trim().length > 0 &&
(t.m || t.f) &&
[t.m, t.f].every((r) => !r || (r.courseRating.trim() && r.slopeRating.trim() && r.par.trim())),
)
const canSubmit = name.trim().length >= 2 && indexPermutationValid && teesValid && !submitting
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
setSubmitting(true)
setError(null)
try {
const body = {
name: name.trim(),
holes: holes.map((h, i) => ({ hole_number: i + 1, par: h.par, stroke_index: h.strokeIndex })),
tees: tees.map((t) => ({
name: t.name.trim(),
ratings: [
...(t.m
? [{ gender: "m", course_rating: Number(t.m.courseRating), slope_rating: Number(t.m.slopeRating), par: Number(t.m.par) }]
: []),
...(t.f
? [{ gender: "f", course_rating: Number(t.f.courseRating), slope_rating: Number(t.f.slopeRating), par: Number(t.f.par) }]
: []),
],
})),
}
const res = await fetch("/personal-courses", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(`create: ${res.status}`)
const created: { id: string; name: string } = await res.json()
onCreated({
source: "custom",
personalCourseId: created.id,
name: created.name,
tees: tees.map((t) => ({
name: t.name.trim(),
genders: [...(t.m ? (["m"] as const) : []), ...(t.f ? (["f"] as const) : [])],
})),
})
} catch {
setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.")
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<BackLink onClick={onBack} label="Søk egen bane" />
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
<div className="flex flex-col gap-2">
<Label htmlFor="course-name" className="text-sm font-semibold">
Navn banen
</Label>
<Input
id="course-name"
autoFocus
placeholder="F.eks. Min Golfklubb Hovedbanen"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-12 rounded-xl text-base"
/>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-2">
<h2 className="text-base font-bold text-foreground">18 hull</h2>
<span className={cn("text-sm font-semibold", parSum === 72 ? "text-muted-foreground" : "text-foreground")}>
Sum par: {parSum}
</span>
</div>
{!indexPermutationValid && (
<p role="alert" className="text-sm font-medium text-destructive">
Hver stroke-indeks (118) brukes nøyaktig én gang.
</p>
)}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{holes.map((h, i) => (
<div key={i} className="flex items-center gap-2 rounded-xl border border-border bg-card p-2.5">
<span className="w-16 shrink-0 text-sm font-bold text-foreground">Hull {i + 1}</span>
<label className="flex flex-1 items-center gap-1.5 text-sm text-muted-foreground">
Par
<select
value={h.par}
onChange={(e) => updateHole(i, { par: Number(e.target.value) })}
className="h-10 flex-1 rounded-lg border border-border bg-background px-2 text-base font-semibold text-foreground"
>
{[3, 4, 5, 6].map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</label>
<label className="flex flex-1 items-center gap-1.5 text-sm text-muted-foreground">
Idx
<select
value={h.strokeIndex}
onChange={(e) => updateHole(i, { strokeIndex: Number(e.target.value) })}
className="h-10 flex-1 rounded-lg border border-border bg-background px-2 text-base font-semibold text-foreground"
>
{Array.from({ length: 18 }, (_, n) => n + 1).map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-3">
<h2 className="text-base font-bold text-foreground">Utslag / rating</h2>
{tees.map((t, i) => (
<div key={i} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
<div className="flex items-center gap-2">
<Input
value={t.name}
onChange={(e) => updateTee(i, { name: e.target.value })}
placeholder="Navn på utslag (f.eks. Gul)"
className="h-11 flex-1 rounded-xl text-base"
/>
{tees.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setTees((prev) => prev.filter((_, idx) => idx !== i))}
className="size-10 shrink-0 rounded-xl text-muted-foreground"
aria-label="Fjern utslag"
>
<X aria-hidden="true" className="size-4" />
</Button>
)}
</div>
<GenderRatingFields
label="Herre-rating"
value={t.m}
onChange={(v) => updateTee(i, { m: v })}
/>
<GenderRatingFields
label="Dame-rating"
value={t.f}
onChange={(v) => updateTee(i, { f: v })}
/>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => setTees((prev) => [...prev, { name: "", m: null, f: null }])}
className="h-11 self-start rounded-xl border-dashed text-sm font-bold"
>
<Plus aria-hidden="true" className="size-4" />
Legg til utslag
</Button>
</div>
<Button type="submit" disabled={!canSubmit} className="h-14 rounded-2xl text-base font-bold shadow-sm">
{submitting ? "Oppretter…" : "Opprett bane og fortsett"}
</Button>
</form>
)
}
function GenderRatingFields({
label,
value,
onChange,
}: {
label: string
value: TeeRatingDraft | null
onChange: (v: TeeRatingDraft | null) => void
}) {
const id = label.toLowerCase().replace(/\s+/g, "-")
return (
<div className="flex flex-col gap-2 rounded-xl border border-border/60 p-3">
<label className="flex items-center gap-2 text-sm font-semibold text-foreground">
<input
type="checkbox"
checked={value !== null}
onChange={(e) => onChange(e.target.checked ? { courseRating: "", slopeRating: "", par: "72" } : null)}
className="size-5 rounded border-border"
/>
{label}
</label>
{value && (
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col gap-1">
<Label htmlFor={`${id}-cr`} className="text-xs text-muted-foreground">
Course rating
</Label>
<Input
id={`${id}-cr`}
inputMode="decimal"
value={value.courseRating}
onChange={(e) => onChange({ ...value, courseRating: e.target.value })}
className="h-10 rounded-lg text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor={`${id}-slope`} className="text-xs text-muted-foreground">
Slope
</Label>
<Input
id={`${id}-slope`}
inputMode="numeric"
value={value.slopeRating}
onChange={(e) => onChange({ ...value, slopeRating: e.target.value })}
className="h-10 rounded-lg text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor={`${id}-par`} className="text-xs text-muted-foreground">
Par
</Label>
<Input
id={`${id}-par`}
inputMode="numeric"
value={value.par}
onChange={(e) => onChange({ ...value, par: e.target.value })}
className="h-10 rounded-lg text-sm"
/>
</div>
</div>
)}
</div>
)
}
// --- Steg 3: bekreft + opprett runde -----------------------------------------
function ConfirmRound({
course,
ownGender,
onBack,
}: {
course: SelectedCourse
ownGender: Gender
onBack: () => void
}) {
const router = useRouter()
const compatibleTees = course.tees.filter((t) => t.genders.includes(ownGender))
const [teeName, setTeeName] = useState(compatibleTees[0]?.name ?? "")
const [playedAt, setPlayedAt] = useState(() => new Date().toISOString().slice(0, 10))
const [startHole, setStartHole] = useState(1)
const [holesPlanned, setHolesPlanned] = useState<9 | 18>(18)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit() {
if (!teeName) return
setSubmitting(true)
setError(null)
try {
const res = await fetch("/rounds", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
course_source: course.source,
teeoff_facility_slug: course.teeoffFacilitySlug,
teeoff_course_id: course.teeoffCourseId,
personal_course_id: course.personalCourseId,
tee_name: teeName,
played_at: playedAt,
start_hole: startHole,
holes_planned: holesPlanned,
}),
})
if (!res.ok) throw new Error(`create round: ${res.status}`)
const created: { id: string } = await res.json()
router.replace(`/rounds/${created.id}`)
} catch {
setError("Klarte ikke å opprette runden. Prøv igjen.")
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<BackLink onClick={onBack} label="Bane-kilde" />
<div className="rounded-2xl border border-border bg-card p-4">
<span className="text-base font-bold text-foreground">{course.name}</span>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{compatibleTees.length === 0 ? (
<p role="alert" className="text-sm font-medium text-destructive">
Denne banen har ingen registrert rating for ditt kjønn noe utslag -- HCP-sporing er ikke mulig for
denne runden. Velg en annen bane, eller fullfør profilen din nytt.
</p>
) : (
<div className="flex flex-col gap-2">
<Label className="text-sm font-semibold">Utslag</Label>
<div className="flex flex-wrap gap-2">
{compatibleTees.map((t) => (
<button
key={t.name}
type="button"
onClick={() => setTeeName(t.name)}
aria-pressed={teeName === t.name}
className={cn(
"h-11 rounded-xl border px-4 text-base font-semibold transition-colors",
teeName === t.name
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{t.name}
</button>
))}
</div>
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="played-at" className="text-sm font-semibold">
Dato
</Label>
<Input
id="played-at"
type="date"
value={playedAt}
onChange={(e) => setPlayedAt(e.target.value)}
className="h-12 rounded-xl text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="start-hole" className="text-sm font-semibold">
Starthull
</Label>
<select
id="start-hole"
value={startHole}
onChange={(e) => setStartHole(Number(e.target.value))}
className="h-12 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={n}>
Hull {n}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-2">
<Label className="text-sm font-semibold">Antall hull</Label>
<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-12 flex-1 rounded-xl border text-base font-bold transition-colors",
holesPlanned === n
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{n} hull
</button>
))}
</div>
</div>
<Button
type="button"
disabled={submitting || compatibleTees.length === 0 || !teeName}
onClick={handleSubmit}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
<Check aria-hidden="true" className="size-5" />
{submitting ? "Oppretter…" : "Start runden"}
</Button>
</div>
)
}
// --- Delt --------------------------------------------------------------------
function BackLink({ onClick, label }: { onClick: () => void; label: string }) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-1.5 self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-4" />
{label}
</button>
)
}