Søk-og-koble-UI-en for GolfAPI (ADR-064) var kun koblet inn i "endre
bane"-skjemaet, ikke i /my-rounds/new (components/ny-runde/) -- den
faktiske primære inngangen. Lagt til der (international-search-
delstilstand + InternationalSearch-komponent).
Fant og fikset en reell 500-feil under verifisering: GET
/personal-courses/{personal_course_id} var registrert før den nye,
mer spesifikke GET /personal-courses/international-search -- FastAPI
matcher ruter i registreringsrekkefølge, så den generiske ruten fanget
"international-search" som en ugyldig UUID. Flyttet de nye
endepunktene foran.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
781 lines
29 KiB
TypeScript
781 lines
29 KiB
TypeScript
"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]
|
||
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 })}
|
||
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 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,
|
||
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 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>
|
||
)
|
||
}
|