teecup/frontend/components/ny-runde/step3-players.tsx

637 lines
22 KiB
TypeScript
Raw Normal View History

"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 type { Tee } from "@/lib/ny-runde/types"
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}
// Standard for EN ANNEN spiller enn den som fører score er "Kun
// slag", uansett hva scorefører selv har valgt for seg selv
// (2026-08-20 brukerfunn -- speilet tidligere feilaktig
// state.statLevel, som normalt er scoreførerens EGET, ofte
// høyere nivå). Organisatoren kan fortsatt sette et høyere nivå
// manuelt per medspiller.
defaultStat="score"
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 ? (
// Redigerbar fra FØRSTE stund (2026-08-20 brukerfunn) -- var
// tidligere en read-only visning ("hentes fra profilen"), som
// tvang organisatoren til å legge til spilleren FØRST og
// overstyre HCP-en senere via "Runde og spillere" (der
// overstyring allerede fungerte, samme PATCH-felt). Forhånds-
// utfylt med profilverdien; urørt betyr fortsatt "bruk
// profilen", akkurat som før.
<Field
label="HCP"
optional
htmlFor={`${player.key}-hcp`}
hint="Hentes fra spillerens profil dersom du lar feltet stå urørt."
>
<TextInput
id={`${player.key}-hcp`}
inputMode="decimal"
placeholder={player.hcp == null ? "Henter fra profil…" : undefined}
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>
) : 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 [teeId, setTeeId] = useState<string | undefined>(() => compatibleTees(course, "mann")[0]?.id)
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,
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
course={course}
first={first}
setFirst={setFirst}
last={last}
setLast={setLast}
email={email}
setEmail={setEmail}
gender={gender}
setGender={setGender}
hcp={hcp}
setHcp={setHcp}
stat={stat}
setStat={setStat}
teeId={teeId}
setTeeId={setTeeId}
firstRef={firstRef}
onBackToSearch={() => setMode("search")}
onSubmit={addGuest}
/>
)}
</div>
)
}
function GuestForm({
course,
first,
setFirst,
last,
setLast,
email,
setEmail,
gender,
setGender,
hcp,
setHcp,
stat,
setStat,
teeId,
setTeeId,
firstRef,
onBackToSearch,
onSubmit,
}: {
course?: Course
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
teeId: string | undefined
setTeeId: (v: string | undefined) => void
firstRef: React.RefObject<HTMLInputElement | null>
onBackToSearch: () => void
onSubmit: () => void
}) {
const tees = compatibleTees(course, gender)
// 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)
const matchedGender = match.gender === "m" ? "mann" : match.gender === "f" ? "kvinne" : null
if (matchedGender) {
const stillOk = compatibleTees(course, matchedGender).some((t) => t.id === teeId)
setGender(matchedGender)
if (!stillOk) setTeeId(compatibleTees(course, matchedGender)[0]?.id)
}
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={(g) => {
const stillOk = compatibleTees(course, g).some((t) => t.id === teeId)
setGender(g)
if (!stillOk) setTeeId(compatibleTees(course, g)[0]?.id)
}}
/>
</Field>
<Field label="Utslag" htmlFor="g-tee">
<NativeSelect id="g-tee" value={teeId ?? ""} onChange={(e) => setTeeId(e.target.value || undefined)}>
<option value="">Velg utslag</option>
{tees.map((t: Tee) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</NativeSelect>
</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>
)
}