2026-02-12 16:12:45 -08:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { Megaphone } from "lucide-react";
|
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import type { Announcement } from "@/contracts/types/announcement.types";
|
|
|
|
|
import { announcements } from "@/lib/announcements/announcements-data";
|
|
|
|
|
import {
|
|
|
|
|
isAnnouncementToasted,
|
|
|
|
|
markAnnouncementRead,
|
|
|
|
|
markAnnouncementToasted,
|
|
|
|
|
} from "@/lib/announcements/announcements-storage";
|
2026-02-19 18:34:49 -05:00
|
|
|
import { getActiveAnnouncements } from "@/lib/announcements/announcements-utils";
|
|
|
|
|
import { isAuthenticated } from "@/lib/auth-utils";
|
2026-02-12 16:12:45 -08:00
|
|
|
|
|
|
|
|
/** Map announcement category to the Sonner toast method */
|
|
|
|
|
const categoryToVariant: Record<string, "info" | "warning" | "success"> = {
|
|
|
|
|
update: "info",
|
|
|
|
|
feature: "success",
|
|
|
|
|
maintenance: "warning",
|
|
|
|
|
info: "info",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Show a single announcement as a toast */
|
|
|
|
|
function showAnnouncementToast(announcement: Announcement) {
|
|
|
|
|
const variant = categoryToVariant[announcement.category] ?? "info";
|
|
|
|
|
|
|
|
|
|
const options = {
|
|
|
|
|
description: truncateText(announcement.description, 120),
|
|
|
|
|
duration: 12000,
|
|
|
|
|
icon: <Megaphone className="h-4 w-4" />,
|
|
|
|
|
action: announcement.link
|
|
|
|
|
? {
|
|
|
|
|
label: announcement.link.label,
|
|
|
|
|
onClick: () => {
|
|
|
|
|
if (announcement.link?.url.startsWith("http")) {
|
2026-03-25 12:32:24 +08:00
|
|
|
window.open(announcement.link.url, "_blank", "noopener,noreferrer");
|
2026-02-12 16:12:45 -08:00
|
|
|
} else if (announcement.link?.url) {
|
|
|
|
|
window.location.href = announcement.link.url;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
: undefined,
|
|
|
|
|
onDismiss: () => {
|
|
|
|
|
markAnnouncementRead(announcement.id);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
toast[variant](announcement.title, options);
|
|
|
|
|
markAnnouncementToasted(announcement.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Global provider that shows important announcements as toast notifications.
|
|
|
|
|
*
|
|
|
|
|
* Place this component once at the root layout level (alongside <Toaster />).
|
2026-02-19 18:34:49 -05:00
|
|
|
* On mount, it checks for active, audience-matched, unread important
|
|
|
|
|
* announcements that haven't been shown as toasts yet, and displays them
|
|
|
|
|
* with a short stagger delay.
|
2026-02-12 16:12:45 -08:00
|
|
|
*/
|
|
|
|
|
export function AnnouncementToastProvider() {
|
|
|
|
|
const hasChecked = useRef(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (hasChecked.current) return;
|
|
|
|
|
hasChecked.current = true;
|
|
|
|
|
|
2026-04-15 20:07:20 +08:00
|
|
|
const staggerTimers: ReturnType<typeof setTimeout>[] = [];
|
|
|
|
|
|
|
|
|
|
const outerTimer = setTimeout(() => {
|
2026-02-19 18:34:49 -05:00
|
|
|
const authed = isAuthenticated();
|
|
|
|
|
const active = getActiveAnnouncements(announcements, authed);
|
|
|
|
|
const importantUntoasted = active.filter(
|
2026-02-20 22:44:56 -08:00
|
|
|
(a) => a.isImportant && !isAnnouncementToasted(a.id)
|
2026-02-12 16:12:45 -08:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < importantUntoasted.length; i++) {
|
|
|
|
|
const announcement = importantUntoasted[i];
|
2026-04-15 17:02:00 -07:00
|
|
|
staggerTimers.push(setTimeout(() => showAnnouncementToast(announcement), i * 800));
|
2026-02-12 16:12:45 -08:00
|
|
|
}
|
2026-02-19 18:34:49 -05:00
|
|
|
}, 1500);
|
2026-02-12 16:12:45 -08:00
|
|
|
|
2026-04-15 20:07:20 +08:00
|
|
|
return () => {
|
|
|
|
|
clearTimeout(outerTimer);
|
|
|
|
|
for (const id of staggerTimers) clearTimeout(id);
|
|
|
|
|
};
|
2026-02-12 16:12:45 -08:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Truncate text to a maximum length, adding ellipsis if needed */
|
|
|
|
|
function truncateText(text: string, maxLength: number): string {
|
|
|
|
|
if (text.length <= maxLength) return text;
|
|
|
|
|
return `${text.slice(0, maxLength).trimEnd()}...`;
|
|
|
|
|
}
|