teecup/frontend/components/dashboard.tsx
Erol Haagenrud f55a51e9e2 e reelle bugger (500 på /feeds paginering, en rute-/rewrite-kollisjon på /feed, og manglende WS-refetch-kobling for kommentarer) — se ADR-044/CHANGELOG for detaljer. Før jeg ruller dette ut mot ekte teecup_db, her er planen:
Kommandoer jeg vil kjøre mot ekte teecup_db/teecup_api/teecup_frontend:

psql migrasjon 058_round_messages.sql mot ekte teecup_db (ny round_message-tabell, ingen endring i eksisterende tabeller).
docker compose up -d --build teecup_api teecup_frontend (begge containere, siden dette er backend+frontend sammen).
Etterpå: bekreft round_message-tabellen finnes, /health//my-rounds/new//my-feed → 200 over https, teeoff.no upåvirket.
2026-08-06 07:01:22 +02:00

1329 lines
52 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,
CalendarDays,
ChevronRight,
Flag,
Home,
KeyRound,
LogOut,
MapPin,
MoreHorizontal,
Trophy,
TrendingDown,
TrendingUp,
User,
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 -- v2 (2026-08-01, dashbord-fargekorrigering
// mot Stitch sitt faktiske skjermbilde). ALDRI #84b089/#a4a5a5 som tekst på
// mørkegrønn (#1a4325) -- målt ~4.5:1, under vårt AAA-mål. `primary` er
// bevisst IKKE Stitch sin rå pikselverdi (#2d950c, som målte kun 3,88:1 med
// hvit tekst -- feiler AA for vanlig tekst) -- egen kontrastkorrigert
// variant (#1f6b08, ~6,6:1) i samme fargefamilie.
const C = {
card: "#ffffff",
glass: "rgba(255, 255, 255, 0.78)",
ink: "#012c11", // primærtekst (nesten-svart grønn), IKKE lenger knappe-bakgrunn
inkOn: "#ffffff",
primary: "#1f6b08", // lys, innbydende CTA-grønn -- hvit tekst/ikon (~6,6:1)
primaryInk: "#ffffff",
accent: "#1a4325", // mørkegrønn aksent (installer-banner-gradient)
accentMuted: "#bfeec4",
successBg: "#91f78e",
successInk: "#00731e",
muted: "#424941",
border: "#c1c9bf",
borderSoft: "#e4ebe3", // lysere strek for flatere/lysere overflater (Venner på banen)
tint: "#edf3ef",
warmBg: "#ffedd5",
warmInk: "#9a3412",
} as const
// 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("/")
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 bg-background">
<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 bg-background">
<header
className="sticky top-0 z-10 border-b bg-background pt-[env(safe-area-inset-top)]"
style={{ 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.primary }}>
<Flag aria-hidden="true" className="size-5" fill={C.successBg} color={C.successBg} />
</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>
{/* 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" 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>
<BottomTabBar active="home" />
</div>
)
}
// Fast bunn-fanerad (2026-08-01, brukerens eget ønske etter å ha sett
// Stitch sin versjon) -- app-bred navigasjon, vist på ALLE skjermer som
// importerer denne. hrefs er bevisst RETTET fra V0 sitt forslag (/rounds,
// /tournaments) til de faktiske frontend-rutene -- /rounds er reservert
// API-prefiks (samme kollisjonsklasse som /rounds->/my-rounds tidligere i
// prosjektet), og det finnes ingen egen "/tournaments"-liste-side, kun
// /tournaments/[id]. "Turneringer" scroller derfor til dashbordets egen
// "Kommende turneringer"-seksjon i stedet for å lenke til en side som ikke
// finnes. "Mer" peker til en ny, minimal samleside (se app/more/page.tsx).
type TabKey = "home" | "rounds" | "tournaments" | "profile" | "more"
export function BottomTabBar({ active }: { active: TabKey }) {
const tabs: { key: TabKey; icon: typeof Flag; label: string; href: string }[] = [
{ key: "home", icon: Home, label: "Hjem", href: "/dashboard" },
{ key: "rounds", icon: CalendarDays, label: "Runder", href: "/my-rounds" },
{ key: "tournaments", icon: Trophy, label: "Turneringer", href: "/dashboard#kommende-turneringer" },
{ key: "profile", icon: User, label: "Profil", href: "/account" },
{ key: "more", icon: MoreHorizontal, label: "Mer", href: "/more" },
]
return (
<nav
aria-label="Hovednavigasjon"
className="fixed inset-x-0 bottom-0 z-20 border-t"
style={{ backgroundColor: C.card, borderColor: C.border, paddingBottom: "env(safe-area-inset-bottom)" }}
>
<ul className="mx-auto flex w-full max-w-2xl items-stretch justify-around px-1">
{tabs.map(({ key, icon: Icon, label, href }) => {
const isActive = key === active
return (
<li key={key} className="flex-1">
<Link
href={href}
aria-current={isActive ? "page" : undefined}
className="flex min-h-[56px] flex-col items-center justify-center gap-1 rounded-lg px-1 py-1.5 text-center transition-colors focus-visible:outline-none focus-visible:ring-2"
style={{ color: isActive ? C.primary : C.muted, outlineColor: C.accent }}
>
<Icon aria-hidden="true" className="size-6" strokeWidth={isActive ? 2.4 : 2} />
<span className="text-[11px] font-bold leading-none">{label}</span>
</Link>
</li>
)
})}
</ul>
</nav>
)
}
// 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 var(--background)` }}
>
{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.primary,
color: C.primaryInk,
boxShadow: "0 14px 30px -20px rgba(31, 107, 8, 0.55)",
}
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.primaryInk} />
<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.primary, color: C.primaryInk }
: { 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" 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={`/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-colors hover:bg-black/[0.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{ backgroundColor: C.card, border: `1px solid ${C.borderSoft}` }}
>
<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" 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 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="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>
)
}