diff --git a/landing/src/components/TopBarBanner/TopBarBannerClient.tsx b/landing/src/components/TopBarBanner/TopBarBannerClient.tsx new file mode 100644 index 000000000..f2cab41c0 --- /dev/null +++ b/landing/src/components/TopBarBanner/TopBarBannerClient.tsx @@ -0,0 +1,553 @@ +"use client"; + +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { + MIN_VISIBLE_HEIGHT, + cacheKey, + varNames, + type BannerZone, +} from "./shared"; + +export type { BannerZone }; +export { topBarBannerReservationScript } from "./shared"; + +const DEFAULT_ROTATE_INTERVAL_MS = 4000; +const SLIDE_MS = 700; + +const rotateLeft = (arr: T[], k: number): T[] => + arr.slice(k).concat(arr.slice(0, k)); +const rotateRight = (arr: T[], k: number): T[] => + arr.slice(arr.length - k).concat(arr.slice(0, arr.length - k)); + +const SCRIPT_SRC = "https://swm-delivery.com/www/assets/js/lib.js"; + +const FALLBACK_BG_COLOR = "#2a47ff"; + +const useIsoLayoutEffect = + typeof window === "undefined" ? useEffect : useLayoutEffect; + +// Cache only the banner's layout (height + bg), never banner content. +// Revive reloads the live banner every time for its own tracking/rotation; this just +// lets the bar be reserved at the right size before the live load arrives. +// Wait this long for the live banner before treating the slot as empty... +const BANNER_SETTLE_MS = 4000; +// ...unless Revive already signalled done (data-content-loaded), then this. +const BANNER_CONFIRM_MS = 500; + +type TopBarBannerCache = { height: number; bgColor: string; timestamp: number }; + +const writeCache = (key: string, height: number, bgColor: string) => { + if (typeof window === "undefined") return; + try { + const entry: TopBarBannerCache = { height, bgColor, timestamp: Date.now() }; + window.localStorage.setItem(key, JSON.stringify(entry)); + } catch { + // best-effort + } +}; + +const clearCache = (key: string) => { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(key); + } catch { + // best-effort + } +}; + +// Null height clears the reservation (bar collapses via the var fallback). +const setReservation = ( + vars: { height: string; bg: string }, + height: number | null, + bgColor?: string +) => { + if (typeof document === "undefined") return; + const root = document.documentElement; + if (height === null) { + root.style.removeProperty(vars.height); + } else { + root.style.setProperty(vars.height, `${height}px`); + } + if (bgColor) root.style.setProperty(vars.bg, bgColor); +}; + +const isTransparent = (color: string) => + !color || + color === "transparent" || + /^rgba?\([^)]*,\s*0\s*\)$/.test(color.replace(/\s+/g, " ")); + +const findOpaqueBg = (nodes: HTMLElement[]): string | null => { + for (const node of nodes) { + const bg = getComputedStyle(node).backgroundColor; + if (!isTransparent(bg)) return bg; + } + return null; +}; + +// Walk loaded banner DOM, return first opaque background-color. +// Revive serves banner inside an iframe, so descend into accessible +// (same-origin) iframe documents too; cross-origin access throws → skip. +const extractBgColor = (root: HTMLElement): string | null => { + const direct = findOpaqueBg([ + root, + ...Array.from(root.querySelectorAll("*")), + ]); + if (direct) return direct; + + for (const frame of Array.from(root.querySelectorAll("iframe"))) { + try { + const doc = frame.contentDocument; + if (!doc?.body) continue; + const fromFrame = findOpaqueBg([ + doc.body, + ...Array.from(doc.body.querySelectorAll("*")), + ]); + if (fromFrame) return fromFrame; + } catch { + // Cross-origin iframe — cannot read; keep fallback. + } + } + return null; +}; + +/** Per-zone measured state lifted to the rotating parent. */ +type ZoneState = { height: number; hasBanner: boolean }; + +interface BannerZoneSlotProps { + zone: BannerZone; + /** This slot's index in the parent's zone list. */ + index: number; + /** + * Reports this zone's measured height + fill state to the parent. Must be a + * stable reference (the reconcile effect depends on it) — pass the parent's + * memoized handler directly, not an inline closure. + */ + onChange: (index: number, state: ZoneState) => void; +} + +/** + * Single Revive slot: owns its own detection observer, cache, and per-instance + * CSS-var reservation (height + bg), exactly as the original single-zone + * TopBarBanner did. It reports {@link ZoneState} up so the parent can drive the + * rotating bar's height and slide offset. The `` mounts immediately so + * every zone's banner loads in the background and warms its own cache. + */ +const BannerZoneSlot = ({ zone, index, onChange }: BannerZoneSlotProps) => { + const fallbackBgColor = zone.fallbackBgColor ?? FALLBACK_BG_COLOR; + const contentRef = useRef(null); + const insRef = useRef(null); + + const keyRef = useRef(cacheKey(zone.zoneId, zone.contentId)); + const varsRef = useRef(varNames(zone.zoneId, zone.contentId)); + + const [hasBanner, setHasBanner] = useState(false); + // Revive finished the slot (filled or empty) then can collapse an empty one fast. + const [reviveDone, setReviveDone] = useState(false); + + useEffect(() => { + const content = contentRef.current; + if (!content) return; + + const getIns = () => { + const current = content.querySelector("ins"); + if (current && current !== insRef.current) { + insRef.current = current as HTMLModElement; + } + return insRef.current; + }; + + const detectBanner = () => { + const height = Math.max(content.scrollHeight, content.offsetHeight); + return height >= MIN_VISIBLE_HEIGHT; + }; + + const updateState = () => { + setHasBanner(detectBanner()); + // Revive stamps data-content-loaded="1" when done, even for an empty slot. + if (getIns()?.getAttribute("data-content-loaded") === "1") { + setReviveDone(true); + } + }; + + const containerObserver = new MutationObserver(updateState); + containerObserver.observe(content, { + childList: true, + subtree: true, + attributes: true, + }); + + const ins = getIns(); + let insObserver: MutationObserver | null = null; + if (ins) { + insObserver = new MutationObserver(updateState); + insObserver.observe(ins, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["data-content-loaded", "style", "class", "id"], + }); + } + + updateState(); + + return () => { + containerObserver.disconnect(); + if (insObserver) insObserver.disconnect(); + }; + }, []); + + // Reconcile the live banner against the reserved size, then persist. + useEffect(() => { + if (hasBanner) { + const content = contentRef.current; + if (!content) return; + + // Sync reservation + cache to the live size (no-op when it matches the + // warm value; a mismatch tweens via the height transition). + const raf = requestAnimationFrame(() => { + // The is a shrink-to-fit flex item; without a definite width the + // iframe's `width:100%` resolves circularly and collapses to the 300px + // replaced-element default. Stretch the to full bar width first, + // then the iframe's 100% has a real containing block to fill. + const ins = content.querySelector("ins"); + if (ins) { + ins.style.display = "block"; + ins.style.width = "100%"; + } + const iframe = content.querySelector("iframe"); + if (iframe) { + iframe.style.display = "block"; + iframe.style.width = "100%"; + iframe.style.maxWidth = "100%"; + } + const height = Math.max(content.scrollHeight, content.offsetHeight); + const bg = extractBgColor(content) ?? fallbackBgColor; + setReservation(varsRef.current, height, bg); + writeCache(keyRef.current, height, bg); + onChange(index, { height, hasBanner: true }); + }); + return () => cancelAnimationFrame(raf); + } + + // No banner yet: a real one arriving flips hasBanner and cancels this; + // otherwise, once it's confirmed empty, collapse + drop the cache. + const delay = reviveDone ? BANNER_CONFIRM_MS : BANNER_SETTLE_MS; + const settle = window.setTimeout(() => { + const content = contentRef.current; + if (!content) return; + const height = Math.max(content.scrollHeight, content.offsetHeight); + if (height < MIN_VISIBLE_HEIGHT) { + clearCache(keyRef.current); + setReservation(varsRef.current, null); + onChange(index, { height: 0, hasBanner: false }); + } + }, delay); + return () => window.clearTimeout(settle); + }, [hasBanner, reviveDone, fallbackBgColor, onChange, index]); + + return ( +
+ +
+ ); +}; + +export interface TopBarBannerProps { + /** + * Zones to rotate through. When omitted, falls back to the single-zone + * {@link TopBarBannerProps.zoneId}/{@link TopBarBannerProps.contentId} props. + */ + zones?: BannerZone[]; + /** Single-zone shorthand (back-compat). Ignored when `zones` is provided. */ + zoneId?: string; + /** Single-zone shorthand (back-compat). Ignored when `zones` is provided. */ + contentId?: string; + /** Bar color for the single-zone shorthand. */ + fallbackBgColor?: string; + /** Called with the active zone's measured height (px), or 0 when empty. */ + setBannerHeight?: (height: number) => void; + /** + * Rotation interval in ms — time the active banner is held before the next + * slide starts. Defaults to 1000. Floored at the slide duration ({@link + * SLIDE_MS}) so ticks aren't silently dropped mid-slide. Single zone never + * rotates. + */ + rotateIntervalMs?: number; + /** + * Slide direction. `"up"` (default) slides each banner upward and pulls the + * next in from below; `"down"` slides downward, pulling from above. Rotation + * is always continuous in one direction — never bounces back. + */ + direction?: "up" | "down"; +} + +// Slider view: which zone slot sits where (`order`), the current translate +// (`offset`, px), whether that translate animates, and the zone driving the +// bar height/bg (`active`). +type SliderView = { + order: number[]; + offset: number; + animate: boolean; + active: number; +}; + +/** + * Revive Adserver banner in a collapsible top bar, rotating through one or more + * {@link BannerZone}s. Each zone's height/bg are driven by per-instance CSS vars + * (not React state) so {@link topBarBannerReservationScript} can reserve the first + * zone's cached size in the layout `` before hydration — see that function + * and the warm-cache note above for the no-content-shift rationale. + * + * Rotation is a vertical slide (rotator-style): zone slots share one slider and + * the visible one is chosen via CSS flex `order` (DOM order stays fixed so the + * Revive iframes are never moved/reloaded), while the bar height tweens to the + * active zone. Empty zones are skipped while rotating. + */ +export const TopBarBanner = ({ + zones, + zoneId, + contentId, + fallbackBgColor = FALLBACK_BG_COLOR, + setBannerHeight, + rotateIntervalMs = DEFAULT_ROTATE_INTERVAL_MS, + direction = "up", +}: TopBarBannerProps) => { + const normalizedZones: BannerZone[] = + zones && zones.length > 0 + ? zones + : zoneId && contentId + ? [{ zoneId, contentId, fallbackBgColor }] + : []; + + const [zoneStates, setZoneStates] = useState(() => + normalizedZones.map(() => ({ height: 0, hasBanner: false })) + ); + const [view, setView] = useState(() => ({ + order: normalizedZones.map((_, i) => i), + offset: 0, + animate: false, + active: 0, + })); + // Off until after first paint so a pre-reserved bar doesn't animate on load. + const [animateIn, setAnimateIn] = useState(false); + // True once at least one zone has reported. Gates the global `bannerLoaded` + // flag so it isn't stamped "false" on mount before any zone has settled. + const [anyReported, setAnyReported] = useState(false); + + // Latest values the rotation timer reads without re-arming on every update. + const zoneStatesRef = useRef(zoneStates); + zoneStatesRef.current = zoneStates; + const viewRef = useRef(view); + viewRef.current = view; + const directionRef = useRef(direction); + directionRef.current = direction; + // True while a slide is mid-flight, so ticks can't overlap. + const busyRef = useRef(false); + + const handleZoneChange = useCallback((i: number, state: ZoneState) => { + setAnyReported(true); + setZoneStates((prev) => { + const cur = prev[i]; + // Bail when nothing changed so a steadily-filled slot doesn't spin the + // reconcile rAF → setState → re-render loop every animation frame. + if ( + cur && + cur.height === state.height && + cur.hasBanner === state.hasBanner + ) { + return prev; + } + const next = prev.slice(); + next[i] = state; + return next; + }); + }, []); + + // Inject the Revive loader once; it drives every zone's . + useEffect(() => { + if (typeof document === "undefined") return; + if (document.querySelector(`script[src="${SCRIPT_SRC}"]`)) return; + + const script = document.createElement("script"); + script.async = true; + script.src = SCRIPT_SRC; + document.body.appendChild(script); + }, []); + + // Arm transition only after the first frame is painted (double rAF), so the + // reserved bar appears instantly but later size changes still animate. + useIsoLayoutEffect(() => { + const id = requestAnimationFrame(() => + requestAnimationFrame(() => setAnimateIn(true)) + ); + return () => cancelAnimationFrame(id); + }, []); + + // One rotation step. Slides in a single direction and then re-homes the slot + // order so the loop is seamless (never bounces back at the wrap). Empty zones + // are skipped. Returns timer/raf ids the interval can cancel on cleanup. + useEffect(() => { + if (normalizedZones.length <= 1) return; + + let pendingTimer = 0; + let pendingRaf = 0; + + const step = () => { + if (busyRef.current) return; + const v = viewRef.current; + const n = v.order.length; + const states = zoneStatesRef.current; + const heights = v.order.map((zi) => states[zi]?.height ?? 0); + + if (directionRef.current === "down") { + // Pull the previous banner zone in from above. + let j = 0; + for (let s = 1; s < n; s++) { + if (states[v.order[(n - s) % n]]?.hasBanner) { + j = s; + break; + } + } + if (j === 0) return; + const newOrder = rotateRight(v.order, j); + const moving = heights.slice(n - j).reduce((sum, h) => sum + h, 0); + const active = newOrder[0]; + busyRef.current = true; + // Place the incoming slot just above the viewport, then slide it down. + setView({ order: newOrder, offset: -moving, animate: false, active }); + pendingRaf = requestAnimationFrame(() => { + pendingRaf = requestAnimationFrame(() => { + setView({ order: newOrder, offset: 0, animate: true, active }); + pendingTimer = window.setTimeout(() => { + busyRef.current = false; + }, SLIDE_MS); + }); + }); + return; + } + + // up: push the active slot out the top, pull the next in from below. + let k = 0; + for (let s = 1; s < n; s++) { + if (states[v.order[s]]?.hasBanner) { + k = s; + break; + } + } + if (k === 0) return; + const moving = heights.slice(0, k).reduce((sum, h) => sum + h, 0); + const active = v.order[k]; + busyRef.current = true; + setView({ order: v.order, offset: -moving, animate: true, active }); + pendingTimer = window.setTimeout(() => { + // Re-home: rotate so the now-visible slot is first, reset the offset. + setView({ + order: rotateLeft(v.order, k), + offset: 0, + animate: false, + active, + }); + busyRef.current = false; + }, SLIDE_MS); + }; + + // Floor at SLIDE_MS: a tick during an in-flight slide is dropped by the + // busy guard, so a shorter interval just wastes ticks instead of rotating + // faster. Holding for the slide duration keeps the cadence honest. + const interval = Math.max(rotateIntervalMs, SLIDE_MS); + const id = window.setInterval(step, interval); + return () => { + window.clearInterval(id); + if (pendingTimer) window.clearTimeout(pendingTimer); + if (pendingRaf) cancelAnimationFrame(pendingRaf); + busyRef.current = false; + }; + }, [normalizedZones.length, rotateIntervalMs]); + + // Report the active zone height + global loaded flag. Held until at least one + // zone reports so the flag isn't stamped "false" before any zone has settled. + useEffect(() => { + if (typeof document === "undefined" || !anyReported) return; + const anyBanner = zoneStates.some((s) => s.hasBanner); + document.documentElement.dataset.bannerLoaded = anyBanner + ? "true" + : "false"; + setBannerHeight?.(zoneStates[view.active]?.height ?? 0); + }, [zoneStates, view.active, setBannerHeight, anyReported]); + + if (normalizedZones.length === 0) return null; + + const activeZone = normalizedZones[view.active] ?? normalizedZones[0]; + const activeVars = varNames(activeZone.zoneId, activeZone.contentId); + const activeFallback = activeZone.fallbackBgColor ?? FALLBACK_BG_COLOR; + + // Visual stacking position per zone. DOM order stays fixed (so Revive's + // iframes are never moved — moving an iframe reloads it); only the CSS flex + // `order` reshuffles, which the slide + re-home rely on. + const slotOrder: number[] = []; + view.order.forEach((zi, pos) => { + slotOrder[zi] = pos; + }); + + return ( +
+
+ {normalizedZones.map((zone, zi) => ( +
+ +
+ ))} +
+
+ ); +}; diff --git a/landing/src/components/TopBarBanner/config.ts b/landing/src/components/TopBarBanner/config.ts new file mode 100644 index 000000000..03c59786a --- /dev/null +++ b/landing/src/components/TopBarBanner/config.ts @@ -0,0 +1,23 @@ +import type { BannerZone } from "./shared"; + +export const TOP_BAR_BANNER = { + rotateIntervalMs: 4000, + hiddenPaths: [] as string[], + zones: [ + { + zoneId: "live-debugger-topbar-1", + contentId: "ea15c4216158c4097b65fe6504a4b3b7", + fallbackBgColor: "#001a72", + }, + { + zoneId: "live-debugger-topbar-2", + contentId: "ea15c4216158c4097b65fe6504a4b3b7", + fallbackBgColor: "#001a72", + }, + { + zoneId: "live-debugger-topbar-3", + contentId: "ea15c4216158c4097b65fe6504a4b3b7", + fallbackBgColor: "#001a72", + }, + ] satisfies BannerZone[], +}; diff --git a/landing/src/components/TopBarBanner/index.ts b/landing/src/components/TopBarBanner/index.ts new file mode 100644 index 000000000..ee6af40fa --- /dev/null +++ b/landing/src/components/TopBarBanner/index.ts @@ -0,0 +1,7 @@ +export { TopBarBanner, type BannerZone } from "./TopBarBannerClient"; +export { + topBarBannerReservationScript, + varNames, + isBannerHidden, +} from "./shared"; +export { TOP_BAR_BANNER } from "./config"; diff --git a/landing/src/components/TopBarBanner/shared.ts b/landing/src/components/TopBarBanner/shared.ts new file mode 100644 index 000000000..47c1285a7 --- /dev/null +++ b/landing/src/components/TopBarBanner/shared.ts @@ -0,0 +1,58 @@ +const MIN_VISIBLE_HEIGHT = 5; + +const CACHE_PREFIX = "swm.topbarbanner.v1"; +const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +const VAR_HEIGHT_PREFIX = "--banner-height"; +const VAR_BG_PREFIX = "--banner-bg"; + +export const cacheKey = (zoneId: string, contentId: string) => + `${CACHE_PREFIX}.${zoneId}.${contentId}`; + +export const varNames = (zoneId: string, contentId: string) => { + const suffix = `${zoneId}-${contentId}`.replace(/[^a-zA-Z0-9_-]/g, "_"); + return { + height: `${VAR_HEIGHT_PREFIX}-${suffix}`, + bg: `${VAR_BG_PREFIX}-${suffix}`, + }; +}; + +export { MIN_VISIBLE_HEIGHT }; + +export interface BannerZone { + zoneId: string; + contentId: string; + fallbackBgColor?: string; +} + +export const isBannerHidden = (pathname: string, hiddenPaths?: string[]) => + !!hiddenPaths?.some((p) => pathname === p || pathname.startsWith(`${p}/`)); + +/** + * Inline `` script that reserves the cached banner size on `` + * before the body paints. Skips reserving on `hiddenPaths` (checked against + * `location.pathname`) so blacklisted pages don't reserve a bar that never shows. + */ +export const topBarBannerReservationScript = ( + zoneId: string, + contentId: string, + hiddenPaths?: string[] +) => { + const vars = varNames(zoneId, contentId); + return ( + `(function(){try{` + + `var hp=${JSON.stringify(hiddenPaths ?? [])};var p=location.pathname;` + + `if(hp.some(function(x){return p===x||p.indexOf(x+"/")===0}))return;` + + `var k=${JSON.stringify(cacheKey(zoneId, contentId))};` + + `var raw=localStorage.getItem(k);if(!raw)return;` + + `var c=JSON.parse(raw);` + + `if(!c||typeof c.height!=="number"||c.height<${MIN_VISIBLE_HEIGHT})return;` + + `if(Date.now()-c.timestamp>=${CACHE_TTL_MS})return;` + + `var r=document.documentElement;` + + `r.style.setProperty(${JSON.stringify(vars.height)},c.height+"px");` + + `if(typeof c.bgColor==="string")r.style.setProperty(${JSON.stringify( + vars.bg + )},c.bgColor);` + + `}catch(e){}})();` + ); +}; diff --git a/landing/src/components/ui/AdBanner.tsx b/landing/src/components/ui/AdBanner.tsx deleted file mode 100644 index 6b8ed4618..000000000 --- a/landing/src/components/ui/AdBanner.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React, { useEffect, useState, useRef } from "react"; - -export interface AdBannerProps { - zoneId: string; - contentId: string; -} - -export const AdBanner = ({ zoneId, contentId }: AdBannerProps) => { - const [isAdLoaded, setIsAdLoaded] = useState(false); - const adContainerRef = useRef(null); - const adContentRef = useRef(null); - const adDetectedRef = useRef(false); - - useEffect(() => { - const adContainer = adContainerRef.current; - if (!adContainer) return; - - const insElement = adContainer.querySelector("ins"); - if (!insElement) return; - - const checkForAd = () => { - if (adDetectedRef.current) return true; - - const isLoaded = - insElement.getAttribute("data-content-loaded") === "1" || - insElement.querySelector(".revive-banner") !== null || - insElement.querySelector("a.revive-banner") !== null || - (insElement.children.length > 0 && insElement.offsetHeight > 10); - - if (isLoaded) { - adDetectedRef.current = true; - setIsAdLoaded(true); - return true; - } - return false; - }; - - const observer = new MutationObserver(() => { - checkForAd(); - }); - - observer.observe(insElement, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ["data-content-loaded", "style", "class", "id"], - }); - - observer.observe(adContainer, { - childList: true, - subtree: true, - attributes: true, - }); - - const handleAdLoaded = () => { - if (!adDetectedRef.current) { - adDetectedRef.current = true; - setIsAdLoaded(true); - } - }; - - const eventName = `content-${contentId}-loaded`; - document.addEventListener(eventName, handleAdLoaded); - - checkForAd(); - - const intervals = [ - setTimeout(checkForAd, 500), - setTimeout(checkForAd, 1000), - setTimeout(checkForAd, 2000), - setTimeout(checkForAd, 3000), - setTimeout(checkForAd, 5000), - ]; - - return () => { - observer.disconnect(); - intervals.forEach(clearTimeout); - document.removeEventListener(eventName, handleAdLoaded); - }; - }, [contentId]); - - useEffect(() => { - const container = adContainerRef.current; - const content = adContentRef.current; - if (!container || !content) return; - - if (isAdLoaded) { - const updateHeight = () => { - const height = content.scrollHeight || content.offsetHeight || 100; - container.style.maxHeight = `${Math.max(height, 100)}px`; - container.style.overflow = "visible"; - }; - requestAnimationFrame(updateHeight); - setTimeout(updateHeight, 100); - setTimeout(updateHeight, 500); - } else { - container.style.maxHeight = "0px"; - container.style.overflow = "hidden"; - } - }, [isAdLoaded]); - - return ( -
-
- -
- -
- ); -}; diff --git a/landing/src/components/ui/Header.tsx b/landing/src/components/ui/Header.tsx index 90bd49517..5881b40e2 100644 --- a/landing/src/components/ui/Header.tsx +++ b/landing/src/components/ui/Header.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, useRef, useImperativeHandle } from "react"; import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/Logo"; import { Github } from "@/components/ui/Github"; -import { AdBanner } from "@/components/ui/AdBanner"; +import { TopBarBanner, TOP_BAR_BANNER } from "@/components/TopBarBanner"; import { getStorageValue } from "@/lib/utils"; export interface HeaderProps extends React.HTMLAttributes {} @@ -105,24 +105,28 @@ const Header = React.forwardRef( }, []); return ( -
- -
+ <> + +
+
@@ -189,7 +193,8 @@ const Header = React.forwardRef(
-
+ + ); } ); diff --git a/landing/src/layouts/Layout.astro b/landing/src/layouts/Layout.astro index bca30ede4..290f87b23 100644 --- a/landing/src/layouts/Layout.astro +++ b/landing/src/layouts/Layout.astro @@ -3,11 +3,21 @@ import "../styles/global.css"; import { Font } from "astro:assets"; import { Header } from "@/components/ui/Header"; import { Footer } from "@/components/ui/Footer"; +import { + topBarBannerReservationScript, + TOP_BAR_BANNER, +} from "@/components/TopBarBanner"; export interface Props { title: string; } +const bannerReservation = topBarBannerReservationScript( + TOP_BAR_BANNER.zones[0]!.zoneId, + TOP_BAR_BANNER.zones[0]!.contentId, + TOP_BAR_BANNER.hiddenPaths +); + const enableAnalytics = import.meta.env.ENABLE_ANALYTICS === true || import.meta.env.ENABLE_ANALYTICS === "true"; @@ -17,6 +27,7 @@ const enableAnalytics = +