teecup/frontend/components/ny-runde/step2-format.tsx
Erol Haagenrud 3db00e72db Åtte brukerrapporterte UX-funn: ny-runde og score-registrering
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>
2026-08-11 08:01:42 +02:00

320 lines
10 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>
{/* 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 (0100 %).</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>
)
}