FeatureBanner.tsx 2.4 KB

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