teecup/frontend/components/public-tournament.tsx
Erol Haagenrud 7002f4c815 Turnering-landingssiden er live på https://teecup.teeoff.no/t/{id}. Oppsummert:
Kun én genuint ny fil denne gangen — public-tournament.tsx, resten av re-eksporten var kjent V0-revert. V0 bygde komponenten men ingen rute; jeg la selv til en bevisst flat /t/[id]-sti (ikke nøstet under org, siden det offentlige API-et kun trenger turnering-id).

To reelle navnekollisjoner løst under wiring, ikke bare et rett-frem uttrekk: API-ets "waitlisted" → komponentens "waitlist", og skjemaets norske kjønnsvalg → API-ets ^[mfx]$-mønster.

En driftsfeil funnet i selve testverktøyet, ikke i produktet: corepack hadde hentet en ny pnpm-versjon som gjorde et tidligere ufarlig varsel til en hard feil i dev-server-oppsettet mitt — rettet med samme flagg Dockerfile allerede bruker. Bekreftet at selve prod-bygget var upåvirket.

Verifisert med en ekte kjørende frontend-dev-server, ikke bare next build: ekte turnering med beskrivelse/kapasitet/sponsor/økt hentet gjennom frontend-proxyen, og en ekte POST-registrering som økte påmeldingstallet fra 0 til 1.
2026-07-18 10:32:53 +02:00

759 lines
25 KiB
TypeScript
Raw 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 type React from "react"
import { useEffect, useState } from "react"
import {
Calendar,
Users,
ChevronDown,
CheckCircle2,
Clock3,
Hourglass,
Lock,
ShieldAlert,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
// --- Types -----------------------------------------------------------------
// Interne visningstyper -- matcher ikke API-et 1:1 (se ApiTournamentInfo/
// ApiSession under og mappingen i PublicTournament), men er det
// presentasjonskomponentene under (uendret fra V0) faktisk konsumerer.
type Session = {
id: string
name: string
format: string
time?: string // ISO-streng, optional
}
type Sponsor = {
id: string
name: string
url: string
}
type TournamentInfo = {
name: string
club: string
intro: string
startDate?: Date
endDate?: Date
registered: number
capacity?: number // undefined = no cap
sessions: Session[]
sponsors: Sponsor[]
}
// "waitlisted" fra API-et vises som "waitlist" her -- se mapOutcome().
type Outcome = "confirmed" | "waitlist" | "pending"
// --- API-typer (matcher app/routers/registration.py) -----------------------
type ApiSponsor = { id: string; name: string; url: string | null }
type ApiTournamentInfo = {
id: string
name: string
organization_name: string
status: string
visibility: string
description: string | null
start_date: string | null
end_date: string | null
registration_open: boolean
registration_deadline: string | null
registration_capacity: number | null
confirmed_count: number
sponsors: ApiSponsor[]
}
type ApiSession = {
id: string
name: string
format: string
scheduled_at: string | null
}
function mapOutcome(status: string): Outcome {
return status === "waitlisted" ? "waitlist" : (status as Outcome)
}
// --- Formatting helpers ----------------------------------------------------
const DATE_FMT = new Intl.DateTimeFormat("no-NO", {
day: "numeric",
month: "long",
year: "numeric",
})
const TIME_FMT = new Intl.DateTimeFormat("no-NO", {
hour: "2-digit",
minute: "2-digit",
})
function formatDateRange(start?: Date, end?: Date) {
if (!start) return "Ingen datoer satt"
if (!end || start.toDateString() === end.toDateString()) {
return DATE_FMT.format(start)
}
return `${DATE_FMT.format(start)} ${DATE_FMT.format(end)}`
}
// --- Root ------------------------------------------------------------------
export function PublicTournament({ tournamentId }: { tournamentId: string }) {
const [info, setInfo] = useState<ApiTournamentInfo | null>(null)
const [sessions, setSessions] = useState<Session[]>([])
const [loading, setLoading] = useState(true)
const [accessError, setAccessError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const res = await fetch(`/public/tournaments/${tournamentId}`, { credentials: "include" })
if (res.status === 403 || res.status === 404) {
const body = await res.json().catch(() => null)
if (!cancelled) {
setAccessError(
body?.detail?.message ??
(res.status === 404 ? "Turneringen finnes ikke." : "Du har ikke tilgang til denne turneringen."),
)
}
return
}
if (!res.ok) throw new Error(`tournament: ${res.status}`)
const data: ApiTournamentInfo = await res.json()
if (cancelled) return
setInfo(data)
const sessionsRes = await fetch(`/public/tournaments/${tournamentId}/sessions`, {
credentials: "include",
})
if (sessionsRes.ok) {
const sessionData: ApiSession[] = await sessionsRes.json()
if (!cancelled) {
setSessions(
sessionData.map((s) => ({
id: s.id,
name: s.name,
format: s.format,
time: s.scheduled_at ?? undefined,
})),
)
}
}
} catch {
if (!cancelled) setAccessError("Klarte ikke å laste turneringen. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [tournamentId])
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 (accessError || !info) {
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">
{accessError ?? "Turneringen finnes ikke."}
</p>
</div>
)
}
const t: TournamentInfo = {
name: info.name,
club: info.organization_name,
intro: info.description ?? "",
startDate: info.start_date ? new Date(info.start_date) : undefined,
endDate: info.end_date ? new Date(info.end_date) : undefined,
registered: info.confirmed_count,
capacity: info.registration_capacity ?? undefined,
sessions,
sponsors: info.sponsors.map((s) => ({ id: s.id, name: s.name, url: s.url ?? "#" })),
}
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={t.name} club={t.club} />
{t.intro && (
<section aria-label="Om turneringen">
<p className="text-[15px] leading-relaxed text-foreground text-pretty">{t.intro}</p>
</section>
)}
<StatusArea
start={t.startDate}
end={t.endDate}
registered={t.registered}
capacity={t.capacity}
registrationOpen={info.registration_open}
/>
<Program sessions={t.sessions} />
{info.registration_open ? (
<RegistrationCard tournamentId={tournamentId} tournamentName={t.name} />
) : (
<ClosedCard />
)}
<Sponsors sponsors={t.sponsors} />
</div>
</main>
</div>
)
}
// --- Banner ----------------------------------------------------------------
function Banner({ name, club }: { name: string; club: 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">
{/* Subtle decorative rings — intentional, part of the brand banner look. */}
<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">
{club}
</span>
<h1 className="text-3xl font-extrabold leading-tight tracking-tight text-primary-foreground text-balance sm:text-4xl">
{name}
</h1>
</div>
</div>
)
}
// --- Status area -----------------------------------------------------------
function StatusArea({
start,
end,
registered,
capacity,
registrationOpen,
}: {
start?: Date
end?: Date
registered: number
capacity?: number
registrationOpen: boolean
}) {
const capacityText =
capacity != null ? `${registered} av ${capacity} plasser` : `${registered} påmeldt`
const full = capacity != null && registered >= capacity
return (
<section className="grid gap-3 sm:grid-cols-2" aria-label="Status">
<div className="flex items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Calendar aria-hidden="true" className="size-5 text-primary" />
</div>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Datoer
</span>
<span className="truncate text-sm font-bold text-foreground">
{formatDateRange(start, end)}
</span>
</div>
</div>
<div className="flex items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Users aria-hidden="true" className="size-5 text-primary" />
</div>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Påmelding
</span>
<span className="truncate text-sm font-bold text-foreground">
{capacityText}
{!registrationOpen && " · stengt"}
{registrationOpen && full && " · fullt"}
</span>
</div>
</div>
</section>
)
}
// --- Program ---------------------------------------------------------------
function Program({ sessions }: { sessions: Session[] }) {
return (
<section aria-label="Program" className="flex flex-col gap-3">
<h2 className="text-base font-bold text-foreground">Program</h2>
<ul className="flex flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm shadow-black/5">
{sessions.map((s) => {
const time = s.time ? TIME_FMT.format(new Date(s.time)) : null
return (
<li
key={s.id}
className="flex items-center gap-4 border-b border-border px-4 py-3.5 last:border-b-0"
>
<div className="flex w-14 shrink-0 items-center gap-1.5 tabular-nums">
{time ? (
<span className="text-sm font-bold text-foreground">{time}</span>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
<div className="flex min-w-0 flex-col">
<span className="truncate font-semibold text-foreground">{s.name}</span>
<span className="truncate text-sm text-muted-foreground">{s.format}</span>
</div>
</li>
)
})}
</ul>
</section>
)
}
// --- Sponsors --------------------------------------------------------------
function Sponsors({ sponsors }: { sponsors: Sponsor[] }) {
if (sponsors.length === 0) return null
return (
<section aria-label="Sponsorer" className="flex flex-col gap-3 border-t border-border pt-6">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Sponsorer
</h2>
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
{sponsors.map((sp) => (
<a
key={sp.id}
href={sp.url}
className="text-sm font-semibold text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline"
>
{sp.name}
</a>
))}
</div>
</section>
)
}
// --- Closed state ----------------------------------------------------------
function ClosedCard() {
return (
<section className="flex flex-col items-center gap-3 rounded-3xl border border-border bg-muted/40 px-6 py-8 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<Lock aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<h2 className="text-xl font-extrabold tracking-tight text-foreground text-balance">
Påmeldingen er stengt
</h2>
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground text-pretty">
Fristen for å melde seg denne turneringen har gått ut. Ta kontakt med arrangøren dersom
du har spørsmål.
</p>
</section>
)
}
// --- Registration ----------------------------------------------------------
function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
}
// nb-skjemaets kjønnsvalg -> API-ets ^[mfx]$ (app/routers/players.py).
const GENDER_TO_API: Record<string, string> = { kvinne: "f", mann: "m", annet: "x" }
function RegistrationCard({
tournamentId,
tournamentName,
}: {
tournamentId: string
tournamentName: string
}) {
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [touchedEmail, setTouchedEmail] = useState(false)
const [showMore, setShowMore] = useState(false)
const [consent, setConsent] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState<Outcome | null>(null)
const [error, setError] = useState<string | null>(null)
const [optional, setOptional] = useState({
mobile: "",
birthDate: "",
nickname: "",
country: "",
club: "",
membershipNo: "",
gender: "",
handicap: "",
})
const emailValid = isValidEmail(email)
const showEmailError = touchedEmail && email.length > 0 && !emailValid
const canSubmit = name.trim().length >= 2 && emailValid && consent && !submitting
function updateOptional(key: keyof typeof optional, value: string) {
setOptional((prev) => ({ ...prev, [key]: value }))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!canSubmit) return
setSubmitting(true)
setError(null)
try {
const hcpRaw = optional.handicap.trim()
const hcpValue = hcpRaw === "" ? undefined : Number(hcpRaw.replace(",", "."))
const res = await fetch(`/public/tournaments/${tournamentId}/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
display_name: name.trim(),
email: email.trim(),
mobile: optional.mobile.trim() || undefined,
birth_date: optional.birthDate || undefined,
nickname: optional.nickname.trim() || undefined,
country: optional.country.trim() || undefined,
club: optional.club.trim() || undefined,
club_member_number: optional.membershipNo.trim() || undefined,
gender: GENDER_TO_API[optional.gender],
handicap_index: hcpValue !== undefined && !Number.isNaN(hcpValue) ? hcpValue : undefined,
consent,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å melde deg på. Prøv igjen.")
}
const result: { id: string; status: string } = await res.json()
setSubmitted(mapOutcome(result.status))
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setSubmitting(false)
}
}
if (submitted) {
return <RegistrationOutcome outcome={submitted} name={name} tournamentName={tournamentName} />
}
return (
<section
aria-label="Påmelding"
className="flex flex-col gap-6 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"
>
<div className="flex flex-col gap-1">
<h2 className="text-lg font-extrabold tracking-tight text-foreground">Meld deg </h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Fyll inn navn og e-post, er du i gang. Resten kan du legge til senere.
</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-5" noValidate>
<div className="flex flex-col gap-2">
<Label htmlFor="reg-name" className="text-sm font-semibold">
Navn
</Label>
<Input
id="reg-name"
autoComplete="name"
placeholder="Ola Nordmann"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-13 rounded-2xl text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="reg-email" className="text-sm font-semibold">
E-post
</Label>
<Input
id="reg-email"
type="email"
inputMode="email"
autoComplete="email"
placeholder="deg@epost.no"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setTouchedEmail(true)}
aria-invalid={showEmailError}
aria-describedby={showEmailError ? "reg-email-error" : undefined}
className="h-13 rounded-2xl text-base"
/>
{showEmailError && (
<p id="reg-email-error" className="text-sm font-medium text-destructive">
Skriv inn en gyldig e-postadresse.
</p>
)}
</div>
{/* Optional details ---------------------------------------------- */}
<div className="rounded-2xl border border-border">
<button
type="button"
onClick={() => setShowMore((v) => !v)}
aria-expanded={showMore}
className="flex w-full items-center justify-between gap-2 rounded-2xl px-4 py-3.5 text-left transition-colors hover:bg-accent/40"
>
<span className="text-sm font-semibold text-foreground">Flere detaljer (valgfritt)</span>
<ChevronDown
aria-hidden="true"
className={cn(
"size-5 text-muted-foreground transition-transform",
showMore && "rotate-180",
)}
/>
</button>
{showMore && (
<div className="flex flex-col gap-4 border-t border-border p-4">
<div className="grid gap-4 sm:grid-cols-2">
<OptionalField
id="opt-mobile"
label="Mobil"
type="tel"
value={optional.mobile}
onChange={(v) => updateOptional("mobile", v)}
/>
<OptionalField
id="opt-birth"
label="Fødselsdato"
type="date"
value={optional.birthDate}
onChange={(v) => updateOptional("birthDate", v)}
/>
<OptionalField
id="opt-nick"
label="Kallenavn"
value={optional.nickname}
onChange={(v) => updateOptional("nickname", v)}
/>
<OptionalField
id="opt-country"
label="Land"
value={optional.country}
onChange={(v) => updateOptional("country", v)}
/>
<OptionalField
id="opt-club"
label="Klubb"
value={optional.club}
onChange={(v) => updateOptional("club", v)}
/>
<OptionalField
id="opt-membership"
label="Medlemsnummer"
value={optional.membershipNo}
onChange={(v) => updateOptional("membershipNo", v)}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="opt-gender" className="text-sm font-semibold">
Kjønn
</Label>
<select
id="opt-gender"
value={optional.gender}
onChange={(e) => updateOptional("gender", e.target.value)}
className="h-12 rounded-2xl border border-input bg-background px-3 text-base text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">Ikke oppgitt</option>
<option value="kvinne">Kvinne</option>
<option value="mann">Mann</option>
<option value="annet">Annet</option>
</select>
</div>
<OptionalField
id="opt-handicap"
label="Handicap-indeks"
inputMode="decimal"
value={optional.handicap}
onChange={(v) => updateOptional("handicap", v)}
/>
</div>
</div>
)}
</div>
{/* Consent ------------------------------------------------------- */}
<label className="flex cursor-pointer items-start gap-3 rounded-2xl bg-muted/40 p-4">
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
className="mt-0.5 size-5 shrink-0 cursor-pointer accent-primary"
/>
<span className="text-sm leading-relaxed text-foreground text-pretty">
Jeg samtykker til at mine opplysninger behandles for denne turneringen.
</span>
</label>
{error && (
<p role="alert" className="text-center text-sm font-medium text-destructive">
{error}
</p>
)}
<Button
type="submit"
disabled={!canSubmit}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
{submitting ? "Sender …" : "Meld meg på"}
</Button>
</form>
</section>
)
}
function OptionalField({
id,
label,
value,
onChange,
type = "text",
inputMode,
}: {
id: string
label: string
value: string
onChange: (value: string) => void
type?: string
inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"]
}) {
return (
<div className="flex flex-col gap-2">
<Label htmlFor={id} className="text-sm font-semibold">
{label}
</Label>
<Input
id={id}
type={type}
inputMode={inputMode}
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-12 rounded-2xl text-base"
/>
</div>
)
}
// --- Registration outcomes -------------------------------------------------
function RegistrationOutcome({
outcome,
name,
tournamentName,
}: {
outcome: Outcome
name: string
tournamentName: string
}) {
const firstName = name.trim().split(/\s+/)[0] || "der"
const config = {
confirmed: {
icon: CheckCircle2,
title: "Du er påmeldt!",
accent: "text-primary",
iconWrap: "bg-primary/15",
ring: "border-primary/30",
body: (
<>
Flott, {firstName}! Plassen din i{" "}
<span className="font-semibold text-foreground">{tournamentName}</span> er bekreftet. Du
får en bekreftelse e-post med alle detaljer.
</>
),
},
waitlist: {
icon: Clock3,
title: "Du står på venteliste",
accent: "text-brand-orange",
iconWrap: "bg-brand-orange/15",
ring: "border-brand-orange/40",
body: (
<>
Turneringen er full akkurat , men du er satt venteliste. Vi gir deg beskjed e-post
med en gang en plass blir ledig.
</>
),
},
pending: {
icon: Hourglass,
title: "Påmeldingen din venter på godkjenning",
accent: "text-foreground",
iconWrap: "bg-muted",
ring: "border-border",
body: (
<>
Takk, {firstName}! Arrangøren godkjenne påmeldingen din før plassen er bekreftet. Du får
en e-post snart den er behandlet.
</>
),
},
}[outcome]
const Icon = config.icon
return (
<section
aria-live="polite"
className={cn(
"flex flex-col items-center gap-4 rounded-3xl border bg-card px-6 py-9 text-center shadow-sm shadow-black/5",
config.ring,
)}
>
<div className={cn("flex size-16 items-center justify-center rounded-2xl", config.iconWrap)}>
<Icon aria-hidden="true" className={cn("size-8", config.accent)} />
</div>
<h2 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
{config.title}
</h2>
<p className="max-w-md text-sm leading-relaxed text-muted-foreground text-pretty">
{config.body}
</p>
</section>
)
}