teecup/frontend/components/dashboard.tsx
Erol Haagenrud 2562e20ec3 13-årsgrense — håndhevet server-side (eneste vei inn i appen, ADR-051) og med umiddelbar rød feilmelding klientside i begge profil-skjemaer. Testet på nøyaktig dagsgrensen.
PWA-installasjonstekst — skiller nå Safari/Chrome på iOS, ikke lenger feilaktig Safari-only.
Beskrivelsestekst — "golfklubber" fjernet, nytt punkt om at turneringsadmin/-presentasjon fungerer godt på PC.
Dette krever både teecup_api og teecup_frontend (ingen migrasjon).
2026-08-10 07:00:11 +02:00

1258 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
// Dashbord-redesign (ADR-035, 2026-07-25): organisasjon er ikke lenger det
// første/viktigste en bruker møter -- en bruker er først og fremst en
// GOLFSPILLER. Presentasjon fra V0 (zip 18), datalag skrevet om fra mock
// til ekte fetch. Organisasjon-opprettelse er nå USYNLIG/automatisk ("Ny
// turnering" oppretter/gjenbruker en organisasjon i bakgrunnen, ingen eget
// "opprett organisasjon"-steg for det vanlige tilfellet) -- se
// ARCHITECTURE_DECISIONS.md ADR-035 Beslutning A.
//
// "Forest Green"-visuell redesign (2026-08-01, V0 zip 23, samme retning som
// login-form.tsx): kun JSX/farger endret nedenfor -- all ekte datahenting,
// alle ekte handlers og alle delte komponenter (RoundCard/TournamentCard/
// InstallPrompt/NewTournamentInline/JoinByCodeInline) er UENDRET, kun
// re-stylet der de selv definerer sin egen visuelle ramme.
//
// "Venner på banen" (ny seksjon, plassert rett under hurtighandlingene per
// brukerens eksplisitte ønske fra en tidligere Stitch-designsamtale) er en
// HELT NY funksjon -- viser venner med en AKTIV runde nå, eller en runde
// fullført innen de siste 24 timene, hentet fra det nye
// GET /friends/on-course-endepunktet (app/routers/rounds.py). Synlighet
// respekterer nøyaktig samme regel som all annen rundedeling (ADR-036
// fase 2, `_can_view_round`s 'friends'-gren) -- håndhevet server-side, ikke
// duplisert her.
//
// "Clubhouse"-visuell redesign (2026-08-07, V0, egen parallell utforskning
// -- IKKE samme retning som "Forest Green" over). Brukeren: "full
// overtagelse, også bakgrunnen" -- eksplisitt bekreftet å gå imot
// 2026-08-02-fiksen som fjernet en utilsiktet grønn hue fra de nøytrale
// tokenene (DESIGN_SYSTEM.md), siden clubhouse-bakgrunnen er en BEVISST
// varm/turf-tonet off-white, ikke en utilsiktet lekkasje. Gjenbruker
// `--tee`/`--tee-strong`/`--cup`/`--cup-strong`/`--clubhouse-*`-tokenene og
// `TeeCupWordmark` som ALLEREDE fantes i `globals.css`/`components/teecup/`
// fra en tidligere, ennå ikke integrert `/logg-inn`-utforskning -- samme
// palett, ikke funnet opp på nytt. Bevisst IKKE lagt til Bricolage-
// visningsfonten (samme utelatelse som `/logg-inn` selv gjorde) -- Nunito
// beholdt for å unngå en ekstra font-avhengighet for kun to skjermer.
//
// Omfangsgrense (delt komponent IKKE re-stylet her, se CHANGELOG.md):
// `RoundCard`/`TournamentCard` (også brukt av /my-rounds) beholder sin
// eksisterende styling inntil videre -- kun selve dashbord-skjermens
// EGNE, ikke-delte JSX er re-stylet i denne runden.
//
// Bunn-navigasjonen (2026-08-08, egen V0-prompt, se CHANGELOG.md): den
// gamle `BottomTabBar` (definert her, `active`-prop-styrt) er ERSTATTET av
// `BottomNav` (`components/teecup/bottom-nav.tsx`) -- en selvstendig,
// bevisst palett-nøytral "flytende hvit flate" som leser pathname selv
// via `usePathname()` i stedet for å få en `active`-prop utenfra, siden
// den deles mellom clubhouse- og Forest Green-sider. hrefs rettet til de
// faktiske rutene under integrering (V0 kjente dem ikke).
import type React from "react"
import { useCallback, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import {
ArrowRight,
Bell,
Building2,
ChevronRight,
Flag,
KeyRound,
LogOut,
MapPin,
Trophy,
TrendingDown,
TrendingUp,
UserCircle,
UserPlus,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { InstallPrompt } from "@/components/install-prompt"
import { TeeCupWordmark } from "@/components/teecup/wordmark"
import { BottomNav } from "@/components/teecup/bottom-nav"
import { RoundCard, type Round } from "@/components/round-card"
import { TournamentCard, type Tournament } from "@/components/tournament-card"
import { type TournamentStatus } from "@/components/tournament-status-badge"
import { cn } from "@/lib/utils"
// Kategoriske pastellfarger for "Venner på banen" -- én tone per person, i
// stedet for én ensfarget tone. Hver par en lys bakgrunn med en mørk,
// kontrastrik tekst-tone for initialene.
const AVATAR_HUES = [
{ bg: "#dbeafe", ink: "#1e40af" }, // lys blå
{ bg: "#ffedd5", ink: "#9a3412" }, // lys oransje
{ bg: "#dcfce7", ink: "#166534" }, // lys grønn
{ bg: "#fce7f3", ink: "#9d174d" }, // lys rosa
] as const
// --- API-typer ---------------------------------------------------------------
type MyOrg = { organization_id: string; name: string; role: string }
// ADR-031: turneringer brukeren er SPILLER i (rostret på et lag), på tvers
// av organisasjoner, uavhengig av organisasjonsmedlemskap.
type MyTournament = {
organization_id: string
organization_name: string
tournament_id: string
tournament_name: string
status: TournamentStatus
team_id: string
team_name: string
team_color: string | null
next_session_at: string | null
my_session_id: string | null
my_match_id: string | null
}
type Me = {
id: string
email: string
display_name: string
first_name: string | null
preferred_locale: string
profile_complete: boolean
handicap_index: number | null
organizations: MyOrg[]
my_tournaments: MyTournament[]
}
type ApiTournament = {
id: string
name: string
status: TournamentStatus
start_date: string | null
end_date: string | null
}
type ApiRoundParticipant = {
user_id: string | null
is_owner: boolean
counts_for_handicap: boolean
score_differential: number | null
}
type ApiRound = {
id: string
name: string | null
course_name_snapshot: string
tee_name_snapshot: string
played_at: string
holes_planned: number
completed_at: string | null
participants: ApiRoundParticipant[]
my_holes_played: number
my_total_score: number | null
my_score_to_par: number | null
my_net_score_to_par: number | null
play_format: string
my_match_status: string | null
my_match_lead: number | null
}
type ApiHandicapPoint = { handicap_index: number; recorded_at: string }
// ADR-036 fase 1 -- kun det dashbordets kompakte kort trenger (ikke hele
// GET /friends-responsen, som også bærer utgående forespørsler/kategorier).
type ApiFriendsSummary = {
friends: { first_name: string | null; last_name: string | null }[]
incoming_requests: unknown[]
}
// "Venner på banen" -- speiler FriendOnCourseEntry i app/routers/rounds.py.
type ApiFriendOnCourse = {
round_id: string
friend_user_id: string
friend_name: string
course_name: string
play_format: string
status: "playing" | "recently_finished"
holes_played: number
holes_planned: number
score_to_par: number | null
started_at: string | null
completed_at: string | null
}
function toTournament(t: ApiTournament): Tournament {
return { id: t.id, name: t.name, status: t.status, startDate: t.start_date ?? undefined, endDate: t.end_date ?? undefined }
}
function toRound(r: ApiRound, viewerId: string | null, personalBestRoundId: string | null): Round {
const me = (viewerId && r.participants.find((p) => p.user_id === viewerId)) || r.participants.find((p) => p.is_owner)
return {
id: r.id,
name: r.name,
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,
holesPlayed: r.my_holes_played,
totalScore: r.my_total_score ?? undefined,
toPar: r.my_score_to_par ?? undefined,
netToPar: r.my_net_score_to_par ?? undefined,
playFormat: r.play_format,
matchStatus: r.my_match_status,
matchLead: r.my_match_lead,
differential: me?.counts_for_handicap ? me.score_differential : null,
isPersonalBest: personalBestRoundId !== null && r.id === personalBestRoundId,
}
}
// Laveste egne til-par blant minst to fullførte runder -- én eneste
// fullført runde er ikke ennå meningsfullt en "rekord".
function findPersonalBestRoundId(completedRounds: ApiRound[]): string | null {
if (completedRounds.length < 2) return null
let best: { id: string; toPar: number } | null = null
for (const r of completedRounds) {
if (r.my_score_to_par === null) continue
if (!best || r.my_score_to_par < best.toPar) best = { id: r.id, toPar: r.my_score_to_par }
}
return best?.id ?? null
}
// --- Kombinert turnering-oppføring (deltaker OG/ELLER arrangør) ------------
type CombinedTournament = { tournament: Tournament; organizer: boolean; orgId: string; sortKey: string | null }
function combineTournaments(myTournaments: MyTournament[], organizerLists: { orgId: string; tournaments: ApiTournament[] }[]): CombinedTournament[] {
const byId = new Map<string, CombinedTournament>()
for (const t of myTournaments) {
byId.set(t.tournament_id, {
tournament: { id: t.tournament_id, name: t.tournament_name, status: t.status, startDate: t.next_session_at ?? undefined },
organizer: false,
orgId: t.organization_id,
sortKey: t.next_session_at,
})
}
for (const { orgId, tournaments } of organizerLists) {
for (const t of tournaments) {
const existing = byId.get(t.id)
byId.set(t.id, {
tournament: existing?.tournament ?? toTournament(t),
organizer: true,
orgId: existing?.orgId ?? orgId,
sortKey: existing?.sortKey ?? t.start_date,
})
}
}
// Kun "kommende" -- fullførte/arkiverte turneringer skal ikke fylle opp
// en "kommende"-liste, uansett dato.
const upcoming = [...byId.values()].filter((c) => c.tournament.status === "draft" || c.tournament.status === "active")
upcoming.sort((a, b) => {
if (a.sortKey && b.sortKey) return a.sortKey < b.sortKey ? -1 : 1
if (a.sortKey) return -1
if (b.sortKey) return 1
return a.tournament.name.localeCompare(b.tournament.name)
})
return upcoming
}
// --- Formattering ------------------------------------------------------------
function formatSigned(value: number, decimals = 1): string {
if (value === 0) return decimals === 0 ? "0" : `0,${"0".repeat(decimals)}`
const abs = Math.abs(value).toFixed(decimals).replace(".", ",")
return value > 0 ? `+${abs}` : `${abs}`
}
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)
}
// "for N timer/minutter siden" -- kun brukt av "Venner på banen" sin
// "nylig fullført"-status.
function timeAgoLabel(iso: string): string {
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return ""
const minutes = Math.max(1, Math.round((Date.now() - then) / 60000))
if (minutes < 60) return `${minutes} min siden`
const hours = Math.round(minutes / 60)
return `${hours} ${hours === 1 ? "time" : "timer"} siden`
}
// --- Root component ----------------------------------------------------------
export function Dashboard() {
const router = useRouter()
const [me, setMe] = useState<Me | null>(null)
const [loadingMe, setLoadingMe] = useState(true)
const [rounds, setRounds] = useState<ApiRound[] | null>(null)
const [combinedTournaments, setCombinedTournaments] = useState<CombinedTournament[]>([])
const [hcpHistory, setHcpHistory] = useState<ApiHandicapPoint[]>([])
const [friendsSummary, setFriendsSummary] = useState<ApiFriendsSummary | null>(null)
const [friendsOnCourse, setFriendsOnCourse] = useState<ApiFriendOnCourse[]>([])
const [unreadNotifications, setUnreadNotifications] = useState(0)
const [error, setError] = useState<string | null>(null)
const loadMe = useCallback(async () => {
try {
const res = await fetch("/auth/me", { credentials: "include" })
if (!res.ok) {
router.replace("/logg-inn")
return
}
const data: Me = await res.json()
// Ikke rett til /account -- se ARCHITECTURE_DECISIONS.md-tillegg
// 2026-08-09: /velkommen viser kontekst før skjemaet.
if (!data.profile_complete) {
router.replace("/velkommen")
return
}
setMe(data)
} catch {
router.replace("/logg-inn")
} finally {
setLoadingMe(false)
}
}, [router])
useEffect(() => {
void loadMe()
}, [loadMe])
useEffect(() => {
fetch("/rounds", { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiRound[]) => setRounds(data))
.catch(() => setRounds([]))
fetch("/auth/profile/handicap-history", { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiHandicapPoint[]) => setHcpHistory(data))
.catch(() => {})
fetch("/friends", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: ApiFriendsSummary | null) => setFriendsSummary(data))
.catch(() => {})
fetch("/friends/on-course", { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiFriendOnCourse[]) => setFriendsOnCourse(data))
.catch(() => {})
fetch("/notifications/unread-count", { credentials: "include" })
.then((res) => (res.ok ? res.json() : { count: 0 }))
.then((data: { count: number }) => setUnreadNotifications(data.count))
.catch(() => {})
}, [])
useEffect(() => {
if (!me) return
let cancelled = false
async function loadOrganizerTournaments() {
try {
const lists = await Promise.all(
me!.organizations.map(async (o) => {
const res = await fetch(`/orgs/${o.organization_id}/tournaments`, { credentials: "include" })
const tournaments: ApiTournament[] = res.ok ? await res.json() : []
return { orgId: o.organization_id, tournaments }
}),
)
if (!cancelled) setCombinedTournaments(combineTournaments(me!.my_tournaments, lists))
} catch {
if (!cancelled) setCombinedTournaments(combineTournaments(me!.my_tournaments, []))
}
}
void loadOrganizerTournaments()
return () => {
cancelled = true
}
}, [me])
// Delt åpen/lukk-tilstand for hurtighandling-skjemaene (turnering/kode) --
// løftet til roten (2026-07-29, "alternativ designinstruks") slik at BÅDE
// Hurtighandlinger-kortene OG tomtilstands-knappene i "Kommende
// turneringer" kan åpne samme inline-skjema, i stedet for at sistnevnte
// var uten href/onClick og ikke gjorde noe ved trykk.
const [quickActionOpen, setQuickActionOpen] = useState<"tournament" | "code" | null>(null)
const openQuickAction = useCallback((which: "tournament" | "code") => {
setQuickActionOpen(which)
document.getElementById("hurtighandlinger")?.scrollIntoView({ behavior: "smooth", block: "start" })
}, [])
// "Ny turnering": organisasjon opprettes/gjenbrukes USYNLIG (ADR-035) --
// ingen egen "opprett organisasjon"-skjerm for det vanlige tilfellet.
// Har brukeren INGEN organisasjon, genereres et navn og POST /orgs kalles
// FØRST ved selve innsendingen (ikke når skjemaet bare åpnes -- unngår en
// foreldreløs tom org hvis brukeren avbryter).
async function handleCreateTournament(name: string, chosenOrgId: string | null, format: "team" | "individual") {
if (!me) return
setError(null)
try {
let orgId = chosenOrgId ?? (me.organizations.length === 1 ? me.organizations[0].organization_id : null)
if (!orgId) {
const orgName = me.display_name ? `${me.display_name}s turneringer` : "Mine turneringer"
const orgRes = await fetch("/orgs", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name: orgName }),
})
if (!orgRes.ok) throw new Error("orgs")
const org: { id: string; name: string; role: string } = await orgRes.json()
orgId = org.id
setMe((prev) => (prev ? { ...prev, organizations: [...prev.organizations, { organization_id: org.id, name: org.name, role: org.role }] } : prev))
}
const res = await fetch(`/orgs/${orgId}/tournaments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name, format_type: format }),
})
if (!res.ok) throw new Error("create tournament")
const created: ApiTournament = await res.json()
router.push(`/tournaments/${created.id}?org=${orgId}&name=${encodeURIComponent(created.name)}`)
} catch {
setError("Klarte ikke å opprette turneringen. Prøv igjen.")
}
}
async function handleLogout() {
try {
await fetch("/auth/logout", { method: "POST", credentials: "include" })
} finally {
router.replace("/logg-inn")
}
}
if (loadingMe) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-clubhouse-bg">
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-tee-strong/20 border-t-tee-strong" />
</div>
)
}
if (!me) return null // router.replace("/logg-inn") er allerede utløst
const rows = rounds ?? []
const activeRounds = rows.filter((r) => !r.completed_at)
const completedRounds = rows.filter((r) => r.completed_at)
const roundsForCards = activeRounds.length > 0 ? activeRounds : rows.slice(0, 3)
const personalBestRoundId = findPersonalBestRoundId(completedRounds)
const avgToParSample = completedRounds
.filter((r) => r.my_score_to_par !== null)
.slice(0, 5)
.map((r) => r.my_score_to_par as number)
const avgToPar = avgToParSample.length > 0 ? formatSigned(avgToParSample.reduce((s, v) => s + v, 0) / avgToParSample.length, 1) : "—"
const hcpValues = hcpHistory.map((h) => h.handicap_index)
const hcpTrend = hcpValues.length >= 2 ? hcpValues[hcpValues.length - 1] - hcpValues[hcpValues.length - 2] : null
const hcpNow = me.handicap_index !== null ? me.handicap_index.toFixed(1).replace(".", ",") : "—"
const coursesMap = new Map<string, { name: string; visits: number; lastPlayed: string }>()
for (const r of rows) {
const existing = coursesMap.get(r.course_name_snapshot)
if (existing) {
existing.visits += 1
if (r.played_at > existing.lastPlayed) existing.lastPlayed = r.played_at
} else {
coursesMap.set(r.course_name_snapshot, { name: r.course_name_snapshot, visits: 1, lastPlayed: r.played_at })
}
}
const playedCourses = [...coursesMap.values()].sort((a, b) => (a.lastPlayed < b.lastPlayed ? 1 : -1))
return (
<div className="flex min-h-[100dvh] flex-col bg-clubhouse-bg">
<header className="sticky top-0 z-10 border-b border-clubhouse-border bg-clubhouse-card/95 backdrop-blur pt-[env(safe-area-inset-top)] supports-[backdrop-filter]:bg-clubhouse-card/80">
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
<Link
href="/dashboard"
aria-label="TeeCup til forsiden"
className="flex items-center rounded-xl py-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
>
<TeeCupWordmark className="h-7" />
</Link>
<div className="flex items-center gap-1">
<NotificationBell unreadCount={unreadNotifications} />
<Link
href="/account"
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-clubhouse-muted transition-all duration-200 ease-in-out hover:bg-clubhouse-field hover:text-clubhouse-ink active:opacity-70"
>
<UserCircle aria-hidden="true" className="size-4" />
Konto
</Link>
<button
type="button"
onClick={handleLogout}
className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-clubhouse-muted transition-all duration-200 ease-in-out hover:bg-clubhouse-field hover:text-clubhouse-ink active:opacity-70"
>
<LogOut aria-hidden="true" className="size-4" />
Logg ut
</button>
</div>
</div>
</header>
{/* Bunn-fanerad (2026-08-01) er fixed/56px + safe-area -- pb-24 gir
<main> nok bunnplass til at "Statistikk" ikke skjules bak den
(reelt overlapp funnet under browserverifisering av zip 24, samme
klasse feil som å glemme at en fixed bar tar plass fra layouten). */}
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-24 pt-6 sm:pt-8">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-extrabold tracking-tight text-balance text-clubhouse-ink">
God dag, {me.first_name ?? me.display_name}
</h1>
<p className="text-base leading-relaxed text-pretty text-clubhouse-muted">
Klar for en ny runde? Her er golfen din ett sted.
</p>
</div>
{error && (
<p role="alert" className="mt-4 text-sm font-medium text-cup-strong">
{error}
</p>
)}
<div className="mt-6">
<InstallPrompt />
</div>
<div className="mt-6 flex flex-col gap-8">
<QuickActions
organizations={me.organizations}
onCreateTournament={handleCreateTournament}
open={quickActionOpen}
onOpenChange={setQuickActionOpen}
/>
<LiveFriends friends={friendsOnCourse} />
<UpcomingRounds rounds={roundsForCards.map((r) => toRound(r, me.id, personalBestRoundId))} loading={rounds === null} />
<UpcomingTournaments
entries={combinedTournaments}
onNewTournament={() => openQuickAction("tournament")}
onJoinWithCode={() => openQuickAction("code")}
/>
<StatsSection roundsCount={rows.length} hcpNow={hcpNow} hcpTrend={hcpTrend} hcpHistory={hcpValues} avgToPar={avgToPar} />
<PlayedCourses courses={playedCourses} />
<FriendsSection summary={friendsSummary} />
<OrganizationsFooter organizations={me.organizations} />
</div>
</main>
<BottomNav />
</div>
)
}
// V0-designet (varsler-runden, 2026-07-25) -- lenker til /my-notifications,
// IKKE /notifications (API-prefiks, samme kollisjonsklasse som
// /rounds->/my-rounds og /friends->/my-friends).
function NotificationBell({ unreadCount }: { unreadCount: number }) {
const hasUnread = unreadCount > 0
const label = hasUnread ? `Varsler, ${unreadCount} uleste` : "Varsler, ingen uleste"
const badgeText = unreadCount > 9 ? "9+" : String(unreadCount)
return (
<Link
href="/my-notifications"
aria-label={label}
className="relative inline-flex size-11 items-center justify-center rounded-lg text-clubhouse-muted transition-all duration-200 ease-in-out hover:bg-clubhouse-field hover:text-clubhouse-ink active:opacity-70 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
>
<Bell aria-hidden="true" className="size-5" />
{hasUnread && (
<span
aria-hidden="true"
className="absolute right-1 top-1 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-cup px-1 text-[11px] font-bold leading-none text-white ring-2 ring-clubhouse-card"
>
{badgeText}
</span>
)}
</Link>
)
}
// --- 4. Hurtighandlinger -----------------------------------------------------
function QuickActions({
organizations,
onCreateTournament,
open,
onOpenChange,
}: {
organizations: MyOrg[]
onCreateTournament: (name: string, orgId: string | null, format: "team" | "individual") => Promise<void>
open: "tournament" | "code" | null
onOpenChange: (value: "tournament" | "code" | null) => void
}) {
return (
<section id="hurtighandlinger" aria-label="Hurtighandlinger" className="flex flex-col gap-3">
<div className="grid grid-cols-3 gap-2 sm:gap-3">
<QuickAction icon={Flag} label="Ny runde" href="/my-rounds/new" primary />
<QuickAction
icon={Trophy}
label="Ny turnering"
onClick={() => onOpenChange(open === "tournament" ? null : "tournament")}
expanded={open === "tournament"}
/>
<QuickAction
icon={KeyRound}
label="Bli med med kode"
onClick={() => onOpenChange(open === "code" ? null : "code")}
expanded={open === "code"}
/>
</div>
{open === "tournament" && (
<NewTournamentInline organizations={organizations} onCreate={onCreateTournament} onClose={() => onOpenChange(null)} />
)}
{open === "code" && <JoinByCodeInline onClose={() => onOpenChange(null)} />}
</section>
)
}
function QuickAction({
icon: Icon,
label,
href,
onClick,
primary = false,
expanded = false,
}: {
icon: typeof Flag
label: string
href?: string
onClick?: () => void
primary?: boolean
expanded?: boolean
}) {
const className = cn(
"flex min-h-[84px] flex-1 flex-col items-center justify-center gap-1.5 rounded-2xl border px-2 py-3 text-center font-semibold transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong",
primary
? "border-transparent bg-tee-strong text-white shadow-sm hover:brightness-110"
: expanded
? "border-tee-strong bg-tee/10 text-clubhouse-ink"
: "border-clubhouse-border bg-clubhouse-card text-clubhouse-ink shadow-sm hover:border-tee-strong/50 hover:bg-tee/5",
)
const inner = (
<>
<Icon aria-hidden="true" className={cn("size-5 sm:size-6", primary ? "text-white" : "text-tee-strong")} />
<span className="text-xs font-bold leading-tight text-balance sm:text-sm">{label}</span>
</>
)
if (href) {
return (
<Link href={href} className={className}>
{inner}
</Link>
)
}
return (
<button type="button" onClick={onClick} aria-expanded={primary ? undefined : expanded} className={className}>
{inner}
</button>
)
}
// Skjemaene under (opprett turnering / bli med med kode) beholdt UENDRET --
// egne, allerede etablerte shadcn-baserte skjemaer, ikke del av selve
// Forest Green-visuell-redesignet (Stitch/V0 designet aldri disse to).
function NewTournamentInline({
organizations,
onCreate,
onClose,
}: {
organizations: MyOrg[]
onCreate: (name: string, orgId: string | null, format: "team" | "individual") => Promise<void>
onClose: () => void
}) {
const [name, setName] = useState("")
const [orgId, setOrgId] = useState(organizations[0]?.organization_id ?? "")
const [format, setFormat] = useState<"team" | "individual">("team")
const [submitting, setSubmitting] = useState(false)
const valid = name.trim().length >= 2
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!valid || submitting) return
setSubmitting(true)
await onCreate(name.trim(), organizations.length > 1 ? orgId : null, format)
setSubmitting(false)
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex flex-col gap-1.5">
<span className="text-sm font-semibold text-foreground">Type turnering</span>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setFormat("team")}
aria-pressed={format === "team"}
className={cn(
"flex min-h-11 flex-col items-start gap-0.5 rounded-xl border px-3 py-2 text-left transition-colors",
format === "team"
? "border-primary bg-primary/10"
: "border-border bg-background hover:bg-accent/50",
)}
>
<span className="text-sm font-bold text-foreground">Lag</span>
<span className="text-xs font-medium text-muted-foreground">To lag mot hverandre (Ryder Cup)</span>
</button>
<button
type="button"
onClick={() => setFormat("individual")}
aria-pressed={format === "individual"}
className={cn(
"flex min-h-11 flex-col items-start gap-0.5 rounded-xl border px-3 py-2 text-left transition-colors",
format === "individual"
? "border-info bg-info/10"
: "border-border bg-background hover:bg-accent/50",
)}
>
<span className="text-sm font-bold text-foreground">Individuell</span>
<span className="text-xs font-medium text-muted-foreground">Flatt felt, én eller flere runder</span>
</button>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="new-tournament-name" className="text-sm font-semibold">
Navn turnering
</Label>
<Input
id="new-tournament-name"
autoFocus
placeholder="F.eks. Sommercup 2026"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
{organizations.length > 1 && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="new-tournament-org" className="text-sm font-semibold">
Organisasjon
</Label>
<select
id="new-tournament-org"
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none sm:w-48"
>
{organizations.map((o) => (
<option key={o.organization_id} value={o.organization_id}>
{o.name}
</option>
))}
</select>
</div>
)}
<div className="flex gap-2">
<Button type="submit" disabled={!valid || submitting} className="h-11 rounded-xl font-bold">
{submitting ? "Oppretter…" : "Opprett"}
</Button>
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-11 rounded-xl text-muted-foreground" aria-label="Avbryt">
<X aria-hidden="true" className="size-5" />
</Button>
</div>
</div>
</form>
)
}
function JoinByCodeInline({ onClose }: { onClose: () => void }) {
const router = useRouter()
const [code, setCode] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const trimmed = code.trim()
if (!trimmed || loading) return
setLoading(true)
setError(null)
try {
const res = await fetch(`/public/tournaments/by-code/${encodeURIComponent(trimmed)}`)
if (!res.ok) {
const body = await res.json().catch(() => null)
setError(body?.detail?.message ?? "Fant ingen turnering med denne koden.")
return
}
const data: { tournament_id: string } = await res.json()
router.push(`/t/${data.tournament_id}?code=${encodeURIComponent(trimmed)}`)
} catch {
setError("Klarte ikke å slå opp koden. Prøv igjen.")
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="join-code-inline" className="text-sm font-semibold">
Invitasjonskode
</Label>
<Input
id="join-code-inline"
autoFocus
placeholder="F.eks. 7K2M9P"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
className="h-11 rounded-xl uppercase tracking-widest"
/>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
</div>
<div className="flex gap-2">
<Button type="submit" disabled={!code.trim() || loading} className="h-11 rounded-xl font-bold">
{loading ? "Søker…" : "Bli med"}
</Button>
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-11 rounded-xl text-muted-foreground" aria-label="Avbryt">
<X aria-hidden="true" className="size-5" />
</Button>
</div>
</form>
)
}
// --- Delt seksjon-"chrome" ----------------------------------------------------
function SectionHeader({ title, action }: { title: string; action?: React.ReactNode }) {
return (
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="text-lg font-extrabold tracking-tight text-clubhouse-ink">{title}</h2>
{action}
</div>
)
}
function SeeAllLink({ href, label }: { href: string; label: string }) {
return (
<Link
href={href}
className="inline-flex min-h-[44px] items-center gap-1 rounded-lg px-2 text-sm font-bold text-tee-strong transition-all duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
>
{label}
<ChevronRight aria-hidden="true" className="size-4" />
</Link>
)
}
function EmptyState({
icon: Icon,
title,
description,
children,
}: {
icon: typeof Flag
title: string
description: string
children?: React.ReactNode
}) {
return (
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-clubhouse-border bg-clubhouse-card/60 px-6 py-8 text-center">
<div className="flex size-12 items-center justify-center rounded-xl bg-clubhouse-field">
<Icon aria-hidden="true" className="size-6 text-clubhouse-muted" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-base font-bold text-balance text-clubhouse-ink">{title}</h3>
<p className="text-sm leading-relaxed text-pretty text-clubhouse-muted">{description}</p>
</div>
{children ? <div className="mt-1 flex flex-wrap justify-center gap-2">{children}</div> : null}
</div>
)
}
function ShortcutButton({
icon: Icon,
label,
href,
onClick,
variant = "primary",
}: {
icon: typeof Flag
label: string
href?: string
onClick?: () => void
variant?: "primary" | "outline"
}) {
const className = cn(
"inline-flex min-h-[44px] items-center gap-2 rounded-xl px-4 text-sm font-bold transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong",
variant === "primary"
? "bg-tee-strong text-white hover:brightness-110"
: "border border-clubhouse-border bg-clubhouse-card text-clubhouse-ink hover:bg-clubhouse-field",
)
const inner = (
<>
<Icon aria-hidden="true" className="size-4" />
{label}
</>
)
if (href) {
return (
<Link href={href} className={className}>
{inner}
</Link>
)
}
return (
<button type="button" onClick={onClick} className={className}>
{inner}
</button>
)
}
// --- 5. Venner på banen -------------------------------------------------------
// Ny seksjon (2026-08-01), plassert rett under hurtighandlingene per
// brukerens eksplisitte ønske. Ekte data fra GET /friends/on-course --
// venner med en AKTIV runde nå, eller fullført innen 24 timer. Bevisst
// UTEN boxed empty-state (kun én rolig linje) -- skal ikke prompte en
// handling når den er tom, ulikt de andre seksjonene.
function friendOnCourseInitials(name: string): 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 LiveFriends({ friends }: { friends: ApiFriendOnCourse[] }) {
return (
<section aria-label="Venner på banen">
<SectionHeader title="Venner på banen" action={<SeeAllLink href="/my-feed" label="Se feed" />} />
{friends.length > 0 ? (
<div className="-mx-4 flex snap-x gap-3 overflow-x-auto px-4 pb-1 sm:mx-0 sm:px-0">
{friends.map((friend, i) => {
const playing = friend.status === "playing"
const hue = AVATAR_HUES[i % AVATAR_HUES.length]
return (
<Link
key={friend.round_id}
href={`/watch/${friend.round_id}`}
className="flex min-w-[15rem] max-w-[16rem] shrink-0 snap-start flex-col gap-3 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-4 shadow-sm transition hover:border-tee-strong/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
>
<div className="flex items-center gap-3">
<div className="relative shrink-0">
<span
className="flex size-11 items-center justify-center rounded-full text-sm font-bold tabular-nums"
style={{ backgroundColor: hue.bg, color: hue.ink }}
>
{friendOnCourseInitials(friend.friend_name)}
</span>
{playing && (
<span aria-hidden="true" className="absolute -right-0.5 -top-0.5 flex size-3.5 items-center justify-center rounded-full bg-clubhouse-card">
<span className="absolute inline-flex size-2.5 animate-ping rounded-full bg-cup opacity-75" />
<span className="relative inline-flex size-2.5 rounded-full bg-cup" />
</span>
)}
</div>
<div className="flex min-w-0 flex-col">
<span className="truncate text-base font-bold text-clubhouse-ink">{friend.friend_name}</span>
<span
className={cn(
"inline-flex items-center gap-1 text-xs font-bold uppercase tracking-wide",
playing ? "text-cup-strong" : "text-clubhouse-muted",
)}
>
{playing ? "Spiller nå" : friend.completed_at ? `Fullførte ${timeAgoLabel(friend.completed_at)}` : "Fullførte nylig"}
</span>
</div>
</div>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm text-clubhouse-muted">{friend.course_name}</span>
<span
className={cn(
"shrink-0 rounded-full px-2.5 py-1 text-xs font-bold tabular-nums",
playing ? "bg-tee/15 text-tee-strong" : "bg-clubhouse-field text-clubhouse-muted",
)}
>
{playing
? `Hull ${friend.holes_played} av ${friend.holes_planned}`
: friend.score_to_par !== null
? formatSigned(friend.score_to_par, 0)
: "Fullført"}
</span>
</div>
</Link>
)
})}
</div>
) : (
<p className="text-sm leading-relaxed text-clubhouse-muted">Ingen venner er banen akkurat .</p>
)}
</section>
)
}
// --- 6. Runder --------------------------------------------------------
function UpcomingRounds({ rounds, loading }: { rounds: Round[]; loading: boolean }) {
const visible = rounds.slice(0, 3)
return (
<section aria-label="Runder">
<SectionHeader title="Runder" action={rounds.length > 0 ? <SeeAllLink href="/my-rounds" label="Se alle" /> : null} />
{loading ? (
<div className="flex justify-center py-8">
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-tee-strong/20 border-t-tee-strong" />
</div>
) : visible.length > 0 ? (
<div className="flex flex-col gap-3">
{visible.map((round) => (
<RoundCard key={round.id} round={round} />
))}
</div>
) : (
<EmptyState icon={Flag} title="Ingen runder på gang" description="Start en runde når du er på banen, så dukker den opp her mens du spiller.">
<ShortcutButton icon={Flag} label="Ny runde" href="/my-rounds/new" />
</EmptyState>
)}
</section>
)
}
// --- 7. Kommende turneringer -----------------------------------------------
function UpcomingTournaments({
entries,
onNewTournament,
onJoinWithCode,
}: {
entries: CombinedTournament[]
onNewTournament: () => void
onJoinWithCode: () => void
}) {
return (
<section id="kommende-turneringer" aria-label="Kommende turneringer" className="scroll-mt-20">
<SectionHeader title="Kommende turneringer" />
{entries.length > 0 ? (
<div className="flex flex-col gap-3">
{entries.map((e) => (
<TournamentCard key={e.tournament.id} tournament={e.tournament} orgId={e.orgId} organizer={e.organizer} />
))}
</div>
) : (
<EmptyState icon={Trophy} title="Ingen turneringer ennå" description="Bli med i en turnering med en kode fra arrangøren, eller opprett din egen.">
<ShortcutButton icon={Trophy} label="Ny turnering" onClick={onNewTournament} />
<ShortcutButton icon={KeyRound} label="Bli med med kode" variant="outline" onClick={onJoinWithCode} />
</EmptyState>
)}
</section>
)
}
// --- 8. Statistikk ---------------------------------------------------------
function Sparkline({ values }: { values: number[] }) {
if (values.length < 2) return null
const max = Math.max(...values)
const min = Math.min(...values)
const range = max - min || 1
return (
<div aria-hidden="true" className="flex h-5 items-end gap-0.5">
{values.map((v, i) => {
const height = 30 + ((v - min) / range) * 70
return (
<div
key={i}
className={cn("w-1 rounded-full", i === values.length - 1 ? "bg-tee-strong" : "bg-clubhouse-border")}
style={{ height: `${height}%` }}
/>
)
})}
</div>
)
}
function StatTile({ label, value, children }: { label: string; value: string; children?: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-4 shadow-sm">
<span className="text-xs font-medium uppercase tracking-wide text-clubhouse-muted">{label}</span>
<span className="text-2xl font-extrabold tabular-nums text-clubhouse-ink sm:text-3xl">{value}</span>
{children}
</div>
)
}
function StatsSection({
roundsCount,
hcpNow,
hcpTrend,
hcpHistory,
avgToPar,
}: {
roundsCount: number
hcpNow: string
hcpTrend: number | null
hcpHistory: number[]
avgToPar: string
}) {
const TrendIcon = hcpTrend !== null && hcpTrend < 0 ? TrendingDown : TrendingUp
return (
<section aria-label="Statistikk">
<SectionHeader
title="Statistikk"
action={roundsCount > 0 ? <SeeAllLink href="/my-rounds/stats" label="Se full statistikk" /> : null}
/>
<div className="grid grid-cols-3 gap-3">
<StatTile label="Runder" value={String(roundsCount)} />
<StatTile label="HCP nå" value={hcpNow}>
{hcpTrend !== null ? (
<div className="flex items-center justify-between gap-1">
<span className="inline-flex items-center gap-1 text-xs font-bold text-tee-strong">
<TrendIcon aria-hidden="true" className="size-4" />
<span className="tabular-nums">{formatSigned(hcpTrend, 1)}</span>
<span className="sr-only">{hcpTrend < 0 ? "handicap gått ned" : "handicap gått opp"}</span>
</span>
<Sparkline values={hcpHistory} />
</div>
) : (
<span className="text-xs font-medium text-clubhouse-muted">Ingen data</span>
)}
</StatTile>
<StatTile label="Snitt til par" value={avgToPar} />
</div>
</section>
)
}
// --- 9. Spilte baner ---------------------------------------------------------
function PlayedCourses({ courses }: { courses: { name: string; visits: number; lastPlayed: string }[] }) {
return (
<section aria-label="Spilte baner">
<SectionHeader title="Spilte baner" />
{courses.length > 0 ? (
<ul className="flex flex-col overflow-hidden rounded-2xl border border-clubhouse-border bg-clubhouse-card shadow-sm">
{courses.map((course, i) => {
const roundLabel = course.visits === 1 ? "runde" : "runder"
return (
<li key={course.name} className={cn(i > 0 && "border-t border-clubhouse-border")}>
<Link
href={`/my-rounds/course/${encodeURIComponent(course.name)}`}
className="flex min-h-[60px] items-center gap-3 px-4 py-3 transition-all duration-200 ease-in-out hover:bg-clubhouse-field focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tee-strong"
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-tee/12 text-tee-strong">
<MapPin aria-hidden="true" className="size-5" />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-base font-bold text-clubhouse-ink">{course.name}</span>
<span className="truncate text-sm font-medium text-clubhouse-muted">
{course.visits} {roundLabel} · Sist {formatDate(course.lastPlayed)}
</span>
</span>
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-clubhouse-muted" />
</Link>
</li>
)
})}
</ul>
) : (
<EmptyState icon={MapPin} title="Ingen baner registrert" description="Banene du spiller dukker opp her, med antall besøk og når du sist spilte." />
)}
</section>
)
}
// --- 10. Venner ---------------------------------------------------------------
// ADR-036 fase 1 -- ekte data fra GET /friends. Distinkt fra "Venner på
// banen" (seksjon 5) -- dette er den generelle vennlisten, ikke aktivitet.
function friendInitials(first: string | null, last: string | null): string {
const f = (first ?? "").trim()[0] ?? ""
const l = (last ?? "").trim()[0] ?? ""
return (f + l).toUpperCase() || "?"
}
function FriendsSection({ summary }: { summary: ApiFriendsSummary | null }) {
const friends = summary?.friends ?? []
const pending = summary?.incoming_requests.length ?? 0
const visible = friends.slice(0, 4)
const overflow = friends.length - visible.length
return (
<section aria-label="Venner">
<SectionHeader title="Venner" />
{friends.length > 0 ? (
<div className="flex flex-col gap-4 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center">
{visible.map((friend, i) => (
<span
key={i}
className="flex size-11 items-center justify-center rounded-full bg-clubhouse-field text-sm font-bold text-clubhouse-ink ring-2 ring-clubhouse-card"
style={{ marginLeft: i > 0 ? "-0.75rem" : undefined }}
>
{friendInitials(friend.first_name, friend.last_name)}
</span>
))}
{overflow > 0 && (
<span
className="flex size-11 items-center justify-center rounded-full bg-clubhouse-ink text-xs font-bold tabular-nums text-white ring-2 ring-clubhouse-card"
style={{ marginLeft: "-0.75rem" }}
>
+{overflow}
</span>
)}
</div>
<div className="flex flex-col">
<span className="text-base font-bold text-clubhouse-ink">
{friends.length} {friends.length === 1 ? "venn" : "venner"}
</span>
{pending > 0 ? (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-cup/15 px-2.5 py-0.5 text-xs font-bold text-cup-strong">
<span aria-hidden="true" className="size-1.5 rounded-full bg-cup-strong" />
{pending} ventende {pending === 1 ? "forespørsel" : "forespørsler"}
</span>
) : (
<span className="text-sm font-medium text-clubhouse-muted">Ingen nye forespørsler</span>
)}
</div>
</div>
<ShortcutButton icon={ArrowRight} label="Se venner" href="/my-friends" variant="outline" />
</div>
) : (
<EmptyState icon={UserPlus} title="Finn vennene dine" description="Søk opp folk du spiller med, så kan dere følge hverandres runder og resultater.">
<ShortcutButton icon={UserPlus} label="Søk etter venner" href="/my-friends" />
</EmptyState>
)}
</section>
)
}
// --- 11. Organisasjoner (bevisst nedtonet) -----------------------------------
function OrganizationsFooter({ organizations }: { organizations: MyOrg[] }) {
const router = useRouter()
if (organizations.length === 0) return null
return (
<div className="flex justify-center pt-2">
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex min-h-[44px] items-center gap-2 rounded-lg px-3 text-sm font-medium text-clubhouse-muted transition-all duration-200 ease-in-out hover:text-clubhouse-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong">
<Building2 aria-hidden="true" className="size-4" />
Dine organisasjoner ({organizations.length})
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-64 rounded-2xl p-1.5">
{organizations.map((o) => (
<DropdownMenuItem
key={o.organization_id}
onClick={() => router.push(`/organizations/${o.organization_id}/members?name=${encodeURIComponent(o.name)}`)}
className="cursor-pointer rounded-xl px-3 py-2.5 text-sm font-semibold"
>
{o.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}