teecup/frontend/components/ny-runde/step5-sharing.tsx
Erol Haagenrud 11d10f7661 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.
2026-08-08 12:34:25 +02:00

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>
)
}