Backend: migrasjon 027 (unik user_id per runde) Backend: søke-basert deltaker-innsetting (user_id via /people/search) Backend: åpne opp co-player-tilgang (accessible vs owned) + viewer-relative RoundOut Frontend: rename Gjest→Medspiller + søk-UI + viewer-relativ "Deg" Frontend: fiks round-stats.tsx/round-scorecard.tsx samme viewer-bug Typesjekket produksjonsbuild Scratch-verifisere hele funksjonen grundig Oppdatere .md-filer Legge frem utrullingsplan og vente på bekreftelse Live. Søket på "+ Medspiller" fungerer nå, og en lagt-til medspiller har full tilgang til å registrere score for hele flighten mens rundeforvaltning (rediger/slett/legg til/fjern) forblir eierens alene.
935 lines
35 KiB
TypeScript
935 lines
35 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.
|
||
|
||
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,
|
||
Plus,
|
||
TrendingDown,
|
||
TrendingUp,
|
||
Trophy,
|
||
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 { Wordmark } from "@/components/wordmark"
|
||
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"
|
||
|
||
// --- 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
|
||
}
|
||
|
||
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[]
|
||
}
|
||
|
||
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): 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,
|
||
differential: me?.counts_for_handicap ? me.score_differential : 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)
|
||
}
|
||
|
||
// --- 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 [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("/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])
|
||
|
||
// "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) {
|
||
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 }),
|
||
})
|
||
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 border-primary/20 border-t-primary" />
|
||
</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 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 border-border bg-background/80 backdrop-blur">
|
||
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
|
||
<Wordmark compact />
|
||
<div className="flex items-center gap-1">
|
||
<NotificationBell unreadCount={unreadNotifications} />
|
||
<Link
|
||
href="/account"
|
||
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
||
>
|
||
<UserCircle aria-hidden="true" className="size-4" />
|
||
Konto
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
onClick={handleLogout}
|
||
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
||
>
|
||
<LogOut aria-hidden="true" className="size-4" />
|
||
Logg ut
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-16 pt-6 sm:pt-8">
|
||
<div className="flex flex-col gap-2">
|
||
<h1 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
|
||
God dag, {me.first_name ?? me.display_name}
|
||
</h1>
|
||
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
|
||
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-destructive">
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
<div className="mt-6 flex flex-col gap-8">
|
||
<QuickActions organizations={me.organizations} onCreateTournament={handleCreateTournament} />
|
||
<UpcomingRounds rounds={roundsForCards.map((r) => toRound(r, me.id))} loading={rounds === null} />
|
||
<UpcomingTournaments entries={combinedTournaments} />
|
||
<StatsSection roundsCount={rows.length} hcpNow={hcpNow} hcpTrend={hcpTrend} hcpHistory={hcpValues} avgToPar={avgToPar} />
|
||
<PlayedCourses courses={playedCourses} />
|
||
<FriendsSection summary={friendsSummary} />
|
||
<OrganizationsFooter organizations={me.organizations} />
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// V0-designet (varsler-runden, 2026-07-25) -- lenker til /my-notifications,
|
||
// IKKE /notifications (API-prefiks, samme kollisjonsklasse som
|
||
// /rounds->/my-rounds og /friends->/my-friends).
|
||
function NotificationBell({ unreadCount }: { unreadCount: number }) {
|
||
const hasUnread = unreadCount > 0
|
||
const label = hasUnread ? `Varsler, ${unreadCount} uleste` : "Varsler, ingen uleste"
|
||
const badgeText = unreadCount > 9 ? "9+" : String(unreadCount)
|
||
|
||
return (
|
||
<Link
|
||
href="/my-notifications"
|
||
aria-label={label}
|
||
className="relative inline-flex size-11 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
>
|
||
<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 bg-brand-orange px-1 text-[11px] font-bold leading-none text-brand-orange-foreground ring-2 ring-background"
|
||
style={{ height: 18 }}
|
||
>
|
||
{badgeText}
|
||
</span>
|
||
)}
|
||
</Link>
|
||
)
|
||
}
|
||
|
||
// --- 1. Hurtighandlinger -----------------------------------------------------
|
||
|
||
function QuickActions({
|
||
organizations,
|
||
onCreateTournament,
|
||
}: {
|
||
organizations: MyOrg[]
|
||
onCreateTournament: (name: string, orgId: string | null) => Promise<void>
|
||
}) {
|
||
const [open, setOpen] = useState<"tournament" | "code" | null>(null)
|
||
|
||
return (
|
||
<section aria-label="Hurtighandlinger" className="flex flex-col gap-3">
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||
<QuickAction icon={Flag} label="Ny runde" href="/my-rounds/new" />
|
||
<QuickAction icon={Trophy} label="Ny turnering" onClick={() => setOpen(open === "tournament" ? null : "tournament")} />
|
||
<QuickAction icon={KeyRound} label="Bli med med kode" onClick={() => setOpen(open === "code" ? null : "code")} />
|
||
</div>
|
||
{open === "tournament" && (
|
||
<NewTournamentInline organizations={organizations} onCreate={onCreateTournament} onClose={() => setOpen(null)} />
|
||
)}
|
||
{open === "code" && <JoinByCodeInline onClose={() => setOpen(null)} />}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function QuickAction({
|
||
icon: Icon,
|
||
label,
|
||
href,
|
||
onClick,
|
||
}: {
|
||
icon: typeof Flag
|
||
label: string
|
||
href?: string
|
||
onClick?: () => void
|
||
}) {
|
||
const className =
|
||
"flex min-h-[108px] flex-1 flex-col items-center justify-center gap-2.5 rounded-2xl border border-border bg-card p-4 text-center shadow-sm shadow-black/5 transition-colors hover:border-primary/60 hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||
const inner = (
|
||
<>
|
||
<span className="flex size-12 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
||
<Icon aria-hidden="true" className="size-6" />
|
||
</span>
|
||
<span className="text-sm font-bold leading-tight text-foreground text-balance">{label}</span>
|
||
</>
|
||
)
|
||
if (href) {
|
||
return (
|
||
<Link href={href} className={className}>
|
||
{inner}
|
||
</Link>
|
||
)
|
||
}
|
||
return (
|
||
<button type="button" onClick={onClick} className={className}>
|
||
{inner}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function NewTournamentInline({
|
||
organizations,
|
||
onCreate,
|
||
onClose,
|
||
}: {
|
||
organizations: MyOrg[]
|
||
onCreate: (name: string, orgId: string | null) => Promise<void>
|
||
onClose: () => void
|
||
}) {
|
||
const [name, setName] = useState("")
|
||
const [orgId, setOrgId] = useState(organizations[0]?.organization_id ?? "")
|
||
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)
|
||
setSubmitting(false)
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 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>
|
||
</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-sm shadow-black/5 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-foreground">{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-primary transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
>
|
||
{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-border bg-card/50 px-6 py-8 text-center">
|
||
<div className="flex size-12 items-center justify-center rounded-xl bg-muted">
|
||
<Icon aria-hidden="true" className="size-6 text-muted-foreground" />
|
||
</div>
|
||
<div className="flex flex-col gap-1">
|
||
<h3 className="text-base font-bold text-foreground text-balance">{title}</h3>
|
||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{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,
|
||
variant = "primary",
|
||
}: {
|
||
icon: typeof Flag
|
||
label: string
|
||
href?: string
|
||
variant?: "primary" | "outline"
|
||
}) {
|
||
const className = cn(
|
||
"inline-flex min-h-[44px] items-center gap-1.5 rounded-xl px-4 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||
variant === "primary" ? "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90" : "border border-border bg-card text-foreground hover:bg-accent/50",
|
||
)
|
||
const inner = (
|
||
<>
|
||
<Icon aria-hidden="true" className="size-4" />
|
||
{label}
|
||
</>
|
||
)
|
||
if (href) {
|
||
return (
|
||
<Link href={href} className={className}>
|
||
{inner}
|
||
</Link>
|
||
)
|
||
}
|
||
return (
|
||
<button type="button" className={className}>
|
||
{inner}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// --- 2. Kommende runder --------------------------------------------------------
|
||
|
||
function UpcomingRounds({ rounds, loading }: { rounds: Round[]; loading: boolean }) {
|
||
const visible = rounds.slice(0, 3)
|
||
return (
|
||
<section aria-label="Kommende runder">
|
||
<SectionHeader title="Kommende 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-primary/20 border-t-primary" />
|
||
</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={Plus} label="Ny runde" href="/my-rounds/new" />
|
||
</EmptyState>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// --- 3. Kommende turneringer -----------------------------------------------
|
||
|
||
function UpcomingTournaments({ entries }: { entries: CombinedTournament[] }) {
|
||
return (
|
||
<section aria-label="Kommende turneringer">
|
||
<SectionHeader title="Kommende turneringer" />
|
||
{entries.length > 0 ? (
|
||
<div className="flex flex-col gap-3">
|
||
{entries.map((e) => (
|
||
<TournamentCard key={e.tournament.id} tournament={e.tournament} orgId={e.orgId} organizer={e.organizer} />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<EmptyState icon={Trophy} title="Ingen turneringer ennå" description="Bli med i en turnering med en kode fra arrangøren, eller opprett din egen.">
|
||
<ShortcutButton icon={Trophy} label="Ny turnering" />
|
||
<ShortcutButton icon={KeyRound} label="Bli med med kode" variant="outline" />
|
||
</EmptyState>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// --- 4. 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 bg-primary/40" 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-border bg-card p-4 shadow-sm shadow-black/5">
|
||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
|
||
<span className="text-2xl font-extrabold tabular-nums text-foreground 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" />
|
||
<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-foreground">
|
||
<TrendIcon aria-hidden="true" className="size-4 text-primary" />
|
||
<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-muted-foreground">Ingen data</span>
|
||
)}
|
||
</StatTile>
|
||
<StatTile label="Snitt til par" value={avgToPar} />
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// --- 5. 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 divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
|
||
{courses.map((course) => {
|
||
const roundLabel = course.visits === 1 ? "runde" : "runder"
|
||
return (
|
||
<li key={course.name} className="flex min-h-[60px] items-center gap-3 px-4 py-3">
|
||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
||
<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-foreground">{course.name}</span>
|
||
<span className="truncate text-sm text-muted-foreground">
|
||
{course.visits} {roundLabel} · Sist {formatDate(course.lastPlayed)}
|
||
</span>
|
||
</span>
|
||
</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>
|
||
)
|
||
}
|
||
|
||
// --- 6. Venner ---------------------------------------------------------------
|
||
// ADR-036 fase 1 (venner-kjernen) er live -- ekte data fra GET /friends.
|
||
|
||
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
|
||
|
||
return (
|
||
<section aria-label="Venner">
|
||
<SectionHeader title="Venner" />
|
||
{friends.length > 0 ? (
|
||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:flex-row sm:items-center sm:justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center">
|
||
{friends.slice(0, 4).map((friend, i) => (
|
||
<span
|
||
key={i}
|
||
className={cn(
|
||
"flex size-11 items-center justify-center rounded-full border-2 border-card bg-primary/15 text-sm font-bold text-foreground",
|
||
i > 0 && "-ml-3",
|
||
)}
|
||
>
|
||
{friendInitials(friend.first_name, friend.last_name)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="flex flex-col">
|
||
<span className="text-base font-bold text-foreground">
|
||
{friends.length} {friends.length === 1 ? "venn" : "venner"}
|
||
</span>
|
||
{pending > 0 ? (
|
||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-brand-orange/15 px-2.5 py-0.5 text-xs font-bold text-foreground">
|
||
<span aria-hidden="true" className="size-1.5 rounded-full bg-brand-orange" />
|
||
{pending} ventende {pending === 1 ? "forespørsel" : "forespørsler"}
|
||
</span>
|
||
) : (
|
||
<span className="text-sm text-muted-foreground">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>
|
||
)
|
||
}
|
||
|
||
// --- 7. 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-1.5 rounded-lg px-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||
<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>
|
||
)
|
||
}
|