740 lines
32 KiB
TypeScript
740 lines
32 KiB
TypeScript
"use client"
|
|
|
|
// Venner-kjernen (ADR-036, fase 1). Presentasjon fra V0 (zip 19), datalag
|
|
// skrevet om fra mock til ekte fetch mot /people/search + /friends.
|
|
// Ruten er bevisst /my-friends, IKKE /friends -- det er nå API-prefikset
|
|
// (samme kollisjonsfelle unngått som /rounds -> /my-rounds tidligere).
|
|
|
|
import type React from "react"
|
|
import { useCallback, useEffect, useState } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import Link from "next/link"
|
|
import {
|
|
ArrowLeft,
|
|
Check,
|
|
ChevronDown,
|
|
Search,
|
|
Tag,
|
|
Trash2,
|
|
UserPlus,
|
|
Users,
|
|
X,
|
|
} from "lucide-react"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Wordmark } from "@/components/wordmark"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
// --- Kategorier --------------------------------------------------------------
|
|
// Rekkefølge og koder MÅ matche `Category` i app/routers/friends.py nøyaktig.
|
|
|
|
const CATEGORY_OPTIONS = [
|
|
{ code: "spouse", label: "Make" },
|
|
{ code: "close_family", label: "Nær familie" },
|
|
{ code: "extended_family", label: "Storfamilie" },
|
|
{ code: "close_friends", label: "Nære venner" },
|
|
{ code: "golf_friends", label: "Golfvenner" },
|
|
{ code: "colleagues", label: "Kollegaer" },
|
|
{ code: "business", label: "Forretningsforbindelser" },
|
|
{ code: "classmates", label: "Studiekamerater" },
|
|
{ code: "acquaintances", label: "Perifere bekjente" },
|
|
{ code: "other", label: "Ymse" },
|
|
] as const
|
|
|
|
function categoryLabel(code: string): string {
|
|
return CATEGORY_OPTIONS.find((c) => c.code === code)?.label ?? code
|
|
}
|
|
|
|
const ALL_CATEGORY_CODES = CATEGORY_OPTIONS.map((c) => c.code)
|
|
|
|
// Delt sjekkboks-rutenett (2026-07-29) -- brukt tre steder nå (ny
|
|
// forespørsel, godta innkommende, rediger en eksisterende venn), derfor
|
|
// trukket ut fremfor duplisert. "Ingen ukategoriserte venner" (samme dag,
|
|
// presisert av bruker) håndheves her ved å nekte å fjerne den SISTE
|
|
// avkrysningen -- backend krever uansett minst én, men UI-et skal aldri
|
|
// tillate å komme i den tilstanden i utgangspunktet.
|
|
function CategoryPicker({ selected, onToggle }: { selected: string[]; onToggle: (code: string) => void }) {
|
|
return (
|
|
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
{CATEGORY_OPTIONS.map(({ code, label }) => {
|
|
const checked = selected.includes(code)
|
|
const blocked = checked && selected.length <= 1
|
|
return (
|
|
<li key={code}>
|
|
<button
|
|
type="button"
|
|
role="checkbox"
|
|
aria-checked={checked}
|
|
disabled={blocked}
|
|
onClick={() => onToggle(code)}
|
|
title={blocked ? "Minst én kategori er påkrevd" : undefined}
|
|
className={cn(
|
|
"flex min-h-[44px] w-full items-center gap-3 rounded-xl border px-3 py-2 text-left text-base font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
checked ? "border-primary bg-primary/10 text-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
|
|
blocked && "cursor-not-allowed opacity-70",
|
|
)}
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"flex size-6 shrink-0 items-center justify-center rounded-md border-2 transition-colors",
|
|
checked ? "border-primary bg-primary text-primary-foreground" : "border-border",
|
|
)}
|
|
>
|
|
{checked && <Check className="size-4" strokeWidth={3} />}
|
|
</span>
|
|
{label}
|
|
</button>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
// --- API-typer ---------------------------------------------------------------
|
|
|
|
type ApiPersonMatch = { id: string; first_name: string; last_name: string; avatar_url: string | null; home_club: string | null }
|
|
type ApiFriendEntry = {
|
|
friendship_id: string
|
|
user_id: string
|
|
first_name: string | null
|
|
last_name: string | null
|
|
avatar_url: string | null
|
|
home_club: string | null
|
|
categories: string[]
|
|
}
|
|
type ApiPendingRequest = {
|
|
friendship_id: string
|
|
user_id: string
|
|
first_name: string | null
|
|
last_name: string | null
|
|
avatar_url: string | null
|
|
created_at: string
|
|
}
|
|
type ApiFriendsOut = { friends: ApiFriendEntry[]; incoming_requests: ApiPendingRequest[]; outgoing_requests: ApiPendingRequest[] }
|
|
|
|
function fullName(first: string | null, last: string | null): string {
|
|
const name = [first, last].filter(Boolean).join(" ").trim()
|
|
return name || "Ukjent"
|
|
}
|
|
|
|
// --- Page ------------------------------------------------------------------
|
|
|
|
export function Friends() {
|
|
const router = useRouter()
|
|
const [data, setData] = useState<ApiFriendsOut | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const loadFriends = useCallback(async () => {
|
|
try {
|
|
const res = await fetch("/friends", { credentials: "include" })
|
|
if (res.status === 401) {
|
|
router.replace("/")
|
|
return
|
|
}
|
|
if (!res.ok) throw new Error(`friends: ${res.status}`)
|
|
setData(await res.json())
|
|
} catch {
|
|
setError("Klarte ikke å hente vennelisten din. Prøv igjen om litt.")
|
|
}
|
|
}, [router])
|
|
|
|
useEffect(() => {
|
|
void loadFriends()
|
|
}, [loadFriends])
|
|
|
|
const friendUserIds = new Set(data?.friends.map((f) => f.user_id) ?? [])
|
|
const outgoingUserIds = new Set(data?.outgoing_requests.map((r) => r.user_id) ?? [])
|
|
|
|
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">
|
|
<Link
|
|
href="/dashboard"
|
|
className="inline-flex min-h-[44px] items-center gap-2 rounded-xl px-2 py-2 text-base font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
>
|
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
|
Til dashbord
|
|
</Link>
|
|
<Wordmark compact />
|
|
</div>
|
|
</header>
|
|
|
|
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
|
|
<div className="flex flex-col gap-2">
|
|
<h1 className="text-3xl font-extrabold tracking-tight text-foreground text-balance">
|
|
Mine venner
|
|
</h1>
|
|
<p className="max-w-prose text-lg leading-relaxed text-muted-foreground text-pretty">
|
|
Finn folk du spiller med, håndter forespørsler, og organiser vennene dine i egne
|
|
kategorier.
|
|
</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<p role="alert" className="mt-4 text-base font-medium text-destructive">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-8 flex flex-col gap-10">
|
|
<SearchSection friendUserIds={friendUserIds} outgoingUserIds={outgoingUserIds} onRequestSent={loadFriends} />
|
|
<RequestsSection incoming={data?.incoming_requests ?? []} outgoing={data?.outgoing_requests ?? []} onChanged={loadFriends} />
|
|
<FriendsSection friends={data?.friends ?? []} onChanged={loadFriends} />
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Section header --------------------------------------------------------
|
|
|
|
function SectionHeading({ children }: { children: React.ReactNode }) {
|
|
return <h2 className="text-xl font-extrabold tracking-tight text-foreground">{children}</h2>
|
|
}
|
|
|
|
// --- 1. Search ---------------------------------------------------------------
|
|
|
|
function SearchSection({
|
|
friendUserIds,
|
|
outgoingUserIds,
|
|
onRequestSent,
|
|
}: {
|
|
friendUserIds: Set<string>
|
|
outgoingUserIds: Set<string>
|
|
onRequestSent: () => void
|
|
}) {
|
|
const [query, setQuery] = useState("")
|
|
const [results, setResults] = useState<ApiPersonMatch[]>([])
|
|
const [searching, setSearching] = useState(false)
|
|
const [justSent, setJustSent] = useState<Record<string, boolean>>({})
|
|
// Ingen ukategoriserte venner (2026-07-29) -- kategorisering skjer nå
|
|
// FØR forespørselen sendes, ikke som et eget steg etterpå. Alle
|
|
// kategorier forhåndsvalgt (bruker: "man må heller velge bort") --
|
|
// ett åpent valg-panel om gangen (personId som nøkkel).
|
|
const [pickerFor, setPickerFor] = useState<string | null>(null)
|
|
const [pickerCategories, setPickerCategories] = useState<string[]>(ALL_CATEGORY_CODES)
|
|
const [sending, setSending] = useState(false)
|
|
|
|
function openPicker(personId: string) {
|
|
setPickerFor(personId)
|
|
setPickerCategories(ALL_CATEGORY_CODES)
|
|
}
|
|
|
|
function toggleCategoryInPicker(code: string) {
|
|
setPickerCategories((prev) => {
|
|
if (prev.includes(code)) return prev.length <= 1 ? prev : prev.filter((c) => c !== code)
|
|
return [...prev, code]
|
|
})
|
|
}
|
|
|
|
useEffect(() => {
|
|
const trimmed = query.trim()
|
|
if (trimmed.length < 2) {
|
|
setResults([])
|
|
return
|
|
}
|
|
let cancelled = false
|
|
setSearching(true)
|
|
const timer = setTimeout(async () => {
|
|
try {
|
|
const res = await fetch(`/people/search?q=${encodeURIComponent(trimmed)}`, { credentials: "include" })
|
|
if (res.ok && !cancelled) setResults(await res.json())
|
|
} catch {
|
|
// Stille -- listen blir bare uendret.
|
|
} finally {
|
|
if (!cancelled) setSearching(false)
|
|
}
|
|
}, 250)
|
|
return () => {
|
|
cancelled = true
|
|
clearTimeout(timer)
|
|
}
|
|
}, [query])
|
|
|
|
async function sendRequest(personId: string) {
|
|
setSending(true)
|
|
try {
|
|
const res = await fetch("/friends", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ addressee_user_id: personId, categories: pickerCategories }),
|
|
})
|
|
if (res.ok) {
|
|
setJustSent((prev) => ({ ...prev, [personId]: true }))
|
|
setPickerFor(null)
|
|
onRequestSent()
|
|
}
|
|
} finally {
|
|
setSending(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section aria-label="Søk etter venner" className="flex flex-col gap-4">
|
|
<SectionHeading>Finn venner</SectionHeading>
|
|
|
|
<div className="relative">
|
|
<Search
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
|
|
/>
|
|
<Input
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Søk etter navn …"
|
|
aria-label="Søk etter navn"
|
|
className="h-14 rounded-2xl pl-12 text-lg"
|
|
/>
|
|
</div>
|
|
|
|
{query.trim().length < 2 ? (
|
|
<p className="rounded-2xl border border-dashed border-border bg-muted/40 px-5 py-6 text-center text-base text-muted-foreground">
|
|
Skriv minst to tegn for å søke etter spillere.
|
|
</p>
|
|
) : searching ? (
|
|
<p className="text-center text-base text-muted-foreground">Søker …</p>
|
|
) : results.length === 0 ? (
|
|
<p className="rounded-2xl border border-border bg-muted/50 px-5 py-6 text-center text-base text-muted-foreground">
|
|
Ingen spillere matcher «{query.trim()}».
|
|
</p>
|
|
) : (
|
|
<ul className="flex flex-col gap-3">
|
|
{results.map((person) => {
|
|
const isFriend = friendUserIds.has(person.id)
|
|
const isSent = outgoingUserIds.has(person.id) || justSent[person.id]
|
|
const pickerOpen = pickerFor === person.id
|
|
return (
|
|
<li
|
|
key={person.id}
|
|
className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8"
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
<Avatar name={fullName(person.first_name, person.last_name)} />
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<span className="truncate text-lg font-bold text-foreground">
|
|
{fullName(person.first_name, person.last_name)}
|
|
</span>
|
|
<span className="truncate text-base text-muted-foreground">
|
|
{person.home_club ?? "Ingen hjemmeklubb"}
|
|
</span>
|
|
</div>
|
|
{isFriend ? (
|
|
<span className="inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded-xl bg-muted px-3 text-sm font-semibold text-muted-foreground">
|
|
<Check aria-hidden="true" className="size-4" />
|
|
Venn
|
|
</span>
|
|
) : isSent ? (
|
|
<span className="inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded-xl bg-brand-orange/15 px-3 text-sm font-semibold text-foreground">
|
|
<Check aria-hidden="true" className="size-4" />
|
|
Sendt
|
|
</span>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => (pickerOpen ? setPickerFor(null) : openPicker(person.id))}
|
|
aria-expanded={pickerOpen}
|
|
className="inline-flex min-h-[44px] shrink-0 items-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
>
|
|
<UserPlus aria-hidden="true" className="size-5" />
|
|
Send forespørsel
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{pickerOpen && (
|
|
<div className="flex flex-col gap-3 rounded-xl border border-border bg-muted/30 p-4">
|
|
<p className="text-sm font-semibold text-foreground">
|
|
Hvilke kategorier skal {fullName(person.first_name, person.last_name)} plasseres i? Alle er
|
|
forhåndsvalgt — velg heller bort det som ikke passer.
|
|
</p>
|
|
<CategoryPicker selected={pickerCategories} onToggle={toggleCategoryInPicker} />
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => sendRequest(person.id)}
|
|
disabled={sending}
|
|
className="inline-flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-60 sm:flex-none"
|
|
>
|
|
{sending ? "Sender…" : "Bekreft og send"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPickerFor(null)}
|
|
className="inline-flex min-h-[44px] items-center justify-center rounded-xl border border-border bg-card px-4 text-base font-bold text-foreground transition-colors hover:bg-accent/50"
|
|
>
|
|
Avbryt
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// --- 2. Requests -------------------------------------------------------------
|
|
|
|
function RequestsSection({
|
|
incoming,
|
|
outgoing,
|
|
onChanged,
|
|
}: {
|
|
incoming: ApiPendingRequest[]
|
|
outgoing: ApiPendingRequest[]
|
|
onChanged: () => void
|
|
}) {
|
|
// Ingen ukategoriserte venner (2026-07-29) -- å godta krever nå at
|
|
// MOTTAKEREN også velger kategorier med det samme (samme regel som
|
|
// avsenderen i SearchSection over), ikke en frivillig senere handling.
|
|
const [acceptPickerFor, setAcceptPickerFor] = useState<string | null>(null)
|
|
const [acceptCategories, setAcceptCategories] = useState<string[]>(ALL_CATEGORY_CODES)
|
|
const [accepting, setAccepting] = useState(false)
|
|
|
|
function openAcceptPicker(friendshipId: string) {
|
|
setAcceptPickerFor(friendshipId)
|
|
setAcceptCategories(ALL_CATEGORY_CODES)
|
|
}
|
|
|
|
function toggleAcceptCategory(code: string) {
|
|
setAcceptCategories((prev) => {
|
|
if (prev.includes(code)) return prev.length <= 1 ? prev : prev.filter((c) => c !== code)
|
|
return [...prev, code]
|
|
})
|
|
}
|
|
|
|
async function confirmAccept(friendshipId: string) {
|
|
setAccepting(true)
|
|
try {
|
|
const res = await fetch(`/friends/${friendshipId}/accept`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ categories: acceptCategories }),
|
|
})
|
|
if (res.ok) {
|
|
setAcceptPickerFor(null)
|
|
onChanged()
|
|
}
|
|
} finally {
|
|
setAccepting(false)
|
|
}
|
|
}
|
|
|
|
async function decline(friendshipId: string) {
|
|
await fetch(`/friends/${friendshipId}`, { method: "DELETE", credentials: "include" })
|
|
onChanged()
|
|
}
|
|
|
|
async function cancelOutgoing(friendshipId: string) {
|
|
await fetch(`/friends/${friendshipId}`, { method: "DELETE", credentials: "include" })
|
|
onChanged()
|
|
}
|
|
|
|
const hasAny = incoming.length > 0 || outgoing.length > 0
|
|
if (!hasAny) return null
|
|
|
|
return (
|
|
<section aria-label="Forespørsler" className="flex flex-col gap-4">
|
|
<SectionHeading>Forespørsler</SectionHeading>
|
|
|
|
{incoming.length > 0 && (
|
|
<div className="flex flex-col gap-3">
|
|
<h3 className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Mottatt</h3>
|
|
<ul className="flex flex-col gap-3">
|
|
{incoming.map((person) => {
|
|
const pickerOpen = acceptPickerFor === person.friendship_id
|
|
return (
|
|
<li
|
|
key={person.friendship_id}
|
|
className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8"
|
|
>
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<Avatar name={fullName(person.first_name, person.last_name)} />
|
|
<div className="flex min-w-0 flex-1 basis-40 flex-col">
|
|
<span className="truncate text-lg font-bold text-foreground">{fullName(person.first_name, person.last_name)}</span>
|
|
</div>
|
|
<div className="flex flex-1 basis-full items-center gap-3 sm:basis-auto">
|
|
<button
|
|
type="button"
|
|
onClick={() => (pickerOpen ? setAcceptPickerFor(null) : openAcceptPicker(person.friendship_id))}
|
|
aria-expanded={pickerOpen}
|
|
className="inline-flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:flex-none"
|
|
>
|
|
<Check aria-hidden="true" className="size-5" />
|
|
Godta
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => decline(person.friendship_id)}
|
|
className="inline-flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 text-base font-bold text-foreground transition-colors 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 sm:flex-none"
|
|
>
|
|
<X aria-hidden="true" className="size-5" />
|
|
Avslå
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{pickerOpen && (
|
|
<div className="flex flex-col gap-3 rounded-xl border border-border bg-muted/30 p-4">
|
|
<p className="text-sm font-semibold text-foreground">
|
|
Hvilke kategorier skal {fullName(person.first_name, person.last_name)} plasseres i? Alle er
|
|
forhåndsvalgt — velg heller bort det som ikke passer.
|
|
</p>
|
|
<CategoryPicker selected={acceptCategories} onToggle={toggleAcceptCategory} />
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => confirmAccept(person.friendship_id)}
|
|
disabled={accepting}
|
|
className="inline-flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-60 sm:flex-none"
|
|
>
|
|
{accepting ? "Godtar…" : "Bekreft og godta"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setAcceptPickerFor(null)}
|
|
className="inline-flex min-h-[44px] items-center justify-center rounded-xl border border-border bg-card px-4 text-base font-bold text-foreground transition-colors hover:bg-accent/50"
|
|
>
|
|
Avbryt
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{outgoing.length > 0 && (
|
|
<div className="flex flex-col gap-3">
|
|
<h3 className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Sendt</h3>
|
|
<ul className="flex flex-col gap-3">
|
|
{outgoing.map((person) => (
|
|
<li
|
|
key={person.friendship_id}
|
|
className="flex flex-wrap items-center gap-4 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8"
|
|
>
|
|
<Avatar name={fullName(person.first_name, person.last_name)} />
|
|
<div className="flex min-w-0 flex-1 basis-40 flex-col">
|
|
<span className="truncate text-lg font-bold text-foreground">{fullName(person.first_name, person.last_name)}</span>
|
|
<span className="truncate text-base text-muted-foreground">Venter på svar</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => cancelOutgoing(person.friendship_id)}
|
|
className="inline-flex min-h-[44px] shrink-0 items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 text-base font-bold text-foreground transition-colors 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"
|
|
>
|
|
<X aria-hidden="true" className="size-5" />
|
|
Kanseller
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// --- 3. Friends ----------------------------------------------------------------
|
|
|
|
function FriendsSection({ friends, onChanged }: { friends: ApiFriendEntry[]; onChanged: () => void }) {
|
|
// Ingen ukategoriserte venner (2026-07-29) -- selv-helbredende sikkerhets-
|
|
// nett for venner fra FØR den regelen ble håndhevet ved selve vennskaps-
|
|
// opprettelsen (send/godta). Skal i praksis aldri ramme en NY relasjon,
|
|
// kun eldre data. Vises som en umulig-å-overse advarsel øverst, og
|
|
// tvinger vedkommendes kategori-panel åpent (ikke bare en lenke dit).
|
|
const uncategorized = friends.filter((f) => f.categories.length === 0)
|
|
|
|
return (
|
|
<section aria-label="Venner" className="flex flex-col gap-4">
|
|
<SectionHeading>Venner</SectionHeading>
|
|
|
|
{uncategorized.length > 0 && (
|
|
<div
|
|
role="alert"
|
|
className="flex flex-col gap-2 rounded-2xl border-2 border-brand-orange bg-brand-orange/10 p-4"
|
|
>
|
|
<p className="text-base font-bold text-foreground">
|
|
{uncategorized.length} {uncategorized.length === 1 ? "venn mangler" : "venner mangler"} kategori
|
|
</p>
|
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
Rundene dine kan ikke vises riktig for {uncategorized.length === 1 ? "denne vennen" : "disse vennene"} før
|
|
de er kategorisert. Kategoriseringen er allerede åpnet under — velg minst én kategori for hver.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{friends.length === 0 ? (
|
|
<div className="flex flex-col items-center gap-4 rounded-2xl border border-dashed border-border bg-muted/40 px-6 py-12 text-center">
|
|
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/15">
|
|
<Users aria-hidden="true" className="size-7 text-primary" />
|
|
</span>
|
|
<div className="flex flex-col gap-1.5">
|
|
<p className="text-lg font-bold text-foreground">Ingen venner ennå</p>
|
|
<p className="mx-auto max-w-sm text-base leading-relaxed text-muted-foreground text-pretty">
|
|
Bruk søkefeltet øverst til å finne folk du spiller med, og send dem en forespørsel.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<ul className="flex flex-col gap-3">
|
|
{friends.map((friend) => (
|
|
<li key={friend.friendship_id}>
|
|
<FriendRow friend={friend} onChanged={onChanged} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function FriendRow({ friend, onChanged }: { friend: ApiFriendEntry; onChanged: () => void }) {
|
|
// Tvinges åpent for en ukategorisert venn (se banneret i FriendsSection)
|
|
// -- ingen vei til å la den stå lukket og utsette valget.
|
|
const [expanded, setExpanded] = useState(friend.categories.length === 0)
|
|
const [confirming, setConfirming] = useState(false)
|
|
// Lokal, optimistisk kopi -- lagres til backend ved hver endring (PUT
|
|
// erstatter HELE settet, matcher backend-kontrakten), men holdes lokalt
|
|
// for umiddelbar UI-respons uten en full refetch per klikk.
|
|
const [categories, setCategories] = useState<string[]>(friend.categories)
|
|
|
|
async function toggleCategory(code: string) {
|
|
// Ingen ukategoriserte venner (2026-07-29) -- nekter å fjerne den
|
|
// SISTE kategorien (backend krever uansett minst én, men UI-et skal
|
|
// aldri kunne komme dit i utgangspunktet).
|
|
if (categories.includes(code) && categories.length <= 1) return
|
|
const next = categories.includes(code) ? categories.filter((c) => c !== code) : [...categories, code]
|
|
setCategories(next)
|
|
await fetch(`/friends/${friend.user_id}/categories`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ categories: next }),
|
|
})
|
|
}
|
|
|
|
async function removeFriend() {
|
|
await fetch(`/friends/${friend.friendship_id}`, { method: "DELETE", credentials: "include" })
|
|
onChanged()
|
|
}
|
|
|
|
const name = fullName(friend.first_name, friend.last_name)
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
|
<Link
|
|
href={`/my-friends/${friend.user_id}`}
|
|
className="flex items-center gap-4 rounded-xl transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<Avatar name={name} />
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<span className="truncate text-lg font-bold text-foreground">{name}</span>
|
|
<span className="truncate text-base text-muted-foreground">{friend.home_club ?? "Ingen hjemmeklubb"}</span>
|
|
</div>
|
|
</Link>
|
|
|
|
{categories.length > 0 && (
|
|
<ul className="flex flex-wrap gap-2" aria-label={`Kategorier for ${name}`}>
|
|
{categories.map((code) => (
|
|
<li key={code} className="inline-flex items-center gap-1.5 rounded-full bg-primary/15 px-3 py-1 text-sm font-semibold text-foreground">
|
|
<Tag aria-hidden="true" className="size-3.5 text-primary" />
|
|
{categoryLabel(code)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
<div className="overflow-hidden rounded-xl border border-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
aria-expanded={expanded}
|
|
className="flex min-h-[44px] w-full items-center justify-between gap-2 px-4 py-3 text-left text-base font-bold text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<Tag aria-hidden="true" className="size-5 text-primary" />
|
|
Kategorier
|
|
{categories.length > 0 && (
|
|
<span className="rounded-full bg-primary px-2 py-0.5 text-xs font-bold text-primary-foreground tabular-nums">
|
|
{categories.length}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<ChevronDown aria-hidden="true" className={cn("size-5 shrink-0 text-muted-foreground transition-transform", expanded && "rotate-180")} />
|
|
</button>
|
|
|
|
{expanded && (
|
|
<div className="flex flex-col gap-3 border-t border-border p-4">
|
|
<CategoryPicker selected={categories} onToggle={toggleCategory} />
|
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
Kun du ser hvilke kategorier du har satt en venn i. Minst én kategori er påkrevd.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{confirming ? (
|
|
<div className="flex flex-col gap-3 rounded-xl border border-destructive/40 bg-destructive/5 p-4">
|
|
<p className="text-base font-semibold text-foreground">Fjerne {name} som venn?</p>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={removeFriend}
|
|
className="inline-flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-destructive px-4 text-base font-bold text-destructive-foreground shadow-sm transition-colors hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
>
|
|
<Trash2 aria-hidden="true" className="size-5" />
|
|
Ja, fjern venn
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirming(false)}
|
|
className="inline-flex min-h-[44px] flex-1 items-center justify-center rounded-xl border border-border bg-card px-4 text-base font-bold text-foreground transition-colors 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"
|
|
>
|
|
Avbryt
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirming(true)}
|
|
className="inline-flex min-h-[44px] items-center justify-center gap-2 self-start rounded-xl px-3 text-base font-semibold text-destructive transition-colors hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
>
|
|
<Trash2 aria-hidden="true" className="size-5" />
|
|
Fjern venn
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Avatar ------------------------------------------------------------------
|
|
|
|
function initials(name: 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 Avatar({ name }: { name: string }) {
|
|
return (
|
|
<span
|
|
aria-hidden="true"
|
|
className="flex size-12 shrink-0 items-center justify-center rounded-full bg-primary/15 text-base font-bold text-foreground"
|
|
>
|
|
{initials(name)}
|
|
</span>
|
|
)
|
|
}
|