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>
  );
}

Avis

Notification Inbox — React Notification Section — Incubator