"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 moneyballSlots: Record scrambleAssign: Record // 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) => void visibleSteps: number[] goNext: () => void goBack: () => void canProceedStep1: boolean isAdditionalFlight: boolean submit: () => Promise exit: () => void } const Ctx = createContext(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(() => initialState({ flightGroupId, prefillPlayedAt, prefillStartHole, prefillHoles, prefillPlayFormatApi }), ) const patch = useCallback((p: Partial) => { 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 }, []) // 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]) 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 "international-search": return { ...s, s1Sub: "own-search" } 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 | null = null if (!(s.useHandicap && s.useCourseHcpAdj && matchplayIsDefault && !hasPercent)) { const override: Record = { 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() 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([["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 = { 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 {children} } export function useWizard() { const ctx = useContext(Ctx) if (!ctx) throw new Error("useWizard must be used within WizardProvider") return ctx } export { ORDINARY_FORMATS }