teecup/frontend/components/ny-runde/step1-course-time.tsx
Erol Haagenrud bf6955bff6 Fiks: eierens statistikknivå/utslag ble ikke lagret i Ny runde-veiviseren (ADR-073)
Eierens spillerobjekt i ny-runde-veiviseren hadde egne teeId/statLevel-felt,
kun synkronisert fra state.teeId/state.statLevel ÉN gang da banen først ble
valgt. For utslag var dette kosmetisk (submit() leser state.teeId direkte
for eieren), men for statistikknivå en reell datafeil: "Statistikk for deg
selv"-valget i steg 2 hadde ingen effekt på eierens faktiske innsending,
som alltid endte på strokes_only uansett hva som ble valgt.

Erstatter den punktvise engangs-synken med én kontinuerlig useEffect i
wizard-context.tsx. Bruker-rapportert regresjon (video vedlagt) -- se
ADR-073/CHANGELOG for full detalj og verifisering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 07:20:20 +02:00

778 lines
29 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { ArrowLeft, Building2, ChevronRight, Flag, MapPin, PencilLine, Plus, Search } from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import {
fetchFacilityCourses,
fetchOwnCourse,
importInternationalCourse,
nearbyOfficialFacilities,
searchInternationalClubs,
searchOfficialFacilities,
searchOwnCourses,
} from "@/lib/ny-runde/api"
import type { ApiInternationalClub, ApiInternationalCourseOption } from "@/lib/ny-runde/api"
import type { Course, Facility } from "@/lib/ny-runde/types"
import { CreateCourseForm } from "./create-course-form"
import { ChoiceCard, Field, NativeSelect, Panel, Pill, TextInput, useDebouncedValue } from "./primitives"
import { useWizard } from "./wizard-context"
export function Step1CourseTime() {
const { state, patch, exit } = useWizard()
const sub = state.s1Sub
function pickCourse(course: Course, meta: { source: "teeoff"; facilitySlug: string; teeoffCourseId: number } | { source: "custom"; personalCourseId: string }) {
if (state.templateMode) {
patch({ templateSeed: course, templateMode: false, s1Sub: "own-create" })
return
}
const compatible = state.ownGender ? course.tees.filter((t) => t.genders.includes(state.ownGender!)) : course.tees
const tee = compatible[0]
// Eierens EGET spillerkort (steg 3) sitt teeId-felt synkroniseres nå
// løpende fra state.teeId av en egen effekt i wizard-context.tsx
// (fanger også senere endringer i "Bane & tid"-bekreftelsen, ikke bare
// dette førstevalget) -- ingen manuell synk nødvendig her lenger.
patch({
course,
courseMeta: meta,
courseSource: meta.source === "teeoff" ? "official" : "own",
teeId: tee?.id,
s1Sub: "fields",
})
}
switch (sub) {
case "source":
return (
<SourceScreen
templateMode={false}
onOfficial={() => patch({ s1Sub: "official-search" })}
onOwn={() => patch({ s1Sub: "own-search" })}
onBack={exit}
/>
)
case "template-source":
return (
<SourceScreen
templateMode
onOfficial={() => patch({ templateMode: true, s1Sub: "official-search" })}
onOwn={() => patch({ templateMode: true, s1Sub: "own-search" })}
onBack={() => patch({ s1Sub: "own-create" })}
/>
)
case "official-search":
return (
<OfficialSearch
templateMode={state.templateMode}
onBack={() => patch({ s1Sub: state.templateMode ? "template-source" : "source" })}
onPickFacility={(f) => patch({ selectedFacilitySlug: f.slug, s1Sub: "official-courses" })}
/>
)
case "official-courses":
return (
<OfficialCourses
slug={state.selectedFacilitySlug}
templateMode={state.templateMode}
onBack={() => patch({ s1Sub: "official-search" })}
onPick={(teeoffCourseId, course) =>
pickCourse(course, { source: "teeoff", facilitySlug: state.selectedFacilitySlug!, teeoffCourseId })
}
/>
)
case "own-search":
return (
<OwnSearch
templateMode={state.templateMode}
onBack={() => patch({ s1Sub: state.templateMode ? "template-source" : "source" })}
onCreate={() => patch({ s1Sub: "own-create", templateSeed: undefined, ownCreateOrigin: "own-search" })}
onPick={(course) => pickCourse(course, { source: "custom", personalCourseId: course.id })}
onInternational={() => patch({ s1Sub: "international-search" })}
/>
)
case "international-search":
return (
<InternationalSearch
onBack={() => patch({ s1Sub: "own-search" })}
onPick={(course) => pickCourse(course, { source: "custom", personalCourseId: course.id })}
/>
)
case "own-create":
return (
<OwnCreate
onBack={() => patch({ s1Sub: "own-search", templateSeed: undefined })}
onTemplate={() => patch({ s1Sub: "template-source", ownCreateOrigin: "own-create" })}
onCreate={(course) => pickCourse(course, { source: "custom", personalCourseId: course.id })}
seed={state.templateSeed}
/>
)
case "fields":
return <Fields />
default:
return null
}
}
/* ------------------------------------------------------------------ */
function SubHeader({ title, onBack, subtitle }: { title: string; onBack: () => void; subtitle?: string }) {
return (
<div className="mb-4 flex items-start gap-3">
<button
type="button"
onClick={onBack}
aria-label="Tilbake"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] text-[var(--nr-ink)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</button>
<div>
<h2 className="text-lg font-semibold tracking-tight text-[var(--nr-ink)]">{title}</h2>
{subtitle ? <p className="text-sm text-[var(--nr-muted)]">{subtitle}</p> : null}
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* source / template-source */
function SourceScreen({
templateMode,
onOfficial,
onOwn,
onBack,
}: {
templateMode: boolean
onOfficial: () => void
onOwn: () => void
onBack: () => void
}) {
return (
<div>
<SubHeader
title={templateMode ? "Velg en mal" : "Hvor spiller du?"}
subtitle={templateMode ? "Velg en bane å basere din egen bane på." : "Velg en offisiell bane eller din egen."}
onBack={onBack}
/>
<div className="grid gap-3">
<ChoiceCard
selected={false}
onSelect={onOfficial}
icon={<Building2 className="h-5 w-5" />}
title="Offisiell bane"
description="Søk opp en registrert golfbane fra databasen."
/>
<ChoiceCard
selected={false}
onSelect={onOwn}
icon={<PencilLine className="h-5 w-5" />}
title="Egen bane"
description="Bruk eller lag din egen manuelt registrerte bane."
/>
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* official-search (med geolokasjon "nærmest deg") */
function OfficialSearch({
templateMode,
onBack,
onPickFacility,
}: {
templateMode: boolean
onBack: () => void
onPickFacility: (f: Facility) => void
}) {
const [query, setQuery] = useState("")
const debounced = useDebouncedValue(query, 250)
const [results, setResults] = useState<Facility[] | null>(null)
const [nearest, setNearest] = useState<(Facility & { distanceKm: number })[]>([])
const requested = useRef(false)
useEffect(() => {
if (requested.current) return
requested.current = true
if (typeof navigator === "undefined" || !navigator.geolocation) return
navigator.geolocation.getCurrentPosition(
(pos) => {
void nearbyOfficialFacilities(pos.coords.latitude, pos.coords.longitude).then(setNearest)
},
() => {
/* stille ved avslag/feil */
},
{ timeout: 8000 },
)
}, [])
useEffect(() => {
const trimmed = debounced.trim()
if (trimmed === "") {
setResults(null)
return
}
let cancelled = false
void searchOfficialFacilities(trimmed).then((r) => {
if (!cancelled) setResults(r)
})
return () => {
cancelled = true
}
}, [debounced])
return (
<div>
<SubHeader
title={templateMode ? "Offisiell bane som mal" : "Offisiell bane"}
subtitle="Søk etter anlegg på navn."
onBack={onBack}
/>
<Field label="Banenavn" htmlFor="official-q">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--nr-faint)]" aria-hidden="true" />
<TextInput
id="official-q"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Søk etter anlegg…"
className="pl-9"
autoComplete="off"
/>
</div>
</Field>
<div className="mt-4">
{results === null ? (
nearest.length > 0 ? (
<>
<p className="mb-2 flex items-center gap-1.5 text-[13px] font-medium text-[var(--nr-muted)]">
<MapPin className="h-4 w-4" aria-hidden="true" /> Nærmest deg
</p>
<ul className="flex flex-col gap-2">
{nearest.map((f) => (
<FacilityRow
key={f.slug}
facility={f}
onClick={() => onPickFacility(f)}
distance={f.distanceKm * 1000 < 1000 ? `${Math.round(f.distanceKm * 1000)} m` : `${Math.round(f.distanceKm)} km`}
/>
))}
</ul>
</>
) : (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
Begynn å skrive for å søke etter en bane.
</p>
)
) : results.length > 0 ? (
<ul className="flex flex-col gap-2">
{results.map((f) => (
<FacilityRow key={f.slug} facility={f} onClick={() => onPickFacility(f)} />
))}
</ul>
) : (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
Ingen anlegg matcher «{debounced}».
</p>
)}
</div>
</div>
)
}
function FacilityRow({ facility, onClick, distance }: { facility: Facility; onClick: () => void; distance?: string }) {
return (
<li>
<button
type="button"
onClick={onClick}
className="flex min-h-14 w-full items-center gap-3 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<Building2 className="h-5 w-5 shrink-0 text-[var(--nr-muted)]" aria-hidden="true" />
<span className="flex-1">
<span className="block text-sm font-medium text-[var(--nr-ink)]">{facility.name}</span>
<span className="block text-xs text-[var(--nr-muted)]">{[facility.city, facility.county].filter(Boolean).join(", ") || " "}</span>
</span>
{distance ? <Pill tone="neutral">{distance}</Pill> : null}
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--nr-faint)]" aria-hidden="true" />
</button>
</li>
)
}
/* ------------------------------------------------------------------ */
/* official-courses */
function OfficialCourses({
slug,
templateMode,
onBack,
onPick,
}: {
slug?: string
templateMode: boolean
onBack: () => void
onPick: (teeoffCourseId: number, course: Course) => void
}) {
const [courses, setCourses] = useState<{ teeoffCourseId: number; course: Course }[] | null>(null)
const [loadError, setLoadError] = useState(false)
useEffect(() => {
if (!slug) return
let cancelled = false
setCourses(null)
setLoadError(false)
fetchFacilityCourses(slug)
.then((r) => {
if (!cancelled) setCourses(r)
})
.catch(() => {
if (!cancelled) setLoadError(true)
})
return () => {
cancelled = true
}
}, [slug])
return (
<div>
<SubHeader title="Baner" subtitle="Velg en bane / sløyfe." onBack={onBack} />
{loadError ? (
<p role="alert" className="rounded-lg border border-[var(--nr-danger)]/30 bg-[var(--nr-danger-soft)] px-4 py-3 text-sm text-[var(--nr-danger)]">
Klarte ikke å hente baner fra teeoff akkurat . Prøv igjen om litt.
</p>
) : courses === null ? (
<p className="text-sm text-[var(--nr-muted)]">Laster baner</p>
) : (
<ul className="flex flex-col gap-2">
{courses.map(({ teeoffCourseId, course }) => (
<li key={teeoffCourseId}>
<CourseRow course={course} templateMode={templateMode} onPick={() => onPick(teeoffCourseId, course)} />
</li>
))}
</ul>
)}
</div>
)
}
function CourseRow({ course, templateMode, onPick }: { course: Course; templateMode: boolean; onPick: () => void }) {
return (
<button
type="button"
onClick={onPick}
className="flex min-h-14 w-full items-center gap-3 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<Flag className="h-5 w-5 shrink-0 text-[var(--nr-muted)]" aria-hidden="true" />
<span className="flex-1">
<span className="block text-sm font-medium text-[var(--nr-ink)]">{course.name}</span>
<span className="block text-xs text-[var(--nr-muted)]">{course.tees.length} utslag</span>
</span>
{templateMode ? <Pill tone="accent">Bruk som mal for egen bane</Pill> : <ChevronRight className="h-4 w-4 shrink-0 text-[var(--nr-faint)]" aria-hidden="true" />}
</button>
)
}
/* ------------------------------------------------------------------ */
/* own-search */
function OwnSearch({
templateMode,
onBack,
onCreate,
onPick,
onInternational,
}: {
templateMode: boolean
onBack: () => void
onCreate: () => void
onPick: (c: Course) => void
onInternational: () => void
}) {
const [query, setQuery] = useState("")
const debounced = useDebouncedValue(query, 250)
const [results, setResults] = useState<{ id: string; name: string }[] | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
useEffect(() => {
const trimmed = debounced.trim()
if (trimmed === "") {
setResults(null)
return
}
let cancelled = false
void searchOwnCourses(trimmed).then((r) => {
if (!cancelled) setResults(r)
})
return () => {
cancelled = true
}
}, [debounced])
async function pick(id: string) {
const course = await fetchOwnCourse(id)
if (course) onPick(course)
}
return (
<div>
<SubHeader
title={templateMode ? "Egen bane som mal" : "Egen bane"}
subtitle="Søk blant banene du har laget selv."
onBack={onBack}
/>
<Field label="Banenavn" htmlFor="own-q">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--nr-faint)]" aria-hidden="true" />
<TextInput
id="own-q"
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Søk blant egne baner…"
className="pl-9"
autoComplete="off"
/>
</div>
</Field>
{!templateMode ? (
<button
type="button"
onClick={onCreate}
className="mt-3 inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-xl border border-dashed border-[var(--nr-border-strong)] bg-[var(--nr-surface)] px-4 text-sm font-medium text-[var(--nr-accent)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<Plus className="h-4 w-4" aria-hidden="true" />
Opprett ny bane
</button>
) : null}
<div className="mt-4">
{results === null ? (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
Søk etter en egen bane{templateMode ? "" : ", eller opprett en ny"}.
</p>
) : results.length > 0 ? (
<ul className="flex flex-col gap-2">
{results.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => void pick(c.id)}
className="flex min-h-14 w-full items-center gap-3 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<Flag className="h-5 w-5 shrink-0 text-[var(--nr-muted)]" aria-hidden="true" />
<span className="flex-1 text-sm font-medium text-[var(--nr-ink)]">{c.name}</span>
{templateMode ? <Pill tone="accent">Bruk som mal for egen bane</Pill> : <ChevronRight className="h-4 w-4 shrink-0 text-[var(--nr-faint)]" aria-hidden="true" />}
</button>
</li>
))}
</ul>
) : (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
Fant ingen egne baner som matcher «{debounced}».
</p>
)}
</div>
<button
type="button"
onClick={onInternational}
className="mt-4 flex min-h-11 w-full items-center justify-center gap-2 text-[13px] font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
>
Fant ikke banen? Søk internasjonalt (GolfAPI)
</button>
</div>
)
}
/* ------------------------------------------------------------------ */
/* international-search (GolfAPI.io, ADR-064) -- for baner utenfor */
/* teeoffs dekning (Tjøme Golfklubb var den konkrete anledningen). */
/* Importerte baner havner i samme personal_course-katalog som "egen */
/* bane" -- onPick gir tilbake en helt vanlig Course, ingen egen */
/* CourseMeta-variant trengs (speiler OwnSearch/tournament-program.tsx). */
function InternationalSearch({
onBack,
onPick,
}: {
onBack: () => void
onPick: (c: Course) => void
}) {
const [query, setQuery] = useState("")
const [clubs, setClubs] = useState<ApiInternationalClub[] | null>(null)
const [selectedClub, setSelectedClub] = useState<ApiInternationalClub | null>(null)
const [searching, setSearching] = useState(false)
const [importingId, setImportingId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
async function runSearch() {
if (!query.trim()) return
setSearching(true)
setError(null)
try {
setClubs(await searchInternationalClubs(query.trim()))
} finally {
setSearching(false)
}
}
async function pick(course: ApiInternationalCourseOption) {
setImportingId(course.course_id)
setError(null)
try {
onPick(await importInternationalCourse(course.course_id))
} catch {
setError("Klarte ikke å importere banen fra GolfAPI. Prøv igjen.")
} finally {
setImportingId(null)
}
}
return (
<div>
<SubHeader
title={selectedClub ? selectedClub.club_name : "Søk internasjonalt"}
subtitle={selectedClub ? "Velg en bane hos klubben." : "For baner utenfor TeeOffs dekning, via GolfAPI."}
onBack={selectedClub ? () => setSelectedClub(null) : onBack}
/>
{error ? (
<p role="alert" className="mb-3 rounded-lg border border-[var(--nr-danger)]/30 bg-[var(--nr-danger-soft)] px-4 py-3 text-sm text-[var(--nr-danger)]">
{error}
</p>
) : null}
{!selectedClub ? (
<>
<Field label="Klubbnavn" htmlFor="intl-q">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--nr-faint)]" aria-hidden="true" />
<TextInput
id="intl-q"
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
void runSearch()
}
}}
placeholder="Søk klubbnavn…"
className="pl-9"
autoComplete="off"
/>
</div>
</Field>
<button
type="button"
onClick={() => void runSearch()}
disabled={searching}
className="mt-3 inline-flex min-h-11 w-full items-center justify-center rounded-xl bg-[var(--nr-accent)] px-4 text-sm font-semibold text-[var(--nr-accent-ink)] transition-colors hover:brightness-95 disabled:opacity-50"
>
{searching ? "Søker…" : "Søk"}
</button>
<div className="mt-4">
{clubs === null ? (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
Begynn å skrive for å søke etter en klubb.
</p>
) : clubs.length > 0 ? (
<ul className="flex flex-col gap-2">
{clubs.map((c) => (
<li key={c.club_id}>
<button
type="button"
onClick={() => setSelectedClub(c)}
className="flex min-h-14 w-full items-center gap-3 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<Building2 className="h-5 w-5 shrink-0 text-[var(--nr-muted)]" aria-hidden="true" />
<span className="flex-1">
<span className="block text-sm font-medium text-[var(--nr-ink)]">{c.club_name}</span>
<span className="block text-xs text-[var(--nr-muted)]">{[c.city, c.country].filter(Boolean).join(", ") || " "}</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--nr-faint)]" aria-hidden="true" />
</button>
</li>
))}
</ul>
) : (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
Ingen klubber matcher «{query}».
</p>
)}
</div>
</>
) : (
<ul className="flex flex-col gap-2">
{selectedClub.courses.map((c) => (
<li key={c.course_id}>
<button
type="button"
disabled={importingId !== null}
onClick={() => void pick(c)}
className="flex min-h-14 w-full items-center gap-3 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40 disabled:opacity-50"
>
<Flag className="h-5 w-5 shrink-0 text-[var(--nr-muted)]" aria-hidden="true" />
<span className="flex-1">
<span className="block text-sm font-medium text-[var(--nr-ink)]">{c.course_name}</span>
<span className="block text-xs text-[var(--nr-muted)]">
{c.num_holes} hull{c.has_gps ? " · avstandsdata tilgjengelig" : ""}
</span>
</span>
{importingId === c.course_id ? (
<span className="text-xs font-medium text-[var(--nr-muted)]">Importerer</span>
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--nr-faint)]" aria-hidden="true" />
)}
</button>
</li>
))}
{selectedClub.courses.length === 0 ? (
<li className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
Ingen baner registrert hos denne klubben hos GolfAPI ennå.
</li>
) : null}
</ul>
)}
</div>
)
}
/* ------------------------------------------------------------------ */
/* own-create */
function OwnCreate({
seed,
onBack,
onTemplate,
onCreate,
}: {
seed?: Course
onBack: () => void
onTemplate: () => void
onCreate: (c: Course) => void
}) {
return (
<div>
<SubHeader
title={seed ? "Ny bane (fra mal)" : "Ny egen bane"}
subtitle="Registrer hull, par, stroke-indeks og utslag."
onBack={onBack}
/>
{!seed ? (
<button
type="button"
onClick={onTemplate}
className="mb-4 text-[13px] font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
>
Basér en eksisterende bane i stedet
</button>
) : (
<div className="mb-4">
<Pill tone="accent">Forhåndsutfylt fra «{seed.name}»</Pill>
</div>
)}
<CreateCourseForm seed={seed} onCancel={onBack} onCreate={onCreate} />
</div>
)
}
/* ------------------------------------------------------------------ */
/* fields */
function Fields() {
const { state, patch } = useWizard()
const course = state.course
if (!course) return null
const compatibleTees = state.ownGender ? course.tees.filter((t) => t.genders.includes(state.ownGender!)) : course.tees
const noCompatibleTee = compatibleTees.length === 0
return (
<div>
<div className="mb-4">
<h2 className="text-lg font-semibold tracking-tight text-[var(--nr-ink)]">Bane & tid</h2>
<p className="text-sm text-[var(--nr-muted)]">Bekreft detaljene for runden.</p>
</div>
<Panel className="mb-4 bg-[var(--nr-surface-2)]">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold text-[var(--nr-ink)]">{course.name}</p>
<p className="text-xs text-[var(--nr-muted)]">{course.tees.length} utslag</p>
</div>
<Pill tone={state.courseSource === "own" ? "neutral" : "accent"}>{state.courseSource === "own" ? "Egen bane" : "Offisiell"}</Pill>
</div>
<button
type="button"
onClick={() => patch({ s1Sub: "source", course: undefined, courseMeta: undefined, teeId: undefined })}
className="mt-3 text-[13px] font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
>
Bytt bane
</button>
</Panel>
<div className="flex flex-col gap-4">
<Field label="Navn på runden" optional htmlFor="round-name" hint="Maks 200 tegn.">
<TextInput
id="round-name"
value={state.roundName}
onChange={(e) => patch({ roundName: e.target.value.slice(0, 200) })}
placeholder={course.name}
maxLength={200}
/>
</Field>
<Field
label="Utslag"
htmlFor="tee"
error={
noCompatibleTee
? "Ingen utslag på denne banen har rating for ditt kjønn. Handicap kan ikke spores for denne runden, men du kan fortsette."
: null
}
>
{noCompatibleTee ? (
<div className="rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface-2)] px-3 py-2.5 text-sm text-[var(--nr-muted)]">
Ingen kompatible utslag.
</div>
) : (
<NativeSelect id="tee" value={state.teeId ?? ""} onChange={(e) => patch({ teeId: e.target.value })}>
{compatibleTees.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</NativeSelect>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Dato" htmlFor="date">
<TextInput id="date" type="date" value={state.date} onChange={(e) => patch({ date: e.target.value })} />
</Field>
<Field label="Utslagstid" optional htmlFor="teetime">
<TextInput id="teetime" type="time" value={state.teeTime} onChange={(e) => patch({ teeTime: e.target.value })} />
</Field>
</div>
<Field label="Starthull" htmlFor="starthole">
<NativeSelect
id="starthole"
value={state.startHole}
onChange={(e) => patch({ startHole: Number.parseInt(e.target.value, 10) })}
>
{Array.from({ length: 18 }, (_, i) => i + 1).map((h) => (
<option key={h} value={h}>
Hull {h}
</option>
))}
</NativeSelect>
</Field>
</div>
</div>
)
}