209 lines
8.4 KiB
TypeScript
209 lines
8.4 KiB
TypeScript
import type React from "react"
|
|
import Link from "next/link"
|
|
import { ChevronRight, Gauge, MapPin, Medal, Trophy } from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
export type RoundStatus = "active" | "completed"
|
|
|
|
export type Round = {
|
|
id: string
|
|
name?: string | null
|
|
courseName: string
|
|
status: RoundStatus
|
|
teeName: string
|
|
holes: 9 | 18
|
|
date: string
|
|
playerCount: number
|
|
// Progress for rounds still in play.
|
|
holesPlayed?: number
|
|
// Owner's score, present once at least one hole is recorded.
|
|
totalScore?: number
|
|
toPar?: number
|
|
// Handicap differential, only when a completed round counted (one decimal).
|
|
differential?: number | null
|
|
// Lowest score-to-par among the viewer's own completed rounds (computed
|
|
// by the caller across the full rounds list, not by the card itself).
|
|
isPersonalBest?: boolean
|
|
}
|
|
|
|
const STATUS_CONFIG: Record<RoundStatus, { label: string }> = {
|
|
active: { label: "Pågår" },
|
|
completed: { label: "Fullført" },
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// "+10", "-2" or "E" for level par. The sign carries the meaning (never color alone).
|
|
function formatToPar(toPar: number) {
|
|
if (toPar === 0) return "E"
|
|
return toPar > 0 ? `+${toPar}` : `${toPar}`
|
|
}
|
|
|
|
function toParDescription(toPar: number) {
|
|
if (toPar === 0) return "på par"
|
|
return toPar > 0 ? `${toPar} over par` : `${Math.abs(toPar)} under par`
|
|
}
|
|
|
|
// Status vises nå som fet, farget tekst (samme "form+farge, aldri farge
|
|
// alene"-prinsipp -- teksten SIER "Pågår"/"Fullført", fargen er kun en
|
|
// forsterkning) i stedet for en pille-badge, for en tettere,
|
|
// nøkkel/verdi-orientert korttype.
|
|
const STATUS_TEXT_CLASS: Record<RoundStatus, string> = {
|
|
active: "text-primary",
|
|
completed: "text-brand-orange",
|
|
}
|
|
|
|
function RoundProgressBar({ played, holes }: { played: number; holes: number }) {
|
|
const pct = holes > 0 ? Math.min(100, Math.round((played / holes) * 100)) : 0
|
|
return (
|
|
<div
|
|
className="h-1.5 w-full overflow-hidden rounded-full bg-border"
|
|
role="progressbar"
|
|
aria-valuenow={played}
|
|
aria-valuemin={0}
|
|
aria-valuemax={holes}
|
|
aria-label={`${played} av ${holes} hull spilt`}
|
|
>
|
|
<div className="h-full rounded-full bg-primary" style={{ width: `${pct}%` }} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Kompakt nøkkel/verdi-celle -- selve rammen for grid-strukturen under
|
|
// tittelen (label liten/dempet over, verdi stor/fet under).
|
|
function InfoCell({
|
|
label,
|
|
value,
|
|
valueClassName,
|
|
}: {
|
|
label: string
|
|
value: React.ReactNode
|
|
valueClassName?: string
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{label}</span>
|
|
<span className={cn("text-base font-bold tabular-nums text-foreground", valueClassName)}>{value}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function RoundCard({ round }: { round: Round }) {
|
|
const playerLabel = round.playerCount === 1 ? "spiller" : "spillere"
|
|
const hasScore = typeof round.totalScore === "number" && typeof round.toPar === "number"
|
|
const showDifferential =
|
|
round.status === "completed" &&
|
|
typeof round.differential === "number" &&
|
|
round.differential !== null
|
|
const differentialText =
|
|
showDifferential && round.differential != null
|
|
? round.differential.toFixed(1).replace(".", ",")
|
|
: null
|
|
const showPersonalBest = round.status === "completed" && round.isPersonalBest === true
|
|
|
|
// Concise summary so the compact score tile reads clearly for screen readers.
|
|
const scoreSummary =
|
|
round.status === "completed" && hasScore
|
|
? `Resultat ${round.totalScore} slag, ${toParDescription(round.toPar as number)}.`
|
|
: `${round.holesPlayed ?? 0} av ${round.holes} hull spilt.`
|
|
const displayTitle = round.name?.trim() || round.courseName
|
|
const ariaLabel = `${displayTitle}, ${STATUS_CONFIG[round.status].label}. ${scoreSummary}`
|
|
|
|
const played = round.holesPlayed ?? 0
|
|
const showResultRow = round.status === "completed" && hasScore
|
|
const toPar = round.toPar as number | undefined
|
|
const toParClassName = toPar === undefined ? "" : toPar > 0 ? "text-brand-orange" : toPar < 0 ? "text-primary" : ""
|
|
|
|
return (
|
|
// Egen ytre wrapper (2026-07-26) -- kortet var tidligere HELE selve
|
|
// lenken til rundesiden; en leaderboard-lenke kan derfor ikke ligge
|
|
// NESTET inni den (ugyldig HTML, samme klasse feil som tidligere
|
|
// nestede-form-/knapp-feller i prosjektet). Hovedinnholdet er fortsatt
|
|
// én stor lenke til rundesiden, leaderboard-lenken en egen, separat
|
|
// rad under.
|
|
<div className="flex flex-col gap-2">
|
|
<Link
|
|
href={`/my-rounds/${round.id}`}
|
|
aria-label={ariaLabel}
|
|
className="group flex w-full flex-col gap-3 rounded-2xl border border-border bg-card p-4 text-left shadow-md shadow-black/8 transition-all duration-200 ease-in-out 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 sm:p-5"
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex min-w-0 flex-col">
|
|
<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">{displayTitle}</span>
|
|
</h3>
|
|
{round.name?.trim() && (
|
|
<span className="truncate pl-7 text-sm text-muted-foreground">{round.courseName}</span>
|
|
)}
|
|
</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"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<span className={cn("text-sm font-bold", STATUS_TEXT_CLASS[round.status])}>
|
|
{STATUS_CONFIG[round.status].label}
|
|
</span>
|
|
{round.status === "active" && (
|
|
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
|
{played}/{round.holes} hull spilt
|
|
</span>
|
|
)}
|
|
{showPersonalBest && (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-gold/20 px-2.5 py-1 text-xs font-bold text-foreground">
|
|
<Medal aria-hidden="true" className="size-3.5 text-gold" />
|
|
Personlig rekord
|
|
</span>
|
|
)}
|
|
</div>
|
|
{round.status === "active" && <RoundProgressBar played={played} holes={round.holes} />}
|
|
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3 border-t border-border pt-3">
|
|
<InfoCell label="Tee" value={round.teeName} />
|
|
<InfoCell label="Hull" value={round.holes} />
|
|
<InfoCell label="Dato" value={formatDate(round.date)} />
|
|
<InfoCell label="Spillere" value={`${round.playerCount} ${playerLabel}`} />
|
|
{showResultRow && (
|
|
<>
|
|
<InfoCell label="Resultat" value={round.totalScore} valueClassName="text-lg" />
|
|
<InfoCell label="Til par" value={formatToPar(toPar as number)} valueClassName={cn("text-lg", toParClassName)} />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{differentialText && (
|
|
<span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-info/15 px-2.5 py-0.5 text-sm font-semibold text-foreground">
|
|
<Gauge aria-hidden="true" className="size-4 shrink-0 text-info" />
|
|
<span className="tabular-nums">Hcp spilt til {differentialText}</span>
|
|
</span>
|
|
)}
|
|
</Link>
|
|
|
|
{/* Leaderboard-lenke (2026-07-26) -- kun meningsfullt med flere enn
|
|
én deltaker. Vises for BÅDE pågående og fullførte runder (en
|
|
fullført rundes sluttstilling er fortsatt nyttig å se). */}
|
|
{round.playerCount > 1 && (
|
|
<Link
|
|
href={`/my-rounds/${round.id}/leaderboard`}
|
|
className="flex min-h-11 items-center gap-1.5 self-start rounded-xl px-4 text-sm font-semibold text-primary transition-colors hover:bg-primary/10"
|
|
>
|
|
<Trophy aria-hidden="true" className="size-4" />
|
|
Se leaderboard
|
|
</Link>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|