diff --git a/package.json b/package.json
index a7db7468..0cc9ac83 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
"allof-merge": "^0.6.6",
"autoprefixer": "^10.4.7",
"clsx": "^1.2.0",
+ "crypto-js": "^4.2.0",
"ejs": "^3.1.9",
"focus-visible": "^5.2.0",
"framer-motion": "10.12.9",
diff --git a/src/components/announcement-banner/AnnouncementBanner.jsx b/src/components/announcement-banner/AnnouncementBanner.jsx
index a958b09b..1ae0e96f 100644
--- a/src/components/announcement-banner/AnnouncementBanner.jsx
+++ b/src/components/announcement-banner/AnnouncementBanner.jsx
@@ -1,11 +1,7 @@
-import { useEffect, useRef } from 'react'
import clsx from 'clsx'
import Link from 'next/link'
-import {
- announcement,
- useAnnouncements,
-} from '@/components/announcement-banner/AnnouncementBannerProvider'
+import { useAnnouncements } from '@/components/announcement-banner/AnnouncementBannerProvider'
import { useCustomQueryURL } from '@/hooks/useCustomQueryURL'
function ArrowRightIcon(props) {
@@ -38,39 +34,11 @@ function CloseIcon(props) {
)
}
-export function AnnouncementBanner() {
- let { isVisible, close, reportHeight } = useAnnouncements()
- let announcementLink = useCustomQueryURL(announcement.link || '')
- let bannerRef = useRef(null)
-
- useEffect(() => {
- if (!isVisible) {
- reportHeight(0)
- return
- }
-
- function updateHeight() {
- if (bannerRef.current) {
- reportHeight(bannerRef.current.offsetHeight || 0)
- } else {
- reportHeight(0)
- }
- }
-
- updateHeight()
- window.addEventListener('resize', updateHeight)
- return () => {
- window.removeEventListener('resize', updateHeight)
- }
- }, [isVisible, reportHeight])
-
- if (!isVisible) {
- return null
- }
+function AnnouncementItem({ announcement, onClose }) {
+ const announcementLink = useCustomQueryURL(announcement.link || '')
return (
onClose(announcement.hash)}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-black transition hover:bg-black/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-black"
aria-label="Dismiss announcement"
>
@@ -110,3 +78,27 @@ export function AnnouncementBanner() {
)
}
+
+export function AnnouncementBanner() {
+ const { announcements, closeAnnouncement } = useAnnouncements()
+
+ if (!announcements) {
+ return null
+ }
+
+ const openAnnouncements = announcements.filter((a) => a.isOpen)
+
+ if (openAnnouncements.length === 0) {
+ return null
+ }
+
+ // Show the first open announcement
+ const announcement = openAnnouncements[0]
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/announcement-banner/AnnouncementBannerProvider.jsx b/src/components/announcement-banner/AnnouncementBannerProvider.jsx
index 94a860cf..51f17ac0 100644
--- a/src/components/announcement-banner/AnnouncementBannerProvider.jsx
+++ b/src/components/announcement-banner/AnnouncementBannerProvider.jsx
@@ -1,87 +1,122 @@
+import md5 from 'crypto-js/md5'
import {
createContext,
useCallback,
useContext,
useEffect,
- useMemo,
+ useRef,
useState,
} from 'react'
-import { useLocalStorage } from '@/hooks/useLocalStorage'
-
-const BANNER_ENABLED = true
-
-export const announcement = {
- tag: 'New',
- text: 'Simplified IdP Integration',
- link: '/selfhosted/identity-providers',
- linkText: 'Learn More',
- linkAlt: 'Learn more about the embedded Identity Provider powered by DEX for self-hosted installations',
- isExternal: false,
- closeable: true,
-}
+const ANNOUNCEMENTS_URL =
+ 'https://raw.githubusercontent.com/netbirdio/dashboard/main/announcements.json'
+const STORAGE_KEY = 'netbird-announcements'
+const CACHE_DURATION_MS = 30 * 60 * 1000
+const BANNER_HEIGHT = 33
const AnnouncementContext = createContext({
- close: () => {},
- isVisible: false,
bannerHeight: 0,
- reportHeight: () => {},
+ announcements: undefined,
+ closeAnnouncement: () => {},
})
-export function AnnouncementBannerProvider({ children }) {
- let [mounted, setMounted] = useState(false)
- let [closedAnnouncement, setClosedAnnouncement] = useLocalStorage(
- 'netbird-announcement',
- undefined
- )
- let announcementId = announcement.text
- let [bannerHeight, setBannerHeight] = useState(0)
+const getAnnouncements = async () => {
+ try {
+ let stored = null
+ try {
+ const data = localStorage.getItem(STORAGE_KEY)
+ stored = data ? JSON.parse(data) : null
+ } catch {}
- let close = () => {
- setClosedAnnouncement(announcementId)
- }
-
- let isActive = useMemo(() => {
- if (!mounted) return false
- if (!BANNER_ENABLED) return false
- return closedAnnouncement !== announcementId
- }, [announcementId, closedAnnouncement, mounted])
+ const now = Date.now()
- let isVisible = isActive // Always visible when active, regardless of scroll
+ let raw
- let reportHeight = useCallback((height) => {
- setBannerHeight(height)
- }, [])
+ if (stored && now - stored.timestamp < CACHE_DURATION_MS) {
+ raw = stored.announcements
+ } else {
+ const response = await fetch(ANNOUNCEMENTS_URL)
+ if (!response.ok) return []
- useEffect(() => {
- setMounted(true)
- return () => setMounted(false)
- }, [])
+ raw = await response.json()
+ }
- // Removed scroll-based hiding to make banner always sticky
- // useEffect(() => {
- // if (typeof window === 'undefined') {
- // return
- // }
+ // Filter announcements - show all for docs site (not cloud-specific)
+ const filtered = raw.filter((a) => !a.isCloudOnly)
+ const hashes = new Set(filtered.map((a) => md5(a.text).toString()))
+ const closed = (stored?.closedAnnouncements ?? []).filter((h) =>
+ hashes.has(h)
+ )
+
+ try {
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({
+ timestamp: now,
+ announcements: raw,
+ closedAnnouncements: closed,
+ })
+ )
+ } catch {}
+
+ return filtered.map((a) => {
+ const hash = md5(a.text).toString()
+ return { ...a, hash, isOpen: !closed.includes(hash) }
+ })
+ } catch {
+ return []
+ }
+}
- // function handleScroll() {
- // setIsHiddenByScroll(window.scrollY > 30)
- // }
+const saveAnnouncements = (closedAnnouncements) => {
+ try {
+ const data = localStorage.getItem(STORAGE_KEY)
+ const stored = data ? JSON.parse(data) : null
+ if (stored) {
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({ ...stored, closedAnnouncements })
+ )
+ }
+ } catch {}
+}
- // handleScroll()
- // window.addEventListener('scroll', handleScroll, { passive: true })
- // return () => window.removeEventListener('scroll', handleScroll)
- // }, [])
+export function AnnouncementBannerProvider({ children }) {
+ const [announcements, setAnnouncements] = useState(undefined)
+ const fetchingRef = useRef(false)
useEffect(() => {
- if (!isVisible && bannerHeight !== 0) {
- setBannerHeight(0)
- }
- }, [bannerHeight, isVisible])
+ if (announcements !== undefined || fetchingRef.current) return
+ fetchingRef.current = true
+ getAnnouncements()
+ .then((a) => setAnnouncements(a))
+ .finally(() => (fetchingRef.current = false))
+ }, [announcements])
+
+ const closeAnnouncement = useCallback(
+ (hash) => {
+ if (!announcements) return
+ const updated = announcements.map((a) =>
+ a.hash === hash ? { ...a, isOpen: false } : a
+ )
+ const closedAnnouncements = updated
+ .filter((a) => !a.isOpen)
+ .map((a) => a.hash)
+ saveAnnouncements(closedAnnouncements)
+ setAnnouncements(updated)
+ },
+ [announcements]
+ )
+
+ const bannerHeight = announcements?.some((a) => a.isOpen) ? BANNER_HEIGHT : 0
return (
{children}
@@ -90,4 +125,4 @@ export function AnnouncementBannerProvider({ children }) {
export function useAnnouncements() {
return useContext(AnnouncementContext)
-}
+}
\ No newline at end of file