teecup/frontend/components/new-round.tsx
2026-08-07 22:02:56 +02:00

3364 lines
136 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client"
// 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<FormatId>(["scramblevssolo", "scramblevssolomatch"])
const TWO_SIDED = new Set<FormatId>([
"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<FormatId>(["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<FormatId>(["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<FormatId, string> = {
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<string, FormatId> = Object.fromEntries(
Object.entries(FORMAT_TO_API).map(([k, v]) => [v, k as FormatId]),
) as Record<string, FormatId>
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<ApiGender | null>(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<S1Sub>("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<S1Sub>("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<OfficialClub | null>(null)
const [course, setCourse] = useState<Course | null>(null)
const [courseMeta, setCourseMeta] = useState<CourseMeta | null>(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<StatLevel>("kun-slag")
const [format, setFormat] = useState<FormatId>(
(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<Player[]>([])
// Step 4
const [sides, setSides] = useState<Side[]>([])
// Step 5
const [visibility, setVisibility] = useState<Visibility>("privat")
const [categories, setCategories] = useState<Set<string>>(new Set(CATEGORY_OPTIONS.map((c) => c.code)))
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(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<string, string>()
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<string, string>([["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<string, unknown> = {
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<string, unknown> | 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<string, unknown> = { 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 (
<div className="flex min-h-[100dvh] items-center justify-center bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
)
}
if (!ownGender) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<p className="max-w-prose text-lg text-muted-foreground">
Profilen din mangler registrert kjønn, som trengs for å beregne banehandicap riktig.{" "}
<Link href="/account" className="font-semibold text-primary underline underline-offset-2">
til kontoinnstillinger
</Link>
.
</p>
</div>
)
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-20 border-b border-border bg-background">
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-3.5">
<button
type="button"
onClick={handleBack}
className="inline-flex min-h-[44px] items-center gap-2 rounded-xl px-2 py-2 text-base font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<ArrowLeft aria-hidden="true" className="size-5" />
Tilbake
</button>
<Wordmark compact />
</div>
<div className="mx-auto w-full max-w-3xl px-5 pb-4">
<StepIndicator steps={stepList} currentIndex={currentIndex} />
</div>
</header>
{/* 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. */}
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pt-6 pb-32 sm:pt-8">
{isAdditionalFlight && stepN === 1 && s1Sub !== "fields" && (
<p className="mb-6 rounded-2xl border border-dashed border-primary/40 bg-primary/5 px-4 py-3 text-sm font-semibold text-foreground">
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.
</p>
)}
{submitError && (
<p role="alert" className="mb-6 rounded-2xl border border-destructive/40 bg-destructive/10 px-4 py-3 text-base font-semibold text-destructive">
{submitError}
</p>
)}
{stepN === 1 && (
<Step1
s1Sub={s1Sub}
setS1Sub={setS1Sub}
setOwnCreateOrigin={setOwnCreateOrigin}
templateMode={templateMode}
setTemplateMode={setTemplateMode}
selectedClub={selectedClub}
setSelectedClub={setSelectedClub}
setCourseSource={setCourseSource}
course={course}
chooseCourse={chooseCourse}
ownGender={ownGender}
roundName={roundName}
setRoundName={setRoundName}
ownerTeeId={ownerTeeId}
setOwnerTeeId={(id) => {
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 && (
<Step2
statLevel={statLevel}
setStatLevel={(v) => {
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 && (
<Step3 course={course} players={players} setPlayers={setPlayers} roundStatLevel={statLevel} />
)}
{stepN === 4 && (
<Step4
format={format}
players={players}
setPlayers={setPlayers}
sides={sides}
setSides={setSides}
roundStatLevel={statLevel}
/>
)}
{stepN === 5 && (
<Step5 visibility={visibility} setVisibility={setVisibility} categories={categories} setCategories={setCategories} />
)}
</main>
{/* Sticky wizard nav */}
<div className="fixed inset-x-0 bottom-0 z-20 border-t border-border bg-background">
<div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-5 py-4">
<Button
type="button"
variant="outline"
size="lg"
onClick={handleBack}
disabled={submitting}
className="h-14 rounded-2xl px-6 text-base font-semibold"
>
Tilbake
</Button>
{showNextButton && (
<Button
type="button"
size="lg"
disabled={!canNext || submitting}
onClick={handleNext}
className={cn("h-14 flex-1 rounded-2xl text-lg shadow-sm", isFinalStep ? "font-extrabold" : "font-bold")}
>
{isFinalStep ? (submitting ? "Oppretter…" : "Start runden") : "Neste"}
</Button>
)}
</div>
</div>
</div>
)
}
// --- Step indicator --------------------------------------------------------
function StepIndicator({ steps, currentIndex }: { steps: { n: number; title: string }[]; currentIndex: number }) {
return (
<div>
<ol className="flex items-center gap-1.5" aria-label="Fremdrift">
{steps.map((s, i) => {
const done = i < currentIndex
const current = i === currentIndex
return (
<li key={s.n} className="flex flex-1 items-center gap-1.5">
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-full text-sm font-bold",
current && "bg-primary text-primary-foreground",
done && "bg-primary/20 text-primary",
!current && !done && "bg-muted text-muted-foreground",
)}
aria-current={current ? "step" : undefined}
>
{done ? <Check aria-hidden="true" className="size-4" /> : i + 1}
</span>
{i < steps.length - 1 && (
<span className={cn("h-1 flex-1 rounded-full", i < currentIndex ? "bg-primary/40" : "bg-muted")} />
)}
</li>
)
})}
</ol>
<p className="mt-2.5 text-sm font-semibold text-muted-foreground">
Steg {currentIndex + 1} av {steps.length}
<span className="mx-1.5 text-border">·</span>
<span className="text-foreground">{steps[currentIndex].title}</span>
</p>
</div>
)
}
// --- Delt UI -----------------------------------------------------------------
function StepHeader({ title, description }: { title: string; description?: string }) {
return (
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-extrabold tracking-tight text-foreground text-balance">{title}</h1>
{description && <p className="max-w-prose text-lg leading-relaxed text-muted-foreground text-pretty">{description}</p>}
</div>
)
}
function SectionTitle({ title, hint }: { title: string; hint?: string }) {
return (
<div className="flex flex-col gap-1">
<h2 className="text-xl font-bold text-foreground">{title}</h2>
{hint && <p className="text-base leading-relaxed text-muted-foreground">{hint}</p>}
</div>
)
}
function Pill({ selected, onClick, children }: { selected: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={selected}
className={cn(
"inline-flex min-h-[44px] items-center justify-center rounded-full border px-5 text-base font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
selected ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:border-primary/50 hover:bg-accent/40",
)}
>
{children}
</button>
)
}
function SegChoice<T extends string>({
label,
value,
options,
onChange,
}: {
label: string
value: T
options: { value: T; label: string }[]
onChange: (v: T) => void
}) {
return (
<div role="group" aria-label={label} className="grid gap-2" style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}>
{options.map((o) => {
const selected = o.value === value
return (
<button
key={o.value}
type="button"
onClick={() => onChange(o.value)}
aria-pressed={selected}
className={cn(
"min-h-[48px] rounded-2xl border px-3 text-base font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
selected ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:border-primary/50 hover:bg-accent/40",
)}
>
{o.label}
</button>
)
})}
</div>
)
}
function ChoiceCard({
selected,
onClick,
title,
description,
icon,
}: {
selected: boolean
onClick: () => void
title: string
description: string
icon?: React.ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={selected}
className={cn(
"flex min-h-[44px] w-full flex-col items-start gap-2 rounded-2xl border-2 p-5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
selected ? "border-primary bg-primary/5" : "border-border bg-card hover:border-primary/40 hover:bg-accent/40",
)}
>
<div className="flex w-full items-center gap-3">
{icon && (
<span className={cn("flex size-11 shrink-0 items-center justify-center rounded-xl", selected ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground")}>
{icon}
</span>
)}
<span className="flex-1 text-lg font-bold text-foreground">{title}</span>
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded-full border-2", selected ? "border-primary bg-primary text-primary-foreground" : "border-border")} aria-hidden="true">
{selected && <Check className="size-4" />}
</span>
</div>
<p className="text-base leading-relaxed text-muted-foreground text-pretty">{description}</p>
</button>
)
}
function CheckRow({ checked, onChange, label, helper }: { checked: boolean; onChange: (v: boolean) => void; label: string; helper?: string }) {
return (
<div className="flex flex-col gap-1.5">
<button
type="button"
role="checkbox"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="flex min-h-[44px] items-center gap-3 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded-md border-2", checked ? "border-primary bg-primary text-primary-foreground" : "border-border")} aria-hidden="true">
{checked && <Check className="size-4" />}
</span>
<span className="text-base font-semibold text-foreground">{label}</span>
</button>
{helper && <p className="px-1 text-sm leading-relaxed text-muted-foreground">{helper}</p>}
</div>
)
}
function NumberButtons({ values, value, onChange, label }: { values: number[]; value: number | undefined; onChange: (v: number) => void; label: string }) {
return (
<div role="group" aria-label={label} className="flex flex-wrap gap-2">
{values.map((v) => {
const selected = v === value
return (
<button
key={v}
type="button"
onClick={() => onChange(v)}
aria-pressed={selected}
className={cn(
"flex size-12 items-center justify-center rounded-2xl border text-lg font-bold tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
selected ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:border-primary/50 hover:bg-accent/40",
)}
>
{v}
</button>
)
})}
</div>
)
}
function Avatar({ name }: { name: string }) {
return <span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-bold text-muted-foreground">{initials(name)}</span>
}
function HelperNote({ children }: { children: React.ReactNode }) {
return <p className="rounded-2xl border border-border bg-muted/50 px-4 py-3 text-base leading-relaxed text-muted-foreground text-pretty">{children}</p>
}
// --- 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 (
<div className="flex flex-col gap-8">
<StepHeader title="Bane & tid" description="Velg hvor banen kommer fra for å komme i gang." />
<div className="grid gap-4 sm:grid-cols-2">
<SourceCard
icon={<Trophy aria-hidden="true" className="size-7 text-muted-foreground" />}
title="Offisiell bane"
description="Søk opp banen i det offisielle baneregisteret."
onClick={() => {
setTemplateMode(false)
setCourseSource("official")
setS1Sub("official-search")
}}
/>
<SourceCard
icon={<Pencil aria-hidden="true" className="size-7 text-muted-foreground" />}
title="Egen bane"
description="Søk opp eller opprett en personlig bane."
onClick={() => {
setTemplateMode(false)
setCourseSource("own")
setS1Sub("own-search")
}}
/>
</div>
</div>
)
}
// 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 (
<div className="flex flex-col gap-8">
<StepHeader title="Basér på en eksisterende bane" description="Velg om malen skal komme fra en offisiell bane eller en av dine egne." />
<div className="grid gap-4 sm:grid-cols-2">
<SourceCard
icon={<Trophy aria-hidden="true" className="size-7 text-muted-foreground" />}
title="Offisiell bane"
description="Søk opp banen i det offisielle baneregisteret."
onClick={() => {
setTemplateMode(true)
setCourseSource("official")
setS1Sub("official-search")
}}
/>
<SourceCard
icon={<Pencil aria-hidden="true" className="size-7 text-muted-foreground" />}
title="Egen bane"
description="Søk blant dine egne baner."
onClick={() => {
setTemplateMode(true)
setCourseSource("own")
setS1Sub("own-search")
}}
/>
</div>
</div>
)
}
if (s1Sub === "official-search") {
return (
<OfficialSearch
resolving={resolvingClub}
onSelectClub={async (facility) => {
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 (
<CourseList
title={selectedClub.name}
description={templateMode ? "Velg hvilken bane du vil bruke som mal for den nye banen din." : "Velg hvilken bane du skal spille."}
courses={selectedClub.courses}
onSelect={(c) => 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 (
<OwnSearch
onSelect={async (picked) => {
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 (
<OwnCreateStep
initial={templatePrefill?.values}
forkedFromId={templatePrefill?.forkedFromId}
onCreated={(created) => 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 <Step1Fields {...props} course={course} ownGender={ownGender} />
}
return null
}
function SourceCard({ icon, title, description, onClick }: { icon: React.ReactNode; title: string; description: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex min-h-[44px] flex-col items-start gap-4 rounded-3xl border border-border bg-card p-6 text-left shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">{icon}</div>
<div className="flex flex-col gap-1.5">
<h2 className="text-xl font-bold text-foreground">{title}</h2>
<p className="text-base leading-relaxed text-muted-foreground text-pretty">{description}</p>
</div>
</button>
)
}
function NearbyClubs({ onSelectClub, resolving }: { onSelectClub: (f: ApiFacility) => void; resolving: boolean }) {
const [nearby, setNearby] = useState<ApiNearbyFacility[] | null>(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 (
<div className="flex flex-col gap-3">
<SectionTitle title="Nærmest deg" />
<ul className="flex flex-col gap-3">
{nearby.map((facility) => (
<li key={facility.slug}>
<ClubRow
name={facility.name}
location={[facility.city, facility.county].filter(Boolean).join(", ")}
distance={facility.distance_km < 1 ? `${Math.round(facility.distance_km * 1000)} m` : `${facility.distance_km.toFixed(0)} km`}
disabled={resolving}
onClick={() => onSelectClub(facility)}
/>
</li>
))}
</ul>
</div>
)
}
function OfficialSearch({ onSelectClub, resolving }: { onSelectClub: (facility: ApiFacility) => void; resolving: boolean }) {
const [query, setQuery] = useState("")
const [results, setResults] = useState<ApiFacility[] | null>(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 (
<div className="flex flex-col gap-8">
<StepHeader title="Offisiell bane" description="Søk opp klubben i baneregisteret." />
{query.trim() === "" && <NearbyClubs onSelectClub={onSelectClub} resolving={resolving} />}
<div className="flex flex-col gap-3">
<div className="relative">
<Search aria-hidden="true" className="pointer-events-none absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Søk etter banenavn…"
aria-label="Banenavn"
className="h-14 rounded-2xl pl-12 text-lg"
/>
</div>
{query.trim() !== "" &&
!searching &&
results !== null &&
(results.length === 0 ? (
<HelperNote>Ingen klubber matcher «{query}». Prøv et annet søk.</HelperNote>
) : (
<ul className="flex flex-col gap-3">
{results.map((facility) => (
<li key={facility.slug}>
<ClubRow
name={facility.name}
location={[facility.city, facility.county].filter(Boolean).join(", ")}
disabled={resolving}
onClick={() => onSelectClub(facility)}
/>
</li>
))}
</ul>
))}
{resolving && <p className="text-base text-muted-foreground">Henter baner </p>}
</div>
</div>
)
}
function ClubRow({ name, location, distance, disabled, onClick }: { name: string; location: string; distance?: string; disabled?: boolean; onClick: () => void }) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className="flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-60"
>
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted">
<Building2 aria-hidden="true" className="size-6 text-muted-foreground" />
</div>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-lg font-bold text-foreground">{name}</span>
{location && (
<span className="flex items-center gap-1.5 text-base text-muted-foreground">
<MapPin aria-hidden="true" className="size-4" />
{location}
</span>
)}
</div>
{distance && <span className="shrink-0 rounded-full bg-muted px-3 py-1 text-sm font-bold tabular-nums text-foreground">{distance}</span>}
</button>
)
}
function CourseList({
title,
description,
courses,
onSelect,
onUseAsTemplate,
}: {
title: string
description: string
courses: Course[]
onSelect: (c: Course) => void
onUseAsTemplate?: (c: Course) => void
}) {
return (
<div className="flex flex-col gap-8">
<StepHeader title={title} description={description} />
<ul className="flex flex-col gap-3">
{courses.map((course) => (
<li key={course.id} className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8">
<button
type="button"
onClick={() => onSelect(course)}
className="flex w-full items-center gap-4 text-left transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted">
<Flag aria-hidden="true" className="size-6 text-muted-foreground" />
</div>
<div className="flex min-w-0 flex-col">
<span className="text-lg font-bold text-foreground">{course.name}</span>
<span className="text-base text-muted-foreground">{course.tees.length} utslag</span>
</div>
</button>
{onUseAsTemplate && course.templateHoles && course.templateHoles.length > 0 && (
<Button
type="button"
variant="outline"
onClick={() => onUseAsTemplate(course)}
className="h-10 self-start rounded-xl text-sm font-bold"
>
Bruk som mal for egen bane
</Button>
)}
</li>
))}
{courses.length === 0 && (
<li className="rounded-2xl border border-border bg-muted/50 px-5 py-6 text-center text-base text-muted-foreground">
Ingen 18-hulls baner registrert hos dette anlegget ennå.
</li>
)}
</ul>
</div>
)
}
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<ApiPersonalCourse[]>([])
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 (
<div className="flex flex-col gap-8">
<StepHeader
title={onUseAsTemplate ? "Basér på en eksisterende bane" : "Egen bane"}
description={
onUseAsTemplate
? "Velg en av dine egne baner som utgangspunkt for den nye, eller fyll inn fra bunnen av."
: "Søk opp en personlig bane, eller opprett en ny."
}
/>
<div className="relative">
<Search aria-hidden="true" className="pointer-events-none absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Søk etter banenavn…"
aria-label="Banenavn"
className="h-14 rounded-2xl pl-12 text-lg"
/>
</div>
{results.length > 0 ? (
<ul className="flex flex-col gap-3">
{results.map((course) => (
<li key={course.id} className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8">
<button
type="button"
onClick={() => onSelect(course)}
className="flex w-full items-center gap-4 text-left transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted">
<Flag aria-hidden="true" className="size-6 text-muted-foreground" />
</div>
<div className="flex min-w-0 flex-col">
<span className="text-lg font-bold text-foreground">{course.name}</span>
<span className="text-sm text-muted-foreground">
{course.is_mine ? "Opprettet av deg" : `Opprettet av ${course.created_by_display_name}`}
</span>
</div>
</button>
{onUseAsTemplate && (
<Button
type="button"
variant="outline"
onClick={() => onUseAsTemplate(course)}
className="h-10 self-start rounded-xl text-sm font-bold"
>
Bruk som mal for egen bane
</Button>
)}
</li>
))}
</ul>
) : (
<HelperNote>{query.trim() ? `Ingen egne baner matcher «${query}».` : "Skriv for å søke, eller opprett en ny bane under."}</HelperNote>
)}
<button
type="button"
onClick={onCreate}
className="flex min-h-[44px] items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border bg-card/40 px-6 py-5 text-lg font-bold text-foreground transition-colors hover:border-primary/60 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Plus aria-hidden="true" className="size-5" />
Opprett ny bane
</button>
</div>
)
}
// --- 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<CreateTee[]>(() =>
initial ? templateToTees(initial.tees) : [{ id: `nt-${Date.now()}`, name: "", men: true, women: false }],
)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(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<CreateTee>) {
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 (
<form onSubmit={handleSubmit} className="flex flex-col gap-8">
<StepHeader title="Opprett ny bane" description="Fyll inn banedata. Du kan endre detaljene senere." />
{onSearchTemplate && !initial && (
<button
type="button"
onClick={onSearchTemplate}
className="flex min-h-11 items-center gap-1.5 self-start text-base font-semibold text-primary underline underline-offset-2 hover:no-underline"
>
Basér en eksisterende bane i stedet
</button>
)}
{error && <p className="text-base font-medium text-destructive">{error}</p>}
<div className="flex flex-col gap-2">
<Label htmlFor="course-name" className="text-base font-semibold">
Navn banen
</Label>
<Input id="course-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="F.eks. Hyttebanen" className="h-14 rounded-2xl text-lg" />
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-bold text-foreground">Hull</h2>
<p className="text-base text-muted-foreground">Sett par og stroke-indeks for hvert hull.</p>
</div>
{!indexesValid && (
<p role="alert" className="text-base font-medium text-destructive">
Hver stroke-indeks (118) brukes nøyaktig én gang.
</p>
)}
<div className="overflow-hidden rounded-2xl border border-border">
<div className="grid grid-cols-[auto_1fr_1fr] items-center gap-3 border-b border-border bg-muted/60 px-4 py-3 text-sm font-bold uppercase tracking-wide text-muted-foreground">
<span className="w-16">Hull</span>
<span>Par</span>
<span>Stroke-indeks</span>
</div>
<ul>
{holes.map((h, index) => (
<li key={h.hole} className="grid grid-cols-[auto_1fr_1fr] items-center gap-3 border-b border-border px-4 py-2.5 last:border-b-0">
<span className="w-16 text-base font-semibold text-foreground">Hull {h.hole}</span>
<select
aria-label={`Par for hull ${h.hole}`}
value={h.par}
onChange={(e) => updateHole(index, "par", e.target.value)}
className="h-12 rounded-xl border border-input bg-background px-3 text-base font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{[3, 4, 5, 6].map((p) => (
<option key={p} value={String(p)}>
Par {p}
</option>
))}
</select>
<select
aria-label={`Stroke-indeks for hull ${h.hole}`}
value={h.strokeIndex}
onChange={(e) => updateHole(index, "strokeIndex", e.target.value)}
className="h-12 rounded-xl border border-input bg-background px-3 text-base font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{Array.from({ length: 18 }, (_, i) => i + 1).map((si) => (
<option key={si} value={String(si)}>
{si}
</option>
))}
</select>
</li>
))}
</ul>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-bold text-foreground">Utslag</h2>
<p className="text-base text-muted-foreground">Legg til ett eller flere utslag med rating.</p>
</div>
{tees.map((t, index) => (
<TeeEditor key={t.id} tee={t} index={index} canRemove={tees.length > 1} onChange={(patch) => updateTee(t.id, patch)} onRemove={() => removeTee(t.id)} />
))}
<button
type="button"
onClick={addTee}
className="flex min-h-[44px] items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border bg-card/40 px-6 py-4 text-base font-bold text-foreground transition-colors hover:border-primary/60 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Plus aria-hidden="true" className="size-5" />
Legg til utslag
</button>
</div>
<div className="flex flex-col gap-3 sm:flex-row-reverse">
<Button type="submit" size="lg" disabled={!canSubmit} className="h-14 flex-1 rounded-2xl text-lg font-bold shadow-sm">
{submitting ? "Oppretter…" : "Opprett bane og fortsett"}
</Button>
<Button type="button" variant="ghost" size="lg" onClick={onCancel} className="h-14 rounded-2xl px-8 text-base font-semibold">
Avbryt
</Button>
</div>
</form>
)
}
function TeeEditor({ tee, index, canRemove, onChange, onRemove }: { tee: CreateTee; index: number; canRemove: boolean; onChange: (patch: Partial<CreateTee>) => void; onRemove: () => void }) {
return (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8">
<div className="flex items-end gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor={`tee-name-${tee.id}`} className="text-base font-semibold">
Navn utslag {index + 1}
</Label>
<Input id={`tee-name-${tee.id}`} value={tee.name} onChange={(e) => onChange({ name: e.target.value })} placeholder="F.eks. Gul" className="h-12 rounded-xl text-base" />
</div>
{canRemove && (
<Button type="button" variant="outline" onClick={onRemove} className="h-12 rounded-xl px-4 text-base font-semibold">
Fjern
</Button>
)}
</div>
<RatingToggle
label="Herre-rating"
enabled={tee.men}
rating={tee.menRating}
onToggle={(men) => onChange({ men, menRating: men ? tee.menRating ?? { courseRating: "", slope: "", par: "" } : tee.menRating })}
onRatingChange={(menRating) => onChange({ menRating })}
idPrefix={`men-${tee.id}`}
/>
<RatingToggle
label="Dame-rating"
enabled={tee.women}
rating={tee.womenRating}
onToggle={(women) => onChange({ women, womenRating: women ? tee.womenRating ?? { courseRating: "", slope: "", par: "" } : tee.womenRating })}
onRatingChange={(womenRating) => onChange({ womenRating })}
idPrefix={`women-${tee.id}`}
/>
</div>
)
}
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 (
<div className="flex flex-col gap-3 rounded-xl border border-border bg-background/60 p-4">
<div className="flex items-center justify-between gap-4">
<Label htmlFor={idPrefix} className="text-base font-semibold text-foreground">
{label}
</Label>
<Switch id={idPrefix} checked={enabled} onCheckedChange={onToggle} />
</div>
{enabled && (
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${idPrefix}-cr`} className="text-sm font-medium text-muted-foreground">
Course Rating
</Label>
<Input id={`${idPrefix}-cr`} inputMode="decimal" value={current.courseRating} onChange={(e) => update("courseRating", e.target.value)} placeholder="71,2" className="h-12 rounded-xl text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${idPrefix}-slope`} className="text-sm font-medium text-muted-foreground">
Slope
</Label>
<Input id={`${idPrefix}-slope`} inputMode="numeric" value={current.slope} onChange={(e) => update("slope", e.target.value)} placeholder="132" className="h-12 rounded-xl text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`${idPrefix}-par`} className="text-sm font-medium text-muted-foreground">
Par
</Label>
<Input id={`${idPrefix}-par`} inputMode="numeric" value={current.par} onChange={(e) => update("par", e.target.value)} placeholder="72" className="h-12 rounded-xl text-base" />
</div>
</div>
)}
</div>
)
}
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<string, unknown>) {
const compatibleTees = course.tees.filter((t) => t.genders.includes(ownGender))
return (
<div className="flex flex-col gap-8">
<div className="flex items-center gap-4 rounded-2xl border border-primary/30 bg-primary/5 p-4">
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Flag aria-hidden="true" className="size-6 text-primary" />
</div>
<div className="flex min-w-0 flex-col">
<span className="text-sm font-bold uppercase tracking-wide text-primary">Valgt bane</span>
<span className="truncate text-lg font-bold text-foreground">{course.name}</span>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="round-name" className="text-base font-semibold">
Navn runden (valgfritt)
</Label>
<Input id="round-name" value={roundName} onChange={(e) => setRoundName(e.target.value)} placeholder={course.name} maxLength={200} className="h-14 rounded-2xl text-lg" />
</div>
{compatibleTees.length === 0 ? (
<p role="alert" className="text-base font-medium text-destructive">
Denne banen har ingen registrert rating for ditt kjønn noe utslag -- HCP-sporing er ikke mulig for denne runden.
</p>
) : (
<div className="flex flex-col gap-3">
<SectionTitle title="Utslag" />
<div className="flex flex-wrap gap-2">
{compatibleTees.map((t) => (
<Pill key={t.id} selected={t.id === ownerTeeId} onClick={() => setOwnerTeeId(t.id)}>
{t.name}
</Pill>
))}
</div>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="date" className="text-base font-semibold">
Dato
</Label>
<Input id="date" type="date" value={date} onChange={(e) => setDate(e.target.value)} className="h-14 rounded-2xl text-lg" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="time" className="text-base font-semibold">
Utslagstid (valgfritt)
</Label>
<Input id="time" type="time" value={time} onChange={(e) => setTime(e.target.value)} className="h-14 rounded-2xl text-lg" />
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="start-hole" className="text-base font-semibold">
Starthull
</Label>
<select
id="start-hole"
value={startHole}
onChange={(e) => setStartHole(e.target.value)}
className="h-14 rounded-2xl border border-input bg-background px-4 text-lg font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{Array.from({ length: 18 }, (_, i) => i + 1).map((h) => (
<option key={h} value={String(h)}>
Hull {h}
</option>
))}
</select>
</div>
</div>
)
}
// --- 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 (
<div className="flex flex-col gap-8">
<StepHeader title="Spilleform" />
<div className="flex flex-col gap-3">
<SectionTitle title="Statistikk for deg selv" />
<div className="flex flex-col gap-3">
{STAT_LEVELS.map((s) => (
<ChoiceCard key={s.id} selected={statLevel === s.id} onClick={() => setStatLevel(s.id)} title={s.label} description={s.desc} />
))}
</div>
</div>
<div className="flex flex-col gap-3">
<SectionTitle title="Spilleform" />
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
{FORMATS.map((f) => {
const selected = f.id === format
return (
<button
key={f.id}
type="button"
onClick={() => setFormat(f.id)}
aria-pressed={selected}
className={cn(
"flex min-h-[56px] items-center justify-center rounded-2xl border-2 px-3 py-2.5 text-center text-base font-bold leading-tight transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
selected ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:border-primary/50 hover:bg-accent/40",
)}
>
{f.label}
</button>
)
})}
</div>
<p className="rounded-2xl bg-muted/60 px-4 py-3 text-base leading-relaxed text-foreground text-pretty">
<span className="font-bold">{selectedFormat.label}:</span> {selectedFormat.desc}
</p>
</div>
<FormatConfig
format={format}
matchExcludeHcp={matchExcludeHcp}
setMatchExcludeHcp={setMatchExcludeHcp}
skinsScoring={skinsScoring}
setSkinsScoring={setSkinsScoring}
skinsTie={skinsTie}
setSkinsTie={setSkinsTie}
bbbBonus={bbbBonus}
setBbbBonus={setBbbBonus}
shambleBest={shambleBest}
setShambleBest={setShambleBest}
soloAllowance={soloAllowance}
setSoloAllowance={setSoloAllowance}
/>
{!SCRAMBLE_SOLO_VARIANTS.has(format) && (
<div className="overflow-hidden rounded-2xl border border-border bg-muted/30">
<button
type="button"
onClick={() => setAdvOpen(!advOpen)}
aria-expanded={advOpen}
className="flex min-h-[56px] w-full items-center gap-2.5 px-4 py-3 text-left transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<Sparkles aria-hidden="true" className="size-5 text-muted-foreground" />
<span className="flex-1 text-base font-bold text-foreground">Avanserte handicap-innstillinger</span>
<ChevronDown aria-hidden="true" className={cn("size-5 text-muted-foreground transition-transform", advOpen && "rotate-180")} />
</button>
{advOpen && (
<div className="flex flex-col gap-5 border-t border-border px-4 py-5">
<ToggleRow label="Bruk handicap" checked={useHcp} onChange={setUseHcp} />
{useHcp && !ORDINARY_FORMATS.has(format) && (
<ToggleRow label="Bruk course handicap-justering" checked={useCourseHcp} onChange={setUseCourseHcp} />
)}
{useHcp && twoSided && (
<div className="flex flex-col gap-1.5">
<ToggleRow label="Bruk matchplay-handicap" checked={useMatchplayHcp} onChange={setUseMatchplayHcp} />
<p className="px-1 text-sm leading-relaxed text-muted-foreground text-pretty">
Fordeler slagene som en differanse mellom sidene i stedet for hvert lags fulle handicap slås av
spilles matchen med brutto/netto per side.
</p>
</div>
)}
{useHcp && (
<div className="flex flex-col gap-2">
<Label htmlFor="hcp-percent" className="text-base font-semibold">
HCP-prosent (valgfritt)
</Label>
<Input id="hcp-percent" inputMode="decimal" value={hcpPercent} onChange={(e) => setHcpPercent(e.target.value)} placeholder="Standard for format" className="h-14 rounded-2xl text-lg" />
</div>
)}
</div>
)}
</div>
)}
<div className="flex flex-col gap-3">
<SectionTitle title="Antall hull" />
<div className="grid grid-cols-2 gap-3">
{([9, 18] as const).map((h) => {
const selected = holes === h
return (
<button
key={h}
type="button"
onClick={() => setHoles(h)}
aria-pressed={selected}
className={cn(
"min-h-[64px] rounded-2xl border-2 text-xl font-extrabold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
selected ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:border-primary/50 hover:bg-accent/40",
)}
>
{h} hull
</button>
)
})}
</div>
</div>
</div>
)
}
function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<div className="flex min-h-[44px] items-center justify-between gap-4">
<Label className="text-base font-semibold text-foreground">{label}</Label>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
)
}
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 (
<CheckRow
checked={matchExcludeHcp}
onChange={setMatchExcludeHcp}
label="Ekskluder denne runden fra mitt faktiske HCP"
helper="Anbefalt for match -- konsederte hull gir ofte et upålitelig grunnlag for HCP-beregning. Du kan endre dette senere."
/>
)
}
if (format === "skins") {
return (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold text-foreground">Poengberegning</Label>
<SegChoice
label="Poengberegning"
value={skinsScoring}
onChange={setSkinsScoring}
options={[
{ value: "netto", label: "Netto (m/HCP)" },
{ value: "brutto", label: "Brutto" },
]}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold text-foreground">Ved uavgjort hull</Label>
<SegChoice
label="Ved uavgjort hull"
value={skinsTie}
onChange={setSkinsTie}
options={[
{ value: "roll", label: "Rullerer over" },
{ value: "split", label: "Deles likt" },
]}
/>
</div>
<HelperNote>Skins krever minst 3 spillere i runden -- legg til medspillere etter at runden er opprettet.</HelperNote>
</div>
)
}
if (format === "bbb") {
return (
<CheckRow
checked={bbbBonus}
onChange={setBbbBonus}
label={'Bonuspoeng ved "sveip"'}
helper="Ekstra poeng til en spiller som vinner alle tre kategoriene (bingo, bango og bongo) på samme hull."
/>
)
}
if (format === "shamble") {
return (
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold text-foreground">Beste N av laget teller per hull</Label>
<NumberButtons label="Beste N av laget teller per hull" values={[1, 2, 3, 4]} value={shambleBest} onChange={setShambleBest} />
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
2-4 spillere laget, alle slår ut og velger beste utslag -- deretter spiller alle egen ball inn. Legg til
medspillere etter opprettelse.
</p>
</div>
)
}
if (format === "moneyball") {
return <HelperNote>Nøyaktig 4 spillere -- du setter opp hvem som er "money ball" hvilket hull i neste steg.</HelperNote>
}
if (format === "copenhagen") {
return <HelperNote>Nøyaktig 3 spillere.</HelperNote>
}
if (SCRAMBLE_SOLO_VARIANTS.has(format)) {
return <SoloAllowanceControl value={soloAllowance} onChange={setSoloAllowance} />
}
if (TWO_SIDED.has(format)) {
return <HelperNote>Du setter opp lagene i neste steg.</HelperNote>
}
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 (
<div className="flex flex-col gap-2">
<Label htmlFor="solo-allowance" className="text-base font-semibold text-foreground">
Individualspillerens handicap-andel
</Label>
<div className="flex items-stretch gap-2">
<button
type="button"
onClick={() => onChange(clamp(value - STEP))}
disabled={value <= MIN}
aria-label="Reduser handicap-andel"
className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-border bg-card text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40"
>
<Minus aria-hidden="true" className="size-6" />
</button>
<div className="relative flex-1">
<Input
id="solo-allowance"
inputMode="numeric"
value={String(value)}
onChange={(e) => {
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"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 text-lg font-bold text-muted-foreground"
>
%
</span>
</div>
<button
type="button"
onClick={() => onChange(clamp(value + STEP))}
disabled={value >= MAX}
aria-label="Øk handicap-andel"
className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-border bg-card text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40"
>
<Plus aria-hidden="true" className="size-6" />
</button>
</div>
<p id="solo-allowance-help" className="px-1 text-sm leading-relaxed text-muted-foreground text-pretty">
Hvor stor andel av egen handicap individualspilleren spiller med. Standard 100 % (full
handicap).
</p>
</div>
)
}
// --- Step 3: Spillere ---------------------------------------------------------
function Step3({
course,
players,
setPlayers,
roundStatLevel,
}: {
course: Course
players: Player[]
setPlayers: React.Dispatch<React.SetStateAction<Player[]>>
roundStatLevel: StatLevel
}) {
const [editingId, setEditingId] = useState<string | null>(null)
const [adding, setAdding] = useState(false)
function updatePlayer(id: string, patch: Partial<Player>) {
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 (
<div className="flex flex-col gap-6">
<StepHeader title="Spillere" />
<ul className="flex flex-col gap-3">
{players.map((p) => (
<li key={p.id}>
<PlayerCard player={p} course={course} editing={editingId === p.id} onToggleEdit={() => setEditingId(editingId === p.id ? null : p.id)} onRemove={() => removePlayer(p.id)} onChange={(patch) => updatePlayer(p.id, patch)} />
</li>
))}
</ul>
{adding ? (
<AddPlayerPanel onCancel={() => setAdding(false)} onAdd={addPlayer} roundStatLevel={roundStatLevel} existingNames={players.map((p) => p.name)} />
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="flex min-h-[44px] items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border bg-card/40 px-6 py-5 text-lg font-bold text-foreground transition-colors hover:border-primary/60 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Plus aria-hidden="true" className="size-5" />
Medspiller
</button>
)}
</div>
)
}
// 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<Player>) => 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 (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor={`first-name-${player.id}`} className="text-base font-semibold">
Fornavn
</Label>
<Input
id={`first-name-${player.id}`}
value={firstName}
onChange={(e) => {
setFirstName(e.target.value)
commit(e.target.value, lastName)
}}
className="h-12 rounded-xl text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`last-name-${player.id}`} className="text-base font-semibold">
Etternavn <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id={`last-name-${player.id}`}
value={lastName}
onChange={(e) => {
setLastName(e.target.value)
commit(firstName, e.target.value)
}}
className="h-12 rounded-xl text-base"
/>
</div>
</div>
)
}
function PlayerCard({
player,
course,
editing,
onToggleEdit,
onRemove,
onChange,
}: {
player: Player
course: Course
editing: boolean
onToggleEdit: () => void
onRemove: () => void
onChange: (patch: Partial<Player>) => 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 (
<div className="rounded-2xl border border-border bg-card shadow-md shadow-black/8">
<div className="flex items-center gap-3 p-4">
<Avatar name={player.name} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-lg font-bold text-foreground">{player.name}</span>
{player.isOwner && (
<>
<Badge>Deg</Badge>
<Badge>Eier</Badge>
</>
)}
{player.isGuest && <Badge tone="muted">Gjest</Badge>}
</div>
<span className="text-base text-muted-foreground">
Utslag: {teeName(course, player.teeId)} · HCP: {player.hcp || "—"}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" variant="outline" onClick={onToggleEdit} className="h-11 rounded-xl px-4 text-base font-semibold">
{editing ? "Lukk" : "Rediger"}
</Button>
{!player.isOwner && (
<button
type="button"
onClick={onRemove}
aria-label={`Fjern ${player.name}`}
className="flex size-11 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden="true" className="size-5" />
</button>
)}
</div>
</div>
{editing && (
<div className="flex flex-col gap-5 border-t border-border p-4">
{player.isGuest && (
<>
<GuestNameFields player={player} onChange={onChange} />
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">Kjønn</Label>
<SegChoice<Gender>
label="Kjønn"
value={player.gender}
onChange={(g) => onChange({ gender: g })}
options={[
{ value: "mann", label: "Mann" },
{ value: "kvinne", label: "Kvinne" },
{ value: "annet", label: "Annet" },
]}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`email-${player.id}`} className="text-base font-semibold">
E-post (valgfritt)
</Label>
<Input id={`email-${player.id}`} type="email" value={player.email ?? ""} onChange={(e) => onChange({ email: e.target.value })} className="h-12 rounded-xl text-base" />
</div>
</>
)}
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">Utslag</Label>
<div className="flex flex-wrap gap-2">
{teeOptions.map((t) => (
<Pill key={t.id} selected={t.id === player.teeId} onClick={() => onChange({ teeId: t.id })}>
{t.name}
</Pill>
))}
</div>
{isLinked && (
<p className="text-sm text-muted-foreground">
Kjønn er ikke kjent før spilleren er lagt til runden -- velg utslag med omhu.
</p>
)}
</div>
{player.isGuest ? (
<div className="flex flex-col gap-2">
<Label htmlFor={`hcp-${player.id}`} className="text-base font-semibold">
HCP
</Label>
<Input id={`hcp-${player.id}`} inputMode="decimal" value={player.hcp} onChange={(e) => onChange({ hcp: e.target.value })} placeholder="F.eks. 14,2" className="h-12 rounded-xl text-base" />
</div>
) : (
<div className="flex flex-col gap-1">
<Label className="text-base font-semibold">HCP</Label>
<p className="text-base text-muted-foreground">
{player.hcp ? `Hentes fra profilen (HCP ${player.hcp})` : "Hentes fra profilen når spilleren legges til."}
</p>
</div>
)}
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">Statistikk</Label>
<SegChoice<StatLevel>
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" },
]}
/>
</div>
</div>
)}
</div>
)
}
function Badge({ children, tone = "primary" }: { children: React.ReactNode; tone?: "primary" | "muted" }) {
return <span className={cn("rounded-full px-2.5 py-0.5 text-sm font-bold", tone === "primary" ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground")}>{children}</span>
}
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<PersonMatch[]>([])
const [searching, setSearching] = useState(false)
const [gFirstName, setGFirstName] = useState("")
const [gLastName, setGLastName] = useState("")
const [gEmail, setGEmail] = useState("")
const [gGender, setGGender] = useState<Gender>("mann")
const [gHcp, setGHcp] = useState("")
const [gStat, setGStat] = useState<StatLevel>(roundStatLevel)
const [knownGuest, setKnownGuest] = useState<KnownGuest | null>(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 (
<div className="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8">
<div className="flex items-center justify-between gap-3">
<h3 className="text-lg font-bold text-foreground">{mode === "search" ? "Søk etter medspiller" : "Legg til gjest"}</h3>
<button type="button" onClick={onCancel} aria-label="Lukk" className="flex size-11 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<X aria-hidden="true" className="size-5" />
</button>
</div>
{mode === "search" ? (
<>
<div className="relative">
<Search aria-hidden="true" className="pointer-events-none absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Navn på medspilleren" aria-label="Navn på medspilleren" className="h-14 rounded-2xl pl-12 text-lg" />
</div>
{query.trim().length > 0 && query.trim().length < 2 && <p className="text-sm text-muted-foreground">Skriv minst 2 tegn for å søke.</p>}
{results.length > 0 ? (
<ul className="flex flex-col gap-2">
{results.map((r) => (
<li key={r.id}>
<button
type="button"
onClick={() =>
onAdd({
id: newId("player"),
userId: r.id,
name: `${r.first_name} ${r.last_name}`,
gender: "mann",
teeId: "",
hcp: r.handicap_index !== null ? r.handicap_index.toFixed(1).replace(".", ",") : "",
statLevel: roundStatLevel,
})
}
className="flex w-full items-center gap-3 rounded-2xl border border-border bg-card p-3 text-left transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Avatar name={`${r.first_name} ${r.last_name}`} />
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-base font-bold text-foreground">
{r.first_name} {r.last_name}
</span>
<span className="text-sm text-muted-foreground">
{r.home_club}
{r.home_club && r.handicap_index !== null && " · "}
{r.handicap_index !== null && `HCP ${r.handicap_index.toFixed(1).replace(".", ",")}`}
</span>
</div>
<Plus aria-hidden="true" className="size-5 text-primary" />
</button>
</li>
))}
</ul>
) : (
!searching && query.trim().length >= 2 && <p className="text-base text-muted-foreground">Fant ingen med det navnet.</p>
)}
<button type="button" onClick={switchToGuestMode} className="min-h-[44px] text-left text-base font-bold text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
Legg til uten konto (gjest)
</button>
</>
) : (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label htmlFor="guest-first-name" className="text-base font-semibold">
Fornavn
</Label>
<Input
id="guest-first-name"
autoFocus
value={gFirstName}
onChange={(e) => setGFirstName(e.target.value)}
placeholder="Fornavn"
className="h-14 rounded-2xl text-lg"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="guest-last-name" className="text-base font-semibold">
Etternavn <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id="guest-last-name"
value={gLastName}
onChange={(e) => setGLastName(e.target.value)}
placeholder="Etternavn"
className="h-14 rounded-2xl text-lg"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="guest-email" className="text-base font-semibold">
E-post <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id="guest-email"
type="email"
value={gEmail}
onChange={(e) => setGEmail(e.target.value)}
placeholder="For scorekort og statistikk etter runden"
className="h-14 rounded-2xl text-lg"
/>
{knownGuest && !knownGuestDismissed && (
<div className="flex flex-col gap-2 rounded-2xl border border-primary/40 bg-primary/5 p-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-foreground">
Vi fant <span className="font-bold">{knownGuest.guest_first_name} {knownGuest.guest_last_name}</span>{" "}
fra en tidligere runde du satte opp.
</p>
<Button type="button" size="sm" onClick={useKnownGuest} className="h-10 shrink-0 rounded-xl px-4 text-sm font-bold">
Bruk disse opplysningene
</Button>
</div>
)}
</div>
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">Kjønn</Label>
<SegChoice<Gender>
label="Kjønn"
value={gGender}
onChange={setGGender}
options={[
{ value: "mann", label: "Mann" },
{ value: "kvinne", label: "Kvinne" },
{ value: "annet", label: "Annet" },
]}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="guest-hcp" className="text-base font-semibold">
HCP (valgfritt)
</Label>
<Input id="guest-hcp" inputMode="decimal" value={gHcp} onChange={(e) => setGHcp(e.target.value)} placeholder="F.eks. 18,0" className="h-14 rounded-2xl text-lg" />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">Statistikk for denne spilleren</Label>
<SegChoice<StatLevel>
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" },
]}
/>
</div>
<div className="flex flex-col gap-3 sm:flex-row-reverse">
<Button
type="button"
size="lg"
disabled={gFirstName.trim() === ""}
onClick={() =>
onAdd({
id: newId("guest"),
name: `${gFirstName.trim()} ${gLastName.trim()}`.trim(),
isGuest: true,
gender: gGender,
email: gEmail.trim() || undefined,
teeId: "",
hcp: gHcp,
statLevel: gStat,
})
}
className="h-14 flex-1 rounded-2xl text-base font-bold"
>
Legg til gjest
</Button>
<Button type="button" variant="ghost" size="lg" onClick={() => setMode("search")} className="h-14 rounded-2xl px-6 text-base font-semibold">
Tilbake til søk
</Button>
</div>
</>
)}
</div>
)
}
// --- Step 4: Lag og rekkefølge ------------------------------------------------
function Step4({
format,
players,
setPlayers,
sides,
setSides,
roundStatLevel,
}: {
format: FormatId
players: Player[]
setPlayers: React.Dispatch<React.SetStateAction<Player[]>>
sides: Side[]
setSides: React.Dispatch<React.SetStateAction<Side[]>>
roundStatLevel: StatLevel
}) {
if (SCRAMBLE_SOLO_VARIANTS.has(format)) {
return <ScrambleVsSoloSides players={players} setPlayers={setPlayers} roundStatLevel={roundStatLevel} />
}
if (format === "moneyball") {
return (
<div className="flex flex-col gap-6">
<StepHeader title="Lag og rekkefølge" />
<HelperNote>Tildel hver spiller en plass (0-3) -- avgjør hvem som er "money ball" hvilket hull.</HelperNote>
<ul className="flex flex-col gap-3">
{players.map((p) => (
<li key={p.id} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex items-center gap-3">
<Avatar name={p.name} />
<span className="text-lg font-bold text-foreground">{p.name}</span>
</div>
<NumberButtons
label={`Plass for ${p.name}`}
values={[0, 1, 2, 3]}
value={p.moneyBallSlot}
onChange={(v) => setPlayers((prev) => prev.map((x) => (x.id === p.id ? { ...x, moneyBallSlot: v } : x)))}
/>
</li>
))}
</ul>
</div>
)
}
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 (
<div className="flex flex-col gap-6">
<StepHeader title="Lag og rekkefølge" description="Sett opp de to sidene." />
<div className="grid gap-3 sm:grid-cols-2">
{[0, 1].map((slot) => {
const side = sides[slot]
if (!side) {
return <CreateSideCard key={slot} slot={slot} onCreate={createSide} disabled={sides.length < slot} />
}
const sidePlayers = players.filter((p) => p.sideId === side.id)
const otherSide = sides.find((s) => s.id !== side.id)
return (
<div key={side.id} className="flex flex-col gap-3 rounded-2xl border-2 border-primary/40 bg-primary/5 p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-lg font-bold text-foreground">{side.label}</span>
<button type="button" onClick={() => deleteSide(side.id)} aria-label={`Slett ${side.label}`} className="flex size-10 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<X aria-hidden="true" className="size-5" />
</button>
</div>
{sidePlayers.length === 0 ? (
<p className="text-base text-muted-foreground">Ingen spillere ennå.</p>
) : (
<ul className="flex flex-col gap-2">
{sidePlayers.map((p) => (
<li key={p.id} className="flex items-center gap-2 rounded-xl border border-border bg-card p-2.5">
<Avatar name={p.name} />
<span className="flex-1 truncate text-base font-semibold text-foreground">{p.name}</span>
{otherSide && (
<button type="button" onClick={() => assign(p.id, otherSide.id)} className="min-h-[40px] rounded-lg px-2.5 text-sm font-bold text-primary hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
annen side
</button>
)}
<button type="button" onClick={() => assign(p.id, undefined)} className="min-h-[40px] rounded-lg px-2.5 text-sm font-bold text-muted-foreground hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
Fjern fra side
</button>
</li>
))}
</ul>
)}
</div>
)
})}
</div>
{sides.length > 0 && unassigned.length > 0 && (
<div className="flex flex-col gap-3">
<SectionTitle title="Ikke tildelt" />
<ul className="flex flex-col gap-2">
{unassigned.map((p) => (
<li key={p.id} className="flex items-center gap-3 rounded-2xl border border-border bg-card p-3 shadow-md shadow-black/8">
<Avatar name={p.name} />
<span className="flex-1 truncate text-base font-bold text-foreground">{p.name}</span>
{sides.map((s, i) => (
<button key={s.id} type="button" onClick={() => assign(p.id, s.id)} className="min-h-[44px] rounded-xl border border-border px-3 text-sm font-bold text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{s.label || `Side ${i === 0 ? "A" : "B"}`}
</button>
))}
</li>
))}
</ul>
</div>
)}
</div>
)
}
// 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<React.SetStateAction<Player[]>>
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 (
<div className="flex flex-col gap-6">
<StepHeader
title="Lag og rekkefølge"
description="Ett lag med delt ball mot én enkeltspiller — sidene er ulike."
/>
{/* Slot 1: Laget (åpen liste) */}
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<span className="flex size-10 items-center justify-center rounded-xl bg-primary/15 text-primary">
<Users aria-hidden="true" className="size-5" />
</span>
<h2 className="text-xl font-bold text-foreground">Laget</h2>
</div>
<span className="rounded-full bg-muted px-3 py-1 text-sm font-bold tabular-nums text-muted-foreground">
{team.length} {team.length === 1 ? "spiller" : "spillere"}
</span>
</div>
<div
className={cn(
"flex items-center gap-2 rounded-xl border px-3 py-2 text-sm font-semibold",
teamOk
? "border-primary/40 bg-primary/10 text-primary"
: "border-border bg-muted/50 text-muted-foreground",
)}
aria-live="polite"
>
{teamOk ? (
<Check aria-hidden="true" className="size-4 shrink-0" />
) : (
<Users aria-hidden="true" className="size-4 shrink-0" />
)}
<span>{teamOk ? "Minst 2 spillere lagt til" : "Minst 2 spillere"}</span>
</div>
{team.length > 0 && (
<ul className="flex flex-col gap-2">
{team.map((p) => (
<MemberRow
key={p.id}
player={p}
onRemove={() => assign(p.id, undefined)}
removeLabel={`Fjern ${p.name} fra laget`}
/>
))}
</ul>
)}
{addingTo === "team" ? (
<AddPlayerPanel
onCancel={() => setAddingTo(null)}
onAdd={(p) => addTo("team", p)}
roundStatLevel={roundStatLevel}
existingNames={existingNames}
/>
) : (
<AddAffordance label="Legg til spiller" onClick={() => setAddingTo("team")} />
)}
</section>
{/* Slot 2: Individuell motstander (begrenset til 1) */}
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8">
<div className="flex items-center gap-2.5">
<span className="flex size-10 items-center justify-center rounded-xl bg-brand-orange/15 text-brand-orange">
<User aria-hidden="true" className="size-5" />
</span>
<div className="flex flex-col">
<h2 className="text-xl font-bold text-foreground">Individuell motstander</h2>
<p className="text-sm text-muted-foreground">Nøyaktig én spiller</p>
</div>
</div>
{soloFull ? (
<ul className="flex flex-col gap-2">
{solo.map((p) => (
<MemberRow
key={p.id}
player={p}
onRemove={() => assign(p.id, undefined)}
removeLabel={`Fjern ${p.name} som motstander`}
/>
))}
</ul>
) : addingTo === "solo" ? (
<AddPlayerPanel
onCancel={() => setAddingTo(null)}
onAdd={(p) => addTo("solo", p)}
roundStatLevel={roundStatLevel}
existingNames={existingNames}
/>
) : (
<AddAffordance label="Legg til motstander" onClick={() => setAddingTo("solo")} />
)}
</section>
{/* Spillere i runden som ennå ikke er plassert på noen side */}
{unplaced.length > 0 && (
<div className="flex flex-col gap-3">
<SectionTitle title="Ikke plassert" hint="Spillere fra runden som ennå ikke er tildelt en side." />
<ul className="flex flex-col gap-2">
{unplaced.map((p) => (
<li
key={p.id}
className="flex flex-wrap items-center gap-2 rounded-2xl border border-border bg-card p-3 shadow-sm shadow-black/5"
>
<Avatar name={p.name} />
<span className="min-w-0 flex-1 truncate text-base font-bold text-foreground">{p.name}</span>
<button
type="button"
onClick={() => assign(p.id, "team")}
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-border px-3 text-sm font-bold text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Users aria-hidden="true" className="size-4" />
Til laget
</button>
{!soloFull && (
<button
type="button"
onClick={() => assign(p.id, "solo")}
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-border px-3 text-sm font-bold text-foreground transition-colors hover:border-brand-orange/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<User aria-hidden="true" className="size-4" />
Som motstander
</button>
)}
</li>
))}
</ul>
</div>
)}
</div>
)
}
function MemberRow({
player,
onRemove,
removeLabel,
}: {
player: Player
onRemove: () => void
removeLabel: string
}) {
return (
<li className="flex items-center gap-3 rounded-2xl border border-border bg-card p-3">
<Avatar name={player.name} />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-base font-bold text-foreground">{player.name}</span>
{player.hcp.trim() !== "" && (
<span className="text-sm text-muted-foreground">HCP {player.hcp}</span>
)}
</div>
<button
type="button"
onClick={onRemove}
aria-label={removeLabel}
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<X aria-hidden="true" className="size-5" />
</button>
</li>
)
}
function AddAffordance({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex min-h-11 items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-card/40 px-6 py-4 text-base font-bold text-foreground transition-colors hover:border-primary/60 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<UserPlus aria-hidden="true" className="size-5" />
{label}
</button>
)
}
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 (
<div className={cn("flex flex-col gap-3 rounded-2xl border-2 border-dashed border-border bg-card/40 p-4", disabled && "opacity-50")}>
<Label htmlFor={`side-label-${slot}`} className="text-base font-semibold">
Navn side (valgfritt)
</Label>
<Input id={`side-label-${slot}`} value={label} onChange={(e) => 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" />
<Button type="button" variant="outline" disabled={disabled} onClick={() => onCreate(label || fallback)} className="h-12 rounded-xl text-base font-bold">
<Plus aria-hidden="true" className="mr-1.5 size-5" />
Opprett side
</Button>
</div>
)
}
// --- Step 5: Deling ------------------------------------------------------------
function Step5({
visibility,
setVisibility,
categories,
setCategories,
}: {
visibility: Visibility
setVisibility: (v: Visibility) => void
categories: Set<string>
setCategories: React.Dispatch<React.SetStateAction<Set<string>>>
}) {
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 (
<div className="flex flex-col gap-6">
<StepHeader title="Deling" />
<div className="flex flex-col gap-3">
<SectionTitle title="Synlighet" />
<div className="flex flex-col gap-3">
<ChoiceCard selected={visibility === "privat"} onClick={() => setVisibility("privat")} title="Privat" description="Kun deg og faktiske medspillere" icon={<Lock aria-hidden="true" className="size-5" />} />
<ChoiceCard selected={visibility === "venner"} onClick={() => setVisibility("venner")} title="Venner" description="Valgte venne-kategorier kan følge live" icon={<Users aria-hidden="true" className="size-5" />} />
<ChoiceCard selected={visibility === "offentlig"} onClick={() => setVisibility("offentlig")} title="Offentlig" description="Alle kan følge live, ingen innlogging" icon={<Globe aria-hidden="true" className="size-5" />} />
</div>
</div>
{visibility === "venner" && (
<div className="flex flex-col gap-3">
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
Alle er forhåndsvalgt velg heller bort det du ikke vil skal se den. Bruk "Ingen"/"Alle"
under for å starte nytt, og finjuster deretter med enkeltkategoriene.
</p>
<div className="flex gap-2" role="group" aria-label="Velg alle eller ingen kategorier">
<Pill selected={categories.size === 0} onClick={() => setCategories(new Set())}>
Ingen
</Pill>
<Pill
selected={categories.size === CATEGORY_OPTIONS.length}
onClick={() => setCategories(new Set(CATEGORY_OPTIONS.map((c) => c.code)))}
>
Alle
</Pill>
</div>
<div className="flex flex-wrap gap-2">
{CATEGORY_OPTIONS.map(({ code, label }) => (
<Pill key={code} selected={categories.has(code)} onClick={() => toggleCategory(code)}>
{label}
</Pill>
))}
</div>
{categories.size === 0 && (
<p className="rounded-2xl border border-destructive/40 bg-destructive/10 px-4 py-3 text-base font-semibold text-destructive">
Ingen kategori valgt ennå -- ingen venner kan se runden før du velger minst én.
</p>
)}
</div>
)}
</div>
)
}