teecup/frontend/components/public-club.tsx

189 lines
6.3 KiB
TypeScript
Raw Normal View History

"use client"
import { useEffect, useState } from "react"
import { CalendarX2, ShieldAlert } from "lucide-react"
import { Wordmark } from "@/components/wordmark"
import { TournamentCard, type Tournament } from "@/components/tournament-card"
// --- API-typer (matcher app/routers/registration.py sin get_public_org) ----
type ApiOrgTournament = {
id: string
name: string
status: string
start_date: string | null
end_date: string | null
}
type ApiOrgInfo = {
name: string
tournaments: ApiOrgTournament[]
}
function toTournament(t: ApiOrgTournament): Tournament {
return {
id: t.id,
name: t.name,
status: t.status as Tournament["status"],
startDate: t.start_date ?? undefined,
endDate: t.end_date ?? undefined,
}
}
// --- Ordering --------------------------------------------------------------
// Upcoming (has a future/any start date and not completed) first, then
// tournaments without dates set, then completed/finished ones last.
function orderTournaments(list: Tournament[]) {
const rank = (t: Tournament) => {
if (t.status === "completed" || t.status === "archived") return 2
if (!t.startDate) return 1
return 0
}
return [...list].sort((a, b) => {
const diff = rank(a) - rank(b)
if (diff !== 0) return diff
// Within the upcoming group, earliest start first.
if (a.startDate && b.startDate) {
return a.startDate.localeCompare(b.startDate)
}
return 0
})
}
// --- Root ------------------------------------------------------------------
export function PublicClub({ slug }: { slug: string }) {
const [org, setOrg] = useState<ApiOrgInfo | 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/orgs/${slug}`, { credentials: "include" })
if (res.status === 404) {
if (!cancelled) setError("Klubben finnes ikke.")
return
}
if (!res.ok) throw new Error(`org: ${res.status}`)
const data: ApiOrgInfo = await res.json()
if (!cancelled) setOrg(data)
} catch {
if (!cancelled) setError("Klarte ikke å laste klubbsiden. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [slug])
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 || !org) {
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 ?? "Klubben finnes ikke."}
</p>
</div>
)
}
const tournaments = orderTournaments(org.tournaments.map(toTournament))
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={org.name} />
<section aria-label="Turneringer" className="flex flex-col gap-4">
<h2 className="text-lg font-bold tracking-tight text-foreground">Turneringer</h2>
{tournaments.length > 0 ? (
<ul className="flex flex-col gap-3">
{tournaments.map((tournament) => (
<li key={tournament.id}>
<TournamentCard tournament={tournament} />
</li>
))}
</ul>
) : (
<EmptyState />
)}
</section>
</div>
</main>
</div>
)
}
// --- Banner ----------------------------------------------------------------
// Same visual treatment as the tournament landing banner: solid brand-green
// gradient block with subtle decorative rings. Permanent look for now.
function Banner({ name }: { name: string }) {
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="text-sm font-semibold uppercase tracking-wide text-primary-foreground/80">
Golfklubb
</span>
<h1 className="text-3xl font-extrabold leading-tight tracking-tight text-primary-foreground text-balance sm:text-4xl">
{name}
</h1>
</div>
</div>
)
}
// --- Empty state -----------------------------------------------------------
function EmptyState() {
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">
<CalendarX2 aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<p className="text-base font-bold text-foreground">Ingen offentlige turneringer akkurat </p>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Denne klubben har ingen kommende turneringer å vise. Kom tilbake senere for å se hva som
skjer.
</p>
</div>
</div>
)
}