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
|
|
|
"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
|
|
|
|
|
}, [])
|
|
|
|
|
|
2026-08-15 07:20:20 +02:00
|
|
|
// Eierens EGET teeId/statLevel-felt i players[] (lest av steg 3 sitt
|
|
|
|
|
// spillerkort, OG -- for statLevel -- av submit() sin faktiske innsending,
|
|
|
|
|
// se linje ~317) er egne, atskilte felt fra state.teeId/state.statLevel
|
|
|
|
|
// (steg 1/steg 2 sine felt). De ble tidligere kun synkronisert ÉN gang,
|
|
|
|
|
// idet banen først velges (pickCourse i step1-course-time.tsx) -- endrer
|
|
|
|
|
// brukeren utslag i "Bane & tid"-bekreftelsen ETTERPÅ, eller statistikknivå
|
|
|
|
|
// i "Spilleform", flyter de to fra hverandre. For utslag var dette rent
|
|
|
|
|
// kosmetisk (steg 3 viste et gammelt utslag, men submit() leser
|
|
|
|
|
// state.teeId direkte for eieren) -- for statistikknivå var det en REELL
|
|
|
|
|
// datafeil: "Statistikk for deg selv"-valget i steg 2 hadde INGEN effekt
|
|
|
|
|
// på selve innsendingen for eieren (kun på default for NYE medspillere via
|
|
|
|
|
// defaultStat-proppen), siden owner.statLevel forble hardkodet "score" fra
|
|
|
|
|
// loadMe() over. Bruker-rapportert regresjon 2026-08-15 (video vedlagt) --
|
|
|
|
|
// holder nå de to i kontinuerlig synk fremfor kun ved første valg.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setState((s) => {
|
|
|
|
|
const owner = s.players.find((p) => p.kind === "owner")
|
|
|
|
|
if (!owner || (owner.teeId === s.teeId && owner.statLevel === s.statLevel)) return s
|
|
|
|
|
return {
|
|
|
|
|
...s,
|
|
|
|
|
players: s.players.map((p) => (p.kind === "owner" ? { ...p, teeId: s.teeId, statLevel: s.statLevel } : p)),
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}, [state.teeId, state.statLevel])
|
|
|
|
|
|
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
|
|
|
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" }
|
2026-08-13 07:39:29 +02:00
|
|
|
case "international-search":
|
|
|
|
|
return { ...s, s1Sub: "own-search" }
|
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
|
|
|
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
|
2026-08-21 08:05:05 +02:00
|
|
|
// Overstyring fra FØRSTE stund (2026-08-20 brukerfunn) -- backend
|
|
|
|
|
// sin add_participant faller tilbake til spillerens profil-HCP
|
|
|
|
|
// når dette utelates/er null, akkurat som PATCH-en "Runde og
|
|
|
|
|
// runde"-panelet allerede bruker for samme overstyring senere.
|
|
|
|
|
// Uendret (`p.hcp` = profilverdien) sendes derfor trygt som
|
|
|
|
|
// no-op når organisatoren ikke har rørt feltet.
|
|
|
|
|
participantBody.handicap_index = p.hcp ?? null
|
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
|
|
|
} 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 }
|