Dashbordets egne JSX re-stylet til --clubhouse-*/--tee*/--cup*-tokens (V0, egen parallell utforskning ved siden av Forest Green) -- delte komponenter som RoundCard/TournamentCard urørt. Den gamle, lokale BottomTabBar erstattet med components/teecup/bottom-nav.tsx (egen V0-prompt, usePathname()-drevet aktiv-fane), koblet inn på alle syv sider som viser den. To reelle bugs funnet og rettet under scratch-verifisering: aktiv-fane-sammenligning som feilaktig strippet #hash (ga to samtidig aktive faner), og manglende fane-gruppering for my-friends/my-feed/my-notifications (løst med en matchPaths-mekanisme). Se CHANGELOG.md punkt 39-40 for full verifiseringsdetalj.
267 lines
9.2 KiB
TypeScript
267 lines
9.2 KiB
TypeScript
"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"
|
|
import { BottomNav } from "@/components/teecup/bottom-nav"
|
|
|
|
// 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("/logg-inn")
|
|
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>
|
|
|
|
<BottomNav />
|
|
</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(
|
|
"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>
|
|
)
|
|
}
|