teecup/frontend/components/public-order-of-merit.tsx
Erol Haagenrud f740814833 Order of Merit: offentlig/delt visning (ADR-076)
Siste av de tre gjenstående OOM-punktene fra ADR-043 (lag-OOM ADR-074,
eclectic på tvers av turneringer ADR-075, offentlig visning her).

Migrasjon 076: SECURITY DEFINER-bro public_order_of_merit_by_id, samme
anti-enumerering som public_org_by_slug/public_tournament_by_code. Ny
/public/order-of-merits/{id}-endepunkt (gjenbruker den eksisterende
leaderboard-beregningen direkte). Ny uautentisert frontend-side
modellert på public-club.tsx, "Del offentlig lenke" i innstillinger.

83/83 backend-tester, 45/45 vitest, scratch-verifisert i egen inkognito-
nettleserkontekst (ingen session-cookie). Migrasjon vist og bekreftet
før kjøring mot ekte teecup_db. Rullet ut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 21:20:23 +02:00

220 lines
8.6 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 { useEffect, useState } from "react"
import { Medal, ShieldAlert, Users } from "lucide-react"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
// --- API-typer (matcher app/routers/order_of_merit.py sin
// get_public_order_of_merit/PublicOrderOfMeritOut) ---------------------
type ApiKind = "player" | "team"
type ApiResultType = "points" | "stableford" | "gross" | "net" | "money"
type ApiLeaderboardEntry = {
player_id: string
player_name: string
results_counted: number
results_available: number
value: number | null
position: string | null
eligible: boolean
ineligible_reason: string | null
}
type ApiPublicOom = {
name: string
kind: ApiKind
result_type: ApiResultType
aggregation_mode: "sum" | "average" | "eclectic"
entries: ApiLeaderboardEntry[]
}
const RESULT_TYPE_LABELS: Record<ApiResultType, string> = {
points: "Poeng etter plassering",
stableford: "Stableford-sum",
gross: "Bruttoscore-sum",
net: "Nettoscore-sum",
money: "Pengeliste",
}
function formatValue(value: number | null, resultType: ApiResultType): string {
if (value === null) return ""
if (resultType === "money") return `${Math.round(value).toLocaleString("nb-NO")} kr`
return value.toLocaleString("nb-NO", { maximumFractionDigits: 1 })
}
// --- Root --------------------------------------------------------------
export function PublicOrderOfMerit({ oomId }: { oomId: string }) {
const [oom, setOom] = useState<ApiPublicOom | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const res = await fetch(`/public/order-of-merits/${oomId}`, { credentials: "include" })
if (res.status === 404) {
if (!cancelled) setError("Order of Merit finnes ikke.")
return
}
if (!res.ok) throw new Error(`oom: ${res.status}`)
const data: ApiPublicOom = await res.json()
if (!cancelled) setOom(data)
} catch {
if (!cancelled) setError("Klarte ikke å laste resultatlisten. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [oomId])
if (loading) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
)
}
if (error || !oom) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<ShieldAlert aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground text-pretty">
{error ?? "Order of Merit finnes ikke."}
</p>
</div>
)
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-2xl items-center justify-center px-5 py-4">
<Wordmark compact />
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
<div className="flex flex-col gap-6">
<Banner name={oom.name} kind={oom.kind} resultType={oom.result_type} />
<section aria-label="Resultatliste" className="flex flex-col gap-4">
<h2 className="text-lg font-bold tracking-tight text-foreground">Resultatliste</h2>
<LeaderboardTable entries={oom.entries} resultType={oom.result_type} />
</section>
</div>
</main>
</div>
)
}
// --- Banner --------------------------------------------------------------
function Banner({ name, kind, resultType }: { name: string; kind: ApiKind; resultType: ApiResultType }) {
return (
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-br from-primary to-primary/80 px-6 py-10 shadow-lg shadow-primary/20 sm:px-8 sm:py-12">
<div
aria-hidden="true"
className="pointer-events-none absolute -right-16 -top-16 size-56 rounded-full border-[12px] border-primary-foreground/10"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute -bottom-20 -left-10 size-48 rounded-full border-[12px] border-primary-foreground/10"
/>
<div className="relative flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-sm font-semibold uppercase tracking-wide text-primary-foreground/80">
<Users aria-hidden="true" className="size-4" />
Order of Merit
</span>
<h1 className="text-3xl font-extrabold leading-tight tracking-tight text-primary-foreground text-balance sm:text-4xl">
{name}
</h1>
<span className="text-sm font-semibold text-primary-foreground/80">
{kind === "team" ? "Lag" : "Spiller"} · {RESULT_TYPE_LABELS[resultType]}
</span>
</div>
</div>
)
}
// --- Resultatliste ---------------------------------------------------------
// Samme visuelle mønster som order-of-merit-detail.tsx sin LeaderboardTable
// (gull-fremhevet førsteplass, "ikke kvalifisert"-seksjon) -- bevisst
// duplisert (ikke delt) i tråd med hvordan list/detail-komponentene i
// dette området allerede holder egne lokale kopier av samme mønster.
function LeaderboardTable({ entries, resultType }: { entries: ApiLeaderboardEntry[]; resultType: ApiResultType }) {
const eligible = entries.filter((e) => e.eligible)
const ineligible = entries.filter((e) => !e.eligible)
if (eligible.length === 0 && ineligible.length === 0) {
return (
<div className="flex flex-col items-center gap-4 rounded-3xl border border-dashed border-border bg-card px-6 py-12 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<Medal aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">Ingen resultater ennå.</p>
</div>
)
}
return (
<div className="flex flex-col gap-4">
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border">
{eligible.map((e) => {
const isLeader = e.position === "1"
return (
<li
key={e.player_id}
className={cn("flex items-center gap-3 px-4 py-3", isLeader ? "bg-gold text-gold-foreground" : "bg-card")}
>
<span className={cn("w-9 shrink-0 text-center text-sm font-extrabold tabular-nums", isLeader ? "text-gold-foreground" : "text-foreground")}>
{e.position}
</span>
<div className="flex min-w-0 flex-1 flex-col">
<span className={cn("truncate text-sm font-bold", isLeader ? "text-gold-foreground" : "text-foreground")}>{e.player_name}</span>
{e.results_counted !== e.results_available && (
<span className={cn("text-xs", isLeader ? "text-gold-foreground/80" : "text-muted-foreground")}>
{e.results_counted} av {e.results_available} resultater talt
</span>
)}
</div>
<span className={cn("shrink-0 text-base font-extrabold tabular-nums", isLeader ? "text-gold-foreground" : "text-foreground")}>
{formatValue(e.value, resultType)}
</span>
</li>
)
})}
</ul>
{ineligible.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Ikke kvalifisert</h3>
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border opacity-70">
{ineligible.map((e) => (
<li key={e.player_id} className="flex items-center gap-3 px-4 py-3">
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold text-muted-foreground">{e.player_name}</span>
{e.ineligible_reason && <span className="text-xs text-muted-foreground">{e.ineligible_reason}</span>}
</div>
</li>
))}
</ul>
</div>
)}
</div>
)
}