teecup/frontend/components/ny-runde/wizard-context.tsx

483 lines
17 KiB
TypeScript
Raw Normal View History

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