teecup/frontend/components/round-new.tsx

835 lines
30 KiB
TypeScript
Raw Normal View History

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
"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>
)
}