221 lines
8.6 KiB
TypeScript
221 lines
8.6 KiB
TypeScript
|
|
"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>
|
|||
|
|
)
|
|||
|
|
}
|