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>
209 lines
7.4 KiB
TypeScript
209 lines
7.4 KiB
TypeScript
import type { ApiCourseHole, ApiCourseTee } from "@/components/course-template-editor"
|
|
import type { Account, ApiGender, Course, Facility, Gender, KnownGuest, Tee } from "./types"
|
|
|
|
// --- Kjønn-konvertering (samme mønster som new-round.tsx) ------------------
|
|
|
|
export function apiGenderOf(g: Gender): ApiGender | "x" {
|
|
return g === "mann" ? "m" : g === "kvinne" ? "f" : "x"
|
|
}
|
|
export function uiGenderOf(g: ApiGender): Gender {
|
|
return g === "m" ? "mann" : "kvinne"
|
|
}
|
|
|
|
// --- Rå API-typer (kun det denne veiviseren faktisk leser) -----------------
|
|
|
|
type ApiTeeOption = { name: string; genders: ApiGender[] }
|
|
type ApiFacility = { slug: string; name: string; city: string | null; county: string | null }
|
|
type ApiNearbyFacility = ApiFacility & { distance_km: number }
|
|
type ApiOfficialCourseOption = {
|
|
teeoff_course_id: number
|
|
name: string
|
|
is_main_course: boolean
|
|
tees: ApiTeeOption[]
|
|
holes: ApiCourseHole[]
|
|
full_tees: ApiCourseTee[]
|
|
}
|
|
type ApiPersonalCourse = { id: string; name: string; is_mine: boolean; created_by_display_name: string }
|
|
type ApiPersonalCourseDetail = {
|
|
id: string
|
|
name: string
|
|
tees: ApiTeeOption[]
|
|
holes: ApiCourseHole[]
|
|
full_tees: ApiCourseTee[]
|
|
is_mine: boolean
|
|
created_by_display_name: string
|
|
forked_from_id: string | null
|
|
}
|
|
export type PersonMatch = {
|
|
id: string
|
|
first_name: string
|
|
last_name: string
|
|
avatar_url: string | null
|
|
home_club: string | null
|
|
handicap_index: number | null
|
|
}
|
|
type ApiKnownGuest = {
|
|
guest_first_name: string
|
|
guest_last_name: string | null
|
|
gender: ApiGender | null
|
|
handicap_index: number | null
|
|
}
|
|
|
|
let idSeq = 0
|
|
function newId(prefix: string) {
|
|
idSeq += 1
|
|
return `${prefix}-${idSeq}`
|
|
}
|
|
|
|
function apiTeesToTees(apiTees: ApiTeeOption[]): Tee[] {
|
|
return apiTees.map((t, i) => ({ id: `${t.name}-${i}`, name: t.name, genders: t.genders }))
|
|
}
|
|
|
|
function courseFromOfficial(teeoffCourseId: number, c: {
|
|
name: string
|
|
tees: ApiTeeOption[]
|
|
holes: ApiCourseHole[]
|
|
full_tees: ApiCourseTee[]
|
|
}): Course {
|
|
return {
|
|
id: `teeoff-${teeoffCourseId}`,
|
|
name: c.name,
|
|
tees: apiTeesToTees(c.tees),
|
|
templateHoles: c.holes,
|
|
templateTees: c.full_tees,
|
|
}
|
|
}
|
|
|
|
// --- Offisielt banesøk -------------------------------------------------------
|
|
|
|
export async function searchOfficialFacilities(query: string): Promise<Facility[]> {
|
|
const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query)}`, { credentials: "include" })
|
|
if (!res.ok) return []
|
|
const data: ApiFacility[] = await res.json()
|
|
return data
|
|
}
|
|
|
|
export async function nearbyOfficialFacilities(lat: number, lng: number): Promise<(Facility & { distanceKm: number })[]> {
|
|
const res = await fetch(`/rounds/official-search/nearby?lat=${lat}&lng=${lng}&limit=5`, { credentials: "include" })
|
|
if (!res.ok) return []
|
|
const data: ApiNearbyFacility[] = await res.json()
|
|
return data.map((f) => ({ slug: f.slug, name: f.name, city: f.city, county: f.county, distanceKm: f.distance_km }))
|
|
}
|
|
|
|
export async function fetchFacilityCourses(slug: string): Promise<{ teeoffCourseId: number; course: Course }[]> {
|
|
const res = await fetch(`/rounds/official-search/${slug}`, { credentials: "include" })
|
|
if (!res.ok) return []
|
|
const data: { slug: string; name: string; courses: ApiOfficialCourseOption[] } = await res.json()
|
|
return data.courses.map((c) => ({
|
|
teeoffCourseId: c.teeoff_course_id,
|
|
course: courseFromOfficial(c.teeoff_course_id, c),
|
|
}))
|
|
}
|
|
|
|
// --- Egne baner ---------------------------------------------------------------
|
|
|
|
export async function searchOwnCourses(query: string): Promise<{ id: string; name: string }[]> {
|
|
const res = await fetch(`/personal-courses?q=${encodeURIComponent(query)}`, { credentials: "include" })
|
|
if (!res.ok) return []
|
|
const data: ApiPersonalCourse[] = await res.json()
|
|
return data.map((c) => ({ id: c.id, name: c.name }))
|
|
}
|
|
|
|
export async function fetchOwnCourse(id: string): Promise<Course | null> {
|
|
const res = await fetch(`/personal-courses/${id}`, { credentials: "include" })
|
|
if (!res.ok) return null
|
|
const c: ApiPersonalCourseDetail = await res.json()
|
|
return {
|
|
id: c.id,
|
|
name: c.name,
|
|
tees: apiTeesToTees(c.tees),
|
|
templateHoles: c.holes,
|
|
templateTees: c.full_tees,
|
|
}
|
|
}
|
|
|
|
export async function createOwnCourse(body: {
|
|
name: string
|
|
holes: { hole_number: number; par: number; stroke_index: number }[]
|
|
tees: { name: string; ratings: { gender: ApiGender; course_rating: number; slope_rating: number; par: number }[] }[]
|
|
forkedFromId?: string | null
|
|
}): Promise<Course> {
|
|
const res = await fetch("/personal-courses", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({
|
|
name: body.name,
|
|
holes: body.holes,
|
|
tees: body.tees,
|
|
forked_from_id: body.forkedFromId ?? null,
|
|
}),
|
|
})
|
|
if (!res.ok) throw new Error(`create: ${res.status}`)
|
|
const created: { id: string; name: string } = await res.json()
|
|
return {
|
|
id: created.id,
|
|
name: created.name,
|
|
tees: body.tees.map((t) => ({
|
|
id: newId(t.name),
|
|
name: t.name,
|
|
genders: t.ratings.map((r) => r.gender),
|
|
})),
|
|
}
|
|
}
|
|
|
|
// --- 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[]> {
|
|
const res = await fetch(`/people/search?q=${encodeURIComponent(query)}`, { credentials: "include" })
|
|
if (!res.ok) return []
|
|
const data: PersonMatch[] = await res.json()
|
|
return data.map((p) => ({ id: p.id, firstName: p.first_name, lastName: p.last_name, handicap: p.handicap_index }))
|
|
}
|
|
|
|
export async function lookupKnownGuest(email: string): Promise<KnownGuest | null> {
|
|
const res = await fetch(`/rounds/guests/known?email=${encodeURIComponent(email)}`, { credentials: "include" })
|
|
if (!res.ok) return null
|
|
const g: ApiKnownGuest | null = await res.json()
|
|
if (!g) return null
|
|
return {
|
|
firstName: g.guest_first_name,
|
|
lastName: g.guest_last_name ?? "",
|
|
gender: g.gender,
|
|
hcp: g.handicap_index,
|
|
}
|
|
}
|