Retour au catalogue
Notification Inbox
Panneau de boite de reception de notifications avec filtres, marquer comme lu, tri par date et actions groupees.
notificationcomplex Both Responsive a11y
minimalcorporatesaasuniversalstacked
Theme
"use client";
import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Bell, Inbox, Check, CheckCheck, Trash2, Filter, Circle, MessageSquare, AlertTriangle, Star, UserPlus, Mail } from "lucide-react";
import React from "react";
type NotificationType = "message" | "alert" | "mention" | "invite" | "system";
type FilterType = "all" | "unread" | NotificationType;
interface InboxNotification {
id: string;
type: NotificationType;
title: string;
message: string;
timestamp: string;
isRead: boolean;
sender?: string;
senderInitials?: string;
}
interface NotificationInboxProps {
title?: string;
subtitle?: string;
notifications?: InboxNotification[];
}
const EASE = [0.16, 1, 0.3, 1] as const;
const typeIcons: Record<NotificationType, React.ElementType> = {
message: MessageSquare,
alert: AlertTriangle,
mention: Star,
invite: UserPlus,
system: Bell,
};
const typeColors: Record<NotificationType, string> = {
message: "#3b82f6",
alert: "#f59e0b",
mention: "#a855f7",
invite: "#22c55e",
system: "var(--color-foreground-muted)",
};
const filterLabels: Record<FilterType, string> = {
all: "Toutes",
unread: "Non lues",
message: "Messages",
alert: "Alertes",
mention: "Mentions",
invite: "Invitations",
system: "Systeme",
};
export default function NotificationInbox({
title,
subtitle,
notifications,
}: NotificationInboxProps) {
const resolvedTitle = title ?? "Boite de reception";
const resolvedSubtitle = subtitle ?? "Gerez vos notifications avec filtres, lecture et actions groupees.";
const initialNotifications: InboxNotification[] = notifications ?? [
{ id: "n1", type: "message", title: "Nouveau message", message: "Marie a envoye un message dans le canal #design.", timestamp: "Il y a 3 min", isRead: false, sender: "Marie D.", senderInitials: "MD" },
{ id: "n2", type: "mention", title: "Vous etes mentionne", message: "Jean vous a mentionne dans le document 'Spec API v3'.", timestamp: "Il y a 12 min", isRead: false, sender: "Jean M.", senderInitials: "JM" },
{ id: "n3", type: "alert", title: "Alerte performance", message: "Le temps de reponse du serveur depasse le seuil configure (>500ms).", timestamp: "Il y a 25 min", isRead: false },
{ id: "n4", type: "invite", title: "Invitation equipe", message: "Sophie vous invite a rejoindre le projet 'Refonte Mobile'.", timestamp: "Il y a 1h", isRead: true, sender: "Sophie B.", senderInitials: "SB" },
{ id: "n5", type: "system", title: "Mise a jour systeme", message: "La maintenance planifiee aura lieu le 15 mars de 2h a 4h.", timestamp: "Il y a 3h", isRead: true },
{ id: "n6", type: "message", title: "Discussion close", message: "Pierre a clos la discussion 'Architecture micro-services'.", timestamp: "Il y a 5h", isRead: true, sender: "Pierre L.", senderInitials: "PL" },
];
const [items, setItems] = useState<InboxNotification[]>(initialNotifications);
const [filter, setFilter] = useState<FilterType>("all");
const filtered = items.filter((item) => {
if (filter === "all") return true;
if (filter === "unread") return !item.isRead;
return item.type === filter;
});
const unreadCount = items.filter((i) => !i.isRead).length;
const markAsRead = useCallback((id: string) => {
setItems((prev) => prev.map((n) => n.id === id ? { ...n, isRead: true } : n));
}, []);
const markAllRead = useCallback(() => {
setItems((prev) => prev.map((n) => ({ ...n, isRead: true })));
}, []);
const deleteNotification = useCallback((id: string) => {
setItems((prev) => prev.filter((n) => n.id !== id));
}, []);
return (
<section
style={{
padding: "var(--section-padding-y, 6rem) 0",
background: "var(--color-background-alt)",
}}
>
<div
style={{
maxWidth: "var(--container-max-width, 700px)",
margin: "0 auto",
padding: "0 var(--container-padding-x, 1.5rem)",
}}
>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, ease: EASE }}
style={{ textAlign: "center", marginBottom: "2.5rem" }}
>
<div style={{ display: "inline-flex", alignItems: "center", gap: "0.5rem", padding: "0.375rem 0.75rem", borderRadius: "999px", background: "color-mix(in srgb, var(--color-accent) 10%, transparent)", marginBottom: "1rem" }}>
<Mail style={{ width: 14, height: 14, color: "var(--color-accent)" }} />
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: "var(--color-accent)", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Inbox
</span>
</div>
<h2 style={{ fontSize: "clamp(1.75rem, 3vw, 2.5rem)", fontWeight: 700, color: "var(--color-foreground)", marginBottom: "0.75rem", letterSpacing: "-0.02em" }}>
{resolvedTitle}
</h2>
<p style={{ fontSize: "1rem", color: "var(--color-foreground-muted)", maxWidth: "500px", margin: "0 auto", lineHeight: 1.6 }}>
{resolvedSubtitle}
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1, ease: EASE }}
style={{
borderRadius: "16px",
border: "1px solid var(--color-border)",
background: "var(--color-background-card)",
overflow: "hidden",
}}
>
{/* Toolbar */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0.75rem 1.25rem", borderBottom: "1px solid var(--color-border)", gap: "0.5rem", flexWrap: "wrap" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<Inbox style={{ width: 16, height: 16, color: "var(--color-foreground-muted)" }} />
<span style={{ fontSize: "0.875rem", fontWeight: 600, color: "var(--color-foreground)" }}>
Notifications
</span>
{unreadCount > 0 && (
<span style={{ fontSize: "0.6875rem", padding: "0.0625rem 0.5rem", borderRadius: "999px", background: "var(--color-accent)", color: "var(--color-background)", fontWeight: 700 }}>
{unreadCount}
</span>
)}
</div>
{unreadCount > 0 && (
<button
onClick={markAllRead}
style={{
display: "flex",
alignItems: "center",
gap: "0.25rem",
padding: "0.375rem 0.625rem",
borderRadius: "6px",
border: "1px solid var(--color-border)",
background: "transparent",
color: "var(--color-foreground-muted)",
fontSize: "0.75rem",
cursor: "pointer",
fontFamily: "inherit",
}}
>
<CheckCheck style={{ width: 12, height: 12 }} />
Tout marquer comme lu
</button>
)}
</div>
{/* Filter pills */}
<div style={{ display: "flex", gap: "0.375rem", padding: "0.75rem 1.25rem", borderBottom: "1px solid var(--color-border)", overflowX: "auto", flexWrap: "nowrap" }}>
{(["all", "unread", "message", "alert", "mention", "invite", "system"] as FilterType[]).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
style={{
padding: "0.375rem 0.75rem",
borderRadius: "999px",
border: filter === f ? "1px solid var(--color-accent)" : "1px solid var(--color-border)",
background: filter === f ? "color-mix(in srgb, var(--color-accent) 10%, transparent)" : "transparent",
color: filter === f ? "var(--color-accent)" : "var(--color-foreground-muted)",
fontSize: "0.75rem",
fontWeight: filter === f ? 600 : 400,
cursor: "pointer",
whiteSpace: "nowrap",
fontFamily: "inherit",
}}
>
{filterLabels[f]}
</button>
))}
</div>
{/* Notification list */}
<div style={{ maxHeight: "450px", overflowY: "auto" }}>
<AnimatePresence initial={false}>
{filtered.map((notif) => {
const TypeIcon = typeIcons[notif.type];
const color = typeColors[notif.type];
return (
<motion.div
key={notif.id}
layout
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: EASE }}
style={{ overflow: "hidden" }}
>
<div
style={{
display: "flex",
gap: "0.75rem",
padding: "1rem 1.25rem",
borderBottom: "1px solid var(--color-border)",
background: notif.isRead ? "transparent" : "color-mix(in srgb, var(--color-accent) 3%, transparent)",
alignItems: "flex-start",
}}
>
{/* Unread dot + icon */}
<div style={{ position: "relative", flexShrink: 0 }}>
{notif.senderInitials ? (
<div style={{ width: "36px", height: "36px", borderRadius: "50%", background: color, display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.6875rem", fontWeight: 700, color: "#fff" }}>
{notif.senderInitials}
</div>
) : (
<div style={{ width: "36px", height: "36px", borderRadius: "50%", background: `color-mix(in srgb, ${color} 12%, transparent)`, display: "flex", alignItems: "center", justifyContent: "center" }}>
<TypeIcon style={{ width: 16, height: 16, color }} />
</div>
)}
{!notif.isRead && (
<div style={{ position: "absolute", top: "-2px", right: "-2px", width: "10px", height: "10px", borderRadius: "50%", background: "var(--color-accent)", border: "2px solid var(--color-background-card)" }} />
)}
</div>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.375rem", marginBottom: "0.125rem" }}>
<span style={{ fontSize: "0.8125rem", fontWeight: notif.isRead ? 500 : 700, color: "var(--color-foreground)" }}>
{notif.title}
</span>
</div>
<p style={{ fontSize: "0.75rem", color: "var(--color-foreground-muted)", margin: 0, lineHeight: 1.5 }}>
{notif.message}
</p>
<span style={{ fontSize: "0.6875rem", color: "var(--color-foreground-light)", marginTop: "0.25rem", display: "block" }}>
{notif.timestamp}
</span>
</div>
{/* Actions */}
<div style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}>
{!notif.isRead && (
<button
onClick={() => markAsRead(notif.id)}
title="Marquer comme lu"
style={{
width: "28px",
height: "28px",
borderRadius: "6px",
border: "none",
background: "transparent",
color: "var(--color-foreground-light)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Check style={{ width: 14, height: 14 }} />
</button>
)}
<button
onClick={() => deleteNotification(notif.id)}
title="Supprimer"
style={{
width: "28px",
height: "28px",
borderRadius: "6px",
border: "none",
background: "transparent",
color: "var(--color-foreground-light)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Trash2 style={{ width: 14, height: 14 }} />
</button>
</div>
</div>
</motion.div>
);
})}
</AnimatePresence>
{filtered.length === 0 && (
<div style={{ padding: "3rem", textAlign: "center", color: "var(--color-foreground-muted)", fontSize: "0.875rem" }}>
Aucune notification a afficher.
</div>
)}
</div>
</motion.div>
</div>
</section>
);
}