Frontend for fase 1 er bygget: components/friends.tsx + ny rute /my-friends (bevisst ikke V0s eget forslag /friends, siden det nå er API-prefikset). Datalag koblet til de ekte endepunktene, kategori-koder mappet til norske visningsnavn. Dashbordets "Venner"-blokk viser nå ekte data (antall venner, ventende forespørsler) og lenker til den nye siden. Typesjekket produksjonsbuild kompilerte rent.
Ikke rullet ut ennå — ren frontend-endring, ingen migrasjon eller backend-kode rørt.
This commit is contained in:
parent
e7dd3e0cb0
commit
e13975396e
8 changed files with 697 additions and 33 deletions
|
|
@ -10,7 +10,10 @@
|
|||
"Bash(grep -v \"ui/dropdown-menu\\\\|^.*DropdownMenuItem,$\\\\|import\")",
|
||||
"Bash(python3 test_friends.py)",
|
||||
"Bash(curl -s -o /dev/null -w \"people/search \\(anon\\): %{http_code}\\\\n\" \"https://teecup.teeoff.no/people/search?q=test\")",
|
||||
"Bash(curl -s -o /dev/null -w \"friends \\(anon\\): %{http_code}\\\\n\" \"https://teecup.teeoff.no/friends\")"
|
||||
"Bash(curl -s -o /dev/null -w \"friends \\(anon\\): %{http_code}\\\\n\" \"https://teecup.teeoff.no/friends\")",
|
||||
"Bash(rm -rf /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/zip19)",
|
||||
"Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/zip19)",
|
||||
"Bash(python3 -m zipfile -e \"/opt/teecup/tee-cup-login-screen \\(19\\).zip\" .)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3038,21 +3038,25 @@ samme "bygg i rekkefølgen ting brukes"-prinsipp som resten av prosjektet:
|
|||
`viewer == owner_user_id OR viewer IN (SELECT user_id FROM
|
||||
round_participant WHERE round_id = $1 AND user_id IS NOT NULL)`.
|
||||
|
||||
**Status: 🔨 FASE 1 (venner-kjernen) BACKEND BYGGET OG SCRATCH-VERIFISERT
|
||||
2026-07-25** — migrasjon `025_friends.sql` + `app/routers/friends.py`
|
||||
(`GET /people/search`, `POST /friends`, `POST /friends/{id}/accept`,
|
||||
`DELETE /friends/{id}`, `GET /friends`, `PUT /friends/{friend_user_id}/
|
||||
categories`). 31 scratch-sjekker bestått, inkl. presist bevist tiered
|
||||
rangering (venn→klubb→land→globalt) og at kategorisering faktisk er
|
||||
PRIVAT (B ser aldri kategoriene A har satt B i). `test_isolation.sql`
|
||||
fortsatt 12/12. **IKKE rullet ut mot ekte systemer ennå** — venter på
|
||||
brukerens bekreftelse. **Frontend IKKE bygget** — V0-prompt skrevet i
|
||||
FEATURE_BACKLOG.md, klar til å kjøres (ny rute foreslått som
|
||||
`/my-friends`, ikke `/friends` — det er nå API-prefikset). Fase 2
|
||||
(rundevisibilitet) og fase 3 (ekte medspillere, inkl. den nylig avklarte
|
||||
"skriv for hele flighten"-regelen) er fortsatt kun designet, ikke bygget.
|
||||
Se "Venner og kategorisert deling"-seksjonen i FEATURE_BACKLOG.md for
|
||||
full detalj og V0-prompten.
|
||||
**Status: 🔨 FASE 1 (venner-kjernen) BACKEND + FRONTEND BYGGET
|
||||
2026-07-25.** Backend: migrasjon `025_friends.sql` + `app/routers/
|
||||
friends.py` (`GET /people/search`, `POST /friends`, `POST /friends/{id}/
|
||||
accept`, `DELETE /friends/{id}`, `GET /friends`, `PUT /friends/
|
||||
{friend_user_id}/categories`) — 31 scratch-sjekker bestått, inkl.
|
||||
presist bevist tiered rangering (venn→klubb→land→globalt) og at
|
||||
kategorisering faktisk er PRIVAT (B ser aldri kategoriene A har satt B
|
||||
i). `test_isolation.sql` fortsatt 12/12. Rullet ut mot ekte `teecup_db`/
|
||||
`teecup_api` samme dag, bruker bekreftet eksplisitt.
|
||||
Frontend: V0-zip (zip 19) integrert som `components/friends.tsx` + ny
|
||||
rute `app/my-friends/page.tsx` (IKKE `/friends`, som V0 selv foreslo —
|
||||
det er API-prefikset, samme kollisjonsklasse som `/rounds` unngått fra
|
||||
start denne gangen). Dashbordets "Venner"-blokk koblet til ekte data i
|
||||
samme runde. Ekte typesjekket produksjonsbuild kompilerte rent.
|
||||
**IKKE rullet ut ennå (frontend-delen)** — venter på brukerens
|
||||
bekreftelse. Fase 2 (rundevisibilitet) og fase 3 (ekte medspillere, inkl.
|
||||
den avklarte "skriv for hele flighten"-regelen) er fortsatt kun
|
||||
designet, ikke bygget. Se "Venner og kategorisert deling"-seksjonen i
|
||||
FEATURE_BACKLOG.md for full detalj.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
30
CLAUDE.md
30
CLAUDE.md
|
|
@ -2677,6 +2677,36 @@ Ferdig og verifisert:
|
|||
FEATURE_BACKLOG.md i stedet, matcher etablert mønster/tidligere
|
||||
korrigering) — venter på at brukeren kjører den i v0.app.
|
||||
|
||||
- **ADR-036 fase 1 FRONTEND BYGGET, IKKE ENNÅ RULLET UT (2026-07-25):**
|
||||
zip 19 mottatt og integrert — kun `components/friends.tsx` og
|
||||
`app/friends/page.tsx` var reelt nye fra V0 (samme full-reeksport-
|
||||
mønster som alltid, resten forventede reverts av allerede tilpassede
|
||||
filer, hoppet over). **V0s egen rute (`/friends`) BEVISST IKKE brukt**
|
||||
— flyttet til `/my-friends`, siden `/friends` nå er API-prefikset
|
||||
(samme kollisjonsklasse som `/rounds` → `/my-rounds` tidligere, unngått
|
||||
fra start denne gangen i stedet for oppdaget i produksjon).
|
||||
Datalag skrevet fullstendig om fra V0s mock til ekte fetch mot
|
||||
`/people/search`+`/friends`-endepunktene — TS-typene speiler Pydantic-
|
||||
modellene i `app/routers/friends.py` felt-for-felt. Kategori-koder
|
||||
(`spouse`, `golf_friends`, osv.) mappet mot V0s norske visningsnavn via
|
||||
en delt `CATEGORY_OPTIONS`-liste i SAMME rekkefølge som backend sin
|
||||
`Category`-type. Søkefeltet håndhever samme 2-tegns-minimum som
|
||||
backend (viser en forklarende tekst i stedet for å bare returnere
|
||||
tomt). "Send forespørsel"/"Godta"/"Avslå"/"Kanseller"/"Fjern venn"
|
||||
trigger alle en full refetch av `GET /friends` etterpå (samme
|
||||
refetch-etter-mutasjon-mønster som resten av appen) — kun kategori-
|
||||
avkrysning er lokalt optimistisk (matcher PUT-endepunktets
|
||||
erstatt-hele-settet-kontrakt).
|
||||
**Dashbordets "Venner"-blokk koblet til ekte data i samme runde:**
|
||||
root-komponenten henter nå `GET /friends`, viser ekte avatar-initialer
|
||||
+ antall venner + ventende forespørsler (samme visuelle design som
|
||||
opprinnelig i zip 18, som ble bevisst forenklet til en inert tom-
|
||||
tilstand forrige runde siden backend ikke fantes ennå) — begge
|
||||
knappene ("Se venner"/"Søk etter venner") lenker nå til `/my-friends`.
|
||||
Ekte typesjekket produksjonsbuild kompilerte rent, `/my-friends` listet
|
||||
blant 22 ruter. **IKKE rullet ut ennå** — venter på brukerens
|
||||
bekreftelse (ren frontend-endring, ingen migrasjon/backend-kode rørt).
|
||||
|
||||
Neste steg:
|
||||
1. **Klart for bygging, venter på brukerens go-ahead:** dashbord-redesign
|
||||
(ADR-035) + venner/kategorisert deling (ADR-036), begge designet
|
||||
|
|
|
|||
|
|
@ -1477,7 +1477,7 @@ gjennomgang av blokk-forslaget over først):
|
|||
|
||||
---
|
||||
|
||||
## Venner, kategorisert deling av runder, og tiered personsøk — 🔨 FASE 1 BACKEND BYGGET 2026-07-25 (se under), fase 2/3 fortsatt kun designet
|
||||
## Venner, kategorisert deling av runder, og tiered personsøk — 🔨 FASE 1 BACKEND+FRONTEND BYGGET 2026-07-25 (se under), fase 2/3 fortsatt kun designet
|
||||
|
||||
Reist av brukeren samme runde som dashbord-forslaget over. Fullt design
|
||||
skrevet som **ADR-036** i ARCHITECTURE_DECISIONS.md — se der for
|
||||
|
|
@ -1568,14 +1568,19 @@ etterpå fungerer. `test_isolation.sql` fortsatt 12/12. Ekte typesjekket
|
|||
produksjonsbuild kompilerte rent (ingen frontend-endring ennå utover
|
||||
`next.config.mjs` sine nye rewrites for `/people`/`/friends`).
|
||||
|
||||
**Frontend IKKE bygget** — venter bevisst på V0 (etablert mønster for ALL
|
||||
frontend i prosjektet, jf. brukerens tidligere eksplisitte korrigering av
|
||||
at rundeskjermene opprinnelig ble hånd-kodet i stedet). Ny frontend-rute
|
||||
foreslått som `/my-friends` (IKKE `/friends` — det er nå API-prefikset,
|
||||
samme kollisjonsfelle som `/rounds` vs. `/my-rounds`, unngått fra start
|
||||
denne gangen).
|
||||
**Frontend BYGGET 2026-07-25, samme dag** — zip 19 mottatt (V0-prompten
|
||||
under kjørt av bruker), integrert som `components/friends.tsx` + ny rute
|
||||
`app/my-friends/page.tsx` (IKKE `app/friends/page.tsx` slik V0 selv
|
||||
foreslo — flyttet bevisst for å unngå kollisjon med API-prefikset
|
||||
`/friends`, samme klasse feil som `/rounds` vs. `/my-rounds` tidligere,
|
||||
denne gangen unngått fra start). Datalag skrevet om fra mock til ekte
|
||||
fetch, kategori-koder mappet mot norske visningsnavn i samme rekkefølge
|
||||
som backend. Dashbordets "Venner"-blokk (`dashboard.tsx`) koblet til
|
||||
ekte `GET /friends`-data i samme runde — viser nå ekte antall
|
||||
venner/ventende forespørsler, lenker til `/my-friends`. Ekte
|
||||
typesjekket produksjonsbuild kompilerte rent.
|
||||
|
||||
**V0-prompt skrevet, klar til å kjøres:**
|
||||
**V0-prompten som ble brukt (til referanse):**
|
||||
|
||||
> Design en ny "Mine venner"-side for TeeCup (golf-app, Next.js +
|
||||
> Tailwind + shadcn/ui, mobil-først, eksisterende merkevarefarger).
|
||||
|
|
|
|||
5
frontend/app/my-friends/page.tsx
Normal file
5
frontend/app/my-friends/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Friends } from "@/components/friends"
|
||||
|
||||
export default function MyFriendsPage() {
|
||||
return <Friends />
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { useCallback, useEffect, useState } from "react"
|
|||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
ArrowRight,
|
||||
Building2,
|
||||
ChevronRight,
|
||||
Flag,
|
||||
|
|
@ -98,6 +99,13 @@ type ApiRound = {
|
|||
|
||||
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 }
|
||||
}
|
||||
|
|
@ -184,6 +192,7 @@ export function Dashboard() {
|
|||
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 [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadMe = useCallback(async () => {
|
||||
|
|
@ -219,6 +228,10 @@ export function Dashboard() {
|
|||
.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(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -373,7 +386,7 @@ export function Dashboard() {
|
|||
<UpcomingTournaments entries={combinedTournaments} />
|
||||
<StatsSection roundsCount={rows.length} hcpNow={hcpNow} hcpTrend={hcpTrend} hcpHistory={hcpValues} avgToPar={avgToPar} />
|
||||
<PlayedCourses courses={playedCourses} />
|
||||
<FriendsSection />
|
||||
<FriendsSection summary={friendsSummary} />
|
||||
<OrganizationsFooter organizations={me.organizations} />
|
||||
</div>
|
||||
</main>
|
||||
|
|
@ -796,18 +809,58 @@ function PlayedCourses({ courses }: { courses: { name: string; visits: number; l
|
|||
}
|
||||
|
||||
// --- 6. Venner ---------------------------------------------------------------
|
||||
// Vennekonseptet (ADR-036) er DESIGNET, ikke bygget ennå -- ingen backend å
|
||||
// hente fra. Seksjonen vises som en ærlig tom-tilstand (matcher V0s design),
|
||||
// men "Søk etter venner"-knappen er bevisst uten href/onClick (inert) til
|
||||
// fase 1 av ADR-036 er bygget -- ingen /friends-side finnes å lenke til ennå.
|
||||
// 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
|
||||
|
||||
function FriendsSection() {
|
||||
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" />
|
||||
<ShortcutButton icon={UserPlus} label="Søk etter venner" href="/my-friends" />
|
||||
</EmptyState>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
564
frontend/components/friends.tsx
Normal file
564
frontend/components/friends.tsx
Normal file
|
|
@ -0,0 +1,564 @@
|
|||
"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<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>>({})
|
||||
|
||||
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 (
|
||||
<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]
|
||||
return (
|
||||
<li
|
||||
key={person.id}
|
||||
className="flex items-center gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5"
|
||||
>
|
||||
<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={() => sendRequest(person.id)}
|
||||
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>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// --- 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 (
|
||||
<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) => (
|
||||
<li
|
||||
key={person.friendship_id}
|
||||
className="flex flex-wrap items-center gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5"
|
||||
>
|
||||
<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={() => respond(person.friendship_id, "accept")}
|
||||
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={() => respond(person.friendship_id, "remove")}
|
||||
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>
|
||||
</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-sm shadow-black/5"
|
||||
>
|
||||
<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={() => respond(person.friendship_id, "remove")}
|
||||
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 }) {
|
||||
return (
|
||||
<section aria-label="Venner" className="flex flex-col gap-4">
|
||||
<SectionHeading>Venner</SectionHeading>
|
||||
|
||||
{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 }) {
|
||||
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<string[]>(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 (
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
||||
<div className="flex items-center gap-4">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{CATEGORY_OPTIONS.map(({ code, label }) => {
|
||||
const checked = categories.includes(code)
|
||||
return (
|
||||
<li key={code}>
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
onClick={() => toggleCategory(code)}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">Kun du ser hvilke kategorier du har satt en venn i.</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>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
Loading…
Reference in a new issue