"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, Copy, 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(null) const [sponsors, setSponsors] = useState([]) const [loading, setLoading] = useState(true) const [loadError, setLoadError] = useState(null) // Skjema-lokal tilstand, seedet fra `tournament` ved (ny) lasting. const [description, setDescription] = useState("") const [visibility, setVisibility] = useState("org") const [requiresApproval, setRequiresApproval] = useState(false) const [overflowPolicy, setOverflowPolicy] = useState("waitlist") const [capacity, setCapacity] = useState("") const [deadline, setDeadline] = useState("") const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) const [saveError, setSaveError] = useState(null) const [uploadingHero, setUploadingHero] = useState(false) const heroInputRef = useRef(null) const [sponsorName, setSponsorName] = useState("") const [sponsorUrl, setSponsorUrl] = useState("") const [addingSponsor, setAddingSponsor] = useState(false) // Kopier-lenke (2026-08-21) -- fant reell forvirring: organisator delte // sin egen administrator-URL (/tournaments/{id}?org=...) i stedet for // denne, siden lenken under kun var ren, ikke-kopierbar tekst. Samme // kopier-mønster som JoinCodeChip i individual-tournament-detail.tsx. const [linkCopied, setLinkCopied] = useState(false) async function copyPublicLink() { try { await navigator.clipboard.writeText(`${window.location.origin}/t/${tournamentId}`) setLinkCopied(true) setTimeout(() => setLinkCopied(false), 1500) } catch { // stille -- ikke kritisk } } const [sponsorError, setSponsorError] = useState(null) const [logoTargetId, setLogoTargetId] = useState(null) const [uploadingLogoFor, setUploadingLogoFor] = useState(null) const sponsorLogoInputRef = useRef(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) { 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) { 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 (

Hvordan turneringen vises på sin offentlige side ( /t/{tournamentId}

{loadError && (

{loadError}

)} {loading ? (
) : tournament ? (
{/* --- Hero-bilde ------------------------------------------------ */}

Hero-bilde

{tournament.hero_image_url ? ( // eslint-disable-next-line @next/next/no-img-element ) : (
{tournament.hero_image_url && ( )}
{/* --- Beskrivelse & synlighet ------------------------------------ */}

Beskrivelse og synlighet