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