"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 } // --- 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>({}) 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) { setJustSent((prev) => ({ ...prev, [personId]: true })) try { const res = await fetch("/friends", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ addressee_user_id: personId }), }) if (res.ok) onRequestSent() } catch { setJustSent((prev) => ({ ...prev, [personId]: 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] return (
  • {fullName(person.first_name, person.last_name)} {person.home_club ?? "Ingen hjemmeklubb"}
    {isFriend ? ( ) : isSent ? ( ) : ( )}
  • ) })}
)}
) } // --- 2. Requests ------------------------------------------------------------- function RequestsSection({ incoming, outgoing, onChanged, }: { incoming: ApiPendingRequest[] outgoing: ApiPendingRequest[] onChanged: () => void }) { async function respond(friendshipId: string, action: "accept" | "remove") { if (action === "accept") { await fetch(`/friends/${friendshipId}/accept`, { method: "POST", credentials: "include" }) } else { 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) => (
  • {fullName(person.first_name, person.last_name)}
  • ))}
)} {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 }) { return (
Venner {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 }) { const [expanded, setExpanded] = useState(false) 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) { 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 && (
    {CATEGORY_OPTIONS.map(({ code, label }) => { const checked = categories.includes(code) return (
  • ) })}

Kun du ser hvilke kategorier du har satt en venn i.

)}
{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 ( ) }