teecup/frontend/components/ny-runde/step4-teams.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

309 lines
13 KiB
TypeScript
Raw Permalink 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 { isScrambleVsSolo, isTwoSided } from "@/lib/ny-runde/formats"
import type { Player } from "@/lib/ny-runde/types"
import { cn } from "@/lib/utils"
import { ArrowLeftRight, Check, UserMinus } from "lucide-react"
import { Avatar, NativeSelect, Panel, Pill, TextInput } from "./primitives"
import { useWizard } from "./wizard-context"
function fullName(p: Player) {
return `${p.firstName}${p.lastName ? ` ${p.lastName}` : ""}`.trim() || "Spiller"
}
export function Step4Teams() {
const { state } = useWizard()
const f = state.format
return (
<div className="flex flex-col gap-4">
<div>
<h2 className="text-lg font-semibold tracking-tight text-[var(--nr-ink)]">Lag og rekkefølge</h2>
<p className="text-sm text-[var(--nr-muted)]">Sett opp lagene for denne spilleformen.</p>
</div>
{isTwoSided(f) ? <TwoSided /> : f === "moneyball" ? <MoneyBall /> : isScrambleVsSolo(f) ? <ScrambleVsSolo /> : null}
</div>
)
}
/* ------------------------------------------------------------------ */
/* Two-sided A/B */
/* ------------------------------------------------------------------ */
function TwoSided() {
const { state, patch } = useWizard()
const assign = state.sideAssign
const unassigned = state.players.filter((p) => !assign[p.key])
const sideA = state.players.filter((p) => assign[p.key] === "A")
const sideB = state.players.filter((p) => assign[p.key] === "B")
function set(key: string, side: "A" | "B" | undefined) {
patch({ sideAssign: { ...assign, [key]: side } })
}
return (
<div className="flex flex-col gap-4">
{unassigned.length > 0 ? (
<Panel className="bg-[var(--nr-surface-2)]">
<p className="mb-2 text-[13px] font-medium text-[var(--nr-muted)]">Ikke tildelt</p>
<ul className="flex flex-col gap-2">
{unassigned.map((p) => (
<li key={p.key} className="flex items-center gap-3 rounded-lg bg-[var(--nr-surface)] p-2 pr-3">
<Avatar first={p.firstName} last={p.lastName} className="h-8 w-8" />
<span className="flex-1 text-sm text-[var(--nr-ink)]">{fullName(p)}</span>
<div className="flex gap-1.5">
<AssignBtn onClick={() => set(p.key, "A")}>{state.sideALabel || "Side A"}</AssignBtn>
<AssignBtn onClick={() => set(p.key, "B")}>{state.sideBLabel || "Side B"}</AssignBtn>
</div>
</li>
))}
</ul>
</Panel>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<SideColumn
label={state.sideALabel}
placeholder="Side A"
suggestion="Rødt lag"
onLabel={(v) => patch({ sideALabel: v })}
players={sideA}
otherSideLabel={state.sideBLabel || "Side B"}
onMoveOther={(k) => set(k, "B")}
onRemove={(k) => set(k, undefined)}
/>
<SideColumn
label={state.sideBLabel}
placeholder="Side B"
suggestion="Blått lag"
onLabel={(v) => patch({ sideBLabel: v })}
players={sideB}
otherSideLabel={state.sideALabel || "Side A"}
onMoveOther={(k) => set(k, "A")}
onRemove={(k) => set(k, undefined)}
/>
</div>
</div>
)
}
function SideColumn({
label,
placeholder,
suggestion,
onLabel,
players,
otherSideLabel,
onMoveOther,
onRemove,
}: {
label: string
placeholder: string
suggestion: string
onLabel: (v: string) => void
players: Player[]
otherSideLabel: string
onMoveOther: (key: string) => void
onRemove: (key: string) => void
}) {
return (
<div className="flex flex-col gap-2 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] p-3">
<TextInput value={label} onChange={(e) => onLabel(e.target.value)} placeholder={placeholder} className="h-10 font-medium" />
{!label ? (
<button
type="button"
onClick={() => onLabel(suggestion)}
className="self-start text-xs font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
>
Foreslå «{suggestion}»
</button>
) : null}
{players.length === 0 ? (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-3 py-6 text-center text-xs text-[var(--nr-muted)]">
Ingen spillere ennå.
</p>
) : (
<ul className="flex flex-col gap-2">
{players.map((p) => (
<li key={p.key} className="flex items-center gap-2 rounded-lg bg-[var(--nr-surface-2)] p-2 pr-2">
<Avatar first={p.firstName} last={p.lastName} className="h-8 w-8" />
<span className="flex-1 truncate text-sm text-[var(--nr-ink)]">{fullName(p)}</span>
<button
type="button"
onClick={() => onMoveOther(p.key)}
aria-label={`Flytt ${fullName(p)} til ${otherSideLabel}`}
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--nr-muted)] hover:bg-[var(--nr-surface)] hover:text-[var(--nr-accent)]"
>
<ArrowLeftRight className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => onRemove(p.key)}
aria-label={`Fjern ${fullName(p)} fra laget`}
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--nr-muted)] hover:bg-[var(--nr-surface)] hover:text-[var(--nr-danger)]"
>
<UserMinus className="h-4 w-4" aria-hidden="true" />
</button>
</li>
))}
</ul>
)}
</div>
)
}
function AssignBtn({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex min-h-9 items-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface-2)] px-3 text-xs font-medium text-[var(--nr-ink)] transition-colors hover:border-[var(--nr-accent)] hover:text-[var(--nr-accent)]"
>
{children}
</button>
)
}
/* ------------------------------------------------------------------ */
/* Money Ball */
/* ------------------------------------------------------------------ */
function MoneyBall() {
const { state, patch } = useWizard()
return (
<Panel>
<p className="mb-3 text-sm text-[var(--nr-muted)]">Gi hver spiller en plass i rotasjonen (03).</p>
<ul className="flex flex-col gap-2">
{state.players.map((p) => (
<li key={p.key} className="flex items-center gap-3 rounded-lg bg-[var(--nr-surface-2)] p-2 pr-3">
<Avatar first={p.firstName} last={p.lastName} className="h-8 w-8" />
<span className="flex-1 truncate text-sm text-[var(--nr-ink)]">{fullName(p)}</span>
<NativeSelect
aria-label={`Plass for ${fullName(p)}`}
value={state.moneyballSlots[p.key] ?? 0}
onChange={(e) => patch({ moneyballSlots: { ...state.moneyballSlots, [p.key]: Number.parseInt(e.target.value, 10) } })}
className="h-10 w-24 bg-[var(--nr-surface)]"
>
{[0, 1, 2, 3].map((n) => (
<option key={n} value={n}>
Plass {n}
</option>
))}
</NativeSelect>
</li>
))}
</ul>
</Panel>
)
}
/* ------------------------------------------------------------------ */
/* Scramble vs solo (asymmetric) */
/* ------------------------------------------------------------------ */
function ScrambleVsSolo() {
const { state, patch } = useWizard()
const assign = state.scrambleAssign
const team = state.players.filter((p) => assign[p.key] === "team")
const opponent = state.players.find((p) => assign[p.key] === "opponent")
const unplaced = state.players.filter((p) => !assign[p.key])
const teamReady = team.length >= 2
const opponentFilled = !!opponent
function set(key: string, slot: "team" | "opponent" | undefined) {
patch({ scrambleAssign: { ...assign, [key]: slot } })
}
return (
<div className="flex flex-col gap-4">
{/* The team — open-ended, emphasized */}
<div className="rounded-xl border-2 border-[var(--nr-accent-ring)] bg-[var(--nr-surface)] p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-base font-semibold text-[var(--nr-ink)]">Laget</h3>
<span
role="status"
aria-live="polite"
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium",
teamReady
? "border-[var(--nr-ok)]/30 bg-[var(--nr-ok-soft)] text-[var(--nr-ok)]"
: "border-[var(--nr-border)] bg-[var(--nr-surface-2)] text-[var(--nr-muted)]",
)}
>
{teamReady ? <Check className="h-3 w-3" strokeWidth={3} /> : null}
{teamReady ? "Minst 2 spillere lagt til" : "Minst 2 spillere"}
</span>
</div>
{team.length === 0 ? (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-3 py-6 text-center text-xs text-[var(--nr-muted)]">
Legg til spillere fra listen under.
</p>
) : (
<ul className="flex flex-col gap-2">
{team.map((p) => (
<li key={p.key} className="flex items-center gap-2 rounded-lg bg-[var(--nr-surface-2)] p-2 pr-2">
<Avatar first={p.firstName} last={p.lastName} className="h-8 w-8" />
<span className="flex-1 truncate text-sm text-[var(--nr-ink)]">{fullName(p)}</span>
<button
type="button"
onClick={() => set(p.key, undefined)}
aria-label={`Fjern ${fullName(p)} fra laget`}
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--nr-muted)] hover:bg-[var(--nr-surface)] hover:text-[var(--nr-danger)]"
>
<UserMinus className="h-4 w-4" aria-hidden="true" />
</button>
</li>
))}
</ul>
)}
</div>
{/* Individual opponent — capped at 1, visually distinct single slot */}
<div className="rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface-2)] p-4">
<h3 className="mb-2 text-sm font-semibold text-[var(--nr-ink)]">Individuell motstander</h3>
{opponent ? (
<div className="flex items-center gap-3 rounded-lg bg-[var(--nr-surface)] p-2 pr-3 ring-1 ring-inset ring-[var(--nr-border)]">
<Avatar first={opponent.firstName} last={opponent.lastName} className="h-10 w-10" />
<span className="flex-1 truncate text-sm font-medium text-[var(--nr-ink)]">{fullName(opponent)}</span>
<Pill tone="neutral">1 av 1</Pill>
<button
type="button"
onClick={() => set(opponent.key, undefined)}
aria-label={`Fjern ${fullName(opponent)} som motstander`}
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--nr-muted)] hover:bg-[var(--nr-surface-2)] hover:text-[var(--nr-danger)]"
>
<UserMinus className="h-4 w-4" aria-hidden="true" />
</button>
</div>
) : (
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-3 py-5 text-center text-xs text-[var(--nr-muted)]">
Én spiller. Velg «Som motstander» en spiller under.
</p>
)}
</div>
{/* Unplaced */}
{unplaced.length > 0 ? (
<Panel>
<p className="mb-2 text-[13px] font-medium text-[var(--nr-muted)]">Ikke plassert</p>
<ul className="flex flex-col gap-2">
{unplaced.map((p) => (
<li key={p.key} className="flex items-center gap-2 rounded-lg bg-[var(--nr-surface-2)] p-2 pr-2">
<Avatar first={p.firstName} last={p.lastName} className="h-8 w-8" />
<span className="flex-1 truncate text-sm text-[var(--nr-ink)]">{fullName(p)}</span>
<AssignBtn onClick={() => set(p.key, "team")}>Til laget</AssignBtn>
<button
type="button"
onClick={() => set(p.key, "opponent")}
disabled={opponentFilled}
className="inline-flex min-h-9 items-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface-2)] px-3 text-xs font-medium text-[var(--nr-ink)] transition-colors hover:border-[var(--nr-accent)] hover:text-[var(--nr-accent)] disabled:cursor-not-allowed disabled:opacity-40"
>
Som motstander
</button>
</li>
))}
</ul>
</Panel>
) : null}
</div>
)
}