SolyQuiz/components/dashboard/NotificationBell.tsx
corenthin-lebreton c499c452f0 new modif
2026-02-27 00:10:09 +01:00

193 lines
7.4 KiB
TypeScript

'use client'
import { useState, useRef, useEffect, useCallback } from 'react'
import { Bell, X, Check, Loader2 } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { cn } from '@/lib/utils'
interface Notification {
id: string
type: string
title: string
body: string
read: boolean
created_at: string
}
const typeIcon: Record<string, string> = {
session_created: '🚀',
student_joined: '👤',
session_ended: '✅',
}
function formatRelativeTime(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60_000)
if (minutes < 1) return "à l'instant"
if (minutes < 60) return `il y a ${minutes} min`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `il y a ${hours}h`
const days = Math.floor(hours / 24)
return `il y a ${days}j`
}
export default function NotificationBell() {
const [open, setOpen] = useState(false)
const [notifications, setNotifications] = useState<Notification[]>([])
const [loading, setLoading] = useState(true)
const ref = useRef<HTMLDivElement>(null)
const unreadCount = notifications.filter((n) => !n.read).length
// ── Fetch initial ────────────────────────────────────────────────────────
const fetchNotifications = useCallback(async () => {
try {
const res = await fetch('/api/notifications')
if (!res.ok) return
const data = await res.json()
setNotifications(data.notifications ?? [])
} catch {
// silencieux — la cloche reste vide plutôt que de crasher
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchNotifications()
}, [fetchNotifications])
// ── Supabase Realtime — nouvelles notifs en temps réel ───────────────────
useEffect(() => {
const supabase = createClient()
const channel = supabase
.channel('notifications-realtime')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'notifications' },
(payload) => {
const newNotif = payload.new as Notification
setNotifications((prev) => [newNotif, ...prev].slice(0, 30))
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [])
// ── Fermer le panneau au clic extérieur ──────────────────────────────────
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])
// ── Marquer comme lu ─────────────────────────────────────────────────────
const markRead = async (id: string) => {
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)))
await fetch('/api/notifications', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [id] }),
})
}
const markAllRead = async () => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })))
await fetch('/api/notifications', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all: true }),
})
}
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className="relative p-2.5 bg-background-card border border-border rounded-lg hover:border-border-light transition-colors"
aria-label="Notifications"
>
<Bell size={18} className="text-text-secondary" />
{unreadCount > 0 && (
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-primary rounded-full animate-pulse" />
)}
</button>
{open && (
<div className="absolute right-0 top-12 w-80 bg-background-card border border-border rounded-xl shadow-2xl shadow-black/50 z-50 overflow-hidden animate-in fade-in slide-in-from-top-2 duration-150">
{/* En-tête */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-text-primary text-sm">Notifications</h3>
{unreadCount > 0 && (
<span className="bg-primary/20 text-primary text-xs px-2 py-0.5 rounded-full font-medium">
{unreadCount}
</span>
)}
</div>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="text-xs text-primary hover:text-primary-light transition-colors flex items-center gap-1"
>
<Check size={11} /> Tout lire
</button>
)}
<button
onClick={() => setOpen(false)}
className="p-1 hover:bg-background-elevated rounded transition-colors"
>
<X size={14} className="text-text-muted" />
</button>
</div>
</div>
{/* Liste */}
<div className="max-h-80 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 size={20} className="animate-spin text-text-muted" />
</div>
) : notifications.length === 0 ? (
<div className="py-10 text-center">
<Bell size={28} className="mx-auto text-text-muted/40 mb-2" />
<p className="text-sm text-text-muted">Aucune notification</p>
</div>
) : (
notifications.map((notif) => (
<div
key={notif.id}
onClick={() => !notif.read && markRead(notif.id)}
className={cn(
'flex items-start gap-3 px-4 py-3 hover:bg-background-elevated/50 transition-colors border-b border-border/50 last:border-0',
!notif.read ? 'bg-primary/5 cursor-pointer' : 'cursor-default'
)}
>
<div className="w-8 h-8 bg-background-elevated rounded-lg flex items-center justify-center text-sm flex-shrink-0">
{typeIcon[notif.type] ?? '🔔'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-0.5">
<p className="text-sm font-medium text-text-primary truncate">{notif.title}</p>
{!notif.read && <div className="w-1.5 h-1.5 bg-primary rounded-full flex-shrink-0" />}
</div>
<p className="text-xs text-text-muted leading-relaxed">{notif.body}</p>
<p className="text-xs text-text-muted/50 mt-1">{formatRelativeTime(notif.created_at)}</p>
</div>
</div>
))
)}
</div>
</div>
)}
</div>
)
}