351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
|
|
"use client"
|
|||
|
|
|
|||
|
|
// Resultatvisning for "Scramble mot enkeltspiller" (migrasjon 056,
|
|||
|
|
// 2026-08-05) -- ren V0-eksport (zip 31), uendret fra levert komponent.
|
|||
|
|
// Adapter fra det generiske API-svaret (ApiFormatResult) til denne
|
|||
|
|
// komponentens ScrambleSoloResult-props ligger i round-leaderboard.tsx
|
|||
|
|
// (buildScrambleSoloResult) og round-detail.tsx (lokal kopi, samme navn).
|
|||
|
|
|
|||
|
|
import { Check, Flag, Minus } from "lucide-react"
|
|||
|
|
import { cn } from "@/lib/utils"
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Data shape (matches the API response this component is built around)
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
export interface ScrambleSoloHole {
|
|||
|
|
holeNumber: number
|
|||
|
|
par: number
|
|||
|
|
strokeIndex: number
|
|||
|
|
result: "a" | "b" | "halved" // "a" = team won hole, "b" = individual won, "halved" = tied
|
|||
|
|
team: { gross: number; net: number; strokesReceived: number }
|
|||
|
|
individual: { gross: number; net: number; strokesReceived: number }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ScrambleSoloResult {
|
|||
|
|
ready: boolean
|
|||
|
|
teamLabel: string
|
|||
|
|
individualLabel: string
|
|||
|
|
teamNet: number | null
|
|||
|
|
individualNet: number | null
|
|||
|
|
margin: number | null // individualNet - teamNet; positive = team leads, negative = individual leads, 0 = tied
|
|||
|
|
holes: ScrambleSoloHole[]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type Leader = "team" | "individual" | "tie"
|
|||
|
|
|
|||
|
|
function leaderFromMargin(margin: number | null): Leader {
|
|||
|
|
if (margin === null) return "tie"
|
|||
|
|
if (margin > 0) return "team"
|
|||
|
|
if (margin < 0) return "individual"
|
|||
|
|
return "tie"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
// Component
|
|||
|
|
// ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
export function ScrambleSoloResultView({ result }: { result: ScrambleSoloResult }) {
|
|||
|
|
return (
|
|||
|
|
<div className="mx-auto flex w-full max-w-xl flex-col gap-4 p-4">
|
|||
|
|
{result.ready ? (
|
|||
|
|
<>
|
|||
|
|
<HeadlineCard result={result} />
|
|||
|
|
<HoleLedger result={result} />
|
|||
|
|
</>
|
|||
|
|
) : (
|
|||
|
|
<WaitingState />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Waiting state ---------------------------------------------------------
|
|||
|
|
|
|||
|
|
function WaitingState() {
|
|||
|
|
return (
|
|||
|
|
<section
|
|||
|
|
className="flex flex-col items-center gap-3 rounded-3xl border border-border bg-card p-8 text-center shadow-md shadow-black/8"
|
|||
|
|
aria-live="polite"
|
|||
|
|
>
|
|||
|
|
<span className="flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
|||
|
|
<Flag aria-hidden="true" className="size-7" />
|
|||
|
|
</span>
|
|||
|
|
<h2 className="text-xl font-bold text-foreground text-balance">
|
|||
|
|
Venter på at oppsettet blir fullført
|
|||
|
|
</h2>
|
|||
|
|
<p className="max-w-sm text-base leading-relaxed text-muted-foreground text-pretty">
|
|||
|
|
Resultatet vises så snart handicap og lag er ferdig satt opp for denne runden.
|
|||
|
|
</p>
|
|||
|
|
</section>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Headline card ---------------------------------------------------------
|
|||
|
|
|
|||
|
|
function HeadlineCard({ result }: { result: ScrambleSoloResult }) {
|
|||
|
|
const leader = leaderFromMargin(result.margin)
|
|||
|
|
const strokes = result.margin === null ? 0 : Math.abs(result.margin)
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8">
|
|||
|
|
<MarginBanner leader={leader} strokes={strokes} result={result} />
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-[1fr_auto_1fr] items-stretch gap-2">
|
|||
|
|
<SideTotal
|
|||
|
|
label={result.teamLabel}
|
|||
|
|
kind="team"
|
|||
|
|
net={result.teamNet}
|
|||
|
|
leading={leader === "team"}
|
|||
|
|
/>
|
|||
|
|
<div className="flex items-center justify-center">
|
|||
|
|
<span className="text-sm font-bold uppercase tracking-wide text-muted-foreground">mot</span>
|
|||
|
|
</div>
|
|||
|
|
<SideTotal
|
|||
|
|
label={result.individualLabel}
|
|||
|
|
kind="individual"
|
|||
|
|
net={result.individualNet}
|
|||
|
|
leading={leader === "individual"}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function MarginBanner({
|
|||
|
|
leader,
|
|||
|
|
strokes,
|
|||
|
|
result,
|
|||
|
|
}: {
|
|||
|
|
leader: Leader
|
|||
|
|
strokes: number
|
|||
|
|
result: ScrambleSoloResult
|
|||
|
|
}) {
|
|||
|
|
let text: string
|
|||
|
|
let tone: "team" | "individual" | "tie"
|
|||
|
|
|
|||
|
|
if (leader === "tie") {
|
|||
|
|
text = "Uavgjort"
|
|||
|
|
tone = "tie"
|
|||
|
|
} else if (leader === "team") {
|
|||
|
|
text = `${result.teamLabel} leder med ${strokes} ${strokes === 1 ? "slag" : "slag"}`
|
|||
|
|
tone = "team"
|
|||
|
|
} else {
|
|||
|
|
text = `${result.individualLabel} leder med ${strokes} slag`
|
|||
|
|
tone = "individual"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className={cn(
|
|||
|
|
"flex items-center justify-center gap-2 rounded-2xl border px-4 py-3 text-center",
|
|||
|
|
tone === "team" && "border-primary/40 bg-primary/10",
|
|||
|
|
tone === "individual" && "border-brand-orange/40 bg-brand-orange/10",
|
|||
|
|
tone === "tie" && "border-border bg-muted/60",
|
|||
|
|
)}
|
|||
|
|
aria-live="polite"
|
|||
|
|
>
|
|||
|
|
{tone === "tie" ? (
|
|||
|
|
<Minus aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
|||
|
|
) : (
|
|||
|
|
<Check
|
|||
|
|
aria-hidden="true"
|
|||
|
|
className={cn(
|
|||
|
|
"size-5 shrink-0",
|
|||
|
|
tone === "team" ? "text-primary" : "text-brand-orange",
|
|||
|
|
)}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
<span
|
|||
|
|
className={cn(
|
|||
|
|
"text-lg font-extrabold text-balance",
|
|||
|
|
tone === "team" && "text-primary",
|
|||
|
|
tone === "individual" && "text-brand-orange",
|
|||
|
|
tone === "tie" && "text-foreground",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{text}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function SideTotal({
|
|||
|
|
label,
|
|||
|
|
kind,
|
|||
|
|
net,
|
|||
|
|
leading,
|
|||
|
|
}: {
|
|||
|
|
label: string
|
|||
|
|
kind: "team" | "individual"
|
|||
|
|
net: number | null
|
|||
|
|
leading: boolean
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className={cn(
|
|||
|
|
"flex flex-col items-center gap-1 rounded-2xl border p-4 text-center transition-colors",
|
|||
|
|
leading && kind === "team" && "border-primary/40 bg-primary/10",
|
|||
|
|
leading && kind === "individual" && "border-brand-orange/40 bg-brand-orange/10",
|
|||
|
|
!leading && "border-border bg-card",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<span
|
|||
|
|
className={cn(
|
|||
|
|
"line-clamp-2 text-base font-bold leading-tight text-pretty",
|
|||
|
|
leading && kind === "team" && "text-primary",
|
|||
|
|
leading && kind === "individual" && "text-brand-orange",
|
|||
|
|
!leading && "text-foreground",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{label}
|
|||
|
|
</span>
|
|||
|
|
<span
|
|||
|
|
className={cn(
|
|||
|
|
"text-5xl font-extrabold tabular-nums",
|
|||
|
|
leading && kind === "team" && "text-primary",
|
|||
|
|
leading && kind === "individual" && "text-brand-orange",
|
|||
|
|
!leading && "text-foreground",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{net ?? "–"}
|
|||
|
|
</span>
|
|||
|
|
<span className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|||
|
|
netto
|
|||
|
|
</span>
|
|||
|
|
{leading && (
|
|||
|
|
<span
|
|||
|
|
className={cn(
|
|||
|
|
"mt-0.5 rounded-full px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide",
|
|||
|
|
kind === "team" ? "bg-primary text-primary-foreground" : "bg-brand-orange text-brand-orange-foreground",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
Leder
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Hole-by-hole ledger ---------------------------------------------------
|
|||
|
|
|
|||
|
|
function HoleLedger({ result }: { result: ScrambleSoloResult }) {
|
|||
|
|
return (
|
|||
|
|
<section className="flex flex-col gap-3">
|
|||
|
|
<div className="flex items-baseline justify-between gap-3 px-1">
|
|||
|
|
<h2 className="text-lg font-bold text-foreground">Hull for hull</h2>
|
|||
|
|
<span className="text-sm text-muted-foreground">
|
|||
|
|
{result.holes.length} {result.holes.length === 1 ? "hull spilt" : "hull spilt"}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Column legend so the two net columns are unambiguous */}
|
|||
|
|
<div className="grid grid-cols-[auto_1fr_1fr] items-center gap-2 px-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
|||
|
|
<span className="w-14">Hull</span>
|
|||
|
|
<span className="truncate text-right">{result.teamLabel}</span>
|
|||
|
|
<span className="truncate text-right">{result.individualLabel}</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<ol className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
|
|||
|
|
{result.holes.map((hole) => (
|
|||
|
|
<HoleRow key={hole.holeNumber} hole={hole} />
|
|||
|
|
))}
|
|||
|
|
</ol>
|
|||
|
|
</section>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function HoleRow({ hole }: { hole: ScrambleSoloHole }) {
|
|||
|
|
return (
|
|||
|
|
<li className="grid grid-cols-[auto_1fr_1fr] items-stretch gap-2 p-3">
|
|||
|
|
{/* Hole meta */}
|
|||
|
|
<div className="flex w-14 flex-col justify-center">
|
|||
|
|
<span className="text-lg font-extrabold tabular-nums text-foreground">{hole.holeNumber}</span>
|
|||
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|||
|
|
Par {hole.par}
|
|||
|
|
<span className="sr-only">, </span> · idx {hole.strokeIndex}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<SideCell
|
|||
|
|
gross={hole.team.gross}
|
|||
|
|
net={hole.team.net}
|
|||
|
|
received={hole.team.strokesReceived}
|
|||
|
|
won={hole.result === "a"}
|
|||
|
|
halved={hole.result === "halved"}
|
|||
|
|
kind="team"
|
|||
|
|
/>
|
|||
|
|
<SideCell
|
|||
|
|
gross={hole.individual.gross}
|
|||
|
|
net={hole.individual.net}
|
|||
|
|
received={hole.individual.strokesReceived}
|
|||
|
|
won={hole.result === "b"}
|
|||
|
|
halved={hole.result === "halved"}
|
|||
|
|
kind="individual"
|
|||
|
|
/>
|
|||
|
|
</li>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function SideCell({
|
|||
|
|
gross,
|
|||
|
|
net,
|
|||
|
|
received,
|
|||
|
|
won,
|
|||
|
|
halved,
|
|||
|
|
kind,
|
|||
|
|
}: {
|
|||
|
|
gross: number
|
|||
|
|
net: number
|
|||
|
|
received: number
|
|||
|
|
won: boolean
|
|||
|
|
halved: boolean
|
|||
|
|
kind: "team" | "individual"
|
|||
|
|
}) {
|
|||
|
|
const wonLabel = won
|
|||
|
|
? kind === "team"
|
|||
|
|
? "Laget vant hullet"
|
|||
|
|
: "Utfordreren vant hullet"
|
|||
|
|
: halved
|
|||
|
|
? "Delt hull"
|
|||
|
|
: undefined
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className={cn(
|
|||
|
|
"flex flex-col items-end justify-center gap-0.5 rounded-xl border px-3 py-2",
|
|||
|
|
won && kind === "team" && "border-primary/40 bg-primary/10",
|
|||
|
|
won && kind === "individual" && "border-brand-orange/40 bg-brand-orange/10",
|
|||
|
|
!won && halved && "border-dashed border-border bg-muted/40",
|
|||
|
|
!won && !halved && "border-transparent bg-transparent",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{/* Net (the number that decides the hole) — largest, bold */}
|
|||
|
|
<div className="flex items-center gap-1.5">
|
|||
|
|
{won && (
|
|||
|
|
<Check
|
|||
|
|
aria-hidden="true"
|
|||
|
|
className={cn("size-4 shrink-0", kind === "team" ? "text-primary" : "text-brand-orange")}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
<span
|
|||
|
|
className={cn(
|
|||
|
|
"text-2xl font-extrabold tabular-nums leading-none",
|
|||
|
|
won && kind === "team" && "text-primary",
|
|||
|
|
won && kind === "individual" && "text-brand-orange",
|
|||
|
|
!won && "text-foreground",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{net}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
{/* Gross + strokes received context */}
|
|||
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|||
|
|
Brutto {gross}
|
|||
|
|
{received > 0 && <> · {received} slag</>}
|
|||
|
|
</span>
|
|||
|
|
{wonLabel && <span className="sr-only">{wonLabel}</span>}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|