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:
parent
a9abe86116
commit
410eaa60ae
7 changed files with 441 additions and 150 deletions
|
|
@ -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å
|
||||
|
|
|
|||
47
CHANGELOG.md
47
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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<InternationalSearch
|
||||
onBack={() => 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({
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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 -------------------------------------------------
|
||||
|
||||
export async function searchAccounts(query: string): Promise<Account[]> {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ export type Sub1 =
|
|||
| "official-search"
|
||||
| "official-courses"
|
||||
| "own-search"
|
||||
| "international-search"
|
||||
| "own-create"
|
||||
| "template-source"
|
||||
| "fields"
|
||||
|
|
|
|||
Loading…
Reference in a new issue