index.tsx373 lines · main
1// @ts-nocheck
2import { useQuery } from '@tanstack/react-query'
3import { IS_PLATFORM, useFeatureFlags } from 'common'
4import { Database } from 'common/marketplace.types'
5import { Search } from 'lucide-react'
6import { useRouter } from 'next/router'
7import { parseAsString, useQueryState } from 'nuqs'
8import { useMemo, type ReactNode } from 'react'
9import { ShimmeringLoader } from 'ui-patterns'
10import { Input } from 'ui-patterns/DataInputs/Input'
11import { PageContainer } from 'ui-patterns/PageContainer'
12import {
13 PageHeader,
14 PageHeaderAside,
15 PageHeaderDescription,
16 PageHeaderMeta,
17 PageHeaderSummary,
18 PageHeaderTitle,
19} from 'ui-patterns/PageHeader'
20import { PageSection, PageSectionContent, PageSectionMeta } from 'ui-patterns/PageSection'
21
22import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
23import {
24 IntegrationCard,
25 IntegrationLoadingCard,
26} from '@/components/interfaces/Integrations/Landing/IntegrationCard'
27import { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
28import { useAvailableIntegrations } from '@/components/interfaces/Integrations/Landing/useAvailableIntegrations'
29import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations'
30import { MarketplaceIndex } from '@/components/interfaces/Integrations/Marketplace/MarketplaceIndex'
31import { DefaultLayout } from '@/components/layouts/DefaultLayout'
32import { ProjectIntegrationsLayoutDispatch } from '@/components/layouts/ProjectIntegrationsLayoutDispatch'
33import { AlertError } from '@/components/ui/AlertError'
34import { DocsButton } from '@/components/ui/DocsButton'
35import { NoSearchResults } from '@/components/ui/NoSearchResults'
36import { useMarketplaceCategoriesQuery } from '@/data/marketplace/integration-categories-query'
37import { BASE_PATH, DOCS_URL } from '@/lib/constants'
38import type { NextPageWithLayout } from '@/types'
39
40const FEATURED_INTEGRATIONS = ['cron', 'queues', 'stripe_sync_engine']
41
42// Featured integration images
43const FEATURED_INTEGRATION_IMAGES: Record<string, string> = {
44 cron: `${BASE_PATH}/img/integrations/covers/cron-cover.webp`,
45 queues: `${BASE_PATH}/img/integrations/covers/queues-cover.png`,
46 stripe_wrapper: `${BASE_PATH}/img/integrations/covers/stripe-cover.png`,
47 stripe_sync_engine: `${BASE_PATH}/img/integrations/covers/stripe-cover.png`,
48}
49
50function getIntegrationImage(integration: IntegrationDefinition) {
51 let featured_image = FEATURED_INTEGRATION_IMAGES[integration.id]
52 if (featured_image) {
53 return featured_image
54 }
55
56 if (integration.files?.length) {
57 const heroImage = integration?.files?.[0]
58 return heroImage
59 }
60}
61
62type PageContent = {
63 title: string
64 subtitle: string
65 secondaryActions?: ReactNode
66}
67
68const DEFAULT_PAGE_CONTENT: PageContent = {
69 title: 'Extend your database',
70 subtitle:
71 'Extensions and wrappers that add functionality to your database and connect to external services.',
72}
73
74const CATEGORY_PAGE_CONTENT = {
75 wrapper: {
76 title: 'Wrappers',
77 subtitle:
78 'Connect to external data sources and services by querying APIs, databases, and files as if they were Postgres tables.',
79 secondaryActions: (
80 <DocsButton href={`${DOCS_URL}/guides/database/extensions/wrappers/overview`} />
81 ),
82 },
83 postgres_extension: {
84 title: 'Postgres Modules',
85 subtitle: 'Extend your database with powerful Postgres extensions.',
86 },
87} satisfies Record<string, PageContent>
88
89// Converts a category string to title
90// Example: some_catory -> Some Category
91function formatCategoryTitle(category: string) {
92 return category
93 .split(/[-_]/)
94 .filter(Boolean)
95 .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
96 .join(' ')
97}
98
99const PageHeaderContentSkeleton = () => (
100 <div className="flex flex-col gap-y-2">
101 <ShimmeringLoader className="h-8 w-48" />
102 <ShimmeringLoader className="h-4 w-full max-w-xl" />
103 </div>
104)
105
106// Returns selected category to filter by
107function useFilterCategory() {
108 const router = useRouter()
109 const [selectedCategory] = useQueryState(
110 'category',
111 parseAsString.withDefault('all').withOptions({ clearOnDefault: true })
112 )
113 const categoryFromUrl = useMemo(() => {
114 const queryString = router.asPath.split('?')[1]
115 if (!queryString) return null
116
117 return new URLSearchParams(queryString).get('category')
118 }, [router.asPath])
119 const resolvedSelectedCategory = router.isReady
120 ? (categoryFromUrl ?? selectedCategory)
121 : undefined
122 const filterCategory = resolvedSelectedCategory ?? 'all'
123 return filterCategory
124}
125
126// Dynamic page content based on selected category
127function usePageContent(
128 integrationFilterCategory: string,
129 categories: Database['public']['Views']['categories']['Row'][]
130) {
131 const pageContent = useMemo<PageContent>(() => {
132 if (integrationFilterCategory === 'all') {
133 return DEFAULT_PAGE_CONTENT
134 }
135
136 if (integrationFilterCategory in CATEGORY_PAGE_CONTENT) {
137 return CATEGORY_PAGE_CONTENT[integrationFilterCategory as keyof typeof CATEGORY_PAGE_CONTENT]
138 }
139
140 const selectedMarketplaceCategory = categories.find(
141 (category) => category.slug === integrationFilterCategory
142 )
143
144 return {
145 title: selectedMarketplaceCategory?.name ?? formatCategoryTitle(integrationFilterCategory),
146 subtitle: selectedMarketplaceCategory?.description ?? DEFAULT_PAGE_CONTENT.subtitle,
147 }
148 }, [categories, integrationFilterCategory])
149 return pageContent
150}
151
152// Filters all available integrations first by category,
153// then by the search term and sorts them first by
154// installation status and then alphabetically
155function useFilteredAndSortedIntegrations(
156 availableIntegrations: IntegrationDefinition[],
157 selectedCategory: string,
158 search: string,
159 installedIds: string[]
160) {
161 const filteredAndSortedIntegrations = useMemo(() => {
162 let filtered = availableIntegrations ?? []
163
164 if (selectedCategory !== 'all') {
165 filtered = filtered.filter(
166 (i) => i.type === selectedCategory || i.categories?.includes(selectedCategory)
167 )
168 }
169
170 if (search.length > 0) {
171 filtered = filtered.filter((i) => i.name.toLowerCase().includes(search.toLowerCase()))
172 }
173
174 // Sort by installation status, then alphabetically
175 return filtered.sort((a, b) => {
176 const aIsInstalled = installedIds.includes(a.id)
177 const bIsInstalled = installedIds.includes(b.id)
178
179 if (aIsInstalled && !bIsInstalled) return -1
180 if (!aIsInstalled && bIsInstalled) return 1
181
182 return a.name.localeCompare(b.name)
183 })
184 }, [availableIntegrations, selectedCategory, search, installedIds])
185
186 return filteredAndSortedIntegrations
187}
188
189// Returns featured integrations
190function useFeaturedIntegratios(
191 filteredAndSortedIntegrations: IntegrationDefinition[],
192 selectedCategory: string,
193 search: string
194) {
195 const groupedIntegrations = useMemo(() => {
196 if (selectedCategory !== 'all' || search.length > 0) {
197 return null
198 }
199
200 const featured = filteredAndSortedIntegrations.filter(
201 (integration) => FEATURED_INTEGRATIONS.includes(integration.id) || integration.featured
202 )
203
204 return featured
205 }, [filteredAndSortedIntegrations, selectedCategory, search])
206
207 return groupedIntegrations
208}
209
210const IntegrationsPage: NextPageWithLayout = () => {
211 const isMarketplaceEnabled = useIsMarketplaceEnabled()
212 if (isMarketplaceEnabled) return <MarketplaceIndex />
213 return <LegacyIntegrationsPage />
214}
215
216const LegacyIntegrationsPage = () => {
217 const { hasLoaded: flagsLoaded } = useFeatureFlags()
218 const isMarketplaceEnabled = useIsMarketplaceEnabled()
219 const [search, setSearch] = useQueryState(
220 'search',
221 parseAsString.withDefault('').withOptions({ clearOnDefault: true })
222 )
223
224 const {
225 data: availableIntegrations,
226 error,
227 isPending: isLoadingAvailableIntegrations,
228 isError,
229 isSuccess: isSuccessAvailableIntegrations,
230 } = useAvailableIntegrations()
231
232 const {
233 installedIntegrations,
234 isLoading: isLoadingInstalledIntegrations,
235 isSuccess: isSuccessInstalledIntegrations,
236 } = useInstalledIntegrations()
237
238 const installedIds = installedIntegrations.map((i) => i.id)
239 const isLoading = isLoadingAvailableIntegrations || isLoadingInstalledIntegrations
240 const isSuccess = isSuccessAvailableIntegrations && isSuccessInstalledIntegrations
241
242 const selectedCategory = useFilterCategory()
243
244 const { data: categories = [], isPending: isPendingCategories } = useQuery(
245 useMarketplaceCategoriesQuery({ enabled: isMarketplaceEnabled })
246 )
247
248 const isLoadingSelectedCategory =
249 selectedCategory !== 'all' &&
250 !(selectedCategory in CATEGORY_PAGE_CONTENT) &&
251 (IS_PLATFORM ? !flagsLoaded || (isMarketplaceEnabled && isPendingCategories) : false)
252
253 const pageContent = usePageContent(selectedCategory, categories)
254
255 const filteredAndSortedIntegrations = useFilteredAndSortedIntegrations(
256 availableIntegrations,
257 selectedCategory,
258 search,
259 installedIds
260 )
261
262 const featuredIntegrations = useFeaturedIntegratios(
263 filteredAndSortedIntegrations,
264 selectedCategory,
265 search
266 )
267
268 return (
269 <>
270 <PageHeader size="large">
271 <PageHeaderMeta>
272 <PageHeaderSummary>
273 {isLoadingSelectedCategory ? (
274 <PageHeaderContentSkeleton />
275 ) : (
276 <>
277 <PageHeaderTitle>{pageContent.title}</PageHeaderTitle>
278 <PageHeaderDescription>{pageContent.subtitle}</PageHeaderDescription>
279 </>
280 )}
281 </PageHeaderSummary>
282 {pageContent.secondaryActions && (
283 <PageHeaderAside>{pageContent.secondaryActions}</PageHeaderAside>
284 )}
285 </PageHeaderMeta>
286 </PageHeader>
287
288 <PageContainer size="large">
289 <PageSection>
290 <PageSectionMeta>
291 <Input
292 value={search}
293 size="tiny"
294 onChange={(e) => setSearch(e.target.value)}
295 placeholder="Search integrations..."
296 icon={<Search size={14} />}
297 className="w-52"
298 />
299 </PageSectionMeta>
300
301 <PageSectionContent>
302 {isLoading && (
303 <div
304 className="grid xl:grid-cols-3 2xl:grid-cols-4 gap-x-4 gap-y-3"
305 style={{ gridAutoRows: 'minmax(110px, auto)' }}
306 >
307 {Array.from({ length: 8 }).map((_, idx) => (
308 <IntegrationLoadingCard key={`integration-loading-${idx}`} />
309 ))}
310 </div>
311 )}
312
313 {/* Error State */}
314 {isError && (
315 <AlertError subject="Failed to retrieve available integrations" error={error} />
316 )}
317
318 {/* Success State */}
319 {isSuccess && (
320 <>
321 {/* No Search Results */}
322 {search.length > 0 && filteredAndSortedIntegrations.length === 0 && (
323 <NoSearchResults searchString={search} onResetFilter={() => setSearch('')} />
324 )}
325
326 {/* Featured Integrations */}
327 {featuredIntegrations && featuredIntegrations.length > 0 && (
328 <div
329 className="grid grid-cols-2 @4xl:grid-cols-3 gap-4 mb-4 items-stretch pb-6 border-b"
330 style={{ gridAutoRows: 'minmax(110px, auto)' }}
331 >
332 {featuredIntegrations.map((integration) => (
333 <IntegrationCard
334 key={integration.id}
335 {...integration}
336 isInstalled={installedIds.includes(integration.id)}
337 featured={true}
338 image={getIntegrationImage(integration)}
339 />
340 ))}
341 </div>
342 )}
343
344 {/* All Filtered and Sorted Integrations */}
345 {filteredAndSortedIntegrations.length > 0 && (
346 <div className="grid @xl:grid-cols-3 @6xl:grid-cols-4 gap-4">
347 {filteredAndSortedIntegrations.map((integration) => (
348 <IntegrationCard
349 key={integration.id}
350 {...integration}
351 isInstalled={installedIds.includes(integration.id)}
352 featured={false}
353 image={FEATURED_INTEGRATION_IMAGES[integration.id]}
354 />
355 ))}
356 </div>
357 )}
358 </>
359 )}
360 </PageSectionContent>
361 </PageSection>
362 </PageContainer>
363 </>
364 )
365}
366
367IntegrationsPage.getLayout = (page) => (
368 <DefaultLayout>
369 <ProjectIntegrationsLayoutDispatch>{page}</ProjectIntegrationsLayoutDispatch>
370 </DefaultLayout>
371)
372
373export default IntegrationsPage