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.
This commit is contained in:
Erol Haagenrud 2026-07-18 10:32:53 +02:00
parent 2058d63db5
commit 7002f4c815
5 changed files with 824 additions and 5 deletions

View file

@ -196,7 +196,11 @@
"Bash(curl -s https://teecup.teeoff.no/public/tournaments/00000000-0000-0000-0000-000000000000)", "Bash(curl -s https://teecup.teeoff.no/public/tournaments/00000000-0000-0000-0000-000000000000)",
"Bash(docker exec -i teeoff_db psql -U teeoff_admin -d teecup_scratch -v ON_ERROR_STOP=1 < /opt/teecup/test_isolation.sql 2>&1 | tail -8 *)", "Bash(docker exec -i teeoff_db psql -U teeoff_admin -d teecup_scratch -v ON_ERROR_STOP=1 < /opt/teecup/test_isolation.sql 2>&1 | tail -8 *)",
"Bash(python3 -m py_compile /opt/teecup/app/auth.py /opt/teecup/app/routers/tournaments.py /opt/teecup/app/routers/organizations.py /opt/teecup/app/routers/registration.py /opt/teecup/app/main.py)", "Bash(python3 -m py_compile /opt/teecup/app/auth.py /opt/teecup/app/routers/tournaments.py /opt/teecup/app/routers/organizations.py /opt/teecup/app/routers/registration.py /opt/teecup/app/main.py)",
"Bash(curl -s https://teecup.teeoff.no/public/orgs/finnes-ikke-garantert)" "Bash(curl -s https://teecup.teeoff.no/public/orgs/finnes-ikke-garantert)",
"Bash(python3 -m zipfile -l \"tee-cup-login-screen \\(3\\).zip\")",
"Bash(python3 -c \"import sys,json;print\\('confirmed_count:', json.load\\(sys.stdin\\)['confirmed_count']\\)\")",
"Bash(rm -f /opt/teecup/.env.scratch /tmp/towner.txt)",
"Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' https://teecup.teeoff.no/t/00000000-0000-0000-0000-000000000000)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

View file

@ -508,11 +508,56 @@ Ferdig og verifisert:
`/public/:path*`-mønsteret fra ADR-016 dekker allerede `/public/orgs/*`). `/public/:path*`-mønsteret fra ADR-016 dekker allerede `/public/orgs/*`).
**Gjenstår:** selve landingsside-SKJERMENE i frontend (V0), og **Gjenstår:** selve landingsside-SKJERMENE i frontend (V0), og
MinIO/bildeopplasting — bevisst utsatt, egen runde. MinIO/bildeopplasting — bevisst utsatt, egen runde.
- **Offentlig turnering-landingsside LIVE (2026-07-18), samme dag:** fjerde
V0-skjerm (`components/public-tournament.tsx`, ny rute `/t/[id]`) —
banner (bevisst permanent farge-/gradient-utseende, ikke en "bilde
kommer"-plassholder), presenterende tekst, status (datoer + "X av Y
plasser"), program, sponsorer (navn+lenke), og et påmeldingsskjema med
navn+e-post synlig først og resten bak en "flere detaljer"-utvidelse —
tre distinkte bekreftelsestilstander (bekreftet/venteliste/godkjenning
venter), ikke én generisk "takk".
**Ingen ny reeksport-kollisjon denne gangen** — kun étt genuint nytt
filnavn (`public-tournament.tsx`), resten var kjent V0-revert av
allerede-tilpassede filer, samme mønster som før.
**V0 opprettet komponenten, men ingen rute** — la selv til `app/t/[id]/
page.tsx` (bevisst en FLAT `/t/[id]`-sti, ikke nøstet under `/orgs/...`
som den innloggede turnering-detalj-siden, siden det offentlige API-et
kun trenger turnering-id, ikke org-id).
**Datalaget skrevet om fra V0s mock:** ekte `fetch` mot
`GET /public/tournaments/{id}` + `/sessions`, ekte `POST .../register`.
Håndterer 403 (`NOT_VISIBLE`, ADR-018) og 404 med en egen tilgang-avvist-
tilstand V0 ikke hadde bedt om (fantes ikke i prompten, men trengs for at
siden faktisk skal fungere for `org`/`participants`-synlige turneringer).
Mappet `status:"waitlisted"` fra API-et til komponentens `"waitlist"`, og
skjemaets norske kjønnsvalg ("kvinne"/"mann"/"annet") til API-ets
`^[mfx]$`-mønster — to reelle navnekollisjoner mellom V0s UI-språk og
API-kontrakten, ikke bare et rett-frem felt-for-felt-uttrekk.
**Reell driftsfeil funnet OG rettet under scratch-test, ikke i
produksjon:** `pnpm install` (uten `--ignore-scripts`) i dev-server-
testoppsettet feilet stille med tom logg og exit 1 — corepack hadde
hentet en NY pnpm-versjon (11.13.1 → 11.14.0) siden sist, som gjør
"ignored builds"-varselet til en hard feil i stedet for bare en
advarsel. Rettet ved å bruke samme `--ignore-scripts`-flagg som
`frontend/Dockerfile` allerede bruker (upåvirket av denne — bekreftet
ved at selve prod-buildet fortsatt gikk rent). **Lærdom:** `Dockerfile`
sin pinning av *kode* er ikke det samme som å pinne *verktøyene rundt*
(corepack henter alltid siste pnpm) — verdt å huske neste gang et
scratch-dev-server-oppsett plutselig feiler uten åpenbar grunn.
**Verifisert grundig mot fersk `teecup_scratch` + en ekte kjørende
frontend-dev-server** (ikke bare `next build`): ekte turnering med
beskrivelse, kapasitet, sponsor og økt opprettet via API-et, hentet
gjennom frontend-proxyen (ikke direkte mot backend) og bekreftet
byte-for-byte riktig — inkludert en ekte `POST`-registrering som økte
`confirmed_count` fra 0 til 1.
**Rullet ut live**, kun `teecup_frontend` (ingen backend-endring denne
runden), `teeoff.no` upåvirket.
**Gjenstår:** org-landingssiden (egen V0-prompt), Open Graph-metadata for
deling, MinIO/bilder.
Neste steg: Neste steg:
1. Landingssider (turnering/org): ADR-018 sin backend er ferdig og live 1. Org-landingsside (V0-prompt, samme mønster som turnering-siden) — siste
(synlighet, org-profil/slug, sponsorer, PATCH-endepunkter) — gjenstår er skjerm i ADR-018. Deretter Open Graph-metadata (og:title/og:image/
selve skjermene i frontend (V0), pluss MinIO/bildeopplasting som egen, og:description) for delingsforhåndsvisning, så MinIO/bilder som egen,
senere runde. senere runde.
2. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) — 2. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) —
samme mønster: design i V0 (fortsett i samme prosjekt), FORVENT en full samme mønster: design i V0 (fortsett i samme prosjekt), FORVENT en full

View file

@ -424,7 +424,8 @@ gjenstår, bevisst egne runder.
| Del-metadata (Open Graph: og:title/og:image/og:description) | 📋 | Frontend-arbeid, ikke gjort ennå. | | Del-metadata (Open Graph: og:title/og:image/og:description) | 📋 | Frontend-arbeid, ikke gjort ennå. |
| Blind draw-skjuling på offentlig side | ✅ | Arves automatisk via delt `_fetch_sessions()`-hjelpefunksjon (ADR-018 Beslutning E) — ikke reimplementert. | | Blind draw-skjuling på offentlig side | ✅ | Arves automatisk via delt `_fetch_sessions()`-hjelpefunksjon (ADR-018 Beslutning E) — ikke reimplementert. |
| Antall påmeldte / ledige plasser vist åpent | ✅ | `confirmed_count` i `GET /public/tournaments/{id}`. | | Antall påmeldte / ledige plasser vist åpent | ✅ | `confirmed_count` i `GET /public/tournaments/{id}`. |
| Frontend-skjermer (turnering-/org-landingsside, påmeldingsskjema) | 📋 | Ikke startet — neste steg. | | Frontend: turnering-landingsside + påmeldingsskjema, LIVE | ✅ | `/t/[id]`. Tre bekreftelsestilstander (bekreftet/venteliste/godkjenning venter). Verifisert med ekte `POST`-registrering mot scratch. |
| Frontend: org-landingsside | 📋 | Ikke startet — neste steg. |
--- ---

View file

@ -0,0 +1,10 @@
import { PublicTournament } from "@/components/public-tournament"
export default async function PublicTournamentPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return <PublicTournament tournamentId={id} />
}

View file

@ -0,0 +1,759 @@
"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>
)
}