teecup/frontend/components/new-round.tsx
Erol Haagenrud 19a21073ba Update Todos
Bygg PWA-installasjonsoppfordring (Android beforeinstallprompt + iOS instruksjonsbanner)

Design og bygg flere flighter i én frittstående runde (løs gruppering, retning 1)

Design og bygg scramble/greensome-statistikk (valgt utslag per spiller)

Bygg push-varsler til telefonens OS (VAPID, push_subscription, service worker)

Scratch-verifiser backend for alle fire (27/27 sjekker gjort)

Browserverifiser alle fire i nettleser mot scratch-backend

Rull ut mot ekte systemer (migrasjoner + containere), med bekreftelse

Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Alle fire er ferdigstilt, verifisert og live. Kort oppsummert:

PWA-installasjonsoppfordring — ekte beforeinstallprompt-fanging, Android-knapp + iOS-instruksjonsbanner, vist på dashbordet. Bekreftet at Chrome faktisk fyrte eventet i testøkten.
Flere flighter i én frittstående runde — løs gruppering av separate runder (delt UUID), "+ Legg til en flight til" gjenbruker hele opprett-flyten forhåndsutfylt, nytt kombinert leaderboard på tvers av flighter. Testet fra knappetrykk til ferdig runde, bekreftet i database.
Scramble/greensome: valgt utslag per spiller — nytt felt i hull-registreringen (helt valgfritt), "Utslag brukt"-oppsummering på statistikksiden. Bygget for frittstående runder; org-turneringer bevisst utenfor omfang denne runden (notert i backloggen).
Push-varsler til telefonens OS — Web Push/VAPID, ny av/på-bryter i kontoinnstillinger, dekker alle fire varseltyper automatisk. VAPID-nøkler generert og lagt i .env uten å vises i chatten. Én ærlig begrensning: ekte OS-varsel-levering med innvilget tillatelse kunne ikke testes i denne automatiserte nettleserøkten (tillatelsen var forhåndsblokkert av miljøet) — verdt å teste selv på en ekte enhet.
27/27 scratch-sjekker, full nettleser-gjennomgang uten konsollfeil, migrasjoner 035–037 kjørt mot ekte teecup_db, begge containere redeployet og verifisert, teeoff.no upåvirket. Alle tre .md-filene er oppdatert med full detalj.
2026-07-28 22:34:16 +02:00

1630 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client"
// Opprett en frittstående runde (ADR-033). Presentasjon fra V0, datalag
// skrevet om fra mock til ekte søk/opprettelse/bekreftelse mot API-et.
// To bane-kilder: offisiell teeoff-bane (live oppslag, ADR-033 Beslutning C
// -- ingen lokal kopi lagres) eller en egendefinert bane (organisasjons-
// uavhengig katalog, søkbar på tvers av alle brukere).
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,
Flag,
MapPin,
Pencil,
Plus,
Search,
Trophy,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
// --- Types -----------------------------------------------------------------
type Rating = { courseRating: string; slope: string; par: string }
type Tee = {
id: string
name: string
men: boolean
women: boolean
menRating?: Rating
womenRating?: Rating
}
type Course = {
id: string
name: string
tees: Tee[]
}
type OfficialClub = {
id: string
name: string
location: string
courses: Course[]
}
type Step =
| "source"
| "official-search"
| "official-courses"
| "own-search"
| "own-create"
| "confirm"
// Hvilken bane-kilde+id som faktisk skal sendes til /rounds -- Course-typen
// over (fra V0) bærer kun det UI-et trenger, ikke dette. Holdt separat i
// stedet for å presse det inn i Course, som er felles for begge kilder.
type CourseMeta =
| { source: "teeoff"; facilitySlug: string; teeoffCourseId: number }
| { source: "custom"; personalCourseId: string }
type Gender = "m" | "f"
type StatLevel = "strokes_only" | "strokes_and_putts" | "full"
// ADR-038/ADR-039 -- selvdeklarert spilleform. Ingen egen match-/skins-motor
// for frittstående runder, så dette taes brukerens ord for. Sider (for de
// to-sidede formatene) opprettes IKKE her -- det skjer i selve runden
// (POST .../sides), siden det krever at deltakerne allerede finnes.
type PlayFormat = "stroke" | "match" | "skins" | "fourball" | "foursome" | "greensome" | "scramble_2" | "scramble_4"
type SkinsScoring = "net" | "gross"
type SkinsTieHandling = "carry" | "split"
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- styrer KUN tredjeparts
// innsyn (venner/offentligheten), aldri en faktisk lagt-til medspiller
// (som alltid ser runden uansett). Trygg standard "private".
type VisibilityMode = "private" | "public" | "friends"
// Samme faste sett/rekkefølge som friends.py sin Category og
// friends.tsx sin CATEGORY_OPTIONS -- egen, lokal kopi (samme
// "liten duplisering fremfor cross-file-import"-mønster som FORMAT_LABELS
// ellers i appen).
const VISIBILITY_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
const PLAY_FORMAT_OPTIONS: { value: PlayFormat; label: string; desc: string }[] = [
{ value: "stroke", label: "Slagspill", desc: "Vanlig individuell runde." },
{ value: "match", label: "Match", desc: "To sider, hull mot hull." },
{ value: "skins", label: "Skins", desc: "Hvert hull er en egen premie -- minst 3 spillere." },
{ value: "fourball", label: "Fourball", desc: "To mot to, beste ball per side." },
{ value: "foursome", label: "Foursome", desc: "To mot to, én delt ball, annenhver slår." },
{ value: "greensome", label: "Greensome", desc: "To mot to, begge slår ut, velger så én ball." },
{ value: "scramble_2", label: "Scramble (2)", desc: "To mot to, velger beste slag hver gang." },
{ value: "scramble_4", label: "Scramble (4)", desc: "Fire mot fire, velger beste slag hver gang." },
]
// --- API-typer ---------------------------------------------------------------
type ApiTeeOption = { name: string; genders: Gender[] }
type ApiFacility = { slug: string; name: string; city: string | null; county: string | null }
type ApiOfficialCourseOption = {
teeoff_course_id: number
name: string
is_main_course: boolean
tees: ApiTeeOption[]
}
type ApiPersonalCourse = { id: string; name: string }
type ApiPersonalCourseDetail = { id: string; name: string; tees: ApiTeeOption[] }
function apiTeesToTees(apiTees: ApiTeeOption[]): Tee[] {
return apiTees.map((t, i) => ({
id: `${t.name}-${i}`,
name: t.name,
men: t.genders.includes("m"),
women: t.genders.includes("f"),
}))
}
// --- Root component --------------------------------------------------------
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. Rene primitiver
// i URL-en (ikke et bane-oppslag) -- brukeren velger selv bane på nytt
// for hver flight, men slipper å taste dato/starthull/spilleform igjen.
const flightGroupId = searchParams.get("flightGroupId")
const prefillPlayedAt = searchParams.get("playedAt") ?? undefined
const prefillStartHole = searchParams.get("startHole")
const prefillHoles = searchParams.get("holes")
const prefillPlayFormat = searchParams.get("playFormat") as PlayFormat | null
const [step, setStep] = useState<Step>("source")
const [ownGender, setOwnGender] = useState<Gender | null>(null)
const [loadingMe, setLoadingMe] = useState(true)
// Official flow
const [selectedClub, setSelectedClub] = useState<OfficialClub | null>(null)
const [resolvingClub, setResolvingClub] = useState(false)
// Chosen course carried into the confirm step (from any source), plus
// which source/id it actually came from -- needed to build the /rounds
// payload correctly.
const [course, setCourse] = useState<Course | null>(null)
const [courseMeta, setCourseMeta] = useState<CourseMeta | 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: { gender: Gender | null } = await res.json()
if (!cancelled) setOwnGender(data.gender)
} finally {
if (!cancelled) setLoadingMe(false)
}
}
void loadMe()
return () => {
cancelled = true
}
}, [router])
function chooseCourse(next: Course, meta: CourseMeta) {
setCourse(next)
setCourseMeta(meta)
setStep("confirm")
}
async function selectClub(club: OfficialClub) {
setSelectedClub(club)
setResolvingClub(true)
try {
const res = await fetch(`/rounds/official-search/${club.id}`, { credentials: "include" })
if (!res.ok) throw new Error(`facility detail: ${res.status}`)
const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
const withCourses: OfficialClub = {
...club,
courses: detail.courses.map((c) => ({
id: String(c.teeoff_course_id),
name: c.name,
tees: apiTeesToTees(c.tees),
})),
}
setSelectedClub(withCourses)
setStep("official-courses")
} catch {
setSelectedClub(null)
} finally {
setResolvingClub(false)
}
}
function chooseOfficialCourse(officialCourse: Course) {
if (!selectedClub) return
chooseCourse(officialCourse, {
source: "teeoff",
facilitySlug: selectedClub.id,
teeoffCourseId: Number(officialCourse.id),
})
}
async function selectOwnCourse(picked: ApiPersonalCourse) {
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 },
)
}
function ownCourseCreated(created: { id: string; name: string; tees: Tee[] }) {
chooseCourse(
{ id: created.id, name: created.name, tees: created.tees },
{ source: "custom", personalCourseId: created.id },
)
}
async function submitRound(payload: {
name: string
teeName: string
date: string
startedAt: string | null
startHole: number
holes: 9 | 18
statLevel: StatLevel
playFormat: PlayFormat
excludeFromHandicap: boolean
skinsScoring: SkinsScoring | null
skinsTieHandling: SkinsTieHandling | null
visibilityMode: VisibilityMode
visibleCategories: string[]
}): Promise<{ id: string }> {
if (!courseMeta) throw new Error("Mangler valgt bane")
const shared = {
tee_name: payload.teeName,
name: payload.name || null,
played_at: payload.date,
started_at: payload.startedAt,
start_hole: payload.startHole,
holes_planned: payload.holes,
stat_level: payload.statLevel,
play_format: payload.playFormat,
exclude_owner_from_handicap: payload.excludeFromHandicap,
skins_scoring: payload.skinsScoring,
skins_tie_handling: payload.skinsTieHandling,
visibility_mode: payload.visibilityMode,
visible_categories: payload.visibilityMode === "friends" ? payload.visibleCategories : [],
flight_group_id: flightGroupId || undefined,
}
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 res = await fetch("/rounds", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(`create round: ${res.status}`)
return res.json()
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
<BackLink step={step} setStep={setStep} onExit={() => router.push("/my-rounds")} />
<Wordmark compact />
</div>
</header>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
{loadingMe ? (
<p className="text-lg text-muted-foreground">Laster </p>
) : !ownGender ? (
<div className="rounded-2xl border border-border bg-card p-5 text-base text-muted-foreground">
Profilen din mangler registrert kjønn, som trengs for å beregne banehandicap riktig.{" "}
<Link href="/account" className="font-semibold text-primary underline underline-offset-2">
til kontoinnstillinger
</Link>
.
</div>
) : (
<>
{step === "source" && (
<SourceStep
onOfficial={() => setStep("official-search")}
onOwn={() => setStep("own-search")}
/>
)}
{step === "official-search" && (
<OfficialSearchStep onSelectClub={selectClub} resolving={resolvingClub} />
)}
{step === "official-courses" && selectedClub && (
<OfficialCoursesStep club={selectedClub} onSelectCourse={chooseOfficialCourse} />
)}
{step === "own-search" && (
<OwnSearchStep onSelectCourse={selectOwnCourse} onCreate={() => setStep("own-create")} />
)}
{step === "own-create" && (
<OwnCreateStep onCreated={ownCourseCreated} onCancel={() => setStep("own-search")} />
)}
{step === "confirm" && course && (
<ConfirmStep
course={course}
ownGender={ownGender}
onSubmit={submitRound}
onCreated={(id) => router.replace(`/my-rounds/${id}`)}
isAdditionalFlight={Boolean(flightGroupId)}
defaultDate={prefillPlayedAt}
defaultStartHole={prefillStartHole ?? undefined}
defaultHoles={prefillHoles === "9" ? 9 : prefillHoles === "18" ? 18 : undefined}
defaultPlayFormat={prefillPlayFormat ?? undefined}
/>
)}
</>
)}
</main>
</div>
)
}
// --- Back link -------------------------------------------------------------
function BackLink({
step,
setStep,
onExit,
}: {
step: Step
setStep: (step: Step) => void
onExit: () => void
}) {
const config: Record<Step, { label: string; onClick: () => void }> = {
source: { label: "Til egne runder", onClick: onExit },
"official-search": { label: "Tilbake", onClick: () => setStep("source") },
"official-courses": { label: "Tilbake til søk", onClick: () => setStep("official-search") },
"own-search": { label: "Tilbake", onClick: () => setStep("source") },
"own-create": { label: "Tilbake til søk", onClick: () => setStep("own-search") },
confirm: { label: "Tilbake", onClick: () => setStep("source") },
}
const { label, onClick } = config[step]
return (
<button
type="button"
onClick={onClick}
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" />
{label}
</button>
)
}
// --- Step header -----------------------------------------------------------
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>
<p className="max-w-prose text-lg leading-relaxed text-muted-foreground text-pretty">
{description}
</p>
</div>
)
}
// --- Step 1: source --------------------------------------------------------
function SourceStep({ onOfficial, onOwn }: { onOfficial: () => void; onOwn: () => void }) {
return (
<div className="flex flex-col gap-8">
<StepHeader
title="Ny runde"
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-primary" />}
title="Offisiell bane"
description="Søk opp klubben i det offisielle baneregisteret."
onClick={onOfficial}
/>
<SourceCard
icon={<Pencil aria-hidden="true" className="size-7 text-primary" />}
title="Egen bane"
description="Banen finnes ikke i registeret bruk en tidligere egendefinert bane eller opprett en ny."
onClick={onOwn}
/>
</div>
</div>
)
}
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-sm shadow-black/5 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-primary/15">{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>
)
}
// --- Step 2a: official search ----------------------------------------------
type ApiNearbyFacility = ApiFacility & { distance_km: number }
function NearbyClubs({
onSelectClub,
resolving,
}: {
onSelectClub: (club: OfficialClub) => 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(() => {})
},
() => {
// Avvist eller utilgjengelig -- stille no-op, dette er kun en
// hjelp, ikke en forutsetning for å kunne søke.
},
{ timeout: 8000 },
)
return () => {
cancelled = true
}
}, [])
if (!nearby || nearby.length === 0) return null
return (
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-muted-foreground">Nærmest deg</span>
<ul className="flex flex-col overflow-hidden rounded-2xl border border-border">
{nearby.map((facility, i) => (
<li key={facility.slug} className="border-b border-border last:border-b-0">
<button
type="button"
disabled={resolving}
onClick={() =>
onSelectClub({
id: facility.slug,
name: facility.name,
location: [facility.city, facility.county].filter(Boolean).join(", "),
courses: [],
})
}
className={cn(
"flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/60",
i === 0 && "bg-primary/5",
)}
>
<div className="flex min-w-0 flex-col">
<span className="flex items-center gap-2 text-base font-semibold text-foreground">
{facility.name}
{i === 0 && (
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-xs font-bold text-primary">Nærmest</span>
)}
</span>
{(facility.city || facility.county) && (
<span className="truncate text-sm text-muted-foreground">
{[facility.city, facility.county].filter(Boolean).join(", ")}
</span>
)}
</div>
<span className="shrink-0 text-sm font-semibold tabular-nums text-muted-foreground">
{facility.distance_km < 1
? `${Math.round(facility.distance_km * 1000)} m`
: `${facility.distance_km.toFixed(0)} km`}
</span>
</button>
</li>
))}
</ul>
</div>
)
}
function OfficialSearchStep({
onSelectClub,
resolving,
}: {
onSelectClub: (club: OfficialClub) => void
resolving: boolean
}) {
const [query, setQuery] = useState("")
const [results, setResults] = useState<ApiFacility[] | null>(null)
const [searching, setSearching] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSearch(e: React.FormEvent) {
e.preventDefault()
setSearching(true)
setError(null)
try {
const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query.trim())}`, {
credentials: "include",
})
if (!res.ok) throw new Error(`search: ${res.status}`)
setResults(await res.json())
} catch {
setError("Klarte ikke å søke i baneregisteret akkurat nå.")
setResults([])
} finally {
setSearching(false)
}
}
return (
<div className="flex flex-col gap-8">
<StepHeader title="Offisiell bane" description="Søk opp klubben i baneregisteret." />
<NearbyClubs onSelectClub={onSelectClub} resolving={resolving} />
<form onSubmit={handleSearch} className="flex flex-col gap-3 sm:flex-row">
<div className="relative flex-1">
<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 klubbnavn…"
aria-label="Klubbnavn"
className="h-14 rounded-2xl pl-12 text-lg"
/>
</div>
<Button type="submit" size="lg" disabled={searching} className="h-14 gap-2 rounded-2xl px-8 text-base font-bold">
<Search aria-hidden="true" className="size-5" />
{searching ? "Søker…" : "Søk"}
</Button>
</form>
{error && <p className="text-base font-medium text-destructive">{error}</p>}
{results !== null && (
<div className="flex flex-col gap-3">
{results.length === 0 ? (
<p className="rounded-2xl border border-border bg-muted/50 px-5 py-6 text-center text-base text-muted-foreground">
Ingen klubber matcher «{query}». Prøv et annet søk.
</p>
) : (
<ul className="flex flex-col gap-3">
{results.map((facility) => (
<li key={facility.slug}>
<button
type="button"
disabled={resolving}
onClick={() =>
onSelectClub({
id: facility.slug,
name: facility.name,
location: [facility.city, facility.county].filter(Boolean).join(", "),
courses: [],
})
}
className="flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-sm shadow-black/5 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-primary/15">
<Building2 aria-hidden="true" className="size-6 text-primary" />
</div>
<div className="flex min-w-0 flex-col">
<span className="text-lg font-bold text-foreground">{facility.name}</span>
{facility.city || facility.county ? (
<span className="flex items-center gap-1.5 text-base text-muted-foreground">
<MapPin aria-hidden="true" className="size-4" />
{[facility.city, facility.county].filter(Boolean).join(", ")}
</span>
) : null}
</div>
</button>
</li>
))}
</ul>
)}
{resolving && <p className="text-base text-muted-foreground">Henter baner </p>}
</div>
)}
</div>
)
}
// --- Step 2a (cont): courses at club ---------------------------------------
function OfficialCoursesStep({
club,
onSelectCourse,
}: {
club: OfficialClub
onSelectCourse: (course: Course) => void
}) {
return (
<div className="flex flex-col gap-8">
<StepHeader title={club.name} description="Velg hvilken bane du skal spille." />
<ul className="flex flex-col gap-3">
{club.courses.map((course) => (
<li key={course.id}>
<button
type="button"
onClick={() => onSelectCourse(course)}
className="flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Flag aria-hidden="true" className="size-6 text-primary" />
</div>
<div className="flex min-w-0 flex-col">
<span className="text-lg font-bold text-foreground">{course.name}</span>
<span className="text-base text-muted-foreground">{course.tees.length} utslag</span>
</div>
</button>
</li>
))}
{club.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>
)
}
// --- Step 2b: own course search --------------------------------------------
function OwnSearchStep({
onSelectCourse,
onCreate,
}: {
onSelectCourse: (course: ApiPersonalCourse) => void
onCreate: () => void
}) {
const [query, setQuery] = useState("")
const [results, setResults] = useState<ApiPersonalCourse[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
const timer = setTimeout(async () => {
try {
const res = await fetch(`/personal-courses?q=${encodeURIComponent(query.trim())}`, {
credentials: "include",
})
if (!res.ok) throw new Error(`search: ${res.status}`)
const data: ApiPersonalCourse[] = await res.json()
if (!cancelled) setResults(data)
} catch {
if (!cancelled) setError("Klarte ikke å søke i egendefinerte baner akkurat nå.")
}
}, 250)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [query])
return (
<div className="flex flex-col gap-8">
<StepHeader
title="Egen bane"
description="Søk opp en tidligere egendefinert bane, eller opprett en ny."
/>
{error && <p className="text-base font-medium text-destructive">{error}</p>}
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
/>
<Input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Søk etter banenavn…"
aria-label="Banenavn"
className="h-14 rounded-2xl pl-12 text-lg"
/>
</div>
{results.length > 0 && (
<ul className="flex flex-col gap-3">
{results.map((course) => (
<li key={course.id}>
<button
type="button"
onClick={() => onSelectCourse(course)}
className="flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Flag aria-hidden="true" className="size-6 text-primary" />
</div>
<div className="flex min-w-0 flex-col">
<span className="text-lg font-bold text-foreground">{course.name}</span>
</div>
</button>
</li>
))}
</ul>
)}
{results.length === 0 && (
<p className="rounded-2xl border border-border bg-muted/50 px-5 py-6 text-center text-base text-muted-foreground">
{query.trim() ? `Ingen egne baner matcher «${query}».` : "Skriv for å søke, eller opprett en ny bane under."}
</p>
)}
<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>
)
}
// --- Step 2b (cont): create course -----------------------------------------
function emptyHoles() {
return Array.from({ length: 18 }, (_, i) => ({
hole: i + 1,
par: "4",
strokeIndex: String(i + 1),
}))
}
function parseDecimal(value: string): number {
return Number(value.replace(",", "."))
}
function OwnCreateStep({
onCreated,
onCancel,
}: {
onCreated: (course: { id: string; name: string; tees: Tee[] }) => void
onCancel: () => void
}) {
const [name, setName] = useState("")
const [holes, setHoles] = useState(emptyHoles)
const [tees, setTees] = useState<Tee[]>([
{ 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<Tee>) {
setTees((prev) => prev.map((t) => (t.id === id ? { ...t, ...patch } : t)))
}
function addTee() {
setTees((prev) => [...prev, { id: `nt-${Date.now()}`, name: "", men: false, women: false }])
}
function removeTee(id: string) {
setTees((prev) => (prev.length > 1 ? prev.filter((t) => t.id !== id) : prev))
}
const indexesValid = useMemo(
() => new Set(holes.map((h) => h.strokeIndex)).size === 18,
[holes],
)
const teesValid = tees.every(
(t) =>
t.name.trim() !== "" &&
(t.men || t.women) &&
(!t.men || (t.menRating && t.menRating.courseRating.trim() && t.menRating.slope.trim() && t.menRating.par.trim())) &&
(!t.women || (t.womenRating && t.womenRating.courseRating.trim() && t.womenRating.slope.trim() && t.womenRating.par.trim())),
)
const canSubmit = name.trim() !== "" && indexesValid && teesValid && !submitting
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
setSubmitting(true)
setError(null)
try {
const body = {
name: name.trim(),
holes: holes.map((h) => ({
hole_number: h.hole,
par: Number(h.par),
stroke_index: Number(h.strokeIndex),
})),
tees: tees.map((t) => ({
name: t.name.trim(),
ratings: [
...(t.men && t.menRating
? [{ gender: "m", course_rating: parseDecimal(t.menRating.courseRating), slope_rating: Number(t.menRating.slope), par: Number(t.menRating.par) }]
: []),
...(t.women && t.womenRating
? [{ gender: "f", course_rating: parseDecimal(t.womenRating.courseRating), slope_rating: Number(t.womenRating.slope), par: Number(t.womenRating.par) }]
: []),
],
})),
}
const res = await fetch("/personal-courses", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(`create: ${res.status}`)
const created: { id: string; name: string } = await res.json()
onCreated({ id: created.id, name: created.name, tees: tees.map((t) => ({ ...t, name: t.name.trim() })) })
} catch {
setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.")
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-8">
<StepHeader title="Opprett ny bane" description="Fyll inn banedata. Du kan endre detaljene senere." />
{error && <p className="text-base font-medium text-destructive">{error}</p>}
{/* Name */}
<div className="flex flex-col gap-2">
<Label htmlFor="course-name" className="text-base font-semibold">
Navn banen
</Label>
<Input
id="course-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="F.eks. Hyttebanen"
className="h-14 rounded-2xl text-lg"
/>
</div>
{/* Holes */}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-bold text-foreground">Hull</h2>
<p className="text-base text-muted-foreground">Sett par og stroke-indeks for hvert hull.</p>
</div>
{!indexesValid && (
<p role="alert" className="text-base font-medium text-destructive">
Hver stroke-indeks (118) brukes nøyaktig én gang.
</p>
)}
<div className="overflow-hidden rounded-2xl border border-border">
<div className="grid grid-cols-[auto_1fr_1fr] items-center gap-3 border-b border-border bg-muted/60 px-4 py-3 text-sm font-bold uppercase tracking-wide text-muted-foreground">
<span className="w-16">Hull</span>
<span>Par</span>
<span>Stroke-indeks</span>
</div>
<ul>
{holes.map((h, index) => (
<li
key={h.hole}
className="grid grid-cols-[auto_1fr_1fr] items-center gap-3 border-b border-border px-4 py-2.5 last:border-b-0"
>
<span className="w-16 text-base font-semibold text-foreground">Hull {h.hole}</span>
<select
aria-label={`Par for hull ${h.hole}`}
value={h.par}
onChange={(e) => updateHole(index, "par", e.target.value)}
className="h-12 rounded-xl border border-input bg-background px-3 text-base font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{[3, 4, 5, 6].map((p) => (
<option key={p} value={String(p)}>
Par {p}
</option>
))}
</select>
<select
aria-label={`Stroke-indeks for hull ${h.hole}`}
value={h.strokeIndex}
onChange={(e) => updateHole(index, "strokeIndex", e.target.value)}
className="h-12 rounded-xl border border-input bg-background px-3 text-base font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{Array.from({ length: 18 }, (_, i) => i + 1).map((si) => (
<option key={si} value={String(si)}>
{si}
</option>
))}
</select>
</li>
))}
</ul>
</div>
</div>
{/* Tees */}
<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: Tee
index: number
canRemove: boolean
onChange: (patch: Partial<Tee>) => void
onRemove: () => void
}) {
return (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-sm shadow-black/5">
<div className="flex items-end gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor={`tee-name-${tee.id}`} className="text-base font-semibold">
Navn utslag {index + 1}
</Label>
<Input
id={`tee-name-${tee.id}`}
value={tee.name}
onChange={(e) => onChange({ name: e.target.value })}
placeholder="F.eks. Gul"
className="h-12 rounded-xl text-base"
/>
</div>
{canRemove && (
<Button type="button" variant="outline" onClick={onRemove} className="h-12 rounded-xl px-4 text-base font-semibold">
Fjern
</Button>
)}
</div>
<RatingToggle
label="Herre-rating"
enabled={tee.men}
rating={tee.menRating}
onToggle={(men) =>
onChange({ men, menRating: men ? tee.menRating ?? { courseRating: "", slope: "", par: "" } : tee.menRating })
}
onRatingChange={(menRating) => onChange({ menRating })}
idPrefix={`men-${tee.id}`}
/>
<RatingToggle
label="Dame-rating"
enabled={tee.women}
rating={tee.womenRating}
onToggle={(women) =>
onChange({ women, womenRating: women ? tee.womenRating ?? { courseRating: "", slope: "", par: "" } : tee.womenRating })
}
onRatingChange={(womenRating) => onChange({ womenRating })}
idPrefix={`women-${tee.id}`}
/>
</div>
)
}
function RatingToggle({
label,
enabled,
rating,
onToggle,
onRatingChange,
idPrefix,
}: {
label: string
enabled: boolean
rating?: Rating
onToggle: (enabled: boolean) => void
onRatingChange: (rating: Rating) => void
idPrefix: string
}) {
const current = rating ?? { courseRating: "", slope: "", par: "" }
function update(field: keyof Rating, 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>
)
}
// --- Step 3: confirm -------------------------------------------------------
function todayIso() {
const now = new Date()
const offset = now.getTimezoneOffset()
const local = new Date(now.getTime() - offset * 60 * 1000)
return local.toISOString().slice(0, 10)
}
function ConfirmStep({
course,
ownGender,
onSubmit,
onCreated,
isAdditionalFlight = false,
defaultDate,
defaultStartHole,
defaultHoles,
defaultPlayFormat,
}: {
course: Course
ownGender: Gender
onSubmit: (payload: {
name: string
teeName: string
date: string
startedAt: string | null
startHole: number
holes: 9 | 18
statLevel: StatLevel
playFormat: PlayFormat
excludeFromHandicap: boolean
skinsScoring: SkinsScoring | null
skinsTieHandling: SkinsTieHandling | null
visibilityMode: VisibilityMode
visibleCategories: string[]
}) => Promise<{ id: string }>
onCreated: (roundId: string) => void
// Flere flighter (migrasjon 035) -- satt av round-detail.tsx sin
// "Legg til en flight til" via URL-parametre, se NewRound() over.
isAdditionalFlight?: boolean
defaultDate?: string
defaultStartHole?: string
defaultHoles?: 9 | 18
defaultPlayFormat?: PlayFormat
}) {
const compatibleTees = course.tees.filter((t) => (ownGender === "m" ? t.men : t.women))
const [teeId, setTeeId] = useState(compatibleTees[0]?.id ?? "")
const [name, setName] = useState("")
const [date, setDate] = useState(defaultDate ?? todayIso)
const [teeTime, setTeeTime] = useState("")
const [startHole, setStartHole] = useState(defaultStartHole ?? "1")
const [holes, setHoles] = useState<9 | 18>(defaultHoles ?? 18)
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
const [playFormat, setPlayFormat] = useState<PlayFormat>(defaultPlayFormat ?? "stroke")
const [excludeFromHandicap, setExcludeFromHandicap] = useState(false)
// ADR-039 Beslutning D -- kun meningsfullt (og påkrevd av backend) for
// play_format="skins". Fornuftige forhåndsvalg, begge overstyrbare.
const [skinsScoring, setSkinsScoring] = useState<SkinsScoring>("net")
const [skinsTieHandling, setSkinsTieHandling] = useState<SkinsTieHandling>("carry")
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- trygg standard "private".
const [visibilityMode, setVisibilityMode] = useState<VisibilityMode>("private")
const [visibleCategories, setVisibleCategories] = useState<string[]>([])
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
function choosePlayFormat(next: PlayFormat) {
setPlayFormat(next)
// ADR-038 Beslutning D -- foreslå (ikke tving) eksklusjon når match
// velges. De andre formatene har egne HCP-regler (ADR-039 Beslutning E)
// som ikke bruker dette feltet -- nullstill det da for å unngå et
// misvisende forhåndsvalg som likevel ikke sendes videre meningsfullt.
setExcludeFromHandicap(next === "match")
}
const selectedTee = compatibleTees.find((t) => t.id === teeId)
// ADR-039 Beslutning A -- match/fourball/foursome/greensome/scramble
// krever sider, opprettet ETTERPÅ inne i selve runden (deltakerne må
// finnes først). Vises som en forklarende note her, ikke et skjema.
const needsSidesSetup = playFormat !== "stroke" && playFormat !== "skins"
async function handleStart() {
if (!selectedTee) return
setSubmitting(true)
setError(null)
try {
// Utslagstid er valgfritt -- kun dato er påkrevd. Kombinerer dato+
// klokkeslett til et ekte tidspunkt (nettleserens lokale tidssone,
// konvertert til UTC av toISOString()) kun når klokkeslett er satt.
const startedAt = teeTime ? new Date(`${date}T${teeTime}`).toISOString() : null
const created = await onSubmit({
name: name.trim(),
teeName: selectedTee.name,
date,
startedAt,
startHole: Number(startHole),
holes,
statLevel,
playFormat,
excludeFromHandicap,
skinsScoring: playFormat === "skins" ? skinsScoring : null,
skinsTieHandling: playFormat === "skins" ? skinsTieHandling : null,
visibilityMode,
visibleCategories,
})
onCreated(created.id)
} catch {
setError("Klarte ikke å opprette runden. Prøv igjen.")
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-8">
<StepHeader title="Bekreft og fullfør" description="Sjekk detaljene før du starter runden." />
{isAdditionalFlight && (
<p className="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>
)}
{/* Course */}
<div className="flex items-center gap-4 rounded-2xl border border-border bg-card p-5 shadow-sm shadow-black/5">
<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 flex-col">
<span className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Valgt bane</span>
<span className="text-lg font-bold text-foreground">{course.name}</span>
</div>
</div>
{error && <p className="text-base font-medium text-destructive">{error}</p>}
{/* Round name */}
<div className="flex flex-col gap-2">
<Label htmlFor="round-name" className="text-base font-semibold">
Navn runden <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id="round-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={course.name}
maxLength={200}
className="h-14 rounded-2xl text-lg"
/>
</div>
{/* Tee */}
{compatibleTees.length === 0 ? (
<p role="alert" className="text-base font-medium text-destructive">
Denne banen har ingen registrert rating for ditt kjønn noe utslag -- HCP-sporing er
ikke mulig for denne runden. Velg en annen bane, eller fullfør profilen din nytt.
</p>
) : (
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-base font-semibold text-foreground">Utslag</legend>
<div className="flex flex-wrap gap-2">
{compatibleTees.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setTeeId(t.id)}
aria-pressed={teeId === t.id}
className={cn(
"inline-flex min-h-[44px] items-center gap-2 rounded-2xl border px-5 py-2.5 text-base font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
teeId === t.id
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
{teeId === t.id && <Check aria-hidden="true" className="size-4 text-primary" />}
{t.name || "Uten navn"}
</button>
))}
</div>
</fieldset>
)}
{/* Date + tee time + start hole */}
<div className="grid gap-5 sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label htmlFor="round-date" className="text-base font-semibold">
Dato
</Label>
<Input
id="round-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="tee-time" className="text-base font-semibold">
Utslagstid <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id="tee-time"
type="time"
value={teeTime}
onChange={(e) => setTeeTime(e.target.value)}
className="h-14 rounded-2xl text-lg"
/>
</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>
{/* Statistikknivå -- kun slag er strengt tatt nødvendig for resultat/HCP */}
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-base font-semibold text-foreground">Statistikk for deg selv</legend>
<div className="flex flex-col gap-2">
{(
[
{ value: "strokes_only", label: "Kun slag", desc: "Raskest -- bare det som trengs for resultat og HCP." },
{ value: "strokes_and_putts", label: "Slag og putter", desc: "Legger til putt-telling per hull." },
{ value: "full", label: "All statistikk", desc: "Kølle, retning, chip, bunker, straffeslag med mer." },
] as const
).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setStatLevel(opt.value)}
aria-pressed={statLevel === opt.value}
className={cn(
"flex min-h-[44px] flex-col items-start gap-0.5 rounded-2xl border px-5 py-3 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",
statLevel === opt.value
? "border-primary bg-primary/10"
: "border-border bg-card hover:bg-accent/50",
)}
>
<span className="text-base font-bold text-foreground">{opt.label}</span>
<span className="text-sm text-muted-foreground">{opt.desc}</span>
</button>
))}
</div>
</fieldset>
{/* Spilleform (ADR-038/ADR-039) -- selvdeklarert, ingen egen match-/
skins-motor. match/fourball/foursome/greensome/scramble krever
sider -- opprettes i selve runden etter at deltakerne er lagt til. */}
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-base font-semibold text-foreground">Spilleform</legend>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{PLAY_FORMAT_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => choosePlayFormat(opt.value)}
aria-pressed={playFormat === opt.value}
className={cn(
"flex min-h-[44px] flex-col items-center justify-center gap-0.5 rounded-2xl border px-3 py-3 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
playFormat === opt.value
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<span className="text-base font-bold">{opt.label}</span>
</button>
))}
</div>
<p className="text-sm text-muted-foreground">
{PLAY_FORMAT_OPTIONS.find((o) => o.value === playFormat)?.desc}
</p>
{playFormat === "match" && (
<label className="flex min-h-[44px] items-start gap-3 rounded-2xl border border-border bg-card px-5 py-3">
<input
type="checkbox"
checked={excludeFromHandicap}
onChange={(e) => setExcludeFromHandicap(e.target.checked)}
className="mt-1 size-5 shrink-0 accent-primary"
/>
<span className="flex flex-col gap-0.5">
<span className="text-base font-bold text-foreground">
Ekskluder denne runden fra mitt faktiske HCP
</span>
<span className="text-sm text-muted-foreground">
Anbefalt for match -- konsederte hull gir ofte et upålitelig grunnlag for
HCP-beregning. Du kan endre dette senere.
</span>
</span>
</label>
)}
{playFormat === "skins" && (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card px-5 py-4">
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Poengberegning</span>
<div className="grid grid-cols-2 gap-2">
{(
[
{ value: "net" as const, label: "Netto (m/HCP)" },
{ value: "gross" as const, label: "Brutto" },
]
).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setSkinsScoring(opt.value)}
aria-pressed={skinsScoring === opt.value}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border text-sm font-bold transition-colors",
skinsScoring === opt.value
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-accent/50",
)}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Ved uavgjort hull</span>
<div className="grid grid-cols-2 gap-2">
{(
[
{ value: "carry" as const, label: "Rullerer over" },
{ value: "split" as const, label: "Deles likt" },
]
).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setSkinsTieHandling(opt.value)}
aria-pressed={skinsTieHandling === opt.value}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border text-sm font-bold transition-colors",
skinsTieHandling === opt.value
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-accent/50",
)}
>
{opt.label}
</button>
))}
</div>
</div>
<p className="text-sm text-muted-foreground">
Skins krever minst 3 spillere i runden -- legg til medspillere etter at runden er opprettet.
</p>
</div>
)}
{needsSidesSetup && (
<p className="text-sm text-muted-foreground">
Du setter opp sidene (og fordeler spillerne) inne i runden, etter at medspillerne er
lagt til.
</p>
)}
</fieldset>
{/* Hole count */}
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-base font-semibold text-foreground">Antall hull</legend>
<div className="grid grid-cols-2 gap-3">
{([9, 18] as const).map((count) => (
<button
key={count}
type="button"
onClick={() => setHoles(count)}
aria-pressed={holes === count}
className={cn(
"flex min-h-[44px] items-center justify-center rounded-2xl border px-4 py-5 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",
holes === count
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
{count} hull
</button>
))}
</div>
</fieldset>
{/* Synlighet (ADR-036 Beslutning B, fase 2) -- styrer KUN tredjeparts
innsyn (venner/offentligheten), aldri en faktisk lagt-til
medspiller (som alltid ser runden uansett). Trygg standard
"Privat", samme filosofi som RLS-policyenes "se ingenting" ved
manglende kontekst ellers i appen. */}
<fieldset className="flex flex-col gap-3">
<legend className="mb-1 text-base font-semibold text-foreground">Synlighet</legend>
<div className="grid grid-cols-3 gap-2">
{(
[
{ value: "private", label: "Privat", desc: "Kun deg og faktiske medspillere." },
{ value: "friends", label: "Venner", desc: "Valgte venne-kategorier kan følge live." },
{ value: "public", label: "Offentlig", desc: "Alle kan følge live, ingen innlogging." },
] as { value: VisibilityMode; label: string; desc: string }[]
).map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setVisibilityMode(opt.value)}
aria-pressed={visibilityMode === opt.value}
className={cn(
"flex min-h-[44px] flex-col items-center gap-0.5 rounded-2xl border px-2 py-3 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
visibilityMode === opt.value
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<span className="text-sm font-bold">{opt.label}</span>
<span className="text-xs">{opt.desc}</span>
</button>
))}
</div>
{visibilityMode === "friends" && (
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-4">
<p className="text-sm font-semibold text-foreground">
Hvilke av dine venne-kategorier skal kunne se runden?
</p>
<div className="flex flex-wrap gap-2">
{VISIBILITY_CATEGORY_OPTIONS.map(({ code, label }) => {
const active = visibleCategories.includes(code)
return (
<button
key={code}
type="button"
onClick={() =>
setVisibleCategories((prev) =>
active ? prev.filter((c) => c !== code) : [...prev, code],
)
}
aria-pressed={active}
className={cn(
"min-h-[44px] rounded-full border px-3 text-sm font-semibold transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-muted-foreground hover:bg-accent/50",
)}
>
{label}
</button>
)
})}
</div>
{visibleCategories.length === 0 && (
<p className="text-xs text-muted-foreground">
Ingen kategori valgt ennå -- ingen venner kan se runden før du velger minst én.
</p>
)}
</div>
)}
</fieldset>
<Button
type="button"
size="lg"
disabled={submitting || compatibleTees.length === 0 || !selectedTee}
onClick={handleStart}
className="h-16 rounded-2xl text-xl font-extrabold shadow-sm"
>
{submitting ? "Oppretter…" : "Start runden"}
</Button>
</div>
)
}