teecup/frontend/components/ny-runde/create-course-form.tsx
Erol Haagenrud 11d10f7661 Erstatt /my-rounds/new med ny-runde-veiviser fra egen V0-prompt
Gammel components/new-round.tsx (3364 linjer) slettet, erstattet av
components/ny-runde/ + lib/ny-runde/ -- V0-eksporten (full teknisk spec,
null designføring) ported mot de faktiske backend-kontraktene i
app/routers/rounds.py: Gender-oversettelse, forenklet Tee-form (aldri
CR/Slope til klienten), CourseMeta-discriminated-union for teeoff- vs.
egen-bane, ekte POST /rounds -> sides -> participants-sekvens, ekte
kontosøk/gjeste-oppslag, ekte banesøk inkl. geolokasjon.

Fire bugs funnet og rettet under scratch-verifisering: manglende
forwardRef på TextInput (i selve V0-eksporten), manglende
tee-synk til eierens spillerkort etter banevalg (egen portingsfeil),
manglende Ingen/Alle-hurtigknapp i steg 5 (samme mangel rettet i appen
2026-08-06), og en produksjonsbug oppdaget EKTE brukere etter utrulling:
fetchFacilityCourses() leste responsen fra offisielt banesøk som en rå
array i stedet for {slug,name,courses}-objektet backend faktisk
returnerer -- ga en uendelig "Laster baner..."-hang for enhver runde
startet på en offisiell (teeoff-)bane. Rettet + lagt til reell
feilhåndtering i OfficialCourses (manglet helt, feilen ble tidligere
en stille, ufanget promise-rejection).

Se CHANGELOG.md punkt 41 og 43 for full verifiseringsdetalj.
2026-08-08 12:34:25 +02:00

384 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import type { ApiGender, Course } from "@/lib/ny-runde/types"
import { createOwnCourse } from "@/lib/ny-runde/api"
import { cn } from "@/lib/utils"
import { Plus, Trash2 } from "lucide-react"
import { useMemo, useState } from "react"
import { Field, NativeSelect, TextInput, Toggle, useAutoFocus } from "./primitives"
interface DraftRating {
cr: string
slope: string
par: string
}
interface DraftTee {
id: string
name: string
menEnabled: boolean
womenEnabled: boolean
men: DraftRating
women: DraftRating
}
interface DraftHole {
par: number
si: number | 0 // 0 = ikke satt
}
let teeCounter = 0
function newTee(): DraftTee {
teeCounter += 1
return {
id: `draft-tee-${teeCounter}`,
name: "",
menEnabled: true,
womenEnabled: false,
men: { cr: "", slope: "", par: "" },
women: { cr: "", slope: "", par: "" },
}
}
// seed.tees har KUN genders (ikke CR/Slope-tall) -- et forhåndsutfylt
// utslag fra en mal kan derfor bare forhåndsutfylle navn+hvilke(t) kjønn
// som er aktive, ikke selve ratingtallene (de er ikke kjent klient-side).
function seedToDraft(seed?: Course): { holes: DraftHole[]; tees: DraftTee[]; name: string } {
if (!seed) {
return {
name: "",
holes: Array.from({ length: 18 }, () => ({ par: 4, si: 0 })),
tees: [newTee()],
}
}
const holes: DraftHole[] = seed.templateHoles?.length
? seed.templateHoles.map((h) => ({ par: h.par, si: h.stroke_index }))
: Array.from({ length: 18 }, () => ({ par: 4, si: 0 }))
const tees: DraftTee[] = seed.templateTees?.length
? seed.templateTees.map((t) => {
teeCounter += 1
const men = t.ratings.find((r) => r.gender === "m")
const women = t.ratings.find((r) => r.gender === "f")
return {
id: `draft-tee-${teeCounter}`,
name: t.name,
menEnabled: !!men,
womenEnabled: !!women,
men: men ? { cr: String(men.course_rating), slope: String(men.slope_rating), par: String(men.par) } : { cr: "", slope: "", par: "" },
women: women ? { cr: String(women.course_rating), slope: String(women.slope_rating), par: String(women.par) } : { cr: "", slope: "", par: "" },
}
})
: [newTee()]
return { name: `${seed.name} (kopi)`, holes, tees }
}
function ratingComplete(r: DraftRating) {
return r.cr.trim() !== "" && r.slope.trim() !== "" && r.par.trim() !== ""
}
export function CreateCourseForm({
seed,
onCancel,
onCreate,
}: {
seed?: Course
onCancel: () => void
onCreate: (course: Course) => void
}) {
const initial = useMemo(() => seedToDraft(seed), [seed])
const [name, setName] = useState(initial.name)
const [holes, setHoles] = useState<DraftHole[]>(initial.holes)
const [tees, setTees] = useState<DraftTee[]>(initial.tees)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const nameRef = useAutoFocus<HTMLInputElement>(true)
const siValues = holes.map((h) => h.si)
const siError = useMemo(() => {
const filled = siValues.filter((v) => v >= 1 && v <= 18)
const set = new Set(filled)
const complete = set.size === 18 && filled.length === 18
return complete ? null : "Hver stroke-indeks (118) må brukes nøyaktig én gang."
}, [siValues])
const teesValid = tees.every((t) => {
if (!t.name.trim()) return false
if (!t.menEnabled && !t.womenEnabled) return false
if (t.menEnabled && !ratingComplete(t.men)) return false
if (t.womenEnabled && !ratingComplete(t.women)) return false
return true
})
const canSubmit = name.trim() !== "" && !siError && tees.length >= 1 && teesValid && !submitting
function setHole(i: number, patch: Partial<DraftHole>) {
setHoles((prev) => prev.map((h, idx) => (idx === i ? { ...h, ...patch } : h)))
}
function setTee(id: string, patch: Partial<DraftTee>) {
setTees((prev) => prev.map((t) => (t.id === id ? { ...t, ...patch } : t)))
}
function setTeeRating(id: string, gender: "men" | "women", patch: Partial<DraftRating>) {
setTees((prev) => prev.map((t) => (t.id === id ? { ...t, [gender]: { ...t[gender], ...patch } } : t)))
}
async function submit() {
if (!canSubmit) return
setSubmitting(true)
setError(null)
try {
const toRatings = (t: DraftTee) => {
const ratings: { gender: ApiGender; course_rating: number; slope_rating: number; par: number }[] = []
if (t.menEnabled) ratings.push({ gender: "m", course_rating: Number.parseFloat(t.men.cr), slope_rating: Number.parseInt(t.men.slope, 10), par: Number.parseInt(t.men.par, 10) })
if (t.womenEnabled) ratings.push({ gender: "f", course_rating: Number.parseFloat(t.women.cr), slope_rating: Number.parseInt(t.women.slope, 10), par: Number.parseInt(t.women.par, 10) })
return ratings
}
// Kun personlige baner (ikke offisielle teeoff-baner) kan "forkes" --
// seed.id er et syntetisk teeoff-N-id for offisielle baner, en ekte
// UUID for personlige (se courseFromOfficial/fetchOwnCourse i api.ts).
const forkedFromId = seed && !seed.id.startsWith("teeoff-") ? seed.id : null
const course = await createOwnCourse({
name: name.trim(),
holes: holes.map((h, i) => ({ hole_number: i + 1, par: h.par, stroke_index: h.si })),
tees: tees.map((t) => ({ name: t.name.trim(), ratings: toRatings(t) })),
forkedFromId,
})
onCreate(course)
} catch {
setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.")
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-5">
<Field label="Navn på banen" htmlFor="cc-name">
<TextInput
id="cc-name"
ref={nameRef}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="F.eks. Skogsbanen"
maxLength={120}
/>
</Field>
{error ? (
<p role="alert" className="text-xs text-[var(--nr-danger)]">
{error}
</p>
) : null}
{/* Hull */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Hull</h3>
<span className="text-xs text-[var(--nr-muted)]">Par og stroke-indeks for 18 hull</span>
</div>
<div className="overflow-hidden rounded-xl border border-[var(--nr-border)]">
<div className="grid grid-cols-[auto_1fr_1fr] gap-px bg-[var(--nr-border)] text-xs">
<div className="bg-[var(--nr-surface-2)] px-3 py-2 font-medium text-[var(--nr-muted)]">Hull</div>
<div className="bg-[var(--nr-surface-2)] px-3 py-2 font-medium text-[var(--nr-muted)]">Par</div>
<div className="bg-[var(--nr-surface-2)] px-3 py-2 font-medium text-[var(--nr-muted)]">Stroke-indeks</div>
{holes.map((h, i) => (
<HoleRow key={i} index={i} hole={h} onChange={(patch) => setHole(i, patch)} />
))}
</div>
</div>
{siError ? (
<p role="alert" className="text-xs text-[var(--nr-danger)]">
{siError}
</p>
) : (
<p className="text-xs text-[var(--nr-ok)]">Alle stroke-indekser 118 er satt.</p>
)}
</div>
{/* Utslag */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Utslag</h3>
<button
type="button"
onClick={() => setTees((p) => [...p, newTee()])}
className="inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] px-3 text-[13px] font-medium text-[var(--nr-accent)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<Plus className="h-4 w-4" aria-hidden="true" />
Legg til utslag
</button>
</div>
{tees.map((t, i) => (
<div key={t.id} className="rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface-2)] p-3">
<div className="flex items-end gap-2">
<Field label="Navn på utslag" htmlFor={`${t.id}-name`} className="flex-1">
<TextInput
id={`${t.id}-name`}
value={t.name}
onChange={(e) => setTee(t.id, { name: e.target.value })}
placeholder={`Utslag ${i + 1}`}
invalid={t.name.trim() === ""}
className="bg-[var(--nr-surface)]"
/>
</Field>
<button
type="button"
onClick={() => tees.length > 1 && setTees((p) => p.filter((x) => x.id !== t.id))}
disabled={tees.length <= 1}
aria-label={`Fjern ${t.name.trim() || `utslag ${i + 1}`}`}
className="mb-0.5 flex h-11 w-11 items-center justify-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] text-[var(--nr-muted)] transition-colors hover:text-[var(--nr-danger)] disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="mt-3 flex flex-col gap-3">
<RatingBlock
title="Herre-rating"
enabled={t.menEnabled}
onToggle={(v) => setTee(t.id, { menEnabled: v })}
rating={t.men}
onChange={(patch) => setTeeRating(t.id, "men", patch)}
idPrefix={`${t.id}-men`}
/>
<RatingBlock
title="Dame-rating"
enabled={t.womenEnabled}
onToggle={(v) => setTee(t.id, { womenEnabled: v })}
rating={t.women}
onChange={(patch) => setTeeRating(t.id, "women", patch)}
idPrefix={`${t.id}-women`}
/>
{!t.menEnabled && !t.womenEnabled ? (
<p role="alert" className="text-xs text-[var(--nr-danger)]">
Minst én rating (herre eller dame) være aktiv for dette utslaget.
</p>
) : null}
</div>
</div>
))}
</div>
<div className="flex items-center justify-between gap-3 pt-1">
<button
type="button"
onClick={onCancel}
className="inline-flex min-h-11 items-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 text-sm font-medium text-[var(--nr-ink)] transition-colors hover:bg-[var(--nr-surface-2)]"
>
Avbryt
</button>
<button
type="button"
onClick={() => void submit()}
disabled={!canSubmit}
className="inline-flex min-h-11 items-center rounded-lg bg-[var(--nr-accent)] px-5 text-sm font-semibold text-[var(--nr-accent-ink)] transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
>
{submitting ? "Lagrer…" : "Lagre bane"}
</button>
</div>
</div>
)
}
function HoleRow({
index,
hole,
onChange,
}: {
index: number
hole: DraftHole
onChange: (patch: Partial<DraftHole>) => void
}) {
return (
<>
<div className="flex items-center bg-[var(--nr-surface)] px-3 py-1.5 text-sm font-medium tabular-nums text-[var(--nr-muted)]">
{index + 1}
</div>
<div className="bg-[var(--nr-surface)] px-2 py-1.5">
<NativeSelect
aria-label={`Par for hull ${index + 1}`}
value={hole.par}
onChange={(e) => onChange({ par: Number.parseInt(e.target.value, 10) })}
className="h-9 bg-[var(--nr-surface-2)]"
>
{[3, 4, 5, 6].map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</NativeSelect>
</div>
<div className="bg-[var(--nr-surface)] px-2 py-1.5">
<NativeSelect
aria-label={`Stroke-indeks for hull ${index + 1}`}
value={hole.si || ""}
onChange={(e) => onChange({ si: Number.parseInt(e.target.value, 10) })}
className="h-9 bg-[var(--nr-surface-2)]"
>
<option value=""></option>
{Array.from({ length: 18 }, (_, i) => i + 1).map((si) => (
<option key={si} value={si}>
{si}
</option>
))}
</NativeSelect>
</div>
</>
)
}
function RatingBlock({
title,
enabled,
onToggle,
rating,
onChange,
idPrefix,
}: {
title: string
enabled: boolean
onToggle: (v: boolean) => void
rating: DraftRating
onChange: (patch: Partial<DraftRating>) => void
idPrefix: string
}) {
return (
<div className={cn("rounded-lg border p-3", enabled ? "border-[var(--nr-accent-ring)] bg-[var(--nr-surface)]" : "border-[var(--nr-border)] bg-[var(--nr-surface)]")}>
<Toggle checked={enabled} onChange={onToggle} label={title} />
{enabled ? (
<div className="mt-3 grid grid-cols-3 gap-2">
<Field label="Course Rating" htmlFor={`${idPrefix}-cr`}>
<TextInput
id={`${idPrefix}-cr`}
inputMode="decimal"
value={rating.cr}
onChange={(e) => onChange({ cr: e.target.value })}
placeholder="72.1"
invalid={rating.cr.trim() === ""}
className="bg-[var(--nr-surface-2)]"
/>
</Field>
<Field label="Slope" htmlFor={`${idPrefix}-slope`}>
<TextInput
id={`${idPrefix}-slope`}
inputMode="numeric"
value={rating.slope}
onChange={(e) => onChange({ slope: e.target.value.replace(/[^\d]/g, "") })}
placeholder="128"
invalid={rating.slope.trim() === ""}
className="bg-[var(--nr-surface-2)]"
/>
</Field>
<Field label="Par" htmlFor={`${idPrefix}-par`}>
<TextInput
id={`${idPrefix}-par`}
inputMode="numeric"
value={rating.par}
onChange={(e) => onChange({ par: e.target.value.replace(/[^\d]/g, "") })}
placeholder="72"
invalid={rating.par.trim() === ""}
className="bg-[var(--nr-surface-2)]"
/>
</Field>
</div>
) : null}
</div>
)
}