round.visibility_mode (privat som trygg standard) + ny tabell for hvilke venne-kategorier som får se en gitt runde. Kjernesjekken (_can_view_round) viste seg å være en ren utvidelse av den eksisterende eier/medspiller-sjekken, så fire lese-endepunkter ble omstrukturert til delte funksjoner og gjenbrukt av sju nye offentlige endepunkter + et nytt offentlig sanntids-WS — i stedet for å bygge alt parallelt fra bunnen. Ny vennprofil-side (/my-friends/[id]) — navn/avatar/HCP/hjemmeklubb + liste over personens synlige runder, "pågår nå" øverst. Ny read-only live-visning (/watch/[id]) for tredjeparter — matchstatus, skins-tavle eller individuell rangering avhengig av spilleform. Synlighetsvelger lagt til både i opprett-runde og rediger-runde. Verifisert grundig: 191 automatiserte sjekker (inkl. full regresjon av to eksisterende testsuiter) + en fullstendig nettleser-gjennomgang med tre reelle brukere i separate innloggingskontekster — inkludert en helt anonym leser som beviste at "offentlig" faktisk betyr offentlig, og en reell venn/kategori-negativ-kontroll som beviste at feil kategori korrekt nekter tilgang. Migrasjon kjørt mot ekte database (kun additiv), begge containere rullet ut, teeoff.no upåvirket.
1595 lines
60 KiB
TypeScript
1595 lines
60 KiB
TypeScript
"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"
|
||
// 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 [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 : [],
|
||
}
|
||
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">
|
||
Gå 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 ----------------------------------------------
|
||
|
||
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 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>
|
||
|
||
{/* 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 (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>
|
||
|
||
{/* 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 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?: 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: {
|
||
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
|
||
}) {
|
||
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(todayIso)
|
||
const [teeTime, setTeeTime] = useState("")
|
||
const [startHole, setStartHole] = useState("1")
|
||
const [holes, setHoles] = useState<9 | 18>(18)
|
||
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
|
||
const [playFormat, setPlayFormat] = useState<PlayFormat>("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." />
|
||
|
||
{/* 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 på 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 på noe utslag -- HCP-sporing er
|
||
ikke mulig for denne runden. Velg en annen bane, eller fullfør profilen din på 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>
|
||
)
|
||
}
|