diff --git a/apps/marketing/src/app/[lang]/components/HeroSection/HeroSection.tsx b/apps/marketing/src/app/[lang]/components/HeroSection/HeroSection.tsx
index 1b99e8a5251..903e1084712 100644
--- a/apps/marketing/src/app/[lang]/components/HeroSection/HeroSection.tsx
+++ b/apps/marketing/src/app/[lang]/components/HeroSection/HeroSection.tsx
@@ -115,7 +115,10 @@ export function HeroSection() {
-
+
setIsWaitlistOpen(true)}
diff --git a/apps/marketing/src/app/[lang]/components/ProductHeroActions/ProductHeroActions.tsx b/apps/marketing/src/app/[lang]/components/ProductHeroActions/ProductHeroActions.tsx
index 5210b628d08..b434e87af05 100644
--- a/apps/marketing/src/app/[lang]/components/ProductHeroActions/ProductHeroActions.tsx
+++ b/apps/marketing/src/app/[lang]/components/ProductHeroActions/ProductHeroActions.tsx
@@ -19,7 +19,10 @@ export function ProductHeroActions({
return (
<>
-
+
setIsWaitlistOpen(true)}
diff --git a/apps/marketing/src/components/CookieConsent/CookieConsent.tsx b/apps/marketing/src/components/CookieConsent/CookieConsent.tsx
index 51964a88488..a3f306c3d02 100644
--- a/apps/marketing/src/components/CookieConsent/CookieConsent.tsx
+++ b/apps/marketing/src/components/CookieConsent/CookieConsent.tsx
@@ -2,16 +2,83 @@
import { Trans } from "@lingui/react/macro";
import { Button } from "@superset/ui/button";
+import { cn } from "@superset/ui/utils";
import { AnimatePresence, m } from "framer-motion";
import Link from "next/link";
-import { useEffect, useState } from "react";
+import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { declineAnalytics } from "@/lib/analytics/decline";
import { withPosthog } from "@/lib/analytics/lazy";
import { ANALYTICS_CONSENT_KEY } from "@/lib/constants";
+/** Elements the banner must never cover, such as the hero download buttons. */
+export const KEEP_CLEAR_ATTRIBUTE = "data-cookie-banner-keep-clear";
+
+// Tried in order; the first one that leaves every keep-clear element
+// uncovered wins. A browser window is often smaller than the screen, so no
+// single fixed position is safe: on a 1000x560 Safari window a bottom bar
+// covers the hero CTA, and on a phone a corner card does.
+const PLACEMENTS = [
+ {
+ name: "corner-left",
+ container: "bottom-4 left-4 w-72 flex-col items-stretch",
+ compact: false,
+ },
+ {
+ name: "corner-right",
+ container: "bottom-4 right-4 w-72 flex-col items-stretch",
+ compact: false,
+ },
+ {
+ name: "bar",
+ container: "inset-x-4 bottom-4 flex-col sm:flex-row sm:items-center",
+ compact: false,
+ },
+ {
+ name: "compact-right",
+ container: "bottom-2 right-2 flex-row items-center",
+ compact: true,
+ },
+ {
+ name: "compact-left",
+ container: "bottom-2 left-2 flex-row items-center",
+ compact: true,
+ },
+] as const;
+
+function overlaps(a: DOMRect, b: DOMRect): boolean {
+ return (
+ a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom
+ );
+}
+
+function coversKeepClear(banner: HTMLElement): boolean {
+ // Measure where the banner sits once its entrance animation ends.
+ const rect = banner.getBoundingClientRect();
+ const offsetY = Number.parseFloat(
+ getComputedStyle(banner).transform.split(",")[5] ?? "0",
+ );
+ const settled = new DOMRect(
+ rect.x,
+ rect.y - (Number.isFinite(offsetY) ? offsetY : 0),
+ rect.width,
+ rect.height,
+ );
+ for (const element of document.querySelectorAll(
+ `[${KEEP_CLEAR_ATTRIBUTE}]`,
+ )) {
+ const target = element.getBoundingClientRect();
+ if (target.width === 0 || target.height === 0) continue;
+ if (overlaps(settled, target)) return true;
+ }
+ return false;
+}
+
export function CookieConsent() {
const [showBanner, setShowBanner] = useState(false);
+ const [placementIndex, setPlacementIndex] = useState(0);
+ const [viewport, setViewport] = useState(null);
+ const bannerRef = useRef(null);
useEffect(() => {
const consent = localStorage.getItem(ANALYTICS_CONSENT_KEY);
@@ -20,6 +87,30 @@ export function CookieConsent() {
}
}, []);
+ // Start over from the preferred placement whenever the window changes size.
+ useEffect(() => {
+ if (!showBanner) return;
+ const onResize = () =>
+ setViewport(`${window.innerWidth}x${window.innerHeight}`);
+ onResize();
+ window.addEventListener("resize", onResize);
+ return () => window.removeEventListener("resize", onResize);
+ }, [showBanner]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: viewport is the trigger
+ useLayoutEffect(() => {
+ setPlacementIndex(0);
+ }, [viewport]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-measure after a resize even when the placement stays the same
+ useLayoutEffect(() => {
+ const banner = bannerRef.current;
+ if (!showBanner || !banner) return;
+ if (placementIndex < PLACEMENTS.length - 1 && coversKeepClear(banner)) {
+ setPlacementIndex(placementIndex + 1);
+ }
+ }, [showBanner, placementIndex, viewport]);
+
const handleAccept = () => {
localStorage.setItem(ANALYTICS_CONSENT_KEY, "accepted");
setShowBanner(false);
@@ -32,36 +123,59 @@ export function CookieConsent() {
setShowBanner(false);
};
+ const placement = PLACEMENTS[placementIndex] ?? PLACEMENTS[0];
+
return (
{showBanner && (
-
-
- We only collect analytics cookies so we can improve your
- experience.
-
-
-
-