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).
1258 lines
50 KiB
TypeScript
1258 lines
50 KiB
TypeScript
"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 på 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 på 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 på banen akkurat nå.</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>
|
||
)
|
||
}
|