310 lines
13 KiB
TypeScript
310 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>
|
|||
|
|
)
|
|||
|
|
}
|