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.
140 lines
5.2 KiB
TypeScript
140 lines
5.2 KiB
TypeScript
"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>
|
|
)
|
|
}
|