From 410eaa60ae7c0838a7a10fac6709a44730ca1b5d Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Thu, 13 Aug 2026 07:39:29 +0200 Subject: [PATCH] =?UTF-8?q?GolfAPI-s=C3=B8k=20i=20den=20prim=C3=A6re=20Ny?= =?UTF-8?q?=20runde-veiviseren=20+=20fiks=20ruting-bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ARCHITECTURE_DECISIONS.md | 22 ++ CHANGELOG.md | 47 +++ app/routers/rounds.py | 307 +++++++++--------- .../components/ny-runde/step1-course-time.tsx | 178 ++++++++++ .../components/ny-runde/wizard-context.tsx | 2 + frontend/lib/ny-runde/api.ts | 34 ++ frontend/lib/ny-runde/types.ts | 1 + 7 files changed, 441 insertions(+), 150 deletions(-) diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 294a856..20e7d37 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -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 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: 1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på diff --git a/CHANGELOG.md b/CHANGELOG.md index 84365c5..06d91ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10756,3 +10756,50 @@ Neste steg: etter). Rene containerlogger begge ganger, ingen konsollfeil på `https://teecup.golf/logg-inn` etter siste omstart. ~15,5 av 20 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. diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 5acfeaf..95adc74 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -203,6 +203,163 @@ async def search_personal_courses( 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) async def get_personal_course( 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) -# --- 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) async def update_personal_course( personal_course_id: str, diff --git a/frontend/components/ny-runde/step1-course-time.tsx b/frontend/components/ny-runde/step1-course-time.tsx index baf4f1f..329c44d 100644 --- a/frontend/components/ny-runde/step1-course-time.tsx +++ b/frontend/components/ny-runde/step1-course-time.tsx @@ -5,10 +5,13 @@ 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" @@ -86,6 +89,14 @@ export function Step1CourseTime() { 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 ( + patch({ s1Sub: "own-search" })} + onPick={(course) => pickCourse(course, { source: "custom", personalCourseId: course.id })} /> ) case "own-create": @@ -370,11 +381,13 @@ function OwnSearch({ 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) @@ -464,6 +477,171 @@ function OwnSearch({

)} + + + + ) +} + +/* ------------------------------------------------------------------ */ +/* 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(null) + const [selectedClub, setSelectedClub] = useState(null) + const [searching, setSearching] = useState(false) + const [importingId, setImportingId] = useState(null) + const [error, setError] = useState(null) + const inputRef = useRef(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 ( +
+ setSelectedClub(null) : onBack} + /> + {error ? ( +

+ {error} +

+ ) : null} + + {!selectedClub ? ( + <> + +
+
+
+ +
+ {clubs === null ? ( +

+ Begynn å skrive for å søke etter en klubb. +

+ ) : clubs.length > 0 ? ( +
    + {clubs.map((c) => ( +
  • + +
  • + ))} +
+ ) : ( +

+ Ingen klubber matcher «{query}». +

+ )} +
+ + ) : ( +
    + {selectedClub.courses.map((c) => ( +
  • + +
  • + ))} + {selectedClub.courses.length === 0 ? ( +
  • + Ingen baner registrert hos denne klubben hos GolfAPI ennå. +
  • + ) : null} +
+ )}
) } diff --git a/frontend/components/ny-runde/wizard-context.tsx b/frontend/components/ny-runde/wizard-context.tsx index d808855..d455c43 100644 --- a/frontend/components/ny-runde/wizard-context.tsx +++ b/frontend/components/ny-runde/wizard-context.tsx @@ -260,6 +260,8 @@ export function WizardProvider({ return { ...s, s1Sub: "official-search" } case "own-search": return { ...s, s1Sub: s.templateMode ? "template-source" : "source" } + case "international-search": + return { ...s, s1Sub: "own-search" } case "own-create": return { ...s, s1Sub: s.ownCreateOrigin } case "fields": diff --git a/frontend/lib/ny-runde/api.ts b/frontend/lib/ny-runde/api.ts index 69e8b82..2d6082c 100644 --- a/frontend/lib/ny-runde/api.ts +++ b/frontend/lib/ny-runde/api.ts @@ -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 { + 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 { + 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 ------------------------------------------------- export async function searchAccounts(query: string): Promise { diff --git a/frontend/lib/ny-runde/types.ts b/frontend/lib/ny-runde/types.ts index a4c7df8..54dd585 100644 --- a/frontend/lib/ny-runde/types.ts +++ b/frontend/lib/ny-runde/types.ts @@ -105,6 +105,7 @@ export type Sub1 = | "official-search" | "official-courses" | "own-search" + | "international-search" | "own-create" | "template-source" | "fields"