refactor: made announcements time-bound and added audiences

- Added startTime and endTime properties to announcements for time-bound visibility.
- Introduced audience targeting to control who sees announcements (all, users, web_visitors).
- Updated related components and hooks to support new announcement features.
- Removed unused state tracking for dismissed announcements to streamline functionality.
This commit is contained in:
Eric Lammertsma 2026-02-19 18:34:49 -05:00
parent 2c68e4ad69
commit f777142017
7 changed files with 211 additions and 263 deletions

View file

@ -3,18 +3,15 @@
import {
Bell,
BellOff,
CheckCheck,
ExternalLink,
Filter,
Info,
type Megaphone,
Rocket,
Wrench,
X,
Zap,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@ -25,16 +22,6 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { AnnouncementCategory } from "@/contracts/types/announcement.types";
import { type AnnouncementWithState, useAnnouncements } from "@/hooks/use-announcements";
import { formatRelativeDate } from "@/lib/format-date";
@ -84,22 +71,14 @@ const categoryConfig: Record<
function AnnouncementCard({
announcement,
onMarkRead,
onDismiss,
}: {
announcement: AnnouncementWithState;
onMarkRead: (id: string) => void;
onDismiss: (id: string) => void;
}) {
const config = categoryConfig[announcement.category];
const config = categoryConfig[announcement.category] ?? categoryConfig.info;
const Icon = config.icon;
return (
<Card
className={`group relative transition-all duration-200 hover:shadow-md ${
!announcement.isRead ? "border-l-4 border-l-primary bg-primary/2" : ""
}`}
>
<Card className="group relative transition-all duration-200 hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 min-w-0">
@ -120,47 +99,12 @@ function AnnouncementCard({
Important
</Badge>
)}
{!announcement.isRead && (
<span className="h-2 w-2 rounded-full bg-primary shrink-0" />
)}
</div>
<CardDescription className="mt-1 text-xs">
{formatRelativeDate(announcement.date)}
</CardDescription>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
{!announcement.isRead && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onMarkRead(announcement.id)}
>
<CheckCheck className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Mark as read</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => onDismiss(announcement.id)}
>
<X className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Dismiss</TooltipContent>
</Tooltip>
</div>
</div>
</CardHeader>
@ -174,7 +118,6 @@ function AnnouncementCard({
<Link
href={announcement.link.url}
target={announcement.link.url.startsWith("http") ? "_blank" : undefined}
onClick={() => onMarkRead(announcement.id)}
>
{announcement.link.label}
<ExternalLink className="h-3 w-3" />
@ -190,23 +133,15 @@ function AnnouncementCard({
// Empty state
// ---------------------------------------------------------------------------
function EmptyState({ hasFilters }: { hasFilters: boolean }) {
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
{hasFilters ? (
<Filter className="h-7 w-7 text-muted-foreground" />
) : (
<BellOff className="h-7 w-7 text-muted-foreground" />
)}
<BellOff className="h-7 w-7 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold">
{hasFilters ? "No matching announcements" : "No announcements"}
</h3>
<h3 className="text-lg font-semibold">No announcements</h3>
<p className="mt-1 text-sm text-muted-foreground max-w-sm">
{hasFilters
? "Try adjusting your filters to see more announcements."
: "You're all caught up! New announcements will appear here."}
You're all caught up! New announcements will appear here.
</p>
</div>
);
@ -217,134 +152,41 @@ function EmptyState({ hasFilters }: { hasFilters: boolean }) {
// ---------------------------------------------------------------------------
export default function AnnouncementsPage() {
const [activeCategories, setActiveCategories] = useState<AnnouncementCategory[]>([]);
const [showOnlyUnread, setShowOnlyUnread] = useState(false);
const { announcements, markAllRead } = useAnnouncements();
const { announcements, unreadCount, markRead, markAllRead, dismiss } = useAnnouncements({
includeDismissed: false,
});
// Apply local filters
const filteredAnnouncements = announcements.filter((a) => {
if (activeCategories.length > 0 && !activeCategories.includes(a.category)) return false;
if (showOnlyUnread && a.isRead) return false;
return true;
});
const hasActiveFilters = activeCategories.length > 0 || showOnlyUnread;
const toggleCategory = (cat: AnnouncementCategory) => {
setActiveCategories((prev) =>
prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]
);
};
// Auto-mark all visible announcements as read when the page is opened
useEffect(() => {
markAllRead();
}, [markAllRead]);
return (
<TooltipProvider delayDuration={0}>
<div className="min-h-screen relative pt-20">
{/* Header */}
<div className="border-b border-border/50">
<div className="max-w-5xl mx-auto relative">
<div className="p-6">
<h1 className="text-4xl font-bold tracking-tight bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent">
Announcements
</h1>
</div>
<div className="min-h-screen relative pt-20">
{/* Header */}
<div className="border-b border-border/50">
<div className="max-w-5xl mx-auto relative">
<div className="p-6">
<h1 className="text-4xl font-bold tracking-tight bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent">
Announcements
</h1>
</div>
</div>
{/* Content */}
<div className="max-w-3xl mx-auto px-6 lg:px-10 pt-8 pb-20">
{/* Toolbar */}
<div className="flex items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-2">
{/* Category filter dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<Filter className="h-3.5 w-3.5" />
Filter
{activeCategories.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
{activeCategories.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuLabel>Categories</DropdownMenuLabel>
<DropdownMenuSeparator />
{(Object.keys(categoryConfig) as AnnouncementCategory[]).map((cat) => {
const cfg = categoryConfig[cat];
const CatIcon = cfg.icon;
return (
<DropdownMenuCheckboxItem
key={cat}
checked={activeCategories.includes(cat)}
onCheckedChange={() => toggleCategory(cat)}
>
<CatIcon className={`mr-2 h-3.5 w-3.5 ${cfg.color}`} />
{cfg.label}
</DropdownMenuCheckboxItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={showOnlyUnread}
onCheckedChange={() => setShowOnlyUnread((v) => !v)}
>
<Bell className="mr-2 h-3.5 w-3.5" />
Unread only
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground"
onClick={() => {
setActiveCategories([]);
setShowOnlyUnread(false);
}}
>
Clear filters
</Button>
)}
</div>
{/* Mark all read */}
{unreadCount > 0 && (
<Button variant="ghost" size="sm" className="gap-1.5 text-xs" onClick={markAllRead}>
<CheckCheck className="h-3.5 w-3.5" />
Mark all as read
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
{unreadCount}
</Badge>
</Button>
)}
</div>
<Separator className="mb-6" />
{/* Announcement list */}
{filteredAnnouncements.length === 0 ? (
<EmptyState hasFilters={hasActiveFilters} />
) : (
<div className="flex flex-col gap-4">
{filteredAnnouncements.map((announcement) => (
<AnnouncementCard
key={announcement.id}
announcement={announcement}
onMarkRead={markRead}
onDismiss={dismiss}
/>
))}
</div>
)}
</div>
</div>
</TooltipProvider>
{/* Content */}
<div className="max-w-3xl mx-auto px-6 lg:px-10 pt-8 pb-20">
{announcements.length === 0 ? (
<EmptyState />
) : (
<div className="flex flex-col gap-4">
{announcements.map((announcement) => (
<AnnouncementCard
key={announcement.id}
announcement={announcement}
/>
))}
</div>
)}
</div>
</div>
);
}