diff --git a/frontend/app/my-rounds/new/page.tsx b/frontend/app/my-rounds/new/page.tsx index df3f1c1..7d711e4 100644 --- a/frontend/app/my-rounds/new/page.tsx +++ b/frontend/app/my-rounds/new/page.tsx @@ -1,19 +1,19 @@ import { Suspense } from "react" -import { NewRound } from "@/components/new-round" +import { NewRoundWizard } from "@/components/ny-runde/wizard-shell" export default function NewRoundPage() { return ( +
} > - + ) } diff --git a/frontend/components/new-round.tsx b/frontend/components/new-round.tsx deleted file mode 100644 index 11a73c9..0000000 --- a/frontend/components/new-round.tsx +++ /dev/null @@ -1,3364 +0,0 @@ -"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, - Minus, - Pencil, - Plus, - Search, - Sparkles, - Trophy, - User, - UserPlus, - 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" | "template-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" - | "scramblevssolo" - | "scramblevssolomatch" - -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." }, - { - id: "scramblevssolo", - label: "Scramble mot enkeltspiller", - desc: "Et lag med delt ball (minst 2 spillere) mot én enkeltspiller — nettoscore avgjør.", - }, - { - id: "scramblevssolomatch", - label: "Scramble mot enkeltspiller (matchspill)", - desc: "Samme oppsett som over, men avgjort hull for hull i stedet for netto totalsum.", - }, -] - -// Begge scramble-mot-enkeltspiller-variantene er BEVISST IKKE i dette -// settet -- de to sidene er strukturelt ulike (variabel lagstørrelse mot -// nøyaktig én individualspiller), ikke to like sider som resten av -// formatene her. Se app/routers/rounds.py sin _TWO_SIDED_FORMATS/ -// _SCRAMBLE_SOLO_FORMATS for samme skille på backend-siden. Egen samlet -// set siden begge variantene deler EKSAKT samme sideoppsett-UI -// (ScrambleVsSoloSides)/allowance-kontroll (SoloAllowanceControl)/ -// side_role-innsendingslogikk -- kun selve resultatvisningen skiller dem. -const SCRAMBLE_SOLO_VARIANTS = new Set(["scramblevssolo", "scramblevssolomatch"]) -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" || SCRAMBLE_SOLO_VARIANTS.has(f) -} - -// 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", - scramblevssolo: "scramble_solo", - scramblevssolomatch: "scramble_solo_match", -} -// 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") - // Hvor "own-create" skal gå TILBAKE til (2026-08-05) -- avhenger av om - // brukeren kom fra det vanlige "finn en bane å spille"-søket, fra - // mal-søket ("Basér på en eksisterende bane" inne i opprett-skjemaet), - // eller broen fra offisielle baner. - const [ownCreateOrigin, setOwnCreateOrigin] = useState("own-search") - // Mal-modus (2026-08-05) -- true KUN når offisiell-/egen-bane-søket er - // nådd via "Basér på en eksisterende bane i stedet" (fra det blanke - // opprett-ny-bane-skjemaet). Styrer om "Bruk som mal for egen bane" - // vises i BÅDE CourseList (offisielle baner) og OwnSearch (egne baner) - // -- samme skjermer som brukes til vanlig banevalg, men knappen skal - // KUN vises når brukeren faktisk har sagt at målet er å opprette en ny - // bane, ikke når målet bare er å velge en bane å spille. - const [templateMode, setTemplateMode] = useState(false) - 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 [soloAllowance, setSoloAllowance] = useState(100) - 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 === "template-source") { - setS1Sub("own-create") - } else if (s1Sub === "official-search") { - setS1Sub(templateMode ? "template-source" : "source") - } else if (s1Sub === "official-courses") { - setS1Sub("official-search") - } else if (s1Sub === "own-search") { - setS1Sub(templateMode ? "template-source" : "source") - } else if (s1Sub === "own-create") { - setS1Sub(ownCreateOrigin) - } 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, - scramble_solo_individual_pct: SCRAMBLE_SOLO_VARIANTS.has(format) ? soloAllowance : 100, - 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) - } - } else if (SCRAMBLE_SOLO_VARIANTS.has(format)) { - // Asymmetrisk format (begge varianter, stroke- og matchspill) -- - // de to sidene er faste (ikke organisator-opprettet via - // CreateSideCard som TWO_SIDED-formatene), og trenger side_role - // (backend kan ellers ikke skille laget fra individualspilleren, - // se RoundSideCreate.side_role). Lokale player.sideId-verdier - // "team"/"solo" (satt av ScrambleVsSoloSides) mappes her til de - // ekte, nyopprettede side-id-ene. - const roles: { localId: "team" | "solo"; label: string; side_role: "team" | "individual" }[] = [ - { localId: "team", label: "Laget", side_role: "team" }, - { localId: "solo", label: "Individuell motstander", side_role: "individual" }, - ] - for (const { localId, label, side_role } of roles) { - const res = await fetch(`/rounds/${roundId}/sides`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ label, side_role }), - }) - if (!res.ok) throw new Error(`create side: ${res.status}`) - const created: { id: string } = await res.json() - sideIdMap.set(localId, 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) || SCRAMBLE_SOLO_VARIANTS.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} - soloAllowance={soloAllowance} - setSoloAllowance={setSoloAllowance} - 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 - setOwnCreateOrigin: (s: S1Sub) => void - templateMode: boolean - setTemplateMode: (v: boolean) => 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, setOwnCreateOrigin, templateMode, setTemplateMode, 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 ( -
- -
-
-
- ) - } - - // Mal-kilde-velger (2026-08-05) -- nådd KUN fra "Basér på en eksisterende - // bane i stedet" i det blanke opprett-ny-bane-skjemaet. Samme to kilder - // som det aller første valget, men her setter vi templateMode(true) FØR - // vi går videre inn i official-search/own-search, slik at "Bruk som mal - // for egen bane" faktisk vises i de skjermene -- den vanlige "source"- - // skjermen over lar `templateMode` stå urørt (alltid false derfra). - if (s1Sub === "template-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) - } - }} - /> - ) - } - - // "Bruk som mal for egen bane" vises KUN i templateMode (2026-08-05) -- - // nådd via "template-source" (fra "Basér på en eksisterende bane i - // stedet" i det blanke opprett-ny-bane-skjemaet). Uten templateMode er - // dette bare den vanlige "velg en offisiell bane å spille"-listen, der - // en mal-forgrening ville vært forvirrende (brukeren rapporterte - // nettopp dette: knappen dukket opp når målet bare var å velge - // Hovedbanen på Tjøme GK for å spille en vanlig runde). - if (s1Sub === "official-courses" && selectedClub) { - return ( - chooseCourse(c, { source: "teeoff", facilitySlug: selectedClub.id, teeoffCourseId: Number(c.id) }, "official")} - onUseAsTemplate={ - templateMode - ? (c) => { - setTemplatePrefill({ values: { name: c.name, holes: c.templateHoles ?? [], tees: c.templateTees ?? [] } }) - setCourseSource("own") - setOwnCreateOrigin("template-source") - setS1Sub("own-create") - } - : undefined - } - /> - ) - } - - // Samme resonnement for egne baner: "Bruk som mal for egen bane" vises - // KUN i templateMode, ikke i det vanlige "finn en bane å spille"-søket - // (samme skjerm brukes til begge -- kun malknappen varierer). - 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={ - templateMode - ? 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, - }) - setOwnCreateOrigin("template-source") - setS1Sub("own-create") - } - : undefined - } - onCreate={() => { - setTemplatePrefill(null) - setOwnCreateOrigin("own-search") - setS1Sub("own-create") - }} - /> - ) - } - - if (s1Sub === "own-create") { - return ( - chooseCourse(created, { source: "custom", personalCourseId: created.id }, "own")} - onSearchTemplate={() => setS1Sub("template-source")} - onCancel={() => { - setTemplatePrefill(null) - setTemplateMode(false) - 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 - // Kun satt når `templateMode` er true i Step1 (nådd via "template-source" - // fra "Basér på en eksisterende bane i stedet") -- undefined i det - // vanlige "finn en bane å spille"-søket, som skjuler knappen under - // (2026-08-05). - 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) => ( -
  • - - {onUseAsTemplate && ( - - )} -
  • - ))} -
- ) : ( - {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, - onSearchTemplate, - onCancel, -}: { - initial?: CourseTemplateValues - forkedFromId?: string - onCreated: (course: Course) => void - // "Basér på en eksisterende bane" (2026-08-05) -- kun tilbudt når skjemaet - // fortsatt er blankt (ingen mal valgt ennå); gir "Bruk som mal for egen - // bane" en riktig plassering: kun der brukeren FAKTISK har sagt at målet - // er å opprette en ny bane, ikke i det vanlige "finn en bane å spille"-søket. - onSearchTemplate?: () => 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 ( -
- - - {onSearchTemplate && !initial && ( - - )} - - {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 - soloAllowance: number - setSoloAllowance: (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, soloAllowance, setSoloAllowance, - 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} -

-
- - - - {!SCRAMBLE_SOLO_VARIANTS.has(format) && ( -
- - {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, - soloAllowance, - setSoloAllowance, -}: { - 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 - soloAllowance: number - setSoloAllowance: (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 (SCRAMBLE_SOLO_VARIANTS.has(format)) { - return - } - - if (TWO_SIDED.has(format)) { - return Du setter opp lagene i neste steg. - } - - return null -} - -// Kompakt 0-100 (steg 5) tallvelger for individualspillerens handicap- -// andel. Bevisst INGEN tilsvarende kontroll for lagsiden -- lagets -// handicap er alltid en fast beregning (TeamAverage), ikke konfigurerbar. -function SoloAllowanceControl({ - value, - onChange, -}: { - value: number - onChange: (v: number) => void -}) { - const STEP = 5 - const MIN = 0 - const MAX = 100 - - function clamp(n: number) { - if (!Number.isFinite(n)) return MIN - return Math.min(MAX, Math.max(MIN, n)) - } - function snap(n: number) { - return clamp(Math.round(n / STEP) * STEP) - } - - return ( -
- -
- - -
- { - const digits = e.target.value.replace(/[^0-9]/g, "") - onChange(digits === "" ? MIN : clamp(Number(digits))) - }} - onBlur={() => onChange(snap(value))} - aria-describedby="solo-allowance-help" - className="h-14 rounded-2xl pr-10 text-center text-lg font-bold tabular-nums" - /> - -
- - -
-

- Hvor stor andel av egen handicap individualspilleren spiller med. Standard 100 % (full - handicap). -

-
- ) -} - -// --- 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, - roundStatLevel, -}: { - format: FormatId - players: Player[] - setPlayers: React.Dispatch> - sides: Side[] - setSides: React.Dispatch> - roundStatLevel: StatLevel -}) { - if (SCRAMBLE_SOLO_VARIANTS.has(format)) { - return - } - - 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) => ( - - ))} -
  • - ))} -
-
- )} -
- ) -} - -// Asymmetrisk sideoppsett for "Scramble mot enkeltspiller": slot 1 (Laget) -// er en åpen liste (ingen øvre grense, "minst 2"-hint), slot 2 (Individuell -// motstander) er begrenset til nøyaktig én spiller. player.sideId bruker -// de faste literalene "team"/"solo" her (ikke en dynamisk opprettet Side- -// id som i den symmetriske Step4 over) -- submitWizard() oppretter de to -// ekte backend-sidene (med side_role) og mapper disse to nøklene til de -// virkelige side-id-ene ved innsending. -function ScrambleVsSoloSides({ - players, - setPlayers, - roundStatLevel, -}: { - players: Player[] - setPlayers: React.Dispatch> - roundStatLevel: StatLevel -}) { - const [addingTo, setAddingTo] = useState<"team" | "solo" | null>(null) - - const team = players.filter((p) => p.sideId === "team") - const solo = players.filter((p) => p.sideId === "solo") - const unplaced = players.filter((p) => p.sideId !== "team" && p.sideId !== "solo") - const soloFull = solo.length >= 1 - const teamOk = team.length >= 2 - - function assign(id: string, sideId: string | undefined) { - setPlayers((prev) => prev.map((p) => (p.id === id ? { ...p, sideId } : p))) - } - function addTo(sideId: "team" | "solo", p: Player) { - setPlayers((prev) => [...prev, { ...p, sideId }]) - setAddingTo(null) - } - - const existingNames = players.map((p) => p.name) - - return ( -
- - - {/* Slot 1: Laget (åpen liste) */} -
-
-
- - -

Laget

-
- - {team.length} {team.length === 1 ? "spiller" : "spillere"} - -
- -
- {teamOk ? ( -
- - {team.length > 0 && ( -
    - {team.map((p) => ( - assign(p.id, undefined)} - removeLabel={`Fjern ${p.name} fra laget`} - /> - ))} -
- )} - - {addingTo === "team" ? ( - setAddingTo(null)} - onAdd={(p) => addTo("team", p)} - roundStatLevel={roundStatLevel} - existingNames={existingNames} - /> - ) : ( - setAddingTo("team")} /> - )} -
- - {/* Slot 2: Individuell motstander (begrenset til 1) */} -
-
- - -
-

Individuell motstander

-

Nøyaktig én spiller

-
-
- - {soloFull ? ( -
    - {solo.map((p) => ( - assign(p.id, undefined)} - removeLabel={`Fjern ${p.name} som motstander`} - /> - ))} -
- ) : addingTo === "solo" ? ( - setAddingTo(null)} - onAdd={(p) => addTo("solo", p)} - roundStatLevel={roundStatLevel} - existingNames={existingNames} - /> - ) : ( - setAddingTo("solo")} /> - )} -
- - {/* Spillere i runden som ennå ikke er plassert på noen side */} - {unplaced.length > 0 && ( -
- -
    - {unplaced.map((p) => ( -
  • - - {p.name} - - {!soloFull && ( - - )} -
  • - ))} -
-
- )} -
- ) -} - -function MemberRow({ - player, - onRemove, - removeLabel, -}: { - player: Player - onRemove: () => void - removeLabel: string -}) { - return ( -
  • - -
    - {player.name} - {player.hcp.trim() !== "" && ( - HCP {player.hcp} - )} -
    - -
  • - ) -} - -function AddAffordance({ label, onClick }: { label: string; onClick: () => void }) { - return ( - - ) -} - -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. Bruk "Ingen"/"Alle" - under for å starte på nytt, og finjuster deretter med enkeltkategoriene. -

    -
    - setCategories(new Set())}> - Ingen - - setCategories(new Set(CATEGORY_OPTIONS.map((c) => c.code)))} - > - Alle - -
    -
    - {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. -

    - )} -
    - )} -
    - ) -} diff --git a/frontend/components/ny-runde/create-course-form.tsx b/frontend/components/ny-runde/create-course-form.tsx new file mode 100644 index 0000000..dfd08c9 --- /dev/null +++ b/frontend/components/ny-runde/create-course-form.tsx @@ -0,0 +1,384 @@ +"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(initial.holes) + const [tees, setTees] = useState(initial.tees) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const nameRef = useAutoFocus(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 (1–18) 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) { + setHoles((prev) => prev.map((h, idx) => (idx === i ? { ...h, ...patch } : h))) + } + function setTee(id: string, patch: Partial) { + setTees((prev) => prev.map((t) => (t.id === id ? { ...t, ...patch } : t))) + } + function setTeeRating(id: string, gender: "men" | "women", patch: Partial) { + 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 ( +
    + + setName(e.target.value)} + placeholder="F.eks. Skogsbanen" + maxLength={120} + /> + + + {error ? ( +

    + {error} +

    + ) : null} + + {/* Hull */} +
    +
    +

    Hull

    + Par og stroke-indeks for 18 hull +
    +
    +
    +
    Hull
    +
    Par
    +
    Stroke-indeks
    + {holes.map((h, i) => ( + setHole(i, patch)} /> + ))} +
    +
    + {siError ? ( +

    + {siError} +

    + ) : ( +

    Alle stroke-indekser 1–18 er satt.

    + )} +
    + + {/* Utslag */} +
    +
    +

    Utslag

    + +
    + + {tees.map((t, i) => ( +
    +
    + + setTee(t.id, { name: e.target.value })} + placeholder={`Utslag ${i + 1}`} + invalid={t.name.trim() === ""} + className="bg-[var(--nr-surface)]" + /> + + +
    + +
    + setTee(t.id, { menEnabled: v })} + rating={t.men} + onChange={(patch) => setTeeRating(t.id, "men", patch)} + idPrefix={`${t.id}-men`} + /> + setTee(t.id, { womenEnabled: v })} + rating={t.women} + onChange={(patch) => setTeeRating(t.id, "women", patch)} + idPrefix={`${t.id}-women`} + /> + {!t.menEnabled && !t.womenEnabled ? ( +

    + Minst én rating (herre eller dame) må være aktiv for dette utslaget. +

    + ) : null} +
    +
    + ))} +
    + +
    + + +
    +
    + ) +} + +function HoleRow({ + index, + hole, + onChange, +}: { + index: number + hole: DraftHole + onChange: (patch: Partial) => void +}) { + return ( + <> +
    + {index + 1} +
    +
    + onChange({ par: Number.parseInt(e.target.value, 10) })} + className="h-9 bg-[var(--nr-surface-2)]" + > + {[3, 4, 5, 6].map((p) => ( + + ))} + +
    +
    + onChange({ si: Number.parseInt(e.target.value, 10) })} + className="h-9 bg-[var(--nr-surface-2)]" + > + + {Array.from({ length: 18 }, (_, i) => i + 1).map((si) => ( + + ))} + +
    + + ) +} + +function RatingBlock({ + title, + enabled, + onToggle, + rating, + onChange, + idPrefix, +}: { + title: string + enabled: boolean + onToggle: (v: boolean) => void + rating: DraftRating + onChange: (patch: Partial) => void + idPrefix: string +}) { + return ( +
    + + {enabled ? ( +
    + + onChange({ cr: e.target.value })} + placeholder="72.1" + invalid={rating.cr.trim() === ""} + className="bg-[var(--nr-surface-2)]" + /> + + + onChange({ slope: e.target.value.replace(/[^\d]/g, "") })} + placeholder="128" + invalid={rating.slope.trim() === ""} + className="bg-[var(--nr-surface-2)]" + /> + + + onChange({ par: e.target.value.replace(/[^\d]/g, "") })} + placeholder="72" + invalid={rating.par.trim() === ""} + className="bg-[var(--nr-surface-2)]" + /> + +
    + ) : null} +
    + ) +} diff --git a/frontend/components/ny-runde/footer-bar.tsx b/frontend/components/ny-runde/footer-bar.tsx new file mode 100644 index 0000000..d70e53c --- /dev/null +++ b/frontend/components/ny-runde/footer-bar.tsx @@ -0,0 +1,53 @@ +"use client" + +import { cn } from "@/lib/utils" +import { ArrowLeft, ArrowRight, Loader2 } from "lucide-react" + +export function FooterBar({ + onBack, + onNext, + nextLabel = "Neste", + nextDisabled, + backDisabled, + backLabel = "Tilbake", + submitting, + isSubmitStep, +}: { + onBack: () => void + onNext: () => void + nextLabel?: string + nextDisabled?: boolean + backDisabled?: boolean + backLabel?: string + submitting?: boolean + isSubmitStep?: boolean +}) { + return ( +
    +
    + + +
    +
    + ) +} diff --git a/frontend/components/ny-runde/primitives.tsx b/frontend/components/ny-runde/primitives.tsx new file mode 100644 index 0000000..fd6f599 --- /dev/null +++ b/frontend/components/ny-runde/primitives.tsx @@ -0,0 +1,455 @@ +"use client" + +import { cn } from "@/lib/utils" +import { Check, Minus, Plus } from "lucide-react" +import type React from "react" +import { forwardRef, useEffect, useId, useRef, useState } from "react" + +/* ------------------------------------------------------------------ */ +/* Debounce hook */ +/* ------------------------------------------------------------------ */ +export function useDebouncedValue(value: T, delay = 250): T { + const [debounced, setDebounced] = useState(value) + useEffect(() => { + const t = setTimeout(() => setDebounced(value), delay) + return () => clearTimeout(t) + }, [value, delay]) + return debounced +} + +/* ------------------------------------------------------------------ */ +/* Field wrapper */ +/* ------------------------------------------------------------------ */ +export function Field({ + label, + hint, + error, + optional, + htmlFor, + children, + className, +}: { + label?: string + hint?: string + error?: string | null + optional?: boolean + htmlFor?: string + children: React.ReactNode + className?: string +}) { + return ( +
    + {label ? ( + + ) : null} + {children} + {hint && !error ?

    {hint}

    : null} + {error ? ( +

    + {error} +

    + ) : null} +
    + ) +} + +/* ------------------------------------------------------------------ */ +/* Text input / textarea / native select */ +/* ------------------------------------------------------------------ */ +const controlBase = + "w-full rounded-lg border bg-[var(--nr-surface-2)] px-3 text-[16px] text-[var(--nr-ink)] outline-none transition-colors placeholder:text-[var(--nr-faint)] focus-visible:border-[var(--nr-accent)] focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40 disabled:opacity-50" + +export const TextInput = forwardRef & { invalid?: boolean }>( + ({ className, invalid, ...props }, ref) => ( + + ), +) +TextInput.displayName = "TextInput" + +export const TextArea = ({ + className, + ...props +}: React.TextareaHTMLAttributes) => ( +