MarketplaceIndex.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. // @ts-nocheck
  2. import { useQuery } from '@tanstack/react-query'
  3. import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs'
  4. import { useMemo } from 'react'
  5. import { Button, Card, ShadowScrollArea, Table, TableBody, TableHeader } from 'ui'
  6. import {
  7. EmptyStatePresentational,
  8. PageContainer,
  9. PageHeader,
  10. PageHeaderDescription,
  11. PageHeaderMeta,
  12. PageHeaderSummary,
  13. PageHeaderTitle,
  14. } from 'ui-patterns'
  15. import {
  16. EXCLUDED_CATEGORY_SLUGS,
  17. FEATURED_INTEGRATION_IDS,
  18. formatCategoryLabel,
  19. getMarketplaceSource,
  20. getMarketplaceType,
  21. INTEGRATION_TYPES,
  22. MARKETPLACE_SOURCES,
  23. type MarketplaceIntegrationType,
  24. type MarketplaceSource,
  25. } from './Marketplace.constants'
  26. import { MarketplaceCard } from './MarketplaceCard'
  27. import { MarketplaceFeaturedHero } from './MarketplaceFeaturedHero'
  28. import { MarketplaceFilterBar, type ViewMode } from './MarketplaceFilterBar'
  29. import { MarketplaceListHeader, MarketplaceListRow } from './MarketplaceListRow'
  30. import { IntegrationLoadingCard } from '@/components/interfaces/Integrations/Landing/IntegrationCard'
  31. import { useAvailableIntegrations } from '@/components/interfaces/Integrations/Landing/useAvailableIntegrations'
  32. import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations'
  33. import { AlertError } from '@/components/ui/AlertError'
  34. import { DocsButton } from '@/components/ui/DocsButton'
  35. import { useMarketplaceCategoriesQuery } from '@/data/marketplace/integration-categories-query'
  36. import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
  37. import { DOCS_URL } from '@/lib/constants'
  38. const MARKETPLACE_VIEW_MODE_STORAGE_KEY = 'briven.marketplace.viewMode'
  39. export const MarketplaceIndex = () => {
  40. const [search, setSearch] = useQueryState(
  41. 'search',
  42. parseAsString.withDefault('').withOptions({ clearOnDefault: true })
  43. )
  44. const [category, setCategory] = useQueryState(
  45. 'category',
  46. parseAsString.withOptions({ clearOnDefault: true })
  47. )
  48. const [type, setType] = useQueryState(
  49. 'type',
  50. parseAsStringEnum<MarketplaceIntegrationType>([
  51. 'oauth',
  52. 'postgres_extension',
  53. 'template',
  54. 'wrapper',
  55. ]).withOptions({ clearOnDefault: true })
  56. )
  57. const [source, setSource] = useQueryState(
  58. 'source',
  59. parseAsStringEnum<MarketplaceSource>(['Official', 'Partner', 'Community']).withOptions({
  60. clearOnDefault: true,
  61. })
  62. )
  63. const [viewMode, setViewMode] = useLocalStorageQuery<ViewMode>(
  64. MARKETPLACE_VIEW_MODE_STORAGE_KEY,
  65. 'grid'
  66. )
  67. const {
  68. data: availableIntegrations,
  69. error,
  70. isPending: isLoadingAvailable,
  71. isError,
  72. isSuccess: isSuccessAvailable,
  73. } = useAvailableIntegrations()
  74. const {
  75. installedIntegrations,
  76. isLoading: isLoadingInstalled,
  77. isSuccess: isSuccessInstalled,
  78. } = useInstalledIntegrations()
  79. const installedIds = installedIntegrations.map((i) => i.id)
  80. const isLoading = isLoadingAvailable || isLoadingInstalled
  81. const isSuccess = isSuccessAvailable && isSuccessInstalled
  82. const { data: marketplaceCategories = [] } = useQuery(useMarketplaceCategoriesQuery())
  83. const categoryOptions = useMemo(
  84. () =>
  85. marketplaceCategories
  86. .filter(
  87. (c): c is { slug: string; name: string } & typeof c =>
  88. !!c.slug && !!c.name && !EXCLUDED_CATEGORY_SLUGS.has(c.slug)
  89. )
  90. .map((c) => ({ slug: c.slug, name: c.name })),
  91. [marketplaceCategories]
  92. )
  93. const typeCounts = useMemo(() => {
  94. const all = availableIntegrations ?? []
  95. return Object.fromEntries(
  96. INTEGRATION_TYPES.map(({ key }) => [
  97. key,
  98. all.filter((i) => getMarketplaceType(i) === key).length,
  99. ])
  100. ) as Record<MarketplaceIntegrationType, number>
  101. }, [availableIntegrations])
  102. const sourceCounts = useMemo(() => {
  103. const all = availableIntegrations ?? []
  104. return Object.fromEntries(
  105. MARKETPLACE_SOURCES.map(({ key }) => [
  106. key,
  107. all.filter((i) => getMarketplaceSource(i) === key).length,
  108. ])
  109. ) as Record<MarketplaceSource, number>
  110. }, [availableIntegrations])
  111. const categoryCounts = useMemo(() => {
  112. const all = availableIntegrations ?? []
  113. const counts: Record<string, number> = {}
  114. for (const { slug } of categoryOptions) {
  115. counts[slug] = all.filter((i) => i.categories?.includes(slug)).length
  116. }
  117. return counts
  118. }, [availableIntegrations, categoryOptions])
  119. const hasActiveFilter = !!(category || type || source)
  120. const hasSearchOrFilter = hasActiveFilter || search.length > 0
  121. const filtered = useMemo(() => {
  122. let result = availableIntegrations ?? []
  123. if (category) {
  124. result = result.filter((i) => i.categories?.includes(category))
  125. }
  126. if (type) {
  127. result = result.filter((i) => getMarketplaceType(i) === type)
  128. }
  129. if (source) {
  130. result = result.filter((i) => getMarketplaceSource(i) === source)
  131. }
  132. if (search.length > 0) {
  133. const needle = search.toLowerCase()
  134. result = result.filter((i) => i.name.toLowerCase().includes(needle))
  135. }
  136. return [...result].sort((a, b) => {
  137. const aInstalled = installedIds.includes(a.id)
  138. const bInstalled = installedIds.includes(b.id)
  139. if (aInstalled && !bInstalled) return -1
  140. if (!aInstalled && bInstalled) return 1
  141. return a.name.localeCompare(b.name)
  142. })
  143. }, [availableIntegrations, category, type, source, search, installedIds])
  144. const featured = useMemo(() => {
  145. if (hasSearchOrFilter) return []
  146. const byId = new Map((availableIntegrations ?? []).map((i) => [i.id, i]))
  147. return FEATURED_INTEGRATION_IDS.map((id) => byId.get(id)).filter(
  148. (i): i is NonNullable<typeof i> => !!i
  149. )
  150. }, [availableIntegrations, hasSearchOrFilter])
  151. const clearAll = () => {
  152. setCategory(null)
  153. setType(null)
  154. setSource(null)
  155. setSearch('')
  156. }
  157. const activeFilters = [category, type, source].filter(Boolean)
  158. const pageTitle = useMemo(() => {
  159. if (activeFilters.length !== 1) return 'Integrations Marketplace'
  160. if (category)
  161. return `Integrations Marketplace: ${formatCategoryLabel(category, categoryOptions)}`
  162. if (type)
  163. return `Integrations Marketplace: ${INTEGRATION_TYPES.find((t) => t.key === type)?.label ?? type}s`
  164. if (source)
  165. return `Integrations Marketplace: ${MARKETPLACE_SOURCES.find((s) => s.key === source)?.label ?? source}`
  166. return 'Integrations Marketplace'
  167. }, [activeFilters.length, category, type, source, categoryOptions])
  168. return (
  169. <>
  170. <PageHeader size="large">
  171. <PageHeaderMeta>
  172. <PageHeaderSummary>
  173. <PageHeaderTitle>{pageTitle}</PageHeaderTitle>
  174. <PageHeaderDescription>
  175. Explore native and third-party integrations to add functionality to your Briven
  176. project.
  177. </PageHeaderDescription>
  178. </PageHeaderSummary>
  179. <div className="flex shrink-0 items-center gap-2">
  180. <DocsButton href={`${DOCS_URL}/guides/integrations/briven-marketplace`} />
  181. </div>
  182. </PageHeaderMeta>
  183. </PageHeader>
  184. <PageContainer size="large" className="flex flex-col gap-4 py-6">
  185. {isLoading && (
  186. <div className="grid gap-4 xl:grid-cols-3 2xl:grid-cols-4">
  187. {Array.from({ length: 8 }).map((_, idx) => (
  188. <IntegrationLoadingCard key={`marketplace-loading-${idx}`} />
  189. ))}
  190. </div>
  191. )}
  192. {isError && (
  193. <AlertError subject="Failed to retrieve available integrations" error={error} />
  194. )}
  195. {isSuccess && (
  196. <>
  197. {featured.length > 0 && (
  198. <MarketplaceFeaturedHero
  199. integrations={featured}
  200. installedIds={installedIds}
  201. categoryOptions={categoryOptions}
  202. />
  203. )}
  204. <MarketplaceFilterBar
  205. resultCount={filtered.length}
  206. search={search}
  207. onSearchChange={(v) => setSearch(v)}
  208. category={category}
  209. onCategoryChange={(v) => setCategory(v)}
  210. categoryOptions={categoryOptions}
  211. categoryCounts={categoryCounts}
  212. type={type}
  213. onTypeChange={(v) => setType(v)}
  214. typeCounts={typeCounts}
  215. source={source}
  216. onSourceChange={(v) => setSource(v)}
  217. sourceCounts={sourceCounts}
  218. viewMode={viewMode}
  219. onViewModeChange={(v) => setViewMode(v)}
  220. hasActiveFilter={hasActiveFilter}
  221. onClearFilters={clearAll}
  222. />
  223. {filtered.length === 0 && (
  224. <EmptyStatePresentational title="No results found">
  225. <Button type="default" onClick={clearAll}>
  226. Clear filters
  227. </Button>
  228. </EmptyStatePresentational>
  229. )}
  230. {filtered.length > 0 &&
  231. (viewMode === 'grid' ? (
  232. <div className="grid @lg:grid-cols-2 gap-3 @4xl:grid-cols-3">
  233. {filtered.map((integration) => (
  234. <MarketplaceCard
  235. key={integration.id}
  236. integration={integration}
  237. isInstalled={installedIds.includes(integration.id)}
  238. />
  239. ))}
  240. </div>
  241. ) : (
  242. <Card>
  243. <ShadowScrollArea>
  244. <Table>
  245. <TableHeader>
  246. <MarketplaceListHeader />
  247. </TableHeader>
  248. <TableBody>
  249. {filtered.map((integration) => (
  250. <MarketplaceListRow
  251. key={integration.id}
  252. integration={integration}
  253. isInstalled={installedIds.includes(integration.id)}
  254. />
  255. ))}
  256. </TableBody>
  257. </Table>
  258. </ShadowScrollArea>
  259. </Card>
  260. ))}
  261. </>
  262. )}
  263. </PageContainer>
  264. </>
  265. )
  266. }