"use client" import { useEffect, useRef, useState } from "react" import dynamic from "next/dynamic" import { Crosshair, LocateFixed, MapPin, X, ChevronLeft, Check } from "lucide-react" import { ClubPicker } from "@/components/teecup/club-picker" import { Textarea } from "@/components/ui/textarea" import { cn } from "@/lib/utils" /** * Lazy, client-only reference to the map step. Because dynamic() defers the * underlying import() until is actually rendered, neither * this module nor a GPS-only run ever pulls in mapbox-gl. A Mapbox map load * (which is billed) only happens once the user chooses "Velg punkt på kart". */ const MapPointPicker = dynamic( () => import("@/components/shot/map-point-picker").then((m) => m.MapPointPicker), { ssr: false, loading: () => (
), }, ) type LngLat = { lng: number; lat: number } type Step = "start" | "map" | "end" | "club" | "result" type GeoStatus = "idle" | "loading" | "error" export type ShotMeasurementSheetProps = { holeNumber: number existingShotCount: number ownBagClubs: string[] /** * Computed by the caller (Haversine) once both points are known. Optional * here so the sheet stays previewable; when absent a rough preview-only * stub is used purely for display. */ distanceMeters?: number /** Vises som en feilmelding i resultat-steget, f.eks. når serveren avviste * innsendingen (ADR-048: reell bug 2026-08-08 -- uten dette lukket arket * seg stille ved en avvist innsending, ingen feil synlig for brukeren). */ submitError?: string | null submitting?: boolean onSubmit: (result: { club: string distanceMeters: number startMethod: "gps" | "map_tap" startLat: number startLng: number endLat: number endLng: number share: boolean shareText: string }) => void onCancel: () => void } // Preview-only rough distance so the result step shows a real number in v0. // The real app passes distanceMeters computed server-/caller-side. function previewDistance(a: LngLat | null, b: LngLat | null): number { if (!a || !b) return 0 const R = 6371000 const toRad = (d: number) => (d * Math.PI) / 180 const dLat = toRad(b.lat - a.lat) const dLng = toRad(b.lng - a.lng) const lat1 = toRad(a.lat) const lat2 = toRad(b.lat) const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2 return Math.round(2 * R * Math.asin(Math.sqrt(h))) } export function ShotMeasurementSheet({ holeNumber, existingShotCount, ownBagClubs, distanceMeters, submitError, submitting, onSubmit, onCancel, }: ShotMeasurementSheetProps) { const [step, setStep] = useState("start") const [startMethod, setStartMethod] = useState<"gps" | "map_tap" | null>(null) const [startPoint, setStartPoint] = useState(null) const [endPoint, setEndPoint] = useState(null) const [startStatus, setStartStatus] = useState("idle") const [endStatus, setEndStatus] = useState("idle") const [club, setClub] = useState("") const [share, setShare] = useState(false) const [shareText, setShareText] = useState("") const shareEdited = useRef(false) function acquirePosition(onOk: (p: LngLat) => void, onFail: () => void) { if (typeof navigator === "undefined" || !navigator.geolocation) { onFail() return } navigator.geolocation.getCurrentPosition( (pos) => onOk({ lng: pos.coords.longitude, lat: pos.coords.latitude }), () => onFail(), { enableHighAccuracy: true, timeout: 10000 }, ) } // Start via device GPS. function useMyPosition() { setStartStatus("loading") acquirePosition( (p) => { setStartPoint(p) setStartMethod("gps") setStartStatus("idle") setStep("end") }, () => setStartStatus("error"), ) } // Ball position is ALWAYS GPS, regardless of how the start was chosen. // Deliberately NOT auto-fired on step mount (real bug found 2026-08-08: // firing immediately gave the user zero time to actually walk from the // start point to the ball, so start/end ended up at ~the same spot and // ~the same instant -- distance was always 0m). Requires an explicit // "Jeg er ved ballen nå" tap once the user has actually walked there. function measureEndPoint() { setEndStatus("loading") acquirePosition( (p) => { setEndPoint(p) setEndStatus("idle") setStep("club") }, () => setEndStatus("error"), ) } const distance = typeof distanceMeters === "number" ? distanceMeters : previewDistance(startPoint, endPoint) // Forhåndsvisning av satellittutsnittet FØR lagring (brukerønske // 2026-08-08, ikke opprinnelig planlagt -- selve DELINGEN genererer sitt // eget, server-side bilde med samme markører+linje uavhengig av dette). // Bygget client-side med det OFFENTLIGE tokenet (trygt i nettleseren, // URL-restriktert) -- ingen server-tur-retur nødvendig kun for en // forhåndsvisning. const previewUrl = startPoint && endPoint && process.env.NEXT_PUBLIC_MAPBOX_TOKEN ? `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/pin-s-a+ff5a1f(${startPoint.lng},${startPoint.lat}),pin-s-b+2f7a3f(${endPoint.lng},${endPoint.lat})/auto/500x300@2x?padding=50&access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}` : null // Prefill the (editable) share text once the result step is reached. useEffect(() => { if (step === "result" && !shareEdited.current) { setShareText(`⛳ ${club || "slag"}, ${distance} m – hull ${holeNumber}`) } }, [step, club, distance, holeNumber]) function goBack() { if (step === "map" || step === "end") { setStartStatus("idle") setEndStatus("idle") setStep("start") } else if (step === "club") { // Re-measure the ball position rather than reusing a stale fix. setEndPoint(null) setStep("end") } else if (step === "result") { setStep("club") } } function confirmSubmit() { if (!startPoint || !endPoint || !club) return onSubmit({ club, distanceMeters: distance, startMethod: startMethod ?? "gps", startLat: startPoint.lat, startLng: startPoint.lng, endLat: endPoint.lat, endLng: endPoint.lng, share, shareText: share ? shareText : "", }) } const onMapStep = step === "map" return (
{/* Header */}
{step !== "start" ? ( ) : null}

{`Mål et slag — hull ${holeNumber}`}

{existingShotCount > 0 ? (

{`${existingShotCount} slag målt tidligere`}

) : null}
{/* Main */}
{step === "start" ? (

Velg hvor slaget startet. Ballens sluttposisjon måles alltid med GPS.

{startStatus === "loading" ? (

Finner posisjonen din …

) : null} {startStatus === "error" ? (

Fant ikke posisjonen din. Sjekk at posisjonstjenester er på, og prøv igjen.

) : null}
) : null} {step === "map" ? { setStartPoint(p) setStep("end") }} /> : null} {step === "end" ? (
{endStatus === "idle" ? ( <>

Gå til der ballen ligger, og trykk når du er fremme.

) : null} {endStatus === "loading" ? ( <>

Finner posisjonen din …

Stå ved ballen mens vi måler sluttpunktet.

) : null} {endStatus === "error" ? (

Kunne ikke måle ballposisjonen. Prøv igjen når du står ved ballen.

) : null}
) : null} {step === "club" ? (

Hvilken kølle brukte du?

Velg køllen for dette slaget.

{/* Reuses the shared ClubPicker (imported, not rebuilt). */}
) : null} {step === "result" ? (
{previewUrl ? ( // eslint-disable-next-line @next/next/no-img-element -- ekstern Mapbox-URL, ikke next/image-verdt Satellittutsnitt av slaget, med start- og sluttpunkt markert ) : null}
Målt lengde {distance} m {club ? ( {club} ) : null}
{/* Private vs share segmented control */}
Deling
{[ { key: false, label: "Behold privat" }, { key: true, label: "Del i feeden" }, ].map((opt) => { const selected = share === opt.key return ( ) })}
{share ? (