"use client" // Opprett en frittstående runde (ADR-033), som ÉN sammenhengende 5-stegs // veiviser ("det store grepet", ADR-040 Beslutning A) -- erstatter det // tidligere "ett langt skjema, så en helt annen skjerm etterpå"-mønsteret. // Presentasjon fra V0 (zip 25), datalag skrevet om fra mock til ekte // søk/opprettelse/innsending mot API-et. // // Rekkefølge: Bane & tid (inkl. bane-kilde-underskjermer) → Spilleform // (inkl. HCP-innstillinger, ADR-040 Beslutning B) → Spillere → Lag/ // rekkefølge (kun to-sidede formater/Money Ball) → Deling. Selve runden // (+ deltakere + sider/lineup) opprettes FØRST når hele veiviseren er // fullført -- se submitWizard() nederst. import type React from "react" import { useEffect, useMemo, useState } from "react" import { useRouter, useSearchParams } from "next/navigation" import Link from "next/link" import { ArrowLeft, Building2, Check, ChevronDown, Flag, Globe, Lock, MapPin, Pencil, Plus, Search, Sparkles, Trophy, Users, X, } 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" import type { ApiCourseHole, ApiCourseTee, CourseTemplateValues } from "@/components/course-template-editor" // --- Types ------------------------------------------------------------------- type ApiGender = "m" | "f" // Tee bærer nå hvilket(e) kjønn den har rating for (ApiTeeOption sitt // genders-felt) -- V0s egen Tee-type droppet dette, men uten det kan // ikke utslagslisten filtreres riktig per spillers kjønn (samme sjekk // backend selv håndhever ved innsending). type Tee = { id: string; name: string; genders: ApiGender[] } type Course = { id: string name: string tees: Tee[] // Fullt hull-/utslagoppsett for "bruk som mal"-broen (2026-08-04) -- // fraværende/tom når kilden (teeoff) ikke har komplette nok data ennå. templateHoles?: ApiCourseHole[] templateTees?: ApiCourseTee[] } type OfficialClub = { id: string; name: string; location: string; courses: Course[] } // Hvilken bane-kilde+id som faktisk skal sendes til /rounds -- Course over // bærer kun det UI-et trenger, ikke dette. type CourseMeta = | { source: "teeoff"; facilitySlug: string; teeoffCourseId: number } | { source: "custom"; personalCourseId: string } type StatLevel = "kun-slag" | "slag-putter" | "all" type Gender = "mann" | "kvinne" | "annet" type Visibility = "privat" | "venner" | "offentlig" type Player = { id: string name: string isOwner?: boolean isGuest?: boolean // Satt når spilleren ble funnet via /people/search (ekte konto) -- avgjør // om vi sender user_id eller guest_name+gender ved innsending, og om // navn/kjønn/e-post kan redigeres i veiviseren (kun for gjester). userId?: string gender: Gender email?: string // "" = ikke valgt ennå -- faller tilbake til rundens eget utslag ved // innsending (samme mønster som ParticipantCreate.tee_name i appen for // øvrig). teeId: string hcp: string statLevel: StatLevel sideId?: string moneyBallSlot?: number } type Side = { id: string; label: string } type S1Sub = "source" | "official-search" | "official-courses" | "own-search" | "own-create" | "fields" // --- Formater ------------------------------------------------------------------ type FormatId = | "slagspill" | "stableford" | "match" | "skins" | "fourball" | "foursome" | "greensome" | "scramble2" | "scramble4" | "chapman" | "copenhagen" | "bbb" | "flag" | "shamble" | "moneyball" | "highlowhigh" const FORMATS: { id: FormatId; label: string; desc: string }[] = [ { id: "slagspill", label: "Slagspill", desc: "Vanlig individuell runde." }, { id: "stableford", label: "Stableford", desc: "Poeng per hull i stedet for slag -- kan plukke opp ved 0 poeng." }, { id: "match", label: "Match", desc: "To sider, hull mot hull." }, { id: "skins", label: "Skins", desc: "Hvert hull er en egen premie -- minst 3 spillere." }, { id: "fourball", label: "Fourball", desc: "To mot to, beste ball per side." }, { id: "foursome", label: "Foursome", desc: "To mot to, én delt ball, annenhver slår." }, { id: "greensome", label: "Greensome", desc: "To mot to, begge slår ut, velger så én ball." }, { id: "scramble2", label: "Scramble (2)", desc: "To mot to, velger beste slag hver gang." }, { id: "scramble4", label: "Scramble (4)", desc: "Fire mot fire, velger beste slag hver gang." }, { id: "chapman", label: "Chapman", desc: "To mot to, bytter utslag, velger så én ball -- som foursome fra andre slag." }, { id: "copenhagen", label: "Københavner", desc: "Nøyaktig 3 spillere -- 6 poeng deles per hull etter rangering." }, { id: "bbb", label: "Bingo Bango Bongo", desc: "Poeng for først på green, nærmest hull, først i hull." }, { id: "flag", label: "Flaggturnering", desc: "Fast slagbudsjett (par + HCP) -- flagget plantes der slagene tar slutt." }, { id: "shamble", label: "Shamble", desc: "2-4 spillere, alle slår ut, velger beste -- spiller så egen ball inn." }, { id: "moneyball", label: "Money Ball", desc: 'Fire spillere, én roterer som "money ball" per hull.' }, { id: "highlowhigh", label: "High-low-high", desc: "To lag à to -- høy mot høy, lav mot lav, per hull." }, ] const TWO_SIDED = new Set([ "match", "fourball", "foursome", "greensome", "scramble2", "scramble4", "chapman", "highlowhigh", ]) // Samme sett som app/handicap.py sin SIDE_IS_UNIT (+ chapman-aliaset) -- // avgjør om HCP-prosenten bygges som "combined" (delt enhet) eller // "per_player" i allowance_override.strategy. const SIDE_IS_UNIT_FORMAT = new Set(["foursome", "greensome", "scramble2", "scramble4", "chapman"]) // "Bruk course handicap-justering" er uten reell nytteverdi for de to // enkleste, rene individuelle formatene (etterspurt av bruker 2026-08-02) // -- skjules for disse, men holdes funksjonelt PÅ (aldri slettet/fjernet // fra selve innsendingen, kun ikke eksponert som et valg i UI-et). const ORDINARY_FORMATS = new Set(["slagspill", "stableford"]) function usesStep4(f: FormatId) { return TWO_SIDED.has(f) || f === "moneyball" } // Wizardens FormatId -> ekte round.play_format-verdi (RoundCreate). const FORMAT_TO_API: Record = { slagspill: "stroke", stableford: "stableford", match: "match", skins: "skins", fourball: "fourball", foursome: "foursome", greensome: "greensome", scramble2: "scramble_2", scramble4: "scramble_4", chapman: "chapman", copenhagen: "copenhagen", bbb: "bbb", flag: "flag", shamble: "shamble", moneyball: "money_ball", highlowhigh: "high_low_high", } // Motsatt vei, brukt til å forhåndsutfylle format ved "legg til en flight // til" (playFormat kommer fra URL som en ekte API-verdi). const API_TO_FORMAT: Record = Object.fromEntries( Object.entries(FORMAT_TO_API).map(([k, v]) => [v, k as FormatId]), ) as Record function apiStatLevel(s: StatLevel): "strokes_only" | "strokes_and_putts" | "full" { return s === "kun-slag" ? "strokes_only" : s === "slag-putter" ? "strokes_and_putts" : "full" } function apiGenderOf(g: Gender): "m" | "f" | "x" { return g === "mann" ? "m" : g === "kvinne" ? "f" : "x" } function uiGenderOf(g: ApiGender): Gender { return g === "m" ? "mann" : "kvinne" } // Samme "første ord = fornavn, resten = etternavn"-heuristikk som // round-detail.tsx sin splitName() -- brukt til å forhåndsutfylle // gjeste-navnefeltene fra søket når "Legg til uten konto (gjest)" trykkes. function splitName(full: string): { first: string; last: string } { const trimmed = full.trim() const spaceIdx = trimmed.indexOf(" ") if (spaceIdx === -1) return { first: trimmed, last: "" } return { first: trimmed.slice(0, spaceIdx), last: trimmed.slice(spaceIdx + 1).trim() } } type KnownGuest = { guest_first_name: string guest_last_name: string | null gender: ApiGender | null handicap_index: number | null } const STAT_LEVELS: { id: StatLevel; label: string; desc: string }[] = [ { id: "kun-slag", label: "Kun slag", desc: "Raskest — bare det som trengs for resultat og HCP" }, { id: "slag-putter", label: "Slag og putter", desc: "Legger til putt-telling per hull" }, { id: "all", label: "All statistikk", desc: "Kølle, retning, chip, bunker, straffeslag med mer" }, ] // Samme faste sett/rekkefølge som friends.py sin Category og // friends.tsx/new-round.tsx (den forrige, ikke-veiviser-baserte // implementasjonen) sin CATEGORY_OPTIONS -- egen, lokal kopi. const CATEGORY_OPTIONS = [ { code: "spouse", label: "Make" }, { code: "close_family", label: "Nær familie" }, { code: "extended_family", label: "Storfamilie" }, { code: "close_friends", label: "Nære venner" }, { code: "golf_friends", label: "Golfvenner" }, { code: "colleagues", label: "Kollegaer" }, { code: "business", label: "Forretningsforbindelser" }, { code: "classmates", label: "Studiekamerater" }, { code: "acquaintances", label: "Perifere bekjente" }, { code: "other", label: "Ymse" }, ] as const // --- API-typer ----------------------------------------------------------------- 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[] // Fullt hull-/utslagoppsett -- tom liste når teeoff ikke har komplette nok // data til å brukes som mal (2026-08-04, "bruk denne banen som mal"-bro). 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 } type PersonMatch = { id: string first_name: string last_name: string avatar_url: string | null home_club: string | null handicap_index: number | null } function apiTeesToTees(apiTees: ApiTeeOption[]): Tee[] { return apiTees.map((t, i) => ({ id: `${t.name}-${i}`, name: t.name, genders: t.genders })) } // --- Helpers --------------------------------------------------------------- let idSeq = 0 function newId(prefix: string) { idSeq += 1 return `${prefix}-${idSeq}` } function initials(name: string) { const parts = name.trim().split(/\s+/) if (parts.length === 0 || parts[0] === "") return "?" if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase() return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() } function teeName(course: Course, teeId: string) { return course.tees.find((t) => t.id === teeId)?.name ?? "—" } function statLabel(level: StatLevel) { return STAT_LEVELS.find((s) => s.id === level)?.label ?? "" } function parseHcp(value: string): number | null { if (value.trim() === "") return null const n = Number(value.replace(",", ".")) return Number.isNaN(n) ? null : n } // Dagens dato/klokkeslett som default ved oppsett av en ny runde (i lokal // tidssone, ikke UTC -- en enkel toISOString().slice(0,10)/toTimeString() // ville gitt feil dato/klokkeslett for brukere øst/vest for UTC nær // midnatt). function todayIso(): string { const now = new Date() const offset = now.getTimezoneOffset() const local = new Date(now.getTime() - offset * 60 * 1000) return local.toISOString().slice(0, 10) } function nowTimeString(): string { const now = new Date() return `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}` } // --- Root -------------------------------------------------------------------- type Me = { gender: ApiGender | "x" | null; handicap_index: number | null; first_name: string | null; last_name: string | null; display_name: string } export function NewRound() { const router = useRouter() const searchParams = useSearchParams() // Flere flighter i én "vanlig" runde (migrasjon 035) -- kommer fra // round-detail.tsx sin "Legg til en flight til"-handling. const flightGroupId = searchParams.get("flightGroupId") const prefillPlayedAt = searchParams.get("playedAt") ?? undefined const prefillStartHole = searchParams.get("startHole") const prefillHoles = searchParams.get("holes") const prefillPlayFormatApi = searchParams.get("playFormat") const isAdditionalFlight = Boolean(flightGroupId) const [loadingMe, setLoadingMe] = useState(true) const [ownGender, setOwnGender] = useState(null) // Top-level step (1..5). Step 4 er hoppet over for enkelte formater. const [stepN, setStepN] = useState(1) // Step 1 const [s1Sub, setS1Sub] = useState("source") const [courseSource, setCourseSource] = useState<"official" | "own">("official") const [selectedClub, setSelectedClub] = useState(null) const [course, setCourse] = useState(null) const [courseMeta, setCourseMeta] = useState(null) const [roundName, setRoundName] = useState("") const [ownerTeeId, setOwnerTeeId] = useState("") const [date, setDate] = useState(() => prefillPlayedAt ?? todayIso()) const [time, setTime] = useState(nowTimeString) const [startHole, setStartHole] = useState(prefillStartHole ?? "1") // Step 2 const [statLevel, setStatLevel] = useState("kun-slag") const [format, setFormat] = useState( (prefillPlayFormatApi && API_TO_FORMAT[prefillPlayFormatApi]) || "slagspill", ) const [holes, setHoles] = useState<9 | 18>(prefillHoles === "9" ? 9 : 18) const [matchExcludeHcp, setMatchExcludeHcp] = useState(true) const [skinsScoring, setSkinsScoring] = useState<"netto" | "brutto">("netto") const [skinsTie, setSkinsTie] = useState<"roll" | "split">("roll") const [bbbBonus, setBbbBonus] = useState(false) const [shambleBest, setShambleBest] = useState(2) const [advOpen, setAdvOpen] = useState(false) const [useHcp, setUseHcp] = useState(true) const [useCourseHcp, setUseCourseHcp] = useState(true) const [useMatchplayHcp, setUseMatchplayHcp] = useState(true) const [hcpPercent, setHcpPercent] = useState("") // "Bruk course handicap-justering" er skjult for de ordinære formatene // (ORDINARY_FORMATS) -- siden brukeren da aldri ser bryteren, må den // funksjonelt holdes PÅ uansett hva den måtte ha stått i tidligere (f.eks. // slått av på et annet format før man byttet tilbake til Slagspill). useEffect(() => { if (ORDINARY_FORMATS.has(format)) setUseCourseHcp(true) }, [format]) // Step 3 const [players, setPlayers] = useState([]) // Step 4 const [sides, setSides] = useState([]) // Step 5 const [visibility, setVisibility] = useState("privat") const [categories, setCategories] = useState>(new Set(CATEGORY_OPTIONS.map((c) => c.code))) const [submitting, setSubmitting] = useState(false) const [submitError, setSubmitError] = 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: Me = await res.json() if (cancelled) return if (data.gender === "m" || data.gender === "f") setOwnGender(data.gender) const ownerName = data.first_name ? `${data.first_name} ${data.last_name ?? ""}`.trim() : data.display_name setPlayers([ { id: "owner", name: ownerName, isOwner: true, gender: data.gender === "f" ? "kvinne" : "mann", teeId: "", hcp: data.handicap_index !== null ? String(data.handicap_index).replace(".", ",") : "", statLevel: "kun-slag", }, ]) } finally { if (!cancelled) setLoadingMe(false) } } void loadMe() return () => { cancelled = true } }, [router]) const showStep4 = usesStep4(format) const stepList = useMemo(() => { const base = [ { n: 1, title: "Bane & tid" }, { n: 2, title: "Spilleform" }, { n: 3, title: "Spillere" }, ] if (showStep4) base.push({ n: 4, title: "Lag og rekkefølge" }) base.push({ n: 5, title: "Deling" }) return base }, [showStep4]) const currentIndex = Math.max(0, stepList.findIndex((s) => s.n === stepN)) function chooseCourse(c: Course, meta: CourseMeta, source: "official" | "own") { setCourse(c) setCourseMeta(meta) setCourseSource(source) const compatible = ownGender ? c.tees.filter((t) => t.genders.includes(ownGender)) : c.tees const firstTee = compatible[0]?.id ?? "" setOwnerTeeId(firstTee) setPlayers((prev) => prev.map((p) => (p.isOwner ? { ...p, teeId: firstTee } : p))) setS1Sub("fields") } const step1Valid = Boolean(course && ownerTeeId && date) const canNext = stepN === 1 ? step1Valid && s1Sub === "fields" : true const isFinalStep = stepList[stepList.length - 1].n === stepN const showNextButton = stepN !== 1 || s1Sub === "fields" async function handleNext() { if (isFinalStep) { await submitWizard() return } const next = stepList[currentIndex + 1] if (next) setStepN(next.n) } function handleBack() { if (stepN === 1) { if (s1Sub === "source") { router.push("/my-rounds") } else if (s1Sub === "official-search") { setS1Sub("source") } else if (s1Sub === "official-courses") { setS1Sub("official-search") } else if (s1Sub === "own-search") { setS1Sub("source") } else if (s1Sub === "own-create") { setS1Sub("own-search") } else if (s1Sub === "fields") { setS1Sub(courseSource === "official" ? "official-courses" : "own-search") } return } const prev = stepList[currentIndex - 1] if (prev) setStepN(prev.n) } // --- Innsending (ADR-040): runden finnes ikke før HELE veiviseren er // fullført -- oppretter runden (eier), deretter sidene (om to-sidet), // deretter hver medspiller (med round_side_id satt direkte der siden // allerede finnes), deretter eierens egen side-tildeling og eventuell // Money Ball-rekkefølge (begge krever PATCH, siden ParticipantCreate // ikke støtter lineup_order og eieren allerede er opprettet FØR sidene). async function submitWizard() { if (!course || !courseMeta || !ownerTeeId) return setSubmitting(true) setSubmitError(null) try { const startedAt = time ? new Date(`${date}T${time}`).toISOString() : null const apiFormat = FORMAT_TO_API[format] const allowanceOverride = buildAllowanceOverride() const shared = { tee_name: teeName(course, ownerTeeId), name: roundName.trim() || null, played_at: date, started_at: startedAt, start_hole: Number(startHole), holes_planned: holes, stat_level: apiStatLevel(players.find((p) => p.isOwner)?.statLevel ?? "kun-slag"), play_format: apiFormat, exclude_owner_from_handicap: format === "match" ? matchExcludeHcp : false, skins_scoring: format === "skins" ? (skinsScoring === "netto" ? "net" : "gross") : null, skins_tie_handling: format === "skins" ? (skinsTie === "roll" ? "carry" : "split") : null, bbb_sweep_bonus_enabled: format === "bbb" ? bbbBonus : false, shamble_best_n: format === "shamble" ? shambleBest : null, visibility_mode: visibility === "privat" ? "private" : visibility === "venner" ? "friends" : "public", visible_categories: visibility === "venner" ? Array.from(categories) : [], flight_group_id: flightGroupId || undefined, allowance_override: allowanceOverride, } 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 roundRes = await fetch("/rounds", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(body), }) if (!roundRes.ok) throw new Error(`create round: ${roundRes.status}`) const round: { id: string; participants: { id: string; is_owner: boolean }[] } = await roundRes.json() const roundId = round.id const ownerParticipantId = round.participants.find((p) => p.is_owner)?.id ?? round.participants[0]?.id // Sider (kun to-sidede formater) -- opprettet FØR medspillerne slik // at round_side_id kan sendes direkte på hver ParticipantCreate. const sideIdMap = new Map() if (TWO_SIDED.has(format)) { for (const side of sides) { const res = await fetch(`/rounds/${roundId}/sides`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ label: side.label || null }), }) if (!res.ok) throw new Error(`create side: ${res.status}`) const created: { id: string } = await res.json() sideIdMap.set(side.id, created.id) } } // Medspillere (alle unntatt eieren, som allerede finnes). const participantIdMap = new Map([["owner", ownerParticipantId]]) for (const p of players) { if (p.isOwner) continue const teeSelected = p.teeId ? teeName(course, p.teeId) : undefined const realSideId = p.sideId ? sideIdMap.get(p.sideId) : undefined const participantBody: Record = { stat_level: apiStatLevel(p.statLevel), ...(teeSelected ? { tee_name: teeSelected } : {}), ...(realSideId ? { round_side_id: realSideId } : {}), } if (p.userId) { participantBody.user_id = p.userId } else { // Samme "første ord = fornavn, resten = etternavn"-heuristikk som // round-detail.tsx sin splitName()/migrasjon 052 sin backfill // (2026-08-03 -- ParticipantCreate godtar ikke lenger guest_name // direkte, kun guest_first_name/guest_last_name). const trimmedName = p.name.trim() const spaceIdx = trimmedName.indexOf(" ") participantBody.guest_first_name = spaceIdx === -1 ? trimmedName : trimmedName.slice(0, spaceIdx) participantBody.guest_last_name = spaceIdx === -1 ? null : trimmedName.slice(spaceIdx + 1).trim() participantBody.gender = apiGenderOf(p.gender) participantBody.handicap_index = parseHcp(p.hcp) if (p.email && p.email.trim()) participantBody.guest_email = p.email.trim() } const res = await fetch(`/rounds/${roundId}/participants`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(participantBody), }) if (!res.ok) { const errBody = await res.json().catch(() => null) throw new Error(errBody?.detail?.message ?? `add participant: ${res.status}`) } const created: { id: string } = await res.json() participantIdMap.set(p.id, created.id) } // Eierens egen side-tildeling (PATCH -- eieren finnes allerede FØR // sidene ble opprettet over). if (TWO_SIDED.has(format)) { const owner = players.find((p) => p.isOwner) const realSideId = owner?.sideId ? sideIdMap.get(owner.sideId) : undefined if (realSideId) { await fetch(`/rounds/${roundId}/participants/${ownerParticipantId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ round_side_id: realSideId }), }) } } // Money Ball-rekkefølge (PATCH -- ikke støttet på selve opprettelsen). if (format === "moneyball") { for (const p of players) { if (p.moneyBallSlot === undefined) continue const realId = participantIdMap.get(p.id) if (!realId) continue await fetch(`/rounds/${roundId}/participants/${realId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ lineup_order: p.moneyBallSlot }), }) } } router.replace(`/my-rounds/${roundId}`) } catch (err) { setSubmitError( err instanceof Error && err.message && !err.message.startsWith("create round:") && !err.message.startsWith("create side:") ? err.message : "Klarte ikke å opprette runden. Prøv igjen.", ) setSubmitting(false) } } // ADR-040 Beslutning B -- HCP-prosenten er alltid justerbar, og // Match-HCP kan slås av/på for to-sidede formater. Samme mønster som // tournament-program.tsx sin CreateSessionCard.buildAllowanceOverride. function buildAllowanceOverride(): Record | null { const percentValue = hcpPercent.trim() === "" ? undefined : Number(hcpPercent.replace(",", ".")) const hasPercent = percentValue !== undefined && !Number.isNaN(percentValue) const twoSided = TWO_SIDED.has(format) const matchplayIsDefault = !twoSided || useMatchplayHcp if (useHcp && useCourseHcp && matchplayIsDefault && !hasPercent) return null const override: Record = { use_handicap: useHcp, use_course_handicap: useCourseHcp } if (twoSided) override.use_matchplay_handicap = useMatchplayHcp if (hasPercent) { const fraction = percentValue! / 100 override.strategy = SIDE_IS_UNIT_FORMAT.has(format) ? { type: "combined", percentage: fraction } : { type: "per_player", percentage: fraction } } return override } if (loadingMe) { return (
) } if (!ownGender) { return (

Profilen din mangler registrert kjønn, som trengs for å beregne banehandicap riktig.{" "} Gå til kontoinnstillinger .

) } return (
{/* pb-32 er reservert klaring for den faste Tilbake/Neste-linjen under -- ALDRI en py- eller sm:py-klasse her (Tailwind emitterer sm-varianter i en egen media-blokk etter grunnklassene, så en sm:py-8 ville slått padding-bottom tilbake til 32px på alle skjermer 640px og bredere, uansett rekkefølge i selve className-strengen -- reell bug funnet 2026-08-02, se CHANGELOG.md). En pt-klasse er trygt, den påvirker aldri padding-bottom. */}
{isAdditionalFlight && stepN === 1 && s1Sub !== "fields" && (

Du setter opp enda en flight i samme oppsett -- dato, starthull, hullantall og spilleform er forhåndsutfylt fra den første flighten, men kan endres.

)} {submitError && (

{submitError}

)} {stepN === 1 && ( { setOwnerTeeId(id) setPlayers((prev) => prev.map((p) => (p.isOwner ? { ...p, teeId: id } : p))) }} date={date} setDate={setDate} time={time} setTime={setTime} startHole={startHole} setStartHole={setStartHole} /> )} {stepN === 2 && ( { setStatLevel(v) setPlayers((prev) => prev.map((p) => (p.isOwner ? { ...p, statLevel: v } : p))) }} format={format} setFormat={setFormat} holes={holes} setHoles={setHoles} matchExcludeHcp={matchExcludeHcp} setMatchExcludeHcp={setMatchExcludeHcp} skinsScoring={skinsScoring} setSkinsScoring={setSkinsScoring} skinsTie={skinsTie} setSkinsTie={setSkinsTie} bbbBonus={bbbBonus} setBbbBonus={setBbbBonus} shambleBest={shambleBest} setShambleBest={setShambleBest} advOpen={advOpen} setAdvOpen={setAdvOpen} useHcp={useHcp} setUseHcp={setUseHcp} useCourseHcp={useCourseHcp} setUseCourseHcp={setUseCourseHcp} useMatchplayHcp={useMatchplayHcp} setUseMatchplayHcp={setUseMatchplayHcp} hcpPercent={hcpPercent} setHcpPercent={setHcpPercent} /> )} {stepN === 3 && course && ( )} {stepN === 4 && ( )} {stepN === 5 && ( )}
{/* Sticky wizard nav */}
{showNextButton && ( )}
) } // --- Step indicator -------------------------------------------------------- function StepIndicator({ steps, currentIndex }: { steps: { n: number; title: string }[]; currentIndex: number }) { return (
    {steps.map((s, i) => { const done = i < currentIndex const current = i === currentIndex return (
  1. {done ? {i < steps.length - 1 && ( )}
  2. ) })}

Steg {currentIndex + 1} av {steps.length} · {steps[currentIndex].title}

) } // --- Delt UI ----------------------------------------------------------------- function StepHeader({ title, description }: { title: string; description?: string }) { return (

{title}

{description &&

{description}

}
) } function SectionTitle({ title, hint }: { title: string; hint?: string }) { return (

{title}

{hint &&

{hint}

}
) } function Pill({ selected, onClick, children }: { selected: boolean; onClick: () => void; children: React.ReactNode }) { return ( ) } function SegChoice({ label, value, options, onChange, }: { label: string value: T options: { value: T; label: string }[] onChange: (v: T) => void }) { return (
{options.map((o) => { const selected = o.value === value return ( ) })}
) } function ChoiceCard({ selected, onClick, title, description, icon, }: { selected: boolean onClick: () => void title: string description: string icon?: React.ReactNode }) { return ( ) } function CheckRow({ checked, onChange, label, helper }: { checked: boolean; onChange: (v: boolean) => void; label: string; helper?: string }) { return (
{helper &&

{helper}

}
) } function NumberButtons({ values, value, onChange, label }: { values: number[]; value: number | undefined; onChange: (v: number) => void; label: string }) { return (
{values.map((v) => { const selected = v === value return ( ) })}
) } function Avatar({ name }: { name: string }) { return {initials(name)} } function HelperNote({ children }: { children: React.ReactNode }) { return

{children}

} // --- Step 1: Bane & tid ------------------------------------------------------ function Step1(props: { s1Sub: S1Sub setS1Sub: (s: S1Sub) => void selectedClub: OfficialClub | null setSelectedClub: (c: OfficialClub | null) => void setCourseSource: (s: "official" | "own") => void course: Course | null chooseCourse: (c: Course, meta: CourseMeta, source: "official" | "own") => void ownGender: ApiGender roundName: string setRoundName: (v: string) => void ownerTeeId: string setOwnerTeeId: (v: string) => void date: string setDate: (v: string) => void time: string setTime: (v: string) => void startHole: string setStartHole: (v: string) => void }) { const { s1Sub, setS1Sub, selectedClub, setSelectedClub, setCourseSource, course, chooseCourse, ownGender } = props const [resolvingClub, setResolvingClub] = useState(false) // "Bruk som mal"-broen (2026-08-04): forhåndsutfylling for own-create, // satt fra enten TeeOff-baneutvalget eller det offentlige bane-søket. const [templatePrefill, setTemplatePrefill] = useState<{ values: CourseTemplateValues; forkedFromId?: string } | null>(null) if (s1Sub === "source") { return (
) } if (s1Sub === "official-search") { return ( { setResolvingClub(true) try { const res = await fetch(`/rounds/official-search/${facility.slug}`, { credentials: "include" }) if (!res.ok) throw new Error(`facility detail: ${res.status}`) const detail: { name: string; courses: ApiOfficialCourseOption[] } = await res.json() const club: OfficialClub = { id: facility.slug, name: detail.name, location: [facility.city, facility.county].filter(Boolean).join(", "), courses: detail.courses.map((c) => ({ id: String(c.teeoff_course_id), name: c.name, tees: apiTeesToTees(c.tees), templateHoles: c.holes, templateTees: c.full_tees, })), } setSelectedClub(club) setS1Sub("official-courses") } catch { setSelectedClub(null) } finally { setResolvingClub(false) } }} /> ) } if (s1Sub === "official-courses" && selectedClub) { return ( chooseCourse(c, { source: "teeoff", facilitySlug: selectedClub.id, teeoffCourseId: Number(c.id) }, "official")} onUseAsTemplate={(c) => { setTemplatePrefill({ values: { name: c.name, holes: c.templateHoles ?? [], tees: c.templateTees ?? [] } }) setCourseSource("own") setS1Sub("own-create") }} /> ) } if (s1Sub === "own-search") { return ( { 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 }, "own", ) }} onUseAsTemplate={async (picked) => { const res = await fetch(`/personal-courses/${picked.id}`, { credentials: "include" }) if (!res.ok) return const detail: ApiPersonalCourseDetail = await res.json() setTemplatePrefill({ values: { name: detail.name, holes: detail.holes, tees: detail.full_tees }, forkedFromId: detail.id, }) setS1Sub("own-create") }} onCreate={() => { setTemplatePrefill(null) setS1Sub("own-create") }} /> ) } if (s1Sub === "own-create") { return ( chooseCourse(created, { source: "custom", personalCourseId: created.id }, "own")} onCancel={() => { setTemplatePrefill(null) setS1Sub("own-search") }} /> ) } // s1Sub === "fields" if (course) { return } return null } function SourceCard({ icon, title, description, onClick }: { icon: React.ReactNode; title: string; description: string; onClick: () => void }) { return ( ) } function NearbyClubs({ onSelectClub, resolving }: { onSelectClub: (f: ApiFacility) => 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(() => {}) }, () => {}, { timeout: 8000 }, ) return () => { cancelled = true } }, []) if (!nearby || nearby.length === 0) return null return (
    {nearby.map((facility) => (
  • onSelectClub(facility)} />
  • ))}
) } function OfficialSearch({ onSelectClub, resolving }: { onSelectClub: (facility: ApiFacility) => void; resolving: boolean }) { const [query, setQuery] = useState("") const [results, setResults] = useState(null) const [searching, setSearching] = useState(false) useEffect(() => { const trimmed = query.trim() if (trimmed === "") { setResults(null) return } setSearching(true) const handle = setTimeout(() => { fetch(`/rounds/official-search?q=${encodeURIComponent(trimmed)}`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : [])) .then((data: ApiFacility[]) => setResults(data)) .catch(() => setResults([])) .finally(() => setSearching(false)) }, 250) return () => clearTimeout(handle) }, [query]) return (
{query.trim() === "" && }
{query.trim() !== "" && !searching && results !== null && (results.length === 0 ? ( Ingen klubber matcher «{query}». Prøv et annet søk. ) : (
    {results.map((facility) => (
  • onSelectClub(facility)} />
  • ))}
))} {resolving &&

Henter baner …

}
) } function ClubRow({ name, location, distance, disabled, onClick }: { name: string; location: string; distance?: string; disabled?: boolean; onClick: () => void }) { return ( ) } function CourseList({ title, description, courses, onSelect, onUseAsTemplate, }: { title: string description: string courses: Course[] onSelect: (c: Course) => void onUseAsTemplate?: (c: Course) => void }) { return (
    {courses.map((course) => (
  • {onUseAsTemplate && course.templateHoles && course.templateHoles.length > 0 && ( )}
  • ))} {courses.length === 0 && (
  • Ingen 18-hulls baner registrert hos dette anlegget ennå.
  • )}
) } function OwnSearch({ onSelect, onUseAsTemplate, onCreate, }: { onSelect: (c: ApiPersonalCourse) => void onUseAsTemplate: (c: ApiPersonalCourse) => void onCreate: () => void }) { const [query, setQuery] = useState("") const [results, setResults] = useState([]) useEffect(() => { let cancelled = false const timer = setTimeout(() => { fetch(`/personal-courses?q=${encodeURIComponent(query.trim())}`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : [])) .then((data: ApiPersonalCourse[]) => { if (!cancelled) setResults(data) }) .catch(() => {}) }, 250) return () => { cancelled = true clearTimeout(timer) } }, [query]) return (
{results.length > 0 ? (
    {results.map((course) => (
  • ))}
) : ( {query.trim() ? `Ingen egne baner matcher «${query}».` : "Skriv for å søke, eller opprett en ny bane under."} )}
) } // --- Egen bane: opprett ny (ekte skjema -- porter fra tidligere versjon) ---- type CreateRating = { courseRating: string; slope: string; par: string } type CreateTee = { id: string; name: string; men: boolean; women: boolean; menRating?: CreateRating; womenRating?: CreateRating } function emptyHoles() { return Array.from({ length: 18 }, (_, i) => ({ hole: i + 1, par: "4", strokeIndex: String(i + 1) })) } // "Bruk som mal"-broen (2026-08-04): konverterer fullt hull-/utslagoppsett // (fra en TeeOff-bane eller en offentlig custom-bane) til det redigerbare // lokale string-baserte skjema-formatet -- gjenbruker samme string-felt- // tilnærming som resten av OwnCreateStep (tillater midlertidig ugyldig/ // tom input mens brukeren redigerer). function templateToHoles(holes: ApiCourseHole[]) { return holes .slice() .sort((a, b) => a.hole_number - b.hole_number) .map((h) => ({ hole: h.hole_number, par: String(h.par), strokeIndex: String(h.stroke_index) })) } function templateToTees(tees: ApiCourseTee[]): CreateTee[] { return tees.map((t, i) => { const m = t.ratings.find((r) => r.gender === "m") const f = t.ratings.find((r) => r.gender === "f") return { id: `tpl-${i}-${t.name}`, name: t.name, men: !!m, women: !!f, menRating: m ? { courseRating: String(m.course_rating), slope: String(m.slope_rating), par: String(m.par) } : undefined, womenRating: f ? { courseRating: String(f.course_rating), slope: String(f.slope_rating), par: String(f.par) } : undefined, } }) } function parseDecimal(value: string): number { return Number(value.replace(",", ".")) } function OwnCreateStep({ initial, forkedFromId, onCreated, onCancel, }: { initial?: CourseTemplateValues forkedFromId?: string onCreated: (course: Course) => void onCancel: () => void }) { const [name, setName] = useState(initial?.name ?? "") const [holes, setHoles] = useState(() => (initial ? templateToHoles(initial.holes) : emptyHoles())) const [tees, setTees] = useState(() => initial ? templateToTees(initial.tees) : [{ 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) }] : []), ], })), forked_from_id: forkedFromId ?? null, } 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) => ({ id: `${t.name.trim()}-${t.id}`, name: t.name.trim(), genders: [...(t.men ? (["m"] as ApiGender[]) : []), ...(t.women ? (["f"] as ApiGender[]) : [])], })), }) } catch { setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.") } finally { setSubmitting(false) } } return (
{error &&

{error}

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

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}
  • ))}

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: CreateTee; 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?: CreateRating onToggle: (enabled: boolean) => void onRatingChange: (rating: CreateRating) => void idPrefix: string }) { const current = rating ?? { courseRating: "", slope: "", par: "" } function update(field: keyof CreateRating, 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" />
)}
) } function Step1Fields({ course, ownGender, roundName, setRoundName, ownerTeeId, setOwnerTeeId, date, setDate, time, setTime, startHole, setStartHole, }: { course: Course ownGender: ApiGender roundName: string setRoundName: (v: string) => void ownerTeeId: string setOwnerTeeId: (v: string) => void date: string setDate: (v: string) => void time: string setTime: (v: string) => void startHole: string setStartHole: (v: string) => void } & Record) { const compatibleTees = course.tees.filter((t) => t.genders.includes(ownGender)) return (
Valgt bane {course.name}
setRoundName(e.target.value)} placeholder={course.name} maxLength={200} className="h-14 rounded-2xl text-lg" />
{compatibleTees.length === 0 ? (

Denne banen har ingen registrert rating for ditt kjønn på noe utslag -- HCP-sporing er ikke mulig for denne runden.

) : (
{compatibleTees.map((t) => ( setOwnerTeeId(t.id)}> {t.name} ))}
)}
setDate(e.target.value)} className="h-14 rounded-2xl text-lg" />
setTime(e.target.value)} className="h-14 rounded-2xl text-lg" />
) } // --- Step 2: Spilleform ------------------------------------------------------ function Step2(props: { statLevel: StatLevel setStatLevel: (v: StatLevel) => void format: FormatId setFormat: (v: FormatId) => void holes: 9 | 18 setHoles: (v: 9 | 18) => void matchExcludeHcp: boolean setMatchExcludeHcp: (v: boolean) => void skinsScoring: "netto" | "brutto" setSkinsScoring: (v: "netto" | "brutto") => void skinsTie: "roll" | "split" setSkinsTie: (v: "roll" | "split") => void bbbBonus: boolean setBbbBonus: (v: boolean) => void shambleBest: number setShambleBest: (v: number) => void advOpen: boolean setAdvOpen: (v: boolean) => void useHcp: boolean setUseHcp: (v: boolean) => void useCourseHcp: boolean setUseCourseHcp: (v: boolean) => void useMatchplayHcp: boolean setUseMatchplayHcp: (v: boolean) => void hcpPercent: string setHcpPercent: (v: string) => void }) { const { statLevel, setStatLevel, format, setFormat, holes, setHoles, matchExcludeHcp, setMatchExcludeHcp, skinsScoring, setSkinsScoring, skinsTie, setSkinsTie, bbbBonus, setBbbBonus, shambleBest, setShambleBest, advOpen, setAdvOpen, useHcp, setUseHcp, useCourseHcp, setUseCourseHcp, useMatchplayHcp, setUseMatchplayHcp, hcpPercent, setHcpPercent, } = props const selectedFormat = FORMATS.find((f) => f.id === format)! const twoSided = TWO_SIDED.has(format) return (
{STAT_LEVELS.map((s) => ( setStatLevel(s.id)} title={s.label} description={s.desc} /> ))}
{FORMATS.map((f) => { const selected = f.id === format return ( ) })}

{selectedFormat.label}: {selectedFormat.desc}

{advOpen && (
{useHcp && !ORDINARY_FORMATS.has(format) && ( )} {useHcp && twoSided && (

Fordeler slagene som en differanse mellom sidene i stedet for hvert lags fulle handicap — slås av spilles matchen med rå brutto/netto per side.

)} {useHcp && (
setHcpPercent(e.target.value)} placeholder="Standard for format" className="h-14 rounded-2xl text-lg" />
)}
)}
{([9, 18] as const).map((h) => { const selected = holes === h return ( ) })}
) } function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) { return (
) } function FormatConfig({ format, matchExcludeHcp, setMatchExcludeHcp, skinsScoring, setSkinsScoring, skinsTie, setSkinsTie, bbbBonus, setBbbBonus, shambleBest, setShambleBest, }: { format: FormatId matchExcludeHcp: boolean setMatchExcludeHcp: (v: boolean) => void skinsScoring: "netto" | "brutto" setSkinsScoring: (v: "netto" | "brutto") => void skinsTie: "roll" | "split" setSkinsTie: (v: "roll" | "split") => void bbbBonus: boolean setBbbBonus: (v: boolean) => void shambleBest: number setShambleBest: (v: number) => void }) { if (format === "match") { return ( ) } if (format === "skins") { return (
Skins krever minst 3 spillere i runden -- legg til medspillere etter at runden er opprettet.
) } if (format === "bbb") { return ( ) } if (format === "shamble") { return (

2-4 spillere på laget, alle slår ut og velger beste utslag -- deretter spiller alle egen ball inn. Legg til medspillere etter opprettelse.

) } if (format === "moneyball") { return Nøyaktig 4 spillere -- du setter opp hvem som er "money ball" på hvilket hull i neste steg. } if (format === "copenhagen") { return Nøyaktig 3 spillere. } if (TWO_SIDED.has(format)) { return Du setter opp lagene i neste steg. } return null } // --- Step 3: Spillere --------------------------------------------------------- function Step3({ course, players, setPlayers, roundStatLevel, }: { course: Course players: Player[] setPlayers: React.Dispatch> roundStatLevel: StatLevel }) { const [editingId, setEditingId] = useState(null) const [adding, setAdding] = useState(false) function updatePlayer(id: string, patch: Partial) { setPlayers((prev) => prev.map((p) => (p.id === id ? { ...p, ...patch } : p))) } function removePlayer(id: string) { setPlayers((prev) => prev.filter((p) => p.id !== id)) if (editingId === id) setEditingId(null) } function addPlayer(p: Player) { setPlayers((prev) => [...prev, p]) setAdding(false) } return (
    {players.map((p) => (
  • setEditingId(editingId === p.id ? null : p.id)} onRemove={() => removePlayer(p.id)} onChange={(patch) => updatePlayer(p.id, patch)} />
  • ))}
{adding ? ( setAdding(false)} onAdd={addPlayer} roundStatLevel={roundStatLevel} existingNames={players.map((p) => p.name)} /> ) : ( )}
) } // Egen komponent for fornavn/etternavn-redigering av en allerede lagt-til // gjest -- lokal state (splittet fra player.name ved mount) fordi feltene // må kunne redigeres uavhengig, men det lagrede player.name forblir det // sammensatte "Fornavn Etternavn"-strenget resten av veiviseren bruker. function GuestNameFields({ player, onChange }: { player: Player; onChange: (patch: Partial) => void }) { const initial = useMemo(() => splitName(player.name), [player.id]) const [firstName, setFirstName] = useState(initial.first) const [lastName, setLastName] = useState(initial.last) function commit(first: string, last: string) { onChange({ name: `${first.trim()} ${last.trim()}`.trim() }) } return (
{ setFirstName(e.target.value) commit(e.target.value, lastName) }} className="h-12 rounded-xl text-base" />
{ setLastName(e.target.value) commit(firstName, e.target.value) }} className="h-12 rounded-xl text-base" />
) } function PlayerCard({ player, course, editing, onToggleEdit, onRemove, onChange, }: { player: Player course: Course editing: boolean onToggleEdit: () => void onRemove: () => void onChange: (patch: Partial) => void }) { const isLinked = Boolean(player.userId) // Kjønnet til en spiller funnet via søk er IKKE kjent før de faktisk er // lagt til (PersonMatch bærer ikke gender) -- utslagslisten kan derfor // ikke filtreres for dem ennå, ulikt gjester (som velger kjønn selv her). // "Annet" har ingen tee-rating i det hele tatt (kun m/f finnes) -- gir // da bevisst en tom liste, samme begrensning som resten av appen. const guestApiGender = apiGenderOf(player.gender) const teeOptions = player.isGuest ? guestApiGender === "x" ? [] : course.tees.filter((t) => t.genders.includes(guestApiGender)) : course.tees return (
{player.name} {player.isOwner && ( <> Deg Eier )} {player.isGuest && Gjest}
Utslag: {teeName(course, player.teeId)} · HCP: {player.hcp || "—"}
{!player.isOwner && ( )}
{editing && (
{player.isGuest && ( <>
label="Kjønn" value={player.gender} onChange={(g) => onChange({ gender: g })} options={[ { value: "mann", label: "Mann" }, { value: "kvinne", label: "Kvinne" }, { value: "annet", label: "Annet" }, ]} />
onChange({ email: e.target.value })} className="h-12 rounded-xl text-base" />
)}
{teeOptions.map((t) => ( onChange({ teeId: t.id })}> {t.name} ))}
{isLinked && (

Kjønn er ikke kjent før spilleren er lagt til runden -- velg utslag med omhu.

)}
{player.isGuest ? (
onChange({ hcp: e.target.value })} placeholder="F.eks. 14,2" className="h-12 rounded-xl text-base" />
) : (

{player.hcp ? `Hentes fra profilen (HCP ${player.hcp})` : "Hentes fra profilen når spilleren legges til."}

)}
label="Statistikk" value={player.statLevel} onChange={(s) => onChange({ statLevel: s })} options={[ { value: "kun-slag", label: "Kun slag" }, { value: "slag-putter", label: "Slag og putter" }, { value: "all", label: "All statistikk" }, ]} />
)}
) } function Badge({ children, tone = "primary" }: { children: React.ReactNode; tone?: "primary" | "muted" }) { return {children} } function AddPlayerPanel({ onCancel, onAdd, roundStatLevel, existingNames, }: { onCancel: () => void onAdd: (p: Player) => void roundStatLevel: StatLevel existingNames: string[] }) { const [mode, setMode] = useState<"search" | "guest">("search") const [query, setQuery] = useState("") const [results, setResults] = useState([]) const [searching, setSearching] = useState(false) const [gFirstName, setGFirstName] = useState("") const [gLastName, setGLastName] = useState("") const [gEmail, setGEmail] = useState("") const [gGender, setGGender] = useState("mann") const [gHcp, setGHcp] = useState("") const [gStat, setGStat] = useState(roundStatLevel) const [knownGuest, setKnownGuest] = useState(null) const [knownGuestDismissed, setKnownGuestDismissed] = useState(false) // "Vi kjenner igjen denne e-posten" -- kun søk i EGNE tidligere // registrerte gjester (håndhevet server-side, se find_known_guest i // app/routers/rounds.py -- aldri et globalt oppslag). Samme mønster som // round-detail.tsx sin AddGuestForm. useEffect(() => { const trimmed = gEmail.trim() setKnownGuestDismissed(false) if (trimmed.length < 5 || !trimmed.includes("@")) { setKnownGuest(null) return } const handle = setTimeout(() => { fetch(`/rounds/guests/known?email=${encodeURIComponent(trimmed)}`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : null)) .then((data: KnownGuest | null) => setKnownGuest(data)) .catch(() => setKnownGuest(null)) }, 350) return () => clearTimeout(handle) }, [gEmail]) function useKnownGuest() { if (!knownGuest) return setGFirstName(knownGuest.guest_first_name) setGLastName(knownGuest.guest_last_name ?? "") if (knownGuest.gender) setGGender(uiGenderOf(knownGuest.gender)) if (knownGuest.handicap_index !== null) setGHcp(String(knownGuest.handicap_index).replace(".", ",")) setKnownGuestDismissed(true) } // Navnet fra søkefeltet skal følge med når brukeren i stedet velger // "Legg til uten konto (gjest)" fordi personen ikke fantes. function switchToGuestMode() { const split = splitName(query) setGFirstName(split.first) setGLastName(split.last) setMode("guest") } useEffect(() => { const trimmed = query.trim() if (trimmed.length < 2) { setResults([]) setSearching(false) return } setSearching(true) const handle = setTimeout(() => { fetch(`/people/search?q=${encodeURIComponent(trimmed)}`, { credentials: "include" }) .then((res) => (res.ok ? res.json() : [])) .then((data: PersonMatch[]) => { const taken = new Set(existingNames.map((n) => n.toLowerCase())) setResults(data.filter((p) => !taken.has(`${p.first_name} ${p.last_name}`.toLowerCase()))) }) .catch(() => setResults([])) .finally(() => setSearching(false)) }, 250) return () => clearTimeout(handle) }, [query, existingNames]) return (

{mode === "search" ? "Søk etter medspiller" : "Legg til gjest"}

{mode === "search" ? ( <>
{query.trim().length > 0 && query.trim().length < 2 &&

Skriv minst 2 tegn for å søke.

} {results.length > 0 ? (
    {results.map((r) => (
  • ))}
) : ( !searching && query.trim().length >= 2 &&

Fant ingen med det navnet.

)} ) : ( <>
setGFirstName(e.target.value)} placeholder="Fornavn" className="h-14 rounded-2xl text-lg" />
setGLastName(e.target.value)} placeholder="Etternavn" className="h-14 rounded-2xl text-lg" />
setGEmail(e.target.value)} placeholder="For scorekort og statistikk etter runden" className="h-14 rounded-2xl text-lg" /> {knownGuest && !knownGuestDismissed && (

Vi fant {knownGuest.guest_first_name} {knownGuest.guest_last_name}{" "} fra en tidligere runde du satte opp.

)}
label="Kjønn" value={gGender} onChange={setGGender} options={[ { value: "mann", label: "Mann" }, { value: "kvinne", label: "Kvinne" }, { value: "annet", label: "Annet" }, ]} />
setGHcp(e.target.value)} placeholder="F.eks. 18,0" className="h-14 rounded-2xl text-lg" />
label="Statistikk for denne spilleren" value={gStat} onChange={setGStat} options={[ { value: "kun-slag", label: "Kun slag" }, { value: "slag-putter", label: "Slag og putter" }, { value: "all", label: "All statistikk" }, ]} />
)}
) } // --- Step 4: Lag og rekkefølge ------------------------------------------------ function Step4({ format, players, setPlayers, sides, setSides, }: { format: FormatId players: Player[] setPlayers: React.Dispatch> sides: Side[] setSides: React.Dispatch> }) { if (format === "moneyball") { return (
Tildel hver spiller en plass (0-3) -- avgjør hvem som er "money ball" på hvilket hull.
    {players.map((p) => (
  • {p.name}
    setPlayers((prev) => prev.map((x) => (x.id === p.id ? { ...x, moneyBallSlot: v } : x)))} />
  • ))}
) } function createSide(label: string) { setSides((prev) => [...prev, { id: newId("side"), label: label.trim() || `Side ${prev.length === 0 ? "A" : "B"}` }]) } function deleteSide(sideId: string) { setSides((prev) => prev.filter((s) => s.id !== sideId)) setPlayers((prev) => prev.map((p) => (p.sideId === sideId ? { ...p, sideId: undefined } : p))) } function assign(playerId: string, sideId: string | undefined) { setPlayers((prev) => prev.map((p) => (p.id === playerId ? { ...p, sideId } : p))) } const unassigned = players.filter((p) => !p.sideId) return (
{[0, 1].map((slot) => { const side = sides[slot] if (!side) { return } const sidePlayers = players.filter((p) => p.sideId === side.id) const otherSide = sides.find((s) => s.id !== side.id) return (
{side.label}
{sidePlayers.length === 0 ? (

Ingen spillere ennå.

) : (
    {sidePlayers.map((p) => (
  • {p.name} {otherSide && ( )}
  • ))}
)}
) })}
{sides.length > 0 && unassigned.length > 0 && (
    {unassigned.map((p) => (
  • {p.name} {sides.map((s, i) => ( ))}
  • ))}
)}
) } function CreateSideCard({ slot, onCreate, disabled }: { slot: number; onCreate: (label: string) => void; disabled: boolean }) { const [label, setLabel] = useState("") const fallback = slot === 0 ? "Side A" : "Side B" return (
setLabel(e.target.value)} placeholder={slot === 0 ? "F.eks. Rødt lag" : "F.eks. Blått lag"} disabled={disabled} className="h-12 rounded-xl text-base" />
) } // --- Step 5: Deling ------------------------------------------------------------ function Step5({ visibility, setVisibility, categories, setCategories, }: { visibility: Visibility setVisibility: (v: Visibility) => void categories: Set setCategories: React.Dispatch>> }) { function toggleCategory(code: string) { setCategories((prev) => { const next = new Set(prev) if (next.has(code)) next.delete(code) else next.add(code) return next }) } return (
setVisibility("privat")} title="Privat" description="Kun deg og faktiske medspillere" icon={
{visibility === "venner" && (

Alle er forhåndsvalgt — velg heller bort det du ikke vil skal se den.

{CATEGORY_OPTIONS.map(({ code, label }) => ( toggleCategory(code)}> {label} ))}
{categories.size === 0 && (

Ingen kategori valgt ennå -- ingen venner kan se runden før du velger minst én.

)}
)}
) }