GolfAPI-søk i den primære Ny runde-veiviseren + fiks ruting-bug

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>
This commit is contained in:
Erol Haagenrud 2026-08-13 07:39:29 +02:00
parent a9abe86116
commit 410eaa60ae
7 changed files with 441 additions and 150 deletions

View file

@ -6193,6 +6193,28 @@ ganger denne dagen -- brukeren stanset bevisst mellom første og andre
gang for å laste opp V0-eksporten). Se CHANGELOG.md punkt 77 for full gang for å laste opp V0-eksporten). Se CHANGELOG.md punkt 77 for full
verifiseringsdetalj. verifiseringsdetalj.
**Tillegg 2026-08-13 — den primære "Ny runde"-veiviseren manglet
GolfAPI-søket, og en reell ruting-bug ble funnet og fikset.** Brukeren
spurte hvor, nøyaktig, funksjonen kunne sees — det viste seg at
2026-08-12-rundens søk-UI kun var koblet inn i "endre bane"-skjemaet
(`round-detail.tsx`), ikke i den faktiske `/my-rounds/new`-veiviseren
(`components/ny-runde/`, en helt separat komponenttre). Lagt til der.
Under verifisering av DENNE tilkoblingen (ikke forrige runde, som kun
reelt HTTP-testet POST-import, aldri GET-søket) ble en ekte 500-feil
funnet: `GET /personal-courses/{personal_course_id}` (eksisterende,
generisk) var registrert FØR den nye, mer spesifikke
`GET /personal-courses/international-search` — FastAPI matcher ruter i
registreringsrekkefølge, så den generiske ruten fanget grådig opp
"international-search" som en ugyldig UUID. Lærdom: en ny literal-path-
rute som deler prefiks med en eksisterende parameterisert rute MÅ
registreres FØR den, ikke etter — nå kommentert direkte i koden
(`rounds.py`) for å unngå gjentakelse. Org-siden (`courses.py`) hadde
aldri dette problemet (ingen bar `/courses/{course_id}`-rute der).
Full scratch-verifisert på nytt (ekte klikk-gjennom i selve "Ny
runde"-veiviseren, ingen konsollfeil, søket bekreftet returnere reelle
treff). **Rullet ut 2026-08-13.** Se CHANGELOG.md punkt 77 (tillegg)
for full detalj.
Disse må avklares før eller under de relevante fasene: Disse må avklares før eller under de relevante fasene:
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på 1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på

View file

@ -10756,3 +10756,50 @@ Neste steg:
etter). Rene containerlogger begge ganger, ingen konsollfeil på etter). Rene containerlogger begge ganger, ingen konsollfeil på
`https://teecup.golf/logg-inn` etter siste omstart. ~15,5 av 20 `https://teecup.golf/logg-inn` etter siste omstart. ~15,5 av 20
GolfAPI-kall gjenstår. GolfAPI-kall gjenstår.
**Tillegg 2026-08-13 -- den PRIMÆRE "Ny runde"-veiviseren manglet
GolfAPI-søket, og en reell ruting-bug ble funnet og fikset.**
Brukeren spurte "hvor, nøyaktig, kan jeg se dette implementert?" --
undersøkelsen avdekket at søk-og-koble-UI-en fra 2026-08-12 KUN var
koblet inn i "endre bane på en allerede opprettet runde"-skjemaet
(`ChangeCourseForm`, round-detail.tsx), IKKE i den faktiske
`/my-rounds/new`-veiviseren (`components/ny-runde/step1-course-
time.tsx` + `lib/ny-runde/api.ts`) -- en helt separat komponenttre
brukeren faktisk møter FØRST. Lagt til der også: ny
`"international-search"`-delstilstand (`Sub1`-typen),
`InternationalSearch`-komponent (samme klubb->baner-todelte søk som
de to andre stedene, i `--nr-*`-designtokens), `searchInternationalClubs`/
`importInternationalCourse` i `lib/ny-runde/api.ts`.
**Ekte bug fanget under nettleserverifisering av DENNE
integrasjonen** (ikke funnet i forrige runde fordi kun POST
`/personal-courses/international-import` ble reelt HTTP-testet der,
aldri GET `/personal-courses/international-search`): 500-feil,
`asyncpg.exceptions.DataError: invalid input for query argument $1:
'international-search' (invalid UUID...)`. Årsak: FastAPI matcher
ruter i REGISTRERINGSREKKEFØLGE -- `GET /personal-courses/
{personal_course_id}` (allerede eksisterende, generisk) var
registrert FØR den nye, mer spesifikke `GET /personal-courses/
international-search`, så `{personal_course_id}` fanget grådig opp
"international-search" som en (ugyldig) UUID. Samme fellgruve
fantes IKKE på org-siden (courses.py) fordi det der ikke finnes noen
bar `/courses/{course_id}`-rute å kollidere med (kun `/{course_id}/
tees` og `/{course_id}/holes`, annen path-form). Fikset ved å flytte
de to nye endepunktene til FØR `get_personal_course` i
`rounds.py` -- lærdom kommentert direkte i koden for å unngå samme
feil neste gang en literal-path-rute legges til ved siden av en
eksisterende parameterisert rute.
**Verifisert på nytt, fullt ut, etter fiksen:** frisk scratch-stack,
ekte innlogging, full klikk-gjennom-flyt i den FAKTISKE "Ny
runde"-veiviseren (Egen bane -> "Fant ikke banen? Søk
internasjonalt" -> søk «Tjøme» -> Tjøme Golfklubb -> importer ->
landet korrekt på "Bane & tid"-steget med "Tjøme Golfklubb Bane",
4 utslag), ingen konsollfeil, bekreftet via nettverksfane at søket
nå returnerer 200 med reelt klubbtreff (ikke lenger 401/500).
`tsc --noEmit` rent, 45/45 vitest, 31/31 backend-tester. Scratch-
ressurser ryddet opp, `teecup_db` bekreftet uendret.
**Rullet ut 2026-08-13**, samme økt. `docker compose build
teecup_api teecup_frontend && up -d`. Rene containerlogger, ingen
konsollfeil på `https://teecup.golf/logg-inn` etter omstart.

View file

@ -203,6 +203,163 @@ async def search_personal_courses(
return [PersonalCourseOut(**dict(r)) for r in rows] return [PersonalCourseOut(**dict(r)) for r in rows]
# --- Internasjonal banedata fra GolfAPI.io (ADR-064) -------------------------
#
# Speiler international-search/-import i courses.py (org-scopet variant),
# men skriver inn i den globale personal_course-katalogen i stedet for
# course/hole/tee. Samme cache-chokepoint (golfapi_cache), samme "fail
# loudly"-prinsipp. round.course_source trenger INGEN endring -- en
# GolfAPI-importert bane blir en helt vanlig personal_course-rad
# (course_source='custom'), kun med external_golfapi_course_id satt for
# å (a) hindre dupliserte importer, (b) la rangefinder-oppslaget finne
# koordinatene senere.
#
# MERK plasseringen: MÅ stå FØR GET /personal-courses/{personal_course_id}
# under -- FastAPI matcher ruter i registreringsrekkefølge, og en generisk
# {personal_course_id}-parameter ville ellers grådig fanget opp
# "international-search" som en (ugyldig) UUID FØR denne mer spesifikke
# ruten noensinne nås. Fanget som en ekte 500 under nettleserverifisering
# 2026-08-13 -- se CHANGELOG.md.
class InternationalCourseOption(BaseModel):
course_id: str
course_name: str
num_holes: int
has_gps: bool
class InternationalClub(BaseModel):
club_id: str
club_name: str
city: str | None = None
country: str | None = None
courses: list[InternationalCourseOption]
class InternationalCourseImport(BaseModel):
golfapi_course_id: str
@router.get("/personal-courses/international-search", response_model=list[InternationalClub])
async def search_international_personal_courses(
q: str = Query(default=""),
user: CurrentUser = Depends(get_current_user),
) -> list[InternationalClub]:
if not q.strip():
return []
try:
clubs = await golfapi_client.search_clubs(name=q)
except golfapi_client.GolfApiNotConfiguredError:
raise app_error(400, "NOT_CONFIGURED", "GolfAPI er ikke konfigurert ennå.")
except golfapi_client.GolfApiUnavailableError:
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra GolfAPI akkurat nå.")
return [
InternationalClub(
club_id=c["clubID"],
club_name=c["clubName"],
city=c.get("city"),
country=c.get("country"),
courses=[
InternationalCourseOption(
course_id=course["courseID"],
course_name=course.get("courseName") or "Bane",
num_holes=int(course["numHoles"]),
has_gps=bool(int(course.get("hasGPS") or 0)),
)
for course in c.get("courses", [])
],
)
for c in clubs
]
@router.post("/personal-courses/international-import", response_model=PersonalCourseOut, status_code=201)
async def import_international_personal_course(
body: InternationalCourseImport,
user: CurrentUser = Depends(get_current_user),
) -> PersonalCourseOut:
if not settings.GOLFAPI_TOKEN:
raise app_error(400, "NOT_CONFIGURED", "GolfAPI er ikke konfigurert ennå.")
async with plain_connection() as conn:
# Idempotent, samme begrunnelse som TeeOff-importen: gi tilbake den
# EKSISTERENDE (globale -- kan være importert av en ANNEN bruker)
# personal_course-raden fremfor å avvise med 409.
existing = await conn.fetchrow(
"""
SELECT pc.id::text AS id, pc.name, (pc.created_by_user_id::text = $2) AS is_mine, au.display_name AS created_by_display_name
FROM personal_course pc JOIN app_user au ON au.id = pc.created_by_user_id
WHERE pc.external_golfapi_course_id = $1
""",
body.golfapi_course_id,
user.user_id,
)
if existing is not None:
return PersonalCourseOut(**dict(existing))
async with plain_connection() as conn, translate_db_errors():
try:
await golfapi_cache.get_or_fetch_golfapi_course(conn, body.golfapi_course_id)
except golfapi_client.GolfApiNotFoundError:
raise app_error(404, "NOT_FOUND", "Banen finnes ikke hos GolfAPI.")
except golfapi_client.GolfApiUnavailableError:
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra GolfAPI akkurat nå.")
gc = await conn.fetchrow(
"SELECT club_name, course_name FROM golfapi_course WHERE course_id = $1", body.golfapi_course_id
)
holes = await conn.fetch(
"SELECT hole_number, par_men, index_men FROM golfapi_course_hole WHERE golfapi_course_id = $1 ORDER BY hole_number",
body.golfapi_course_id,
)
tees = await conn.fetch(
"SELECT name, course_rating_men, slope_men, course_rating_women, slope_women FROM golfapi_course_tee WHERE golfapi_course_id = $1",
body.golfapi_course_id,
)
if len(holes) != 18:
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull hos GolfAPI.")
if not tees:
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ingen registrerte utslag hos GolfAPI.")
par_total = sum(h["par_men"] for h in holes)
course_name = f"{gc['club_name']} {gc['course_name']}" if gc["course_name"] else gc["club_name"]
own_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
async with conn.transaction():
course_row = await conn.fetchrow(
"""
INSERT INTO personal_course (name, created_by_user_id, external_golfapi_course_id)
VALUES ($1, $2, $3) RETURNING id::text AS id, name
""",
course_name,
user.user_id,
body.golfapi_course_id,
)
for h in holes:
await conn.execute(
"INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
course_row["id"], h["hole_number"], h["par_men"], h["index_men"],
)
for t in tees:
tee_row = await conn.fetchrow(
"INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id",
course_row["id"], t["name"],
)
ratings: list[tuple[str, float, int, int]] = [("m", float(t["course_rating_men"]), int(t["slope_men"]), par_total)]
if t["course_rating_women"] is not None and t["slope_women"] is not None:
ratings.append(("f", float(t["course_rating_women"]), int(t["slope_women"]), par_total))
for gender, course_rating, slope_rating, par in ratings:
await conn.execute(
"""
INSERT INTO personal_course_tee_rating (personal_course_tee_id, gender, course_rating, slope_rating, par)
VALUES ($1, $2, $3, $4, $5)
""",
tee_row["id"], gender, course_rating, slope_rating, par,
)
return PersonalCourseOut(id=course_row["id"], name=course_row["name"], is_mine=True, created_by_display_name=own_name)
@router.get("/personal-courses/{personal_course_id}", response_model=PersonalCourseDetail) @router.get("/personal-courses/{personal_course_id}", response_model=PersonalCourseDetail)
async def get_personal_course( async def get_personal_course(
personal_course_id: str, personal_course_id: str,
@ -353,156 +510,6 @@ async def create_personal_course(
return PersonalCourseOut(id=course_row["id"], name=course_row["name"], is_mine=True, created_by_display_name=own_name) return PersonalCourseOut(id=course_row["id"], name=course_row["name"], is_mine=True, created_by_display_name=own_name)
# --- Internasjonal banedata fra GolfAPI.io (ADR-064) -------------------------
#
# Speiler international-search/-import i courses.py (org-scopet variant),
# men skriver inn i den globale personal_course-katalogen i stedet for
# course/hole/tee. Samme cache-chokepoint (golfapi_cache), samme "fail
# loudly"-prinsipp. round.course_source trenger INGEN endring -- en
# GolfAPI-importert bane blir en helt vanlig personal_course-rad
# (course_source='custom'), kun med external_golfapi_course_id satt for
# å (a) hindre dupliserte importer, (b) la rangefinder-oppslaget finne
# koordinatene senere.
class InternationalCourseOption(BaseModel):
course_id: str
course_name: str
num_holes: int
has_gps: bool
class InternationalClub(BaseModel):
club_id: str
club_name: str
city: str | None = None
country: str | None = None
courses: list[InternationalCourseOption]
class InternationalCourseImport(BaseModel):
golfapi_course_id: str
@router.get("/personal-courses/international-search", response_model=list[InternationalClub])
async def search_international_personal_courses(
q: str = Query(default=""),
user: CurrentUser = Depends(get_current_user),
) -> list[InternationalClub]:
if not q.strip():
return []
try:
clubs = await golfapi_client.search_clubs(name=q)
except golfapi_client.GolfApiNotConfiguredError:
raise app_error(400, "NOT_CONFIGURED", "GolfAPI er ikke konfigurert ennå.")
except golfapi_client.GolfApiUnavailableError:
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baner fra GolfAPI akkurat nå.")
return [
InternationalClub(
club_id=c["clubID"],
club_name=c["clubName"],
city=c.get("city"),
country=c.get("country"),
courses=[
InternationalCourseOption(
course_id=course["courseID"],
course_name=course.get("courseName") or "Bane",
num_holes=int(course["numHoles"]),
has_gps=bool(int(course.get("hasGPS") or 0)),
)
for course in c.get("courses", [])
],
)
for c in clubs
]
@router.post("/personal-courses/international-import", response_model=PersonalCourseOut, status_code=201)
async def import_international_personal_course(
body: InternationalCourseImport,
user: CurrentUser = Depends(get_current_user),
) -> PersonalCourseOut:
if not settings.GOLFAPI_TOKEN:
raise app_error(400, "NOT_CONFIGURED", "GolfAPI er ikke konfigurert ennå.")
async with plain_connection() as conn:
# Idempotent, samme begrunnelse som TeeOff-importen: gi tilbake den
# EKSISTERENDE (globale -- kan være importert av en ANNEN bruker)
# personal_course-raden fremfor å avvise med 409.
existing = await conn.fetchrow(
"""
SELECT pc.id::text AS id, pc.name, (pc.created_by_user_id::text = $2) AS is_mine, au.display_name AS created_by_display_name
FROM personal_course pc JOIN app_user au ON au.id = pc.created_by_user_id
WHERE pc.external_golfapi_course_id = $1
""",
body.golfapi_course_id,
user.user_id,
)
if existing is not None:
return PersonalCourseOut(**dict(existing))
async with plain_connection() as conn, translate_db_errors():
try:
await golfapi_cache.get_or_fetch_golfapi_course(conn, body.golfapi_course_id)
except golfapi_client.GolfApiNotFoundError:
raise app_error(404, "NOT_FOUND", "Banen finnes ikke hos GolfAPI.")
except golfapi_client.GolfApiUnavailableError:
raise app_error(502, "EXTERNAL_SERVICE_UNAVAILABLE", "Klarte ikke å hente baneinfo fra GolfAPI akkurat nå.")
gc = await conn.fetchrow(
"SELECT club_name, course_name FROM golfapi_course WHERE course_id = $1", body.golfapi_course_id
)
holes = await conn.fetch(
"SELECT hole_number, par_men, index_men FROM golfapi_course_hole WHERE golfapi_course_id = $1 ORDER BY hole_number",
body.golfapi_course_id,
)
tees = await conn.fetch(
"SELECT name, course_rating_men, slope_men, course_rating_women, slope_women FROM golfapi_course_tee WHERE golfapi_course_id = $1",
body.golfapi_course_id,
)
if len(holes) != 18:
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ikke 18 registrerte hull hos GolfAPI.")
if not tees:
raise app_error(400, "EXTERNAL_DATA_INCOMPLETE", "Banen har ingen registrerte utslag hos GolfAPI.")
par_total = sum(h["par_men"] for h in holes)
course_name = f"{gc['club_name']} {gc['course_name']}" if gc["course_name"] else gc["club_name"]
own_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
async with conn.transaction():
course_row = await conn.fetchrow(
"""
INSERT INTO personal_course (name, created_by_user_id, external_golfapi_course_id)
VALUES ($1, $2, $3) RETURNING id::text AS id, name
""",
course_name,
user.user_id,
body.golfapi_course_id,
)
for h in holes:
await conn.execute(
"INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
course_row["id"], h["hole_number"], h["par_men"], h["index_men"],
)
for t in tees:
tee_row = await conn.fetchrow(
"INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id",
course_row["id"], t["name"],
)
ratings: list[tuple[str, float, int, int]] = [("m", float(t["course_rating_men"]), int(t["slope_men"]), par_total)]
if t["course_rating_women"] is not None and t["slope_women"] is not None:
ratings.append(("f", float(t["course_rating_women"]), int(t["slope_women"]), par_total))
for gender, course_rating, slope_rating, par in ratings:
await conn.execute(
"""
INSERT INTO personal_course_tee_rating (personal_course_tee_id, gender, course_rating, slope_rating, par)
VALUES ($1, $2, $3, $4, $5)
""",
tee_row["id"], gender, course_rating, slope_rating, par,
)
return PersonalCourseOut(id=course_row["id"], name=course_row["name"], is_mine=True, created_by_display_name=own_name)
@router.patch("/personal-courses/{personal_course_id}", response_model=PersonalCourseOut) @router.patch("/personal-courses/{personal_course_id}", response_model=PersonalCourseOut)
async def update_personal_course( async def update_personal_course(
personal_course_id: str, personal_course_id: str,

View file

@ -5,10 +5,13 @@ import { useEffect, useMemo, useRef, useState } from "react"
import { import {
fetchFacilityCourses, fetchFacilityCourses,
fetchOwnCourse, fetchOwnCourse,
importInternationalCourse,
nearbyOfficialFacilities, nearbyOfficialFacilities,
searchInternationalClubs,
searchOfficialFacilities, searchOfficialFacilities,
searchOwnCourses, searchOwnCourses,
} from "@/lib/ny-runde/api" } from "@/lib/ny-runde/api"
import type { ApiInternationalClub, ApiInternationalCourseOption } from "@/lib/ny-runde/api"
import type { Course, Facility } from "@/lib/ny-runde/types" import type { Course, Facility } from "@/lib/ny-runde/types"
import { CreateCourseForm } from "./create-course-form" import { CreateCourseForm } from "./create-course-form"
import { ChoiceCard, Field, NativeSelect, Panel, Pill, TextInput, useDebouncedValue } from "./primitives" import { ChoiceCard, Field, NativeSelect, Panel, Pill, TextInput, useDebouncedValue } from "./primitives"
@ -86,6 +89,14 @@ export function Step1CourseTime() {
onBack={() => patch({ s1Sub: state.templateMode ? "template-source" : "source" })} onBack={() => patch({ s1Sub: state.templateMode ? "template-source" : "source" })}
onCreate={() => patch({ s1Sub: "own-create", templateSeed: undefined, ownCreateOrigin: "own-search" })} onCreate={() => patch({ s1Sub: "own-create", templateSeed: undefined, ownCreateOrigin: "own-search" })}
onPick={(course) => pickCourse(course, { source: "custom", personalCourseId: course.id })} 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": case "own-create":
@ -370,11 +381,13 @@ function OwnSearch({
onBack, onBack,
onCreate, onCreate,
onPick, onPick,
onInternational,
}: { }: {
templateMode: boolean templateMode: boolean
onBack: () => void onBack: () => void
onCreate: () => void onCreate: () => void
onPick: (c: Course) => void onPick: (c: Course) => void
onInternational: () => void
}) { }) {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const debounced = useDebouncedValue(query, 250) const debounced = useDebouncedValue(query, 250)
@ -464,6 +477,171 @@ function OwnSearch({
</p> </p>
)} )}
</div> </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> </div>
) )
} }

View file

@ -260,6 +260,8 @@ export function WizardProvider({
return { ...s, s1Sub: "official-search" } return { ...s, s1Sub: "official-search" }
case "own-search": case "own-search":
return { ...s, s1Sub: s.templateMode ? "template-source" : "source" } return { ...s, s1Sub: s.templateMode ? "template-source" : "source" }
case "international-search":
return { ...s, s1Sub: "own-search" }
case "own-create": case "own-create":
return { ...s, s1Sub: s.ownCreateOrigin } return { ...s, s1Sub: s.ownCreateOrigin }
case "fields": case "fields":

View file

@ -152,6 +152,40 @@ export async function createOwnCourse(body: {
} }
} }
// --- Internasjonal bane fra GolfAPI.io (ADR-064) ------------------------------
// Importerte baner havner i DEN SAMME personal_course-katalogen som "egen
// bane" over -- derav gjenbruk av fetchOwnCourse for å hente full detalj
// (tees/holes) etter import, samme to-stegs mønster.
export type ApiInternationalCourseOption = { course_id: string; course_name: string; num_holes: number; has_gps: boolean }
export type ApiInternationalClub = {
club_id: string
club_name: string
city: string | null
country: string | null
courses: ApiInternationalCourseOption[]
}
export async function searchInternationalClubs(query: string): Promise<ApiInternationalClub[]> {
const res = await fetch(`/personal-courses/international-search?q=${encodeURIComponent(query)}`, { credentials: "include" })
if (!res.ok) return []
return res.json()
}
export async function importInternationalCourse(golfapiCourseId: string): Promise<Course> {
const importRes = await fetch(`/personal-courses/international-import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ golfapi_course_id: golfapiCourseId }),
})
if (!importRes.ok) throw new Error(`international-import: ${importRes.status}`)
const imported: { id: string } = await importRes.json()
const course = await fetchOwnCourse(imported.id)
if (!course) throw new Error("international-import: klarte ikke å hente full banedetalj")
return course
}
// --- Spillersøk / kjent gjest ------------------------------------------------- // --- Spillersøk / kjent gjest -------------------------------------------------
export async function searchAccounts(query: string): Promise<Account[]> { export async function searchAccounts(query: string): Promise<Account[]> {

View file

@ -105,6 +105,7 @@ export type Sub1 =
| "official-search" | "official-search"
| "official-courses" | "official-courses"
| "own-search" | "own-search"
| "international-search"
| "own-create" | "own-create"
| "template-source" | "template-source"
| "fields" | "fields"