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.
1329 lines
52 KiB
TypeScript
1329 lines
52 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.
|
||
|
||
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 på 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 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" 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 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" 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>
|
||
)
|
||
}
|