teecup/frontend/components/new-round.tsx
Erol Haagenrud 65f876e6dc Oppsummering av det som er fikset/bygget i denne runden:
Starthull-bugen (og trolig GIR-avviket den forårsaket) — fikset
Kølle-bag i profilen (28 faste kølletyper, maks 14) — bygget
Anywayslag-statistikkfelt — bygget
Valgfritt statistikknivå per deltaker (Kun slag / Slag og putter / All statistikk, default Kun slag) — bygget
Putt-avstand som faste bøtter i stedet for fritekst — bygget
«Hullet er spilt»-avkrysningen fjernet — gjort
Bekreft, så kjører jeg migrasjonen og ruller ut.
2026-07-24 06:27:35 +02:00

1195 lines
42 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 } 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"
// --- 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 [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: {
teeName: string
date: string
startHole: number
holes: 9 | 18
statLevel: StatLevel
}): Promise<{ id: string }> {
if (!courseMeta) throw new Error("Mangler valgt bane")
const body =
courseMeta.source === "teeoff"
? {
course_source: "teeoff",
teeoff_facility_slug: courseMeta.facilitySlug,
teeoff_course_id: courseMeta.teeoffCourseId,
tee_name: payload.teeName,
played_at: payload.date,
start_hole: payload.startHole,
holes_planned: payload.holes,
stat_level: payload.statLevel,
}
: {
course_source: "custom",
personal_course_id: courseMeta.personalCourseId,
tee_name: payload.teeName,
played_at: payload.date,
start_hole: payload.startHole,
holes_planned: payload.holes,
stat_level: payload.statLevel,
}
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}`)}
/>
)}
</>
)}
</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 ----------------------------------------------
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." />
<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,
}: {
course: Course
ownGender: Gender
onSubmit: (payload: {
teeName: string
date: string
startHole: number
holes: 9 | 18
statLevel: StatLevel
}) => Promise<{ id: string }>
onCreated: (roundId: string) => void
}) {
const compatibleTees = course.tees.filter((t) => (ownGender === "m" ? t.men : t.women))
const [teeId, setTeeId] = useState(compatibleTees[0]?.id ?? "")
const [date, setDate] = useState(todayIso)
const [startHole, setStartHole] = useState("1")
const [holes, setHoles] = useState<9 | 18>(18)
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const selectedTee = compatibleTees.find((t) => t.id === teeId)
async function handleStart() {
if (!selectedTee) return
setSubmitting(true)
setError(null)
try {
const created = await onSubmit({
teeName: selectedTee.name,
date,
startHole: Number(startHole),
holes,
statLevel,
})
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." />
{/* 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>}
{/* 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 + start hole */}
<div className="grid gap-5 sm:grid-cols-2">
<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="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>
{/* 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>
<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>
)
}