"use client" // Opprett en frittstående runde (ADR-033). Presentasjon fra V0, datalag // skrevet om fra mock til ekte søk/opprettelse/bekreftelse mot API-et. // To bane-kilder: offisiell teeoff-bane (live oppslag, ADR-033 Beslutning C // -- ingen lokal kopi lagres) eller en egendefinert bane (organisasjons- // uavhengig katalog, søkbar på tvers av alle brukere). import type React from "react" import { useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" import Link from "next/link" import { ArrowLeft, Building2, Check, Flag, MapPin, Pencil, Plus, Search, Trophy, } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" import { Wordmark } from "@/components/wordmark" import { cn } from "@/lib/utils" // --- Types ----------------------------------------------------------------- type Rating = { courseRating: string; slope: string; par: string } type Tee = { id: string name: string men: boolean women: boolean menRating?: Rating womenRating?: Rating } type Course = { id: string name: string tees: Tee[] } type OfficialClub = { id: string name: string location: string courses: Course[] } type Step = | "source" | "official-search" | "official-courses" | "own-search" | "own-create" | "confirm" // Hvilken bane-kilde+id som faktisk skal sendes til /rounds -- Course-typen // over (fra V0) bærer kun det UI-et trenger, ikke dette. Holdt separat i // stedet for å presse det inn i Course, som er felles for begge kilder. type CourseMeta = | { source: "teeoff"; facilitySlug: string; teeoffCourseId: number } | { source: "custom"; personalCourseId: string } type Gender = "m" | "f" type StatLevel = "strokes_only" | "strokes_and_putts" | "full" // ADR-038/ADR-039 -- selvdeklarert spilleform. Ingen egen match-/skins-motor // for frittstående runder, så dette taes brukerens ord for. Sider (for de // to-sidede formatene) opprettes IKKE her -- det skjer i selve runden // (POST .../sides), siden det krever at deltakerne allerede finnes. type PlayFormat = "stroke" | "match" | "skins" | "fourball" | "foursome" | "greensome" | "scramble_2" | "scramble_4" type SkinsScoring = "net" | "gross" type SkinsTieHandling = "carry" | "split" const PLAY_FORMAT_OPTIONS: { value: PlayFormat; label: string; desc: string }[] = [ { value: "stroke", label: "Slagspill", desc: "Vanlig individuell runde." }, { value: "match", label: "Match", desc: "To sider, hull mot hull." }, { value: "skins", label: "Skins", desc: "Hvert hull er en egen premie -- minst 3 spillere." }, { value: "fourball", label: "Fourball", desc: "To mot to, beste ball per side." }, { value: "foursome", label: "Foursome", desc: "To mot to, én delt ball, annenhver slår." }, { value: "greensome", label: "Greensome", desc: "To mot to, begge slår ut, velger så én ball." }, { value: "scramble_2", label: "Scramble (2)", desc: "To mot to, velger beste slag hver gang." }, { value: "scramble_4", label: "Scramble (4)", desc: "Fire mot fire, velger beste slag hver gang." }, ] // --- API-typer --------------------------------------------------------------- type ApiTeeOption = { name: string; genders: Gender[] } type ApiFacility = { slug: string; name: string; city: string | null; county: string | null } type ApiOfficialCourseOption = { teeoff_course_id: number name: string is_main_course: boolean tees: ApiTeeOption[] } type ApiPersonalCourse = { id: string; name: string } type ApiPersonalCourseDetail = { id: string; name: string; tees: ApiTeeOption[] } function apiTeesToTees(apiTees: ApiTeeOption[]): Tee[] { return apiTees.map((t, i) => ({ id: `${t.name}-${i}`, name: t.name, men: t.genders.includes("m"), women: t.genders.includes("f"), })) } // --- Root component -------------------------------------------------------- export function NewRound() { const router = useRouter() const [step, setStep] = useState("source") const [ownGender, setOwnGender] = useState(null) const [loadingMe, setLoadingMe] = useState(true) // Official flow const [selectedClub, setSelectedClub] = useState(null) const [resolvingClub, setResolvingClub] = useState(false) // Chosen course carried into the confirm step (from any source), plus // which source/id it actually came from -- needed to build the /rounds // payload correctly. const [course, setCourse] = useState(null) const [courseMeta, setCourseMeta] = useState(null) useEffect(() => { let cancelled = false async function loadMe() { try { const res = await fetch("/auth/me", { credentials: "include" }) if (res.status === 401) { router.replace("/") return } const data: { gender: Gender | null } = await res.json() if (!cancelled) setOwnGender(data.gender) } finally { if (!cancelled) setLoadingMe(false) } } void loadMe() return () => { cancelled = true } }, [router]) function chooseCourse(next: Course, meta: CourseMeta) { setCourse(next) setCourseMeta(meta) setStep("confirm") } async function selectClub(club: OfficialClub) { setSelectedClub(club) setResolvingClub(true) try { const res = await fetch(`/rounds/official-search/${club.id}`, { credentials: "include" }) if (!res.ok) throw new Error(`facility detail: ${res.status}`) const detail: { courses: ApiOfficialCourseOption[] } = await res.json() const withCourses: OfficialClub = { ...club, courses: detail.courses.map((c) => ({ id: String(c.teeoff_course_id), name: c.name, tees: apiTeesToTees(c.tees), })), } setSelectedClub(withCourses) setStep("official-courses") } catch { setSelectedClub(null) } finally { setResolvingClub(false) } } function chooseOfficialCourse(officialCourse: Course) { if (!selectedClub) return chooseCourse(officialCourse, { source: "teeoff", facilitySlug: selectedClub.id, teeoffCourseId: Number(officialCourse.id), }) } async function selectOwnCourse(picked: ApiPersonalCourse) { const res = await fetch(`/personal-courses/${picked.id}`, { credentials: "include" }) if (!res.ok) return const detail: ApiPersonalCourseDetail = await res.json() chooseCourse( { id: detail.id, name: detail.name, tees: apiTeesToTees(detail.tees) }, { source: "custom", personalCourseId: detail.id }, ) } function ownCourseCreated(created: { id: string; name: string; tees: Tee[] }) { chooseCourse( { id: created.id, name: created.name, tees: created.tees }, { source: "custom", personalCourseId: created.id }, ) } async function submitRound(payload: { name: string teeName: string date: string startedAt: string | null startHole: number holes: 9 | 18 statLevel: StatLevel playFormat: PlayFormat excludeFromHandicap: boolean skinsScoring: SkinsScoring | null skinsTieHandling: SkinsTieHandling | null }): Promise<{ id: string }> { if (!courseMeta) throw new Error("Mangler valgt bane") const shared = { tee_name: payload.teeName, name: payload.name || null, played_at: payload.date, started_at: payload.startedAt, start_hole: payload.startHole, holes_planned: payload.holes, stat_level: payload.statLevel, play_format: payload.playFormat, exclude_owner_from_handicap: payload.excludeFromHandicap, skins_scoring: payload.skinsScoring, skins_tie_handling: payload.skinsTieHandling, } const body = courseMeta.source === "teeoff" ? { course_source: "teeoff", teeoff_facility_slug: courseMeta.facilitySlug, teeoff_course_id: courseMeta.teeoffCourseId, ...shared, } : { course_source: "custom", personal_course_id: courseMeta.personalCourseId, ...shared, } const res = await fetch("/rounds", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(body), }) if (!res.ok) throw new Error(`create round: ${res.status}`) return res.json() } return (
router.push("/my-rounds")} />
{loadingMe ? (

Laster …

) : !ownGender ? (
Profilen din mangler registrert kjønn, som trengs for å beregne banehandicap riktig.{" "} Gå til kontoinnstillinger .
) : ( <> {step === "source" && ( setStep("official-search")} onOwn={() => setStep("own-search")} /> )} {step === "official-search" && ( )} {step === "official-courses" && selectedClub && ( )} {step === "own-search" && ( setStep("own-create")} /> )} {step === "own-create" && ( setStep("own-search")} /> )} {step === "confirm" && course && ( router.replace(`/my-rounds/${id}`)} /> )} )}
) } // --- Back link ------------------------------------------------------------- function BackLink({ step, setStep, onExit, }: { step: Step setStep: (step: Step) => void onExit: () => void }) { const config: Record void }> = { source: { label: "Til egne runder", onClick: onExit }, "official-search": { label: "Tilbake", onClick: () => setStep("source") }, "official-courses": { label: "Tilbake til søk", onClick: () => setStep("official-search") }, "own-search": { label: "Tilbake", onClick: () => setStep("source") }, "own-create": { label: "Tilbake til søk", onClick: () => setStep("own-search") }, confirm: { label: "Tilbake", onClick: () => setStep("source") }, } const { label, onClick } = config[step] return ( ) } // --- Step header ----------------------------------------------------------- function StepHeader({ title, description }: { title: string; description: string }) { return (

{title}

{description}

) } // --- Step 1: source -------------------------------------------------------- function SourceStep({ onOfficial, onOwn }: { onOfficial: () => void; onOwn: () => void }) { return (
) } function SourceCard({ icon, title, description, onClick, }: { icon: React.ReactNode title: string description: string onClick: () => void }) { return ( ) } // --- Step 2a: official search ---------------------------------------------- type ApiNearbyFacility = ApiFacility & { distance_km: number } function NearbyClubs({ onSelectClub, resolving, }: { onSelectClub: (club: OfficialClub) => void resolving: boolean }) { const [nearby, setNearby] = useState(null) useEffect(() => { if (!("geolocation" in navigator)) return let cancelled = false navigator.geolocation.getCurrentPosition( (position) => { if (cancelled) return const { latitude, longitude } = position.coords fetch(`/rounds/official-search/nearby?lat=${latitude}&lng=${longitude}&limit=5`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : null)) .then((data: ApiNearbyFacility[] | null) => { if (!cancelled && data) setNearby(data) }) .catch(() => {}) }, () => { // Avvist eller utilgjengelig -- stille no-op, dette er kun en // hjelp, ikke en forutsetning for å kunne søke. }, { timeout: 8000 }, ) return () => { cancelled = true } }, []) if (!nearby || nearby.length === 0) return null return (
Nærmest deg
    {nearby.map((facility, i) => (
  • ))}
) } function OfficialSearchStep({ onSelectClub, resolving, }: { onSelectClub: (club: OfficialClub) => void resolving: boolean }) { const [query, setQuery] = useState("") const [results, setResults] = useState(null) const [searching, setSearching] = useState(false) const [error, setError] = useState(null) async function handleSearch(e: React.FormEvent) { e.preventDefault() setSearching(true) setError(null) try { const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query.trim())}`, { credentials: "include", }) if (!res.ok) throw new Error(`search: ${res.status}`) setResults(await res.json()) } catch { setError("Klarte ikke å søke i baneregisteret akkurat nå.") setResults([]) } finally { setSearching(false) } } return (
{error &&

{error}

} {results !== null && (
{results.length === 0 ? (

Ingen klubber matcher «{query}». Prøv et annet søk.

) : (
    {results.map((facility) => (
  • ))}
)} {resolving &&

Henter baner …

}
)}
) } // --- Step 2a (cont): courses at club --------------------------------------- function OfficialCoursesStep({ club, onSelectCourse, }: { club: OfficialClub onSelectCourse: (course: Course) => void }) { return (
    {club.courses.map((course) => (
  • ))} {club.courses.length === 0 && (
  • Ingen 18-hulls baner registrert hos dette anlegget ennå.
  • )}
) } // --- Step 2b: own course search -------------------------------------------- function OwnSearchStep({ onSelectCourse, onCreate, }: { onSelectCourse: (course: ApiPersonalCourse) => void onCreate: () => void }) { const [query, setQuery] = useState("") const [results, setResults] = useState([]) const [error, setError] = useState(null) useEffect(() => { let cancelled = false const timer = setTimeout(async () => { try { const res = await fetch(`/personal-courses?q=${encodeURIComponent(query.trim())}`, { credentials: "include", }) if (!res.ok) throw new Error(`search: ${res.status}`) const data: ApiPersonalCourse[] = await res.json() if (!cancelled) setResults(data) } catch { if (!cancelled) setError("Klarte ikke å søke i egendefinerte baner akkurat nå.") } }, 250) return () => { cancelled = true clearTimeout(timer) } }, [query]) return (
{error &&

{error}

}
{results.length > 0 && (
    {results.map((course) => (
  • ))}
)} {results.length === 0 && (

{query.trim() ? `Ingen egne baner matcher «${query}».` : "Skriv for å søke, eller opprett en ny bane under."}

)}
) } // --- Step 2b (cont): create course ----------------------------------------- function emptyHoles() { return Array.from({ length: 18 }, (_, i) => ({ hole: i + 1, par: "4", strokeIndex: String(i + 1), })) } function parseDecimal(value: string): number { return Number(value.replace(",", ".")) } function OwnCreateStep({ onCreated, onCancel, }: { onCreated: (course: { id: string; name: string; tees: Tee[] }) => void onCancel: () => void }) { const [name, setName] = useState("") const [holes, setHoles] = useState(emptyHoles) const [tees, setTees] = useState([ { id: `nt-${Date.now()}`, name: "", men: true, women: false }, ]) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) function updateHole(index: number, field: "par" | "strokeIndex", value: string) { setHoles((prev) => prev.map((h, i) => (i === index ? { ...h, [field]: value } : h))) } function updateTee(id: string, patch: Partial) { setTees((prev) => prev.map((t) => (t.id === id ? { ...t, ...patch } : t))) } function addTee() { setTees((prev) => [...prev, { id: `nt-${Date.now()}`, name: "", men: false, women: false }]) } function removeTee(id: string) { setTees((prev) => (prev.length > 1 ? prev.filter((t) => t.id !== id) : prev)) } const indexesValid = useMemo( () => new Set(holes.map((h) => h.strokeIndex)).size === 18, [holes], ) const teesValid = tees.every( (t) => t.name.trim() !== "" && (t.men || t.women) && (!t.men || (t.menRating && t.menRating.courseRating.trim() && t.menRating.slope.trim() && t.menRating.par.trim())) && (!t.women || (t.womenRating && t.womenRating.courseRating.trim() && t.womenRating.slope.trim() && t.womenRating.par.trim())), ) const canSubmit = name.trim() !== "" && indexesValid && teesValid && !submitting async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!canSubmit) return setSubmitting(true) setError(null) try { const body = { name: name.trim(), holes: holes.map((h) => ({ hole_number: h.hole, par: Number(h.par), stroke_index: Number(h.strokeIndex), })), tees: tees.map((t) => ({ name: t.name.trim(), ratings: [ ...(t.men && t.menRating ? [{ gender: "m", course_rating: parseDecimal(t.menRating.courseRating), slope_rating: Number(t.menRating.slope), par: Number(t.menRating.par) }] : []), ...(t.women && t.womenRating ? [{ gender: "f", course_rating: parseDecimal(t.womenRating.courseRating), slope_rating: Number(t.womenRating.slope), par: Number(t.womenRating.par) }] : []), ], })), } const res = await fetch("/personal-courses", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(body), }) if (!res.ok) throw new Error(`create: ${res.status}`) const created: { id: string; name: string } = await res.json() onCreated({ id: created.id, name: created.name, tees: tees.map((t) => ({ ...t, name: t.name.trim() })) }) } catch { setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.") } finally { setSubmitting(false) } } return (
{error &&

{error}

} {/* Name */}
setName(e.target.value)} placeholder="F.eks. Hyttebanen" className="h-14 rounded-2xl text-lg" />
{/* Holes */}

Hull

Sett par og stroke-indeks for hvert hull.

{!indexesValid && (

Hver stroke-indeks (1–18) må brukes nøyaktig én gang.

)}
Hull Par Stroke-indeks
    {holes.map((h, index) => (
  • Hull {h.hole}
  • ))}
{/* Tees */}

Utslag

Legg til ett eller flere utslag med rating.

{tees.map((t, index) => ( 1} onChange={(patch) => updateTee(t.id, patch)} onRemove={() => removeTee(t.id)} /> ))}
) } function TeeEditor({ tee, index, canRemove, onChange, onRemove, }: { tee: Tee index: number canRemove: boolean onChange: (patch: Partial) => void onRemove: () => void }) { return (
onChange({ name: e.target.value })} placeholder="F.eks. Gul" className="h-12 rounded-xl text-base" />
{canRemove && ( )}
onChange({ men, menRating: men ? tee.menRating ?? { courseRating: "", slope: "", par: "" } : tee.menRating }) } onRatingChange={(menRating) => onChange({ menRating })} idPrefix={`men-${tee.id}`} /> onChange({ women, womenRating: women ? tee.womenRating ?? { courseRating: "", slope: "", par: "" } : tee.womenRating }) } onRatingChange={(womenRating) => onChange({ womenRating })} idPrefix={`women-${tee.id}`} />
) } function RatingToggle({ label, enabled, rating, onToggle, onRatingChange, idPrefix, }: { label: string enabled: boolean rating?: Rating onToggle: (enabled: boolean) => void onRatingChange: (rating: Rating) => void idPrefix: string }) { const current = rating ?? { courseRating: "", slope: "", par: "" } function update(field: keyof Rating, value: string) { onRatingChange({ ...current, [field]: value }) } return (
{enabled && (
update("courseRating", e.target.value)} placeholder="71,2" className="h-12 rounded-xl text-base" />
update("slope", e.target.value)} placeholder="132" className="h-12 rounded-xl text-base" />
update("par", e.target.value)} placeholder="72" className="h-12 rounded-xl text-base" />
)}
) } // --- Step 3: confirm ------------------------------------------------------- function todayIso() { const now = new Date() const offset = now.getTimezoneOffset() const local = new Date(now.getTime() - offset * 60 * 1000) return local.toISOString().slice(0, 10) } function ConfirmStep({ course, ownGender, onSubmit, onCreated, }: { course: Course ownGender: Gender onSubmit: (payload: { name: string teeName: string date: string startedAt: string | null startHole: number holes: 9 | 18 statLevel: StatLevel playFormat: PlayFormat excludeFromHandicap: boolean skinsScoring: SkinsScoring | null skinsTieHandling: SkinsTieHandling | null }) => Promise<{ id: string }> onCreated: (roundId: string) => void }) { const compatibleTees = course.tees.filter((t) => (ownGender === "m" ? t.men : t.women)) const [teeId, setTeeId] = useState(compatibleTees[0]?.id ?? "") const [name, setName] = useState("") const [date, setDate] = useState(todayIso) const [teeTime, setTeeTime] = useState("") const [startHole, setStartHole] = useState("1") const [holes, setHoles] = useState<9 | 18>(18) const [statLevel, setStatLevel] = useState("strokes_only") const [playFormat, setPlayFormat] = useState("stroke") const [excludeFromHandicap, setExcludeFromHandicap] = useState(false) // ADR-039 Beslutning D -- kun meningsfullt (og påkrevd av backend) for // play_format="skins". Fornuftige forhåndsvalg, begge overstyrbare. const [skinsScoring, setSkinsScoring] = useState("net") const [skinsTieHandling, setSkinsTieHandling] = useState("carry") const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) function choosePlayFormat(next: PlayFormat) { setPlayFormat(next) // ADR-038 Beslutning D -- foreslå (ikke tving) eksklusjon når match // velges. De andre formatene har egne HCP-regler (ADR-039 Beslutning E) // som ikke bruker dette feltet -- nullstill det da for å unngå et // misvisende forhåndsvalg som likevel ikke sendes videre meningsfullt. setExcludeFromHandicap(next === "match") } const selectedTee = compatibleTees.find((t) => t.id === teeId) // ADR-039 Beslutning A -- match/fourball/foursome/greensome/scramble // krever sider, opprettet ETTERPÅ inne i selve runden (deltakerne må // finnes først). Vises som en forklarende note her, ikke et skjema. const needsSidesSetup = playFormat !== "stroke" && playFormat !== "skins" async function handleStart() { if (!selectedTee) return setSubmitting(true) setError(null) try { // Utslagstid er valgfritt -- kun dato er påkrevd. Kombinerer dato+ // klokkeslett til et ekte tidspunkt (nettleserens lokale tidssone, // konvertert til UTC av toISOString()) kun når klokkeslett er satt. const startedAt = teeTime ? new Date(`${date}T${teeTime}`).toISOString() : null const created = await onSubmit({ name: name.trim(), teeName: selectedTee.name, date, startedAt, startHole: Number(startHole), holes, statLevel, playFormat, excludeFromHandicap, skinsScoring: playFormat === "skins" ? skinsScoring : null, skinsTieHandling: playFormat === "skins" ? skinsTieHandling : null, }) onCreated(created.id) } catch { setError("Klarte ikke å opprette runden. Prøv igjen.") setSubmitting(false) } } return (
{/* Course */}
Valgt bane {course.name}
{error &&

{error}

} {/* Round name */}
setName(e.target.value)} placeholder={course.name} maxLength={200} className="h-14 rounded-2xl text-lg" />
{/* Tee */} {compatibleTees.length === 0 ? (

Denne banen har ingen registrert rating for ditt kjønn på noe utslag -- HCP-sporing er ikke mulig for denne runden. Velg en annen bane, eller fullfør profilen din på nytt.

) : (
Utslag
{compatibleTees.map((t) => ( ))}
)} {/* Date + tee time + start hole */}
setDate(e.target.value)} className="h-14 rounded-2xl text-lg" />
setTeeTime(e.target.value)} className="h-14 rounded-2xl text-lg" />
{/* Statistikknivå -- kun slag er strengt tatt nødvendig for resultat/HCP */}
Statistikk for deg selv
{( [ { value: "strokes_only", label: "Kun slag", desc: "Raskest -- bare det som trengs for resultat og HCP." }, { value: "strokes_and_putts", label: "Slag og putter", desc: "Legger til putt-telling per hull." }, { value: "full", label: "All statistikk", desc: "Kølle, retning, chip, bunker, straffeslag med mer." }, ] as const ).map((opt) => ( ))}
{/* Spilleform (ADR-038/ADR-039) -- selvdeklarert, ingen egen match-/ skins-motor. match/fourball/foursome/greensome/scramble krever sider -- opprettes i selve runden etter at deltakerne er lagt til. */}
Spilleform
{PLAY_FORMAT_OPTIONS.map((opt) => ( ))}

{PLAY_FORMAT_OPTIONS.find((o) => o.value === playFormat)?.desc}

{playFormat === "match" && ( )} {playFormat === "skins" && (
Poengberegning
{( [ { value: "net" as const, label: "Netto (m/HCP)" }, { value: "gross" as const, label: "Brutto" }, ] ).map((opt) => ( ))}
Ved uavgjort hull
{( [ { value: "carry" as const, label: "Rullerer over" }, { value: "split" as const, label: "Deles likt" }, ] ).map((opt) => ( ))}

Skins krever minst 3 spillere i runden -- legg til medspillere etter at runden er opprettet.

)} {needsSidesSetup && (

Du setter opp sidene (og fordeler spillerne) inne i runden, etter at medspillerne er lagt til.

)}
{/* Hole count */}
Antall hull
{([9, 18] as const).map((count) => ( ))}
) }