import { useCallback, useEffect, useState } from "react"; import { X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useNavigate } from "react-router-dom"; import { useClientNotifications } from "@/contexts/ClientNotificationContext"; import { resolveNotificationLink } from "@/components/generic/notificationDisplay"; import { getTierColor, getContrastText } from "@/utils/tierColors"; import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouselDialog"; const ROTATE_INTERVAL_MS = 6000; // resolveNotificationLink already gives explicit link_url (the admin "On // Open" section) precedence over the type-based fallbacks, for broadcasts // sent before that field existed. function resolveClickAction(stickyAnnouncement) { return resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data); } export default function StickyAnnouncementBar() { const navigate = useNavigate(); const { stickyAnnouncements, bannerImage, markSeen } = useClientNotifications(); const [activeIndex, setActiveIndex] = useState(0); const [detailsOpen, setDetailsOpen] = useState(false); const count = stickyAnnouncements.length; // Derived rather than clamped via effect — safe the instant the array // shrinks (e.g. after a dismiss), no render with a stale out-of-range index. const safeIndex = count ? Math.min(activeIndex, count - 1) : 0; // Auto-rotate through active announcements while more than one is live. useEffect(() => { if (count <= 1) return; const id = setInterval(() => { setActiveIndex((i) => (i + 1) % count); }, ROTATE_INTERVAL_MS); return () => clearInterval(id); }, [count]); const current = stickyAnnouncements[safeIndex]; const onDismiss = useCallback(async () => { if (!current) return; await markSeen(current.notification_id); }, [current, markSeen]); // Opening the dialog must NOT mark it seen — markSeen removes the row from // stickyAnnouncements, which would unmount this component (dialog included) // before it ever shows. const onClickBanner = useCallback(() => { if (!current) return; setDetailsOpen(true); }, [current]); // Closing the details dialog (X, Escape, overlay click — any reason) // dismisses whichever announcement was being viewed at the time. This is // the only dismiss path when multiple are active (no per-item X on the bar // itself — see the count > 1 branch below). const onDialogOpenChange = useCallback((open) => { setDetailsOpen(open); if (!open) void onDismiss(); }, [onDismiss]); if (!current) return null; const swatch = getTierColor(current.color || "indigo").swatch; const textColor = getContrastText(swatch, current.color || "indigo"); const clickAction = resolveClickAction(current); return ( <>

{current.title || "Announcement"}

{clickAction && ( )}
{count > 1 && (
{stickyAnnouncements.map((a, i) => (
)} {/* Dismiss-X only makes sense for a single active announcement — with multiple, the dialog's own close button (shadcn Dialog) is the way to close/step away, no per-item dismiss from the bar. */} {count <= 1 && (
{ e.preventDefault(); e.stopPropagation(); void onDismiss(); }} onKeyDown={(e) => { if (e.key !== "Enter" && e.key !== " ") return; e.preventDefault(); e.stopPropagation(); void onDismiss(); }} aria-label="Dismiss sticky announcement" title="Dismiss" className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity" style={{ color: textColor }} >
)}
); }