teecup/frontend/components/ny-runde/step1-course-time.tsx
Erol Haagenrud 11d10f7661 Erstatt /my-rounds/new med ny-runde-veiviser fra egen V0-prompt
Gammel components/new-round.tsx (3364 linjer) slettet, erstattet av
components/ny-runde/ + lib/ny-runde/ -- V0-eksporten (full teknisk spec,
null designføring) ported mot de faktiske backend-kontraktene i
app/routers/rounds.py: Gender-oversettelse, forenklet Tee-form (aldri
CR/Slope til klienten), CourseMeta-discriminated-union for teeoff- vs.
egen-bane, ekte POST /rounds -> sides -> participants-sekvens, ekte
kontosøk/gjeste-oppslag, ekte banesøk inkl. geolokasjon.

Fire bugs funnet og rettet under scratch-verifisering: manglende
forwardRef på TextInput (i selve V0-eksporten), manglende
tee-synk til eierens spillerkort etter banevalg (egen portingsfeil),
manglende Ingen/Alle-hurtigknapp i steg 5 (samme mangel rettet i appen
2026-08-06), og en produksjonsbug oppdaget EKTE brukere etter utrulling:
fetchFacilityCourses() leste responsen fra offisielt banesøk som en rå
array i stedet for {slug,name,courses}-objektet backend faktisk
returnerer -- ga en uendelig "Laster baner..."-hang for enhver runde
startet på en offisiell (teeoff-)bane. Rettet + lagt til reell
feilhåndtering i OfficialCourses (manglet helt, feilen ble tidligere
en stille, ufanget promise-rejection).

Se CHANGELOG.md punkt 41 og 43 for full verifiseringsdetalj.
2026-08-08 12:34:25 +02:00

603 lines
22 KiB
TypeScript
Raw 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,
nearbyOfficialFacilities,
searchOfficialFacilities,
searchOwnCourses,
} 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]
patch({
course,
courseMeta: meta,
courseSource: meta.source === "teeoff" ? "official" : "own",
teeId: tee?.id,
s1Sub: "fields",
// Eierens EGET spillerkort (steg 3) har sitt eget teeId-felt, atskilt
// fra state.teeId over -- uten denne synkroniseringen viser kortet
// "Ikke valgt" selv om et utslag faktisk er valgt (kun kosmetisk, selve
// innsendingen leser state.teeId direkte for eieren -- men samme
// synkronisering som den opprinnelige new-round.tsx sin chooseCourse()
// gjorde, så oppførselen matcher eksakt).
players: state.players.map((p) => (p.kind === "owner" ? { ...p, teeId: tee?.id } : p)),
})
}
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 })}
/>
)
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,
}: {
templateMode: boolean
onBack: () => void
onCreate: () => void
onPick: (c: Course) => 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>
</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>
)
}