Retour au catalogue

Notification Activity Feed

Fil d'activite en temps reel avec avatars, timestamps relatifs, types d'evenements et animations d'apparition.

notificationmedium Both Responsive a11y
minimalcorporatesaasuniversalstacked
Theme
"use client";

import { useState, useCallback, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { MessageSquare, GitCommit, UserPlus, FileText, Star, Upload, Bell, RefreshCw } from "lucide-react";
import React from "react";

type ActivityType = "comment" | "commit" | "member" | "document" | "review" | "deploy";

interface ActivityEvent {
  id: string;
  type: ActivityType;
  author: string;
  authorInitials: string;
  authorColor?: string;
  action: string;
  target?: string;
  timestamp: string;
  isNew?: boolean;
}

interface NotificationActivityFeedProps {
  title?: string;
  subtitle?: string;
  badge?: string;
  events?: ActivityEvent[];
}

const EASE = [0.16, 1, 0.3, 1] as const;

const typeIcons: Record<ActivityType, React.ElementType> = {
  comment: MessageSquare,
  commit: GitCommit,
  member: UserPlus,
  document: FileText,
  review: Star,
  deploy: Upload,
};

const typeColors: Record<ActivityType, string> = {
  comment: "#3b82f6",
  commit: "#a855f7",
  member: "#22c55e",
  document: "#f59e0b",
  review: "#ec4899",
  deploy: "#06b6d4",
};

export default function NotificationActivityFeed({
  title,
  subtitle,
  badge,
  events,
}: NotificationActivityFeedProps) {
  const resolvedTitle = title ?? "Activite recente";
  const resolvedSubtitle = subtitle ?? "Suivez les dernieres actions de votre equipe en temps reel.";
  const resolvedBadge = badge ?? "Activite";

  const initialEvents: ActivityEvent[] = events ?? [
    { id: "a1", type: "commit", author: "Marie D.", authorInitials: "MD", authorColor: "#a855f7", action: "a pousse un commit sur", target: "feat/nouveau-dashboard", timestamp: "Il y a 2 min", isNew: true },
    { id: "a2", type: "comment", author: "Jean M.", authorInitials: "JM", authorColor: "#3b82f6", action: "a commente sur", target: "Revue de sprint #12", timestamp: "Il y a 5 min", isNew: true },
    { id: "a3", type: "member", author: "Sophie B.", authorInitials: "SB", authorColor: "#22c55e", action: "a rejoint l'equipe", target: "Engineering", timestamp: "Il y a 15 min" },
    { id: "a4", type: "document", author: "Pierre L.", authorInitials: "PL", authorColor: "#f59e0b", action: "a modifie", target: "Specification API v3", timestamp: "Il y a 32 min" },
    { id: "a5", type: "review", author: "Claire R.", authorInitials: "CR", authorColor: "#ec4899", action: "a approuve la PR", target: "#247 - Refacto auth", timestamp: "Il y a 1h" },
    { id: "a6", type: "deploy", author: "Thomas G.", authorInitials: "TG", authorColor: "#06b6d4", action: "a deploye en production", target: "v4.2.1", timestamp: "Il y a 2h" },
  ];

  const [feedEvents, setFeedEvents] = useState<ActivityEvent[]>(initialEvents);
  const counterRef = useRef(0);

  const newEventTemplates: Omit<ActivityEvent, "id">[] = [
    { type: "comment", author: "Lea V.", authorInitials: "LV", authorColor: "#3b82f6", action: "a repondu a", target: "Discussion architecture", timestamp: "A l'instant", isNew: true },
    { type: "commit", author: "Hugo F.", authorInitials: "HF", authorColor: "#a855f7", action: "a fusionne", target: "fix/correction-login", timestamp: "A l'instant", isNew: true },
    { type: "deploy", author: "Emilie P.", authorInitials: "EP", authorColor: "#06b6d4", action: "a deploye en staging", target: "v4.3.0-beta", timestamp: "A l'instant", isNew: true },
  ];

  const addNewEvent = useCallback(() => {
    const template = newEventTemplates[counterRef.current % newEventTemplates.length];
    counterRef.current++;
    const newEvent: ActivityEvent = {
      ...template,
      id: `new-${counterRef.current}`,
    };
    setFeedEvents((prev) => [newEvent, ...prev].slice(0, 8));
  }, [newEventTemplates]);

  return (
    <section
      style={{
        padding: "var(--section-padding-y, 6rem) 0",
        background: "var(--color-background)",
      }}
    >
      <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" }}>
            <Bell 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" }}>
              {resolvedBadge}
            </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>

        {/* Feed card */}
        <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",
          }}
        >
          {/* Header */}
          <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "1rem 1.25rem", borderBottom: "1px solid var(--color-border)" }}>
            <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
              <span style={{ fontSize: "0.875rem", fontWeight: 600, color: "var(--color-foreground)" }}>
                Fil d&apos;activite
              </span>
              <span style={{ fontSize: "0.6875rem", padding: "0.125rem 0.5rem", borderRadius: "999px", background: "var(--color-accent)", color: "var(--color-background)", fontWeight: 700 }}>
                {feedEvents.filter((e) => e.isNew).length}
              </span>
            </div>
            <motion.button
              whileHover={{ scale: 1.05 }}
              whileTap={{ scale: 0.95 }}
              onClick={addNewEvent}
              style={{
                display: "flex",
                alignItems: "center",
                gap: "0.375rem",
                padding: "0.375rem 0.75rem",
                borderRadius: "8px",
                border: "1px solid var(--color-border)",
                background: "transparent",
                color: "var(--color-foreground-muted)",
                fontSize: "0.75rem",
                cursor: "pointer",
                fontFamily: "inherit",
              }}
            >
              <RefreshCw style={{ width: 12, height: 12 }} />
              Simuler
            </motion.button>
          </div>

          {/* Events */}
          <div style={{ maxHeight: "480px", overflowY: "auto" }}>
            <AnimatePresence initial={false}>
              {feedEvents.map((event) => {
                const EventIcon = typeIcons[event.type];
                const eventColor = typeColors[event.type];

                return (
                  <motion.div
                    key={event.id}
                    layout
                    initial={{ opacity: 0, height: 0 }}
                    animate={{ opacity: 1, height: "auto" }}
                    exit={{ opacity: 0, height: 0 }}
                    transition={{ duration: 0.35, ease: EASE }}
                    style={{ overflow: "hidden" }}
                  >
                    <div
                      style={{
                        display: "flex",
                        gap: "0.75rem",
                        padding: "0.875rem 1.25rem",
                        borderBottom: "1px solid var(--color-border)",
                        background: event.isNew
                          ? "color-mix(in srgb, var(--color-accent) 3%, transparent)"
                          : "transparent",
                        transition: "background 0.3s",
                      }}
                    >
                      {/* Avatar */}
                      <div
                        style={{
                          width: "36px",
                          height: "36px",
                          borderRadius: "50%",
                          background: event.authorColor ?? "var(--color-accent)",
                          display: "flex",
                          alignItems: "center",
                          justifyContent: "center",
                          flexShrink: 0,
                          fontSize: "0.6875rem",
                          fontWeight: 700,
                          color: "#fff",
                        }}
                      >
                        {event.authorInitials}
                      </div>

                      {/* Content */}
                      <div style={{ flex: 1, minWidth: 0 }}>
                        <div style={{ fontSize: "0.8125rem", color: "var(--color-foreground)", lineHeight: 1.5 }}>
                          <span style={{ fontWeight: 600 }}>{event.author}</span>{" "}
                          <span style={{ color: "var(--color-foreground-muted)" }}>{event.action}</span>{" "}
                          {event.target && (
                            <span style={{ fontWeight: 500, color: "var(--color-accent)" }}>{event.target}</span>
                          )}
                        </div>
                        <div style={{ fontSize: "0.6875rem", color: "var(--color-foreground-light)", marginTop: "0.25rem" }}>
                          {event.timestamp}
                        </div>
                      </div>

                      {/* Type icon */}
                      <div
                        style={{
                          width: "28px",
                          height: "28px",
                          borderRadius: "6px",
                          background: `color-mix(in srgb, ${eventColor} 10%, transparent)`,
                          display: "flex",
                          alignItems: "center",
                          justifyContent: "center",
                          flexShrink: 0,
                        }}
                      >
                        <EventIcon style={{ width: 14, height: 14, color: eventColor }} />
                      </div>
                    </div>
                  </motion.div>
                );
              })}
            </AnimatePresence>
          </div>
        </motion.div>
      </div>
    </section>
  );
}

Avis

Notification Activity Feed — React Notification Section — Incubator