"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" import { BottomTabBar } from "@/components/dashboard" // --- 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 ( ) } // --- 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(null) const [error, setError] = useState(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 (

Mine venner

Finn folk du spiller med, håndter forespørsler, og organiser vennene dine i egne kategorier.

{error && (

{error}

)}
) } // --- Section header -------------------------------------------------------- function SectionHeading({ children }: { children: React.ReactNode }) { return

{children}

} // --- 1. Search --------------------------------------------------------------- function SearchSection({ friendUserIds, outgoingUserIds, onRequestSent, }: { friendUserIds: Set outgoingUserIds: Set onRequestSent: () => void }) { const [query, setQuery] = useState("") const [results, setResults] = useState([]) const [searching, setSearching] = useState(false) const [justSent, setJustSent] = useState>({}) // 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(null) const [pickerCategories, setPickerCategories] = useState(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 (
Finn venner
{query.trim().length < 2 ? (

Skriv minst to tegn for å søke etter spillere.

) : searching ? (

Søker …

) : results.length === 0 ? (

Ingen spillere matcher «{query.trim()}».

) : (
    {results.map((person) => { const isFriend = friendUserIds.has(person.id) const isSent = outgoingUserIds.has(person.id) || justSent[person.id] const pickerOpen = pickerFor === person.id return (
  • {fullName(person.first_name, person.last_name)} {person.home_club ?? "Ingen hjemmeklubb"}
    {isFriend ? ( ) : isSent ? ( ) : ( )}
    {pickerOpen && (

    Hvilke kategorier skal {fullName(person.first_name, person.last_name)} plasseres i? Alle er forhåndsvalgt — velg heller bort det som ikke passer.

    )}
  • ) })}
)}
) } // --- 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(null) const [acceptCategories, setAcceptCategories] = useState(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 (
Forespørsler {incoming.length > 0 && (

Mottatt

    {incoming.map((person) => { const pickerOpen = acceptPickerFor === person.friendship_id return (
  • {fullName(person.first_name, person.last_name)}
    {pickerOpen && (

    Hvilke kategorier skal {fullName(person.first_name, person.last_name)} plasseres i? Alle er forhåndsvalgt — velg heller bort det som ikke passer.

    )}
  • ) })}
)} {outgoing.length > 0 && (

Sendt

    {outgoing.map((person) => (
  • {fullName(person.first_name, person.last_name)} Venter på svar
  • ))}
)}
) } // --- 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 (
Venner {uncategorized.length > 0 && (

{uncategorized.length} {uncategorized.length === 1 ? "venn mangler" : "venner mangler"} kategori

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.

)} {friends.length === 0 ? (

Ingen venner ennå

Bruk søkefeltet øverst til å finne folk du spiller med, og send dem en forespørsel.

) : (
    {friends.map((friend) => (
  • ))}
)}
) } 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(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 (
{name} {friend.home_club ?? "Ingen hjemmeklubb"}
{categories.length > 0 && (
    {categories.map((code) => (
  • ))}
)}
{expanded && (

Kun du ser hvilke kategorier du har satt en venn i. Minst én kategori er påkrevd.

)}
{confirming ? (

Fjerne {name} som venn?

) : ( )}
) } // --- 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 ( ) }