teecup/frontend/components/tournament-detail.tsx

965 lines
34 KiB
TypeScript
Raw Normal View History

"use client"
import type React from "react"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import {
ArrowLeft,
Check,
Copy,
Flag,
KeyRound,
MessageCircle,
MoreVertical,
Plus,
Star,
Trash2,
UserPlus,
Users,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
import { cn } from "@/lib/utils"
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/players.py) -
type ApiPlayer = {
id: string
display_name: string
handicap_index: number | null
}
type ApiRosterEntry = {
id: string
player_id: string
display_name: string
handicap_index_snapshot: number | null
is_captain: boolean
}
type Team = {
id: string
name: string
color: string
roster: ApiRosterEntry[]
}
// Team colors er DATA brukt på scorekort senere -- bevisst atskilt fra
// app-ens merkevare-grønn/oransje.
const TEAM_COLORS: { value: string; label: string }[] = [
{ value: "#2563eb", label: "Blå" },
{ value: "#dc2626", label: "Rød" },
{ value: "#7c3aed", label: "Lilla" },
{ value: "#0891b2", label: "Turkis" },
{ value: "#db2777", label: "Rosa" },
{ value: "#475569", label: "Skifer" },
]
// --- Component -------------------------------------------------------------
export function TournamentDetail({
organizationId,
tournamentId,
tournamentName,
}: {
organizationId: string
tournamentId: string
tournamentName: string
}) {
const [teams, setTeams] = useState<[Team | null, Team | null]>([null, null])
const [pool, setPool] = useState<ApiPlayer[]>([])
const [joinCode, setJoinCode] = useState<string | null>(null)
const [status, setStatus] = useState<TournamentStatus | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const [teamsRes, poolRes, tournamentsRes] = await Promise.all([
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, {
credentials: "include",
}),
fetch(`/orgs/${organizationId}/players`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" }),
])
if (!teamsRes.ok || !poolRes.ok) throw new Error("load failed")
const teamsData: { id: string; name: string; color: string | null }[] =
await teamsRes.json()
const poolData: ApiPlayer[] = await poolRes.json()
// Ingen enkelt-turnering-GET finnes ennå -- listen bærer allerede
// join_code (ADR-020), så vi finner raden herfra i stedet for å
// legge til et nytt endepunkt kun for dette.
if (tournamentsRes.ok) {
const tournamentsData: { id: string; join_code: string; status: TournamentStatus }[] =
await tournamentsRes.json()
const mine = tournamentsData.find((t) => t.id === tournamentId)
if (mine) {
setJoinCode(mine.join_code)
setStatus(mine.status)
}
}
const withRosters = await Promise.all(
teamsData.map(async (t) => {
const rosterRes = await fetch(`/orgs/${organizationId}/teams/${t.id}/roster`, {
credentials: "include",
})
const roster: ApiRosterEntry[] = rosterRes.ok ? await rosterRes.json() : []
return { id: t.id, name: t.name, color: t.color ?? TEAM_COLORS[0].value, roster }
}),
)
if (cancelled) return
setTeams([withRosters[0] ?? null, withRosters[1] ?? null])
setPool(poolData)
} catch {
if (!cancelled) setError("Klarte ikke å laste lag og spillere. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [organizationId, tournamentId])
async function createTeam(slot: 0 | 1, name: string, color: string) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name, color }),
})
if (!res.ok) throw new Error(`create team: ${res.status}`)
const created: { id: string; name: string; color: string | null } = await res.json()
setTeams((prev) => {
const next: [Team | null, Team | null] = [prev[0], prev[1]]
next[slot] = { id: created.id, name: created.name, color: created.color ?? color, roster: [] }
return next
})
} catch {
setError("Klarte ikke å opprette laget. Prøv igjen.")
}
}
async function addExistingPlayer(teamId: string, playerId: string) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/roster`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ player_id: playerId, is_captain: false }),
})
if (!res.ok) throw new Error(`add roster: ${res.status}`)
const entry: ApiRosterEntry = await res.json()
setTeams((prev) =>
prev.map((t) => (t && t.id === teamId ? { ...t, roster: [...t.roster, entry] } : t)) as [
Team | null,
Team | null,
],
)
} catch {
setError("Klarte ikke å legge til spilleren. Prøv igjen.")
}
}
async function addNewPlayer(teamId: string, name: string, handicap?: number) {
setError(null)
try {
const playerRes = await fetch(`/orgs/${organizationId}/players`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ display_name: name, handicap_index: handicap ?? null }),
})
if (!playerRes.ok) throw new Error(`create player: ${playerRes.status}`)
const player: ApiPlayer = await playerRes.json()
setPool((prev) => [...prev, player])
await addExistingPlayer(teamId, player.id)
} catch {
setError("Klarte ikke å opprette spilleren. Prøv igjen.")
}
}
async function toggleCaptain(teamId: string, rosterId: string, current: boolean) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/roster/${rosterId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ is_captain: !current }),
})
if (!res.ok) throw new Error(`toggle captain: ${res.status}`)
const updated: ApiRosterEntry = await res.json()
setTeams((prev) =>
prev.map((t) =>
t && t.id === teamId
? { ...t, roster: t.roster.map((r) => (r.id === updated.id ? updated : r)) }
: t,
) as [Team | null, Team | null],
)
} catch {
setError("Klarte ikke å endre kaptein. Prøv igjen.")
}
}
async function removePlayer(teamId: string, rosterId: string) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/roster/${rosterId}`, {
method: "DELETE",
credentials: "include",
})
if (!res.ok && res.status !== 204) throw new Error(`remove roster: ${res.status}`)
setTeams((prev) =>
prev.map((t) =>
t && t.id === teamId ? { ...t, roster: t.roster.filter((r) => r.id !== rosterId) } : t,
) as [Team | null, Team | null],
)
} catch {
setError("Klarte ikke å fjerne spilleren. Prøv igjen.")
}
}
async function updateStatus(newStatus: TournamentStatus) {
setError(null)
const previous = status
setStatus(newStatus) // optimistisk -- rulles tilbake under ved feil
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ status: newStatus }),
})
if (!res.ok) throw new Error(`update status: ${res.status}`)
} catch {
setStatus(previous)
setError("Klarte ikke å endre turnering-status. Prøv igjen.")
}
}
// Walkover/konsesjon på turnering-nivå (ADR-024): gir opp ALLE
// ikke-avgjorte matcher laget har i turneringen, i én operasjon. Kun
// kaptein for laget som gir seg (eller org-admin) får lov -- backend
// håndhever dette, en 403 vises bare som vanlig feiltekst.
async function concedeTournament(teamId: string): Promise<string | null> {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/concede`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ conceding_team_id: teamId }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
const message: string = body?.detail?.message ?? "Klarte ikke å gi opp turneringen."
setError(message)
return message
}
return null
} catch {
const message = "Klarte ikke å gi opp turneringen."
setError(message)
return message
}
}
// Hvilket lag er en gitt spiller (fra pool) allerede rostret på, om noen?
function findPlayerTeam(playerId: string): Team | null {
for (const t of teams) {
if (t && t.roster.some((r) => r.player_id === playerId)) return t
}
return null
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-4xl items-center gap-3 px-5 py-4">
<Link
href="/dashboard"
aria-label="Tilbake til dashbord"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Turnering
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
{tournamentName}
</h1>
</div>
{status && <TournamentStatusPicker status={status} onChange={updateStatus} />}
{joinCode && <JoinCodeChip code={joinCode} />}
</div>
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
<span
aria-current="page"
className="rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
>
Lag og spillere
</span>
<Link
href={`/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Program
</Link>
<Link
href={`/tournaments/${tournamentId}/leaderboard?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Leaderboard
</Link>
</nav>
</header>
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-6 sm:py-8">
<div className="mb-5 flex flex-col gap-1">
<h2 className="text-base font-bold text-foreground">Lag og spillere</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Ryder Cup-format: nøyaktig to lag. Sett opp begge lagene og fyll troppene før
turneringen kan starte.
</p>
</div>
{error && (
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
{error}
</p>
)}
{loading ? (
<div className="flex justify-center py-16">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
{([0, 1] as const).map((slot) => {
const team = teams[slot]
if (team) {
const otherTeam = teams[slot === 0 ? 1 : 0]
return (
<TeamPanel
key={team.id}
team={team}
otherTeam={otherTeam}
pool={pool}
chatHref={`/tournaments/${tournamentId}/teams/${team.id}/chat?org=${organizationId}&name=${encodeURIComponent(tournamentName)}&team=${encodeURIComponent(team.name)}`}
findPlayerTeam={findPlayerTeam}
onToggleCaptain={toggleCaptain}
onRemovePlayer={removePlayer}
onAddExisting={addExistingPlayer}
onAddNew={addNewPlayer}
onConcedeTournament={concedeTournament}
/>
)
}
return <CreateTeamCard key={`slot-${slot}`} slot={slot} onCreate={createTeam} />
})}
</div>
)}
</main>
</div>
)
}
// --- Invitasjonskode-chip (ADR-020) -----------------------------------------
// Vises til organisator slik at koden kan gis muntlig/på en lapp til
// spillere som ellers ikke ville funnet turneringen. Overstyrer
// tournament.visibility når den brukes -- se login-skjermets kode-felt.
function JoinCodeChip({ code }: { code: string }) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Utilgjengelig clipboard-API (f.eks. usikker kontekst) -- koden er
// uansett synlig i chipen, bare uten kopier-snarveien.
}
}
return (
<button
type="button"
onClick={handleCopy}
title="Del denne koden med spillere som skal finne turneringen uten lenke"
className="flex shrink-0 items-center gap-1.5 rounded-xl border border-border bg-secondary/60 px-3 py-2 text-sm font-bold tracking-widest text-foreground transition-colors hover:bg-secondary"
>
<KeyRound aria-hidden="true" className="size-3.5 text-muted-foreground" />
{code}
{copied ? (
<Check aria-hidden="true" className="size-3.5 text-primary" />
) : (
<Copy aria-hidden="true" className="size-3.5 text-muted-foreground" />
)}
</button>
)
}
// --- Create team (State A) -------------------------------------------------
function CreateTeamCard({
slot,
onCreate,
}: {
slot: 0 | 1
onCreate: (slot: 0 | 1, name: string, color: string) => void
}) {
const [name, setName] = useState("")
const [color, setColor] = useState(TEAM_COLORS[slot === 0 ? 0 : 1].value)
const valid = name.trim().length >= 2
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!valid) return
onCreate(slot, name.trim(), color)
}
return (
<form
onSubmit={handleSubmit}
className="flex flex-col gap-5 rounded-3xl border border-dashed border-border bg-card/50 p-5 sm:p-6"
>
<div className="flex items-center gap-3">
<div className="flex size-11 items-center justify-center rounded-xl bg-muted">
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Lag {slot + 1}
</span>
<span className="text-base font-bold text-foreground">Opprett lag</span>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`team-name-${slot}`} className="text-sm font-semibold">
Lagnavn
</Label>
<Input
id={`team-name-${slot}`}
placeholder="F.eks. Lag Birdie"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-12 rounded-2xl text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-sm font-semibold">Lagfarge</Label>
<div className="flex flex-wrap gap-2.5">
{TEAM_COLORS.map((c) => {
const selected = c.value === color
return (
<button
key={c.value}
type="button"
onClick={() => setColor(c.value)}
aria-label={c.label}
aria-pressed={selected}
title={c.label}
className={cn(
"flex size-9 items-center justify-center rounded-full ring-2 ring-offset-2 ring-offset-card transition-transform hover:scale-105",
selected ? "ring-foreground" : "ring-transparent",
)}
style={{ backgroundColor: c.value }}
>
{selected && <Check aria-hidden="true" className="size-4 text-white" />}
</button>
)
})}
</div>
</div>
<Button
type="submit"
disabled={!valid}
className="h-12 rounded-2xl text-base font-bold shadow-sm"
>
<Plus aria-hidden="true" className="size-5" />
Opprett lag
</Button>
</form>
)
}
// --- Team panel with roster (State B) --------------------------------------
function TeamPanel({
team,
otherTeam,
pool,
chatHref,
findPlayerTeam,
onToggleCaptain,
onRemovePlayer,
onAddExisting,
onAddNew,
onConcedeTournament,
}: {
team: Team
otherTeam: Team | null
pool: ApiPlayer[]
chatHref: string
findPlayerTeam: (playerId: string) => Team | null
onToggleCaptain: (teamId: string, rosterId: string, current: boolean) => void
onRemovePlayer: (teamId: string, rosterId: string) => void
onAddExisting: (teamId: string, playerId: string) => void
onAddNew: (teamId: string, name: string, handicap?: number) => void
onConcedeTournament: (teamId: string) => Promise<string | null>
}) {
const [confirmingId, setConfirmingId] = useState<string | null>(null)
const [confirmingGiveUp, setConfirmingGiveUp] = useState(false)
const [giveUpError, setGiveUpError] = useState<string | null>(null)
const [givingUp, setGivingUp] = useState(false)
async function handleGiveUp() {
setGivingUp(true)
const message = await onConcedeTournament(team.id)
setGivingUp(false)
if (message) {
setGiveUpError(message)
} else {
setConfirmingGiveUp(false)
}
}
return (
<section
className="flex flex-col overflow-hidden rounded-3xl border border-border bg-card shadow-sm shadow-black/5"
style={{ borderLeftWidth: 6, borderLeftColor: team.color }}
aria-label={`Lag ${team.name}`}
>
<div className="flex items-center gap-3 border-b border-border px-5 py-4">
<span
aria-hidden="true"
className="size-4 shrink-0 rounded-full"
style={{ backgroundColor: team.color }}
/>
<div className="flex min-w-0 flex-1 flex-col">
<h3 className="truncate text-lg font-extrabold tracking-tight text-foreground">
{team.name}
</h3>
<span className="text-sm text-muted-foreground">
{team.roster.length} {team.roster.length === 1 ? "spiller" : "spillere"}
</span>
</div>
<Link
href={chatHref}
title="Privat lagchat -- kun for spillere rostret på laget"
className="flex size-9 shrink-0 items-center justify-center rounded-xl border border-border text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<MessageCircle aria-hidden="true" className="size-4" />
</Link>
</div>
<ul className="flex flex-col">
{team.roster.length === 0 && (
<li className="px-5 py-6 text-center text-sm text-muted-foreground">
Ingen spillere ennå. Legg til nedenfor.
</li>
)}
{team.roster.map((entry) => {
const confirming = confirmingId === entry.id
if (confirming) {
return (
<li
key={entry.id}
className="flex items-center justify-between gap-3 border-b border-border bg-destructive/5 px-5 py-3 last:border-b-0"
>
<span className="text-sm text-foreground text-pretty">
Fjern <span className="font-semibold">{entry.display_name}</span> fra laget?
</span>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
size="sm"
variant="destructive"
className="h-9 rounded-xl"
onClick={() => {
onRemovePlayer(team.id, entry.id)
setConfirmingId(null)
}}
>
Fjern
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-9 rounded-xl"
onClick={() => setConfirmingId(null)}
>
Avbryt
</Button>
</div>
</li>
)
}
return (
<li
key={entry.id}
className="flex items-center gap-3 border-b border-border px-5 py-3 last:border-b-0"
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate font-semibold text-foreground">
{entry.display_name}
</span>
{entry.is_captain && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-foreground">
<Star aria-hidden="true" className="size-3 fill-primary text-primary" />
Kaptein
</span>
)}
</div>
<span className="shrink-0 tabular-nums text-sm font-medium text-muted-foreground">
{formatHandicap(entry.handicap_index_snapshot)}
</span>
<DropdownMenu>
<DropdownMenuTrigger
aria-label={`Handlinger for ${entry.display_name}`}
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<MoreVertical aria-hidden="true" className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52 rounded-2xl p-1.5">
<DropdownMenuItem
onClick={() => onToggleCaptain(team.id, entry.id, entry.is_captain)}
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
>
<Star aria-hidden="true" className="size-4" />
{entry.is_captain ? "Fjern som kaptein" : "Gjør til kaptein"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setConfirmingId(entry.id)}
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
Fjern fra laget
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</li>
)
})}
</ul>
<div className="border-t border-border p-4">
<AddPlayerControl
team={team}
otherTeam={otherTeam}
pool={pool}
findPlayerTeam={findPlayerTeam}
onAddExisting={onAddExisting}
onAddNew={onAddNew}
/>
</div>
<div className="border-t border-border p-4">
{confirmingGiveUp ? (
<div className="flex flex-col gap-3 rounded-2xl bg-destructive/5 p-3">
{giveUpError && (
<p role="alert" className="text-xs font-medium text-destructive text-pretty">
{giveUpError}
</p>
)}
<p className="text-sm text-foreground text-pretty">
Er du sikker at <span className="font-semibold">{team.name}</span> gir opp
resten av turneringen? Motstanderen får full poengsum for ALLE ikke-avgjorte
matcher, med én gang.
</p>
<div className="flex justify-end gap-2">
<Button
type="button"
size="sm"
variant="destructive"
className="h-9 rounded-xl"
disabled={givingUp}
onClick={handleGiveUp}
>
Gi opp turneringen
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-9 rounded-xl"
onClick={() => {
setConfirmingGiveUp(false)
setGiveUpError(null)
}}
>
Avbryt
</Button>
</div>
</div>
) : (
<Button
type="button"
variant="ghost"
onClick={() => setConfirmingGiveUp(true)}
className="h-9 w-full justify-start rounded-xl text-xs font-semibold text-muted-foreground hover:text-destructive"
>
<Flag aria-hidden="true" className="size-3.5" />
Gi opp resten av turneringen for laget (walkover)
</Button>
)}
</div>
</section>
)
}
// --- Add player type-ahead -------------------------------------------------
function AddPlayerControl({
team,
pool,
findPlayerTeam,
onAddExisting,
onAddNew,
}: {
team: Team
otherTeam: Team | null
pool: ApiPlayer[]
findPlayerTeam: (playerId: string) => Team | null
onAddExisting: (teamId: string, playerId: string) => void
onAddNew: (teamId: string, name: string, handicap?: number) => void
}) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const [creating, setCreating] = useState(false)
const [newHandicap, setNewHandicap] = useState("")
const trimmed = query.trim()
const matches = useMemo(() => {
if (!trimmed) return []
const q = trimmed.toLowerCase()
return pool
.filter((p) => p.display_name.toLowerCase().includes(q))
.map((p) => {
const onTeam = findPlayerTeam(p.id)
return {
player: p,
onThisTeam: onTeam?.id === team.id,
onOtherTeam: onTeam && onTeam.id !== team.id ? onTeam : null,
}
})
.filter((m) => !m.onThisTeam)
}, [pool, trimmed, findPlayerTeam, team.id])
const exactMatch = useMemo(
() => pool.some((p) => p.display_name.toLowerCase() === trimmed.toLowerCase()),
[pool, trimmed],
)
const showCreate = trimmed.length >= 2 && !exactMatch
function reset() {
setQuery("")
setCreating(false)
setNewHandicap("")
}
function handleAddExisting(playerId: string) {
onAddExisting(team.id, playerId)
reset()
}
function handleCreate() {
if (trimmed.length < 2) return
const hcpValue = newHandicap.trim() === "" ? undefined : Number(newHandicap.replace(",", "."))
const hcp = hcpValue !== undefined && !Number.isNaN(hcpValue) ? hcpValue : undefined
onAddNew(team.id, trimmed, hcp)
reset()
}
if (!open) {
return (
<Button
type="button"
variant="secondary"
onClick={() => setOpen(true)}
className="h-11 w-full rounded-2xl text-sm font-bold"
>
<UserPlus aria-hidden="true" className="size-4" />
Legg til spiller
</Button>
)
}
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Input
autoFocus
placeholder="Søk eller skriv navn…"
value={query}
onChange={(e) => {
setQuery(e.target.value)
setCreating(false)
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false)
reset()
}
}}
className="h-11 flex-1 rounded-2xl text-base"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
setOpen(false)
reset()
}}
className="size-11 shrink-0 rounded-2xl text-muted-foreground"
aria-label="Lukk"
>
<X aria-hidden="true" className="size-5" />
</Button>
</div>
{trimmed.length > 0 && (
<div className="overflow-hidden rounded-2xl border border-border bg-background">
<ul className="flex max-h-64 flex-col overflow-auto">
{matches.map(({ player, onOtherTeam }) => {
if (onOtherTeam) {
return (
<li
key={player.id}
className="flex cursor-not-allowed items-center justify-between gap-3 border-b border-border px-4 py-2.5 opacity-60 last:border-b-0"
>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-muted-foreground line-through">
{player.display_name}
</span>
<span className="truncate text-xs text-muted-foreground">
allerede {onOtherTeam.name}
</span>
</div>
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
{formatHandicap(player.handicap_index)}
</span>
</li>
)
}
return (
<li key={player.id} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() => handleAddExisting(player.id)}
className="flex w-full items-center justify-between gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/60"
>
<span className="truncate text-sm font-semibold text-foreground">
{player.display_name}
</span>
<span className="shrink-0 tabular-nums text-sm text-muted-foreground">
{formatHandicap(player.handicap_index)}
</span>
</button>
</li>
)
})}
{matches.length === 0 && !showCreate && (
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
Ingen treff.
</li>
)}
{showCreate && !creating && (
<li className="border-t border-border">
<button
type="button"
onClick={() => setCreating(true)}
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-semibold text-primary transition-colors hover:bg-accent/60"
>
<Plus aria-hidden="true" className="size-4" />
Opprett ny spiller: «{trimmed}»
</button>
</li>
)}
{showCreate && creating && (
<li className="flex flex-col gap-3 border-t border-border bg-accent/30 px-4 py-3">
<span className="text-sm font-semibold text-foreground">
Ny spiller: {trimmed}
</span>
<div className="flex items-end gap-2">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="new-hcp" className="text-xs font-semibold text-muted-foreground">
Handicap-indeks (valgfritt)
</Label>
<Input
id="new-hcp"
autoFocus
inputMode="decimal"
placeholder="F.eks. 12.5"
value={newHandicap}
onChange={(e) => setNewHandicap(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleCreate()
}
}}
className="h-11 rounded-xl text-base"
/>
</div>
<Button
type="button"
onClick={handleCreate}
className="h-11 shrink-0 rounded-xl font-bold"
>
Legg til
</Button>
</div>
</li>
)}
</ul>
</div>
)}
</div>
)
}
// --- Helpers ---------------------------------------------------------------
function formatHandicap(handicap: number | null | undefined) {
if (handicap === null || handicap === undefined) return "—"
return handicap.toFixed(1)
}