teecup/frontend/components/public-tournament.tsx
Erol Haagenrud 317e24a800 Alt er ferdig og live. Kort oppsummering av hele runden:
Backend (ADR-020):

tournament.join_code — kort, unik kode generert automatisk, overstyrer synlighet
match.leading_side — cachet ledende side, oppdateres ved hvert hull
Leaderboardet fikk projected_points — hva stillingen blir om pågående matcher holder seg
Ny migrasjon 011, kjørt mot ekte teecup_db, test_isolation.sql 12/12
Frontend:

Login-skjermen har nå et kode-felt som tar deg rett til turneringen, uten innlogging
Invitasjonskoden vises i turnering-detalj med kopier-knapp
Leaderboardet har to fargesegmenterte barer øverst — faktisk stilling og projisert stilling
Matchlisten i blind draw er fargekodet etter hvem som leder
Underveis oppsto en reell hendelse: en feilformulert kommando eksponerte teeoff_db sitt superbrukerpassord. Det ble flagget umiddelbart, du valgte å rotere det, og det ble gjort trygt uten at verdien noensinne ble vist på nytt — bekreftet med rene logger og 200 på både teeoff.no og teecup.teeoff.no etterpå.

Alt er verifisert i scratch før utrulling, typesjekket build kjørt, og .md-filene (CLAUDE.md, FEATURE_BACKLOG.md, ARCHITECTURE_DECISIONS.md) er oppdatert. Jeg kommuniserer på norsk videre i dette prosjektet.
2026-07-19 09:33:34 +02:00

799 lines
26 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
logoUrl?: string
}
type TournamentInfo = {
name: string
club: string
intro: string
startDate?: Date
endDate?: Date
registered: number
capacity?: number // undefined = no cap
heroImageUrl?: string
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; logo_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
hero_image_url: string | null
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, code }: { tournamentId: string; code?: 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)
// ADR-020: en gyldig invitasjonskode overstyrer turneringens synlighet --
// føres med på BÅDE lesingen og registreringen (RegistrationCard under),
// ikke bare det første oppslaget som tok deg hit fra login-skjermet.
const codeParam = code ? `?code=${encodeURIComponent(code)}` : ""
useEffect(() => {
let cancelled = false
async function load() {
try {
const res = await fetch(`/public/tournaments/${tournamentId}${codeParam}`, { 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${codeParam}`, {
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, codeParam])
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,
heroImageUrl: info.hero_image_url ?? undefined,
sessions,
sponsors: info.sponsors.map((s) => ({
id: s.id,
name: s.name,
url: s.url ?? "#",
logoUrl: s.logo_url ?? undefined,
})),
}
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} imageUrl={t.heroImageUrl} />
{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} code={code} />
) : (
<ClosedCard />
)}
<Sponsors sponsors={t.sponsors} />
</div>
</main>
</div>
)
}
// --- Banner ----------------------------------------------------------------
function Banner({ name, club, imageUrl }: { name: string; club: string; imageUrl?: 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">
{imageUrl ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element -- images.unoptimized er alt satt, ingen next/image-fordel her */}
<img
src={imageUrl}
alt=""
aria-hidden="true"
className="absolute inset-0 size-full object-cover"
/>
{/* Skrim for lesbar tekst over et vilkårlig opplastet bilde. */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-black/10" />
</>
) : (
<>
{/* Ingen bilde lastet opp ennå -- bevisst permanent fargebanner, ikke en "kommer snart"-plassholder. */}
<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-6 gap-y-3">
{sponsors.map((sp) =>
sp.logoUrl ? (
<a key={sp.id} href={sp.url} aria-label={sp.name} className="opacity-80 transition-opacity hover:opacity-100">
{/* eslint-disable-next-line @next/next/no-img-element -- images.unoptimized er alt satt */}
<img src={sp.logoUrl} alt={sp.name} className="h-8 w-auto object-contain" />
</a>
) : (
<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,
code,
}: {
tournamentId: string
tournamentName: string
code?: 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,
code: code || undefined,
}),
})
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>
)
}