teecup/frontend/components/ny-runde/step2-format.tsx
Erol Haagenrud 11d10f7661 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

315 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client"
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>
{/* 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 (0100 %).</p>
</div>
</section>
) : null}
{/* Advanced HCP (hidden for scramble vs solo) */}
{!svs ? <AdvancedHcp /> : null}
{/* Number of holes */}
<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>
</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>
)
}