diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 21a523e..1081d05 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -358,7 +358,9 @@ "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/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": [ "/opt/teeoff/deploy", diff --git a/frontend/app/rounds/new/page.tsx b/frontend/app/rounds/new/page.tsx index 8c8dccf..e870adb 100644 --- a/frontend/app/rounds/new/page.tsx +++ b/frontend/app/rounds/new/page.tsx @@ -1,4 +1,4 @@ -import { NewRound } from "@/components/round-new" +import { NewRound } from "@/components/new-round" export default function NewRoundPage() { return diff --git a/frontend/app/rounds/page.tsx b/frontend/app/rounds/page.tsx index 9d7ba8b..18bc1b0 100644 --- a/frontend/app/rounds/page.tsx +++ b/frontend/app/rounds/page.tsx @@ -1,5 +1,5 @@ -import { PersonalRounds } from "@/components/personal-rounds" +import { OwnRounds } from "@/components/own-rounds" export default function RoundsPage() { - return + return } diff --git a/frontend/components/new-round.tsx b/frontend/components/new-round.tsx new file mode 100644 index 0000000..0b311cb --- /dev/null +++ b/frontend/components/new-round.tsx @@ -0,0 +1,1153 @@ +"use client" + +// Opprett en frittstående runde (ADR-033). Presentasjon fra V0, datalag +// skrevet om fra mock til ekte søk/opprettelse/bekreftelse mot API-et. +// To bane-kilder: offisiell teeoff-bane (live oppslag, ADR-033 Beslutning C +// -- ingen lokal kopi lagres) eller en egendefinert bane (organisasjons- +// uavhengig katalog, søkbar på tvers av alle brukere). + +import type React from "react" +import { useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import { + ArrowLeft, + Building2, + Check, + Flag, + MapPin, + Pencil, + Plus, + Search, + Trophy, +} from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" +import { Wordmark } from "@/components/wordmark" +import { cn } from "@/lib/utils" + +// --- Types ----------------------------------------------------------------- + +type Rating = { courseRating: string; slope: string; par: string } + +type Tee = { + id: string + name: string + men: boolean + women: boolean + menRating?: Rating + womenRating?: Rating +} + +type Course = { + id: string + name: string + tees: Tee[] +} + +type OfficialClub = { + id: string + name: string + location: string + courses: Course[] +} + +type Step = + | "source" + | "official-search" + | "official-courses" + | "own-search" + | "own-create" + | "confirm" + +// Hvilken bane-kilde+id som faktisk skal sendes til /rounds -- Course-typen +// over (fra V0) bærer kun det UI-et trenger, ikke dette. Holdt separat i +// stedet for å presse det inn i Course, som er felles for begge kilder. +type CourseMeta = + | { source: "teeoff"; facilitySlug: string; teeoffCourseId: number } + | { source: "custom"; personalCourseId: string } + +type Gender = "m" | "f" + +// --- API-typer --------------------------------------------------------------- + +type ApiTeeOption = { name: string; genders: Gender[] } +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: ApiTeeOption[] +} +type ApiPersonalCourse = { id: string; name: string } +type ApiPersonalCourseDetail = { id: string; name: string; tees: ApiTeeOption[] } + +function apiTeesToTees(apiTees: ApiTeeOption[]): Tee[] { + return apiTees.map((t, i) => ({ + id: `${t.name}-${i}`, + name: t.name, + men: t.genders.includes("m"), + women: t.genders.includes("f"), + })) +} + +// --- Root component -------------------------------------------------------- + +export function NewRound() { + const router = useRouter() + const [step, setStep] = useState("source") + + const [ownGender, setOwnGender] = useState(null) + const [loadingMe, setLoadingMe] = useState(true) + + // Official flow + const [selectedClub, setSelectedClub] = useState(null) + const [resolvingClub, setResolvingClub] = useState(false) + + // Chosen course carried into the confirm step (from any source), plus + // which source/id it actually came from -- needed to build the /rounds + // payload correctly. + const [course, setCourse] = useState(null) + const [courseMeta, setCourseMeta] = useState(null) + + 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 chooseCourse(next: Course, meta: CourseMeta) { + setCourse(next) + setCourseMeta(meta) + setStep("confirm") + } + + async function selectClub(club: OfficialClub) { + setSelectedClub(club) + setResolvingClub(true) + try { + const res = await fetch(`/rounds/official-search/${club.id}`, { credentials: "include" }) + if (!res.ok) throw new Error(`facility detail: ${res.status}`) + const detail: { courses: ApiOfficialCourseOption[] } = await res.json() + const withCourses: OfficialClub = { + ...club, + courses: detail.courses.map((c) => ({ + id: String(c.teeoff_course_id), + name: c.name, + tees: apiTeesToTees(c.tees), + })), + } + setSelectedClub(withCourses) + setStep("official-courses") + } catch { + setSelectedClub(null) + } finally { + setResolvingClub(false) + } + } + + function chooseOfficialCourse(officialCourse: Course) { + if (!selectedClub) return + chooseCourse(officialCourse, { + source: "teeoff", + facilitySlug: selectedClub.id, + teeoffCourseId: Number(officialCourse.id), + }) + } + + async function selectOwnCourse(picked: ApiPersonalCourse) { + const res = await fetch(`/personal-courses/${picked.id}`, { credentials: "include" }) + if (!res.ok) return + const detail: ApiPersonalCourseDetail = await res.json() + chooseCourse( + { id: detail.id, name: detail.name, tees: apiTeesToTees(detail.tees) }, + { source: "custom", personalCourseId: detail.id }, + ) + } + + function ownCourseCreated(created: { id: string; name: string; tees: Tee[] }) { + chooseCourse( + { id: created.id, name: created.name, tees: created.tees }, + { source: "custom", personalCourseId: created.id }, + ) + } + + async function submitRound(payload: { + teeName: string + date: string + startHole: number + holes: 9 | 18 + }): Promise<{ id: string }> { + if (!courseMeta) throw new Error("Mangler valgt bane") + const body = + courseMeta.source === "teeoff" + ? { + course_source: "teeoff", + teeoff_facility_slug: courseMeta.facilitySlug, + teeoff_course_id: courseMeta.teeoffCourseId, + tee_name: payload.teeName, + played_at: payload.date, + start_hole: payload.startHole, + holes_planned: payload.holes, + } + : { + course_source: "custom", + personal_course_id: courseMeta.personalCourseId, + tee_name: payload.teeName, + played_at: payload.date, + start_hole: payload.startHole, + holes_planned: payload.holes, + } + const res = await fetch("/rounds", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(body), + }) + if (!res.ok) throw new Error(`create round: ${res.status}`) + return res.json() + } + + return ( +
+
+
+ router.push("/rounds")} /> + +
+
+ +
+ {loadingMe ? ( +

Laster …

+ ) : !ownGender ? ( +
+ Profilen din mangler registrert kjønn, som trengs for å beregne banehandicap riktig.{" "} + + Gå til kontoinnstillinger + + . +
+ ) : ( + <> + {step === "source" && ( + setStep("official-search")} + onOwn={() => setStep("own-search")} + /> + )} + + {step === "official-search" && ( + + )} + + {step === "official-courses" && selectedClub && ( + + )} + + {step === "own-search" && ( + setStep("own-create")} /> + )} + + {step === "own-create" && ( + setStep("own-search")} /> + )} + + {step === "confirm" && course && ( + router.replace(`/rounds/${id}`)} + /> + )} + + )} +
+
+ ) +} + +// --- Back link ------------------------------------------------------------- + +function BackLink({ + step, + setStep, + onExit, +}: { + step: Step + setStep: (step: Step) => void + onExit: () => void +}) { + const config: Record void }> = { + source: { label: "Til egne runder", onClick: onExit }, + "official-search": { label: "Tilbake", onClick: () => setStep("source") }, + "official-courses": { label: "Tilbake til søk", onClick: () => setStep("official-search") }, + "own-search": { label: "Tilbake", onClick: () => setStep("source") }, + "own-create": { label: "Tilbake til søk", onClick: () => setStep("own-search") }, + confirm: { label: "Tilbake", onClick: () => setStep("source") }, + } + + const { label, onClick } = config[step] + + return ( + + ) +} + +// --- Step header ----------------------------------------------------------- + +function StepHeader({ title, description }: { title: string; description: string }) { + return ( +
+

+ {title} +

+

+ {description} +

+
+ ) +} + +// --- Step 1: source -------------------------------------------------------- + +function SourceStep({ onOfficial, onOwn }: { onOfficial: () => void; onOwn: () => void }) { + return ( +
+ +
+
+
+ ) +} + +function SourceCard({ + icon, + title, + description, + onClick, +}: { + icon: React.ReactNode + title: string + description: string + onClick: () => void +}) { + return ( + + ) +} + +// --- Step 2a: official search ---------------------------------------------- + +function OfficialSearchStep({ + onSelectClub, + resolving, +}: { + onSelectClub: (club: OfficialClub) => void + resolving: boolean +}) { + const [query, setQuery] = useState("") + const [results, setResults] = useState(null) + const [searching, setSearching] = useState(false) + const [error, setError] = useState(null) + + async function handleSearch(e: React.FormEvent) { + e.preventDefault() + 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}`) + setResults(await res.json()) + } catch { + setError("Klarte ikke å søke i baneregisteret akkurat nå.") + setResults([]) + } finally { + setSearching(false) + } + } + + return ( +
+ + +
+
+
+ +
+ + {error &&

{error}

} + + {results !== null && ( +
+ {results.length === 0 ? ( +

+ Ingen klubber matcher «{query}». Prøv et annet søk. +

+ ) : ( +
    + {results.map((facility) => ( +
  • + +
  • + ))} +
+ )} + {resolving &&

Henter baner …

} +
+ )} +
+ ) +} + +// --- Step 2a (cont): courses at club --------------------------------------- + +function OfficialCoursesStep({ + club, + onSelectCourse, +}: { + club: OfficialClub + onSelectCourse: (course: Course) => void +}) { + return ( +
+ +
    + {club.courses.map((course) => ( +
  • + +
  • + ))} + {club.courses.length === 0 && ( +
  • + Ingen 18-hulls baner registrert hos dette anlegget ennå. +
  • + )} +
+
+ ) +} + +// --- Step 2b: own course search -------------------------------------------- + +function OwnSearchStep({ + onSelectCourse, + onCreate, +}: { + onSelectCourse: (course: ApiPersonalCourse) => void + onCreate: () => void +}) { + const [query, setQuery] = useState("") + const [results, setResults] = useState([]) + const [error, setError] = useState(null) + + 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]) + + return ( +
+ + + {error &&

{error}

} + +
+
+ + {results.length > 0 && ( +
    + {results.map((course) => ( +
  • + +
  • + ))} +
+ )} + + {results.length === 0 && ( +

+ {query.trim() ? `Ingen egne baner matcher «${query}».` : "Skriv for å søke, eller opprett en ny bane under."} +

+ )} + + +
+ ) +} + +// --- Step 2b (cont): create course ----------------------------------------- + +function emptyHoles() { + return Array.from({ length: 18 }, (_, i) => ({ + hole: i + 1, + par: "4", + strokeIndex: String(i + 1), + })) +} + +function parseDecimal(value: string): number { + return Number(value.replace(",", ".")) +} + +function OwnCreateStep({ + onCreated, + onCancel, +}: { + onCreated: (course: { id: string; name: string; tees: Tee[] }) => void + onCancel: () => void +}) { + const [name, setName] = useState("") + const [holes, setHoles] = useState(emptyHoles) + const [tees, setTees] = useState([ + { id: `nt-${Date.now()}`, name: "", men: true, women: false }, + ]) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + function updateHole(index: number, field: "par" | "strokeIndex", value: string) { + setHoles((prev) => prev.map((h, i) => (i === index ? { ...h, [field]: value } : h))) + } + + function updateTee(id: string, patch: Partial) { + setTees((prev) => prev.map((t) => (t.id === id ? { ...t, ...patch } : t))) + } + + function addTee() { + setTees((prev) => [...prev, { id: `nt-${Date.now()}`, name: "", men: false, women: false }]) + } + + function removeTee(id: string) { + setTees((prev) => (prev.length > 1 ? prev.filter((t) => t.id !== id) : prev)) + } + + const indexesValid = useMemo( + () => new Set(holes.map((h) => h.strokeIndex)).size === 18, + [holes], + ) + const teesValid = tees.every( + (t) => + t.name.trim() !== "" && + (t.men || t.women) && + (!t.men || (t.menRating && t.menRating.courseRating.trim() && t.menRating.slope.trim() && t.menRating.par.trim())) && + (!t.women || (t.womenRating && t.womenRating.courseRating.trim() && t.womenRating.slope.trim() && t.womenRating.par.trim())), + ) + const canSubmit = name.trim() !== "" && indexesValid && 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) => ({ + hole_number: h.hole, + par: Number(h.par), + stroke_index: Number(h.strokeIndex), + })), + tees: tees.map((t) => ({ + name: t.name.trim(), + ratings: [ + ...(t.men && t.menRating + ? [{ gender: "m", course_rating: parseDecimal(t.menRating.courseRating), slope_rating: Number(t.menRating.slope), par: Number(t.menRating.par) }] + : []), + ...(t.women && t.womenRating + ? [{ gender: "f", course_rating: parseDecimal(t.womenRating.courseRating), slope_rating: Number(t.womenRating.slope), par: Number(t.womenRating.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({ id: created.id, name: created.name, tees: tees.map((t) => ({ ...t, name: t.name.trim() })) }) + } catch { + setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.") + } finally { + setSubmitting(false) + } + } + + return ( +
+ + + {error &&

{error}

} + + {/* Name */} +
+ + setName(e.target.value)} + placeholder="F.eks. Hyttebanen" + className="h-14 rounded-2xl text-lg" + /> +
+ + {/* Holes */} +
+
+

Hull

+

Sett par og stroke-indeks for hvert hull.

+
+ {!indexesValid && ( +

+ Hver stroke-indeks (1–18) må brukes nøyaktig én gang. +

+ )} +
+
+ Hull + Par + Stroke-indeks +
+
    + {holes.map((h, index) => ( +
  • + Hull {h.hole} + + +
  • + ))} +
+
+
+ + {/* Tees */} +
+
+

Utslag

+

Legg til ett eller flere utslag med rating.

+
+ + {tees.map((t, index) => ( + 1} + onChange={(patch) => updateTee(t.id, patch)} + onRemove={() => removeTee(t.id)} + /> + ))} + + +
+ +
+ + +
+ + ) +} + +function TeeEditor({ + tee, + index, + canRemove, + onChange, + onRemove, +}: { + tee: Tee + index: number + canRemove: boolean + onChange: (patch: Partial) => void + onRemove: () => void +}) { + return ( +
+
+
+ + onChange({ name: e.target.value })} + placeholder="F.eks. Gul" + className="h-12 rounded-xl text-base" + /> +
+ {canRemove && ( + + )} +
+ + + onChange({ men, menRating: men ? tee.menRating ?? { courseRating: "", slope: "", par: "" } : tee.menRating }) + } + onRatingChange={(menRating) => onChange({ menRating })} + idPrefix={`men-${tee.id}`} + /> + + onChange({ women, womenRating: women ? tee.womenRating ?? { courseRating: "", slope: "", par: "" } : tee.womenRating }) + } + onRatingChange={(womenRating) => onChange({ womenRating })} + idPrefix={`women-${tee.id}`} + /> +
+ ) +} + +function RatingToggle({ + label, + enabled, + rating, + onToggle, + onRatingChange, + idPrefix, +}: { + label: string + enabled: boolean + rating?: Rating + onToggle: (enabled: boolean) => void + onRatingChange: (rating: Rating) => void + idPrefix: string +}) { + const current = rating ?? { courseRating: "", slope: "", par: "" } + + function update(field: keyof Rating, value: string) { + onRatingChange({ ...current, [field]: value }) + } + + return ( +
+
+ + +
+ {enabled && ( +
+
+ + update("courseRating", e.target.value)} + placeholder="71,2" + className="h-12 rounded-xl text-base" + /> +
+
+ + update("slope", e.target.value)} + placeholder="132" + className="h-12 rounded-xl text-base" + /> +
+
+ + update("par", e.target.value)} + placeholder="72" + className="h-12 rounded-xl text-base" + /> +
+
+ )} +
+ ) +} + +// --- Step 3: confirm ------------------------------------------------------- + +function todayIso() { + const now = new Date() + const offset = now.getTimezoneOffset() + const local = new Date(now.getTime() - offset * 60 * 1000) + return local.toISOString().slice(0, 10) +} + +function ConfirmStep({ + course, + ownGender, + onSubmit, + onCreated, +}: { + course: Course + ownGender: Gender + onSubmit: (payload: { teeName: string; date: string; startHole: number; holes: 9 | 18 }) => Promise<{ id: string }> + onCreated: (roundId: string) => void +}) { + const compatibleTees = course.tees.filter((t) => (ownGender === "m" ? t.men : t.women)) + const [teeId, setTeeId] = useState(compatibleTees[0]?.id ?? "") + const [date, setDate] = useState(todayIso) + const [startHole, setStartHole] = useState("1") + const [holes, setHoles] = useState<9 | 18>(18) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const selectedTee = compatibleTees.find((t) => t.id === teeId) + + async function handleStart() { + if (!selectedTee) return + setSubmitting(true) + setError(null) + try { + const created = await onSubmit({ + teeName: selectedTee.name, + date, + startHole: Number(startHole), + holes, + }) + onCreated(created.id) + } catch { + setError("Klarte ikke å opprette runden. Prøv igjen.") + setSubmitting(false) + } + } + + return ( +
+ + + {/* Course */} +
+
+
+
+ Valgt bane + {course.name} +
+
+ + {error &&

{error}

} + + {/* Tee */} + {compatibleTees.length === 0 ? ( +

+ 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. +

+ ) : ( +
+ Utslag +
+ {compatibleTees.map((t) => ( + + ))} +
+
+ )} + + {/* Date + start hole */} +
+
+ + setDate(e.target.value)} + className="h-14 rounded-2xl text-lg" + /> +
+
+ + +
+
+ + {/* Hole count */} +
+ Antall hull +
+ {([9, 18] as const).map((count) => ( + + ))} +
+
+ + +
+ ) +} diff --git a/frontend/components/own-rounds.tsx b/frontend/components/own-rounds.tsx new file mode 100644 index 0000000..66ef3ee --- /dev/null +++ b/frontend/components/own-rounds.tsx @@ -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(null) + const [error, setError] = useState(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 ( +
+
+
+ +
+
+ +
+
+
+

+ Egne runder +

+

+ Frittstående golfrunder du har registrert selv, uavhengig av turnering og klubb. +

+
+ + +
+ + {error && ( +

+ {error} +

+ )} + +
+ {rounds === null ? ( + + ) : rounds.length === 0 ? ( + + ) : ( +
    + {rounds.map((round) => ( +
  • + +
  • + ))} +
+ )} +
+
+
+ ) +} + +function LoadingState() { + return ( +
+
+ ) +} + +function EmptyState() { + return ( +
+
+
+
+

Ingen runder ennå

+

+ Her dukker rundene dine opp etter hvert som du registrerer dem. Trykk på{" "} + «Ny runde» øverst for å komme i + gang. +

+
+
+ ) +} diff --git a/frontend/components/personal-rounds.tsx b/frontend/components/personal-rounds.tsx deleted file mode 100644 index c55b087..0000000 --- a/frontend/components/personal-rounds.tsx +++ /dev/null @@ -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(null) - const [error, setError] = useState(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 ( -
-
-
- -
-
- -
-
-
-

Egne runder

-

- Registrer en runde på egen hånd, med eller uten turnering, og få detaljert statistikk. -

-
- - - -
- - {error && ( -

- {error} -

- )} - - {rounds === null ? ( -
- - ) : rounds.length === 0 ? ( -
-
-
-
-

Ingen runder registrert ennå

-

- Trykk på «Ny runde» for å registrere den første. -

-
-
- ) : ( -
    - {rounds.map((r) => ( -
  • - -
    -
    -

    {r.course_name_snapshot}

    - {r.completed_at ? ( - - Fullført - - ) : ( - - Pågår - - )} -
    - - {r.tee_name_snapshot} · {r.holes_planned} hull · {formatDate(r.played_at)} - -
    -
    -
    -
  • - ))} -
- )} -
-
- ) -} - -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) -} diff --git a/frontend/components/round-card.tsx b/frontend/components/round-card.tsx new file mode 100644 index 0000000..9432cd0 --- /dev/null +++ b/frontend/components/round-card.tsx @@ -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 = { + 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 ( + + + ) +} + +export function RoundCard({ round }: { round: Round }) { + const playerLabel = round.playerCount === 1 ? "spiller" : "spillere" + + return ( + +
+
+

+

+ +
+ +
+ + + + + + +
+
+ +