Navn/datoer/synlighet/beskrivelse/påmelding -- rent innbytte mot props-kontrakten. Fant og fikset samme tidssone-bugklasse som ADR-088 proaktivt denne gangen (mønstergjenkjenning) før den nådde en nettleser.
438 lines
17 KiB
TypeScript
438 lines
17 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useRef, useState } from "react"
|
|
import {
|
|
Building2,
|
|
Check,
|
|
Globe,
|
|
Loader2,
|
|
TriangleAlert,
|
|
Users,
|
|
} from "lucide-react"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Switch } from "@/components/ui/switch"
|
|
|
|
export type TournamentSettingsValues = {
|
|
name: string
|
|
startDate: string | null // "YYYY-MM-DD", or null if not set
|
|
endDate: string | null // "YYYY-MM-DD", or null if not set
|
|
visibility: "public" | "org" | "participants"
|
|
description: string // "" if empty, never null
|
|
registrationDeadline: string | null // ISO datetime ("YYYY-MM-DDTHH:mm"), or null
|
|
registrationCapacity: number | null // null = uncapped
|
|
registrationOverflowPolicy: "waitlist" | "closed" // only meaningful once a capacity is set
|
|
registrationRequiresApproval: boolean
|
|
}
|
|
|
|
const VISIBILITY_OPTIONS: {
|
|
value: TournamentSettingsValues["visibility"]
|
|
label: string
|
|
description: string
|
|
icon: typeof Globe
|
|
}[] = [
|
|
{
|
|
value: "public",
|
|
label: "Offentlig",
|
|
description: "Hvem som helst kan finne turneringen.",
|
|
icon: Globe,
|
|
},
|
|
{
|
|
value: "org",
|
|
label: "Organisasjonen",
|
|
description: "Bare medlemmer av organisasjonen din.",
|
|
icon: Building2,
|
|
},
|
|
{
|
|
value: "participants",
|
|
label: "Kun deltakere",
|
|
description: "Bare de som allerede er påmeldt.",
|
|
icon: Users,
|
|
},
|
|
]
|
|
|
|
const inputBase =
|
|
"h-11 w-full rounded-xl border border-border bg-background px-3 text-base text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50"
|
|
|
|
export function TournamentSettingsCard({
|
|
values,
|
|
onSave,
|
|
}: {
|
|
values: TournamentSettingsValues
|
|
onSave: (next: TournamentSettingsValues) => Promise<void>
|
|
}) {
|
|
const [draft, setDraft] = useState<TournamentSettingsValues>(values)
|
|
const [status, setStatus] = useState<"idle" | "saving" | "saved" | "error">("idle")
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
|
|
|
// Only re-sync the draft from props when the SAVED values genuinely change
|
|
// (e.g. after a real save round-trip), never on an incidental parent
|
|
// re-render that hands us a new object with identical contents.
|
|
const lastSyncedRef = useRef<string>(JSON.stringify(values))
|
|
useEffect(() => {
|
|
const serialized = JSON.stringify(values)
|
|
if (serialized !== lastSyncedRef.current) {
|
|
lastSyncedRef.current = serialized
|
|
setDraft(values)
|
|
}
|
|
}, [values])
|
|
|
|
const savedFadeRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
useEffect(() => {
|
|
return () => {
|
|
if (savedFadeRef.current) clearTimeout(savedFadeRef.current)
|
|
}
|
|
}, [])
|
|
|
|
function patch<K extends keyof TournamentSettingsValues>(
|
|
key: K,
|
|
value: TournamentSettingsValues[K],
|
|
) {
|
|
setDraft((prev) => ({ ...prev, [key]: value }))
|
|
// Any edit clears a lingering "saved"/"error" indicator.
|
|
setStatus((s) => (s === "saving" ? s : "idle"))
|
|
setErrorMessage(null)
|
|
}
|
|
|
|
const nameEmpty = draft.name.trim() === ""
|
|
const dirty = JSON.stringify(draft) !== JSON.stringify(values)
|
|
const capacitySet = draft.registrationCapacity != null
|
|
const saving = status === "saving"
|
|
const canSave = dirty && !nameEmpty && !saving
|
|
|
|
async function handleSave() {
|
|
if (!canSave) return
|
|
setStatus("saving")
|
|
setErrorMessage(null)
|
|
try {
|
|
// Normalize: trim the name; overflow policy is irrelevant without a cap.
|
|
const next: TournamentSettingsValues = {
|
|
...draft,
|
|
name: draft.name.trim(),
|
|
}
|
|
await onSave(next)
|
|
lastSyncedRef.current = JSON.stringify(next)
|
|
setDraft(next)
|
|
setStatus("saved")
|
|
if (savedFadeRef.current) clearTimeout(savedFadeRef.current)
|
|
savedFadeRef.current = setTimeout(() => setStatus("idle"), 2600)
|
|
} catch (err) {
|
|
setStatus("error")
|
|
setErrorMessage(
|
|
err instanceof Error ? err.message : "Kunne ikke lagre. Prøv igjen.",
|
|
)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section
|
|
aria-labelledby="tsc-heading"
|
|
className="rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8 lg:p-7"
|
|
>
|
|
<header className="mb-6 flex flex-col gap-1">
|
|
<h2 id="tsc-heading" className="text-xl font-bold text-foreground lg:text-2xl">
|
|
Grunnleggende
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground text-pretty">
|
|
Navn, datoer, synlighet og påmelding for turneringen.
|
|
</p>
|
|
</header>
|
|
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
{/* Navn — spans full width */}
|
|
<div className="flex flex-col gap-2 lg:col-span-2">
|
|
<Label htmlFor="tsc-name" className="text-base font-semibold text-foreground">
|
|
Navn
|
|
</Label>
|
|
<Input
|
|
id="tsc-name"
|
|
value={draft.name}
|
|
onChange={(e) => patch("name", e.target.value)}
|
|
aria-required="true"
|
|
aria-invalid={nameEmpty}
|
|
aria-describedby={nameEmpty ? "tsc-name-error" : undefined}
|
|
className="h-11 rounded-xl text-base"
|
|
placeholder="F.eks. Klubbmesterskapet 2026"
|
|
/>
|
|
{nameEmpty && (
|
|
<p id="tsc-name-error" className="text-sm font-medium text-destructive">
|
|
Navn er påkrevd.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Datoer — one connected range control */}
|
|
<fieldset className="flex flex-col gap-2 lg:col-span-2">
|
|
<legend className="mb-2 text-base font-semibold text-foreground">Datoer</legend>
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
|
<div className="flex flex-1 flex-col gap-1.5">
|
|
<Label htmlFor="tsc-start" className="text-sm text-muted-foreground">
|
|
Startdato
|
|
</Label>
|
|
<input
|
|
id="tsc-start"
|
|
type="date"
|
|
value={draft.startDate ?? ""}
|
|
max={draft.endDate ?? undefined}
|
|
onChange={(e) => patch("startDate", e.target.value || null)}
|
|
className={inputBase}
|
|
/>
|
|
</div>
|
|
<span
|
|
aria-hidden="true"
|
|
className="hidden shrink-0 pb-3 text-muted-foreground sm:block"
|
|
>
|
|
–
|
|
</span>
|
|
<div className="flex flex-1 flex-col gap-1.5">
|
|
<Label htmlFor="tsc-end" className="text-sm text-muted-foreground">
|
|
Sluttdato
|
|
</Label>
|
|
<input
|
|
id="tsc-end"
|
|
type="date"
|
|
value={draft.endDate ?? ""}
|
|
min={draft.startDate ?? undefined}
|
|
onChange={(e) => patch("endDate", e.target.value || null)}
|
|
className={inputBase}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
{/* Synlighet — real radio group, spans full width */}
|
|
<fieldset className="flex flex-col gap-3 lg:col-span-2">
|
|
<legend className="text-base font-semibold text-foreground">Synlighet</legend>
|
|
<div
|
|
role="radiogroup"
|
|
aria-label="Synlighet"
|
|
className="grid grid-cols-1 gap-3 sm:grid-cols-3"
|
|
>
|
|
{VISIBILITY_OPTIONS.map((o) => {
|
|
const selected = draft.visibility === o.value
|
|
const Icon = o.icon
|
|
return (
|
|
<label
|
|
key={o.value}
|
|
className={cn(
|
|
"flex cursor-pointer flex-col gap-1.5 rounded-xl border p-4 transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background",
|
|
selected
|
|
? "border-primary bg-primary/5 ring-1 ring-primary"
|
|
: "border-border bg-background hover:border-primary/50 hover:bg-accent/40",
|
|
)}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<input
|
|
type="radio"
|
|
name="tsc-visibility"
|
|
value={o.value}
|
|
checked={selected}
|
|
onChange={() => patch("visibility", o.value)}
|
|
className="size-5 shrink-0 accent-primary"
|
|
/>
|
|
<Icon
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"size-4 shrink-0",
|
|
selected ? "text-primary" : "text-muted-foreground",
|
|
)}
|
|
/>
|
|
<span className="text-base font-bold text-foreground">{o.label}</span>
|
|
</span>
|
|
<span className="pl-7 text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
{o.description}
|
|
</span>
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
</fieldset>
|
|
|
|
{/* Beskrivelse — spans full width */}
|
|
<div className="flex flex-col gap-2 lg:col-span-2">
|
|
<Label htmlFor="tsc-desc" className="text-base font-semibold text-foreground">
|
|
Beskrivelse
|
|
</Label>
|
|
<textarea
|
|
id="tsc-desc"
|
|
value={draft.description}
|
|
onChange={(e) => patch("description", e.target.value)}
|
|
rows={4}
|
|
className="w-full rounded-xl border border-border bg-background px-3 py-2.5 text-base leading-relaxed text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
placeholder="Valgfri informasjon til deltakerne."
|
|
/>
|
|
</div>
|
|
|
|
{/* Påmelding — grouped inset panel, spans full width */}
|
|
<fieldset className="rounded-2xl border border-border bg-background/60 p-4 lg:col-span-2 lg:p-5">
|
|
<legend className="flex items-center gap-2 rounded-lg bg-card px-2 text-base font-bold text-foreground">
|
|
Påmelding
|
|
</legend>
|
|
|
|
<div className="grid grid-cols-1 gap-5 lg:grid-cols-2">
|
|
{/* Påmeldingsfrist */}
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="tsc-deadline" className="text-base font-semibold text-foreground">
|
|
Påmeldingsfrist
|
|
</Label>
|
|
<input
|
|
id="tsc-deadline"
|
|
type="datetime-local"
|
|
value={draft.registrationDeadline ?? ""}
|
|
onChange={(e) => patch("registrationDeadline", e.target.value || null)}
|
|
className={inputBase}
|
|
/>
|
|
<p className="text-sm text-muted-foreground">Valgfri. Tom = ingen frist.</p>
|
|
</div>
|
|
|
|
{/* Maks antall påmeldte */}
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="tsc-capacity" className="text-base font-semibold text-foreground">
|
|
Maks antall påmeldte
|
|
</Label>
|
|
<input
|
|
id="tsc-capacity"
|
|
type="number"
|
|
inputMode="numeric"
|
|
min={1}
|
|
step={1}
|
|
value={draft.registrationCapacity ?? ""}
|
|
onChange={(e) => {
|
|
const v = e.target.value
|
|
if (v === "") return patch("registrationCapacity", null)
|
|
const n = Math.max(1, Math.floor(Number(v)))
|
|
patch("registrationCapacity", Number.isFinite(n) ? n : null)
|
|
}}
|
|
className={inputBase}
|
|
placeholder="Ubegrenset"
|
|
/>
|
|
<p className="text-sm text-muted-foreground">Tom = ubegrenset.</p>
|
|
</div>
|
|
|
|
{/* Når fullt — disabled + explained when no capacity */}
|
|
<fieldset
|
|
className="flex flex-col gap-3"
|
|
disabled={!capacitySet}
|
|
aria-describedby={!capacitySet ? "tsc-overflow-hint" : undefined}
|
|
>
|
|
<legend
|
|
className={cn(
|
|
"text-base font-semibold",
|
|
capacitySet ? "text-foreground" : "text-muted-foreground",
|
|
)}
|
|
>
|
|
Når fullt
|
|
</legend>
|
|
<div
|
|
role="radiogroup"
|
|
aria-label="Når fullt"
|
|
className={cn("grid grid-cols-2 gap-2", !capacitySet && "opacity-50")}
|
|
>
|
|
{(
|
|
[
|
|
{ value: "waitlist", label: "Venteliste" },
|
|
{ value: "closed", label: "Stengt" },
|
|
] as const
|
|
).map((o) => {
|
|
const selected = draft.registrationOverflowPolicy === o.value
|
|
return (
|
|
<label
|
|
key={o.value}
|
|
className={cn(
|
|
"flex min-h-11 cursor-pointer items-center justify-center gap-2 rounded-xl border px-3 text-base font-bold transition-colors focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background",
|
|
!capacitySet && "cursor-not-allowed",
|
|
selected
|
|
? "border-primary bg-primary text-primary-foreground"
|
|
: "border-border bg-card text-foreground hover:border-primary/50 hover:bg-accent/40",
|
|
)}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="tsc-overflow"
|
|
value={o.value}
|
|
checked={selected}
|
|
disabled={!capacitySet}
|
|
onChange={() => patch("registrationOverflowPolicy", o.value)}
|
|
className="sr-only"
|
|
/>
|
|
{o.label}
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
{!capacitySet && (
|
|
<p
|
|
id="tsc-overflow-hint"
|
|
className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground"
|
|
>
|
|
<TriangleAlert aria-hidden="true" className="size-4 shrink-0" />
|
|
Sett et maks-antall for å bruke dette.
|
|
</p>
|
|
)}
|
|
</fieldset>
|
|
|
|
{/* Krev godkjenning */}
|
|
<div className="flex flex-col gap-2">
|
|
<span className="text-base font-semibold text-foreground">Krev godkjenning</span>
|
|
<label
|
|
htmlFor="tsc-approval"
|
|
className="flex min-h-11 cursor-pointer items-center justify-between gap-3 rounded-xl border border-border bg-card px-4 py-2"
|
|
>
|
|
<span className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
{draft.registrationRequiresApproval
|
|
? "Organisator må godkjenne hver påmelding manuelt."
|
|
: "Påmeldinger godkjennes automatisk."}
|
|
</span>
|
|
<Switch
|
|
id="tsc-approval"
|
|
checked={draft.registrationRequiresApproval}
|
|
onCheckedChange={(checked) =>
|
|
patch("registrationRequiresApproval", checked === true)
|
|
}
|
|
aria-label="Krev godkjenning av påmeldinger"
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
</div>
|
|
|
|
{/* Save row */}
|
|
<div className="mt-7 flex flex-wrap items-center gap-3 border-t border-border pt-5">
|
|
<Button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={!canSave}
|
|
className="h-11 rounded-xl px-6 text-base font-bold"
|
|
>
|
|
{saving && <Loader2 aria-hidden="true" className="size-4 animate-spin" />}
|
|
{saving ? "Lagrer…" : "Lagre"}
|
|
</Button>
|
|
|
|
{status === "saved" && (
|
|
<span
|
|
role="status"
|
|
className="flex animate-in fade-in items-center gap-1.5 text-sm font-bold text-primary duration-300"
|
|
>
|
|
<Check aria-hidden="true" className="size-4" />
|
|
Lagret
|
|
</span>
|
|
)}
|
|
|
|
{status === "error" && errorMessage && (
|
|
<span role="alert" className="flex items-center gap-1.5 text-sm font-bold text-destructive">
|
|
<TriangleAlert aria-hidden="true" className="size-4" />
|
|
{errorMessage}
|
|
</span>
|
|
)}
|
|
|
|
{!dirty && status === "idle" && (
|
|
<span className="text-sm text-muted-foreground">Ingen ulagrede endringer.</span>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|