From 7002f4c81533a7e2c0e71e33898649fe6b6d9c6d Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sat, 18 Jul 2026 10:32:53 +0200 Subject: [PATCH] =?UTF-8?q?Turnering-landingssiden=20er=20live=20p=C3=A5?= =?UTF-8?q?=20https://teecup.teeoff.no/t/{id}.=20Oppsummert:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .claude/settings.local.json | 6 +- CLAUDE.md | 51 +- FEATURE_BACKLOG.md | 3 +- frontend/app/t/[id]/page.tsx | 10 + frontend/components/public-tournament.tsx | 759 ++++++++++++++++++++++ 5 files changed, 824 insertions(+), 5 deletions(-) create mode 100644 frontend/app/t/[id]/page.tsx create mode 100644 frontend/components/public-tournament.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3df022f..3e3fb02 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -196,7 +196,11 @@ "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(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": [ "/opt/teeoff/deploy", diff --git a/CLAUDE.md b/CLAUDE.md index 00cfd16..8691a1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -508,11 +508,56 @@ Ferdig og verifisert: `/public/:path*`-mønsteret fra ADR-016 dekker allerede `/public/orgs/*`). **Gjenstår:** selve landingsside-SKJERMENE i frontend (V0), og 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: -1. Landingssider (turnering/org): ADR-018 sin backend er ferdig og live - (synlighet, org-profil/slug, sponsorer, PATCH-endepunkter) — gjenstår er - selve skjermene i frontend (V0), pluss MinIO/bildeopplasting som egen, +1. Org-landingsside (V0-prompt, samme mønster som turnering-siden) — siste + skjerm i ADR-018. Deretter Open Graph-metadata (og:title/og:image/ + og:description) for delingsforhåndsvisning, så MinIO/bilder som egen, senere runde. 2. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) — samme mønster: design i V0 (fortsett i samme prosjekt), FORVENT en full diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 1804cf3..ac49122 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -424,7 +424,8 @@ gjenstår, bevisst egne runder. | 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. | | 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. | --- diff --git a/frontend/app/t/[id]/page.tsx b/frontend/app/t/[id]/page.tsx new file mode 100644 index 0000000..5bd7006 --- /dev/null +++ b/frontend/app/t/[id]/page.tsx @@ -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 +} diff --git a/frontend/components/public-tournament.tsx b/frontend/components/public-tournament.tsx new file mode 100644 index 0000000..fcf5d31 --- /dev/null +++ b/frontend/components/public-tournament.tsx @@ -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(null) + const [sessions, setSessions] = useState([]) + const [loading, setLoading] = useState(true) + const [accessError, setAccessError] = useState(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 ( +
+ + ) + } + + if (accessError || !info) { + return ( +
+
+
+

+ {accessError ?? "Turneringen finnes ikke."} +

+
+ ) + } + + 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 ( +
+
+
+ +
+
+ +
+
+ + + {t.intro && ( +
+

{t.intro}

+
+ )} + + + + + + {info.registration_open ? ( + + ) : ( + + )} + + +
+
+
+ ) +} + +// --- Banner ---------------------------------------------------------------- + +function Banner({ name, club }: { name: string; club: string }) { + return ( +
+ {/* Subtle decorative rings — intentional, part of the brand banner look. */} +