Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
580 lines
19 KiB
TypeScript
580 lines
19 KiB
TypeScript
"use client"
|
|
|
|
import type React from "react"
|
|
import { useEffect, useState } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import Link from "next/link"
|
|
import { Building2, Calendar, ChevronRight, ChevronsUpDown, LogOut, Plus, Trophy, Users, UserCircle, X, Check } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu"
|
|
import { Wordmark } from "@/components/wordmark"
|
|
import { TournamentCard, type Tournament } from "@/components/tournament-card"
|
|
import { TournamentStatusBadge, type TournamentStatus } from "@/components/tournament-status-badge"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
type MyOrg = { organization_id: string; name: string; role: string }
|
|
|
|
// ADR-031: "Mine runder" -- turneringer brukeren er SPILLER i (rostret på et
|
|
// lag), på tvers av organisasjoner, uavhengig av organisasjonsmedlemskap.
|
|
type MyTournament = {
|
|
organization_id: string
|
|
organization_name: string
|
|
tournament_id: string
|
|
tournament_name: string
|
|
status: TournamentStatus
|
|
team_id: string
|
|
team_name: string
|
|
team_color: string | null
|
|
next_session_at: string | null
|
|
}
|
|
|
|
type Me = {
|
|
id: string
|
|
email: string
|
|
display_name: string
|
|
preferred_locale: string
|
|
organizations: MyOrg[]
|
|
my_tournaments: MyTournament[]
|
|
}
|
|
|
|
type ApiTournament = {
|
|
id: string
|
|
name: string
|
|
status: TournamentStatus
|
|
start_date: string | null
|
|
end_date: string | null
|
|
}
|
|
|
|
function toTournament(t: ApiTournament): Tournament {
|
|
return {
|
|
id: t.id,
|
|
name: t.name,
|
|
status: t.status,
|
|
startDate: t.start_date ?? undefined,
|
|
endDate: t.end_date ?? undefined,
|
|
}
|
|
}
|
|
|
|
// --- Component -------------------------------------------------------------
|
|
|
|
export function Dashboard() {
|
|
const router = useRouter()
|
|
const [me, setMe] = useState<Me | null>(null)
|
|
const [loadingMe, setLoadingMe] = useState(true)
|
|
const [activeOrgId, setActiveOrgId] = useState<string | null>(null)
|
|
const [tournaments, setTournaments] = useState<Tournament[]>([])
|
|
const [loadingTournaments, setLoadingTournaments] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Hent innlogget bruker + organisasjonsmedlemskap. Ingen gyldig sesjon ->
|
|
// tilbake til innloggingssiden (denne siden krever auth).
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
async function loadMe() {
|
|
try {
|
|
const res = await fetch("/auth/me", { credentials: "include" })
|
|
if (!res.ok) {
|
|
router.replace("/")
|
|
return
|
|
}
|
|
const data: Me = await res.json()
|
|
if (cancelled) return
|
|
setMe(data)
|
|
setActiveOrgId(data.organizations[0]?.organization_id ?? null)
|
|
} catch {
|
|
if (!cancelled) router.replace("/")
|
|
} finally {
|
|
if (!cancelled) setLoadingMe(false)
|
|
}
|
|
}
|
|
void loadMe()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [router])
|
|
|
|
// Turneringer hentes per valgt organisasjon, ikke i /auth/me-kallet.
|
|
useEffect(() => {
|
|
if (!activeOrgId) {
|
|
setTournaments([])
|
|
return
|
|
}
|
|
let cancelled = false
|
|
async function loadTournaments() {
|
|
setLoadingTournaments(true)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(`/orgs/${activeOrgId}/tournaments`, { credentials: "include" })
|
|
if (!res.ok) throw new Error(`tournaments: ${res.status}`)
|
|
const data: ApiTournament[] = await res.json()
|
|
if (!cancelled) setTournaments(data.map(toTournament))
|
|
} catch {
|
|
if (!cancelled) setError("Klarte ikke å hente turneringer. Prøv igjen om litt.")
|
|
} finally {
|
|
if (!cancelled) setLoadingTournaments(false)
|
|
}
|
|
}
|
|
void loadTournaments()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [activeOrgId])
|
|
|
|
async function handleCreateOrg(name: string) {
|
|
setError(null)
|
|
try {
|
|
const res = await fetch("/orgs", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ name }),
|
|
})
|
|
if (!res.ok) throw new Error(`orgs: ${res.status}`)
|
|
const org: { id: string; name: string; role: string } = await res.json()
|
|
setMe((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
organizations: [
|
|
...prev.organizations,
|
|
{ organization_id: org.id, name: org.name, role: org.role },
|
|
],
|
|
}
|
|
: prev,
|
|
)
|
|
setActiveOrgId(org.id)
|
|
} catch {
|
|
setError("Klarte ikke å opprette organisasjonen. Prøv igjen.")
|
|
}
|
|
}
|
|
|
|
async function handleCreateTournament(name: string) {
|
|
if (!activeOrgId) return
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(`/orgs/${activeOrgId}/tournaments`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ name }),
|
|
})
|
|
if (!res.ok) throw new Error(`create tournament: ${res.status}`)
|
|
const created: ApiTournament = await res.json()
|
|
setTournaments((prev) => [toTournament(created), ...prev])
|
|
} catch {
|
|
setError("Klarte ikke å opprette turneringen. Prøv igjen.")
|
|
}
|
|
}
|
|
|
|
async function handleLogout() {
|
|
try {
|
|
await fetch("/auth/logout", { method: "POST", credentials: "include" })
|
|
} finally {
|
|
router.replace("/")
|
|
}
|
|
}
|
|
|
|
if (loadingMe) {
|
|
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 (!me) return null // router.replace("/") er allerede utløst
|
|
|
|
const activeOrg = me.organizations.find((o) => o.organization_id === activeOrgId) ?? null
|
|
const hasOrg = me.organizations.length > 0 && activeOrg !== null
|
|
const hasMyTournaments = me.my_tournaments.length > 0
|
|
|
|
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-3xl items-center justify-between gap-4 px-5 py-4">
|
|
<Wordmark compact />
|
|
<div className="flex items-center gap-1">
|
|
<Link
|
|
href="/account"
|
|
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<UserCircle aria-hidden="true" className="size-4" />
|
|
Konto
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
onClick={handleLogout}
|
|
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<LogOut aria-hidden="true" className="size-4" />
|
|
Logg ut
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
|
|
{error && (
|
|
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-8">
|
|
{hasMyTournaments && <MyToursSection tournaments={me.my_tournaments} />}
|
|
|
|
{hasOrg && activeOrg ? (
|
|
<OrganizationView
|
|
org={activeOrg}
|
|
orgs={me.organizations}
|
|
tournaments={tournaments}
|
|
loadingTournaments={loadingTournaments}
|
|
onSelectOrg={setActiveOrgId}
|
|
onCreateTournament={handleCreateTournament}
|
|
/>
|
|
) : (
|
|
<CreateOrganizationState onCreate={handleCreateOrg} compact={hasMyTournaments} />
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- "Mine runder" (ADR-031) -----------------------------------------------
|
|
// Turneringer brukeren er SPILLER i, uavhengig av organisasjonsmedlemskap.
|
|
// Lenker til den offentlige turnering-siden (fungerer for enhver deltaker,
|
|
// se ADR-031-utvidelsen av check_visibility) -- IKKE til lag-chat/
|
|
// scorekort ennå, som fortsatt krever organisasjonsmedlemskap (kjent,
|
|
// notert begrensning, se FEATURE_BACKLOG.md).
|
|
|
|
function MyToursSection({ tournaments }: { tournaments: MyTournament[] }) {
|
|
return (
|
|
<section className="flex flex-col gap-3" aria-label="Mine runder">
|
|
<h2 className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Mine runder</h2>
|
|
<div className="flex flex-col gap-3">
|
|
{tournaments.map((t) => (
|
|
<Link
|
|
key={`${t.organization_id}-${t.tournament_id}`}
|
|
href={`/t/${t.tournament_id}`}
|
|
className="group flex 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"
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className="size-3 shrink-0 rounded-full"
|
|
style={{ backgroundColor: t.team_color ?? "#64748b" }}
|
|
/>
|
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
<h3 className="truncate text-base font-bold text-foreground">{t.tournament_name}</h3>
|
|
<TournamentStatusBadge status={t.status} />
|
|
</div>
|
|
<span className="truncate text-sm text-muted-foreground">
|
|
{t.organization_name} · {t.team_name}
|
|
</span>
|
|
{t.next_session_at && (
|
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
<Calendar aria-hidden="true" className="size-4 shrink-0" />
|
|
<span className="tabular-nums">{formatNextSession(t.next_session_at)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<ChevronRight
|
|
aria-hidden="true"
|
|
className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
|
|
/>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function formatNextSession(iso: string) {
|
|
const date = new Date(iso)
|
|
if (Number.isNaN(date.getTime())) return iso
|
|
return new Intl.DateTimeFormat("no-NO", {
|
|
weekday: "short",
|
|
day: "numeric",
|
|
month: "short",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}).format(date)
|
|
}
|
|
|
|
// --- State A: no organization ---------------------------------------------
|
|
|
|
function CreateOrganizationState({
|
|
onCreate,
|
|
compact = false,
|
|
}: {
|
|
onCreate: (name: string) => void
|
|
compact?: boolean
|
|
}) {
|
|
const [name, setName] = useState("")
|
|
const valid = name.trim().length >= 2
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (!valid) return
|
|
onCreate(name.trim())
|
|
setName("")
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"mx-auto flex max-w-md flex-col items-center text-center",
|
|
compact ? "gap-4" : "gap-6 pt-6 sm:pt-12",
|
|
)}
|
|
>
|
|
{!compact && (
|
|
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
|
|
<Building2 aria-hidden="true" className="size-8 text-primary" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<h1 className={cn("font-extrabold tracking-tight text-foreground text-balance", compact ? "text-lg" : "text-2xl")}>
|
|
{compact ? "Opprett din egen organisasjon" : "Du har ingen organisasjon ennå"}
|
|
</h1>
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
{compact
|
|
? "Skal du selv arrangere turneringer? En organisasjon er golfklubben eller bedriften som står bak."
|
|
: "En organisasjon er golfklubben eller bedriften som arrangerer turneringen. Opprett en for å komme i gang."}
|
|
</p>
|
|
</div>
|
|
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className="flex w-full flex-col gap-4 rounded-3xl border border-border bg-card p-6 text-left shadow-lg shadow-black/5"
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="org-name" className="text-sm font-semibold">
|
|
Navn på organisasjon
|
|
</Label>
|
|
<Input
|
|
id="org-name"
|
|
autoFocus
|
|
placeholder="F.eks. Oslo Golfklubb"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="h-14 rounded-2xl text-base"
|
|
/>
|
|
</div>
|
|
<Button
|
|
type="submit"
|
|
disabled={!valid}
|
|
className="h-14 rounded-2xl text-base font-bold shadow-sm"
|
|
>
|
|
Opprett organisasjon
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- State B: has organization(s) -----------------------------------------
|
|
|
|
function OrganizationView({
|
|
org,
|
|
orgs,
|
|
tournaments,
|
|
loadingTournaments,
|
|
onSelectOrg,
|
|
onCreateTournament,
|
|
}: {
|
|
org: MyOrg
|
|
orgs: MyOrg[]
|
|
tournaments: Tournament[]
|
|
loadingTournaments: boolean
|
|
onSelectOrg: (id: string) => void
|
|
onCreateTournament: (name: string) => void
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
{orgs.length > 1 ? (
|
|
<OrganizationSwitcher org={org} orgs={orgs} onSelectOrg={onSelectOrg} />
|
|
) : (
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
|
|
<Building2 aria-hidden="true" className="size-5 text-primary" />
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
Organisasjon
|
|
</span>
|
|
<span className="text-lg font-extrabold tracking-tight text-foreground">
|
|
{org.name}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Link
|
|
href={`/orgs/${org.organization_id}/members?name=${encodeURIComponent(org.name)}`}
|
|
className="inline-flex h-11 items-center gap-1.5 rounded-2xl border border-border bg-card px-4 text-sm font-semibold text-foreground transition-colors hover:bg-accent/50"
|
|
>
|
|
<Users aria-hidden="true" className="size-4" />
|
|
Medlemmer
|
|
</Link>
|
|
<NewTournamentControl onCreate={onCreateTournament} />
|
|
</div>
|
|
</div>
|
|
|
|
<section className="flex flex-col gap-3" aria-label="Turneringer">
|
|
{loadingTournaments ? (
|
|
<div className="flex justify-center py-12">
|
|
<div
|
|
aria-hidden="true"
|
|
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
|
/>
|
|
</div>
|
|
) : tournaments.length > 0 ? (
|
|
tournaments.map((t) => (
|
|
<TournamentCard key={t.id} tournament={t} orgId={org.organization_id} />
|
|
))
|
|
) : (
|
|
<EmptyTournamentState orgName={org.name} />
|
|
)}
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function OrganizationSwitcher({
|
|
org,
|
|
orgs,
|
|
onSelectOrg,
|
|
}: {
|
|
org: MyOrg
|
|
orgs: MyOrg[]
|
|
onSelectOrg: (id: string) => void
|
|
}) {
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger className="flex items-center gap-3 rounded-2xl border border-border bg-card px-3 py-2.5 text-left shadow-sm shadow-black/5 transition-colors hover:bg-accent/50">
|
|
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
|
|
<Building2 aria-hidden="true" className="size-5 text-primary" />
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
Organisasjon
|
|
</span>
|
|
<span className="text-lg font-extrabold tracking-tight text-foreground">
|
|
{org.name}
|
|
</span>
|
|
</div>
|
|
<ChevronsUpDown aria-hidden="true" className="ml-1 size-4 text-muted-foreground" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-64 rounded-2xl p-1.5">
|
|
{orgs.map((o) => (
|
|
<DropdownMenuItem
|
|
key={o.organization_id}
|
|
onClick={() => onSelectOrg(o.organization_id)}
|
|
className="flex cursor-pointer items-center justify-between gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
|
|
>
|
|
{o.name}
|
|
{o.organization_id === org.organization_id && (
|
|
<Check aria-hidden="true" className="size-4 text-primary" />
|
|
)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
function NewTournamentControl({ onCreate }: { onCreate: (name: string) => void }) {
|
|
const [open, setOpen] = useState(false)
|
|
const [name, setName] = useState("")
|
|
const valid = name.trim().length >= 2
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (!valid) return
|
|
onCreate(name.trim())
|
|
setName("")
|
|
setOpen(false)
|
|
}
|
|
|
|
if (!open) {
|
|
return (
|
|
<Button
|
|
type="button"
|
|
onClick={() => setOpen(true)}
|
|
className="h-12 shrink-0 rounded-2xl text-base font-bold shadow-sm"
|
|
>
|
|
<Plus aria-hidden="true" className="size-5" />
|
|
Ny turnering
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className="flex w-full items-center gap-2 rounded-2xl border border-border bg-card p-2 shadow-sm shadow-black/5 sm:w-auto"
|
|
>
|
|
<Input
|
|
autoFocus
|
|
placeholder="Navn på turnering"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") setOpen(false)
|
|
}}
|
|
className="h-10 min-w-0 flex-1 rounded-xl text-base sm:w-56"
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
disabled={!valid}
|
|
size="icon"
|
|
className="size-10 shrink-0 rounded-xl"
|
|
aria-label="Opprett turnering"
|
|
>
|
|
<Check aria-hidden="true" className="size-5" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setOpen(false)}
|
|
className="size-10 shrink-0 rounded-xl text-muted-foreground"
|
|
aria-label="Avbryt"
|
|
>
|
|
<X aria-hidden="true" className="size-5" />
|
|
</Button>
|
|
</form>
|
|
)
|
|
}
|
|
|
|
function EmptyTournamentState({ orgName }: { orgName: string }) {
|
|
return (
|
|
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-12 text-center">
|
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
|
<Trophy aria-hidden="true" className="size-7 text-muted-foreground" />
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<h2 className="text-base font-bold text-foreground text-balance">
|
|
Ingen turneringer ennå i {orgName}
|
|
</h2>
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
Trykk på «Ny turnering» for å opprette den første.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|