@vulcantech/banners
v0.1.3
Published
CMS-driven announcement banners for consumer sites — top/bottom positions, page targeting, priority ordering, and dismissible bars backed by localStorage
Maintainers
Readme
@vulcantech/banners
CMS-driven announcement banners for VulcanTech consumer sites. Renders top/bottom bars fetched from the CMS, with page targeting, priority ordering, and per-user dismissal (localStorage).
Exports: CMSBanners (drop-in layout component), BannerBar (single bar), and the BannerData type.
Display only — this package ships no fetcher. You own the server fetch (step 3 below) and pass the result in as props.
Banner shape (BannerData)
message and linkText are plain text, not HTML — they are rendered as React
children (<span>{banner.message}</span>), so any markup in them is escaped and
shown literally. There is no HTML field on a banner and nothing here is ever
injected with dangerouslySetInnerHTML.
interface BannerData {
id: string; // Firestore doc id
position: "top" | "bottom"; // which bar this belongs to — you render both
message: string; // plain text — see above
linkText?: string; // plain text; a "→" is appended by the renderer
linkUrl?: string; // plain <a href>, same tab, no next/link
backgroundColor: string; // raw CSS color, applied inline — required
textColor: string; // raw CSS color, applied inline — required
icon?: string; // preset icon NAME, not a URL — not rendered here
dismissible: boolean; // shows the ✕ button
/** Key used for dismiss localStorage. Reset in CMS to re-show to users. */
dismissKey?: string; // rotates on save — see below
priority: number; // ascending: lower numbers render first
/** Page slugs where this banner appears. Empty = all pages. */
pages?: string[]; // needs `currentPath` to have any effect
}Field notes:
message/linkTextare plain text;linkUrlrenders as a bare<a href>in the same tab with notargetorrel. The CMS rejectsjavascript:,data:andvbscript:URLs on write, so the stored value is a navigable URL.backgroundColor/textColorare whatever string an editor saved (the CMS presets store hex like#2563eb) and go straight intostyle. They are required — a banner never falls back to a themed default, so this package deliberately ignores your Tailwind theme. Don't expect token classes to apply.iconis a preset icon name ("info","alert","sparkles","gift","wrench","rocket","alert-triangle"), not an image URL.BannerBardoes not render it — only the CMS dashboard preview maps those names to icons. Treat it as advisory metadata if you build your own bar.dismissKeyvsid— dismissal is keyed ondismissKey || id(the fallback is for banners created before the field existed). The CMS regeneratesdismissKeyon every save from the editor's default "Save & Reset Dismiss" button, which re-shows the banner to everyone who dismissed it; the "Save without resetting" option in that button's dropdown preserves it. So an edited banner reappearing for users is intended behavior, not a cache bug.prioritysorts ascending —0renders above10. The CMS already returns banners in this order andCMSBannerssorts again, so the order is the same whichever way you render them.pagesis a list of path prefixes (["/", "/services", "/blog"]); empty or absent means every page. Matching is case-insensitive, ignores trailing slashes, and matches subtrees —"/blog"also matches/blog/my-post, while"/"matches only the root. Targeting is inert unless you passcurrentPath: with nocurrentPathevery banner passes the filter, including page-targeted ones. Note that the CMS's current/api/bannerswrite validators do not persist this field, so banners authored through the dashboard today arrive withpagesabsent — build against the semantics above, but don't expect live data in it yet.
BannerData is a display subset of the CMS's internal Banner record. The API
response also carries projectId, preset, active, startDate, endDate,
createdAt and updatedAt; they are typed away here because the display
components never need them. active and the date window are applied server-side
(below), so there is nothing to filter yourself.
Fetching and failure mode
GET /api/banners?projectId=…&apiKey=… returns { banners: BannerData[] } for
both positions at once, already filtered to active === true and inside the
startDate/endDate window, sorted by priority ascending. Fetch it once in the
layout and hand the same array to both <CMSBanners position="top"> and
position="bottom" — do not fetch twice.
Two consequences of the server-side date filter:
- Scheduling resolves at fetch time, not render time. A banner starts or
expires only as fast as your ISR window (60s in the fetcher below) plus the
CMS's own
s-maxage=60. The CMS purges thebanners:{projectId}tag on every create/update/delete, so editor changes land immediately; astartDatepassing on its own does not trigger a purge. - Dates are ISO 8601 UTC strings and are compared as strings by the CMS, which is
correct for the
toISOString()values its editor writes.
Nothing in this package throws. The components have no fetching and no error
path: CMSBanners returns null when nothing matches the position/path filter,
and BannerBar returns null when the banner is dismissed or before mount. The
recommended fetcher swallows every failure (unconfigured env, non-2xx, network
error) and returns []. So there is no error UI to write — a broken CMS looks
exactly like "no banners".
Rendering behavior
- Both components are
"use client".BannerBarrendersnulluntil it has mounted (it must readlocalStorageto know what was dismissed), so banners are never in the server-rendered HTML — they appear after hydration. Expect a small shift at the top of the page and don't rely on banner text for SEO. CMSBannerswraps thetopgroup in aposition: relative; z-index: 51div in normal flow, and thebottomgroup inposition: sticky; bottom: 0; z-index: 40. A site whose header isposition: fixedwill cover the top banner — offset the header yourself (measure the rendered height and push the header down; the CMS does this with a ResizeObserver and a CSS variable).- Dismissals persist to one shared
localStoragekey,vulcantech_dismissed_banners(a JSON array of dismiss ids, capped at the 50 most recent). It is per-browser, not per-user account, and the cap means a site with heavy banner turnover eventually forgets the oldest dismissals. Writes are wrapped in try/catch, so a full or blocked storage quota degrades to "the banner comes back", never an exception. - Flipping
dismissibletofalsere-shows the banner to everyone: the dismissal lookup only runs for dismissible banners.
Integration
Wire banners into a consumer site (Next.js App Router):
1. Add the dependency:
npm install @vulcantech/banners(Inside the VulcanTech monorepo, use the workspace wiring in MONOREPO.md instead.)
2. Set the env — see Required env below.
3. Create the server fetcher src/lib/cms/banners.ts (ISR-cached, fails open):
import type { BannerData } from "@vulcantech/banners";
const CMS_URL = process.env.VULCANTECH_CMS_URL || "https://cms.vulcantech.io";
const PROJECT_ID = process.env.VULCANTECH_PROJECT_ID;
const API_KEY = process.env.VULCANTECH_API_KEY;
export async function fetchBanners(): Promise<BannerData[]> {
if (!PROJECT_ID || !API_KEY) return [];
const params = new URLSearchParams({ projectId: PROJECT_ID, apiKey: API_KEY });
try {
const res = await fetch(`${CMS_URL}/api/banners?${params}`, {
next: { revalidate: 60, tags: [`banners:${PROJECT_ID}`] },
});
if (!res.ok) return [];
const json = (await res.json()) as { banners: BannerData[] };
return json.banners ?? [];
} catch {
return [];
}
}4. Render in the root layout (src/app/layout.tsx):
import { CMSBanners } from "@vulcantech/banners";
const banners = await fetchBanners();
// <body>
// <CMSBanners banners={banners} position="top" />
// {children}
// <CMSBanners banners={banners} position="bottom" />
// </body>One fetch feeds both positions — the API returns top and bottom banners together.
4b. (Optional) page targeting — pages is only honored when CMSBanners
receives currentPath. A root layout is a server component and has no pathname,
so pass it from a thin client wrapper:
"use client";
import { usePathname } from "next/navigation";
import { CMSBanners, type BannerData } from "@vulcantech/banners";
export function SiteBanners({ banners }: { banners: BannerData[] }) {
const pathname = usePathname();
return (
<>
<CMSBanners banners={banners} position="top" currentPath={pathname} />
<CMSBanners banners={banners} position="bottom" currentPath={pathname} />
</>
);
}Without this, every banner shows on every page.
5. ISR invalidation — ensure src/app/api/revalidate/route.ts exists, set VULCANTECH_REVALIDATE_SECRET, and register the site under Settings → Form Consumers (ISR Revalidation) in the CMS (the list serves every integration, not just forms). The CMS purges the banners:{projectId} tag when a banner is edited. Route contract and reference copy: the @vulcantech/cms README, ISR revalidation.
Required env
VULCANTECH_PROJECT_ID, VULCANTECH_API_KEY (required) · VULCANTECH_REVALIDATE_SECRET (required for instant ISR purges; see step 5) · VULCANTECH_CMS_URL (optional, defaults to https://cms.vulcantech.io).
Per-app values
None beyond env — banners is fully config-driven from the CMS.
Notes
- Components are
"use client"; dismissal persists tolocalStorage(vulcantech_dismissed_banners). See Rendering behavior for the hydration, stacking, and dismissal-cap details.
