teecup/frontend/components/dashboard.tsx

1256 lines
48 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.
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 { 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"
// Låst "Forest Green"-palett (2026-08-01) -- samme sett som login-form.tsx,
// se DESIGN_SYSTEM.md sitt notat om denne runden. ALDRI #84b089/#a4a5a5 som
// tekst på mørkegrønn (#1a4325) -- målt ~4.5:1, under vårt AAA-mål (7:1).
const C = {
bg: "#f7faf8",
card: "#ffffff",
glass: "rgba(255, 255, 255, 0.78)",
ink: "#012c11",
inkOn: "#ffffff",
accent: "#1a4325",
accentMuted: "#bfeec4",
successBg: "#91f78e",
successInk: "#00731e",
muted: "#424941",
border: "#c1c9bf",
tint: "#edf3ef",
warmBg: "#ffedd5",
warmInk: "#9a3412",
} 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("/")
return
}
const data: Me = await res.json()
if (!data.profile_complete) {
router.replace("/account")
return
}
setMe(data)
} catch {
router.replace("/")
} 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("/")
}
}
if (loadingMe) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4" style={{ backgroundColor: C.bg }}>
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4" style={{ borderColor: `${C.accent}33`, borderTopColor: C.accent }} />
</div>
)
}
if (!me) return null // router.replace("/") 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" style={{ backgroundColor: C.bg }}>
<header
className="sticky top-0 z-10 border-b pt-[env(safe-area-inset-top)] backdrop-blur-xl"
style={{ backgroundColor: C.glass, borderColor: C.border }}
>
<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 gap-2 rounded-xl">
<span className="flex size-9 items-center justify-center rounded-xl" style={{ backgroundColor: C.ink }}>
<Flag aria-hidden="true" className="size-5" fill="#f79b3d" color="#f79b3d" />
</span>
<span className="text-lg font-extrabold tracking-tight" style={{ color: C.ink }}>
TeeCup
</span>
</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 transition-all duration-200 ease-in-out active:opacity-70"
style={{ color: C.muted }}
>
<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 transition-all duration-200 ease-in-out active:opacity-70"
style={{ color: C.muted }}
>
<LogOut aria-hidden="true" className="size-4" />
Logg ut
</button>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-[max(4rem,env(safe-area-inset-bottom))] pt-6 sm:pt-8">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-extrabold tracking-tight text-balance" style={{ color: C.ink }}>
God dag, {me.first_name ?? me.display_name}
</h1>
<p className="text-base leading-relaxed text-pretty" style={{ color: C.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" style={{ color: "#b3261e" }}>
{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>
</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 transition-all duration-200 ease-in-out active:opacity-70 focus-visible:outline-none focus-visible:ring-2"
style={{ color: C.muted, outlineColor: C.accent }}
>
<Bell aria-hidden="true" className="size-5" />
{hasUnread && (
<span
aria-hidden="true"
className="absolute right-1 top-1 flex min-w-[18px] items-center justify-center rounded-full px-1 text-[11px] font-bold leading-none"
style={{ height: 18, backgroundColor: C.warmInk, color: "#ffffff", boxShadow: `0 0 0 2px ${C.glass}` }}
>
{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" />
<QuickAction icon={Trophy} label="Ny turnering" onClick={() => onOpenChange(open === "tournament" ? null : "tournament")} />
<QuickAction icon={KeyRound} label="Bli med med kode" onClick={() => onOpenChange(open === "code" ? null : "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,
}: {
icon: typeof Flag
label: string
href?: string
onClick?: () => void
}) {
const style: React.CSSProperties = {
backgroundColor: C.ink,
color: C.inkOn,
boxShadow: "0 14px 30px -20px rgba(1, 44, 17, 0.7)",
}
const className =
"flex min-h-[84px] flex-1 flex-col items-center justify-center gap-1.5 rounded-2xl px-2 py-3 text-center transition-all duration-200 ease-in-out active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
const inner = (
<>
<Icon aria-hidden="true" className="size-5 sm:size-6" color={C.successBg} />
<span className="text-xs font-bold leading-tight text-balance sm:text-sm">{label}</span>
</>
)
if (href) {
return (
<Link href={href} className={className} style={style}>
{inner}
</Link>
)
}
return (
<button type="button" onClick={onClick} className={className} style={style}>
{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" style={{ color: C.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 transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2"
style={{ color: C.accent, outlineColor: C.accent }}
>
{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 px-6 py-8 text-center"
style={{ borderColor: C.border, backgroundColor: "rgba(255,255,255,0.5)" }}
>
<div className="flex size-12 items-center justify-center rounded-xl" style={{ backgroundColor: C.tint }}>
<Icon aria-hidden="true" className="size-6" color={C.muted} />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-base font-bold text-balance" style={{ color: C.ink }}>
{title}
</h3>
<p className="text-sm leading-relaxed text-pretty" style={{ color: C.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 style: React.CSSProperties =
variant === "primary"
? { backgroundColor: C.ink, color: C.inkOn }
: { backgroundColor: C.card, color: C.ink, border: `1px solid ${C.border}` }
const className =
"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-none focus-visible:ring-2 focus-visible:ring-offset-2"
const inner = (
<>
<Icon aria-hidden="true" className="size-4" />
{label}
</>
)
if (href) {
return (
<Link href={href} className={className} style={style}>
{inner}
</Link>
)
}
return (
<button type="button" onClick={onClick} className={className} style={style}>
{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" />
{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) => {
const playing = friend.status === "playing"
return (
<Link
key={friend.round_id}
href={`/my-rounds/${friend.round_id}`}
className="flex min-w-[15rem] max-w-[16rem] shrink-0 snap-start flex-col gap-3 rounded-2xl p-4 transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{ backgroundColor: C.card, border: `1px solid ${C.border}`, boxShadow: "0 10px 26px -20px rgba(1, 44, 17, 0.35)" }}
>
<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: C.tint, color: C.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" style={{ backgroundColor: C.card }}>
<span className="absolute inline-flex size-2.5 animate-ping rounded-full opacity-75" style={{ backgroundColor: C.successBg }} />
<span className="relative inline-flex size-2.5 rounded-full" style={{ backgroundColor: C.successInk }} />
</span>
)}
</div>
<div className="flex min-w-0 flex-col">
<span className="truncate text-base font-bold" style={{ color: C.ink }}>
{friend.friend_name}
</span>
<span className="inline-flex items-center gap-1 text-xs font-bold uppercase tracking-wide" style={{ color: playing ? C.successInk : C.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" style={{ color: C.muted }}>
{friend.course_name}
</span>
<span className="shrink-0 rounded-full px-2.5 py-1 text-xs font-bold tabular-nums" style={{ backgroundColor: C.tint, color: C.ink }}>
{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" style={{ color: C.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" style={{ borderColor: `${C.accent}33`, borderTopColor: C.accent }} />
</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 aria-label="Kommende turneringer">
<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="w-1 rounded-full"
style={{ height: `${height}%`, backgroundColor: i === values.length - 1 ? C.successInk : C.border }}
/>
)
})}
</div>
)
}
function StatTile({ label, value, children }: { label: string; value: string; children?: React.ReactNode }) {
return (
<div
className="flex flex-col gap-1.5 rounded-2xl p-4"
style={{ backgroundColor: C.card, border: `1px solid ${C.border}`, boxShadow: "0 10px 26px -20px rgba(1, 44, 17, 0.35)" }}
>
<span className="text-xs font-medium uppercase tracking-wide" style={{ color: C.muted }}>
{label}
</span>
<span className="text-2xl font-extrabold tabular-nums sm:text-3xl" style={{ color: C.ink }}>
{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" style={{ color: C.ink }}>
<TrendIcon aria-hidden="true" className="size-4" color={C.successInk} />
<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" style={{ color: C.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"
style={{ border: `1px solid ${C.border}`, backgroundColor: C.card, boxShadow: "0 10px 26px -20px rgba(1, 44, 17, 0.35)" }}
>
{courses.map((course, i) => {
const roundLabel = course.visits === 1 ? "runde" : "runder"
return (
<li key={course.name} style={i > 0 ? { borderTop: `1px solid ${C.border}` } : undefined}>
<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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset"
style={{ outlineColor: C.accent }}
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl" style={{ backgroundColor: C.tint }}>
<MapPin aria-hidden="true" className="size-5" color={C.ink} />
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-base font-bold" style={{ color: C.ink }}>
{course.name}
</span>
<span className="truncate text-sm font-medium" style={{ color: C.muted }}>
{course.visits} {roundLabel} · Sist {formatDate(course.lastPlayed)}
</span>
</span>
<ChevronRight aria-hidden="true" className="size-5 shrink-0" color={C.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 p-4 sm:flex-row sm:items-center sm:justify-between"
style={{ backgroundColor: C.card, border: `1px solid ${C.border}`, boxShadow: "0 10px 26px -20px rgba(1, 44, 17, 0.35)" }}
>
<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 text-sm font-bold"
style={{ backgroundColor: C.tint, color: C.ink, border: `2px solid ${C.card}`, 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 text-xs font-bold tabular-nums"
style={{ backgroundColor: C.accent, color: C.inkOn, border: `2px solid ${C.card}`, marginLeft: "-0.75rem" }}
>
+{overflow}
</span>
)}
</div>
<div className="flex flex-col">
<span className="text-base font-bold" style={{ color: C.ink }}>
{friends.length} {friends.length === 1 ? "venn" : "venner"}
</span>
{pending > 0 ? (
<span
className="inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-bold"
style={{ backgroundColor: C.warmBg, color: C.warmInk }}
>
<span aria-hidden="true" className="size-1.5 rounded-full" style={{ backgroundColor: C.warmInk }} />
{pending} ventende {pending === 1 ? "forespørsel" : "forespørsler"}
</span>
) : (
<span className="text-sm font-medium" style={{ color: C.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 transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2"
style={{ color: C.muted, outlineColor: C.accent }}
>
<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>
)
}