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