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.
309 lines
13 KiB
TypeScript
309 lines
13 KiB
TypeScript
"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 (0–3).</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» på 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>
|
||
)
|
||
}
|