teecup/frontend/components/shot/shot-measurement-sheet.tsx

455 lines
18 KiB
TypeScript
Raw Permalink Normal View History

"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"
import { haversineMeters } from "@/lib/geo"
/**
* 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[]
/**
* Kompassgrader (0-360) kartet roteres til, regnet ut av kalleren fra
* spillerens EGET forrige slag dette hullet (startslutt-retningen) --
* gir "opp på skjermen = fremover på hullet" uten lagrede green-
* koordinater (brukerønske 2026-08-10). `undefined` når dette er det
* første slaget som måles hullet (ingen forrige slag å regne fra) --
* MapPointPicker faller da selv tilbake til enhetens GPS-heading eller
* nord, se den komponentens egen dokumentasjon.
*/
initialBearing?: number
/**
* 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
endMethod: "gps" | "map_tap"
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
return Math.round(haversineMeters(a, b))
}
export function ShotMeasurementSheet({
holeNumber,
existingShotCount,
ownBagClubs,
initialBearing,
distanceMeters,
submitError,
submitting,
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 [endMethod, setEndMethod] = useState<"gps" | "map_tap" | null>(null)
const [endPoint, setEndPoint] = useState<LngLat | null>(null)
const [startStatus, setStartStatus] = 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"),
)
}
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")
setStep("start")
} else if (step === "club") {
// Re-measure the ball position rather than reusing a stale fix.
setEndPoint(null)
setEndMethod(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,
endMethod: endMethod ?? "gps",
endLat: endPoint.lat,
endLng: endPoint.lng,
share,
shareText: share ? shareText : "",
})
}
const onMapStep = step === "map" || step === "end"
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.
</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}
{/* method-argumentet i onConfirm ignoreres bevisst her -- alltid
"map_tap" start-steget siden ingen referencePoint er satt. */}
{step === "map" ? (
<MapPointPicker
initialBearing={initialBearing}
onConfirm={(p) => {
setStartPoint(p)
setStep("end")
}}
/>
) : null}
{/* Ball-steget viser NÅ alltid kartet (ADR-048-tillegg 2026-08-08,
brukerønske: "jeg skal se start og slutt et satelittfoto...
mens jeg går opp mot ballen", "jeg ønsker å se lengden langt
i sanntid"). Ingen egen valg-skjerm lenger -- MapPointPicker
selv viser BÅDE "Bekreft ballens posisjon" (trykket punkt) og
"Jeg er ved ballen nå" (nåværende sporede posisjon) i footeren
når referencePoint er satt, se den komponenten. */}
{step === "end" ? (
<MapPointPicker
referencePoint={startPoint ?? undefined}
referenceLabel="Utslag"
initialBearing={initialBearing}
onConfirm={(p, method) => {
setEndPoint(p)
setEndMethod(method)
setStep("club")
}}
/>
) : 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">
{previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- ekstern Mapbox-URL, ikke next/image-verdt
<img
src={previewUrl || "/placeholder.svg"}
alt="Satellittutsnitt av slaget, med start- og sluttpunkt markert"
className="w-full rounded-2xl border border-border object-cover"
/>
) : null}
<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>
{submitError ? (
<p role="alert" className="text-base text-destructive text-pretty">
{submitError}
</p>
) : null}
</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"
disabled={submitting}
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 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? (
<div className="size-5 shrink-0 animate-spin rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground" />
) : (
<Check className="size-5 shrink-0" aria-hidden="true" />
)}
{submitting ? "Lagrer …" : "Lagre slag"}
</button>
)}
</footer>
) : null}
</div>
)
}
export default ShotMeasurementSheet