teecup/frontend/components/ny-runde/step3-players.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

576 lines
20 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 { apiGenderOf, lookupKnownGuest, searchAccounts } from "@/lib/ny-runde/api"
import type { Account, Course, Gender, KnownGuest, Player, StatLevel } from "@/lib/ny-runde/types"
import { cn } from "@/lib/utils"
import { ChevronDown, Search, Sparkles, UserPlus, X } from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import { Avatar, Field, NativeSelect, Pill, TextInput, ToggleButton, useDebouncedValue } from "./primitives"
import { useWizard } from "./wizard-context"
const GENDERS: { value: Gender; label: string }[] = [
{ value: "mann", label: "Mann" },
{ value: "kvinne", label: "Kvinne" },
{ value: "annet", label: "Annet" },
]
const STAT: { value: StatLevel; label: string }[] = [
{ value: "score", label: "Kun slag" },
{ value: "score_putts", label: "Slag + putter" },
{ value: "full", label: "Alt" },
]
let keyCounter = 0
function nextKey() {
keyCounter += 1
return `p-${Date.now()}-${keyCounter}`
}
function teeName(course: Course | undefined, teeId?: string) {
if (!course || !teeId) return "Ikke valgt"
return course.tees.find((t) => t.id === teeId)?.name ?? "Ikke valgt"
}
function compatibleTees(course: Course | undefined, gender?: Gender) {
if (!course) return []
if (!gender) return course.tees // ukjent kjønn (nylig lenket konto): vis alle
const g = apiGenderOf(gender)
if (g === "x") return course.tees
return course.tees.filter((t) => t.genders.includes(g))
}
export function Step3Players() {
const { state, patch } = useWizard()
const [adding, setAdding] = useState(false)
function updatePlayer(key: string, p: Partial<Player>) {
patch({ players: state.players.map((pl) => (pl.key === key ? { ...pl, ...p } : pl)) })
}
function removePlayer(key: string) {
patch({ players: state.players.filter((pl) => pl.key !== key) })
}
function addPlayer(p: Player) {
patch({ players: [...state.players, p] })
setAdding(false)
}
return (
<div className="flex flex-col gap-4">
<div>
<h2 className="text-lg font-semibold tracking-tight text-[var(--nr-ink)]">Spillere</h2>
<p className="text-sm text-[var(--nr-muted)]">
{state.players.length} {state.players.length === 1 ? "spiller" : "spillere"} i runden.
</p>
</div>
<ul className="flex flex-col gap-2">
{state.players.map((player) => (
<li key={player.key}>
<PlayerCard
player={player}
course={state.course}
onChange={(p) => updatePlayer(player.key, p)}
onRemove={() => removePlayer(player.key)}
/>
</li>
))}
</ul>
{adding ? (
<AddPlayerPanel
course={state.course}
defaultStat={state.statLevel}
existing={state.players}
onCancel={() => setAdding(false)}
onAdd={addPlayer}
/>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl border border-dashed border-[var(--nr-border-strong)] bg-[var(--nr-surface)] px-4 text-sm font-medium text-[var(--nr-accent)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<UserPlus className="h-4 w-4" aria-hidden="true" />
Medspiller
</button>
)}
</div>
)
}
/* ------------------------------------------------------------------ */
/* Player card */
function PlayerCard({
player,
course,
onChange,
onRemove,
}: {
player: Player
course?: Course
onChange: (p: Partial<Player>) => void
onRemove: () => void
}) {
const [open, setOpen] = useState(false)
const fullName = `${player.firstName}${player.lastName ? ` ${player.lastName}` : ""}`.trim()
const isOwner = player.kind === "owner"
const hcpText = player.hcpFromProfile
? player.hcp != null
? String(player.hcp)
: "fra profil"
: player.hcp != null
? String(player.hcp)
: ""
const tees = compatibleTees(course, player.gender)
return (
<div className="rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)]">
<div className="flex items-center gap-3 p-3">
<Avatar first={player.firstName || "?"} last={player.lastName} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-semibold text-[var(--nr-ink)]">{fullName || "Ny spiller"}</span>
{isOwner ? <Pill tone="accent">Deg · Eier</Pill> : null}
{player.isGuest ? <Pill tone="neutral">Gjest</Pill> : null}
</div>
<p className="truncate text-xs text-[var(--nr-muted)]">
Utslag: {teeName(course, player.teeId)} · HCP: {hcpText}
</p>
</div>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-label={open ? `Skjul redigering av ${fullName}` : `Rediger ${fullName}`}
className="flex h-10 w-10 items-center justify-center rounded-lg border border-[var(--nr-border)] text-[var(--nr-muted)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<ChevronDown className={cn("h-4 w-4 transition-transform", open && "rotate-180")} aria-hidden="true" />
</button>
{!isOwner ? (
<button
type="button"
onClick={onRemove}
aria-label={`Fjern ${fullName}`}
className="flex h-10 w-10 items-center justify-center rounded-lg border border-[var(--nr-border)] text-[var(--nr-muted)] transition-colors hover:text-[var(--nr-danger)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
) : null}
</div>
{open ? (
<div className="flex flex-col gap-4 border-t border-[var(--nr-border)] p-4">
{player.isGuest ? (
<>
<div className="grid grid-cols-2 gap-3">
<Field label="Fornavn" htmlFor={`${player.key}-first`}>
<TextInput
id={`${player.key}-first`}
value={player.firstName}
onChange={(e) => onChange({ firstName: e.target.value })}
invalid={player.firstName.trim() === ""}
/>
</Field>
<Field label="Etternavn" optional htmlFor={`${player.key}-last`}>
<TextInput
id={`${player.key}-last`}
value={player.lastName}
onChange={(e) => onChange({ lastName: e.target.value })}
/>
</Field>
</div>
<Field label="Kjønn">
<GenderChoice
value={player.gender ?? "mann"}
onChange={(g) => {
const stillOk = compatibleTees(course, g).some((t) => t.id === player.teeId)
onChange({ gender: g, teeId: stillOk ? player.teeId : compatibleTees(course, g)[0]?.id })
}}
/>
</Field>
<Field label="E-post" optional htmlFor={`${player.key}-email`}>
<TextInput
id={`${player.key}-email`}
type="email"
value={player.email ?? ""}
onChange={(e) => onChange({ email: e.target.value })}
/>
</Field>
</>
) : null}
<Field label="Utslag" htmlFor={`${player.key}-tee`}>
<NativeSelect
id={`${player.key}-tee`}
value={player.teeId ?? ""}
onChange={(e) => onChange({ teeId: e.target.value })}
>
<option value="">Velg utslag</option>
{tees.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</NativeSelect>
</Field>
{player.isGuest ? (
<Field label="HCP" optional htmlFor={`${player.key}-hcp`}>
<TextInput
id={`${player.key}-hcp`}
inputMode="decimal"
value={player.hcp != null ? String(player.hcp) : ""}
onChange={(e) => {
const v = e.target.value
onChange({ hcp: v.trim() === "" ? null : Number.parseFloat(v.replace(",", ".")) })
}}
className="max-w-[10rem]"
/>
</Field>
) : player.hcpFromProfile && !isOwner ? (
<Field label="HCP">
<div className="rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface-2)] px-3 py-2.5 text-sm text-[var(--nr-muted)]">
{player.hcp != null ? `${player.hcp} · hentes fra profilen` : "Hentes fra profilen…"}
</div>
</Field>
) : null}
<Field label="Statistikk">
<StatChoice value={player.statLevel} onChange={(s) => onChange({ statLevel: s })} />
</Field>
</div>
) : null}
</div>
)
}
/* ------------------------------------------------------------------ */
/* Legg til-panel (søk + gjest) */
function AddPlayerPanel({
course,
defaultStat,
existing,
onCancel,
onAdd,
}: {
course?: Course
defaultStat: StatLevel
existing: Player[]
onCancel: () => void
onAdd: (p: Player) => void
}) {
const [mode, setMode] = useState<"search" | "guest">("search")
const [query, setQuery] = useState("")
const debounced = useDebouncedValue(query, 250)
const [results, setResults] = useState<Account[]>([])
// gjesteskjema
const [first, setFirst] = useState("")
const [last, setLast] = useState("")
const [email, setEmail] = useState("")
const [gender, setGender] = useState<Gender>("mann")
const [hcp, setHcp] = useState("")
const [stat, setStat] = useState<StatLevel>(defaultStat)
const firstRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (mode === "guest") firstRef.current?.focus()
}, [mode])
const existingAccountIds = new Set(existing.map((p) => p.accountId).filter(Boolean))
useEffect(() => {
const q = debounced.trim()
if (q.length < 2) {
setResults([])
return
}
let cancelled = false
void searchAccounts(q).then((r) => {
if (!cancelled) setResults(r.filter((a) => !existingAccountIds.has(a.id)))
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debounced])
function switchToGuest() {
const parts = query.trim().split(/\s+/)
setFirst(parts[0] ?? "")
setLast(parts.slice(1).join(" "))
setMode("guest")
}
function addAccount(acc: Account) {
onAdd({
key: nextKey(),
kind: "account",
accountId: acc.id,
firstName: acc.firstName,
lastName: acc.lastName,
gender: undefined, // ukjent klient-side inntil profilen lastes
isGuest: false,
hcp: acc.handicap,
hcpFromProfile: true,
teeId: course?.tees[0]?.id,
statLevel: defaultStat,
})
}
function addGuest() {
if (first.trim() === "") return
onAdd({
key: nextKey(),
kind: "guest",
firstName: first.trim(),
lastName: last.trim(),
gender,
isGuest: true,
email: email.trim() || undefined,
hcp: hcp.trim() === "" ? null : Number.parseFloat(hcp.replace(",", ".")),
teeId: compatibleTees(course, gender)[0]?.id,
statLevel: stat,
})
}
return (
<div className="rounded-xl border border-[var(--nr-accent-ring)] bg-[var(--nr-surface)] p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Legg til medspiller</h3>
<button
type="button"
onClick={onCancel}
aria-label="Lukk"
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--nr-muted)] hover:bg-[var(--nr-surface-2)]"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{mode === "search" ? (
<div className="flex flex-col gap-3">
<Field label="Navn på medspilleren" htmlFor="add-search" hint="Skriv minst 2 tegn for å søke.">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--nr-faint)]" aria-hidden="true" />
<TextInput
id="add-search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Søk etter en konto…"
className="pl-9"
autoComplete="off"
/>
</div>
</Field>
{debounced.trim().length >= 2 ? (
results.length > 0 ? (
<ul className="flex flex-col gap-2">
{results.map((a) => (
<li key={a.id}>
<button
type="button"
onClick={() => addAccount(a)}
className="flex min-h-14 w-full items-center gap-3 rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] px-3 py-2 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<Avatar first={a.firstName} last={a.lastName} />
<span className="flex-1">
<span className="block text-sm font-medium text-[var(--nr-ink)]">{a.firstName} {a.lastName}</span>
<span className="block text-xs text-[var(--nr-muted)]">{a.handicap != null ? `HCP ${a.handicap}` : "Ingen HCP registrert"}</span>
</span>
<UserPlus className="h-4 w-4 text-[var(--nr-accent)]" aria-hidden="true" />
</button>
</li>
))}
</ul>
) : (
<p className="text-sm text-[var(--nr-muted)]">Ingen kontoer matcher søket.</p>
)
) : null}
<button
type="button"
onClick={switchToGuest}
className="text-left text-[13px] font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
>
Legg til uten konto (gjest)
</button>
</div>
) : (
<GuestForm
first={first}
setFirst={setFirst}
last={last}
setLast={setLast}
email={email}
setEmail={setEmail}
gender={gender}
setGender={setGender}
hcp={hcp}
setHcp={setHcp}
stat={stat}
setStat={setStat}
firstRef={firstRef}
onBackToSearch={() => setMode("search")}
onSubmit={addGuest}
/>
)}
</div>
)
}
function GuestForm({
first,
setFirst,
last,
setLast,
email,
setEmail,
gender,
setGender,
hcp,
setHcp,
stat,
setStat,
firstRef,
onBackToSearch,
onSubmit,
}: {
first: string
setFirst: (v: string) => void
last: string
setLast: (v: string) => void
email: string
setEmail: (v: string) => void
gender: Gender
setGender: (v: Gender) => void
hcp: string
setHcp: (v: string) => void
stat: StatLevel
setStat: (v: StatLevel) => void
firstRef: React.RefObject<HTMLInputElement | null>
onBackToSearch: () => void
onSubmit: () => void
}) {
// kjent-gjest-oppslag på e-post (scoped til DENNE brukeren server-side)
const debouncedEmail = useDebouncedValue(email, 350)
const [match, setMatch] = useState<KnownGuest | null>(null)
useEffect(() => {
const e = debouncedEmail.trim()
if (e.length < 5 || !e.includes("@")) {
setMatch(null)
return
}
let cancelled = false
void lookupKnownGuest(e).then((g) => {
if (!cancelled) setMatch(g)
})
return () => {
cancelled = true
}
}, [debouncedEmail])
return (
<div className="flex flex-col gap-3">
<button
type="button"
onClick={onBackToSearch}
className="self-start text-[13px] font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
>
Søk etter konto i stedet
</button>
<div className="grid grid-cols-2 gap-3">
<Field label="Fornavn" htmlFor="g-first">
<TextInput id="g-first" ref={firstRef} value={first} onChange={(e) => setFirst(e.target.value)} invalid={first.trim() === ""} />
</Field>
<Field label="Etternavn" optional htmlFor="g-last">
<TextInput id="g-last" value={last} onChange={(e) => setLast(e.target.value)} />
</Field>
</div>
<Field label="E-post" optional htmlFor="g-email">
<TextInput id="g-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="navn@example.com" />
</Field>
{match ? (
<div className="flex items-start gap-3 rounded-lg border border-[var(--nr-accent-ring)] bg-[var(--nr-accent-soft)] p-3">
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-[var(--nr-accent)]" aria-hidden="true" />
<div className="flex-1">
<p className="text-[13px] text-[var(--nr-ink)]">
Vi fant {match.firstName} {match.lastName} fra en tidligere runde du satte opp.
</p>
<button
type="button"
onClick={() => {
setFirst(match.firstName)
setLast(match.lastName)
if (match.gender === "m") setGender("mann")
else if (match.gender === "f") setGender("kvinne")
if (match.hcp != null) setHcp(String(match.hcp))
}}
className="mt-1 text-[13px] font-semibold text-[var(--nr-accent)] underline-offset-2 hover:underline"
>
Fyll inn automatisk
</button>
</div>
</div>
) : null}
<Field label="Kjønn">
<GenderChoice value={gender} onChange={setGender} />
</Field>
<Field label="HCP" optional htmlFor="g-hcp">
<TextInput
id="g-hcp"
inputMode="decimal"
value={hcp}
onChange={(e) => setHcp(e.target.value)}
className="max-w-[10rem]"
/>
</Field>
<Field label="Statistikk for denne spilleren">
<StatChoice value={stat} onChange={setStat} />
</Field>
<button
type="button"
onClick={onSubmit}
disabled={first.trim() === ""}
className="mt-1 inline-flex min-h-11 items-center justify-center rounded-lg bg-[var(--nr-accent)] px-5 text-sm font-semibold text-[var(--nr-accent-ink)] transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
>
Legg til gjest
</button>
</div>
)
}
/* ------------------------------------------------------------------ */
function GenderChoice({ value, onChange }: { value: Gender; onChange: (v: Gender) => void }) {
return (
<div className="flex gap-2">
{GENDERS.map((g) => (
<ToggleButton key={g.value} active={value === g.value} onClick={() => onChange(g.value)} className="flex-1">
{g.label}
</ToggleButton>
))}
</div>
)
}
function StatChoice({ value, onChange }: { value: StatLevel; onChange: (v: StatLevel) => void }) {
return (
<div className="flex gap-2">
{STAT.map((s) => (
<ToggleButton key={s.value} active={value === s.value} onClick={() => onChange(s.value)} className="flex-1">
{s.label}
</ToggleButton>
))}
</div>
)
}