Fullført: TeeCup-beskrivelse for logo — levert tidligere. Migrasjon 056 (scramble_solo) — kjørt og live. Matchspill-varianten (scramble_solo_match, migrasjon 057) — bygget, verifisert, live. Kritisk bug funnet og rettet: lagspillere kunne ikke registrere score i det hele tatt for scramble_solo-formatene (evig spinner, ingen feilmelding) — var allerede live og ødelagt for ekte lagspillere. Rettet for begge varianter, rullet ut. "Bruk som mal for egen bane" — vises nå kun i "Opprett ny bane"-flyten (ny lenke "Basér på en eksisterende bane i stedet"), ikke i det vanlige søket. Alt er rullet ut live på teecup.golf, verifisert i nettleser mot isolerte scratch-miljøer (ryddet opp etterpå), og dokumentert i CHANGELOG.md (punkt 22–24) og FEATURE_BACKLOG.md.
3314 lines
133 KiB
TypeScript
3314 lines
133 KiB
TypeScript
"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" | "official-search" | "official-courses" | "own-search" | "own-search-template" | "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")
|
||
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 === "official-search") {
|
||
setS1Sub("source")
|
||
} else if (s1Sub === "official-courses") {
|
||
setS1Sub("official-search")
|
||
} else if (s1Sub === "own-search") {
|
||
setS1Sub("source")
|
||
} else if (s1Sub === "own-search-template") {
|
||
setS1Sub("own-create")
|
||
} 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">
|
||
Gå 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}
|
||
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
|
||
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, 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={() => {
|
||
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),
|
||
templateHoles: c.holes,
|
||
templateTees: c.full_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")}
|
||
onUseAsTemplate={(c) => {
|
||
setTemplatePrefill({ values: { name: c.name, holes: c.templateHoles ?? [], tees: c.templateTees ?? [] } })
|
||
setCourseSource("own")
|
||
setOwnCreateOrigin("official-courses")
|
||
setS1Sub("own-create")
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
// Vanlig "finn en bane å spille"-søk (2026-08-05) -- IKKE "Bruk som mal
|
||
// for egen bane" her lenger. Den knappen hører hjemme der brukeren
|
||
// FAKTISK har sagt at målet er å OPPRETTE en ny bane (se
|
||
// "own-search-template" under, nådd fra "Basér på en eksisterende
|
||
// bane"-lenken inne i det blanke opprett-skjemaet) -- ikke i det
|
||
// vanlige søket der målet som oftest bare er å velge en bane å spille.
|
||
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={() => {
|
||
setTemplatePrefill(null)
|
||
setOwnCreateOrigin("own-search")
|
||
setS1Sub("own-create")
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
|
||
// Mal-søk (2026-08-05) -- samme søkeskjerm som over, men nådd KUN fra
|
||
// "Basér på en eksisterende bane" inne i opprett-ny-bane-skjemaet, så
|
||
// "Bruk som mal for egen bane" er meningsfull her (brukeren har allerede
|
||
// sagt at målet er å opprette en ny bane).
|
||
if (s1Sub === "own-search-template") {
|
||
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={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("own-search-template")
|
||
setS1Sub("own-create")
|
||
}}
|
||
onCreate={() => {
|
||
setTemplatePrefill(null)
|
||
setOwnCreateOrigin("own-search-template")
|
||
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("own-search-template")}
|
||
onCancel={() => {
|
||
setTemplatePrefill(null)
|
||
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 søket er nådd med hensikten å OPPRETTE en ny bane (se
|
||
// "own-search-template" i Step1) -- 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 på 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 på 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 (1–18) må 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 på 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 på 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 på 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 rå 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 på 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" på 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" på 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 på 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>
|
||
)
|
||
}
|