teecup/frontend/components/public-tournament.tsx

1010 lines
34 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 Link from "next/link"
import {
Calendar,
Users,
ChevronDown,
ChevronRight,
CheckCircle2,
Clock3,
Hourglass,
ImagePlus,
Lock,
MessageSquare,
Send,
ShieldAlert,
Trophy,
X,
} 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}
/>
<Link
href={`/t/${tournamentId}/live${codeParam}`}
className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:bg-accent/40"
>
<div className="flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Trophy aria-hidden="true" className="size-5 text-primary" />
</div>
<span className="text-sm font-bold text-foreground">Følg live -- stilling og matcher</span>
</div>
<ChevronRight aria-hidden="true" className="size-5 text-muted-foreground" />
</Link>
<Program sessions={t.sessions} />
{info.registration_open ? (
<RegistrationCard tournamentId={tournamentId} tournamentName={t.name} code={code} />
) : (
<ClosedCard />
)}
<Sponsors sponsors={t.sponsors} />
<TournamentFeed tournamentId={tournamentId} code={code} />
</div>
</main>
</div>
)
}
// --- Offentlig runde-feed («Banter Board», ADR-025) -------------------------
//
// LESING er tilgjengelig for alle som kan se selve turneringssiden (samme
// visibility-sjekk som info-kallet over -- får denne siden vist, får du også
// lest feeden). POSTING er strengere (håndheves server-side, ikke gjettet
// her): krever innlogging OG org-medlemskap/deltakelse -- en anonym eller
// uvedkommende bruker ser derfor et forklarende felt i stedet for skjemaet.
type ApiFeedMessage = {
id: string
author_user_id: string
author_display_name: string
body: string | null
image_url: string | null
created_at: string
}
const FEED_TIME_FMT = new Intl.DateTimeFormat("no-NO", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
})
function feedWsUrl(tournamentId: string, code?: string): string {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
const query = code ? `?code=${encodeURIComponent(code)}` : ""
return `${protocol}//${window.location.host}/ws/public/tournaments/${tournamentId}/feed${query}`
}
function TournamentFeed({ tournamentId, code }: { tournamentId: string; code?: string }) {
const [messages, setMessages] = useState<ApiFeedMessage[]>([])
const [loading, setLoading] = useState(true)
const [text, setText] = useState("")
const [image, setImage] = useState<File | null>(null)
const [sending, setSending] = useState(false)
const [postError, setPostError] = useState<string | null>(null)
const codeParam = code ? `?code=${encodeURIComponent(code)}` : ""
useEffect(() => {
let cancelled = false
fetch(`/public/tournaments/${tournamentId}/feed${codeParam}`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiFeedMessage[]) => {
if (!cancelled) setMessages(data)
})
.catch(() => {})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [tournamentId, codeParam])
useEffect(() => {
const socket = new WebSocket(feedWsUrl(tournamentId, code))
socket.onmessage = (event) => {
try {
const msg: ApiFeedMessage = JSON.parse(event.data)
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
} catch {
// ignorer ugyldig payload
}
}
return () => socket.close()
}, [tournamentId, code])
async function send(e: React.FormEvent) {
e.preventDefault()
if (!text.trim() && !image) return
setSending(true)
setPostError(null)
try {
const form = new FormData()
if (text.trim()) form.set("body", text.trim())
if (image) form.set("image", image)
if (code) form.set("code", code)
const res = await fetch(`/public/tournaments/${tournamentId}/feed`, {
method: "POST",
credentials: "include",
body: form,
})
if (res.status === 401) {
setPostError("Du må logge inn for å poste i feeden.")
return
}
if (res.status === 403) {
setPostError("Du må være medlem av arrangøren eller delta i turneringen for å poste.")
return
}
if (!res.ok) throw new Error(`post: ${res.status}`)
const msg: ApiFeedMessage = await res.json()
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]))
setText("")
setImage(null)
} catch {
setPostError("Klarte ikke å poste. Prøv igjen.")
} finally {
setSending(false)
}
}
return (
<section aria-label="Runde-feed" className="flex flex-col gap-3 border-t border-border pt-6">
<div className="flex items-center gap-2">
<MessageSquare aria-hidden="true" className="size-4 text-primary" />
<h2 className="text-base font-bold text-foreground">Oppslagstavle</h2>
</div>
{loading ? (
<div className="flex justify-center py-6">
<div
aria-hidden="true"
className="size-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"
/>
</div>
) : (
<div className="flex flex-col gap-3">
{messages.length === 0 && (
<p className="text-sm text-muted-foreground">Ingenting postet ennå -- bli den første.</p>
)}
{messages.map((m) => (
<div key={m.id} className="rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="mb-1.5 flex items-baseline justify-between gap-2">
<span className="text-sm font-bold text-foreground">{m.author_display_name}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{FEED_TIME_FMT.format(new Date(m.created_at))}
</span>
</div>
{m.image_url && (
// eslint-disable-next-line @next/next/no-img-element -- images.unoptimized er alt satt
<img src={m.image_url} alt="" className="mb-2 max-h-72 w-full rounded-xl object-cover" />
)}
{m.body && <p className="text-sm leading-relaxed text-foreground text-pretty">{m.body}</p>}
</div>
))}
</div>
)}
<form onSubmit={send} className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-3">
{postError && (
<p role="alert" className="text-xs font-medium text-destructive text-pretty">
{postError}
</p>
)}
{image && (
<div className="flex w-fit items-center gap-2 rounded-xl bg-muted px-3 py-1.5 text-xs font-medium text-foreground">
{image.name}
<button type="button" onClick={() => setImage(null)} aria-label="Fjern bilde">
<X aria-hidden="true" className="size-3.5" />
</button>
</div>
)}
<div className="flex items-center gap-2">
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
id="feed-image-input"
className="hidden"
onChange={(e) => setImage(e.target.files?.[0] ?? null)}
/>
<label
htmlFor="feed-image-input"
className="flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<ImagePlus aria-hidden="true" className="size-5" />
</label>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Skriv noe til feeden …"
className="h-10 flex-1 rounded-xl border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<Button
type="submit"
size="icon"
disabled={sending || (!text.trim() && !image)}
aria-label="Post"
className="size-10 shrink-0 rounded-xl"
>
<Send aria-hidden="true" className="size-4" />
</Button>
</div>
</form>
</section>
)
}
// --- 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-md shadow-black/8">
<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-md shadow-black/8">
<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-md shadow-black/8">
{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-md shadow-black/8 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-md shadow-black/8",
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>
)
}