V0-integrasjonen er ferdig. Kort om hva som skjedde:
Diff mot levende tre (samme rutine som alltid): alle tre eksportene var fulle re-eksporter av hele prosjektet, bare fire filer var faktisk nye/endret for dette formålet: round-card.tsx, own-rounds.tsx, new-round.tsx, round-detail.tsx (+ tre app/rounds*/page.tsx-ruter). Alt annet (login-form, dashboard, config osv.) var forventede full-reverts og ble ikke rørt. Ett kjent V0-avvik dukket opp igjen (app/clubs/[id]/page.tsx med feil parameternavn — samme feil som ble rettet i klubbsiderunden) — hoppet bevisst over den, som vanlig. Erstattet mine hånd-bygde skjermer med V0s presentasjon, datalag skrevet om fra mock til ekte fetch, blant annet: Egen-bane-søket er nå et ekte to-stegs oppslag (søk → hent detalj med utslag) siden V0s modell antok baner allerede hadde utslagene ferdig innbakt i søkeresultatet. La til «Annet» som et tredje kjønnsvalg for gjestespillere (V0 hadde kun mann/kvinne) — matcher appens ellers etablerte konvensjon. Beholdt merge-før-PATCH-sikringen fra forrige runde (hull-endepunktet skriver alle felt hver gang). Fjernet V0s dev-only forhåndsvisningsbrytere («Forhåndsvis: Pågår/Fullført» osv.) — samme opprydning som i tidligere runder. Verifisert: ekte typesjekket produksjonsbuild (docker build --target builder) kompilerte rent, alle ruter listet. Ingen backend-endring i denne runden, så API-kontrakten er allerede scratch-bevist fra forrige runde — jeg har ikke kjørt en ny interaktiv nettleser-test (intet slikt verktøy tilgjengelig), kun kodegjennomgang + typesjekk. Zip-filene er slettet fra prosjektroten.
This commit is contained in:
parent
2cbb9fd308
commit
546b52665f
9 changed files with 2113 additions and 1519 deletions
|
|
@ -358,7 +358,9 @@
|
||||||
"Bash(TEECUP_API_ORIGIN=http://localhost:8000 npx next build)",
|
"Bash(TEECUP_API_ORIGIN=http://localhost:8000 npx next build)",
|
||||||
"Bash(curl -s -o /dev/null -w \"teecup.teeoff.no/health: %{http_code}\\\\n\" https://teecup.teeoff.no/health)",
|
"Bash(curl -s -o /dev/null -w \"teecup.teeoff.no/health: %{http_code}\\\\n\" https://teecup.teeoff.no/health)",
|
||||||
"Bash(curl -s -o /dev/null -w \"teecup.teeoff.no/dashboard: %{http_code}\\\\n\" https://teecup.teeoff.no/dashboard)",
|
"Bash(curl -s -o /dev/null -w \"teecup.teeoff.no/dashboard: %{http_code}\\\\n\" https://teecup.teeoff.no/dashboard)",
|
||||||
"Bash(curl -s -o /dev/null -w \"teecup.teeoff.no/rounds: %{http_code}\\\\n\" https://teecup.teeoff.no/rounds)"
|
"Bash(curl -s -o /dev/null -w \"teecup.teeoff.no/rounds: %{http_code}\\\\n\" https://teecup.teeoff.no/rounds)",
|
||||||
|
"Read(//home/**)",
|
||||||
|
"Bash(rm -f \"tee-cup-login-screen \\(10\\).zip\" \"tee-cup-login-screen \\(11\\).zip\" \"tee-cup-login-screen \\(12\\).zip\")"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { NewRound } from "@/components/round-new"
|
import { NewRound } from "@/components/new-round"
|
||||||
|
|
||||||
export default function NewRoundPage() {
|
export default function NewRoundPage() {
|
||||||
return <NewRound />
|
return <NewRound />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { PersonalRounds } from "@/components/personal-rounds"
|
import { OwnRounds } from "@/components/own-rounds"
|
||||||
|
|
||||||
export default function RoundsPage() {
|
export default function RoundsPage() {
|
||||||
return <PersonalRounds />
|
return <OwnRounds />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1153
frontend/components/new-round.tsx
Normal file
1153
frontend/components/new-round.tsx
Normal file
File diff suppressed because it is too large
Load diff
162
frontend/components/own-rounds.tsx
Normal file
162
frontend/components/own-rounds.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Frittstående rundeføring (ADR-033) -- runder eid direkte av en BRUKER
|
||||||
|
// (app_user), ikke en organisasjon. Presentasjon fra V0, datalag skrevet om
|
||||||
|
// fra mock til ekte fetch mot /rounds (samme mønster som resten av appen).
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Loader2, Plus, ClipboardList } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Wordmark } from "@/components/wordmark"
|
||||||
|
import { RoundCard, type Round } from "@/components/round-card"
|
||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
|
||||||
|
type ApiRoundParticipant = {
|
||||||
|
id: string
|
||||||
|
is_owner: boolean
|
||||||
|
guest_name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiRound = {
|
||||||
|
id: string
|
||||||
|
course_name_snapshot: string
|
||||||
|
tee_name_snapshot: string
|
||||||
|
played_at: string
|
||||||
|
holes_planned: number
|
||||||
|
completed_at: string | null
|
||||||
|
participants: ApiRoundParticipant[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRound(r: ApiRound): Round {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
courseName: r.course_name_snapshot,
|
||||||
|
status: r.completed_at ? "completed" : "active",
|
||||||
|
teeName: r.tee_name_snapshot,
|
||||||
|
holes: r.holes_planned === 9 ? 9 : 18,
|
||||||
|
date: r.played_at,
|
||||||
|
playerCount: r.participants.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OwnRounds() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [rounds, setRounds] = useState<Round[] | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/rounds", { credentials: "include" })
|
||||||
|
if (res.status === 401) {
|
||||||
|
router.replace("/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`rounds: ${res.status}`)
|
||||||
|
const data: ApiRound[] = await res.json()
|
||||||
|
if (!cancelled) setRounds(data.map(toRound))
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError("Klarte ikke å hente rundene dine. Prøv igjen om litt.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||||
|
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="inline-flex min-h-[44px] items-center gap-2 rounded-xl px-2 py-2 text-base font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||||
|
Til dashbord
|
||||||
|
</Link>
|
||||||
|
<Wordmark compact />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-3xl font-extrabold tracking-tight text-foreground text-balance">
|
||||||
|
Egne runder
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-prose text-lg leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Frittstående golfrunder du har registrert selv, uavhengig av turnering og klubb.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
render={<Link href="/rounds/new" />}
|
||||||
|
size="lg"
|
||||||
|
className="h-12 shrink-0 gap-2 rounded-2xl px-6 text-base font-bold shadow-sm"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" className="size-5" />
|
||||||
|
Ny runde
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mt-4 text-base font-medium text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
{rounds === null ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : rounds.length === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-4">
|
||||||
|
{rounds.map((round) => (
|
||||||
|
<li key={round.id}>
|
||||||
|
<RoundCard round={round} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingState() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="flex flex-col items-center justify-center gap-4 py-20 text-center"
|
||||||
|
>
|
||||||
|
<Loader2 aria-hidden="true" className="size-9 animate-spin text-primary" />
|
||||||
|
<span className="text-base font-medium text-muted-foreground">Laster runder…</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-5 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-16 text-center">
|
||||||
|
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
|
||||||
|
<ClipboardList aria-hidden="true" className="size-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex max-w-md flex-col gap-2">
|
||||||
|
<h2 className="text-xl font-bold text-foreground">Ingen runder ennå</h2>
|
||||||
|
<p className="text-lg leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Her dukker rundene dine opp etter hvert som du registrerer dem. Trykk på{" "}
|
||||||
|
<span className="font-semibold text-foreground">«Ny runde»</span> øverst for å komme i
|
||||||
|
gang.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// Frittstående rundeføring (ADR-033) -- runder eid direkte av en BRUKER
|
|
||||||
// (app_user), ikke en organisasjon. Bevisst adskilt navn fra dashbordets
|
|
||||||
// "Mine runder" (turnering-deltakelse, se MyToursSection i dashboard.tsx)
|
|
||||||
// for å unngå forveksling -- denne funksjonen kalles "Egne runder" overalt
|
|
||||||
// i UI-et.
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { ArrowLeft, Calendar, ChevronRight, Flag, Plus } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Wordmark } from "@/components/wordmark"
|
|
||||||
|
|
||||||
type ApiRoundParticipant = {
|
|
||||||
id: string
|
|
||||||
is_owner: boolean
|
|
||||||
guest_name: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiRound = {
|
|
||||||
id: string
|
|
||||||
course_name_snapshot: string
|
|
||||||
tee_name_snapshot: string
|
|
||||||
played_at: string
|
|
||||||
holes_planned: number
|
|
||||||
completed_at: string | null
|
|
||||||
participants: ApiRoundParticipant[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PersonalRounds() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [rounds, setRounds] = useState<ApiRound[] | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/rounds", { credentials: "include" })
|
|
||||||
if (res.status === 401) {
|
|
||||||
router.replace("/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!res.ok) throw new Error(`rounds: ${res.status}`)
|
|
||||||
const data: ApiRound[] = await res.json()
|
|
||||||
if (!cancelled) setRounds(data)
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) setError("Klarte ikke å hente rundene dine. Prøv igjen om litt.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void load()
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
|
||||||
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
|
||||||
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
|
|
||||||
<Link
|
|
||||||
href="/dashboard"
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft aria-hidden="true" className="size-4" />
|
|
||||||
Dashbord
|
|
||||||
</Link>
|
|
||||||
<Wordmark compact />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
|
|
||||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h1 className="text-2xl font-extrabold tracking-tight text-foreground">Egne runder</h1>
|
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
||||||
Registrer en runde på egen hånd, med eller uten turnering, og få detaljert statistikk.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link href="/rounds/new">
|
|
||||||
<Button className="h-12 rounded-2xl text-base font-bold shadow-sm">
|
|
||||||
<Plus aria-hidden="true" className="size-5" />
|
|
||||||
Ny runde
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{rounds === null ? (
|
|
||||||
<div className="flex justify-center py-12">
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : rounds.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-12 text-center">
|
|
||||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
|
||||||
<Flag aria-hidden="true" className="size-7 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h2 className="text-base font-bold text-foreground text-balance">Ingen runder registrert ennå</h2>
|
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
||||||
Trykk på «Ny runde» for å registrere den første.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="flex flex-col gap-3">
|
|
||||||
{rounds.map((r) => (
|
|
||||||
<li key={r.id}>
|
|
||||||
<Link
|
|
||||||
href={`/rounds/${r.id}`}
|
|
||||||
className="group flex items-center gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 sm:p-5"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
||||||
<h3 className="truncate text-base font-bold text-foreground">{r.course_name_snapshot}</h3>
|
|
||||||
{r.completed_at ? (
|
|
||||||
<span className="inline-flex items-center rounded-full bg-primary/15 px-2.5 py-0.5 text-xs font-bold text-primary">
|
|
||||||
Fullført
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-xs font-bold text-muted-foreground">
|
|
||||||
Pågår
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="truncate text-sm text-muted-foreground">
|
|
||||||
{r.tee_name_snapshot} · {r.holes_planned} hull · {formatDate(r.played_at)}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
|
||||||
<Calendar aria-hidden="true" className="size-4 shrink-0" />
|
|
||||||
{r.participants.length} {r.participants.length === 1 ? "spiller" : "spillere"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRight
|
|
||||||
aria-hidden="true"
|
|
||||||
className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string) {
|
|
||||||
const date = new Date(iso)
|
|
||||||
if (Number.isNaN(date.getTime())) return iso
|
|
||||||
return new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "short", year: "numeric" }).format(date)
|
|
||||||
}
|
|
||||||
100
frontend/components/round-card.tsx
Normal file
100
frontend/components/round-card.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import Link from "next/link"
|
||||||
|
import { CalendarDays, ChevronRight, Flag, MapPin, Users } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export type RoundStatus = "active" | "completed"
|
||||||
|
|
||||||
|
export type Round = {
|
||||||
|
id: string
|
||||||
|
courseName: string
|
||||||
|
status: RoundStatus
|
||||||
|
teeName: string
|
||||||
|
holes: 9 | 18
|
||||||
|
date: string
|
||||||
|
playerCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<RoundStatus, { label: string; dot: string; badge: string }> = {
|
||||||
|
active: {
|
||||||
|
label: "Pågår",
|
||||||
|
dot: "bg-primary",
|
||||||
|
badge: "bg-primary/15 text-foreground",
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
label: "Fullført",
|
||||||
|
dot: "bg-brand-orange",
|
||||||
|
badge: "bg-brand-orange/12 text-foreground",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDate(value: string) {
|
||||||
|
const parsed = new Date(value)
|
||||||
|
if (Number.isNaN(parsed.getTime())) return value
|
||||||
|
return dateFormatter.format(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoundStatusBadge({ status }: { status: RoundStatus }) {
|
||||||
|
const config = STATUS_CONFIG[status]
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold",
|
||||||
|
config.badge,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn("size-2 rounded-full", config.dot)} aria-hidden="true" />
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundCard({ round }: { round: Round }) {
|
||||||
|
const playerLabel = round.playerCount === 1 ? "spiller" : "spillere"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/rounds/${round.id}`}
|
||||||
|
className="group flex min-h-[88px] w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/60 hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
|
<h3 className="flex min-w-0 items-center gap-2 text-lg font-bold text-foreground">
|
||||||
|
<MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" />
|
||||||
|
<span className="truncate">{round.courseName}</span>
|
||||||
|
</h3>
|
||||||
|
<RoundStatusBadge status={round.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-1.5 text-base text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Flag aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{round.teeName} · {round.holes} hull
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<CalendarDays aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
<span className="tabular-nums">{formatDate(round.date)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Users aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{round.playerCount} {playerLabel}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChevronRight
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-6 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,834 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
// Opprett en frittstående runde (ADR-033). To bane-kilder: offisiell
|
|
||||||
// teeoff-bane (live oppslag, Beslutning C -- ingen lokal kopi lagres) eller
|
|
||||||
// en egendefinert bane (organisasjonsuavhengig katalog, søkbar på tvers av
|
|
||||||
// alle brukere -- samme "søk før du oppretter"-idé som org-banene).
|
|
||||||
|
|
||||||
import type React from "react"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { ArrowLeft, Check, ChevronRight, Plus, Search, X } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Wordmark } from "@/components/wordmark"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
type Gender = "m" | "f"
|
|
||||||
type TeeOption = { name: string; genders: Gender[] }
|
|
||||||
|
|
||||||
type SelectedCourse = {
|
|
||||||
source: "teeoff" | "custom"
|
|
||||||
teeoffFacilitySlug?: string
|
|
||||||
teeoffCourseId?: number
|
|
||||||
personalCourseId?: string
|
|
||||||
name: string
|
|
||||||
tees: TeeOption[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type Step = "source" | "teeoff" | "custom-search" | "custom-create" | "confirm"
|
|
||||||
|
|
||||||
export function NewRound() {
|
|
||||||
const router = useRouter()
|
|
||||||
const [step, setStep] = useState<Step>("source")
|
|
||||||
const [selected, setSelected] = useState<SelectedCourse | null>(null)
|
|
||||||
const [ownGender, setOwnGender] = useState<Gender | null>(null)
|
|
||||||
const [loadingMe, setLoadingMe] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
async function loadMe() {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/auth/me", { credentials: "include" })
|
|
||||||
if (res.status === 401) {
|
|
||||||
router.replace("/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const data: { gender: Gender | null } = await res.json()
|
|
||||||
if (!cancelled) setOwnGender(data.gender)
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoadingMe(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void loadMe()
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
function pickCourse(course: SelectedCourse) {
|
|
||||||
setSelected(course)
|
|
||||||
setStep("confirm")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
|
||||||
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
|
||||||
<div className="mx-auto flex w-full max-w-2xl items-center justify-between gap-4 px-5 py-4">
|
|
||||||
<Link
|
|
||||||
href="/rounds"
|
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft aria-hidden="true" className="size-4" />
|
|
||||||
Egne runder
|
|
||||||
</Link>
|
|
||||||
<Wordmark compact />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-8 sm:py-10">
|
|
||||||
<h1 className="mb-6 text-2xl font-extrabold tracking-tight text-foreground">Ny runde</h1>
|
|
||||||
|
|
||||||
{loadingMe ? (
|
|
||||||
<div className="flex justify-center py-12">
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : !ownGender ? (
|
|
||||||
<div className="rounded-2xl border border-border bg-card p-5 text-sm text-muted-foreground">
|
|
||||||
Profilen din mangler registrert kjønn, som trengs for å beregne banehandicap riktig.{" "}
|
|
||||||
<Link href="/account" className="font-semibold text-primary underline underline-offset-2">
|
|
||||||
Gå til kontoinnstillinger
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</div>
|
|
||||||
) : step === "source" ? (
|
|
||||||
<SourceChoice
|
|
||||||
onPickTeeoff={() => setStep("teeoff")}
|
|
||||||
onPickCustom={() => setStep("custom-search")}
|
|
||||||
/>
|
|
||||||
) : step === "teeoff" ? (
|
|
||||||
<TeeoffCourseSearch onBack={() => setStep("source")} onPick={pickCourse} />
|
|
||||||
) : step === "custom-search" ? (
|
|
||||||
<CustomCourseSearch
|
|
||||||
onBack={() => setStep("source")}
|
|
||||||
onPick={pickCourse}
|
|
||||||
onCreateNew={() => setStep("custom-create")}
|
|
||||||
/>
|
|
||||||
) : step === "custom-create" ? (
|
|
||||||
<CustomCourseCreate onBack={() => setStep("custom-search")} onCreated={pickCourse} />
|
|
||||||
) : selected ? (
|
|
||||||
<ConfirmRound course={selected} ownGender={ownGender} onBack={() => setStep("source")} />
|
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Steg 1: velg kilde ------------------------------------------------------
|
|
||||||
|
|
||||||
function SourceChoice({ onPickTeeoff, onPickCustom }: { onPickTeeoff: () => void; onPickCustom: () => void }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
||||||
Hvor spilte du runden?
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onPickTeeoff}
|
|
||||||
className="flex items-center justify-between gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-base font-bold text-foreground">Offisiell bane</span>
|
|
||||||
<span className="text-sm text-muted-foreground">Søk opp klubben i teeoff sitt register</span>
|
|
||||||
</div>
|
|
||||||
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onPickCustom}
|
|
||||||
className="flex items-center justify-between gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-base font-bold text-foreground">Egen bane</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Banen finnes ikke i teeoff -- søk opp eller opprett den selv
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Steg 2a: teeoff-søk -----------------------------------------------------
|
|
||||||
|
|
||||||
type ApiFacility = { slug: string; name: string; city: string | null; county: string | null }
|
|
||||||
type ApiOfficialCourseOption = { teeoff_course_id: number; name: string; is_main_course: boolean; tees: TeeOption[] }
|
|
||||||
|
|
||||||
function TeeoffCourseSearch({
|
|
||||||
onBack,
|
|
||||||
onPick,
|
|
||||||
}: {
|
|
||||||
onBack: () => void
|
|
||||||
onPick: (course: SelectedCourse) => void
|
|
||||||
}) {
|
|
||||||
const [query, setQuery] = useState("")
|
|
||||||
const [facilities, setFacilities] = useState<ApiFacility[] | null>(null)
|
|
||||||
const [selectedFacility, setSelectedFacility] = useState<ApiFacility | null>(null)
|
|
||||||
const [courses, setCourses] = useState<ApiOfficialCourseOption[] | null>(null)
|
|
||||||
const [searching, setSearching] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
async function runSearch() {
|
|
||||||
setSearching(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query.trim())}`, {
|
|
||||||
credentials: "include",
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`search: ${res.status}`)
|
|
||||||
setFacilities(await res.json())
|
|
||||||
} catch {
|
|
||||||
setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
|
|
||||||
setFacilities([])
|
|
||||||
} finally {
|
|
||||||
setSearching(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickFacility(facility: ApiFacility) {
|
|
||||||
setSelectedFacility(facility)
|
|
||||||
setError(null)
|
|
||||||
setCourses(null)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/rounds/official-search/${facility.slug}`, { credentials: "include" })
|
|
||||||
if (!res.ok) throw new Error(`facility detail: ${res.status}`)
|
|
||||||
const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
|
|
||||||
setCourses(detail.courses)
|
|
||||||
} catch {
|
|
||||||
setError("Klarte ikke å hente baner for dette anlegget.")
|
|
||||||
setCourses([])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{!selectedFacility ? (
|
|
||||||
<>
|
|
||||||
<BackLink onClick={onBack} label="Bane-kilde" />
|
|
||||||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
autoFocus
|
|
||||||
placeholder="Søk klubbnavn…"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault()
|
|
||||||
runSearch()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="h-12 flex-1 rounded-xl text-base"
|
|
||||||
/>
|
|
||||||
<Button type="button" onClick={runSearch} disabled={searching} className="h-12 shrink-0 rounded-xl font-bold">
|
|
||||||
<Search aria-hidden="true" className="size-4" />
|
|
||||||
{searching ? "Søker…" : "Søk"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{facilities && (
|
|
||||||
<ul className="flex flex-col overflow-hidden rounded-2xl border border-border">
|
|
||||||
{facilities.map((f) => (
|
|
||||||
<li key={f.slug} className="border-b border-border last:border-b-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => pickFacility(f)}
|
|
||||||
className="flex w-full flex-col px-4 py-3 text-left transition-colors hover:bg-accent/60"
|
|
||||||
>
|
|
||||||
<span className="text-base font-semibold text-foreground">{f.name}</span>
|
|
||||||
{(f.city || f.county) && (
|
|
||||||
<span className="text-sm text-muted-foreground">{[f.city, f.county].filter(Boolean).join(", ")}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{facilities.length === 0 && (
|
|
||||||
<li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<BackLink onClick={() => setSelectedFacility(null)} label={selectedFacility.name} />
|
|
||||||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
|
||||||
{courses === null ? (
|
|
||||||
<p className="text-sm text-muted-foreground">Laster baner…</p>
|
|
||||||
) : (
|
|
||||||
<ul className="flex flex-col overflow-hidden rounded-2xl border border-border">
|
|
||||||
{courses.map((c) => (
|
|
||||||
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
onPick({
|
|
||||||
source: "teeoff",
|
|
||||||
teeoffFacilitySlug: selectedFacility.slug,
|
|
||||||
teeoffCourseId: c.teeoff_course_id,
|
|
||||||
name: `${selectedFacility.name} – ${c.name}`,
|
|
||||||
tees: c.tees,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-accent/60"
|
|
||||||
>
|
|
||||||
<span className="text-base font-semibold text-foreground">{c.name}</span>
|
|
||||||
<ChevronRight aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{courses.length === 0 && (
|
|
||||||
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
|
|
||||||
Ingen 18-hulls baner registrert hos dette anlegget ennå.
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Steg 2b: egen bane -- søk eksisterende ---------------------------------
|
|
||||||
|
|
||||||
type ApiPersonalCourse = { id: string; name: string }
|
|
||||||
|
|
||||||
function CustomCourseSearch({
|
|
||||||
onBack,
|
|
||||||
onPick,
|
|
||||||
onCreateNew,
|
|
||||||
}: {
|
|
||||||
onBack: () => void
|
|
||||||
onPick: (course: SelectedCourse) => void
|
|
||||||
onCreateNew: () => void
|
|
||||||
}) {
|
|
||||||
const [query, setQuery] = useState("")
|
|
||||||
const [results, setResults] = useState<ApiPersonalCourse[]>([])
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [resolving, setResolving] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false
|
|
||||||
const timer = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/personal-courses?q=${encodeURIComponent(query.trim())}`, { credentials: "include" })
|
|
||||||
if (!res.ok) throw new Error(`search: ${res.status}`)
|
|
||||||
const data: ApiPersonalCourse[] = await res.json()
|
|
||||||
if (!cancelled) setResults(data)
|
|
||||||
} catch {
|
|
||||||
if (!cancelled) setError("Klarte ikke å søke i egendefinerte baner akkurat nå.")
|
|
||||||
}
|
|
||||||
}, 250)
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [query])
|
|
||||||
|
|
||||||
async function pick(course: ApiPersonalCourse) {
|
|
||||||
setResolving(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/personal-courses/${course.id}`, { credentials: "include" })
|
|
||||||
if (!res.ok) throw new Error(`detail: ${res.status}`)
|
|
||||||
const detail: { tees: TeeOption[] } = await res.json()
|
|
||||||
onPick({ source: "custom", personalCourseId: course.id, name: course.name, tees: detail.tees })
|
|
||||||
} catch {
|
|
||||||
setError("Klarte ikke å hente banedetaljer. Prøv igjen.")
|
|
||||||
} finally {
|
|
||||||
setResolving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<BackLink onClick={onBack} label="Bane-kilde" />
|
|
||||||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
|
||||||
<Input
|
|
||||||
autoFocus
|
|
||||||
placeholder="Søk egendefinert bane…"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
className="h-12 rounded-xl text-base"
|
|
||||||
/>
|
|
||||||
<ul className="flex flex-col overflow-hidden rounded-2xl border border-border">
|
|
||||||
{results.map((c) => (
|
|
||||||
<li key={c.id} className="border-b border-border last:border-b-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={resolving}
|
|
||||||
onClick={() => pick(c)}
|
|
||||||
className="flex w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-accent/60 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<span className="text-base font-semibold text-foreground">{c.name}</span>
|
|
||||||
<ChevronRight aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{results.length === 0 && (
|
|
||||||
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
|
|
||||||
{query.trim() ? "Ingen treff." : "Skriv for å søke, eller opprett en ny bane under."}
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCreateNew}
|
|
||||||
className="h-12 self-start rounded-2xl border-dashed text-base font-bold"
|
|
||||||
>
|
|
||||||
<Plus aria-hidden="true" className="size-5" />
|
|
||||||
Opprett ny bane
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Steg 2c: egen bane -- opprett ny ---------------------------------------
|
|
||||||
|
|
||||||
const DEFAULT_PARS = [4, 4, 3, 5, 4, 4, 3, 5, 4, 4, 4, 3, 5, 4, 4, 3, 5, 4]
|
|
||||||
|
|
||||||
type HoleDraft = { par: number; strokeIndex: number }
|
|
||||||
type TeeRatingDraft = { courseRating: string; slopeRating: string; par: string }
|
|
||||||
type TeeDraft = { name: string; m: TeeRatingDraft | null; f: TeeRatingDraft | null }
|
|
||||||
|
|
||||||
function CustomCourseCreate({
|
|
||||||
onBack,
|
|
||||||
onCreated,
|
|
||||||
}: {
|
|
||||||
onBack: () => void
|
|
||||||
onCreated: (course: SelectedCourse) => void
|
|
||||||
}) {
|
|
||||||
const [name, setName] = useState("")
|
|
||||||
const [holes, setHoles] = useState<HoleDraft[]>(
|
|
||||||
DEFAULT_PARS.map((par, i) => ({ par, strokeIndex: i + 1 })),
|
|
||||||
)
|
|
||||||
const [tees, setTees] = useState<TeeDraft[]>([
|
|
||||||
{ name: "Gul", m: { courseRating: "", slopeRating: "", par: "72" }, f: null },
|
|
||||||
])
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const parSum = holes.reduce((s, h) => s + h.par, 0)
|
|
||||||
const indexPermutationValid = new Set(holes.map((h) => h.strokeIndex)).size === 18
|
|
||||||
|
|
||||||
function updateHole(i: number, patch: Partial<HoleDraft>) {
|
|
||||||
setHoles((prev) => prev.map((h, idx) => (idx === i ? { ...h, ...patch } : h)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTee(i: number, patch: Partial<TeeDraft>) {
|
|
||||||
setTees((prev) => prev.map((t, idx) => (idx === i ? { ...t, ...patch } : t)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const teesValid = tees.every(
|
|
||||||
(t) =>
|
|
||||||
t.name.trim().length > 0 &&
|
|
||||||
(t.m || t.f) &&
|
|
||||||
[t.m, t.f].every((r) => !r || (r.courseRating.trim() && r.slopeRating.trim() && r.par.trim())),
|
|
||||||
)
|
|
||||||
const canSubmit = name.trim().length >= 2 && indexPermutationValid && teesValid && !submitting
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!canSubmit) return
|
|
||||||
setSubmitting(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const body = {
|
|
||||||
name: name.trim(),
|
|
||||||
holes: holes.map((h, i) => ({ hole_number: i + 1, par: h.par, stroke_index: h.strokeIndex })),
|
|
||||||
tees: tees.map((t) => ({
|
|
||||||
name: t.name.trim(),
|
|
||||||
ratings: [
|
|
||||||
...(t.m
|
|
||||||
? [{ gender: "m", course_rating: Number(t.m.courseRating), slope_rating: Number(t.m.slopeRating), par: Number(t.m.par) }]
|
|
||||||
: []),
|
|
||||||
...(t.f
|
|
||||||
? [{ gender: "f", course_rating: Number(t.f.courseRating), slope_rating: Number(t.f.slopeRating), par: Number(t.f.par) }]
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
const res = await fetch("/personal-courses", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`create: ${res.status}`)
|
|
||||||
const created: { id: string; name: string } = await res.json()
|
|
||||||
onCreated({
|
|
||||||
source: "custom",
|
|
||||||
personalCourseId: created.id,
|
|
||||||
name: created.name,
|
|
||||||
tees: tees.map((t) => ({
|
|
||||||
name: t.name.trim(),
|
|
||||||
genders: [...(t.m ? (["m"] as const) : []), ...(t.f ? (["f"] as const) : [])],
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.")
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
|
||||||
<BackLink onClick={onBack} label="Søk egen bane" />
|
|
||||||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="course-name" className="text-sm font-semibold">
|
|
||||||
Navn på banen
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="course-name"
|
|
||||||
autoFocus
|
|
||||||
placeholder="F.eks. Min Golfklubb – Hovedbanen"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
className="h-12 rounded-xl text-base"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
|
||||||
<h2 className="text-base font-bold text-foreground">18 hull</h2>
|
|
||||||
<span className={cn("text-sm font-semibold", parSum === 72 ? "text-muted-foreground" : "text-foreground")}>
|
|
||||||
Sum par: {parSum}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!indexPermutationValid && (
|
|
||||||
<p role="alert" className="text-sm font-medium text-destructive">
|
|
||||||
Hver stroke-indeks (1–18) må brukes nøyaktig én gang.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
||||||
{holes.map((h, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-2 rounded-xl border border-border bg-card p-2.5">
|
|
||||||
<span className="w-16 shrink-0 text-sm font-bold text-foreground">Hull {i + 1}</span>
|
|
||||||
<label className="flex flex-1 items-center gap-1.5 text-sm text-muted-foreground">
|
|
||||||
Par
|
|
||||||
<select
|
|
||||||
value={h.par}
|
|
||||||
onChange={(e) => updateHole(i, { par: Number(e.target.value) })}
|
|
||||||
className="h-10 flex-1 rounded-lg border border-border bg-background px-2 text-base font-semibold text-foreground"
|
|
||||||
>
|
|
||||||
{[3, 4, 5, 6].map((p) => (
|
|
||||||
<option key={p} value={p}>
|
|
||||||
{p}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-1 items-center gap-1.5 text-sm text-muted-foreground">
|
|
||||||
Idx
|
|
||||||
<select
|
|
||||||
value={h.strokeIndex}
|
|
||||||
onChange={(e) => updateHole(i, { strokeIndex: Number(e.target.value) })}
|
|
||||||
className="h-10 flex-1 rounded-lg border border-border bg-background px-2 text-base font-semibold text-foreground"
|
|
||||||
>
|
|
||||||
{Array.from({ length: 18 }, (_, n) => n + 1).map((n) => (
|
|
||||||
<option key={n} value={n}>
|
|
||||||
{n}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<h2 className="text-base font-bold text-foreground">Utslag / rating</h2>
|
|
||||||
{tees.map((t, i) => (
|
|
||||||
<div key={i} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
value={t.name}
|
|
||||||
onChange={(e) => updateTee(i, { name: e.target.value })}
|
|
||||||
placeholder="Navn på utslag (f.eks. Gul)"
|
|
||||||
className="h-11 flex-1 rounded-xl text-base"
|
|
||||||
/>
|
|
||||||
{tees.length > 1 && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setTees((prev) => prev.filter((_, idx) => idx !== i))}
|
|
||||||
className="size-10 shrink-0 rounded-xl text-muted-foreground"
|
|
||||||
aria-label="Fjern utslag"
|
|
||||||
>
|
|
||||||
<X aria-hidden="true" className="size-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<GenderRatingFields
|
|
||||||
label="Herre-rating"
|
|
||||||
value={t.m}
|
|
||||||
onChange={(v) => updateTee(i, { m: v })}
|
|
||||||
/>
|
|
||||||
<GenderRatingFields
|
|
||||||
label="Dame-rating"
|
|
||||||
value={t.f}
|
|
||||||
onChange={(v) => updateTee(i, { f: v })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setTees((prev) => [...prev, { name: "", m: null, f: null }])}
|
|
||||||
className="h-11 self-start rounded-xl border-dashed text-sm font-bold"
|
|
||||||
>
|
|
||||||
<Plus aria-hidden="true" className="size-4" />
|
|
||||||
Legg til utslag
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" disabled={!canSubmit} className="h-14 rounded-2xl text-base font-bold shadow-sm">
|
|
||||||
{submitting ? "Oppretter…" : "Opprett bane og fortsett"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function GenderRatingFields({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
value: TeeRatingDraft | null
|
|
||||||
onChange: (v: TeeRatingDraft | null) => void
|
|
||||||
}) {
|
|
||||||
const id = label.toLowerCase().replace(/\s+/g, "-")
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 rounded-xl border border-border/60 p-3">
|
|
||||||
<label className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={value !== null}
|
|
||||||
onChange={(e) => onChange(e.target.checked ? { courseRating: "", slopeRating: "", par: "72" } : null)}
|
|
||||||
className="size-5 rounded border-border"
|
|
||||||
/>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
{value && (
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<Label htmlFor={`${id}-cr`} className="text-xs text-muted-foreground">
|
|
||||||
Course rating
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`${id}-cr`}
|
|
||||||
inputMode="decimal"
|
|
||||||
value={value.courseRating}
|
|
||||||
onChange={(e) => onChange({ ...value, courseRating: e.target.value })}
|
|
||||||
className="h-10 rounded-lg text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<Label htmlFor={`${id}-slope`} className="text-xs text-muted-foreground">
|
|
||||||
Slope
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`${id}-slope`}
|
|
||||||
inputMode="numeric"
|
|
||||||
value={value.slopeRating}
|
|
||||||
onChange={(e) => onChange({ ...value, slopeRating: e.target.value })}
|
|
||||||
className="h-10 rounded-lg text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<Label htmlFor={`${id}-par`} className="text-xs text-muted-foreground">
|
|
||||||
Par
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`${id}-par`}
|
|
||||||
inputMode="numeric"
|
|
||||||
value={value.par}
|
|
||||||
onChange={(e) => onChange({ ...value, par: e.target.value })}
|
|
||||||
className="h-10 rounded-lg text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Steg 3: bekreft + opprett runde -----------------------------------------
|
|
||||||
|
|
||||||
function ConfirmRound({
|
|
||||||
course,
|
|
||||||
ownGender,
|
|
||||||
onBack,
|
|
||||||
}: {
|
|
||||||
course: SelectedCourse
|
|
||||||
ownGender: Gender
|
|
||||||
onBack: () => void
|
|
||||||
}) {
|
|
||||||
const router = useRouter()
|
|
||||||
const compatibleTees = course.tees.filter((t) => t.genders.includes(ownGender))
|
|
||||||
const [teeName, setTeeName] = useState(compatibleTees[0]?.name ?? "")
|
|
||||||
const [playedAt, setPlayedAt] = useState(() => new Date().toISOString().slice(0, 10))
|
|
||||||
const [startHole, setStartHole] = useState(1)
|
|
||||||
const [holesPlanned, setHolesPlanned] = useState<9 | 18>(18)
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
if (!teeName) return
|
|
||||||
setSubmitting(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const res = await fetch("/rounds", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({
|
|
||||||
course_source: course.source,
|
|
||||||
teeoff_facility_slug: course.teeoffFacilitySlug,
|
|
||||||
teeoff_course_id: course.teeoffCourseId,
|
|
||||||
personal_course_id: course.personalCourseId,
|
|
||||||
tee_name: teeName,
|
|
||||||
played_at: playedAt,
|
|
||||||
start_hole: startHole,
|
|
||||||
holes_planned: holesPlanned,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`create round: ${res.status}`)
|
|
||||||
const created: { id: string } = await res.json()
|
|
||||||
router.replace(`/rounds/${created.id}`)
|
|
||||||
} catch {
|
|
||||||
setError("Klarte ikke å opprette runden. Prøv igjen.")
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<BackLink onClick={onBack} label="Bane-kilde" />
|
|
||||||
<div className="rounded-2xl border border-border bg-card p-4">
|
|
||||||
<span className="text-base font-bold text-foreground">{course.name}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
|
||||||
|
|
||||||
{compatibleTees.length === 0 ? (
|
|
||||||
<p role="alert" className="text-sm font-medium text-destructive">
|
|
||||||
Denne banen har ingen registrert rating for ditt kjønn på noe utslag -- HCP-sporing er ikke mulig for
|
|
||||||
denne runden. Velg en annen bane, eller fullfør profilen din på nytt.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-sm font-semibold">Utslag</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{compatibleTees.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t.name}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTeeName(t.name)}
|
|
||||||
aria-pressed={teeName === t.name}
|
|
||||||
className={cn(
|
|
||||||
"h-11 rounded-xl border px-4 text-base font-semibold transition-colors",
|
|
||||||
teeName === t.name
|
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
|
||||||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="played-at" className="text-sm font-semibold">
|
|
||||||
Dato
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="played-at"
|
|
||||||
type="date"
|
|
||||||
value={playedAt}
|
|
||||||
onChange={(e) => setPlayedAt(e.target.value)}
|
|
||||||
className="h-12 rounded-xl text-base"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="start-hole" className="text-sm font-semibold">
|
|
||||||
Starthull
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="start-hole"
|
|
||||||
value={startHole}
|
|
||||||
onChange={(e) => setStartHole(Number(e.target.value))}
|
|
||||||
className="h-12 rounded-xl border border-border bg-background px-3 text-base font-semibold text-foreground"
|
|
||||||
>
|
|
||||||
{Array.from({ length: 18 }, (_, i) => i + 1).map((n) => (
|
|
||||||
<option key={n} value={n}>
|
|
||||||
Hull {n}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label className="text-sm font-semibold">Antall hull</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{([9, 18] as const).map((n) => (
|
|
||||||
<button
|
|
||||||
key={n}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setHolesPlanned(n)}
|
|
||||||
aria-pressed={holesPlanned === n}
|
|
||||||
className={cn(
|
|
||||||
"h-12 flex-1 rounded-xl border text-base font-bold transition-colors",
|
|
||||||
holesPlanned === n
|
|
||||||
? "border-primary bg-primary text-primary-foreground"
|
|
||||||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{n} hull
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={submitting || compatibleTees.length === 0 || !teeName}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="h-14 rounded-2xl text-base font-bold shadow-sm"
|
|
||||||
>
|
|
||||||
<Check aria-hidden="true" className="size-5" />
|
|
||||||
{submitting ? "Oppretter…" : "Start runden"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Delt --------------------------------------------------------------------
|
|
||||||
|
|
||||||
function BackLink({ onClick, label }: { onClick: () => void; label: string }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className="inline-flex items-center gap-1.5 self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeft aria-hidden="true" className="size-4" />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue