"Plukket opp" — a new button in the scoring wizard's Slag step (only for Slagspill/Stableford, when handicap is known). Tapping it auto-hops forward just like picking a stroke count, and the server writes the exact Net Double Bogey score (par+2+mottatte slag) — so it flows through the existing HCP/AGS pipeline unchanged, always nets to exactly 0 Stableford points. Shown everywhere as a "PU" badge instead of a raw number: scorekortet, leaderboardet, "Så langt i runden". Along the way, browser testing caught two real bugs (not just theoretical): the new-round form briefly showed a nonsensical "set up sides" notice under Stableford, and — more seriously — the match/skins territory-bar panel was rendering empty "Side A/Side B" bars on a Stableford round. Both traced to the same root cause (=== "stroke" checks that needed to also exclude the new format), and a full grep caught three more of the same pattern in watch-round.tsx before they could reach production. Everything verified in scratch (hand-calculated math matched exactly) and in the browser (all four display surfaces, plus a regression check on an existing match round). Migration 038 applied to the real database, both containers redeployed, teeoff.no unaffected.
243 lines
8.9 KiB
TypeScript
243 lines
8.9 KiB
TypeScript
"use client"
|
|
|
|
// Vennprofil-side (ADR-036 fase 2, 2026-07-28) -- svar på at det tidligere
|
|
// ikke fantes NOEN vei til å gå inn på en venns profil og følge en runde
|
|
// live, kun selve tilgangskontrollen. Viser navn/avatar/hjemmeklubb/HCP
|
|
// (samme lavsensitive felt-sett som personsøket, GET /people/{id}) og en
|
|
// liste over personens runder som SPØRREREN har lov til å se
|
|
// (GET /public/people/{id}/rounds -- backend filtrerer allerede på
|
|
// visibility_mode/venne-kategorisering, denne siden viser bare det den
|
|
// får). Bevisst uten venneskap-administrasjon (send/fjern venn) -- det
|
|
// hører hjemme i /my-friends, denne siden er en ren visning, nåbar for
|
|
// enhver synlig person, ikke bare venner (en offentlig runde er synlig
|
|
// for alle, også fremmede/anonyme).
|
|
|
|
import { useEffect, useState } from "react"
|
|
import Link from "next/link"
|
|
import { ArrowLeft, ChevronRight, MapPin, Radio } from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
type ApiPerson = {
|
|
id: string
|
|
first_name: string
|
|
last_name: string
|
|
avatar_url: string | null
|
|
home_club: string | null
|
|
handicap_index: number | null
|
|
}
|
|
|
|
type ApiPersonRound = {
|
|
id: string
|
|
name: string | null
|
|
course_name_snapshot: string
|
|
played_at: string
|
|
completed_at: string | null
|
|
play_format: string
|
|
}
|
|
|
|
const FORMAT_LABELS: Record<string, string> = {
|
|
stroke: "Slagspill",
|
|
stableford: "Stableford",
|
|
match: "Match",
|
|
skins: "Skins",
|
|
fourball: "Fourball",
|
|
foursome: "Foursome",
|
|
greensome: "Greensome",
|
|
scramble_2: "Scramble (2)",
|
|
scramble_4: "Scramble (4)",
|
|
}
|
|
|
|
const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" })
|
|
|
|
function initials(name: string) {
|
|
const parts = name.trim().split(/\s+/)
|
|
const first = parts[0]?.[0] ?? ""
|
|
const last = parts.length > 1 ? parts[parts.length - 1][0] : ""
|
|
return (first + last).toUpperCase()
|
|
}
|
|
|
|
function Avatar({ name, url, size = "large" }: { name: string; url: string | null; size?: "large" | "small" }) {
|
|
const dim = size === "large" ? "size-16 text-xl" : "size-10 text-sm"
|
|
if (url) {
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
return <img src={url} alt="" className={cn(dim, "shrink-0 rounded-full object-cover")} />
|
|
}
|
|
return (
|
|
<span
|
|
aria-hidden="true"
|
|
className={cn(dim, "flex shrink-0 items-center justify-center rounded-full bg-primary/15 font-bold text-foreground")}
|
|
>
|
|
{initials(name)}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function RoundRow({ round }: { round: ApiPersonRound }) {
|
|
const ongoing = round.completed_at === null
|
|
return (
|
|
<Link
|
|
href={`/watch/${round.id}`}
|
|
className="flex items-center gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/30"
|
|
>
|
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
<span className="flex items-center gap-2">
|
|
<span className="truncate text-base font-bold text-foreground">
|
|
{round.name?.trim() || round.course_name_snapshot}
|
|
</span>
|
|
{ongoing && (
|
|
<span className="flex shrink-0 items-center gap-1 rounded-full bg-primary px-2 py-0.5 text-xs font-bold text-primary-foreground">
|
|
<Radio aria-hidden="true" className="size-3" />
|
|
Pågår nå
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="truncate text-sm text-muted-foreground">
|
|
{round.name?.trim() ? `${round.course_name_snapshot} · ` : ""}
|
|
{FORMAT_LABELS[round.play_format] ?? round.play_format} · {dateFormatter.format(new Date(round.played_at))}
|
|
</span>
|
|
</div>
|
|
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
export function FriendProfile({ userId }: { userId: string }) {
|
|
const [person, setPerson] = useState<ApiPerson | null>(null)
|
|
const [rounds, setRounds] = useState<ApiPersonRound[] | null>(null)
|
|
const [viewerId, setViewerId] = useState<string | null>(null)
|
|
const [notFound, setNotFound] = useState(false)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
fetch("/auth/me", { credentials: "include" })
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
.then((data: { id: string } | null) => {
|
|
if (!cancelled && data) setViewerId(data.id)
|
|
})
|
|
.catch(() => {})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
async function load() {
|
|
const [personRes, roundsRes] = await Promise.all([
|
|
fetch(`/people/${userId}`, { credentials: "include" }),
|
|
fetch(`/public/people/${userId}/rounds`, { credentials: "include" }),
|
|
])
|
|
if (personRes.status === 404) {
|
|
if (!cancelled) setNotFound(true)
|
|
return
|
|
}
|
|
if (!personRes.ok || !roundsRes.ok) return
|
|
const personData: ApiPerson = await personRes.json()
|
|
const roundsData: ApiPersonRound[] = await roundsRes.json()
|
|
if (!cancelled) {
|
|
setPerson(personData)
|
|
setRounds(roundsData)
|
|
}
|
|
}
|
|
void load()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [userId])
|
|
|
|
if (notFound) {
|
|
return (
|
|
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-background px-5 text-center">
|
|
<p className="text-base font-medium text-destructive">Fant ikke denne brukeren.</p>
|
|
<Link href="/my-friends" className="text-base font-semibold text-primary underline underline-offset-2">
|
|
Tilbake til venner
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!person || !rounds) {
|
|
return (
|
|
<div className="flex min-h-dvh flex-col items-center justify-center bg-background">
|
|
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const name = `${person.first_name} ${person.last_name}`.trim()
|
|
const isSelf = viewerId !== null && viewerId === userId
|
|
const ongoing = rounds.filter((r) => r.completed_at === null)
|
|
const finished = rounds.filter((r) => r.completed_at !== null)
|
|
|
|
return (
|
|
<div className="min-h-dvh bg-background">
|
|
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
|
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
|
<Link
|
|
href="/my-friends"
|
|
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
|
>
|
|
<ArrowLeft aria-hidden="true" className="size-6" />
|
|
Profil
|
|
</Link>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
|
<div className="flex items-center gap-4 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
|
<Avatar name={name} url={person.avatar_url} />
|
|
<div className="flex min-w-0 flex-col gap-0.5">
|
|
<span className="truncate text-xl font-extrabold tracking-tight text-foreground">{name}</span>
|
|
{person.home_club && (
|
|
<span className="flex items-center gap-1 truncate text-sm font-semibold text-muted-foreground">
|
|
<MapPin aria-hidden="true" className="size-3.5 shrink-0" />
|
|
{person.home_club}
|
|
</span>
|
|
)}
|
|
{person.handicap_index !== null && (
|
|
<span className="text-sm font-semibold text-muted-foreground">HCP {person.handicap_index.toFixed(1)}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isSelf && (
|
|
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
|
Dette er din egen profil. Gå til{" "}
|
|
<Link href="/my-rounds" className="font-semibold text-primary underline underline-offset-2">
|
|
Egne runder
|
|
</Link>{" "}
|
|
for å se alle rundene dine, inkludert de private.
|
|
</p>
|
|
)}
|
|
|
|
{ongoing.length > 0 && (
|
|
<div className="flex flex-col gap-2">
|
|
<h2 className="text-base font-bold text-foreground">Pågår nå</h2>
|
|
<div className="flex flex-col gap-2">
|
|
{ongoing.map((r) => (
|
|
<RoundRow key={r.id} round={r} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<h2 className="text-base font-bold text-foreground">Tidligere runder</h2>
|
|
{finished.length === 0 ? (
|
|
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
|
{rounds.length === 0
|
|
? "Ingen runder du har tilgang til å se ennå."
|
|
: "Ingen fullførte runder du har tilgang til å se ennå."}
|
|
</p>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{finished.map((r) => (
|
|
<RoundRow key={r.id} round={r} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|