2026-08-02 16:34:54 +02:00
|
|
|
"use client"
|
|
|
|
|
|
|
|
|
|
// Delt header + fane-rad for Score/Scorekort/Leaderboard (ADR-040
|
|
|
|
|
// Beslutning C/D, "det store grepet"). Presentasjon fra V0 (zip 26),
|
|
|
|
|
// tatt inn UENDRET (rent visuelt/strukturelt, ingen ekte logikk å
|
|
|
|
|
// bytte ut -- selve rundedataen og handlingene wires inn av
|
|
|
|
|
// `round-page-shell.tsx`, som eier ekte henting/POST/DELETE).
|
|
|
|
|
|
|
|
|
|
import { useEffect, useRef, useState } from "react"
|
|
|
|
|
import { createPortal } from "react-dom"
|
|
|
|
|
import Link from "next/link"
|
|
|
|
|
import {
|
|
|
|
|
ArrowLeft,
|
|
|
|
|
CalendarDays,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
Clock,
|
|
|
|
|
Flag,
|
|
|
|
|
MapPin,
|
|
|
|
|
Settings2,
|
|
|
|
|
Trash2,
|
|
|
|
|
Users,
|
|
|
|
|
X,
|
|
|
|
|
} from "lucide-react"
|
|
|
|
|
import { Button } from "@/components/ui/button"
|
|
|
|
|
import { cn } from "@/lib/utils"
|
|
|
|
|
|
|
|
|
|
// --- Types -----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export type RoundTab = "score" | "scorecard" | "leaderboard"
|
|
|
|
|
|
|
|
|
|
export type RoundHeaderInfo = {
|
|
|
|
|
/** Course name — always known. Shown as heading unless a customName exists. */
|
|
|
|
|
courseName: string
|
|
|
|
|
/** Optional custom round name; when set it becomes the heading and the
|
|
|
|
|
* course name drops to a secondary line. */
|
|
|
|
|
customName?: string
|
|
|
|
|
teeName: string
|
|
|
|
|
date: string
|
|
|
|
|
/** Optional tee time, e.g. "09:20". */
|
|
|
|
|
teeTime?: string
|
|
|
|
|
/** Head-to-head match-play round → shows the outlined "Matchspill" badge. */
|
|
|
|
|
isMatchPlay?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type RoundHeaderProps = {
|
|
|
|
|
activeTab: RoundTab
|
|
|
|
|
round: RoundHeaderInfo
|
|
|
|
|
/** Where the back button navigates (out of the round entirely). */
|
|
|
|
|
backHref?: string
|
|
|
|
|
/** Per-tab destinations. When omitted, tabs render as inert buttons
|
|
|
|
|
* (useful for isolated previews). */
|
|
|
|
|
tabHrefs?: Record<RoundTab, string>
|
|
|
|
|
/** Destination for the round-management screen ("Spillere og runde"). */
|
|
|
|
|
manageHref?: string
|
2026-08-04 19:48:17 +02:00
|
|
|
/** Fullfør runde. Returnerer `null` hvis brukeren avbrøt bekreftelsen
|
|
|
|
|
* (dialogen skal da bare forbli åpen, ingen feilmelding), ellers
|
|
|
|
|
* `{ ok, message }` -- `ok: false` viser `message` i dialogen i stedet
|
|
|
|
|
* for å lukke den (2026-08-04 bugfiks: denne handlingen fulgte
|
|
|
|
|
* tidligere aldri opp om `fetch`-kallet faktisk lyktes -- dialogen
|
|
|
|
|
* lukket seg uansett utfall, uten feilmelding, se CHANGELOG.md). */
|
|
|
|
|
onFinishRound?: () => Promise<{ ok: boolean; message?: string } | null>
|
|
|
|
|
/** Når satt: "Fullfør runde"-knappen deaktiveres og denne teksten vises
|
|
|
|
|
* i stedet (samme oppsett-ufullstendig-sjekk som round-detail.tsx sin
|
|
|
|
|
* egen, uavhengige "Spillere og runde"-fane allerede håndhever). */
|
|
|
|
|
finishDisabledReason?: string | null
|
2026-08-02 16:34:54 +02:00
|
|
|
onDeleteRound?: () => void
|
|
|
|
|
className?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const TABS: { key: RoundTab; label: string }[] = [
|
|
|
|
|
{ key: "score", label: "Score" },
|
|
|
|
|
{ key: "scorecard", label: "Scorekort" },
|
|
|
|
|
{ key: "leaderboard", label: "Leaderboard" },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
// --- Shared header ---------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export function RoundHeader({
|
|
|
|
|
activeTab,
|
|
|
|
|
round,
|
|
|
|
|
backHref = "/rounds",
|
|
|
|
|
tabHrefs,
|
|
|
|
|
manageHref = "#",
|
|
|
|
|
onFinishRound,
|
2026-08-04 19:48:17 +02:00
|
|
|
finishDisabledReason,
|
2026-08-02 16:34:54 +02:00
|
|
|
onDeleteRound,
|
|
|
|
|
className,
|
|
|
|
|
}: RoundHeaderProps) {
|
|
|
|
|
const [manageOpen, setManageOpen] = useState(false)
|
|
|
|
|
|
|
|
|
|
const heading = round.customName ?? round.courseName
|
|
|
|
|
// Only show the course name as a secondary line when a custom name replaced it.
|
|
|
|
|
const showCourseLine = Boolean(round.customName)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<header
|
|
|
|
|
className={cn(
|
|
|
|
|
"sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-3 sm:px-5 sm:py-4">
|
|
|
|
|
{/* Row 1: back + identity + manage */}
|
|
|
|
|
<div className="flex items-start gap-3">
|
|
|
|
|
<Link
|
|
|
|
|
href={backHref}
|
|
|
|
|
aria-label="Tilbake til egne runder"
|
|
|
|
|
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
|
|
|
>
|
|
|
|
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
|
|
|
|
</Link>
|
|
|
|
|
|
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
|
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
|
|
|
<h1 className="flex min-w-0 items-center gap-2 text-lg font-extrabold leading-tight tracking-tight text-foreground text-balance sm:text-xl">
|
|
|
|
|
<MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" />
|
|
|
|
|
<span className="min-w-0 break-words">{heading}</span>
|
|
|
|
|
</h1>
|
|
|
|
|
{round.isMatchPlay && (
|
|
|
|
|
<span className="inline-flex shrink-0 items-center rounded-full border border-primary/50 px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide text-primary">
|
|
|
|
|
Matchspill
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Secondary line(s): course name (if custom), then tee / date / time */}
|
|
|
|
|
<div className="flex flex-col gap-0.5">
|
|
|
|
|
{showCourseLine && (
|
|
|
|
|
<span className="flex items-center gap-1.5 text-sm font-semibold text-muted-foreground">
|
|
|
|
|
<MapPin aria-hidden="true" className="size-4 shrink-0" />
|
|
|
|
|
<span className="break-words">{round.courseName}</span>
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm font-medium text-muted-foreground">
|
|
|
|
|
<span className="flex items-center gap-1.5">
|
|
|
|
|
<Flag aria-hidden="true" className="size-4 shrink-0" />
|
|
|
|
|
{round.teeName}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="flex items-center gap-1.5 tabular-nums">
|
|
|
|
|
<CalendarDays aria-hidden="true" className="size-4 shrink-0" />
|
|
|
|
|
{round.date}
|
|
|
|
|
</span>
|
|
|
|
|
{round.teeTime && (
|
|
|
|
|
<span className="flex items-center gap-1.5 tabular-nums">
|
|
|
|
|
<Clock aria-hidden="true" className="size-4 shrink-0" />
|
|
|
|
|
{round.teeTime}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Quiet, labeled management affordance — secondary to the tabs. */}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setManageOpen(true)}
|
|
|
|
|
className="flex min-h-11 shrink-0 items-center gap-1.5 rounded-xl border border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
|
|
|
>
|
|
|
|
|
<Settings2 aria-hidden="true" className="size-4" />
|
|
|
|
|
<span className="hidden sm:inline">Administrer</span>
|
|
|
|
|
<span className="sr-only sm:hidden">Administrer runde</span>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Row 2: three-tab segmented control */}
|
|
|
|
|
<nav aria-label="Rundevisning">
|
|
|
|
|
<div className="flex items-center gap-1 rounded-full border border-border bg-muted/60 p-1">
|
|
|
|
|
{TABS.map((tab) => {
|
|
|
|
|
const active = tab.key === activeTab
|
|
|
|
|
const shared =
|
|
|
|
|
"flex min-h-11 flex-1 items-center justify-center rounded-full px-3 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:text-base"
|
|
|
|
|
const activeCls = "bg-primary text-primary-foreground shadow-sm"
|
|
|
|
|
const inactiveCls =
|
|
|
|
|
"text-muted-foreground hover:text-foreground hover:bg-background/60"
|
|
|
|
|
if (tabHrefs) {
|
|
|
|
|
return (
|
|
|
|
|
<Link
|
|
|
|
|
key={tab.key}
|
|
|
|
|
href={tabHrefs[tab.key]}
|
|
|
|
|
aria-current={active ? "page" : undefined}
|
|
|
|
|
className={cn(shared, active ? activeCls : inactiveCls)}
|
|
|
|
|
>
|
|
|
|
|
{tab.label}
|
|
|
|
|
</Link>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={tab.key}
|
|
|
|
|
type="button"
|
|
|
|
|
aria-current={active ? "page" : undefined}
|
|
|
|
|
className={cn(shared, active ? activeCls : inactiveCls)}
|
|
|
|
|
>
|
|
|
|
|
{tab.label}
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</nav>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{manageOpen && (
|
|
|
|
|
<ManageRoundDialog
|
|
|
|
|
manageHref={manageHref}
|
|
|
|
|
onClose={() => setManageOpen(false)}
|
|
|
|
|
onFinishRound={onFinishRound}
|
2026-08-04 19:48:17 +02:00
|
|
|
finishDisabledReason={finishDisabledReason}
|
2026-08-02 16:34:54 +02:00
|
|
|
onDeleteRound={onDeleteRound}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</header>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Management dialog -----------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function ManageRoundDialog({
|
|
|
|
|
manageHref,
|
|
|
|
|
onClose,
|
|
|
|
|
onFinishRound,
|
2026-08-04 19:48:17 +02:00
|
|
|
finishDisabledReason,
|
2026-08-02 16:34:54 +02:00
|
|
|
onDeleteRound,
|
|
|
|
|
}: {
|
|
|
|
|
manageHref: string
|
|
|
|
|
onClose: () => void
|
2026-08-04 19:48:17 +02:00
|
|
|
onFinishRound?: () => Promise<{ ok: boolean; message?: string } | null>
|
|
|
|
|
finishDisabledReason?: string | null
|
2026-08-02 16:34:54 +02:00
|
|
|
onDeleteRound?: () => void
|
|
|
|
|
}) {
|
|
|
|
|
const closeRef = useRef<HTMLButtonElement>(null)
|
|
|
|
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
|
|
|
const [mounted, setMounted] = useState(false)
|
2026-08-04 19:48:17 +02:00
|
|
|
const [finishing, setFinishing] = useState(false)
|
|
|
|
|
const [finishError, setFinishError] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
async function handleFinish() {
|
|
|
|
|
if (!onFinishRound || finishing) return
|
|
|
|
|
setFinishing(true)
|
|
|
|
|
setFinishError(null)
|
|
|
|
|
const result = await onFinishRound()
|
|
|
|
|
setFinishing(false)
|
|
|
|
|
if (result === null) return // brukeren avbrøt bekreftelsen -- la dialogen stå
|
|
|
|
|
if (result.ok) {
|
|
|
|
|
onClose()
|
|
|
|
|
} else {
|
|
|
|
|
setFinishError(result.message ?? "Klarte ikke å fullføre runden. Prøv igjen.")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-02 16:34:54 +02:00
|
|
|
|
|
|
|
|
// Escape to close + focus the close button on open + lock scroll.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setMounted(true)
|
|
|
|
|
closeRef.current?.focus()
|
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
if (e.key === "Escape") onClose()
|
|
|
|
|
}
|
|
|
|
|
document.addEventListener("keydown", onKey)
|
|
|
|
|
const prevOverflow = document.body.style.overflow
|
|
|
|
|
document.body.style.overflow = "hidden"
|
|
|
|
|
return () => {
|
|
|
|
|
document.removeEventListener("keydown", onKey)
|
|
|
|
|
document.body.style.overflow = prevOverflow
|
|
|
|
|
}
|
|
|
|
|
}, [onClose])
|
|
|
|
|
|
|
|
|
|
if (!mounted) return null
|
|
|
|
|
|
|
|
|
|
// Portal to <body> so the fixed overlay isn't trapped by the header's
|
|
|
|
|
// backdrop-filter containing block.
|
|
|
|
|
return createPortal(
|
|
|
|
|
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
|
|
|
|
|
<div
|
|
|
|
|
className="absolute inset-0 bg-foreground/40 backdrop-blur-sm"
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
aria-labelledby="manage-round-title"
|
|
|
|
|
className="relative z-10 flex w-full max-w-md flex-col gap-5 rounded-t-3xl border border-border bg-card p-5 shadow-xl sm:rounded-3xl sm:p-6"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-start justify-between gap-3">
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
<h2
|
|
|
|
|
id="manage-round-title"
|
|
|
|
|
className="text-xl font-extrabold tracking-tight text-foreground"
|
|
|
|
|
>
|
|
|
|
|
Administrer runde
|
|
|
|
|
</h2>
|
|
|
|
|
<p className="text-sm font-medium text-muted-foreground text-pretty">
|
|
|
|
|
Spillere, lag og administrasjon for denne runden.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
ref={closeRef}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
aria-label="Lukk"
|
|
|
|
|
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
|
|
|
>
|
|
|
|
|
<X aria-hidden="true" className="size-5" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-08-21 08:05:05 +02:00
|
|
|
{/* Reach the roster / team setup screen. `onClick={onClose}` (2026-08-20
|
|
|
|
|
brukerfunn): denne lenken navigerer ofte til KUN en søkeparameter-
|
|
|
|
|
endring på samme rute (?tab=manage, se round-detail.tsx), som ikke
|
|
|
|
|
remounter RoundHeader -- uten dette ble dialogen stående åpen over
|
|
|
|
|
innholdet under, og måtte lukkes manuelt. */}
|
2026-08-02 16:34:54 +02:00
|
|
|
<Link
|
|
|
|
|
href={manageHref}
|
2026-08-21 08:05:05 +02:00
|
|
|
onClick={onClose}
|
2026-08-02 16:34:54 +02:00
|
|
|
className="flex min-h-14 items-center gap-3 rounded-2xl border border-border bg-background px-4 py-3 text-left transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
|
|
|
>
|
|
|
|
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
|
|
|
|
<Users aria-hidden="true" className="size-5" />
|
|
|
|
|
</span>
|
|
|
|
|
<span className="flex min-w-0 flex-col">
|
|
|
|
|
<span className="text-base font-bold text-foreground">Spillere og runde</span>
|
|
|
|
|
<span className="text-sm font-medium text-muted-foreground">
|
|
|
|
|
Legg til, fjern eller rediger spillere og lag
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
</Link>
|
|
|
|
|
|
|
|
|
|
{/* Administrative actions */}
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
<Button
|
2026-08-04 19:48:17 +02:00
|
|
|
onClick={handleFinish}
|
|
|
|
|
disabled={finishing || Boolean(finishDisabledReason)}
|
|
|
|
|
title={finishDisabledReason ?? undefined}
|
2026-08-02 16:34:54 +02:00
|
|
|
className="min-h-12 w-full justify-center gap-2 rounded-2xl text-base font-bold"
|
|
|
|
|
>
|
|
|
|
|
<CheckCircle2 aria-hidden="true" className="size-5" />
|
2026-08-04 19:48:17 +02:00
|
|
|
{finishing ? "Fullfører…" : "Fullfør runde"}
|
2026-08-02 16:34:54 +02:00
|
|
|
</Button>
|
2026-08-04 19:48:17 +02:00
|
|
|
{finishDisabledReason && (
|
|
|
|
|
<p className="text-sm font-medium text-muted-foreground text-pretty">
|
|
|
|
|
{finishDisabledReason}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
{finishError && (
|
|
|
|
|
<p role="alert" className="text-sm font-semibold text-destructive text-pretty">
|
|
|
|
|
{finishError}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
2026-08-02 16:34:54 +02:00
|
|
|
|
|
|
|
|
{confirmDelete ? (
|
|
|
|
|
<div className="flex flex-col gap-2 rounded-2xl border border-destructive/40 bg-destructive/5 p-3">
|
|
|
|
|
<p className="text-sm font-semibold text-foreground text-pretty">
|
|
|
|
|
Slette runden permanent? Dette kan ikke angres.
|
|
|
|
|
</p>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => setConfirmDelete(false)}
|
|
|
|
|
className="min-h-11 flex-1 justify-center rounded-xl font-bold"
|
|
|
|
|
>
|
|
|
|
|
Avbryt
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="destructive"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
onDeleteRound?.()
|
|
|
|
|
onClose()
|
|
|
|
|
}}
|
|
|
|
|
className="min-h-11 flex-1 justify-center gap-1.5 rounded-xl font-bold"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 aria-hidden="true" className="size-4" />
|
|
|
|
|
Slett runde
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<Button
|
|
|
|
|
variant="destructive"
|
|
|
|
|
onClick={() => setConfirmDelete(true)}
|
|
|
|
|
className="min-h-12 w-full justify-center gap-2 rounded-2xl text-base font-bold"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 aria-hidden="true" className="size-5" />
|
|
|
|
|
Slett runde
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>,
|
|
|
|
|
document.body,
|
|
|
|
|
)
|
|
|
|
|
}
|