Files
supabase/apps/studio/components/ui/BannerStack/BannerStack.tsx
kemal.earth 3162cad715 feat(studio): add a banner to promote unified logs (#46847)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Adds a one time banner to the `<BannerStack />` to promote Unified Logs
becoming available. This also fixes the `<BannerStack />` components
issue with stacking varying height banners.



https://github.com/user-attachments/assets/40f02709-0d67-43a9-ab95-750d9a4a582a




<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a dismissible "Unified Logs" banner with an animated sample-log
carousel, CTA to Unified Logs, and a preview/enable flow for non-enabled
users. Dismissal is persisted locally and telemetry is recorded for CTA
and dismiss actions; banner appears only for eligible projects.

* **Refactor**
* Banner stack UI updated to display a single front banner with animated
"peek" slivers and refined hover/animation behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-11 17:00:48 +01:00

79 lines
2.6 KiB
TypeScript

import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import { useState } from 'react'
import { useBannerStack } from './BannerStackProvider'
const PEEK_OFFSET = 8
const MAX_PEEKS = 2
const SPRING = { type: 'spring', stiffness: 300, damping: 30 } as const
export const BannerStack = () => {
const { banners } = useBannerStack()
const [isHovered, setIsHovered] = useState(false)
const reduceMotion = useReducedMotion()
const activeBanners = banners.filter((b) => !b.isDismissed)
if (activeBanners.length === 0) return null
const [frontBanner, ...extraBanners] = activeBanners
const peekCount = Math.min(extraBanners.length, MAX_PEEKS)
// Deepest sliver first so the closer ones paint on top of it.
const peeks = Array.from({ length: peekCount }, (_, i) => peekCount - i)
const transition = reduceMotion ? { duration: 0 } : SPRING
return (
<motion.div
className="fixed bottom-4 right-4 z-50 flex flex-col-reverse items-end gap-2"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
animate={{ y: isHovered ? -8 : 0 }}
transition={transition}
>
<div className="relative w-full max-w-72">
<AnimatePresence>
{!isHovered &&
peeks.map((depth) => (
<motion.div
key={`peek-${depth}`}
className="absolute inset-0 rounded-2xl border bg-surface-75 shadow-lg"
style={{ transformOrigin: 'center top' }}
initial={{ opacity: 0 }}
animate={{ opacity: 1, y: -depth * PEEK_OFFSET, scaleX: 1 - depth * 0.06 }}
exit={{ opacity: 0, y: 0, scaleX: 1 }}
transition={transition}
/>
))}
</AnimatePresence>
<motion.div
className="relative z-10"
initial={{ opacity: 0, scale: 0.99, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.99, y: 8 }}
transition={transition}
>
{frontBanner.content}
</motion.div>
</div>
<AnimatePresence>
{isHovered &&
extraBanners.map((banner, index) => (
<motion.div
key={banner.id}
className="w-full max-w-72"
initial={{ opacity: 0, scale: 0.98, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98, y: 8 }}
transition={reduceMotion ? { duration: 0 } : { ...SPRING, delay: index * 0.04 }}
>
{banner.content}
</motion.div>
))}
</AnimatePresence>
</motion.div>
)
}