Erstatt /my-rounds/new med ny-runde-veiviser fra egen V0-prompt
Gammel components/new-round.tsx (3364 linjer) slettet, erstattet av
components/ny-runde/ + lib/ny-runde/ -- V0-eksporten (full teknisk spec,
null designføring) ported mot de faktiske backend-kontraktene i
app/routers/rounds.py: Gender-oversettelse, forenklet Tee-form (aldri
CR/Slope til klienten), CourseMeta-discriminated-union for teeoff- vs.
egen-bane, ekte POST /rounds -> sides -> participants-sekvens, ekte
kontosøk/gjeste-oppslag, ekte banesøk inkl. geolokasjon.
Fire bugs funnet og rettet under scratch-verifisering: manglende
forwardRef på TextInput (i selve V0-eksporten), manglende
tee-synk til eierens spillerkort etter banevalg (egen portingsfeil),
manglende Ingen/Alle-hurtigknapp i steg 5 (samme mangel rettet i appen
2026-08-06), og en produksjonsbug oppdaget EKTE brukere etter utrulling:
fetchFacilityCourses() leste responsen fra offisielt banesøk som en rå
array i stedet for {slug,name,courses}-objektet backend faktisk
returnerer -- ga en uendelig "Laster baner..."-hang for enhver runde
startet på en offisiell (teeoff-)bane. Rettet + lagt til reell
feilhåndtering i OfficialCourses (manglet helt, feilen ble tidligere
en stille, ufanget promise-rejection).
Se CHANGELOG.md punkt 41 og 43 for full verifiseringsdetalj.
2026-08-08 12:34:25 +02:00
|
|
|
|
"use client"
|
|
|
|
|
|
|
|
|
|
|
|
import { FORMATS, FORMAT_MAP, isScrambleVsSolo, isTwoSided } from "@/lib/ny-runde/formats"
|
|
|
|
|
|
import type { StatLevel } from "@/lib/ny-runde/types"
|
|
|
|
|
|
import { cn } from "@/lib/utils"
|
|
|
|
|
|
import { ChevronDown } from "lucide-react"
|
|
|
|
|
|
import { useState } from "react"
|
|
|
|
|
|
import { ChoiceCard, Field, Panel, Segmented, Stepper, TextInput, Toggle, ToggleButton } from "./primitives"
|
|
|
|
|
|
import { useWizard } from "./wizard-context"
|
|
|
|
|
|
|
|
|
|
|
|
const STAT_OPTIONS: { value: StatLevel; title: string; description: string }[] = [
|
|
|
|
|
|
{ value: "score", title: "Kun slag", description: "Registrer bare score per hull." },
|
|
|
|
|
|
{ value: "score_putts", title: "Slag og putter", description: "Score pluss antall putter." },
|
|
|
|
|
|
{ value: "full", title: "All statistikk", description: "Kølle, retning, chip, bunker, straff m.m." },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
export function Step2Format() {
|
|
|
|
|
|
const { state, patch } = useWizard()
|
|
|
|
|
|
const format = state.format
|
|
|
|
|
|
const selectedDef = FORMAT_MAP[format]
|
|
|
|
|
|
const svs = isScrambleVsSolo(format)
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-6">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 className="text-lg font-semibold tracking-tight text-[var(--nr-ink)]">Spilleform</h2>
|
|
|
|
|
|
<p className="text-sm text-[var(--nr-muted)]">Velg statistikknivå og hvordan runden spilles.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Stat level for owner */}
|
|
|
|
|
|
<section className="flex flex-col gap-2">
|
|
|
|
|
|
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Statistikk for deg selv</h3>
|
|
|
|
|
|
<div className="grid gap-2 sm:grid-cols-3">
|
|
|
|
|
|
{STAT_OPTIONS.map((o) => (
|
|
|
|
|
|
<ChoiceCard
|
|
|
|
|
|
key={o.value}
|
|
|
|
|
|
selected={state.statLevel === o.value}
|
|
|
|
|
|
onSelect={() => patch({ statLevel: o.value })}
|
|
|
|
|
|
title={o.title}
|
|
|
|
|
|
description={o.description}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-08-11 08:01:42 +02:00
|
|
|
|
{/* Antall hull -- et grunnleggende oppsettsvalg, derfor FØR det store
|
|
|
|
|
|
formatrutenettet, ikke etter (2026-08-11, brukertilbakemelding: ble
|
|
|
|
|
|
tidligere lett oversett fordi den lå sist, bak et 18-knappers
|
|
|
|
|
|
rutenett man må skrolle forbi først). */}
|
|
|
|
|
|
<section className="flex flex-col gap-2">
|
|
|
|
|
|
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Antall hull</h3>
|
|
|
|
|
|
<div className="max-w-xs">
|
|
|
|
|
|
<Segmented
|
|
|
|
|
|
label="Antall hull"
|
|
|
|
|
|
value={String(state.numHoles) as "9" | "18"}
|
|
|
|
|
|
onChange={(v) => patch({ numHoles: v === "9" ? 9 : 18 })}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "9", label: "9 hull" },
|
|
|
|
|
|
{ value: "18", label: "18 hull" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Avanserte HCP-innstillinger (skjult for scramble mot solo) -- fortsatt
|
|
|
|
|
|
kollapset som standard, KUN rekkefølgen flyttet opp, samme grunn som
|
|
|
|
|
|
Antall hull over. */}
|
|
|
|
|
|
{!svs ? <AdvancedHcp /> : null}
|
|
|
|
|
|
|
Erstatt /my-rounds/new med ny-runde-veiviser fra egen V0-prompt
Gammel components/new-round.tsx (3364 linjer) slettet, erstattet av
components/ny-runde/ + lib/ny-runde/ -- V0-eksporten (full teknisk spec,
null designføring) ported mot de faktiske backend-kontraktene i
app/routers/rounds.py: Gender-oversettelse, forenklet Tee-form (aldri
CR/Slope til klienten), CourseMeta-discriminated-union for teeoff- vs.
egen-bane, ekte POST /rounds -> sides -> participants-sekvens, ekte
kontosøk/gjeste-oppslag, ekte banesøk inkl. geolokasjon.
Fire bugs funnet og rettet under scratch-verifisering: manglende
forwardRef på TextInput (i selve V0-eksporten), manglende
tee-synk til eierens spillerkort etter banevalg (egen portingsfeil),
manglende Ingen/Alle-hurtigknapp i steg 5 (samme mangel rettet i appen
2026-08-06), og en produksjonsbug oppdaget EKTE brukere etter utrulling:
fetchFacilityCourses() leste responsen fra offisielt banesøk som en rå
array i stedet for {slug,name,courses}-objektet backend faktisk
returnerer -- ga en uendelig "Laster baner..."-hang for enhver runde
startet på en offisiell (teeoff-)bane. Rettet + lagt til reell
feilhåndtering i OfficialCourses (manglet helt, feilen ble tidligere
en stille, ufanget promise-rejection).
Se CHANGELOG.md punkt 41 og 43 for full verifiseringsdetalj.
2026-08-08 12:34:25 +02:00
|
|
|
|
{/* Format grid */}
|
|
|
|
|
|
<section className="flex flex-col gap-2">
|
|
|
|
|
|
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Spilleform</h3>
|
|
|
|
|
|
<div
|
|
|
|
|
|
role="radiogroup"
|
|
|
|
|
|
aria-label="Spilleform"
|
|
|
|
|
|
className="grid grid-cols-2 gap-2 sm:grid-cols-3"
|
|
|
|
|
|
>
|
|
|
|
|
|
{FORMATS.map((f) => (
|
|
|
|
|
|
<ToggleButton key={f.id} active={format === f.id} onClick={() => patch({ format: f.id })}>
|
|
|
|
|
|
{f.label}
|
|
|
|
|
|
</ToggleButton>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Panel className="mt-1 bg-[var(--nr-surface-2)] py-3">
|
|
|
|
|
|
<p className="text-sm text-[var(--nr-ink)]">
|
|
|
|
|
|
<span className="font-semibold">{selectedDef.label}.</span>{" "}
|
|
|
|
|
|
<span className="text-[var(--nr-muted)]">{selectedDef.description}</span>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</Panel>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Format-specific config */}
|
|
|
|
|
|
<FormatConfig />
|
|
|
|
|
|
|
|
|
|
|
|
{/* Solo allowance (scramble vs solo only) */}
|
|
|
|
|
|
{svs ? (
|
|
|
|
|
|
<section className="flex flex-col gap-2">
|
|
|
|
|
|
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Individualspillerens handicap-andel</h3>
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<Stepper
|
|
|
|
|
|
value={state.soloAllowance}
|
|
|
|
|
|
onChange={(v) => patch({ soloAllowance: v })}
|
|
|
|
|
|
min={0}
|
|
|
|
|
|
max={100}
|
|
|
|
|
|
step={5}
|
|
|
|
|
|
snap
|
|
|
|
|
|
suffix="%"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p className="text-xs text-[var(--nr-muted)]">Andel av enkeltspillerens handicap som teller (0–100 %).</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function FormatConfig() {
|
|
|
|
|
|
const { state, patch } = useWizard()
|
|
|
|
|
|
const f = state.format
|
|
|
|
|
|
|
|
|
|
|
|
if (f === "match") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<ConfigCard>
|
|
|
|
|
|
<CheckboxRow
|
|
|
|
|
|
checked={state.matchExcludeHcp}
|
|
|
|
|
|
onChange={(v) => patch({ matchExcludeHcp: v })}
|
|
|
|
|
|
label="Ekskluder denne runden fra mitt faktiske HCP"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</ConfigCard>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (f === "skins") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<ConfigCard>
|
|
|
|
|
|
<Field label="Poengberegning">
|
|
|
|
|
|
<Segmented
|
|
|
|
|
|
value={state.skinsScoring}
|
|
|
|
|
|
onChange={(v) => patch({ skinsScoring: v })}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "net", label: "Netto (m/HCP)" },
|
|
|
|
|
|
{ value: "gross", label: "Brutto" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
<Field label="Ved uavgjort hull">
|
|
|
|
|
|
<Segmented
|
|
|
|
|
|
value={state.skinsTie}
|
|
|
|
|
|
onChange={(v) => patch({ skinsTie: v })}
|
|
|
|
|
|
options={[
|
|
|
|
|
|
{ value: "carry", label: "Rullerer over" },
|
|
|
|
|
|
{ value: "split", label: "Deles likt" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
<p className="text-xs text-[var(--nr-muted)]">Skins krever minst 3 spillere.</p>
|
|
|
|
|
|
</ConfigCard>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (f === "bbb") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<ConfigCard>
|
|
|
|
|
|
<CheckboxRow
|
|
|
|
|
|
checked={state.bbbSweep}
|
|
|
|
|
|
onChange={(v) => patch({ bbbSweep: v })}
|
|
|
|
|
|
label="Bonuspoeng ved «sveip»"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</ConfigCard>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (f === "shamble") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<ConfigCard>
|
|
|
|
|
|
<Field label="Beste N av laget teller per hull">
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
{[1, 2, 3, 4].map((n) => (
|
|
|
|
|
|
<ToggleButton
|
|
|
|
|
|
key={n}
|
|
|
|
|
|
active={state.shambleBestN === n}
|
|
|
|
|
|
onClick={() => patch({ shambleBestN: n })}
|
|
|
|
|
|
className="flex-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
{n}
|
|
|
|
|
|
</ToggleButton>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
</ConfigCard>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (f === "moneyball") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<ConfigCard>
|
|
|
|
|
|
<p className="text-sm text-[var(--nr-muted)]">Rotasjonen for «money ball» settes opp i neste steg.</p>
|
|
|
|
|
|
</ConfigCard>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (f === "copenhagen") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<ConfigCard>
|
|
|
|
|
|
<p className="text-sm text-[var(--nr-muted)]">Nøyaktig 3 spillere.</p>
|
|
|
|
|
|
</ConfigCard>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isScrambleVsSolo(f)) {
|
|
|
|
|
|
return null // handled by the dedicated allowance stepper
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isTwoSided(f)) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<ConfigCard>
|
|
|
|
|
|
<p className="text-sm text-[var(--nr-muted)]">Du setter opp lagene i neste steg.</p>
|
|
|
|
|
|
</ConfigCard>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function AdvancedHcp() {
|
|
|
|
|
|
const { state, patch } = useWizard()
|
|
|
|
|
|
const [open, setOpen] = useState(false)
|
|
|
|
|
|
const f = state.format
|
|
|
|
|
|
const isSlagOrStable = f === "slagspill" || f === "stableford"
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section className="rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)]">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setOpen((o) => !o)}
|
|
|
|
|
|
aria-expanded={open}
|
|
|
|
|
|
className="flex min-h-11 w-full items-center justify-between gap-2 px-4 py-3 text-left focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="text-sm font-semibold text-[var(--nr-ink)]">Avanserte handicap-innstillinger</span>
|
|
|
|
|
|
<ChevronDown className={cn("h-4 w-4 text-[var(--nr-muted)] transition-transform", open && "rotate-180")} aria-hidden="true" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{open ? (
|
|
|
|
|
|
<div className="flex flex-col gap-4 border-t border-[var(--nr-border)] p-4">
|
|
|
|
|
|
<Toggle checked={state.useHandicap} onChange={(v) => patch({ useHandicap: v })} label="Bruk handicap" />
|
|
|
|
|
|
|
|
|
|
|
|
{state.useHandicap && !isSlagOrStable ? (
|
|
|
|
|
|
<Toggle
|
|
|
|
|
|
checked={state.useCourseHcpAdj}
|
|
|
|
|
|
onChange={(v) => patch({ useCourseHcpAdj: v })}
|
|
|
|
|
|
label="Bruk course handicap-justering"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{state.useHandicap && isTwoSided(f) ? (
|
|
|
|
|
|
<Toggle
|
|
|
|
|
|
checked={state.useMatchplayHcp}
|
|
|
|
|
|
onChange={(v) => patch({ useMatchplayHcp: v })}
|
|
|
|
|
|
label="Bruk matchplay-handicap"
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{state.useHandicap ? (
|
|
|
|
|
|
<Field label="HCP-prosent" optional htmlFor="hcp-pct">
|
|
|
|
|
|
<TextInput
|
|
|
|
|
|
id="hcp-pct"
|
|
|
|
|
|
inputMode="decimal"
|
|
|
|
|
|
value={state.hcpPercent}
|
|
|
|
|
|
onChange={(e) => patch({ hcpPercent: e.target.value })}
|
|
|
|
|
|
placeholder="F.eks. 95"
|
|
|
|
|
|
className="max-w-[10rem]"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ConfigCard({ children }: { children: React.ReactNode }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section className="flex flex-col gap-4 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] p-4">
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function CheckboxRow({
|
|
|
|
|
|
checked,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
label,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
checked: boolean
|
|
|
|
|
|
onChange: (v: boolean) => void
|
|
|
|
|
|
label: string
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="checkbox"
|
|
|
|
|
|
aria-checked={checked}
|
|
|
|
|
|
onClick={() => onChange(!checked)}
|
|
|
|
|
|
className="flex min-h-11 w-full items-center gap-3 text-left focus-visible:outline-none"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex h-5 w-5 shrink-0 items-center justify-center rounded-[6px] border transition-colors",
|
|
|
|
|
|
checked ? "border-[var(--nr-accent)] bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]" : "border-[var(--nr-border-strong)] bg-[var(--nr-surface)]",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{checked ? (
|
|
|
|
|
|
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
|
|
|
|
<path d="M3 8.5l3.5 3.5L13 5" strokeLinecap="round" strokeLinejoin="round" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-sm text-[var(--nr-ink)]">{label}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|