"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 (
{CATEGORY_OPTIONS.map(({ code, label }) => {
const checked = selected.includes(code)
const blocked = checked && selected.length <= 1
return (
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",
)}
>
{checked && }
{label}
)
})}
)
}
// --- 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
setQuery(e.target.value)}
placeholder="Søk etter navn …"
aria-label="Søk etter navn"
className="h-14 rounded-2xl pl-12 text-lg"
/>
{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 ? (
Venn
) : isSent ? (
Sendt
) : (
(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"
>
Send forespørsel
)}
{pickerOpen && (
Hvilke kategorier skal {fullName(person.first_name, person.last_name)} plasseres i? Alle er
forhåndsvalgt — velg heller bort det som ikke passer.
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"}
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
)}
)
})}
)}
)
}
// --- 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 ? 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"
>
Godta
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"
>
Avslå
{pickerOpen && (
Hvilke kategorier skal {fullName(person.first_name, person.last_name)} plasseres i? Alle er
forhåndsvalgt — velg heller bort det som ikke passer.
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"}
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
)}
)
})}
)}
{outgoing.length > 0 && (
)}
)
}
// --- 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) => (
{categoryLabel(code)}
))}
)}
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"
>
Kategorier
{categories.length > 0 && (
{categories.length}
)}
{expanded && (
Kun du ser hvilke kategorier du har satt en venn i. Minst én kategori er påkrevd.
)}
{confirming ? (
Fjerne {name} som venn?
Ja, fjern venn
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
) : (
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"
>
Fjern 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 (
{initials(name)}
)
}