index.tsx373 lines · main
| 1 | // @ts-nocheck |
| 2 | import { useQuery } from '@tanstack/react-query' |
| 3 | import { IS_PLATFORM, useFeatureFlags } from 'common' |
| 4 | import { Database } from 'common/marketplace.types' |
| 5 | import { Search } from 'lucide-react' |
| 6 | import { useRouter } from 'next/router' |
| 7 | import { parseAsString, useQueryState } from 'nuqs' |
| 8 | import { useMemo, type ReactNode } from 'react' |
| 9 | import { ShimmeringLoader } from 'ui-patterns' |
| 10 | import { Input } from 'ui-patterns/DataInputs/Input' |
| 11 | import { PageContainer } from 'ui-patterns/PageContainer' |
| 12 | import { |
| 13 | PageHeader, |
| 14 | PageHeaderAside, |
| 15 | PageHeaderDescription, |
| 16 | PageHeaderMeta, |
| 17 | PageHeaderSummary, |
| 18 | PageHeaderTitle, |
| 19 | } from 'ui-patterns/PageHeader' |
| 20 | import { PageSection, PageSectionContent, PageSectionMeta } from 'ui-patterns/PageSection' |
| 21 | |
| 22 | import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' |
| 23 | import { |
| 24 | IntegrationCard, |
| 25 | IntegrationLoadingCard, |
| 26 | } from '@/components/interfaces/Integrations/Landing/IntegrationCard' |
| 27 | import { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants' |
| 28 | import { useAvailableIntegrations } from '@/components/interfaces/Integrations/Landing/useAvailableIntegrations' |
| 29 | import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations' |
| 30 | import { MarketplaceIndex } from '@/components/interfaces/Integrations/Marketplace/MarketplaceIndex' |
| 31 | import { DefaultLayout } from '@/components/layouts/DefaultLayout' |
| 32 | import { ProjectIntegrationsLayoutDispatch } from '@/components/layouts/ProjectIntegrationsLayoutDispatch' |
| 33 | import { AlertError } from '@/components/ui/AlertError' |
| 34 | import { DocsButton } from '@/components/ui/DocsButton' |
| 35 | import { NoSearchResults } from '@/components/ui/NoSearchResults' |
| 36 | import { useMarketplaceCategoriesQuery } from '@/data/marketplace/integration-categories-query' |
| 37 | import { BASE_PATH, DOCS_URL } from '@/lib/constants' |
| 38 | import type { NextPageWithLayout } from '@/types' |
| 39 | |
| 40 | const FEATURED_INTEGRATIONS = ['cron', 'queues', 'stripe_sync_engine'] |
| 41 | |
| 42 | // Featured integration images |
| 43 | const 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 | |
| 50 | function 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 | |
| 62 | type PageContent = { |
| 63 | title: string |
| 64 | subtitle: string |
| 65 | secondaryActions?: ReactNode |
| 66 | } |
| 67 | |
| 68 | const 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 | |
| 74 | const 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 |
| 91 | function 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 | |
| 99 | const 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 |
| 107 | function 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 |
| 127 | function 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 |
| 155 | function 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 |
| 190 | function 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 | |
| 210 | const IntegrationsPage: NextPageWithLayout = () => { |
| 211 | const isMarketplaceEnabled = useIsMarketplaceEnabled() |
| 212 | if (isMarketplaceEnabled) return <MarketplaceIndex /> |
| 213 | return <LegacyIntegrationsPage /> |
| 214 | } |
| 215 | |
| 216 | const 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 | |
| 367 | IntegrationsPage.getLayout = (page) => ( |
| 368 | <DefaultLayout> |
| 369 | <ProjectIntegrationsLayoutDispatch>{page}</ProjectIntegrationsLayoutDispatch> |
| 370 | </DefaultLayout> |
| 371 | ) |
| 372 | |
| 373 | export default IntegrationsPage |