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:
parent
7002f4c815
commit
8445b137ec
6 changed files with 245 additions and 11 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
35
CLAUDE.md
35
CLAUDE.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
10
frontend/app/clubs/[slug]/page.tsx
Normal file
10
frontend/app/clubs/[slug]/page.tsx
Normal 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} />
|
||||||
|
}
|
||||||
188
frontend/components/public-club.tsx
Normal file
188
frontend/components/public-club.tsx
Normal 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 nå</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue