MarketplaceFeaturedHero.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import { ArrowRight, Pause, Play } from 'lucide-react'
  2. import Link from 'next/link'
  3. import { useEffect, useRef, useState } from 'react'
  4. import { Badge, Button, cn } from 'ui'
  5. import { IntegrationLogo } from '../Integration/IntegrationLogo'
  6. import {
  7. formatCategoryLabel,
  8. getMarketplaceSource,
  9. MarketplaceSourceBadge,
  10. } from './Marketplace.constants'
  11. import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
  12. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  13. const ROTATION_INTERVAL_MS = 7000
  14. interface MarketplaceFeaturedHeroProps {
  15. integrations: IntegrationDefinition[]
  16. installedIds: string[]
  17. categoryOptions: Array<{ slug: string; name: string }>
  18. }
  19. export const MarketplaceFeaturedHero = ({
  20. integrations,
  21. installedIds,
  22. categoryOptions,
  23. }: MarketplaceFeaturedHeroProps) => {
  24. const { data: project } = useSelectedProjectQuery()
  25. const [activeIndex, setActiveIndex] = useState(0)
  26. const [isPaused, setIsPaused] = useState(false)
  27. const hoveringRef = useRef(false)
  28. const cardRef = useRef<HTMLDivElement>(null)
  29. useEffect(() => {
  30. if (integrations.length <= 1) return
  31. if (isPaused) return
  32. const interval = setInterval(() => {
  33. if (hoveringRef.current) return
  34. setActiveIndex((idx) => (idx + 1) % integrations.length)
  35. }, ROTATION_INTERVAL_MS)
  36. return () => clearInterval(interval)
  37. }, [integrations.length, isPaused])
  38. useEffect(() => {
  39. const node = cardRef.current
  40. if (!node) return
  41. const enter = () => {
  42. hoveringRef.current = true
  43. }
  44. const leave = () => {
  45. hoveringRef.current = false
  46. }
  47. node.addEventListener('pointerenter', enter)
  48. node.addEventListener('pointerleave', leave)
  49. return () => {
  50. node.removeEventListener('pointerenter', enter)
  51. node.removeEventListener('pointerleave', leave)
  52. }
  53. }, [])
  54. if (integrations.length === 0) return null
  55. const active = integrations[Math.min(activeIndex, integrations.length - 1)]
  56. const activeCategorySlug = active.categories?.[0]
  57. const activeCategoryName = activeCategorySlug
  58. ? formatCategoryLabel(activeCategorySlug, categoryOptions)
  59. : null
  60. const isActiveInstalled = installedIds.includes(active.id)
  61. const source = getMarketplaceSource(active)
  62. const handleTabClick = (idx: number) => {
  63. setActiveIndex(idx)
  64. }
  65. return (
  66. <section>
  67. <div className="mb-2 flex items-center justify-between">
  68. <div className="flex items-center gap-2">
  69. <h2 className="text-sm">Featured integrations</h2>
  70. </div>
  71. <Button
  72. aria-label={isPaused ? 'Resume auto-rotation' : 'Pause auto-rotation'}
  73. type="default"
  74. size="tiny"
  75. className="px-1.5"
  76. icon={isPaused ? <Play size={10} /> : <Pause size={10} />}
  77. onClick={() => setIsPaused((p) => !p)}
  78. />
  79. </div>
  80. <div ref={cardRef} className="overflow-hidden rounded-lg border bg-surface-100">
  81. <Link
  82. href={`/project/${project?.ref}/integrations/${active.id}/overview`}
  83. className="grid gap-0 @3xl:grid-cols-[minmax(0,280px)_minmax(0,1fr)] hover:bg-selection/20 transition-colors"
  84. >
  85. <div className="relative hidden @3xl:block">
  86. <FeaturedCover integration={active} categoryLabel={activeCategoryName} />
  87. </div>
  88. <div className="flex flex-col gap-4 p-5 @3xl:p-6">
  89. <div className="flex items-start @lg:items-center justify-between gap-4">
  90. <div className="flex items-center gap-2">
  91. <div className="flex flex-wrap flex-col @lg:flex-row @lg:items-center gap-2">
  92. <div className="flex items-center gap-2">
  93. <IntegrationLogo integration={active} size="h-7 w-7" />
  94. <span className="text-lg font-medium text-foreground">{active.name}</span>
  95. </div>
  96. <div className="flex items-center gap-2">
  97. <MarketplaceSourceBadge source={source} />
  98. {active.status && <Badge variant="warning">{active.status}</Badge>}
  99. </div>
  100. </div>
  101. </div>
  102. {isActiveInstalled && (
  103. <Badge variant="success" className="mt-2 @lg:mt-0 px-1.5 py-0 text-[10px]">
  104. Installed
  105. </Badge>
  106. )}
  107. </div>
  108. <div className="grow flex flex-col gap-2">
  109. <p className="text-base text-foreground-light">{active.description}</p>
  110. </div>
  111. <div className="mt-1 flex items-center gap-4">
  112. <Button
  113. asChild
  114. type="text"
  115. className="p-0 pointer-events-none"
  116. iconRight={<ArrowRight size={12} />}
  117. size="tiny"
  118. >
  119. <Link href={`/project/${project?.ref}/integrations/${active.id}/overview`}>
  120. {isActiveInstalled ? 'Manage integration' : 'View integration'}
  121. </Link>
  122. </Button>
  123. </div>
  124. </div>
  125. </Link>
  126. <div
  127. className="grid border-t"
  128. style={{ gridTemplateColumns: `repeat(${integrations.length}, minmax(0, 1fr))` }}
  129. >
  130. {integrations.map((integration, idx) => {
  131. const isActive = idx === activeIndex
  132. const slug = integration.categories?.[0]
  133. const categoryLabel = slug ? formatCategoryLabel(slug, categoryOptions) : null
  134. return (
  135. <button
  136. key={integration.id}
  137. type="button"
  138. onClick={() => handleTabClick(idx)}
  139. className={cn(
  140. 'group relative flex items-center gap-2.5 border-r p-3 text-left transition-colors last:border-r-0',
  141. isActive ? 'bg-surface-200' : 'bg-transparent hover:bg-surface-200/60'
  142. )}
  143. aria-pressed={isActive}
  144. >
  145. <IntegrationLogo
  146. integration={integration}
  147. size="h-7 w-7"
  148. className="hidden sm:flex"
  149. />
  150. <div className="min-w-0 flex-1">
  151. <div
  152. className={cn(
  153. 'truncate text-sm',
  154. isActive ? 'text-foreground' : 'text-foreground-light'
  155. )}
  156. >
  157. {integration.name}
  158. </div>
  159. {categoryLabel && (
  160. <div className="truncate text-xs text-foreground-lighter">{categoryLabel}</div>
  161. )}
  162. </div>
  163. {isActive && (
  164. <span
  165. key={activeIndex}
  166. aria-hidden
  167. className="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 origin-left animate-marketplace-featured-progress bg-brand"
  168. style={{
  169. animationDuration: `${ROTATION_INTERVAL_MS}ms`,
  170. animationPlayState: isPaused ? 'paused' : 'running',
  171. }}
  172. />
  173. )}
  174. </button>
  175. )
  176. })}
  177. </div>
  178. </div>
  179. </section>
  180. )
  181. }
  182. interface FeaturedCoverProps {
  183. integration: IntegrationDefinition
  184. categoryLabel: string | null
  185. }
  186. // Decorative cover panel on the left of the hero. Uses a subtle dotted
  187. // gradient and renders the integration's own icon at a large scale so each
  188. // partner stays visually distinct without bespoke artwork per listing.
  189. const FeaturedCover = ({ integration, categoryLabel }: FeaturedCoverProps) => (
  190. <div
  191. key={integration.id}
  192. className="relative h-full min-h-[200px] w-full overflow-hidden bg-linear-to-br from-surface-200 via-surface-100 to-surface-300"
  193. >
  194. <div
  195. aria-hidden
  196. className="absolute inset-0 opacity-60"
  197. style={{
  198. backgroundImage:
  199. 'radial-gradient(circle at 1px 1px, hsl(var(--border-default)/0.6) 1px, transparent 0)',
  200. backgroundSize: '14px 14px',
  201. }}
  202. />
  203. <div className="absolute inset-0 flex items-center justify-center">
  204. <div className="relative flex h-24 w-24 items-center justify-center overflow-hidden rounded-2xl border bg-white shadow-sm">
  205. {integration.icon({ className: 'h-16 w-16' })}
  206. </div>
  207. </div>
  208. {categoryLabel && (
  209. <span className="absolute bottom-3 left-3 text-[10px] font-medium uppercase tracking-wider text-foreground-lighter">
  210. {categoryLabel}
  211. </span>
  212. )}
  213. </div>
  214. )