feat(announcements): enhance AnnouncementCard and add spotlight feature

- Added image support to the AnnouncementCard component for improved visual presentation of announcements.
- Introduced a spotlight feature in the announcement types to allow critical announcements to be displayed in a blocking dialog until acknowledged.
- Updated AnnouncementToastProvider to skip spotlight announcements to prevent duplicate notifications.
- Included a new AI automation announcement with an image in the announcements data for demonstration purposes.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-31 18:51:49 -07:00
parent 0ae30839aa
commit ec0342faa2
7 changed files with 154 additions and 2 deletions

View file

@ -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 (
<Card className="group relative transition-all duration-200 hover:shadow-md">
<Card className="group relative overflow-hidden transition-all duration-200 hover:shadow-md">
{announcement.image && (
<div className="relative aspect-video w-full overflow-hidden border-b bg-muted">
<Image
src={announcement.image.src}
alt={announcement.image.alt}
fill
sizes="(max-width: 768px) 95vw, 600px"
className="object-cover"
/>
</div>
)}
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 min-w-0">

View file

@ -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<Set<string>>(() => 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 (
<Dialog open={ready} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md gap-0 overflow-hidden p-0">
{current.image && (
<div className="relative aspect-video w-full border-b bg-muted">
<Image
src={current.image.src}
alt={current.image.alt}
fill
sizes="(max-width: 768px) 95vw, 448px"
className="object-cover"
priority
/>
</div>
)}
<div className="flex flex-col gap-3 p-6">
<DialogTitle className="text-xl">{current.title}</DialogTitle>
<DialogDescription className="text-sm leading-relaxed text-muted-foreground">
{current.description}
</DialogDescription>
<DialogFooter className="mt-2">
{current.link && (
<Button variant="outline" asChild className="gap-1.5" onClick={handleAcknowledge}>
<Link
href={current.link.url}
target={current.link.url.startsWith("http") ? "_blank" : undefined}
>
{current.link.label}
<ExternalLink className="h-3.5 w-3.5" />
</Link>
</Button>
)}
<Button onClick={handleAcknowledge}>Got it</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -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++) {

View file

@ -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
/>
<AnnouncementsDialog />
<AnnouncementSpotlight />
{/* Agent action log + revert dialog */}
<ActionLogDialog />