teecup/frontend/components/new-round.tsx

2546 lines
102 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,
Pencil,
Plus,
Search,
Sparkles,
Trophy,
Users,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
// --- 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[] }
type OfficialClub = { id: string; name: string; location: string; courses: Course[] }
// Hvilken bane-kilde+id som faktisk skal sendes til /rounds -- Course over
// bærer kun det UI-et trenger, ikke dette.
type CourseMeta =
| { source: "teeoff"; facilitySlug: string; teeoffCourseId: number }
| { source: "custom"; personalCourseId: string }
type StatLevel = "kun-slag" | "slag-putter" | "all"
type Gender = "mann" | "kvinne" | "annet"
type Visibility = "privat" | "venner" | "offentlig"
type Player = {
id: string
name: string
isOwner?: boolean
isGuest?: boolean
// Satt når spilleren ble funnet via /people/search (ekte konto) -- avgjør
// om vi sender user_id eller guest_name+gender ved innsending, og om
// navn/kjønn/e-post kan redigeres i veiviseren (kun for gjester).
userId?: string
gender: Gender
email?: string
// "" = ikke valgt ennå -- faller tilbake til rundens eget utslag ved
// innsending (samme mønster som ParticipantCreate.tee_name i appen for
// øvrig).
teeId: string
hcp: string
statLevel: StatLevel
sideId?: string
moneyBallSlot?: number
}
type Side = { id: string; label: string }
type S1Sub = "source" | "official-search" | "official-courses" | "own-search" | "own-create" | "fields"
// --- Formater ------------------------------------------------------------------
type FormatId =
| "slagspill"
| "stableford"
| "match"
| "skins"
| "fourball"
| "foursome"
| "greensome"
| "scramble2"
| "scramble4"
| "chapman"
| "copenhagen"
| "bbb"
| "flag"
| "shamble"
| "moneyball"
| "highlowhigh"
const FORMATS: { id: FormatId; label: string; desc: string }[] = [
{ id: "slagspill", label: "Slagspill", desc: "Vanlig individuell runde." },
{ id: "stableford", label: "Stableford", desc: "Poeng per hull i stedet for slag -- kan plukke opp ved 0 poeng." },
{ id: "match", label: "Match", desc: "To sider, hull mot hull." },
{ id: "skins", label: "Skins", desc: "Hvert hull er en egen premie -- minst 3 spillere." },
{ id: "fourball", label: "Fourball", desc: "To mot to, beste ball per side." },
{ id: "foursome", label: "Foursome", desc: "To mot to, én delt ball, annenhver slår." },
{ id: "greensome", label: "Greensome", desc: "To mot to, begge slår ut, velger så én ball." },
{ id: "scramble2", label: "Scramble (2)", desc: "To mot to, velger beste slag hver gang." },
{ id: "scramble4", label: "Scramble (4)", desc: "Fire mot fire, velger beste slag hver gang." },
{ id: "chapman", label: "Chapman", desc: "To mot to, bytter utslag, velger så én ball -- som foursome fra andre slag." },
{ id: "copenhagen", label: "Københavner", desc: "Nøyaktig 3 spillere -- 6 poeng deles per hull etter rangering." },
{ id: "bbb", label: "Bingo Bango Bongo", desc: "Poeng for først på green, nærmest hull, først i hull." },
{ id: "flag", label: "Flaggturnering", desc: "Fast slagbudsjett (par + HCP) -- flagget plantes der slagene tar slutt." },
{ id: "shamble", label: "Shamble", desc: "2-4 spillere, alle slår ut, velger beste -- spiller så egen ball inn." },
{ id: "moneyball", label: "Money Ball", desc: 'Fire spillere, én roterer som "money ball" per hull.' },
{ id: "highlowhigh", label: "High-low-high", desc: "To lag à to -- høy mot høy, lav mot lav, per hull." },
]
const TWO_SIDED = new Set<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"])
function usesStep4(f: FormatId) {
return TWO_SIDED.has(f) || f === "moneyball"
}
// 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",
}
// 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"
}
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[]
}
type ApiPersonalCourse = { id: string; name: string }
type ApiPersonalCourseDetail = { id: string; name: string; tees: ApiTeeOption[] }
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")
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 [advOpen, setAdvOpen] = useState(false)
const [useHcp, setUseHcp] = useState(true)
const [useCourseHcp, setUseCourseHcp] = useState(true)
const [useMatchplayHcp, setUseMatchplayHcp] = useState(true)
const [hcpPercent, setHcpPercent] = useState("")
// 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 === "official-search") {
setS1Sub("source")
} else if (s1Sub === "official-courses") {
setS1Sub("official-search")
} else if (s1Sub === "own-search") {
setS1Sub("source")
} else if (s1Sub === "own-create") {
setS1Sub("own-search")
} else if (s1Sub === "fields") {
setS1Sub(courseSource === "official" ? "official-courses" : "own-search")
}
return
}
const prev = stepList[currentIndex - 1]
if (prev) setStepN(prev.n)
}
// --- Innsending (ADR-040): runden finnes ikke før HELE veiviseren er
// fullført -- oppretter runden (eier), deretter sidene (om to-sidet),
// deretter hver medspiller (med round_side_id satt direkte der siden
// allerede finnes), deretter eierens egen side-tildeling og eventuell
// Money Ball-rekkefølge (begge krever PATCH, siden ParticipantCreate
// ikke støtter lineup_order og eieren allerede er opprettet FØR sidene).
async function submitWizard() {
if (!course || !courseMeta || !ownerTeeId) return
setSubmitting(true)
setSubmitError(null)
try {
const startedAt = time ? new Date(`${date}T${time}`).toISOString() : null
const apiFormat = FORMAT_TO_API[format]
const allowanceOverride = buildAllowanceOverride()
const shared = {
tee_name: teeName(course, ownerTeeId),
name: roundName.trim() || null,
played_at: date,
started_at: startedAt,
start_hole: Number(startHole),
holes_planned: holes,
stat_level: apiStatLevel(players.find((p) => p.isOwner)?.statLevel ?? "kun-slag"),
play_format: apiFormat,
exclude_owner_from_handicap: format === "match" ? matchExcludeHcp : false,
skins_scoring: format === "skins" ? (skinsScoring === "netto" ? "net" : "gross") : null,
skins_tie_handling: format === "skins" ? (skinsTie === "roll" ? "carry" : "split") : null,
bbb_sweep_bonus_enabled: format === "bbb" ? bbbBonus : false,
shamble_best_n: format === "shamble" ? shambleBest : null,
visibility_mode: visibility === "privat" ? "private" : visibility === "venner" ? "friends" : "public",
visible_categories: visibility === "venner" ? Array.from(categories) : [],
flight_group_id: flightGroupId || undefined,
allowance_override: allowanceOverride,
}
const body =
courseMeta.source === "teeoff"
? {
course_source: "teeoff",
teeoff_facility_slug: courseMeta.facilitySlug,
teeoff_course_id: courseMeta.teeoffCourseId,
...shared,
}
: { course_source: "custom", personal_course_id: courseMeta.personalCourseId, ...shared }
const roundRes = await fetch("/rounds", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
})
if (!roundRes.ok) throw new Error(`create round: ${roundRes.status}`)
const round: { id: string; participants: { id: string; is_owner: boolean }[] } = await roundRes.json()
const roundId = round.id
const ownerParticipantId = round.participants.find((p) => p.is_owner)?.id ?? round.participants[0]?.id
// Sider (kun to-sidede formater) -- opprettet FØR medspillerne slik
// at round_side_id kan sendes direkte på hver ParticipantCreate.
const sideIdMap = new Map<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)
}
}
// 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 {
participantBody.guest_name = p.name
participantBody.gender = apiGenderOf(p.gender)
participantBody.handicap_index = parseHcp(p.hcp)
if (p.email && p.email.trim()) participantBody.guest_email = p.email.trim()
}
const res = await fetch(`/rounds/${roundId}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(participantBody),
})
if (!res.ok) {
const errBody = await res.json().catch(() => null)
throw new Error(errBody?.detail?.message ?? `add participant: ${res.status}`)
}
const created: { id: string } = await res.json()
participantIdMap.set(p.id, created.id)
}
// Eierens egen side-tildeling (PATCH -- eieren finnes allerede FØR
// sidene ble opprettet over).
if (TWO_SIDED.has(format)) {
const owner = players.find((p) => p.isOwner)
const realSideId = owner?.sideId ? sideIdMap.get(owner.sideId) : undefined
if (realSideId) {
await fetch(`/rounds/${roundId}/participants/${ownerParticipantId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ round_side_id: realSideId }),
})
}
}
// Money Ball-rekkefølge (PATCH -- ikke støttet på selve opprettelsen).
if (format === "moneyball") {
for (const p of players) {
if (p.moneyBallSlot === undefined) continue
const realId = participantIdMap.get(p.id)
if (!realId) continue
await fetch(`/rounds/${roundId}/participants/${realId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ lineup_order: p.moneyBallSlot }),
})
}
}
router.replace(`/my-rounds/${roundId}`)
} catch (err) {
setSubmitError(
err instanceof Error && err.message && !err.message.startsWith("create round:") && !err.message.startsWith("create side:")
? err.message
: "Klarte ikke å opprette runden. Prøv igjen.",
)
setSubmitting(false)
}
}
// ADR-040 Beslutning B -- HCP-prosenten er alltid justerbar, og
// Match-HCP kan slås av/på for to-sidede formater. Samme mønster som
// tournament-program.tsx sin CreateSessionCard.buildAllowanceOverride.
function buildAllowanceOverride(): Record<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>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 pb-32 sm:py-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}
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}
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} />
)}
{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
selectedClub: OfficialClub | null
setSelectedClub: (c: OfficialClub | null) => void
setCourseSource: (s: "official" | "own") => void
course: Course | null
chooseCourse: (c: Course, meta: CourseMeta, source: "official" | "own") => void
ownGender: ApiGender
roundName: string
setRoundName: (v: string) => void
ownerTeeId: string
setOwnerTeeId: (v: string) => void
date: string
setDate: (v: string) => void
time: string
setTime: (v: string) => void
startHole: string
setStartHole: (v: string) => void
}) {
const { s1Sub, setS1Sub, selectedClub, setSelectedClub, setCourseSource, course, chooseCourse, ownGender } = props
const [resolvingClub, setResolvingClub] = useState(false)
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={() => {
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={() => {
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) })),
}
setSelectedClub(club)
setS1Sub("official-courses")
} catch {
setSelectedClub(null)
} finally {
setResolvingClub(false)
}
}}
/>
)
}
if (s1Sub === "official-courses" && selectedClub) {
return (
<CourseList
title={selectedClub.name}
description="Velg hvilken bane du skal spille."
courses={selectedClub.courses}
onSelect={(c) => chooseCourse(c, { source: "teeoff", facilitySlug: selectedClub.id, teeoffCourseId: Number(c.id) }, "official")}
/>
)
}
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",
)
}}
onCreate={() => setS1Sub("own-create")}
/>
)
}
if (s1Sub === "own-create") {
return (
<OwnCreateStep
onCreated={(created) => chooseCourse(created, { source: "custom", personalCourseId: created.id }, "own")}
onCancel={() => 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 }: { title: string; description: string; courses: Course[]; onSelect: (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}>
<button
type="button"
onClick={() => onSelect(course)}
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"
>
<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>
</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, onCreate }: { onSelect: (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="Egen bane" description="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}>
<button
type="button"
onClick={() => onSelect(course)}
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"
>
<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>
</div>
</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) }))
}
function parseDecimal(value: string): number {
return Number(value.replace(",", "."))
}
function OwnCreateStep({ onCreated, onCancel }: { onCreated: (course: Course) => void; onCancel: () => void }) {
const [name, setName] = useState("")
const [holes, setHoles] = useState(emptyHoles)
const [tees, setTees] = useState<CreateTee[]>([{ 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) }]
: []),
],
})),
}
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." />
{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
advOpen: boolean
setAdvOpen: (v: boolean) => void
useHcp: boolean
setUseHcp: (v: boolean) => void
useCourseHcp: boolean
setUseCourseHcp: (v: boolean) => void
useMatchplayHcp: boolean
setUseMatchplayHcp: (v: boolean) => void
hcpPercent: string
setHcpPercent: (v: string) => void
}) {
const {
statLevel, setStatLevel, format, setFormat, holes, setHoles,
matchExcludeHcp, setMatchExcludeHcp, skinsScoring, setSkinsScoring, skinsTie, setSkinsTie,
bbbBonus, setBbbBonus, shambleBest, setShambleBest,
advOpen, setAdvOpen, useHcp, setUseHcp, useCourseHcp, setUseCourseHcp,
useMatchplayHcp, setUseMatchplayHcp, hcpPercent, setHcpPercent,
} = props
const selectedFormat = FORMATS.find((f) => f.id === format)!
const twoSided = TWO_SIDED.has(format)
return (
<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}
/>
<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} />
<ToggleRow label="Bruk course handicap-justering" checked={useCourseHcp} onChange={setUseCourseHcp} />
{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>
)}
<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,
}: {
format: FormatId
matchExcludeHcp: boolean
setMatchExcludeHcp: (v: boolean) => void
skinsScoring: "netto" | "brutto"
setSkinsScoring: (v: "netto" | "brutto") => void
skinsTie: "roll" | "split"
setSkinsTie: (v: "roll" | "split") => void
bbbBonus: boolean
setBbbBonus: (v: boolean) => void
shambleBest: number
setShambleBest: (v: number) => void
}) {
if (format === "match") {
return (
<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 (TWO_SIDED.has(format)) {
return <HelperNote>Du setter opp lagene i neste steg.</HelperNote>
}
return null
}
// --- 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>
)
}
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 && (
<>
<div className="flex flex-col gap-2">
<Label htmlFor={`name-${player.id}`} className="text-base font-semibold">
Navn
</Label>
<Input id={`name-${player.id}`} value={player.name} onChange={(e) => onChange({ name: e.target.value })} className="h-12 rounded-xl text-base" />
</div>
<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 [gName, setGName] = useState("")
const [gGender, setGGender] = useState<Gender>("mann")
const [gHcp, setGHcp] = useState("")
const [gStat, setGStat] = useState<StatLevel>(roundStatLevel)
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={() => setMode("guest")} 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="flex flex-col gap-2">
<Label htmlFor="guest-name" className="text-base font-semibold">
Navn
</Label>
<Input id="guest-name" value={gName} onChange={(e) => setGName(e.target.value)} placeholder="Navn på gjesten" className="h-14 rounded-2xl text-lg" />
</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={gName.trim() === ""}
onClick={() =>
onAdd({ id: newId("guest"), name: gName.trim(), isGuest: true, gender: gGender, 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,
}: {
format: FormatId
players: Player[]
setPlayers: React.Dispatch<React.SetStateAction<Player[]>>
sides: Side[]
setSides: React.Dispatch<React.SetStateAction<Side[]>>
}) {
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>
)
}
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.
</p>
<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>
)
}