From 91496dfc987dc6a9389876f48c4e71e3282f25c8 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:17:04 +0530 Subject: [PATCH] feat(site-nav): add sticky /sunset announcement bar to the nav header --- surfsense_web/.env.example | 5 ++ .../homepage/top-announcement-bar.tsx | 87 +++++++++++++++++++ surfsense_web/components/site/site-nav.tsx | 2 + surfsense_web/components/site/site-shell.tsx | 3 +- surfsense_web/components/ui/icons.tsx | 2 + surfsense_web/lib/env-config.ts | 6 ++ 6 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 surfsense_web/components/homepage/top-announcement-bar.tsx diff --git a/surfsense_web/.env.example b/surfsense_web/.env.example index a6b72a1da5..6f0719eb89 100644 --- a/surfsense_web/.env.example +++ b/surfsense_web/.env.example @@ -76,6 +76,11 @@ NEXT_PUBLIC_GOOGLE_ADSENSE_SLOT_FREE_HUB_BEFORE_FAQ= NEXT_PUBLIC_GLOBAL_ANNOUNCEMENT_ENABLED=false NEXT_PUBLIC_GLOBAL_ANNOUNCEMENT_MESSAGE= +# ───────────────────────────────────────────────────────────────────────────── +# Sticky top announcement bar linking to /sunset. Set to "true" to show it. +# ───────────────────────────────────────────────────────────────────────────── +NEXT_PUBLIC_TOP_ANNOUNCEMENT_ENABLED=false + # ───────────────────────────────────────────────────────────────────────────── # Stripe Payment Link (Individual licence) # ───────────────────────────────────────────────────────────────────────────── diff --git a/surfsense_web/components/homepage/top-announcement-bar.tsx b/surfsense_web/components/homepage/top-announcement-bar.tsx new file mode 100644 index 0000000000..37fb387a8d --- /dev/null +++ b/surfsense_web/components/homepage/top-announcement-bar.tsx @@ -0,0 +1,87 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { Cancel01Icon, LinkSquare02Icon } from "@/components/ui/icons"; +import { TOP_ANNOUNCEMENT_ENABLED } from "@/lib/env-config"; + +// Versioned so a new announcement resurfaces even for visitors who dismissed +// an older one. Bump the version when the announcement changes. +const DISMISS_KEY = "surfsense:top-announcement-dismissed:v1"; + +/** + * Top banner pointing visitors at the /sunset announcement. + * + * Rendered as the first child of `SiteNav`'s `.ss-home-nav` header rather than + * as its own sticky element: that header is already `position: sticky`, so + * stacking the banner inside it makes the two scroll and stick together as + * one unit instead of each being independently sticky at `top: 0` and + * overlapping. + * + * Uses `--notice`, the blue defined alongside the rest of the palette in + * `app/(home)/home.css`, with a literal fallback so it also reads correctly + * on `(home)` routes that have not been moved onto that palette yet. + * + * Toggled with `NEXT_PUBLIC_TOP_ANNOUNCEMENT_ENABLED` like `GlobalAnnouncement`, + * so it can flip on/off from Vercel without a code change. Dismissal is + * persisted in localStorage so it stays hidden across reloads and navigations. + */ +export function TopAnnouncementBar() { + // Default visible so the banner is there on first paint instead of popping + // in after the localStorage check and shifting the nav down. The tradeoff + // is a visitor who already dismissed it sees a brief flash before this + // effect hides it again, which is preferable to every other visitor seeing + // a layout shift on every load. + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + try { + if (localStorage.getItem(DISMISS_KEY) === "1") { + setDismissed(true); + } + } catch { + // localStorage can throw in private browsing / when disabled; leave + // the banner visible. + } + }, []); + + const handleDismiss = () => { + setDismissed(true); + try { + localStorage.setItem(DISMISS_KEY, "1"); + } catch { + // Ignore: dismissal just won't persist across reloads. + } + }; + + if (!TOP_ANNOUNCEMENT_ENABLED || dismissed) { + return null; + } + + return ( +
+ {/* Same box as `.ss-home-nav-bar` below it: centered, capped at + `--home-max`, bordered on the sides. Filling *this* box with the + notice color (rather than the full-bleed strip around it) is what + keeps the banner reading as the same width as the nav. */} +
+ SurfSense is moving to a local app. + + Read announcement + + + +
+
+ ); +} diff --git a/surfsense_web/components/site/site-nav.tsx b/surfsense_web/components/site/site-nav.tsx index 364e5da97e..d528d3f4ec 100644 --- a/surfsense_web/components/site/site-nav.tsx +++ b/surfsense_web/components/site/site-nav.tsx @@ -4,6 +4,7 @@ import { IconChevronDown, IconMenu2, IconX } from "@tabler/icons-react"; import Image from "next/image"; import Link from "next/link"; import { useEffect, useRef, useState } from "react"; +import { TopAnnouncementBar } from "@/components/homepage/top-announcement-bar"; import { NAV_LINKS, NAV_RESOURCES } from "@/components/site/site-content"; import { SiteStars } from "@/components/site/site-stars"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; @@ -90,6 +91,7 @@ export function SiteNav({ starCount, starsHref }: { starCount: number | null; st return (
+
diff --git a/surfsense_web/components/site/site-shell.tsx b/surfsense_web/components/site/site-shell.tsx index 42b3154aaf..f54f1e1e26 100644 --- a/surfsense_web/components/site/site-shell.tsx +++ b/surfsense_web/components/site/site-shell.tsx @@ -88,7 +88,8 @@ export function SiteShell({ {/* Above every branch, so it is one element in one slot for every route. React keeps it mounted across navigations and only the content below - changes. */} + changes. The announcement bar renders inside SiteNav's sticky header + rather than here, so the two stick together as one unit. */} {usesSiteDesign ? ( diff --git a/surfsense_web/components/ui/icons.tsx b/surfsense_web/components/ui/icons.tsx index 5754b0fd15..82eb613346 100644 --- a/surfsense_web/components/ui/icons.tsx +++ b/surfsense_web/components/ui/icons.tsx @@ -3,6 +3,7 @@ import { ArrowRightIcon as ArrowRightIconData, ArrowUp02Icon as ArrowUp02IconData, ArrowUpRight01Icon as ArrowUpRight01IconData, + Cancel01Icon as Cancel01IconData, Cards01Icon as Cards01IconData, ChartHistogramIcon as ChartHistogramIconData, CheckIcon as CheckIconData, @@ -73,6 +74,7 @@ export const AiSearchLinesIcon = createIcon(AiSearchLinesIconData); export const ArrowRightIcon = createIcon(ArrowRightIconData); export const ArrowUp02Icon = createIcon(ArrowUp02IconData); export const ArrowUpRight01Icon = createIcon(ArrowUpRight01IconData); +export const Cancel01Icon = createIcon(Cancel01IconData); export const Cards01Icon = createIcon(Cards01IconData); export const ChartHistogramIcon = createIcon(ChartHistogramIconData); export const CheckIcon = createIcon(CheckIconData); diff --git a/surfsense_web/lib/env-config.ts b/surfsense_web/lib/env-config.ts index 818ccddabc..8c3085a524 100644 --- a/surfsense_web/lib/env-config.ts +++ b/surfsense_web/lib/env-config.ts @@ -94,3 +94,9 @@ export const GLOBAL_ANNOUNCEMENT_ENABLED = export const GLOBAL_ANNOUNCEMENT_MESSAGE = process.env.NEXT_PUBLIC_GLOBAL_ANNOUNCEMENT_MESSAGE ?? ""; + +// Sticky top banner pointing at the /sunset announcement. Distinct from the +// maintenance banner above: this one sits above the nav and links straight to +// /sunset. Toggle via NEXT_PUBLIC_TOP_ANNOUNCEMENT_ENABLED ("true" to show) — +// the copy and link are fixed in TopAnnouncementBar. +export const TOP_ANNOUNCEMENT_ENABLED = process.env.NEXT_PUBLIC_TOP_ANNOUNCEMENT_ENABLED === "true";