diff --git a/frontend/components/round-detail.tsx b/frontend/components/round-detail.tsx index 44fa795..2c639e4 100644 --- a/frontend/components/round-detail.tsx +++ b/frontend/components/round-detail.tsx @@ -30,6 +30,7 @@ import { Minus, Plus, RefreshCw, + Ruler, Search, Settings2, Target, @@ -45,6 +46,8 @@ import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/sc import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { cn } from "@/lib/utils" +import { ClubPicker } from "@/components/teecup/club-picker" +import { ShotMeasurementSheet } from "@/components/shot/shot-measurement-sheet" import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue" // --- Types ----------------------------------------------------------------- @@ -1638,6 +1641,7 @@ export function RoundDetail({ roundId }: { roundId: string }) { )} {wizardPlayer && hole && ( { + let cancelled = false + fetch(`/rounds/${roundId}/participants/${player.id}/holes/${hole.holeNumber}/shots`, { credentials: "include" }) + .then((res) => (res.ok ? res.json() : [])) + .then((shots: unknown[]) => { + if (!cancelled) setShotCount(shots.length) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [roundId, player.id, hole.holeNumber]) + + async function submitShot(result: { + club: string + distanceMeters: number + startMethod: "gps" | "map_tap" + startLat: number + startLng: number + endLat: number + endLng: number + share: boolean + shareText: string + }) { + const res = await fetch( + `/rounds/${roundId}/participants/${player.id}/holes/${hole.holeNumber}/shots`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + club: result.club, + distance_meters: result.distanceMeters, + start_method: result.startMethod, + start_lat: result.startLat, + start_lng: result.startLng, + end_lat: result.endLat, + end_lng: result.endLng, + }), + }, + ) + if (!res.ok) { + setShotSheetOpen(false) + return + } + const shot = await res.json() + if (result.share) { + await fetch(`/rounds/${roundId}/shots/${shot.id}/share`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ body: result.shareText }), + }) + } + setShotCount((n) => n + 1) + setShotSheetOpen(false) + } + const steps = wizardStepsFor(player.statLevel) const [stepIndex, setStepIndex] = useState(0) // Nullstill til steg 1 hver gang veiviseren åpnes for en NY spiller -- @@ -1936,6 +2007,17 @@ function ScoringWizard({ readOnly={readOnly} /> + {!readOnly && ( + + )} + {hole.par !== 3 && ( + + {shotSheetOpen && ( + setShotSheetOpen(false)} + /> + )} ) } @@ -6255,63 +6347,6 @@ function DirectionCross({ ) } -// --- Kølle-plukker ----------------------------------------------------- -// Delt mellom ScoringWizard sitt "details"-steg (utslags-kølle) og -// ShotMeasurementSheet (ADR-048, slag-for-slag måling) -- ren utrekking, -// ingen atferdsendring for veiviserens eksisterende bruk. - -function ClubPicker({ - value, - onChange, - ownBagClubs, - readOnly = false, - label = "Kølle", -}: { - value: string - onChange: (club: string) => void - ownBagClubs: string[] - readOnly?: boolean - label?: string -}) { - return ( -
- - {ownBagClubs.length > 0 ? ( -
- {ownBagClubs.map((club) => { - const selected = value === club - return ( - - ) - })} -
- ) : ( - onChange(e.target.value)} - placeholder="F.eks. Driver, 3-jern" - disabled={readOnly} - className="h-12 rounded-2xl text-base" - /> - )} -
- ) -} - // --- Choice row (segmented buttons) ---------------------------------------- function ChoiceRow({ diff --git a/frontend/components/shot/map-point-picker.tsx b/frontend/components/shot/map-point-picker.tsx new file mode 100644 index 0000000..3de1e55 --- /dev/null +++ b/frontend/components/shot/map-point-picker.tsx @@ -0,0 +1,125 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import mapboxgl from "mapbox-gl" +import "mapbox-gl/dist/mapbox-gl.css" +import { MapPin } from "lucide-react" + +type LngLat = { lng: number; lat: number } + +/** + * MapPointPicker + * + * Rendered ONLY when the user chooses "Velg punkt på kart", and always via + * next/dynamic(..., { ssr: false }) from the parent. It mounts once per + * sheet-open and stays mounted until the sheet closes, so the Mapbox map is + * created exactly once (empty-deps useEffect) — taps, pans and marker drags + * never re-initialise it. This keeps Mapbox map loads (which are billed) to a + * single load per sheet-open. + */ +export function MapPointPicker({ onConfirm }: { onConfirm: (lngLat: LngLat) => void }) { + const containerRef = useRef(null) + const mapRef = useRef(null) + const markerRef = useRef(null) + + const [loaded, setLoaded] = useState(false) + const [point, setPoint] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN + const container = containerRef.current + if (!container) return + + if (!token) { + setError("Kartet er ikke tilgjengelig akkurat nå. Bruk «Min posisjon nå» i stedet.") + return + } + + mapboxgl.accessToken = token + const map = new mapboxgl.Map({ + container, + style: "mapbox://styles/mapbox/satellite-v9", + center: [10.7522, 59.9139], // Oslo fallback; real app centers on last-known position + zoom: 16, + attributionControl: false, + }) + mapRef.current = map + + map.on("load", () => setLoaded(true)) + map.on("error", () => setError("Kunne ikke laste kartet.")) + + // Tap to place/move a single draggable marker — never re-inits the map. + map.on("click", (e) => { + const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat } + if (!markerRef.current) { + const marker = new mapboxgl.Marker({ draggable: true, color: "#d2551a" }) + .setLngLat(e.lngLat) + .addTo(map) + marker.on("dragend", () => { + const p = marker.getLngLat() + setPoint({ lng: p.lng, lat: p.lat }) + }) + markerRef.current = marker + } else { + markerRef.current.setLngLat(e.lngLat) + } + setPoint(lngLat) + }) + + return () => { + markerRef.current?.remove() + markerRef.current = null + map.remove() + mapRef.current = null + } + }, []) + + return ( +
+
+ {/* Map canvas host — kept mounted for the map's whole lifetime. */} +
+ + {!loaded && !error ? ( +
+
+
+

Laster kart …

+
+
+ ) : null} + + {error ? ( +
+

+ {error} +

+
+ ) : null} + + {loaded && !point && !error ? ( +
+

+

+
+ ) : null} +
+ +
+ +
+
+ ) +} + +export default MapPointPicker diff --git a/frontend/components/shot/shot-measurement-sheet.tsx b/frontend/components/shot/shot-measurement-sheet.tsx new file mode 100644 index 0000000..abe5297 --- /dev/null +++ b/frontend/components/shot/shot-measurement-sheet.tsx @@ -0,0 +1,446 @@ +"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 + 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, + 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. + const endRequested = useRef(false) + useEffect(() => { + if (step !== "end") { + endRequested.current = false + return + } + if (endRequested.current) return + endRequested.current = true + setEndStatus("loading") + acquirePosition( + (p) => { + setEndPoint(p) + setEndStatus("idle") + setStep("club") + }, + () => setEndStatus("error"), + ) + }, [step]) + + const distance = + typeof distanceMeters === "number" ? distanceMeters : previewDistance(startPoint, endPoint) + + // 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 retryEnd() { + setEndStatus("loading") + acquirePosition( + (p) => { + setEndPoint(p) + setEndStatus("idle") + setStep("club") + }, + () => setEndStatus("error"), + ) + } + + 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 === "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" ? ( +
+
+ + 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 ? ( +
+ +