189 lines
6.3 KiB
TypeScript
189 lines
6.3 KiB
TypeScript
|
|
"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 nå</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>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|