FeatureBanner.tsx88 lines · main
1import { useParams } from 'common/hooks'
2import { HTMLMotionProps, motion } from 'framer-motion'
3import { X } from 'lucide-react'
4import { ReactNode } from 'react'
5import { Button, cn } from 'ui'
6
7import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
8
9// Base props common to all feature banners
10interface BaseFeatureBannerProps extends HTMLMotionProps<'div'> {
11 children: ReactNode
12 className?: string
13 dismissClassName?: string
14 defaultDismissed?: boolean
15 illustration?: ReactNode
16 bgAlt?: boolean
17}
18
19// Type for non-dismissable banners (no storageKey needed)
20interface NonDismissableFeatureBannerProps extends BaseFeatureBannerProps {
21 dismissable?: false
22 storageKey?: never
23}
24
25// Type for dismissable banners (requires storageKey)
26interface DismissableFeatureBannerProps extends BaseFeatureBannerProps {
27 dismissable: true
28 storageKey: string | ((ref: string) => string)
29}
30
31// Union type that enforces the constraint
32export type FeatureBannerProps = NonDismissableFeatureBannerProps | DismissableFeatureBannerProps
33
34export const FeatureBanner = ({
35 storageKey,
36 children,
37 className,
38 dismissClassName,
39 defaultDismissed = false,
40 illustration,
41 dismissable = false,
42 bgAlt = false,
43 ...props
44}: FeatureBannerProps) => {
45 const { ref } = useParams()
46 const key = storageKey && typeof storageKey === 'function' ? storageKey(ref ?? '') : storageKey
47
48 const [isDismissed, setIsDismissed] = useLocalStorageQuery(
49 key || 'feature-banner-dismissed',
50 defaultDismissed
51 )
52
53 if (dismissable && storageKey && isDismissed) return null
54
55 return (
56 <motion.div
57 initial={{ opacity: 0, y: 6 }}
58 animate={{ opacity: 1, y: 0 }}
59 transition={{
60 type: 'spring',
61 stiffness: 500,
62 damping: 30,
63 mass: 1,
64 }}
65 {...props}
66 className={cn(
67 'pb-36 pt-10 relative w-full border xl:py-10 px-10 rounded-md overflow-hidden',
68 bgAlt && 'bg-background-alternative',
69 className
70 )}
71 >
72 {children}
73 {illustration}
74 {dismissable && storageKey && (
75 <div className={cn('absolute top-3 right-3', dismissClassName)}>
76 <Button
77 type="text"
78 size="tiny"
79 icon={<X size={16} strokeWidth={1.5} />}
80 onClick={() => setIsDismissed(true)}
81 className="opacity-75 px-1"
82 aria-label="Dismiss notification"
83 />
84 </div>
85 )}
86 </motion.div>
87 )
88}