-
-
-
Hull {hole.hole_number}
-
- · Par {hole.par} · Idx {hole.stroke_index}
-
-
- {gir !== null && (
-
- {gir ? "GIR ✓" : "Ikke GIR"}
-
- )}
-
-
-
- onChange({ played: e.target.checked })}
- className="size-5 rounded border-border"
- />
- Hullet er spilt
-
-
-
- Slag
- onChange({ score: n, played: true })} />
-
-
-
- Putter
- onChange({ putts: n })} />
-
-
-
setShowDetails((v) => !v)}
- className="self-start text-sm font-semibold text-primary"
- >
- {showDetails ? "Skjul detaljer" : "Flere detaljer (kølle, retning, chip, bunker …)"}
-
-
- {showDetails && (
-
-
-
- Kølle brukt ved utslaget
-
- onChange({ club_off_tee: e.target.value || null })}
- placeholder="F.eks. Driver"
- className="h-11 rounded-xl text-base"
- />
-
-
- {hole.par >= 4 && (
-
-
Utslag
-
- {(["left", "fairway", "right"] as const).map((v) => (
- onChange({ tee_shot_result: v })}
- >
- {v === "fairway" ? "Fairway" : v === "left" ? "Venstre" : "Høyre"}
-
- ))}
-
-
- )}
-
-
-
Innspill
-
- {(["left", "short", "hit", "long", "right"] as const).map((v) => (
- onChange({ approach_result: v })}
- >
- {v === "hit" ? "Traff" : v === "long" ? "Langt" : v === "short" ? "Kort" : v === "left" ? "Venstre" : "Høyre"}
-
- ))}
-
-
-
-
- onChange({ chip_count: n })} />
- onChange({ bunker_shot_count: n })} />
- onChange({ penalty_strokes: n })} />
-
-
-
-
- Avstand første putt (m)
-
- onChange({ first_putt_distance_m: e.target.value === "" ? null : Number(e.target.value) })}
- className="h-11 rounded-xl text-base"
- />
-
-
- )}
-
- )
-}
-
-function ChoiceButton({
- active,
- disabled,
- onClick,
- children,
-}: {
- active: boolean
- disabled: boolean
- onClick: () => void
- children: React.ReactNode
-}) {
+function PlayedToggle({ checked, onChange, readOnly }: { checked: boolean; onChange: (value: boolean) => void; readOnly: boolean }) {
return (
-
{label}
-
- onChange(Math.max(0, current - 1))}
- className="size-9 shrink-0 rounded-lg"
- aria-label={`Reduser ${label}`}
- >
- −
-
- {current}
- onChange(current + 1)}
- className="size-9 shrink-0 rounded-lg"
- aria-label={`Øk ${label}`}
- >
- +
-
-
-
- )
-}
+ const valueInExpand = value !== null && expandValues.includes(value)
+ const [expanded, setExpanded] = useState(false)
+ const showExpanded = expanded || valueInExpand
-// Tallvelger, samme mønster som StrokePicker i session-scorecard.tsx: rask
-// direkte-trykk for de vanligste verdiene, med en "flere"-utvidelse for
-// resten -- ikke en +/- stepper (for mange klikk for typiske slagtall).
-function NumberPicker({
- value,
- min,
- max,
- disabled,
- onSelect,
-}: {
- value: number | null
- min: number
- max: number
- disabled: boolean
- onSelect: (n: number) => void
-}) {
- const splitAt = Math.min(min + 8, max)
- const [showHigh, setShowHigh] = useState(value !== null && value > splitAt)
-
- useEffect(() => {
- setShowHigh(value !== null && value > splitAt)
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [value])
-
- const low = Array.from({ length: splitAt - min + 1 }, (_, i) => min + i)
- const high = Array.from({ length: max - splitAt }, (_, i) => splitAt + 1 + i)
- const numbers = showHigh ? high : low
+ const visibleValues = showExpanded ? [...directValues, ...expandValues] : directValues
return (
-
- {numbers.map((n) => (
-
onSelect(n)}
- aria-pressed={value === n}
- className={cn(
- "flex h-12 items-center justify-center rounded-xl border text-base font-bold tabular-nums transition-colors active:scale-95 disabled:opacity-60",
- value === n
- ? "border-primary bg-primary text-primary-foreground"
- : "border-border bg-background text-foreground hover:bg-accent/50",
- )}
- >
- {n}
-
- ))}
- {!showHigh && high.length > 0 && (
+
{label}
+
+ {visibleValues.map((n) => {
+ const selected = value === n
+ return (
+ onChange(n)}
+ aria-label={`${label} ${n}`}
+ aria-pressed={selected}
+ className={cn(
+ "flex size-12 items-center justify-center rounded-2xl border text-lg font-bold tabular-nums transition-colors disabled:opacity-100",
+ selected ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
+ readOnly && !selected && "opacity-40",
+ )}
+ >
+ {n}
+
+ )
+ })}
+ {!showExpanded && !readOnly && (
setShowHigh(true)}
- className="flex h-12 items-center justify-center rounded-xl border border-dashed border-border bg-background text-xs font-bold text-muted-foreground transition-colors hover:bg-accent/50"
+ onClick={() => setExpanded(true)}
+ className="flex h-12 min-w-12 items-center justify-center rounded-2xl border border-dashed border-border bg-card px-3 text-base font-bold text-foreground transition-colors hover:bg-accent/50"
>
- {splitAt + 1}+
+ {expandLabel}
)}
- {showHigh && (
-
setShowHigh(false)} className="self-start text-xs font-semibold text-primary">
- ← Tilbake
-
- )}
)
}
-function formatDate(iso: string) {
- const date = new Date(iso)
- if (Number.isNaN(date.getTime())) return iso
- return new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "short", year: "numeric" }).format(date)
+// --- Choice row (segmented buttons) ----------------------------------------
+
+function ChoiceRow({
+ label,
+ options,
+ value,
+ onChange,
+ readOnly,
+}: {
+ label: string
+ options: { value: string; label: string }[]
+ value: string | null
+ onChange: (value: string) => void
+ readOnly: boolean
+}) {
+ return (
+
+
{label}
+
+ {options.map((opt) => {
+ const selected = value === opt.value
+ return (
+ onChange(opt.value)}
+ aria-pressed={selected}
+ className={cn(
+ "flex min-h-12 flex-1 items-center justify-center rounded-2xl border px-4 text-base font-bold transition-colors disabled:opacity-100",
+ selected ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
+ readOnly && !selected && "opacity-40",
+ )}
+ >
+ {opt.label}
+
+ )
+ })}
+
+
+ )
+}
+
+// --- Stepper (+/-) ---------------------------------------------------------
+
+function Stepper({ label, value, onChange, readOnly }: { label: string; value: number; onChange: (value: number) => void; readOnly: boolean }) {
+ return (
+
+
{label}
+
+
onChange(Math.max(0, value - 1))}
+ aria-label={`Færre ${label}`}
+ className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
+ >
+
+
+
{value}
+
onChange(value + 1)}
+ aria-label={`Flere ${label}`}
+ className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
+ >
+
+
+
+
+ )
}
diff --git a/frontend/components/round-new.tsx b/frontend/components/round-new.tsx
deleted file mode 100644
index 08ed076..0000000
--- a/frontend/components/round-new.tsx
+++ /dev/null
@@ -1,834 +0,0 @@
-"use client"
-
-// Opprett en frittstående runde (ADR-033). To bane-kilder: offisiell
-// teeoff-bane (live oppslag, Beslutning C -- ingen lokal kopi lagres) eller
-// en egendefinert bane (organisasjonsuavhengig katalog, søkbar på tvers av
-// alle brukere -- samme "søk før du oppretter"-idé som org-banene).
-
-import type React from "react"
-import { useEffect, useState } from "react"
-import Link from "next/link"
-import { useRouter } from "next/navigation"
-import { ArrowLeft, Check, ChevronRight, Plus, Search, X } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import { Wordmark } from "@/components/wordmark"
-import { cn } from "@/lib/utils"
-
-type Gender = "m" | "f"
-type TeeOption = { name: string; genders: Gender[] }
-
-type SelectedCourse = {
- source: "teeoff" | "custom"
- teeoffFacilitySlug?: string
- teeoffCourseId?: number
- personalCourseId?: string
- name: string
- tees: TeeOption[]
-}
-
-type Step = "source" | "teeoff" | "custom-search" | "custom-create" | "confirm"
-
-export function NewRound() {
- const router = useRouter()
- const [step, setStep] = useState
("source")
- const [selected, setSelected] = useState(null)
- const [ownGender, setOwnGender] = useState(null)
- const [loadingMe, setLoadingMe] = useState(true)
-
- 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 pickCourse(course: SelectedCourse) {
- setSelected(course)
- setStep("confirm")
- }
-
- return (
-
-
-
-
- Ny runde
-
- {loadingMe ? (
-
- ) : !ownGender ? (
-
- Profilen din mangler registrert kjønn, som trengs for å beregne banehandicap riktig.{" "}
-
- Gå til kontoinnstillinger
-
- .
-
- ) : step === "source" ? (
- setStep("teeoff")}
- onPickCustom={() => setStep("custom-search")}
- />
- ) : step === "teeoff" ? (
- setStep("source")} onPick={pickCourse} />
- ) : step === "custom-search" ? (
- setStep("source")}
- onPick={pickCourse}
- onCreateNew={() => setStep("custom-create")}
- />
- ) : step === "custom-create" ? (
- setStep("custom-search")} onCreated={pickCourse} />
- ) : selected ? (
- setStep("source")} />
- ) : null}
-
-
- )
-}
-
-// --- Steg 1: velg kilde ------------------------------------------------------
-
-function SourceChoice({ onPickTeeoff, onPickCustom }: { onPickTeeoff: () => void; onPickCustom: () => void }) {
- return (
-
-
- Hvor spilte du runden?
-
-
-
- Offisiell bane
- Søk opp klubben i teeoff sitt register
-
-
-
-
-
- Egen bane
-
- Banen finnes ikke i teeoff -- søk opp eller opprett den selv
-
-
-
-
-
- )
-}
-
-// --- Steg 2a: teeoff-søk -----------------------------------------------------
-
-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: TeeOption[] }
-
-function TeeoffCourseSearch({
- onBack,
- onPick,
-}: {
- onBack: () => void
- onPick: (course: SelectedCourse) => void
-}) {
- const [query, setQuery] = useState("")
- const [facilities, setFacilities] = useState(null)
- const [selectedFacility, setSelectedFacility] = useState(null)
- const [courses, setCourses] = useState(null)
- const [searching, setSearching] = useState(false)
- const [error, setError] = useState(null)
-
- async function runSearch() {
- 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}`)
- setFacilities(await res.json())
- } catch {
- setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
- setFacilities([])
- } finally {
- setSearching(false)
- }
- }
-
- async function pickFacility(facility: ApiFacility) {
- setSelectedFacility(facility)
- setError(null)
- setCourses(null)
- try {
- const res = await fetch(`/rounds/official-search/${facility.slug}`, { credentials: "include" })
- if (!res.ok) throw new Error(`facility detail: ${res.status}`)
- const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
- setCourses(detail.courses)
- } catch {
- setError("Klarte ikke å hente baner for dette anlegget.")
- setCourses([])
- }
- }
-
- return (
-
- {!selectedFacility ? (
- <>
-
- {error &&
{error}
}
-
- setQuery(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault()
- runSearch()
- }
- }}
- className="h-12 flex-1 rounded-xl text-base"
- />
-
-
- {searching ? "Søker…" : "Søk"}
-
-
- {facilities && (
-
- {facilities.map((f) => (
-
- pickFacility(f)}
- className="flex w-full flex-col px-4 py-3 text-left transition-colors hover:bg-accent/60"
- >
- {f.name}
- {(f.city || f.county) && (
- {[f.city, f.county].filter(Boolean).join(", ")}
- )}
-
-
- ))}
- {facilities.length === 0 && (
- Ingen treff.
- )}
-
- )}
- >
- ) : (
- <>
-
setSelectedFacility(null)} label={selectedFacility.name} />
- {error && {error}
}
- {courses === null ? (
- Laster baner…
- ) : (
-
- {courses.map((c) => (
-
-
- onPick({
- source: "teeoff",
- teeoffFacilitySlug: selectedFacility.slug,
- teeoffCourseId: c.teeoff_course_id,
- name: `${selectedFacility.name} – ${c.name}`,
- tees: c.tees,
- })
- }
- className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-accent/60"
- >
- {c.name}
-
-
-
- ))}
- {courses.length === 0 && (
-
- Ingen 18-hulls baner registrert hos dette anlegget ennå.
-
- )}
-
- )}
- >
- )}
-
- )
-}
-
-// --- Steg 2b: egen bane -- søk eksisterende ---------------------------------
-
-type ApiPersonalCourse = { id: string; name: string }
-
-function CustomCourseSearch({
- onBack,
- onPick,
- onCreateNew,
-}: {
- onBack: () => void
- onPick: (course: SelectedCourse) => void
- onCreateNew: () => void
-}) {
- const [query, setQuery] = useState("")
- const [results, setResults] = useState([])
- const [error, setError] = useState(null)
- const [resolving, setResolving] = useState(false)
-
- 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])
-
- async function pick(course: ApiPersonalCourse) {
- setResolving(true)
- setError(null)
- try {
- const res = await fetch(`/personal-courses/${course.id}`, { credentials: "include" })
- if (!res.ok) throw new Error(`detail: ${res.status}`)
- const detail: { tees: TeeOption[] } = await res.json()
- onPick({ source: "custom", personalCourseId: course.id, name: course.name, tees: detail.tees })
- } catch {
- setError("Klarte ikke å hente banedetaljer. Prøv igjen.")
- } finally {
- setResolving(false)
- }
- }
-
- return (
-
-
- {error &&
{error}
}
-
setQuery(e.target.value)}
- className="h-12 rounded-xl text-base"
- />
-
- {results.map((c) => (
-
- pick(c)}
- className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-accent/60 disabled:opacity-50"
- >
- {c.name}
-
-
-
- ))}
- {results.length === 0 && (
-
- {query.trim() ? "Ingen treff." : "Skriv for å søke, eller opprett en ny bane under."}
-
- )}
-
-
-
- Opprett ny bane
-
-
- )
-}
-
-// --- Steg 2c: egen bane -- opprett ny ---------------------------------------
-
-const DEFAULT_PARS = [4, 4, 3, 5, 4, 4, 3, 5, 4, 4, 4, 3, 5, 4, 4, 3, 5, 4]
-
-type HoleDraft = { par: number; strokeIndex: number }
-type TeeRatingDraft = { courseRating: string; slopeRating: string; par: string }
-type TeeDraft = { name: string; m: TeeRatingDraft | null; f: TeeRatingDraft | null }
-
-function CustomCourseCreate({
- onBack,
- onCreated,
-}: {
- onBack: () => void
- onCreated: (course: SelectedCourse) => void
-}) {
- const [name, setName] = useState("")
- const [holes, setHoles] = useState(
- DEFAULT_PARS.map((par, i) => ({ par, strokeIndex: i + 1 })),
- )
- const [tees, setTees] = useState([
- { name: "Gul", m: { courseRating: "", slopeRating: "", par: "72" }, f: null },
- ])
- const [submitting, setSubmitting] = useState(false)
- const [error, setError] = useState(null)
-
- const parSum = holes.reduce((s, h) => s + h.par, 0)
- const indexPermutationValid = new Set(holes.map((h) => h.strokeIndex)).size === 18
-
- function updateHole(i: number, patch: Partial) {
- setHoles((prev) => prev.map((h, idx) => (idx === i ? { ...h, ...patch } : h)))
- }
-
- function updateTee(i: number, patch: Partial) {
- setTees((prev) => prev.map((t, idx) => (idx === i ? { ...t, ...patch } : t)))
- }
-
- const teesValid = tees.every(
- (t) =>
- t.name.trim().length > 0 &&
- (t.m || t.f) &&
- [t.m, t.f].every((r) => !r || (r.courseRating.trim() && r.slopeRating.trim() && r.par.trim())),
- )
- const canSubmit = name.trim().length >= 2 && indexPermutationValid && 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, i) => ({ hole_number: i + 1, par: h.par, stroke_index: h.strokeIndex })),
- tees: tees.map((t) => ({
- name: t.name.trim(),
- ratings: [
- ...(t.m
- ? [{ gender: "m", course_rating: Number(t.m.courseRating), slope_rating: Number(t.m.slopeRating), par: Number(t.m.par) }]
- : []),
- ...(t.f
- ? [{ gender: "f", course_rating: Number(t.f.courseRating), slope_rating: Number(t.f.slopeRating), par: Number(t.f.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({
- source: "custom",
- personalCourseId: created.id,
- name: created.name,
- tees: tees.map((t) => ({
- name: t.name.trim(),
- genders: [...(t.m ? (["m"] as const) : []), ...(t.f ? (["f"] as const) : [])],
- })),
- })
- } catch {
- setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.")
- } finally {
- setSubmitting(false)
- }
- }
-
- return (
-
- )
-}
-
-function GenderRatingFields({
- label,
- value,
- onChange,
-}: {
- label: string
- value: TeeRatingDraft | null
- onChange: (v: TeeRatingDraft | null) => void
-}) {
- const id = label.toLowerCase().replace(/\s+/g, "-")
- return (
-
-
- onChange(e.target.checked ? { courseRating: "", slopeRating: "", par: "72" } : null)}
- className="size-5 rounded border-border"
- />
- {label}
-
- {value && (
-
- )}
-
- )
-}
-
-// --- Steg 3: bekreft + opprett runde -----------------------------------------
-
-function ConfirmRound({
- course,
- ownGender,
- onBack,
-}: {
- course: SelectedCourse
- ownGender: Gender
- onBack: () => void
-}) {
- const router = useRouter()
- const compatibleTees = course.tees.filter((t) => t.genders.includes(ownGender))
- const [teeName, setTeeName] = useState(compatibleTees[0]?.name ?? "")
- const [playedAt, setPlayedAt] = useState(() => new Date().toISOString().slice(0, 10))
- const [startHole, setStartHole] = useState(1)
- const [holesPlanned, setHolesPlanned] = useState<9 | 18>(18)
- const [submitting, setSubmitting] = useState(false)
- const [error, setError] = useState(null)
-
- async function handleSubmit() {
- if (!teeName) return
- setSubmitting(true)
- setError(null)
- try {
- const res = await fetch("/rounds", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- credentials: "include",
- body: JSON.stringify({
- course_source: course.source,
- teeoff_facility_slug: course.teeoffFacilitySlug,
- teeoff_course_id: course.teeoffCourseId,
- personal_course_id: course.personalCourseId,
- tee_name: teeName,
- played_at: playedAt,
- start_hole: startHole,
- holes_planned: holesPlanned,
- }),
- })
- if (!res.ok) throw new Error(`create round: ${res.status}`)
- const created: { id: string } = await res.json()
- router.replace(`/rounds/${created.id}`)
- } catch {
- setError("Klarte ikke å opprette runden. Prøv igjen.")
- setSubmitting(false)
- }
- }
-
- return (
-
-
-
- {course.name}
-
-
- {error &&
{error}
}
-
- {compatibleTees.length === 0 ? (
-
- 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.
-
- ) : (
-
-
Utslag
-
- {compatibleTees.map((t) => (
- setTeeName(t.name)}
- aria-pressed={teeName === t.name}
- className={cn(
- "h-11 rounded-xl border px-4 text-base font-semibold transition-colors",
- teeName === t.name
- ? "border-primary bg-primary text-primary-foreground"
- : "border-border bg-card text-foreground hover:bg-accent/50",
- )}
- >
- {t.name}
-
- ))}
-
-
- )}
-
-
-
- Dato
-
- setPlayedAt(e.target.value)}
- className="h-12 rounded-xl text-base"
- />
-
-
-
-
- Starthull
-
- setStartHole(Number(e.target.value))}
- className="h-12 rounded-xl border border-border bg-background px-3 text-base font-semibold text-foreground"
- >
- {Array.from({ length: 18 }, (_, i) => i + 1).map((n) => (
-
- Hull {n}
-
- ))}
-
-
-
-
-
Antall hull
-
- {([9, 18] as const).map((n) => (
- setHolesPlanned(n)}
- aria-pressed={holesPlanned === n}
- className={cn(
- "h-12 flex-1 rounded-xl border text-base font-bold transition-colors",
- holesPlanned === n
- ? "border-primary bg-primary text-primary-foreground"
- : "border-border bg-card text-foreground hover:bg-accent/50",
- )}
- >
- {n} hull
-
- ))}
-
-
-
-
-
- {submitting ? "Oppretter…" : "Start runden"}
-
-
- )
-}
-
-// --- Delt --------------------------------------------------------------------
-
-function BackLink({ onClick, label }: { onClick: () => void; label: string }) {
- return (
-
-
- {label}
-
- )
-}