2026-07-18 10:32:53 +02:00
|
|
|
|
"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
|
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend.
Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd.
To reelle feil funnet under scratch-verifisering, aldri i produksjon:
pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker.
MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur.
Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet.
Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden.
Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført.
Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
|
|
|
|
logoUrl?: string
|
2026-07-18 10:32:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type TournamentInfo = {
|
|
|
|
|
|
name: string
|
|
|
|
|
|
club: string
|
|
|
|
|
|
intro: string
|
|
|
|
|
|
startDate?: Date
|
|
|
|
|
|
endDate?: Date
|
|
|
|
|
|
registered: number
|
|
|
|
|
|
capacity?: number // undefined = no cap
|
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend.
Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd.
To reelle feil funnet under scratch-verifisering, aldri i produksjon:
pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker.
MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur.
Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet.
Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden.
Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført.
Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
|
|
|
|
heroImageUrl?: string
|
2026-07-18 10:32:53 +02:00
|
|
|
|
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) -----------------------
|
|
|
|
|
|
|
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend.
Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd.
To reelle feil funnet under scratch-verifisering, aldri i produksjon:
pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker.
MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur.
Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet.
Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden.
Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført.
Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
|
|
|
|
type ApiSponsor = { id: string; name: string; url: string | null; logo_url: string | null }
|
2026-07-18 10:32:53 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend.
Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd.
To reelle feil funnet under scratch-verifisering, aldri i produksjon:
pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker.
MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur.
Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet.
Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden.
Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført.
Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
|
|
|
|
hero_image_url: string | null
|
2026-07-18 10:32:53 +02:00
|
|
|
|
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,
|
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend.
Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd.
To reelle feil funnet under scratch-verifisering, aldri i produksjon:
pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker.
MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur.
Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet.
Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden.
Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført.
Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
|
|
|
|
heroImageUrl: info.hero_image_url ?? undefined,
|
2026-07-18 10:32:53 +02:00
|
|
|
|
sessions,
|
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend.
Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd.
To reelle feil funnet under scratch-verifisering, aldri i produksjon:
pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker.
MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur.
Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet.
Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden.
Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført.
Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
|
|
|
|
sponsors: info.sponsors.map((s) => ({
|
|
|
|
|
|
id: s.id,
|
|
|
|
|
|
name: s.name,
|
|
|
|
|
|
url: s.url ?? "#",
|
|
|
|
|
|
logoUrl: s.logo_url ?? undefined,
|
|
|
|
|
|
})),
|
2026-07-18 10:32:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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">
|
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend.
Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd.
To reelle feil funnet under scratch-verifisering, aldri i produksjon:
pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker.
MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur.
Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet.
Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden.
Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført.
Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
|
|
|
|
<Banner name={t.name} club={t.club} imageUrl={t.heroImageUrl} />
|
2026-07-18 10:32:53 +02:00
|
|
|
|
|
|
|
|
|
|
{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 ----------------------------------------------------------------
|
|
|
|
|
|
|
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend.
Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd.
To reelle feil funnet under scratch-verifisering, aldri i produksjon:
pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker.
MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur.
Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet.
Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden.
Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført.
Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
|
|
|
|
function Banner({ name, club, imageUrl }: { name: string; club: string; imageUrl?: string }) {
|
2026-07-18 10:32:53 +02:00
|
|
|
|
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">
|
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend.
Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd.
To reelle feil funnet under scratch-verifisering, aldri i produksjon:
pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker.
MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur.
Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet.
Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden.
Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført.
Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
|
|
|
|
{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"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-07-18 10:32:53 +02:00
|
|
|
|
<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>
|
MinIO-runden er ferdig, verifisert og live — ADR-018 er nå helt komplett, ingenting utsatt igjen bortsett fra selve opplastings-skjermen i frontend.
Presiseringen din midt i byggingen (AVIF) endret arkitekturen til det bedre: droppet den opprinnelige planen med presignerte nettleser-URL-er til fordel for ekte multipart-opplasting gjennom API-et, som konverterer til AVIF (Pillow) før lagring. Dette forenklet ting betydelig — kun én MinIO-klient trengs nå, og Caddy-ruten slapp den skjøre signatur-Host-header-følsomheten presignering ville krevd.
To reelle feil funnet under scratch-verifisering, aldri i produksjon:
pillow-avif-plugin testet frittstående først — fungerer uten ekstra systempakker.
MinIO avviser understrek i vertsnavn (teecup_minio feilet umiddelbart) — rettet til teecup-minio (bindestrek) før noe ble forsøkt mot ekte infrastruktur.
Caddy-ruten unngikk en skjult DNS-felle: en ny subdomene ville pekt til en IPv6-adresse denne serveren ikke har — løst med en sti-prefiks (/teecup-media/*) på det eksisterende domenet i stedet.
Verifisert i flere lag: ekte bilde lastet opp i scratch, bekreftet konvertert til gyldig AVIF, bekreftet lesbart anonymt direkte mot MinIO, alle tre valideringsveier (ugyldig format, korrupt fil, for stor fil) testet. Etter Caddy-omstart: et ekte anonymt kall mot produksjonsdomenet ga en ekte MinIO-feilrespons — beviser ruten treffer MinIO, ikke frontend sin 404-side. teeoff.no upåvirket gjennom hele runden.
Bevisst ikke gjort: et ekte bilde lastet opp til en ekte, live turnering (ville skrevet i din ekte konto uten at du ble spurt) — tilbys, ikke utført.
Vil du at jeg tester en ekte opplasting mot en av dine eksisterende turneringer, eller går vi videre til neste V0-skjerm (økt/program, blind draw, scorekort eller leaderboard)?
2026-07-18 11:25:52 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
),
|
|
|
|
|
|
)}
|
2026-07-18 10:32:53 +02:00
|
|
|
|
</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 på 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 på</h2>
|
|
|
|
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
|
|
|
|
Fyll inn navn og e-post, så 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 på 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 nå, men du er satt på venteliste. Vi gir deg beskjed på 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 må godkjenne påmeldingen din før plassen er bekreftet. Du får
|
|
|
|
|
|
en e-post så 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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|