diff --git a/surfsense_web/components/announcements/AnnouncementCard.tsx b/surfsense_web/components/announcements/AnnouncementCard.tsx index ea0288b43..83a0e09b8 100644 --- a/surfsense_web/components/announcements/AnnouncementCard.tsx +++ b/surfsense_web/components/announcements/AnnouncementCard.tsx @@ -1,6 +1,7 @@ "use client"; import { Bell, ExternalLink, Info, type LucideIcon, Rocket, Wrench, Zap } from "lucide-react"; +import Image from "next/image"; import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -49,7 +50,18 @@ export function AnnouncementCard({ announcement }: { announcement: AnnouncementW const Icon = config.icon; return ( - + + {announcement.image && ( +
+ {announcement.image.alt} +
+ )}
diff --git a/surfsense_web/components/announcements/AnnouncementSpotlight.tsx b/surfsense_web/components/announcements/AnnouncementSpotlight.tsx new file mode 100644 index 000000000..794a3c1cf --- /dev/null +++ b/surfsense_web/components/announcements/AnnouncementSpotlight.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { ExternalLink } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from "@/components/ui/dialog"; +import { useAnnouncements } from "@/hooks/use-announcements"; + +/** + * Proactively shows important "spotlight" announcements in a blocking dialog. + * + * Behaviour: + * - On load, the first active, audience-matched, unread spotlight announcement + * is shown automatically. + * - The user must explicitly acknowledge it ("Got it" or the CTA link), which + * marks it as read so it never shows again. + * - Closing via the X / Escape / outside-click only hides it for the current + * session; it reappears on the next load until the user marks it as seen. + */ +export function AnnouncementSpotlight() { + const { announcements, markRead } = useAnnouncements(); + const [sessionDismissed, setSessionDismissed] = useState>(() => new Set()); + const [ready, setReady] = useState(false); + + // Short delay so the spotlight doesn't flash during initial hydration/layout. + useEffect(() => { + const timer = setTimeout(() => setReady(true), 800); + return () => clearTimeout(timer); + }, []); + + const current = useMemo( + () => + announcements.find( + (a) => a.spotlight && a.isImportant && !a.isRead && !sessionDismissed.has(a.id) + ) ?? null, + [announcements, sessionDismissed] + ); + + if (!current) return null; + + const handleAcknowledge = () => { + markRead(current.id); + }; + + const handleOpenChange = (next: boolean) => { + if (!next) { + setSessionDismissed((prev) => { + const updated = new Set(prev); + updated.add(current.id); + return updated; + }); + } + }; + + return ( + + + {current.image && ( +
+ {current.image.alt} +
+ )} +
+ {current.title} + + {current.description} + + + {current.link && ( + + )} + + +
+
+
+ ); +} diff --git a/surfsense_web/components/announcements/AnnouncementToastProvider.tsx b/surfsense_web/components/announcements/AnnouncementToastProvider.tsx index 3e99f5c32..5578eba17 100644 --- a/surfsense_web/components/announcements/AnnouncementToastProvider.tsx +++ b/surfsense_web/components/announcements/AnnouncementToastProvider.tsx @@ -70,8 +70,10 @@ export function AnnouncementToastProvider() { const outerTimer = setTimeout(() => { const authed = isAuthenticated(); const active = getActiveAnnouncements(announcements, authed); + // Spotlight announcements are handled by the blocking spotlight dialog, + // so skip them here to avoid double-notifying the user. const importantUntoasted = active.filter( - (a) => a.isImportant && !isAnnouncementToasted(a.id) + (a) => a.isImportant && !a.spotlight && !isAnnouncementToasted(a.id) ); for (let i = 0; i < importantUntoasted.length; i++) { diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 4284e3da7..34fd15e3b 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -18,6 +18,7 @@ import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { ActionLogDialog } from "@/components/agent-action-log/action-log-dialog"; +import { AnnouncementSpotlight } from "@/components/announcements/AnnouncementSpotlight"; import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog"; import { AlertDialog, @@ -909,6 +910,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid /> + {/* Agent action log + revert dialog */} diff --git a/surfsense_web/contracts/types/announcement.types.ts b/surfsense_web/contracts/types/announcement.types.ts index 6c5206d9d..05cf14d21 100644 --- a/surfsense_web/contracts/types/announcement.types.ts +++ b/surfsense_web/contracts/types/announcement.types.ts @@ -35,6 +35,20 @@ export interface Announcement { audience: AnnouncementAudience; /** If true, the user will see a toast notification for this announcement */ isImportant: boolean; + /** + * If true, this announcement is shown in a blocking spotlight dialog that the + * user must explicitly acknowledge ("Got it"). Until acknowledged it keeps + * reappearing; once acknowledged it never shows again. Spotlight announcements + * are skipped by the lightweight toast provider to avoid double notifications. + */ + spotlight?: boolean; + /** Optional head/banner image shown at the top of the announcement */ + image?: { + /** Image source (public path or absolute URL) */ + src: string; + /** Accessible alt text */ + alt: string; + }; /** Optional CTA link */ link?: { label: string; diff --git a/surfsense_web/lib/announcements/announcements-data.ts b/surfsense_web/lib/announcements/announcements-data.ts index ce44ec539..e5f3f0fce 100644 --- a/surfsense_web/lib/announcements/announcements-data.ts +++ b/surfsense_web/lib/announcements/announcements-data.ts @@ -13,6 +13,27 @@ import type { Announcement } from "@/contracts/types/announcement.types"; * This file can be replaced with an API call in the future. */ export const announcements: Announcement[] = [ + { + id: "2026-05-31-ai-automations", + title: "Introducing AI Automations", + description: + "Turn prompts into hands-off AI agent workflows. Describe an automation in plain English and SurfSense builds it, run it on a schedule, or trigger it the moment a document lands in a folder. Automations work across Notion, Slack, Google Drive, Gmail, GitHub, Linear, Jira and more.", + category: "feature", + date: "2026-05-31T00:00:00Z", + startTime: "2026-05-31T00:00:00Z", + endTime: "2026-07-15T00:00:00Z", + audience: "users", + isImportant: true, + spotlight: true, + image: { + src: "/announcements/automations.png", + alt: "Connector tiles flowing into a central AI core that triggers scheduled and event-driven automations.", + }, + link: { + label: "See what's new", + url: "/changelog", + }, + }, { id: "announcement-1", title: "Introducing What's New", diff --git a/surfsense_web/public/announcements/automations.png b/surfsense_web/public/announcements/automations.png new file mode 100644 index 000000000..39eb35837 Binary files /dev/null and b/surfsense_web/public/announcements/automations.png differ