Slag-for-slag GPS-avstandsmåling: entry point i scoring-veiviseren (ADR-048)

Første inngangspunkt av to planlagte: en "Mål et slag"-knapp i
ScoringWizard sitt detalj-steg, rett etter kølle-plukkeren for utslaget.
Komponentene (components/shot/shot-measurement-sheet.tsx,
map-point-picker.tsx) kommer fra en egen, teknisk V0-prompt (design-
tokens fra DESIGN_SYSTEM.md spesifisert som hard begrensning, siden
dette er et ark INNI en eksisterende Forest Green-skjerm, ikke en ny
frittstående side) -- meget tro mot spesifikasjonen: korrekt
next/dynamic({ssr:false})-lasting av kartsteget, ingen mapbox-gl-import
på GPS-only-stien, kartet mountes kun én gang per arkåpning.

components/ui/textarea.tsx (ny shadcn-primitiv) lagt til. Eksisterende,
lokale ClubPicker i round-detail.tsx trukket ut til
components/teecup/club-picker.tsx (ren utrekking, ingen atferdsendring)
slik at måle-arket kan gjenbruke den fremfor V0s egen plassholder-kopi.

Wiret mot ekte backend: POST .../holes/{n}/shots ved innsending, valgfri
etterfølgende POST .../shots/{id}/share ved deling, shot-telling hentet
og vist i selve knappen ("Mål et slag (N målt)").

Gjenstår: inngangspunkt to (alltid-synlig merkelapp på hull-kortet for
retroaktiv måling + lagformat-støtte), ekte Mapbox-tokens, og migrasjon
060_round_shot.sql mot ekte teecup_db (ingen av disse rørt ennå).
This commit is contained in:
Erol Haagenrud 2026-08-08 12:37:30 +02:00
parent 8b1c73cf4f
commit 9538dfec7f
5 changed files with 746 additions and 57 deletions

View file

@ -30,6 +30,7 @@ import {
Minus, Minus,
Plus, Plus,
RefreshCw, RefreshCw,
Ruler,
Search, Search,
Settings2, Settings2,
Target, Target,
@ -45,6 +46,8 @@ import { ScrambleSoloResultView, type ScrambleSoloResult } from "@/components/sc
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils" 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" import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
// --- Types ----------------------------------------------------------------- // --- Types -----------------------------------------------------------------
@ -1638,6 +1641,7 @@ export function RoundDetail({ roundId }: { roundId: string }) {
)} )}
{wizardPlayer && hole && ( {wizardPlayer && hole && (
<ScoringWizard <ScoringWizard
roundId={roundId}
players={players} players={players}
player={wizardPlayer} player={wizardPlayer}
hole={hole} hole={hole}
@ -1694,6 +1698,7 @@ function autoAdvanceFieldFor(step: WizardStep): "strokes" | "putts" | "firstPutt
} }
function ScoringWizard({ function ScoringWizard({
roundId,
players, players,
player, player,
hole, hole,
@ -1707,6 +1712,7 @@ function ScoringWizard({
playFormat, playFormat,
strokesReceived, strokesReceived,
}: { }: {
roundId: string
players: Player[] players: Player[]
player: Player player: Player
hole: Hole hole: Hole
@ -1732,6 +1738,71 @@ function ScoringWizard({
const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1 const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1
const nextPlayer = !isLastPlayer ? players[playerIndex + 1] : null const nextPlayer = !isLastPlayer ? players[playerIndex + 1] : null
// Slag-for-slag GPS-avstandsmåling (ADR-048, 2026-08-08) -- kun
// deltaker-eide hull (denne veiviseren brukes utelukkende for
// per-spiller-formater, se ScoringWizard sin egen kommentar over --
// delt-ball-formater bruker en egen, enklere side-veiviser uten dette
// steget). `shotCount` lastes på nytt hver gang hull/spiller endres.
const [shotSheetOpen, setShotSheetOpen] = useState(false)
const [shotCount, setShotCount] = useState(0)
useEffect(() => {
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 steps = wizardStepsFor(player.statLevel)
const [stepIndex, setStepIndex] = useState(0) const [stepIndex, setStepIndex] = useState(0)
// Nullstill til steg 1 hver gang veiviseren åpnes for en NY spiller -- // Nullstill til steg 1 hver gang veiviseren åpnes for en NY spiller --
@ -1936,6 +2007,17 @@ function ScoringWizard({
readOnly={readOnly} readOnly={readOnly}
/> />
{!readOnly && (
<button
type="button"
onClick={() => setShotSheetOpen(true)}
className="flex min-h-11 items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-card px-4 text-base font-semibold text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Ruler className="size-5 shrink-0" aria-hidden="true" />
{shotCount > 0 ? `Mål et slag (${shotCount} målt)` : "Mål et slag"}
</button>
)}
{hole.par !== 3 && ( {hole.par !== 3 && (
<DirectionCross <DirectionCross
label="Utslag" label="Utslag"
@ -1998,6 +2080,16 @@ function ScoringWizard({
{isLastStep ? (isLastPlayer ? "Ferdig" : `Neste: ${nextPlayer?.name}`) : "Neste"} {isLastStep ? (isLastPlayer ? "Ferdig" : `Neste: ${nextPlayer?.name}`) : "Neste"}
</Button> </Button>
</footer> </footer>
{shotSheetOpen && (
<ShotMeasurementSheet
holeNumber={hole.holeNumber}
existingShotCount={shotCount}
ownBagClubs={player.isSelf ? ownBagClubs : []}
onSubmit={submitShot}
onCancel={() => setShotSheetOpen(false)}
/>
)}
</div> </div>
) )
} }
@ -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 (
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">{label}</Label>
{ownBagClubs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{ownBagClubs.map((club) => {
const selected = value === club
return (
<button
key={club}
type="button"
disabled={readOnly}
onClick={() => onChange(club)}
aria-pressed={selected}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{club}
</button>
)
})}
</div>
) : (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="F.eks. Driver, 3-jern"
disabled={readOnly}
className="h-12 rounded-2xl text-base"
/>
)}
</div>
)
}
// --- Choice row (segmented buttons) ---------------------------------------- // --- Choice row (segmented buttons) ----------------------------------------
function ChoiceRow({ function ChoiceRow({

View file

@ -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<HTMLDivElement | null>(null)
const mapRef = useRef<mapboxgl.Map | null>(null)
const markerRef = useRef<mapboxgl.Marker | null>(null)
const [loaded, setLoaded] = useState(false)
const [point, setPoint] = useState<LngLat | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="flex h-full flex-col">
<div className="relative flex-1">
{/* Map canvas host — kept mounted for the map's whole lifetime. */}
<div ref={containerRef} className="absolute inset-0" aria-label="Satellittkart" role="application" />
{!loaded && !error ? (
<div className="absolute inset-0 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
<p className="text-base text-muted-foreground">Laster kart </p>
</div>
</div>
) : null}
{error ? (
<div className="absolute inset-0 flex items-center justify-center p-6">
<p role="alert" className="max-w-sm text-center text-base text-destructive">
{error}
</p>
</div>
) : null}
{loaded && !point && !error ? (
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center px-4">
<p className="flex items-center gap-2 rounded-full bg-card/95 px-4 py-2 text-sm font-semibold text-foreground shadow-md">
<MapPin className="size-4 shrink-0" aria-hidden="true" />
Trykk kartet for å plassere punktet
</p>
</div>
) : null}
</div>
<div className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
<button
type="button"
disabled={!point}
onClick={() => point && onConfirm(point)}
className="inline-flex min-h-14 w-full items-center justify-center rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
Bekreft punkt
</button>
</div>
</div>
)
}
export default MapPointPicker

View file

@ -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 <MapPointPicker /> 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: () => (
<div className="flex h-full items-center justify-center">
<div className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
),
},
)
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<Step>("start")
const [startMethod, setStartMethod] = useState<"gps" | "map_tap" | null>(null)
const [startPoint, setStartPoint] = useState<LngLat | null>(null)
const [endPoint, setEndPoint] = useState<LngLat | null>(null)
const [startStatus, setStartStatus] = useState<GeoStatus>("idle")
const [endStatus, setEndStatus] = useState<GeoStatus>("idle")
const [club, setClub] = useState<string>("")
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 (
<div
className="fixed inset-0 z-50 flex flex-col bg-background text-foreground"
style={{ fontFamily: "var(--font-nunito), ui-sans-serif, system-ui, sans-serif" }}
role="dialog"
aria-modal="true"
aria-label={`Mål et slag, hull ${holeNumber}`}
>
{/* Header */}
<header className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-4 pb-4 pt-[max(1rem,env(safe-area-inset-top))]">
<div className="flex items-start gap-2">
{step !== "start" ? (
<button
type="button"
onClick={goBack}
className="inline-flex min-h-11 items-center gap-1 rounded-xl px-2 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronLeft className="size-5 shrink-0" aria-hidden="true" />
Tilbake
</button>
) : null}
<div className="pt-1.5">
<h1 className="text-lg font-bold leading-tight text-balance">
{`Mål et slag — hull ${holeNumber}`}
</h1>
{existingShotCount > 0 ? (
<p className="mt-0.5 text-sm text-muted-foreground">
{`${existingShotCount} slag målt tidligere`}
</p>
) : null}
</div>
</div>
<button
type="button"
onClick={onCancel}
className="inline-flex min-h-11 items-center gap-1 rounded-xl px-3 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-5 shrink-0" aria-hidden="true" />
Lukk
</button>
</header>
{/* Main */}
<main className={cn("flex-1", onMapStep ? "overflow-hidden" : "overflow-y-auto p-4")}>
{step === "start" ? (
<div className="mx-auto flex max-w-md flex-col gap-4 pt-2">
<p className="text-base text-muted-foreground text-pretty">
Velg hvor slaget startet. Ballens sluttposisjon måles alltid med GPS.
</p>
<button
type="button"
onClick={useMyPosition}
disabled={startStatus === "loading"}
className="flex min-h-16 items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
>
<span className="flex size-11 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<LocateFixed className="size-6" aria-hidden="true" />
</span>
<span className="flex flex-col">
<span className="text-base font-bold">Min posisjon </span>
<span className="text-sm text-muted-foreground">Bruk telefonens GPS</span>
</span>
</button>
<button
type="button"
onClick={() => {
setStartMethod("map_tap")
setStep("map")
}}
className="flex min-h-16 items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="flex size-11 shrink-0 items-center justify-center rounded-full bg-brand-orange/15 text-brand-orange">
<MapPin className="size-6" aria-hidden="true" />
</span>
<span className="flex flex-col">
<span className="text-base font-bold">Velg punkt kart</span>
<span className="text-sm text-muted-foreground">Trykk satellittkartet</span>
</span>
</button>
{startStatus === "loading" ? (
<div className="flex items-center justify-center gap-3 pt-2">
<div className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
<p className="text-base text-muted-foreground">Finner posisjonen din </p>
</div>
) : null}
{startStatus === "error" ? (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
<p role="alert" className="text-base text-destructive text-pretty">
Fant ikke posisjonen din. Sjekk at posisjonstjenester er , og prøv igjen.
</p>
<button
type="button"
onClick={useMyPosition}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Crosshair className="size-5" aria-hidden="true" />
Prøv igjen
</button>
</div>
) : null}
</div>
) : null}
{step === "map" ? <MapPointPicker onConfirm={(p) => {
setStartPoint(p)
setStep("end")
}} /> : null}
{step === "end" ? (
<div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-10 text-center">
{endStatus === "loading" ? (
<>
<div className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
<p className="text-base text-muted-foreground">Finner posisjonen din </p>
<p className="text-sm text-muted-foreground text-pretty">
Stå ved ballen mens vi måler sluttpunktet.
</p>
</>
) : null}
{endStatus === "error" ? (
<div className="flex w-full flex-col gap-3 rounded-2xl border border-border bg-card p-4 text-left">
<p role="alert" className="text-base text-destructive text-pretty">
Kunne ikke måle ballposisjonen. Prøv igjen når du står ved ballen.
</p>
<button
type="button"
onClick={retryEnd}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Crosshair className="size-5" aria-hidden="true" />
Prøv igjen
</button>
</div>
) : null}
</div>
) : null}
{step === "club" ? (
<div className="mx-auto flex max-w-md flex-col gap-6 pt-2">
<div className="flex flex-col gap-1">
<h2 className="text-lg font-bold">Hvilken kølle brukte du?</h2>
<p className="text-base text-muted-foreground">Velg køllen for dette slaget.</p>
</div>
{/* Reuses the shared ClubPicker (imported, not rebuilt). */}
<ClubPicker value={club} onChange={setClub} ownBagClubs={ownBagClubs} />
</div>
) : null}
{step === "result" ? (
<div className="mx-auto flex max-w-md flex-col gap-6 pt-2">
<div className="flex flex-col items-center gap-1 rounded-2xl border border-border bg-card p-6">
<span className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Målt lengde
</span>
<span className="text-6xl font-extrabold tabular-nums leading-none">
{distance}
<span className="ml-1 text-3xl font-bold text-muted-foreground">m</span>
</span>
{club ? (
<span className="mt-2 inline-flex items-center rounded-full bg-muted px-3 py-1 text-sm font-semibold text-foreground">
{club}
</span>
) : null}
</div>
{/* Private vs share segmented control */}
<div className="flex flex-col gap-3">
<span className="text-sm font-semibold text-muted-foreground">Deling</span>
<div
role="radiogroup"
aria-label="Deling av slaget"
className="grid grid-cols-2 gap-2 rounded-full bg-muted p-1"
>
{[
{ key: false, label: "Behold privat" },
{ key: true, label: "Del i feeden" },
].map((opt) => {
const selected = share === opt.key
return (
<button
key={String(opt.key)}
type="button"
role="radio"
aria-checked={selected}
onClick={() => setShare(opt.key)}
className={cn(
"inline-flex min-h-11 items-center justify-center rounded-full px-4 text-base font-bold transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
</button>
)
})}
</div>
{share ? (
<div className="flex flex-col gap-2">
<label htmlFor="share-text" className="text-sm font-semibold text-muted-foreground">
Innlegg
</label>
<Textarea
id="share-text"
value={shareText}
onChange={(e) => {
shareEdited.current = true
setShareText(e.target.value)
}}
rows={3}
/>
</div>
) : null}
</div>
</div>
) : null}
</main>
{/* Footer hidden on map step (MapPointPicker owns its own confirm bar)
and on the transient GPS steps. */}
{step === "club" || step === "result" ? (
<footer className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
{step === "club" ? (
<button
type="button"
disabled={!club.trim()}
onClick={() => setStep("result")}
className="inline-flex min-h-14 w-full items-center justify-center rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
Neste
</button>
) : (
<button
type="button"
onClick={confirmSubmit}
className="inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Check className="size-5 shrink-0" aria-hidden="true" />
Lagre slag
</button>
)}
</footer>
) : null}
</div>
)
}
export default ShotMeasurementSheet

View file

@ -0,0 +1,61 @@
"use client"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
// Delt mellom ScoringWizard sitt "details"-steg (round-detail.tsx,
// utslags-kølle) og ShotMeasurementSheet (ADR-048, slag-for-slag måling) --
// ren utrekking 2026-08-08, ingen atferdsendring for veiviserens
// eksisterende bruk.
export function ClubPicker({
value,
onChange,
ownBagClubs,
readOnly = false,
label = "Kølle",
}: {
value: string
onChange: (club: string) => void
ownBagClubs: string[]
readOnly?: boolean
label?: string
}) {
return (
<div className="flex flex-col gap-2">
<Label className="text-base font-semibold">{label}</Label>
{ownBagClubs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{ownBagClubs.map((club) => {
const selected = value === club
return (
<button
key={club}
type="button"
disabled={readOnly}
onClick={() => onChange(club)}
aria-pressed={selected}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{club}
</button>
)
})}
</div>
) : (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="F.eks. Driver, 3-jern"
disabled={readOnly}
className="h-12 rounded-2xl text-base"
/>
)}
</div>
)
}

View file

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex min-h-24 w-full rounded-xl border border-border bg-background px-4 py-3 text-base text-foreground shadow-sm transition-colors",
"placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
"resize-none leading-relaxed",
className,
)}
{...props}
/>
)
}
export { Textarea }