Fire rettet direkte (reelle logikkfeil/tekstvalg): round-card.tsx sin
"Hull"-celle viste alltid planlagt antall, aldri faktisk spilt for
tidlig avsluttede runder; putt-avstand-etiketter gjort konsekvente
("X-Ym" i stedet for blandet "<Xm ... 8m+"); "Se feed" presisert til
"Se venneaktivitet"; @-tagging av medspillere vurdert og bevisst utsatt.
Fire sendt via V0 (reelt interaksjonsdesign), diffet mot live-treet før
integrering: ny-runde steg 2 sin rekkefølge (Antall hull/Avanserte HCP
flyttet før formatrutenettet), "detaljer"-steget delt i seksjoner med
et nytt scroll-hint, Anywayslag unifisert til samme +/--mønster som
Chip/Bunker/Straffeslag, netto par-markør + egen Putter-aksent.
Se ADR-062/CHANGELOG punkt 71 for full detalj. tsc rent, full
scratch-stack browserverifisert (lys+mørk, mobil viewport) før
utrulling.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
"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>
|
||
|
||
{/* 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}
|
||
|
||
{/* 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>
|
||
)
|
||
}
|