teecup/frontend/components/tournament-presentation.tsx

681 lines
28 KiB
TypeScript
Raw Normal View History

"use client"
// Turnering-presentasjon (ADR-018): beskrivelse, synlighet, påmeldings-
// innstillinger, hero-bilde og sponsorer. Backend for alt dette har vært
// klart siden 2026-07-18 -- denne skjermen var det manglende siste steget
// (se FEATURE_BACKLOG.md "Landingssider"-seksjonen, rettet 2026-08-03).
// Delt mellom lag- og individuelle turneringer (samme `tournament`-tabell,
// disse feltene er ikke format_type-spesifikke) -- se tournament-router.tsx
// for hvordan format_type ellers velger skjerm.
import type React from "react"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import {
ArrowLeft,
Camera,
Check,
ExternalLink,
Globe,
Lock,
Plus,
Trash2,
Users,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
type Visibility = "public" | "org" | "participants"
type OverflowPolicy = "waitlist" | "closed"
type ApiTournament = {
id: string
visibility: Visibility
description: string | null
registration_deadline: string | null
registration_capacity: number | null
registration_overflow_policy: OverflowPolicy
registration_requires_approval: boolean
hero_image_url: string | null
}
type ApiSponsor = {
id: string
name: string
url: string | null
logo_url: string | null
}
const VISIBILITY_OPTIONS: { value: Visibility; label: string; hint: string; icon: typeof Globe }[] = [
{ value: "public", label: "Offentlig", hint: "Alle kan se siden, uten innlogging.", icon: Globe },
{ value: "org", label: "Kun org", hint: "Kun medlemmer av klubben.", icon: Users },
{ value: "participants", label: "Kun deltakere", hint: "Kun rostrede/registrerte spillere.", icon: Lock },
]
function isoToLocalInputValue(iso: string | null): string {
if (!iso) return ""
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ""
const pad = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function localInputValueToIso(value: string): string | null {
if (!value.trim()) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d.toISOString()
}
// Innholdet uten header/fanerad -- egen eksport slik at
// individual-tournament-detail.tsx (som har sitt eget in-page fanesystem,
// ikke egne ruter som lagturneringene) kan bygge den inn som en fjerde fane
// uten å duplisere en header. TournamentPresentation under (full side, egen
// rute) er kun en tynn header+nav-innpakning rundt denne.
export function TournamentPresentationPanel({
organizationId,
tournamentId,
}: {
organizationId: string
tournamentId: string
}) {
const [tournament, setTournament] = useState<ApiTournament | null>(null)
const [sponsors, setSponsors] = useState<ApiSponsor[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
// Skjema-lokal tilstand, seedet fra `tournament` ved (ny) lasting.
const [description, setDescription] = useState("")
const [visibility, setVisibility] = useState<Visibility>("org")
const [requiresApproval, setRequiresApproval] = useState(false)
const [overflowPolicy, setOverflowPolicy] = useState<OverflowPolicy>("waitlist")
const [capacity, setCapacity] = useState("")
const [deadline, setDeadline] = useState("")
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [uploadingHero, setUploadingHero] = useState(false)
const heroInputRef = useRef<HTMLInputElement>(null)
const [sponsorName, setSponsorName] = useState("")
const [sponsorUrl, setSponsorUrl] = useState("")
const [addingSponsor, setAddingSponsor] = useState(false)
const [sponsorError, setSponsorError] = useState<string | null>(null)
const [logoTargetId, setLogoTargetId] = useState<string | null>(null)
const [uploadingLogoFor, setUploadingLogoFor] = useState<string | null>(null)
const sponsorLogoInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const [tournamentsRes, sponsorsRes] = await Promise.all([
fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sponsors`, { credentials: "include" }),
])
if (!tournamentsRes.ok) throw new Error("load failed")
const list: ApiTournament[] = await tournamentsRes.json()
const mine = list.find((t) => t.id === tournamentId)
if (!mine) throw new Error("not found")
if (cancelled) return
setTournament(mine)
setDescription(mine.description ?? "")
setVisibility(mine.visibility)
setRequiresApproval(mine.registration_requires_approval)
setOverflowPolicy(mine.registration_overflow_policy)
setCapacity(mine.registration_capacity != null ? String(mine.registration_capacity) : "")
setDeadline(isoToLocalInputValue(mine.registration_deadline))
if (sponsorsRes.ok) setSponsors(await sponsorsRes.json())
} catch {
if (!cancelled) setLoadError("Klarte ikke å laste turnering-presentasjonen. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [organizationId, tournamentId])
async function handleSave() {
setSaving(true)
setSaveError(null)
setSaved(false)
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
description: description.trim() === "" ? null : description.trim(),
visibility,
registration_requires_approval: requiresApproval,
registration_overflow_policy: overflowPolicy,
registration_capacity: capacity.trim() === "" ? null : Number.parseInt(capacity, 10),
registration_deadline: localInputValueToIso(deadline),
}),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å lagre.")
}
const updated: ApiTournament = await res.json()
setTournament(updated)
setSaved(true)
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Klarte ikke å lagre. Prøv igjen.")
} finally {
setSaving(false)
}
}
async function handleHeroSelected(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setUploadingHero(true)
setSaveError(null)
try {
const formData = new FormData()
formData.append("file", file)
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/hero-image`, {
method: "POST",
credentials: "include",
body: formData,
})
if (!res.ok) throw new Error("Klarte ikke å laste opp bildet.")
const updated: ApiTournament = await res.json()
setTournament(updated)
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Klarte ikke å laste opp bildet.")
} finally {
setUploadingHero(false)
if (heroInputRef.current) heroInputRef.current.value = ""
}
}
async function handleRemoveHero() {
setUploadingHero(true)
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/hero-image`, {
method: "DELETE",
credentials: "include",
})
if (res.ok) setTournament(await res.json())
} finally {
setUploadingHero(false)
}
}
async function handleAddSponsor(e: React.FormEvent) {
e.preventDefault()
if (sponsorName.trim() === "") return
setAddingSponsor(true)
setSponsorError(null)
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sponsors`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name: sponsorName.trim(), url: sponsorUrl.trim() === "" ? null : sponsorUrl.trim() }),
})
if (!res.ok) throw new Error("Klarte ikke å legge til sponsoren.")
const created: ApiSponsor = await res.json()
setSponsors((prev) => [...prev, created])
setSponsorName("")
setSponsorUrl("")
} catch (err) {
setSponsorError(err instanceof Error ? err.message : "Klarte ikke å legge til sponsoren.")
} finally {
setAddingSponsor(false)
}
}
async function handleDeleteSponsor(sponsorId: string) {
setSponsorError(null)
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sponsors/${sponsorId}`, {
method: "DELETE",
credentials: "include",
})
if (res.status !== 204) throw new Error("Klarte ikke å fjerne sponsoren.")
setSponsors((prev) => prev.filter((s) => s.id !== sponsorId))
} catch (err) {
setSponsorError(err instanceof Error ? err.message : "Klarte ikke å fjerne sponsoren.")
}
}
function triggerLogoUpload(sponsorId: string) {
setLogoTargetId(sponsorId)
sponsorLogoInputRef.current?.click()
}
async function handleLogoSelected(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
const sponsorId = logoTargetId
if (!file || !sponsorId) return
setUploadingLogoFor(sponsorId)
setSponsorError(null)
try {
const formData = new FormData()
formData.append("file", file)
const res = await fetch(
`/orgs/${organizationId}/tournaments/${tournamentId}/sponsors/${sponsorId}/logo`,
{ method: "POST", credentials: "include", body: formData },
)
if (!res.ok) throw new Error("Klarte ikke å laste opp logoen.")
const updated: ApiSponsor = await res.json()
setSponsors((prev) => prev.map((s) => (s.id === updated.id ? updated : s)))
} catch (err) {
setSponsorError(err instanceof Error ? err.message : "Klarte ikke å laste opp logoen.")
} finally {
setUploadingLogoFor(null)
setLogoTargetId(null)
if (sponsorLogoInputRef.current) sponsorLogoInputRef.current.value = ""
}
}
return (
<div className="flex flex-col gap-1">
<div className="mb-4 flex flex-col gap-1">
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Hvordan turneringen vises sin offentlige side (
<Link
href={`/t/${tournamentId}`}
target="_blank"
className="underline underline-offset-2 hover:text-foreground"
>
/t/{tournamentId}
<ExternalLink aria-hidden="true" className="ml-1 inline size-3" />
</Link>
) og klubbsiden.
</p>
</div>
<div className="flex flex-col gap-4">
{loadError && (
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
{loadError}
</p>
)}
{loading ? (
<div className="flex justify-center py-16">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
) : tournament ? (
<div className="flex flex-col gap-4">
{/* --- Hero-bilde ------------------------------------------------ */}
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<h3 className="text-sm font-bold text-foreground">Hero-bilde</h3>
<div className="flex items-center gap-4">
<div className="relative flex h-20 w-32 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-secondary">
{tournament.hero_image_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={tournament.hero_image_url} alt="" className="size-full object-cover" />
) : (
<Camera aria-hidden="true" className="size-6 text-muted-foreground" />
)}
</div>
<div className="flex flex-col gap-1.5">
<input
ref={heroInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={handleHeroSelected}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingHero}
onClick={() => heroInputRef.current?.click()}
className="h-11 rounded-xl font-semibold"
>
<Camera aria-hidden="true" className="size-4" />
{tournament.hero_image_url ? "Bytt bilde" : "Last opp bilde"}
</Button>
{tournament.hero_image_url && (
<button
type="button"
onClick={handleRemoveHero}
disabled={uploadingHero}
className="inline-flex w-fit items-center gap-1 text-xs font-semibold text-muted-foreground transition-colors hover:text-destructive"
>
<X aria-hidden="true" className="size-3" />
Fjern bilde
</button>
)}
</div>
</div>
</section>
{/* --- Beskrivelse & synlighet ------------------------------------ */}
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<h3 className="text-sm font-bold text-foreground">Beskrivelse og synlighet</h3>
<div className="flex flex-col gap-1.5">
<Label htmlFor="tournament-description">Beskrivelse</Label>
<textarea
id="tournament-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
placeholder="Fortell spillere og besøkende hva turneringen handler om…"
className="w-full rounded-xl border border-input bg-transparent px-3 py-2.5 text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Hvem kan se siden</Label>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{VISIBILITY_OPTIONS.map((opt) => {
const Icon = opt.icon
const selected = visibility === opt.value
return (
<button
key={opt.value}
type="button"
onClick={() => setVisibility(opt.value)}
aria-pressed={selected}
className={cn(
"flex min-h-11 flex-col items-start gap-1 rounded-2xl border px-3 py-3 text-left transition-colors",
selected
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<span className="flex items-center gap-1.5 text-sm font-semibold">
<Icon aria-hidden="true" className="size-4" />
{opt.label}
</span>
<span className="text-xs leading-snug text-muted-foreground text-pretty">{opt.hint}</span>
</button>
)
})}
</div>
</div>
</section>
{/* --- Påmelding --------------------------------------------------- */}
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<h3 className="text-sm font-bold text-foreground">Påmelding</h3>
<div className="flex items-center justify-between gap-4 border-b border-border py-1 last:border-b-0">
<span className="text-sm font-medium text-foreground text-pretty">
Krev godkjenning før bekreftet plass
</span>
<Switch checked={requiresApproval} onCheckedChange={setRequiresApproval} />
</div>
<div className="flex flex-col gap-1.5">
<Label>Når plassene tar slutt</Label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setOverflowPolicy("waitlist")}
aria-pressed={overflowPolicy === "waitlist"}
className={cn(
"flex min-h-11 items-center justify-center rounded-2xl border px-3 py-3 text-sm font-semibold transition-colors",
overflowPolicy === "waitlist"
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
Sett venteliste
</button>
<button
type="button"
onClick={() => setOverflowPolicy("closed")}
aria-pressed={overflowPolicy === "closed"}
className={cn(
"flex min-h-11 items-center justify-center rounded-2xl border px-3 py-3 text-sm font-semibold transition-colors",
overflowPolicy === "closed"
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
Steng påmelding
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="tournament-capacity">Maks antall påmeldte (valgfritt)</Label>
<Input
id="tournament-capacity"
type="number"
min={1}
inputMode="numeric"
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
placeholder="Ingen grense"
className="h-11"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="tournament-deadline">Påmeldingsfrist (valgfritt)</Label>
<Input
id="tournament-deadline"
type="datetime-local"
value={deadline}
onChange={(e) => setDeadline(e.target.value)}
className="h-11"
/>
</div>
</div>
</section>
{saveError && (
<p role="alert" className="text-sm font-medium text-destructive">
{saveError}
</p>
)}
<div className="flex items-center gap-3">
<Button
type="button"
onClick={handleSave}
disabled={saving}
className="h-11 rounded-xl px-6 font-semibold"
>
{saving ? "Lagrer…" : "Lagre endringer"}
</Button>
{saved && !saving && (
<span className="flex items-center gap-1 text-sm font-semibold text-primary">
<Check aria-hidden="true" className="size-4" />
Lagret
</span>
)}
</div>
{/* --- Sponsorer ----------------------------------------------------- */}
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<h3 className="text-sm font-bold text-foreground">Sponsorer</h3>
<input
ref={sponsorLogoInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={handleLogoSelected}
/>
{sponsors.length > 0 && (
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
{sponsors.map((sp) => (
<li key={sp.id} className="flex flex-col gap-2 px-3 py-3 sm:flex-row sm:items-center">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-secondary">
{sp.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={sp.logo_url} alt="" className="size-full object-contain" />
) : (
<Camera aria-hidden="true" className="size-4 text-muted-foreground" />
)}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold text-foreground">{sp.name}</span>
{sp.url && (
<span className="truncate text-xs text-muted-foreground">{sp.url}</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center justify-end gap-1">
<button
type="button"
onClick={() => triggerLogoUpload(sp.id)}
disabled={uploadingLogoFor === sp.id}
className="flex h-11 shrink-0 items-center gap-1 rounded-xl px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<Camera aria-hidden="true" className="size-3.5" />
{sp.logo_url ? "Bytt logo" : "Last opp logo"}
</button>
<button
type="button"
onClick={() => handleDeleteSponsor(sp.id)}
aria-label={`Fjern ${sp.name}`}
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</button>
</div>
</li>
))}
</ul>
)}
{sponsorError && (
<p role="alert" className="text-sm font-medium text-destructive">
{sponsorError}
</p>
)}
<form onSubmit={handleAddSponsor} className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="sponsor-name">Sponsornavn</Label>
<Input
id="sponsor-name"
value={sponsorName}
onChange={(e) => setSponsorName(e.target.value)}
placeholder="F.eks. Tjøme Rørlegger AS"
className="h-11"
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="sponsor-url">Lenke (valgfritt)</Label>
<Input
id="sponsor-url"
value={sponsorUrl}
onChange={(e) => setSponsorUrl(e.target.value)}
placeholder="https://…"
className="h-11"
/>
</div>
<Button
type="submit"
variant="outline"
disabled={addingSponsor || sponsorName.trim() === ""}
className="h-11 shrink-0 rounded-xl font-semibold"
>
<Plus aria-hidden="true" className="size-4" />
Legg til
</Button>
</form>
</section>
</div>
) : null}
</div>
</div>
)
}
// Full side (egen rute, `/tournaments/[id]/presentation`) -- header+fanerad
// rundt panelet over, samme mønster som tournament-detail.tsx/
// tournament-program.tsx/tournament-leaderboard.tsx sine respektive header-
// blokker (duplisert der også, ikke en delt komponent -- se CHANGELOG.md
// 2026-08-02 for et tidligere funnet eksempel på nettopp denne dupliseringen
// som årsak til en bug reparert flere steder).
export function TournamentPresentation({
organizationId,
tournamentId,
tournamentName,
}: {
organizationId: string
tournamentId: string
tournamentName: string
}) {
const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
const leaderboardHref = `/tournaments/${tournamentId}/leaderboard?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-4xl items-center gap-3 px-5 py-4">
<Link
href={detailHref}
aria-label="Tilbake til turnering"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Turnering
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
{tournamentName}
</h1>
</div>
</div>
<nav
className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto px-5 pb-3"
aria-label="Turneringsseksjoner"
>
<Link
href={detailHref}
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Lag og spillere
</Link>
<Link
href={programHref}
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Program
</Link>
<Link
href={leaderboardHref}
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Leaderboard
</Link>
<span
aria-current="page"
className="shrink-0 rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
>
Presentasjon
</span>
</nav>
</header>
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-6 sm:py-8">
<h2 className="mb-1 text-base font-bold text-foreground">Presentasjon</h2>
<TournamentPresentationPanel organizationId={organizationId} tournamentId={tournamentId} />
</main>
</div>
)
}