Klubb-landingssiden er live på https://teecup.teeoff.no/clubs/{slug} — dermed er ADR-018 sine to planlagte frontend-skjermer komplette.

Én reell komponentkonflikt løst, ikke duplisert bort: TournamentCard var bygget kun for den innloggede dashbord-konteksten. I stedet for å lage en egen kopi for den offentlige klubbsiden, gjorde jeg orgId valgfri — satt gir innlogget lenke, utelatt gir /t/{id} i stedet. Samme kort, to kontekster. Bekreftet dashbordet fortsatt fungerer uendret.

V0 laget selv en rute denne gangen, men kalte parameteren [id] selv om det faktisk er en slug — skrev en egen, riktig navngitt [slug]-rute i stedet.

Verifisert mot ekte scratch-data gjennom en kjørende frontend-dev-server: en org med to turneringer (én offentlig, én org-privat) — klubbsiden viste kun den offentlige, akkurat som filteret i API-et tilsier.

Live, teeoff.no upåvirket. Gjenstår av ADR-018: Open Graph-metadata for delingsforhåndsvisning, og MinIO/bilder som egen runde. Vil du ta Open Graph-metadataen nå, siden det er en liten, avgrenset bit?
This commit is contained in:
Erol Haagenrud 2026-07-18 10:48:20 +02:00
parent 7002f4c815
commit 8445b137ec
6 changed files with 245 additions and 11 deletions

View file

@ -200,7 +200,10 @@
"Bash(python3 -m zipfile -l \"tee-cup-login-screen \\(3\\).zip\")", "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(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(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)" "Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' https://teecup.teeoff.no/t/00000000-0000-0000-0000-000000000000)",
"Bash(python3 -m zipfile -l \"tee-cup-login-screen \\(4\\).zip\")",
"Bash(rm -f /opt/teecup/.env.scratch /tmp/cowner.txt)",
"Bash(curl -s -o /dev/null -w '/clubs/ukjent: %{http_code}\\\\n' https://teecup.teeoff.no/clubs/ukjent-slug-garantert)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

View file

@ -553,12 +553,39 @@ Ferdig og verifisert:
runden), `teeoff.no` upåvirket. runden), `teeoff.no` upåvirket.
**Gjenstår:** org-landingssiden (egen V0-prompt), Open Graph-metadata for **Gjenstår:** org-landingssiden (egen V0-prompt), Open Graph-metadata for
deling, MinIO/bilder. deling, MinIO/bilder.
- **Offentlig klubb-landingsside LIVE (2026-07-18), samme dag:** femte og
siste V0-skjerm i ADR-018 (`components/public-club.tsx`, ny rute
`/clubs/[slug]`). Samme banner-språk som turnering-siden, liste over
klubbens turneringer (gjenbrukte eksisterende `TournamentCard`), egen tom-
tilstand.
**Reell delt-komponent-kollisjon løst, ikke duplisert bort:**
`TournamentCard` var bygget for KUN den innloggede konteksten (krevde
`orgId`, lenket til `/tournaments/{id}?org=...`). I stedet for en egen
kopi av kortet for den offentlige siden, gjort `orgId` valgfri —
satt (dashbordet) gir innlogget lenke, utelatt (klubbsiden) gir
`/t/{id}` i stedet. Samme kort, to kontekster, ingen duplisering.
Bekreftet dashbordets egen bruk uendret/upåvirket etterpå.
**V0 opprettet denne gangen selv en rute** (`app/clubs/[id]/page.tsx`,
uten noen props sendt inn i det hele tatt) — men navnga parameteren
`[id]` selv om den faktisk er en SLUG (`public_org_by_slug()`, ADR-018).
Skrev selv en ny, riktig `app/clubs/[slug]/page.tsx` i stedet for å bruke
V0s (feilnavngitte og prop-løse) versjon.
**Verifisert mot fersk `teecup_scratch` + ekte kjørende frontend-dev-
server:** org med slug+`public_profile`, én turnering satt `public`, én
latt stå på default `org` — klubbsiden viste GJENNOM frontend-proxyen
kun den ene offentlige turneringen, den org-private var korrekt
usynlig (samme filtermønster som API-et selv, bekreftet fra
klientsiden også). Ukjent slug ga korrekt 404.
**Rullet ut live**, kun `teecup_frontend`, `teeoff.no` upåvirket.
**ADR-018s planlagte skjermer er dermed komplette.** Gjenstår: Open
Graph-metadata for deling, MinIO/bilder — begge bevisst egne, senere
runder.
Neste steg: Neste steg:
1. Org-landingsside (V0-prompt, samme mønster som turnering-siden) — siste 1. Open Graph-metadata (og:title/og:image/og:description) på
skjerm i ADR-018. Deretter Open Graph-metadata (og:title/og:image/ `/t/[id]`/`/clubs/[slug]` for delingsforhåndsvisning, deretter
og:description) for delingsforhåndsvisning, så MinIO/bilder som egen, MinIO/bildeopplasting som egen runde — de to gjenværende punktene fra
senere runde. ADR-018.
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
re-eksport hver gang — diff mot live-treet i et scratch-område før noe re-eksport hver gang — diff mot live-treet i et scratch-område før noe

View file

@ -405,11 +405,11 @@
--- ---
## Landingssider (turnering + organisasjon) — ADR-018, backend ✅ LIVE 2026-07-18 ## Landingssider (turnering + organisasjon) — ADR-018 ✅ LIVE 2026-07-18
Reist av brukeren 2026-07-18, rett etter registrerings-ADR-en (ADR-017). Reist av brukeren 2026-07-18, rett etter registrerings-ADR-en (ADR-017).
Backend bygget og live samme dag. Frontend-skjermene og MinIO/bilder Backend OG begge planlagte frontend-skjermer bygget og live samme dag.
gjenstår, bevisst egne runder. Open Graph-metadata og MinIO/bilder gjenstår, bevisst egne runder.
| Del | Status | Notat | | Del | Status | Notat |
|---|---|---| |---|---|---|
@ -425,7 +425,7 @@ gjenstår, bevisst egne runder.
| 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: turnering-landingsside + påmeldingsskjema, LIVE | ✅ | `/t/[id]`. Tre bekreftelsestilstander (bekreftet/venteliste/godkjenning venter). Verifisert med ekte `POST`-registrering mot scratch. | | 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. | | Frontend: org-/klubb-landingsside, LIVE | ✅ | `/clubs/[slug]`. Gjenbruker `TournamentCard` (nå med valgfri `orgId`) for turneringslisten. Verifisert: viser kun `public`-synlige turneringer. |
--- ---

View file

@ -0,0 +1,10 @@
import { PublicClub } from "@/components/public-club"
export default async function ClubPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return <PublicClub slug={slug} />
}

View file

@ -0,0 +1,188 @@
"use client"
import { useEffect, useState } from "react"
import { CalendarX2, ShieldAlert } from "lucide-react"
import { Wordmark } from "@/components/wordmark"
import { TournamentCard, type Tournament } from "@/components/tournament-card"
// --- API-typer (matcher app/routers/registration.py sin get_public_org) ----
type ApiOrgTournament = {
id: string
name: string
status: string
start_date: string | null
end_date: string | null
}
type ApiOrgInfo = {
name: string
tournaments: ApiOrgTournament[]
}
function toTournament(t: ApiOrgTournament): Tournament {
return {
id: t.id,
name: t.name,
status: t.status as Tournament["status"],
startDate: t.start_date ?? undefined,
endDate: t.end_date ?? undefined,
}
}
// --- Ordering --------------------------------------------------------------
// Upcoming (has a future/any start date and not completed) first, then
// tournaments without dates set, then completed/finished ones last.
function orderTournaments(list: Tournament[]) {
const rank = (t: Tournament) => {
if (t.status === "completed" || t.status === "archived") return 2
if (!t.startDate) return 1
return 0
}
return [...list].sort((a, b) => {
const diff = rank(a) - rank(b)
if (diff !== 0) return diff
// Within the upcoming group, earliest start first.
if (a.startDate && b.startDate) {
return a.startDate.localeCompare(b.startDate)
}
return 0
})
}
// --- Root ------------------------------------------------------------------
export function PublicClub({ slug }: { slug: string }) {
const [org, setOrg] = useState<ApiOrgInfo | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const res = await fetch(`/public/orgs/${slug}`, { credentials: "include" })
if (res.status === 404) {
if (!cancelled) setError("Klubben finnes ikke.")
return
}
if (!res.ok) throw new Error(`org: ${res.status}`)
const data: ApiOrgInfo = await res.json()
if (!cancelled) setOrg(data)
} catch {
if (!cancelled) setError("Klarte ikke å laste klubbsiden. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [slug])
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 (error || !org) {
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">
{error ?? "Klubben finnes ikke."}
</p>
</div>
)
}
const tournaments = orderTournaments(org.tournaments.map(toTournament))
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={org.name} />
<section aria-label="Turneringer" className="flex flex-col gap-4">
<h2 className="text-lg font-bold tracking-tight text-foreground">Turneringer</h2>
{tournaments.length > 0 ? (
<ul className="flex flex-col gap-3">
{tournaments.map((tournament) => (
<li key={tournament.id}>
<TournamentCard tournament={tournament} />
</li>
))}
</ul>
) : (
<EmptyState />
)}
</section>
</div>
</main>
</div>
)
}
// --- Banner ----------------------------------------------------------------
// Same visual treatment as the tournament landing banner: solid brand-green
// gradient block with subtle decorative rings. Permanent look for now.
function Banner({ name }: { name: 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">
<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">
Golfklubb
</span>
<h1 className="text-3xl font-extrabold leading-tight tracking-tight text-primary-foreground text-balance sm:text-4xl">
{name}
</h1>
</div>
</div>
)
}
// --- Empty state -----------------------------------------------------------
function EmptyState() {
return (
<div className="flex flex-col items-center gap-4 rounded-3xl border border-dashed border-border bg-card px-6 py-12 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<CalendarX2 aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<p className="text-base font-bold text-foreground">Ingen offentlige turneringer akkurat </p>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Denne klubben har ingen kommende turneringer å vise. Kom tilbake senere for å se hva som
skjer.
</p>
</div>
</div>
)
}

View file

@ -35,13 +35,19 @@ export function TournamentCard({
orgId, orgId,
}: { }: {
tournament: Tournament tournament: Tournament
orgId: string // Satt (dashbordet, innlogget) -> lenker til den innloggede detaljsiden.
// Utelatt (klubb-landingsside, offentlig) -> lenker til den offentlige
// /t/{id}-ruten i stedet. Samme kort, to kontekster.
orgId?: string
}) { }) {
const dateRange = formatDateRange(tournament.startDate, tournament.endDate) const dateRange = formatDateRange(tournament.startDate, tournament.endDate)
const href = orgId
? `/tournaments/${tournament.id}?org=${orgId}&name=${encodeURIComponent(tournament.name)}`
: `/t/${tournament.id}`
return ( return (
<Link <Link
href={`/tournaments/${tournament.id}?org=${orgId}&name=${encodeURIComponent(tournament.name)}`} href={href}
className="group flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-4 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50 sm:p-5" className="group flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-4 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50 sm:p-5"
> >
<div className="flex min-w-0 flex-1 flex-col gap-2"> <div className="flex min-w-0 flex-1 flex-col gap-2">