round.visibility_mode (privat som trygg standard) + ny tabell for hvilke venne-kategorier som får se en gitt runde. Kjernesjekken (_can_view_round) viste seg å være en ren utvidelse av den eksisterende eier/medspiller-sjekken, så fire lese-endepunkter ble omstrukturert til delte funksjoner og gjenbrukt av sju nye offentlige endepunkter + et nytt offentlig sanntids-WS — i stedet for å bygge alt parallelt fra bunnen. Ny vennprofil-side (/my-friends/[id]) — navn/avatar/HCP/hjemmeklubb + liste over personens synlige runder, "pågår nå" øverst. Ny read-only live-visning (/watch/[id]) for tredjeparter — matchstatus, skins-tavle eller individuell rangering avhengig av spilleform. Synlighetsvelger lagt til både i opprett-runde og rediger-runde. Verifisert grundig: 191 automatiserte sjekker (inkl. full regresjon av to eksisterende testsuiter) + en fullstendig nettleser-gjennomgang med tre reelle brukere i separate innloggingskontekster — inkludert en helt anonym leser som beviste at "offentlig" faktisk betyr offentlig, og en reell venn/kategori-negativ-kontroll som beviste at feil kategori korrekt nekter tilgang. Migrasjon kjørt mot ekte database (kun additiv), begge containere rullet ut, teeoff.no upåvirket.
242 lines
8.9 KiB
TypeScript
242 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",
|
|
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-sm shadow-black/5 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-sm shadow-black/5">
|
|
<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>
|
|
)
|
|
}
|