Erstatt /my-rounds/new med ny-runde-veiviser fra egen V0-prompt
Gammel components/new-round.tsx (3364 linjer) slettet, erstattet av
components/ny-runde/ + lib/ny-runde/ -- V0-eksporten (full teknisk spec,
null designføring) ported mot de faktiske backend-kontraktene i
app/routers/rounds.py: Gender-oversettelse, forenklet Tee-form (aldri
CR/Slope til klienten), CourseMeta-discriminated-union for teeoff- vs.
egen-bane, ekte POST /rounds -> sides -> participants-sekvens, ekte
kontosøk/gjeste-oppslag, ekte banesøk inkl. geolokasjon.
Fire bugs funnet og rettet under scratch-verifisering: manglende
forwardRef på TextInput (i selve V0-eksporten), manglende
tee-synk til eierens spillerkort etter banevalg (egen portingsfeil),
manglende Ingen/Alle-hurtigknapp i steg 5 (samme mangel rettet i appen
2026-08-06), og en produksjonsbug oppdaget EKTE brukere etter utrulling:
fetchFacilityCourses() leste responsen fra offisielt banesøk som en rå
array i stedet for {slug,name,courses}-objektet backend faktisk
returnerer -- ga en uendelig "Laster baner..."-hang for enhver runde
startet på en offisiell (teeoff-)bane. Rettet + lagt til reell
feilhåndtering i OfficialCourses (manglet helt, feilen ble tidligere
en stille, ufanget promise-rejection).
Se CHANGELOG.md punkt 41 og 43 for full verifiseringsdetalj.
This commit is contained in:
parent
8ef2f61f4d
commit
11d10f7661
17 changed files with 3970 additions and 3369 deletions
|
|
@ -1,19 +1,19 @@
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
import { NewRound } from "@/components/new-round"
|
import { NewRoundWizard } from "@/components/ny-runde/wizard-shell"
|
||||||
|
|
||||||
export default function NewRoundPage() {
|
export default function NewRoundPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex min-h-[100dvh] items-center justify-center bg-background">
|
<div className="flex min-h-dvh items-center justify-center bg-[var(--nr-bg)]">
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
className="h-10 w-10 animate-spin rounded-full border-4 border-[var(--nr-accent-ring)] border-t-[var(--nr-accent)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<NewRound />
|
<NewRoundWizard />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
384
frontend/components/ny-runde/create-course-form.tsx
Normal file
384
frontend/components/ny-runde/create-course-form.tsx
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { ApiGender, Course } from "@/lib/ny-runde/types"
|
||||||
|
import { createOwnCourse } from "@/lib/ny-runde/api"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Plus, Trash2 } from "lucide-react"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { Field, NativeSelect, TextInput, Toggle, useAutoFocus } from "./primitives"
|
||||||
|
|
||||||
|
interface DraftRating {
|
||||||
|
cr: string
|
||||||
|
slope: string
|
||||||
|
par: string
|
||||||
|
}
|
||||||
|
interface DraftTee {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
menEnabled: boolean
|
||||||
|
womenEnabled: boolean
|
||||||
|
men: DraftRating
|
||||||
|
women: DraftRating
|
||||||
|
}
|
||||||
|
interface DraftHole {
|
||||||
|
par: number
|
||||||
|
si: number | 0 // 0 = ikke satt
|
||||||
|
}
|
||||||
|
|
||||||
|
let teeCounter = 0
|
||||||
|
function newTee(): DraftTee {
|
||||||
|
teeCounter += 1
|
||||||
|
return {
|
||||||
|
id: `draft-tee-${teeCounter}`,
|
||||||
|
name: "",
|
||||||
|
menEnabled: true,
|
||||||
|
womenEnabled: false,
|
||||||
|
men: { cr: "", slope: "", par: "" },
|
||||||
|
women: { cr: "", slope: "", par: "" },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seed.tees har KUN genders (ikke CR/Slope-tall) -- et forhåndsutfylt
|
||||||
|
// utslag fra en mal kan derfor bare forhåndsutfylle navn+hvilke(t) kjønn
|
||||||
|
// som er aktive, ikke selve ratingtallene (de er ikke kjent klient-side).
|
||||||
|
function seedToDraft(seed?: Course): { holes: DraftHole[]; tees: DraftTee[]; name: string } {
|
||||||
|
if (!seed) {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
holes: Array.from({ length: 18 }, () => ({ par: 4, si: 0 })),
|
||||||
|
tees: [newTee()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const holes: DraftHole[] = seed.templateHoles?.length
|
||||||
|
? seed.templateHoles.map((h) => ({ par: h.par, si: h.stroke_index }))
|
||||||
|
: Array.from({ length: 18 }, () => ({ par: 4, si: 0 }))
|
||||||
|
const tees: DraftTee[] = seed.templateTees?.length
|
||||||
|
? seed.templateTees.map((t) => {
|
||||||
|
teeCounter += 1
|
||||||
|
const men = t.ratings.find((r) => r.gender === "m")
|
||||||
|
const women = t.ratings.find((r) => r.gender === "f")
|
||||||
|
return {
|
||||||
|
id: `draft-tee-${teeCounter}`,
|
||||||
|
name: t.name,
|
||||||
|
menEnabled: !!men,
|
||||||
|
womenEnabled: !!women,
|
||||||
|
men: men ? { cr: String(men.course_rating), slope: String(men.slope_rating), par: String(men.par) } : { cr: "", slope: "", par: "" },
|
||||||
|
women: women ? { cr: String(women.course_rating), slope: String(women.slope_rating), par: String(women.par) } : { cr: "", slope: "", par: "" },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: [newTee()]
|
||||||
|
return { name: `${seed.name} (kopi)`, holes, tees }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratingComplete(r: DraftRating) {
|
||||||
|
return r.cr.trim() !== "" && r.slope.trim() !== "" && r.par.trim() !== ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateCourseForm({
|
||||||
|
seed,
|
||||||
|
onCancel,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
seed?: Course
|
||||||
|
onCancel: () => void
|
||||||
|
onCreate: (course: Course) => void
|
||||||
|
}) {
|
||||||
|
const initial = useMemo(() => seedToDraft(seed), [seed])
|
||||||
|
const [name, setName] = useState(initial.name)
|
||||||
|
const [holes, setHoles] = useState<DraftHole[]>(initial.holes)
|
||||||
|
const [tees, setTees] = useState<DraftTee[]>(initial.tees)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const nameRef = useAutoFocus<HTMLInputElement>(true)
|
||||||
|
|
||||||
|
const siValues = holes.map((h) => h.si)
|
||||||
|
const siError = useMemo(() => {
|
||||||
|
const filled = siValues.filter((v) => v >= 1 && v <= 18)
|
||||||
|
const set = new Set(filled)
|
||||||
|
const complete = set.size === 18 && filled.length === 18
|
||||||
|
return complete ? null : "Hver stroke-indeks (1–18) må brukes nøyaktig én gang."
|
||||||
|
}, [siValues])
|
||||||
|
|
||||||
|
const teesValid = tees.every((t) => {
|
||||||
|
if (!t.name.trim()) return false
|
||||||
|
if (!t.menEnabled && !t.womenEnabled) return false
|
||||||
|
if (t.menEnabled && !ratingComplete(t.men)) return false
|
||||||
|
if (t.womenEnabled && !ratingComplete(t.women)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = name.trim() !== "" && !siError && tees.length >= 1 && teesValid && !submitting
|
||||||
|
|
||||||
|
function setHole(i: number, patch: Partial<DraftHole>) {
|
||||||
|
setHoles((prev) => prev.map((h, idx) => (idx === i ? { ...h, ...patch } : h)))
|
||||||
|
}
|
||||||
|
function setTee(id: string, patch: Partial<DraftTee>) {
|
||||||
|
setTees((prev) => prev.map((t) => (t.id === id ? { ...t, ...patch } : t)))
|
||||||
|
}
|
||||||
|
function setTeeRating(id: string, gender: "men" | "women", patch: Partial<DraftRating>) {
|
||||||
|
setTees((prev) => prev.map((t) => (t.id === id ? { ...t, [gender]: { ...t[gender], ...patch } } : t)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!canSubmit) return
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const toRatings = (t: DraftTee) => {
|
||||||
|
const ratings: { gender: ApiGender; course_rating: number; slope_rating: number; par: number }[] = []
|
||||||
|
if (t.menEnabled) ratings.push({ gender: "m", course_rating: Number.parseFloat(t.men.cr), slope_rating: Number.parseInt(t.men.slope, 10), par: Number.parseInt(t.men.par, 10) })
|
||||||
|
if (t.womenEnabled) ratings.push({ gender: "f", course_rating: Number.parseFloat(t.women.cr), slope_rating: Number.parseInt(t.women.slope, 10), par: Number.parseInt(t.women.par, 10) })
|
||||||
|
return ratings
|
||||||
|
}
|
||||||
|
// Kun personlige baner (ikke offisielle teeoff-baner) kan "forkes" --
|
||||||
|
// seed.id er et syntetisk teeoff-N-id for offisielle baner, en ekte
|
||||||
|
// UUID for personlige (se courseFromOfficial/fetchOwnCourse i api.ts).
|
||||||
|
const forkedFromId = seed && !seed.id.startsWith("teeoff-") ? seed.id : null
|
||||||
|
const course = await createOwnCourse({
|
||||||
|
name: name.trim(),
|
||||||
|
holes: holes.map((h, i) => ({ hole_number: i + 1, par: h.par, stroke_index: h.si })),
|
||||||
|
tees: tees.map((t) => ({ name: t.name.trim(), ratings: toRatings(t) })),
|
||||||
|
forkedFromId,
|
||||||
|
})
|
||||||
|
onCreate(course)
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.")
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Field label="Navn på banen" htmlFor="cc-name">
|
||||||
|
<TextInput
|
||||||
|
id="cc-name"
|
||||||
|
ref={nameRef}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="F.eks. Skogsbanen"
|
||||||
|
maxLength={120}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-xs text-[var(--nr-danger)]">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Hull */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Hull</h3>
|
||||||
|
<span className="text-xs text-[var(--nr-muted)]">Par og stroke-indeks for 18 hull</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-[var(--nr-border)]">
|
||||||
|
<div className="grid grid-cols-[auto_1fr_1fr] gap-px bg-[var(--nr-border)] text-xs">
|
||||||
|
<div className="bg-[var(--nr-surface-2)] px-3 py-2 font-medium text-[var(--nr-muted)]">Hull</div>
|
||||||
|
<div className="bg-[var(--nr-surface-2)] px-3 py-2 font-medium text-[var(--nr-muted)]">Par</div>
|
||||||
|
<div className="bg-[var(--nr-surface-2)] px-3 py-2 font-medium text-[var(--nr-muted)]">Stroke-indeks</div>
|
||||||
|
{holes.map((h, i) => (
|
||||||
|
<HoleRow key={i} index={i} hole={h} onChange={(patch) => setHole(i, patch)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{siError ? (
|
||||||
|
<p role="alert" className="text-xs text-[var(--nr-danger)]">
|
||||||
|
{siError}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[var(--nr-ok)]">Alle stroke-indekser 1–18 er satt.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Utslag */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Utslag</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTees((p) => [...p, newTee()])}
|
||||||
|
className="inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] px-3 text-[13px] font-medium text-[var(--nr-accent)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Legg til utslag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tees.map((t, i) => (
|
||||||
|
<div key={t.id} className="rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface-2)] p-3">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Field label="Navn på utslag" htmlFor={`${t.id}-name`} className="flex-1">
|
||||||
|
<TextInput
|
||||||
|
id={`${t.id}-name`}
|
||||||
|
value={t.name}
|
||||||
|
onChange={(e) => setTee(t.id, { name: e.target.value })}
|
||||||
|
placeholder={`Utslag ${i + 1}`}
|
||||||
|
invalid={t.name.trim() === ""}
|
||||||
|
className="bg-[var(--nr-surface)]"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => tees.length > 1 && setTees((p) => p.filter((x) => x.id !== t.id))}
|
||||||
|
disabled={tees.length <= 1}
|
||||||
|
aria-label={`Fjern ${t.name.trim() || `utslag ${i + 1}`}`}
|
||||||
|
className="mb-0.5 flex h-11 w-11 items-center justify-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] text-[var(--nr-muted)] transition-colors hover:text-[var(--nr-danger)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-col gap-3">
|
||||||
|
<RatingBlock
|
||||||
|
title="Herre-rating"
|
||||||
|
enabled={t.menEnabled}
|
||||||
|
onToggle={(v) => setTee(t.id, { menEnabled: v })}
|
||||||
|
rating={t.men}
|
||||||
|
onChange={(patch) => setTeeRating(t.id, "men", patch)}
|
||||||
|
idPrefix={`${t.id}-men`}
|
||||||
|
/>
|
||||||
|
<RatingBlock
|
||||||
|
title="Dame-rating"
|
||||||
|
enabled={t.womenEnabled}
|
||||||
|
onToggle={(v) => setTee(t.id, { womenEnabled: v })}
|
||||||
|
rating={t.women}
|
||||||
|
onChange={(patch) => setTeeRating(t.id, "women", patch)}
|
||||||
|
idPrefix={`${t.id}-women`}
|
||||||
|
/>
|
||||||
|
{!t.menEnabled && !t.womenEnabled ? (
|
||||||
|
<p role="alert" className="text-xs text-[var(--nr-danger)]">
|
||||||
|
Minst én rating (herre eller dame) må være aktiv for dette utslaget.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="inline-flex min-h-11 items-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 text-sm font-medium text-[var(--nr-ink)] transition-colors hover:bg-[var(--nr-surface-2)]"
|
||||||
|
>
|
||||||
|
Avbryt
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void submit()}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="inline-flex min-h-11 items-center rounded-lg bg-[var(--nr-accent)] px-5 text-sm font-semibold text-[var(--nr-accent-ink)] transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{submitting ? "Lagrer…" : "Lagre bane"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoleRow({
|
||||||
|
index,
|
||||||
|
hole,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
index: number
|
||||||
|
hole: DraftHole
|
||||||
|
onChange: (patch: Partial<DraftHole>) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center bg-[var(--nr-surface)] px-3 py-1.5 text-sm font-medium tabular-nums text-[var(--nr-muted)]">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="bg-[var(--nr-surface)] px-2 py-1.5">
|
||||||
|
<NativeSelect
|
||||||
|
aria-label={`Par for hull ${index + 1}`}
|
||||||
|
value={hole.par}
|
||||||
|
onChange={(e) => onChange({ par: Number.parseInt(e.target.value, 10) })}
|
||||||
|
className="h-9 bg-[var(--nr-surface-2)]"
|
||||||
|
>
|
||||||
|
{[3, 4, 5, 6].map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[var(--nr-surface)] px-2 py-1.5">
|
||||||
|
<NativeSelect
|
||||||
|
aria-label={`Stroke-indeks for hull ${index + 1}`}
|
||||||
|
value={hole.si || ""}
|
||||||
|
onChange={(e) => onChange({ si: Number.parseInt(e.target.value, 10) })}
|
||||||
|
className="h-9 bg-[var(--nr-surface-2)]"
|
||||||
|
>
|
||||||
|
<option value="">–</option>
|
||||||
|
{Array.from({ length: 18 }, (_, i) => i + 1).map((si) => (
|
||||||
|
<option key={si} value={si}>
|
||||||
|
{si}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RatingBlock({
|
||||||
|
title,
|
||||||
|
enabled,
|
||||||
|
onToggle,
|
||||||
|
rating,
|
||||||
|
onChange,
|
||||||
|
idPrefix,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
enabled: boolean
|
||||||
|
onToggle: (v: boolean) => void
|
||||||
|
rating: DraftRating
|
||||||
|
onChange: (patch: Partial<DraftRating>) => void
|
||||||
|
idPrefix: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-lg border p-3", enabled ? "border-[var(--nr-accent-ring)] bg-[var(--nr-surface)]" : "border-[var(--nr-border)] bg-[var(--nr-surface)]")}>
|
||||||
|
<Toggle checked={enabled} onChange={onToggle} label={title} />
|
||||||
|
{enabled ? (
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
|
<Field label="Course Rating" htmlFor={`${idPrefix}-cr`}>
|
||||||
|
<TextInput
|
||||||
|
id={`${idPrefix}-cr`}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={rating.cr}
|
||||||
|
onChange={(e) => onChange({ cr: e.target.value })}
|
||||||
|
placeholder="72.1"
|
||||||
|
invalid={rating.cr.trim() === ""}
|
||||||
|
className="bg-[var(--nr-surface-2)]"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Slope" htmlFor={`${idPrefix}-slope`}>
|
||||||
|
<TextInput
|
||||||
|
id={`${idPrefix}-slope`}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={rating.slope}
|
||||||
|
onChange={(e) => onChange({ slope: e.target.value.replace(/[^\d]/g, "") })}
|
||||||
|
placeholder="128"
|
||||||
|
invalid={rating.slope.trim() === ""}
|
||||||
|
className="bg-[var(--nr-surface-2)]"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Par" htmlFor={`${idPrefix}-par`}>
|
||||||
|
<TextInput
|
||||||
|
id={`${idPrefix}-par`}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={rating.par}
|
||||||
|
onChange={(e) => onChange({ par: e.target.value.replace(/[^\d]/g, "") })}
|
||||||
|
placeholder="72"
|
||||||
|
invalid={rating.par.trim() === ""}
|
||||||
|
className="bg-[var(--nr-surface-2)]"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
53
frontend/components/ny-runde/footer-bar.tsx
Normal file
53
frontend/components/ny-runde/footer-bar.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ArrowLeft, ArrowRight, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
export function FooterBar({
|
||||||
|
onBack,
|
||||||
|
onNext,
|
||||||
|
nextLabel = "Neste",
|
||||||
|
nextDisabled,
|
||||||
|
backDisabled,
|
||||||
|
backLabel = "Tilbake",
|
||||||
|
submitting,
|
||||||
|
isSubmitStep,
|
||||||
|
}: {
|
||||||
|
onBack: () => void
|
||||||
|
onNext: () => void
|
||||||
|
nextLabel?: string
|
||||||
|
nextDisabled?: boolean
|
||||||
|
backDisabled?: boolean
|
||||||
|
backLabel?: string
|
||||||
|
submitting?: boolean
|
||||||
|
isSubmitStep?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="sticky bottom-0 z-10 border-t border-[var(--nr-border)] bg-[var(--nr-surface)]/90 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-2xl items-center justify-between gap-3 px-4 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
disabled={backDisabled}
|
||||||
|
className="inline-flex min-h-11 items-center gap-2 rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 text-sm font-medium text-[var(--nr-ink)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
|
{backLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={nextDisabled || submitting}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-h-11 items-center gap-2 rounded-lg px-5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/50",
|
||||||
|
"bg-[var(--nr-accent)] text-[var(--nr-accent-ink)] hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submitting ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : null}
|
||||||
|
{nextLabel}
|
||||||
|
{!submitting && !isSubmitStep ? <ArrowRight className="h-4 w-4" aria-hidden="true" /> : null}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
455
frontend/components/ny-runde/primitives.tsx
Normal file
455
frontend/components/ny-runde/primitives.tsx
Normal file
|
|
@ -0,0 +1,455 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Check, Minus, Plus } from "lucide-react"
|
||||||
|
import type React from "react"
|
||||||
|
import { forwardRef, useEffect, useId, useRef, useState } from "react"
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Debounce hook */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
export function useDebouncedValue<T>(value: T, delay = 250): T {
|
||||||
|
const [debounced, setDebounced] = useState(value)
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebounced(value), delay)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [value, delay])
|
||||||
|
return debounced
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Field wrapper */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
optional,
|
||||||
|
htmlFor,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label?: string
|
||||||
|
hint?: string
|
||||||
|
error?: string | null
|
||||||
|
optional?: boolean
|
||||||
|
htmlFor?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||||
|
{label ? (
|
||||||
|
<label
|
||||||
|
htmlFor={htmlFor}
|
||||||
|
className="text-[13px] font-medium tracking-tight text-[var(--nr-ink)]"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{optional ? <span className="ml-1 font-normal text-[var(--nr-faint)]">(valgfritt)</span> : null}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
{hint && !error ? <p className="text-xs leading-relaxed text-[var(--nr-muted)]">{hint}</p> : null}
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-xs leading-relaxed text-[var(--nr-danger)]">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Text input / textarea / native select */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
const controlBase =
|
||||||
|
"w-full rounded-lg border bg-[var(--nr-surface-2)] px-3 text-[16px] text-[var(--nr-ink)] outline-none transition-colors placeholder:text-[var(--nr-faint)] focus-visible:border-[var(--nr-accent)] focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40 disabled:opacity-50"
|
||||||
|
|
||||||
|
export const TextInput = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement> & { invalid?: boolean }>(
|
||||||
|
({ className, invalid, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
controlBase,
|
||||||
|
"h-11",
|
||||||
|
invalid ? "border-[var(--nr-danger)]" : "border-[var(--nr-border)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-invalid={invalid || undefined}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
TextInput.displayName = "TextInput"
|
||||||
|
|
||||||
|
export const TextArea = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => (
|
||||||
|
<textarea className={cn(controlBase, "min-h-[44px] py-2.5 border-[var(--nr-border)]", className)} {...props} />
|
||||||
|
)
|
||||||
|
|
||||||
|
export const NativeSelect = ({
|
||||||
|
className,
|
||||||
|
invalid,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.SelectHTMLAttributes<HTMLSelectElement> & { invalid?: boolean }) => (
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
className={cn(
|
||||||
|
controlBase,
|
||||||
|
"h-11 appearance-none pr-9",
|
||||||
|
invalid ? "border-[var(--nr-danger)]" : "border-[var(--nr-border)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-invalid={invalid || undefined}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--nr-muted)]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M6 8l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Choice card (single or multi select) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
export function ChoiceCard({
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
selected: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
title: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
icon?: React.ReactNode
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelect}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex w-full items-start gap-3 rounded-xl border p-4 text-left transition-all min-h-11",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40",
|
||||||
|
selected
|
||||||
|
? "border-[var(--nr-accent)] bg-[var(--nr-accent-soft)]"
|
||||||
|
: "border-[var(--nr-border)] bg-[var(--nr-surface)] hover:border-[var(--nr-border-strong)]",
|
||||||
|
disabled && "cursor-not-allowed opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg",
|
||||||
|
selected ? "bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]" : "bg-[var(--nr-surface-2)] text-[var(--nr-muted)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-semibold text-[var(--nr-ink)]">{title}</span>
|
||||||
|
{description ? (
|
||||||
|
<span className="text-xs leading-relaxed text-[var(--nr-muted)]">{description}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border transition-colors",
|
||||||
|
selected ? "border-[var(--nr-accent)] bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]" : "border-[var(--nr-border-strong)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected ? <Check className="h-3.5 w-3.5" strokeWidth={3} /> : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Segmented control */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
export function Segmented<T extends string>({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
value: T
|
||||||
|
onChange: (v: T) => void
|
||||||
|
options: { value: T; label: string }[]
|
||||||
|
label?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={label}
|
||||||
|
className="inline-flex w-full rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface-2)] p-1"
|
||||||
|
>
|
||||||
|
{options.map((o) => {
|
||||||
|
const active = o.value === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
className={cn(
|
||||||
|
"min-h-9 flex-1 rounded-md px-3 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--nr-accent)]",
|
||||||
|
active
|
||||||
|
? "bg-[var(--nr-surface)] text-[var(--nr-ink)] shadow-sm"
|
||||||
|
: "text-[var(--nr-muted)] hover:text-[var(--nr-ink)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Toggle switch */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
export function Toggle({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
checked: boolean
|
||||||
|
onChange: (v: boolean) => void
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
}) {
|
||||||
|
const id = useId()
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<label htmlFor={id} className="text-sm font-medium text-[var(--nr-ink)]">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{description ? <span className="text-xs text-[var(--nr-muted)]">{description}</span> : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id={id}
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40",
|
||||||
|
checked ? "bg-[var(--nr-accent)]" : "bg-[var(--nr-border-strong)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform",
|
||||||
|
checked ? "translate-x-[22px]" : "translate-x-0.5",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Stepper (numeric, +/-) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
export function Stepper({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
step = 5,
|
||||||
|
snap = false,
|
||||||
|
suffix,
|
||||||
|
}: {
|
||||||
|
value: number
|
||||||
|
onChange: (v: number) => void
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
snap?: boolean
|
||||||
|
suffix?: string
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState(String(value))
|
||||||
|
useEffect(() => setText(String(value)), [value])
|
||||||
|
|
||||||
|
const clamp = (n: number) => Math.min(max, Math.max(min, n))
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
const parsed = Number.parseInt(text, 10)
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
setText(String(value))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let next = clamp(parsed)
|
||||||
|
if (snap) next = clamp(Math.round(next / step) * step)
|
||||||
|
onChange(next)
|
||||||
|
setText(String(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-stretch overflow-hidden rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Reduser"
|
||||||
|
onClick={() => onChange(clamp(value - step))}
|
||||||
|
disabled={value <= min}
|
||||||
|
className="flex h-11 w-11 items-center justify-center text-[var(--nr-muted)] transition-colors hover:bg-[var(--nr-surface-2)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Minus className="h-4 w-4" strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center border-x border-[var(--nr-border)] px-1">
|
||||||
|
<input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value.replace(/[^\d]/g, ""))}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur()
|
||||||
|
}}
|
||||||
|
className="h-11 w-12 bg-transparent text-center text-[16px] font-semibold tabular-nums text-[var(--nr-ink)] outline-none"
|
||||||
|
aria-label="Verdi"
|
||||||
|
/>
|
||||||
|
{suffix ? <span className="pr-1 text-sm text-[var(--nr-muted)]">{suffix}</span> : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Øk"
|
||||||
|
onClick={() => onChange(clamp(value + step))}
|
||||||
|
disabled={value >= max}
|
||||||
|
className="flex h-11 w-11 items-center justify-center text-[var(--nr-muted)] transition-colors hover:bg-[var(--nr-surface-2)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Small pill / badge */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
export function Pill({
|
||||||
|
children,
|
||||||
|
tone = "neutral",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
tone?: "neutral" | "accent" | "ok" | "danger"
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const tones: Record<string, string> = {
|
||||||
|
neutral: "bg-[var(--nr-surface-2)] text-[var(--nr-muted)] border-[var(--nr-border)]",
|
||||||
|
accent: "bg-[var(--nr-accent-soft)] text-[var(--nr-accent)] border-[var(--nr-accent-ring)]",
|
||||||
|
ok: "bg-[var(--nr-ok-soft)] text-[var(--nr-ok)] border-[var(--nr-ok)]/30",
|
||||||
|
danger: "bg-[var(--nr-danger-soft)] text-[var(--nr-danger)] border-[var(--nr-danger)]/30",
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium",
|
||||||
|
tones[tone],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Toggle-button grid item (used for formats / numeric picks) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
export function ToggleButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"min-h-11 rounded-lg border px-3 py-2 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40",
|
||||||
|
active
|
||||||
|
? "border-[var(--nr-accent)] bg-[var(--nr-accent-soft)] text-[var(--nr-accent)]"
|
||||||
|
: "border-[var(--nr-border)] bg-[var(--nr-surface)] text-[var(--nr-ink)] hover:border-[var(--nr-border-strong)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Avatar initials */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
export function initials(first: string, last?: string) {
|
||||||
|
const a = first?.trim()?.[0] ?? "?"
|
||||||
|
const b = last?.trim()?.[0] ?? ""
|
||||||
|
return (a + b).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({ first, last, className }: { first: string; last?: string; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--nr-surface-2)] text-xs font-semibold text-[var(--nr-muted)] ring-1 ring-inset ring-[var(--nr-border)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{initials(first, last)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Card container used throughout the wizard body. */
|
||||||
|
export function Panel({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] p-4 sm:p-5", className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep a ref-focus helper for autofocus fields on mount. */
|
||||||
|
export function useAutoFocus<T extends HTMLElement>(active = true) {
|
||||||
|
const ref = useRef<T>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) ref.current?.focus()
|
||||||
|
}, [active])
|
||||||
|
return ref
|
||||||
|
}
|
||||||
67
frontend/components/ny-runde/progress.tsx
Normal file
67
frontend/components/ny-runde/progress.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
import { useWizard } from "./wizard-context"
|
||||||
|
|
||||||
|
const STEP_LABELS: Record<number, string> = {
|
||||||
|
1: "Bane & tid",
|
||||||
|
2: "Spilleform",
|
||||||
|
3: "Spillere",
|
||||||
|
4: "Lag og rekkefølge",
|
||||||
|
5: "Deling",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressSteps() {
|
||||||
|
const { state, visibleSteps } = useWizard()
|
||||||
|
const currentIdx = visibleSteps.indexOf(state.step)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Fremdrift">
|
||||||
|
<ol className="flex items-center gap-1.5">
|
||||||
|
{visibleSteps.map((stepNo, i) => {
|
||||||
|
const isCurrent = i === currentIdx
|
||||||
|
const isDone = i < currentIdx
|
||||||
|
return (
|
||||||
|
<li key={stepNo} className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold tabular-nums transition-colors",
|
||||||
|
isCurrent && "bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]",
|
||||||
|
isDone && "bg-[var(--nr-accent-soft)] text-[var(--nr-accent)]",
|
||||||
|
!isCurrent && !isDone && "bg-[var(--nr-surface-2)] text-[var(--nr-faint)] ring-1 ring-inset ring-[var(--nr-border)]",
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{isDone ? <Check className="h-3.5 w-3.5" strokeWidth={3} /> : i + 1}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"hidden truncate text-xs font-medium sm:inline",
|
||||||
|
isCurrent ? "text-[var(--nr-ink)]" : "text-[var(--nr-muted)]",
|
||||||
|
)}
|
||||||
|
aria-current={isCurrent ? "step" : undefined}
|
||||||
|
>
|
||||||
|
{STEP_LABELS[stepNo]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{i < visibleSteps.length - 1 ? (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"h-px flex-1 transition-colors",
|
||||||
|
isDone ? "bg-[var(--nr-accent-ring)]" : "bg-[var(--nr-border)]",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
<p className="sr-only" aria-live="polite">
|
||||||
|
Steg {currentIdx + 1} av {visibleSteps.length}: {STEP_LABELS[state.step]}
|
||||||
|
</p>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
603
frontend/components/ny-runde/step1-course-time.tsx
Normal file
603
frontend/components/ny-runde/step1-course-time.tsx
Normal file
|
|
@ -0,0 +1,603 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ArrowLeft, Building2, ChevronRight, Flag, MapPin, PencilLine, Plus, Search } from "lucide-react"
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import {
|
||||||
|
fetchFacilityCourses,
|
||||||
|
fetchOwnCourse,
|
||||||
|
nearbyOfficialFacilities,
|
||||||
|
searchOfficialFacilities,
|
||||||
|
searchOwnCourses,
|
||||||
|
} from "@/lib/ny-runde/api"
|
||||||
|
import type { Course, Facility } from "@/lib/ny-runde/types"
|
||||||
|
import { CreateCourseForm } from "./create-course-form"
|
||||||
|
import { ChoiceCard, Field, NativeSelect, Panel, Pill, TextInput, useDebouncedValue } from "./primitives"
|
||||||
|
import { useWizard } from "./wizard-context"
|
||||||
|
|
||||||
|
export function Step1CourseTime() {
|
||||||
|
const { state, patch, exit } = useWizard()
|
||||||
|
const sub = state.s1Sub
|
||||||
|
|
||||||
|
function pickCourse(course: Course, meta: { source: "teeoff"; facilitySlug: string; teeoffCourseId: number } | { source: "custom"; personalCourseId: string }) {
|
||||||
|
if (state.templateMode) {
|
||||||
|
patch({ templateSeed: course, templateMode: false, s1Sub: "own-create" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const compatible = state.ownGender ? course.tees.filter((t) => t.genders.includes(state.ownGender!)) : course.tees
|
||||||
|
const tee = compatible[0]
|
||||||
|
patch({
|
||||||
|
course,
|
||||||
|
courseMeta: meta,
|
||||||
|
courseSource: meta.source === "teeoff" ? "official" : "own",
|
||||||
|
teeId: tee?.id,
|
||||||
|
s1Sub: "fields",
|
||||||
|
// Eierens EGET spillerkort (steg 3) har sitt eget teeId-felt, atskilt
|
||||||
|
// fra state.teeId over -- uten denne synkroniseringen viser kortet
|
||||||
|
// "Ikke valgt" selv om et utslag faktisk er valgt (kun kosmetisk, selve
|
||||||
|
// innsendingen leser state.teeId direkte for eieren -- men samme
|
||||||
|
// synkronisering som den opprinnelige new-round.tsx sin chooseCourse()
|
||||||
|
// gjorde, så oppførselen matcher eksakt).
|
||||||
|
players: state.players.map((p) => (p.kind === "owner" ? { ...p, teeId: tee?.id } : p)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (sub) {
|
||||||
|
case "source":
|
||||||
|
return (
|
||||||
|
<SourceScreen
|
||||||
|
templateMode={false}
|
||||||
|
onOfficial={() => patch({ s1Sub: "official-search" })}
|
||||||
|
onOwn={() => patch({ s1Sub: "own-search" })}
|
||||||
|
onBack={exit}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "template-source":
|
||||||
|
return (
|
||||||
|
<SourceScreen
|
||||||
|
templateMode
|
||||||
|
onOfficial={() => patch({ templateMode: true, s1Sub: "official-search" })}
|
||||||
|
onOwn={() => patch({ templateMode: true, s1Sub: "own-search" })}
|
||||||
|
onBack={() => patch({ s1Sub: "own-create" })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "official-search":
|
||||||
|
return (
|
||||||
|
<OfficialSearch
|
||||||
|
templateMode={state.templateMode}
|
||||||
|
onBack={() => patch({ s1Sub: state.templateMode ? "template-source" : "source" })}
|
||||||
|
onPickFacility={(f) => patch({ selectedFacilitySlug: f.slug, s1Sub: "official-courses" })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "official-courses":
|
||||||
|
return (
|
||||||
|
<OfficialCourses
|
||||||
|
slug={state.selectedFacilitySlug}
|
||||||
|
templateMode={state.templateMode}
|
||||||
|
onBack={() => patch({ s1Sub: "official-search" })}
|
||||||
|
onPick={(teeoffCourseId, course) =>
|
||||||
|
pickCourse(course, { source: "teeoff", facilitySlug: state.selectedFacilitySlug!, teeoffCourseId })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "own-search":
|
||||||
|
return (
|
||||||
|
<OwnSearch
|
||||||
|
templateMode={state.templateMode}
|
||||||
|
onBack={() => patch({ s1Sub: state.templateMode ? "template-source" : "source" })}
|
||||||
|
onCreate={() => patch({ s1Sub: "own-create", templateSeed: undefined, ownCreateOrigin: "own-search" })}
|
||||||
|
onPick={(course) => pickCourse(course, { source: "custom", personalCourseId: course.id })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "own-create":
|
||||||
|
return (
|
||||||
|
<OwnCreate
|
||||||
|
onBack={() => patch({ s1Sub: "own-search", templateSeed: undefined })}
|
||||||
|
onTemplate={() => patch({ s1Sub: "template-source", ownCreateOrigin: "own-create" })}
|
||||||
|
onCreate={(course) => pickCourse(course, { source: "custom", personalCourseId: course.id })}
|
||||||
|
seed={state.templateSeed}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case "fields":
|
||||||
|
return <Fields />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
function SubHeader({ title, onBack, subtitle }: { title: string; onBack: () => void; subtitle?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex items-start gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
aria-label="Tilbake"
|
||||||
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] text-[var(--nr-ink)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight text-[var(--nr-ink)]">{title}</h2>
|
||||||
|
{subtitle ? <p className="text-sm text-[var(--nr-muted)]">{subtitle}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* source / template-source */
|
||||||
|
function SourceScreen({
|
||||||
|
templateMode,
|
||||||
|
onOfficial,
|
||||||
|
onOwn,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
templateMode: boolean
|
||||||
|
onOfficial: () => void
|
||||||
|
onOwn: () => void
|
||||||
|
onBack: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SubHeader
|
||||||
|
title={templateMode ? "Velg en mal" : "Hvor spiller du?"}
|
||||||
|
subtitle={templateMode ? "Velg en bane å basere din egen bane på." : "Velg en offisiell bane eller din egen."}
|
||||||
|
onBack={onBack}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<ChoiceCard
|
||||||
|
selected={false}
|
||||||
|
onSelect={onOfficial}
|
||||||
|
icon={<Building2 className="h-5 w-5" />}
|
||||||
|
title="Offisiell bane"
|
||||||
|
description="Søk opp en registrert golfbane fra databasen."
|
||||||
|
/>
|
||||||
|
<ChoiceCard
|
||||||
|
selected={false}
|
||||||
|
onSelect={onOwn}
|
||||||
|
icon={<PencilLine className="h-5 w-5" />}
|
||||||
|
title="Egen bane"
|
||||||
|
description="Bruk eller lag din egen manuelt registrerte bane."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* official-search (med geolokasjon "nærmest deg") */
|
||||||
|
function OfficialSearch({
|
||||||
|
templateMode,
|
||||||
|
onBack,
|
||||||
|
onPickFacility,
|
||||||
|
}: {
|
||||||
|
templateMode: boolean
|
||||||
|
onBack: () => void
|
||||||
|
onPickFacility: (f: Facility) => void
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const debounced = useDebouncedValue(query, 250)
|
||||||
|
const [results, setResults] = useState<Facility[] | null>(null)
|
||||||
|
const [nearest, setNearest] = useState<(Facility & { distanceKm: number })[]>([])
|
||||||
|
const requested = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (requested.current) return
|
||||||
|
requested.current = true
|
||||||
|
if (typeof navigator === "undefined" || !navigator.geolocation) return
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
void nearbyOfficialFacilities(pos.coords.latitude, pos.coords.longitude).then(setNearest)
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
/* stille ved avslag/feil */
|
||||||
|
},
|
||||||
|
{ timeout: 8000 },
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = debounced.trim()
|
||||||
|
if (trimmed === "") {
|
||||||
|
setResults(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
void searchOfficialFacilities(trimmed).then((r) => {
|
||||||
|
if (!cancelled) setResults(r)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [debounced])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SubHeader
|
||||||
|
title={templateMode ? "Offisiell bane som mal" : "Offisiell bane"}
|
||||||
|
subtitle="Søk etter anlegg på navn."
|
||||||
|
onBack={onBack}
|
||||||
|
/>
|
||||||
|
<Field label="Banenavn" htmlFor="official-q">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--nr-faint)]" aria-hidden="true" />
|
||||||
|
<TextInput
|
||||||
|
id="official-q"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Søk etter anlegg…"
|
||||||
|
className="pl-9"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
{results === null ? (
|
||||||
|
nearest.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="mb-2 flex items-center gap-1.5 text-[13px] font-medium text-[var(--nr-muted)]">
|
||||||
|
<MapPin className="h-4 w-4" aria-hidden="true" /> Nærmest deg
|
||||||
|
</p>
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{nearest.map((f) => (
|
||||||
|
<FacilityRow
|
||||||
|
key={f.slug}
|
||||||
|
facility={f}
|
||||||
|
onClick={() => onPickFacility(f)}
|
||||||
|
distance={f.distanceKm * 1000 < 1000 ? `${Math.round(f.distanceKm * 1000)} m` : `${Math.round(f.distanceKm)} km`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
|
||||||
|
Begynn å skrive for å søke etter en bane.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
) : results.length > 0 ? (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{results.map((f) => (
|
||||||
|
<FacilityRow key={f.slug} facility={f} onClick={() => onPickFacility(f)} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
|
||||||
|
Ingen anlegg matcher «{debounced}».
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FacilityRow({ facility, onClick, distance }: { facility: Facility; onClick: () => void; distance?: string }) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex min-h-14 w-full items-center gap-3 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
|
||||||
|
>
|
||||||
|
<Building2 className="h-5 w-5 shrink-0 text-[var(--nr-muted)]" aria-hidden="true" />
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="block text-sm font-medium text-[var(--nr-ink)]">{facility.name}</span>
|
||||||
|
<span className="block text-xs text-[var(--nr-muted)]">{[facility.city, facility.county].filter(Boolean).join(", ") || " "}</span>
|
||||||
|
</span>
|
||||||
|
{distance ? <Pill tone="neutral">{distance}</Pill> : null}
|
||||||
|
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--nr-faint)]" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* official-courses */
|
||||||
|
function OfficialCourses({
|
||||||
|
slug,
|
||||||
|
templateMode,
|
||||||
|
onBack,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
slug?: string
|
||||||
|
templateMode: boolean
|
||||||
|
onBack: () => void
|
||||||
|
onPick: (teeoffCourseId: number, course: Course) => void
|
||||||
|
}) {
|
||||||
|
const [courses, setCourses] = useState<{ teeoffCourseId: number; course: Course }[] | null>(null)
|
||||||
|
const [loadError, setLoadError] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slug) return
|
||||||
|
let cancelled = false
|
||||||
|
setCourses(null)
|
||||||
|
setLoadError(false)
|
||||||
|
fetchFacilityCourses(slug)
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setCourses(r)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setLoadError(true)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SubHeader title="Baner" subtitle="Velg en bane / sløyfe." onBack={onBack} />
|
||||||
|
{loadError ? (
|
||||||
|
<p role="alert" className="rounded-lg border border-[var(--nr-danger)]/30 bg-[var(--nr-danger-soft)] px-4 py-3 text-sm text-[var(--nr-danger)]">
|
||||||
|
Klarte ikke å hente baner fra teeoff akkurat nå. Prøv igjen om litt.
|
||||||
|
</p>
|
||||||
|
) : courses === null ? (
|
||||||
|
<p className="text-sm text-[var(--nr-muted)]">Laster baner…</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{courses.map(({ teeoffCourseId, course }) => (
|
||||||
|
<li key={teeoffCourseId}>
|
||||||
|
<CourseRow course={course} templateMode={templateMode} onPick={() => onPick(teeoffCourseId, course)} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CourseRow({ course, templateMode, onPick }: { course: Course; templateMode: boolean; onPick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPick}
|
||||||
|
className="flex min-h-14 w-full items-center gap-3 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
|
||||||
|
>
|
||||||
|
<Flag className="h-5 w-5 shrink-0 text-[var(--nr-muted)]" aria-hidden="true" />
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="block text-sm font-medium text-[var(--nr-ink)]">{course.name}</span>
|
||||||
|
<span className="block text-xs text-[var(--nr-muted)]">{course.tees.length} utslag</span>
|
||||||
|
</span>
|
||||||
|
{templateMode ? <Pill tone="accent">Bruk som mal for egen bane</Pill> : <ChevronRight className="h-4 w-4 shrink-0 text-[var(--nr-faint)]" aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* own-search */
|
||||||
|
function OwnSearch({
|
||||||
|
templateMode,
|
||||||
|
onBack,
|
||||||
|
onCreate,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
templateMode: boolean
|
||||||
|
onBack: () => void
|
||||||
|
onCreate: () => void
|
||||||
|
onPick: (c: Course) => void
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const debounced = useDebouncedValue(query, 250)
|
||||||
|
const [results, setResults] = useState<{ id: string; name: string }[] | null>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = debounced.trim()
|
||||||
|
if (trimmed === "") {
|
||||||
|
setResults(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
void searchOwnCourses(trimmed).then((r) => {
|
||||||
|
if (!cancelled) setResults(r)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [debounced])
|
||||||
|
|
||||||
|
async function pick(id: string) {
|
||||||
|
const course = await fetchOwnCourse(id)
|
||||||
|
if (course) onPick(course)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SubHeader
|
||||||
|
title={templateMode ? "Egen bane som mal" : "Egen bane"}
|
||||||
|
subtitle="Søk blant banene du har laget selv."
|
||||||
|
onBack={onBack}
|
||||||
|
/>
|
||||||
|
<Field label="Banenavn" htmlFor="own-q">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--nr-faint)]" aria-hidden="true" />
|
||||||
|
<TextInput
|
||||||
|
id="own-q"
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Søk blant egne baner…"
|
||||||
|
className="pl-9"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{!templateMode ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCreate}
|
||||||
|
className="mt-3 inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-xl border border-dashed border-[var(--nr-border-strong)] bg-[var(--nr-surface)] px-4 text-sm font-medium text-[var(--nr-accent)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Opprett ny bane
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
{results === null ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
|
||||||
|
Søk etter en egen bane{templateMode ? "" : ", eller opprett en ny"}.
|
||||||
|
</p>
|
||||||
|
) : results.length > 0 ? (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{results.map((c) => (
|
||||||
|
<li key={c.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void pick(c.id)}
|
||||||
|
className="flex min-h-14 w-full items-center gap-3 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
|
||||||
|
>
|
||||||
|
<Flag className="h-5 w-5 shrink-0 text-[var(--nr-muted)]" aria-hidden="true" />
|
||||||
|
<span className="flex-1 text-sm font-medium text-[var(--nr-ink)]">{c.name}</span>
|
||||||
|
{templateMode ? <Pill tone="accent">Bruk som mal for egen bane</Pill> : <ChevronRight className="h-4 w-4 shrink-0 text-[var(--nr-faint)]" aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-4 py-6 text-center text-sm text-[var(--nr-muted)]">
|
||||||
|
Fant ingen egne baner som matcher «{debounced}».
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* own-create */
|
||||||
|
function OwnCreate({
|
||||||
|
seed,
|
||||||
|
onBack,
|
||||||
|
onTemplate,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
seed?: Course
|
||||||
|
onBack: () => void
|
||||||
|
onTemplate: () => void
|
||||||
|
onCreate: (c: Course) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SubHeader
|
||||||
|
title={seed ? "Ny bane (fra mal)" : "Ny egen bane"}
|
||||||
|
subtitle="Registrer hull, par, stroke-indeks og utslag."
|
||||||
|
onBack={onBack}
|
||||||
|
/>
|
||||||
|
{!seed ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTemplate}
|
||||||
|
className="mb-4 text-[13px] font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Basér på en eksisterende bane i stedet
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="mb-4">
|
||||||
|
<Pill tone="accent">Forhåndsutfylt fra «{seed.name}»</Pill>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<CreateCourseForm seed={seed} onCancel={onBack} onCreate={onCreate} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* fields */
|
||||||
|
function Fields() {
|
||||||
|
const { state, patch } = useWizard()
|
||||||
|
const course = state.course
|
||||||
|
if (!course) return null
|
||||||
|
|
||||||
|
const compatibleTees = state.ownGender ? course.tees.filter((t) => t.genders.includes(state.ownGender!)) : course.tees
|
||||||
|
const noCompatibleTee = compatibleTees.length === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight text-[var(--nr-ink)]">Bane & tid</h2>
|
||||||
|
<p className="text-sm text-[var(--nr-muted)]">Bekreft detaljene for runden.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel className="mb-4 bg-[var(--nr-surface-2)]">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-[var(--nr-ink)]">{course.name}</p>
|
||||||
|
<p className="text-xs text-[var(--nr-muted)]">{course.tees.length} utslag</p>
|
||||||
|
</div>
|
||||||
|
<Pill tone={state.courseSource === "own" ? "neutral" : "accent"}>{state.courseSource === "own" ? "Egen bane" : "Offisiell"}</Pill>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patch({ s1Sub: "source", course: undefined, courseMeta: undefined, teeId: undefined })}
|
||||||
|
className="mt-3 text-[13px] font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Bytt bane
|
||||||
|
</button>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Field label="Navn på runden" optional htmlFor="round-name" hint="Maks 200 tegn.">
|
||||||
|
<TextInput
|
||||||
|
id="round-name"
|
||||||
|
value={state.roundName}
|
||||||
|
onChange={(e) => patch({ roundName: e.target.value.slice(0, 200) })}
|
||||||
|
placeholder={course.name}
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Utslag"
|
||||||
|
htmlFor="tee"
|
||||||
|
error={
|
||||||
|
noCompatibleTee
|
||||||
|
? "Ingen utslag på denne banen har rating for ditt kjønn. Handicap kan ikke spores for denne runden, men du kan fortsette."
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{noCompatibleTee ? (
|
||||||
|
<div className="rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface-2)] px-3 py-2.5 text-sm text-[var(--nr-muted)]">
|
||||||
|
Ingen kompatible utslag.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<NativeSelect id="tee" value={state.teeId ?? ""} onChange={(e) => patch({ teeId: e.target.value })}>
|
||||||
|
{compatibleTees.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Dato" htmlFor="date">
|
||||||
|
<TextInput id="date" type="date" value={state.date} onChange={(e) => patch({ date: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Utslagstid" optional htmlFor="teetime">
|
||||||
|
<TextInput id="teetime" type="time" value={state.teeTime} onChange={(e) => patch({ teeTime: e.target.value })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Starthull" htmlFor="starthole">
|
||||||
|
<NativeSelect
|
||||||
|
id="starthole"
|
||||||
|
value={state.startHole}
|
||||||
|
onChange={(e) => patch({ startHole: Number.parseInt(e.target.value, 10) })}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 18 }, (_, i) => i + 1).map((h) => (
|
||||||
|
<option key={h} value={h}>
|
||||||
|
Hull {h}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
315
frontend/components/ny-runde/step2-format.tsx
Normal file
315
frontend/components/ny-runde/step2-format.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { FORMATS, FORMAT_MAP, isScrambleVsSolo, isTwoSided } from "@/lib/ny-runde/formats"
|
||||||
|
import type { StatLevel } from "@/lib/ny-runde/types"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDown } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { ChoiceCard, Field, Panel, Segmented, Stepper, TextInput, Toggle, ToggleButton } from "./primitives"
|
||||||
|
import { useWizard } from "./wizard-context"
|
||||||
|
|
||||||
|
const STAT_OPTIONS: { value: StatLevel; title: string; description: string }[] = [
|
||||||
|
{ value: "score", title: "Kun slag", description: "Registrer bare score per hull." },
|
||||||
|
{ value: "score_putts", title: "Slag og putter", description: "Score pluss antall putter." },
|
||||||
|
{ value: "full", title: "All statistikk", description: "Kølle, retning, chip, bunker, straff m.m." },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Step2Format() {
|
||||||
|
const { state, patch } = useWizard()
|
||||||
|
const format = state.format
|
||||||
|
const selectedDef = FORMAT_MAP[format]
|
||||||
|
const svs = isScrambleVsSolo(format)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight text-[var(--nr-ink)]">Spilleform</h2>
|
||||||
|
<p className="text-sm text-[var(--nr-muted)]">Velg statistikknivå og hvordan runden spilles.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat level for owner */}
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Statistikk for deg selv</h3>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
|
{STAT_OPTIONS.map((o) => (
|
||||||
|
<ChoiceCard
|
||||||
|
key={o.value}
|
||||||
|
selected={state.statLevel === o.value}
|
||||||
|
onSelect={() => patch({ statLevel: o.value })}
|
||||||
|
title={o.title}
|
||||||
|
description={o.description}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Format grid */}
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Spilleform</h3>
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Spilleform"
|
||||||
|
className="grid grid-cols-2 gap-2 sm:grid-cols-3"
|
||||||
|
>
|
||||||
|
{FORMATS.map((f) => (
|
||||||
|
<ToggleButton key={f.id} active={format === f.id} onClick={() => patch({ format: f.id })}>
|
||||||
|
{f.label}
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Panel className="mt-1 bg-[var(--nr-surface-2)] py-3">
|
||||||
|
<p className="text-sm text-[var(--nr-ink)]">
|
||||||
|
<span className="font-semibold">{selectedDef.label}.</span>{" "}
|
||||||
|
<span className="text-[var(--nr-muted)]">{selectedDef.description}</span>
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Format-specific config */}
|
||||||
|
<FormatConfig />
|
||||||
|
|
||||||
|
{/* Solo allowance (scramble vs solo only) */}
|
||||||
|
{svs ? (
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Individualspillerens handicap-andel</h3>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Stepper
|
||||||
|
value={state.soloAllowance}
|
||||||
|
onChange={(v) => patch({ soloAllowance: v })}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
snap
|
||||||
|
suffix="%"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--nr-muted)]">Andel av enkeltspillerens handicap som teller (0–100 %).</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Advanced HCP (hidden for scramble vs solo) */}
|
||||||
|
{!svs ? <AdvancedHcp /> : null}
|
||||||
|
|
||||||
|
{/* Number of holes */}
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Antall hull</h3>
|
||||||
|
<div className="max-w-xs">
|
||||||
|
<Segmented
|
||||||
|
label="Antall hull"
|
||||||
|
value={String(state.numHoles) as "9" | "18"}
|
||||||
|
onChange={(v) => patch({ numHoles: v === "9" ? 9 : 18 })}
|
||||||
|
options={[
|
||||||
|
{ value: "9", label: "9 hull" },
|
||||||
|
{ value: "18", label: "18 hull" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormatConfig() {
|
||||||
|
const { state, patch } = useWizard()
|
||||||
|
const f = state.format
|
||||||
|
|
||||||
|
if (f === "match") {
|
||||||
|
return (
|
||||||
|
<ConfigCard>
|
||||||
|
<CheckboxRow
|
||||||
|
checked={state.matchExcludeHcp}
|
||||||
|
onChange={(v) => patch({ matchExcludeHcp: v })}
|
||||||
|
label="Ekskluder denne runden fra mitt faktiske HCP"
|
||||||
|
/>
|
||||||
|
</ConfigCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f === "skins") {
|
||||||
|
return (
|
||||||
|
<ConfigCard>
|
||||||
|
<Field label="Poengberegning">
|
||||||
|
<Segmented
|
||||||
|
value={state.skinsScoring}
|
||||||
|
onChange={(v) => patch({ skinsScoring: v })}
|
||||||
|
options={[
|
||||||
|
{ value: "net", label: "Netto (m/HCP)" },
|
||||||
|
{ value: "gross", label: "Brutto" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Ved uavgjort hull">
|
||||||
|
<Segmented
|
||||||
|
value={state.skinsTie}
|
||||||
|
onChange={(v) => patch({ skinsTie: v })}
|
||||||
|
options={[
|
||||||
|
{ value: "carry", label: "Rullerer over" },
|
||||||
|
{ value: "split", label: "Deles likt" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<p className="text-xs text-[var(--nr-muted)]">Skins krever minst 3 spillere.</p>
|
||||||
|
</ConfigCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f === "bbb") {
|
||||||
|
return (
|
||||||
|
<ConfigCard>
|
||||||
|
<CheckboxRow
|
||||||
|
checked={state.bbbSweep}
|
||||||
|
onChange={(v) => patch({ bbbSweep: v })}
|
||||||
|
label="Bonuspoeng ved «sveip»"
|
||||||
|
/>
|
||||||
|
</ConfigCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f === "shamble") {
|
||||||
|
return (
|
||||||
|
<ConfigCard>
|
||||||
|
<Field label="Beste N av laget teller per hull">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[1, 2, 3, 4].map((n) => (
|
||||||
|
<ToggleButton
|
||||||
|
key={n}
|
||||||
|
active={state.shambleBestN === n}
|
||||||
|
onClick={() => patch({ shambleBestN: n })}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</ConfigCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f === "moneyball") {
|
||||||
|
return (
|
||||||
|
<ConfigCard>
|
||||||
|
<p className="text-sm text-[var(--nr-muted)]">Rotasjonen for «money ball» settes opp i neste steg.</p>
|
||||||
|
</ConfigCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f === "copenhagen") {
|
||||||
|
return (
|
||||||
|
<ConfigCard>
|
||||||
|
<p className="text-sm text-[var(--nr-muted)]">Nøyaktig 3 spillere.</p>
|
||||||
|
</ConfigCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isScrambleVsSolo(f)) {
|
||||||
|
return null // handled by the dedicated allowance stepper
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTwoSided(f)) {
|
||||||
|
return (
|
||||||
|
<ConfigCard>
|
||||||
|
<p className="text-sm text-[var(--nr-muted)]">Du setter opp lagene i neste steg.</p>
|
||||||
|
</ConfigCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdvancedHcp() {
|
||||||
|
const { state, patch } = useWizard()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const f = state.format
|
||||||
|
const isSlagOrStable = f === "slagspill" || f === "stableford"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex min-h-11 w-full items-center justify-between gap-2 px-4 py-3 text-left focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-[var(--nr-ink)]">Avanserte handicap-innstillinger</span>
|
||||||
|
<ChevronDown className={cn("h-4 w-4 text-[var(--nr-muted)] transition-transform", open && "rotate-180")} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className="flex flex-col gap-4 border-t border-[var(--nr-border)] p-4">
|
||||||
|
<Toggle checked={state.useHandicap} onChange={(v) => patch({ useHandicap: v })} label="Bruk handicap" />
|
||||||
|
|
||||||
|
{state.useHandicap && !isSlagOrStable ? (
|
||||||
|
<Toggle
|
||||||
|
checked={state.useCourseHcpAdj}
|
||||||
|
onChange={(v) => patch({ useCourseHcpAdj: v })}
|
||||||
|
label="Bruk course handicap-justering"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.useHandicap && isTwoSided(f) ? (
|
||||||
|
<Toggle
|
||||||
|
checked={state.useMatchplayHcp}
|
||||||
|
onChange={(v) => patch({ useMatchplayHcp: v })}
|
||||||
|
label="Bruk matchplay-handicap"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.useHandicap ? (
|
||||||
|
<Field label="HCP-prosent" optional htmlFor="hcp-pct">
|
||||||
|
<TextInput
|
||||||
|
id="hcp-pct"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={state.hcpPercent}
|
||||||
|
onChange={(e) => patch({ hcpPercent: e.target.value })}
|
||||||
|
placeholder="F.eks. 95"
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigCard({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-4 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] p-4">
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CheckboxRow({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
checked: boolean
|
||||||
|
onChange: (v: boolean) => void
|
||||||
|
label: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className="flex min-h-11 w-full items-center gap-3 text-left focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex h-5 w-5 shrink-0 items-center justify-center rounded-[6px] border transition-colors",
|
||||||
|
checked ? "border-[var(--nr-accent)] bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]" : "border-[var(--nr-border-strong)] bg-[var(--nr-surface)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{checked ? (
|
||||||
|
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M3 8.5l3.5 3.5L13 5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-[var(--nr-ink)]">{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
576
frontend/components/ny-runde/step3-players.tsx
Normal file
576
frontend/components/ny-runde/step3-players.tsx
Normal file
|
|
@ -0,0 +1,576 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { apiGenderOf, lookupKnownGuest, searchAccounts } from "@/lib/ny-runde/api"
|
||||||
|
import type { Account, Course, Gender, KnownGuest, Player, StatLevel } from "@/lib/ny-runde/types"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDown, Search, Sparkles, UserPlus, X } from "lucide-react"
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { Avatar, Field, NativeSelect, Pill, TextInput, ToggleButton, useDebouncedValue } from "./primitives"
|
||||||
|
import { useWizard } from "./wizard-context"
|
||||||
|
|
||||||
|
const GENDERS: { value: Gender; label: string }[] = [
|
||||||
|
{ value: "mann", label: "Mann" },
|
||||||
|
{ value: "kvinne", label: "Kvinne" },
|
||||||
|
{ value: "annet", label: "Annet" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STAT: { value: StatLevel; label: string }[] = [
|
||||||
|
{ value: "score", label: "Kun slag" },
|
||||||
|
{ value: "score_putts", label: "Slag + putter" },
|
||||||
|
{ value: "full", label: "Alt" },
|
||||||
|
]
|
||||||
|
|
||||||
|
let keyCounter = 0
|
||||||
|
function nextKey() {
|
||||||
|
keyCounter += 1
|
||||||
|
return `p-${Date.now()}-${keyCounter}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function teeName(course: Course | undefined, teeId?: string) {
|
||||||
|
if (!course || !teeId) return "Ikke valgt"
|
||||||
|
return course.tees.find((t) => t.id === teeId)?.name ?? "Ikke valgt"
|
||||||
|
}
|
||||||
|
|
||||||
|
function compatibleTees(course: Course | undefined, gender?: Gender) {
|
||||||
|
if (!course) return []
|
||||||
|
if (!gender) return course.tees // ukjent kjønn (nylig lenket konto): vis alle
|
||||||
|
const g = apiGenderOf(gender)
|
||||||
|
if (g === "x") return course.tees
|
||||||
|
return course.tees.filter((t) => t.genders.includes(g))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step3Players() {
|
||||||
|
const { state, patch } = useWizard()
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
|
||||||
|
function updatePlayer(key: string, p: Partial<Player>) {
|
||||||
|
patch({ players: state.players.map((pl) => (pl.key === key ? { ...pl, ...p } : pl)) })
|
||||||
|
}
|
||||||
|
function removePlayer(key: string) {
|
||||||
|
patch({ players: state.players.filter((pl) => pl.key !== key) })
|
||||||
|
}
|
||||||
|
function addPlayer(p: Player) {
|
||||||
|
patch({ players: [...state.players, p] })
|
||||||
|
setAdding(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight text-[var(--nr-ink)]">Spillere</h2>
|
||||||
|
<p className="text-sm text-[var(--nr-muted)]">
|
||||||
|
{state.players.length} {state.players.length === 1 ? "spiller" : "spillere"} i runden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{state.players.map((player) => (
|
||||||
|
<li key={player.key}>
|
||||||
|
<PlayerCard
|
||||||
|
player={player}
|
||||||
|
course={state.course}
|
||||||
|
onChange={(p) => updatePlayer(player.key, p)}
|
||||||
|
onRemove={() => removePlayer(player.key)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{adding ? (
|
||||||
|
<AddPlayerPanel
|
||||||
|
course={state.course}
|
||||||
|
defaultStat={state.statLevel}
|
||||||
|
existing={state.players}
|
||||||
|
onCancel={() => setAdding(false)}
|
||||||
|
onAdd={addPlayer}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdding(true)}
|
||||||
|
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl border border-dashed border-[var(--nr-border-strong)] bg-[var(--nr-surface)] px-4 text-sm font-medium text-[var(--nr-accent)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
|
||||||
|
>
|
||||||
|
<UserPlus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Medspiller
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Player card */
|
||||||
|
function PlayerCard({
|
||||||
|
player,
|
||||||
|
course,
|
||||||
|
onChange,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
player: Player
|
||||||
|
course?: Course
|
||||||
|
onChange: (p: Partial<Player>) => void
|
||||||
|
onRemove: () => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const fullName = `${player.firstName}${player.lastName ? ` ${player.lastName}` : ""}`.trim()
|
||||||
|
const isOwner = player.kind === "owner"
|
||||||
|
const hcpText = player.hcpFromProfile
|
||||||
|
? player.hcp != null
|
||||||
|
? String(player.hcp)
|
||||||
|
: "fra profil"
|
||||||
|
: player.hcp != null
|
||||||
|
? String(player.hcp)
|
||||||
|
: "–"
|
||||||
|
|
||||||
|
const tees = compatibleTees(course, player.gender)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)]">
|
||||||
|
<div className="flex items-center gap-3 p-3">
|
||||||
|
<Avatar first={player.firstName || "?"} last={player.lastName} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-sm font-semibold text-[var(--nr-ink)]">{fullName || "Ny spiller"}</span>
|
||||||
|
{isOwner ? <Pill tone="accent">Deg · Eier</Pill> : null}
|
||||||
|
{player.isGuest ? <Pill tone="neutral">Gjest</Pill> : null}
|
||||||
|
</div>
|
||||||
|
<p className="truncate text-xs text-[var(--nr-muted)]">
|
||||||
|
Utslag: {teeName(course, player.teeId)} · HCP: {hcpText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={open ? `Skjul redigering av ${fullName}` : `Rediger ${fullName}`}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-lg border border-[var(--nr-border)] text-[var(--nr-muted)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
|
||||||
|
>
|
||||||
|
<ChevronDown className={cn("h-4 w-4 transition-transform", open && "rotate-180")} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{!isOwner ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
aria-label={`Fjern ${fullName}`}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-lg border border-[var(--nr-border)] text-[var(--nr-muted)] transition-colors hover:text-[var(--nr-danger)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<div className="flex flex-col gap-4 border-t border-[var(--nr-border)] p-4">
|
||||||
|
{player.isGuest ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Fornavn" htmlFor={`${player.key}-first`}>
|
||||||
|
<TextInput
|
||||||
|
id={`${player.key}-first`}
|
||||||
|
value={player.firstName}
|
||||||
|
onChange={(e) => onChange({ firstName: e.target.value })}
|
||||||
|
invalid={player.firstName.trim() === ""}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Etternavn" optional htmlFor={`${player.key}-last`}>
|
||||||
|
<TextInput
|
||||||
|
id={`${player.key}-last`}
|
||||||
|
value={player.lastName}
|
||||||
|
onChange={(e) => onChange({ lastName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Kjønn">
|
||||||
|
<GenderChoice
|
||||||
|
value={player.gender ?? "mann"}
|
||||||
|
onChange={(g) => {
|
||||||
|
const stillOk = compatibleTees(course, g).some((t) => t.id === player.teeId)
|
||||||
|
onChange({ gender: g, teeId: stillOk ? player.teeId : compatibleTees(course, g)[0]?.id })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="E-post" optional htmlFor={`${player.key}-email`}>
|
||||||
|
<TextInput
|
||||||
|
id={`${player.key}-email`}
|
||||||
|
type="email"
|
||||||
|
value={player.email ?? ""}
|
||||||
|
onChange={(e) => onChange({ email: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Field label="Utslag" htmlFor={`${player.key}-tee`}>
|
||||||
|
<NativeSelect
|
||||||
|
id={`${player.key}-tee`}
|
||||||
|
value={player.teeId ?? ""}
|
||||||
|
onChange={(e) => onChange({ teeId: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Velg utslag</option>
|
||||||
|
{tees.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{player.isGuest ? (
|
||||||
|
<Field label="HCP" optional htmlFor={`${player.key}-hcp`}>
|
||||||
|
<TextInput
|
||||||
|
id={`${player.key}-hcp`}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={player.hcp != null ? String(player.hcp) : ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
onChange({ hcp: v.trim() === "" ? null : Number.parseFloat(v.replace(",", ".")) })
|
||||||
|
}}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : player.hcpFromProfile && !isOwner ? (
|
||||||
|
<Field label="HCP">
|
||||||
|
<div className="rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface-2)] px-3 py-2.5 text-sm text-[var(--nr-muted)]">
|
||||||
|
{player.hcp != null ? `${player.hcp} · hentes fra profilen` : "Hentes fra profilen…"}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Field label="Statistikk">
|
||||||
|
<StatChoice value={player.statLevel} onChange={(s) => onChange({ statLevel: s })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Legg til-panel (søk + gjest) */
|
||||||
|
function AddPlayerPanel({
|
||||||
|
course,
|
||||||
|
defaultStat,
|
||||||
|
existing,
|
||||||
|
onCancel,
|
||||||
|
onAdd,
|
||||||
|
}: {
|
||||||
|
course?: Course
|
||||||
|
defaultStat: StatLevel
|
||||||
|
existing: Player[]
|
||||||
|
onCancel: () => void
|
||||||
|
onAdd: (p: Player) => void
|
||||||
|
}) {
|
||||||
|
const [mode, setMode] = useState<"search" | "guest">("search")
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const debounced = useDebouncedValue(query, 250)
|
||||||
|
const [results, setResults] = useState<Account[]>([])
|
||||||
|
|
||||||
|
// gjesteskjema
|
||||||
|
const [first, setFirst] = useState("")
|
||||||
|
const [last, setLast] = useState("")
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [gender, setGender] = useState<Gender>("mann")
|
||||||
|
const [hcp, setHcp] = useState("")
|
||||||
|
const [stat, setStat] = useState<StatLevel>(defaultStat)
|
||||||
|
const firstRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "guest") firstRef.current?.focus()
|
||||||
|
}, [mode])
|
||||||
|
|
||||||
|
const existingAccountIds = new Set(existing.map((p) => p.accountId).filter(Boolean))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const q = debounced.trim()
|
||||||
|
if (q.length < 2) {
|
||||||
|
setResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
void searchAccounts(q).then((r) => {
|
||||||
|
if (!cancelled) setResults(r.filter((a) => !existingAccountIds.has(a.id)))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [debounced])
|
||||||
|
|
||||||
|
function switchToGuest() {
|
||||||
|
const parts = query.trim().split(/\s+/)
|
||||||
|
setFirst(parts[0] ?? "")
|
||||||
|
setLast(parts.slice(1).join(" "))
|
||||||
|
setMode("guest")
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAccount(acc: Account) {
|
||||||
|
onAdd({
|
||||||
|
key: nextKey(),
|
||||||
|
kind: "account",
|
||||||
|
accountId: acc.id,
|
||||||
|
firstName: acc.firstName,
|
||||||
|
lastName: acc.lastName,
|
||||||
|
gender: undefined, // ukjent klient-side inntil profilen lastes
|
||||||
|
isGuest: false,
|
||||||
|
hcp: acc.handicap,
|
||||||
|
hcpFromProfile: true,
|
||||||
|
teeId: course?.tees[0]?.id,
|
||||||
|
statLevel: defaultStat,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGuest() {
|
||||||
|
if (first.trim() === "") return
|
||||||
|
onAdd({
|
||||||
|
key: nextKey(),
|
||||||
|
kind: "guest",
|
||||||
|
firstName: first.trim(),
|
||||||
|
lastName: last.trim(),
|
||||||
|
gender,
|
||||||
|
isGuest: true,
|
||||||
|
email: email.trim() || undefined,
|
||||||
|
hcp: hcp.trim() === "" ? null : Number.parseFloat(hcp.replace(",", ".")),
|
||||||
|
teeId: compatibleTees(course, gender)[0]?.id,
|
||||||
|
statLevel: stat,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[var(--nr-accent-ring)] bg-[var(--nr-surface)] p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Legg til medspiller</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
aria-label="Lukk"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--nr-muted)] hover:bg-[var(--nr-surface-2)]"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === "search" ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Field label="Navn på medspilleren" htmlFor="add-search" hint="Skriv minst 2 tegn for å søke.">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--nr-faint)]" aria-hidden="true" />
|
||||||
|
<TextInput
|
||||||
|
id="add-search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Søk etter en konto…"
|
||||||
|
className="pl-9"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{debounced.trim().length >= 2 ? (
|
||||||
|
results.length > 0 ? (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{results.map((a) => (
|
||||||
|
<li key={a.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addAccount(a)}
|
||||||
|
className="flex min-h-14 w-full items-center gap-3 rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] px-3 py-2 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
|
||||||
|
>
|
||||||
|
<Avatar first={a.firstName} last={a.lastName} />
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="block text-sm font-medium text-[var(--nr-ink)]">{a.firstName} {a.lastName}</span>
|
||||||
|
<span className="block text-xs text-[var(--nr-muted)]">{a.handicap != null ? `HCP ${a.handicap}` : "Ingen HCP registrert"}</span>
|
||||||
|
</span>
|
||||||
|
<UserPlus className="h-4 w-4 text-[var(--nr-accent)]" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--nr-muted)]">Ingen kontoer matcher søket.</p>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={switchToGuest}
|
||||||
|
className="text-left text-[13px] font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Legg til uten konto (gjest)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<GuestForm
|
||||||
|
first={first}
|
||||||
|
setFirst={setFirst}
|
||||||
|
last={last}
|
||||||
|
setLast={setLast}
|
||||||
|
email={email}
|
||||||
|
setEmail={setEmail}
|
||||||
|
gender={gender}
|
||||||
|
setGender={setGender}
|
||||||
|
hcp={hcp}
|
||||||
|
setHcp={setHcp}
|
||||||
|
stat={stat}
|
||||||
|
setStat={setStat}
|
||||||
|
firstRef={firstRef}
|
||||||
|
onBackToSearch={() => setMode("search")}
|
||||||
|
onSubmit={addGuest}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuestForm({
|
||||||
|
first,
|
||||||
|
setFirst,
|
||||||
|
last,
|
||||||
|
setLast,
|
||||||
|
email,
|
||||||
|
setEmail,
|
||||||
|
gender,
|
||||||
|
setGender,
|
||||||
|
hcp,
|
||||||
|
setHcp,
|
||||||
|
stat,
|
||||||
|
setStat,
|
||||||
|
firstRef,
|
||||||
|
onBackToSearch,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
first: string
|
||||||
|
setFirst: (v: string) => void
|
||||||
|
last: string
|
||||||
|
setLast: (v: string) => void
|
||||||
|
email: string
|
||||||
|
setEmail: (v: string) => void
|
||||||
|
gender: Gender
|
||||||
|
setGender: (v: Gender) => void
|
||||||
|
hcp: string
|
||||||
|
setHcp: (v: string) => void
|
||||||
|
stat: StatLevel
|
||||||
|
setStat: (v: StatLevel) => void
|
||||||
|
firstRef: React.RefObject<HTMLInputElement | null>
|
||||||
|
onBackToSearch: () => void
|
||||||
|
onSubmit: () => void
|
||||||
|
}) {
|
||||||
|
// kjent-gjest-oppslag på e-post (scoped til DENNE brukeren server-side)
|
||||||
|
const debouncedEmail = useDebouncedValue(email, 350)
|
||||||
|
const [match, setMatch] = useState<KnownGuest | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const e = debouncedEmail.trim()
|
||||||
|
if (e.length < 5 || !e.includes("@")) {
|
||||||
|
setMatch(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
void lookupKnownGuest(e).then((g) => {
|
||||||
|
if (!cancelled) setMatch(g)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [debouncedEmail])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBackToSearch}
|
||||||
|
className="self-start text-[13px] font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
← Søk etter konto i stedet
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label="Fornavn" htmlFor="g-first">
|
||||||
|
<TextInput id="g-first" ref={firstRef} value={first} onChange={(e) => setFirst(e.target.value)} invalid={first.trim() === ""} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Etternavn" optional htmlFor="g-last">
|
||||||
|
<TextInput id="g-last" value={last} onChange={(e) => setLast(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="E-post" optional htmlFor="g-email">
|
||||||
|
<TextInput id="g-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="navn@example.com" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{match ? (
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-[var(--nr-accent-ring)] bg-[var(--nr-accent-soft)] p-3">
|
||||||
|
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-[var(--nr-accent)]" aria-hidden="true" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-[13px] text-[var(--nr-ink)]">
|
||||||
|
Vi fant {match.firstName} {match.lastName} fra en tidligere runde du satte opp.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFirst(match.firstName)
|
||||||
|
setLast(match.lastName)
|
||||||
|
if (match.gender === "m") setGender("mann")
|
||||||
|
else if (match.gender === "f") setGender("kvinne")
|
||||||
|
if (match.hcp != null) setHcp(String(match.hcp))
|
||||||
|
}}
|
||||||
|
className="mt-1 text-[13px] font-semibold text-[var(--nr-accent)] underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Fyll inn automatisk
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Field label="Kjønn">
|
||||||
|
<GenderChoice value={gender} onChange={setGender} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="HCP" optional htmlFor="g-hcp">
|
||||||
|
<TextInput
|
||||||
|
id="g-hcp"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={hcp}
|
||||||
|
onChange={(e) => setHcp(e.target.value)}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Statistikk for denne spilleren">
|
||||||
|
<StatChoice value={stat} onChange={setStat} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={first.trim() === ""}
|
||||||
|
className="mt-1 inline-flex min-h-11 items-center justify-center rounded-lg bg-[var(--nr-accent)] px-5 text-sm font-semibold text-[var(--nr-accent-ink)] transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Legg til gjest
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
function GenderChoice({ value, onChange }: { value: Gender; onChange: (v: Gender) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{GENDERS.map((g) => (
|
||||||
|
<ToggleButton key={g.value} active={value === g.value} onClick={() => onChange(g.value)} className="flex-1">
|
||||||
|
{g.label}
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatChoice({ value, onChange }: { value: StatLevel; onChange: (v: StatLevel) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{STAT.map((s) => (
|
||||||
|
<ToggleButton key={s.value} active={value === s.value} onClick={() => onChange(s.value)} className="flex-1">
|
||||||
|
{s.label}
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
309
frontend/components/ny-runde/step4-teams.tsx
Normal file
309
frontend/components/ny-runde/step4-teams.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { isScrambleVsSolo, isTwoSided } from "@/lib/ny-runde/formats"
|
||||||
|
import type { Player } from "@/lib/ny-runde/types"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ArrowLeftRight, Check, UserMinus } from "lucide-react"
|
||||||
|
import { Avatar, NativeSelect, Panel, Pill, TextInput } from "./primitives"
|
||||||
|
import { useWizard } from "./wizard-context"
|
||||||
|
|
||||||
|
function fullName(p: Player) {
|
||||||
|
return `${p.firstName}${p.lastName ? ` ${p.lastName}` : ""}`.trim() || "Spiller"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step4Teams() {
|
||||||
|
const { state } = useWizard()
|
||||||
|
const f = state.format
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight text-[var(--nr-ink)]">Lag og rekkefølge</h2>
|
||||||
|
<p className="text-sm text-[var(--nr-muted)]">Sett opp lagene for denne spilleformen.</p>
|
||||||
|
</div>
|
||||||
|
{isTwoSided(f) ? <TwoSided /> : f === "moneyball" ? <MoneyBall /> : isScrambleVsSolo(f) ? <ScrambleVsSolo /> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Two-sided A/B */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
function TwoSided() {
|
||||||
|
const { state, patch } = useWizard()
|
||||||
|
const assign = state.sideAssign
|
||||||
|
|
||||||
|
const unassigned = state.players.filter((p) => !assign[p.key])
|
||||||
|
const sideA = state.players.filter((p) => assign[p.key] === "A")
|
||||||
|
const sideB = state.players.filter((p) => assign[p.key] === "B")
|
||||||
|
|
||||||
|
function set(key: string, side: "A" | "B" | undefined) {
|
||||||
|
patch({ sideAssign: { ...assign, [key]: side } })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{unassigned.length > 0 ? (
|
||||||
|
<Panel className="bg-[var(--nr-surface-2)]">
|
||||||
|
<p className="mb-2 text-[13px] font-medium text-[var(--nr-muted)]">Ikke tildelt</p>
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{unassigned.map((p) => (
|
||||||
|
<li key={p.key} className="flex items-center gap-3 rounded-lg bg-[var(--nr-surface)] p-2 pr-3">
|
||||||
|
<Avatar first={p.firstName} last={p.lastName} className="h-8 w-8" />
|
||||||
|
<span className="flex-1 text-sm text-[var(--nr-ink)]">{fullName(p)}</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<AssignBtn onClick={() => set(p.key, "A")}>{state.sideALabel || "Side A"}</AssignBtn>
|
||||||
|
<AssignBtn onClick={() => set(p.key, "B")}>{state.sideBLabel || "Side B"}</AssignBtn>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<SideColumn
|
||||||
|
label={state.sideALabel}
|
||||||
|
placeholder="Side A"
|
||||||
|
suggestion="Rødt lag"
|
||||||
|
onLabel={(v) => patch({ sideALabel: v })}
|
||||||
|
players={sideA}
|
||||||
|
otherSideLabel={state.sideBLabel || "Side B"}
|
||||||
|
onMoveOther={(k) => set(k, "B")}
|
||||||
|
onRemove={(k) => set(k, undefined)}
|
||||||
|
/>
|
||||||
|
<SideColumn
|
||||||
|
label={state.sideBLabel}
|
||||||
|
placeholder="Side B"
|
||||||
|
suggestion="Blått lag"
|
||||||
|
onLabel={(v) => patch({ sideBLabel: v })}
|
||||||
|
players={sideB}
|
||||||
|
otherSideLabel={state.sideALabel || "Side A"}
|
||||||
|
onMoveOther={(k) => set(k, "A")}
|
||||||
|
onRemove={(k) => set(k, undefined)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SideColumn({
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
suggestion,
|
||||||
|
onLabel,
|
||||||
|
players,
|
||||||
|
otherSideLabel,
|
||||||
|
onMoveOther,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
placeholder: string
|
||||||
|
suggestion: string
|
||||||
|
onLabel: (v: string) => void
|
||||||
|
players: Player[]
|
||||||
|
otherSideLabel: string
|
||||||
|
onMoveOther: (key: string) => void
|
||||||
|
onRemove: (key: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] p-3">
|
||||||
|
<TextInput value={label} onChange={(e) => onLabel(e.target.value)} placeholder={placeholder} className="h-10 font-medium" />
|
||||||
|
{!label ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onLabel(suggestion)}
|
||||||
|
className="self-start text-xs font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Foreslå «{suggestion}»
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{players.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-3 py-6 text-center text-xs text-[var(--nr-muted)]">
|
||||||
|
Ingen spillere ennå.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{players.map((p) => (
|
||||||
|
<li key={p.key} className="flex items-center gap-2 rounded-lg bg-[var(--nr-surface-2)] p-2 pr-2">
|
||||||
|
<Avatar first={p.firstName} last={p.lastName} className="h-8 w-8" />
|
||||||
|
<span className="flex-1 truncate text-sm text-[var(--nr-ink)]">{fullName(p)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onMoveOther(p.key)}
|
||||||
|
aria-label={`Flytt ${fullName(p)} til ${otherSideLabel}`}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--nr-muted)] hover:bg-[var(--nr-surface)] hover:text-[var(--nr-accent)]"
|
||||||
|
>
|
||||||
|
<ArrowLeftRight className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(p.key)}
|
||||||
|
aria-label={`Fjern ${fullName(p)} fra laget`}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--nr-muted)] hover:bg-[var(--nr-surface)] hover:text-[var(--nr-danger)]"
|
||||||
|
>
|
||||||
|
<UserMinus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssignBtn({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex min-h-9 items-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface-2)] px-3 text-xs font-medium text-[var(--nr-ink)] transition-colors hover:border-[var(--nr-accent)] hover:text-[var(--nr-accent)]"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Money Ball */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
function MoneyBall() {
|
||||||
|
const { state, patch } = useWizard()
|
||||||
|
return (
|
||||||
|
<Panel>
|
||||||
|
<p className="mb-3 text-sm text-[var(--nr-muted)]">Gi hver spiller en plass i rotasjonen (0–3).</p>
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{state.players.map((p) => (
|
||||||
|
<li key={p.key} className="flex items-center gap-3 rounded-lg bg-[var(--nr-surface-2)] p-2 pr-3">
|
||||||
|
<Avatar first={p.firstName} last={p.lastName} className="h-8 w-8" />
|
||||||
|
<span className="flex-1 truncate text-sm text-[var(--nr-ink)]">{fullName(p)}</span>
|
||||||
|
<NativeSelect
|
||||||
|
aria-label={`Plass for ${fullName(p)}`}
|
||||||
|
value={state.moneyballSlots[p.key] ?? 0}
|
||||||
|
onChange={(e) => patch({ moneyballSlots: { ...state.moneyballSlots, [p.key]: Number.parseInt(e.target.value, 10) } })}
|
||||||
|
className="h-10 w-24 bg-[var(--nr-surface)]"
|
||||||
|
>
|
||||||
|
{[0, 1, 2, 3].map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
Plass {n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Scramble vs solo (asymmetric) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
function ScrambleVsSolo() {
|
||||||
|
const { state, patch } = useWizard()
|
||||||
|
const assign = state.scrambleAssign
|
||||||
|
|
||||||
|
const team = state.players.filter((p) => assign[p.key] === "team")
|
||||||
|
const opponent = state.players.find((p) => assign[p.key] === "opponent")
|
||||||
|
const unplaced = state.players.filter((p) => !assign[p.key])
|
||||||
|
const teamReady = team.length >= 2
|
||||||
|
const opponentFilled = !!opponent
|
||||||
|
|
||||||
|
function set(key: string, slot: "team" | "opponent" | undefined) {
|
||||||
|
patch({ scrambleAssign: { ...assign, [key]: slot } })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* The team — open-ended, emphasized */}
|
||||||
|
<div className="rounded-xl border-2 border-[var(--nr-accent-ring)] bg-[var(--nr-surface)] p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-semibold text-[var(--nr-ink)]">Laget</h3>
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium",
|
||||||
|
teamReady
|
||||||
|
? "border-[var(--nr-ok)]/30 bg-[var(--nr-ok-soft)] text-[var(--nr-ok)]"
|
||||||
|
: "border-[var(--nr-border)] bg-[var(--nr-surface-2)] text-[var(--nr-muted)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{teamReady ? <Check className="h-3 w-3" strokeWidth={3} /> : null}
|
||||||
|
{teamReady ? "Minst 2 spillere lagt til" : "Minst 2 spillere"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{team.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-3 py-6 text-center text-xs text-[var(--nr-muted)]">
|
||||||
|
Legg til spillere fra listen under.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{team.map((p) => (
|
||||||
|
<li key={p.key} className="flex items-center gap-2 rounded-lg bg-[var(--nr-surface-2)] p-2 pr-2">
|
||||||
|
<Avatar first={p.firstName} last={p.lastName} className="h-8 w-8" />
|
||||||
|
<span className="flex-1 truncate text-sm text-[var(--nr-ink)]">{fullName(p)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => set(p.key, undefined)}
|
||||||
|
aria-label={`Fjern ${fullName(p)} fra laget`}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--nr-muted)] hover:bg-[var(--nr-surface)] hover:text-[var(--nr-danger)]"
|
||||||
|
>
|
||||||
|
<UserMinus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Individual opponent — capped at 1, visually distinct single slot */}
|
||||||
|
<div className="rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface-2)] p-4">
|
||||||
|
<h3 className="mb-2 text-sm font-semibold text-[var(--nr-ink)]">Individuell motstander</h3>
|
||||||
|
{opponent ? (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg bg-[var(--nr-surface)] p-2 pr-3 ring-1 ring-inset ring-[var(--nr-border)]">
|
||||||
|
<Avatar first={opponent.firstName} last={opponent.lastName} className="h-10 w-10" />
|
||||||
|
<span className="flex-1 truncate text-sm font-medium text-[var(--nr-ink)]">{fullName(opponent)}</span>
|
||||||
|
<Pill tone="neutral">1 av 1</Pill>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => set(opponent.key, undefined)}
|
||||||
|
aria-label={`Fjern ${fullName(opponent)} som motstander`}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--nr-muted)] hover:bg-[var(--nr-surface-2)] hover:text-[var(--nr-danger)]"
|
||||||
|
>
|
||||||
|
<UserMinus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="rounded-lg border border-dashed border-[var(--nr-border)] px-3 py-5 text-center text-xs text-[var(--nr-muted)]">
|
||||||
|
Én spiller. Velg «Som motstander» på en spiller under.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unplaced */}
|
||||||
|
{unplaced.length > 0 ? (
|
||||||
|
<Panel>
|
||||||
|
<p className="mb-2 text-[13px] font-medium text-[var(--nr-muted)]">Ikke plassert</p>
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{unplaced.map((p) => (
|
||||||
|
<li key={p.key} className="flex items-center gap-2 rounded-lg bg-[var(--nr-surface-2)] p-2 pr-2">
|
||||||
|
<Avatar first={p.firstName} last={p.lastName} className="h-8 w-8" />
|
||||||
|
<span className="flex-1 truncate text-sm text-[var(--nr-ink)]">{fullName(p)}</span>
|
||||||
|
<AssignBtn onClick={() => set(p.key, "team")}>Til laget</AssignBtn>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => set(p.key, "opponent")}
|
||||||
|
disabled={opponentFilled}
|
||||||
|
className="inline-flex min-h-9 items-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface-2)] px-3 text-xs font-medium text-[var(--nr-ink)] transition-colors hover:border-[var(--nr-accent)] hover:text-[var(--nr-accent)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Som motstander
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
frontend/components/ny-runde/step5-sharing.tsx
Normal file
140
frontend/components/ny-runde/step5-sharing.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Lock, Users, Globe } from "lucide-react"
|
||||||
|
import { useWizard, CATEGORY_OPTIONS } from "./wizard-context"
|
||||||
|
import { ChoiceCard } from "./primitives"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { Visibility } from "@/lib/ny-runde/types"
|
||||||
|
|
||||||
|
const VISIBILITY_CARDS: {
|
||||||
|
value: Visibility
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
icon: React.ReactNode
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
value: "private",
|
||||||
|
title: "Privat",
|
||||||
|
description: "Bare du kan se denne runden.",
|
||||||
|
icon: <Lock className="h-5 w-5" aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "friends",
|
||||||
|
title: "Venner",
|
||||||
|
description: "Del med utvalgte kategorier av venner.",
|
||||||
|
icon: <Users className="h-5 w-5" aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "public",
|
||||||
|
title: "Offentlig",
|
||||||
|
description: "Kan ses av alle, også uten innlogging.",
|
||||||
|
icon: <Globe className="h-5 w-5" aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function Step5Sharing() {
|
||||||
|
const { state, patch } = useWizard()
|
||||||
|
|
||||||
|
function toggleCategory(code: string) {
|
||||||
|
const set = new Set(state.categories)
|
||||||
|
if (set.has(code)) set.delete(code)
|
||||||
|
else set.add(code)
|
||||||
|
patch({ categories: Array.from(set) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const allCodes = CATEGORY_OPTIONS.map((c) => c.code)
|
||||||
|
const noneSelected = state.categories.length === 0
|
||||||
|
const allSelected = state.categories.length === allCodes.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<header className="flex flex-col gap-1">
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight text-[var(--nr-ink)]">Deling</h2>
|
||||||
|
<p className="text-sm text-[var(--nr-muted)]">Bestem hvem som kan se denne runden.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<legend className="mb-1 text-[13px] font-medium text-[var(--nr-ink)]">Synlighet</legend>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{VISIBILITY_CARDS.map((c) => (
|
||||||
|
<ChoiceCard
|
||||||
|
key={c.value}
|
||||||
|
selected={state.visibility === c.value}
|
||||||
|
onSelect={() => patch({ visibility: c.value })}
|
||||||
|
title={c.title}
|
||||||
|
description={c.description}
|
||||||
|
icon={c.icon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{state.visibility === "friends" ? (
|
||||||
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<legend className="mb-1 text-[13px] font-medium text-[var(--nr-ink)]">Del med kategorier</legend>
|
||||||
|
<p className="mb-1 text-xs text-[var(--nr-muted)]">
|
||||||
|
Alle er forhåndsvalgt — velg heller bort det du ikke vil skal se runden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-1 flex gap-2" role="group" aria-label="Velg alle eller ingen kategorier">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patch({ categories: [] })}
|
||||||
|
aria-pressed={noneSelected}
|
||||||
|
className={cn(
|
||||||
|
"min-h-9 rounded-full border px-3.5 text-[13px] font-semibold transition-colors",
|
||||||
|
noneSelected
|
||||||
|
? "border-[var(--nr-accent)] bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]"
|
||||||
|
: "border-[var(--nr-border)] bg-[var(--nr-surface)] text-[var(--nr-ink)] hover:border-[var(--nr-border-strong)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Ingen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => patch({ categories: allCodes })}
|
||||||
|
aria-pressed={allSelected}
|
||||||
|
className={cn(
|
||||||
|
"min-h-9 rounded-full border px-3.5 text-[13px] font-semibold transition-colors",
|
||||||
|
allSelected
|
||||||
|
? "border-[var(--nr-accent)] bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]"
|
||||||
|
: "border-[var(--nr-border)] bg-[var(--nr-surface)] text-[var(--nr-ink)] hover:border-[var(--nr-border-strong)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Alle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CATEGORY_OPTIONS.map((opt) => {
|
||||||
|
const active = state.categories.includes(opt.code)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.code}
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={active}
|
||||||
|
onClick={() => toggleCategory(opt.code)}
|
||||||
|
className={cn(
|
||||||
|
"min-h-11 rounded-full border px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40",
|
||||||
|
active
|
||||||
|
? "border-[var(--nr-accent)] bg-[var(--nr-accent-soft)] text-[var(--nr-accent)]"
|
||||||
|
: "border-[var(--nr-border)] bg-[var(--nr-surface)] text-[var(--nr-ink)] hover:border-[var(--nr-border-strong)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{noneSelected ? (
|
||||||
|
<p role="alert" className="rounded-lg border border-[var(--nr-danger)]/40 bg-[var(--nr-danger-soft)] px-3 py-2 text-[13px] font-semibold text-[var(--nr-danger)]">
|
||||||
|
Ingen kategori valgt ennå — ingen venner kan se runden før du velger minst én.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</fieldset>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
480
frontend/components/ny-runde/wizard-context.tsx
Normal file
480
frontend/components/ny-runde/wizard-context.tsx
Normal file
|
|
@ -0,0 +1,480 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"
|
||||||
|
import {
|
||||||
|
API_TO_FORMAT,
|
||||||
|
FORMAT_TO_API,
|
||||||
|
ORDINARY_FORMATS,
|
||||||
|
SCRAMBLE_VS_SOLO,
|
||||||
|
SIDE_IS_UNIT_FORMAT,
|
||||||
|
TWO_SIDED,
|
||||||
|
apiStatLevel,
|
||||||
|
showsTeamStep,
|
||||||
|
} from "@/lib/ny-runde/formats"
|
||||||
|
import { apiGenderOf } from "@/lib/ny-runde/api"
|
||||||
|
import type { ApiGender, Course, CourseMeta, FormatId, Player, StatLevel, Sub1, Visibility } from "@/lib/ny-runde/types"
|
||||||
|
|
||||||
|
/** Samme faste sett/rekkefølge som friends.py sin Category. */
|
||||||
|
export const CATEGORY_OPTIONS = [
|
||||||
|
{ code: "spouse", label: "Make" },
|
||||||
|
{ code: "close_family", label: "Nær familie" },
|
||||||
|
{ code: "extended_family", label: "Storfamilie" },
|
||||||
|
{ code: "close_friends", label: "Nære venner" },
|
||||||
|
{ code: "golf_friends", label: "Golfvenner" },
|
||||||
|
{ code: "colleagues", label: "Kollegaer" },
|
||||||
|
{ code: "business", label: "Forretningsforbindelser" },
|
||||||
|
{ code: "classmates", label: "Studiekamerater" },
|
||||||
|
{ code: "acquaintances", label: "Perifere bekjente" },
|
||||||
|
{ code: "other", label: "Ymse" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export interface WizardState {
|
||||||
|
step: number // 1..5 (logisk; steg 4 kan hoppes over)
|
||||||
|
|
||||||
|
// Auth/profil
|
||||||
|
loadingMe: boolean
|
||||||
|
ownGender: ApiGender | null
|
||||||
|
|
||||||
|
// Steg 1 under-veiviser
|
||||||
|
s1Sub: Sub1
|
||||||
|
ownCreateOrigin: Sub1
|
||||||
|
templateMode: boolean
|
||||||
|
courseSource: "official" | "own"
|
||||||
|
selectedFacilitySlug?: string
|
||||||
|
course?: Course
|
||||||
|
courseMeta?: CourseMeta
|
||||||
|
templateSeed?: Course
|
||||||
|
|
||||||
|
// Steg 1-felt
|
||||||
|
roundName: string
|
||||||
|
teeId?: string
|
||||||
|
date: string
|
||||||
|
teeTime: string
|
||||||
|
startHole: number
|
||||||
|
|
||||||
|
// Steg 2
|
||||||
|
statLevel: StatLevel
|
||||||
|
format: FormatId
|
||||||
|
numHoles: 9 | 18
|
||||||
|
matchExcludeHcp: boolean
|
||||||
|
skinsScoring: "net" | "gross"
|
||||||
|
skinsTie: "carry" | "split"
|
||||||
|
bbbSweep: boolean
|
||||||
|
shambleBestN: number
|
||||||
|
soloAllowance: number
|
||||||
|
useHandicap: boolean
|
||||||
|
useCourseHcpAdj: boolean
|
||||||
|
useMatchplayHcp: boolean
|
||||||
|
hcpPercent: string
|
||||||
|
|
||||||
|
// Steg 3
|
||||||
|
players: Player[]
|
||||||
|
|
||||||
|
// Steg 4
|
||||||
|
sideALabel: string
|
||||||
|
sideBLabel: string
|
||||||
|
sideAssign: Record<string, "A" | "B" | undefined>
|
||||||
|
moneyballSlots: Record<string, number>
|
||||||
|
scrambleAssign: Record<string, "team" | "opponent" | undefined>
|
||||||
|
|
||||||
|
// Steg 5
|
||||||
|
visibility: Visibility
|
||||||
|
categories: string[]
|
||||||
|
|
||||||
|
submitting: boolean
|
||||||
|
submitError: string | null
|
||||||
|
|
||||||
|
// "Legg til en flight til" (migrasjon 035)
|
||||||
|
flightGroupId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayISO() {
|
||||||
|
const now = new Date()
|
||||||
|
const offset = now.getTimezoneOffset()
|
||||||
|
const local = new Date(now.getTime() - offset * 60 * 1000)
|
||||||
|
return local.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
function nowTime() {
|
||||||
|
const d = new Date()
|
||||||
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialState(params: {
|
||||||
|
flightGroupId: string | null
|
||||||
|
prefillPlayedAt?: string
|
||||||
|
prefillStartHole?: string | null
|
||||||
|
prefillHoles?: string | null
|
||||||
|
prefillPlayFormatApi?: string | null
|
||||||
|
}): WizardState {
|
||||||
|
const statLevel: StatLevel = "score"
|
||||||
|
const format = (params.prefillPlayFormatApi && API_TO_FORMAT[params.prefillPlayFormatApi]) || "slagspill"
|
||||||
|
return {
|
||||||
|
step: 1,
|
||||||
|
loadingMe: true,
|
||||||
|
ownGender: null,
|
||||||
|
s1Sub: "source",
|
||||||
|
ownCreateOrigin: "own-search",
|
||||||
|
templateMode: false,
|
||||||
|
courseSource: "official",
|
||||||
|
roundName: "",
|
||||||
|
date: params.prefillPlayedAt ?? todayISO(),
|
||||||
|
teeTime: nowTime(),
|
||||||
|
startHole: params.prefillStartHole ? Number(params.prefillStartHole) : 1,
|
||||||
|
statLevel,
|
||||||
|
format,
|
||||||
|
numHoles: params.prefillHoles === "9" ? 9 : 18,
|
||||||
|
matchExcludeHcp: true,
|
||||||
|
skinsScoring: "net",
|
||||||
|
skinsTie: "carry",
|
||||||
|
bbbSweep: false,
|
||||||
|
shambleBestN: 2,
|
||||||
|
soloAllowance: 100,
|
||||||
|
useHandicap: true,
|
||||||
|
useCourseHcpAdj: true,
|
||||||
|
useMatchplayHcp: true,
|
||||||
|
hcpPercent: "",
|
||||||
|
players: [],
|
||||||
|
sideALabel: "",
|
||||||
|
sideBLabel: "",
|
||||||
|
sideAssign: {},
|
||||||
|
moneyballSlots: {},
|
||||||
|
scrambleAssign: {},
|
||||||
|
visibility: "private",
|
||||||
|
categories: CATEGORY_OPTIONS.map((c) => c.code),
|
||||||
|
submitting: false,
|
||||||
|
submitError: null,
|
||||||
|
flightGroupId: params.flightGroupId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WizardCtx {
|
||||||
|
state: WizardState
|
||||||
|
patch: (p: Partial<WizardState>) => void
|
||||||
|
visibleSteps: number[]
|
||||||
|
goNext: () => void
|
||||||
|
goBack: () => void
|
||||||
|
canProceedStep1: boolean
|
||||||
|
isAdditionalFlight: boolean
|
||||||
|
submit: () => Promise<void>
|
||||||
|
exit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<WizardCtx | null>(null)
|
||||||
|
|
||||||
|
export function WizardProvider({
|
||||||
|
children,
|
||||||
|
onExit,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
onExit: () => void
|
||||||
|
onCreated: (roundId: string) => void
|
||||||
|
}) {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const flightGroupId = searchParams.get("flightGroupId")
|
||||||
|
const prefillPlayedAt = searchParams.get("playedAt") ?? undefined
|
||||||
|
const prefillStartHole = searchParams.get("startHole")
|
||||||
|
const prefillHoles = searchParams.get("holes")
|
||||||
|
const prefillPlayFormatApi = searchParams.get("playFormat")
|
||||||
|
const isAdditionalFlight = Boolean(flightGroupId)
|
||||||
|
|
||||||
|
const [state, setState] = useState<WizardState>(() =>
|
||||||
|
initialState({ flightGroupId, prefillPlayedAt, prefillStartHole, prefillHoles, prefillPlayFormatApi }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const patch = useCallback((p: Partial<WizardState>) => {
|
||||||
|
setState((s) => ({ ...s, ...p }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Egen profil -- eieren av runden er ALLTID første spiller i listen.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function loadMe() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/auth/me", { credentials: "include" })
|
||||||
|
if (res.status === 401) {
|
||||||
|
onExit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data: {
|
||||||
|
gender: ApiGender | "x" | null
|
||||||
|
handicap_index: number | null
|
||||||
|
first_name: string | null
|
||||||
|
last_name: string | null
|
||||||
|
} = await res.json()
|
||||||
|
if (cancelled) return
|
||||||
|
const ownGender = data.gender === "m" || data.gender === "f" ? data.gender : null
|
||||||
|
const ownerPlayer: Player = {
|
||||||
|
key: "owner",
|
||||||
|
kind: "owner",
|
||||||
|
firstName: data.first_name ?? "",
|
||||||
|
lastName: data.last_name ?? "",
|
||||||
|
gender: data.gender === "f" ? "kvinne" : "mann",
|
||||||
|
isGuest: false,
|
||||||
|
hcp: data.handicap_index,
|
||||||
|
hcpFromProfile: true,
|
||||||
|
statLevel: "score",
|
||||||
|
}
|
||||||
|
setState((s) => ({ ...s, ownGender, loadingMe: false, players: [ownerPlayer] }))
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setState((s) => ({ ...s, loadingMe: false }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadMe()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const hasTeamStep = showsTeamStep(state.format)
|
||||||
|
const visibleSteps = useMemo(() => (hasTeamStep ? [1, 2, 3, 4, 5] : [1, 2, 3, 5]), [hasTeamStep])
|
||||||
|
|
||||||
|
const canProceedStep1 = state.s1Sub === "fields" && !!state.course && !!state.teeId && !!state.date
|
||||||
|
|
||||||
|
const goNext = useCallback(() => {
|
||||||
|
setState((s) => {
|
||||||
|
const team = showsTeamStep(s.format)
|
||||||
|
const seq = team ? [1, 2, 3, 4, 5] : [1, 2, 3, 5]
|
||||||
|
const idx = seq.indexOf(s.step)
|
||||||
|
const next = seq[Math.min(idx + 1, seq.length - 1)]
|
||||||
|
return { ...s, step: next }
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Speiler EKSAKT den forrige handleBack() sin steg-1-undertilstandslogikk
|
||||||
|
// (new-round.tsx) -- hver s1Sub har sitt eget, spesifikke tilbake-mål.
|
||||||
|
const goBack = useCallback(() => {
|
||||||
|
setState((s) => {
|
||||||
|
if (s.step === 1) {
|
||||||
|
switch (s.s1Sub) {
|
||||||
|
case "source":
|
||||||
|
onExit()
|
||||||
|
return s
|
||||||
|
case "template-source":
|
||||||
|
return { ...s, s1Sub: "own-create" }
|
||||||
|
case "official-search":
|
||||||
|
return { ...s, s1Sub: s.templateMode ? "template-source" : "source" }
|
||||||
|
case "official-courses":
|
||||||
|
return { ...s, s1Sub: "official-search" }
|
||||||
|
case "own-search":
|
||||||
|
return { ...s, s1Sub: s.templateMode ? "template-source" : "source" }
|
||||||
|
case "own-create":
|
||||||
|
return { ...s, s1Sub: s.ownCreateOrigin }
|
||||||
|
case "fields":
|
||||||
|
return { ...s, s1Sub: s.courseSource === "official" ? "official-courses" : "own-search" }
|
||||||
|
default:
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const team = showsTeamStep(s.format)
|
||||||
|
const seq = team ? [1, 2, 3, 4, 5] : [1, 2, 3, 5]
|
||||||
|
const idx = seq.indexOf(s.step)
|
||||||
|
const prev = seq[Math.max(idx - 1, 0)]
|
||||||
|
return { ...s, step: prev }
|
||||||
|
})
|
||||||
|
}, [onExit])
|
||||||
|
|
||||||
|
// --- Innsending -- speiler EKSAKT new-round.tsx sin submitWizard() -----
|
||||||
|
const submit = useCallback(async () => {
|
||||||
|
const s = state
|
||||||
|
if (!s.course || !s.courseMeta || !s.teeId) return
|
||||||
|
patch({ submitting: true, submitError: null })
|
||||||
|
try {
|
||||||
|
const startedAt = s.teeTime ? new Date(`${s.date}T${s.teeTime}`).toISOString() : null
|
||||||
|
const apiFormat = FORMAT_TO_API[s.format]
|
||||||
|
|
||||||
|
const percentValue = s.hcpPercent.trim() === "" ? undefined : Number(s.hcpPercent.replace(",", "."))
|
||||||
|
const hasPercent = percentValue !== undefined && !Number.isNaN(percentValue)
|
||||||
|
const twoSided = TWO_SIDED.includes(s.format)
|
||||||
|
const matchplayIsDefault = !twoSided || s.useMatchplayHcp
|
||||||
|
let allowanceOverride: Record<string, unknown> | null = null
|
||||||
|
if (!(s.useHandicap && s.useCourseHcpAdj && matchplayIsDefault && !hasPercent)) {
|
||||||
|
const override: Record<string, unknown> = { use_handicap: s.useHandicap, use_course_handicap: s.useCourseHcpAdj }
|
||||||
|
if (twoSided) override.use_matchplay_handicap = s.useMatchplayHcp
|
||||||
|
if (hasPercent) {
|
||||||
|
const fraction = percentValue! / 100
|
||||||
|
override.strategy = SIDE_IS_UNIT_FORMAT.includes(s.format)
|
||||||
|
? { type: "combined", percentage: fraction }
|
||||||
|
: { type: "per_player", percentage: fraction }
|
||||||
|
}
|
||||||
|
allowanceOverride = override
|
||||||
|
}
|
||||||
|
|
||||||
|
const teeName = s.course.tees.find((t) => t.id === s.teeId)?.name ?? ""
|
||||||
|
const owner = s.players.find((p) => p.kind === "owner")
|
||||||
|
|
||||||
|
const shared = {
|
||||||
|
tee_name: teeName,
|
||||||
|
name: s.roundName.trim() || null,
|
||||||
|
played_at: s.date,
|
||||||
|
started_at: startedAt,
|
||||||
|
start_hole: s.startHole,
|
||||||
|
holes_planned: s.numHoles,
|
||||||
|
stat_level: apiStatLevel(owner?.statLevel ?? "score"),
|
||||||
|
play_format: apiFormat,
|
||||||
|
exclude_owner_from_handicap: s.format === "match" ? s.matchExcludeHcp : false,
|
||||||
|
skins_scoring: s.format === "skins" ? s.skinsScoring : null,
|
||||||
|
skins_tie_handling: s.format === "skins" ? s.skinsTie : null,
|
||||||
|
bbb_sweep_bonus_enabled: s.format === "bbb" ? s.bbbSweep : false,
|
||||||
|
shamble_best_n: s.format === "shamble" ? s.shambleBestN : null,
|
||||||
|
scramble_solo_individual_pct: SCRAMBLE_VS_SOLO.includes(s.format) ? s.soloAllowance : 100,
|
||||||
|
visibility_mode: s.visibility === "private" ? "private" : s.visibility === "friends" ? "friends" : "public",
|
||||||
|
visible_categories: s.visibility === "friends" ? s.categories : [],
|
||||||
|
flight_group_id: s.flightGroupId || undefined,
|
||||||
|
allowance_override: allowanceOverride,
|
||||||
|
}
|
||||||
|
const body =
|
||||||
|
s.courseMeta.source === "teeoff"
|
||||||
|
? {
|
||||||
|
course_source: "teeoff",
|
||||||
|
teeoff_facility_slug: s.courseMeta.facilitySlug,
|
||||||
|
teeoff_course_id: s.courseMeta.teeoffCourseId,
|
||||||
|
...shared,
|
||||||
|
}
|
||||||
|
: { course_source: "custom", personal_course_id: s.courseMeta.personalCourseId, ...shared }
|
||||||
|
|
||||||
|
const roundRes = await fetch("/rounds", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
if (!roundRes.ok) throw new Error("create round:" + roundRes.status)
|
||||||
|
const round: { id: string; participants: { id: string; is_owner: boolean }[] } = await roundRes.json()
|
||||||
|
const roundId = round.id
|
||||||
|
const ownerParticipantId = round.participants.find((p) => p.is_owner)?.id ?? round.participants[0]?.id
|
||||||
|
|
||||||
|
const sideIdMap = new Map<string, string>()
|
||||||
|
if (TWO_SIDED.includes(s.format)) {
|
||||||
|
const labels: [string, string][] = [
|
||||||
|
["A", s.sideALabel],
|
||||||
|
["B", s.sideBLabel],
|
||||||
|
]
|
||||||
|
for (const [localId, label] of labels) {
|
||||||
|
const res = await fetch(`/rounds/${roundId}/sides`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ label: label || null }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("create side:" + res.status)
|
||||||
|
const created: { id: string } = await res.json()
|
||||||
|
sideIdMap.set(localId, created.id)
|
||||||
|
}
|
||||||
|
} else if (SCRAMBLE_VS_SOLO.includes(s.format)) {
|
||||||
|
const roles: { localId: "team" | "opponent"; label: string; side_role: "team" | "individual" }[] = [
|
||||||
|
{ localId: "team", label: "Laget", side_role: "team" },
|
||||||
|
{ localId: "opponent", label: "Individuell motstander", side_role: "individual" },
|
||||||
|
]
|
||||||
|
for (const { localId, label, side_role } of roles) {
|
||||||
|
const res = await fetch(`/rounds/${roundId}/sides`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ label, side_role }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("create side:" + res.status)
|
||||||
|
const created: { id: string } = await res.json()
|
||||||
|
sideIdMap.set(localId, created.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sideKeyOf = (p: Player): string | undefined =>
|
||||||
|
TWO_SIDED.includes(s.format) ? s.sideAssign[p.key] : SCRAMBLE_VS_SOLO.includes(s.format) ? s.scrambleAssign[p.key] : undefined
|
||||||
|
|
||||||
|
const participantIdMap = new Map<string, string>([["owner", ownerParticipantId]])
|
||||||
|
for (const p of s.players) {
|
||||||
|
if (p.kind === "owner") continue
|
||||||
|
const teeSelected = p.teeId ? s.course.tees.find((t) => t.id === p.teeId)?.name : undefined
|
||||||
|
const sideKey = sideKeyOf(p)
|
||||||
|
const realSideId = sideKey ? sideIdMap.get(sideKey) : undefined
|
||||||
|
const participantBody: Record<string, unknown> = {
|
||||||
|
stat_level: apiStatLevel(p.statLevel),
|
||||||
|
...(teeSelected ? { tee_name: teeSelected } : {}),
|
||||||
|
...(realSideId ? { round_side_id: realSideId } : {}),
|
||||||
|
}
|
||||||
|
if (p.accountId) {
|
||||||
|
participantBody.user_id = p.accountId
|
||||||
|
} else {
|
||||||
|
participantBody.guest_first_name = p.firstName.trim()
|
||||||
|
participantBody.guest_last_name = p.lastName.trim() || null
|
||||||
|
participantBody.gender = apiGenderOf(p.gender ?? "mann")
|
||||||
|
participantBody.handicap_index = p.hcp ?? null
|
||||||
|
if (p.email && p.email.trim()) participantBody.guest_email = p.email.trim()
|
||||||
|
}
|
||||||
|
const res = await fetch(`/rounds/${roundId}/participants`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(participantBody),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const errBody = await res.json().catch(() => null)
|
||||||
|
throw new Error(errBody?.detail?.message ?? `add participant: ${res.status}`)
|
||||||
|
}
|
||||||
|
const created: { id: string } = await res.json()
|
||||||
|
participantIdMap.set(p.key, created.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TWO_SIDED.includes(s.format) || SCRAMBLE_VS_SOLO.includes(s.format)) {
|
||||||
|
const sideKey = owner ? sideKeyOf(owner) : undefined
|
||||||
|
const realSideId = sideKey ? sideIdMap.get(sideKey) : undefined
|
||||||
|
if (realSideId) {
|
||||||
|
await fetch(`/rounds/${roundId}/participants/${ownerParticipantId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ round_side_id: realSideId }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.format === "moneyball") {
|
||||||
|
for (const p of s.players) {
|
||||||
|
if (s.moneyballSlots[p.key] === undefined) continue
|
||||||
|
const realId = participantIdMap.get(p.key)
|
||||||
|
if (!realId) continue
|
||||||
|
await fetch(`/rounds/${roundId}/participants/${realId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ lineup_order: s.moneyballSlots[p.key] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCreated(roundId)
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : ""
|
||||||
|
patch({
|
||||||
|
submitting: false,
|
||||||
|
submitError: msg && !msg.startsWith("create round:") && !msg.startsWith("create side:") ? msg : "Klarte ikke å opprette runden. Prøv igjen.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [state, patch, onCreated])
|
||||||
|
|
||||||
|
const value: WizardCtx = {
|
||||||
|
state,
|
||||||
|
patch,
|
||||||
|
visibleSteps,
|
||||||
|
goNext,
|
||||||
|
goBack,
|
||||||
|
canProceedStep1,
|
||||||
|
isAdditionalFlight,
|
||||||
|
submit,
|
||||||
|
exit: onExit,
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWizard() {
|
||||||
|
const ctx = useContext(Ctx)
|
||||||
|
if (!ctx) throw new Error("useWizard must be used within WizardProvider")
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ORDINARY_FORMATS }
|
||||||
142
frontend/components/ny-runde/wizard-shell.tsx
Normal file
142
frontend/components/ny-runde/wizard-shell.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Flag } from "lucide-react"
|
||||||
|
import { WizardProvider, useWizard } from "./wizard-context"
|
||||||
|
import { ProgressSteps } from "./progress"
|
||||||
|
import { FooterBar } from "./footer-bar"
|
||||||
|
import { Step1CourseTime } from "./step1-course-time"
|
||||||
|
import { Step2Format } from "./step2-format"
|
||||||
|
import { Step3Players } from "./step3-players"
|
||||||
|
import { Step4Teams } from "./step4-teams"
|
||||||
|
import { Step5Sharing } from "./step5-sharing"
|
||||||
|
|
||||||
|
function StepBody() {
|
||||||
|
const { state } = useWizard()
|
||||||
|
switch (state.step) {
|
||||||
|
case 1:
|
||||||
|
return <Step1CourseTime />
|
||||||
|
case 2:
|
||||||
|
return <Step2Format />
|
||||||
|
case 3:
|
||||||
|
return <Step3Players />
|
||||||
|
case 4:
|
||||||
|
return <Step4Teams />
|
||||||
|
case 5:
|
||||||
|
return <Step5Sharing />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function WizardInner() {
|
||||||
|
const { state, goNext, goBack, canProceedStep1, submit, isAdditionalFlight } = useWizard()
|
||||||
|
|
||||||
|
const isStep1Sub = state.step === 1 && state.s1Sub !== "fields"
|
||||||
|
const isStep1Fields = state.step === 1 && state.s1Sub === "fields"
|
||||||
|
const isLastStep = state.step === 5
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
if (state.submitting) return
|
||||||
|
if (isLastStep) {
|
||||||
|
void submit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
goNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDisabled = isStep1Fields ? !canProceedStep1 : false
|
||||||
|
|
||||||
|
if (state.loadingMe) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center bg-[var(--nr-bg)]">
|
||||||
|
<div aria-hidden="true" className="h-10 w-10 animate-spin rounded-full border-4 border-[var(--nr-accent-ring)] border-t-[var(--nr-accent)]" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.ownGender) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-[var(--nr-bg)] px-5 text-center">
|
||||||
|
<p className="max-w-prose text-[15px] text-[var(--nr-ink)]">
|
||||||
|
Profilen din mangler registrert kjønn, som trengs for å beregne banehandicap riktig.{" "}
|
||||||
|
<Link href="/account" className="font-semibold text-[var(--nr-accent)] underline underline-offset-2">
|
||||||
|
Gå til kontoinnstillinger
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="nr flex min-h-dvh flex-col bg-[var(--nr-bg)]">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-[var(--nr-border)] bg-[var(--nr-surface)]/90 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-2xl flex-col gap-3 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]">
|
||||||
|
<Flag className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold tracking-tight text-[var(--nr-ink)]">Ny runde</span>
|
||||||
|
</div>
|
||||||
|
<ProgressSteps />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isAdditionalFlight ? (
|
||||||
|
<div className="mx-auto mt-3 w-full max-w-2xl px-4">
|
||||||
|
<p className="rounded-lg border border-[var(--nr-accent-ring)] bg-[var(--nr-accent-soft)] px-3 py-2 text-[13px] text-[var(--nr-ink)]">
|
||||||
|
Legger til en ny flight i samme rundegruppe -- dato, starthull, antall hull og spilleform er
|
||||||
|
forhåndsutfylt fra den første flighten, men kan endres.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.submitError ? (
|
||||||
|
<div className="mx-auto mt-3 w-full max-w-2xl px-4">
|
||||||
|
<p role="alert" className="rounded-lg border border-[var(--nr-danger)]/30 bg-[var(--nr-danger-soft)] px-3 py-2 text-[13px] text-[var(--nr-danger)]">
|
||||||
|
{state.submitError}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* pb-28 er reservert klaring for FooterBar (sticky bottom-0) --
|
||||||
|
ALDRI en py-/sm:py-klasse her (Tailwind emitterer sm-varianter i
|
||||||
|
en egen media-blokk etter grunnklassene, så en slik klasse ville
|
||||||
|
slått padding-bottom tilbake ned uansett rekkefølge i selve
|
||||||
|
className-strengen). Reell, browser-verifisert bug denne runden:
|
||||||
|
uten denne klaringen overlapper FooterBar innhold nederst på
|
||||||
|
lengre steg (f.eks. "Legg til gjest"-knappen i steg 3), og sluker
|
||||||
|
klikk stille -- samme feilklasse som er dokumentert i
|
||||||
|
ARCHITECTURE_DECISIONS.md/CHANGELOG.md fra 2026-08-02. En
|
||||||
|
pt-klasse er trygt, den påvirker aldri padding-bottom. */}
|
||||||
|
<main className="mx-auto w-full max-w-2xl flex-1 px-4 pb-28 pt-5">
|
||||||
|
<StepBody />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{!isStep1Sub ? (
|
||||||
|
<FooterBar
|
||||||
|
onBack={goBack}
|
||||||
|
onNext={handleNext}
|
||||||
|
nextDisabled={nextDisabled}
|
||||||
|
backDisabled={state.submitting}
|
||||||
|
submitting={state.submitting}
|
||||||
|
isSubmitStep={isLastStep}
|
||||||
|
nextLabel={isLastStep ? (state.submitting ? "Oppretter…" : "Start runden") : "Neste"}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewRoundWizard() {
|
||||||
|
const router = useRouter()
|
||||||
|
const exit = () => router.push("/my-rounds")
|
||||||
|
const created = (roundId: string) => router.replace(`/my-rounds/${roundId}`)
|
||||||
|
return (
|
||||||
|
<WizardProvider onExit={exit} onCreated={created}>
|
||||||
|
<WizardInner />
|
||||||
|
</WizardProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
175
frontend/lib/ny-runde/api.ts
Normal file
175
frontend/lib/ny-runde/api.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import type { ApiCourseHole, ApiCourseTee } from "@/components/course-template-editor"
|
||||||
|
import type { Account, ApiGender, Course, Facility, Gender, KnownGuest, Tee } from "./types"
|
||||||
|
|
||||||
|
// --- Kjønn-konvertering (samme mønster som new-round.tsx) ------------------
|
||||||
|
|
||||||
|
export function apiGenderOf(g: Gender): ApiGender | "x" {
|
||||||
|
return g === "mann" ? "m" : g === "kvinne" ? "f" : "x"
|
||||||
|
}
|
||||||
|
export function uiGenderOf(g: ApiGender): Gender {
|
||||||
|
return g === "m" ? "mann" : "kvinne"
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rå API-typer (kun det denne veiviseren faktisk leser) -----------------
|
||||||
|
|
||||||
|
type ApiTeeOption = { name: string; genders: ApiGender[] }
|
||||||
|
type ApiFacility = { slug: string; name: string; city: string | null; county: string | null }
|
||||||
|
type ApiNearbyFacility = ApiFacility & { distance_km: number }
|
||||||
|
type ApiOfficialCourseOption = {
|
||||||
|
teeoff_course_id: number
|
||||||
|
name: string
|
||||||
|
is_main_course: boolean
|
||||||
|
tees: ApiTeeOption[]
|
||||||
|
holes: ApiCourseHole[]
|
||||||
|
full_tees: ApiCourseTee[]
|
||||||
|
}
|
||||||
|
type ApiPersonalCourse = { id: string; name: string; is_mine: boolean; created_by_display_name: string }
|
||||||
|
type ApiPersonalCourseDetail = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
tees: ApiTeeOption[]
|
||||||
|
holes: ApiCourseHole[]
|
||||||
|
full_tees: ApiCourseTee[]
|
||||||
|
is_mine: boolean
|
||||||
|
created_by_display_name: string
|
||||||
|
forked_from_id: string | null
|
||||||
|
}
|
||||||
|
export type PersonMatch = {
|
||||||
|
id: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
avatar_url: string | null
|
||||||
|
home_club: string | null
|
||||||
|
handicap_index: number | null
|
||||||
|
}
|
||||||
|
type ApiKnownGuest = {
|
||||||
|
guest_first_name: string
|
||||||
|
guest_last_name: string | null
|
||||||
|
gender: ApiGender | null
|
||||||
|
handicap_index: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
let idSeq = 0
|
||||||
|
function newId(prefix: string) {
|
||||||
|
idSeq += 1
|
||||||
|
return `${prefix}-${idSeq}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiTeesToTees(apiTees: ApiTeeOption[]): Tee[] {
|
||||||
|
return apiTees.map((t, i) => ({ id: `${t.name}-${i}`, name: t.name, genders: t.genders }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function courseFromOfficial(teeoffCourseId: number, c: {
|
||||||
|
name: string
|
||||||
|
tees: ApiTeeOption[]
|
||||||
|
holes: ApiCourseHole[]
|
||||||
|
full_tees: ApiCourseTee[]
|
||||||
|
}): Course {
|
||||||
|
return {
|
||||||
|
id: `teeoff-${teeoffCourseId}`,
|
||||||
|
name: c.name,
|
||||||
|
tees: apiTeesToTees(c.tees),
|
||||||
|
templateHoles: c.holes,
|
||||||
|
templateTees: c.full_tees,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Offisielt banesøk -------------------------------------------------------
|
||||||
|
|
||||||
|
export async function searchOfficialFacilities(query: string): Promise<Facility[]> {
|
||||||
|
const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query)}`, { credentials: "include" })
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data: ApiFacility[] = await res.json()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function nearbyOfficialFacilities(lat: number, lng: number): Promise<(Facility & { distanceKm: number })[]> {
|
||||||
|
const res = await fetch(`/rounds/official-search/nearby?lat=${lat}&lng=${lng}&limit=5`, { credentials: "include" })
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data: ApiNearbyFacility[] = await res.json()
|
||||||
|
return data.map((f) => ({ slug: f.slug, name: f.name, city: f.city, county: f.county, distanceKm: f.distance_km }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFacilityCourses(slug: string): Promise<{ teeoffCourseId: number; course: Course }[]> {
|
||||||
|
const res = await fetch(`/rounds/official-search/${slug}`, { credentials: "include" })
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data: { slug: string; name: string; courses: ApiOfficialCourseOption[] } = await res.json()
|
||||||
|
return data.courses.map((c) => ({
|
||||||
|
teeoffCourseId: c.teeoff_course_id,
|
||||||
|
course: courseFromOfficial(c.teeoff_course_id, c),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Egne baner ---------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function searchOwnCourses(query: string): Promise<{ id: string; name: string }[]> {
|
||||||
|
const res = await fetch(`/personal-courses?q=${encodeURIComponent(query)}`, { credentials: "include" })
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data: ApiPersonalCourse[] = await res.json()
|
||||||
|
return data.map((c) => ({ id: c.id, name: c.name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOwnCourse(id: string): Promise<Course | null> {
|
||||||
|
const res = await fetch(`/personal-courses/${id}`, { credentials: "include" })
|
||||||
|
if (!res.ok) return null
|
||||||
|
const c: ApiPersonalCourseDetail = await res.json()
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
tees: apiTeesToTees(c.tees),
|
||||||
|
templateHoles: c.holes,
|
||||||
|
templateTees: c.full_tees,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOwnCourse(body: {
|
||||||
|
name: string
|
||||||
|
holes: { hole_number: number; par: number; stroke_index: number }[]
|
||||||
|
tees: { name: string; ratings: { gender: ApiGender; course_rating: number; slope_rating: number; par: number }[] }[]
|
||||||
|
forkedFromId?: string | null
|
||||||
|
}): Promise<Course> {
|
||||||
|
const res = await fetch("/personal-courses", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: body.name,
|
||||||
|
holes: body.holes,
|
||||||
|
tees: body.tees,
|
||||||
|
forked_from_id: body.forkedFromId ?? null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`create: ${res.status}`)
|
||||||
|
const created: { id: string; name: string } = await res.json()
|
||||||
|
return {
|
||||||
|
id: created.id,
|
||||||
|
name: created.name,
|
||||||
|
tees: body.tees.map((t) => ({
|
||||||
|
id: newId(t.name),
|
||||||
|
name: t.name,
|
||||||
|
genders: t.ratings.map((r) => r.gender),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Spillersøk / kjent gjest -------------------------------------------------
|
||||||
|
|
||||||
|
export async function searchAccounts(query: string): Promise<Account[]> {
|
||||||
|
const res = await fetch(`/people/search?q=${encodeURIComponent(query)}`, { credentials: "include" })
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data: PersonMatch[] = await res.json()
|
||||||
|
return data.map((p) => ({ id: p.id, firstName: p.first_name, lastName: p.last_name, handicap: p.handicap_index }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lookupKnownGuest(email: string): Promise<KnownGuest | null> {
|
||||||
|
const res = await fetch(`/rounds/guests/known?email=${encodeURIComponent(email)}`, { credentials: "include" })
|
||||||
|
if (!res.ok) return null
|
||||||
|
const g: ApiKnownGuest | null = await res.json()
|
||||||
|
if (!g) return null
|
||||||
|
return {
|
||||||
|
firstName: g.guest_first_name,
|
||||||
|
lastName: g.guest_last_name ?? "",
|
||||||
|
gender: g.gender,
|
||||||
|
hcp: g.handicap_index,
|
||||||
|
}
|
||||||
|
}
|
||||||
156
frontend/lib/ny-runde/formats.ts
Normal file
156
frontend/lib/ny-runde/formats.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import type { FormatId } from "./types"
|
||||||
|
|
||||||
|
export interface FormatDef {
|
||||||
|
id: FormatId
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FORMATS: FormatDef[] = [
|
||||||
|
{ id: "slagspill", label: "Slagspill", description: "Vanlig individuell runde." },
|
||||||
|
{
|
||||||
|
id: "stableford",
|
||||||
|
label: "Stableford",
|
||||||
|
description: "Poeng per hull i stedet for slag – kan plukke opp ved 0 poeng.",
|
||||||
|
},
|
||||||
|
{ id: "match", label: "Match", description: "To sider, hull mot hull." },
|
||||||
|
{ id: "skins", label: "Skins", description: "Hvert hull er en egen premie – minst 3 spillere." },
|
||||||
|
{ id: "fourball", label: "Fourball", description: "To mot to, beste ball per side." },
|
||||||
|
{ id: "foursome", label: "Foursome", description: "To mot to, én delt ball, annenhver slår." },
|
||||||
|
{
|
||||||
|
id: "greensome",
|
||||||
|
label: "Greensome",
|
||||||
|
description: "To mot to, begge slår ut, velger så én ball.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scramble2",
|
||||||
|
label: "Scramble (2)",
|
||||||
|
description: "To mot to, velger beste slag hver gang.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scramble4",
|
||||||
|
label: "Scramble (4)",
|
||||||
|
description: "Fire mot fire, velger beste slag hver gang.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chapman",
|
||||||
|
label: "Chapman",
|
||||||
|
description: "To mot to, bytter utslag, velger så én ball – som foursome fra andre slag.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "copenhagen",
|
||||||
|
label: "Københavner",
|
||||||
|
description: "Nøyaktig 3 spillere – 6 poeng deles per hull etter rangering.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bbb",
|
||||||
|
label: "Bingo Bango Bongo",
|
||||||
|
description: "Poeng for først på green, nærmest hull, først i hull.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "flag",
|
||||||
|
label: "Flaggturnering",
|
||||||
|
description: "Fast slagbudsjett (par + HCP) – flagget plantes der slagene tar slutt.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "shamble",
|
||||||
|
label: "Shamble",
|
||||||
|
description: "2-4 spillere, alle slår ut, velger beste – spiller så egen ball inn.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "moneyball",
|
||||||
|
label: "Money Ball",
|
||||||
|
description: "Fire spillere, én roterer som 'money ball' per hull.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "highlowhigh",
|
||||||
|
label: "High-low-high",
|
||||||
|
description: "To lag à to – høy mot høy, lav mot lav, per hull.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scramblevssolo",
|
||||||
|
label: "Scramble mot enkeltspiller",
|
||||||
|
description:
|
||||||
|
"Et lag med delt ball (minst 2 spillere) mot én enkeltspiller — nettoscore avgjør.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scramblevssolomatch",
|
||||||
|
label: "Scramble mot enkeltspiller (matchspill)",
|
||||||
|
description: "Samme oppsett som over, men avgjort hull for hull i stedet for netto totalsum.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const FORMAT_MAP: Record<FormatId, FormatDef> = FORMATS.reduce(
|
||||||
|
(acc, f) => {
|
||||||
|
acc[f.id] = f
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<FormatId, FormatDef>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Two-sided team formats (drive the standard step-4 A/B layout). */
|
||||||
|
export const TWO_SIDED: FormatId[] = [
|
||||||
|
"match",
|
||||||
|
"fourball",
|
||||||
|
"foursome",
|
||||||
|
"greensome",
|
||||||
|
"scramble2",
|
||||||
|
"scramble4",
|
||||||
|
"chapman",
|
||||||
|
"highlowhigh",
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SCRAMBLE_VS_SOLO: FormatId[] = ["scramblevssolo", "scramblevssolomatch"]
|
||||||
|
|
||||||
|
// Samme sett som app/handicap.py sin SIDE_IS_UNIT (+ chapman-aliaset) --
|
||||||
|
// avgjør om HCP-prosenten bygges som "combined" (delt enhet) eller
|
||||||
|
// "per_player" i allowance_override.strategy.
|
||||||
|
export const SIDE_IS_UNIT_FORMAT: FormatId[] = ["foursome", "greensome", "scramble2", "scramble4", "chapman"]
|
||||||
|
|
||||||
|
// "Bruk course handicap-justering" er skjult for disse to (ingen reell
|
||||||
|
// nytteverdi), men holdes funksjonelt PÅ uansett -- se wizard-context.tsx.
|
||||||
|
export const ORDINARY_FORMATS: FormatId[] = ["slagspill", "stableford"]
|
||||||
|
|
||||||
|
export function isTwoSided(f: FormatId): boolean {
|
||||||
|
return TWO_SIDED.includes(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isScrambleVsSolo(f: FormatId): boolean {
|
||||||
|
return SCRAMBLE_VS_SOLO.includes(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the Teams & order step (4) is shown for a given format. */
|
||||||
|
export function showsTeamStep(f: FormatId): boolean {
|
||||||
|
return isTwoSided(f) || f === "moneyball" || isScrambleVsSolo(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wizardens FormatId -> ekte round.play_format-verdi (RoundCreate).
|
||||||
|
export const FORMAT_TO_API: Record<FormatId, string> = {
|
||||||
|
slagspill: "stroke",
|
||||||
|
stableford: "stableford",
|
||||||
|
match: "match",
|
||||||
|
skins: "skins",
|
||||||
|
fourball: "fourball",
|
||||||
|
foursome: "foursome",
|
||||||
|
greensome: "greensome",
|
||||||
|
scramble2: "scramble_2",
|
||||||
|
scramble4: "scramble_4",
|
||||||
|
chapman: "chapman",
|
||||||
|
copenhagen: "copenhagen",
|
||||||
|
bbb: "bbb",
|
||||||
|
flag: "flag",
|
||||||
|
shamble: "shamble",
|
||||||
|
moneyball: "money_ball",
|
||||||
|
highlowhigh: "high_low_high",
|
||||||
|
scramblevssolo: "scramble_solo",
|
||||||
|
scramblevssolomatch: "scramble_solo_match",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const API_TO_FORMAT: Record<string, FormatId> = Object.fromEntries(
|
||||||
|
Object.entries(FORMAT_TO_API).map(([k, v]) => [v, k as FormatId]),
|
||||||
|
) as Record<string, FormatId>
|
||||||
|
|
||||||
|
export function apiStatLevel(s: StatLevelLike): "strokes_only" | "strokes_and_putts" | "full" {
|
||||||
|
return s === "score" ? "strokes_only" : s === "score_putts" ? "strokes_and_putts" : "full"
|
||||||
|
}
|
||||||
|
type StatLevelLike = "score" | "score_putts" | "full"
|
||||||
110
frontend/lib/ny-runde/types.ts
Normal file
110
frontend/lib/ny-runde/types.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import type { ApiCourseHole, ApiCourseTee } from "@/components/course-template-editor"
|
||||||
|
|
||||||
|
export type ApiGender = "m" | "f"
|
||||||
|
export type Gender = "mann" | "kvinne" | "annet"
|
||||||
|
|
||||||
|
export type StatLevel = "score" | "score_putts" | "full"
|
||||||
|
|
||||||
|
/** Banens utslag -- ekte API-et eksponerer KUN hvilke(t) kjønn utslaget har
|
||||||
|
* rating for (`genders`), ikke selve CR/Slope-tallene til klienten (de
|
||||||
|
* brukes kun server-side til handicap-beregning). */
|
||||||
|
export interface Tee {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
genders: ApiGender[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Course {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
tees: Tee[]
|
||||||
|
/** Fullt hull-/utslagoppsett for "bruk som mal"-broen -- fraværende/tom
|
||||||
|
* når kilden (teeoff) ikke har komplette nok data til å brukes som mal. */
|
||||||
|
templateHoles?: ApiCourseHole[]
|
||||||
|
templateTees?: ApiCourseTee[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Facility {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
city: string | null
|
||||||
|
county: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hvilken bane-kilde+id som faktisk skal sendes til /rounds. */
|
||||||
|
export type CourseMeta =
|
||||||
|
| { source: "teeoff"; facilitySlug: string; teeoffCourseId: number }
|
||||||
|
| { source: "custom"; personalCourseId: string }
|
||||||
|
|
||||||
|
/** En bane funnet i et offisielt anlegg (fra /rounds/official-search/{slug}). */
|
||||||
|
export interface OfficialCourseOption {
|
||||||
|
teeoffCourseId: number
|
||||||
|
course: Course
|
||||||
|
}
|
||||||
|
|
||||||
|
/** En konto som kan søkes opp og lenkes inn i runden. */
|
||||||
|
export interface Account {
|
||||||
|
id: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
handicap: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** En gjest brukeren har registrert før (scoped til denne brukeren). */
|
||||||
|
export interface KnownGuest {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
gender: ApiGender | null
|
||||||
|
hcp: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlayerKind = "owner" | "account" | "guest"
|
||||||
|
|
||||||
|
export interface Player {
|
||||||
|
key: string // stabil lokal nøkkel
|
||||||
|
kind: PlayerKind
|
||||||
|
accountId?: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
gender?: Gender // kjent for eier/gjest; ukjent for nettopp lenkede kontoer
|
||||||
|
isGuest: boolean
|
||||||
|
email?: string
|
||||||
|
hcp?: number | null // gjest/eier numerisk; null når ukjent
|
||||||
|
hcpFromProfile?: boolean
|
||||||
|
teeId?: string
|
||||||
|
statLevel: StatLevel
|
||||||
|
/** "A" | "B" for to-sidede formater, "team" | "solo" for scramble-mot-solo. */
|
||||||
|
sideId?: string
|
||||||
|
moneyBallSlot?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormatId =
|
||||||
|
| "slagspill"
|
||||||
|
| "stableford"
|
||||||
|
| "match"
|
||||||
|
| "skins"
|
||||||
|
| "fourball"
|
||||||
|
| "foursome"
|
||||||
|
| "greensome"
|
||||||
|
| "scramble2"
|
||||||
|
| "scramble4"
|
||||||
|
| "chapman"
|
||||||
|
| "copenhagen"
|
||||||
|
| "bbb"
|
||||||
|
| "flag"
|
||||||
|
| "shamble"
|
||||||
|
| "moneyball"
|
||||||
|
| "highlowhigh"
|
||||||
|
| "scramblevssolo"
|
||||||
|
| "scramblevssolomatch"
|
||||||
|
|
||||||
|
export type Visibility = "private" | "friends" | "public"
|
||||||
|
|
||||||
|
export type Sub1 =
|
||||||
|
| "source"
|
||||||
|
| "official-search"
|
||||||
|
| "official-courses"
|
||||||
|
| "own-search"
|
||||||
|
| "own-create"
|
||||||
|
| "template-source"
|
||||||
|
| "fields"
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue