teecup/frontend/components/notifications.tsx

268 lines
9.2 KiB
TypeScript
Raw Normal View History

"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import {
ArrowLeft,
Bell,
BellOff,
CheckCheck,
ChevronRight,
Flag,
Trophy,
UserPlus,
Users,
} from "lucide-react"
import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils"
2026-08-07 22:02:56 +02:00
import { BottomTabBar } from "@/components/dashboard"
// V0-designet (zip 20, varsler-runden 2026-07-25) -- datalag skrevet om fra
// mock til ekte fetch mot GET /notifications + POST .../read + .../read-all
// (app/routers/notifications.py). Scenario-veksleren fra V0-eksporten fjernet.
type NotificationKind = "friend" | "tournament" | "round" | "result"
type ApiNotification = {
id: string
type: NotificationKind
message: string
link_path: string
created_at: string
read_at: string | null
}
const KIND_ICON: Record<NotificationKind, typeof Bell> = {
friend: UserPlus,
tournament: Trophy,
round: Flag,
result: Users,
}
const KIND_LABEL: Record<NotificationKind, string> = {
friend: "Venneforespørsel",
tournament: "Turnering",
round: "Runde",
result: "Resultat",
}
function formatRelativeTime(iso: string): string {
const minutesAgo = Math.max(0, (Date.now() - new Date(iso).getTime()) / 60000)
if (minutesAgo < 1) return "akkurat nå"
if (minutesAgo < 60) {
const m = Math.round(minutesAgo)
return `for ${m} ${m === 1 ? "minutt" : "minutter"} siden`
}
const hours = Math.floor(minutesAgo / 60)
if (hours < 24) {
return `for ${hours} ${hours === 1 ? "time" : "timer"} siden`
}
const days = Math.floor(hours / 24)
if (days < 7) {
return `for ${days} ${days === 1 ? "dag" : "dager"} siden`
}
const weeks = Math.floor(days / 7)
return `for ${weeks} ${weeks === 1 ? "uke" : "uker"} siden`
}
export function Notifications() {
const router = useRouter()
const [items, setItems] = useState<ApiNotification[] | null>(null)
useEffect(() => {
fetch("/notifications", { credentials: "include" })
.then((res) => {
if (res.status === 401) {
router.replace("/")
return null
}
return res.ok ? res.json() : []
})
.then((data: ApiNotification[] | null) => {
if (data) setItems(data)
})
.catch(() => setItems([]))
}, [router])
const sorted = useMemo(
() => [...(items ?? [])].sort((a, b) => (a.created_at < b.created_at ? 1 : -1)),
[items],
)
const unreadCount = sorted.filter((n) => n.read_at === null).length
async function markAllRead() {
setItems((prev) => (prev ? prev.map((n) => ({ ...n, read_at: n.read_at ?? new Date().toISOString() })) : prev))
await fetch("/notifications/read-all", { method: "POST", credentials: "include" })
}
async function markRead(id: string) {
setItems((prev) => (prev ? prev.map((n) => (n.id === id ? { ...n, read_at: n.read_at ?? new Date().toISOString() } : n)) : prev))
await fetch(`/notifications/${id}/read`, { method: "POST", credentials: "include" })
}
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-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowLeft aria-hidden="true" className="size-4" />
Til dashbord
</Link>
<Wordmark compact />
</div>
</header>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 pb-28 pt-6 sm:pt-8">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">Varsler</h1>
<p className="text-base leading-relaxed text-muted-foreground text-pretty">
{items === null
? "Henter varsler …"
: unreadCount > 0
? unreadCount === 1
? "Du har 1 ulest varsel."
: `Du har ${unreadCount} uleste varsler.`
: "Alt er lest. Her dukker nye varsler opp."}
</p>
</div>
{sorted.length > 0 && (
<div className="flex">
<button
type="button"
onClick={markAllRead}
disabled={unreadCount === 0}
className="inline-flex min-h-[44px] items-center gap-2 rounded-xl border border-border bg-card px-4 py-2 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<CheckCheck aria-hidden="true" className="size-4" />
Merk alle som lest
</button>
</div>
)}
</div>
{items !== null && sorted.length === 0 ? (
<EmptyState />
) : (
<ul className="mt-4 flex flex-col gap-2.5">
{sorted.map((n) => (
<li key={n.id}>
<NotificationRow notification={n} onActivate={() => markRead(n.id)} />
</li>
))}
</ul>
)}
</main>
2026-08-07 22:02:56 +02:00
<BottomTabBar active="more" />
</div>
)
}
function NotificationRow({
notification,
onActivate,
}: {
notification: ApiNotification
onActivate: () => void
}) {
const Icon = KIND_ICON[notification.type]
const unread = notification.read_at === null
const readState = unread ? "Ulest" : "Lest"
return (
<Link
href={notification.link_path}
onClick={onActivate}
aria-label={`${readState}. ${KIND_LABEL[notification.type]}: ${notification.message} ${formatRelativeTime(
notification.created_at,
)}`}
className={cn(
2026-07-29 12:44:51 +02:00
"group flex items-center gap-3 rounded-2xl border p-4 text-left shadow-md shadow-black/8 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
unread
? "border-brand-orange/40 bg-brand-orange/5 hover:bg-brand-orange/10"
: "border-border bg-card hover:bg-accent/50",
)}
>
{/* Ulest-indikator: en prikk i en reservert plass, slik at leste rader forblir på linje. */}
<span className="flex w-3 shrink-0 justify-center" aria-hidden="true">
{unread && <span className="size-3 rounded-full bg-brand-orange" />}
</span>
<span
aria-hidden="true"
className={cn(
"flex size-11 shrink-0 items-center justify-center rounded-xl",
unread ? "bg-brand-orange/15 text-foreground" : "bg-muted text-muted-foreground",
)}
>
<Icon className="size-5" />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex items-center gap-2">
<span
className={cn(
"text-xs uppercase tracking-wide",
unread ? "font-bold text-foreground" : "font-semibold text-muted-foreground",
)}
>
{KIND_LABEL[notification.type]}
</span>
{unread && (
<span className="rounded-full bg-brand-orange px-2 py-0.5 text-[11px] font-bold leading-none text-brand-orange-foreground">
Ny
</span>
)}
</span>
<span
className={cn(
"text-base leading-snug text-foreground text-pretty",
unread ? "font-bold" : "font-normal",
)}
>
{notification.message}
</span>
<span className="text-sm font-medium text-muted-foreground tabular-nums">
{formatRelativeTime(notification.created_at)}
</span>
</div>
<ChevronRight
aria-hidden="true"
className="size-5 shrink-0 self-center text-muted-foreground transition-transform group-hover:translate-x-0.5"
/>
</Link>
)
}
function EmptyState() {
return (
<div className="mt-6 flex flex-col items-center gap-4 rounded-3xl border border-dashed border-border bg-card px-6 py-14 text-center">
<span
aria-hidden="true"
className="flex size-16 items-center justify-center rounded-2xl bg-muted text-muted-foreground"
>
<BellOff className="size-8" />
</span>
<div className="flex flex-col gap-2">
<h2 className="text-lg font-extrabold tracking-tight text-foreground">Ingen varsler ennå</h2>
<p className="mx-auto max-w-xs text-base leading-relaxed text-muted-foreground text-pretty">
Når noe skjer med rundene, turneringene eller vennene dine, dukker det opp her.
</p>
</div>
<Link
href="/dashboard"
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-xl bg-primary px-5 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"
>
Tilbake til dashbord
</Link>
</div>
)
}