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
|
|
|
|
"use client"
|
|
|
|
|
|
|
|
|
|
|
|
import { ArrowLeft, Building2, ChevronRight, Flag, MapPin, PencilLine, Plus, Search } from "lucide-react"
|
|
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react"
|
|
|
|
|
|
import {
|
|
|
|
|
|
fetchFacilityCourses,
|
|
|
|
|
|
fetchOwnCourse,
|
2026-08-13 07:39:29 +02:00
|
|
|
|
importInternationalCourse,
|
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
|
|
|
|
nearbyOfficialFacilities,
|
2026-08-13 07:39:29 +02:00
|
|
|
|
searchInternationalClubs,
|
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
|
|
|
|
searchOfficialFacilities,
|
|
|
|
|
|
searchOwnCourses,
|
|
|
|
|
|
} from "@/lib/ny-runde/api"
|
2026-08-13 07:39:29 +02:00
|
|
|
|
import type { ApiInternationalClub, ApiInternationalCourseOption } from "@/lib/ny-runde/api"
|
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
|
|
|
|
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]
|
2026-08-15 07:20:20 +02:00
|
|
|
|
// 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.
|
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
|
|
|
|
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 })}
|
2026-08-13 07:39:29 +02:00
|
|
|
|
onInternational={() => patch({ s1Sub: "international-search" })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
case "international-search":
|
|
|
|
|
|
return (
|
|
|
|
|
|
<InternationalSearch
|
|
|
|
|
|
onBack={() => patch({ s1Sub: "own-search" })}
|
|
|
|
|
|
onPick={(course) => pickCourse(course, { source: "custom", personalCourseId: course.id })}
|
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
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
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 nå. 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,
|
2026-08-13 07:39:29 +02:00
|
|
|
|
onInternational,
|
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
|
|
|
|
}: {
|
|
|
|
|
|
templateMode: boolean
|
|
|
|
|
|
onBack: () => void
|
|
|
|
|
|
onCreate: () => void
|
|
|
|
|
|
onPick: (c: Course) => void
|
2026-08-13 07:39:29 +02:00
|
|
|
|
onInternational: () => void
|
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
|
|
|
|
}) {
|
|
|
|
|
|
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>
|
2026-08-13 07:39:29 +02:00
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
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
|
|
|
|
</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 på 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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|