teecup/frontend/components/tournament-settings-card.tsx

439 lines
17 KiB
TypeScript
Raw Normal View History

"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"
>
&ndash;
</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>
)
}