teecup/frontend/components/public-order-of-merit.tsx

221 lines
8.6 KiB
TypeScript
Raw Permalink Normal View History

"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>
)
}